karajan-code 2.10.2 → 2.12.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 (47) hide show
  1. package/README.md +5 -1
  2. package/package.json +1 -1
  3. package/src/audit/plan-adherence.js +222 -0
  4. package/src/audit/sonar-findings.js +12 -0
  5. package/src/cli/register-meta.js +4 -4
  6. package/src/cli/register-pipeline.js +3 -3
  7. package/src/cli/register-plan.js +1 -1
  8. package/src/commands/board.js +28 -4
  9. package/src/commands/init.js +24 -5
  10. package/src/config/loader.js +32 -1
  11. package/src/git/automation.js +94 -4
  12. package/src/git/hu-automation.js +35 -3
  13. package/src/golden/asserter.js +90 -0
  14. package/src/golden/loader.js +52 -0
  15. package/src/golden/runner.js +92 -0
  16. package/src/golden/schema.js +41 -0
  17. package/src/orchestrator/config-init.js +17 -0
  18. package/src/orchestrator/drivers/post-loop.js +64 -3
  19. package/src/orchestrator/flow-runner.js +41 -0
  20. package/src/orchestrator/stages/sonar-stage.js +19 -0
  21. package/src/session/journal/summary-writer.js +39 -0
  22. package/src/skills/addyosmani-catalog.js +29 -7
  23. package/src/sonar/project-key.js +37 -0
  24. package/src/types/session.js +2 -1
  25. package/src/utils/display/session-summary.js +12 -2
  26. package/src/utils/git.js +132 -1
  27. package/templates/roles/architect/anthropic.md +9 -0
  28. package/templates/roles/architect/default.md +9 -0
  29. package/templates/roles/architect/google.md +9 -0
  30. package/templates/roles/architect/openai.md +9 -0
  31. package/templates/roles/architect/opencode.md +9 -0
  32. package/templates/roles/architect.md +9 -0
  33. package/templates/roles/coder/anthropic.md +9 -0
  34. package/templates/roles/coder/default.md +9 -0
  35. package/templates/roles/coder/google.md +9 -0
  36. package/templates/roles/coder/openai.md +9 -0
  37. package/templates/roles/coder/opencode.md +9 -0
  38. package/templates/roles/coder.md +9 -0
  39. package/templates/roles/planner/anthropic.md +9 -0
  40. package/templates/roles/planner/default.md +9 -0
  41. package/templates/roles/planner/google.md +9 -0
  42. package/templates/roles/planner/openai.md +9 -0
  43. package/templates/roles/planner/opencode.md +9 -0
  44. package/templates/roles/planner.md +9 -0
  45. package/templates/roles/refactorer.md +9 -0
  46. package/templates/roles/repairer.md +9 -0
  47. package/templates/roles/tester.md +9 -0
package/README.md CHANGED
@@ -23,7 +23,11 @@
23
23
 
24
24
  ---
25
25
 
