pi-workflow-engine 0.13.0 → 1.0.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 (92) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +106 -391
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +1 -1
  3. package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +8 -8
  4. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +7 -1
  5. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +48 -62
  6. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +46 -67
  7. package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +14 -37
  8. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +5 -11
  9. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +15 -21
  10. package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +13 -43
  11. package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +17 -5
  12. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +93 -100
  13. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +11 -43
  14. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +4 -18
  15. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +33 -86
  16. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +1 -5
  17. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +25 -80
  18. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +16 -23
  19. package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +1 -4
  20. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +39 -98
  21. package/.pi/extensions/pi-workflow-engine/src/engine.ts +37 -80
  22. package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +2 -10
  23. package/.pi/extensions/pi-workflow-engine/src/git.ts +55 -0
  24. package/.pi/extensions/pi-workflow-engine/src/guards.ts +8 -0
  25. package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +7 -19
  26. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +171 -106
  27. package/.pi/extensions/pi-workflow-engine/src/journal.ts +0 -12
  28. package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +37 -8
  29. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +15 -66
  30. package/.pi/extensions/pi-workflow-engine/src/options.ts +97 -16
  31. package/.pi/extensions/pi-workflow-engine/src/perf.ts +43 -72
  32. package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +14 -4
  33. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +2 -0
  34. package/.pi/extensions/pi-workflow-engine/src/progress.ts +79 -80
  35. package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +28 -20
  36. package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +2 -7
  37. package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +0 -1
  38. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +27 -31
  39. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +75 -139
  40. package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +14 -31
  41. package/.pi/extensions/pi-workflow-engine/src/review/patch-validation.ts +3 -4
  42. package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +1 -1
  43. package/.pi/extensions/pi-workflow-engine/src/review/review-diff-lines.ts +54 -0
  44. package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +7 -9
  45. package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +51 -117
  46. package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +1 -1
  47. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +8 -13
  48. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +3 -7
  49. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +158 -241
  50. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +28 -38
  51. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +35 -44
  52. package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +13 -14
  53. package/.pi/extensions/pi-workflow-engine/src/text.ts +29 -0
  54. package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +2 -1
  55. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +16 -28
  56. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +34 -14
  57. package/.pi/extensions/pi-workflow-engine/src/types.ts +6 -0
  58. package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +193 -0
  59. package/.pi/extensions/pi-workflow-engine/src/ui/research-result-renderer.ts +177 -0
  60. package/.pi/extensions/pi-workflow-engine/src/ui/result-layout.ts +209 -0
  61. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +17 -44
  62. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +703 -235
  63. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-picker.ts +48 -0
  64. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +239 -129
  65. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +461 -0
  66. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +91 -0
  67. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +316 -98
  68. package/.pi/extensions/pi-workflow-engine/src/usage.ts +21 -32
  69. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +56 -19
  70. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +19 -24
  71. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +17 -37
  72. package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +79 -0
  73. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +4 -14
  74. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +77 -63
  75. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +42 -29
  76. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +15 -14
  77. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +27 -31
  78. package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +1 -5
  79. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +3 -6
  80. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +48 -110
  81. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +64 -93
  82. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +25 -67
  83. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +19 -53
  84. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +21 -49
  85. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +74 -66
  86. package/USAGE.md +31 -17
  87. package/package.json +1 -2
  88. package/skills/workflow-code-review-actions/SKILL.md +12 -23
  89. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +0 -81
  90. package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +0 -24
  91. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +0 -52
  92. package/.pi/settings.json +0 -3
