@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.
package/src/completion.js CHANGED
@@ -5,6 +5,8 @@ const TOP_LEVEL = [
5
5
  'config',
6
6
  'policy',
7
7
  'completion',
8
+ 'generate',
9
+ 'apply',
8
10
  'split',
9
11
  '--help',
10
12
  '--version',
@@ -16,6 +18,7 @@ const TOP_LEVEL = [
16
18
  '--no-reasoning',
17
19
  '--dry-run',
18
20
  '--yes',
21
+ '--allow-single-fallback',
19
22
  '--output',
20
23
  '--debug',
21
24
  ];
@@ -32,7 +35,7 @@ _aicommit() {
32
35
  config) COMPREPLY=( $(compgen -W "show validate path --provider --model --output --debug" -- "$cur") ); return ;;
33
36
  policy) COMPREPLY=( $(compgen -W "template check --file --range --output --debug" -- "$cur") ); return ;;
34
37
  completion) COMPREPLY=( $(compgen -W "bash zsh fish" -- "$cur") ); return ;;
35
- split) COMPREPLY=( $(compgen -W "run plan apply resume abort --scope --file --yes --output --debug" -- "$cur") ); return ;;
38
+ split) COMPREPLY=( $(compgen -W "run plan apply resume abort status --scope --file --yes --allow-single-fallback --output --debug" -- "$cur") ); return ;;
36
39
  esac
37
40
  case "$prev" in
38
41
  --lang|-l) COMPREPLY=( $(compgen -W "zh en" -- "$cur") ); return ;;
@@ -57,7 +60,9 @@ _aicommit() {
57
60
  'config:inspect or validate configuration'
58
61
  'policy:print or enforce a repository team policy'
59
62
  'completion:generate shell completion'
60
- 'split:run, plan, apply, resume, or abort split commits'
63
+ 'generate:export one commit plan'
64
+ 'apply:apply a validated commit plan'
65
+ 'split:run, plan, apply, resume, abort, or inspect split commits'
61
66
  )
62
67
  options=(
63
68
  '--help[show help]'
@@ -65,13 +70,14 @@ _aicommit() {
65
70
  '--lang=[commit language]:language:(zh en)'
66
71
  '--provider=[provider name]:provider:'
67
72
  '--model=[model name]:model:'
68
- '--scope=[split scope]:scope:(staged all)'
69
- '--file=[split plan or commit-message file]:path:_files'
73
+ '--scope=[change scope]:scope:(staged all)'
74
+ '--file=[commit plan or commit-message file]:path:_files'
70
75
  '--range=[Git range for policy check]:revision:'
71
76
  '--reasoning=[reasoning effort]:effort:(low medium high xhigh max)'
72
77
  '--no-reasoning[disable reasoning]'
73
78
  '--dry-run[review without committing]'
74
79
  '--yes[accept without prompts]'
80
+ '--allow-single-fallback[allow non-interactive split to commit one conservative group]'
75
81
  '--output=[output mode]:output:(text json)'
76
82
  '--debug[show debug details]'
77
83
  )
@@ -85,7 +91,7 @@ _aicommit() {
85
91
  config) _values 'config action' show validate path ;;
86
92
  policy) _values 'policy action' template check ;;
87
93
  completion) _values 'shell' bash zsh fish ;;
88
- split) _values 'split action' run plan apply resume abort ;;
94
+ split) _values 'split action' run plan apply resume abort status ;;
89
95
  esac
90
96
  ;;
91
97
  esac
@@ -101,23 +107,26 @@ complete -c aicommit -n '__fish_use_subcommand' -a doctor -d 'Diagnose configura
101
107
  complete -c aicommit -n '__fish_use_subcommand' -a config -d 'Inspect or validate configuration'
102
108
  complete -c aicommit -n '__fish_use_subcommand' -a policy -d 'Print or enforce a repository team policy'
103
109
  complete -c aicommit -n '__fish_use_subcommand' -a completion -d 'Generate shell completion'
