@skyhook-io/radar-app 1.14.2 → 1.14.4

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 (83) hide show
  1. package/package.json +2 -2
  2. package/src/api/client.ts +32 -0
  3. package/src/api/diagnose.ts +7 -1
  4. package/src/components/CloudConnectFlow.tsx +21 -7
  5. package/src/components/CloudFunnelButton.tsx +85 -8
  6. package/src/components/cloudConnectHandoff.test.ts +10 -1
  7. package/src/components/cloudConnectHandoff.ts +15 -1
  8. package/src/components/diagnose/AIMarkdown.tsx +87 -0
  9. package/src/components/diagnose/ActivityTurn.tsx +1244 -0
  10. package/src/components/diagnose/AgentControls.tsx +603 -0
  11. package/src/components/diagnose/AnalysisStory.test.tsx +31 -25
  12. package/src/components/diagnose/AnalysisStory.tsx +13 -18
  13. package/src/components/diagnose/ApplyDialog.tsx +289 -0
  14. package/src/components/diagnose/AssessmentCard.tsx +1579 -0
  15. package/src/components/diagnose/DiagnoseSurface.test.tsx +9 -9
  16. package/src/components/diagnose/DiagnoseSurface.tsx +5 -1
  17. package/src/components/diagnose/EvidenceCard.tsx +611 -0
  18. package/src/components/diagnose/Home.test.tsx +1 -3
  19. package/src/components/diagnose/Home.tsx +2 -1
  20. package/src/components/diagnose/InvestigationEvidencePane.story.test.tsx +6 -8
  21. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +5 -3
  22. package/src/components/diagnose/InvestigationEvidencePane.tsx +31 -3445
  23. package/src/components/diagnose/InvestigationEvidenceSections.tsx +523 -0
  24. package/src/components/diagnose/InvestigationView.tsx +39 -51
  25. package/src/components/diagnose/StoryExcerpt.tsx +118 -0
  26. package/src/components/diagnose/assessmentCopy.ts +169 -0
  27. package/src/components/diagnose/investigationCase.test.tsx +658 -2
  28. package/src/components/diagnose/investigationCase.ts +135 -26
  29. package/src/components/diagnose/investigationEvidence/adapters/coverage.test.tsx +682 -0
  30. package/src/components/diagnose/investigationEvidence/adapters/diagnose.test.ts +8 -2
  31. package/src/components/diagnose/investigationEvidence/adapters/diagnose.ts +1 -1
  32. package/src/components/diagnose/investigationEvidence/adapters/{helm.test.ts → helm.test.tsx} +63 -0
  33. package/src/components/diagnose/investigationEvidence/adapters/helm.ts +70 -1
  34. package/src/components/diagnose/investigationEvidence/adapters/listings.ts +136 -0
  35. package/src/components/diagnose/investigationEvidence/adapters/{permissions.test.ts → permissions.test.tsx} +50 -0
  36. package/src/components/diagnose/investigationEvidence/adapters/permissions.ts +18 -0
  37. package/src/components/diagnose/investigationEvidence/adapters/posture.ts +272 -0
  38. package/src/components/diagnose/investigationEvidence/adapters/prometheus.ts +49 -0
  39. package/src/components/diagnose/investigationEvidence/adapters/rankings.ts +147 -0
  40. package/src/components/diagnose/investigationEvidence/adapters/resource.ts +20 -10
  41. package/src/components/diagnose/investigationEvidence/bodies/index.tsx +127 -0
  42. package/src/components/diagnose/investigationEvidence/bodies/inventory.tsx +158 -0
  43. package/src/components/diagnose/investigationEvidence/bodies/metrics.tsx +373 -0
  44. package/src/components/diagnose/investigationEvidence/bodies/network.tsx +274 -0
  45. package/src/components/diagnose/investigationEvidence/bodies/platform.tsx +475 -0
  46. package/src/components/diagnose/investigationEvidence/bodies/posture.tsx +60 -0
  47. package/src/components/diagnose/investigationEvidence/bodies/ranking.tsx +76 -0
  48. package/src/components/diagnose/investigationEvidence/bodies/streams.tsx +212 -0
  49. package/src/components/diagnose/investigationEvidence/bodies/workload.tsx +335 -0
  50. package/src/components/diagnose/investigationEvidence/builder.ts +15 -0
  51. package/src/components/diagnose/investigationEvidence/cardParts.tsx +312 -0
  52. package/src/components/diagnose/investigationEvidence/excerpt.test.ts +105 -0
  53. package/src/components/diagnose/investigationEvidence/excerpt.ts +92 -0
  54. package/src/components/diagnose/investigationEvidence/identity.test.ts +5 -7
  55. package/src/components/diagnose/investigationEvidence/index.ts +1 -1
  56. package/src/components/diagnose/investigationEvidence/navigation.tsx +26 -0
  57. package/src/components/diagnose/investigationEvidence/observations.ts +13 -0
  58. package/src/components/diagnose/investigationEvidence/projection.test.ts +1 -1
  59. package/src/components/diagnose/investigationEvidence/types.ts +97 -2
  60. package/src/components/diagnose/investigationEvidenceKinds.ts +22 -0
  61. package/src/components/diagnose/investigationEvidencePartition.ts +296 -0
  62. package/src/components/diagnose/investigationState.test.ts +2 -2
  63. package/src/components/diagnose/investigationState.ts +2 -2
  64. package/src/components/diagnose/investigationStory.ts +1 -1
  65. package/src/components/diagnose/parts.test.tsx +15 -1
  66. package/src/components/diagnose/parts.tsx +26 -3904
  67. package/src/components/diagnose/target.ts +9 -3
  68. package/src/components/diagnose/toolCallLabel.test.ts +47 -1
  69. package/src/components/diagnose/toolCallLabel.ts +41 -2
  70. package/src/components/helm/HelmReleaseDrawer.tsx +1 -1
  71. package/src/components/home/MCPSetupDialog.tsx +1 -1
  72. package/src/components/home/mcpToolCatalog.ts +27 -0
  73. package/src/components/resource/PrometheusChartsGrid.tsx +1 -1
  74. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
  75. package/src/components/resource/WorkloadMetricsSection.tsx +1 -1
  76. package/src/components/resources/PodFilePreview.tsx +1 -1
  77. package/src/components/rightsizing/RightsizingScanView.tsx +20 -1
  78. package/src/components/ui/CommandPalette.tsx +12 -8
  79. package/src/components/ui/DiagnosticsOverlay.tsx +7 -5
  80. package/src/components/ui/Omnibar.tsx +96 -60
  81. package/src/components/ui/ShortcutHelpOverlay.tsx +8 -5
  82. package/src/components/workload/WorkloadView.tsx +7 -1
  83. package/src/components/ui/Disclosure.tsx +0 -47
