pi-workflow-engine 0.11.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 (79) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +255 -97
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +75 -0
  3. package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +90 -0
  4. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +13 -5
  5. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +3 -1
  6. package/.pi/extensions/pi-workflow-engine/src/agent-limits.ts +35 -0
  7. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +27 -4
  8. package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +116 -0
  9. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +49 -45
  10. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +95 -26
  11. package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +50 -0
  12. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +158 -203
  13. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +48 -42
  14. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +26 -4
  15. package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +75 -0
  16. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +492 -0
  17. package/.pi/extensions/pi-workflow-engine/src/budget.ts +3 -3
  18. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +38 -0
  19. package/.pi/extensions/pi-workflow-engine/src/command-completions.ts +36 -0
  20. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +82 -10
  21. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +16 -130
  22. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +9 -5
  23. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +109 -60
  24. package/.pi/extensions/pi-workflow-engine/src/engine.ts +166 -18
  25. package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +4 -0
  26. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +2 -2
  27. package/.pi/extensions/pi-workflow-engine/src/journal.ts +13 -6
  28. package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +167 -0
  29. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +310 -0
  30. package/.pi/extensions/pi-workflow-engine/src/options.ts +88 -1
  31. package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +32 -0
  32. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +50 -0
  33. package/.pi/extensions/pi-workflow-engine/src/progress.ts +72 -117
  34. package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +189 -0
  35. package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +89 -0
  36. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +289 -0
  37. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +127 -41
  38. package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +24 -0
  39. package/.pi/extensions/pi-workflow-engine/src/review/patch-validation.ts +120 -0
  40. package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +43 -7
  41. package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +2 -1
  42. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +1 -1
  43. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +1 -1
  44. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +2 -1
  45. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +11 -30
  46. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +5 -5
  47. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +3 -4
  48. package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +130 -0
  49. package/.pi/extensions/pi-workflow-engine/src/session-identity.ts +6 -0
  50. package/.pi/extensions/pi-workflow-engine/src/structured-output.ts +32 -0
  51. package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +44 -0
  52. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +1 -4
  53. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +1 -4
  54. package/.pi/extensions/pi-workflow-engine/src/types.ts +52 -15
  55. package/.pi/extensions/pi-workflow-engine/src/ui/dynamax-editor-decoration.ts +220 -0
  56. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +26 -6
  57. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +88 -46
  58. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +7 -3
  59. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-viewer-layout.ts +51 -0
  60. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +12 -67
  61. package/.pi/extensions/pi-workflow-engine/src/usage.ts +124 -16
  62. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +151 -178
  63. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +1 -1
  64. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +72 -0
  65. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +354 -0
  66. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +162 -0
  67. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +632 -0
  68. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +172 -0
  69. package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +80 -0
  70. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +2 -0
  71. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +47 -3
  72. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +65 -162
  73. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +48 -149
  74. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +28 -77
  75. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +28 -77
  76. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +170 -0
  77. package/README.md +72 -141
  78. package/USAGE.md +270 -17
  79. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
- import { AdvisoryCandidatesSchema, AdvisoryVerdictSchema, type AdvisoryCandidate, type AdvisoryFinding, type AdvisoryLocation, type AdvisoryReport, type AdvisoryVerdict } from "./advisory-schema.ts";
2
- import { compactResults } from "./concurrency.ts";
3
- import type { AgentOptions, WorkflowApi, WorkflowProgressEvent } from "./types.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
+ import type { AgentOptions, WorkflowApi, WorkflowProgressEvent, WorkflowRunStats } from "./types.ts";
4
4
 
