@gitkraken/compose-tools 0.3.2 → 0.4.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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/) and this
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.0] - 2026-06-26
10
+
11
+ ### Added
12
+
13
+ - Adds `refinePlan` — produces an updated `ComposePlan` from a prior plan + session via a single AI continuation call (no diff collection, no compose pipeline, no git mutation). Accepts `lockedCommits` (ids preserved verbatim from the prior plan) and `clientContext` (consumer-supplied prompt context). Type exports: `RefinePlanInput`, `RefinePlanResult`, `RefineProgressEvent`.
14
+ - Exports a named `ComposeDiffStats` type for the `{ fileCount, hunkCount, addedLines, removedLines }` shape (previously inline on `ComposeResult.diffStats`).
15
+
16
+ ### Changed
17
+
18
+ - `ComposeProgressEvent`'s `collecting` variant now emits twice: once at the start as a spinner trigger (no `stats`), then again after collection and `hunkFilter` complete with a new `stats?: ComposeDiffStats` field carrying `{ fileCount, hunkCount, addedLines, removedLines }` — so consumers can render live file/hunk/line counts inline ("Collecting changes… done (3 files, +94/-5)") without re-deriving from `git diff --numstat`. The same second emit now also fires from `applyComposePlan`.
19
+ - `ComposePlanResult.diffStats` — `composePlan` now forwards the existing `diffStats` from `ComposeResult` so consumers using the two-phase `composePlan → applyComposePlan` flow can see the same counts on the plan result.
20
+
21
+ ### Fixed
22
+
23
+ - Fixes undo for compose apply targeting pre-existing branches. Previously, `applyBranchTarget` unconditionally recorded the target in createdRefs, causing undo to delete the branch entirely instead of restoring it.
24
+
9
25
  ## [0.3.2] - 2026-06-02
10
26
 
11
27
  ### Fixed
package/README.md CHANGED
@@ -600,6 +600,8 @@ const result = await compose({
600
600
  // event.event: ProgressEvent with { type, attempt, maxAttempts, errors? } (AI phases)
601
601
  // event.detail: phase-specific string (non-AI phases)
602
602
  // event.branch: only for 'partitioning', names the branch being subdivided
603
+ // event.stats: only for 'collecting'; set on the post-collection emit
604
+ // with { fileCount, hunkCount, addedLines, removedLines }
603
605
  },
604
606
  });
605
607
 
@@ -1 +1 @@
1
- import{GitError as e}from"../errors/git.js";import{applyPatchToWorkdir as t,checkout as n,cleanWorkdir as r,currentBranch as i,deleteBranch as a,discardWorkdirChanges as o,reset as s,revParse as c,updateRef as l}from"./port-dispatch.js";import{applyBranchStructure as u}from"./apply-branches.js";import{reparentDescendants as d}from"./reparent.js";import{applyCommitChain as f,applyRangeRewrite as p,buildCommitChain as m,moveRefsAtSha as h}from"./commit-chain.js";import{verifyMultiBranchDiff as g,verifyResultingDiff as _}from"./safety.js";async function v(t){let{git:n,source:r,target:i,branchPlans:a,allOrderedCommits:o,hunks:s,snapshot:l,collection:d,signing:h,authorAttribution:g,stashLabel:v,leftoverPatch:x}=t;if(i.type===`rewrite-range`){if(!d.branchName||!d.branchSha)throw new e(`OPERATION_FAILED`,`rewrite-range target requires a source that records branchName and branchSha (branch or commit-range).`);let t;try{t=await c(n,`${i.rangeFrom}^`)}catch{throw new e(`OPERATION_FAILED`,`rewrite-range target's rangeFrom (${i.rangeFrom.slice(0,8)}) has no parent (root commit) — rewriting the root commit is not currently supported.`)}let{headSha:a,shaMap:u}=await m({git:n,baseSha:t,commits:o,hunks:s,signing:h,authorAttribution:g}),f=await _(n,t,a,l);if(!f.valid)throw new e(`SAFETY_CHECK_FAILED`,`Diff verification failed: ${f.error}`);let y=r.type===`commit-range`&&r.includeWorkdir!=null,b=await p({git:n,branch:i.branch,rangeFrom:i.rangeFrom,rangeTo:i.rangeTo,newHeadSha:a,originalBranchSha:d.branchSha,stashLabel:v,leftoverPatch:x,includeWorkdirActive:y});return{commitShas:Object.fromEntries(u),mutations:T(b.refMovements),stashConflict:b.stashConflict}}if(d.branchName&&!d.mergeBaseSha)throw new e(`OPERATION_FAILED`,`Branch source is missing mergeBaseSha — cannot determine base commit`);let S=d.mergeBaseSha??await c(n,`HEAD`),w=D(r,i);if(i.type===`auto`&&a.length>1)return y(n,S,a,s,l,d,w,h,g);if(i.type===`auto`&&a.length===1&&a[0].partitioning)return b(n,S,a[0],s,l,d,w,h,g);let{headSha:k,shaMap:A}=await m({git:n,baseSha:S,commits:o,hunks:s,signing:h,authorAttribution:g}),j=Object.fromEntries(A),M=await _(n,S,k,l);if(!M.valid)throw new e(`SAFETY_CHECK_FAILED`,`Diff verification failed: ${M.error}`);if(i.type===`branch`)return O({git:n,source:r,target:i,newHeadSha:k,collection:d,stashLabel:v,leftoverPatch:x,commitShas:j});if(i.type===`auto`){let e=a[0]?.branchGroup.name||d.branchName;if(e){let t=!w&&d.branchName&&d.branchSha?{originalBranchName:d.branchName,originalBranchSha:d.branchSha}:{},r={branchName:e,title:a[0]?.branchGroup.title??``,description:a[0]?.branchGroup.description??``,commitIds:o.map(e=>e.id)};await u({git:n,branches:[{partitions:[r],tipSha:k}],...t});let i=E([r],j,t.originalBranchName,t.originalBranchSha);return{partitions:[r],commitShas:j,mutations:i}}let t=await f({git:n,newHeadSha:k,stashLabel:v});return{commitShas:j,mutations:C(),stashConflict:t.stashConflict}}let N=await f({git:n,newHeadSha:k,stashLabel:v,leftoverPatch:x});return{commitShas:j,mutations:C(),stashConflict:N.stashConflict}}async function y(t,n,i,a,s,c,l,d,f){let p=[],h=[],_=[],v=[],y={};for(let e of i){let r=x(e),{headSha:i,shaMap:o}=await m({git:t,baseSha:n,commits:r,hunks:a,signing:d,authorAttribution:f});_.push(i);for(let[e,t]of o)y[e]=t;if(e.partitioning&&e.partitioning.partitions.length>1){let t=S(e.partitioning.partitions,o);p.push(...t),h.push(t),v.push({partitions:t,tipSha:i})}else{let t={branchName:e.branchGroup.name,title:e.branchGroup.title,description:e.branchGroup.description,commitIds:r.map(e=>o.get(e.id)??e.id)};p.push(t),h.push([t]),v.push({partitions:[t],tipSha:i})}}let b=await g(t,n,_,s);if(!b.valid)throw new e(`SAFETY_CHECK_FAILED`,`Diff verification failed: ${b.error}`);let C=!l&&c.branchName&&c.branchSha?{originalBranchName:c.branchName,originalBranchSha:c.branchSha}:{};return await u({git:t,branches:v,...C}),!c.branchName&&!l&&(await o(t),c.untrackedIncluded&&await r(t)),{partitions:p,partitionGroups:h,commitShas:y,mutations:E(p,y,C.originalBranchName,C.originalBranchSha)}}async function b(t,n,i,a,s,c,l,d,p){let{headSha:h,shaMap:g}=await m({git:t,baseSha:n,commits:x(i),hunks:a,signing:d,authorAttribution:p}),v=await _(t,n,h,s);if(!v.valid)throw new e(`SAFETY_CHECK_FAILED`,`Diff verification failed: ${v.error}`);let y=S(i.partitioning.partitions,g),b=Object.fromEntries(g);if(y.length>1){let e=!l&&c.branchName&&c.branchSha?{originalBranchName:c.branchName,originalBranchSha:c.branchSha}:{};return await u({git:t,branches:[{partitions:y,tipSha:h}],...e}),!c.branchName&&!l&&(await o(t),c.untrackedIncluded&&await r(t,{force:!0,directories:!0})),{partitions:y,commitShas:b,mutations:E(y,b,e.originalBranchName,e.originalBranchSha)}}return await f({git:t,newHeadSha:h}),{partitions:y,commitShas:b,mutations:C()}}function x(t){return t.orderedCommitIds.map(n=>{let r=t.branchGroup.commits.find(e=>e.id===n);if(!r)throw new e(`OPERATION_FAILED`,`Commit ${n} not found in branch '${t.branchGroup.id}'`);return r})}function S(t,n){return t.map(t=>({...t,commitIds:t.commitIds.map(t=>{let r=n.get(t);if(!r)throw new e(`OPERATION_FAILED`,`No SHA found for commit ID '${t}'`);return r})}))}function C(){return{createdRefs:[],deletedRefs:[],movedRefs:[]}}function w(t){if(t.startsWith(`refs/heads/`))return{kind:`branch`,name:t.slice(11)};if(t.startsWith(`refs/tags/`))return{kind:`tag`,name:t.slice(10)};throw new e(`OPERATION_FAILED`,`Unexpected refname '${t}' in apply mutations — only refs/heads/* and refs/tags/* are tracked.`)}function T(e){let t=[];for(let n of e){let{kind:e,name:r}=w(n.refname);t.push({kind:e,name:r,previousSha:n.previousSha,newSha:n.newSha})}return{createdRefs:[],deletedRefs:[],movedRefs:t}}function E(e,t,n,r){let i=e.map(e=>{let n=e.commitIds[e.commitIds.length-1];return{kind:`branch`,name:e.branchName,sha:t[n]??n}}),a=[];return n&&r&&a.push({kind:`branch`,name:n,sha:r}),{createdRefs:i,deletedRefs:a,movedRefs:[]}}function D(e,t){return t.type!==`branch`&&t.type!==`auto`||e.type===`commit-range`?!0:t.preserveOriginal===!0}async function O(r){let{git:o,source:u,target:f,newHeadSha:p,collection:m,leftoverPatch:g,commitShas:_}=r,v=D(u,f),y=u.type===`branch`&&!v&&m.branchName!=null&&m.branchSha!=null;if(y&&f.name===m.branchName)throw new e(`OPERATION_FAILED`,`target 'branch' with name '${f.name}' conflicts with the source branch (preserveOriginal=false). Use target 'rewrite-range' to rewrite the branch in place under its existing name.`);let b={createdRefs:[{kind:`branch`,name:f.name,sha:p}],deletedRefs:[],movedRefs:[]},x=await i(o),S=await c(o,`HEAD`);if(await l(o,`refs/heads/${f.name}`,p),y){let e=m.branchSha,r=m.branchName,i=await d({git:o,oldToSha:e,newTopSha:p,sourceBranchName:void 0});for(let e of i.reparentedRefs){let{kind:t,name:n}=w(e.refname);b.movedRefs.push({kind:t,name:n,previousSha:e.oldSha,newSha:e.newSha})}let c=await h(o,e,p,`refs/heads/${r}`);for(let e of c){let{kind:t,name:n}=w(e.refname);b.movedRefs.push({kind:t,name:n,previousSha:e.previousSha,newSha:e.newSha})}x===r&&(await n(o,f.name),await s(o,p,`hard`),g&&await t(o,g)),await a(o,r,{force:!0}),b.deletedRefs.push({kind:`branch`,name:r,sha:e})}else u.type===`workdir`&&!v&&(await s(o,S,`hard`),g&&await t(o,g));return{commitShas:_,mutations:b}}export{v as applyToTarget};
1
+ import{GitError as e}from"../errors/git.js";import{applyPatchToWorkdir as t,checkout as n,cleanWorkdir as r,currentBranch as i,deleteBranch as a,discardWorkdirChanges as o,reset as s,revParse as c,revParseVerify as l,updateRef as u}from"./port-dispatch.js";import{applyBranchStructure as d}from"./apply-branches.js";import{reparentDescendants as f}from"./reparent.js";import{applyCommitChain as p,applyRangeRewrite as m,buildCommitChain as h,moveRefsAtSha as g}from"./commit-chain.js";import{verifyMultiBranchDiff as _,verifyResultingDiff as v}from"./safety.js";async function y(t){let{git:n,source:r,target:i,branchPlans:a,allOrderedCommits:o,hunks:s,snapshot:l,collection:u,signing:f,authorAttribution:g,stashLabel:_,leftoverPatch:y}=t;if(i.type===`rewrite-range`){if(!u.branchName||!u.branchSha)throw new e(`OPERATION_FAILED`,`rewrite-range target requires a source that records branchName and branchSha (branch or commit-range).`);let t;try{t=await c(n,`${i.rangeFrom}^`)}catch{throw new e(`OPERATION_FAILED`,`rewrite-range target's rangeFrom (${i.rangeFrom.slice(0,8)}) has no parent (root commit) — rewriting the root commit is not currently supported.`)}let{headSha:a,shaMap:d}=await h({git:n,baseSha:t,commits:o,hunks:s,signing:f,authorAttribution:g}),p=await v(n,t,a,l);if(!p.valid)throw new e(`SAFETY_CHECK_FAILED`,`Diff verification failed: ${p.error}`);let b=r.type===`commit-range`&&r.includeWorkdir!=null,x=await m({git:n,branch:i.branch,rangeFrom:i.rangeFrom,rangeTo:i.rangeTo,newHeadSha:a,originalBranchSha:u.branchSha,stashLabel:_,leftoverPatch:y,includeWorkdirActive:b});return{commitShas:Object.fromEntries(d),mutations:E(x.refMovements),stashConflict:x.stashConflict}}if(u.branchName&&!u.mergeBaseSha)throw new e(`OPERATION_FAILED`,`Branch source is missing mergeBaseSha — cannot determine base commit`);let S=u.mergeBaseSha??await c(n,`HEAD`),C=O(r,i);if(i.type===`auto`&&a.length>1)return b(n,S,a,s,l,u,C,f,g);if(i.type===`auto`&&a.length===1&&a[0].partitioning)return x(n,S,a[0],s,l,u,C,f,g);let{headSha:T,shaMap:A}=await h({git:n,baseSha:S,commits:o,hunks:s,signing:f,authorAttribution:g}),j=Object.fromEntries(A),M=await v(n,S,T,l);if(!M.valid)throw new e(`SAFETY_CHECK_FAILED`,`Diff verification failed: ${M.error}`);if(i.type===`branch`)return k({git:n,source:r,target:i,newHeadSha:T,collection:u,stashLabel:_,leftoverPatch:y,commitShas:j});if(i.type===`auto`){let e=a[0]?.branchGroup.name||u.branchName;if(e){let t=!C&&u.branchName&&u.branchSha?{originalBranchName:u.branchName,originalBranchSha:u.branchSha}:{},r={branchName:e,title:a[0]?.branchGroup.title??``,description:a[0]?.branchGroup.description??``,commitIds:o.map(e=>e.id)};await d({git:n,branches:[{partitions:[r],tipSha:T}],...t});let i=D([r],j,t.originalBranchName,t.originalBranchSha);return{partitions:[r],commitShas:j,mutations:i}}let t=await p({git:n,newHeadSha:T,stashLabel:_});return{commitShas:j,mutations:w(),stashConflict:t.stashConflict}}let N=await p({git:n,newHeadSha:T,stashLabel:_,leftoverPatch:y});return{commitShas:j,mutations:w(),stashConflict:N.stashConflict}}async function b(t,n,i,a,s,c,l,u,f){let p=[],m=[],g=[],v=[],y={};for(let e of i){let r=S(e),{headSha:i,shaMap:o}=await h({git:t,baseSha:n,commits:r,hunks:a,signing:u,authorAttribution:f});g.push(i);for(let[e,t]of o)y[e]=t;if(e.partitioning&&e.partitioning.partitions.length>1){let t=C(e.partitioning.partitions,o);p.push(...t),m.push(t),v.push({partitions:t,tipSha:i})}else{let t={branchName:e.branchGroup.name,title:e.branchGroup.title,description:e.branchGroup.description,commitIds:r.map(e=>o.get(e.id)??e.id)};p.push(t),m.push([t]),v.push({partitions:[t],tipSha:i})}}let b=await _(t,n,g,s);if(!b.valid)throw new e(`SAFETY_CHECK_FAILED`,`Diff verification failed: ${b.error}`);let x=!l&&c.branchName&&c.branchSha?{originalBranchName:c.branchName,originalBranchSha:c.branchSha}:{};return await d({git:t,branches:v,...x}),!c.branchName&&!l&&(await o(t),c.untrackedIncluded&&await r(t)),{partitions:p,partitionGroups:m,commitShas:y,mutations:D(p,y,x.originalBranchName,x.originalBranchSha)}}async function x(t,n,i,a,s,c,l,u,f){let{headSha:m,shaMap:g}=await h({git:t,baseSha:n,commits:S(i),hunks:a,signing:u,authorAttribution:f}),_=await v(t,n,m,s);if(!_.valid)throw new e(`SAFETY_CHECK_FAILED`,`Diff verification failed: ${_.error}`);let y=C(i.partitioning.partitions,g),b=Object.fromEntries(g);if(y.length>1){let e=!l&&c.branchName&&c.branchSha?{originalBranchName:c.branchName,originalBranchSha:c.branchSha}:{};return await d({git:t,branches:[{partitions:y,tipSha:m}],...e}),!c.branchName&&!l&&(await o(t),c.untrackedIncluded&&await r(t,{force:!0,directories:!0})),{partitions:y,commitShas:b,mutations:D(y,b,e.originalBranchName,e.originalBranchSha)}}return await p({git:t,newHeadSha:m}),{partitions:y,commitShas:b,mutations:w()}}function S(t){return t.orderedCommitIds.map(n=>{let r=t.branchGroup.commits.find(e=>e.id===n);if(!r)throw new e(`OPERATION_FAILED`,`Commit ${n} not found in branch '${t.branchGroup.id}'`);return r})}function C(t,n){return t.map(t=>({...t,commitIds:t.commitIds.map(t=>{let r=n.get(t);if(!r)throw new e(`OPERATION_FAILED`,`No SHA found for commit ID '${t}'`);return r})}))}function w(){return{createdRefs:[],deletedRefs:[],movedRefs:[],composedBranchCount:0}}function T(t){if(t.startsWith(`refs/heads/`))return{kind:`branch`,name:t.slice(11)};if(t.startsWith(`refs/tags/`))return{kind:`tag`,name:t.slice(10)};throw new e(`OPERATION_FAILED`,`Unexpected refname '${t}' in apply mutations — only refs/heads/* and refs/tags/* are tracked.`)}function E(e){let t=[];for(let n of e){let{kind:e,name:r}=T(n.refname);t.push({kind:e,name:r,previousSha:n.previousSha,newSha:n.newSha})}return{createdRefs:[],deletedRefs:[],movedRefs:t,composedBranchCount:1}}function D(e,t,n,r){let i=e.map(e=>{let n=e.commitIds[e.commitIds.length-1];return{kind:`branch`,name:e.branchName,sha:t[n]??n}}),a=[];return n&&r&&a.push({kind:`branch`,name:n,sha:r}),{createdRefs:i,deletedRefs:a,movedRefs:[],composedBranchCount:i.length}}function O(e,t){return t.type!==`branch`&&t.type!==`auto`||e.type===`commit-range`?!0:t.preserveOriginal===!0}async function k(r){let{git:o,source:d,target:p,newHeadSha:m,collection:h,leftoverPatch:_,commitShas:v}=r,y=O(d,p),b=d.type===`branch`&&!y&&h.branchName!=null&&h.branchSha!=null;if(b&&p.name===h.branchName)throw new e(`OPERATION_FAILED`,`target 'branch' with name '${p.name}' conflicts with the source branch (preserveOriginal=false). Use target 'rewrite-range' to rewrite the branch in place under its existing name.`);let x=await l(o,`refs/heads/${p.name}`),S=x?{createdRefs:[],deletedRefs:[],movedRefs:[{kind:`branch`,name:p.name,previousSha:x,newSha:m}],composedBranchCount:1}:{createdRefs:[{kind:`branch`,name:p.name,sha:m}],deletedRefs:[],movedRefs:[],composedBranchCount:1},C=await i(o),w=await c(o,`HEAD`);if(await u(o,`refs/heads/${p.name}`,m),b){let e=h.branchSha,r=h.branchName,i=await f({git:o,oldToSha:e,newTopSha:m,sourceBranchName:void 0});for(let e of i.reparentedRefs){let{kind:t,name:n}=T(e.refname);S.movedRefs.push({kind:t,name:n,previousSha:e.oldSha,newSha:e.newSha})}let c=await g(o,e,m,`refs/heads/${r}`);for(let e of c){let{kind:t,name:n}=T(e.refname);S.movedRefs.push({kind:t,name:n,previousSha:e.previousSha,newSha:e.newSha})}C===r&&(await n(o,p.name),await s(o,m,`hard`),_&&await t(o,_)),await a(o,r,{force:!0}),S.deletedRefs.push({kind:`branch`,name:r,sha:e})}else d.type===`workdir`&&!y&&(await s(o,w,`hard`),_&&await t(o,_));return{commitShas:v,mutations:S}}export{y as applyToTarget};
@@ -34,6 +34,14 @@ interface ApplyMutations {
34
34
  createdRefs: RefSnapshot[];
35
35
  deletedRefs: RefSnapshot[];
36
36
  movedRefs: RefMovement[];
37
+ /**
38
+ * Number of branches the compose operation produced as its output. Cannot be derived from
39
+ * `movedRefs`: when a source branch is deleted (or a range rewritten), `movedRefs` also
40
+ * accumulates reparented descendants and tip-refs that moved alongside the source — those are
41
+ * collateral repositioning, not composed output. This count is set where that distinction is
42
+ * still known, so the undo manifest description doesn't report "5 branches" for a 1-branch compose.
43
+ */
44
+ composedBranchCount: number;
37
45
  }
