pi-daddy 0.17.0 → 0.18.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 (113) hide show
  1. package/CHANGELOG.md +154 -80
  2. package/README.md +54 -25
  3. package/dist/approval-prompt.d.ts +3 -1
  4. package/dist/approval-prompt.d.ts.map +1 -1
  5. package/dist/approval-prompt.js +1 -1
  6. package/dist/approval-prompt.js.map +1 -1
  7. package/dist/approval-store.d.ts.map +1 -1
  8. package/dist/approval-store.js +4 -1
  9. package/dist/approval-store.js.map +1 -1
  10. package/dist/approval.d.ts +20 -2
  11. package/dist/approval.d.ts.map +1 -1
  12. package/dist/approval.js +26 -6
  13. package/dist/approval.js.map +1 -1
  14. package/dist/chain.d.ts +6 -1
  15. package/dist/chain.d.ts.map +1 -1
  16. package/dist/chain.js +1 -1
  17. package/dist/chain.js.map +1 -1
  18. package/dist/check-runner.d.ts +61 -0
  19. package/dist/check-runner.d.ts.map +1 -0
  20. package/dist/check-runner.js +237 -0
  21. package/dist/check-runner.js.map +1 -0
  22. package/dist/correlation.d.ts +90 -0
  23. package/dist/correlation.d.ts.map +1 -0
  24. package/dist/correlation.js +183 -0
  25. package/dist/correlation.js.map +1 -0
  26. package/dist/delegate-types.d.ts +140 -0
  27. package/dist/delegate-types.d.ts.map +1 -0
  28. package/dist/delegate-types.js +8 -0
  29. package/dist/delegate-types.js.map +1 -0
  30. package/dist/delegate.d.ts +4 -128
  31. package/dist/delegate.d.ts.map +1 -1
  32. package/dist/delegate.js +68 -72
  33. package/dist/delegate.js.map +1 -1
  34. package/dist/delegation-approval.d.ts +36 -0
  35. package/dist/delegation-approval.d.ts.map +1 -0
  36. package/dist/delegation-approval.js +67 -0
  37. package/dist/delegation-approval.js.map +1 -0
  38. package/dist/git-identity.d.ts +13 -0
  39. package/dist/git-identity.d.ts.map +1 -0
  40. package/dist/git-identity.js +44 -0
  41. package/dist/git-identity.js.map +1 -0
  42. package/dist/index.d.ts +5 -1
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +5 -1
  45. package/dist/index.js.map +1 -1
  46. package/dist/lease-record.d.ts +78 -0
  47. package/dist/lease-record.d.ts.map +1 -0
  48. package/dist/lease-record.js +52 -0
  49. package/dist/lease-record.js.map +1 -0
  50. package/dist/ledger-events.d.ts +87 -0
  51. package/dist/ledger-events.d.ts.map +1 -0
  52. package/dist/ledger-events.js +45 -0
  53. package/dist/ledger-events.js.map +1 -0
  54. package/dist/ledger-report.d.ts +19 -12
  55. package/dist/ledger-report.d.ts.map +1 -1
  56. package/dist/ledger-report.js +81 -1
  57. package/dist/ledger-report.js.map +1 -1
  58. package/dist/ledger.d.ts +44 -1
  59. package/dist/ledger.d.ts.map +1 -1
  60. package/dist/ledger.js +20 -2
  61. package/dist/ledger.js.map +1 -1
  62. package/dist/refusals.d.ts +16 -0
  63. package/dist/refusals.d.ts.map +1 -0
  64. package/dist/refusals.js +51 -0
  65. package/dist/refusals.js.map +1 -0
  66. package/dist/run-child.d.ts +4 -0
  67. package/dist/run-child.d.ts.map +1 -1
  68. package/dist/run-child.js +21 -1
  69. package/dist/run-child.js.map +1 -1
  70. package/dist/run-herdr.d.ts +7 -0
  71. package/dist/run-herdr.d.ts.map +1 -1
  72. package/dist/run-herdr.js +41 -12
  73. package/dist/run-herdr.js.map +1 -1
  74. package/dist/workspace-lease.d.ts +28 -0
  75. package/dist/workspace-lease.d.ts.map +1 -0
  76. package/dist/workspace-lease.js +276 -0
  77. package/dist/workspace-lease.js.map +1 -0
  78. package/dist/workspace.d.ts +32 -0
  79. package/dist/workspace.d.ts.map +1 -0
  80. package/dist/workspace.js +79 -0
  81. package/dist/workspace.js.map +1 -0
  82. package/extensions/approval-banking.ts +66 -0
  83. package/extensions/approvals.ts +95 -7
  84. package/extensions/chain-approval-facts.ts +51 -0
  85. package/extensions/chain-ledger.ts +48 -0
  86. package/extensions/delegate-chain.ts +115 -81
  87. package/extensions/delegation.ts +81 -23
  88. package/extensions/execute-child.ts +289 -0
  89. package/extensions/fanout-outcome.ts +97 -0
  90. package/extensions/run-delegation.ts +130 -120
  91. package/extensions/session.ts +4 -0
  92. package/extensions/workspace-runtime.ts +165 -0
  93. package/package.json +17 -1
  94. package/src/approval-prompt.ts +4 -2
  95. package/src/approval-store.ts +4 -1
  96. package/src/approval.ts +47 -14
  97. package/src/chain.ts +3 -1
  98. package/src/check-runner.ts +341 -0
  99. package/src/correlation.ts +260 -0
  100. package/src/delegate-types.ts +144 -0
  101. package/src/delegate.ts +80 -179
  102. package/src/delegation-approval.ts +99 -0
  103. package/src/git-identity.ts +52 -0
  104. package/src/index.ts +50 -0
  105. package/src/lease-record.ts +119 -0
  106. package/src/ledger-events.ts +138 -0
  107. package/src/ledger-report.ts +90 -2
  108. package/src/ledger.ts +68 -3
  109. package/src/refusals.ts +66 -0
  110. package/src/run-child.ts +23 -1
  111. package/src/run-herdr.ts +41 -11
  112. package/src/workspace-lease.ts +303 -0
  113. package/src/workspace.ts +135 -0
