pi-daddy 0.18.1 → 0.20.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 (218) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/README.md +136 -23
  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/contracts/ledger/v3/README.md +36 -0
  10. package/contracts/ledger/v3/fixtures/capability-decision.json +98 -0
  11. package/contracts/ledger/v3/fixtures/check-receipt.json +42 -0
  12. package/contracts/ledger/v3/fixtures/child-lifecycle.json +45 -0
  13. package/contracts/ledger/v3/fixtures/workflow-fact.json +41 -0
  14. package/contracts/ledger/v3/fixtures/workspace-lease.json +43 -0
  15. package/contracts/ledger/v3/ledger-event.schema.json +930 -0
  16. package/dist/approval-prompt.d.ts +2 -1
  17. package/dist/approval-prompt.d.ts.map +1 -1
  18. package/dist/approval-prompt.js +9 -0
  19. package/dist/approval-prompt.js.map +1 -1
  20. package/dist/approval.d.ts +4 -2
  21. package/dist/approval.d.ts.map +1 -1
  22. package/dist/approval.js +4 -0
  23. package/dist/approval.js.map +1 -1
  24. package/dist/capabilities.d.ts +90 -0
  25. package/dist/capabilities.d.ts.map +1 -1
  26. package/dist/capabilities.js +114 -3
  27. package/dist/capabilities.js.map +1 -1
  28. package/dist/catalog.d.ts +15 -1
  29. package/dist/catalog.d.ts.map +1 -1
  30. package/dist/catalog.js +54 -3
  31. package/dist/catalog.js.map +1 -1
  32. package/dist/check-runner.d.ts.map +1 -1
  33. package/dist/check-runner.js +19 -7
  34. package/dist/check-runner.js.map +1 -1
  35. package/dist/cli.d.ts.map +1 -1
  36. package/dist/cli.js +21 -1
  37. package/dist/cli.js.map +1 -1
  38. package/dist/correlation.d.ts.map +1 -1
  39. package/dist/correlation.js +10 -0
  40. package/dist/correlation.js.map +1 -1
  41. package/dist/dashboard-cli.d.ts +18 -0
  42. package/dist/dashboard-cli.d.ts.map +1 -0
  43. package/dist/dashboard-cli.js +154 -0
  44. package/dist/dashboard-cli.js.map +1 -0
  45. package/dist/dashboard-handshake.d.ts +37 -0
  46. package/dist/dashboard-handshake.d.ts.map +1 -0
  47. package/dist/dashboard-handshake.js +127 -0
  48. package/dist/dashboard-handshake.js.map +1 -0
  49. package/dist/dashboard-herdr.d.ts +54 -0
  50. package/dist/dashboard-herdr.d.ts.map +1 -0
  51. package/dist/dashboard-herdr.js +286 -0
  52. package/dist/dashboard-herdr.js.map +1 -0
  53. package/dist/dashboard-projection.d.ts +73 -0
  54. package/dist/dashboard-projection.d.ts.map +1 -0
  55. package/dist/dashboard-projection.js +294 -0
  56. package/dist/dashboard-projection.js.map +1 -0
  57. package/dist/dashboard-render.d.ts +10 -0
  58. package/dist/dashboard-render.d.ts.map +1 -0
  59. package/dist/dashboard-render.js +208 -0
  60. package/dist/dashboard-render.js.map +1 -0
  61. package/dist/definitions.d.ts.map +1 -1
  62. package/dist/definitions.js +7 -1
  63. package/dist/definitions.js.map +1 -1
  64. package/dist/delegate-types.d.ts +6 -2
  65. package/dist/delegate-types.d.ts.map +1 -1
  66. package/dist/delegate-types.js.map +1 -1
  67. package/dist/delegate.d.ts.map +1 -1
  68. package/dist/delegate.js +34 -4
  69. package/dist/delegate.js.map +1 -1
  70. package/dist/delegation-approval.d.ts.map +1 -1
  71. package/dist/delegation-approval.js +37 -12
  72. package/dist/delegation-approval.js.map +1 -1
  73. package/dist/execution-id.d.ts +6 -0
  74. package/dist/execution-id.d.ts.map +1 -0
  75. package/dist/execution-id.js +13 -0
  76. package/dist/execution-id.js.map +1 -0
  77. package/dist/executor.d.ts +2 -1
  78. package/dist/executor.d.ts.map +1 -1
  79. package/dist/executor.js +1 -0
  80. package/dist/executor.js.map +1 -1
  81. package/dist/grant-env.d.ts +2 -0
  82. package/dist/grant-env.d.ts.map +1 -1
  83. package/dist/grant-env.js +26 -3
  84. package/dist/grant-env.js.map +1 -1
  85. package/dist/index.d.ts +4 -1
  86. package/dist/index.d.ts.map +1 -1
  87. package/dist/index.js +4 -1
  88. package/dist/index.js.map +1 -1
  89. package/dist/init.d.ts +13 -1
  90. package/dist/init.d.ts.map +1 -1
  91. package/dist/init.js +35 -2
  92. package/dist/init.js.map +1 -1
  93. package/dist/lease-helper.d.ts +58 -0
  94. package/dist/lease-helper.d.ts.map +1 -0
  95. package/dist/lease-helper.js +94 -0
  96. package/dist/lease-helper.js.map +1 -0
  97. package/dist/lease-record.d.ts +15 -3
  98. package/dist/lease-record.d.ts.map +1 -1
  99. package/dist/lease-record.js.map +1 -1
  100. package/dist/ledger-events.d.ts +59 -15
  101. package/dist/ledger-events.d.ts.map +1 -1
  102. package/dist/ledger-events.js +67 -5
  103. package/dist/ledger-events.js.map +1 -1
  104. package/dist/ledger-identifiers.d.ts +7 -0
  105. package/dist/ledger-identifiers.d.ts.map +1 -0
  106. package/dist/ledger-identifiers.js +20 -0
  107. package/dist/ledger-identifiers.js.map +1 -0
  108. package/dist/ledger-report.d.ts +5 -2
  109. package/dist/ledger-report.d.ts.map +1 -1
  110. package/dist/ledger-report.js +38 -14
  111. package/dist/ledger-report.js.map +1 -1
  112. package/dist/ledger-v3-validation.d.ts +11 -0
  113. package/dist/ledger-v3-validation.d.ts.map +1 -0
  114. package/dist/ledger-v3-validation.js +265 -0
  115. package/dist/ledger-v3-validation.js.map +1 -0
  116. package/dist/ledger.d.ts +27 -9
  117. package/dist/ledger.d.ts.map +1 -1
  118. package/dist/ledger.js +38 -6
  119. package/dist/ledger.js.map +1 -1
  120. package/dist/propagation.d.ts +19 -1
  121. package/dist/propagation.d.ts.map +1 -1
  122. package/dist/propagation.js +23 -1
  123. package/dist/propagation.js.map +1 -1
  124. package/dist/refusals.d.ts +1 -1
  125. package/dist/refusals.d.ts.map +1 -1
  126. package/dist/refusals.js +1 -0
  127. package/dist/refusals.js.map +1 -1
  128. package/dist/resolve.d.ts +10 -0
  129. package/dist/resolve.d.ts.map +1 -1
  130. package/dist/resolve.js +27 -3
  131. package/dist/resolve.js.map +1 -1
  132. package/dist/routing-authority.d.ts +71 -0
  133. package/dist/routing-authority.d.ts.map +1 -0
  134. package/dist/routing-authority.js +100 -0
  135. package/dist/routing-authority.js.map +1 -0
  136. package/dist/run-child.d.ts +3 -1
  137. package/dist/run-child.d.ts.map +1 -1
  138. package/dist/run-child.js +30 -2
  139. package/dist/run-child.js.map +1 -1
  140. package/dist/run-herdr.d.ts +2 -0
  141. package/dist/run-herdr.d.ts.map +1 -1
  142. package/dist/run-herdr.js +8 -0
  143. package/dist/run-herdr.js.map +1 -1
  144. package/dist/skill-packages.d.ts +11 -5
  145. package/dist/skill-packages.d.ts.map +1 -1
  146. package/dist/skill-packages.js +20 -11
  147. package/dist/skill-packages.js.map +1 -1
  148. package/dist/workflow-fact-id.d.ts +4 -0
  149. package/dist/workflow-fact-id.d.ts.map +1 -0
  150. package/dist/workflow-fact-id.js +14 -0
  151. package/dist/workflow-fact-id.js.map +1 -0
  152. package/dist/workflow-facts.d.ts +34 -0
  153. package/dist/workflow-facts.d.ts.map +1 -0
  154. package/dist/workflow-facts.js +43 -0
  155. package/dist/workflow-facts.js.map +1 -0
  156. package/dist/workspace-lease.d.ts +15 -3
  157. package/dist/workspace-lease.d.ts.map +1 -1
  158. package/dist/workspace-lease.js +81 -24
  159. package/dist/workspace-lease.js.map +1 -1
  160. package/dist/workspace.d.ts +25 -0
  161. package/dist/workspace.d.ts.map +1 -1
  162. package/dist/workspace.js +142 -5
  163. package/dist/workspace.js.map +1 -1
  164. package/extensions/chain-ledger.ts +4 -0
  165. package/extensions/chain-plan.ts +98 -0
  166. package/extensions/delegate-chain.ts +63 -122
  167. package/extensions/delegation-ledger.ts +60 -0
  168. package/extensions/delegation.ts +20 -14
  169. package/extensions/execute-child.ts +85 -21
  170. package/extensions/execution-occurrence.ts +20 -0
  171. package/extensions/grants-command.ts +33 -4
  172. package/extensions/grants.ts +50 -1
  173. package/extensions/init-command.ts +33 -2
  174. package/extensions/run-delegation.ts +36 -56
  175. package/extensions/session-report.ts +13 -20
  176. package/extensions/session.ts +16 -3
  177. package/extensions/workspace-runtime.ts +44 -4
  178. package/herdr-plugin/herdr-plugin.toml +18 -0
  179. package/package.json +22 -5
  180. package/src/approval-prompt.ts +2 -1
  181. package/src/approval.ts +4 -2
  182. package/src/capabilities.ts +120 -4
  183. package/src/catalog.ts +62 -4
  184. package/src/check-runner.ts +24 -8
  185. package/src/cli.ts +24 -1
  186. package/src/correlation.ts +11 -0
  187. package/src/dashboard-cli.ts +171 -0
  188. package/src/dashboard-handshake.ts +185 -0
  189. package/src/dashboard-herdr.ts +355 -0
  190. package/src/dashboard-projection.ts +390 -0
  191. package/src/dashboard-render.ts +254 -0
  192. package/src/definitions.ts +7 -1
  193. package/src/delegate-types.ts +6 -2
  194. package/src/delegate.ts +43 -4
  195. package/src/delegation-approval.ts +37 -12
  196. package/src/execution-id.ts +18 -0
  197. package/src/executor.ts +2 -1
  198. package/src/grant-env.ts +39 -7
  199. package/src/index.ts +26 -0
  200. package/src/init.ts +40 -2
  201. package/src/lease-helper.ts +97 -0
  202. package/src/lease-record.ts +15 -3
  203. package/src/ledger-events.ts +116 -28
  204. package/src/ledger-identifiers.ts +21 -0
  205. package/src/ledger-report.ts +40 -19
  206. package/src/ledger-v3-validation.ts +266 -0
  207. package/src/ledger.ts +75 -12
  208. package/src/propagation.ts +24 -1
  209. package/src/refusals.ts +1 -0
  210. package/src/resolve.ts +29 -3
  211. package/src/routing-authority.ts +121 -0
  212. package/src/run-child.ts +35 -3
  213. package/src/run-herdr.ts +9 -0
  214. package/src/skill-packages.ts +20 -13
  215. package/src/workflow-fact-id.ts +16 -0
  216. package/src/workflow-facts.ts +65 -0
  217. package/src/workspace-lease.ts +84 -24
  218. package/src/workspace.ts +179 -6
