pi-workflow-engine 0.5.0 → 0.6.1

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.
@@ -0,0 +1,90 @@
1
+ import { formatIssueLocation, type ReviewContext, type ReviewIssue } from "./review-issues.ts";
2
+
3
+ export interface ReviewHandoffPayload {
4
+ readonly context: ReviewContext | undefined;
5
+ readonly issues: readonly ReviewHandoffIssue[];
6
+ }
7
+
8
+ export interface ReviewHandoffIssue {
9
+ readonly id: string;
10
+ readonly summary: string;
11
+ readonly category: string;
12
+ readonly severity: string;
13
+ readonly confidence: string;
14
+ readonly location: {
15
+ readonly file?: string;
16
+ readonly line?: number;
17
+ readonly symbol?: string;
18
+ readonly display: string;
19
+ };
20
+ readonly impact: string;
21
+ readonly evidence: readonly string[];
22
+ readonly recommendation: string;
23
+ }
24
+
25
+ export function buildFixHandoffPrompt(issues: readonly ReviewIssue[], context: ReviewContext | undefined): string {
26
+ return `Use the workflow-code-review-actions skill if available.
27
+
28
+ Mode: fix selected code-review findings.
29
+
30
+ Selected findings JSON:
31
+ \`\`\`json
32
+ ${JSON.stringify(toHandoffPayload(issues, context))}
33
+ \`\`\`
34
+
35
+ Instructions:
36
+ - Inspect the selected issue JSON before editing.
37
+ - Make minimal edits that address only the selected findings.
38
+ - Preserve unrelated user changes and avoid broad refactors.
39
+ - Run focused validation if an appropriate local check is available.
40
+ - Do not post GitHub PR comments or any upstream review comments.
41
+ - Summarize changed files and validation results when done.`;
42
+ }
43
+
44
+ export function buildCommentHandoffPrompt(issues: readonly ReviewIssue[], context: ReviewContext | undefined, reason: string): string {
45
+ return `Use the workflow-code-review-actions skill if available.
46
+
47
+ Mode: post inline GitHub PR comments for selected code-review findings.
48
+
49
+ Fallback reason: ${reason}
50
+
51
+ Selected findings JSON:
52
+ \`\`\`json
53
+ ${JSON.stringify(toHandoffPayload(issues, context))}
54
+ \`\`\`
55
+
56
+ Instructions:
57
+ - Do not edit files or make code changes.
58
+ - Prefer installed GitHub MCP/tools if present; otherwise use the GitHub CLI (gh).
59
+ - With gh, resolve the PR using \`gh pr view\` and \`gh repo view\`, then call \`gh api repos/{owner}/{repo}/pulls/{number}/comments\` with \`commit_id\`, \`path\`, \`line\`, and \`side=RIGHT\`.
60
+ - Do not post duplicate comments.
61
+ - Do not post line-less findings as inline comments.
62
+ - Ask the user if the upstream PR cannot be identified.
63
+ - Summarize posted, skipped, and failed comments when done.`;
64
+ }
65
+
66
+ function toHandoffPayload(issues: readonly ReviewIssue[], context: ReviewContext | undefined): ReviewHandoffPayload {
67
+ return {
68
+ context,
69
+ issues: issues.map(toHandoffIssue),
70
+ };
71
+ }
72
+
73
+ function toHandoffIssue(issue: ReviewIssue): ReviewHandoffIssue {
74
+ return {
75
+ id: issue.id,
76
+ summary: issue.finding.summary,
77
+ category: issue.finding.category,
78
+ severity: issue.finding.severity,
79
+ confidence: issue.finding.confidence,
80
+ location: {
81
+ file: issue.file,
82
+ line: issue.line,
83
+ symbol: issue.symbol,
84
+ display: formatIssueLocation(issue),
85
+ },
86
+ impact: issue.finding.impact,
87
+ evidence: issue.finding.evidence,
88
+ recommendation: issue.finding.recommendation,
89
+ };
90
+ }
@@ -0,0 +1,61 @@
1
+ import type { AdvisoryFinding, AdvisoryReport } from "../advisory-schema.ts";
2
+
3
+ export type ReviewIssueAction = "fix" | "comment" | "close";
4
+
5
+ export interface ReviewIssueSelection {
6
+ readonly action: ReviewIssueAction;
7
+ readonly issueIds: readonly string[];
8
+ }
9
+
10
+ export interface ReviewContext {
11
+ readonly workflowName: string;
12
+ readonly target: string;
13
+ readonly diffCommand: string;
14
+ readonly files: readonly string[];
15
+ readonly summary?: string;
16
+ }
17
+
18
+ export interface ReviewIssue {
19
+ readonly id: string;
20
+ readonly index: number;
21
+ readonly workflowName: string;
22
+ readonly file?: string;
23
+ readonly line?: number;
24
+ readonly symbol?: string;
25
+ readonly finding: AdvisoryFinding;
26
+ }
27
+
28
+ export interface ReviewReportWithContext extends AdvisoryReport {
29
+ readonly stats?: Record<string, string | number>;
30
+ readonly reviewContext?: ReviewContext;
31
+ }
32
+
33
+ export function toReviewIssues(name: string, report: Pick<AdvisoryReport, "findings">): ReviewIssue[] {
34
+ return report.findings.map((finding, index) => {
35
+ const location = finding.locations[0];
36
+ return {
37
+ id: formatIssueId(index),
38
+ index,
39
+ workflowName: name,
40
+ file: location?.file,
41
+ line: location?.line,
42
+ symbol: location?.symbol,
43
+ finding,
44
+ };
45
+ });
46
+ }
47
+
48
+ export function formatIssueLocation(issue: ReviewIssue): string {
49
+ if (!issue.file) return "(no location)";
50
+ const line = issue.line != null ? `:${issue.line}` : "";
51
+ const symbol = issue.symbol ? ` (${issue.symbol})` : "";
52
+ return `${issue.file}${line}${symbol}`;
53
+ }
54
+
55
+ export function isCommentableIssue(issue: ReviewIssue): boolean {
56
+ return typeof issue.file === "string" && issue.file.trim().length > 0 && typeof issue.line === "number" && Number.isFinite(issue.line);
57
+ }
58
+
59
+ function formatIssueId(index: number): string {
60
+ return `R${String(index + 1).padStart(3, "0")}`;
61
+ }
@@ -0,0 +1,56 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import type { WorkflowRunOptions } from "../types.ts";
3
+ import { isAdvisoryReport, type AdvisoryWorkflowResult } from "../ui/workflow-result-renderer.ts";
4
+ import { toReviewIssues, type ReviewIssue, type ReviewIssueSelection } from "./review-issues.ts";
5
+ import { ReviewResultsViewer } from "./review-results-viewer.ts";
6
+
7
+ export type ReviewResultsPresentationDecision =
8
+ | {
9
+ readonly kind: "send";
10
+ readonly reason: "tool-invocation" | "not-code-review" | "not-advisory" | "no-findings" | "disabled" | "not-tui" | "not-requested";
11
+ }
12
+ | { readonly kind: "open"; readonly report: AdvisoryWorkflowResult; readonly issues: readonly ReviewIssue[]; readonly findingCount: number };
13
+
14
+ export interface ReviewResultsDecisionInput {
15
+ readonly workflowName: string;
16
+ readonly result: unknown;
17
+ readonly mode?: string;
18
+ readonly hasUI: boolean;
19
+ readonly resultViewer?: WorkflowRunOptions["resultViewer"];
20
+ readonly invocationKind?: "command" | "tool";
21
+ }
22
+
23
+ export interface ReviewResultsViewerContext {
24
+ readonly ui: Pick<ExtensionCommandContext["ui"], "custom">;
25
+ }
26
+
27
+ export function extensionContextMode(ctx: ExtensionCommandContext): string | undefined {
28
+ const candidate = (ctx as ExtensionCommandContext & { readonly mode?: unknown }).mode;
29
+ return typeof candidate === "string" ? candidate : undefined;
30
+ }
31
+
32
+ export function decideReviewResultsPresentation(input: ReviewResultsDecisionInput): ReviewResultsPresentationDecision {
33
+ if (input.invocationKind === "tool") return { kind: "send", reason: "tool-invocation" };
34
+ if (input.workflowName !== "code-review") return { kind: "send", reason: "not-code-review" };
35
+ if (!isAdvisoryReport(input.result)) return { kind: "send", reason: "not-advisory" };
36
+ if (input.result.findings.length === 0) return { kind: "send", reason: "no-findings" };
37
+ if (input.resultViewer === "skip") return { kind: "send", reason: "disabled" };
38
+ if (input.mode !== "tui" || !input.hasUI) return { kind: "send", reason: "not-tui" };
39
+
40
+ if (input.resultViewer !== "open") return { kind: "send", reason: "not-requested" };
41
+
42
+ const issues = toReviewIssues(input.workflowName, input.result);
43
+ return { kind: "open", report: input.result, issues, findingCount: issues.length };
44
+ }
45
+
46
+ export async function maybeShowReviewResultsViewer(
47
+ ctx: ReviewResultsViewerContext,
48
+ decision: ReviewResultsPresentationDecision,
49
+ ): Promise<ReviewIssueSelection | undefined> {
50
+ if (decision.kind !== "open") return undefined;
51
+
52
+ return await ctx.ui.custom<ReviewIssueSelection>(
53
+ (tui, theme, _keybindings, done) => new ReviewResultsViewer(decision.issues, "code-review", theme, () => tui.requestRender(), done),
54
+ { overlay: true, overlayOptions: { anchor: "right-center", width: "80%", maxHeight: "90%", margin: 1 } },
55
+ );
56
+ }
@@ -0,0 +1,240 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { matchesKey, visibleWidth, type Component } from "@earendil-works/pi-tui";
3
+ import { renderIssueDetailLines } from "./review-format.ts";
4
+ import { formatIssueLocation, type ReviewIssue, type ReviewIssueSelection } from "./review-issues.ts";
5
+ import { truncateDisplay } from "../ui/workflow-format.ts";
6
+
7
+ const MIN_WIDTH = 40;
8
+ const SPLIT_WIDTH = 96;
9
+ const LIST_MIN_WIDTH = 34;
10
+ const LIST_MAX_WIDTH = 48;
11
+ const DETAIL_SCROLL_STEP = 5;
12
+
13
+ export class ReviewResultsViewer implements Component {
14
+ private readonly selected = new Set<string>();
15
+ private cursor = 0;
16
+ private detailScroll = 0;
17
+ private detailsExpanded = true;
18
+ private warning: string | undefined;
19
+
20
+ constructor(
21
+ private readonly issues: readonly ReviewIssue[],
22
+ private readonly workflowName: string,
23
+ private readonly theme: Theme,
24
+ private readonly requestRender: () => void,
25
+ private readonly done: (result: ReviewIssueSelection) => void,
26
+ ) {}
27
+
28
+ invalidate(): void {}
29
+
30
+ render(width: number): string[] {
31
+ const outerWidth = Math.max(MIN_WIDTH, width);
32
+ const innerWidth = Math.max(1, outerWidth - 2);
33
+ const body = this.renderBody(innerWidth);
34
+ return [this.topBorder(innerWidth), ...body.map((line) => `│${padAnsi(line, innerWidth)}│`), this.bottomBorder(innerWidth)];
35
+ }
36
+
37
+ handleInput(data: string): void {
38
+ const digitJump = digitJumpIndex(data);
39
+ if (digitJump !== undefined) {
40
+ this.jumpTo(digitJump);
41
+ return;
42
+ }
43
+
44
+ if (matchesKey(data, "up")) {
45
+ this.moveCursor(-1);
46
+ return;
47
+ }
48
+ if (matchesKey(data, "down")) {
49
+ this.moveCursor(1);
50
+ return;
51
+ }
52
+ if (matchesKey(data, "space") || data === " ") {
53
+ this.toggleCurrent();
54
+ return;
55
+ }
56
+ if (data === "a") {
57
+ this.toggleAllVisible();
58
+ return;
59
+ }
60
+ if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\r") {
61
+ this.detailsExpanded = !this.detailsExpanded;
62
+ this.detailScroll = 0;
63
+ this.warning = undefined;
64
+ this.requestRender();
65
+ return;
66
+ }
67
+ if (matchesKey(data, "pageUp") || matchesKey(data, "left")) {
68
+ this.scrollDetail(-DETAIL_SCROLL_STEP);
69
+ return;
70
+ }
71
+ if (matchesKey(data, "pageDown") || matchesKey(data, "right")) {
72
+ this.scrollDetail(DETAIL_SCROLL_STEP);
73
+ return;
74
+ }
75
+ if (data === "f") {
76
+ this.finishSelected("fix");
77
+ return;
78
+ }
79
+ if (data === "c") {
80
+ this.finishSelected("comment");
81
+ return;
82
+ }
83
+ if (data === "q" || matchesKey(data, "escape") || matchesKey(data, "esc")) {
84
+ this.done({ action: "close", issueIds: this.selectedIssueIds() });
85
+ }
86
+ }
87
+
88
+ private renderBody(width: number): string[] {
89
+ const header = this.headerLine(width);
90
+ const help = this.helpLine(width);
91
+ const warning = this.warning ? [this.theme.fg("warning", this.warning)] : [];
92
+ if (this.issues.length === 0) return [header, this.theme.fg("success", "No findings."), ...warning, help];
93
+
94
+ const split = width >= SPLIT_WIDTH;
95
+ const content = split ? this.renderSplit(width) : this.renderStacked(width);
96
+ return [header, ...warning, ...content, help];
97
+ }
98
+
99
+ private renderSplit(width: number): string[] {
100
+ const listWidth = Math.min(LIST_MAX_WIDTH, Math.max(LIST_MIN_WIDTH, Math.floor(width * 0.42)));
101
+ const detailWidth = Math.max(20, width - listWidth - 3);
102
+ const list = this.renderIssueList(listWidth);
103
+ const details = this.renderDetail(detailWidth);
104
+ const rows = Math.max(list.length, details.length);
105
+ const lines: string[] = [];
106
+ for (let index = 0; index < rows; index++) {
107
+ lines.push(`${padAnsi(list[index] ?? "", listWidth)} ${this.theme.fg("dim", "│")} ${padAnsi(details[index] ?? "", detailWidth)}`);
108
+ }
109
+ return lines;
110
+ }
111
+
112
+ private renderStacked(width: number): string[] {
113
+ return [...this.renderIssueList(width), this.theme.fg("dim", "─".repeat(Math.max(1, width))), ...this.renderDetail(width)];
114
+ }
115
+
116
+ private renderIssueList(width: number): string[] {
117
+ return this.issues.map((issue, index) => {
118
+ const cursor = index === this.cursor ? this.theme.fg("accent", ">") : " ";
119
+ const checked = this.selected.has(issue.id) ? this.theme.fg("success", "x") : " ";
120
+ const severity = this.theme.fg(severityColor(issue.finding.severity), issue.finding.severity);
121
+ const row = `${cursor} [${checked}] ${issue.id} ${severity} ${issue.finding.category} ${formatIssueLocation(issue)} — ${issue.finding.summary}`;
122
+ return truncateDisplay(row, width);
123
+ });
124
+ }
125
+
126
+ private renderDetail(width: number): string[] {
127
+ const issue = this.issues[this.cursor];
128
+ if (!issue) return [this.theme.fg("dim", "No finding selected.")];
129
+ if (!this.detailsExpanded) return [this.theme.fg("accent", issue.id), issue.finding.summary, this.theme.fg("dim", "Press enter to expand details.")];
130
+ const lines = renderIssueDetailLines(issue, this.theme, width);
131
+ const maxScroll = Math.max(0, lines.length - 1);
132
+ this.detailScroll = Math.min(this.detailScroll, maxScroll);
133
+ const visible = lines.slice(this.detailScroll, this.detailScroll + 14);
134
+ if (this.detailScroll > 0) visible.unshift(this.theme.fg("dim", "↑ more"));
135
+ if (this.detailScroll + 14 < lines.length) visible.push(this.theme.fg("dim", "↓ more"));
136
+ return visible.map((line) => truncateDisplay(line, width));
137
+ }
138
+
139
+ private headerLine(width: number): string {
140
+ const count = `${this.issues.length} finding${this.issues.length === 1 ? "" : "s"}`;
141
+ const selected = `${this.selected.size} selected`;
142
+ return truncateDisplay(`${this.theme.fg("accent", this.theme.bold("Review results"))} ${this.theme.fg("muted", this.workflowName)} · ${count} · ${selected}`, width);
143
+ }
144
+
145
+ private helpLine(width: number): string {
146
+ return truncateDisplay(this.theme.fg("dim", "↑/↓ move · 1-9 jump · space tag · a all · enter expand/collapse · ←/→ scroll · f fix · c comment · q close"), width);
147
+ }
148
+
149
+ private topBorder(innerWidth: number): string {
150
+ return `┌${this.theme.fg("dim", "─".repeat(innerWidth))}┐`;
151
+ }
152
+
153
+ private bottomBorder(innerWidth: number): string {
154
+ return `└${this.theme.fg("dim", "─".repeat(innerWidth))}┘`;
155
+ }
156
+
157
+ private moveCursor(delta: number): void {
158
+ if (this.issues.length === 0) return;
159
+ this.cursor = Math.min(this.issues.length - 1, Math.max(0, this.cursor + delta));
160
+ this.detailScroll = 0;
161
+ this.warning = undefined;
162
+ this.requestRender();
163
+ }
164
+
165
+ private jumpTo(index: number): void {
166
+ if (index < 0 || index >= this.issues.length) {
167
+ this.warning = `No finding ${index + 1}.`;
168
+ this.requestRender();
169
+ return;
170
+ }
171
+ this.cursor = index;
172
+ this.detailScroll = 0;
173
+ this.warning = undefined;
174
+ this.requestRender();
175
+ }
176
+
177
+ private toggleCurrent(): void {
178
+ const issue = this.issues[this.cursor];
179
+ if (!issue) return;
180
+ this.toggleIssue(issue.id);
181
+ this.warning = undefined;
182
+ this.requestRender();
183
+ }
184
+
185
+ private toggleAllVisible(): void {
186
+ const allSelected = this.issues.length > 0 && this.issues.every((issue) => this.selected.has(issue.id));
187
+ this.selected.clear();
188
+ if (!allSelected) {
189
+ for (const issue of this.issues) this.selected.add(issue.id);
190
+ }
191
+ this.warning = undefined;
192
+ this.requestRender();
193
+ }
194
+
195
+ private scrollDetail(delta: number): void {
196
+ this.detailScroll = Math.max(0, this.detailScroll + delta);
197
+ this.warning = undefined;
198
+ this.requestRender();
199
+ }
200
+
201
+ private finishSelected(action: "fix" | "comment"): void {
202
+ const issueIds = this.selectedIssueIds();
203
+ if (issueIds.length === 0) {
204
+ this.warning = `Select at least one finding before pressing ${action === "fix" ? "f" : "c"}.`;
205
+ this.requestRender();
206
+ return;
207
+ }
208
+ this.done({ action, issueIds });
209
+ }
210
+
211
+ private toggleIssue(id: string): void {
212
+ if (this.selected.has(id)) this.selected.delete(id);
213
+ else this.selected.add(id);
214
+ }
215
+
216
+ private selectedIssueIds(): string[] {
217
+ return this.issues.filter((issue) => this.selected.has(issue.id)).map((issue) => issue.id);
218
+ }
219
+ }
220
+
221
+ function padAnsi(text: string, width: number): string {
222
+ const truncated = truncateDisplay(text, width);
223
+ const padding = Math.max(0, width - visibleWidth(truncated));
224
+ return `${truncated}${" ".repeat(padding)}`;
225
+ }
226
+
227
+ function digitJumpIndex(data: string): number | undefined {
228
+ return data.length === 1 && data >= "1" && data <= "9" ? Number(data) - 1 : undefined;
229
+ }
230
+
231
+ function severityColor(severity: ReviewIssue["finding"]["severity"]): Parameters<Theme["fg"]>[0] {
232
+ switch (severity) {
233
+ case "high":
234
+ return "error";
235
+ case "medium":
236
+ return "warning";
237
+ case "low":
238
+ return "muted";
239
+ }
240
+ }
@@ -2,6 +2,7 @@ import type { Static, TSchema } from "typebox";
2
2
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
3
3
  import type { Pipeline } from "./concurrency.ts";
