@hifullmoon/aicommit 2.5.0 → 2.6.1

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,28 @@ This file lists notable user-facing changes. Internal refactors, test-only chang
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.6.1] - 2026-09-14
8
+
9
+ ### Fixed
10
+
11
+ - Split mode now streams thinking during each large-change planning batch, with batch progress in the spinner; interactive review still shows only the final plan's reasoning.
12
+
13
+ ## [2.6.0] - 2026-09-14
14
+
15
+ ### Added
16
+
17
+ - Added `generate --scope=staged|all` and `apply` for a reviewable single-commit plan that rechecks the repository snapshot before committing.
18
+ - Added read-only `split status --output=json` so interrupted split transactions expose completed commits and the next recovery action.
19
+
20
+ ### Changed
21
+
22
+ - Machine output schema 1.1 now reports commit IDs, plan paths, explicit change scope, and structured recovery errors. Consumers pinned to schema 1.0 should update their parsers.
23
+ - Staged snapshot checks use Git object IDs instead of regenerating full patches, and normal generation skips `git diff --stat` unless its diff is truncated.
24
+
25
+ ### Fixed
26
+
27
+ - Split planning now displays the final model request's thinking for large changes and preserves it in interactive review; `auto` mode displays thinking when a provider emits it. JSON output remains free of model reasoning.
28
+
7
29
  ## [2.5.0] - 2026-09-10
8
30
 
9
31
  ### Added
@@ -217,7 +239,9 @@ This file lists notable user-facing changes. Internal refactors, test-only chang
217
239
  - Added file-level split planning and execution with Git-state concurrency checks.
218
240
  - Added provider presets and user/project configuration boundaries.
219
241
 
220
- [Unreleased]: https://github.com/hi-fullmoon/AICommit/compare/v2.5.0...HEAD
242
+ [Unreleased]: https://github.com/hi-fullmoon/AICommit/compare/v2.6.1...HEAD
243
+ [2.6.1]: https://github.com/hi-fullmoon/AICommit/releases/tag/v2.6.1
244
+ [2.6.0]: https://github.com/hi-fullmoon/AICommit/releases/tag/v2.6.0
221
245
  [2.5.0]: https://github.com/hi-fullmoon/AICommit/releases/tag/v2.5.0
222
246
  [2.4.1]: https://github.com/hi-fullmoon/AICommit/releases/tag/v2.4.1
223
247
  [2.4.0]: https://github.com/hi-fullmoon/AICommit/releases/tag/v2.4.0
package/README.md CHANGED
@@ -311,11 +311,15 @@ aicommit --dry-run # generate and review without creating a commit
311
311
  aicommit split --dry-run # review a split plan without creating commits
312
312
  aicommit --yes # non-interactively commit already staged changes
313
313
  aicommit --yes --dry-run # non-interactively preview all changes; restores staging
314
+ aicommit --yes --dry-run --scope=staged --output=json # preview only the index
315
+ aicommit generate --scope=staged --file=/tmp/commit-plan.json --yes --output=json
316
+ aicommit apply --file=/tmp/commit-plan.json --yes --output=json
314
317
  aicommit split --scope=all --yes # non-interactively plan and commit all working-tree changes
315
318
  aicommit split --scope=all --yes --allow-single-fallback # explicitly permit a conservative fallback commit
316
319
  aicommit split plan --scope=staged --file=/tmp/split-plan.json --yes
317
320
  aicommit split apply --file=/tmp/split-plan.json --yes
318
321
  aicommit split resume --yes # resume an interrupted split transaction
322
+ aicommit split status --output=json # inspect recovery state without changing Git
319
323
  aicommit split abort --yes # discard a stale checkpoint; keep commits and changes
320
324
  aicommit --reasoning=low # stream low-effort reasoning; Ctrl+O expands/collapses it
321
325
  aicommit --no-reasoning # explicitly disable reasoning when supported
@@ -331,8 +335,8 @@ aicommit -h # help
331
335
  | `-l`, `--lang` | Commit message language (`zh` or `en`) |
332
336
  | `-p`, `--provider` | Use the named provider from `providers` |
333
337
  | `-m`, `--model` | Use a named model profile from the selected provider |
334
- | `--scope` | `staged` or `all` scope for `aicommit split` and `aicommit split plan` |
335
- | `--file` | JSON plan path for `aicommit split plan` and `aicommit split apply` |
338
+ | `--scope` | `staged` or `all` scope for dry-run, generate, and split planning |
339
+ | `--file` | JSON plan path for generate/apply and split plan/apply |
336
340
  | `--dry-run` | Generate and review a message or split plan without creating commits |
337
341
  | `-y`, `--yes` | Accept without prompts; normal mode requires explicitly staged changes |
338
342
  | `--allow-single-fallback` | Explicitly permit non-interactive split to create one complete fallback commit |
@@ -388,11 +392,13 @@ Verify the registration with `whence -w _aicommit`; it should print `_aicommit:
388
392
 
389
393
  ### Machine-readable output
390
394
 
391
- Use `--output=json` for scripts and CI. Commit and split flows also require `--yes`, preventing a machine consumer from hanging on an interactive prompt. stdout contains exactly one JSON object; progress, debug details, and diagnostics go to stderr. `doctor --output=json` and `update --output=json` do not require `--yes`.
395
+ Use `--output=json` for scripts and CI. Commit, generate, apply, and split execution flows also require `--yes`, preventing a machine consumer from hanging on an interactive prompt. stdout contains exactly one JSON object; progress, debug details, and diagnostics go to stderr. `split status`, `doctor`, and `update` in JSON mode do not require `--yes`.
396
+
397
+ For an agent-reviewed single commit, run `generate --scope=staged|all --file=<path> --yes --output=json`, inspect the returned message and plan, then run `apply --file=<path> --yes --output=json`. The plan is a validated one-group artifact; apply checks its base HEAD, change list, and content fingerprint before committing. Keep the file outside the worktree or in `.git/aicommit/`. `generate --scope=all` includes staged, unstaged, and untracked changes, restores its temporary staging, and refuses detected sensitive content in non-interactive mode. For a quick preview without an artifact, use `--dry-run --scope=staged|all --yes --output=json`; an omitted scope retains the older automatic staging behavior when the index is empty.
392
398
 
393
399
  ```json
