@mgiles/perk 2.3.0 → 3.0.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 (128) hide show
  1. package/README.md +1 -1
  2. package/extension/adapters/planAdapterPlannotator.ts +132 -91
  3. package/extension/adapters/planAdapterTombell.ts +5 -3
  4. package/extension/doors/address.ts +235 -37
  5. package/extension/doors/annotationPush.ts +924 -0
  6. package/extension/doors/auditWaveTools.ts +352 -0
  7. package/extension/doors/ciExecutor.ts +220 -55
  8. package/extension/doors/draftReviewWaveTools.ts +384 -0
  9. package/extension/doors/harvestWaveTools.ts +279 -0
  10. package/extension/doors/land.ts +1 -0
  11. package/extension/doors/learn.ts +24 -6
  12. package/extension/doors/learnFactory.ts +33 -7
  13. package/extension/doors/lifecycleGates.ts +1 -1
  14. package/extension/doors/objectiveReviewBrowser.ts +514 -0
  15. package/extension/doors/objectiveStack.ts +1143 -0
  16. package/extension/doors/planReviewBrowser.ts +453 -0
  17. package/extension/doors/plannotatorHandoff.ts +120 -49
  18. package/extension/doors/prReview.ts +26 -16
  19. package/extension/doors/prReviewBrowser.ts +31 -23
  20. package/extension/doors/prReviewDynamic.ts +25 -18
  21. package/extension/doors/prReviewTerminal.ts +6 -11
  22. package/extension/doors/reviewWaveTools.ts +374 -0
  23. package/extension/doors/selfcheck.ts +1 -1
  24. package/extension/doors/submit.ts +82 -7
  25. package/extension/factories/gistAuthor.ts +4 -4
  26. package/extension/factories/gistDraft.ts +1 -1
  27. package/extension/factories/gistSave.ts +2 -2
  28. package/extension/factories/objective.ts +7 -7
  29. package/extension/factories/objectiveAuthor.ts +5 -4
  30. package/extension/factories/objectiveDraft.ts +61 -6
  31. package/extension/factories/objectivePlan.ts +163 -9
  32. package/extension/factories/objectiveSave.ts +16 -1
  33. package/extension/factories/planMode.ts +5 -3
  34. package/extension/factories/planReview.ts +131 -36
  35. package/extension/factories/planTitle.ts +27 -9
  36. package/extension/hunkFeedback/inbox.ts +389 -0
  37. package/extension/hunkFeedback/perkFeedback.ts +364 -0
  38. package/extension/hunkFeedback/receiver.ts +296 -0
  39. package/extension/hunkFeedback/store.ts +533 -0
  40. package/extension/index.ts +109 -44
  41. package/extension/substrate/cache.ts +75 -22
  42. package/extension/substrate/coldDoor.ts +2 -3
  43. package/extension/substrate/config.ts +36 -12
  44. package/extension/substrate/git.ts +10 -0
  45. package/extension/substrate/providers.ts +4 -33
  46. package/extension/substrate/resources.ts +11 -0
  47. package/extension/substrate/result.ts +13 -5
  48. package/extension/substrate/sessionData.ts +3 -3
  49. package/extension/substrate/sessionPointers.ts +3 -3
  50. package/extension/substrate/structuredOutput.ts +78 -19
  51. package/extension/substrate/toolGating.ts +170 -23
  52. package/extension/substrate/workflowState.ts +6 -0
  53. package/extension/surfaces/footerProvider.ts +6 -6
  54. package/extension/surfaces/surfaces.ts +98 -282
  55. package/extension/vendor/btw/btw.ts +57 -12
  56. package/extension/waves/adversarialReviewWave.ts +164 -0
  57. package/extension/waves/auditWave.ts +312 -0
  58. package/extension/waves/draftReviewWave.ts +189 -0
  59. package/extension/waves/harvestWave.ts +399 -0
  60. package/extension/waves/memoryAdapter.ts +14 -1
  61. package/extension/waves/objectiveExplorerWave.ts +120 -0
  62. package/extension/waves/prReviewDynamicWave.ts +291 -61
  63. package/extension/waves/prReviewWave.ts +58 -13
  64. package/extension/waves/reportWave.ts +406 -106
  65. package/extension/waves/reviewClassifierWave.ts +109 -0
  66. package/extension/waves/rpcAdapter.ts +65 -2
  67. package/extension/worker/readOnlySession.ts +3 -3
  68. package/extension/worker/worker.ts +71 -93
  69. package/extension/workerMain.ts +4 -6
  70. package/package.json +15 -5
  71. package/prompts/README.md +19 -0
  72. package/prompts/_fixtures/live.yaml +66 -65
  73. package/prompts/common/resume-advisory.md +1 -0
  74. package/prompts/contexts/adapters/plannotator-gist.md +13 -0
  75. package/prompts/contexts/adapters/plannotator-objective.md +1 -2
  76. package/prompts/contexts/adapters/plannotator-plan.md +1 -2
  77. package/prompts/contexts/gist-authoring.md +6 -20
  78. package/prompts/contexts/objective-authoring.md +6 -18
  79. package/prompts/stages/address/action.md +3 -14
  80. package/prompts/stages/address/preview.md +2 -13
  81. package/prompts/stages/audit.md +18 -0
  82. package/prompts/stages/gist-author/seed.md +4 -4
  83. package/prompts/stages/gist-save.md +1 -1
  84. package/prompts/stages/implement.md +3 -1
  85. package/prompts/stages/learn-code.md +1 -1
  86. package/prompts/stages/learn-docs.md +2 -2
  87. package/prompts/stages/learn-harvest.md +15 -0
  88. package/prompts/stages/learn-orchestrate.md +1 -1
  89. package/prompts/stages/objective-author/adopt.md +5 -3
  90. package/prompts/stages/objective-author/file.md +5 -4
  91. package/prompts/stages/objective-author/seed.md +5 -4
  92. package/prompts/stages/objective-land.md +7 -0
  93. package/prompts/stages/objective-plan/guidance.md +2 -13
  94. package/prompts/stages/objective-plan/seed.md +5 -12
  95. package/prompts/stages/objective-recover.md +8 -0
  96. package/prompts/stages/objective-replan.md +4 -2
  97. package/prompts/stages/objective-review-browser.md +9 -0
  98. package/prompts/stages/objective-sync.md +6 -0
  99. package/prompts/stages/plan-from/adopt.md +2 -2
  100. package/prompts/stages/plan-from/file.md +2 -2
  101. package/prompts/stages/plan-review-browser.md +9 -0
  102. package/prompts/stages/pr-review-browser/active.md +6 -14
  103. package/prompts/stages/pr-review-browser/foreign.md +6 -14
  104. package/prompts/stages/pr-review-dynamic.md +3 -3
  105. package/prompts/stages/pr-review-terminal/active.md +7 -15
  106. package/prompts/stages/pr-review-terminal/foreign.md +7 -15
  107. package/prompts/stages/pr-review.md +2 -2
  108. package/prompts/stages/replan.md +3 -3
  109. package/shared/README.md +2 -2
  110. package/shared/bindings.yaml +12 -0
  111. package/shared/contracts-history.md +11 -0
  112. package/shared/contracts.md +3652 -236
  113. package/shared/providers.yaml +27 -60
  114. package/shared/registry.yaml +32 -8
  115. package/shared/schemas/outputs/objective-doctor.schema.json +449 -0
  116. package/shared/schemas/outputs/objective-stack-land.schema.json +732 -0
  117. package/shared/schemas/outputs/objective-stack-recover.schema.json +397 -0
  118. package/shared/schemas/outputs/objective-stack-status.schema.json +577 -0
  119. package/shared/schemas/outputs/objective-stack-sync.schema.json +184 -0
  120. package/shared/schemas/outputs/plan-save.schema.json +12 -0
  121. package/shared/schemas/outputs/pr-submit.schema.json +177 -1
  122. package/extension/adapters/todoAdapterJuicesharp.ts +0 -102
  123. package/extension/checkpoints/checkpoints.ts +0 -550
  124. package/extension/checkpoints/planSteps.ts +0 -108
  125. package/extension/doors/askUser.ts +0 -198
  126. package/prompts/common/output-schemas/objective-explorer.md +0 -36
  127. package/prompts/common/output-schemas/review-classifier.md +0 -47
  128. package/prompts/contexts/adapters/juicesharp-todo.md +0 -7
