karajan-code 2.10.2 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,7 +23,9 @@
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.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.
27
29
  >
28
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.
29
31
  >
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "2.10.2",
3
+ "version": "2.11.0",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -22,6 +22,7 @@
22
22
 
23
23
  import { isSonarReachable } from "../sonar/manager.js";
24
24
  import { getOpenIssues, getQualityGateStatus } from "../sonar/api.js";
25
+ import { canResolveSonarProjectKey } from "../sonar/project-key.js";
25
26
 
26
27
  /**
27
28
  * Collect deterministic Sonar findings for the audit prompt.
@@ -43,6 +44,17 @@ export async function collectSonarFindings(config, logger = null) {
43
44
  return { available: false, reason: `sonar host ${host} not reachable` };
44
45
  }
45
46
 
47
+ // No git remote and no explicit project_key → silent skip. This is the
48
+ // typical `kj run` in a fresh /tmp/... folder. Reporting it as a
49
+ // warning treats it like an error (which it isn't — sonar simply
50
+ // doesn't apply to a remoteless repo).
51
+ if (!(await canResolveSonarProjectKey(config))) {
52
+ return {
53
+ available: false,
54
+ reason: "not applicable: no git remote and no sonarqube.project_key configured",
55
+ };
56
+ }
57
+
46
58
  let issuesResult;
47
59
  try {
48
60
  issuesResult = await getOpenIssues(config);
@@ -24,7 +24,7 @@ export function registerMeta(program, { pkgVersion }) {
24
24
  program
25
25
  .command("discover")
26
26
  .description("Analyze task for gaps, ambiguities and missing info")
27
- .argument("[task]", "Task description (or use --task-file)")
27
+ .argument("[task]", "Task description (REQUIRED — provide as argument or via --task-file)")
28
28
  .option("--task-file <path>", "Read the task from a file (e.g. .md)")
29
29
  .option("--mode <name>", "Discovery mode: gaps|momtest|wendel|classify|jtbd", "gaps")
30
30
  .option("--discover <name>", "Override discover agent")
@@ -41,7 +41,7 @@ export function registerMeta(program, { pkgVersion }) {
41
41
  program
42
42
  .command("triage")
43
43
  .description("Classify task complexity and recommend pipeline roles")
44
- .argument("[task]", "Task description (or use --task-file)")
44
+ .argument("[task]", "Task description (REQUIRED — provide as argument or via --task-file)")
45
45
  .option("--task-file <path>", "Read the task from a file (e.g. .md)")
46
46
  .option("--triage <name>", "Override triage agent")
47
47
  .option("--triage-model <name>", "Override triage model")
@@ -57,7 +57,7 @@ export function registerMeta(program, { pkgVersion }) {
57
57
  program
58
58
  .command("researcher")
59
59
  .description("Research codebase for a task (files, patterns, constraints)")
60
- .argument("[task]", "Task description (or use --task-file)")
60
+ .argument("[task]", "Task description (REQUIRED — provide as argument or via --task-file)")
61
61
  .option("--task-file <path>", "Read the task from a file (e.g. .md)")
62
62
  .option("--researcher <name>", "Override researcher agent")
63
63
  .option("--researcher-model <name>", "Override researcher model")
@@ -72,7 +72,7 @@ export function registerMeta(program, { pkgVersion }) {
72
72
  program
73
73
  .command("architect")
74
74
  .description("Design solution architecture (layers, patterns, contracts)")
75
- .argument("[task]", "Task description (or use --task-file)")
75
+ .argument("[task]", "Task description (REQUIRED — provide as argument or via --task-file)")
76
76
  .option("--task-file <path>", "Read the task from a file (e.g. .md)")
77
77
  .option("--architect <name>", "Override architect agent")
78
78
  .option("--architect-model <name>", "Override architect model")
@@ -40,7 +40,7 @@ export function registerPipeline(program, { pkgVersion }) {
40
40
  program
41
41
  .command("run")
42
42
  .description("Run coder+sonar+reviewer loop")
43
- .argument("[task]", "Task description (or use --task-file to read from a .md file)")
43
+ .argument("[task]", "Task description (REQUIRED — provide as argument or via --task-file)")
44
44
  .option("--task-file <path>", "Read the task from a file (e.g. .md). Alternative to the positional task argument.")
45
45
  .option("-y, --yes", "Skip the cwd confirmation prompt (CI / scripted runs).")
46
46
  .option("--planner <name>")
@@ -135,7 +135,7 @@ export function registerPipeline(program, { pkgVersion }) {
135
135
  program
136
136
  .command("code")
137
137
  .description("Run only coder")
138
- .argument("[task]", "Task description (or use --task-file)")
138
+ .argument("[task]", "Task description (REQUIRED — provide as argument or via --task-file)")
139
139
  .option("--task-file <path>", "Read the task from a file (e.g. .md)")
140
140
  .option("--coder <name>")
141
141
  .option("--coder-model <name>")
@@ -150,7 +150,7 @@ export function registerPipeline(program, { pkgVersion }) {
150
150
  program
151
151
  .command("review")
152
152
  .description("Run only reviewer")
153
- .argument("[task]", "Task description (or use --task-file)")
153
+ .argument("[task]", "Task description (REQUIRED — provide as argument or via --task-file)")
154
154
  .option("--task-file <path>", "Read the task from a file (e.g. .md)")
155
155
  .option("--reviewer <name>")
156
156
  .option("--reviewer-model <name>")
@@ -26,7 +26,7 @@ export function registerPlan(program, { pkgVersion }) {
26
26
  plan
27
27
  .command("generate", { isDefault: true })
28
28
  .description("Generate implementation plan with HUs")
29
- .argument("[task]", "Task description (or use --task-file)")
29
+ .argument("[task]", "Task description (REQUIRED — provide as argument or via --task-file)")
30
30
  .option("--task-file <path>", "Read the task from a file (e.g. .md)")
31
31
  .option("--planner <name>")
32
32
  .option("--planner-model <name>")
@@ -126,14 +126,29 @@ export async function startBoard(desiredPort = 4000, opts = {}) {
126
126
  const karajanHome = getKarajanHome();
127
127
  fs.mkdirSync(karajanHome, { recursive: true });
128
128
 
129
+ // Open a log file for the daemon. Pre-fix the daemon was spawned
130
+ // with `stdio: "ignore"`, so every `console.log` from server.js
131
+ // (including the new [zombie-reaper] messages) went straight to
132
+ // /dev/null and the user had no way to see what was happening.
133
+ // The file is opened in append-only mode so successive starts
134
+ // don't truncate the history.
135
+ const logPath = path.join(karajanHome, "hu-board.log");
136
+ const logFd = fs.openSync(logPath, "a");
137
+ fs.writeSync(
138
+ logFd,
139
+ `\n--- kj board start at ${new Date().toISOString()} (port ${port}, bind ${bind}) ---\n`,
140
+ );
141
+
129
142
  // Use process.execPath (absolute path to current node binary) instead of "node".
130
143
  // Fixes ENOENT on nvm setups where `node` is not in the spawned process's PATH.
131
144
  const child = spawn(process.execPath, [serverPath], {
132
145
  env: { ...process.env, PORT: String(port), BIND_HOST: bind },
133
146
  detached: true,
134
- stdio: "ignore",
147
+ stdio: ["ignore", logFd, logFd],
135
148
  cwd: BOARD_DIR
136
149
  });
150
+ // The parent doesn't need the fd anymore — the child inherited it.
151
+ fs.closeSync(logFd);
137
152
  // Swallow spawn errors so an HU Board failure never crashes the pipeline.
138
153
  child.on("error", () => { /* non-blocking — caller logs via tryAutoStartBoard catch */ });
