@mgiles/perk 2.2.0 → 2.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.
Files changed (34) hide show
  1. package/extension/doors/address.ts +3 -3
  2. package/extension/doors/learn.ts +219 -23
  3. package/extension/doors/prReview.ts +189 -18
  4. package/extension/doors/prReviewDynamic.ts +249 -0
  5. package/extension/doors/submit.ts +4 -3
  6. package/extension/factories/objectivePlan.ts +3 -2
  7. package/extension/index.ts +7 -0
  8. package/extension/substrate/config.ts +8 -4
  9. package/extension/substrate/terminalLaunch.ts +1 -1
  10. package/extension/substrate/toolGating.ts +36 -3
  11. package/extension/waves/learnWave.ts +155 -0
  12. package/extension/waves/memoryAdapter.ts +126 -0
  13. package/extension/waves/prReviewDynamicWave.ts +466 -0
  14. package/extension/waves/prReviewWave.ts +229 -0
  15. package/extension/waves/reportWave.ts +449 -0
  16. package/extension/waves/rpcAdapter.ts +201 -0
  17. package/package.json +6 -1
  18. package/prompts/_fixtures/live.yaml +9 -11
  19. package/prompts/common/output-schemas/objective-explorer.md +36 -0
  20. package/prompts/common/output-schemas/review-classifier.md +47 -0
  21. package/prompts/stages/address/action.md +15 -4
  22. package/prompts/stages/address/preview.md +14 -3
  23. package/prompts/stages/conflict-resolution.md +1 -1
  24. package/prompts/stages/learn-orchestrate.md +7 -5
  25. package/prompts/stages/objective-plan/guidance.md +12 -1
  26. package/prompts/stages/objective-plan/seed.md +12 -1
  27. package/prompts/stages/pr-review-browser/active.md +11 -3
  28. package/prompts/stages/pr-review-browser/foreign.md +11 -3
  29. package/prompts/stages/pr-review-dynamic.md +7 -0
  30. package/prompts/stages/pr-review-terminal/active.md +11 -3
  31. package/prompts/stages/pr-review-terminal/foreign.md +11 -3
  32. package/prompts/stages/pr-review.md +7 -6
  33. package/shared/bindings.yaml +3 -0
  34. package/shared/contracts.md +102 -32
