pi-daddy 0.43.0 → 0.44.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 (222) hide show
  1. package/CHANGELOG.md +104 -33
  2. package/README.md +27 -5
  3. package/THIRD_PARTY_LICENSES/musl-COPYRIGHT.txt +193 -0
  4. package/THIRD_PARTY_LICENSES/zig-MIT.txt +21 -0
  5. package/THIRD_PARTY_NOTICES.md +9 -0
  6. package/contracts/ledger-record/v1/README.md +40 -25
  7. package/dist/cli.d.ts +2 -1
  8. package/dist/cli.d.ts.map +1 -1
  9. package/dist/cli.js +10 -5
  10. package/dist/cli.js.map +1 -1
  11. package/dist/executors/activity-session.js +116 -65
  12. package/dist/executors/activity-session.js.map +1 -1
  13. package/dist/executors/captured-execution.d.ts +13 -0
  14. package/dist/executors/captured-execution.d.ts.map +1 -0
  15. package/dist/executors/captured-execution.js +53 -0
  16. package/dist/executors/captured-execution.js.map +1 -0
  17. package/dist/executors/child-final.d.ts +31 -0
  18. package/dist/executors/child-final.d.ts.map +1 -0
  19. package/dist/executors/child-final.js +197 -0
  20. package/dist/executors/child-final.js.map +1 -0
  21. package/dist/executors/executor.d.ts +2 -1
  22. package/dist/executors/executor.d.ts.map +1 -1
  23. package/dist/executors/executor.js +12 -0
  24. package/dist/executors/executor.js.map +1 -1
  25. package/dist/executors/owned-worker.d.ts +18 -0
  26. package/dist/executors/owned-worker.d.ts.map +1 -0
  27. package/dist/executors/owned-worker.js +295 -0
  28. package/dist/executors/owned-worker.js.map +1 -0
  29. package/dist/executors/worker-artifact.d.ts +3 -0
  30. package/dist/executors/worker-artifact.d.ts.map +1 -0
  31. package/dist/executors/worker-artifact.js +3 -0
  32. package/dist/executors/worker-artifact.js.map +1 -0
  33. package/dist/governance/approval-prompt.d.ts +4 -3
  34. package/dist/governance/approval-prompt.d.ts.map +1 -1
  35. package/dist/governance/approval-prompt.js +13 -6
  36. package/dist/governance/approval-prompt.js.map +1 -1
  37. package/dist/governance/captured-worker-record.d.ts +9 -0
  38. package/dist/governance/captured-worker-record.d.ts.map +1 -0
  39. package/dist/governance/captured-worker-record.js +123 -0
  40. package/dist/governance/captured-worker-record.js.map +1 -0
  41. package/dist/governance/lease-record.d.ts +4 -0
  42. package/dist/governance/lease-record.d.ts.map +1 -1
  43. package/dist/governance/lease-record.js +19 -2
  44. package/dist/governance/lease-record.js.map +1 -1
  45. package/dist/governance/ledger-events.d.ts +5 -5
  46. package/dist/governance/ledger-events.d.ts.map +1 -1
  47. package/dist/governance/ledger-v3-validation.js +1 -1
  48. package/dist/governance/ledger-v3-validation.js.map +1 -1
  49. package/dist/governance/workspace-acceptance.d.ts +3 -1
  50. package/dist/governance/workspace-acceptance.d.ts.map +1 -1
  51. package/dist/governance/workspace-acceptance.js +21 -5
  52. package/dist/governance/workspace-acceptance.js.map +1 -1
  53. package/dist/governance/workspace-lease.d.ts.map +1 -1
  54. package/dist/governance/workspace-lease.js +44 -1
  55. package/dist/governance/workspace-lease.js.map +1 -1
  56. package/dist/kernel/authority-text.d.ts +3 -0
  57. package/dist/kernel/authority-text.d.ts.map +1 -0
  58. package/dist/kernel/authority-text.js +22 -0
  59. package/dist/kernel/authority-text.js.map +1 -0
  60. package/dist/kernel/bounded-read-failures.d.ts +9 -0
  61. package/dist/kernel/bounded-read-failures.d.ts.map +1 -0
  62. package/dist/kernel/bounded-read-failures.js +33 -0
  63. package/dist/kernel/bounded-read-failures.js.map +1 -0
  64. package/dist/kernel/bounded-read-sync.d.ts +24 -0
  65. package/dist/kernel/bounded-read-sync.d.ts.map +1 -0
  66. package/dist/kernel/bounded-read-sync.js +75 -0
  67. package/dist/kernel/bounded-read-sync.js.map +1 -0
  68. package/dist/kernel/bounded-read.d.ts +29 -2
  69. package/dist/kernel/bounded-read.d.ts.map +1 -1
  70. package/dist/kernel/bounded-read.js +98 -9
  71. package/dist/kernel/bounded-read.js.map +1 -1
  72. package/dist/kernel/capacity.d.ts +36 -0
  73. package/dist/kernel/capacity.d.ts.map +1 -0
  74. package/dist/kernel/capacity.js +94 -0
  75. package/dist/kernel/capacity.js.map +1 -0
  76. package/dist/kernel/captured-worker-contract.d.ts +38 -0
  77. package/dist/kernel/captured-worker-contract.d.ts.map +1 -0
  78. package/dist/kernel/captured-worker-contract.js +2 -0
  79. package/dist/kernel/captured-worker-contract.js.map +1 -0
  80. package/dist/kernel/catalog.d.ts +12 -3
  81. package/dist/kernel/catalog.d.ts.map +1 -1
  82. package/dist/kernel/catalog.js +28 -8
  83. package/dist/kernel/catalog.js.map +1 -1
  84. package/dist/kernel/chain.d.ts +1 -0
  85. package/dist/kernel/chain.d.ts.map +1 -1
  86. package/dist/kernel/chain.js.map +1 -1
  87. package/dist/kernel/definitions.d.ts +11 -1
  88. package/dist/kernel/definitions.d.ts.map +1 -1
  89. package/dist/kernel/definitions.js +6 -3
  90. package/dist/kernel/definitions.js.map +1 -1
  91. package/dist/kernel/fanout.d.ts +5 -25
  92. package/dist/kernel/fanout.d.ts.map +1 -1
  93. package/dist/kernel/fanout.js +15 -30
  94. package/dist/kernel/fanout.js.map +1 -1
  95. package/dist/kernel/grant-env.d.ts +0 -2
  96. package/dist/kernel/grant-env.d.ts.map +1 -1
  97. package/dist/kernel/grant-env.js +0 -1
  98. package/dist/kernel/grant-env.js.map +1 -1
  99. package/dist/kernel/model-preflight.d.ts +8 -0
  100. package/dist/kernel/model-preflight.d.ts.map +1 -1
  101. package/dist/kernel/model-preflight.js +23 -0
  102. package/dist/kernel/model-preflight.js.map +1 -1
  103. package/dist/kernel/selected-definitions.d.ts +15 -0
  104. package/dist/kernel/selected-definitions.d.ts.map +1 -0
  105. package/dist/kernel/selected-definitions.js +146 -0
  106. package/dist/kernel/selected-definitions.js.map +1 -0
  107. package/dist/kernel/skill-packages.d.ts +4 -2
  108. package/dist/kernel/skill-packages.d.ts.map +1 -1
  109. package/dist/kernel/skill-packages.js +34 -41
  110. package/dist/kernel/skill-packages.js.map +1 -1
  111. package/dist/kernel/skill-resources.d.ts.map +1 -1
  112. package/dist/kernel/skill-resources.js +8 -11
  113. package/dist/kernel/skill-resources.js.map +1 -1
  114. package/dist/kernel/workspace.d.ts +6 -3
  115. package/dist/kernel/workspace.d.ts.map +1 -1
  116. package/dist/kernel/workspace.js +10 -6
  117. package/dist/kernel/workspace.js.map +1 -1
  118. package/dist/products/dashboard-cli.js +1 -1
  119. package/dist/products/dashboard-cli.js.map +1 -1
  120. package/dist/products/dashboard-render.d.ts +1 -2
  121. package/dist/products/dashboard-render.d.ts.map +1 -1
  122. package/dist/products/dashboard-render.js +3 -1
  123. package/dist/products/dashboard-render.js.map +1 -1
  124. package/dist/products/dashboard-session-client.d.ts +1 -2
  125. package/dist/products/dashboard-session-client.d.ts.map +1 -1
  126. package/dist/products/dashboard-session-client.js.map +1 -1
  127. package/dist/products/episode-report.d.ts +12 -8
  128. package/dist/products/episode-report.d.ts.map +1 -1
  129. package/dist/products/episode-report.js +61 -12
  130. package/dist/products/episode-report.js.map +1 -1
  131. package/extensions/approvals.ts +1 -1
  132. package/extensions/chain-plan.ts +9 -3
  133. package/extensions/context-staging.ts +30 -35
  134. package/extensions/dashboard-session-server.ts +1 -2
  135. package/extensions/definition-describe.ts +44 -0
  136. package/extensions/definition-runtime.ts +89 -26
  137. package/extensions/delegate-chain.ts +273 -256
  138. package/extensions/delegation-activation.ts +1 -1
  139. package/extensions/delegation.ts +41 -53
  140. package/extensions/execute-child.ts +167 -134
  141. package/extensions/fanout-outcome.ts +1 -9
  142. package/extensions/grants-command.ts +2 -9
  143. package/extensions/grants.ts +106 -38
  144. package/extensions/init-command.ts +52 -12
  145. package/extensions/reload-environment.ts +21 -2
  146. package/extensions/retired-inputs.ts +7 -0
  147. package/extensions/run-delegation.ts +212 -265
  148. package/extensions/session-capacity.ts +53 -0
  149. package/extensions/session-environment.ts +5 -2
  150. package/extensions/session-model-prompt.ts +33 -32
  151. package/extensions/session-report.ts +28 -11
  152. package/extensions/session.ts +200 -157
  153. package/extensions/workspace-runtime.ts +2 -1
  154. package/native/README.md +25 -0
  155. package/native/linux-x64/worker +0 -0
  156. package/native/linux-x64/worker.sha256 +1 -0
  157. package/native/provenance.json +42 -0
  158. package/native/toolchain.json +18 -0
  159. package/native/worker.c +209 -0
  160. package/package.json +15 -7
  161. package/src/cli.ts +16 -6
  162. package/src/executors/activity-session.ts +120 -66
  163. package/src/executors/captured-execution.ts +61 -0
  164. package/src/executors/child-final.ts +190 -0
  165. package/src/executors/executor.ts +14 -1
  166. package/src/executors/owned-worker.ts +322 -0
  167. package/src/executors/worker-artifact.ts +2 -0
  168. package/src/governance/approval-prompt.ts +18 -8
  169. package/src/governance/captured-worker-record.ts +138 -0
  170. package/src/governance/lease-record.ts +21 -2
  171. package/src/governance/ledger-events.ts +5 -5
  172. package/src/governance/ledger-v3-validation.ts +1 -1
  173. package/src/governance/workspace-acceptance.ts +20 -4
  174. package/src/governance/workspace-lease.ts +59 -1
  175. package/src/kernel/authority-text.ts +17 -0
  176. package/src/kernel/bounded-read-failures.ts +42 -0
  177. package/src/kernel/bounded-read-sync.ts +87 -0
  178. package/src/kernel/bounded-read.ts +128 -11
  179. package/src/kernel/capacity.ts +123 -0
  180. package/src/kernel/captured-worker-contract.ts +29 -0
  181. package/src/kernel/catalog.ts +45 -16
  182. package/src/kernel/chain.ts +1 -0
  183. package/src/kernel/definitions.ts +11 -2
  184. package/src/kernel/fanout.ts +17 -32
  185. package/src/kernel/grant-env.ts +0 -3
  186. package/src/kernel/model-preflight.ts +29 -0
  187. package/src/kernel/selected-definitions.ts +156 -0
  188. package/src/kernel/skill-packages.ts +40 -38
  189. package/src/kernel/skill-resources.ts +8 -9
  190. package/src/kernel/workspace.ts +12 -5
  191. package/src/products/dashboard-cli.ts +1 -1
  192. package/src/products/dashboard-render.ts +6 -2
  193. package/src/products/dashboard-session-client.ts +1 -2
  194. package/src/products/episode-report.ts +80 -17
  195. package/dist/advisors/advisor.d.ts +0 -90
  196. package/dist/advisors/advisor.d.ts.map +0 -1
  197. package/dist/advisors/advisor.js +0 -154
  198. package/dist/advisors/advisor.js.map +0 -1
  199. package/dist/advisors/decider.d.ts +0 -88
  200. package/dist/advisors/decider.d.ts.map +0 -1
  201. package/dist/advisors/decider.js +0 -29
  202. package/dist/advisors/decider.js.map +0 -1
  203. package/dist/advisors/jev.d.ts +0 -41
  204. package/dist/advisors/jev.d.ts.map +0 -1
  205. package/dist/advisors/jev.js +0 -139
  206. package/dist/advisors/jev.js.map +0 -1
  207. package/dist/advisors/settings.d.ts +0 -49
  208. package/dist/advisors/settings.d.ts.map +0 -1
  209. package/dist/advisors/settings.js +0 -99
  210. package/dist/advisors/settings.js.map +0 -1
  211. package/dist/governance/episode-cost-gate.d.ts +0 -31
  212. package/dist/governance/episode-cost-gate.d.ts.map +0 -1
  213. package/dist/governance/episode-cost-gate.js +0 -112
  214. package/dist/governance/episode-cost-gate.js.map +0 -1
  215. package/extensions/advisor-session.ts +0 -67
  216. package/extensions/effort-advice.ts +0 -112
  217. package/extensions/pruning-advice.ts +0 -133
  218. package/src/advisors/advisor.ts +0 -246
  219. package/src/advisors/decider.ts +0 -74
  220. package/src/advisors/jev.ts +0 -163
  221. package/src/advisors/settings.ts +0 -115
  222. package/src/governance/episode-cost-gate.ts +0 -128