26
- > **v2.10.2 released** — 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.
26
+ > **v2.12.0 released** — Quality-measurement release. Two new features land together: every `kj run` against a known plan now scores how faithfully the coder followed it (deterministic 0–100 **plan adherence** metric, four weighted components, rendered in `summary.md`), and a small **golden-tasks** regression suite (`todo-rest-api`, `npm-package-cli`, `react-counter-component`) catches output-quality drops between Karajan versions before npm publish. Plus the shrink-budget CI gate now exempts human-facing docs from its 200-LOC ceiling while keeping AI-rule files (CLAUDE.md, AGENTS.md, role prompts) capped. 3 PRs for plan adherence (#645–#647), 4 for golden tasks (#648, #650–#652), 1 for the CI policy (#649). 4522/4522 tests passing. Safe upgrade from 2.11.0.
27
+ >
28
+ > **v2.11.0** — 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.
29
+ >
30
+ > **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.
27
31
  >
28
32
  > **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.
29
33
  >
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "2.10.2",
3
+ "version": "2.12.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",
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Plan adherence metric — KJC-TSK-0376 (deepeval-inspired).
3
+ * Pure offline 0-100 score: how faithfully did the coder follow the plan?
4
+ * Components (weighted): commit_attribution 40%, acceptance_tests 30%,
5
+ * scope_discipline 20%, dependency_order 10%. Components return null
6
+ * when no data; aggregator redistributes the weight.
7
+ * See docs/plan-adherence.md (PR-C) for the full spec.
8
+ */
9
+
10
+ const WEIGHTS = {
11
+ commit_attribution: 40,
12
+ acceptance_tests: 30,
13
+ scope_discipline: 20,
14
+ dependency_order: 10,
15
+ };
16
+
17
+ /**
18
+ * Build a regex that matches the HU id anywhere in a commit message
19
+ * (case-insensitive, word-boundary friendly). The `_` and `-` characters
20
+ * appear inside HU ids so we anchor on whole-token rather than \b.
21
+ */
22
+ function huIdMatcher(huId) {
23
+ const escaped = String(huId).replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&");
24
+ return new RegExp(`(^|[^a-z0-9])${escaped}([^a-z0-9]|$)`, "i");
25
+ }
26
+
27
+ /**
28
+ * Slugify an HU title for branch-name / scope matching. Mirrors the
29
+ * `buildHuBranchName` slug rule (lowercase, non-alnum → "-").
30
+ */
31
+ function slugify(text) {
32
+ return String(text || "")
33
+ .toLowerCase()
34
+ .replaceAll(/[^a-z0-9]+/g, "-")
35
+ .replaceAll(/(^-+)|(-+$)/g, "");
36
+ }
37
+
38
+ /**
39
+ * Try to attribute a single commit to one of the plan's HUs.
40
+ *
41
+ * Order of evidence, strongest first:
42
+ * 1. The HU id appears in the commit message.
43
+ * 2. The HU id appears in the branch name where the commit lives
44
+ * (commit.branch is supplied by listCommitsBetween's caller when
45
+ * it knows the per-HU branches).
46
+ * 3. The HU title slug matches the commit message.
47
+ *
48
+ * Returns the matched huId, or null when none of the above fires.
49
+ */
50
+ export function attributeCommitToHu(commit, hus) {
51
+ if (!commit || !Array.isArray(hus) || hus.length === 0) return null;
52
+ const msg = String(commit.message || "");
53
+ const branch = String(commit.branch || "");
54
+ for (const hu of hus) {
55
+ if (!hu?.id) continue;
56
+ const matcher = huIdMatcher(hu.id);
57
+ if (matcher.test(msg)) return hu.id;
58
+ if (branch && matcher.test(branch)) return hu.id;
59
+ }
60
+ // Fallback: title slug
61
+ for (const hu of hus) {
62
+ const slug = slugify(hu.title);
63
+ if (!slug || slug.length < 6) continue;
64
+ if (slugify(msg).includes(slug)) return hu.id;
65
+ }
66
+ return null;
67
+ }
68
+
69
+ /**
70
+ * Component 1: commit attribution. Returns null when the run produced
71
+ * no commits at all (avoids penalising analysis-only or doc-only runs).
72
+ */
73
+ export function computeCommitAttribution(commits, plan) {
74
+ if (!Array.isArray(commits) || commits.length === 0) return null;
75
+ if (!plan?.hus || plan.hus.length === 0) return null;
76
+ let attributed = 0;
77
+ for (const commit of commits) {
78
+ if (attributeCommitToHu(commit, plan.hus)) attributed += 1;
79
+ }
80
+ return Math.round((attributed / commits.length) * 100);
81
+ }
82
+
83
+ /**
84
+ * Component 2: acceptance test coverage. Heuristic: each HU declares
85
+ * `acceptance_tests`. We check whether at least one commit from that
86
+ * HU's pool has a `test:`-prefixed message. Per-HU score (1 or 0),
87
+ * averaged across HUs that DO declare tests. Returns null when no HU
88
+ * declares acceptance tests.
89
+ */
90
+ export function computeAcceptanceTestCoverage(commits, plan) {
91
+ if (!plan?.hus || plan.hus.length === 0) return null;
92
+ if (!Array.isArray(commits) || commits.length === 0) return null;
93
+ const husWithTests = plan.hus.filter(
94
+ (hu) => Array.isArray(hu?.acceptance_tests) && hu.acceptance_tests.length > 0,
95
+ );
96
+ if (husWithTests.length === 0) return null;
97
+
98
+ let covered = 0;
99
+ for (const hu of husWithTests) {
100
+ const huCommits = (commits || []).filter(
101
+ (c) => attributeCommitToHu(c, [hu]) === hu.id,
102
+ );
103
+ const hasTestCommit = huCommits.some((c) =>
104
+ /^test[:(]/i.test(String(c.message || "").trim()),
105
+ );
106
+ if (hasTestCommit) covered += 1;
107
+ }
108
+ return Math.round((covered / husWithTests.length) * 100);
109
+ }
110
+
111
+ /**
112
+ * Component 3: scope discipline. For each file changed by the run, we
113
+ * try to match it against at least one HU's id slug, title slug, or
114
+ * `scope` text. A file that matches no HU is flagged as out-of-scope.
115
+ * Returns null when the run changed no files.
116
+ */
117
+ export function computeScopeDiscipline(filesChanged, plan) {
118
+ if (!Array.isArray(filesChanged) || filesChanged.length === 0) return null;
119
+ if (!plan?.hus || plan.hus.length === 0) return null;
120
+ const huKeywords = plan.hus.map((hu) => ({
121
+ id: hu.id,
122
+ matchers: [
123
+ slugify(hu.id),
124
+ slugify(hu.title),
125
+ // Pull alpha-rich words from `scope` text (length >= 4).
126
+ ...String(hu.scope || "")
127
+ .toLowerCase()
128
+ .split(/[^a-z]+/)
129
+ .filter((w) => w.length >= 4),
130
+ ].filter(Boolean),
131
+ }));
132
+ let inScope = 0;
133
+ for (const file of filesChanged) {
134
+ const slug = slugify(file);
135
+ const matched = huKeywords.some((hk) =>
136
+ hk.matchers.some((m) => slug.includes(m) || file.includes(m)),
137
+ );
138
+ if (matched) inScope += 1;
139
+ }
140
+ return Math.round((inScope / filesChanged.length) * 100);
141
+ }
142
+
143
+ /**
144
+ * Component 4: dependency order. For every `(parent, child)` edge
145
+ * declared via `child.blocked_by`, check that the EARLIEST commit
146
+ * attributed to `parent` is older than the EARLIEST commit attributed
147
+ * to `child`. Returns null when no dependency edges exist.
148
+ *
149
+ * `commits` are expected oldest-first (the shape `listCommitsBetween`
150
+ * returns). When that's not the case the score still makes sense — we
151
+ * just compare relative positions in the array.
152
+ */
153
+ export function computeDependencyOrder(commits, plan) {
154
+ if (!plan?.hus || plan.hus.length === 0) return null;
155
+ const edges = [];
156
+ for (const hu of plan.hus) {
157
+ for (const parentId of hu.blocked_by || []) {
158
+ edges.push([parentId, hu.id]);
159
+ }
160
+ }
161
+ if (edges.length === 0) return null;
162
+ if (!Array.isArray(commits) || commits.length === 0) return null;
163
+
164
+ // Build huId → first commit index (oldest first attribution).
165
+ const firstIndex = new Map();
166
+ commits.forEach((commit, idx) => {
167
+ const huId = attributeCommitToHu(commit, plan.hus);
168
+ if (huId && !firstIndex.has(huId)) firstIndex.set(huId, idx);
169
+ });
170
+
171
+ let respected = 0;
172
+ let evaluable = 0;
173
+ for (const [parentId, childId] of edges) {
174
+ const p = firstIndex.get(parentId);
175
+ const c = firstIndex.get(childId);
176
+ if (p === undefined || c === undefined) continue; // can't evaluate
177
+ evaluable += 1;
178
+ if (p < c) respected += 1;
179
+ }
180
+ if (evaluable === 0) return null;
181
+ return Math.round((respected / evaluable) * 100);
182
+ }
183
+
184
+ /**
185
+ * Aggregator: compute every component and weight them. Components
186
+ * that return `null` are excluded and their weight redistributed
187
+ * proportionally across the rest, so a run that legitimately has no
188
+ * acceptance tests or no dependencies isn't penalised.
189
+ *
190
+ * Returns the full structured result (see file header for shape).
191
+ */
192
+ export function computePlanAdherenceScore({ commits, plan, filesChanged }) {
193
+ const breakdown = {
194
+ commit_attribution: computeCommitAttribution(commits, plan),
195
+ acceptance_tests: computeAcceptanceTestCoverage(commits, plan),
196
+ scope_discipline: computeScopeDiscipline(filesChanged, plan),
197
+ dependency_order: computeDependencyOrder(commits, plan),
198
+ };
199
+
200
+ // Weighted average over non-null components only.
201
+ let totalWeight = 0;
202
+ let weightedSum = 0;
203
+ for (const [key, weight] of Object.entries(WEIGHTS)) {
204
+ const value = breakdown[key];
205
+ if (value === null || value === undefined) continue;
206
+ totalWeight += weight;
207
+ weightedSum += value * weight;
208
+ }
209
+ const score = totalWeight === 0 ? null : Math.round(weightedSum / totalWeight);
210
+
211
+ // Per-HU detail: was the HU "touched" (attributed at least one commit)?
212
+ const hu_scores = (plan?.hus || []).map((hu) => {
213
+ const huCommits = (commits || []).filter(
214
+ (c) => attributeCommitToHu(c, [hu]) === hu.id,
215
+ );
216
+ const issues = [];
217
+ if (huCommits.length === 0) issues.push("no commit attributed");
218
+ return { huId: hu.id, attributed: huCommits.length > 0, issues };
219
+ });
220
+
221
+ return { score, breakdown, hu_scores };
222
+ }
@@ -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
  }
@@ -277,6 +277,22 @@ async function runWizard(config, logger) {
277
277
  return config;
278
278
  }
279
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
+
280
296
  async function handleConfigSetup({ config, configExists, interactive, configPath, logger }) {
281
297
  if (configExists && interactive) {
282
298
  const wizard = createWizard();
@@ -284,7 +300,7 @@ async function handleConfigSetup({ config, configExists, interactive, configPath
284
300
  const reconfigure = await wizard.confirm("Config already exists. Reconfigure?", false);
285
301
  if (reconfigure) {
286
302
  const updated = await runWizard(config, logger);
287
- await writeConfig(configPath, updated);
303
+ await writeInitConfig(configPath, updated);
288
304
  logger.info(`Updated ${configPath}`);
289
305
  } else {
290
306
  logger.info("Keeping existing config.");
@@ -294,10 +310,10 @@ async function handleConfigSetup({ config, configExists, interactive, configPath
294
310
  }
295
311
  } else if (!configExists && interactive) {
296
312
  const updated = await runWizard(config, logger);
297
- await writeConfig(configPath, updated);
313
+ await writeInitConfig(configPath, updated);
298
314
  logger.info(`Created ${configPath}`);
299
315
  } else if (!configExists) {
300
- await writeConfig(configPath, config);
316
+ await writeInitConfig(configPath, config);
301
317
  logger.info(`Created ${configPath}`);
302
318
  }
303
319
  }
@@ -561,7 +577,7 @@ export async function initCommand({ logger, flags = {} }) {
561
577
  config.development.methodology = config.development.methodology || "tdd";
562
578
  }
563
579
 
564
- await writeConfig(configPath, config);
580
+ await writeInitConfig(configPath, config);
565
581
  } else {
566
582
  logger.info("No project stack detected — using default configuration.");
567
583
  }
@@ -582,7 +598,10 @@ export async function initCommand({ logger, flags = {} }) {
582
598
  await scaffoldCiGateway(config, flags, logger);
583
599
 
584
600
  // Persist any changes setupSonarQube made (token, etc.) back to disk.
585
- await writeConfig(configPath, config);
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);
586
605
 
587
606
  // Telemetry: anonymous install event (non-blocking)
588
607
  const { sendTelemetryEvent } = await import("../utils/telemetry.js");
@@ -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.
@@ -15,14 +15,88 @@ import {
15
15
  buildBranchName,
16
16
  commitAll,
17
17
  pushBranch,
18
- createPullRequest
18
+ createPullRequest,
19
+ listPendingPaths
19
20
  } from "../utils/git.js";
20
21
 
21
- export function commitMessageFromTask(task) {
22
+ /**
23
+ * Karajan writes / updates several internal directories during a pipeline
24
+ * run (the workspace scaffold + the session journal). When they're the
25
+ * ONLY thing pending at finalization, the post-loop commit shouldn't
26
+ * inherit the user task's `feat:` message — it should be a clear
27
+ * `chore: scaffold karajan workspace` so `git log --oneline` reads:
28
+ *
29
+ * 2d770aa docs: add JSDoc comment to index.js
30
+ * abc1234 chore: scaffold karajan workspace
31
+ * 8a95ef0 initial
32
+ *
33
+ * A path counts as scaffolding when it lives at one of these prefixes
34
+ * OR is the project's root `.gitignore` (we extend it to include
35
+ * stack-specific entries during triage).
36
+ */
37
+ const KARAJAN_SCAFFOLD_PREFIXES = [
38
+ ".karajan/",
39
+ ".reviews/",
40
+ ".kj/",
41
+ ".agent/",
42
+ ];
43
+
44
+ function isKarajanScaffoldPath(p) {
45
+ if (p === ".gitignore") return true;
46
+ return KARAJAN_SCAFFOLD_PREFIXES.some((prefix) => p.startsWith(prefix));
47
+ }
48
+
49
+ /**
50
+ * @param {string[]} paths
51
+ * @returns {boolean} true when every pending path is a Karajan internal
52
+ */
53
+ export function pendingPathsAreOnlyKarajanScaffold(paths) {
54
+ if (!Array.isArray(paths) || paths.length === 0) return false;
55
+ return paths.every(isKarajanScaffoldPath);
56
+ }
57
+
58
+ /**
59
+ * Map the taskType (as classified by triage) to the matching
60
+ * Conventional Commits prefix. When triage hasn't run or the type is
61
+ * unknown we default to `feat:` (the pre-fix behaviour).
62
+ */
63
+ const TASK_TYPE_TO_PREFIX = {
64
+ doc: "docs",
65
+ "add-tests": "test",
66
+ refactor: "refactor",
67
+ infra: "chore",
68
+ sw: "feat",
69
+ };
70
+
71
+ function prefixForTaskType(taskType) {
72
+ return TASK_TYPE_TO_PREFIX[taskType] || "feat";
73
+ }
74
+
75
+ export function commitMessageFromTask(task, taskType = null) {
22
76
  const clean = String(task || "")
23
77
  .replaceAll(/\s+/g, " ")
24
78
  .trim();
25
- return `feat: ${clean.slice(0, 72) || "karajan update"}`;
79
+ const prefix = prefixForTaskType(taskType);
80
+ return `${prefix}: ${clean.slice(0, 72) || "karajan update"}`;
81
+ }
82
+
83
+ /**
84
+ * Build the message used by `finalizeGitAutomation` for the post-loop
85
+ * commit. Two paths:
86
+ * - When the only pending changes are Karajan scaffold files
87
+ * (.gitignore, .karajan/, .reviews/, ...), use a fixed
88
+ * `chore: scaffold karajan workspace` message. This avoids the
89
+ * "duplicate feat:" git log seen when triage extends .gitignore
90
+ * for a freshly detected stack and the coder already committed
91
+ * the user-visible change.
92
+ * - Otherwise, fall back to `commitMessageFromTask(task, taskType)`,
93
+ * respecting the type for the prefix.
94
+ */
95
+ export function buildFinalizeCommitMessage({ task, taskType, pendingPaths }) {
96
+ if (pendingPathsAreOnlyKarajanScaffold(pendingPaths)) {
97
+ return "chore: scaffold karajan workspace";
98
+ }
99
+ return commitMessageFromTask(task, taskType);
26
100
  }
27
101
 
28
102
  export async function prepareGitAutomation({ config, task, logger, session }) {
@@ -173,7 +247,23 @@ export async function incrementalPush({ gitCtx, task, logger, session }) {
173
247
  export async function finalizeGitAutomation({ config, gitCtx, task, logger, session, stageResults = null }) {
174
248
  if (!gitCtx?.enabled) return { git: "disabled", commits: [] };
175
249
 
176
- const commitMsg = config.git.commit_message || commitMessageFromTask(task);
250
+ // Take a snapshot of pending paths BEFORE staging so we can decide
251
+ // whether what's about to be committed is just Karajan scaffolding
252
+ // (in which case we use `chore: scaffold karajan workspace`) or a
253
+ // real, user-visible change (in which case we honour the task's
254
+ // taskType for the Conventional Commits prefix).
255
+ const taskType = stageResults?.triage?.taskType ?? null;
256
+ let pendingPaths = [];
257
+ if (config.git.auto_commit) {
258
+ try {
259
+ pendingPaths = await listPendingPaths();
260
+ } catch { /* best-effort — fall back to default message */ }
261
+ }
262
+
263
+ const commitMsg =
264
+ config.git.commit_message
265
+ || buildFinalizeCommitMessage({ task, taskType, pendingPaths });
266
+
177
267
  let committed = false;
178
268
  const commits = [];
179
269
  if (config.git.auto_commit) {