@hifullmoon/aicommit 2.6.4 → 2.6.6

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/CHANGELOG.md CHANGED
@@ -4,6 +4,18 @@ This file lists notable user-facing changes. Internal refactors, test-only chang
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.6.6] - 2026-09-21
8
+
9
+ ### Fixed
10
+
11
+ - Large split plans now preserve complete file groupings while correcting only invalid commit messages, with bounded retries and character counts. Interactive runs can edit messages after automatic correction fails and continue to plan review; non-interactive failures preserve a private diagnostic draft for protected input.
12
+
13
+ ## [2.6.5] - 2026-09-21
14
+
15
+ ### Fixed
16
+
17
+ - Large split plans now validate commit messages during planning and automatically retry with specific policy errors, including missing or empty subjects. Planning failures are labeled accurately instead of being reported as API failures.
18
+
7
19
  ## [2.6.4] - 2026-09-19
8
20
 
9
21
  ### Fixed
@@ -261,7 +273,9 @@ This file lists notable user-facing changes. Internal refactors, test-only chang
261
273
  - Added file-level split planning and execution with Git-state concurrency checks.
262
274
  - Added provider presets and user/project configuration boundaries.
263
275
 
264
- [Unreleased]: https://github.com/hi-fullmoon/AICommit/compare/v2.6.4...HEAD
276
+ [Unreleased]: https://github.com/hi-fullmoon/AICommit/compare/v2.6.6...HEAD
277
+ [2.6.6]: https://github.com/hi-fullmoon/AICommit/releases/tag/v2.6.6
278
+ [2.6.5]: https://github.com/hi-fullmoon/AICommit/releases/tag/v2.6.5
265
279
  [2.6.4]: https://github.com/hi-fullmoon/AICommit/releases/tag/v2.6.4
266
280
  [2.6.3]: https://github.com/hi-fullmoon/AICommit/releases/tag/v2.6.3
267
281
  [2.6.2]: https://github.com/hi-fullmoon/AICommit/releases/tag/v2.6.2
package/README.md CHANGED
@@ -516,4 +516,6 @@ For exhaustive chunk-by-chunk model analysis, opt in through personal configurat
516
516
 
517
517
  `deep` spends more requests and tokens, with a maximum of 256 requests. Before dispatch, a preflight estimate covers initial chunks, required reductions, and the minimum hierarchical planning tree; an impossible deep run switches to the local inventory path, and validated cache hits are excluded from that estimate. Validated initial fact chunks are stored briefly under Git metadata, reused when the same snapshot is retried after failure or interruption, and removed after complete generation succeeds. The cache does not directly store captured diffs, reasoning, credentials, or complete provider responses; it stores model summaries that may contain code-derived details. Unprotected original input is not persisted unless personal configuration explicitly enables `allowUnprotected`. Repository configuration cannot change this personal strategy, enable unprotected caching, or raise spending/cache ceilings. Both strategies use conservative token estimates; cache hits consume no request or token budget. Incomplete model output is never committed: budget/capacity fallback is a new complete plan containing every reviewed file; interactive runs show it for review, while non-interactive committing requires `--allow-single-fallback`.
518
518
 
519
+ Large-change split planning completes file grouping before repairing commit messages. If a message violates the policy (for example, its description exceeds 72 characters), up to three message-only correction rounds preserve file assignments and valid messages without resending diffs. These rounds share the existing request, time, and token budgets; JSON recovery may require an additional request within a round. If automatic repair fails, interactive runs offer an editor for the invalid messages and then return to normal plan review. Non-interactive runs stop without committing or collapsing the groups into a single commit. For protected input, a private temporary diagnostic JSON file preserves the complete grouping and messages; it is not an artifact accepted by `split apply`.
520
+
519
521
  Complete patches and larger untracked text are captured in local temporary files, with descriptors opened only during reads and writes. Files are cleaned on normal exit or cancellation; crashes may leave them behind. Content reads are bounded. Lines exceeding 1 MiB and experimental hunk planning for large changes fail explicitly; file-level planning capacity uses the complete conservative fallback.
package/README.zh-CN.md CHANGED
@@ -518,4 +518,6 @@ exec zsh
518
518
 
