@mgiles/perk 2.3.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/README.md +1 -1
  2. package/extension/adapters/planAdapterPlannotator.ts +135 -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/commitCompact.ts +98 -10
  9. package/extension/doors/draftReviewWaveTools.ts +412 -0
  10. package/extension/doors/dreamWaveTools.ts +475 -0
  11. package/extension/doors/harvestWaveTools.ts +279 -0
  12. package/extension/doors/land.ts +1 -0
  13. package/extension/doors/learn.ts +24 -6
  14. package/extension/doors/learnFactory.ts +33 -7
  15. package/extension/doors/lifecycleGates.ts +1 -1
  16. package/extension/doors/objectiveReviewBrowser.ts +537 -0
  17. package/extension/doors/objectiveStack.ts +1143 -0
  18. package/extension/doors/planReviewBrowser.ts +475 -0
  19. package/extension/doors/plannotatorHandoff.ts +120 -49
  20. package/extension/doors/prReview.ts +180 -63
  21. package/extension/doors/prReviewBrowser.ts +31 -23
  22. package/extension/doors/prReviewDynamic.ts +57 -30
  23. package/extension/doors/prReviewTerminal.ts +6 -11
  24. package/extension/doors/reviewWaveTools.ts +397 -0
  25. package/extension/doors/selfcheck.ts +1 -1
  26. package/extension/doors/submit.ts +82 -7
  27. package/extension/factories/gistAuthor.ts +4 -4
  28. package/extension/factories/gistDraft.ts +1 -1
  29. package/extension/factories/gistSave.ts +2 -2
  30. package/extension/factories/objective.ts +7 -7
  31. package/extension/factories/objectiveAuthor.ts +5 -4
  32. package/extension/factories/objectiveDraft.ts +153 -30
  33. package/extension/factories/objectiveDreamReport.ts +347 -0
  34. package/extension/factories/objectivePlan.ts +163 -9
  35. package/extension/factories/objectiveSave.ts +90 -2
  36. package/extension/factories/planMode.ts +5 -3
  37. package/extension/factories/planReview.ts +304 -46
  38. package/extension/factories/planTitle.ts +27 -9
  39. package/extension/hunkFeedback/inbox.ts +389 -0
  40. package/extension/hunkFeedback/perkFeedback.ts +364 -0
  41. package/extension/hunkFeedback/receiver.ts +296 -0
  42. package/extension/hunkFeedback/store.ts +533 -0
  43. package/extension/index.ts +169 -57
  44. package/extension/substrate/agentScratch.ts +171 -0
  45. package/extension/substrate/bindingDelivery.ts +9 -11
  46. package/extension/substrate/cache.ts +166 -23
  47. package/extension/substrate/coldDoor.ts +2 -3
  48. package/extension/substrate/command.ts +9 -6
  49. package/extension/substrate/config.ts +41 -12
  50. package/extension/substrate/git.ts +95 -2
  51. package/extension/substrate/providers.ts +4 -33
  52. package/extension/substrate/resources.ts +11 -0
  53. package/extension/substrate/result.ts +16 -7
  54. package/extension/substrate/sessionData.ts +8 -6
  55. package/extension/substrate/sessionPointers.ts +4 -5
  56. package/extension/substrate/structuredOutput.ts +78 -19
  57. package/extension/substrate/toolGating.ts +179 -23
  58. package/extension/substrate/workflowState.ts +50 -2
  59. package/extension/surfaces/footerProvider.ts +6 -6
  60. package/extension/surfaces/report.ts +38 -12
  61. package/extension/surfaces/surfaces.ts +213 -275
  62. package/extension/vendor/btw/btw.ts +94 -17
  63. package/extension/waves/adversarialReviewWave.ts +181 -0
  64. package/extension/waves/auditWave.ts +312 -0
  65. package/extension/waves/draftReviewWave.ts +205 -0
  66. package/extension/waves/dreamReducerWave.ts +700 -0
  67. package/extension/waves/dreamReport.ts +1494 -0
  68. package/extension/waves/dreamWave.ts +927 -0
  69. package/extension/waves/harvestWave.ts +399 -0
  70. package/extension/waves/memoryAdapter.ts +14 -1
  71. package/extension/waves/objectiveExplorerWave.ts +120 -0
  72. package/extension/waves/ponytail.ts +104 -0
  73. package/extension/waves/prReviewDynamicWave.ts +383 -72
  74. package/extension/waves/prReviewWave.ts +175 -25
  75. package/extension/waves/reportWave.ts +503 -107
  76. package/extension/waves/reviewClassifierWave.ts +109 -0
  77. package/extension/waves/rpcAdapter.ts +65 -2
  78. package/extension/worker/readOnlySession.ts +4 -5
  79. package/extension/worker/worker.ts +71 -93
  80. package/extension/workerMain.ts +4 -6
  81. package/package.json +18 -5
  82. package/prompts/README.md +19 -0
  83. package/prompts/_fixtures/live.yaml +115 -65
  84. package/prompts/commit-and-compact-continuation.md +13 -0
  85. package/prompts/common/resume-advisory.md +1 -0
  86. package/prompts/contexts/adapters/plannotator-gist.md +13 -0
  87. package/prompts/contexts/adapters/plannotator-objective.md +8 -3
  88. package/prompts/contexts/adapters/plannotator-plan.md +8 -3
  89. package/prompts/contexts/gist-authoring.md +6 -20
  90. package/prompts/contexts/objective-authoring.md +6 -18
  91. package/prompts/stages/address/action.md +3 -14
  92. package/prompts/stages/address/preview.md +2 -13
  93. package/prompts/stages/audit.md +18 -0
  94. package/prompts/stages/conflict-resolution.md +1 -1
  95. package/prompts/stages/gist-author/seed.md +4 -4
  96. package/prompts/stages/gist-save.md +1 -1
  97. package/prompts/stages/implement.md +3 -1
  98. package/prompts/stages/learn-code.md +1 -1
  99. package/prompts/stages/learn-docs.md +2 -2
  100. package/prompts/stages/learn-dream.md +10 -0
  101. package/prompts/stages/learn-harvest.md +15 -0
  102. package/prompts/stages/learn-orchestrate.md +1 -1
  103. package/prompts/stages/objective-author/adopt.md +5 -3
  104. package/prompts/stages/objective-author/file.md +5 -4
  105. package/prompts/stages/objective-author/seed.md +5 -4
  106. package/prompts/stages/objective-land.md +7 -0
  107. package/prompts/stages/objective-plan/guidance.md +2 -13
  108. package/prompts/stages/objective-plan/seed.md +5 -12
  109. package/prompts/stages/objective-recover.md +8 -0
  110. package/prompts/stages/objective-replan.md +4 -2
  111. package/prompts/stages/objective-review-browser.md +9 -0
  112. package/prompts/stages/objective-sync.md +6 -0
  113. package/prompts/stages/plan-from/adopt.md +2 -2
  114. package/prompts/stages/plan-from/file.md +2 -2
  115. package/prompts/stages/plan-review-browser.md +9 -0
  116. package/prompts/stages/pr-review-browser/active.md +6 -14
  117. package/prompts/stages/pr-review-browser/foreign.md +6 -14
  118. package/prompts/stages/pr-review-dynamic.md +6 -6
  119. package/prompts/stages/pr-review-terminal/active.md +7 -15
  120. package/prompts/stages/pr-review-terminal/foreign.md +7 -15
  121. package/prompts/stages/pr-review-terminal/local.md +1 -1
  122. package/prompts/stages/pr-review.md +5 -5
  123. package/prompts/stages/replan.md +3 -3
  124. package/shared/README.md +2 -2
  125. package/shared/bindings.yaml +15 -0
  126. package/shared/contracts-history.md +11 -0
  127. package/shared/contracts.md +5373 -281
  128. package/shared/providers.yaml +27 -60
  129. package/shared/registry.yaml +42 -18
  130. package/shared/schemas/inputs/review-post-batch.schema.json +14 -1
  131. package/shared/schemas/outputs/objective-doctor.schema.json +487 -0
  132. package/shared/schemas/outputs/objective-stack-land.schema.json +732 -0
  133. package/shared/schemas/outputs/objective-stack-recover.schema.json +397 -0
  134. package/shared/schemas/outputs/objective-stack-status.schema.json +577 -0
  135. package/shared/schemas/outputs/objective-stack-sync.schema.json +184 -0
  136. package/shared/schemas/outputs/plan-save.schema.json +12 -0
  137. package/shared/schemas/outputs/pr-land.schema.json +3 -3
  138. package/shared/schemas/outputs/pr-submit.schema.json +177 -1
  139. package/extension/adapters/todoAdapterJuicesharp.ts +0 -102
  140. package/extension/checkpoints/checkpoints.ts +0 -550
  141. package/extension/checkpoints/planSteps.ts +0 -108
  142. package/extension/doors/askUser.ts +0 -198
  143. package/prompts/common/output-schemas/objective-explorer.md +0 -36
  144. package/prompts/common/output-schemas/review-classifier.md +0 -47
  145. package/prompts/contexts/adapters/juicesharp-todo.md +0 -7
