@kyubiware/commit-mint 0.9.3 → 0.10.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
@@ -72,6 +72,7 @@ cmint # interactive cli for committing changes
72
72
  ```bash
73
73
  cmint # interactive: stage → checks → review → commit
74
74
  cmint -a # auto-group, generate messages, commit everything
75
+ cmint -a 3 # auto-group into exactly 3 commits
75
76
  cmint -s # stage all tracked files in single commit, skip staging menu
76
77
  cmint config # edit provider, model, locale, etc.
77
78
  cmint update # update cmint to the latest published version
@@ -185,7 +186,7 @@ single raw-stderr entry.
185
186
 
186
187
  | Flag | Description |
187
188
  |---|---|
188
- | `-a`, `--auto` | Auto-stage tracked files and auto-group into commits |
189
+ | `-a`, `--auto [N]` | Auto-group files into N commits (default: LLM decides). Use `-a 0` or `-a` for AI-determined groups |
189
190
  | `-m`, `--message <msg>` | Use your own message instead of AI generation |
190
191
  | `-H`, `--hint <hint>` | Context hint to the AI (e.g. `"refactor only"`) |
191
192
  | `-r`, `--retry` | Retry last failed commit (uses cached message) |
@@ -274,7 +275,7 @@ returns either.
274
275
  | Form | Behavior |
275
276
  |---|---|
276
277
  | `string` | Matched files are appended as trailing arguments. Paths with spaces are quoted automatically. |
277
- | `string[]` | Commands run sequentially, each as a separate command. First failure stops the run. |
278
+ | `string[]` | Commands run sequentially, each as a separate command. All commands run regardless of failures. |
278
279
  | `(files) => string \| string[]` | Function receives the matched files. Use when the command depends on the file list. |
279
280
 
280
281
  **String command:**
@@ -313,8 +314,8 @@ export default {
313
314
 
314
315
  - Checks run after `git add`, before the AI call.
315
316
  - Globs are processed in declaration order.
316
- - Commands run sequentially per glob. First failure stops the run (fail-fast)
317
- and skips remaining globs.
317
+ - Commands run sequentially per glob. All commands and all globs always run;
318
+ failures are collected and returned together.
318
319
  - 60s timeout per command. ENOENT (command not found) and timeouts are
319
320
  reported back to the menu as their own error.
320
321
  - Skipped entirely with `cmint -N` or the `c` hotkey toggle.
package/dist/bin.mjs CHANGED
@@ -33,7 +33,7 @@ var __exportAll = (all, no_symbols) => {
33
33
  //#region package.json
34
34
  var package_default = {
35
35
  name: "@kyubiware/commit-mint",
36
- version: "0.9.3",
36
+ version: "0.10.0",
37
37
  description: "🌿 AI-powered git commit tool — auto-group changed files, generate messages, run pre-commit checks",
38
38
  type: "module",
39
39
  bin: { "cmint": "./dist/bin.mjs" },
@@ -1727,8 +1727,8 @@ function statusIndicator(status) {
1727
1727
  function buildFileSummary(files) {
1728
1728
  return files.map((f) => `${f.path} (${statusIndicator(f.status)})`).join("\n");
1729
1729
  }
1730
- function buildGroupingSystemPrompt() {
1731
- return [
1730
+ function buildGroupingSystemPrompt(groupCount) {
1731
+ const lines = [
1732
1732
  "You are analyzing changed files in a git repository. Group them into logical commits based on what changed and why. Each group should be a coherent unit of work.",
1733
1733
  "",
1734
1734
  "Rules:",
@@ -1745,7 +1745,9 @@ function buildGroupingSystemPrompt() {
1745
1745
  "files: array of exact file paths from the input",
1746
1746
  "",
1747
1747
  "Output ONLY valid JSON. No markdown fences, no explanation."
1748
- ].join("\n");
1748
+ ];
1749
+ if (groupCount && groupCount > 0) lines.unshift(`Create exactly ${groupCount} groups.`);
1750
+ return lines.join("\n");
1749
1751
  }
1750
1752
  function buildGroupingUserPrompt(summary) {
1751
1753
  return [
@@ -1778,7 +1780,24 @@ function buildRetryGroupingPrompt() {
1778
1780
  "Output ONLY valid JSON. No markdown fences, no explanation."
1779
1781
  ].join("\n");
1780
1782
  }
1781
- async function generateGroups(files, apiKey, model, timeout, provider, proxy) {
1783
+ /**
1784
+ * When a specific group count is requested but there are fewer included files
1785
+ * than groups, create one-file-per-group groups without calling the AI.
1786
+ * Returns null when the pre-condition is not met.
1787
+ */
1788
+ function createPerFileGroups(files, groupCount, excluded) {
1789
+ if (!(groupCount && groupCount > 0 && files.length < groupCount)) return null;
1790
+ debug("generateGroups: %d files < %d requested groups, creating per-file groups", files.length, groupCount);
1791
+ return {
1792
+ groups: files.map((f) => ({
1793
+ name: f.path.split("/").pop() || f.path,
1794
+ description: `Changes to ${f.path}`,
1795
+ files: [f.path]
1796
+ })),
1797
+ excluded
1798
+ };
1799
+ }
1800
+ async function generateGroups(files, apiKey, model, timeout, provider, proxy, groupCount) {
1782
1801
  debug("generateGroups: %d files, model=%s", files.length, model ?? "default");
1783
1802
  const { included, excluded } = filterExcludedFiles(files);
1784
1803
  if (included.length === 0) {
@@ -1788,8 +1807,10 @@ async function generateGroups(files, apiKey, model, timeout, provider, proxy) {
1788
1807
  excluded
1789
1808
  };
1790
1809
  }
1810
+ const perFile = createPerFileGroups(included, groupCount, excluded);
1811
+ if (perFile) return perFile;
1791
1812
  const summary = buildFileSummary(included);
1792
- const systemPrompt = buildGroupingSystemPrompt();
1813
+ const systemPrompt = buildGroupingSystemPrompt(groupCount);
1793
1814
  const userPrompt = buildGroupingUserPrompt(summary);
1794
1815
  debug("File summary:\n%s", summary);
1795
1816
  debug("User prompt length: %d chars", userPrompt.length);
@@ -2287,30 +2308,29 @@ function resolveCommands(commands, matchedFiles) {
2287
2308
  /**
2288
2309
  * Run resolved commands for a single glob entry, appending results.
2289
2310
  * Function-originated commands run as-is; string commands get matched files appended.
2290
- * Returns false if any command fails (for fail-fast signaling).
2311
+ * All commands run regardless of individual failures.
2291
2312
  */
2292
- async function runCommandsForGlob(cmds, matchedFiles, timeout, results, repoRoot) {
2313
+ async function runCommandsForGlob(cmds, matchedFiles, timeout, results, repoRoot, observer) {
2293
2314
  for (const { command, fromFunction } of cmds) {
2294
2315
  const fullCommand = fromFunction ? command : buildCommand(command, matchedFiles);
2316
+ const tool = extractToolName(fullCommand) ?? fullCommand.split(" ")[0];
2295
2317
  debug("runCommandsForGlob: running '%s'", fullCommand);
2318
+ observer?.onStart?.(tool, fullCommand, matchedFiles);
2296
2319
  const result = await runCommand(fullCommand, timeout, repoRoot);
2297
2320
  results.push({
2298
2321
  ...result,
2299
2322
  files: matchedFiles
2300
2323
  });
2301
- if (!result.ok) {
2302
- debug("runCommandsForGlob: check failed, stopping (fail-fast)");
2303
- return false;
2304
- }
2324
+ observer?.onResult?.(result);
2325
+ if (!result.ok) debug("runCommandsForGlob: check failed, continuing to next command");
2305
2326
  }
2306
- return true;
2307
2327
  }
2308
2328
  /**
2309
2329
  * Run all user-defined checks from .cmintrc against staged files.
2310
2330
  * Returns a no-op result when no config exists.
2311
- * Fail-fast: stops on first error.
2331
+ * All checks always run; failures are collected and returned together.
2312
2332
  */
2313
- async function runAllChecks(repoRoot, stagedFiles, timeout) {
2333
+ async function runAllChecks(repoRoot, stagedFiles, timeout, observer) {
2314
2334
  debug("runAllChecks: %d staged files, checking for config in %s", stagedFiles.length, repoRoot);
2315
2335
  if (!await detectConfig(repoRoot)) {
2316
2336
  debug("runAllChecks: no config found, skipping checks");
@@ -2329,10 +2349,7 @@ async function runAllChecks(repoRoot, stagedFiles, timeout) {
2329
2349
  continue;
2330
2350
  }
2331
2351
  debug("runAllChecks: pattern '%s' matched %d files", glob, matchedFiles.length);
2332
- if (!await runCommandsForGlob(resolveCommands(commands, matchedFiles), matchedFiles, timeout, results, repoRoot)) return {
2333
- ok: false,
2334
- results
2335
- };
2352
+ await runCommandsForGlob(resolveCommands(commands, matchedFiles), matchedFiles, timeout, results, repoRoot, observer);
2336
2353
  }
2337
2354
  const ok = results.every((r) => r.ok);
2338
2355
  debug("runAllChecks: complete — ok=%s, %d results", ok, results.length);
@@ -2414,7 +2431,9 @@ function extractEslintDiagnostics(raw) {
2414
2431
  function extractTestFailures(raw) {
2415
2432
  const failures = [];
2416
2433
  const seen = /* @__PURE__ */ new Set();
2417
- for (const line of raw.split("\n")) {
2434
+ const lines = raw.split("\n");
2435
+ for (let i = 0; i < lines.length; i++) {
2436
+ const line = lines[i];
2418
2437
  const match = TEST_FILE_FAIL.exec(line);
2419
2438
  if (!match) continue;
2420
2439
  const file = (match[1] ?? "").trim();
@@ -2423,13 +2442,35 @@ function extractTestFailures(raw) {
2423
2442
  const key = `${file}\u0000${name}`;
2424
2443
  if (seen.has(key)) continue;
2425
2444
  seen.add(key);
2445
+ const message = scanForwardForMessage(lines, i + 1, TEST_FILE_FAIL);
2426
2446
  failures.push({
2427
2447
  file,
2428
- name
2448
+ name,
2449
+ message
2429
2450
  });
2430
2451
  }
2431
2452
  return failures;
2432
2453
  }
2454
+ /**
2455
+ * Scan forward from `startIndex` to find the first assertion error message line
2456
+ * after a FAIL header. Skips blank lines, separator dashes, stack frames,
2457
+ * subsequent FAIL lines, and vitest summary footers.
2458
+ */
2459
+ function scanForwardForMessage(lines, startIndex, testFileFailRegex) {
2460
+ for (let j = startIndex; j < lines.length; j++) {
2461
+ const rawLine = lines[j];
2462
+ const trimmed = rawLine.trim();
2463
+ if (!trimmed) continue;
2464
+ const stripped = trimmed.replace(/\s/g, "");
2465
+ if (stripped.length > 5) {
2466
+ if ((stripped.match(/[⎯\-═]/g) ?? []).length / stripped.length > .5) continue;
2467
+ }
2468
+ if (/^\s*[❯>]\s/.test(rawLine)) continue;
2469
+ if (testFileFailRegex.test(trimmed)) break;
2470
+ if (/^(Test Files|Tests)\s/.test(trimmed)) break;
2471
+ return truncate(trimmed, MAX_SUMMARY_LINE_LENGTH);
2472
+ }
2473
+ }
2433
2474
  function formatTestFailureSummary(failures, tool) {
2434
2475
  const total = failures.length;
2435
2476
  const visible = failures.slice(0, MAX_TEST_FAILURES);
@@ -2440,13 +2481,19 @@ function formatTestFailureSummary(failures, tool) {
2440
2481
  const lines = [` ${red("•")} [${tool}] ${total} failed ${testNoun} in ${fileCount} ${fileNoun}`];
2441
2482
  const byFile = /* @__PURE__ */ new Map();
2442
2483
  for (const failure of visible) {
2443
- const names = byFile.get(failure.file) ?? [];
2444
- names.push(failure.name);
2445
- byFile.set(failure.file, names);
2484
+ const entries = byFile.get(failure.file) ?? [];
2485
+ entries.push({
2486
+ name: failure.name,
2487
+ message: failure.message
2488
+ });
2489
+ byFile.set(failure.file, entries);
2446
2490
  }
2447
- for (const [file, names] of byFile) {
2491
+ for (const [file, entries] of byFile) {
2448
2492
  lines.push(` ${truncate(file, MAX_SUMMARY_LINE_LENGTH)}`);
2449
- for (const name of names) lines.push(` ${red("×")} ${truncate(name, MAX_SUMMARY_LINE_LENGTH)}`);
2493
+ for (const entry of entries) {
2494
+ lines.push(` ${red("×")} ${truncate(entry.name, MAX_SUMMARY_LINE_LENGTH)}`);
2495
+ if (entry.message) lines.push(` ${dim("→")} ${entry.message}`);
2496
+ }
2450
2497
  }
2451
2498
  if (hidden > 0) lines.push(dim(` +${hidden} more failed ${hidden === 1 ? "test" : "tests"}. View full output for details.`));
2452
2499
  return lines.join("\n");
@@ -2543,24 +2590,42 @@ async function showCheckFailureMenu(errors, rawStderr, onRetry) {
2543
2590
  }
2544
2591
  }
2545
2592
  //#endregion
2546
- //#region src/ui/check-summary.ts
2593
+ //#region src/ui/check-progress.ts
2547
2594
  /**
2548
- * Stop a check spinner with a per-tool summary of the check results.
2595
+ * Creates a live check progress display.
2549
2596
  *
2550
- * - On success: stops with "All checks passed" and prints a `✓ tool` line
2551
- * for each result.
2552
- * - On failure: stops with "N checks failed" (pluralized). Raw error output
2553
- * is intentionally NOT printed here — callers handle failure display
2554
- * (menu, raw print, etc.).
2597
+ * - Each check gets its own line. While a check runs, a spinner animates
2598
+ * in the status position. When it completes, the spinner becomes a ✓ or ✗.
2599
+ * - There is no separate "Running checks" header the spinner IS the
2600
+ * check line.
2601
+ * - When all checks finish, a `log.info` summary line is emitted.
2602
+ *
2603
+ * Returns a `CheckObserver` (pass directly to `runAllChecks`) plus a `finish()`
2604
+ * method that prints the final summary.
2555
2605
  */
2556
- function stopCheckSpinner(spinner, results) {
2557
- if (results.ok) {
2558
- spinner.stop("All checks passed");
2559
- if (results.results.length > 0) log.info(results.results.map((r) => ` ${green("✓")} ${r.tool}`).join("\n"));
2560
- } else {
2561
- const failed = results.results.filter((r) => !r.ok);
2562
- spinner.stop(`${failed.length} check${failed.length !== 1 ? "s" : ""} failed`);
2563
- }
2606
+ function createCheckProgressDisplay() {
2607
+ const s = spinner();
2608
+ const toolResults = [];
2609
+ let started = false;
2610
+ return {
2611
+ onStart(_tool, _command, _matchedFiles) {
2612
+ if (!started) {
2613
+ s.start(_tool);
2614
+ started = true;
2615
+ } else s.message(_tool);
2616
+ },
2617
+ onResult(result) {
2618
+ toolResults.push({
2619
+ tool: result.tool,
2620
+ ok: result.ok
2621
+ });
2622
+ s.message(`${result.ok ? "✓" : "✗"} ${result.tool}`);
2623
+ },
2624
+ finish(ok) {
2625
+ s.stop(ok ? green("All checks passed") : red("Some checks failed"));
2626
+ if (toolResults.length > 0) log.info(toolResults.map((r) => ` ${r.ok ? green("✓") : red("✗")} ${r.tool}`).join("\n"));
2627
+ }
2628
+ };
2564
2629
  }
2565
2630
  //#endregion
2566
2631
  //#region src/commands/check-phase.ts
@@ -2569,7 +2634,7 @@ function stopCheckSpinner(spinner, results) {
2569
2634
  *
2570
2635
  * Single entry point for the check-execution pipeline shared by `runPreCommitChecks`
2571
2636
  * (post-staging, normal commit flow) and `runAutoGroupFlow` (pre-staging, auto-group
2572
- * flow). Encapsulates: detectConfig guard → spinner → runAllChecks → retry loop with
2637
+ * flow). Encapsulates: detectConfig guard → live progress display → runAllChecks → retry loop with
2573
2638
  * `showCheckFailureMenu`.
2574
2639
  *
2575
2640
  * Caller responsibilities:
@@ -2585,10 +2650,9 @@ function stopCheckSpinner(spinner, results) {
2585
2650
  async function runCheckPhaseInteractive(repoRoot, files, timeout, onRetry) {
2586
2651
  if (!await detectConfig(repoRoot)) return "passed";
2587
2652
  debug("Running user checks on %d files...", files.length);
2588
- const ck = spinner();
2589
- ck.start("Running checks...");
2590
- let checkResults = await runAllChecks(repoRoot, files, timeout);
2591
- stopCheckSpinner(ck, checkResults);
2653
+ const display = createCheckProgressDisplay();
2654
+ let checkResults = await runAllChecks(repoRoot, files, timeout, display);
2655
+ display.finish(checkResults.ok);
2592
2656
  debug("Check results: ok=%s, count=%d", checkResults.ok, checkResults.results.length);
2593
2657
  while (!checkResults.ok) {
2594
2658
  const rawOutput = checkResults.results.filter((r) => !r.ok).map((r) => `[${r.tool}]\n${r.stdout}\n${r.stderr}`.trim()).join("\n\n");
@@ -2599,9 +2663,9 @@ async function runCheckPhaseInteractive(repoRoot, files, timeout, onRetry) {
2599
2663
  if (menuResult === "retried") {
2600
2664
  debug("Re-running checks after retry...");
2601
2665
  if (onRetry) await onRetry();
2602
- ck.start("Running checks...");
2603
- checkResults = await runAllChecks(repoRoot, files, timeout);
2604
- stopCheckSpinner(ck, checkResults);
2666
+ const retryDisplay = createCheckProgressDisplay();
2667
+ checkResults = await runAllChecks(repoRoot, files, timeout, retryDisplay);
2668
+ retryDisplay.finish(checkResults.ok);
2605
2669
  debug("Retry check results: ok=%s, count=%d", checkResults.ok, checkResults.results.length);
2606
2670
  continue;
2607
2671
  }
@@ -2662,13 +2726,14 @@ async function runAutoGroupFlow(changedFiles, flags) {
2662
2726
  await setConfigValue(configKey, String(key).trim());
2663
2727
  debug("API key saved to config");
2664
2728
  }
2729
+ const groupCount = typeof flags.auto === "number" ? flags.auto : 0;
2665
2730
  const s = spinner();
2666
2731
  s.start("Analyzing files...");
2667
- const validatedGroups = validateGroups((await generateGroups(included, await getProviderApiKey(provider), getModelForProvider(config, provider, PROVIDER_CONFIGS[provider].defaultModel), config.timeout ? parseInt(config.timeout, 10) : void 0, provider, config.proxy)).groups, included);
2732
+ const validatedGroups = validateGroups((await generateGroups(included, await getProviderApiKey(provider), getModelForProvider(config, provider, PROVIDER_CONFIGS[provider].defaultModel), config.timeout ? parseInt(config.timeout, 10) : void 0, provider, config.proxy, groupCount)).groups, included);
2668
2733
  s.stop("Files analyzed");
2669
2734
  showGroupedFiles(validatedGroups, included);
2670
2735
  const autoAccept = await getAutoAccept();
2671
- const skipPrompts = flags.auto || autoAccept;
2736
+ const skipPrompts = flags.auto !== false || autoAccept;
2672
2737
  if (skipPrompts) debug("Skipping grouping confirmation (auto=%s autoAccept=%s)", flags.auto, autoAccept);
2673
2738
  else if (!await showGroupingConfirmation(validatedGroups, excluded)) {
2674
2739
  outro(dim("Cancelled."));
@@ -3691,11 +3756,13 @@ function buildMultiSelectRender(message, options, output) {
3691
3756
  return `${header}\n${dim(S_BAR_END)} ${cancelled}`;
3692
3757
  }
3693
3758
  default: {
3759
+ const stdio = output ?? process.stdout;
3760
+ const termRows = ("rows" in stdio ? stdio.rows : void 0) ?? 24;
3694
3761
  const lines = limitOptions({
3695
3762
  cursor,
3696
3763
  options,
3697
3764
  style: (opt, active) => renderCheckboxOption(opt.label, value.includes(opt.value), active),
3698
- maxItems: 7,
3765
+ maxItems: Math.min(options.length, Math.max(5, termRows - 3)),
3699
3766
  output: output ?? process.stdout
3700
3767
  }).map((line) => `${dim(S_BAR)} ${line}`);
3701
3768
  const hintLine = "↑/↓ navigate · space select · enter confirm · A select all · N select none";
@@ -3806,8 +3873,8 @@ function buildPromptRenderer(opts, state) {
3806
3873
  const toggleList = opts.toggles;
3807
3874
  return function() {
3808
3875
  const sym = symbol(this.state);
3809
- const statusLines = toggleList.map((t) => renderToggleState(t, state[t.hotkey])).join("\n");
3810
- const header = `${sym} ${opts.message}\n${dim(S_BAR)} ${statusLines}`;
3876
+ const statusLines = toggleList.map((t) => `${dim(S_BAR)} ${renderToggleState(t, state[t.hotkey])}`).join("\n");
3877
+ const header = `${sym} ${opts.message}\n${statusLines}`;
3811
3878
  switch (this.state) {
3812
3879
  case "submit": {
3813
3880
  const selected = optionList[this.cursor];
@@ -3820,11 +3887,14 @@ function buildPromptRenderer(opts, state) {
3820
3887
  return `${header}\n${dim(S_BAR)} ${styleText(["strikethrough", "dim"], text)}\n${dim(S_BAR_END)}`;
3821
3888
  }
3822
3889
  default: {
3890
+ const stdio = opts.output ?? process.stdout;
3891
+ const termRows = ("rows" in stdio ? stdio.rows : void 0) ?? 24;
3892
+ const dynamicMax = Math.min(optionList.length, Math.max(5, termRows - 5));
3823
3893
  const lines = limitOptions({
3824
3894
  cursor: this.cursor,
3825
3895
  options: optionList,
3826
3896
  style: (opt, active) => renderOption(opt, active),
3827
- maxItems: 7,
3897
+ maxItems: dynamicMax,
3828
3898
  output: opts.output ?? process.stdout
3829
3899
  }).map((line) => `${dim(S_BAR)} ${line}`);
3830
3900
  const hotkeysHint = toggleList.map((t) => `\`${t.hotkey}\` toggle ${t.label.toLowerCase()}`).join(" • ");
