karajan-code 3.1.0 → 3.3.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.
Files changed (68) hide show
  1. package/README.md +12 -4
  2. package/package.json +6 -1
  3. package/packages/hu-board/package.json +3 -1
  4. package/packages/hu-board/public/index.html +1 -0
  5. package/packages/hu-board/public/styles.css +32 -0
  6. package/packages/hu-board/public/utils/board-view.js +21 -1
  7. package/packages/hu-board/public/utils/formatters.js +68 -0
  8. package/packages/hu-board/public/wiki.html +32 -0
  9. package/packages/hu-board/public/wiki.js +53 -0
  10. package/packages/hu-board/src/db.js +137 -0
  11. package/packages/hu-board/src/format.js +124 -0
  12. package/packages/hu-board/src/plan-mutations.js +4 -4
  13. package/packages/hu-board/src/routes/api.js +16 -1
  14. package/packages/hu-board/src/routes/rag.js +1 -1
  15. package/packages/hu-board/src/routes/wiki.js +43 -0
  16. package/packages/hu-board/src/run-tracker.js +1 -1
  17. package/packages/hu-board/src/server.js +4 -2
  18. package/packages/hu-board/src/sync.js +1 -1
  19. package/scripts/git-hooks/post-merge +13 -2
  20. package/src/agents/aider-agent.js +42 -1
  21. package/src/agents/claude-agent.js +14 -3
  22. package/src/agents/codex-agent.js +44 -7
  23. package/src/agents/gemini-agent.js +37 -1
  24. package/src/agents/opencode-agent.js +34 -1
  25. package/src/audit/ai-slop-findings.js +102 -0
  26. package/src/audit/deterministic-summary.js +18 -0
  27. package/src/brain/standby-scheduler.js +10 -92
  28. package/src/brain/standby-store.js +12 -168
  29. package/src/checks/ai-trash.js +34 -0
  30. package/src/checks/qmd.js +41 -0
  31. package/src/checks/squeezr.js +36 -0
  32. package/src/cli/register-meta.js +21 -0
  33. package/src/cli/register-pipeline.js +3 -0
  34. package/src/commands/audit.js +6 -4
  35. package/src/commands/board.js +34 -0
  36. package/src/commands/doctor.js +6 -0
  37. package/src/commands/init.js +73 -8
  38. package/src/commands/qmd.js +86 -0
  39. package/src/commands/resume.js +6 -45
  40. package/src/commands/run.js +6 -50
  41. package/src/git/hu-snapshot.js +10 -105
  42. package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +27 -0
  43. package/src/orchestrator/drivers/run-hu-batch.js +5 -1
  44. package/src/orchestrator/flow-runner.js +10 -2
  45. package/src/orchestrator/hu-sub-pipeline.js +82 -8
  46. package/src/plan/plan-hu-ops.js +16 -338
  47. package/src/plan/plan-id.js +4 -45
  48. package/src/plan/plan-validation.js +4 -82
  49. package/src/rag/vec-store.js +17 -253
  50. package/src/roles/audit-role.js +11 -1
  51. package/src/session/journal/summary-writer.js +55 -1
  52. package/src/utils/ai-trash.js +48 -0
  53. package/src/utils/atomic-write.js +6 -43
  54. package/src/utils/budget.js +38 -9
  55. package/src/utils/cli-ask-question.js +76 -0
  56. package/src/utils/doc-links.js +2 -0
  57. package/src/utils/os-detect.js +10 -0
  58. package/src/utils/paths.js +14 -123
  59. package/src/utils/port-check.js +6 -45
  60. package/src/utils/process.js +4 -188
  61. package/src/utils/qmd-collection.js +72 -0
  62. package/src/utils/qmd-detect.js +18 -0
  63. package/src/utils/qmd-install.js +37 -0
  64. package/src/utils/run-registry.js +9 -116
  65. package/src/utils/shared-paths.js +12 -41
  66. package/src/utils/squeezr-detect.js +18 -0
  67. package/src/utils/squeezr-install.js +37 -0
  68. package/src/utils/telemetry.js +43 -0
