pi-daddy 0.16.0 → 0.17.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.
@@ -0,0 +1,357 @@
1
+ /**
2
+ * `delegate_chain` — a governed sequential pipeline, planned and gated as one unit (ADR-0033).
3
+ *
4
+ * Its own file rather than a third tool in `delegation.ts`, which is near the 400-line ceiling. The seam is real
5
+ * anyway: `delegate` and `delegate_all` differ only in cardinality, while a chain differs in **composition** — each
6
+ * step's task is built from the previous step's output, and the whole thing is planned before any of it runs.
7
+ *
8
+ * **Nothing here re-implements a governance rule.** Every step goes through `runOneDelegation`, so the grant, the
9
+ * ceiling, `agent:<name>` authorisation, the depth bound, the ledger and `--tools` enforcement are exactly what a
10
+ * single `delegate` gets. What a chain adds is the handoff (`src/chain.ts`), one gate instead of N, a budget unit
11
+ * per step, and abort-on-failure.
12
+ *
13
+ * **Why the upfront gate is exact rather than an approximation**, which is the least obvious thing here: an approval
14
+ * is keyed `capability@subject` and **the task is never part of it** (ADR-0021 — the task is not stored anywhere). So
15
+ * the union of a chain's gated capabilities is fully determined before any step's task exists, even though steps
16
+ * 2..N have no task until their predecessor runs. That is what makes asking once honest instead of optimistic.
17
+ */
18
+
19
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
20
+ import { Type } from "typebox";
21
+ import type { InheritableApproval } from "../src/approval.ts";
22
+ import { DELEGATE_SUBJECT, shouldSeekApproval } from "../src/approval.ts";
23
+ import { chainStepSpec, PLACEHOLDER } from "../src/chain.ts";
24
+ import { planDelegation } from "../src/delegate.ts";
25
+ import { MAX_CHAIN_STEPS, childSpawnId, splitBudget } from "../src/fanout.ts";
26
+ import { PAINT_INTERVAL_MS, appendTail, emptyTail, renderProgress, replaceTail, throttle, type ChildProgress } from "../src/progress.ts";
27
+ import type { Capability } from "../src/resolve.ts";
28
+ import { appendRecord, buildRecord } from "../src/ledger.ts";
29
+ import { obtainApprovals, snapshotOf } from "./approvals.ts";
30
+ import { runOneDelegation } from "./run-delegation.ts";
31
+ import type { GrantsSession } from "./session.ts";
32
+
33
+ /** 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
+ }
40
+
41
+ /**
42
+ * One dialog's worth of gate: a single capability, for a single subject, described by the step that needs it.
43
+ *
44
+ * **One request per `capability@subject`, not per subject.** Grouping by subject alone merged every `tools:`-only
45
+ * step under the constant `<delegate>` and froze the *first* step's task into the dialog — so an operator approved
46
+ * `tool:write` while reading a task that only listed files. The dialog is the one place a human learns what they are
47
+ * authorising, so it names the step that actually needs the capability.
48
+ */
49
+ interface GateRequest {
50
+ subject: string;
51
+ /** `"definition"` offers `always`; `"delegate"` must not (ADR-0019). Derived from the subject, never assumed. */
52
+ path: "definition" | "delegate";
53
+ capability: Capability;
54
+ /** The task of the step that needs this capability. */
55
+ task: string;
56
+ }
57
+
58
+ /** Either every gate the chain will hit, or the first step that can never run and why. */
59
+ interface ChainPlan {
60
+ requests: GateRequest[];
61
+ /** 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 };
63
+ }
64
+
65
+ /**
66
+ * Plan every step and collect the gates — or find a step that can never run.
67
+ *
68
+ * **`plan.ok` is honoured here, and ignoring it was a privilege path.** The first version read only
69
+ * `gatedBlocked`, so a step refused for an unheld `agent:` id, an unknown definition, an empty task or a universal
70
+ * capability still raised its gate and the answer was still banked. Measured: `delegate({tools:["bash","agent:x"]})`
71
+ * asks nobody and refuses, while the same step inside a chain raised a dialog, took *Allow for this session*, refused
72
+ * anyway — and left `tool:bash` pre-approved for every later delegation in the session. On the `agent:` path it
73
+ * banked a **30-day** entry, including for a step whose task was whitespace, whose dialog therefore read `task:`
74
+ * followed by nothing.
75
+ *
76
+ * `shouldSeekApproval` is the rule every other gate in this package already applies, and its own docstring names
77
+ * this hazard: *"both banked against a spawn that never happened, and both reachable by a model that appends one
78
+ * unheld capability to an otherwise ordinary request."* The chain reimplemented the decision beside it instead of
79
+ * routing through it. So a doomed step now refuses the whole chain **before any dialog**, which is the same
80
+ * principle the executor check follows.
81
+ *
82
+ * Planned with no UI: `planWithApprovals` would open a dialog per step during the very phase whose purpose is to ask
83
+ * upfront. A step's task is unknown for everything after the first, which is fine — an approval key never contains
84
+ * the task (ADR-0021), so the set of gates is fully determined without it.
85
+ */
86
+ async function planChain(session: GrantsSession, steps: StepSpec[], perStepBudget: number): Promise<ChainPlan> {
87
+ const requests: GateRequest[] = [];
88
+ const seen = new Set<string>();
89
+ const context = await session.delegationContext();
90
+
91
+ for (const [index, step] of steps.entries()) {
92
+ // The real task, not a placeholder: the planner's own empty-task guard must run here rather than at spawn time,
93
+ // or a step with a whitespace task raises a dialog and is refused afterwards. The handoff is absent at planning
94
+ // time and cannot change which capabilities are gated.
95
+ const plan = planDelegation(
96
+ { task: step.task, agent: step.agent, tools: step.tools, model: step.model },
97
+ { ...context, spawnId: session.ownSpawnId, childSpawnId: childSpawnId(session.ownSpawnId, index) },
98
+ );
99
+
100
+ // A gate is the ONLY refusal an approval can lift. Anything else is doomed, and asking about it banks authority
101
+ // for a spawn that will never happen.
102
+ if (!plan.ok && !shouldSeekApproval(plan.result)) {
103
+ return { requests: [], doomed: { step: index + 1, reason: plan.reason ?? "this step cannot run" } };
104
+ }
105
+
106
+ const subject = step.agent ?? DELEGATE_SUBJECT;
107
+ for (const capability of plan.result.gatedBlocked) {
108
+ const key = `${capability}@${subject}`;
109
+ if (seen.has(key)) continue;
110
+ seen.add(key);
111
+ requests.push({ subject, path: step.agent ? "definition" : "delegate", capability, task: step.task });
112
+ }
113
+ }
114
+ return { requests };
115
+ }
116
+
117
+ export function registerChainTool(pi: ExtensionAPI, session: GrantsSession): void {
118
+ // No `mayDelegate` guard here: `registerDelegationTools` already returns before calling this, so a second check
119
+ // was dead code that made the leaf half of a wiring test pass for the wrong reason. The guard lives in one place.
120
+
121
+ const stepShape = Type.Object({
122
+ task: Type.String({
123
+ description:
124
+ `What this step should do. Write ${PLACEHOLDER} where the previous step's output belongs; if you omit it, ` +
125
+ `that output is appended instead. The first step never receives one.`,
126
+ }),
127
+ agent: Type.Optional(Type.String({ description: "Definition to spawn for this step." })),
128
+ tools: Type.Optional(Type.Array(Type.String(), { description: "Capabilities, when no 'agent' fits." })),
129
+ model: Type.Optional(Type.String({ description: "Model as provider/id. Defaults to this session's." })),
130
+ });
131
+
132
+ const params = Type.Object({
133
+ steps: Type.Array(stepShape, {
134
+ minItems: 1,
135
+ maxItems: MAX_CHAIN_STEPS,
136
+ description: "The steps to run IN ORDER. Each one sees the previous one's output.",
137
+ }),
138
+ });
139
+
140
+ pi.registerTool({
141
+ name: "delegate_chain",
142
+ label: "Delegate a chain of sub-agents (governed, sequential)",
143
+ description:
144
+ "Run sub-agents ONE AFTER ANOTHER, each receiving the previous one's output. Use this when a step needs " +
145
+ "what an earlier step produced — a decision feeding a design feeding an implementation. Use `delegate_all` " +
146
+ "instead when the tasks are independent and can run at the same time, and `delegate` for a single child. " +
147
+ `At most ${MAX_CHAIN_STEPS} steps, each spending one unit of the session's fan-out budget. Every step is ` +
148
+ "governed exactly as a single `delegate` is: it holds only what you grant it, and you cannot grant what you " +
149
+ "do not hold. A failed step ABORTS the rest, and you still receive everything that completed.",
150
+ parameters: params,
151
+ async execute(_toolCallId, args, signal, onUpdate, ctx) {
152
+ const steps = args.steps ?? [];
153
+
154
+ // **Every cheap refusal happens before any human is asked.** Yesterday's lesson on the `delegate` path: with
155
+ // `PI_GRANTS_HERDR=1` and herdr down, the gate ran first, an operator approved `bash`, a 30-day entry was
156
+ // written, and the delegation was then refused anyway. `runOneDelegation` checks the executor before its own
157
+ // gate; a chain hoists its gate above `runOneDelegation`, so the check has to be repeated here or that
158
+ // ordering is simply bypassed.
159
+ if (session.executor.refusal) throw new Error(`chain refused: ${session.executor.refusal}`);
160
+
161
+ // Cardinality next, still before the gate. `splitBudget` is reused rather than re-derived, so a chain and a
162
+ // fan-out cannot disagree about what the budget means.
163
+ const split = splitBudget(session.fanoutBudget, steps.length);
164
+ if (!split.ok) throw new Error(`chain refused: ${split.reason}`);
165
+
166
+ // Plan every step first. A step that can never run refuses the chain HERE, before anyone is asked — see
167
+ // `planChain`.
168
+ const chainPlan = await planChain(session, steps, split.perChild);
169
+ 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
+ );
175
+ }
176
+
177
+ // One dialog per `capability@subject`, each naming the step that needs it, all before the first step runs.
178
+ const preApproved: InheritableApproval[] = [];
179
+ let declined: { capability: Capability; subject: string } | undefined;
180
+ let humanDenied = false;
181
+
182
+ for (const request of chainPlan.requests) {
183
+ const outcome = await obtainApprovals(session, [request.capability], request.subject, request.path, ctx, request.task, signal);
184
+ humanDenied = humanDenied || outcome.humanDenied;
185
+ if (!outcome.approved.includes(request.capability)) {
186
+ // **Stop asking.** The chain's outcome is already fixed, and every further dialog banks authority — a
187
+ // `session` yes into `sessionApprovals` and an `always` yes onto disk for 30 days — for a chain that will
188
+ // not run. The single-delegate path breaks on the first no for the same reason.
189
+ declined = { capability: request.capability, subject: request.subject };
190
+ break;
191
+ }
192
+ preApproved.push({
193
+ capability: request.capability,
194
+ subject: request.subject,
195
+ scope: outcome.scopes[request.capability] ?? ("once" as const),
196
+ // Pinned to THIS subject's body (ADR-0022). Stamped from a single shared subject, the pin was verified
197
+ // against one definition's instructions while the capability was spent on another's.
198
+ bodySha256: snapshotOf(session, request.subject)?.bodySha256,
199
+ });
200
+ }
201
+
202
+ 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
+ );
234
+ });
235
+ }
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
+ );
241
+ }
242
+
243
+ const children: ChildProgress[] = steps.map((step) => ({
244
+ label: step.agent ?? "delegate",
245
+ state: "starting",
246
+ startedAt: Date.now(),
247
+ tail: emptyTail,
248
+ }));
249
+ const paint = throttle(() => {
250
+ (onUpdate as ((partial: { content: Array<{ type: "text"; text: string }> }) => void) | undefined)?.({
251
+ content: [{ type: "text", text: renderProgress(children, session.executor.kind, Date.now()) }],
252
+ });
253
+ }, PAINT_INTERVAL_MS);
254
+
255
+ const outcomes: Array<{ ok: boolean; text: string; reason?: string; step: number; agent?: string }> = [];
256
+ let previous: string | undefined;
257
+ let aborted = false;
258
+ /**
259
+ * Approvals still available to later steps.
260
+ *
261
+ * **`once` is consumed by the first step that spends it, and not honouring that was a confused deputy.**
262
+ * Measured: three steps of one definition, one dialog naming step 1's task — *"survey the north field"* — and
263
+ * three children spawned, the last of which had been told *"…and burn the evidence"*. Steps 2 and 3 were never
264
+ * described to anyone. Two sequential plain `delegate` calls raise two dialogs, because a `once` answer never
265
+ * enters `sessionApprovals`; the chain was the outlier. R-29 exists for this exact shape one level down.
266
+ *
267
+ * A later step needing the same capability now reaches its own gate and prompts with its OWN task, which is
268
+ * what `once` means.
269
+ */
270
+ let available = [...preApproved];
271
+
272
+ for (const [index, step] of steps.entries()) {
273
+ const childId = childSpawnId(session.ownSpawnId, index);
274
+ const outcome = await runOneDelegation(
275
+ session,
276
+ chainStepSpec(step, previous),
277
+ { parentId: session.ownSpawnId, childId },
278
+ split.perChild,
279
+ ctx,
280
+ signal,
281
+ {
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
+ },
297
+ onProgress: (update) => {
298
+ const child = children[index];
299
+ if (!child) return;
300
+ if (update.paneId) child.paneId = update.paneId;
301
+ if (update.agentName) child.agentName = update.agentName;
302
+ if (update.chunk) child.tail = appendTail(child.tail, update.chunk);
303
+ if (update.snapshot) child.tail = replaceTail(update.snapshot);
304
+ if (update.state) child.state = update.state;
305
+ else if (child.state === "starting") child.state = "running";
306
+ paint.call();
307
+ },
308
+ // Provenance: which child's output composed THIS step's task (ADR-0033). Absent for step 1.
309
+ taskFrom: index === 0 ? undefined : childSpawnId(session.ownSpawnId, index - 1),
310
+ },
311
+ );
312
+
313
+ children[index].state = outcome.ok ? "completed" : "failed";
314
+ children[index].settledAt = Date.now();
315
+ outcomes.push({ ok: outcome.ok, text: outcome.text, reason: outcome.reason, step: index + 1, agent: step.agent });
316
+
317
+ // 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));
320
+
321
+ if (!outcome.ok) {
322
+ // **Abort, and mark the rest.** Continuing would make the next step's task an error message, which is
323
+ // never what an orchestrator wants. Everything completed is still returned — R-03's rule.
324
+ aborted = true;
325
+ for (let rest = index + 1; rest < children.length; rest += 1) children[rest].state = "failed";
326
+ break;
327
+ }
328
+ previous = outcome.text;
329
+ }
330
+ paint.flush();
331
+
332
+ const report = outcomes
333
+ .map((o) => {
334
+ const label = `### step ${o.step}${o.agent ? ` (${o.agent})` : ""}`;
335
+ return o.ok ? `${label} — completed\n\n${o.text || "(no output)"}` : `${label} — FAILED: ${o.reason}${o.text ? `\n\n${o.text}` : ""}`;
336
+ })
337
+ .join("\n\n---\n\n");
338
+
339
+ const skipped = steps.length - outcomes.length;
340
+ const tail = aborted
341
+ ? `\n\n---\n\n**The chain stopped at step ${outcomes.length}.** ${skipped} later step(s) did not run, ` +
342
+ `because each one's task is built from the previous step's output and there was none to pass on.`
343
+ : "";
344
+
345
+ if (outcomes.length === 1 && !outcomes[0].ok) {
346
+ // Nothing completed at all, so there is no partial result to hand back — and a tool that returns text when
347
+ // nothing ran is how a wrong summary gets written.
348
+ throw new Error(`chain failed at its first step.\n\n${report}`);
349
+ }
350
+
351
+ return {
352
+ content: [{ type: "text", text: `${report}${tail}` }],
353
+ details: { steps: steps.length, completed: outcomes.filter((o) => o.ok).length, aborted, budgetPerStep: split.perChild },
354
+ };
355
+ },
356
+ });
357
+ }
@@ -22,6 +22,7 @@ import {
22
22
  throttle,
23
23
  type ChildProgress,
24
24
  } from "../src/progress.ts";
