@mgiles/perk 2.2.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/README.md +1 -1
  2. package/extension/adapters/planAdapterPlannotator.ts +132 -91
  3. package/extension/adapters/planAdapterTombell.ts +5 -3
  4. package/extension/doors/address.ts +235 -37
  5. package/extension/doors/annotationPush.ts +924 -0
  6. package/extension/doors/auditWaveTools.ts +352 -0
  7. package/extension/doors/ciExecutor.ts +220 -55
  8. package/extension/doors/draftReviewWaveTools.ts +384 -0
  9. package/extension/doors/harvestWaveTools.ts +279 -0
  10. package/extension/doors/land.ts +1 -0
  11. package/extension/doors/learn.ts +238 -24
  12. package/extension/doors/learnFactory.ts +33 -7
  13. package/extension/doors/lifecycleGates.ts +1 -1
  14. package/extension/doors/objectiveReviewBrowser.ts +514 -0
  15. package/extension/doors/objectiveStack.ts +1143 -0
  16. package/extension/doors/planReviewBrowser.ts +453 -0
  17. package/extension/doors/plannotatorHandoff.ts +120 -49
  18. package/extension/doors/prReview.ts +203 -22
  19. package/extension/doors/prReviewBrowser.ts +31 -23
  20. package/extension/doors/prReviewDynamic.ts +256 -0
  21. package/extension/doors/prReviewTerminal.ts +6 -11
  22. package/extension/doors/reviewWaveTools.ts +374 -0
  23. package/extension/doors/selfcheck.ts +1 -1
  24. package/extension/doors/submit.ts +86 -10
  25. package/extension/factories/gistAuthor.ts +4 -4
  26. package/extension/factories/gistDraft.ts +1 -1
  27. package/extension/factories/gistSave.ts +2 -2
  28. package/extension/factories/objective.ts +7 -7
  29. package/extension/factories/objectiveAuthor.ts +5 -4
  30. package/extension/factories/objectiveDraft.ts +61 -6
  31. package/extension/factories/objectivePlan.ts +163 -8
  32. package/extension/factories/objectiveSave.ts +16 -1
  33. package/extension/factories/planMode.ts +5 -3
  34. package/extension/factories/planReview.ts +131 -36
  35. package/extension/factories/planTitle.ts +27 -9
  36. package/extension/hunkFeedback/inbox.ts +389 -0
  37. package/extension/hunkFeedback/perkFeedback.ts +364 -0
  38. package/extension/hunkFeedback/receiver.ts +296 -0
  39. package/extension/hunkFeedback/store.ts +533 -0
  40. package/extension/index.ts +116 -44
  41. package/extension/substrate/cache.ts +75 -22
  42. package/extension/substrate/coldDoor.ts +2 -3
  43. package/extension/substrate/config.ts +42 -14
  44. package/extension/substrate/git.ts +10 -0
  45. package/extension/substrate/providers.ts +4 -33
  46. package/extension/substrate/resources.ts +11 -0
  47. package/extension/substrate/result.ts +13 -5
  48. package/extension/substrate/sessionData.ts +3 -3
  49. package/extension/substrate/sessionPointers.ts +3 -3
  50. package/extension/substrate/structuredOutput.ts +78 -19
  51. package/extension/substrate/terminalLaunch.ts +1 -1
  52. package/extension/substrate/toolGating.ts +206 -26
  53. package/extension/substrate/workflowState.ts +6 -0
  54. package/extension/surfaces/footerProvider.ts +6 -6
  55. package/extension/surfaces/surfaces.ts +98 -282
  56. package/extension/vendor/btw/btw.ts +57 -12
  57. package/extension/waves/adversarialReviewWave.ts +164 -0
  58. package/extension/waves/auditWave.ts +312 -0
  59. package/extension/waves/draftReviewWave.ts +189 -0
  60. package/extension/waves/harvestWave.ts +399 -0
  61. package/extension/waves/learnWave.ts +155 -0
  62. package/extension/waves/memoryAdapter.ts +139 -0
  63. package/extension/waves/objectiveExplorerWave.ts +120 -0
  64. package/extension/waves/prReviewDynamicWave.ts +696 -0
  65. package/extension/waves/prReviewWave.ts +274 -0
  66. package/extension/waves/reportWave.ts +749 -0
  67. package/extension/waves/reviewClassifierWave.ts +109 -0
  68. package/extension/waves/rpcAdapter.ts +264 -0
  69. package/extension/worker/readOnlySession.ts +3 -3
  70. package/extension/worker/worker.ts +71 -93
  71. package/extension/workerMain.ts +4 -6
  72. package/package.json +20 -5
  73. package/prompts/README.md +19 -0
  74. package/prompts/_fixtures/live.yaml +71 -72
  75. package/prompts/common/resume-advisory.md +1 -0
  76. package/prompts/contexts/adapters/plannotator-gist.md +13 -0
  77. package/prompts/contexts/adapters/plannotator-objective.md +1 -2
  78. package/prompts/contexts/adapters/plannotator-plan.md +1 -2
  79. package/prompts/contexts/gist-authoring.md +6 -20
  80. package/prompts/contexts/objective-authoring.md +6 -18
  81. package/prompts/stages/address/action.md +4 -4
  82. package/prompts/stages/address/preview.md +3 -3
  83. package/prompts/stages/audit.md +18 -0
  84. package/prompts/stages/conflict-resolution.md +1 -1
  85. package/prompts/stages/gist-author/seed.md +4 -4
  86. package/prompts/stages/gist-save.md +1 -1
  87. package/prompts/stages/implement.md +3 -1
  88. package/prompts/stages/learn-code.md +1 -1
  89. package/prompts/stages/learn-docs.md +2 -2
  90. package/prompts/stages/learn-harvest.md +15 -0
  91. package/prompts/stages/learn-orchestrate.md +7 -5
  92. package/prompts/stages/objective-author/adopt.md +5 -3
  93. package/prompts/stages/objective-author/file.md +5 -4
  94. package/prompts/stages/objective-author/seed.md +5 -4
  95. package/prompts/stages/objective-land.md +7 -0
  96. package/prompts/stages/objective-plan/guidance.md +2 -2
  97. package/prompts/stages/objective-plan/seed.md +5 -1
  98. package/prompts/stages/objective-recover.md +8 -0
  99. package/prompts/stages/objective-replan.md +4 -2
  100. package/prompts/stages/objective-review-browser.md +9 -0
  101. package/prompts/stages/objective-sync.md +6 -0
  102. package/prompts/stages/plan-from/adopt.md +2 -2
  103. package/prompts/stages/plan-from/file.md +2 -2
  104. package/prompts/stages/plan-review-browser.md +9 -0
  105. package/prompts/stages/pr-review-browser/active.md +6 -6
  106. package/prompts/stages/pr-review-browser/foreign.md +6 -6
  107. package/prompts/stages/pr-review-dynamic.md +7 -0
  108. package/prompts/stages/pr-review-terminal/active.md +7 -7
  109. package/prompts/stages/pr-review-terminal/foreign.md +7 -7
  110. package/prompts/stages/pr-review.md +7 -6
  111. package/prompts/stages/replan.md +3 -3
  112. package/shared/README.md +2 -2
  113. package/shared/bindings.yaml +15 -0
  114. package/shared/contracts-history.md +11 -0
  115. package/shared/contracts.md +3713 -227
  116. package/shared/providers.yaml +27 -60
  117. package/shared/registry.yaml +32 -8
  118. package/shared/schemas/outputs/objective-doctor.schema.json +449 -0
  119. package/shared/schemas/outputs/objective-stack-land.schema.json +732 -0
  120. package/shared/schemas/outputs/objective-stack-recover.schema.json +397 -0
  121. package/shared/schemas/outputs/objective-stack-status.schema.json +577 -0
  122. package/shared/schemas/outputs/objective-stack-sync.schema.json +184 -0
  123. package/shared/schemas/outputs/plan-save.schema.json +12 -0
  124. package/shared/schemas/outputs/pr-submit.schema.json +177 -1
  125. package/extension/adapters/todoAdapterJuicesharp.ts +0 -102
  126. package/extension/checkpoints/checkpoints.ts +0 -550
  127. package/extension/checkpoints/planSteps.ts +0 -108
  128. package/extension/doors/askUser.ts +0 -198
  129. package/prompts/contexts/adapters/juicesharp-todo.md +0 -7