@@ -43,7 +43,7 @@ import {
43
43
  canInvestigateFurther,
44
44
  } from "./investigationState";
45
45
  import type { AssessmentExplanation } from "./parts";
46
- import { describeToolCall } from "./toolCallLabel";
46
+ import { investigationRunningLabel } from "./toolCallLabel";
47
47
  import { AGENT_ROLE_LABELS } from "./AgentCase";
48
48
  import {
49
49
  Fragment,
@@ -99,9 +99,9 @@ import {
99
99
  import { resolveInvestigationCase } from "./investigationCase";
100
100
  import {
101
101
  InvestigationEvidencePane,
102
- partitionInvestigationEvidence,
103
102
  type InvestigationTimelineScope,
104
103
  } from "./InvestigationEvidencePane";
104
+ import { partitionInvestigationEvidence } from "./investigationEvidencePartition";
105
105
  import type { DiagnosisResourceRef } from "./diagnoseEvidenceTypes";
106
106
  import { formatInvestigationTarget } from "./target";
107
107
  import { parseContextName } from "../../utils/context-name";
@@ -176,19 +176,17 @@ export function InvestigationView({
176
176
  onOpenTimeline?: (scope: InvestigationTimelineScope) => void;
177
177
  }) {
178
178
  const { kind, namespace, name } = run;
179
- // Apply is off for hosted agents (read-only server-side). Keyed on the selected
180
- // agent, which matches run.agent unless a deployment mixes hosted + local agents.
181
- const {
182
- refreshRuns,
183
- openInvestigation,
184
- startError,
185
- dismissError,
186
- hosted,
187
- agents,
188
- } = useDiagnose();
189
- const explanationEnabled = supportsAssessmentExplanation(
190
- agents.find((agent) => agent.name === run.agent),
191
- );
179
+ const { refreshRuns, openInvestigation, startError, dismissError, agents } =
180
+ useDiagnose();
181
+ // Capabilities are the declared ones of the agent that ran this run, not
182
+ // the picker's: a reopened run keeps the backend it was made with.
183
+ const runAgent = agents.find((agent) => agent.name === run.agent);
184
+ const explanationEnabled = supportsAssessmentExplanation(runAgent);
185
+ const canApply = runAgent?.apply === true;
186
+ // Read through a ref by the stream callback, which lives as long as the
187
+ // run and would otherwise keep the value from before the agents loaded.
188
+ const verifiesAfterApplyRef = useRef(false);
189
+ verifiesAfterApplyRef.current = runAgent?.verification === true;
192
190
  // Investigate again means look again, so it asks for a new session explicitly and only
193
191
  // carries the issue forward — being handed the previous answer is the one
194
192
  // thing someone clicking this doesn't want.
@@ -536,10 +534,13 @@ export function InvestigationView({
536
534
  });
537
535
  pendingApplyStartedLiveRef.current = false;
538
536
  if (effects.refreshClusterState) refreshClusterState();
539
- // A successful apply is one compound server-owned job. Its next
540
- // durable event is the automatic read-only verification turn; hold
541
- // the controls through that adjacent event so there is no idle flash.
542
- if (effects.verificationPending) setVerificationPending(true);
537
+ // On a backend that verifies, a successful apply is one compound
538
+ // server-owned job whose next durable event is the automatic
539
+ // read-only verification turn; hold the controls through that
540
+ // adjacent event so there is no idle flash. A backend that
541
+ // declares no verification sends no such turn, so nothing waits.
542
+ if (effects.verificationPending && verifiesAfterApplyRef.current)
543
+ setVerificationPending(true);
543
544
  }
544
545
  if (live || (isApply && applyStartedLive)) refreshRuns();
545
546
  break;
@@ -1299,13 +1300,15 @@ export function InvestigationView({
1299
1300
  (rootCauseEvidenceResolution?.links.length ||
1300
1301
  investigationCase?.items.length ||
1301
1302
  currentAssessment.diagnosis.unlinkedEvidence ||
1302
- currentAssessment.diagnosis.evidenceMalformed) ? (
1303
+ currentAssessment.diagnosis.evidenceMalformed ||
1304
+ currentAssessment.diagnosis.omittedEntries) ? (
1303
1305
  <AssessmentSources
1304
1306
  renderedGroupIds={visibleEvidenceGroupIds}
1305
1307
  resolution={rootCauseEvidenceResolution}
1306
1308
  investigationCase={investigationCase}
1307
1309
  unlinkedEvidence={currentAssessment.diagnosis.unlinkedEvidence}
1308
1310
  evidenceMalformed={currentAssessment.diagnosis.evidenceMalformed}
1311
+ omittedEntries={currentAssessment.diagnosis.omittedEntries}
1309
1312
  onViewSource={viewActivitySource}
1310
1313
  />
1311
1314
  ) : undefined;
@@ -1313,7 +1316,8 @@ export function InvestigationView({
1313
1316
  storyShape &&
1314
1317
  (investigationCase?.items.some((item) => item.placement === "source") ||
1315
1318
  currentAssessment?.diagnosis?.unlinkedEvidence ||
1316
- currentAssessment?.diagnosis?.evidenceMalformed)
1319
+ currentAssessment?.diagnosis?.evidenceMalformed ||
1320
+ currentAssessment?.diagnosis?.omittedEntries)
1317
1321
  ? assessmentSourcesNode
1318
1322
  : undefined;
1319
1323
  const assessmentLimits = useMemo(() => {
@@ -1345,8 +1349,7 @@ export function InvestigationView({
1345
1349
  );
1346
1350
  // While the first assessment is still running the pane keeps the story
1347
1351
  // shape, so the page fills in rather than rearranging when the verdict lands.
1348
- const storyShell =
1349
- !hosted && !currentAssessment && lastTurn?.status === "running";
1352
+ const storyShell = !currentAssessment && lastTurn?.status === "running";
1350
1353
  const currentAssessmentEvidenceConflict =
1351
1354
  currentAssessment?.diagnosis?.healthy === true &&
1352
1355
  investigationEvidenceConflictsWithHealthy(projection);
@@ -1458,37 +1461,20 @@ export function InvestigationView({
1458
1461
  toolSeenRef.current = toolSignature;
1459
1462
  toolChangedAtRef.current = Date.now();
1460
1463
  }, [toolSignature]);
1461
- const investigatingLabel = (() => {
1462
- const items = lastTurn?.timeline ?? [];
1463
- const reads = items.filter((item) => item.kind === "tool").length;
1464
+ const investigatingLabel = investigationRunningLabel({
1465
+ lastTool: lastTool && lastTool.kind === "tool" ? lastTool : undefined,
1466
+ reads: (lastTurn?.timeline ?? []).filter((item) => item.kind === "tool")
1467
+ .length,
1464
1468
  // The render that first shows a changed tool is not quiet yet; the effect
1465
1469
  // that stamps the change runs after it.
1466
- const quietFor =
1470
+ quietFor:
1467
1471
  toolSeenRef.current === toolSignature
1468
1472
  ? Date.now() - toolChangedAtRef.current
1469
- : 0;
1470
- let current = "Investigating";
1471
- if (lastTool && lastTool.kind === "tool" && lastTool.tool) {
1472
- current =
1473
- lastTool.status === "running" || quietFor < 8000
1474
- ? describeToolCall(lastTool.tool, lastTool.summary)
1475
- : "Thinking";
1476
- }
1477
- const elapsed = runningSinceRef.current
1473
+ : 0,
1474
+ elapsedSeconds: runningSinceRef.current
1478
1475
  ? Math.round((Date.now() - runningSinceRef.current) / 1000)
1479
- : 0;
1480
- // The tool text changes width as it changes; the counters sit apart on
1481
- // the right so they never move with it.
1482
- return {
1483
- current: `${current}…`,
1484
- meta: [
1485
- reads > 0 ? `${reads} ${reads === 1 ? "read" : "reads"}` : undefined,
1486
- elapsed >= 2 ? `${elapsed} s` : undefined,
1487
- ]
1488
- .filter(Boolean)
1489
- .join(" · "),
1490
- };
1491
- })();
1476
+ : 0,
1477
+ });
1492
1478
  const showSplitWorkspace = maximized;
1493
1479
  const splitGridClass = showSplitWorkspace
1494
1480
  ? "@min-[1000px]/investigation:grid-cols-[minmax(320px,min(30%,520px))_minmax(0,1fr)]"
@@ -1677,7 +1663,7 @@ export function InvestigationView({
1677
1663
  lastApplyAttemptIdx,
1678
1664
  localApplyAttemptAssessmentIdx,
1679
1665
  interactionsBlocked,
1680
- hosted,
1666
+ canApply,
1681
1667
  hasNewerEvidence: hasEvidenceCollectedAfterAssessment,
1682
1668
  })
1683
1669
  ? requestApply
@@ -1983,7 +1969,8 @@ export function InvestigationView({
1983
1969
  return turnCase.items.length ||
1984
1970
  turnResolution?.links.length ||
1985
1971
  turn.diagnosis.unlinkedEvidence ||
1986
- turn.diagnosis.evidenceMalformed ? (
1972
+ turn.diagnosis.evidenceMalformed ||
1973
+ turn.diagnosis.omittedEntries ? (
1987
1974
  <AssessmentSources
1988
1975
  renderedGroupIds={visibleEvidenceGroupIds}
1989
1976
  resolution={turnResolution}
@@ -1994,6 +1981,7 @@ export function InvestigationView({
1994
1981
  evidenceMalformed={
1995
1982
  turn.diagnosis.evidenceMalformed
1996
1983
  }
1984
+ omittedEntries={turn.diagnosis.omittedEntries}
1997
1985
  readOnly
1998
1986
  onViewSource={viewActivitySource}
1999
1987
  />
@@ -0,0 +1,118 @@
1
+ import { clsx } from "clsx";
2
+ import {
3
+ Collapse,
4
+ TerminalBlock,
5
+ TerminalBlockLabel,
6
+ } from "@skyhook-io/k8s-ui";
7
+ import { InventoryRow } from "./investigationEvidence/bodies/inventory";
8
+ import { RankingRow } from "./investigationEvidence/bodies/ranking";
9
+ import { PostureFindingRow } from "./investigationEvidence/bodies/posture";
10
+ import { MetricsBody } from "./investigationEvidence/bodies/metrics";
11
+ import type { StoryExcerpt as Excerpt } from "./investigationEvidence/excerpt";
12
+ import type { InvestigationEvidenceData } from "./investigationEvidence";
13
+ import type { MetricsChangeCoverage } from "./investigationMetrics";
14
+
15
+ /** The inline slice of a placed card, rendered from Radar's record. */
16
+ export function StoryExcerpt({
17
+ excerpt,
18
+ data,
19
+ prominence,
20
+ open,
21
+ canExpand,
22
+ changeCoverage,
23
+ }: {
24
+ excerpt: Excerpt;
25
+ data: InvestigationEvidenceData;
26
+ prominence: "primary" | "supporting" | "secondary";
27
+ open: boolean;
28
+ canExpand: boolean;
29
+ changeCoverage?: MetricsChangeCoverage;
30
+ }) {
31
+ const padding = prominence === "primary" ? "px-3 pb-2.5" : "px-2.5 pb-2";
32
+ switch (excerpt.kind) {
33
+ case "lines":
34
+ return (
35
+ <div data-story-log-lines className={padding}>
36
+ <TerminalBlock
37
+ footer={
38
+ excerpt.rest.length > 0 ? (
39
+ <Collapse open={open && canExpand}>
40
+ <TerminalBlockLabel divider>Earlier lines</TerminalBlockLabel>
41
+ <pre className="overflow-x-auto whitespace-pre-wrap break-words px-3 pb-2.5 font-mono text-xs leading-relaxed text-[var(--terminal-text)]">
42
+ {excerpt.rest.join("\n")}
43
+ </pre>
44
+ </Collapse>
45
+ ) : null
46
+ }
47
+ >
48
+ {excerpt.head.join("\n")}
49
+ </TerminalBlock>
50
+ </div>
51
+ );
52
+ case "rows":
53
+ return (
54
+ <div data-story-inventory-rows className={padding}>
55
+ <div className="rounded-md border border-theme-border">
56
+ {excerpt.entries.map((entry, index) =>
57
+ entry.row ? (
58
+ <InventoryRow
59
+ key={entry.key}
60
+ resource={entry.row}
61
+ divider={index > 0}
62
+ />
63
+ ) : (
64
+ <p
65
+ key={entry.key}
66
+ data-story-inventory-absent={
67
+ entry.matches === 0 ? "" : undefined
68
+ }
69
+ className={clsx(
70
+ "px-2.5 py-1.5 font-mono text-xs text-theme-text-secondary",
71
+ index > 0 && "border-t border-theme-border/60",
72
+ )}
73
+ >
74
+ {entry.matches === 0
75
+ ? `Not in this listing: ${entry.label}`
76
+ : `${entry.label}: ${entry.matches} entries match in this listing`}
77
+ </p>
78
+ ),
79
+ )}
80
+ </div>
81
+ </div>
82
+ );
83
+ case "ranking":
84
+ return (
85
+ <div data-story-ranking-rows className={padding}>
86
+ <div className="rounded-md border border-theme-border">
87
+ {excerpt.rows.map((row, index) => (
88
+ <RankingRow
89
+ key={`${row.namespace ?? ""}/${row.name}`}
90
+ row={row}
91
+ divider={index > 0}
92
+ />
93
+ ))}
94
+ </div>
95
+ </div>
96
+ );
97
+ case "posture":
98
+ return (
99
+ <div data-story-posture-findings className={padding}>
100
+ <div className="rounded-md border border-theme-border">
101
+ {excerpt.findings.map((finding, index) => (
102
+ <PostureFindingRow
103
+ key={`${finding.name}-${finding.check}`}
104
+ finding={finding}
105
+ divider={index > 0}
106
+ />
107
+ ))}
108
+ </div>
109
+ </div>
110
+ );
111
+ case "chart":
112
+ return data.type === "metrics" ? (
113
+ <div data-story-metrics className={padding}>
114
+ <MetricsBody data={data} changeCoverage={changeCoverage} />
115
+ </div>
116
+ ) : null;
117
+ }
118
+ }
@@ -0,0 +1,169 @@
1
+ import type { Diagnosis } from "../../api/diagnose";
2
+ import { storyPlainText } from "./investigationStory";
3
+
4
+ /**
5
+ * The whole assessment as text: headline, certainty, what is unresolved, the
6
+ * technical cause, the story without its markers, and the steps. Copying only
7
+ * the headline would ship the persuasive half without its caveats.
8
+ */
9
+ /** What Radar adds to the agent's verdict when the assessment is copied for a channel. */
10
+ export interface AssessmentCopyRadar {
11
+ /** Reads Radar could not complete, as shown in Still open. */
12
+ limits?: readonly string[];
13
+ /** Adverse Radar cards the agent explained, as shown in Still open. */
14
+ signals?: readonly string[];
15
+ /** Adverse Radar cards nothing explains, as shown above a healthy verdict. */
16
+ flags?: readonly string[];
17
+ /** Where and when: the header line a reader in a channel needs first. */
18
+ context?: {
19
+ target: string;
20
+ cluster?: string;
21
+ startedAt?: string;
22
+ agent?: string;
23
+ };
24
+ /** The placed cards, as short excerpts: the receipts behind the headline. */
25
+ receipts?: readonly {
26
+ title: string;
27
+ role?: string;
28
+ lines: readonly string[];
29
+ /** The agent's statement of what this result does not cover. */
30
+ gap?: string;
31
+ }[];
32
+ }
33
+
34
+ /**
35
+ * The assessment as Markdown for a channel or a ticket: context, headline,
36
+ * cause, the receipts, what is still open, the next step. The full story and
37
+ * every captured result stay in Radar; the text says so.
38
+ */
39
+ export function assessmentCopyText(
40
+ diagnosis: Diagnosis,
41
+ radar: AssessmentCopyRadar = {},
42
+ ): string {
43
+ const parts: string[] = [];
44
+ if (radar.context) {
45
+ const when = radar.context.startedAt
46
+ ? new Date(radar.context.startedAt).toLocaleString(undefined, {
47
+ dateStyle: "medium",
48
+ timeStyle: "short",
49
+ })
50
+ : undefined;
51
+ parts.push(
52
+ [
53
+ `**${radar.context.target}**`,
54
+ radar.context.cluster,
55
+ when,
56
+ radar.context.agent ? `via ${radar.context.agent}` : undefined,
57
+ ]
58
+ .filter(Boolean)
59
+ .join(" · "),
60
+ );
61
+ }
62
+ if (diagnosis.summary) {
63
+ const certainty = diagnosis.healthy
64
+ ? "Healthy"
65
+ : diagnosis.certainty
66
+ ? CERTAINTY_LABEL[diagnosis.certainty]
67
+ : undefined;
68
+ parts.push(
69
+ `**${diagnosis.summary.trim()}**${certainty ? ` — ${certainty} (agent's word)` : ""}`,
70
+ );
71
+ } else if (diagnosis.certainty) {
72
+ parts.push(`Certainty (agent): ${CERTAINTY_LABEL[diagnosis.certainty]}`);
73
+ }
74
+ // The pasted text must not read more confident than the screen: Radar's
75
+ // own qualifications travel with the agent's.
76
+ const flags = (radar.flags ?? []).filter((item) => item.trim());
77
+ if (flags.length > 0)
78
+ parts.push(
79
+ [
80
+ "Radar flagged:",
81
+ ...flags.map((item) => `- ${item}`),
82
+ "Read those cards before treating this as an all-clear.",
83
+ ].join("\n"),
84
+ );
85
+ const unresolved = [
86
+ ...(diagnosis.unresolved ?? []),
87
+ ...(radar.limits ?? []),
88
+ ...(radar.signals ?? []),
89
+ ].filter((item) => item.trim());
90
+ if (unresolved.length > 0)
91
+ parts.push(
92
+ ["Still open:", ...unresolved.map((item) => `- ${item}`)].join("\n"),
93
+ );
94
+ if (diagnosis.rootCause) parts.push(storyPlainText(diagnosis.rootCause));
95
+ const receipts = (radar.receipts ?? []).filter((receipt) => receipt.title);
96
+ if (receipts.length > 0) {
97
+ parts.push(
98
+ [
99
+ "Evidence (Radar):",
100
+ ...receipts.map((receipt) =>
101
+ [
102
+ `- **${receipt.title}**${receipt.role ? ` · ${receipt.role}` : ""}`,
103
+ ...receipt.lines
104
+ .filter((line) => line.trim())
105
+ .map((line) => ` > ${line.trim()}`),
106
+ ...(receipt.gap ? [` Not shown: ${receipt.gap}`] : []),
107
+ ].join("\n"),
108
+ ),
109
+ ].join("\n"),
110
+ );
111
+ } else if (!radar.context) {
112
+ const story = storyPlainText(diagnosis.report ?? "");
113
+ if (story) parts.push(story);
114
+ }
115
+ const steps = diagnosis.steps?.length
116
+ ? diagnosis.steps.map(
117
+ (step, index) =>
118
+ `${index + 1}. [${STEP_KIND_LABEL[step.kind]}] ${step.text}${step.precondition ? ` (only if ${step.precondition})` : ""}`,
119
+ )
120
+ : (diagnosis.remediation ?? []).map(
121
+ (text, index) => `${index + 1}. ${text}`,
122
+ );
123
+ if (steps.length > 0) {
124
+ if (radar.context) {
125
+ // A channel gets the recommended step; the rest wait in Radar.
126
+ const lead = diagnosis.recommendedIndex
127
+ ? diagnosis.recommendedIndex - 1
128
+ : 0;
129
+ const first = steps[lead] ?? steps[0];
130
+ const rest = steps.length - 1;
131
+ parts.push(
132
+ [
133
+ `Next step: ${first.replace(/^\d+\. /, "")}`,
134
+ diagnosis.recommendedReason
135
+ ? `Why: ${diagnosis.recommendedReason}`
136
+ : undefined,
137
+ rest > 0
138
+ ? `${rest} more ${rest === 1 ? "step" : "steps"} in Radar.`
139
+ : undefined,
140
+ ]
141
+ .filter(Boolean)
142
+ .join("\n"),
143
+ );
144
+ } else parts.push(["Next steps:", ...steps].join("\n"));
145
+ }
146
+ if (radar.context)
147
+ parts.push(
148
+ "Full analysis and every captured result: open the investigation in Radar.",
149
+ );
150
+ return parts.join("\n\n");
151
+ }
152
+
153
+ export const CERTAINTY_LABEL: Record<
154
+ NonNullable<Diagnosis["certainty"]>,
155
+ string
156
+ > = {
157
+ established: "Established",
158
+ likely: "Likely",
159
+ suspected: "Suspected",
160
+ };
161
+
162
+ export const STEP_KIND_LABEL: Record<
163
+ NonNullable<Diagnosis["steps"]>[number]["kind"],
164
+ string
165
+ > = {
166
+ mitigate: "Mitigate",
167
+ verify: "Verify",
168
+ investigate: "Investigate",
169
+ };