karajan-code 1.38.2 → 1.40.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
@@ -110,87 +110,23 @@ kj-tail --help # Full options
110
110
  **Typical workflow:**
111
111
 
112
112
  ```
113
- ┌─────────────────────────┐ ┌─────────────────────────┐
114
- │ Terminal 1 │ │ Terminal 2 │
115
- │ │ │ │
116
- │ $ claude │ │ $ kj-tail │
117
- │ > implement the next │ │ │
118
- │ priority task │ │ ├─ 📋 Triage: medium │
119
- │ │ │ ├─ 🔬 Researcher ✅ │
120
- │ (Claude calls kj_run │ │ ├─ 🧠 Planner ✅ │
121
- │ via MCP, you see │ │ ├─ 🔨 Coder ✅ │
122
- │ only the final result) │ │ ├─ 🔍 Sonar: OK │
123
- │ │ │ ├─ 👁️ Reviewer ❌ │
124
- │ │ │ ├─ ⚖️ Solomon: 2 cond. │
125
- │ │ │ ├─ 🔨 Coder (iter 2) ✅ │
126
- │ │ │ ├─ ✅ Review: APPROVED │
127
- │ │ │ ├─ 🧪 Tester: passed │
128
- │ │ │ └─ 🏁 Result: APPROVED │
129
- └─────────────────────────┘ └─────────────────────────┘
130
- ```
131
-
132
- **Full pipeline example**, a complex task with all roles:
133
-
134
- ```
135
- ┌─ Terminal 1 ─────────────────────────────────────────────────────────────────┐
136
- │ │
137
- │ $ claude │
138
- │ │
139
- │ > Build a REST API for a booking system. Requirements: │
140
- │ > - Express + TypeScript with Zod validation on every endpoint │
141
- │ > - Endpoints: POST /bookings, GET /bookings/:id, PATCH /bookings/:id/cancel│
142
- │ > - A booking has: id, guestName, roomType (standard|suite|penthouse), │
143
- │ > checkIn, checkOut, status (confirmed|cancelled) │
144
- │ > - Validate: checkOut must be after checkIn, no past dates, │
145
- │ > roomType must be a valid enum value │
146
- │ > - Cancel returns 409 if already cancelled │
147
- │ > - Use TDD. Run it through Karajan with architect and planner enabled, │
148
- │ > paranoid review mode. Coder claude, reviewer codex. │
149
- │ │
150
- │ Claude calls kj_run via MCP with: │
151
- │ --enable-architect --enable-researcher --enable-planner --mode paranoid │
152
- │ │
153
- └──────────────────────────────────────────────────────────────────────────────┘
154
-
155
- ┌─ Terminal 2: kj-tail ────────────────────────────────────────────────────────┐
156
- │ │
157
- │ kj-tail v1.38.1 — .kj/run.log │
158
- │ │
159
- │ ├─ 📋 Triage: medium (sw) — enabling researcher, architect, planner │
160
- │ ├─ ⚙️ Preflight passed — all checks OK │
161
- │ ├─ 🔬 Researcher: 8 files analyzed, 3 patterns, 5 constraints │
162
- │ ├─ 🏗️ Architect: 3-layer design (routes → service → validators) │
163
- │ ├─ 🧠 Planner: 6 steps — tests first, then routes, service, validators │
164
- │ │ │
165
- │ ▶ Iteration 1/5 │
166
- │ ├─ 🔨 Coder (claude): implemented 3 endpoints + 18 tests │
167
- │ ├─ 📋 TDD: PASS (3 src, 2 test files) │
168
- │ ├─ 🔍 Sonar: Quality gate OK — 0 blockers │
169
- │ ├─ 👁️ Reviewer (codex): REJECTED (2 blocking) │
170
- │ │ "Missing 404 for GET nonexistent booking" │
171
- │ │ "Cancel endpoint lacks idempotency test" │
172
- │ ├─ ⚖️ Solomon: approve_with_conditions (2 conditions) │
173
- │ │ "Add 404 response and test for GET /bookings/:id with unknown id" │
174
- │ │ "Add test: cancel already-cancelled booking returns 409, not 500" │
175
- │ │ │
176
- │ ▶ Iteration 2/5 │
177
- │ ├─ 🔨 Coder (claude): fixed — 22 tests now │
178
- │ ├─ 📋 TDD: PASS │
179
- │ ├─ 🔍 Sonar: OK │
180
- │ ├─ 👁️ Reviewer (codex): APPROVED │
181
- │ ├─ 🧪 Tester: passed — coverage 94%, 22 tests │
182
- │ ├─ 🔒 Security: passed — 0 critical, 1 low (helmet recommended) │
183
- │ ├─ 📊 Audit: CERTIFIED (with 3 advisory warnings) │
184
- │ │ │
185
- │ 🏁 Result: APPROVED │
186
- │ 🔬 Research: 8 files, 3 patterns identified │
187
- │ 🗺 Plan: 6 steps (tests first) │
188
- │ 🧪 Coverage: 94%, 22 tests │
189
- │ 🔒 Security: passed │
190
- │ 🔍 Sonar: OK │
191
- │ 💰 Budget: $0.42 (claude: $0.38, codex: $0.04) │
192
- │ │
193
- └──────────────────────────────────────────────────────────────────────────────┘
113
+ Terminal 1 Terminal 2
114
+
115
+ $ claude $ kj-tail
116
+ > implement the next
117
+ priority task [triage] medium (sw)
118
+ [researcher] 3 patterns, 5 constraints
119
+ (Claude calls kj_run [planner] 6 steps (tests first)
120
+ via MCP, you see [coder] 3 endpoints + 18 tests
121
+ only the final result) [tdd] PASS (3 src, 2 test)
122
+ [sonar] Quality gate OK
123
+ [reviewer] REJECTED (2 blocking)
124
+ [solomon] 2 conditions
125
+ [coder] fixed, 22 tests now
126
+ [reviewer] APPROVED
127
+ [tester] 94% coverage, 22 tests
128
+ [security] passed
129
+ Result: APPROVED
194
130
  ```
