karajan-code 2.7.4 → 2.8.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 (165) hide show
  1. package/README.md +1 -1
  2. package/package.json +24 -4
  3. package/scripts/diet-baseline.mjs +170 -0
  4. package/scripts/regen-arch-stats.sh +71 -0
  5. package/src/agents/availability.js +15 -9
  6. package/src/agents/claude-agent.js +143 -45
  7. package/src/agents/model-registry.js +1 -1
  8. package/src/audit/agent-readiness.js +240 -0
  9. package/src/bootstrap.js +26 -1
  10. package/src/brain/decision-tracker.js +3 -1
  11. package/src/brain/decisor.js +1 -1
  12. package/src/canvas/parse-md.js +238 -0
  13. package/src/canvas/schema.js +101 -0
  14. package/src/canvas/to-flat.js +85 -0
  15. package/src/canvas/validate.js +163 -0
  16. package/src/checks/binaries.js +1 -1
  17. package/src/checks/ports.js +85 -31
  18. package/src/checks/sonar.js +1 -1
  19. package/src/checks/system.js +1 -1
  20. package/src/cli/_shared.js +30 -0
  21. package/src/cli/register-meta.js +204 -0
  22. package/src/cli/register-pipeline.js +212 -0
  23. package/src/cli/register-plan.js +152 -0
  24. package/src/cli/register-roles-skills.js +38 -0
  25. package/src/cli/register-sonar.js +28 -0
  26. package/src/cli.js +83 -468
  27. package/src/commands/agents.js +18 -6
  28. package/src/commands/architect.js +29 -2
  29. package/src/commands/audit.js +27 -3
  30. package/src/commands/board.js +160 -30
  31. package/src/commands/clean.js +83 -0
  32. package/src/commands/code.js +7 -2
  33. package/src/commands/discover.js +7 -2
  34. package/src/commands/init.js +2 -2
  35. package/src/commands/plan/_shared.js +40 -0
  36. package/src/commands/plan/add-hu.js +21 -0
  37. package/src/commands/plan/delete.js +9 -0
  38. package/src/commands/plan/generate.js +284 -0
  39. package/src/commands/plan/index.js +14 -0
  40. package/src/commands/plan/list.js +21 -0
  41. package/src/commands/plan/ready.js +46 -0
  42. package/src/commands/plan/remove-hu.js +17 -0
  43. package/src/commands/plan/show.js +81 -0
  44. package/src/commands/plan/validate.js +20 -0
  45. package/src/commands/plan.js +14 -291
  46. package/src/commands/researcher.js +7 -2
  47. package/src/commands/resume.js +24 -1
  48. package/src/commands/review.js +7 -2
  49. package/src/commands/run.js +52 -1
  50. package/src/commands/status.js +1 -1
  51. package/src/commands/sync.js +75 -0
  52. package/src/commands/triage.js +7 -2
  53. package/src/commands/undo.js +1 -1
  54. package/src/config/defaults.js +213 -0
  55. package/src/config/loader.js +137 -0
  56. package/src/config/overrides.js +199 -0
  57. package/src/config/role-resolver.js +159 -0
  58. package/src/config/test-harness.js +79 -0
  59. package/src/config.js +30 -632
  60. package/src/git/automation.js +2 -2
  61. package/src/guards/output-guard.js +0 -2
  62. package/src/hu/acceptance-runner.js +52 -10
  63. package/src/hu/store.js +11 -0
  64. package/src/mcp/handlers/direct-handlers.js +2 -5
  65. package/src/mcp/handlers/management-handlers.js +48 -4
  66. package/src/mcp/handlers/run-handler.js +9 -8
  67. package/src/mcp/preflight.js +32 -13
  68. package/src/mcp/server-handlers.js +4 -3
  69. package/src/mcp/suggest-handler.js +3 -2
  70. package/src/mcp/tools.js +15 -0
  71. package/src/orchestrator/agent-fallback.js +1 -1
  72. package/src/orchestrator/ci-integration.js +10 -10
  73. package/src/orchestrator/config-init.js +25 -7
  74. package/src/orchestrator/direct-actions.js +6 -2
  75. package/src/orchestrator/drivers/error-recovery.js +287 -0
  76. package/src/orchestrator/drivers/init-context.js +222 -0
  77. package/src/orchestrator/drivers/iteration-loop.js +311 -0
  78. package/src/orchestrator/drivers/iteration-phases/coder-and-refactorer.js +48 -0
  79. package/src/orchestrator/drivers/iteration-phases/guards.js +84 -0
  80. package/src/orchestrator/drivers/iteration-phases/handle-approved.js +29 -0
  81. package/src/orchestrator/drivers/iteration-phases/quality-gates.js +76 -0
  82. package/src/orchestrator/drivers/iteration-phases/reviewer-gate.js +60 -0
  83. package/src/orchestrator/drivers/post-loop.js +370 -0
  84. package/src/orchestrator/drivers/pre-loop-phases/auto-hu-batch.js +116 -0
  85. package/src/orchestrator/drivers/pre-loop-phases/config-deprecations.js +47 -0
  86. package/src/orchestrator/drivers/pre-loop-phases/ensure-addyosmani-skills.js +84 -0
  87. package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +268 -0
  88. package/src/orchestrator/drivers/pre-loop.js +434 -0
  89. package/src/orchestrator/drivers/run-hu-batch.js +367 -0
  90. package/src/orchestrator/flow-control.js +4 -3
  91. package/src/orchestrator/flow-runner.js +121 -2077
  92. package/src/orchestrator/fs-leak-detector.js +156 -0
  93. package/src/orchestrator/hu-sub-pipeline.js +197 -9
  94. package/src/orchestrator/integrations.js +88 -0
  95. package/src/orchestrator/post-loop-stages.js +35 -8
  96. package/src/orchestrator/preflight-checks.js +20 -7
  97. package/src/orchestrator/repair/repair-detector.js +117 -0
  98. package/src/orchestrator/repair/repair-runner.js +142 -0
  99. package/src/orchestrator/reviewer-fallback.js +1 -1
  100. package/src/orchestrator/solomon-escalation.js +3 -2
  101. package/src/orchestrator/solomon-rules.js +7 -3
  102. package/src/orchestrator/stages/architect-stage.js +12 -11
  103. package/src/orchestrator/stages/coder-stage.js +56 -7
  104. package/src/orchestrator/stages/hu-reviewer-phases/detect-and-apply-splits.js +127 -0
  105. package/src/orchestrator/stages/hu-reviewer-stage.js +6 -79
  106. package/src/orchestrator/stages/planner-stage.js +1 -1
  107. package/src/orchestrator/stages/research-stage.js +1 -1
  108. package/src/orchestrator/stages/reviewer-stage.js +7 -7
  109. package/src/orchestrator/stages/sonar-stage.js +94 -10
  110. package/src/orchestrator/stages/stage-classes.js +86 -0
  111. package/src/orchestrator/stages/stage-executor.js +1 -1
  112. package/src/orchestrator/stages/triage-stage.js +1 -1
  113. package/src/orchestrator/standby.js +3 -2
  114. package/src/orchestrator/verification-gate.js +20 -12
  115. package/src/plan/adr-generator.js +170 -0
  116. package/src/plan/adr-loader.js +60 -0
  117. package/src/plan/plan-executor.js +19 -1
  118. package/src/plan/plan-hu-ops.js +170 -1
  119. package/src/plan/plan-reviewer.js +199 -0
  120. package/src/plan/plan-schema.js +69 -1
  121. package/src/plan/plan-store.js +40 -3
  122. package/src/plan/spec-detector.js +61 -0
  123. package/src/plan/tests-synthesizer.js +116 -0
  124. package/src/planning-game/client.js +3 -14
  125. package/src/planning-game/decomposition.js +1 -1
  126. package/src/planning-game/pipeline-adapter.js +36 -5
  127. package/src/prompts/coder.js +172 -1
  128. package/src/prompts/hu-reviewer.js +0 -1
  129. package/src/prompts/planner.js +81 -4
  130. package/src/review/diff-generator.js +35 -22
  131. package/src/review/snapshot-diff.js +1 -1
  132. package/src/roles/coder-role.js +25 -3
  133. package/src/roles/planner-role.js +42 -1
  134. package/src/roles/repairer-role.js +181 -0
  135. package/src/roles/tester-role.js +70 -4
  136. package/src/session/mutators.js +304 -0
  137. package/src/session/runtime-overrides.js +59 -0
  138. package/src/{session-store.js → session/store.js} +2 -2
  139. package/src/skills/semantic-detector.js +10 -5
  140. package/src/skills/skill-detector.js +1 -1
  141. package/src/sonar/cloud-scanner.js +0 -1
  142. package/src/sonar/credentials.js +14 -6
  143. package/src/sonar/discovery.js +123 -0
  144. package/src/sonar/manager.js +18 -0
  145. package/src/sync/detect-drift.js +113 -0
  146. package/src/tracker-bootstrap.js +44 -0
  147. package/src/utils/agent-detect.js +15 -11
  148. package/src/utils/board-prompt-bridge.js +109 -0
  149. package/src/utils/cli-progress.js +161 -0
  150. package/src/utils/cwd-confirm.js +39 -0
  151. package/src/utils/derive-project-name-from-cwd.js +119 -0
  152. package/src/utils/garbage-collector.js +396 -0
  153. package/src/utils/git.js +16 -4
  154. package/src/utils/locale.js +1 -1
  155. package/src/utils/paths.js +21 -1
  156. package/src/utils/process.js +1 -1
  157. package/src/utils/prompt-project-name.js +35 -0
  158. package/src/utils/run-context.js +75 -0
  159. package/src/utils/run-log.js +12 -0
  160. package/src/utils/task-file.js +1 -1
  161. package/src/utils/telemetry.js +13 -1
  162. package/src/webperf/devtools-detect.js +1 -1
  163. package/templates/coder-rules.md +13 -0
  164. package/templates/roles/coder.md +34 -0
  165. package/templates/roles/repairer.md +94 -0
