@mgiles/perk 3.1.0 → 3.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 (211) hide show
  1. package/README.md +5 -0
  2. package/extension/authoring/gist/draft.ts +198 -0
  3. package/extension/authoring/gist/prose.ts +46 -0
  4. package/extension/authoring/gist/review.ts +133 -0
  5. package/extension/authoring/gist/save.ts +118 -0
  6. package/extension/authoring/objective/draft.ts +345 -0
  7. package/extension/{factories/objectiveDreamReport.ts → authoring/objective/dreamReportGate.ts} +74 -131
  8. package/extension/authoring/objective/planning.ts +124 -0
  9. package/extension/authoring/objective/prose.ts +103 -0
  10. package/extension/authoring/objective/review.ts +128 -0
  11. package/extension/authoring/objective/save.ts +224 -0
  12. package/extension/authoring/plan/draft.ts +84 -0
  13. package/extension/authoring/plan/prose.ts +41 -0
  14. package/extension/authoring/plan/review.ts +269 -0
  15. package/extension/authoring/plan/save.ts +256 -0
  16. package/extension/authoring/plan/source.ts +82 -0
  17. package/extension/authoring/refinement/context.ts +468 -0
  18. package/extension/authoring/refinement/draft.ts +261 -0
  19. package/extension/authoring/refinement/prose.ts +79 -0
  20. package/extension/authoring/refinement/review.ts +111 -0
  21. package/extension/authoring/refinement/save.ts +119 -0
  22. package/extension/authoring/review/approvalGate.ts +34 -0
  23. package/extension/authoring/review/draftContext.ts +68 -0
  24. package/extension/codeReview/automated.ts +352 -0
  25. package/extension/codeReview/submission.ts +229 -0
  26. package/extension/delivery/address.ts +295 -0
  27. package/extension/delivery/ci.ts +355 -0
  28. package/extension/delivery/commitCompact.ts +93 -0
  29. package/extension/delivery/conflictResolution.ts +247 -0
  30. package/extension/delivery/ready.ts +193 -0
  31. package/extension/delivery/stackConflict.ts +361 -0
  32. package/extension/delivery/stackObjective.ts +16 -0
  33. package/extension/delivery/stackReconcile.ts +165 -0
  34. package/extension/delivery/submit.ts +171 -0
  35. package/extension/index.ts +369 -359
  36. package/extension/learning/analystWave.ts +324 -0
  37. package/extension/learning/audit.ts +667 -0
  38. package/extension/learning/capture.ts +92 -0
  39. package/extension/learning/containment.ts +104 -0
  40. package/extension/{waves/dreamWave.ts → learning/dream.ts} +112 -94
  41. package/extension/learning/dreamAnalysis.ts +435 -0
  42. package/extension/{waves/dreamReducerWave.ts → learning/dreamReducer.ts} +46 -41
  43. package/extension/{waves → learning}/dreamReport.ts +35 -31
  44. package/extension/learning/harvest.ts +491 -0
  45. package/extension/learning/prose.ts +66 -0
  46. package/extension/learning/routing.ts +79 -0
  47. package/extension/pi/v1/bashScanTimeout.ts +64 -0
  48. package/extension/{doors/prReview.ts → pi/v1/codeReview/automated.ts} +215 -310
  49. package/extension/{doors/prReviewBrowser.ts → pi/v1/codeReview/browser.ts} +125 -57
  50. package/extension/{doors/hunkHandoff.ts → pi/v1/codeReview/checkout.ts} +12 -8
  51. package/extension/{doors/reviewWaveTools.ts → pi/v1/codeReview/reviewWave.ts} +169 -116
  52. package/extension/pi/v1/codeReview/stack.ts +606 -0
  53. package/extension/pi/v1/codeReview/submit.ts +354 -0
  54. package/extension/{doors/prReviewTerminal.ts → pi/v1/codeReview/terminal.ts} +32 -27
  55. package/extension/pi/v1/contextEvidence.ts +80 -0
  56. package/extension/pi/v1/contextInjection.ts +207 -0
  57. package/extension/{doors → pi/v1/delivery}/address.ts +163 -265
  58. package/extension/pi/v1/delivery/ci.ts +570 -0
  59. package/extension/pi/v1/delivery/commitCompact.ts +201 -0
  60. package/extension/pi/v1/delivery/conflictResolverEngine.ts +425 -0
  61. package/extension/{doors → pi/v1/delivery}/land.ts +128 -60
  62. package/extension/pi/v1/delivery/ready.ts +322 -0
  63. package/extension/pi/v1/delivery/stackConflictResolver.ts +172 -0
  64. package/extension/pi/v1/delivery/stackDrive.ts +120 -0
  65. package/extension/pi/v1/delivery/stackLand.ts +223 -0
  66. package/extension/pi/v1/delivery/stackRecover.ts +265 -0
  67. package/extension/pi/v1/delivery/stackStatus.ts +237 -0
  68. package/extension/pi/v1/delivery/stackSync.ts +658 -0
  69. package/extension/pi/v1/delivery/submit.ts +389 -0
  70. package/extension/pi/v1/delivery/submitConflict.ts +186 -0
  71. package/extension/pi/v1/draftReview.ts +431 -0
  72. package/extension/{doors → pi/v1}/draftReviewWaveTools.ts +141 -151
  73. package/extension/pi/v1/gist.ts +794 -0
  74. package/extension/pi/v1/learning/audit.ts +186 -0
  75. package/extension/pi/v1/learning/dream.ts +207 -0
  76. package/extension/{doors/learnFactory.ts → pi/v1/learning/factory.ts} +18 -65
  77. package/extension/{doors/harvestWaveTools.ts → pi/v1/learning/harvest.ts} +46 -100
  78. package/extension/pi/v1/learning/learn.ts +585 -0
  79. package/extension/{doors → pi/v1}/lifecycleGates.ts +14 -59
  80. package/extension/{factories → pi/v1}/objective.ts +53 -33
  81. package/extension/pi/v1/objectiveAuthoring.ts +672 -0
  82. package/extension/pi/v1/objectiveDreamGate.ts +160 -0
  83. package/extension/pi/v1/objectivePlanning.ts +776 -0
  84. package/extension/pi/v1/objectiveRefinement.ts +1320 -0
  85. package/extension/pi/v1/objectiveReview.ts +451 -0
  86. package/extension/{doors → pi/v1}/objectiveReviewBrowser.ts +259 -172
  87. package/extension/pi/v1/plan.ts +812 -0
  88. package/extension/pi/v1/planReview.ts +820 -0
  89. package/extension/{doors → pi/v1}/planReviewBrowser.ts +228 -152
  90. package/extension/{doors/annotationPush.ts → pi/v1/providers/annotations.ts} +158 -89
  91. package/extension/pi/v1/providers/plannotator.ts +487 -0
  92. package/extension/{doors → pi/v1/providers}/plannotatorHandoff.ts +152 -34
  93. package/extension/pi/v1/providers/selection.ts +43 -0
  94. package/extension/{adapters/planAdapterTombell.ts → pi/v1/providers/tombell.ts} +43 -72
  95. package/extension/pi/v1/review.ts +538 -0
  96. package/extension/pi/v1/reviewOutcome.ts +9 -0
  97. package/extension/pi/v1/scoutWave.ts +318 -0
  98. package/extension/{doors → pi/v1}/selfcheck.ts +4 -4
  99. package/extension/session/branchWorkflowSession.ts +60 -0
  100. package/extension/session/lifecycle.ts +644 -0
  101. package/extension/session/lifecycleGates.ts +64 -0
  102. package/extension/session/saveDestination.ts +87 -0
  103. package/extension/session/workflowSession.ts +971 -0
  104. package/extension/substrate/agentScratch.ts +27 -54
  105. package/extension/substrate/bashScanTimeout.ts +181 -0
  106. package/extension/substrate/bindingDelivery.ts +38 -30
  107. package/extension/substrate/bindings.ts +4 -5
  108. package/extension/substrate/cache.ts +64 -12
  109. package/extension/substrate/childRestrictions.ts +39 -0
  110. package/extension/substrate/coldDoor.ts +17 -1
  111. package/extension/substrate/config.ts +157 -19
  112. package/extension/substrate/git.ts +88 -6
  113. package/extension/substrate/modelVisible.ts +53 -0
  114. package/extension/substrate/paths.ts +2 -7
  115. package/extension/substrate/prompts.ts +22 -0
  116. package/extension/substrate/registry.ts +2 -0
  117. package/extension/substrate/resolverLease.ts +364 -0
  118. package/extension/substrate/sessionData.ts +85 -152
  119. package/extension/substrate/toolGating.ts +279 -84
  120. package/extension/substrate/unifiedDiff.ts +1 -1
  121. package/extension/substrate/workflowState.ts +191 -166
  122. package/extension/substrate/worktreeResolverLock.ts +261 -0
  123. package/extension/surfaces/surfaces.ts +79 -27
  124. package/extension/waves/adversarialReviewWave.ts +103 -48
  125. package/extension/waves/blockedReports.ts +59 -0
  126. package/extension/waves/draftReviewWave.ts +42 -42
  127. package/extension/waves/laneIdentity.ts +77 -0
  128. package/extension/waves/objectiveExplorerWave.ts +24 -24
  129. package/extension/waves/prReviewWave.ts +89 -77
  130. package/extension/waves/reportWave.ts +438 -578
  131. package/extension/waves/reviewClassifierWave.ts +22 -22
  132. package/extension/waves/rpcAdapter.ts +100 -15
  133. package/extension/waves/scoutWave.ts +192 -0
  134. package/extension/waves/transport.ts +480 -0
  135. package/extension/worker/sdkAdapter.ts +494 -0
  136. package/extension/worker/stageExecution.ts +679 -0
  137. package/extension/workerMain.ts +18 -19
  138. package/package.json +6 -4
  139. package/prompts/_fixtures/live.yaml +98 -10
  140. package/prompts/contexts/adapters/plannotator-gist.md +6 -0
  141. package/prompts/contexts/adapters/plannotator-objective.md +6 -0
  142. package/prompts/contexts/adapters/plannotator-plan.md +8 -1
  143. package/prompts/contexts/adapters/plannotator-refinement.md +22 -0
  144. package/prompts/contexts/adapters/tombell-plan.md +4 -0
  145. package/prompts/contexts/objective-refinement.md +17 -0
  146. package/prompts/contexts/plan-authoring.md +6 -5
  147. package/prompts/contexts/read-only.md +1 -1
  148. package/prompts/stages/conflict-resolution-continuation.md +9 -0
  149. package/prompts/stages/conflict-resolution.md +4 -4
  150. package/prompts/stages/objective-author/adopt.md +1 -1
  151. package/prompts/stages/objective-author/file.md +1 -1
  152. package/prompts/stages/objective-author/seed.md +1 -1
  153. package/prompts/stages/objective-plan/guidance.md +2 -2
  154. package/prompts/stages/objective-plan/seed.md +9 -1
  155. package/prompts/stages/objective-reconcile-ready.md +7 -0
  156. package/prompts/stages/objective-reconcile.md +1 -1
  157. package/prompts/stages/objective-refine/seed.md +18 -0
  158. package/prompts/stages/objective-review-browser.md +4 -4
  159. package/prompts/stages/objective-sync.md +1 -1
  160. package/prompts/stages/plan-review-browser.md +4 -4
  161. package/prompts/stages/pr-review-browser/active.md +3 -4
  162. package/prompts/stages/pr-review-browser/foreign.md +3 -4
  163. package/prompts/stages/pr-review-terminal/active.md +3 -3
  164. package/prompts/stages/pr-review-terminal/foreign.md +3 -3
  165. package/prompts/stages/pr-review.md +3 -3
  166. package/prompts/stages/stack-review/cold.md +1 -0
  167. package/prompts/stages/stack-review-browser/stack.md +22 -0
  168. package/shared/README.md +8 -3
  169. package/shared/bindings.yaml +6 -3
  170. package/shared/contracts.md +4499 -2147
  171. package/shared/fixtures/issues-table.json +130 -0
  172. package/shared/registry.yaml +29 -1
  173. package/shared/schemas/outputs/objective-node-engagement.schema.json +318 -0
  174. package/shared/schemas/outputs/objective-stack-status.schema.json +178 -2
  175. package/shared/schemas/outputs/pr-ready.schema.json +110 -2
  176. package/shared/schemas/outputs/pr-review-context.schema.json +54 -9
  177. package/shared/schemas/outputs/pr-review-stack-context.schema.json +196 -0
  178. package/extension/adapters/planAdapterPlannotator.ts +0 -362
  179. package/extension/doors/auditWaveTools.ts +0 -352
  180. package/extension/doors/ciExecutor.ts +0 -756
  181. package/extension/doors/commitCompact.ts +0 -251
  182. package/extension/doors/dreamWaveTools.ts +0 -475
  183. package/extension/doors/learn.ts +0 -655
  184. package/extension/doors/objectiveStack.ts +0 -1143
  185. package/extension/doors/prReviewDynamic.ts +0 -276
  186. package/extension/doors/ready.ts +0 -87
  187. package/extension/doors/submit.ts +0 -347
  188. package/extension/doors/submitPrReview.ts +0 -408
  189. package/extension/factories/gistAuthor.ts +0 -94
  190. package/extension/factories/gistDraft.ts +0 -265
  191. package/extension/factories/gistSave.ts +0 -251
  192. package/extension/factories/implementHere.ts +0 -116
  193. package/extension/factories/objectiveAuthor.ts +0 -98
  194. package/extension/factories/objectiveDraft.ts +0 -466
  195. package/extension/factories/objectivePlan.ts +0 -975
  196. package/extension/factories/objectiveSave.ts +0 -363
  197. package/extension/factories/planDraft.ts +0 -140
  198. package/extension/factories/planMode.ts +0 -205
  199. package/extension/factories/planReview.ts +0 -1237
  200. package/extension/factories/planSave.ts +0 -604
  201. package/extension/factories/planTitle.ts +0 -141
  202. package/extension/substrate/structuredOutput.ts +0 -202
  203. package/extension/waves/auditWave.ts +0 -312
  204. package/extension/waves/harvestWave.ts +0 -399
  205. package/extension/waves/learnWave.ts +0 -155
  206. package/extension/waves/memoryAdapter.ts +0 -139
  207. package/extension/waves/prReviewDynamicWave.ts +0 -777
  208. package/extension/worker/readOnlySession.ts +0 -294
  209. package/extension/worker/worker.ts +0 -899
  210. package/prompts/stages/pr-review-dynamic.md +0 -7
  211. package/shared/contracts-history.md +0 -605