110
+ complete -c aicommit -n '__fish_use_subcommand' -a generate -d 'Export one commit plan'
111
+ complete -c aicommit -n '__fish_use_subcommand' -a apply -d 'Apply a validated commit plan'
104
112
  complete -c aicommit -n '__fish_use_subcommand' -a split -d 'Run, plan, apply, resume, or abort split commits'
105
113
  complete -c aicommit -n '__fish_seen_subcommand_from config' -a 'show validate path'
106
114
  complete -c aicommit -n '__fish_seen_subcommand_from policy' -a 'template check'
107
115
  complete -c aicommit -n '__fish_seen_subcommand_from completion' -a 'bash zsh fish'
108
- complete -c aicommit -n '__fish_seen_subcommand_from split' -a 'run plan apply resume abort'
116
+ complete -c aicommit -n '__fish_seen_subcommand_from split' -a 'run plan apply resume abort status'
109
117
  complete -c aicommit -s h -l help -d 'Show help'
110
118
  complete -c aicommit -s v -l version -d 'Show version'
111
119
  complete -c aicommit -s l -l lang -x -a 'zh en' -d 'Commit language'
112
120
  complete -c aicommit -s p -l provider -x -d 'Provider name'
113
121
  complete -c aicommit -s m -l model -x -d 'Model name'
114
- complete -c aicommit -l scope -x -a 'staged all' -d 'Split plan scope'
115
- complete -c aicommit -l file -r -d 'Split plan file'
122
+ complete -c aicommit -l scope -x -a 'staged all' -d 'Change scope'
123
+ complete -c aicommit -l file -r -d 'Commit plan file'
116
124
  complete -c aicommit -l range -r -d 'Git range for policy check'
117
125
  complete -c aicommit -l reasoning -x -a 'low medium high xhigh max' -d 'Reasoning effort'
118
126
  complete -c aicommit -l no-reasoning -d 'Disable reasoning'
119
127
  complete -c aicommit -l dry-run -d 'Review without committing'
120
128
  complete -c aicommit -s y -l yes -d 'Accept without prompts'
129
+ complete -c aicommit -l allow-single-fallback -d 'Allow non-interactive split to commit one conservative group'
121
130
  complete -c aicommit -l output -x -a 'text json' -d 'Output mode'
122
131
  complete -c aicommit -l debug -d 'Show debug details'