@@ -0,0 +1,48 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { SelectList, visibleWidth, type Component, type SelectItem, type SelectListTheme } from "@earendil-works/pi-tui";
3
+ import { dot, fit, keyHints } from "./kit.ts";
4
+
5
+ const MAX_VISIBLE = 10;
6
+ const NAME_GAP = 2;
7
+
8
+ /**
9
+ * The `/workflow` picker: pi's own `SelectList` (so names and descriptions align like the slash
10
+ * autocomplete) under a title, with key hints below, between two rules like pi's native selectors.
11
+ * Items without a description (the author option) render as a full-width label.
12
+ */
13
+ export class WorkflowPicker implements Component {
14
+ private readonly list: SelectList;
15
+
16
+ constructor(
17
+ private readonly items: readonly SelectItem[],
18
+ private readonly theme: Theme,
19
+ listTheme: SelectListTheme,
20
+ done: (value: string | undefined) => void,
21
+ ) {
22
+ const nameWidth = Math.max(0, ...items.filter((item) => item.description).map((item) => visibleWidth(item.label))) + NAME_GAP;
23
+ this.list = new SelectList([...items], Math.min(MAX_VISIBLE, items.length), listTheme, {
24
+ minPrimaryColumnWidth: nameWidth,
25
+ maxPrimaryColumnWidth: nameWidth,
26
+ });
27
+ this.list.onSelect = (item) => done(item.value);
28
+ this.list.onCancel = () => done(undefined);
29
+ }
30
+
31
+ invalidate(): void {
32
+ this.list.invalidate();
33
+ }
34
+
35
+ render(width: number): string[] {
36
+ const inner = Math.max(1, width - 2);
37
+ const rule = this.theme.fg("border", "─".repeat(Math.max(1, width)));
38
+ const count = this.items.filter((item) => item.description).length;
39
+ const title = `${this.theme.fg("accent", this.theme.bold("Run a workflow"))}${dot(this.theme)}${this.theme.fg("muted", `${count} available`)}`;
40
+ const hints = keyHints([["↑↓", "move"], ["enter", "run"], ["esc", "cancel"]], this.theme);
41
+ const pad = (line: string) => ` ${fit(line, inner)} `;
42
+ return [rule, pad(title), "", ...this.list.render(inner).map(pad), "", pad(hints), rule];
43
+ }
44
+
45
+ handleInput(data: string): void {
46
+ this.list.handleInput(data);
47
+ }
48
+ }
@@ -1,171 +1,281 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
- import { Box, type Component, Text } from "@earendil-works/pi-tui";
2
+ import { Box, truncateToWidth, type Component } from "@earendil-works/pi-tui";
3
3
  import { isAdvisoryReport, type AdvisoryReportWithStats } from "../advisory-schema.ts";