@@ -2,16 +2,29 @@
2
2
  // typed outcomes under stable lane keys. Report waves were previously model-authored prompt
3
3
  // mechanics (a script skeleton the parent model had to transcribe faithfully — the known
4
4
  // prompt-drift risk); this module makes the mechanics CODE. It renders the complete, tested
5
- // `workflowScript`, launches it through a `WaveAdapter` (async-only, `mission: false`), blocks on
5
+ // `workflowScript`, launches it through a `WaveAdapter` (async-only, `mission: false`), waits on
6
6
  // the run's async-complete event with a module-owned timeout, reads the durable `status.json`
7
7
  // `workflow.value` aggregate, and normalizes `{complete, reports[], failures[]}` under a
8
- // flow-specific completeness policy.
8
+ // flow-specific completeness policy. Each launch additionally records an OUTPUT-FREE
9
+ // `WaveScriptReceipt` (run handle + per-child identity/artifact trail from the completion
10
+ // payload) — write-only telemetry for correlation: `status.json.workflow.value` stays the sole
11
+ // source of reports, and receipt absence never changes a verdict, completeness, or retry
12
+ // selection (contracts.md §8.35).
13
+ //
14
+ // The module hosts BOTH the blocking runner and the non-blocking streaming sibling over ONE
15
+ // operational core: `startWaveScript` performs the front half (abort pre-check → capability
16
+ // ping → subscribe-before-spawn → async spawn) and returns the run handle plus a NEVER-REJECTING
17
+ // `result` promise carrying the back half (completion wait, best-effort stop on timeout/cancel,
18
+ // aggregate read, receipt assembly, unsubscribe-on-settle); `runWaveScript` is that start +
19
+ // await. `startReportWave`/`runReportWave` are the lane-level pair over the same split. The
20
+ // blocking runner is live under the per-flow entrypoints (`prReviewWave.ts`, `learnWave.ts`,
21
+ // `prReviewDynamicWave.ts`); the streaming sibling serves flows whose parent must return from
22
+ // the launch and hold a model-held `subagent_wait` relay loop open (`adversarialReviewWave.ts`,
23
+ // behind the `start_review_wave`/`collect_review_wave` pair).
9
24
  //
