karajan-code 1.56.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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "1.56.0",
3
+ "version": "1.57.0",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
package/src/cli.js CHANGED
@@ -40,6 +40,12 @@ async function withConfig(commandName, flags, fn) {
40
40
  const merged = applyRunOverrides(config, flags || {});
41
41
  validateConfig(merged, commandName);
42
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
+
43
49
  await fn({ config: merged, logger, flags });
44
50
  }
45
51
 
@@ -374,4 +374,15 @@ export async function initCommand({ logger, flags = {} }) {
374
374
 
375
375
  await setupSonarQube(config, logger);
376
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 */ }
377
388
  }
package/src/config.js CHANGED
@@ -171,6 +171,7 @@ const DEFAULTS = {
171
171
  devtools_mcp: false,
172
172
  thresholds: { lcp: 2500, cls: 0.1, inp: 200 }
173
173
  },
174
+ telemetry: true,
174
175
  guards: {
175
176
  output: {
176
177
  enabled: true,
@@ -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
+ }
@@ -28,7 +28,7 @@ import {
28
28
  assertNotOnBaseBranch,
29
29
  buildConfig,
30
30
  buildDirectEmitter,
31
- } from "../server-handlers.js";
31
+ } from "../shared-helpers.js";
32
32
 
33
33
  /**
34
34
  * Run bootstrap gate: validate all environment prerequisites before execution.
@@ -7,7 +7,7 @@ import { handleSuggestion } from "../suggest-handler.js";
7
7
  import {
8
8
  resolveProjectDir,
9
9
  failPayload,
10
- } from "../server-handlers.js";
10
+ } from "../shared-helpers.js";
11
11
 
12
12
  export async function handleHu(a, server) {
13
13
  const action = a.action;
@@ -13,7 +13,7 @@ import {
13
13
  failPayload,
14
14
  responseText,
15
15
  buildConfig,
16
- } from "../server-handlers.js";
16
+ } from "../shared-helpers.js";
17
17
  import { buildDashboardJson } from "../../utils/status-dashboard.js";
18
18
  import { loadMostRecentSession } from "../../session-store.js";
19
19
 
@@ -21,7 +21,7 @@ import {
21
21
  buildConfig,
22
22
  buildAskQuestion,
23
23
  enrichedFailPayload,
24
- } from "../server-handlers.js";
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
- const config = await buildConfig(a);
158
- const logger = createLogger(config.output.log_level, "mcp");
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,
@@ -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
- export function setupVersionWatcher({ pkgPath, currentVersion, exitFn = () => process.exit(0) } = {}) {
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
- exitFn();
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 remain here; handler implementations are in ./handlers/*.
4
- * Re-exports everything so existing imports continue to work.
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
- import { EventEmitter } from "node:events";
8
- import fs from "node:fs/promises";
9
- import { loadConfig, applyRunOverrides, validateConfig } from "../config.js";
10
- import { currentBranch } from "../utils/git.js";
11
- import { buildProgressNotifier } from "./progress.js";
12
- import { compressResponse, compactStringify } from "./response-compressor.js";
13
- import { withDocLink } from "../utils/doc-links.js";
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
- // ── Shared helpers (used by sub-modules and external consumers) ──────
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 = toolHandlers[name];
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
+ }
@@ -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,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
+ }