@mgiles/perk 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/extension/adapters/planAdapterPlannotator.ts +64 -1
  2. package/extension/doors/address.ts +3 -3
  3. package/extension/doors/commitCompact.ts +163 -0
  4. package/extension/doors/learn.ts +219 -23
  5. package/extension/doors/prReview.ts +189 -18
  6. package/extension/doors/prReviewDynamic.ts +249 -0
  7. package/extension/doors/submit.ts +4 -3
  8. package/extension/factories/gistAuthor.ts +94 -0
  9. package/extension/factories/gistDraft.ts +265 -0
  10. package/extension/factories/gistSave.ts +251 -0
  11. package/extension/factories/objectivePlan.ts +3 -2
  12. package/extension/factories/planMode.ts +8 -5
  13. package/extension/factories/planReview.ts +233 -12
  14. package/extension/index.ts +26 -0
  15. package/extension/substrate/config.ts +8 -4
  16. package/extension/substrate/git.ts +38 -0
  17. package/extension/substrate/terminalLaunch.ts +1 -1
  18. package/extension/substrate/toolGating.ts +44 -3
  19. package/extension/substrate/unifiedDiff.ts +224 -0
  20. package/extension/waves/learnWave.ts +155 -0
  21. package/extension/waves/memoryAdapter.ts +126 -0
  22. package/extension/waves/prReviewDynamicWave.ts +466 -0
  23. package/extension/waves/prReviewWave.ts +229 -0
  24. package/extension/waves/reportWave.ts +449 -0
  25. package/extension/waves/rpcAdapter.ts +201 -0
  26. package/package.json +7 -1
  27. package/prompts/_fixtures/live.yaml +22 -11
  28. package/prompts/commit-and-compact.md +7 -0
  29. package/prompts/common/output-schemas/objective-explorer.md +36 -0
  30. package/prompts/common/output-schemas/review-classifier.md +47 -0
  31. package/prompts/contexts/adapters/plannotator-objective.md +8 -1
  32. package/prompts/contexts/adapters/plannotator-plan.md +6 -1
  33. package/prompts/contexts/gist-authoring.md +22 -0
  34. package/prompts/stages/address/action.md +15 -4
  35. package/prompts/stages/address/preview.md +14 -3
  36. package/prompts/stages/conflict-resolution.md +1 -1
  37. package/prompts/stages/gist-author/seed.md +10 -0
  38. package/prompts/stages/gist-save.md +9 -0
  39. package/prompts/stages/learn-orchestrate.md +7 -5
  40. package/prompts/stages/objective-plan/guidance.md +12 -1
  41. package/prompts/stages/objective-plan/seed.md +12 -1
  42. package/prompts/stages/pr-review-browser/active.md +11 -3
  43. package/prompts/stages/pr-review-browser/foreign.md +11 -3
  44. package/prompts/stages/pr-review-dynamic.md +7 -0
  45. package/prompts/stages/pr-review-terminal/active.md +11 -3
  46. package/prompts/stages/pr-review-terminal/foreign.md +11 -3
  47. package/prompts/stages/pr-review.md +7 -6
  48. package/shared/bindings.yaml +6 -0
  49. package/shared/contracts.md +221 -45
  50. package/shared/registry.yaml +31 -1
