pi-super-dev 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 +35 -0
- package/LICENSE +21 -0
- package/README.md +135 -0
- package/agents/adversarial-reviewer.md +64 -0
- package/agents/architecture-designer.md +43 -0
- package/agents/architecture-improver.md +46 -0
- package/agents/bdd-scenario-writer.md +37 -0
- package/agents/build-cleaner.md +44 -0
- package/agents/code-assessor.md +24 -0
- package/agents/code-reviewer.md +59 -0
- package/agents/debug-analyzer.md +54 -0
- package/agents/docs-executor.md +49 -0
- package/agents/handoff-writer.md +62 -0
- package/agents/implementer.md +47 -0
- package/agents/orchestrator.md +42 -0
- package/agents/product-designer.md +42 -0
- package/agents/prototype-runner.md +36 -0
- package/agents/qa-agent.md +76 -0
- package/agents/requirements-clarifier.md +58 -0
- package/agents/research-agent.md +33 -0
- package/agents/spec-reviewer.md +46 -0
- package/agents/spec-writer.md +32 -0
- package/agents/tdd-guide.md +51 -0
- package/agents/ui-ux-designer.md +50 -0
- package/package.json +40 -0
- package/skills/super-dev/SKILL.md +35 -0
- package/src/agents.ts +38 -0
- package/src/control.ts +85 -0
- package/src/doc-validators.ts +164 -0
- package/src/extension.ts +164 -0
- package/src/helpers.ts +263 -0
- package/src/nodes.ts +550 -0
- package/src/pi-spawn.ts +296 -0
- package/src/pipeline.ts +15 -0
- package/src/prompts.ts +120 -0
- package/src/session-agent.ts +305 -0
- package/src/setup.ts +141 -0
- package/src/stages/design.ts +33 -0
- package/src/stages/implementation.ts +80 -0
- package/src/stages/index.ts +172 -0
- package/src/stages/prototype.ts +43 -0
- package/src/stages/setup.ts +32 -0
- package/src/stages/writers.ts +105 -0
- package/src/types.ts +235 -0
- package/src/workflow.ts +181 -0
package/src/nodes.ts
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The control-flow node algebra.
|
|
3
|
+
*
|
|
4
|
+
* A pipeline is a tree of self-evaluating `Node`s. Leaf `task` nodes wrap a
|
|
5
|
+
* `Stage` (a unit of work). Control nodes compose nodes and implement their
|
|
6
|
+
* own `run(state, ctx)` by recursively evaluating children. The runner
|
|
7
|
+
* (`workflow.ts`) is just `await root.run(state, ctx)` — adding a new control
|
|
8
|
+
* construct means writing one builder here, never touching the runner.
|
|
9
|
+
*
|
|
10
|
+
* Node set (lineage in parens):
|
|
11
|
+
* task (ASL Task) leaf; runs a stage, stores result
|
|
12
|
+
* sequence (WCP1) run in order; fail-fast or tolerant
|
|
13
|
+
* branch (WCP4 Exclusive Choice) binary conditional
|
|
14
|
+
* choose (WCP4) multi-way conditional
|
|
15
|
+
* parallel (WCP2+WCP3 Split+Sync) concurrent branches + optional join
|
|
16
|
+
* loop (WCP10 Arbitrary Cycles) while/until/times iteration
|
|
17
|
+
* retry (ASL Retry) repeat-on-error with backoff
|
|
18
|
+
* gate (domain quality gates) validate output, re-run until valid
|
|
19
|
+
* map (WCP12-14 Multi-Instance) fan-out over a collection
|
|
20
|
+
* wait (ASL Wait) delay
|
|
21
|
+
* waitForEvent (WCP16 Deferred Choice) external signal sync (human-in-loop)
|
|
22
|
+
* tryCatch (ASL Catch) error boundary
|
|
23
|
+
* noop (ASL Pass) no-op
|
|
24
|
+
*
|
|
25
|
+
* Every node returns a truthful `NodeResult`. `status`:
|
|
26
|
+
* ok succeeded
|
|
27
|
+
* skipped intentionally not run (predicate/budget/disabled)
|
|
28
|
+
* failed ran but did not succeed (caught error / gate not satisfied)
|
|
29
|
+
* cancelled aborted via signal
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import type {
|
|
33
|
+
Node,
|
|
34
|
+
NodeResult,
|
|
35
|
+
NodeStatus,
|
|
36
|
+
PipelineState,
|
|
37
|
+
Stage,
|
|
38
|
+
StageContext,
|
|
39
|
+
ControlObj,
|
|
40
|
+
} from "./types.ts";
|
|
41
|
+
import { specDocExists } from "./doc-validators.ts";
|
|
42
|
+
|
|
43
|
+
// ─── Shared helper types ────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
type Predicate = (state: PipelineState, ctx: StageContext) => boolean | Promise<boolean>;
|
|
46
|
+
/** A gate validator returns structured errors, not just pass/fail — the gate feeds
|
|
47
|
+
* those errors into the next retry's prompt so retries CONVERGE instead of
|
|
48
|
+
* blind-resampling the same distribution (the root cause of "gate failed after
|
|
49
|
+
* 3 attempts" on a probabilistic agent). */
|
|
50
|
+
type Validator = (state: PipelineState, ctx: StageContext) => Promise<{ pass: boolean; errors: string[] }> | { pass: boolean; errors: string[] };
|
|
51
|
+
|
|
52
|
+
/** Run async functions with a concurrency cap, preserving order. */
|
|
53
|
+
async function runConcurrent<T>(fns: Array<() => Promise<T>>, concurrency = Infinity): Promise<T[]> {
|
|
54
|
+
const results = [] as T[];
|
|
55
|
+
const queue = fns.map((fn, i) => [i, fn] as const);
|
|
56
|
+
async function worker(): Promise<void> {
|
|
57
|
+
while (queue.length > 0) {
|
|
58
|
+
const entry = queue.shift();
|
|
59
|
+
if (!entry) return;
|
|
60
|
+
const [i, fn] = entry;
|
|
61
|
+
results[i] = await fn();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const n = Math.min(concurrency, fns.length);
|
|
65
|
+
if (n <= 0) return results;
|
|
66
|
+
await Promise.all(Array.from({ length: n }, () => worker()));
|
|
67
|
+
return results;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const sleep = (ms: number, signal?: AbortSignal): Promise<void> =>
|
|
71
|
+
new Promise((resolve) => {
|
|
72
|
+
if (signal?.aborted) return resolve();
|
|
73
|
+
const t = setTimeout(resolve, ms);
|
|
74
|
+
signal?.addEventListener(
|
|
75
|
+
"abort",
|
|
76
|
+
() => {
|
|
77
|
+
clearTimeout(t);
|
|
78
|
+
resolve();
|
|
79
|
+
},
|
|
80
|
+
{ once: true },
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const OK: NodeResult = { status: "ok" };
|
|
85
|
+
const NOOP_RESULT: NodeResult = { status: "ok" };
|
|
86
|
+
const failed = (error: string): NodeResult => ({ status: "failed", error });
|
|
87
|
+
const cancelled = (): NodeResult => ({ status: "cancelled" });
|
|
88
|
+
|
|
89
|
+
// ─── task ───────────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
/** Lift a `Stage` into a leaf node. Stores the return value under `state[id]`. */
|
|
92
|
+
export function task(stage: Stage): Node {
|
|
93
|
+
const record = (ctx: StageContext, status: NodeStatus, error?: string) =>
|
|
94
|
+
ctx.results.push({ id: stage.id, label: stage.label, status, error });
|
|
95
|
+
return {
|
|
96
|
+
kind: "task",
|
|
97
|
+
label: stage.label,
|
|
98
|
+
async run(state, ctx) {
|
|
99
|
+
if (ctx.signal?.aborted) return { status: "cancelled" };
|
|
100
|
+
if (stage.enabled && !stage.enabled(state)) {
|
|
101
|
+
ctx.log(`task "${stage.id}": skipped (disabled)`);
|
|
102
|
+
record(ctx, "skipped");
|
|
103
|
+
return { status: "skipped" };
|
|
104
|
+
}
|
|
105
|
+
if (!ctx.budget.check()) {
|
|
106
|
+
ctx.log(`task "${stage.id}": skipped (budget exhausted)`);
|
|
107
|
+
record(ctx, "skipped");
|
|
108
|
+
return { status: "skipped" };
|
|
109
|
+
}
|
|
110
|
+
// Precondition: verify upstream artifact docs exist before running. Logs
|
|
111
|
+
// ✓/✗ per required glob so inter-stage dependencies are visible. Missing
|
|
112
|
+
// artifacts are NOT fatal — the tolerant pipeline proceeds (the prompt
|
|
113
|
+
// shows "N/A" for absent upstream) and the gap is logged.
|
|
114
|
+
const specDir = state.setup?.specDirectory ?? "";
|
|
115
|
+
if (stage.requires?.length && specDir) {
|
|
116
|
+
for (const glob of stage.requires) {
|
|
117
|
+
ctx.log(`precondition ${stage.id}: ${specDocExists(specDir, glob) ? "✓" : "✗ missing"} ${glob}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
ctx.events.emit("phase", stage.label);
|
|
122
|
+
const result = await stage.run(state, ctx);
|
|
123
|
+
if (result !== undefined && result !== null) state[stage.id] = result;
|
|
124
|
+
record(ctx, "ok");
|
|
125
|
+
return { status: "ok", value: result };
|
|
126
|
+
} catch (err) {
|
|
127
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
128
|
+
record(ctx, "failed", error);
|
|
129
|
+
if (stage.fatal) throw err;
|
|
130
|
+
return { status: "failed", error };
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ─── sequence ───────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
export interface SequenceOptions {
|
|
139
|
+
tolerant?: boolean;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Run nodes in order. Fail-fast by default; `tolerant` logs+continues past failures. */
|
|
143
|
+
export function sequence(children: Node[], opts: SequenceOptions = {}): Node {
|
|
144
|
+
return {
|
|
145
|
+
kind: "sequence",
|
|
146
|
+
async run(state, ctx) {
|
|
147
|
+
for (const child of children) {
|
|
148
|
+
if (ctx.signal?.aborted) return { status: "cancelled" };
|
|
149
|
+
let r: NodeResult;
|
|
150
|
+
try {
|
|
151
|
+
r = await child.run(state, ctx);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
// A thrown exception must NOT bypass a tolerant sequence and abort the
|
|
154
|
+
// whole run (the original bug: gate({fatal:true}) threw through
|
|
155
|
+
// `tolerant` and discarded every prior stage's artifacts). Tolerant
|
|
156
|
+
// means tolerant — convert throws to failed and continue.
|
|
157
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
158
|
+
if (!opts.tolerant) throw err;
|
|
159
|
+
ctx.log(`sequence: stage threw — ${error} (tolerant: continuing)`);
|
|
160
|
+
r = { status: "failed", error };
|
|
161
|
+
}
|
|
162
|
+
if (r.status === "cancelled") return r;
|
|
163
|
+
if (r.status === "failed" && !opts.tolerant) return r;
|
|
164
|
+
}
|
|
165
|
+
return OK;
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ─── branch / choose ────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
/** Binary conditional (WCP4 Exclusive Choice). */
|
|
173
|
+
export function branch(predicate: Predicate, branches: { yes: Node; no?: Node }): Node {
|
|
174
|
+
return {
|
|
175
|
+
kind: "branch",
|
|
176
|
+
async run(state, ctx) {
|
|
177
|
+
if (ctx.signal?.aborted) return { status: "cancelled" };
|
|
178
|
+
const cond = await predicate(state, ctx);
|
|
179
|
+
const chosen = cond ? branches.yes : branches.no;
|
|
180
|
+
if (!chosen) return { status: "skipped" };
|
|
181
|
+
return chosen.run(state, ctx);
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface ChooseCase {
|
|
187
|
+
when: Predicate;
|
|
188
|
+
run: Node;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Multi-way conditional. First matching case wins; else `otherwise` or skipped. */
|
|
192
|
+
export function choose(cases: ChooseCase[], otherwise?: Node): Node {
|
|
193
|
+
return {
|
|
194
|
+
kind: "choose",
|
|
195
|
+
async run(state, ctx) {
|
|
196
|
+
if (ctx.signal?.aborted) return { status: "cancelled" };
|
|
197
|
+
for (const c of cases) {
|
|
198
|
+
if (await c.when(state, ctx)) return c.run.run(state, ctx);
|
|
199
|
+
}
|
|
200
|
+
return otherwise ? otherwise.run(state, ctx) : { status: "skipped" };
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ─── parallel ───────────────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
export interface ParallelOptions {
|
|
208
|
+
into?: string;
|
|
209
|
+
join?: (results: NodeResult[], state: PipelineState, ctx: StageContext) => Promise<unknown> | unknown;
|
|
210
|
+
concurrency?: number;
|
|
211
|
+
tolerant?: boolean;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Run branches concurrently (WCP2 parallel split). Branches share `state`;
|
|
216
|
+
* they MUST write distinct keys to avoid clobbering. Optional `join` reduces
|
|
217
|
+
* branch results and stores the value under `into`.
|
|
218
|
+
*/
|
|
219
|
+
export function parallel(branches: Node[], opts: ParallelOptions = {}): Node {
|
|
220
|
+
return {
|
|
221
|
+
kind: "parallel",
|
|
222
|
+
async run(state, ctx) {
|
|
223
|
+
if (ctx.signal?.aborted) return { status: "cancelled" };
|
|
224
|
+
const results = await runConcurrent(
|
|
225
|
+
branches.map((b) => () => b.run(state, ctx)),
|
|
226
|
+
opts.concurrency ?? ctx.options.maxConcurrency ?? Infinity,
|
|
227
|
+
);
|
|
228
|
+
if (results.some((r) => r.status === "cancelled")) return { status: "cancelled" };
|
|
229
|
+
if (!opts.tolerant && results.some((r) => r.status === "failed")) {
|
|
230
|
+
const first = results.find((r) => r.status === "failed");
|
|
231
|
+
return { status: "failed", error: first?.error };
|
|
232
|
+
}
|
|
233
|
+
if (opts.join) {
|
|
234
|
+
const joined = await opts.join(results, state, ctx);
|
|
235
|
+
if (opts.into) state[opts.into] = joined;
|
|
236
|
+
return { status: "ok", value: joined };
|
|
237
|
+
}
|
|
238
|
+
return { status: "ok", value: results };
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ─── loop ───────────────────────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
export interface LoopOptions {
|
|
246
|
+
while?: Predicate;
|
|
247
|
+
until?: Predicate;
|
|
248
|
+
times?: number;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Arbitrary-cycle iteration (WCP10). `while`/`until` checked before each body run. */
|
|
252
|
+
export function loop(opts: LoopOptions, body: Node): Node {
|
|
253
|
+
return {
|
|
254
|
+
kind: "loop",
|
|
255
|
+
async run(state, ctx) {
|
|
256
|
+
const max = opts.times ?? Infinity;
|
|
257
|
+
let last: NodeResult = OK;
|
|
258
|
+
for (let attempt = 1; attempt <= max; attempt++) {
|
|
259
|
+
if (ctx.signal?.aborted) return { status: "cancelled" };
|
|
260
|
+
if (opts.while && !(await opts.while(state, ctx))) break;
|
|
261
|
+
if (opts.until && (await opts.until(state, ctx))) break;
|
|
262
|
+
last = await body.run(state, ctx);
|
|
263
|
+
if (last.status === "cancelled") return last;
|
|
264
|
+
if (last.status === "failed") return last;
|
|
265
|
+
}
|
|
266
|
+
return { ...last, attempts: max === Infinity ? undefined : max };
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ─── retry ──────────────────────────────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
export interface RetryOptions {
|
|
274
|
+
attempts: number;
|
|
275
|
+
backoff?: number | ((attempt: number) => number);
|
|
276
|
+
matches?: (result: NodeResult, state: PipelineState, ctx: StageContext) => boolean | Promise<boolean>;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Repeat a node on failure (ASL Retry / Temporal RetryPolicy). */
|
|
280
|
+
export function retry(opts: RetryOptions, node: Node): Node {
|
|
281
|
+
return {
|
|
282
|
+
kind: "retry",
|
|
283
|
+
async run(state, ctx) {
|
|
284
|
+
let last: NodeResult = { status: "failed", error: "never ran" };
|
|
285
|
+
for (let attempt = 1; attempt <= opts.attempts; attempt++) {
|
|
286
|
+
if (ctx.signal?.aborted) return { status: "cancelled" };
|
|
287
|
+
last = await node.run(state, ctx);
|
|
288
|
+
if (last.status === "cancelled") return last;
|
|
289
|
+
if (last.status === "ok" || last.status === "skipped") return { ...last, attempts: attempt };
|
|
290
|
+
// failed:
|
|
291
|
+
if (opts.matches && !(await opts.matches(last, state, ctx))) return { ...last, attempts: attempt };
|
|
292
|
+
if (attempt < opts.attempts) {
|
|
293
|
+
const delay = typeof opts.backoff === "function" ? opts.backoff(attempt) : opts.backoff;
|
|
294
|
+
if (delay) await sleep(delay, ctx.signal);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return { ...last, attempts: opts.attempts };
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ─── gate ───────────────────────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
export interface GateOptions {
|
|
305
|
+
validate: Validator;
|
|
306
|
+
attempts?: number;
|
|
307
|
+
/** Remediation node run between failed validations (defaults to re-running `node`). */
|
|
308
|
+
fix?: Node;
|
|
309
|
+
/** Stage id; the gate stores the validator's errors under state.__feedback[feedbackKey]
|
|
310
|
+
* so the next retry's agent prompt includes them (see workflow.ts agent()). */
|
|
311
|
+
feedbackKey?: string;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Run `node`, validate its output, and repeat (running `fix`, or `node` again)
|
|
316
|
+
* until validation passes or attempts are exhausted.
|
|
317
|
+
*
|
|
318
|
+
* First-principles behavior for a pipeline over PROBABILISTIC agents:
|
|
319
|
+
* - Retries CONVERGE: the validator returns structured errors, which are fed
|
|
320
|
+
* into the next attempt's prompt (via state.__feedback + workflow.ts), so the
|
|
321
|
+
* agent fixes the specific failure instead of blind-resampling.
|
|
322
|
+
* - Exhaustion NEVER throws/aborts. A thrown gate would bypass `tolerant`
|
|
323
|
+
* sequences and discard every prior stage's artifacts. Exhaustion logs and
|
|
324
|
+
* returns failed; the tolerant pipeline proceeds with the best-available
|
|
325
|
+
* artifact. (Only the setup stage is truly fatal — it's not a gate.)
|
|
326
|
+
*/
|
|
327
|
+
export function gate(opts: GateOptions, node: Node): Node {
|
|
328
|
+
return {
|
|
329
|
+
kind: "gate",
|
|
330
|
+
async run(state, ctx) {
|
|
331
|
+
const max = opts.attempts ?? 3;
|
|
332
|
+
const label = opts.feedbackKey ? ` gate ${opts.feedbackKey}` : "";
|
|
333
|
+
let lastErrors: string[] = [];
|
|
334
|
+
let last: NodeResult = OK;
|
|
335
|
+
for (let attempt = 1; attempt <= max; attempt++) {
|
|
336
|
+
if (ctx.signal?.aborted) return { status: "cancelled" };
|
|
337
|
+
const target = attempt === 1 ? node : (opts.fix ?? node);
|
|
338
|
+
last = await target.run(state, ctx);
|
|
339
|
+
if (last.status === "cancelled") return last;
|
|
340
|
+
if (last.status === "failed") {
|
|
341
|
+
if (attempt < max) continue;
|
|
342
|
+
break; // exhausted → non-fatal return below
|
|
343
|
+
}
|
|
344
|
+
const v = await opts.validate(state, ctx);
|
|
345
|
+
if (v.pass) {
|
|
346
|
+
ctx.log(`gate${label}: ✓ validated (attempt ${attempt}${attempt > 1 ? ", after feedback" : ""})`);
|
|
347
|
+
return { status: "ok", attempts: attempt };
|
|
348
|
+
}
|
|
349
|
+
lastErrors = v.errors;
|
|
350
|
+
ctx.log(`gate${label}: ✗ FAIL attempt ${attempt}/${max}${v.errors.length ? ` — ${v.errors.join("; ")}` : ""}`);
|
|
351
|
+
// Feed the errors forward so the next attempt's agent prompt names them.
|
|
352
|
+
if (opts.feedbackKey) {
|
|
353
|
+
const all = (state as Record<string, unknown>).__feedback as Record<string, string[]> | undefined;
|
|
354
|
+
(state as Record<string, unknown>).__feedback = { ...(all ?? {}), [opts.feedbackKey]: v.errors };
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
const msg = `gate${label} could not pass after ${max} attempt(s)${lastErrors.length ? `: ${lastErrors.join("; ")}` : ""}`;
|
|
358
|
+
ctx.log(`gate: EXHAUSTED (non-fatal) — proceeding with best-available artifact`);
|
|
359
|
+
return { status: "failed", error: msg, attempts: max };
|
|
360
|
+
},
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ─── map ────────────────────────────────────────────────────────────────────
|
|
365
|
+
|
|
366
|
+
export interface MapOptions {
|
|
367
|
+
over: (state: PipelineState, ctx: StageContext) => unknown[] | Promise<unknown[]>;
|
|
368
|
+
as: string;
|
|
369
|
+
into?: string;
|
|
370
|
+
join?: (results: NodeResult[], state: PipelineContextState, ctx: StageContext) => Promise<unknown> | unknown;
|
|
371
|
+
concurrency?: number;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// (alias to avoid a circular type reference in JSDoc only)
|
|
375
|
+
type PipelineContextState = PipelineState;
|
|
376
|
+
|
|
377
|
+
/** Fan-out over a collection (WCP12-14 Multiple Instances). NOTE: concurrent
|
|
378
|
+
* iterations share `state`; use distinct keys or `concurrency: 1` for safety. */
|
|
379
|
+
export function map(opts: MapOptions, body: Node): Node {
|
|
380
|
+
return {
|
|
381
|
+
kind: "map",
|
|
382
|
+
async run(state, ctx) {
|
|
383
|
+
if (ctx.signal?.aborted) return { status: "cancelled" };
|
|
384
|
+
const items = await opts.over(state, ctx);
|
|
385
|
+
const results = await runConcurrent(
|
|
386
|
+
items.map((item) => async () => {
|
|
387
|
+
(state as Record<string, unknown>)[opts.as] = item;
|
|
388
|
+
return body.run(state, ctx);
|
|
389
|
+
}),
|
|
390
|
+
opts.concurrency ?? 1,
|
|
391
|
+
);
|
|
392
|
+
if (results.some((r) => r.status === "cancelled")) return { status: "cancelled" };
|
|
393
|
+
if (opts.join) {
|
|
394
|
+
const joined = await opts.join(results, state, ctx);
|
|
395
|
+
if (opts.into) state[opts.into] = joined;
|
|
396
|
+
return { status: "ok", value: joined };
|
|
397
|
+
}
|
|
398
|
+
return { status: "ok", value: results };
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ─── wait / waitForEvent ────────────────────────────────────────────────────
|
|
404
|
+
|
|
405
|
+
/** Delay (ASL Wait). Signal-aware. */
|
|
406
|
+
export function wait(ms: number): Node {
|
|
407
|
+
return {
|
|
408
|
+
kind: "wait",
|
|
409
|
+
async run(_state, ctx) {
|
|
410
|
+
if (ctx.signal?.aborted) return { status: "cancelled" };
|
|
411
|
+
await sleep(ms, ctx.signal);
|
|
412
|
+
return ctx.signal?.aborted ? { status: "cancelled" } : OK;
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export interface WaitForEventOptions {
|
|
418
|
+
timeout?: number;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Block until an event is emitted on `ctx.events` (WCP16 Deferred Choice). */
|
|
422
|
+
export function waitForEvent(name: string, opts: WaitForEventOptions = {}): Node {
|
|
423
|
+
return {
|
|
424
|
+
kind: "waitForEvent",
|
|
425
|
+
async run(_state, ctx) {
|
|
426
|
+
if (ctx.signal?.aborted) return { status: "cancelled" };
|
|
427
|
+
return new Promise<NodeResult>((resolve) => {
|
|
428
|
+
let done = false;
|
|
429
|
+
const finish = (r: NodeResult) => {
|
|
430
|
+
if (done) return;
|
|
431
|
+
done = true;
|
|
432
|
+
ctx.events.removeListener(name, onEvent);
|
|
433
|
+
clearTimeout(timer);
|
|
434
|
+
resolve(r);
|
|
435
|
+
};
|
|
436
|
+
const onEvent = () => finish(OK);
|
|
437
|
+
ctx.events.once(name, onEvent);
|
|
438
|
+
const timer = opts.timeout
|
|
439
|
+
? setTimeout(() => finish(failed(`timeout waiting for event "${name}"`)), opts.timeout)
|
|
440
|
+
: undefined;
|
|
441
|
+
ctx.signal?.addEventListener("abort", () => finish(cancelled()), { once: true });
|
|
442
|
+
});
|
|
443
|
+
},
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ─── tryCatch ───────────────────────────────────────────────────────────────
|
|
448
|
+
|
|
449
|
+
export interface TryCatchOptions {
|
|
450
|
+
catch?: Node;
|
|
451
|
+
finally?: Node;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Error boundary (ASL Catch). Catches thrown errors (e.g. fatal tasks). */
|
|
455
|
+
export function tryCatch(body: Node, opts: TryCatchOptions = {}): Node {
|
|
456
|
+
return {
|
|
457
|
+
kind: "tryCatch",
|
|
458
|
+
async run(state, ctx) {
|
|
459
|
+
try {
|
|
460
|
+
const r = await body.run(state, ctx);
|
|
461
|
+
if (opts.finally) await opts.finally.run(state, ctx);
|
|
462
|
+
return r;
|
|
463
|
+
} catch (err) {
|
|
464
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
465
|
+
(state as Record<string, unknown>).__lastError = error;
|
|
466
|
+
ctx.log(`tryCatch: caught error — ${error}`);
|
|
467
|
+
const r = opts.catch ? await opts.catch.run(state, ctx) : failed(error);
|
|
468
|
+
if (opts.finally) await opts.finally.run(state, ctx);
|
|
469
|
+
return r;
|
|
470
|
+
}
|
|
471
|
+
},
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** No-op node (ASL Pass). */
|
|
476
|
+
export function noop(): Node {
|
|
477
|
+
return { kind: "noop", async run() { return NOOP_RESULT; } };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ─── Convenience stage builders ─────────────────────────────────────────────
|
|
481
|
+
|
|
482
|
+
/** A task that spawns one specialist agent and returns its parsed control. */
|
|
483
|
+
export function writerTask(spec: {
|
|
484
|
+
id: string;
|
|
485
|
+
label: string;
|
|
486
|
+
agent: string;
|
|
487
|
+
buildPrompt: (state: PipelineState, ctx: StageContext) => string;
|
|
488
|
+
fatal?: boolean;
|
|
489
|
+
/** Upstream artifact docs this writer needs (globs); checked by task() before run. */
|
|
490
|
+
requires?: string[];
|
|
491
|
+
}): Stage {
|
|
492
|
+
return {
|
|
493
|
+
id: spec.id,
|
|
494
|
+
label: spec.label,
|
|
495
|
+
fatal: spec.fatal,
|
|
496
|
+
requires: spec.requires,
|
|
497
|
+
async run(state, ctx) {
|
|
498
|
+
if (!ctx.budget.check()) return undefined;
|
|
499
|
+
const result = await ctx.agent({
|
|
500
|
+
id: `pipeline.${spec.id}`,
|
|
501
|
+
agent: spec.agent,
|
|
502
|
+
prompt: spec.buildPrompt(state, ctx),
|
|
503
|
+
});
|
|
504
|
+
if (result.error) ctx.log(`${spec.id}: agent error — ${result.error}`);
|
|
505
|
+
if (!result.control) {
|
|
506
|
+
const said = result.text ? ` (last text: ${result.text.replace(/\s+/g, " ")})` : "";
|
|
507
|
+
ctx.log(`${spec.id}: agent produced no control object${said}`);
|
|
508
|
+
}
|
|
509
|
+
return result.control ?? {};
|
|
510
|
+
},
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** A task that runs a deterministic helper and returns its value. */
|
|
515
|
+
export function helperTask(spec: {
|
|
516
|
+
id: string;
|
|
517
|
+
label: string;
|
|
518
|
+
helper: string;
|
|
519
|
+
sources: (state: PipelineState, ctx: StageContext) => Record<string, unknown>;
|
|
520
|
+
options?: (state: PipelineState, ctx: StageContext) => Record<string, unknown>;
|
|
521
|
+
context?: (state: PipelineState, ctx: StageContext) => Record<string, unknown>;
|
|
522
|
+
}): Stage {
|
|
523
|
+
return {
|
|
524
|
+
id: spec.id,
|
|
525
|
+
label: spec.label,
|
|
526
|
+
async run(state, ctx) {
|
|
527
|
+
const result = await ctx.helper({
|
|
528
|
+
name: spec.helper,
|
|
529
|
+
sources: spec.sources(state, ctx),
|
|
530
|
+
options: spec.options?.(state, ctx),
|
|
531
|
+
context: spec.context?.(state, ctx),
|
|
532
|
+
});
|
|
533
|
+
return result.value as ControlObj;
|
|
534
|
+
},
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** A validator backed by a gate helper. */
|
|
539
|
+
export function gateValidator(helperName: string, sourceKey: string, stateKey: string): Validator {
|
|
540
|
+
return async (state, ctx) => {
|
|
541
|
+
const result = await ctx.helper({
|
|
542
|
+
name: helperName,
|
|
543
|
+
// Include setup so content gates can read docs from the spec directory
|
|
544
|
+
// (the control object's docPath may be missing/misreported by the agent).
|
|
545
|
+
sources: { [sourceKey]: (state as Record<string, unknown>)[stateKey] ?? {}, setup: state.setup },
|
|
546
|
+
});
|
|
547
|
+
const value = result.value as { pass?: boolean; errors?: string[] };
|
|
548
|
+
return { pass: Boolean(value.pass), errors: value.errors ?? [] };
|
|
549
|
+
};
|
|
550
|
+
}
|