@@ -129,18 +129,23 @@ export async function refineSkillsSemantically({ task, alreadyDetected = [], con
129
129
 
130
130
  /**
131
131
  * Resolve the effective skills mode from config + flags.
132
- * Precedence: flags.skillsMode > config.skills.mode > globalThis default > "auto".
132
+ * Precedence: flags.skillsMode > config.skills.mode > config.testHarness.defaultSkillsMode > "auto".
133
133
  *
134
- * The global default lets the test harness opt out of semantic detection
135
- * without each orchestrator test having to mock this module (tests/setup.js
136
- * sets globalThis.__KJ_DEFAULT_SKILLS_MODE = "regex").
134
+ * The testHarness default lets the test harness opt out of semantic
135
+ * detection without each orchestrator test having to mock this module.
136
+ * Production code reads `config.testHarness.defaultSkillsMode` only;
137
+ * the legacy global override is documented and exclusively read in
138
+ * src/config/test-harness.js (ESLint rule #557 blocks any reintroduction
139
+ * of `globalThis.__KJ_*` outside that one file). Legacy callers without
140
+ * a fully-loaded config still work because `config.testHarness` is
141
+ * undefined → falls through to `"auto"`.
137
142
  *
138
143
  * @param {Object} config
139
144
  * @param {Object} [flags]
140
145
  * @returns {"auto"|"regex"|"semantic"|"none"}
141
146
  */
142
147
  export function resolveSkillsMode(config, flags = {}) {
143
- const defaultMode = globalThis.__KJ_DEFAULT_SKILLS_MODE || "auto";
148
+ const defaultMode = config?.testHarness?.defaultSkillsMode || "auto";
144
149
  const candidate = flags.skillsMode ?? config?.skills?.mode ?? defaultMode;
145
150
  const valid = new Set(["auto", "regex", "semantic", "none"]);
146
151
  return valid.has(candidate) ? candidate : "auto";
@@ -226,7 +226,7 @@ async function collectPythonDeps(projectDir, needed) {
226
226
  for (const line of raw.split("\n")) {
227
227
  const trimmed = line.trim().split("#")[0].trim();
228
228
  if (!trimmed) continue;
229
- const name = trimmed.split(/[<>=!~\[\s]/)[0].toLowerCase();
229
+ const name = trimmed.split(/[<>=!~[\s]/)[0].toLowerCase();
230
230
  const skill = PYTHON_DEPS[name];
231
231
  if (skill) needed.add(skill);
232
232
  }
@@ -5,7 +5,6 @@ function buildCloudScannerArgs(projectKey, config) {
5
5
  const sc = config.sonarcloud || {};
6
6
  const scanner = sc.scanner || {};
7
7
  const host = sc.host || "https://sonarcloud.io";
8
- const token = process.env.KJ_SONARCLOUD_TOKEN || sc.token;
9
8
  const organization = process.env.KJ_SONARCLOUD_ORG || sc.organization;
10
9
 
11
10
  const args = [
@@ -1,13 +1,20 @@
1
1
  /**
2
- * Load SonarQube admin credentials from ~/.karajan/sonar-credentials.json.
2
+ * Load SonarQube credentials from ~/.karajan/sonar-credentials.json.
3
3
  *
4
- * File format:
4
+ * File format (any subset):
5
5
  * {
6
6
  * "user": "admin",
7
- * "password": "your-password"
7
+ * "password": "your-password",
8
+ * "token": "squ_..."
8
9
  * }
9
10
  *
10
- * Returns { user, password } or { user: null, password: null } if file missing.
11
+ * Returns { user, password, token } with each field null when missing.
12
+ *
13
+ * The `token` field is consumed by resolveSonarTokenAsync as the
14
+ * lowest-priority fallback. Until v2.7.5 it was not extracted here, which
15
+ * meant a token persisted to the credentials file by `kj init` was never
16
+ * actually used at scan time — Karajan would silently fall back to the
17
+ * stale token in kj.config.yml or fail with a 401.
11
18
  */
12
19
 
13
20
  import fs from "node:fs/promises";
@@ -23,10 +30,11 @@ export async function loadSonarCredentials() {
23
30
  const data = JSON.parse(raw);
24
31
  return {
25
32
  user: data.user || null,
26
- password: data.password || null
33
+ password: data.password || null,
34
+ token: data.token || null
27
35
  };
28
36
  } catch { /* credentials file may not exist */
29
- return { user: null, password: null };
37
+ return { user: null, password: null, token: null };
30
38
  }
31
39
  }
32
40
 
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Discover running SonarQube containers regardless of the user's
3
+ * config. The point: never bother the user about port 9000 being
4
+ * "occupied by an unknown process" — if there's a Sonar already up
5
+ * somewhere, just point at it and move on.
6
+ *
7
+ * The previous preflight check insisted on owning port 9000 by name
8
+ * (`karajan-sonarqube`). If you happened to have a `sonarqube` from
9
+ * another project running on 9000 (or had pulled a different image
10
+ * tag), we'd error out asking the user to "stop the conflicting
11
+ * process". That's busywork — Sonar is Sonar; reuse what's there.
12
+ *
13
+ * The discovery strategy:
14
+ * 1. List Docker containers whose image looks like sonarqube
15
+ * (image-name regex / ancestor filter — covers `sonarqube`,
16
+ * `sonarqube:community`, `sonarqube:lts`, `sonarqube:9.9`, …).
17
+ * 2. Parse the published host port (`0.0.0.0:9001->9000/tcp`).
18
+ * 3. Optionally HTTP-probe to confirm it's actually serving
19
+ * `/api/system/status` so a misnamed image can't hijack us.
20
+ */
21
+
22
+ import { runCommand } from "../utils/process.js";
23
+ import { isSonarReachable } from "./manager.js";
24
+
25
+ /**
26
+ * @typedef {Object} DiscoveredSonar
27
+ * @property {boolean} found
28
+ * @property {string} [containerName]
29
+ * @property {string} [image]
30
+ * @property {number} [port] - host port (what we connect to)
31
+ * @property {string} [host] - http://localhost:<port>
32
+ * @property {boolean} [reachable] - true after a successful HTTP probe
33
+ */
34
+
35
+ /**
36
+ * Parse the Ports column from `docker ps`. Looks for any host binding
37
+ * that maps to the container's `internalPort` (default 9000).
38
+ * Examples:
39
+ * "0.0.0.0:9000->9000/tcp" → 9000
40
+ * "127.0.0.1:9001->9000/tcp" → 9001
41
+ * "9000/tcp" → null (no host binding)
42
+ * "0.0.0.0:9000->9000/tcp, 9092/tcp" → 9000
43
+ *
44
+ * @param {string} portsStr
45
+ * @param {number} [internalPort=9000]
46
+ * @returns {number|null}
47
+ */
48
+ export function extractHostPort(portsStr, internalPort = 9000) {
49
+ if (!portsStr) return null;
50
+ const re = new RegExp(
51
+ `(?:0\\.0\\.0\\.0|127\\.0\\.0\\.1|::|\\[::\\]):(\\d+)\\s*->\\s*${internalPort}\\/tcp`,
52
+ "g"
53
+ );
54
+ const matches = [...portsStr.matchAll(re)];
55
+ if (matches.length === 0) return null;
56
+ // Prefer the smallest port number (likeliest the "main" binding);
57
+ // arbitrary but deterministic.
58
+ return Math.min(...matches.map((m) => Number(m[1])));
59
+ }
60
+
61
+ /**
62
+ * Run `docker ps` with a list-of-images filter and parse the result.
63
+ * Returns the FIRST container with a usable host port, or null.
64
+ *
65
+ * @returns {Promise<DiscoveredSonar|null>}
66
+ */
67
+ async function dockerPsForSonar() {
68
+ // List all running containers with their image so we can image-match
69
+ // ourselves. Cheap (single docker call) and tolerant of image tags.
70
+ const res = await runCommand("docker", [
71
+ "ps",
72
+ "--format", "{{.ID}}|{{.Names}}|{{.Image}}|{{.Ports}}",
73
+ ]);
74
+ if (res.exitCode !== 0 || !res.stdout?.trim()) return null;
75
+
76
+ const lines = res.stdout.trim().split("\n");
77
+ for (const line of lines) {
78
+ const [, name, image, ports] = line.split("|");
79
+ if (!image) continue;
80
+ if (!/sonarqube/i.test(image)) continue;
81
+ const port = extractHostPort(ports, 9000);
82
+ if (!port) continue;
83
+ return {
84
+ found: true,
85
+ containerName: name,
86
+ image,
87
+ port,
88
+ host: `http://localhost:${port}`,
89
+ };
90
+ }
91
+ return null;
92
+ }
93
+
94
+ /**
95
+ * Public entry point: find a usable, reachable Sonar.
96
+ *
97
+ * `probe`: when true (default) HTTP-pings `/api/system/status` to
98
+ * confirm the container is actually serving Sonar. Tests can disable
99
+ * the probe for hermetic mocking.
100
+ *
101
+ * @param {object} [opts]
102
+ * @param {boolean} [opts.probe=true]
103
+ * @param {number} [opts.healthcheckSeconds=5]
104
+ * @returns {Promise<DiscoveredSonar>}
105
+ */
106
+ export async function discoverRunningSonar(opts = {}) {
107
+ const { probe = true, healthcheckSeconds = 5 } = opts;
108
+ let candidate;
109
+ try {
110
+ candidate = await dockerPsForSonar();
111
+ } catch {
112
+ candidate = null;
113
+ }
114
+ if (!candidate) return { found: false };
115
+ if (!probe) return { ...candidate, reachable: true };
116
+ let reachable;
117
+ try {
118
+ reachable = await isSonarReachable(candidate.host, healthcheckSeconds);
119
+ } catch {
120
+ reachable = false;
121
+ }
122
+ return { ...candidate, reachable };
123
+ }
@@ -87,6 +87,24 @@ export async function sonarUp(hostOverride = null) {
87
87
  };
88
88
  }
89
89
 
90
+ // Discover-before-spawn (v2.7.5): if there's already a SonarQube
91
+ // container running anywhere — even under a different name on a
92
+ // different port — reuse it instead of spinning up a second one.
93
+ // Avoids the user accidentally accumulating Sonar containers across
94
+ // projects, and matches what the port preflight check already does.
95
+ const { discoverRunningSonar } = await import("./discovery.js");
96
+ const discovered = await discoverRunningSonar({
97
+ healthcheckSeconds: sonarConfig.timeouts.healthcheckSeconds,
98
+ });
99
+ if (discovered.found && discovered.reachable) {
100
+ return {
101
+ exitCode: 0,
102
+ stdout: `Reusing existing SonarQube container "${discovered.containerName}" at ${discovered.host} (skipping container start).`,
103
+ stderr: "",
104
+ reusedHost: discovered.host,
105
+ };
106
+ }
107
+
90
108
  const compose = await ensureComposeFile(config.sonarqube);
91
109
  return runCommand("docker", ["compose", "-f", compose, "up", "-d"], { timeout: sonarConfig.timeouts.composeUpMs });
92
110
  }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * kj sync — drift detection between code and the Canvas / plan
3
+ * (issue #540, MVP).
4
+ *
5
+ * Scope of THIS module: pure data transformation. Given a plan
6
+ * (with Operations and their file_hints) plus a list of currently
7
+ * tracked source files (mtime + path), classify each file:
8
+ *
9
+ * - covered — file IS referenced by some Operation's file_hint
10
+ * AND was modified since the plan was created.
11
+ * (Expected drift; plan still authoritative.)
12
+ * - untracked — file exists, modified, but no Operation references
13
+ * it. The user added it manually after the run; the
14
+ * Canvas needs a new Operation to reflect it.
15
+ * - missing — Operation references a file that no longer exists
16
+ * on disk. Either the file was deleted or the plan
17
+ * is wrong about its location.
18
+ * - clean — file is referenced by an Operation AND has not
19
+ * been modified since the plan. No action needed.
20
+ *
21
+ * The classifier is pure: caller does the I/O (walking the tree,
22
+ * stating files, parsing plan JSON). Easy to unit-test.
23
+ */
24
+
25
+ /**
26
+ * @param {object} plan — plan JSON with `operations` (or `hus`) carrying
27
+ * optional `file_hint` strings, and `createdAt` ISO
28
+ * @param {Array<{path: string, mtime: Date}>} files — files in the project
29
+ * @returns {{ covered: object[], untracked: object[], missing: object[], clean: object[] }}
30
+ */
31
+ export function classifyDrift(plan, files) {
32
+ const planMtime = new Date(plan?.createdAt || plan?.updatedAt || 0);
33
+
34
+ // Collect every file_hint declared by any operation/HU. Both keys
35
+ // are accepted for back-compat with v2 plans (HUs) and Canvas
36
+ // (Operations).
37
+ const ops = Array.isArray(plan?.operations) ? plan.operations
38
+ : Array.isArray(plan?.hus) ? plan.hus
39
+ : [];
40
+ const hinted = new Map(); // file → operation index
41
+ ops.forEach((op, idx) => {
42
+ const hint = op.file_hint || op.fileHint;
43
+ if (typeof hint === "string" && hint.length > 0) {
44
+ hinted.set(hint, idx);
45
+ }
46
+ });
47
+
48
+ const out = { covered: [], untracked: [], missing: [], clean: [] };
49
+ const seenInFs = new Set();
50
+
51
+ for (const f of files) {
52
+ if (!f?.path) continue;
53
+ seenInFs.add(f.path);
54
+ const opIdx = hinted.get(f.path);
55
+ const modified = f.mtime instanceof Date && f.mtime > planMtime;
56
+
57
+ if (opIdx === undefined) {
58
+ // Not referenced by any operation. Two sub-cases:
59
+ // - modified: real drift, propose new operation.
60
+ // - not modified: probably a pre-existing file from before the
61
+ // plan; ignore (no action).
62
+ if (modified) {
63
+ out.untracked.push({ path: f.path, mtime: f.mtime, suggestion: "new operation" });
64
+ }
65
+ continue;
66
+ }
67
+ if (modified) {
68
+ out.covered.push({ path: f.path, mtime: f.mtime, operationIndex: opIdx });
69
+ } else {
70
+ out.clean.push({ path: f.path, operationIndex: opIdx });
71
+ }
72
+ }
73
+
74
+ // Missing: operation says a file should exist, but we never saw it.
75
+ for (const [hint, opIdx] of hinted.entries()) {
76
+ if (!seenInFs.has(hint)) {
77
+ out.missing.push({ path: hint, operationIndex: opIdx, suggestion: "remove operation OR fix the file_hint" });
78
+ }
79
+ }
80
+
81
+ return out;
82
+ }
83
+
84
+ /**
85
+ * Render a drift report as plain-text for the terminal.
86
+ */
87
+ export function formatDriftReport(report, plan) {
88
+ const lines = [`Drift report — plan ${plan?.planId || "(unknown)"}`, ""];
89
+ if (report.untracked.length === 0 && report.missing.length === 0 && report.covered.length === 0) {
90
+ lines.push(" No drift detected.");
91
+ return lines.join("\n");
92
+ }
93
+ if (report.covered.length > 0) {
94
+ lines.push(`Covered (${report.covered.length}) — modified, an Operation references it; consider extending its Approach:`);
95
+ for (const c of report.covered) lines.push(` ~ ${c.path} [op #${c.operationIndex + 1}]`);
96
+ lines.push("");
97
+ }
98
+ if (report.untracked.length > 0) {
99
+ lines.push(`Untracked (${report.untracked.length}) — modified, NO Operation references it; propose new Operation:`);
100
+ for (const u of report.untracked) lines.push(` + ${u.path}`);
101
+ lines.push("");
102
+ }
103
+ if (report.missing.length > 0) {
104
+ lines.push(`Missing (${report.missing.length}) — Operation file_hint points at a file that doesn't exist:`);
105
+ for (const m of report.missing) lines.push(` ! ${m.path} [op #${m.operationIndex + 1}] — ${m.suggestion}`);
106
+ lines.push("");
107
+ }
108
+ if (report.clean.length > 0) {
109
+ lines.push(`Clean (${report.clean.length}) — covered, unchanged.`);
110
+ }
111
+ lines.push("", "Run with --json for machine-readable output. Future: --apply to propose Canvas patches (issue #540 follow-ups).");
112
+ return lines.join("\n");
113
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Tracker bootstrap — registers the configured task-tracker adapter into
3
+ * the orchestrator's integrations registry at runtime.
4
+ *
5
+ * This module exists so that `src/orchestrator/` never imports
6
+ * `src/planning-game/` (or any other tracker) directly — the architecture
7
+ * invariant `tests/architecture/tracker-coupling.test.js` enforces that.
8
+ * `runFlow` calls `ensureTrackerRegistered(config)` from here; this
9
+ * module owns the conditional import of each adapter.
10
+ *
11
+ * Adding a new tracker:
12
+ * 1. Write `src/adapters/<name>/adapter.js` with the hook surface
13
+ * documented in `src/orchestrator/integrations.js`.
14
+ * 2. Add a branch below that imports and registers it when the relevant
15
+ * config flag is true.
16
+ *
17
+ * The function is idempotent: once registered, subsequent calls are
18
+ * no-ops (the registry already has an entry for the slot).
19
+ */
20
+
21
+ import { getIntegration, registerIntegration } from "./orchestrator/integrations.js";
22
+
23
+ /**
24
+ * Ensure the tracker adapter configured by `config` is registered under
25
+ * the `"tracker"` slot. Safe to call on every `runFlow` invocation.
26
+ *
27
+ * @param {object|null} config
28
+ * @returns {Promise<void>}
29
+ */
30
+ export async function ensureTrackerRegistered(config) {
31
+ if (getIntegration("tracker")) return; // Already registered.
32
+
33
+ if (config?.planning_game?.enabled) {
34
+ try {
35
+ const { planningGameAdapter } = await import("./planning-game/pipeline-adapter.js");
36
+ registerIntegration("tracker", planningGameAdapter);
37
+ } catch { /* registration failed — non-blocking, adapter stays absent */ }
38
+ return;
39
+ }
40
+
41
+ // No tracker enabled — the orchestrator's `getIntegration("tracker")`
42
+ // calls will return undefined and every hook becomes an optional-chain
43
+ // no-op. That's the intended behavior for "tracker disabled" runs.
44
+ }
@@ -18,17 +18,21 @@ export async function checkBinary(name, versionArg = "--version") {
18
18
  }
19
19
 
20
20
  export async function detectAvailableAgents() {
21
- const results = [];
22
- for (const agent of KNOWN_AGENTS) {
23
- const check = await checkBinary(agent.name);
24
- results.push({
25
- name: agent.name,
26
- available: check.ok,
27
- version: check.ok ? check.version : null,
28
- install: agent.install
29
- });
30
- }
31
- return results;
21
+ // PR-J (audit quick win): probe the 5 agent binaries in parallel
22
+ // via Promise.all instead of awaiting in a for-of. checkBinary is
23
+ // a pure shell-out (no shared state), so serialising them was
24
+ // wasted latency. With 5 agents at ~80ms each: O(n) → O(1).
25
+ return Promise.all(
26
+ KNOWN_AGENTS.map(async (agent) => {
27
+ const check = await checkBinary(agent.name);
28
+ return {
29
+ name: agent.name,
30
+ available: check.ok,
31
+ version: check.ok ? check.version : null,
32
+ install: agent.install
33
+ };
34
+ })
35
+ );
32
36
  }
33
37
 
34
38
  /**
@@ -0,0 +1,109 @@
1
+ /**
2
+ * File-based RPC between a non-interactive Karajan run and the HU
3
+ * Board. When the runner needs an answer to a question (Solomon
4
+ * escalation, max-iterations resolution, anything wired to
5
+ * askQuestion) and stdin is unavailable (the board's ▶ Run plan
6
+ * spawns with stdio=ignore), it writes a prompt JSON to a known
7
+ * directory and polls for an "answer" sibling file. The board's
8
+ * chokidar watcher sees the prompt and surfaces it as a modal; the
9
+ * user's answer is POSTed back, written as the answer file, and the
10
+ * runner picks it up and resolves.
11
+ *
12
+ * Why files instead of a socket / SSE-back-channel:
13
+ * - Zero new server in the runner. A detached child has no listening
14
+ * port; opening one would mean discovery + auth + cleanup.
15
+ * - Survives a runner restart: if the user kills + resumes, the
16
+ * pending prompt is still on disk and the board can re-show it.
17
+ * - The board already watches `~/.karajan/`; one more glob is cheap.
18
+ *
19
+ * Layout:
20
+ * $KJ_HOME/prompts/<promptId>.json - { sessionId, question, context, createdAt }
21
+ * $KJ_HOME/prompts/<promptId>.answer.json - { answer, answeredAt } (written by board)
22
+ *
23
+ * The bridge owns the lifecycle: write the prompt, poll until the
24
+ * answer arrives, delete BOTH files, return the answer string (or
25
+ * null when the user picked "stop"). Aborts cleanly on signal or on
26
+ * a configurable timeout (default 30 min — long enough for a coffee).
27
+ */
28
+
29
+ import { mkdir, writeFile, readFile, unlink, stat } from "node:fs/promises";
30
+ import { join } from "node:path";
31
+ import { homedir } from "node:os";
32
+ import { randomUUID } from "node:crypto";
33
+
34
+ const POLL_INTERVAL_MS = 500;
35
+ const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000; // 30 min
36
+
37
+ function getPromptsDir() {
38
+ const home = process.env.KJ_HOME || join(homedir(), ".karajan");
39
+ return join(home, "prompts");
40
+ }
41
+
42
+ function exists(path) {
43
+ return stat(path).then(() => true, () => false);
44
+ }
45
+
46
+ /**
47
+ * Publish a prompt + wait for the answer. Returns the answer string,
48
+ * `null` when the user explicitly picked "stop", or rejects on
49
+ * timeout / abort.
50
+ *
51
+ * @param {object} args
52
+ * @param {string} args.sessionId
53
+ * @param {string} args.question
54
+ * @param {object} [args.context] - serialised verbatim into the prompt JSON
55
+ * @param {number} [args.timeoutMs]
56
+ * @param {AbortSignal} [args.signal]
57
+ * @param {object} [args.logger] - { info, warn } — optional, for breadcrumbs
58
+ * @returns {Promise<string|null>}
59
+ */
60
+ export async function askThroughBoard({ sessionId, question, context = null, timeoutMs = DEFAULT_TIMEOUT_MS, signal = null, logger = null } = {}) {
61
+ if (!question) throw new Error("askThroughBoard: question is required");
62
+
63
+ const dir = getPromptsDir();
64
+ await mkdir(dir, { recursive: true });
65
+
66
+ const promptId = `prompt-${Date.now()}-${randomUUID().slice(0, 8)}`;
67
+ const promptPath = join(dir, `${promptId}.json`);
68
+ const answerPath = join(dir, `${promptId}.answer.json`);
69
+
70
+ const payload = {
71
+ promptId,
72
+ sessionId: sessionId || null,
73
+ question,
74
+ context: context || null,
75
+ createdAt: new Date().toISOString(),
76
+ };
77
+ await writeFile(promptPath, JSON.stringify(payload, null, 2), "utf-8");
78
+ logger?.info?.(`[prompt-bridge] published ${promptId} — waiting for answer via the board`);
79
+
80
+ const startedAt = Date.now();
81
+ try {
82
+ while (true) {
83
+ if (signal?.aborted) throw new Error("askThroughBoard: aborted");
84
+ if (Date.now() - startedAt > timeoutMs) {
85
+ throw new Error(`askThroughBoard: timed out after ${Math.round(timeoutMs / 1000)}s waiting for answer to ${promptId}`);
86
+ }
87
+ if (await exists(answerPath)) {
88
+ const raw = await readFile(answerPath, "utf-8");
89
+ let answer;
90
+ try { ({ answer } = JSON.parse(raw)); } catch { answer = null; }
91
+ await unlink(answerPath).catch(() => {});
92
+ await unlink(promptPath).catch(() => {});
93
+ if (typeof answer === "string" && answer.trim().toLowerCase() === "stop") return null;
94
+ return typeof answer === "string" ? answer.trim() : null;
95
+ }
96
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
97
+ }
98
+ } catch (err) {
99
+ // Best-effort cleanup of the prompt file on error so it doesn't
100
+ // get stuck pending on the board forever.
101
+ await unlink(promptPath).catch(() => {});
102
+ throw err;
103
+ }
104
+ }
105
+
106
+ /** Test seam — exposes the resolved prompts dir for assertions. */
107
+ export function _getPromptsDirForTests() {
108
+ return getPromptsDir();
109
+ }