519
519
  `deep` 会增加请求和 token 消耗,最多 256 次请求。请求前会预估初始分块、必要归并和最小分层规划树的成本;确定无法装入总预算时直接切换到本地清单路径,已验证的缓存命中不计入这次预估。通过完整校验的初始事实分块会短期写入 Git 元数据目录;同一快照失败或中断后重试时直接复用,完整生成成功后清理。缓存不直接保存捕获的 diff、推理、凭据或完整 Provider 响应,只保存可能包含代码派生细节的模型摘要;选择发送未保护的原始内容时默认不落盘,只有个人配置显式设置 `allowUnprotected: true` 才允许。仓库配置不能切换策略、启用未保护缓存或提高费用与缓存上限。两种策略均使用保守 token 估算,未知 usage 按预留额度计入;缓存命中不计请求或 token。模型的不完整输出永远不会被提交:预算或容量降级会重新生成一个包含全部已审核文件的完整计划;交互模式先展示确认,非交互提交则要求 `--allow-single-fallback`。
520
520
 
521
+ 大变更拆分先完成全部文件分组,再修正不合规的提交信息。自动修正最多三轮,只发送出错的消息、实际长度和校验原因,不重发 diff,也不改动文件归属或已合规的消息。修正与原规划共用请求、时间和 token 预算;每轮 JSON 恢复可能额外请求一次。自动修正失败后,交互模式允许编辑出错的提交信息,校验通过后返回正常计划确认流程。非交互模式停止提交,不会将已有分组合并成单次提交;对于已保护的输入,会将完整分组和消息保存到私有临时诊断文件,该文件不能直接用于 `split apply`。
522
+
521
523
  完整补丁及较大未跟踪文本暂存到本地临时文件,只在读写时打开文件句柄,正常结束或取消时清理;异常崩溃可能遗留临时文件。正文读取有界。单行超过 1 MiB 和大变更的实验性 hunk 规划会明确报错;文件级规划容量不足时使用覆盖完整变更的保守回退。
@@ -33,7 +33,7 @@ The release workflow uses npm Trusted Publishing without a long-lived `NPM_TOKEN
33
33
  ```bash
34
34
  workdir=$(mktemp -d)
35
35
  cd "$workdir"
36
- npm install --package-lock-only @hifullmoon/aicommit@2.6.4
36
+ npm install --package-lock-only @hifullmoon/aicommit@2.6.6
37
37
  npm audit signatures