4
- import { renderIssueDetails, renderIssuesTable } from "../review/review-format.ts";
5
- import { toReviewIssues } from "../review/review-issues.ts";
6
- import { formatCount } from "./workflow-format.ts";
7
- import { formatWorkflowUsageLine } from "../usage.ts";
8
- import type { WorkflowPerfDetails, WorkflowResultEnvelope } from "../workflow-execution.ts";
9
- import { unknownErrorMessage } from "../unknown-error.ts";
10
-
11
- export function isWorkflowResult(value: unknown): value is WorkflowResultEnvelope {
4
+ import { isRecord } from "../guards.ts";
5
+ import { findingPathColumn, renderFindingDetail, renderFindingRow, sortIssuesForDisplay } from "../review/review-format.ts";
6
+ import { toReviewIssues, type ReviewIssue } from "../review/review-issues.ts";
7
+ import { formatCount, prettyJson } from "../text.ts";
8
+ import { workflowResultSummary } from "../workflow-execution.ts";
9
+ import { GLYPH, joinParts, severityColor, type Severity } from "./kit.ts";
10
+ import { isResearchReport, renderResearchResult } from "./research-result-renderer.ts";
11
+ import {
12
+ agentCount,
13
+ bullets,
14
+ clips,
15
+ footerLine,
16
+ headerLine,
17
+ indent,
18
+ innerWidth,
19
+ labelled,
20
+ LinesComponent,
21
+ paragraph,
22
+ plural,
23
+ runDetailRows,
24
+ type HintPair,
25
+ type ResultFrame,
26
+ type WorkflowResultHints,
27
+ type WorkflowResultView,
28
+ } from "./result-layout.ts";
29
+
30
+ export type { WorkflowResultHints, WorkflowResultView } from "./result-layout.ts";
31
+
32
+ const COLLAPSED_SUMMARY_LINES = 2;
33
+ const COLLAPSED_GENERIC_LINES = 3;
34
+ const COLLAPSED_FINDINGS = 6;
35
+
36
+ /** Recognises persisted result-envelope details by the fields every envelope carries. */
37
+ export function isWorkflowResult(value: unknown): value is WorkflowResultView {
12
38
  if (!isRecord(value)) return false;
13
39
  return typeof value.name === "string" && "result" in value && typeof value.completedAt === "number";
14
40
  }
15
41
 
16
- export interface WorkflowRunDisplayMetadata {
17
- readonly runId?: string;
18
- readonly resumedFromRunId?: string;
42
+ /** The `workflow-result` message: the result on pi's custom-message background. */
43
+ export function renderWorkflowResult(view: WorkflowResultView, expanded: boolean, theme: Theme, hints: WorkflowResultHints = {}): Component {
44
+ const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
45
+ box.addChild(workflowResultComponent(view, expanded, theme, hints));
46
+ return box;
19
47
  }
20
48
 
21
- export interface WorkflowDetailLineInput {
22
- readonly usage?: unknown;
23
- readonly perf?: WorkflowPerfDetails;
24
- readonly metadata?: WorkflowRunDisplayMetadata;
49
+ /** The bare result, for surfaces that already provide a shell (the workflow tool row). */
50
+ export function workflowResultComponent(view: WorkflowResultView, expanded: boolean, theme: Theme, hints: WorkflowResultHints = {}): Component {
51
+ return new LinesComponent((width) => renderWorkflowResultLines(view, expanded, width, theme, hints));
25
52
  }
26
53
 
27
- export function renderWorkflowResult(
28
- name: string,
29
- result: unknown,
54
+ export function renderWorkflowResultLines(
55
+ view: WorkflowResultView,
30
56
  expanded: boolean,
57
+ width: number,
31
58
  theme: Theme,
32
- usage?: unknown,
33
- metadata?: WorkflowRunDisplayMetadata,
34
- perf?: WorkflowPerfDetails,
35
- ): Component {
36
- const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
37
- box.addChild(new Text(renderWorkflowResultText(name, result, expanded, theme, usage, metadata, perf), 0, 0));
38
- return box;
59
+ hints: WorkflowResultHints = {},
60
+ ): string[] {
61
+ const frame: ResultFrame = { view, expanded, width, theme, hints };
62
+ const lines = view.background === true && isInterruptedState(view.status) ? renderInterruptedRun(frame, view.status)
63
+ : isAdvisoryReport(view.result) ? renderAdvisoryResult(frame, view.result)
64
+ : isResearchReport(view.result) ? renderResearchResult(frame, view.result)
65
+ : renderGenericResult(frame);
66
+ return lines.map((line) => truncateToWidth(line, width, "…"));
39
67
  }
40
68
 
41
- export function renderWorkflowResultText(
42
- name: string,
43
- result: unknown,
44
- expanded: boolean,
45
- theme: Theme,
46
- usage?: unknown,
47
- metadata?: WorkflowRunDisplayMetadata,
48
- perf?: WorkflowPerfDetails,
49
- ): string {
50
- if (isAdvisoryReport(result)) {
51
- return renderAdvisoryResult(name, result, expanded, theme, usage, metadata, perf);
69
+ function renderAdvisoryResult(frame: ResultFrame, report: AdvisoryReportWithStats): string[] {
70
+ const { view, expanded, theme, hints } = frame;
71
+ const width = innerWidth(frame);
72
+ const issues = sortIssuesForDisplay(toReviewIssues(report));
73
+ const incomplete = report.status === "incomplete";
74
+ const failures = incompleteStages(report.coverage ?? []);
75
+ const [findings, ...severities] = findingFacts(issues, incomplete, theme);
76
+ const facts = [
77
+ incomplete ? theme.fg("warning", "incomplete") : undefined,
78
+ ...failures.map((failure) => theme.fg("warning", failure)),
79
+ findings,
80
+ ];
81
+ const glyph = incomplete ? theme.fg("warning", GLYPH.warning) : theme.fg("success", GLYPH.done);
82
+ const lines = [headerLine(glyph, frame, facts, severities)];
83
+ // An incomplete report's summary restates the header ("Incomplete review: n branch(es) failed…"); show it expanded only.
84
+ if (expanded || !incomplete) {
85
+ lines.push(...indent(paragraph(report.summary, width, (line) => theme.fg("text", line), expanded ? undefined : COLLAPSED_SUMMARY_LINES)));
52
86
  }
53
- return renderGenericWorkflowResult(name, result, expanded, theme, usage, metadata, perf);
54
- }
87
+ const gaps = report.gaps ?? [];
88
+ if (!expanded && gaps.length > 0) lines.push(...indent([gapLine(gaps, theme)]));
55
89
 
56
- function renderAdvisoryResult(
57
- name: string,
58
- result: AdvisoryReportWithStats,
59
- expanded: boolean,
60
- theme: Theme,
61
- usage?: unknown,
62
- metadata?: WorkflowRunDisplayMetadata,
63
- perf?: WorkflowPerfDetails,
64
- ): string {
65
- const incomplete = result.status === "incomplete";
66
- const icon = incomplete ? theme.fg("warning", "⚠") : theme.fg("success", "✓");
67
- const title = theme.fg("accent", theme.bold(`Workflow: ${name}`));
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));
71
- const stats = statsLine(result.stats, theme);
72
- if (stats) lines.push(stats);
73
- pushWorkflowDetailLines(lines, theme, { usage, metadata, perf });
74
-
75
- if (result.findings.length === 0) {
76
- lines.push(incomplete ? theme.fg("warning", "No verified findings; coverage is incomplete.") : theme.fg("success", "No findings."));
77
- if (expanded && result.nextSteps.length > 0) renderNextSteps(result.nextSteps, lines, theme);
78
- return lines.join("\n");
90
+ if (issues.length > 0) {
91
+ lines.push("");
92
+ if (expanded) {
93
+ issues.forEach((issue, index) => {
94
+ if (index > 0) lines.push("");
95
+ lines.push(...indent(renderFindingDetail(issue, width, theme)));
96
+ });
97
+ } else {
98
+ const shown = issues.slice(0, COLLAPSED_FINDINGS);
99
+ const column = findingPathColumn(shown);
100
+ lines.push(...indent(shown.map((issue) => renderFindingRow(issue, width, theme, column))));
101
+ const hidden = issues.length - COLLAPSED_FINDINGS;
102
+ if (hidden > 0) lines.push(...indent([theme.fg("dim", `+${plural(hidden, "more finding", "more findings")}`)]));
103
+ }
79
104
  }
80
105
 
81
- const issues = toReviewIssues(name, result);
82
- lines.push(theme.fg("dim", "Findings:"));
83
- lines.push(renderIssuesTable(issues, theme, { maxRows: expanded ? issues.length : 12 }));
84
106
  if (expanded) {
85
- for (const issue of issues) {
86
- lines.push(renderIssueDetails(issue, theme));
87
- }
107
+ const rows = [
108
+ ...labelled(frame, "Coverage", coverageItems(report, theme)),
109
+ ...labelled(frame, "Gaps", gaps.map((gap) => theme.fg("warning", `${GLYPH.failed} ${gap}`)), " "),
110
+ ...labelled(frame, "Funnel", funnelItems(report.stats, theme)),
111
+ ...labelled(frame, "Follow-up", bullets(report.nextSteps), " "),
112
+ ...runDetailRows(frame),
113
+ ];
114
+ if (rows.length > 0) lines.push("", ...rows);
88
115
  }
89
- if (expanded && result.nextSteps.length > 0) renderNextSteps(result.nextSteps, lines, theme);
90
- return lines.join("\n");
91
- }
92
116
 
93
- function renderNextSteps(nextSteps: string[], lines: string[], theme: Theme): void {
94
- lines.push(theme.fg("dim", "Next steps:"));
95
- for (const step of nextSteps) {
96
- lines.push(` - ${theme.fg("muted", step)}`);
97
- }
117
+ const pairs: HintPair[] = hints.triage && view.name === "code-review" && issues.length > 0 ? [[hints.triage, "triage"]] : [];
118
+ const footer = footerLine(frame, pairs, { more: true });
119
+ if (footer) lines.push(footer);
120
+ return lines;
98
121
  }
99
122
 
100
- function renderGenericWorkflowResult(
101
- name: string,
102
- result: unknown,
103
- expanded: boolean,
104
- theme: Theme,
105
- usage?: unknown,
106
- metadata?: WorkflowRunDisplayMetadata,
107
- perf?: WorkflowPerfDetails,
108
- ): string {
109
- const lines = [`${theme.fg("success", "✓")} ${theme.fg("accent", theme.bold(`Workflow: ${name}`))}`];
110
- const summary = extractSummary(result);
111
- if (summary) lines.push(theme.fg("muted", summary));
112
- pushWorkflowDetailLines(lines, theme, { usage, metadata, perf });
113
- if (expanded) lines.push(theme.fg("dim", safeJson(result)));
114
- else if (!summary) lines.push(theme.fg("dim", "Result available in expanded view."));
115
- return lines.join("\n");
116
- }
117
-
118
- function pushWorkflowDetailLines(lines: string[], theme: Theme, input: WorkflowDetailLineInput): void {
119
- for (const line of formatWorkflowDetailLines(input)) {
120
- lines.push(theme.fg("dim", line));
121
- }
123
+ /** `3 findings · 1 high · 1 medium`, zero severities hidden; `no findings` when the report is clean. */
124
+ function findingFacts(issues: readonly ReviewIssue[], incomplete: boolean, theme: Theme): string[] {
125
+ if (issues.length === 0) return [theme.fg(incomplete ? "warning" : "success", incomplete ? "no verified findings" : "no findings")];
126
+ const severities: Severity[] = ["high", "medium", "low"];
127
+ return [
128
+ theme.fg("text", plural(issues.length, "finding")),
129
+ ...severities.flatMap((severity) => {
130
+ const count = issues.filter((issue) => issue.finding.severity === severity).length;
131
+ return count > 0 ? [theme.fg(severityColor(severity), `${count} ${severity}`)] : [];
132
+ }),
133
+ ];
122
134
  }
123
135
 
124
- export function formatWorkflowDetailLines(input: WorkflowDetailLineInput): string[] {
125
- return [
126
- formatWorkflowRunLine(input.metadata),
127
- formatWorkflowUsageLine(input.usage),
128
- formatWorkflowPerfLine(input.perf),
129
- ].filter((line): line is string => line !== undefined);
136
+ type AdvisoryCoverage = NonNullable<AdvisoryReportWithStats["coverage"]>[number];
137
+
138
+ const STAGE_AGENTS: Readonly<Record<string, string>> = { Find: "finders", Verify: "verifiers", Challenge: "challengers" };
139
+
140
+ /** `1 of 5 finders failed`, `Synthesize failed`: one phrase per stage that lost work. */
141
+ function incompleteStages(coverage: readonly AdvisoryCoverage[]): string[] {
142
+ return coverage.flatMap((stage) => {
143
+ const missing = stage.failed > 0 ? stage.failed : stage.expected - stage.completed;
144
+ if (missing <= 0) return [];
145
+ const outcome = stage.failed > 0 ? "failed" : "missing";
146
+ if (stage.expected <= 1) return [`${stage.stage} ${outcome}`];
147
+ return [`${missing} of ${stage.expected} ${STAGE_AGENTS[stage.stage] ?? `${stage.stage} branches`} ${outcome}`];
148
+ });
130
149
  }
131
150
 
132
- export function formatWorkflowRunLine(metadata: WorkflowRunDisplayMetadata | undefined): string | undefined {
133
- if (!metadata?.runId) return undefined;
134
- return metadata.resumedFromRunId ? `Run: ${metadata.runId} (resumed from ${metadata.resumedFromRunId})` : `Run: ${metadata.runId}`;
151
+ /** The first coverage gap, with a count of the rest: `✗ Find/error-paths: provider failed · +1 more`. */
152
+ function gapLine(gaps: readonly string[], theme: Theme): string {
153
+ const more = gaps.length > 1 ? theme.fg("dim", ` · +${gaps.length - 1} more`) : "";
154
+ return `${theme.fg("error", GLYPH.failed)} ${theme.fg("warning", gaps[0] ?? "")}${more}`;
135
155
  }
136
156
 
137
- export function formatWorkflowPerfLine(perf: WorkflowPerfDetails | undefined): string | undefined {
138
- if (!perf) return undefined;
139
- const parts = perf.aggregates.slice(0, 4).map((aggregate) => `${aggregate.name} ${Math.round(aggregate.total)}ms`);
140
- return parts.length > 0 ? `Perf: ${parts.join(" · ")}` : "Perf: no samples";
157
+ /** `9 of 10 branches complete · Find 4 of 5`: totals, then only the stages that lost work. */
158
+ function coverageItems(report: AdvisoryReportWithStats, theme: Theme): string[] {
159
+ const coverage = report.coverage ?? [];
160
+ if (coverage.length === 0) return [];
161
+ const expected = coverage.reduce((sum, stage) => sum + stage.expected, 0);
162
+ const completed = coverage.reduce((sum, stage) => sum + stage.completed, 0);
163
+ const lossy = coverage
164
+ .filter((stage) => stage.completed < stage.expected)
165
+ .map((stage) => theme.fg("warning", `${stage.stage} ${stage.completed} of ${stage.expected}${stage.failed > 0 ? ` (${stage.failed} failed)` : ""}`));
166
+ return [joinParts([theme.fg(lossy.length > 0 ? "warning" : "muted", `${completed} of ${plural(expected, "branch", "branches")} complete`), ...lossy], theme)];
141
167
  }
142
168
 
143
- function statsLine(stats: Record<string, string | number> | undefined, theme: Theme): string | undefined {
144
- if (!stats) return undefined;
145
- const ordered = ["files", "candidates", "dropped", "verified", "kept"];
146
- const parts = ordered.flatMap((key) => {
169
+ const FUNNEL_STAGES: readonly (readonly [key: string, singular: string, pluralLabel: string])[] = [
170
+ ["files", "file", "files"],
171
+ ["candidates", "candidate", "candidates"],
172
+ ["verified", "verified", "verified"],
173
+ ["kept", "kept", "kept"],
174
+ ];
175
+
176
+ /** `3 files → 3 candidates → 3 verified → 3 kept · 1 refuted`, zero side counts hidden. */
177
+ function funnelItems(stats: AdvisoryReportWithStats["stats"], theme: Theme): string[] {
178
+ if (!stats) return [];
179
+ const known = new Set(FUNNEL_STAGES.map(([key]) => key));
180
+ const stages = FUNNEL_STAGES.flatMap(([key, singular, pluralLabel]) => {
147
181
  const value = stats[key];
148
182
  if (value === undefined) return [];
149
- return [`${key} ${typeof value === "number" ? formatCount(value) : value}`];
183
+ return [typeof value === "number" ? `${formatCount(value)} ${value === 1 ? singular : pluralLabel}` : `${value} ${pluralLabel}`];
150
184
  });
151
- if (parts.length === 0) return undefined;
152
- return theme.fg("dim", parts.join(" · "));
185
+ const extras = Object.entries(stats).flatMap(([key, value]) => {
186
+ if (known.has(key) || value === 0 || value === "") return [];
187
+ return [`${typeof value === "number" ? formatCount(value) : value} ${key}`];
188
+ });
189
+ const funnel = stages.join(theme.fg("dim", ` ${GLYPH.arrow} `));
190
+ const line = joinParts([funnel ? theme.fg("muted", funnel) : undefined, ...extras.map((extra) => theme.fg("muted", extra))], theme);
191
+ return line ? [line] : [];
192
+ }
193
+
194
+ function renderGenericResult(frame: ResultFrame): string[] {
195
+ const { view, expanded, theme } = frame;
196
+ const width = innerWidth(frame);
197
+ const summary = workflowResultSummary(view.result);
198
+ const legacyBackground = view.background === true;
199
+ const agents = agentCount(view);
200
+ const facts = [theme.fg("success", "done"), agents > 0 ? theme.fg("muted", plural(agents, "agent")) : undefined];
201
+ const lines = [headerLine(theme.fg("success", GLYPH.done), frame, facts)];
202
+
203
+ const json = resultJson(view.result, summary !== undefined);
204
+ let more = json !== undefined;
205
+ if (summary) {
206
+ const style = (line: string) => theme.fg("text", line);
207
+ lines.push(...indent(paragraph(summary, width, style, expanded ? undefined : COLLAPSED_GENERIC_LINES)));
208
+ more ||= clips(summary, width, COLLAPSED_GENERIC_LINES);
209
+ } else if (!expanded) {
210
+ lines.push(...indent([theme.fg("dim", resultShape(view.result))]));
211
+ }
212
+ if (expanded && json !== undefined) {
213
+ if (summary) lines.push("");
214
+ lines.push(...indent(jsonLines(json, width, theme)));
215
+ }
216
+ if (expanded) {
217
+ const rows = runDetailRows(frame, { agents: false });
218
+ if (rows.length > 0) lines.push("", ...rows);
219
+ }
220
+
221
+ const pairs: HintPair[] = legacyBackground ? [["/workflow:runs", "run history"]] : [];
222
+ const footer = footerLine(frame, pairs, { more: more || Boolean(view.runId) });
223
+ if (footer) lines.push(footer);
224
+ return lines;
153
225
  }
154
226
 
155
- function extractSummary(value: unknown): string | undefined {
156
- if (typeof value === "string") return value;
157
- if (isRecord(value) && typeof value.summary === "string") return value.summary;
158
- return undefined;
227
+ /** The result as pretty JSON, minus the summary already shown above it; undefined when nothing is left. */
228
+ function resultJson(result: unknown, summaryShown: boolean): string | undefined {
229
+ if (typeof result === "string") return undefined;
230
+ if (!summaryShown || !isRecord(result)) return prettyJson(result);
231
+ const rest = Object.entries(result).filter(([key]) => key !== "summary");
232
+ return rest.length > 0 ? prettyJson(Object.fromEntries(rest)) : undefined;
159
233
  }
160
234
 
161
- function safeJson(value: unknown): string {
162
- try {
163
- return JSON.stringify(value, null, 2) ?? String(value);
164
- } catch (error) {
165
- return unknownErrorMessage(error);
235
+ /** What a summary-less result holds: `result · areas, risks, notes` or `result · 12 items`. */
236
+ function resultShape(result: unknown): string {
237
+ if (result === undefined || result === null) return "no result";
238
+ if (Array.isArray(result)) return `result · ${plural(result.length, "item")}`;
239
+ if (isRecord(result)) {
240
+ const keys = Object.keys(result);
241
+ return keys.length === 0 ? "empty result" : `result · ${keys.join(", ")}`;
166
242
  }
243
+ return `result · ${String(result)}`;
244
+ }
245
+
246
+ /** Pretty JSON, each long line wrapped under its own indentation. */
247
+ function jsonLines(json: string, width: number, theme: Theme): string[] {
248
+ return json.split("\n").flatMap((line) => {
249
+ const depth = line.length - line.trimStart().length;
250
+ const lead = " ".repeat(depth);
251
+ return paragraph(line.trimStart(), Math.max(8, width - depth - 2), (part) => part).map((part, index) =>
252
+ theme.fg("dim", `${index === 0 ? lead : `${lead} `}${part}`));
253
+ });
167
254
  }
168
255
 
169
- function isRecord(value: unknown): value is Record<string, unknown> {
170
- return typeof value === "object" && value !== null;
256
+ type InterruptedState = "failed" | "stopped" | "paused";
257
+
258
+ function isInterruptedState(status: unknown): status is InterruptedState {
259
+ return status === "failed" || status === "stopped" || status === "paused";
260
+ }
261
+
262
+ /** A background run that ended without a result: what happened, and where to resume it. */
263
+ function renderInterruptedRun(frame: ResultFrame, status: InterruptedState): string[] {
264
+ const { view, expanded, theme } = frame;
265
+ const color = status === "failed" ? "error" : "warning";
266
+ const glyph = theme.fg(color, status === "failed" ? GLYPH.failed : GLYPH.warning);
267
+ const lines = [headerLine(glyph, frame, [theme.fg(color, status)])];
268
+ const summary = workflowResultSummary(view.result);
269
+ if (summary) {
270
+ lines.push(...indent(paragraph(summary, innerWidth(frame), (line) => theme.fg("muted", line), expanded ? undefined : COLLAPSED_GENERIC_LINES)));
271
+ }
272
+ if (expanded) {
273
+ const rows = runDetailRows(frame);
274
+ if (rows.length > 0) lines.push("", ...rows);
275
+ }
276
+ const footer = footerLine(frame, [["/workflow:runs", status === "failed" ? "resume or restart" : "resume"]], {
277
+ more: Boolean(view.runId) || (summary !== undefined && clips(summary, innerWidth(frame), COLLAPSED_GENERIC_LINES)),
278
+ });
279
+ if (footer) lines.push(footer);
280
+ return lines;
171
281
  }