pi-daddy 0.17.1 → 0.18.1
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/CHANGELOG.md +72 -0
- package/README.md +54 -25
- package/dist/approval-prompt.d.ts +3 -1
- package/dist/approval-prompt.d.ts.map +1 -1
- package/dist/approval-prompt.js +1 -1
- package/dist/approval-prompt.js.map +1 -1
- package/dist/approval-store.d.ts.map +1 -1
- package/dist/approval-store.js +4 -1
- package/dist/approval-store.js.map +1 -1
- package/dist/approval.d.ts +20 -2
- package/dist/approval.d.ts.map +1 -1
- package/dist/approval.js +26 -6
- package/dist/approval.js.map +1 -1
- package/dist/capabilities.d.ts +15 -0
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +47 -0
- package/dist/capabilities.js.map +1 -1
- package/dist/chain.d.ts +6 -1
- package/dist/chain.d.ts.map +1 -1
- package/dist/chain.js +1 -1
- package/dist/chain.js.map +1 -1
- package/dist/check-runner.d.ts +61 -0
- package/dist/check-runner.d.ts.map +1 -0
- package/dist/check-runner.js +237 -0
- package/dist/check-runner.js.map +1 -0
- package/dist/correlation.d.ts +90 -0
- package/dist/correlation.d.ts.map +1 -0
- package/dist/correlation.js +183 -0
- package/dist/correlation.js.map +1 -0
- package/dist/delegate-types.d.ts +140 -0
- package/dist/delegate-types.d.ts.map +1 -0
- package/dist/delegate-types.js +8 -0
- package/dist/delegate-types.js.map +1 -0
- package/dist/delegate.d.ts +4 -128
- package/dist/delegate.d.ts.map +1 -1
- package/dist/delegate.js +70 -73
- package/dist/delegate.js.map +1 -1
- package/dist/delegation-approval.d.ts +36 -0
- package/dist/delegation-approval.d.ts.map +1 -0
- package/dist/delegation-approval.js +67 -0
- package/dist/delegation-approval.js.map +1 -0
- package/dist/git-identity.d.ts +13 -0
- package/dist/git-identity.d.ts.map +1 -0
- package/dist/git-identity.js +44 -0
- package/dist/git-identity.js.map +1 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/lease-record.d.ts +78 -0
- package/dist/lease-record.d.ts.map +1 -0
- package/dist/lease-record.js +52 -0
- package/dist/lease-record.js.map +1 -0
- package/dist/ledger-events.d.ts +87 -0
- package/dist/ledger-events.d.ts.map +1 -0
- package/dist/ledger-events.js +45 -0
- package/dist/ledger-events.js.map +1 -0
- package/dist/ledger-report.d.ts +19 -12
- package/dist/ledger-report.d.ts.map +1 -1
- package/dist/ledger-report.js +81 -1
- package/dist/ledger-report.js.map +1 -1
- package/dist/ledger.d.ts +44 -1
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +20 -2
- package/dist/ledger.js.map +1 -1
- package/dist/propagation.d.ts.map +1 -1
- package/dist/propagation.js +2 -1
- package/dist/propagation.js.map +1 -1
- package/dist/refusals.d.ts +16 -0
- package/dist/refusals.d.ts.map +1 -0
- package/dist/refusals.js +52 -0
- package/dist/refusals.js.map +1 -0
- package/dist/resolve.d.ts.map +1 -1
- package/dist/resolve.js +7 -1
- package/dist/resolve.js.map +1 -1
- package/dist/run-child.d.ts +4 -0
- package/dist/run-child.d.ts.map +1 -1
- package/dist/run-child.js +21 -1
- package/dist/run-child.js.map +1 -1
- package/dist/run-herdr.d.ts +7 -0
- package/dist/run-herdr.d.ts.map +1 -1
- package/dist/run-herdr.js +41 -12
- package/dist/run-herdr.js.map +1 -1
- package/dist/workspace-lease.d.ts +28 -0
- package/dist/workspace-lease.d.ts.map +1 -0
- package/dist/workspace-lease.js +276 -0
- package/dist/workspace-lease.js.map +1 -0
- package/dist/workspace.d.ts +32 -0
- package/dist/workspace.d.ts.map +1 -0
- package/dist/workspace.js +79 -0
- package/dist/workspace.js.map +1 -0
- package/extensions/approval-banking.ts +66 -0
- package/extensions/approvals.ts +95 -7
- package/extensions/chain-approval-facts.ts +51 -0
- package/extensions/chain-ledger.ts +48 -0
- package/extensions/delegate-chain.ts +115 -81
- package/extensions/delegation.ts +81 -23
- package/extensions/execute-child.ts +289 -0
- package/extensions/fanout-outcome.ts +97 -0
- package/extensions/run-delegation.ts +130 -120
- package/extensions/session.ts +4 -0
- package/extensions/workspace-runtime.ts +165 -0
- package/package.json +17 -1
- package/src/approval-prompt.ts +4 -2
- package/src/approval-store.ts +4 -1
- package/src/approval.ts +47 -14
- package/src/capabilities.ts +53 -0
- package/src/chain.ts +3 -1
- package/src/check-runner.ts +341 -0
- package/src/correlation.ts +260 -0
- package/src/delegate-types.ts +144 -0
- package/src/delegate.ts +82 -180
- package/src/delegation-approval.ts +99 -0
- package/src/git-identity.ts +52 -0
- package/src/index.ts +50 -0
- package/src/lease-record.ts +119 -0
- package/src/ledger-events.ts +138 -0
- package/src/ledger-report.ts +90 -2
- package/src/ledger.ts +68 -3
- package/src/propagation.ts +2 -1
- package/src/refusals.ts +67 -0
- package/src/resolve.ts +7 -1
- package/src/run-child.ts +23 -1
- package/src/run-herdr.ts +41 -11
- package/src/workspace-lease.ts +303 -0
- package/src/workspace.ts +135 -0
package/extensions/approvals.ts
CHANGED
|
@@ -28,7 +28,12 @@ import type { createApprovalGate } from "../src/approval-prompt.ts";
|
|
|
28
28
|
import { timeoutMsFromEnv, type PromptOutcomeKind } from "../src/approval-prompt.ts";
|
|
29
29
|
import { ceilingForDefinition, digestDefinition } from "../src/definitions.ts";
|
|
30
30
|
import type { Capability } from "../src/resolve.ts";
|
|
31
|
+
import { approvalBindingDigest, type ApprovalBinding } from "../src/correlation.ts";
|
|
32
|
+
import type { RefusalCode } from "../src/refusals.ts";
|
|
31
33
|
import type { GrantsSession } from "./session.ts";
|
|
34
|
+
import type { BankedApproval } from "./approval-banking.ts";
|
|
35
|
+
export { unbankApprovals } from "./approval-banking.ts";
|
|
36
|
+
export type { BankedApproval } from "./approval-banking.ts";
|
|
32
37
|
|
|
33
38
|
/**
|
|
34
39
|
* What a subject looks like right now, for the confused-deputy check in the approval store.
|
|
@@ -102,6 +107,8 @@ export interface ApprovalOutcome {
|
|
|
102
107
|
* and the behaviour that was already in place.
|
|
103
108
|
*/
|
|
104
109
|
scopes: Record<Capability, ApprovalScope>;
|
|
110
|
+
/** Complete scope map for the ledger, including inherited, session, and persisted answers. */
|
|
111
|
+
recordedScopes: Record<Capability, ApprovalScope>;
|
|
105
112
|
/**
|
|
106
113
|
* Where EACH approved capability's yes came from (R-46).
|
|
107
114
|
*
|
|
@@ -110,6 +117,10 @@ export interface ApprovalOutcome {
|
|
|
110
117
|
* always computed this map; the bug was that it was thrown away.
|
|
111
118
|
*/
|
|
112
119
|
sources: Record<Capability, ApprovalSource>;
|
|
120
|
+
/** Exact binding per approved capability when this is a correlated controller call. */
|
|
121
|
+
bindings: Record<Capability, ApprovalBinding>;
|
|
122
|
+
expiresAt: Record<Capability, string>;
|
|
123
|
+
uses: Record<Capability, { max: number; remaining: number }>;
|
|
113
124
|
humanDenied: boolean;
|
|
114
125
|
/**
|
|
115
126
|
* Which of the five prompt outcomes ended the loop, when one did (ADR-0026's F5).
|
|
@@ -119,6 +130,18 @@ export interface ApprovalOutcome {
|
|
|
119
130
|
* dismissal is a queue or a longer timeout, `no-ui` is an operator pre-approving, `error` is a defect.
|
|
120
131
|
*/
|
|
121
132
|
gateOutcome?: PromptOutcomeKind;
|
|
133
|
+
/**
|
|
134
|
+
* Authority this gate LEFT BEHIND: session keys added, and 30-day entries written to disk.
|
|
135
|
+
*
|
|
136
|
+
* Recorded so a caller that refuses after the gate can take it back. `run-delegation.ts` already
|
|
137
|
+
* documents the rule at length — "a refused operation must not leave authority behind" — and moved the
|
|
138
|
+
* executor check before the gate to honour it. The adjacent path did not: an operator clicks *Always*,
|
|
139
|
+
* the load-bearing ledger append then fails (a lock timeout under fan-out is a documented condition),
|
|
140
|
+
* the delegation is refused, and a project-wide 30-day approval survives for a spawn that never
|
|
141
|
+
* happened (R-113).
|
|
142
|
+
*/
|
|
143
|
+
banked?: BankedApproval[];
|
|
144
|
+
refusalCode?: RefusalCode;
|
|
122
145
|
reason?: string;
|
|
123
146
|
}
|
|
124
147
|
|
|
@@ -143,8 +166,9 @@ export async function storedApprovals(
|
|
|
143
166
|
session: GrantsSession,
|
|
144
167
|
gated: Capability[],
|
|
145
168
|
subject: string,
|
|
169
|
+
expectedBinding?: ApprovalBinding,
|
|
146
170
|
): Promise<ResolveApprovalsResult> {
|
|
147
|
-
const { valid } = await loadApprovals({
|
|
171
|
+
const { valid, dropped } = await loadApprovals({
|
|
148
172
|
cwd: session.cwd,
|
|
149
173
|
now: new Date(),
|
|
150
174
|
snapshotOf: (name) => snapshotOf(session, name),
|
|
@@ -153,7 +177,10 @@ export async function storedApprovals(
|
|
|
153
177
|
gated,
|
|
154
178
|
subject,
|
|
155
179
|
sessionApprovals: session.sessionApprovals,
|
|
180
|
+
sessionApprovalBindings: session.sessionApprovalBindings,
|
|
156
181
|
persisted: valid,
|
|
182
|
+
expectedBinding,
|
|
183
|
+
expiredKeys: new Set(dropped.filter((item) => item.verdict === "expired").map((item) => item.key)),
|
|
157
184
|
// ADR-0022. Verified HERE rather than at parse time because it needs `session.definitions`, which
|
|
158
185
|
// arrives at `session_start` — after the session object is built.
|
|
159
186
|
inherited: verifyInherited(session.inheritedApprovals, (name) => snapshotOf(session, name)),
|
|
@@ -180,11 +207,23 @@ export async function obtainApprovals(
|
|
|
180
207
|
ctx: ApprovalUIContext | null,
|
|
181
208
|
task?: string,
|
|
182
209
|
signal?: AbortSignal,
|
|
210
|
+
expectedBinding?: ApprovalBinding,
|
|
183
211
|
): Promise<ApprovalOutcome> {
|
|
184
212
|
const snapshot = (name: string) => snapshotOf(session, name);
|
|
185
|
-
const pre = await storedApprovals(session, gatedBlocked, subject);
|
|
213
|
+
const pre = await storedApprovals(session, gatedBlocked, subject, expectedBinding);
|
|
214
|
+
const preBindings: Record<Capability, ApprovalBinding> = {};
|
|
215
|
+
if (expectedBinding) for (const capability of pre.approved) preBindings[capability] = expectedBinding;
|
|
186
216
|
if (ctx === null || pre.needsPrompt.length === 0) {
|
|
187
|
-
return {
|
|
217
|
+
return {
|
|
218
|
+
approved: pre.approved,
|
|
219
|
+
sources: pre.sources,
|
|
220
|
+
scopes: {},
|
|
221
|
+
recordedScopes: pre.scopes,
|
|
222
|
+
bindings: preBindings,
|
|
223
|
+
expiresAt: pre.expiresAt,
|
|
224
|
+
uses: {},
|
|
225
|
+
humanDenied: false,
|
|
226
|
+
};
|
|
188
227
|
}
|
|
189
228
|
|
|
190
229
|
// The gate PROVIDER lives on the session, not here: `obtainApprovals` runs once per `tool_call` and once
|
|
@@ -203,12 +242,21 @@ export async function obtainApprovals(
|
|
|
203
242
|
// about — so a mixed set reports exactly which yes came from where.
|
|
204
243
|
const sources: Record<Capability, ApprovalSource> = { ...pre.sources };
|
|
205
244
|
const scopes: Record<Capability, ApprovalScope> = {};
|
|
245
|
+
const recordedScopes: Record<Capability, ApprovalScope> = { ...pre.scopes };
|
|
246
|
+
const bindings: Record<Capability, ApprovalBinding> = { ...preBindings };
|
|
247
|
+
const expiresAt: Record<Capability, string> = { ...pre.expiresAt };
|
|
248
|
+
const uses: Record<Capability, { max: number; remaining: number }> = {};
|
|
206
249
|
let humanDenied = false;
|
|
250
|
+
const banked: BankedApproval[] = [];
|
|
207
251
|
let gateOutcome: PromptOutcomeKind | undefined;
|
|
252
|
+
let refusalCode: RefusalCode | undefined;
|
|
208
253
|
let reason: string | undefined;
|
|
209
254
|
|
|
210
255
|
for (const capability of pre.needsPrompt) {
|
|
211
|
-
const outcome = await gate.request({
|
|
256
|
+
const outcome = await gate.request({
|
|
257
|
+
capability, subject, path, task, signal,
|
|
258
|
+
...(expectedBinding ? { bindingKey: approvalBindingDigest(expectedBinding) } : {}),
|
|
259
|
+
});
|
|
212
260
|
if (outcome.scope === null) {
|
|
213
261
|
// Forward the gate's own discriminant rather than re-deriving it from `hasUI`: `hasUI` is true in
|
|
214
262
|
// RPC mode too, so an automated client's timeout or dismissal there would misreport as "a human
|
|
@@ -219,6 +267,11 @@ export async function obtainApprovals(
|
|
|
219
267
|
// so "was there an operator who timed out, or was there nobody?" had no answer, and the two want
|
|
220
268
|
// different fixes. It was computed here all along and thrown away.
|
|
221
269
|
gateOutcome = outcome.kind;
|
|
270
|
+
refusalCode = pre.expired.includes(capability)
|
|
271
|
+
? "APPROVAL_EXPIRED"
|
|
272
|
+
: pre.scopeMismatched.includes(capability)
|
|
273
|
+
? "APPROVAL_SCOPE_MISMATCH"
|
|
274
|
+
: "GATED_UNAPPROVED";
|
|
222
275
|
reason = outcome.reason;
|
|
223
276
|
break;
|
|
224
277
|
}
|
|
@@ -230,9 +283,20 @@ export async function obtainApprovals(
|
|
|
230
283
|
// were not.
|
|
231
284
|
sources[capability] = outcome.joined ? "session" : "prompt";
|
|
232
285
|
scopes[capability] = outcome.scope;
|
|
286
|
+
recordedScopes[capability] = outcome.scope;
|
|
287
|
+
if (expectedBinding) bindings[capability] = expectedBinding;
|
|
288
|
+
if (outcome.scope === "once") uses[capability] = { max: 1, remaining: 0 };
|
|
233
289
|
|
|
234
290
|
if (outcome.scope === "session" || outcome.scope === "always") {
|
|
235
|
-
|
|
291
|
+
const key = approvalKey(capability, subject);
|
|
292
|
+
// Whether THIS call created the authority, decided before we add it. `joined` means a human answer
|
|
293
|
+
// opened by another delegation was shared with us; an existing key means it was already standing.
|
|
294
|
+
// Either way it is not ours to take back if we are refused — see `BankedApproval.owned`.
|
|
295
|
+
const owned = !outcome.joined
|
|
296
|
+
&& !(expectedBinding ? session.sessionApprovalBindings.has(key) : session.sessionApprovals.has(key));
|
|
297
|
+
if (expectedBinding) session.sessionApprovalBindings.set(key, expectedBinding);
|
|
298
|
+
else session.sessionApprovals.add(key);
|
|
299
|
+
banked.push({ key, capability, subject, persisted: false, owned });
|
|
236
300
|
}
|
|
237
301
|
if (outcome.scope === "always") {
|
|
238
302
|
const now = new Date();
|
|
@@ -242,6 +306,8 @@ export async function obtainApprovals(
|
|
|
242
306
|
// "type-missing" or "type-changed". Writing it is not unsafe (it fails closed), it is simply a
|
|
243
307
|
// dead entry that silently accumulates in the file. Skip it and say so, taking the same
|
|
244
308
|
// downgrade-to-session path as a failed write below: the human's yes still stands.
|
|
309
|
+
const expires = expiryFor(now);
|
|
310
|
+
expiresAt[capability] = expires;
|
|
245
311
|
const written =
|
|
246
312
|
current === null
|
|
247
313
|
? false
|
|
@@ -250,12 +316,13 @@ export async function obtainApprovals(
|
|
|
250
316
|
approvalKey(capability, subject),
|
|
251
317
|
{
|
|
252
318
|
approvedAt: now.toISOString(),
|
|
253
|
-
expiresAt:
|
|
319
|
+
expiresAt: expires,
|
|
254
320
|
cwd: session.cwd,
|
|
255
321
|
grantAtApproval: current.ceiling,
|
|
256
322
|
// ADR-0019: the tools AND the instructions the human actually saw. Pinning only the
|
|
257
323
|
// former would let a rewritten body inherit a yes that was given about different text.
|
|
258
324
|
bodyAtApproval: current.bodySha256,
|
|
325
|
+
...(expectedBinding ? { binding: expectedBinding } : {}),
|
|
259
326
|
// The TASK is deliberately absent (ADR-0021). It is shown in the dialog, where a human
|
|
260
327
|
// needs it, and never written down, because the model assembles it from the parent's
|
|
261
328
|
// context and it can carry anything the parent could see.
|
|
@@ -263,6 +330,12 @@ export async function obtainApprovals(
|
|
|
263
330
|
snapshot,
|
|
264
331
|
now,
|
|
265
332
|
);
|
|
333
|
+
if (written) {
|
|
334
|
+
// Mark the on-disk half so a later refusal knows there is a file entry to take back, not just a
|
|
335
|
+
// session key that dies with the process.
|
|
336
|
+
const persistedKey = approvalKey(capability, subject);
|
|
337
|
+
for (const entry of banked) if (entry.key === persistedKey) entry.persisted = true;
|
|
338
|
+
}
|
|
266
339
|
if (!written) {
|
|
267
340
|
// The human already said yes; the security decision stands. Only the convenience cache
|
|
268
341
|
// failed, so this downgrades scope rather than refusing the delegation (see approval-store.ts).
|
|
@@ -276,10 +349,25 @@ export async function obtainApprovals(
|
|
|
276
349
|
// The human's yes stands; only the cache failed. Downgrade THIS capability, not the set — under
|
|
277
350
|
// the old scalar this also rewrote the scope of every other capability answered in the same call.
|
|
278
351
|
scopes[capability] = "session";
|
|
352
|
+
recordedScopes[capability] = "session";
|
|
353
|
+
delete expiresAt[capability];
|
|
279
354
|
}
|
|
280
355
|
}
|
|
281
356
|
session.publishChildEnv(); // a new session approval widens what children may inherit — republish now
|
|
282
357
|
}
|
|
283
358
|
|
|
284
|
-
return {
|
|
359
|
+
return {
|
|
360
|
+
approved,
|
|
361
|
+
sources,
|
|
362
|
+
scopes,
|
|
363
|
+
recordedScopes,
|
|
364
|
+
bindings,
|
|
365
|
+
expiresAt,
|
|
366
|
+
uses,
|
|
367
|
+
humanDenied,
|
|
368
|
+
gateOutcome,
|
|
369
|
+
refusalCode,
|
|
370
|
+
reason,
|
|
371
|
+
banked,
|
|
372
|
+
};
|
|
285
373
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { InheritableApproval } from "../src/approval.ts";
|
|
2
|
+
import type { Capability } from "../src/resolve.ts";
|
|
3
|
+
import type { ApprovalOutcome } from "./approvals.ts";
|
|
4
|
+
|
|
5
|
+
export interface ChainApprovalAudit {
|
|
6
|
+
sources: Map<string, ApprovalOutcome["sources"][Capability]>;
|
|
7
|
+
scopes: Map<string, ApprovalOutcome["recordedScopes"][Capability]>;
|
|
8
|
+
expiresAt: Map<string, string>;
|
|
9
|
+
uses: Map<string, { max: number; remaining: number }>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function newChainApprovalAudit(): ChainApprovalAudit {
|
|
13
|
+
return { sources: new Map(), scopes: new Map(), expiresAt: new Map(), uses: new Map() };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function rememberChainApproval(
|
|
17
|
+
audit: ChainApprovalAudit,
|
|
18
|
+
capability: Capability,
|
|
19
|
+
subject: string,
|
|
20
|
+
outcome: ApprovalOutcome,
|
|
21
|
+
): void {
|
|
22
|
+
const key = `${capability}@${subject}`;
|
|
23
|
+
const source = outcome.sources[capability];
|
|
24
|
+
const scope = outcome.recordedScopes[capability];
|
|
25
|
+
if (source) audit.sources.set(key, source);
|
|
26
|
+
if (scope) audit.scopes.set(key, scope);
|
|
27
|
+
const expiresAt = outcome.expiresAt[capability];
|
|
28
|
+
if (expiresAt) audit.expiresAt.set(key, expiresAt);
|
|
29
|
+
const uses = outcome.uses[capability];
|
|
30
|
+
if (uses) audit.uses.set(key, uses);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function chainApprovalFacts(
|
|
34
|
+
audit: ChainApprovalAudit,
|
|
35
|
+
available: InheritableApproval[],
|
|
36
|
+
subject: string,
|
|
37
|
+
): Pick<ApprovalOutcome, "approved" | "sources" | "scopes" | "expiresAt" | "uses" | "humanDenied"> {
|
|
38
|
+
const entries = available.filter((approval) => approval.subject === subject);
|
|
39
|
+
const values = <T>(map: Map<string, T>) => Object.fromEntries(entries.flatMap((approval) => {
|
|
40
|
+
const value = map.get(`${approval.capability}@${approval.subject}`);
|
|
41
|
+
return value === undefined ? [] : [[approval.capability, value]];
|
|
42
|
+
}));
|
|
43
|
+
return {
|
|
44
|
+
approved: entries.map((approval) => approval.capability),
|
|
45
|
+
sources: values(audit.sources),
|
|
46
|
+
scopes: values(audit.scopes),
|
|
47
|
+
expiresAt: values(audit.expiresAt),
|
|
48
|
+
uses: values(audit.uses),
|
|
49
|
+
humanDenied: false,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ApprovalOutcome } from "./approvals.ts";
|
|
2
|
+
import type { Delegation } from "../src/delegate.ts";
|
|
3
|
+
import { appendRecord, buildRecord } from "../src/ledger.ts";
|
|
4
|
+
import type { StructuredRefusal } from "../src/refusals.ts";
|
|
5
|
+
import { childSpawnId } from "../src/fanout.ts";
|
|
6
|
+
import type { GrantsSession } from "./session.ts";
|
|
7
|
+
|
|
8
|
+
/** Audit a chain preflight/gate refusal with the same trusted planner facts as a normal delegation. */
|
|
9
|
+
export async function recordChainRefusal(input: {
|
|
10
|
+
session: GrantsSession;
|
|
11
|
+
plan: Delegation;
|
|
12
|
+
stepIndex: number;
|
|
13
|
+
agent?: string;
|
|
14
|
+
reason: string;
|
|
15
|
+
refusal?: StructuredRefusal;
|
|
16
|
+
approval?: ApprovalOutcome;
|
|
17
|
+
}): Promise<void> {
|
|
18
|
+
if (!input.session.ledgerPath) return;
|
|
19
|
+
await appendRecord(
|
|
20
|
+
{ path: input.session.ledgerPath, strict: true },
|
|
21
|
+
buildRecord({
|
|
22
|
+
parentId: input.session.ownSpawnId,
|
|
23
|
+
childId: childSpawnId(input.session.ownSpawnId, input.stepIndex),
|
|
24
|
+
depth: input.plan.childDepth,
|
|
25
|
+
agentType: input.agent ?? "delegate",
|
|
26
|
+
requested: input.plan.requested,
|
|
27
|
+
parentGrant: input.session.ownGrant,
|
|
28
|
+
result: input.plan.result,
|
|
29
|
+
blocked: true,
|
|
30
|
+
reason: input.reason,
|
|
31
|
+
approved: input.approval?.approved,
|
|
32
|
+
approvalSources: input.approval?.sources,
|
|
33
|
+
approvalScopes: input.approval?.recordedScopes,
|
|
34
|
+
approvalExpiresAt: input.approval?.expiresAt,
|
|
35
|
+
approvalUses: input.approval?.uses,
|
|
36
|
+
humanDenied: input.approval?.humanDenied,
|
|
37
|
+
gateOutcome: input.approval?.gateOutcome,
|
|
38
|
+
definitionDigest: input.plan.definitionDigest,
|
|
39
|
+
taskDigest: input.plan.taskDigest,
|
|
40
|
+
correlation: input.plan.correlation,
|
|
41
|
+
// An approval banked before a later chain decline is a yes that led to no spawn, not the stale
|
|
42
|
+
// GATED_UNAPPROVED refusal from the pre-approval plan.
|
|
43
|
+
refusal: input.refusal ?? (input.approval?.approved.length ? undefined : input.plan.refusal),
|
|
44
|
+
executor: input.session.executor.kind,
|
|
45
|
+
now: new Date(),
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -20,23 +20,21 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
20
20
|
import { Type } from "typebox";
|
|
21
21
|
import type { InheritableApproval } from "../src/approval.ts";
|
|
22
22
|
import { DELEGATE_SUBJECT, shouldSeekApproval } from "../src/approval.ts";
|
|
23
|
-
import { chainStepSpec, PLACEHOLDER } from "../src/chain.ts";
|
|
23
|
+
import { chainStepSpec, PLACEHOLDER, type ChainStep } from "../src/chain.ts";
|
|
24
24
|
import { planDelegation } from "../src/delegate.ts";
|
|
25
25
|
import { MAX_CHAIN_STEPS, childSpawnId, splitBudget } from "../src/fanout.ts";
|
|
26
26
|
import { PAINT_INTERVAL_MS, appendTail, emptyTail, renderProgress, replaceTail, throttle, type ChildProgress } from "../src/progress.ts";
|
|
27
27
|
import type { Capability } from "../src/resolve.ts";
|
|
28
|
-
import {
|
|
28
|
+
import { recordChainRefusal } from "./chain-ledger.ts";
|
|
29
29
|
import { obtainApprovals, snapshotOf } from "./approvals.ts";
|
|
30
30
|
import { runOneDelegation } from "./run-delegation.ts";
|
|
31
|
+
import { isCriticalAssuranceBlock } from "./execute-child.ts";
|
|
31
32
|
import type { GrantsSession } from "./session.ts";
|
|
33
|
+
import { GovernanceRefusal, refusal, type StructuredRefusal } from "../src/refusals.ts";
|
|
34
|
+
import { chainApprovalFacts, newChainApprovalAudit, rememberChainApproval } from "./chain-approval-facts.ts";
|
|
32
35
|
|
|
33
36
|
/** One step of a chain, as the model describes it. */
|
|
34
|
-
|
|
35
|
-
task: string;
|
|
36
|
-
agent?: string;
|
|
37
|
-
tools?: string[];
|
|
38
|
-
model?: string;
|
|
39
|
-
}
|
|
37
|
+
type StepSpec = ChainStep;
|
|
40
38
|
|
|
41
39
|
/**
|
|
42
40
|
* One dialog's worth of gate: a single capability, for a single subject, described by the step that needs it.
|
|
@@ -53,13 +51,17 @@ interface GateRequest {
|
|
|
53
51
|
capability: Capability;
|
|
54
52
|
/** The task of the step that needs this capability. */
|
|
55
53
|
task: string;
|
|
54
|
+
stepIndex: number;
|
|
55
|
+
plan: ReturnType<typeof planDelegation>;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
/** Either every gate the chain will hit, or the first step that can never run and why. */
|
|
59
59
|
interface ChainPlan {
|
|
60
60
|
requests: GateRequest[];
|
|
61
|
+
/** Exact approval keys each step would spend, including duplicate subjects across steps. */
|
|
62
|
+
uses: Map<number, Set<string>>;
|
|
61
63
|
/** Set when a step is refused for a reason no approval can fix. The chain must refuse before asking anyone. */
|
|
62
|
-
doomed?: { step: number; reason: string };
|
|
64
|
+
doomed?: { step: number; reason: string; plan: ReturnType<typeof planDelegation>; agent?: string; refusal?: StructuredRefusal };
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
/**
|
|
@@ -85,6 +87,7 @@ interface ChainPlan {
|
|
|
85
87
|
*/
|
|
86
88
|
async function planChain(session: GrantsSession, steps: StepSpec[], perStepBudget: number): Promise<ChainPlan> {
|
|
87
89
|
const requests: GateRequest[] = [];
|
|
90
|
+
const uses = new Map<number, Set<string>>();
|
|
88
91
|
const seen = new Set<string>();
|
|
89
92
|
const context = await session.delegationContext();
|
|
90
93
|
|
|
@@ -93,25 +96,49 @@ async function planChain(session: GrantsSession, steps: StepSpec[], perStepBudge
|
|
|
93
96
|
// or a step with a whitespace task raises a dialog and is refused afterwards. The handoff is absent at planning
|
|
94
97
|
// time and cannot change which capabilities are gated.
|
|
95
98
|
const plan = planDelegation(
|
|
96
|
-
{
|
|
99
|
+
{
|
|
100
|
+
task: step.task,
|
|
101
|
+
agent: step.agent,
|
|
102
|
+
tools: step.tools,
|
|
103
|
+
model: step.model,
|
|
104
|
+
correlation: step.workspace
|
|
105
|
+
? { ...(step.correlation ?? {}), workspace_id: step.workspace.workspace_id }
|
|
106
|
+
: step.correlation,
|
|
107
|
+
// Routing spec, not the model's correlation claim — see R-110 in `src/correlation.ts`.
|
|
108
|
+
boundWorkspaceId: step.workspace?.workspace_id,
|
|
109
|
+
boundContextId: step.correlation?.context_id,
|
|
110
|
+
},
|
|
97
111
|
{ ...context, spawnId: session.ownSpawnId, childSpawnId: childSpawnId(session.ownSpawnId, index) },
|
|
98
112
|
);
|
|
99
113
|
|
|
100
114
|
// A gate is the ONLY refusal an approval can lift. Anything else is doomed, and asking about it banks authority
|
|
101
115
|
// for a spawn that will never happen.
|
|
102
116
|
if (!plan.ok && !shouldSeekApproval(plan.result)) {
|
|
103
|
-
return {
|
|
117
|
+
return {
|
|
118
|
+
requests: [], uses, doomed: {
|
|
119
|
+
step: index + 1, reason: plan.reason ?? "this step cannot run", plan, agent: step.agent,
|
|
120
|
+
...(plan.refusal ? { refusal: plan.refusal } : {}),
|
|
121
|
+
},
|
|
122
|
+
};
|
|
104
123
|
}
|
|
105
124
|
|
|
125
|
+
// A correlated approval is bound to the exact task. Step N's final task does not exist until step N-1
|
|
126
|
+
// finishes, so asking upfront would bind the template and then spend it on different instructions.
|
|
127
|
+
// Legacy chains keep their upfront dialogs; correlated steps gate when their composed task exists.
|
|
128
|
+
if (plan.approvalBinding) continue;
|
|
129
|
+
|
|
106
130
|
const subject = step.agent ?? DELEGATE_SUBJECT;
|
|
131
|
+
uses.set(index, new Set(plan.result.gatedBlocked.map((capability) => `${capability}@${subject}`)));
|
|
107
132
|
for (const capability of plan.result.gatedBlocked) {
|
|
108
133
|
const key = `${capability}@${subject}`;
|
|
109
134
|
if (seen.has(key)) continue;
|
|
110
135
|
seen.add(key);
|
|
111
|
-
requests.push({
|
|
136
|
+
requests.push({
|
|
137
|
+
subject, path: step.agent ? "definition" : "delegate", capability, task: step.task, stepIndex: index, plan,
|
|
138
|
+
});
|
|
112
139
|
}
|
|
113
140
|
}
|
|
114
|
-
return { requests };
|
|
141
|
+
return { requests, uses };
|
|
115
142
|
}
|
|
116
143
|
|
|
117
144
|
export function registerChainTool(pi: ExtensionAPI, session: GrantsSession): void {
|
|
@@ -127,6 +154,23 @@ export function registerChainTool(pi: ExtensionAPI, session: GrantsSession): voi
|
|
|
127
154
|
agent: Type.Optional(Type.String({ description: "Definition to spawn for this step." })),
|
|
128
155
|
tools: Type.Optional(Type.Array(Type.String(), { description: "Capabilities, when no 'agent' fits." })),
|
|
129
156
|
model: Type.Optional(Type.String({ description: "Model as provider/id. Defaults to this session's." })),
|
|
157
|
+
correlation: Type.Optional(Type.Object({
|
|
158
|
+
schema_version: Type.Optional(Type.String()), run_id: Type.Optional(Type.String()),
|
|
159
|
+
task_id: Type.Optional(Type.String()), workspace_id: Type.Optional(Type.String()),
|
|
160
|
+
context_id: Type.Optional(Type.String()), phase: Type.Optional(Type.String()),
|
|
161
|
+
assurance: Type.Optional(Type.String()), assurance_effective: Type.Optional(Type.String()),
|
|
162
|
+
policy_label: Type.Optional(Type.String()), assurance_source: Type.Optional(Type.String()),
|
|
163
|
+
assurance_scope: Type.Optional(Type.Any()), activated_at: Type.Optional(Type.String()),
|
|
164
|
+
plan_digest: Type.Optional(Type.String()), definition_digest: Type.Optional(Type.String()),
|
|
165
|
+
task_digest: Type.Optional(Type.String()), base_sha: Type.Optional(Type.String()),
|
|
166
|
+
head_sha: Type.Optional(Type.String()), tree_sha: Type.Optional(Type.String()),
|
|
167
|
+
event_seq: Type.Optional(Type.Number()), last_change_seq: Type.Optional(Type.Number()),
|
|
168
|
+
last_authority_seq: Type.Optional(Type.Number()), check_receipt_id: Type.Optional(Type.String()),
|
|
169
|
+
})),
|
|
170
|
+
workspace: Type.Optional(Type.Object({
|
|
171
|
+
workspace_id: Type.String(),
|
|
172
|
+
access: Type.Union([Type.Literal("read"), Type.Literal("write")]),
|
|
173
|
+
})),
|
|
130
174
|
});
|
|
131
175
|
|
|
132
176
|
const params = Type.Object({
|
|
@@ -156,37 +200,41 @@ export function registerChainTool(pi: ExtensionAPI, session: GrantsSession): voi
|
|
|
156
200
|
// written, and the delegation was then refused anyway. `runOneDelegation` checks the executor before its own
|
|
157
201
|
// gate; a chain hoists its gate above `runOneDelegation`, so the check has to be repeated here or that
|
|
158
202
|
// ordering is simply bypassed.
|
|
159
|
-
if (session.executor.refusal) throw new
|
|
203
|
+
if (session.executor.refusal) throw new GovernanceRefusal(refusal("EXECUTOR_UNAVAILABLE", `chain refused: ${session.executor.refusal}`));
|
|
160
204
|
|
|
161
205
|
// Cardinality next, still before the gate. `splitBudget` is reused rather than re-derived, so a chain and a
|
|
162
206
|
// fan-out cannot disagree about what the budget means.
|
|
163
207
|
const split = splitBudget(session.fanoutBudget, steps.length);
|
|
164
|
-
if (!split.ok) throw new
|
|
208
|
+
if (!split.ok) throw new GovernanceRefusal(refusal("FANOUT_EXCEEDED", `chain refused: ${split.reason}`));
|
|
165
209
|
|
|
166
210
|
// Plan every step first. A step that can never run refuses the chain HERE, before anyone is asked — see
|
|
167
211
|
// `planChain`.
|
|
168
212
|
const chainPlan = await planChain(session, steps, split.perChild);
|
|
169
213
|
if (chainPlan.doomed) {
|
|
170
|
-
|
|
171
|
-
`
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
214
|
+
const message = `chain refused at step ${chainPlan.doomed.step}: ${chainPlan.doomed.reason} No step ran, and nobody was ` +
|
|
215
|
+
`asked to approve anything — a step that cannot run must not bank authority for a spawn that will never happen.`;
|
|
216
|
+
await recordChainRefusal({
|
|
217
|
+
session, plan: chainPlan.doomed.plan, stepIndex: chainPlan.doomed.step - 1,
|
|
218
|
+
agent: chainPlan.doomed.agent, reason: message, refusal: chainPlan.doomed.refusal,
|
|
219
|
+
});
|
|
220
|
+
if (chainPlan.doomed.refusal) throw new GovernanceRefusal({ ...chainPlan.doomed.refusal, message });
|
|
221
|
+
throw new Error(message);
|
|
175
222
|
}
|
|
176
223
|
|
|
177
224
|
// One dialog per `capability@subject`, each naming the step that needs it, all before the first step runs.
|
|
178
225
|
const preApproved: InheritableApproval[] = [];
|
|
179
|
-
|
|
180
|
-
|
|
226
|
+
const approvalAudit = newChainApprovalAudit();
|
|
227
|
+
const approvalStep = new Map<string, number>();
|
|
228
|
+
const approvedDecisions: Array<{ request: GateRequest; outcome: Awaited<ReturnType<typeof obtainApprovals>> }> = [];
|
|
229
|
+
let declined: { request: GateRequest; outcome: Awaited<ReturnType<typeof obtainApprovals>> } | undefined;
|
|
181
230
|
|
|
182
231
|
for (const request of chainPlan.requests) {
|
|
183
232
|
const outcome = await obtainApprovals(session, [request.capability], request.subject, request.path, ctx, request.task, signal);
|
|
184
|
-
humanDenied = humanDenied || outcome.humanDenied;
|
|
185
233
|
if (!outcome.approved.includes(request.capability)) {
|
|
186
234
|
// **Stop asking.** The chain's outcome is already fixed, and every further dialog banks authority — a
|
|
187
235
|
// `session` yes into `sessionApprovals` and an `always` yes onto disk for 30 days — for a chain that will
|
|
188
236
|
// not run. The single-delegate path breaks on the first no for the same reason.
|
|
189
|
-
declined = {
|
|
237
|
+
declined = { request, outcome };
|
|
190
238
|
break;
|
|
191
239
|
}
|
|
192
240
|
preApproved.push({
|
|
@@ -197,47 +245,28 @@ export function registerChainTool(pi: ExtensionAPI, session: GrantsSession): voi
|
|
|
197
245
|
// against one definition's instructions while the capability was spent on another's.
|
|
198
246
|
bodySha256: snapshotOf(session, request.subject)?.bodySha256,
|
|
199
247
|
});
|
|
248
|
+
rememberChainApproval(approvalAudit, request.capability, request.subject, outcome);
|
|
249
|
+
approvalStep.set(`${request.capability}@${request.subject}`, request.stepIndex);
|
|
250
|
+
approvedDecisions.push({ request, outcome });
|
|
200
251
|
}
|
|
201
252
|
|
|
202
253
|
if (declined) {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
buildRecord({
|
|
212
|
-
parentId: session.ownSpawnId,
|
|
213
|
-
childId: childSpawnId(session.ownSpawnId, Math.max(0, at)),
|
|
214
|
-
depth: session.depth + 1,
|
|
215
|
-
agentType: declined.subject === DELEGATE_SUBJECT ? "delegate" : declined.subject,
|
|
216
|
-
requested: [declined.capability],
|
|
217
|
-
parentGrant: session.ownGrant,
|
|
218
|
-
result: { effective: [], denied: [], clipped: [], gatedBlocked: [declined.capability], universal: [], subsumedBy: [] },
|
|
219
|
-
blocked: true,
|
|
220
|
-
humanDenied,
|
|
221
|
-
reason: `chain refused: ${declined.capability} not approved for ${declined.subject}; no step ran`,
|
|
222
|
-
executor: session.executor.kind,
|
|
223
|
-
now: new Date(),
|
|
224
|
-
}),
|
|
225
|
-
).catch((error) => {
|
|
226
|
-
// Not swallowed. Everywhere else a `strict` ledger failure fails closed, and losing the one line that
|
|
227
|
-
// records a human's refusal is the direction rule 8 forbids — the chain refuses either way, so saying so
|
|
228
|
-
// costs nothing.
|
|
229
|
-
ctx.ui?.notify?.(
|
|
230
|
-
`grants: the chain was refused AND its ledger line could not be written (${String(error)}) — the ` +
|
|
231
|
-
`refusal happened, but this audit trail does not show it.`,
|
|
232
|
-
"error",
|
|
233
|
-
);
|
|
254
|
+
const { request, outcome } = declined;
|
|
255
|
+
const message = `chain refused: ${request.capability} was not approved for ${request.subject}, so no step ran. A chain ` +
|
|
256
|
+
`is gated as a unit — running only its approved steps would return a partial result that reads like a complete one.`;
|
|
257
|
+
const structured = outcome.refusalCode ? refusal(outcome.refusalCode, message) : undefined;
|
|
258
|
+
for (const approved of approvedDecisions) {
|
|
259
|
+
await recordChainRefusal({
|
|
260
|
+
session, plan: approved.request.plan, stepIndex: approved.request.stepIndex,
|
|
261
|
+
agent: steps[approved.request.stepIndex]?.agent, reason: message, approval: approved.outcome,
|
|
234
262
|
});
|
|
235
263
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
);
|
|
264
|
+
await recordChainRefusal({
|
|
265
|
+
session, plan: request.plan, stepIndex: request.stepIndex, agent: steps[request.stepIndex]?.agent,
|
|
266
|
+
reason: message, refusal: structured, approval: outcome,
|
|
267
|
+
});
|
|
268
|
+
if (structured) throw new GovernanceRefusal(structured);
|
|
269
|
+
throw new Error(message);
|
|
241
270
|
}
|
|
242
271
|
|
|
243
272
|
const children: ChildProgress[] = steps.map((step) => ({
|
|
@@ -252,7 +281,9 @@ export function registerChainTool(pi: ExtensionAPI, session: GrantsSession): voi
|
|
|
252
281
|
});
|
|
253
282
|
}, PAINT_INTERVAL_MS);
|
|
254
283
|
|
|
255
|
-
const outcomes: Array<{
|
|
284
|
+
const outcomes: Array<{
|
|
285
|
+
ok: boolean; text: string; reason?: string; refusal?: StructuredRefusal; step: number; agent?: string;
|
|
286
|
+
}> = [];
|
|
256
287
|
let previous: string | undefined;
|
|
257
288
|
let aborted = false;
|
|
258
289
|
/**
|
|
@@ -271,6 +302,11 @@ export function registerChainTool(pi: ExtensionAPI, session: GrantsSession): voi
|
|
|
271
302
|
|
|
272
303
|
for (const [index, step] of steps.entries()) {
|
|
273
304
|
const childId = childSpawnId(session.ownSpawnId, index);
|
|
305
|
+
const availableForStep = available.filter((approval) => {
|
|
306
|
+
const key = `${approval.capability}@${approval.subject}`;
|
|
307
|
+
return chainPlan.uses.get(index)?.has(key) &&
|
|
308
|
+
(approval.scope !== "once" || approvalStep.get(key) === index);
|
|
309
|
+
});
|
|
274
310
|
const outcome = await runOneDelegation(
|
|
275
311
|
session,
|
|
276
312
|
chainStepSpec(step, previous),
|
|
@@ -279,21 +315,9 @@ export function registerChainTool(pi: ExtensionAPI, session: GrantsSession): voi
|
|
|
279
315
|
ctx,
|
|
280
316
|
signal,
|
|
281
317
|
{
|
|
282
|
-
preApproved:
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
// chain as a whole.
|
|
286
|
-
approved: available.filter((a) => a.subject === (step.agent ?? DELEGATE_SUBJECT)).map((a) => a.capability),
|
|
287
|
-
sources: Object.fromEntries(
|
|
288
|
-
available
|
|
289
|
-
.filter((a) => a.subject === (step.agent ?? DELEGATE_SUBJECT))
|
|
290
|
-
.map((a) => [a.capability, "prompt" as const]),
|
|
291
|
-
),
|
|
292
|
-
scopes: Object.fromEntries(
|
|
293
|
-
available.filter((a) => a.subject === (step.agent ?? DELEGATE_SUBJECT)).map((a) => [a.capability, a.scope]),
|
|
294
|
-
),
|
|
295
|
-
humanDenied: false,
|
|
296
|
-
},
|
|
318
|
+
preApproved: availableForStep,
|
|
319
|
+
// Only approvals actually offered to this step are attributed or consumed here.
|
|
320
|
+
approvalFacts: chainApprovalFacts(approvalAudit, availableForStep, step.agent ?? DELEGATE_SUBJECT),
|
|
297
321
|
onProgress: (update) => {
|
|
298
322
|
const child = children[index];
|
|
299
323
|
if (!child) return;
|
|
@@ -312,11 +336,16 @@ export function registerChainTool(pi: ExtensionAPI, session: GrantsSession): voi
|
|
|
312
336
|
|
|
313
337
|
children[index].state = outcome.ok ? "completed" : "failed";
|
|
314
338
|
children[index].settledAt = Date.now();
|
|
315
|
-
outcomes.push({
|
|
339
|
+
outcomes.push({
|
|
340
|
+
ok: outcome.ok, text: outcome.text, reason: outcome.reason, refusal: outcome.refusal,
|
|
341
|
+
step: index + 1, agent: step.agent,
|
|
342
|
+
});
|
|
343
|
+
if (isCriticalAssuranceBlock(outcome)) throw new Error(outcome.text);
|
|
316
344
|
|
|
317
345
|
// Spend any `once` this step was handed, before the next step sees the list.
|
|
318
|
-
|
|
319
|
-
|
|
346
|
+
available = available.filter((approval) =>
|
|
347
|
+
approval.scope !== "once" || approvalStep.get(`${approval.capability}@${approval.subject}`) !== index,
|
|
348
|
+
);
|
|
320
349
|
|
|
321
350
|
if (!outcome.ok) {
|
|
322
351
|
// **Abort, and mark the rest.** Continuing would make the next step's task an error message, which is
|
|
@@ -345,12 +374,17 @@ export function registerChainTool(pi: ExtensionAPI, session: GrantsSession): voi
|
|
|
345
374
|
if (outcomes.length === 1 && !outcomes[0].ok) {
|
|
346
375
|
// Nothing completed at all, so there is no partial result to hand back — and a tool that returns text when
|
|
347
376
|
// nothing ran is how a wrong summary gets written.
|
|
348
|
-
|
|
377
|
+
const message = `chain failed at its first step.\n\n${report}`;
|
|
378
|
+
if (outcomes[0].refusal) throw new GovernanceRefusal({ ...outcomes[0].refusal, message });
|
|
379
|
+
throw new Error(message);
|
|
349
380
|
}
|
|
350
381
|
|
|
351
382
|
return {
|
|
352
383
|
content: [{ type: "text", text: `${report}${tail}` }],
|
|
353
|
-
details: {
|
|
384
|
+
details: {
|
|
385
|
+
steps: steps.length, completed: outcomes.filter((o) => o.ok).length, aborted,
|
|
386
|
+
budgetPerStep: split.perChild, refusals: outcomes.map((outcome) => outcome.refusal ?? null),
|
|
387
|
+
},
|
|
354
388
|
};
|
|
355
389
|
},
|
|
356
390
|
});
|