@hifullmoon/aicommit 2.4.1 → 2.6.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.
@@ -12,7 +12,7 @@ import {
12
12
  isDeepAnalysis,
13
13
  isGeneratedFile,
14
14
  localOverview,
15
- localPlanItems,
15
+ localPlanBatches,
16
16
  } from './local-analysis.js';
17
17
 
18
18
  function gitQuote(path) {
@@ -230,16 +230,17 @@ const ANALYSIS_SYSTEM =
230
230
  export function analysisConfig(config) {
231
231
  if (config.analysisBudget) return config;
232
232
  const settings = { ...DEFAULT_LARGE_CHANGE, ...config.largeChange };
233
+ settings.cache = { ...DEFAULT_LARGE_CHANGE.cache, ...config.largeChange?.cache };
233
234
  const model = getProviderAdapter(config).model;
234
- const output = Math.max(
235
- config.maxTokens || 1024,
236
- config.reasoning?.mode === 'on' ? config.reasoning.maxTokens || 4096 : 0,
237
- );
235
+ const output =
236
+ config.reasoning?.mode === 'off'
237
+ ? config.maxTokens || 1024
238
+ : Math.max(config.maxTokens || 1024, config.reasoning?.maxTokens || 4096);
238
239
  settings.chunkInputTokens = Math.min(
239
240
  settings.chunkInputTokens,
240
241
  Math.max(256, model.contextWindow - output - 1024),
241
242
  );
242
- return { ...config, analysisBudget: createAnalysisBudget(settings) };
243
+ return { ...config, largeChange: settings, analysisBudget: createAnalysisBudget(settings) };
243
244
  }
244
245
 
245
246
  export function packItems(items, maxChars, maxItems = 24) {
@@ -312,7 +313,7 @@ function parseAnalysisJson(raw) {
312
313
  throw new SyntaxError('Response contains no complete JSON array.');
313
314
  }
314
315
 
315
- async function jsonCall(config, instruction, items, validate = null) {
316
+ async function jsonCall(config, instruction, items, validate = null, stream = null) {
316
317
  const parseAndValidate = (raw) => {
317
318
  const parsed = parseAnalysisJson(raw);
318
319
  validate?.(parsed);
@@ -328,7 +329,7 @@ async function jsonCall(config, instruction, items, validate = null) {
328
329
  Math.min(2048, config.maxTokens || 1024),
329
330
  'Return the complete requested JSON array, preserving every required input ID exactly once. ' +
330
331
  `Required IDs: ${JSON.stringify(items.map((item) => item.id))}`,
331
- null,
332
+ stream,
332
333
  (response) => {
333
334
  try {
334
335
  parseAndValidate(response);
@@ -338,6 +339,7 @@ async function jsonCall(config, instruction, items, validate = null) {
338
339
  }
339
340
  },
340
341
  );
342
+ stream?.onReasoningComplete?.(result.reasoning);
341
343
  try {
342
344
  return parseAndValidate(result.text);
343
345
  } catch (cause) {
@@ -372,63 +374,201 @@ export function validatePartition(groups, ids) {
372
374
  throw fail(ERROR_CATEGORIES.RESPONSE_FORMAT, 'Analysis left input IDs unassigned.');
373
375
  }
374
376
 
377
+ function fragmentCacheKey(config, input, protect, persistentCache) {
378
+ return persistentCache
379
+ ? persistentCache.keyFor('fragment-analysis', input)
380
+ : createHash('sha256')
381
+ .update(
382
+ JSON.stringify({
383
+ version: 1,
384
+ input,
385
+ protect,
386
+ model: config.modelId,
387
+ endpoint: config.apiUrl,
388
+ context: config.repositoryContextText,
389
+ }),
390
+ )
391
+ .digest('hex');
392
+ }
393
+
394
+ function validateFragmentGroups(candidate, inputIds) {
395
+ validatePartition(candidate, inputIds);
396
+ if (candidate.some((group) => group.ids.length !== 1))
397
+ throw fail(
398
+ ERROR_CATEGORIES.RESPONSE_FORMAT,
399
+ 'Fragment analysis combined unrelated source IDs.',
400
+ );
401
+ }
402
+
403
+ function estimateInitialDeepCost(
404
+ config,
405
+ capture,
406
+ protect,
407
+ previews,
408
+ maxBatchTokens,
409
+ persistentCache,
410
+ ) {
411
+ const batchSizes = [];
412
+ let batch = [];
413
+ let batchTokens = 0;
414
+ let chunks = 0;
415
+ let cachedChunks = 0;
416
+ let oversizedInput = false;
417
+ const fragmentsByFile = new Map();
418
+ const flush = () => {
419
+ if (!batch.length) return;
420
+ chunks++;
421
+ const inputIds = batch.map((item) => item.id);
422
+ const cacheKey = fragmentCacheKey(config, batch, protect, persistentCache);
423
+ const cached =
424
+ capture.analysisCache?.get(cacheKey) ||
425
+ persistentCache?.read(cacheKey, inputIds, (candidate) =>
426
+ validateFragmentGroups(candidate, inputIds),
427
+ );
428
+ if (cached) cachedChunks++;
429
+ else batchSizes.push(batchTokens);
430
+ batch = [];
431
+ batchTokens = 0;
432
+ };
433
+ for (const unit of capture.units(protect, previews)) {
434
+ if (unit.metadataOnly) continue;
435
+ fragmentsByFile.set(unit.fileId, (fragmentsByFile.get(unit.fileId) || 0) + 1);
436
+ const size = estimateTokens(JSON.stringify(unit));
437
+ if (size + 768 > config.analysisBudget.limits.chunkInputTokens) oversizedInput = true;
438
+ if (batch.length && (batchTokens + size > maxBatchTokens || batch.length >= 16)) flush();
439
+ batch.push(unit);
440
+ batchTokens += size;
441
+ }
442
+ flush();
443
+ const output = Math.min(2048, config.maxTokens || 1024);
444
+ let reductionRequests = 0;
445
+ if (config.analysisTask === 'split') {
446
+ for (const fragments of fragmentsByFile.values()) {
447
+ let summaries = fragments;
448
+ while (summaries * 160 > 2000) {
449
+ const batches = Math.ceil(summaries / 24);
450
+ reductionRequests += batches;
451
+ summaries = batches;
452
+ }
453
+ }
454
+ }
455
+ let planningRequests = 1;
456
+ if (config.analysisTask === 'split') {
457
+ planningRequests = 0;
458
+ let candidates = Math.max(1, capture.manifest.length);
459
+ for (let level = 0; level < 8; level++) {
460
+ const batches = Math.ceil(candidates / (config.splitMaxPlanFiles || 100));
461
+ planningRequests += batches;
462
+ if (batches === 1) break;
463
+ candidates = batches;
464
+ }
465
+ }
466
+ const downstreamRequests = reductionRequests + planningRequests;
467
+ const downstreamReserve =
468
+ downstreamRequests * (config.analysisBudget.limits.chunkInputTokens + output);
469
+ const estimatedTokens =
470
+ batchSizes.reduce(
471
+ (total, input) =>
472
+ total + Math.min(config.analysisBudget.limits.chunkInputTokens, input + 768) + output,
473
+ 0,
474
+ ) + downstreamReserve;
475
+ return {
476
+ chunks,
477
+ cachedChunks,
478
+ requestedChunks: batchSizes.length,
479
+ estimatedTokens,
480
+ oversizedInput,
481
+ reductionRequests,
482
+ planningRequests,
483
+ downstreamReserve,
484
+ };
485
+ }
486
+
375
487
  export async function analyzeChanges(
376
488
  config,
377
489
  capture,
378
490
  protect = true,
379
491
  previews = null,
380
492
  onProgress = null,
493
+ persistentCache = null,
381
494
  ) {
382
495
  if (!isDeepAnalysis(config)) return analyzeLocally(config, capture, protect, previews);
383
496
  const budget = config.analysisBudget;
497
+ const maxBatchTokens = Math.min(
498
+ estimateTokens('x'.repeat(config.maxDiffChars || 30000)),
499
+ Math.floor(budget.limits.chunkInputTokens * 0.65),
500
+ );
501
+ // chunkInputTokens is a token budget, while String#length counts UTF-16 code
502
+ // units. Track the same conservative UTF-8 estimate used at dispatch time so
503
+ // ASCII-heavy repositories do not produce roughly twice as many requests and
504
+ // multibyte input still stays within the configured ceiling.
384
505
  const maxChars = Math.min(
385
506
  config.maxDiffChars || 30000,
386
507
  Math.floor(budget.limits.chunkInputTokens * 0.65),
387
508
  );
388
- budget.reserveFinal(
389
- budget.limits.chunkInputTokens +
390
- Math.max(config.maxTokens || 1024, config.reasoning?.maxTokens || 4096),
509
+ const estimate = estimateInitialDeepCost(
510
+ config,
511
+ capture,
512
+ protect,
513
+ previews,
514
+ maxBatchTokens,
515
+ persistentCache,
391
516
  );
517
+ if (estimate.oversizedInput || estimate.estimatedTokens > budget.limits.maxTotalTokens) {
518
+ const fallbackConfig = {
519
+ ...config,
520
+ largeChange: { ...config.largeChange, strategy: 'auto' },
521
+ };
522
+ const local = analyzeLocally(fallbackConfig, capture, protect, previews);
523
+ local.coverage = {
524
+ ...local.coverage,
525
+ degradedFrom: 'deep',
526
+ fallbackReason: estimate.oversizedInput ? 'preflight_input' : 'preflight_tokens',
527
+ estimatedDeepChunks: estimate.chunks,
528
+ estimatedCachedChunks: estimate.cachedChunks,
529
+ estimatedRequestedChunks: estimate.requestedChunks,
530
+ estimatedReductionRequests: estimate.reductionRequests,
531
+ estimatedPlanningRequests: estimate.planningRequests,
532
+ estimatedDeepTokens: estimate.estimatedTokens,
533
+ };
534
+ return local;
535
+ }
536
+ budget.reserveFinal(estimate.downstreamReserve);
392
537
  const fileFacts = new Map(capture.manifest.map((f) => [f.id, []]));
393
538
  let batch = [];
394
- let length = 0;
539
+ let batchTokens = 0;
395
540
  let done = 0;
396
541
  let analyzed = 0;
542
+ let cachedChunks = 0;
543
+ let requestedChunks = 0;
397
544
  const pending = [];
398
545
  const cache = (capture.analysisCache ||= new Map());
399
546
  async function flush() {
400
547
  if (!batch.length) return;
401
548
  const input = batch;
402
549
  batch = [];
403
- length = 0;
404
- const cacheKey = createHash('sha256')
405
- .update(
406
- JSON.stringify({
407
- version: 1,
408
- input,
409
- protect,
410
- model: config.modelId,
411
- endpoint: config.apiUrl,
412
- context: config.repositoryContextText,
413
- }),
414
- )
415
- .digest('hex');
550
+ batchTokens = 0;
551
+ const cacheKey = fragmentCacheKey(config, input, protect, persistentCache);
416
552
  const inputIds = input.map((x) => x.id);
417
- const groups =
418
- cache.get(cacheKey) ||
419
- (await jsonCall(
553
+ const validateGroups = (candidate) => validateFragmentGroups(candidate, inputIds);
554
+ let groups = cache.get(cacheKey);
555
+ let cacheHit = Boolean(groups);
556
+ if (!groups && persistentCache) {
557
+ groups = persistentCache.read(cacheKey, inputIds, validateGroups);
558
+ cacheHit = Boolean(groups);
559
+ }
560
+ if (!groups) {
561
+ groups = await jsonCall(
420
562
  config,
421
563
  'Return one entry per input ID: [{"ids":["input ID"],"summary":"concise factual changes and uncertainties"}]. Each input ID must appear exactly once. Never combine different IDs. Summaries must be at most 160 characters.',
422
564
  input,
423
- (candidate) => {
424
- validatePartition(candidate, inputIds);
425
- if (candidate.some((group) => group.ids.length !== 1))
426
- throw fail(
427
- ERROR_CATEGORIES.RESPONSE_FORMAT,
428
- 'Fragment analysis combined unrelated source IDs.',
429
- );
430
- },
431
- ));
565
+ validateGroups,
566
+ );
567
+ requestedChunks++;
568
+ persistentCache?.write(cacheKey, inputIds, groups);
569
+ } else {
570
+ cachedChunks++;
571
+ }
432
572
  const byId = new Map(input.map((x) => [x.id, x.fileId]));
433
573
  cache.set(cacheKey, groups);
434
574
  for (const group of groups) {
@@ -436,7 +576,13 @@ export async function analyzeChanges(
436
576
  fileFacts.get(id).push(group.summary);
437
577
  }
438
578
  analyzed += input.filter((x) => !x.metadataOnly).length;
439
- onProgress?.({ completedChunks: ++done, analyzedFragments: analyzed });
579
+ onProgress?.({
580
+ completedChunks: ++done,
581
+ analyzedFragments: analyzed,
582
+ cachedChunks,
583
+ requestedChunks,
584
+ cacheHit,
585
+ });
440
586
  }
441
587
  async function schedule() {
442
588
  pending.push(
@@ -456,10 +602,11 @@ export async function analyzeChanges(
456
602
  fileFacts.get(unit.fileId).push(unit.text);
457
603
  continue;
458
604
  }
459
- const size = JSON.stringify(unit).length;
460
- if (batch.length && (length + size > maxChars || batch.length >= 16)) await schedule();
605
+ const size = estimateTokens(JSON.stringify(unit));
606
+ if (batch.length && (batchTokens + size > maxBatchTokens || batch.length >= 16))
607
+ await schedule();
461
608
  batch.push(unit);
462
- length += size;
609
+ batchTokens += size;
463
610
  }
464
611
  await schedule();
465
612
  for (const err of await Promise.all(pending)) if (err) throw err;
@@ -510,15 +657,22 @@ export async function analyzeChanges(
510
657
  metadataOnlyFiles: capture.manifest.filter((f) => f.metadataOnly).length,
511
658
  failedFiles: 0,
512
659
  completedChunks: done,
660
+ cachedChunks,
661
+ requestedChunks,
513
662
  },
514
663
  };
515
664
  } catch (err) {
665
+ const exhausted = err.data?.analysis?.exhausted || (!budget.remainingMs() ? 'time' : undefined);
516
666
  err.data = {
517
667
  ...err.data,
518
668
  analysis: {
519
669
  ...budget.snapshot(),
670
+ ...err.data?.analysis,
671
+ ...(exhausted ? { exhausted } : {}),
520
672
  totalFiles: capture.manifest.length,
521
673
  completedChunks: done,
674
+ cachedChunks,
675
+ requestedChunks,
522
676
  complete: false,
523
677
  },
524
678
  };
@@ -559,8 +713,9 @@ export async function summarizeChanges(config, facts) {
559
713
  return `Structured change summaries (not raw diff):\n${JSON.stringify(items)}`;
560
714
  }
561
715
 
562
- export async function planAnalyzedChanges(config, facts, coverage = null) {
716
+ export async function planAnalyzedChanges(config, facts, coverage = null, stream = null) {
563
717
  let candidates = facts;
718
+ const deep = isDeepAnalysis(config) && coverage?.strategy !== 'auto';
564
719
  const policy = normalizeCommitPolicy(config.commitPolicy, config.language);
565
720
  const cap = Math.min(
566
721
  config.splitMaxDiffChars || 16000,
@@ -568,15 +723,16 @@ export async function planAnalyzedChanges(config, facts, coverage = null) {
568
723
  );
569
724
  for (let level = 0; level < 8; level++) {
570
725
  const byId = new Map(candidates.map((x) => [x.id, x]));
571
- const batches = !isDeepAnalysis(config)
572
- ? [localPlanItems(config, candidates, cap)]
573
- : packItems(
574
- candidates.map(({ id, path, summary }) => ({ id, path, summary })),
575
- cap,
576
- config.splitMaxPlanFiles || 100,
577
- );
578
- if (!isDeepAnalysis(config) && coverage) {
579
- coverage.sampledFiles = batches[0].filter((item) => item.representativeExcerpt).length;
726
+ const batches =
727
+ !deep && level === 0
728
+ ? localPlanBatches(config, candidates, cap)
729
+ : packItems(
730
+ candidates.map(({ id, path, summary }) => ({ id, path, summary })),
731
+ cap,
732
+ config.splitMaxPlanFiles || 100,
733
+ );
734
+ if (!deep && level === 0 && coverage) {
735
+ coverage.sampledFiles = batches.flat().filter((item) => item.representativeExcerpt).length;
580
736
  coverage.metadataOnlyFiles = coverage.totalFiles - coverage.sampledFiles;
581
737
  }
582
738
  const next = [];
@@ -594,6 +750,9 @@ export async function planAnalyzedChanges(config, facts, coverage = null) {
594
750
  'Analysis plan is missing a commit subject.',
595
751
  );
596
752
  },
753
+ // Earlier batches may run many requests. Surface thinking from the
754
+ // final plan, which is the reasoning relevant to the review screen.
755
+ batches.length === 1 ? stream : null,
597
756
  );
598
757
  for (const group of groups) {
599
758
  next.push({
@@ -605,14 +764,11 @@ export async function planAnalyzedChanges(config, facts, coverage = null) {
605
764
  }
606
765
  if (batches.length === 1)
607
766
  return next.map(({ subject, body, files }) => ({ subject, body, files }));
608
- if (next.length >= candidates.length)
609
- throw fail(
610
- ERROR_CATEGORIES.PROVIDER,
611
- 'Too many independent change groups for a complete global plan. Stage a smaller logical change or increase planning limits.',
612
- );
613
767
  candidates = next;
614
768
  }
615
- throw fail(ERROR_CATEGORIES.PROVIDER, 'Split planning exceeded the maximum summary depth.');
769
+ throw fail(ERROR_CATEGORIES.PROVIDER, 'Split planning exceeded the maximum summary depth.', {
770
+ data: { fallbackPlan: true },
771
+ });
616
772
  }
617
773
 
618
774
  export function needsAnalysis(capture, config, split = false) {
package/src/cli.js CHANGED
@@ -16,13 +16,17 @@ function showHelp() {
16
16
  ${chalk.dim('$')} aicommit [path] [options]
17
17
  ${chalk.dim('$')} aicommit setup
18
18
  ${chalk.dim('$')} aicommit update
19
- ${chalk.dim('$')} aicommit split [run|plan|apply|resume|abort] [options]
19
+ ${chalk.dim('$')} aicommit generate --scope=staged|all --file=<path> [options]
20
+ ${chalk.dim('$')} aicommit apply --file=<path> [options]
21
+ ${chalk.dim('$')} aicommit split [run|plan|apply|resume|abort|status] [options]
20
22
 
21
23
  ${chalk.bold('Everyday commands:')}
22
24
  setup Interactive configuration wizard
23
25
  update Update the global npm installation to latest
24
26
  doctor Diagnose runtime, config, credentials, and connectivity
25
27
  split Plan and create file-level logical commits
28
+ generate Export one validated commit plan without committing
29
+ apply Apply a validated commit plan
26
30
 
27
31
  ${chalk.bold('Advanced commands:')}
28
32
  config show Print the effective configuration with secrets redacted
@@ -35,6 +39,7 @@ function showHelp() {
35
39
  split apply Validate and apply an exported JSON plan
36
40
  split resume Resume the repository's unfinished transaction
37
41
  split abort Discard recovery metadata; keep commits and changes
42
+ split status Inspect recovery metadata without changing Git state
38
43
 
39
44
  ${chalk.bold('Arguments:')}
40
45
  path Target directory (default: current directory)
@@ -45,13 +50,14 @@ function showHelp() {
45
50
  -l, --lang=<zh|en> Commit message language (default: zh)
46
51
  -p, --provider=<name> Use the named provider from config "providers"
47
52
  -m, --model=<name> Use a named model from the selected provider
48
- --scope=<scope> Scope for "split|split plan": staged, all
49
- --file=<path> Split-plan artifact or commit-message file
53
+ --scope=<scope> Scope for dry-run, generate, or split planning: staged, all
54
+ --file=<path> Commit-plan artifact or commit-message file
50
55
  --range=<revision> Git revision/range for "policy check" (default: HEAD)
51
56
  --reasoning=<level> Set reasoning effort (enabled by default: medium)
52
57
  --no-reasoning Explicitly disable reasoning when supported
53
58
  --dry-run Generate and review without creating commits
54
59
  -y, --yes Non-interactive: accept the generated message/plan
60
+ --allow-single-fallback Let non-interactive split commit one complete group if planning exhausts its budget
55
61
  --output=<text|json> Output mode; JSON requires --yes for commit flows
56
62
  --debug Print debug info (parsed args, final config, etc.)
57
63
 
@@ -90,9 +96,13 @@ function parsedDefaults(overrides = {}) {
90
96
  debug: false,
91
97
  split: null,
92
98
  splitCommand: null,
99
+ generate: false,
100
+ generateScope: null,
101
+ previewScope: null,
93
102
  splitPlanFile: null,
94
103
  dryRun: false,
95
104
  yes: false,
105
+ allowSingleFallback: false,
96
106
  setup: false,
97
107
  update: false,
98
108
  doctor: false,
@@ -163,10 +173,17 @@ export function parseArgs(args = process.argv.slice(2)) {
163
173
  args = args.slice(2);
164
174
  }
165
175
 
176
+ const generate = !update && args[0] === 'generate';
177
+ if (generate) args = args.slice(1);
178
+
166
179
  let splitCommand = null;
180
+ if (!update && args[0] === 'apply') {
181
+ splitCommand = 'apply';
182
+ args = args.slice(1);
183
+ }
167
184
  if (!update && args[0] === 'split') {
168
185
  const requestedAction = args[1];
169
- const actions = ['run', 'plan', 'apply', 'resume', 'abort'];
186
+ const actions = ['run', 'plan', 'apply', 'resume', 'abort', 'status'];
170
187
  if (!requestedAction || requestedAction.startsWith('-')) {
171
188
  // Keep the common path short: `aicommit split` is the interactive
172
189
  // split flow, while explicit actions remain available for automation
@@ -179,7 +196,7 @@ export function parseArgs(args = process.argv.slice(2)) {
179
196
  } else {
180
197
  throw fail(
181
198
  ERROR_CATEGORIES.CONFIG,
182
- 'split requires one action: run, plan, apply, resume, or abort.',
199
+ 'split requires one action: run, plan, apply, resume, abort, or status.',
183
200
  );
184
201
  }
185
202
  }
@@ -195,12 +212,15 @@ export function parseArgs(args = process.argv.slice(2)) {
195
212
  let output = 'text';
196
213
  let debug = false;
197
214
  let split = null;
215
+ let generateScope = null;
216
+ let previewScope = null;
198
217
  let splitPlanFile = null;
199
218
  let policyMessageFile = null;
200
219
  let policyRange = null;
201
220
  let splitScopeOption = false;
202
221
  let dryRun = false;
203
222
  let yes = false;
223
+ let allowSingleFallback = false;
204
224
  const setup = false;
205
225
 
206
226
  for (let i = 0; i < args.length; i++) {
@@ -235,20 +255,26 @@ export function parseArgs(args = process.argv.slice(2)) {
235
255
 
236
256
  if (arg === '--scope') {
237
257
  splitScopeOption = true;
238
- split = takeValue(args, i, arg, 'staged|all');
258
+ const scope = takeValue(args, i, arg, 'staged|all');
239
259
  i++;
240
- if (!['staged', 'all'].includes(split)) {
241
- throw fail(ERROR_CATEGORIES.CONFIG, `Invalid split scope: "${split}". Use staged or all.`);
260
+ if (!['staged', 'all'].includes(scope)) {
261
+ throw fail(ERROR_CATEGORIES.CONFIG, `Invalid split scope: "${scope}". Use staged or all.`);
242
262
  }
263
+ if (generate) generateScope = scope;
264
+ else if (splitCommand) split = scope;
265
+ else previewScope = scope;
243
266
  continue;
244
267
  }
245
268
 
246
269
  if (arg.startsWith('--scope=')) {
247
270
  splitScopeOption = true;
248
- split = arg.slice('--scope='.length);
249
- if (!['staged', 'all'].includes(split)) {
250
- throw fail(ERROR_CATEGORIES.CONFIG, `Invalid split scope: "${split}". Use staged or all.`);
271
+ const scope = arg.slice('--scope='.length);
272
+ if (!['staged', 'all'].includes(scope)) {
273
+ throw fail(ERROR_CATEGORIES.CONFIG, `Invalid split scope: "${scope}". Use staged or all.`);
251
274
  }
275
+ if (generate) generateScope = scope;
276
+ else if (splitCommand) split = scope;
277
+ else previewScope = scope;
252
278
  continue;
253
279
  }
254
280
 
@@ -290,6 +316,11 @@ export function parseArgs(args = process.argv.slice(2)) {
290
316
  continue;
291
317
  }
292
318
 
319
+ if (arg === '--allow-single-fallback') {
320
+ allowSingleFallback = true;
321
+ continue;
322
+ }
323
+
293
324
  if (arg === '--reasoning') {
294
325
  cliReasoning = takeValue(args, i, arg, 'low|medium|high|xhigh|max');
295
326
  i++;
@@ -419,14 +450,38 @@ export function parseArgs(args = process.argv.slice(2)) {
419
450
  }
420
451
  }
421
452
  }
422
- if (splitScopeOption && !['run', 'plan'].includes(splitCommand)) {
453
+ if (generate) {
454
+ if (!generateScope || !splitPlanFile) {
455
+ throw fail(
456
+ ERROR_CATEGORIES.CONFIG,
457
+ 'generate requires --scope=staged|all and --file=<path>.',
458
+ );
459
+ }
460
+ if (splitCommand || allowSingleFallback) {
461
+ throw fail(ERROR_CATEGORIES.CONFIG, 'generate does not accept split commit options.');
462
+ }
463
+ dryRun = true;
464
+ }
465
+ if (splitScopeOption && !generate && splitCommand && !['run', 'plan'].includes(splitCommand)) {
423
466
  throw fail(
424
467
  ERROR_CATEGORIES.CONFIG,
425
- '--scope is only valid with "aicommit split run" or "aicommit split plan".',
468
+ '--scope is only valid with dry-run, generate, split run, or split plan.',
426
469
  );
427
470
  }
428
- if (splitPlanFile && !['plan', 'apply'].includes(splitCommand)) {
429
- throw fail(ERROR_CATEGORIES.CONFIG, '--file is only valid with split plan or split apply.');
471
+ if (previewScope && !dryRun) {
472
+ throw fail(ERROR_CATEGORIES.CONFIG, '--scope without a subcommand requires --dry-run.');
473
+ }
474
+ if (splitPlanFile && !generate && !['plan', 'apply'].includes(splitCommand)) {
475
+ throw fail(
476
+ ERROR_CATEGORIES.CONFIG,
477
+ '--file is only valid with generate, apply, or split plan/apply.',
478
+ );
479
+ }
480
+ if (allowSingleFallback && (splitCommand !== 'run' || !yes || dryRun)) {
481
+ throw fail(
482
+ ERROR_CATEGORIES.CONFIG,
483
+ '--allow-single-fallback requires non-dry-run "aicommit split run --yes".',
484
+ );
430
485
  }
431
486
  if (
432
487
  configAction &&
@@ -494,9 +549,13 @@ export function parseArgs(args = process.argv.slice(2)) {
494
549
  debug,
495
550
  split,
496
551
  splitCommand,
552
+ generate,
553
+ generateScope,
554
+ previewScope,
497
555
  splitPlanFile,
498
556
  dryRun,
499
557
  yes,
558
+ allowSingleFallback,
500
559
  setup,
501
560
  update,
502
561
  doctor,