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
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Canvas → flat task string adapter.
3
+ *
4
+ * Renders a validated Canvas object as a single markdown blob the
5
+ * existing planner can consume verbatim. Preserves every section
6
+ * with its original heading so downstream steps (planner prompt,
7
+ * coder prompt, plan JSON `task` field) see the same shape.
8
+ *
9
+ * The point: Canvas users get planner-time validation (catching
10
+ * broken acceptance tests before they're persisted) AND benefit from
11
+ * every existing planner / coder pipeline without those pipelines
12
+ * needing Canvas awareness. Norms + Safeguards explicitly bracketed
13
+ * so prompts that scan for them can find them with a regex.
14
+ */
15
+
16
+ /**
17
+ * @param {object} canvas — output of validateCanvas (.canvas)
18
+ * @returns {string} markdown task ready for the planner
19
+ */
20
+ export function canvasToTask(canvas) {
21
+ const lines = [`# ${canvas.title}`, ""];
22
+
23
+ // Requirements
24
+ lines.push("## Requirements", canvas.requirements.problem);
25
+ if (canvas.requirements.done && canvas.requirements.done.length > 0) {
26
+ lines.push("", "Definition of done:");
27
+ for (const d of canvas.requirements.done) lines.push(`- ${d}`);
28
+ }
29
+ lines.push("");
30
+
31
+ // Entities (optional)
32
+ if (canvas.entities && canvas.entities.length > 0) {
33
+ lines.push("## Entities");
34
+ for (const e of canvas.entities) {
35
+ const fields = e.fields && e.fields.length > 0 ? ` — fields: ${e.fields.join(", ")}` : "";
36
+ lines.push(`- ${e.name}${fields}${e.notes ? `. ${e.notes}` : ""}`);
37
+ }
38
+ lines.push("");
39
+ }
40
+
41
+ // Approach
42
+ lines.push("## Approach", canvas.approach, "");
43
+
44
+ // Structure (optional content)
45
+ lines.push("## Structure");
46
+ if (canvas.structure?.touches?.length > 0) lines.push(`- touches: ${canvas.structure.touches.join(", ")}`);
47
+ if (canvas.structure?.depends_on?.length > 0) lines.push(`- depends_on: ${canvas.structure.depends_on.join(", ")}`);
48
+ if (canvas.structure?.contracts?.length > 0) lines.push(`- contracts: ${canvas.structure.contracts.join(", ")}`);
49
+ lines.push("");
50
+
51
+ // Operations — each becomes one HU. Acceptance tests inline so the
52
+ // planner sees the contract from turn 1.
53
+ lines.push("## Operations");
54
+ canvas.operations.forEach((op, i) => {
55
+ lines.push(`${i + 1}. ${op.title}`);
56
+ if (op.file_hint) lines.push(` file: ${op.file_hint}`);
57
+ if (op.blocked_by && op.blocked_by.length > 0) lines.push(` depends on: ${op.blocked_by.join(", ")}`);
58
+ lines.push(" Acceptance:");
59
+ for (const t of op.acceptance) {
60
+ // Render shell as one-liner, gherkin as fenced block so newlines survive.
61
+ if (t.type === "gherkin") {
62
+ lines.push(" - gherkin:", " ```gherkin", ...t.content.split("\n").map((l) => ` ${l}`), " ```");
63
+ } else {
64
+ lines.push(` - shell: \`${t.content}\``);
65
+ }
66
+ }
67
+ });
68
+ lines.push("");
69
+
70
+ // Norms (optional) — bracketed so prompts can find them.
71
+ if (canvas.norms && canvas.norms.length > 0) {
72
+ lines.push("## Norms (per-task engineering standards — coder MUST follow)");
73
+ for (const n of canvas.norms) lines.push(`- ${n}`);
74
+ lines.push("");
75
+ }
76
+
77
+ // Safeguards (optional) — bracketed similarly.
78
+ if (canvas.safeguards && canvas.safeguards.length > 0) {
79
+ lines.push("## Safeguards (non-negotiable invariants — code MUST hold these)");
80
+ for (const s of canvas.safeguards) lines.push(`- ${s}`);
81
+ lines.push("");
82
+ }
83
+
84
+ return lines.join("\n");
85
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * REASONS Canvas validator.
3
+ *
4
+ * Schema validation + planner-time sanity checks for acceptance tests.
5
+ * The schema is enforced via Valibot in canvas/schema.js. This module
6
+ * adds the bug-class fix: parse-check every shell acceptance test
7
+ * BEFORE it gets persisted into a plan and BEFORE any LLM is called.
8
+ *
9
+ * Catches the 2026-04-29 incident class:
10
+ * `length as $n | .filesOver300LOC == $n` → exits non-zero with a
11
+ * descriptive error pointing at the offending operation + test
12
+ * index, BEFORE the user has paid for any agent run.
13
+ */
14
+
15
+ import { spawnSync } from "node:child_process";
16
+ import { safeParseCanvas } from "./schema.js";
17
+
18
+ /**
19
+ * Run a single shell acceptance test through `bash -n` (parse-only,
20
+ * doesn't execute) to catch shell-level syntax errors. Then, if the
21
+ * test invokes `jq`, also probe the jq query through `jq -n <query>`
22
+ * with empty input — jq errors there iff the query has bad syntax.
23
+ *
24
+ * Trade-off accepted: bash -n won't catch every command-substitution
25
+ * pitfall (it parses but doesn't expand), and jq -n with empty input
26
+ * may pass queries that fail at runtime against real input. The point
27
+ * is to catch the OBVIOUS bugs cheaply, not to be airtight.
28
+ *
29
+ * @param {string} cmd
30
+ * @returns {{ ok: true } | { ok: false, kind: string, error: string }}
31
+ */
32
+ export function validateShellTest(cmd) {
33
+ if (typeof cmd !== "string" || cmd.trim().length === 0) {
34
+ return { ok: false, kind: "empty", error: "shell test is empty" };
35
+ }
36
+
37
+ // Layer 1: bash -n parse-check on the whole command.
38
+ const bash = spawnSync("bash", ["-n", "-c", cmd], { encoding: "utf8", timeout: 5000 });
39
+ if (bash.status !== 0) {
40
+ const err = (bash.stderr || "").trim() || "bash -n rejected the command";
41
+ return { ok: false, kind: "shell-syntax", error: err };
42
+ }
43
+
44
+ // Layer 2: every `jq '...'` invocation gets its query parse-checked.
45
+ // Permissive regex: catches `jq -e 'X'`, `jq "X"`, `| jq 'X'`. False
46
+ // negatives are fine — better to skip a real jq than misinterpret a
47
+ // shell-quoting edge case.
48
+ //
49
+ // We distinguish COMPILE errors (the bug we want to catch — invalid
50
+ // syntax, e.g. `length as $n | .field` over an array) from RUNTIME
51
+ // errors (the query is well-formed but fails on null input from
52
+ // `-n`, like `.foo[]` on null). Only compile errors fail validation;
53
+ // runtime errors against synthetic input are expected because real
54
+ // input isn't available at plan-time.
55
+ const jqRe = /\bjq\b\s+(?:-[a-zA-Z]+\s+)*(?:'([^']+)'|"([^"]+)")/g;
56
+ for (const m of cmd.matchAll(jqRe)) {
57
+ const query = m[1] || m[2];
58
+ if (!query) continue;
59
+
60
+ // Static pattern detector for the 2026-04-29 as-binding misuse:
61
+ // `[expr] | length as $X | .field` and similar. The `as $X`
62
+ // binds the value to a name but does NOT change the value
63
+ // flowing through the pipe. The next `.field` is therefore
64
+ // applied to the array tail (or whatever the previous stage
65
+ // returned), not to the original root. Always wrong.
66
+ //
67
+ // Detection: a pipeline whose tail is `length as $X | .X` (or
68
+ // any `<terminal-operator> as $X | .field`) is the smell.
69
+ const asBindingMisuse = /\b(?:length|keys|values|min|max|add|first|last)\s+as\s+\$[A-Za-z_][\w]*\s*\|\s*\.[A-Za-z_]/;
70
+ if (asBindingMisuse.test(query)) {
71
+ return {
72
+ ok: false,
73
+ kind: "jq-syntax",
74
+ error: "Likely as-binding misuse: `length as $n | .field` does not change the value flowing through; `.field` is applied to the previous stage's output, not the root. Use `. as $r | <transform> | $r.field` instead.",
75
+ };
76
+ }
77
+
78
+ const jq = spawnSync("jq", ["-n", query], { encoding: "utf8", timeout: 5000 });
79
+ if (jq.status !== 0) {
80
+ const err = (jq.stderr || "").trim() || "jq rejected the query";
81
+ // jq distinguishes compile errors with these tokens. Anything
82
+ // else (e.g. "Cannot index null with string") is a runtime error
83
+ // against the empty input, NOT a syntax bug.
84
+ const isCompileError = /\b(compile error|syntax error|unexpected|jq: error \(at <stdin>:\d+\): ?$)/i.test(err);
85
+ if (isCompileError) {
86
+ return { ok: false, kind: "jq-syntax", error: err };
87
+ }
88
+ // Special case: `length as $n | .field` returns "Cannot index
89
+ // array/number/string with string" on -n input because the as-
90
+ // binding misuse means `.field` is applied to the wrong thing.
91
+ // This is exactly the 2026-04-29 bug class. Treat it as syntax-
92
+ // adjacent: the query is "compilable" but semantically broken
93
+ // by misuse of as-binding.
94
+ if (/Cannot index (array|number|string) with string/i.test(err)) {
95
+ return { ok: false, kind: "jq-syntax", error: err };
96
+ }
97
+ // Otherwise let it pass — runtime error against synthetic input
98
+ // is fine. jq parsed the query successfully.
99
+ }
100
+ }
101
+
102
+ return { ok: true };
103
+ }
104
+
105
+ /**
106
+ * Validate every operation's acceptance tests in a parsed Canvas.
107
+ * Returns an array of issues; empty array means everything passed.
108
+ *
109
+ * @param {object} canvas
110
+ * @returns {Array<{ operationIndex: number, operationTitle: string, testIndex: number, type: string, content: string, kind: string, error: string }>}
111
+ */
112
+ export function validateAcceptanceTests(canvas) {
113
+ const issues = [];
114
+ if (!canvas || !Array.isArray(canvas.operations)) return issues;
115
+ canvas.operations.forEach((op, opIdx) => {
116
+ (op.acceptance || []).forEach((t, tIdx) => {
117
+ if (t.type === "shell") {
118
+ const r = validateShellTest(t.content);
119
+ if (!r.ok) {
120
+ issues.push({
121
+ operationIndex: opIdx,
122
+ operationTitle: op.title,
123
+ testIndex: tIdx,
124
+ type: t.type,
125
+ content: t.content,
126
+ kind: r.kind,
127
+ error: r.error,
128
+ });
129
+ }
130
+ } else if (t.type === "gherkin") {
131
+ // Minimal gherkin sanity: must contain at least one Given AND
132
+ // one Then. When/And/But are optional. This catches "blank
133
+ // gherkin" without trying to parse the language.
134
+ if (!/\bGiven\b/i.test(t.content) || !/\bThen\b/i.test(t.content)) {
135
+ issues.push({
136
+ operationIndex: opIdx,
137
+ operationTitle: op.title,
138
+ testIndex: tIdx,
139
+ type: t.type,
140
+ content: t.content,
141
+ kind: "gherkin-incomplete",
142
+ error: "Gherkin scenario must contain at least one Given and one Then.",
143
+ });
144
+ }
145
+ }
146
+ });
147
+ });
148
+ return issues;
149
+ }
150
+
151
+ /**
152
+ * Top-level validator. Runs schema + acceptance-test parse-checks.
153
+ *
154
+ * @param {unknown} canvas
155
+ * @returns {{ valid: true, canvas: object } | { valid: false, schemaIssues?: object[], testIssues?: object[] }}
156
+ */
157
+ export function validateCanvas(canvas) {
158
+ const schema = safeParseCanvas(canvas);
159
+ if (!schema.success) return { valid: false, schemaIssues: schema.issues };
160
+ const testIssues = validateAcceptanceTests(schema.output);
161
+ if (testIssues.length > 0) return { valid: false, testIssues };
162
+ return { valid: true, canvas: schema.output };
163
+ }
@@ -88,7 +88,7 @@ export function createSerenaCheck() {
88
88
  applies: (config) => config.serena?.enabled === true,
89
89
  describe: "Install Serena via: uvx --from git+https://github.com/oraios/serena serena",
90
90
  async detect() {
91
- let ok = false;
91
+ let ok;
92
92
  try {
93
93
  const res = await runCommand("serena", ["--version"]);
94
94
  ok = res.exitCode === 0;
@@ -12,6 +12,8 @@
12
12
 
13
13
  import { isPortAvailable, findAvailablePort } from "../utils/port-check.js";
14
14
  import { getPortOccupant } from "../utils/port-occupant.js";
15
+ import { discoverRunningSonar } from "../sonar/discovery.js";
16
+ import { isSonarReachable } from "../sonar/manager.js";
15
17
  import { STRATEGY } from "./types.js";
16
18
 
17
19
  const DEFAULT_SONAR_PORT = 9000;
@@ -22,58 +24,110 @@ function extractPortFromHost(host, fallback) {
22
24
  return m ? Number(m[1]) : fallback;
23
25
  }
24
26
 
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
27
  export function createSonarPortCheck() {
37
28
  return {
38
29
  name: "port:sonar",
39
30
  label: "SonarQube port",
40
- strategy: STRATEGY.MANUAL,
41
- // Since v2.7.4 Sonar is intrinsic to Karajan for code tasks, so the
42
- // port check almost always applies. The only legitimate skip is when
43
- // the user runs their own external Sonar instance (`external: true`),
44
- // in which case the port is managed outside Karajan. The legacy
45
- // `sonarqube.enabled` field is ignored (deprecation warning emitted
46
- // at config load time — see emitConfigDeprecations in flow-runner.js).
31
+ // AUTO since v2.7.5 — Karajan now resolves SonarQube discovery on
32
+ // its own. If a Sonar container is running anywhere (any name, any
33
+ // image tag), point Karajan at it. If port 9000 is occupied by
34
+ // someone non-Sonar, rebind to the next free port. Asking the user
35
+ // to "stop the unknown process" was busywork.
36
+ strategy: STRATEGY.AUTO,
47
37
  applies: (config) => {
48
38
  if (config?.sonarqube?.external === true) {
49
39
  return { applies: false, reason: "sonarqube.external is true — port managed outside Karajan" };
50
40
  }
51
41
  return true;
52
42
  },
43
+ describe: "Discover or rebind SonarQube port (no user action needed)",
53
44
  async detect({ config }) {
54
- const port = extractPortFromHost(config?.sonarqube?.host, DEFAULT_SONAR_PORT);
55
- const free = await isPortAvailable(port);
45
+ const configuredPort = extractPortFromHost(config?.sonarqube?.host, DEFAULT_SONAR_PORT);
46
+
47
+ // Step 1 — is there ANY running SonarQube container we can reuse?
48
+ // Container name doesn't matter; we just need a published host port
49
+ // that responds to /api/system/status. This avoids "port 9000 is
50
+ // taken by an unknown process" errors when the unknown process is
51
+ // actually a perfectly good SonarQube from another project.
52
+ const discovered = await discoverRunningSonar();
53
+ if (discovered.found && discovered.reachable) {
54
+ // If the discovered host matches what's already configured,
55
+ // there's nothing to fix — happy path.
56
+ if (discovered.port === configuredPort) {
57
+ return {
58
+ ok: true,
59
+ severity: "info",
60
+ detail: `Reusing running SonarQube container "${discovered.containerName}" on port ${discovered.port}`,
61
+ };
62
+ }
63
+ // Otherwise we want to rewrite the host config at runtime.
64
+ return {
65
+ ok: false,
66
+ severity: "warn",
67
+ detail: `Found running SonarQube container "${discovered.containerName}" on port ${discovered.port} (config points at ${configuredPort})`,
68
+ extra: { remap: { host: discovered.host, port: discovered.port, container: discovered.containerName } },
69
+ };
70
+ }
71
+
72
+ // Step 2 — no running container. Is the configured port even free?
73
+ const free = await isPortAvailable(configuredPort);
56
74
  if (free) {
57
- return { ok: true, severity: "info", detail: `Port ${port} is free (Sonar container not yet running)` };
75
+ return {
76
+ ok: true,
77
+ severity: "info",
78
+ detail: `Port ${configuredPort} is free (Sonar container will be started on demand)`,
79
+ };
58
80
  }
59
- // Busy — is it our container?
60
- const occupant = await getPortOccupant(port);
61
- if (occupant && isKarajanSonarOccupant(occupant)) {
81
+
82
+ // Step 3 — port is busy. Maybe the occupant IS Sonar but not
83
+ // discovered by `docker ps` (rare: bare-metal Sonar, host-network
84
+ // container, etc.). HTTP-probe to find out.
85
+ const probeHost = `http://localhost:${configuredPort}`;
86
+ if (await isSonarReachable(probeHost)) {
62
87
  return {
63
88
  ok: true,
64
89
  severity: "info",
65
- detail: `Port ${port} held by Karajan-managed Sonar (${occupant.command} PID ${occupant.pid})`,
90
+ detail: `Port ${configuredPort} held by a Sonar instance (HTTP /api/system/status responds) — reusing it`,
66
91
  };
67
92
  }
68
- const who = occupant
69
- ? `${occupant.command || "unknown"} (PID ${occupant.pid ?? "?"})`
70
- : "unknown process";
93
+
94
+ // Step 4 — really not Sonar. Find an alternate free port and tell
95
+ // the runtime to use it. The compose template's published port is
96
+ // hardcoded at 9000 so we can't auto-rebind the container itself,
97
+ // but we CAN warn loudly with a clear, fixable message.
98
+ const occupant = await getPortOccupant(configuredPort);
99
+ const alt = await findAvailablePort(configuredPort + 1, 20);
100
+ if (alt) {
101
+ return {
102
+ ok: false,
103
+ severity: "warn",
104
+ detail: `Port ${configuredPort} occupied by ${occupant?.command || "unknown"} (PID ${occupant?.pid ?? "?"}). Will rebind Karajan-managed Sonar to ${alt}.`,
105
+ extra: { remap: { host: `http://localhost:${alt}`, port: alt, fromPort: configuredPort } },
106
+ };
107
+ }
108
+ // Last-resort failure (no free port in the next 20).
71
109
  return {
72
110
  ok: false,
73
111
  severity: "fail",
74
- detail: `Port ${port} occupied by ${who}`,
75
- fix: `Stop the process holding port ${port} or change sonarqube.host in kj.config.yml.`,
76
- extra: { port, occupant },
112
+ detail: `Port ${configuredPort} occupied by ${occupant?.command || "unknown"} (PID ${occupant?.pid ?? "?"}) and no free port in ${configuredPort + 1}..${configuredPort + 20}`,
113
+ fix: "Stop the conflicting process or set sonarqube.host in kj.config.yml.",
114
+ };
115
+ },
116
+ async remediate({ extra }) {
117
+ if (!extra?.remap) {
118
+ return { fixed: false, detail: "No remap available" };
119
+ }
120
+ const { host, container, fromPort } = extra.remap;
121
+ const detail = container
122
+ ? `Pointed Karajan at running SonarQube ${container} (${host})`
123
+ : `Rebound Karajan-managed Sonar from port ${fromPort} to ${host}`;
124
+ return {
125
+ fixed: true,
126
+ detail,
127
+ // The runner merges this into the runtime config so every
128
+ // downstream Sonar caller (scanner, manager, MCP handler) sees
129
+ // the new host without the user touching kj.config.yml.
130
+ changes: { sonarqube: { host } },
77
131
  };
78
132
  },
79
133
  };
@@ -36,7 +36,7 @@ export function createSonarStatusCheck() {
36
36
  if (!sonarEnabled(config)) {
37
37
  return { ok: true, severity: "info", detail: "Disabled in config" };
38
38
  }
39
- let reachable = false;
39
+ let reachable;
40
40
  try {
41
41
  reachable = await isSonarReachable(host);
42
42
  } catch {
@@ -32,7 +32,7 @@ export function createGitRepoCheck() {
32
32
  label: "Git repository",
33
33
  strategy: STRATEGY.MANUAL,
34
34
  async detect() {
35
- let gitOk = false;
35
+ let gitOk;
36
36
  try {
37
37
  gitOk = await ensureGitRepo();
38
38
  } catch {
@@ -0,0 +1,30 @@
1
+ import { applyRunOverrides, loadConfig, validateConfig } from "../config.js";
2
+ import { createLogger } from "../utils/logger.js";
3
+
4
+ /**
5
+ * Wraps a CLI action so it loads + validates config, builds the logger,
6
+ * and fires the anonymous cli_command telemetry event before invoking
7
+ * the real command body. Shared across every register-*.js module so
8
+ * each one doesn't need to re-import the config plumbing.
9
+ *
10
+ * @param {string} pkgVersion Current kj version (used for telemetry).
11
+ * @param {string} commandName The CLI command name being executed.
12
+ * @param {object} flags Flags parsed by Commander.
13
+ * @param {(ctx: { config: object, logger: object, flags: object }) => Promise<any>} fn
14
+ * Body of the command — receives the merged config + logger.
15
+ */
16
+ export async function withConfig(pkgVersion, commandName, flags, fn) {
17
+ const { config } = await loadConfig();
18
+ const merged = applyRunOverrides(config, flags || {});
19
+ validateConfig(merged, commandName);
20
+ const logger = createLogger(merged.output.log_level);
21
+
22
+ // Telemetry: anonymous cli_command event (non-blocking).
23
+ import("../utils/telemetry.js")
24
+ .then(({ sendTelemetryEvent }) =>
25
+ sendTelemetryEvent("cli_command", { command: commandName, version: pkgVersion }, merged)
26
+ )
27
+ .catch(() => {});
28
+
29
+ return fn({ config: merged, logger, flags });
30
+ }
@@ -0,0 +1,204 @@
1
+ import { discoverCommand } from "../commands/discover.js";
2
+ import { triageCommand } from "../commands/triage.js";
3
+ import { researcherCommand } from "../commands/researcher.js";
4
+ import { architectCommand } from "../commands/architect.js";
5
+ import { auditCommand } from "../commands/audit.js";
6
+ import { resumeCommand } from "../commands/resume.js";
7
+ import { boardCommand } from "../commands/board.js";
8
+ import { undoCommand } from "../commands/undo.js";
9
+ import { syncCommand } from "../commands/sync.js";
10
+ import { cleanCommand } from "../commands/clean.js";
11
+ import { withConfig } from "./_shared.js";
12
+
13
+ /**
14
+ * Register the "meta" / single-role / housekeeping commands: pre-pipeline
15
+ * analysis (discover, triage, researcher, architect, audit), session
16
+ * management (resume, board, undo) and maintenance (update, sync, clean).
17
+ *
18
+ * Note: `triage` lives here (not in pipeline) because it's a single-role
19
+ * analysis command in the same family as discover / researcher /
20
+ * architect / audit, none of which run the full agent loop.
21
+ */
22
+ export function registerMeta(program, { pkgVersion }) {
23
+ program
24
+ .command("discover")
25
+ .description("Analyze task for gaps, ambiguities and missing info")
26
+ .argument("[task]", "Task description (or use --task-file)")
27
+ .option("--task-file <path>", "Read the task from a file (e.g. .md)")
28
+ .option("--mode <name>", "Discovery mode: gaps|momtest|wendel|classify|jtbd", "gaps")
29
+ .option("--discover <name>", "Override discover agent")
30
+ .option("--discover-model <name>", "Override discover model")
31
+ .option("--json", "Output raw JSON")
32
+ .action(async (task, flags) => {
33
+ await withConfig(pkgVersion, "discover", flags, async ({ config, logger }) => {
34
+ const { resolveTaskInput } = await import("../utils/task-file.js");
35
+ const resolvedTask = await resolveTaskInput({ task, taskFile: flags.taskFile, projectDir: config.projectDir, logger });
36
+ await discoverCommand({ task: resolvedTask, config, logger, mode: flags.mode, json: flags.json });
37
+ });
38
+ });
39
+
40
+ program
41
+ .command("triage")
42
+ .description("Classify task complexity and recommend pipeline roles")
43
+ .argument("[task]", "Task description (or use --task-file)")
44
+ .option("--task-file <path>", "Read the task from a file (e.g. .md)")
45
+ .option("--triage <name>", "Override triage agent")
46
+ .option("--triage-model <name>", "Override triage model")
47
+ .option("--json", "Output raw JSON")
48
+ .action(async (task, flags) => {
49
+ await withConfig(pkgVersion, "triage", flags, async ({ config, logger }) => {
50
+ const { resolveTaskInput } = await import("../utils/task-file.js");
51
+ const resolvedTask = await resolveTaskInput({ task, taskFile: flags.taskFile, projectDir: config.projectDir, logger });
52
+ await triageCommand({ task: resolvedTask, config, logger, json: flags.json });
53
+ });
54
+ });
55
+
56
+ program
57
+ .command("researcher")
58
+ .description("Research codebase for a task (files, patterns, constraints)")
59
+ .argument("[task]", "Task description (or use --task-file)")
60
+ .option("--task-file <path>", "Read the task from a file (e.g. .md)")
61
+ .option("--researcher <name>", "Override researcher agent")
62
+ .option("--researcher-model <name>", "Override researcher model")
63
+ .action(async (task, flags) => {
64
+ await withConfig(pkgVersion, "researcher", flags, async ({ config, logger }) => {
65
+ const { resolveTaskInput } = await import("../utils/task-file.js");
66
+ const resolvedTask = await resolveTaskInput({ task, taskFile: flags.taskFile, projectDir: config.projectDir, logger });
67
+ await researcherCommand({ task: resolvedTask, config, logger });
68
+ });
69
+ });
70
+
71
+ program
72
+ .command("architect")
73
+ .description("Design solution architecture (layers, patterns, contracts)")
74
+ .argument("[task]", "Task description (or use --task-file)")
75
+ .option("--task-file <path>", "Read the task from a file (e.g. .md)")
76
+ .option("--architect <name>", "Override architect agent")
77
+ .option("--architect-model <name>", "Override architect model")
78
+ .option("--context <text>", "Additional context (e.g. researcher output)")
79
+ .option("--json", "Output raw JSON")
80
+ .action(async (task, flags) => {
81
+ await withConfig(pkgVersion, "architect", flags, async ({ config, logger }) => {
82
+ const { resolveTaskInput } = await import("../utils/task-file.js");
83
+ const resolvedTask = await resolveTaskInput({ task, taskFile: flags.taskFile, projectDir: config.projectDir, logger });
84
+ await architectCommand({ task: resolvedTask, config, logger, context: flags.context, json: flags.json });
85
+ });
86
+ });
87
+
88
+ program
89
+ .command("audit")
90
+ .description("Analyze codebase health (read-only)")
91
+ .argument("[task]", "Task description. If absent, defaults to a full-codebase analysis. Use --task-file to point at a .md.")
92
+ .option("--task-file <path>", "Read the task from a file (e.g. .md)")
93
+ .option("--dimensions <list>", "Comma-separated: security,quality,performance,architecture,testing", "all")
94
+ .option("--json", "Output raw JSON")
95
+ .option("--agent-readiness", "Score the repo for AI-agent readability (llms.txt, SKILL.md coverage, page token budgets, robots allowlist, heading hierarchy). LLM-free; uses [path] or cwd as the audit target. See issue #542.")
96
+ .option("--path <dir>", "Path to audit (used with --agent-readiness; defaults to cwd)")
97
+ .action(async (task, flags) => {
98
+ await withConfig(pkgVersion, "audit", flags, async ({ config, logger }) => {
99
+ let resolvedTask = task;
100
+ if (flags.taskFile) {
101
+ const { readTaskFile } = await import("../utils/task-file.js");
102
+ resolvedTask = await readTaskFile(flags.taskFile, { projectDir: config.projectDir });
103
+ }
104
+ await auditCommand({
105
+ task: resolvedTask || "Analyze the full codebase",
106
+ config, logger,
107
+ dimensions: flags.dimensions, json: flags.json,
108
+ agentReadiness: Boolean(flags.agentReadiness),
109
+ path: flags.path,
110
+ });
111
+ });
112
+ });
113
+
114
+ program
115
+ .command("resume")
116
+ .description("Resume a paused session")
117
+ .argument("<sessionId>")
118
+ .option("--answer <text>", "Answer to the question that caused the pause")
119
+ .option("--json", "Output JSON only")
120
+ .action(async (sessionId, flags) => {
121
+ await withConfig(pkgVersion, "resume", flags, async ({ config, logger }) => {
122
+ await resumeCommand({ sessionId, answer: flags.answer, config, logger, flags });
123
+ });
124
+ });
125
+
126
+ program
127
+ .command("update")
128
+ .description("Update karajan-code to the latest version from npm")
129
+ .action(async () => {
130
+ // Audit follow-up: was execaCommand (shell parsing). Inputs are
131
+ // constants today, but the project standard since #555 is execa
132
+ // with arg arrays (no shell). Migrated for consistency.
133
+ const { execa } = await import("execa");
134
+ console.log(`Current version: ${pkgVersion}`);
135
+ console.log("Checking for updates...");
136
+ try {
137
+ const { stdout } = await execa("npm", ["view", "karajan-code", "version"]);
138
+ const latest = stdout.trim();
139
+ if (latest === pkgVersion) {
140
+ console.log(`Already on the latest version (${pkgVersion}).`);
141
+ return;
142
+ }
143
+ console.log(`Updating ${pkgVersion} → ${latest}...`);
144
+ await execa("npm", ["install", "-g", "karajan-code@latest"], { stdio: "inherit" });
145
+ console.log(`Updated to ${latest}. Restart Claude to pick up the new MCP server.`);
146
+ } catch (err) {
147
+ console.error(`Update failed: ${err.message}`);
148
+ process.exit(1);
149
+ }
150
+ });
151
+
152
+ program
153
+ .command("board [action]")
154
+ .description("Manage HU Board (start|stop|status|open)")
155
+ .option("--port <number>", "Port (default: 4000)", "4000")
156
+ .action(async (action = "start", opts) => {
157
+ await withConfig(pkgVersion, "board", opts, async ({ config, logger }) => {
158
+ const port = Number(opts.port) || config.hu_board?.port || 4000;
159
+ await boardCommand({ action, port, logger });
160
+ });
161
+ });
162
+
163
+ program
164
+ .command("undo")
165
+ .description("Revert last pipeline run")
166
+ .option("--hard", "Discard all changes (default: soft reset, keeps changes staged)")
167
+ .action(async (flags) => {
168
+ await withConfig(pkgVersion, "undo", flags, async ({ logger }) => {
169
+ const result = await undoCommand({ hard: !!flags.hard, logger });
170
+ if (!result.ok) process.exit(1);
171
+ });
172
+ });
173
+
174
+ program
175
+ .command("sync")
176
+ .description("Detect drift between code and the latest plan. Read-only report (issue #540 MVP).")
177
+ .option("--plan <planId>", "Sync against a specific plan instead of the latest")
178
+ .option("--json", "Emit machine-readable JSON instead of human output")
179
+ .action(async (flags) => {
180
+ await withConfig(pkgVersion, "sync", flags, async ({ config, logger }) => {
181
+ await syncCommand({ config, logger, planId: flags.plan, json: flags.json });
182
+ });
183
+ });
184
+
185
+ program
186
+ .command("clean")
187
+ .description("Garbage-collect stale plans, sessions and HU batches (dry-run by default)")
188
+ .option("--yes", "Actually delete — without this flag, only prints what would be removed")
189
+ .option("--nuke", "Retention=0 everywhere + wipe HU board DB. \"I want it all gone\"")
190
+ .option("--plan-days <n>", "Keep finalised plans (approved/rejected/executed) for N days (default: 30)")
191
+ .option("--draft-days <n>", "Keep draft plans for N days (default: 60)")
192
+ .option("--session-days <n>", "Keep finalised sessions for N days (default: 7)")
193
+ .option("--hu-days <n>", "Keep HU story batches for N days (default: 14)")
194
+ .action(async (flags) => {
195
+ await cleanCommand({
196
+ yes: Boolean(flags.yes),
197
+ nuke: Boolean(flags.nuke),
198
+ planDays: flags.planDays ? Number(flags.planDays) : undefined,
199
+ draftDays: flags.draftDays ? Number(flags.draftDays) : undefined,
200
+ sessionDays: flags.sessionDays ? Number(flags.sessionDays) : undefined,
201
+ huDays: flags.huDays ? Number(flags.huDays) : undefined,
202
+ });
203
+ });
204
+ }