@@ -0,0 +1,126 @@
1
+ // The in-memory `WaveAdapter` test double — a FIRST-CLASS deliverable: the runner's own tests
2
+ // and the future flow tests drive the whole wave lifecycle through it with no event bus, no
3
+ // child processes, and no temp dirs. Every failure arm of `runReportWave` is reachable through
4
+ // a config knob, and the recorded calls let tests assert the spawn contract (`mission: false`,
5
+ // `context: "fresh"`, the rendered script) and the stop-on-timeout/cancel behavior.
6
+ //
7
+ // It honors the same sequencing contract as the production adapter: `onComplete()` before a
8
+ // successful `ping()` throws (the async-complete channel is advertised by ping, not pinned).
9
+
10
+ import type {
11
+ WaveAdapter,
12
+ WaveCompletion,
13
+ WavePing,
14
+ WaveRunHandle,
15
+ WaveSpawnParams,
16
+ } from "./reportWave.ts";
17
+
18
+ export interface MemoryWaveAdapterConfig {
19
+ /** The ping outcome; null exercises the unavailable arm. Defaults to a valid ping. */
20
+ ping?: WavePing | null;
21
+ /** When set, spawn throws this message (the spawn-failed arm). */
22
+ spawnError?: string;
23
+ /**
24
+ * Delivery ordering of the auto-completion relative to the spawn reply. The default delivers
25
+ * after the reply settles; `complete-then-reply` delivers synchronously inside spawn — the
26
+ * real completion-before-reply race the runner must buffer through.
27
+ */
28
+ ordering?: "reply-then-complete" | "complete-then-reply";
29
+ /** `false` ⇒ the run never completes (tests pair this with a tiny `spec.timeoutMs`). */
30
+ completion?: false;
31
+ /** What `readAggregate` returns. Defaults to a complete run with an empty aggregate. */
32
+ aggregate?: { state: string; error?: string; value: unknown };
33
+ /**
34
+ * Per-spawn aggregate FIFO for multi-wave tests (e.g. the pr-review retry): each spawn assigns
35
+ * the next queued aggregate to its handle (keyed by `asyncDir`), and `readAggregate(handle)`
36
+ * returns the handle's assigned aggregate. When the queue is exhausted (or absent), reads fall
37
+ * back to the single `aggregate`/`setAggregate` staging — the knob is purely additive.
38
+ */
39
+ aggregates?: { state: string; error?: string; value: unknown }[];
40
+ /** When true, `readAggregate` throws (the aggregate-unreadable arm). */
41
+ aggregateError?: boolean;
42
+ }
43
+
44
+ export interface MemoryWaveAdapter extends WaveAdapter {
45
+ calls: { spawn: WaveSpawnParams[]; stop: WaveRunHandle[] };
46
+ /** Deliver a completion to the subscribed handlers (contract-suite plumbing). */
47
+ emitCompletion(completion: WaveCompletion): void;
48
+ /** Replace the staged aggregate (contract-suite plumbing). */
49
+ setAggregate(aggregate: { state: string; error?: string; value: unknown }): void;
50
+ }
51
+
52
+ export function createMemoryWaveAdapter(config: MemoryWaveAdapterConfig = {}): MemoryWaveAdapter {
53
+ const ping =
54
+ config.ping === undefined ? { asyncCompleteEvent: "subagent:async-complete" } : config.ping;
55
+ let aggregate = config.aggregate ?? { state: "complete", value: [] as unknown[] };
56
+ const aggregateQueue = [...(config.aggregates ?? [])];
57
+ const assignedAggregates = new Map<string, { state: string; error?: string; value: unknown }>();
58
+ let pinged = false;
59
+ let spawnCount = 0;
60
+ const handlers = new Set<(completion: WaveCompletion) => void>();
61
+ const calls: MemoryWaveAdapter["calls"] = { spawn: [], stop: [] };
62
+
63
+ const deliver = (completion: WaveCompletion): void => {
64
+ for (const handler of handlers) handler(completion);
65
+ };
66
+
67
+ return {
68
+ calls,
69
+ emitCompletion: deliver,
70
+ setAggregate(next): void {
71
+ aggregate = next;
72
+ },
73
+
74
+ async ping(): Promise<WavePing | null> {
75
+ if (ping !== null) pinged = true;
76
+ return ping;
77
+ },
78
+
79
+ async spawn(params: WaveSpawnParams): Promise<WaveRunHandle> {
80
+ calls.spawn.push(params);
81
+ if (config.spawnError !== undefined) throw new Error(config.spawnError);
82
+ spawnCount += 1;
83
+ const handle = {
84
+ asyncId: `wave-async-${spawnCount}`,
85
+ asyncDir: `/memory/wave-async-${spawnCount}`,
86
+ };
87
+ const queued = aggregateQueue.shift();
88
+ if (queued !== undefined) assignedAggregates.set(handle.asyncDir, queued);
89
+ if (config.completion !== false) {
90
+ const completion = { asyncId: handle.asyncId, asyncDir: handle.asyncDir };
91
+ if (config.ordering === "complete-then-reply") {
92
+ // Deliver BEFORE the spawn promise resolves — the buffered-completion race.
93
+ deliver(completion);
94
+ } else {
95
+ // Deliver strictly after the caller's `await spawn(...)` continuation has run
96
+ // (a macrotask — a microtask would still beat the awaiting continuation).
97
+ setTimeout(() => deliver(completion), 0);
98
+ }
99
+ }
100
+ return handle;
101
+ },
102
+
103
+ onComplete(handler: (completion: WaveCompletion) => void): () => void {
104
+ if (!pinged) {
105
+ throw new Error(
106
+ "onComplete requires a successful ping first (the async-complete channel is advertised, not pinned)",
107
+ );
108
+ }
109
+ handlers.add(handler);
110
+ return () => handlers.delete(handler);
111
+ },
112
+
113
+ async stop(handle: WaveRunHandle): Promise<void> {
114
+ calls.stop.push(handle);
115
+ },
116
+
117
+ async readAggregate(
118
+ handle: WaveRunHandle,
119
+ ): Promise<{ state: string; error?: string; value: unknown }> {
120
+ if (config.aggregateError === true) {
121
+ throw new Error("simulated unreadable status.json");
122
+ }
123
+ return assignedAggregates.get(handle.asyncDir) ?? aggregate;
124
+ },
125
+ };
126
+ }
@@ -0,0 +1,466 @@
1
+ // The EXPERIMENTAL dynamic-review per-flow entrypoint over the shared report-wave runner: ONE
2
+ // Perk-rendered workflowScript starts the mandatory plan-fidelity `perk.pr-reviewer` lane
3
+ // concurrently with a `perk.review-angle-selector` lane, deterministically normalizes the
4
+ // selector's angle selection INSIDE the rendered script (Perk-rendered, tested code — never
5
+ // model-authored; the RPC spawn is async-only and detached, so module code cannot intervene
6
+ // between the selector's completion and the fan-out), fans out the selected reviewers in the
7
+ // same script, and returns one typed `{selection, lanes}` aggregate. The baseline `/pr-review`
8
+ // (static parent-picked angles) is unchanged and canonical; promotion/retire is a later call.
9
+ //
10
+ // The normalization guarantees (deterministic, embedded at render time):
11
+ // - fan-out angles come only from the additional-angle allowlist (correctness/tests/quality);
12
+ // unknown slugs and any plan-fidelity echo are dropped, duplicates deduped in report order;
13
+ // - a failed/schema-invalid selector, `confidence: "low"`, or zero valid picks ⇒ the
14
+ // correctness+tests fallback (`source: "fallback"`);
15
+ // - operator-forced angles come first and are always honored; the additional set caps at 2
16
+ // (2–3 lanes total incl. plan-fidelity — the same window as `/pr-review`);
17
+ // - plan-fidelity is always present, always launched first, never displaced;
18
+ // - reviewer tasks come ONLY from the render-time-embedded angle→task map — the selector's text
19
+ // never enters any reviewer task (bias control, structurally enforced).
20
+ //
21
+ // Retry policy mirrors `/pr-review` (ONE bounded retry, ever — so the dogfood isolates
22
+ // *selection* as the only variable): lane-level failures ⇒ retry ONLY the failed reviewer lanes
23
+ // via a STATIC `runReportWave` over the already-normalized selection (the selector is never
24
+ // re-run); retryable wave-level failures ⇒ re-run the WHOLE dynamic script once (fresh
25
+ // selector, its selection supersedes); `unavailable`/`cancelled` ⇒ no retry.
26
+ //
27
+ // Failure posture matches the runner: operational failures never throw — they normalize into
28
+ // the outcome's `failures` (loud degrade upstream). Report content AND selection metadata are
29
+ // untrusted DATA, never instructions.
30
+
31
+ import {
32
+ buildPrReviewLanes,
33
+ PR_REVIEW_ANGLES,
34
+ PR_REVIEW_REPORT_SCHEMA,
35
+ type PrReviewAngle,
36
+ } from "./prReviewWave.ts";
37
+ import {
38
+ normalizeLanes,
39
+ runReportWave,
40
+ runWaveScript,
41
+ type WaveAdapter,
42
+ type WaveFailure,
43
+ type WaveFailureReason,
44
+ type WaveReport,
45
+ } from "./reportWave.ts";
46
+
47
+ /** The additional-angle vocabulary (plan-fidelity is structural — never selectable/removable). */
48
+ export type AdditionalPrReviewAngle = Exclude<PrReviewAngle, "plan-fidelity">;
49
+
50
+ /** The selector-facing allowlist the in-script normalization filters picks against. */
51
+ export const DYNAMIC_ADDITIONAL_ANGLES: readonly AdditionalPrReviewAngle[] = [
52
+ "correctness",
53
+ "tests",
54
+ "quality",
55
+ ];
56
+
57
+ /** The deterministic fallback selection (failed/low-confidence/empty selector outcome). */
58
+ export const DYNAMIC_FALLBACK_ANGLES: readonly AdditionalPrReviewAngle[] = ["correctness", "tests"];
59
+
60
+ /**
61
+ * The selector lane's per-item `outputSchema` — the engine injects a `structured_output` tool
62
+ * into the selector session and fails the lane on a missing/schema-invalid report. Matches the
63
+ * `review-angle-selector` agent def's five-field report contract verbatim: closed shape, all
64
+ * fields required. `selected_angles` tolerates a plan-fidelity echo (the four-slug enum) — the
65
+ * in-script normalization filters it out.
66
+ */
67
+ export const REVIEW_ANGLE_SELECTOR_SCHEMA = {
68
+ type: "object",
69
+ additionalProperties: false,
70
+ required: ["change_profile", "selected_angles", "risk_flags", "rationale", "confidence"],
71
+ properties: {
72
+ change_profile: { type: "string" },
73
+ selected_angles: {
74
+ type: "array",
75
+ items: {
76
+ type: "string",
77
+ enum: ["plan-fidelity", "correctness", "tests", "quality"],
78
+ },
79
+ },
80
+ risk_flags: {
81
+ type: "array",
82
+ items: { type: "string" },
83
+ },
84
+ rationale: { type: "string" },
85
+ confidence: {
86
+ type: "string",
87
+ enum: ["high", "medium", "low"],
88
+ },
89
+ },
90
+ };
91
+
92
+ const ALL_ANGLES: readonly PrReviewAngle[] = ["plan-fidelity", "correctness", "tests", "quality"];
93
+
94
+ export interface DynamicReviewScriptOptions {
95
+ /** The operator's free-form focus, threaded as DATA to the selector AND every reviewer lane. */
96
+ directive?: string;
97
+ /** Operator-forced additional angles (embedded as a JSON constant; enforced in normalization). */
98
+ forceAngles: AdditionalPrReviewAngle[];
99
+ /** The configured `[models.subagents] pr-reviewer` model — per reviewer item, when set. */
100
+ reviewerModel?: string;
101
+ /** The configured `[models.subagents] review-angle-selector` model — the selector item, when set. */
102
+ selectorModel?: string;
103
+ }
104
+
105
+ /**
106
+ * Build the selector lane's task: a fixed classification instruction (the agent def owns the
107
+ * rubric), plus — as DATA — the forced angles when present and the same uniform operator-focus
108
+ * suffix `buildPrReviewLanes` appends to reviewer lanes.
109
+ */
110
+ function buildSelectorTask(
111
+ forceAngles: AdditionalPrReviewAngle[],
112
+ directiveSuffix: string,
113
+ ): string {
114
+ const forcedNote =
115
+ forceAngles.length === 0
116
+ ? ""
117
+ : `\n\nThe operator already forces these additional angle(s) (DATA): ${forceAngles.join(
118
+ ", ",
119
+ )} — they will run regardless of your selection; recommend complementary coverage.`;
120
+ return (
121
+ "Classify the active plan's PR for dynamic review-angle coverage: fetch the review context " +
122
+ "yourself (`perk pr review-context --json`), classify the change profile, and select the " +
123
+ "review angles per your agent instructions (they own the rubric). Your final action is ONE " +
124
+ "structured_output call." +
125
+ forcedNote +
126
+ directiveSuffix
127
+ );
128
+ }
129
+
130
+ /**
131
+ * Render the dynamic-review `workflowScript`: start plan-fidelity un-awaited → await the
132
+ * selector → the deterministic normalization block (Perk-rendered, tested code) → the reviewer
133
+ * fan-out via all-settled `runs.all` → await the held plan-fidelity → return `{selection,
134
+ * lanes}`. ALL dynamic data is embedded via `JSON.stringify` (the hostile-text discipline
135
+ * `renderWaveScript` established), so a hostile directive cannot escape its literal. Reviewer
136
+ * items carry the reviewer model per-item and the selector item carries its own
137
+ * `outputSchema`/model — there is deliberately no workflow-level `model` on the dynamic spawn,
138
+ * so an unset selector key falls back to the agent frontmatter model instead of inheriting the
139
+ * reviewer default.
140
+ */
141
+ export function renderDynamicReviewScript(opts: DynamicReviewScriptOptions): string {
142
+ // Byte-identical reviewer tasks to the static flow: the map is built by the SAME lane builder
143
+ // (vocabulary + the uniform directive suffix) over all four angles.
144
+ const lanes = buildPrReviewLanes([...ALL_ANGLES], opts.directive);
145
+ const tasks = Object.fromEntries(lanes.map((lane) => [lane.key, lane.task]));
146
+ const planFidelityTask = tasks["plan-fidelity"] ?? "";
147
+ const directiveSuffix = planFidelityTask.slice(PR_REVIEW_ANGLES["plan-fidelity"].length);
148
+ const selectorItem = {
149
+ agent: "perk.review-angle-selector",
150
+ task: buildSelectorTask(opts.forceAngles, directiveSuffix),
151
+ outputSchema: REVIEW_ANGLE_SELECTOR_SCHEMA,
152
+ ...(opts.selectorModel !== undefined ? { model: opts.selectorModel } : {}),
153
+ label: "angle-selector",
154
+ phase: "select",
155
+ };
156
+ return [
157
+ `const TASKS = ${JSON.stringify(tasks, null, 2)};`,
158
+ `const FORCED = ${JSON.stringify(opts.forceAngles)};`,
159
+ `const REVIEWER_MODEL = ${JSON.stringify(opts.reviewerModel ?? null)};`,
160
+ `const ALLOWLIST_ADDITIONAL = ${JSON.stringify(DYNAMIC_ADDITIONAL_ANGLES)};`,
161
+ `const FALLBACK_ANGLES = ${JSON.stringify(DYNAMIC_FALLBACK_ANGLES)};`,
162
+ "const reviewerParams = (angle) => ({",
163
+ ' agent: "perk.pr-reviewer",',
164
+ " task: TASKS[angle],",
165
+ " label: angle,",
166
+ ' phase: "review",',
167
+ " ...(REVIEWER_MODEL === null ? {} : { model: REVIEWER_MODEL }),",
168
+ "});",
169
+ "const laneOf = (key, run) => run.then(",
170
+ " (r) => ({ key, ok: r.ok === true, error: r.error ?? null, report: r.structuredOutput ?? null }),",
171
+ " (error) => ({ key, ok: false, error: error instanceof Error ? error.message : String(error), report: null }),",
172
+ ");",
173
+ "// plan-fidelity launches FIRST and runs concurrently with the selector (held promise).",
174
+ 'const planFidelity = laneOf("plan-fidelity", runs.run("plan-fidelity", reviewerParams("plan-fidelity")));',
175
+ "let sel = null;",
176
+ "let selectorError = null;",
177
+ "try {",
178
+ ` sel = await runs.run("angle-selector", ${JSON.stringify(selectorItem, null, 2)});`,
179
+ "} catch (error) {",
180
+ " selectorError = error instanceof Error ? error.message : String(error);",
181
+ "}",
182
+ "const report =",
183
+ ' sel !== null && sel.ok === true && typeof sel.structuredOutput === "object" &&',
184
+ " sel.structuredOutput !== null && !Array.isArray(sel.structuredOutput)",
185
+ " ? sel.structuredOutput",
186
+ " : null;",
187
+ "if (report === null && selectorError === null) {",
188
+ ' selectorError = sel !== null && typeof sel.error === "string" && sel.error !== ""',
189
+ " ? sel.error",
190
+ ' : "selector lane resolved without a schema-valid report";',
191
+ "}",
192
+ "// The deterministic normalization: filter to the allowlist (drops unknown slugs AND any",
193
+ "// plan-fidelity echo), dedupe preserving report order; a failed selector, low confidence,",
194
+ "// or zero valid picks falls back to correctness+tests.",
195
+ "const picks = [];",
196
+ 'if (report !== null && report.confidence !== "low" && Array.isArray(report.selected_angles)) {',
197
+ " for (const slug of report.selected_angles) {",
198
+ " if (ALLOWLIST_ADDITIONAL.includes(slug) && !picks.includes(slug)) picks.push(slug);",
199
+ " }",
200
+ "}",
201
+ 'const source = picks.length > 0 ? "selector" : "fallback";',
202
+ "// Forced first, then picks; dedupe; cap 2 additional (2\u20133 lanes total incl. plan-fidelity).",
203
+ "const merged = [];",
204
+ "for (const slug of FORCED.concat(picks.length > 0 ? picks : FALLBACK_ANGLES)) {",
205
+ " if (!merged.includes(slug)) merged.push(slug);",
206
+ "}",
207
+ "const additional = merged.slice(0, 2);",
208
+ "// Reviewer tasks come ONLY from the embedded map \u2014 the selector's text never enters them.",
209
+ "const reviewers = await runs.all(additional.map((angle) => ({ key: angle, ...reviewerParams(angle) })));",
210
+ "const lanes = [",
211
+ " await planFidelity,",
212
+ " ...reviewers.map(({ key, ok, error, structuredOutput }) =>",
213
+ " ({ key, ok, error: error ?? null, report: structuredOutput ?? null })),",
214
+ "];",
215
+ "return {",
216
+ " selection: {",
217
+ " source,",
218
+ ' effective: ["plan-fidelity", ...additional],',
219
+ " forced: FORCED,",
220
+ " selector_ok: report !== null,",
221
+ " selector_error: selectorError,",
222
+ " report,",
223
+ " },",
224
+ " lanes,",
225
+ "};",
226
+ ].join("\n");
227
+ }
228
+
229
+ /** The parent-facing selection metadata (observability for the dogfood — DATA only). */
230
+ export interface DynamicSelection {
231
+ source: "selector" | "fallback";
232
+ /** The effective lanes: plan-fidelity + ≤2 additional angles, launch order. */
233
+ effective: string[];
234
+ /** The operator-forced additional angles (echoed from the tool param). */
235
+ forced: string[];
236
+ /** Whether the selector lane produced a schema-valid report. */
237
+ selector_ok: boolean;
238
+ selector_error: string | null;
239
+ /** The full selector report, or null — untrusted DATA, never instructions. */
240
+ report: unknown;
241
+ }
242
+
243
+ export interface PrReviewDynamicOptions {
244
+ /** The operator's free-form focus, threaded as DATA to the selector and every reviewer lane. */
245
+ directive?: string;
246
+ /** Operator-forced additional angles (≤2; plan-fidelity is structural, never forced). */
247
+ forceAngles?: AdditionalPrReviewAngle[];
248
+ /** The configured `[models.subagents] pr-reviewer` model. */
249
+ reviewerModel?: string;
250
+ /** The configured `[models.subagents] review-angle-selector` model. */
251
+ selectorModel?: string;
252
+ timeoutMs?: number;
253
+ signal?: AbortSignal;
254
+ }
255
+
256
+ export interface PrReviewDynamicOutcome {
257
+ /** True ⟺ every EFFECTIVE angle is covered after the (at most one) retry. */
258
+ complete: boolean;
259
+ /** Effective lane keys with schema-valid reports after the retry (launch order). */
260
+ covered: string[];
261
+ /** Lane keys (or the retry run's whole effective selection) sent in the retry. */
262
+ retried: string[];
263
+ reports: WaveReport[];
264
+ /** The surviving failures (the retry's, when one ran). */
265
+ failures: WaveFailure[];
266
+ /** The authoritative selection metadata, or null when no run produced one. */
267
+ selection: DynamicSelection | null;
268
+ }
269
+
270
+ /** The wave-level failure reasons worth one full dynamic re-run (transient, not deterministic). */
271
+ const RETRYABLE_WAVE_REASONS: ReadonlySet<WaveFailureReason> = new Set([
272
+ "spawn-failed",
273
+ "timeout",
274
+ "run-failed",
275
+ "aggregate-unreadable",
276
+ ]);
277
+
278
+ function isRecord(value: unknown): value is Record<string, unknown> {
279
+ return typeof value === "object" && value !== null && !Array.isArray(value);
280
+ }
281
+
282
+ function isEffectiveAngle(value: string): value is PrReviewAngle {
283
+ return (
284
+ value === "plan-fidelity" || (DYNAMIC_ADDITIONAL_ANGLES as readonly string[]).includes(value)
285
+ );
286
+ }
287
+
288
+ /**
289
+ * Defensive module-side re-validation of the returned `{selection, lanes}` value (the module
290
+ * rendered the script, but the value crossed a process boundary — a violation is upstream
291
+ * drift). Returns an error detail string on non-conformance (⇒ `aggregate-unreadable`).
292
+ */
293
+ function parseDynamicValue(
294
+ value: unknown,
295
+ ): { selection: DynamicSelection; lanes: unknown[] } | string {
296
+ if (!isRecord(value)) {
297
+ return "dynamic wave aggregate carries no {selection, lanes} object (the script's explicit return is missing)";
298
+ }
299
+ const selection = value.selection;
300
+ const lanes = value.lanes;
301
+ if (!isRecord(selection) || !Array.isArray(lanes)) {
302
+ return "dynamic wave aggregate lacks a selection object or lanes array";
303
+ }
304
+ const source = selection.source;
305
+ if (source !== "selector" && source !== "fallback") {
306
+ return `dynamic selection carries an unknown source (${String(source)})`;
307
+ }
308
+ const effective = selection.effective;
309
+ if (!Array.isArray(effective) || !effective.every((slug) => typeof slug === "string")) {
310
+ return "dynamic selection carries no effective lane-key array";
311
+ }
312
+ const forced = selection.forced;
313
+ if (!Array.isArray(forced) || !forced.every((slug) => typeof slug === "string")) {
314
+ return "dynamic selection carries no forced angle array";
315
+ }
316
+ if (typeof selection.selector_ok !== "boolean") {
317
+ return "dynamic selection carries no boolean selector_ok";
318
+ }
319
+ const selectorError = selection.selector_error;
320
+ if (selectorError !== null && typeof selectorError !== "string") {
321
+ return "dynamic selection carries a non-string selector_error";
322
+ }
323
+ // Re-validate the effective selection against the normalization guarantees.
324
+ if (!effective.every(isEffectiveAngle)) {
325
+ return `dynamic selection carries an out-of-allowlist effective angle (${effective.join(", ")})`;
326
+ }
327
+ if (!effective.includes("plan-fidelity")) {
328
+ return "dynamic selection dropped the mandatory plan-fidelity lane";
329
+ }
330
+ if (effective.length > 3) {
331
+ return `dynamic selection exceeds the 3-lane cap (${effective.join(", ")})`;
332
+ }
333
+ if (new Set(effective).size !== effective.length) {
334
+ return `dynamic selection carries duplicate effective angles (${effective.join(", ")})`;
335
+ }
336
+ return {
337
+ selection: {
338
+ source,
339
+ effective,
340
+ forced,
341
+ selector_ok: selection.selector_ok,
342
+ selector_error: selectorError,
343
+ report: selection.report ?? null,
344
+ },
345
+ lanes,
346
+ };
347
+ }
348
+
349
+ type DynamicRun =
350
+ | { kind: "parsed"; selection: DynamicSelection; reports: WaveReport[]; failures: WaveFailure[] }
351
+ | { kind: "wave-failure"; failure: WaveFailure };
352
+
353
+ async function runDynamicOnce(
354
+ adapter: WaveAdapter,
355
+ opts: PrReviewDynamicOptions,
356
+ ): Promise<DynamicRun> {
357
+ const workflowScript = renderDynamicReviewScript({
358
+ forceAngles: opts.forceAngles ?? [],
359
+ ...(opts.directive !== undefined ? { directive: opts.directive } : {}),
360
+ ...(opts.reviewerModel !== undefined ? { reviewerModel: opts.reviewerModel } : {}),
361
+ ...(opts.selectorModel !== undefined ? { selectorModel: opts.selectorModel } : {}),
362
+ });
363
+ const run = await runWaveScript(
364
+ adapter,
365
+ {
366
+ flow: "pr-review-dynamic",
367
+ workflowScript,
368
+ // The workflow-level default is the reviewer-lane schema; the selector item overrides it
369
+ // per-item. Deliberately NO workflow-level model (per-item models only).
370
+ outputSchema: PR_REVIEW_REPORT_SCHEMA,
371
+ ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
372
+ },
373
+ opts.signal,
374
+ );
375
+ if (!run.ok) return { kind: "wave-failure", failure: run.failure };
376
+ const parsed = parseDynamicValue(run.value);
377
+ if (typeof parsed === "string") {
378
+ return {
379
+ kind: "wave-failure",
380
+ failure: { key: null, reason: "aggregate-unreadable", detail: parsed },
381
+ };
382
+ }
383
+ const { reports, failures } = normalizeLanes(parsed.selection.effective, parsed.lanes);
384
+ return { kind: "parsed", selection: parsed.selection, reports, failures };
385
+ }
386
+
387
+ function outcomeOf(
388
+ selection: DynamicSelection | null,
389
+ reports: WaveReport[],
390
+ failures: WaveFailure[],
391
+ retried: string[],
392
+ ): PrReviewDynamicOutcome {
393
+ const effective = selection?.effective ?? [];
394
+ const byKey = new Map(reports.map((report) => [report.key, report]));
395
+ const ordered = effective.flatMap((angle) => {
396
+ const report = byKey.get(angle);
397
+ return report === undefined ? [] : [report];
398
+ });
399
+ return {
400
+ complete: selection !== null && ordered.length === effective.length,
401
+ covered: ordered.map((report) => report.key),
402
+ retried,
403
+ reports: ordered,
404
+ failures,
405
+ selection,
406
+ };
407
+ }
408
+
409
+ /**
410
+ * Run the dynamic review wave: render + run the ONE dynamic script (concurrent plan-fidelity +
411
+ * selector, in-script normalization, in-script fan-out), defensively re-validate the returned
412
+ * `{selection, lanes}`, normalize per effective lane key under the STRICT completeness policy,
413
+ * and apply the ONE bounded retry: failed lanes only via a static `runReportWave` (the selector
414
+ * is never re-run), or one full dynamic re-run on a retryable wave-level failure (its selection
415
+ * supersedes), or none on `unavailable`/`cancelled`.
416
+ */
417
+ export async function runPrReviewDynamicWave(
418
+ adapter: WaveAdapter,
419
+ opts: PrReviewDynamicOptions = {},
420
+ ): Promise<PrReviewDynamicOutcome> {
421
+ const first = await runDynamicOnce(adapter, opts);
422
+
423
+ if (first.kind === "wave-failure") {
424
+ if (!RETRYABLE_WAVE_REASONS.has(first.failure.reason)) {
425
+ return outcomeOf(null, [], [first.failure], []);
426
+ }
427
+ // Retryable wave-level failure ⇒ ONE full dynamic re-run (fresh selector); its selection
428
+ // supersedes. The re-run's outcome is final — never a second retry.
429
+ const second = await runDynamicOnce(adapter, opts);
430
+ if (second.kind === "wave-failure") {
431
+ return outcomeOf(null, [], [second.failure], []);
432
+ }
433
+ return outcomeOf(second.selection, second.reports, second.failures, second.selection.effective);
434
+ }
435
+
436
+ const firstOutcome = outcomeOf(first.selection, first.reports, first.failures, []);
437
+ if (firstOutcome.complete) return firstOutcome;
438
+
439
+ // Lane-level failures ⇒ retry ONLY the failed reviewer lanes, STATICALLY, over the
440
+ // already-normalized selection — byte-identical lanes via the shared builder; the selector is
441
+ // never re-run.
442
+ const failedKeys = first.selection.effective.filter((key) =>
443
+ first.failures.some((failure) => failure.key === key),
444
+ );
445
+ const retryAngles = failedKeys.filter(isEffectiveAngle);
446
+ if (retryAngles.length === 0) return firstOutcome;
447
+
448
+ const staticRetry = await runReportWave(
449
+ adapter,
450
+ {
451
+ flow: "pr-review-dynamic",
452
+ lanes: buildPrReviewLanes(retryAngles, opts.directive),
453
+ outputSchema: PR_REVIEW_REPORT_SCHEMA,
454
+ completeness: "strict",
455
+ ...(opts.reviewerModel !== undefined ? { model: opts.reviewerModel } : {}),
456
+ ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
457
+ },
458
+ opts.signal,
459
+ );
460
+ const retriedSet = new Set<string>(retryAngles);
461
+ const merged = [
462
+ ...first.reports.filter((report) => !retriedSet.has(report.key)),
463
+ ...staticRetry.reports,
464
+ ];
465
+ return outcomeOf(first.selection, merged, staticRetry.failures, retryAngles);
466
+ }