pi-daddy 0.18.0 → 0.19.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 (130) hide show
  1. package/CHANGELOG.md +154 -0
  2. package/README.md +46 -2
  3. package/contracts/ledger/v2/README.md +59 -0
  4. package/contracts/ledger/v2/fixtures/capability-decision.json +95 -0
  5. package/contracts/ledger/v2/fixtures/check-receipt.json +40 -0
  6. package/contracts/ledger/v2/fixtures/child-lifecycle.json +42 -0
  7. package/contracts/ledger/v2/fixtures/workspace-lease.json +41 -0
  8. package/contracts/ledger/v2/ledger-event.schema.json +633 -0
  9. package/dist/approval-prompt.d.ts +2 -1
  10. package/dist/approval-prompt.d.ts.map +1 -1
  11. package/dist/approval-prompt.js +9 -0
  12. package/dist/approval-prompt.js.map +1 -1
  13. package/dist/approval.d.ts +4 -2
  14. package/dist/approval.d.ts.map +1 -1
  15. package/dist/approval.js +4 -0
  16. package/dist/approval.js.map +1 -1
  17. package/dist/capabilities.d.ts +105 -0
  18. package/dist/capabilities.d.ts.map +1 -1
  19. package/dist/capabilities.js +161 -3
  20. package/dist/capabilities.js.map +1 -1
  21. package/dist/catalog.d.ts +15 -1
  22. package/dist/catalog.d.ts.map +1 -1
  23. package/dist/catalog.js +54 -3
  24. package/dist/catalog.js.map +1 -1
  25. package/dist/check-runner.d.ts.map +1 -1
  26. package/dist/check-runner.js +5 -7
  27. package/dist/check-runner.js.map +1 -1
  28. package/dist/cli.d.ts.map +1 -1
  29. package/dist/cli.js +21 -1
  30. package/dist/cli.js.map +1 -1
  31. package/dist/definitions.d.ts.map +1 -1
  32. package/dist/definitions.js +7 -1
  33. package/dist/definitions.js.map +1 -1
  34. package/dist/delegate.d.ts.map +1 -1
  35. package/dist/delegate.js +32 -4
  36. package/dist/delegate.js.map +1 -1
  37. package/dist/delegation-approval.d.ts.map +1 -1
  38. package/dist/delegation-approval.js +37 -12
  39. package/dist/delegation-approval.js.map +1 -1
  40. package/dist/executor.d.ts +2 -1
  41. package/dist/executor.d.ts.map +1 -1
  42. package/dist/executor.js +1 -0
  43. package/dist/executor.js.map +1 -1
  44. package/dist/grant-env.d.ts +2 -0
  45. package/dist/grant-env.d.ts.map +1 -1
  46. package/dist/grant-env.js +26 -3
  47. package/dist/grant-env.js.map +1 -1
  48. package/dist/index.d.ts +1 -1
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +1 -1
  51. package/dist/index.js.map +1 -1
  52. package/dist/init.d.ts +13 -1
  53. package/dist/init.d.ts.map +1 -1
  54. package/dist/init.js +35 -2
  55. package/dist/init.js.map +1 -1
  56. package/dist/lease-helper.d.ts +58 -0
  57. package/dist/lease-helper.d.ts.map +1 -0
  58. package/dist/lease-helper.js +94 -0
  59. package/dist/lease-helper.js.map +1 -0
  60. package/dist/lease-record.d.ts +15 -3
  61. package/dist/lease-record.d.ts.map +1 -1
  62. package/dist/lease-record.js.map +1 -1
  63. package/dist/ledger-events.d.ts +35 -14
  64. package/dist/ledger-events.d.ts.map +1 -1
  65. package/dist/ledger-events.js +41 -0
  66. package/dist/ledger-events.js.map +1 -1
  67. package/dist/ledger.d.ts +10 -5
  68. package/dist/ledger.d.ts.map +1 -1
  69. package/dist/ledger.js +13 -3
  70. package/dist/ledger.js.map +1 -1
  71. package/dist/propagation.d.ts +16 -0
  72. package/dist/propagation.d.ts.map +1 -1
  73. package/dist/propagation.js +22 -2
  74. package/dist/propagation.js.map +1 -1
  75. package/dist/refusals.d.ts +1 -1
  76. package/dist/refusals.d.ts.map +1 -1
  77. package/dist/refusals.js +2 -0
  78. package/dist/refusals.js.map +1 -1
  79. package/dist/resolve.d.ts +10 -0
  80. package/dist/resolve.d.ts.map +1 -1
  81. package/dist/resolve.js +33 -3
  82. package/dist/resolve.js.map +1 -1
  83. package/dist/routing-authority.d.ts +71 -0
  84. package/dist/routing-authority.d.ts.map +1 -0
  85. package/dist/routing-authority.js +100 -0
  86. package/dist/routing-authority.js.map +1 -0
  87. package/dist/skill-packages.d.ts +11 -5
  88. package/dist/skill-packages.d.ts.map +1 -1
  89. package/dist/skill-packages.js +20 -11
  90. package/dist/skill-packages.js.map +1 -1
  91. package/dist/workspace-lease.d.ts +15 -3
  92. package/dist/workspace-lease.d.ts.map +1 -1
  93. package/dist/workspace-lease.js +81 -24
  94. package/dist/workspace-lease.js.map +1 -1
  95. package/dist/workspace.d.ts +25 -0
  96. package/dist/workspace.d.ts.map +1 -1
  97. package/dist/workspace.js +142 -5
  98. package/dist/workspace.js.map +1 -1
  99. package/extensions/delegation.ts +7 -1
  100. package/extensions/grants-command.ts +11 -1
  101. package/extensions/grants.ts +6 -1
  102. package/extensions/init-command.ts +33 -2
  103. package/extensions/session-report.ts +13 -20
  104. package/extensions/session.ts +9 -1
  105. package/extensions/workspace-runtime.ts +34 -4
  106. package/package.json +8 -3
  107. package/src/approval-prompt.ts +2 -1
  108. package/src/approval.ts +4 -2
  109. package/src/capabilities.ts +173 -4
  110. package/src/catalog.ts +62 -4
  111. package/src/check-runner.ts +8 -8
  112. package/src/cli.ts +24 -1
  113. package/src/definitions.ts +7 -1
  114. package/src/delegate.ts +42 -4
  115. package/src/delegation-approval.ts +37 -12
  116. package/src/executor.ts +2 -1
  117. package/src/grant-env.ts +39 -7
  118. package/src/index.ts +1 -0
  119. package/src/init.ts +40 -2
  120. package/src/lease-helper.ts +97 -0
  121. package/src/lease-record.ts +15 -3
  122. package/src/ledger-events.ts +66 -22
  123. package/src/ledger.ts +30 -6
  124. package/src/propagation.ts +23 -2
  125. package/src/refusals.ts +2 -0
  126. package/src/resolve.ts +35 -3
  127. package/src/routing-authority.ts +121 -0
  128. package/src/skill-packages.ts +20 -13
  129. package/src/workspace-lease.ts +84 -24
  130. package/src/workspace.ts +179 -6