@@ -0,0 +1,224 @@
1
+ // A deliberately strict unified-diff applier for the plannotator "Direct Edits" feedback only
2
+ // (`extension/adapters/planAdapterPlannotator.ts` extracts the ```diff fence; the plan arm of
3
+ // `plan_review` applies it to the exact draft bytes it submitted) — this is NOT a general-purpose
4
+ // patch tool.
5
+ //
6
+ // Why this exists: the extension must stay zero-runtime-dependency (the bare-clone invariant —
7
+ // see `miniYaml.ts` / `miniJinja.ts`, the two prior vendored-engine precedents), so it cannot
8
+ // import jsdiff at runtime. This module covers exactly the unified-diff subset jsdiff's
9
+ // `createTwoFilesPatch(..., { context: 3 })` emits — the generator plannotator uses — pinned by
10
+ // generator-parity tests in `unifiedDiff.test.ts` (jsdiff is a dev-only dependency there).
11
+ //
12
+ // Why it is STRICT (null on ANY anomaly, never throw, never fuzz): the consumer sits on a
13
+ // fail-open ladder — a `null` merely falls back to today's behavior (the reviewed bytes are
14
+ // saved verbatim and the diff stays in the feedback as guidance). A lenient/fuzzy apply could
15
+ // silently save bytes the reviewer never approved, which is worse than declining to apply.
16
+ //
17
+ // One deliberate leniency, matching the generator: plannotator embeds `patch.trimEnd()` in the
18
+ // fence, so trailing WHITESPACE-ONLY context lines of the final hunk may have been trimmed away.
19
+ // The applier reconstructs them from the base (they are context — their bytes ARE the base's)
20
+ // and still verifies each reconstructed line is whitespace-only (anything else is a genuine
21
+ // truncation → null).
22
+
23
+ /** A parsed `@@ -a[,b] +c[,d] @@` hunk header (counts default to 1 when omitted). */
24
+ interface HunkHeader {
25
+ oldStart: number;
26
+ oldLines: number;
27
+ newLines: number;
28
+ }
29
+
30
+ const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
31
+ const NO_NEWLINE_MARKER = "\";
32
+
33
+ /** One side's line entry: the text plus whether the file ends WITHOUT a newline at this line. */
34
+ interface SideLine {
35
+ text: string;
36
+ noNewline: boolean;
37
+ }
38
+
39
+ /** A fully parsed hunk: the header plus the old/new side projections of its body. */
40
+ interface Hunk {
41
+ header: HunkHeader;
42
+ oldSide: SideLine[];
43
+ newSide: SideLine[];
44
+ }
45
+
46
+ /**
47
+ * Split `text` into terminator-free lines plus the trailing-newline flag. An empty string is
48
+ * ZERO lines (not one empty line); `"\n"` is one empty line.
49
+ */
50
+ function splitLines(text: string): { lines: string[]; endsWithNewline: boolean } {
51
+ if (text === "") return { lines: [], endsWithNewline: false };
52
+ const endsWithNewline = text.endsWith("\n");
53
+ const lines = text.split("\n");
54
+ if (endsWithNewline) lines.pop();
55
+ return { lines, endsWithNewline };
56
+ }
57
+
58
+ /** True for the optional pre-hunk header lines jsdiff's `formatPatch` emits (labels ignored). */
59
+ function isFileHeaderLine(line: string): boolean {
60
+ return (
61
+ line.startsWith("Index: ") ||
62
+ line.startsWith("===") ||
63
+ line.startsWith("--- ") ||
64
+ line.startsWith("+++ ")
65
+ );
66
+ }
67
+
68
+ /**
69
+ * Attach a `` marker to the side(s) the preceding body line belongs
70
+ * to (context → both). False when there is no line to attach to (a leading or doubled marker).
71
+ */
72
+ function attachNoNewline(
73
+ lastPrefix: " " | "-" | "+" | null,
74
+ oldSide: SideLine[],
75
+ newSide: SideLine[],
76
+ ): boolean {
77
+ if (lastPrefix === null) return false;
78
+ const flag = (side: SideLine[]): boolean => {
79
+ const last = side[side.length - 1];
80
+ if (last === undefined) return false;
81
+ last.noNewline = true;
82
+ return true;
83
+ };
84
+ if (lastPrefix === " ") return flag(oldSide) && flag(newSide);
85
+ if (lastPrefix === "-") return flag(oldSide);
86
+ return flag(newSide);
87
+ }
88
+
89
+ /**
90
+ * Parse the diff text into hunks, or null on any anomaly (malformed hunk header, unknown body
91
+ * prefix, a `\` marker with nothing to attach to, an over-long hunk body, an asymmetric or
92
+ * mid-diff shortfall, zero hunks, trailing garbage). The body is projected into old-side /
93
+ * new-side line lists as it parses: ` ` feeds both sides, `-` the old, `+` the new.
94
+ */
95
+ function parseHunks(diff: string): Hunk[] | null {
96
+ const { lines } = splitLines(diff.endsWith("\n") ? diff : `${diff}\n`);
97
+ const hunks: Hunk[] = [];
98
+ let i = 0;
99
+ // Optional file-header preamble (Index: / === / --- / +++), before the first hunk only.
100
+ while (i < lines.length && isFileHeaderLine(lines[i] ?? "")) i++;
101
+ while (i < lines.length) {
102
+ const m = HUNK_HEADER.exec(lines[i] ?? "");
103
+ if (m === null) return null; // trailing garbage / malformed hunk header
104
+ const header: HunkHeader = {
105
+ oldStart: Number(m[1]),
106
+ oldLines: m[2] === undefined ? 1 : Number(m[2]),
107
+ newLines: m[4] === undefined ? 1 : Number(m[4]),
108
+ };
109
+ i++;
110
+ const oldSide: SideLine[] = [];
111
+ const newSide: SideLine[] = [];
112
+ let lastPrefix: " " | "-" | "+" | null = null;
113
+ while (
114
+ i < lines.length &&
115
+ (oldSide.length < header.oldLines ||
116
+ newSide.length < header.newLines ||
117
+ lines[i] === NO_NEWLINE_MARKER)
118
+ ) {
119
+ const line = lines[i] ?? "";
120
+ if (line === NO_NEWLINE_MARKER) {
121
+ if (!attachNoNewline(lastPrefix, oldSide, newSide)) return null;
122
+ lastPrefix = null; // a doubled marker is malformed
123
+ i++;
124
+ continue;
125
+ }
126
+ if (HUNK_HEADER.test(line)) break; // a new hunk began before this one's counts filled
127
+ const prefix = line[0];
128
+ const text = line.slice(1);
129
+ if (prefix === " ") {
130
+ oldSide.push({ text, noNewline: false });
131
+ newSide.push({ text, noNewline: false });
132
+ lastPrefix = " ";
133
+ } else if (prefix === "-") {
134
+ oldSide.push({ text, noNewline: false });
135
+ lastPrefix = "-";
136
+ } else if (prefix === "+") {
137
+ newSide.push({ text, noNewline: false });
138
+ lastPrefix = "+";
139
+ } else {
140
+ return null; // unknown body prefix (an empty line included — jsdiff never emits one)
141
+ }
142
+ i++;
143
+ }
144
+ // Over-long sides cannot happen (the loop stops on filled counts); short sides are tolerated
145
+ // ONLY as the generator's `trimEnd()` artifact — an equal shortfall on both sides, at the
146
+ // very end of the diff — and the applier reconstructs the missing context from the base.
147
+ const oldShort = header.oldLines - oldSide.length;
148
+ const newShort = header.newLines - newSide.length;
149
+ if (oldShort !== newShort || oldShort < 0) return null;
150
+ if (oldShort > 0 && i < lines.length) return null; // short mid-diff is a truncation
151
+ hunks.push({ header, oldSide, newSide });
152
+ }
153
+ if (hunks.length === 0) return null;
154
+ return hunks;
155
+ }
156
+
157
+ /**
158
+ * Apply a unified diff (the jsdiff `createTwoFilesPatch` subset — see the module header) to
159
+ * `base`, strictly and cleanly. Returns the patched text, or null on ANY anomaly: a context or
160
+ * `-` line that does not byte-match the base at the hunk's stated old-file offsets, malformed
161
+ * hunk headers, unknown prefixes, zero hunks, out-of-order/overlapping hunks, trailing garbage,
162
+ * or a no-newline marker that contradicts the base. Never throws.
163
+ */
164
+ export function applyUnifiedDiff(base: string, diff: string): string | null {
165
+ const hunks = parseHunks(diff);
166
+ if (hunks === null) return null;
167
+
168
+ const { lines: baseLines, endsWithNewline: baseEndsWithNewline } = splitLines(base);
169
+ const output: string[] = [];
170
+ // Whether the CURRENT final output line ends without a newline. Every emission checks it:
171
+ // nothing may follow a no-newline line, so a mid-diff `\` marker on the new side (or a
172
+ // no-newline base tail followed by anything) fails strictly instead of mis-joining.
173
+ let resultNoNewline = false;
174
+ const emit = (text: string, noNewline: boolean): boolean => {
175
+ if (resultNoNewline) return false;
176
+ output.push(text);
177
+ resultNoNewline = noNewline;
178
+ return true;
179
+ };
180
+ /** Whether `index` is the base's final line and the base ends without a newline. */
181
+ const baseNoNewlineAt = (index: number): boolean =>
182
+ index === baseLines.length - 1 && !baseEndsWithNewline;
183
+
184
+ let cursor = 0; // 0-based index of the next unconsumed base line
185
+ for (const { header, oldSide, newSide } of hunks) {
186
+ // The 0-based old-file start. Unified-diff quirk: a zero-length old range states the line
187
+ // BEFORE the insertion point (0 = insert at the very start), i.e. already the 0-based index.
188
+ const start = header.oldLines === 0 ? header.oldStart : header.oldStart - 1;
189
+ if (start < cursor || start > baseLines.length) return null; // out-of-order / out-of-range
190
+ // Copy the untouched span before this hunk (all mid-file lines — always newline-terminated).
191
+ for (let i = cursor; i < start; i++) {
192
+ if (!emit(baseLines[i] as string, false)) return null;
193
+ }
194
+ cursor = start;
195
+ // Match the old side against the base at the stated offsets; the new side splices in.
196
+ for (const entry of oldSide) {
197
+ const line = baseLines[cursor];
198
+ if (line === undefined || line !== entry.text) return null;
199
+ if (entry.noNewline !== baseNoNewlineAt(cursor)) return null;
200
+ cursor++;
201
+ }
202
+ for (const entry of newSide) {
203
+ if (!emit(entry.text, entry.noNewline)) return null;
204
+ }
205
+ // Reconstruct trailing context the generator's trimEnd() ate (see parseHunks): consume the
206
+ // next shortfall base lines, verifying each is a whitespace-only, newline-terminated line
207
+ // (a non-whitespace or final-no-newline line could never have been trimmed → truncation).
208
+ const shortfall = header.oldLines - oldSide.length;
209
+ for (let i = 0; i < shortfall; i++) {
210
+ const line = baseLines[cursor];
211
+ if (line === undefined || line.trim() !== "" || baseNoNewlineAt(cursor)) return null;
212
+ if (!emit(line, false)) return null;
213
+ cursor++;
214
+ }
215
+ }
216
+
217
+ // Copy the untouched tail; its final line inherits the base's trailing-newline behavior.
218
+ for (let i = cursor; i < baseLines.length; i++) {
219
+ if (!emit(baseLines[i] as string, baseNoNewlineAt(i))) return null;
220
+ }
221
+
222
+ if (output.length === 0) return "";
223
+ return output.join("\n") + (resultNoNewline ? "" : "\n");
224
+ }
@@ -0,0 +1,155 @@
1
+ // The `/learn` flow's per-flow wave entrypoint over the shared report-wave runner: the analyst
2
+ // fan-out as CODE. It owns the four learn angles, the analyst report schema, the tool-enforced
3
+ // angle policy (2–4 angles, `session-deviations` mandatory), and the lane/task composition —
4
+ // delegating spawn/timeout/aggregate mechanics to `runReportWave` under the `best-effort`
5
+ // completeness policy (a failed analyst is an explicitly-reported skipped angle, never a failed
6
+ // pass). Analyst reports come back as engine-validated structured output (the workflow-level
7
+ // `outputSchema` → the injected `structured_output` tool), replacing fenced-JSON scraping.
8
+
9
+ import { runReportWave, type WaveAdapter, type WaveLane, type WaveResult } from "./reportWave.ts";
10
+
11
+ /** The four learn angles; `session-deviations` is the mandatory member of every selection. */
12
+ export const LEARN_ANGLES = [
13
+ "session-deviations",
14
+ "plan-vs-implementation",
15
+ "existing-docs",
16
+ "validation-risk",
17
+ ] as const;
18
+
19
+ const MANDATORY_ANGLE = "session-deviations";
20
+
21
+ /**
22
+ * The per-lane analyst report schema (the workflow-level `outputSchema`): closed shape,
23
+ * all-required, enums, `target` required-nullable ({angle, verdict, candidates, fyi} — the same
24
+ * field semantics as the agent def's report contract). DELIBERATE DIVERGENCE from
25
+ * `PR_REVIEW_REPORT_SCHEMA`: no if/then verdict↔candidates conditional. Under `best-effort`
26
+ * completeness, salvaging an internally inconsistent report beats failing its lane — the parent
27
+ * derives the real verdict from `candidates[]` (`verdict` is derived data), so an inconsistent
28
+ * verdict costs nothing while a failed lane loses the whole angle.
29
+ */
30
+ export const LEARN_ANALYST_REPORT_SCHEMA = {
31
+ type: "object",
32
+ additionalProperties: false,
33
+ required: ["angle", "verdict", "candidates", "fyi"],
34
+ properties: {
35
+ angle: {
36
+ type: "string",
37
+ enum: [...LEARN_ANGLES],
38
+ },
39
+ verdict: {
40
+ type: "string",
41
+ enum: ["clean", "actionable"],
42
+ },
43
+ candidates: {
44
+ type: "array",
45
+ items: {
46
+ type: "object",
47
+ additionalProperties: false,
48
+ required: ["decision", "summary", "target", "evidence"],
49
+ properties: {
50
+ decision: {
51
+ type: "string",
52
+ enum: [
53
+ "CAPTURE_LEARN",
54
+ "SHOULD_BE_CODE",
55
+ "UPDATE_EXISTING_DOC",
56
+ "NEW_DOC",
57
+ "STALE_DOC",
58
+ "SKIP",
59
+ ],
60
+ },
61
+ summary: { type: "string" },
62
+ target: { type: ["string", "null"] },
63
+ evidence: { type: "string" },
64
+ },
65
+ },
66
+ },
67
+ fyi: {
68
+ type: "array",
69
+ items: { type: "string" },
70
+ },
71
+ },
72
+ };
73
+
74
+ /** One chosen angle + the parent's optional plan-specific emphasis for its task text. */
75
+ export interface LearnAngleSelection {
76
+ angle: string;
77
+ emphasis?: string;
78
+ }
79
+
80
+ /**
81
+ * The angle policy as one pure function (tested implementation, not guidance): 2–4 angles, no
82
+ * duplicates, only the four known slugs, and `session-deviations` always included. Returns the
83
+ * human-readable rule violation, or null when the selection is valid.
84
+ */
85
+ export function angleSelectionError(selections: LearnAngleSelection[]): string | null {
86
+ if (selections.length < 2 || selections.length > 4) {
87
+ return `choose 2–4 angles (got ${selections.length})`;
88
+ }
89
+ const seen = new Set<string>();
90
+ for (const { angle } of selections) {
91
+ if (!(LEARN_ANGLES as readonly string[]).includes(angle)) {
92
+ return `unknown angle '${angle}' — the valid angles are ${LEARN_ANGLES.join(", ")}`;
93
+ }
94
+ if (seen.has(angle)) {
95
+ return `duplicate angle '${angle}' — each angle at most once`;
96
+ }
97
+ seen.add(angle);
98
+ }
99
+ if (!seen.has(MANDATORY_ANGLE)) {
100
+ return `the '${MANDATORY_ANGLE}' angle is mandatory — always include it`;
101
+ }
102
+ return null;
103
+ }
104
+
105
+ /**
106
+ * Compose one lane's task text IN CODE (the prompt-drift-proof half of the migration): the
107
+ * assigned angle, the absolute manifest path (read first), the bundle dir, and the parent's
108
+ * optional emphasis appended verbatim. Deliberately short — the angle rubric lives in the agent
109
+ * def, not the task.
110
+ */
111
+ function laneTask(selection: LearnAngleSelection, manifestPath: string, bundleDir: string): string {
112
+ const base =
113
+ `angle: ${selection.angle} — analyze ONLY this angle. ` +
114
+ `Read the evidence-bundle manifest FIRST: ${manifestPath} (bundle dir: ${bundleDir}). ` +
115
+ "Do not re-gather the bundle.";
116
+ const emphasis = selection.emphasis?.trim();
117
+ return emphasis !== undefined && emphasis !== "" ? `${base} Emphasis: ${emphasis}` : base;
118
+ }
119
+
120
+ /**
121
+ * Run the learn analyst wave: one `perk.learn-analyst` lane per selected angle over the shared
122
+ * evidence bundle, `best-effort` completeness (lane failure = a skipped angle; only a wave-level
123
+ * failure makes the result incomplete). Assumes a validated selection — the `run_learn_wave` tool
124
+ * runs `angleSelectionError` first; `renderWaveScript`'s programmer-error throws (empty/duplicate
125
+ * keys) remain the backstop.
126
+ */
127
+ export async function runLearnWave(
128
+ adapter: WaveAdapter,
129
+ opts: {
130
+ selections: LearnAngleSelection[];
131
+ manifestPath: string;
132
+ bundleDir: string;
133
+ model?: string;
134
+ },
135
+ signal?: AbortSignal,
136
+ ): Promise<WaveResult> {
137
+ const lanes: WaveLane[] = opts.selections.map((selection) => ({
138
+ key: selection.angle,
139
+ label: selection.angle,
140
+ agent: "perk.learn-analyst",
141
+ phase: "learn",
142
+ task: laneTask(selection, opts.manifestPath, opts.bundleDir),
143
+ }));
144
+ return await runReportWave(
145
+ adapter,
146
+ {
147
+ flow: "learn",
148
+ lanes,
149
+ outputSchema: LEARN_ANALYST_REPORT_SCHEMA,
150
+ completeness: "best-effort",
151
+ ...(opts.model !== undefined ? { model: opts.model } : {}),
152
+ },
153
+ signal,
154
+ );
155
+ }
@@ -0,0 +1,126 @@
1
+ // The in-memory `WaveAdapter` test double — a FIRST-CLASS deliverable: the runner's own tests
2
+ // and the future flow tests drive the whole wave lifecycle through it with no event bus, no
3
+ // child processes, and no temp dirs. Every failure arm of `runReportWave` is reachable through
4
+ // a config knob, and the recorded calls let tests assert the spawn contract (`mission: false`,
5
+ // `context: "fresh"`, the rendered script) and the stop-on-timeout/cancel behavior.
6
+ //
7
+ // It honors the same sequencing contract as the production adapter: `onComplete()` before a
8
+ // successful `ping()` throws (the async-complete channel is advertised by ping, not pinned).
9
+
10
+ import type {
11
+ WaveAdapter,
12
+ WaveCompletion,
13
+ WavePing,
14
+ WaveRunHandle,
15
+ WaveSpawnParams,
16
+ } from "./reportWave.ts";
17
+
18
+ export interface MemoryWaveAdapterConfig {
19
+ /** The ping outcome; null exercises the unavailable arm. Defaults to a valid ping. */
20
+ ping?: WavePing | null;
21
+ /** When set, spawn throws this message (the spawn-failed arm). */
22
+ spawnError?: string;
23
+ /**
24
+ * Delivery ordering of the auto-completion relative to the spawn reply. The default delivers
25
+ * after the reply settles; `complete-then-reply` delivers synchronously inside spawn — the
26
+ * real completion-before-reply race the runner must buffer through.
27
+ */
28
+ ordering?: "reply-then-complete" | "complete-then-reply";
29
+ /** `false` ⇒ the run never completes (tests pair this with a tiny `spec.timeoutMs`). */
30
+ completion?: false;
31
+ /** What `readAggregate` returns. Defaults to a complete run with an empty aggregate. */
32
+ aggregate?: { state: string; error?: string; value: unknown };
33
+ /**
34
+ * Per-spawn aggregate FIFO for multi-wave tests (e.g. the pr-review retry): each spawn assigns
35
+ * the next queued aggregate to its handle (keyed by `asyncDir`), and `readAggregate(handle)`
36
+ * returns the handle's assigned aggregate. When the queue is exhausted (or absent), reads fall
37
+ * back to the single `aggregate`/`setAggregate` staging — the knob is purely additive.
38
+ */
39
+ aggregates?: { state: string; error?: string; value: unknown }[];
40
+ /** When true, `readAggregate` throws (the aggregate-unreadable arm). */
41
+ aggregateError?: boolean;
42
+ }
43
+
44
+ export interface MemoryWaveAdapter extends WaveAdapter {
45
+ calls: { spawn: WaveSpawnParams[]; stop: WaveRunHandle[] };
46
+ /** Deliver a completion to the subscribed handlers (contract-suite plumbing). */
47
+ emitCompletion(completion: WaveCompletion): void;
48
+ /** Replace the staged aggregate (contract-suite plumbing). */
49
+ setAggregate(aggregate: { state: string; error?: string; value: unknown }): void;
50
+ }
51
+
52
+ export function createMemoryWaveAdapter(config: MemoryWaveAdapterConfig = {}): MemoryWaveAdapter {
53
+ const ping =
54
+ config.ping === undefined ? { asyncCompleteEvent: "subagent:async-complete" } : config.ping;
55
+ let aggregate = config.aggregate ?? { state: "complete", value: [] as unknown[] };
56
+ const aggregateQueue = [...(config.aggregates ?? [])];
57
+ const assignedAggregates = new Map<string, { state: string; error?: string; value: unknown }>();
58
+ let pinged = false;
59
+ let spawnCount = 0;
60
+ const handlers = new Set<(completion: WaveCompletion) => void>();
61
+ const calls: MemoryWaveAdapter["calls"] = { spawn: [], stop: [] };
62
+
63
+ const deliver = (completion: WaveCompletion): void => {
64
+ for (const handler of handlers) handler(completion);
65
+ };
66
+
67
+ return {
68
+ calls,
69
+ emitCompletion: deliver,
70
+ setAggregate(next): void {
71
+ aggregate = next;
72
+ },
73
+
74
+ async ping(): Promise<WavePing | null> {
75
+ if (ping !== null) pinged = true;
76
+ return ping;
77
+ },
78
+
79
+ async spawn(params: WaveSpawnParams): Promise<WaveRunHandle> {
80
+ calls.spawn.push(params);
81
+ if (config.spawnError !== undefined) throw new Error(config.spawnError);
82
+ spawnCount += 1;
83
+ const handle = {
84
+ asyncId: `wave-async-${spawnCount}`,
85
+ asyncDir: `/memory/wave-async-${spawnCount}`,
86
+ };
87
+ const queued = aggregateQueue.shift();
88
+ if (queued !== undefined) assignedAggregates.set(handle.asyncDir, queued);
89
+ if (config.completion !== false) {
90
+ const completion = { asyncId: handle.asyncId, asyncDir: handle.asyncDir };
91
+ if (config.ordering === "complete-then-reply") {
92
+ // Deliver BEFORE the spawn promise resolves — the buffered-completion race.
93
+ deliver(completion);
94
+ } else {
95
+ // Deliver strictly after the caller's `await spawn(...)` continuation has run
96
+ // (a macrotask — a microtask would still beat the awaiting continuation).
97
+ setTimeout(() => deliver(completion), 0);
98
+ }
99
+ }
100
+ return handle;
101
+ },
102
+
103
+ onComplete(handler: (completion: WaveCompletion) => void): () => void {
104
+ if (!pinged) {
105
+ throw new Error(
106
+ "onComplete requires a successful ping first (the async-complete channel is advertised, not pinned)",
107
+ );
108
+ }
109
+ handlers.add(handler);
110
+ return () => handlers.delete(handler);
111
+ },
112
+
113
+ async stop(handle: WaveRunHandle): Promise<void> {
114
+ calls.stop.push(handle);
115
+ },
116
+
117
+ async readAggregate(
118
+ handle: WaveRunHandle,
119
+ ): Promise<{ state: string; error?: string; value: unknown }> {
120
+ if (config.aggregateError === true) {
121
+ throw new Error("simulated unreadable status.json");
122
+ }
123
+ return assignedAggregates.get(handle.asyncDir) ?? aggregate;
124
+ },
125
+ };
126
+ }