@gitkraken/conflict-tools 0.2.4-beta.2 → 0.2.4-beta.20

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
@@ -9,8 +9,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
9
9
 
10
10
  ### Added
11
11
 
12
- - Reference-integrity detection. `resolveConflicts` now attaches a `StepResult.detection` report listing broken references (symbols that resolved in a pre-merge parent but no longer resolve in the merged result, with provenance), duplicate definitions, and syntax errors in the resolved files. Detection is opt-in via a new `ParserPort` (`ResolverDeps.parser`) that supplies parsing/symbol resolution per surface (tree-sitter, language server); when it is absent, unavailable, or a file's language is unsupported, the report says so rather than implying a clean result. Report-only in this release — no automatic fixing.
13
- - Auto-merged scope for reference-integrity detection: the detector discovers the merge's incoming/auto-merged set itself (new optional `changedFiles` git-port op; `git diff --name-only base..theirs`, base from `refs.base` or `mergeBase`) and includes it in scope, so dangling references in cleanly-merged files are caught. Scope is capped and reported via `DetectionReport.scope`; an optional `ResolutionContext.touchedFiles` overrides discovery. Adds a `dropped-side` finding when a whole-side take (take-ours/take-theirs/fallback) discards a substantial diff from the other side.
12
+ - `ResolverConfig.maxModelCalls` — an optional global model-call budget for the whole operation. When set and the budget is spent, the opt-in fix phase stops attempting further fixes and leaves the remainder in the findings (so the verdict still reflects the true post-fix state), emitting a new `fix:budget-exhausted` progress event (`{ spent, limit }`). When unset, the fix phase is not budget-limited.
13
+ - `fix:failed` progress event (`{ filePath?, reason }`) — emitted when the opt-in fix phase throws a non-abort error, so a consumer can observe the graceful degradation rather than only seeing the checks flip to `errored`.
14
+ - Cross-file fixes in the fix phase (opt-in `ResolverConfig.fix`). After per-file convergence, a project-tier broken reference (an import/cross-file use whose target stopped resolving) is routed to the refine of its **use-site** file: the finding becomes a fix instruction, and the whole set is re-detected, up to `crossFileMaxIterations` rounds (default 2). Each cross-file fix is a single refine pass plus a mandatory per-file re-validation; the final whole-set detection also re-checks per-file for files a cross-file fix touched. **Limitation:** only use-site files that have an AI resolution are routed — a cross-file break landing on an auto-merged file (no resolution to carry the edit) is reported but not auto-fixed yet (needs a patches channel). The definition side, if broken, arrives as its own finding on its own file. Report-only (fix off) is unchanged.
15
+ - Per-file fix loop (opt-in via `ResolverConfig.fix`, default `false`). When enabled, an AI-resolved (`strategy: 'ai'`) text file that carries error-severity per-file findings is routed back through refine: the findings become fix instructions, the file is re-analyzed after each edit, and the loop repeats until the file is clean, a round makes no progress, or `fixLoopMaxIterations` (default 3) is reached. Whatever it cannot fix stays in the findings, so the verdict reflects the post-fix state. The no-markers invariant holds (a fix that reintroduces markers is discarded). New progress events `fix:started` / `fix:completed`. Report-only (fix off) is unchanged. Deferred for the opt-in fix path: rebase per-step-ref/transient discipline, parser/validator call caching, cross-file fixes, and a full finding-lifecycle model (double-counting is handled here by fingerprint dedup when folding findings into the report).
16
+ - `collectFileFindings` — a per-file detection unit (structural + intra-file broken references + consumer validator issues, all `tier: 'file'`) over already-loaded parent snapshots; the fix loop re-runs it after each edit. `detect()` remains the authoritative whole-set report.
17
+ - Reference-integrity detection. `resolveConflicts` now attaches a `StepResult.detection` report listing broken references (symbols that resolved in a pre-merge parent but no longer resolve in the merged result, with provenance), duplicate definitions, and syntax errors in the resolved files. Detection is opt-in via a new `ParserPort` (`ResolverDeps.parser`) that supplies parsing/symbol resolution per surface (tree-sitter, language server); when it is absent, unavailable, or a file's language is unsupported, the report says so rather than implying a clean result. Detection itself is report-only; fixing is opt-in via the fix phase (above) and off by default.
18
+ - Auto-merged scope for reference-integrity detection: the detector discovers the merge's incoming/auto-merged set itself (new optional `changedFiles` git-port op; `git diff --name-only base..theirs`, base from `refs.base` or `mergeBase`) and includes it in scope, so dangling references in cleanly-merged files are caught. Scope is capped and reported via `DetectionReport.scope`; an optional `ResolutionContext.touchedFiles` overrides discovery. Adds a `dropped-side` finding when the resolver's emergency fallback took one side wholesale and discarded a substantial diff from the other; a deliberate rule/default take is not flagged.
19
+ - `ResolutionContext.detectionRefs` — optional parent tips for reference-integrity detection's before/after comparison, separate from `refs` (which drives the resolver's three-way diff). Detection uses `detectionRefs ?? refs`. Needed when the resolver's refs are the in-flight per-step state (e.g. a rebase) while detection needs the two stable pre-merge branch tips; in a merge they coincide and it can be left unset.
20
+ - Binding-aware broken-reference detection. `ParsedFile` gains an optional `bindings` (names bound at any scope, module-level and nested), and reference-integrity now treats a name as "resolvable before the merge" if a parent resolved it as a reference **or** defined/bound it. This catches a reference broken by removing a nested binding (e.g. a `const` used by a closure elsewhere in the same file): the dangling use was never a resolved reference — because it was locally bound — so the before/after reference diff alone could not see the regression. Backends that only surface top-level `definitions` can omit `bindings`; they simply do not gain the nested-binding case.
21
+ - Operation-level assessment: `buildOperationDigest`/`summarizeOperation` now fold `StepResult.detection` into the rollup. A file with any finding is added to `needsReview` (`reason: 'finding'`) regardless of confidence — even a file with no resolution of its own; an error finding (broken-reference, syntax-error, dropped-side) drives `verdict: 'blocked'`, while a warning finding (duplicate-definition, consumer-validator warnings) drives `needs-review`; `OperationDigest.detectionComplete` reports whether every detection check ran (so empty findings on a skipped check are not read as clean); and a derived `OperationDigest.verdict` (`'clean' | 'needs-review' | 'blocked'`) gives consumers an honest gate signal. `ReviewItem` gains `reason` and optional `findingCategories`, and its `confidence` is now optional.
22
+
23
+ ### Changed
24
+
25
+ - Reference-integrity findings are now tiered by locality: an intra-file break — a local definition/binding the merge dropped while a use of it remains in the same file (e.g. a `const` used by a closure below) — is a `tier: 'file'` finding; a broken cross-file reference (an import whose target stopped resolving) stays `tier: 'project'`. Previously every `broken-reference` was `'project'`. Classification uses the reference's `target` file when the parser supplies it, else whether the name was a local definition/binding in the parent. This sets up the per-file vs cross-file fix loops.
26
+ - **Breaking**: consumer validation moved to a `Validator` port — `validate(sources: SourceOverlay): Promise<ValidatorFinding[]>` (replaces the per-file `ResolutionVerifier.verify`). It runs post-refine, non-blocking, over an overlay (one file for per-file validation, or the touched set for cross-file); its findings fold into the operation's detection findings and verdict. `Resolution.findings` replaces `Resolution.issues`. `Finding.category` is now an open `string` so a consumer validator can contribute its own categories — the package still emits the well-known `FindingCategory` values. `defaultVerifier` / `VerificationResult` are no longer exported (`checkResidualMarkers` stays internal, the built-in marker gate).
27
+ - **Breaking**: the resolver's in-loop blocking check is now **residual conflict markers only** (built-in, language-agnostic). Consumer validation (the `Validator` port, above) no longer gates the resolve loop or triggers re-resolution — it runs once **after** refine, **non-blocking**, and its findings are folded into the report (advisory). This stops the resolver from burning its step budget on non-marker validation (which was exhausting into a whole-file `take-theirs` fallback), and puts consumer validation in its intended post-refine, report-only role. `ResolverLoopResult` no longer carries `issues`.
28
+ - The per-file fix loop now emits `fix:completed` (iterations 0, remaining 0) even when a file has no findings to fix, so a consumer can distinguish "the fix loop evaluated this file and it was clean" from "the fix loop never reached this file". Previously a clean file returned silently, leaving the two cases indistinguishable in the event stream.
29
+
30
+ ### Fixed
31
+
32
+ - The opt-in fix phase is now fault-isolated. A parser, validator, or model throw inside the fix phase no longer rejects the whole operation: the resolutions (possibly partially fixed) are kept, both detection checks are reported as `errored` (so the verdict is `needs-review`, never a false `clean`), and the throw is surfaced via `fix:failed`. A user abort still propagates and cancels the operation.
33
+ - `OperationDigest.verdict` can now reach `clean` on a real repository. `detectionComplete` treated `partial-unsupported-language` as "did not run", so any operation whose scope contained a non-code file (package.json, README, lockfiles — always present in a real merge) forced `verdict: 'needs-review'` even with zero findings and full confidence. Since `--fix` gates auto-apply on `verdict === 'clean'`, this made auto-apply effectively unreachable. A check now counts as complete when it ran, including when some scope files were an unsupported language (they carry no references to break); only a check that could not run at all — no parser, no refs, an unreadable parent snapshot, or a thrown parser — leaves detection incomplete.
34
+ - A non-abort error in the refine pass no longer destroys the pre-refine resolution. Previously the refine `catch` rethrew, bubbling to `resolveOne` and degrading a good resolve-stage result into a take-side fallback (confidence 0) — the same class of failure as a throwing validator, one stage later. The pre-refine resolution is now kept, its confidence capped (the enhancement did not complete), and the error reported via `refine:failed`. A user abort still cancels (rethrown past the fallback).
35
+ - A throwing consumer validator no longer destroys a good resolution. The post-refine `validator.validate()` call is now guarded: a validator error is reported via a new `validation:failed` progress event and the resolution is kept intact, instead of bubbling to `resolveOne` and degrading a successful AI resolve into a whole-file take-side fallback (confidence 0) — the exact dropped-side loss the tool guards against. The validator stays advisory.
36
+ - A user abort mid-resolve now cancels the operation instead of degrading into a take-side fallback. `resolveOne` rethrows when `signal.aborted` (covering aborts raised from the resolve loop, refine, or the validator), bypassing the emergency fallback rather than turning a cancellation into a whole-file take-one-side resolution.
37
+ - The refine pass no longer floods its bounded step budget when the model spirals. After a run of consecutive `edit_file_lines` apply errors (a model repeatedly proposing ops that never apply), the pass bails early — keeping whatever edits landed — and emits a new `refine:bailed` progress event, instead of executing failing tool calls until the step budget is exhausted.
38
+ - Reference-integrity detection no longer reads as "clean" when the parent snapshots could not be loaded. A git port that returns empty content for an unreadable parent (e.g. a `showFile` that swallows read errors into `""` instead of throwing) previously produced empty findings with `referenceIntegrity: 'ran'`, masking a total read failure as a clean result. `buildSnapshots` now ignores empty parent content; `DetectionReport.scope` reports `readable` (parents actually loaded) alongside `analyzed` (files in scope); and `referenceIntegrity` reports a new `'skipped-unreadable-source'` status when nothing in scope could be read — which flips `OperationDigest.detectionComplete` to `false` and the verdict to `needs-review` instead of `clean`.
14
39
 
15
40
  ## [0.2.3] - 2026-07-06
16
41
 
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @gitkraken/conflict-tools
2
2
 
3
- AI-powered git conflict resolution. Parses conflict markers (including delete-modify conflicts), resolves them with an agentic AI loop that has access to git context (blame, grep, diff, log, show, file content), and writes results back to the working tree.
3
+ AI-powered git conflict resolution. Parses conflict markers (including delete-modify conflicts), resolves them with an agentic AI loop that has access to git context (blame, grep, diff, log, show, file content), refines the result with targeted intra-file edits, then runs deterministic reference-integrity detection over the whole touched set and produces an honest verdict. Results are written back to the working tree by the consumer.
4
4
 
5
5
  ## What it does
6
6
 
@@ -8,9 +8,9 @@ Three entry points, each serving a different consumer need:
8
8
 
9
9
  - `extractConflict()` — parses conflict markers from a single file and returns a structured representation. Handles both text conflicts (with markers) and delete-modify conflicts (one side deleted, the other modified). No AI, no resolution.
10
10
 
11
- - `resolveConflict()` — takes one `Conflict` and an optional `ResolutionContext`, runs the AI tool-use loop, returns a `Resolution` with resolved content, confidence, and metrics. The single-file workhorse.
11
+ - `resolveConflict()` — takes one `Conflict` and an optional `ResolutionContext`, runs the AI tool-use loop and per-file refine, returns a `Resolution` with resolved content, confidence, and metrics. The single-file workhorse.
12
12
 
13
- - `resolveConflicts() + applyResolutions()` — batch-resolve every currently unmerged file in the working tree, then write and stage results. Supports pattern-based routing: skip files, apply take-ours/take-theirs, or route to AI per glob pattern. The library does NOT orchestrate `git rebase`/`merge`/`cherry-pick` — the consumer drives the operation lifecycle.
13
+ - `resolveConflicts() + applyResolutions()` — batch-resolve every currently unmerged file in the working tree, run set-level detection, then write and stage results. Supports pattern-based routing: skip files, apply take-ours/take-theirs, or route to AI per glob pattern. The library does NOT orchestrate `git rebase`/`merge`/`cherry-pick` — the consumer drives the operation lifecycle.
14
14
 
15
15
  The library never calls `git` or an AI provider directly. Consumers supply adapters via ports.
16
16
 
@@ -20,6 +20,16 @@ The library never calls `git` or an AI provider directly. Consumers supply adapt
20
20
  pnpm add @gitkraken/conflict-tools