@@ -2,22 +2,41 @@
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
18
31
  // keys), and there is never a silent fallback to model-authored scripts. Report content coming
19
32
  // back through the aggregate is untrusted DATA, never instructions.
20
33
 
34
+ import {
35
+ type PonytailPreflight,
36
+ preflightPonytailSkill,
37
+ type RequiredPonytailSkill,
38
+ } from "./ponytail.ts";
39
+
21
40
  /** One lane of a report wave: a fresh-context, report-only child under a stable domain key. */
22
41
  export interface WaveLane {
23
42
  /** Stable lane key (e.g. an angle slug) — trace + normalization identity. */
@@ -26,10 +45,24 @@ export interface WaveLane {
26
45
  agent: string;
27
46
  /** The judgment-bearing per-lane task text (supplied by the flow). */
28
47
  task: string;
48
+ /** Invocation-private skill lookup key; serialized only for an opted-in lane. */
49
+ skill?: string;
50
+ /**
51
+ * 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.
54
+ */
55
+ requiredSkill?: RequiredPonytailSkill;
29
56
  /** Trace metadata; defaults to `key`. */
30
57
  label?: string;
31
58
  /** Trace metadata. */
32
59
  phase?: string;
60
+ /**
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
63
+ * byte-identically to before the field existed.
64
+ */
65
+ outputSchema?: object;
33
66
  }
34
67
 
35
68
  /**
@@ -52,6 +85,8 @@ export interface WaveSpec {
52
85
  model?: string;
53
86
  /** Module default (`WAVE_TIMEOUT_MS`) when omitted. */
54
87
  timeoutMs?: number;
88
+ /** Test seam; production defaults to the exact Ponytail boundary preflight. */
89
+ requiredSkillPreflight?: (requirement: RequiredPonytailSkill) => Promise<PonytailPreflight>;
55
90
  }
