pi-pipelines 0.1.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.
- package/CHANGELOG.md +29 -0
- package/LICENSE +21 -0
- package/README.md +367 -0
- package/extensions/config-loader.ts +444 -0
- package/extensions/index.ts +447 -0
- package/extensions/pipeline-runner.ts +1346 -0
- package/extensions/subagent-bridge.ts +291 -0
- package/extensions/tui-widgets.ts +68 -0
- package/extensions/types.ts +153 -0
- package/extensions/utils.ts +15 -0
- package/package.json +79 -0
- package/pipelines/dev-sprint.pipeline.yaml +104 -0
- package/pipelines/hello-world.pipeline.yaml +25 -0
- package/pipelines/refactor.pipeline.yaml +59 -0
- package/pipelines/release-check.pipeline.yaml +60 -0
- package/pipelines/tdd-review.pipeline.yaml +78 -0
- package/skills/pi-pipelines/SKILL.md +575 -0
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi Pipelines Extension
|
|
3
|
+
*
|
|
4
|
+
* Defines and runs multi-agent pipelines with review gates, loops, and parallel execution.
|
|
5
|
+
* Powered by pi-subagents for agent delegation.
|
|
6
|
+
*
|
|
7
|
+
* Commands:
|
|
8
|
+
* /pipeline-<name> [task] — Run a specific pipeline by name (auto-discovered)
|
|
9
|
+
* /run-pipeline <name> [task] — Generic fallback to run any pipeline by name
|
|
10
|
+
* /list-pipelines — List all available pipelines
|
|
11
|
+
* /pipeline- prefix = group — Tab-completion shows all pipeline commands
|
|
12
|
+
*
|
|
13
|
+
* Tools:
|
|
14
|
+
* run_pipeline — LLM-callable tool to execute a pipeline
|
|
15
|
+
* list_pipelines — LLM-callable tool to discover pipelines
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
import * as fs from "node:fs";
|
|
20
|
+
import * as os from "node:os";
|
|
21
|
+
import type {
|
|
22
|
+
ExtensionAPI,
|
|
23
|
+
ExtensionContext,
|
|
24
|
+
AgentToolUpdateCallback,
|
|
25
|
+
} from "@earendil-works/pi-coding-agent";
|
|
26
|
+
import { Type } from "typebox";
|
|
27
|
+
|
|
28
|
+
import { fileURLToPath } from "node:url";
|
|
29
|
+
import { loadPipeline, findPipelineFile, listPipelinesFromDirs } from "./config-loader.ts";
|
|
30
|
+
|
|
31
|
+
/** Extension's own bundled pipelines directory */
|
|
32
|
+
const BUNDLED_PIPELINES_DIR = path.resolve(
|
|
33
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
34
|
+
"../pipelines",
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
/** User's global pipelines directory (~/.pi/pipelines/) */
|
|
38
|
+
const USER_GLOBAL_PIPELINES_DIR = path.join(os.homedir(), ".pi", "pipelines");
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Get the pipeline search directories.
|
|
42
|
+
* Order: project → user global → extension bundled
|
|
43
|
+
*/
|
|
44
|
+
function getPipelineDirs(cwd: string): string[] {
|
|
45
|
+
const dirs: string[] = [];
|
|
46
|
+
const projectDir = path.join(cwd, ".pi/pipelines");
|
|
47
|
+
if (projectDir !== USER_GLOBAL_PIPELINES_DIR && projectDir !== BUNDLED_PIPELINES_DIR) {
|
|
48
|
+
dirs.push(projectDir);
|
|
49
|
+
}
|
|
50
|
+
if (USER_GLOBAL_PIPELINES_DIR !== BUNDLED_PIPELINES_DIR) {
|
|
51
|
+
dirs.push(USER_GLOBAL_PIPELINES_DIR);
|
|
52
|
+
}
|
|
53
|
+
dirs.push(BUNDLED_PIPELINES_DIR);
|
|
54
|
+
return dirs;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Seed user's global pipelines directory with bundled pipelines
|
|
59
|
+
* (copies only pipelines that don't already exist there).
|
|
60
|
+
*/
|
|
61
|
+
function seedUserPipelines(): void {
|
|
62
|
+
try {
|
|
63
|
+
if (!fs.existsSync(BUNDLED_PIPELINES_DIR)) return;
|
|
64
|
+
fs.mkdirSync(USER_GLOBAL_PIPELINES_DIR, { recursive: true });
|
|
65
|
+
for (const file of fs.readdirSync(BUNDLED_PIPELINES_DIR)) {
|
|
66
|
+
const target = path.join(USER_GLOBAL_PIPELINES_DIR, file);
|
|
67
|
+
if (!fs.existsSync(target)) {
|
|
68
|
+
fs.copyFileSync(path.join(BUNDLED_PIPELINES_DIR, file), target);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
// Best effort — user global dir might not be writable.
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
import { runPipeline, buildPipelineContextMessage } from "./pipeline-runner.ts";
|
|
76
|
+
import { formatDuration } from "./utils.ts";
|
|
77
|
+
import type { PipelineResult } from "./types.ts";
|
|
78
|
+
|
|
79
|
+
/** Widget key for the pipeline status widget */
|
|
80
|
+
const WIDGET_KEY = "pi-pipelines-status";
|
|
81
|
+
|
|
82
|
+
/** Internal state */
|
|
83
|
+
interface PipelinesState {
|
|
84
|
+
currentResult: PipelineResult | null;
|
|
85
|
+
lastPipelinesDir: string | null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export default function registerPipelinesExtension(pi: ExtensionAPI): void {
|
|
89
|
+
const state: PipelinesState = {
|
|
90
|
+
currentResult: null,
|
|
91
|
+
lastPipelinesDir: null,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// ========================================================================
|
|
95
|
+
// Helper: resolve pipeline file from user dir + extension dir
|
|
96
|
+
// ========================================================================
|
|
97
|
+
function findPipeline(name: string, cwd: string): { file: string; dir: string } | null {
|
|
98
|
+
const dirs = getPipelineDirs(cwd);
|
|
99
|
+
for (const dir of dirs) {
|
|
100
|
+
const file = findPipelineFile(dir, name);
|
|
101
|
+
if (file) return { file, dir };
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ========================================================================
|
|
107
|
+
// Helper: run a pipeline by name and handle result display
|
|
108
|
+
// ========================================================================
|
|
109
|
+
async function runAndReport(
|
|
110
|
+
pipelineName: string,
|
|
111
|
+
task: string,
|
|
112
|
+
ctx: ExtensionContext,
|
|
113
|
+
): Promise<void> {
|
|
114
|
+
ctx.ui.setStatus(WIDGET_KEY, `🚀 Running pipeline: ${pipelineName}`);
|
|
115
|
+
|
|
116
|
+
const found = findPipeline(pipelineName, ctx.cwd);
|
|
117
|
+
const pipelinesDir = found?.dir ?? path.join(ctx.cwd, ".pi/pipelines");
|
|
118
|
+
|
|
119
|
+
const result = await runPipeline(pi, ctx, {
|
|
120
|
+
pipeline: pipelineName,
|
|
121
|
+
task,
|
|
122
|
+
pipelinesDir,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
state.currentResult = result;
|
|
126
|
+
ctx.ui.setStatus(WIDGET_KEY, "");
|
|
127
|
+
|
|
128
|
+
if (result.success) {
|
|
129
|
+
ctx.ui.notify(`✅ Pipeline "${pipelineName}" completed successfully`, "info");
|
|
130
|
+
} else if (result.error) {
|
|
131
|
+
ctx.ui.notify(`❌ Pipeline "${pipelineName}" failed: ${result.error}`, "error");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (ctx.hasUI && result.stages.length > 0) {
|
|
135
|
+
ctx.ui.setWidget(WIDGET_KEY, buildWidgetLines(result));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Inject pipeline result into the main agent's context
|
|
139
|
+
// so the LLM can summarize what happened.
|
|
140
|
+
const contextMsg = buildPipelineContextMessage(result);
|
|
141
|
+
try {
|
|
142
|
+
pi.sendMessage(
|
|
143
|
+
{ customType: "pipeline-result", content: contextMsg, display: true },
|
|
144
|
+
{ triggerTurn: true, deliverAs: "followUp" },
|
|
145
|
+
);
|
|
146
|
+
} catch {
|
|
147
|
+
// Stale context after reload — result still visible via TUI widget.
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ========================================================================
|
|
152
|
+
// COMMAND GENERATOR: scan .pi/pipelines/ and register /pipeline-* commands
|
|
153
|
+
// (runs eagerly at extension load time; uses process.cwd() for scan)
|
|
154
|
+
// ========================================================================
|
|
155
|
+
function scanAndRegisterPipelineCommands(): void {
|
|
156
|
+
const cwd = process.cwd();
|
|
157
|
+
const pipelines = listPipelinesFromDirs(getPipelineDirs(cwd));
|
|
158
|
+
|
|
159
|
+
for (const p of pipelines) {
|
|
160
|
+
const cmdName = `pipeline-${p.name}`;
|
|
161
|
+
let description: string;
|
|
162
|
+
try {
|
|
163
|
+
const def = loadPipeline(p.file);
|
|
164
|
+
description = def.description;
|
|
165
|
+
} catch {
|
|
166
|
+
description = `Run the ${p.name} pipeline`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
pi.registerCommand(cmdName, {
|
|
170
|
+
description: `Run pipeline "${p.name}": ${description}. Usage: /${cmdName} [task description]`,
|
|
171
|
+
handler: async (args: string, ctxInner: ExtensionContext) => {
|
|
172
|
+
const task = args.trim() || p.name;
|
|
173
|
+
await runAndReport(p.name, task, ctxInner);
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Seed user's global pipelines directory with bundled pipelines,
|
|
180
|
+
// then scan and register /pipeline-* commands.
|
|
181
|
+
seedUserPipelines();
|
|
182
|
+
scanAndRegisterPipelineCommands();
|
|
183
|
+
|
|
184
|
+
// ========================================================================
|
|
185
|
+
// COMMAND: /run-pipeline <name> [task] (generic fallback)
|
|
186
|
+
// ========================================================================
|
|
187
|
+
pi.registerCommand("run-pipeline", {
|
|
188
|
+
description:
|
|
189
|
+
"Run a pipeline defined in .pi/pipelines/ (project), ~/.pi/pipelines/ (global), or bundled with the extension. " +
|
|
190
|
+
"Usage: /run-pipeline <name> [task description]",
|
|
191
|
+
handler: async (args: string, ctx: ExtensionContext) => {
|
|
192
|
+
// Parse args: first word is pipeline name, rest is task
|
|
193
|
+
const parts = args.trim().split(/\s+/);
|
|
194
|
+
if (parts.length === 0 || !parts[0]) {
|
|
195
|
+
const available = listPipelinesFromDirs(getPipelineDirs(ctx.cwd));
|
|
196
|
+
const names = available.map((p) => ` ${p.name} -> /pipeline-${p.name}`).join("\n");
|
|
197
|
+
ctx.ui.notify(
|
|
198
|
+
`Usage: /run-pipeline <name> [task]\nOr use named commands directly:\n${names}`,
|
|
199
|
+
"warning",
|
|
200
|
+
);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const pipelineName = parts[0]!;
|
|
205
|
+
const task = parts.slice(1).join(" ") || pipelineName;
|
|
206
|
+
await runAndReport(pipelineName, task, ctx);
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// ========================================================================
|
|
211
|
+
// COMMAND: /list-pipelines
|
|
212
|
+
// ========================================================================
|
|
213
|
+
pi.registerCommand("list-pipelines", {
|
|
214
|
+
description: "List all available pipeline definitions with their dedicated commands",
|
|
215
|
+
handler: async (_args: string, ctx: ExtensionContext) => {
|
|
216
|
+
const dirs = getPipelineDirs(ctx.cwd);
|
|
217
|
+
const pipelines = listPipelinesFromDirs(dirs);
|
|
218
|
+
|
|
219
|
+
if (pipelines.length === 0) {
|
|
220
|
+
ctx.ui.notify(`No pipelines found in ${dirs.join(" or ")}/`, "info");
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const lines = pipelines.map((p) => {
|
|
225
|
+
try {
|
|
226
|
+
const def = loadPipeline(p.file);
|
|
227
|
+
const stagesInfo = def.stages.map((s) => (s.gate ? `${s.id} [gate]` : s.id)).join(" → ");
|
|
228
|
+
return ` /pipeline-${p.name}\n ${def.description}\n Stages: ${stagesInfo}`;
|
|
229
|
+
} catch {
|
|
230
|
+
return ` /pipeline-${p.name}\n (invalid pipeline file)`;
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
ctx.ui.notify(`Available pipelines (${pipelines.length}):\n\n${lines.join("\n\n")}`, "info");
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// ========================================================================
|
|
239
|
+
// TOOL: run_pipeline (for LLM)
|
|
240
|
+
// ========================================================================
|
|
241
|
+
pi.registerTool({
|
|
242
|
+
name: "run_pipeline",
|
|
243
|
+
label: "Run Pipeline",
|
|
244
|
+
description:
|
|
245
|
+
"Execute a predefined multi-agent pipeline with review gates. " +
|
|
246
|
+
"Pipelines are defined in .pi/pipelines/ (project), ~/.pi/pipelines/ (global), or bundled with the extension. " +
|
|
247
|
+
"The task is passed through {task} variables to each stage.",
|
|
248
|
+
promptSnippet: "Run a multi-agent pipeline from the project's pipeline library",
|
|
249
|
+
promptGuidelines: [
|
|
250
|
+
"Use run_pipeline when the user asks to run a defined workflow or pipeline.",
|
|
251
|
+
"Specify the pipeline name and a clear task description.",
|
|
252
|
+
"Use list_pipelines first if you don't know what pipelines are available.",
|
|
253
|
+
],
|
|
254
|
+
parameters: Type.Object({
|
|
255
|
+
pipeline: Type.String({
|
|
256
|
+
description:
|
|
257
|
+
"Pipeline name — searched in .pi/pipelines/ (project), ~/.pi/pipelines/ (global), then extension bundled",
|
|
258
|
+
}),
|
|
259
|
+
task: Type.String({
|
|
260
|
+
description: "Task description passed through {task} to pipeline stages",
|
|
261
|
+
}),
|
|
262
|
+
}),
|
|
263
|
+
async execute(
|
|
264
|
+
_toolCallId: string,
|
|
265
|
+
params: { pipeline: string; task: string },
|
|
266
|
+
signal: AbortSignal | undefined,
|
|
267
|
+
_onUpdate: AgentToolUpdateCallback<Record<string, unknown> | undefined> | undefined,
|
|
268
|
+
ctx: ExtensionContext,
|
|
269
|
+
) {
|
|
270
|
+
if (signal?.aborted) {
|
|
271
|
+
return {
|
|
272
|
+
content: [{ type: "text", text: "Pipeline execution cancelled." }],
|
|
273
|
+
details: { cancelled: true },
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const found = findPipeline(params.pipeline, ctx.cwd);
|
|
278
|
+
const pipelinesDir = found?.dir ?? path.join(ctx.cwd, ".pi/pipelines");
|
|
279
|
+
|
|
280
|
+
ctx.ui.setStatus(WIDGET_KEY, `🚀 Running pipeline: ${params.pipeline}`);
|
|
281
|
+
|
|
282
|
+
const result = await runPipeline(pi, ctx, {
|
|
283
|
+
pipeline: params.pipeline,
|
|
284
|
+
task: params.task,
|
|
285
|
+
pipelinesDir,
|
|
286
|
+
signal,
|
|
287
|
+
// Default timeout 30 min per stage. Override with stageTimeoutMs if needed.
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
state.currentResult = result;
|
|
291
|
+
ctx.ui.setStatus(WIDGET_KEY, "");
|
|
292
|
+
|
|
293
|
+
// Set widget for TUI
|
|
294
|
+
if (ctx.hasUI && result.stages.length > 0) {
|
|
295
|
+
const widgetLines = buildWidgetLines(result);
|
|
296
|
+
ctx.ui.setWidget(WIDGET_KEY, widgetLines);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Build enhanced result with structured summary + instruction
|
|
300
|
+
// so the LLM naturally summarizes the pipeline run.
|
|
301
|
+
const enhancedResult = buildPipelineContextMessage(result);
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
content: [
|
|
305
|
+
{
|
|
306
|
+
type: "text" as const,
|
|
307
|
+
text: enhancedResult,
|
|
308
|
+
},
|
|
309
|
+
],
|
|
310
|
+
details: {
|
|
311
|
+
pipelineName: result.pipelineName,
|
|
312
|
+
task: result.task,
|
|
313
|
+
success: result.success,
|
|
314
|
+
stages: result.stages.map((s) => ({
|
|
315
|
+
stageId: s.stageId,
|
|
316
|
+
success: s.success,
|
|
317
|
+
durationMs: s.durationMs,
|
|
318
|
+
rounds: s.rounds,
|
|
319
|
+
scores: s.scores,
|
|
320
|
+
error: s.error,
|
|
321
|
+
})),
|
|
322
|
+
totalDurationMs: result.totalDurationMs,
|
|
323
|
+
error: result.error,
|
|
324
|
+
} as Record<string, unknown>,
|
|
325
|
+
};
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// ========================================================================
|
|
330
|
+
// TOOL: list_pipelines (for LLM)
|
|
331
|
+
// ========================================================================
|
|
332
|
+
pi.registerTool({
|
|
333
|
+
name: "list_pipelines",
|
|
334
|
+
label: "List Pipelines",
|
|
335
|
+
description:
|
|
336
|
+
"List all available pipeline definitions from project (.pi/pipelines/), global (~/.pi/pipelines/), and extension-bundled pipelines",
|
|
337
|
+
promptSnippet: "List available pipeline definitions for running workflows",
|
|
338
|
+
promptGuidelines: [
|
|
339
|
+
"Use list_pipelines before run_pipeline when you don't know what pipelines exist.",
|
|
340
|
+
"Pipelines are defined in .pi/pipelines/ (project), ~/.pi/pipelines/ (global), or bundled with the extension.",
|
|
341
|
+
],
|
|
342
|
+
parameters: Type.Object({
|
|
343
|
+
query: Type.Optional(
|
|
344
|
+
Type.String({
|
|
345
|
+
description: "Optional search term to filter pipelines by name or description",
|
|
346
|
+
}),
|
|
347
|
+
),
|
|
348
|
+
}),
|
|
349
|
+
async execute(
|
|
350
|
+
_toolCallId: string,
|
|
351
|
+
params: { query?: string },
|
|
352
|
+
_signal: AbortSignal | undefined,
|
|
353
|
+
_onUpdate: AgentToolUpdateCallback<Record<string, unknown> | undefined> | undefined,
|
|
354
|
+
ctx: ExtensionContext,
|
|
355
|
+
) {
|
|
356
|
+
const all = listPipelinesFromDirs(getPipelineDirs(ctx.cwd));
|
|
357
|
+
|
|
358
|
+
if (all.length === 0) {
|
|
359
|
+
const dirs = getPipelineDirs(ctx.cwd);
|
|
360
|
+
return {
|
|
361
|
+
content: [
|
|
362
|
+
{
|
|
363
|
+
type: "text" as const,
|
|
364
|
+
text: `No pipelines found. Searched: ${dirs.join(", ")}`,
|
|
365
|
+
},
|
|
366
|
+
],
|
|
367
|
+
details: { pipelines: [] },
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
let filtered = all;
|
|
372
|
+
if (params.query) {
|
|
373
|
+
const q = params.query.toLowerCase();
|
|
374
|
+
filtered = all.filter((p) => p.name.toLowerCase().includes(q));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const details = filtered.map((p) => {
|
|
378
|
+
try {
|
|
379
|
+
const def = loadPipeline(p.file);
|
|
380
|
+
return {
|
|
381
|
+
name: def.name,
|
|
382
|
+
description: def.description,
|
|
383
|
+
command: `/pipeline-${def.name}`,
|
|
384
|
+
stages: def.stages.map((s) => ({
|
|
385
|
+
id: s.id,
|
|
386
|
+
agent: s.agent ?? "(parallel)",
|
|
387
|
+
hasGate: !!s.gate,
|
|
388
|
+
gateType: s.gate?.type,
|
|
389
|
+
})),
|
|
390
|
+
};
|
|
391
|
+
} catch {
|
|
392
|
+
return { name: p.name, description: "(invalid)", stages: [] };
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
const lines = details.map(
|
|
397
|
+
(d) =>
|
|
398
|
+
` - ${d.name}: ${d.description}${d.command ? ` (/${d.command})` : ""} (${d.stages.length} stages${
|
|
399
|
+
d.stages.filter((s) => s.hasGate).length > 0
|
|
400
|
+
? `, ${d.stages.filter((s) => s.hasGate).length} with gates`
|
|
401
|
+
: ""
|
|
402
|
+
})`,
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
return {
|
|
406
|
+
content: [
|
|
407
|
+
{
|
|
408
|
+
type: "text" as const,
|
|
409
|
+
text: `Available pipelines (${details.length}):\n${lines.join("\n")}`.trim(),
|
|
410
|
+
},
|
|
411
|
+
],
|
|
412
|
+
details: { pipelines: details } as Record<string, unknown>,
|
|
413
|
+
};
|
|
414
|
+
},
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// ========================================================================
|
|
418
|
+
// Cleanup on session shutdown
|
|
419
|
+
// ========================================================================
|
|
420
|
+
pi.on("session_shutdown", () => {
|
|
421
|
+
state.currentResult = null;
|
|
422
|
+
state.lastPipelinesDir = null;
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Build widget lines for the TUI pipeline status widget.
|
|
428
|
+
*/
|
|
429
|
+
function buildWidgetLines(result: PipelineResult): string[] {
|
|
430
|
+
const lines: string[] = [];
|
|
431
|
+
const statusIcon = result.success ? "✅" : "❌";
|
|
432
|
+
lines.push(`${statusIcon} ${result.pipelineName} (${formatDuration(result.totalDurationMs)})`);
|
|
433
|
+
|
|
434
|
+
for (const stage of result.stages) {
|
|
435
|
+
const icon = stage.success ? "✓" : "✗";
|
|
436
|
+
let meta = "";
|
|
437
|
+
if (stage.rounds) meta += ` ${stage.rounds}r`;
|
|
438
|
+
if (stage.scores?.length) meta += ` [${stage.scores.join(",")}]`;
|
|
439
|
+
lines.push(` ${icon} ${stage.stageId}${meta}`);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (result.error && result.stages.length === 0) {
|
|
443
|
+
lines.push(` ✗ ${result.error.slice(0, 60)}`);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return lines;
|
|
447
|
+
}
|