10
25
  // The module is a DEEP seam with two adapters: `rpcAdapter.ts` (production, over the
11
26
  // pi-subagents v1 extension RPC on pi's event bus) and `memoryAdapter.ts` (the first-class
12
- // in-memory test double). It is deliberately dormant — no flow calls it and no model-facing tool
13
- // exists — until the flow migrations wire their per-flow `WaveSpec`-building entrypoints over
14
- // `runReportWave`.
27
+ // in-memory test double).
15
28
  //
16
29
  // Failure posture: LOUD DEGRADE. Every failure arm normalizes into `WaveResult.failures` with a
17
30
  // typed reason — the runner never throws except on programmer error (empty lanes, duplicate lane
@@ -30,6 +43,12 @@ export interface WaveLane {
30
43
  label?: string;
31
44
  /** Trace metadata. */
32
45
  phase?: string;
46
+ /**
47
+ * Per-lane report schema — rendered as the item's `outputSchema`, overriding the
48
+ * workflow-level default (the established per-item mechanic). Omitted lanes render
49
+ * byte-identically to before the field existed.
50
+ */
51
+ outputSchema?: object;
33
52
  }
34
53
 
35
54
  /**
@@ -83,6 +102,71 @@ export interface WaveResult {
83
102
  complete: boolean;
84
103
  reports: WaveReport[];
85
104
  failures: WaveFailure[];
105
+ /** The launch's output-free attempt receipt — write-only telemetry, never a decision input. */
106
+ receipt: WaveScriptReceipt;
107
+ }
108
+
109
+ // -------------------------------------------------------------------- the attempt receipts
110
+
111
+ /**
112
+ * The terminal disposition of ONE top-level workflow launch, as the runner observed it. Every
113
+ * launch reaches exactly one of these arms (`"running"` is unreachable — the runner always
114
+ * settles); `"unavailable"` preserves even a pre-spawn capability failure as an attempt.
115
+ */
116
+ export type WaveReceiptState =
117
+ | "unavailable" // ping failed/incomplete — nothing launched
118
+ | "spawn-failed" // spawn rejected/threw — no run handle
119
+ | "complete" // completion observed, durable state "complete"
120
+ | "failed" // completion observed, durable/observed failure
121
+ | "timed-out" // module timeout expired (handle preserved)
122
+ | "cancelled"; // AbortSignal honored (handle preserved when spawned)
123
+
124
+ /**
125
+ * One child lane's identity/artifact trail from the completion payload — OUTPUT-FREE by
126
+ * invariant: reports, summaries, and structured output never enter a receipt (they stay in the
127
+ * durable `status.json.workflow.value`, the sole report authority).
128
+ */
129
+ export interface WaveChildReceipt {
130
+ /** The Perk lane key (mapped FROM the upstream row's overloaded `agent` field). */
131
+ key: string;
132
+ /** The child agent name, enriched from the Perk-owned lane spec where known. */
133
+ agent?: string;
134
+ /** The child's opaque run id — never parsed or synthesized from paths. */
135
+ runId?: string;
136
+ success?: boolean;
137
+ outputState?: "present" | "absent" | "unknown";
138
+ /** String path fields only; output-free. */
139
+ artifactPaths?: Record<string, string>;
140
+ }
141
+
142
+ /** One script launch's receipt: the run handle (where known) + the observed children. */
143
+ export interface WaveScriptReceipt {
144
+ /** The top-level async run id (the spawn handle's asyncId). */
145
+ runId?: string;
146
+ asyncDir?: string;
147
+ state: WaveReceiptState;
148
+ children: WaveChildReceipt[];
149
+ }
150
+
151
+ /**
152
+ * A flow-attributed attempt: one receipt per top-level workflow launch, ordered by `attempt`
153
+ * (one-based, assigned by the flow entrypoint that owns retry policy). `requestedKeys` is the
154
+ * lane manifest BEFORE launch — never reconstructed from the observed children.
155
+ */
156
+ export interface WaveAttemptReceipt extends WaveScriptReceipt {
157
+ flow: string;
158
+ attempt: number;
159
+ requestedKeys: string[];
160
+ }
161
+
162
+ /** Assemble one flow attempt from a script receipt (the uniform builder the flows share). */
163
+ export function toAttemptReceipt(
164
+ flow: string,
165
+ attempt: number,
166
+ requestedKeys: string[],
167
+ receipt: WaveScriptReceipt,
168
+ ): WaveAttemptReceipt {
169
+ return { flow, attempt, requestedKeys: [...requestedKeys], ...receipt };
86
170
  }
87
171
 
88
172
  // ------------------------------------------------------------------------- the adapter seam
@@ -104,12 +188,36 @@ export interface WaveRunHandle {
104
188
  asyncDir: string;
105
189
  }
106
190
 
107
- /** An async-complete notification; at least one identifier is present on real payloads. */
191
+ /**
192
+ * An async-complete notification; at least one identifier is present on real payloads. The
193
+ * observability fields are optional — an identity-only completion stays valid (receipt absence
194
+ * degrades correlation, never behavior). The adapter normalizes them output-free and leaves
195
+ * each child's `agent` unset (enrichment happens against Perk-owned lane specs).
196
+ */
108
197
  export interface WaveCompletion {
109
198
  asyncId?: string;
110
199
  asyncDir?: string;
200
+ /** The run's raw terminal state string, when the payload carries one. */
201
+ state?: string;
202
+ success?: boolean;
203
+ children?: WaveChildReceipt[];
111
204
  }