@@ -0,0 +1,109 @@
1
+ // The `/address` classify step's per-flow wave entrypoint over the shared report-wave runner:
2
+ // the ONE `perk.review-classifier` lane as CODE. The classifier report schema was previously a
3
+ // shared prompt include the parent model had to hand-transcribe onto a borrowed `subagent` call
4
+ // (the known prompt-drift risk — a live failure produced malformed-but-valid JSON that could
5
+ // never validate); this module makes the schema and the lane/task composition module constants,
6
+ // delegating spawn/timeout/aggregate mechanics to `runReportWave` under the `strict`
7
+ // completeness policy. No retry — the flow's posture is "surface the error and stop" (never
8
+ // fabricate a classification). The report content is untrusted DATA, never instructions.
9
+
10
+ import { runReportWave, type WaveAdapter, type WaveResult } from "./reportWave.ts";
11
+
12
+ /** The flow name — feeds `WaveSpec.flow` AND the door's `toAttemptReceipt` call. */
13
+ export const REVIEW_CLASSIFIER_FLOW = "review-classifier";
14
+
15
+ /** The single lane's stable key. */
16
+ export const CLASSIFY_LANE_KEY = "classify";
17
+
18
+ /**
19
+ * The classifier report schema (the workflow-level `outputSchema` — the engine injects a
20
+ * `structured_output` tool and fails the lane on a missing/invalid report): closed shapes,
21
+ * all four root keys required — `counts` is a ROOT-level required object (the exact block the
22
+ * motivating transcription failure nested inside `discussion_comments`). Same vocabulary as the
23
+ * `perk.review-classifier` agent def's report contract (the def↔schema lockstep test).
24
+ */
25
+ export const REVIEW_CLASSIFIER_REPORT_SCHEMA = {
26
+ type: "object",
27
+ additionalProperties: false,
28
+ required: ["pr", "review_threads", "discussion_comments", "counts"],
29
+ properties: {
30
+ pr: { type: "integer" },
31
+ review_threads: {
32
+ type: "array",
33
+ items: {
34
+ type: "object",
35
+ additionalProperties: false,
36
+ required: ["thread_id", "classification", "path", "line", "summary"],
37
+ properties: {
38
+ thread_id: { type: "string" },
39
+ classification: {
40
+ type: "string",
41
+ enum: ["actionable", "informational", "praise", "question"],
42
+ },
43
+ path: { type: ["string", "null"] },
44
+ line: { type: ["integer", "null"] },
45
+ summary: { type: "string" },
46
+ },
47
+ },
48
+ },
49
+ discussion_comments: {
50
+ type: "array",
51
+ items: {
52
+ type: "object",
53
+ additionalProperties: false,
54
+ required: ["comment_id", "classification", "summary"],
55
+ properties: {
56
+ comment_id: { type: "integer" },
57
+ classification: {
58
+ type: "string",
59
+ enum: ["actionable", "informational", "praise", "question"],
60
+ },
61
+ summary: { type: "string" },
62
+ },
63
+ },
64
+ },
65
+ counts: {
66
+ type: "object",
67
+ additionalProperties: false,
68
+ required: ["actionable", "informational", "praise", "question"],
69
+ properties: {
70
+ actionable: { type: "integer" },
71
+ informational: { type: "integer" },
72
+ praise: { type: "integer" },
73
+ question: { type: "integer" },
74
+ },
75
+ },
76
+ },
77
+ };
78
+
79
+ /**
80
+ * Run the review-classifier wave: ONE fresh-context `perk.review-classifier` lane with the fixed
81
+ * code-owned task (the child fetches the feedback itself via `perk pr feedback --json` — nothing
82
+ * model-relayed enters the task), `strict` completeness, no retry, module-default timeout.
83
+ * Returns the runner's `WaveResult` unchanged — the only projection lives in the door.
84
+ */
85
+ export async function runReviewClassifierWave(
86
+ adapter: WaveAdapter,
87
+ opts: { model?: string; timeoutMs?: number; signal?: AbortSignal } = {},
88
+ ): Promise<WaveResult> {
89
+ return await runReportWave(
90
+ adapter,
91
+ {
92
+ flow: REVIEW_CLASSIFIER_FLOW,
93
+ lanes: [
94
+ {
95
+ key: CLASSIFY_LANE_KEY,
96
+ label: CLASSIFY_LANE_KEY,
97
+ agent: "perk.review-classifier",
98
+ phase: "address",
99
+ task: "Fetch + classify the review feedback on this plan's PR.",
100
+ },
101
+ ],
102
+ outputSchema: REVIEW_CLASSIFIER_REPORT_SCHEMA,
103
+ completeness: "strict",
104
+ ...(opts.model !== undefined ? { model: opts.model } : {}),
105
+ ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
106
+ },
107
+ opts.signal,
108
+ );
109
+ }
@@ -0,0 +1,264 @@
1
+ // The production `WaveAdapter` over the pi-subagents v1 extension RPC seam, pure over pi's
2
+ // in-process event bus (unit-testable offline with a fake bus + a fake RPC responder, exactly
3
+ // like the plannotator bridge).
4
+ //
5
+ // ENVELOPE (pinned against pi-subagents 0.43.0, re-verified at 0.45.0; `src/extension/rpc.ts`):
6
+ // requests are emitted on
7
+ // `subagents:rpc:v1:request` as `{version: 1, requestId, method, params?, source?}`; the reply
8
+ // arrives once on `subagents:rpc:v1:reply:<requestId>` as
9
+ // `{version, requestId, method?, success: true, data} | {…, success: false, error: {code, message}}`.
10
+ // `ping` works even with no active session and advertises capabilities plus the event channel
11
+ // names — `events.asyncComplete` is the ADVERTISED async-complete channel, deliberately NOT
12
+ // pinned here (only the versioned request/reply literals are; that is what the versioned
13
+ // envelope is for). `pi-subagents` is not an allowed bare import (`bareImportGuard.test.ts`), so
14
+ // its constants/types cannot be imported — the doctor `subagent-compat` probes are the drift
15
+ // tripwire, and every pi-subagents bump warrants an adapter re-verify.
16
+ //
17
+ // COMPLETION PAYLOAD (source-read-derived, 0.45.0 `src/runs/background/result-watcher.ts` +
18
+ // `src/runs/foreground/subagent-executor.ts`): the async-complete event spreads the result-file
19
+ // data plus a normalized per-child `results` array; on workflow rows the `agent` field carries
20
+ // the workflow LANE KEY (the overloaded upstream field — mapped to `WaveChildReceipt.key` here,
21
+ // never exposed). Normalization is defensively output-free: `output`/`summary`/
22
+ // `structuredOutput` never enter a receipt child, unknown fields are ignored, and malformed
23
+ // rows are dropped without failing the wave (receipt absence degrades correlation only).
24
+
25
+ import { randomUUID } from "node:crypto";
26
+ import { readFileSync } from "node:fs";
27
+ import { join } from "node:path";
28
+ import type {
29
+ WaveAdapter,
30
+ WaveBus,
31
+ WaveChildReceipt,
32
+ WaveCompletion,
33
+ WavePing,
34
+ WaveRunHandle,
35
+ WaveSpawnParams,
36
+ } from "./reportWave.ts";
37
+
38
+ /** The pinned v1 request channel (pi-subagents `SUBAGENT_RPC_REQUEST_EVENT`). */
39
+ export const WAVE_RPC_REQUEST_EVENT = "subagents:rpc:v1:request";
40
+ /** The pinned v1 reply-channel prefix (pi-subagents `SUBAGENT_RPC_REPLY_EVENT_PREFIX`). */
41
+ export const WAVE_RPC_REPLY_EVENT_PREFIX = "subagents:rpc:v1:reply:";
42
+ /** The pinned v1 protocol version. */
43
+ export const WAVE_RPC_PROTOCOL_VERSION = 1;
44
+
45
+ /**
46
+ * The ping reply timeout: fast loud-degrade when pi-subagents is absent (ping is a pure
47
+ * in-process lookup on the responder side). Overridable for tests via PERK_WAVE_RPC_PING_MS.
48
+ */
49
+ export const WAVE_RPC_PING_TIMEOUT_MS = 5_000;
50
+
51
+ /**
52
+ * The reply timeout for the working methods (spawn does real work: writes run files, forks the
53
+ * detached process). Overridable for tests via PERK_WAVE_RPC_REPLY_MS.
54
+ */
55
+ export const WAVE_RPC_REPLY_TIMEOUT_MS = 30_000;
56
+
57
+ function envTimeoutMs(name: string, fallback: number): number {
58
+ const raw = Number(process.env[name] ?? "");
59
+ return Number.isFinite(raw) && raw > 0 ? raw : fallback;
60
+ }
61
+
62
+ function pingTimeoutMs(): number {
63
+ return envTimeoutMs("PERK_WAVE_RPC_PING_MS", WAVE_RPC_PING_TIMEOUT_MS);
64
+ }
65
+
66
+ function replyTimeoutMs(): number {
67
+ return envTimeoutMs("PERK_WAVE_RPC_REPLY_MS", WAVE_RPC_REPLY_TIMEOUT_MS);
68
+ }
69
+
70
+ function isRecord(value: unknown): value is Record<string, unknown> {
71
+ return typeof value === "object" && value !== null && !Array.isArray(value);
72
+ }
73
+
74
+ /**
75
+ * One v1 request/reply round trip: subscribe the per-request reply channel (disposed via the
76
+ * returned unsubscribe once settled), emit the request envelope, await the reply within
77
+ * `timeoutMs`. A `success: false` reply narrows to a thrown `Error` carrying `code: message`.
78
+ */
79
+ async function request(
80
+ bus: WaveBus,
81
+ method: string,
82
+ params: unknown,
83
+ timeoutMs: number,
84
+ ): Promise<unknown> {
85
+ const requestId = randomUUID();
86
+ return await new Promise<unknown>((resolve, reject) => {
87
+ let settled = false;
88
+ const settle = (fn: () => void): void => {
89
+ if (settled) return;
90
+ settled = true;
91
+ clearTimeout(timer);
92
+ unsubscribe();
93
+ fn();
94
+ };
95
+ const unsubscribe = bus.on(`${WAVE_RPC_REPLY_EVENT_PREFIX}${requestId}`, (data) => {
96
+ if (!isRecord(data)) {
97
+ settle(() => reject(new Error(`subagent RPC ${method} reply is not an object`)));
98
+ return;
99
+ }
100
+ if (data.success === true) {
101
+ settle(() => resolve(data.data));
102
+ return;
103
+ }
104
+ const error = isRecord(data.error) ? data.error : {};
105
+ const code = typeof error.code === "string" ? error.code : "unknown_error";
106
+ const message = typeof error.message === "string" ? error.message : "no error detail";
107
+ settle(() => reject(new Error(`${code}: ${message}`)));
108
+ });
109
+ const timer = setTimeout(
110
+ () =>
111
+ settle(() => reject(new Error(`subagent RPC ${method} timed out after ${timeoutMs}ms`))),
112
+ timeoutMs,
113
+ );
114
+ bus.emit(WAVE_RPC_REQUEST_EVENT, {
115
+ version: WAVE_RPC_PROTOCOL_VERSION,
116
+ requestId,
117
+ method,
118
+ ...(params !== undefined ? { params } : {}),
119
+ source: { extension: "perk" },
120
+ });
121
+ });
122
+ }
123
+
124
+ /**
125
+ * Narrow one completion-payload `results` row's `artifactPaths` to its string-valued own
126
+ * properties; when none survive, fall back to the watcher-normalized `artifactPath` as
127
+ * `{ outputPath }`. Undefined when neither yields a path.
128
+ */
129
+ function narrowArtifactPaths(row: Record<string, unknown>): Record<string, string> | undefined {
130
+ if (isRecord(row.artifactPaths)) {
131
+ const paths: Record<string, string> = {};
132
+ for (const [key, value] of Object.entries(row.artifactPaths)) {
133
+ if (typeof value === "string") paths[key] = value;
134
+ }
135
+ if (Object.keys(paths).length > 0) return paths;
136
+ }
137
+ if (typeof row.artifactPath === "string" && row.artifactPath !== "") {
138
+ return { outputPath: row.artifactPath };
139
+ }
140
+ return undefined;
141
+ }
142
+
143
+ /**
144
+ * Narrow one `results` row into an output-free receipt child; null ⇒ the row is dropped (a
145
+ * malformed row never fails the wave). The upstream `agent` field carries the workflow lane key
146
+ * — it becomes `key`; `agent` is deliberately left unset (enriched from Perk-owned lane specs
147
+ * upstream). `output`/`summary`/`structuredOutput` and unknown fields are NEVER copied.
148
+ */
149
+ function narrowReceiptChild(row: unknown): WaveChildReceipt | null {
150
+ if (!isRecord(row)) return null;
151
+ const key = row.agent;
152
+ if (typeof key !== "string" || key === "") return null;
153
+ const artifactPaths = narrowArtifactPaths(row);
154
+ return {
155
+ key,
156
+ ...(typeof row.runId === "string" && row.runId !== "" ? { runId: row.runId } : {}),
157
+ ...(typeof row.success === "boolean" ? { success: row.success } : {}),
158
+ ...(row.outputState === "present" ||
159
+ row.outputState === "absent" ||
160
+ row.outputState === "unknown"
161
+ ? { outputState: row.outputState }
162
+ : {}),
163
+ ...(artifactPaths !== undefined ? { artifactPaths } : {}),
164
+ };
165
+ }
166
+
167
+ /** Narrow a ping reply to the advertised async-complete channel; any miss ⇒ null (unavailable). */
168
+ function narrowPing(data: unknown): WavePing | null {
169
+ if (!isRecord(data)) return null;
170
+ const capabilities = isRecord(data.capabilities) ? data.capabilities : {};
171
+ if (capabilities.asyncSpawn !== true) return null;
172
+ if (!Array.isArray(data.methods) || !data.methods.includes("spawn")) return null;
173
+ const events = isRecord(data.events) ? data.events : {};
174
+ const asyncComplete = events.asyncComplete;
175
+ if (typeof asyncComplete !== "string" || asyncComplete === "") return null;
176
+ return { asyncCompleteEvent: asyncComplete };
177
+ }
178
+
179
+ /**
180
+ * Create the production wave adapter over pi's event bus. Sequencing contract (enforced): a
181
+ * successful `ping()` must precede `onComplete()` — the completion channel name is taken from
182
+ * ping's advertised `events.asyncComplete`, never pinned.
183
+ */
184
+ export function createRpcWaveAdapter(bus: WaveBus): WaveAdapter {
185
+ let advertised: WavePing | null = null;
186
+
187
+ return {
188
+ async ping(): Promise<WavePing | null> {
189
+ let data: unknown;
190
+ try {
191
+ data = await request(bus, "ping", undefined, pingTimeoutMs());
192
+ } catch {
193
+ return null;
194
+ }
195
+ advertised = narrowPing(data);
196
+ return advertised;
197
+ },
198
+
199
+ async spawn(params: WaveSpawnParams): Promise<WaveRunHandle> {
200
+ const data = await request(bus, "spawn", params, replyTimeoutMs());
201
+ const details = isRecord(data) && isRecord(data.details) ? data.details : {};
202
+ const asyncId = details.asyncId;
203
+ const asyncDir = details.asyncDir;
204
+ if (typeof asyncId !== "string" || asyncId === "") {
205
+ throw new Error("subagent RPC spawn reply carries no asyncId");
206
+ }
207
+ if (typeof asyncDir !== "string" || asyncDir === "") {
208
+ throw new Error("subagent RPC spawn reply carries no asyncDir");
209
+ }
210
+ return { asyncId, asyncDir };
211
+ },
212
+
213
+ onComplete(handler: (completion: WaveCompletion) => void): () => void {
214
+ if (advertised === null) {
215
+ throw new Error(
216
+ "onComplete requires a successful ping first (the async-complete channel is advertised, not pinned)",
217
+ );
218
+ }
219
+ return bus.on(advertised.asyncCompleteEvent, (data) => {
220
+ if (!isRecord(data)) return;
221
+ // The payload spreads the result-file data: `id` is the async run id; `asyncDir` the
222
+ // durable run directory. At least one is present on real payloads. The observability
223
+ // fields (state/success/results) are optional — identity-only payloads stay valid.
224
+ const children = Array.isArray(data.results)
225
+ ? data.results.flatMap((row) => {
226
+ const child = narrowReceiptChild(row);
227
+ return child === null ? [] : [child];
228
+ })
229
+ : undefined;
230
+ handler({
231
+ ...(typeof data.id === "string" ? { asyncId: data.id } : {}),
232
+ ...(typeof data.asyncDir === "string" ? { asyncDir: data.asyncDir } : {}),
233
+ ...(typeof data.state === "string" && data.state !== "" ? { state: data.state } : {}),
234
+ ...(typeof data.success === "boolean" ? { success: data.success } : {}),
235
+ ...(children !== undefined ? { children } : {}),
236
+ });
237
+ });
238
+ },
239
+
240
+ async stop(handle: WaveRunHandle): Promise<void> {
241
+ try {
242
+ await request(bus, "stop", { id: handle.asyncId }, replyTimeoutMs());
243
+ } catch {
244
+ // Best-effort by contract: the run may already be terminal, or the responder gone.
245
+ }
246
+ },
247
+
248
+ async readAggregate(
249
+ handle: WaveRunHandle,
250
+ ): Promise<{ state: string; error?: string; value: unknown }> {
251
+ const raw = readFileSync(join(handle.asyncDir, "status.json"), "utf8");
252
+ const parsed: unknown = JSON.parse(raw);
253
+ if (!isRecord(parsed) || typeof parsed.state !== "string") {
254
+ throw new Error("status.json carries no state field");
255
+ }
256
+ const workflow = isRecord(parsed.workflow) ? parsed.workflow : {};
257
+ return {
258
+ state: parsed.state,
259
+ ...(typeof parsed.error === "string" ? { error: parsed.error } : {}),
260
+ value: workflow.value,
261
+ };
262
+ },
263
+ };
264
+ }
@@ -19,7 +19,7 @@
19
19
  // loading perk's own extension into the child). The `no*` flags keep perk's machinery out of the
