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.
Files changed (45) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/LICENSE +21 -0
  3. package/README.md +135 -0
  4. package/agents/adversarial-reviewer.md +64 -0
  5. package/agents/architecture-designer.md +43 -0
  6. package/agents/architecture-improver.md +46 -0
  7. package/agents/bdd-scenario-writer.md +37 -0
  8. package/agents/build-cleaner.md +44 -0
  9. package/agents/code-assessor.md +24 -0
  10. package/agents/code-reviewer.md +59 -0
  11. package/agents/debug-analyzer.md +54 -0
  12. package/agents/docs-executor.md +49 -0
  13. package/agents/handoff-writer.md +62 -0
  14. package/agents/implementer.md +47 -0
  15. package/agents/orchestrator.md +42 -0
  16. package/agents/product-designer.md +42 -0
  17. package/agents/prototype-runner.md +36 -0
  18. package/agents/qa-agent.md +76 -0
  19. package/agents/requirements-clarifier.md +58 -0
  20. package/agents/research-agent.md +33 -0
  21. package/agents/spec-reviewer.md +46 -0
  22. package/agents/spec-writer.md +32 -0
  23. package/agents/tdd-guide.md +51 -0
  24. package/agents/ui-ux-designer.md +50 -0
  25. package/package.json +40 -0
  26. package/skills/super-dev/SKILL.md +35 -0
  27. package/src/agents.ts +38 -0
  28. package/src/control.ts +85 -0
  29. package/src/doc-validators.ts +164 -0
  30. package/src/extension.ts +164 -0
  31. package/src/helpers.ts +263 -0
  32. package/src/nodes.ts +550 -0
  33. package/src/pi-spawn.ts +296 -0
  34. package/src/pipeline.ts +15 -0
  35. package/src/prompts.ts +120 -0
  36. package/src/session-agent.ts +305 -0
  37. package/src/setup.ts +141 -0
  38. package/src/stages/design.ts +33 -0
  39. package/src/stages/implementation.ts +80 -0
  40. package/src/stages/index.ts +172 -0
  41. package/src/stages/prototype.ts +43 -0
  42. package/src/stages/setup.ts +32 -0
  43. package/src/stages/writers.ts +105 -0
  44. package/src/types.ts +235 -0
  45. 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
+ }