@@ -1,3 +1,4 @@
1
+ import { assertDefinitionIdentity } from "./definition-describe.ts";
1
2
  /**
2
3
  * `delegate_chain` — a governed sequential pipeline, planned and gated as one unit (ADR-0033).
3
4
  *
@@ -20,8 +21,9 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
20
21
  import { Type } from "typebox";
21
22
  import type { InheritableApproval } from "../src/kernel/approval.ts";
22
23
  import { DELEGATE_SUBJECT } from "../src/kernel/approval.ts";
23
- import { chainStepSpec, PLACEHOLDER } from "../src/kernel/chain.ts";
24
- import { MAX_CHAIN_STEPS, childSpawnId, splitBudget } from "../src/kernel/fanout.ts";
24
+ import { reconcileDelegationCapacity, reserveDelegationCapacity } from "./session-capacity.ts";
25
+ import { chainStepSpec, PLACEHOLDER, HANDOFF_MAX_BYTES } from "../src/kernel/chain.ts";
26
+ import { MAX_CHAIN_STEPS, childSpawnId } from "../src/kernel/fanout.ts";
25
27
  import {
26
28
  PAINT_INTERVAL_MS,
27
29
  appendTail,
@@ -42,9 +44,9 @@ import { chainApprovalFacts, newChainApprovalAudit, rememberChainApproval } from
42
44
  import { newExecutionId } from "../src/kernel/execution-id.ts";
43
45
  import { planChain, type GateRequest } from "./chain-plan.ts";
44
46
  import { contextShape } from "./context-shape.ts";
45
- import { preflightModel } from "../src/kernel/model-preflight.ts";
47
+ import { preflightModel, runtimePairUnavailable } from "../src/kernel/model-preflight.ts";
46
48
  import { resolveDefinitionRuntime } from "./definition-runtime.ts";
47
- import { ensureSessionModelPrompt } from "./session-model-prompt.ts";
49
+ import { withoutRetiredDelegationArguments } from "./retired-inputs.ts";
48
50
  import { assertDelegationAuthority } from "./delegation-authority.ts";
49
51
 
50
52
  /** One chain step is one execution occurrence, however many capability dialogs contributed to its answer. */