package/src/catalog.ts CHANGED
@@ -23,9 +23,11 @@ import { homedir } from "node:os";
23
23
  import { join } from "node:path";
24
24
  import { loadDefinitions, type SkillDefinition } from "./definitions.ts";
25
25
  import { PI_BUILTIN_TOOLS, WILDCARD } from "./pi-tools.ts";
26
- import { AGENT_WILDCARD, type Capability } from "./resolve.ts";
26
+ import { AGENT_WILDCARD, WORKSPACE_WILDCARD, type Capability } from "./resolve.ts";
27
+ import { loadWorkspaceRegistry, type WorkspaceRegistryFile } from "./workspace.ts";
28
+ import { isSafeWorkspaceId } from "./capabilities.ts";
27
29
 
28
- export type CapabilityKind = "builtin" | "extension" | "skill" | "agentType";
30
+ export type CapabilityKind = "builtin" | "extension" | "skill" | "agentType" | "workspace";
29
31
 
30
32
  export interface CatalogEntry {
31
33
  capability: Capability;
@@ -112,6 +114,22 @@ export function definitionEntries(definitions: Map<string, SkillDefinition>): Ca
112
114
  }));
113
115
  }
114
116
 
117
+ /**
118
+ * Registered workspaces, as `workspace:<id>` capabilities (ADR-0035).
119
+ *
120
+ * For DISPLAY and SCAFFOLDING only — `/grants` listing what this session may route to, and `init` offering
121
+ * the ids without choosing among them (ADR-0028). It is deliberately **not** what `unknownCapabilities`
122
+ * checks against; see the comment there.
123
+ *
124
+ * The operator registry is the authority on which ids exist, exactly as it is at `resolveWorkspace`. This
125
+ * enumerates it; it does not decide anything.
126
+ */
127
+ export function workspaceEntries(registry: WorkspaceRegistryFile, source?: string): CatalogEntry[] {
128
+ return Object.keys(registry.workspaces)
129
+ .sort()
130
+ .map((id) => ({ capability: `workspace:${id}` as Capability, kind: "workspace" as const, ...(source ? { source } : {}) }));
131
+ }
132
+
115
133
  /** Assemble a catalog from parts. Pure, so it is testable without a filesystem. */