195
131
 
196
132
  [**Watch the full pipeline demo**](https://karajancode.com#demo): triage, architecture, TDD, SonarQube, code review, Solomon arbitration, security audit.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "1.38.2",
3
+ "version": "1.40.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
@@ -25,9 +25,14 @@ import { architectCommand } from "./commands/architect.js";
25
25
  import { auditCommand } from "./commands/audit.js";
26
26
  import { boardCommand } from "./commands/board.js";
27
27
 
28
+ import { printUpdateNotice } from "./utils/update-check.js";
29
+
28
30
  const PKG_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../package.json");
29
31
  const PKG_VERSION = JSON.parse(readFileSync(PKG_PATH, "utf8")).version;
30
32
 
33
+ // Non-blocking update check (runs in background, prints after command output)
34
+ printUpdateNotice(PKG_VERSION).catch(() => {});
35
+
31
36
  async function withConfig(commandName, flags, fn) {
32
37
  const { config } = await loadConfig();
33
38
  const merged = applyRunOverrides(config, flags || {});
@@ -158,6 +158,15 @@ async function setupSonarQube(config, logger) {
158
158
  logger.info("SonarQube disabled — skipping container setup.");
159
159
  return;
160
160
  }
161
+
162
+ // Check Docker first — if not available, disable sonar gracefully
163
+ const dockerCheck = await runCommand("docker", ["--version"]);
164
+ if (dockerCheck.exitCode !== 0) {
165
+ logger.info("Docker not found — disabling SonarQube. Install Docker to enable it later.");
166
+ config.sonarqube.enabled = false;
167
+ return;
168
+ }
169
+
161
170
  const vmCheck = await checkVmMaxMapCount(os.platform());
162
171
  if (!vmCheck.ok) {
163
172
  logger.warn(`vm.max_map_count check failed: ${vmCheck.reason}`);
@@ -168,7 +177,9 @@ async function setupSonarQube(config, logger) {
168
177
 
169
178
  const sonar = await sonarUp();
170
179
  if (sonar.exitCode !== 0) {
171
- throw new Error(`Failed to start SonarQube: ${sonar.stderr || sonar.stdout}`);
180
+ logger.info(`SonarQube could not be started — disabling for now. You can enable it later with 'kj sonar start'.`);
181
+ config.sonarqube.enabled = false;
182
+ return;
172
183
  }
173
184
 
174
185
  logger.info("SonarQube container started");
@@ -25,6 +25,8 @@ import { createRunLog, readRunLog } from "../utils/run-log.js";
25
25
  import { currentBranch } from "../utils/git.js";
26
26
  import { isPreflightAcked, ackPreflight, getSessionOverrides } from "./preflight.js";
27
27
  import { ensureBootstrap } from "../bootstrap.js";
28
+ import { validateSovereignty } from "./sovereignty-guard.js";
29
+ import { handleSuggestion } from "./suggest-handler.js";
28
30
 
29
31
  /**
30
32
  * Resolve the user's project directory.
@@ -823,6 +825,19 @@ async function handleRun(a, server, extra) {
823
825
  return failPayload(`Invalid taskType "${a.taskType}". Valid values: ${[...validTypes].join(", ")}`);
824
826
  }
825
827
  }
828
+
829
+ // Sovereignty guard: sanitise params and check for active sessions
830
+ const projectDir = await resolveProjectDir(server, a.projectDir);
831
+ const sovereignty = validateSovereignty(a, { projectDir });
832
+ if (sovereignty.error) {
833
+ return failPayload(sovereignty.error);
834
+ }
835
+ if (sovereignty.warnings.length > 0) {
836
+ const logger = createLogger("info", "mcp");
837
+ for (const w of sovereignty.warnings) logger.warn(`[sovereignty] ${w}`);
838
+ }
839
+ Object.assign(a, sovereignty.params);
840
+
826
841
  await runBootstrapGate(server, a);
827
842
  if (!isPreflightAcked()) {
828
843
  // Auto-acknowledge with defaults for autonomous operation
@@ -963,6 +978,17 @@ async function handleScan(a, server) {
963
978
  return runKjCommand({ command: "scan", options: a });
964
979
  }
965
980
 
981
+ async function handleSuggest(a) {
982
+ if (!a.suggestion) {
983
+ return failPayload("Missing required field: suggestion");
984
+ }
985
+ return handleSuggestion({
986
+ suggestion: a.suggestion,
987
+ context: a.context || null,
988
+ projectDir: a.projectDir || null
989
+ });
990
+ }
991
+
966
992
  /* ── Handler dispatch map ─────────────────────────────────────────── */
967
993
 
968
994
  const toolHandlers = {
@@ -986,7 +1012,8 @@ const toolHandlers = {
986
1012
  kj_architect: (a, server, extra) => handleArchitect(a, server, extra),
987
1013
  kj_audit: (a, server, extra) => handleAudit(a, server, extra),
988
1014
  kj_board: (a) => handleBoard(a),
989
- kj_hu: (a, server) => handleHu(a, server)
1015
+ kj_hu: (a, server) => handleHu(a, server),
1016
+ kj_suggest: (a) => handleSuggest(a)
990
1017
  };
991
1018
 
992
1019
  export async function handleToolCall(name, args, server, extra) {
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Sovereignty guard for MCP inputs.
3
+ *
4
+ * Validates kj_run parameters and rejects host-AI attempts to override
5
+ * pipeline decisions that Karajan makes internally (triage, hu-reviewer, etc.).
6
+ */
7
+
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+
11
+ /** Parameters that the host AI is allowed to pass through without restriction. */
12
+ const ALLOWED_PARAMS = new Set([
13
+ "task", "projectDir", "pgTask", "pgProject",
14
+ "planner", "coder", "reviewer", "refactorer",
15
+ "plannerModel", "coderModel", "reviewerModel", "refactorerModel",
16
+ "enablePlanner", "enableReviewer", "enableRefactorer", "enableResearcher",
17
+ "enableTester", "enableSecurity", "enableImpeccable",
18
+ "enableDiscover", "enableArchitect",
19
+ "architectModel", "huFile",
20
+ "enableSerena", "enableBecaria",
21
+ "reviewerFallback", "reviewerRetries",
22
+ "mode", "maxIterations", "maxIterationMinutes", "maxTotalMinutes",
23
+ "baseBranch", "baseRef", "methodology",
24
+ "autoCommit", "autoPush", "autoPr", "autoRebase", "branchPrefix",
25
+ "autoSimplify", "smartModels", "checkpointInterval",
26
+ "taskType", "quiet", "noSonar", "enableSonarcloud",
27
+ "kjHome", "sonarToken", "timeoutMs",
28
+ // Sovereign flags — validated below but still "known"
29
+ "enableTriage", "enableHuReviewer",
30
+ ]);
31
+
32
+ const MIN_ITERATIONS = 1;
33
+ const MAX_ITERATIONS = 10;
34
+ const ACTIVE_SESSION_THRESHOLD_MS = 60_000;
35
+
36
+ /**
37
+ * Check if a pipeline is already running for this project.
38
+ * Looks at .kj/run.log modification time.
39
+ *
40
+ * @param {string} projectDir - Project root directory
41
+ * @returns {{ active: boolean, message?: string }}
42
+ */
43
+ export function checkActiveSession(projectDir) {
44
+ if (!projectDir) return { active: false };
45
+ const logPath = path.join(projectDir, ".kj", "run.log");
46
+ try {
47
+ const stat = fs.statSync(logPath);
48
+ const ageMs = Date.now() - stat.mtimeMs;
49
+ if (ageMs < ACTIVE_SESSION_THRESHOLD_MS) {
50
+ return {
51
+ active: true,
52
+ message:
53
+ "A pipeline is already running for this project. " +
54
+ "Wait for it to complete or use kj_status to check progress.",
55
+ };
56
+ }
57
+ } catch {
58
+ // File doesn't exist or can't be read — no active session
59
+ }
60
+ return { active: false };
61
+ }
62
+
63
+ /**
64
+ * Validate and sanitise kj_run parameters.
65
+ *
66
+ * @param {Record<string, unknown>} params - Raw parameters from the MCP call
67
+ * @param {{ projectDir?: string }} context - Runtime context
68
+ * @returns {{ params: Record<string, unknown>, warnings: string[], error?: string }}
69
+ */
70
+ export function validateSovereignty(params, context = {}) {
71
+ const warnings = [];
72
+ const sanitized = { ...params };
73
+
74
+ // 1. Strip unknown parameters
75
+ for (const key of Object.keys(sanitized)) {
76
+ if (!ALLOWED_PARAMS.has(key)) {
77
+ warnings.push(`Unknown parameter "${key}" stripped`);
78
+ delete sanitized[key];
79
+ }
80
+ }
81
+
82
+ // 2. Sovereign flags — pipeline decides, not the host AI
83
+ if (sanitized.enableHuReviewer === false) {
84
+ warnings.push("Pipeline decides hu-reviewer activation, ignoring override");
85
+ delete sanitized.enableHuReviewer;
86
+ }
87
+
88
+ if (sanitized.enableTriage === false) {
89
+ warnings.push("Triage is mandatory, ignoring override");
90
+ delete sanitized.enableTriage;
91
+ }
92
+
93
+ // 3. Clamp maxIterations to [1, 10]
94
+ if (sanitized.maxIterations !== undefined) {
95
+ const n = Number(sanitized.maxIterations);
96
+ if (!Number.isFinite(n) || n < MIN_ITERATIONS) {
97
+ warnings.push(`maxIterations ${sanitized.maxIterations} clamped to ${MIN_ITERATIONS}`);
98
+ sanitized.maxIterations = MIN_ITERATIONS;
99
+ } else if (n > MAX_ITERATIONS) {
100
+ warnings.push(`maxIterations ${sanitized.maxIterations} clamped to ${MAX_ITERATIONS}`);
101
+ sanitized.maxIterations = MAX_ITERATIONS;
102
+ }
103
+ }
104
+
105
+ // 4. Active session detection
106
+ const session = checkActiveSession(context.projectDir);
107
+ if (session.active) {
108
+ return { params: sanitized, warnings, error: session.message };
109
+ }
110
+
111
+ return { params: sanitized, warnings };
112
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Handler for kj_suggest MCP tool.
3
+ * Allows the host AI to propose observations to Solomon without override power.
4
+ */
5
+
6
+ import fs from "node:fs/promises";
7
+ import path from "node:path";
8
+ import { getSessionRoot } from "../utils/paths.js";
9
+
10
+ /**
11
+ * Find the most recent running session for a given projectDir.
12
+ * If projectDir is not provided, returns the most recent running session overall.
13
+ */
14
+ async function findActiveSession(projectDir) {
15
+ const sessionRoot = getSessionRoot();
16
+ let entries;
17
+ try {
18
+ entries = await fs.readdir(sessionRoot, { withFileTypes: true });
19
+ } catch {
20
+ return null;
21
+ }
22
+
23
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
24
+ // Walk backwards (most recent first)
25
+ for (let i = dirs.length - 1; i >= 0; i--) {
26
+ try {
27
+ const raw = await fs.readFile(path.join(sessionRoot, dirs[i], "session.json"), "utf8");
28
+ const session = JSON.parse(raw);
29
+ if (session.status !== "running") continue;
30
+ if (projectDir && session.projectDir && session.projectDir !== projectDir) continue;
31
+ return session;
32
+ } catch {
33
+ continue;
34
+ }
35
+ }
36
+ return null;
37
+ }
38
+
39
+ /**
40
+ * Handle a suggestion from the host AI.
41
+ * Logs it to the active session's suggestions array for Solomon to evaluate.
42
+ *
43
+ * @param {object} params
44
+ * @param {string} params.suggestion - The observation or proposal
45
+ * @param {string} [params.context] - Additional context
46
+ * @param {string} [params.projectDir] - Project directory to scope session lookup
47
+ * @returns {Promise<object>} Result with accepted status and message
48
+ */
49
+ export async function handleSuggestion({ suggestion, context, projectDir }) {
50
+ if (!suggestion || typeof suggestion !== "string" || suggestion.trim() === "") {
51
+ return {
52
+ accepted: false,
53
+ reason: "Suggestion text is required and must be a non-empty string."
54
+ };
55
+ }
56
+
57
+ const session = await findActiveSession(projectDir);
58
+
59
+ if (!session) {
60
+ return {
61
+ accepted: false,
62
+ reason: "No active pipeline session. Suggestions are only evaluated during pipeline execution."
63
+ };
64
+ }
65
+
66
+ // Initialize suggestions array if not present
67
+ if (!Array.isArray(session.suggestions)) {
68
+ session.suggestions = [];
69
+ }
70
+
71
+ const entry = {
72
+ suggestion: suggestion.trim(),
73
+ context: context || null,
74
+ timestamp: new Date().toISOString()
75
+ };
76
+
77
+ session.suggestions.push(entry);
78
+
79
+ // Persist the updated session
80
+ const sessionRoot = getSessionRoot();
81
+ const sessionFile = path.join(sessionRoot, session.id, "session.json");
82
+ session.updated_at = new Date().toISOString();
83
+ await fs.writeFile(sessionFile, JSON.stringify(session, null, 2), "utf8");
84
+
85
+ return {
86
+ accepted: true,
87
+ status: "logged",
88
+ sessionId: session.id,
89
+ message: "Suggestion logged for Solomon evaluation in next iteration. Solomon will decide whether to act on it."
90
+ };
91
+ }
package/src/mcp/tools.js CHANGED
@@ -334,5 +334,18 @@ export const tools = [
334
334
  projectDir: { type: "string", description: "Project directory (defaults to cwd)" }
335
335
  }
336
336
  }
337
+ },
338
+ {
339
+ name: "kj_suggest",
340
+ description: "Propose an observation or suggestion to Karajan's Solomon engine. Solomon will evaluate it and decide whether to accept, reject, or ask the human. You CANNOT override pipeline decisions through this tool, only propose.",
341
+ inputSchema: {
342
+ type: "object",
343
+ required: ["suggestion"],
344
+ properties: {
345
+ suggestion: { type: "string", description: "What you observed or want to propose" },
346
+ context: { type: "string", description: "Additional context (current HU, iteration, etc.)" },
347
+ projectDir: { type: "string", description: "Project directory" }
348
+ }
349
+ }
337
350
  }
338
351
  ];
@@ -1,5 +1,5 @@
1
1
  import { SolomonRole } from "../roles/solomon-role.js";
2
- import { addCheckpoint, pauseSession } from "../session-store.js";
2
+ import { addCheckpoint, pauseSession, saveSession } from "../session-store.js";
3
3
  import { emitProgress, makeEvent } from "../utils/events.js";
4
4
 
5
5
  /**
@@ -80,11 +80,19 @@ export async function invokeSolomon({ config, logger, emitter, eventBase, stage,
80
80
  })
81
81
  );
82
82
 
83
+ // Collect pending suggestions from the session for Solomon's context
84
+ const pendingSuggestions = Array.isArray(session.suggestions) && session.suggestions.length > 0
85
+ ? session.suggestions.slice()
86
+ : null;
87
+
83
88
  const solomon = new SolomonRole({ config, logger, emitter });
84
89
  await solomon.init({ task: conflict.task || session.task, iteration });
85
90
  let ruling;
86
91
  try {
87
- ruling = await solomon.run({ conflict });
92
+ const conflictWithSuggestions = pendingSuggestions
93
+ ? { ...conflict, hostSuggestions: pendingSuggestions }
94
+ : conflict;
95
+ ruling = await solomon.run({ conflict: conflictWithSuggestions });
88
96
  } catch (err) {
89
97
  logger.warn(`Solomon threw: ${err.message}`);
90
98
  return escalateToHuman({
@@ -93,6 +101,12 @@ export async function invokeSolomon({ config, logger, emitter, eventBase, stage,
93
101
  });
94
102
  }
95
103
 
104
+ // Clear processed suggestions from session
105
+ if (pendingSuggestions) {
106
+ session.suggestions = [];
107
+ try { await saveSession(session); } catch { /* best-effort */ }
108
+ }
109
+
96
110
  const solomonError = ruling.result?.error;
97
111
  if (!ruling.ok && solomonError) {
98
112
  logger.warn(`Solomon execution failed: ${solomonError}`);
@@ -0,0 +1,74 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { getKarajanHome } from "./paths.js";
4
+
5
+ const CACHE_FILE = "update-check.json";
6
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
7
+ const PACKAGE_NAME = "karajan-code";
8
+
9
+ /**
10
+ * Check npm for a newer version. Non-blocking, cached for 24h.
11
+ * Returns { updateAvailable, latest, current } or null if check fails/cached.
12
+ */
13
+ export async function checkForUpdate(currentVersion) {
14
+ try {
15
+ const cachePath = path.join(getKarajanHome(), CACHE_FILE);
16
+
17
+ // Check cache first
18
+ try {
19
+ const raw = await fs.readFile(cachePath, "utf8");
20
+ const cache = JSON.parse(raw);
21
+ if (cache.checkedAt && Date.now() - cache.checkedAt < CACHE_TTL_MS) {
22
+ if (!cache.latest || cache.latest === currentVersion) return null;
23
+ return { updateAvailable: true, latest: cache.latest, current: currentVersion };
24
+ }
25
+ } catch { /* no cache or expired */ }
26
+
27
+ // Fetch from npm (timeout 3s, don't block)
28
+ const controller = new AbortController();
29
+ const timeout = setTimeout(() => controller.abort(), 3000);
30
+ const res = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, {
31
+ signal: controller.signal,
32
+ headers: { "Accept": "application/json" },
33
+ });
34
+ clearTimeout(timeout);
35
+
36
+ if (!res.ok) return null;
37
+ const data = await res.json();
38
+ const latest = data.version;
39
+
40
+ // Save cache
41
+ await fs.mkdir(getKarajanHome(), { recursive: true });
42
+ await fs.writeFile(cachePath, JSON.stringify({ latest, checkedAt: Date.now() }), "utf8");
43
+
44
+ if (latest === currentVersion) return null;
45
+ if (compareVersions(latest, currentVersion) > 0) {
46
+ return { updateAvailable: true, latest, current: currentVersion };
47
+ }
48
+ return null;
49
+ } catch {
50
+ return null; // Network error, offline, etc. Never block.
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Print update notice if available. Call at CLI startup, non-blocking.
56
+ */
57
+ export async function printUpdateNotice(currentVersion) {
58
+ const result = await checkForUpdate(currentVersion);
59
+ if (result?.updateAvailable) {
60
+ console.log(`\n Update available: v${result.current} → v${result.latest}`);
61
+ console.log(` Run: npm install -g ${PACKAGE_NAME}\n`);
62
+ }
63
+ }
64
+
65
+ /** Simple semver compare: returns >0 if a > b, <0 if a < b, 0 if equal */
66
+ function compareVersions(a, b) {
67
+ const pa = a.split(".").map(Number);
68
+ const pb = b.split(".").map(Number);
69
+ for (let i = 0; i < 3; i++) {
70
+ if ((pa[i] || 0) > (pb[i] || 0)) return 1;
71
+ if ((pa[i] || 0) < (pb[i] || 0)) return -1;
72
+ }
73
+ return 0;
74
+ }