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 +5 -1
- package/package.json +1 -1
- package/src/audit/sonar-findings.js +12 -0
- package/src/cli/register-meta.js +4 -4
- package/src/cli/register-pipeline.js +3 -3
- package/src/cli/register-plan.js +1 -1
- package/src/commands/board.js +28 -4
- package/src/commands/init.js +270 -61
- package/src/config/loader.js +32 -1
- package/src/git/automation.js +94 -4
- package/src/git/hu-automation.js +35 -3
- package/src/orchestrator/config-init.js +17 -0
- package/src/orchestrator/drivers/post-loop.js +31 -2
- package/src/orchestrator/flow-runner.js +41 -0
- package/src/orchestrator/stages/sonar-stage.js +19 -0
- package/src/skills/addyosmani-catalog.js +29 -7
- package/src/sonar/project-key.js +37 -0
- package/src/sonar/token-bootstrap.js +182 -0
- package/src/types/session.js +2 -1
- package/src/utils/display/session-summary.js +12 -2
- package/src/utils/git.js +105 -1
package/README.md
CHANGED
|
@@ -23,7 +23,11 @@
|
|
|
23
23
|
|
|
24
24
|
---
|
|
25
25
|
|
|
26
|
-
> **v2.
|
|
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
|
@@ -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);
|
package/src/cli/register-meta.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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>")
|
package/src/cli/register-plan.js
CHANGED
|
@@ -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
|
|
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>")
|
package/src/commands/board.js
CHANGED
|
@@ -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.
|
|
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 {
|
|
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
|
}
|
package/src/commands/init.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
262
|
+
logger.info("");
|
|
263
|
+
logger.info("Git automation:");
|
|
264
|
+
await askGitAutomation(wizard, config, logger);
|
|
110
265
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
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
|
|
313
|
+
await writeInitConfig(configPath, updated);
|
|
146
314
|
logger.info(`Created ${configPath}`);
|
|
147
315
|
} else if (!configExists) {
|
|
148
|
-
await
|
|
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
|
|
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
|
+
};
|
package/src/config/loader.js
CHANGED
|
@@ -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
|
-
|
|
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.
|