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,444 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config Loader — reads and validates pipeline definitions
|
|
3
|
+
* from .pi/pipelines/*.pipeline.yaml files
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
import * as path from "node:path";
|
|
8
|
+
import yaml from "js-yaml";
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
PipelineDef,
|
|
12
|
+
Stage,
|
|
13
|
+
ExpandConfig,
|
|
14
|
+
ReviewGate,
|
|
15
|
+
ReviewerDef,
|
|
16
|
+
ReportConfig,
|
|
17
|
+
StageReportConfig,
|
|
18
|
+
} from "./types.ts";
|
|
19
|
+
|
|
20
|
+
/** Errors found during validation */
|
|
21
|
+
class PipelineValidationError extends Error {
|
|
22
|
+
constructor(
|
|
23
|
+
message: string,
|
|
24
|
+
public readonly filePath?: string,
|
|
25
|
+
) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = "PipelineValidationError";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Default values for optional pipeline fields */
|
|
32
|
+
const DEFAULTS = {
|
|
33
|
+
version: 1,
|
|
34
|
+
judgeModel: undefined as string | undefined,
|
|
35
|
+
gateMaxRounds: 3,
|
|
36
|
+
gateTargetScore: 8,
|
|
37
|
+
maxSubagentDepth: 1,
|
|
38
|
+
} as const;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Find all pipeline definition files in the given directory.
|
|
42
|
+
* Looks for *.pipeline.yaml and *.pipeline.yml.
|
|
43
|
+
*/
|
|
44
|
+
export function discoverPipelineFiles(pipelinesDir: string): string[] {
|
|
45
|
+
try {
|
|
46
|
+
if (!fs.existsSync(pipelinesDir)) return [];
|
|
47
|
+
return fs
|
|
48
|
+
.readdirSync(pipelinesDir)
|
|
49
|
+
.filter((f) => f.endsWith(".pipeline.yaml") || f.endsWith(".pipeline.yml"))
|
|
50
|
+
.map((f) => path.join(pipelinesDir, f))
|
|
51
|
+
.sort();
|
|
52
|
+
} catch {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Find a pipeline file by name (with or without extension).
|
|
59
|
+
*/
|
|
60
|
+
export function findPipelineFile(pipelinesDir: string, name: string): string | null {
|
|
61
|
+
const candidates = [
|
|
62
|
+
path.join(pipelinesDir, `${name}.pipeline.yaml`),
|
|
63
|
+
path.join(pipelinesDir, `${name}.pipeline.yml`),
|
|
64
|
+
path.join(pipelinesDir, name), // exact path
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
for (const candidate of candidates) {
|
|
68
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Like findPipelineFile but searches multiple directories (first match wins).
|
|
76
|
+
*/
|
|
77
|
+
export function searchPipelineFile(dirs: string[], name: string): string | null {
|
|
78
|
+
for (const dir of dirs) {
|
|
79
|
+
const found = findPipelineFile(dir, name);
|
|
80
|
+
if (found) return found;
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* List pipelines from multiple directories, deduplicated by name (first dir wins).
|
|
87
|
+
*/
|
|
88
|
+
export function listPipelinesFromDirs(dirs: string[]): { name: string; file: string }[] {
|
|
89
|
+
const seen = new Set<string>();
|
|
90
|
+
const result: { name: string; file: string }[] = [];
|
|
91
|
+
for (const dir of dirs) {
|
|
92
|
+
for (const p of listPipelines(dir)) {
|
|
93
|
+
if (!seen.has(p.name)) {
|
|
94
|
+
seen.add(p.name);
|
|
95
|
+
result.push(p);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* List all available pipeline names in a single directory.
|
|
104
|
+
*/
|
|
105
|
+
export function listPipelines(pipelinesDir: string): { name: string; file: string }[] {
|
|
106
|
+
return discoverPipelineFiles(pipelinesDir).map((file) => ({
|
|
107
|
+
name: path.basename(file).replace(/\.pipeline\.(ya?ml)$/, ""),
|
|
108
|
+
file,
|
|
109
|
+
}));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Load and parse a pipeline definition file.
|
|
114
|
+
* Throws PipelineValidationError on invalid content.
|
|
115
|
+
*/
|
|
116
|
+
export function loadPipeline(filePath: string): PipelineDef {
|
|
117
|
+
let raw: string;
|
|
118
|
+
try {
|
|
119
|
+
raw = fs.readFileSync(filePath, "utf-8");
|
|
120
|
+
} catch (_err) {
|
|
121
|
+
throw new PipelineValidationError(`Cannot read pipeline file: ${filePath}`, filePath);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let parsed: unknown;
|
|
125
|
+
try {
|
|
126
|
+
parsed = yaml.load(raw);
|
|
127
|
+
} catch (_err) {
|
|
128
|
+
throw new PipelineValidationError(
|
|
129
|
+
`YAML parse error in ${filePath}: ${(_err as Error).message}`,
|
|
130
|
+
filePath,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
135
|
+
throw new PipelineValidationError("Pipeline must be a YAML object (mapping)", filePath);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const data = parsed as Record<string, unknown>;
|
|
139
|
+
|
|
140
|
+
// Validate required fields
|
|
141
|
+
if (typeof data.name !== "string" || !data.name.trim()) {
|
|
142
|
+
throw new PipelineValidationError(
|
|
143
|
+
"Pipeline must have a 'name' field (non-empty string)",
|
|
144
|
+
filePath,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
if (typeof data.description !== "string") {
|
|
148
|
+
throw new PipelineValidationError(
|
|
149
|
+
"Pipeline must have a 'description' field (string)",
|
|
150
|
+
filePath,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
if (!Array.isArray(data.stages) || data.stages.length === 0) {
|
|
154
|
+
throw new PipelineValidationError(
|
|
155
|
+
"Pipeline must have at least one stage in 'stages' array",
|
|
156
|
+
filePath,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Validate and normalize stages (sequential, so expand.from can reference earlier stages)
|
|
161
|
+
const prevStageIds = new Set<string>();
|
|
162
|
+
const stages: Stage[] = [];
|
|
163
|
+
for (let idx = 0; idx < data.stages.length; idx++) {
|
|
164
|
+
const s = data.stages[idx]!;
|
|
165
|
+
const validated = validateStage(s, idx, filePath, prevStageIds);
|
|
166
|
+
if (validated.expand) {
|
|
167
|
+
validateExpand(validated.expand, validated.id, prevStageIds, filePath);
|
|
168
|
+
}
|
|
169
|
+
prevStageIds.add(validated.id);
|
|
170
|
+
stages.push(validated);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Parse optional report config
|
|
174
|
+
let report: ReportConfig | false | undefined;
|
|
175
|
+
if (data.report === false) {
|
|
176
|
+
report = false;
|
|
177
|
+
} else if (typeof data.report === "object" && data.report !== null) {
|
|
178
|
+
const r = data.report as Record<string, unknown>;
|
|
179
|
+
report = {
|
|
180
|
+
agent: typeof r.agent === "string" ? r.agent : undefined,
|
|
181
|
+
focus: typeof r.focus === "string" ? r.focus : undefined,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
// undefined = not configured → use default (synthesis enabled)
|
|
185
|
+
|
|
186
|
+
const pipeline: PipelineDef = {
|
|
187
|
+
name: data.name.trim(),
|
|
188
|
+
description: data.description.trim(),
|
|
189
|
+
version: typeof data.version === "number" ? data.version : DEFAULTS.version,
|
|
190
|
+
judgeModel: typeof data.judgeModel === "string" ? data.judgeModel : DEFAULTS.judgeModel,
|
|
191
|
+
stages,
|
|
192
|
+
report,
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
return pipeline;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Validate and normalize a single stage object.
|
|
200
|
+
*/
|
|
201
|
+
function validateStage(
|
|
202
|
+
data: unknown,
|
|
203
|
+
index: number,
|
|
204
|
+
filePath?: string,
|
|
205
|
+
prevStageIds?: Set<string>,
|
|
206
|
+
allowParallel = true,
|
|
207
|
+
): Stage {
|
|
208
|
+
if (typeof data !== "object" || data === null) {
|
|
209
|
+
throw new PipelineValidationError(`Stage #${index + 1} must be an object`, filePath);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const stage = data as Record<string, unknown>;
|
|
213
|
+
|
|
214
|
+
if (typeof stage.id !== "string" || !stage.id.trim()) {
|
|
215
|
+
throw new PipelineValidationError(
|
|
216
|
+
`Stage #${index + 1} must have an 'id' field (non-empty string)`,
|
|
217
|
+
filePath,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const id = stage.id.trim();
|
|
222
|
+
|
|
223
|
+
// Parallel stage
|
|
224
|
+
if (Array.isArray(stage.parallel)) {
|
|
225
|
+
if (!allowParallel) {
|
|
226
|
+
throw new PipelineValidationError(
|
|
227
|
+
`Stage "${id}" cannot use nested 'parallel' stages`,
|
|
228
|
+
filePath,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
if (typeof stage.agent === "string" && stage.agent.trim()) {
|
|
232
|
+
throw new PipelineValidationError(
|
|
233
|
+
`Stage "${id}" cannot have both 'agent' and 'parallel'`,
|
|
234
|
+
filePath,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
const children = stage.parallel.map((s, ci) =>
|
|
238
|
+
validateStage(s, ci, filePath, undefined, false),
|
|
239
|
+
);
|
|
240
|
+
return {
|
|
241
|
+
id,
|
|
242
|
+
task: undefined,
|
|
243
|
+
parallel: children,
|
|
244
|
+
report: validateStageReport(stage.report, id, filePath),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Simple agent stage
|
|
249
|
+
if (typeof stage.agent !== "string" || !stage.agent.trim()) {
|
|
250
|
+
throw new PipelineValidationError(
|
|
251
|
+
`Stage "${id}" must have an 'agent' field (non-empty string) or be a 'parallel' stage`,
|
|
252
|
+
filePath,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const agent = stage.agent.trim();
|
|
257
|
+
|
|
258
|
+
// Validate task
|
|
259
|
+
const task = typeof stage.task === "string" ? stage.task : `Execute task for stage: ${id}`;
|
|
260
|
+
|
|
261
|
+
// Validate gate if present
|
|
262
|
+
let gate: ReviewGate | undefined;
|
|
263
|
+
if (stage.gate !== undefined) {
|
|
264
|
+
gate = validateGate(stage.gate, id, filePath);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Expand stage
|
|
268
|
+
let expand: ExpandConfig | undefined;
|
|
269
|
+
if (stage.expand !== undefined) {
|
|
270
|
+
expand = validateExpand(stage.expand, id, prevStageIds, filePath);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Optional fields
|
|
274
|
+
const model = typeof stage.model === "string" ? stage.model.trim() : undefined;
|
|
275
|
+
const output = typeof stage.output === "string" ? stage.output.trim() : undefined;
|
|
276
|
+
const reads = Array.isArray(stage.reads) ? stage.reads.map(String) : undefined;
|
|
277
|
+
const maxSubagentDepth =
|
|
278
|
+
typeof stage.maxSubagentDepth === "number" ? stage.maxSubagentDepth : undefined;
|
|
279
|
+
const report = validateStageReport(stage.report, id, filePath);
|
|
280
|
+
|
|
281
|
+
return { id, agent, task, expand, gate, model, output, reads, maxSubagentDepth, report };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Validate an expand configuration.
|
|
286
|
+
* Checks that `from` references a valid previously defined stage.
|
|
287
|
+
*/
|
|
288
|
+
function validateExpand(
|
|
289
|
+
data: unknown,
|
|
290
|
+
stageId: string,
|
|
291
|
+
prevStageIds: Set<string> | undefined,
|
|
292
|
+
filePath?: string,
|
|
293
|
+
): ExpandConfig {
|
|
294
|
+
if (typeof data !== "object" || data === null) {
|
|
295
|
+
throw new PipelineValidationError(
|
|
296
|
+
`Stage "${stageId}": expand must be an object with a 'from' field`,
|
|
297
|
+
filePath,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const expand = data as Record<string, unknown>;
|
|
302
|
+
|
|
303
|
+
if (typeof expand.from !== "string" || !expand.from.trim()) {
|
|
304
|
+
throw new PipelineValidationError(
|
|
305
|
+
`Stage "${stageId}": expand.from must be a non-empty string (stage ID)`,
|
|
306
|
+
filePath,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const from = expand.from.trim();
|
|
311
|
+
|
|
312
|
+
// Validate that `from` references an earlier stage in the pipeline
|
|
313
|
+
if (prevStageIds && !prevStageIds.has(from)) {
|
|
314
|
+
const available =
|
|
315
|
+
prevStageIds.size > 0
|
|
316
|
+
? ` Available stages before "${stageId}": ${[...prevStageIds].join(", ")}`
|
|
317
|
+
: " No stages before this one.";
|
|
318
|
+
throw new PipelineValidationError(
|
|
319
|
+
`Stage "${stageId}": expand.from "${from}" does not match any stage defined before "${stageId}".${available}`,
|
|
320
|
+
filePath,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
let maxItems: number | undefined;
|
|
325
|
+
if (expand.maxItems !== undefined) {
|
|
326
|
+
if (
|
|
327
|
+
typeof expand.maxItems !== "number" ||
|
|
328
|
+
!Number.isFinite(expand.maxItems) ||
|
|
329
|
+
expand.maxItems <= 0
|
|
330
|
+
) {
|
|
331
|
+
throw new PipelineValidationError(
|
|
332
|
+
`Stage "${stageId}": expand.maxItems must be a positive number if provided`,
|
|
333
|
+
filePath,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
maxItems = expand.maxItems;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return { from, maxItems };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Validate optional stage-level report compression config.
|
|
344
|
+
*/
|
|
345
|
+
function validateStageReport(
|
|
346
|
+
data: unknown,
|
|
347
|
+
stageId: string,
|
|
348
|
+
filePath?: string,
|
|
349
|
+
): StageReportConfig | undefined {
|
|
350
|
+
if (data === undefined) return undefined;
|
|
351
|
+
|
|
352
|
+
if (typeof data !== "object" || data === null) {
|
|
353
|
+
throw new PipelineValidationError(
|
|
354
|
+
`Stage "${stageId}": report must be an object with optional mode, maxLength, instruction`,
|
|
355
|
+
filePath,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const report = data as Record<string, unknown>;
|
|
360
|
+
const mode = report.mode;
|
|
361
|
+
if (mode !== undefined && mode !== "full" && mode !== "summary") {
|
|
362
|
+
throw new PipelineValidationError(
|
|
363
|
+
`Stage "${stageId}": report.mode must be "full" or "summary"`,
|
|
364
|
+
filePath,
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const maxLength = report.maxLength;
|
|
369
|
+
if (maxLength !== undefined) {
|
|
370
|
+
if (typeof maxLength !== "number" || !Number.isFinite(maxLength) || maxLength <= 0) {
|
|
371
|
+
throw new PipelineValidationError(
|
|
372
|
+
`Stage "${stageId}": report.maxLength must be a positive number if provided`,
|
|
373
|
+
filePath,
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const instruction = report.instruction;
|
|
379
|
+
if (instruction !== undefined && typeof instruction !== "string") {
|
|
380
|
+
throw new PipelineValidationError(
|
|
381
|
+
`Stage "${stageId}": report.instruction must be a string if provided`,
|
|
382
|
+
filePath,
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return {
|
|
387
|
+
mode: mode as "full" | "summary" | undefined,
|
|
388
|
+
maxLength: typeof maxLength === "number" ? maxLength : undefined,
|
|
389
|
+
instruction: typeof instruction === "string" ? instruction : undefined,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Validate a review gate definition.
|
|
395
|
+
*/
|
|
396
|
+
function validateGate(data: unknown, stageId: string, filePath?: string): ReviewGate {
|
|
397
|
+
if (typeof data !== "object" || data === null) {
|
|
398
|
+
throw new PipelineValidationError(`Stage "${stageId}": gate must be an object`, filePath);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const gate = data as Record<string, unknown>;
|
|
402
|
+
|
|
403
|
+
if (gate.type !== "review-loop") {
|
|
404
|
+
throw new PipelineValidationError(
|
|
405
|
+
`Stage "${stageId}": gate.type must be "review-loop"`,
|
|
406
|
+
filePath,
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (!Array.isArray(gate.reviewers) || gate.reviewers.length === 0) {
|
|
411
|
+
throw new PipelineValidationError(
|
|
412
|
+
`Stage "${stageId}": gate must have at least one reviewer`,
|
|
413
|
+
filePath,
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const reviewers: ReviewerDef[] = gate.reviewers.map((r: unknown, i: number) => {
|
|
418
|
+
if (typeof r !== "object" || r === null) {
|
|
419
|
+
throw new PipelineValidationError(
|
|
420
|
+
`Stage "${stageId}": reviewer #${i + 1} must be an object with 'focus'`,
|
|
421
|
+
filePath,
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
const rv = r as Record<string, unknown>;
|
|
425
|
+
if (typeof rv.focus !== "string" || !rv.focus.trim()) {
|
|
426
|
+
throw new PipelineValidationError(
|
|
427
|
+
`Stage "${stageId}": reviewer #${i + 1} must have a 'focus' string`,
|
|
428
|
+
filePath,
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
return {
|
|
432
|
+
focus: rv.focus.trim(),
|
|
433
|
+
agent: typeof rv.agent === "string" ? rv.agent.trim() : "reviewer",
|
|
434
|
+
};
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
return {
|
|
438
|
+
type: "review-loop",
|
|
439
|
+
maxRounds: typeof gate.maxRounds === "number" ? gate.maxRounds : DEFAULTS.gateMaxRounds,
|
|
440
|
+
targetScore: typeof gate.targetScore === "number" ? gate.targetScore : DEFAULTS.gateTargetScore,
|
|
441
|
+
reviewers,
|
|
442
|
+
judgeModel: typeof gate.judgeModel === "string" ? gate.judgeModel.trim() : undefined,
|
|
443
|
+
};
|
|
444
|
+
}
|