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
@@ -11,20 +11,29 @@
11
11
  * tool surface is observed.
12
12
  */
13
13
 
14
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
- import { Type } from "typebox";
16
14
  import { DELEGATE_SUBJECT, shouldSeekApproval } from "../src/approval.ts";
17
- import { maySpawnDefinition, planDelegation } from "../src/delegate.ts";
18
- import { MAX_CHILDREN_PER_CALL, childSpawnId, splitBudget } from "../src/fanout.ts";
15
+ import { planDelegation } from "../src/delegate.ts";
19
16
  import { appendRecord, buildRecord } from "../src/ledger.ts";
20
- import { mergeChildEnv } from "../src/propagation.ts";
21
- import type { Capability } from "../src/resolve.ts";
22
- import { ENV_CHILD_TIMEOUT, runChild, timeoutFromEnv } from "../src/run-child.ts";
23
- import { runHerdrPane } from "../src/run-herdr.ts";
24
- import { obtainApprovals, republishable, snapshotOf, type ApprovalOutcome, type ApprovalUIContext } from "./approvals.ts";
17
+ import {
18
+ obtainApprovals,
19
+ republishable,
20
+ snapshotOf,
21
+ unbankApprovals,
22
+ type ApprovalOutcome,
23
+ type ApprovalUIContext,
24
+ } from "./approvals.ts";
25
25
  import type { InheritableApproval } from "../src/approval.ts";
26
- import { resolveWorkspace } from "../src/herdr-cli.ts";
27
- import { ENV_HERDR_KEEP_PANE, type GrantsSession } from "./session.ts";
26
+ import type { GrantsSession } from "./session.ts";
27
+ import type { CorrelationMetadata } from "../src/correlation.ts";
28
+ import { GovernanceRefusal, refusal as structuredRefusal } from "../src/refusals.ts";
29
+ import { executePlannedChild, type DelegationOutcome } from "./execute-child.ts";
30
+ import {
31
+ governedWorkspaceAccess,
32
+ prepareDelegationWorkspace,
33
+ releaseDelegationWorkspace,
34
+ type DelegationWorkspaceSpec,
35
+ type PreparedWorkspace,
36
+ } from "./workspace-runtime.ts";
28
37
 
29
38
  /** What one child was asked to do. The shape both tools accept, per child. */
30
39
  interface ChildSpec {
@@ -32,6 +41,8 @@ interface ChildSpec {
32
41
  agent?: string;
33
42
  tools?: string[];
34
43
  model?: string;
44
+ correlation?: CorrelationMetadata;
45
+ workspace?: DelegationWorkspaceSpec;
35
46
  }
36
47
 
37
48
  /**
@@ -106,6 +117,7 @@ export async function planWithApprovals(
106
117
  ctx,
107
118
  request.task,
108
119
  signal,
120
+ plan.approvalBinding,
109
121
  );
110
122
  const outcome = approval;
111
123
  if (outcome.approved.length > 0) {
@@ -128,28 +140,29 @@ export async function planWithApprovals(
128
140
  // headline property was false on the hot path, for the approvals it was written to cover.
129
141
  // Taken from this session's snapshot, the same source `republishable` uses.
130
142
  bodySha256: snapshotOf(session, approvalSubject)?.bodySha256,
143
+ ...(outcome.bindings[capability] ? { binding: outcome.bindings[capability] } : {}),
131
144
  })),
132
145
  ])),
133
146
  ...extra,
134
147
  });
135
148
  }
136
- if (!plan.ok && approval.reason) plan = { ...plan, reason: approval.reason };
149
+ if (!plan.ok && approval.reason) {
150
+ plan = {
151
+ ...plan,
152
+ reason: approval.reason,
153
+ ...(approval.refusalCode
154
+ ? { refusal: structuredRefusal(approval.refusalCode, approval.reason) }
155
+ : {}),
156
+ };
157
+ }
137
158
  } catch (error) {
138
- plan = { ...plan, reason: `grants: approval flow failed, denying (${String(error)})` };
159
+ const message = `grants: approval flow failed, denying (${String(error)})`;
160
+ plan = { ...plan, reason: message, refusal: structuredRefusal("APPROVAL_FLOW_FAILED", message) };
139
161
  }
140
162
 
141
163
  return { plan, approval };
142
164
  }
143
165
 
144
- interface DelegationOutcome {
145
- ok: boolean;
146
- text: string;
147
- reason?: string;
148
- granted: Capability[];
149
- depth: number;
150
- exitCode: number | null;
151
- }
152
-
153
166
  /**
154
167
  * Plan, gate, audit and run ONE governed child. Shared by `delegate` and `delegate_all`.
155
168
  *
@@ -215,14 +228,27 @@ export async function runOneDelegation(
215
228
  * where nothing was ever gated — `/grants ledger` counted it in neither `bySource` nor `unattributed`, so it did
216
229
  * not even show up as a gap, and ADR-0010's compensating control was blind to every chain step.
217
230
  */
218
- approvalFacts?: Pick<ApprovalOutcome, "approved" | "sources" | "scopes" | "humanDenied">;
231
+ approvalFacts?: Pick<ApprovalOutcome, "approved" | "sources" | "scopes" | "expiresAt" | "uses" | "humanDenied">;
219
232
  } = {},
