karajan-code 2.7.4 → 2.9.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 (192) 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/audit/basal-cost.js +92 -9
  10. package/src/audit/deterministic-summary.js +200 -0
  11. package/src/audit/osv-findings.js +162 -0
  12. package/src/audit/semgrep-findings.js +117 -0
  13. package/src/audit/sonar-findings.js +81 -0
  14. package/src/audit/webperf-input.js +90 -0
  15. package/src/bootstrap.js +26 -1
  16. package/src/brain/decision-tracker.js +3 -1
  17. package/src/brain/decisor.js +1 -1
  18. package/src/canvas/parse-md.js +238 -0
  19. package/src/canvas/schema.js +101 -0
  20. package/src/canvas/to-flat.js +85 -0
  21. package/src/canvas/validate.js +163 -0
  22. package/src/checks/binaries.js +3 -3
  23. package/src/checks/ci.js +2 -2
  24. package/src/checks/config-files.js +6 -6
  25. package/src/checks/dir-setup.js +4 -0
  26. package/src/checks/mcp-health.js +6 -0
  27. package/src/checks/ports.js +91 -29
  28. package/src/checks/rtk.js +1 -1
  29. package/src/checks/skills.js +5 -1
  30. package/src/checks/sonar.js +2 -2
  31. package/src/checks/system.js +3 -3
  32. package/src/checks/tokens.js +1 -1
  33. package/src/checks/types.js +0 -12
  34. package/src/cli/_shared.js +30 -0
  35. package/src/cli/register-meta.js +218 -0
  36. package/src/cli/register-pipeline.js +212 -0
  37. package/src/cli/register-plan.js +152 -0
  38. package/src/cli/register-roles-skills.js +38 -0
  39. package/src/cli/register-sonar.js +28 -0
  40. package/src/cli.js +83 -468
  41. package/src/commands/agents.js +18 -6
  42. package/src/commands/architect.js +29 -2
  43. package/src/commands/audit.js +265 -30
  44. package/src/commands/board.js +160 -30
  45. package/src/commands/clean.js +83 -0
  46. package/src/commands/code.js +7 -2
  47. package/src/commands/discover.js +7 -2
  48. package/src/commands/doctor.js +1 -1
  49. package/src/commands/init.js +2 -2
  50. package/src/commands/plan/_shared.js +40 -0
  51. package/src/commands/plan/add-hu.js +21 -0
  52. package/src/commands/plan/delete.js +9 -0
  53. package/src/commands/plan/generate.js +284 -0
  54. package/src/commands/plan/index.js +14 -0
  55. package/src/commands/plan/list.js +21 -0
  56. package/src/commands/plan/ready.js +46 -0
  57. package/src/commands/plan/remove-hu.js +17 -0
  58. package/src/commands/plan/show.js +81 -0
  59. package/src/commands/plan/validate.js +20 -0
  60. package/src/commands/plan.js +14 -291
  61. package/src/commands/researcher.js +7 -2
  62. package/src/commands/resume.js +24 -1
  63. package/src/commands/review.js +7 -2
  64. package/src/commands/run.js +52 -1
  65. package/src/commands/status.js +1 -1
  66. package/src/commands/sync.js +75 -0
  67. package/src/commands/triage.js +7 -2
  68. package/src/commands/undo.js +1 -1
  69. package/src/config/defaults.js +213 -0
  70. package/src/config/loader.js +137 -0
  71. package/src/config/overrides.js +199 -0
  72. package/src/config/role-resolver.js +159 -0
  73. package/src/config/test-harness.js +78 -0
  74. package/src/config.js +30 -632
  75. package/src/git/automation.js +2 -2
  76. package/src/guards/output-guard.js +0 -2
  77. package/src/hu/acceptance-runner.js +52 -10
  78. package/src/hu/store.js +11 -0
  79. package/src/mcp/handlers/direct-handlers.js +2 -5
  80. package/src/mcp/handlers/management-handlers.js +48 -4
  81. package/src/mcp/handlers/run-handler.js +9 -8
  82. package/src/mcp/preflight.js +32 -13
  83. package/src/mcp/server-handlers.js +4 -3
  84. package/src/mcp/suggest-handler.js +3 -2
  85. package/src/mcp/tools.js +15 -0
  86. package/src/orchestrator/agent-fallback.js +1 -1
  87. package/src/orchestrator/ci-integration.js +11 -11
  88. package/src/orchestrator/config-init.js +25 -7
  89. package/src/orchestrator/direct-actions.js +7 -3
  90. package/src/orchestrator/drivers/error-recovery.js +287 -0
  91. package/src/orchestrator/drivers/init-context.js +222 -0
  92. package/src/orchestrator/drivers/iteration-loop.js +301 -0
  93. package/src/orchestrator/drivers/iteration-phases/coder-and-refactorer.js +48 -0
  94. package/src/orchestrator/drivers/iteration-phases/guards.js +84 -0
  95. package/src/orchestrator/drivers/iteration-phases/handle-approved.js +29 -0
  96. package/src/orchestrator/drivers/iteration-phases/quality-gates.js +76 -0
  97. package/src/orchestrator/drivers/iteration-phases/reviewer-gate.js +60 -0
  98. package/src/orchestrator/drivers/post-loop.js +370 -0
  99. package/src/orchestrator/drivers/pre-loop-phases/auto-hu-batch.js +116 -0
  100. package/src/orchestrator/drivers/pre-loop-phases/config-deprecations.js +47 -0
  101. package/src/orchestrator/drivers/pre-loop-phases/ensure-addyosmani-skills.js +84 -0
  102. package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +268 -0
  103. package/src/orchestrator/drivers/pre-loop.js +431 -0
  104. package/src/orchestrator/drivers/run-hu-batch.js +367 -0
  105. package/src/orchestrator/feedback-queue.js +0 -1
  106. package/src/orchestrator/flow-control.js +4 -3
  107. package/src/orchestrator/flow-runner.js +121 -2077
  108. package/src/orchestrator/fs-leak-detector.js +156 -0
  109. package/src/orchestrator/hu-sub-pipeline.js +197 -9
  110. package/src/orchestrator/integrations.js +81 -0
  111. package/src/orchestrator/post-loop-stages.js +35 -8
  112. package/src/orchestrator/preflight-checks.js +20 -7
  113. package/src/orchestrator/repair/repair-detector.js +117 -0
  114. package/src/orchestrator/repair/repair-runner.js +142 -0
  115. package/src/orchestrator/reviewer-fallback.js +1 -1
  116. package/src/orchestrator/role-output-compressor.js +0 -1
  117. package/src/orchestrator/session-journal.js +4 -41
  118. package/src/orchestrator/solomon-escalation.js +3 -2
  119. package/src/orchestrator/solomon-rules.js +7 -3
  120. package/src/orchestrator/stages/architect-stage.js +12 -11
  121. package/src/orchestrator/stages/coder-stage.js +56 -7
  122. package/src/orchestrator/stages/hu-reviewer-phases/detect-and-apply-splits.js +127 -0
  123. package/src/orchestrator/stages/hu-reviewer-stage.js +6 -79
  124. package/src/orchestrator/stages/planner-stage.js +1 -1
  125. package/src/orchestrator/stages/research-stage.js +1 -1
  126. package/src/orchestrator/stages/reviewer-stage.js +7 -7
  127. package/src/orchestrator/stages/sonar-stage.js +94 -10
  128. package/src/orchestrator/stages/stage-classes.js +84 -0
  129. package/src/orchestrator/stages/stage-executor.js +1 -1
  130. package/src/orchestrator/stages/triage-stage.js +1 -1
  131. package/src/orchestrator/standby.js +3 -2
  132. package/src/orchestrator/verification-gate.js +20 -12
  133. package/src/plan/adr-generator.js +170 -0
  134. package/src/plan/adr-loader.js +60 -0
  135. package/src/plan/plan-executor.js +19 -1
  136. package/src/plan/plan-hu-ops.js +170 -1
  137. package/src/plan/plan-reviewer.js +199 -0
  138. package/src/plan/plan-schema.js +69 -1
  139. package/src/plan/plan-store.js +40 -17
  140. package/src/plan/spec-detector.js +61 -0
  141. package/src/plan/tests-synthesizer.js +116 -0
  142. package/src/planning-game/client.js +3 -28
  143. package/src/planning-game/decomposition.js +1 -1
  144. package/src/planning-game/pipeline-adapter.js +37 -6
  145. package/src/prompts/audit.js +206 -6
  146. package/src/prompts/coder.js +172 -1
  147. package/src/prompts/hu-reviewer.js +0 -1
  148. package/src/prompts/planner.js +81 -4
  149. package/src/prompts/rtk-snippet.js +1 -1
  150. package/src/review/diff-generator.js +35 -22
  151. package/src/review/snapshot-diff.js +1 -1
  152. package/src/roles/audit-role.js +125 -13
  153. package/src/roles/coder-role.js +25 -3
  154. package/src/roles/planner-role.js +42 -1
  155. package/src/roles/repairer-role.js +181 -0
  156. package/src/roles/tester-role.js +70 -4
  157. package/src/session/mutators.js +304 -0
  158. package/src/session/runtime-overrides.js +59 -0
  159. package/src/{session-store.js → session/store.js} +2 -2
  160. package/src/skills/semantic-detector.js +10 -5
  161. package/src/skills/skill-detector.js +1 -1
  162. package/src/sonar/api.js +1 -1
  163. package/src/sonar/cloud-scanner.js +0 -1
  164. package/src/sonar/credentials.js +14 -6
  165. package/src/sonar/discovery.js +123 -0
  166. package/src/sonar/manager.js +18 -0
  167. package/src/sync/detect-drift.js +113 -0
  168. package/src/tracker-bootstrap.js +44 -0
  169. package/src/utils/agent-detect.js +15 -11
  170. package/src/utils/board-prompt-bridge.js +109 -0
  171. package/src/utils/cli-progress.js +161 -0
  172. package/src/utils/cwd-confirm.js +39 -0
  173. package/src/utils/derive-project-name-from-cwd.js +119 -0
  174. package/src/utils/display/event-handlers.js +1 -1
  175. package/src/utils/display/header.js +1 -1
  176. package/src/utils/display/session-summary.js +3 -3
  177. package/src/utils/display/solomon.js +1 -1
  178. package/src/utils/garbage-collector.js +396 -0
  179. package/src/utils/git.js +16 -4
  180. package/src/utils/locale.js +1 -1
  181. package/src/utils/paths.js +21 -1
  182. package/src/utils/process.js +1 -1
  183. package/src/utils/prompt-project-name.js +35 -0
  184. package/src/utils/role-assigner.js +1 -1
  185. package/src/utils/run-context.js +75 -0
  186. package/src/utils/run-log.js +12 -0
  187. package/src/utils/task-file.js +1 -1
  188. package/src/utils/telemetry.js +13 -1
  189. package/src/webperf/devtools-detect.js +1 -1
  190. package/templates/coder-rules.md +13 -0
  191. package/templates/roles/coder.md +34 -0
  192. package/templates/roles/repairer.md +94 -0