@@ -15,6 +15,8 @@ const ERROR_DOCS = {
15
15
  config_missing: `${BASE_URL}/getting-started/quick-start/`,
16
16
  branch_error: `${BASE_URL}/guides/pipeline/#git-workflow`,
17
17
  rtk_install: `${BASE_URL}/guides/configuration/#rtk`,
18
+ squeezr_install: `${BASE_URL}/guides/configuration/#squeezr`,
19
+ qmd_install: `${BASE_URL}/guides/configuration/#qmd`,
18
20
  };
19
21
 
20
22
  export function docLink(errorType) {
@@ -16,6 +16,16 @@ const INSTALL_COMMANDS = {
16
16
  linux: "curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh && rtk init --global",
17
17
  windows: "Download from https://github.com/rtk-ai/rtk/releases and add to PATH"
18
18
  },
19
+ squeezr: {
20
+ macos: "npm install -g squeezr-ai",
21
+ linux: "npm install -g squeezr-ai",
22
+ windows: "npm install -g squeezr-ai"
23
+ },
24
+ qmd: {
25
+ macos: "npm install -g @tobilu/qmd",
26
+ linux: "npm install -g @tobilu/qmd",
27
+ windows: "npm install -g @tobilu/qmd"
28
+ },
19
29
  claude: {
20
30
  macos: "npm install -g @anthropic-ai/claude-code",
21
31
  linux: "npm install -g @anthropic-ai/claude-code",
@@ -1,123 +1,14 @@
1
- import fs from "node:fs";
2
- import os from "node:os";
3
- import path from "node:path";
4
-
5
- // Per-process vitest root. We memoise the *root* (not the suffixed
6
- // `.karajan` / `.kj` path) so callers with different legacy defaults
7
- // — plan-store wants `.kj`, db.js wants `.karajan` — can share one
8
- // random tmp prefix per test run without colliding.
9
- let _vitestRoot = null;
10
- function vitestRoot() {
11
- if (_vitestRoot) return _vitestRoot;
12
- _vitestRoot = path.join(
13
- os.tmpdir(),
14
- `karajan-vitest-${process.pid}-${Math.random().toString(36).slice(2, 10)}`
15
- );
16
- // KJC-BUG-0075: clean up on clean exit so /tmp doesn't accumulate one
17
- // dir per fork × run. `process.on('exit')` runs synchronously and only
18
- // for graceful exits — SIGKILL leaks survive but are caught by the
19
- // global-setup mtime purge at the start of the next run.
20
- process.on("exit", () => {
21
- try {
22
- fs.rmSync(_vitestRoot, { recursive: true, force: true });
23
- } catch {
24
- // best-effort: never crash a test process on cleanup failure
25
- }
26
- });
27
- return _vitestRoot;
28
- }
29
-
30
- function vitestTmpHome(defaultSegment) {
31
- return path.join(vitestRoot(), defaultSegment);
32
- }
33
-
34
- // One-shot warning so users with KJ_HOME set in their shell rcfile do
35
- // not get spammed once per `kj` invocation. Reset across processes,
36
- // which is the granularity that matters for a humans-reading-stderr
37
- // audience.
38
- let _kjHomeWarned = false;
39
- function emitKjHomeDeprecationWarning() {
40
- if (_kjHomeWarned) return;
41
- _kjHomeWarned = true;
42
- // eslint-disable-next-line no-console
43
- console.warn(
44
- "\x1b[33m[warn]\x1b[0m KJ_HOME is deprecated, rename to KARAJAN_HOME (KJ_HOME will be removed in a future release)"
45
- );
46
- }
47
-
48
- /**
49
- * Unified resolver for Karajan's HOME-level storage root. Used by every
50
- * helper that previously rolled its own `getKjHome()` with subtly
51
- * different defaults and VITEST handling.
52
- *
53
- * Precedence (highest first):
54
- * 1. KARAJAN_HOME env var — explicit, no warning
55
- * 2. KJ_HOME env var — explicit, prints deprecation warning once
56
- * 3. VITEST tmp dir — auto-isolation under `os.tmpdir()/karajan-vitest-<pid>-<rand>/<defaultSegment>`
57
- * 4. `~/<defaultSegment>` — production default
58
- *
59
- * Callers pass `defaultSegment` so we can preserve `.kj` for legacy
60
- * paths (plan-store, standby-store) and `.karajan` for the canonical
61
- * root, until PR 3 unifies the defaults too.
62
- *
63
- * @param {object} [options]
64
- * @param {string} [options.defaultSegment=".karajan"] HOME subdir name
65
- * @returns {string} absolute path
66
- */
67
- export function resolveHome({ defaultSegment = ".karajan" } = {}) {
68
- if (process.env.KARAJAN_HOME) {
69
- return path.resolve(process.env.KARAJAN_HOME);
70
- }
71
- if (process.env.KJ_HOME) {
72
- emitKjHomeDeprecationWarning();
73
- return path.resolve(process.env.KJ_HOME);
74
- }
75
- if (process.env.VITEST) {
76
- return vitestTmpHome(defaultSegment);
77
- }
78
- return path.join(os.homedir(), defaultSegment);
79
- }
80
-
81
- /**
82
- * Canonical Karajan home (`~/.karajan` by default). Every helper that
83
- * used to roll its own `getKjHome()` now goes through this one — see
84
- * KJC-PCS-0047 PR 3 for the consolidation.
85
- */
86
- export function getKarajanHome() {
87
- return resolveHome({ defaultSegment: ".karajan" });
88
- }
89
-
90
- // Test-only export. Tests that depend on observing the deprecation
91
- // warning being emitted exactly once need to reset the latch between
92
- // cases.
93
- export function __resetKjHomeWarningForTests() {
94
- _kjHomeWarned = false;
95
- }
96
-
97
- export function getSessionRoot() {
98
- return path.join(getKarajanHome(), "sessions");
99
- }
100
-
101
- export function getSonarComposePath() {
102
- return path.join(getKarajanHome(), "docker-compose.sonar.yml");
103
- }
104
-
105
- /** Ollama RAG embedder compose: `<karajan-home>/docker-compose.ollama.yml` — KJC-TSK-0435. */
106
- export function getOllamaComposePath() {
107
- return path.join(getKarajanHome(), "docker-compose.ollama.yml");
108
- }
109
-
110
- /** Webperf cache: `<karajan-home>/webperf/` — KJC-TSK-0420. */
111
- export function getWebperfDir() {
112
- return path.join(getKarajanHome(), "webperf");
113
- }
114
-
115
- /** Run-registry: `<karajan-home>/runs/` — KJC-TSK-0420. */
116
- export function getRunsDir() {
117
- return path.join(getKarajanHome(), "runs");
118
- }
119
-
120
- /** Board prompt bridge: `<karajan-home>/prompts/` — KJC-TSK-0420. */
121
- export function getPromptsDir() {
122
- return path.join(getKarajanHome(), "prompts");
123
- }
1
+ // Shim: paths primitives now live in @karajan/core/paths so the
2
+ // hu-board workspace can consume them without a relative dep on the
3
+ // CLI src tree. KJC-TSK-0511 PR3.
4
+ export {
5
+ resolveHome,
6
+ getKarajanHome,
7
+ __resetKjHomeWarningForTests,
8
+ getSessionRoot,
9
+ getSonarComposePath,
10
+ getOllamaComposePath,
11
+ getWebperfDir,
12
+ getRunsDir,
13
+ getPromptsDir,
14
+ } from "@karajan/core/paths";
@@ -1,45 +1,6 @@
1
- /**
2
- * TCP port availability helpers.
3
- *
4
- * Originally lived in packages/hu-board/src/server.js. Moved here so the
5
- * doctor/preflight subsystem (src/checks/) and hu-board share a single
6
- * implementation.
7
- */
8
-
9
- import { createServer } from "node:net";
10
-
11
- /**
12
- * Check whether a TCP port is available to bind on localhost.
13
- *
14
- * @param {number} port
15
- * @returns {Promise<boolean>}
16
- */
17
- export function isPortAvailable(port) {
18
- return new Promise((resolve) => {
19
- const server = createServer();
20
- server.once("error", () => resolve(false));
21
- server.once("listening", () => {
22
- server.close();
23
- resolve(true);
24
- });
25
- server.listen(port);
26
- });
27
- }
28
-
29
- /**
30
- * Find the first available port starting from `startPort`.
31
- *
32
- * @param {number} startPort - first port to try
33
- * @param {number} [maxAttempts=11] - how many consecutive ports to probe
34
- * @param {(port: number) => void} [onBusy] - called for each busy port seen
35
- * @returns {Promise<number>} - resolves with the first free port found
36
- * @throws {Error} if no port is free within the range
37
- */
38
- export async function findAvailablePort(startPort, maxAttempts = 11, onBusy) {
39
- for (let i = 0; i < maxAttempts; i++) {
40
- const port = startPort + i;
41
- if (await isPortAvailable(port)) return port;
42
- if (onBusy) onBusy(port);
43
- }
44
- throw new Error(`No available port found between ${startPort} and ${startPort + maxAttempts - 1}`);
45
- }
1
+ // Shim — the real implementation now lives in @karajan/core/port-check.
2
+ // Kept here so CLI callers (src/**/*.js) keep importing
3
+ // "../utils/port-check.js" without churn during KJC-TSK-0511 (decouple
4
+ // @karajan/hu-board). Will be retired once every consumer migrates to
5
+ // the @karajan/core import.
6
+ export { isPortAvailable, findAvailablePort } from "@karajan/core/port-check";
@@ -1,188 +1,4 @@
1
- import { execa } from "execa";
2
-
3
- const isWin = process.platform === "win32";
4
- const KILL_SIGNAL = isWin ? "SIGTERM" : "SIGKILL";
5
-
6
- function buildSilenceKilledResult(silenceTimeoutMs, stdout, signal) {
7
- return {
8
- exitCode: 143,
9
- stdout,
10
- stderr: `Command killed after ${Number(silenceTimeoutMs)}ms without output`,
11
- timedOut: true,
12
- signal: signal || KILL_SIGNAL
13
- };
14
- }
15
-
16
- function setupOutputStreaming(subprocess, onOutput, partialOutputFlushMs) {
17
- const flushMs = Number(partialOutputFlushMs) > 0 ? Number(partialOutputFlushMs) : 2000;
18
- const streams = {};
19
- const makeHandler = (stream) => {
20
- const state = { partial: "", dirty: false };
21
- streams[stream] = state;
22
- return (chunk) => {
23
- state.partial += chunk.toString();
24
- const lines = state.partial.split(/\r\n|\n|\r/);
25
- state.partial = lines.pop() ?? "";
26
- state.dirty = state.partial.length > 0;
27
- for (const line of lines) {
28
- if (line) onOutput({ stream, line });
29
- }
30
- };
31
- };
32
-
33
- const flushPartials = () => {
34
- for (const [stream, state] of Object.entries(streams)) {
35
- if (!state.dirty || !state.partial) continue;
36
- onOutput({ stream, line: state.partial });
37
- state.partial = "";
38
- state.dirty = false;
39
- }
40
- };
41
-
42
- if (subprocess.stdout) subprocess.stdout.on("data", makeHandler("stdout"));
43
- if (subprocess.stderr) subprocess.stderr.on("data", makeHandler("stderr"));
44
- const flushInterval = setInterval(flushPartials, flushMs);
45
- flushInterval.unref?.();
46
-
47
- subprocess.finally(() => {
48
- flushPartials();
49
- clearInterval(flushInterval);
50
- });
51
- }
52
-
53
- async function awaitWithTimeout(subprocess, timeout, getStdout) {
54
- let timer = null;
55
- const timeoutResult = new Promise((resolve) => {
56
- timer = setTimeout(() => {
57
- try {
58
- subprocess.kill(KILL_SIGNAL, { forceKillAfterDelay: 1000 });
59
- } catch {
60
- // no-op
61
- }
62
- resolve({
63
- exitCode: 143,
64
- stdout: getStdout(),
65
- stderr: `Command timed out after ${timeout}ms`,
66
- timedOut: true,
67
- signal: KILL_SIGNAL
68
- });
69
- }, timeout);
70
- });
71
-
72
- const result = await Promise.race([subprocess, timeoutResult]);
73
- if (timer) clearTimeout(timer);
74
- return result;
75
- }
76
-
77
- export async function runCommand(command, args = [], options = {}) {
78
- const { timeout, onOutput, silenceTimeoutMs, partialOutputFlushMs, ...rest } = options;
79
- // Detach stdin unless the caller provides input (e.g. Codex uses input: prompt).
80
- // Without this, a subprocess prompting for input hangs forever since there is no TTY.
81
- if (!rest.stdin && !rest.input) rest.stdin = "ignore";
82
- const subprocess = execa(command, args, { reject: false, ...rest });
83
-
84
- let stdoutAccum = "";
85
- let stderrAccum = "";
86
- let outputSilenceTimer = null;
87
- let silenceTimedOut = false;
88
-
89
- function clearSilenceTimer() {
90
- if (outputSilenceTimer) {
91
- clearTimeout(outputSilenceTimer);
92
- outputSilenceTimer = null;
93
- }
94
- }
95
-
96
- function armSilenceTimer() {
97
- const ms = Number(silenceTimeoutMs);
98
- if (!Number.isFinite(ms) || ms <= 0 || silenceTimedOut) return;
99
- clearSilenceTimer();
100
- outputSilenceTimer = setTimeout(() => {
101
- silenceTimedOut = true;
102
- try {
103
- subprocess.kill(KILL_SIGNAL, { forceKillAfterDelay: 1000 });
104
- } catch {
105
- // no-op
106
- }
107
- }, ms);
108
- }
109
-
110
- if (subprocess.stdout) {
111
- subprocess.stdout.on("data", (chunk) => {
112
- stdoutAccum += chunk.toString();
113
- armSilenceTimer();
114
- });
115
- }
116
- if (subprocess.stderr) {
117
- subprocess.stderr.on("data", (chunk) => {
118
- stderrAccum += chunk.toString();
119
- armSilenceTimer();
120
- });
121
- }
122
-
123
- if (onOutput) {
124
- setupOutputStreaming(subprocess, onOutput, partialOutputFlushMs);
125
- }
126
- armSilenceTimer();
127
-
128
- try {
129
- const result = timeout
130
- ? await awaitWithTimeout(subprocess, timeout, () => stdoutAccum)
131
- : await subprocess;
132
- clearSilenceTimer();
133
- if (silenceTimedOut) return buildSilenceKilledResult(silenceTimeoutMs, stdoutAccum);
134
- return enrichResult(result, stdoutAccum, stderrAccum);
135
- } catch (error) {
136
- clearSilenceTimer();
137
- if (silenceTimedOut) return buildSilenceKilledResult(silenceTimeoutMs, error?.stdout || stdoutAccum, error?.signal);
138
-
139
- const details = [error?.shortMessage, error?.originalMessage, error?.stderr, error?.stdout, error?.message]
140
- .filter(Boolean)
141
- .join("\n");
142
-
143
- return {
144
- exitCode: 1,
145
- stdout: error?.stdout || stdoutAccum,
146
- stderr: details || String(error),
147
- signal: error?.signal || null
148
- };
149
- }
150
- }
151
-
152
- function enrichResult(result, stdoutAccum, _stderrAccum) {
153
- if (result.timedOut) return result;
154
-
155
- // execa with reject:false RESOLVES (does not throw) on a spawn
156
- // failure — ENOENT (binary not in PATH), EACCES, etc. The result
157
- // then carries exitCode null/undefined, an empty stderr, and the
158
- // real cause only in `code` / `shortMessage`. Surface it: a missing
159
- // agent CLI used to fail with a completely empty error message,
160
- // leaving the user with "coder failed" and no idea what to install.
161
- if (result.failed && result.exitCode == null && !result.signal) {
162
- const reason = result.shortMessage || result.message
163
- || `spawn failed${result.code ? ` (${result.code})` : ""}`;
164
- return {
165
- ...result,
166
- exitCode: 1,
167
- stdout: result.stdout || stdoutAccum,
168
- stderr: result.stderr || reason,
169
- spawnError: result.code || true,
170
- };
171
- }
172
-
173
- const killed = result.killed || !!result.signal;
174
- const signal = result.signal || null;
175
-
176
- if (killed && !result.stderr) {
177
- return {
178
- ...result,
179
- stdout: result.stdout || stdoutAccum,
180
- stderr: signal
181
- ? `Process killed by signal ${signal}`
182
- : "Process was killed externally",
183
- signal
184
- };
185
- }
186
-
187
- return result;
188
- }
1
+ // Shim: process runner now lives in @karajan/core/process so the
2
+ // hu-board workspace can consume it without a relative dep on the CLI
3
+ // src tree. KJC-TSK-0511 PR6.
4
+ export { runCommand } from "@karajan/core/process";
@@ -0,0 +1,72 @@
1
+ import path from "node:path";
2
+ import { existsSync, statSync } from "node:fs";
3
+ import { runCommand } from "./process.js";
4
+
5
+ export const QMD_DEFAULT_PATHS = ["docs", ".reviews", ".karajan/plans"];
6
+
7
+ export function projectSlug(projectDir) {
8
+ const base = path.basename(path.resolve(projectDir));
9
+ return base.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "project";
10
+ }
11
+
12
+ export function planQmdCollections(projectDir) {
13
+ const slug = projectSlug(projectDir);
14
+ const result = [];
15
+ for (const rel of QMD_DEFAULT_PATHS) {
16
+ const abs = path.join(projectDir, rel);
17
+ if (!existsSync(abs)) continue;
18
+ try { if (!statSync(abs).isDirectory()) continue; } catch { continue; }
19
+ const suffix = rel.replace(/^\./, "").replace(/[/\\]+/g, "-");
20
+ result.push({ name: `${slug}-${suffix}`, path: abs });
21
+ }
22
+ return result;
23
+ }
24
+
25
+ async function listExistingCollections() {
26
+ try {
27
+ const res = await runCommand("qmd", ["collection", "list"]);
28
+ if (res.exitCode !== 0) return new Set();
29
+ const names = new Set();
30
+ for (const line of (res.stdout || "").split("\n")) {
31
+ const m = line.match(/^([\w.-]+)\s+\(qmd:\/\//);
32
+ if (m) names.add(m[1]);
33
+ }
34
+ return names;
35
+ } catch { return new Set(); }
36
+ }
37
+
38
+ /**
39
+ * Register all default QMD collections for the project. Idempotent: skips
40
+ * collections already present in `qmd collection list`. qmd's CLI only
41
+ * accepts one path per `collection add`, so we register up to 3 sibling
42
+ * collections (`<slug>-docs`, `<slug>-reviews`, `<slug>-karajan-plans`).
43
+ */
44
+ export async function registerQmdCollections(projectDir, logger) {
45
+ const planned = planQmdCollections(projectDir);
46
+ const result = { ok: true, added: [], skipped: [], errors: [] };
47
+ if (planned.length === 0) {
48
+ logger.info("QMD: no indexable folders yet (docs/, .reviews/, .karajan/plans/ all missing).");
49
+ return result;
50
+ }
51
+ const existing = await listExistingCollections();
52
+ for (const { name, path: abs } of planned) {
53
+ if (existing.has(name)) { result.skipped.push(name); continue; }
54
+ try {
55
+ const res = await runCommand("qmd", ["collection", "add", abs, "--name", name], { timeout: 60_000 });
56
+ if (res.exitCode === 0) {
57
+ result.added.push(name);
58
+ } else {
59
+ const err = (res.stderr || res.stdout || "").trim() || `exit code ${res.exitCode}`;
60
+ result.errors.push(`${name}: ${err}`);
61
+ result.ok = false;
62
+ }
63
+ } catch (err) {
64
+ result.errors.push(`${name}: ${err.message || String(err)}`);
65
+ result.ok = false;
66
+ }
67
+ }
68
+ if (result.added.length) logger.info(`QMD: registered ${result.added.length} collection(s): ${result.added.join(", ")}.`);
69
+ if (result.skipped.length) logger.info(`QMD: ${result.skipped.length} collection(s) already present, skipped.`);
70
+ for (const err of result.errors) logger.warn(`QMD: collection registration failed — ${err}`);
71
+ return result;
72
+ }
@@ -0,0 +1,18 @@
1
+ import { runCommand } from "./process.js";
2
+
3
+ /**
4
+ * Detect whether QMD is installed and available.
5
+ * @returns {Promise<{ available: boolean, version: string|null }>}
6
+ */
7
+ export async function detectQmd() {
8
+ try {
9
+ const result = await runCommand("qmd", ["--version"]);
10
+ if (result.exitCode === 0) {
11
+ const version = (result.stdout || "").trim() || null;
12
+ return { available: true, version };
13
+ }
14
+ return { available: false, version: null };
15
+ } catch { /* qmd binary not found */
16
+ return { available: false, version: null };
17
+ }
18
+ }
@@ -0,0 +1,37 @@
1
+ import { runCommand } from "./process.js";
2
+ import { getInstallCommand } from "./os-detect.js";
3
+ import { detectQmd } from "./qmd-detect.js";
4
+
5
+ /**
6
+ * Install QMD using the OS-appropriate command.
7
+ * @param {object} logger - Logger with info/warn methods
8
+ * @returns {Promise<{ ok: boolean, version: string|null, error: string|null }>}
9
+ */
10
+ export async function installQmd(logger) {
11
+ const command = getInstallCommand("qmd");
12
+
13
+ logger.info("Installing QMD...");
14
+
15
+ try {
16
+ const result = await runCommand("sh", ["-c", command], { timeout: 120_000 });
17
+
18
+ if (result.exitCode !== 0) {
19
+ const error = (result.stderr || "").trim() || `exit code ${result.exitCode}`;
20
+ logger.warn(`QMD install failed: ${error}`);
21
+ return { ok: false, version: null, error };
22
+ }
23
+
24
+ const check = await detectQmd();
25
+ if (check.available) {
26
+ logger.info(`QMD ${check.version || ""} installed successfully.`);
27
+ return { ok: true, version: check.version, error: null };
28
+ }
29
+
30
+ logger.warn("QMD install command succeeded but qmd binary not found in PATH.");
31
+ return { ok: false, version: null, error: "Binary not found after install" };
32
+ } catch (err) {
33
+ const error = err.message || String(err);
34
+ logger.warn(`QMD install failed: ${error}`);
35
+ return { ok: false, version: null, error };
36
+ }
37
+ }
@@ -1,116 +1,9 @@
1
- /**
2
- * Run registry — registro persistente de `kj run` activos.
3
- *
4
- * KJC-TSK-0396 (extensión): el HU Board necesita ver runs lanzados desde
5
- * la terminal externa (no solo los lanzados desde el propio board). Para
6
- * eso, cada `kj run` escribe su PID + planId + huIds + projectDir a un
7
- * archivo en `~/.karajan/runs/<runId>.json` al arrancar, y lo borra al
8
- * terminar (success o crash con cleanup vía finally).
9
- *
10
- * El board lee ese directorio en startup + cada vez que sirve el endpoint
11
- * `/api/runs/:planId/active`, descartando los registros con PID muerto
12
- * (filtrado con `process.kill(pid, 0)`). Resultado: bidireccionalidad
13
- * total terminal ↔ board.
14
- *
15
- * Compartido entre el CLI (src/commands/run.js) y el board
16
- * (packages/hu-board/src/run-tracker.js). Vive en src/utils/ porque
17
- * el board ya importa cosas de src/ sin problema.
18
- */
19
-
20
- import fs from "node:fs";
21
- import path from "node:path";
22
- import { writeJsonAtomicSync } from "./atomic-write.js";
23
- import { getRunsDir } from "./paths.js";
24
-
25
- /**
26
- * Directorio raíz donde viven los registros de run.
27
- * Delega en `getRunsDir()` (KJC-TSK-0420) — respeta KARAJAN_HOME,
28
- * KJ_HOME (con warning de deprecación), VITEST tmp y default.
29
- */
30
- export function runsDir() {
31
- return getRunsDir();
32
- }
33
-
34
- /**
35
- * Registra un nuevo run en disco. El runId es derivado del PID + timestamp
36
- * para ser único por proceso.
37
- *
38
- * @param {{ pid: number, planId?: string|null, huIds?: string[]|null, projectDir?: string|null, source?: string }} info
39
- * @returns {string|null} runId o null si la escritura falló (no bloquea al caller).
40
- */
41
- export function registerRun(info = {}) {
42
- if (!info?.pid) return null;
43
- try {
44
- fs.mkdirSync(runsDir(), { recursive: true });
45
- // Sufijo random para evitar colisiones cuando 2 runs del mismo PID
46
- // se registran en el mismo ms (raro en CLI, posible en tests).
47
- const runId = `run-${Date.now()}-${info.pid}-${Math.random().toString(36).slice(2, 8)}`;
48
- const filePath = path.join(runsDir(), `${runId}.json`);
49
- const entry = {
50
- runId,
51
- pid: info.pid,
52
- planId: info.planId || null,
53
- huIds: Array.isArray(info.huIds) ? info.huIds : null,
54
- projectDir: info.projectDir || null,
55
- source: info.source || "cli",
56
- startedAt: new Date().toISOString(),
57
- };
58
- writeJsonAtomicSync(filePath, entry);
59
- return runId;
60
- } catch {
61
- return null;
62
- }
63
- }
64
-
65
- /**
66
- * Borra el registro de un run. Idempotente — silencia ENOENT.
67
- *
68
- * @param {string} runId
69
- */
70
- export function unregisterRun(runId) {
71
- if (!runId) return;
72
- try {
73
- fs.unlinkSync(path.join(runsDir(), `${runId}.json`));
74
- } catch {
75
- // Idempotente
76
- }
77
- }
78
-
79
- /**
80
- * Lista todos los runs activos en disco, filtrando los con PID muerto.
81
- * Los muertos se borran del FS (auto-cleanup defensivo).
82
- *
83
- * @param {{ planId?: string }} [filter] - si se pasa planId, devuelve solo los runs de ese plan.
84
- * @returns {Array<{runId, pid, planId, huIds, projectDir, source, startedAt}>}
85
- */
86
- export function listActiveRuns(filter = {}) {
87
- const dir = runsDir();
88
- if (!fs.existsSync(dir)) return [];
89
- const out = [];
90
- let files;
91
- try { files = fs.readdirSync(dir); } catch { return []; }
92
- for (const f of files) {
93
- if (!f.endsWith(".json")) continue;
94
- const filePath = path.join(dir, f);
95
- let entry;
96
- try { entry = JSON.parse(fs.readFileSync(filePath, "utf8")); }
97
- catch { try { fs.unlinkSync(filePath); } catch { /* swallow */ } continue; }
98
- if (!isAlive(entry.pid)) {
99
- try { fs.unlinkSync(filePath); } catch { /* swallow */ }
100
- continue;
101
- }
102
- if (filter.planId && entry.planId !== filter.planId) continue;
103
- out.push(entry);
104
- }
105
- return out;
106
- }
107
-
108
- /**
109
- * Verifica si un PID sigue vivo. `process.kill(pid, 0)` NO envía
110
- * señal — solo lanza ESRCH si el proceso no existe.
111
- */
112
- function isAlive(pid) {
113
- if (!pid) return false;
114
- try { process.kill(pid, 0); return true; }
115
- catch (err) { return err.code === "EPERM"; }
116
- }
1
+ // Shim: run-registry now lives in @karajan/core/run-registry so the
2
+ // hu-board workspace can consume it without a relative dep on the CLI
3
+ // src tree. KJC-TSK-0511 PR3.
4
+ export {
5
+ runsDir,
6
+ registerRun,
7
+ unregisterRun,
8
+ listActiveRuns,
9
+ } from "@karajan/core/run-registry";