@gitkraken/compose-tools 0.9.0 → 0.10.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
@@ -1,10 +1,10 @@
1
1
  # Changelog
2
2
 
3
- All notable changes to this project will be documented in this file.
3
+ ## 0.10.0
4
4
 
5
- The format is based on [Keep a Changelog](https://keepachangelog.com/) and this project adheres to [Semantic Versioning](https://semver.org/).
5
+ ### Minor Changes
6
6
 
7
- ## [Unreleased]
7
+ - [#90](https://github.com/gitkraken/core-tools/pull/90) [`19c7d2f`](https://github.com/gitkraken/core-tools/commit/19c7d2f5aadd84159f5eaf6e7172cae6ff70134b) Thanks [@alejandroSuch](https://github.com/alejandroSuch)! - A `group` / `composeGroup` response that leaves hunks unassigned is now repaired with one small targeted call instead of a full retry. After the deterministic dedupe above, the remaining missing indices are sent on their own — those hunks' content plus the response's commits as id and message, no diff, no system prompt, no history — and the model answers with a `{hunkIndex, commitId}` assignment map. Asking for an assignment map rather than a fresh grouping keeps the repair from restructuring decisions the first response already made. Over four eval runs missing hunks accounted for 4 of 29 outright `VALIDATION_EXHAUSTED` failures, each previously costing a re-send of the whole diff. The repair is reported as a `missing-hunks` `ValidationRepair` (distinct from the free `duplicate-hunks` kind) and its call is accounted for like any other: its own entry in `callIds`, its own `onBeforeModelCall` / `onAfterModelCall` pair (flagged `isRepair`, sharing the `attempt` of the response it repairs), its tokens in `usage`, and its prompt through the `onBeforePrompt` gate. At most one repair per loop: a repair response that is itself invalid — malformed, incomplete, naming an unknown commit, or moving a hunk it was not asked about — falls through to the existing retry behaviour, as does a repair call that fails in transport. Conversation-mode `group` (hunks in the model's context, not in the task input) has no hunk bodies to ask about and retries as before, and a repair prompt that would exceed the input-token budget drops the hunk bodies and asks on file metadata alone. The repair honours `condenseHunks` / `condensed` and `redactHunkContent` like the main prompt; in `branches` mode each commit in the list carries its branch name and title. It is skipped, straight to the retry, when more than a quarter of the hunks are missing (a repair of two or fewer hunks is always tried), since past that the repair re-sends most of the diff. Declining the repair prompt at `onBeforePrompt` falls back to the retry instead of aborting. `feedbackTargetCallId` points at the response a repair completed, not at the repair call.
8
8
 
9
9
  ## [0.9.0] - 2026-10-05
10
10
 
package/README.md CHANGED
@@ -256,15 +256,18 @@ Equivalent to calling `composePlan` — useful when you want the sugar `compose`
256
256
  Per-call observability hooks. Informational only — the library doesn't interpret the return.
257
257
 
258
258
  ```typescript
259
- onBeforeModelCall: ({ phase, attempt, maxAttempts, callId }) => {
259
+ onBeforeModelCall: ({ phase, attempt, maxAttempts, callId, isRepair }) => {
260
260
  // phase: 'analyze' | 'group' | 'order' | 'partition' | 'compose-group' | 'quick-compose'
261
261
  // callId: stable UUID — correlates with onAfterModelCall and with result.feedbackTargetCallId
262
+ // isRepair: true for a targeted missing-hunks repair call, which shares the `attempt` of the
263
+ // response it repairs
262
264
  },
263
265
 
264
- onAfterModelCall: ({ phase, attempt, callId, success, durationMs, usage, error, promptVersion, repairs }) => {
266
+ onAfterModelCall: ({ phase, attempt, callId, success, durationMs, usage, error, promptVersion, repairs, isRepair }) => {
265
267
  // Fires on both success and error, after the response validator runs (durationMs still
266
- // measures only the model call). `repairs` lists deterministic fixes applied to an accepted
267
- // response — see ValidationRepair. An exception thrown here is swallowed.
268
+ // measures only the model call). `repairs` lists the fixes applied to an accepted response,
269
+ // see ValidationRepair: `duplicate-hunks` is deterministic and free, `missing-hunks` costs one
270
+ // extra model call. `isRepair` as above. An exception thrown here is swallowed.
268
271
  },
269
272
  ```
270
273
 
@@ -272,10 +275,10 @@ Consumers build their own per-call telemetry from these. No built-in telemetry i
272
275
 
273
276
  ### `onBeforePrompt`
274
277
 
275
- Gating hook — run immediately before each `model.generate`. Returning `false` aborts the workflow with `ComposeWorkflowError('CANCELLED')`. Use for large-prompt confirmations, cost warnings, org policy checks.
278
+ Gating hook — run immediately before each `model.generate`. Returning `false` aborts the workflow with `ComposeWorkflowError('CANCELLED')`, except on a missing-hunks repair call (`isRepair: true`): declining that one skips the repair and falls back to a regular retry, which goes through this gate too. Use for large-prompt confirmations, cost warnings, org policy checks.
276
279
 
277
280
  ```typescript
278
- onBeforePrompt: async ({ phase, attempt, tokenEstimate, charCount, messageCount }) => {
281
+ onBeforePrompt: async ({ phase, attempt, tokenEstimate, charCount, messageCount, isRepair }) => {
279
282
  if (tokenEstimate > 20_000) {
280
283
  return await showUserConfirmation(`Large prompt (${tokenEstimate} tokens). Continue?`);
281
284
  }
@@ -398,6 +401,8 @@ const second = await composePlan({ git, model, depth: 'deep', session: first.ses
398
401
 
399
402
  A resumed task contributes no usage and no callIds — telemetry for that phase reflects the original run.
400
403
 
404
+ A phase's `callIds` has one entry per attempt plus one per missing-hunks repair, so it can be longer than `attempts`.
405
+
401
406
  ### `promptOverrides`
402
407
 
403
408
  Per-task prompt overrides for consumers with their own prompt template services (server-driven templates, provider-specific templates, org-tuned wording).
@@ -455,10 +460,10 @@ Every top-level entry point returns an object with these common fields:
455
460
  | `snapshot` | all paths | Safety hash fingerprint of the collection — carry it to apply time |
456
461
  | `source` | all paths | `{ type, hunks, commitMessages?, branchName?, baseBranch? }` — the hunks the AI operated on |
457
462
  | `session` | all paths | Pass to a subsequent call to resume |
458
- | `phases` | all paths | `PhaseDetail[]` with `{ name, durationMs, attempts, usage?, modelId?, promptVersion?, callIds?, repairs? }` per phase |
463
+ | `phases` | all paths | `PhaseDetail[]` with `{ name, durationMs, attempts, usage?, modelId?, promptVersion?, callIds?, repairs? }` per phase; `callIds` can outnumber `attempts` by the repair calls |
459
464
  | `usage` | all paths | Aggregated `{ inputTokens, outputTokens, ... }` across all AI calls |
460
465
  | `diffStats` | sugar / plan | `{ fileCount, hunkCount, addedLines, removedLines }` |
461
- | `feedbackTargetCallId` | when AI ran | callId of the last validated model response — stamp this on thumbs up/down feedback events |
466
+ | `feedbackTargetCallId` | when AI ran | callId of the last validated model response (after a missing-hunks repair, the response it completed, not the repair call) — stamp this on thumbs up/down feedback events |
462
467
  | `hunksFilteredCount` | when `hunkFilter` ran | Number of hunks the filter dropped |
463
468
  | `commitShas` | apply paths | `{ commitId: sha }` mapping for every commit written |
464
469
  | `undoId` | apply paths | Pass to `undoCompose()` to reverse |
@@ -31,14 +31,27 @@ interface DuplicateHunksRepair {
31
31
  detail: string;
32
32
  }
33
33
  /**
34
- * A deterministic fix a response validator applied to an AI response that would otherwise have
35
- * been rejected, sparing a retry. A repaired response is treated as valid — repairs are reported
36
- * for observability, not as a failure signal.
34
+ * Hunk indices assigned to no commit were placed by asking the model where they belong, sending
35
+ * only those hunks and the commit list. Costs one extra model call, which shows up in the usual
36
+ * call, token and cost accounting.
37
+ */
38
+ interface MissingHunksRepair {
39
+ kind: 'missing-hunks';
40
+ /** The hunk indices the repair placed, ascending. */
41
+ hunkIndices: number[];
42
+ /** Unstable log string. */
43
+ detail: string;
44
+ }
45
+ /**
46
+ * A fix a response validator applied to an AI response that would otherwise have been rejected,
47
+ * sparing a full retry. A repaired response is treated as valid — repairs are reported for
48
+ * observability, not as a failure signal. `duplicate-hunks` is deterministic and free;
49
+ * `missing-hunks` costs one small extra model call, visible in the usual call and cost accounting.
37
50
  *
38
- * This covers duplicate-hunk repair only. It is not an exhaustive record of every correction a
39
- * validator makes: refine's locked-content substitution and `restoreLockedOrdering` stay silent.
51
+ * This is not an exhaustive record of every correction a validator makes: refine's locked-content
52
+ * substitution and `restoreLockedOrdering` stay silent.
40
53
  */
41
- type ValidationRepair = DuplicateHunksRepair;
54
+ type ValidationRepair = DuplicateHunksRepair | MissingHunksRepair;
42
55
  interface TokenUsage {
43
56
  inputTokens: number;
44
57
  outputTokens: number;
@@ -58,10 +71,13 @@ interface PhaseDetail {
58
71
  modelId?: string;
59
72
  /** Identifier of the prompt template used for this phase, when a consumer override supplied one. */
60
73
  promptVersion?: string;
61
- /** Stable call IDs for each model.generate invocation in this phase (one per attempt). */
74
+ /**
75
+ * Stable call IDs for each model.generate invocation in this phase: one per attempt, plus one
76
+ * for each targeted repair, so the count can exceed `attempts`.
77
+ */
62
78
  callIds?: string[];
63
79
  /**
64
- * Deterministic repairs applied to this phase's accepted response instead of spending a retry.
80
+ * Repairs applied to this phase's accepted response instead of spending a retry.
65
81
  * Undefined when the response needed no repair.
66
82
  */
67
83
  repairs?: ValidationRepair[];
@@ -73,4 +89,4 @@ interface ProgressEvent {
73
89
  errors?: string[];
74
90
  }
75
91
  //#endregion
76
- export { DuplicateHunksRepair, PhaseDetail, ProgressEvent, RepairScope, RepairedHunkClaim, TokenUsage, ValidationRepair, addUsage, emptyUsage };
92
+ export { DuplicateHunksRepair, MissingHunksRepair, PhaseDetail, ProgressEvent, RepairScope, RepairedHunkClaim, TokenUsage, ValidationRepair, addUsage, emptyUsage };
@@ -16,6 +16,12 @@ interface BeforeModelCallInfo {
16
16
  maxAttempts: number;
17
17
  /** Stable UUID identifying this model.generate call. Pairs with the matching After event. */
18
18
  callId: string;
19
+ /**
20
+ * True when this is a targeted repair of the previous response rather than a fresh attempt:
21
+ * a much smaller prompt carrying only what the fix needs. Shares `attempt` with the call
22
+ * whose response it repairs.
23
+ */
24
+ isRepair?: boolean;
19
25
  }
20
26
  interface AfterModelCallInfo {
21
27
  phase: string;
@@ -31,17 +37,20 @@ interface AfterModelCallInfo {
31
37
  /** Identifier of the prompt template used for this call, when a consumer override supplied one. */
32
38
  promptVersion?: string;
33
39
  /**
34
- * Deterministic repairs applied to this response so it could be accepted instead of retried.
40
+ * Repairs applied to this response so it could be accepted instead of retried.
35
41
  * Undefined when the response needed no repair (or when the call itself failed).
36
42
  */
37
43
  repairs?: ValidationRepair[];
44
+ /** See {@link BeforeModelCallInfo.isRepair}. */
45
+ isRepair?: boolean;
38
46
  }
39
47
  type OnBeforeModelCall = (info: BeforeModelCallInfo) => void;
40
48
  type OnAfterModelCall = (info: AfterModelCallInfo) => void;
41
49
  /**
42
50
  * Gating hook fired immediately before each `model.generate` call. Returning
43
51
  * `false` aborts the loop with a native `AbortError` (DOMException with
44
- * `name === 'AbortError'`). Intended for cost warnings, large-prompt
52
+ * `name === 'AbortError'`), except on a repair call (`isRepair`), where it skips
53
+ * the repair and falls back to the regular retry. Intended for cost warnings, large-prompt
45
54
  * confirmations, policy checks, and similar caller-side UX.
46
55
  *
47
56
  * `tokenEstimate` is a rough `charCount / 2.8` approximation — reasonable for
@@ -55,6 +64,8 @@ interface BeforePromptInfo {
55
64
  charCount: number;
56
65
  messageCount: number;
57
66
  tokenEstimate: number;
67
+ /** See {@link BeforeModelCallInfo.isRepair}. */
68
+ isRepair?: boolean;
58
69
  }
59
70
  type OnBeforePrompt = (info: BeforePromptInfo) => Promise<boolean>;
60
71
  /** Character-to-token approximation for English/code prompts. */
@@ -84,6 +95,21 @@ interface ValidationResult<T> {
84
95
  retryFeedback?: string;
85
96
  /** Set (non-empty) only when the validator repaired the response to make it valid. */
86
97
  repairs?: ValidationRepair[];
98
+ /**
99
+ * Offered by a validator on a rejected response whose only defect one small model call can
100
+ * settle. The loop attempts it once, before falling back to a full retry.
101
+ */
102
+ repair?: TargetedRepair<T>;
103
+ }
104
+ /**
105
+ * A rejected response the validator can still salvage by asking the model one narrow question.
106
+ * The prompt must be self-contained — the loop sends it on its own, without the system prompt
107
+ * or the message history, which is the entire point of the escalation.
108
+ */
109
+ interface TargetedRepair<T> {
110
+ prompt: string;
111
+ /** Validates the repair response and folds it into the response being repaired. Must be pure. */
112
+ apply: (responseText: string) => ValidationResult<T>;
87
113
  }
88
114
  interface AiLoopResult<T> {
89
115
  result: T;
@@ -99,4 +125,4 @@ interface AiLoopResult<T> {
99
125
  declare function runAiLoop<T>(params: AiLoopParams<T>): Promise<AiLoopResult<T>>;
100
126
  declare function extractOutputTag(text: string): string | null;
101
127
  //#endregion
102
- export { AfterModelCallInfo, BeforeModelCallInfo, BeforePromptInfo, OnAfterModelCall, OnBeforeModelCall, OnBeforePrompt, ValidationResult, estimatedCharactersPerToken, extractOutputTag, runAiLoop };
128
+ export { AfterModelCallInfo, BeforeModelCallInfo, BeforePromptInfo, OnAfterModelCall, OnBeforeModelCall, OnBeforePrompt, TargetedRepair, ValidationResult, estimatedCharactersPerToken, extractOutputTag, runAiLoop };
@@ -1,2 +1,2 @@
1
- import{AIError as e}from"../errors/ai.js";import{TaskInputError as t}from"../errors/task.js";import{appendAssistantMessage as n,appendUserMessage as r,createAiSession as i}from"../ai/session.js";import{addUsage as a}from"../ai/types.js";import{randomUUID as o}from"node:crypto";const s=2.8;async function c(a){let{model:s,userPrompt:c,maxRetries:d=3,maxTokens:f=16384,onProgress:p,validate:m,taskName:h,cancellation:g,phase:_=h,onBeforeModelCall:v,onAfterModelCall:y,promptVersion:b}=a;if(!a.session&&!a.systemPrompt)throw new t(`${h}: either 'session' or 'systemPrompt' must be provided`);let x=Date.now(),S;a.session?S=r(a.session,c):(S=i(a.systemPrompt),S=r(S,c));let C,w=[],T=d+1,E=[];for(let e=0;e<=d;e++){g?.throwIfAborted();let t=e+1;if(p?.({type:`attempt_start`,attempt:t,maxAttempts:T}),a.onBeforePrompt){let e=(S.systemPrompt?.length??0)+S.messages.reduce((e,t)=>e+t.content.length,0);if(!await a.onBeforePrompt({phase:_,attempt:t,maxAttempts:T,charCount:e,messageCount:S.messages.length,tokenEstimate:Math.ceil(e/2.8)}))throw new DOMException(`Prompt gate declined by caller (onBeforePrompt returned false)`,`AbortError`)}let i=o();E.push(i);let c=Date.now();v?.({phase:_,attempt:t,maxAttempts:T,callId:i});let h;try{h=await s.generate({system:S.systemPrompt,messages:[...S.messages],maxTokens:f,temperature:0,signal:g})}catch(e){let n=e instanceof Error?e.message:String(e);try{y?.({phase:_,attempt:t,callId:i,success:!1,durationMs:Date.now()-c,error:{code:e?.code??`UNKNOWN`,message:n},promptVersion:b})}catch{}throw l(e)||g?.aborted?new DOMException(n,`AbortError`):e}let D=Date.now()-c,O=e=>{try{y?.({phase:_,attempt:t,callId:i,success:!0,durationMs:D,usage:h.usage,promptVersion:b,repairs:e})}catch{}};C=u(C,h.usage);let k;try{k=m(h.text)}catch(e){throw O(),e}if(O(k.valid?k.repairs:void 0),k.valid&&k.result!==void 0)return S=n(S,h.text),{result:k.result,session:S,usage:C,attempts:t,durationMs:Date.now()-x,callIds:E,repairs:k.repairs};w=k.errors??[`Unknown validation error`],p?.({type:`validation_failed`,attempt:t,maxAttempts:T,errors:w}),e<d&&k.retryFeedback&&(S=n(S,h.text),S=r(S,k.retryFeedback))}throw new e(`VALIDATION_EXHAUSTED`,`Failed to get valid ${h} after ${T} attempts. Last errors:\n${w.join(`
1
+ import{AIError as e}from"../errors/ai.js";import{TaskInputError as t}from"../errors/task.js";import{appendAssistantMessage as n,appendUserMessage as r,createAiSession as i}from"../ai/session.js";import{addUsage as a}from"../ai/types.js";import{randomUUID as o}from"node:crypto";const s=2.8;async function c(a){let{model:c,userPrompt:d,maxRetries:f=3,maxTokens:p=16384,onProgress:m,validate:h,taskName:g,cancellation:_,phase:v=g,onBeforeModelCall:y,onAfterModelCall:b,promptVersion:x}=a;if(!a.session&&!a.systemPrompt)throw new t(`${g}: either 'session' or 'systemPrompt' must be provided`);let S=Date.now(),C;a.session?C=r(a.session,d):(C=i(a.systemPrompt),C=r(C,d));let w,T=[],E=f+1,D=[],O=!1,k=async e=>a.onBeforePrompt?a.onBeforePrompt({phase:v,attempt:e.attempt,maxAttempts:E,charCount:e.charCount,messageCount:e.messageCount,tokenEstimate:Math.ceil(e.charCount/s),isRepair:e.isRepair}):!0,A=async(e,t)=>{if(!await k({attempt:t,charCount:e.prompt.length,messageCount:1,isRepair:!0}))return;let n=o();D.push(n);let r=Date.now();y?.({phase:v,attempt:t,maxAttempts:E,callId:n,isRepair:!0});let i;try{i=await c.generate({messages:[{role:`user`,content:e.prompt}],maxTokens:p,temperature:0,signal:_})}catch(e){let i=e instanceof Error?e.message:String(e);try{b?.({phase:v,attempt:t,callId:n,success:!1,durationMs:Date.now()-r,error:{code:e?.code??`UNKNOWN`,message:i},promptVersion:x,isRepair:!0})}catch{}if(l(e)||_?.aborted)throw new DOMException(i,`AbortError`);return}let a=Date.now()-r;w=u(w,i.usage);let s=e=>{try{b?.({phase:v,attempt:t,callId:n,success:!0,durationMs:a,usage:i.usage,promptVersion:x,repairs:e,isRepair:!0})}catch{}},d;try{d=e.apply(i.text)}catch(e){throw s(),e}return s(d.valid?d.repairs:void 0),d.valid&&d.result!==void 0?{result:d.result,repairs:d.repairs,responseText:i.text}:{errors:d.errors??[`Unknown repair validation error`]}};for(let e=0;e<=f;e++){_?.throwIfAborted();let t=e+1;if(m?.({type:`attempt_start`,attempt:t,maxAttempts:E}),a.onBeforePrompt&&!await k({attempt:t,charCount:(C.systemPrompt?.length??0)+C.messages.reduce((e,t)=>e+t.content.length,0),messageCount:C.messages.length}))throw new DOMException(`Prompt gate declined by caller (onBeforePrompt returned false)`,`AbortError`);let i=o();D.push(i);let s=Date.now();y?.({phase:v,attempt:t,maxAttempts:E,callId:i});let d;try{d=await c.generate({system:C.systemPrompt,messages:[...C.messages],maxTokens:p,temperature:0,signal:_})}catch(e){let n=e instanceof Error?e.message:String(e);try{b?.({phase:v,attempt:t,callId:i,success:!1,durationMs:Date.now()-s,error:{code:e?.code??`UNKNOWN`,message:n},promptVersion:x})}catch{}throw l(e)||_?.aborted?new DOMException(n,`AbortError`):e}let g=Date.now()-s,j=e=>{try{b?.({phase:v,attempt:t,callId:i,success:!0,durationMs:g,usage:d.usage,promptVersion:x,repairs:e})}catch{}};w=u(w,d.usage);let M;try{M=h(d.text)}catch(e){throw j(),e}if(j(M.valid?M.repairs:void 0),M.valid&&M.result!==void 0)return C=n(C,d.text),{result:M.result,session:C,usage:w,attempts:t,durationMs:Date.now()-S,callIds:D,repairs:M.repairs};if(T=M.errors??[`Unknown validation error`],M.repair&&!O){O=!0;let e=await A(M.repair,t);if(e&&`result`in e)return C=n(C,d.text),C=r(C,M.repair.prompt),C=n(C,e.responseText),{result:e.result,session:C,usage:w,attempts:t,durationMs:Date.now()-S,callIds:D,repairs:e.repairs};e&&(T=[...T,`Targeted repair rejected: ${e.errors.join(`; `)}`])}m?.({type:`validation_failed`,attempt:t,maxAttempts:E,errors:T}),e<f&&M.retryFeedback&&(C=n(C,d.text),C=r(C,M.retryFeedback))}throw new e(`VALIDATION_EXHAUSTED`,`Failed to get valid ${g} after ${E} attempts. Last errors:\n${T.join(`
2
2
  `)}`)}function l(e){return e instanceof Error&&(e.name===`AbortError`||e.name===`DOMException`)}function u(e,t){if(!e)return t;if(!t)return e;let n={...e};return a(n,t),n}function d(e){let t=e.match(/<output>([\s\S]*?)(?:<\/output>|$)/)?.[1]?.trim()??null;return t?t.match(/^```(?:\w*)\n?([\s\S]*?)```$/)?.[1]?.trim()??t:null}export{s as estimatedCharactersPerToken,d as extractOutputTag,c as runAiLoop};
@@ -1 +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{validateGroupResponse as r}from"../group/validator.js";import{buildComposeGroupSystemPrompt as i,buildComposeGroupUserPrompt as a}from"./prompt.js";async function o(o){let s=o.hunks,c=o.mode??`branches`;if(!s||s.length===0)throw new e(`No hunks provided for compose-group`);let l=new Set(s.map(e=>e.index));if(o.session?.completedSteps.composeGroup)return{result:o.session.completedSteps.composeGroup,session:o.session,attempts:0,durationMs:0,callIds:[]};let u=o.promptOverride?.system??i(o.conventions,c,o.strategy,o.includeAnalysis,o.lightweight),d,f=n(a({hunks:s,commitMessages:o.commitMessages,commitOrder:o.commitOrder,branchName:o.branchName,baseBranch:o.baseBranch,issueUrl:o.issueUrl,pullRequestUrl:o.pullRequestUrl,authoring:o.authoring,analysis:o.analysis,hunkSummaries:o.hunkSummaries,instructions:o.instructions,condensed:o.condensed,redactHunkContent:o.redactHunkContent,inputTokenBudget:o.inputTokenBudget,systemPromptChars:u.length,onBudgetReduction:e=>{d=e}}),o.promptOverride),p=await t({model:o.model,systemPrompt:u,userPrompt:f,maxRetries:o.maxRetries,maxTokens:o.maxTokens,onProgress:o.onProgress,cancellation:o.cancellation,onBeforeModelCall:o.onBeforeModelCall,onAfterModelCall:o.onAfterModelCall,onBeforePrompt:o.onBeforePrompt,validate:e=>r(e,l,c,o.existingBranchNames),taskName:`compose-group`}),m=o.session?.completedSteps??{},h={...p.session,completedSteps:{...m,composeGroup:p.result}};return{result:p.result,session:h,usage:p.usage,attempts:p.attempts,durationMs:p.durationMs,callIds:p.callIds,hunksCondensedCount:d,repairs:p.repairs}}export{o as composeGroup};
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{validateGroupResponse as r}from"../group/validator.js";import{buildComposeGroupSystemPrompt as i,buildComposeGroupUserPrompt as a}from"./prompt.js";async function o(o){let s=o.hunks,c=o.mode??`branches`;if(!s||s.length===0)throw new e(`No hunks provided for compose-group`);let l=new Set(s.map(e=>e.index));if(o.session?.completedSteps.composeGroup)return{result:o.session.completedSteps.composeGroup,session:o.session,attempts:0,durationMs:0,callIds:[]};let u=o.promptOverride?.system??i(o.conventions,c,o.strategy,o.includeAnalysis,o.lightweight),d,f=n(a({hunks:s,commitMessages:o.commitMessages,commitOrder:o.commitOrder,branchName:o.branchName,baseBranch:o.baseBranch,issueUrl:o.issueUrl,pullRequestUrl:o.pullRequestUrl,authoring:o.authoring,analysis:o.analysis,hunkSummaries:o.hunkSummaries,instructions:o.instructions,condensed:o.condensed,redactHunkContent:o.redactHunkContent,inputTokenBudget:o.inputTokenBudget,systemPromptChars:u.length,onBudgetReduction:e=>{d=e}}),o.promptOverride),p=await t({model:o.model,systemPrompt:u,userPrompt:f,maxRetries:o.maxRetries,maxTokens:o.maxTokens,onProgress:o.onProgress,cancellation:o.cancellation,onBeforeModelCall:o.onBeforeModelCall,onAfterModelCall:o.onAfterModelCall,onBeforePrompt:o.onBeforePrompt,validate:e=>r(e,l,c,o.existingBranchNames,{repairContext:{hunks:s,condensed:o.condensed,redactHunkContent:o.redactHunkContent,inputTokenBudget:o.inputTokenBudget}}),taskName:`compose-group`}),m=o.session?.completedSteps??{},h={...p.session,completedSteps:{...m,composeGroup:p.result}};return{result:p.result,session:h,usage:p.usage,attempts:p.attempts,durationMs:p.durationMs,callIds:p.callIds,hunksCondensedCount:d,repairs:p.repairs}}export{o as composeGroup};
@@ -1 +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{validateGroupResponse as r}from"./validator.js";import{buildGroupContinuationPrompt as i,buildGroupSystemPrompt as a,buildGroupUserPrompt as o}from"./prompt.js";async function s(s){let c=s.hunks,l=s.mode??`branches`;if((!c||c.length===0)&&!s.session)throw new e(`No hunks provided: supply 'hunks' or run analyze() first with a session`);if(s.session?.completedSteps.group)return{result:s.session.completedSteps.group,session:s.session,attempts:0,durationMs:0,callIds:[]};let u=s.session?void 0:s.promptOverride?.system??a(s.conventions,l,s.strategy),d,f=n(s.session?i(s,l):o({hunks:c,commitMessages:s.commitMessages,instructions:s.instructions,authoring:s.authoring,analysis:s.analysis,hunkSummaries:s.hunkSummaries,condensed:s.condensed,mode:l,redactHunkContent:s.redactHunkContent,inputTokenBudget:s.inputTokenBudget,systemPromptChars:u?.length,onBudgetReduction:e=>{d=e}}),s.promptOverride),p=c?.length?new Set(c.map(e=>e.index)):new Set(s.session?.completedSteps.analyze?.hunkAnnotations.map(e=>e.hunkIndex)??[]);if(p.size===0)throw new e(`Cannot validate grouping: no hunk indices available from input or session`);let m=await t({model:s.model,systemPrompt:u,userPrompt:f,session:s.session,maxRetries:s.maxRetries,maxTokens:s.maxTokens,onProgress:s.onProgress,cancellation:s.cancellation,onBeforeModelCall:s.onBeforeModelCall,onAfterModelCall:s.onAfterModelCall,onBeforePrompt:s.onBeforePrompt,validate:e=>r(e,p,l,s.existingBranchNames),taskName:`group`}),h=s.session?.completedSteps??{},g={...m.session,completedSteps:{...h,group:m.result}};return{result:m.result,session:g,usage:m.usage,attempts:m.attempts,durationMs:m.durationMs,callIds:m.callIds,hunksCondensedCount:d,repairs:m.repairs}}export{s as group};
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{validateGroupResponse as r}from"./validator.js";import{buildGroupContinuationPrompt as i,buildGroupSystemPrompt as a,buildGroupUserPrompt as o}from"./prompt.js";async function s(s){let c=s.hunks,l=s.mode??`branches`;if((!c||c.length===0)&&!s.session)throw new e(`No hunks provided: supply 'hunks' or run analyze() first with a session`);if(s.session?.completedSteps.group)return{result:s.session.completedSteps.group,session:s.session,attempts:0,durationMs:0,callIds:[]};let u=s.session?void 0:s.promptOverride?.system??a(s.conventions,l,s.strategy),d,f=n(s.session?i(s,l):o({hunks:c,commitMessages:s.commitMessages,instructions:s.instructions,authoring:s.authoring,analysis:s.analysis,hunkSummaries:s.hunkSummaries,condensed:s.condensed,mode:l,redactHunkContent:s.redactHunkContent,inputTokenBudget:s.inputTokenBudget,systemPromptChars:u?.length,onBudgetReduction:e=>{d=e}}),s.promptOverride),p=c?.length?new Set(c.map(e=>e.index)):new Set(s.session?.completedSteps.analyze?.hunkAnnotations.map(e=>e.hunkIndex)??[]);if(p.size===0)throw new e(`Cannot validate grouping: no hunk indices available from input or session`);let m=await t({model:s.model,systemPrompt:u,userPrompt:f,session:s.session,maxRetries:s.maxRetries,maxTokens:s.maxTokens,onProgress:s.onProgress,cancellation:s.cancellation,onBeforeModelCall:s.onBeforeModelCall,onAfterModelCall:s.onAfterModelCall,onBeforePrompt:s.onBeforePrompt,validate:e=>r(e,p,l,s.existingBranchNames,c?.length?{repairContext:{hunks:c,condensed:s.condensed,redactHunkContent:s.redactHunkContent,inputTokenBudget:s.inputTokenBudget}}:void 0),taskName:`group`}),h=s.session?.completedSteps??{},g={...m.session,completedSteps:{...h,group:m.result}};return{result:m.result,session:g,usage:m.usage,attempts:m.attempts,durationMs:m.durationMs,callIds:m.callIds,hunksCondensedCount:d,repairs:m.repairs}}export{s as group};
@@ -0,0 +1,39 @@
1
+ import { ValidationRepair } from "../../ai/types.js";
2
+ import { BranchGroup, ComposeHunk } from "../types.js";
3
+ import { TargetedRepair } from "../ai-loop.js";
4
+ import { RedactHunkContent } from "../utils/redact.js";
5
+ import { GroupingResult } from "./types.js";
6
+
7
+ //#region src/tasks/group/repair.d.ts
8
+ /**
9
+ * What the missing-hunk repair needs from the task to build its prompt. Omitted in conversation
10
+ * mode, where the hunks live in the model's context rather than in the task input — without hunk
11
+ * bodies there is nothing to ask about, so a short response falls straight through to a retry.
12
+ */
13
+ interface MissingHunksRepairContext {
14
+ hunks: readonly ComposeHunk[];
15
+ /** The caller asked for metadata only. Honoured here too: the repair never sends a hunk body. */
16
+ condensed?: boolean;
17
+ /** See {@link RedactHunkContent}. Honoured here too: a repair prompt must not leak excluded content. */
18
+ redactHunkContent?: RedactHunkContent;
19
+ /** Ceiling above which the repair prompt drops hunk bodies and asks on metadata alone. */
20
+ inputTokenBudget?: number;
21
+ }
22
+ /**
23
+ * Plans the repair for a grouping that left hunks unassigned: ask the model where the orphans
24
+ * belong instead of re-sending the whole diff. The question is deliberately narrow — an
25
+ * assignment map for the missing indices, never a fresh grouping — so a response cannot
26
+ * restructure decisions the first response already made.
27
+ *
28
+ * Returns undefined when the repair cannot be posed (no body for a missing index), or when so many
29
+ * hunks are missing that the question is most of the diff again.
30
+ */
31
+ declare function planMissingHunksRepair(params: {
32
+ branches: BranchGroup[];
33
+ mode: 'commits' | 'branches';
34
+ missingIndices: number[];
35
+ priorRepairs?: ValidationRepair[];
36
+ context: MissingHunksRepairContext;
37
+ }): TargetedRepair<GroupingResult> | undefined;
38
+ //#endregion
39
+ export { MissingHunksRepairContext, planMissingHunksRepair };
@@ -0,0 +1,16 @@
1
+ import{extractOutputTag as e}from"../ai-loop.js";import{DEFAULT_INPUT_TOKEN_BUDGET as t,estimateTokens as n,serializeHunk as r}from"../utils/budget.js";import{validateJsonSchema as i}from"../utils/json-schema.js";const a={$schema:`https://json-schema.org/draft/2020-12/schema`,type:`object`,properties:{assignments:{minItems:1,type:`array`,items:{type:`object`,properties:{hunkIndex:{type:`integer`,description:`Index of an unassigned hunk`},commitId:{type:`string`,minLength:1,description:`ID of the commit this hunk belongs to`}},required:[`hunkIndex`,`commitId`],additionalProperties:!1},description:`One entry per unassigned hunk`}},required:[`assignments`],additionalProperties:!1};function o(e){let{branches:t,mode:n,missingIndices:r,priorRepairs:i,context:a}=e;if(r.length>Math.max(2,a.hunks.length*.25))return;let o=new Map(a.hunks.map(e=>[e.index,e])),l=r.map(e=>o.get(e));if(l.some(e=>e===void 0))return;let u=t.flatMap(e=>e.commits.map(t=>n===`branches`?{id:t.id,message:t.message,branch:e.name,branchTitle:e.title}:{id:t.id,message:t.message}));if(u.length!==0)return{prompt:s({hunks:l.map(e=>e),commits:u,condensed:a.condensed,redactHunkContent:a.redactHunkContent,inputTokenBudget:a.inputTokenBudget}),apply:e=>c(e,{branches:t,missingIndices:r,priorRepairs:i})}}function s(e){let{hunks:i,commits:a,condensed:o,redactHunkContent:s,inputTokenBudget:c=t}=e,l=e=>{let t=JSON.stringify(i.map(t=>r(t,e?s?.(t)?`redacted`:`full`:`condensed`))),n=i.length===1?`hunk`:`hunks`,o=a.some(e=>e.branch!==void 0),c=JSON.stringify({assignments:[{hunkIndex:i[0].index,commitId:a[0].id}]},null,2);return`A grouping of code hunks into commits left ${i.length} ${n} unassigned. Decide which existing commit each one belongs to. Do not regroup anything else: the commits below are fixed, and every other hunk is already assigned.${o?` Each commit sits on the branch named next to it; choose the branch whose work the hunk belongs to, then the commit on it.`:``}
2
+
3
+ <unassigned_hunks>
4
+ ${t}
5
+ </unassigned_hunks>
6
+
7
+ <commits>
8
+ ${JSON.stringify(a)}
9
+ </commits>
10
+
11
+ Return JSON inside <output> tags with exactly one entry per unassigned hunk — the ${i.length} indices above, no others — each pointing at one of the commit IDs above:
12
+ <output>
13
+ ${c}
14
+ </output>
15
+
16
+ Return only the <output> tag and no other text.`};if(o)return l(!1);let u=l(!0);return n(u.length)>c?l(!1):u}function c(t,n){let{branches:r,missingIndices:o,priorRepairs:s}=n,c=e(t);if(!c)return{valid:!1,errors:[`Repair response has no <output> tag`]};let l;try{l=JSON.parse(c)}catch(e){return{valid:!1,errors:[`Repair response is not valid JSON: ${e instanceof Error?e.message:String(e)}`]}}let u=i(a,l);if(!u.success)return{valid:!1,errors:u.issues.map(e=>`Repair response ${e.path.join(`.`)}: ${e.message}`)};let d=new Set(r.flatMap(e=>e.commits.map(e=>e.id))),f=new Set(o),p=new Map,m=[];for(let{hunkIndex:e,commitId:t}of u.data.assignments){if(!f.has(e)){m.push(`Repair assigned hunk ${e}, which was not one of the unassigned hunks`);continue}if(p.has(e)){m.push(`Repair assigned hunk ${e} more than once`);continue}if(!d.has(t)){m.push(`Repair assigned hunk ${e} to unknown commit '${t}'`);continue}p.set(e,t)}let h=o.filter(e=>!p.has(e));if(h.length>0&&m.push(`Repair left hunks unassigned: ${h.join(`, `)}`),m.length>0)return{valid:!1,errors:m};let g={branches:r.map(e=>({...e,commits:e.commits.map(e=>{let t=[...p].filter(([,t])=>t===e.id).map(([e])=>e);return t.length===0?e:{...e,hunkIndices:[...e.hunkIndices,...t].sort((e,t)=>e-t)}})}))},_=[...p].sort(([e],[t])=>e-t).map(([e,t])=>`hunk ${e} → ${t}`).join(`; `);return{valid:!0,result:g,repairs:[...s??[],{kind:`missing-hunks`,hunkIndices:[...o].sort((e,t)=>e-t),detail:`Placed ${o.length} unassigned hunk(s) via a targeted repair call: ${_}`}]}}export{o as planMissingHunksRepair};
@@ -86,7 +86,7 @@ interface GroupOutput {
86
86
  /** Hunks condensed to metadata to fit the input-token budget. Undefined when the AI call was skipped. */
87
87
  hunksCondensedCount?: number;
88
88
  /**
89
- * Deterministic repairs applied to the accepted AI response instead of spending a retry on it.
89
+ * Repairs applied to the accepted AI response instead of spending a retry on it.
90
90
  * Undefined when the response needed no repair.
91
91
  */
92
92
  repairs?: ValidationRepair[];
@@ -1,5 +1,6 @@
1
1
  import { ValidationResult } from "../ai-loop.js";
2
2
  import { GroupingResult } from "./types.js";
3
+ import { MissingHunksRepairContext } from "./repair.js";
3
4
 
4
5
  //#region src/tasks/group/validator.d.ts
5
6
  interface GroupValidationOptions {
@@ -9,6 +10,11 @@ interface GroupValidationOptions {
9
10
  * be told the hunk is missing or duplicated when it did neither.
10
11
  */
11
12
  lockedCommitIds?: ReadonlySet<string>;
13
+ /**
14
+ * Enables the targeted repair for a response that left hunks unassigned. Omit it and such a
15
+ * response is rejected outright, as it was before the repair existed.
16
+ */
17
+ repairContext?: MissingHunksRepairContext;
12
18
  }
13
19
  declare function validateGroupResponse(responseText: string, expectedHunkIndices: ReadonlySet<number>, mode?: 'commits' | 'branches', existingBranchNames?: Set<string>, options?: GroupValidationOptions): ValidationResult<GroupingResult>;
14
20
  //#endregion
@@ -1,9 +1,9 @@
1
- import{extractOutputTag as e}from"../ai-loop.js";import{validateJsonSchema as t}from"../utils/json-schema.js";import{groupingResultJsonSchema as n}from"./schema.js";function r(r,o,s=`branches`,c,l){let u=null;try{if(u=e(r),!u)return i([`No <output> tag found in response`],a(s));let d=t(n,JSON.parse(u));if(!d.success)return i(d.issues.map(e=>`${e.path.join(`.`)}: ${e.message}`),a(s));let f=d.data,p=[],m=new Set,h=[],g=new Map,_=new Map,v=new Set,y=[],b=[],x=[],S=new Set,C=new Set;for(let e of f.branches){if(S.has(e.id))return i([`Duplicate branch ID: '${e.id}'`],a(s));if(S.add(e.id),s===`branches`&&e.name.length===0)return i([`Branch '${e.id}': missing or empty 'name'`],a(s));if(s===`branches`&&e.name.length>0){if(C.has(e.name))return i([`Duplicate branch name: '${e.name}'`],`Multiple branches use the name '${e.name}'. Each branch must have a unique name. Please rename one of them.`);if(C.add(e.name),c?.has(e.name))return i([`Branch name '${e.name}' already exists in the repository`],`The branch name '${e.name}' already exists in the repository. Please choose a different name for branch '${e.id}'.`)}for(let t of e.commits){v.has(t.id)&&y.push(t.id),v.add(t.id);let n=s===`branches`?`${e.id}/${t.id}`:t.id;for(let r of t.hunks){let i=g.get(r);i?i.push(n):g.set(r,[n]);let a=_.get(r);a?a.push({branchId:e.id,commitId:t.id,label:n}):_.set(r,[{branchId:e.id,commitId:t.id,label:n}])}}}let w=new Map,T=[],E=!1;for(let[e,t]of _){if(t.length<2)continue;if(h.push(e),s===`branches`&&new Set(t.map(e=>e.branchId)).size>1){E=!0;continue}let n=t.find(e=>l?.lockedCommitIds?.has(e.commitId))??t[0];w.set(e,n.label),T.push({hunkIndex:e,keptIn:n.label,droppedFrom:t.filter(e=>e!==n).map(e=>e.label),scope:new Set(t.map(e=>e.commitId)).size===1?`within-commit`:`across-commits`})}let D=h.length>0&&!E;for(let e of f.branches){let t=[];for(let n of e.commits){let r=s===`branches`?`${e.id}/${n.id}`:n.id,i=[],a=!1;for(let e of n.hunks){if(w.has(e)&&w.get(e)!==r){a=!0;continue}m.has(e)||(m.add(e),i.push(e))}n.hunks.length===0?b.push(n.id):i.length===0&&a&&x.push(n.id),t.push({id:n.id,message:n.message,explanation:n.explanation,hunkIndices:i})}p.push({id:e.id,name:e.name,title:e.title,description:e.description,commits:t})}if(y.length>0)return i([`Duplicate commit IDs: ${y.join(`, `)}`],`Some commit IDs appear multiple times. Each must be unique.\n\nDuplicates: ${y.join(`, `)}`);if(b.length>0)return i([`Empty commits (no hunks assigned): ${b.join(`, `)}`],`Every commit must contain at least one hunk. These commits have an empty \`hunks\` array: ${b.join(`, `)}.
1
+ import{extractOutputTag as e}from"../ai-loop.js";import{validateJsonSchema as t}from"../utils/json-schema.js";import{planMissingHunksRepair as n}from"./repair.js";import{groupingResultJsonSchema as r}from"./schema.js";function i(i,s,c=`branches`,l,u){let d=null;try{if(d=e(i),!d)return a([`No <output> tag found in response`],o(c));let f=t(r,JSON.parse(d));if(!f.success)return a(f.issues.map(e=>`${e.path.join(`.`)}: ${e.message}`),o(c));let p=f.data,m=[],h=new Set,g=[],_=new Map,v=new Map,y=new Set,b=[],x=[],S=[],C=new Set,w=new Set;for(let e of p.branches){if(C.has(e.id))return a([`Duplicate branch ID: '${e.id}'`],o(c));if(C.add(e.id),c===`branches`&&e.name.length===0)return a([`Branch '${e.id}': missing or empty 'name'`],o(c));if(c===`branches`&&e.name.length>0){if(w.has(e.name))return a([`Duplicate branch name: '${e.name}'`],`Multiple branches use the name '${e.name}'. Each branch must have a unique name. Please rename one of them.`);if(w.add(e.name),l?.has(e.name))return a([`Branch name '${e.name}' already exists in the repository`],`The branch name '${e.name}' already exists in the repository. Please choose a different name for branch '${e.id}'.`)}for(let t of e.commits){y.has(t.id)&&b.push(t.id),y.add(t.id);let n=c===`branches`?`${e.id}/${t.id}`:t.id;for(let r of t.hunks){let i=_.get(r);i?i.push(n):_.set(r,[n]);let a=v.get(r);a?a.push({branchId:e.id,commitId:t.id,label:n}):v.set(r,[{branchId:e.id,commitId:t.id,label:n}])}}}let T=new Map,E=[],D=!1;for(let[e,t]of v){if(t.length<2)continue;if(g.push(e),c===`branches`&&new Set(t.map(e=>e.branchId)).size>1){D=!0;continue}let n=t.find(e=>u?.lockedCommitIds?.has(e.commitId))??t[0];T.set(e,n.label),E.push({hunkIndex:e,keptIn:n.label,droppedFrom:t.filter(e=>e!==n).map(e=>e.label),scope:new Set(t.map(e=>e.commitId)).size===1?`within-commit`:`across-commits`})}let O=g.length>0&&!D;for(let e of p.branches){let t=[];for(let n of e.commits){let r=c===`branches`?`${e.id}/${n.id}`:n.id,i=[],a=!1;for(let e of n.hunks){if(T.has(e)&&T.get(e)!==r){a=!0;continue}h.has(e)||(h.add(e),i.push(e))}n.hunks.length===0?x.push(n.id):i.length===0&&a&&S.push(n.id),t.push({id:n.id,message:n.message,explanation:n.explanation,hunkIndices:i})}m.push({id:e.id,name:e.name,title:e.title,description:e.description,commits:t})}if(b.length>0)return a([`Duplicate commit IDs: ${b.join(`, `)}`],`Some commit IDs appear multiple times. Each must be unique.\n\nDuplicates: ${b.join(`, `)}`);if(x.length>0)return a([`Empty commits (no hunks assigned): ${x.join(`, `)}`],`Every commit must contain at least one hunk. These commits have an empty \`hunks\` array: ${x.join(`, `)}.
2
2
 
3
- Either assign at least one hunk to each of these commits, or remove them entirely. If a commit's purpose is already covered by another commit, remove the duplicate. If a commit is supposed to cover specific hunks, assign those hunk indices to it. Every hunk index still needs to appear in exactly one commit.`);let O=[...o].filter(e=>!m.has(e)).sort((e,t)=>e-t),k=[...m].filter(e=>!o.has(e)).sort((e,t)=>e-t),A=[...new Set(h)].sort((e,t)=>e-t),j=A.length>0&&!D;if(O.length>0||k.length>0||x.length>0||j){let e=[],t=[];if(A.length>0){let n=A.map(e=>` hunk ${e} → ${(g.get(e)??[]).join(`, `)}`).join(`
4
- `);e.push(`Duplicate hunks (each must appear in exactly ONE commit):\n${n}`),t.push(`Duplicate hunks: ${A.join(`, `)}`)}O.length>0&&(e.push(`Missing hunks (not assigned to any commit): ${O.join(`, `)}`),t.push(`Missing hunks: ${O.join(`, `)}`)),k.length>0&&(e.push(`Extra hunks (not present in the input): ${k.join(`, `)}`),t.push(`Extra hunks: ${k.join(`, `)}`)),x.length>0&&(e.push(`Commits whose only hunks were already claimed by an earlier commit: ${x.join(`, `)}`),t.push(`Empty commits after dropping duplicate hunks: ${x.join(`, `)}`));let n=[...o].sort((e,t)=>e-t),r=[...g.values()].reduce((e,t)=>e+t.length,0);return i(t,`There are ${n.length} hunks, with indices: ${n.join(`, `)}. Every one of these indices must appear in exactly one commit — no duplicates, no omissions, no other indices. Use the indices exactly as given; do not renumber them.
3
+ Either assign at least one hunk to each of these commits, or remove them entirely. If a commit's purpose is already covered by another commit, remove the duplicate. If a commit is supposed to cover specific hunks, assign those hunk indices to it. Every hunk index still needs to appear in exactly one commit.`);let k=[...s].filter(e=>!h.has(e)).sort((e,t)=>e-t),A=[...h].filter(e=>!s.has(e)).sort((e,t)=>e-t),j=[...new Set(g)].sort((e,t)=>e-t),M=[...E].sort((e,t)=>e.hunkIndex-t.hunkIndex),N=M.length>0?{kind:`duplicate-hunks`,hunkIndices:M.map(e=>e.hunkIndex),claims:M,detail:`Dropped duplicate claims on ${M.length} hunk(s): ${M.map(e=>`hunk ${e.hunkIndex} kept in ${e.keptIn}`).join(`; `)}`}:void 0,P=j.length>0&&!O;if(k.length>0||A.length>0||S.length>0||P){let e=[],t=[];if(j.length>0){let n=j.map(e=>` hunk ${e} → ${(_.get(e)??[]).join(`, `)}`).join(`
4
+ `);e.push(`Duplicate hunks (each must appear in exactly ONE commit):\n${n}`),t.push(`Duplicate hunks: ${j.join(`, `)}`)}k.length>0&&(e.push(`Missing hunks (not assigned to any commit): ${k.join(`, `)}`),t.push(`Missing hunks: ${k.join(`, `)}`)),A.length>0&&(e.push(`Extra hunks (not present in the input): ${A.join(`, `)}`),t.push(`Extra hunks: ${A.join(`, `)}`)),S.length>0&&(e.push(`Commits whose only hunks were already claimed by an earlier commit: ${S.join(`, `)}`),t.push(`Empty commits after dropping duplicate hunks: ${S.join(`, `)}`));let r=[...s].sort((e,t)=>e-t),i=[..._.values()].reduce((e,t)=>e+t.length,0),o=`There are ${r.length} hunks, with indices: ${r.join(`, `)}. Every one of these indices must appear in exactly one commit — no duplicates, no omissions, no other indices. Use the indices exactly as given; do not renumber them.
5
5
 
6
- Your response listed ${r} hunk references across ${g.size} unique indices. Expected: ${n.length} references across ${n.length} unique indices.
6
+ Your response listed ${i} hunk references across ${_.size} unique indices. Expected: ${r.length} references across ${r.length} unique indices.
7
7
 
8
8
  Specific problems:
9
9
  ${e.map(e=>`- ${e}`).join(`
@@ -11,7 +11,7 @@ ${e.map(e=>`- ${e}`).join(`
11
11
 
12
12
  To fix duplicates: for each duplicated hunk index, decide which one commit it truly belongs in and remove it from the others. Do not split a single hunk across two commits — git only knows how to apply a hunk once.
13
13
 
14
- Return the corrected JSON inside <output> tags.`)}if(T.length>0){let e=[...T].sort((e,t)=>e.hunkIndex-t.hunkIndex),t=e.map(e=>`hunk ${e.hunkIndex} kept in ${e.keptIn}`).join(`; `);return{valid:!0,result:{branches:p},repairs:[{kind:`duplicate-hunks`,hunkIndices:e.map(e=>e.hunkIndex),claims:e,detail:`Dropped duplicate claims on ${e.length} hunk(s): ${t}`}]}}return{valid:!0,result:{branches:p}}}catch(e){let t=e instanceof Error?e.message:String(e),n=(u??r).slice(-300);return i([`Failed to parse response as valid JSON: ${t}`,`Response tail: ${n}`],a(s))}}function i(e,t){return{valid:!1,errors:e,retryFeedback:t}}function a(e){return e===`commits`?`Your response has an invalid structure. Please return JSON inside <output> tags. Use a single branch with id "default", name "", description "":
14
+ Return the corrected JSON inside <output> tags.`;if(u?.repairContext&&k.length>0&&A.length===0&&S.length===0&&!P){let e=n({branches:m,mode:c,missingIndices:k,priorRepairs:N?[N]:void 0,context:u.repairContext});if(e)return{...a(t,o),repair:e}}return a(t,o)}return N?{valid:!0,result:{branches:m},repairs:[N]}:{valid:!0,result:{branches:m}}}catch(e){let t=e instanceof Error?e.message:String(e),n=(d??i).slice(-300);return a([`Failed to parse response as valid JSON: ${t}`,`Response tail: ${n}`],o(c))}}function a(e,t){return{valid:!1,errors:e,retryFeedback:t}}function o(e){return e===`commits`?`Your response has an invalid structure. Please return JSON inside <output> tags. Use a single branch with id "default", name "", description "":
15
15
  <output>
16
16
  {
17
17
  "branches": [
@@ -55,4 +55,4 @@ Return only the <output> tag and no other text.`:`Your response has an invalid s
55
55
  }
56
56
  </output>
57
57
 
58
- Return only the <output> tag and no other text.`}export{r as validateGroupResponse};
58
+ Return only the <output> tag and no other text.`}export{i as validateGroupResponse};
@@ -13,6 +13,14 @@ import { RedactHunkContent } from "./redact.js";
13
13
  declare const DEFAULT_INPUT_TOKEN_BUDGET = 180000;
14
14
  /** Rough char→token estimate shared with `runAiLoop`'s prompt-gate heuristic. */
15
15
  declare function estimateTokens(chars: number): number;
16
+ /**
17
+ * How a hunk is serialized into a prompt:
18
+ * - `full`: verbatim patch body (default).
19
+ * - `redacted`: body replaced with a placeholder, metadata + line counts kept.
20
+ * - `condensed`: metadata + line counts only, no `content` field at all.
21
+ */
22
+ type HunkRenderMode = 'full' | 'redacted' | 'condensed';
23
+ declare function serializeHunk(hunk: ComposeHunk, mode: HunkRenderMode): Record<string, unknown>;
16
24
  interface AssembledHunkPrompt {
17
25
  prompt: string;
18
26
  /**
@@ -37,4 +45,4 @@ declare function assembleHunkPrompt(params: {
37
45
  systemPromptChars?: number;
38
46
  }): AssembledHunkPrompt;
39
47
  //#endregion
40
- export { AssembledHunkPrompt, DEFAULT_INPUT_TOKEN_BUDGET, assembleHunkPrompt, estimateTokens };
48
+ export { AssembledHunkPrompt, DEFAULT_INPUT_TOKEN_BUDGET, HunkRenderMode, assembleHunkPrompt, estimateTokens, serializeHunk };
@@ -1 +1 @@
1
- import{DiffTooLargeError as e}from"../../errors/task.js";import{estimatedCharactersPerToken as t}from"../ai-loop.js";import{REDACTED_HUNK_CONTENT as n}from"./redact.js";const r=18e4;function i(e){return Math.ceil(e/t)}function a(e,t){let r={...e.isBinary?{isBinary:!0}:{},...e.isRename?{isRename:!0}:{},...e.originalFileName?{originalFileName:e.originalFileName}:{}};return t===`condensed`?{index:e.index,fileName:e.fileName,hunkHeader:e.hunkHeader,additions:e.additions,deletions:e.deletions,...r}:t===`redacted`?{index:e.index,fileName:e.fileName,hunkHeader:e.hunkHeader,additions:e.additions,deletions:e.deletions,content:n,...r}:{index:e.index,fileName:e.fileName,hunkHeader:e.hunkHeader,content:e.content,...r}}function o(e,t){return JSON.stringify(a(e,t)).length}function s(e,t,n,r){let a=new Map(t),s=2+Math.max(0,e.length-1),c=0,l=e.map(e=>{let t=a.get(e.index),n=o(e,`condensed`),r=t===`condensed`?n:o(e,t);return c+=r,{hunk:e,currentLen:r,condensedLen:n}}),u=()=>i(r+s+c),d=()=>u()>n;if(d()){let e=l.map(e=>({hunk:e.hunk,savings:e.currentLen-e.condensedLen})).filter(e=>e.savings>0).sort((e,t)=>t.savings-e.savings);for(let{hunk:t,savings:n}of e){if(!d())break;c-=n,a.set(t.index,`condensed`)}}return{modes:a,estimatedTokens:u(),tooLarge:d()}}function c(t){let{hunks:n,assemble:i,condensed:o,redactHunkContent:c,inputTokenBudget:l=r,systemPromptChars:u=0}=t,d=new Map(n.map(e=>o?[e.index,`condensed`]:c?.(e)?[e.index,`redacted`]:[e.index,`full`])),{modes:f,estimatedTokens:p,tooLarge:m}=s(n,d,l,i(``).length+u);if(m)throw new e(p,l);let h=0;for(let e of n)d.get(e.index)===`full`&&f.get(e.index)===`condensed`&&h++;return{prompt:i(JSON.stringify(n.map(e=>a(e,f.get(e.index))))),hunksCondensedForBudget:h}}export{r as DEFAULT_INPUT_TOKEN_BUDGET,c as assembleHunkPrompt,i as estimateTokens};
1
+ import{DiffTooLargeError as e}from"../../errors/task.js";import{estimatedCharactersPerToken as t}from"../ai-loop.js";import{REDACTED_HUNK_CONTENT as n}from"./redact.js";const r=18e4;function i(e){return Math.ceil(e/t)}function a(e,t){let r={...e.isBinary?{isBinary:!0}:{},...e.isRename?{isRename:!0}:{},...e.originalFileName?{originalFileName:e.originalFileName}:{}};return t===`condensed`?{index:e.index,fileName:e.fileName,hunkHeader:e.hunkHeader,additions:e.additions,deletions:e.deletions,...r}:t===`redacted`?{index:e.index,fileName:e.fileName,hunkHeader:e.hunkHeader,additions:e.additions,deletions:e.deletions,content:n,...r}:{index:e.index,fileName:e.fileName,hunkHeader:e.hunkHeader,content:e.content,...r}}function o(e,t){return JSON.stringify(a(e,t)).length}function s(e,t,n,r){let a=new Map(t),s=2+Math.max(0,e.length-1),c=0,l=e.map(e=>{let t=a.get(e.index),n=o(e,`condensed`),r=t===`condensed`?n:o(e,t);return c+=r,{hunk:e,currentLen:r,condensedLen:n}}),u=()=>i(r+s+c),d=()=>u()>n;if(d()){let e=l.map(e=>({hunk:e.hunk,savings:e.currentLen-e.condensedLen})).filter(e=>e.savings>0).sort((e,t)=>t.savings-e.savings);for(let{hunk:t,savings:n}of e){if(!d())break;c-=n,a.set(t.index,`condensed`)}}return{modes:a,estimatedTokens:u(),tooLarge:d()}}function c(t){let{hunks:n,assemble:i,condensed:o,redactHunkContent:c,inputTokenBudget:l=r,systemPromptChars:u=0}=t,d=new Map(n.map(e=>o?[e.index,`condensed`]:c?.(e)?[e.index,`redacted`]:[e.index,`full`])),{modes:f,estimatedTokens:p,tooLarge:m}=s(n,d,l,i(``).length+u);if(m)throw new e(p,l);let h=0;for(let e of n)d.get(e.index)===`full`&&f.get(e.index)===`condensed`&&h++;return{prompt:i(JSON.stringify(n.map(e=>a(e,f.get(e.index))))),hunksCondensedForBudget:h}}export{r as DEFAULT_INPUT_TOKEN_BUDGET,c as assembleHunkPrompt,i as estimateTokens,a as serializeHunk};
@@ -251,7 +251,8 @@ interface ComposeInput {
251
251
  onAfterModelCall?: OnAfterModelCall;
252
252
  /**
253
253
  * Gating hook called immediately before each model.generate call. Returning
254
- * `false` aborts the workflow with `ComposeWorkflowError('CANCELLED')`. Use for
254
+ * `false` aborts the workflow with `ComposeWorkflowError('CANCELLED')`, except on
255
+ * a missing-hunks repair call, where it falls back to a regular retry. Use for
255
256
  * large-prompt confirmations, cost warnings, org policy checks, etc.
256
257
  */
257
258
  onBeforePrompt?: OnBeforePrompt;
@@ -483,7 +484,8 @@ interface ComposeResult {
483
484
  * Call ID of the "decision" model response for this workflow — the last
484
485
  * validated response across all AI phases. Consumers that surface user
485
486
  * feedback (thumbs up/down) stamp this ID on the feedback event so it maps
486
- * back to a specific prompt/response. Undefined when no AI phases ran
487
+ * back to a specific prompt/response. When a missing-hunks repair completed
488
+ * that response, this is the response, not the repair call. Undefined when no AI phases ran
487
489
  * (e.g. {@link applyComposePlan} without prior {@link composePlan}).
488
490
  */
489
491
  feedbackTargetCallId?: string;
@@ -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{applyPatchToIndex as r,currentBranch as i,diffTree as a,forEachRef as o,log as s,mergeBase as c,readTree as l,revParse as u,writeTree as d}from"../git/port-dispatch.js";import{validateRewriteRangeSafety as f}from"../git/safety-range.js";import{addUsage as p,emptyUsage as m}from"../ai/types.js";import{memoizeRedactHunkContent as h}from"../tasks/utils/redact.js";import{analyze as g}from"../tasks/analyze/analyze.js";import{composeGroup as _}from"../tasks/compose-group/compose-group.js";import{group as v}from"../tasks/group/group.js";import{order as y}from"../tasks/order/order.js";import{partition as b}from"../tasks/partition/partition.js";import{withDefaultNoiseRedaction as x}from"../tasks/utils/noise.js";import{sha256 as S}from"../git/utils/crypto.js";import{collectFromSource as C,resolveWorkdirLayers as w}from"../git/diff-collector.js";import{buildPatchForCommit as T}from"../git/utils/patch-builder.js";import{verifySnapshot as E}from"../git/safety.js";import{applyToTarget as D}from"../git/apply-target.js";import{requireExec as O}from"../git/require-exec.js";import{extractConventions as k}from"../git/utils/conventions.js";import{rethrowAsWorkflowError as A}from"../utils/cancellation.js";import{randomUUID as j}from"node:crypto";import{unlinkSync as M}from"node:fs";import{tmpdir as N}from"node:os";import{join as P}from"node:path";import{UndoStore as F,capturePreOperationState as I,generateUndoId as L}from"@gitkraken/shared-tools";const R={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 z(r){if(!r.applyPlan&&!r.model)throw new n(`Either 'model' or 'applyPlan' must be provided`);if(r.applyPlan)return te(r);if(r.analysis!=null&&r.session!=null)throw new n(`'analysis' cannot be combined with 'session'; provide one or the other`);let{git:i,model:a,source:o={type:`workdir`},onProgress:s,onBeforeApply:c,cancellation:l}=r,u=a,d=r.depth??`balanced`,p=r.target??await Y(i,o);Z(o,p),await ie(i,r.applyCommitIds,r.hunkFilter,o,p),await f({git:i,source:o,target:p,overrides:r.overrideSafetyFailures});let m=[];s?.({phase:`collecting`,...R.collecting});let g=Date.now(),_=await C(i,o);if(m.push({name:`collecting`,durationMs:Date.now()-g,attempts:1}),_.hunks.length===0)throw new e(`NO_CHANGES`,X(o));let{hunks:v,filteredCount:y}=await q(_.hunks,r.hunkFilter);if(v.length===0)throw new e(`NO_CHANGES`,X(o));let b=K(v);s?.({phase:`collecting`,...R.collecting,stats:b});let S=y!==void 0&&y>0?await J(i,_.snapshot,v):_.snapshot,w=r.conventions??await k({git:i,author:r.conventionAuthor}),T=await ce(i),j=p.type===`auto`?`branches`:`commits`,M=r.disableDefaultNoiseReduction?r.redactHunkContent:x(r.redactHunkContent),N=M?h(M):void 0,P=N?v.filter(e=>N(e)).length:void 0,F={hunks:v,collection:_,conventions:w,existingBranchNames:T,groupMode:j,target:p,model:u,instructions:r.instructions,context:r.context,analysis:r.analysis,condenseHunks:r.condenseHunks,hunkSummaries:r.hunkSummaries,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:N,inputTokenBudget:r.inputTokenBudget},L;try{L=await ee(d,F)}catch(e){A(e,l)}let z={analysis:L.analysis,grouping:L.grouping,ordering:L.ordering,branches:L.branchPlans,allOrderedCommits:L.allOrderedCommits};if(m.push(...L.phases),c&&!await c(z))return{plan:z,snapshot:S,session:L.session,usage:L.usage,phases:m,source:{type:o.type===`branch`?`branch`:`workdir`,hunks:v,commitMessages:_.commitMessages,branchName:_.branchName,baseBranch:_.baseBranch},diffStats:b,feedbackTargetCallId:ne(m),hunksFilteredCount:y,hunksRedactedCount:P,hunksCondensedCount:L.hunksCondensedCount};let B=await I(O(i,`capturing pre-operation state`));s?.({phase:`verifying`,...R.verifying});let V=Date.now(),H=await E(i,_.snapshot,re(o,_));if(!H.valid)throw new t(`SAFETY_CHECK_FAILED`,`Safety check failed: ${H.error}`);m.push({name:`verifying`,durationMs:Date.now()-V,attempts:1});let U=ae(L.allOrderedCommits,r.applyCommitIds),{leftoverHunks:W,leftoverPatch:G}=se(U,_.hunks),Q=W.length>0?await J(i,S,oe(U,v)):S;s?.({phase:`applying`,...R.applying});let ue=Date.now(),$=await D({git:i,source:o,target:p,branchPlans:L.branchPlans,allOrderedCommits:U,hunks:v,snapshot:Q,collection:_,signing:r.signing,authorAttribution:r.authorAttribution,stashLabel:r.stashLabel,leftoverPatch:G});m.push({name:`applying`,durationMs:Date.now()-ue,attempts:1});let de=await le(i,B,$.mutations,p,o);return{plan:z,snapshot:Q,partitions:$.partitions,partitionGroups:$.partitionGroups,session:L.session,usage:L.usage,phases:m,source:{type:o.type===`branch`?`branch`:`workdir`,hunks:v,commitMessages:_.commitMessages,branchName:_.branchName,baseBranch:_.baseBranch},diffStats:b,commitShas:$.commitShas,undoId:de,feedbackTargetCallId:ne(m),hunksFilteredCount:y,hunksRedactedCount:P,hunksCondensedCount:L.hunksCondensedCount,appliedCommitIds:U.map(e=>e.id),unappliedHunkIndices:W.length>0?W.map(e=>e.index):void 0,stashConflict:$.stashConflict,stashNotRestored:$.stashNotRestored}}function ee(e,t){switch(e){case`quick`:return B(t);case`balanced`:return V(t);case`deep`:return U(t)}}async function B(e){return H(e,{lightweight:!0,skipOrder:!0})}async function V(e){return H(e,{includeAnalysis:!0})}async function H(e,t){let{hunks:n,collection:r,conventions:i,existingBranchNames:a,groupMode:o,target:s,model:c,instructions:l,context:u,strategy:d,maxRetries:f,maxTokens:h,onProgress:g,cancellation:v,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C}=e,w=m(),T=[],E=await _({hunks:n,commitMessages:r.commitMessages,commitOrder:r.commitOrder,branchName:r.branchName,baseBranch:r.baseBranch,issueUrl:u?.issueUrl,pullRequestUrl:u?.pullRequestUrl,authoring:u,analysis:e.analysis,hunkSummaries:e.hunkSummaries,instructions:l,conventions:i,mode:o,strategy:d?.grouping,existingBranchNames:o===`branches`?a:void 0,includeAnalysis:t.includeAnalysis&&e.analysis==null,lightweight:t.lightweight,condensed:t.condensed||e.condenseHunks,redactHunkContent:e.redactHunkContent,inputTokenBudget:e.inputTokenBudget,model:c,session:e.session,maxRetries:f,maxTokens:h,onProgress:e=>g?.({phase:`compose-grouping`,...R[`compose-grouping`],event:e}),cancellation:v,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.[`compose-group`]});E.usage&&p(w,E.usage),T.push({name:`compose-grouping`,durationMs:E.durationMs,attempts:E.attempts,usage:E.usage,callIds:E.callIds,repairs:E.repairs});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 y({branches:E.result.branches,hunks:n,instructions:l,model:c,session:E.session,maxRetries:f,maxTokens:h,onProgress:e=>g?.({phase:`ordering`,...R.ordering,event:e}),cancellation:v,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.order});t.usage&&p(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(s.type===`auto`){let t=new Set(a);for(let e of E.result.branches)e.name&&t.add(e.name);for(let n of O){let r=await b({commits:Q(n.branchGroup,n.orderedCommitIds),orderedCommitIds:n.orderedCommitIds,sourceBranchName:n.branchGroup.name,instructions:l,conventions:i,strategy:d?.partition,existingBranchNames:t,model:c,maxRetries:f,maxTokens:h,onProgress:e=>g?.({phase:`partitioning`,...R.partitioning,branch:n.branchGroup.name,event:e}),cancellation:v,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.partition});r.usage&&p(w,r.usage),T.push({name:`partitioning:${n.branchGroup.name}`,durationMs:r.durationMs,attempts:r.attempts,usage:r.usage,callIds:r.callIds}),n.partitioning=r.result,D=r.session;for(let e of r.result.partitions)t.add(e.branchName)}}let A=O.flatMap(e=>Q(e.branchGroup,e.orderedCommitIds));return{analysis:e.analysis,grouping:E.result,ordering:k,branchPlans:O,allOrderedCommits:A,session:D,usage:w,phases:T,hunksCondensedCount:E.hunksCondensedCount}}async function U(e){let{hunks:t,collection:n,conventions:r,existingBranchNames:i,groupMode:a,target:o,model:s,instructions:c,context:l,strategy:u,maxRetries:d,maxTokens:f,onProgress:h,cancellation:_,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C}=e,w=m(),T=[],E=e.analysis,D,O,k;if(E!=null)D=E,O=void 0,T.push({name:`analyzing`,durationMs:0,attempts:0,callIds:[]});else{let r=await g({hunks:t,commitMessages:n.commitMessages,commitOrder:n.commitOrder,branchName:n.branchName,baseBranch:n.baseBranch,issueUrl:l?.issueUrl,pullRequestUrl:l?.pullRequestUrl,authoring:l,hunkSummaries:e.hunkSummaries,instructions:c,condensed:e.condenseHunks,redactHunkContent:e.redactHunkContent,inputTokenBudget:e.inputTokenBudget,model:s,session:e.session,maxRetries:d,maxTokens:f,onProgress:e=>h?.({phase:`analyzing`,...R.analyzing,event:e}),cancellation:_,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.analyze});r.usage&&p(w,r.usage),T.push({name:`analyzing`,durationMs:r.durationMs,attempts:r.attempts,usage:r.usage,callIds:r.callIds}),D=r.result,O=r.session,k=r.hunksCondensedCount}let A=await v({hunks:t,commitMessages:n.commitMessages,instructions:c,authoring:l,analysis:E,condensed:e.condenseHunks,hunkSummaries:e.hunkSummaries,conventions:r,mode:a,strategy:u?.grouping,existingBranchNames:a===`branches`?i:void 0,redactHunkContent:e.redactHunkContent,inputTokenBudget:e.inputTokenBudget,model:s,session:O,maxRetries:d,maxTokens:f,onProgress:e=>h?.({phase:`grouping`,...R.grouping,event:e}),cancellation:_,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.group});A.usage&&p(w,A.usage),T.push({name:`grouping`,durationMs:A.durationMs,attempts:A.attempts,usage:A.usage,callIds:A.callIds,repairs:A.repairs});let j=await y({branches:A.result.branches,hunks:t,instructions:c,model:s,session:A.session,maxRetries:d,maxTokens:f,onProgress:e=>h?.({phase:`ordering`,...R.ordering,event:e}),cancellation:_,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.order});j.usage&&p(w,j.usage),T.push({name:`ordering`,durationMs:j.durationMs,attempts:j.attempts,usage:j.usage,callIds:j.callIds});let M=A.result.branches.map(e=>({branchGroup:e,orderedCommitIds:j.result.branches.find(t=>t.branchId===e.id)?.orderedCommitIds??e.commits.map(e=>e.id)})),N=j.session;if(o.type===`auto`){let t=new Set(i);for(let e of A.result.branches)e.name&&t.add(e.name);for(let n of M){let i=await b({commits:Q(n.branchGroup,n.orderedCommitIds),orderedCommitIds:n.orderedCommitIds,sourceBranchName:n.branchGroup.name,instructions:c,conventions:r,strategy:u?.partition,existingBranchNames:t,model:s,maxRetries:d,maxTokens:f,onProgress:e=>h?.({phase:`partitioning`,...R.partitioning,branch:n.branchGroup.name,event:e}),cancellation:_,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.partition});i.usage&&p(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,N=i.session;for(let e of i.result.partitions)t.add(e.branchName)}}let P=M.flatMap(e=>Q(e.branchGroup,e.orderedCommitIds));return{analysis:D,grouping:A.result,ordering:j.result,branchPlans:M,allOrderedCommits:P,session:N,usage:w,phases:T,hunksCondensedCount:k??A.hunksCondensedCount}}async function W(e){let t=await z({...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,hunksRedactedCount:t.hunksRedactedCount,hunksCondensedCount:t.hunksCondensedCount,diffStats:t.diffStats}}async function G(e){let t=await z({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,overrideSafetyFailures:e.overrideSafetyFailures});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,stashNotRestored:t.stashNotRestored}}async function te(n){let{git:r,applyPlan:i,onProgress:a}=n,{plan:o,snapshot:s,source:c}=i,l=n.source??c,u=n.target??i?.target??await Y(r,l);Z(l,u),await ie(r,n.applyCommitIds,n.hunkFilter,l,u),await f({git:r,source:l,target:u,overrides:n.overrideSafetyFailures});let d=await I(O(r,`capturing pre-operation state`)),p=[];a?.({phase:`collecting`,...R.collecting});let h=Date.now(),g=await C(r,l);if(p.push({name:`collecting`,durationMs:Date.now()-h,attempts:1}),g.hunks.length===0)throw new e(`NO_CHANGES`,X(l));let{hunks:_}=await q(g.hunks,n.hunkFilter);if(_.length===0)throw new e(`NO_CHANGES`,X(l));let v=K(_);a?.({phase:`collecting`,...R.collecting,stats:v});let y=_.length<g.hunks.length?await J(r,g.snapshot,_):g.snapshot,b={...g,hunks:_,snapshot:y};if(b.snapshot.diffHash!==s.diffHash)throw new t(`SAFETY_CHECK_FAILED`,`Working directory has changed since the plan was created. Re-run compose to generate a new plan.`);a?.({phase:`verifying`,...R.verifying});let x=Date.now(),S=await E(r,b.snapshot,re(l,b));if(!S.valid)throw new t(`SAFETY_CHECK_FAILED`,`Safety check failed: ${S.error}`);p.push({name:`verifying`,durationMs:Date.now()-x,attempts:1});let w=ae(o.allOrderedCommits,n.applyCommitIds),{leftoverHunks:T,leftoverPatch:k}=se(w,g.hunks),A=T.length>0?await J(r,b.snapshot,oe(w,b.hunks)):b.snapshot;a?.({phase:`applying`,...R.applying});let j=Date.now(),M=await D({git:r,source:l,target:u,branchPlans:o.branches,allOrderedCommits:w,hunks:b.hunks,snapshot:A,collection:b,signing:n.signing,authorAttribution:n.authorAttribution,stashLabel:n.stashLabel,leftoverPatch:k});p.push({name:`applying`,durationMs:Date.now()-j,attempts:1});let N=await le(r,d,M.mutations,u,l);return{plan:o,snapshot:A,partitions:M.partitions,partitionGroups:M.partitionGroups,session:{messages:[],systemPrompt:``,completedSteps:{}},usage:m(),phases:p,source:{type:l.type===`branch`?`branch`:`workdir`,hunks:b.hunks,commitMessages:b.commitMessages,branchName:b.branchName,baseBranch:b.baseBranch},diffStats:v,commitShas:M.commitShas,undoId:N,appliedCommitIds:w.map(e=>e.id),unappliedHunkIndices:T.length>0?T.map(e=>e.index):void 0,stashConflict:M.stashConflict,stashNotRestored:M.stashNotRestored}}function K(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 ne(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 q(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 J(e,t,n){let i=t.baseSha??t.headSha,o=P(N(),`compose-filtered-idx-${j()}`),s={GIT_INDEX_FILE:o};try{await l(e,i,{indexEnv:s});let o=T(n);o.length>0&&await r(e,o,{indexEnv:s});let c=await a(e,i,await d(e,{indexEnv:s}));return{...t,diffHash:S(c)}}finally{try{M(o)}catch{}}}function re(e,t){return e.type===`workdir`?{includeUnstaged:!e.stagedOnly,includeUntracked:t.untrackedIncluded}:e.type===`commit-range`&&e.includeWorkdir?w(e.includeWorkdir):{includeUnstaged:!1,includeUntracked:!1}}async function ie(e,t,r,a,o){if(r==null&&t==null||a.type===`workdir`||!(o.type===`rewrite-range`||(o.type===`branch`||o.type===`auto`)&&o.preserveOriginal!==!0))return;let s=a.type===`branch`?await u(e,a.name):await u(e,a.to),c=await u(e,`HEAD`);if(await i(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(c!==s)throw new n(`Filtering and partial apply require HEAD to be at the source's tip (${s.slice(0,8)}); HEAD is at ${c.slice(0,8)}.`)}function ae(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 oe(e,t){let n=new Set(e.flatMap(e=>e.hunkIndices));return t.filter(e=>n.has(e.index))}function se(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=T(r);return{leftoverHunks:r,leftoverPatch:i.length>0?i:void 0}}async function Y(e,t){if(t.type===`branch`){let r=await u(e,t.name),i=(await s(e,{format:`%H`,range:`${await c(e,await u(e,t.mergeTarget??`main`),r)}..${r}`})).trim().split(`
1
+ import{GitError as e}from"../errors/git.js";import{ComposeWorkflowError as t,ComposeWorkflowInputError as n}from"../errors/workflow.js";import{applyPatchToIndex as r,currentBranch as i,diffTree as a,forEachRef as o,log as s,mergeBase as c,readTree as l,revParse as u,writeTree as d}from"../git/port-dispatch.js";import{validateRewriteRangeSafety as f}from"../git/safety-range.js";import{addUsage as p,emptyUsage as m}from"../ai/types.js";import{memoizeRedactHunkContent as h}from"../tasks/utils/redact.js";import{analyze as g}from"../tasks/analyze/analyze.js";import{composeGroup as _}from"../tasks/compose-group/compose-group.js";import{group as v}from"../tasks/group/group.js";import{order as y}from"../tasks/order/order.js";import{partition as b}from"../tasks/partition/partition.js";import{withDefaultNoiseRedaction as x}from"../tasks/utils/noise.js";import{sha256 as S}from"../git/utils/crypto.js";import{collectFromSource as C,resolveWorkdirLayers as w}from"../git/diff-collector.js";import{buildPatchForCommit as T}from"../git/utils/patch-builder.js";import{verifySnapshot as E}from"../git/safety.js";import{applyToTarget as D}from"../git/apply-target.js";import{requireExec as O}from"../git/require-exec.js";import{extractConventions as k}from"../git/utils/conventions.js";import{rethrowAsWorkflowError as A}from"../utils/cancellation.js";import{randomUUID as j}from"node:crypto";import{unlinkSync as M}from"node:fs";import{tmpdir as N}from"node:os";import{join as P}from"node:path";import{UndoStore as F,capturePreOperationState as I,generateUndoId as L}from"@gitkraken/shared-tools";const R={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 z(r){if(!r.applyPlan&&!r.model)throw new n(`Either 'model' or 'applyPlan' must be provided`);if(r.applyPlan)return te(r);if(r.analysis!=null&&r.session!=null)throw new n(`'analysis' cannot be combined with 'session'; provide one or the other`);let{git:i,model:a,source:o={type:`workdir`},onProgress:s,onBeforeApply:c,cancellation:l}=r,u=a,d=r.depth??`balanced`,p=r.target??await Y(i,o);Z(o,p),await ie(i,r.applyCommitIds,r.hunkFilter,o,p),await f({git:i,source:o,target:p,overrides:r.overrideSafetyFailures});let m=[];s?.({phase:`collecting`,...R.collecting});let g=Date.now(),_=await C(i,o);if(m.push({name:`collecting`,durationMs:Date.now()-g,attempts:1}),_.hunks.length===0)throw new e(`NO_CHANGES`,X(o));let{hunks:v,filteredCount:y}=await q(_.hunks,r.hunkFilter);if(v.length===0)throw new e(`NO_CHANGES`,X(o));let b=K(v);s?.({phase:`collecting`,...R.collecting,stats:b});let S=y!==void 0&&y>0?await J(i,_.snapshot,v):_.snapshot,w=r.conventions??await k({git:i,author:r.conventionAuthor}),T=await ce(i),j=p.type===`auto`?`branches`:`commits`,M=r.disableDefaultNoiseReduction?r.redactHunkContent:x(r.redactHunkContent),N=M?h(M):void 0,P=N?v.filter(e=>N(e)).length:void 0,F={hunks:v,collection:_,conventions:w,existingBranchNames:T,groupMode:j,target:p,model:u,instructions:r.instructions,context:r.context,analysis:r.analysis,condenseHunks:r.condenseHunks,hunkSummaries:r.hunkSummaries,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:N,inputTokenBudget:r.inputTokenBudget},L;try{L=await ee(d,F)}catch(e){A(e,l)}let z={analysis:L.analysis,grouping:L.grouping,ordering:L.ordering,branches:L.branchPlans,allOrderedCommits:L.allOrderedCommits};if(m.push(...L.phases),c&&!await c(z))return{plan:z,snapshot:S,session:L.session,usage:L.usage,phases:m,source:{type:o.type===`branch`?`branch`:`workdir`,hunks:v,commitMessages:_.commitMessages,branchName:_.branchName,baseBranch:_.baseBranch},diffStats:b,feedbackTargetCallId:ne(m),hunksFilteredCount:y,hunksRedactedCount:P,hunksCondensedCount:L.hunksCondensedCount};let B=await I(O(i,`capturing pre-operation state`));s?.({phase:`verifying`,...R.verifying});let V=Date.now(),H=await E(i,_.snapshot,re(o,_));if(!H.valid)throw new t(`SAFETY_CHECK_FAILED`,`Safety check failed: ${H.error}`);m.push({name:`verifying`,durationMs:Date.now()-V,attempts:1});let U=ae(L.allOrderedCommits,r.applyCommitIds),{leftoverHunks:W,leftoverPatch:G}=se(U,_.hunks),Q=W.length>0?await J(i,S,oe(U,v)):S;s?.({phase:`applying`,...R.applying});let ue=Date.now(),$=await D({git:i,source:o,target:p,branchPlans:L.branchPlans,allOrderedCommits:U,hunks:v,snapshot:Q,collection:_,signing:r.signing,authorAttribution:r.authorAttribution,stashLabel:r.stashLabel,leftoverPatch:G});m.push({name:`applying`,durationMs:Date.now()-ue,attempts:1});let de=await le(i,B,$.mutations,p,o);return{plan:z,snapshot:Q,partitions:$.partitions,partitionGroups:$.partitionGroups,session:L.session,usage:L.usage,phases:m,source:{type:o.type===`branch`?`branch`:`workdir`,hunks:v,commitMessages:_.commitMessages,branchName:_.branchName,baseBranch:_.baseBranch},diffStats:b,commitShas:$.commitShas,undoId:de,feedbackTargetCallId:ne(m),hunksFilteredCount:y,hunksRedactedCount:P,hunksCondensedCount:L.hunksCondensedCount,appliedCommitIds:U.map(e=>e.id),unappliedHunkIndices:W.length>0?W.map(e=>e.index):void 0,stashConflict:$.stashConflict,stashNotRestored:$.stashNotRestored}}function ee(e,t){switch(e){case`quick`:return B(t);case`balanced`:return V(t);case`deep`:return U(t)}}async function B(e){return H(e,{lightweight:!0,skipOrder:!0})}async function V(e){return H(e,{includeAnalysis:!0})}async function H(e,t){let{hunks:n,collection:r,conventions:i,existingBranchNames:a,groupMode:o,target:s,model:c,instructions:l,context:u,strategy:d,maxRetries:f,maxTokens:h,onProgress:g,cancellation:v,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C}=e,w=m(),T=[],E=await _({hunks:n,commitMessages:r.commitMessages,commitOrder:r.commitOrder,branchName:r.branchName,baseBranch:r.baseBranch,issueUrl:u?.issueUrl,pullRequestUrl:u?.pullRequestUrl,authoring:u,analysis:e.analysis,hunkSummaries:e.hunkSummaries,instructions:l,conventions:i,mode:o,strategy:d?.grouping,existingBranchNames:o===`branches`?a:void 0,includeAnalysis:t.includeAnalysis&&e.analysis==null,lightweight:t.lightweight,condensed:t.condensed||e.condenseHunks,redactHunkContent:e.redactHunkContent,inputTokenBudget:e.inputTokenBudget,model:c,session:e.session,maxRetries:f,maxTokens:h,onProgress:e=>g?.({phase:`compose-grouping`,...R[`compose-grouping`],event:e}),cancellation:v,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.[`compose-group`]});E.usage&&p(w,E.usage),T.push({name:`compose-grouping`,durationMs:E.durationMs,attempts:E.attempts,usage:E.usage,callIds:E.callIds,repairs:E.repairs});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 y({branches:E.result.branches,hunks:n,instructions:l,model:c,session:E.session,maxRetries:f,maxTokens:h,onProgress:e=>g?.({phase:`ordering`,...R.ordering,event:e}),cancellation:v,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.order});t.usage&&p(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(s.type===`auto`){let t=new Set(a);for(let e of E.result.branches)e.name&&t.add(e.name);for(let n of O){let r=await b({commits:Q(n.branchGroup,n.orderedCommitIds),orderedCommitIds:n.orderedCommitIds,sourceBranchName:n.branchGroup.name,instructions:l,conventions:i,strategy:d?.partition,existingBranchNames:t,model:c,maxRetries:f,maxTokens:h,onProgress:e=>g?.({phase:`partitioning`,...R.partitioning,branch:n.branchGroup.name,event:e}),cancellation:v,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.partition});r.usage&&p(w,r.usage),T.push({name:`partitioning:${n.branchGroup.name}`,durationMs:r.durationMs,attempts:r.attempts,usage:r.usage,callIds:r.callIds}),n.partitioning=r.result,D=r.session;for(let e of r.result.partitions)t.add(e.branchName)}}let A=O.flatMap(e=>Q(e.branchGroup,e.orderedCommitIds));return{analysis:e.analysis,grouping:E.result,ordering:k,branchPlans:O,allOrderedCommits:A,session:D,usage:w,phases:T,hunksCondensedCount:E.hunksCondensedCount}}async function U(e){let{hunks:t,collection:n,conventions:r,existingBranchNames:i,groupMode:a,target:o,model:s,instructions:c,context:l,strategy:u,maxRetries:d,maxTokens:f,onProgress:h,cancellation:_,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C}=e,w=m(),T=[],E=e.analysis,D,O,k;if(E!=null)D=E,O=void 0,T.push({name:`analyzing`,durationMs:0,attempts:0,callIds:[]});else{let r=await g({hunks:t,commitMessages:n.commitMessages,commitOrder:n.commitOrder,branchName:n.branchName,baseBranch:n.baseBranch,issueUrl:l?.issueUrl,pullRequestUrl:l?.pullRequestUrl,authoring:l,hunkSummaries:e.hunkSummaries,instructions:c,condensed:e.condenseHunks,redactHunkContent:e.redactHunkContent,inputTokenBudget:e.inputTokenBudget,model:s,session:e.session,maxRetries:d,maxTokens:f,onProgress:e=>h?.({phase:`analyzing`,...R.analyzing,event:e}),cancellation:_,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.analyze});r.usage&&p(w,r.usage),T.push({name:`analyzing`,durationMs:r.durationMs,attempts:r.attempts,usage:r.usage,callIds:r.callIds}),D=r.result,O=r.session,k=r.hunksCondensedCount}let A=await v({hunks:t,commitMessages:n.commitMessages,instructions:c,authoring:l,analysis:E,condensed:e.condenseHunks,hunkSummaries:e.hunkSummaries,conventions:r,mode:a,strategy:u?.grouping,existingBranchNames:a===`branches`?i:void 0,redactHunkContent:e.redactHunkContent,inputTokenBudget:e.inputTokenBudget,model:s,session:O,maxRetries:d,maxTokens:f,onProgress:e=>h?.({phase:`grouping`,...R.grouping,event:e}),cancellation:_,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.group});A.usage&&p(w,A.usage),T.push({name:`grouping`,durationMs:A.durationMs,attempts:A.attempts,usage:A.usage,callIds:A.callIds,repairs:A.repairs});let j=await y({branches:A.result.branches,hunks:t,instructions:c,model:s,session:A.session,maxRetries:d,maxTokens:f,onProgress:e=>h?.({phase:`ordering`,...R.ordering,event:e}),cancellation:_,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.order});j.usage&&p(w,j.usage),T.push({name:`ordering`,durationMs:j.durationMs,attempts:j.attempts,usage:j.usage,callIds:j.callIds});let M=A.result.branches.map(e=>({branchGroup:e,orderedCommitIds:j.result.branches.find(t=>t.branchId===e.id)?.orderedCommitIds??e.commits.map(e=>e.id)})),N=j.session;if(o.type===`auto`){let t=new Set(i);for(let e of A.result.branches)e.name&&t.add(e.name);for(let n of M){let i=await b({commits:Q(n.branchGroup,n.orderedCommitIds),orderedCommitIds:n.orderedCommitIds,sourceBranchName:n.branchGroup.name,instructions:c,conventions:r,strategy:u?.partition,existingBranchNames:t,model:s,maxRetries:d,maxTokens:f,onProgress:e=>h?.({phase:`partitioning`,...R.partitioning,branch:n.branchGroup.name,event:e}),cancellation:_,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.partition});i.usage&&p(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,N=i.session;for(let e of i.result.partitions)t.add(e.branchName)}}let P=M.flatMap(e=>Q(e.branchGroup,e.orderedCommitIds));return{analysis:D,grouping:A.result,ordering:j.result,branchPlans:M,allOrderedCommits:P,session:N,usage:w,phases:T,hunksCondensedCount:k??A.hunksCondensedCount}}async function W(e){let t=await z({...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,hunksRedactedCount:t.hunksRedactedCount,hunksCondensedCount:t.hunksCondensedCount,diffStats:t.diffStats}}async function G(e){let t=await z({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,overrideSafetyFailures:e.overrideSafetyFailures});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,stashNotRestored:t.stashNotRestored}}async function te(n){let{git:r,applyPlan:i,onProgress:a}=n,{plan:o,snapshot:s,source:c}=i,l=n.source??c,u=n.target??i?.target??await Y(r,l);Z(l,u),await ie(r,n.applyCommitIds,n.hunkFilter,l,u),await f({git:r,source:l,target:u,overrides:n.overrideSafetyFailures});let d=await I(O(r,`capturing pre-operation state`)),p=[];a?.({phase:`collecting`,...R.collecting});let h=Date.now(),g=await C(r,l);if(p.push({name:`collecting`,durationMs:Date.now()-h,attempts:1}),g.hunks.length===0)throw new e(`NO_CHANGES`,X(l));let{hunks:_}=await q(g.hunks,n.hunkFilter);if(_.length===0)throw new e(`NO_CHANGES`,X(l));let v=K(_);a?.({phase:`collecting`,...R.collecting,stats:v});let y=_.length<g.hunks.length?await J(r,g.snapshot,_):g.snapshot,b={...g,hunks:_,snapshot:y};if(b.snapshot.diffHash!==s.diffHash)throw new t(`SAFETY_CHECK_FAILED`,`Working directory has changed since the plan was created. Re-run compose to generate a new plan.`);a?.({phase:`verifying`,...R.verifying});let x=Date.now(),S=await E(r,b.snapshot,re(l,b));if(!S.valid)throw new t(`SAFETY_CHECK_FAILED`,`Safety check failed: ${S.error}`);p.push({name:`verifying`,durationMs:Date.now()-x,attempts:1});let w=ae(o.allOrderedCommits,n.applyCommitIds),{leftoverHunks:T,leftoverPatch:k}=se(w,g.hunks),A=T.length>0?await J(r,b.snapshot,oe(w,b.hunks)):b.snapshot;a?.({phase:`applying`,...R.applying});let j=Date.now(),M=await D({git:r,source:l,target:u,branchPlans:o.branches,allOrderedCommits:w,hunks:b.hunks,snapshot:A,collection:b,signing:n.signing,authorAttribution:n.authorAttribution,stashLabel:n.stashLabel,leftoverPatch:k});p.push({name:`applying`,durationMs:Date.now()-j,attempts:1});let N=await le(r,d,M.mutations,u,l);return{plan:o,snapshot:A,partitions:M.partitions,partitionGroups:M.partitionGroups,session:{messages:[],systemPrompt:``,completedSteps:{}},usage:m(),phases:p,source:{type:l.type===`branch`?`branch`:`workdir`,hunks:b.hunks,commitMessages:b.commitMessages,branchName:b.branchName,baseBranch:b.baseBranch},diffStats:v,commitShas:M.commitShas,undoId:N,appliedCommitIds:w.map(e=>e.id),unappliedHunkIndices:T.length>0?T.map(e=>e.index):void 0,stashConflict:M.stashConflict,stashNotRestored:M.stashNotRestored}}function K(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 ne(e){for(let t=e.length-1;t>=0;t--){let{callIds:n,repairs:r}=e[t];if(!n||n.length===0)continue;let i=r?.some(e=>e.kind===`missing-hunks`)&&n.length>1;return n[n.length-(i?2:1)]}}async function q(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 J(e,t,n){let i=t.baseSha??t.headSha,o=P(N(),`compose-filtered-idx-${j()}`),s={GIT_INDEX_FILE:o};try{await l(e,i,{indexEnv:s});let o=T(n);o.length>0&&await r(e,o,{indexEnv:s});let c=await a(e,i,await d(e,{indexEnv:s}));return{...t,diffHash:S(c)}}finally{try{M(o)}catch{}}}function re(e,t){return e.type===`workdir`?{includeUnstaged:!e.stagedOnly,includeUntracked:t.untrackedIncluded}:e.type===`commit-range`&&e.includeWorkdir?w(e.includeWorkdir):{includeUnstaged:!1,includeUntracked:!1}}async function ie(e,t,r,a,o){if(r==null&&t==null||a.type===`workdir`||!(o.type===`rewrite-range`||(o.type===`branch`||o.type===`auto`)&&o.preserveOriginal!==!0))return;let s=a.type===`branch`?await u(e,a.name):await u(e,a.to),c=await u(e,`HEAD`);if(await i(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(c!==s)throw new n(`Filtering and partial apply require HEAD to be at the source's tip (${s.slice(0,8)}); HEAD is at ${c.slice(0,8)}.`)}function ae(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 oe(e,t){let n=new Set(e.flatMap(e=>e.hunkIndices));return t.filter(e=>n.has(e.index))}function se(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=T(r);return{leftoverHunks:r,leftoverPatch:i.length>0?i:void 0}}async function Y(e,t){if(t.type===`branch`){let r=await u(e,t.name),i=(await s(e,{format:`%H`,range:`${await c(e,await u(e,t.mergeTarget??`main`),r)}..${r}`})).trim().split(`
2
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 X(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 Z(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 Q(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 ce(e){let t=(await o(e,{format:`%(refname:short)`,prefix:`refs/heads/`})).trim().split(`
3
3
  `).filter(Boolean);return new Set(t)}async function le(e,t,n,r,i){let a=L(),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 u(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 F(O(e,`undo manifest persistence`)).save(l),a}export{G as applyComposePlan,q as applyHunkFilter,z as compose,W as composePlan,K as computeDiffStats,R as defaultPhaseMessages,Y as defaultTargetForSource,X as describeNoChangesForSource,J as rebuildFilteredSnapshot,Z as validateSourceTarget};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gitkraken/compose-tools",
3
- "version": "0.9.0",
3
+ "version": "0.10.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",
@@ -20,7 +20,7 @@
20
20
  "CHANGELOG.md"
21
21
  ],
22
22
  "dependencies": {
23
- "@gitkraken/shared-tools": "^0.2.0"
23
+ "@gitkraken/shared-tools": "^0.3.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/node": "^22.0.0",