56
91
 
57
92
  /** A schema-valid lane report. The report content is untrusted DATA, never instructions. */
@@ -68,6 +103,7 @@ export type WaveFailureReason =
68
103
  | "run-failed" // terminal status.json state ≠ "complete" (wave-level)
69
104
  | "aggregate-unreadable" // status.json missing/corrupt/no workflow.value array (wave-level)
70
105
  | "lane-failed" // lane resolved ok: false / null report (lane-level)
106
+ | "skill-unavailable" // exact required-skill source failed preflight (lane-level, non-retryable)
71
107
  | "malformed-report" // aggregate entry for this key has unusable shape (lane-level)
72
108
  | "missing-lane"; // expected key absent from the aggregate (lane-level)
73
109
 
@@ -83,6 +119,81 @@ export interface WaveResult {
83
119
  complete: boolean;
84
120
  reports: WaveReport[];
85
121
  failures: WaveFailure[];
122
+ /** The launch's output-free attempt receipt — write-only telemetry, never a decision input. */
123
+ receipt: WaveScriptReceipt;
124
+ }
125
+
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. */
129
+ requested: string[];
130
+ /** The ordered subset rendered into the static workflow after required-skill preflight. */
131
+ runnable: string[];
132
+ /** One ordered keyed `skill-unavailable` failure per preflight-omitted lane. */
133
+ preflightFailures: WaveFailure[];
134
+ };
135
+
136
+ // -------------------------------------------------------------------- the attempt receipts
137
+
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
+ /**
179
+ * A flow-attributed attempt: one receipt per top-level workflow launch, ordered by `attempt`
180
+ * (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.
182
+ */
183
+ export interface WaveAttemptReceipt extends WaveScriptReceipt {
184
+ flow: string;
185
+ attempt: number;
186
+ requestedKeys: string[];
187
+ }
188
+
189
+ /** Assemble one flow attempt from a script receipt (the uniform builder the flows share). */
190
+ export function toAttemptReceipt(
191
+ flow: string,
192
+ attempt: number,
193
+ requestedKeys: string[],
194
+ receipt: WaveScriptReceipt,
195
+ ): WaveAttemptReceipt {
196
+ return { flow, attempt, requestedKeys: [...requestedKeys], ...receipt };
86
197
  }