@@ -60,6 +60,8 @@ export interface GrantsCommandContext {
60
60
  * from the caller, visibly.
61
61
  */
62
62
  runInit: () => Promise<void>;
63
+ /** Open or reuse the read-only Herdr dashboard; injected so this diagnostic never becomes enforcement. */
64
+ openDashboard: () => Promise<{ kind: "opened" | "reused"; paneId: string; visibleBesideCaller: boolean }>;
63
65
  }
64
66
 
65
67
  /**
@@ -69,12 +71,12 @@ export interface GrantsCommandContext {
69
71
  const PREVIEW_LIMIT = 12;
70
72
 
71
73
  /** The verbs `/grants` answers to. Anything else is refused rather than silently treated as no verb. */
72
- const KNOWN_SUBCOMMANDS: readonly string[] = ["init", "ledger", "approvals", "revoke"];
74
+ const KNOWN_SUBCOMMANDS: readonly string[] = ["init", "dashboard", "ledger", "approvals", "revoke"];
73
75
 
74
76
  export const grantsCommand = {
75
77
  description:
76
78
  "Show this session's capability grant, delegation depth, and known agent-type ceilings; " +
77
- "/grants approvals | /grants ledger | /grants revoke <key>|--all",
79
+ "/grants dashboard | /grants approvals | /grants ledger | /grants revoke <key>|--all",
78
80
  handler: async (args: string, ctx: any) => {
79
81
  // Everything this command may see, named in one place. Previously these were whatever happened to be in
80
82
  // the enclosing closure — which is how a diagnostic came to disagree with the enforcer (R-28).
@@ -90,6 +92,20 @@ handler: async (args: string, ctx: any) => {
90
92
  return;
91
93
  }
92
94
 
95
+ if (sub === "dashboard") {
96
+ try {
97
+ const opened = await ctx.grants.openDashboard();
98
+ ctx.ui.notify(
99
+ `grants: dashboard ${opened.kind} in Herdr pane ${opened.paneId} without changing focus` +
100
+ (opened.visibleBesideCaller ? "." : " (the existing pane is in another tab)."),
101
+ "info",
102
+ );
103
+ } catch (error) {
104
+ ctx.ui.notify(`grants: dashboard unavailable — ${error instanceof Error ? error.message : String(error)}`, "error");
105
+ }
106
+ return;
107
+ }
108
+
93
109
  if (sub === "ledger") {
94
110
  // The detector, made reachable. `verifyLedger` exists because nothing in this package had ever read
95
111
  // a ledger back, so a torn line was indistinguishable from a spawn that never happened — and a
@@ -109,7 +125,7 @@ handler: async (args: string, ctx: any) => {
109
125
  ` escalation ${report.escalationAttempts} attempt(s) — grants refused for exceeding what the session held`,
110
126
  ` integrity ${report.ok ? "OK" : `${report.corrupt.length} UNPARSEABLE LINE(S)`}`,
111
127
  ];
112
- for (const bad of report.corrupt.slice(0, 5)) lines.push(` line ${bad.line}: ${bad.text}`);
128
+ for (const bad of report.corrupt.slice(0, 5)) lines.push(` line ${bad.line}: ${bad.reason}`);
113
129
  // ADR-0031, and R-51's lesson applied on the day the field was added rather than a release later: a field
114
130
  // the writer sets and no diagnostic reads is one that needs `jq`, and `docs/SPEC.md` claims the executor is
115
131
  // announced "per child in the ledger". `unknown` is shown only when present, because on a fresh ledger it
@@ -121,6 +137,9 @@ handler: async (args: string, ctx: any) => {
121
137
  ? ` · ${report.executors.unknown} not recorded (written before 0.16.0, which added the field)`
122
138
  : ""),
123
139
  );
140
+ if (report.workflowFacts > 0) {
141
+ lines.push(` workflow ${report.workflowFacts} provenance-labelled fact(s) — not counted as enforced children`);
142
+ }
124
143
  // R-51. ADR-0018 advertises that the ledger answers "did these four children run the same
125
144
  // instructions?" and "has this definition changed since?" — and nothing read `definitionDigest`, so
126
145
  // both needed hand-written jq and the second was not reproducible with `sha256sum` (the digest covers
@@ -330,9 +349,19 @@ handler: async (args: string, ctx: any) => {
330
349
  ` approvals ${sessionApprovals.size} this session, ${valid.size} persisted` +
331
350
  `${inheritedApprovals.size > 0 ? `, ${inheritedApprovals.size} inherited` : ""}` +
332
351
  ` — /grants approvals`,
352
+ // Every kind the catalog can hold is named, and the numbers add up to the total on purpose. `workspace`
353
+ // was missing when ADR-0035's review added the kind: the total counted them and the breakdown did not,
354
+ // so `catalog 16 capabilities — 9 builtin, 0 extension, 2 skill, 2 agent-type` printed a sum of 13. And
355
+ // the comment that justified putting workspaces in the catalog said it was "for `/grants` to list what
356
+ // this session may route to", which nothing then did — the claim was in the commit and the behaviour
357
+ // was not. A kind added without a term here silently stops adding up.
333
358
  ` catalog ${catalog.all.length} capabilities — ` +
334
359
  `${catalog.byKind("builtin").length} builtin, ${catalog.byKind("extension").length} extension, ` +
335
- `${catalog.byKind("skill").length} skill, ${catalog.byKind("agentType").length} agent-type`,
360
+ `${catalog.byKind("skill").length} skill, ${catalog.byKind("agentType").length} agent-type, ` +
361
+ `${catalog.byKind("workspace").length} workspace`,
362
+ ...(catalog.byKind("workspace").length > 0
363
+ ? [` routable ${catalog.byKind("workspace").join(", ")} — held ones only are usable (ADR-0035)`]
364
+ : []),
336
365
  ];
337
366
  // Runs the REAL planner AND the real approval step over each definition, so this listing cannot
338
367
  // disagree with what a spawn would do — the R-28 lesson, kept structural rather than remembered.
@@ -22,9 +22,12 @@
22
22
  */
23
23
 
24
24
  import { fileURLToPath } from "node:url";
25
+ import { homedir } from "node:os";
26
+ import { join, resolve } from "node:path";
25
27
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
26
28
  import { WILDCARD } from "../src/pi-tools.ts";
27
29
  import { buildCatalog } from "../src/catalog.ts";
30
+ import { ENV_WORKSPACE_REGISTRY } from "../src/workspace.ts";
28
31
  import { appendRecord, buildRecord } from "../src/ledger.ts";
29
32
  import { openPaneCount, reapOpenPanesAsync } from "../src/pane-reaper.ts";
30
33
  import {
@@ -37,6 +40,11 @@ import { planWithApprovals } from "./run-delegation.ts";
37
40
  import { createGrantsSession, loadProjectDefinitions, resolveExecutor, type GrantsSession } from "./session.ts";
38
41
  import { reportSessionStart } from "./session-report.ts";
39
42
  import { SPAWN_TOOLS, tripwireReason } from "./tripwire.ts";
43
+ import {
44
+ defaultDashboardPaths,
45
+ offerDashboardHandshake,
46
+ openDashboardCommand,
47
+ } from "../src/dashboard-handshake.ts";
40
48
 
41
49
  export default function (pi: ExtensionAPI) {
42
50
  // The path pi loads as the extension, so a child granted `tool:delegate` can be started with `-e <this>`.
@@ -50,6 +58,10 @@ export default function (pi: ExtensionAPI) {
50
58
  })();
51
59
 
52
60
  const session = createGrantsSession(extensionPath);
61
+ const dashboardPluginRoot = fileURLToPath(new URL("../herdr-plugin/", import.meta.url));
62
+ const dashboardPaths = defaultDashboardPaths(
63
+ process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), ".pi", "agent"),
64
+ );
53
65
 
54
66
  // Filled in by `registerDelegationTools` at the bottom of this function. A holder rather than a reordering,
55
67
  // because the hooks below have to be registered before the tools and both need to call it — the tool
@@ -58,6 +70,10 @@ export default function (pi: ExtensionAPI) {
58
70
 
59
71
  pi.on("session_start", async (_event, ctx) => {
60
72
  session.cwd = ctx.cwd;
73
+ // A routed child changes cwd. Publishing a relative ledger string unchanged therefore fragments one
74
+ // execution tree across workspaces. Resolve it once at the root's actual pi cwd; descendants inherit
75
+ // this absolute identity verbatim, and resolve(abs) remains abs at every deeper session start.
76
+ if (session.ledgerPath) session.ledgerPath = resolve(ctx.cwd, session.ledgerPath);
61
77
  // The one case the stored-grant lookup can get wrong (ADR-0030). The factory reads the store keyed by
62
78
  // `process.cwd()` because it runs before any hook and therefore before `ctx` exists — and S-5 forces
63
79
  // that ordering, since whether `delegate` is registered is decided there. Almost always the two agree.
@@ -151,6 +167,27 @@ export default function (pi: ExtensionAPI) {
151
167
  "warning",
152
168
  );
153
169
  }
170
+ // Explicit installation handshake. It only runs in a Herdr-hosted TUI, asks once, and the only branch
171
+ // that links software is the literal human choice "Install and open".
172
+ try {
173
+ await offerDashboardHandshake({
174
+ mode: ctx.mode,
175
+ env: process.env,
176
+ pid: process.pid,
177
+ cwd: ctx.cwd,
178
+ ledgerPath: session.ledgerPath,
179
+ pluginRoot: dashboardPluginRoot,
180
+ preferencePath: dashboardPaths.preferencePath,
181
+ paneStatePath: dashboardPaths.paneStatePath,
182
+ ui: ctx.ui,
183
+ });
184
+ } catch (error) {
185
+ ctx.ui.notify(
186
+ `grants: dashboard installation handshake could not run (${error instanceof Error ? error.message : String(error)}). ` +
187
+ `Nothing was installed; /grants dashboard will report manual setup.`,
188
+ "warning",
189
+ );
190
+ }
154
191
  } catch (error) {
155
192
  // Rule 8 — fail closed, and be LOUD about it. Swallowing is still right: a startup fault must not
156
193
  // reach the agent loop. Swallowing SILENTLY is what let R-60 exist, and would let the next one exist
@@ -216,7 +253,11 @@ export default function (pi: ExtensionAPI) {
216
253
  // Keep the handle: a concurrent `delegate` awaits this rather than reading a half-built catalog.
217
254
  // The `catch` resolves to the CURRENT catalog rather than rejecting, so a failed refresh degrades
218
255
  // to the previous view instead of failing every delegation in the session.
219
- session.catalogReady = buildCatalog({ cwd: session.cwd, observedTools: names })
256
+ session.catalogReady = buildCatalog({
257
+ cwd: session.cwd,
258
+ observedTools: names,
259
+ registryPath: process.env[ENV_WORKSPACE_REGISTRY],
260
+ })
220
261
  .then((c) => (session.catalog = c))
221
262
  .catch(() => session.catalog);
222
263
  } catch {
@@ -311,6 +352,14 @@ export default function (pi: ExtensionAPI) {
311
352
  await loadProjectDefinitions(session, ctx.cwd);
312
353
  delegation.refreshSpawnable();
313
354
  }),
355
+ openDashboard: () => openDashboardCommand({
356
+ env: process.env,
357
+ pid: process.pid,
358
+ cwd: ctx.cwd,
359
+ ledgerPath: session.ledgerPath,
360
+ pluginRoot: dashboardPluginRoot,
361
+ paneStatePath: dashboardPaths.paneStatePath,
362
+ }),
314
363
  previewDelegation: (name: string) =>
315
364
  planWithApprovals(session, { task: "(preview)", agent: name }, {}, null),
316
365
  },
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { discoverSkillPackages } from "../src/skill-packages.ts";
11
11
  import { applyInit, planInit } from "../src/init.ts";
12
+ import { registeredWorkspaceIds } from "../src/workspace.ts";
12
13
  import { saveGrant, grantStorePath } from "../src/grant-store.ts";
13
14
  import { expandSubsumed, SUBSUMPTION, type Capability } from "../src/resolve.ts";
14
15
  import type { GrantsSession } from "./session.ts";
@@ -50,7 +51,7 @@ export async function runInit(
50
51
  return;
51
52
  }
52
53
 
53
- const plan = planInit(packages, ctx.cwd);
54
+ const plan = planInit(packages, ctx.cwd, await registeredWorkspaceIds());
54
55
  const outcome = await applyInit(plan);
55
56
  const lines = [
56
57
  `grants: ${plan.skills.length} definition(s) from ${packages.map((p) => `${p.name}@${p.version}`).join(", ")}`,
@@ -81,9 +82,28 @@ export async function runInit(
81
82
  for (const name of neededBy) grant.add(`agent:${name}` as Capability);
82
83
  continue;
83
84
  }
85
+ // The consequence sentence is per capability, and it reads the gate **in effect for this session** —
86
+ // `session.gated`, which is `PI_GRANTS_GATED` when the operator set it and `DEFAULT_GATED` otherwise.
87
+ //
88
+ // The first version read `DEFAULT_GATED` directly, which is the compile-time constant `["tool:bash"]`, and
89
+ // `tool:bash` is consumed by the branch above — so the branch was **unreachable in every configuration**,
90
+ // and with `PI_GRANTS_GATED="tool:bash,tool:write"` (the value `renderGrantEnv` itself suggests) the
91
+ // dialog still printed the exact false sentence the fix claimed to remove. Dead code beside a claim that
92
+ // it worked, which is this project's failure mode, in the commit correcting that failure mode.
93
+ // No subsumption closure here, and the absence is deliberate. A `|| SUBSUMPTION[capability]…` disjunct was
94
+ // added and is **dead in every configuration**: `SUBSUMPTION` has one key, `tool:bash`, and `tool:bash` is
95
+ // consumed by the ternary below before `gatedAtSpawn` is ever read. Two reviewers and a mutation confirmed
96
+ // it changed no result — dead code beside a claim that it worked, in the hunk whose comment denounces
97
+ // exactly that. A second `SUBSUMPTION` entry is the moment to add it back.
98
+ const gatedAtSpawn = session.gated.includes(capability);
84
99
  const answer = await ctx.ui.select(
85
100
  `grants: grant ${capability} to sub-agents?\n needed by: ${neededBy.join(", ")}\n` +
86
- ` this can change your machine — ${capability === "tool:bash" ? "and bash also confers write and edit" : "it is not gated, so no dialog at spawn time"}`,
101
+ ` this can change your machine — ` +
102
+ (capability === "tool:bash"
103
+ ? "and bash also confers write and edit"
104
+ : gatedAtSpawn
105
+ ? "a human is still asked at spawn time"
106
+ : "it is not gated, so no dialog at spawn time"),
87
107
  ["No", "Yes"],
88
108
  );
89
109
  if (answer === "Yes") {
@@ -121,6 +141,17 @@ export async function runInit(
121
141
  lines.push(` ALREADY CONFERRED, not asked about: ${alreadyConferred.join(", ")}`);
122
142
  }
123
143
  if (declined.length > 0) lines.push(` withheld: ${declined.join("; ")}`);
144
+ // Routing is LISTED, never granted (ADR-0028) — so the operator has to be told it exists, or the listing is
145
+ // invisible to anyone running `/grants init` in-session rather than reading the generated file. 0.19.0 made
146
+ // `workspace:<id>` mandatory for routing, so this is also the migration hint, and a breaking change whose
147
+ // migration is only discoverable by opening a file is not much of a migration.
148
+ if (plan.routableWorkspaces.length > 0) {
149
+ lines.push(
150
+ ` ROUTABLE WORKSPACES, listed and NOT granted: ${plan.routableWorkspaces.join(", ")}`,
151
+ ` routing a child to one needs its id in the grant (ADR-0035); add the ones this project may use to ` +
152
+ `${plan.grantEnvPath}. Which worktree a child starts in is not something a package can declare for you.`,
153
+ );
154
+ }
124
155
  lines.push(` live now (${finalGrant.length} capabilities) — no restart. /grants shows the verdicts.`);
125
156
  ctx.ui.notify(lines.join("\n"), "info");
126
157
  }
@@ -13,7 +13,6 @@
13
13
 
14
14
  import { DELEGATE_SUBJECT, shouldSeekApproval } from "../src/approval.ts";
15
15
  import { planDelegation } from "../src/delegate.ts";
16
- import { appendRecord, buildRecord } from "../src/ledger.ts";
17
16
  import {
18
17
  obtainApprovals,
19
18
  republishable,
@@ -27,6 +26,8 @@ import type { GrantsSession } from "./session.ts";
27
26
  import type { CorrelationMetadata } from "../src/correlation.ts";
28
27
  import { GovernanceRefusal, refusal as structuredRefusal } from "../src/refusals.ts";
29
28
  import { executePlannedChild, type DelegationOutcome } from "./execute-child.ts";
29
+ import { recordDelegationDecision, type ApprovalLedgerFacts } from "./delegation-ledger.ts";
30
+ import type { ExecutionOccurrenceIds } from "./execution-occurrence.ts";
30
31
  import {
31
32
  governedWorkspaceAccess,
32
33
  prepareDelegationWorkspace,
@@ -178,7 +179,7 @@ export async function planWithApprovals(
178
179
  export async function runOneDelegation(
179
180
  session: GrantsSession,
180
181
  spec: ChildSpec,
181
- ids: { parentId: string; childId: string },
182
+ ids: ExecutionOccurrenceIds,
182
183
  budget: number | undefined,
183
184
  ctx: DelegationToolContext,
184
185
  signal: AbortSignal | undefined,
@@ -207,7 +208,7 @@ export async function runOneDelegation(
207
208
  paneId?: string;
208
209
  /** The name herdr actually knows this child by — minted in `runHerdrPane`, so it cannot be derived. */
209
210
  agentName?: string;
210
- state?: "running" | "completed" | "failed";
211
+ state?: "starting" | "running" | "completed" | "failed";
211
212
  }) => void;
212
213
  /** Approvals already obtained by the caller — see `planWithApprovals`. `delegate_chain` uses it. */
213
214
  preApproved?: InheritableApproval[];
@@ -218,6 +219,8 @@ export async function runOneDelegation(
218
219
  * the question the chain's framed-rather-than-enforced handoff makes worth asking.
219
220
  */
220
221
  taskFrom?: string;
222
+ /** Unique occurrence whose output composed this task; separate from the logical taskFrom position. */
223
+ taskFromExecutionId?: string;
221
224
  /**
222
225
  * What the caller's own gate decided, for the LEDGER — not for the plan.
223
226
  *
@@ -228,10 +231,10 @@ export async function runOneDelegation(
228
231
  * where nothing was ever gated — `/grants ledger` counted it in neither `bySource` nor `unattributed`, so it did
229
232
  * not even show up as a gap, and ADR-0010's compensating control was blind to every chain step.
230
233
  */
231
- approvalFacts?: Pick<ApprovalOutcome, "approved" | "sources" | "scopes" | "expiresAt" | "uses" | "humanDenied">;
234
+ approvalFacts?: ApprovalLedgerFacts;
232
235
  } = {},
233
236
  ): Promise<DelegationOutcome> {
234
- const { onProgress, preApproved, taskFrom, approvalFacts } = options;
237
+ const { onProgress, preApproved, taskFrom, taskFromExecutionId, approvalFacts } = options;
235
238
  // pi resolves a BARE model id to an unauthenticated provider and the child dies at startup — the id
236
239
  // alone is not enough, it must be qualified with its provider (`Model<Api>` carries both).
237
240
  const defaultModel = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
@@ -249,7 +252,12 @@ export async function runOneDelegation(
249
252
  boundWorkspaceId: spec.workspace?.workspace_id,
250
253
  boundContextId: spec.correlation?.context_id,
251
254
  };
252
- const extra = { fanoutBudget: budget, spawnId: ids.parentId, childSpawnId: ids.childId };
255
+ const extra = {
256
+ fanoutBudget: budget,
257
+ spawnId: ids.parentId,
258
+ childSpawnId: ids.childId,
259
+ childExecutionId: ids.executionId,
260
+ };
253
261
 
254
262
  // ADR-0031: herdr was DEMANDED (`PI_GRANTS_HERDR=1`) and is not answering. Refused rather than relocated —
255
263
  // the operator chose that over falling back, so the ledger can never name a child that ran somewhere nobody
@@ -269,7 +277,6 @@ export async function runOneDelegation(
269
277
  let preparedWorkspace: PreparedWorkspace | undefined;
270
278
  let approvalOutcome: ApprovalOutcome | undefined;
271
279
  let plan: ReturnType<typeof planDelegation>;
272
- let ledgerDenied = false;
273
280
 
274
281
  if (spec.workspace && !executorRefusal) {
275
282
  // Check non-liftable refusals before taking a lease, and take the lease before asking a human. This
@@ -283,6 +290,8 @@ export async function runOneDelegation(
283
290
  spec: { ...spec.workspace, access: governedWorkspaceAccess(spec.workspace.access, plan.requested) },
284
291
  correlation: spec.correlation,
285
292
  childId: ids.childId,
293
+ executionId: ids.executionId,
294
+ parentExecutionId: ids.parentExecutionId,
286
295
  signal,
287
296
  ledgerPath: session.ledgerPath,
288
297
  });
@@ -308,55 +317,19 @@ export async function runOneDelegation(
308
317
  plan = { ...plan, ok: false, reason: message, refusal: structuredRefusal("EXECUTOR_UNAVAILABLE", message) };
309
318
  }
310
319
 
311
- // G6 / B-I3: no `&& plan.result` guard — `planDelegation` always carries one now.
312
- if (session.ledgerPath) {
313
- await appendRecord(
314
- { path: session.ledgerPath, strict: true },
315
- buildRecord({
316
- // F8: real ids, not depth labels. Four concurrent siblings used to produce four lines identical
317
- // except `ts`, so the ledger could not be joined to a result, a process, or the child's own
318
- // lines one level down.
319
- parentId: ids.parentId,
320
- childId: ids.childId,
321
- depth: plan.childDepth,
322
- agentType: spec.agent ?? "delegate",
323
- // ADR-0031: where this child actually ran. Read off the live session, which the probe has settled
324
- // by now, so the record and the executor cannot disagree.
325
- executor: session.executor.kind,
326
- taskFrom,
327
- requested: plan.requested,
328
- parentGrant: session.ownGrant,
329
- result: plan.result,
330
- blocked: !plan.ok,
331
- reason: plan.reason,
332
- // `approvalOutcome` when this call's own gate ran; `approvalFacts` when a caller gated upfront on our behalf
333
- // (a chain). Without the second, an approved chain step recorded nothing about the human who authorised it.
334
- approved: approvalOutcome?.approved ?? approvalFacts?.approved,
335
- approvalSources: approvalOutcome?.sources ?? approvalFacts?.sources,
336
- approvalScopes: approvalOutcome?.recordedScopes ?? approvalFacts?.scopes,
337
- approvalExpiresAt: approvalOutcome?.expiresAt ?? approvalFacts?.expiresAt,
338
- approvalUses: approvalOutcome?.uses ?? approvalFacts?.uses,
339
- humanDenied: approvalOutcome?.humanDenied ?? approvalFacts?.humanDenied,
340
- gateOutcome: approvalOutcome?.gateOutcome,
341
- // ADR-0018: taken from the PLAN, never re-derived here. The B-I3 lesson — a call site that
342
- // recomputed the digest could record one the planner never used.
343
- definitionDigest: plan.definitionDigest,
344
- taskDigest: plan.taskDigest,
345
- correlation: plan.correlation,
346
- refusal: plan.refusal,
347
- now: new Date(),
348
- }),
349
- ).catch((error) => {
350
- // G6 / A-R4 + B-I2: fail closed. This path PROVISIONS, so an unrecorded delegation would be a
351
- // child running with granted capabilities and no audit line.
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;
320
+ // The capability decision provisions the child, so this append remains load-bearing and fails closed.
321
+ try {
322
+ await recordDelegationDecision({
323
+ session, plan, ids, agent: spec.agent, taskFrom, taskFromExecutionId,
324
+ approval: approvalOutcome, approvalFacts,
359
325
  });
326
+ } catch (error) {
327
+ plan = {
328
+ ...plan,
329
+ ok: false,
330
+ reason: `grants: ledger write failed, denying — ${String(error)}`,
331
+ refusal: structuredRefusal("LEDGER_WRITE_FAILED", `grants: ledger write failed, denying — ${String(error)}`),
332
+ };
360
333
  }
361
334
 
362
335
  if (!plan.ok) {
@@ -368,7 +341,12 @@ export async function runOneDelegation(
368
341
  // unwritable an unguarded call replaced the governance refusal, and its code, with a ledger error.
369
342
  try {
370
343
  await releaseDelegationWorkspace({
371
- prepared: preparedWorkspace, childId: ids.childId, ledgerPath: session.ledgerPath, reason: "refused",
344
+ prepared: preparedWorkspace,
345
+ childId: ids.childId,
346
+ executionId: ids.executionId,
347
+ parentExecutionId: ids.parentExecutionId,
348
+ ledgerPath: session.ledgerPath,
349
+ reason: "refused",
372
350
  });
373
351
  } catch (error) {
374
352
  plan = { ...plan, reason: `${plan.reason ?? "refused"}; workspace release record failed: ${String(error)}` };
@@ -389,6 +367,8 @@ export async function runOneDelegation(
389
367
  plan,
390
368
  agent: spec.agent,
391
369
  childId: ids.childId,
370
+ executionId: ids.executionId,
371
+ parentExecutionId: ids.parentExecutionId,
392
372
  cwd: ctx.cwd,
393
373
  preparedWorkspace,
394
374
  signal,
@@ -80,16 +80,6 @@ export async function reportSessionStart(session: GrantsSession, ctx: SessionRep
80
80
  } catch {
81
81
  /* never throw into the agent loop */
82
82
  }
83
- // R-47. `gatedBlocked` filters `requested`, and for a definition spawn `requested` is that
84
- // definition's CEILING — which never contains `agent:<name>`, because the authorisation check
85
- // (ADR-0017) is a separate, ungated branch. So `PI_GRANTS_GATED=agent:deploy`, written by an operator
86
- // who read "it attenuates like any other capability" and meant "ask me before deploy runs", produces
87
- // no dialog and no warning. It DOES bite when a definition passes the id down in its own
88
- // `allowed-tools`, so the flag half-works — which is worse than not working, and is R-25's shape in
89
- // the namespace ADR-0017 just promoted out of exactly that state.
90
- //
91
- // Warned rather than enforced: making it gate the spawn is a behaviour change and wants a decision.
92
- // Silence is the part that is indefensible either way.
93
83
  // `agent:*` grants no tools, but it authorises every definition in BOTH skill roots — including
94
84
  // `~/.pi/agent/skills/`, which other software installs into, so ADR-0017's "an operator-authored
95
85
  // file" is not true of everything it covers. Paired with a shell that is every body on disk running
@@ -103,16 +93,19 @@ export async function reportSessionStart(session: GrantsSession, ctx: SessionRep
103
93
  "warning",
104
94
  );
105
95
  }
106
- const inertGates = session.gated.filter((c) => c.startsWith("agent:"));
107
- if (inertGates.length > 0) {
108
- ctx.ui.notify(
109
- `grants: ${inertGates.join(", ")} in PI_GRANTS_GATED does NOT gate spawning that definition — ` +
110
- `the authorisation check for a definition is separate and ungated, so a human is never asked. ` +
111
- `It applies only where a definition passes the id down in its own allowed-tools. To control ` +
112
- `which definitions may run, withhold the agent: capability from PI_GRANTS_GRANT instead.`,
113
- "warning",
114
- );
115
- }
96
+ // REMOVED 2026-08-21. This warned that an `agent:` id in `PI_GRANTS_GATED` "does NOT gate spawning that
97
+ // definition — a human is never asked". It was R-47's PARTIAL fix (0.11.1) and became false one release
98
+ // later at 0.12.0, when ADR-0024's gate landed (`4673348`, "gating a definition asks before it runs") and
99
+ // a gated `agent:<name>` began blocking the spawn until somebody approved it. ADR-0024's own Costs section
100
+ // leaned on this warning — "mitigated by the fact that the warning shipped hours earlier tells them it
101
+ // currently does nothing" — and nothing retired it when its own decision made the mitigation false.
102
+ //
103
+ // So from 0.12.0 through 0.18.1 the session banner told operators to delete a gate that works, and
104
+ // `test-integration/governance.it.ts` REQUIRED it to, which is how a stale claim outlives the person who
105
+ // notices. It is R-28's shape in the direction that talks somebody OUT of a control. ADR-0035 then
106
+ // repeated the pattern one namespace over by claiming a `workspace:` gate that did not exist; both are
107
+ // gates now, so there is nothing inert left to warn about. Deleted rather than reworded — a warning with
108
+ // no live case is the next stale claim. R-134.
116
109
  // R-34. `verifyLedger` existed and nothing ran it, so a torn line was detectable and undetected —
117
110
  // and a check an operator has to know to run is not a control, it is a feature. Setting
118
111
  // `PI_GRANTS_LEDGER` already means "I want an audit trail"; noticing that the trail is damaged is
@@ -31,6 +31,7 @@ import {
31
31
  gatedFromEnv,
32
32
  ENV_APPROVED,
33
33
  ENV_DEPTH,
34
+ ENV_EXECUTION_ID,
34
35
  ENV_FANOUT,
35
36
  ENV_GATED,
36
37
  ENV_GRANT,
@@ -42,6 +43,7 @@ import {
42
43
  import type { Capability } from "../src/resolve.ts";
43
44
  import { loadDefinitions } from "../src/definitions.ts";
44
45
  import { buildCatalog } from "../src/catalog.ts";
46
+ import { ENV_WORKSPACE_REGISTRY } from "../src/workspace.ts";
45
47
  import { loadGrantSync, grantStorePath } from "../src/grant-store.ts";
46
48
  import { republishable } from "./approvals.ts";
47
49
 
@@ -94,7 +96,8 @@ export interface GrantsSession {
94
96
  /** Bound variables that could not be read as non-negative integers — spawning is disabled, loudly. */
95
97
  readonly malformedBounds: string[];
96
98
  readonly gated: Capability[];
97
- readonly ledgerPath?: string;
99
+ /** Resolved against the actual pi cwd at session start, then inherited verbatim by every descendant. */
100
+ ledgerPath?: string;
98
101
  /**
99
102
  * Which executor runs this session's children — ADR-0031.
100
103
  *
@@ -107,8 +110,10 @@ export interface GrantsSession {
107
110
  * before the probe, which is the same hazard as capturing `ownGrant` before the tool surface is observed.
108
111
  */
109
112
  executor: ExecutorChoice;
110
- /** This session's ledger identity; children descend from it (F8). */
113
+ /** This session's readable logical ledger identity; children descend from it (F8). */
111
114
  readonly ownSpawnId: string;
115
+ /** Unique identity when this session is itself a governed child; roots have no governed parent. */
116
+ readonly ownExecutionId?: string;
112
117
  /** Descendants this subtree may still create — the cardinality bound ADR-0008 never had. */
113
118
  readonly fanoutBudget: number;
114
119
  /** Whether `delegate` / `delegate_all` are registered at all (S-5). Decided on the INHERITED grant. */
@@ -210,7 +215,14 @@ export interface GrantsSession {
210
215
  */
211
216
  export async function loadProjectDefinitions(session: GrantsSession, cwd: string): Promise<void> {
212
217
  session.definitions = await loadDefinitions(cwd);
213
- session.catalogReady = buildCatalog({ cwd, observedTools: session.observedTools });
218
+ session.catalogReady = buildCatalog({
219
+ cwd,
220
+ observedTools: session.observedTools,
221
+ // ADR-0035: `workspace:<id>` is a capability, so the registered ids belong in the catalog the same way
222
+ // discovered definitions do — for `/grants` to list what this session may route to and for `init` to
223
+ // scaffold them. Read live rather than cached at load, because the registry is an operator file.
224
+ registryPath: process.env[ENV_WORKSPACE_REGISTRY],
225
+ });
214
226
  session.catalog = await session.catalogReady;
215
227
  }
216
228
 
@@ -276,6 +288,7 @@ export function createGrantsSession(extensionPath: string | undefined): GrantsSe
276
288
  // `ownSpawnId` comes from the parent (F8), so ids form one tree across process boundaries instead of
277
289
  // every level restarting at `d0` and the ledger becoming unjoinable.
278
290
  ownSpawnId: process.env[ENV_PARENT_ID]?.trim() || `d${depth}`,
291
+ ownExecutionId: process.env[ENV_EXECUTION_ID]?.trim() || undefined,
279
292
  // The cardinality bound ADR-0008 never had: it attenuates downward like depth, so a subtree can never
280
293
  // create more descendants than its root was given — with no shared state, no lock and no counter file.
281
294
  fanoutBudget: budgetFromEnv(process.env[ENV_FANOUT]),
@@ -23,12 +23,39 @@ export interface DelegationWorkspaceSpec {
23
23
  access: WorkspaceAccess;
24
24
  }
25
25
 
26
+ /**
27
+ * Tools that cannot change a worktree.
28
+ *
29
+ * **`tool:delegate` is deliberately NOT here, and that is a scope decision.** It was added, and it was true of
30
+ * the capability and false of the code: a routed child's cwd IS the leased root, `PI_GRANTS_LEDGER` was
31
+ * passed through relative (`init` scaffolds `.pi/grants.jsonl`), and a `read` lease takes no kernel lock — so
32
+ * two delegating children classified `read` both created `.pi/` in one worktree and neither excluded the
33
+ * other. Making `tool:delegate` non-writing requires first making every child-inherited path absolute, and
34
+ * that is a change to the ledger and lease plumbing rather than to ADR-0035. Tracked as R-141.
35
+ */
26
36
  const KNOWN_READ_ONLY_TOOLS = new Set(["tool:read", "tool:grep", "tool:find", "tool:ls"]);
27
37
 
28
- /** A model may ask for stricter coordination but cannot label a write-capable grant read-only. */
38
+ /**
39
+ * A model may ask for stricter coordination but cannot label a write-capable grant read-only.
40
+ *
41
+ * **Only a TOOL is considered, and that is a fix rather than an oversight.** The check used to require *every*
42
+ * requested capability to be a known read-only tool, which was correct while `tool:` and `ext:` were the only
43
+ * things that could appear — and became wrong the moment ADR-0035's review made `workspace:<id>` grantable to
44
+ * a child. Measured: `governedWorkspaceAccess("read", ["tool:read", "workspace:staging"])` returned `"write"`,
45
+ * so the intended shape — *route this child read-only and let it route its own grandchild* — silently took an
46
+ * exclusive writer lease, blocked every other writer on that root, and recorded `access: "write"` in the
47
+ * ledger when the operator had asked for `read`. A record asserting a stronger claim than anybody made is the
48
+ * same failure this whole review is about, pointing the other way.
49
+ *
50
+ * A `workspace:`, `agent:` or `skill:` id confers no filesystem ability whatsoever: routing chooses a
51
+ * directory, `agent:` authorises a definition whose ceiling is still clipped to the child's own grant, and
52
+ * `skill:` loads instructions. None of them can write, so none of them should force a writer lease. If a
53
+ * descendant does hold a write tool, that tool is in `requested` and this check refuses on its own terms.
54
+ */
29
55
  export function governedWorkspaceAccess(declared: WorkspaceAccess, requested: readonly Capability[]): WorkspaceAccess {
30
56
  if (declared === "write") return "write";
31
- return requested.every((capability) => KNOWN_READ_ONLY_TOOLS.has(capability)) ? "read" : "write";
57
+ const tools = requested.filter((c) => c.startsWith("tool:") || c.startsWith("ext:"));
58
+ return tools.every((capability) => KNOWN_READ_ONLY_TOOLS.has(capability)) ? "read" : "write";
32
59
  }
33
60
 
34
61
  export interface PreparedWorkspace {
@@ -42,6 +69,8 @@ export async function prepareDelegationWorkspace(input: {
42
69
  spec: DelegationWorkspaceSpec;
43
70
  correlation?: CorrelationMetadata;
44
71
  childId: string;
72
+ executionId: string;
73
+ parentExecutionId: string | null;
45
74
  signal?: AbortSignal;
46
75
  ledgerPath?: string;
47
76
  }): Promise<PreparedWorkspace> {
@@ -75,6 +104,8 @@ export async function prepareDelegationWorkspace(input: {
75
104
  await appendLedgerEvent(
76
105
  { path: input.ledgerPath, strict: true },
77
106
  buildWorkspaceLeaseEvent({
107
+ executionId: input.executionId,
108
+ parentExecutionId: input.parentExecutionId,
78
109
  childId: input.childId,
79
110
  workspaceId: workspace.workspaceId,
80
111
  root: workspace.root,
@@ -98,6 +129,8 @@ export async function prepareDelegationWorkspace(input: {
98
129
  await appendLedgerEvent(
99
130
  { path: input.ledgerPath, strict: true },
100
131
  buildWorkspaceLeaseEvent({
132
+ executionId: input.executionId,
133
+ parentExecutionId: input.parentExecutionId,
101
134
  childId: input.childId,
102
135
  workspaceId: workspace.workspaceId,
103
136
  root: workspace.root,
@@ -124,6 +157,8 @@ export async function prepareDelegationWorkspace(input: {
124
157
  export async function releaseDelegationWorkspace(input: {
125
158
  prepared: PreparedWorkspace | undefined;
126
159
  childId: string;
160
+ executionId: string;
161
+ parentExecutionId: string | null;
127
162
  ledgerPath?: string;
128
163
  reason: string;
129
164
  /** Deliberately keep the lease: a herdr writer tab would not close, so the pane may still be live. */
@@ -133,13 +168,18 @@ export async function releaseDelegationWorkspace(input: {
133
168
  // A retained lease writes no `state: "released"`, so the record stays `active` and the NEXT owner reads
134
169
  // it as a crash — the exact blame `retained` was added to remove. Marking it keeps the successor honest;
135
170
  // 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")
171
+ // **Ledger what happened, not what was intended (R-152).** This used to discard `markRetained`'s result and
172
+ // write the word "retained" unconditionally, so a helper that had already died, or a lease already released,
173
+ // was still recorded as a pane that may still be live.
174
+ const outcome: LeaseReleaseOutcome = input.retain
175
+ ? await input.prepared.lease.markRetained(input.reason)
138
176
  : await input.prepared.lease.release(input.reason);
139
177
  if (input.ledgerPath) {
140
178
  await appendLedgerEvent(
141
179
  { path: input.ledgerPath, strict: true },
142
180
  buildWorkspaceLeaseEvent({
181
+ executionId: input.executionId,
182
+ parentExecutionId: input.parentExecutionId,
143
183
  childId: input.childId,
144
184
  workspaceId: input.prepared.workspace.workspaceId,
145
185
  root: input.prepared.workspace.root,