21
21
  ```
22
22
 
23
+ ## Pipeline
24
+
25
+ An operation runs in three stages. **Report-only is the default**, and the stage shape is identical whether or not the opt-in fix phase is enabled — the fix phase only adds edit attempts, it does not change the flow.
26
+
27
+ 1. **Resolve** — the agentic AI tool-use loop replaces conflict markers, self-assessing a confidence per marker. The *only* blocking gate is residual conflict markers (checked built-in, language-agnostic): a candidate that still contains markers is rejected and retried. No other check gates this stage — parser, validator, and detection are all downstream and advisory.
28
+ 2. **Refine** (per file) — when the resolver returns `followUpInstructions` (targeted edits it could not express as a whole-chunk decision), a second agentic loop applies them via the `edit_file_lines` tool. A refine that reintroduces markers is discarded; a refined file's confidence is capped at 0.9.
29
+ 3. **Detection** (set level) — after every file is resolved, deterministic reference-integrity detection runs over the whole touched set: per-file structural checks (syntax errors, duplicate definitions, intra-file broken references) on the resolved *and* the auto-merged/incoming files, plus cross-file broken-reference checks. Report-only — it never mutates state or fails the operation. It produces the `DetectionReport` attached to `StepResult.detection`, which drives the verdict.
30
+
31
+ The opt-in **fix phase** (`config.fix: true`) sits between refine and the final detection report: it re-detects, routes the error-severity findings back through refine (per-file convergence, then cross-file routing), and only then emits the final report. What it cannot fix stays in the findings, so the verdict always reflects the post-fix state. See [Fix phase](#fix-phase-opt-in).
32
+
23
33
  ## Ports
24
34
 
25
35
  ### `ConflictGitPort`
@@ -42,6 +52,7 @@ High-level ops the library dispatches through:
42
52
  | `showFile(ref, path, opts?)` | Read file content at a specific ref. Supports `startLine`/`endLine` |
43
53
  | `unmergedEntries()` | List files git considers unresolved |
44
54
  | `mergeBase(a, b)` | Common ancestor between two refs |
55
+ | `changedFiles(from, to)` | Paths changed between two refs (name-only). Used to discover the merge's incoming/auto-merged set for detection |
45
56
  | `blame(path, opts?)` | Line-level authorship. Supports `startLine`/`endLine` (maps to `git blame -L`) |
46
57
  | `grep(pattern, opts?)` | Content search across the tree. Supports `maxResults` |
47
58
  | `diff(from, to, opts?)` | Unified diff between refs, optionally scoped to a path |
@@ -73,29 +84,64 @@ const model: ConflictModelPort = {
73
84
 
74
85
  The shape of `tools`, `toolCalls`, and `toolResults` follows abstract types (`ToolDefinition`, `ToolCall`, `ToolResult`) so consumers can adapt any SDK. The library does not require streaming or any provider-specific feature.
75
86
 
76
- ### `ResolutionVerifier` (optional)
87
+ ### `ParserPort` (optional — enables detection)
88
+
89
+ A **facts-in** port. Given a content overlay, it returns per-file parse facts — definitions, references (each with a `resolved` flag), optional bindings, and syntax errors. The *package* derives findings from those facts; the port itself never classifies anything as a conflict or a break.
90
+
91
+ ```typescript
92
+ import type { ParserPort, SourceOverlay, ParsedFile } from '@gitkraken/conflict-tools';
93
+
94
+ const parser: ParserPort = {
95
+ async analyze(sources: SourceOverlay): Promise<ReadonlyMap<string, ParsedFile>> {
96
+ // tree-sitter (merge-mate) or a language server (GitLens/GKD), overlaying `sources`.
97
+ // Return one ParsedFile per input path.
98
+ },
99
+ };
100
+ ```
101
+
102
+ Three properties define the contract:
103
+
104
+ - **Content-in overlay, no FS access.** `analyze(sources)` sees exactly the `SourceOverlay` contents (a `ReadonlyMap<path, content>`) and never reads the filesystem itself. This is what lets the package hand it pre-merge parent snapshots *and* the post-merge result and diff the two. A tree-sitter backend sees only these files; a language server may resolve against its workspace with these contents overlaid on top.
105
+ - **Language-agnostic.** A file the backend cannot parse comes back with `language: 'unsupported'` and empty facts; the detector skips it (and reports `partial-unsupported-language`) rather than emitting false findings.
106
+ - **Precision-abstracting.** A tree-sitter backend and a language-server backend differ in how completely they resolve references — the port hides that difference behind the same shape.
107
+
108
+ `ParsedFile`:
109
+
110
+ | Field | Meaning |
111
+ |---|---|
112
+ | `language` | Backend's language id; `'unsupported'` means "skip this file" |
113
+ | `definitions` | `SymbolDefinition[]` — top-level defs (`name`, `kind?`, 1-indexed `line`) |
114
+ | `references` | `SymbolReference[]` — `name`, `line`, `column?`, `resolved` (did it resolve within the overlay), `target?` |
115
+ | `bindings?` | Optional names bound at *any* scope (module-level and nested). Populating it lets detection catch a reference broken by removing a nested binding. A backend that only surfaces top-level `definitions` may omit it |
116
+ | `syntaxErrors` | `SyntaxIssue[]` — `line`, `message` |
117
+
118
+ Pass via `deps.parser`. When absent, detection is skipped and the report says so (`skipped-no-parser`) — never a silent clean.
77
119
 
78
- A pluggable post-resolution check. The library ships a `defaultVerifier` that scans the assembled content for residual conflict markers. During the AI loop, a failed verification triggers a retry with feedback — the model gets a chance to self-correct.
120
+ ### `Validator` (optional)
121
+
122
+ The consumer's validation port. Plug in any checks — syntax, real import resolution, types, lint, project rules. It runs **post-refine over an overlay** (one file for per-file validation, or the whole touched set for cross-file) and returns `ValidatorFinding[]`.
79
123
 
80
124
  ```typescript