394
400
  {
395
- "schemaVersion": "1.0",
401
+ "schemaVersion": "1.1",
396
402
  "ok": true,
397
403
  "message": "fix: handle provider retry limits",
398
404
  "plan": null,
@@ -407,11 +413,16 @@ Use `--output=json` for scripts and CI. Commit and split flows also require `--y
407
413
  "warnings": [],
408
414
  "exitReason": "dry_run",
409
415
  "committed": false,
416
+ "commitState": "none",
417
+ "commitSha": null,
418
+ "planFile": null,
419
+ "scope": "staged",
420
+ "changeCount": 1,
410
421
  "error": null
411
422
  }
412
423
  ```
413
424
 
414
- The published [JSON schema](schemas/aicommit-output.schema.json) covers success, split-plan, doctor/check, and error results. Machine output never includes the diff or model reasoning. Split output exposes only each group message and its assigned paths.
425
+ The published [JSON schema](schemas/aicommit-output.schema.json) covers success, plans, status, doctor/check, and error results. Machine output never includes the diff or model reasoning. Plans expose only each group message and its assigned paths. `committed` reports whether this invocation created a commit; `commitState` describes `none`, `partial`, `complete`, or `unknown` transaction state. A split failure can return `committed: true` with `error.code: "split_partial_failure"`, completed commit IDs in `data.split`, and `error.nextAction`. After a crash that emits no JSON, use `split status --output=json` before retrying.
415
426
 
416
427
  Stable process exits are shared by text and JSON modes:
417
428
 
@@ -444,7 +455,7 @@ The staged index (or complete split-mode working tree, including untracked file
444
455
 
445
456
  Reasoning defaults to `on` with `medium` effort. It is mapped natively for OpenAI reasoning models, DeepSeek, OpenRouter, and MiniMax; models that do not expose reasoning continue normally and show an unavailable notice instead of failing. Official OpenAI endpoints validate the selected effort against the model generation before sending the request, so unsupported combinations such as `o3 --no-reasoning` or `gpt-5.1 --reasoning=max` fail locally with a clear list of supported levels. DeepSeek's current `deepseek-v4-flash` and `deepseek-v4-pro` models receive `thinking: { "type": "enabled" }` plus `reasoning_effort`; `medium`/`xhigh` are normalized to DeepSeek's `high` level.
446
457
 
447
- When reasoning mode is `on` (including via `--reasoning=<level>`), aicommit requests a streaming response and displays reasoning as it arrives. The live view follows the newest two terminal lines by default; press `Ctrl+O` to expand or collapse the accumulated text during generation or review. Long expanded output is kept inside the terminal viewport—use `PageUp`/`PageDown` to read every page. Holding `Ctrl+O` counts as one toggle, so key repeat cannot leave duplicate panels behind. Output is sanitized and capped by `reasoning.maxDisplayChars`; providers that do not expose reasoning show a short unavailable notice.
458
+ When reasoning mode is `on` (including via `--reasoning=<level>`), aicommit requests a streaming response and displays reasoning as it arrives. In `auto` mode it also displays thinking if the provider emits it, without forcing reasoning on. Split mode streams thinking from each sequential planning batch with a batch label; the review prompt keeps only the final plan's reasoning. Earlier deep-analysis chunks show progress. The live panel requires an interactive terminal, and JSON output never includes reasoning. The live view follows the newest two terminal lines by default; press `Ctrl+O` to expand or collapse the accumulated text during generation or review. Long expanded output is kept inside the terminal viewport—use `PageUp`/`PageDown` to read every page. Holding `Ctrl+O` counts as one toggle, so key repeat cannot leave duplicate panels behind. Output is sanitized and capped by `reasoning.maxDisplayChars`; providers that do not expose reasoning show a short unavailable notice.
448
459
 
449
460
  ```json
450
461
  {
package/README.zh-CN.md CHANGED
@@ -313,11 +313,15 @@ aicommit --dry-run # 生成并审阅,但不创建提交
313
313
  aicommit split --dry-run # 审阅拆分计划,但不创建提交
314
314
  aicommit --yes # 非交互提交已明确暂存的变更
315
315
  aicommit --yes --dry-run # 非交互预览所有变更;退出时恢复暂存状态
316
+ aicommit --yes --dry-run --scope=staged --output=json # 只预览暂存区
317
+ aicommit generate --scope=staged --file=/tmp/commit-plan.json --yes --output=json
318
+ aicommit apply --file=/tmp/commit-plan.json --yes --output=json
316
319
  aicommit split --scope=all --yes # 非交互规划并提交所有工作区变更
317
320
  aicommit split --scope=all --yes --allow-single-fallback # 明确允许规划预算耗尽后的保守提交
318
321
  aicommit split plan --scope=staged --file=/tmp/split-plan.json --yes
319
322
  aicommit split apply --file=/tmp/split-plan.json --yes
320
323
  aicommit split resume --yes # 恢复中断的拆分事务
324
+ aicommit split status --output=json # 只读查询恢复状态
321
325
  aicommit split abort --yes # 丢弃过期 checkpoint;保留提交和变更
322
326
  aicommit --reasoning=low # 流式显示低强度推理;Ctrl+O 展开或收起
323
327
  aicommit --no-reasoning # Provider / 模型支持时显式关闭推理
@@ -333,8 +337,8 @@ aicommit -h # 帮助
333
337
  | `-l`, `--lang` | 提交信息语言:`zh` 或 `en` |
334
338
  | `-p`, `--provider` | 使用 `providers` 中的命名 Provider |
335
339
  | `-m`, `--model` | 使用所选 Provider 下的命名模型配置 |
336
- | `--scope` | `aicommit split` 和 `aicommit split plan` 的范围:`staged` 或 `all` |
337
- | `--file` | `aicommit split plan` 和 `aicommit split apply` 的 JSON 计划路径 |
340
+ | `--scope` | dry-run、generate 和 split 规划的范围:`staged` 或 `all` |
341
+ | `--file` | generate/apply 和 split plan/apply 的 JSON 计划路径 |
338
342
  | `--dry-run` | 生成并审阅消息或拆分计划,但不创建提交 |
339
343
  | `-y`, `--yes` | 不提示直接接受;普通模式要求变更已明确暂存 |
340
344
  | `--allow-single-fallback` | 明确允许非交互拆分创建一个覆盖完整变更的保守提交 |
@@ -390,11 +394,13 @@ exec zsh
390
394
 
391
395
  ### 机器可读输出
392
396
 
393
- 脚本和 CI 请使用 `--output=json`。提交和 split 流程还必须使用 `--yes`,避免机器消费者卡在交互提示上。stdout 只包含一个 JSON 对象;进度、调试信息和诊断输出会写入 stderr。`doctor --output=json` 和 `update --output=json` 不要求 `--yes`。
397
+ 脚本和 CI 请使用 `--output=json`。提交、generate、apply 和 split 执行流程还必须使用 `--yes`,避免机器消费者卡在交互提示上。stdout 只包含一个 JSON 对象;进度、调试信息和诊断输出会写入 stderr。`split status`、`doctor` 和 `update` 的 JSON 模式不要求 `--yes`。
398
+
399
+ 让 AI 审阅单次提交时,先执行 `generate --scope=staged|all --file=<路径> --yes --output=json`,检查返回的消息和计划,再执行 `apply --file=<路径> --yes --output=json`。计划复用经过校验的单组工件;apply 会在提交前核对基础 HEAD、变更清单和内容指纹。计划文件应放在工作区外或 `.git/aicommit/`。`generate --scope=all` 包含已暂存、未暂存和未跟踪变更,结束时恢复临时暂存;非交互模式遇到检测出的敏感内容会停止。只想快速预览时,可用 `--dry-run --scope=staged|all --yes --output=json`;不指定范围时,保留旧版在暂存区为空时自动暂存的行为。
394
400
 
395
401
  ```json
396
402
  {
397
- "schemaVersion": "1.0",
403
+ "schemaVersion": "1.1",
398
404
  "ok": true,
399
405
  "message": "fix: handle provider retry limits",
400
406
  "plan": null,
@@ -409,11 +415,16 @@ exec zsh
409
415
  "warnings": [],
410
416
  "exitReason": "dry_run",
411
417
  "committed": false,
418
+ "commitState": "none",
419
+ "commitSha": null,
420
+ "planFile": null,
421
+ "scope": "staged",
422
+ "changeCount": 1,
412
423
  "error": null
413
424
  }
414
425
  ```
415
426
 
416
- 已发布的 [JSON schema](schemas/aicommit-output.schema.json) 覆盖成功、拆分计划、doctor / check 和错误结果。机器输出绝不包含 diff 或模型推理。split 输出只暴露每组消息及其分配路径。
427
+ 已发布的 [JSON schema](schemas/aicommit-output.schema.json) 覆盖成功、计划、状态、doctor / check 和错误结果。机器输出绝不包含 diff 或模型推理;计划只暴露每组消息及其分配路径。`committed` 表示本次调用是否创建了提交;`commitState` 表示事务处于 `none`、`partial`、`complete` 或 `unknown`。拆分失败时可能返回 `committed: true`、`error.code: "split_partial_failure"`、`data.split` 中已完成的提交 ID,以及 `error.nextAction`。若进程崩溃而没有输出 JSON,应先执行 `split status --output=json` 再决定下一步。
417
428
 
418
429
  文本与 JSON 模式共享稳定的进程退出码:
419
430
 
@@ -446,7 +457,7 @@ exec zsh
446
457
 
447
458
  推理默认为 `on`,强度为 `medium`。OpenAI 推理模型、DeepSeek、OpenRouter 和 MiniMax 使用各自原生映射;不暴露推理能力的模型会正常继续,并显示“不可用”提示,而不是失败。官方 OpenAI 端点会在发送请求前根据模型代际校验所选强度,因此 `o3 --no-reasoning` 或 `gpt-5.1 --reasoning=max` 等不支持的组合会在本地失败,并清楚列出支持的级别。DeepSeek 当前的 `deepseek-v4-flash` 和 `deepseek-v4-pro` 会收到 `thinking: { "type": "enabled" }` 与 `reasoning_effort`;`medium` / `xhigh` 会归一化为 DeepSeek 的 `high` 级别。
448
459
 
449
- 推理模式为 `on` 时(包括通过 `--reasoning=<level>` 启用),AICommit 会请求流式响应并实时显示推理。默认实时视图跟随终端最新两行;生成或审阅期间按 `Ctrl+O` 可展开或收起累计文本。较长的展开输出会限制在终端视口内,可用 `PageUp` / `PageDown` 阅读所有页面。长按 `Ctrl+O` 只计为一次切换,避免按键重复留下多个面板。输出会经过清理,并受 `reasoning.maxDisplayChars` 限制;不暴露推理的 Provider 会显示简短的不可用提示。
460
+ 推理模式为 `on` 时(包括通过 `--reasoning=<level>` 启用),AICommit 会请求流式响应并实时显示推理;`auto` 模式下若 Provider 主动返回 think,也会显示,但不会强制开启推理。split 模式会按批次实时显示规划请求的 think,并标明当前批次;审阅界面只保留最终规划的推理。更早的深度分析分块仍只显示进度。实时面板需要交互式终端,JSON 输出始终不包含推理。默认实时视图跟随终端最新两行;生成或审阅期间按 `Ctrl+O` 可展开或收起累计文本。较长的展开输出会限制在终端视口内,可用 `PageUp` / `PageDown` 阅读所有页面。长按 `Ctrl+O` 只计为一次切换,避免按键重复留下多个面板。输出会经过清理,并受 `reasoning.maxDisplayChars` 限制;不暴露推理的 Provider 会显示简短的不可用提示。
450
461
 
451
462
  ```json
452
463
  {
@@ -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.5.0
36
+ npm install --package-lock-only @hifullmoon/aicommit@2.6.1
37
37
  npm audit signatures
38
38
  ```
39
39
 
@@ -15,20 +15,20 @@ JSON mode keeps one machine object on stdout and diagnostics on stderr. The `err
15
15
 
16
16
  JSON 模式保证 stdout 只有一个机器对象,诊断进入 stderr。`error.category` 与进程退出码可稳定用于自动化。
17
17
 
18
- | Symptom / 症状 | Category / exit | Likely cause / 常见原因 | Check and recovery / 检查与恢复 |
19
- | ------------------------------------------------------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
20
- | Config fails before Git/provider access / 在 Git/provider 前配置失败 | `config` / `2` | Malformed JSON, invalid URL, unsupported option, missing credential / JSON 损坏、URL 或选项无效、凭据缺失 | `aicommit config path`; `aicommit config validate`; fix only the user-owned file shown |
21
- | Project settings are ignored / 项目设置被忽略 | warning | Repository config tried to set endpoint, credential, reasoning, or raise a ceiling / 仓库配置尝试设置连接、凭据、推理或提高预算 | Move trusted connection settings to `~/.aicommit/config.json`; keep team rules in `.aicommit.policy.json` |
22
- | Not a Git repository, conflict, empty index / 非仓库、冲突或 index 为空 | `git_state` / `3` | Wrong directory or unsafe Git state / 目录错误或 Git 状态不安全 | `git status`; `aicommit /absolute/repo/path`; resolve conflicts before retrying |
23
- | DNS, connection, or timeout / DNS、连接或超时 | `network` / `4` | Endpoint unavailable, proxy/TLS issue, budget too short / endpoint 不可达、代理/TLS、超时过短 | `aicommit doctor -p NAME`; verify HTTPS URL; raise user-owned `timeoutMs` only if expected |
24
- | HTTP authentication/rate/parameter failure / 鉴权、限流或参数失败 | `provider` / `5` | Wrong key/model/body; non-retryable 4xx / key、model、body 错误 | Check `apiKeyEnv`, model and compatibility table; authentication is never retried automatically |
25
- | Empty or malformed model reply / 空或畸形回复 | `response_format` / `6` | Unsupported response dialect, token limit, or policy validation failure / 响应方言、token 限制或 policy 校验失败 | Raise `maxTokens`, choose the matching adapter, inspect validator issue code; one correction is already attempted |
26
- | `split run --scope=all --yes` stops before API call / split 非交互在 API 前停止 | `sensitive_data` / `7` | Complete untracked scan found sensitive-looking data / 完整未跟踪扫描发现疑似敏感数据 | Review/stage intended files explicitly; do not bypass without checking the actual content |
27
- | Commit aborts after generation / 生成后提交中止 | `concurrent_modification` / `8` | Index/worktree changed during the protected window / 受保护窗口中 index/worktree 被修改 | Review `git status`, restore the intended snapshot, and generate again |
28
- | Split stopped after one or more commits / split 部分提交后停止 | reported Git failure | Hook, crash, SIGINT, or concurrent pending edit / hook、崩溃、中断或待处理文件变化 | Run `aicommit split resume`; if another Git workflow already replaced the transaction, use `aicommit split abort`(只删除恢复元数据,不改提交或工作区) |
29
- | `aicommit update` refuses the installation / update 拒绝当前安装 | `config` / `2` | Source checkout, npm link, or another active Node/npm environment / 源码、npm link 或当前是另一套 Node/npm 环境 | Activate the Node environment that installed AICommit, or run `npm install --global @hifullmoon/aicommit@latest` manually |
30
- | `aicommit update` cannot reach npm / update 无法访问 npm | `network` / `4` | Registry authentication, proxy, DNS, or network failure / registry 鉴权、代理、DNS 或网络失败 | Check `npm config get registry` and npm authentication/proxy settings, then retry |
31
- | npm provenance is absent or invalid / npm provenance 缺失或失败 | npm audit failure | Old npm CLI, non-trusted release, or wrong version / npm 过旧、非可信发布或版本错误 | Upgrade npm; run `npm audit signatures`; install only a version linked to the official workflow |
18
+ | Symptom / 症状 | Category / exit | Likely cause / 常见原因 | Check and recovery / 检查与恢复 |
19
+ | ------------------------------------------------------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
20
+ | Config fails before Git/provider access / 在 Git/provider 前配置失败 | `config` / `2` | Malformed JSON, invalid URL, unsupported option, missing credential / JSON 损坏、URL 或选项无效、凭据缺失 | `aicommit config path`; `aicommit config validate`; fix only the user-owned file shown |
21
+ | Project settings are ignored / 项目设置被忽略 | warning | Repository config tried to set endpoint, credential, reasoning, or raise a ceiling / 仓库配置尝试设置连接、凭据、推理或提高预算 | Move trusted connection settings to `~/.aicommit/config.json`; keep team rules in `.aicommit.policy.json` |
22
+ | Not a Git repository, conflict, empty index / 非仓库、冲突或 index 为空 | `git_state` / `3` | Wrong directory or unsafe Git state / 目录错误或 Git 状态不安全 | `git status`; `aicommit /absolute/repo/path`; resolve conflicts before retrying |
23
+ | DNS, connection, or timeout / DNS、连接或超时 | `network` / `4` | Endpoint unavailable, proxy/TLS issue, budget too short / endpoint 不可达、代理/TLS、超时过短 | `aicommit doctor -p NAME`; verify HTTPS URL; raise user-owned `timeoutMs` only if expected |
24
+ | HTTP authentication/rate/parameter failure / 鉴权、限流或参数失败 | `provider` / `5` | Wrong key/model/body; non-retryable 4xx / key、model、body 错误 | Check `apiKeyEnv`, model and compatibility table; authentication is never retried automatically |
25
+ | Empty or malformed model reply / 空或畸形回复 | `response_format` / `6` | Unsupported response dialect, token limit, or policy validation failure / 响应方言、token 限制或 policy 校验失败 | Raise `maxTokens`, choose the matching adapter, inspect validator issue code; one correction is already attempted |
26
+ | `split run --scope=all --yes` stops before API call / split 非交互在 API 前停止 | `sensitive_data` / `7` | Complete untracked scan found sensitive-looking data / 完整未跟踪扫描发现疑似敏感数据 | Review/stage intended files explicitly; do not bypass without checking the actual content |
27
+ | Commit aborts after generation / 生成后提交中止 | `concurrent_modification` / `8` | Index/worktree changed during the protected window / 受保护窗口中 index/worktree 被修改 | Review `git status`, restore the intended snapshot, and generate again |
28
+ | Split stopped after one or more commits / split 部分提交后停止 | `git_state` / `3` | Hook, crash, SIGINT, or concurrent pending edit / hook、崩溃、中断或待处理文件变化 | Inspect `aicommit split status --output=json`, then run `aicommit split resume --yes`; only use `split abort` to discard recovery metadata |
29
+ | `aicommit update` refuses the installation / update 拒绝当前安装 | `config` / `2` | Source checkout, npm link, or another active Node/npm environment / 源码、npm link 或当前是另一套 Node/npm 环境 | Activate the Node environment that installed AICommit, or run `npm install --global @hifullmoon/aicommit@latest` manually |
30
+ | `aicommit update` cannot reach npm / update 无法访问 npm | `network` / `4` | Registry authentication, proxy, DNS, or network failure / registry 鉴权、代理、DNS 或网络失败 | Check `npm config get registry` and npm authentication/proxy settings, then retry |
31
+ | npm provenance is absent or invalid / npm provenance 缺失或失败 | npm audit failure | Old npm CLI, non-trusted release, or wrong version / npm 过旧、非可信发布或版本错误 | Upgrade npm; run `npm audit signatures`; install only a version linked to the official workflow |
32
32
 
33
33
  If a failure remains, capture `aicommit doctor --output=json`, Node/Git versions, the error category, and redacted config sources. Never attach a diff, commit message, config file, API key, reasoning trace, or credential-helper output to a public issue.
34
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hifullmoon/aicommit",
3
- "version": "2.5.0",
3
+ "version": "2.6.1",
4
4
  "description": "Safe, local-first AI commit message generator for Git workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,11 +16,16 @@
16
16
  "warnings",
17
17
  "exitReason",
18
18
  "committed",
19
+ "commitState",
20
+ "commitSha",
21
+ "planFile",
22
+ "scope",
23
+ "changeCount",
19
24
  "error"
20
25
  ],
21
26
  "properties": {
22
27
  "schemaVersion": {
23
- "const": "1.0"
28
+ "const": "1.1"
24
29
  },
25
30
  "ok": {
26
31
  "type": "boolean"
@@ -107,10 +112,26 @@
107
112
  "committed": {
108
113
  "type": "boolean"
109
114
  },
115
+ "commitState": {
116
+ "enum": ["none", "partial", "complete", "unknown"]
117
+ },
118
+ "commitSha": {
119
+ "type": ["string", "null"]
120
+ },
121
+ "planFile": {
122
+ "type": ["string", "null"]
123
+ },
124
+ "scope": {
125
+ "type": ["string", "null"]
126
+ },
127
+ "changeCount": {
128
+ "type": ["integer", "null"],
129
+ "minimum": 0
130
+ },
110
131
  "error": {
111
132
  "type": ["object", "null"],
112
133
  "additionalProperties": false,
113
- "required": ["category", "message"],
134
+ "required": ["category", "code", "message", "retryable", "nextAction"],
114
135
  "properties": {
115
136
  "category": {
116
137
  "enum": [
@@ -126,6 +147,16 @@
126
147
  },
127
148
  "message": {
128
149
  "type": "string"
150
+ },
151
+ "code": {
152
+ "type": "string",
153
+ "minLength": 1
154
+ },
155
+ "retryable": {
156
+ "type": "boolean"
157
+ },
158
+ "nextAction": {
159
+ "type": ["string", "null"]
129
160
  }
130
161
  }
131
162
  },
@@ -150,7 +181,7 @@
150
181
  "description": "Files represented by partial excerpts; not fully analyzed files."
151
182
  },
152
183
  "strategy": {
153
- "enum": ["auto", "deep"]
184
+ "enum": ["auto", "deep", "fallback"]
154
185
  },
155
186
  "metadataOnlyFiles": {
156
187
  "type": "number",
@@ -181,6 +212,33 @@
181
212
  "minimum": 0
182
213
  }
183
214
  }
215
+ },
216
+ "split": {
217
+ "type": "object",
218
+ "additionalProperties": false,
219
+ "required": ["state"],
220
+ "properties": {
221
+ "state": { "enum": ["idle", "pending"] },
222
+ "transactionId": { "type": "string" },
223
+ "checkpointPath": { "type": "string" },
224
+ "scope": { "enum": ["staged", "all"] },
225
+ "totalGroups": { "type": "integer", "minimum": 0 },
226
+ "completedGroups": { "type": "integer", "minimum": 0 },
227
+ "pendingGroups": { "type": "integer", "minimum": 0 },
228
+ "completedCommits": {
229
+ "type": "array",
230
+ "items": { "type": "string", "pattern": "^[0-9a-f]{40}([0-9a-f]{24})?$" }
231
+ },
232
+ "inFlight": {
233
+ "type": ["object", "null"],
234
+ "additionalProperties": false,
235
+ "properties": {
236
+ "group": { "type": "integer", "minimum": 1 },
237
+ "state": { "enum": ["pending", "committed", "unknown"] }
238
+ }
239
+ },
240
+ "nextAction": { "type": "string" }
241
+ }
184
242
  }
185
243
  }
186
244
  }
