pi-workflow-engine 0.12.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 +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 +20 -2
  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 +18 -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 +37 -86
  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 +1 -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 +128 -142
  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 +119 -0
  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 +41 -7
  45. package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +51 -116
  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 +9 -14
  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 +9 -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 -126
  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 +178 -188
  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 +55 -110
  81. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +101 -203
  82. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +51 -187
  83. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +33 -109
  84. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +36 -106
  85. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +74 -66
  86. package/USAGE.md +90 -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 -49
  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,168 +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 icon = theme.fg("success", "✓");
66
- const title = theme.fg("accent", theme.bold(`Workflow: ${name}`));
67
- const lines = [`${icon} ${title}`, theme.fg("muted", result.summary)];
68
- const stats = statsLine(result.stats, theme);
69
- if (stats) lines.push(stats);
70
- pushWorkflowDetailLines(lines, theme, { usage, metadata, perf });
71
-
72
- if (result.findings.length === 0) {
73
- lines.push(theme.fg("success", "No findings."));
74
- if (expanded && result.nextSteps.length > 0) renderNextSteps(result.nextSteps, lines, theme);
75
- 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
+ }
76
104
  }
77
105
 
78
- const issues = toReviewIssues(name, result);
79
- lines.push(theme.fg("dim", "Findings:"));
80
- lines.push(renderIssuesTable(issues, theme, { maxRows: expanded ? issues.length : 12 }));
81
106
  if (expanded) {
82
- for (const issue of issues) {
83
- lines.push(renderIssueDetails(issue, theme));
84
- }
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);
85
115
  }
86
- if (expanded && result.nextSteps.length > 0) renderNextSteps(result.nextSteps, lines, theme);
87
- return lines.join("\n");
88
- }
89
116
 
90
- function renderNextSteps(nextSteps: string[], lines: string[], theme: Theme): void {
91
- lines.push(theme.fg("dim", "Next steps:"));
92
- for (const step of nextSteps) {
93
- lines.push(` - ${theme.fg("muted", step)}`);
94
- }
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;
95
121
  }
96
122
 
97
- function renderGenericWorkflowResult(
98
- name: string,
99
- result: unknown,
100
- expanded: boolean,
101
- theme: Theme,
102
- usage?: unknown,
103
- metadata?: WorkflowRunDisplayMetadata,
104
- perf?: WorkflowPerfDetails,
105
- ): string {
106
- const lines = [`${theme.fg("success", "✓")} ${theme.fg("accent", theme.bold(`Workflow: ${name}`))}`];
107
- const summary = extractSummary(result);
108
- if (summary) lines.push(theme.fg("muted", summary));
109
- pushWorkflowDetailLines(lines, theme, { usage, metadata, perf });
110
- if (expanded) lines.push(theme.fg("dim", safeJson(result)));
111
- else if (!summary) lines.push(theme.fg("dim", "Result available in expanded view."));
112
- return lines.join("\n");
113
- }
114
-
115
- function pushWorkflowDetailLines(lines: string[], theme: Theme, input: WorkflowDetailLineInput): void {
116
- for (const line of formatWorkflowDetailLines(input)) {
117
- lines.push(theme.fg("dim", line));
118
- }
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
+ ];
119
134
  }
120
135
 
121
- export function formatWorkflowDetailLines(input: WorkflowDetailLineInput): string[] {
122
- return [
123
- formatWorkflowRunLine(input.metadata),
124
- formatWorkflowUsageLine(input.usage),
125
- formatWorkflowPerfLine(input.perf),
126
- ].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
+ });
127
149
  }
128
150
 
129
- export function formatWorkflowRunLine(metadata: WorkflowRunDisplayMetadata | undefined): string | undefined {
130
- if (!metadata?.runId) return undefined;
131
- 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}`;
132
155
  }
133
156
 
134
- export function formatWorkflowPerfLine(perf: WorkflowPerfDetails | undefined): string | undefined {
135
- if (!perf) return undefined;
136
- const parts = perf.aggregates.slice(0, 4).map((aggregate) => `${aggregate.name} ${Math.round(aggregate.total)}ms`);
137
- 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)];
138
167
  }
139
168
 
140
- function statsLine(stats: Record<string, string | number> | undefined, theme: Theme): string | undefined {
141
- if (!stats) return undefined;
142
- const ordered = ["files", "candidates", "dropped", "verified", "kept"];
143
- 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]) => {
144
181
  const value = stats[key];
145
182
  if (value === undefined) return [];
146
- return [`${key} ${typeof value === "number" ? formatCount(value) : value}`];
183
+ return [typeof value === "number" ? `${formatCount(value)} ${value === 1 ? singular : pluralLabel}` : `${value} ${pluralLabel}`];
147
184
  });
148
- if (parts.length === 0) return undefined;
149
- 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;
150
225
  }
151
226
 
152
- function extractSummary(value: unknown): string | undefined {
153
- if (typeof value === "string") return value;
154
- if (isRecord(value) && typeof value.summary === "string") return value.summary;
155
- 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;
156
233
  }
157
234
 
158
- function safeJson(value: unknown): string {
159
- try {
160
- return JSON.stringify(value, null, 2) ?? String(value);
161
- } catch (error) {
162
- 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(", ")}`;
163
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
+ });
164
254
  }
165
255
 
166
- function isRecord(value: unknown): value is Record<string, unknown> {
167
- 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;
168
281
  }