karajan-code 2.10.1 → 2.11.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.
package/README.md CHANGED
@@ -23,7 +23,11 @@
23
23
 
24
24
  ---
25
25
 
26
- > **v2.10.1 released** — Patch release. One-line fix for a stdout contamination bug in `kj audit --agent-readiness --json` (the `[info]` banner was breaking downstream `jq` pipes), plus polish in the asciinema demo scripts. Safe upgrade from 2.10.0.
26
+ > **v2.11.0 released** — Dogfooding pass release. Two-day pass through a 10-level test plan surfaced and fixed a long tail of UX papercuts and three latent bugs that only show up on fresh `/tmp` repos: the `SonarStage` no longer loops on remoteless projects (was burning iterations until `max_iterations`-fallback-approval), the post-loop `commitAll` now tolerates the locale-specific "nothing to commit" race, the HU sub-pipeline branches off `master`/`HEAD` when the configured `main` doesn't exist, and `runFlow` now seals `session.status` at the boundary so `kj status` never shows zombi `running` runs again. Plus `hu-board` gains automatic ephemeral-project cleanup and an in-UI help modal for the five views. 14 PRs (#624–#637), 4452/4452 tests passing. Safe upgrade from 2.10.2.
27
+ >
28
+ > **v2.10.2** — Patch release. `kj init` wizard expanded from 9 prompts to a full setup: per-role provider selection (10 roles, "inherit / pick CLI / disable"), automatic SonarQube token generation via REST API (no more web UI walkthrough), git automation flags (`auto_commit/push/pr`) and HU Board security (bind host + port). +16 new tests. Safe upgrade from 2.10.1.
29
+ >
30
+ > **v2.10.1** — Patch release. One-line fix for a stdout contamination bug in `kj audit --agent-readiness --json` (the `[info]` banner was breaking downstream `jq` pipes), plus polish in the asciinema demo scripts. Safe upgrade from 2.10.0.
27
31
  >
