pi-daddy 0.19.0 → 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 (125) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +105 -36
  3. package/contracts/ledger/v3/README.md +36 -0
  4. package/contracts/ledger/v3/fixtures/capability-decision.json +98 -0
  5. package/contracts/ledger/v3/fixtures/check-receipt.json +42 -0
  6. package/contracts/ledger/v3/fixtures/child-lifecycle.json +45 -0
  7. package/contracts/ledger/v3/fixtures/workflow-fact.json +41 -0
  8. package/contracts/ledger/v3/fixtures/workspace-lease.json +43 -0
  9. package/contracts/ledger/v3/ledger-event.schema.json +930 -0
  10. package/dist/check-runner.d.ts.map +1 -1
  11. package/dist/check-runner.js +14 -0
  12. package/dist/check-runner.js.map +1 -1
  13. package/dist/cli.js +0 -0
  14. package/dist/correlation.d.ts.map +1 -1
  15. package/dist/correlation.js +10 -0
  16. package/dist/correlation.js.map +1 -1
  17. package/dist/dashboard-cli.d.ts +18 -0
  18. package/dist/dashboard-cli.d.ts.map +1 -0
  19. package/dist/dashboard-cli.js +154 -0
  20. package/dist/dashboard-cli.js.map +1 -0
  21. package/dist/dashboard-handshake.d.ts +37 -0
  22. package/dist/dashboard-handshake.d.ts.map +1 -0
  23. package/dist/dashboard-handshake.js +127 -0
  24. package/dist/dashboard-handshake.js.map +1 -0
  25. package/dist/dashboard-herdr.d.ts +54 -0
  26. package/dist/dashboard-herdr.d.ts.map +1 -0
  27. package/dist/dashboard-herdr.js +286 -0
  28. package/dist/dashboard-herdr.js.map +1 -0
  29. package/dist/dashboard-projection.d.ts +73 -0
  30. package/dist/dashboard-projection.d.ts.map +1 -0
  31. package/dist/dashboard-projection.js +294 -0
  32. package/dist/dashboard-projection.js.map +1 -0
  33. package/dist/dashboard-render.d.ts +10 -0
  34. package/dist/dashboard-render.d.ts.map +1 -0
  35. package/dist/dashboard-render.js +208 -0
  36. package/dist/dashboard-render.js.map +1 -0
  37. package/dist/delegate-types.d.ts +6 -2
  38. package/dist/delegate-types.d.ts.map +1 -1
  39. package/dist/delegate-types.js.map +1 -1
  40. package/dist/delegate.d.ts.map +1 -1
  41. package/dist/delegate.js +4 -1
  42. package/dist/delegate.js.map +1 -1
  43. package/dist/execution-id.d.ts +6 -0
  44. package/dist/execution-id.d.ts.map +1 -0
  45. package/dist/execution-id.js +13 -0
  46. package/dist/execution-id.js.map +1 -0
  47. package/dist/index.d.ts +4 -1
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +4 -1
  50. package/dist/index.js.map +1 -1
  51. package/dist/ledger-events.d.ts +27 -4
  52. package/dist/ledger-events.d.ts.map +1 -1
  53. package/dist/ledger-events.js +30 -9
  54. package/dist/ledger-events.js.map +1 -1
  55. package/dist/ledger-identifiers.d.ts +7 -0
  56. package/dist/ledger-identifiers.d.ts.map +1 -0
  57. package/dist/ledger-identifiers.js +20 -0
  58. package/dist/ledger-identifiers.js.map +1 -0
  59. package/dist/ledger-report.d.ts +5 -2
  60. package/dist/ledger-report.d.ts.map +1 -1
  61. package/dist/ledger-report.js +38 -14
  62. package/dist/ledger-report.js.map +1 -1
  63. package/dist/ledger-v3-validation.d.ts +11 -0
  64. package/dist/ledger-v3-validation.d.ts.map +1 -0
  65. package/dist/ledger-v3-validation.js +265 -0
  66. package/dist/ledger-v3-validation.js.map +1 -0
  67. package/dist/ledger.d.ts +18 -5
  68. package/dist/ledger.d.ts.map +1 -1
  69. package/dist/ledger.js +29 -7
  70. package/dist/ledger.js.map +1 -1
  71. package/dist/propagation.d.ts +3 -1
  72. package/dist/propagation.d.ts.map +1 -1
  73. package/dist/propagation.js +3 -0
  74. package/dist/propagation.js.map +1 -1
  75. package/dist/run-child.d.ts +3 -1
  76. package/dist/run-child.d.ts.map +1 -1
  77. package/dist/run-child.js +30 -2
  78. package/dist/run-child.js.map +1 -1
  79. package/dist/run-herdr.d.ts +2 -0
  80. package/dist/run-herdr.d.ts.map +1 -1
  81. package/dist/run-herdr.js +8 -0
  82. package/dist/run-herdr.js.map +1 -1
  83. package/dist/workflow-fact-id.d.ts +4 -0
  84. package/dist/workflow-fact-id.d.ts.map +1 -0
  85. package/dist/workflow-fact-id.js +14 -0
  86. package/dist/workflow-fact-id.js.map +1 -0
  87. package/dist/workflow-facts.d.ts +34 -0
  88. package/dist/workflow-facts.d.ts.map +1 -0
  89. package/dist/workflow-facts.js +43 -0
  90. package/dist/workflow-facts.js.map +1 -0
  91. package/extensions/chain-ledger.ts +4 -0
  92. package/extensions/chain-plan.ts +98 -0
  93. package/extensions/delegate-chain.ts +63 -122
  94. package/extensions/delegation-ledger.ts +60 -0
  95. package/extensions/delegation.ts +13 -13
  96. package/extensions/execute-child.ts +85 -21
  97. package/extensions/execution-occurrence.ts +20 -0
  98. package/extensions/grants-command.ts +22 -3
  99. package/extensions/grants.ts +44 -0
  100. package/extensions/run-delegation.ts +36 -56
  101. package/extensions/session.ts +7 -2
  102. package/extensions/workspace-runtime.ts +10 -0
  103. package/herdr-plugin/herdr-plugin.toml +18 -0
  104. package/package.json +16 -4
  105. package/src/check-runner.ts +16 -0
  106. package/src/correlation.ts +11 -0
  107. package/src/dashboard-cli.ts +171 -0
  108. package/src/dashboard-handshake.ts +185 -0
  109. package/src/dashboard-herdr.ts +355 -0
  110. package/src/dashboard-projection.ts +390 -0
  111. package/src/dashboard-render.ts +254 -0
  112. package/src/delegate-types.ts +6 -2
  113. package/src/delegate.ts +4 -2
  114. package/src/execution-id.ts +18 -0
  115. package/src/index.ts +25 -0
  116. package/src/ledger-events.ts +54 -10
  117. package/src/ledger-identifiers.ts +21 -0
  118. package/src/ledger-report.ts +40 -19
  119. package/src/ledger-v3-validation.ts +266 -0
  120. package/src/ledger.ts +49 -10
  121. package/src/propagation.ts +3 -0
  122. package/src/run-child.ts +35 -3
  123. package/src/run-herdr.ts +9 -0
  124. package/src/workflow-fact-id.ts +16 -0
  125. package/src/workflow-facts.ts +65 -0