@@ -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
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * CLI progress reporter — prints live agent activity to the terminal while
3
+ * an agent (planner, coder, reviewer, auditor, …) is thinking.
4
+ *
5
+ * Context: commands like `kj plan --task-file spec.md` used to call
6
+ * `agent.runTask(...)` without an `onOutput` callback. The CLI then hung
7
+ * for 30 s – 3 min with zero feedback until a line like "Plan saved: …"
8
+ * finally appeared. Users assumed the command had deadlocked.
9
+ *
10
+ * This helper gives every CLI command the same live feedback the `kj run`
11
+ * orchestrator already emits: a header with role + start, a stream of
12
+ * tool calls / short text lines as the agent works, and a footer with
13
+ * outcome + elapsed time. It wraps `onOutput` so every command can pass
14
+ * `createCliProgressReporter({ role: "planner" }).onOutput` straight
15
+ * into the agent's runTask / reviewTask call.
16
+ *
17
+ * Output shape:
18
+ *
19
+ * [planner] starting...
20
+ * ▸ Read SPEC.md
21
+ * ▸ Grep "architecture"
22
+ * · Analyzing sections 1–5…
23
+ * ▸ Write plan.json
24
+ * [planner] done (42.8s)
25
+ *
26
+ * Design notes:
27
+ * - Writes to `process.stderr` by default (so stdout is reserved for
28
+ * structured output like `--json` mode).
29
+ * - `onOutput` is shaped as `{ stream, line, kind }` to match the
30
+ * contract Claude's `createStreamJsonFilter` emits — so passing
31
+ * this reporter's `onOutput` into a runTask{…, onOutput} call Just
32
+ * Works with Claude, Codex, Gemini, Aider and OpenCode (each one's
33
+ * agent wrapper already normalizes its CLI's stream format into
34
+ * that shape).
35
+ * - `kind` is optional; when absent the default bullet is used.
36
+ * - `quiet: true` turns the reporter into a no-op (useful when the
37
+ * command is running under `--json` and should not pollute stdout).
38
+ */
39
+
40
+ const ICONS = {
41
+ tool: "▸", // ▸ right-pointing small triangle
42
+ text: "·", // · middle dot
43
+ thinking: "…", // …
44
+ heartbeat: "⠿", // braille dots — distinct from real activity icons
45
+ };
46
+
47
+ /**
48
+ * Rotating idle messages so the user knows the process is alive without
49
+ * the same string repeating every minute. Picked at random each tick;
50
+ * the previous one is held back so two ticks in a row never duplicate.
51
+ */
52
+ const HEARTBEAT_LINES = [
53
+ "still working…",
54
+ "thinking through it…",
55
+ "wading through tokens…",
56
+ "asking the model nicely…",
57
+ "crunching the spec…",
58
+ "burning down the prompt…",
59
+ "kneading the context window…",
60
+ "still here, give me a sec…",
61
+ "the LLM is doing LLM things…",
62
+ "drafting, redrafting…",
63
+ ];
64
+
65
+ /**
66
+ * Default cadence for the idle heartbeat. ~45s is short enough to feel
67
+ * responsive on a quiet planner run (Claude can think for 1-2 min on a
68
+ * complex spec) without spamming every few seconds.
69
+ */
70
+ const DEFAULT_HEARTBEAT_MS = 45_000;
71
+
72
+ /**
73
+ * @typedef {Object} CliProgressReporter
74
+ * @property {(event: { stream?: string, line: string, kind?: string }) => void} onOutput
75
+ * @property {(outcome?: string) => void} finish
76
+ */
77
+
78
+ /**
79
+ * @param {Object} [opts]
80
+ * @param {string} [opts.role] - label shown in the header/footer ("planner", "auditor", …).
81
+ * Default: "agent".
82
+ * @param {NodeJS.WriteStream} [opts.stream] - output destination. Default: process.stderr.
83
+ * @param {boolean} [opts.quiet] - when true, nothing is printed (useful for --json mode).
84
+ * @param {number} [opts.heartbeatMs] - silent interval before printing an
85
+ * "alive" line. Defaults to 45 s. Set to 0 to disable the heartbeat
86
+ * (useful in tests).
87
+ * @returns {CliProgressReporter}
88
+ */
89
+ export function createCliProgressReporter(opts = {}) {
90
+ const {
91
+ role = "agent",
92
+ stream = process.stderr,
93
+ quiet = false,
94
+ heartbeatMs = DEFAULT_HEARTBEAT_MS,
95
+ } = opts;
96
+
97
+ if (quiet) {
98
+ return { onOutput: () => {}, finish: () => {} };
99
+ }
100
+
101
+ const startedAt = Date.now();
102
+ stream.write(`[${role}] starting...\n`);
103
+
104
+ // Idle heartbeat: a periodic timer that prints a varied "still alive"
105
+ // line whenever the agent goes quiet for longer than `heartbeatMs`.
106
+ // Reset on every real onOutput so heavy chatter doesn't trigger the
107
+ // duplicate. Always elapses to a different message than last time so
108
+ // two consecutive ticks never read the same.
109
+ let lastActivityAt = Date.now();
110
+ let lastHeartbeatIdx = -1;
111
+ let heartbeatTimer = null;
112
+
113
+ function pickHeartbeatLine() {
114
+ let idx;
115
+ do {
116
+ idx = Math.floor(Math.random() * HEARTBEAT_LINES.length);
117
+ } while (idx === lastHeartbeatIdx && HEARTBEAT_LINES.length > 1);
118
+ lastHeartbeatIdx = idx;
119
+ return HEARTBEAT_LINES[idx];
120
+ }
121
+
122
+ function tick() {
123
+ const idleFor = Date.now() - lastActivityAt;
124
+ if (idleFor < heartbeatMs) return; // real activity reset us
125
+ const elapsed = Math.round((Date.now() - startedAt) / 1000);
126
+ stream.write(` ${ICONS.heartbeat} ${pickHeartbeatLine()} (${elapsed}s)\n`);
127
+ lastActivityAt = Date.now(); // collapse next ticks
128
+ }
129
+
130
+ if (heartbeatMs > 0) {
131
+ heartbeatTimer = setInterval(tick, heartbeatMs);
132
+ if (typeof heartbeatTimer.unref === "function") heartbeatTimer.unref();
133
+ }
134
+
135
+ const onOutput = (event) => {
136
+ if (!event || typeof event !== "object") return;
137
+ const raw = typeof event.line === "string" ? event.line : "";
138
+ if (!raw) return;
139
+ // Strip trailing newline — we re-add our own.
140
+ const line = raw.replace(/\s+$/, "");
141
+ if (!line) return;
142
+ lastActivityAt = Date.now();
143
+ const icon = ICONS[event.kind] || ICONS.text;
144
+ stream.write(` ${icon} ${line}\n`);
145
+ };
146
+
147
+ /**
148
+ * @param {string} [outcome] default "done". Use "failed", "stopped", "timeout" etc.
149
+ */
150
+ const finish = (outcome = "done") => {
151
+ if (heartbeatTimer) {
152
+ clearInterval(heartbeatTimer);
153
+ heartbeatTimer = null;
154
+ }
155
+ const ms = Date.now() - startedAt;
156
+ const s = (ms / 1000).toFixed(1);
157
+ stream.write(`[${role}] ${outcome} (${s}s)\n`);
158
+ };
159
+
160
+ return { onOutput, finish };
161
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * PR-F: ask the user "Run on $(pwd)?" before launching `kj run` from
3
+ * a terminal. Same defensive UX as `git push --force-with-lease`
4
+ * surfacing the branch — the user wants to know what they're aiming
5
+ * at before the rocket fires.
6
+ *
7
+ * The prompt is plain readline (no extra deps) and accepts any of:
8
+ * y, yes, s, sí, ENTER → true
9
+ * n, no, ENTER+Esc → false
10
+ *
11
+ * Skipped automatically by the caller when stdin/stdout are not a
12
+ * TTY, when --yes is set, or when --json is set. So this module is
13
+ * never called in CI / non-interactive contexts.
14
+ */
15
+
16
+ import readline from "node:readline";
17
+
18
+ /**
19
+ * @param {string} projectDir absolute path of the cwd / projectDir
20
+ * @returns {Promise<boolean>} true to continue, false to abort
21
+ */
22
+ export async function confirmCwd(projectDir) {
23
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
24
+ // No emoji — keep terminal-safe even on basic Windows shells.
25
+ const question = `Karajan va a ejecutarse sobre:\n ${projectDir}\n\n¿Continuar? [Y/n] `;
26
+ return new Promise((resolve) => {
27
+ rl.question(question, (answer) => {
28
+ rl.close();
29
+ const trimmed = String(answer || "").trim().toLowerCase();
30
+ // Empty answer = ENTER = accept the default = yes.
31
+ if (trimmed === "" || trimmed === "y" || trimmed === "yes" || trimmed === "s" || trimmed === "si" || trimmed === "sí") {
32
+ resolve(true);
33
+ } else {
34
+ resolve(false);
35
+ }
36
+ });
37
+ rl.on("SIGINT", () => { rl.close(); resolve(false); });
38
+ });
39
+ }