@@ -72,6 +74,9 @@ function mergeGateOutcomes(outcomes: readonly ApprovalOutcome[]): ApprovalOutcom
72
74
 
73
75
  export function registerChainTool(pi: ExtensionAPI, session: GrantsSession): void {
74
76
  const stepShape = Type.Object({
77
+ definitionId: Type.Optional(
78
+ Type.String({ description: "Snapshot id returned by delegate_describe; required for Principal phases." }),
79
+ ),
75
80
  task: Type.String({
76
81
  description:
77
82
  `What this step should do. Write ${PLACEHOLDER} where the previous step's output belongs; if you omit it, ` +
@@ -102,9 +107,6 @@ export function registerChainTool(pi: ExtensionAPI, session: GrantsSession): voi
102
107
  maxItems: MAX_CHAIN_STEPS,
103
108
  description: "The steps to run IN ORDER. Each one sees the previous one's output.",
104
109
  }),
105
- episodeCostCeiling: Type.Optional(
106
- Type.Number({ exclusiveMinimum: 0, description: "Override the USD ceiling for this whole episode." }),
107
- ),
108
110
  });
109
111
 
110
112
  pi.registerTool({
@@ -118,9 +120,12 @@ export function registerChainTool(pi: ExtensionAPI, session: GrantsSession): voi
118
120
  "governed exactly as a single `delegate` is: it holds only what you grant it, and you cannot grant what you " +
119
121
  "do not hold. A failed step ABORTS the rest, and you still receive everything that completed.",
120
122
  parameters: params,
123
+ prepareArguments: withoutRetiredDelegationArguments,
121
124
  async execute(_toolCallId, args, signal, onUpdate, ctx) {
122
125
  assertDelegationAuthority(session);
123
126
  const steps = args.steps ?? [];
127
+ await session.ensureDefinitions?.();
128
+ for (const step of steps) assertDefinitionIdentity(session, step);
124
129
 
125
130
  // **Every cheap refusal happens before any human is asked.** Yesterday's lesson on the `delegate` path: with
126
131
  // `PI_DADDY_HERDR=1` and herdr down, the gate ran first, an operator approved `bash`, a 30-day entry was
@@ -130,281 +135,293 @@ export function registerChainTool(pi: ExtensionAPI, session: GrantsSession): voi
130
135
  if (session.executor.refusal)
131
136
  throw new GovernanceRefusal(refusal("EXECUTOR_UNAVAILABLE", `chain refused: ${session.executor.refusal}`));
132
137
 
133
- // Cardinality next, still before the gate. `splitBudget` is reused rather than re-derived, so a chain and a
134
- // fan-out cannot disagree about what the budget means.
135
- const split = splitBudget(session.fanoutBudget, steps.length);
136
- if (!split.ok) throw new GovernanceRefusal(refusal("FANOUT_EXCEEDED", `chain refused: ${split.reason}`));
137
-
138
- await ensureSessionModelPrompt(
139
- session,
140
- steps.flatMap((step) => (step.agent ? [step.agent] : [])),
141
- {
142
- hasUI: ctx.hasUI && typeof ctx.ui.input === "function",
143
- input: (title, placeholder) => ctx.ui.input(title, placeholder),
144
- notify: (message, type) => ctx.ui.notify(message, type),
145
- },
146
- );
138
+ if (steps.length < 1 || steps.length > MAX_CHAIN_STEPS)
139
+ throw new GovernanceRefusal(refusal("FANOUT_EXCEEDED", `chain needs 1 to ${MAX_CHAIN_STEPS} steps`));
147
140
 
148
141
  // Plan every step first. A step that can never run refuses the chain HERE, before anyone is asked — see
149
142
  // `planChain`.
150
143
  const executionIds = steps.map(() => newExecutionId());
151
- const parentExecutionId = session.ownExecutionId ?? null;
152
- const piModel = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
153
- const chainPlan = await planChain(
154
- session,
155
- steps,
156
- executionIds,
157
- (model) =>
158
- preflightModel(model, ctx.modelRegistry, session.modelResolutionCache, session.allowUnresolvedModels),
159
- (step) =>
160
- resolveDefinitionRuntime({
161
- definition: step.agent,
162
- explicit: { model: step.model, thinking: step.thinking },
163
- session: session.definitionRuntimeOverrides,
164
- settings: session.definitionRuntimeSettings,
165
- piModel,
166
- }).model,
167
- );
168
- if (chainPlan.doomed) {
169
- const message =
170
- `chain refused at step ${chainPlan.doomed.step}: ${chainPlan.doomed.reason} No step ran, and nobody was ` +
171
- `asked to approve anything — a step that cannot run must not bank authority for a spawn that will never happen.`;
172
- await recordChainRefusal({
173
- session,
174
- plan: chainPlan.doomed.plan,
175
- stepIndex: chainPlan.doomed.step - 1,
176
- executionId: executionIds[chainPlan.doomed.step - 1],
177
- parentExecutionId,
178
- agent: chainPlan.doomed.agent,
179
- reason: message,
180
- refusal: chainPlan.doomed.refusal,
181
- });
182
- if (chainPlan.doomed.refusal) throw new GovernanceRefusal({ ...chainPlan.doomed.refusal, message });
183
- throw new Error(message);
184
- }
185
-
186
- // One dialog per `capability@subject`, each naming the step that needs it, all before the first step runs.
187
- const preApproved: InheritableApproval[] = [];
188
- const approvalAudit = newChainApprovalAudit();
189
- const approvalStep = new Map<string, number>();
190
- const approvedDecisions: Array<{ request: GateRequest; outcome: Awaited<ReturnType<typeof obtainApprovals>> }> =
191
- [];
192
- let declined: { request: GateRequest; outcome: Awaited<ReturnType<typeof obtainApprovals>> } | undefined;
193
-
194
- for (const request of chainPlan.requests) {
195
- const outcome = await obtainApprovals(
144
+ await reconcileDelegationCapacity(session);
145
+ const firstReservation = reserveDelegationCapacity(session, executionIds[0]);
146
+ try {
147
+ const parentExecutionId = session.ownExecutionId ?? null;
148
+ const piModel = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
149
+ const runtimeChoices = new Map<
150
+ import("../src/kernel/chain.ts").ChainStep,
151
+ import("./definition-runtime.ts").ResolvedDefinitionRuntime
152
+ >();
153
+ const chainPlan = await planChain(
196
154
  session,
197
- [request.capability],
198
- request.subject,
199
- request.path,
200
- ctx,
201
- request.task,
202
- signal,
155
+ steps,
156
+ executionIds,
157
+ (model) =>
158
+ preflightModel(model, ctx.modelRegistry, session.modelResolutionCache, session.allowUnresolvedModels),
159
+ (step) => {
160
+ const selected = resolveDefinitionRuntime({
161
+ definition: step.agent,
162
+ explicit: { model: step.model, thinking: step.thinking },
163
+ session: session.definitionRuntimeOverrides,
164
+ settings: session.definitionRuntimeSettings,
165
+ piModel,
166
+ piThinking: session.currentThinking?.(),
167
+ authoredPreferences: step.agent ? session.definitions.get(step.agent)?.runtimePreferences : undefined,
168
+ unavailable: (choice) => runtimePairUnavailable(choice, ctx.modelRegistry, session.allowUnresolvedModels),
169
+ });
170
+ runtimeChoices.set(step, selected);
171
+ return selected.model;
172
+ },
203
173
  );
204
- if (!outcome.approved.includes(request.capability)) {
205
- // **Stop asking.** The chain's outcome is already fixed, and every further dialog banks authority — a
206
- // `session` yes into `sessionApprovals` and an `always` yes onto disk for 30 days — for a chain that will
207
- // not run. The single-delegate path breaks on the first no for the same reason.
208
- declined = { request, outcome };
209
- break;
210
- }
211
- preApproved.push({
212
- capability: request.capability,
213
- subject: request.subject,
214
- scope: outcome.scopes[request.capability] ?? ("once" as const),
215
- // Pinned to THIS subject's body (ADR-0022). Stamped from a single shared subject, the pin was verified
216
- // against one definition's instructions while the capability was spent on another's.
217
- bodySha256: snapshotOf(session, request.subject)?.bodySha256,
218
- });
219
- rememberChainApproval(approvalAudit, request.capability, request.subject, outcome);
220
- approvalStep.set(`${request.capability}@${request.subject}`, request.stepIndex);
221
- approvedDecisions.push({ request, outcome });
222
- }
223
-
224
- if (declined) {
225
- const { request, outcome } = declined;
226
- const message =
227
- `chain refused: ${request.capability} was not approved for ${request.subject}, so no step ran. A chain ` +
228
- `is gated as a unit — running only its approved steps would return a partial result that reads like a complete one.`;
229
- const structured = outcome.refusalCode ? refusal(outcome.refusalCode, message) : undefined;
230
- const decisions = new Map<
231
- number,
232
- { request: GateRequest; outcomes: ApprovalOutcome[]; refusal?: typeof structured }
233
- >();
234
- for (const approved of approvedDecisions) {
235
- const decision = decisions.get(approved.request.stepIndex) ?? {
236
- request: approved.request,
237
- outcomes: [],
238
- };
239
- decision.outcomes.push(approved.outcome);
240
- decisions.set(approved.request.stepIndex, decision);
241
- }
242
- const deniedDecision = decisions.get(request.stepIndex) ?? { request, outcomes: [] };
243
- deniedDecision.request = request;
244
- deniedDecision.outcomes.push(outcome);
245
- deniedDecision.refusal = structured;
246
- decisions.set(request.stepIndex, deniedDecision);
247
-
248
- for (const [stepIndex, decision] of [...decisions].sort(([left], [right]) => left - right)) {
174
+ if (chainPlan.doomed) {
175
+ const message =
176
+ `chain refused at step ${chainPlan.doomed.step}: ${chainPlan.doomed.reason} No step ran, and nobody was ` +
177
+ `asked to approve anything — a step that cannot run must not bank authority for a spawn that will never happen.`;
249
178
  await recordChainRefusal({
250
179
  session,
251
- plan: decision.request.plan,
252
- stepIndex,
253
- executionId: executionIds[stepIndex],
180
+ plan: chainPlan.doomed.plan,
181
+ stepIndex: chainPlan.doomed.step - 1,
182
+ executionId: executionIds[chainPlan.doomed.step - 1],
254
183
  parentExecutionId,
255
- agent: steps[stepIndex]?.agent,
184
+ agent: chainPlan.doomed.agent,
256
185
  reason: message,
257
- refusal: decision.refusal,
258
- approval: mergeGateOutcomes(decision.outcomes),
186
+ refusal: chainPlan.doomed.refusal,
259
187
  });
188
+ if (chainPlan.doomed.refusal) throw new GovernanceRefusal({ ...chainPlan.doomed.refusal, message });
189
+ throw new Error(message);
260
190
  }
261
- if (structured) throw new GovernanceRefusal(structured);
262
- throw new Error(message);
263
- }
264
191
 
265
- const children: ChildProgress[] = steps.map((step) => ({
266
- label: step.agent ?? "delegate",
267
- state: "starting",
268
- startedAt: Date.now(),
269
- tail: emptyTail,
270
- }));
271
- const paint = throttle(() => {
272
- try {
273
- (onUpdate as ((partial: { content: Array<{ type: "text"; text: string }> }) => void) | undefined)?.({
274
- content: [{ type: "text", text: renderProgress(children, session.executor.kind, Date.now()) }],
192
+ // One dialog per `capability@subject`, each naming the step that needs it, all before the first step runs.
193
+ const preApproved: InheritableApproval[] = [];
194
+ const approvalAudit = newChainApprovalAudit();
195
+ const approvalStep = new Map<string, number>();
196
+ const approvedDecisions: Array<{ request: GateRequest; outcome: Awaited<ReturnType<typeof obtainApprovals>> }> =
197
+ [];
198
+ let declined: { request: GateRequest; outcome: Awaited<ReturnType<typeof obtainApprovals>> } | undefined;
199
+
200
+ for (const request of chainPlan.requests) {
201
+ const outcome = await obtainApprovals(
202
+ session,
203
+ [request.capability],
204
+ request.subject,
205
+ request.path,
206
+ ctx,
207
+ request.task,
208
+ signal,
209
+ );
210
+ if (!outcome.approved.includes(request.capability)) {
211
+ // **Stop asking.** The chain's outcome is already fixed, and every further dialog banks authority — a
212
+ // `session` yes into `sessionApprovals` and an `always` yes onto disk for 30 days — for a chain that will
213
+ // not run. The single-delegate path breaks on the first no for the same reason.
214
+ declined = { request, outcome };
215
+ break;
216
+ }
217
+ preApproved.push({
218
+ capability: request.capability,
219
+ subject: request.subject,
220
+ scope: outcome.scopes[request.capability] ?? ("once" as const),
221
+ // Pinned to THIS subject's body (ADR-0022). Stamped from a single shared subject, the pin was verified
222
+ // against one definition's instructions while the capability was spent on another's.
223
+ bodySha256: snapshotOf(session, request.subject)?.bodySha256,
275
224
  });
276
- } catch {
277
- // Display only: a broken partial-result sink must not become a child cancellation mechanism.
225
+ rememberChainApproval(approvalAudit, request.capability, request.subject, outcome);
226
+ approvalStep.set(`${request.capability}@${request.subject}`, request.stepIndex);
227
+ approvedDecisions.push({ request, outcome });
278
228
  }
279
- }, PAINT_INTERVAL_MS);
280
229
 
281
- const outcomes: Array<{
282
- ok: boolean;
283
- text: string;
284
- reason?: string;
285
- refusal?: StructuredRefusal;
286
- step: number;
287
- agent?: string;
288
- }> = [];
289
- let previous: string | undefined;
290
- let aborted = false;
291
- /**
292
- * Approvals still available to later steps.
293
- *
294
- * **`once` is consumed by the first step that spends it, and not honouring that was a confused deputy.**
295
- * Measured: three steps of one definition, one dialog naming step 1's task — *"survey the north field"* — and
296
- * three children spawned, the last of which had been told *"…and burn the evidence"*. Steps 2 and 3 were never
297
- * described to anyone. Two sequential plain `delegate` calls raise two dialogs, because a `once` answer never
298
- * enters `sessionApprovals`; the chain was the outlier. R-29 exists for this exact shape one level down.
299
- *
300
- * A later step needing the same capability now reaches its own gate and prompts with its OWN task, which is
301
- * what `once` means.
302
- */
303
- let available = [...preApproved];
230
+ if (declined) {
231
+ const { request, outcome } = declined;
232
+ const message =
233
+ `chain refused: ${request.capability} was not approved for ${request.subject}, so no step ran. A chain ` +
234
+ `is gated as a unit — running only its approved steps would return a partial result that reads like a complete one.`;
235
+ const structured = outcome.refusalCode ? refusal(outcome.refusalCode, message) : undefined;
236
+ const decisions = new Map<
237
+ number,
238
+ { request: GateRequest; outcomes: ApprovalOutcome[]; refusal?: typeof structured }
239
+ >();
240
+ for (const approved of approvedDecisions) {
241
+ const decision = decisions.get(approved.request.stepIndex) ?? {
242
+ request: approved.request,
243
+ outcomes: [],
244
+ };
245
+ decision.outcomes.push(approved.outcome);
246
+ decisions.set(approved.request.stepIndex, decision);
247
+ }
248
+ const deniedDecision = decisions.get(request.stepIndex) ?? { request, outcomes: [] };
249
+ deniedDecision.request = request;
250
+ deniedDecision.outcomes.push(outcome);
251
+ deniedDecision.refusal = structured;
252
+ decisions.set(request.stepIndex, deniedDecision);
304
253
 
305
- if (args.episodeCostCeiling !== undefined) session.episodeCostGate.setCeiling(args.episodeCostCeiling);
306
- for (const [index, step] of steps.entries()) {
307
- const childId = childSpawnId(session.ownSpawnId, index);
308
- const availableForStep = available.filter((approval) => {
309
- const key = `${approval.capability}@${approval.subject}`;
310
- return chainPlan.uses.get(index)?.has(key) && (approval.scope !== "once" || approvalStep.get(key) === index);
311
- });
312
- const outcome = await runOneDelegation(
313
- session,
314
- chainStepSpec(step, previous),
315
- {
316
- parentId: session.ownSpawnId,
317
- childId,
318
- executionId: executionIds[index],
319
- parentExecutionId,
320
- },
321
- split.perChild,
322
- ctx,
323
- signal,
324
- {
325
- preApproved: availableForStep,
326
- toolCallId: _toolCallId,
327
- // Only approvals actually offered to this step are attributed or consumed here.
328
- approvalFacts: chainApprovalFacts(approvalAudit, availableForStep, step.agent ?? DELEGATE_SUBJECT),
329
- onProgress: (update) => {
330
- const child = children[index];
331
- if (!child) return;
332
- if (update.paneId) child.paneId = update.paneId;
333
- if (update.agentName) child.agentName = update.agentName;
334
- if (update.chunk) child.tail = appendTail(child.tail, update.chunk);
335
- if (update.snapshot) child.tail = replaceTail(update.snapshot);
336
- if (update.state) child.state = update.state;
337
- else if (child.state === "starting") child.state = "running";
338
- paint.call();
254
+ for (const [stepIndex, decision] of [...decisions].sort(([left], [right]) => left - right)) {
255
+ await recordChainRefusal({
256
+ session,
257
+ plan: decision.request.plan,
258
+ stepIndex,
259
+ executionId: executionIds[stepIndex],
260
+ parentExecutionId,
261
+ agent: steps[stepIndex]?.agent,
262
+ reason: message,
263
+ refusal: decision.refusal,
264
+ approval: mergeGateOutcomes(decision.outcomes),
265
+ });
266
+ }
267
+ if (structured) throw new GovernanceRefusal(structured);
268
+ throw new Error(message);
269
+ }
270
+
271
+ const children: ChildProgress[] = steps.map((step) => ({
272
+ label: step.agent ?? "delegate",
273
+ state: "starting",
274
+ startedAt: Date.now(),
275
+ tail: emptyTail,
276
+ }));
277
+ const paint = throttle(() => {
278
+ try {
279
+ (onUpdate as ((partial: { content: Array<{ type: "text"; text: string }> }) => void) | undefined)?.({
280
+ content: [{ type: "text", text: renderProgress(children, session.executor.kind, Date.now()) }],
281
+ });
282
+ } catch {
283
+ // Display only: a broken partial-result sink must not become a child cancellation mechanism.
284
+ }
285
+ }, PAINT_INTERVAL_MS);
286
+
287
+ const outcomes: Array<import("./execute-child.ts").DelegationOutcome & { step: number; agent?: string }> = [];
288
+ let previous: string | undefined;
289
+ let aborted = false;
290
+ let blockedReason: "full-final-too-large-for-handoff" | undefined;
291
+ /**
292
+ * Approvals still available to later steps.
293
+ *
294
+ * **`once` is consumed by the first step that spends it, and not honouring that was a confused deputy.**
295
+ * Measured: three steps of one definition, one dialog naming step 1's task — *"survey the north field"* — and
296
+ * three children spawned, the last of which had been told *"…and burn the evidence"*. Steps 2 and 3 were never
297
+ * described to anyone. Two sequential plain `delegate` calls raise two dialogs, because a `once` answer never
298
+ * enters `sessionApprovals`; the chain was the outlier. R-29 exists for this exact shape one level down.
299
+ *
300
+ * A later step needing the same capability now reaches its own gate and prompts with its OWN task, which is
301
+ * what `once` means.
302
+ */
303
+ let available = [...preApproved];
304
+
305
+ for (const [index, step] of steps.entries()) {
306
+ const childId = childSpawnId(session.ownSpawnId, index);
307
+ const availableForStep = available.filter((approval) => {
308
+ const key = `${approval.capability}@${approval.subject}`;
309
+ return (
310
+ chainPlan.uses.get(index)?.has(key) && (approval.scope !== "once" || approvalStep.get(key) === index)
311
+ );
312
+ });
313
+ const outcome = await runOneDelegation(
314
+ session,
315
+ chainStepSpec(step, previous),
316
+ {
317
+ parentId: session.ownSpawnId,
318
+ childId,
319
+ executionId: executionIds[index],
320
+ parentExecutionId,
339
321
  },
340
- // Provenance: which child's output composed THIS step's task (ADR-0033). Absent for step 1.
341
- taskFrom: index === 0 ? undefined : childSpawnId(session.ownSpawnId, index - 1),
342
- taskFromExecutionId: index === 0 ? undefined : executionIds[index - 1],
343
- },
344
- );
322
+ index === 0 ? firstReservation.childAllowance : undefined,
323
+ ctx,
324
+ signal,
325
+ {
326
+ ...(index === 0 ? { capacityReservation: firstReservation } : {}),
327
+ resolvedRuntime: runtimeChoices.get(step),
328
+ preApproved: availableForStep,
329
+ toolCallId: _toolCallId,
330
+ // Only approvals actually offered to this step are attributed or consumed here.
331
+ approvalFacts: chainApprovalFacts(approvalAudit, availableForStep, step.agent ?? DELEGATE_SUBJECT),
332
+ onProgress: (update) => {
333
+ const child = children[index];
334
+ if (!child) return;
335
+ if (update.paneId) child.paneId = update.paneId;
336
+ if (update.agentName) child.agentName = update.agentName;
337
+ if (update.chunk) child.tail = appendTail(child.tail, update.chunk);
338
+ if (update.snapshot) child.tail = replaceTail(update.snapshot);
339
+ if (update.state) child.state = update.state;
340
+ else if (child.state === "starting") child.state = "running";
341
+ paint.call();
342
+ },
343
+ // Provenance: which child's output composed THIS step's task (ADR-0033). Absent for step 1.
344
+ taskFrom: index === 0 ? undefined : childSpawnId(session.ownSpawnId, index - 1),
345
+ taskFromExecutionId: index === 0 ? undefined : executionIds[index - 1],
346
+ },
347
+ );
345
348
 
346
- children[index].state = outcome.ok ? "completed" : "failed";
347
- children[index].settledAt = Date.now();
348
- outcomes.push({
349
- ok: outcome.ok,
350
- text: outcome.text,
351
- reason: outcome.reason,
352
- refusal: outcome.refusal,
353
- step: index + 1,
354
- agent: step.agent,
355
- });
356
- if (isCriticalAssuranceBlock(outcome)) throw new Error(outcome.text);
349
+ children[index].state = outcome.ok ? "completed" : "failed";
350
+ children[index].settledAt = Date.now();
351
+ outcomes.push({
352
+ ...outcome,
353
+ step: index + 1,
354
+ agent: step.agent,
355
+ });
356
+ if (isCriticalAssuranceBlock(outcome)) throw new Error(outcome.text);
357
357
 
358
- // Spend any `once` this step was handed, before the next step sees the list.
359
- available = available.filter(
360
- (approval) =>
361
- approval.scope !== "once" || approvalStep.get(`${approval.capability}@${approval.subject}`) !== index,
362
- );
358
+ // Spend any `once` this step was handed, before the next step sees the list.
359
+ available = available.filter(
360
+ (approval) =>
361
+ approval.scope !== "once" || approvalStep.get(`${approval.capability}@${approval.subject}`) !== index,
362
+ );
363
363
 
364
- if (!outcome.ok) {
365
- // **Abort, and mark the rest.** Continuing would make the next step's task an error message, which is
366
- // never what an orchestrator wants. Everything completed is still returned — R-03's rule.
367
- aborted = true;
368
- for (let rest = index + 1; rest < children.length; rest += 1) children[rest].state = "failed";
369
- break;
364
+ if (!outcome.ok || outcome.control === "failed" || outcome.final?.state === "unavailable") {
365
+ // **Abort, and mark the rest.** Continuing would make the next step's task an error message, which is
366
+ // never what an orchestrator wants. Everything completed is still returned — R-03's rule.
367
+ aborted = true;
368
+ for (let rest = index + 1; rest < children.length; rest += 1) children[rest].state = "failed";
369
+ break;
370
+ }
371
+ if (index + 1 < steps.length && Buffer.byteLength(outcome.text, "utf8") > HANDOFF_MAX_BYTES) {
372
+ aborted = true;
373
+ blockedReason = "full-final-too-large-for-handoff";
374
+ for (let rest = index + 1; rest < children.length; rest += 1) children[rest].state = "failed";
375
+ break;
376
+ }
377
+ previous = outcome.text;
370
378
  }
371
- previous = outcome.text;
372
- }
373
- paint.flush();
379
+ paint.flush();
374
380
 
375
- const report = outcomes
376
- .map((o) => {
377
- const label = `### step ${o.step}${o.agent ? ` (${o.agent})` : ""}`;
378
- return o.ok
379
- ? `${label} — completed\n\n${o.text || "(no output)"}`
380
- : `${label} — FAILED: ${o.reason}${o.text ? `\n\n${o.text}` : ""}`;
381
- })
382
- .join("\n\n---\n\n");
381
+ const report = outcomes
382
+ .map((o) => {
383
+ const label = `### step ${o.step}${o.agent ? ` (${o.agent})` : ""}`;
384
+ return o.ok && o.control !== "failed"
385
+ ? `${label} — completed\n\n${o.text || "(no output)"}`
386
+ : `${label} — FAILED: ${o.reason}${o.text ? `\n\n${o.text}` : ""}`;
387
+ })
388
+ .join("\n\n---\n\n");
383
389
 
384
- const skipped = steps.length - outcomes.length;
385
- const tail = aborted
386
- ? `\n\n---\n\n**The chain stopped at step ${outcomes.length}.** ${skipped} later step(s) did not run, ` +
387
- `because each one's task is built from the previous step's output and there was none to pass on.`
388
- : "";
390
+ const skipped = steps.length - outcomes.length;
391
+ const tail = aborted
392
+ ? `\n\n---\n\n**The chain stopped at step ${outcomes.length}.** ${skipped} later step(s) did not run, ` +
393
+ (blockedReason
394
+ ? `because the complete final exceeds the ${HANDOFF_MAX_BYTES}-byte handoff limit; it is preserved above and no partial handoff was used.`
395
+ : `because the previous step did not supply a qualified successful final for the next task.`)
396
+ : "";
389
397
 
390
- if (outcomes.length === 1 && !outcomes[0].ok) {
391
- // Nothing completed at all, so there is no partial result to hand back — and a tool that returns text when
392
- // nothing ran is how a wrong summary gets written.
393
- const message = `chain failed at its first step.\n\n${report}`;
394
- if (outcomes[0].refusal) throw new GovernanceRefusal({ ...outcomes[0].refusal, message });
395
- throw new Error(message);
398
+ if (
399
+ outcomes.length === 1 &&
400
+ !outcomes[0].ok &&
401
+ !outcomes[0].work &&
402
+ !outcomes[0].final &&
403
+ !outcomes[0].cleanup
404
+ ) {
405
+ const message = `chain failed at its first step.\n\n${report}`;
406
+ if (outcomes[0].refusal) throw new GovernanceRefusal({ ...outcomes[0].refusal, message });
407
+ throw new Error(message);
408
+ }
409
+ return {
410
+ isError: aborted || outcomes.some((o) => o.control === "failed"),
411
+ content: [{ type: "text", text: `${report}${tail}` }],
412
+ details: {
413
+ outcomes,
414
+ steps: steps.length,
415
+ completed: outcomes.filter((o) => o.ok).length,
416
+ aborted,
417
+ budgetPerStep: firstReservation.childAllowance,
418
+ ...(blockedReason ? { blockedReason } : {}),
419
+ refusals: outcomes.map((outcome) => outcome.refusal ?? null),
420
+ },
421
+ };
422
+ } finally {
423
+ firstReservation.finalize({ state: "not-started", reason: "chain ended before its first execution" });
396
424
  }
397
-
398
- return {
399
- content: [{ type: "text", text: `${report}${tail}` }],
400
- details: {
401
- steps: steps.length,
402
- completed: outcomes.filter((o) => o.ok).length,
403
- aborted,
404
- budgetPerStep: split.perChild,
405
- refusals: outcomes.map((outcome) => outcome.refusal ?? null),
406
- },
407
- };
408
425
  },
409
426
  });
410
427
  }
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import type { GrantsSession } from "./session.ts";
3
3
 
4
- const DELEGATION_TOOL_NAMES = ["delegate", "delegate_all", "delegate_chain"];
4
+ const DELEGATION_TOOL_NAMES = ["delegate", "delegate_all", "delegate_chain", "delegate_describe"];
5
5
 
6
6
  function setDelegationActive(pi: ExtensionAPI, enabled: boolean): void {
7
7
  const surface = pi as ExtensionAPI & { getActiveTools?: () => string[]; setActiveTools?: (names: string[]) => void };