38
38
  ```
39
39
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hifullmoon/aicommit",
3
- "version": "2.6.4",
3
+ "version": "2.6.6",
4
4
  "description": "Safe, local-first AI commit message generator for Git workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/api.js CHANGED
@@ -207,6 +207,7 @@ export async function getResponseText(
207
207
  : reasoning || '';
208
208
 
209
209
  const partial = text.trim();
210
+ const followUp = typeof followUpPrompt === 'function' ? followUpPrompt() : followUpPrompt;
210
211
  const recoveryPrompt =
211
212
  truncatedByLimit || invalidResponse
212
213
  ? `The previous response was ${
@@ -214,8 +215,8 @@ export async function getResponseText(
214
215
  }. ` +
215
216
  'Reproduce the COMPLETE answer from the beginning; do not continue from the cut-off point. ' +
216
217
  'Keep the answer concise.\n\n' +
217
- followUpPrompt
218
- : followUpPrompt;
218
+ followUp
219
+ : followUp;
219
220
 
220
221
  // With reasoning, its conclusion plus the partial answer is enough to
221
222
  // reconstruct the output without paying to send the original diff again.
@@ -4,7 +4,7 @@ import { createAnalysisBudget, DEFAULT_LARGE_CHANGE, estimateTokens } from './an
4
4
  import { getResponseText } from './api.js';
5
5
  import { encodeUntrustedData } from './trust.js';
6
6
  import { ERROR_CATEGORIES, fail } from './errors.js';
7
- import { normalizeCommitPolicy } from './policy.js';
7
+ import { normalizeCommitPolicy, parseCommitMessage, validateCommitCandidate } from './policy.js';
8
8
  import { getProviderAdapter } from './providers.js';
9
9
  import { createHash } from 'node:crypto';
10
10
  import {
@@ -315,6 +315,7 @@ function parseAnalysisJson(raw) {
315
315
  }
316
316
 
317
317
  async function jsonCall(config, instruction, items, validate = null, stream = null) {
318
+ let validationError = '';
318
319
  const parseAndValidate = (raw) => {
319
320
  const parsed = parseAnalysisJson(raw);
320
321
  validate?.(parsed);
@@ -328,14 +329,17 @@ async function jsonCall(config, instruction, items, validate = null, stream = nu
328
329
  ],
329
330
  0,
330
331
  Math.min(2048, config.maxTokens || 1024),
331
- 'Return the complete requested JSON array, preserving every required input ID exactly once. ' +
332
- `Required IDs: ${JSON.stringify(items.map((item) => item.id))}`,
332
+ () =>
333
+ 'Return the complete requested JSON array, preserving every required input ID exactly once. ' +
334
+ `Required IDs: ${JSON.stringify(items.map((item) => item.id))}. ` +
335
+ (validationError ? `Correct these validation errors: ${validationError}` : ''),
333
336
  stream,
334
337
  (response) => {
335
338
  try {
336
339
  parseAndValidate(response);
337
340
  return true;
338
- } catch {
341
+ } catch (error) {
342
+ validationError = error.message;
339
343
  return false;
340
344
  }
341
345
  },
@@ -351,6 +355,99 @@ async function jsonCall(config, instruction, items, validate = null, stream = nu
351
355
  }
352
356
  }
353
357
 
358
+ export function validatePlanMessages(groups, policy) {
359
+ const errors = [];
360
+ for (const [index, group] of groups.entries()) {
361
+ const subject = typeof group.subject === 'string' ? group.subject.trim() : '';
362
+ if (!subject) {
363
+ errors.push(`Group ${index + 1}: The subject field must be a non-empty commit header.`);
364
+ continue;
365
+ }
366
+ const body = typeof group.body === 'string' ? group.body.trim() : '';
367
+ const validation = validateCommitCandidate(body ? `${subject}\n\n${body}` : subject, {
368
+ policy,
369
+ });
370
+ if (!validation.valid)
371
+ errors.push(`Group ${index + 1}: ${validation.errors.map((item) => item.message).join(' ')}`);
372
+ }
373
+ if (errors.length)
374
+ throw fail(
375
+ ERROR_CATEGORIES.RESPONSE_FORMAT,
376
+ `Split commit messages violate commitPolicy: ${errors.join(' ')}`,
377
+ );
378
+ }
379
+
380
+ export function invalidPlanMessages(groups, policy) {
381
+ return groups.flatMap((group, index) => {
382
+ try {
383
+ validatePlanMessages([group], policy);
384
+ return [];
385
+ } catch (error) {
386
+ const message = parseCommitMessage(typeof group.subject === 'string' ? group.subject : '');
387
+ return [
388
+ {
389
+ id: String(index),
390
+ subject: group.subject,
391
+ body: group.body,
392
+ subjectLength: [...(message.parsed?.subject || '')].length,
393
+ subjectMaxLength: policy.subject.maxLength,
394
+ errors: error.message,
395
+ },
396
+ ];
397
+ }
398
+ });
399
+ }
400
+
401
+ export function applyMessageCorrections(groups, corrections, policy) {
402
+ const invalid = invalidPlanMessages(groups, policy);
403
+ const remaining = new Set(invalid.map((item) => item.id));
404
+ if (!Array.isArray(corrections)) throw new Error('Expected a message correction array.');
405
+ const next = groups.map((group) => ({ ...group }));
406
+ for (const correction of corrections) {
407
+ if (!correction || !remaining.delete(correction.id))
408
+ throw new Error('Corrections must contain each invalid group ID exactly once.');
409
+ validatePlanMessages([correction], policy);
410
+ // Never accept model-provided file assignments, summaries, or group ordering.
411
+ next[Number(correction.id)] = {
412
+ ...next[Number(correction.id)],
413
+ subject: correction.subject,
414
+ body: correction.body,
415
+ };
416
+ }
417
+ if (remaining.size) throw new Error('Corrections omitted invalid group IDs.');
418
+ return next;
419
+ }
420
+
421
+ async function repairPlanMessages(config, groups, policy, stream) {
422
+ for (let attempt = 1; attempt <= 3; attempt++) {
423
+ const invalid = invalidPlanMessages(groups, policy);
424
+ if (!invalid.length) return groups;
425
+ stream?.onProgress?.(`Correcting ${invalid.length} commit messages (${attempt}/3) ...`);
426
+ try {
427
+ const corrections = await jsonCall(
428
+ config,
429
+ `Repair only the supplied commit messages. Policy: ${JSON.stringify(policy)}. ` +
430
+ `The description after the type/scope prefix must fit within ${policy.subject.maxLength} Unicode characters. ` +
431
+ (policy.subject.headerMaxLength
432
+ ? `The complete header must fit within ${policy.subject.headerMaxLength} characters. `
433
+ : '') +
434
+ 'Rewrite concisely; do not truncate. Move detail into the body only when permitted. ' +
435
+ 'Return [{"id":"original ID","subject":"complete commit header","body":"optional body"}]. ' +
436
+ 'Preserve every supplied ID exactly once. Do not plan or regroup files.',
437
+ invalid,
438
+ (candidate) => applyMessageCorrections(groups, candidate, policy),
439
+ stream,
440
+ );
441
+ return applyMessageCorrections(groups, corrections, policy);
442
+ } catch (error) {
443
+ if (error.category !== ERROR_CATEGORIES.RESPONSE_FORMAT || attempt === 3) {
444
+ error.data = { ...error.data, messageRepairDraft: groups };
445
+ throw error;
446
+ }
447
+ }
448
+ }
449
+ }
450
+
354
451
  export function validatePartition(groups, ids, requireSummary = true) {
355
452
  const remaining = new Set(ids);
356
453
  if (!Array.isArray(groups) || !groups.length)
@@ -777,15 +874,10 @@ export async function planAnalyzedChanges(config, facts, coverage = null, stream
777
874
  };
778
875
  const groups = await jsonCall(
779
876
  config,
780
- `Each commit message must follow this policy: ${JSON.stringify(policy)}.\nGroup related changes into logical commits, including related implementation and tests across directories. Return [{"ids":[input IDs],"summary":"factual combined change summary","subject":"commit subject","body":"optional commit body"}]. Assign every input ID exactly once. Do not merge unrelated changes just to reduce group count.${finalPlan ? ' The summary field is optional in this final plan.' : ''}${!deep && level === 0 ? ' Inputs are a compact local inventory, not full semantic summaries. One ID may represent multiple files and must remain atomic; use kind, module, status, fileCount, examples, and any representativeExcerpt conservatively.' : ''}`,
877
+ `Each commit message must follow this policy: ${JSON.stringify(policy)}.\nThe subject field must contain the complete Conventional Commit header: <type>[optional scope][optional !]: <description>. Respect the policy's language, scope, length, body, and breaking-change rules; include a body when required and omit it when forbidden.\nGroup related changes into logical commits, including related implementation and tests across directories. Return [{"ids":[input IDs],"summary":"factual combined change summary","subject":"complete commit header","body":"commit body if permitted or required"}]. Assign every input ID exactly once. Do not merge unrelated changes just to reduce group count.${finalPlan ? ' The summary field is optional in this final plan.' : ''}${!deep && level === 0 ? ' Inputs are a compact local inventory, not full semantic summaries. One ID may represent multiple files and must remain atomic; use kind, module, status, fileCount, examples, and any representativeExcerpt conservatively.' : ''}`,
781
878
  batch,
782
879
  (candidate) => {
783
880
  validatePartition(candidate, ids, false);
784
- if (candidate.some((group) => typeof group.subject !== 'string' || !group.subject.trim()))
785
- throw fail(
786
- ERROR_CATEGORIES.RESPONSE_FORMAT,
787
- 'Analysis plan is missing a commit subject.',
788
- );
789
881
  },
790
882
  batchStream,
791
883
  );
