@nicknisi/pi-workflows 0.2.1 → 0.3.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/engine.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * The workflow script engine — pi-free and testable.
3
3
  *
4
4
  * A workflow script is a JavaScript statement body with injected globals
5
- * (args, agent, parallel, pipeline, phase, log, budget, cwd) and a leading
5
+ * (args, agent, parallel, pipeline, phase, log, budget, cwd, checkpoint, ask) and a leading
6
6
  * `export const meta = { name, description }` declaration. It returns a value
7
7
  * by evaluating a trailing expression or a top-level `return` (the body is
8
8
  * wrapped in an async function so a bare `return` compiles).
@@ -71,6 +71,58 @@ export interface EngineBudget {
71
71
  remaining: number;
72
72
  }
73
73
 
74
+ // ── Run gate (pause/resume) ────────────────────────────────────────────────
75
+ // A per-run hold. agent() awaits gate.wait() before every spawn, so pausing
76
+ // lets the in-flight step finish and holds the run before the next one — the
77
+ // script contract's equivalent of osolmaz/pi-workflows' `/workflow pause`.
78
+ // abort() rejects current AND future waiters so a stopped run never hangs
79
+ // parked at a gate.
80
+
81
+ export interface RunGate {
82
+ readonly paused: boolean;
83
+ pause(): void;
84
+ resume(): void;
85
+ /** Reject all current and future waiters (run stopped/timed out). */
86
+ abort(): void;
87
+ wait(): Promise<void>;
88
+ }
89
+
90
+ export function createRunGate(): RunGate {
91
+ let paused = false;
92
+ let aborted = false;
93
+ let waiters: Array<{ resolve: () => void; reject: (e: Error) => void }> = [];
94
+ return {
95
+ get paused() {
96
+ return paused;
97
+ },
98
+ pause() {
99
+ paused = true;
100
+ },
101
+ resume() {
102
+ paused = false;
103
+ const w = waiters;
104
+ waiters = [];
105
+ for (const { resolve } of w) resolve();
106
+ },
107
+ abort() {
108
+ aborted = true;
109
+ const w = waiters;
110
+ waiters = [];
111
+ for (const { reject } of w) reject(new Error('run aborted while paused'));
112
+ },
113
+ wait() {
114
+ if (aborted) return Promise.reject(new Error('run aborted while paused'));
115
+ if (!paused) return Promise.resolve();
116
+ return new Promise<void>((resolve, reject) => {
117
+ waiters.push({ resolve, reject });
118
+ });
119
+ },
120
+ };
121
+ }
122
+
123
+ /** Script-global human question: select when options given, confirm otherwise. undefined = dismissed. */
124
+ export type EngineAskFn = (question: string, options?: string[]) => Promise<string | boolean | undefined>;
125
+
74
126
  export interface ScriptMeta {
75
127
  name?: string;
76
128
  description?: string;
@@ -84,6 +136,19 @@ export interface RunScriptOptions {
84
136
  cwd: string;
85
137
  budgetTotal?: number;
86
138
  onLog?: (line: string) => void;
139
+ /** Pause gate; agent() awaits it before every spawn. */
140
+ gate?: RunGate;
141
+ /**
142
+ * Host-side human gate for the `checkpoint(label?)` global. When omitted,
143
+ * checkpoint is a no-op that logs a skip note (pi-free hosts, tests).
144
+ */
145
+ checkpoint?: (label?: string) => Promise<void>;
146
+ /**
147
+ * Host-side human question for the `ask(question, options?)` global. When
148
+ * omitted, ask throws — a script that needs an answer should fail loudly
149
+ * rather than invent one.
150
+ */
151
+ ask?: EngineAskFn;
87
152
  }
88
153
 
89
154
  export interface RunScriptResult {
@@ -168,6 +233,8 @@ export async function runScript(opts: RunScriptOptions): Promise<RunScriptResult
168
233
  if (typeof agentOpts.agentType === 'string') {
169
234
  log(`(agentType '${agentOpts.agentType}' accepted but ignored — no agent-type registry)`);
170
235
  }
236
+ // Pause gate: hold here (between steps) until resumed. Rejects on abort.
237
+ await opts.gate?.wait();
171
238
  const spawnOpts: EngineSpawnOptions = {
172
239
  prompt,
173
240
  ...(typeof agentOpts.label === 'string' ? { agent: agentOpts.label } : {}),
@@ -191,6 +258,25 @@ export async function runScript(opts: RunScriptOptions): Promise<RunScriptResult
191
258
  return res.data ?? res.text ?? null;
192
259
  };
193
260
 
261
+ // checkpoint(label?): script-internal human gate. The host decides what
262
+ // "continue?" means (a confirm dialog in pi); default is a logged no-op.
263
+ const checkpoint = async (label?: string): Promise<void> => {
264
+ if (!opts.checkpoint) {
265
+ log(`(checkpoint${label ? ` '${label}'` : ''} skipped — no host gate)`);
266
+ return;
267
+ }
268
+ log(`⏸ checkpoint${label ? `: ${label}` : ''}`);
269
+ await opts.checkpoint(label);
270
+ };
271
+
272
+ // ask(question, options?) — human answer inside a run. select with options,
273
+ // confirm without. Default throws: never invent an answer.
274
+ const ask: EngineAskFn = async (question, options) => {
275
+ if (!opts.ask) throw new Error('ask() unavailable in this host');
276
+ log(`? ${question}`);
277
+ return opts.ask(question, options);
278
+ };
279
+
194
280
  const parallel = <T>(thunks: Array<() => Promise<T>>): Promise<T[]> => Promise.all(thunks.map((t) => t()));
195
281
 
196
282
  // pipeline(items, ...stages): each stage maps over the previous stage's
@@ -206,7 +292,7 @@ export async function runScript(opts: RunScriptOptions): Promise<RunScriptResult
206
292
  // Compile + run. compileScript is extracted so callers (tests, future
207
293
  // tooling) can compile + read `meta` without a real spawn.
208
294
  const compiled = compileScript(script);
209
- const value = await compiled.fn(args, agent, parallel, pipeline, phase, log, budget, cwd);
295
+ const value = await compiled.fn(args, agent, parallel, pipeline, phase, log, budget, cwd, checkpoint, ask);
210
296
  return { value, meta: compiled.meta, logs, usage, durationMs: Date.now() - startedAt };
211
297
  }
212
298
 
@@ -222,6 +308,8 @@ export type CompiledFn = (
222
308
  log: unknown,
223
309
  budget: unknown,
224
310
  cwd: string,
311
+ checkpoint: unknown,
312
+ ask: unknown,
225
313
  ) => Promise<unknown>;
226
314
 
227
315
  export interface CompiledScript {
@@ -245,7 +333,7 @@ export interface CompiledScript {
245
333
  export function compileScript(script: string): CompiledScript {
246
334
  const metaHolder: { value: ScriptMeta | undefined } = { value: undefined };
247
335
  const stripped = script.replace(STRIP_META, 'const meta = metaHolder.value =');
248
- const wrapped = `(async function(args, agent, parallel, pipeline, phase, log, budget, cwd, metaHolder){\n${stripped}\n})`;
336
+ const wrapped = `(async function(args, agent, parallel, pipeline, phase, log, budget, cwd, checkpoint, ask, metaHolder){\n${stripped}\n})`;
249
337
  const raw = new vm.Script(wrapped, { filename: 'workflow.js' }).runInThisContext() as (
250
338
  args: unknown,
251
339
  agent: unknown,
@@ -255,12 +343,14 @@ export function compileScript(script: string): CompiledScript {
255
343
  log: unknown,
256
344
  budget: unknown,
257
345
  cwd: string,
346
+ checkpoint: unknown,
347
+ ask: unknown,
258
348
  metaHolder: { value: ScriptMeta | undefined },
259
349
  ) => Promise<unknown>;
260
- // Bind metaHolder so callers invoke an 8-arg fn; the holder rides the call
350
+ // Bind metaHolder so callers invoke a 10-arg fn; the holder rides the call
261
351
  // (runInThisContext cannot see a closure variable, so it must be a parameter).
262
- const fn: CompiledFn = (args, agent, parallel, pipeline, phase, log, budget, cwd) =>
263
- raw(args, agent, parallel, pipeline, phase, log, budget, cwd, metaHolder);
352
+ const fn: CompiledFn = (args, agent, parallel, pipeline, phase, log, budget, cwd, checkpoint, ask) =>
353
+ raw(args, agent, parallel, pipeline, phase, log, budget, cwd, checkpoint, ask, metaHolder);
264
354
 
265
355
  // Stub dry-run to read `meta`. Well-formed scripts declare `meta` first, so
266
356
  // it is assigned synchronously before the first `await`; we await the whole
@@ -274,6 +364,8 @@ export function compileScript(script: string): CompiledScript {
274
364
  return values as U[];
275
365
  };
276
366
  const stubBudget = { total: Infinity, spent: 0, remaining: Infinity };
367
+ const stubCheckpoint = async (): Promise<void> => {};
368
+ const stubAsk = async (): Promise<undefined> => undefined;
277
369
  void fn(
278
370
  undefined,
279
371
  stubAgent,
@@ -283,6 +375,8 @@ export function compileScript(script: string): CompiledScript {
283
375
  () => {},
284
376
  stubBudget,
285
377
  '/tmp',
378
+ stubCheckpoint,
379
+ stubAsk,
286
380
  ).catch(() => {});
287
381
 
288
382
  // `meta` is a getter so a caller that re-runs `fn` with real globals sees
@@ -0,0 +1,80 @@
1
+ // autoimplement.js — implement a plan end-to-end with a bounded review/fix loop.
2
+ //
3
+ // Ported from osolmaz/pi-workflows' autoimplement, cut to the loop that
4
+ // matters: gate the plan with a human (their approval modes become one ask),
5
+ // build, verify, review, fix, repeat until a reviewer finds no P0/P1 or the
6
+ // round cap trips. Theirs adds PR/CI watching and pi-reviewer integration —
7
+ // add that back as a stage when you need it (YAGNI until then).
8
+ //
9
+ // THE REASONING SHAPE (the part worth stealing):
10
+ // 1. NEVER devise a new plan — the plan is input. If evidence invalidates
11
+ // it mid-run, stop and say so (re-plan is autoplan's job).
12
+ // 2. ask() gates the plan BEFORE tokens burn: approve / edit / abort.
13
+ // 3. Review loop is BOUNDED (MAX_ROUNDS) — an unbounded fix loop is a
14
+ // wallet drain with extra steps. P0/P1 block; P2 noted but non-blocking.
15
+ // 4. Verification runs after EVERY fix round, not just the first build.
16
+ //
17
+ // HOW TO ADAPT: pass { task, plan, verify } via args —
18
+ // /wf run autoimplement {"task":"add timeout fallback","plan":"...","verify":"pnpm typecheck"}
19
+
20
+ export const meta = {
21
+ name: 'autoimplement',
22
+ description: 'Human-gated plan → build → verify → review/fix loop, bounded rounds, P0/P1 block',
23
+ };
24
+
25
+ const TASK = (args && args.task) || 'Implement the plan.';
26
+ const PLAN = (args && args.plan) || '(no plan supplied — stop and ask for one)';
27
+ const VERIFY = (args && args.verify) || 'npx tsgo --noEmit';
28
+ const MAX_ROUNDS = 3;
29
+ const BUILDER_TOOLS = ['read', 'edit', 'write', 'bash', 'grep', 'find'];
30
+
31
+ if (!args || !args.plan) throw new Error('autoimplement requires args.plan — never devise a new plan here.');
32
+
33
+ const decision = await ask(`Implement this plan?\n\nTask: ${TASK}\n\nPlan:\n${PLAN.slice(0, 2000)}`, [
34
+ 'implement',
35
+ 'abort',
36
+ ]);
37
+ if (decision !== 'implement') throw new Error(`aborted at plan gate (${decision ?? 'dismissed'})`);
38
+
39
+ phase('build');
40
+ let build = await agent(
41
+ `Implement this plan end-to-end, minimal and correct. Run \`${VERIFY}\` and make it pass before ` +
42
+ `finishing. Report: files changed, one line per change, verify output tail.\n\nTASK: ${TASK}\n\nPLAN:\n${PLAN}`,
43
+ { label: 'builder', tools: BUILDER_TOOLS },
44
+ );
45
+
46
+ for (let round = 1; round <= MAX_ROUNDS; round++) {
47
+ phase(`review round ${round}`);
48
+ const review = await agent(
49
+ `Review the implementation against the plan. Classify every finding P0 (wrong/unsafe), P1 (should ` +
50
+ `fix before merge), P2 (proportionate improvements). Verify claims against the actual files — no ` +
51
+ `evidence, no finding. Ignore legacy-compatibility asks unless the plan requires them.\n\n` +
52
+ `PLAN:\n${PLAN}\n\nBUILDER REPORT:\n${build}`,
53
+ {
54
+ label: 'reviewer',
55
+ schema: {
56
+ type: 'object',
57
+ properties: {
58
+ p0: { type: 'array', items: { type: 'string' } },
59
+ p1: { type: 'array', items: { type: 'string' } },
60
+ p2: { type: 'array', items: { type: 'string' } },
61
+ },
62
+ required: ['p0', 'p1', 'p2'],
63
+ },
64
+ },
65
+ );
66
+
67
+ if (review.p0.length === 0 && review.p1.length === 0) {
68
+ return { status: 'clean', rounds: round, p2: review.p2, build };
69
+ }
70
+ if (round === MAX_ROUNDS) {
71
+ return { status: 'round-cap', blocking: [...review.p0, ...review.p1], build };
72
+ }
73
+
74
+ phase(`fix round ${round}`);
75
+ build = await agent(
76
+ `Fix these review findings, then re-run \`${VERIFY}\` and make it pass. Report files changed.\n\n` +
77
+ `P0/P1 FINDINGS:\n${[...review.p0, ...review.p1].map((f) => `- ${f}`).join('\n')}`,
78
+ { label: 'fixer', tools: BUILDER_TOOLS },
79
+ );
80
+ }
@@ -0,0 +1,123 @@
1
+ // autoplan.js — pick the best practical solution and write its implementation plan.
2
+ //
3
+ // Ported from osolmaz/pi-workflows' autoplan. Theirs is a durable graph with
4
+ // human decision gates; this is the same reasoning shape as a flat script:
5
+ // candidates fan out in parallel, a judge picks one WITHOUT asking the user
6
+ // to choose, and a checkpoint gates the expensive plan write.
7
+ //
8
+ // THE REASONING SHAPE (the part worth stealing):
9
+ // 1. 2-4 distinct practical candidates IN PARALLEL, each answering: gist,
10
+ // full solution, rationale, parts, trade-offs, and "is this the
11
+ // long-term-elegant production-ready answer?"
12
+ // 2. The Holy Grail described SEPARATELY — the unconstrained ideal — with
13
+ // every dependency outside our authority named.
14
+ // 3. An advisor ranks the options with a recommendation, curbing Grail
15
+ // ideas that need upstream changes we don't control.
16
+ // 4. THE DECISION GATE IS THE HUMAN: ask() lists the options with the
17
+ // recommendation on top. The whole point of the workflow is that you
18
+ // only decide AFTER it finishes mining — including rejecting everything.
19
+ // 5. Plan writer elaborates the CHOSEN option: per step, what changes,
20
+ // where, and how to verify.
21
+ //
22
+ // HOW TO ADAPT: pass { problem, scope, constraints } via args —
23
+ // /wf run autoplan {"problem":"choose a timeout fallback","scope":"packages/workflows only","constraints":["keep cancellation terminal"]}
24
+ // Or just say "autoplan this" — the bundled skill derives the args from the
25
+ // conversation.
26
+
27
+ export const meta = {
28
+ name: 'autoplan',
29
+ description: 'Parallel candidates, judge picks without punting to the user, checkpoint-gated plan write',
30
+ };
31
+
32
+ const PROBLEM = (args && args.problem) || 'State the decision or planning problem and its observable end state.';
33
+ const SCOPE = (args && args.scope) || 'This repository. Name important exclusions.';
34
+ const CONSTRAINTS = (args && args.constraints) || [];
35
+
36
+ const CONTEXT = `PROBLEM: ${PROBLEM}\nSCOPE: ${SCOPE}\nCONSTRAINTS: ${JSON.stringify(CONSTRAINTS)}`;
37
+
38
+ phase('candidates');
39
+ const CANDIDATE_IDS = ['A', 'B', 'C'];
40
+ const candidates = await parallel(
41
+ CANDIDATE_IDS.map((id) => async () => {
42
+ return agent(
43
+ `${CONTEXT}\n\nYou are candidate advocate ${id}. Propose a DISTINCT practical solution ` +
44
+ `(different from what other advocates would pick). Give: short title, plain gist, full ` +
45
+ `solution, rationale, parts, trade-offs, and a yes/no on "is this the long-term-elegant ` +
46
+ `production-ready answer". Ground every claim in the actual codebase.`,
47
+ { label: `candidate-${id}` },
48
+ );
49
+ }),
50
+ );
51
+
52
+ phase('holy grail');
53
+ const grail = await agent(
54
+ `${CONTEXT}\n\nDescribe the Holy Grail: the ideal solution with no practical constraints. ` +
55
+ `Name every dependency outside our authority (upstream changes, other teams, new services).`,
56
+ { label: 'grail' },
57
+ );
58
+
59
+ phase('advisor');
60
+ // The advisor RECOMMENDS but does not decide — the human is the gate. It
61
+ // curbs Holy Grail ideas that need changes outside our authority.
62
+ const advice = await agent(
63
+ `${CONTEXT}\n\nCANDIDATES:\n${candidates.map((c, i) => `--- Candidate ${CANDIDATE_IDS[i]} ---\n${c}`).join('\n\n')}\n\n` +
64
+ `HOLY GRAIL:\n${grail}\n\nRank the options for practicality and simplicity, preferring interfaces we ` +
65
+ `control; reject any Grail that needs an upstream change as UNIMPLEMENTABLE NOW (note it, don't pick it). ` +
66
+ `Return a short title + one-line gist per option (candidates AND the grail if it qualifies), the ` +
67
+ `recommended id, why, and one rejection reason per loser.`,
68
+ {
69
+ label: 'advisor',
70
+ schema: {
71
+ type: 'object',
72
+ properties: {
73
+ options: {
74
+ type: 'array',
75
+ items: {
76
+ type: 'object',
77
+ properties: { id: { type: 'string' }, title: { type: 'string' }, gist: { type: 'string' } },
78
+ required: ['id', 'title', 'gist'],
79
+ },
80
+ },
81
+ recommended: { type: 'string' },
82
+ why: { type: 'string' },
83
+ rejections: {
84
+ type: 'array',
85
+ items: { type: 'object', properties: { id: { type: 'string' }, reason: { type: 'string' } } },
86
+ },
87
+ },
88
+ required: ['options', 'recommended', 'why', 'rejections'],
89
+ },
90
+ },
91
+ );
92
+
93
+ phase('decision gate');
94
+ // The human decides — after the mining, not during. Recommendation first.
95
+ const ordered = [...advice.options].sort((a, b) =>
96
+ a.id === advice.recommended ? -1 : b.id === advice.recommended ? 1 : 0,
97
+ );
98
+ const REJECT = 'none — reject all / re-mine';
99
+ const choice = await ask(`Recommended: ${advice.recommended} — ${advice.why}\n\nPick a direction:`, [
100
+ ...ordered.map((o) => `${o.id}: ${o.title} — ${o.gist}`),
101
+ REJECT,
102
+ ]);
103
+ if (!choice || choice === REJECT) {
104
+ return {
105
+ decided: false,
106
+ options: advice.options,
107
+ recommended: advice.recommended,
108
+ why: advice.why,
109
+ rejections: advice.rejections,
110
+ };
111
+ }
112
+ const picked = ordered.find((o) => choice.startsWith(`${o.id}:`)) ?? ordered[0];
113
+ log(`human picked: ${picked.id} ${picked.title}`);
114
+
115
+ phase('plan');
116
+ const plan = await agent(
117
+ `${CONTEXT}\n\nWrite the implementation plan for the SELECTED option (${picked.id}): ${picked.title} — ${picked.gist}\n\n` +
118
+ `For each step state WHAT changes, WHERE (exact files), and HOW TO VERIFY it. ` +
119
+ `End with a "rejected alternatives" section: ${JSON.stringify(advice.rejections)}`,
120
+ { label: 'planner' },
121
+ );
122
+
123
+ return { decided: true, choice: picked, why: advice.why, rejections: advice.rejections, plan };
@@ -0,0 +1,107 @@
1
+ // bake-off.js — Bake-Off mode: race N models on one task, an advisory judge picks.
2
+ //
3
+ // WHEN BAKE-OFF BEATS A SINGLE BUILDER: empirically, a single GLM-5.2-class
4
+ // builder produces decent-but-flawed code — right shape, subtle bugs. Racing
5
+ // two models on the SAME task in ISOLATED worktrees and letting an advisory
6
+ // judge pick the winner is a quality lever: the judge sees two independent
7
+ // attempts AND their patches, and the better one wins. It costs ~2x the
8
+ // tokens of a single build for a measurably better hit rate on hard tasks.
9
+ // Don't run it for trivial edits — the judge overhead isn't worth it there.
10
+ //
11
+ // HOW IT WORKS:
12
+ // 1. Each contender runs as a builder agent with `worktree: true` — it
13
+ // writes into its own detached worktree, so contenders never stomp each
14
+ // other. On settle the subagent runtime captures the full change set
15
+ // (including untracked files) to a `.patch`.
16
+ // 2. agent() returns `{ value, patchPath, runId }` for a worktree run that
17
+ // changed files — the wrapper is opt-in: non-worktree agent() calls keep
18
+ // returning the bare value, so existing scripts are unaffected.
19
+ // 3. A single judge agent receives all contender summaries + their patch
20
+ // paths and returns `{ winner, why, confidence }`.
21
+ // 4. The workflow returns the winner's patchPath — apply it via the
22
+ // subagents fleet apply flow (the `/patches` staging area: pre-flights
23
+ // each patch without applying, then `Enter` to `git apply --3way`).
24
+ //
25
+ // Adapt: set CONTENDERS to the models you want to race, pass `task` in args
26
+ // (or edit DEFAULT_TASK). The judge can be a stronger/different model than the
27
+ // builders — set JUDGE_MODEL.
28
+
29
+ export const meta = {
30
+ name: 'bake_off',
31
+ description: 'Race N models on one task in isolated worktrees; an advisory judge picks the winner',
32
+ };
33
+
34
+ // Models to race, in 'provider/id' form. Add or remove contenders freely.
35
+ const CONTENDERS = ['z-ai/glm-5.2', 'anthropic/claude-sonnet-4-5'];
36
+ const JUDGE_MODEL = 'anthropic/claude-opus-4-1';
37
+
38
+ const DEFAULT_TASK = 'Implement X in packages/foo/bar.ts such that Y holds. Keep the change minimal and correct.';
39
+ const TASK = (args && args.task) || DEFAULT_TASK;
40
+
41
+ const BUILDER_TOOLS = ['read', 'edit', 'write', 'bash', 'grep', 'find'];
42
+ const BUILDER_PROMPT =
43
+ 'You are a builder. Implement the following task in this repo. Make the change minimal ' +
44
+ 'and correct; run the repo typecheck before finishing. Report a one-paragraph summary of ' +
45
+ 'what you changed and why.\n\nTASK:\n' +
46
+ TASK;
47
+
48
+ // A throw inside parallel() collapses the wave — wrap each contender so a
49
+ // failure reports which model died instead of nuking the whole race.
50
+ const safeRun = (model, i) => async () => {
51
+ try {
52
+ const r = await agent(BUILDER_PROMPT, {
53
+ label: 'contender-' + (i + 1),
54
+ model,
55
+ tools: BUILDER_TOOLS,
56
+ worktree: true,
57
+ });
58
+ if (r && typeof r === 'object' && 'patchPath' in r) {
59
+ return { model, ok: true, summary: r.value, patchPath: r.patchPath, runId: r.runId };
60
+ }
61
+ return { model, ok: true, summary: r, patchPath: null, runId: null };
62
+ } catch (e) {
63
+ return { model, ok: false, summary: 'contender crashed: ' + (e && e.message), patchPath: null, runId: null };
64
+ }
65
+ };
66
+
67
+ phase('race');
68
+ const entries = await parallel(CONTENDERS.map(safeRun));
69
+
70
+ phase('judge');
71
+ const verdict = await agent(
72
+ 'You are an advisory judge for a model bake-off. Two builders each attempted the SAME ' +
73
+ 'task below, independently, in isolated worktrees. You are given each contender model, ' +
74
+ 'its self-reported summary, and the path to its patch. Read each patch (use bash: ' +
75
+ '`cat <patchPath>`) and compare them on correctness, minimalism, and style. Pick the ' +
76
+ 'winner. Do not just trust the summaries — read the actual diffs (the read tool takes a path; no bash needed).\n\n' +
77
+ 'TASK:\n' +
78
+ TASK +
79
+ '\n\nCONTENDERS JSON:\n' +
80
+ JSON.stringify(entries, null, 2) +
81
+ '\n\nReturn JSON { winner: <model id>, why: <one paragraph>, confidence: "low"|"medium"|"high" }.',
82
+ {
83
+ label: 'judge',
84
+ model: JUDGE_MODEL,
85
+ tools: ['read'], // patches are files — read-only judging, no bash
86
+ schema: {
87
+ type: 'object',
88
+ properties: {
89
+ winner: { type: 'string' },
90
+ why: { type: 'string' },
91
+ confidence: { type: 'string', enum: ['low', 'medium', 'high'] },
92
+ },
93
+ required: ['winner', 'why', 'confidence'],
94
+ },
95
+ },
96
+ );
97
+
98
+ const winner = entries.find((e) => e.model === verdict.winner) ?? entries[0];
99
+
100
+ return {
101
+ winner: verdict.winner,
102
+ why: verdict.why,
103
+ confidence: verdict.confidence,
104
+ // Apply this via the subagents /patches staging area (git apply --3way).
105
+ patchPath: (winner && winner.patchPath) || null,
106
+ contenders: entries.map((e) => ({ model: e.model, ok: e.ok, patchPath: e.patchPath })),
107
+ };
@@ -0,0 +1,106 @@
1
+ // gates.js — judge/verify PROMPT builders.
2
+ //
3
+ // WHAT IT'S FOR: the prompt engineering that turns a flaky judge into a
4
+ // reliable gate. Each builder is a function returning a prompt STRING; copy
5
+ // the one that matches your gate into your own workflow and hand it to an
6
+ // agent() with a JSON schema. These are PATTERNS, not an API — read them,
7
+ // adapt the framing to your task, do not import this file.
8
+ //
9
+ // The valuable knowledge here is WHAT failure mode each framing prevents:
10
+ // adversarialReview → confirmation bias / sycophancy
11
+ // deepResearchCoverage → silent source omission / single-source skew
12
+ // codeReviewVerdict → verdict collapse + severity ordering
13
+ //
14
+ // Prompt patterns distilled from @quintinshaw/pi-dynamic-workflows (MIT).
15
+ // Substantial portions of the prompt text below are adapted from that
16
+ // package, whose LICENSE requires this notice:
17
+ //
18
+ // Copyright (c) 2026 QuintinShaw
19
+ // Copyright (c) Michael Livs (original pi-dynamic-workflows)
20
+ //
21
+ // Permission is hereby granted, free of charge, to any person obtaining a
22
+ // copy of this software and associated documentation files (the
23
+ // "Software"), to deal in the Software without restriction, including
24
+ // without limitation the rights to use, copy, modify, merge, publish,
25
+ // distribute, sublicense, and/or sell copies of the Software, and to
26
+ // permit persons to whom the Software is furnished to do so, subject to
27
+ // the following conditions: the above copyright notice and this
28
+ // permission notice shall be included in all copies or substantial
29
+ // portions of the Software.
30
+
31
+ export const meta = {
32
+ name: 'gates',
33
+ description: 'Judge/verify prompt builders: adversarial refutation, research coverage, code-review verdict',
34
+ };
35
+
36
+ // ── Adversarial refutation ─────────────────────────────────────────────────
37
+ // Prevents: confirmation bias. A reviewer asked "is this finding real?" tends
38
+ // to agree (sycophancy), and a wrong finding survives. Reframing the job as
39
+ // "try to REFUTE this; default to real=false when uncertain" flips the prior:
40
+ // a finding survives only when enough independent skeptics FAIL to refute it.
41
+ // `reviewers` skeptics vote in parallel; the finding survives when the
42
+ // real-vote share meets `threshold`. The "state the strongest reason it could
43
+ // be WRONG first" line forces the skeptic to actually attack, not rubber-stamp.
44
+ const adversarialReview = (task, finding, reviewers, threshold) =>
45
+ 'You are a skeptical reviewer. Try to REFUTE this finding for the task below. ' +
46
+ 'Default to real=false when uncertain; investigate with the available tools if needed. ' +
47
+ 'State the strongest reason the finding could be WRONG before you decide.\n\n' +
48
+ 'TASK: ' +
49
+ task +
50
+ '\nFINDING: ' +
51
+ finding +
52
+ '\n\nReturn JSON { real: boolean, reason: string }. This finding is counted real only ' +
53
+ 'if it survives ' +
54
+ reviewers +
55
+ ' independent refuters at threshold ' +
56
+ threshold +
57
+ '.';
58
+
59
+ // ── Deep-research coverage check ──────────────────────────────────────────
60
+ // Prevents: silent source omission. A research fan-out gathers N sources and
61
+ // lists claims; without a coverage check, a claim from a single weak source
62
+ // (or one a model half-remembered and attributed to a plausible URL) survives
63
+ // alongside well-sourced ones. This gate groups claims asserting the SAME
64
+ // fact across DISTINCT source URLs and keeps a claim only when it has
65
+ // `minSupport` distinct sources OR one clearly authoritative source. Conflicts
66
+ // and single-source claims are discarded — the report says so explicitly.
67
+ const deepResearchCoverage = (sources, minSupport) =>
68
+ 'Cross-check these research sources. Group claims that assert the same fact across ' +
69
+ 'different source URLs. Keep a claim only if it is supported by at least ' +
70
+ minSupport +
71
+ ' distinct source URLs OR by one clearly authoritative source. Discard claims found in ' +
72
+ 'a single weak source or that conflict with others. Do not invent sources.\n\n' +
73
+ 'SOURCES JSON:\n' +
74
+ JSON.stringify(sources) +
75
+ '\n\nReturn JSON { supported: [{ claim, sources: [url] }], discarded: [claim] }.';
76
+
77
+ // ── Code-review verdict + severity ─────────────────────────────────────────
78
+ // Prevents: verdict collapse. A boolean "is this issue real?" collapses
79
+ // CONFIRMED (will break) and PLAUSIBLE (worth a look) into one bucket, losing
80
+ // the hedge the report needs. A 3-way verdict — CONFIRMED / PLAUSIBLE /
81
+ // REFUTED — with a per-finding failure scenario keeps the signal; only REFUTED
82
+ // is filtered out. Severity framing: name the concrete failure scenario, not a
83
+ // vague "this might be bad" — a finding with no traceable failure is PLAUSIBLE
84
+ // at best, never CONFIRMED.
85
+ const codeReviewVerdict = (finding, diffBlock) =>
86
+ 'You are a verifier. Determine whether this code review finding is CONFIRMED, PLAUSIBLE, ' +
87
+ 'or REFUTED.\n' +
88
+ 'CONFIRMED = you can trace the exact failure in the diff.\n' +
89
+ 'PLAUSIBLE = the concern is valid but not certain.\n' +
90
+ 'REFUTED = the finding is wrong or already handled.\n\n' +
91
+ 'FINDING:\n' +
92
+ finding +
93
+ diffBlock +
94
+ '\n\nReturn JSON { verdict: "CONFIRMED"|"PLAUSIBLE"|"REFUTED", reason: string }. ' +
95
+ 'If you cannot trace a concrete failure scenario, return at most PLAUSIBLE.';
96
+
97
+ // Demonstrate the shapes — copy whichever builder fits your gate.
98
+ phase('gates');
99
+ log('adversarialReview →', adversarialReview('ship the release', 'auth refresh leaks', 3, 0.66).slice(0, 60) + '…');
100
+ log('deepResearchCoverage →', deepResearchCoverage([{ url: 'https://example', claims: ['x'] }], 2).slice(0, 60) + '…');
101
+ log('codeReviewVerdict →', codeReviewVerdict('file.ts:42 null deref', '\n<diff>…</diff>').slice(0, 60) + '…');
102
+
103
+ return {
104
+ builders: ['adversarialReview', 'deepResearchCoverage', 'codeReviewVerdict'],
105
+ note: 'Copy the builder that matches your gate; call it from agent() with a JSON schema.',
106
+ };
@@ -0,0 +1,70 @@
1
+ // lanes.js — N parallel agents editing FILE-DISJOINT lanes of one repo.
2
+ //
3
+ // WHAT IT'S FOR: when a task splits into independent, file-disjoint edits
4
+ // across a single repo, lanes runs them concurrently under hard rules that
5
+ // make the parallelism safe: each lane owns a fixed file set, no lane touches
6
+ // git or installs, and the parent integrates centrally. This is the pattern
7
+ // behind "hardening lanes" — a blocker list split by subsystem, each lane
8
+ // owned by one agent.
9
+ //
10
+ // HOW TO ADAPT:
11
+ // 1. Set VERIFY to the repo's typecheck/lint command (repo-wide is fine —
12
+ // errors in files a lane does NOT own are other lanes mid-edit; each
13
+ // lane ignores them; only its own files must be clean).
14
+ // 2. Fill LANES with one entry per parallel edit: a `name`, the exact
15
+ // `files` that lane may touch (no others), and a one-paragraph `brief`.
16
+ // 3. Run it: /wf run lanes
17
+ // 4. You (the parent) integrate the lanes' edits centrally — review and
18
+ // stage them here.
19
+ //
20
+ // The hard-rules preamble is the pattern's soul — keep it. The fan-out is a
21
+ // single parallel() over the lanes. Cap lanes at the subagent runtime's child
22
+ // budget (4 by default); split into a second wave if you need more.
23
+
24
+ export const meta = {
25
+ name: 'lanes',
26
+ description: 'Parallel file-disjoint editing lanes under a hard-rules preamble',
27
+ };
28
+
29
+ // Repo-wide verification command. Replace with your project's typecheck
30
+ // (`npx tsgo --noEmit`, `tsc --noEmit`, `cargo check`, …).
31
+ const VERIFY = 'npx tsgo --noEmit';
32
+
33
+ const COMMON = `You are one of N parallel agents editing the same repo (cwd is the repo root).
34
+ HARD RULES — read before anything else:
35
+ 1. Edit ONLY the files listed for your lane. Other agents own the rest concurrently; touching their files corrupts their work.
36
+ 2. NO git commands, NO installs, NO format/test runners. The parent integrates centrally.
37
+ 3. Verify ONLY with \`${VERIFY}\` run from the repo root. It is repo-wide, so errors in files you do NOT own are other lanes mid-edit — ignore those. YOUR files must be clean.
38
+ 4. Match the existing code style.
39
+ 5. No behavior changes beyond your lane's brief.
40
+ Report: files changed + one line per change.`;
41
+
42
+ // One entry per parallel edit. `files` is the lane's exclusive write set.
43
+ const LANES = [
44
+ {
45
+ name: 'engine',
46
+ files: ['packages/shared/subagents.ts', 'packages/shared/workflow.ts'],
47
+ brief: 'Make the budget counter event handling typecheck-visible…',
48
+ },
49
+ {
50
+ name: 'dispatch',
51
+ files: ['packages/subagents/index.ts'],
52
+ brief: 'Add allowTreeMutation + serialize tree-mutating tasks…',
53
+ },
54
+ // Add lanes here (≤ ~4 per wave).
55
+ ];
56
+
57
+ const lanes = LANES.map((l) => ({
58
+ label: l.name,
59
+ prompt: COMMON + '\n\nLANE ' + l.name + ' — ' + l.files.join(', ') + ' (no other files):\n\n' + l.brief,
60
+ }));
61
+
62
+ phase('lanes');
63
+ const results = await parallel(
64
+ lanes.map((l) => () => agent(l.prompt, { label: l.label, tools: ['read', 'edit', 'write', 'bash', 'grep'] })),
65
+ );
66
+
67
+ return LANES.reduce((acc, l, i) => {
68
+ acc[l.name] = results[i] ?? 'LANE RETURNED NULL (missing coverage)';
69
+ return acc;
70
+ }, {});