123
132
  `;
package/src/config.js CHANGED
@@ -420,7 +420,11 @@ function assertUrl(config, key) {
420
420
  }
421
421
  }
422
422
 
423
- function assertNumber(config, key, { integer = false, min = -Infinity, max = Infinity } = {}) {
423
+ function assertNumber(
424
+ config,
425
+ key,
426
+ { integer = false, min = -Infinity, max = Infinity, label = key } = {},
427
+ ) {
424
428
  const value = config[key];
425
429
  const ok =
426
430
  typeof value === 'number' &&
@@ -431,7 +435,7 @@ function assertNumber(config, key, { integer = false, min = -Infinity, max = Inf
431
435
  if (!ok) {
432
436
  const kind = integer ? 'integer' : 'number';
433
437
  const range = Number.isFinite(max) ? ` between ${min} and ${max}` : ` >= ${min}`;
434
- throw new Error(`Invalid config "${key}": expected a ${kind}${range}.`);
438
+ throw new Error(`Invalid config "${label}": expected a ${kind}${range}.`);
435
439
  }
436
440
  }
437
441
 
@@ -449,6 +453,30 @@ export function validateConfig(config) {
449
453
  assertNumber(settings, 'concurrency', { integer: true, min: 1, max: 4 });
450
454
  if (settings.maxTotalTokens < settings.chunkInputTokens)
451
455
  throw new Error('largeChange.maxTotalTokens must cover one input request.');
456
+ if (!object(settings.cache))
457
+ throw new Error('Invalid config "largeChange.cache": expected an object.');
458
+ assertKnownKeys(
459
+ settings.cache,
460
+ new Set(Object.keys(DEFAULT_LARGE_CHANGE.cache)),
461
+ 'largeChange.cache',
462
+ );
463
+ const cache = { ...DEFAULT_LARGE_CHANGE.cache, ...settings.cache };
464
+ for (const key of ['enabled', 'allowUnprotected']) {
465
+ if (typeof cache[key] !== 'boolean')
466
+ throw new Error(`Invalid config "largeChange.cache.${key}": expected a boolean.`);
467
+ }
468
+ assertNumber(cache, 'ttlMs', {
469
+ integer: true,
470
+ min: 60_000,
471
+ max: 7 * 24 * 60 * 60 * 1000,
472
+ label: 'largeChange.cache.ttlMs',
473
+ });
474
+ assertNumber(cache, 'maxBytes', {
475
+ integer: true,
476
+ min: 1024 * 1024,
477
+ max: 256 * 1024 * 1024,
478
+ label: 'largeChange.cache.maxBytes',
479
+ });
452
480
  }
453
481
  assertUrl(config, 'apiUrl');
454
482
  assertString(config, 'modelId');
package/src/errors.js CHANGED
@@ -38,8 +38,13 @@ export class AicommitError extends Error {
38
38
  super(message);
39
39
  this.name = 'AicommitError';
40
40
  this.category = category;
41
+ this.code = options.code || category;
41
42
  this.exitCode = EXIT_CODES[category] ?? EXIT_CODES.internal;
42
43
  this.reported = Boolean(options.reported);
44
+ this.retryable = Boolean(options.retryable);
45
+ this.nextAction = options.nextAction || null;
46
+ this.committed = Boolean(options.committed);
47
+ this.commitState = options.commitState || 'none';
43
48
  if (options.data && typeof options.data === 'object' && !Array.isArray(options.data)) {
44
49
  this.data = options.data;
45
50
  }
@@ -62,11 +67,10 @@ export function classifyError(err) {
62
67
  const lower = message.toLowerCase();
63
68
  const code = errorCode(err);
64
69
  if (
65
- err instanceof TypeError ||
66
70
  NETWORK_CODES.has(code) ||
67
71
  /timed out|fetch failed|socket|network|dns|econn|enotfound/.test(lower)
68
72
  ) {
69
- return fail(ERROR_CATEGORIES.NETWORK, message, { cause: err });
73
+ return fail(ERROR_CATEGORIES.NETWORK, message, { cause: err, retryable: true });
70
74
  }
71
75
  if (/^http \d{3}:|streaming api error|rate limit|provider request/.test(lower)) {
72
76
  return fail(ERROR_CATEGORIES.PROVIDER, message, { cause: err });
@@ -18,7 +18,7 @@ export async function runModelTask({
18
18
  const spinner = ora({ text: chalk.dim(spinnerText), color: 'cyan' }).start();
19
19
  let liveReasoning;
20
20
  const stream =
21
- reasoning?.mode === 'on' && !machineOutput
21
+ reasoning && reasoning.mode !== 'off' && !machineOutput
22
22
  ? {
23
23
  onReasoningDelta(chunk) {
24
24
  if (!liveReasoning) {
package/src/git.js CHANGED
@@ -70,13 +70,24 @@ export function getStagedDiff(cwd, contextLines) {
70
70
  return readGit(['diff', unifiedArg(contextLines), '--staged'], cwd).trim();
71
71
  }
72
72
 
73
- // Hash the complete staged patch (including binary changes and full object
74
- // ids) so a long-running AI/review step cannot silently commit a different
75
- // index from the one used to generate the message.
73
+ // Git's raw staged diff contains the full old/new blob IDs, modes, status,
74
+ // and NUL-delimited paths for every changed entry. Hashing that metadata is
75
+ // equivalent to hashing the staged patch for snapshot identity, including
76
+ // binary changes, without regenerating and spooling the entire patch during
77
+ // every pre/post-model safety check.
76
78
  export function getIndexFingerprint(cwd) {
77
79
  return updateGitHash(
78
80
  createHash('sha256'),
79
- ['diff', '--staged', '--binary', '--full-index', '--no-ext-diff'],
81
+ [
82
+ 'diff',
83
+ '--raw',
84
+ '--staged',
85
+ '--no-renames',
86
+ '--no-ext-diff',
87
+ '--no-textconv',
88
+ '--abbrev=64',
89
+ '-z',
90
+ ],
80
91
  cwd,
81
92
  ).digest('hex');
82
93
  }
@@ -263,7 +274,8 @@ export function condenseDiff(diff, maxChars, stat, maxSectionChars = Infinity) {
263
274
  }
264
275
  const marker = `... (diff truncated — ${diff.length} chars total)`;
265
276
  const body = kept ? `${kept}\n${marker}` : marker;
266
- return { diff: stat ? `${stat}\n\n${body}` : body, truncated: true };
277
+ const resolvedStat = typeof stat === 'function' ? stat() : stat;
278
+ return { diff: resolvedStat ? `${resolvedStat}\n\n${body}` : body, truncated: true };
267
279
  }
268
280
 
269
281
  export function getChangedFiles(cwd) {
@@ -245,27 +245,57 @@ export function analyzeLocally(config, capture, protect, previews) {
245
245
  metadataOnlyFiles: capture.manifest.length - overview.sampledFiles,
246
246
  failedFiles: 0,
247
247
  completedChunks: 0,
248
+ cachedChunks: 0,
249
+ requestedChunks: 0,
248
250
  },
249
251
  };
250
252
  }
251
253
 
252
- export function localPlanItems(config, facts, cap) {
254
+ export function localPlanBatches(config, facts, cap) {
253
255
  const error = () =>
254
- fail(
256
+ fail(ERROR_CATEGORIES.CONFIG, 'A split candidate is too large for one planning request.', {
257
+ data: { fallbackPlan: true },
258
+ });
259
+ const maxItems = config.splitMaxPlanFiles || 100;
260
+ const batches = [];
261
+ let batch = [];
262
+ for (const fact of facts) {
263
+ const item = {
264
+ ...summaryOf(fact, false),
265
+ summary: 'Local candidate only; verify grouping. Content not fully analyzed.',
266
+ };
267
+ if (Buffer.byteLength(JSON.stringify([item])) > cap) throw error();
268
+ const candidate = [...batch, item];
269
+ if (
270
+ batch.length &&
271
+ (candidate.length > maxItems || Buffer.byteLength(JSON.stringify(candidate)) > cap)
272
+ ) {
273
+ batches.push(batch);
274
+ batch = [];
275
+ }
276
+ batch.push(item);
277
+ }
278
+ if (batch.length) batches.push(batch);
279
+
280
+ const factsById = new Map(facts.map((fact) => [fact.id, fact]));
281
+ for (const items of batches) {
282
+ for (const item of items) {
283
+ const evidence = factsById.get(item.id)?.evidence;
284
+ if (!evidence) continue;
285
+ item.representativeExcerpt = evidence;
286
+ if (Buffer.byteLength(JSON.stringify(items)) > cap) delete item.representativeExcerpt;
287
+ }
288
+ }
289
+ return batches;
290
+ }
291
+
292
+ export function localPlanItems(config, facts, cap) {
293
+ const batches = localPlanBatches(config, facts, cap);
294
+ if (batches.length !== 1)
295
+ throw fail(
255
296
  ERROR_CATEGORIES.CONFIG,
256
- 'The complete split candidate inventory does not fit one request. Stage a smaller logical change or explicitly set personal largeChange.strategy to deep. No planning request was sent.',
297
+ 'The complete split candidate inventory requires multiple planning requests.',
298
+ { data: { fallbackPlan: true } },
257
299
  );
258
- if (facts.length > (config.splitMaxPlanFiles || 100)) throw error();
259
- const items = facts.map((fact) => ({
260
- ...summaryOf(fact, false),
261
- summary: 'Local candidate only; verify grouping. Content not fully analyzed.',
262
- }));
263
- if (Buffer.byteLength(JSON.stringify(items)) > cap) throw error();
264
- for (const fact of ranked(facts)) {
265
- if (!fact.evidence) continue;
266
- const item = items.find((item) => item.id === fact.id);
267
- item.representativeExcerpt = fact.evidence;
268
- if (Buffer.byteLength(JSON.stringify(items)) > cap) delete item.representativeExcerpt;
269
- }
270
- return items;
300
+ return batches[0];
271
301
  }
package/src/main.js CHANGED
@@ -17,6 +17,8 @@ import {
17
17
  runGit,
18
18
  isGitRepo,
19
19
  getIndexFingerprint,
20
+ hasHead,
21
+ readGit,
20
22
  createIndexTransaction,
21
23
  protectSensitiveDiff,
22
24
  unifiedArg,
@@ -29,6 +31,7 @@ import {
29
31
  summarizeChanges,
30
32
  } from './change-analysis.js';
31
33
  import { cleanupGitSpools } from './git-spool.js';
34
+ import { createAnalysisCache } from './analysis-cache.js';
32
35
  import { generateCommitMessage } from './api.js';
33
36
  import {
34
37
  statusColor,
@@ -53,8 +56,13 @@ import {
53
56
  applySplitPlan,
54
57
  resumeSplit,
55
58
  splitFlow,
59
+ splitStatus,
56
60
  getStagedChangedFiles,
61
+ getAllChangedFiles,
62
+ getSplitStateFingerprint,
63
+ safeExportPlanPath,
57
64
  } from './split.js';
65
+ import { createSplitPlanArtifact, writeSplitPlanArtifact } from './split-plan.js';
58
66
  import { runModelTask } from './generation-ui.js';
59
67
  import { runSetup } from './setup.js';
60
68
  import { detectProviderType } from './providers.js';
@@ -107,9 +115,13 @@ async function runMain() {
107
115
  debug,
108
116
  split,
109
117
  splitCommand,
118
+ generate,
119
+ generateScope,
120
+ previewScope,
110
121
  splitPlanFile,
111
122
  dryRun,
112
123
  yes,
124
+ allowSingleFallback,
113
125
  setup,
114
126
  update,
115
127
  doctor,
@@ -128,7 +140,15 @@ async function runMain() {
128
140
  return { exitReason: 'completion' };
129
141
  }
130
142
  const machineOutput = output === 'json';
131
- if (machineOutput && !yes && !doctor && !configAction && !policyAction && !update) {
143
+ if (
144
+ machineOutput &&
145
+ !yes &&
146
+ !doctor &&
147
+ !configAction &&
148
+ !policyAction &&
149
+ !update &&
150
+ splitCommand !== 'status'
151
+ ) {
132
152
  throw fail(ERROR_CATEGORIES.CONFIG, '--output=json requires --yes for commit and split flows.');
133
153
  }
134
154
 
@@ -176,13 +196,14 @@ async function runMain() {
176
196
  console.log(' ' + chalk.dim('─'.repeat(45)));
177
197
  console.log(' ' + chalk.dim(`Working directory: ${sanitizeTerminalText(process.cwd())}`));
178
198
 
179
- if (['apply', 'resume', 'abort'].includes(splitCommand)) {
199
+ if (['apply', 'resume', 'abort', 'status'].includes(splitCommand)) {
180
200
  const projectRoot = getProjectRoot();
181
201
  if (!isGitRepo(projectRoot)) {
182
202
  throw fail(ERROR_CATEGORIES.GIT_STATE, `Not a git repository: ${process.cwd()}`);
183
203
  }
184
204
  if (splitCommand === 'resume') return resumeSplit(projectRoot, { yes, machineOutput });
185
205
  if (splitCommand === 'abort') return abortSplit(projectRoot, { yes, machineOutput });
206
+ if (splitCommand === 'status') return splitStatus(projectRoot);
186
207
  return applySplitPlan(projectRoot, splitPlanFile, { yes, machineOutput });
187
208
  }
188
209
 
@@ -291,6 +312,7 @@ async function runMain() {
291
312
  console.log(chalk.dim(` split: ${split}`));
292
313
  console.log(chalk.dim(` dryRun: ${dryRun}`));
293
314
  console.log(chalk.dim(` yes: ${yes}`));
315
+ console.log(chalk.dim(` singleFallback: ${allowSingleFallback}`));
294
316
  console.log(chalk.dim(' final config:'));
295
317
  for (const [key, value] of Object.entries(config)) {
296
318
  const display =
@@ -321,6 +343,7 @@ async function runMain() {
321
343
  scope: split,
322
344
  dryRun,
323
345
  yes,
346
+ allowSingleFallback,
324
347
  machineOutput,
325
348
  provider: selectedProvider,
326
349
  exportPlanPath: splitCommand === 'plan' ? splitPlanFile : null,
@@ -336,6 +359,35 @@ async function runMain() {
336
359
  // All git commands run at the repo root (projectRoot), even when aicommit
337
360
  // is invoked from a subdirectory.
338
361
  let indexTransaction = null;
362
+ const effectiveScope = generate ? generateScope : previewScope;
363
+ let stagedAllForPreview = false;
364
+ const generateSnapshot = generate
365
+ ? (() => {
366
+ const changes =
367
+ generateScope === 'staged'
368
+ ? getStagedChangedFiles(projectRoot)
369
+ : getAllChangedFiles(projectRoot);
370
+ if (!changes.length) {
371
+ throw fail(
372
+ ERROR_CATEGORIES.GIT_STATE,
373
+ `No ${generateScope} changes to generate a plan for.`,
374
+ );
375
+ }
376
+ return {
377
+ changes,
378
+ baseHead: hasHead(projectRoot)
379
+ ? readGit(['rev-parse', 'HEAD'], projectRoot).trim()
380
+ : null,
381
+ fingerprint: getSplitStateFingerprint(
382
+ projectRoot,
383
+ hasHead(projectRoot),
384
+ changes,
385
+ generateScope,
386
+ ),
387
+ file: safeExportPlanPath(projectRoot, splitPlanFile),
388
+ };
389
+ })()
390
+ : null;
339
391
  const finishCancelled = ({
340
392
  notice = 'Commit cancelled.',
341
393
  message = null,
@@ -370,6 +422,20 @@ async function runMain() {
370
422
  indexTransaction ||= createIndexTransaction(projectRoot);
371
423
  return indexTransaction;
372
424
  };
425
+ if (effectiveScope === 'all') {
426
+ try {
427
+ beginIndexTransaction();
428
+ runGit(['add', '-A'], projectRoot);
429
+ indexTransaction.markOwned();
430
+ stagedAllForPreview = true;
431
+ } catch (err) {
432
+ indexTransaction?.restore({ force: true });
433
+ indexTransaction = null;
434
+ throw fail(ERROR_CATEGORIES.GIT_STATE, `Failed to stage plan snapshot: ${err.message}`, {
435
+ cause: err,
436
+ });
437
+ }
438
+ }
373
439
  if (!getChangedFiles(projectRoot).length) {
374
440
  // Nothing staged. But git diff --staged is also empty for unstaged work
375
441
  // and untracked files — surface what git status actually shows instead of
@@ -386,6 +452,10 @@ async function runMain() {
386
452
  throw fail(ERROR_CATEGORIES.GIT_STATE, 'No changes to commit.', { reported: true });
387
453
  }
388
454
 
455
+ if (effectiveScope === 'staged' || generate) {
456
+ throw fail(ERROR_CATEGORIES.GIT_STATE, `No ${effectiveScope} changes to preview.`);
457
+ }
458
+
389
459
  console.log('\n ' + chalk.yellow(`✗ No staged changes — ${tips.join(', ')}.`));
390
460
  printChangeList(withUntrackedFiles(unstaged, untracked));
391
461
  console.log('');
@@ -527,6 +597,13 @@ async function runMain() {
527
597
  let diffForModel = diff;
528
598
  let protectAnalysis = true;
529
599
  if (protectedInput.findings.length) {
600
+ if (generate && generateScope === 'all' && yes) {
601
+ throw fail(
602
+ ERROR_CATEGORIES.SENSITIVE_DATA,
603
+ 'Non-interactive generate --scope=all refuses sensitive content; review and stage intended files explicitly.',
604
+ { code: 'sensitive_all_scope' },
605
+ );
606
+ }
530
607
  warnings.push('Sensitive data was detected and protected before the provider request.');
531
608
  console.log('\n ' + chalk.yellow.bold('⚠ Potential sensitive data detected:'));
532
609
  for (const finding of protectedInput.findings) {
@@ -567,14 +644,30 @@ async function runMain() {
567
644
  : condenseDiff(
568
645
  strippedDiff,
569
646
  config.maxDiffChars,
570
- getDiffStat(projectRoot),
647
+ () => getDiffStat(projectRoot),
571
648
  config.maxFileDiffChars,
572
649
  );
573
650
  let analysis;
574
651
  let analyzedFacts;
652
+ let persistentAnalysisCache = null;
575
653
  let generationConfig = config;
576
654
  if (large) {
577
655
  generationConfig = analysisConfig(config);
656
+ persistentAnalysisCache = createAnalysisCache({
657
+ projectRoot,
658
+ snapshotFingerprint: plannedIndexFingerprint,
659
+ config: generationConfig,
660
+ protect: protectAnalysis,
661
+ });
662
+ if (
663
+ generationConfig.largeChange?.strategy === 'deep' &&
664
+ generationConfig.largeChange.cache?.enabled &&
665
+ !protectAnalysis &&
666
+ !generationConfig.largeChange.cache.allowUnprotected
667
+ ) {
668
+ warnings.push('Deep-analysis recovery cache was disabled for unprotected input.');
669
+ console.log(chalk.dim(' Recovery cache: disabled for unprotected input.'));
670
+ }
578
671
  console.log(
579
672
  chalk.dim(
580
673
  generationConfig.largeChange?.strategy === 'deep'
@@ -622,8 +715,11 @@ async function runMain() {
622
715
  captured,
623
716
  protectAnalysis,
624
717
  null,
625
- ({ completedChunks }) =>
626
- console.error(` Analysis: ${completedChunks} chunks completed`),
718
+ ({ completedChunks, cachedChunks }) =>
719
+ console.error(
720
+ ` Analysis: ${completedChunks} chunks completed${cachedChunks ? ` (${cachedChunks} cached)` : ''}`,
721
+ ),
722
+ persistentAnalysisCache,
627
723
  );
628
724
  modelDiff =
629
725
  analyzedFacts.summary ||
@@ -691,6 +787,8 @@ async function runMain() {
691
787
  reported: true,
692
788
  });
693
789
  }
790
+ persistentAnalysisCache?.clear();
791
+ persistentAnalysisCache = null;
694
792
 
695
793
  // ── 4. User action ─────────────────────────────────────────────────
696
794
 
@@ -698,7 +796,7 @@ async function runMain() {
698
796
  ? 'use'
699
797
  : await confirmAction(
700
798
  message,
701
- reasoningEnabled
799
+ reasoningEnabled || reasoningText
702
800
  ? {
703
801
  text: reasoningText,
704
802
  maxChars: config.reasoning.maxDisplayChars,
@@ -748,14 +846,51 @@ async function runMain() {
748
846
  console.log('');
749
847
 
750
848
  if (dryRun) {
849
+ stagedAllForPreview ||= Boolean(indexTransaction);
751
850
  const restored = indexTransaction ? indexTransaction.restore() : true;
752
851
  indexTransaction = null;
753
852
  if (!restored) {
853
+ if (generate) {
854
+ throw fail(
855
+ ERROR_CATEGORIES.CONCURRENT_MODIFICATION,
856
+ 'The Git index changed while generating a commit plan; no plan was written.',
857
+ );
858
+ }
754
859
  warnings.push('The Git index changed during the run and was left untouched.');
755
860
  console.log(
756
861
  ' ' + chalk.yellow('⚠ The Git index changed during the run and was left untouched.'),
757
862
  );
758
863
  }
864
+ let plan = null;
865
+ let planFile = null;
866
+ if (generate) {
867
+ const fingerprint = getSplitStateFingerprint(
868
+ projectRoot,
869
+ hasHead(projectRoot),
870
+ undefined,
871
+ generateScope,
872
+ );
873
+ const baseHead = hasHead(projectRoot)
874
+ ? readGit(['rev-parse', 'HEAD'], projectRoot).trim()
875
+ : null;
876
+ if (fingerprint !== generateSnapshot.fingerprint || baseHead !== generateSnapshot.baseHead) {
877
+ throw fail(
878
+ ERROR_CATEGORIES.CONCURRENT_MODIFICATION,
879
+ 'Repository changes changed while generating a commit plan; no plan was written.',
880
+ );
881
+ }
882
+ plan = [{ message, files: generateSnapshot.changes.map((change) => change.path) }];
883
+ const artifact = createSplitPlanArtifact({
884
+ scope: generateScope,
885
+ baseHead,
886
+ fingerprint,
887
+ language: config.language,
888
+ commitPolicy: config.commitPolicy,
889
+ changes: generateSnapshot.changes,
890
+ groups: plan,
891
+ });
892
+ planFile = await writeSplitPlanArtifact(generateSnapshot.file, artifact);
893
+ }
759
894
  console.log(
760
895
  ' ' +
761
896
  chalk.green.bold(
@@ -764,6 +899,10 @@ async function runMain() {
764
899
  );
765
900
  return {
766
901
  message,
902
+ plan,
903
+ planFile,
904
+ scope: effectiveScope || (stagedAllForPreview || indexTransaction ? 'all' : 'staged'),
905
+ changeCount: generate ? generateSnapshot.changes.length : changedFiles.length,
767
906
  provider: selectedProvider,
768
907
  model: config.modelId,
769
908
  latencyMs: elapsed,
@@ -810,6 +949,9 @@ async function runMain() {
810
949
  console.log('\n ' + chalk.green.bold('✓ Done!\n'));
811
950
  return {
812
951
  message,
952
+ commitSha: readGit(['rev-parse', 'HEAD'], projectRoot).trim(),
953
+ scope: 'staged',
954
+ changeCount: changedFiles.length,
813
955
  provider: selectedProvider,
814
956
  model: config.modelId,
815
957
  latencyMs: elapsed,
package/src/output.js CHANGED
@@ -1,6 +1,10 @@
1
1
  import { classifyError, EXIT_CODES } from './errors.js';
2
2
 
3
- export const OUTPUT_SCHEMA_VERSION = '1.0';
3
+ export const OUTPUT_SCHEMA_VERSION = '1.1';
4
+
5
+ function safeString(value) {
6
+ return typeof value === 'string' && value ? value : null;
7
+ }
4
8
 
5
9
  function normalizedUsage(usage) {
6
10
  if (!usage) return null;
@@ -43,6 +47,11 @@ export function successOutput(result = {}) {
43
47
  warnings: Array.isArray(result.warnings) ? result.warnings.map(String) : [],
44
48
  exitReason: result.exitReason || 'success',
45
49
  committed: Boolean(result.committed),
50
+ commitState: result.commitState || (result.committed ? 'complete' : 'none'),
51
+ commitSha: safeString(result.commitSha),
52
+ planFile: safeString(result.planFile),
53
+ scope: safeString(result.scope),
54
+ changeCount: Number.isInteger(result.changeCount) ? result.changeCount : null,
46
55
  error: null,
47
56
  };
48
57
  if (result.data && typeof result.data === 'object' && !Array.isArray(result.data)) {
@@ -64,10 +73,18 @@ export function errorOutput(err) {
64
73
  usage: null,
65
74
  warnings: [],
66
75
  exitReason: classified.category,
67
- committed: false,
76
+ committed: classified.committed,
77
+ commitState: classified.commitState,
78
+ commitSha: null,
79
+ planFile: null,
80
+ scope: null,
81
+ changeCount: null,
68
82
  error: {
69
83
  category: classified.category,
84
+ code: classified.code,
70
85
  message: classified.message,
86
+ retryable: classified.retryable,
87
+ nextAction: classified.nextAction,
71
88
  },
72
89
  };
73
90
  if (classified.data && typeof classified.data === 'object' && !Array.isArray(classified.data)) {