@@ -313,7 +313,7 @@ function parseAnalysisJson(raw) {
313
313
  throw new SyntaxError('Response contains no complete JSON array.');
314
314
  }
315
315
 
316
- async function jsonCall(config, instruction, items, validate = null) {
316
+ async function jsonCall(config, instruction, items, validate = null, stream = null) {
317
317
  const parseAndValidate = (raw) => {
318
318
  const parsed = parseAnalysisJson(raw);
319
319
  validate?.(parsed);
@@ -329,7 +329,7 @@ async function jsonCall(config, instruction, items, validate = null) {
329
329
  Math.min(2048, config.maxTokens || 1024),
330
330
  'Return the complete requested JSON array, preserving every required input ID exactly once. ' +
331
331
  `Required IDs: ${JSON.stringify(items.map((item) => item.id))}`,
332
- null,
332
+ stream,
333
333
  (response) => {
334
334
  try {
335
335
  parseAndValidate(response);
@@ -339,6 +339,7 @@ async function jsonCall(config, instruction, items, validate = null) {
339
339
  }
340
340
  },
341
341
  );
342
+ stream?.onReasoningComplete?.(result.reasoning);
342
343
  try {
343
344
  return parseAndValidate(result.text);
344
345
  } catch (cause) {
@@ -712,7 +713,7 @@ export async function summarizeChanges(config, facts) {
712
713
  return `Structured change summaries (not raw diff):\n${JSON.stringify(items)}`;
713
714
  }
714
715
 
715
- export async function planAnalyzedChanges(config, facts, coverage = null) {
716
+ export async function planAnalyzedChanges(config, facts, coverage = null, stream = null) {
716
717
  let candidates = facts;
717
718
  const deep = isDeepAnalysis(config) && coverage?.strategy !== 'auto';
718
719
  const policy = normalizeCommitPolicy(config.commitPolicy, config.language);
@@ -735,8 +736,29 @@ export async function planAnalyzedChanges(config, facts, coverage = null) {
735
736
  coverage.metadataOnlyFiles = coverage.totalFiles - coverage.sampledFiles;
736
737
  }
737
738
  const next = [];
738
- for (const batch of batches) {
739
+ for (const [batchIndex, batch] of batches.entries()) {
739
740
  const ids = batch.map((x) => x.id);
741
+ const finalPlan = batches.length === 1;
742
+ stream?.onProgress?.(
743
+ finalPlan
744
+ ? 'Planning final split merge ...'
745
+ : `Planning split batch ${batchIndex + 1}/${batches.length} ...`,
746
+ );
747
+ let startedThinking = false;
748
+ const batchStream = stream && {
749
+ onReasoningDelta(chunk) {
750
+ if (!startedThinking && chunk) {
751
+ startedThinking = true;
752
+ if (!finalPlan || level > 0) {
753
+ const stage = finalPlan ? 'final merge' : `batch ${batchIndex + 1}/${batches.length}`;
754
+ stream.onReasoningDelta?.(`\n\n[Planning ${stage}]\n`);
755
+ }
756
+ }
757
+ stream.onReasoningDelta?.(chunk);
758
+ },
759
+ // Only the final merge belongs in the subsequent review prompt.
760
+ onReasoningComplete: finalPlan ? (text) => stream.onReasoningComplete?.(text) : undefined,
761
+ };
740
762
  const groups = await jsonCall(
741
763
  config,
742
764
  `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.`,
@@ -749,6 +771,7 @@ export async function planAnalyzedChanges(config, facts, coverage = null) {
749
771
  'Analysis plan is missing a commit subject.',
750
772
  );
751
773
  },
774
+ batchStream,
752
775
  );
753
776
  for (const group of groups) {
754
777
  next.push({
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,8 +50,8 @@ 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
@@ -91,6 +96,9 @@ function parsedDefaults(overrides = {}) {
91
96
  debug: false,
92
97
  split: null,
93
98
  splitCommand: null,
99
+ generate: false,
100
+ generateScope: null,
101
+ previewScope: null,
94
102
  splitPlanFile: null,
95
103
  dryRun: false,
96
104
  yes: false,
@@ -165,10 +173,17 @@ export function parseArgs(args = process.argv.slice(2)) {
165
173
  args = args.slice(2);
166
174
  }
167
175
 
176
+ const generate = !update && args[0] === 'generate';
177
+ if (generate) args = args.slice(1);
178
+
168
179
  let splitCommand = null;
180
+ if (!update && args[0] === 'apply') {
181
+ splitCommand = 'apply';
182
+ args = args.slice(1);
183
+ }
169
184
  if (!update && args[0] === 'split') {
170
185
  const requestedAction = args[1];
171
- const actions = ['run', 'plan', 'apply', 'resume', 'abort'];
186
+ const actions = ['run', 'plan', 'apply', 'resume', 'abort', 'status'];
172
187
  if (!requestedAction || requestedAction.startsWith('-')) {
173
188
  // Keep the common path short: `aicommit split` is the interactive
174
189
  // split flow, while explicit actions remain available for automation
@@ -181,7 +196,7 @@ export function parseArgs(args = process.argv.slice(2)) {
181
196
  } else {
182
197
  throw fail(
183
198
  ERROR_CATEGORIES.CONFIG,
184
- 'split requires one action: run, plan, apply, resume, or abort.',
199
+ 'split requires one action: run, plan, apply, resume, abort, or status.',
185
200
  );
186
201
  }
187
202
  }
@@ -197,6 +212,8 @@ export function parseArgs(args = process.argv.slice(2)) {
197
212
  let output = 'text';
198
213
  let debug = false;
199
214
  let split = null;
215
+ let generateScope = null;
216
+ let previewScope = null;
200
217
  let splitPlanFile = null;
201
218
  let policyMessageFile = null;
202
219
  let policyRange = null;
@@ -238,20 +255,26 @@ export function parseArgs(args = process.argv.slice(2)) {
238
255
 
239
256
  if (arg === '--scope') {
240
257
  splitScopeOption = true;
241
- split = takeValue(args, i, arg, 'staged|all');
258
+ const scope = takeValue(args, i, arg, 'staged|all');
242
259
  i++;
243
- if (!['staged', 'all'].includes(split)) {
244
- 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.`);
245
262
  }
263
+ if (generate) generateScope = scope;
264
+ else if (splitCommand) split = scope;
265
+ else previewScope = scope;
246
266
  continue;
247
267
  }
248
268
 
249
269
  if (arg.startsWith('--scope=')) {
250
270
  splitScopeOption = true;
251
- split = arg.slice('--scope='.length);
252
- if (!['staged', 'all'].includes(split)) {
253
- 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.`);
254
274
  }
275
+ if (generate) generateScope = scope;
276
+ else if (splitCommand) split = scope;
277
+ else previewScope = scope;
255
278
  continue;
256
279
  }
257
280
 
@@ -427,14 +450,32 @@ export function parseArgs(args = process.argv.slice(2)) {
427
450
  }
428
451
  }
429
452
  }
430
- 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)) {
431
466
  throw fail(
432
467
  ERROR_CATEGORIES.CONFIG,
433
- '--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.',
434
469
  );
435
470
  }
436
- if (splitPlanFile && !['plan', 'apply'].includes(splitCommand)) {
437
- 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
+ );
438
479
  }
439
480
  if (allowSingleFallback && (splitCommand !== 'run' || !yes || dryRun)) {
440
481
  throw fail(
@@ -508,6 +549,9 @@ export function parseArgs(args = process.argv.slice(2)) {
508
549
  debug,
509
550
  split,
510
551
  splitCommand,
552
+ generate,
553
+ generateScope,
554
+ previewScope,
511
555
  splitPlanFile,
512
556
  dryRun,
513
557
  yes,
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',
@@ -33,7 +35,7 @@ _aicommit() {
33
35
  config) COMPREPLY=( $(compgen -W "show validate path --provider --model --output --debug" -- "$cur") ); return ;;
34
36
  policy) COMPREPLY=( $(compgen -W "template check --file --range --output --debug" -- "$cur") ); return ;;
35
37
  completion) COMPREPLY=( $(compgen -W "bash zsh fish" -- "$cur") ); return ;;
36
- split) COMPREPLY=( $(compgen -W "run plan apply resume abort --scope --file --yes --allow-single-fallback --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 ;;
37
39
  esac
38
40
  case "$prev" in
39
41
  --lang|-l) COMPREPLY=( $(compgen -W "zh en" -- "$cur") ); return ;;
@@ -58,7 +60,9 @@ _aicommit() {
58
60
  'config:inspect or validate configuration'
59
61
  'policy:print or enforce a repository team policy'
60
62
  'completion:generate shell completion'
61
- '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'
62
66
  )
63
67
  options=(
64
68
  '--help[show help]'
@@ -66,8 +70,8 @@ _aicommit() {
66
70
  '--lang=[commit language]:language:(zh en)'
67
71
  '--provider=[provider name]:provider:'
68
72
  '--model=[model name]:model:'
69
- '--scope=[split scope]:scope:(staged all)'
70
- '--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'
71
75
  '--range=[Git range for policy check]:revision:'
72
76
  '--reasoning=[reasoning effort]:effort:(low medium high xhigh max)'
73
77
  '--no-reasoning[disable reasoning]'
@@ -87,7 +91,7 @@ _aicommit() {
87
91
  config) _values 'config action' show validate path ;;
88
92
  policy) _values 'policy action' template check ;;
89
93
  completion) _values 'shell' bash zsh fish ;;
90
- split) _values 'split action' run plan apply resume abort ;;
94
+ split) _values 'split action' run plan apply resume abort status ;;
91
95
  esac
92
96
  ;;
93
97
  esac
@@ -103,18 +107,20 @@ complete -c aicommit -n '__fish_use_subcommand' -a doctor -d 'Diagnose configura
103
107
  complete -c aicommit -n '__fish_use_subcommand' -a config -d 'Inspect or validate configuration'
104
108
  complete -c aicommit -n '__fish_use_subcommand' -a policy -d 'Print or enforce a repository team policy'
105
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'
106
112
  complete -c aicommit -n '__fish_use_subcommand' -a split -d 'Run, plan, apply, resume, or abort split commits'
107
113
  complete -c aicommit -n '__fish_seen_subcommand_from config' -a 'show validate path'
108
114
  complete -c aicommit -n '__fish_seen_subcommand_from policy' -a 'template check'
109
115
  complete -c aicommit -n '__fish_seen_subcommand_from completion' -a 'bash zsh fish'
110
- 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'
111
117
  complete -c aicommit -s h -l help -d 'Show help'
112
118
  complete -c aicommit -s v -l version -d 'Show version'
113
119
  complete -c aicommit -s l -l lang -x -a 'zh en' -d 'Commit language'
114
120
  complete -c aicommit -s p -l provider -x -d 'Provider name'
115
121
  complete -c aicommit -s m -l model -x -d 'Model name'
116
- complete -c aicommit -l scope -x -a 'staged all' -d 'Split plan scope'
117
- 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'
118
124
  complete -c aicommit -l range -r -d 'Git range for policy check'
119
125
  complete -c aicommit -l reasoning -x -a 'low medium high xhigh max' -d 'Reasoning effort'
120
126
  complete -c aicommit -l no-reasoning -d 'Disable reasoning'
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,8 +18,11 @@ 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
+ onProgress(status) {
24
+ if (spinner.isSpinning) spinner.text = chalk.dim(status);
25
+ },
23
26
  onReasoningDelta(chunk) {
24
27
  if (!liveReasoning) {
25
28
  spinner.stop();
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) {
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,
@@ -54,8 +56,13 @@ import {
54
56
  applySplitPlan,
55
57
  resumeSplit,
56
58
  splitFlow,
59
+ splitStatus,
57
60
  getStagedChangedFiles,
61
+ getAllChangedFiles,
62
+ getSplitStateFingerprint,
63
+ safeExportPlanPath,
58
64
  } from './split.js';
65
+ import { createSplitPlanArtifact, writeSplitPlanArtifact } from './split-plan.js';
59
66
  import { runModelTask } from './generation-ui.js';
60
67
  import { runSetup } from './setup.js';
61
68
  import { detectProviderType } from './providers.js';
@@ -108,6 +115,9 @@ async function runMain() {
108
115
  debug,
109
116
  split,
110
117
  splitCommand,
118
+ generate,
119
+ generateScope,
120
+ previewScope,
111
121
  splitPlanFile,
112
122
  dryRun,
113
123
  yes,
@@ -130,7 +140,15 @@ async function runMain() {
130
140
  return { exitReason: 'completion' };
131
141
  }
132
142
  const machineOutput = output === 'json';
133
- 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
+ ) {
134
152
  throw fail(ERROR_CATEGORIES.CONFIG, '--output=json requires --yes for commit and split flows.');
135
153
  }
136
154
 
@@ -178,13 +196,14 @@ async function runMain() {
178
196
  console.log(' ' + chalk.dim('─'.repeat(45)));
179
197
  console.log(' ' + chalk.dim(`Working directory: ${sanitizeTerminalText(process.cwd())}`));
180
198
 
181
- if (['apply', 'resume', 'abort'].includes(splitCommand)) {
199
+ if (['apply', 'resume', 'abort', 'status'].includes(splitCommand)) {
182
200
  const projectRoot = getProjectRoot();
183
201
  if (!isGitRepo(projectRoot)) {
184
202
  throw fail(ERROR_CATEGORIES.GIT_STATE, `Not a git repository: ${process.cwd()}`);
185
203
  }
186
204
  if (splitCommand === 'resume') return resumeSplit(projectRoot, { yes, machineOutput });
187
205
  if (splitCommand === 'abort') return abortSplit(projectRoot, { yes, machineOutput });
206
+ if (splitCommand === 'status') return splitStatus(projectRoot);
188
207
  return applySplitPlan(projectRoot, splitPlanFile, { yes, machineOutput });
189
208
  }
190
209
 
@@ -340,6 +359,35 @@ async function runMain() {
340
359
  // All git commands run at the repo root (projectRoot), even when aicommit
341
360
  // is invoked from a subdirectory.
342
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;
343
391
  const finishCancelled = ({
344
392
  notice = 'Commit cancelled.',
345
393
  message = null,
@@ -374,6 +422,20 @@ async function runMain() {
374
422
  indexTransaction ||= createIndexTransaction(projectRoot);
375
423
  return indexTransaction;
376
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
+ }
377
439
  if (!getChangedFiles(projectRoot).length) {
378
440
  // Nothing staged. But git diff --staged is also empty for unstaged work
379
441
  // and untracked files — surface what git status actually shows instead of
@@ -390,6 +452,10 @@ async function runMain() {
390
452
  throw fail(ERROR_CATEGORIES.GIT_STATE, 'No changes to commit.', { reported: true });
391
453
  }
392
454
 
455
+ if (effectiveScope === 'staged' || generate) {
456
+ throw fail(ERROR_CATEGORIES.GIT_STATE, `No ${effectiveScope} changes to preview.`);
457
+ }
458
+
393
459
  console.log('\n ' + chalk.yellow(`✗ No staged changes — ${tips.join(', ')}.`));
394
460
  printChangeList(withUntrackedFiles(unstaged, untracked));
395
461
  console.log('');
@@ -531,6 +597,13 @@ async function runMain() {
531
597
  let diffForModel = diff;
532
598
  let protectAnalysis = true;
533
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
+ }
534
607
  warnings.push('Sensitive data was detected and protected before the provider request.');
535
608
  console.log('\n ' + chalk.yellow.bold('⚠ Potential sensitive data detected:'));
536
609
  for (const finding of protectedInput.findings) {
@@ -571,7 +644,7 @@ async function runMain() {
571
644
  : condenseDiff(
572
645
  strippedDiff,
573
646
  config.maxDiffChars,
574
- getDiffStat(projectRoot),
647
+ () => getDiffStat(projectRoot),
575
648
  config.maxFileDiffChars,
576
649
  );
577
650
  let analysis;
@@ -723,7 +796,7 @@ async function runMain() {
723
796
  ? 'use'
724
797
  : await confirmAction(
725
798
  message,
726
- reasoningEnabled
799
+ reasoningEnabled || reasoningText
727
800
  ? {
728
801
  text: reasoningText,
729
802
  maxChars: config.reasoning.maxDisplayChars,
@@ -773,14 +846,51 @@ async function runMain() {
773
846
  console.log('');
774
847
 
775
848
  if (dryRun) {
849
+ stagedAllForPreview ||= Boolean(indexTransaction);
776
850
  const restored = indexTransaction ? indexTransaction.restore() : true;
777
851
  indexTransaction = null;
778
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
+ }
779
859
  warnings.push('The Git index changed during the run and was left untouched.');
780
860
  console.log(
781
861
  ' ' + chalk.yellow('⚠ The Git index changed during the run and was left untouched.'),
782
862
  );
783
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
+ }
784
894
  console.log(
785
895
  ' ' +
786
896
  chalk.green.bold(
@@ -789,6 +899,10 @@ async function runMain() {
789
899
  );
790
900
  return {
791
901
  message,
902
+ plan,
903
+ planFile,
904
+ scope: effectiveScope || (stagedAllForPreview || indexTransaction ? 'all' : 'staged'),
905
+ changeCount: generate ? generateSnapshot.changes.length : changedFiles.length,
792
906
  provider: selectedProvider,
793
907
  model: config.modelId,
794
908
  latencyMs: elapsed,
@@ -835,6 +949,9 @@ async function runMain() {
835
949
  console.log('\n ' + chalk.green.bold('✓ Done!\n'));
836
950
  return {
837
951
  message,
952
+ commitSha: readGit(['rev-parse', 'HEAD'], projectRoot).trim(),
953
+ scope: 'staged',
954
+ changeCount: changedFiles.length,
838
955
  provider: selectedProvider,
839
956
  model: config.modelId,
840
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)) {
package/src/split.js CHANGED
@@ -1163,6 +1163,83 @@ function currentHeadParent(projectRoot) {
1163
1163
  return fields[1] || null;
1164
1164
  }
1165
1165
 
1166
+ function splitCheckpointSummary(projectRoot, loaded) {
1167
+ const { path, checkpoint } = loaded;
1168
+ const knownCommits = checkpoint.completed.map((record) => record.commit);
1169
+ let inFlight = null;
1170
+ if (checkpoint.inFlight) {
1171
+ const record = checkpoint.inFlight;
1172
+ const head = currentHead(projectRoot);
1173
+ const state =
1174
+ head === record.parent
1175
+ ? 'pending'
1176
+ : currentHeadParent(projectRoot) === record.parent &&
1177
+ currentHeadTree(projectRoot) === record.tree
1178
+ ? 'committed'
1179
+ : 'unknown';
1180
+ if (state === 'committed') knownCommits.push(head);
1181
+ inFlight = { group: record.index + 1, state };
1182
+ }
1183
+ const totalGroups = checkpoint.plan.groups.length;
1184
+ return {
1185
+ state: 'pending',
1186
+ transactionId: checkpoint.transactionId,
1187
+ checkpointPath: path,
1188
+ scope: checkpoint.plan.scope,
1189
+ totalGroups,
1190
+ completedGroups: knownCommits.length,
1191
+ pendingGroups: totalGroups - knownCommits.length,
1192
+ completedCommits: knownCommits,
1193
+ inFlight,
1194
+ nextAction: 'aicommit split resume --yes',
1195
+ };
1196
+ }
1197
+
1198
+ export function splitStatus(projectRoot) {
1199
+ let summary;
1200
+ try {
1201
+ summary = splitCheckpointSummary(projectRoot, readSplitCheckpoint(projectRoot));
1202
+ } catch (err) {
1203
+ if (!/No split checkpoint found:/.test(err.message)) {
1204
+ throw fail(ERROR_CATEGORIES.GIT_STATE, `Cannot inspect split checkpoint: ${err.message}`, {
1205
+ code: 'split_checkpoint_invalid',
1206
+ cause: err,
1207
+ });
1208
+ }
1209
+ summary = { state: 'idle' };
1210
+ }
1211
+ const known = summary.completedCommits || [];
1212
+ const commitState =
1213
+ summary.inFlight?.state === 'unknown'
1214
+ ? 'unknown'
1215
+ : !known.length
1216
+ ? 'none'
1217
+ : summary.pendingGroups
1218
+ ? 'partial'
1219
+ : 'complete';
1220
+ return {
1221
+ exitReason: 'split_status',
1222
+ committed: false,
1223
+ commitState,
1224
+ commitSha: known.at(-1) || null,
1225
+ scope: summary.scope || null,
1226
+ data: { split: summary },
1227
+ };
1228
+ }
1229
+
1230
+ function splitCommitFailure(projectRoot, message) {
1231
+ const status = splitStatus(projectRoot);
1232
+ const knownCommits = status.data.split.completedCommits || [];
1233
+ throw fail(ERROR_CATEGORIES.GIT_STATE, message, {
1234
+ code: knownCommits.length ? 'split_partial_failure' : 'split_commit_failed',
1235
+ reported: true,
1236
+ committed: knownCommits.length > 0,
1237
+ commitState: status.commitState,
1238
+ nextAction: 'aicommit split resume --yes',
1239
+ data: status.data,
1240
+ });
1241
+ }
1242
+
1166
1243
  function readHeadEntries(projectRoot, paths) {
1167
1244
  const entries = new Map();
1168
1245
  if (!hasHead(projectRoot) || !paths.length) return entries;
@@ -1717,6 +1794,20 @@ export async function splitFlow(
1717
1794
  task: async (stream) => {
1718
1795
  if (large) {
1719
1796
  let plan;
1797
+ let planReasoning = null;
1798
+ const planningStream = stream
1799
+ ? {
1800
+ onProgress(status) {
1801
+ stream.onProgress?.(status);
1802
+ },
1803
+ onReasoningDelta(chunk) {
1804
+ stream.onReasoningDelta(chunk);
1805
+ },
1806
+ onReasoningComplete(text) {
1807
+ planReasoning = text || null;
1808
+ },
1809
+ }
1810
+ : null;
1720
1811
  try {
1721
1812
  analysis = await analyzeChanges(
1722
1813
  planningConfig,
@@ -1729,7 +1820,12 @@ export async function splitFlow(
1729
1820
  ),
1730
1821
  persistentAnalysisCache,
1731
1822
  );
1732
- plan = await planAnalyzedChanges(planningConfig, analysis.facts, analysis.coverage);
1823
+ plan = await planAnalyzedChanges(
1824
+ planningConfig,
1825
+ analysis.facts,
1826
+ analysis.coverage,
1827
+ planningStream,
1828
+ );
1733
1829
  } catch (err) {
1734
1830
  const exhausted =
1735
1831
  err.data?.analysis?.exhausted ||
@@ -1759,6 +1855,7 @@ export async function splitFlow(
1759
1855
  fallbackReason: exhausted || 'planning_capacity',
1760
1856
  };
1761
1857
  plan = normalizePlan([], allFiles, config.language, config.commitPolicy);
1858
+ planReasoning = null;
1762
1859
  const warning = `Large-change planning used one conservative all-files commit after ${exhausted || 'planning capacity'} exhaustion; review the fallback message and grouping.`;
1763
1860
  warnings.push(warning);
1764
1861
  console.error(` Fallback: ${warning}`);
@@ -1781,7 +1878,7 @@ export async function splitFlow(
1781
1878
  raw: JSON.stringify(plan),
1782
1879
  elapsed: planningConfig.analysisBudget.snapshot().elapsedMs,
1783
1880
  usage: planningConfig.analysisBudget.snapshot().usage,
1784
- reasoning: null,
1881
+ reasoning: planReasoning,
1785
1882
  };
1786
1883
  }
1787
1884
  return generateSplitPlan(
@@ -1876,7 +1973,7 @@ export async function splitFlow(
1876
1973
  { name: 'Cancel', value: 'cancel', description: 'Abort without committing' },
1877
1974
  ],
1878
1975
  },
1879
- reasoningEnabled
1976
+ reasoningEnabled || reasoningText
1880
1977
  ? {
1881
1978
  text: reasoningText,
1882
1979
  maxChars: config.reasoning.maxDisplayChars,
@@ -2013,6 +2110,8 @@ export async function splitFlow(
2013
2110
  return {
2014
2111
  plan: groups,
2015
2112
  planFile: writtenPlanPath,
2113
+ scope,
2114
+ changeCount: allFiles.length,
2016
2115
  provider,
2017
2116
  model: config.modelId,
2018
2117
  latencyMs: elapsed,
@@ -2054,12 +2153,13 @@ export async function splitFlow(
2054
2153
  if (ok) {
2055
2154
  console.log('\n ' + chalk.green.bold(`✓ Done! Created ${groups.length} commits.\n`));
2056
2155
  } else {
2057
- throw fail(ERROR_CATEGORIES.GIT_STATE, 'One or more split commits failed.', {
2058
- reported: true,
2059
- });
2156
+ splitCommitFailure(projectRoot, 'One or more split commits failed.');
2060
2157
  }
2061
2158
  return {
2062
2159
  plan: groups,
2160
+ commitSha: currentHead(projectRoot),
2161
+ scope,
2162
+ changeCount: allFiles.length,
2063
2163
  provider,
2064
2164
  model: config.modelId,
2065
2165
  latencyMs: elapsed,
@@ -2168,9 +2268,13 @@ export async function resumeSplit(projectRoot, { yes = false, machineOutput = fa
2168
2268
  );
2169
2269
  return {
2170
2270
  plan: checkpoint.plan.groups,
2271
+ commitSha: currentHead(projectRoot),
2272
+ scope: checkpoint.plan.scope,
2273
+ changeCount: checkpoint.plan.changes.length,
2171
2274
  warnings: [],
2172
2275
  exitReason: 'success',
2173
- committed: true,
2276
+ committed: false,
2277
+ commitState: 'complete',
2174
2278
  edited: false,
2175
2279
  rewrites: 0,
2176
2280
  };
@@ -2225,9 +2329,7 @@ export async function resumeSplit(projectRoot, { yes = false, machineOutput = fa
2225
2329
  { transaction: { path: transaction.path, checkpoint } },
2226
2330
  );
2227
2331
  if (!ok) {
2228
- throw fail(ERROR_CATEGORIES.GIT_STATE, 'One or more resumed split commits failed.', {
2229
- reported: true,
2230
- });
2332
+ splitCommitFailure(projectRoot, 'One or more resumed split commits failed.');
2231
2333
  }
2232
2334
  console.log(
2233
2335
  '\n ' +
@@ -2237,6 +2339,9 @@ export async function resumeSplit(projectRoot, { yes = false, machineOutput = fa
2237
2339
  );
2238
2340
  return {
2239
2341
  plan: checkpoint.plan.groups,
2342
+ commitSha: currentHead(projectRoot),
2343
+ scope: checkpoint.plan.scope,
2344
+ changeCount: checkpoint.plan.changes.length,
2240
2345
  provider: null,
2241
2346
  model: null,
2242
2347
  latencyMs: null,
@@ -2443,13 +2548,15 @@ export async function applySplitPlan(
2443
2548
  { planArtifact: artifact },
2444
2549
  );
2445
2550
  if (!ok) {
2446
- throw fail(ERROR_CATEGORIES.GIT_STATE, 'One or more split commits failed.', {
2447
- reported: true,
2448
- });
2551
+ splitCommitFailure(projectRoot, 'One or more split commits failed.');
2449
2552
  }
2450
2553
  console.log('\n ' + chalk.green.bold(`✓ Done! Created ${artifact.groups.length} commits.\n`));
2451
2554
  return {
2452
2555
  plan: artifact.groups,
2556
+ planFile: loaded.path,
2557
+ commitSha: readGit(['rev-parse', 'HEAD'], projectRoot).trim(),
2558
+ scope: artifact.scope,
2559
+ changeCount: artifact.changes.length,
2453
2560
  provider: null,
2454
2561
  model: null,
2455
2562
  latencyMs: null,