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.
@@ -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) {
@@ -41,6 +41,34 @@ export function resolveHuBase(story, huBranches, baseBranch) {
41
41
  return baseBranch;
42
42
  }
43
43
 
44
+ /**
45
+ * Resolve to an actually-existing local ref. Tries the preferred branch
46
+ * first, then `main` and `master`, then plain `HEAD` as last resort.
47
+ *
48
+ * Why: N6 plan-flow dogfooding (2026-05-07) on a `git init -q` repo
49
+ * with `init.defaultBranch=master` produced 7 identical warnings:
50
+ * `failed to create branch feat/...-001 from main:
51
+ * fatal: 'main' is not a commit and a branch can't be created`.
52
+ * Every HU then fell back to running on the original branch, which
53
+ * defeats the per-HU isolation the sub-pipeline was supposed to give.
54
+ *
55
+ * The configured base_branch is honoured when it exists; otherwise we
56
+ * pick the most likely default. HEAD always exists once the repo has
57
+ * any commit, so the fallback never throws past the gate.
58
+ *
59
+ * @param {string} preferred
60
+ * @returns {Promise<string>}
61
+ */
62
+ async function resolveExistingBranchRef(preferred) {
63
+ const candidates = [preferred, "main", "master", "HEAD"]
64
+ .filter((c, i, arr) => c && arr.indexOf(c) === i); // unique, truthy
65
+ for (const ref of candidates) {
66
+ const probe = await runCommand("git", ["rev-parse", "--verify", "--quiet", ref], {});
67
+ if (probe.exitCode === 0) return ref;
68
+ }
69
+ return preferred; // give up; caller will surface the original error
70
+ }
71
+
44
72
  /**
45
73
  * Create a branch for an HU starting from its resolved base.
46
74
  * Returns the branch name created (or null if git automation is disabled).
@@ -57,17 +85,21 @@ export async function prepareHuBranch({ story, huBranches, config, logger }) {
57
85
  return null;
58
86
  }
59
87
  const baseBranch = resolveHuBase(story, huBranches, config.base_branch || "main");
88
+ const effectiveBase = await resolveExistingBranchRef(baseBranch);
89
+ if (effectiveBase !== baseBranch) {
90
+ logger.info(`HU git: configured base '${baseBranch}' not found locally — using '${effectiveBase}' instead`);
91
+ }
60
92
  const prefix = config.git?.branch_prefix || "feat/";
61
93
  const branchName = buildHuBranchName(prefix, story);
62
94
 
63
95
  // Checkout from the resolved base. Use `git checkout -B` to overwrite if rerun.
64
- const res = await runCommand("git", ["checkout", "-B", branchName, baseBranch], {});
96
+ const res = await runCommand("git", ["checkout", "-B", branchName, effectiveBase], {});
65
97
  if (res.exitCode !== 0) {
66
- logger.warn(`HU git: failed to create branch ${branchName} from ${baseBranch}: ${res.stderr}`);
98
+ logger.warn(`HU git: failed to create branch ${branchName} from ${effectiveBase}: ${res.stderr}`);
67
99
  return null;
68
100
  }
69
101
  huBranches.set(story.id, branchName);
70
- logger.info(`HU ${story.id}: branch '${branchName}' from '${baseBranch}'`);
102
+ logger.info(`HU ${story.id}: branch '${branchName}' from '${effectiveBase}'`);
71
103
  return branchName;
72
104
  }
73
105
 
@@ -5,6 +5,7 @@
5
5
  import fs from "node:fs/promises";
6
6
  import path from "node:path";
7
7
  import { computeBaseRef, setSnapshot } from "../review/diff-generator.js";
8
+ import { revParse } from "../utils/git.js";
8
9
  import { buildCoderPrompt } from "../prompts/coder.js";
9
10
  import { buildReviewerPrompt } from "../prompts/reviewer.js";
10
11
  import { resolveRole } from "../config.js";
@@ -367,12 +368,28 @@ export async function initializeSession({ task, config, flags, pgTaskId, pgProje
367
368
  const projectDir = config.projectDir || process.cwd();
368
369
  const project_id = projectSlug(projectDir);
369
370
 
371
+ // Capture HEAD at the very start of the run, separately from base_ref.
372
+ // session_start_sha was being conflated with base_ref (used for `git diff`)
373
+ // — when the user's repo had a single commit and no remote main, base_ref
374
+ // fell through to git's empty-tree SHA. The post-loop journal then read
375
+ // session_start_sha to enumerate "commits made by this run" and got back
376
+ // the entire history, including pre-existing commits. Capturing actual
377
+ // HEAD here makes "what did the run produce?" deterministic and correct.
378
+ // Falls back to baseRef when HEAD doesn't exist (zero-commits repo); in
379
+ // that case "everything since baseRef" still equals "everything the run
380
+ // produced" because there was no prior history.
381
+ let headAtStart;
382
+ try {
383
+ headAtStart = (await revParse("HEAD")) || baseRef;
384
+ } catch { headAtStart = baseRef; }
385
+
370
386
  const sessionInit = {
371
387
  task,
372
388
  project_id,
373
389
  config_snapshot: config,
374
390
  base_ref: baseRef,
375
391
  session_start_sha: baseRef,
392
+ head_at_start: headAtStart,
376
393
  last_reviewer_feedback: null,
377
394
  repeated_issue_count: 0,
378
395
  sonar_retry_count: 0,
@@ -23,6 +23,7 @@
23
23
 
24
24
  import { generateDiff } from "../../review/diff-generator.js";
25
25
  import { finalizeGitAutomation } from "../../git/automation.js";
26
+ import { listCommitsBetween } from "../../utils/git.js";
26
27
  import { saveSession, markSessionStatus } from "../../session/store.js";
27
28
  import {
28
29
  setReviewerFeedback, setBudget, setRtkSavings, resetRetryCount,
@@ -151,6 +152,20 @@ export async function finalizeApprovedSession({ config, gitCtx, task, logger, se
151
152
  const { writeSummaryJournal: writeSummaryJournalV2 } =
152
153
  await import("../../session/journal/summary-writer.js");
153
154
  const startedAt = session._startedAt ? new Date(session._startedAt).toISOString() : undefined;
155
+ // 2026-05-07 dogfooding: gitResult.commits only carries the post-loop
156
+ // commit (scaffold), not the coder's own commit. The summary's
157
+ // "Commits" section was therefore misleading — it claimed the run
158
+ // produced one chore commit when in fact the coder had also written
159
+ // a docs/feat commit. Compute the full range from session_start_sha
160
+ // so every commit between then and HEAD shows up in execution order.
161
+ // try/catch (instead of `.catch(...)`) so we tolerate both promise
162
+ // rejection AND a sync return of undefined — the latter happens
163
+ // when a test mocks listCommitsBetween with a plain vi.fn() that
164
+ // resetAllMocks has stripped of its mockResolvedValue.
165
+ let allCommits = [];
166
+ try { allCommits = (await listCommitsBetween(session.head_at_start || session.session_start_sha)) || []; }
167
+ catch { allCommits = gitResult?.commits || []; }
168
+ const summaryCommits = allCommits.length > 0 ? allCommits : (gitResult?.commits || []);
154
169
  await writeSummaryJournalV2(journalDir, {
155
170
  task: session.task,
156
171
  result: "APPROVED",
@@ -159,7 +174,7 @@ export async function finalizeApprovedSession({ config, gitCtx, task, logger, se
159
174
  durationMs: Date.now() - (session._startedAt || Date.now()),
160
175
  budget: budgetSummary(),
161
176
  stages: stageResults,
162
- commits: gitResult?.commits || [],
177
+ commits: summaryCommits,
163
178
  files: journalFiles,
164
179
  startedAt,
165
180
  finishedAt: new Date().toISOString(),
@@ -180,7 +195,21 @@ export async function finalizeApprovedSession({ config, gitCtx, task, logger, se
180
195
  }
181
196
  }
182
197
 
183
- const endDetail = { approved: true, iterations: i, stages: stageResults, git: gitResult, budget: budgetSummary(), deferredIssues };
198
+ // Mirror the same enriched commit list into the session:end event so the
199
+ // terminal `🏁 Result` banner (printSessionGit) shows every commit the
200
+ // run produced, not just the post-loop scaffold one. We compute again
201
+ // (cheap: one git log call) rather than threading a variable through
202
+ // because the journal block is wrapped in try/catch and may have set
203
+ // `summaryCommits` to a fallback we don't want for the event detail.
204
+ // try/catch tolerates both promise rejection and sync undefined returns
205
+ // (see the journal block above for the detailed rationale).
206
+ let eventCommits = null;
207
+ try { eventCommits = await listCommitsBetween(session.head_at_start || session.session_start_sha); }
208
+ catch { /* leave null — we'll use gitResult unchanged */ }
209
+ const gitForEvent = (Array.isArray(eventCommits) && eventCommits.length > 0)
210
+ ? { ...gitResult, commits: eventCommits }
211
+ : gitResult;
212
+ const endDetail = { approved: true, iterations: i, stages: stageResults, git: gitForEvent, budget: budgetSummary(), deferredIssues };
184
213
  if (rtkSavings) endDetail.rtk_savings = rtkSavings;
