@hifullmoon/aicommit 2.6.5 → 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,12 @@ 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
+
7
13
  ## [2.6.5] - 2026-09-21
8
14
 
9
15
  ### Fixed
@@ -267,7 +273,8 @@ This file lists notable user-facing changes. Internal refactors, test-only chang
267
273
  - Added file-level split planning and execution with Git-state concurrency checks.
268
274
  - Added provider presets and user/project configuration boundaries.
269
275
 
270
- [Unreleased]: https://github.com/hi-fullmoon/AICommit/compare/v2.6.5...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
271
278
  [2.6.5]: https://github.com/hi-fullmoon/AICommit/releases/tag/v2.6.5
272
279
  [2.6.4]: https://github.com/hi-fullmoon/AICommit/releases/tag/v2.6.4
273
280
  [2.6.3]: https://github.com/hi-fullmoon/AICommit/releases/tag/v2.6.3
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.5
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.5",
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": {
@@ -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, validateCommitCandidate } 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 {
@@ -377,6 +377,77 @@ export function validatePlanMessages(groups, policy) {
377
377
  );
378
378
  }
379
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
+
380
451
  export function validatePartition(groups, ids, requireSummary = true) {
381
452
  const remaining = new Set(ids);
382
453
  if (!Array.isArray(groups) || !groups.length)
@@ -807,7 +878,6 @@ export async function planAnalyzedChanges(config, facts, coverage = null, stream
807
878
  batch,
808
879
  (candidate) => {
809
880
  validatePartition(candidate, ids, false);
810
- validatePlanMessages(candidate, policy);
811
881
  },
812
882
  batchStream,
813
883
  );
@@ -834,8 +904,15 @@ export async function planAnalyzedChanges(config, facts, coverage = null, stream
834
904
  });
835
905
  }
836
906
  }
837
- if (batches.length === 1)
838
- 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
+ }
839
916
  candidates = next;
840
917
  }
841
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 =
@@ -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;