@@ -812,8 +904,15 @@ export async function planAnalyzedChanges(config, facts, coverage = null, stream
812
904
  });
813
905
  }
814
906
  }
815
- if (batches.length === 1)
816
- return next.map(({ subject, body, files }) => ({ subject, body, files }));
907
+ if (batches.length === 1) {
908
+ const plan = next.map(({ subject, body, files }) => ({ subject, body, files }));
909
+ try {
910
+ return await repairPlanMessages(config, plan, policy, stream);
911
+ } catch (error) {
912
+ error.data = { ...error.data, messageRepairDraft: plan, messageRepairComplete: true };
913
+ throw error;
914
+ }
915
+ }
817
916
  candidates = next;
818
917
  }
819
918
  throw fail(ERROR_CATEGORIES.PROVIDER, 'Split planning exceeded the maximum summary depth.', {
package/src/split.js CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  readlinkSync,
9
9
  realpathSync,
10
10
  rmSync,
11
+ writeFileSync,
11
12
  constants as fsConstants,
12
13
  } from 'node:fs';
13
14
  import { createHash } from 'node:crypto';
@@ -71,6 +72,8 @@ import {
71
72
  analysisConfig,
72
73
  analyzeChanges,
73
74
  planAnalyzedChanges,
75
+ invalidPlanMessages,
76
+ applyMessageCorrections,
74
77
  summarizeChanges,
75
78
  } from './change-analysis.js';
76
79
  import { updateGitHash, spoolGit } from './git-spool.js';
@@ -675,6 +678,35 @@ async function editPlan(groups, allFiles, language, commitPolicy) {
675
678
  }
676
679
  }
677
680
 