4
4
  import type { PerfSink, PerfSnapshot } from "./perf.ts";
5
+ import type { WorkflowProgressSnapshot } from "./progress.ts";
5
6
 
6
7
  /** Metadata every workflow module must export. */
7
8
  export interface WorkflowMeta {
@@ -23,12 +24,15 @@ export interface WorkflowRunOptions {
23
24
  perf?: boolean;
24
25
  concurrency?: number;
25
26
  parallelSubmissionLimit?: number;
27
+ resultViewer?: "open" | "skip";
26
28
  /** Additional abort signal to compose with the host context signal. */
27
29
  signal?: AbortSignal;
28
30
  /** Internal recorder override for command/tool invocation timing. */
29
31
  perfRecorder?: PerfSink;
30
32
  /** Called with the final performance snapshot when perf is enabled. */
31
33
  onPerfSnapshot?: (snapshot: PerfSnapshot) => void;
34
+ /** Called with the final completed progress snapshot after live workflow UI teardown. */
35
+ onProgressSnapshot?: (snapshot: WorkflowProgressSnapshot) => void;
32
36
  }
33
37
 
34
38
  /** Options for a single `agent()` call. */
@@ -1,5 +1,5 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
- import { matchesKey, type TUI, visibleWidth } from "@earendil-works/pi-tui";
2
+ import { matchesKey, type TUI, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
3
  import type { AgentRowSnapshot, WorkflowLaneItemSnapshot, WorkflowProgressSnapshot } from "../progress.ts";
4
4
  import { agentDetailParts, formatCount, formatDuration, statusIcon, truncateDisplay } from "./workflow-format.ts";
5
5
 
@@ -89,7 +89,7 @@ export class WorkflowInspector {
89
89
  const count = Math.max(1, this.itemCount(this.currentSection()));
90
90
  lines.push(
91
91
  this.row(
92
- `${th.fg("dim", `${body.length} lines · ${pct} · ${selected}/${count}`)} ${th.fg("dim", "· tab sections · ↑↓ select · enter expand · q/esc close")}`,
92
+ `${th.fg("dim", `${body.length} lines · ${pct} · ${selected}/${count}`)} ${th.fg("dim", "· tab sections · ↑↓ select · enter expand/collapse · q/esc close")}`,
93
93
  inner,
94
94
  ),
95
95
  );
@@ -208,7 +208,7 @@ export class WorkflowInspector {
208
208
  return lines.length > 0 ? lines : [{ text: ` ${this.theme.fg("dim", "No details yet.")}` }];
209
209
  }
210
210
 
211
- private findingLines(snapshot: WorkflowProgressSnapshot, _width: number): InspectorLine[] {
211
+ private findingLines(snapshot: WorkflowProgressSnapshot, width: number): InspectorLine[] {
212
212
  const lines: InspectorLine[] = [];
213
213
  let selectableIndex = -1;
214
214
  const overflowByLane = new Map(snapshot.laneOverflow);
@@ -221,7 +221,7 @@ export class WorkflowInspector {
221
221
  const key = `finding:${lane}:${index}:${item.createdAt}`;
222
222
  const selected = this.currentSection() === "Findings" && this.selected.Findings === selectableIndex;
223
223
  lines.push({ text: this.findingLine(item, selected), selectable: true, key });
224
- if (this.expanded.has(key) && item.details) lines.push({ text: ` ${this.theme.fg("dim", item.details)}` });
224
+ if (this.expanded.has(key)) lines.push(...this.findingDetails(item, width));
225
225
  });
226
226
  }
227
227
  return lines.length > 0 ? lines : [{ text: this.theme.fg("dim", "No findings lanes yet.") }];
@@ -233,6 +233,24 @@ export class WorkflowInspector {
233
233
  return selected ? this.theme.bg("selectedBg", text) : text;
234
234
  }
235
235
 
236
+ private findingDetails(item: WorkflowLaneItemSnapshot, width: number): InspectorLine[] {
237
+ return [
238
+ ...this.detailFieldLines("Title", item.title, width, "text"),
239
+ ...this.detailFieldLines("Location", item.subtitle || "(unknown)", width, "accent"),
240
+ ...this.detailFieldLines("Status", item.status, width, "muted"),
241
+ ...this.detailFieldLines("Details", item.details || "(no details)", width, "muted"),
242
+ ];
243
+ }
244
+
245
+ private detailFieldLines(label: string, value: string, width: number, color: Parameters<Theme["fg"]>[0]): InspectorLine[] {
246
+ const plainPrefix = ` ${label}: `;
247
+ const prefix = ` ${this.theme.fg("dim", `${label}:`)} `;
248
+ const continuation = ` ${" ".repeat(visibleWidth(`${label}: `))}`;
249
+ const wrapped = wrapTextWithAnsi(this.theme.fg(color, value), Math.max(10, width - visibleWidth(plainPrefix) - 4));
250
+ if (wrapped.length === 0) return [{ text: prefix }];
251
+ return wrapped.map((line, index) => ({ text: `${index === 0 ? prefix : continuation}${line}` }));
252
+ }
253
+
236
254
  private logLines(snapshot: WorkflowProgressSnapshot): InspectorLine[] {
237
255
  if (snapshot.logs.length === 0) return [{ text: this.theme.fg("dim", "No logs yet.") }];
238
256
  return snapshot.logs.map((log, index) => {
@@ -2,7 +2,9 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { Box, type Component, Text } from "@earendil-works/pi-tui";
3
3
  import type { AdvisoryFinding, AdvisoryLocation, AdvisoryReport } from "../advisory-schema.ts";
4
4
  import type { PerfAggregate } from "../perf.ts";
5
- import { badge, formatCount, type WorkflowThemeColor } from "./workflow-format.ts";
5
+ import { renderIssueDetails, renderIssuesTable } from "../review/review-format.ts";
6
+ import { toReviewIssues, type ReviewContext } from "../review/review-issues.ts";
7
+ import { formatCount } from "./workflow-format.ts";
6
8
 
7
9
  export interface WorkflowPerfDetails {
8
10
  readonly enabled: boolean;
@@ -19,6 +21,7 @@ export interface WorkflowResultEnvelope {
19
21
 
20
22
  export interface AdvisoryWorkflowResult extends AdvisoryReport {
21
23
  stats?: Record<string, string | number>;
24
+ reviewContext?: ReviewContext;
22
25
  }
23
26
 
24
27
  export function isWorkflowResult(value: unknown): value is WorkflowResultEnvelope {
@@ -82,33 +85,18 @@ function renderAdvisoryResult(name: string, result: AdvisoryWorkflowResult, expa
82
85
  return lines.join("\n");
83
86
  }
84
87
 
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)`));
88
+ const issues = toReviewIssues(name, result);
89
+ lines.push(theme.fg("dim", "Findings:"));
90
+ lines.push(renderIssuesTable(issues, theme, { maxRows: expanded ? issues.length : 12 }));
91
+ if (expanded) {
92
+ for (const issue of issues) {
93
+ lines.push(renderIssueDetails(issue, theme));
94
+ }
92
95
  }
93
96
  if (expanded && result.nextSteps.length > 0) renderNextSteps(result.nextSteps, lines, theme);
94
97
  return lines.join("\n");
95
98
  }
96
99
 
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
100
  function renderNextSteps(nextSteps: string[], lines: string[], theme: Theme): void {
113
101
  lines.push(theme.fg("dim", "Next steps:"));
114
102
  for (const step of nextSteps) {
@@ -116,35 +104,6 @@ function renderNextSteps(nextSteps: string[], lines: string[], theme: Theme): vo
116
104
  }
117
105
  }
118
106
 
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
107
  function renderGenericWorkflowResult(name: string, result: unknown, expanded: boolean, theme: Theme): string {
149
108
  const lines = [`${theme.fg("success", "✓")} ${theme.fg("accent", theme.bold(`Workflow: ${name}`))}`];
150
109
  const summary = extractSummary(result);
@@ -16,6 +16,7 @@ import {
16
16
  verdictConfidence,
17
17
  } from "../src/workflow-advisory-utils.ts";
18
18
  import { captureDiff } from "../src/diff-capture.ts";
19
+ import type { ReviewContext } from "../src/review/review-issues.ts";
19
20
  import type { WorkflowApi, WorkflowMeta, WorkflowRunStats } from "../src/types.ts";
20
21
 
21
22
  export const meta: WorkflowMeta = {
@@ -137,6 +138,15 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
137
138
  if (scope.files.length === 0) {
138
139
  return { summary: "No changes found to review.", findings: [], nextSteps: ["Provide a PR, ref range, or changed files to review."], stats: makeStats(0, 0) };
139
140
  }
141
+
142
+ const reviewContext: ReviewContext = {
143
+ workflowName: "code-review",
144
+ target,
145
+ diffCommand: scope.diffCommand,
146
+ files: scope.files,
147
+ summary: scope.summary,
148
+ };
149
+
140
150
  log(`${scope.files.length} changed files`);
141
151
 
142
152
  // Capture the diff once, deterministically, so findings can be bounded to changed lines in code.
@@ -259,7 +269,7 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
259
269
  log(`${verified.length} verified → ${surviving.length} kept`);
260
270
 
261
271
  if (surviving.length === 0) {
262
- return { summary: "No findings survived verification.", findings: [], nextSteps: ["No code-review action is recommended from this workflow run."], stats };
272
+ return { summary: "No findings survived verification.", findings: [], nextSteps: ["No code-review action is recommended from this workflow run."], stats, reviewContext };
263
273
  }
264
274
 
265
275
  // ─── Synthesize: rank, merge, report ───
@@ -283,10 +293,10 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
283
293
  { phase: "Synthesize", label: "synthesize", thinkingLevel: "medium", schema: AdvisoryReportSchema },
284
294
  );
285
295
 
286
- if (!report) return { summary: "Synthesis produced no output.", findings: [], nextSteps: ["Re-run the workflow or inspect verifier evidence manually."], stats };
296
+ if (!report) return { summary: "Synthesis produced no output.", findings: [], nextSteps: ["Re-run the workflow or inspect verifier evidence manually."], stats, reviewContext };
287
297
 
288
298
  const findings = backfillAdvisoryFindings(report.findings, ranked, {
289
299
  impact: "Impact not restated by synthesis.",
290
300
  });
291
- return { ...report, findings, stats };
301
+ return { ...report, findings, stats, reviewContext };
292
302
  }