28
32
  > **v2.10.0** — Agent-readiness release. Karajan is now the first orchestrator with a full agent-readability surface: an `llms.txt` index at the root, a `SKILL.md` per CLI command under `docs/agents/`, and a static auditor that scores any third-party repo against the same shape. Highlights: (1) **`kj audit --agent-readiness`** scores any repo 0–100 across seven checks (llms.txt, robots AI-bot allowlist, page token budgets ≤ 32 KB, heading hierarchy, agents/README, SKILL.md coverage). LLM-free, deterministic, JSON-able. Karajan-on-Karajan: **100/100**. (2) **Six new SKILL.md files** (`kj doctor / init / board / review / resume / clean`) under `docs/agents/`, all with the same `What it does · Inputs · Outputs · Side effects · Failure modes · Example` contract; CI guards every link in `llms.txt` resolves. (3) **Webperf quality gate inside the iteration loop** (`pipeline.perf.enabled`): PASS continues, FAIL pushes blocking-metric feedback to the coder, scanner-missing skips best-effort. (4) **HU Board hardening**: binds 127.0.0.1 by default, opt-in `--bind 0.0.0.0` enforces an auto-generated token, helmet headers, rate limiting at 300 req/min — "safe by default on a coffee-shop WiFi". (5) **a11y skills auto-route**: tasks mentioning `accessibility / WCAG / ARIA / screen reader / keyboard nav` automatically pull the `frontend-ui-engineering` skill. (6) **Asciinema demo scripts** under `docs/demos/` so the recordings re-record per release instead of rotting. 5 PRs merged (#605–#609 + #610), 4358/4358 tests passing. See [CHANGELOG.md](./CHANGELOG.md) for the full punch list.
29
33
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "2.10.1",
3
+ "version": "2.11.0",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -22,6 +22,7 @@
22
22
 
23
23
  import { isSonarReachable } from "../sonar/manager.js";
24
24
  import { getOpenIssues, getQualityGateStatus } from "../sonar/api.js";
25
+ import { canResolveSonarProjectKey } from "../sonar/project-key.js";
25
26
 
26
27
  /**
27
28
  * Collect deterministic Sonar findings for the audit prompt.
@@ -43,6 +44,17 @@ export async function collectSonarFindings(config, logger = null) {
43
44
  return { available: false, reason: `sonar host ${host} not reachable` };
44
45
  }
45
46
 
47
+ // No git remote and no explicit project_key → silent skip. This is the
48
+ // typical `kj run` in a fresh /tmp/... folder. Reporting it as a
49
+ // warning treats it like an error (which it isn't — sonar simply
50
+ // doesn't apply to a remoteless repo).
51
+ if (!(await canResolveSonarProjectKey(config))) {
52
+ return {
53
+ available: false,
54
+ reason: "not applicable: no git remote and no sonarqube.project_key configured",
55
+ };
56
+ }
57
+
46
58
  let issuesResult;
47
59
  try {
48
60
  issuesResult = await getOpenIssues(config);
@@ -24,7 +24,7 @@ export function registerMeta(program, { pkgVersion }) {
24
24
  program
25
25
  .command("discover")
26
26
  .description("Analyze task for gaps, ambiguities and missing info")
27
- .argument("[task]", "Task description (or use --task-file)")
27
+ .argument("[task]", "Task description (REQUIRED — provide as argument or via --task-file)")
28
28
  .option("--task-file <path>", "Read the task from a file (e.g. .md)")
29
29
  .option("--mode <name>", "Discovery mode: gaps|momtest|wendel|classify|jtbd", "gaps")
30
30
  .option("--discover <name>", "Override discover agent")
@@ -41,7 +41,7 @@ export function registerMeta(program, { pkgVersion }) {
41
41
  program
42
42
  .command("triage")
43
43
  .description("Classify task complexity and recommend pipeline roles")
44
- .argument("[task]", "Task description (or use --task-file)")
44
+ .argument("[task]", "Task description (REQUIRED — provide as argument or via --task-file)")
45
45
  .option("--task-file <path>", "Read the task from a file (e.g. .md)")
46
46
  .option("--triage <name>", "Override triage agent")
47
47
  .option("--triage-model <name>", "Override triage model")
@@ -57,7 +57,7 @@ export function registerMeta(program, { pkgVersion }) {
57
57
  program
58
58
  .command("researcher")
59
59
  .description("Research codebase for a task (files, patterns, constraints)")
60
- .argument("[task]", "Task description (or use --task-file)")
60
+ .argument("[task]", "Task description (REQUIRED — provide as argument or via --task-file)")
61
61
  .option("--task-file <path>", "Read the task from a file (e.g. .md)")
62
62
  .option("--researcher <name>", "Override researcher agent")
63
63
  .option("--researcher-model <name>", "Override researcher model")
@@ -72,7 +72,7 @@ export function registerMeta(program, { pkgVersion }) {
72
72
  program
73
73
  .command("architect")
74
74
  .description("Design solution architecture (layers, patterns, contracts)")
75
- .argument("[task]", "Task description (or use --task-file)")
75
+ .argument("[task]", "Task description (REQUIRED — provide as argument or via --task-file)")
76
76
  .option("--task-file <path>", "Read the task from a file (e.g. .md)")
77
77
  .option("--architect <name>", "Override architect agent")
78
78
  .option("--architect-model <name>", "Override architect model")
@@ -40,7 +40,7 @@ export function registerPipeline(program, { pkgVersion }) {
40
40
  program
41
41
  .command("run")
42
42
  .description("Run coder+sonar+reviewer loop")
43
- .argument("[task]", "Task description (or use --task-file to read from a .md file)")
43
+ .argument("[task]", "Task description (REQUIRED — provide as argument or via --task-file)")
44
44
  .option("--task-file <path>", "Read the task from a file (e.g. .md). Alternative to the positional task argument.")
45
45
  .option("-y, --yes", "Skip the cwd confirmation prompt (CI / scripted runs).")
46
46
  .option("--planner <name>")
@@ -135,7 +135,7 @@ export function registerPipeline(program, { pkgVersion }) {
135
135
  program
136
136
  .command("code")
137
137
  .description("Run only coder")
138
- .argument("[task]", "Task description (or use --task-file)")
138
+ .argument("[task]", "Task description (REQUIRED — provide as argument or via --task-file)")
139
139
  .option("--task-file <path>", "Read the task from a file (e.g. .md)")
140
140
  .option("--coder <name>")
141
141
  .option("--coder-model <name>")
@@ -150,7 +150,7 @@ export function registerPipeline(program, { pkgVersion }) {
150
150
  program
151
151
  .command("review")
152
152
  .description("Run only reviewer")
153
- .argument("[task]", "Task description (or use --task-file)")
153
+ .argument("[task]", "Task description (REQUIRED — provide as argument or via --task-file)")
154
154
  .option("--task-file <path>", "Read the task from a file (e.g. .md)")
155
155
  .option("--reviewer <name>")
156
156
  .option("--reviewer-model <name>")
@@ -26,7 +26,7 @@ export function registerPlan(program, { pkgVersion }) {
26
26
  plan
27
27
  .command("generate", { isDefault: true })
28
28
  .description("Generate implementation plan with HUs")
29
- .argument("[task]", "Task description (or use --task-file)")
29
+ .argument("[task]", "Task description (REQUIRED — provide as argument or via --task-file)")
30
30
  .option("--task-file <path>", "Read the task from a file (e.g. .md)")
31
31
  .option("--planner <name>")
32
32
  .option("--planner-model <name>")
@@ -126,14 +126,29 @@ export async function startBoard(desiredPort = 4000, opts = {}) {
126
126
  const karajanHome = getKarajanHome();
127
127
  fs.mkdirSync(karajanHome, { recursive: true });
128
128
 
129
+ // Open a log file for the daemon. Pre-fix the daemon was spawned
130
+ // with `stdio: "ignore"`, so every `console.log` from server.js
131
+ // (including the new [zombie-reaper] messages) went straight to
132
+ // /dev/null and the user had no way to see what was happening.
133
+ // The file is opened in append-only mode so successive starts
134
+ // don't truncate the history.
135
+ const logPath = path.join(karajanHome, "hu-board.log");
136
+ const logFd = fs.openSync(logPath, "a");
137
+ fs.writeSync(
138
+ logFd,
139
+ `\n--- kj board start at ${new Date().toISOString()} (port ${port}, bind ${bind}) ---\n`,
140
+ );
141
+
129
142
  // Use process.execPath (absolute path to current node binary) instead of "node".
130
143
  // Fixes ENOENT on nvm setups where `node` is not in the spawned process's PATH.
131
144
  const child = spawn(process.execPath, [serverPath], {
132
145
  env: { ...process.env, PORT: String(port), BIND_HOST: bind },
133
146
  detached: true,
134
- stdio: "ignore",
147
+ stdio: ["ignore", logFd, logFd],
135
148
  cwd: BOARD_DIR
136
149
  });
150
+ // The parent doesn't need the fd anymore — the child inherited it.
151
+ fs.closeSync(logFd);
137
152
  // Swallow spawn errors so an HU Board failure never crashes the pipeline.
138
153
  child.on("error", () => { /* non-blocking — caller logs via tryAutoStartBoard catch */ });
