@hifullmoon/aicommit 2.6.3 → 2.6.5

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.5] - 2026-09-21
8
+
9
+ ### Fixed
10
+
11
+ - 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.
12
+
13
+ ## [2.6.4] - 2026-09-19
14
+
15
+ ### Fixed
16
+
17
+ - Large split planning now sizes batches for the model response limit and preserves valid file groupings when the provider omits a group summary.
18
+
7
19
  ## [2.6.3] - 2026-09-15
8
20
 
9
21
  ### Changed
@@ -255,7 +267,9 @@ This file lists notable user-facing changes. Internal refactors, test-only chang
255
267
  - Added file-level split planning and execution with Git-state concurrency checks.
256
268
  - Added provider presets and user/project configuration boundaries.
257
269
 
258
- [Unreleased]: https://github.com/hi-fullmoon/AICommit/compare/v2.6.3...HEAD
270
+ [Unreleased]: https://github.com/hi-fullmoon/AICommit/compare/v2.6.5...HEAD
271
+ [2.6.5]: https://github.com/hi-fullmoon/AICommit/releases/tag/v2.6.5
272
+ [2.6.4]: https://github.com/hi-fullmoon/AICommit/releases/tag/v2.6.4
259
273
  [2.6.3]: https://github.com/hi-fullmoon/AICommit/releases/tag/v2.6.3
260
274
  [2.6.2]: https://github.com/hi-fullmoon/AICommit/releases/tag/v2.6.2
261
275
  [2.6.1]: https://github.com/hi-fullmoon/AICommit/releases/tag/v2.6.1
@@ -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.3
36
+ npm install --package-lock-only @hifullmoon/aicommit@2.6.5
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.3",
3
+ "version": "2.6.5",
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, 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,17 +355,39 @@ async function jsonCall(config, instruction, items, validate = null, stream = nu
351
355
  }
352
356
  }
353
357
 
354
- export function validatePartition(groups, ids) {
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 validatePartition(groups, ids, requireSummary = true) {
355
381
  const remaining = new Set(ids);
356
382
  if (!Array.isArray(groups) || !groups.length)
357
383
  throw fail(ERROR_CATEGORIES.RESPONSE_FORMAT, 'Analysis returned an empty partition.');
358
384
  for (const group of groups) {
359
385
  if (
386
+ !group ||
360
387
  !Array.isArray(group.ids) ||
361
388
  !group.ids.length ||
362
- typeof group.summary !== 'string' ||
363
- !group.summary.trim() ||
364
- group.summary.length > 2000
389
+ (requireSummary &&
390
+ (typeof group.summary !== 'string' || !group.summary.trim() || group.summary.length > 2000))
365
391
  )
366
392
  throw fail(ERROR_CATEGORIES.RESPONSE_FORMAT, 'Analysis returned an invalid group.');
367
393
  for (const id of group.ids)
@@ -729,15 +755,23 @@ export async function planAnalyzedChanges(config, facts, coverage = null, stream
729
755
  config.splitMaxDiffChars || 16000,
730
756
  Math.floor(config.analysisBudget.limits.chunkInputTokens * 0.6),
731
757
  );
758
+ // A model may keep every input as a separate commit. Limit candidate count
759
+ // by the response budget too, leaving room for a complete JSON plan.
760
+ const outputTokens = Math.min(2048, config.maxTokens || 1024);
761
+ const maxPlanItems = Math.max(
762
+ 1,
763
+ Math.min(config.splitMaxPlanFiles || 100, Math.floor((outputTokens - 224) / 80)),
764
+ );
765
+ const batchConfig = { ...config, splitMaxPlanFiles: maxPlanItems };
732
766
  for (let level = 0; level < 8; level++) {
733
767
  const byId = new Map(candidates.map((x) => [x.id, x]));
734
768
  const batches =
735
769
  !deep && level === 0
736
- ? localPlanBatches(config, candidates, cap)
770
+ ? localPlanBatches(batchConfig, candidates, cap)
737
771
  : packItems(
738
772
  candidates.map(({ id, path, summary }) => ({ id, path, summary })),
739
773
  cap,
740
- config.splitMaxPlanFiles || 100,
774
+ maxPlanItems,
741
775
  );
742
776
  if (!deep && level === 0 && coverage) {
743
777
  coverage.sampledFiles = batches.flat().filter((item) => item.representativeExcerpt).length;
@@ -769,21 +803,32 @@ export async function planAnalyzedChanges(config, facts, coverage = null, stream
769
803
  };
770
804
  const groups = await jsonCall(
771
805
  config,
772
- `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.${!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.' : ''}`,
806
+ `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.' : ''}`,
773
807
  batch,
774
808
  (candidate) => {
775
- validatePartition(candidate, ids);
776
- if (candidate.some((group) => typeof group.subject !== 'string' || !group.subject.trim()))
777
- throw fail(
778
- ERROR_CATEGORIES.RESPONSE_FORMAT,
779
- 'Analysis plan is missing a commit subject.',
780
- );
809
+ validatePartition(candidate, ids, false);
810
+ validatePlanMessages(candidate, policy);
781
811
  },
782
812
  batchStream,
783
813
  );
784
814
  for (const group of groups) {
815
+ const sourceSummary = group.ids
816
+ .map((id) => {
817
+ const fact = byId.get(id);
818
+ return (
819
+ fact.summary ||
820
+ `${fact.status || 'Changed'} ${fact.kind || 'files'} in ${fact.module || fact.path || 'repository'} (${fact.files.length} files)`
821
+ ).slice(0, 160);
822
+ })
823
+ .join('; ');
785
824
  next.push({
786
825
  ...group,
826
+ summary:
827
+ typeof group.summary === 'string' &&
828
+ group.summary.trim() &&
829
+ group.summary.length <= 2000
830
+ ? group.summary
831
+ : sourceSummary.slice(0, 2000),
787
832
  id: `L${level}G${next.length}`,
788
833
  files: group.ids.flatMap((id) => byId.get(id).files),
789
834
  });
package/src/split.js CHANGED
@@ -1836,7 +1836,7 @@ export async function splitFlow(
1836
1836
  reasoning: config.reasoning,
1837
1837
  machineOutput,
1838
1838
  cancelMessage: 'Split cancelled.',
1839
- failureMessage: 'API call failed',
1839
+ failureMessage: 'Split planning failed',
1840
1840
  task: async (stream) => {
1841
1841
  if (large) {
1842
1842
  let plan;