139
154
  if (!child.pid) {
@@ -143,10 +158,16 @@ export async function startBoard(desiredPort = 4000, opts = {}) {
143
158
  // Detach for real: `detached: true` only sets up the new session;
144
159
  // without `unref()` the parent's event loop still holds a reference
145
160
  // to the child handle and `kj board start` hangs at the prompt
146
- // instead of returning. Pair this with stdio:"ignore" (already set)
147
- // to ensure the parent isn't waiting on any of the child's pipes.
161
+ // instead of returning.
148
162
  child.unref();
149
- return { ok: true, alreadyRunning: false, pid: child.pid, url: buildBoardUrl(port, projectSlug), port };
163
+ return {
164
+ ok: true,
165
+ alreadyRunning: false,
166
+ pid: child.pid,
167
+ url: buildBoardUrl(port, projectSlug),
168
+ port,
169
+ logPath,
170
+ };
150
171
  }
151
172
 
152
173
  export async function stopBoard(opts = {}) {
@@ -273,6 +294,9 @@ export async function boardCommand({ action = "start", port = 4000, bind = "127.
273
294
  logger.info(`HU Board already running (PID ${result.pid}) at ${result.url}`);
274
295
  } else {
275
296
  logger.info(`HU Board started (PID ${result.pid}) at ${result.url}`);
297
+ if (result.logPath) {
298
+ logger.info(`Daemon logs: tail -f ${result.logPath}`);
299
+ }
276
300
  if (bind !== "127.0.0.1" && bind !== "localhost") {
277
301
  logger.warn(`HU Board bound to ${bind} — token auth enforced for non-loopback peers. Token: ~/.karajan/hu-board/token`);
278
302
  }
@@ -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) {
@@ -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
+ }
@@ -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]
@@ -26,9 +26,19 @@ function printSessionPlanner(planner) {
26
26
  }
27
27
  }
28
28
 
29
- function printSessionSonar(sonar) {
29
+ export function printSessionSonar(sonar) {
30
30
  if (!sonar) return;
31
- const gateLabel = sonar.gateStatus === "OK" ? ANSI.green : ANSI.red;
31
+ // Three buckets so the banner reads correctly:
32
+ // - OK \u2192 green (real pass).
33
+ // - SKIPPED / PENDING \u2192 gray (informational; sonar
34
+ // wasn't gated against, this is NOT a fail). Pre-2026-05-07 N4
35
+ // dogfooding these were rendered red, which made a clean run
36
+ // look like a sonar failure during the live demo.
37
+ // - everything else (ERROR, WARN, ...) \u2192 red (real fail).
38
+ let gateLabel;
39
+ if (sonar.gateStatus === "OK") gateLabel = ANSI.green;
40
+ else if (sonar.gateStatus === "SKIPPED" || sonar.gateStatus === "PENDING") gateLabel = ANSI.gray;
41
+ else gateLabel = ANSI.red;
32
42
  console.log(` ${ANSI.dim}\ud83d\udd0d Sonar: ${gateLabel}${sonar.gateStatus}${ANSI.reset}${ANSI.dim} (${sonar.openIssues ?? 0} issues)${ANSI.reset}`);
33
43
  if (typeof sonar.issuesInitial === "number" || typeof sonar.issuesResolved === "number") {
34
44
  const issuesInitial = sonar.issuesInitial ?? sonar.openIssues ?? 0;
package/src/utils/git.js CHANGED
@@ -65,6 +65,42 @@ export async function revParse(ref) {
65
65
  return runGit(["rev-parse", ref]);
66
66
  }
67
67
 
68
+ /**
69
+ * List commits in the range `fromSha..toRef`. Used by the post-loop
70
+ * journal builder to enumerate every commit produced during a `kj run`
71
+ * — both the coder's own commits AND the post-loop scaffold commit —
72
+ * so the user-visible summary doesn't lose track of them. Pre-fix the
73
+ * summary only listed `gitResult.commits` (post-loop only), which made
74
+ * `kj run "..."` runs look like the coder hadn't committed.
75
+ *
76
+ * Returns oldest-first so the list reads in execution order.
77
+ *
78
+ * @param {string|null|undefined} fromSha The SHA right BEFORE the run started
79
+ * (typically `session.session_start_sha`). Empty/missing → returns [].
80
+ * @param {string} [toRef="HEAD"]
81
+ * @returns {Promise<Array<{hash: string, message: string}>>}
82
+ */
83
+ export async function listCommitsBetween(fromSha, toRef = "HEAD") {
84
+ if (!fromSha) return [];
85
+ const result = await run(
86
+ "git",
87
+ ["log", "--reverse", "--format=%H%x09%s", `${fromSha}..${toRef}`],
88
+ ).catch(() => null);
89
+ if (!result || result.exitCode !== 0) return [];
90
+ return String(result.stdout || "")
91
+ .split("\n")
92
+ .map((line) => line.trimEnd())
93
+ .filter(Boolean)
94
+ .map((line) => {
95
+ const tabIdx = line.indexOf("\t");
96
+ // Defensive: if git emitted no tab (shouldn't happen with our format)
97
+ // treat the whole line as the hash and leave the message empty rather
98
+ // than throw — caller can still render the row.
99
+ if (tabIdx === -1) return { hash: line, message: "" };
100
+ return { hash: line.slice(0, tabIdx), message: line.slice(tabIdx + 1) };
101
+ });
102
+ }
103
+
68
104
  export async function syncBaseBranch({ baseBranch, autoRebase }) {
69
105
  const local = await revParse(baseBranch);
70
106
  const remote = await revParse(`origin/${baseBranch}`);
@@ -109,11 +145,79 @@ export async function hasChanges() {
109
145
  return status.length > 0;
110
146
  }
111
147
 
148
+ /**
149
+ * Return the relative paths of every uncommitted change (modified,
150
+ * untracked, staged, etc.) in the working tree. Each path is the
151
+ * filename as `git status --porcelain` reports it (after the 2-char
152
+ * status code + space). Returns an empty array when the tree is clean.
153
+ *
154
+ * Used by `finalizeGitAutomation` to decide whether the changes
155
+ * pending at the end of a pipeline are exclusively Karajan
156
+ * scaffolding (and thus deserve a `chore:` commit message instead
157
+ * of inheriting the user task's `feat:`).
158
+ *
159
+ * NOTE: bypasses `runGit()` because that helper trims stdout, which
160
+ * eats the leading space of porcelain lines whose first status char
161
+ * is " " (e.g. " M .gitignore" → "M .gitignore"). The leading space
162
+ * is structural here — without it the slice(3) below would crop one
163
+ * character off the first path.
164
+ */
165
+ export async function listPendingPaths() {
166
+ const res = await run("git", ["status", "--porcelain"]);
167
+ if (res.exitCode !== 0) {
168
+ throw new Error(`git status --porcelain failed: ${res.stderr || res.stdout}`);
169
+ }
170
+ const stdout = res.stdout;
171
+ if (!stdout) return [];
172
+ const lines = stdout.split("\n").map((l) => l.replace(/\r$/, "")).filter(Boolean);
173
+ const out = [];
174
+ for (const line of lines) {
175
+ // Each line is "XY <path>" (XY = 2-char status, space, path).
176
+ // Renames look like "R old -> new"; we want the new path.
177
+ const after = line.slice(3);
178
+ const arrow = after.indexOf(" -> ");
179
+ out.push(arrow !== -1 ? after.slice(arrow + 4) : after);
180
+ }
181
+ return out;
182
+ }
183
+
184
+ // Locale-tolerant detection of "git commit refused because the working
185
+ // tree was already clean by the time we ran it". Observed in N4
186
+ // dogfooding (2026-05-07) — `hasChanges()` reported true after `add -A`
187
+ // but `git commit` then failed with the Spanish message "nada para hacer
188
+ // commit, el árbol de trabajo está limpio". The race window is real:
189
+ // post-loop journal writes can finish between hasChanges and commit, and
190
+ // some files come back from `add -A` with no diff vs HEAD even though
191
+ // they show up in `git status --porcelain`. We treat any of those as
192
+ // "no commit needed" rather than escalating to Solomon.
193
+ const NOTHING_TO_COMMIT_PATTERNS = [
194
+ /nothing to commit/i,
195
+ /nada para hacer commit/i, // git in es_ES locale
196
+ /no hay nada para confirmar/i, // git in es_ES alt phrasing
197
+ /aucune modification ajout[ée]e au commit/i, // fr_FR
198
+ /nichts zu committen/i, // de_DE
199
+ ];
200
+ function isNothingToCommit(message) {
201
+ const m = String(message || "");
202
+ return NOTHING_TO_COMMIT_PATTERNS.some((re) => re.test(m));
203
+ }
204
+
112
205
  export async function commitAll(message) {
113
206
  await runGit(["add", "-A"]);
114
207
  const changed = await hasChanges();
115
208
  if (!changed) return { committed: false };
116
- await runGit(["commit", "-m", message]);
209
+ try {
210
+ await runGit(["commit", "-m", message]);
211
+ } catch (err) {
212
+ // `git status --porcelain` and `git commit` disagreed about whether
213
+ // there was anything to commit. Don't escalate — the only outcome
214
+ // either way is "no new commit was created", which the caller
215
+ // already handles via committed=false.
216
+ if (isNothingToCommit(err?.message)) {
217
+ return { committed: false };
218
+ }
219
+ throw err;
220
+ }
117
221
  const raw = await runGit(["log", "-1", "--pretty=format:%H%x1f%s"]);
118
222
  const [hash, commitMessage] = raw.split("\x1f");
119
223
  return { committed: true, commit: { hash, message: commitMessage } };