220
233
  ): Promise<DelegationOutcome> {
221
234
  const { onProgress, preApproved, taskFrom, approvalFacts } = options;
222
235
  // pi resolves a BARE model id to an unauthenticated provider and the child dies at startup — the id
223
236
  // alone is not enough, it must be qualified with its provider (`Model<Api>` carries both).
224
237
  const defaultModel = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
225
- const request = { task: spec.task, agent: spec.agent, tools: spec.tools, model: spec.model ?? defaultModel };
238
+ const request = {
239
+ task: spec.task,
240
+ agent: spec.agent,
241
+ tools: spec.tools,
242
+ model: spec.model ?? defaultModel,
243
+ correlation: spec.workspace
244
+ ? { ...(spec.correlation ?? {}), workspace_id: spec.workspace.workspace_id }
245
+ : spec.correlation,
246
+ // The binding's workspace comes from the ROUTING SPEC, which is resolved against the operator
247
+ // registry and leased before any human is asked — never from `correlation`, which is a model-supplied
248
+ // claim that nothing validates when no spec accompanies it (R-110).
249
+ boundWorkspaceId: spec.workspace?.workspace_id,
250
+ boundContextId: spec.correlation?.context_id,
251
+ };
226
252
  const extra = { fanoutBudget: budget, spawnId: ids.parentId, childSpawnId: ids.childId };
227
253
 
228
254
  // ADR-0031: herdr was DEMANDED (`PI_GRANTS_HERDR=1`) and is not answering. Refused rather than relocated —
@@ -239,24 +265,47 @@ export async function runOneDelegation(
239
265
  // `ctx: null` rather than skipping the plan entirely: the ledger still gets a full, honest record of what was
240
266
  // requested and refused, and stored approvals still count toward it — nothing is *hidden*, only nobody is
241
267
  // *asked*. It is the same argument `/grants` uses for its preview.
242
- const refusal = session.executor.refusal;
243
- // Planning and the gate live in `planWithApprovals`, shared with the `/grants` preview so the two cannot
244
- // disagree (R-38). This call is the enforcing one when a human may be asked: `ctx` is passed unless the
245
- // executor has already made the outcome certain.
246
- let { plan, approval: approvalOutcome } = await planWithApprovals(
247
- session,
248
- request,
249
- extra,
250
- refusal ? null : ctx,
251
- signal,
252
- preApproved,
253
- );
268
+ const executorRefusal = session.executor.refusal;
269
+ let preparedWorkspace: PreparedWorkspace | undefined;
270
+ let approvalOutcome: ApprovalOutcome | undefined;
271
+ let plan: ReturnType<typeof planDelegation>;
272
+ let ledgerDenied = false;
273
+
274
+ if (spec.workspace && !executorRefusal) {
275
+ // Check non-liftable refusals before taking a lease, and take the lease before asking a human. This
276
+ // preserves both anti-race rules: a doomed spawn cannot bank approval, and a conflicting writer starts
277
+ // no child process.
278
+ const preview = await planWithApprovals(session, request, extra, null, signal, preApproved);
279
+ plan = preview.plan;
280
+ if (plan.ok || shouldSeekApproval(plan.result)) {
281
+ try {
282
+ preparedWorkspace = await prepareDelegationWorkspace({
283
+ spec: { ...spec.workspace, access: governedWorkspaceAccess(spec.workspace.access, plan.requested) },
284
+ correlation: spec.correlation,
285
+ childId: ids.childId,
286
+ signal,
287
+ ledgerPath: session.ledgerPath,
288
+ });
289
+ request.correlation = preparedWorkspace.correlation;
290
+ const gated = await planWithApprovals(session, request, extra, ctx, signal, preApproved);
291
+ plan = gated.plan;
292
+ approvalOutcome = gated.approval;
293
+ } catch (error) {
294
+ const value = error instanceof GovernanceRefusal
295
+ ? { code: error.code, message: error.message, ...(error.details ? { details: error.details } : {}) }
296
+ : structuredRefusal("WORKSPACE_LEASE_STALE", `workspace setup failed (${String(error)})`);
297
+ plan = { ...plan, ok: false, reason: value.message, refusal: value };
298
+ }
299
+ }
300
+ } else {
301
+ const gated = await planWithApprovals(session, request, extra, executorRefusal ? null : ctx, signal, preApproved);
302
+ plan = gated.plan;
303
+ approvalOutcome = gated.approval;
304
+ }
254
305
 
255
- // Applied in front of the ledger write below, so the record describes a refusal rather than a spawn. Turning
256
- // `plan.ok` off reuses the existing blocked-record path, so this adds a reason rather than a second refusal
257
- // mechanism.
258
- if (refusal) {
259
- plan = { ...plan, ok: false, reason: `grants: ${refusal}` };
306
+ if (executorRefusal) {
307
+ const message = `grants: ${executorRefusal}`;
308
+ plan = { ...plan, ok: false, reason: message, refusal: structuredRefusal("EXECUTOR_UNAVAILABLE", message) };
260
309
  }
261
310
 
262
311
  // G6 / B-I3: no `&& plan.result` guard — `planDelegation` always carries one now.
@@ -284,104 +333,65 @@ export async function runOneDelegation(
284
333
  // (a chain). Without the second, an approved chain step recorded nothing about the human who authorised it.
285
334
  approved: approvalOutcome?.approved ?? approvalFacts?.approved,
286
335
  approvalSources: approvalOutcome?.sources ?? approvalFacts?.sources,
287
- approvalScopes: approvalOutcome?.scopes ?? approvalFacts?.scopes,
336
+ approvalScopes: approvalOutcome?.recordedScopes ?? approvalFacts?.scopes,
337
+ approvalExpiresAt: approvalOutcome?.expiresAt ?? approvalFacts?.expiresAt,
338
+ approvalUses: approvalOutcome?.uses ?? approvalFacts?.uses,
288
339
  humanDenied: approvalOutcome?.humanDenied ?? approvalFacts?.humanDenied,
289
340
  gateOutcome: approvalOutcome?.gateOutcome,
290
341
  // ADR-0018: taken from the PLAN, never re-derived here. The B-I3 lesson — a call site that
291
342
  // recomputed the digest could record one the planner never used.
292
343
  definitionDigest: plan.definitionDigest,
344
+ taskDigest: plan.taskDigest,
345
+ correlation: plan.correlation,
346
+ refusal: plan.refusal,
293
347
  now: new Date(),
294
348
  }),
295
349
  ).catch((error) => {
296
350
  // G6 / A-R4 + B-I2: fail closed. This path PROVISIONS, so an unrecorded delegation would be a
297
351
  // child running with granted capabilities and no audit line.
298
- plan = { ...plan, ok: false, reason: `grants: ledger write failed, denying — ${String(error)}` };
352
+ plan = {
353
+ ...plan,
354
+ ok: false,
355
+ reason: `grants: ledger write failed, denying — ${String(error)}`,
356
+ refusal: structuredRefusal("LEDGER_WRITE_FAILED", `grants: ledger write failed, denying — ${String(error)}`),
357
+ };
358
+ ledgerDenied = true;
299
359
  });
300
360
  }