@@ -1,56 +1,88 @@
1
1
  // The Perk-owned report-wave module: bounded sets of fresh-context, report-only children with
2
- // typed outcomes under stable lane keys. Report waves were previously model-authored prompt
3
- // mechanics (a script skeleton the parent model had to transcribe faithfully — the known
2
+ // typed outcomes under stable assignment keys. Report waves were previously model-authored
3
+ // prompt 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
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
8
  // flow-specific completeness policy. Each launch additionally records an OUTPUT-FREE
9
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).
10
+ // payload) — correlation telemetry, not policy input. Complete runs use the durable aggregate;
11
+ // explicit native partial settlement may retain keyed child reports, always beside its wave
12
+ // failure. Receipt absence never changes verdict/completeness or retry selection (§8.35).
13
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).
14
+ // This is the LOGICAL tier: assignments (`ReportAssignment`), preflight partitioning,
15
+ // aggregate normalization, and the completeness policy. The TRANSPORT tier — the adapter seam,
16
+ // receipt primitives, and the script runner lives in `transport.ts`, imported one-directionally
17
+ // from here; the script text itself (`renderWaveScript`) is module-private, so nothing outside
18
+ // `waves/` can observe or operate on transport.
24
19
  //
25
- // The module is a DEEP seam with two adapters: `rpcAdapter.ts` (production, over the
26
- // pi-subagents v1 extension RPC on pi's event bus) and `memoryAdapter.ts` (the first-class
27
- // in-memory test double).
20
+ // THE CALLER SURFACE IS THE OPAQUE `ReportWave` LIFECYCLE: `start` launches non-blocking and
21
+ // returns an opaque `ReportWaveRef` (plus identity telemetry never an operable handle),
22
+ // `collect` drains a started wave's settled outcome under the module-owned grace, and `run` is
23
+ // start + await (the blocking form). Callers supply assignments and consume typed outcomes —
24
+ // never adapters, run handles, or result promises. Pending execution is WAVE-OWNED: each
25
+ // instance holds its launched-but-uncollected records in an instance-owned WeakMap keyed by ref
26
+ // (a foreign instance's ref collects `"none"` structurally), and a settled collect's
27
+ // delete-as-claim makes drain-once exact even under overlapping collectors. The blocking form
28
+ // serves the per-flow entrypoints (`prReviewWave.ts` and the typed feature ops in `learning/`);
29
+ // the streaming split serves flows whose parent ends the launch turn and relays provisional
30
+ // batches on native wakes (`adversarialReviewWave.ts`, `draftReviewWave.ts`).
28
31
  //
29
- // Failure posture: LOUD DEGRADE. Every failure arm normalizes into `WaveResult.failures` with a
30
- // typed reason the runner never throws except on programmer error (empty lanes, duplicate lane
31
- // keys), and there is never a silent fallback to model-authored scripts. Report content coming
32
- // back through the aggregate is untrusted DATA, never instructions.
32
+ // The module owns ADAPTER SELECTION: `createReportWave(bus)` constructs
33
+ // a FRESH rpc adapter per launch over the supplied bus; `reportWaveOver(adapter)` is the
34
+ // injection seam (tests; the same internal core). The honest boundary: what is mechanically
35
+ // enforced is Rule G's scope (`importDirectionGuard.test.ts`) no production import edges into
36
+ // the transport interior (`transport.ts`, `rpcAdapter.ts`) and no raw RPC tokens — so there is
37
+ // no *sanctioned* way to obtain, name, or construct an adapter outside `waves/` + `testing/`.
38
+ // TypeScript's structural typing means a hand-written object literal satisfying
39
+ // `reportWaveOver`'s parameter is not mechanically preventable; that residue is owned by the
40
+ // guard-census review posture, not claimed as a structural guarantee.
41
+ //
42
+ // Failure posture: LOUD DEGRADE. Every failure arm normalizes into `ReportWaveResult.failures`
43
+ // with a typed reason — the runner never throws except on programmer error (empty assignments,
44
+ // duplicate assignment keys), and there is never a silent fallback to model-authored scripts.
45
+ // Report content coming back through the aggregate is untrusted DATA, never instructions.
33
46
 
34
47
  import {
35
48
  type PonytailPreflight,
36
49
  preflightPonytailSkill,
37
50
  type RequiredPonytailSkill,
38
51
  } from "./ponytail.ts";