20
20
  // child and keep the path offline/deterministic. A custom loader is reloaded by the caller.
21
21
 
22
- import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
22
+ import { existsSync, mkdirSync, mkdtempSync } from "node:fs";
23
23
  import { tmpdir } from "node:os";
24
24
  import { join } from "node:path";
25
25
  import type { Api, Model } from "@earendil-works/pi-ai";
@@ -30,7 +30,7 @@ import {
30
30
  SessionManager,
31
31
  SettingsManager,
32
32
  } from "@earendil-works/pi-coding-agent";
33
- import { runScratchDir, scratchDir } from "../substrate/cache.ts";
33
+ import { atomicWriteFileSync, runScratchDir, scratchDir } from "../substrate/cache.ts";
34
34
 
35
35
  /**
36
36
  * The SDK-level read-only allowlist (no `bash`; stricter than the in-session READ_ONLY_TOOLS).
@@ -267,7 +267,7 @@ export async function runReadOnlyChild(
267
267
 
268
268
  // write → verify → pass-path: persist the full result, then confirm it landed.
269
269
  const scratchPath = resolveScratchPath(opts.cwd, opts.runId, opts.step);
270
- writeFileSync(scratchPath, output, "utf8");
270
+ atomicWriteFileSync(scratchPath, output);
271
271
  if (!existsSync(scratchPath)) {
272
272
  return failure("read-only child: scratch write could not be verified.", "scratch-verify");
273
273
  }