38
46
  /** Reported when the apply path's stash pop hit conflicts. The apply itself succeeded. */
39
47
  interface StashConflict {
package/dist/index.d.ts CHANGED
@@ -24,8 +24,9 @@ import { group } from "./tasks/group/group.js";
24
24
  import { order } from "./tasks/order/order.js";
25
25
  import { partition } from "./tasks/partition/partition.js";
26
26
  import { quickCompose } from "./tasks/quick-compose/quick-compose.js";
27
- import { ApplyComposePlanInput, ApplyComposePlanResult, ComposeApplyPlan, ComposeDepth, ComposeInput, ComposePlan, ComposePlanInput, ComposePlanResult, ComposeProgressEvent, ComposeResult, ComposeStrategy, applyComposePlan, compose, composePlan, defaultPhaseMessages } from "./workflows/compose.js";
27
+ import { ApplyComposePlanInput, ApplyComposePlanResult, ComposeApplyPlan, ComposeDepth, ComposeDiffStats, ComposeInput, ComposePlan, ComposePlanInput, ComposePlanResult, ComposeProgressEvent, ComposeResult, ComposeStrategy, applyComposePlan, compose, composePlan, defaultPhaseMessages } from "./workflows/compose.js";
28
+ import { RefinePlanInput, RefinePlanResult, RefineProgressEvent, refinePlan } from "./workflows/refine.js";
28
29
  import { SplitInput, SplitPlan, SplitProgressEvent, SplitResult, split } from "./workflows/split.js";
29
30
  import { UndoComposeInput, ValidateUndoComposeInput, listUndoIds, undoCompose, validateUndoCompose } from "./workflows/undo.js";
30
31
  import { UndoForceOptions } from "@gitkraken/shared-tools";
31
- export { AIError, type AIErrorCode, type AfterModelCallInfo, type AnalysisResult, type AnalyzeInput, type AnalyzeOutput, type ApplyComposePlanInput, type ApplyComposePlanResult, type ApplyPatchOptions, type BeforeModelCallInfo, type BeforePromptInfo, type BranchGroup, type BranchOrdering, type BranchPartition, type CheckoutOptions, type CleanOptions, type CommitDetail, type CommitSuggestion, type CommitTreeOptions, type ComposeApplyPlan, type ComposeBranchPlan, type ComposeContext, type ComposeDepth, type ComposeGitOps, type ComposeGitPort, ComposeGitPortMissingOpError, type ComposeGroupInput, type ComposeGroupOutput, type ComposeHunk, type ComposeInput, type ComposePlan, type ComposePlanInput, type ComposePlanResult, type ComposeProgressEvent, type ComposeResult, type ComposeSession, type ComposeSource, type ComposeStrategy, type ComposeTarget, type ComposeTaskName, ComposeWorkflowError, type ComposeWorkflowErrorCode, ComposeWorkflowInputError, type Concern, type ConcernDependency, type ConventionDescriptor, type DeleteBranchOptions, type DiffTreeOptions, type ForEachRefOptions, type GitCommitIdentity, GitError, type GitErrorCode, type GroupInput, type GroupOutput, type GroupingResult, type GroupingStrategy, type HunkAnnotation, type HunkOrderingConstraint, type IndexScopedOptions, type LogOptions, type OnAfterModelCall, type OnBeforeModelCall, type OnBeforePrompt, type OpOptions, type OrderInput, type OrderOutput, type OrderingResult, type PartitionInput, type PartitionOutput, type PartitionResult, type PartitionStrategy, type PhaseDetail, type ProgressEvent, type PromptOverride, type QuickComposeInput, type QuickComposeOutput, type QuickComposeResult, REDACTED_HUNK_CONTENT, type RedactHunkContent, type SigningConfig, type SplitInput, type SplitPlan, type SplitProgressEvent, type SplitResult, type StageAllOptions, type StashConflict, type StashPushOptions, TaskInputError, type TokenUsage, type UndoComposeInput, type UndoForceOptions, type UpdateRefOptions, type ValidateUndoComposeInput, analyze, applyComposePlan, compose, composeGroup, composePlan, defaultPhaseMessages, estimatedCharactersPerToken, group, listUndoIds, order, partition, quickCompose, split, undoCompose, validateUndoCompose };
32
+ export { AIError, type AIErrorCode, type AfterModelCallInfo, type AnalysisResult, type AnalyzeInput, type AnalyzeOutput, type ApplyComposePlanInput, type ApplyComposePlanResult, type ApplyPatchOptions, type BeforeModelCallInfo, type BeforePromptInfo, type BranchGroup, type BranchOrdering, type BranchPartition, type CheckoutOptions, type CleanOptions, type CommitDetail, type CommitSuggestion, type CommitTreeOptions, type ComposeApplyPlan, type ComposeBranchPlan, type ComposeContext, type ComposeDepth, type ComposeDiffStats, type ComposeGitOps, type ComposeGitPort, ComposeGitPortMissingOpError, type ComposeGroupInput, type ComposeGroupOutput, type ComposeHunk, type ComposeInput, type ComposePlan, type ComposePlanInput, type ComposePlanResult, type ComposeProgressEvent, type ComposeResult, type ComposeSession, type ComposeSource, type ComposeStrategy, type ComposeTarget, type ComposeTaskName, ComposeWorkflowError, type ComposeWorkflowErrorCode, ComposeWorkflowInputError, type Concern, type ConcernDependency, type ConventionDescriptor, type DeleteBranchOptions, type DiffTreeOptions, type ForEachRefOptions, type GitCommitIdentity, GitError, type GitErrorCode, type GroupInput, type GroupOutput, type GroupingResult, type GroupingStrategy, type HunkAnnotation, type HunkOrderingConstraint, type IndexScopedOptions, type LogOptions, type OnAfterModelCall, type OnBeforeModelCall, type OnBeforePrompt, type OpOptions, type OrderInput, type OrderOutput, type OrderingResult, type PartitionInput, type PartitionOutput, type PartitionResult, type PartitionStrategy, type PhaseDetail, type ProgressEvent, type PromptOverride, type QuickComposeInput, type QuickComposeOutput, type QuickComposeResult, REDACTED_HUNK_CONTENT, type RedactHunkContent, type RefinePlanInput, type RefinePlanResult, type RefineProgressEvent, type SigningConfig, type SplitInput, type SplitPlan, type SplitProgressEvent, type SplitResult, type StageAllOptions, type StashConflict, type StashPushOptions, TaskInputError, type TokenUsage, type UndoComposeInput, type UndoForceOptions, type UpdateRefOptions, type ValidateUndoComposeInput, analyze, applyComposePlan, compose, composeGroup, composePlan, defaultPhaseMessages, estimatedCharactersPerToken, group, listUndoIds, order, partition, quickCompose, refinePlan, split, undoCompose, validateUndoCompose };
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- import{AIError as e}from"./errors/ai.js";import{GitError as t}from"./errors/git.js";import{TaskInputError as n}from"./errors/task.js";import{ComposeWorkflowError as r,ComposeWorkflowInputError as i}from"./errors/workflow.js";import{ComposeGitPortMissingOpError as a}from"./ports/git.js";import{estimatedCharactersPerToken as o}from"./tasks/ai-loop.js";import{REDACTED_HUNK_CONTENT as s}from"./tasks/utils/redact.js";import{analyze as c}from"./tasks/analyze/analyze.js";import{composeGroup as l}from"./tasks/compose-group/compose-group.js";import{group as u}from"./tasks/group/group.js";import{order as d}from"./tasks/order/order.js";import{partition as f}from"./tasks/partition/partition.js";import{quickCompose as p}from"./tasks/quick-compose/quick-compose.js";import{applyComposePlan as m,compose as h,composePlan as g,defaultPhaseMessages as _}from"./workflows/compose.js";import{split as v}from"./workflows/split.js";import{listUndoIds as y,undoCompose as b,validateUndoCompose as x}from"./workflows/undo.js";export{e as AIError,a as ComposeGitPortMissingOpError,r as ComposeWorkflowError,i as ComposeWorkflowInputError,t as GitError,s as REDACTED_HUNK_CONTENT,n as TaskInputError,c as analyze,m as applyComposePlan,h as compose,l as composeGroup,g as composePlan,_ as defaultPhaseMessages,o as estimatedCharactersPerToken,u as group,y as listUndoIds,d as order,f as partition,p as quickCompose,v as split,b as undoCompose,x as validateUndoCompose};
1
+ import{AIError as e}from"./errors/ai.js";import{GitError as t}from"./errors/git.js";import{TaskInputError as n}from"./errors/task.js";import{ComposeWorkflowError as r,ComposeWorkflowInputError as i}from"./errors/workflow.js";import{ComposeGitPortMissingOpError as a}from"./ports/git.js";import{estimatedCharactersPerToken as o}from"./tasks/ai-loop.js";import{REDACTED_HUNK_CONTENT as s}from"./tasks/utils/redact.js";import{analyze as c}from"./tasks/analyze/analyze.js";import{composeGroup as l}from"./tasks/compose-group/compose-group.js";import{group as u}from"./tasks/group/group.js";import{order as d}from"./tasks/order/order.js";import{partition as f}from"./tasks/partition/partition.js";import{quickCompose as p}from"./tasks/quick-compose/quick-compose.js";import{applyComposePlan as m,compose as h,composePlan as g,defaultPhaseMessages as _}from"./workflows/compose.js";import{refinePlan as v}from"./workflows/refine.js";import{split as y}from"./workflows/split.js";import{listUndoIds as b,undoCompose as x,validateUndoCompose as S}from"./workflows/undo.js";export{e as AIError,a as ComposeGitPortMissingOpError,r as ComposeWorkflowError,i as ComposeWorkflowInputError,t as GitError,s as REDACTED_HUNK_CONTENT,n as TaskInputError,c as analyze,m as applyComposePlan,h as compose,l as composeGroup,g as composePlan,_ as defaultPhaseMessages,o as estimatedCharactersPerToken,u as group,b as listUndoIds,d as order,f as partition,p as quickCompose,v as refinePlan,y as split,x as undoCompose,S as validateUndoCompose};
@@ -0,0 +1,31 @@
1
+ import { CommitSuggestion } from "../types.js";
2
+
3
+ //#region src/tasks/refine/prompt.d.ts
4
+ interface BuildRefinementPromptParams {
5
+ instructions?: string;
6
+ lockedCommits: readonly CommitSuggestion[];
7
+ clientContext?: string;
8
+ hunkCount: number;
9
+ mode: 'commits' | 'branches';
10
+ }
11
+ /**
12
+ * Build the user-prompt for the refine task. Sections in order:
13
+ *
14
+ * 1. Framing — short, directive, emphasizes that EVERY commit must include id, message,
15
+ * explanation, AND hunks. The "validator already accepted" language is intentionally
16
+ * omitted because some models read it as license to emit a delta-only summary.
17
+ * 2. Client-supplied context (e.g. UI-ordering hint) — verbatim, before the locked block
18
+ * so the AI has the framing before reading positional references.
19
+ * 3. Locked commits — plain-text descriptions (NOT JSON snippets). Earlier versions listed
20
+ * locked commits as JSON objects and the model started emitting output in the locked-
21
+ * list's shape, dropping the wrapper and the `hunks` field on non-locked commits. Plain
22
+ * text gives the AI nothing to mistake for a target schema.
23
+ * 4. User instructions — placed near the end of the prompt so the model attends to them
24
+ * strongly (recency bias).
25
+ * 5. Output format + concrete worked example — a real-looking JSON example with all four
26
+ * required fields per commit. No JS-comment placeholders inside the template, because
27
+ * the model interprets those as "leave that key out."
28
+ */
29
+ declare function buildRefinementPrompt(params: BuildRefinementPromptParams): string;
30
+ //#endregion
31
+ export { buildRefinementPrompt };
@@ -0,0 +1,41 @@
1
+ function e(e){let t=[];if(t.push(`The user has reviewed the commit plan you produced in your prior turn (above in our conversation) and is requesting refinements. Produce a complete, updated plan that incorporates their changes — every commit in your output must be fully specified with all required fields (id, message, explanation, and hunks).`),e.clientContext?.trim()&&t.push(e.clientContext.trim()),e.lockedCommits.length>0){let n=e.lockedCommits.map(e=>{let t=e.message.split(`
2
+ `)[0];return` - Commit id "${e.id}" — message starts: "${t}" — hunks: [${e.hunkIndices.join(`, `)}]`}).join(`
3
+ `);t.push(`LOCKED COMMITS (must appear in your output unchanged — same id, same message, same explanation, same hunks):
4
+
5
+ ${n}
6
+
7
+ If any locked commit's id is missing from your output, the validator will reject your response. Locked commits' hunk indices must remain exclusively in those commits — do NOT move them into other commits. You may reposition locked entries in the ordering only if the user explicitly asks; otherwise keep them roughly where they were.
8
+
9
+ The other commits (and their hunks) may be freely restructured (split, merged, reordered, re-messaged) per the user's request below.`)}e.instructions?.trim()?t.push(`USER'S REFINEMENT REQUEST:\n${e.instructions.trim()}`):t.push(`The user did not include specific instructions on this turn. Review your prior plan critically and produce an improved version that addresses any obvious issues with grouping, ordering, or commit messages.`);let n=e.mode===`branches`?`across every commit, across every branch`:`across every commit`,r=e.lockedCommits.length>0?`
10
+ - Every locked commit listed above appears in your output as its own entry, with id/message/hunks unchanged.`:``;return t.push(`OUTPUT FORMAT — emit JSON inside a single <output> tag. Every commit object MUST have ALL FOUR of these keys: "id" (a unique string), "message" (a string), "explanation" (a string describing what/why), and "hunks" (an array of integer indices). Omitting any of these keys — especially "hunks" — is a validation error.
11
+
12
+ This is a concrete example of the structure. Your actual ids, messages, explanations, and hunk assignments will differ — but EVERY commit entry must follow this shape:
13
+
14
+ <output>
15
+ {
16
+ "branches": [
17
+ {
18
+ "id": "default",
19
+ "name": "",
20
+ "title": "",
21
+ "description": "",
22
+ "commits": [
23
+ {"id": "commit-a", "message": "Add changelog entry", "explanation": "Adds a CHANGELOG entry for the new feature.", "hunks": [0, 1]},
24
+ {"id": "commit-b", "message": "Refactor logger", "explanation": "Extracts shared logging code into a helper.", "hunks": [2, 3, 4]}
25
+ ]
26
+ }
27
+ ]
28
+ }
29
+ </output>
30
+
31
+ Constraints (the validator enforces all of these):
32
+ - Each commit object contains "id" (unique string), "message" (string), "explanation" (string), and "hunks" (array of integers). All four keys are required.
33
+ - Each hunk index appears EXACTLY ONCE ${n}.
34
+ - Every hunk index 0..${String(e.hunkCount-1)} appears in some commit's hunks array. Total = ${String(e.hunkCount)} indices.
35
+ - Each commit contains at least one hunk (no empty commits).${r}
36
+
37
+ Before emitting, recount: the union of every commit's "hunks" array must contain exactly the integers 0..${String(e.hunkCount-1)}.
38
+
39
+ Return only the <output> tag and no other text.`),t.join(`
40
+
41
+ `)}export{e as buildRefinementPrompt};
@@ -0,0 +1,19 @@
1
+ import { RefineInput, RefineOutput } from "./types.js";
2
+
3
+ //#region src/tasks/refine/refine.d.ts
4
+ /**
5
+ * Refine a previously-generated plan via a single AI continuation call.
6
+ *
7
+ * The task assumes the input session already carries the prior conversation (the hunks the
8
+ * plan was produced from + the prior plan as an assistant message). It threads the session
9
+ * unchanged into `runAiLoop`, runs ONE model call with a refinement-specific user prompt,
10
+ * and validates the response with locked-commits enforcement + content substitution.
11
+ *
12
+ * Refine intentionally does NOT cache its result into `completedSteps` — each refinement is
13
+ * a fresh chat turn, not a resumable pipeline step. The returned session has the new turn
14
+ * appended (so a follow-up refine can continue the conversation) but `completedSteps` is
15
+ * carried through from the input session unchanged.
16
+ */
17
+ declare function refine(input: RefineInput): Promise<RefineOutput>;
18
+ //#endregion
19
+ export { refine };
@@ -0,0 +1 @@
1
+ import{TaskInputError as e}from"../../errors/task.js";import{runAiLoop as t}from"../ai-loop.js";import{applyUserPromptOverride as n}from"../types.js";import{buildRefinementPrompt as r}from"./prompt.js";import{validateRefineResponse as i}from"./validator.js";async function a(a){if(a.session.messages.length===0)throw new e(`refine: session must contain prior messages from a previous compose call.`);if(a.hunkCount<=0)throw new e(`refine: hunkCount must be a positive integer.`);let o=a.mode??`branches`,s=new Map;for(let e of a.lockedCommits??[])s.set(e.id,e);let c=n(r({instructions:a.instructions,lockedCommits:a.lockedCommits??[],clientContext:a.clientContext,hunkCount:a.hunkCount,mode:o}),a.promptOverride),l=await t({model:a.model,systemPrompt:void 0,userPrompt:c,session:a.session,maxRetries:a.maxRetries,maxTokens:a.maxTokens,onProgress:a.onProgress,cancellation:a.cancellation,onBeforeModelCall:a.onBeforeModelCall,onAfterModelCall:a.onAfterModelCall,onBeforePrompt:a.onBeforePrompt,validate:e=>i(e,{hunkCount:a.hunkCount,mode:o,lockedCommits:s}),taskName:`refine`}),u={...l.session,completedSteps:a.session.completedSteps};return{result:l.result,session:u,usage:l.usage,attempts:l.attempts,durationMs:l.durationMs,callIds:l.callIds}}export{a as refine};
@@ -0,0 +1,62 @@
1
+ import { ProgressEvent, TokenUsage } from "../../ai/types.js";
2
+ import { CommitSuggestion, PromptOverride } from "../types.js";
3
+ import { OnAfterModelCall, OnBeforeModelCall, OnBeforePrompt } from "../ai-loop.js";
4
+ import { GroupingResult } from "../group/types.js";
5
+ import { ComposeSession } from "../session.js";
6
+ import { AiModelPort } from "@gitkraken/shared-tools";
7
+
8
+ //#region src/tasks/refine/types.d.ts
9
+ /**
10
+ * Input for the `refine` task — a chat-style continuation that updates a previously generated
11
+ * plan based on new user instructions.
12
+ *
13
+ * The task does NOT compose from a diff. It assumes the prior session already contains the
14
+ * hunks + the prior plan in conversation, and instructs the AI to emit an updated plan in
15
+ * the same shape. The task layer handles the AI call, the locked-commits substitution, and
16
+ * validation; the workflow layer materializes locked commits from a `ComposePlan` and lifts
17
+ * the task's `GroupingResult` back into a full `ComposePlan`.
18
+ */
19
+ interface RefineInput {
20
+ /** Session from a prior compose task. MUST contain at least one message — refinement of
21
+ * a cold session is undefined behavior. The task threads this through `runAiLoop`
22
+ * unchanged so the prior conversation (hunks + prior plan) remains the AI's context. */
23
+ session: ComposeSession;
24
+ /** Locked commits the AI must preserve verbatim in the output. The task lists them in the
25
+ * prompt and enforces their presence + substitutes their content (id, message,
26
+ * explanation, hunkIndices) into the AI's response after validation. */
27
+ lockedCommits?: readonly CommitSuggestion[];
28
+ /** Per-call user instructions appended to the refinement prompt. */
29
+ instructions?: string;
30
+ /** Free-text consumer-supplied context (e.g. UI-ordering notes) injected verbatim into
31
+ * the prompt. Library-opaque; the consumer is responsible for content. */
32
+ clientContext?: string;
33
+ /** Total number of hunks the prior plan was produced from. Used to validate that the
34
+ * refined plan still covers every hunk index in `0..hunkCount-1` exactly once. */
35
+ hunkCount: number;
36
+ /** Output mode — must match the mode the prior plan was produced in (the task's locked-
37
+ * commits substitution and re-validation rely on this). Defaults to `'branches'`. */
38
+ mode?: 'commits' | 'branches';
39
+ model: AiModelPort;
40
+ maxRetries?: number;
41
+ /** Output-token cap forwarded to `runAiLoop`. Default 16384. */
42
+ maxTokens?: number;
43
+ onProgress?: (event: ProgressEvent) => void;
44
+ cancellation?: AbortSignal;
45
+ onBeforeModelCall?: OnBeforeModelCall;
46
+ onAfterModelCall?: OnAfterModelCall;
47
+ onBeforePrompt?: OnBeforePrompt;
48
+ /** Optional prompt override for the refine task. */
49
+ promptOverride?: PromptOverride;
50
+ }
51
+ interface RefineOutput {
52
+ /** The refined plan as a `GroupingResult` — same shape `group` / `composeGroup` produce,
53
+ * so workflows can lift it into a `ComposePlan` identically. */
54
+ result: GroupingResult;
55
+ session: ComposeSession;
56
+ usage?: TokenUsage;
57
+ attempts: number;
58
+ durationMs: number;
59
+ callIds: string[];
60
+ }
61
+ //#endregion
62
+ export { RefineInput, RefineOutput };
@@ -0,0 +1 @@
1
+ export{};
@@ -0,0 +1,31 @@
1
+ import { CommitSuggestion } from "../types.js";
2
+ import { ValidationResult } from "../ai-loop.js";
3
+ import { GroupingResult } from "../group/types.js";
4
+
5
+ //#region src/tasks/refine/validator.d.ts
6
+ interface ValidateRefineParams {
7
+ hunkCount: number;
8
+ mode: 'commits' | 'branches';
9
+ lockedCommits: Map<string, CommitSuggestion>;
10
+ }
11
+ /**
12
+ * Two-stage validation for the refine task:
13
+ *
14
+ * 1. Base structural validation via `validateGroupResponse` — same invariants the rest of
15
+ * the library enforces (hunk index uniqueness + coverage, non-empty commits, unique ids).
16
+ * 2. Locked-commits check + content substitution + post-substitution invariant check.
17
+ *
18
+ * The substitution step is what makes refine resilient to minor AI drift: when the AI emits
19
+ * a commit with a locked id but slightly different message text, we silently restore the
20
+ * prior message rather than failing validation. The retry path is reserved for the case
21
+ * the AI actually drops a locked commit (or moves its hunks elsewhere such that substitution
22
+ * produces a structurally invalid plan).
23
+ *
24
+ * Note: the post-substitution check operates directly on the `GroupingResult` (which uses
25
+ * `hunkIndices`, the internal field name) — we don't round-trip through `validateGroupResponse`
26
+ * because that validator parses an `<output>`-wrapped JSON whose schema uses `hunks` as the
27
+ * field name. Round-tripping would surface every commit's `hunks` as undefined.
28
+ */
29
+ declare function validateRefineResponse(responseText: string, params: ValidateRefineParams): ValidationResult<GroupingResult>;
30
+ //#endregion
31
+ export { validateRefineResponse };
@@ -0,0 +1 @@
1
+ import{validateGroupResponse as e}from"../group/validator.js";function t(t,r){let i=e(t,r.hunkCount,r.mode);if(!i.valid||i.result===void 0)return i;let a=i.result;if(r.lockedCommits.size===0)return i;let o=new Map;for(let e of a.branches)for(let t=0;t<e.commits.length;t++)o.set(e.commits[t].id,{branch:e,commitIndex:t});let s=[];for(let e of r.lockedCommits.keys())o.has(e)||s.push(e);if(s.length>0){let e=s.map(e=>`'${e}'`).join(`, `);return{valid:!1,errors:[`Missing locked commits: ${e}`],retryFeedback:`Your response is missing the following LOCKED commits, which must always appear in the output: ${e}. Re-emit the plan with these commits present (preserving their id, message, explanation, and hunks exactly).`}}for(let[e,t]of r.lockedCommits){let n=o.get(e);if(n==null)continue;let r=n.branch.commits[n.commitIndex];n.branch.commits[n.commitIndex]={...r,id:t.id,message:t.message,explanation:t.explanation,hunkIndices:[...t.hunkIndices]}}let c=n(a,r.hunkCount);return c==null?{valid:!0,result:a}:{valid:!1,errors:[c],retryFeedback:`After restoring the locked commits' original hunk assignments, the resulting plan is structurally invalid: ${c}. This means you assigned hunks belonging to a locked commit to a different commit. Locked commits' hunks must remain exclusive to them — do not move those hunks elsewhere.`}}function n(e,t){let n=new Set,r=[],i=[],a=new Set,o=[];for(let t of e.branches)for(let e of t.commits){if(a.has(e.id)&&o.push(e.id),a.add(e.id),e.hunkIndices.length===0){i.push(e.id);continue}for(let t of e.hunkIndices)n.has(t)&&r.push(t),n.add(t)}if(o.length>0)return`duplicate commit ids after substitution: ${o.join(`, `)}`;if(i.length>0)return`empty commits after substitution (no hunks assigned): ${i.join(`, `)}`;if(r.length>0)return`hunk indices appear in more than one commit: ${[...new Set(r)].join(`, `)}`;let s=[];for(let e=0;e<t;e++)n.has(e)||s.push(e);if(s.length>0)return`hunk indices missing from the plan: ${s.join(`, `)}`;let c=[...n].filter(e=>e>=t||e<0);if(c.length>0)return`hunk indices outside the valid range 0..${String(t-1)}: ${c.join(`, `)}`}export{t as validateRefineResponse};
@@ -74,7 +74,7 @@ interface BranchPartition {
74
74
  * value passed to `runAiLoop` and the phase label surfaced on {@link
75
75
  * import('../ai/types.js').PhaseDetail}.
76
76
  */
77
- type ComposeTaskName = 'analyze' | 'group' | 'compose-group' | 'order' | 'partition' | 'quick-compose';
77
+ type ComposeTaskName = 'analyze' | 'group' | 'compose-group' | 'order' | 'partition' | 'quick-compose' | 'refine';
78
78
  /**
79
79
  * Per-task prompt override. Every field is optional; undefined fields fall
80
80
  * back to the library's built-in prompt.
@@ -48,6 +48,17 @@ interface ComposePlanInput {
48
48
  /** See {@link ComposeInput.promptOverrides}. */
49
49
  promptOverrides?: ComposeInput['promptOverrides'];
50
50
  }
51
+ /**
52
+ * Size of the diff the AI operated on — files, hunks, and per-line counts.
53
+ * Carried on {@link ComposeResult.diffStats}, {@link ComposePlanResult.diffStats},
54
+ * and on the post-collection {@link ComposeProgressEvent} `collecting` emit's `stats` field.
55
+ */
56
+ interface ComposeDiffStats {
57
+ fileCount: number;
58
+ hunkCount: number;
59
+ addedLines: number;
60
+ removedLines: number;
61
+ }
51
62
  /** Result of {@link composePlan}. Contains everything needed to later call {@link applyComposePlan}. */
52
63
  interface ComposePlanResult {
53
64
  plan: ComposePlan;
@@ -60,6 +71,8 @@ interface ComposePlanResult {
60
71
  feedbackTargetCallId?: string;
61
72
  /** See {@link ComposeResult.hunksFilteredCount}. */
62
73
  hunksFilteredCount?: number;
74
+ /** See {@link ComposeResult.diffStats}. */
75
+ diffStats?: ComposeDiffStats;
63
76
  }
64
77
  /**
65
78
  * Pure-apply input — takes a previously computed plan and writes it to the repository.
@@ -285,12 +298,17 @@ interface ComposePlan {
285
298
  * a typical run; individual numbers are approximate and may be tuned over
286
299
  * library releases. Treat as a hint, not a promise.
287
300
  * - `detail`/`event`: phase-specific extra info (git state, AI attempt, etc.).
301
+ *
302
+ * The `collecting` phase emits twice: once at the start as a spinner trigger
303
+ * (no `stats`), then again after collection and hunk-filter complete with
304
+ * `stats` populated so consumers can render live file/hunk/line counts.
288
305
  */
289
306
  type ComposeProgressEvent = {
290
307
  phase: 'collecting';
291
308
  message: string;
292
309
  weight: number;
293
310
  detail?: string;
311
+ stats?: ComposeDiffStats;
294
312
  } | {
295
313
  phase: 'composing';
296
314
  message: string;
@@ -365,12 +383,7 @@ interface ComposeResult {
365
383
  * Size of the input the AI operated on. Useful for consumers building
366
384
  * telemetry events — no need to recount hunks and lines from the source.
367
385
  */
368
- diffStats: {
369
- fileCount: number;
370
- hunkCount: number;
371
- addedLines: number;
372
- removedLines: number;
373
- };
386
+ diffStats: ComposeDiffStats;
374
387
  commitShas?: Record<string, string>;
375
388
  /**
376
389
  * Identifier for undoing this operation.
@@ -447,4 +460,4 @@ declare function composePlan(input: ComposePlanInput): Promise<ComposePlanResult
447
460
  */
448
461
  declare function applyComposePlan(input: ApplyComposePlanInput): Promise<ApplyComposePlanResult>;
449
462
  //#endregion
450
- export { ApplyComposePlanInput, ApplyComposePlanResult, ComposeApplyPlan, type ComposeBranchPlan, type ComposeContext, ComposeDepth, ComposeInput, ComposePlan, ComposePlanInput, ComposePlanResult, ComposeProgressEvent, ComposeResult, type ComposeSource, ComposeStrategy, type ComposeTarget, type StashConflict, applyComposePlan, compose, composePlan, defaultPhaseMessages };
463
+ export { ApplyComposePlanInput, ApplyComposePlanResult, ComposeApplyPlan, type ComposeBranchPlan, type ComposeContext, ComposeDepth, ComposeDiffStats, ComposeInput, ComposePlan, ComposePlanInput, ComposePlanResult, ComposeProgressEvent, ComposeResult, type ComposeSource, ComposeStrategy, type ComposeTarget, type StashConflict, applyComposePlan, compose, composePlan, defaultPhaseMessages };
@@ -1,3 +1,3 @@
1
- import{GitError as e}from"../errors/git.js";import{ComposeWorkflowError as t,ComposeWorkflowInputError as n}from"../errors/workflow.js";import{addUsage as r,emptyUsage as i}from"../ai/types.js";import{analyze as a}from"../tasks/analyze/analyze.js";import{composeGroup as o}from"../tasks/compose-group/compose-group.js";import{group as s}from"../tasks/group/group.js";import{order as c}from"../tasks/order/order.js";import{partition as l}from"../tasks/partition/partition.js";import{applyPatchToIndex as u,currentBranch as d,diffTree as f,forEachRef as p,log as m,mergeBase as h,readTree as g,revParse as _,writeTree as v}from"../git/port-dispatch.js";import{buildPatchForCommit as y}from"../git/utils/patch-builder.js";import{sha256 as b}from"../git/utils/crypto.js";import{collectFromSource as x}from"../git/diff-collector.js";import{verifySnapshot as S}from"../git/safety.js";import{applyToTarget as C}from"../git/apply-target.js";import{requireExec as w}from"../git/require-exec.js";import{validateRewriteRangeSafety as T}from"../git/safety-range.js";import{extractConventions as E}from"../git/utils/conventions.js";import{rethrowAsWorkflowError as D}from"../utils/cancellation.js";import{randomUUID as O}from"node:crypto";import{unlinkSync as k}from"node:fs";import{tmpdir as A}from"node:os";import{join as j}from"node:path";import{UndoStore as M,capturePreOperationState as N,generateUndoId as P}from"@gitkraken/shared-tools";const F={collecting:{message:`Collecting changes…`,weight:5},composing:{message:`Composing commits…`,weight:40},"compose-grouping":{message:`Grouping changes into commits…`,weight:40},analyzing:{message:`Analyzing changes…`,weight:20},grouping:{message:`Grouping changes into commits…`,weight:25},ordering:{message:`Ordering commits…`,weight:15},partitioning:{message:`Subdividing branches…`,weight:20},verifying:{message:`Verifying…`,weight:5},applying:{message:`Applying to repository…`,weight:10}};async function I(r){if(!r.applyPlan&&!r.model)throw new n(`Either 'model' or 'applyPlan' must be provided`);if(r.applyPlan)return re(r);let{git:i,model:a,source:o={type:`workdir`},onProgress:s,onBeforeApply:c,cancellation:l}=r,u=a,d=r.depth??`balanced`,f=r.target??await X(i,o);Q(o,f),await K(i,r.applyCommitIds,r.hunkFilter,o,f),await T({git:i,source:o,target:f});let p=[];s?.({phase:`collecting`,...F.collecting});let m=Date.now(),h=await x(i,o);if(p.push({name:`collecting`,durationMs:Date.now()-m,attempts:1}),h.hunks.length===0)throw new e(`NO_CHANGES`,Z(o));let{hunks:g,filteredCount:_}=await U(h.hunks,r.hunkFilter);if(g.length===0)throw new e(`NO_CHANGES`,Z(o));let v=_!==void 0&&_>0?await W(i,h.snapshot,g):h.snapshot,y={hunks:g,collection:h,conventions:r.conventions??await E({git:i,author:r.conventionAuthor}),existingBranchNames:await ie(i),groupMode:f.type===`auto`?`branches`:`commits`,target:f,model:u,instructions:r.instructions,context:r.context,strategy:r.strategy,maxRetries:r.maxRetries,maxTokens:r.maxTokens,onProgress:s,cancellation:l,onBeforeModelCall:r.onBeforeModelCall,onAfterModelCall:r.onAfterModelCall,onBeforePrompt:r.onBeforePrompt,session:r.session,promptOverrides:r.promptOverrides,redactHunkContent:r.redactHunkContent},b;try{b=await ee(d,y)}catch(e){D(e,l)}let O={analysis:b.analysis,grouping:b.grouping,ordering:b.ordering,branches:b.branchPlans,allOrderedCommits:b.allOrderedCommits};if(p.push(...b.phases),c&&!await c(O))return{plan:O,snapshot:v,session:b.session,usage:b.usage,phases:p,source:{type:o.type===`branch`?`branch`:`workdir`,hunks:g,commitMessages:h.commitMessages,branchName:h.branchName,baseBranch:h.baseBranch},diffStats:V(g),feedbackTargetCallId:H(p),hunksFilteredCount:_};let k=await N(w(i,`capturing pre-operation state`));s?.({phase:`verifying`,...F.verifying});let A=Date.now(),j=await S(i,h.snapshot,G(o,h));if(!j.valid)throw new t(`SAFETY_CHECK_FAILED`,`Safety check failed: ${j.error}`);p.push({name:`verifying`,durationMs:Date.now()-A,attempts:1});let M=q(b.allOrderedCommits,r.applyCommitIds),{leftoverHunks:P,leftoverPatch:I}=Y(M,h.hunks),L=P.length>0?await W(i,v,J(M,h.hunks)):v;s?.({phase:`applying`,...F.applying});let R=Date.now(),z=await C({git:i,source:o,target:f,branchPlans:b.branchPlans,allOrderedCommits:M,hunks:h.hunks,snapshot:L,collection:h,signing:r.signing,authorAttribution:r.authorAttribution,stashLabel:r.stashLabel,leftoverPatch:I});p.push({name:`applying`,durationMs:Date.now()-R,attempts:1});let B=await ae(i,k,z.mutations,f,o);return{plan:O,snapshot:L,partitions:z.partitions,partitionGroups:z.partitionGroups,session:b.session,usage:b.usage,phases:p,source:{type:o.type===`branch`?`branch`:`workdir`,hunks:g,commitMessages:h.commitMessages,branchName:h.branchName,baseBranch:h.baseBranch},diffStats:V(g),commitShas:z.commitShas,undoId:B,feedbackTargetCallId:H(p),hunksFilteredCount:_,appliedCommitIds:M.map(e=>e.id),unappliedHunkIndices:P.length>0?P.map(e=>e.index):void 0,stashConflict:z.stashConflict}}function ee(e,t){switch(e){case`quick`:return L(t);case`balanced`:return R(t);case`deep`:return B(t)}}async function L(e){return z(e,{lightweight:!0,skipOrder:!0})}async function R(e){return z(e,{includeAnalysis:!0})}async function z(e,t){let{hunks:n,collection:a,conventions:s,existingBranchNames:u,groupMode:d,target:f,model:p,instructions:m,context:h,strategy:g,maxRetries:_,maxTokens:v,onProgress:y,cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C}=e,w=i(),T=[],E=await o({hunks:n,commitMessages:a.commitMessages,commitOrder:a.commitOrder,branchName:a.branchName,baseBranch:a.baseBranch,issueUrl:h?.issueUrl,pullRequestUrl:h?.pullRequestUrl,instructions:m,conventions:s,mode:d,strategy:g?.grouping,existingBranchNames:d===`branches`?u:void 0,includeAnalysis:t.includeAnalysis,lightweight:t.lightweight,condensed:t.condensed,redactHunkContent:e.redactHunkContent,model:p,session:e.session,maxRetries:_,maxTokens:v,onProgress:e=>y?.({phase:`compose-grouping`,...F[`compose-grouping`],event:e}),cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.[`compose-group`]});E.usage&&r(w,E.usage),T.push({name:`compose-grouping`,durationMs:E.durationMs,attempts:E.attempts,usage:E.usage,callIds:E.callIds});let D=E.session,O,k;if(t.skipOrder)O=E.result.branches.map(e=>({branchGroup:e,orderedCommitIds:e.commits.map(e=>e.id)})),k={branches:O.map(e=>({branchId:e.branchGroup.id,orderedCommitIds:e.orderedCommitIds})),rationale:``};else{let t=await c({branches:E.result.branches,hunks:n,instructions:m,model:p,session:E.session,maxRetries:_,maxTokens:v,onProgress:e=>y?.({phase:`ordering`,...F.ordering,event:e}),cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.order});t.usage&&r(w,t.usage),T.push({name:`ordering`,durationMs:t.durationMs,attempts:t.attempts,usage:t.usage,callIds:t.callIds}),D=t.session,k=t.result,O=E.result.branches.map(e=>({branchGroup:e,orderedCommitIds:k.branches.find(t=>t.branchId===e.id)?.orderedCommitIds??e.commits.map(e=>e.id)}))}if(f.type===`auto`){let t=new Set(u);for(let e of E.result.branches)e.name&&t.add(e.name);for(let n of O){let i=await l({commits:$(n.branchGroup,n.orderedCommitIds),orderedCommitIds:n.orderedCommitIds,sourceBranchName:n.branchGroup.name,instructions:m,conventions:s,strategy:g?.partition,existingBranchNames:t,model:p,maxRetries:_,maxTokens:v,onProgress:e=>y?.({phase:`partitioning`,...F.partitioning,branch:n.branchGroup.name,event:e}),cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.partition});i.usage&&r(w,i.usage),T.push({name:`partitioning:${n.branchGroup.name}`,durationMs:i.durationMs,attempts:i.attempts,usage:i.usage,callIds:i.callIds}),n.partitioning=i.result,D=i.session;for(let e of i.result.partitions)t.add(e.branchName)}}let A=O.flatMap(e=>$(e.branchGroup,e.orderedCommitIds));return{analysis:void 0,grouping:E.result,ordering:k,branchPlans:O,allOrderedCommits:A,session:D,usage:w,phases:T}}async function B(e){let{hunks:t,collection:n,conventions:o,existingBranchNames:u,groupMode:d,target:f,model:p,instructions:m,context:h,strategy:g,maxRetries:_,maxTokens:v,onProgress:y,cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C}=e,w=i(),T=[],E=await a({hunks:t,commitMessages:n.commitMessages,commitOrder:n.commitOrder,branchName:n.branchName,baseBranch:n.baseBranch,issueUrl:h?.issueUrl,pullRequestUrl:h?.pullRequestUrl,instructions:m,redactHunkContent:e.redactHunkContent,model:p,session:e.session,maxRetries:_,maxTokens:v,onProgress:e=>y?.({phase:`analyzing`,...F.analyzing,event:e}),cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.analyze});E.usage&&r(w,E.usage),T.push({name:`analyzing`,durationMs:E.durationMs,attempts:E.attempts,usage:E.usage,callIds:E.callIds});let D=await s({hunks:t,commitMessages:n.commitMessages,instructions:m,conventions:o,mode:d,strategy:g?.grouping,existingBranchNames:d===`branches`?u:void 0,redactHunkContent:e.redactHunkContent,model:p,session:E.session,maxRetries:_,maxTokens:v,onProgress:e=>y?.({phase:`grouping`,...F.grouping,event:e}),cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.group});D.usage&&r(w,D.usage),T.push({name:`grouping`,durationMs:D.durationMs,attempts:D.attempts,usage:D.usage,callIds:D.callIds});let O=await c({branches:D.result.branches,hunks:t,instructions:m,model:p,session:D.session,maxRetries:_,maxTokens:v,onProgress:e=>y?.({phase:`ordering`,...F.ordering,event:e}),cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.order});O.usage&&r(w,O.usage),T.push({name:`ordering`,durationMs:O.durationMs,attempts:O.attempts,usage:O.usage,callIds:O.callIds});let k=D.result.branches.map(e=>({branchGroup:e,orderedCommitIds:O.result.branches.find(t=>t.branchId===e.id)?.orderedCommitIds??e.commits.map(e=>e.id)})),A=O.session;if(f.type===`auto`){let t=new Set(u);for(let e of D.result.branches)e.name&&t.add(e.name);for(let n of k){let i=await l({commits:$(n.branchGroup,n.orderedCommitIds),orderedCommitIds:n.orderedCommitIds,sourceBranchName:n.branchGroup.name,instructions:m,conventions:o,strategy:g?.partition,existingBranchNames:t,model:p,maxRetries:_,maxTokens:v,onProgress:e=>y?.({phase:`partitioning`,...F.partitioning,branch:n.branchGroup.name,event:e}),cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.partition});i.usage&&r(w,i.usage),T.push({name:`partitioning:${n.branchGroup.name}`,durationMs:i.durationMs,attempts:i.attempts,usage:i.usage,callIds:i.callIds}),n.partitioning=i.result,A=i.session;for(let e of i.result.partitions)t.add(e.branchName)}}let j=k.flatMap(e=>$(e.branchGroup,e.orderedCommitIds));return{analysis:E.result,grouping:D.result,ordering:O.result,branchPlans:k,allOrderedCommits:j,session:A,usage:w,phases:T}}async function te(e){let t=await I({...e,onBeforeApply:async()=>!1});return{plan:t.plan,snapshot:t.snapshot,source:t.source,session:t.session,usage:t.usage,phases:t.phases,feedbackTargetCallId:t.feedbackTargetCallId,hunksFilteredCount:t.hunksFilteredCount}}async function ne(e){let t=await I({git:e.git,applyPlan:e.applyPlan,source:e.source,target:e.target,onProgress:e.onProgress,signing:e.signing,authorAttribution:e.authorAttribution,applyCommitIds:e.applyCommitIds,stashLabel:e.stashLabel,hunkFilter:e.hunkFilter});return{commitShas:t.commitShas,partitions:t.partitions,partitionGroups:t.partitionGroups,phases:t.phases,undoId:t.undoId,appliedCommitIds:t.appliedCommitIds,unappliedHunkIndices:t.unappliedHunkIndices,stashConflict:t.stashConflict}}async function re(n){let{git:r,applyPlan:a,onProgress:o}=n,{plan:s,snapshot:c,source:l}=a,u=n.source??l,d=n.target??a?.target??await X(r,u);Q(u,d),await K(r,n.applyCommitIds,n.hunkFilter,u,d),await T({git:r,source:u,target:d});let f=await N(w(r,`capturing pre-operation state`)),p=[];o?.({phase:`collecting`,...F.collecting});let m=Date.now(),h=await x(r,u);if(p.push({name:`collecting`,durationMs:Date.now()-m,attempts:1}),h.hunks.length===0)throw new e(`NO_CHANGES`,Z(u));let{hunks:g}=await U(h.hunks,n.hunkFilter);if(g.length===0)throw new e(`NO_CHANGES`,Z(u));let _=g.length<h.hunks.length?await W(r,h.snapshot,g):h.snapshot,v={...h,hunks:g,snapshot:_};if(v.snapshot.diffHash!==c.diffHash)throw new t(`SAFETY_CHECK_FAILED`,`Working directory has changed since the plan was created. Re-run compose to generate a new plan.`);o?.({phase:`verifying`,...F.verifying});let y=Date.now(),b=await S(r,v.snapshot,G(u,v));if(!b.valid)throw new t(`SAFETY_CHECK_FAILED`,`Safety check failed: ${b.error}`);p.push({name:`verifying`,durationMs:Date.now()-y,attempts:1});let E=q(s.allOrderedCommits,n.applyCommitIds),{leftoverHunks:D,leftoverPatch:O}=Y(E,v.hunks),k=D.length>0?await W(r,v.snapshot,J(E,v.hunks)):v.snapshot;o?.({phase:`applying`,...F.applying});let A=Date.now(),j=await C({git:r,source:u,target:d,branchPlans:s.branches,allOrderedCommits:E,hunks:v.hunks,snapshot:k,collection:v,signing:n.signing,authorAttribution:n.authorAttribution,stashLabel:n.stashLabel,leftoverPatch:O});p.push({name:`applying`,durationMs:Date.now()-A,attempts:1});let M=await ae(r,f,j.mutations,d,u);return{plan:s,snapshot:k,partitions:j.partitions,partitionGroups:j.partitionGroups,session:{messages:[],systemPrompt:``,completedSteps:{}},usage:i(),phases:p,source:{type:u.type===`branch`?`branch`:`workdir`,hunks:v.hunks,commitMessages:v.commitMessages,branchName:v.branchName,baseBranch:v.baseBranch},diffStats:V(v.hunks),commitShas:j.commitShas,undoId:M,appliedCommitIds:E.map(e=>e.id),unappliedHunkIndices:D.length>0?D.map(e=>e.index):void 0,stashConflict:j.stashConflict}}function V(e){let t=new Set,n=0,r=0;for(let i of e)t.add(i.fileName),n+=i.additions,r+=i.deletions;return{fileCount:t.size,hunkCount:e.length,addedLines:n,removedLines:r}}function H(e){for(let t=e.length-1;t>=0;t--){let n=e[t].callIds;if(n&&n.length>0)return n[n.length-1]}}async function U(e,t){if(!t)return{hunks:e,filteredCount:void 0};let n=await t(e);return{hunks:n,filteredCount:e.length-n.length}}async function W(e,t,n){let r=t.baseSha??t.headSha,i=j(A(),`compose-filtered-idx-${O()}`),a={GIT_INDEX_FILE:i};try{await g(e,r,{indexEnv:a});let i=y(n);i.length>0&&await u(e,i,{indexEnv:a});let o=await f(e,r,await v(e,{indexEnv:a}));return{...t,diffHash:b(o)}}finally{try{k(i)}catch{}}}function G(e,t){return e.type===`workdir`?{includeUnstaged:!e.stagedOnly,includeUntracked:t.untrackedIncluded}:e.type===`commit-range`&&e.includeWorkdir?{includeStaged:e.includeWorkdir.includeStaged??!0,includeUnstaged:e.includeWorkdir.includeUnstaged??!0,includeUntracked:e.includeWorkdir.includeUntracked??!0}:{includeUnstaged:!1,includeUntracked:!1}}async function K(e,t,r,i,a){if(r==null&&t==null||i.type===`workdir`||!(a.type===`rewrite-range`||(a.type===`branch`||a.type===`auto`)&&a.preserveOriginal!==!0))return;let o=i.type===`branch`?await _(e,i.name):await _(e,i.to),s=await _(e,`HEAD`);if(await d(e)==null)throw new n(`Filtering and partial apply require HEAD to be on a branch (non-detached) when rewriting existing commits. Check out the source branch before composing.`);if(s!==o)throw new n(`Filtering and partial apply require HEAD to be at the source's tip (${o.slice(0,8)}); HEAD is at ${s.slice(0,8)}.`)}function q(e,t){if(t==null)return e;let r=new Set(t),i=e.filter(e=>r.has(e.id)),a=new Set(i.map(e=>e.id)),o=t.filter(e=>!a.has(e));if(o.length>0)throw new n(`applyCommitIds contains commit ids not found in plan.allOrderedCommits: ${o.join(`, `)}`);return i}function J(e,t){let n=new Set(e.flatMap(e=>e.hunkIndices));return t.filter(e=>n.has(e.index))}function Y(e,t){let n=new Set(e.flatMap(e=>e.hunkIndices)),r=t.filter(e=>!n.has(e.index));if(r.length===0)return{leftoverHunks:[],leftoverPatch:void 0};let i=y(r);return{leftoverHunks:r,leftoverPatch:i.length>0?i:void 0}}async function X(e,t){if(t.type===`branch`){let r=await _(e,t.name),i=(await m(e,{format:`%H`,range:`${await h(e,await _(e,t.mergeTarget??`main`),r)}..${r}`})).trim().split(`
2
- `).at(-1);if(!i)throw new n(`Cannot recompose branch '${t.name}': no commits between merge-base and branch tip.`);return{type:`rewrite-range`,branch:t.name,rangeFrom:i,rangeTo:r}}return t.type===`commit-range`?{type:`rewrite-range`,branch:t.branch,rangeFrom:t.from,rangeTo:t.to}:{type:`head`}}function Z(e){switch(e.type){case`workdir`:return`No changes found in working directory`;case`branch`:return`No changes found on branch '${e.name}'`;case`commit-range`:return`No changes found in range '${e.from}..${e.to}' on branch '${e.branch}'`}}function Q(e,t){if(t.type===`rewrite-range`&&e.type===`workdir`)throw new n(`target 'rewrite-range' is not valid with 'workdir' source`);if(t.type===`head`&&e.type!==`workdir`)throw new n(`target 'head' is not valid with '${e.type}' source — use 'branch', 'auto', or 'rewrite-range'`)}function $(e,n){return n.map(n=>{let r=e.commits.find(e=>e.id===n);if(!r)throw new t(`INTERNAL`,`Commit ${n} not found in branch '${e.id}'`);return r})}async function ie(e){let t=(await p(e,{format:`%(refname:short)`,prefix:`refs/heads/`})).trim().split(`
3
- `).filter(Boolean);return new Set(t)}async function ae(e,t,n,r,i){let a=P(),o=n.createdRefs.filter(e=>e.kind===`branch`).length||1,s=`compose: ${o} branch${o===1?``:`es`} from ${i.type}`,c=[...n.movedRefs];if(r.type===`head`&&t.headBranch){let n=await _(e,`HEAD`);n!==t.headSha&&c.push({kind:`branch`,name:t.headBranch,previousSha:t.headSha,newSha:n})}let l={version:1,id:a,createdAt:new Date().toISOString(),description:s,previousHead:{sha:t.headSha,branch:t.headBranch},workdirClean:t.workdirClean,workdirBackupSha:t.workdirBackupSha,createdRefs:n.createdRefs,deletedRefs:n.deletedRefs,movedRefs:c};return await new M(w(e,`undo manifest persistence`)).save(l),a}export{ne as applyComposePlan,I as compose,te as composePlan,F as defaultPhaseMessages};
1
+ import{GitError as e}from"../errors/git.js";import{ComposeWorkflowError as t,ComposeWorkflowInputError as n}from"../errors/workflow.js";import{addUsage as r,emptyUsage as i}from"../ai/types.js";import{analyze as a}from"../tasks/analyze/analyze.js";import{composeGroup as o}from"../tasks/compose-group/compose-group.js";import{group as s}from"../tasks/group/group.js";import{order as c}from"../tasks/order/order.js";import{partition as l}from"../tasks/partition/partition.js";import{applyPatchToIndex as u,currentBranch as d,diffTree as f,forEachRef as p,log as m,mergeBase as h,readTree as g,revParse as _,writeTree as v}from"../git/port-dispatch.js";import{buildPatchForCommit as y}from"../git/utils/patch-builder.js";import{sha256 as b}from"../git/utils/crypto.js";import{collectFromSource as x}from"../git/diff-collector.js";import{verifySnapshot as S}from"../git/safety.js";import{applyToTarget as C}from"../git/apply-target.js";import{requireExec as w}from"../git/require-exec.js";import{validateRewriteRangeSafety as T}from"../git/safety-range.js";import{extractConventions as E}from"../git/utils/conventions.js";import{rethrowAsWorkflowError as D}from"../utils/cancellation.js";import{randomUUID as O}from"node:crypto";import{unlinkSync as k}from"node:fs";import{tmpdir as A}from"node:os";import{join as j}from"node:path";import{UndoStore as M,capturePreOperationState as N,generateUndoId as P}from"@gitkraken/shared-tools";const F={collecting:{message:`Collecting changes…`,weight:5},composing:{message:`Composing commits…`,weight:40},"compose-grouping":{message:`Grouping changes into commits…`,weight:40},analyzing:{message:`Analyzing changes…`,weight:20},grouping:{message:`Grouping changes into commits…`,weight:25},ordering:{message:`Ordering commits…`,weight:15},partitioning:{message:`Subdividing branches…`,weight:20},verifying:{message:`Verifying…`,weight:5},applying:{message:`Applying to repository…`,weight:10}};async function I(r){if(!r.applyPlan&&!r.model)throw new n(`Either 'model' or 'applyPlan' must be provided`);if(r.applyPlan)return ne(r);let{git:i,model:a,source:o={type:`workdir`},onProgress:s,onBeforeApply:c,cancellation:l}=r,u=a,d=r.depth??`balanced`,f=r.target??await Z(i,o);re(o,f),await q(i,r.applyCommitIds,r.hunkFilter,o,f),await T({git:i,source:o,target:f});let p=[];s?.({phase:`collecting`,...F.collecting});let m=Date.now(),h=await x(i,o);if(p.push({name:`collecting`,durationMs:Date.now()-m,attempts:1}),h.hunks.length===0)throw new e(`NO_CHANGES`,Q(o));let{hunks:g,filteredCount:_}=await W(h.hunks,r.hunkFilter);if(g.length===0)throw new e(`NO_CHANGES`,Q(o));let v=H(g);s?.({phase:`collecting`,...F.collecting,stats:v});let y=_!==void 0&&_>0?await G(i,h.snapshot,g):h.snapshot,b={hunks:g,collection:h,conventions:r.conventions??await E({git:i,author:r.conventionAuthor}),existingBranchNames:await ie(i),groupMode:f.type===`auto`?`branches`:`commits`,target:f,model:u,instructions:r.instructions,context:r.context,strategy:r.strategy,maxRetries:r.maxRetries,maxTokens:r.maxTokens,onProgress:s,cancellation:l,onBeforeModelCall:r.onBeforeModelCall,onAfterModelCall:r.onAfterModelCall,onBeforePrompt:r.onBeforePrompt,session:r.session,promptOverrides:r.promptOverrides,redactHunkContent:r.redactHunkContent},O;try{O=await ee(d,b)}catch(e){D(e,l)}let k={analysis:O.analysis,grouping:O.grouping,ordering:O.ordering,branches:O.branchPlans,allOrderedCommits:O.allOrderedCommits};if(p.push(...O.phases),c&&!await c(k))return{plan:k,snapshot:y,session:O.session,usage:O.usage,phases:p,source:{type:o.type===`branch`?`branch`:`workdir`,hunks:g,commitMessages:h.commitMessages,branchName:h.branchName,baseBranch:h.baseBranch},diffStats:v,feedbackTargetCallId:U(p),hunksFilteredCount:_};let A=await N(w(i,`capturing pre-operation state`));s?.({phase:`verifying`,...F.verifying});let j=Date.now(),M=await S(i,h.snapshot,K(o,h));if(!M.valid)throw new t(`SAFETY_CHECK_FAILED`,`Safety check failed: ${M.error}`);p.push({name:`verifying`,durationMs:Date.now()-j,attempts:1});let P=J(O.allOrderedCommits,r.applyCommitIds),{leftoverHunks:I,leftoverPatch:L}=X(P,h.hunks),R=I.length>0?await G(i,y,Y(P,h.hunks)):y;s?.({phase:`applying`,...F.applying});let z=Date.now(),B=await C({git:i,source:o,target:f,branchPlans:O.branchPlans,allOrderedCommits:P,hunks:h.hunks,snapshot:R,collection:h,signing:r.signing,authorAttribution:r.authorAttribution,stashLabel:r.stashLabel,leftoverPatch:L});p.push({name:`applying`,durationMs:Date.now()-z,attempts:1});let V=await ae(i,A,B.mutations,f,o);return{plan:k,snapshot:R,partitions:B.partitions,partitionGroups:B.partitionGroups,session:O.session,usage:O.usage,phases:p,source:{type:o.type===`branch`?`branch`:`workdir`,hunks:g,commitMessages:h.commitMessages,branchName:h.branchName,baseBranch:h.baseBranch},diffStats:v,commitShas:B.commitShas,undoId:V,feedbackTargetCallId:U(p),hunksFilteredCount:_,appliedCommitIds:P.map(e=>e.id),unappliedHunkIndices:I.length>0?I.map(e=>e.index):void 0,stashConflict:B.stashConflict}}function ee(e,t){switch(e){case`quick`:return L(t);case`balanced`:return R(t);case`deep`:return B(t)}}async function L(e){return z(e,{lightweight:!0,skipOrder:!0})}async function R(e){return z(e,{includeAnalysis:!0})}async function z(e,t){let{hunks:n,collection:a,conventions:s,existingBranchNames:u,groupMode:d,target:f,model:p,instructions:m,context:h,strategy:g,maxRetries:_,maxTokens:v,onProgress:y,cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C}=e,w=i(),T=[],E=await o({hunks:n,commitMessages:a.commitMessages,commitOrder:a.commitOrder,branchName:a.branchName,baseBranch:a.baseBranch,issueUrl:h?.issueUrl,pullRequestUrl:h?.pullRequestUrl,instructions:m,conventions:s,mode:d,strategy:g?.grouping,existingBranchNames:d===`branches`?u:void 0,includeAnalysis:t.includeAnalysis,lightweight:t.lightweight,condensed:t.condensed,redactHunkContent:e.redactHunkContent,model:p,session:e.session,maxRetries:_,maxTokens:v,onProgress:e=>y?.({phase:`compose-grouping`,...F[`compose-grouping`],event:e}),cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.[`compose-group`]});E.usage&&r(w,E.usage),T.push({name:`compose-grouping`,durationMs:E.durationMs,attempts:E.attempts,usage:E.usage,callIds:E.callIds});let D=E.session,O,k;if(t.skipOrder)O=E.result.branches.map(e=>({branchGroup:e,orderedCommitIds:e.commits.map(e=>e.id)})),k={branches:O.map(e=>({branchId:e.branchGroup.id,orderedCommitIds:e.orderedCommitIds})),rationale:``};else{let t=await c({branches:E.result.branches,hunks:n,instructions:m,model:p,session:E.session,maxRetries:_,maxTokens:v,onProgress:e=>y?.({phase:`ordering`,...F.ordering,event:e}),cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.order});t.usage&&r(w,t.usage),T.push({name:`ordering`,durationMs:t.durationMs,attempts:t.attempts,usage:t.usage,callIds:t.callIds}),D=t.session,k=t.result,O=E.result.branches.map(e=>({branchGroup:e,orderedCommitIds:k.branches.find(t=>t.branchId===e.id)?.orderedCommitIds??e.commits.map(e=>e.id)}))}if(f.type===`auto`){let t=new Set(u);for(let e of E.result.branches)e.name&&t.add(e.name);for(let n of O){let i=await l({commits:$(n.branchGroup,n.orderedCommitIds),orderedCommitIds:n.orderedCommitIds,sourceBranchName:n.branchGroup.name,instructions:m,conventions:s,strategy:g?.partition,existingBranchNames:t,model:p,maxRetries:_,maxTokens:v,onProgress:e=>y?.({phase:`partitioning`,...F.partitioning,branch:n.branchGroup.name,event:e}),cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.partition});i.usage&&r(w,i.usage),T.push({name:`partitioning:${n.branchGroup.name}`,durationMs:i.durationMs,attempts:i.attempts,usage:i.usage,callIds:i.callIds}),n.partitioning=i.result,D=i.session;for(let e of i.result.partitions)t.add(e.branchName)}}let A=O.flatMap(e=>$(e.branchGroup,e.orderedCommitIds));return{analysis:void 0,grouping:E.result,ordering:k,branchPlans:O,allOrderedCommits:A,session:D,usage:w,phases:T}}async function B(e){let{hunks:t,collection:n,conventions:o,existingBranchNames:u,groupMode:d,target:f,model:p,instructions:m,context:h,strategy:g,maxRetries:_,maxTokens:v,onProgress:y,cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C}=e,w=i(),T=[],E=await a({hunks:t,commitMessages:n.commitMessages,commitOrder:n.commitOrder,branchName:n.branchName,baseBranch:n.baseBranch,issueUrl:h?.issueUrl,pullRequestUrl:h?.pullRequestUrl,instructions:m,redactHunkContent:e.redactHunkContent,model:p,session:e.session,maxRetries:_,maxTokens:v,onProgress:e=>y?.({phase:`analyzing`,...F.analyzing,event:e}),cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.analyze});E.usage&&r(w,E.usage),T.push({name:`analyzing`,durationMs:E.durationMs,attempts:E.attempts,usage:E.usage,callIds:E.callIds});let D=await s({hunks:t,commitMessages:n.commitMessages,instructions:m,conventions:o,mode:d,strategy:g?.grouping,existingBranchNames:d===`branches`?u:void 0,redactHunkContent:e.redactHunkContent,model:p,session:E.session,maxRetries:_,maxTokens:v,onProgress:e=>y?.({phase:`grouping`,...F.grouping,event:e}),cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.group});D.usage&&r(w,D.usage),T.push({name:`grouping`,durationMs:D.durationMs,attempts:D.attempts,usage:D.usage,callIds:D.callIds});let O=await c({branches:D.result.branches,hunks:t,instructions:m,model:p,session:D.session,maxRetries:_,maxTokens:v,onProgress:e=>y?.({phase:`ordering`,...F.ordering,event:e}),cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.order});O.usage&&r(w,O.usage),T.push({name:`ordering`,durationMs:O.durationMs,attempts:O.attempts,usage:O.usage,callIds:O.callIds});let k=D.result.branches.map(e=>({branchGroup:e,orderedCommitIds:O.result.branches.find(t=>t.branchId===e.id)?.orderedCommitIds??e.commits.map(e=>e.id)})),A=O.session;if(f.type===`auto`){let t=new Set(u);for(let e of D.result.branches)e.name&&t.add(e.name);for(let n of k){let i=await l({commits:$(n.branchGroup,n.orderedCommitIds),orderedCommitIds:n.orderedCommitIds,sourceBranchName:n.branchGroup.name,instructions:m,conventions:o,strategy:g?.partition,existingBranchNames:t,model:p,maxRetries:_,maxTokens:v,onProgress:e=>y?.({phase:`partitioning`,...F.partitioning,branch:n.branchGroup.name,event:e}),cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.partition});i.usage&&r(w,i.usage),T.push({name:`partitioning:${n.branchGroup.name}`,durationMs:i.durationMs,attempts:i.attempts,usage:i.usage,callIds:i.callIds}),n.partitioning=i.result,A=i.session;for(let e of i.result.partitions)t.add(e.branchName)}}let j=k.flatMap(e=>$(e.branchGroup,e.orderedCommitIds));return{analysis:E.result,grouping:D.result,ordering:O.result,branchPlans:k,allOrderedCommits:j,session:A,usage:w,phases:T}}async function V(e){let t=await I({...e,onBeforeApply:async()=>!1});return{plan:t.plan,snapshot:t.snapshot,source:t.source,session:t.session,usage:t.usage,phases:t.phases,feedbackTargetCallId:t.feedbackTargetCallId,hunksFilteredCount:t.hunksFilteredCount,diffStats:t.diffStats}}async function te(e){let t=await I({git:e.git,applyPlan:e.applyPlan,source:e.source,target:e.target,onProgress:e.onProgress,signing:e.signing,authorAttribution:e.authorAttribution,applyCommitIds:e.applyCommitIds,stashLabel:e.stashLabel,hunkFilter:e.hunkFilter});return{commitShas:t.commitShas,partitions:t.partitions,partitionGroups:t.partitionGroups,phases:t.phases,undoId:t.undoId,appliedCommitIds:t.appliedCommitIds,unappliedHunkIndices:t.unappliedHunkIndices,stashConflict:t.stashConflict}}async function ne(n){let{git:r,applyPlan:a,onProgress:o}=n,{plan:s,snapshot:c,source:l}=a,u=n.source??l,d=n.target??a?.target??await Z(r,u);re(u,d),await q(r,n.applyCommitIds,n.hunkFilter,u,d),await T({git:r,source:u,target:d});let f=await N(w(r,`capturing pre-operation state`)),p=[];o?.({phase:`collecting`,...F.collecting});let m=Date.now(),h=await x(r,u);if(p.push({name:`collecting`,durationMs:Date.now()-m,attempts:1}),h.hunks.length===0)throw new e(`NO_CHANGES`,Q(u));let{hunks:g}=await W(h.hunks,n.hunkFilter);if(g.length===0)throw new e(`NO_CHANGES`,Q(u));let _=H(g);o?.({phase:`collecting`,...F.collecting,stats:_});let v=g.length<h.hunks.length?await G(r,h.snapshot,g):h.snapshot,y={...h,hunks:g,snapshot:v};if(y.snapshot.diffHash!==c.diffHash)throw new t(`SAFETY_CHECK_FAILED`,`Working directory has changed since the plan was created. Re-run compose to generate a new plan.`);o?.({phase:`verifying`,...F.verifying});let b=Date.now(),E=await S(r,y.snapshot,K(u,y));if(!E.valid)throw new t(`SAFETY_CHECK_FAILED`,`Safety check failed: ${E.error}`);p.push({name:`verifying`,durationMs:Date.now()-b,attempts:1});let D=J(s.allOrderedCommits,n.applyCommitIds),{leftoverHunks:O,leftoverPatch:k}=X(D,y.hunks),A=O.length>0?await G(r,y.snapshot,Y(D,y.hunks)):y.snapshot;o?.({phase:`applying`,...F.applying});let j=Date.now(),M=await C({git:r,source:u,target:d,branchPlans:s.branches,allOrderedCommits:D,hunks:y.hunks,snapshot:A,collection:y,signing:n.signing,authorAttribution:n.authorAttribution,stashLabel:n.stashLabel,leftoverPatch:k});p.push({name:`applying`,durationMs:Date.now()-j,attempts:1});let P=await ae(r,f,M.mutations,d,u);return{plan:s,snapshot:A,partitions:M.partitions,partitionGroups:M.partitionGroups,session:{messages:[],systemPrompt:``,completedSteps:{}},usage:i(),phases:p,source:{type:u.type===`branch`?`branch`:`workdir`,hunks:y.hunks,commitMessages:y.commitMessages,branchName:y.branchName,baseBranch:y.baseBranch},diffStats:_,commitShas:M.commitShas,undoId:P,appliedCommitIds:D.map(e=>e.id),unappliedHunkIndices:O.length>0?O.map(e=>e.index):void 0,stashConflict:M.stashConflict}}function H(e){let t=new Set,n=0,r=0;for(let i of e)t.add(i.fileName),n+=i.additions,r+=i.deletions;return{fileCount:t.size,hunkCount:e.length,addedLines:n,removedLines:r}}function U(e){for(let t=e.length-1;t>=0;t--){let n=e[t].callIds;if(n&&n.length>0)return n[n.length-1]}}async function W(e,t){if(!t)return{hunks:e,filteredCount:void 0};let n=await t(e);return{hunks:n,filteredCount:e.length-n.length}}async function G(e,t,n){let r=t.baseSha??t.headSha,i=j(A(),`compose-filtered-idx-${O()}`),a={GIT_INDEX_FILE:i};try{await g(e,r,{indexEnv:a});let i=y(n);i.length>0&&await u(e,i,{indexEnv:a});let o=await f(e,r,await v(e,{indexEnv:a}));return{...t,diffHash:b(o)}}finally{try{k(i)}catch{}}}function K(e,t){return e.type===`workdir`?{includeUnstaged:!e.stagedOnly,includeUntracked:t.untrackedIncluded}:e.type===`commit-range`&&e.includeWorkdir?{includeStaged:e.includeWorkdir.includeStaged??!0,includeUnstaged:e.includeWorkdir.includeUnstaged??!0,includeUntracked:e.includeWorkdir.includeUntracked??!0}:{includeUnstaged:!1,includeUntracked:!1}}async function q(e,t,r,i,a){if(r==null&&t==null||i.type===`workdir`||!(a.type===`rewrite-range`||(a.type===`branch`||a.type===`auto`)&&a.preserveOriginal!==!0))return;let o=i.type===`branch`?await _(e,i.name):await _(e,i.to),s=await _(e,`HEAD`);if(await d(e)==null)throw new n(`Filtering and partial apply require HEAD to be on a branch (non-detached) when rewriting existing commits. Check out the source branch before composing.`);if(s!==o)throw new n(`Filtering and partial apply require HEAD to be at the source's tip (${o.slice(0,8)}); HEAD is at ${s.slice(0,8)}.`)}function J(e,t){if(t==null)return e;let r=new Set(t),i=e.filter(e=>r.has(e.id)),a=new Set(i.map(e=>e.id)),o=t.filter(e=>!a.has(e));if(o.length>0)throw new n(`applyCommitIds contains commit ids not found in plan.allOrderedCommits: ${o.join(`, `)}`);return i}function Y(e,t){let n=new Set(e.flatMap(e=>e.hunkIndices));return t.filter(e=>n.has(e.index))}function X(e,t){let n=new Set(e.flatMap(e=>e.hunkIndices)),r=t.filter(e=>!n.has(e.index));if(r.length===0)return{leftoverHunks:[],leftoverPatch:void 0};let i=y(r);return{leftoverHunks:r,leftoverPatch:i.length>0?i:void 0}}async function Z(e,t){if(t.type===`branch`){let r=await _(e,t.name),i=(await m(e,{format:`%H`,range:`${await h(e,await _(e,t.mergeTarget??`main`),r)}..${r}`})).trim().split(`
2
+ `).at(-1);if(!i)throw new n(`Cannot recompose branch '${t.name}': no commits between merge-base and branch tip.`);return{type:`rewrite-range`,branch:t.name,rangeFrom:i,rangeTo:r}}return t.type===`commit-range`?{type:`rewrite-range`,branch:t.branch,rangeFrom:t.from,rangeTo:t.to}:{type:`head`}}function Q(e){switch(e.type){case`workdir`:return`No changes found in working directory`;case`branch`:return`No changes found on branch '${e.name}'`;case`commit-range`:return`No changes found in range '${e.from}..${e.to}' on branch '${e.branch}'`}}function re(e,t){if(t.type===`rewrite-range`&&e.type===`workdir`)throw new n(`target 'rewrite-range' is not valid with 'workdir' source`);if(t.type===`head`&&e.type!==`workdir`)throw new n(`target 'head' is not valid with '${e.type}' source — use 'branch', 'auto', or 'rewrite-range'`)}function $(e,n){return n.map(n=>{let r=e.commits.find(e=>e.id===n);if(!r)throw new t(`INTERNAL`,`Commit ${n} not found in branch '${e.id}'`);return r})}async function ie(e){let t=(await p(e,{format:`%(refname:short)`,prefix:`refs/heads/`})).trim().split(`
3
+ `).filter(Boolean);return new Set(t)}async function ae(e,t,n,r,i){let a=P(),o=n.composedBranchCount||1,s=`compose: ${o} branch${o===1?``:`es`} from ${i.type}`,c=[...n.movedRefs];if(r.type===`head`&&t.headBranch){let n=await _(e,`HEAD`);n!==t.headSha&&c.push({kind:`branch`,name:t.headBranch,previousSha:t.headSha,newSha:n})}let l={version:1,id:a,createdAt:new Date().toISOString(),description:s,previousHead:{sha:t.headSha,branch:t.headBranch},workdirClean:t.workdirClean,workdirBackupSha:t.workdirBackupSha,createdRefs:n.createdRefs,deletedRefs:n.deletedRefs,movedRefs:c};return await new M(w(e,`undo manifest persistence`)).save(l),a}export{te as applyComposePlan,I as compose,V as composePlan,F as defaultPhaseMessages};
@@ -0,0 +1,88 @@
1
+ import { PhaseDetail, ProgressEvent, TokenUsage } from "../ai/types.js";
2
+ import { PromptOverride } from "../tasks/types.js";
3
+ import { OnAfterModelCall, OnBeforeModelCall, OnBeforePrompt } from "../tasks/ai-loop.js";
4
+ import { ComposeSession } from "../tasks/session.js";
5
+ import { ComposePlan } from "./compose.js";
6
+ import { AiModelPort } from "@gitkraken/shared-tools";
7
+
8
+ //#region src/workflows/refine.d.ts
9
+ /** Progress event for {@link refinePlan} — single phase, mirrors the workflow ProgressEvent
10
+ * shape used elsewhere (phase label + human-readable message + AI-loop sub-event). */
11
+ type RefineProgressEvent = {
12
+ phase: 'refining';
13
+ message: string;
14
+ weight: number;
15
+ event?: ProgressEvent;
16
+ };
17
+ /**
18
+ * Input for {@link refinePlan} — the workflow-level wrapper around the `refine` task.
19
+ *
20
+ * The workflow takes a higher-level shape than the task does — it accepts a full
21
+ * {@link ComposePlan} and a list of `lockedCommits` *as ids*, materializes those ids into
22
+ * `CommitSuggestion` objects from the prior plan, derives the hunk count from the prior
23
+ * plan's commits, and lifts the task's `GroupingResult` back into a full `ComposePlan`. The
24
+ * task itself stays lower-level and reusable.
25
+ */
26
+ interface RefinePlanInput {
27
+ /**
28
+ * Session from a prior `composePlan` (or `refinePlan`) call. MUST contain `messages` —
29
+ * the AI's prior plan + the hunks the plan was produced from live there. Passed
30
+ * unchanged to the refine task.
31
+ */
32
+ session: ComposeSession;
33
+ /** The plan to be refined. Used to resolve `lockedCommits` ids to their fixed content
34
+ * and to derive total hunk count + output mode (commits vs branches) for validation. */
35
+ priorPlan: ComposePlan;
36
+ model: AiModelPort;
37
+ /** The user's refinement instructions. If absent, the prompt uses a generic "review your
38
+ * prior plan critically and produce an improved version" fallback. */
39
+ instructions?: string;
40
+ /**
41
+ * Commit ids from `priorPlan.allOrderedCommits` that must be preserved verbatim — same
42
+ * id, message, explanation, and hunk assignments. The validator (inside the task)
43
+ * requires every locked id to appear in the AI's output; if any locked id is missing,
44
+ * the AI loop retries with feedback. When all locked ids ARE present, the task
45
+ * substitutes the prior plan's content for each locked id in the AI's response, so
46
+ * minor AI drift on message text is invisibly normalized.
47
+ */
48
+ lockedCommits?: readonly string[];
49
+ /**
50
+ * Free-text the caller injects into the refinement prompt — e.g. UI-ordering hints,
51
+ * external context about how the user views the plan. The library doesn't interpret
52
+ * this string; it's appended verbatim ahead of the locked-commits and user-instructions
53
+ * blocks.
54
+ */
55
+ clientContext?: string;
56
+ maxRetries?: number;
57
+ maxTokens?: number;
58
+ onProgress?: (event: RefineProgressEvent) => void;
59
+ cancellation?: AbortSignal;
60
+ onBeforeModelCall?: OnBeforeModelCall;
61
+ onAfterModelCall?: OnAfterModelCall;
62
+ onBeforePrompt?: OnBeforePrompt;
63
+ /** Prompt override forwarded to the refine task. */
64
+ promptOverride?: PromptOverride;
65
+ }
66
+ interface RefinePlanResult {
67
+ plan: ComposePlan;
68
+ session: ComposeSession;
69
+ usage: TokenUsage;
70
+ phases: PhaseDetail[];
71
+ /** See `ComposeResult.feedbackTargetCallId`. */
72
+ feedbackTargetCallId?: string;
73
+ }
74
+ /**
75
+ * Refine a previously-generated plan via a single AI continuation.
76
+ *
77
+ * Workflow-level orchestration only: validates the prior plan, materializes `lockedCommits`
78
+ * ids into `CommitSuggestion` objects, calls the `refine` task, and lifts the returned
79
+ * `GroupingResult` into a full `ComposePlan`. No AI invocation lives here — that's the
80
+ * task's job (see `tasks/refine/refine.ts`).
81
+ *
82
+ * Unlike `composePlan`, refinePlan does NOT touch git, run `collectFromSource`, or invoke
83
+ * any of the compose pipeline tasks. The caller reuses the snapshot from the original
84
+ * compose for apply-time validation.
85
+ */
86
+ declare function refinePlan(input: RefinePlanInput): Promise<RefinePlanResult>;
87
+ //#endregion
88
+ export { RefinePlanInput, RefinePlanResult, RefineProgressEvent, refinePlan };
@@ -0,0 +1 @@
1
+ import{ComposeWorkflowInputError as e}from"../errors/workflow.js";import{emptyUsage as t}from"../ai/types.js";import{rethrowAsWorkflowError as n}from"../utils/cancellation.js";import{refine as r}from"../tasks/refine/refine.js";async function i(i){if(i.session.messages.length===0)throw new e(`refinePlan: session must contain prior messages from a previous compose call.`);if(i.priorPlan.allOrderedCommits.length===0)throw new e(`refinePlan: priorPlan must contain at least one commit.`);let s=[];if(i.lockedCommits?.length)for(let t of i.lockedCommits){let n=i.priorPlan.allOrderedCommits.find(e=>e.id===t);if(n==null)throw new e(`refinePlan: lockedCommits id '${t}' not found in priorPlan.allOrderedCommits.`);s.push(n)}let c=a(i.priorPlan),l=o(i.priorPlan),u=Date.now(),d;try{d=await r({session:i.session,lockedCommits:s,instructions:i.instructions,clientContext:i.clientContext,hunkCount:c,mode:l,model:i.model,maxRetries:i.maxRetries,maxTokens:i.maxTokens,cancellation:i.cancellation,onBeforeModelCall:i.onBeforeModelCall,onAfterModelCall:i.onAfterModelCall,onBeforePrompt:i.onBeforePrompt,promptOverride:i.promptOverride,onProgress:i.onProgress?e=>i.onProgress?.({phase:`refining`,message:`Refining commits…`,weight:100,event:e}):void 0})}catch(e){n(e,i.cancellation)}let f=d.result,p=f.branches.map(e=>({branchGroup:e,orderedCommitIds:e.commits.map(e=>e.id)})),m={branches:p.map(e=>({branchId:e.branchGroup.id,orderedCommitIds:e.orderedCommitIds})),rationale:``},h=p.flatMap(e=>e.branchGroup.commits);return{plan:{analysis:i.session.completedSteps.analyze,grouping:f,ordering:m,branches:p,allOrderedCommits:h},session:d.session,usage:d.usage??t(),phases:[{name:`refine`,durationMs:Date.now()-u,attempts:d.attempts,usage:d.usage,callIds:d.callIds}],feedbackTargetCallId:d.callIds.length>0?d.callIds[d.callIds.length-1]:void 0}}function a(e){let t=new Set;for(let n of e.allOrderedCommits)for(let e of n.hunkIndices)t.add(e);return t.size}function o(e){return e.branches.length===1&&e.branches[0].branchGroup.id===`default`&&e.branches[0].branchGroup.name===``?`commits`:`branches`}export{i as refinePlan};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gitkraken/compose-tools",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "AI-powered organization of git changes into clean, atomic commits and branches.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Axosoft, LLC dba GitKraken",