@@ -3972,10 +4042,9 @@ async function handleStaging(changedFiles, flags) {
3972
4042
  await stageAll();
3973
4043
  const allFiles = await getStagedFiles();
3974
4044
  if (await detectConfig(repoRoot)) {
3975
- const ckSpinner = spinner();
3976
- ckSpinner.start("Running checks...");
3977
- const ckResult = await runAllChecks(repoRoot, allFiles, 6e4);
3978
- stopCheckSpinner(ckSpinner, ckResult);
4045
+ const display = createCheckProgressDisplay();
4046
+ const ckResult = await runAllChecks(repoRoot, allFiles, 6e4, display);
4047
+ display.finish(ckResult.ok);
3979
4048
  if (!ckResult.ok) for (const r of ckResult.results.filter((r) => !r.ok)) log.info(r.stderr?.trim() || r.stdout?.trim() || `Check failed: ${r.command}`);
3980
4049
  }
3981
4050
  currentFiles = await getChangedFiles();
@@ -4054,7 +4123,7 @@ async function commitCommand(flags, version) {
4054
4123
  if (flags.single) {
4055
4124
  debug("Single-commit mode: staging all files");
4056
4125
  await stageAll();
4057
- } else if (flags.auto) {
4126
+ } else if (flags.auto !== false) {
4058
4127
  if (flags.message) {
4059
4128
  outro(red("--message flag is not compatible with auto-group mode."));
4060
4129
  return;
@@ -4356,9 +4425,10 @@ async function agentCommand(flags) {
4356
4425
  }
4357
4426
  const model = getModelForProvider(config, provider, PROVIDER_CONFIGS[provider].defaultModel);
4358
4427
  const timeout = config.timeout ? parseInt(config.timeout, 10) : void 0;
4428
+ const groupCount = typeof flags.auto === "number" ? flags.auto : 0;
4359
4429
  let groups;
4360
4430
  try {
4361
- groups = validateGroups((await generateGroups(included, apiKey, model, timeout, provider, config.proxy)).groups, included);
4431
+ groups = validateGroups((await generateGroups(included, apiKey, model, timeout, provider, config.proxy, groupCount)).groups, included);
4362
4432
  } catch (err) {
4363
4433
  process.exitCode = EXIT_CODES.AI;
4364
4434
  writeAgentResult({
@@ -4820,8 +4890,12 @@ cli({
4820
4890
  default: false
4821
4891
  },
4822
4892
  auto: {
4823
- type: Boolean,
4824
- description: "Auto-group files into commits and accept messages (no prompts)",
4893
+ type: (raw) => {
4894
+ if (raw === "") return true;
4895
+ const n = Number(raw);
4896
+ return Number.isNaN(n) ? true : n;
4897
+ },
4898
+ description: "Auto-group files into commits. Use -a <N> to request N groups (0 = LLM decides)",
4825
4899
  alias: "a",
4826
4900
  default: false
4827
4901
  },