301
361
 
302
362
  if (!plan.ok) {
303
- return { ok: false, text: "", reason: plan.reason, granted: [], depth: plan.childDepth, exitCode: null };
304
- }
305
-
306
- // G8: bounded output, a wall-clock timeout with SIGTERM->SIGKILL escalation, and an abort observed
307
- // even if it happened before we got here. See src/run-child.ts for why each one exists.
308
- //
309
- // ADR-0016 point 6: two executors, one plan. `runChild` needs nothing installed; herdr gives the same
310
- // governed argv a VISIBLE, attachable pane.
311
- //
312
- // **Which one is chosen was reversed by ADR-0031**: the session probes for a reachable herdr server at
313
- // startup rather than waiting to be told. Still never detected from a binary on `PATH` — only from a server
314
- // that answered — and the choice is disclosed at session start, in `/grants`, and per child in the ledger.
315
- // Read live off `session.executor`, because the probe finishes after this module is loaded.
316
- const output = session.executor.kind === "herdr"
317
- ? await runHerdrPane({
318
- args: plan.args.slice(0, -1),
319
- // The task is delivered as a prompt, so it never reaches argv at all. `plan.args` still ends
320
- // with the neutralised task (planSpawn is executor-agnostic), hence the slice — and the leading
321
- // space `neutralisePrompt` added is stripped because there is no parser to defend against here.
322
- prompt: plan.args[plan.args.length - 1].trimStart(),
323
- // Grant/depth/ledger go on the PANE: `herdr agent start` has no --env, but a pane's environment
324
- // reaches the shell that launches the agent (docs/probes/g16-herdr).
325
- env: plan.env,
326
- cwd: ctx.cwd,
327
- name: `${spec.agent ?? "delegate"}-${ids.childId}`,
328
- // Was `process.env[ENV_HERDR_WORKSPACE]`, i.e. "omitted lets herdr choose" — which put children in a
329
- // different workspace from the pi session that spawned them, so switching to one meant hopping
330
- // workspaces rather than tabs. `resolveWorkspace` prefers the operator's explicit answer and otherwise
331
- // inherits the parent's own `HERDR_WORKSPACE_ID` (measured; herdr sets it in every pane it creates).
332
- workspace: resolveWorkspace(process.env),
333
- signal,
334
- timeoutMs: timeoutFromEnv(process.env[ENV_CHILD_TIMEOUT]),
335
- keepPane: process.env[ENV_HERDR_KEEP_PANE] === "1",
336
- // ADR-0032. The pane id arrives first and is what a human switches to; the pane's tail follows as the
337
- // child works. Both are display only.
338
- //
339
- // `snapshot`, not `chunk`: `agent read` returns a snapshot of a bounded terminal, and the sink must
340
- // REPLACE what it holds. Treating it as a stream produced an 89,000× amplification and fabricated lines
341
- // the child never printed — see `tailLines` in `src/herdr-poll.ts`.
342
- onPane: onProgress ? (paneId, agentName) => onProgress({ paneId, agentName, state: "running" }) : undefined,
343
- onSnapshot: onProgress ? (snapshot) => onProgress({ snapshot }) : undefined,
344
- })
345
- : await runChild({
346
- command: "pi",
347
- args: plan.args,
348
- // Explicit per-child env — the parent's own grant vars must not leak in. A plain spread would
349
- // not achieve that: a key `plan.env` does not set is a key the parent's value survives into, so
350
- // `mergeChildEnv` strips every governance variable first and lets only the plan put them back.
351
- env: mergeChildEnv(process.env, plan.env),
352
- cwd: ctx.cwd,
353
- signal,
354
- timeoutMs: timeoutFromEnv(process.env[ENV_CHILD_TIMEOUT]),
355
- // No pane on this path, so streaming is the ONLY observability a subprocess child can have — which is
356
- // why ADR-0032 chose the streaming option over status lines alone.
357
- onOutput: onProgress ? (chunk) => onProgress({ chunk }) : undefined,
363
+ // EVERY refusal reached after the gate ran. Gating on `ledgerDenied` left three post-gate refusals
364
+ // stranding a 30-day approval — most reachably a human declining the SECOND of two gated capabilities,
365
+ // which needs no fault at all. The predicate is now the rule itself, so it cannot drift from it again.
366
+ if (ctx) await unbankApprovals(session, ctx, approvalOutcome?.banked);
367
+ // Guarded: this contains a `strict: true` append, and on the path where the ledger is already known
368
+ // unwritable an unguarded call replaced the governance refusal, and its code, with a ledger error.
369
+ try {
370
+ await releaseDelegationWorkspace({
371
+ prepared: preparedWorkspace, childId: ids.childId, ledgerPath: session.ledgerPath, reason: "refused",
358
372
  });
359
-
360
- // G8: a child that failed is reported as a failure. A non-zero exit, a timeout and a truncated flood
361
- // all used to come back as ordinary tool results, so the orchestrator read them as answers.
362
- if (output.spawnError || output.aborted || output.timedOut || output.code !== 0) {
363
- const why = output.spawnError
364
- ? `could not be started: ${output.spawnError}`
365
- : output.aborted
366
- ? "was cancelled"
367
- : output.timedOut
368
- ? "exceeded its time limit and was killed"
369
- : `exited with code ${output.code}`;
373
+ } catch (error) {
374
+ plan = { ...plan, reason: `${plan.reason ?? "refused"}; workspace release record failed: ${String(error)}` };
375
+ }
370
376
  return {
371
377
  ok: false,
372
- text: output.text.trim(),
373
- reason: `the sub-agent ${why}`,
374
- granted: plan.effective,
378
+ text: "",
379
+ reason: plan.reason,
380
+ granted: [],
375
381
  depth: plan.childDepth,
376
- exitCode: output.code,
382
+ exitCode: null,
383
+ ...(plan.refusal ? { refusal: plan.refusal } : {}),
377
384
  };
378
385
  }
379
386
 
380
- return {
381
- ok: true,
382
- text: output.text.trim(),
383
- granted: plan.effective,
384
- depth: plan.childDepth,
385
- exitCode: output.code,
386
- };
387
+ return executePlannedChild({
388
+ session,
389
+ plan,
390
+ agent: spec.agent,
391
+ childId: ids.childId,
392
+ cwd: ctx.cwd,
393
+ preparedWorkspace,
394
+ signal,
395
+ onProgress,
396
+ });
387
397
  }
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import { parseInherited, type InheritableApproval } from "../src/approval.ts";
18
+ import type { ApprovalBinding } from "../src/correlation.ts";
18
19
  import { createApprovalGateProvider } from "../src/approval-prompt.ts";
19
20
  import { makeCatalog, skillPathsFromCatalog, type Catalog } from "../src/catalog.ts";
20
21
  import type { SkillDefinition } from "../src/definitions.ts";
@@ -117,6 +118,8 @@ export interface GrantsSession {
117
118
 
118
119
  /** Approval keys approved for this session. In memory only — this dies with the process. */
119
120
  readonly sessionApprovals: Set<string>;
121
+ /** Exact bindings for correlated approvals; these never inherit across a delegation boundary. */
122
+ readonly sessionApprovalBindings: Map<string, ApprovalBinding>;
120
123
  /**
121
124
  * Approvals inherited from the delegator, already clamped to this session's grant upstream.
122
125
  *
@@ -288,6 +291,7 @@ export function createGrantsSession(extensionPath: string | undefined): GrantsSe
288
291
  extensionPath,
289
292
 
290
293
  sessionApprovals: new Set<string>(),
294
+ sessionApprovalBindings: new Map<string, ApprovalBinding>(),
291
295
  inheritedApprovals: parseInherited(process.env[ENV_APPROVED]),
292
296
  approvalGateFor: createApprovalGateProvider(),
293
297
 
@@ -0,0 +1,165 @@
1
+ import type { CorrelationMetadata } from "../src/correlation.ts";
2
+ import type { Capability } from "../src/resolve.ts";
3
+ import {
4
+ appendLedgerEvent,
5
+ buildWorkspaceLeaseEvent,
6
+ } from "../src/ledger.ts";
7
+ import { GovernanceRefusal, refusal, type StructuredRefusal } from "../src/refusals.ts";
8
+ import {
9
+ acquireWorkspaceLease,
10
+ defaultWorkspaceLeaseDir,
11
+ ENV_WORKSPACE_REGISTRY,
12
+ loadWorkspaceRegistry,
13
+ resolveWorkspace,
14
+ type ValidatedWorkspace,
15
+ type WorkspaceAccess,
16
+ type WorkspaceLease,
17
+ leaseAcquisitionOutcome,
18
+ type LeaseReleaseOutcome,
19
+ } from "../src/workspace.ts";
20
+
21
+ export interface DelegationWorkspaceSpec {
22
+ workspace_id: string;
23
+ access: WorkspaceAccess;
24
+ }
25
+
26
+ const KNOWN_READ_ONLY_TOOLS = new Set(["tool:read", "tool:grep", "tool:find", "tool:ls"]);
27
+
28
+ /** A model may ask for stricter coordination but cannot label a write-capable grant read-only. */
29
+ export function governedWorkspaceAccess(declared: WorkspaceAccess, requested: readonly Capability[]): WorkspaceAccess {
30
+ if (declared === "write") return "write";
31
+ return requested.every((capability) => KNOWN_READ_ONLY_TOOLS.has(capability)) ? "read" : "write";
32
+ }
33
+
34
+ export interface PreparedWorkspace {
35
+ workspace: ValidatedWorkspace;
36
+ lease: WorkspaceLease;
37
+ correlation: CorrelationMetadata;
38
+ }
39
+
40
+ /** Resolve an operator-registered root and acquire its governed-writer lease before any child starts. */
41
+ export async function prepareDelegationWorkspace(input: {
42
+ spec: DelegationWorkspaceSpec;
43
+ correlation?: CorrelationMetadata;
44
+ childId: string;
45
+ signal?: AbortSignal;
46
+ ledgerPath?: string;
47
+ }): Promise<PreparedWorkspace> {
48
+ if (input.correlation?.workspace_id && input.correlation.workspace_id !== input.spec.workspace_id) {
49
+ throw new GovernanceRefusal(refusal(
50
+ "APPROVAL_SCOPE_MISMATCH",
51
+ `correlation workspace ${input.correlation.workspace_id} does not match requested workspace ${input.spec.workspace_id}`,
52
+ { workspace_id: input.spec.workspace_id },
53
+ ));
54
+ }
55
+ const registryPath = process.env[ENV_WORKSPACE_REGISTRY];
56
+ if (!registryPath) {
57
+ throw new GovernanceRefusal(refusal(
58
+ "WORKSPACE_NOT_REGISTERED",
59
+ `${ENV_WORKSPACE_REGISTRY} is required when a delegation names a workspace`,
60
+ { workspace_id: input.spec.workspace_id },
61
+ ));
62
+ }
63
+ const workspace = await resolveWorkspace(await loadWorkspaceRegistry(registryPath), input.spec.workspace_id);
64
+ let lease: WorkspaceLease | undefined;
65
+ try {
66
+ lease = await acquireWorkspaceLease({
67
+ workspace,
68
+ access: input.spec.access,
69
+ leaseDir: defaultWorkspaceLeaseDir(),
70
+ ownerId: input.childId,
71
+ signal: input.signal,
72
+ });
73
+ const correlation = { ...(input.correlation ?? {}), workspace_id: input.spec.workspace_id };
74
+ if (input.ledgerPath) {
75
+ await appendLedgerEvent(
76
+ { path: input.ledgerPath, strict: true },
77
+ buildWorkspaceLeaseEvent({
78
+ childId: input.childId,
79
+ workspaceId: workspace.workspaceId,
80
+ root: workspace.root,
81
+ access: input.spec.access,
82
+ outcome: leaseAcquisitionOutcome(input.spec.access, lease.recovered),
83
+ recovered: lease.recovered,
84
+ correlation,
85
+ now: new Date(),
86
+ }),
87
+ );
88
+ }
89
+ return { workspace, lease, correlation };
90
+ } catch (error) {
91
+ // A load-bearing ledger failure can happen after the kernel lock was acquired. Release before trying
92
+ // to record the refusal, or this live parent would strand its own writer lease until process exit.
93
+ await lease?.release("setup-failed");
94
+ const structured: StructuredRefusal = error instanceof GovernanceRefusal
95
+ ? { code: error.code, message: error.message, ...(error.details ? { details: error.details } : {}) }
96
+ : refusal("WORKSPACE_LEASE_STALE", `workspace lease failed (${String(error)})`);
97
+ if (input.ledgerPath) {
98
+ await appendLedgerEvent(
99
+ { path: input.ledgerPath, strict: true },
100
+ buildWorkspaceLeaseEvent({
101
+ childId: input.childId,
102
+ workspaceId: workspace.workspaceId,
103
+ root: workspace.root,
104
+ access: input.spec.access,
105
+ outcome: "refused",
106
+ refusal: structured,
107
+ correlation: { ...(input.correlation ?? {}), workspace_id: input.spec.workspace_id },
108
+ now: new Date(),
109
+ }),
110
+ );
111
+ }
112
+ throw error;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Records what release actually DID, rather than asserting a handover. `release()` cannot throw
118
+ * (R-99) — but THIS function still can, through its own strict append, so callers wrap it rather than
119
+ * calling it bare from a `finally`. An earlier version of this comment claimed the opposite, and the
120
+ * refusal path took it at its word. Returning a value rather than throwing is
121
+ * the whole reason it returns a value. `retained` is a deliberate non-release and is reported as such
122
+ * so the next owner's `recovered: true` does not blame a healthy path (R-104).
123
+ */
124
+ export async function releaseDelegationWorkspace(input: {
125
+ prepared: PreparedWorkspace | undefined;
126
+ childId: string;
127
+ ledgerPath?: string;
128
+ reason: string;
129
+ /** Deliberately keep the lease: a herdr writer tab would not close, so the pane may still be live. */
130
+ retain?: boolean;
131
+ }): Promise<LeaseReleaseOutcome | "retained" | undefined> {
132
+ if (!input.prepared) return undefined;
133
+ // A retained lease writes no `state: "released"`, so the record stays `active` and the NEXT owner reads
134
+ // it as a crash — the exact blame `retained` was added to remove. Marking it keeps the successor honest;
135
+ // R-104 was fixed in the release event's wording only.
136
+ const outcome: LeaseReleaseOutcome | "retained" = input.retain
137
+ ? (await input.prepared.lease.markRetained(input.reason), "retained")
138
+ : await input.prepared.lease.release(input.reason);
139
+ if (input.ledgerPath) {
140
+ await appendLedgerEvent(
141
+ { path: input.ledgerPath, strict: true },
142
+ buildWorkspaceLeaseEvent({
143
+ childId: input.childId,
144
+ workspaceId: input.prepared.workspace.workspaceId,
145
+ root: input.prepared.workspace.root,
146
+ access: input.prepared.lease.access,
147
+ outcome: leaseReleaseLedgerOutcome(outcome, input.reason),
148
+ releaseReason: input.reason,
149
+ correlation: input.prepared.correlation,
150
+ now: new Date(),
151
+ }),
152
+ );
153
+ }
154
+ return outcome;
155
+ }
156
+
157
+ function leaseReleaseLedgerOutcome(
158
+ outcome: LeaseReleaseOutcome | "retained",
159
+ reason: string,
160
+ ): "timeout" | "released" | "released-unrecorded" | "lost" | "retained" {
161
+ if (outcome === "retained") return "retained";
162
+ if (outcome === "lost") return "lost";
163
+ if (outcome === "released-unrecorded") return "released-unrecorded";
164
+ return reason === "timeout" ? "timeout" : "released";
165
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-daddy",
3
- "version": "0.17.0",
3
+ "version": "0.18.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",
@@ -112,6 +112,22 @@
112
112
  "./skill-packages": {
113
113
  "types": "./dist/skill-packages.d.ts",
114
114
  "default": "./dist/skill-packages.js"
115
+ },
116
+ "./correlation": {
117
+ "types": "./dist/correlation.d.ts",
118
+ "default": "./dist/correlation.js"
119
+ },
120
+ "./refusals": {
121
+ "types": "./dist/refusals.d.ts",
122
+ "default": "./dist/refusals.js"
123
+ },
124
+ "./workspace": {
125
+ "types": "./dist/workspace.d.ts",
126
+ "default": "./dist/workspace.js"
127
+ },
128
+ "./check-runner": {
129
+ "types": "./dist/check-runner.d.ts",
130
+ "default": "./dist/check-runner.js"
115
131
  }
116
132
  },
117
133
  "bin": {
@@ -36,8 +36,10 @@ export interface PromptRequest {
36
36
  /** Agent type name, or `DELEGATE_SUBJECT` on the delegate path. */
37
37
  subject: string;
38
38
  path: ApprovalPath;
39
- /** Shown to the human for context. Never part of a key. */
39
+ /** Shown to the human for context. Never itself part of a key. */
40
40
  task?: string;
41
+ /** Trusted exact-scope identity; bound requests single-flight only with an identical binding. */
42
+ bindingKey?: string;
41
43
  signal?: AbortSignal;
42
44
  }
43
45
 
@@ -194,7 +196,7 @@ export function createApprovalGate(
194
196
  };
195
197
  }
196
198
 
197
- const key = approvalKey(request.capability, request.subject);
199
+ const key = `${approvalKey(request.capability, request.subject)}${request.bindingKey ? `#${request.bindingKey}` : ""}`;
198
200
 
199
201
  // R-29. Join an in-flight dialog, but only *keep* its answer if that answer was about more than one
200
202
  // spawn. A `once` belongs to whichever caller the human was actually looking at — the title shows
@@ -36,6 +36,7 @@ import { withFileLock } from "./file-lock.ts";
36
36
  import { homedir } from "node:os";
37
37
  import { basename, dirname, join } from "node:path";
38
38
  import { entryVerdict, type ApprovalEntry, type EntryVerdict, type SubjectSnapshot } from "./approval.ts";
39
+ import { isApprovalBinding } from "./correlation.ts";
39
40
 
40
41
  interface ApprovalFile {
41
42
  version: 1;
@@ -148,7 +149,8 @@ function isValidEntryShape(entry: unknown): entry is ApprovalEntry {
148
149
  typeof obj.approvedAt === "string" &&
149
150
  typeof obj.expiresAt === "string" &&
150
151
  typeof obj.cwd === "string" &&
151
- Array.isArray(obj.grantAtApproval)
152
+ Array.isArray(obj.grantAtApproval) &&
153
+ (obj.binding === undefined || isApprovalBinding(obj.binding))
152
154
  );
153
155
  }
154
156
 
@@ -173,6 +175,7 @@ function sanitise(valid: Map<string, ApprovalEntry>): Record<string, ApprovalEnt
173
175
  cwd: e.cwd,
174
176
  grantAtApproval: e.grantAtApproval,
175
177
  ...(e.bodyAtApproval !== undefined ? { bodyAtApproval: e.bodyAtApproval } : {}),
178
+ ...(e.binding !== undefined ? { binding: structuredClone(e.binding) } : {}),
176
179
  },
177
180
  ]),
178
181
  );