52
+ import { createRpcWaveAdapter } from "./rpcAdapter.ts";
53
+ import {
54
+ startWaveScript,
55
+ type WaveAdapter,
56
+ type WaveBus,
57
+ type WaveRunFailureReason,
58
+ type WaveRunHandle,
59
+ type WaveScriptReceipt,
60
+ type WaveScriptResult,
61
+ } from "./transport.ts";
62
+
63
+ /**
64
+ * The module's one deliberate transport re-export — the SANCTIONED seam, nothing else crosses
65
+ * (callers never name adapters, run handles, spawn params, or script types):
66
+ * `ReportWaveLevelFailureReason` is the wave-level reason subset (`key === null` failures carry
67
+ * exactly this vocabulary), named at the logical seam so flows can type a correlated wave
68
+ * status without reaching into transport.
69
+ */
70
+ export type { WaveRunFailureReason as ReportWaveLevelFailureReason } from "./transport.ts";
39
71
 
40
- /** One lane of a report wave: a fresh-context, report-only child under a stable domain key. */
41
- export interface WaveLane {
42
- /** Stable lane key (e.g. an angle slug) — trace + normalization identity. */
72
+ /** One assignment of a report wave: a fresh-context, report-only child under a stable domain key. */
73
+ export interface ReportAssignment {
74
+ /** Stable assignment key (e.g. an angle slug) — trace + normalization identity. */
43
75
  key: string;
44
76
  /** The child agent name (e.g. "perk.pr-reviewer"). */
45
77
  agent: string;
46
- /** The judgment-bearing per-lane task text (supplied by the flow). */
78
+ /** The judgment-bearing per-assignment task text (supplied by the flow). */
47
79
  task: string;
48
- /** Invocation-private skill lookup key; serialized only for an opted-in lane. */
80
+ /** Invocation-private skill lookup key; serialized only for an opted-in assignment. */
49
81
  skill?: string;
50
82
  /**
51
83
  * Exact source requirement for a source-bound skill. This metadata is preflight-only and is
52
- * NEVER serialized into the workflow script; a failed requirement skips this lane instead of
53
- * allowing pi-subagents to resolve a hostile same-named global/project skill.
84
+ * NEVER serialized into the workflow script; a failed requirement skips this assignment
85
+ * instead of allowing pi-subagents to resolve a hostile same-named global/project skill.
54
86
  */
55
87
  requiredSkill?: RequiredPonytailSkill;
56
88
  /** Trace metadata; defaults to `key`. */
@@ -58,8 +90,8 @@ export interface WaveLane {
58
90
  /** Trace metadata. */
59
91
  phase?: string;
60
92
  /**
61
- * Per-lane report schema — rendered as the item's `outputSchema`, overriding the
62
- * workflow-level default (the established per-item mechanic). Omitted lanes render
93
+ * Per-assignment report schema — rendered as the item's `outputSchema`, overriding the
94
+ * workflow-level default (the established per-item mechanic). Omitted assignments render
63
95
  * byte-identically to before the field existed.
64
96
  */
65
97
  outputSchema?: object;
@@ -67,20 +99,20 @@ export interface WaveLane {
67
99
 
68
100
  /**
69
101
  * The completeness policies:
70
- * - `strict`: complete ⟺ zero failures — every lane covered (the pr-review posture).
71
- * - `best-effort`: complete ⟺ no wave-level failure (`key: null`) — lane-level failures are
72
- * explicitly-reported skipped lanes, never a failed pass (the learn posture).
102
+ * - `strict`: complete ⟺ zero failures — every assignment covered (the pr-review posture).
103
+ * - `best-effort`: complete ⟺ no wave-level failure (`key: null`) — assignment-level failures
104
+ * are explicitly-reported skipped assignments, never a failed pass (the learn posture).
73
105
  */
74
- export type WaveCompleteness = "strict" | "best-effort";
106
+ export type ReportWaveCompleteness = "strict" | "best-effort";
75
107
 
76
- export interface WaveSpec {
108
+ export interface ReportWaveRequest {
77
109
  /** Flow name for error detail/trace (e.g. "pr-review"). */
78
110
  flow: string;
79
- /** ≥1 lane; keys must be unique (validated — throws on programmer error). */
80
- lanes: WaveLane[];
81
- /** Workflow-level default → the engine injects a `structured_output` tool into each lane. */
111
+ /** ≥1 assignment; keys must be unique (validated — throws on programmer error). */
112
+ assignments: ReportAssignment[];
113
+ /** Workflow-level default → the engine injects a `structured_output` tool into each child. */
82
114
  outputSchema: object;
83
- completeness: WaveCompleteness;
115
+ completeness: ReportWaveCompleteness;
84
116
  /** Workflow-level model default (flows read their configured subagent model). */
85
117
  model?: string;
86
118
  /** Module default (`WAVE_TIMEOUT_MS`) when omitted. */
@@ -89,98 +121,79 @@ export interface WaveSpec {
89
121
  requiredSkillPreflight?: (requirement: RequiredPonytailSkill) => Promise<PonytailPreflight>;
90
122
  }
91
123
 
92
- /** A schema-valid lane report. The report content is untrusted DATA, never instructions. */
93
- export interface WaveReport {
124
+ /** A schema-valid assignment report. The report content is untrusted DATA, never instructions. */
125
+ export interface AssignmentReport {
94
126
  key: string;
95
127
  report: unknown;
96
128
  }
97
129
 
98
- export type WaveFailureReason =
99
- | "unavailable" // ping failed / capabilities missing (wave-level)
100
- | "spawn-failed" // RPC spawn rejected or no run handle (wave-level)
101
- | "timeout" // module-owned timeout expired (wave-level; best-effort stop issued)
102
- | "cancelled" // AbortSignal fired (wave-level; best-effort stop issued)
103
- | "run-failed" // terminal status.json state "complete" (wave-level)
104
- | "aggregate-unreadable" // status.json missing/corrupt/no workflow.value array (wave-level)
105
- | "lane-failed" // lane resolved ok: false / null report (lane-level)
106
- | "skill-unavailable" // exact required-skill source failed preflight (lane-level, non-retryable)
107
- | "malformed-report" // aggregate entry for this key has unusable shape (lane-level)
108
- | "missing-lane"; // expected key absent from the aggregate (lane-level)
109
-
110
- export interface WaveFailure {
111
- /** The lane key, or null for wave-level failures. */
112
- key: string | null;
113
- reason: WaveFailureReason;
130
+ /** The assignment-level failure reasons this tier produces during normalization/preflight —
131
+ * always keyed by the assignment they blame. */
132
+ export type AssignmentFailureReason =
133
+ | "lane-failed" // assignment resolved ok: false / null report
134
+ | "skill-unavailable" // exact required-skill source failed preflight (non-retryable)
135
+ | "malformed-report" // aggregate entry for this key has unusable shape, or the key appears more than once
136
+ | "missing-lane"; // expected key absent from the aggregate
137
+
138
+ /**
139
+ * The caller-facing failure vocabulary: the transport tier's wave-level subset
140
+ * (`WaveRunFailureReason` always `key: null`) widened with the assignment-level reasons this
141
+ * tier produces during normalization. The subset union keeps the split one-directional with
142
+ * zero runtime mapping.
143
+ */
144
+ export type ReportWaveFailureReason = WaveRunFailureReason | AssignmentFailureReason;
145
+
146
+ /** A wave-level failure: the whole run failed, so there is no assignment to blame — the same
147
+ * record shape as the transport tier's `WaveRunFailure` (script failures flow upward with zero
148
+ * runtime mapping). */
149
+ export interface ReportWaveLevelFailure {
150
+ key: null;
151
+ reason: WaveRunFailureReason;
114
152
  /** Human-readable diagnosis (error strings routed here, never re-thrown). */
115
153
  detail: string;
116
154
  }
117
155
 
118
- export interface WaveResult {
156
+ /** An assignment-level failure, keyed by the assignment it blames. */
157
+ export interface AssignmentFailure {
158
+ key: string;
159
+ reason: AssignmentFailureReason;
160
+ detail: string;
161
+ }
162
+
163
+ /**
164
+ * One wave failure — a DISCRIMINATED union on `key`: `key === null` narrows the reason to
165
+ * exactly the wave-level subset (and a string key to the assignment-level reasons), so a
166
+ * wave-level failure carrying an assignment reason — or vice versa — is unrepresentable and
167
+ * every flow inherits the correlation without reimplementing transport knowledge.
168
+ */
169
+ export type ReportWaveFailure = ReportWaveLevelFailure | AssignmentFailure;
170
+
171
+ export interface ReportWaveResult {
119
172
  complete: boolean;
120
- reports: WaveReport[];
121
- failures: WaveFailure[];
173
+ reports: AssignmentReport[];
174
+ failures: ReportWaveFailure[];
122
175
  /** The launch's output-free attempt receipt — write-only telemetry, never a decision input. */
123
176
  receipt: WaveScriptReceipt;
124
177
  }
125
178
 
126
- /** The truthful preflight partition reported by every streaming report-wave start. */
127
- export type WaveLaunchManifest = {
128
- /** The complete logical lane manifest, in `spec.lanes` order. */
179
+ /** The truthful preflight partition reported by every report-wave start. */
180
+ export type ReportWaveLaunchManifest = {
181
+ /** The complete logical assignment manifest, in `request.assignments` order. */
129
182
  requested: string[];
130
183
  /** The ordered subset rendered into the static workflow after required-skill preflight. */
131
184
  runnable: string[];
132
- /** One ordered keyed `skill-unavailable` failure per preflight-omitted lane. */
133
- preflightFailures: WaveFailure[];
185
+ /** One ordered keyed `skill-unavailable` failure per preflight-omitted assignment. */
186
+ preflightFailures: ReportWaveFailure[];
134
187
  };
135
188
 
136
189
  // -------------------------------------------------------------------- the attempt receipts
137
190
 
138
- /**
139
- * The terminal disposition of ONE top-level workflow launch, as the runner observed it. Every
140
- * launch reaches exactly one of these arms (`"running"` is unreachable — the runner always
141
- * settles); `"unavailable"` preserves even a pre-spawn capability failure as an attempt.
142
- */
143
- export type WaveReceiptState =
144
- | "unavailable" // ping failed/incomplete — nothing launched
145
- | "spawn-failed" // spawn rejected/threw — no run handle
146
- | "complete" // completion observed, durable state "complete"
147
- | "failed" // completion observed, durable/observed failure
148
- | "timed-out" // module timeout expired (handle preserved)
149
- | "cancelled"; // AbortSignal honored (handle preserved when spawned)
150
-
151
- /**
152
- * One child lane's identity/artifact trail from the completion payload — OUTPUT-FREE by
153
- * invariant: reports, summaries, and structured output never enter a receipt (they stay in the
154
- * durable `status.json.workflow.value`, the sole report authority).
155
- */
156
- export interface WaveChildReceipt {
157
- /** The Perk lane key (mapped FROM the upstream row's overloaded `agent` field). */
158
- key: string;
159
- /** The child agent name, enriched from the Perk-owned lane spec where known. */
160
- agent?: string;
161
- /** The child's opaque run id — never parsed or synthesized from paths. */
162
- runId?: string;
163
- success?: boolean;
164
- outputState?: "present" | "absent" | "unknown";
165
- /** String path fields only; output-free. */
166
- artifactPaths?: Record<string, string>;
167
- }
168
-
169
- /** One script launch's receipt: the run handle (where known) + the observed children. */
170
- export interface WaveScriptReceipt {
171
- /** The top-level async run id (the spawn handle's asyncId). */
172
- runId?: string;
173
- asyncDir?: string;
174
- state: WaveReceiptState;
175
- children: WaveChildReceipt[];
176
- }
177
-
178
191
  /**
179
192
  * A flow-attributed attempt: one receipt per top-level workflow launch, ordered by `attempt`
180
193
  * (one-based, assigned by the flow entrypoint that owns retry policy). `requestedKeys` is the
181
- * lane manifest BEFORE launch — never reconstructed from the observed children.
194
+ * assignment manifest BEFORE launch — never reconstructed from the observed children.
182
195
  */
183
- export interface WaveAttemptReceipt extends WaveScriptReceipt {
196
+ export interface ReportWaveAttemptReceipt extends WaveScriptReceipt {
184
197
  flow: string;
185
198
  attempt: number;
186
199
  requestedKeys: string[];
@@ -190,91 +203,12 @@ export interface WaveAttemptReceipt extends WaveScriptReceipt {
190
203
  export function toAttemptReceipt(
191
204
  flow: string,
192
205
  attempt: number,
193
- requestedKeys: string[],
206
+ requestedKeys: readonly string[],
194
207
  receipt: WaveScriptReceipt,
195
- ): WaveAttemptReceipt {
208
+ ): ReportWaveAttemptReceipt {
196
209
  return { flow, attempt, requestedKeys: [...requestedKeys], ...receipt };
197
210
  }
198
211
 
199
- // ------------------------------------------------------------------------- the adapter seam
200
-
201
- /** The minimal pi event-bus surface an adapter needs (mirrors pi's EventBus, whose `on` returns an unsubscribe function). */
202
- export interface WaveBus {
203
- emit(channel: string, data: unknown): void;
204
- on(channel: string, handler: (data: unknown) => void): () => void;
205
- }
206
-
207
- /** A successful capability ping; `asyncCompleteEvent` is the ADVERTISED async-complete channel. */
208
- export interface WavePing {
209
- asyncCompleteEvent: string;
210
- }
211
-
212
- /** The detached async run a spawn launched. */
213
- export interface WaveRunHandle {
214
- asyncId: string;
215
- asyncDir: string;
216
- }
217
-
218
- /**
219
- * An async-complete notification; at least one identifier is present on real payloads. The
220
- * observability fields are optional — an identity-only completion stays valid (receipt absence
221
- * degrades correlation, never behavior). The adapter normalizes them output-free and leaves
222
- * each child's `agent` unset (enrichment happens against Perk-owned lane specs).
223
- */
224
- export interface WaveCompletion {
225
- asyncId?: string;
226
- asyncDir?: string;
227
- /** The run's raw terminal state string, when the payload carries one. */
228
- state?: string;
229
- success?: boolean;
230
- children?: WaveChildReceipt[];
231
- }
232
-
233
- /**
234
- * The explicit acceptance-disable every wave spawn carries. Without it, pi-subagents
235
- * auto-infers a generic acceptance contract for reviewer/analyst-named or read-only children
236
- * and injects a fenced `acceptance-report` completion instruction into each lane — a COMPETING
237
- * completion contract observed steering children into invalid `structured_output` attempts.
238
- * `{level: "none"}` is the sanctioned disable shape (pi-subagents `explicitAcceptanceCanDisable`);
239
- * `formatAcceptancePrompt` emits nothing at level none, so no contract block reaches a lane.
240
- * Deliberately module-wide with no opt-out: every report-wave child's sole completion contract
241
- * is the engine-validated `structured_output` report.
242
- */
243
- export const WAVE_ACCEPTANCE = {
244
- level: "none",
245
- reason: "perk report-wave lanes complete via the engine-validated structured_output report",
246
- } as const;
247
-
248
- /** The full spawn params the runner fixes: async-only, ephemeral, fresh-context by definition. */
249
- export interface WaveSpawnParams {
250
- workflowScript: string;
251
- async: true;
252
- /** Waves are ephemeral by explicit decision — never mission-attached. */
253
- mission: false;
254
- /** A report wave is by definition fresh-context. */
255
- context: "fresh";
256
- /** The fixed acceptance disable (`WAVE_ACCEPTANCE`) — pi-subagents' workflow-defaults spread
257
- * delivers it onto every lane child, suppressing the auto-inferred acceptance contract. */
258
- acceptance: { level: "none"; reason: string };
259
- outputSchema: object;
260
- model?: string;
261
- /** Orphan insurance: the run enforces the same deadline even if the parent session dies. */
262
- timeoutMs: number;
263
- }
264
-
265
- export interface WaveAdapter {
266
- /** Capability-checked ping; null ⇒ unavailable (loud degrade upstream). Must be called first. */
267
- ping(): Promise<WavePing | null>;
268
- /** Launch the async workflowScript run; throws ⇒ spawn-failed. */
269
- spawn(params: WaveSpawnParams): Promise<WaveRunHandle>;
270
- /** Subscribe to run completions (any run — the runner matches the handle); returns unsubscribe. */
271
- onComplete(handler: (completion: WaveCompletion) => void): () => void;
272
- /** Best-effort stop of a live run (timeout/cancel path); never throws. */
273
- stop(handle: WaveRunHandle): Promise<void>;
274
- /** Read the run's durable aggregate; throws ⇒ aggregate-unreadable. */
275
- readAggregate(handle: WaveRunHandle): Promise<{ state: string; error?: string; value: unknown }>;
276
- }
277
-
278
212
  // ---------------------------------------------------------------------------- the renderer
279
213
 
280
214
  /**
@@ -287,41 +221,55 @@ export interface WaveAdapter {
287
221
  export const RUN_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
288
222
 
289
223
  /**
290
- * Render the wave `workflowScript`: an explicit-return, all-settled `runs.all` over the lane
291
- * items, projected to the compact typed aggregate only (lane key, outcome, error, and the
292
- * schema-validated report — children's prose never enters the aggregate beyond `error`/`output`
293
- * on failure). Lane items are embedded via `JSON.stringify`, so hostile task text (quotes,
294
- * newlines, backticks, `${}`) cannot escape the array literal. Throws on programmer error:
295
- * empty lanes, duplicate lane keys, or a lane key outside the run-key contract.
224
+ * The report restriction packet (contracts.md §8.35): a constant every report child receives.
225
+ * With the runner bit it is the whole authorization input for the child's read-only floor.
226
+ */
227
+ const REPORT_CHILD_RESTRICTIONS = { "perk.parent-restrictions/1": { readOnly: true } } as const;
228
+
229
+ /**
230
+ * Validate the assignment manifest (throws on programmer error: empty, duplicate keys, or a key
231
+ * outside the run-key contract). Module-private — the script surface never leaves `waves/`.
296
232
  */
297
- function validateWaveLanes(lanes: WaveLane[]): void {
298
- if (lanes.length === 0) {
233
+ function validateAssignments(assignments: ReportAssignment[]): void {
234
+ if (assignments.length === 0) {
299
235
  throw new Error("renderWaveScript: a report wave needs at least one lane");
300
236
  }
301
237
  const seen = new Set<string>();
302
- for (const lane of lanes) {
303
- if (seen.has(lane.key)) {
304
- throw new Error(`renderWaveScript: duplicate lane key '${lane.key}'`);
238
+ for (const assignment of assignments) {
239
+ if (seen.has(assignment.key)) {
240
+ throw new Error(`renderWaveScript: duplicate lane key '${assignment.key}'`);
305
241
  }
306
- if (!RUN_KEY_PATTERN.test(lane.key)) {
242
+ if (!RUN_KEY_PATTERN.test(assignment.key)) {
307
243
  throw new Error(
308
- `renderWaveScript: lane key '${lane.key}' violates the pi-subagents run-key contract`,
244
+ `renderWaveScript: lane key '${assignment.key}' violates the pi-subagents run-key contract`,
309
245
  );
310
246
  }
311
- seen.add(lane.key);
247
+ seen.add(assignment.key);
312
248
  }
313
249
  }
314
250
 
315
- export function renderWaveScript(lanes: WaveLane[]): string {
316
- validateWaveLanes(lanes);
317
- const items = lanes.map((lane) => ({
318
- key: lane.key,
319
- agent: lane.agent,
320
- task: lane.task,
321
- ...(lane.skill !== undefined ? { skill: lane.skill } : {}),
322
- label: lane.label ?? lane.key,
323
- ...(lane.phase !== undefined ? { phase: lane.phase } : {}),
324
- ...(lane.outputSchema !== undefined ? { outputSchema: lane.outputSchema } : {}),
251
+ /**
252
+ * Render the wave `workflowScript`: an explicit-return, all-settled `runs.all` over the
253
+ * assignment items, projected to the compact typed aggregate only (assignment key, outcome,
254
+ * error, and the schema-validated report — children's prose never enters the aggregate beyond
255
+ * `error`/`output` on failure). Every report child is read-only under perk's floor and runs in
256
+ * the caller checkout; hostile task/assignment fields cannot override either (explicit field
257
+ * selection + `JSON.stringify`, so hostile task text quotes, newlines, backticks, `${}` —
258
+ * cannot escape the array literal either). Module-private: the script bytes are observable
259
+ * outside `waves/` only through the adapter seam's spawn params.
260
+ */
261
+ function renderWaveScript(assignments: ReportAssignment[]): string {
262
+ validateAssignments(assignments);
263
+ const items = assignments.map((assignment) => ({
264
+ key: assignment.key,
265
+ agent: assignment.agent,
266
+ task: assignment.task,
267
+ extensionBindings: REPORT_CHILD_RESTRICTIONS,
268
+ worktree: false,
269
+ ...(assignment.skill !== undefined ? { skill: assignment.skill } : {}),
270
+ label: assignment.label ?? assignment.key,
271
+ ...(assignment.phase !== undefined ? { phase: assignment.phase } : {}),
272
+ ...(assignment.outputSchema !== undefined ? { outputSchema: assignment.outputSchema } : {}),
325
273
  }));
326
274
  return (
327
275
  `const reports = await runs.all(${JSON.stringify(items, null, 2)});\n` +
@@ -332,126 +280,86 @@ export function renderWaveScript(lanes: WaveLane[]): string {
332
280
 
333
281
  // ------------------------------------------------------------------------------- the runner
334
282
 
335
- /**
336
- * The module-owned wave timeout default: a deliberate tightening vs the 30-minute foreground
337
- * default the prompt-mechanics wave rode. Per-flow `spec.timeoutMs` overrides; the default is
338
- * overridable for tests via PERK_WAVE_TIMEOUT_MS.
339
- */
340
- export const WAVE_TIMEOUT_MS = 15 * 60_000;
341
-
342
- function waveTimeoutMs(): number {
343
- const raw = Number(process.env.PERK_WAVE_TIMEOUT_MS ?? "");
344
- return Number.isFinite(raw) && raw > 0 ? raw : WAVE_TIMEOUT_MS;
345
- }
346
-
347
283
  function waveFailure(
348
- reason: WaveFailureReason,
284
+ reason: WaveRunFailureReason,
349
285
  detail: string,
350
286
  receipt: WaveScriptReceipt,
351
- ): WaveResult {
287
+ ): ReportWaveResult {
352
288
  return { complete: false, reports: [], failures: [{ key: null, reason, detail }], receipt };
353
289
  }
354
290
 
355
- /** The judgment-bearing pieces a script run needs (the lane-free slice of `WaveSpec`). */
356
- export interface WaveScriptSpec {
357
- /** Flow name for error detail/trace (e.g. "pr-review-dynamic"). */
358
- flow: string;
359
- /** The complete, module-rendered workflowScript (never model-authored). */
360
- workflowScript: string;
361
- /** Workflow-level default → the engine injects a `structured_output` tool into each child. */
362
- outputSchema: object;
363
- /** Workflow-level model default (per-item `model` fields in the script override it). */
364
- model?: string;
365
- /** Module default (`WAVE_TIMEOUT_MS`) when omitted. */
366
- timeoutMs?: number;
367
- }
368
-
369
- /** A script run's outcome: the raw `workflow.value` on success, one wave-level failure otherwise. */
370
- export type WaveScriptResult =
371
- | { ok: true; value: unknown; receipt: WaveScriptReceipt }
372
- | { ok: false; failure: WaveFailure; receipt: WaveScriptReceipt };
373
-
374
- /**
375
- * A launched (or launch-refused) script run. On `ok: true` the run is LIVE: `handle` is the
376
- * detached async run, and `result` settles when the back half finishes (completion wait under
377
- * the module-owned timeout, AbortSignal honor, best-effort stop on timeout/cancel, the durable
378
- * aggregate read, receipt assembly, unsubscribe-on-settle). `result` NEVER rejects — every arm
379
- * normalizes into `WaveScriptResult`, so an uncollected wave can never become an unhandled
380
- * rejection. Pre-spawn failures (aborted-before-launch, ping fail/null, spawn throw) take the
381
- * `ok: false` arm with the same failure/receipt values the blocking runner reports, and the
382
- * completion subscription is released immediately.
383
- */
384
- export type WaveScriptStart =
385
- | { ok: true; handle: WaveRunHandle; result: Promise<WaveScriptResult> }
386
- | { ok: false; failure: WaveFailure; receipt: WaveScriptReceipt };
387
-
388
- function errorDetail(error: unknown): string {
389
- return error instanceof Error ? error.message : String(error);
390
- }
391
-
392
291
  function isRecord(value: unknown): value is Record<string, unknown> {
393
292
  return typeof value === "object" && value !== null && !Array.isArray(value);
394
293
  }
395
294
 
396
295
  /**
397
- * Normalize the aggregate's entries against the expected lane keys (defensive — the module
398
- * rendered the script, but the aggregate crossed a process boundary). Unknown extra keys are
399
- * ignored: the module owns the script, so extras cannot occur without upstream drift, and the
400
- * per-lane reasons below already make the wave incomplete under `strict`. Exported for the
401
- * per-flow entrypoints whose scripts produce the same compact lane projection (e.g. the
402
- * dynamic-review sibling normalizing against its runtime-selected keys).
296
+ * Normalize the aggregate's entries against the expected assignment keys (defensive — the
297
+ * module rendered the script, but the aggregate crossed a process boundary). Unknown extra keys
298
+ * are ignored: the module owns the script, so extras cannot occur without upstream drift, and
299
+ * the per-assignment reasons below already make the wave incomplete under `strict`. A key
300
+ * appearing more than once is ambiguous identity: `malformed-report`, every row withheld (§8.35).
301
+ * Module-private: the wave's settle is the only consumer.
403
302
  */
404
- export function normalizeLanes(
303
+ function normalizeAssignments(
405
304
  keys: string[],
406
305
  entries: unknown[],
407
- ): { reports: WaveReport[]; failures: WaveFailure[] } {
408
- const reports: WaveReport[] = [];
409
- const failures: WaveFailure[] = [];
306
+ ): { reports: AssignmentReport[]; failures: ReportWaveFailure[] } {
307
+ const reports: AssignmentReport[] = [];
308
+ const failures: ReportWaveFailure[] = [];
410
309
  for (const key of keys) {
411
- const lane = { key };
412
- const entry = entries.find((e) => isRecord(e) && e.key === lane.key);
413
- if (!isRecord(entry)) {
310
+ const rows = entries.filter((e): e is Record<string, unknown> => isRecord(e) && e.key === key);
311
+ if (rows.length > 1) {
312
+ // Duplicate expected keys are ambiguous identity: withhold every row (§8.35).
313
+ failures.push({
314
+ key,
315
+ reason: "malformed-report",
316
+ detail: `lane '${key}' appears ${rows.length} times in the wave aggregate — ambiguous identity, evidence withheld`,
317
+ });
318
+ continue;
319
+ }
320
+ const entry = rows[0];
321
+ if (entry === undefined) {
414
322
  failures.push({
415
- key: lane.key,
323
+ key,
416
324
  reason: "missing-lane",
417
- detail: `lane '${lane.key}' is absent from the wave aggregate`,
325
+ detail: `lane '${key}' is absent from the wave aggregate`,
418
326
  });
419
327
  continue;
420
328
  }
421
329
  if (entry.ok === true) {
422
330
  const report = entry.report;
423
331
  if (isRecord(report)) {
424
- reports.push({ key: lane.key, report });
332
+ reports.push({ key, report });
425
333
  } else if (report === null || report === undefined) {
426
334
  failures.push({
427
- key: lane.key,
335
+ key,
428
336
  reason: "lane-failed",
429
337
  detail:
430
338
  typeof entry.error === "string" && entry.error !== ""
431
339
  ? entry.error
432
- : `lane '${lane.key}' resolved without a schema-valid report`,
340
+ : `lane '${key}' resolved without a schema-valid report`,
433
341
  });
434
342
  } else {
435
343
  failures.push({
436
- key: lane.key,
344
+ key,
437
345
  reason: "malformed-report",
438
- detail: `lane '${lane.key}' carries a non-object report (${Array.isArray(report) ? "array" : typeof report})`,
346
+ detail: `lane '${key}' carries a non-object report (${Array.isArray(report) ? "array" : typeof report})`,
439
347
  });
440
348
  }
441
349
  } else if (entry.ok === false) {
442
350
  failures.push({
443
- key: lane.key,
351
+ key,
444
352
  reason: "lane-failed",
445
353
  detail:
446
354
  typeof entry.error === "string" && entry.error !== ""
447
355
  ? entry.error
448
- : `lane '${lane.key}' failed without error detail`,
356
+ : `lane '${key}' failed without error detail`,
449
357
  });
450
358
  } else {
451
359
  failures.push({
452
- key: lane.key,
360
+ key,
453
361
  reason: "malformed-report",
454
- detail: `lane '${lane.key}' aggregate entry has no boolean 'ok'`,
362
+ detail: `lane '${key}' aggregate entry has no boolean 'ok'`,
455
363
  });
456
364
  }
457
365
  }
@@ -459,258 +367,32 @@ export function normalizeLanes(
459
367
  }
460
368
 
461
369
  /**
462
- * Start one module-rendered workflowScript through the adapter the non-blocking front half:
463
- * capability ping subscribe-before-spawn (the completion-before-reply buffer) async spawn.
464
- * On success the back half (block on the async-complete event under the module-owned timeout,
465
- * abortable → best-effort stop on timeout/cancel → read the durable aggregate → the
466
- * `state !== "complete"` / unreadable arms) runs behind the returned `result` promise, which
467
- * never rejects. The shared operational core under every runner; per-flow value normalization
468
- * stays with the caller.
469
- */
470
- export async function startWaveScript(
471
- adapter: WaveAdapter,
472
- spec: WaveScriptSpec,
473
- signal?: AbortSignal,
474
- ): Promise<WaveScriptStart> {
475
- // The receipt is assembled in EVERY terminal arm — write-only telemetry: nothing below reads
476
- // it back into the ok/failure decision.
477
- const receiptOf = (
478
- state: WaveReceiptState,
479
- spawned: WaveRunHandle | null,
480
- completion?: WaveCompletion,
481
- ): WaveScriptReceipt => ({
482
- ...(spawned !== null ? { runId: spawned.asyncId, asyncDir: spawned.asyncDir } : {}),
483
- state,
484
- children: completion?.children ?? [],
485
- });
486
- const startFailure = (
487
- reason: WaveFailureReason,
488
- detail: string,
489
- receipt: WaveScriptReceipt,
490
- ): WaveScriptStart => ({
491
- ok: false,
492
- failure: { key: null, reason, detail },
493
- receipt,
494
- });
495
-
496
- // Read through a closure so TS's readonly-property narrowing never staples the first
497
- // check's `false` onto the post-await re-check (the signal CAN flip during an await).
498
- const aborted = (): boolean => signal?.aborted === true;
499
- const cancelledBeforeLaunch = (): WaveScriptStart =>
500
- startFailure(
501
- "cancelled",
502
- `wave '${spec.flow}' was cancelled before launch`,
503
- receiptOf("cancelled", null),
504
- );
505
-
506
- if (aborted()) return cancelledBeforeLaunch();
507
-
508
- // 1. Capability check — the loud-degrade arm: the result explicitly names the wave
509
- // unavailable; callers surface it, never silently fall back to model-authored scripts.
510
- let ping: WavePing | null;
511
- try {
512
- ping = await adapter.ping();
513
- } catch (error) {
514
- return startFailure(
515
- "unavailable",
516
- `subagent RPC ping failed: ${errorDetail(error)}`,
517
- receiptOf("unavailable", null),
518
- );
519
- }
520
- if (ping === null) {
521
- return startFailure(
522
- "unavailable",
523
- "pi-subagents did not advertise the report-wave capabilities (ping failed or incomplete)",
524
- receiptOf("unavailable", null),
525
- );
526
- }
527
-
528
- // An abort can arrive WHILE the ping await is pending — re-check before subscribe/spawn so a
529
- // cancelled wave never launches (the pre-launch check alone leaves this window open).
530
- if (aborted()) return cancelledBeforeLaunch();
531
-
532
- // 2. Subscribe BEFORE spawn: a completion can arrive before the spawn reply resolves (the
533
- // completion-before-reply race) — every completion is buffered and re-checked once the
534
- // handle is known.
535
- let handle: WaveRunHandle | null = null;
536
- let notifyMatch: (() => void) | null = null;
537
- const buffered: WaveCompletion[] = [];
538
- const matchesHandle = (completion: WaveCompletion): boolean =>
539
- handle !== null &&
540
- ((completion.asyncDir !== undefined && completion.asyncDir === handle.asyncDir) ||
541
- (completion.asyncId !== undefined && completion.asyncId === handle.asyncId));
542
- const unsubscribe = adapter.onComplete((completion) => {
543
- buffered.push(completion);
544
- if (matchesHandle(completion) && notifyMatch !== null) notifyMatch();
545
- });
546
-
547
- // 3. Spawn: async-only, ephemeral, fresh-context — the module fixes those; the flow's spec
548
- // supplies the judgment-bearing pieces (lanes, schema, model, policy).
549
- const timeoutMs = spec.timeoutMs ?? waveTimeoutMs();
550
- try {
551
- handle = await adapter.spawn({
552
- workflowScript: spec.workflowScript,
553
- async: true,
554
- mission: false,
555
- context: "fresh",
556
- acceptance: WAVE_ACCEPTANCE,
557
- outputSchema: spec.outputSchema,
558
- ...(spec.model !== undefined ? { model: spec.model } : {}),
559
- timeoutMs,
560
- });
561
- } catch (error) {
562
- unsubscribe();
563
- return startFailure(
564
- "spawn-failed",
565
- `wave spawn failed: ${errorDetail(error)}`,
566
- receiptOf("spawn-failed", null),
567
- );
568
- }
569
- const spawned = handle;
570
-
571
- const scriptFailure = (
572
- reason: WaveFailureReason,
573
- detail: string,
574
- receipt: WaveScriptReceipt,
575
- ): WaveScriptResult => ({
576
- ok: false,
577
- failure: { key: null, reason, detail },
578
- receipt,
579
- });
580
-
581
- // The back half: every arm below RETURNS a normalized `WaveScriptResult` (never throws), so
582
- // `result` never rejects; the subscription is released exactly when it settles.
583
- const settle = async (): Promise<WaveScriptResult> => {
584
- try {
585
- // 4. Block on completion with the module-owned timeout; honor the caller's AbortSignal.
586
- const outcome = await new Promise<"complete" | "timeout" | "cancelled">((resolve) => {
587
- if (buffered.some(matchesHandle)) {
588
- resolve("complete");
589
- return;
590
- }
591
- const settleOutcome = (value: "complete" | "timeout" | "cancelled"): void => {
592
- clearTimeout(timer);
593
- signal?.removeEventListener("abort", onAbort);
594
- notifyMatch = null;
595
- resolve(value);
596
- };
597
- const timer = setTimeout(() => settleOutcome("timeout"), timeoutMs);
598
- const onAbort = (): void => settleOutcome("cancelled");
599
- notifyMatch = () => settleOutcome("complete");
600
- signal?.addEventListener("abort", onAbort, { once: true });
601
- if (signal?.aborted === true) settleOutcome("cancelled");
602
- });
603
- if (outcome !== "complete") {
604
- // Best-effort stop — adapters never throw here by contract, but a broken adapter's error
605
- // is still swallowed into the detail rather than re-thrown.
606
- let stopNote = "";
607
- try {
608
- await adapter.stop(spawned);
609
- } catch (error) {
610
- stopNote = ` (stop failed: ${errorDetail(error)})`;
611
- }
612
- return outcome === "timeout"
613
- ? scriptFailure(
614
- "timeout",
615
- `wave '${spec.flow}' timed out after ${timeoutMs}ms${stopNote}`,
616
- receiptOf("timed-out", spawned),
617
- )
618
- : scriptFailure(
619
- "cancelled",
620
- `wave '${spec.flow}' was cancelled${stopNote}`,
621
- receiptOf("cancelled", spawned),
622
- );
623
- }
624
-
625
- // The MATCHED completion (retained for the receipt — its normalized children are the
626
- // child-lane identity/artifact trail; an identity-only completion yields empty children).
627
- const matched = buffered.find(matchesHandle);
628
-
629
- // 5. Read the durable aggregate; surface the terminal-state arms.
630
- let aggregate: { state: string; error?: string; value: unknown };
631
- try {
632
- aggregate = await adapter.readAggregate(spawned);
633
- } catch (error) {
634
- // Aggregate-unreadable: the completion identity is retained — the receipt state derives
635
- // from the OBSERVED completion (a correlation label, not a verdict; the authoritative
636
- // failure reason stays in the wave failure).
637
- return scriptFailure(
638
- "aggregate-unreadable",
639
- `wave aggregate unreadable: ${errorDetail(error)}`,
640
- receiptOf(matched?.success === false ? "failed" : "complete", spawned, matched),
641
- );
642
- }
643
- if (aggregate.state !== "complete") {
644
- const detail = aggregate.error !== undefined ? `: ${aggregate.error}` : "";
645
- return scriptFailure(
646
- "run-failed",
647
- `wave run ended '${aggregate.state}'${detail}`,
648
- receiptOf("failed", spawned, matched),
649
- );
650
- }
651
- return { ok: true, value: aggregate.value, receipt: receiptOf("complete", spawned, matched) };
652
- } finally {
653
- unsubscribe();
654
- }
655
- };
656
- return { ok: true, handle: spawned, result: settle() };
657
- }
658
-
659
- /**
660
- * Run one module-rendered workflowScript to completion — the blocking form: `startWaveScript` +
661
- * await its `result` (one operational core, behavior identical to the historical blocking
662
- * runner).
663
- */
664
- export async function runWaveScript(
665
- adapter: WaveAdapter,
666
- spec: WaveScriptSpec,
667
- signal?: AbortSignal,
668
- ): Promise<WaveScriptResult> {
669
- const start = await startWaveScript(adapter, spec, signal);
670
- if (!start.ok) return { ok: false, failure: start.failure, receipt: start.receipt };
671
- return await start.result;
672
- }
673
-
674
- /**
675
- * Enrich receipt children's `agent` from the Perk-owned lane specs by key. Children are never
676
- * synthesized from lanes — an identity-only completion keeps its empty children (receipt absence
677
- * degrades correlation, never behavior).
370
+ * Enrich receipt children's `agent` from the Perk-owned assignment specs by key. Children are
371
+ * never synthesized from assignments an identity-only completion keeps its empty children
372
+ * (receipt absence degrades correlation, never behavior).
678
373
  */
679
- function enrichReceipt(receipt: WaveScriptReceipt, lanes: WaveLane[]): WaveScriptReceipt {
374
+ function enrichReceipt(
375
+ receipt: WaveScriptReceipt,
376
+ assignments: ReportAssignment[],
377
+ ): WaveScriptReceipt {
680
378
  return {
681
379
  ...receipt,
682
380
  children: receipt.children.map((child) => {
683
381
  if (child.agent !== undefined) return child;
684
- const agent = lanes.find((lane) => lane.key === child.key)?.agent;
382
+ const agent = assignments.find((assignment) => assignment.key === child.key)?.agent;
685
383
  return agent === undefined ? child : { ...child, agent };
686
384
  }),
687
385
  };
688
386
  }
689
387
 
690
388
  /**
691
- * A launched (or launch-failed) report wave — the lane-level sibling of `WaveScriptStart`. On
692
- * `ok: true` the wave is LIVE: `result` settles into the normalized `WaveResult` (lane
693
- * normalization + completeness policy + receipt enrichment) and never rejects. On `ok: false`
694
- * the launch failure is already normalized into a `WaveResult` (receipt included) — no promise
695
- * to await, nothing left running.
696
- */
697
- export type ReportWaveStart =
698
- | {
699
- ok: true;
700
- handle: WaveRunHandle;
701
- result: Promise<WaveResult>;
702
- launch: WaveLaunchManifest;
703
- }
704
- | { ok: false; result: WaveResult; launch: WaveLaunchManifest };
705
-
706
- /**
707
- * Settle one script outcome into the lane-level `WaveResult`: receipt enrichment, the
708
- * workflow.value array check, per-lane-key normalization, and the completeness policy — the
709
- * single back half both the blocking runner and the streaming sibling apply.
389
+ * Settle one script outcome into the assignment-level `ReportWaveResult`: receipt enrichment,
390
+ * the workflow.value array check, per-key normalization, and the completeness policy — the
391
+ * single back half both the blocking and streaming lifecycles apply.
710
392
  */
711
- function settleReportWave(run: WaveScriptResult, spec: WaveSpec): WaveResult {
712
- const receipt = enrichReceipt(run.receipt, spec.lanes);
713
- if (!run.ok) {
393
+ function settleReportWave(run: WaveScriptResult, request: ReportWaveRequest): ReportWaveResult {
394
+ const receipt = enrichReceipt(run.receipt, request.assignments);
395
+ if (!run.ok && !Array.isArray(run.value)) {
714
396
  return { complete: false, reports: [], failures: [run.failure], receipt };
715
397
  }
716
398
  if (!Array.isArray(run.value)) {
@@ -721,64 +403,176 @@ function settleReportWave(run: WaveScriptResult, spec: WaveSpec): WaveResult {
721
403
  );
722
404
  }
723
405
 
724
- const { reports, failures } = normalizeLanes(
725
- spec.lanes.map((lane) => lane.key),
406
+ const normalized = normalizeAssignments(
407
+ request.assignments.map((assignment) => assignment.key),
726
408
  run.value,
727
409
  );
410
+ const { reports } = normalized;
411
+ // Retained evidence does not imply completion under either policy. Keep the transport
412
+ // failure first, followed by keyed normalization failures in request order.
413
+ const failures = run.ok ? normalized.failures : [run.failure, ...normalized.failures];
728
414
  const complete =
729
- spec.completeness === "strict"
415
+ request.completeness === "strict"
730
416
  ? failures.length === 0
731
417
  : failures.every((failure) => failure.key !== null);
732
418
  return { complete, reports, failures, receipt };
733
419
  }
734
420
 
421
+ // -------------------------------------------------------------------- the opaque lifecycle
422
+
423
+ declare const REPORT_WAVE_REF: unique symbol;
424
+
425
+ /**
426
+ * The opaque handle to one started, uncollected report wave — NOMINAL by a declared
427
+ * (value-less) unique-symbol brand: no structural forgery can satisfy it, and it exposes no
428
+ * operational members. Minted at exactly one internal site (immediately after the runtime
429
+ * evidence of a successful launch); meaningful only to the instance that minted it — a foreign
430
+ * instance's `collect` answers `"none"` structurally.
431
+ */
432
+ export interface ReportWaveRef {
433
+ readonly [REPORT_WAVE_REF]: true;
434
+ }
435
+
436
+ /** The optional per-call controls; `signal` cancels pre-launch and best-effort stops post-launch. */
437
+ export interface WaveControl {
438
+ signal?: AbortSignal;
439
+ }
440
+
441
+ /**
442
+ * A started (or launch-failed) report wave. On `ok: true` the wave is LIVE behind the opaque
443
+ * `ref` — `runId`/`asyncDir` are identity telemetry only (receipt vocabulary), never an
444
+ * operable handle. On `ok: false` the launch failure is already normalized into a
445
+ * `ReportWaveResult` (receipt included) — nothing left running, nothing to collect.
446
+ */
447
+ export type StartWaveResult =
448
+ | {
449
+ ok: true;
450
+ ref: ReportWaveRef;
451
+ /** Identity telemetry only (receipt vocabulary) — never an operable handle. */
452
+ runId: string;
453
+ asyncDir: string;
454
+ launch: ReportWaveLaunchManifest;
455
+ }
456
+ | { ok: false; result: ReportWaveResult; launch: ReportWaveLaunchManifest };
457
+
458
+ /**
459
+ * A collect's outcome:
460
+ * - `"none"`: unknown ref — never started here, already drained, or a foreign instance's.
461
+ * - `"running"`: unsettled after the grace — the ref stays pending. A premature collector
462
+ * yields until matching workflow completion; expiry after observed completion is a lifecycle
463
+ * contradiction for owner diagnosis, not a polling cue. The module-owned timeout stays.
464
+ * - `"settled"`: this collector won the drain — `keys` is the launch's frozen requested
465
+ * manifest snapshot, `result` the normalized outcome. Drain-once is exact even under
466
+ * overlapping collectors (delete-as-claim).
467
+ */
468
+ export type CollectWaveResult =
469
+ | { kind: "none" }
470
+ | { kind: "running" }
471
+ | { kind: "settled"; keys: readonly string[]; result: ReportWaveResult };
472
+
473
+ /**
474
+ * The deep seam: callers supply assignments and consume typed outcomes — never adapters, run
475
+ * handles, or result promises. `start`/`collect` are the streaming split (the parent returns
476
+ * from the launch, ends its turn, and resumes on native wakes); `run` is the blocking form (start + await, no
477
+ * ref escapes). The only throws are programmer errors (empty assignments, duplicate keys, keys
478
+ * outside `RUN_KEY_PATTERN`); every operational failure normalizes into `ReportWaveResult`.
479
+ */
480
+ export interface ReportWave {
481
+ start(request: ReportWaveRequest, control?: WaveControl): Promise<StartWaveResult>;
482
+ collect(ref: ReportWaveRef): Promise<CollectWaveResult>;
483
+ run(request: ReportWaveRequest, control?: WaveControl): Promise<ReportWaveResult>;
484
+ }
485
+
486
+ /**
487
+ * The grace a collect allows a not-yet-settled wave before answering `"running"`: long enough
488
+ * to absorb ordering skew between the native completion notice and aggregate resolution,
489
+ * bounded so a premature call can yield again. The `PERK_WAVE_COLLECT_GRACE_MS` env knob is the ONE grace seam
490
+ * (module-private — there is no per-call grace parameter); invalid values fall back.
491
+ */
492
+ const WAVE_COLLECT_GRACE_MS = 15_000;
493
+
494
+ function collectGraceMs(): number {
495
+ const raw = Number(process.env.PERK_WAVE_COLLECT_GRACE_MS ?? "");
496
+ return Number.isFinite(raw) && raw > 0 ? raw : WAVE_COLLECT_GRACE_MS;
497
+ }
498
+
499
+ /**
500
+ * One pending (started, uncollected) wave: the frozen pre-launch key manifest snapshot (copied
501
+ * from the launch manifest at start — caller mutation of the returned `StartWaveResult.launch`
502
+ * can never change a later collect's keys) plus the never-rejecting result promise. NO drained
503
+ * flag: presence in the instance's map IS pending. After settlement the normalized promise
504
+ * owns retained partial reports until collection; no later artifact read or resume recovers them.
505
+ */
506
+ interface PendingRecord {
507
+ keys: readonly string[];
508
+ result: Promise<ReportWaveResult>;
509
+ }
510
+
511
+ /**
512
+ * A launched (or launch-failed) wave as the internal core reports it — the module-private
513
+ * predecessor shape the opaque lifecycle wraps (the run handle and result promise never leave
514
+ * the module).
515
+ */
516
+ type InternalStart =
517
+ | {
518
+ ok: true;
519
+ handle: WaveRunHandle;
520
+ result: Promise<ReportWaveResult>;
521
+ launch: ReportWaveLaunchManifest;
522
+ }
523
+ | { ok: false; result: ReportWaveResult; launch: ReportWaveLaunchManifest };
524
+
735
525
  /**
736
- * Start a report wave without blocking on completion: render the all-settled lane script (the
737
- * programmer-error throws — empty lanes / duplicate keys — are preserved), launch it via
738
- * `startWaveScript`, and on success return the run handle plus a `result` promise that applies
739
- * the shared settle (normalization + completeness + receipt enrichment) when the run finishes.
740
- * A launch failure comes back as an already-settled, normalized `WaveResult`.
526
+ * The internal launch core: validate the COMPLETE requested manifest (the programmer-error
527
+ * throws — empty/duplicate/invalid keys — are preserved even for an unavailable required
528
+ * skill), run the required-skill preflight partition, render the all-settled assignment script
529
+ * over the runnable subset, and launch it via `startWaveScript`. On success the never-rejecting
530
+ * `result` promise applies the shared settle (normalization + completeness + receipt
531
+ * enrichment + preflight-failure merge) when the run finishes; a launch failure comes back as
532
+ * an already-settled, normalized `ReportWaveResult`.
741
533
  */
742
- export async function startReportWave(
743
- adapter: WaveAdapter,
744
- spec: WaveSpec,
534
+ async function startWave(
535
+ supplyAdapter: () => WaveAdapter,
536
+ request: ReportWaveRequest,
745
537
  signal?: AbortSignal,
746
- ): Promise<ReportWaveStart> {
747
- // Validate the COMPLETE requested manifest before source preflight partitions any lane out.
748
- // This preserves the programmer-error contract even for an unavailable required skill.
749
- validateWaveLanes(spec.lanes);
750
- const preflight = spec.requiredSkillPreflight ?? preflightPonytailSkill;
538
+ ): Promise<InternalStart> {
539
+ validateAssignments(request.assignments);
540
+ const preflight = request.requiredSkillPreflight ?? preflightPonytailSkill;
751
541
  const checked = new Map<string, PonytailPreflight>();
752
- const runnable: WaveLane[] = [];
753
- const skillFailures: WaveFailure[] = [];
754
- for (const lane of spec.lanes) {
755
- if (lane.requiredSkill === undefined) {
756
- runnable.push(lane);
542
+ const runnable: ReportAssignment[] = [];
543
+ const skillFailures: ReportWaveFailure[] = [];
544
+ for (const assignment of request.assignments) {
545
+ if (assignment.requiredSkill === undefined) {
546
+ runnable.push(assignment);
757
547
  continue;
758
548
  }
759
- let result = checked.get(lane.requiredSkill.skillFile);
549
+ let result = checked.get(assignment.requiredSkill.skillFile);
760
550
  if (result === undefined) {
761
- result = await preflight(lane.requiredSkill);
762
- checked.set(lane.requiredSkill.skillFile, result);
551
+ result = await preflight(assignment.requiredSkill);
552
+ checked.set(assignment.requiredSkill.skillFile, result);
763
553
  }
764
554
  if (result.ok) {
765
- runnable.push(lane);
555
+ runnable.push(assignment);
766
556
  } else {
767
- skillFailures.push({ key: lane.key, reason: "skill-unavailable", detail: result.detail });
557
+ skillFailures.push({
558
+ key: assignment.key,
559
+ reason: "skill-unavailable",
560
+ detail: result.detail,
561
+ });
768
562
  }
769
563
  }
770
564
 
771
- const launch: WaveLaunchManifest = {
772
- requested: spec.lanes.map((lane) => lane.key),
773
- runnable: runnable.map((lane) => lane.key),
565
+ const launch: ReportWaveLaunchManifest = {
566
+ requested: request.assignments.map((assignment) => assignment.key),
567
+ runnable: runnable.map((assignment) => assignment.key),
774
568
  preflightFailures: [...skillFailures],
775
569
  };
776
570
 
777
- const settleWithSkillFailures = (result: WaveResult): WaveResult => {
571
+ const settleWithSkillFailures = (result: ReportWaveResult): ReportWaveResult => {
778
572
  if (skillFailures.length === 0) return result;
779
573
  const failures = [...result.failures, ...skillFailures];
780
574
  const complete =
781
- spec.completeness === "strict"
575
+ request.completeness === "strict"
782
576
  ? failures.length === 0
783
577
  : failures.every((failure) => failure.key !== null);
784
578
  return { ...result, complete, failures };
@@ -793,18 +587,18 @@ export async function startReportWave(
793
587
  };
794
588
  }
795
589
 
796
- // Required-skill metadata never reaches the renderer; only runnable lanes spawn.
797
- const runnableSpec: WaveSpec = { ...spec, lanes: runnable };
590
+ // Required-skill metadata never reaches the renderer; only runnable assignments spawn.
591
+ const runnableRequest: ReportWaveRequest = { ...request, assignments: runnable };
798
592
  const workflowScript = renderWaveScript(runnable);
799
593
 
800
594
  const start = await startWaveScript(
801
- adapter,
595
+ supplyAdapter(),
802
596
  {
803
- flow: spec.flow,
597
+ flow: request.flow,
804
598
  workflowScript,
805
- outputSchema: spec.outputSchema,
806
- ...(spec.model !== undefined ? { model: spec.model } : {}),
807
- ...(spec.timeoutMs !== undefined ? { timeoutMs: spec.timeoutMs } : {}),
599
+ outputSchema: request.outputSchema,
600
+ ...(request.model !== undefined ? { model: request.model } : {}),
601
+ ...(request.timeoutMs !== undefined ? { timeoutMs: request.timeoutMs } : {}),
808
602
  },
809
603
  signal,
810
604
  );
@@ -814,7 +608,7 @@ export async function startReportWave(
814
608
  result: settleWithSkillFailures(
815
609
  settleReportWave(
816
610
  { ok: false, failure: start.failure, receipt: start.receipt },
817
- runnableSpec,
611
+ runnableRequest,
818
612
  ),
819
613
  ),
820
614
  launch,
@@ -824,22 +618,88 @@ export async function startReportWave(
824
618
  ok: true,
825
619
  handle: start.handle,
826
620
  result: start.result.then((run) =>
827
- settleWithSkillFailures(settleReportWave(run, runnableSpec)),
621
+ settleWithSkillFailures(settleReportWave(run, runnableRequest)),
828
622
  ),
829
623
  launch,
830
624
  };
831
625
  }
832
626
 
627
+ const STILL_RUNNING = Symbol("wave-still-running");
628
+
833
629
  /**
834
- * Run a report wave to completion the blocking form: `startReportWave` + await its `result`
835
- * (one operational core). Every operational failure normalizes into `WaveResult`the only
836
- * throws are programmer errors (empty lanes / duplicate keys, via `renderWaveScript`).
630
+ * The one internal core both factories share: an instance-owned pending map over a per-launch
631
+ * adapter supplier. Pending state belongs to the wave INSTANCE `waveB.collect(refFromA)` is
632
+ * `"none"` structurally and the WeakMap plus the settled drain's delete both release retained
633
+ * results promptly.
837
634
  */
838
- export async function runReportWave(
839
- adapter: WaveAdapter,
840
- spec: WaveSpec,
841
- signal?: AbortSignal,
842
- ): Promise<WaveResult> {
843
- const start = await startReportWave(adapter, spec, signal);
844
- return start.ok ? await start.result : start.result;
635
+ function waveOver(supplyAdapter: () => WaveAdapter): ReportWave {
636
+ const records = new WeakMap<ReportWaveRef, PendingRecord>();
637
+
638
+ return {
639
+ async start(request, control) {
640
+ const start = await startWave(supplyAdapter, request, control?.signal);
641
+ if (!start.ok) {
642
+ return { ok: false, result: start.result, launch: start.launch };
643
+ }
644
+ // The ONE mint site — the isolated assertion, immediately after the runtime evidence of
645
+ // a successful launch. The keys snapshot is frozen and copied, never an alias of the
646
+ // returned manifest.
647
+ const ref = {} as ReportWaveRef;
648
+ records.set(ref, { keys: Object.freeze([...start.launch.requested]), result: start.result });
649
+ return {
650
+ ok: true,
651
+ ref,
652
+ runId: start.handle.asyncId,
653
+ asyncDir: start.handle.asyncDir,
654
+ launch: start.launch,
655
+ };
656
+ },
657
+
658
+ async collect(ref) {
659
+ const record = records.get(ref);
660
+ if (record === undefined) return { kind: "none" };
661
+ let timer: ReturnType<typeof setTimeout> | undefined;
662
+ let raced: ReportWaveResult | typeof STILL_RUNNING;
663
+ try {
664
+ raced = await Promise.race([
665
+ record.result,
666
+ new Promise<typeof STILL_RUNNING>((resolve) => {
667
+ timer = setTimeout(() => resolve(STILL_RUNNING), collectGraceMs());
668
+ }),
669
+ ]);
670
+ } finally {
671
+ clearTimeout(timer);
672
+ }
673
+ if (raced === STILL_RUNNING) {
674
+ // The record stays in the map: its bound remains the module-owned wave timeout, and a
675
+ // later collect drains whatever it settles into.
676
+ return { kind: "running" };
677
+ }
678
+ // The delete IS the atomic drain claim: single-threaded JS makes the post-await
679
+ // delete-as-claim exact — overlapping collects of one ref yield exactly one settled
680
+ // winner; the loser (already-deleted) answers `"none"`.
681
+ if (!records.delete(ref)) return { kind: "none" };
682
+ return { kind: "settled", keys: record.keys, result: raced };
683
+ },
684
+
685
+ async run(request, control) {
686
+ const start = await startWave(supplyAdapter, request, control?.signal);
687
+ return start.ok ? await start.result : start.result;
688
+ },
689
+ };
690
+ }
691
+
692
+ /**
693
+ * The PRODUCTION factory — the wave owns adapter selection: constructs a FRESH rpc adapter per
694
+ * launch over the supplied bus (per-execute adapter freshness; no shared mutable ping state).
695
+ * One per-activation instance is constructed at the composition root (`extension/index.ts`) and
696
+ * threaded to the installers.
697
+ */
698
+ export function createReportWave(bus: WaveBus): ReportWave {
699
+ return waveOver(() => createRpcWaveAdapter(bus));
700
+ }
701
+
702
+ /** The injection seam (tests; the same internal core over one supplied adapter). */
703
+ export function reportWaveOver(adapter: WaveAdapter): ReportWave {
704
+ return waveOver(() => adapter);
845
705
  }