185
214
  emitProgress(
186
215
  emitter,
@@ -60,6 +60,45 @@ import { runSecurityStage, runFinalAuditStage } from "./post-loop-stages.js";
60
60
  // PG card "In Progress" logic moved to src/planning-game/pipeline-adapter.js → initPgAdapter()
61
61
 
62
62
 
63
+ /**
64
+ * Boundary guard: make sure the session's terminal status reflects what
65
+ * actually happened, even if some inner exit path forgot to call
66
+ * `markSessionStatus`. KJC-BUG-0037 (N1 dogfooding 2026-05-07): runs
67
+ * that took the Brain "max_iterations approved" / "Solomon-after-error
68
+ * approved" / "Brain solomon-approved" exits returned `{approved:true}`
69
+ * upstream but left `session.status = "running"` on disk indefinitely.
70
+ * `kj status` then showed "Pipeline RUNNING" forever and the HU Board
71
+ * carried a perma-zombie.
72
+ *
73
+ * Maps the result shape to a terminal status:
74
+ * approved → "approved"
75
+ * paused → "paused"
76
+ * cancelled → "cancelled"
77
+ * anything else → "failed"
78
+ *
79
+ * Idempotent: only writes when the current status is still "running"
80
+ * (or unset). If an inner path already sealed it correctly, this is a
81
+ * no-op.
82
+ *
83
+ * Safe-by-default: never throws. A failure here must not block the
84
+ * top-level result from being returned to the caller.
85
+ *
86
+ * @param {object} session
87
+ * @param {object} [result]
88
+ */
89
+ export async function sealSessionStatusIfStillRunning(session, result) {
90
+ try {
91
+ if (!session) return;
92
+ const current = session.status;
93
+ if (current && current !== "running") return;
94
+ let target = "failed";
95
+ if (result?.approved === true) target = "approved";
96
+ else if (result?.paused === true) target = "paused";
97
+ else if (result?.cancelled === true) target = "cancelled";
98
+ await markSessionStatus(session, target);
99
+ } catch { /* non-blocking */ }
100
+ }
101
+
63
102
  export async function runFlow(opts) {
64
103
  // TSK-0338: isolate per-run state (git/diff runner, projectDir, snapshot)
65
104
  // inside an AsyncLocalStorage scope. Two concurrent `runFlow` invocations
@@ -193,12 +232,14 @@ async function _runFlowInner({ task, config, logger, flags = {}, emitter = null,
193
232
  // policies + acceptance-tests gate + Sonar + tester translation.
194
233
  const huBatch = await runHuBatch({ ctx, task, askQuestion, emitter, logger });
195
234
  if (huBatch.handled) {
235
+ await sealSessionStatusIfStillRunning(ctx.session, huBatch.result);
196
236
  return huBatch.result;
197
237
  }
198
238
 
199
239
  // --- Standard single-task pipeline (1 HU or no HU reviewer) ---
200
240
  const result = await runIterationLoop(ctx, { task: ctx.plannedTask, askQuestion, emitter, logger });
201
241
  await writeHistoryRecord({ sessionId: ctx.session.id, task, result, logger });
242
+ await sealSessionStatusIfStillRunning(ctx.session, result);
202
243
  return result;
203
244
  } finally {
204
245
  // --- Cleanup auto-installed skills ---
@@ -11,6 +11,7 @@ import {
11
11
  import { emitProgress, makeEvent } from "../../utils/events.js";
12
12
  import { invokeSolomon } from "../solomon-escalation.js";
13
13
  import { sonarUp, isSonarReachable } from "../../sonar/manager.js";
14
+ import { canResolveSonarProjectKey } from "../../sonar/project-key.js";
14
15
  import { addEntries } from "../feedback-queue.js";
15
16
 
16
17
  /**
@@ -222,6 +223,24 @@ export async function runSonarStage({ config, logger, emitter, eventBase, sessio
222
223
  logger.info("SonarQube is ready");
223
224
  }
224
225
 
226
+ // 2026-05-07 (N4 dogfooding): when the working tree has neither a git
227
+ // remote nor an explicit `sonarqube.project_key`, the scanner inside
228
+ // SonarRole would throw "Missing git remote.origin.url" — the Brain
229
+ // logs that as a sonar error, the iteration cap is reached, and the
230
+ // run finalises as "approved" by exhaustion fallback even though no
231
+ // sonar gate ever ran. KJC-TSK-0373 silenced this for the audit
232
+ // collector but the run loop hit it on the parallel code path.
233
+ // Skipping cleanly here means the gate reports "SKIPPED" once and
234
+ // the iteration loop progresses on the next stage.
235
+ if (!await canResolveSonarProjectKey(config)) {
236
+ logger.info("Sonar gate skipped: no git remote and no sonarqube.project_key configured");
237
+ emitProgress(emitter, makeEvent("sonar:end", { ...eventBase, stage: "sonar" }, {
238
+ status: "skip",
239
+ message: "Sonar skipped — no git remote and no sonarqube.project_key configured (run `git remote add origin …` or set `sonarqube.project_key` to enable)"
240
+ }));
241
+ return { action: "ok", stageResult: { gateStatus: "SKIPPED", reason: "no git remote and no sonarqube.project_key configured" } };
242
+ }
243
+
225
244
  const sonarRole = new SonarRole({ config, logger, emitter });
226
245
  await sonarRole.init({ iteration });
227
246
  const sonarStart = Date.now();
@@ -146,7 +146,7 @@ export async function ensureCatalog(opts = {}) {
146
146
  * ensureCatalog().
147
147
  *
148
148
  * @param {{ refreshMs?: number, repoUrl?: string, force?: boolean, logger?: object }} [opts]
149
- * @returns {Promise<{ok: boolean, action: "cloned"|"pulled"|"fresh"|"skipped", error?: string}>}
149
+ * @returns {Promise<{ok: boolean, action: "cloned"|"pulled"|"reset"|"fresh"|"skipped", error?: string}>}
150
150
  */
151
151
  export async function refreshIfStale(opts = {}) {
152
152
  const { refreshMs = DEFAULT_REFRESH_MS, repoUrl = REPO_URL, force = false, logger } = opts;
@@ -171,16 +171,38 @@ export async function refreshIfStale(opts = {}) {
171
171
  return { ok: true, action: "skipped" };
172
172
  }
173
173
 
174
- const result = await runCommand(
174
+ const pullResult = await runCommand(
175
175
  "git",
176
176
  ["-C", root, "pull", "--ff-only", "--depth", "1"],
177
177
  { timeout: 60_000 }
178
178
  );
179
179
 
180
- if (result.exitCode !== 0) {
181
- const stderr = (result.stderr || "").trim();
182
- logger?.warn?.(`addyosmani-catalog: git pull failed — ${stderr} (keeping stale cache)`);
183
- return { ok: false, action: "skipped", error: stderr };
180
+ let action = "pulled";
181
+ if (pullResult.exitCode !== 0) {
182
+ // The cached catalog is read-only (we never commit to it), so a
183
+ // non-fast-forward result is virtually always upstream rewriting
184
+ // history with `git push --force` — see KJC-BUG-0033 / N3-2 (the
185
+ // user hit this on 2026-05-07 when addyosmani force-pushed his
186
+ // skills repo). Recover by fetching shallow and hard-resetting to
187
+ // FETCH_HEAD: there's nothing local to lose.
188
+ const fetchResult = await runCommand(
189
+ "git",
190
+ ["-C", root, "fetch", "--depth", "1", "origin", "HEAD"],
191
+ { timeout: 60_000 }
192
+ );
193
+ const resetResult = fetchResult.exitCode === 0
194
+ ? await runCommand("git", ["-C", root, "reset", "--hard", "FETCH_HEAD"], { timeout: 30_000 })
195
+ : { exitCode: fetchResult.exitCode, stderr: fetchResult.stderr };
196
+
197
+ if (resetResult.exitCode !== 0) {
198
+ const pullErr = (pullResult.stderr || "").trim();
199
+ const recoveryErr = (resetResult.stderr || "").trim();
200
+ const combined = recoveryErr ? `${pullErr} | recovery: ${recoveryErr}` : pullErr;
201
+ logger?.warn?.(`addyosmani-catalog: git pull failed — ${combined} (keeping stale cache)`);
202
+ return { ok: false, action: "skipped", error: combined };
203
+ }
204
+ action = "reset";
205
+ logger?.info?.("addyosmani-catalog: ff-only failed, recovered via fetch+reset (upstream force-pushed)");
184
206
  }
185
207
 
186
208
  await writeMeta({
@@ -189,7 +211,7 @@ export async function refreshIfStale(opts = {}) {
189
211
  lastPullAt: new Date().toISOString(),
190
212
  });
191
213
 
192
- return { ok: true, action: "pulled" };
214
+ return { ok: true, action };
193
215
  }
194
216
 
195
217
  /**
@@ -81,3 +81,40 @@ export async function resolveSonarProjectKey(config, options = {}) {
81
81
  const repo = slug(repoId.split("/").pop());
82
82
  return normalizeProjectKey(`kj-${repo}-${digest(repoId)}`);
83
83
  }
84
+
85
+ /**
86
+ * Non-throwing predicate: can a Sonar project key be resolved for `config`?
87
+ *
88
+ * Used by callers (audit collector, run-loop SonarStage) that want to skip
89
+ * the Sonar work cleanly instead of letting `resolveSonarProjectKey` throw
90
+ * "Missing git remote.origin.url" deep inside the scanner. The scanner's
91
+ * thrown error is fine for an explicit `kj sonar` call (the user asked
92
+ * for it), but for implicit pipeline gates it produces a noisy "Sonar
93
+ * scan failed" loop in fresh /tmp/... repos.
94
+ *
95
+ * Returns true when EITHER:
96
+ * - an explicit `project_key` is set (env var, config, or per-call option),
97
+ * - OR `git config --get remote.origin.url` succeeds with a non-empty value.
98
+ *
99
+ * Side-effect-free apart from the git probe.
100
+ *
101
+ * @param {object} config
102
+ * @param {object} [options]
103
+ * @param {string} [options.projectKey]
104
+ * @returns {Promise<boolean>}
105
+ */
106
+ export async function canResolveSonarProjectKey(config, options = {}) {
107
+ const explicit = String(
108
+ options.projectKey || process.env.KJ_SONAR_PROJECT_KEY || config?.sonarqube?.project_key || ""
109
+ ).trim();
110
+ if (explicit) return true;
111
+ // try/catch (instead of `.catch(...)`) tolerates two legitimate test
112
+ // realities: a runCommand mock that returns undefined, and a real
113
+ // promise rejection from a missing git binary. Either way we treat
114
+ // it as "no remote available" and let the caller skip Sonar cleanly.
115
+ let remote;
116
+ try { remote = await runCommand("git", ["config", "--get", "remote.origin.url"]); }
117
+ catch { return false; }
118
+ if (!remote) return false;
119
+ return remote.exitCode === 0 && String(remote.stdout || "").trim().length > 0;
120
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Sonar token bootstrap (KJC-TSK-0367).
3
+ *
4
+ * On `kj init`, after the SonarQube container is up, try to generate the
5
+ * analysis token via the REST API instead of asking the user to walk
6
+ * through the web UI manually. The bootstrapper:
7
+ *
8
+ * 1. Probes the host with admin/admin (the container default).
9
+ * 2. If login succeeds AND the password is still default, it rotates
10
+ * the password to a freshly generated value and persists that to
11
+ * `~/.karajan/sonar.admin-password` (mode 0600). This avoids
12
+ * leaving the deployment with the well-known default credentials.
13
+ * 3. Generates a Global Analysis Token named `karajan-cli` via
14
+ * `POST /api/user_tokens/generate` and returns it.
15
+ * 4. Persists the token to `~/.karajan/sonar.token` (mode 0600). The
16
+ * caller writes it into `config.sonarqube.token` separately.
17
+ *
18
+ * On any HTTP error, we return `{ ok: false, reason }` and let the
19
+ * caller fall back to the manual-flow instructions that existed before
20
+ * this card. We never throw — the wizard must keep going.
21
+ */
22
+
23
+ import { writeFileSync, chmodSync, mkdirSync } from "node:fs";
24
+ import { dirname } from "node:path";
25
+ import { randomBytes } from "node:crypto";
26
+ import { getKarajanHome } from "../utils/paths.js";
27
+ import path from "node:path";
28
+
29
+ const DEFAULT_USER = "admin";
30
+ const DEFAULT_PASS = "admin";
31
+ const TOKEN_NAME = "karajan-cli";
32
+
33
+ /**
34
+ * @param {string} host e.g. http://localhost:9000
35
+ * @param {string} pathname "/api/..."
36
+ * @param {object} opts
37
+ * @param {"GET"|"POST"} [opts.method="GET"]
38
+ * @param {Record<string,string>} [opts.form] form-urlencoded body
39
+ * @param {string} opts.user
40
+ * @param {string} opts.pass
41
+ * @returns {Promise<{ status: number, body: any }>}
42
+ */
43
+ async function call(host, pathname, opts) {
44
+ const url = host.replace(/\/$/, "") + pathname;
45
+ const headers = {
46
+ Authorization: "Basic " + Buffer.from(`${opts.user}:${opts.pass}`).toString("base64"),
47
+ };
48
+ let body;
49
+ if (opts.method === "POST" && opts.form) {
50
+ headers["Content-Type"] = "application/x-www-form-urlencoded";
51
+ body = new URLSearchParams(opts.form).toString();
52
+ }
53
+ const res = await fetch(url, { method: opts.method || "GET", headers, body });
54
+ let parsed = null;
55
+ const text = await res.text();
56
+ if (text) {
57
+ try { parsed = JSON.parse(text); } catch { parsed = text; }
58
+ }
59
+ return { status: res.status, body: parsed };
60
+ }
61
+
62
+ /**
63
+ * Generate a strong (32-byte hex) random password. We never re-use the
64
+ * default `admin` password and never persist `admin` to disk.
65
+ */
66
+ function generatePassword() {
67
+ return randomBytes(24).toString("base64url");
68
+ }
69
+
70
+ /**
71
+ * Persist a value at `~/.karajan/<filename>` with mode 0600.
72
+ */
73
+ function persistSecret(filename, value) {
74
+ const home = getKarajanHome();
75
+ const target = path.join(home, filename);
76
+ mkdirSync(dirname(target), { recursive: true });
77
+ writeFileSync(target, value, { mode: 0o600 });
78
+ try { chmodSync(target, 0o600); } catch { /* best-effort on Windows */ }
79
+ return target;
80
+ }
81
+
82
+ /**
83
+ * Bootstrap the Sonar analysis token. Idempotent: if the
84
+ * `karajan-cli` token already exists Sonar will refuse to create it
85
+ * again (HTTP 400 with `"errors":[{"msg":"A user token...exists"}]`).
86
+ * In that case we delete-and-recreate so kj init always returns a
87
+ * usable token.
88
+ *
89
+ * @param {object} opts
90
+ * @param {string} opts.host Default http://localhost:9000
91
+ * @returns {Promise<{
92
+ * ok: boolean,
93
+ * token?: string,
94
+ * savedTo?: string,
95
+ * passwordRotated?: boolean,
96
+ * passwordSavedTo?: string,
97
+ * reason?: string,
98
+ * }>}
99
+ */
100
+ export async function bootstrapSonarToken({ host = "http://localhost:9000" } = {}) {
101
+ let user = DEFAULT_USER;
102
+ let pass = DEFAULT_PASS;
103
+ let passwordRotated = false;
104
+ let passwordSavedTo = null;
105
+
106
+ // 1) Probe: does admin/admin still work?
107
+ const probe = await call(host, "/api/authentication/validate", { user, pass }).catch((err) => ({ status: 0, body: err.message }));
108
+ if (probe.status === 0) {
109
+ return { ok: false, reason: `network error reaching ${host}: ${probe.body}` };
110
+ }
111
+ if (probe.status === 401 || (probe.body && probe.body.valid === false)) {
112
+ return { ok: false, reason: "admin/admin login failed (password may already be customized — set sonarqube.token manually)" };
113
+ }
114
+
115
+ // 2) Rotate the default password if it's still in place. This stops
116
+ // the well-known credentials from being a stable attack surface
117
+ // on this user's machine.
118
+ const newPass = generatePassword();
119
+ const change = await call(host, "/api/users/change_password", {
120
+ method: "POST",
121
+ user,
122
+ pass,
123
+ form: { login: user, previousPassword: pass, password: newPass },
124
+ }).catch((err) => ({ status: 0, body: err.message }));
125
+
126
+ if (change.status === 204 || change.status === 200) {
127
+ pass = newPass;
128
+ passwordRotated = true;
129
+ passwordSavedTo = persistSecret("sonar.admin-password", pass);
130
+ } else if (change.status === 400 && Array.isArray(change.body?.errors)
131
+ && change.body.errors.some(e => /default/i.test(e.msg))) {
132
+ // Sonar complained the password matches a default — rare. Try with
133
+ // a longer one.
134
+ const longerPass = generatePassword() + generatePassword();
135
+ const retry = await call(host, "/api/users/change_password", {
136
+ method: "POST",
137
+ user,
138
+ pass,
139
+ form: { login: user, previousPassword: pass, password: longerPass },
140
+ }).catch(() => ({ status: 0 }));
141
+ if (retry.status === 204 || retry.status === 200) {
142
+ pass = longerPass;
143
+ passwordRotated = true;
144
+ passwordSavedTo = persistSecret("sonar.admin-password", pass);
145
+ }
146
+ } else if (change.status !== 0 && change.status !== 401) {
147
+ // Non-fatal: probably the admin already changed the password. We
148
+ // continue using whatever credentials worked at the probe step.
149
+ }
150
+
151
+ // 3) Token already exists? Revoke it so we can re-create cleanly.
152
+ await call(host, "/api/user_tokens/revoke", {
153
+ method: "POST",
154
+ user,
155
+ pass,
156
+ form: { name: TOKEN_NAME },
157
+ }).catch(() => { /* token may not exist — that's fine */ });
158
+
159
+ // 4) Generate the analysis token.
160
+ const gen = await call(host, "/api/user_tokens/generate", {
161
+ method: "POST",
162
+ user,
163
+ pass,
164
+ form: { name: TOKEN_NAME, type: "GLOBAL_ANALYSIS_TOKEN" },
165
+ }).catch((err) => ({ status: 0, body: err.message }));
166
+
167
+ if (gen.status !== 200 || !gen.body?.token) {
168
+ const detail = gen.body?.errors?.[0]?.msg || gen.body || `HTTP ${gen.status}`;
169
+ return { ok: false, reason: `token generation failed: ${detail}`, passwordRotated, passwordSavedTo };
170
+ }
171
+
172
+ const token = gen.body.token;
173
+ const savedTo = persistSecret("sonar.token", token);
174
+
175
+ return {
176
+ ok: true,
177
+ token,
178
+ savedTo,
179
+ passwordRotated,
180
+ passwordSavedTo: passwordRotated ? passwordSavedTo : null,
181
+ };
182
+ }
@@ -64,7 +64,8 @@
64
64
  * @property {string[]} [skillsRecommended] - wouldHaveUsed when openskills missing
65
65
  * @property {string[]} [autoInstalledSkills]
66
66
  * @property {RtkSavings} [rtk_savings]
67
- * @property {string} [session_start_sha]
67
+ * @property {string} [session_start_sha] - baseline for `git diff`; may be the empty-tree SHA when no prior commits exist
68
+ * @property {string} [head_at_start] - actual HEAD when the run started; used by the post-loop journal to enumerate commits produced BY the run. Falls back to session_start_sha in zero-commits repos. (2026-05-07 dogfooding fix.)
68
69
  * @property {string} [_journalDir] - runtime journal path
69
70
  * @property {string[]} [_journalIterations] - rendered iteration markdown
70
71
  * @property {string[]} [_journalFiles]