@@ -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
@@ -22,6 +22,8 @@
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";
@@ -38,6 +40,11 @@ import { planWithApprovals } from "./run-delegation.ts";
38
40
  import { createGrantsSession, loadProjectDefinitions, resolveExecutor, type GrantsSession } from "./session.ts";
39
41
  import { reportSessionStart } from "./session-report.ts";
40
42
  import { SPAWN_TOOLS, tripwireReason } from "./tripwire.ts";
43
+ import {
44
+ defaultDashboardPaths,
45
+ offerDashboardHandshake,
46
+ openDashboardCommand,
47
+ } from "../src/dashboard-handshake.ts";
41
48
 
42
49
  export default function (pi: ExtensionAPI) {
43
50
  // The path pi loads as the extension, so a child granted `tool:delegate` can be started with `-e <this>`.
@@ -51,6 +58,10 @@ export default function (pi: ExtensionAPI) {
51
58
  })();
52
59
 
53
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
+ );
54
65
 
55
66
  // Filled in by `registerDelegationTools` at the bottom of this function. A holder rather than a reordering,
56
67
  // because the hooks below have to be registered before the tools and both need to call it — the tool
@@ -59,6 +70,10 @@ export default function (pi: ExtensionAPI) {
59
70
 
60
71
  pi.on("session_start", async (_event, ctx) => {
61
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);
62
77
  // The one case the stored-grant lookup can get wrong (ADR-0030). The factory reads the store keyed by
63
78
  // `process.cwd()` because it runs before any hook and therefore before `ctx` exists — and S-5 forces
64
79
  // that ordering, since whether `delegate` is registered is decided there. Almost always the two agree.
@@ -152,6 +167,27 @@ export default function (pi: ExtensionAPI) {
152
167
  "warning",
153
168
  );
154
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
+ }
155
191
  } catch (error) {
156
192
  // Rule 8 — fail closed, and be LOUD about it. Swallowing is still right: a startup fault must not
157
193
  // reach the agent loop. Swallowing SILENTLY is what let R-60 exist, and would let the next one exist
@@ -316,6 +352,14 @@ export default function (pi: ExtensionAPI) {
316
352
  await loadProjectDefinitions(session, ctx.cwd);
317
353
  delegation.refreshSpawnable();
318
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
+ }),
319
363
  previewDelegation: (name: string) =>
