pi-workflow-engine 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +256 -0
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +64 -0
  3. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +238 -0
  4. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +28 -0
  5. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +171 -0
  6. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +152 -0
  7. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +114 -0
  8. package/{src → .pi/extensions/pi-workflow-engine/src}/engine.ts +36 -8
  9. package/.pi/extensions/pi-workflow-engine/src/options.ts +46 -0
  10. package/.pi/extensions/pi-workflow-engine/src/perf.ts +139 -0
  11. package/{src → .pi/extensions/pi-workflow-engine/src}/progress.ts +82 -35
  12. package/{src → .pi/extensions/pi-workflow-engine/src}/types.ts +12 -4
  13. package/{src → .pi/extensions/pi-workflow-engine/src}/ui/workflow-format.ts +59 -6
  14. package/{src → .pi/extensions/pi-workflow-engine/src}/ui/workflow-inspector.ts +8 -6
  15. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +198 -0
  16. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +182 -0
  17. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +263 -0
  18. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +24 -0
  19. package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/code-review.ts +89 -145
  20. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +244 -0
  21. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +188 -0
  22. package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/ping.ts +1 -0
  23. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +181 -0
  24. package/.pi/settings.json +3 -0
  25. package/README.md +163 -63
  26. package/assets/preview-candidate-core.png +0 -0
  27. package/assets/preview-candidate-relic-emblem.png +0 -0
  28. package/assets/preview-candidate-robot-squad.png +0 -0
  29. package/assets/preview.png +0 -0
  30. package/package.json +32 -6
  31. package/src/agent-runner.ts +0 -122
  32. package/src/concurrency.ts +0 -55
  33. package/src/discovery.ts +0 -54
  34. package/src/index.ts +0 -141
  35. package/src/ui/workflow-result-renderer.ts +0 -149
  36. package/src/ui/workflow-widget.ts +0 -143
  37. package/src/workflows.ts +0 -14