25
+ import { registerChainTool } from "./delegate-chain.ts";
25
26
  import { runOneDelegation } from "./run-delegation.ts";
26
27
  import { type GrantsSession } from "./session.ts";
27
28
 
@@ -208,7 +209,7 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
208
209
  session.fanoutBudget,
209
210
  ctx,
210
211
  signal,
211
- progress.sink(0),
212
+ { onProgress: progress.sink(0) },
212
213
  );
213
214
  progress.settle([outcome]);
214
215
 
@@ -278,7 +279,7 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
278
279
  split.perChild,
279
280
  ctx,
280
281
  signal,
281
- progress.sink(index),
282
+ { onProgress: progress.sink(index) },
282
283
  ),
283
284
  ),
284
285
  );
@@ -315,6 +316,11 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
315
316
  },
316
317
  });
317
318
 
319
+ // ADR-0033. Registered here so all three tools appear together and share the `mayDelegate` guard, but its logic
320
+ // lives in its own file: `delegate` and `delegate_all` differ only in cardinality, while a chain differs in
321
+ // composition, and this file is near the 400-line ceiling.
322
+ registerChainTool(pi, session);
323
+
318
324
  return {
319
325
  // Written through the CONSTRUCTED schema (`properties.agent`) rather than the object handed to
320
326
  // `Type.Optional`, because `Optional` shallow-copies — mutating the input would update a discarded
@@ -22,6 +22,7 @@ import type { Capability } from "../src/resolve.ts";
22
22
  import { ENV_CHILD_TIMEOUT, runChild, timeoutFromEnv } from "../src/run-child.ts";
23
23
  import { runHerdrPane } from "../src/run-herdr.ts";
24
24
  import { obtainApprovals, republishable, snapshotOf, type ApprovalOutcome, type ApprovalUIContext } from "./approvals.ts";
25
+ import type { InheritableApproval } from "../src/approval.ts";
25
26
  import { resolveWorkspace } from "../src/herdr-cli.ts";
26
27
  import { ENV_HERDR_KEEP_PANE, type GrantsSession } from "./session.ts";
27
28
 
@@ -72,12 +73,23 @@ export async function planWithApprovals(
72
73
  extra: Record<string, unknown>,
73
74
  ctx: ApprovalUIContext | null,
74
75
  signal?: AbortSignal,
76
+ /**
77
+ * Approvals a caller has ALREADY obtained, so this plan does not ask again — ADR-0033's upfront gate.
78
+ *
79
+ * `delegate_chain` collects the union of its steps' gated capabilities and asks once, then hands the answer to
80
+ * every step. Without this each step would re-open the dialog *after* the operator had already answered for the
81
+ * whole chain, which is R-25's fatigue shape with nothing bought.
82
+ *
83
+ * It cannot widen anything: `planDelegation` intersects `approved` with the grant on every path, so a
84
+ * pre-approval for something the session does not hold is still refused. What it changes is who is asked.
85
+ */
86
+ preApproved?: InheritableApproval[],
75
87
  ): Promise<GatedPlan> {
76
88
  // Spelled ONCE. It is asked for twice — when the human is prompted, and when the answer is fed back into
77
89
  // the re-plan — and two spellings of one argument is the defect R-28 was.
78
90
  const approvalSubject = request.agent ?? DELEGATE_SUBJECT;
79
91
 
80
- let plan = planDelegation(request, { ...(await session.delegationContext()), ...extra });
92
+ let plan = planDelegation(request, { ...(await session.delegationContext(preApproved)), ...extra });
81
93
  if (plan.ok || !shouldSeekApproval(plan.result)) return { plan };
82
94
 
83
95
  let approval: ApprovalOutcome | undefined;
@@ -163,17 +175,50 @@ export async function runOneDelegation(
163
175
  * One sink for both executors: the herdr path additionally reports a pane id, and the process path never
164
176
  * has one. Every field is display-only — the child's answer is still the returned outcome.
165
177
  */
166
- onProgress?: (update: {
167
- /** Appended (process executor: a genuine byte stream). */
168
- chunk?: string;
169
- /** Replaces (herdr executor: a snapshot of a bounded terminal). The two are NOT interchangeable. */
170
- snapshot?: string[];
171
- paneId?: string;
172
- /** The name herdr actually knows this child by — minted in `runHerdrPane`, so it cannot be derived. */
173
- agentName?: string;
174
- state?: "running" | "completed" | "failed";
175
- }) => void,
178
+ /**
179
+ * The optional tail, as ONE object rather than positional arguments.
180
+ *
181
+ * **R-28's lesson, applied before it cost anything.** Adding `preApproved` as a seventh positional parameter put
182
+ * it in front of `onProgress`, and two existing call sites silently passed a progress sink where approvals were
183
+ * expected. TypeScript caught it only because the types happen to differ — which is luck, not a control, and
184
+ * R-28 was precisely "a defect in an argument list that 226 pure tests could not see". An object makes the
185
+ * mistake unspellable.
186
+ */
187
+ options: {
188
+ /** Progress for the parent's status block (ADR-0032). Display only. */
189
+ onProgress?: (update: {
190
+ /** Appended (process executor: a genuine byte stream). */
191
+ chunk?: string;
192
+ /** Replaces (herdr executor: a snapshot of a bounded terminal). The two are NOT interchangeable. */
193
+ snapshot?: string[];
194
+ paneId?: string;
195
+ /** The name herdr actually knows this child by — minted in `runHerdrPane`, so it cannot be derived. */
196
+ agentName?: string;
197
+ state?: "running" | "completed" | "failed";
198
+ }) => void;
199
+ /** Approvals already obtained by the caller — see `planWithApprovals`. `delegate_chain` uses it. */
200
+ preApproved?: InheritableApproval[];
201
+ /**
202
+ * The child whose output composed this child's task (ADR-0033).
203
+ *
204
+ * Recorded, never acted on: it exists so "who wrote this instruction?" is answerable from the trail, which is
205
+ * the question the chain's framed-rather-than-enforced handoff makes worth asking.
206
+ */
207
+ taskFrom?: string;
208
+ /**
209
+ * What the caller's own gate decided, for the LEDGER — not for the plan.
210
+ *
211
+ * **Required because pre-filling `approved` silences the record.** The doc comment on `planWithApprovals` above
212
+ * warns about exactly this: satisfying the gate on the first plan means `obtainApprovals` never runs, so
213
+ * `approval` is undefined and this record writes no `approved`, `approvalSources`, `approvalScopes` or
214
+ * `humanDenied`. Measured: a chain step that spent `tool:bash` on a human's click was indistinguishable from one
215
+ * where nothing was ever gated — `/grants ledger` counted it in neither `bySource` nor `unattributed`, so it did
216
+ * not even show up as a gap, and ADR-0010's compensating control was blind to every chain step.
217
+ */
218
+ approvalFacts?: Pick<ApprovalOutcome, "approved" | "sources" | "scopes" | "humanDenied">;
219
+ } = {},
176
220
  ): Promise<DelegationOutcome> {
221
+ const { onProgress, preApproved, taskFrom, approvalFacts } = options;
177
222
  // pi resolves a BARE model id to an unauthenticated provider and the child dies at startup — the id
178
223
  // alone is not enough, it must be qualified with its provider (`Model<Api>` carries both).
179
224
  const defaultModel = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
@@ -198,7 +243,14 @@ export async function runOneDelegation(
198
243
  // Planning and the gate live in `planWithApprovals`, shared with the `/grants` preview so the two cannot
199
244
  // disagree (R-38). This call is the enforcing one when a human may be asked: `ctx` is passed unless the
200
245
  // executor has already made the outcome certain.
201
- let { plan, approval: approvalOutcome } = await planWithApprovals(session, request, extra, refusal ? null : ctx, signal);
246
+ let { plan, approval: approvalOutcome } = await planWithApprovals(
247
+ session,
248
+ request,
249
+ extra,
250
+ refusal ? null : ctx,
251
+ signal,
252
+ preApproved,
253
+ );
202
254
 
203
255
  // Applied in front of the ledger write below, so the record describes a refusal rather than a spawn. Turning
204
256
  // `plan.ok` off reuses the existing blocked-record path, so this adds a reason rather than a second refusal
@@ -222,15 +274,18 @@ export async function runOneDelegation(
222
274
  // ADR-0031: where this child actually ran. Read off the live session, which the probe has settled
223
275
  // by now, so the record and the executor cannot disagree.
224
276
  executor: session.executor.kind,
277
+ taskFrom,
225
278
  requested: plan.requested,
226
279
  parentGrant: session.ownGrant,
227
280
  result: plan.result,
228
281
  blocked: !plan.ok,
229
282
  reason: plan.reason,
230
- approved: approvalOutcome?.approved,
231
- approvalSources: approvalOutcome?.sources,
232
- approvalScopes: approvalOutcome?.scopes,
233
- humanDenied: approvalOutcome?.humanDenied,
283
+ // `approvalOutcome` when this call's own gate ran; `approvalFacts` when a caller gated upfront on our behalf
284
+ // (a chain). Without the second, an approved chain step recorded nothing about the human who authorised it.
285
+ approved: approvalOutcome?.approved ?? approvalFacts?.approved,
286
+ approvalSources: approvalOutcome?.sources ?? approvalFacts?.sources,
287
+ approvalScopes: approvalOutcome?.scopes ?? approvalFacts?.scopes,
288
+ humanDenied: approvalOutcome?.humanDenied ?? approvalFacts?.humanDenied,
234
289
  gateOutcome: approvalOutcome?.gateOutcome,
235
290
  // ADR-0018: taken from the PLAN, never re-derived here. The B-I3 lesson — a call site that
236
291
  // recomputed the digest could record one the planner never used.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-daddy",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Capability governance for pi sub-agents: spawn Agent Skills (SKILL.md) definitions whose allowed-tools becomes a grant that can only narrow going down a delegation tree, enforced by pi's own --tools allowlist, with an append-only ledger.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -85,6 +85,10 @@
85
85
  "types": "./dist/progress.d.ts",
86
86
  "default": "./dist/progress.js"
87
87
  },
88
+ "./chain": {
89
+ "types": "./dist/chain.d.ts",
90
+ "default": "./dist/chain.js"
91
+ },
88
92
  "./run-child": {
89
93
  "types": "./dist/run-child.d.ts",
90
94
  "default": "./dist/run-child.js"