81
- import { defaultVerifier } from '@gitkraken/conflict-tools';
82
- import type { ResolutionVerifier } from '@gitkraken/conflict-tools';
83
-
84
- // Use the built-in (checks for residual <<<<<<< / ======= / >>>>>>>)
85
- const verifier = defaultVerifier;
86
-
87
- // Or provide your own (e.g. run a linter, compile check)
88
- const custom: ResolutionVerifier = {
89
- verify: async (filePath, content) => {
90
- const errors = await lint(content);
91
- return errors.length === 0
92
- ? { valid: true }
93
- : { valid: false, feedback: errors.join('\n') };
125
+ import type { Validator, ValidatorFinding } from '@gitkraken/conflict-tools';
126
+
127
+ const validator: Validator = {
128
+ async validate(sources) {
129
+ const findings: ValidatorFinding[] = [];
130
+ for (const [file, content] of sources) {
131
+ for (const err of await typecheck(file, content)) {
132
+ findings.push({ category: 'type-error', severity: 'error', file, line: err.line, message: err.message });
133
+ }
134
+ }
135
+ return findings;
94
136
  },
95
137
  };
96
138
  ```
97
139
 
98
- Pass the verifier via `deps.verifier` to `resolveConflict` or `resolveConflicts`.
140
+ **Advisory, never blocking.** Its findings fold into the operation's detection findings and the verdict; a validator finding never triggers a re-resolve, and a *throwing* validator is caught (reported via `validation:failed`) and the resolution is kept intact. `ValidatorFinding.category` is an open string (e.g. `'type-error'`, `'lint:no-console'`) so consumer categories flow through alongside the package's own `FindingCategory` values.
141
+
142
+ The one blocking gate — **residual conflict markers** — is *not* this port's job. It is checked built-in and language-agnostic inside the resolve loop. (This was the old `defaultVerifier`; it is now internal. `defaultVerifier`, `ResolutionVerifier`, and `VerificationResult` are no longer exported.)
143
+
144
+ Pass via `deps.validator` to `resolveConflict` or `resolveConflicts`.
99
145
 
100
146
  ## Entry points
101
147
 
@@ -130,19 +176,25 @@ import { resolveConflict } from '@gitkraken/conflict-tools';
130
176
  const resolution = await resolveConflict(conflict, context, {
131
177
  model,
132
178
  git,
179
+ validator, // optional — advisory per-file checks
133
180
  config: { maxSteps: 15, temperature: 0 },
134
181
  onProgress: (event) => console.log(event),
135
182
  });
136
183
 
137
184
  // resolution.content — resolved file content (markers replaced)
138
185
  // resolution.strategy — 'ai' | 'take-ours' | 'take-theirs' | 'deleted' | 'skipped'
139
- // resolution.confidence — 0..1, from the AI's own self-assessment
186
+ // resolution.confidence — 0..1, from the AI's own self-assessment (capped at 0.9 if refined)
140
187
  // resolution.description — one-or-two sentence rationale, from the AI
188
+ // resolution.findings? — advisory per-file findings from the validator port (post-refine)
141
189
  // resolution.chunks? — per-marker decisions (for observability and validation)
190
+ // resolution.edits? — IntraFileEditOp[] applied during refine (absent if refine did not run)
142
191
  // resolution.metrics? — { inputTokens, outputTokens, stepCount, toolCallCount, ... }
192
+ // resolution.rawSignals? — policy-free measurements for the consumer's own assessment
143
193
  ```
144
194
 
145
- `ResolutionContext` carries optional hints the AI can use:
195
+ `resolveConflict` runs stages 1–2 (resolve + refine) for a single file. Set-level detection (stage 3) is only run by the batch entry point, since it needs the whole touched set; use `detect()` directly if you want detection for a single-file flow.
196
+
197
+ `ResolutionContext` carries optional hints the AI can use, plus the refs detection compares against:
146
198
 
147
199
  ```typescript
148
200
  interface ResolutionContext {
@@ -150,21 +202,25 @@ interface ResolutionContext {
150
202
  prDescription?: string;
151
203
  previousResolutions?: Resolution[];
152
204
  fileNeighbors?: string[];
205
+ /** Auto-merged files beyond the conflict set. Usually unset — detection discovers this from refs. */
206
+ touchedFiles?: string[];
153
207
  refs?: { ours: string; theirs: string; base?: string };
208
+ /** Parent tips for detection's before/after comparison, when they differ from `refs`. */
209
+ detectionRefs?: { ours: string; theirs: string; base?: string };
154
210
  threeWayDiff?: { oursDiff: string; theirsDiff: string };
211
+ /** Whole-file free-text guidance, rendered as a top-level <user-guidance> block. */
212
+ userGuidance?: string;
155
213
  metadata?: Record<string, unknown>;
156
214
  }
157
215
  ```
158
216
 
159
217
  When `refs` is provided, the library computes `git diff base..ours -- file` and `git diff base..theirs -- file` automatically and includes both in the prompt. This typically lets the AI resolve without any tool calls. Consumers that already have these diffs cached can pass `threeWayDiff` directly to skip the computation.
160
218
 
161
- ### Phase 2 refine pass
162
-
163
- After Phase 1 resolves conflict markers, the AI may return `followUpInstructions: string[]` on the resolution — a list of targeted edits it could not express as a whole-chunk decision (for example, re-applying a partial change from the incoming branch). When `followUpInstructions` is non-empty, `resolveConflict` automatically runs a second agentic loop (Phase 2) that applies those edits via the `edit_file_lines` tool.
219
+ `refs` drives the resolver's three-way diff; `detectionRefs` (falling back to `refs`) drives detection's before/after comparison. They differ in a rebase — where `refs` is the in-flight per-step state while detection needs the two stable pre-merge branch tips — and coincide in a plain merge, so `detectionRefs` is usually left unset.
164
220
 
165
- `ResolverConfig.refineMaxSteps?: number` controls how many model turns Phase 2 is allowed. Defaults to 5.
221
+ #### Refine edits
166
222
 
167
- `Resolution.edits?: IntraFileEditOp[]` carries the list of edit operations that were successfully applied during Phase 2. Absent when Phase 2 did not run or all edits were discarded.
223
+ The refine pass (stage 2) applies edits the resolver could not express as whole-chunk decisions — for example, re-applying a partial change from the incoming branch. `ResolverConfig.refineMaxSteps` (default 5) bounds its model turns. `Resolution.edits?: IntraFileEditOp[]` lists the operations that survived (absent when refine did not run or all edits were discarded).
168
224
 
169
225
  `IntraFileEditOp` is a discriminated union on `kind`:
170
226
 
@@ -176,12 +232,14 @@ After Phase 1 resolves conflict markers, the AI may return `followUpInstructions
176
232
  ### `resolveConflicts(deps, context?) + applyResolutions(resolutions, deps)` — batch
177
233
 
178
234
  ```typescript
179
- import { resolveConflicts, applyResolutions } from '@gitkraken/conflict-tools';
235
+ import { resolveConflicts, applyResolutions, summarizeOperation } from '@gitkraken/conflict-tools';
180
236
 
181
237
  const step = await resolveConflicts(
182
238
  {
183
239
  model,
184
240
  git,
241
+ parser, // optional — supply to enable set-level detection (stage 3)
242
+ validator, // optional — advisory per-file checks
185
243
  config: {
186
244
  rules: [
187
245
  { match: ['*.lock', 'pnpm-lock.yaml'], strategy: 'take-theirs' },
@@ -200,8 +258,13 @@ const step = await resolveConflicts(
200
258
  // step.resolutions[] — successful resolutions
201
259
  // step.errors[] — { filePath, error } for files that failed (no fallback or AI exhausted)
202
260
  // step.skipped?[] — { filePath, reason } for files that were excluded or had no markers
261
+ // step.detection? — DetectionReport over the whole touched set (present when a parser was supplied)
203
262
 
204
- await applyResolutions(step.resolutions, { git });
263
+ // Gate on the verdict, not on raw confidence — see below.
264
+ const { digest } = await summarizeOperation(step);
265
+ if (digest.verdict === 'clean') {
266
+ await applyResolutions(step.resolutions, { git });
267
+ }
205
268
  // Resolutions are written via writeFile, checkoutFile, or removeFile and staged.
206
269
  ```
207
270
 
@@ -221,7 +284,117 @@ Available strategies for rules and `defaultStrategy`:
221
284
  | `'deleted'` | Remove the file entirely |
222
285
  | `'skip'` | Skip the file (no resolution produced) |
223
286
 
224
- The function inspects the working tree once (via `unmergedEntries`), processes each conflict sequentially, and returns. It does not call `git rebase --continue` or any other operation command — that is the consumer's job.
287
+ The function inspects the working tree once (via `unmergedEntries`), processes each conflict sequentially, runs detection over the touched set, and returns. It does not call `git rebase --continue` or any other operation command — that is the consumer's job.
288
+
289
+ ## Detection & verdict
290
+
291
+ ### `detect(resolutions, context, deps)`
292
+
293
+ Deterministic, report-only post-resolution detection. It never mutates state or fails the operation. `resolveConflicts` calls it for you and attaches the result to `StepResult.detection`; call it directly for a single-file or custom flow.
294
+
295
+ ```typescript
296
+ import { detect } from '@gitkraken/conflict-tools';
297
+
298
+ const report = await detect(step.resolutions, { refs }, { git, parser });
299
+ ```
300
+
301
+ `DetectDeps` is `{ git; parser?; signal? }`. It flags:
302
+
303
+ - references that resolved in a pre-merge parent (ours/theirs) but no longer resolve in the merged result (`broken-reference`, with `provenance`),
304
+ - duplicate definitions and syntax errors in the resolved files (`duplicate-definition`, `syntax-error`),
305
+ - the resolver's emergency fallback taking one side wholesale and discarding a substantial diff (`dropped-side`).
306
+
307
+ The scope includes the resolved files *and* the merge's auto-merged/incoming set (discovered from `refs` via `changedFiles`/`mergeBase`, or overridden by `context.touchedFiles`), so a dangling reference in a cleanly-merged file is still caught.
308
+
309
+ `DetectionReport`:
310
+
311
+ ```typescript
312
+ interface DetectionReport {
313
+ findings: Finding[];
314
+ checks: { referenceIntegrity: CheckStatus; structural: CheckStatus };
315
+ /** Honest coverage — present only when the reference check ran. */
316
+ scope?: { analyzed: number; readable: number; truncated: boolean };
317
+ }
318
+ ```
319
+
320
+ `Finding`:
321
+
322
+ | Field | Meaning |
323
+ |---|---|
324
+ | `category` | Open string. The package emits `FindingCategory`; a consumer validator may add its own |
325
+ | `severity` | `'error'` \| `'warning'` |
326
+ | `tier` | `'file'` (intra-file) or `'project'` (cross-file) — the fix phase routes each tier differently |
327
+ | `location` | `{ file; line; symbol? }` |
328
+ | `provenance?` | For `broken-reference`: the pre-merge side (`'ours'`/`'theirs'`) that still resolved it, and where |
329
+ | `message` | Human-readable description |
330
+
331
+ `FindingCategory` (the well-known values the package itself emits):
332
+
333
+ | Category | Meaning |
334
+ |---|---|
335
+ | `broken-reference` | A symbol resolved in a pre-merge parent but no longer resolves in the merged result |
336
+ | `duplicate-definition` | The same symbol is defined twice in a resolved file (advisory `warning` — the detector cannot tell a genuine redefinition from legal overloads / declaration merging) |
337
+ | `syntax-error` | The parser reported a syntax error in a resolved file |
338
+ | `dropped-side` | The resolver's emergency fallback took one side wholesale, discarding a substantial diff (a deliberate rule/default take is not flagged) |
339
+
340
+ `CheckStatus` tells "ran, clean" apart from "did not run" — so an empty `findings` array is never mistaken for a clean result:
341
+
342
+ | Status | Meaning |
343
+ |---|---|
344
+ | `ran` | The check ran to completion |
345
+ | `skipped-no-parser` | No `ParserPort` supplied |
346
+ | `skipped-no-refs` | No refs, so the before/after comparison was impossible (structural checks still ran) |
347
+ | `skipped-unreadable-source` | Refs were present but the parent snapshots came back empty (unreadable source) |
348
+ | `partial-unsupported-language` | The check ran, but some files in scope were an unsupported language |
349
+ | `errored` | The parser was present but threw |
350
+
351
+ ### `summarizeOperation` / `buildOperationDigest`
352
+
353
+ `buildOperationDigest(result, reviewThreshold?)` is a pure, model-free rollup of a `StepResult` that folds in `detection`. `summarizeOperation(result, opts?)` returns `{ digest, prose?, usage? }` — always the digest, plus a prose narrative when `opts.model` is supplied.
354
+
355
+ ```typescript
356
+ import { buildOperationDigest } from '@gitkraken/conflict-tools';
357
+
358
+ const digest = buildOperationDigest(step);
359
+ // digest.verdict — 'clean' | 'needs-review' | 'blocked'
360
+ // digest.detectionComplete — did every detection check run to completion
361
+ // digest.findingCount — total detection findings
362
+ // digest.needsReview — ReviewItem[] (findings first, then lowest confidence first)
363
+ // digest.minConfidence — lowest file confidence, or null
364
+ // digest.byStrategy — resolution count per strategy
365
+ ```
366
+
367
+ `OperationVerdict`:
368
+
369
+ - `blocked` — at least one error-severity finding exists (the build is known-broken).
370
+ - `needs-review` — confidence is low, detection did not fully run, a resolution errored, or a warning finding exists.
371
+ - `clean` — none of the above.
372
+
373
+ **Gate auto-apply on the verdict, not on confidence.** A refined or fixed file has its confidence capped at 0.9 — so a "confidence must be 1.0" gate would reject exactly the files the tool improved. `verdict === 'clean'` is the honest signal. And an *absent* verdict must NOT be read as clean: if you never ran detection (no parser) or never summarized, `detectionComplete` is `false` and empty findings mean "we didn't look," not "nothing's wrong." Only `verdict === 'clean'` is clean.
374
+
375
+ A `ReviewItem` distinguishes a low-confidence flag from a finding: `reason: 'low-confidence' | 'finding'`, with `confidence?` (absent for a file flagged purely by a finding, e.g. an auto-merged file with no resolution of its own) and `findingCategories?` (present when `reason` is `'finding'`).
376
+
377
+ ## Fix phase (opt-in)
378
+
379
+ By default the pipeline is **report-only**: detection findings land in `StepResult.detection` and the verdict, and the consumer decides what to do. Set `config.fix: true` to have the library attempt to fix error-severity findings before emitting the final report. The pipeline shape is identical in both modes — the fix phase only inserts edit attempts between refine and the final detection.
380
+
381
+ When enabled:
382
+
383
+ - **Per-file convergence** — an AI-resolved (`strategy: 'ai'`) text file carrying error-severity per-file findings is routed back through refine; the file is re-analyzed after each edit and the loop repeats until it is clean, a round makes no progress, or `fixLoopMaxIterations` (default 3) is reached.
384
+ - **Cross-file routing** — a `tier: 'project'` broken reference (an import/cross-file use whose target stopped resolving) is routed to the refine of its *use-site* file, and the whole set is re-detected, up to `crossFileMaxIterations` (default 2) rounds. Only use-site files that have an AI resolution are routed; a break landing on an auto-merged file (no resolution to carry the edit) is reported but not auto-fixed.
385
+
386
+ Whatever the fix phase cannot fix stays in the findings, so the verdict always reflects the post-fix state. The no-markers invariant holds throughout: a fix that reintroduces conflict markers is discarded rather than written.
387
+
388
+ `ResolverConfig` fix-phase fields:
389
+
390
+ | Field | Default | Effect |
391
+ |---|---|---|
392
+ | `fix` | `false` | Opt in to the fix phase. Off → report-only |
393
+ | `fixLoopMaxIterations` | `3` | Max per-file fix iterations (refine → re-detect the file) |
394
+ | `crossFileMaxIterations` | `2` | Max cross-file fix rounds (route project-level breaks → re-detect the set) |
395
+ | `maxModelCalls` | unset | Global model-call budget for the whole operation. When set and the budget is spent, the fix phase stops attempting further fixes and leaves the remainder in the findings, emitting `fix:budget-exhausted`. When unset, the fix phase is not budget-limited |
396
+
397
+ The fix phase is **fault-isolated**: a parser, validator, or model throw inside it does not reject the whole operation. The resolutions computed so far (possibly partially fixed, since the fix phase mutates them as it goes) are kept, both detection checks are reported as `errored`, and a `fix:failed` event is emitted (a user abort still propagates and cancels the operation).
225
398
 
226
399
  ## Consumer examples
227
400
 
@@ -241,8 +414,9 @@ if (conflict) {
241
414
  ### GitLens / GKD — batch "resolve all" (user-driven)
242
415
 
243
416
  ```typescript
244
- const step = await resolveConflicts({ git, model, onProgress: webview.post });
245
- const approved = await reviewDialog(step.resolutions);
417
+ const step = await resolveConflicts({ git, model, parser, onProgress: webview.post }, { refs });
418
+ const { digest } = await summarizeOperation(step);
419
+ const approved = await reviewDialog(step.resolutions, step.detection, digest.verdict);
246
420
  await applyResolutions(approved, { git });
247
421
  // User clicks "continue rebase" themselves.
248
422
  ```
@@ -256,13 +430,18 @@ for (const commit of commits) {
256
430
  if (result.exitCode === 0) continue;
257
431
 
258
432
  const step = await resolveConflicts({
259
- git, model,
433
+ git, model, parser,
260
434
  config: {
261
435
  rules: [{ match: '*.lock', strategy: 'take-theirs' }],
262
436
  fallbackStrategy: 'take-ours',
437
+ fix: true, // attempt to fix detected breaks before reporting
263
438
  },
264
439
  onProgress: emit,
265
- });
440
+ }, { refs });
441
+
442
+ const { digest } = await summarizeOperation(step);
443
+ if (digest.verdict === 'blocked') { await escalateToHuman(step); break; }
444
+
266
445
  await applyResolutions(step.resolutions, { git });
267
446
  await git.exec(['rebase', '--continue']);
268
447
  }
@@ -282,27 +461,34 @@ score(resolution, goldenFile);
282
461
 
283
462
  | Event | When |
284
463
  |---|---|
285
- | `conflict:found` | A conflict file has been parsed |
464
+ | `conflict:found` | A conflict file has been parsed (`conflictType`, `markerCount`) |
286
465
  | `conflict:excluded` | A file was skipped by a rule with `strategy: 'skip'` |
287
- | `conflict:skipped` | A file had no conflict markers and no delete-modify reason |
288
- | `resolution:applied` | A resolution was produced for a file |
289
- | `resolution:fallback` | AI failed; fallback strategy applied |
290
- | `resolution:failed` | AI failed and no fallback configured |
291
- | `resolver:tool-call` | The AI invoked a tool. Includes `reason` (why the AI called it) |
292
- | `resolver:step-usage` | Token usage for one model call |
293
- | `resolver:completed` | The resolver finished a single file |
294
- | `resolver:response` | Phase 1 produced its final response |
295
- | `resolver:tool-result` | A Phase 1 tool returned content |
296
- | `refine:started` | Phase 2 has begun |
297
- | `refine:skipped` | Phase 2 was not run (no follow-ups) |
298
- | `refine:tool-call` | Phase 2 model invoked `edit_file_lines` |
299
- | `refine:tool-result` | Phase 2 tool returned feedback |
300
- | `refine:final-text` | Phase 2 model emitted final description |
301
- | `refine:completed` | Phase 2 finished successfully (includes `appliedCount`) |
302
- | `refine:failed` | Phase 2 threw before completing |
303
- | `edit:discarded` | An edit batch was rejected (introduced markers) |
304
-
305
- Note: `refine:tool-result.appliedCount` reports edits applied to an in-progress buffer per turn. The final disposition is signaled by `refine:completed` / `edit:discarded` / `refine:failed` — only edits that survive residual-marker validation appear in `Resolution.edits`.
466
+ | `conflict:skipped` | A file had no conflict markers and no delete-modify reason (`reason`, `entryReason?`) |
467
+ | `resolution:applied` | A resolution was produced for a file (`strategy`, `confidence?`, `description?`) |
468
+ | `resolution:fallback` | AI failed; fallback strategy applied (`error`, `cause?`) |
469
+ | `resolution:failed` | AI failed and no fallback configured (`error`) |
470
+ | `resolver:tool-call` | The AI invoked a tool during resolve (`tool`, `args`, `stepNumber`, `reason?`) |
471
+ | `resolver:step-usage` | Token usage for one resolve model call (`stepNumber`, `usage`) |
472
+ | `resolver:completed` | The resolver finished a single file (`stepCount`) |
473
+ | `resolver:response` | The resolve stage produced its final structured response (`chunks`, `confidence`, `description`, `strategy?`, `followUpInstructions?`) |
474
+ | `resolver:tool-result` | A resolve-stage tool returned content (`tool`, `stepNumber`, `content`) |
475
+ | `refine:started` | The refine pass has begun |
476
+ | `refine:skipped` | Refine was not run (`reason`, e.g. no follow-ups) |
477
+ | `refine:tool-call` | Refine model invoked `edit_file_lines` (`stepNumber`, `ops`) |
478
+ | `refine:tool-result` | A refine tool returned feedback (`stepNumber`, `appliedCount`, `isError`, `feedback`) |
479
+ | `refine:final-text` | Refine model emitted its final description (`text`) |
480
+ | `refine:completed` | Refine finished successfully (`appliedCount`) |
481
+ | `refine:failed` | Refine threw before completing (`reason`) |
482
+ | `refine:bailed` | Refine bailed early after consecutive apply errors, keeping edits that landed (`reason`) |
483
+ | `refine:discarded` | A refine/fix edit batch was rejected because it reintroduced conflict markers (`reason`) |
484
+ | `refine:touched-user-region` | Refine modified a user-resolved marker region (`markerIndexes`) |
485
+ | `fix:started` | The fix loop began fixing a file (`findingCount`) |
486
+ | `fix:completed` | The fix loop finished evaluating a file (`iterations`, `remaining`) — emitted even for a clean file (iterations 0, remaining 0) |
487
+ | `fix:failed` | The opt-in fix phase threw a non-abort error (`filePath?`, `reason`); the operation degrades gracefully — the resolutions (possibly partially fixed) are kept and both checks are reported as `errored` |
488
+ | `fix:budget-exhausted` | The global `maxModelCalls` budget was spent (`spent`, `limit`); the fix phase stopped and left the remainder in findings |
489
+ | `validation:failed` | The consumer validator threw; reported and swallowed, resolution kept (`reason`) |
490
+
491
+ Note: `refine:tool-result.appliedCount` reports edits applied to an in-progress buffer per turn. The final disposition is signaled by `refine:completed` / `refine:discarded` / `refine:failed` — only edits that survive residual-marker validation appear in `Resolution.edits`.
306
492
 
307
493
  Operation-level events (rebase started, step completed, etc.) are the consumer's responsibility — the library has no opinion about the surrounding workflow.
308
494
 
@@ -328,7 +514,7 @@ try {
328
514
  }
329
515
  ```
330
516
 
331
- In the batch entry point, errors that have a configured fallback do not throw — they are downgraded to `resolution:fallback` events, and the resulting resolutions appear in `step.resolutions`. Errors without a fallback land in `step.errors` and the loop continues with the next file.
517
+ In the batch entry point, errors that have a configured fallback do not throw — they are downgraded to `resolution:fallback` events, and the resulting resolutions appear in `step.resolutions`. Errors without a fallback land in `step.errors` and the loop continues with the next file. The advisory stages (refine, validator, detection, fix phase) are fault-isolated — a non-abort throw there is reported and swallowed rather than destroying a good resolution. A user abort (`signal.aborted`) always propagates and cancels the operation.
332
518
 
333
519
  ## Semver policy
334
520
 
package/dist/index.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { AIError, AIErrorCode } from "./errors/ai.js";
2
2
  import { ConflictError, ConflictErrorCode } from "./errors/conflict.js";
3
3
  import { BlameOptions, ConflictDiffOptions, ConflictGitOps, ConflictGitPort, ConflictGitPortMissingOpError, GrepOptions, LogOptions, OpOptions, ShowFileOptions, ShowOptions, UnmergedEntry, UnmergedReason } from "./ports/git.js";
4
+ import { CheckStatus, DetectionReport, Finding, FindingCategory, FindingTier } from "./types/findings.js";
4
5
  import { IntraFileEditOp } from "./types/hints.js";
5
- import { Issue } from "./types/issue.js";
6
6
  import { DiffStats, Resolution, ResolutionContext, ResolutionMetrics, ResolutionRawSignals, ResolutionRefs, ResolutionStrategy, ResolvedChunk, ThreeWayDiff } from "./types/resolution.js";
7
7
  import { Conflict, ConflictMarker, ConflictMarkerUserInput, ConflictType } from "./types/conflict.js";
8
8
  import { extractConflict } from "./extraction/extract.js";
@@ -10,8 +10,7 @@ import { checkoutFile, removeFile, show } from "./git/port-dispatch.js";
10
10
  import { ToolCall, ToolDefinition, ToolResult } from "./types/tool.js";
11
11
  import { ConflictModelMessage, ConflictModelParams, ConflictModelPort, ConflictModelResult, ResolverConfig } from "./ports/model.js";
12
12
  import { ParsedFile, ParserPort, SourceOverlay, SymbolDefinition, SymbolReference, SyntaxIssue } from "./ports/parser.js";
13
- import { ResolutionVerifier, VerificationResult, defaultVerifier } from "./ports/verify.js";
14
- import { CheckStatus, DetectionReport, Finding, FindingCategory, FindingTier } from "./types/findings.js";
13
+ import { Validator, ValidatorFinding } from "./ports/verify.js";
15
14
  import { DetectDeps, detect } from "./reference-integrity/detect.js";
16
15
  import { createResolution } from "./resolution/create-resolution.js";
17
16
  import { takeOurs, takeTheirs } from "./resolution/take-strategy.js";
@@ -20,6 +19,6 @@ import { ConflictProgressEvent } from "./types/events.js";
20
19
  import { resolveConflict } from "./resolver/resolve-conflict.js";
21
20
  import { FileRule, StepConfig, StepResult } from "./types/step.js";
22
21
  import { resolveConflicts } from "./resolver/resolve-conflicts.js";
23
- import { OperationDigest, OperationSummary, ReviewItem } from "./types/summary.js";
22
+ import { OperationDigest, OperationSummary, OperationVerdict, ReviewItem } from "./types/summary.js";
24
23
  import { SummarizeOperationOptions, buildOperationDigest, summarizeOperation } from "./resolver/summarize-operation.js";
25
- export { AIError, type AIErrorCode, type BlameOptions, type CheckStatus, type Conflict, type ConflictDiffOptions, ConflictError, type ConflictErrorCode, type ConflictGitOps, type ConflictGitPort, ConflictGitPortMissingOpError, type ConflictMarker, type ConflictMarkerUserInput, type ConflictModelMessage, type ConflictModelParams, type ConflictModelPort, type ConflictModelResult, type ConflictProgressEvent, type ConflictType, type DetectDeps, type DetectionReport, type DiffStats, type FileRule, type Finding, type FindingCategory, type FindingTier, type GrepOptions, type IntraFileEditOp, type Issue, type LogOptions, type OpOptions, type OperationDigest, type OperationSummary, type ParsedFile, type ParserPort, type Resolution, type ResolutionContext, type ResolutionMetrics, type ResolutionRawSignals, type ResolutionRefs, type ResolutionStrategy, type ResolutionVerifier, type ResolvedChunk, type ResolverConfig, type ReviewItem, type ShowFileOptions, type ShowOptions, type SourceOverlay, type StepConfig, type StepResult, type SummarizeOperationOptions, type SymbolDefinition, type SymbolReference, type SyntaxIssue, type ThreeWayDiff, type ToolCall, type ToolDefinition, type ToolResult, type UnmergedEntry, type UnmergedReason, type VerificationResult, applyResolutions, buildOperationDigest, checkoutFile, createResolution, defaultVerifier, detect, extractConflict, removeFile, resolveConflict, resolveConflicts, show, summarizeOperation, takeOurs, takeTheirs };
24
+ export { AIError, type AIErrorCode, type BlameOptions, type CheckStatus, type Conflict, type ConflictDiffOptions, ConflictError, type ConflictErrorCode, type ConflictGitOps, type ConflictGitPort, ConflictGitPortMissingOpError, type ConflictMarker, type ConflictMarkerUserInput, type ConflictModelMessage, type ConflictModelParams, type ConflictModelPort, type ConflictModelResult, type ConflictProgressEvent, type ConflictType, type DetectDeps, type DetectionReport, type DiffStats, type FileRule, type Finding, type FindingCategory, type FindingTier, type GrepOptions, type IntraFileEditOp, type LogOptions, type OpOptions, type OperationDigest, type OperationSummary, type OperationVerdict, type ParsedFile, type ParserPort, type Resolution, type ResolutionContext, type ResolutionMetrics, type ResolutionRawSignals, type ResolutionRefs, type ResolutionStrategy, type ResolvedChunk, type ResolverConfig, type ReviewItem, type ShowFileOptions, type ShowOptions, type SourceOverlay, type StepConfig, type StepResult, type SummarizeOperationOptions, type SymbolDefinition, type SymbolReference, type SyntaxIssue, type ThreeWayDiff, type ToolCall, type ToolDefinition, type ToolResult, type UnmergedEntry, type UnmergedReason, type Validator, type ValidatorFinding, applyResolutions, buildOperationDigest, checkoutFile, createResolution, detect, extractConflict, removeFile, resolveConflict, resolveConflicts, show, summarizeOperation, takeOurs, takeTheirs };
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- import{AIError as e}from"./errors/ai.js";import{ConflictError as t}from"./errors/conflict.js";import{ConflictGitPortMissingOpError as n}from"./ports/git.js";import{checkoutFile as r,removeFile as i,show as a}from"./git/port-dispatch.js";import{extractConflict as o}from"./extraction/extract.js";import{defaultVerifier as s}from"./ports/verify.js";import{detect as c}from"./reference-integrity/detect.js";import{createResolution as l}from"./resolution/create-resolution.js";import{takeOurs as u,takeTheirs as d}from"./resolution/take-strategy.js";import{applyResolutions as f}from"./resolver/apply-resolutions.js";import{resolveConflict as p}from"./resolver/resolve-conflict.js";import{resolveConflicts as m}from"./resolver/resolve-conflicts.js";import{buildOperationDigest as h,summarizeOperation as g}from"./resolver/summarize-operation.js";export{e as AIError,t as ConflictError,n as ConflictGitPortMissingOpError,f as applyResolutions,h as buildOperationDigest,r as checkoutFile,l as createResolution,s as defaultVerifier,c as detect,o as extractConflict,i as removeFile,p as resolveConflict,m as resolveConflicts,a as show,g as summarizeOperation,u as takeOurs,d as takeTheirs};
1
+ import{AIError as e}from"./errors/ai.js";import{ConflictError as t}from"./errors/conflict.js";import{ConflictGitPortMissingOpError as n}from"./ports/git.js";import{checkoutFile as r,removeFile as i,show as a}from"./git/port-dispatch.js";import{extractConflict as o}from"./extraction/extract.js";import{detect as s}from"./reference-integrity/detect.js";import{createResolution as c}from"./resolution/create-resolution.js";import{takeOurs as l,takeTheirs as u}from"./resolution/take-strategy.js";import{applyResolutions as d}from"./resolver/apply-resolutions.js";import{resolveConflict as f}from"./resolver/resolve-conflict.js";import{resolveConflicts as p}from"./resolver/resolve-conflicts.js";import{buildOperationDigest as m,summarizeOperation as h}from"./resolver/summarize-operation.js";export{e as AIError,t as ConflictError,n as ConflictGitPortMissingOpError,d as applyResolutions,m as buildOperationDigest,r as checkoutFile,c as createResolution,s as detect,o as extractConflict,i as removeFile,f as resolveConflict,p as resolveConflicts,a as show,h as summarizeOperation,l as takeOurs,u as takeTheirs};
@@ -35,6 +35,13 @@ interface ResolverConfig {
35
35
  maxSteps?: number;
36
36
  temperature?: number;
37
37
  refineMaxSteps?: number;
38
+ /** Global cap on `model.generate` calls for the whole operation (resolve + refine + fix). Default
39
+ * `undefined` = unlimited. Primarily a backstop for the fix phase's multiplicative worst case
40
+ * (files × fixLoopMaxIterations + crossFileMaxIterations × routable calls). When spent, the fix
41
+ * phase stops attempting further fixes and leaves the remainder in findings — it never aborts the
42
+ * operation. The resolve loop is not gated by this (it is already bounded by `maxSteps`); its calls
43
+ * still count toward the cap. */
44
+ maxModelCalls?: number;
38
45
  }
39
46
  //#endregion
40
47
  export { ConflictModelMessage, ConflictModelParams, ConflictModelPort, ConflictModelResult, ResolverConfig };
@@ -32,6 +32,12 @@ interface ParsedFile {
32
32
  language: string;
33
33
  definitions: SymbolDefinition[];
34
34
  references: SymbolReference[];
35
+ /** Names bound anywhere in the file, at any scope (module-level and nested). Optional: a backend that
36
+ * only surfaces top-level `definitions` may omit it. Populating it lets the detector catch a
37
+ * reference broken by removing a *nested* binding — a use satisfied by a local binding is never
38
+ * itself reported as a resolved reference, so without the binding as its "before" the before/after
39
+ * diff has no transition to flag. */
40
+ bindings?: SymbolDefinition[];
35
41
  syntaxErrors: SyntaxIssue[];
36
42
  }
37
43
  /**
@@ -43,9 +49,9 @@ interface ParserPort {
43
49
  /**
44
50
  * Parse and resolve every file in `sources`, treating those contents as authoritative for their
45
51
  * paths. Returns one {@link ParsedFile} per input path. Works only from the provided content — it
46
- * never reads the filesystem itself.
52
+ * never reads the filesystem itself. `signal`, when provided, should abort an in-flight analysis.
47
53
  */
48
- analyze(sources: SourceOverlay): Promise<ReadonlyMap<string, ParsedFile>>;
54
+ analyze(sources: SourceOverlay, signal?: AbortSignal): Promise<ReadonlyMap<string, ParsedFile>>;
49
55
  }
50
56
  //#endregion
51
57
  export { ParsedFile, ParserPort, SourceOverlay, SymbolDefinition, SymbolReference, SyntaxIssue };
@@ -1,3 +1,4 @@
1
+ import { SourceOverlay } from "./parser.js";
1
2
  import { Issue } from "../types/issue.js";
2
3
 
3
4
  //#region src/ports/verify.d.ts
@@ -5,10 +6,25 @@ interface VerificationResult {
5
6
  valid: boolean;
6
7
  issues?: Issue[];
7
8
  }
8
- interface ResolutionVerifier {
9
- verify(filePath: string, content: string): Promise<VerificationResult>;
9
+ interface ValidatorFinding {
10
+ /** Open category string set by the consumer (e.g. `'type-error'`, `'lint:no-console'`). */
11
+ category: string;
12
+ severity: 'error' | 'warning';
13
+ file: string;
14
+ /** 1-indexed. */
15
+ line?: number;
16
+ message: string;
17
+ }
18
+ /**
19
+ * The consumer's validation port (RFC stages 5/7). Plug in any checks — syntax, real import
20
+ * resolution, types, lint, project rules. **Non-blocking**: runs post-refine over an overlay (one file
21
+ * for per-file validation, or the whole touched set for cross-file); its findings are advisory (folded
22
+ * into the operation's findings and verdict), never a re-resolve. The only blocking gate is residual
23
+ * conflict markers, checked built-in by the resolver ({@link checkResidualMarkers}) — not this port.
24
+ */
25
+ interface Validator {
26
+ validate(sources: SourceOverlay, signal?: AbortSignal): Promise<ValidatorFinding[]>;
10
27
  }
11
28
  declare function checkResidualMarkers(_filePath: string, content: string): VerificationResult;
12
- declare const defaultVerifier: ResolutionVerifier;
13
29
  //#endregion
14
- export { ResolutionVerifier, VerificationResult, checkResidualMarkers, defaultVerifier };
30
+ export { Validator, ValidatorFinding, VerificationResult, checkResidualMarkers };
@@ -1,2 +1,2 @@
1
1
  const e=[/^<{7}(\s|$)/,/^\|{7}(\s|$)/,/^={7}(\s|$)/,/^>{7}(\s|$)/];function t(t,n){let r=n.split(`
2
- `),i=[];for(let t=0;t<r.length;t++){let n=r[t]??``;for(let r of e)if(r.test(n)){i.push(t+1);break}}return i.length===0?{valid:!0}:{valid:!1,issues:[{severity:`error`,message:`Residual conflict markers found at line(s) ${i.join(`, `)}.`}]}}const n={async verify(e,n){return t(e,n)}};export{t as checkResidualMarkers,n as defaultVerifier};
2
+ `),i=[];for(let t=0;t<r.length;t++){let n=r[t]??``;for(let r of e)if(r.test(n)){i.push(t+1);break}}return i.length===0?{valid:!0}:{valid:!1,issues:[{severity:`error`,message:`Residual conflict markers found at line(s) ${i.join(`, `)}.`}]}}export{t as checkResidualMarkers};
@@ -1,7 +1,8 @@
1
1
  import { ConflictGitPort } from "../ports/git.js";
2
+ import { DetectionReport, Finding } from "../types/findings.js";
2
3
  import { Resolution, ResolutionContext } from "../types/resolution.js";
3
4
  import { ParserPort } from "../ports/parser.js";
4
- import { DetectionReport } from "../types/findings.js";
5
+ import { Validator } from "../ports/verify.js";
5
6
 
6
7
  //#region src/reference-integrity/detect.d.ts
7
8
  interface DetectDeps {
@@ -17,5 +18,19 @@ interface DetectDeps {
17
18
  * parser is supplied, or refs are missing, the report says so instead of implying a clean result.
18
19
  */
19
20
  declare function detect(resolutions: Resolution[], context: ResolutionContext, deps: DetectDeps): Promise<DetectionReport>;
21
+ interface FileFindingsDeps {
22
+ parser: ParserPort;
23
+ validator?: Validator;
24
+ signal?: AbortSignal;
25
+ }
26
+ /**
27
+ * Per-file findings for one file's current content, compared against its pre-merge parents: structural
28
+ * (syntax/duplicate) + intra-file broken references (a local definition/binding the merge dropped while
29
+ * a use of it remains in the same file) + the consumer validator's per-file issues. All `tier: 'file'`.
30
+ * Cross-file references need the full set and are not computed here — `detect()` remains the
31
+ * authoritative whole-set report. This is the lightweight unit the per-file fix loop re-runs after each
32
+ * edit, over already-loaded parent snapshots (no git reads, no incoming-set discovery).
33
+ */
34
+ declare function collectFileFindings(file: string, oursContent: string | undefined, theirsContent: string | undefined, resultContent: string, deps: FileFindingsDeps): Promise<Finding[]>;
20
35
  //#endregion
21
- export { DetectDeps, detect };
36
+ export { DetectDeps, FileFindingsDeps, collectFileFindings, detect };
@@ -1,3 +1,3 @@
1
- import{buildSnapshots as e}from"./snapshot.js";async function t(t,r,o){let c=o.parser;if(!c)return{findings:[],checks:{referenceIntegrity:`skipped-no-parser`,structural:`skipped-no-parser`}};try{let l=r.refs?await e(t,r,o):null;if(l){let[e,r,o]=await Promise.all([c.analyze(l.ours),c.analyze(l.theirs),c.analyze(l.result)]);return{findings:[...n(e,r,o),...i(o),...a(t,l)],checks:{referenceIntegrity:s([e,r,o])?`partial-unsupported-language`:`ran`,structural:s([o])?`partial-unsupported-language`:`ran`},scope:{analyzed:l.analyzed,truncated:l.truncated}}}let u=new Map(t.map(e=>[e.filePath,e.content])),d=await c.analyze(u);return{findings:i(d),checks:{referenceIntegrity:`skipped-no-refs`,structural:s([d])?`partial-unsupported-language`:`ran`}}}catch{return{findings:[],checks:{referenceIntegrity:`errored`,structural:`errored`}}}}function n(e,t,n){let i=new Map;r(e,`ours`,i),r(t,`theirs`,i);let a=[];for(let[e,t]of n)for(let n of t.references){if(n.resolved)continue;let t=i.get(c(e,n.name));t&&a.push({category:`broken-reference`,severity:`error`,tier:`project`,location:{file:e,line:n.line,symbol:n.name},provenance:t,message:`Reference "${n.name}" resolved before the merge (on ${t.side}) but no longer resolves.`})}return a}function r(e,t,n){for(let[r,i]of e)for(let e of i.references){if(!e.resolved)continue;let i=c(r,e.name);n.has(i)||n.set(i,{side:t,line:e.line})}}function i(e){let t=[];for(let[n,r]of e){let e=new Map;for(let i of r.definitions){let r=e.get(i.name);r==null?e.set(i.name,i.line):t.push({category:`duplicate-definition`,severity:`error`,tier:`file`,location:{file:n,line:i.line,symbol:i.name},message:`Duplicate definition of "${i.name}" (also defined at line ${r}).`})}for(let e of r.syntaxErrors)t.push({category:`syntax-error`,severity:`error`,tier:`file`,location:{file:n,line:e.line},message:e.message})}return t}function a(e,t){let n=[];for(let r of e){let e=r.strategy===`take-ours`?`theirs`:r.strategy===`take-theirs`?`ours`:null;if(!e)continue;let i=(e===`ours`?t.ours:t.theirs).get(r.filePath);if(i==null)continue;let a=o(i,r.content);a<20||n.push({category:`dropped-side`,severity:`error`,tier:`file`,location:{file:r.filePath,line:1},message:`Resolution took "${r.strategy}" wholesale, discarding ~${a} line(s) of the ${e} side.`})}return n}function o(e,t){let n=new Set(t.split(`
1
+ import{buildSnapshots as e}from"./snapshot.js";async function t(t,n,i){let s=i.parser;if(!s)return{findings:[],checks:{referenceIntegrity:`skipped-no-parser`,structural:`skipped-no-parser`}};try{let l=n.detectionRefs??n.refs?await e(t,n,i):null;if(l){let[e,n,u]=await Promise.all([s.analyze(l.ours,i.signal),s.analyze(l.theirs,i.signal),s.analyze(l.result,i.signal)]),d=l.analyzed>0&&l.readable===0?`skipped-unreadable-source`:c([e,n,u])?`partial-unsupported-language`:`ran`;return{findings:[...r(e,n,u),...a(u),...o(t,l)],checks:{referenceIntegrity:d,structural:c([u])?`partial-unsupported-language`:`ran`},scope:{analyzed:l.analyzed,readable:l.readable,truncated:l.truncated}}}let u=new Map(t.map(e=>[e.filePath,e.content])),d=await s.analyze(u,i.signal);return{findings:a(d),checks:{referenceIntegrity:`skipped-no-refs`,structural:c([d])?`partial-unsupported-language`:`ran`}}}catch{return{findings:[],checks:{referenceIntegrity:`errored`,structural:`errored`}}}}async function n(e,t,n,i,o){let s=async t=>t==null?new Map:o.parser.analyze(new Map([[e,t]]),o.signal),c=await s(t),l=await s(n),u=await o.parser.analyze(new Map([[e,i]]),o.signal),d=[...r(c,l,u).filter(e=>e.tier===`file`),...a(u)];if(o.validator){let t=await o.validator.validate(new Map([[e,i]]),o.signal);for(let e of t)d.push({category:e.category,severity:e.severity,tier:`file`,location:{file:e.file,line:e.line??1},message:e.message})}return d}function r(e,t,n){let r=new Map;i(e,`ours`,r),i(t,`theirs`,r);let a=[];for(let[e,t]of n)for(let n of t.references){if(n.resolved)continue;let t=r.get(l(e,n.name));t&&a.push({category:`broken-reference`,severity:`error`,tier:t.crossFile?`project`:`file`,location:{file:e,line:n.line,symbol:n.name},provenance:{side:t.side,line:t.line},message:`Reference "${n.name}" resolved before the merge (on ${t.side}) but no longer resolves.`})}return a}function i(e,t,n){let r=(e,r,i,a)=>{let o=l(e,r);n.has(o)||n.set(o,{side:t,line:i,crossFile:a})};for(let[t,n]of e){for(let e of n.definitions)r(t,e.name,e.line,!1);for(let e of n.bindings??[])r(t,e.name,e.line,!1)}for(let[t,n]of e)for(let e of n.references)e.resolved&&r(t,e.name,e.line,e.target?e.target.file!==t:!0)}function a(e){let t=[];for(let[n,r]of e){let e=new Map;for(let i of r.definitions){let r=e.get(i.name);r==null?e.set(i.name,i.line):t.push({category:`duplicate-definition`,severity:`warning`,tier:`file`,location:{file:n,line:i.line,symbol:i.name},message:`Duplicate definition of "${i.name}" (also defined at line ${r}).`})}for(let e of r.syntaxErrors)t.push({category:`syntax-error`,severity:`error`,tier:`file`,location:{file:n,line:e.line},message:e.message})}return t}function o(e,t){let n=[];for(let r of e){let e=r.strategy===`take-ours`?`theirs`:r.strategy===`take-theirs`?`ours`:null;if(!e||r.confidence!==0)continue;let i=(e===`ours`?t.ours:t.theirs).get(r.filePath);if(i==null)continue;let a=s(i,r.content);a<20||n.push({category:`dropped-side`,severity:`error`,tier:`file`,location:{file:r.filePath,line:1},message:`Resolution took "${r.strategy}" wholesale, discarding ~${a} line(s) of the ${e} side.`})}return n}function s(e,t){let n=new Set(t.split(`
2
2
  `)),r=0;for(let t of e.split(`
3
- `))t.trim()!==``&&!n.has(t)&&r++;return r}function s(e){return e.some(e=>{for(let t of e.values())if(t.language===`unsupported`)return!0;return!1})}function c(e,t){return JSON.stringify([e,t])}export{t as detect};
3
+ `))t.trim()!==``&&!n.has(t)&&r++;return r}function c(e){return e.some(e=>{for(let t of e.values())if(t.language===`unsupported`)return!0;return!1})}function l(e,t){return JSON.stringify([e,t])}export{n as collectFileFindings,t as detect};
@@ -7,8 +7,11 @@ interface DetectionSnapshots {
7
7
  ours: SourceOverlay;
8
8
  theirs: SourceOverlay;
9
9
  result: SourceOverlay;
10
- /** Number of files actually included in the snapshots (after the cap). */
10
+ /** Number of files in scope (after the cap). */
11
11
  analyzed: number;
12
+ /** How many scope files had loadable parent content on `ours` or `theirs`. When this is 0 the
13
+ * before/after comparison had nothing to read — a total read failure, not a clean result. */
14
+ readable: number;
12
15
  /** Whether the scope exceeded the cap and was truncated. */
13
16
  truncated: boolean;
14
17
  }
@@ -1 +1 @@
1
- import{changedFiles as e,mergeBase as t,readFile as n,showFile as r}from"../git/port-dispatch.js";async function i(e,t,n){let r=t.refs;if(!r)return null;let i=n.signal?{signal:n.signal}:void 0,c=new Map(e.map(e=>[e.filePath,e.content])),l=t.touchedFiles??await a(n.git,r,i),u=[],d=new Set;for(let e of[...c.keys(),...l,...t.fileNeighbors??[]])d.has(e)||(d.add(e),u.push(e));let f=u.length>400,p=f?u.slice(0,400):u,m=new Map,h=new Map,g=new Map;for(let e of p){let t=await o(n.git,r.ours,e,i);t!=null&&m.set(e,t);let a=await o(n.git,r.theirs,e,i);a!=null&&h.set(e,a);let l=c.get(e);if(l!=null)g.set(e,l);else{let t=await s(n.git,e,i);t!=null&&g.set(e,t)}}return{ours:m,theirs:h,result:g,analyzed:p.length,truncated:f}}async function a(n,r,i){try{return await e(n,r.base??await t(n,r.ours,r.theirs,i),r.theirs,i)}catch{return[]}}async function o(e,t,n,i){try{return await r(e,t,n,i)}catch{return null}}async function s(e,t,r){try{return await n(e,t,r)}catch{return null}}export{i as buildSnapshots};
1
+ import{changedFiles as e,mergeBase as t,readFile as n,showFile as r}from"../git/port-dispatch.js";async function i(e,t,n){let r=t.detectionRefs??t.refs;if(!r)return null;let i=n.signal?{signal:n.signal}:void 0,c=new Map(e.map(e=>[e.filePath,e.content])),l=t.touchedFiles??await a(n.git,r,i),u=[],d=new Set;for(let e of[...c.keys(),...l,...t.fileNeighbors??[]])d.has(e)||(d.add(e),u.push(e));let f=u.length>400,p=f?u.slice(0,400):u,m=new Map,h=new Map,g=new Map,_=0;for(let e of p){let t=await o(n.git,r.ours,e,i);t&&m.set(e,t);let a=await o(n.git,r.theirs,e,i);a&&h.set(e,a),(t||a)&&_++;let l=c.get(e);if(l!=null)g.set(e,l);else{let t=await s(n.git,e,i);t!=null&&g.set(e,t)}}return{ours:m,theirs:h,result:g,analyzed:p.length,readable:_,truncated:f}}async function a(n,r,i){try{return await e(n,r.base??await t(n,r.ours,r.theirs,i),r.theirs,i)}catch{return[]}}async function o(e,t,n,i){try{return await r(e,t,n,i)}catch{return null}}async function s(e,t,r){try{return await n(e,t,r)}catch{return null}}export{i as buildSnapshots};
@@ -1,7 +1,7 @@
1
1
  import { ConflictGitPort } from "../ports/git.js";
2
2
  import { ConflictModelPort, ResolverConfig } from "../ports/model.js";
3
3
  import { ParserPort } from "../ports/parser.js";
4
- import { ResolutionVerifier } from "../ports/verify.js";
4
+ import { Validator } from "../ports/verify.js";
5
5
  import { ConflictProgressEvent } from "../types/events.js";
6
6
 
7
7
  //#region src/resolver/deps.d.ts
@@ -11,7 +11,7 @@ interface ResolverDeps<C extends ResolverConfig = ResolverConfig> {
11
11
  config?: C;
12
12
  onProgress?: (event: ConflictProgressEvent) => void;
13
13
  signal?: AbortSignal;
14
- verifier?: ResolutionVerifier;
14
+ validator?: Validator;
15
15
  parser?: ParserPort;
16
16
  }
17
17
  //#endregion
@@ -0,0 +1,61 @@
1
+ import { ConflictGitPort } from "../ports/git.js";
2
+ import { DetectionReport } from "../types/findings.js";
3
+ import { Resolution, ResolutionContext } from "../types/resolution.js";
4
+ import { ConflictModelPort } from "../ports/model.js";
5
+ import { ParserPort } from "../ports/parser.js";
6
+ import { Validator } from "../ports/verify.js";
7
+ import { ConflictProgressEvent } from "../types/events.js";
8
+ import { StepConfig } from "../types/step.js";
9
+ import { ModelBudget } from "./model-budget.js";
10
+
11
+ //#region src/resolver/fix-loop.d.ts
12
+ interface FixLoopDeps {
13
+ model: ConflictModelPort;
14
+ parser: ParserPort;
15
+ validator?: Validator;
16
+ config?: StepConfig;
17
+ onProgress?: (event: ConflictProgressEvent) => void;
18
+ signal?: AbortSignal;
19
+ /** Global model-call budget for the operation. When exhausted, the loop stops before any further
20
+ * model call and leaves the remaining findings unfixed. Absent = unlimited. */
21
+ budget?: ModelBudget;
22
+ /** Invoked the first time the budget stops work. The caller dedupes so the operation emits a single
23
+ * `fix:budget-exhausted`, whether the stop happens here or in the cross-file stage. */
24
+ onBudgetExhausted?: () => void;
25
+ }
26
+ /**
27
+ * Per-file convergence loop (opt-in `config.fix`). Re-detects the file's per-file findings, feeds the
28
+ * error-severity ones back through refine as fix instructions, and repeats until the file is clean, a
29
+ * round makes no progress, or the iteration budget runs out. What it cannot fix stays in the returned
30
+ * `Resolution.findings` — the verdict is computed from that remainder, never from a stale pre-fix set.
31
+ *
32
+ * Only intra-file findings are addressed here (from {@link collectFileFindings}); cross-file references
33
+ * are a separate set-level stage. `dropped-side` is not produced per-file and so is never routed. The
34
+ * caller should invoke this only for AI-resolved (`strategy: 'ai'`) text files.
35
+ */
36
+ declare function runFixLoop(resolution: Resolution, oursContent: string | undefined, theirsContent: string | undefined, deps: FixLoopDeps): Promise<Resolution>;
37
+ interface FixPhaseDeps {
38
+ model: ConflictModelPort;
39
+ git: ConflictGitPort;
40
+ parser: ParserPort;
41
+ validator?: Validator;
42
+ config?: StepConfig;
43
+ onProgress?: (event: ConflictProgressEvent) => void;
44
+ signal?: AbortSignal;
45
+ /** Global model-call budget for the operation. When exhausted, both fix stages stop before any
46
+ * further model call, leaving the remainder in the report. Absent = unlimited. */
47
+ budget?: ModelBudget;
48
+ }
49
+ /**
50
+ * The fix phase (opt-in). Stage 2: per-file convergence over each AI resolution ({@link runFixLoop}).
51
+ * Stage 3: route cross-file (`tier: 'project'`) broken references back to the refine of their use-site
52
+ * file and re-detect over the whole set, up to `crossFileMaxIterations` rounds. Mutates `resolutions`
53
+ * in place and returns the final whole-set {@link DetectionReport} — which, being whole-set, also
54
+ * re-checks per-file for files touched by cross-file fixes.
55
+ *
56
+ * Cross-file findings on files without an AI resolution (e.g. auto-merged) are not routed: they have no
57
+ * carrier for an edit yet (needs a patches channel), so they remain in the report unchanged.
58
+ */
59
+ declare function runFixPhase(resolutions: Resolution[], context: ResolutionContext, deps: FixPhaseDeps): Promise<DetectionReport>;
60
+ //#endregion
61
+ export { FixLoopDeps, FixPhaseDeps, runFixLoop, runFixPhase };
@@ -0,0 +1,2 @@
1
+ import{buildSnapshots as e}from"../reference-integrity/snapshot.js";import{collectFileFindings as t,detect as n}from"../reference-integrity/detect.js";import{checkResidualMarkers as r}from"../ports/verify.js";import{runRefinePass as i}from"./refine-pass.js";const a=.9;async function o(e,n,o,l){let u=l.config?.fixLoopMaxIterations??3,d={parser:l.parser,validator:l.validator,signal:l.signal},f=e.content,p=await t(e.filePath,n,o,f,d),m=p.filter(e=>e.severity===`error`);if(m.length===0)return l.onProgress?.({type:`fix:completed`,filePath:e.filePath,iterations:0,remaining:0}),p.length>0?{...e,findings:p}:e;if(l.budget?.exhausted())return l.onBudgetExhausted?.(),l.onProgress?.({type:`fix:completed`,filePath:e.filePath,iterations:0,remaining:m.length}),{...e,findings:p};l.onProgress?.({type:`fix:started`,filePath:e.filePath,findingCount:m.length});let h=[],g=0,_=c(m);for(;g<u&&m.length>0;g++){if(l.budget?.exhausted()){l.onBudgetExhausted?.();break}let a=await i(f,e.description,m.map(s),{model:l.model,signal:l.signal,temperature:l.config?.temperature,refineMaxSteps:l.config?.refineMaxSteps,onProgress:l.onProgress,filePath:e.filePath});if(a.edits.length===0)break;if(!r(e.filePath,a.content).valid){l.onProgress?.({type:`refine:discarded`,filePath:e.filePath,reason:`Fix edits introduced conflict markers.`});break}f=a.content,h.push(...a.edits),p=await t(e.filePath,n,o,f,d),m=p.filter(e=>e.severity===`error`);let u=c(m);if(u===_)break;_=u}l.onProgress?.({type:`fix:completed`,filePath:e.filePath,iterations:g,remaining:m.length});let v={...e,content:f,findings:p};return h.length>0&&(v={...v,strategy:`ai`,edits:[...e.edits??[],...h],confidence:Math.min(v.confidence,a)}),v}function s(e){let t=e.location.symbol?`"${e.location.symbol}" (line ${e.location.line})`:`line ${e.location.line}`;return`${e.category} at ${t}: ${e.message}`}function c(e){return e.map(e=>`${e.category}\u0000${e.location.file}\u0000${e.location.symbol??``}`).sort().join(`
2
+ `)}async function l(t,r,i){let a={git:i.git,parser:i.parser,signal:i.signal},s=await e(t,r,{git:i.git,signal:i.signal});if(!s)return n(t,r,a);let c=!1,l=()=>{c||(c=!0,i.onProgress?.({type:`fix:budget-exhausted`,spent:i.budget?.spent()??0,limit:i.config?.maxModelCalls??0}))},f={model:i.model,parser:i.parser,validator:i.validator,config:i.config,onProgress:i.onProgress,signal:i.signal,budget:i.budget,onBudgetExhausted:l};for(let e=0;e<t.length;e++){let n=t[e];!n||n.strategy!==`ai`||(t[e]=await o(n,s.ours.get(n.filePath),s.theirs.get(n.filePath),f))}let p=i.config?.crossFileMaxIterations??2,m=await n(t,r,a);for(let e=0;e<p;e++){if(i.budget?.exhausted()){l();break}let e=d(m.findings,t);if(e.size===0)break;let o=!1;for(let[n,r]of e){if(i.budget?.exhausted()){l();break}let e=t.findIndex(e=>e.filePath===n),a=e>=0?t[e]:void 0;if(!a)continue;let c=a.content,d=await u(a,r,s.ours.get(n),s.theirs.get(n),f);t[e]=d,d.content!==c&&(o=!0)}if(!o)break;m=await n(t,r,a)}return m}async function u(e,n,o,c,l){let u=await i(e.content,e.description,n.map(s),{model:l.model,signal:l.signal,temperature:l.config?.temperature,refineMaxSteps:l.config?.refineMaxSteps,onProgress:l.onProgress,filePath:e.filePath});if(u.edits.length===0)return e;if(!r(e.filePath,u.content).valid)return l.onProgress?.({type:`refine:discarded`,filePath:e.filePath,reason:`Cross-file fix introduced conflict markers.`}),e;let d=await t(e.filePath,o,c,u.content,{parser:l.parser,validator:l.validator,signal:l.signal});return{...e,content:u.content,strategy:`ai`,edits:[...e.edits??[],...u.edits],confidence:Math.min(e.confidence,a),findings:d}}function d(e,t){let n=new Set(t.filter(e=>e.strategy===`ai`).map(e=>e.filePath)),r=new Map;for(let t of e){if(t.tier!==`project`||t.severity!==`error`||!n.has(t.location.file))continue;let e=r.get(t.location.file)??[];e.push(t),r.set(t.location.file,e)}return r}export{o as runFixLoop,l as runFixPhase};
@@ -1,4 +1,3 @@
1
- import { Issue } from "../types/issue.js";
2
1
  import { ResolutionContext, ResolutionMetrics, ResolvedChunk } from "../types/resolution.js";
3
2
  import { Conflict } from "../types/conflict.js";
4
3
  import { ResolverDeps } from "./deps.js";
@@ -11,7 +10,6 @@ interface ResolverLoopResult {
11
10
  description: string;
12
11
  metrics: ResolutionMetrics;
13
12
  followUpInstructions?: string[];
14
- issues?: Issue[];
15
13
  }
16
14
  declare function runResolverLoop(conflict: Conflict, context: ResolutionContext, deps: ResolverDeps): Promise<ResolverLoopResult>;
17
15
  //#endregion
@@ -1 +1 @@
1
- import{AIError as e}from"../errors/ai.js";import{applyChunks as t}from"../resolution/chunk-apply.js";import{sumUsages as n}from"../resolution/metrics.js";import{parseModelResponse as r}from"./parse-response.js";import{buildPrompt as i}from"./prompt.js";import{createResolverTools as a}from"./tools.js";import{validateChunks as o}from"./validate.js";async function s(n,s,d){let f=d.config?.maxSteps??15,p=d.config?.temperature??0,{definitions:m,execute:h}=a(d.git),{system:g,userMessage:_}=i(n,s,{maxSteps:f}),v=[{role:`user`,content:_}],y=d.signal?{signal:d.signal}:void 0,b=0,x=0,S=0,C=[],w=n.markers.some(e=>e.userResolution!=null),T;for(;b<f;){b++;let e=await d.model.generate({system:g,messages:[...v],tools:m,temperature:p,signal:d.signal});if(e.usage&&(C.push(e.usage),d.onProgress?.({type:`resolver:step-usage`,filePath:n.filePath,stepNumber:b,usage:e.usage})),e.toolCalls&&e.toolCalls.length>0){v.push({role:`assistant`,content:e.text,toolCalls:e.toolCalls});for(let t of e.toolCalls){let e=typeof t.args.reason==`string`?t.args.reason:void 0;d.onProgress?.({type:`resolver:tool-call`,filePath:n.filePath,tool:t.name,args:t.args,stepNumber:b,reason:e}),x++;let r=await h(t,y);d.onProgress?.({type:`resolver:tool-result`,filePath:n.filePath,tool:t.name,stepNumber:b,content:r.content}),v.push({role:`tool`,toolCallId:r.toolCallId,toolName:t.name,content:r.content})}continue}let i=e.text??``;if(!i.trim()){S++,v.push({role:`assistant`,content:``}),v.push({role:`user`,content:`[Step ${b}/${f}] Your response was empty. Please respond with a valid JSON object containing "description" (1-2 sentences) and either "strategy" ("ours", "theirs", or "deleted") with a top-level "confidence" (0-1) for a file-level decision, or "chunks" (array of { markerIndex, strategy, confidence, content?, reason? }).`});continue}let a;try{a=r(i,n.markers.length)}catch(e){S++;let t=e instanceof Error?e.message:String(e);v.push({role:`assistant`,content:i}),v.push({role:`user`,content:`[Step ${b}/${f}] Failed to parse your response: ${t}. Please respond with a valid JSON object containing "description" (1-2 sentences) and either "strategy" ("ours", "theirs", or "deleted") with a top-level "confidence" (0-1) for a file-level decision, or "chunks" (array of { markerIndex, strategy, confidence, content?, reason? }).`});continue}if(a.strategy===`deleted`&&n.type!==`delete-modify`){S++,v.push({role:`assistant`,content:i}),v.push({role:`user`,content:`[Step ${b}/${f}] Strategy "deleted" is only valid for delete-modify conflicts. This is a text conflict — respond with "chunks" or a file-level "ours"/"theirs" strategy.`});continue}if(a.strategy){let e=a.strategy;if((e===`ours`||e===`theirs`)&&w)a.chunks=n.markers.map((t,n)=>t.userResolution==null?{markerIndex:n,strategy:e}:null).filter(e=>e!==null),a.strategy=void 0;else return d.onProgress?.({type:`resolver:response`,filePath:n.filePath,chunks:a.chunks,strategy:e,confidence:a.confidence,description:a.description,...a.followUpInstructions?{followUpInstructions:a.followUpInstructions}:{}}),{chunks:a.chunks,strategy:e,confidence:a.confidence,description:a.description,metrics:u(C,b,x,S),...a.followUpInstructions?{followUpInstructions:a.followUpInstructions}:{}}}if(n.markers.length===0&&!a.strategy){S++,v.push({role:`assistant`,content:i}),v.push({role:`user`,content:`[Step ${b}/${f}] Delete-modify conflicts require a file-level strategy field ("ours", "theirs", or "deleted"). Please respond with a JSON object containing "strategy".`});continue}let s=a.chunks.filter(e=>n.markers[e.markerIndex]?.userResolution==null),_=o(s,n);if(!_.valid){S++;let e=(_.issues??[]).map(e=>e.message).join(` `);v.push({role:`assistant`,content:i}),v.push({role:`user`,content:`[Step ${b}/${f}] Validation failed: ${e}\nPlease fix the issues and respond with a corrected JSON object. Make sure every marker index appears exactly once, or use a top-level "strategy" field.`});continue}let E=l(s,n);if(d.verifier&&n.type===`text`){let e=t(n.rawContent,n.markers,E),r=await d.verifier.verify(n.filePath,e);if(!r.valid){S++;let e=(r.issues??[]).filter(e=>e.severity===`error`).map(e=>e.message).join(` `)||`Content did not pass verification.`;v.push({role:`assistant`,content:i}),v.push({role:`user`,content:`[Step ${b}/${f}] Verification failed: ${e}\nPlease fix and respond with corrected JSON.`});continue}let a=(r.issues??[]).filter(e=>e.severity===`warning`);a.length>0&&(T=a,d.onProgress?.({type:`verification:issues`,filePath:n.filePath,issues:a}))}let D=c(s,a.confidence);return d.onProgress?.({type:`resolver:response`,filePath:n.filePath,chunks:a.chunks,confidence:D,description:a.description,...a.followUpInstructions?{followUpInstructions:a.followUpInstructions}:{}}),{chunks:E,confidence:D,description:a.description,metrics:u(C,b,x,S),...a.followUpInstructions?{followUpInstructions:a.followUpInstructions}:{},...T&&T.length>0?{issues:T}:{}}}throw new e(`VALIDATION_EXHAUSTED`,`Resolver exhausted ${f} steps without producing a valid resolution for ${n.filePath}.`)}function c(e,t){let n=e.map(e=>e.confidence).filter(e=>e!==void 0);return n.length>0?Math.min(...n):e.length>0?t:1}function l(e,t){let n=[...e];for(let e=0;e<t.markers.length;e++){let r=t.markers[e]?.userResolution;r!=null&&n.push({markerIndex:e,...r})}return n.sort((e,t)=>e.markerIndex-t.markerIndex),n}function u(e,t,r,i){return{...n(e)??{inputTokens:0,outputTokens:0},stepCount:t,toolCallCount:r,retryCount:i}}export{s as runResolverLoop};
1
+ import{AIError as e}from"../errors/ai.js";import{sumUsages as t}from"../resolution/metrics.js";import{parseModelResponse as n}from"./parse-response.js";import{buildPrompt as r}from"./prompt.js";import{createResolverTools as i}from"./tools.js";import{validateChunks as a}from"./validate.js";async function o(t,o,u){let d=u.config?.maxSteps??15,f=u.config?.temperature??0,{definitions:p,execute:m}=i(u.git),{system:h,userMessage:g}=r(t,o,{maxSteps:d}),_=[{role:`user`,content:g}],v=u.signal?{signal:u.signal}:void 0,y=0,b=0,x=0,S=[],C=t.markers.some(e=>e.userResolution!=null);for(;y<d;){y++;let e=await u.model.generate({system:h,messages:[..._],tools:p,temperature:f,signal:u.signal});if(e.usage&&(S.push(e.usage),u.onProgress?.({type:`resolver:step-usage`,filePath:t.filePath,stepNumber:y,usage:e.usage})),e.toolCalls&&e.toolCalls.length>0){_.push({role:`assistant`,content:e.text,toolCalls:e.toolCalls});for(let n of e.toolCalls){let e=typeof n.args.reason==`string`?n.args.reason:void 0;u.onProgress?.({type:`resolver:tool-call`,filePath:t.filePath,tool:n.name,args:n.args,stepNumber:y,reason:e}),b++;let r=await m(n,v);u.onProgress?.({type:`resolver:tool-result`,filePath:t.filePath,tool:n.name,stepNumber:y,content:r.content}),_.push({role:`tool`,toolCallId:r.toolCallId,toolName:n.name,content:r.content})}continue}let r=e.text??``;if(!r.trim()){x++,_.push({role:`assistant`,content:``}),_.push({role:`user`,content:`[Step ${y}/${d}] Your response was empty. Please respond with a valid JSON object containing "description" (1-2 sentences) and either "strategy" ("ours", "theirs", or "deleted") with a top-level "confidence" (0-1) for a file-level decision, or "chunks" (array of { markerIndex, strategy, confidence, content?, reason? }).`});continue}let i;try{i=n(r,t.markers.length)}catch(e){x++;let t=e instanceof Error?e.message:String(e);_.push({role:`assistant`,content:r}),_.push({role:`user`,content:`[Step ${y}/${d}] Failed to parse your response: ${t}. Please respond with a valid JSON object containing "description" (1-2 sentences) and either "strategy" ("ours", "theirs", or "deleted") with a top-level "confidence" (0-1) for a file-level decision, or "chunks" (array of { markerIndex, strategy, confidence, content?, reason? }).`});continue}if(i.strategy===`deleted`&&t.type!==`delete-modify`){x++,_.push({role:`assistant`,content:r}),_.push({role:`user`,content:`[Step ${y}/${d}] Strategy "deleted" is only valid for delete-modify conflicts. This is a text conflict — respond with "chunks" or a file-level "ours"/"theirs" strategy.`});continue}if(i.strategy){let e=i.strategy;if((e===`ours`||e===`theirs`)&&C)i.chunks=t.markers.map((t,n)=>t.userResolution==null?{markerIndex:n,strategy:e}:null).filter(e=>e!==null),i.strategy=void 0;else return u.onProgress?.({type:`resolver:response`,filePath:t.filePath,chunks:i.chunks,strategy:e,confidence:i.confidence,description:i.description,...i.followUpInstructions?{followUpInstructions:i.followUpInstructions}:{}}),{chunks:i.chunks,strategy:e,confidence:i.confidence,description:i.description,metrics:l(S,y,b,x),...i.followUpInstructions?{followUpInstructions:i.followUpInstructions}:{}}}if(t.markers.length===0&&!i.strategy){x++,_.push({role:`assistant`,content:r}),_.push({role:`user`,content:`[Step ${y}/${d}] Delete-modify conflicts require a file-level strategy field ("ours", "theirs", or "deleted"). Please respond with a JSON object containing "strategy".`});continue}let o=i.chunks.filter(e=>t.markers[e.markerIndex]?.userResolution==null),g=a(o,t);if(!g.valid){x++;let e=(g.issues??[]).map(e=>e.message).join(` `);_.push({role:`assistant`,content:r}),_.push({role:`user`,content:`[Step ${y}/${d}] Validation failed: ${e}\nPlease fix the issues and respond with a corrected JSON object. Make sure every marker index appears exactly once, or use a top-level "strategy" field.`});continue}let w=c(o,t),T=s(o,i.confidence);return u.onProgress?.({type:`resolver:response`,filePath:t.filePath,chunks:i.chunks,confidence:T,description:i.description,...i.followUpInstructions?{followUpInstructions:i.followUpInstructions}:{}}),{chunks:w,confidence:T,description:i.description,metrics:l(S,y,b,x),...i.followUpInstructions?{followUpInstructions:i.followUpInstructions}:{}}}throw new e(`VALIDATION_EXHAUSTED`,`Resolver exhausted ${d} steps without producing a valid resolution for ${t.filePath}.`)}function s(e,t){let n=e.map(e=>e.confidence).filter(e=>e!==void 0);return n.length>0?Math.min(...n):e.length>0?t:1}function c(e,t){let n=[...e];for(let e=0;e<t.markers.length;e++){let r=t.markers[e]?.userResolution;r!=null&&n.push({markerIndex:e,...r})}return n.sort((e,t)=>e.markerIndex-t.markerIndex),n}function l(e,n,r,i){return{...t(e)??{inputTokens:0,outputTokens:0},stepCount:n,toolCallCount:r,retryCount:i}}export{o as runResolverLoop};
@@ -0,0 +1,20 @@
1
+ import { ConflictModelPort } from "../ports/model.js";
2
+
3
+ //#region src/resolver/model-budget.d.ts
4
+ /** A shared, per-operation counter of `model.generate` calls. Created once per {@link resolveConflicts}
5
+ * run; the wrapped model increments the counter but never blocks — enforcement is the caller's job via
6
+ * {@link ModelBudget.exhausted}, checked before fix-phase model calls only. Counting instead of throwing
7
+ * is deliberate: a throw inside the resolve loop would be caught by the fallback path and degrade a good
8
+ * resolution into a whole-side take — the exact data loss this package guards against. */
9
+ interface ModelBudget {
10
+ /** Wrap a model so every `generate` call increments this budget's shared counter, then delegates to
11
+ * the real model unchanged. Multiple wraps share one counter. */
12
+ wrap(model: ConflictModelPort): ConflictModelPort;
13
+ spent(): number;
14
+ /** Calls left before the cap. `Infinity` when the budget is unlimited (`max` undefined). */
15
+ remaining(): number;
16
+ exhausted(): boolean;
17
+ }
18
+ declare function createModelBudget(max?: number): ModelBudget;
19
+ //#endregion
20
+ export { ModelBudget, createModelBudget };
@@ -0,0 +1 @@
1
+ function e(e){let t=0,n=()=>e===void 0?1/0:e-t;return{wrap(e){return{generate:n=>(t++,e.generate(n))}},spent:()=>t,remaining:n,exhausted:()=>n()<=0}}export{e as createModelBudget};
@@ -1 +1 @@
1
- import{sumUsages as e}from"../resolution/metrics.js";import{buildRefinePrompt as t}from"./prompt.js";import{createRefineTools as n}from"./refine-tools.js";async function r(r,i,a,o){let s=o.refineMaxSteps??5,{system:c,userMessage:l}=t(r,i,a),{definitions:u,execute:d}=n(),f=[{role:`user`,content:l}],p=r,m=[],h=0,g=[];for(let t=0;t<s;t++){let n=t+1,r=await o.model.generate({system:c,messages:[...f],tools:u,temperature:o.temperature??0,signal:o.signal});if(r.usage&&g.push(r.usage),r.toolCalls&&r.toolCalls.length>0){f.push({role:`assistant`,content:r.text,toolCalls:r.toolCalls});for(let e of r.toolCalls){let t=e.args.ops,r=Array.isArray(t)?t:[];o.onProgress?.({type:`refine:tool-call`,filePath:o.filePath,stepNumber:n,ops:r});let{content:i,edits:a,verifiedCount:s,feedback:c,isError:l}=d(e,p);l||(p=i,m.push(...a),h+=s),o.onProgress?.({type:`refine:tool-result`,filePath:o.filePath,stepNumber:n,appliedCount:a.length,isError:l,feedback:c}),f.push({role:`tool`,toolCallId:e.id,toolName:e.name,content:c})}continue}let i=(r.text??``).trim();return o.onProgress?.({type:`refine:final-text`,filePath:o.filePath,text:i}),{edits:m,verifies:h,content:p,description:i,usage:e(g)}}return{edits:m,verifies:h,content:p,description:``,usage:e(g)}}export{r as runRefinePass};
1
+ import{sumUsages as e}from"../resolution/metrics.js";import{buildRefinePrompt as t}from"./prompt.js";import{createRefineTools as n}from"./refine-tools.js";async function r(r,i,a,o){let s=o.refineMaxSteps??5,{system:c,userMessage:l}=t(r,i,a),{definitions:u,execute:d}=n(),f=[{role:`user`,content:l}],p=r,m=[],h=0,g=0,_=[];for(let t=0;t<s;t++){let n=t+1,r=await o.model.generate({system:c,messages:[...f],tools:u,temperature:o.temperature??0,signal:o.signal});if(r.usage&&_.push(r.usage),r.toolCalls&&r.toolCalls.length>0){f.push({role:`assistant`,content:r.text,toolCalls:r.toolCalls});for(let t of r.toolCalls){let r=t.args.ops,i=Array.isArray(r)?r:[];o.onProgress?.({type:`refine:tool-call`,filePath:o.filePath,stepNumber:n,ops:i});let{content:a,edits:s,verifiedCount:c,feedback:l,isError:u}=d(t,p);if(u?g++:(p=a,m.push(...s),h+=c,g=0),o.onProgress?.({type:`refine:tool-result`,filePath:o.filePath,stepNumber:n,appliedCount:s.length,isError:u,feedback:l}),f.push({role:`tool`,toolCallId:t.id,toolName:t.name,content:l}),g>=3)return o.onProgress?.({type:`refine:bailed`,filePath:o.filePath,reason:`Stopped after ${g} consecutive apply errors.`}),{edits:m,verifies:h,content:p,description:``,usage:e(_)}}continue}let i=(r.text??``).trim();return o.onProgress?.({type:`refine:final-text`,filePath:o.filePath,text:i}),{edits:m,verifies:h,content:p,description:i,usage:e(_)}}return{edits:m,verifies:h,content:p,description:``,usage:e(_)}}export{r as runRefinePass};
@@ -1 +1 @@
1
- import{checkResidualMarkers as e}from"../ports/verify.js";import{resolveChunkContent as t}from"../resolution/chunk-apply.js";import{createResolution as n}from"../resolution/create-resolution.js";import{mergeUsages as r}from"../resolution/metrics.js";import{runResolverLoop as i}from"./loop.js";import{runRefinePass as a}from"./refine-pass.js";import{buildThreeWayDiff as o}from"./three-way-diff.js";const s={refineNoAction:.6,refineApplied:.9};async function c(c,l,u){let d=l.threeWayDiff||!l.refs?l:{...l,threeWayDiff:await o(c.filePath,l.refs,{git:u.git,signal:u.signal})},{chunks:f,strategy:p,confidence:m,description:h,metrics:g,followUpInstructions:_,issues:v}=await i(c,d,{model:u.model,git:u.git,config:u.config,onProgress:u.onProgress,signal:u.signal,verifier:u.verifier}),y=n(c,f,{confidence:m,description:h,metrics:g,...p?{fileStrategy:p}:{},...d.threeWayDiff?{threeWayDiff:d.threeWayDiff}:{}});v&&v.length>0&&(y={...y,issues:v});let b=_&&_.length>0;if(c.type===`text`&&b){u.onProgress?.({type:`refine:started`,filePath:c.filePath});let n=0;try{let i=await a(y.content,y.description,_,{model:u.model,signal:u.signal,temperature:u.config?.temperature,refineMaxSteps:u.config?.refineMaxSteps,onProgress:u.onProgress,filePath:c.filePath});if(i.edits.length>0){let t=e(c.filePath,i.content);if(t.valid)y={...y,content:i.content,edits:i.edits,strategy:`ai`},n=i.edits.length;else{let e=(t.issues??[]).map(e=>e.message).join(` `);u.onProgress?.({type:`refine:discarded`,filePath:c.filePath,reason:`Edits introduced conflict markers: ${e}`})}}if(n>0){y={...y,confidence:Math.min(y.confidence,s.refineApplied)};let e=[];for(let n=0;n<c.markers.length;n++){let r=c.markers[n];if(!r||r.userResolution==null)continue;let a=t({markerIndex:n,...r.userResolution},r);a!==``&&(i.content.includes(a)||e.push(n))}e.length>0&&(u.onProgress?.({type:`refine:touched-user-region`,filePath:c.filePath,markerIndexes:e}),y={...y,note:`Refine modified user-resolved region(s): marker ${e.join(`, `)}.`})}else i.edits.length===0&&i.verifies>0||(y={...y,confidence:Math.min(y.confidence,s.refineNoAction)});if(i.usage){let e=r(y.metrics??{inputTokens:0,outputTokens:0},i.usage);y={...y,metrics:{...y.metrics,...e}}}u.onProgress?.({type:`refine:completed`,filePath:c.filePath,appliedCount:n})}catch(e){let t=e instanceof Error?e.message:String(e);throw u.onProgress?.({type:`refine:failed`,filePath:c.filePath,reason:t}),e}}else u.onProgress?.({type:`refine:skipped`,filePath:c.filePath,reason:`no-follow-ups`});return u.onProgress?.({type:`resolver:completed`,filePath:c.filePath,stepCount:g.stepCount??0}),y}export{c as resolveConflict};
1
+ import{resolveChunkContent as e}from"../resolution/chunk-apply.js";import{createResolution as t}from"../resolution/create-resolution.js";import{checkResidualMarkers as n}from"../ports/verify.js";import{mergeUsages as r}from"../resolution/metrics.js";import{runResolverLoop as i}from"./loop.js";import{runRefinePass as a}from"./refine-pass.js";import{buildThreeWayDiff as o}from"./three-way-diff.js";const s={refineNoAction:.6,refineApplied:.9};async function c(c,l,u){let d=l.threeWayDiff||!l.refs?l:{...l,threeWayDiff:await o(c.filePath,l.refs,{git:u.git,signal:u.signal})},{chunks:f,strategy:p,confidence:m,description:h,metrics:g,followUpInstructions:_}=await i(c,d,{model:u.model,git:u.git,config:u.config,onProgress:u.onProgress,signal:u.signal}),v=t(c,f,{confidence:m,description:h,metrics:g,...p?{fileStrategy:p}:{},...d.threeWayDiff?{threeWayDiff:d.threeWayDiff}:{}}),y=_&&_.length>0;if(c.type===`text`&&y){u.onProgress?.({type:`refine:started`,filePath:c.filePath});let t=0;try{let i=await a(v.content,v.description,_,{model:u.model,signal:u.signal,temperature:u.config?.temperature,refineMaxSteps:u.config?.refineMaxSteps,onProgress:u.onProgress,filePath:c.filePath});if(i.edits.length>0){let e=n(c.filePath,i.content);if(e.valid)v={...v,content:i.content,edits:i.edits,strategy:`ai`},t=i.edits.length;else{let t=(e.issues??[]).map(e=>e.message).join(` `);u.onProgress?.({type:`refine:discarded`,filePath:c.filePath,reason:`Edits introduced conflict markers: ${t}`})}}if(t>0){v={...v,confidence:Math.min(v.confidence,s.refineApplied)};let t=[];for(let n=0;n<c.markers.length;n++){let r=c.markers[n];if(!r||r.userResolution==null)continue;let a=e({markerIndex:n,...r.userResolution},r);a!==``&&(i.content.includes(a)||t.push(n))}t.length>0&&(u.onProgress?.({type:`refine:touched-user-region`,filePath:c.filePath,markerIndexes:t}),v={...v,note:`Refine modified user-resolved region(s): marker ${t.join(`, `)}.`})}else i.edits.length===0&&i.verifies>0||(v={...v,confidence:Math.min(v.confidence,s.refineNoAction)});if(i.usage){let e=r(v.metrics??{inputTokens:0,outputTokens:0},i.usage);v={...v,metrics:{...v.metrics,...e}}}u.onProgress?.({type:`refine:completed`,filePath:c.filePath,appliedCount:t})}catch(e){if(u.signal?.aborted)throw e;let t=e instanceof Error?e.message:String(e);u.onProgress?.({type:`refine:failed`,filePath:c.filePath,reason:t}),v={...v,confidence:Math.min(v.confidence,s.refineNoAction)}}}else u.onProgress?.({type:`refine:skipped`,filePath:c.filePath,reason:`no-follow-ups`});if(u.validator&&c.type===`text`)try{let e=await u.validator.validate(new Map([[c.filePath,v.content]]),u.signal);if(e.length>0){let t=e.map(e=>({category:e.category,severity:e.severity,tier:`file`,location:{file:e.file,line:e.line??1},message:e.message}));v={...v,findings:t}}}catch(e){if(u.signal?.aborted)throw e;let t=e instanceof Error?e.message:String(e);u.onProgress?.({type:`validation:failed`,filePath:c.filePath,reason:t})}return u.onProgress?.({type:`resolver:completed`,filePath:c.filePath,stepCount:g.stepCount??0}),v}export{c as resolveConflict};
@@ -1 +1 @@
1
- import{AIError as e}from"../errors/ai.js";import{unmergedEntries as t}from"../git/port-dispatch.js";import{extractConflict as n}from"../extraction/extract.js";import{detect as r}from"../reference-integrity/detect.js";import{createResolution as i}from"../resolution/create-resolution.js";import{takeOurs as a,takeTheirs as o}from"../resolution/take-strategy.js";import{resolveConflict as s}from"./resolve-conflict.js";import c from"picomatch";function l(e){let t=e.map(e=>{let t=(Array.isArray(e.match)?e.match:[e.match]).map(e=>c(e,{basename:!e.includes(`/`)}));return{isMatch:e=>t.some(t=>t(e)),strategy:e.strategy}});return e=>{for(let{isMatch:n,strategy:r}of t)if(n(e))return r}}async function u(e,s){let c=e.config?.rules??[],u=e.config?.defaultStrategy??`ai`,p=l(c),m=await t(e.git,{signal:e.signal}),h={...s??{}},g=[],_=[],v=[];for(let t of m){if(e.signal?.aborted)break;let r=p(t.path)??u;if(r===`skip`){v.push({filePath:t.path,reason:`excluded`}),e.onProgress?.({type:`conflict:excluded`,filePath:t.path});continue}let s;try{s=await n(t.path,{git:e.git,signal:e.signal},t.reason)}catch(e){_.push({filePath:t.path,error:f(e)});continue}if(!s){v.push({filePath:t.path,reason:`no-markers`}),e.onProgress?.({type:`conflict:skipped`,filePath:t.path,reason:`no-markers`,entryReason:t.reason});continue}e.onProgress?.({type:`conflict:found`,filePath:s.filePath,conflictType:s.type,markerCount:s.markers.length});let c=null;c=r===`take-ours`?a(s):r===`take-theirs`?o(s):r===`deleted`?i(s,[],{confidence:1,fileStrategy:`deleted`}):await d(s,h,e,_),c&&(h.previousResolutions=[...h.previousResolutions??[],c],g.push(c),e.onProgress?.({type:`resolution:applied`,filePath:c.filePath,strategy:c.strategy,confidence:c.confidence,description:c.description}))}return{resolutions:g,errors:_,skipped:v,detection:await r(g,h,{git:e.git,parser:e.parser,signal:e.signal})}}async function d(t,n,r,i){try{return await s(t,n,{model:r.model,git:r.git,config:r.config,onProgress:r.onProgress,signal:r.signal,verifier:r.verifier})}catch(n){let s=f(n),c=r.config?.fallbackStrategy;return c?(r.onProgress?.({type:`resolution:fallback`,filePath:t.filePath,error:s,cause:s instanceof e?s.code:void 0}),{...c===`take-ours`?a(t):o(t),confidence:0}):(i.push({filePath:t.filePath,error:s}),r.onProgress?.({type:`resolution:failed`,filePath:t.filePath,error:s}),null)}}function f(e){return e instanceof Error?e:Error(String(e))}export{u as resolveConflicts};
1
+ import{AIError as e}from"../errors/ai.js";import{unmergedEntries as t}from"../git/port-dispatch.js";import{extractConflict as n}from"../extraction/extract.js";import{detect as r}from"../reference-integrity/detect.js";import{createResolution as i}from"../resolution/create-resolution.js";import{takeOurs as a,takeTheirs as o}from"../resolution/take-strategy.js";import{resolveConflict as s}from"./resolve-conflict.js";import{runFixPhase as c}from"./fix-loop.js";import{createModelBudget as l}from"./model-budget.js";import u from"picomatch";function d(e){let t=e.map(e=>{let t=(Array.isArray(e.match)?e.match:[e.match]).map(e=>u(e,{basename:!e.includes(`/`)}));return{isMatch:e=>t.some(t=>t(e)),strategy:e.strategy}});return e=>{for(let{isMatch:n,strategy:r}of t)if(n(e))return r}}async function f(e,s){let u=e.config?.rules??[],f=e.config?.defaultStrategy??`ai`,g=d(u),_=await t(e.git,{signal:e.signal}),v=l(e.config?.maxModelCalls),y=v.wrap(e.model),b={...s??{}},x=[],S=[],C=[];for(let t of _){if(e.signal?.aborted)break;let r=g(t.path)??f;if(r===`skip`){C.push({filePath:t.path,reason:`excluded`}),e.onProgress?.({type:`conflict:excluded`,filePath:t.path});continue}let s;try{s=await n(t.path,{git:e.git,signal:e.signal},t.reason)}catch(e){S.push({filePath:t.path,error:m(e)});continue}if(!s){C.push({filePath:t.path,reason:`no-markers`}),e.onProgress?.({type:`conflict:skipped`,filePath:t.path,reason:`no-markers`,entryReason:t.reason});continue}e.onProgress?.({type:`conflict:found`,filePath:s.filePath,conflictType:s.type,markerCount:s.markers.length});let c=null;c=r===`take-ours`?a(s):r===`take-theirs`?o(s):r===`deleted`?i(s,[],{confidence:1,fileStrategy:`deleted`}):await p(s,b,{...e,model:y},S),c&&(b.previousResolutions=[...b.previousResolutions??[],c],x.push(c),e.onProgress?.({type:`resolution:applied`,filePath:c.filePath,strategy:c.strategy,confidence:c.confidence,description:c.description}))}let w;if(e.config?.fix&&e.parser)try{w=await c(x,b,{model:y,git:e.git,parser:e.parser,validator:e.validator,config:e.config,onProgress:e.onProgress,signal:e.signal,budget:v})}catch(t){if(e.signal?.aborted)throw t;e.onProgress?.({type:`fix:failed`,reason:m(t).message}),w=await r(x,b,{git:e.git,parser:e.parser,signal:e.signal}),w={...w,checks:{referenceIntegrity:`errored`,structural:`errored`}}}else w=await r(x,b,{git:e.git,parser:e.parser,signal:e.signal});let T=x.flatMap(e=>e.findings??[]);return{resolutions:x,errors:S,skipped:C,detection:{...w,findings:h([...w.findings,...T])}}}async function p(t,n,r,i){try{return await s(t,n,{model:r.model,git:r.git,config:r.config,onProgress:r.onProgress,signal:r.signal,validator:r.validator})}catch(n){let s=m(n);if(r.signal?.aborted)throw s;let c=r.config?.fallbackStrategy;return c?(r.onProgress?.({type:`resolution:fallback`,filePath:t.filePath,error:s,cause:s instanceof e?s.code:void 0}),{...c===`take-ours`?a(t):o(t),confidence:0}):(i.push({filePath:t.filePath,error:s}),r.onProgress?.({type:`resolution:failed`,filePath:t.filePath,error:s}),null)}}function m(e){return e instanceof Error?e:Error(String(e))}function h(e){let t=new Set,n=[];for(let r of e){let{category:e,location:i}=r,a=`${e}\u0000${i.file}\u0000${i.line}\u0000${i.symbol??``}`;t.has(a)||(t.add(a),n.push(r))}return n}export{f as resolveConflicts};
@@ -5,7 +5,7 @@ import { OperationDigest, OperationSummary } from "../types/summary.js";
5
5
  //#region src/resolver/summarize-operation.d.ts
6
6
  /**
7
7
  * Options for {@link summarizeOperation}. Intentionally a flat bag rather than the resolver's
8
- * `ResolverDeps` shape: summarization is a leaf operation that needs no git, verifier, or progress
8
+ * `ResolverDeps` shape: summarization is a leaf operation that needs no git, validator, or progress
9
9
  * sink, and its `model` is optional — omit it for a digest-only result.
10
10
  */
11
11
  interface SummarizeOperationOptions {
@@ -16,7 +16,8 @@ interface SummarizeOperationOptions {
16
16
  reviewThreshold?: number;
17
17
  temperature?: number;
18
18
  }
19
- /** Pure, model-free rollup of a {@link StepResult}. */
19
+ /** Pure, model-free rollup of a {@link StepResult}, folding in `detection` so a file with an error
20
+ * finding — or a detection check that did not run — can never read as clean. */
20
21
  declare function buildOperationDigest(result: StepResult, reviewThreshold?: number): OperationDigest;
21
22
  /**
22
23
  * Summarize a resolve operation for a reviewer. Always returns a deterministic `digest`; when
@@ -1 +1 @@
1
- import{buildSummaryPrompt as e}from"./prompt.js";const t=.8;function n(e,n=t){let r={ai:0,"take-ours":0,"take-theirs":0,deleted:0,skipped:0},i=0,a=null,o=[];for(let t of e.resolutions)r[t.strategy]++,i+=t.chunks?.length??0,a=a===null?t.confidence:Math.min(a,t.confidence),t.confidence<n&&o.push({filePath:t.filePath,confidence:t.confidence});return o.sort((e,t)=>e.confidence-t.confidence),{files:e.resolutions.length,conflicts:i,byStrategy:r,minConfidence:a,needsReview:o,skipped:e.skipped?.length??0,errors:e.errors.length}}async function r(r,i={}){let a=n(r,i.reviewThreshold??t);if(!i.model)return{digest:a};let{system:o,userMessage:s}=e(r,a),c=await i.model.generate({system:o,messages:[{role:`user`,content:s}],temperature:i.temperature??0,signal:i.signal}),l=c.text?.trim();return{digest:a,...l?{prose:l}:{},...c.usage?{usage:c.usage}:{}}}export{n as buildOperationDigest,r as summarizeOperation};
1
+ import{buildSummaryPrompt as e}from"./prompt.js";const t=.8;function n(e,n=t){let o={ai:0,"take-ours":0,"take-theirs":0,deleted:0,skipped:0},s=0,c=null,l=new Map;for(let t of e.resolutions)o[t.strategy]++,s+=t.chunks?.length??0,c=c===null?t.confidence:Math.min(c,t.confidence),t.confidence<n&&l.set(t.filePath,{filePath:t.filePath,confidence:t.confidence,reason:`low-confidence`});let u=e.detection,d=u?.findings??[],f=new Map,p=!1;for(let e of d){e.severity===`error`&&(p=!0);let t=f.get(e.location.file)??new Set;t.add(e.category),f.set(e.location.file,t)}for(let[e,t]of f){let n=l.get(e);l.set(e,{filePath:e,...n?.confidence==null?{}:{confidence:n.confidence},reason:`finding`,findingCategories:[...t].sort()})}let m=[...l.values()].sort(i),h=u!=null&&r(u.checks.referenceIntegrity)&&r(u.checks.structural),g=a({hasErrorFinding:p,hasWarningFinding:d.some(e=>e.severity===`warning`),needsReview:m.length>0,detectionComplete:h,hasErrors:e.errors.length>0});return{files:e.resolutions.length,conflicts:s,byStrategy:o,minConfidence:c,needsReview:m,skipped:e.skipped?.length??0,errors:e.errors.length,detectionComplete:h,findingCount:d.length,verdict:g}}function r(e){return e===`ran`||e===`partial-unsupported-language`}function i(e,t){return e.reason===t.reason?(e.confidence??1)-(t.confidence??1):e.reason===`finding`?-1:1}function a(e){return e.hasErrorFinding?`blocked`:e.needsReview||!e.detectionComplete||e.hasErrors||e.hasWarningFinding?`needs-review`:`clean`}async function o(r,i={}){let a=n(r,i.reviewThreshold??t);if(!i.model)return{digest:a};let{system:o,userMessage:s}=e(r,a),c=await i.model.generate({system:o,messages:[{role:`user`,content:s}],temperature:i.temperature??0,signal:i.signal}),l=c.text?.trim();return{digest:a,...l?{prose:l}:{},...c.usage?{usage:c.usage}:{}}}export{n as buildOperationDigest,o as summarizeOperation};
@@ -1,6 +1,6 @@
1
- import { Issue } from "../types/issue.js";
2
1
  import { ResolvedChunk } from "../types/resolution.js";
3
2
  import { Conflict } from "../types/conflict.js";
3
+ import { Issue } from "../types/issue.js";
4
4
 
5
5
  //#region src/resolver/validate.d.ts
6
6
  interface ValidationResult {
@@ -1,4 +1,3 @@
1
- import { Issue } from "./issue.js";
2
1
  import { ResolutionStrategy, ResolvedChunk } from "./resolution.js";
3
2
  import { ConflictType } from "./conflict.js";
4
3
  import { AiTokenUsage } from "@gitkraken/shared-tools";
@@ -91,14 +90,35 @@ type ConflictProgressEvent = {
91
90
  type: 'refine:failed';
92
91
  filePath: string;
93
92
  reason: string;
93
+ } | {
94
+ type: 'refine:bailed';
95
+ filePath: string;
96
+ reason: string;
94
97
  } | {
95
98
  type: 'refine:touched-user-region';
96
99
  filePath: string;
97
100
  markerIndexes: number[];
98
101
  } | {
99
- type: 'verification:issues';
102
+ type: 'fix:started';
100
103
  filePath: string;
101
- issues: Issue[];
104
+ findingCount: number;
105
+ } | {
106
+ type: 'fix:completed';
107
+ filePath: string;
108
+ iterations: number;
109
+ remaining: number;
110
+ } | {
111
+ type: 'fix:failed';
112
+ filePath?: string;
113
+ reason: string;
114
+ } | {
115
+ type: 'fix:budget-exhausted';
116
+ spent: number;
117
+ limit: number;
118
+ } | {
119
+ type: 'validation:failed';
120
+ filePath: string;
121
+ reason: string;
102
122
  } | {
103
123
  type: 'resolver:tool-result';
104
124
  filePath: string;
@@ -1,8 +1,13 @@
1
1
  //#region src/types/findings.d.ts
2
+ /** The package's own detection categories. `Finding.category` is an open `string` so consumer
3
+ * validators can contribute their own kinds (e.g. `'type-error'`, `'lint:no-console'`); these are the
4
+ * well-known values the package itself emits. */
2
5
  type FindingCategory = 'broken-reference' | 'duplicate-definition' | 'syntax-error' | 'dropped-side';
3
6
  type FindingTier = 'file' | 'project';
4
7
  interface Finding {
5
- category: FindingCategory;
8
+ /** Finding kind. Open string — the package emits {@link FindingCategory} values; a consumer
9
+ * validator may use its own category strings. */
10
+ category: string;
6
11
  severity: 'error' | 'warning';
7
12
  tier: FindingTier;
8
13
  location: {
@@ -19,19 +24,22 @@ interface Finding {
19
24
  }
20
25
  /** Why a check produced its result — lets a consumer tell "ran, clean" apart from "did not run".
21
26
  * `errored` means the parser was present but threw; `partial-unsupported-language` means some files
22
- * in scope could not be parsed. */
23
- type CheckStatus = 'ran' | 'skipped-no-parser' | 'skipped-no-refs' | 'partial-unsupported-language' | 'errored';
27
+ * in scope could not be parsed; `skipped-unreadable-source` means the parent snapshots came back
28
+ * empty (unreadable source), so the before/after comparison had nothing to compare against. */
29
+ type CheckStatus = 'ran' | 'skipped-no-parser' | 'skipped-no-refs' | 'skipped-unreadable-source' | 'partial-unsupported-language' | 'errored';
24
30
  interface DetectionReport {
25
31
  findings: Finding[];
26
32
  checks: {
27
33
  referenceIntegrity: CheckStatus;
28
34
  structural: CheckStatus;
29
35
  };
30
- /** How many files were analyzed and whether the scope was capped — an honest coverage signal so
31
- * empty findings on a truncated scope are not read as "clean". Present only when the reference
32
- * check ran (refs available). */
36
+ /** Honest coverage signal so empty findings are not read as "clean": `analyzed` is the number of
37
+ * files in scope, `readable` how many had loadable parent content (0 means the before/after
38
+ * comparison had nothing to compare), and `truncated` whether the scope was capped. Present only
39
+ * when the reference check ran (refs available). */
33
40
  scope?: {
34
41
  analyzed: number;
42
+ readable: number;
35
43
  truncated: boolean;
36
44
  };
37
45
  }
@@ -1,5 +1,5 @@
1
+ import { Finding } from "./findings.js";
1
2
  import { IntraFileEditOp } from "./hints.js";
2
- import { Issue } from "./issue.js";
3
3
  import { ConflictType } from "./conflict.js";
4
4
  import { AiTokenUsage } from "@gitkraken/shared-tools";
5
5
 
@@ -26,7 +26,9 @@ interface Resolution {
26
26
  confidence: number;
27
27
  description: string;
28
28
  note?: string;
29
- issues?: Issue[];
29
+ /** Non-blocking per-file findings from the consumer's validator port (post-refine). Folded into
30
+ * the operation's detection findings, hence into the verdict. */
31
+ findings?: Finding[];
30
32
  chunks?: ResolvedChunk[];
31
33
  edits?: IntraFileEditOp[];
32
34
  metrics?: ResolutionMetrics;
@@ -74,6 +76,11 @@ interface ResolutionContext {
74
76
  * When provided, it overrides auto-discovery and defines the incoming detection scope. */
75
77
  touchedFiles?: string[];
76
78
  refs?: ResolutionRefs;
79
+ /** Parent tips for reference-integrity detection's before/after comparison, when they differ from
80
+ * `refs`. `refs` is the resolver's three-way source (in a rebase, the in-flight per-step state);
81
+ * detection needs the two stable pre-merge branch tips. In a merge they coincide, so this is
82
+ * usually left unset and detection falls back to `refs`. */
83
+ detectionRefs?: ResolutionRefs;
77
84
  threeWayDiff?: ThreeWayDiff;
78
85
  /** Free-text guidance from the user that applies to the whole file ("prefer logging across the
79
86
  * board; throw on invalid input"). Rendered into the prompt as a top-level `<user-guidance>`
@@ -1,6 +1,6 @@
1
+ import { DetectionReport } from "./findings.js";
1
2
  import { Resolution } from "./resolution.js";
2
3
  import { ResolverConfig } from "../ports/model.js";
3
- import { DetectionReport } from "./findings.js";
4
4
 
5
5
  //#region src/types/step.d.ts
6
6
  interface StepResult {
@@ -23,6 +23,16 @@ interface StepConfig extends ResolverConfig {
23
23
  defaultStrategy?: 'ai' | 'take-ours' | 'take-theirs' | 'skip' | 'deleted';
24
24
  rules?: FileRule[];
25
25
  fallbackStrategy?: 'take-ours' | 'take-theirs';
26
+ /** Opt in to the fix phase (default `false` → report-only). When enabled, an AI-resolved file carrying
27
+ * error-severity per-file findings is routed back through refine to attempt a fix; what it cannot fix
28
+ * remains in the findings. Requires `ResolverDeps.parser` — without a parser the fix phase is a no-op
29
+ * and the report-only detection path runs instead. Off by default so the report-only path is unchanged. */
30
+ fix?: boolean;
31
+ /** Max per-file fix iterations (refine → re-detect the file) when `fix` is on. Default 3. */
32
+ fixLoopMaxIterations?: number;
33
+ /** Max cross-file fix rounds (route project-level breaks to their use-site file → re-detect the whole
34
+ * set) when `fix` is on. Default 2. */
35
+ crossFileMaxIterations?: number;
26
36
  }
27
37
  //#endregion
28
38
  export { FileRule, StepConfig, StepResult };
@@ -2,11 +2,21 @@ import { ResolutionStrategy } from "./resolution.js";
2
2
  import { AiTokenUsage } from "@gitkraken/shared-tools";
3
3
 
4
4
  //#region src/types/summary.d.ts
5
- /** A resolved file flagged for human review because its file confidence is below the threshold. */
5
+ /** A file flagged for human review. `reason` distinguishes a low file-confidence flag from a
6
+ * detection finding; `confidence` is absent for files flagged purely by a finding (e.g. an
7
+ * auto-merged file with no resolution of its own). */
6
8
  interface ReviewItem {
7
9
  filePath: string;
8
- confidence: number;
10
+ confidence?: number;
11
+ reason: 'low-confidence' | 'finding';
12
+ /** Categories of the error-severity findings on this file; present when `reason` is `'finding'`. */
13
+ findingCategories?: string[];
9
14
  }
15
+ /** Suggested honest verdict for the operation. `blocked` when any error-severity finding exists;
16
+ * `needs-review` when confidence is low, detection did not fully run, resolution errored, or a
17
+ * warning finding exists; `clean` only when none of those apply. Advisory — the consumer owns its
18
+ * own gate, but this must not read as clean when the build is known-broken. */
19
+ type OperationVerdict = 'clean' | 'needs-review' | 'blocked';
10
20
  /** Deterministic, model-free rollup of a resolve operation. */
11
21
  interface OperationDigest {
12
22
  /** Files that produced a resolution. */
@@ -17,12 +27,21 @@ interface OperationDigest {
17
27
  byStrategy: Record<ResolutionStrategy, number>;
18
28
  /** Lowest file confidence across all resolutions; null when there are none. */
19
29
  minConfidence: number | null;
20
- /** Resolutions below the review threshold, lowest confidence first. */
30
+ /** Files needing review — low-confidence resolutions and files with error findings, findings
31
+ * first, then lowest confidence first. */
21
32
  needsReview: ReviewItem[];
22
33
  /** Files skipped by rule or because they had no markers. */
23
34
  skipped: number;
24
35
  /** Files that errored during resolution. */
25
36
  errors: number;
37
+ /** Whether reference-integrity detection ran to completion (every check `ran`). `false` when no
38
+ * detection was attached or any check was skipped/errored — so empty findings must NOT be read
39
+ * as "clean". */
40
+ detectionComplete: boolean;
41
+ /** Total detection findings across the operation. */
42
+ findingCount: number;
43
+ /** Suggested honest verdict; see {@link OperationVerdict}. */
44
+ verdict: OperationVerdict;
26
45
  }
27
46
  /** Result of summarizing an operation: always a digest, plus a narrative when a model was provided. */
28
47
  interface OperationSummary {
@@ -33,4 +52,4 @@ interface OperationSummary {
33
52
  usage?: AiTokenUsage;
34
53
  }
35
54
  //#endregion
36
- export { OperationDigest, OperationSummary, ReviewItem };
55
+ export { OperationDigest, OperationSummary, OperationVerdict, ReviewItem };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gitkraken/conflict-tools",
3
- "version": "0.2.4-beta.2",
3
+ "version": "0.2.4-beta.20",
4
4
  "description": "AI-powered git conflict resolution.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Axosoft, LLC dba GitKraken",