@@ -0,0 +1,198 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Box, type Component, Text } from "@earendil-works/pi-tui";
3
+ import type { AdvisoryFinding, AdvisoryLocation, AdvisoryReport } from "../advisory-schema.ts";
4
+ import type { PerfAggregate } from "../perf.ts";
5
+ import { badge, formatCount, type WorkflowThemeColor } from "./workflow-format.ts";
6
+
7
+ export interface WorkflowPerfDetails {
8
+ readonly enabled: boolean;
9
+ readonly startedAt: number;
10
+ readonly aggregates: readonly PerfAggregate[];
11
+ }
12
+
13
+ export interface WorkflowResultEnvelope {
14
+ name: string;
15
+ result: unknown;
16
+ completedAt: number;
17
+ perf?: WorkflowPerfDetails;
18
+ }
19
+
20
+ export interface AdvisoryWorkflowResult extends AdvisoryReport {
21
+ stats?: Record<string, string | number>;
22
+ }
23
+
24
+ export function isWorkflowResult(value: unknown): value is WorkflowResultEnvelope {
25
+ if (!isRecord(value)) return false;
26
+ return typeof value.name === "string" && "result" in value && typeof value.completedAt === "number";
27
+ }
28
+
29
+ function isAdvisoryLocation(value: unknown): value is AdvisoryLocation {
30
+ if (!isRecord(value)) return false;
31
+ if (typeof value.file !== "string") return false;
32
+ if (value.line !== undefined && typeof value.line !== "number") return false;
33
+ if (value.symbol !== undefined && typeof value.symbol !== "string") return false;
34
+ return true;
35
+ }
36
+
37
+ function isAdvisoryFinding(value: unknown): value is AdvisoryFinding {
38
+ if (!isRecord(value)) return false;
39
+ if (typeof value.summary !== "string") return false;
40
+ if (typeof value.category !== "string") return false;
41
+ if (!isSeverity(value.severity)) return false;
42
+ if (!isConfidence(value.confidence)) return false;
43
+ if (!Array.isArray(value.locations) || !value.locations.every(isAdvisoryLocation)) return false;
44
+ if (!Array.isArray(value.evidence) || !value.evidence.every((entry) => typeof entry === "string")) return false;
45
+ if (typeof value.impact !== "string") return false;
46
+ if (typeof value.recommendation !== "string") return false;
47
+ return true;
48
+ }
49
+
50
+ export function isAdvisoryReport(value: unknown): value is AdvisoryWorkflowResult {
51
+ if (!isRecord(value)) return false;
52
+ if (typeof value.summary !== "string" || !Array.isArray(value.findings)) return false;
53
+ if (!value.findings.every(isAdvisoryFinding)) return false;
54
+ if (!Array.isArray(value.nextSteps) || !value.nextSteps.every((entry) => typeof entry === "string")) return false;
55
+ if (value.stats !== undefined && !isStats(value.stats)) return false;
56
+ return true;
57
+ }
58
+
59
+ export function renderWorkflowResult(name: string, result: unknown, expanded: boolean, theme: Theme): Component {
60
+ const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
61
+ box.addChild(new Text(renderWorkflowResultText(name, result, expanded, theme), 0, 0));
62
+ return box;
63
+ }
64
+
65
+ export function renderWorkflowResultText(name: string, result: unknown, expanded: boolean, theme: Theme): string {
66
+ if (isAdvisoryReport(result)) {
67
+ return renderAdvisoryResult(name, result, expanded, theme);
68
+ }
69
+ return renderGenericWorkflowResult(name, result, expanded, theme);
70
+ }
71
+
72
+ function renderAdvisoryResult(name: string, result: AdvisoryWorkflowResult, expanded: boolean, theme: Theme): string {
73
+ const icon = theme.fg("success", "✓");
74
+ const title = theme.fg("accent", theme.bold(`Workflow: ${name}`));
75
+ const lines = [`${icon} ${title}`, theme.fg("muted", result.summary)];
76
+ const stats = statsLine(result.stats, theme);
77
+ if (stats) lines.push(stats);
78
+
79
+ if (result.findings.length === 0) {
80
+ lines.push(theme.fg("success", "No findings."));
81
+ if (expanded && result.nextSteps.length > 0) renderNextSteps(result.nextSteps, lines, theme);
82
+ return lines.join("\n");
83
+ }
84
+
85
+ const findings = expanded ? result.findings : result.findings.slice(0, 3);
86
+ lines.push(theme.fg("dim", expanded ? "Findings:" : "Top findings:"));
87
+ for (const finding of findings) {
88
+ lines.push(renderAdvisoryFinding(finding, expanded, theme));
89
+ }
90
+ if (!expanded && result.findings.length > findings.length) {
91
+ lines.push(theme.fg("dim", `… ${result.findings.length - findings.length} more finding(s)`));
92
+ }
93
+ if (expanded && result.nextSteps.length > 0) renderNextSteps(result.nextSteps, lines, theme);
94
+ return lines.join("\n");
95
+ }
96
+
97
+ function renderAdvisoryFinding(finding: AdvisoryFinding, expanded: boolean, theme: Theme): string {
98
+ const location = formatLocation(finding.locations[0]);
99
+ let text =
100
+ ` ${badge(finding.category, "accent", theme)} ` +
101
+ `${badge(finding.severity, severityColor(finding.severity), theme)} ` +
102
+ `${badge(`${finding.confidence} confidence`, confidenceColor(finding.confidence), theme)} ` +
103
+ `${theme.fg("accent", location)} ${theme.fg("muted", finding.summary)}`;
104
+ if (expanded) {
105
+ text += `\n ${theme.fg("dim", `Impact: ${finding.impact}`)}`;
106
+ text += `\n ${theme.fg("dim", `Evidence: ${finding.evidence.join("; ") || "(none cited)"}`)}`;
107
+ text += `\n ${theme.fg("dim", `Recommendation: ${finding.recommendation}`)}`;
108
+ }
109
+ return text;
110
+ }
111
+
112
+ function renderNextSteps(nextSteps: string[], lines: string[], theme: Theme): void {
113
+ lines.push(theme.fg("dim", "Next steps:"));
114
+ for (const step of nextSteps) {
115
+ lines.push(` - ${theme.fg("muted", step)}`);
116
+ }
117
+ }
118
+
119
+ function formatLocation(location: AdvisoryLocation | undefined): string {
120
+ if (!location) return "(no location)";
121
+ const line = location.line != null ? `:${location.line}` : "";
122
+ const symbol = location.symbol ? ` (${location.symbol})` : "";
123
+ return `${location.file}${line}${symbol}`;
124
+ }
125
+
126
+ function severityColor(severity: AdvisoryFinding["severity"]): WorkflowThemeColor {
127
+ switch (severity) {
128
+ case "high":
129
+ return "error";
130
+ case "medium":
131
+ return "warning";
132
+ case "low":
133
+ return "muted";
134
+ }
135
+ }
136
+
137
+ function confidenceColor(confidence: AdvisoryFinding["confidence"]): WorkflowThemeColor {
138
+ switch (confidence) {
139
+ case "high":
140
+ return "success";
141
+ case "medium":
142
+ return "warning";
143
+ case "low":
144
+ return "muted";
145
+ }
146
+ }
147
+
148
+ function renderGenericWorkflowResult(name: string, result: unknown, expanded: boolean, theme: Theme): string {
149
+ const lines = [`${theme.fg("success", "✓")} ${theme.fg("accent", theme.bold(`Workflow: ${name}`))}`];
150
+ const summary = extractSummary(result);
151
+ if (summary) lines.push(theme.fg("muted", summary));
152
+ if (expanded) lines.push(theme.fg("dim", safeJson(result)));
153
+ else if (!summary) lines.push(theme.fg("dim", "Result available in expanded view."));
154
+ return lines.join("\n");
155
+ }
156
+
157
+ function statsLine(stats: Record<string, string | number> | undefined, theme: Theme): string | undefined {
158
+ if (!stats) return undefined;
159
+ const ordered = ["files", "candidates", "dropped", "verified", "kept"];
160
+ const parts = ordered.flatMap((key) => {
161
+ const value = stats[key];
162
+ if (value === undefined) return [];
163
+ return [`${key} ${typeof value === "number" ? formatCount(value) : value}`];
164
+ });
165
+ if (parts.length === 0) return undefined;
166
+ return theme.fg("dim", parts.join(" · "));
167
+ }
168
+
169
+ function extractSummary(value: unknown): string | undefined {
170
+ if (typeof value === "string") return value;
171
+ if (isRecord(value) && typeof value.summary === "string") return value.summary;
172
+ return undefined;
173
+ }
174
+
175
+ function safeJson(value: unknown): string {
176
+ try {
177
+ return JSON.stringify(value, null, 2) ?? String(value);
178
+ } catch (error) {
179
+ return error instanceof Error ? error.message : String(error);
180
+ }
181
+ }
182
+
183
+ function isSeverity(value: unknown): value is AdvisoryFinding["severity"] {
184
+ return value === "low" || value === "medium" || value === "high";
185
+ }
186
+
187
+ function isConfidence(value: unknown): value is AdvisoryFinding["confidence"] {
188
+ return value === "low" || value === "medium" || value === "high";
189
+ }
190
+
191
+ function isStats(value: unknown): value is Record<string, string | number> {
192
+ if (!isRecord(value)) return false;
193
+ return Object.values(value).every((entry) => typeof entry === "string" || typeof entry === "number");
194
+ }
195
+
196
+ function isRecord(value: unknown): value is Record<string, unknown> {
197
+ return typeof value === "object" && value !== null;
198
+ }
@@ -0,0 +1,182 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import type { AgentRowSnapshot, PhaseSnapshot, WorkflowProgressSnapshot } from "../progress.ts";
3
+ import { agentDetailParts, agentLabelColor, formatCount, formatDuration, statusIcon, truncateDisplay } from "./workflow-format.ts";
4
+
5
+ const MAX_WIDGET_LINES = 12;
6
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
7
+
8
+ export interface WorkflowWidget {
9
+ nextFrame(): void;
10
+ render(width: number, theme: Theme): string[];
11
+ invalidate(): void;
12
+ }
13
+
14
+ export function createWorkflowWidget(snapshotProvider: () => WorkflowProgressSnapshot): WorkflowWidget {
15
+ return new LiveWorkflowWidget(snapshotProvider);
16
+ }
17
+
18
+ export function renderWorkflowWidgetLines(
19
+ snapshot: WorkflowProgressSnapshot,
20
+ frame: number,
21
+ width: number,
22
+ theme: Theme,
23
+ ): string[] {
24
+ const safeWidth = Math.max(1, width);
25
+ const counts = countAgents(snapshot.phases);
26
+ const active = counts.running + counts.queued;
27
+ const elapsed = formatDuration((snapshot.doneAt ?? Date.now()) - snapshot.startedAt);
28
+ const spinner = active > 0 ? theme.fg("accent", SPINNER[frame % SPINNER.length]) : theme.fg("success", "✓");
29
+
30
+ const headingParts = [`${counts.done}/${counts.total} done`, elapsed];
31
+ if (counts.running > 0) headingParts.unshift(`${counts.running} running`);
32
+ if (counts.queued > 0) headingParts.unshift(`${counts.queued} queued`);
33
+ if (counts.failed > 0) headingParts.unshift(theme.fg("error", `${counts.failed} failed`));
34
+
35
+ const lines: string[] = [
36
+ truncateDisplay(
37
+ `${spinner} ${theme.bold(snapshot.title)} ${theme.fg("dim", "·")} ${theme.fg("muted", snapshot.currentPhase)} ${theme.fg("dim", "·")} ${headingParts.join(` ${theme.fg("dim", "·")} `)}`,
38
+ safeWidth,
39
+ ),
40
+ ];
41
+
42
+ const footer = footerLine(snapshot, theme);
43
+ const bodyBudget = Math.max(0, MAX_WIDGET_LINES - lines.length - (footer ? 1 : 0));
44
+ const body = visibleBodyLines(snapshot.phases, bodyBudget, theme);
45
+ const reserveHiddenLine = body.hidden > 0 && bodyBudget > 0;
46
+ const visibleBodyLinesToRender = reserveHiddenLine ? body.lines.slice(0, Math.max(0, bodyBudget - 1)) : body.lines;
47
+ const hidden = body.hidden + (body.lines.length - visibleBodyLinesToRender.length);
48
+
49
+ for (const line of visibleBodyLinesToRender) lines.push(truncateDisplay(line, safeWidth));
50
+ if (hidden > 0 && lines.length < MAX_WIDGET_LINES) {
51
+ lines.push(truncateDisplay(`${theme.fg("dim", "└─")} ${theme.fg("dim", `+${hidden} more`)}`, safeWidth));
52
+ }
53
+ if (footer && lines.length < MAX_WIDGET_LINES) lines.push(truncateDisplay(footer, safeWidth));
54
+
55
+ return lines.slice(0, MAX_WIDGET_LINES).map((line) => truncateDisplay(line, safeWidth));
56
+ }
57
+
58
+ class LiveWorkflowWidget implements WorkflowWidget {
59
+ private frame = 0;
60
+ private cachedWidth: number | undefined;
61
+ private cachedFrame: number | undefined;
62
+ private cachedDoneAt: number | undefined;
63
+ private cachedLines: string[] | undefined;
64
+
65
+ constructor(private readonly snapshotProvider: () => WorkflowProgressSnapshot) {}
66
+
67
+ nextFrame(): void {
68
+ this.frame = (this.frame + 1) % SPINNER.length;
69
+ this.invalidate();
70
+ }
71
+
72
+ render(width: number, theme: Theme): string[] {
73
+ const snapshot = this.snapshotProvider();
74
+ if (
75
+ this.cachedLines &&
76
+ this.cachedWidth === width &&
77
+ this.cachedFrame === this.frame &&
78
+ this.cachedDoneAt === snapshot.doneAt
79
+ ) {
80
+ return this.cachedLines;
81
+ }
82
+
83
+ this.cachedLines = renderWorkflowWidgetLines(snapshot, this.frame, width, theme);
84
+ this.cachedWidth = width;
85
+ this.cachedFrame = this.frame;
86
+ this.cachedDoneAt = snapshot.doneAt;
87
+ return this.cachedLines;
88
+ }
89
+
90
+ invalidate(): void {
91
+ this.cachedWidth = undefined;
92
+ this.cachedFrame = undefined;
93
+ this.cachedDoneAt = undefined;
94
+ this.cachedLines = undefined;
95
+ }
96
+ }
97
+
98
+ interface AgentCounts {
99
+ queued: number;
100
+ running: number;
101
+ done: number;
102
+ failed: number;
103
+ total: number;
104
+ }
105
+
106
+ function countAgents(phases: readonly PhaseSnapshot[]): AgentCounts {
107
+ const counts: AgentCounts = { queued: 0, running: 0, done: 0, failed: 0, total: 0 };
108
+ for (const phase of phases) {
109
+ for (const agent of phase.agents) {
110
+ counts[agent.status]++;
111
+ counts.total++;
112
+ }
113
+ }
114
+ return counts;
115
+ }
116
+
117
+ function visibleBodyLines(phases: readonly PhaseSnapshot[], budget: number, theme: Theme): { lines: string[]; hidden: number } {
118
+ const lines: string[] = [];
119
+ let totalRows = 0;
120
+ const visit = (phase: PhaseSnapshot): void => {
121
+ if (phase.agents.length === 0 && phase.title === "Workflow") return;
122
+ const counts = countPhaseAgents(phase);
123
+ totalRows++;
124
+ if (lines.length < budget) lines.push(phaseLine(phase.title, counts, theme));
125
+ appendAgentGroup(phase.agents, (agent) => agent.status === "running" || agent.status === "queued", lines, budget, theme, () => totalRows++);
126
+ appendAgentGroup(phase.agents, (agent) => agent.status === "failed", lines, budget, theme, () => totalRows++);
127
+ appendAgentGroup(phase.agents, (agent) => agent.status === "done", lines, budget, theme, () => totalRows++);
128
+ };
129
+
130
+ for (const phase of phases) {
131
+ if (phase.agents.some((agent) => agent.status === "running" || agent.status === "queued")) visit(phase);
132
+ }
133
+ for (const phase of phases) {
134
+ if (!phase.agents.some((agent) => agent.status === "running" || agent.status === "queued")) visit(phase);
135
+ }
136
+
137
+ return { lines, hidden: Math.max(0, totalRows - lines.length) };
138
+ }
139
+
140
+ function appendAgentGroup(
141
+ agents: readonly AgentRowSnapshot[],
142
+ include: (agent: AgentRowSnapshot) => boolean,
143
+ lines: string[],
144
+ budget: number,
145
+ theme: Theme,
146
+ countRow: () => void,
147
+ ): void {
148
+ for (const agent of agents) {
149
+ if (!include(agent)) continue;
150
+ countRow();
151
+ if (lines.length < budget) lines.push(agentLine(agent, theme));
152
+ }
153
+ }
154
+
155
+ function countPhaseAgents(phase: PhaseSnapshot): AgentCounts {
156
+ const counts: AgentCounts = { queued: 0, running: 0, done: 0, failed: 0, total: phase.agents.length };
157
+ for (const agent of phase.agents) counts[agent.status]++;
158
+ return counts;
159
+ }
160
+
161
+ function phaseLine(title: string, counts: AgentCounts, theme: Theme): string {
162
+ const parts: string[] = [];
163
+ if (counts.running > 0) parts.push(`${counts.running} running`);
164
+ if (counts.done > 0) parts.push(`${counts.done} done`);
165
+ if (counts.failed > 0) parts.push(`${counts.failed} failed`);
166
+ return `${theme.fg("dim", "├─")} ${theme.fg(counts.running > 0 ? "accent" : "muted", title)}${parts.length ? ` ${theme.fg("dim", `(${parts.join(" · ")})`)}` : ""}`;
167
+ }
168
+
169
+ function agentLine(agent: AgentRowSnapshot, theme: Theme): string {
170
+ const detailParts = agentDetailParts(agent, { includeQueuedStatus: false });
171
+ const activity = detailParts.length > 0 ? ` ${theme.fg("dim", `· ${detailParts.join(" · ")}`)}` : "";
172
+ return `${theme.fg("dim", "│ ")} ${statusIcon(agent.status, theme)} ${theme.fg(agentLabelColor(agent), agent.label)}${activity}`;
173
+ }
174
+
175
+ function footerLine(snapshot: WorkflowProgressSnapshot, theme: Theme): string | undefined {
176
+ const counters = snapshot.counters.slice(0, 4).map((counter) => `${counter.label} ${formatCount(counter.value)}`);
177
+ const latestLog = snapshot.logs[snapshot.logs.length - 1];
178
+ const parts = counters.length > 0 ? counters : [];
179
+ if (latestLog) parts.push(latestLog);
180
+ if (parts.length === 0) return undefined;
181
+ return `${theme.fg("dim", "└─")} ${theme.fg("dim", parts.join(" · "))}`;
182
+ }
@@ -0,0 +1,263 @@
1
+ import { AdvisoryCandidatesSchema, AdvisoryVerdictSchema, type AdvisoryCandidate, type AdvisoryFinding, type AdvisoryLocation, type AdvisoryReport, type AdvisoryVerdict } from "./advisory-schema.ts";
2
+ import type { AgentOptions, WorkflowApi, WorkflowProgressEvent } from "./types.ts";
3
+
4
+ export interface AdvisoryLens {
5
+ label: string;
6
+ category: string;
7
+ text: string;
8
+ }
9
+
10
+ export type AdvisoryVerified<Candidate extends AdvisoryCandidate = AdvisoryCandidate> = Candidate & {
11
+ verdict: AdvisoryVerdict["verdict"];
12
+ evidence: string[];
13
+ confidence?: AdvisoryVerdict["confidence"];
14
+ };
15
+
16
+ export interface LensVerificationPipelineResult<Verified> {
17
+ verified: Verified[];
18
+ rawCandidates: number;
19
+ dropped: number;
20
+ refuted: number;
21
+ }
22
+
23
+ const DEFAULT_ADVISORY_TOOLS = ["read", "bash"];
24
+
25
+ export type AdvisorySchedulingMode = "pipeline" | "finder-barrier";
26
+
27
+ export interface LensVerificationPipelineOptions<Lens extends AdvisoryLens, Verified extends AdvisoryVerified> {
28
+ api: Pick<WorkflowApi, "agent" | "parallel" | "pipeline" | "progress" | "log">;
29
+ lenses: readonly Lens[];
30
+ perLens: number;
31
+ tools?: AgentOptions["tools"];
32
+ finderPhase?: string;
33
+ verifierPhase?: string;
34
+ schedulingMode?: AdvisorySchedulingMode;
35
+ finderPrompt(lens: Lens): string;
36
+ verifierPrompt(candidate: AdvisoryCandidate): string;
37
+ makeVerified(candidate: AdvisoryCandidate, lens: Lens, verdict: AdvisoryVerdict): Verified;
38
+ }
39
+
40
+ export interface AdvisoryBackfillDefaults {
41
+ impact: string;
42
+ recommendation?: string;
43
+ }
44
+
45
+ interface FoundForLens<Lens> {
46
+ lens: Lens;
47
+ candidates: AdvisoryCandidate[];
48
+ }
49
+
50
+ interface NovelCandidate<Lens> {
51
+ lens: Lens;
52
+ candidate: AdvisoryCandidate;
53
+ }
54
+
55
+ export async function runLensVerificationPipeline<Lens extends AdvisoryLens, Verified extends AdvisoryVerified>(
56
+ options: LensVerificationPipelineOptions<Lens, Verified>,
57
+ ): Promise<LensVerificationPipelineResult<Verified>> {
58
+ const {
59
+ api,
60
+ lenses,
61
+ perLens,
62
+ tools = DEFAULT_ADVISORY_TOOLS,
63
+ finderPhase = "Find",
64
+ verifierPhase = "Verify",
65
+ schedulingMode = "pipeline",
66
+ finderPrompt,
67
+ verifierPrompt,
68
+ makeVerified,
69
+ } = options;
70
+ const seen = new Set<string>();
71
+ let rawCandidates = 0;
72
+ let dropped = 0;
73
+ let refuted = 0;
74
+
75
+ const findForLens = async (lens: Lens): Promise<FoundForLens<Lens>> => {
76
+ const found = await api.agent(finderPrompt(lens), {
77
+ phase: finderPhase,
78
+ label: `find:${lens.label}`,
79
+ tools,
80
+ thinkingLevel: "low",
81
+ schema: AdvisoryCandidatesSchema,
82
+ });
83
+ const candidates = (found?.candidates ?? []).slice(0, perLens);
84
+ rawCandidates += candidates.length;
85
+ api.progress({ type: "counter_delta", key: "candidates", label: "candidates", delta: candidates.length });
86
+ for (const candidate of candidates) {
87
+ api.progress({
88
+ type: "lane_item",
89
+ lane: "Candidates",
90
+ title: candidate.summary,
91
+ subtitle: formatLocation(candidate),
92
+ status: "pending",
93
+ details: candidate.impact,
94
+ });
95
+ }
96
+ return { lens, candidates };
97
+ };
98
+
99
+ const dedupeFound = (found: FoundForLens<Lens>): NovelCandidate<Lens>[] => {
100
+ const novel = candidatesNovelToRun(found.candidates, seen).map((candidate) => ({ lens: found.lens, candidate }));
101
+ const droppedForLens = found.candidates.length - novel.length;
102
+ if (droppedForLens > 0) {
103
+ dropped += droppedForLens;
104
+ api.progress({ type: "counter_delta", key: "dropped", label: "dropped", delta: droppedForLens });
105
+ api.log(`find:${found.lens.label}: dropped ${droppedForLens} duplicate candidate(s)`);
106
+ }
107
+ return novel;
108
+ };
109
+
110
+ const verifyCandidate = async ({ lens, candidate }: NovelCandidate<Lens>): Promise<Verified | null> => {
111
+ const location = primaryLocation(candidate);
112
+ const judged = await api.agent(verifierPrompt(candidate), {
113
+ phase: verifierPhase,
114
+ label: `verify:${location.file.split("/").pop() ?? location.file}`,
115
+ tools,
116
+ thinkingLevel: "low",
117
+ schema: AdvisoryVerdictSchema,
118
+ });
119
+ if (!judged) return null;
120
+ recordVerdictProgress(api.progress, candidate, judged, () => {
121
+ refuted += 1;
122
+ });
123
+ return makeVerified(candidate, lens, judged);
124
+ };
125
+
126
+ if (schedulingMode === "finder-barrier") {
127
+ const found = await api.parallel(lenses.map((lens) => async () => findForLens(lens)));
128
+ const novel = found.flatMap(dedupeFound);
129
+ const verdicts = await api.parallel(novel.map((entry) => async () => verifyCandidate(entry)));
130
+ return { verified: verdicts.filter((value): value is Verified => value !== null), rawCandidates, dropped, refuted };
131
+ }
132
+
133
+ const perLensVerified = await api.pipeline(
134
+ lenses,
135
+ async (_prev, lens) => findForLens(lens),
136
+ async (found) => {
137
+ const novel = dedupeFound(found);
138
+ const verdicts = await api.parallel(novel.map((entry) => async () => verifyCandidate(entry)));
139
+ return verdicts.filter((value): value is Verified => value !== null);
140
+ },
141
+ );
142
+
143
+ return { verified: perLensVerified.flat(), rawCandidates, dropped, refuted };
144
+ }
145
+
146
+ function candidatesNovelToRun(candidates: readonly AdvisoryCandidate[], seen: Set<string>): AdvisoryCandidate[] {
147
+ return candidates.filter((candidate) => {
148
+ const key = advisoryDedupKey(candidate);
149
+ if (seen.has(key)) return false;
150
+ seen.add(key);
151
+ return true;
152
+ });
153
+ }
154
+
155
+ export function primaryLocation(candidate: Pick<AdvisoryCandidate, "locations">): AdvisoryLocation {
156
+ return candidate.locations[0] ?? { file: "" };
157
+ }
158
+
159
+ export function formatLocation(candidate: Pick<AdvisoryCandidate, "locations">): string {
160
+ const location = primaryLocation(candidate);
161
+ const line = location.line != null ? `:${location.line}` : "";
162
+ const symbol = location.symbol ? ` (${location.symbol})` : "";
163
+ return `${location.file}${line}${symbol}`;
164
+ }
165
+
166
+ export function formatEvidence(evidence: readonly string[]): string {
167
+ return evidence.join("; ");
168
+ }
169
+
170
+ export function normalizePath(path: string): string {
171
+ return path.replace(/^\.\//, "").replace(/^[ab]\//, "");
172
+ }
173
+
174
+ export function advisoryDedupKey(candidate: AdvisoryCandidate): string {
175
+ const location = primaryLocation(candidate);
176
+ const lineKey = location.line != null ? Math.round(location.line / 5) * 5 : "file";
177
+ return `${candidate.category}:${normalizePath(location.file)}:${lineKey}:${candidate.summary.slice(0, 60).toLowerCase()}`;
178
+ }
179
+
180
+ export function verdictLane(verdict: AdvisoryVerdict["verdict"]): string {
181
+ switch (verdict) {
182
+ case "CONFIRMED":
183
+ return "Confirmed";
184
+ case "PLAUSIBLE":
185
+ return "Plausible";
186
+ case "REFUTED":
187
+ return "Refuted";
188
+ }
189
+ }
190
+
191
+ export function verdictStatus(verdict: AdvisoryVerdict["verdict"]): "success" | "warning" | "error" {
192
+ switch (verdict) {
193
+ case "CONFIRMED":
194
+ return "success";
195
+ case "PLAUSIBLE":
196
+ return "warning";
197
+ case "REFUTED":
198
+ return "error";
199
+ }
200
+ }
201
+
202
+ export function verdictConfidence(verdict: AdvisoryVerdict["verdict"]): "high" | "medium" | "low" {
203
+ switch (verdict) {
204
+ case "CONFIRMED":
205
+ return "high";
206
+ case "PLAUSIBLE":
207
+ return "medium";
208
+ case "REFUTED":
209
+ return "low";
210
+ }
211
+ }
212
+
213
+ export function sameFinding(candidate: Pick<AdvisoryCandidate, "locations">, finding: Pick<AdvisoryFinding, "locations" | "summary">): boolean {
214
+ return findingLocationKey(candidate) === findingLocationKey(finding);
215
+ }
216
+
217
+ export function findingLocationKey(value: Pick<AdvisoryCandidate, "locations"> | Pick<AdvisoryFinding, "locations">): string {
218
+ const location = primaryLocation(value);
219
+ return `${normalizePath(location.file)}:${location.line ?? "file"}`;
220
+ }
221
+
222
+ export function recordVerdictProgress(
223
+ progress: (event: WorkflowProgressEvent) => void,
224
+ candidate: Pick<AdvisoryCandidate, "locations" | "summary">,
225
+ verdict: Pick<AdvisoryVerdict, "verdict" | "evidence">,
226
+ onRefuted?: () => void,
227
+ ): void {
228
+ progress({ type: "counter_delta", key: `verdict.${verdict.verdict.toLowerCase()}`, label: verdict.verdict, delta: 1 });
229
+ if (verdict.verdict === "REFUTED") {
230
+ onRefuted?.();
231
+ progress({ type: "counter_delta", key: "refuted", label: "refuted", delta: 1 });
232
+ }
233
+ progress({
234
+ type: "lane_item",
235
+ lane: verdictLane(verdict.verdict),
236
+ title: candidate.summary,
237
+ subtitle: formatLocation(candidate),
238
+ status: verdictStatus(verdict.verdict),
239
+ details: formatEvidence(verdict.evidence),
240
+ });
241
+ }
242
+
243
+ export function backfillAdvisoryFindings<Source extends AdvisoryVerified>(
244
+ findings: AdvisoryReport["findings"],
245
+ ranked: readonly Source[],
246
+ defaults: AdvisoryBackfillDefaults,
247
+ ): AdvisoryReport["findings"] {
248
+ const rankedByLocation = new Map<string, Source>();
249
+ for (const candidate of ranked) {
250
+ const key = findingLocationKey(candidate);
251
+ if (!rankedByLocation.has(key)) rankedByLocation.set(key, candidate);
252
+ }
253
+
254
+ return findings.map((finding) => {
255
+ const source = rankedByLocation.get(findingLocationKey(finding));
256
+ return {
257
+ ...finding,
258
+ evidence: finding.evidence.length > 0 ? finding.evidence : (source?.evidence ?? []),
259
+ impact: finding.impact || source?.impact || defaults.impact,
260
+ recommendation: finding.recommendation || source?.recommendation || defaults.recommendation || "",
261
+ };
262
+ });
263
+ }
@@ -0,0 +1,24 @@
1
+ import type { WorkflowModule } from "./types.ts";
2
+ import * as codeReview from "../workflows/code-review.ts";
3
+ import * as ping from "../workflows/ping.ts";
4
+ import * as refactorScout from "../workflows/refactor-scout.ts";
5
+ import * as diagnose from "../workflows/diagnose.ts";
6
+ import * as perfReview from "../workflows/perf-review.ts";
7
+
8
+ /**
9
+ * Workflows statically imported by the extension. These are loaded through pi's own
10
+ * jiti instance (because they're part of the extension's module graph), so they share
11
+ * pi's bundled `typebox` / SDK — guaranteeing schema validation works.
12
+ *
13
+ * To add a guaranteed workflow: drop a `.ts` in `workflows/`, import it here, and add
14
+ * it to this array. (Drop-in workflows discovered at runtime are also supported — see
15
+ * discovery.ts — but the static registry is the always-correct path.)
16
+ *
17
+ * Do not replace these built-in imports with dynamic imports unless a dedicated test
18
+ * proves pi's jiti virtual modules preserve the same bundled `typebox` identity for
19
+ * dynamically imported built-ins. Startup optimizations should happen at the extension
20
+ * entrypoint/discovery boundary first.
21
+ */
22
+ export const BUILTIN_WORKFLOWS: WorkflowModule[] = [codeReview, ping, refactorScout, diagnose, perfReview];
23
+ export const BUILTIN_WORKFLOW_FILES = new Set(["code-review.ts", "ping.ts", "refactor-scout.ts", "diagnose.ts", "perf-review.ts"]);
24
+ export const BUILTIN_WORKFLOW_NAMES = BUILTIN_WORKFLOWS.map((mod) => mod.meta.name);