@gitkraken/compose-tools 0.1.0

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.
Files changed (122) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/LICENSE +19 -0
  3. package/README.md +543 -0
  4. package/dist/ai/session.d.ts +12 -0
  5. package/dist/ai/session.js +1 -0
  6. package/dist/ai/types.d.ts +31 -0
  7. package/dist/ai/types.js +1 -0
  8. package/dist/cancellation.d.ts +20 -0
  9. package/dist/cancellation.js +1 -0
  10. package/dist/errors/ai.d.ts +10 -0
  11. package/dist/errors/ai.js +1 -0
  12. package/dist/errors/git.d.ts +12 -0
  13. package/dist/errors/git.js +1 -0
  14. package/dist/errors/task.d.ts +6 -0
  15. package/dist/errors/task.js +1 -0
  16. package/dist/errors/workflow.d.ts +13 -0
  17. package/dist/errors/workflow.js +1 -0
  18. package/dist/git/apply-branches.d.ts +28 -0
  19. package/dist/git/apply-branches.js +1 -0
  20. package/dist/git/apply-target.d.ts +39 -0
  21. package/dist/git/apply-target.js +1 -0
  22. package/dist/git/commit-chain.d.ts +87 -0
  23. package/dist/git/commit-chain.js +6 -0
  24. package/dist/git/diff-collector.d.ts +80 -0
  25. package/dist/git/diff-collector.js +8 -0
  26. package/dist/git/index.d.ts +7 -0
  27. package/dist/git/index.js +1 -0
  28. package/dist/git/port-dispatch.d.ts +34 -0
  29. package/dist/git/port-dispatch.js +3 -0
  30. package/dist/git/require-exec.d.ts +17 -0
  31. package/dist/git/require-exec.js +1 -0
  32. package/dist/git/safety.d.ts +22 -0
  33. package/dist/git/safety.js +1 -0
  34. package/dist/git/types.d.ts +43 -0
  35. package/dist/git/types.js +1 -0
  36. package/dist/git/utils/conventions.d.ts +33 -0
  37. package/dist/git/utils/conventions.js +10 -0
  38. package/dist/git/utils/crypto.d.ts +4 -0
  39. package/dist/git/utils/crypto.js +1 -0
  40. package/dist/git/utils/hunk-parser.d.ts +6 -0
  41. package/dist/git/utils/hunk-parser.js +7 -0
  42. package/dist/git/utils/patch-builder.d.ts +6 -0
  43. package/dist/git/utils/patch-builder.js +2 -0
  44. package/dist/git/utils/temp-index.d.ts +6 -0
  45. package/dist/git/utils/temp-index.js +1 -0
  46. package/dist/index.d.ts +28 -0
  47. package/dist/index.js +1 -0
  48. package/dist/ports/git.d.ts +199 -0
  49. package/dist/ports/git.js +1 -0
  50. package/dist/tasks/ai-loop.d.ts +99 -0
  51. package/dist/tasks/ai-loop.js +2 -0
  52. package/dist/tasks/analyze/analyze.d.ts +6 -0
  53. package/dist/tasks/analyze/analyze.js +1 -0
  54. package/dist/tasks/analyze/prompt.d.ts +16 -0
  55. package/dist/tasks/analyze/prompt.js +34 -0
  56. package/dist/tasks/analyze/schema.d.ts +38 -0
  57. package/dist/tasks/analyze/schema.js +1 -0
  58. package/dist/tasks/analyze/types.d.ts +69 -0
  59. package/dist/tasks/analyze/types.js +1 -0
  60. package/dist/tasks/analyze/validator.d.ts +7 -0
  61. package/dist/tasks/analyze/validator.js +13 -0
  62. package/dist/tasks/compose-group/compose-group.d.ts +15 -0
  63. package/dist/tasks/compose-group/compose-group.js +1 -0
  64. package/dist/tasks/compose-group/prompt.d.ts +26 -0
  65. package/dist/tasks/compose-group/prompt.js +162 -0
  66. package/dist/tasks/compose-group/types.d.ts +62 -0
  67. package/dist/tasks/compose-group/types.js +1 -0
  68. package/dist/tasks/group/group.d.ts +6 -0
  69. package/dist/tasks/group/group.js +1 -0
  70. package/dist/tasks/group/prompt.d.ts +15 -0
  71. package/dist/tasks/group/prompt.js +158 -0
  72. package/dist/tasks/group/schema.d.ts +19 -0
  73. package/dist/tasks/group/schema.js +1 -0
  74. package/dist/tasks/group/types.d.ts +61 -0
  75. package/dist/tasks/group/types.js +1 -0
  76. package/dist/tasks/group/validator.d.ts +7 -0
  77. package/dist/tasks/group/validator.js +53 -0
  78. package/dist/tasks/index.d.ts +15 -0
  79. package/dist/tasks/index.js +1 -0
  80. package/dist/tasks/order/order.d.ts +6 -0
  81. package/dist/tasks/order/order.js +1 -0
  82. package/dist/tasks/order/prompt.d.ts +13 -0
  83. package/dist/tasks/order/prompt.js +45 -0
  84. package/dist/tasks/order/schema.d.ts +12 -0
  85. package/dist/tasks/order/schema.js +1 -0
  86. package/dist/tasks/order/types.d.ts +36 -0
  87. package/dist/tasks/order/types.js +1 -0
  88. package/dist/tasks/order/validator.d.ts +11 -0
  89. package/dist/tasks/order/validator.js +12 -0
  90. package/dist/tasks/partition/partition.d.ts +6 -0
  91. package/dist/tasks/partition/partition.js +1 -0
  92. package/dist/tasks/partition/prompt.d.ts +16 -0
  93. package/dist/tasks/partition/prompt.js +61 -0
  94. package/dist/tasks/partition/schema.d.ts +14 -0
  95. package/dist/tasks/partition/schema.js +1 -0
  96. package/dist/tasks/partition/types.d.ts +53 -0
  97. package/dist/tasks/partition/types.js +1 -0
  98. package/dist/tasks/partition/validator.d.ts +7 -0
  99. package/dist/tasks/partition/validator.js +11 -0
  100. package/dist/tasks/quick-compose/prompt.d.ts +18 -0
  101. package/dist/tasks/quick-compose/prompt.js +47 -0
  102. package/dist/tasks/quick-compose/quick-compose.d.ts +6 -0
  103. package/dist/tasks/quick-compose/quick-compose.js +1 -0
  104. package/dist/tasks/quick-compose/schema.d.ts +27 -0
  105. package/dist/tasks/quick-compose/schema.js +1 -0
  106. package/dist/tasks/quick-compose/types.d.ts +63 -0
  107. package/dist/tasks/quick-compose/types.js +1 -0
  108. package/dist/tasks/quick-compose/validator.d.ts +7 -0
  109. package/dist/tasks/quick-compose/validator.js +25 -0
  110. package/dist/tasks/session.d.ts +22 -0
  111. package/dist/tasks/session.js +1 -0
  112. package/dist/tasks/types.d.ts +101 -0
  113. package/dist/tasks/types.js +3 -0
  114. package/dist/workflows/compose.d.ts +371 -0
  115. package/dist/workflows/compose.js +2 -0
  116. package/dist/workflows/split.d.ts +80 -0
  117. package/dist/workflows/split.js +3 -0
  118. package/dist/workflows/types.d.ts +63 -0
  119. package/dist/workflows/types.js +1 -0
  120. package/dist/workflows/undo.d.ts +56 -0
  121. package/dist/workflows/undo.js +1 -0
  122. package/package.json +37 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/) and this project adheres to [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-04-23
10
+
11
+ ### Added
12
+
13
+ - Adds `compose(input)` — one-call sugar that runs planning and apply together; ideal when the consumer does not need to surface the plan for review
14
+ - Adds `composePlan(input)` and `applyComposePlan(input)` — a two-phase API for consumers (e.g. GitLens's webview) that surface the plan for user review or editing before applying; `applyComposePlan` reverifies the workdir matches the plan's snapshot before mutating
15
+ - Adds `split(input)` — partitions an existing multi-commit branch into a stack of smaller, reviewable branches
16
+ - Adds `undoCompose(input)` and `validateUndoCompose(input)` — surgical reversal of only the git mutations a compose operation recorded, plus a dry-run validator that reports blockers and warnings without mutating the repo
17
+ - Adds `ComposeGitPort` — the git surface used by every workflow; accepts raw `exec`, a hybrid of `exec` plus targeted high-level op overrides (e.g. a signing-aware `commitTree`), or an ops-only implementation for libgit2-backed consumers; missing ops throw `ComposeGitPortMissingOpError` naming the missing op
18
+ - Adds source types for selecting what to compose — `{ type: 'workdir' }` (staged, unstaged, and untracked changes with optional `stagedOnly` and `noUntracked` filters), `{ type: 'branch' }` (merge-base to tip), and `{ type: 'commit-range' }` (a contiguous range with per-hunk author info preserved for attribution on rewritten commits)
19
+ - Adds target types for deciding where composed output lands — `{ type: 'head' }`, `{ type: 'branch' }`, `{ type: 'branch-replace' }` (replace the source branch in place), `{ type: 'auto' }` (one branch per concern, optionally subdivided into stacked sub-branches), and `{ type: 'rewrite-range' }` (rewrite a contiguous range in the middle of a branch while preserving the commits before and after)
20
+ - Adds a `depth` option that tunes how many AI passes run and how deeply each reasons over the input
21
+ - Adds an `onProgress` callback that surfaces phase transitions, per-call token usage, and call IDs in real time — suitable for wiring progress UI
22
+ - Adds an `onBeforeApply` hook that lets consumers inspect the plan and gate or cancel application before anything is mutated
23
+ - Adds a `session` parameter that resumes a prior planning conversation by passing the session returned from an earlier `composePlan` result
24
+ - Adds a `feedbackTargetCallId` on results — a stable ID for the model's "decision" call, for wiring thumbs-up/thumbs-down telemetry on specific outputs
25
+ - Adds safety-snapshot verification between plan and apply — `applyComposePlan` rehashes the workdir before mutating, and drift throws `WorkflowError('SAFETY_CHECK_FAILED')`
26
+ - Adds cancellation support via `AbortSignal` — propagates from every workflow input into every git `exec` and AI `generate` call
27
+ - Adds a typed error taxonomy — `GitError`, `AiError`, `TaskError`, `WorkflowError`, and `ComposeGitPortMissingOpError`, each carrying a `code` property so consumers can branch on failure modes
package/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Core Tools License
2
+
3
+ Copyright (c) 2025-2026 Axosoft, LLC dba GitKraken ("GitKraken")
4
+
5
+ This software and associated documentation files (the "Software") may be compiled as part of GitKraken software products, including the gitkraken/vscode-gitlens open source project (collectively, the "GitKraken Products"), to the extent the Software is a required component of the GitKraken Products; provided, however, that the Software and its functionality may only be used if you (and any entity that you represent) have agreed to, and are in compliance with, the GitKraken End User License Agreement, available at https://gitkraken.com/eula (the "EULA"), or other agreement governing the use of the Software, as agreed by you and GitKraken, and otherwise have a valid subscription for the correct number of user seats for the applicable version of the Software (e.g., GitLens Free+, GitLens Pro, GitLens Teams, and GitLens Enterprise).
6
+
7
+ The Software is licensed, not sold. This license only gives you some rights to use the Software. GitKraken reserves all other rights. Unless applicable law gives you more rights despite this limitation, you may use the Software only as expressly permitted in this license. In doing so, you must comply with any technical limitations in the Software that only allow you to use it in certain ways. You may not (i) work around any technical limitations in the Software, (ii) reverse engineer, decompile or disassemble the Software, or otherwise attempt to derive the source code for the Software, except and to the extent required by third party licensing terms governing use of certain open source components that may be included with the Software, (iii) remove, minimize, block or modify any notices of GitKraken or its suppliers in the Software, (iv) use the software in any way that is against the law, (v) host, share, publish, rent or lease the Software; (vi) distribute the Software as a stand-alone or integrated offering or combine it with any of your applications for others to use, (vii) use the Software other than in connection with validly licensed GitKraken Products or (viii) use any portion of the Software to create software with the same or similar functionality.
8
+
9
+ You agree that GitKraken and/or its licensors (as applicable) retain all right, title and interest in and to the Software and all modifications and/or patches thereto. You are not granted any other rights beyond what is expressly stated herein. Except as set forth above, it is forbidden to copy, merge, publish, distribute, sublicense, modify and/or sell the Software.
10
+
11
+ If you give feedback about the Software to GitKraken, you give to GitKraken, without charge, the right to use, share and commercialize your feedback in any way and for any purpose. You will not give feedback that is subject to a license that requires GitKraken to license its software or documentation to third parties because we include your feedback in them. These rights survive this License.
12
+
13
+ The full text of this Core Tools License shall be included in all copies or substantial portions of the Software.
14
+
15
+ This License and the EULA, if applicable, are the entire agreement for the Software. You must comply with all domestic and international export laws and regulations that apply to the Software, which include restrictions on destinations, end users and end use. This License is governed and construed by the laws of the State of Arizona, United States without regard to any choice or conflict of law principles or rules.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL GITKRAKEN, THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
18
+
19
+ For all third party components incorporated into the Software, those components are licensed under the original license provided by the owner of the applicable component.
package/README.md ADDED
@@ -0,0 +1,543 @@
1
+ # @gitkraken/compose-tools
2
+
3
+ AI-powered git change organization. Takes a messy working directory or a branch full of interleaved commits and restructures them into clean, atomic commits on well-named branches — ready for code review.
4
+
5
+ ## What it does
6
+
7
+ `compose()` collects your changes, sends the diffs through an AI pipeline that understands the logical structure of code changes, and produces clean commits grouped by concern. Depending on configuration, it can:
8
+
9
+ - Rewrite a working directory's staged / unstaged / untracked changes into atomic commits on the current branch
10
+ - Reorganize a branch's commit history into logically grouped commits
11
+ - Rewrite a contiguous range of commits in the middle of a branch, preserving the commits before and after the range
12
+ - Split changes across multiple new branches (one per concern)
13
+ - Subdivide branches into stacked sub-branches for incremental review
14
+
15
+ `split()` is a focused variant — it takes an existing multi-commit branch and partitions it into a stack of smaller branches.
16
+
17
+ Every operation that mutates the repository returns an `undoId` that can be passed to `undoCompose()` to surgically reverse only the git mutations that were recorded.
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ pnpm add @gitkraken/compose-tools @gitkraken/shared-tools
23
+ ```
24
+
25
+ The library is runtime-agnostic — it never executes git or AI calls directly. Consumers provide adapters for their own git and AI backends.
26
+
27
+ ## Implement the ports
28
+
29
+ ### `ComposeGitPort`
30
+
31
+ The git surface the library needs. Two ways to provide it:
32
+
33
+ **Minimal — raw `exec` only.** Fine for a CLI-git consumer.
34
+
35
+ ```typescript
36
+ import type { ComposeGitPort } from '@gitkraken/compose-tools';
37
+
38
+ const git: ComposeGitPort = {
39
+ exec: async (args, options) => {
40
+ const result = await yourGitBackend.run(args, {
41
+ cwd: '/path/to/repo',
42
+ env: options?.env,
43
+ stdin: options?.stdin,
44
+ signal: options?.signal,
45
+ });
46
+ if (result.exitCode !== 0) throw new Error(`git ${args[0]} failed: ${result.stderr}`);
47
+ return result.stdout;
48
+ },
49
+ };
50
+ ```
51
+
52
+ The library builds every git operation it needs (diff, write-tree, commit-tree, update-ref, stash, cherry-pick, etc.) from `exec`.
53
+
54
+ **Hybrid — `exec` plus targeted high-level ops.** Provide a high-level op when your backend has a better implementation. The library prefers the high-level op over routing through `exec`.
55
+
56
+ ```typescript
57
+ const git: ComposeGitPort = {
58
+ exec: async (args, options) => { /* ... */ },
59
+
60
+ // Consumer has a signing-aware commit creator; library should prefer it.
61
+ commitTree: async (tree, parents, message, opts) => {
62
+ return yourSigningCommitCreator({ tree, parents, message, signing: opts?.signing });
63
+ },
64
+ };
65
+ ```
66
+
67
+ **Libgit2-style — high-level ops only, no `exec`.** A libgit2-backed consumer implements the specific ops used by its flow. If a flow needs an op the consumer didn't provide and `exec` isn't available either, the library throws `ComposeGitPortMissingOpError` naming the missing op.
68
+
69
+ The full list of high-level ops is in the `ComposeGitOps` type; the most commonly overridden are `commitTree`, `readTree`, `writeTree`, `applyPatchToIndex`, `diffTree`, `stageAll`. Every op takes an `OpOptions` with `signal?: AbortSignal`.
70
+
71
+ ### `AiModelPort`
72
+
73
+ Stateless text generation — the caller passes the full message history each call.
74
+
75
+ ```typescript
76
+ import type { AiModelPort } from '@gitkraken/shared-tools';
77
+
78
+ const model: AiModelPort = {
79
+ generate: async (params) => {
80
+ const response = await yourLLM.chat({
81
+ system: params.system,
82
+ messages: params.messages,
83
+ maxTokens: params.maxTokens,
84
+ temperature: params.temperature,
85
+ signal: params.signal,
86
+ });
87
+ return {
88
+ text: response.text,
89
+ usage: { inputTokens: response.inputTokens, outputTokens: response.outputTokens },
90
+ };
91
+ },
92
+ };
93
+ ```
94
+
95
+ The adapter doesn't track sessions — session state is managed inside the library.
96
+
97
+ ## Entry points
98
+
99
+ The library exposes three flavors of the main workflow plus split and undo.
100
+
101
+ ### `compose(input)` — one-call sugar
102
+
103
+ Runs planning and apply together. Ideal when the consumer doesn't need to surface the plan for review.
104
+
105
+ ```typescript
106
+ import { compose } from '@gitkraken/compose-tools';
107
+
108
+ const result = await compose({
109
+ git,
110
+ model,
111
+ source: { type: 'workdir' },
112
+ onProgress: (event) => console.log(event.phase, event.message),
113
+ });
114
+
115
+ // result.plan — AI's analysis, grouping, ordering
116
+ // result.commitShas — { 'commit-1': 'abc123…', … }
117
+ // result.undoId — pass to undoCompose() to reverse
118
+ // result.phases — timing/usage/callIds per phase
119
+ // result.usage — aggregated token counts
120
+ // result.feedbackTargetCallId — stable ID of the "decision" model call (for thumbs up/down events)
121
+ ```
122
+
123
+ ### `composePlan(input)` + `applyComposePlan(input)` — two-phase
124
+
125
+ Returns the plan without mutating the repo, lets the user review/edit it, then applies. This is the shape GitLens-style UIs use (plan → webview → user edits → apply).
126
+
127
+ ```typescript
128
+ import { composePlan, applyComposePlan } from '@gitkraken/compose-tools';
129
+
130
+ const planResult = await composePlan({
131
+ git,
132
+ model,
133
+ source: { type: 'workdir' },
134
+ });
135
+
136
+ // Show planResult.plan + planResult.source.hunks to the user. They may
137
+ // reassign hunks between commits, reorder, edit messages, etc.
138
+
139
+ const userEditedPlan = /* user-edited version of planResult.plan */;
140
+
141
+ const applied = await applyComposePlan({
142
+ git,
143
+ applyPlan: {
144
+ plan: userEditedPlan,
145
+ source: planResult.source, // the library's collected hunks
146
+ snapshot: planResult.snapshot, // safety hash — verified at apply time
147
+ },
148
+ });
149
+ ```
150
+
151
+ `applyComposePlan` reverifies the workdir matches `snapshot` before mutating. If it drifted between plan and apply, it throws `WorkflowError('SAFETY_CHECK_FAILED')`.
152
+
153
+ ### `split(input)`
154
+
155
+ Partitions an existing branch into a stack of smaller branches.
156
+
157
+ ```typescript
158
+ import { split } from '@gitkraken/compose-tools';
159
+
160
+ const result = await split({
161
+ git,
162
+ model,
163
+ branchName: 'feature/large-pr',
164
+ baseBranch: 'main',
165
+ onBeforeApply: async (plan) => {
166
+ for (const p of plan.partitioning.partitions) console.log(`${p.branchName}: ${p.title}`);
167
+ return true;
168
+ },
169
+ });
170
+ ```
171
+
172
+ Requires at least 2 commits on the branch. The original branch is replaced by the partition branches.
173
+
174
+ ### `undoCompose(input)`
175
+
176
+ See [Undo](#undo) below.
177
+
178
+ ## Sources
179
+
180
+ | Source | Description |
181
+ |---|---|
182
+ | `{ type: 'workdir', stagedOnly?, noUntracked? }` | Collects staged, unstaged, and untracked changes. `stagedOnly: true` limits to the index; `noUntracked: true` skips untracked files. |
183
+ | `{ type: 'branch', name, mergeTarget? }` | Collects `mergeBase(mergeTarget, name) → name` as one combined diff. `mergeTarget` defaults to `'main'`. |
184
+ | `{ type: 'commit-range', branch, from, to }` | Collects a contiguous range on `branch` with per-hunk author info. `from` is **exclusive** (parent of the first commit to include); `to` is **inclusive**. For a single-commit recompose: `from = parent(sha), to = sha`. |
185
+
186
+ ## Targets
187
+
188
+ | Target | Description |
189
+ |---|---|
190
+ | `{ type: 'head' }` | Commit onto the current branch. Default for `workdir` source. |
191
+ | `{ type: 'branch', name, preserveOriginal? }` | Create a single named branch. `preserveOriginal: true` keeps the source branch. |
192
+ | `{ type: 'branch-replace' }` | Replace the source branch in-place. Only valid with a `branch` source. |
193
+ | `{ type: 'auto', preserveOriginal? }` | Create one branch per concern, optionally subdividing into stacked sub-branches. Default for `branch` source. |
194
+ | `{ type: 'rewrite-range', branch, rangeFrom, rangeTo, preserveSuffix? }` | Rewrite a contiguous range on `branch`. Commits before `rangeFrom` always stay. When `preserveSuffix: true` (default), commits after `rangeTo` are cherry-picked onto the new range tip. Intended for use with `source: 'commit-range'`. Cherry-pick conflicts surface as `GitError('CHERRY_PICK_CONFLICT')`. |
195
+
196
+ ## Options
197
+
198
+ ### `depth`
199
+
200
+ Controls how many AI calls run and how deeply each reasons.
201
+
202
+ | Depth | Phases | Best for |
203
+ |---|---|---|
204
+ | `'quick'` | Single `compose-group` call (lightweight prompt) | Small changes, fast feedback |
205
+ | `'balanced'` (default) | `compose-group` + optional `order` | Most use cases |
206
+ | `'deep'` | `analyze` + `group` + `order` + optional `partition` per branch | Large, complex changesets |
207
+
208
+ ### `strategy`
209
+
210
+ Controls AI aggressiveness at grouping and partitioning time. All settings default to `'balanced'`.
211
+
212
+ ```typescript
213
+ strategy: {
214
+ grouping: {
215
+ branchSplitting: 'conservative' | 'balanced' | 'aggressive',
216
+ commitGranularity: 'consolidated' | 'balanced' | 'granular',
217
+ },
218
+ partition: {
219
+ splitting: 'conservative' | 'balanced' | 'aggressive',
220
+ },
221
+ }
222
+ ```
223
+
224
+ - `branchSplitting` — how aggressively to separate changes into distinct branches
225
+ - `commitGranularity` — how fine-grained individual commits should be
226
+ - `partition.splitting` — how aggressively to subdivide branches into stacked sub-branches
227
+
228
+ ### `onBeforeApply`
229
+
230
+ Inspect the plan before any git mutations. Return `false` to abort — the workflow returns the plan without applying it.
231
+
232
+ ```typescript
233
+ onBeforeApply: async (plan) => {
234
+ console.log(`${plan.branches.length} branches, ${plan.allOrderedCommits.length} commits`);
235
+ return true;
236
+ }
237
+ ```
238
+
239
+ Equivalent to calling `composePlan` — useful when you want the sugar `compose` path but need a chance to intervene.
240
+
241
+ ### `onBeforeModelCall` / `onAfterModelCall`
242
+
243
+ Per-call observability hooks. Informational only — the library doesn't interpret the return.
244
+
245
+ ```typescript
246
+ onBeforeModelCall: ({ phase, attempt, maxAttempts, callId }) => {
247
+ // phase: 'analyze' | 'group' | 'order' | 'partition' | 'compose-group' | 'quick-compose'
248
+ // callId: stable UUID — correlates with onAfterModelCall and with result.feedbackTargetCallId
249
+ },
250
+
251
+ onAfterModelCall: ({ phase, attempt, callId, success, durationMs, usage, error, promptVersion }) => {
252
+ // Fires on both success and error
253
+ },
254
+ ```
255
+
256
+ Consumers build their own per-call telemetry from these. No built-in telemetry is emitted by the library.
257
+
258
+ ### `onBeforePrompt`
259
+
260
+ Gating hook — run immediately before each `model.generate`. Returning `false` aborts the workflow with `WorkflowError('CANCELLED')`. Use for large-prompt confirmations, cost warnings, org policy checks.
261
+
262
+ ```typescript
263
+ onBeforePrompt: async ({ phase, attempt, tokenEstimate, charCount, messageCount }) => {
264
+ if (tokenEstimate > 20_000) {
265
+ return await showUserConfirmation(`Large prompt (${tokenEstimate} tokens). Continue?`);
266
+ }
267
+ return true;
268
+ }
269
+ ```
270
+
271
+ ### `hunkFilter`
272
+
273
+ Runs after source collection and before the AI pipeline. Drops hunks the consumer doesn't want the AI to see. Used for AIIgnore globs, org policy filters, etc.
274
+
275
+ ```typescript
276
+ hunkFilter: async (hunks) => hunks.filter(h => !isAiIgnored(h.fileName)),
277
+ ```
278
+
279
+ The filter must preserve the original `index` values on remaining hunks (only drop entries, never renumber). The library rebuilds the safety snapshot to reflect just the filtered subset, so `verifyResultingDiff` still has a correct expectation. `result.hunksFilteredCount` tells the consumer how many were dropped.
280
+
281
+ ### `signing`
282
+
283
+ GPG signing for produced commits. Typically derived from `git config commit.gpgsign` + `user.signingkey`.
284
+
285
+ ```typescript
286
+ signing: {
287
+ enabled: true,
288
+ signingKey: 'ABCD1234', // optional — passed to commit-tree as -S<key>
289
+ gpgProgram: '/usr/bin/gpg', // optional — sets GIT_GPG_PROGRAM env
290
+ }
291
+ ```
292
+
293
+ ### `authorAttribution`
294
+
295
+ How the primary author is picked when a commit's hunks come from multiple original authors (recompose / commit-range sources). Ignored when no hunks carry author info (e.g. workdir source).
296
+
297
+ | Value | Behavior |
298
+ |---|---|
299
+ | `'plurality'` (default) | The author who owns the most hunks wins; all other distinct authors become `Co-authored-by:` trailers |
300
+ | `'first-hunk'` | Use the first hunk's author; other distinct authors become `Co-authored-by:` trailers |
301
+ | function | Fully custom — inspect the hunks and return the primary identity (or `undefined` to use the commit creator's identity). Co-authors are still derived from the union of remaining authors |
302
+
303
+ ### `signal`
304
+
305
+ Standard `AbortSignal`. Threaded through every AI call, every git operation, and every hook. On abort, the workflow throws `WorkflowError('CANCELLED')`.
306
+
307
+ ### `session` (resumable workflows)
308
+
309
+ Pass a `ComposeSession` from a prior run to skip already-completed AI phases. Each task checks its own entry in `completedSteps`; if present, the task returns the cached result instead of invoking the model.
310
+
311
+ Useful when a user ToS-rejected between phases, when a CLI wants to build plans incrementally, or when recovering from a transient failure without redoing the whole pipeline.
312
+
313
+ ```typescript
314
+ // First attempt — aborted between analyze and group
315
+ const first = await composePlan({ git, model, depth: 'deep', ... });
316
+
317
+ // Retry with the session — analyze is skipped, group/order/partition run fresh
318
+ const second = await composePlan({ git, model, depth: 'deep', session: first.session, ... });
319
+ ```
320
+
321
+ A resumed task contributes no usage and no callIds — telemetry for that phase reflects the original run.
322
+
323
+ ### `promptOverrides`
324
+
325
+ Per-task prompt overrides for consumers with their own prompt template services (server-driven templates, provider-specific templates, org-tuned wording).
326
+
327
+ ```typescript
328
+ import type { ComposeTaskName, PromptOverride } from '@gitkraken/compose-tools';
329
+
330
+ promptOverrides: {
331
+ group: {
332
+ system: myCustomGroupSystemPrompt, // replaces the built-in system prompt entirely
333
+ userPrefix: 'Additional context:\n...', // prepended to the user prompt
334
+ userSuffix: '\nReminder: prefer short subjects.', // appended
335
+ },
336
+ order: {
337
+ userSuffix: myOrderingHints,
338
+ },
339
+ }
340
+ ```
341
+
342
+ Task names: `'analyze' | 'group' | 'compose-group' | 'order' | 'partition' | 'quick-compose'`.
343
+
344
+ The library still runs the same validators against the model response, so a replacement `system` prompt must preserve the output contract the task expects (JSON shape inside `<output>` tags, hunk index constraints, etc.).
345
+
346
+ ### `context`
347
+
348
+ Optional issue / PR URLs surfaced in commit messages.
349
+
350
+ ```typescript
351
+ context: { issueUrl: 'https://github.com/org/repo/issues/42', pullRequestUrl: '...' }
352
+ ```
353
+
354
+ ### `instructions`
355
+
356
+ Free-form natural-language guidance passed to the AI.
357
+
358
+ ```typescript
359
+ instructions: 'Separate the refactoring from the feature work. Put all test changes in their own commit.'
360
+ ```
361
+
362
+ ### `maxRetries`
363
+
364
+ Per-task retry count when the AI output fails validation. Default `3`.
365
+
366
+ ### `conventions` / `conventionAuthor`
367
+
368
+ Override auto-detected commit message conventions. By default the library samples recent commits from the repo. Set `conventionAuthor` to sample commits from a specific author only (handy when the current user's style should drive it).
369
+
370
+ ## Result metadata
371
+
372
+ Every top-level entry point returns an object with these common fields:
373
+
374
+ | Field | Set on | Meaning |
375
+ |---|---|---|
376
+ | `plan` | all paths | The AI's full analysis / grouping / ordering / branches |
377
+ | `snapshot` | all paths | Safety hash fingerprint of the collection — carry it to apply time |
378
+ | `source` | all paths | `{ type, hunks, commitMessages?, branchName?, baseBranch? }` — the hunks the AI operated on |
379
+ | `session` | all paths | Pass to a subsequent call to resume |
380
+ | `phases` | all paths | `PhaseDetail[]` with `{ name, durationMs, attempts, usage?, modelId?, promptVersion?, callIds? }` per phase |
381
+ | `usage` | all paths | Aggregated `{ inputTokens, outputTokens, ... }` across all AI calls |
382
+ | `diffStats` | sugar / plan | `{ fileCount, hunkCount, addedLines, removedLines }` |
383
+ | `feedbackTargetCallId` | when AI ran | callId of the last validated model response — stamp this on thumbs up/down feedback events |
384
+ | `hunksFilteredCount` | when `hunkFilter` ran | Number of hunks the filter dropped |
385
+ | `commitShas` | apply paths | `{ commitId: sha }` mapping for every commit written |
386
+ | `undoId` | apply paths | Pass to `undoCompose()` to reverse |
387
+ | `partitions` / `partitionGroups` | apply paths with partitioning | Applied partition info |
388
+
389
+ ## Undo
390
+
391
+ Every operation that mutates the repository returns an `undoId`. The undo system is **surgical** — it reverses only the git mutations that were recorded, rather than restoring a full repository snapshot.
392
+
393
+ ### Undo an operation
394
+
395
+ ```typescript
396
+ import { undoCompose } from '@gitkraken/compose-tools';
397
+
398
+ const undoResult = await undoCompose({
399
+ git,
400
+ undoId: result.undoId,
401
+ onProgress: (step) => console.log(step),
402
+ });
403
+
404
+ // undoResult.deletedBranches — branches removed (reversed a creation)
405
+ // undoResult.recreatedBranches — branches restored (reversed a deletion)
406
+ // undoResult.restoredBranches — branches moved back to their pre-operation SHA
407
+ // undoResult.restoredHead — whether HEAD was restored
408
+ // undoResult.restoredWorkdir — whether the working directory was restored
409
+ // undoResult.warnings — non-fatal issues encountered
410
+ ```
411
+
412
+ ### Safety checks
413
+
414
+ The undo system validates safety before executing. It refuses to proceed when:
415
+
416
+ - **Dirty working directory** — uncommitted changes would be at risk. Never force-able.
417
+ - **Branch has new commits** — a branch created by the operation has been modified since. Force-able with `force: true`.
418
+ - **HEAD SHA unreachable** — the pre-operation HEAD commit is no longer reachable (e.g. after garbage collection).
419
+ - **Manifest not found** — the undo ID doesn't correspond to a stored manifest.
420
+
421
+ ### Dry-run validation
422
+
423
+ ```typescript
424
+ import { validateUndoCompose } from '@gitkraken/compose-tools';
425
+
426
+ const validation = await validateUndoCompose({ git, undoId: result.undoId });
427
+
428
+ if (!validation.safe) {
429
+ for (const blocker of validation.blockers) console.error(`Blocked: ${blocker.type} — ${blocker.message}`);
430
+ }
431
+ for (const warning of validation.warnings) console.warn(`Warning: ${warning.type} — ${warning.message}`);
432
+ ```
433
+
434
+ ### Force undo
435
+
436
+ ```typescript
437
+ await undoCompose({ git, undoId, force: true });
438
+ ```
439
+
440
+ Downgrades the "branch has new commits" blocker to a warning. The `dirty_workdir` blocker is never force-able.
441
+
442
+ ### List all undo IDs
443
+
444
+ ```typescript
445
+ import { listUndoIds } from '@gitkraken/compose-tools';
446
+
447
+ const ids = await listUndoIds(git);
448
+ ```
449
+
450
+ Manifests are persisted as git objects under `refs/undo/<id>` — they survive branch operations and are cleaned up automatically when an undo is executed.
451
+
452
+ ## Individual task functions
453
+
454
+ The AI pipeline is built from composable task functions. Call them individually for custom workflows.
455
+
456
+ | Function | Purpose |
457
+ |---|---|
458
+ | `analyze(input)` | Identify logical concerns, dependencies, and ordering constraints in a set of hunks |
459
+ | `group(input)` | Group hunks into branches and commits |
460
+ | `order(input)` | Determine optimal commit ordering within each branch |
461
+ | `partition(input)` | Subdivide a branch into stacked sub-branches |
462
+ | `composeGroup(input)` | Combined analyze + group in a single AI call (used by `balanced` / `quick` depths) |
463
+ | `quickCompose(input)` | Full pipeline (analyze + group + order + optional partition) in a single AI call |
464
+
465
+ Each task accepts an optional `session` to maintain AI conversation context (or to resume from cached `completedSteps`), and returns an updated session in its output. Each task also accepts `promptOverride`, `onBeforeModelCall`, `onAfterModelCall`, `onBeforePrompt`, and `signal` for the same behaviors as the top-level entry points.
466
+
467
+ ## Error handling
468
+
469
+ | Error | Codes |
470
+ |---|---|
471
+ | `GitError` | `NO_CHANGES`, `BRANCH_NOT_FOUND`, `OPERATION_FAILED`, `SAFETY_CHECK_FAILED`, `CHERRY_PICK_CONFLICT` |
472
+ | `WorkflowError` | `SAFETY_CHECK_FAILED` (workdir changed between plan and apply), `INTERNAL`, `CANCELLED` |
473
+ | `WorkflowInputError` | Invalid workflow input (missing `model`, incompatible source/target combination, etc.) |
474
+ | `TaskInputError` | Invalid task input |
475
+ | `AIError` | AI output failed schema validation after all retries. Code: `VALIDATION_EXHAUSTED` |
476
+ | `ComposeGitPortMissingOpError` | A flow needs a `ComposeGitOps` method the consumer didn't provide and `exec` isn't available either. Has `.op` (missing op name) and `.hint`. |
477
+
478
+ ```typescript
479
+ import { compose, GitError, WorkflowError, AIError } from '@gitkraken/compose-tools';
480
+
481
+ try {
482
+ await compose({ git, model });
483
+ } catch (error) {
484
+ if (error instanceof GitError && error.code === 'NO_CHANGES') {
485
+ console.log('Nothing to compose');
486
+ } else if (error instanceof GitError && error.code === 'CHERRY_PICK_CONFLICT') {
487
+ console.error(`Conflict cherry-picking ${error.detail?.sha}`);
488
+ } else if (error instanceof AIError) {
489
+ console.error('AI could not produce valid output after retries');
490
+ } else if (error instanceof WorkflowError && error.code === 'CANCELLED') {
491
+ console.log('Operation cancelled');
492
+ } else if (error instanceof WorkflowError) {
493
+ console.error(`Workflow failed: ${error.code} — ${error.message}`);
494
+ }
495
+ }
496
+ ```
497
+
498
+ ## Progress tracking
499
+
500
+ Every top-level entry point accepts an `onProgress` callback that receives a discriminated `ComposeProgressEvent`. Each event carries a default `message`, a relative `weight` (for progress bar math), and a phase-specific payload.
501
+
502
+ ```typescript
503
+ import { compose, defaultPhaseMessages } from '@gitkraken/compose-tools';
504
+
505
+ const result = await compose({
506
+ git,
507
+ model,
508
+ onProgress: (event) => {
509
+ // event.phase: 'collecting' | 'compose-grouping' | 'analyzing' | 'grouping' |
510
+ // 'ordering' | 'partitioning' | 'verifying' | 'applying'
511
+ // event.message: default English label — use directly or map to localized copy
512
+ // event.weight: approximate relative duration for progress math
513
+ // event.event: ProgressEvent with { type, attempt, maxAttempts, errors? } (AI phases)
514
+ // event.detail: phase-specific string (non-AI phases)
515
+ // event.branch: only for 'partitioning', names the branch being subdivided
516
+ },
517
+ });
518
+
519
+ // defaultPhaseMessages is exported for consumers who want to remap phase labels
520
+ // for localization without losing the weights.
521
+ ```
522
+
523
+ After completion, `result.phases` has timing and usage per phase:
524
+
525
+ ```typescript
526
+ for (const phase of result.phases) {
527
+ console.log(`${phase.name}: ${phase.durationMs}ms, ${phase.attempts} attempts`);
528
+ if (phase.usage) console.log(` ${phase.usage.inputTokens} in, ${phase.usage.outputTokens} out`);
529
+ if (phase.callIds) console.log(` call IDs: ${phase.callIds.join(', ')}`);
530
+ }
531
+
532
+ console.log(`Total tokens: ${result.usage.inputTokens} in, ${result.usage.outputTokens} out`);
533
+ ```
534
+
535
+ ## Contributing
536
+
537
+ This package follows the monorepo's standard tooling — see the root [CONTRIBUTING.md](../../CONTRIBUTING.md). Local commands:
538
+
539
+ ```bash
540
+ pnpm --filter @gitkraken/compose-tools build
541
+ pnpm --filter @gitkraken/compose-tools typecheck
542
+ pnpm --filter @gitkraken/compose-tools test
543
+ ```
@@ -0,0 +1,12 @@
1
+ import { AiMessage } from "@gitkraken/shared-tools";
2
+
3
+ //#region src/ai/session.d.ts
4
+ interface AiSession {
5
+ messages: AiMessage[];
6
+ systemPrompt: string;
7
+ }
8
+ declare function createAiSession(systemPrompt: string): AiSession;
9
+ declare function appendUserMessage<S extends AiSession>(session: S, content: string): S;
10
+ declare function appendAssistantMessage<S extends AiSession>(session: S, content: string): S;
11
+ //#endregion
12
+ export { AiSession, appendAssistantMessage, appendUserMessage, createAiSession };
@@ -0,0 +1 @@
1
+ function e(e){return{messages:[],systemPrompt:e}}function t(e,t){return{...e,messages:[...e.messages,{role:`user`,content:t}]}}function n(e,t){return{...e,messages:[...e.messages,{role:`assistant`,content:t}]}}export{n as appendAssistantMessage,t as appendUserMessage,e as createAiSession};
@@ -0,0 +1,31 @@
1
+ //#region src/ai/types.d.ts
2
+ interface TokenUsage {
3
+ inputTokens: number;
4
+ outputTokens: number;
5
+ cacheReadTokens?: number;
6
+ cacheWriteTokens?: number;
7
+ reasoningTokens?: number;
8
+ cost?: number;
9
+ }
10
+ declare function addUsage(total: TokenUsage, part: TokenUsage): void;
11
+ declare function emptyUsage(): TokenUsage;
12
+ interface PhaseDetail {
13
+ name: string;
14
+ durationMs: number;
15
+ attempts: number;
16
+ usage?: TokenUsage;
17
+ /** Which model ran this phase. Set for AI phases, undefined otherwise. */
18
+ modelId?: string;
19
+ /** Identifier of the prompt template used for this phase, when a consumer override supplied one. */
20
+ promptVersion?: string;
21
+ /** Stable call IDs for each model.generate invocation in this phase (one per attempt). */
22
+ callIds?: string[];
23
+ }
24
+ interface ProgressEvent {
25
+ type: 'attempt_start' | 'validation_failed';
26
+ attempt: number;
27
+ maxAttempts: number;
28
+ errors?: string[];
29
+ }
30
+ //#endregion
31
+ export { PhaseDetail, ProgressEvent, TokenUsage, addUsage, emptyUsage };
@@ -0,0 +1 @@
1
+ function e(e,t){e.inputTokens+=t.inputTokens,e.outputTokens+=t.outputTokens,t.cacheReadTokens!=null&&(e.cacheReadTokens=(e.cacheReadTokens??0)+t.cacheReadTokens),t.cacheWriteTokens!=null&&(e.cacheWriteTokens=(e.cacheWriteTokens??0)+t.cacheWriteTokens),t.reasoningTokens!=null&&(e.reasoningTokens=(e.reasoningTokens??0)+t.reasoningTokens),t.cost!=null&&(e.cost=(e.cost??0)+t.cost)}function t(){return{inputTokens:0,outputTokens:0}}export{e as addUsage,t as emptyUsage};
@@ -0,0 +1,20 @@
1
+ //#region src/cancellation.d.ts
2
+ /**
3
+ * Throws a `WorkflowError('CANCELLED')` if the signal has been aborted.
4
+ *
5
+ * Call at workflow phase boundaries so cancellation is observable even when
6
+ * a consumer's adapter doesn't fully respect the signal on every operation.
7
+ * Individual `git.exec` and `model.generate` calls should also receive the
8
+ * signal via their options; this helper is the coarse-grained backstop.
9
+ */
10
+ declare function throwIfCancelled(signal?: AbortSignal): void;
11
+ /**
12
+ * Wraps a native `AbortError` (thrown by a signal-aware adapter) in a
13
+ * `WorkflowError('CANCELLED')`. Rethrows non-cancellation errors unchanged.
14
+ *
15
+ * Use to normalize exceptions from `git.exec` or `model.generate` so consumers
16
+ * only have to check for `WorkflowError` with code `'CANCELLED'`.
17
+ */
18
+ declare function wrapCancellation<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T>;
19
+ //#endregion
20
+ export { throwIfCancelled, wrapCancellation };
@@ -0,0 +1 @@
1
+ import{WorkflowError as e}from"./errors/workflow.js";function t(t){if(t?.aborted)throw new e(`CANCELLED`,t.reason instanceof Error?t.reason.message:`Operation cancelled`)}function n(t,n){return t.catch(t=>{throw r(t)||n?.aborted?new e(`CANCELLED`,t instanceof Error?t.message:`Operation cancelled`,{cause:t}):t})}function r(e){return e instanceof Error&&(e.name===`AbortError`||e.name===`DOMException`)}export{t as throwIfCancelled,n as wrapCancellation};