@hifullmoon/aicommit 2.2.0 → 2.2.2
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/.aicommit.config.example.json +5 -7
- package/CHANGELOG.md +20 -1
- package/README.md +78 -42
- package/README.zh-CN.md +78 -42
- package/docs/distribution.md +1 -1
- package/package.json +1 -1
- package/src/cli.js +2 -1
- package/src/completion.js +7 -3
- package/src/config.js +9 -6
- package/src/context.js +103 -15
- package/src/git.js +1 -1
- package/src/policy-command.js +13 -2
- package/src/policy.js +76 -2
- package/src/providers.js +21 -1
- package/src/setup.js +104 -3
- package/src/split-plan.js +16 -3
- package/src/split.js +40 -4
|
@@ -10,12 +10,7 @@
|
|
|
10
10
|
"models": {
|
|
11
11
|
"default": {
|
|
12
12
|
"modelId": "MiniMax-M3",
|
|
13
|
-
"
|
|
14
|
-
"thinking": {
|
|
15
|
-
"type": "disabled"
|
|
16
|
-
},
|
|
17
|
-
"reasoning_split": true
|
|
18
|
-
}
|
|
13
|
+
"reasoning": { "mode": "on", "effort": "medium" }
|
|
19
14
|
}
|
|
20
15
|
}
|
|
21
16
|
},
|
|
@@ -25,7 +20,10 @@
|
|
|
25
20
|
"apiKeyEnv": "DEEPSEEK_API_KEY",
|
|
26
21
|
"defaultModel": "chat",
|
|
27
22
|
"models": {
|
|
28
|
-
"chat": {
|
|
23
|
+
"chat": {
|
|
24
|
+
"modelId": "deepseek-v4-flash",
|
|
25
|
+
"reasoning": { "mode": "on", "effort": "high" }
|
|
26
|
+
}
|
|
29
27
|
}
|
|
30
28
|
},
|
|
31
29
|
"openrouter": {
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,23 @@ This file lists notable user-facing changes. Internal refactors, test-only chang
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [2.2.2] - 2026-08-31
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Added per-model reasoning mode and effort selection to interactive setup, with a balanced `medium` default and model-aware OpenAI option filtering.
|
|
12
|
+
|
|
13
|
+
## [2.2.1] - 2026-08-29
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Combined repository commit policies with commitlint constraints without allowing either source to relax the other, including correct `never` and complete-header length handling.
|
|
18
|
+
- Hardened split transactions against changes during snapshot capture, restricted plan exports to safe locations, and prevented existing output files from being overwritten.
|
|
19
|
+
|
|
20
|
+
### Security
|
|
21
|
+
|
|
22
|
+
- Redacted credential assignments with quoted JSON or YAML keys before diffs and untracked previews are sent to a model.
|
|
23
|
+
|
|
7
24
|
## [2.2.0] - 2026-08-28
|
|
8
25
|
|
|
9
26
|
### Added
|
|
@@ -153,7 +170,9 @@ This file lists notable user-facing changes. Internal refactors, test-only chang
|
|
|
153
170
|
- Added file-level split planning and execution with Git-state concurrency checks.
|
|
154
171
|
- Added provider presets and user/project configuration boundaries.
|
|
155
172
|
|
|
156
|
-
[Unreleased]: https://github.com/hi-fullmoon/AICommit/compare/v2.2.
|
|
173
|
+
[Unreleased]: https://github.com/hi-fullmoon/AICommit/compare/v2.2.2...HEAD
|
|
174
|
+
[2.2.2]: https://github.com/hi-fullmoon/AICommit/releases/tag/v2.2.2
|
|
175
|
+
[2.2.1]: https://github.com/hi-fullmoon/AICommit/releases/tag/v2.2.1
|
|
157
176
|
[2.2.0]: https://github.com/hi-fullmoon/AICommit/releases/tag/v2.2.0
|
|
158
177
|
[2.1.0]: https://github.com/hi-fullmoon/AICommit/releases/tag/v2.1.0
|
|
159
178
|
[2.0.1]: https://github.com/hi-fullmoon/AICommit/releases/tag/v2.0.1
|
package/README.md
CHANGED
|
@@ -46,6 +46,8 @@ aicommit setup
|
|
|
46
46
|
|
|
47
47
|
It walks you through choosing built-in provider defaults (OpenAI, DeepSeek, OpenRouter, MiniMax, Kimi Code, and Ollama) or entering a custom OpenAI-compatible endpoint, then entering your API key and one or more models, choosing a default model and commit language, and optionally testing the connection. Configuration is written atomically to the user config (`~/.aicommit/config.json`); a malformed or old-format existing file is backed up before replacement. The legacy `~/.aicommit.config.json` path remains readable and `aicommit setup` migrates its settings to the canonical path when saving.
|
|
48
48
|
|
|
49
|
+
The model step configures reasoning independently for each profile. `auto` sends no explicit reasoning switch and leaves the behavior to the provider, `on` requests reasoning and then prompts for its effort, and `off` explicitly disables reasoning when the model supports that switch. Effort defaults to `medium`. For known OpenAI models, setup removes unsupported mode and effort choices, including `off` when reasoning cannot be disabled. When editing an existing model, the wizard preselects valid saved values, falling back to the global reasoning settings when applicable; an unsupported saved mode falls back to `auto`.
|
|
50
|
+
|
|
49
51
|
To configure by hand, start from [.aicommit.config.example.json](.aicommit.config.example.json). User config is loaded first, then allow-listed generation preferences from the project config at `./.aicommit.config.json` are deep-merged over it. Project config may set `language`, `commitPolicy`, `stripFiles`, `temperature`, and lower diff/token/timeout or repository-context ceilings. A project-owned `prompt` is ignored unless the user config explicitly sets `allowProjectPrompt: true`. Connection/provider fields (including `apiKeyEnv`), reasoning request controls, unknown keys, and attempts to raise a ceiling are ignored with a warning. This prevents a cloned repository from redirecting an authenticated request or silently increasing its cost/data scope.
|
|
50
52
|
|
|
51
53
|
To keep a key out of the JSON file, set `"apiKeyEnv": "OPENAI_API_KEY"` (and leave `apiKey` empty), or enter `env:OPENAI_API_KEY` in the setup wizard. Environment variables take priority over every other credential source and are recommended for CI and other stateless environments.
|
|
@@ -68,10 +70,7 @@ Each provider owns one or more named model profiles. Switch providers with `-p`
|
|
|
68
70
|
"default": {
|
|
69
71
|
"label": "MiniMax M3",
|
|
70
72
|
"modelId": "MiniMax-M3",
|
|
71
|
-
"
|
|
72
|
-
"thinking": { "type": "disabled" },
|
|
73
|
-
"reasoning_split": true
|
|
74
|
-
}
|
|
73
|
+
"reasoning": { "mode": "on", "effort": "medium" }
|
|
75
74
|
}
|
|
76
75
|
}
|
|
77
76
|
},
|
|
@@ -81,14 +80,85 @@ Each provider owns one or more named model profiles. Switch providers with `-p`
|
|
|
81
80
|
"apiKeyEnv": "DEEPSEEK_API_KEY",
|
|
82
81
|
"defaultModel": "chat",
|
|
83
82
|
"models": {
|
|
84
|
-
"chat": {
|
|
85
|
-
|
|
83
|
+
"chat": {
|
|
84
|
+
"modelId": "deepseek-v4-flash",
|
|
85
|
+
"reasoning": { "mode": "on", "effort": "medium" }
|
|
86
|
+
},
|
|
87
|
+
"reasoner": {
|
|
88
|
+
"modelId": "deepseek-v4-pro",
|
|
89
|
+
"reasoning": { "mode": "on", "effort": "high" }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
"openai": {
|
|
94
|
+
"providerType": "openai",
|
|
95
|
+
"apiUrl": "https://api.openai.com/v1/chat/completions",
|
|
96
|
+
"apiKeyEnv": "OPENAI_API_KEY",
|
|
97
|
+
"defaultModel": "fast",
|
|
98
|
+
"models": {
|
|
99
|
+
"fast": {
|
|
100
|
+
"modelId": "gpt-4o",
|
|
101
|
+
"reasoning": { "mode": "auto" }
|
|
102
|
+
},
|
|
103
|
+
"reasoner": {
|
|
104
|
+
"modelId": "gpt-5.6-sol",
|
|
105
|
+
"reasoning": { "mode": "on", "effort": "medium" }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
"openrouter": {
|
|
110
|
+
"providerType": "openrouter",
|
|
111
|
+
"apiUrl": "https://openrouter.ai/api/v1/chat/completions",
|
|
112
|
+
"apiKeyEnv": "OPENROUTER_API_KEY",
|
|
113
|
+
"defaultModel": "auto",
|
|
114
|
+
"models": {
|
|
115
|
+
"auto": {
|
|
116
|
+
"modelId": "openrouter/auto",
|
|
117
|
+
"reasoning": { "mode": "auto" }
|
|
118
|
+
},
|
|
119
|
+
"quality": {
|
|
120
|
+
"modelId": "openai/gpt-5.6-terra",
|
|
121
|
+
"reasoning": { "mode": "on", "effort": "high" }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
"ollama": {
|
|
126
|
+
"providerType": "ollama",
|
|
127
|
+
"apiUrl": "http://127.0.0.1:11434/api/chat",
|
|
128
|
+
"defaultModel": "qwen",
|
|
129
|
+
"models": {
|
|
130
|
+
"qwen": {
|
|
131
|
+
"modelId": "qwen3:8b",
|
|
132
|
+
"reasoning": { "mode": "on", "effort": "medium" }
|
|
133
|
+
},
|
|
134
|
+
"deepseek": {
|
|
135
|
+
"modelId": "deepseek-r1:8b",
|
|
136
|
+
"reasoning": { "mode": "on", "effort": "medium" }
|
|
137
|
+
}
|
|
86
138
|
}
|
|
87
139
|
}
|
|
88
140
|
}
|
|
89
141
|
}
|
|
90
142
|
```
|
|
91
143
|
|
|
144
|
+
After exporting the API-key variables used by the providers you configured, validate each profile before its first real commit:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
export MINIMAX_API_KEY='your-minimax-api-key'
|
|
148
|
+
export DEEPSEEK_API_KEY='your-deepseek-api-key'
|
|
149
|
+
export OPENAI_API_KEY='your-openai-api-key'
|
|
150
|
+
export OPENROUTER_API_KEY='your-openrouter-api-key'
|
|
151
|
+
|
|
152
|
+
aicommit doctor -p minimax -m default
|
|
153
|
+
aicommit doctor -p deepseek -m reasoner
|
|
154
|
+
aicommit doctor -p openai -m reasoner
|
|
155
|
+
aicommit doctor -p openrouter -m quality
|
|
156
|
+
aicommit doctor -p ollama -m qwen
|
|
157
|
+
|
|
158
|
+
aicommit -p minimax
|
|
159
|
+
aicommit -p deepseek -m reasoner
|
|
160
|
+
```
|
|
161
|
+
|
|
92
162
|
`schemaVersion`, `defaultProvider`, `providers`, and every provider's `providerType`, `apiUrl`, `defaultModel`, and non-empty `models` map are required. Without `-p`, AICommit selects `defaultProvider`; without `-m`, it selects that provider's `defaultModel`. Model profiles inherit global generation settings and provider connection settings, then may override `temperature`, `maxTokens`, `timeoutMs`, `reasoning`, and `extraBody`. Provider and model names are stable local aliases; `modelId` is the identifier sent to the API.
|
|
93
163
|
|
|
94
164
|
This is the only supported user-config shape. Earlier flat or provider-level `modelId` configurations are rejected; run `aicommit setup` or migrate them explicitly.
|
|
@@ -123,44 +193,10 @@ This is the only supported user-config shape. Earlier flat or provider-level `mo
|
|
|
123
193
|
| `stripFiles` | Extra files to stub out of the diff like lock files, matched by basename with `*`/`?` wildcards, e.g. `["*.min.js", "*.map", "*.snap"]` (default: `[]`; project-level entries are merged with user-level ones, not replaced) |
|
|
124
194
|
| `regenerateWithDiff` | `true` re-sends the full diff on every regenerate for more varied rewrites; `false` (default) only asks the model to reword its previous message, which is far cheaper |
|
|
125
195
|
| `extraBody` | Model-profile JSON fields merged into the request body, except `model`/`messages` (default: `{}`) |
|
|
126
|
-
| `reasoning` | Global or model-profile reasoning controls: `mode
|
|
196
|
+
| `reasoning` | Global or model-profile reasoning controls: `mode` / `effort` (default: `on` / `medium`), `maxTokens`, and `maxDisplayChars` |
|
|
127
197
|
|
|
128
198
|
Works with OpenAI, DeepSeek, [OpenRouter](https://openrouter.ai), MiniMax, [Kimi Code](https://www.kimi.com/code/docs/), Ollama (native `/api/chat` or OpenAI-compatible `/v1/chat/completions`), LiteLLM, and other compatible endpoints. HTTPS is required for remote endpoints; plaintext HTTP is accepted only for localhost/loopback.
|
|
129
199
|
|
|
130
|
-
### Kimi Code example
|
|
131
|
-
|
|
132
|
-
Create an API key in the Kimi Code console, export it without storing the secret in JSON, and select the bundled preset in `aicommit setup`. For a manual configuration, use the OpenAI-compatible full endpoint:
|
|
133
|
-
|
|
134
|
-
```bash
|
|
135
|
-
export KIMI_API_KEY='your-kimi-code-api-key'
|
|
136
|
-
```
|
|
137
|
-
|
|
138
|
-
```json
|
|
139
|
-
{
|
|
140
|
-
"schemaVersion": 1,
|
|
141
|
-
"defaultProvider": "kimi-code",
|
|
142
|
-
"providers": {
|
|
143
|
-
"kimi-code": {
|
|
144
|
-
"providerType": "custom",
|
|
145
|
-
"apiUrl": "https://api.kimi.com/coding/v1/chat/completions",
|
|
146
|
-
"apiKeyEnv": "KIMI_API_KEY",
|
|
147
|
-
"defaultModel": "default",
|
|
148
|
-
"models": {
|
|
149
|
-
"default": { "modelId": "kimi-for-coding" }
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
```
|
|
155
|
-
|
|
156
|
-
Verify the endpoint, key, and model before the first commit:
|
|
157
|
-
|
|
158
|
-
```bash
|
|
159
|
-
aicommit doctor -p kimi-code
|
|
160
|
-
```
|
|
161
|
-
|
|
162
|
-
`kimi-for-coding` is available to all Kimi Code membership tiers and follows the service's rolling model upgrades. Kimi Code membership keys use `api.kimi.com`; Kimi Platform pay-as-you-go keys use a different endpoint and are not interchangeable.
|
|
163
|
-
|
|
164
200
|
See the bilingual [provider compatibility table](docs/provider-compatibility.md) for streaming, reasoning, token-budget, usage, and authentication boundaries.
|
|
165
201
|
|
|
166
202
|
### Repository policy and bounded context
|
|
@@ -415,7 +451,7 @@ When reasoning mode is `on` (including via `--reasoning=<level>`), aicommit requ
|
|
|
415
451
|
|
|
416
452
|
`aicommit split` (also available as the explicit `aicommit split run`) asks whether to group the staged index snapshot or all staged, unstaged, and untracked changes into file-level logical commits. Use `--scope=staged` or `--scope=all` when the boundary must be explicit, including every non-interactive run. You can review the plan, regenerate messages for selected groups, or edit the plan as JSON before committing. Sensitive-content detection fails closed before a non-interactive provider request or automatic staging.
|
|
417
453
|
|
|
418
|
-
For an auditable two-step flow, `aicommit split plan --scope=staged|all --file=<path>` exports a versioned JSON artifact, and `aicommit split apply --file=<path>` rechecks its base commit, change set, and content fingerprint before touching the index. Keep plan files outside the worktree or under `.git
|
|
454
|
+
For an auditable two-step flow, `aicommit split plan --scope=staged|all --file=<path>` exports a versioned JSON artifact, and `aicommit split apply --file=<path>` rechecks its base commit, change set, and content fingerprint before touching the index. Keep plan files outside the worktree or under the dedicated `.git/aicommit/` directory so they cannot become part of their own plan. Export never overwrites an existing destination.
|
|
419
455
|
|
|
420
456
|
Execution uses temporary indexes and a code-free checkpoint under `.git/aicommit`. A hook, Git error, interruption, or crash leaves completed commits in history and preserves the pending snapshot; the failure report shows checkpointed, in-flight, pending, and current worktree/index state. Resolve the cause and run `aicommit split resume`. Resume reconciles the possible post-commit crash window before creating anything else, so a completed group is neither duplicated nor omitted. If you intentionally finished or replaced the interrupted work through another Git workflow, run `aicommit split abort`; it removes only the stale checkpoint and never rewrites HEAD, the index, or the worktree. New committing split runs detect a checkpoint before contacting the provider. If planning or preflight fails before the first group, no split commit is created and the real index remains unchanged.
|
|
421
457
|
|
package/README.zh-CN.md
CHANGED
|
@@ -48,6 +48,8 @@ aicommit setup
|
|
|
48
48
|
|
|
49
49
|
向导会引导你选择内置 Provider 默认值(OpenAI、DeepSeek、OpenRouter、MiniMax、Kimi Code 和 Ollama),或填写自定义 OpenAI 兼容端点;随后输入 API Key 和一个或多个模型、选择默认模型和提交信息语言,并可选测试连接。配置会原子写入用户配置文件 `~/.aicommit/config.json`;如果已有文件格式错误或属于旧格式,替换前会先备份。旧路径 `~/.aicommit.config.json` 仍可读取,运行 `aicommit setup` 并保存时会把其中设置迁移到规范路径。
|
|
50
50
|
|
|
51
|
+
模型步骤会为每个配置单独设置推理。`auto` 不发送显式推理开关,交给 Provider 使用默认行为;`on` 请求推理并继续选择强度;`off` 在模型支持该开关时显式关闭推理。强度默认为 `medium`。对于已知的 OpenAI 模型,setup 会过滤不支持的模式和强度选项;无法关闭推理时也不会列出 `off`。编辑已有模型时,向导会回填其中仍有效的保存值;模型级未配置时,则在适用情况下回填全局推理设置,不再受支持的旧模式会回退到 `auto`。
|
|
52
|
+
|
|
51
53
|
如需手动配置,请从 [.aicommit.config.example.json](.aicommit.config.example.json) 开始。AICommit 先加载用户配置,再将项目配置 `./.aicommit.config.json` 中白名单内的生成偏好深度合并到用户配置之上。项目配置可以设置 `language`、`commitPolicy`、`stripFiles`、`temperature`,也可以降低 diff、token、timeout 或仓库上下文上限。项目拥有的 `prompt` 默认会被忽略,除非用户配置明确设置 `allowProjectPrompt: true`。连接或 Provider 字段(包括 `apiKeyEnv`)、推理请求控制、未知字段,以及任何试图提高上限的配置,都会被忽略并给出警告。这样可以防止克隆的仓库重定向已鉴权请求,或在不知情的情况下扩大成本和数据范围。
|
|
52
54
|
|
|
53
55
|
如需避免把密钥写入 JSON,请设置 `"apiKeyEnv": "OPENAI_API_KEY"`(并将 `apiKey` 留空),或在 setup 向导中输入 `env:OPENAI_API_KEY`。环境变量优先于所有其他凭据来源,推荐用于 CI 和其他无状态环境。
|
|
@@ -70,10 +72,7 @@ AICommit 也可以读取操作系统上已经配置的 Git credential helper。
|
|
|
70
72
|
"default": {
|
|
71
73
|
"label": "MiniMax M3",
|
|
72
74
|
"modelId": "MiniMax-M3",
|
|
73
|
-
"
|
|
74
|
-
"thinking": { "type": "disabled" },
|
|
75
|
-
"reasoning_split": true
|
|
76
|
-
}
|
|
75
|
+
"reasoning": { "mode": "on", "effort": "medium" }
|
|
77
76
|
}
|
|
78
77
|
}
|
|
79
78
|
},
|
|
@@ -83,14 +82,85 @@ AICommit 也可以读取操作系统上已经配置的 Git credential helper。
|
|
|
83
82
|
"apiKeyEnv": "DEEPSEEK_API_KEY",
|
|
84
83
|
"defaultModel": "chat",
|
|
85
84
|
"models": {
|
|
86
|
-
"chat": {
|
|
87
|
-
|
|
85
|
+
"chat": {
|
|
86
|
+
"modelId": "deepseek-v4-flash",
|
|
87
|
+
"reasoning": { "mode": "on", "effort": "medium" }
|
|
88
|
+
},
|
|
89
|
+
"reasoner": {
|
|
90
|
+
"modelId": "deepseek-v4-pro",
|
|
91
|
+
"reasoning": { "mode": "on", "effort": "high" }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
"openai": {
|
|
96
|
+
"providerType": "openai",
|
|
97
|
+
"apiUrl": "https://api.openai.com/v1/chat/completions",
|
|
98
|
+
"apiKeyEnv": "OPENAI_API_KEY",
|
|
99
|
+
"defaultModel": "fast",
|
|
100
|
+
"models": {
|
|
101
|
+
"fast": {
|
|
102
|
+
"modelId": "gpt-4o",
|
|
103
|
+
"reasoning": { "mode": "auto" }
|
|
104
|
+
},
|
|
105
|
+
"reasoner": {
|
|
106
|
+
"modelId": "gpt-5.6-sol",
|
|
107
|
+
"reasoning": { "mode": "on", "effort": "medium" }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
"openrouter": {
|
|
112
|
+
"providerType": "openrouter",
|
|
113
|
+
"apiUrl": "https://openrouter.ai/api/v1/chat/completions",
|
|
114
|
+
"apiKeyEnv": "OPENROUTER_API_KEY",
|
|
115
|
+
"defaultModel": "auto",
|
|
116
|
+
"models": {
|
|
117
|
+
"auto": {
|
|
118
|
+
"modelId": "openrouter/auto",
|
|
119
|
+
"reasoning": { "mode": "auto" }
|
|
120
|
+
},
|
|
121
|
+
"quality": {
|
|
122
|
+
"modelId": "openai/gpt-5.6-terra",
|
|
123
|
+
"reasoning": { "mode": "on", "effort": "high" }
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
"ollama": {
|
|
128
|
+
"providerType": "ollama",
|
|
129
|
+
"apiUrl": "http://127.0.0.1:11434/api/chat",
|
|
130
|
+
"defaultModel": "qwen",
|
|
131
|
+
"models": {
|
|
132
|
+
"qwen": {
|
|
133
|
+
"modelId": "qwen3:8b",
|
|
134
|
+
"reasoning": { "mode": "on", "effort": "medium" }
|
|
135
|
+
},
|
|
136
|
+
"deepseek": {
|
|
137
|
+
"modelId": "deepseek-r1:8b",
|
|
138
|
+
"reasoning": { "mode": "on", "effort": "medium" }
|
|
139
|
+
}
|
|
88
140
|
}
|
|
89
141
|
}
|
|
90
142
|
}
|
|
91
143
|
}
|
|
92
144
|
```
|
|
93
145
|
|
|
146
|
+
导出所配置 Provider 使用的 API Key 环境变量后,建议在第一次真实提交前逐个验证模型配置:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
export MINIMAX_API_KEY='your-minimax-api-key'
|
|
150
|
+
export DEEPSEEK_API_KEY='your-deepseek-api-key'
|
|
151
|
+
export OPENAI_API_KEY='your-openai-api-key'
|
|
152
|
+
export OPENROUTER_API_KEY='your-openrouter-api-key'
|
|
153
|
+
|
|
154
|
+
aicommit doctor -p minimax -m default
|
|
155
|
+
aicommit doctor -p deepseek -m reasoner
|
|
156
|
+
aicommit doctor -p openai -m reasoner
|
|
157
|
+
aicommit doctor -p openrouter -m quality
|
|
158
|
+
aicommit doctor -p ollama -m qwen
|
|
159
|
+
|
|
160
|
+
aicommit -p minimax
|
|
161
|
+
aicommit -p deepseek -m reasoner
|
|
162
|
+
```
|
|
163
|
+
|
|
94
164
|
`schemaVersion`、`defaultProvider`、`providers`,以及每个 Provider 的 `providerType`、`apiUrl`、`defaultModel` 和非空 `models` 都是必填项。未指定 `-p` 时选择 `defaultProvider`;未指定 `-m` 时选择该 Provider 的 `defaultModel`。模型配置会继承全局生成设置和 Provider 连接设置,并可覆盖 `temperature`、`maxTokens`、`timeoutMs`、`reasoning` 与 `extraBody`。Provider 名和模型名是稳定的本地别名,`modelId` 才是发送给 API 的模型标识。
|
|
95
165
|
|
|
96
166
|
这是唯一支持的用户配置格式。旧版扁平配置或 Provider 级 `modelId` 会被直接拒绝;请运行 `aicommit setup` 或显式迁移。
|
|
@@ -125,44 +195,10 @@ AICommit 也可以读取操作系统上已经配置的 Git credential helper。
|
|
|
125
195
|
| `stripFiles` | 额外替换为占位的文件,按 basename 使用 `*` / `?` 通配,如 `["*.min.js", "*.map", "*.snap"]`(默认:`[]`;项目项与用户项合并而非覆盖) |
|
|
126
196
|
| `regenerateWithDiff` | `true` 表示每次重写都重发完整 diff,以获得更多变化;`false`(默认)只要求模型改写上一条消息,成本更低 |
|
|
127
197
|
| `extraBody` | 模型配置中合并到请求体的 JSON 字段,但不允许覆盖 `model` / `messages`(默认:`{}`) |
|
|
128
|
-
| `reasoning` | 全局或模型级推理控制:`mode
|
|
198
|
+
| `reasoning` | 全局或模型级推理控制:`mode` / `effort`(默认:`on` / `medium`)、`maxTokens` 和 `maxDisplayChars` |
|
|
129
199
|
|
|
130
200
|
AICommit 支持 OpenAI、DeepSeek、[OpenRouter](https://openrouter.ai)、MiniMax、[Kimi Code](https://www.kimi.com/code/docs/)、Ollama(原生 `/api/chat` 或 OpenAI 兼容 `/v1/chat/completions`)、LiteLLM,以及其他兼容端点。远程端点必须使用 HTTPS;明文 HTTP 只允许 localhost / loopback。
|
|
131
201
|
|
|
132
|
-
### Kimi Code 示例
|
|
133
|
-
|
|
134
|
-
在 Kimi Code 控制台创建 API Key,通过环境变量导出,避免把密钥存入 JSON,然后在 `aicommit setup` 中选择内置预设。手动配置时请使用完整的 OpenAI 兼容端点:
|
|
135
|
-
|
|
136
|
-
```bash
|
|
137
|
-
export KIMI_API_KEY='your-kimi-code-api-key'
|
|
138
|
-
```
|
|
139
|
-
|
|
140
|
-
```json
|
|
141
|
-
{
|
|
142
|
-
"schemaVersion": 1,
|
|
143
|
-
"defaultProvider": "kimi-code",
|
|
144
|
-
"providers": {
|
|
145
|
-
"kimi-code": {
|
|
146
|
-
"providerType": "custom",
|
|
147
|
-
"apiUrl": "https://api.kimi.com/coding/v1/chat/completions",
|
|
148
|
-
"apiKeyEnv": "KIMI_API_KEY",
|
|
149
|
-
"defaultModel": "default",
|
|
150
|
-
"models": {
|
|
151
|
-
"default": { "modelId": "kimi-for-coding" }
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
```
|
|
157
|
-
|
|
158
|
-
首次提交前验证端点、Key 和模型:
|
|
159
|
-
|
|
160
|
-
```bash
|
|
161
|
-
aicommit doctor -p kimi-code
|
|
162
|
-
```
|
|
163
|
-
|
|
164
|
-
`kimi-for-coding` 对所有 Kimi Code 会员档位开放,并随服务滚动升级模型。Kimi Code 会员 Key 使用 `api.kimi.com`;Kimi 开放平台按量付费 Key 使用不同端点,两者不能混用。
|
|
165
|
-
|
|
166
202
|
流式输出、推理、token 预算、usage 和鉴权边界,请参阅双语 [Provider 兼容表](docs/provider-compatibility.md)。
|
|
167
203
|
|
|
168
204
|
### 仓库策略与受限上下文
|
|
@@ -417,7 +453,7 @@ exec zsh
|
|
|
417
453
|
|
|
418
454
|
`aicommit split`(也可以显式写成 `aicommit split run`)会询问是对暂存 index 快照分组,还是对全部已暂存、未暂存和未跟踪变更做文件级分组。边界必须明确时请使用 `--scope=staged` 或 `--scope=all`,所有非交互运行都应显式指定范围。提交前可以审阅计划、为选中的组重新生成消息,或直接编辑 JSON 计划。敏感内容检测会在非交互 Provider 请求或自动暂存前 fail closed。
|
|
419
455
|
|
|
420
|
-
如需可审计的两步流程,使用 `aicommit split plan --scope=staged|all --file=<path>` 导出版本化 JSON 工件,再用 `aicommit split apply --file=<path>` 在接触 index 前重新校验 base commit
|
|
456
|
+
如需可审计的两步流程,使用 `aicommit split plan --scope=staged|all --file=<path>` 导出版本化 JSON 工件,再用 `aicommit split apply --file=<path>` 在接触 index 前重新校验 base commit、变更集和内容指纹。计划文件应保存在工作区之外或专用的 `.git/aicommit/` 目录下,避免被纳入自身计划;导出不会覆盖已有目标文件。
|
|
421
457
|
|
|
422
458
|
执行过程使用临时 index,并在 `.git/aicommit` 下保存不含代码内容的 checkpoint。hook、Git 错误、中断或崩溃发生后,已完成提交仍保留在历史中,待处理快照也会保留;失败报告会显示已 checkpoint、执行中、待处理,以及当前工作区 / index 状态。解决问题后运行 `aicommit split resume`。恢复流程会先协调“提交完成后崩溃”的可能窗口,再创建任何新提交,因此不会重复或遗漏已完成分组。如果你通过其他 Git 流程有意完成或替换了中断工作,请运行 `aicommit split abort`;它只删除过期 checkpoint,绝不会改写 HEAD、index 或工作区。新的 split 提交流程会在联系 Provider 前检测现有 checkpoint。如果规划或预检在第一组之前失败,不会创建任何 split 提交,真实 index 也保持不变。
|
|
423
459
|
|
package/docs/distribution.md
CHANGED
|
@@ -26,7 +26,7 @@ The release workflow uses npm Trusted Publishing without a long-lived `NPM_TOKEN
|
|
|
26
26
|
```bash
|
|
27
27
|
workdir=$(mktemp -d)
|
|
28
28
|
cd "$workdir"
|
|
29
|
-
npm install --package-lock-only @hifullmoon/aicommit@2.2.
|
|
29
|
+
npm install --package-lock-only @hifullmoon/aicommit@2.2.2
|
|
30
30
|
npm audit signatures
|
|
31
31
|
```
|
|
32
32
|
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -3,6 +3,7 @@ import { createRequire } from 'node:module';
|
|
|
3
3
|
import chalk from 'chalk';
|
|
4
4
|
|
|
5
5
|
import { ERROR_CATEGORIES, fail } from './errors.js';
|
|
6
|
+
import { REASONING_EFFORTS } from './providers.js';
|
|
6
7
|
|
|
7
8
|
const _require = createRequire(import.meta.url);
|
|
8
9
|
const { version: VERSION } = _require('../package.json');
|
|
@@ -364,7 +365,7 @@ export function parseArgs(args = process.argv.slice(2)) {
|
|
|
364
365
|
}
|
|
365
366
|
}
|
|
366
367
|
|
|
367
|
-
const reasoningLevels = ['off',
|
|
368
|
+
const reasoningLevels = ['off', ...REASONING_EFFORTS];
|
|
368
369
|
if (cliReasoning && !reasoningLevels.includes(cliReasoning)) {
|
|
369
370
|
throw fail(
|
|
370
371
|
ERROR_CATEGORIES.CONFIG,
|
package/src/completion.js
CHANGED
|
@@ -46,7 +46,9 @@ complete -F _aicommit aicommit
|
|
|
46
46
|
|
|
47
47
|
const ZSH = `#compdef aicommit
|
|
48
48
|
_aicommit() {
|
|
49
|
+
local curcontext="$curcontext" state state_descr line
|
|
49
50
|
local -a commands options
|
|
51
|
+
typeset -A opt_args
|
|
50
52
|
commands=(
|
|
51
53
|
'setup:interactive configuration wizard'
|
|
52
54
|
'doctor:diagnose configuration and connectivity'
|
|
@@ -71,15 +73,17 @@ _aicommit() {
|
|
|
71
73
|
'--output=[output mode]:output:(text json)'
|
|
72
74
|
'--debug[show debug details]'
|
|
73
75
|
)
|
|
74
|
-
_arguments -C $options '*::argument:->args'
|
|
76
|
+
_arguments -C $options '1:command:->command' '*::argument:->args'
|
|
75
77
|
case $state in
|
|
78
|
+
command)
|
|
79
|
+
_describe 'command' commands
|
|
80
|
+
;;
|
|
76
81
|
args)
|
|
77
|
-
case $
|
|
82
|
+
case $line[1] in
|
|
78
83
|
config) _values 'config action' show validate path ;;
|
|
79
84
|
policy) _values 'policy action' template check ;;
|
|
80
85
|
completion) _values 'shell' bash zsh fish ;;
|
|
81
86
|
split) _values 'split action' run plan apply resume abort ;;
|
|
82
|
-
*) _describe 'command' commands ;;
|
|
83
87
|
esac
|
|
84
88
|
;;
|
|
85
89
|
esac
|
package/src/config.js
CHANGED
|
@@ -14,7 +14,12 @@ import {
|
|
|
14
14
|
validateRepositoryContextConfig,
|
|
15
15
|
} from './context.js';
|
|
16
16
|
import { readTeamPolicy } from './team-policy.js';
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
DEFAULT_REASONING_EFFORT,
|
|
19
|
+
isProviderType,
|
|
20
|
+
PROVIDER_TYPES,
|
|
21
|
+
REASONING_EFFORTS,
|
|
22
|
+
} from './providers.js';
|
|
18
23
|
import { projectConfigPath, resolveConfigLocations, userConfigLocations } from './config-paths.js';
|
|
19
24
|
|
|
20
25
|
// Repository-owned config is untrusted input: a cloned repository must never
|
|
@@ -173,7 +178,7 @@ export const DEFAULT_CONFIG = {
|
|
|
173
178
|
// switch.
|
|
174
179
|
reasoning: {
|
|
175
180
|
mode: 'on',
|
|
176
|
-
effort:
|
|
181
|
+
effort: DEFAULT_REASONING_EFFORT,
|
|
177
182
|
maxTokens: 4096,
|
|
178
183
|
maxDisplayChars: 12000,
|
|
179
184
|
},
|
|
@@ -508,10 +513,8 @@ export function validateConfig(config) {
|
|
|
508
513
|
if (!['auto', 'on', 'off'].includes(config.reasoning.mode)) {
|
|
509
514
|
throw new Error('Invalid config "reasoning.mode": expected "auto", "on", or "off".');
|
|
510
515
|
}
|
|
511
|
-
if (!
|
|
512
|
-
throw new Error(
|
|
513
|
-
'Invalid config "reasoning.effort": expected low, medium, high, xhigh, or max.',
|
|
514
|
-
);
|
|
516
|
+
if (!REASONING_EFFORTS.includes(config.reasoning.effort)) {
|
|
517
|
+
throw new Error(`Invalid config "reasoning.effort": expected ${REASONING_EFFORTS.join(', ')}.`);
|
|
515
518
|
}
|
|
516
519
|
for (const key of ['enabledBody', 'disabledBody']) {
|
|
517
520
|
const value = config.reasoning[key];
|
package/src/context.js
CHANGED
|
@@ -381,20 +381,22 @@ function quotedValues(text) {
|
|
|
381
381
|
|
|
382
382
|
function enumRule(text, name) {
|
|
383
383
|
const index = text.search(new RegExp(`['"]?${name}['"]?\\s*:`));
|
|
384
|
-
if (index < 0) return
|
|
384
|
+
if (index < 0) return null;
|
|
385
385
|
const window = text.slice(index, index + 3000);
|
|
386
386
|
const nested = window.match(
|
|
387
|
-
/\[\s*[012]\s*,\s*['"](
|
|
387
|
+
/\[\s*([012])\s*,\s*['"](always|never)['"]\s*,\s*\[([\s\S]*?)\]\s*\]/i,
|
|
388
388
|
);
|
|
389
|
-
|
|
389
|
+
if (!nested || nested[1] === '0') return null;
|
|
390
|
+
return { condition: nested[2].toLowerCase(), values: quotedValues(nested[3]) };
|
|
390
391
|
}
|
|
391
392
|
|
|
392
393
|
function numberRule(text, name) {
|
|
393
394
|
const index = text.search(new RegExp(`['"]?${name}['"]?\\s*:`));
|
|
394
395
|
if (index < 0) return null;
|
|
395
396
|
const window = text.slice(index, index + 500);
|
|
396
|
-
const match = window.match(/\[\s*[012]\s*,\s*['"](
|
|
397
|
-
|
|
397
|
+
const match = window.match(/\[\s*([012])\s*,\s*['"](always|never)['"]\s*,\s*(\d{1,3})\s*\]/i);
|
|
398
|
+
if (!match || match[1] === '0') return null;
|
|
399
|
+
return { condition: match[2].toLowerCase(), value: Number(match[3]) };
|
|
398
400
|
}
|
|
399
401
|
|
|
400
402
|
export function detectCommitlintConstraints(
|
|
@@ -410,22 +412,53 @@ export function detectCommitlintConstraints(
|
|
|
410
412
|
) {
|
|
411
413
|
continue;
|
|
412
414
|
}
|
|
413
|
-
const
|
|
414
|
-
const
|
|
415
|
-
const
|
|
416
|
-
const
|
|
417
|
-
|
|
415
|
+
const typeRule = enumRule(text, 'type-enum');
|
|
416
|
+
const scopeRule = enumRule(text, 'scope-enum');
|
|
417
|
+
const subjectLengthRule = numberRule(text, 'subject-max-length');
|
|
418
|
+
const headerLengthRule = numberRule(text, 'header-max-length');
|
|
419
|
+
const validTypes = (typeRule?.values || []).filter((value) => /^[a-z][a-z0-9-]*$/.test(value));
|
|
420
|
+
const validScopes = (scopeRule?.values || []).filter((value) => /^[a-z0-9._/-]+$/i.test(value));
|
|
421
|
+
const types = typeRule?.condition === 'always' ? validTypes : [];
|
|
422
|
+
const disallowedTypes = typeRule?.condition === 'never' ? validTypes : [];
|
|
423
|
+
const scopes = scopeRule?.condition === 'always' ? validScopes : [];
|
|
424
|
+
const disallowedScopes = scopeRule?.condition === 'never' ? validScopes : [];
|
|
425
|
+
const subjectMaxLength =
|
|
426
|
+
subjectLengthRule?.condition === 'always' ? subjectLengthRule.value : null;
|
|
427
|
+
const headerMaxLength =
|
|
428
|
+
headerLengthRule?.condition === 'always' ? headerLengthRule.value : null;
|
|
429
|
+
const unsupported = [];
|
|
430
|
+
if (subjectLengthRule?.condition === 'never') unsupported.push('subject-max-length: never');
|
|
431
|
+
if (headerLengthRule?.condition === 'never') unsupported.push('header-max-length: never');
|
|
432
|
+
if (
|
|
433
|
+
!types.length &&
|
|
434
|
+
!disallowedTypes.length &&
|
|
435
|
+
!scopes.length &&
|
|
436
|
+
!disallowedScopes.length &&
|
|
437
|
+
!subjectMaxLength &&
|
|
438
|
+
!headerMaxLength &&
|
|
439
|
+
!unsupported.length
|
|
440
|
+
) {
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
418
443
|
const lines = [`Detected commitlint constraints from ${path}:`];
|
|
419
444
|
if (types.length) lines.push(`- allowed types: ${types.join(', ')}`);
|
|
445
|
+
if (disallowedTypes.length) lines.push(`- disallowed types: ${disallowedTypes.join(', ')}`);
|
|
420
446
|
if (scopes.length) lines.push(`- allowed scopes: ${scopes.join(', ')}`);
|
|
447
|
+
if (disallowedScopes.length) {
|
|
448
|
+
lines.push(`- disallowed scopes: ${disallowedScopes.join(', ')}`);
|
|
449
|
+
}
|
|
421
450
|
if (subjectMaxLength) lines.push(`- subject max length: ${subjectMaxLength}`);
|
|
422
451
|
if (headerMaxLength) lines.push(`- header max length: ${headerMaxLength}`);
|
|
452
|
+
if (unsupported.length) lines.push(`- unsupported inverted rules: ${unsupported.join(', ')}`);
|
|
423
453
|
return {
|
|
424
454
|
path,
|
|
425
455
|
types,
|
|
456
|
+
disallowedTypes,
|
|
426
457
|
scopes,
|
|
458
|
+
disallowedScopes,
|
|
427
459
|
subjectMaxLength,
|
|
428
460
|
headerMaxLength,
|
|
461
|
+
unsupported,
|
|
429
462
|
text: truncate(lines.join('\n'), settings.maxChars),
|
|
430
463
|
};
|
|
431
464
|
}
|
|
@@ -436,17 +469,72 @@ export function applyCommitlintPolicy(commitPolicy, constraints, fallbackLanguag
|
|
|
436
469
|
if (!constraints) return commitPolicy;
|
|
437
470
|
const current = normalizeCommitPolicy(commitPolicy, fallbackLanguage);
|
|
438
471
|
const patch = {};
|
|
439
|
-
if (constraints.
|
|
472
|
+
if (constraints.unsupported?.length) {
|
|
473
|
+
throw new Error(
|
|
474
|
+
`Invalid config: unsupported inverted commitlint rules: ${constraints.unsupported.join(', ')}.`,
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
let types = [...current.types];
|
|
479
|
+
if (constraints.types?.length) {
|
|
480
|
+
types = types.filter((type) => constraints.types.includes(type));
|
|
481
|
+
}
|
|
482
|
+
if (constraints.disallowedTypes?.length) {
|
|
483
|
+
types = types.filter((type) => !constraints.disallowedTypes.includes(type));
|
|
484
|
+
}
|
|
485
|
+
if (!types.length) {
|
|
486
|
+
throw new Error('Invalid config: commitPolicy and commitlint allow no common commit types.');
|
|
487
|
+
}
|
|
488
|
+
if (types.length !== current.types.length) patch.types = types;
|
|
489
|
+
|
|
490
|
+
let scopeMode = current.scope.mode;
|
|
491
|
+
let scopes = [...current.scope.values];
|
|
492
|
+
const disallowedScopes = new Set(current.scope.disallowedValues || []);
|
|
493
|
+
const hasFiniteScopeAllowlist = Boolean(
|
|
494
|
+
current.scope.values.length || constraints.scopes?.length,
|
|
495
|
+
);
|
|
440
496
|
if (constraints.scopes?.length) {
|
|
441
|
-
|
|
497
|
+
scopes = scopes.length
|
|
498
|
+
? scopes.filter((scope) => constraints.scopes.includes(scope))
|
|
499
|
+
: [...constraints.scopes];
|
|
500
|
+
}
|
|
501
|
+
for (const scope of constraints.disallowedScopes || []) disallowedScopes.add(scope);
|
|
502
|
+
if (scopes.length) scopes = scopes.filter((scope) => !disallowedScopes.has(scope));
|
|
503
|
+
if (!scopes.length && hasFiniteScopeAllowlist) {
|
|
504
|
+
if (scopeMode === 'required') {
|
|
505
|
+
throw new Error('Invalid config: commitPolicy and commitlint allow no common scopes.');
|
|
506
|
+
}
|
|
507
|
+
scopeMode = 'forbidden';
|
|
508
|
+
disallowedScopes.clear();
|
|
509
|
+
}
|
|
510
|
+
if (
|
|
511
|
+
scopeMode !== current.scope.mode ||
|
|
512
|
+
JSON.stringify(scopes) !== JSON.stringify(current.scope.values) ||
|
|
513
|
+
JSON.stringify([...disallowedScopes]) !== JSON.stringify(current.scope.disallowedValues || [])
|
|
514
|
+
) {
|
|
515
|
+
const nextScope = {
|
|
442
516
|
...current.scope,
|
|
443
|
-
|
|
517
|
+
mode: scopeMode,
|
|
518
|
+
values: scopes,
|
|
519
|
+
...(disallowedScopes.size ? { disallowedValues: [...disallowedScopes] } : {}),
|
|
444
520
|
};
|
|
521
|
+
if (!disallowedScopes.size) delete nextScope.disallowedValues;
|
|
522
|
+
patch.scope = nextScope;
|
|
445
523
|
}
|
|
446
|
-
if (constraints.subjectMaxLength) {
|
|
524
|
+
if (constraints.subjectMaxLength || constraints.headerMaxLength) {
|
|
447
525
|
patch.subject = {
|
|
448
526
|
...current.subject,
|
|
449
|
-
maxLength:
|
|
527
|
+
maxLength: constraints.subjectMaxLength
|
|
528
|
+
? Math.min(current.subject.maxLength, constraints.subjectMaxLength)
|
|
529
|
+
: current.subject.maxLength,
|
|
530
|
+
...(constraints.headerMaxLength
|
|
531
|
+
? {
|
|
532
|
+
headerMaxLength: Math.min(
|
|
533
|
+
current.subject.headerMaxLength ?? Infinity,
|
|
534
|
+
constraints.headerMaxLength,
|
|
535
|
+
),
|
|
536
|
+
}
|
|
537
|
+
: {}),
|
|
450
538
|
};
|
|
451
539
|
}
|
|
452
540
|
const merged = mergeCommitPolicy(current, patch);
|
package/src/git.js
CHANGED
|
@@ -336,7 +336,7 @@ const SENSITIVE_FILE_RE =
|
|
|
336
336
|
const PRIVATE_KEY_RE = /-----BEGIN (?:[A-Z0-9 ]+ )?PRIVATE KEY-----/i;
|
|
337
337
|
const AWS_KEY_RE = /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g;
|
|
338
338
|
const ASSIGNED_SECRET_RE =
|
|
339
|
-
/(\b(?:api[_-]?key|access[_-]?token|auth(?:orization)?|client[_-]?secret|password|passwd|secret|token)\b
|
|
339
|
+
/(\b(?:api[_-]?key|access[_-]?token|auth(?:orization)?|client[_-]?secret|password|passwd|secret|token)\b["']?\s*[:=]\s*["']?)([^\s,"'}]{8,})/gi;
|
|
340
340
|
|
|
341
341
|
export function isSensitiveFile(path) {
|
|
342
342
|
const normalized = String(path || '').replace(/\\/g, '/');
|
package/src/policy-command.js
CHANGED
|
@@ -21,8 +21,19 @@ function normalizedPolicyDocument(policy) {
|
|
|
21
21
|
return {
|
|
22
22
|
version: policy.version,
|
|
23
23
|
types: [...policy.types],
|
|
24
|
-
scope: {
|
|
25
|
-
|
|
24
|
+
scope: {
|
|
25
|
+
mode: policy.scope.mode,
|
|
26
|
+
values: [...policy.scope.values],
|
|
27
|
+
...(policy.scope.disallowedValues?.length
|
|
28
|
+
? { disallowedValues: [...policy.scope.disallowedValues] }
|
|
29
|
+
: {}),
|
|
30
|
+
},
|
|
31
|
+
subject: {
|
|
32
|
+
maxLength: policy.subject.maxLength,
|
|
33
|
+
...(policy.subject.headerMaxLength
|
|
34
|
+
? { headerMaxLength: policy.subject.headerMaxLength }
|
|
35
|
+
: {}),
|
|
36
|
+
},
|
|
26
37
|
body: { mode: policy.body.mode, maxLines: policy.body.maxLines },
|
|
27
38
|
breakingChange: policy.breakingChange,
|
|
28
39
|
language: policy.language,
|
package/src/policy.js
CHANGED
|
@@ -46,7 +46,13 @@ function clonePolicy(policy) {
|
|
|
46
46
|
return {
|
|
47
47
|
...policy,
|
|
48
48
|
types: [...policy.types],
|
|
49
|
-
scope: {
|
|
49
|
+
scope: {
|
|
50
|
+
...policy.scope,
|
|
51
|
+
values: [...policy.scope.values],
|
|
52
|
+
...(policy.scope.disallowedValues
|
|
53
|
+
? { disallowedValues: [...policy.scope.disallowedValues] }
|
|
54
|
+
: {}),
|
|
55
|
+
},
|
|
50
56
|
subject: { ...policy.subject },
|
|
51
57
|
body: { ...policy.body },
|
|
52
58
|
};
|
|
@@ -69,6 +75,14 @@ export function mergeCommitPolicy(base = DEFAULT_COMMIT_POLICY, override = {}) {
|
|
|
69
75
|
: base.scope.values,
|
|
70
76
|
}
|
|
71
77
|
: override.scope;
|
|
78
|
+
if (object(scope)) {
|
|
79
|
+
if (Array.isArray(scope.values)) scope.values = [...scope.values];
|
|
80
|
+
if (Array.isArray(scope.disallowedValues)) {
|
|
81
|
+
scope.disallowedValues = [...scope.disallowedValues];
|
|
82
|
+
} else {
|
|
83
|
+
delete scope.disallowedValues;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
72
86
|
const subject =
|
|
73
87
|
override.subject === undefined
|
|
74
88
|
? { ...base.subject }
|
|
@@ -111,6 +125,25 @@ function assertStringArray(values, name, { max = 64, pattern } = {}) {
|
|
|
111
125
|
}
|
|
112
126
|
}
|
|
113
127
|
|
|
128
|
+
function assertOptionalStringArray(values, name, { max = 64, pattern } = {}) {
|
|
129
|
+
if (values === undefined) return;
|
|
130
|
+
if (!Array.isArray(values) || values.length > max) {
|
|
131
|
+
throw new Error(`Invalid config "${name}": expected at most ${max} strings.`);
|
|
132
|
+
}
|
|
133
|
+
if (
|
|
134
|
+
values.some(
|
|
135
|
+
(value) =>
|
|
136
|
+
typeof value !== 'string' ||
|
|
137
|
+
!value ||
|
|
138
|
+
value.length > 64 ||
|
|
139
|
+
(pattern && !pattern.test(value)),
|
|
140
|
+
) ||
|
|
141
|
+
new Set(values).size !== values.length
|
|
142
|
+
) {
|
|
143
|
+
throw new Error(`Invalid config "${name}": contains an invalid or duplicate value.`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
114
147
|
export function validateCommitPolicyConfig(value) {
|
|
115
148
|
if (!object(value)) throw new Error('Invalid config "commitPolicy": expected an object.');
|
|
116
149
|
if (value.version !== 1) {
|
|
@@ -137,6 +170,14 @@ export function validateCommitPolicyConfig(value) {
|
|
|
137
170
|
) {
|
|
138
171
|
throw new Error('Invalid config "commitPolicy.scope.values": contains an invalid value.');
|
|
139
172
|
}
|
|
173
|
+
assertOptionalStringArray(value.scope.disallowedValues, 'commitPolicy.scope.disallowedValues', {
|
|
174
|
+
pattern: SCOPE_RE,
|
|
175
|
+
});
|
|
176
|
+
if (value.scope.disallowedValues?.some((scope) => value.scope.values.includes(scope))) {
|
|
177
|
+
throw new Error(
|
|
178
|
+
'Invalid config "commitPolicy.scope": allowed and disallowed values must not overlap.',
|
|
179
|
+
);
|
|
180
|
+
}
|
|
140
181
|
|
|
141
182
|
if (!object(value.subject)) {
|
|
142
183
|
throw new Error('Invalid config "commitPolicy.subject": expected an object.');
|
|
@@ -150,6 +191,16 @@ export function validateCommitPolicyConfig(value) {
|
|
|
150
191
|
'Invalid config "commitPolicy.subject.maxLength": expected an integer between 1 and 200.',
|
|
151
192
|
);
|
|
152
193
|
}
|
|
194
|
+
if (
|
|
195
|
+
value.subject.headerMaxLength !== undefined &&
|
|
196
|
+
(!Number.isInteger(value.subject.headerMaxLength) ||
|
|
197
|
+
value.subject.headerMaxLength < 1 ||
|
|
198
|
+
value.subject.headerMaxLength > 1000)
|
|
199
|
+
) {
|
|
200
|
+
throw new Error(
|
|
201
|
+
'Invalid config "commitPolicy.subject.headerMaxLength": expected an integer between 1 and 1000.',
|
|
202
|
+
);
|
|
203
|
+
}
|
|
153
204
|
|
|
154
205
|
if (!object(value.body)) {
|
|
155
206
|
throw new Error('Invalid config "commitPolicy.body": expected an object.');
|
|
@@ -197,7 +248,10 @@ function scopeRule(policy) {
|
|
|
197
248
|
const allowed = policy.scope.values.length
|
|
198
249
|
? `; allowed values: ${policy.scope.values.join(', ')}`
|
|
199
250
|
: '';
|
|
200
|
-
|
|
251
|
+
const disallowed = policy.scope.disallowedValues?.length
|
|
252
|
+
? `; disallowed values: ${policy.scope.disallowedValues.join(', ')}`
|
|
253
|
+
: '';
|
|
254
|
+
return `${policy.scope.mode}${allowed}${disallowed}`;
|
|
201
255
|
}
|
|
202
256
|
|
|
203
257
|
export function buildCommitPolicyPrompt(policy, customPrompt = '') {
|
|
@@ -219,6 +273,9 @@ export function buildCommitPolicyPrompt(policy, customPrompt = '') {
|
|
|
219
273
|
`- Allowed types: ${policy.types.join(', ')}`,
|
|
220
274
|
`- Scope: ${scopeRule(policy)}`,
|
|
221
275
|
`- Subject text: required, at most ${policy.subject.maxLength} characters`,
|
|
276
|
+
...(policy.subject.headerMaxLength
|
|
277
|
+
? [`- Complete header: at most ${policy.subject.headerMaxLength} characters`]
|
|
278
|
+
: []),
|
|
222
279
|
`- Body: ${policy.body.mode}, at most ${policy.body.maxLines} non-empty lines, separated from the subject by a blank line`,
|
|
223
280
|
`- Breaking change: ${policy.breakingChange}; use "!" and/or a "BREAKING CHANGE:" footer`,
|
|
224
281
|
`- Language: ${targetLanguage}`,
|
|
@@ -344,6 +401,9 @@ export function validateCommitCandidate(message, { policy, diff = '' } = {}) {
|
|
|
344
401
|
),
|
|
345
402
|
);
|
|
346
403
|
}
|
|
404
|
+
if (parsed.scope && normalizedPolicy.scope.disallowedValues?.includes(parsed.scope)) {
|
|
405
|
+
issues.push(issue('scope_value', `Scope "${parsed.scope}" is forbidden.`));
|
|
406
|
+
}
|
|
347
407
|
if ([...parsed.subject].length > normalizedPolicy.subject.maxLength) {
|
|
348
408
|
issues.push(
|
|
349
409
|
issue(
|
|
@@ -352,6 +412,17 @@ export function validateCommitCandidate(message, { policy, diff = '' } = {}) {
|
|
|
352
412
|
),
|
|
353
413
|
);
|
|
354
414
|
}
|
|
415
|
+
if (
|
|
416
|
+
normalizedPolicy.subject.headerMaxLength &&
|
|
417
|
+
[...parsedMessage.header].length > normalizedPolicy.subject.headerMaxLength
|
|
418
|
+
) {
|
|
419
|
+
issues.push(
|
|
420
|
+
issue(
|
|
421
|
+
'header_length',
|
|
422
|
+
`Header exceeds ${normalizedPolicy.subject.headerMaxLength} characters.`,
|
|
423
|
+
),
|
|
424
|
+
);
|
|
425
|
+
}
|
|
355
426
|
if (effectiveLanguage === 'zh' && !/\p{Script=Han}/u.test(parsed.subject)) {
|
|
356
427
|
issues.push(issue('language', 'The subject must be written in Simplified Chinese.'));
|
|
357
428
|
}
|
|
@@ -414,6 +485,9 @@ export function buildPolicyCorrectionPrompt(badReply, errors, policy) {
|
|
|
414
485
|
`Allowed types: ${policy.types.join(', ')}`,
|
|
415
486
|
`Scope rule: ${scopeRule(policy)}`,
|
|
416
487
|
`Maximum subject length: ${policy.subject.maxLength}`,
|
|
488
|
+
...(policy.subject.headerMaxLength
|
|
489
|
+
? [`Maximum complete header length: ${policy.subject.headerMaxLength}`]
|
|
490
|
+
: []),
|
|
417
491
|
`Body rule: ${policy.body.mode}, maximum ${policy.body.maxLines} non-empty lines`,
|
|
418
492
|
`Breaking-change rule: ${policy.breakingChange}`,
|
|
419
493
|
'Rewrite it once. Output only the complete corrected commit message; do not include explanations or fences.',
|
package/src/providers.js
CHANGED
|
@@ -6,6 +6,8 @@ export const PROVIDER_TYPES = Object.freeze([
|
|
|
6
6
|
'ollama',
|
|
7
7
|
'custom',
|
|
8
8
|
]);
|
|
9
|
+
export const REASONING_EFFORTS = Object.freeze(['low', 'medium', 'high', 'xhigh', 'max']);
|
|
10
|
+
export const DEFAULT_REASONING_EFFORT = 'medium';
|
|
9
11
|
const PROVIDER_TYPE_SET = new Set(PROVIDER_TYPES);
|
|
10
12
|
|
|
11
13
|
export function isProviderType(value) {
|
|
@@ -62,6 +64,24 @@ function openAIReasoningEfforts(modelId) {
|
|
|
62
64
|
return null;
|
|
63
65
|
}
|
|
64
66
|
|
|
67
|
+
// Setup uses this to avoid offering an effort that a recognized official
|
|
68
|
+
// OpenAI model will reject. Other adapters either accept the common effort
|
|
69
|
+
// vocabulary, normalize it, or are model-dependent, so they retain the full
|
|
70
|
+
// list and let the user/provider make the final choice.
|
|
71
|
+
export function reasoningEffortsForModel(providerType, modelId) {
|
|
72
|
+
if ((providerType || '').toLowerCase() === 'openai') {
|
|
73
|
+
const supported = openAIReasoningEfforts(modelId);
|
|
74
|
+
if (supported) return supported.filter((effort) => effort !== 'none');
|
|
75
|
+
}
|
|
76
|
+
return [...REASONING_EFFORTS];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function canDisableReasoningForModel(providerType, modelId) {
|
|
80
|
+
if ((providerType || '').toLowerCase() !== 'openai') return true;
|
|
81
|
+
const supported = openAIReasoningEfforts(modelId);
|
|
82
|
+
return !supported || supported.includes('none');
|
|
83
|
+
}
|
|
84
|
+
|
|
65
85
|
function openAIReasoningEffort(modelId, enabled, effort) {
|
|
66
86
|
const requested = enabled ? effort : 'none';
|
|
67
87
|
const supported = openAIReasoningEfforts(modelId);
|
|
@@ -179,7 +199,7 @@ function applyReasoning(payload, provider, modelId, reasoning, nativeOllama) {
|
|
|
179
199
|
if (mode === 'auto') return;
|
|
180
200
|
|
|
181
201
|
const enabled = mode === 'on';
|
|
182
|
-
const effort = reasoning?.effort ||
|
|
202
|
+
const effort = reasoning?.effort || DEFAULT_REASONING_EFFORT;
|
|
183
203
|
|
|
184
204
|
if (provider === 'openai') {
|
|
185
205
|
if (!isOpenAIReasoningModel(modelId)) return;
|
package/src/setup.js
CHANGED
|
@@ -9,11 +9,56 @@ import password from '@inquirer/password';
|
|
|
9
9
|
import confirm from '@inquirer/confirm';
|
|
10
10
|
|
|
11
11
|
import { checkConnection } from './api.js';
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
CONFIG_SCHEMA_VERSION,
|
|
14
|
+
DEFAULT_CONFIG,
|
|
15
|
+
isSecureApiUrl,
|
|
16
|
+
validateUserConfig,
|
|
17
|
+
} from './config.js';
|
|
13
18
|
import { vimSelect } from './ui.js';
|
|
14
19
|
import { fileExists, formatMs, indentError, maskApiKey } from './utils.js';
|
|
15
20
|
import { loadProviderPresetManifest } from './provider-presets.js';
|
|
16
21
|
import { resolveConfigLocations, userConfigLocations } from './config-paths.js';
|
|
22
|
+
import {
|
|
23
|
+
canDisableReasoningForModel,
|
|
24
|
+
getProviderAdapter,
|
|
25
|
+
reasoningEffortsForModel,
|
|
26
|
+
} from './providers.js';
|
|
27
|
+
|
|
28
|
+
const REASONING_MODES = new Set(['auto', 'on', 'off']);
|
|
29
|
+
const REASONING_MODE_CHOICES = Object.freeze([
|
|
30
|
+
{
|
|
31
|
+
name: 'Provider default (auto)',
|
|
32
|
+
value: 'auto',
|
|
33
|
+
description: 'Do not send an explicit reasoning switch',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: 'Enabled (on)',
|
|
37
|
+
value: 'on',
|
|
38
|
+
description: 'Request reasoning and configure its effort',
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: 'Disabled (off)',
|
|
42
|
+
value: 'off',
|
|
43
|
+
description: 'Explicitly disable reasoning when the model supports that switch',
|
|
44
|
+
},
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
function suggestedReasoningMode({ currentModel, globalReasoning, apiUrl, providerType, modelId }) {
|
|
48
|
+
const configured = currentModel.reasoning?.mode ?? globalReasoning?.mode;
|
|
49
|
+
if (REASONING_MODES.has(configured)) return configured;
|
|
50
|
+
return getProviderAdapter({ apiUrl, providerType, modelId }).capabilities.reasoning === 'native'
|
|
51
|
+
? 'on'
|
|
52
|
+
: 'auto';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function effortDescription(effort) {
|
|
56
|
+
if (effort === 'low') return 'Fastest and lowest-cost reasoning';
|
|
57
|
+
if (effort === 'medium') return 'Balanced reasoning (default)';
|
|
58
|
+
if (effort === 'high') return 'Deeper reasoning with more latency and tokens';
|
|
59
|
+
if (effort === 'xhigh') return 'Very deep reasoning for models that support it';
|
|
60
|
+
return 'Maximum reasoning for models that support it';
|
|
61
|
+
}
|
|
17
62
|
|
|
18
63
|
// Merge the wizard's answers into the one supported Provider/Model schema.
|
|
19
64
|
// Existing providers and unrelated global settings are preserved; the
|
|
@@ -175,6 +220,7 @@ export async function runSetup(dependencies = {}) {
|
|
|
175
220
|
|
|
176
221
|
// ── 4. Model ────────────────────────────────────────────────────────
|
|
177
222
|
|
|
223
|
+
const providerType = presetAdapter || existingProvider.providerType || 'custom';
|
|
178
224
|
const models = { ...presetModels, ...existingProvider.models };
|
|
179
225
|
let suggestedModel = existingProvider.defaultModel || presetDefaultModel;
|
|
180
226
|
while (true) {
|
|
@@ -192,7 +238,46 @@ export async function runSetup(dependencies = {}) {
|
|
|
192
238
|
default: currentModel.modelId || undefined,
|
|
193
239
|
validate: (v) => (v.trim() ? true : 'Model ID is required'),
|
|
194
240
|
});
|
|
195
|
-
|
|
241
|
+
const modelId = modelIdInput.trim();
|
|
242
|
+
const reasoningModeChoices = canDisableReasoningForModel(providerType, modelId)
|
|
243
|
+
? REASONING_MODE_CHOICES
|
|
244
|
+
: REASONING_MODE_CHOICES.filter((choice) => choice.value !== 'off');
|
|
245
|
+
const suggestedMode = suggestedReasoningMode({
|
|
246
|
+
currentModel,
|
|
247
|
+
globalReasoning: existing.reasoning,
|
|
248
|
+
apiUrl,
|
|
249
|
+
providerType,
|
|
250
|
+
modelId,
|
|
251
|
+
});
|
|
252
|
+
const reasoningMode = await selectPrompt({
|
|
253
|
+
message: `Reasoning mode for ${modelName}`,
|
|
254
|
+
default: reasoningModeChoices.some((choice) => choice.value === suggestedMode)
|
|
255
|
+
? suggestedMode
|
|
256
|
+
: 'auto',
|
|
257
|
+
choices: reasoningModeChoices,
|
|
258
|
+
});
|
|
259
|
+
const reasoning = { ...currentModel.reasoning, mode: reasoningMode };
|
|
260
|
+
if (reasoningMode === 'on') {
|
|
261
|
+
const efforts = reasoningEffortsForModel(providerType, modelId);
|
|
262
|
+
const configuredEffort = currentModel.reasoning?.effort ?? existing.reasoning?.effort;
|
|
263
|
+
const defaultEffort = efforts.includes(configuredEffort)
|
|
264
|
+
? configuredEffort
|
|
265
|
+
: efforts.includes(DEFAULT_CONFIG.reasoning.effort)
|
|
266
|
+
? DEFAULT_CONFIG.reasoning.effort
|
|
267
|
+
: efforts[0];
|
|
268
|
+
reasoning.effort = await selectPrompt({
|
|
269
|
+
message: `Reasoning effort for ${modelName}`,
|
|
270
|
+
default: defaultEffort,
|
|
271
|
+
choices: efforts.map((effort) => ({
|
|
272
|
+
name: effort,
|
|
273
|
+
value: effort,
|
|
274
|
+
description: effortDescription(effort),
|
|
275
|
+
})),
|
|
276
|
+
});
|
|
277
|
+
} else {
|
|
278
|
+
delete reasoning.effort;
|
|
279
|
+
}
|
|
280
|
+
models[modelName] = { ...currentModel, modelId, reasoning };
|
|
196
281
|
const addAnother = await confirmPrompt({
|
|
197
282
|
message: 'Add another model?',
|
|
198
283
|
default: false,
|
|
@@ -214,6 +299,11 @@ export async function runSetup(dependencies = {}) {
|
|
|
214
299
|
})),
|
|
215
300
|
});
|
|
216
301
|
const selectedModel = models[defaultModel];
|
|
302
|
+
const selectedReasoning = {
|
|
303
|
+
...DEFAULT_CONFIG.reasoning,
|
|
304
|
+
...existing.reasoning,
|
|
305
|
+
...selectedModel.reasoning,
|
|
306
|
+
};
|
|
217
307
|
|
|
218
308
|
// ── 5. Commit message language ──────────────────────────────────────
|
|
219
309
|
|
|
@@ -230,7 +320,7 @@ export async function runSetup(dependencies = {}) {
|
|
|
230
320
|
apiUrl,
|
|
231
321
|
apiKey,
|
|
232
322
|
apiKeyEnv,
|
|
233
|
-
providerType
|
|
323
|
+
providerType,
|
|
234
324
|
defaultModel,
|
|
235
325
|
models,
|
|
236
326
|
...(existingProvider.retry ? { retry: existingProvider.retry } : {}),
|
|
@@ -257,6 +347,7 @@ export async function runSetup(dependencies = {}) {
|
|
|
257
347
|
const report = await connectionCheck({
|
|
258
348
|
...entry,
|
|
259
349
|
...selectedModel,
|
|
350
|
+
reasoning: selectedReasoning,
|
|
260
351
|
apiKey: apiKeyEnv ? process.env[apiKeyEnv] : apiKey,
|
|
261
352
|
maxTokens: 64,
|
|
262
353
|
timeoutMs: 120000,
|
|
@@ -292,6 +383,16 @@ export async function runSetup(dependencies = {}) {
|
|
|
292
383
|
console.log(
|
|
293
384
|
' ' + chalk.dim(` Provider: ${providerName}/${defaultModel} (${selectedModel.modelId})`),
|
|
294
385
|
);
|
|
386
|
+
console.log(
|
|
387
|
+
' ' +
|
|
388
|
+
chalk.dim(
|
|
389
|
+
` Reasoning: ${
|
|
390
|
+
selectedReasoning.mode === 'on'
|
|
391
|
+
? `${selectedReasoning.mode}/${selectedReasoning.effort}`
|
|
392
|
+
: selectedReasoning.mode
|
|
393
|
+
}`,
|
|
394
|
+
),
|
|
395
|
+
);
|
|
295
396
|
console.log(
|
|
296
397
|
' ' + chalk.dim(` API key: ${apiKeyEnv ? `env:${apiKeyEnv}` : maskApiKey(apiKey)}`),
|
|
297
398
|
);
|
package/src/split-plan.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { lstat, mkdir, readFile,
|
|
1
|
+
import { link, lstat, mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { dirname, isAbsolute, resolve } from 'node:path';
|
|
3
3
|
|
|
4
4
|
import { normalizeCommitPolicy, validateCommitCandidate } from './policy.js';
|
|
@@ -35,7 +35,13 @@ function clonePolicy(policy, language) {
|
|
|
35
35
|
return {
|
|
36
36
|
version: normalized.version,
|
|
37
37
|
types: [...normalized.types],
|
|
38
|
-
scope: {
|
|
38
|
+
scope: {
|
|
39
|
+
...normalized.scope,
|
|
40
|
+
values: [...normalized.scope.values],
|
|
41
|
+
...(normalized.scope.disallowedValues
|
|
42
|
+
? { disallowedValues: [...normalized.scope.disallowedValues] }
|
|
43
|
+
: {}),
|
|
44
|
+
},
|
|
39
45
|
subject: { ...normalized.subject },
|
|
40
46
|
body: { ...normalized.body },
|
|
41
47
|
breakingChange: normalized.breakingChange,
|
|
@@ -310,12 +316,19 @@ export async function writeSplitPlanArtifact(path, artifact) {
|
|
|
310
316
|
await writeFile(temporary, JSON.stringify(validated, null, 2) + '\n', {
|
|
311
317
|
encoding: 'utf8',
|
|
312
318
|
mode: 0o600,
|
|
319
|
+
flag: 'wx',
|
|
313
320
|
});
|
|
314
|
-
|
|
321
|
+
// A same-directory hard link installs the completed file atomically while
|
|
322
|
+
// failing with EEXIST instead of replacing an unrelated destination.
|
|
323
|
+
await link(temporary, absolute);
|
|
315
324
|
} catch (err) {
|
|
316
325
|
await unlink(temporary).catch(() => {});
|
|
326
|
+
if (err.code === 'EEXIST') {
|
|
327
|
+
throw new Error(`Split plan output already exists: ${absolute}`, { cause: err });
|
|
328
|
+
}
|
|
317
329
|
throw err;
|
|
318
330
|
}
|
|
331
|
+
await unlink(temporary).catch(() => {});
|
|
319
332
|
return absolute;
|
|
320
333
|
}
|
|
321
334
|
|
package/src/split.js
CHANGED
|
@@ -117,17 +117,22 @@ function canonicalDestination(path) {
|
|
|
117
117
|
}
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
function safeExportPlanPath(projectRoot, path) {
|
|
120
|
+
export function safeExportPlanPath(projectRoot, path) {
|
|
121
121
|
const absolute = resolve(path);
|
|
122
122
|
const rawGitDir = readGit(['rev-parse', '--git-dir'], projectRoot).trim();
|
|
123
123
|
const gitDir = isAbsolute(rawGitDir) ? rawGitDir : resolve(projectRoot, rawGitDir);
|
|
124
124
|
const canonicalRoot = canonicalExistingPath(projectRoot);
|
|
125
125
|
const canonicalGitDir = canonicalDestination(gitDir);
|
|
126
|
+
const canonicalAicommitDir = canonicalDestination(join(gitDir, 'aicommit'));
|
|
126
127
|
const canonicalPlan = canonicalDestination(absolute);
|
|
127
|
-
|
|
128
|
+
const insideAicommitDir = pathIsWithin(canonicalAicommitDir, canonicalPlan);
|
|
129
|
+
if (
|
|
130
|
+
(pathIsWithin(canonicalRoot, canonicalPlan) && !insideAicommitDir) ||
|
|
131
|
+
(pathIsWithin(canonicalGitDir, canonicalPlan) && !insideAicommitDir)
|
|
132
|
+
) {
|
|
128
133
|
throw fail(
|
|
129
134
|
ERROR_CATEGORIES.CONFIG,
|
|
130
|
-
|
|
135
|
+
"Split plan output must be outside the working tree or inside the repository Git directory's dedicated .git/aicommit/ metadata subdirectory.",
|
|
131
136
|
);
|
|
132
137
|
}
|
|
133
138
|
return absolute;
|
|
@@ -336,8 +341,13 @@ export async function generateSplitPlan(
|
|
|
336
341
|
: []),
|
|
337
342
|
'Rules:',
|
|
338
343
|
`- Each group must represent ONE logical change and use one allowed type: ${policy.types.join(', ')}.`,
|
|
339
|
-
`- Scope mode: ${policy.scope.mode}${policy.scope.values.length ? `; allowed scopes: ${policy.scope.values.join(', ')}` : ''}.`,
|
|
344
|
+
`- Scope mode: ${policy.scope.mode}${policy.scope.values.length ? `; allowed scopes: ${policy.scope.values.join(', ')}` : ''}${policy.scope.disallowedValues?.length ? `; disallowed scopes: ${policy.scope.disallowedValues.join(', ')}` : ''}.`,
|
|
340
345
|
`- Subject text must not exceed ${policy.subject.maxLength} characters.`,
|
|
346
|
+
...(policy.subject.headerMaxLength
|
|
347
|
+
? [
|
|
348
|
+
`- The complete commit header must not exceed ${policy.subject.headerMaxLength} characters.`,
|
|
349
|
+
]
|
|
350
|
+
: []),
|
|
341
351
|
`- Body mode: ${policy.body.mode}; at most ${policy.body.maxLines} non-empty lines.`,
|
|
342
352
|
`- Breaking changes: ${policy.breakingChange}.`,
|
|
343
353
|
'- Give every message a short subject line; when the subject alone does not say it all, add a body of bullet lines (what changed and why), each starting with "- " — the same format the single-commit flow produces.',
|
|
@@ -1277,13 +1287,39 @@ export function executeSplit(
|
|
|
1277
1287
|
groups,
|
|
1278
1288
|
hunkMode: allFiles.some((change) => change.hunks?.length),
|
|
1279
1289
|
});
|
|
1290
|
+
const fingerprintBeforeSnapshot = getSplitStateFingerprint(
|
|
1291
|
+
projectRoot,
|
|
1292
|
+
hasHead(projectRoot),
|
|
1293
|
+
undefined,
|
|
1294
|
+
scope,
|
|
1295
|
+
);
|
|
1296
|
+
if (fingerprintBeforeSnapshot !== plan.fingerprint) {
|
|
1297
|
+
throw fail(
|
|
1298
|
+
ERROR_CATEGORIES.CONCURRENT_MODIFICATION,
|
|
1299
|
+
'Split state changed before the transaction snapshot was captured.',
|
|
1300
|
+
);
|
|
1301
|
+
}
|
|
1302
|
+
options.faultInjector?.('before_snapshot_capture', { plan });
|
|
1280
1303
|
const snapshots = captureCheckpointSnapshots(projectRoot, scope, allFiles);
|
|
1304
|
+
const fingerprintAfterSnapshot = getSplitStateFingerprint(
|
|
1305
|
+
projectRoot,
|
|
1306
|
+
hasHead(projectRoot),
|
|
1307
|
+
undefined,
|
|
1308
|
+
scope,
|
|
1309
|
+
);
|
|
1310
|
+
if (fingerprintAfterSnapshot !== plan.fingerprint) {
|
|
1311
|
+
throw fail(
|
|
1312
|
+
ERROR_CATEGORIES.CONCURRENT_MODIFICATION,
|
|
1313
|
+
'Split state changed while the transaction snapshot was being captured.',
|
|
1314
|
+
);
|
|
1315
|
+
}
|
|
1281
1316
|
hunkExecution = validateHunkTransaction(projectRoot, plan, snapshots);
|
|
1282
1317
|
transaction = {
|
|
1283
1318
|
...createSplitCheckpoint(projectRoot, plan, snapshots),
|
|
1284
1319
|
faultInjector: options.faultInjector,
|
|
1285
1320
|
};
|
|
1286
1321
|
} catch (err) {
|
|
1322
|
+
if (err?.category === ERROR_CATEGORIES.CONCURRENT_MODIFICATION) throw err;
|
|
1287
1323
|
throw fail(ERROR_CATEGORIES.GIT_STATE, `Failed to create split checkpoint: ${err.message}`, {
|
|
1288
1324
|
cause: err,
|
|
1289
1325
|
});
|