681
+ async function editInvalidPlanMessages(groups, policy) {
682
+ let draft = JSON.stringify(invalidPlanMessages(groups, policy), null, 2);
683
+ while (true) {
684
+ const action = await vimSelect({
685
+ message: 'File grouping is ready. Edit the invalid commit messages to continue?',
686
+ choices: [
687
+ { name: 'Edit commit messages', value: 'edit' },
688
+ { name: 'Cancel', value: 'cancel' },
689
+ ],
690
+ });
691
+ if (action === 'cancel') return null;
692
+ const edited = await editor({
693
+ message: 'Fix subject/body fields; keep each id unchanged. Save and close to validate.',
694
+ default: draft,
695
+ postfix: '.json',
696
+ waitForUseInput: false,
697
+ });
698
+ try {
699
+ const candidate = JSON.parse(edited);
700
+ const corrected = applyMessageCorrections(groups, candidate, policy);
701
+ return corrected;
702
+ } catch (error) {
703
+ // Keep the user's edits available for the next attempt, including invalid JSON.
704
+ console.error(` ${sanitizeTerminalText(error.message)}`);
705
+ draft = edited;
706
+ }
707
+ }
708
+ }
709
+
678
710
  // Common binary formats — sending their bytes to the model as utf-8 garbage
679
711
  // helps no one, so previews are skipped for these extensions.
680
712
  const BINARY_FILE_RE =
@@ -1836,7 +1868,7 @@ export async function splitFlow(
1836
1868
  reasoning: config.reasoning,
1837
1869
  machineOutput,
1838
1870
  cancelMessage: 'Split cancelled.',
1839
- failureMessage: 'API call failed',
1871
+ failureMessage: 'Split planning failed',
1840
1872
  task: async (stream) => {
1841
1873
  if (large) {
1842
1874
  let plan;
@@ -1880,6 +1912,9 @@ export async function splitFlow(
1880
1912
  );
1881
1913
  }
1882
1914
  } catch (err) {
1915
+ // A validated partition must not become an all-files fallback just
1916
+ // because repairing its messages exhausted the remaining budget.
1917
+ if (err.data?.messageRepairDraft) throw err;
1883
1918
  const exhausted =
1884
1919
  err.data?.analysis?.exhausted ||
1885
1920
  (!planningConfig.analysisBudget.remainingMs() ? 'time' : null);
@@ -1950,9 +1985,58 @@ export async function splitFlow(
1950
1985
  },
1951
1986
  }));
1952
1987
  } catch (err) {
1953
- console.log(`\n ${indentError(err)}\n`);
1954
- err.reported = true;
1955
- throw err;
1988
+ if (
1989
+ err.data?.messageRepairComplete &&
1990
+ !yes &&
1991
+ !machineOutput &&
1992
+ process.stdin.isTTY &&
1993
+ process.stdout.isTTY
1994
+ ) {
1995
+ console.error(` ${indentError(err)}`);
1996
+ const corrected = await editInvalidPlanMessages(
1997
+ err.data.messageRepairDraft,
1998
+ normalizeCommitPolicy(config.commitPolicy, config.language),
1999
+ );
2000
+ if (!corrected) return finishCancelled();
2001
+ raw = JSON.stringify(corrected);
2002
+ elapsed = planningConfig.analysisBudget.snapshot().elapsedMs;
2003
+ usage = planningConfig.analysisBudget.snapshot().usage;
2004
+ reasoningText = null;
2005
+ warnings.push('Commit messages were corrected manually; the file grouping was preserved.');
2006
+ }
2007
+ if (!raw && err.data?.messageRepairDraft && protectModelInput) {
2008
+ try {
2009
+ const directory = mkdtempSync(join(tmpdir(), 'aicommit-message-repair-'));
2010
+ const path = join(directory, 'draft.json');
2011
+ writeFileSync(
2012
+ path,
2013
+ JSON.stringify(
2014
+ {
2015
+ kind: 'aicommit-message-repair-draft',
2016
+ notice:
2017
+ 'Complete file grouping with invalid messages; not an apply-ready split plan.',
2018
+ groups: err.data.messageRepairDraft,
2019
+ },
2020
+ null,
2021
+ 2,
2022
+ ) + '\n',
2023
+ { mode: 0o600, flag: 'wx' },
2024
+ );
2025
+ err.data.messageRepairDraftPath = path;
2026
+ console.error(
2027
+ ` Message repair draft saved to ${path} (complete grouping, not apply-ready).`,
2028
+ );
2029
+ } catch (writeError) {
2030
+ console.error(
2031
+ ` Could not save message repair draft: ${sanitizeTerminalText(writeError.message)}`,
2032
+ );
2033
+ }
2034
+ }
2035
+ if (!raw) {
2036
+ console.log(`\n ${indentError(err)}\n`);
2037
+ err.reported = true;
2038
+ throw err;
2039
+ }
1956
2040
  }
1957
2041
 
1958
2042
  let groups;