5
5
  export interface AdvisoryLens {
6
6
  label: string;
@@ -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. */
@@ -27,20 +29,34 @@ export const DEFAULT_ADVISORY_TOOLS: NonNullable<AgentOptions["tools"]> = ["read
27
29
  /** Dynamically include installed grep/find/search-like extension tools. */
28
30
  export const DEFAULT_ADVISORY_TOOL_HINTS: NonNullable<AgentOptions["toolHints"]> = ["search"];
29
31
 
30
- export type AdvisorySchedulingMode = "pipeline" | "finder-barrier";
32
+ export function emptyAdvisoryReport<Stats extends WorkflowRunStats>(
33
+ summary: string,
34
+ nextSteps: string[],
35
+ stats: Stats,
36
+ ): AdvisoryReport & { stats: Stats } {
37
+ return { summary, findings: [], nextSteps, stats };
38
+ }
39
+
40
+ export function publishVerifiedKeptProgress(
41
+ api: Pick<WorkflowApi, "progress" | "log">,
42
+ verified: number,
43
+ kept: number,
44
+ ): void {
45
+ api.progress({ type: "counter", key: "verified", label: "verified", value: verified });
46
+ api.progress({ type: "counter", key: "kept", label: "kept", value: kept });
47
+ api.progress({ type: "summary", key: "verified", value: verified });
48
+ api.progress({ type: "summary", key: "kept", value: kept });
49
+ api.log(`${verified} verified → ${kept} kept`);
50
+ }
31
51
 
32
- export interface LensVerificationPipelineOptions<Lens extends AdvisoryLens, Verified extends AdvisoryVerified> {
33
- api: Pick<WorkflowApi, "agent" | "parallel" | "pipeline" | "progress" | "log">;
34
- lenses: readonly Lens[];
52
+ export interface LensVerificationPipelineOptions {
53
+ api: Pick<WorkflowApi, "agent" | "parallel" | "phase" | "progress" | "log">;
54
+ lenses: readonly AdvisoryLens[];
35
55
  perLens: number;
36
- tools?: AgentOptions["tools"];
37
- toolHints?: AgentOptions["toolHints"];
38
- finderPhase?: string;
39
- verifierPhase?: string;
40
- schedulingMode?: AdvisorySchedulingMode;
41
- finderPrompt(lens: Lens): string;
42
- verifierPrompt(candidate: AdvisoryCandidate): string;
43
- 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;
44
60
  }
45
61
 
46
62
  export interface AdvisoryBackfillDefaults {
@@ -48,117 +64,77 @@ export interface AdvisoryBackfillDefaults {
48
64
  recommendation?: string;
49
65
  }
50
66
 
51
- interface FoundForLens<Lens> {
52
- lens: Lens;
53
- candidates: AdvisoryCandidate[];
54
- }
55
-
56
- interface NovelCandidate<Lens> {
57
- lens: Lens;
58
- candidate: AdvisoryCandidate;
59
- }
60
-
61
- export async function runLensVerificationPipeline<Lens extends AdvisoryLens, Verified extends AdvisoryVerified>(
62
- options: LensVerificationPipelineOptions<Lens, Verified>,
63
- ): Promise<LensVerificationPipelineResult<Verified>> {
64
- const {
65
- api,
66
- lenses,
67
- perLens,
68
- tools = DEFAULT_ADVISORY_TOOLS,
69
- toolHints = DEFAULT_ADVISORY_TOOL_HINTS,
70
- finderPhase = "Find",
71
- verifierPhase = "Verify",
72
- schedulingMode = "pipeline",
73
- finderPrompt,
74
- verifierPrompt,
75
- makeVerified,
76
- } = options;
77
- 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[] = [];
78
73
  let rawCandidates = 0;
79
- let dropped = 0;
80
74
  let refuted = 0;
81
-
82
- const findForLens = async (lens: Lens): Promise<FoundForLens<Lens>> => {
83
- const found = await api.agent(finderPrompt(lens), {
84
- phase: finderPhase,
85
- label: `find:${lens.label}`,
86
- tools,
87
- toolHints,
88
- thinkingLevel: "low",
89
- 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,
90
81
  });
91
- const candidates = (found?.candidates ?? []).slice(0, perLens);
92
- rawCandidates += candidates.length;
93
- 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;
94
89
  for (const candidate of candidates) {
95
- api.progress({
96
- type: "lane_item",
97
- lane: "Candidates",
98
- title: candidate.summary,
99
- subtitle: formatLocation(candidate),
100
- status: "pending",
101
- details: candidate.impact,
102
- });
90
+ api.progress({ type: "lane_item", lane: finderPhase === "Hypothesize" ? "Hypotheses" : "Candidates",
91
+ title: candidate.summary, subtitle: formatLocation(candidate), status: "pending", details: candidate.impact });
103
92
  }
104
- return { lens, candidates };
105
- };
106
-
107
- const dedupeFound = (found: FoundForLens<Lens>): NovelCandidate<Lens>[] => {
108
- const novel = candidatesNovelToRun(found.candidates, seen).map((candidate) => ({ lens: found.lens, candidate }));
109
- const droppedForLens = found.candidates.length - novel.length;
110
- if (droppedForLens > 0) {
111
- dropped += droppedForLens;
112
- api.progress({ type: "counter_delta", key: "dropped", label: "dropped", delta: droppedForLens });
113
- api.log(`find:${found.lens.label}: dropped ${droppedForLens} duplicate candidate(s)`);
114
- }
115
- return novel;
116
- };
117
-
118
- const verifyCandidate = async ({ lens, candidate }: NovelCandidate<Lens>): Promise<Verified | null> => {
119
- const location = primaryLocation(candidate);
120
- const judged = await api.agent(verifierPrompt(candidate), {
121
- phase: verifierPhase,
122
- label: `verify:${location.file.split("/").pop() ?? location.file}`,
123
- tools,
124
- toolHints,
125
- thinkingLevel: "low",
126
- schema: AdvisoryVerdictSchema,
127
- });
128
- if (!judged) return null;
129
- recordVerdictProgress(api.progress, candidate, judged, () => {
130
- refuted += 1;
131
- });
132
- return makeVerified(candidate, lens, judged);
133
- };
134
-
135
- if (schedulingMode === "finder-barrier") {
136
- const found = await api.parallel(lenses.map((lens) => async () => findForLens(lens)));
137
- const novel = compactResults(found).flatMap(dedupeFound);
138
- const verdicts = await api.parallel(novel.map((entry) => async () => verifyCandidate(entry)));
139
- 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)`);
140
100
  }
141
-
142
- const perLensVerified = await api.pipeline(
143
- lenses,
144
- async (_prev, lens) => findForLens(lens),
145
- async (found) => {
146
- const novel = dedupeFound(found);
147
- const verdicts = await api.parallel(novel.map((entry) => async () => verifyCandidate(entry)));
148
- 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 };
149
112
  },
150
- );
113
+ })), coverage);
114
+ return { verified, rawCandidates, dropped, refuted, coverage };
115
+ }
151
116
 
152
- 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);
153
132
  }
154
133
 
155
- function candidatesNovelToRun(candidates: readonly AdvisoryCandidate[], seen: Set<string>): AdvisoryCandidate[] {
156
- return candidates.filter((candidate) => {
157
- const key = advisoryDedupKey(candidate);
158
- if (seen.has(key)) return false;
159
- seen.add(key);
160
- return true;
161
- });
134
+ export function finishAdvisoryReport<T extends AdvisoryReport>(
135
+ report: T, coverage: AdvisoryStageCoverage[], verification: AdvisoryVerified[] = [],
136
+ ) {
137
+ return { ...withAdvisoryCoverage(report, coverage), verification };
162
138
  }
163
139
 
164
140
  export function primaryLocation(candidate: Pick<AdvisoryCandidate, "locations">): AdvisoryLocation {
@@ -180,52 +156,17 @@ export function normalizePath(path: string): string {
180
156
  return path.replace(/^\.\//, "").replace(/^[ab]\//, "");
181
157
  }
182
158
 
183
- export function advisoryDedupKey(candidate: AdvisoryCandidate): string {
184
- const location = primaryLocation(candidate);
185
- const lineKey = location.line != null ? Math.round(location.line / 5) * 5 : "file";
186
- return `${candidate.category}:${normalizePath(location.file)}:${lineKey}:${candidate.summary.slice(0, 60).toLowerCase()}`;
187
- }
188
-
189
- export function verdictLane(verdict: AdvisoryVerdict["verdict"]): string {
190
- switch (verdict) {
191
- case "CONFIRMED":
192
- return "Confirmed";
193
- case "PLAUSIBLE":
194
- return "Plausible";
195
- case "REFUTED":
196
- return "Refuted";
197
- }
198
- }
199
-
200
- export function verdictStatus(verdict: AdvisoryVerdict["verdict"]): "success" | "warning" | "error" {
201
- switch (verdict) {
202
- case "CONFIRMED":
203
- return "success";
204
- case "PLAUSIBLE":
205
- return "warning";
206
- case "REFUTED":
207
- return "error";
208
- }
209
- }
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
+ }>;
210
167
 
211
168
  export function verdictConfidence(verdict: AdvisoryVerdict["verdict"]): "high" | "medium" | "low" {
212
- switch (verdict) {
213
- case "CONFIRMED":
214
- return "high";
215
- case "PLAUSIBLE":
216
- return "medium";
217
- case "REFUTED":
218
- return "low";
219
- }
220
- }
221
-
222
- export function sameFinding(candidate: Pick<AdvisoryCandidate, "locations">, finding: Pick<AdvisoryFinding, "locations" | "summary">): boolean {
223
- return findingLocationKey(candidate) === findingLocationKey(finding);
224
- }
225
-
226
- export function findingLocationKey(value: Pick<AdvisoryCandidate, "locations"> | Pick<AdvisoryFinding, "locations">): string {
227
- const location = primaryLocation(value);
228
- return `${normalizePath(location.file)}:${location.line ?? "file"}`;
169
+ return VERDICT_PRESENTATION[verdict].confidence;
229
170
  }
230
171
 
231
172
  export function recordVerdictProgress(
@@ -241,32 +182,64 @@ export function recordVerdictProgress(
241
182
  }
242
183
  progress({
243
184
  type: "lane_item",
244
- lane: verdictLane(verdict.verdict),
185
+ lane: VERDICT_PRESENTATION[verdict.verdict].lane,
245
186
  title: candidate.summary,
246
187
  subtitle: formatLocation(candidate),
247
- status: verdictStatus(verdict.verdict),
188
+ status: VERDICT_PRESENTATION[verdict.verdict].status,
248
189
  details: formatEvidence(verdict.evidence),
249
190
  });
250
191
  }
251
192
 
252
193
  export function backfillAdvisoryFindings<Source extends AdvisoryVerified>(
253
- findings: AdvisoryReport["findings"],
194
+ findings: AdvisorySynthesis["findings"],
254
195
  ranked: readonly Source[],
255
196
  defaults: AdvisoryBackfillDefaults,
256
197
  ): AdvisoryReport["findings"] {
257
- const rankedByLocation = new Map<string, Source>();
258
- for (const candidate of ranked) {
259
- const key = findingLocationKey(candidate);
260
- if (!rankedByLocation.has(key)) rankedByLocation.set(key, candidate);
261
- }
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
+ }
262
224
 
263
- return findings.map((finding) => {
264
- 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) {
265
233
  return {
266
- ...finding,
267
- evidence: finding.evidence.length > 0 ? finding.evidence : (source?.evidence ?? []),
268
- impact: finding.impact || source?.impact || defaults.impact,
269
- 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."],
270
237
  };
271
- });
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 };
272
245
  }
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { ResolvedWorkflowRunOptions } from "./options.ts";
3
3
  import type { PerfAggregate, PerfSink, PerfSnapshot } from "./perf.ts";
4
- import type { WorkflowProgressSnapshot } from "./progress.ts";
4
+ import type { WorkflowProgressSnapshot } from "./progress-types.ts";
5
5
  import type {
6
6
  LoadedWorkflow,
7
7
  WorkflowProgressSource,
@@ -0,0 +1,72 @@
1
+ import type { WorkflowBackgroundOrigin } from "./types.ts";
2
+ import type { WorkflowRunRecord } from "./workflow-run-record.ts";
3
+
4
+ const MAX_BACKGROUND_TEXT = 2_048;
5
+
6
+ export type WorkflowRunDelivery =
7
+ | { readonly state: "pending" }
8
+ | { readonly state: "delivered"; readonly deliveredAt: number }
9
+ | { readonly state: "unavailable"; readonly attemptedAt: number; readonly message: string };
10
+
11
+ export interface PersistedWorkflowBackground {
12
+ readonly origin: WorkflowBackgroundOrigin;
13
+ readonly delivery: WorkflowRunDelivery;
14
+ }
15
+
16
+ export function createPersistedWorkflowBackground(
17
+ origin: WorkflowBackgroundOrigin | undefined,
18
+ ): PersistedWorkflowBackground | undefined {
19
+ if (!origin) return undefined;
20
+ return {
21
+ origin: {
22
+ sessionId: boundedText(origin.sessionId),
23
+ requestedAt: origin.requestedAt,
24
+ },
25
+ delivery: { state: "pending" },
26
+ };
27
+ }
28
+
29
+ export function updateWorkflowRunDelivery(
30
+ record: WorkflowRunRecord,
31
+ delivery: WorkflowRunDelivery,
32
+ at = Date.now(),
33
+ ): WorkflowRunRecord {
34
+ if (!record.background) throw new Error(`Workflow run ${record.runId} is not a background run.`);
35
+ return {
36
+ ...record,
37
+ updatedAt: at,
38
+ background: {
39
+ origin: record.background.origin,
40
+ delivery: delivery.state === "unavailable"
41
+ ? { ...delivery, message: boundedText(delivery.message) }
42
+ : delivery,
43
+ },
44
+ };
45
+ }
46
+
47
+ export function isPersistedWorkflowBackground(value: unknown): value is PersistedWorkflowBackground {
48
+ if (!isRecord(value) || !isRecord(value.origin) || !isRecord(value.delivery)) return false;
49
+ if (typeof value.origin.sessionId !== "string" || !isFiniteNumber(value.origin.requestedAt)) return false;
50
+ switch (value.delivery.state) {
51
+ case "pending":
52
+ return true;
53
+ case "delivered":
54
+ return isFiniteNumber(value.delivery.deliveredAt);
55
+ case "unavailable":
56
+ return isFiniteNumber(value.delivery.attemptedAt) && typeof value.delivery.message === "string";
57
+ default:
58
+ return false;
59
+ }
60
+ }
61
+
62
+ function boundedText(value: string): string {
63
+ return value.length <= MAX_BACKGROUND_TEXT ? value : `${value.slice(0, MAX_BACKGROUND_TEXT - 1)}…`;
64
+ }
65
+
66
+ function isFiniteNumber(value: unknown): value is number {
67
+ return typeof value === "number" && Number.isFinite(value);
68
+ }
69
+
70
+ function isRecord(value: unknown): value is Record<string, unknown> {
71
+ return typeof value === "object" && value !== null && !Array.isArray(value);
72
+ }