@abelxiaoxing/cadence 1.0.2 → 1.0.3

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.
package/src/index.ts CHANGED
@@ -99,6 +99,14 @@ function activateDispatcher(
99
99
  }
100
100
  }
101
101
 
102
+ function splitUsage(result: unknown): { payload: unknown; usage?: unknown } {
103
+ if (typeof result !== "object" || result === null || !("usage" in result)) {
104
+ return { payload: result };
105
+ }
106
+ const { usage, ...payload } = result as Record<string, unknown>;
107
+ return { payload, usage };
108
+ }
109
+
102
110
  export default function register(pi: ExtensionAPI): void {
103
111
  const parentPayloadBridge = new ParentPayloadBridge();
104
112
  const runtime = new Runtime({ parentPayloadBridge });
@@ -108,7 +116,7 @@ export default function register(pi: ExtensionAPI): void {
108
116
  name: DISPATCH_TOOL,
109
117
  label: "Abel Dispatch",
110
118
  description:
111
- "Private Abel workflow delegation: run bounded read-only evidence or Worker phase requests, apply or discard retained results, cancel work, or finish the stage. Inactive unless an eligible Abel stage verified its invocation.",
119
+ "Private Abel workflow delegation: run bounded read-only evidence or Worker phase requests, apply or discard retained results, apply a parent-only stable AGENTS checkpoint, cancel work, or finish the stage. Inactive unless an eligible Abel stage verified its invocation.",
112
120
  parameters: {
113
121
  type: "object",
114
122
  properties: {
@@ -121,12 +129,32 @@ export default function register(pi: ExtensionAPI): void {
121
129
  type: "string",
122
130
  description: "Retained result id for apply/discard",
123
131
  },
132
+ requestId: {
133
+ type: "string",
134
+ description: "Current Implement apply/discard operation identity",
135
+ },
136
+ rejection: {
137
+ type: "object",
138
+ description: "Typed parent rejection for Implement discard",
139
+ },
140
+ agentsCheckpoint: {
141
+ type: "object",
142
+ description:
143
+ "Parent-owned approved managed-block checkpoint for action=apply (mutually exclusive with resultId)",
144
+ },
124
145
  },
125
146
  required: ["action"],
126
147
  },
127
148
  async execute(
128
149
  toolCallId: string,
129
- params: { action?: string; request?: unknown; resultId?: string },
150
+ params: {
151
+ action?: string;
152
+ request?: unknown;
153
+ resultId?: string;
154
+ requestId?: string;
155
+ rejection?: unknown;
156
+ agentsCheckpoint?: unknown;
157
+ },
130
158
  signal: AbortSignal | undefined,
131
159
  onUpdate: AgentToolUpdateCallback<unknown> | undefined,
132
160
  ctx: ExtensionContext,
@@ -135,10 +163,11 @@ export default function register(pi: ExtensionAPI): void {
135
163
  const validRun =
136
164
  action === "run" && runtime.validateRequest(params.request).ok;
137
165
  const tuiRun = ctx.mode === "tui" && validRun;
166
+ const { action: _action, ...operation } = params;
138
167
  const result = tuiRun
139
168
  ? await runtime.execute(
140
169
  action,
141
- params,
170
+ operation,
142
171
  ctx,
143
172
  signal,
144
173
  activity.observe(
@@ -146,24 +175,21 @@ export default function register(pi: ExtensionAPI): void {
146
175
  onUpdate as ((result: unknown) => void) | undefined,
147
176
  ),
148
177
  )
149
- : await runtime.execute(action, params, ctx, signal);
178
+ : await runtime.execute(action, operation, ctx, signal);
150
179
  const display = tuiRun
151
180
  ? activity.finalize(toolCallId, result)
152
181
  : undefined;
182
+ const { payload, usage } = splitUsage(result);
153
183
  const details = display
154
- ? { ...result, [ACTIVITY_DETAILS_KEY]: display }
155
- : result;
156
- if (result.ok) {
157
- return {
158
- content: [{ type: "text", text: JSON.stringify(result) }],
159
- details,
160
- usage: result.usage,
161
- };
162
- }
184
+ ? {
185
+ ...(payload as Record<string, unknown>),
186
+ [ACTIVITY_DETAILS_KEY]: display,
187
+ }
188
+ : payload;
163
189
  return {
164
- content: [{ type: "text", text: JSON.stringify(result) }],
190
+ content: [{ type: "text", text: JSON.stringify(payload) }],
165
191
  details,
166
- isError: true,
192
+ ...(usage === undefined ? {} : { usage }),
167
193
  };
168
194
  },
169
195
  renderCall(args: unknown, theme: Theme, _context: unknown) {
@@ -207,7 +233,15 @@ export default function register(pi: ExtensionAPI): void {
207
233
  activateDispatcher(pi, runtime.activation, prompt, event.prompt);
208
234
  });
209
235
 
210
- pi.on("session_start", (_event, ctx) => {
236
+ pi.on("session_start", async (_event, ctx) => {
237
+ pendingPrompt = undefined;
238
+ activity.detach();
239
+ const active = pi.getActiveTools();
240
+ if (active.includes(DISPATCH_TOOL)) {
241
+ pi.setActiveTools(deactivateTool(active, DISPATCH_TOOL));
242
+ }
243
+ await runtime.drain();
244
+ activity.clear();
211
245
  const sessionId = ctx.sessionManager?.getSessionId?.();
212
246
  if (typeof sessionId === "string") {
213
247
  parentPayloadBridge.beginSession(sessionId);
@@ -217,15 +251,7 @@ export default function register(pi: ExtensionAPI): void {
217
251
  } else {
218
252
  parentPayloadBridge.clear();
219
253
  }
220
- pendingPrompt = undefined;
221
- activity.detach();
222
254
  if (ctx.mode === "tui") activity.attach(ctx.ui);
223
- // Restore the default: dispatch is inactive unless a verified stage
224
- // reactivates it through the activation helpers.
225
- const active = pi.getActiveTools();
226
- if (active.includes(DISPATCH_TOOL)) {
227
- pi.setActiveTools(deactivateTool(active, DISPATCH_TOOL));
228
- }
229
255
  });
230
256
 
231
257
  pi.on("model_select", (event, ctx) => {
@@ -7,6 +7,7 @@ import {
7
7
  type ExtensionContext,
8
8
  ModelRuntime,
9
9
  } from "@earendil-works/pi-coding-agent";
10
+ import type { ChildFailure } from "./contracts.ts";
10
11
  import type {
11
12
  ParentPayloadBridge,
12
13
  ParentPayloadCallback,
@@ -18,6 +19,33 @@ export interface PhasePayloadBridge {
18
19
  readonly capture: ParentPayloadCapture;
19
20
  }
20
21
 
22
+ type PhaseRuntimeFailure = Extract<
23
+ ChildFailure,
24
+ { kind: "cancelled" | "environment" | "transport" }
25
+ >;
26
+
27
+ export type PhaseRuntimeResult =
28
+ | { ok: true; modelRuntime: ModelRuntime; model: Model<string> }
29
+ | { ok: false; error: string; failure: PhaseRuntimeFailure };
30
+
31
+ function phaseRuntimeFailure(
32
+ failure: PhaseRuntimeFailure,
33
+ error: string,
34
+ ): Extract<PhaseRuntimeResult, { ok: false }> {
35
+ return { ok: false, error, failure };
36
+ }
37
+
38
+ function cancelledPhaseRuntime(): Extract<PhaseRuntimeResult, { ok: false }> {
39
+ return phaseRuntimeFailure(
40
+ { kind: "cancelled", code: "cancelled" },
41
+ "child phase cancelled",
42
+ );
43
+ }
44
+
45
+ function isCancellation(error: unknown, signal?: AbortSignal): boolean {
46
+ return signal?.aborted === true && error === signal.reason;
47
+ }
48
+
21
49
  function phasePayloadCallback(
22
50
  payloadBridge: PhasePayloadBridge,
23
51
  childOnPayload: ParentPayloadCallback | undefined,
@@ -137,44 +165,82 @@ export async function runtimeFromContext(
137
165
  ctx: Pick<ExtensionContext, "model" | "modelRegistry">,
138
166
  payloadBridge: ParentPayloadBridge,
139
167
  signal?: AbortSignal,
140
- ): Promise<{ modelRuntime: ModelRuntime; model: Model<string> }> {
141
- signal?.throwIfAborted();
142
- if (!ctx.model) throw new Error("parent model is unavailable");
168
+ ): Promise<PhaseRuntimeResult> {
169
+ if (signal?.aborted) return cancelledPhaseRuntime();
170
+ if (!ctx.model) {
171
+ return phaseRuntimeFailure(
172
+ { kind: "environment", code: "sandbox-runtime-unavailable" },
173
+ "parent model is unavailable",
174
+ );
175
+ }
143
176
  const selectedModel = ctx.model;
144
177
  const selectedModelKey = modelKeyFor(selectedModel);
145
- const resolved = await abortable(
146
- ctx.modelRegistry.getApiKeyAndHeaders(selectedModel),
147
- signal,
148
- );
149
- if (!resolved.ok) throw new Error(resolved.error);
150
- signal?.throwIfAborted();
178
+ let resolved: Awaited<
179
+ ReturnType<typeof ctx.modelRegistry.getApiKeyAndHeaders>
180
+ >;
181
+ try {
182
+ resolved = await abortable(
183
+ ctx.modelRegistry.getApiKeyAndHeaders(selectedModel),
184
+ signal,
185
+ );
186
+ } catch (error) {
187
+ if (isCancellation(error, signal)) return cancelledPhaseRuntime();
188
+ throw error;
189
+ }
190
+ if (!resolved.ok) {
191
+ return phaseRuntimeFailure(
192
+ { kind: "environment", code: "sandbox-runtime-unavailable" },
193
+ "phase authentication is unavailable",
194
+ );
195
+ }
196
+ if (signal?.aborted) return cancelledPhaseRuntime();
151
197
  const model = {
152
198
  ...selectedModel,
153
199
  baseUrl: resolved.baseUrl ?? selectedModel.baseUrl,
154
200
  } as Model<string>;
155
201
  const effectiveModelKey = modelKeyFor(model);
156
202
  const capture = payloadBridge.capture(effectiveModelKey, ctx.modelRegistry);
157
- if (!capture) throw new Error("parent payload bridge is unavailable");
158
- const requireReadyCapture = () => {
159
- if (
160
- !ctx.model ||
161
- !sameSelectedModel(ctx.model, selectedModelKey) ||
162
- payloadBridge.capture(effectiveModelKey, ctx.modelRegistry) !== capture
163
- ) {
164
- throw new Error("parent payload bridge is unavailable");
165
- }
203
+ if (!capture) {
204
+ return phaseRuntimeFailure(
205
+ { kind: "transport", code: "transport-failure" },
206
+ "parent payload bridge is unavailable",
207
+ );
208
+ }
209
+ const captureIsReady = () => {
210
+ const currentModel = ctx.model;
211
+ return (
212
+ currentModel !== undefined &&
213
+ sameSelectedModel(currentModel, selectedModelKey) &&
214
+ payloadBridge.capture(effectiveModelKey, ctx.modelRegistry) === capture
215
+ );
166
216
  };
167
- requireReadyCapture();
217
+ if (!captureIsReady()) {
218
+ return phaseRuntimeFailure(
219
+ { kind: "transport", code: "transport-failure" },
220
+ "parent payload bridge is unavailable",
221
+ );
222
+ }
168
223
  const delegate = capture.delegate;
169
224
  if (!delegate) throw new Error("parent Provider is unavailable");
170
225
  const provider = phaseProvider(delegate, resolved, {
171
226
  bridge: payloadBridge,
172
227
  capture,
173
228
  });
174
- const modelRuntime = await runtimeForProvider(provider, signal);
175
- signal?.throwIfAborted();
176
- requireReadyCapture();
177
- return { modelRuntime, model };
229
+ let modelRuntime: ModelRuntime;
230
+ try {
231
+ modelRuntime = await runtimeForProvider(provider, signal);
232
+ } catch (error) {
233
+ if (isCancellation(error, signal)) return cancelledPhaseRuntime();
234
+ throw error;
235
+ }
236
+ if (signal?.aborted) return cancelledPhaseRuntime();
237
+ if (!captureIsReady()) {
238
+ return phaseRuntimeFailure(
239
+ { kind: "transport", code: "transport-failure" },
240
+ "parent payload bridge is unavailable",
241
+ );
242
+ }
243
+ return { ok: true, modelRuntime, model };
178
244
  }
179
245
 
180
246
  function abortable<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
package/src/patch.ts CHANGED
@@ -1,7 +1,23 @@
1
1
  import { spawn } from "node:child_process";
2
+ import {
3
+ copyFileSync,
4
+ lstatSync,
5
+ mkdirSync,
6
+ mkdtempSync,
7
+ readFileSync,
8
+ rmSync,
9
+ } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import path from "node:path";
2
12
  import { preflightCandidate } from "./candidate-preflight.ts";
3
- import { diffWritePaths } from "./contracts.ts";
4
- import { isCurrent } from "./file-snapshot.ts";
13
+ import {
14
+ type AgentsCheckpointRequest,
15
+ type ApplyCandidateResult,
16
+ type CandidateFailure,
17
+ diffWritePaths,
18
+ validateAgentsCheckpointRequest,
19
+ } from "./contracts.ts";
20
+ import { type Bound, isCurrent } from "./file-snapshot.ts";
5
21
  import type { ResultStore } from "./result-store.ts";
6
22
 
7
23
  interface CommandResult {
@@ -58,8 +74,223 @@ async function git(
58
74
  });
59
75
  }
60
76
 
61
- function cancellationFailure(): { ok: false; error: string } {
62
- return { ok: false, error: "candidate application cancelled" };
77
+ function failure(value: CandidateFailure): {
78
+ ok: false;
79
+ failure: CandidateFailure;
80
+ } {
81
+ return { ok: false, failure: value };
82
+ }
83
+
84
+ function cancellationFailure(): {
85
+ ok: false;
86
+ failure: CandidateFailure;
87
+ } {
88
+ return failure({ kind: "cancelled", code: "cancelled" });
89
+ }
90
+
91
+ type AgentsCheckpointResult =
92
+ | {
93
+ ok: true;
94
+ target: string;
95
+ agentsImpact: AgentsCheckpointRequest["agentsImpact"];
96
+ checkExitCode: 0;
97
+ applyExitCode: 0;
98
+ }
99
+ | {
100
+ ok: false;
101
+ failure: CandidateFailure;
102
+ };
103
+
104
+ const AGENTS_START = "<!-- ABEL:AGENTS-INDEX:START -->";
105
+ const AGENTS_END = "<!-- ABEL:AGENTS-INDEX:END -->";
106
+ const NONREGULAR_AGENTS_TARGET = Symbol("nonregular AGENTS target");
107
+
108
+ function managedParts(text: string): { prefix: string; suffix: string } | null {
109
+ const lines = text.split("\n");
110
+ const starts: Array<{ line: number; start: number; end: number }> = [];
111
+ const ends: Array<{ line: number; start: number; end: number }> = [];
112
+ let offset = 0;
113
+ for (const [line, value] of lines.entries()) {
114
+ const range = { line, start: offset, end: offset + value.length };
115
+ if (value === AGENTS_START) starts.push(range);
116
+ if (value === AGENTS_END) ends.push(range);
117
+ offset = range.end + 1;
118
+ }
119
+ if (
120
+ starts.length !== 1 ||
121
+ ends.length !== 1 ||
122
+ starts[0].line >= ends[0].line
123
+ ) {
124
+ return null;
125
+ }
126
+ return {
127
+ prefix: text.slice(0, starts[0].start),
128
+ suffix: text.slice(ends[0].end),
129
+ };
130
+ }
131
+
132
+ function checkpointContentIsScoped(
133
+ impact: AgentsCheckpointRequest["agentsImpact"],
134
+ before: string | null,
135
+ after: string | null,
136
+ ): boolean {
137
+ const beforeParts = before === null ? null : managedParts(before);
138
+ const afterParts = after === null ? null : managedParts(after);
139
+ if (impact === "update-existing") {
140
+ return (
141
+ beforeParts !== null &&
142
+ afterParts !== null &&
143
+ beforeParts.prefix === afterParts.prefix &&
144
+ beforeParts.suffix === afterParts.suffix
145
+ );
146
+ }
147
+ if (impact === "create-index") {
148
+ return (
149
+ before === null &&
150
+ afterParts !== null &&
151
+ afterParts.prefix.trim() === "" &&
152
+ afterParts.suffix.trim() === ""
153
+ );
154
+ }
155
+ if (beforeParts === null) return false;
156
+ if (after === null) {
157
+ return beforeParts.prefix.trim() === "" && beforeParts.suffix.trim() === "";
158
+ }
159
+ return (
160
+ managedParts(after) === null &&
161
+ after === `${beforeParts.prefix}${beforeParts.suffix}`
162
+ );
163
+ }
164
+
165
+ function regularText(
166
+ root: string,
167
+ target: string,
168
+ ): string | null | typeof NONREGULAR_AGENTS_TARGET {
169
+ const resolvedRoot = path.resolve(root);
170
+ const absolute = path.resolve(resolvedRoot, target);
171
+ if (
172
+ absolute === resolvedRoot ||
173
+ !absolute.startsWith(`${resolvedRoot}${path.sep}`)
174
+ ) {
175
+ throw new Error("AGENTS target escapes the root");
176
+ }
177
+ const stat = lstatSync(absolute, { throwIfNoEntry: false });
178
+ if (!stat) return null;
179
+ if (!stat.isFile() || stat.isSymbolicLink()) {
180
+ return NONREGULAR_AGENTS_TARGET;
181
+ }
182
+ return readFileSync(absolute, "utf8");
183
+ }
184
+
185
+ export async function applyAgentsCheckpoint(
186
+ root: string,
187
+ value: unknown,
188
+ signal?: AbortSignal,
189
+ ): Promise<AgentsCheckpointResult> {
190
+ const validation = validateAgentsCheckpointRequest(value);
191
+ if (!validation.ok) {
192
+ return failure({ kind: "artifact", code: "invalid-checkpoint-contract" });
193
+ }
194
+ const request = validation.value;
195
+ if (signal?.aborted) {
196
+ return cancellationFailure();
197
+ }
198
+ const before = regularText(root, request.agentsTarget);
199
+ if (before === NONREGULAR_AGENTS_TARGET) {
200
+ return failure({ kind: "artifact", code: "nonregular-mode" });
201
+ }
202
+ if (!isCurrent(root, request.snapshot as Bound)) {
203
+ return failure({ kind: "stale", code: "stale-snapshot" });
204
+ }
205
+ let targets: string[];
206
+ try {
207
+ targets = diffWritePaths(request.diff).paths;
208
+ } catch {
209
+ return failure({ kind: "artifact", code: "invalid-diff" });
210
+ }
211
+ if (targets.length !== 1 || targets[0] !== request.agentsTarget) {
212
+ return failure({ kind: "artifact", code: "agents-target-mismatch" });
213
+ }
214
+
215
+ let temp: string | undefined;
216
+ try {
217
+ if (
218
+ (request.agentsImpact === "create-index" && before !== null) ||
219
+ (request.agentsImpact !== "create-index" && before === null)
220
+ ) {
221
+ return failure({ kind: "artifact", code: "agents-impact-mismatch" });
222
+ }
223
+ temp = mkdtempSync(path.join(tmpdir(), "cadence-agents-checkpoint-"));
224
+ if (before !== null) {
225
+ const temporaryTarget = path.join(temp, request.agentsTarget);
226
+ mkdirSync(path.dirname(temporaryTarget), { recursive: true });
227
+ copyFileSync(path.join(root, request.agentsTarget), temporaryTarget);
228
+ }
229
+ const bytes = Buffer.from(request.diff, "utf8");
230
+ const candidateCheck = await git(
231
+ temp,
232
+ ["apply", "--check", "--recount", "--whitespace=nowarn", "-"],
233
+ bytes,
234
+ signal,
235
+ );
236
+ if (candidateCheck.cancelled || signal?.aborted) {
237
+ return cancellationFailure();
238
+ }
239
+ if (candidateCheck.code !== 0) {
240
+ return failure({ kind: "artifact", code: "git-apply-check-failed" });
241
+ }
242
+ const candidateApply = await git(
243
+ temp,
244
+ ["apply", "--recount", "--whitespace=nowarn", "-"],
245
+ bytes,
246
+ signal,
247
+ );
248
+ if (candidateApply.cancelled || signal?.aborted) {
249
+ return cancellationFailure();
250
+ }
251
+ if (candidateApply.code !== 0) {
252
+ return failure({ kind: "artifact", code: "git-apply-failed" });
253
+ }
254
+ const after = regularText(temp, request.agentsTarget);
255
+ if (after === NONREGULAR_AGENTS_TARGET) {
256
+ return failure({ kind: "artifact", code: "nonregular-mode" });
257
+ }
258
+ if (!checkpointContentIsScoped(request.agentsImpact, before, after)) {
259
+ return failure({ kind: "artifact", code: "outside-managed-region" });
260
+ }
261
+ if (!isCurrent(root, request.snapshot as Bound)) {
262
+ return failure({ kind: "stale", code: "stale-snapshot" });
263
+ }
264
+ const check = await git(
265
+ root,
266
+ ["apply", "--check", "--recount", "--whitespace=nowarn", "-"],
267
+ bytes,
268
+ signal,
269
+ );
270
+ if (check.cancelled || signal?.aborted) {
271
+ return cancellationFailure();
272
+ }
273
+ if (check.code !== 0) {
274
+ return failure({ kind: "stale", code: "git-apply-check-failed" });
275
+ }
276
+ const apply = await git(
277
+ root,
278
+ ["apply", "--recount", "--whitespace=nowarn", "-"],
279
+ bytes,
280
+ );
281
+ if (apply.code !== 0) {
282
+ return failure({ kind: "environment", code: "git-apply-failed" });
283
+ }
284
+ return {
285
+ ok: true,
286
+ target: request.agentsTarget,
287
+ agentsImpact: request.agentsImpact,
288
+ checkExitCode: 0,
289
+ applyExitCode: 0,
290
+ };
291
+ } finally {
292
+ if (temp) rmSync(temp, { recursive: true, force: true });
293
+ }
63
294
  }
64
295
 
65
296
  export async function applyRetainedPatch(input: {
@@ -67,29 +298,22 @@ export async function applyRetainedPatch(input: {
67
298
  id: string;
68
299
  store: ResultStore;
69
300
  signal?: AbortSignal;
70
- }): Promise<
71
- | { ok: true; targets: string[]; checkExitCode: 0; applyExitCode: 0 }
72
- | { ok: false; error: string }
73
- > {
301
+ }): Promise<ApplyCandidateResult> {
74
302
  if (input.signal?.aborted) return cancellationFailure();
75
- const retained = input.store.get(input.id);
76
- if (!retained) return { ok: false, error: "retained result not found" };
303
+ const retained = input.store.resolveForApply(input.id);
77
304
  if (retained.root !== input.root)
78
- return { ok: false, error: "retained result root mismatch" };
305
+ throw new Error("retained result root mismatch");
79
306
  if (!retained.verification && !isCurrent(input.root, retained.snapshot))
80
- return { ok: false, error: "stale file snapshot" };
307
+ return failure({ kind: "stale", code: "stale-snapshot" });
81
308
 
82
309
  let targets: string[];
83
310
  try {
84
311
  targets = diffWritePaths(retained.diff.toString("utf8")).paths;
85
- } catch (error) {
86
- return { ok: false, error: (error as Error).message };
312
+ } catch {
313
+ return failure({ kind: "artifact", code: "invalid-diff" });
87
314
  }
88
315
  if (targets.some((path) => !retained.writeSet.includes(path))) {
89
- return {
90
- ok: false,
91
- error: "patch target is outside the declared write set",
92
- };
316
+ return failure({ kind: "artifact", code: "write-set-mismatch" });
93
317
  }
94
318
 
95
319
  if (retained.verification) {
@@ -98,15 +322,13 @@ export async function applyRetainedPatch(input: {
98
322
  !retained.lockfile ||
99
323
  !retained.dependencyTarget
100
324
  ) {
101
- return {
102
- ok: false,
103
- error: "retained candidate preflight inputs are incomplete",
104
- };
325
+ throw new Error("retained candidate preflight inputs are incomplete");
105
326
  }
106
327
  const preflight = await preflightCandidate({
107
328
  root: input.root,
108
329
  diff: retained.diff,
109
330
  writeSet: retained.writeSet,
331
+ approvedDependencies: retained.approvedDependencies,
110
332
  snapshot: retained.snapshot,
111
333
  baseline: retained.baseline,
112
334
  verification: retained.verification,
@@ -116,13 +338,25 @@ export async function applyRetainedPatch(input: {
116
338
  signal: input.signal,
117
339
  });
118
340
  if (!preflight.ok) {
119
- return {
120
- ok: false,
121
- error: `candidate preflight rejected: ${preflight.class}:${preflight.code}`,
122
- };
341
+ switch (preflight.kind) {
342
+ case "artifact":
343
+ return failure({
344
+ kind: preflight.kind,
345
+ code: preflight.code,
346
+ ...(preflight.excerpt ? { evidence: [preflight.excerpt] } : {}),
347
+ });
348
+ case "stale":
349
+ return failure({ kind: preflight.kind, code: preflight.code });
350
+ case "environment":
351
+ return failure({ kind: preflight.kind, code: preflight.code });
352
+ case "approval-boundary":
353
+ return failure({ kind: preflight.kind, code: preflight.code });
354
+ case "cancelled":
355
+ return failure({ kind: preflight.kind, code: preflight.code });
356
+ }
123
357
  }
124
358
  if (!isCurrent(input.root, retained.snapshot)) {
125
- return { ok: false, error: "stale file snapshot after preflight" };
359
+ return failure({ kind: "stale", code: "stale-snapshot" });
126
360
  }
127
361
  }
128
362
  if (input.signal?.aborted) return cancellationFailure();
@@ -135,10 +369,7 @@ export async function applyRetainedPatch(input: {
135
369
  );
136
370
  if (numstat.cancelled || input.signal?.aborted) return cancellationFailure();
137
371
  if (numstat.code !== 0 || numstat.stdout.includes(Buffer.from("-\t-\t"))) {
138
- return {
139
- ok: false,
140
- error: `git numstat screening failed: ${numstat.stderr.toString("utf8")}`,
141
- };
372
+ return failure({ kind: "artifact", code: "invalid-diff" });
142
373
  }
143
374
  const summary = await git(
144
375
  input.root,
@@ -153,7 +384,7 @@ export async function applyRetainedPatch(input: {
153
384
  summary.stdout.toString("utf8"),
154
385
  )
155
386
  ) {
156
- return { ok: false, error: "git summary screening rejected the patch" };
387
+ return failure({ kind: "artifact", code: "invalid-diff" });
157
388
  }
158
389
  const check = await git(
159
390
  input.root,
@@ -163,10 +394,7 @@ export async function applyRetainedPatch(input: {
163
394
  );
164
395
  if (check.cancelled || input.signal?.aborted) return cancellationFailure();
165
396
  if (check.code !== 0)
166
- return {
167
- ok: false,
168
- error: `git apply --check failed: ${check.stderr.toString("utf8")}`,
169
- };
397
+ return failure({ kind: "artifact", code: "git-apply-check-failed" });
170
398
  if (input.signal?.aborted) return cancellationFailure();
171
399
  const apply = await git(
172
400
  input.root,
@@ -174,10 +402,10 @@ export async function applyRetainedPatch(input: {
174
402
  retained.diff,
175
403
  );
176
404
  if (apply.code !== 0)
177
- return {
178
- ok: false,
179
- error: `git apply failed: ${apply.stderr.toString("utf8")}`,
180
- };
405
+ return failure({ kind: "environment", code: "git-apply-failed" });
181
406
  input.store.discard(input.id);
182
- return { ok: true, targets, checkExitCode: 0, applyExitCode: 0 };
407
+ return {
408
+ ok: true,
409
+ result: { targets, checkExitCode: 0, applyExitCode: 0 },
410
+ };
183
411
  }