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
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_EXECUTION_ID, ENV_FANOUT, ENV_GATED, ENV_GRANT, ENV_LEDGER, ENV_MAX_DEPTH,
23
+ ENV_PARENT_ID, 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";
@@ -72,6 +81,17 @@ export function planDelegation(request: DelegationRequest, ctx: DelegationContex
72
81
  }
73
82
  if (!request.task?.trim()) return denied({ ...empty, reason: "a delegation needs a task" }, "TASK_MISSING");
74
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
+
75
95
  // ADR-0016. A named definition replaces the model's tool list with an operator-authored ceiling.
76
96
  let requested: Capability[];
77
97
  let systemPrompt: string | undefined;
@@ -181,6 +201,13 @@ export function planDelegation(request: DelegationRequest, ctx: DelegationContex
181
201
  }
182
202
  }
183
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
+
184
211
  const { result, approvalBinding, bindingMismatch } = resolveDelegationApproval({
185
212
  task: request.task,
186
213
  agent: request.agent,
@@ -266,8 +293,14 @@ export function planDelegation(request: DelegationRequest, ctx: DelegationContex
266
293
  args.splice(args.length - 1, 0, "-e", ctx.extensionPath);
267
294
  }
268
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);
269
302
  const env: Record<string, string> = {
270
- [ENV_GRANT]: (assertCapabilitiesArePropagatable(result.effective), result.effective.join(",")),
303
+ [ENV_GRANT]: (assertCapabilitiesArePropagatable(inheritable), inheritable.join(",")),
271
304
  [ENV_DEPTH]: String(childDepth),
272
305
  [ENV_MAX_DEPTH]: String(ctx.maxDepth),
273
306
  };
@@ -276,12 +309,17 @@ export function planDelegation(request: DelegationRequest, ctx: DelegationContex
276
309
  // process boundaries with no shared state.
277
310
  if (ctx.fanoutBudget !== undefined) env[ENV_FANOUT] = String(ctx.fanoutBudget);
278
311
  if (ctx.childSpawnId) env[ENV_PARENT_ID] = ctx.childSpawnId;
312
+ if (ctx.childExecutionId) env[ENV_EXECUTION_ID] = ctx.childExecutionId;
279
313
  if (ctx.gated.length > 0) env[ENV_GATED] = ctx.gated.join(",");
280
314
  // Approvals ride down with the grant, but only ever for what this child actually received — so
281
315
  // `approved ⊆ grant` holds at every level (ADR-0010). Written even when empty, so this object states
282
316
  // the child's approval set outright rather than leaving it to whatever the caller merges over; see
283
317
  // `mergeChildEnv`, which is what actually stops the parent's value leaking through.
284
- env[ENV_APPROVED] = inheritApprovals(ctx.approved ?? [], result.effective).join(",");
318
+ // Clamped to what the child actually INHERITS, not to what it was granted. The two differ only for a
319
+ // non-inheritable wildcard, and passing down an approval for a capability the child does not hold would
320
+ // leave banked authority with nothing to spend it on — `childEnv` clamps to `inheritable` for the same
321
+ // reason on the other path.
322
+ env[ENV_APPROVED] = inheritApprovals(ctx.approved ?? [], inheritable).join(",");
285
323
  if (ctx.ledgerPath) env[ENV_LEDGER] = ctx.ledgerPath;
286
324
 
287
325
  return {
@@ -293,6 +331,7 @@ export function planDelegation(request: DelegationRequest, ctx: DelegationContex
293
331
  childDepth,
294
332
  requested,
295
333
  childId: ctx.childSpawnId,
334
+ executionId: ctx.childExecutionId,
296
335
  taskDigest,
297
336
  ...(correlation ? { correlation } : {}),
298
337
  ...(approvalBinding ? { approvalBinding } : {}),
@@ -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
  }
@@ -0,0 +1,18 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ /** Unique identity for one governed execution occurrence. Logical child ids remain readable positions. */
4
+ export type ExecutionId = `exec:${string}`;
5
+
6
+ const EXECUTION_ID_RE = /^exec:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
7
+
8
+ export function isExecutionId(value: unknown): value is ExecutionId {
9
+ return typeof value === "string" && EXECUTION_ID_RE.test(value);
10
+ }
11
+
12
+ export function newExecutionId(): ExecutionId {
13
+ return `exec:${randomUUID()}`;
14
+ }
15
+
16
+ export function assertExecutionId(value: unknown, field = "executionId"): asserts value is ExecutionId {
17
+ if (!isExecutionId(value)) throw new TypeError(`${field} must be a pi-daddy execution id`);
18
+ }
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,9 +11,14 @@ export {
11
11
  export {
12
12
  appendLedgerEvent,
13
13
  appendRecord,
14
+ buildCheckReceiptLedgerEvent,
14
15
  buildChildLifecycleEvent,
15
16
  buildRecord,
16
17
  buildWorkspaceLeaseEvent,
18
+ buildWorkflowFactEvent,
19
+ WORKFLOW_FACT_KINDS,
20
+ WORKFLOW_FACT_PROVENANCE,
21
+ WORKFLOW_FACT_STATES,
17
22
  isEscalationAttempt,
18
23
  LEDGER_VERSION,
19
24
  type CheckReceiptLedgerEvent,
@@ -23,6 +28,10 @@ export {
23
28
  type RuntimeLedgerEvent,
24
29
  type WorkspaceLeaseEvent,
25
30
  type WorkspaceLeaseOutcome,
31
+ type WorkflowFactEvent,
32
+ type WorkflowFactKind,
33
+ type WorkflowFactProvenance,
34
+ type WorkflowFactState,
26
35
  } from "./ledger.ts";
27
36
 
28
37
  export { planSpawn, type SpawnPlan, type SpawnPlanInput } from "./spawn.ts";
@@ -96,6 +105,23 @@ export {
96
105
  type CheckRegistry,
97
106
  } from "./check-runner.ts";
98
107
 
108
+ export {
109
+ isExecutionId,
110
+ newExecutionId,
111
+ type ExecutionId,
112
+ } from "./execution-id.ts";
113
+
114
+ export {
115
+ parseDashboardLedger,
116
+ type DashboardNode,
117
+ type DashboardProjection,
118
+ type DashboardState,
119
+ type DashboardWorkflow,
120
+ type DashboardWorkflowFact,
121
+ } from "./dashboard-projection.ts";
122
+
123
+ export { renderDashboard, type DashboardRenderOptions } from "./dashboard-render.ts";
124
+
99
125
  export {
100
126
  createApprovalGate,
101
127
  createApprovalGateProvider,
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
  };
@@ -0,0 +1,97 @@
1
+ /**
2
+ * **The lock helper: a separate program, and the parent's side of its pipes.**
3
+ *
4
+ * Split out of `workspace-lease.ts` because `test/file-size.test.ts` refused that file — at 405 lines on the
5
+ * ADR-0035 branch when PR #14 merged into it, and at 435 here once R-152's guards landed. Both branches take
6
+ * the same seam so they converge rather than diverge, and the cap has never been raised: `delegate.ts` was
7
+ * split at 413 the same way (rule: when a guard fails, obey it).
8
+ *
9
+ * The seam is not arbitrary. Everything here concerns the process that HOLDS the kernel lock — the source it
10
+ * runs, the readiness token it prints, and the parent-side handles that keep it referenced.
11
+ * `workspace-lease.ts` keeps the lease lifecycle that talks to it.
12
+ */
13
+
14
+ export const LEASE_READY = "PI_DADDY_LEASE_READY";
15
+ // The lock holder also owns crash cleanup for the governed child. If the parent dies, stdin closes;
16
+ // the helper signals the attached process, or closes the herdr tab, before releasing flock. A raw
17
+ // descendant deliberately detached by bash remains ADR-0012's OS-containment boundary, not a lease
18
+ // guarantee. The process branch SIGTERMs, escalates to SIGKILL at +500ms, and releases at +750ms
19
+ // WITHOUT confirming death (R-101). The herdr branch retries `tab close` a BOUNDED number of times
20
+ // and then releases anyway, leaving a marker file: an unreleasable lock strands a worktree forever
21
+ // with no in-product recovery, which is strictly worse than a recorded failure to close (R-102).
22
+ //
23
+ // **Each attempt is bounded in WALL CLOCK, not just in count (R-146).** `execFile` with no `timeout`
24
+ // never calls back if `herdr` accepts the close and does not answer, so the retry counter never
25
+ // decrements, `giveUp` never runs and no marker is written — the lock is held forever. That was masked
26
+ // while the parent could not exit, because the operator saw a hung `pi` instead; measured with a `herdr`
27
+ // that sleeps, the parent then exited in 82ms and left a silent strand, which is R-102's rejected outcome
28
+ // reached quietly. A bound on retries is not a bound on time.
29
+ export const HELPER_SOURCE = `
30
+ import { execFile } from "node:child_process";
31
+ import { writeFileSync } from "node:fs";
32
+ let clean=false, target=null, buffered="";
33
+ process.stdout.write(${JSON.stringify(`${LEASE_READY}:`)}+process.pid+"\\n");
34
+ process.stdin.setEncoding("utf8");
35
+ process.stdin.on("data",chunk=>{buffered+=chunk;for(;;){const i=buffered.indexOf("\\n");if(i<0)break;const line=buffered.slice(0,i);buffered=buffered.slice(i+1);try{const value=JSON.parse(line);if(value.release)clean=true;else if(value.process_pid)target={process_pid:value.process_pid};else if(value.herdr_tab)target={herdr_tab:value.herdr_tab};}catch{}}});
36
+ process.stdin.on("end",()=>{if(clean||!target)return process.exit(0);if(target.process_pid){try{process.kill(target.process_pid,"SIGTERM")}catch{return process.exit(0)}setTimeout(()=>{try{process.kill(target.process_pid,"SIGKILL")}catch{}},500);return setTimeout(()=>process.exit(0),750);}let left=Number(process.env.PI_DADDY_LEASE_CLOSE_ATTEMPTS||10);const giveUp=last=>{try{if(process.env.PI_DADDY_LEASE_MARKER)writeFileSync(process.env.PI_DADDY_LEASE_MARKER,JSON.stringify({reason:last&&(last.killed||last.signal==="SIGKILL")?"herdr-close-timeout":"herdr-close-failed",herdr_tab:target.herdr_tab})+"\\n")}catch{}process.exit(0)};const close=()=>execFile("herdr",["tab","close",target.herdr_tab],{timeout:Number(process.env.PI_DADDY_LEASE_CLOSE_TIMEOUT_MS||15000),killSignal:"SIGKILL"},error=>{if(!error)return process.exit(0);if(--left<=0)return giveUp(error);setTimeout(close,1000)});close();});
37
+ process.stdin.resume();`;
38
+
39
+ /**
40
+ * `unref` the parent's end of one of the helper's pipes.
41
+ *
42
+ * A spawned pipe is a `net.Socket`, which has `unref`; `ChildProcess.stdin` is typed `Writable`, which does
43
+ * not. Hence the narrow cast. The optional call is **defensive, not load-bearing** — the holder is spawned at
44
+ * exactly one site with three pipes, so none of these is ever `null`, and an earlier version of this comment
45
+ * claimed otherwise by citing a `stdio: "ignore"` caller that does not exist.
46
+ *
47
+ * Only `stdout` and `stderr` are unref'ed. `stdin` was too, until a line-by-line reversion showed the suite
48
+ * stayed green without it — an unforced line inside the fix that exists to be forced, which is exactly the
49
+ * shape R-122 was about.
50
+ */
51
+ export const unrefStream = (stream: unknown): void => {
52
+ (stream as { unref?: () => void } | null)?.unref?.();
53
+ };
54
+
55
+ /**
56
+ * The bounds on the helper's `herdr tab close` attempts live here, with the attempts they bound — moved out of
57
+ * `workspace-lease.ts` when the line ceiling refused it at 402 lines, once both this branch's ADR-0035 work and
58
+ * `main`'s R-152 guards had landed in it. Third time that guard has fired on this file and third time the
59
+ * answer was a seam rather than a bigger cap.
60
+ */
61
+ /**
62
+ * **A bound that is not a bound throws, and it throws a `RangeError` (R-146, R-152).**
63
+ *
64
+ * Both ends fail, in opposite directions and both silently:
65
+ *
66
+ * - `0` reads as "no limit" and Node agrees — `execFile` treats `timeout: 0` as *no* timeout (measured: the
67
+ * callback for a 3s sleep arrives at 3004ms with no error), reinstating the unbounded hang the bound exists
68
+ * to prevent. Negatives behave identically.
69
+ * - anything above `2^31 - 1` truncates: `setTimeout` warns `TimeoutOverflowWarning … set to 1`, so
70
+ * `Number.MAX_SAFE_INTEGER` — the plausible "effectively no limit" sentinel, given the argument about `0`
71
+ * — SIGKILLs every `herdr tab close` after 1ms, before herdr can act. Measured: callback at 3ms.
72
+ *
73
+ * **Not a `GovernanceRefusal`.** It was one, carrying `WORKSPACE_LEASE_STALE`, which everywhere else in this
74
+ * package means *the lease went stale or was lost* — so an ADR-0034 controller switching on codes (the reason
75
+ * codes exist, R-103) would classify a permanent caller bug as transient and retry a call that can never
76
+ * succeed. A refusal is a governance outcome that gets ledgered; a bad argument is neither.
77
+ *
78
+ * Checked for read leases too, which the first version did not: it sat below the read-lease early return, so a
79
+ * controller smoke-testing its configuration against a read lease got a false all-clear.
80
+ */
81
+ export const assertCloseBounds = (input: { herdrCloseTimeoutMs?: number; herdrCloseAttempts?: number }): void => {
82
+ const MAX_TIMER = 2_147_483_647;
83
+ for (const [name, value, ceiling] of [
84
+ ["herdrCloseTimeoutMs", input.herdrCloseTimeoutMs, MAX_TIMER],
85
+ ["herdrCloseAttempts", input.herdrCloseAttempts, Number.MAX_SAFE_INTEGER],
86
+ ] as const) {
87
+ if (value === undefined) continue;
88
+ if (!Number.isInteger(value) || value < 1 || value > ceiling) {
89
+ throw new RangeError(
90
+ `${name} must be a whole number between 1 and ${ceiling}, not ${String(value)}. Zero and negatives ` +
91
+ `are not "no limit" but no bound at all, a value past ${MAX_TIMER} truncates to 1ms, and a ` +
92
+ `fractional count is not a count — this bound exists so a hung herdr cannot hold the writer lock ` +
93
+ `forever (R-146).`,
94
+ );
95
+ }
96
+ }
97
+ };
@@ -53,13 +53,18 @@ export interface WorkspaceLease {
53
53
  * Needed because a retained lease never calls `release()`, so the metadata stays `state: "active"` and
54
54
  * whoever acquires next reports `recovered: true` — blaming a crash on a known-good path.
55
55
  */
56
- markRetained(reason?: string): Promise<void>;
56
+ /**
57
+ * Record that the lease is being KEPT rather than handed back, and answer what actually happened — the
58
+ * caller ledgers this word (R-152). It is not always `retained`: a helper that has already died makes the
59
+ * fact `lost`, and a lease already settled by `release()` keeps the outcome it had.
60
+ */
61
+ markRetained(reason?: string): Promise<LeaseReleaseOutcome>;
57
62
  /** Reads the give-up marker the helper leaves when it could not close a herdr writer tab. */
58
63
  readCloseFailure(): Promise<{ reason: string; herdr_tab?: string } | null>;
59
64
  }
60
65
 
61
66
  /**
62
- * What a release actually did. Five members rather than three, because the first version conflated facts
67
+ * What a release actually did. Six members rather than three, because the first version conflated facts
63
68
  * that call for different responses — and one of them is an alarm:
64
69
  * `released` the lock went back and THIS owner wrote its own handover;
65
70
  * `released-unrecorded` the lock went back and the record does not say so, so the next owner will report
@@ -77,7 +82,14 @@ export type LeaseReleaseOutcome =
77
82
  | "released-unrecorded"
78
83
  | "released-superseded"
79
84
  | "not-held"
80
- | "lost";
85
+ | "lost"
86
+ /**
87
+ * The lease was RETAINED and is therefore already settled — `release()` after `markRetained()` answers
88
+ * this instead of running the clean handshake (R-146). It was previously expressible only as the
89
+ * `| "retained"` bolted onto two signatures, which is why `release()` could not say it and claimed
90
+ * `released` instead: a clean handover for a lease kept precisely because a pane would not close.
91
+ */
92
+ | "retained";
81
93
 
82
94
  export function leasePaths(leaseDir: string, root: string) {
83
95
  // Canonical root, never caller-chosen workspace ID: aliases for one worktree must contend.