pi-workflow-engine 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +75 -0
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +90 -0
  3. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +13 -1
  4. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +16 -2
  5. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +12 -6
  6. package/.pi/extensions/pi-workflow-engine/src/journal.ts +1 -0
  7. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +53 -3
  8. package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +0 -25
  9. package/.pi/extensions/pi-workflow-engine/src/review/patch-validation.ts +120 -0
  10. package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +43 -7
  11. package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +2 -1
  12. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +1 -1
  13. package/.pi/extensions/pi-workflow-engine/src/types.ts +3 -0
  14. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +5 -2
  15. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +131 -178
  16. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +7 -0
  17. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +55 -128
  18. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +45 -139
  19. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +25 -67
  20. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +25 -67
  21. package/USAGE.md +59 -0
  22. package/package.json +1 -1
@@ -55,9 +55,10 @@ export function renderIssueDetails(issue: ReviewIssue, theme: Theme): string {
55
55
 
56
56
  export function renderIssueDetailLines(issue: ReviewIssue, theme: Theme, width: number): string[] {
57
57
  const finding = issue.finding;
58
- const metadata = `${finding.category} · severity ${finding.severity} · confidence ${finding.confidence}`;
58
+ const metadata = `${finding.category} · severity ${finding.severity} · confidence ${finding.confidence}${finding.verdict ? ` · ${finding.verdict}` : ""}`;
59
59
  const lines = [`${theme.fg("accent", issue.id)} ${theme.fg("text", finding.summary)}`];
60
60
  lines.push(...fieldLines("Metadata", metadata, width, theme));
61
+ if (finding.sourceCandidateIds?.length) lines.push(...fieldLines("Sources", finding.sourceCandidateIds.join(", "), width, theme));
61
62
  lines.push(...fieldLines("Location", formatIssueLocation(issue), width, theme, "accent"));
62
63
  lines.push(...fieldLines("Impact", finding.impact, width, theme));
63
64
  lines.push(...fieldLines("Evidence", finding.evidence.join("; ") || "(none cited)", width, theme));
@@ -37,7 +37,7 @@ export interface SerializedReviewIssue {
37
37
 
38
38
  export function toReviewIssues(name: string, report: Pick<AdvisoryReport, "findings">): ReviewIssue[] {
39
39
  return report.findings.map((finding, index) => {
40
- const location = finding.locations[0];
40
+ const location = finding.reviewAnchor ?? finding.locations[0];
41
41
  return {
42
42
  id: formatIssueId(index),
43
43
  index,
@@ -98,6 +98,7 @@ export type AgentToolHint = "search" | "external-search";
98
98
  export type AgentResumePolicy = "read-only" | "off";
99
99
 
100
100
  export interface IsolatedAgentResult<T> {
101
+ readonly baselineOid?: string;
101
102
  readonly result: T;
102
103
  readonly patch: string;
103
104
  readonly changed: boolean;
@@ -152,6 +153,8 @@ export interface AgentOptions<S extends TSchema = TSchema> {
152
153
  resumeInputs?: readonly string[];
153
154
  /** Run this agent in a disposable git worktree and return its patch with the result. */
154
155
  isolation?: "worktree";
156
+ /** Apply a captured candidate to a fresh isolated baseline before evaluation. */
157
+ candidatePatch?: { readonly baselineOid: string; readonly patch: string };
155
158
  /** Allowlist of concrete tool names the agent may use (e.g. ["read", "bash"]). */
156
159
  tools?: string[];
157
160
  /**
@@ -62,15 +62,18 @@ function renderAdvisoryResult(
62
62
  metadata?: WorkflowRunDisplayMetadata,
63
63
  perf?: WorkflowPerfDetails,
64
64
  ): string {
65
- const icon = theme.fg("success", "✓");
65
+ const incomplete = result.status === "incomplete";
66
+ const icon = incomplete ? theme.fg("warning", "⚠") : theme.fg("success", "✓");
66
67
  const title = theme.fg("accent", theme.bold(`Workflow: ${name}`));
67
68
  const lines = [`${icon} ${title}`, theme.fg("muted", result.summary)];
69
+ if (result.coverage?.length) lines.push(theme.fg("dim", result.coverage.map((stage) => `${stage.stage}: ${stage.completed}/${stage.expected} complete, ${stage.failed} failed`).join(" · ")));
70
+ if (expanded) for (const gap of result.gaps ?? []) lines.push(theme.fg("warning", gap));
68
71
  const stats = statsLine(result.stats, theme);
69
72
  if (stats) lines.push(stats);
70
73
  pushWorkflowDetailLines(lines, theme, { usage, metadata, perf });
71
74
 
72
75
  if (result.findings.length === 0) {
73
- lines.push(theme.fg("success", "No findings."));
76
+ lines.push(incomplete ? theme.fg("warning", "No verified findings; coverage is incomplete.") : theme.fg("success", "No findings."));
74
77
  if (expanded && result.nextSteps.length > 0) renderNextSteps(result.nextSteps, lines, theme);
75
78
  return lines.join("\n");
76
79
  }
@@ -1,5 +1,5 @@
1
- import { AdvisoryCandidatesSchema, AdvisoryVerdictSchema, type AdvisoryCandidate, type AdvisoryFinding, type AdvisoryLocation, type AdvisoryReport, type AdvisoryVerdict } from "./advisory-schema.ts";
2
- import { compactResults } from "./concurrency.ts";
1
+ import { AdvisoryCandidatesSchema, AdvisoryVerdictSchema, type AdvisoryCandidate, type IdentifiedAdvisoryCandidate, type AdvisoryLocation, type AdvisoryReport, type AdvisoryVerdict } from "./advisory-schema.ts";
2
+ import { AdvisorySynthesisSchema, SYNTHESIS_ID_INSTRUCTIONS, withAdvisoryCoverage, collectAdvisoryStage, dedupeCandidates, identifyCandidates, uniqueLocations, type AdvisoryStageCoverage, type AdvisorySynthesis } from "./advisory-evidence.ts";
3
3
  import type { AgentOptions, WorkflowApi, WorkflowProgressEvent, WorkflowRunStats } from "./types.ts";
4
4
 
5
5
  export interface AdvisoryLens {
@@ -8,17 +8,19 @@ export interface AdvisoryLens {
8
8
  text: string;
9
9
  }
10
10
 
11
- export type AdvisoryVerified<Candidate extends AdvisoryCandidate = AdvisoryCandidate> = Candidate & {
11
+ export type AdvisoryVerified = IdentifiedAdvisoryCandidate & {
12
12
  verdict: AdvisoryVerdict["verdict"];
13
13
  evidence: string[];
14
14
  confidence?: AdvisoryVerdict["confidence"];
15
+ challenge?: import("./advisory-challenge.ts").ChallengeRecord;
15
16
  };
16
17
 
17
- export interface LensVerificationPipelineResult<Verified> {
18
- verified: Verified[];
18
+ export interface LensVerificationPipelineResult {
19
+ verified: AdvisoryVerified[];
19
20
  rawCandidates: number;
20
21
  dropped: number;
21
22
  refuted: number;
23
+ coverage: AdvisoryStageCoverage[];
22
24
  }
23
25
 
24
26
  /** Default concrete read/inspect tools for advisory workflows. */
@@ -47,20 +49,14 @@ export function publishVerifiedKeptProgress(
47
49
  api.log(`${verified} verified → ${kept} kept`);
48
50
  }
49
51
 
50
- export type AdvisorySchedulingMode = "pipeline" | "finder-barrier";
51
-
52
- export interface LensVerificationPipelineOptions<Lens extends AdvisoryLens, Verified extends AdvisoryVerified> {
53
- api: Pick<WorkflowApi, "agent" | "parallel" | "pipeline" | "progress" | "log">;
54
- lenses: readonly Lens[];
52
+ export interface LensVerificationPipelineOptions {
53
+ api: Pick<WorkflowApi, "agent" | "parallel" | "phase" | "progress" | "log">;
54
+ lenses: readonly AdvisoryLens[];
55
55
  perLens: number;
56
- tools?: AgentOptions["tools"];
57
- toolHints?: AgentOptions["toolHints"];
58
- finderPhase?: string;
59
- verifierPhase?: string;
60
- schedulingMode?: AdvisorySchedulingMode;
61
- finderPrompt(lens: Lens): string;
62
- verifierPrompt(candidate: AdvisoryCandidate): string;
63
- makeVerified(candidate: AdvisoryCandidate, lens: Lens, verdict: AdvisoryVerdict): Verified;
56
+ finderPhase?: "Find" | "Hypothesize";
57
+ finderPrompt(lens: AdvisoryLens): string;
58
+ verifierPrompt(candidate: IdentifiedAdvisoryCandidate): string;
59
+ boundCandidate?(candidate: IdentifiedAdvisoryCandidate, lens: AdvisoryLens): IdentifiedAdvisoryCandidate | undefined;
64
60
  }
65
61
 
66
62
  export interface AdvisoryBackfillDefaults {
@@ -68,117 +64,77 @@ export interface AdvisoryBackfillDefaults {
68
64
  recommendation?: string;
69
65
  }
70
66
 
71
- interface FoundForLens<Lens> {
72
- lens: Lens;
73
- candidates: AdvisoryCandidate[];
74
- }
75
-
76
- interface NovelCandidate<Lens> {
77
- lens: Lens;
78
- candidate: AdvisoryCandidate;
79
- }
80
-
81
- export async function runLensVerificationPipeline<Lens extends AdvisoryLens, Verified extends AdvisoryVerified>(
82
- options: LensVerificationPipelineOptions<Lens, Verified>,
83
- ): Promise<LensVerificationPipelineResult<Verified>> {
84
- const {
85
- api,
86
- lenses,
87
- perLens,
88
- tools = DEFAULT_ADVISORY_TOOLS,
89
- toolHints = DEFAULT_ADVISORY_TOOL_HINTS,
90
- finderPhase = "Find",
91
- verifierPhase = "Verify",
92
- schedulingMode = "pipeline",
93
- finderPrompt,
94
- verifierPrompt,
95
- makeVerified,
96
- } = options;
97
- const seen = new Set<string>();
67
+ /** Finish all discovery before verification competes for the shared agent limit. */
68
+ export async function runLensVerificationPipeline(
69
+ options: LensVerificationPipelineOptions,
70
+ ): Promise<LensVerificationPipelineResult> {
71
+ const { api, lenses, perLens, finderPhase = "Find", finderPrompt, verifierPrompt, boundCandidate } = options;
72
+ const coverage: AdvisoryStageCoverage[] = [];
98
73
  let rawCandidates = 0;
99
- let dropped = 0;
100
74
  let refuted = 0;
101
-
102
- const findForLens = async (lens: Lens): Promise<FoundForLens<Lens>> => {
103
- const found = await api.agent(finderPrompt(lens), {
104
- phase: finderPhase,
105
- label: `find:${lens.label}`,
106
- tools,
107
- toolHints,
108
- profile: "small",
109
- schema: AdvisoryCandidatesSchema,
75
+ api.phase(finderPhase);
76
+ const found = await collectAdvisoryStage(api, "Find", lenses.map((lens) => ({ id: lens.label, run: async () => {
77
+ const result = await api.agent(finderPrompt(lens), {
78
+ phase: finderPhase, label: `${finderPhase.toLowerCase()}:${lens.label}`,
79
+ tools: DEFAULT_ADVISORY_TOOLS, toolHints: DEFAULT_ADVISORY_TOOL_HINTS,
80
+ profile: "small", schema: AdvisoryCandidatesSchema,
110
81
  });
111
- const candidates = (found?.candidates ?? []).slice(0, perLens);
112
- rawCandidates += candidates.length;
113
- api.progress({ type: "counter_delta", key: "candidates", label: "candidates", delta: candidates.length });
82
+ const raw = identifyCandidates(result.candidates.slice(0, perLens), lens.label);
83
+ rawCandidates += raw.length;
84
+ api.progress({ type: "counter_delta", key: "candidates", label: "candidates", delta: raw.length });
85
+ const candidates = boundCandidate ? raw.flatMap((candidate) => {
86
+ const bounded = boundCandidate(candidate, lens);
87
+ return bounded ? [bounded] : [];
88
+ }) : raw;
114
89
  for (const candidate of candidates) {
115
- api.progress({
116
- type: "lane_item",
117
- lane: "Candidates",
118
- title: candidate.summary,
119
- subtitle: formatLocation(candidate),
120
- status: "pending",
121
- details: candidate.impact,
122
- });
90
+ api.progress({ type: "lane_item", lane: finderPhase === "Hypothesize" ? "Hypotheses" : "Candidates",
91
+ title: candidate.summary, subtitle: formatLocation(candidate), status: "pending", details: candidate.impact });
123
92
  }
124
- return { lens, candidates };
125
- };
126
-
127
- const dedupeFound = (found: FoundForLens<Lens>): NovelCandidate<Lens>[] => {
128
- const novel = candidatesNovelToRun(found.candidates, seen).map((candidate) => ({ lens: found.lens, candidate }));
129
- const droppedForLens = found.candidates.length - novel.length;
130
- if (droppedForLens > 0) {
131
- dropped += droppedForLens;
132
- api.progress({ type: "counter_delta", key: "dropped", label: "dropped", delta: droppedForLens });
133
- api.log(`find:${found.lens.label}: dropped ${droppedForLens} duplicate candidate(s)`);
134
- }
135
- return novel;
136
- };
137
-
138
- const verifyCandidate = async ({ lens, candidate }: NovelCandidate<Lens>): Promise<Verified | null> => {
139
- const location = primaryLocation(candidate);
140
- const judged = await api.agent(verifierPrompt(candidate), {
141
- phase: verifierPhase,
142
- label: `verify:${location.file.split("/").pop() ?? location.file}`,
143
- tools,
144
- toolHints,
145
- profile: "small",
146
- schema: AdvisoryVerdictSchema,
147
- });
148
- if (!judged) return null;
149
- recordVerdictProgress(api.progress, candidate, judged, () => {
150
- refuted += 1;
151
- });
152
- return makeVerified(candidate, lens, judged);
153
- };
154
-
155
- if (schedulingMode === "finder-barrier") {
156
- const found = await api.parallel(lenses.map((lens) => async () => findForLens(lens)));
157
- const novel = compactResults(found).flatMap(dedupeFound);
158
- const verdicts = await api.parallel(novel.map((entry) => async () => verifyCandidate(entry)));
159
- return { verified: compactResults(verdicts), rawCandidates, dropped, refuted };
93
+ return candidates;
94
+ } })), coverage);
95
+ const candidates = dedupeCandidates(found.flat());
96
+ const dropped = rawCandidates - candidates.length;
97
+ if (dropped > 0) {
98
+ api.progress({ type: "counter_delta", key: "dropped", label: "dropped", delta: dropped });
99
+ api.log(`Dropped ${dropped} duplicate or out-of-scope candidate(s)`);
160
100
  }
161
-
162
- const perLensVerified = await api.pipeline(
163
- lenses,
164
- async (_prev, lens) => findForLens(lens),
165
- async (found) => {
166
- const novel = dedupeFound(found);
167
- const verdicts = await api.parallel(novel.map((entry) => async () => verifyCandidate(entry)));
168
- return compactResults(verdicts);
101
+ api.phase("Verify");
102
+ const verified = await collectAdvisoryStage(api, "Verify", candidates.map((candidate) => ({
103
+ id: candidate.candidateId, candidate, run: async (): Promise<AdvisoryVerified> => {
104
+ const location = primaryLocation(candidate);
105
+ const judged = await api.agent(`${verifierPrompt(candidate)}\nCandidate record: ${JSON.stringify(candidate)}`, {
106
+ phase: "Verify", label: `verify:${location.file.split("/").pop() ?? location.file}`,
107
+ tools: DEFAULT_ADVISORY_TOOLS, toolHints: DEFAULT_ADVISORY_TOOL_HINTS,
108
+ profile: "small", schema: AdvisoryVerdictSchema,
109
+ });
110
+ recordVerdictProgress(api.progress, candidate, judged, () => { refuted += 1; });
111
+ return { ...candidate, verdict: judged.verdict, evidence: judged.evidence, confidence: judged.confidence };
169
112
  },
170
- );
113
+ })), coverage);
114
+ return { verified, rawCandidates, dropped, refuted, coverage };
115
+ }
171
116
 
172
- return { verified: compactResults(perLensVerified).flat(), rawCandidates, dropped, refuted };
117
+ export async function synthesizeAdvisoryReport(
118
+ api: Pick<WorkflowApi, "agent" | "parallel" | "phase">,
119
+ prompt: string,
120
+ ranked: readonly AdvisoryVerified[],
121
+ coverage: AdvisoryStageCoverage[],
122
+ ): Promise<AdvisoryReport> {
123
+ api.phase("Synthesize");
124
+ const [report] = await collectAdvisoryStage(api, "Synthesize", [{ id: "synthesize", run: () => api.agent(
125
+ SYNTHESIS_ID_INSTRUCTIONS + prompt,
126
+ { phase: "Synthesize", label: "synthesize", tools: [], profile: "medium", resume: "read-only", schema: AdvisorySynthesisSchema },
127
+ ) }], coverage);
128
+ return resolveAdvisorySynthesis(report, ranked, {
129
+ impact: "Impact not restated by verification.",
130
+ recommendation: "Inspect the cited evidence and validate the smallest repair.",
131
+ }, coverage);
173
132
  }
174
133
 
175
- function candidatesNovelToRun(candidates: readonly AdvisoryCandidate[], seen: Set<string>): AdvisoryCandidate[] {
176
- return candidates.filter((candidate) => {
177
- const key = advisoryDedupKey(candidate);
178
- if (seen.has(key)) return false;
179
- seen.add(key);
180
- return true;
181
- });
134
+ export function finishAdvisoryReport<T extends AdvisoryReport>(
135
+ report: T, coverage: AdvisoryStageCoverage[], verification: AdvisoryVerified[] = [],
136
+ ) {
137
+ return { ...withAdvisoryCoverage(report, coverage), verification };
182
138
  }
183
139
 
184
140
  export function primaryLocation(candidate: Pick<AdvisoryCandidate, "locations">): AdvisoryLocation {
@@ -200,52 +156,17 @@ export function normalizePath(path: string): string {
200
156
  return path.replace(/^\.\//, "").replace(/^[ab]\//, "");
201
157
  }
202
158
 
203
- export function advisoryDedupKey(candidate: AdvisoryCandidate): string {
204
- const location = primaryLocation(candidate);
205
- const lineKey = location.line != null ? Math.round(location.line / 5) * 5 : "file";
206
- return `${candidate.category}:${normalizePath(location.file)}:${lineKey}:${candidate.summary.slice(0, 60).toLowerCase()}`;
207
- }
208
-
209
- export function verdictLane(verdict: AdvisoryVerdict["verdict"]): string {
210
- switch (verdict) {
211
- case "CONFIRMED":
212
- return "Confirmed";
213
- case "PLAUSIBLE":
214
- return "Plausible";
215
- case "REFUTED":
216
- return "Refuted";
217
- }
218
- }
219
-
220
- export function verdictStatus(verdict: AdvisoryVerdict["verdict"]): "success" | "warning" | "error" {
221
- switch (verdict) {
222
- case "CONFIRMED":
223
- return "success";
224
- case "PLAUSIBLE":
225
- return "warning";
226
- case "REFUTED":
227
- return "error";
228
- }
229
- }
159
+ const VERDICT_PRESENTATION = {
160
+ CONFIRMED: { lane: "Confirmed", status: "success", confidence: "high" },
161
+ PLAUSIBLE: { lane: "Plausible", status: "warning", confidence: "medium" },
162
+ NOT_SUBSTANTIATED: { lane: "Unresolved", status: "warning", confidence: "medium" },
163
+ REFUTED: { lane: "Refuted", status: "error", confidence: "low" },
164
+ } satisfies Record<AdvisoryVerdict["verdict"], {
165
+ lane: string; status: "success" | "warning" | "error"; confidence: NonNullable<AdvisoryVerdict["confidence"]>;
166
+ }>;
230
167
 
231
168
  export function verdictConfidence(verdict: AdvisoryVerdict["verdict"]): "high" | "medium" | "low" {
232
- switch (verdict) {
233
- case "CONFIRMED":
234
- return "high";
235
- case "PLAUSIBLE":
236
- return "medium";
237
- case "REFUTED":
238
- return "low";
239
- }
240
- }
241
-
242
- export function sameFinding(candidate: Pick<AdvisoryCandidate, "locations">, finding: Pick<AdvisoryFinding, "locations" | "summary">): boolean {
243
- return findingLocationKey(candidate) === findingLocationKey(finding);
244
- }
245
-
246
- export function findingLocationKey(value: Pick<AdvisoryCandidate, "locations"> | Pick<AdvisoryFinding, "locations">): string {
247
- const location = primaryLocation(value);
248
- return `${normalizePath(location.file)}:${location.line ?? "file"}`;
169
+ return VERDICT_PRESENTATION[verdict].confidence;
249
170
  }
250
171
 
251
172
  export function recordVerdictProgress(
@@ -261,32 +182,64 @@ export function recordVerdictProgress(
261
182
  }
262
183
  progress({
263
184
  type: "lane_item",
264
- lane: verdictLane(verdict.verdict),
185
+ lane: VERDICT_PRESENTATION[verdict.verdict].lane,
265
186
  title: candidate.summary,
266
187
  subtitle: formatLocation(candidate),
267
- status: verdictStatus(verdict.verdict),
188
+ status: VERDICT_PRESENTATION[verdict.verdict].status,
268
189
  details: formatEvidence(verdict.evidence),
269
190
  });
270
191
  }
271
192
 
272
193
  export function backfillAdvisoryFindings<Source extends AdvisoryVerified>(
273
- findings: AdvisoryReport["findings"],
194
+ findings: AdvisorySynthesis["findings"],
274
195
  ranked: readonly Source[],
275
196
  defaults: AdvisoryBackfillDefaults,
276
197
  ): AdvisoryReport["findings"] {
277
- const rankedByLocation = new Map<string, Source>();
278
- for (const candidate of ranked) {
279
- const key = findingLocationKey(candidate);
280
- if (!rankedByLocation.has(key)) rankedByLocation.set(key, candidate);
281
- }
198
+ const sources = new Map(ranked.flatMap((source) => source.sourceCandidateIds.map((id) => [id, source] as const)));
199
+ const used = new Set<string>();
200
+ return findings.flatMap((selection) => {
201
+ const ids = [...new Set(selection.sourceCandidateIds)];
202
+ if (ids.length === 0 || ids.some((id) => !sources.has(id) || used.has(id))) return [];
203
+ const records = [...new Set(ids.map((id) => sources.get(id)!))];
204
+ if (records.some((record) => record.verdict === "REFUTED")) return [];
205
+ const sourceCandidateIds = [...new Set(records.flatMap((record) => record.sourceCandidateIds))];
206
+ if (sourceCandidateIds.some((id) => used.has(id))) return [];
207
+ sourceCandidateIds.forEach((id) => used.add(id));
208
+ const first = records[0]!;
209
+ return [{
210
+ sourceCandidateIds,
211
+ summary: [...new Set(records.map((record) => record.summary))].join("; "),
212
+ category: first.category,
213
+ severity: selection.severity,
214
+ verdict: records.some((record) => record.verdict === "NOT_SUBSTANTIATED") ? "NOT_SUBSTANTIATED" as const : records.some((record) => record.verdict === "PLAUSIBLE") ? "PLAUSIBLE" as const : "CONFIRMED" as const,
215
+ confidence: records.every((record) => record.verdict === "CONFIRMED") ? "high" as const : "medium" as const,
216
+ locations: uniqueLocations(records.flatMap((record) => record.locations)),
217
+ ...(first.reviewAnchor ? { reviewAnchor: first.reviewAnchor } : {}),
218
+ evidence: [...new Set(records.flatMap((record) => [...(record.discoveryEvidence ?? []), ...record.evidence]))],
219
+ impact: [...new Set(records.map((record) => record.impact || defaults.impact))].join("\n"),
220
+ recommendation: selection.recommendation || first.recommendation || defaults.recommendation || "",
221
+ }];
222
+ });
223
+ }
282
224
 
283
- return findings.map((finding) => {
284
- const source = rankedByLocation.get(findingLocationKey(finding));
225
+ /** Retain verified records if synthesis fails, and expose invalid ID selections as a gap. */
226
+ export function resolveAdvisorySynthesis<Source extends AdvisoryVerified>(
227
+ report: AdvisorySynthesis | undefined,
228
+ ranked: readonly Source[],
229
+ defaults: AdvisoryBackfillDefaults,
230
+ coverage: AdvisoryStageCoverage[],
231
+ ): AdvisoryReport {
232
+ if (!report) {
285
233
  return {
286
- ...finding,
287
- evidence: finding.evidence.length > 0 ? finding.evidence : (source?.evidence ?? []),
288
- impact: finding.impact || source?.impact || defaults.impact,
289
- recommendation: finding.recommendation || source?.recommendation || defaults.recommendation || "",
234
+ summary: "Synthesis unavailable; verified records retained.",
235
+ findings: backfillAdvisoryFindings(ranked.map((finding) => ({ sourceCandidateIds: finding.sourceCandidateIds, severity: "medium", recommendation: finding.recommendation ?? defaults.recommendation ?? "Inspect verifier evidence." })), ranked, defaults),
236
+ nextSteps: ["Inspect verifier evidence or rerun synthesis."],
290
237
  };
291
- });
238
+ }
239
+ const findings = backfillAdvisoryFindings(report.findings, ranked, defaults);
240
+ if (findings.length !== report.findings.length) {
241
+ coverage.push({ stage: "Synthesis provenance", expected: report.findings.length, completed: findings.length,
242
+ failed: report.findings.length - findings.length, failures: [{ branch: "synthesize", reason: "Discarded invalid, repeated, or unverified candidate IDs." }] });
243
+ }
244
+ return { ...report, findings };
292
245
  }
@@ -154,6 +154,13 @@ export class WorktreeRegistry {
154
154
  });
155
155
  }
156
156
 
157
+ async applyPatch(path: string, patch: string, signal?: AbortSignal): Promise<WorktreeGitCommandResult> {
158
+ return this.runner.runGit({
159
+ cwd: path, args: ["apply", "--index", "--binary", "-"],
160
+ stdin: patch, signal, timeoutMs: this.timeoutMs,
161
+ });
162
+ }
163
+
157
164
  async validatePatch(path: string, candidate: WorktreePatch, signal?: AbortSignal): Promise<WorktreeGitCommandResult> {
158
165
  return await validateWorktreePatch({
159
166
  worktreePath: path,