karajan-code 1.55.0 → 1.57.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 +8 -0
- package/docs/README.es.md +8 -0
- package/package.json +1 -1
- package/src/cli.js +15 -0
- package/src/commands/init.js +41 -0
- package/src/commands/status.js +35 -0
- package/src/config.js +1 -0
- package/src/mcp/error-classifiers.js +66 -0
- package/src/mcp/handlers/direct-handlers.js +1 -1
- package/src/mcp/handlers/hu-handlers.js +1 -1
- package/src/mcp/handlers/management-handlers.js +23 -2
- package/src/mcp/handlers/run-handler.js +6 -4
- package/src/mcp/orphan-guard.js +41 -4
- package/src/mcp/server-handlers.js +44 -229
- package/src/mcp/server.js +8 -1
- package/src/mcp/shared-helpers.js +144 -0
- package/src/orchestrator.js +14 -0
- package/src/utils/stack-detect.js +135 -0
- package/src/utils/status-dashboard.js +254 -0
- package/src/utils/telemetry.js +47 -0
package/README.md
CHANGED
|
@@ -264,6 +264,14 @@ npm run validate # Lint + test
|
|
|
264
264
|
|
|
265
265
|
Issues and pull requests welcome. If something doesn't work as documented, [open an issue](https://github.com/manufosela/karajan-code/issues). That's the most useful contribution at this stage.
|
|
266
266
|
|
|
267
|
+
## Telemetry
|
|
268
|
+
|
|
269
|
+
Karajan collects anonymous usage statistics to improve the tool:
|
|
270
|
+
version, OS, command used, pipeline duration and success rate.
|
|
271
|
+
No code, task descriptions, or personal data is ever sent.
|
|
272
|
+
|
|
273
|
+
Opt out: set `telemetry: false` in `~/.karajan/kj.config.yml`
|
|
274
|
+
|
|
267
275
|
## Links
|
|
268
276
|
|
|
269
277
|
- [Website](https://karajancode.com) (also [kj-code.com](https://kj-code.com))
|
package/docs/README.es.md
CHANGED
|
@@ -237,6 +237,14 @@ npm run validate # Lint + test
|
|
|
237
237
|
|
|
238
238
|
Issues y pull requests bienvenidos. Si algo no funciona como esta documentado, [abre un issue](https://github.com/manufosela/karajan-code/issues). Es la contribucion mas util en esta fase.
|
|
239
239
|
|
|
240
|
+
## Telemetria
|
|
241
|
+
|
|
242
|
+
Karajan recopila estadisticas de uso anonimas para mejorar la herramienta:
|
|
243
|
+
version, sistema operativo, comando utilizado, duracion del pipeline y tasa de exito.
|
|
244
|
+
No se envia nunca codigo, descripciones de tareas ni datos personales.
|
|
245
|
+
|
|
246
|
+
Desactivar: establece `telemetry: false` en `~/.karajan/kj.config.yml`
|
|
247
|
+
|
|
240
248
|
## Enlaces
|
|
241
249
|
|
|
242
250
|
- [Web](https://karajancode.com) (tambien [kj-code.com](https://kj-code.com))
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -25,6 +25,7 @@ import { architectCommand } from "./commands/architect.js";
|
|
|
25
25
|
import { auditCommand } from "./commands/audit.js";
|
|
26
26
|
import { boardCommand } from "./commands/board.js";
|
|
27
27
|
import { undoCommand } from "./commands/undo.js";
|
|
28
|
+
import { statusCommand } from "./commands/status.js";
|
|
28
29
|
|
|
29
30
|
import { printUpdateNotice } from "./utils/update-check.js";
|
|
30
31
|
|
|
@@ -39,6 +40,12 @@ async function withConfig(commandName, flags, fn) {
|
|
|
39
40
|
const merged = applyRunOverrides(config, flags || {});
|
|
40
41
|
validateConfig(merged, commandName);
|
|
41
42
|
const logger = createLogger(merged.output.log_level);
|
|
43
|
+
|
|
44
|
+
// Telemetry: anonymous cli_command event (non-blocking)
|
|
45
|
+
import("./utils/telemetry.js")
|
|
46
|
+
.then(({ sendTelemetryEvent }) => sendTelemetryEvent("cli_command", { command: commandName, version: PKG_VERSION }, merged))
|
|
47
|
+
.catch(() => {});
|
|
48
|
+
|
|
42
49
|
await fn({ config: merged, logger, flags });
|
|
43
50
|
}
|
|
44
51
|
|
|
@@ -168,6 +175,14 @@ program
|
|
|
168
175
|
await withConfig("doctor", {}, doctorCommand);
|
|
169
176
|
});
|
|
170
177
|
|
|
178
|
+
program
|
|
179
|
+
.command("status")
|
|
180
|
+
.description("Show pipeline dashboard with HU progress")
|
|
181
|
+
.option("-n, --lines <n>", "Max log lines to read", "50")
|
|
182
|
+
.action(async (flags) => {
|
|
183
|
+
await statusCommand({ lines: Number(flags.lines) });
|
|
184
|
+
});
|
|
185
|
+
|
|
171
186
|
program
|
|
172
187
|
.command("report")
|
|
173
188
|
.description("Show latest session report")
|
package/src/commands/init.js
CHANGED
|
@@ -12,6 +12,7 @@ import { getInstallCommand } from "../utils/os-detect.js";
|
|
|
12
12
|
import { detectOsLocale, SUPPORTED_LANGUAGES } from "../utils/locale.js";
|
|
13
13
|
import { detectRtk } from "../utils/rtk-detect.js";
|
|
14
14
|
import { installRtk } from "../utils/rtk-install.js";
|
|
15
|
+
import { detectProjectStack } from "../utils/stack-detect.js";
|
|
15
16
|
|
|
16
17
|
async function runWizard(config, logger) {
|
|
17
18
|
const agents = await detectAvailableAgents();
|
|
@@ -330,6 +331,35 @@ export async function initCommand({ logger, flags = {} }) {
|
|
|
330
331
|
await ensureCoderRules(coderRulesPath, logger);
|
|
331
332
|
await installSkills(logger, interactive);
|
|
332
333
|
|
|
334
|
+
// Auto-detect project stack and preconfigure
|
|
335
|
+
const stack = await detectProjectStack(process.cwd());
|
|
336
|
+
if (stack.frameworks.length > 0 || stack.language) {
|
|
337
|
+
const parts = [];
|
|
338
|
+
if (stack.frameworks.length > 0) parts.push(stack.frameworks.join(" + "));
|
|
339
|
+
if (stack.language) parts.push(stack.language);
|
|
340
|
+
const stackType = stack.isFullstack ? "fullstack" : stack.isFrontend ? "frontend" : stack.isBackend ? "backend" : "unknown";
|
|
341
|
+
logger.info(`Detected stack: ${parts.join(" / ")} (${stackType})`);
|
|
342
|
+
|
|
343
|
+
if (stack.suggestions.impeccable) {
|
|
344
|
+
config.impeccable = config.impeccable || {};
|
|
345
|
+
config.impeccable.enabled = true;
|
|
346
|
+
logger.info(" -> Impeccable design audit enabled (frontend detected)");
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (stack.suggestions.skills.length > 0) {
|
|
350
|
+
logger.info(` -> Suggested skills: ${stack.suggestions.skills.join(", ")}`);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (stack.language && !configExists) {
|
|
354
|
+
config.development = config.development || {};
|
|
355
|
+
config.development.methodology = config.development.methodology || "tdd";
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
await writeConfig(configPath, config);
|
|
359
|
+
} else {
|
|
360
|
+
logger.info("No project stack detected — using default configuration.");
|
|
361
|
+
}
|
|
362
|
+
|
|
333
363
|
// Check RTK availability — auto-install if missing
|
|
334
364
|
const rtk = await detectRtk();
|
|
335
365
|
if (rtk.available) {
|
|
@@ -344,4 +374,15 @@ export async function initCommand({ logger, flags = {} }) {
|
|
|
344
374
|
|
|
345
375
|
await setupSonarQube(config, logger);
|
|
346
376
|
await scaffoldBecariaGateway(config, flags, logger);
|
|
377
|
+
|
|
378
|
+
// Telemetry: anonymous install event (non-blocking)
|
|
379
|
+
const { sendTelemetryEvent } = await import("../utils/telemetry.js");
|
|
380
|
+
const { readFileSync } = await import("node:fs");
|
|
381
|
+
const { resolve, dirname } = await import("node:path");
|
|
382
|
+
const { fileURLToPath } = await import("node:url");
|
|
383
|
+
try {
|
|
384
|
+
const pkgPath = resolve(dirname(fileURLToPath(import.meta.url)), "../../package.json");
|
|
385
|
+
const version = JSON.parse(readFileSync(pkgPath, "utf8")).version;
|
|
386
|
+
sendTelemetryEvent("install", { version }, config).catch(() => {});
|
|
387
|
+
} catch { /* non-blocking */ }
|
|
347
388
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI status command — shows a terminal dashboard of the current pipeline state.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { readRunLog } from "../utils/run-log.js";
|
|
6
|
+
import { loadMostRecentSession } from "../session-store.js";
|
|
7
|
+
import { buildDashboard } from "../utils/status-dashboard.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Run the `kj status` command: print a human-readable dashboard to stdout.
|
|
11
|
+
* @param {object} options
|
|
12
|
+
* @param {number} [options.lines=50] - Max log lines to read.
|
|
13
|
+
* @param {string} [options.projectDir] - Project directory override.
|
|
14
|
+
*/
|
|
15
|
+
export async function statusCommand({ lines = 50, projectDir } = {}) {
|
|
16
|
+
const dir = projectDir || process.cwd();
|
|
17
|
+
const logResult = readRunLog(dir, lines);
|
|
18
|
+
|
|
19
|
+
let session = null;
|
|
20
|
+
try {
|
|
21
|
+
session = await loadMostRecentSession();
|
|
22
|
+
} catch { /* no session available */ }
|
|
23
|
+
|
|
24
|
+
let stories = [];
|
|
25
|
+
if (session) {
|
|
26
|
+
try {
|
|
27
|
+
const { loadHuBatch } = await import("../hu/store.js");
|
|
28
|
+
const batch = await loadHuBatch(session.id);
|
|
29
|
+
if (batch?.stories) stories = batch.stories;
|
|
30
|
+
} catch { /* no HU batch */ }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const dashboard = buildDashboard(session, logResult.lines || [], { stories });
|
|
34
|
+
console.log(dashboard);
|
|
35
|
+
}
|
package/src/config.js
CHANGED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error classification for MCP tool responses.
|
|
3
|
+
* Extracted to its own module to avoid circular import TDZ issues
|
|
4
|
+
* (server-handlers.js ↔ handlers/*.js circular chain).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { withDocLink } from "../utils/doc-links.js";
|
|
8
|
+
|
|
9
|
+
const ERROR_CLASSIFIERS = [
|
|
10
|
+
{
|
|
11
|
+
test: (lower) => lower.includes("without output") || lower.includes("silent for") || lower.includes("unresponsive") || lower.includes("exceeded max silence"),
|
|
12
|
+
category: "agent_stall",
|
|
13
|
+
suggestion: "Agent output stalled. Check live details with kj_status, then retry with a smaller prompt or increase session.max_agent_silence_minutes if needed."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
test: (lower) => lower.includes("sonar") && (lower.includes("connect") || lower.includes("econnrefused") || lower.includes("not available") || lower.includes("not running")),
|
|
17
|
+
category: "sonar_unavailable",
|
|
18
|
+
suggestion: withDocLink("SonarQube is not reachable. Try: kj_init to set up SonarQube, or run 'docker start sonarqube' if already installed. Use --no-sonar to skip SonarQube.", "sonar_docker")
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
test: (lower) => lower.includes("401") || lower.includes("unauthorized") || lower.includes("invalid token"),
|
|
22
|
+
category: "auth_error",
|
|
23
|
+
suggestion: withDocLink("Authentication failed. Regenerate the SonarQube token and update it via kj_init or in ~/.karajan/kj.config.yml under sonarqube.token.", "sonar_token")
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
test: (lower) => lower.includes("config") && (lower.includes("missing") || lower.includes("not found") || lower.includes("invalid")),
|
|
27
|
+
category: "config_error",
|
|
28
|
+
suggestion: withDocLink("Configuration issue detected. Run kj_doctor to diagnose, or kj_init to create a fresh config.", "config_missing")
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
test: (lower) => lower.includes("missing provider") || lower.includes("not found") && (lower.includes("claude") || lower.includes("codex") || lower.includes("gemini") || lower.includes("aider")),
|
|
32
|
+
category: "agent_missing",
|
|
33
|
+
suggestion: withDocLink("Required agent CLI not found. Run kj_doctor to check which agents are installed and get installation instructions.", "agent_not_found")
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
test: (lower) => lower.includes("timed out") || lower.includes("timeout"),
|
|
37
|
+
category: "timeout",
|
|
38
|
+
suggestion: "The agent did not complete in time. Try: (1) increase --max-iteration-minutes (default: 5), (2) split the task into smaller pieces, (3) use kj_code for single-agent tasks. If a SonarQube scan timed out, check Docker health."
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
test: (lower) => lower.includes("you are on the base branch"),
|
|
42
|
+
category: "branch_error",
|
|
43
|
+
suggestion: withDocLink("Create a feature branch before running Karajan. Use 'git checkout -b feat/<task-description>' and then retry. Do NOT run kj_code directly on the base branch.", "branch_error")
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
test: (lower) => lower.includes("not a git repository"),
|
|
47
|
+
category: "git_error",
|
|
48
|
+
suggestion: "Current directory is not a git repository. Navigate to your project root or initialize git with 'git init'."
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
test: (lower) => lower.includes("bootstrap failed"),
|
|
52
|
+
category: "bootstrap_error",
|
|
53
|
+
suggestion: withDocLink("Environment prerequisites not met. Run kj_doctor for diagnostics, then fix the issues listed. Do NOT work around these — fix them properly.", "bootstrap_failed")
|
|
54
|
+
}
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
export function classifyError(error) {
|
|
58
|
+
const msg = error?.message || String(error);
|
|
59
|
+
const lower = msg.toLowerCase();
|
|
60
|
+
|
|
61
|
+
const match = ERROR_CLASSIFIERS.find(c => c.test(lower));
|
|
62
|
+
if (match) {
|
|
63
|
+
return { category: match.category, suggestion: match.suggestion };
|
|
64
|
+
}
|
|
65
|
+
return { category: "unknown", suggestion: null };
|
|
66
|
+
}
|
|
@@ -13,7 +13,9 @@ import {
|
|
|
13
13
|
failPayload,
|
|
14
14
|
responseText,
|
|
15
15
|
buildConfig,
|
|
16
|
-
} from "../
|
|
16
|
+
} from "../shared-helpers.js";
|
|
17
|
+
import { buildDashboardJson } from "../../utils/status-dashboard.js";
|
|
18
|
+
import { loadMostRecentSession } from "../../session-store.js";
|
|
17
19
|
|
|
18
20
|
const AGENT_ROLES = new Set(["coder", "reviewer", "tester", "security", "solomon"]);
|
|
19
21
|
|
|
@@ -29,7 +31,26 @@ async function runBootstrapGate(server, a) {
|
|
|
29
31
|
export async function handleStatus(a, server) {
|
|
30
32
|
const maxLines = a.lines || 50;
|
|
31
33
|
const projectDir = await resolveProjectDir(server, a.projectDir);
|
|
32
|
-
|
|
34
|
+
const logResult = readRunLog(projectDir, maxLines);
|
|
35
|
+
|
|
36
|
+
// Enrich with dashboard data when possible
|
|
37
|
+
try {
|
|
38
|
+
const session = await loadMostRecentSession();
|
|
39
|
+
const stories = [];
|
|
40
|
+
if (session) {
|
|
41
|
+
try {
|
|
42
|
+
const { loadHuBatch } = await import("../../hu/store.js");
|
|
43
|
+
const batch = await loadHuBatch(session.id);
|
|
44
|
+
if (batch?.stories) stories.push(...batch.stories);
|
|
45
|
+
} catch { /* no HU batch for this session */ }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const dashboard = buildDashboardJson(session, logResult.lines || [], { stories });
|
|
49
|
+
return { ...logResult, dashboard };
|
|
50
|
+
} catch {
|
|
51
|
+
// Fallback: return raw log without dashboard
|
|
52
|
+
return logResult;
|
|
53
|
+
}
|
|
33
54
|
}
|
|
34
55
|
|
|
35
56
|
export async function handleAgents(a) {
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
buildConfig,
|
|
22
22
|
buildAskQuestion,
|
|
23
23
|
enrichedFailPayload,
|
|
24
|
-
} from "../
|
|
24
|
+
} from "../shared-helpers.js";
|
|
25
25
|
|
|
26
26
|
const MAX_AUTO_RESUMES = 2;
|
|
27
27
|
const NON_RECOVERABLE_CATEGORIES = new Set([
|
|
@@ -154,8 +154,10 @@ export async function handleRunDirect(a, server, extra) {
|
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
export async function handleResumeDirect(a, server, extra) {
|
|
157
|
-
|
|
158
|
-
|
|
157
|
+
// Use a minimal config only for the logger; let resumeFlow use the session's
|
|
158
|
+
// config_snapshot so that flags like --no-sonar from the original run are preserved.
|
|
159
|
+
const freshConfig = await buildConfig(a);
|
|
160
|
+
const logger = createLogger(freshConfig.output.log_level, "mcp");
|
|
159
161
|
|
|
160
162
|
const projectDir = await resolveProjectDir(server, a.projectDir);
|
|
161
163
|
const runLog = createRunLog(projectDir);
|
|
@@ -172,7 +174,7 @@ export async function handleResumeDirect(a, server, extra) {
|
|
|
172
174
|
const result = await resumeFlow({
|
|
173
175
|
sessionId: a.sessionId,
|
|
174
176
|
answer: a.answer || null,
|
|
175
|
-
config,
|
|
177
|
+
config: null, // Use session.config_snapshot (preserves --no-sonar, etc.)
|
|
176
178
|
logger,
|
|
177
179
|
flags: a,
|
|
178
180
|
emitter,
|
package/src/mcp/orphan-guard.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import { readFileSync, watch } from "node:fs";
|
|
1
|
+
import { readFileSync, writeFileSync, unlinkSync, watch, mkdirSync, existsSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
|
|
5
|
+
const RESTART_MARKER_DIR = path.join(os.homedir(), ".kj");
|
|
6
|
+
const RESTART_MARKER_PATH = path.join(RESTART_MARKER_DIR, ".mcp-restart");
|
|
2
7
|
|
|
3
8
|
const DEFAULT_INTERVAL_MS = 5000;
|
|
4
9
|
|
|
@@ -69,14 +74,46 @@ export function setupMemoryWatchdog({
|
|
|
69
74
|
return { timer };
|
|
70
75
|
}
|
|
71
76
|
|
|
72
|
-
|
|
77
|
+
const DEFAULT_GRACE_MS = 2000;
|
|
78
|
+
|
|
79
|
+
export function writeRestartMarker(version, markerPath = RESTART_MARKER_PATH) {
|
|
80
|
+
try {
|
|
81
|
+
mkdirSync(path.dirname(markerPath), { recursive: true });
|
|
82
|
+
writeFileSync(markerPath, version, "utf8");
|
|
83
|
+
} catch { /* best-effort */ }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function readAndDeleteRestartMarker(markerPath = RESTART_MARKER_PATH) {
|
|
87
|
+
try {
|
|
88
|
+
if (!existsSync(markerPath)) return null;
|
|
89
|
+
const version = readFileSync(markerPath, "utf8").trim();
|
|
90
|
+
unlinkSync(markerPath);
|
|
91
|
+
return version || null;
|
|
92
|
+
} catch { /* ignore */ }
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function setupVersionWatcher({
|
|
97
|
+
pkgPath,
|
|
98
|
+
currentVersion,
|
|
99
|
+
gracePeriodMs = DEFAULT_GRACE_MS,
|
|
100
|
+
exitFn = () => process.exit(0),
|
|
101
|
+
markerPath = RESTART_MARKER_PATH
|
|
102
|
+
} = {}) {
|
|
73
103
|
if (!pkgPath) return null;
|
|
74
104
|
|
|
105
|
+
let _gracePeriodMs = gracePeriodMs;
|
|
106
|
+
|
|
75
107
|
function checkVersion() {
|
|
76
108
|
try {
|
|
77
109
|
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
78
110
|
if (pkg.version !== currentVersion) {
|
|
79
|
-
|
|
111
|
+
const newVersion = pkg.version;
|
|
112
|
+
process.stderr.write(
|
|
113
|
+
`[karajan-mcp] Karajan MCP updated (v${currentVersion} → v${newVersion}). Restarting...\n`
|
|
114
|
+
);
|
|
115
|
+
writeRestartMarker(newVersion, markerPath);
|
|
116
|
+
setTimeout(() => exitFn(), _gracePeriodMs);
|
|
80
117
|
return true;
|
|
81
118
|
}
|
|
82
119
|
} catch { /* ignore read errors */ }
|
|
@@ -90,5 +127,5 @@ export function setupVersionWatcher({ pkgPath, currentVersion, exitFn = () => pr
|
|
|
90
127
|
});
|
|
91
128
|
} catch { /* ignore watch errors */ }
|
|
92
129
|
|
|
93
|
-
return { watcher, checkVersion };
|
|
130
|
+
return { watcher, checkVersion, get gracePeriodMs() { return _gracePeriodMs; }, set gracePeriodMs(v) { _gracePeriodMs = v; } };
|
|
94
131
|
}
|
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MCP server handler logic.
|
|
3
|
-
* Shared helpers
|
|
4
|
-
*
|
|
3
|
+
* Shared helpers live in ./shared-helpers.js (to avoid circular imports).
|
|
4
|
+
* Handler implementations are in ./handlers/*.
|
|
5
|
+
* This module re-exports everything so existing imports continue to work.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
8
|
+
// ── Shared helpers (re-exported from shared-helpers.js) ──────────────
|
|
9
|
+
export {
|
|
10
|
+
resolveProjectDir,
|
|
11
|
+
asObject,
|
|
12
|
+
responseText,
|
|
13
|
+
failPayload,
|
|
14
|
+
assertNotOnBaseBranch,
|
|
15
|
+
enrichedFailPayload,
|
|
16
|
+
buildConfig,
|
|
17
|
+
buildAskQuestion,
|
|
18
|
+
buildDirectEmitter,
|
|
19
|
+
classifyError
|
|
20
|
+
} from "./shared-helpers.js";
|
|
14
21
|
|
|
15
22
|
// ── Sub-module re-exports ────────────────────────────────────────────
|
|
16
23
|
export { handleRunDirect, handleResumeDirect, validateResumeAnswer, handleRun, handleResume } from "./handlers/run-handler.js";
|
|
@@ -18,199 +25,9 @@ export { handlePlanDirect, handleCodeDirect, handleReviewDirect, handleDiscoverD
|
|
|
18
25
|
export { handleStatus, handleAgents, handlePreflight, handleRoles, handleReport, handleInit, handleDoctor, handleConfig, handleScan, handleBoard, handleUndo, buildPreflightRequiredResponse } from "./handlers/management-handlers.js";
|
|
19
26
|
export { handleHu, handleSkills, handleSuggest } from "./handlers/hu-handlers.js";
|
|
20
27
|
|
|
21
|
-
// ──
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Resolve the user's project directory.
|
|
25
|
-
* Priority: 1) explicit projectDir param, 2) MCP roots, 3) error with instructions.
|
|
26
|
-
*/
|
|
27
|
-
export async function resolveProjectDir(server, explicitProjectDir) {
|
|
28
|
-
// 1. Explicit projectDir from tool parameter — always wins
|
|
29
|
-
if (explicitProjectDir) return explicitProjectDir;
|
|
30
|
-
|
|
31
|
-
// 2. MCP roots (host-provided workspace directory)
|
|
32
|
-
try {
|
|
33
|
-
const { roots } = await server.listRoots();
|
|
34
|
-
if (roots?.length > 0) {
|
|
35
|
-
const uri = roots[0].uri;
|
|
36
|
-
if (uri.startsWith("file://")) return new URL(uri).pathname;
|
|
37
|
-
return uri;
|
|
38
|
-
}
|
|
39
|
-
} catch { /* client may not support roots */ }
|
|
40
|
-
|
|
41
|
-
// 3. Check if process.cwd() looks like a real project (has package.json or .git)
|
|
42
|
-
const cwd = process.cwd();
|
|
43
|
-
try {
|
|
44
|
-
const hasGit = await fs.access(`${cwd}/.git`).then(() => true).catch(() => false);
|
|
45
|
-
const hasPkg = await fs.access(`${cwd}/package.json`).then(() => true).catch(() => false);
|
|
46
|
-
if (hasGit || hasPkg) return cwd;
|
|
47
|
-
} catch { /* ignore */ }
|
|
48
|
-
|
|
49
|
-
// 4. No valid project directory — fail with clear instructions
|
|
50
|
-
throw new Error(
|
|
51
|
-
`Cannot determine project directory. The MCP server is running from "${cwd}" which does not appear to be your project. ` +
|
|
52
|
-
`Fix: pass the "projectDir" parameter with the absolute path to your project (e.g., projectDir: "/home/user/my-project"), ` +
|
|
53
|
-
`or run "kj init" inside your project directory.`
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export function asObject(value) {
|
|
58
|
-
if (value && typeof value === "object") return value;
|
|
59
|
-
return {};
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export function responseText(payload) {
|
|
63
|
-
const compressed = compressResponse(payload);
|
|
64
|
-
return {
|
|
65
|
-
content: [{ type: "text", text: compactStringify(compressed) }]
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export function failPayload(message, details = {}) {
|
|
70
|
-
return {
|
|
71
|
-
ok: false,
|
|
72
|
-
error: message,
|
|
73
|
-
...details
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const ERROR_CLASSIFIERS = [
|
|
78
|
-
{
|
|
79
|
-
test: (lower) => lower.includes("without output") || lower.includes("silent for") || lower.includes("unresponsive") || lower.includes("exceeded max silence"),
|
|
80
|
-
category: "agent_stall",
|
|
81
|
-
suggestion: "Agent output stalled. Check live details with kj_status, then retry with a smaller prompt or increase session.max_agent_silence_minutes if needed."
|
|
82
|
-
},
|
|
83
|
-
{
|
|
84
|
-
test: (lower) => lower.includes("sonar") && (lower.includes("connect") || lower.includes("econnrefused") || lower.includes("not available") || lower.includes("not running")),
|
|
85
|
-
category: "sonar_unavailable",
|
|
86
|
-
suggestion: withDocLink("SonarQube is not reachable. Try: kj_init to set up SonarQube, or run 'docker start sonarqube' if already installed. Use --no-sonar to skip SonarQube.", "sonar_docker")
|
|
87
|
-
},
|
|
88
|
-
{
|
|
89
|
-
test: (lower) => lower.includes("401") || lower.includes("unauthorized") || lower.includes("invalid token"),
|
|
90
|
-
category: "auth_error",
|
|
91
|
-
suggestion: withDocLink("Authentication failed. Regenerate the SonarQube token and update it via kj_init or in ~/.karajan/kj.config.yml under sonarqube.token.", "sonar_token")
|
|
92
|
-
},
|
|
93
|
-
{
|
|
94
|
-
test: (lower) => lower.includes("config") && (lower.includes("missing") || lower.includes("not found") || lower.includes("invalid")),
|
|
95
|
-
category: "config_error",
|
|
96
|
-
suggestion: withDocLink("Configuration issue detected. Run kj_doctor to diagnose, or kj_init to create a fresh config.", "config_missing")
|
|
97
|
-
},
|
|
98
|
-
{
|
|
99
|
-
test: (lower) => lower.includes("missing provider") || lower.includes("not found") && (lower.includes("claude") || lower.includes("codex") || lower.includes("gemini") || lower.includes("aider")),
|
|
100
|
-
category: "agent_missing",
|
|
101
|
-
suggestion: withDocLink("Required agent CLI not found. Run kj_doctor to check which agents are installed and get installation instructions.", "agent_not_found")
|
|
102
|
-
},
|
|
103
|
-
{
|
|
104
|
-
test: (lower) => lower.includes("timed out") || lower.includes("timeout"),
|
|
105
|
-
category: "timeout",
|
|
106
|
-
suggestion: "The agent did not complete in time. Try: (1) increase --max-iteration-minutes (default: 5), (2) split the task into smaller pieces, (3) use kj_code for single-agent tasks. If a SonarQube scan timed out, check Docker health."
|
|
107
|
-
},
|
|
108
|
-
{
|
|
109
|
-
test: (lower) => lower.includes("you are on the base branch"),
|
|
110
|
-
category: "branch_error",
|
|
111
|
-
suggestion: withDocLink("Create a feature branch before running Karajan. Use 'git checkout -b feat/<task-description>' and then retry. Do NOT run kj_code directly on the base branch.", "branch_error")
|
|
112
|
-
},
|
|
113
|
-
{
|
|
114
|
-
test: (lower) => lower.includes("not a git repository"),
|
|
115
|
-
category: "git_error",
|
|
116
|
-
suggestion: "Current directory is not a git repository. Navigate to your project root or initialize git with 'git init'."
|
|
117
|
-
},
|
|
118
|
-
{
|
|
119
|
-
test: (lower) => lower.includes("bootstrap failed"),
|
|
120
|
-
category: "bootstrap_error",
|
|
121
|
-
suggestion: withDocLink("Environment prerequisites not met. Run kj_doctor for diagnostics, then fix the issues listed. Do NOT work around these — fix them properly.", "bootstrap_failed")
|
|
122
|
-
}
|
|
123
|
-
];
|
|
124
|
-
|
|
125
|
-
export function classifyError(error) {
|
|
126
|
-
const msg = error?.message || String(error);
|
|
127
|
-
const lower = msg.toLowerCase();
|
|
128
|
-
|
|
129
|
-
const match = ERROR_CLASSIFIERS.find(c => c.test(lower));
|
|
130
|
-
if (match) {
|
|
131
|
-
return { category: match.category, suggestion: match.suggestion };
|
|
132
|
-
}
|
|
133
|
-
return { category: "unknown", suggestion: null };
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
export async function assertNotOnBaseBranch(config) {
|
|
137
|
-
const baseBranch = config?.base_branch || "main";
|
|
138
|
-
let branch;
|
|
139
|
-
try {
|
|
140
|
-
branch = await currentBranch();
|
|
141
|
-
} catch {
|
|
142
|
-
return; // not a git repo or detached HEAD — let downstream handle it
|
|
143
|
-
}
|
|
144
|
-
if (branch === baseBranch) {
|
|
145
|
-
throw new Error(
|
|
146
|
-
`You are on the base branch '${baseBranch}'. Karajan needs a feature branch to compute the diff for review. ` +
|
|
147
|
-
`Create a new branch first (e.g. 'git checkout -b feat/<task-description>') and then run this command again. ` +
|
|
148
|
-
`Do NOT run kj_code directly — create the branch first so the full pipeline (code + review) works correctly.`
|
|
149
|
-
);
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
export function enrichedFailPayload(error, toolName) {
|
|
154
|
-
const msg = error?.message || String(error);
|
|
155
|
-
const { category, suggestion } = classifyError(error);
|
|
156
|
-
const payload = {
|
|
157
|
-
ok: false,
|
|
158
|
-
error: msg,
|
|
159
|
-
tool: toolName,
|
|
160
|
-
category
|
|
161
|
-
};
|
|
162
|
-
if (suggestion) payload.suggestion = suggestion;
|
|
163
|
-
return payload;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
export async function buildConfig(options, commandName = "run") {
|
|
167
|
-
const { config } = await loadConfig();
|
|
168
|
-
const merged = applyRunOverrides(config, options || {});
|
|
169
|
-
validateConfig(merged, commandName);
|
|
170
|
-
return merged;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
export function buildAskQuestion(server) {
|
|
174
|
-
return async (question) => {
|
|
175
|
-
try {
|
|
176
|
-
const result = await server.elicitInput({
|
|
177
|
-
message: question,
|
|
178
|
-
requestedSchema: {
|
|
179
|
-
type: "object",
|
|
180
|
-
properties: {
|
|
181
|
-
answer: { type: "string", description: "Your response" }
|
|
182
|
-
},
|
|
183
|
-
required: ["answer"]
|
|
184
|
-
}
|
|
185
|
-
});
|
|
186
|
-
return result.action === "accept" ? result.content?.answer || null : null;
|
|
187
|
-
} catch { /* elicitInput not supported by client */
|
|
188
|
-
return null;
|
|
189
|
-
}
|
|
190
|
-
};
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
const EVENT_LOG_LEVELS = {
|
|
194
|
-
"agent:stall": "warning",
|
|
195
|
-
"agent:heartbeat": "info"
|
|
196
|
-
};
|
|
197
|
-
|
|
198
|
-
export function buildDirectEmitter(server, runLog, extra) {
|
|
199
|
-
const emitter = new EventEmitter();
|
|
200
|
-
emitter.on("progress", (event) => {
|
|
201
|
-
try {
|
|
202
|
-
const level = EVENT_LOG_LEVELS[event.type] || "debug";
|
|
203
|
-
server.sendLoggingMessage({ level, logger: "karajan", data: event });
|
|
204
|
-
} catch { /* best-effort */ }
|
|
205
|
-
if (runLog) runLog.logEvent(event);
|
|
206
|
-
});
|
|
207
|
-
const progressNotifier = buildProgressNotifier(extra);
|
|
208
|
-
if (progressNotifier) emitter.on("progress", progressNotifier);
|
|
209
|
-
return emitter;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// ── Handler dispatch map (imports from sub-modules) ──────────────────
|
|
28
|
+
// ── Handler dispatch ─────────────────────────────────────────────────
|
|
213
29
|
|
|
30
|
+
import { asObject, failPayload } from "./shared-helpers.js";
|
|
214
31
|
import { handleRun, handleResume } from "./handlers/run-handler.js";
|
|
215
32
|
import {
|
|
216
33
|
handleCode, handleReview, handlePlan, handleDiscover,
|
|
@@ -223,36 +40,34 @@ import {
|
|
|
223
40
|
} from "./handlers/management-handlers.js";
|
|
224
41
|
import { handleHu, handleSkills, handleSuggest } from "./handlers/hu-handlers.js";
|
|
225
42
|
|
|
226
|
-
const toolHandlers = {
|
|
227
|
-
kj_status: (a, server) => handleStatus(a, server),
|
|
228
|
-
kj_init: (a) => handleInit(a),
|
|
229
|
-
kj_doctor: (a) => handleDoctor(a),
|
|
230
|
-
kj_agents: (a) => handleAgents(a),
|
|
231
|
-
kj_preflight: (a) => handlePreflight(a),
|
|
232
|
-
kj_config: (a) => handleConfig(a),
|
|
233
|
-
kj_scan: (a, server) => handleScan(a, server),
|
|
234
|
-
kj_roles: (a) => handleRoles(a),
|
|
235
|
-
kj_report: (a) => handleReport(a),
|
|
236
|
-
kj_resume: (a, server, extra) => handleResume(a, server, extra),
|
|
237
|
-
kj_run: (a, server, extra) => handleRun(a, server, extra),
|
|
238
|
-
kj_code: (a, server, extra) => handleCode(a, server, extra),
|
|
239
|
-
kj_review: (a, server, extra) => handleReview(a, server, extra),
|
|
240
|
-
kj_plan: (a, server, extra) => handlePlan(a, server, extra),
|
|
241
|
-
kj_discover: (a, server, extra) => handleDiscover(a, server, extra),
|
|
242
|
-
kj_triage: (a, server, extra) => handleTriage(a, server, extra),
|
|
243
|
-
kj_researcher: (a, server, extra) => handleResearcher(a, server, extra),
|
|
244
|
-
kj_architect: (a, server, extra) => handleArchitect(a, server, extra),
|
|
245
|
-
kj_audit: (a, server, extra) => handleAudit(a, server, extra),
|
|
246
|
-
kj_board: (a) => handleBoard(a),
|
|
247
|
-
kj_hu: (a, server) => handleHu(a, server),
|
|
248
|
-
kj_suggest: (a) => handleSuggest(a),
|
|
249
|
-
kj_skills: (a) => handleSkills(a),
|
|
250
|
-
kj_undo: (a, server) => handleUndo(a, server)
|
|
251
|
-
};
|
|
252
|
-
|
|
253
43
|
export async function handleToolCall(name, args, server, extra) {
|
|
254
44
|
const a = asObject(args);
|
|
255
|
-
const handler =
|
|
45
|
+
const handler = {
|
|
46
|
+
kj_status: (a, server) => handleStatus(a, server),
|
|
47
|
+
kj_init: (a) => handleInit(a),
|
|
48
|
+
kj_doctor: (a) => handleDoctor(a),
|
|
49
|
+
kj_agents: (a) => handleAgents(a),
|
|
50
|
+
kj_preflight: (a) => handlePreflight(a),
|
|
51
|
+
kj_config: (a) => handleConfig(a),
|
|
52
|
+
kj_scan: (a, server) => handleScan(a, server),
|
|
53
|
+
kj_roles: (a) => handleRoles(a),
|
|
54
|
+
kj_report: (a) => handleReport(a),
|
|
55
|
+
kj_resume: (a, server, extra) => handleResume(a, server, extra),
|
|
56
|
+
kj_run: (a, server, extra) => handleRun(a, server, extra),
|
|
57
|
+
kj_code: (a, server, extra) => handleCode(a, server, extra),
|
|
58
|
+
kj_review: (a, server, extra) => handleReview(a, server, extra),
|
|
59
|
+
kj_plan: (a, server, extra) => handlePlan(a, server, extra),
|
|
60
|
+
kj_discover: (a, server, extra) => handleDiscover(a, server, extra),
|
|
61
|
+
kj_triage: (a, server, extra) => handleTriage(a, server, extra),
|
|
62
|
+
kj_researcher: (a, server, extra) => handleResearcher(a, server, extra),
|
|
63
|
+
kj_architect: (a, server, extra) => handleArchitect(a, server, extra),
|
|
64
|
+
kj_audit: (a, server, extra) => handleAudit(a, server, extra),
|
|
65
|
+
kj_board: (a) => handleBoard(a),
|
|
66
|
+
kj_hu: (a, server) => handleHu(a, server),
|
|
67
|
+
kj_suggest: (a) => handleSuggest(a),
|
|
68
|
+
kj_skills: (a) => handleSkills(a),
|
|
69
|
+
kj_undo: (a, server) => handleUndo(a, server)
|
|
70
|
+
}[name];
|
|
256
71
|
if (handler) {
|
|
257
72
|
return handler(a, server, extra);
|
|
258
73
|
}
|
package/src/mcp/server.js
CHANGED
|
@@ -50,7 +50,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
|
50
50
|
});
|
|
51
51
|
|
|
52
52
|
// --- Orphan process protection + version watcher ---
|
|
53
|
-
import { setupOrphanGuard, setupVersionWatcher, setupMemoryWatchdog } from "./orphan-guard.js";
|
|
53
|
+
import { setupOrphanGuard, setupVersionWatcher, setupMemoryWatchdog, readAndDeleteRestartMarker } from "./orphan-guard.js";
|
|
54
|
+
|
|
55
|
+
// Check if this is a restart after update
|
|
56
|
+
const restartedVersion = readAndDeleteRestartMarker();
|
|
57
|
+
if (restartedVersion) {
|
|
58
|
+
process.stderr.write(`[karajan-mcp] MCP server restarted after update to v${restartedVersion}\n`);
|
|
59
|
+
}
|
|
60
|
+
|
|
54
61
|
setupOrphanGuard();
|
|
55
62
|
setupVersionWatcher({ pkgPath: PKG_PATH, currentVersion: LOADED_VERSION });
|
|
56
63
|
setupMemoryWatchdog();
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers used by MCP handler sub-modules.
|
|
3
|
+
* Extracted from server-handlers.js to break circular import chains
|
|
4
|
+
* (server-handlers.js ↔ handlers/*.js).
|
|
5
|
+
*
|
|
6
|
+
* Sub-handlers MUST import from here, NOT from server-handlers.js.
|
|
7
|
+
* server-handlers.js re-exports everything for external consumers.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { EventEmitter } from "node:events";
|
|
11
|
+
import fs from "node:fs/promises";
|
|
12
|
+
import { loadConfig, applyRunOverrides, validateConfig } from "../config.js";
|
|
13
|
+
import { currentBranch } from "../utils/git.js";
|
|
14
|
+
import { buildProgressNotifier } from "./progress.js";
|
|
15
|
+
import { compressResponse, compactStringify } from "./response-compressor.js";
|
|
16
|
+
import { classifyError } from "./error-classifiers.js";
|
|
17
|
+
|
|
18
|
+
export { classifyError } from "./error-classifiers.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the user's project directory.
|
|
22
|
+
* Priority: 1) explicit projectDir param, 2) MCP roots, 3) error with instructions.
|
|
23
|
+
*/
|
|
24
|
+
export async function resolveProjectDir(server, explicitProjectDir) {
|
|
25
|
+
if (explicitProjectDir) return explicitProjectDir;
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const { roots } = await server.listRoots();
|
|
29
|
+
if (roots?.length > 0) {
|
|
30
|
+
const uri = roots[0].uri;
|
|
31
|
+
if (uri.startsWith("file://")) return new URL(uri).pathname;
|
|
32
|
+
return uri;
|
|
33
|
+
}
|
|
34
|
+
} catch { /* client may not support roots */ }
|
|
35
|
+
|
|
36
|
+
const cwd = process.cwd();
|
|
37
|
+
try {
|
|
38
|
+
const hasGit = await fs.access(`${cwd}/.git`).then(() => true).catch(() => false);
|
|
39
|
+
const hasPkg = await fs.access(`${cwd}/package.json`).then(() => true).catch(() => false);
|
|
40
|
+
if (hasGit || hasPkg) return cwd;
|
|
41
|
+
} catch { /* ignore */ }
|
|
42
|
+
|
|
43
|
+
throw new Error(
|
|
44
|
+
`Cannot determine project directory. The MCP server is running from "${cwd}" which does not appear to be your project. ` +
|
|
45
|
+
`Fix: pass the "projectDir" parameter with the absolute path to your project (e.g., projectDir: "/home/user/my-project"), ` +
|
|
46
|
+
`or run "kj init" inside your project directory.`
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function asObject(value) {
|
|
51
|
+
if (value && typeof value === "object") return value;
|
|
52
|
+
return {};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function responseText(payload) {
|
|
56
|
+
const compressed = compressResponse(payload);
|
|
57
|
+
return {
|
|
58
|
+
content: [{ type: "text", text: compactStringify(compressed) }]
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function failPayload(message, details = {}) {
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
error: message,
|
|
66
|
+
...details
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function assertNotOnBaseBranch(config) {
|
|
71
|
+
const baseBranch = config?.base_branch || "main";
|
|
72
|
+
let branch;
|
|
73
|
+
try {
|
|
74
|
+
branch = await currentBranch();
|
|
75
|
+
} catch {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (branch === baseBranch) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`You are on the base branch '${baseBranch}'. Karajan needs a feature branch to compute the diff for review. ` +
|
|
81
|
+
`Create a new branch first (e.g. 'git checkout -b feat/<task-description>') and then run this command again. ` +
|
|
82
|
+
`Do NOT run kj_code directly — create the branch first so the full pipeline (code + review) works correctly.`
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function enrichedFailPayload(error, toolName) {
|
|
88
|
+
const msg = error?.message || String(error);
|
|
89
|
+
const { category, suggestion } = classifyError(error);
|
|
90
|
+
const payload = {
|
|
91
|
+
ok: false,
|
|
92
|
+
error: msg,
|
|
93
|
+
tool: toolName,
|
|
94
|
+
category
|
|
95
|
+
};
|
|
96
|
+
if (suggestion) payload.suggestion = suggestion;
|
|
97
|
+
return payload;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function buildConfig(options, commandName = "run") {
|
|
101
|
+
const { config } = await loadConfig();
|
|
102
|
+
const merged = applyRunOverrides(config, options || {});
|
|
103
|
+
validateConfig(merged, commandName);
|
|
104
|
+
return merged;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function buildAskQuestion(server) {
|
|
108
|
+
return async (question) => {
|
|
109
|
+
try {
|
|
110
|
+
const result = await server.elicitInput({
|
|
111
|
+
message: question,
|
|
112
|
+
requestedSchema: {
|
|
113
|
+
type: "object",
|
|
114
|
+
properties: {
|
|
115
|
+
answer: { type: "string", description: "Your response" }
|
|
116
|
+
},
|
|
117
|
+
required: ["answer"]
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
return result.action === "accept" ? result.content?.answer || null : null;
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const EVENT_LOG_LEVELS = {
|
|
128
|
+
"agent:stall": "warning",
|
|
129
|
+
"agent:heartbeat": "info"
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export function buildDirectEmitter(server, runLog, extra) {
|
|
133
|
+
const emitter = new EventEmitter();
|
|
134
|
+
emitter.on("progress", (event) => {
|
|
135
|
+
try {
|
|
136
|
+
const level = EVENT_LOG_LEVELS[event.type] || "debug";
|
|
137
|
+
server.sendLoggingMessage({ level, logger: "karajan", data: event });
|
|
138
|
+
} catch { /* best-effort */ }
|
|
139
|
+
if (runLog) runLog.logEvent(event);
|
|
140
|
+
});
|
|
141
|
+
const progressNotifier = buildProgressNotifier(extra);
|
|
142
|
+
if (progressNotifier) emitter.on("progress", progressNotifier);
|
|
143
|
+
return emitter;
|
|
144
|
+
}
|
package/src/orchestrator.js
CHANGED
|
@@ -1613,6 +1613,20 @@ export async function runFlow({ task, config, logger, flags = {}, emitter = null
|
|
|
1613
1613
|
logger.warn(`Skill cleanup failed (non-blocking): ${err.message}`);
|
|
1614
1614
|
}
|
|
1615
1615
|
}
|
|
1616
|
+
|
|
1617
|
+
// --- Telemetry: anonymous pipeline_complete event (non-blocking) ---
|
|
1618
|
+
try {
|
|
1619
|
+
const { sendTelemetryEvent } = await import("./utils/telemetry.js");
|
|
1620
|
+
const durationS = Math.round((Date.now() - ctx.startedAt) / 1000);
|
|
1621
|
+
const sessionStatus = ctx.session?.status || "unknown";
|
|
1622
|
+
sendTelemetryEvent("pipeline_complete", {
|
|
1623
|
+
mode: config.review_mode,
|
|
1624
|
+
agent: ctx.coderRole?.provider || config.coder,
|
|
1625
|
+
duration_s: durationS,
|
|
1626
|
+
success: sessionStatus === "approved",
|
|
1627
|
+
taskType: ctx.session?.resolved_policies?.taskType || null
|
|
1628
|
+
}, config).catch(() => {});
|
|
1629
|
+
} catch { /* non-blocking */ }
|
|
1616
1630
|
}
|
|
1617
1631
|
}
|
|
1618
1632
|
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detects the project technology stack by scanning project files.
|
|
3
|
+
* Used by `kj init` to auto-configure roles, skills, and settings.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import fs from "node:fs/promises";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Frameworks detectable from package.json dependencies.
|
|
11
|
+
* Maps dependency name -> { name, type } where type is "frontend" | "backend" | "fullstack".
|
|
12
|
+
*/
|
|
13
|
+
const PKG_FRAMEWORKS = {
|
|
14
|
+
react: { name: "react", type: "frontend" },
|
|
15
|
+
"react-dom": { name: "react", type: "frontend" },
|
|
16
|
+
vue: { name: "vue", type: "frontend" },
|
|
17
|
+
svelte: { name: "svelte", type: "frontend" },
|
|
18
|
+
"@angular/core": { name: "angular", type: "frontend" },
|
|
19
|
+
astro: { name: "astro", type: "frontend" },
|
|
20
|
+
next: { name: "next", type: "fullstack" },
|
|
21
|
+
nuxt: { name: "nuxt", type: "fullstack" },
|
|
22
|
+
"solid-js": { name: "solid", type: "frontend" },
|
|
23
|
+
lit: { name: "lit", type: "frontend" },
|
|
24
|
+
express: { name: "express", type: "backend" },
|
|
25
|
+
fastify: { name: "fastify", type: "backend" },
|
|
26
|
+
"@nestjs/core": { name: "nestjs", type: "backend" },
|
|
27
|
+
hono: { name: "hono", type: "backend" },
|
|
28
|
+
koa: { name: "koa", type: "backend" },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Language markers: file presence -> { language, type }.
|
|
33
|
+
*/
|
|
34
|
+
const LANGUAGE_FILE_MARKERS = [
|
|
35
|
+
{ file: "go.mod", language: "go", type: "backend" },
|
|
36
|
+
{ file: "Cargo.toml", language: "rust", type: "backend" },
|
|
37
|
+
{ file: "pom.xml", language: "java", type: "backend" },
|
|
38
|
+
{ file: "build.gradle", language: "java", type: "backend" },
|
|
39
|
+
{ file: "build.gradle.kts", language: "kotlin", type: "backend" },
|
|
40
|
+
{ file: "pyproject.toml", language: "python", type: "backend" },
|
|
41
|
+
{ file: "setup.py", language: "python", type: "backend" },
|
|
42
|
+
{ file: "requirements.txt", language: "python", type: "backend" },
|
|
43
|
+
{ file: "Gemfile", language: "ruby", type: "backend" },
|
|
44
|
+
{ file: "composer.json", language: "php", type: "backend" },
|
|
45
|
+
{ file: "pubspec.yaml", language: "dart", type: "frontend" },
|
|
46
|
+
{ file: "Package.swift", language: "swift", type: "backend" },
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Maps framework names to skill names for auto-install suggestions.
|
|
51
|
+
*/
|
|
52
|
+
const FRAMEWORK_TO_SKILL = {
|
|
53
|
+
react: "react",
|
|
54
|
+
vue: "vue",
|
|
55
|
+
svelte: "svelte",
|
|
56
|
+
angular: "angular",
|
|
57
|
+
astro: "astro",
|
|
58
|
+
next: "nextjs",
|
|
59
|
+
nuxt: "vue",
|
|
60
|
+
solid: "solid",
|
|
61
|
+
lit: "lit",
|
|
62
|
+
express: "express",
|
|
63
|
+
fastify: "fastify",
|
|
64
|
+
nestjs: "nestjs",
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Detect the project technology stack.
|
|
69
|
+
* @param {string} projectDir - Absolute path to project root.
|
|
70
|
+
* @returns {Promise<{frameworks: string[], language: string|null, isFrontend: boolean, isBackend: boolean, isFullstack: boolean, suggestions: {impeccable: boolean, skills: string[]}}>}
|
|
71
|
+
*/
|
|
72
|
+
export async function detectProjectStack(projectDir) {
|
|
73
|
+
const frameworks = new Set();
|
|
74
|
+
let language = null;
|
|
75
|
+
let hasFrontend = false;
|
|
76
|
+
let hasBackend = false;
|
|
77
|
+
|
|
78
|
+
// 1. Scan package.json for known frameworks
|
|
79
|
+
try {
|
|
80
|
+
const pkgRaw = await fs.readFile(path.join(projectDir, "package.json"), "utf8");
|
|
81
|
+
const pkg = JSON.parse(pkgRaw);
|
|
82
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
83
|
+
|
|
84
|
+
if (!language) language = "javascript";
|
|
85
|
+
|
|
86
|
+
// Check for TypeScript
|
|
87
|
+
if (allDeps.typescript || allDeps["ts-node"]) {
|
|
88
|
+
language = "typescript";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const [dep, info] of Object.entries(PKG_FRAMEWORKS)) {
|
|
92
|
+
if (allDeps[dep]) {
|
|
93
|
+
frameworks.add(info.name);
|
|
94
|
+
if (info.type === "frontend" || info.type === "fullstack") hasFrontend = true;
|
|
95
|
+
if (info.type === "backend" || info.type === "fullstack") hasBackend = true;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
} catch { /* no package.json or parse error */ }
|
|
99
|
+
|
|
100
|
+
// 2. Check language file markers
|
|
101
|
+
for (const marker of LANGUAGE_FILE_MARKERS) {
|
|
102
|
+
try {
|
|
103
|
+
await fs.access(path.join(projectDir, marker.file));
|
|
104
|
+
if (!language || language === "javascript") {
|
|
105
|
+
language = marker.language;
|
|
106
|
+
}
|
|
107
|
+
if (marker.type === "frontend") hasFrontend = true;
|
|
108
|
+
if (marker.type === "backend") hasBackend = true;
|
|
109
|
+
} catch { /* file not found */ }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const frameworkList = Array.from(frameworks);
|
|
113
|
+
const isFullstack = hasFrontend && hasBackend;
|
|
114
|
+
|
|
115
|
+
// Build suggestions
|
|
116
|
+
const skills = [];
|
|
117
|
+
for (const fw of frameworkList) {
|
|
118
|
+
const skill = FRAMEWORK_TO_SKILL[fw];
|
|
119
|
+
if (skill && !skills.includes(skill)) {
|
|
120
|
+
skills.push(skill);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
frameworks: frameworkList,
|
|
126
|
+
language,
|
|
127
|
+
isFrontend: hasFrontend,
|
|
128
|
+
isBackend: hasBackend,
|
|
129
|
+
isFullstack,
|
|
130
|
+
suggestions: {
|
|
131
|
+
impeccable: hasFrontend,
|
|
132
|
+
skills,
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Status dashboard formatter.
|
|
3
|
+
* Builds a structured terminal dashboard from session data and run-log lines.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { HU_STATUS } from "../hu/store.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Status label mapping with visual indicators for terminal output.
|
|
10
|
+
* @type {Record<string, string>}
|
|
11
|
+
*/
|
|
12
|
+
const STATUS_LABELS = {
|
|
13
|
+
[HU_STATUS.PENDING]: "pending",
|
|
14
|
+
[HU_STATUS.CODING]: "coding",
|
|
15
|
+
[HU_STATUS.REVIEWING]: "reviewing",
|
|
16
|
+
[HU_STATUS.DONE]: "done",
|
|
17
|
+
[HU_STATUS.FAILED]: "failed",
|
|
18
|
+
[HU_STATUS.BLOCKED]: "blocked",
|
|
19
|
+
[HU_STATUS.CERTIFIED]: "certified",
|
|
20
|
+
[HU_STATUS.NEEDS_CONTEXT]: "needs_ctx",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Format a millisecond duration into a human-readable string (e.g. "3m 42s").
|
|
25
|
+
* @param {number} ms
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
export function formatDuration(ms) {
|
|
29
|
+
if (!ms || ms < 0) return "0s";
|
|
30
|
+
const totalSec = Math.floor(ms / 1000);
|
|
31
|
+
const hours = Math.floor(totalSec / 3600);
|
|
32
|
+
const minutes = Math.floor((totalSec % 3600) / 60);
|
|
33
|
+
const seconds = totalSec % 60;
|
|
34
|
+
|
|
35
|
+
const parts = [];
|
|
36
|
+
if (hours > 0) parts.push(`${hours}h`);
|
|
37
|
+
if (minutes > 0) parts.push(`${minutes}m`);
|
|
38
|
+
if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`);
|
|
39
|
+
return parts.join(" ");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parse the current pipeline stage from log lines.
|
|
44
|
+
* @param {string[]} lines - Raw log lines.
|
|
45
|
+
* @returns {{ currentStage: string|null, lastEvent: string|null }}
|
|
46
|
+
*/
|
|
47
|
+
export function parseStageFromLog(lines) {
|
|
48
|
+
let currentStage = null;
|
|
49
|
+
let lastEvent = null;
|
|
50
|
+
|
|
51
|
+
for (const line of lines) {
|
|
52
|
+
const stageStart = /\[(\w+):start\]/.exec(line);
|
|
53
|
+
if (stageStart) currentStage = stageStart[1];
|
|
54
|
+
|
|
55
|
+
const stageDone = /\[(\w+):done\]|\[(\w+)\] finished/.exec(line);
|
|
56
|
+
if (stageDone) {
|
|
57
|
+
const doneName = stageDone[1] || stageDone[2];
|
|
58
|
+
if (doneName === currentStage) currentStage = "idle";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (line.includes("[standby]") || line.includes("standby")) {
|
|
62
|
+
currentStage = "standby";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (line.trim()) lastEvent = line.trim();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { currentStage, lastEvent };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Format a single HU line for the dashboard.
|
|
73
|
+
* @param {object} story - HU story object.
|
|
74
|
+
* @param {boolean} isCurrent - Whether this is the currently active HU.
|
|
75
|
+
* @returns {string}
|
|
76
|
+
*/
|
|
77
|
+
function formatHuLine(story, isCurrent) {
|
|
78
|
+
const id = (story.id || "???").padEnd(12);
|
|
79
|
+
const rawStatus = story.status || "pending";
|
|
80
|
+
const label = STATUS_LABELS[rawStatus] || rawStatus;
|
|
81
|
+
const statusStr = `[${label}]`.padEnd(13);
|
|
82
|
+
const title = story.title || story.original?.text || "(untitled)";
|
|
83
|
+
const truncTitle = title.length > 40 ? title.slice(0, 37) + "..." : title;
|
|
84
|
+
|
|
85
|
+
let timing = "";
|
|
86
|
+
if (story.duration_ms) {
|
|
87
|
+
timing = formatDuration(story.duration_ms);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const blockedInfo = story.blocked_by?.length
|
|
91
|
+
? ` (needs ${story.blocked_by.join(", ")})`
|
|
92
|
+
: "";
|
|
93
|
+
|
|
94
|
+
const marker = isCurrent ? " <-- current" : "";
|
|
95
|
+
const timingStr = timing ? ` ${timing}` : "";
|
|
96
|
+
|
|
97
|
+
return ` ${id} ${statusStr} ${truncTitle}${blockedInfo}${timingStr}${marker}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Build a terminal dashboard string from session data and log lines.
|
|
102
|
+
*
|
|
103
|
+
* @param {object|null} sessionData - Session JSON (from session-store) or null.
|
|
104
|
+
* @param {string[]} logLines - Raw run-log lines.
|
|
105
|
+
* @param {object} [options] - Optional overrides.
|
|
106
|
+
* @param {object[]|null} [options.stories] - HU stories array (from hu batch).
|
|
107
|
+
* @returns {string}
|
|
108
|
+
*/
|
|
109
|
+
export function buildDashboard(sessionData, logLines, options = {}) {
|
|
110
|
+
if (!sessionData) {
|
|
111
|
+
return "No active pipeline";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const lines = [];
|
|
115
|
+
const config = sessionData.config_snapshot || {};
|
|
116
|
+
const maxIter = config.max_iterations ?? "?";
|
|
117
|
+
const status = sessionData.status || "unknown";
|
|
118
|
+
|
|
119
|
+
// Parse log for current stage info
|
|
120
|
+
const logInfo = parseStageFromLog(logLines || []);
|
|
121
|
+
|
|
122
|
+
// Determine iteration from session or log
|
|
123
|
+
const iteration = sessionData.reviewer_retry_count != null
|
|
124
|
+
? sessionData.reviewer_retry_count + 1
|
|
125
|
+
: null;
|
|
126
|
+
const iterFromLog = parseIterationFromLog(logLines || []);
|
|
127
|
+
const displayIter = iterFromLog || iteration || "?";
|
|
128
|
+
|
|
129
|
+
// Pipeline header
|
|
130
|
+
const statusUpper = status.toUpperCase();
|
|
131
|
+
lines.push(`Pipeline: ${statusUpper} (iteration ${displayIter}/${maxIter})`);
|
|
132
|
+
|
|
133
|
+
// Duration
|
|
134
|
+
const duration = computeDuration(sessionData);
|
|
135
|
+
lines.push(`Duration: ${formatDuration(duration)}`);
|
|
136
|
+
|
|
137
|
+
// Agent info
|
|
138
|
+
const coderProvider = config.coder?.provider || config.roles?.coder?.provider || "?";
|
|
139
|
+
const reviewerProvider = config.reviewer?.provider || config.roles?.reviewer?.provider || "?";
|
|
140
|
+
lines.push(`Agent: ${coderProvider} (coder) / ${reviewerProvider} (reviewer)`);
|
|
141
|
+
|
|
142
|
+
// HU stories section
|
|
143
|
+
const stories = options.stories || [];
|
|
144
|
+
if (stories.length > 0) {
|
|
145
|
+
lines.push("");
|
|
146
|
+
lines.push("HUs:");
|
|
147
|
+
|
|
148
|
+
// Determine which story is currently being worked on
|
|
149
|
+
const currentStory = stories.find(s =>
|
|
150
|
+
s.status === HU_STATUS.CODING || s.status === HU_STATUS.REVIEWING
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
for (const story of stories) {
|
|
154
|
+
const isCurrent = currentStory && story.id === currentStory.id;
|
|
155
|
+
lines.push(formatHuLine(story, isCurrent));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Current stage and last event
|
|
160
|
+
lines.push("");
|
|
161
|
+
const stage = logInfo.currentStage || "idle";
|
|
162
|
+
const iterSuffix = displayIter !== "?" ? ` (iteration ${displayIter})` : "";
|
|
163
|
+
lines.push(`Current stage: ${stage}${iterSuffix}`);
|
|
164
|
+
|
|
165
|
+
if (logInfo.lastEvent) {
|
|
166
|
+
// Truncate last event for display
|
|
167
|
+
const maxLen = 120;
|
|
168
|
+
const event = logInfo.lastEvent.length > maxLen
|
|
169
|
+
? logInfo.lastEvent.slice(0, maxLen - 3) + "..."
|
|
170
|
+
: logInfo.lastEvent;
|
|
171
|
+
lines.push(`Last event: ${event}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Errors
|
|
175
|
+
if (sessionData.deferred_issues?.length) {
|
|
176
|
+
lines.push("");
|
|
177
|
+
lines.push(`Deferred issues: ${sessionData.deferred_issues.length}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return lines.join("\n");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Build a structured JSON response for MCP consumers.
|
|
185
|
+
*
|
|
186
|
+
* @param {object|null} sessionData - Session JSON or null.
|
|
187
|
+
* @param {string[]} logLines - Raw run-log lines.
|
|
188
|
+
* @param {object} [options] - Optional overrides.
|
|
189
|
+
* @param {object[]|null} [options.stories] - HU stories array.
|
|
190
|
+
* @returns {object}
|
|
191
|
+
*/
|
|
192
|
+
export function buildDashboardJson(sessionData, logLines, options = {}) {
|
|
193
|
+
if (!sessionData) {
|
|
194
|
+
return { ok: false, message: "No active pipeline" };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const config = sessionData.config_snapshot || {};
|
|
198
|
+
const logInfo = parseStageFromLog(logLines || []);
|
|
199
|
+
const iterFromLog = parseIterationFromLog(logLines || []);
|
|
200
|
+
const iteration = iterFromLog
|
|
201
|
+
|| (sessionData.reviewer_retry_count != null ? sessionData.reviewer_retry_count + 1 : null);
|
|
202
|
+
|
|
203
|
+
const stories = (options.stories || []).map(s => ({
|
|
204
|
+
id: s.id,
|
|
205
|
+
status: s.status || "pending",
|
|
206
|
+
title: s.title || s.original?.text || null,
|
|
207
|
+
blocked_by: s.blocked_by || [],
|
|
208
|
+
duration_ms: s.duration_ms || null,
|
|
209
|
+
}));
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
ok: true,
|
|
213
|
+
pipeline: {
|
|
214
|
+
status: sessionData.status || "unknown",
|
|
215
|
+
iteration: iteration || null,
|
|
216
|
+
maxIterations: config.max_iterations ?? null,
|
|
217
|
+
duration_ms: computeDuration(sessionData),
|
|
218
|
+
coder: config.coder?.provider || config.roles?.coder?.provider || null,
|
|
219
|
+
reviewer: config.reviewer?.provider || config.roles?.reviewer?.provider || null,
|
|
220
|
+
},
|
|
221
|
+
hus: stories,
|
|
222
|
+
currentStage: logInfo.currentStage || "idle",
|
|
223
|
+
lastEvent: logInfo.lastEvent || null,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Parse iteration number from log lines.
|
|
229
|
+
* @param {string[]} lines
|
|
230
|
+
* @returns {number|null}
|
|
231
|
+
*/
|
|
232
|
+
function parseIterationFromLog(lines) {
|
|
233
|
+
let iteration = null;
|
|
234
|
+
for (const line of lines) {
|
|
235
|
+
const m = /[Ii]teration\s+(\d+)/.exec(line);
|
|
236
|
+
if (m) iteration = Number.parseInt(m[1], 10);
|
|
237
|
+
}
|
|
238
|
+
return iteration;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Compute pipeline duration in milliseconds from session timestamps.
|
|
243
|
+
* @param {object} sessionData
|
|
244
|
+
* @returns {number}
|
|
245
|
+
*/
|
|
246
|
+
function computeDuration(sessionData) {
|
|
247
|
+
const created = sessionData.created_at;
|
|
248
|
+
if (!created) return 0;
|
|
249
|
+
const start = new Date(created).getTime();
|
|
250
|
+
const now = sessionData.updated_at
|
|
251
|
+
? new Date(sessionData.updated_at).getTime()
|
|
252
|
+
: Date.now();
|
|
253
|
+
return Math.max(0, now - start);
|
|
254
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
const TELEMETRY_ENDPOINT = "https://karajan-code.web.app/api/telemetry";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Send an anonymous telemetry event. Non-blocking, fire-and-forget.
|
|
5
|
+
* Never throws, never blocks the pipeline.
|
|
6
|
+
*
|
|
7
|
+
* @param {string} eventName - Event name (e.g. "install", "pipeline_complete", "cli_command")
|
|
8
|
+
* @param {object} data - Event-specific data
|
|
9
|
+
* @param {object} [config] - Karajan config (checked for telemetry opt-out)
|
|
10
|
+
*/
|
|
11
|
+
export async function sendTelemetryEvent(eventName, data, config) {
|
|
12
|
+
if (!isTelemetryEnabled(config)) return;
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
const payload = {
|
|
16
|
+
event: eventName,
|
|
17
|
+
v: data.version || "unknown",
|
|
18
|
+
os: process.platform,
|
|
19
|
+
node: process.version,
|
|
20
|
+
ts: Date.now(),
|
|
21
|
+
...data
|
|
22
|
+
};
|
|
23
|
+
const controller = new AbortController();
|
|
24
|
+
const timeout = setTimeout(() => controller.abort(), 3000);
|
|
25
|
+
try {
|
|
26
|
+
await fetch(TELEMETRY_ENDPOINT, {
|
|
27
|
+
method: "POST",
|
|
28
|
+
headers: { "Content-Type": "application/json" },
|
|
29
|
+
body: JSON.stringify(payload),
|
|
30
|
+
signal: controller.signal
|
|
31
|
+
});
|
|
32
|
+
} finally {
|
|
33
|
+
clearTimeout(timeout);
|
|
34
|
+
}
|
|
35
|
+
} catch { /* never block, never fail */ }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Check if telemetry is enabled (opt-out model).
|
|
40
|
+
* Returns false only when the user explicitly sets telemetry: false.
|
|
41
|
+
*
|
|
42
|
+
* @param {object} [config] - Karajan config
|
|
43
|
+
* @returns {boolean}
|
|
44
|
+
*/
|
|
45
|
+
export function isTelemetryEnabled(config) {
|
|
46
|
+
return config?.telemetry !== false;
|
|
47
|
+
}
|