320
364
  planWithApprovals(session, { task: "(preview)", agent: name }, {}, null),
321
365
  },
@@ -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,
@@ -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,
@@ -95,7 +96,8 @@ export interface GrantsSession {
95
96
  /** Bound variables that could not be read as non-negative integers — spawning is disabled, loudly. */
96
97
  readonly malformedBounds: string[];
97
98
  readonly gated: Capability[];
98
- readonly ledgerPath?: string;
99
+ /** Resolved against the actual pi cwd at session start, then inherited verbatim by every descendant. */
100
+ ledgerPath?: string;
99
101
  /**
100
102
  * Which executor runs this session's children — ADR-0031.
101
103
  *
@@ -108,8 +110,10 @@ export interface GrantsSession {
108
110
  * before the probe, which is the same hazard as capturing `ownGrant` before the tool surface is observed.
109
111
  */
110
112
  executor: ExecutorChoice;
111
- /** This session's ledger identity; children descend from it (F8). */
113
+ /** This session's readable logical ledger identity; children descend from it (F8). */
112
114
  readonly ownSpawnId: string;
115
+ /** Unique identity when this session is itself a governed child; roots have no governed parent. */
116
+ readonly ownExecutionId?: string;
113
117
  /** Descendants this subtree may still create — the cardinality bound ADR-0008 never had. */
114
118
  readonly fanoutBudget: number;
115
119
  /** Whether `delegate` / `delegate_all` are registered at all (S-5). Decided on the INHERITED grant. */
@@ -284,6 +288,7 @@ export function createGrantsSession(extensionPath: string | undefined): GrantsSe
284
288
  // `ownSpawnId` comes from the parent (F8), so ids form one tree across process boundaries instead of
285
289
  // every level restarting at `d0` and the ledger becoming unjoinable.
286
290
  ownSpawnId: process.env[ENV_PARENT_ID]?.trim() || `d${depth}`,
291
+ ownExecutionId: process.env[ENV_EXECUTION_ID]?.trim() || undefined,
287
292
  // The cardinality bound ADR-0008 never had: it attenuates downward like depth, so a subtree can never
288
293
  // create more descendants than its root was given — with no shared state, no lock and no counter file.
289
294
  fanoutBudget: budgetFromEnv(process.env[ENV_FANOUT]),
@@ -69,6 +69,8 @@ export async function prepareDelegationWorkspace(input: {
69
69
  spec: DelegationWorkspaceSpec;
70
70
  correlation?: CorrelationMetadata;
71
71
  childId: string;
72
+ executionId: string;
73
+ parentExecutionId: string | null;
72
74
  signal?: AbortSignal;
73
75
  ledgerPath?: string;
74
76
  }): Promise<PreparedWorkspace> {
@@ -102,6 +104,8 @@ export async function prepareDelegationWorkspace(input: {
102
104
  await appendLedgerEvent(
103
105
  { path: input.ledgerPath, strict: true },
104
106
  buildWorkspaceLeaseEvent({
107
+ executionId: input.executionId,
108
+ parentExecutionId: input.parentExecutionId,
105
109
  childId: input.childId,
106
110
  workspaceId: workspace.workspaceId,
107
111
  root: workspace.root,
@@ -125,6 +129,8 @@ export async function prepareDelegationWorkspace(input: {
125
129
  await appendLedgerEvent(
126
130
  { path: input.ledgerPath, strict: true },
127
131
  buildWorkspaceLeaseEvent({
132
+ executionId: input.executionId,
133
+ parentExecutionId: input.parentExecutionId,
128
134
  childId: input.childId,
129
135
  workspaceId: workspace.workspaceId,
130
136
  root: workspace.root,
@@ -151,6 +157,8 @@ export async function prepareDelegationWorkspace(input: {
151
157
  export async function releaseDelegationWorkspace(input: {
152
158
  prepared: PreparedWorkspace | undefined;
153
159
  childId: string;
160
+ executionId: string;
161
+ parentExecutionId: string | null;
154
162
  ledgerPath?: string;
155
163
  reason: string;
156
164
  /** Deliberately keep the lease: a herdr writer tab would not close, so the pane may still be live. */
@@ -170,6 +178,8 @@ export async function releaseDelegationWorkspace(input: {
170
178
  await appendLedgerEvent(
171
179
  { path: input.ledgerPath, strict: true },
172
180
  buildWorkspaceLeaseEvent({
181
+ executionId: input.executionId,
182
+ parentExecutionId: input.parentExecutionId,
173
183
  childId: input.childId,
174
184
  workspaceId: input.prepared.workspace.workspaceId,
175
185
  root: input.prepared.workspace.root,
@@ -0,0 +1,18 @@
1
+ id = "pi-daddy.dashboard"
2
+ name = "pi-daddy dashboard"
3
+ version = "1.0.0"
4
+ min_herdr_version = "0.8.0"
5
+ description = "Read-only live projection of a pi-daddy governance ledger"
6
+ platforms = ["linux", "macos", "windows"]
7
+
8
+ [[panes]]
9
+ id = "dashboard"
10
+ title = "PI-DADDY"
11
+ description = "Live governed execution and refusal tree"
12
+ placement = "split"
13
+ command = [
14
+ "node",
15
+ "--input-type=module",
16
+ "-e",
17
+ "import('node:url').then(({ pathToFileURL }) => import(pathToFileURL(process.env.HERDR_PLUGIN_ROOT + '/../dist/dashboard-cli.js').href)).then(m => m.runDashboard()).catch(e => { console.error(e); process.exitCode = 1; })"
18
+ ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-daddy",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Capability governance for pi sub-agents: spawn Agent Skills (SKILL.md) definitions whose allowed-tools becomes a grant that can only narrow going down a delegation tree, enforced by pi's own --tools allowlist, with an append-only ledger.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -129,27 +129,39 @@
129
129
  "types": "./dist/check-runner.d.ts",
130
130
  "default": "./dist/check-runner.js"
131
131
  },
132
+ "./dashboard-projection": {
133
+ "types": "./dist/dashboard-projection.d.ts",
134
+ "default": "./dist/dashboard-projection.js"
135
+ },
136
+ "./dashboard-render": {
137
+ "types": "./dist/dashboard-render.d.ts",
138
+ "default": "./dist/dashboard-render.js"
139
+ },
140
+ "./contracts/ledger/v3/ledger-event.schema.json": "./contracts/ledger/v3/ledger-event.schema.json",
141
+ "./contracts/ledger/v3/fixtures/*.json": "./contracts/ledger/v3/fixtures/*.json",
132
142
  "./contracts/ledger/v2/ledger-event.schema.json": "./contracts/ledger/v2/ledger-event.schema.json",
133
143
  "./contracts/ledger/v2/fixtures/*.json": "./contracts/ledger/v2/fixtures/*.json"
134
144
  },
135
145
  "bin": {
136
- "pi-daddy": "./dist/cli.js"
146
+ "pi-daddy": "./dist/cli.js",
147
+ "pi-daddy-dashboard": "./dist/dashboard-cli.js"
137
148
  },
138
149
  "files": [
139
150
  "dist",
140
151
  "extensions",
141
152
  "src",
142
153
  "contracts",
154
+ "herdr-plugin",
143
155
  "README.md",
144
156
  "CHANGELOG.md",
145
157
  "LICENSE"
146
158
  ],
147
159
  "scripts": {
148
- "build": "tsc -p tsconfig.json",
160
+ "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
149
161
  "typecheck": "tsc -p tsconfig.check.json",
150
162
  "test": "node --test test/*.test.ts",
151
163
  "test:integration": "node --test test-integration/*.it.ts",
152
- "contracts:generate": "node scripts/generate-ledger-v2-contract.ts",
164
+ "contracts:generate": "node scripts/generate-ledger-v3-contract.ts",
153
165
  "prepack": "npm run build",
154
166
  "test:smoke": "node scripts/smoke-installed.mjs",
155
167
  "test:mutation": "node scripts/mutation-audit.mjs"
@@ -19,6 +19,8 @@ import {
19
19
  leaseAcquisitionOutcome,
20
20
  leaseReleaseLedgerOutcome,
21
21
  } from "./workspace.ts";
22
+ import { newExecutionId } from "./execution-id.ts";
23
+ import { isLedgerDisplayIdentifier } from "./ledger-identifiers.ts";
22
24
 
23
25
  export interface CheckDefinition {
24
26
  /** Absolute operator-owned executable. PATH lookup is deliberately not part of the contract. */
@@ -85,6 +87,12 @@ export function buildCheckEnvironment(
85
87
  }
86
88
 
87
89
  function validateDefinition(checkId: string, value: CheckDefinition | undefined): CheckDefinition {
90
+ // `check:<id>:<uuid>` is also the v3 childId, whose closed display-identifier bound is 512 characters.
91
+ if (!isLedgerDisplayIdentifier(checkId) || checkId.length > 460) {
92
+ throw new GovernanceRefusal(refusal(
93
+ "CHECK_CONFIGURATION_INVALID", `check ${JSON.stringify(checkId)} must be an ASCII identifier`, { check_id: checkId },
94
+ ));
95
+ }
88
96
  if (!value) {
89
97
  throw new GovernanceRefusal(refusal(
90
98
  "CHECK_NOT_CONFIGURED", `check ${JSON.stringify(checkId)} is not present in operator-owned configuration`, { check_id: checkId },
@@ -147,6 +155,8 @@ export async function runNamedCheck(input: {
147
155
  // governed writer. `access` still records what the configured executable is intended to do.
148
156
  const leaseAccess: WorkspaceAccess = "write";
149
157
  const ownerId = `check:${input.checkId}:${randomUUID()}`;
158
+ const executionId = newExecutionId();
159
+ const parentExecutionId = null;
150
160
  const correlation = normaliseCorrelation(input.correlation);
151
161
  let lease: Awaited<ReturnType<typeof acquireWorkspaceLease>> | undefined;
152
162
  let stagedDir: string | undefined;
@@ -163,6 +173,7 @@ export async function runNamedCheck(input: {
163
173
  if (input.ledgerPath) await appendLedgerEvent(
164
174
  { path: input.ledgerPath, strict: true },
165
175
  buildWorkspaceLeaseEvent({
176
+ executionId, parentExecutionId,
166
177
  childId: ownerId, workspaceId: input.workspace.workspaceId, root: input.workspace.root, access: leaseAccess,
167
178
  outcome: "refused",
168
179
  correlation, now: new Date(),
@@ -178,6 +189,7 @@ export async function runNamedCheck(input: {
178
189
  await appendLedgerEvent(
179
190
  { path: input.ledgerPath, strict: true },
180
191
  buildWorkspaceLeaseEvent({
192
+ executionId, parentExecutionId,
181
193
  childId: ownerId, workspaceId: input.workspace.workspaceId, root: input.workspace.root, access: leaseAccess,
182
194
  outcome: leaseAcquisitionOutcome(leaseAccess, lease.recovered), recovered: lease.recovered,
183
195
  correlation, now: new Date(),
@@ -256,6 +268,7 @@ export async function runNamedCheck(input: {
256
268
  if (input.ledgerPath) await appendLedgerEvent(
257
269
  { path: input.ledgerPath, strict: true },
258
270
  buildWorkspaceLeaseEvent({
271
+ executionId, parentExecutionId,
259
272
  childId: ownerId, workspaceId: input.workspace.workspaceId, root: input.workspace.root, access: leaseAccess,
260
273
  outcome: leaseReleaseLedgerOutcome(releaseOutcome, releaseReason), releaseReason, correlation, now: new Date(),
261
274
  }),
@@ -274,6 +287,7 @@ export async function runNamedCheck(input: {
274
287
  await appendLedgerEvent(
275
288
  { path: input.ledgerPath, strict: true },
276
289
  buildCheckReceiptLedgerEvent({
290
+ executionId, parentExecutionId,
277
291
  childId: ownerId, receiptId: receipt.receipt_id, workspaceId: input.workspace.workspaceId,
278
292
  checkId: input.checkId, treeSha: receipt.tree_sha, correlation, now: ended,
279
293
  }),
@@ -304,6 +318,7 @@ export async function runNamedCheck(input: {
304
318
  onFailure: (cause) => { notes.push(`check refusal record failed: ${String(cause)}`); },
305
319
  },
306
320
  buildWorkspaceLeaseEvent({
321
+ executionId, parentExecutionId,
307
322
  childId: ownerId, workspaceId: input.workspace.workspaceId, root: input.workspace.root, access: leaseAccess,
308
323
  outcome: "refused",
309
324
  refusal: error instanceof GovernanceRefusal
@@ -327,6 +342,7 @@ export async function runNamedCheck(input: {
327
342
  onFailure: (cause) => { notes.push(`check lease release record failed: ${String(cause)}`); },
328
343
  },
329
344
  buildWorkspaceLeaseEvent({
345
+ executionId, parentExecutionId,
330
346
  childId: ownerId, workspaceId: input.workspace.workspaceId, root: input.workspace.root, access: leaseAccess,
331
347
  outcome: outcome === "lost"
332
348
  ? "lost"
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import type { DefinitionDigest } from "./definitions.ts";
3
3
  import type { Capability } from "./resolve.ts";
4
4
  import { GovernanceRefusal, refusal } from "./refusals.ts";
5
+ import { isLedgerCorrelationIdentifier } from "./ledger-identifiers.ts";
5
6
 
6
7
  export type JsonPrimitive = string | number | boolean | null;
7
8
  export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
@@ -71,6 +72,13 @@ const CORRELATION_NUMERIC = new Set<keyof CorrelationMetadata>([
71
72
  "event_seq", "last_change_seq", "last_authority_seq",
72
73
  ]);
73
74
 
75
+ // These fields are rendered as labels/identities. Treating arbitrary prose as an "id" let a model copy
76
+ // task or output text into the ledger and dashboard through correlation while staying schema-valid.
77
+ const CORRELATION_IDENTIFIERS = new Set<keyof CorrelationMetadata>([
78
+ "schema_version", "run_id", "task_id", "workspace_id", "context_id", "phase", "assurance",
79
+ "assurance_effective", "policy_label", "assurance_source",
80
+ ]);
81
+
74
82
  function correlationRefusal(message: string, details?: Record<string, string | number>): GovernanceRefusal {
75
83
  return new GovernanceRefusal(refusal("CORRELATION_INVALID", `correlation metadata: ${message}`, details));
76
84
  }
@@ -147,6 +155,9 @@ export function normaliseCorrelation(input: CorrelationMetadata | undefined): Co
147
155
  { field: key, limit: MAX_CORRELATION_FIELD_CHARS, actual: value.length },
148
156
  );
149
157
  }
158
+ if (CORRELATION_IDENTIFIERS.has(key as keyof CorrelationMetadata) && !isLedgerCorrelationIdentifier(value)) {
159
+ throw correlationRefusal(`${key} must be an ASCII identifier, not free-form text`, { field: key });
160
+ }
150
161
  output[key] = value;
151
162
  }
152
163
  return output as CorrelationMetadata;