112
205
 
206
+ /**
207
+ * The explicit acceptance-disable every wave spawn carries. Without it, pi-subagents
208
+ * auto-infers a generic acceptance contract for reviewer/analyst-named or read-only children
209
+ * and injects a fenced `acceptance-report` completion instruction into each lane — a COMPETING
210
+ * completion contract observed steering children into invalid `structured_output` attempts.
211
+ * `{level: "none"}` is the sanctioned disable shape (pi-subagents `explicitAcceptanceCanDisable`);
212
+ * `formatAcceptancePrompt` emits nothing at level none, so no contract block reaches a lane.
213
+ * Deliberately module-wide with no opt-out: every report-wave child's sole completion contract
214
+ * is the engine-validated `structured_output` report.
215
+ */
216
+ export const WAVE_ACCEPTANCE = {
217
+ level: "none",
218
+ reason: "perk report-wave lanes complete via the engine-validated structured_output report",
219
+ } as const;
220
+
113
221
  /** The full spawn params the runner fixes: async-only, ephemeral, fresh-context by definition. */
114
222
  export interface WaveSpawnParams {
115
223
  workflowScript: string;
@@ -118,6 +226,9 @@ export interface WaveSpawnParams {
118
226
  mission: false;
119
227
  /** A report wave is by definition fresh-context. */
120
228
  context: "fresh";
229
+ /** The fixed acceptance disable (`WAVE_ACCEPTANCE`) — pi-subagents' workflow-defaults spread
230
+ * delivers it onto every lane child, suppressing the auto-inferred acceptance contract. */
231
+ acceptance: { level: "none"; reason: string };
121
232
  outputSchema: object;
122
233
  model?: string;
123
234
  /** Orphan insurance: the run enforces the same deadline even if the parent session dies. */
@@ -139,13 +250,22 @@ export interface WaveAdapter {
139
250
 
140
251
  // ---------------------------------------------------------------------------- the renderer
141
252
 
253
+ /**
254
+ * pi-subagents' scripted-workflow run-key contract for `runs.all` item keys: start
255
+ * alphanumeric, then letters/digits/`.`/`_`/`-`, ≤128 chars total. Mirrored here because the
256
+ * upstream pattern is enforced only inside the live workflow worker — an invalid key fails the
257
+ * WHOLE wave at dispatch (`run-failed`), a path no offline adapter exercises — so the renderer
258
+ * rejects it up front as a programmer error.
259
+ */
260
+ export const RUN_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
261
+
142
262
  /**
143
263
  * Render the wave `workflowScript`: an explicit-return, all-settled `runs.all` over the lane
144
264
  * items, projected to the compact typed aggregate only (lane key, outcome, error, and the
145
265
  * schema-validated report — children's prose never enters the aggregate beyond `error`/`output`
146
266
  * on failure). Lane items are embedded via `JSON.stringify`, so hostile task text (quotes,
147
267
  * newlines, backticks, `${}`) cannot escape the array literal. Throws on programmer error:
148
- * empty lanes or duplicate lane keys.
268
+ * empty lanes, duplicate lane keys, or a lane key outside the run-key contract.
149
269
  */
150
270
  export function renderWaveScript(lanes: WaveLane[]): string {
151
271
  if (lanes.length === 0) {
@@ -156,6 +276,11 @@ export function renderWaveScript(lanes: WaveLane[]): string {
156
276
  if (seen.has(lane.key)) {
157
277
  throw new Error(`renderWaveScript: duplicate lane key '${lane.key}'`);
158
278
  }
279
+ if (!RUN_KEY_PATTERN.test(lane.key)) {
280
+ throw new Error(
281
+ `renderWaveScript: lane key '${lane.key}' violates the pi-subagents run-key contract`,
282
+ );
283
+ }
159
284
  seen.add(lane.key);
160
285
  }
161
286
  const items = lanes.map((lane) => ({
@@ -164,6 +289,7 @@ export function renderWaveScript(lanes: WaveLane[]): string {
164
289
  task: lane.task,
165
290
  label: lane.label ?? lane.key,
166
291
  ...(lane.phase !== undefined ? { phase: lane.phase } : {}),
292
+ ...(lane.outputSchema !== undefined ? { outputSchema: lane.outputSchema } : {}),
167
293
  }));
168
294
  return (
169
295
  `const reports = await runs.all(${JSON.stringify(items, null, 2)});\n` +
@@ -186,8 +312,12 @@ function waveTimeoutMs(): number {
186
312
  return Number.isFinite(raw) && raw > 0 ? raw : WAVE_TIMEOUT_MS;
187
313
  }
188
314
 
189
- function waveFailure(reason: WaveFailureReason, detail: string): WaveResult {
190
- return { complete: false, reports: [], failures: [{ key: null, reason, detail }] };
315
+ function waveFailure(
316
+ reason: WaveFailureReason,
317
+ detail: string,
318
+ receipt: WaveScriptReceipt,
319
+ ): WaveResult {
320
+ return { complete: false, reports: [], failures: [{ key: null, reason, detail }], receipt };
191
321
  }
192
322
 
193
323
  /** The judgment-bearing pieces a script run needs (the lane-free slice of `WaveSpec`). */
@@ -205,7 +335,23 @@ export interface WaveScriptSpec {
205
335
  }
206
336
 
207
337
  /** A script run's outcome: the raw `workflow.value` on success, one wave-level failure otherwise. */
208
- export type WaveScriptResult = { ok: true; value: unknown } | { ok: false; failure: WaveFailure };
338
+ export type WaveScriptResult =
339
+ | { ok: true; value: unknown; receipt: WaveScriptReceipt }
340
+ | { ok: false; failure: WaveFailure; receipt: WaveScriptReceipt };
341
+
342
+ /**
343
+ * A launched (or launch-refused) script run. On `ok: true` the run is LIVE: `handle` is the
344
+ * detached async run, and `result` settles when the back half finishes (completion wait under
345
+ * the module-owned timeout, AbortSignal honor, best-effort stop on timeout/cancel, the durable
346
+ * aggregate read, receipt assembly, unsubscribe-on-settle). `result` NEVER rejects — every arm
347
+ * normalizes into `WaveScriptResult`, so an uncollected wave can never become an unhandled
348
+ * rejection. Pre-spawn failures (aborted-before-launch, ping fail/null, spawn throw) take the
349
+ * `ok: false` arm with the same failure/receipt values the blocking runner reports, and the
350
+ * completion subscription is released immediately.
351
+ */
352
+ export type WaveScriptStart =
353
+ | { ok: true; handle: WaveRunHandle; result: Promise<WaveScriptResult> }
354
+ | { ok: false; failure: WaveFailure; receipt: WaveScriptReceipt };
209
355
 
210
356
  function errorDetail(error: unknown): string {
211
357
  return error instanceof Error ? error.message : String(error);
@@ -281,26 +427,51 @@ export function normalizeLanes(
281
427
  }
282
428
 
283
429
  /**
284
- * Run one module-rendered workflowScript through the adapter: capability ping
285
- * subscribe-before-spawn (the completion-before-reply buffer) → async spawn → block on the
286
- * async-complete event (module-owned timeout, abortable) → best-effort stop on timeout/cancel →
287
- * read the durable aggregate the `state !== "complete"` / unreadable arms. Returns the raw
288
- * `workflow.value` on success the shared operational core under `runReportWave` and the
289
- * dynamic-review sibling; per-flow value normalization stays with the caller.
430
+ * Start one module-rendered workflowScript through the adapter the non-blocking front half:
431
+ * capability ping → subscribe-before-spawn (the completion-before-reply buffer) → async spawn.
432
+ * On success the back half (block on the async-complete event under the module-owned timeout,
433
+ * abortable best-effort stop on timeout/cancel read the durable aggregate the
434
+ * `state !== "complete"` / unreadable arms) runs behind the returned `result` promise, which
435
+ * never rejects. The shared operational core under every runner; per-flow value normalization
436
+ * stays with the caller.
290
437
  */
291
- export async function runWaveScript(
438
+ export async function startWaveScript(
292
439
  adapter: WaveAdapter,
293
440
  spec: WaveScriptSpec,
294
441
  signal?: AbortSignal,
295
- ): Promise<WaveScriptResult> {
296
- const scriptFailure = (reason: WaveFailureReason, detail: string): WaveScriptResult => ({
442
+ ): Promise<WaveScriptStart> {
443
+ // The receipt is assembled in EVERY terminal arm — write-only telemetry: nothing below reads
444
+ // it back into the ok/failure decision.
445
+ const receiptOf = (
446
+ state: WaveReceiptState,
447
+ spawned: WaveRunHandle | null,
448
+ completion?: WaveCompletion,
449
+ ): WaveScriptReceipt => ({
450
+ ...(spawned !== null ? { runId: spawned.asyncId, asyncDir: spawned.asyncDir } : {}),
451
+ state,
452
+ children: completion?.children ?? [],
453
+ });
454
+ const startFailure = (
455
+ reason: WaveFailureReason,
456
+ detail: string,
457
+ receipt: WaveScriptReceipt,
458
+ ): WaveScriptStart => ({
297
459
  ok: false,
298
460
  failure: { key: null, reason, detail },
461
+ receipt,
299
462
  });
300
463
 
301
- if (signal?.aborted === true) {
302
- return scriptFailure("cancelled", `wave '${spec.flow}' was cancelled before launch`);
303
- }
464
+ // Read through a closure so TS's readonly-property narrowing never staples the first
465
+ // check's `false` onto the post-await re-check (the signal CAN flip during an await).
466
+ const aborted = (): boolean => signal?.aborted === true;
467
+ const cancelledBeforeLaunch = (): WaveScriptStart =>
468
+ startFailure(
469
+ "cancelled",
470
+ `wave '${spec.flow}' was cancelled before launch`,
471
+ receiptOf("cancelled", null),
472
+ );
473
+
474
+ if (aborted()) return cancelledBeforeLaunch();
304
475
 
305
476
  // 1. Capability check — the loud-degrade arm: the result explicitly names the wave
306
477
  // unavailable; callers surface it, never silently fall back to model-authored scripts.
@@ -308,15 +479,24 @@ export async function runWaveScript(
308
479
  try {
309
480
  ping = await adapter.ping();
310
481
  } catch (error) {
311
- return scriptFailure("unavailable", `subagent RPC ping failed: ${errorDetail(error)}`);
482
+ return startFailure(
483
+ "unavailable",
484
+ `subagent RPC ping failed: ${errorDetail(error)}`,
485
+ receiptOf("unavailable", null),
486
+ );
312
487
  }
313
488
  if (ping === null) {
314
- return scriptFailure(
489
+ return startFailure(
315
490
  "unavailable",
316
491
  "pi-subagents did not advertise the report-wave capabilities (ping failed or incomplete)",
492
+ receiptOf("unavailable", null),
317
493
  );
318
494
  }
319
495
 
496
+ // An abort can arrive WHILE the ping await is pending — re-check before subscribe/spawn so a
497
+ // cancelled wave never launches (the pre-launch check alone leaves this window open).
498
+ if (aborted()) return cancelledBeforeLaunch();
499
+
320
500
  // 2. Subscribe BEFORE spawn: a completion can arrive before the spawn reply resolves (the
321
501
  // completion-before-reply race) — every completion is buffered and re-checked once the
322
502
  // handle is known.
@@ -332,108 +512,175 @@ export async function runWaveScript(
332
512
  if (matchesHandle(completion) && notifyMatch !== null) notifyMatch();
333
513
  });
334
514
 
515
+ // 3. Spawn: async-only, ephemeral, fresh-context — the module fixes those; the flow's spec
516
+ // supplies the judgment-bearing pieces (lanes, schema, model, policy).
517
+ const timeoutMs = spec.timeoutMs ?? waveTimeoutMs();
335
518
  try {
336
- // 3. Spawn: async-only, ephemeral, fresh-context — the module fixes those; the flow's spec
337
- // supplies the judgment-bearing pieces (lanes, schema, model, policy).
338
- const timeoutMs = spec.timeoutMs ?? waveTimeoutMs();
519
+ handle = await adapter.spawn({
520
+ workflowScript: spec.workflowScript,
521
+ async: true,
522
+ mission: false,
523
+ context: "fresh",
524
+ acceptance: WAVE_ACCEPTANCE,
525
+ outputSchema: spec.outputSchema,
526
+ ...(spec.model !== undefined ? { model: spec.model } : {}),
527
+ timeoutMs,
528
+ });
529
+ } catch (error) {
530
+ unsubscribe();
531
+ return startFailure(
532
+ "spawn-failed",
533
+ `wave spawn failed: ${errorDetail(error)}`,
534
+ receiptOf("spawn-failed", null),
535
+ );
536
+ }
537
+ const spawned = handle;
538
+
539
+ const scriptFailure = (
540
+ reason: WaveFailureReason,
541
+ detail: string,
542
+ receipt: WaveScriptReceipt,
543
+ ): WaveScriptResult => ({
544
+ ok: false,
545
+ failure: { key: null, reason, detail },
546
+ receipt,
547
+ });
548
+
549
+ // The back half: every arm below RETURNS a normalized `WaveScriptResult` (never throws), so
550
+ // `result` never rejects; the subscription is released exactly when it settles.
551
+ const settle = async (): Promise<WaveScriptResult> => {
339
552
  try {
340
- handle = await adapter.spawn({
341
- workflowScript: spec.workflowScript,
342
- async: true,
343
- mission: false,
344
- context: "fresh",
345
- outputSchema: spec.outputSchema,
346
- ...(spec.model !== undefined ? { model: spec.model } : {}),
347
- timeoutMs,
553
+ // 4. Block on completion with the module-owned timeout; honor the caller's AbortSignal.
554
+ const outcome = await new Promise<"complete" | "timeout" | "cancelled">((resolve) => {
555
+ if (buffered.some(matchesHandle)) {
556
+ resolve("complete");
557
+ return;
558
+ }
559
+ const settleOutcome = (value: "complete" | "timeout" | "cancelled"): void => {
560
+ clearTimeout(timer);
561
+ signal?.removeEventListener("abort", onAbort);
562
+ notifyMatch = null;
563
+ resolve(value);
564
+ };
565
+ const timer = setTimeout(() => settleOutcome("timeout"), timeoutMs);
566
+ const onAbort = (): void => settleOutcome("cancelled");
567
+ notifyMatch = () => settleOutcome("complete");
568
+ signal?.addEventListener("abort", onAbort, { once: true });
569
+ if (signal?.aborted === true) settleOutcome("cancelled");
348
570
  });
349
- } catch (error) {
350
- return scriptFailure("spawn-failed", `wave spawn failed: ${errorDetail(error)}`);
351
- }
352
-
353
- // 4. Block on completion with the module-owned timeout; honor the caller's AbortSignal.
354
- const outcome = await new Promise<"complete" | "timeout" | "cancelled">((resolve) => {
355
- if (buffered.some(matchesHandle)) {
356
- resolve("complete");
357
- return;
571
+ if (outcome !== "complete") {
572
+ // Best-effort stop — adapters never throw here by contract, but a broken adapter's error
573
+ // is still swallowed into the detail rather than re-thrown.
574
+ let stopNote = "";
575
+ try {
576
+ await adapter.stop(spawned);
577
+ } catch (error) {
578
+ stopNote = ` (stop failed: ${errorDetail(error)})`;
579
+ }
580
+ return outcome === "timeout"
581
+ ? scriptFailure(
582
+ "timeout",
583
+ `wave '${spec.flow}' timed out after ${timeoutMs}ms${stopNote}`,
584
+ receiptOf("timed-out", spawned),
585
+ )
586
+ : scriptFailure(
587
+ "cancelled",
588
+ `wave '${spec.flow}' was cancelled${stopNote}`,
589
+ receiptOf("cancelled", spawned),
590
+ );
358
591
  }
359
- const settle = (value: "complete" | "timeout" | "cancelled"): void => {
360
- clearTimeout(timer);
361
- signal?.removeEventListener("abort", onAbort);
362
- notifyMatch = null;
363
- resolve(value);
364
- };
365
- const timer = setTimeout(() => settle("timeout"), timeoutMs);
366
- const onAbort = (): void => settle("cancelled");
367
- notifyMatch = () => settle("complete");
368
- signal?.addEventListener("abort", onAbort, { once: true });
369
- if (signal?.aborted === true) settle("cancelled");
370
- });
371
- if (outcome !== "complete") {
372
- // Best-effort stop — adapters never throw here by contract, but a broken adapter's error
373
- // is still swallowed into the detail rather than re-thrown.
374
- let stopNote = "";
592
+
593
+ // The MATCHED completion (retained for the receipt — its normalized children are the
594
+ // child-lane identity/artifact trail; an identity-only completion yields empty children).
595
+ const matched = buffered.find(matchesHandle);
596
+
597
+ // 5. Read the durable aggregate; surface the terminal-state arms.
598
+ let aggregate: { state: string; error?: string; value: unknown };
375
599
  try {
376
- await adapter.stop(handle);
600
+ aggregate = await adapter.readAggregate(spawned);
377
601
  } catch (error) {
378
- stopNote = ` (stop failed: ${errorDetail(error)})`;
602
+ // Aggregate-unreadable: the completion identity is retained — the receipt state derives
603
+ // from the OBSERVED completion (a correlation label, not a verdict; the authoritative
604
+ // failure reason stays in the wave failure).
605
+ return scriptFailure(
606
+ "aggregate-unreadable",
607
+ `wave aggregate unreadable: ${errorDetail(error)}`,
608
+ receiptOf(matched?.success === false ? "failed" : "complete", spawned, matched),
609
+ );
379
610
  }
380
- return outcome === "timeout"
381
- ? scriptFailure("timeout", `wave '${spec.flow}' timed out after ${timeoutMs}ms${stopNote}`)
382
- : scriptFailure("cancelled", `wave '${spec.flow}' was cancelled${stopNote}`);
383
- }
384
-
385
- // 5. Read the durable aggregate; surface the terminal-state arms.
386
- let aggregate: { state: string; error?: string; value: unknown };
387
- try {
388
- aggregate = await adapter.readAggregate(handle);
389
- } catch (error) {
390
- return scriptFailure(
391
- "aggregate-unreadable",
392
- `wave aggregate unreadable: ${errorDetail(error)}`,
393
- );
394
- }
395
- if (aggregate.state !== "complete") {
396
- const detail = aggregate.error !== undefined ? `: ${aggregate.error}` : "";
397
- return scriptFailure("run-failed", `wave run ended '${aggregate.state}'${detail}`);
611
+ if (aggregate.state !== "complete") {
612
+ const detail = aggregate.error !== undefined ? `: ${aggregate.error}` : "";
613
+ return scriptFailure(
614
+ "run-failed",
615
+ `wave run ended '${aggregate.state}'${detail}`,
616
+ receiptOf("failed", spawned, matched),
617
+ );
618
+ }
619
+ return { ok: true, value: aggregate.value, receipt: receiptOf("complete", spawned, matched) };
620
+ } finally {
621
+ unsubscribe();
398
622
  }
399
- return { ok: true, value: aggregate.value };
400
- } finally {
401
- unsubscribe();
402
- }
623
+ };
624
+ return { ok: true, handle: spawned, result: settle() };
403
625
  }
404
626
 
405
627
  /**
406
- * Run a report wave: render the all-settled lane script, run it through `runWaveScript`, then
407
- * normalize per lane key and apply the completeness policy. Every operational failure normalizes
408
- * into `WaveResult` — the only throws are programmer errors (empty lanes / duplicate keys, via
409
- * `renderWaveScript`).
628
+ * Run one module-rendered workflowScript to completion the blocking form: `startWaveScript` +
629
+ * await its `result` (one operational core, behavior identical to the historical blocking
630
+ * runner).
410
631
  */
411
- export async function runReportWave(
632
+ export async function runWaveScript(
412
633
  adapter: WaveAdapter,
413
- spec: WaveSpec,
634
+ spec: WaveScriptSpec,
414
635
  signal?: AbortSignal,
415
- ): Promise<WaveResult> {
416
- // Programmer-error validation first (throws): the script render is spec-only.
417
- const workflowScript = renderWaveScript(spec.lanes);
636
+ ): Promise<WaveScriptResult> {
637
+ const start = await startWaveScript(adapter, spec, signal);
638
+ if (!start.ok) return { ok: false, failure: start.failure, receipt: start.receipt };
639
+ return await start.result;
640
+ }
418
641
 
419
- const run = await runWaveScript(
420
- adapter,
421
- {
422
- flow: spec.flow,
423
- workflowScript,
424
- outputSchema: spec.outputSchema,
425
- ...(spec.model !== undefined ? { model: spec.model } : {}),
426
- ...(spec.timeoutMs !== undefined ? { timeoutMs: spec.timeoutMs } : {}),
427
- },
428
- signal,
429
- );
642
+ /**
643
+ * Enrich receipt children's `agent` from the Perk-owned lane specs by key. Children are never
644
+ * synthesized from lanes — an identity-only completion keeps its empty children (receipt absence
645
+ * degrades correlation, never behavior).
646
+ */
647
+ function enrichReceipt(receipt: WaveScriptReceipt, lanes: WaveLane[]): WaveScriptReceipt {
648
+ return {
649
+ ...receipt,
650
+ children: receipt.children.map((child) => {
651
+ if (child.agent !== undefined) return child;
652
+ const agent = lanes.find((lane) => lane.key === child.key)?.agent;
653
+ return agent === undefined ? child : { ...child, agent };
654
+ }),
655
+ };
656
+ }
657
+
658
+ /**
659
+ * A launched (or launch-failed) report wave — the lane-level sibling of `WaveScriptStart`. On
660
+ * `ok: true` the wave is LIVE: `result` settles into the normalized `WaveResult` (lane
661
+ * normalization + completeness policy + receipt enrichment) and never rejects. On `ok: false`
662
+ * the launch failure is already normalized into a `WaveResult` (receipt included) — no promise
663
+ * to await, nothing left running.
664
+ */
665
+ export type ReportWaveStart =
666
+ | { ok: true; handle: WaveRunHandle; result: Promise<WaveResult> }
667
+ | { ok: false; result: WaveResult };
668
+
669
+ /**
670
+ * Settle one script outcome into the lane-level `WaveResult`: receipt enrichment, the
671
+ * workflow.value array check, per-lane-key normalization, and the completeness policy — the
672
+ * single back half both the blocking runner and the streaming sibling apply.
673
+ */
674
+ function settleReportWave(run: WaveScriptResult, spec: WaveSpec): WaveResult {
675
+ const receipt = enrichReceipt(run.receipt, spec.lanes);
430
676
  if (!run.ok) {
431
- return { complete: false, reports: [], failures: [run.failure] };
677
+ return { complete: false, reports: [], failures: [run.failure], receipt };
432
678
  }
433
679
  if (!Array.isArray(run.value)) {
434
680
  return waveFailure(
435
681
  "aggregate-unreadable",
436
682
  "wave aggregate carries no workflow.value array (the script's explicit return is missing)",
683
+ receipt,
437
684
  );
438
685
  }
439
686
 
@@ -445,5 +692,58 @@ export async function runReportWave(
445
692
  spec.completeness === "strict"
446
693
  ? failures.length === 0
447
694
  : failures.every((failure) => failure.key !== null);
448
- return { complete, reports, failures };
695
+ return { complete, reports, failures, receipt };
696
+ }
697
+
698
+ /**
699
+ * Start a report wave without blocking on completion: render the all-settled lane script (the
700
+ * programmer-error throws — empty lanes / duplicate keys — are preserved), launch it via
701
+ * `startWaveScript`, and on success return the run handle plus a `result` promise that applies
702
+ * the shared settle (normalization + completeness + receipt enrichment) when the run finishes.
703
+ * A launch failure comes back as an already-settled, normalized `WaveResult`.
704
+ */
705
+ export async function startReportWave(
706
+ adapter: WaveAdapter,
707
+ spec: WaveSpec,
708
+ signal?: AbortSignal,
709
+ ): Promise<ReportWaveStart> {
710
+ // Programmer-error validation first (throws): the script render is spec-only.
711
+ const workflowScript = renderWaveScript(spec.lanes);
712
+
713
+ const start = await startWaveScript(
714
+ adapter,
715
+ {
716
+ flow: spec.flow,
717
+ workflowScript,
718
+ outputSchema: spec.outputSchema,
719
+ ...(spec.model !== undefined ? { model: spec.model } : {}),
720
+ ...(spec.timeoutMs !== undefined ? { timeoutMs: spec.timeoutMs } : {}),
721
+ },
722
+ signal,
723
+ );
724
+ if (!start.ok) {
725
+ return {
726
+ ok: false,
727
+ result: settleReportWave({ ok: false, failure: start.failure, receipt: start.receipt }, spec),
728
+ };
729
+ }
730
+ return {
731
+ ok: true,
732
+ handle: start.handle,
733
+ result: start.result.then((run) => settleReportWave(run, spec)),
734
+ };
735
+ }
736
+
737
+ /**
738
+ * Run a report wave to completion — the blocking form: `startReportWave` + await its `result`
739
+ * (one operational core). Every operational failure normalizes into `WaveResult` — the only
740
+ * throws are programmer errors (empty lanes / duplicate keys, via `renderWaveScript`).
741
+ */
742
+ export async function runReportWave(
743
+ adapter: WaveAdapter,
744
+ spec: WaveSpec,
745
+ signal?: AbortSignal,
746
+ ): Promise<WaveResult> {
747
+ const start = await startReportWave(adapter, spec, signal);
748
+ return start.ok ? await start.result : start.result;
449
749
  }