karajan-code 2.5.0 → 2.6.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 (109) hide show
  1. package/README.md +20 -4
  2. package/package.json +5 -2
  3. package/src/agents/aider-agent.js +1 -2
  4. package/src/agents/base-agent.js +63 -1
  5. package/src/agents/claude-agent.js +3 -4
  6. package/src/agents/codex-agent.js +1 -2
  7. package/src/agents/gemini-agent.js +1 -2
  8. package/src/agents/index.js +11 -2
  9. package/src/agents/opencode-agent.js +1 -2
  10. package/src/brain/decision-tracker.js +123 -0
  11. package/src/brain/decisor.js +243 -0
  12. package/src/brain/solomon-consult.js +163 -0
  13. package/src/budget/comparison.js +96 -0
  14. package/src/checks/binaries.js +123 -0
  15. package/src/checks/ci.js +105 -0
  16. package/src/checks/config-files.js +217 -0
  17. package/src/checks/dir-setup.js +63 -0
  18. package/src/checks/mcp-health.js +157 -0
  19. package/src/checks/node.js +61 -0
  20. package/src/checks/ports.js +110 -0
  21. package/src/checks/rtk.js +36 -0
  22. package/src/checks/runner.js +267 -0
  23. package/src/checks/skills.js +49 -0
  24. package/src/checks/sonar.js +67 -0
  25. package/src/checks/system.js +53 -0
  26. package/src/checks/tokens.js +96 -0
  27. package/src/checks/types.js +105 -0
  28. package/src/cli.js +29 -3
  29. package/src/commands/board.js +23 -0
  30. package/src/commands/doctor.js +130 -362
  31. package/src/commands/resume.js +1 -1
  32. package/src/commands/run.js +2 -1
  33. package/src/commands/skills.js +49 -0
  34. package/src/config/schema.js +222 -0
  35. package/src/config.js +32 -3
  36. package/src/guards/policy-resolver.js +8 -0
  37. package/src/hu/store.js +9 -0
  38. package/src/infrastructure/command-runner.js +44 -0
  39. package/src/infrastructure/environment.js +38 -0
  40. package/src/infrastructure/file-system-service.js +63 -0
  41. package/src/infrastructure/mocks.js +182 -0
  42. package/src/mcp/handlers/management-handlers.js +7 -2
  43. package/src/mcp/tools.js +10 -3
  44. package/src/orchestrator/config-init.js +16 -1
  45. package/src/orchestrator/flow-runner.js +2085 -0
  46. package/src/orchestrator/pipeline-context.js +17 -0
  47. package/src/orchestrator/preflight-checks.js +78 -4
  48. package/src/orchestrator/solomon-escalation.js +21 -0
  49. package/src/orchestrator/stages/stage-executor.js +155 -0
  50. package/src/orchestrator.js +19 -1923
  51. package/src/prompts/architect.js +2 -2
  52. package/src/prompts/coder.js +2 -2
  53. package/src/prompts/prompt-resolver.js +127 -0
  54. package/src/prompts/reviewer.js +2 -2
  55. package/src/roles/architect-role.js +3 -1
  56. package/src/roles/base-role.js +23 -2
  57. package/src/roles/coder-role.js +3 -1
  58. package/src/roles/security-role.js +12 -0
  59. package/src/roles/tester-role.js +11 -0
  60. package/src/session/journal/decisions-writer.js +200 -0
  61. package/src/session/journal/iteration-logger.js +194 -0
  62. package/src/session/journal/summary-writer.js +176 -0
  63. package/src/session/journal/tree-writer.js +164 -0
  64. package/src/skills/semantic-detector.js +147 -0
  65. package/src/skills/skill-detector.js +186 -8
  66. package/src/skills/skill-loader.js +131 -1
  67. package/src/skills/skills-cache.js +128 -0
  68. package/src/types/agent.js +55 -0
  69. package/src/types/config.js +114 -0
  70. package/src/types/hu.js +47 -0
  71. package/src/types/index.js +34 -0
  72. package/src/types/policy.js +43 -0
  73. package/src/types/session.js +74 -0
  74. package/src/types/stage.js +68 -0
  75. package/src/utils/display/event-handlers.js +389 -0
  76. package/src/utils/display/formatters.js +132 -0
  77. package/src/utils/display/header.js +48 -0
  78. package/src/utils/display/session-summary.js +108 -0
  79. package/src/utils/display/solomon.js +42 -0
  80. package/src/utils/port-check.js +45 -0
  81. package/src/utils/port-occupant.js +95 -0
  82. package/src/utils/provider-env.js +116 -0
  83. package/src/utils/with-timeout.js +42 -0
  84. package/templates/roles/architect/anthropic.md +65 -0
  85. package/templates/roles/architect/default.md +63 -0
  86. package/templates/roles/architect/google.md +52 -0
  87. package/templates/roles/architect/openai.md +51 -0
  88. package/templates/roles/architect/opencode.md +50 -0
  89. package/templates/roles/coder/anthropic.md +72 -0
  90. package/templates/roles/coder/default.md +69 -0
  91. package/templates/roles/coder/google.md +78 -0
  92. package/templates/roles/coder/openai.md +59 -0
  93. package/templates/roles/coder/opencode.md +49 -0
  94. package/templates/roles/planner/anthropic.md +50 -0
  95. package/templates/roles/planner/default.md +48 -0
  96. package/templates/roles/planner/google.md +66 -0
  97. package/templates/roles/planner/openai.md +46 -0
  98. package/templates/roles/planner/opencode.md +53 -0
  99. package/templates/roles/reviewer/anthropic.md +61 -0
  100. package/templates/roles/reviewer/default.md +62 -0
  101. package/templates/roles/reviewer/google.md +63 -0
  102. package/templates/roles/reviewer/openai.md +57 -0
  103. package/templates/roles/reviewer/opencode.md +42 -0
  104. package/templates/roles/solomon/anthropic.md +71 -0
  105. package/templates/roles/solomon/default.md +83 -0
  106. package/templates/roles/solomon/google.md +61 -0
  107. package/templates/roles/solomon/openai.md +53 -0
  108. package/templates/roles/solomon/opencode.md +55 -0
  109. package/src/utils/display.js +0 -669
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Ensure ~/.karajan/ directory tree exists. Strategy: auto — creating
3
+ * directories under the user's home is non-invasive.
4
+ */
5
+
6
+ import fs from "node:fs/promises";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ import { STRATEGY } from "./types.js";
10
+
11
+ const REQUIRED_DIRS = [
12
+ "", // ~/.karajan/
13
+ "sessions",
14
+ "skills-cache",
15
+ "logs",
16
+ ];
17
+
18
+ export function createKarajanDirsCheck() {
19
+ return {
20
+ name: "karajan-dirs",
21
+ label: "~/.karajan directory tree",
22
+ strategy: STRATEGY.AUTO,
23
+ describe: "Create missing ~/.karajan subdirectories",
24
+ async detect() {
25
+ const home = os.homedir();
26
+ const root = path.join(home, ".karajan");
27
+ const missing = [];
28
+ for (const sub of REQUIRED_DIRS) {
29
+ const full = sub ? path.join(root, sub) : root;
30
+ try {
31
+ const stat = await fs.stat(full);
32
+ if (!stat.isDirectory()) missing.push(full);
33
+ } catch {
34
+ missing.push(full);
35
+ }
36
+ }
37
+ if (missing.length === 0) {
38
+ return { ok: true, severity: "info", detail: `All ${REQUIRED_DIRS.length} directories present under ${root}` };
39
+ }
40
+ return {
41
+ ok: false,
42
+ severity: "fail",
43
+ detail: `Missing: ${missing.map((m) => path.relative(home, m)).join(", ")}`,
44
+ fix: `mkdir -p ${missing.join(" ")}`,
45
+ extra: { missing },
46
+ };
47
+ },
48
+ async remediate({ extra }) {
49
+ const missing = extra?.missing || [];
50
+ for (const dir of missing) {
51
+ await fs.mkdir(dir, { recursive: true });
52
+ }
53
+ return {
54
+ fixed: true,
55
+ detail: `Created ${missing.length} director${missing.length === 1 ? "y" : "ies"} under ~/.karajan`,
56
+ };
57
+ },
58
+ };
59
+ }
60
+
61
+ export function getDirSetupChecks() {
62
+ return [createKarajanDirsCheck()];
63
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * MCP server health checks.
3
+ *
4
+ * Karajan integrates with several MCP servers — karajan-mcp (stdio), Serena,
5
+ * Sonar MCP, Planning Game MCP, etc. This module verifies they respond to
6
+ * a minimal JSON-RPC `initialize` call within a short deadline.
7
+ *
8
+ * For stdio MCPs that Karajan spawns itself (karajan-mcp), a failed ping is
9
+ * auto-remediable by re-spawning. External MCPs (Serena) are warn-only.
10
+ */
11
+
12
+ import { spawn } from "node:child_process";
13
+ import { fileURLToPath } from "node:url";
14
+ import path from "node:path";
15
+ import { STRATEGY } from "./types.js";
16
+ import { withTimeout, TimeoutError } from "../utils/with-timeout.js";
17
+ import { checkBinary } from "../utils/agent-detect.js";
18
+
19
+ const PING_TIMEOUT_MS = 3000;
20
+
21
+ /**
22
+ * Send a JSON-RPC `initialize` to an MCP server over stdio and wait for
23
+ * any response. Kills the process immediately after.
24
+ *
25
+ * @param {string} command
26
+ * @param {string[]} args
27
+ * @returns {Promise<{ok: boolean, detail: string}>}
28
+ */
29
+ async function pingMcpStdio(command, args) {
30
+ const handshake = () =>
31
+ new Promise((resolve) => {
32
+ let child;
33
+ try {
34
+ child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"] });
35
+ } catch (err) {
36
+ resolve({ ok: false, detail: `spawn failed: ${err.message}` });
37
+ return;
38
+ }
39
+ let settled = false;
40
+ const finish = (ok, detail) => {
41
+ if (settled) return;
42
+ settled = true;
43
+ try {
44
+ child.kill();
45
+ } catch { /* already exited */ }
46
+ resolve({ ok, detail });
47
+ };
48
+ child.on("error", (err) => finish(false, `process error: ${err.message}`));
49
+ child.on("exit", (code) => {
50
+ if (!settled) finish(false, `exited with code ${code} before response`);
51
+ });
52
+ child.stdout.on("data", (buf) => {
53
+ const text = buf.toString();
54
+ // Any JSON-RPC response line counts as alive.
55
+ if (text.includes("\"jsonrpc\"") || text.includes("\"result\"") || text.includes("\"error\"")) {
56
+ finish(true, "Responded to initialize");
57
+ }
58
+ });
59
+ // Send initialize request
60
+ const req = JSON.stringify({
61
+ jsonrpc: "2.0",
62
+ id: 1,
63
+ method: "initialize",
64
+ params: {
65
+ protocolVersion: "2024-11-05",
66
+ clientInfo: { name: "kj-doctor", version: "1.0.0" },
67
+ capabilities: {},
68
+ },
69
+ });
70
+ try {
71
+ child.stdin.write(req + "\n");
72
+ } catch (err) {
73
+ finish(false, `stdin write failed: ${err.message}`);
74
+ }
75
+ });
76
+ try {
77
+ return await withTimeout(handshake(), PING_TIMEOUT_MS, "mcp-ping");
78
+ } catch (err) {
79
+ if (err instanceof TimeoutError) {
80
+ return { ok: false, detail: `No response within ${PING_TIMEOUT_MS}ms` };
81
+ }
82
+ return { ok: false, detail: err.message };
83
+ }
84
+ }
85
+
86
+ /**
87
+ * karajan-mcp health — stdio server bundled with this package.
88
+ * Strategy: auto (we can re-spawn it ourselves).
89
+ */
90
+ export function createKarajanMcpCheck() {
91
+ return {
92
+ name: "mcp:karajan",
93
+ label: "MCP: karajan-mcp",
94
+ strategy: STRATEGY.AUTO,
95
+ describe: "Respawn karajan-mcp stdio process",
96
+ async detect() {
97
+ const binPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../bin/karajan-mcp.js");
98
+ const result = await pingMcpStdio(process.execPath, [binPath]);
99
+ return {
100
+ ok: result.ok,
101
+ severity: result.ok ? "info" : "warn",
102
+ detail: result.ok ? result.detail : `karajan-mcp did not respond: ${result.detail}`,
103
+ fix: result.ok ? undefined : "Re-run karajan-mcp via `node bin/karajan-mcp.js`",
104
+ extra: { binPath },
105
+ };
106
+ },
107
+ async remediate({ extra }) {
108
+ // Spawn once detached so the MCP client can pick it up for subsequent runs.
109
+ // This is idempotent: if one instance is already listening, another will
110
+ // coexist over a different pipe.
111
+ try {
112
+ spawn(process.execPath, [extra.binPath], { detached: true, stdio: "ignore" }).unref();
113
+ return { fixed: true, detail: "Re-spawned karajan-mcp in background" };
114
+ } catch (err) {
115
+ return { fixed: false, detail: `Failed to respawn: ${err.message}` };
116
+ }
117
+ },
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Serena MCP — external tool the user installs themselves. Warn only when
123
+ * enabled in config. Strategy: manual (we don't manage its lifecycle).
124
+ */
125
+ export function createSerenaMcpCheck() {
126
+ return {
127
+ name: "mcp:serena",
128
+ label: "MCP: serena",
129
+ strategy: STRATEGY.MANUAL,
130
+ applies: (config) => config?.serena?.enabled === true,
131
+ async detect() {
132
+ const bin = await checkBinary("serena");
133
+ if (!bin.ok) {
134
+ return {
135
+ ok: false,
136
+ severity: "warn",
137
+ detail: "serena binary not on PATH",
138
+ fix: "Install Serena: uvx --from git+https://github.com/oraios/serena serena",
139
+ };
140
+ }
141
+ const result = await pingMcpStdio("serena", ["start", "--mcp"]);
142
+ return {
143
+ ok: result.ok,
144
+ severity: result.ok ? "info" : "warn",
145
+ detail: result.ok ? "Serena responded to initialize" : `No response: ${result.detail}`,
146
+ fix: result.ok ? undefined : "Restart Serena manually and verify connectivity.",
147
+ };
148
+ },
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Aggregate of MCP health checks. Only enabled servers run their ping.
154
+ */
155
+ export function getMcpHealthChecks() {
156
+ return [createKarajanMcpCheck(), createSerenaMcpCheck()];
157
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Node.js runtime version check. Requires Node >= 20 (minimum version that
3
+ * ships structuredClone, Array.prototype.findLast, AbortSignal.timeout, and
4
+ * stable fetch). Strategy: manual — we cannot auto-upgrade the runtime.
5
+ */
6
+
7
+ import { STRATEGY } from "./types.js";
8
+
9
+ export const MIN_NODE_MAJOR = 20;
10
+
11
+ /**
12
+ * Accepts a version string in the form "v20.12.1" or "20.12.1" and returns
13
+ * an object { major, minor, patch } or null when the shape is invalid.
14
+ */
15
+ export function parseNodeVersion(versionString) {
16
+ const str = String(versionString || "").trim();
17
+ const m = str.match(/^v?(\d+)\.(\d+)\.(\d+)/);
18
+ if (!m) return null;
19
+ return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) };
20
+ }
21
+
22
+ /**
23
+ * Create the Node version check. Accepts an optional override of the
24
+ * reported version (useful in tests).
25
+ *
26
+ * @param {{ version?: string }} [opts]
27
+ * @returns {import("./types.js").Check}
28
+ */
29
+ export function createNodeVersionCheck(opts = {}) {
30
+ return {
31
+ name: "node-version",
32
+ label: "Node.js runtime",
33
+ strategy: STRATEGY.MANUAL,
34
+ async detect() {
35
+ const raw = opts.version ?? process.env.KJ_FORCE_NODE_VERSION ?? process.version;
36
+ const parsed = parseNodeVersion(raw);
37
+ if (!parsed) {
38
+ return {
39
+ ok: false,
40
+ severity: "fail",
41
+ detail: `Unrecognized Node version: ${raw}`,
42
+ fix: `Install Node ${MIN_NODE_MAJOR}+ from https://nodejs.org/ (current: ${raw}).`,
43
+ };
44
+ }
45
+ if (parsed.major < MIN_NODE_MAJOR) {
46
+ return {
47
+ ok: false,
48
+ severity: "fail",
49
+ detail: `Node ${raw} detected, requires >=${MIN_NODE_MAJOR}`,
50
+ fix: `Upgrade Node to ${MIN_NODE_MAJOR}+ (nvm install ${MIN_NODE_MAJOR} && nvm use ${MIN_NODE_MAJOR}, or brew upgrade node).`,
51
+ extra: { detected: parsed, required: MIN_NODE_MAJOR },
52
+ };
53
+ }
54
+ return { ok: true, severity: "info", detail: `${raw} (>= ${MIN_NODE_MAJOR} required)` };
55
+ },
56
+ };
57
+ }
58
+
59
+ export function getNodeChecks() {
60
+ return [createNodeVersionCheck()];
61
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Port availability checks for services Karajan controls.
3
+ *
4
+ * - SonarQube port (default 9000): the Docker container binds a fixed port,
5
+ * so if it's occupied by a non-Karajan process we cannot "move" it; we
6
+ * report FAIL with the occupant PID/command to help the user stop the
7
+ * conflicting process. Strategy: manual.
8
+ *
9
+ * - HU Board port (default 4000): an in-process Node server we can rebind
10
+ * to any free port in runtime overrides. Strategy: auto.
11
+ */
12
+
13
+ import { isPortAvailable, findAvailablePort } from "../utils/port-check.js";
14
+ import { getPortOccupant } from "../utils/port-occupant.js";
15
+ import { STRATEGY } from "./types.js";
16
+
17
+ const DEFAULT_SONAR_PORT = 9000;
18
+ const DEFAULT_HU_BOARD_PORT = 4000;
19
+
20
+ function extractPortFromHost(host, fallback) {
21
+ const m = String(host || "").match(/:(\d+)(?:\/|$)/);
22
+ return m ? Number(m[1]) : fallback;
23
+ }
24
+
25
+ /**
26
+ * Whether the occupant looks like the Karajan-managed Sonar container.
27
+ * Docker Desktop's proxy is typically `com.docker.backend` / `com.docker.vmnetd`
28
+ * on macOS and `docker-proxy` on Linux. We treat those as OK.
29
+ */
30
+ function isKarajanSonarOccupant(occupant) {
31
+ if (!occupant?.command) return false;
32
+ const cmd = occupant.command.toLowerCase();
33
+ return cmd.includes("docker") || cmd.includes("sonar");
34
+ }
35
+
36
+ export function createSonarPortCheck() {
37
+ return {
38
+ name: "port:sonar",
39
+ label: "SonarQube port",
40
+ strategy: STRATEGY.MANUAL,
41
+ applies: (config) => config?.sonarqube?.enabled !== false,
42
+ async detect({ config }) {
43
+ const port = extractPortFromHost(config?.sonarqube?.host, DEFAULT_SONAR_PORT);
44
+ const free = await isPortAvailable(port);
45
+ if (free) {
46
+ return { ok: true, severity: "info", detail: `Port ${port} is free (Sonar container not yet running)` };
47
+ }
48
+ // Busy — is it our container?
49
+ const occupant = await getPortOccupant(port);
50
+ if (occupant && isKarajanSonarOccupant(occupant)) {
51
+ return {
52
+ ok: true,
53
+ severity: "info",
54
+ detail: `Port ${port} held by Karajan-managed Sonar (${occupant.command} PID ${occupant.pid})`,
55
+ };
56
+ }
57
+ const who = occupant
58
+ ? `${occupant.command || "unknown"} (PID ${occupant.pid ?? "?"})`
59
+ : "unknown process";
60
+ return {
61
+ ok: false,
62
+ severity: "fail",
63
+ detail: `Port ${port} occupied by ${who}`,
64
+ fix: `Stop the process holding port ${port} or change sonarqube.host in kj.config.yml.`,
65
+ extra: { port, occupant },
66
+ };
67
+ },
68
+ };
69
+ }
70
+
71
+ export function createHuBoardPortCheck() {
72
+ return {
73
+ name: "port:hu-board",
74
+ label: "HU Board port",
75
+ strategy: STRATEGY.AUTO,
76
+ applies: (config) => config?.hu_board?.enabled !== false,
77
+ describe: "Rebind HU Board to the next free port",
78
+ async detect({ config }) {
79
+ const port = config?.hu_board?.port || DEFAULT_HU_BOARD_PORT;
80
+ const free = await isPortAvailable(port);
81
+ if (free) {
82
+ return { ok: true, severity: "info", detail: `Port ${port} is free` };
83
+ }
84
+ const occupant = await getPortOccupant(port);
85
+ const who = occupant
86
+ ? `${occupant.command || "unknown"} (PID ${occupant.pid ?? "?"})`
87
+ : "unknown process";
88
+ return {
89
+ ok: false,
90
+ severity: "warn",
91
+ detail: `Port ${port} occupied by ${who}`,
92
+ fix: `Free port ${port} or set hu_board.port in kj.config.yml`,
93
+ extra: { port, occupant },
94
+ };
95
+ },
96
+ async remediate({ extra }) {
97
+ const start = (extra?.port ?? DEFAULT_HU_BOARD_PORT) + 1;
98
+ const found = await findAvailablePort(start, 20);
99
+ return {
100
+ fixed: true,
101
+ detail: `Rebound HU Board to port ${found} (in-memory override)`,
102
+ changes: { hu_board: { port: found } },
103
+ };
104
+ },
105
+ };
106
+ }
107
+
108
+ export function getPortChecks() {
109
+ return [createSonarPortCheck(), createHuBoardPortCheck()];
110
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * RTK (Rust Token Killer) availability check. Manual strategy — installation
3
+ * depends on the host platform and is handled by getInstallCommand().
4
+ */
5
+
6
+ import { runCommand } from "../utils/process.js";
7
+ import { getInstallCommand } from "../utils/os-detect.js";
8
+ import { withDocLink } from "../utils/doc-links.js";
9
+ import { STRATEGY } from "./types.js";
10
+
11
+ export function createRtkCheck() {
12
+ return {
13
+ name: "rtk",
14
+ label: "RTK (Rust Token Killer)",
15
+ strategy: STRATEGY.MANUAL,
16
+ async detect() {
17
+ try {
18
+ const res = await runCommand("rtk", ["--version"]);
19
+ if (res.exitCode === 0) {
20
+ return { ok: true, severity: "info", detail: `${res.stdout.trim()} — token savings active` };
21
+ }
22
+ } catch { /* not installed */ }
23
+ const installCmd = getInstallCommand("rtk");
24
+ return {
25
+ ok: false,
26
+ severity: "warn",
27
+ detail: "Not found — 60-90% token savings available",
28
+ fix: withDocLink(`Install: ${installCmd}`, "rtk_install"),
29
+ };
30
+ },
31
+ };
32
+ }
33
+
34
+ export function getRtkChecks() {
35
+ return [createRtkCheck()];
36
+ }
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Check runner. Orchestrates the detect → remediate → re-verify phases for
3
+ * a list of Check objects.
4
+ *
5
+ * Phase 1 (detect): executed in parallel with per-check timeout. Timeouts
6
+ * are reported as TIMEOUT status (WARN-level), never throw.
7
+ *
8
+ * Phase 2 (remediate): sequential, only for failures with strategy auto or
9
+ * prompt (and user consent for prompt). Skipped entirely when mode is
10
+ * "check-only". Each remediation has its own timeout (5s default, 60s for
11
+ * prompted actions like npm install).
12
+ *
13
+ * Phase 3 (re-verify): re-runs `detect` once for each check that was
14
+ * remediated, to confirm the fix actually worked. If re-verify fails, the
15
+ * status is FAIL (remediation lied).
16
+ *
17
+ * Runtime overrides returned by remediations are merged into `runReport.overrides`
18
+ * for consumers (e.g. preflight → orchestrator) to apply to the active session.
19
+ */
20
+
21
+ import { STATUS, STRATEGY } from "./types.js";
22
+ import { withTimeout, TimeoutError } from "../utils/with-timeout.js";
23
+
24
+ const DEFAULT_DETECT_TIMEOUT_MS = 5000;
25
+ const DEFAULT_REMEDIATE_TIMEOUT_MS = 60000;
26
+
27
+ /**
28
+ * @param {import("./types.js").Check[]} checks
29
+ * @param {{ config: Object }} ctx
30
+ * @param {{
31
+ * mode?: "fix"|"check-only",
32
+ * yes?: boolean,
33
+ * onConfirm?: (describe: string) => Promise<boolean>,
34
+ * timeoutMs?: number,
35
+ * remediateTimeoutMs?: number,
36
+ * signal?: AbortSignal,
37
+ * logger?: { info: Function, warn: Function, error: Function }
38
+ * }} [options]
39
+ * @returns {Promise<import("./types.js").RunReport>}
40
+ */
41
+ export async function runChecks(checks, ctx, options = {}) {
42
+ const {
43
+ mode = "fix",
44
+ yes = false,
45
+ onConfirm = defaultOnConfirm,
46
+ timeoutMs = DEFAULT_DETECT_TIMEOUT_MS,
47
+ remediateTimeoutMs = DEFAULT_REMEDIATE_TIMEOUT_MS,
48
+ signal,
49
+ logger = silentLogger(),
50
+ } = options;
51
+
52
+ const { config } = ctx;
53
+ const startTotal = Date.now();
54
+ const entries = [];
55
+ const overrides = {};
56
+
57
+ // Build applicable entries (skipped checks noted up front).
58
+ for (const check of checks) {
59
+ if (typeof check.applies === "function" && !check.applies(config)) {
60
+ entries.push({
61
+ check,
62
+ status: STATUS.SKIPPED,
63
+ detail: "Not applicable for current configuration",
64
+ runMs: 0,
65
+ detectResult: null,
66
+ remediated: false,
67
+ });
68
+ } else {
69
+ entries.push({ check, status: null, detail: null, runMs: 0, detectResult: null, remediated: false });
70
+ }
71
+ }
72
+
73
+ // Phase 1: detect in parallel with timeout.
74
+ const detectBase = Date.now();
75
+ await Promise.all(
76
+ entries.map(async (entry) => {
77
+ if (entry.status === STATUS.SKIPPED) return;
78
+ const start = Date.now();
79
+ try {
80
+ const result = await withTimeout(entry.check.detect({ config, signal }), timeoutMs, entry.check.name);
81
+ entry.detectResult = result;
82
+ entry.runMs = Date.now() - start;
83
+ entry.status = resolveStatusFromDetect(entry.check, result);
84
+ entry.detail = result.detail;
85
+ } catch (err) {
86
+ entry.runMs = Date.now() - start;
87
+ if (err instanceof TimeoutError) {
88
+ entry.detectResult = { ok: false, severity: "warn", detail: `Timeout after ${timeoutMs}ms` };
89
+ entry.status = STATUS.TIMEOUT;
90
+ entry.detail = entry.detectResult.detail;
91
+ } else {
92
+ entry.detectResult = { ok: false, severity: "fail", detail: `Detection error: ${err.message}` };
93
+ entry.status = STATUS.FAIL;
94
+ entry.detail = entry.detectResult.detail;
95
+ }
96
+ }
97
+ })
98
+ );
99
+ logger.info?.(`Detect phase done in ${Date.now() - detectBase}ms`);
100
+
101
+ // Phase 2: remediate sequentially (only in "fix" mode, only failing checks).
102
+ if (mode !== "check-only") {
103
+ for (const entry of entries) {
104
+ if (!shouldAttemptRemediation(entry)) continue;
105
+ const { check, detectResult } = entry;
106
+ if (check.strategy === STRATEGY.PROMPT && !yes) {
107
+ const approved = await onConfirm(check.describe || `Fix: ${check.name}`);
108
+ if (!approved) {
109
+ logger.info?.(`User declined remediation for ${check.name}`);
110
+ continue;
111
+ }
112
+ }
113
+ const start = Date.now();
114
+ try {
115
+ const rem = await withTimeout(
116
+ check.remediate({ config, extra: detectResult.extra, signal, logger }),
117
+ remediateTimeoutMs,
118
+ `${check.name}:remediate`
119
+ );
120
+ entry.remediated = rem.fixed === true;
121
+ entry.runMs += Date.now() - start;
122
+ if (rem.fixed) {
123
+ if (rem.changes) mergeOverrides(overrides, rem.changes);
124
+ entry.detail = rem.detail;
125
+ entry.status = STATUS.FIXED;
126
+ } else {
127
+ entry.detail = rem.detail || entry.detail;
128
+ entry.status = STATUS.FAIL;
129
+ }
130
+ } catch (err) {
131
+ entry.runMs += Date.now() - start;
132
+ const msg = err instanceof TimeoutError ? `Remediation timeout after ${remediateTimeoutMs}ms` : `Remediation error: ${err.message}`;
133
+ entry.detail = msg;
134
+ entry.status = STATUS.FAIL;
135
+ logger.warn?.(`Remediation failed for ${check.name}: ${msg}`);
136
+ }
137
+ }
138
+
139
+ // Phase 3: re-verify fixed checks using the config merged with any
140
+ // runtime overrides applied by remediations. Without this merge, a check
141
+ // that rebinds to a new port (override) would re-verify against the old
142
+ // config and wrongly report failure.
143
+ const verifyConfig = mergeConfigAndOverrides(config, overrides);
144
+ await Promise.all(
145
+ entries.filter((e) => e.remediated).map(async (entry) => {
146
+ const start = Date.now();
147
+ try {
148
+ const result = await withTimeout(entry.check.detect({ config: verifyConfig, signal }), timeoutMs, `${entry.check.name}:reverify`);
149
+ entry.runMs += Date.now() - start;
150
+ if (!result.ok) {
151
+ entry.status = STATUS.FAIL;
152
+ entry.detail = `${entry.detail} (but re-verify failed: ${result.detail})`;
153
+ }
154
+ } catch (err) {
155
+ entry.runMs += Date.now() - start;
156
+ entry.status = STATUS.FAIL;
157
+ entry.detail = `${entry.detail} (re-verify error: ${err.message})`;
158
+ }
159
+ })
160
+ );
161
+ }
162
+
163
+ // Flatten into reports.
164
+ const reports = entries.map((e) => ({
165
+ name: e.check.name,
166
+ label: e.check.label,
167
+ status: e.status,
168
+ strategy: e.check.strategy,
169
+ detail: e.detail ?? "",
170
+ fix: e.detectResult?.fix,
171
+ runMs: e.runMs,
172
+ }));
173
+
174
+ const summary = summarize(reports);
175
+
176
+ return {
177
+ checks: reports,
178
+ overrides,
179
+ totalMs: Date.now() - startTotal,
180
+ summary,
181
+ };
182
+ }
183
+
184
+ function shouldAttemptRemediation(entry) {
185
+ if (entry.status === STATUS.SKIPPED) return false;
186
+ if (entry.status === STATUS.OK) return false;
187
+ if (entry.status === STATUS.TIMEOUT) return false; // don't remediate when detect itself timed out
188
+ const { check, detectResult } = entry;
189
+ if (!check.remediate || !detectResult) return false;
190
+ if (check.strategy === STRATEGY.MANUAL || check.strategy === STRATEGY.NONE) return false;
191
+ // Soft failures (WARN) with PROMPT strategy: don't nag the user for optional issues.
192
+ if (entry.status === STATUS.WARN && check.strategy === STRATEGY.PROMPT) return false;
193
+ return true;
194
+ }
195
+
196
+ function resolveStatusFromDetect(check, result) {
197
+ if (result.ok) return STATUS.OK;
198
+ const severity = result.severity || "fail";
199
+ if (severity === "warn") return STATUS.WARN;
200
+ if (severity === "info") return STATUS.OK; // info is always non-failing
201
+ if (check.strategy === STRATEGY.NONE) return STATUS.WARN;
202
+ return STATUS.FAIL;
203
+ }
204
+
205
+ function summarize(reports) {
206
+ const summary = { ok: 0, fixed: 0, warn: 0, fail: 0, skipped: 0, timeout: 0 };
207
+ for (const r of reports) {
208
+ const k = r.status.toLowerCase();
209
+ if (k in summary) summary[k]++;
210
+ }
211
+ return summary;
212
+ }
213
+
214
+ /**
215
+ * Deep-merge (shallow for now, one level deep) remediation changes into overrides.
216
+ * Remediation outputs like { hu_board: { port: 4007 } } should not overwrite a
217
+ * sibling key { hu_board: { enabled: true } } that a prior check set.
218
+ */
219
+ function mergeOverrides(target, source) {
220
+ for (const [key, value] of Object.entries(source)) {
221
+ if (value && typeof value === "object" && !Array.isArray(value) && typeof target[key] === "object") {
222
+ Object.assign(target[key], value);
223
+ } else {
224
+ target[key] = value;
225
+ }
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Produce a shallow-merged copy of config + overrides used during re-verify.
231
+ * Does not mutate the original config.
232
+ */
233
+ function mergeConfigAndOverrides(config, overrides) {
234
+ if (!overrides || Object.keys(overrides).length === 0) return config;
235
+ const merged = { ...config };
236
+ for (const [key, value] of Object.entries(overrides)) {
237
+ if (value && typeof value === "object" && !Array.isArray(value) && typeof config[key] === "object") {
238
+ merged[key] = { ...config[key], ...value };
239
+ } else {
240
+ merged[key] = value;
241
+ }
242
+ }
243
+ return merged;
244
+ }
245
+
246
+ function defaultOnConfirm() {
247
+ // Non-interactive by default: refuse prompts unless the caller supplies a real handler.
248
+ return Promise.resolve(false);
249
+ }
250
+
251
+ function silentLogger() {
252
+ return { info() {}, warn() {}, error() {} };
253
+ }
254
+
255
+ /**
256
+ * Flatten a RunReport into the legacy doctor.js shape.
257
+ * OK/FIXED/SKIPPED → ok:true. WARN/FAIL/TIMEOUT → ok:false.
258
+ */
259
+ export function toLegacyShape(runReport) {
260
+ return runReport.checks.map((c) => ({
261
+ name: c.name,
262
+ label: c.label,
263
+ ok: c.status === STATUS.OK || c.status === STATUS.FIXED || c.status === STATUS.SKIPPED,
264
+ detail: c.detail,
265
+ fix: c.fix ?? null,
266
+ }));
267
+ }