116
134
  export function makeCatalog(entries: CatalogEntry[]): Catalog {
117
135
  const deduped = new Map<Capability, CatalogEntry>();
@@ -131,8 +149,23 @@ export function makeCatalog(entries: CatalogEntry[]): Catalog {
131
149
  export async function buildCatalog(input: {
132
150
  cwd: string;
133
151
  observedTools: string[] | null;
152
+ /** Operator workspace registry (`PI_GRANTS_WORKSPACE_REGISTRY`). Absent or unreadable yields no entries. */
153
+ registryPath?: string;
134
154
  }): Promise<Catalog> {
135
- const [skills, definitions] = await Promise.all([loadSkills(input.cwd), loadDefinitions(input.cwd)]);
155
+ const [skills, definitions, workspaces] = await Promise.all([
156
+ loadSkills(input.cwd),
157
+ loadDefinitions(input.cwd),
158
+ // Fails SOFT, and only because nothing here is an authority. A malformed registry must not stop a
159
+ // session from starting — `loadWorkspaceRegistry` throws a GovernanceRefusal naming the file, and that
160
+ // refusal is the operator's signal at the point of USE, where routing actually depends on it. Swallowing
161
+ // it there would be unsafe; swallowing it here costs a display list.
162
+ input.registryPath
163
+ ? loadWorkspaceRegistry(input.registryPath).then(
164
+ (r) => workspaceEntries(r, input.registryPath),
165
+ () => [] as CatalogEntry[],
166
+ )
167
+ : Promise.resolve([] as CatalogEntry[]),
168
+ ]);
136
169
  return makeCatalog([
137
170
  // pi's built-ins are seeded unconditionally, because they are known statically and the catalog is
138
171
  // consulted BEFORE any provider request has happened — `/grants` runs at that point. Without this,
@@ -148,6 +181,7 @@ export async function buildCatalog(input: {
148
181
  ...(input.observedTools ? classifyToolNames(input.observedTools) : []),
149
182
  ...skills,
150
183
  ...definitionEntries(definitions),
184
+ ...workspaces,
151
185
  ]);
152
186
  }
153
187
 
@@ -165,7 +199,31 @@ export function unknownCapabilities(requested: Capability[], catalog: Catalog):
165
199
  // refused it BEFORE `resolve` could apply ADR-0023's rule. That made the ADR's "a parent holding
166
200
  // `agent:*` may hand down `agent:*`" false, and made a definition declaring `allowed-tools: agent:*`
167
201
  // unspawnable from any grant. The wildcard is live only at the root without this.
168
- return requested.filter((c) => c !== WILDCARD && c !== AGENT_WILDCARD && !catalog.has(c)).sort();
202
+ //
203
+ // `workspace:` is exempt as a NAMESPACE, not merely at its wildcard, and that asymmetry is deliberate.
204
+ // ADR-0035 minted the namespace and taught `normaliseCapability`, `resolve` and `childEnv` about it, but
205
+ // not this line — so with a catalog present (and `delegationContext` always supplies one) every requested
206
+ // `workspace:<id>` was refused UNKNOWN_TOOL as *"a typo, or an uninstalled package"*. A child could
207
+ // therefore never be granted a workspace capability at all, which made routing stop dead below the root
208
+ // instead of attenuating, and made the ADR's own "two authorities, not one" unreachable in production.
209
+ //
210
+ // Exempt rather than catalogued-and-checked because the operator registry is the authority and it is
211
+ // consulted where it matters: `resolveWorkspace` refuses an unregistered id with WORKSPACE_NOT_REGISTERED,
212
+ // naming the registry. A second, weaker check here can only turn that precise refusal into a misleading
213
+ // one — and it would do so for reasons that have nothing to do with the id, like a registry this session
214
+ // cannot read. `buildCatalog` still ENUMERATES workspaces, for `/grants` and for `init`'s scaffold; that
215
+ // is display, and display is not authority. Same trade-off the built-ins comment above states and accepts.
216
+ // The workspace exemption requires a WELL-FORMED id, not merely the prefix. A bare `workspace:` names
217
+ // nothing, and exempting it let it reach a child's grant and the ledger as authority over no workspace at
218
+ // all — an exemption for ids the registry is authoritative about should not also cover ids no registry
219
+ // could contain.
220
+ // `WORKSPACE_WILDCARD` is listed with the other two because it is GRAMMAR, and `isSafeCapability` refuses
221
+ // wildcards by design — so folding it into the namespace test below un-exempts it. Caught by the tests for
222
+ // the previous two fixes, which is the checklist paying for itself.
223
+ const exempt = (c: Capability) =>
224
+ c === WILDCARD || c === AGENT_WILDCARD || c === WORKSPACE_WILDCARD
225
+ || (c.startsWith("workspace:") && isSafeWorkspaceId(c.slice("workspace:".length)));
226
+ return requested.filter((c) => !exempt(c) && !catalog.has(c)).sort();
169
227
  }
170
228
 
171
229
  /**
@@ -5,9 +5,8 @@ import { basename, isAbsolute, join } from "node:path";
5
5
  import { normaliseCorrelation, type CorrelationMetadata } from "./correlation.ts";
6
6
  import {
7
7
  appendLedgerEvent,
8
+ buildCheckReceiptLedgerEvent,
8
9
  buildWorkspaceLeaseEvent,
9
- type CheckReceiptLedgerEvent,
10
- LEDGER_VERSION,
11
10
  } from "./ledger.ts";
12
11
  import { computeGitCandidateIdentity } from "./git-identity.ts";
13
12
  import { runChild } from "./run-child.ts";
@@ -272,12 +271,13 @@ export async function runNamedCheck(input: {
272
271
  };
273
272
  const receipt: CheckReceipt = { receipt_id: receiptId(body), ...body };
274
273
  if (input.ledgerPath) {
275
- const event: CheckReceiptLedgerEvent = {
276
- ledgerVersion: LEDGER_VERSION, event: "check_receipt", ts: ended.toISOString(), childId: ownerId,
277
- receiptId: receipt.receipt_id, workspaceId: input.workspace.workspaceId, checkId: input.checkId,
278
- treeSha: receipt.tree_sha, ...(correlation ? { correlation } : {}),
279
- };
280
- await appendLedgerEvent({ path: input.ledgerPath, strict: true }, event);
274
+ await appendLedgerEvent(
275
+ { path: input.ledgerPath, strict: true },
276
+ buildCheckReceiptLedgerEvent({
277
+ childId: ownerId, receiptId: receipt.receipt_id, workspaceId: input.workspace.workspaceId,
278
+ checkId: input.checkId, treeSha: receipt.tree_sha, correlation, now: ended,
279
+ }),
280
+ );
281
281
  }
282
282
  return { output: result.text, exitCode: result.code, signal: result.signal ?? null, receipt };
283
283
  } catch (error) {
package/src/cli.ts CHANGED
@@ -19,6 +19,7 @@ import { relative, resolve as resolvePath } from "node:path";
19
19
  import { pathToFileURL } from "node:url";
20
20
  import { UnsafeGrantError } from "./grant-env.ts";
21
21
  import { applyInit, countDeclaring, planInit, type InitPlan } from "./init.ts";
22
+ import { registeredWorkspaceIds } from "./workspace.ts";
22
23
  import { discoverSkillPackages, skillPackageRoots, type RefusedSkill, type SkillPackage } from "./skill-packages.ts";
23
24
 
24
25
  const USAGE = `pi-daddy — capability governance for pi sub-agents
@@ -109,7 +110,7 @@ async function init(cwd: string, force: boolean): Promise<number> {
109
110
 
110
111
  let plan: InitPlan;
111
112
  try {
112
- plan = planInit(packages, cwd);
113
+ plan = planInit(packages, cwd, await registeredWorkspaceIds());
113
114
  } catch (error) {
114
115
  // R-78's backstop reaching the surface. Nothing is written: a grant that could mean something to a
115
116
  // shell is not a grant, and half-scaffolding a project would be worse than scaffolding none of it.
@@ -210,6 +211,28 @@ function report(plan: InitPlan): void {
210
211
  );
211
212
  }
212
213
 
214
+ // ROUTING, which had no line here at all — and `report()` is the output of the command the docs tell an
215
+ // operator to run. Keeping `workspace:` ids out of `withheldCapabilities` (so the `/grants init` dialog
216
+ // could not grant them off a package declaration) removed the ONLY thing this path said about a definition
217
+ // that cannot be spawned: `needs-withheld` is not one of the cases handled above, so a routing package
218
+ // produced a copied definition, an unusable grant, and total silence.
219
+ //
220
+ // The fix that caused it argued that "a breaking change whose migration is only discoverable by opening a
221
+ // file is not much of a migration" — and then applied that to the in-session notify and not to the CLI.
222
+ if (plan.routableWorkspaces.length > 0) {
223
+ const blocked = plan.skills.filter((s) => s.withheld === "needs-withheld").map((s) => s.name);
224
+ console.log(
225
+ `\nROUTABLE WORKSPACES: ${plan.routableWorkspaces.join(", ")}.\n` +
226
+ `Routing a child to one needs its id in PI_GRANTS_GRANT (ADR-0035); without it the delegation is\n` +
227
+ `refused WORKSPACE_NOT_AUTHORIZED. They are listed COMMENTED in .pi/grants.env and never granted for\n` +
228
+ `you — which worktree a child starts in is not something a package can declare.` +
229
+ (blocked.length > 0
230
+ ? `\nUntil you grant one, these cannot be spawned: ${blocked.join(", ")} — add the capability, then\n` +
231
+ `their \`agent:\` ids.`
232
+ : ""),
233
+ );
234
+ }
235
+
213
236
  console.log(
214
237
  `\nLive grant (${plan.grant.length} capabilities): ${plan.grant.join(", ")}\n\n` +
215
238
  ` $EDITOR .pi/grants.env # review it, then commit it\n` +
@@ -23,6 +23,7 @@ import { createHash } from "node:crypto";
23
23
  import { readFile, readdir } from "node:fs/promises";
24
24
  import { join } from "node:path";
25
25
  import { skillDirs } from "./catalog.ts";
26
+ import { CAPABILITY_NAMESPACE_PREFIXES } from "./capabilities.ts";
26
27
  import type { Capability } from "./resolve.ts";
27
28
 
28
29
  /**
@@ -182,7 +183,12 @@ export function ceilingForDefinition(definition: SkillDefinition): DefinitionCei
182
183
  patterns.push(entry);
183
184
  continue;
184
185
  }
185
- if (entry.startsWith("ext:") || entry.startsWith("skill:") || entry.startsWith("agent:")) {
186
+ // Every namespaced id passes through untouched; only a BARE tool name gets the `tool:` prefix and the
187
+ // lowercasing. `workspace:` was missing here after ADR-0035 taught `normaliseCapability` about it, so a
188
+ // definition declaring `allowed-tools: workspace:prod` produced `tool:workspace:prod` — a capability
189
+ // that names nothing, refused as unknown, and dropped by `init` as "probably a typo or an attack". Two
190
+ // spellings of one grammar with this one wrong is R-28's shape; the list is now shared.
191
+ if (CAPABILITY_NAMESPACE_PREFIXES.some((prefix) => entry.startsWith(prefix))) {
186
192
  capabilities.add(entry);
187
193
  continue;
188
194
  }
package/src/delegate.ts CHANGED
@@ -6,13 +6,22 @@
6
6
  import { planSpawn } from "./spawn.ts";
7
7
  import { ceilingForDefinition, digestDefinition, type DefinitionDigest, type SkillDefinition } from "./definitions.ts";
8
8
  import { assertNarrowing, type Capability, type ResolveResult } from "./resolve.ts";
9
- import { DELEGATE_CAPABILITY, agentCapability, maySpawnDefinition, normaliseCapability } from "./capabilities.ts";
9
+ import { checkRoutingAuthority, checkWorkspaceWildcardRequest } from "./routing-authority.ts";
10
+ import {
11
+ DELEGATE_CAPABILITY,
12
+ agentCapability,
13
+ maySpawnDefinition,
14
+ normaliseCapability,
15
+ } from "./capabilities.ts";
10
16
 
11
17
  // Re-exported so the split stays internal: `delegate.ts` has been the import site for these since 0.6.0 and
12
18
  // four modules plus the test suite name it. Moving the definitions without moving the door would be churn
13
19
  // charged to every caller for a line count they did not cause.
14
20
  export { DELEGATE_CAPABILITY, agentCapability, maySpawnDefinition, normaliseCapability } from "./capabilities.ts";
15
- import { ENV_APPROVED, ENV_DEPTH, ENV_FANOUT, ENV_GATED, ENV_GRANT, ENV_LEDGER, ENV_MAX_DEPTH, ENV_PARENT_ID } from "./propagation.ts";
21
+ import {
22
+ ENV_APPROVED, ENV_DEPTH, ENV_FANOUT, ENV_GATED, ENV_GRANT, ENV_LEDGER, ENV_MAX_DEPTH, ENV_PARENT_ID,
23
+ inheritableGrant,
24
+ } from "./propagation.ts";
16
25
  import { inheritApprovals, type InheritableApproval } from "./approval.ts";
17
26
  import { suggestForUnknown, unknownCapabilities, type Catalog } from "./catalog.ts";
18
27
  import { GovernanceRefusal, refusal, type RefusalCode, type StructuredRefusal } from "./refusals.ts";
@@ -24,6 +33,7 @@ import {
24
33
  } from "./correlation.ts";
25
34
  import { resolveDelegationApproval } from "./delegation-approval.ts";
26
35
  import type { Delegation, DelegationContext, DelegationRequest } from "./delegate-types.ts";
36
+ import { assertCapabilitiesArePropagatable } from "./capabilities.ts";
27
37
  export type { Delegation, DelegationContext, DelegationRequest } from "./delegate-types.ts";
28
38
 
29
39
  export function planDelegation(request: DelegationRequest, ctx: DelegationContext): Delegation {
@@ -71,6 +81,17 @@ export function planDelegation(request: DelegationRequest, ctx: DelegationContex
71
81
  }
72
82
  if (!request.task?.trim()) return denied({ ...empty, reason: "a delegation needs a task" }, "TASK_MISSING");
73
83
 
84
+ // ADR-0035's routing guards live in `routing-authority.ts` with their rationale; both are checked here,
85
+ // before anything is said about the target, because they are governance questions about the SESSION.
86
+ const routing = checkRoutingAuthority(request.boundWorkspaceId, ctx.ownGrant);
87
+ if (routing) {
88
+ return denied({
89
+ ...empty,
90
+ ...(routing.denied ? { requested: routing.denied, result: { ...empty.result, denied: routing.denied } } : {}),
91
+ reason: routing.reason,
92
+ }, routing.code);
93
+ }
94
+
74
95
  // ADR-0016. A named definition replaces the model's tool list with an operator-authored ceiling.
75
96
  let requested: Capability[];
76
97
  let systemPrompt: string | undefined;
@@ -180,6 +201,13 @@ export function planDelegation(request: DelegationRequest, ctx: DelegationContex
180
201
  }
181
202
  }
182
203
 
204
+ // See `checkWorkspaceWildcardRequest`: refused for a HOLDER (the ledger must not record authority the
205
+ // child will not receive), and left to `resolve()` for anyone else, so the probe lands in `denied`.
206
+ const wildcardRequest = checkWorkspaceWildcardRequest(requested, ctx.ownGrant);
207
+ if (wildcardRequest) {
208
+ return denied({ ...empty, requested, reason: wildcardRequest.reason }, wildcardRequest.code);
209
+ }
210
+
183
211
  const { result, approvalBinding, bindingMismatch } = resolveDelegationApproval({
184
212
  task: request.task,
185
213
  agent: request.agent,
@@ -265,8 +293,14 @@ export function planDelegation(request: DelegationRequest, ctx: DelegationContex
265
293
  args.splice(args.length - 1, 0, "-e", ctx.extensionPath);
266
294
  }
267
295
 
296
+ // `inheritableGrant`, not `result.effective` directly: this is the path a DELEGATED child's grant
297
+ // actually travels, and the "held but never inherited" rule for `tool:*` and `workspace:*` was enforced
298
+ // only in `childEnv`. A parent holding `workspace:*` could request it for its child and this line handed
299
+ // it over, so the rule ADR-0035 advertises held by accident — masked downstream rather than enforced
300
+ // here. One spelling of the rule, called from both paths.
301
+ const inheritable = inheritableGrant(result.effective);
268
302
  const env: Record<string, string> = {
269
- [ENV_GRANT]: result.effective.join(","),
303
+ [ENV_GRANT]: (assertCapabilitiesArePropagatable(inheritable), inheritable.join(",")),
270
304
  [ENV_DEPTH]: String(childDepth),
271
305
  [ENV_MAX_DEPTH]: String(ctx.maxDepth),
272
306
  };
@@ -280,7 +314,11 @@ export function planDelegation(request: DelegationRequest, ctx: DelegationContex
280
314
  // `approved ⊆ grant` holds at every level (ADR-0010). Written even when empty, so this object states
281
315
  // the child's approval set outright rather than leaving it to whatever the caller merges over; see
282
316
  // `mergeChildEnv`, which is what actually stops the parent's value leaking through.
283
- env[ENV_APPROVED] = inheritApprovals(ctx.approved ?? [], result.effective).join(",");
317
+ // Clamped to what the child actually INHERITS, not to what it was granted. The two differ only for a
318
+ // non-inheritable wildcard, and passing down an approval for a capability the child does not hold would
319
+ // leave banked authority with nothing to spend it on — `childEnv` clamps to `inheritable` for the same
320
+ // reason on the other path.
321
+ env[ENV_APPROVED] = inheritApprovals(ctx.approved ?? [], inheritable).join(",");
284
322
  if (ctx.ledgerPath) env[ENV_LEDGER] = ctx.ledgerPath;
285
323
 
286
324
  return {
@@ -1,5 +1,5 @@
1
1
  import { DELEGATE_SUBJECT, type InheritableApproval } from "./approval.ts";
2
- import { agentCapability } from "./capabilities.ts";
2
+ import { agentCapability, workspaceCapability } from "./capabilities.ts";
3
3
  import {
4
4
  approvalBindingsEqual,
5
5
  buildApprovalBinding,
@@ -7,7 +7,7 @@ import {
7
7
  type CorrelationMetadata,
8
8
  } from "./correlation.ts";
9
9
  import type { DefinitionDigest, SkillDefinition } from "./definitions.ts";
10
- import { AGENT_WILDCARD, resolve, type Capability, type ResolveResult } from "./resolve.ts";
10
+ import { AGENT_WILDCARD, WORKSPACE_WILDCARD, resolve, type Capability, type ResolveResult } from "./resolve.ts";
11
11
 
12
12
  /**
13
13
  * Resolve the approval half of one delegation after its requested capability set is known.
@@ -44,15 +44,32 @@ export function resolveDelegationApproval(input: {
44
44
  approved: [],
45
45
  });
46
46
 
47
- // ADR-0024: a definition's authorising id is gated as the PARENT's authority to run it now. It never
48
- // joins requested/effective, because that would hand the child authority to recursively spawn itself.
49
- let authorisingCapability: Capability | undefined;
50
- if (input.spawned) {
51
- const authorising = agentCapability(input.spawned.name);
52
- if (input.gated.includes(authorising) || input.gated.includes(AGENT_WILDCARD)) {
53
- authorisingCapability = authorising;
47
+ /**
48
+ * Authorities the PARENT is spending on this one delegation, gated as such.
49
+ *
50
+ * ADR-0024 established the shape for `agent:<name>`: the id is gated as the parent's authority to run
51
+ * that definition *now*, and never joins requested/effective, because that would hand the child authority
52
+ * to recursively spawn itself. ADR-0035 added a second member of the category — `workspace:<id>`, the
53
+ * authority to route this child somewhere — and shipped without it, so `PI_GRANTS_GATED=workspace:prod`
54
+ * was accepted, recorded, and silently inert: no human was ever asked. The ADR claimed the opposite in
55
+ * three places.
56
+ *
57
+ * A LIST rather than two variables on purpose. This is the third namespace whose authorising id is gated
58
+ * per-delegation rather than granted downward, and the first two were written as one special case each;
59
+ * a fourth should extend an array, not add a third `if` and a third field to thread through.
60
+ */
61
+ const authorisingCapabilities: Capability[] = [];
62
+ const gateAuthority = (authorising: Capability, wildcard: Capability) => {
63
+ if (input.gated.includes(authorising) || input.gated.includes(wildcard)) {
64
+ authorisingCapabilities.push(authorising);
54
65
  unapproved.gatedBlocked = [...unapproved.gatedBlocked, authorising];
55
66
  }
67
+ };
68
+ if (input.spawned) gateAuthority(agentCapability(input.spawned.name), AGENT_WILDCARD);
69
+ // Trusted: `boundWorkspaceId` is set only from a routing spec resolved against the operator registry,
70
+ // never from a model-supplied `correlation` claim (R-110).
71
+ if (input.boundWorkspaceId) {
72
+ gateAuthority(workspaceCapability(input.boundWorkspaceId), WORKSPACE_WILDCARD);
56
73
  }
57
74
 
58
75
  const potential = resolve({
@@ -92,8 +109,16 @@ export function resolveDelegationApproval(input: {
92
109
  gated: input.gated,
93
110
  approved: approvedCapabilities,
94
111
  });
95
- if (authorisingCapability && !approvedCapabilities.includes(authorisingCapability)) {
96
- result.gatedBlocked = [...result.gatedBlocked, authorisingCapability];
97
- }
112
+ // Filtered against what `resolve()` already listed, not just against approvals. In the ORDINARY chained
113
+ // configuration — route the child to `prod` AND grant it `workspace:prod` so it can route onward — the
114
+ // authorising id and the requested id are spelled identically, so appending unconditionally produced
115
+ // `gatedBlocked: ["workspace:prod","workspace:prod"]`, which reached the refusal text a model reads
116
+ // (*"workspace:prod, workspace:prod requires explicit approval"*) and the append-only ledger, whose schema
117
+ // has no `uniqueItems`. The old `agent:`-only code had the same shape and never hit it, because a
118
+ // self-recursive `agent:X` ceiling is not a thing anybody writes.
119
+ const stillGated = authorisingCapabilities.filter(
120
+ (c) => !approvedCapabilities.includes(c) && !result.gatedBlocked.includes(c),
121
+ );
122
+ if (stillGated.length > 0) result.gatedBlocked = [...result.gatedBlocked, ...stillGated];
98
123
  return { result, ...(approvalBinding ? { approvalBinding } : {}), bindingMismatch };
99
124
  }
package/src/executor.ts CHANGED
@@ -18,7 +18,8 @@ import type { HerdrProbe } from "./herdr-cli.ts";
18
18
 
19
19
  export const ENV_HERDR = "PI_GRANTS_HERDR";
20
20
 
21
- export type ExecutorKind = "herdr" | "process";
21
+ export const EXECUTOR_KINDS = ["process", "herdr"] as const;
22
+ export type ExecutorKind = typeof EXECUTOR_KINDS[number];
22
23
 
23
24
  export interface ExecutorChoice {
24
25
  kind: ExecutorKind;
package/src/grant-env.ts CHANGED
@@ -42,6 +42,12 @@ export const WITHHELD_BY_DEFAULT: readonly Capability[] = [
42
42
 
43
43
  /** Would `init` put this capability in the live grant? */
44
44
  export function isLiveByDefault(capability: Capability): boolean {
45
+ // No `workspace:<id>` is ever live by default (ADR-0035), and this is the same rule the list above states
46
+ // rather than a new one: authority "does not become live because a package asked for it". A registry id is
47
+ // a *choice of where a child runs*, which is the operator's to make and cannot be inferred from a
48
+ // declaration — ADR-0028's whole position. Enumerated ids are unbounded, so this is a namespace test and
49
+ // not a list membership.
50
+ if (capability.startsWith("workspace:")) return false;
45
51
  return !WITHHELD_BY_DEFAULT.includes(capability);
46
52
  }
47
53
 
@@ -87,6 +93,8 @@ export interface GrantEnvInput {
87
93
  withheld: Map<Capability, string[]>;
88
94
  /** Definitions whose `agent:` id is withheld because they need a withheld capability. */
89
95
  withheldDefinitions: string[];
96
+ /** `workspace:<id>` ids this project could route to (ADR-0035). Rendered commented, never granted. */
97
+ routableWorkspaces?: Capability[];
90
98
  /** `agent:<name>` ids a ceiling names that `init` did not write here. Reported, never granted. */
91
99
  crossReferences: { from: string; capability: Capability }[];
92
100
  cautions: string[];
@@ -140,13 +148,37 @@ export function renderGrantEnv(input: GrantEnvInput): string {
140
148
  for (const [capability, needed] of [...input.withheld].sort()) {
141
149
  lines.push(`# ${capability.padEnd(width)} (${needed.join(", ")})`);
142
150
  }
143
- if (input.withheldDefinitions.length > 0) {
144
- lines.push(
145
- `# …and then: ${input.withheldDefinitions.map((n) => `agent:${n}`).join(",")}`,
146
- "# Their `agent:` ids are withheld too: a definition that cannot receive what it declares would",
147
- "# be authorised to run and then refused, which is a worse answer than not being authorised.",
148
- );
149
- }
151
+ lines.push("#");
152
+ }
153
+
154
+ // OUTSIDE the block above, and that is the fix. This is the file's only statement that a withheld
155
+ // definition's `agent:` id must be granted too — and it used to render only when `withheld` was non-empty,
156
+ // so a package whose sole withheld capability is a ROUTING id (the common read-only routing case) lost it
157
+ // entirely once `workspace:` ids stopped going into that map. The reason line above still said "see below"
158
+ // and pointed at nothing.
159
+ if (input.withheldDefinitions.length > 0) {
160
+ lines.push(
161
+ `# …and then: ${input.withheldDefinitions.map((n) => `agent:${n}`).join(",")}`,
162
+ "# Their `agent:` ids are withheld too: a definition that cannot receive what it declares would",
163
+ "# be authorised to run and then refused, which is a worse answer than not being authorised.",
164
+ "#",
165
+ );
166
+ }
167
+
168
+ // ADR-0035. Routing became a capability in 0.19.0, which made every existing grant that routes start
169
+ // refusing — so the migration has to be visible from the file the operator already opens. Listed and NOT
170
+ // granted: which worktree a child starts in is the operator's decision and cannot be read off a
171
+ // declaration (ADR-0028), and granting one because a package named it is the "does not become live because
172
+ // a package asked for it" rule that `WITHHELD_BY_DEFAULT` above states.
173
+ if (input.routableWorkspaces && input.routableWorkspaces.length > 0) {
174
+ lines.push(
175
+ "# ROUTABLE WORKSPACES — routing a child to a registered worktree needs the id in PI_GRANTS_GRANT",
176
+ "# (ADR-0035, 0.19.0). Without it a delegation naming one is refused WORKSPACE_NOT_AUTHORIZED. Add the",
177
+ "# ones this project's children may start in; a child can only pass on ids it holds itself, so this is",
178
+ "# also the list of what any DESCENDANT could reach. Not granted for you: `workspace:*` exists but is",
179
+ "# held and never inherited, which makes it the wrong answer for anything but a single-worktree setup.",
180
+ );
181
+ for (const capability of input.routableWorkspaces) lines.push(`# ${capability}`);
150
182
  lines.push("#");
151
183
  }
152
184
 
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ export {
11
11
  export {
12
12
  appendLedgerEvent,
13
13
  appendRecord,
14
+ buildCheckReceiptLedgerEvent,
14
15
  buildChildLifecycleEvent,
15
16
  buildRecord,
16
17
  buildWorkspaceLeaseEvent,
package/src/init.ts CHANGED
@@ -24,7 +24,7 @@
24
24
 
25
25
  import { mkdir, open, rm, writeFile } from "node:fs/promises";
26
26
  import { join } from "node:path";
27
- import { agentCapability } from "./capabilities.ts";
27
+ import { agentCapability, workspaceCapability } from "./capabilities.ts";
28
28
  import { ceilingForDefinition } from "./definitions.ts";
29
29
  import { ALWAYS_LIVE, assertGrantIsWritable, isLiveByDefault, renderGrantEnv, type GrantEnvSkill } from "./grant-env.ts";
30
30
  import { PI_BUILTIN_TOOLS } from "./pi-tools.ts";
@@ -78,6 +78,8 @@ export interface InitPlan {
78
78
  grant: Capability[];
79
79
  /** Withheld capability → the definitions that declared it (ADR-0029). Emitted commented. */
80
80
  withheldCapabilities: Map<Capability, string[]>;
81
+ /** `workspace:<id>` this project could route to (ADR-0035). Always commented — `init` does not choose. */
82
+ routableWorkspaces: Capability[];
81
83
  grantEnvPath: string;
82
84
  grantEnvContent: string;
83
85
  /** Capabilities a declared ceiling names that pi 0.84.1 has no tool for — a caution, not a verdict. */
@@ -143,7 +145,20 @@ function unknownToolIds(capabilities: Capability[]): Capability[] {
143
145
  * tools at all and the whole file is inert. Everything else is emitted commented, named, and one uncomment
144
146
  * away (ADR-0029).
145
147
  */
146
- export function planInit(packages: SkillPackage[], cwd: string): InitPlan {
148
+ export function planInit(
149
+ packages: SkillPackage[],
150
+ cwd: string,
151
+ /**
152
+ * Ids from the operator's workspace registry, when one is configured — read by the CALLER, because
153
+ * `planInit` is pure and stays that way.
154
+ *
155
+ * ADR-0035 made routing a capability and said `init` "scaffolds the registered ids so the common path is a
156
+ * one-line grant edit". It did not: `init` had never heard of the registry, so the ADR's own stated
157
+ * migration path for a breaking change did not exist. These are emitted **commented**, never live —
158
+ * offering the ids while refusing to choose among them is exactly ADR-0028's position.
159
+ */
160
+ registeredWorkspaceIds: readonly string[] = [],
161
+ ): InitPlan {
147
162
  const skills: PlannedSkill[] = [];
148
163
  const collisions: string[] = [];
149
164
  const seen = new Set<string>();
@@ -190,6 +205,20 @@ export function planInit(packages: SkillPackage[], cwd: string): InitPlan {
190
205
  for (const skill of declared) {
191
206
  for (const capability of skill.ceiling) {
192
207
  if (isLiveByDefault(capability)) continue;
208
+ // A routing destination is withheld but does NOT belong in this map, and the difference is not
209
+ // cosmetic. This map drives two things: the "WITHHELD BY DEFAULT — these can change your machine"
210
+ // block, and `/grants init`'s dialog. Review found a `workspace:` id reaching both — described to the
211
+ // operator with `tool:bash`'s rationale (routing does not change your machine, and unlike `bash` it
212
+ // *is* gateable), and then granted **live and persisted** on one "Yes", off a third-party package's
213
+ // declaration. That is the rule `grant-env.ts` states — "does not become live because a package asked
214
+ // for it" — honoured by the rendered file and broken by the dialog beside it: two surfaces of one
215
+ // command disagreeing, which is R-28's shape inside the fix for R-28.
216
+ //
217
+ // Which worktree a child starts in is not derivable from a declaration (ADR-0028), so `init` lists
218
+ // routing and never grants it. `routableWorkspaces` below is where these go; the definition that
219
+ // declared one still loses its live `agent:` id via the `needs-withheld` pass, so nothing becomes
220
+ // spawnable behind the operator's back either.
221
+ if (capability.startsWith("workspace:")) continue;
193
222
  withheldCapabilities.set(capability, [...(withheldCapabilities.get(capability) ?? []), skill.name]);
194
223
  }
195
224
  }
@@ -246,11 +275,19 @@ export function planInit(packages: SkillPackage[], cwd: string): InitPlan {
246
275
  ...(s.withheld ? { unspawnable: describe[s.withheld](s) } : {}),
247
276
  }));
248
277
 
278
+ // Registry ids the operator could route to, plus any a copied definition actually declares — a package
279
+ // naming `workspace:prod` is evidence that id matters here, and it must still be uncommented by hand.
280
+ const declaredWorkspaces = skills.flatMap((s) => s.ceiling.filter((c) => c.startsWith("workspace:")));
281
+ const routableWorkspaces = [
282
+ ...new Set([...registeredWorkspaceIds.map(workspaceCapability), ...declaredWorkspaces]),
283
+ ].sort();
284
+
249
285
  return {
250
286
  skills,
251
287
  collisions,
252
288
  grant,
253
289
  withheldCapabilities,
290
+ routableWorkspaces,
254
291
  grantEnvPath: join(cwd, ".pi", "grants.env"),
255
292
  grantEnvContent: renderGrantEnv({
256
293
  skills: grantEnvSkills,
@@ -259,6 +296,7 @@ export function planInit(packages: SkillPackage[], cwd: string): InitPlan {
259
296
  withheldDefinitions: skills.filter((s) => s.withheld === "needs-withheld").map((s) => s.name),
260
297
  crossReferences,
261
298
  cautions,
299
+ routableWorkspaces,
262
300
  }),
263
301
  cautions,
264
302
  };