87
198
 
88
199
  // ------------------------------------------------------------------------- the adapter seam
@@ -104,12 +215,36 @@ export interface WaveRunHandle {
104
215
  asyncDir: string;
105
216
  }
106
217
 
107
- /** An async-complete notification; at least one identifier is present on real payloads. */
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
+ */
108
224
  export interface WaveCompletion {
109
225
  asyncId?: string;
110
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[];
111
231
  }
112
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
+
113
248
  /** The full spawn params the runner fixes: async-only, ephemeral, fresh-context by definition. */
114
249
  export interface WaveSpawnParams {
115
250
  workflowScript: string;
@@ -118,6 +253,9 @@ export interface WaveSpawnParams {
118
253
  mission: false;
119
254
  /** A report wave is by definition fresh-context. */
120
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 };
121
259
  outputSchema: object;
122
260
  model?: string;
123
261
  /** Orphan insurance: the run enforces the same deadline even if the parent session dies. */
@@ -139,15 +277,24 @@ export interface WaveAdapter {
139
277
 
140
278
  // ---------------------------------------------------------------------------- the renderer
141
279
 
280
+ /**
281
+ * pi-subagents' scripted-workflow run-key contract for `runs.all` item keys: start
282
+ * alphanumeric, then letters/digits/`.`/`_`/`-`, ≤128 chars total. Mirrored here because the
283
+ * upstream pattern is enforced only inside the live workflow worker — an invalid key fails the
284
+ * WHOLE wave at dispatch (`run-failed`), a path no offline adapter exercises — so the renderer
285
+ * rejects it up front as a programmer error.
286
+ */
287
+ export const RUN_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
288
+
142
289
  /**
143
290
  * Render the wave `workflowScript`: an explicit-return, all-settled `runs.all` over the lane
144
291
  * items, projected to the compact typed aggregate only (lane key, outcome, error, and the
145
292
  * schema-validated report — children's prose never enters the aggregate beyond `error`/`output`
146
293
  * on failure). Lane items are embedded via `JSON.stringify`, so hostile task text (quotes,
147
294
  * newlines, backticks, `${}`) cannot escape the array literal. Throws on programmer error:
148
- * empty lanes or duplicate lane keys.
295
+ * empty lanes, duplicate lane keys, or a lane key outside the run-key contract.
149
296
  */
150
- export function renderWaveScript(lanes: WaveLane[]): string {
297
+ function validateWaveLanes(lanes: WaveLane[]): void {
151
298
  if (lanes.length === 0) {
152
299
  throw new Error("renderWaveScript: a report wave needs at least one lane");
153
300
  }
@@ -156,14 +303,25 @@ export function renderWaveScript(lanes: WaveLane[]): string {
156
303
  if (seen.has(lane.key)) {
157
304
  throw new Error(`renderWaveScript: duplicate lane key '${lane.key}'`);
158
305
  }
306
+ if (!RUN_KEY_PATTERN.test(lane.key)) {
307
+ throw new Error(
308
+ `renderWaveScript: lane key '${lane.key}' violates the pi-subagents run-key contract`,
309
+ );
310
+ }
159
311
  seen.add(lane.key);
160
312
  }
313
+ }
314
+
315
+ export function renderWaveScript(lanes: WaveLane[]): string {
316
+ validateWaveLanes(lanes);
161
317
  const items = lanes.map((lane) => ({
162
318
  key: lane.key,
163
319
  agent: lane.agent,
164
320
  task: lane.task,
321
+ ...(lane.skill !== undefined ? { skill: lane.skill } : {}),
165
322
  label: lane.label ?? lane.key,
166
323
  ...(lane.phase !== undefined ? { phase: lane.phase } : {}),
324
+ ...(lane.outputSchema !== undefined ? { outputSchema: lane.outputSchema } : {}),
167
325
  }));
168
326
  return (
169
327
  `const reports = await runs.all(${JSON.stringify(items, null, 2)});\n` +
@@ -186,8 +344,12 @@ function waveTimeoutMs(): number {
186
344
  return Number.isFinite(raw) && raw > 0 ? raw : WAVE_TIMEOUT_MS;
187
345
  }
188
346
 
189
- function waveFailure(reason: WaveFailureReason, detail: string): WaveResult {
190
- return { complete: false, reports: [], failures: [{ key: null, reason, detail }] };
347
+ function waveFailure(
348
+ reason: WaveFailureReason,
349
+ detail: string,
350
+ receipt: WaveScriptReceipt,
351
+ ): WaveResult {
352
+ return { complete: false, reports: [], failures: [{ key: null, reason, detail }], receipt };
191
353
  }
192
354
 
193
355
  /** The judgment-bearing pieces a script run needs (the lane-free slice of `WaveSpec`). */
@@ -205,7 +367,23 @@ export interface WaveScriptSpec {
205
367
  }
206
368
 
207
369
  /** 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 };
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 };
209
387
 
210
388
  function errorDetail(error: unknown): string {
211
389
  return error instanceof Error ? error.message : String(error);
@@ -281,26 +459,51 @@ export function normalizeLanes(
281
459
  }
282
460
 
283
461
  /**
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.
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.
290
469
  */
291
- export async function runWaveScript(
470
+ export async function startWaveScript(
292
471
  adapter: WaveAdapter,
293
472
  spec: WaveScriptSpec,
294
473
  signal?: AbortSignal,
295
- ): Promise<WaveScriptResult> {
296
- const scriptFailure = (reason: WaveFailureReason, detail: string): WaveScriptResult => ({
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 => ({
297
491
  ok: false,
298
492
  failure: { key: null, reason, detail },
493
+ receipt,
299
494
  });
300
495
 
301
- if (signal?.aborted === true) {
302
- return scriptFailure("cancelled", `wave '${spec.flow}' was cancelled before launch`);
303
- }
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();
304
507
 
305
508
  // 1. Capability check — the loud-degrade arm: the result explicitly names the wave
306
509
  // unavailable; callers surface it, never silently fall back to model-authored scripts.
@@ -308,15 +511,24 @@ export async function runWaveScript(
308
511
  try {
309
512
  ping = await adapter.ping();
310
513
  } catch (error) {
311
- return scriptFailure("unavailable", `subagent RPC ping failed: ${errorDetail(error)}`);
514
+ return startFailure(
515
+ "unavailable",
516
+ `subagent RPC ping failed: ${errorDetail(error)}`,
517
+ receiptOf("unavailable", null),
518
+ );
312
519
  }
313
520
  if (ping === null) {
314
- return scriptFailure(
521
+ return startFailure(
315
522
  "unavailable",
316
523
  "pi-subagents did not advertise the report-wave capabilities (ping failed or incomplete)",
524
+ receiptOf("unavailable", null),
317
525
  );
318
526
  }
319
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
+
320
532
  // 2. Subscribe BEFORE spawn: a completion can arrive before the spawn reply resolves (the
321
533
  // completion-before-reply race) — every completion is buffered and re-checked once the
322
534
  // handle is known.
@@ -332,108 +544,180 @@ export async function runWaveScript(
332
544
  if (matchesHandle(completion) && notifyMatch !== null) notifyMatch();
333
545
  });
334
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();
335
550
  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();
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> => {
339
584
  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,
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");
348
602
  });
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;
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
+ );
358
623
  }
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 = "";
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 };
375
631
  try {
376
- await adapter.stop(handle);
632
+ aggregate = await adapter.readAggregate(spawned);
377
633
  } catch (error) {
378
- stopNote = ` (stop failed: ${errorDetail(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
+ );
379
642
  }
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}`);
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();
398
654
  }
399
- return { ok: true, value: aggregate.value };
400
- } finally {
401
- unsubscribe();
402
- }
655
+ };
656
+ return { ok: true, handle: spawned, result: settle() };
403
657
  }
404
658
 
405
659
  /**
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`).
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).
410
663
  */
411
- export async function runReportWave(
664
+ export async function runWaveScript(
412
665
  adapter: WaveAdapter,
413
- spec: WaveSpec,
666
+ spec: WaveScriptSpec,
414
667
  signal?: AbortSignal,
415
- ): Promise<WaveResult> {
416
- // Programmer-error validation first (throws): the script render is spec-only.
417
- const workflowScript = renderWaveScript(spec.lanes);
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
+ }
418
673
 
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
- );
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).
678
+ */
679
+ function enrichReceipt(receipt: WaveScriptReceipt, lanes: WaveLane[]): WaveScriptReceipt {
680
+ return {
681
+ ...receipt,
682
+ children: receipt.children.map((child) => {
683
+ if (child.agent !== undefined) return child;
684
+ const agent = lanes.find((lane) => lane.key === child.key)?.agent;
685
+ return agent === undefined ? child : { ...child, agent };
686
+ }),
687
+ };
688
+ }
689
+
690
+ /**
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.
710
+ */
711
+ function settleReportWave(run: WaveScriptResult, spec: WaveSpec): WaveResult {
712
+ const receipt = enrichReceipt(run.receipt, spec.lanes);
430
713
  if (!run.ok) {
431
- return { complete: false, reports: [], failures: [run.failure] };
714
+ return { complete: false, reports: [], failures: [run.failure], receipt };
432
715
  }
433
716
  if (!Array.isArray(run.value)) {
434
717
  return waveFailure(
435
718
  "aggregate-unreadable",
436
719
  "wave aggregate carries no workflow.value array (the script's explicit return is missing)",
720
+ receipt,
437
721
  );
438
722
  }
439
723
 
@@ -445,5 +729,117 @@ export async function runReportWave(
445
729
  spec.completeness === "strict"
446
730
  ? failures.length === 0
447
731
  : failures.every((failure) => failure.key !== null);
448
- return { complete, reports, failures };
732
+ return { complete, reports, failures, receipt };
733
+ }
734
+
735
+ /**
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`.
741
+ */
742
+ export async function startReportWave(
743
+ adapter: WaveAdapter,
744
+ spec: WaveSpec,
745
+ 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;
751
+ 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);
757
+ continue;
758
+ }
759
+ let result = checked.get(lane.requiredSkill.skillFile);
760
+ if (result === undefined) {
761
+ result = await preflight(lane.requiredSkill);
762
+ checked.set(lane.requiredSkill.skillFile, result);
763
+ }
764
+ if (result.ok) {
765
+ runnable.push(lane);
766
+ } else {
767
+ skillFailures.push({ key: lane.key, reason: "skill-unavailable", detail: result.detail });
768
+ }
769
+ }
770
+
771
+ const launch: WaveLaunchManifest = {
772
+ requested: spec.lanes.map((lane) => lane.key),
773
+ runnable: runnable.map((lane) => lane.key),
774
+ preflightFailures: [...skillFailures],
775
+ };
776
+
777
+ const settleWithSkillFailures = (result: WaveResult): WaveResult => {
778
+ if (skillFailures.length === 0) return result;
779
+ const failures = [...result.failures, ...skillFailures];
780
+ const complete =
781
+ spec.completeness === "strict"
782
+ ? failures.length === 0
783
+ : failures.every((failure) => failure.key !== null);
784
+ return { ...result, complete, failures };
785
+ };
786
+
787
+ if (runnable.length === 0) {
788
+ const receipt: WaveScriptReceipt = { state: "unavailable", children: [] };
789
+ return {
790
+ ok: false,
791
+ result: settleWithSkillFailures({ complete: false, reports: [], failures: [], receipt }),
792
+ launch,
793
+ };
794
+ }
795
+
796
+ // Required-skill metadata never reaches the renderer; only runnable lanes spawn.
797
+ const runnableSpec: WaveSpec = { ...spec, lanes: runnable };
798
+ const workflowScript = renderWaveScript(runnable);
799
+
800
+ const start = await startWaveScript(
801
+ adapter,
802
+ {
803
+ flow: spec.flow,
804
+ workflowScript,
805
+ outputSchema: spec.outputSchema,
806
+ ...(spec.model !== undefined ? { model: spec.model } : {}),
807
+ ...(spec.timeoutMs !== undefined ? { timeoutMs: spec.timeoutMs } : {}),
808
+ },
809
+ signal,
810
+ );
811
+ if (!start.ok) {
812
+ return {
813
+ ok: false,
814
+ result: settleWithSkillFailures(
815
+ settleReportWave(
816
+ { ok: false, failure: start.failure, receipt: start.receipt },
817
+ runnableSpec,
818
+ ),
819
+ ),
820
+ launch,
821
+ };
822
+ }
823
+ return {
824
+ ok: true,
825
+ handle: start.handle,
826
+ result: start.result.then((run) =>
827
+ settleWithSkillFailures(settleReportWave(run, runnableSpec)),
828
+ ),
829
+ launch,
830
+ };
831
+ }
832
+
833
+ /**
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`).
837
+ */
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;
449
845
  }