@@ -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 { appendRecord, buildRecord } from "../src/ledger.ts";
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
- interface StepSpec {
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
- { task: step.task, agent: step.agent, tools: step.tools, model: step.model },
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 { requests: [], doomed: { step: index + 1, reason: plan.reason ?? "this step cannot run" } };
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({ subject, path: step.agent ? "definition" : "delegate", capability, task: step.task });
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 Error(`chain refused: ${session.executor.refusal}`);
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 Error(`chain refused: ${split.reason}`);
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
- throw new Error(
171
- `chain refused at step ${chainPlan.doomed.step}: ${chainPlan.doomed.reason} No step ran, and nobody was ` +
172
- `asked to approve anything — a step that cannot run must not bank authority for a spawn that will ` +
173
- `never happen.`,
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
- let declined: { capability: Capability; subject: string } | undefined;
180
- let humanDenied = false;
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 = { capability: request.capability, subject: request.subject };
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
- // Recorded before it is thrown, and recorded against the subject that was actually refused. The first version
204
- // hardcoded step 1's identity, so the trail asserted a human had denied a capability for `digger` when they
205
- // had *approved* it for `digger` and denied it for `shaper` — the ledger and the approval store asserting
206
- // opposite facts about the same key, which is R-28's shape.
207
- if (session.ledgerPath) {
208
- const at = steps.findIndex((step) => (step.agent ?? DELEGATE_SUBJECT) === declined.subject);
209
- await appendRecord(
210
- { path: session.ledgerPath, strict: true },
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
- throw new Error(
237
- `chain refused: ${declined.capability} was not approved for ${declined.subject}, so no step ran. A chain ` +
238
- `is gated as a unit — running only its approved steps would return a partial result that reads like a ` +
239
- `complete one.`,
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<{ ok: boolean; text: string; reason?: string; step: number; agent?: string }> = [];
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: available,
283
- approvalFacts: {
284
- // Only this step's subject, so the record says what was authorised for THIS child rather than for the
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({ ok: outcome.ok, text: outcome.text, reason: outcome.reason, step: index + 1, agent: step.agent });
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
- const spentSubject = step.agent ?? DELEGATE_SUBJECT;
319
- available = available.filter((a) => !(a.scope === "once" && a.subject === spentSubject));
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
- throw new Error(`chain failed at its first step.\n\n${report}`);
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: { steps: steps.length, completed: outcomes.filter((o) => o.ok).length, aborted, budgetPerStep: split.perChild },
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
  });
@@ -23,7 +23,15 @@ import {
23
23
  type ChildProgress,
24
24
  } from "../src/progress.ts";
25
25
  import { registerChainTool } from "./delegate-chain.ts";
26
+ import { GovernanceRefusal, refusal } from "../src/refusals.ts";
26
27
  import { runOneDelegation } from "./run-delegation.ts";
28
+ import {
29
+ buildFanoutReport,
30
+ childFailureOutcome,
31
+ throwFanoutInfrastructure,
32
+ totalFanoutFailure,
33
+ } from "./fanout-outcome.ts";
34
+ import { isCriticalAssuranceBlock, type DelegationOutcome } from "./execute-child.ts";
27
35
  import { type GrantsSession } from "./session.ts";
28
36
 
29
37
  /**
@@ -150,11 +158,42 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
150
158
  `Name of a definition to spawn — its allowed-tools become the grant and its instructions ` +
151
159
  `become the sub-agent's system prompt. Available: ${names.join(", ") || "none"}.`;
152
160
 
161
+ const correlationShape = Type.Object({
162
+ schema_version: Type.Optional(Type.String()),
163
+ run_id: Type.Optional(Type.String()),
164
+ task_id: Type.Optional(Type.String()),
165
+ workspace_id: Type.Optional(Type.String()),
166
+ context_id: Type.Optional(Type.String()),
167
+ phase: Type.Optional(Type.String()),
168
+ assurance: Type.Optional(Type.String()),
169
+ assurance_effective: Type.Optional(Type.String()),
170
+ policy_label: Type.Optional(Type.String()),
171
+ assurance_source: Type.Optional(Type.String()),
172
+ assurance_scope: Type.Optional(Type.Any()),
173
+ activated_at: Type.Optional(Type.String()),
174
+ plan_digest: Type.Optional(Type.String()),
175
+ definition_digest: Type.Optional(Type.String()),
176
+ task_digest: Type.Optional(Type.String()),
177
+ base_sha: Type.Optional(Type.String()),
178
+ head_sha: Type.Optional(Type.String()),
179
+ tree_sha: Type.Optional(Type.String()),
180
+ event_seq: Type.Optional(Type.Number()),
181
+ last_change_seq: Type.Optional(Type.Number()),
182
+ last_authority_seq: Type.Optional(Type.Number()),
183
+ check_receipt_id: Type.Optional(Type.String()),
184
+ });
185
+ const workspaceShape = Type.Object({
186
+ workspace_id: Type.String({ description: "ID from the operator-owned workspace registry." }),
187
+ access: Type.Union([Type.Literal("read"), Type.Literal("write")]),
188
+ });
189
+
153
190
  const childShape = Type.Object({
154
191
  task: Type.String({ description: "The task for this sub-agent. It receives only this." }),
155
192
  agent: Type.Optional(Type.String({ description: describeAgent(spawnable()) })),
156
193
  tools: Type.Optional(Type.Array(Type.String(), { description: "Capabilities, when no 'agent' fits." })),
157
194
  model: Type.Optional(Type.String({ description: "Model as provider/id. Defaults to this session's." })),
195
+ correlation: Type.Optional(correlationShape),
196
+ workspace: Type.Optional(workspaceShape),
158
197
  });
159
198
 
160
199
  const delegateAllParams = Type.Object({
@@ -184,6 +223,8 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
184
223
  "Defaults to this session's model, already provider-qualified.",
185
224
  }),
186
225
  ),
226
+ correlation: Type.Optional(correlationShape),
227
+ workspace: Type.Optional(workspaceShape),
187
228
  });
188
229
 
189
230
  pi.registerTool({
@@ -201,7 +242,14 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
201
242
  const progress = progressReporter(session, [params.agent ?? "delegate"], onUpdate as never);
202
243
  const outcome = await runOneDelegation(
203
244
  session,
204
- { task: params.task, agent: params.agent, tools: params.tools, model: params.model },
245
+ {
246
+ task: params.task,
247
+ agent: params.agent,
248
+ tools: params.tools,
249
+ model: params.model,
250
+ correlation: params.correlation,
251
+ workspace: params.workspace,
252
+ },
205
253
  { parentId: session.ownSpawnId, childId: childSpawnId(session.ownSpawnId, 0) },
206
254
  // A single blocking delegation spends nothing from the subtree budget: cardinality is already
207
255
  // bounded to one by the call being blocking, which is the accident fan-out removes. Passing the
@@ -214,12 +262,15 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
214
262
  progress.settle([outcome]);
215
263
 
216
264
  if (!outcome.ok) {
265
+ if (isCriticalAssuranceBlock(outcome)) throw new Error(outcome.text);
217
266
  // THROW, do not return. `AgentToolResult` has no `isError` field: pi sets it only when `execute`
218
267
  // throws (`pi-agent-core/dist/agent-loop.js` — a normal return is hardcoded `isError: false`).
219
268
  // Returning `isError: true` was silently discarded, so every refusal this package made was
220
269
  // recorded by pi as a SUCCESSFUL tool call. Found by the integration suite on its first run.
221
270
  const detail = outcome.text ? `\n\n${outcome.text}` : "";
222
- throw new Error(`delegation refused: ${outcome.reason}${detail}`);
271
+ const message = `delegation refused: ${outcome.reason}${detail}`;
272
+ if (outcome.refusal) throw new GovernanceRefusal({ ...outcome.refusal, message });
273
+ throw new Error(message);
223
274
  }
224
275
 
225
276
  return {
@@ -261,7 +312,7 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
261
312
  if (!split.ok) {
262
313
  // Thrown, not returned: a returned `isError` is discarded by pi, so a refusal that came back as a
263
314
  // normal result would read to the orchestrator as a successful fan-out of zero children.
264
- throw new Error(`fan-out refused: ${split.reason}`);
315
+ throw new GovernanceRefusal(refusal("FANOUT_EXCEEDED", `fan-out refused: ${split.reason}`));
265
316
  }
266
317
 
267
318
  // ADR-0032: ONE status block covering every child. `onUpdate` replaces the tool's rendered result, so a
@@ -270,38 +321,44 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
270
321
 
271
322
  // Concurrent by construction. Each child gets its own budget share and its own ledger id, so the
272
323
  // records form a tree and two siblings can never be confused for one another.
324
+ // EVERY sibling's error, not just the first. `??=` discarded the rest with no log anywhere, and one
325
+ // of the discardable ones is `HerdrWriterCloseError`, whose entire meaning is "a pane may still be
326
+ // live and its writer lease is deliberately retained" — a resource-retention notice, not a per-child
327
+ // failure. Losing it meant nobody was told a lease is held with no owner until the process exits
328
+ // (R-116).
329
+ const infrastructureErrors: unknown[] = [];
273
330
  const outcomes = await Promise.all(
274
- children.map((child, index) =>
275
- runOneDelegation(
276
- session,
277
- child,
278
- { parentId: session.ownSpawnId, childId: childSpawnId(session.ownSpawnId, index) },
279
- split.perChild,
280
- ctx,
281
- signal,
282
- { onProgress: progress.sink(index) },
283
- ),
284
- ),
331
+ children.map(async (child, index): Promise<DelegationOutcome> => {
332
+ try {
333
+ return await runOneDelegation(
334
+ session, child,
335
+ { parentId: session.ownSpawnId, childId: childSpawnId(session.ownSpawnId, index) },
336
+ split.perChild, ctx, signal, { onProgress: progress.sink(index) },
337
+ );
338
+ } catch (error) {
339
+ infrastructureErrors.push(error);
340
+ return childFailureOutcome(error, session.depth + 1);
341
+ }
342
+ }),
285
343
  );
286
344
  progress.settle(outcomes);
345
+ // The upstream controller's verdict outranks our own infrastructure noise — it is the answer the
346
+ // caller is waiting for, and ADR-0034 requires it to pass through unchanged. But an infrastructure
347
+ // failure must not VANISH behind it, which is what happened when a retained-lease error and a
348
+ // critical block landed in the same fan-out (R-117).
349
+ throwFanoutInfrastructure(outcomes, infrastructureErrors);
287
350
 
288
351
  const failed = outcomes.filter((o) => !o.ok);
289
352
  // Every child is reported, including the ones that failed. R-03's rule: a missing result must never
290
353
  // be indistinguishable from an empty one, and a fan-out that hid its refusals would let an
291
354
  // orchestrator summarise four reviews when only three happened.
292
- const report = outcomes
293
- .map((outcome, index) => {
294
- const label = `### child ${index + 1}${children[index].agent ? ` (${children[index].agent})` : ""}`;
295
- return outcome.ok
296
- ? `${label} — completed\n\n${outcome.text || "(no output)"}`
297
- : `${label} — FAILED: ${outcome.reason}${outcome.text ? `\n\n${outcome.text}` : ""}`;
298
- })
299
- .join("\n\n---\n\n");
355
+ const report = buildFanoutReport(outcomes, children);
300
356
 
301
357
  if (failed.length === children.length) {
302
358
  // All of them failed, so there is no partial result to hand back — and a tool that returns text
303
359
  // when nothing ran is exactly how a wrong summary gets written.
304
- throw new Error(`fan-out failed: every child was refused or failed.\n\n${report}`);
360
+ const message = `fan-out failed: every child was refused or failed.\n\n${report}`;
361
+ throw totalFanoutFailure(failed, message);
305
362
  }
306
363
 
307
364
  return {
@@ -311,6 +368,7 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
311
368
  failed: failed.length,
312
369
  budgetPerChild: split.perChild,
313
370
  granted: outcomes.map((o) => o.granted),
371
+ refusals: outcomes.map((o) => o.refusal ?? null),
314
372
  },
315
373
  };
316
374
  },