139
154
  if (!child.pid) {
@@ -143,10 +158,16 @@ export async function startBoard(desiredPort = 4000, opts = {}) {
143
158
  // Detach for real: `detached: true` only sets up the new session;
144
159
  // without `unref()` the parent's event loop still holds a reference
145
160
  // to the child handle and `kj board start` hangs at the prompt
146
- // instead of returning. Pair this with stdio:"ignore" (already set)
147
- // to ensure the parent isn't waiting on any of the child's pipes.
161
+ // instead of returning.
148
162
  child.unref();
149
- return { ok: true, alreadyRunning: false, pid: child.pid, url: buildBoardUrl(port, projectSlug), port };
163
+ return {
164
+ ok: true,
165
+ alreadyRunning: false,
166
+ pid: child.pid,
167
+ url: buildBoardUrl(port, projectSlug),
168
+ port,
169
+ logPath,
170
+ };
150
171
  }
151
172
 
152
173
  export async function stopBoard(opts = {}) {
@@ -273,6 +294,9 @@ export async function boardCommand({ action = "start", port = 4000, bind = "127.
273
294
  logger.info(`HU Board already running (PID ${result.pid}) at ${result.url}`);
274
295
  } else {
275
296
  logger.info(`HU Board started (PID ${result.pid}) at ${result.url}`);
297
+ if (result.logPath) {
298
+ logger.info(`Daemon logs: tail -f ${result.logPath}`);
299
+ }
276
300
  if (bind !== "127.0.0.1" && bind !== "localhost") {
277
301
  logger.warn(`HU Board bound to ${bind} — token auth enforced for non-loopback peers. Token: ~/.karajan/hu-board/token`);
278
302
  }
@@ -13,6 +13,200 @@ import { detectOsLocale, SUPPORTED_LANGUAGES } from "../utils/locale.js";
13
13
  import { detectRtk } from "../utils/rtk-detect.js";
14
14
  import { installRtk } from "../utils/rtk-install.js";
15
15
  import { detectProjectStack } from "../utils/stack-detect.js";
16
+ import { bootstrapSonarToken } from "../sonar/token-bootstrap.js";
17
+
18
+ /**
19
+ * Roles asked individually after coder/reviewer. Each entry:
20
+ * - key: config.roles.<key>.provider
21
+ * - pipelineKey: config.pipeline.<key>.enabled (omit when not gated by a flag)
22
+ * - default: which role's CLI to inherit ("coder" | "reviewer")
23
+ * - allowDisable: true if "off" is a valid choice
24
+ *
25
+ * Order matters — keep the most-impactful first so the wizard never hides
26
+ * critical roles behind a long tail of opt-ins.
27
+ */
28
+ const PER_ROLE_QUESTIONS = [
29
+ { key: "planner", pipelineKey: "planner", default: "coder", allowDisable: true, label: "Planner (decomposes complex tasks into HUs)" },
30
+ { key: "researcher", pipelineKey: "researcher", default: "coder", allowDisable: true, label: "Researcher (explores codebase before coding)" },
31
+ { key: "architect", pipelineKey: "architect", default: "coder", allowDisable: true, label: "Architect (designs solution + contracts)" },
32
+ { key: "tester", pipelineKey: "tester", default: "coder", allowDisable: false, label: "Tester (verifies tests + coverage)" },
33
+ { key: "security", pipelineKey: "security", default: "coder", allowDisable: false, label: "Security (OWASP audit)" },
34
+ { key: "solomon", pipelineKey: "solomon", default: "reviewer", allowDisable: false, label: "Solomon (judge AI for dilemmas)" },
35
+ { key: "refactorer", pipelineKey: "refactorer", default: "coder", allowDisable: true, label: "Refactorer (clarity passes)" },
36
+ { key: "impeccable", pipelineKey: "impeccable", default: "coder", allowDisable: true, label: "Impeccable (UI/UX audit — frontend only)" },
37
+ { key: "perf", pipelineKey: "perf", default: "coder", allowDisable: true, label: "Perf (Core Web Vitals gate — frontend only)" },
38
+ { key: "hu_reviewer", pipelineKey: "hu_reviewer", default: "reviewer", allowDisable: true, label: "HU Reviewer (mandatory user-story certification)" },
39
+ ];
40
+
41
+ // ---- Sub-asks ----
42
+
43
+ async function askAgents(wizard, available, config, logger) {
44
+ if (available.length === 1) {
45
+ const only = available[0].name;
46
+ logger.info(`Only one agent available: ${only}. Using it for all roles.\n`);
47
+ config.coder = only;
48
+ config.reviewer = only;
49
+ config.roles.coder.provider = only;
50
+ config.roles.reviewer.provider = only;
51
+ return { coder: only, reviewer: only };
52
+ }
53
+
54
+ const agentOptions = available.map((a) => ({
55
+ label: `${a.name} (${a.version})`,
56
+ value: a.name,
57
+ available: true,
58
+ }));
59
+
60
+ const coder = await wizard.select("Select default CODER agent:", agentOptions);
61
+ config.coder = coder;
62
+ config.roles.coder.provider = coder;
63
+ logger.info(` -> Coder: ${coder}`);
64
+
65
+ const reviewer = await wizard.select("Select default REVIEWER agent:", agentOptions);
66
+ config.reviewer = reviewer;
67
+ config.roles.reviewer.provider = reviewer;
68
+ logger.info(` -> Reviewer: ${reviewer}`);
69
+
70
+ return { coder, reviewer };
71
+ }
72
+
73
+ /**
74
+ * For each non-coder/non-reviewer role, ask: use coder default | use reviewer
75
+ * default | pick a specific CLI | (when allowed) disable. The choice is
76
+ * persisted as `config.roles.<role>.provider` (null means "inherit from
77
+ * coder" at runtime). Pipeline-flag-gated roles also flip
78
+ * `config.pipeline.<role>.enabled`.
79
+ *
80
+ * `available.length === 1` short-circuits: every role uses the only CLI
81
+ * available, no point asking.
82
+ */
83
+ async function askPerRoleProviders(wizard, available, config, { coder, reviewer }, logger) {
84
+ if (available.length <= 1) return;
85
+
86
+ logger.info("");
87
+ logger.info("Configure provider per role (Enter accepts the default):");
88
+
89
+ // Make sure containers exist for every role/pipeline key we touch —
90
+ // older configs may have a sparser shape than DEFAULTS.
91
+ config.roles = config.roles || {};
92
+ config.pipeline = config.pipeline || {};
93
+
94
+ for (const role of PER_ROLE_QUESTIONS) {
95
+ config.roles[role.key] = config.roles[role.key] || { provider: null, model: null };
96
+ if (role.pipelineKey) {
97
+ config.pipeline[role.pipelineKey] = config.pipeline[role.pipelineKey] || { enabled: false };
98
+ }
99
+
100
+ const defaultLabel = role.default === "coder" ? `coder (${coder})` : `reviewer (${reviewer})`;
101
+ const baseOptions = [
102
+ { label: `Use ${defaultLabel} — default`, value: "__default__", available: true },
103
+ ];
104
+ for (const a of available) {
105
+ if ((role.default === "coder" && a.name === coder) || (role.default === "reviewer" && a.name === reviewer)) continue;
106
+ baseOptions.push({ label: a.name, value: a.name, available: true });
107
+ }
108
+ if (role.allowDisable) {
109
+ baseOptions.push({ label: "Disable role", value: "__disabled__", available: true });
110
+ }
111
+
112
+ const choice = await wizard.select(` ${role.label}:`, baseOptions);
113
+
114
+ if (choice === "__disabled__") {
115
+ if (role.pipelineKey) config.pipeline[role.pipelineKey].enabled = false;
116
+ logger.info(` -> ${role.key}: disabled`);
117
+ } else if (choice === "__default__") {
118
+ // Inherit from coder/reviewer at runtime — leave provider null.
119
+ config.roles[role.key].provider = null;
120
+ if (role.pipelineKey) config.pipeline[role.pipelineKey].enabled = true;
121
+ logger.info(` -> ${role.key}: ${defaultLabel}`);
122
+ } else {
123
+ config.roles[role.key].provider = choice;
124
+ if (role.pipelineKey) config.pipeline[role.pipelineKey].enabled = true;
125
+ logger.info(` -> ${role.key}: ${choice}`);
126
+ }
127
+ }
128
+ }
129
+
130
+ async function askMethodology(wizard, config, logger) {
131
+ const methodology = await wizard.select("Development methodology:", [
132
+ { label: "TDD (test-driven development)", value: "tdd", available: true },
133
+ { label: "Standard (no TDD enforcement)", value: "standard", available: true },
134
+ ]);
135
+ config.development.methodology = methodology;
136
+ config.development.require_test_changes = methodology === "tdd";
137
+ logger.info(` -> Methodology: ${methodology}`);
138
+ }
139
+
140
+ async function askLanguages(wizard, config, logger) {
141
+ const detectedLocale = detectOsLocale();
142
+ const allLangEntries = Object.entries(SUPPORTED_LANGUAGES).map(([code, name]) => ({
143
+ label: `${name} (${code})`,
144
+ value: code,
145
+ available: true,
146
+ }));
147
+ const languageOptions = [
148
+ ...allLangEntries.filter((o) => o.value === detectedLocale),
149
+ ...allLangEntries.filter((o) => o.value !== detectedLocale),
150
+ ];
151
+
152
+ const pipelineLang = await wizard.select("Pipeline language:", languageOptions);
153
+ config.language = pipelineLang;
154
+ logger.info(` -> Pipeline language: ${pipelineLang}`);
155
+
156
+ const huLangOptions = [
157
+ ...allLangEntries.filter((o) => o.value === pipelineLang),
158
+ ...allLangEntries.filter((o) => o.value !== pipelineLang),
159
+ ];
160
+ const huLang = await wizard.select("HU language (for user stories):", huLangOptions);
161
+ config.hu_language = huLang;
162
+ logger.info(` -> HU language: ${huLang}`);
163
+ }
164
+
165
+ async function askGitAutomation(wizard, config, logger) {
166
+ config.git = config.git || {};
167
+ const autoCommit = await wizard.confirm("Auto-commit per HU? (recommended for `kj run` so commits show up in git log)", false);
168
+ config.git.auto_commit = autoCommit;
169
+ logger.info(` -> auto_commit: ${autoCommit}`);
170
+
171
+ const autoPush = await wizard.confirm("Auto-push to remote after each commit?", false);
172
+ config.git.auto_push = autoPush;
173
+ logger.info(` -> auto_push: ${autoPush}`);
174
+
175
+ const autoPr = await wizard.confirm("Auto-open a Pull Request when the run finishes?", false);
176
+ config.git.auto_pr = autoPr;
177
+ logger.info(` -> auto_pr: ${autoPr}`);
178
+
179
+ // Branch prefix only worth asking when auto_commit is on; otherwise leave default.
180
+ if (autoCommit) {
181
+ const prefix = await wizard.ask("Branch prefix (e.g. feat/, kj/, ai/) [feat/]: ");
182
+ config.git.branch_prefix = (prefix && prefix.trim()) || "feat/";
183
+ logger.info(` -> branch_prefix: ${config.git.branch_prefix}`);
184
+ }
185
+ }
186
+
187
+ async function askBoardSecurity(wizard, config, logger) {
188
+ if (!config.hu_board?.enabled) return;
189
+
190
+ const bindChoice = await wizard.select(
191
+ "HU Board bind host:",
192
+ [
193
+ { label: "127.0.0.1 (loopback only — recommended for personal laptops)", value: "127.0.0.1", available: true },
194
+ { label: "0.0.0.0 (LAN exposure — token auth auto-enforced for non-loopback peers)", value: "0.0.0.0", available: true },
195
+ ],
196
+ );
197
+ config.hu_board.bind = bindChoice;
198
+ logger.info(` -> HU Board bind: ${bindChoice}`);
199
+
200
+ if (bindChoice === "0.0.0.0") {
201
+ logger.info(" A secret token will be auto-generated on first start at ~/.karajan/hu-board/token (mode 0600).");
202
+ logger.info(" Open the URL printed by `kj board start` to grab it.");
203
+ }
204
+
205
+ const portInput = await wizard.ask("HU Board port [4000]: ");
206
+ const port = Number.parseInt(portInput, 10);
207
+ config.hu_board.port = Number.isFinite(port) && port > 0 && port < 65536 ? port : 4000;
208
+ logger.info(` -> HU Board port: ${config.hu_board.port}`);
209
+ }
16
210
 
17
211
  async function runWizard(config, logger) {
18
212
  const agents = await detectAvailableAgents();
@@ -41,30 +235,7 @@ async function runWizard(config, logger) {
41
235
 
42
236
  const wizard = createWizard();
43
237
  try {
44
- if (available.length === 1) {
45
- const only = available[0].name;
46
- logger.info(`Only one agent available: ${only}. Using it for all roles.\n`);
47
- config.coder = only;
48
- config.reviewer = only;
49
- config.roles.coder.provider = only;
50
- config.roles.reviewer.provider = only;
51
- } else {
52
- const agentOptions = available.map((a) => ({
53
- label: `${a.name} (${a.version})`,
54
- value: a.name,
55
- available: true
56
- }));
57
-
58
- const coder = await wizard.select("Select default CODER agent:", agentOptions);
59
- config.coder = coder;
60
- config.roles.coder.provider = coder;
61
- logger.info(` -> Coder: ${coder}`);
62
-
63
- const reviewer = await wizard.select("Select default REVIEWER agent:", agentOptions);
64
- config.reviewer = reviewer;
65
- config.roles.reviewer.provider = reviewer;
66
- logger.info(` -> Reviewer: ${reviewer}`);
67
- }
238
+ const { coder, reviewer } = await askAgents(wizard, available, config, logger);
68
239
 
69
240
  const enableTriage = await wizard.confirm("Enable triage (auto-classify task complexity)?", false);
70
241
  config.pipeline.triage = config.pipeline.triage || {};
@@ -84,38 +255,19 @@ async function runWizard(config, logger) {
84
255
  }
85
256
  logger.info(` -> HU Board: ${enableHuBoard ? "enabled (auto-start on kj run)" : "disabled"}`);
86
257
 
87
- const methodology = await wizard.select("Development methodology:", [
88
- { label: "TDD (test-driven development)", value: "tdd", available: true },
89
- { label: "Standard (no TDD enforcement)", value: "standard", available: true }
90
- ]);
91
- config.development.methodology = methodology;
92
- config.development.require_test_changes = methodology === "tdd";
93
- logger.info(` -> Methodology: ${methodology}`);
94
-
95
- const detectedLocale = detectOsLocale();
96
- const allLangEntries = Object.entries(SUPPORTED_LANGUAGES).map(([code, name]) => ({
97
- label: `${name} (${code})`,
98
- value: code,
99
- available: true
100
- }));
101
- // Put detected locale first so it becomes the default selection
102
- const languageOptions = [
103
- ...allLangEntries.filter((o) => o.value === detectedLocale),
104
- ...allLangEntries.filter((o) => o.value !== detectedLocale)
105
- ];
258
+ await askPerRoleProviders(wizard, available, config, { coder, reviewer }, logger);
259
+ await askMethodology(wizard, config, logger);
260
+ await askLanguages(wizard, config, logger);
106
261
 
107
- const pipelineLang = await wizard.select("Pipeline language:", languageOptions);
108
- config.language = pipelineLang;
109
- logger.info(` -> Pipeline language: ${pipelineLang}`);
262
+ logger.info("");
263
+ logger.info("Git automation:");
264
+ await askGitAutomation(wizard, config, logger);
110
265
 
111
- // Reorder for HU language with pipeline language as default
112
- const huLangOptions = [
113
- ...allLangEntries.filter((o) => o.value === pipelineLang),
114
- ...allLangEntries.filter((o) => o.value !== pipelineLang)
115
- ];
116
- const huLang = await wizard.select("HU language (for user stories):", huLangOptions);
117
- config.hu_language = huLang;
118
- logger.info(` -> HU language: ${huLang}`);
266
+ if (enableHuBoard) {
267
+ logger.info("");
268
+ logger.info("HU Board security:");
269
+ await askBoardSecurity(wizard, config, logger);
270
+ }
119
271
 
120
272
  logger.info("");
121
273
  } finally {
@@ -125,6 +277,22 @@ async function runWizard(config, logger) {
125
277
  return config;
126
278
  }
127
279
 
280
+ /**
281
+ * Backwards-compatible alias for `writeConfig`. Pre-KJC-BUG-0036 this
282
+ * wrapper was the *only* writer that stripped `sonarqube.enabled`
283
+ * before serialising — we needed it because the global `writeConfig`
284
+ * would happily persist the wizard's transient hint. KJC-BUG-0036
285
+ * moved that strip (and the equivalent `_deprecated` strip) into
286
+ * `writeConfig` itself, so this is now a thin alias kept around to
287
+ * keep callers and tests stable. Prefer `writeConfig` in new code.
288
+ *
289
+ * @param {string} configPath
290
+ * @param {object} config
291
+ */
292
+ export async function writeInitConfig(configPath, config) {
293
+ await writeConfig(configPath, config);
294
+ }
295
+
128
296
  async function handleConfigSetup({ config, configExists, interactive, configPath, logger }) {
129
297
  if (configExists && interactive) {
130
298
  const wizard = createWizard();
@@ -132,7 +300,7 @@ async function handleConfigSetup({ config, configExists, interactive, configPath
132
300
  const reconfigure = await wizard.confirm("Config already exists. Reconfigure?", false);
133
301
  if (reconfigure) {
134
302
  const updated = await runWizard(config, logger);
135
- await writeConfig(configPath, updated);
303
+ await writeInitConfig(configPath, updated);
136
304
  logger.info(`Updated ${configPath}`);
137
305
  } else {
138
306
  logger.info("Keeping existing config.");
@@ -142,10 +310,10 @@ async function handleConfigSetup({ config, configExists, interactive, configPath
142
310
  }
143
311
  } else if (!configExists && interactive) {
144
312
  const updated = await runWizard(config, logger);
145
- await writeConfig(configPath, updated);
313
+ await writeInitConfig(configPath, updated);
146
314
  logger.info(`Created ${configPath}`);
147
315
  } else if (!configExists) {
148
- await writeConfig(configPath, config);
316
+ await writeInitConfig(configPath, config);
149
317
  logger.info(`Created ${configPath}`);
150
318
  }
151
319
  }
@@ -182,7 +350,7 @@ async function ensureCoderRules(coderRulesPath, logger) {
182
350
  logger.info("Created .karajan/coder-rules.md");
183
351
  }
184
352
 
185
- async function setupSonarQube(config, logger) {
353
+ async function setupSonarQube(config, logger, { interactive } = {}) {
186
354
  if (config.sonarqube?.enabled === false) {
187
355
  logger.info("SonarQube disabled — skipping container setup.");
188
356
  return;
@@ -212,8 +380,32 @@ async function setupSonarQube(config, logger) {
212
380
  }
213
381
 
214
382
  logger.info("SonarQube container started");
383
+
384
+ // KJC-TSK-0367: try to bootstrap the analysis token automatically. The
385
+ // bootstrapper logs in with admin/admin, rotates the password if it's
386
+ // still the default, and POSTs to /api/user_tokens/generate. On 401 or
387
+ // any other failure, we fall back to the manual flow that was the only
388
+ // option before this card.
389
+ if (interactive) {
390
+ logger.info("Bootstrapping SonarQube token automatically...");
391
+ const result = await bootstrapSonarToken({
392
+ host: config.sonarqube.host || "http://localhost:9000",
393
+ configPath: getConfigPath(),
394
+ });
395
+ if (result.ok) {
396
+ config.sonarqube.token = result.token;
397
+ logger.info(` -> Sonar token saved to ${result.savedTo}`);
398
+ if (result.passwordRotated) {
399
+ logger.info(` -> Sonar admin password rotated. New value at ${result.passwordSavedTo}`);
400
+ }
401
+ return;
402
+ }
403
+ logger.warn(`Could not auto-generate Sonar token: ${result.reason}`);
404
+ logger.info("Falling back to manual flow:");
405
+ }
406
+
215
407
  logger.info("");
216
- logger.info("To configure the SonarQube token:");
408
+ logger.info("To configure the SonarQube token manually:");
217
409
  logger.info(" 1. Open http://localhost:9000");
218
410
  logger.info(" 2. Log in (default credentials: admin / admin)");
219
411
  logger.info(" 3. Go to: My Account > Security > Generate Token");
@@ -385,7 +577,7 @@ export async function initCommand({ logger, flags = {} }) {
385
577
  config.development.methodology = config.development.methodology || "tdd";
386
578
  }
387
579
 
388
- await writeConfig(configPath, config);
580
+ await writeInitConfig(configPath, config);
389
581
  } else {
390
582
  logger.info("No project stack detected — using default configuration.");
391
583
  }
@@ -402,9 +594,15 @@ export async function initCommand({ logger, flags = {} }) {
402
594
  }
403
595
  }
404
596
 
405
- await setupSonarQube(config, logger);
597
+ await setupSonarQube(config, logger, { interactive });
406
598
  await scaffoldCiGateway(config, flags, logger);
407
599
 
600
+ // Persist any changes setupSonarQube made (token, etc.) back to disk.
601
+ // Use writeInitConfig so the deprecated `sonarqube.enabled` key —
602
+ // which setupSonarQube still mutates as an in-memory hint — never
603
+ // reaches the YAML file.
604
+ await writeInitConfig(configPath, config);
605
+
408
606
  // Telemetry: anonymous install event (non-blocking)
409
607
  const { sendTelemetryEvent } = await import("../utils/telemetry.js");
410
608
  const { readFileSync } = await import("node:fs");
@@ -416,3 +614,14 @@ export async function initCommand({ logger, flags = {} }) {
416
614
  sendTelemetryEvent("install", { version }, config).catch(() => {});
417
615
  } catch { /* non-blocking */ }
418
616
  }
617
+
618
+ // Internal exports for unit testing (KJC-TSK-0367)
619
+ export const __test__ = {
620
+ askAgents,
621
+ askPerRoleProviders,
622
+ askMethodology,
623
+ askLanguages,
624
+ askGitAutomation,
625
+ askBoardSecurity,
626
+ PER_ROLE_QUESTIONS,
627
+ };
@@ -128,9 +128,40 @@ export async function loadConfig(projectDir) {
128
128
  return { config: merged, path: configPath, exists: globalExists, hasProjectConfig: !!projectConfig };
129
129
  }
130
130
 
131
+ /**
132
+ * Runtime-only keys that the loader synthesises on every load (or that
133
+ * a wizard uses as transient in-memory hints). They MUST NOT survive
134
+ * to the on-disk YAML — otherwise the `kj run` deprecation warning
135
+ * fossilises (KJC-BUG-0036, observed in dogfooding 2026-05-07: the
136
+ * `_deprecated.sonarqubeEnabledKey: true` block ended up baked into
137
+ * `~/.karajan/kj.config.yml` and re-fired the warning on every run
138
+ * even after the user cleaned `sonarqube.enabled`).
139
+ *
140
+ * Pure: returns a shallow-copied config without the runtime keys —
141
+ * the caller's object is left untouched.
142
+ */
143
+ function stripRuntimeOnlyKeys(config) {
144
+ if (!config || typeof config !== "object") return config;
145
+ const out = { ...config };
146
+ // _deprecated is loader-synthesised — never written intentionally.
147
+ if ("_deprecated" in out) delete out._deprecated;
148
+ // sonarqube.enabled was deprecated in v2.7.4. The init wizard still
149
+ // uses it as a hint to drive setupSonarQube during install, but the
150
+ // runtime ignores it. Persisting it would re-trigger the deprecation
151
+ // warning on every subsequent kj run.
152
+ if (out.sonarqube && typeof out.sonarqube === "object"
153
+ && Object.prototype.hasOwnProperty.call(out.sonarqube, "enabled")) {
154
+ const { enabled: _drop, ...rest } = out.sonarqube;
155
+ void _drop;
156
+ out.sonarqube = rest;
157
+ }
158
+ return out;
159
+ }
160
+
131
161
  export async function writeConfig(configPath, config) {
132
162
  await ensureDir(path.dirname(configPath));
133
- await fs.writeFile(configPath, yaml.dump(config, { lineWidth: 120 }), "utf8");
163
+ const sanitized = stripRuntimeOnlyKeys(config);
164
+ await fs.writeFile(configPath, yaml.dump(sanitized, { lineWidth: 120 }), "utf8");
134
165
  }
135
166
 
136
167
  // Declarative mappings for applyRunOverrides to reduce cognitive complexity.