pi-daddy 0.19.0 → 0.20.1

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 +71 -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
@@ -0,0 +1,266 @@
1
+ import { normaliseCorrelation, type CorrelationMetadata } from "./correlation.ts";
2
+ import { isExecutionId } from "./execution-id.ts";
3
+ import { REFUSAL_CODES } from "./refusals.ts";
4
+ import { isWorkflowFactId, isWorkflowIdentifier, workflowFactStateMatches } from "./workflow-fact-id.ts";
5
+ import { isLedgerCapabilityIdentifier, isLedgerDisplayIdentifier } from "./ledger-identifiers.ts";
6
+
7
+ export type LedgerV3Object = Record<string, unknown>;
8
+
9
+ const SHA256_RE = /^[a-f0-9]{64}$/i;
10
+ const EXECUTORS = new Set(["process", "herdr"]);
11
+ const APPROVAL_SOURCES = new Set(["prompt", "session", "persisted", "inherited"]);
12
+ const APPROVAL_SCOPES = new Set(["once", "session", "always"]);
13
+ const GATE_OUTCOMES = new Set(["declined", "dismissed", "no-ui", "error"]);
14
+ const LEASE_OUTCOMES = new Set([
15
+ "acquired", "uncontended", "refused", "released", "released-unrecorded", "lost", "retained", "timeout", "recovered",
16
+ ]);
17
+ const PROCESS_SIGNALS = new Set([
18
+ "SIGABRT", "SIGALRM", "SIGBUS", "SIGCHLD", "SIGCONT", "SIGFPE", "SIGHUP", "SIGILL", "SIGINT", "SIGIO",
19
+ "SIGIOT", "SIGKILL", "SIGPIPE", "SIGPOLL", "SIGPROF", "SIGPWR", "SIGQUIT", "SIGSEGV", "SIGSTKFLT",
20
+ "SIGSTOP", "SIGSYS", "SIGTERM", "SIGTRAP", "SIGTSTP", "SIGTTIN", "SIGTTOU", "SIGUNUSED", "SIGURG",
21
+ "SIGUSR1", "SIGUSR2", "SIGVTALRM", "SIGWINCH", "SIGXCPU", "SIGXFSZ", "SIGBREAK", "SIGLOST", "SIGINFO",
22
+ ]);
23
+
24
+ const FIELDS = {
25
+ capability_decision: new Set([
26
+ "ledgerVersion", "event", "ts", "executionId", "parentExecutionId", "parentId", "childId", "depth",
27
+ "agentType", "requested", "parentGrant", "effective", "denied", "clipped", "gatedBlocked", "blocked",
28
+ "reason", "approved", "approvalSource", "approvalSources", "approvalScopes", "approvalExpiresAt",
29
+ "approvalUses", "approvalScope", "humanDenied", "gateOutcome", "definitionDigest", "executor", "taskFrom",
30
+ "taskFromExecutionId", "taskDigest", "correlation", "refusal",
31
+ ]),
32
+ workspace_lease: new Set([
33
+ "ledgerVersion", "event", "ts", "executionId", "parentExecutionId", "childId", "workspaceId", "root",
34
+ "access", "outcome", "recovered", "releaseReason", "refusal", "correlation",
35
+ ]),
36
+ child_lifecycle: new Set([
37
+ "ledgerVersion", "event", "ts", "executionId", "parentExecutionId", "childId", "state", "executor",
38
+ "exitCode", "signal", "timedOut", "aborted", "truncated", "reason", "deadlineAt", "herdrPaneId",
39
+ "herdrAgentName", "correlation",
40
+ ]),
41
+ check_receipt: new Set([
42
+ "ledgerVersion", "event", "ts", "executionId", "parentExecutionId", "childId", "receiptId", "workspaceId",
43
+ "checkId", "treeSha", "correlation",
44
+ ]),
45
+ workflow_fact: new Set([
46
+ "ledgerVersion", "event", "ts", "factId", "source", "provenance", "kind", "subject", "state", "correlation",
47
+ ]),
48
+ } as const;
49
+ type EventKind = keyof typeof FIELDS;
50
+
51
+ export function isLedgerObject(value: unknown): value is LedgerV3Object {
52
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
53
+ const prototype = Object.getPrototypeOf(value);
54
+ return prototype === Object.prototype || prototype === null;
55
+ }
56
+
57
+ export function isNonEmptyString(value: unknown): value is string {
58
+ return typeof value === "string" && value.length > 0;
59
+ }
60
+
61
+ /** Ledger timestamp profile: JSON Schema `date-time`, narrowed to seconds 00-59 for JS date arithmetic. */
62
+ export function isTimestamp(value: unknown): value is string {
63
+ if (typeof value !== "string") return false;
64
+ const match = /^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:[Zz]|([+-])(\d{2}):(\d{2}))$/.exec(value);
65
+ if (!match) return false;
66
+ const [, yearText, monthText, dayText, hourText, minuteText, secondText, , offsetHourText, offsetMinuteText] = match;
67
+ const year = Number(yearText);
68
+ const month = Number(monthText);
69
+ const day = Number(dayText);
70
+ const hour = Number(hourText);
71
+ const minute = Number(minuteText);
72
+ const second = Number(secondText);
73
+ if (month < 1 || month > 12 || hour > 23 || minute > 59 || second > 59) return false;
74
+ const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
75
+ const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
76
+ if (day < 1 || day > days[month - 1]) return false;
77
+ return offsetHourText === undefined || (Number(offsetHourText) <= 23 && Number(offsetMinuteText) <= 59);
78
+ }
79
+
80
+ export function isStringArray(value: unknown): value is string[] {
81
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
82
+ }
83
+
84
+ function optional(event: LedgerV3Object, field: string, valid: (value: unknown) => boolean): boolean {
85
+ return !Object.hasOwn(event, field) || valid(event[field]);
86
+ }
87
+
88
+ function stringMap(value: unknown, valid: (entry: unknown) => boolean): boolean {
89
+ return isLedgerObject(value) && Object.values(value).every(valid);
90
+ }
91
+
92
+ function capabilityMap(value: unknown, valid: (entry: unknown) => boolean): boolean {
93
+ return isLedgerObject(value) && Object.keys(value).every(isLedgerCapabilityIdentifier) && Object.values(value).every(valid);
94
+ }
95
+
96
+ function validCorrelation(value: unknown): boolean {
97
+ if (!isLedgerObject(value)) return false;
98
+ try {
99
+ const normalised = normaliseCorrelation(value as CorrelationMetadata);
100
+ // `normaliseCorrelation` drops nulls. The closed schema does not, so equality is part of validation.
101
+ return JSON.stringify(normalised) === JSON.stringify(value);
102
+ } catch {
103
+ return false;
104
+ }
105
+ }
106
+
107
+ function validRefusal(value: unknown): boolean {
108
+ if (!isLedgerObject(value) || Object.keys(value).some((key) => !["code", "message", "details"].includes(key))) return false;
109
+ if (!REFUSAL_CODES.includes(value.code as never) || typeof value.message !== "string") return false;
110
+ return optional(value, "details", (details) => isLedgerObject(details) && Object.values(details).every(
111
+ (entry) => entry === null || ["string", "number", "boolean"].includes(typeof entry),
112
+ ));
113
+ }
114
+
115
+ function validDefinitionDigest(value: unknown): boolean {
116
+ return isLedgerObject(value) &&
117
+ Object.keys(value).every((key) => ["name", "source", "sha256"].includes(key)) &&
118
+ typeof value.name === "string" && typeof value.source === "string" && SHA256_RE.test(String(value.sha256));
119
+ }
120
+
121
+ function validApprovalUse(value: unknown): boolean {
122
+ return isLedgerObject(value) && Object.keys(value).every((key) => ["max", "remaining"].includes(key)) &&
123
+ Number.isInteger(value.max) && (value.max as number) >= 0 &&
124
+ Number.isInteger(value.remaining) && (value.remaining as number) >= 0;
125
+ }
126
+
127
+ function validateBase(event: LedgerV3Object): string | null {
128
+ if (!isTimestamp(event.ts)) return "ts must be an RFC 3339 timestamp";
129
+ if (!isExecutionId(event.executionId)) return "executionId is missing or invalid";
130
+ if (event.parentExecutionId !== null && !isExecutionId(event.parentExecutionId)) {
131
+ return "parentExecutionId must be an execution id or null";
132
+ }
133
+ if (event.parentExecutionId === event.executionId) return "an execution cannot be its own parent";
134
+ if (!isLedgerDisplayIdentifier(event.childId)) return "childId is missing or invalid";
135
+ return null;
136
+ }
137
+
138
+ function validateCapabilityDecision(event: LedgerV3Object): string | null {
139
+ if (!isLedgerDisplayIdentifier(event.parentId) || !Number.isInteger(event.depth) || (event.depth as number) < 0) {
140
+ return "capability decision identity is invalid";
141
+ }
142
+ for (const field of ["requested", "parentGrant", "effective", "denied", "clipped", "gatedBlocked"]) {
143
+ if (!isStringArray(event[field]) || !event[field].every(isLedgerCapabilityIdentifier)) {
144
+ return `capability decision ${field} must contain capability identifiers`;
145
+ }
146
+ }
147
+ if (typeof event.blocked !== "boolean" || !EXECUTORS.has(String(event.executor)) || !SHA256_RE.test(String(event.taskDigest))) {
148
+ return "capability decision required fields are invalid";
149
+ }
150
+ if (!optional(event, "agentType", isLedgerDisplayIdentifier) ||
151
+ !optional(event, "reason", (value) => typeof value === "string") ||
152
+ !optional(event, "approved", (value) => isStringArray(value) && value.every(isLedgerCapabilityIdentifier)) ||
153
+ !optional(event, "approvalSource", (value) => APPROVAL_SOURCES.has(String(value))) ||
154
+ !optional(event, "approvalSources", (value) => capabilityMap(value, (entry) => APPROVAL_SOURCES.has(String(entry)))) ||
155
+ !optional(event, "approvalScopes", (value) => capabilityMap(value, (entry) => APPROVAL_SCOPES.has(String(entry)))) ||
156
+ !optional(event, "approvalExpiresAt", (value) => capabilityMap(value, isTimestamp)) ||
157
+ !optional(event, "approvalUses", (value) => capabilityMap(value, validApprovalUse)) ||
158
+ !optional(event, "approvalScope", (value) => APPROVAL_SCOPES.has(String(value))) ||
159
+ !optional(event, "humanDenied", (value) => value === true) ||
160
+ !optional(event, "gateOutcome", (value) => GATE_OUTCOMES.has(String(value))) ||
161
+ !optional(event, "definitionDigest", validDefinitionDigest) ||
162
+ !optional(event, "taskFrom", isLedgerDisplayIdentifier) ||
163
+ !optional(event, "taskFromExecutionId", isExecutionId) ||
164
+ !optional(event, "correlation", validCorrelation) ||
165
+ !optional(event, "refusal", validRefusal)) {
166
+ return "capability decision optional fields are invalid";
167
+ }
168
+ return null;
169
+ }
170
+
171
+ function validateWorkspaceLease(event: LedgerV3Object): string | null {
172
+ if (!isLedgerDisplayIdentifier(event.workspaceId) || !isNonEmptyString(event.root) ||
173
+ !["read", "write"].includes(String(event.access)) || !LEASE_OUTCOMES.has(String(event.outcome))) {
174
+ return "workspace lease required fields are invalid";
175
+ }
176
+ if (!optional(event, "recovered", (value) => value === true || value === false || value === "unknown") ||
177
+ !optional(event, "releaseReason", (value) => typeof value === "string") ||
178
+ !optional(event, "refusal", validRefusal) || !optional(event, "correlation", validCorrelation)) {
179
+ return "workspace lease optional fields are invalid";
180
+ }
181
+ return null;
182
+ }
183
+
184
+ function validateChildLifecycle(event: LedgerV3Object): string | null {
185
+ if (!["starting", "running", "completed", "failed"].includes(String(event.state)) || !EXECUTORS.has(String(event.executor))) {
186
+ return "child lifecycle required fields are invalid";
187
+ }
188
+ if (["starting", "running"].includes(String(event.state)) && !isTimestamp(event.deadlineAt)) {
189
+ return "deadlineAt is required and must be an RFC 3339 timestamp for a non-terminal lifecycle";
190
+ }
191
+ if (!optional(event, "deadlineAt", isTimestamp) ||
192
+ !optional(event, "exitCode", (value) => value === null || Number.isInteger(value)) ||
193
+ !optional(event, "signal", (value) => value === null || PROCESS_SIGNALS.has(String(value))) ||
194
+ !optional(event, "timedOut", (value) => value === true) ||
195
+ !optional(event, "aborted", (value) => value === true) ||
196
+ !optional(event, "truncated", (value) => value === true) ||
197
+ !optional(event, "reason", (value) => typeof value === "string") ||
198
+ !optional(event, "correlation", validCorrelation)) {
199
+ return "child lifecycle optional fields are invalid";
200
+ }
201
+ if (!optional(event, "herdrPaneId", isLedgerDisplayIdentifier) ||
202
+ !optional(event, "herdrAgentName", isLedgerDisplayIdentifier)) {
203
+ return "Herdr runtime identity is invalid";
204
+ }
205
+ if (Object.hasOwn(event, "herdrPaneId") !== Object.hasOwn(event, "herdrAgentName")) {
206
+ return "Herdr runtime identity must be paired";
207
+ }
208
+ if (Object.hasOwn(event, "herdrPaneId") && event.executor !== "herdr") {
209
+ return "Herdr runtime identity requires the Herdr executor";
210
+ }
211
+ return null;
212
+ }
213
+
214
+ function validateCheckReceipt(event: LedgerV3Object): string | null {
215
+ if (!SHA256_RE.test(String(event.receiptId)) || !isLedgerDisplayIdentifier(event.workspaceId) ||
216
+ !isLedgerDisplayIdentifier(event.checkId) || !isNonEmptyString(event.treeSha) ||
217
+ !optional(event, "correlation", validCorrelation)) {
218
+ return "check receipt required fields are invalid";
219
+ }
220
+ return null;
221
+ }
222
+
223
+ function validateWorkflowFact(event: LedgerV3Object): string | null {
224
+ if (!isTimestamp(event.ts) || !isWorkflowFactId(event.factId) ||
225
+ !isWorkflowIdentifier(event.source) || !isWorkflowIdentifier(event.subject) ||
226
+ !["planned", "observed", "controller_validated"].includes(String(event.provenance)) ||
227
+ !["workflow_phase", "inline_skill", "transition"].includes(String(event.kind)) ||
228
+ !["pending", "observed", "started", "completed", "blocked"].includes(String(event.state)) ||
229
+ !validCorrelation(event.correlation) ||
230
+ !isNonEmptyString((event.correlation as LedgerV3Object).run_id)) {
231
+ return "workflow fact required fields are invalid";
232
+ }
233
+ return workflowFactStateMatches(event.provenance, event.state)
234
+ ? null
235
+ : "workflow fact provenance contradicts its state";
236
+ }
237
+
238
+ /** Exact, content-free runtime validation for one closed ledger v3 event. */
239
+ export function validateLedgerV3Event(event: LedgerV3Object): string | null {
240
+ if (event.ledgerVersion !== 3) return "unsupported ledger version";
241
+ const kind = event.event;
242
+ if (typeof kind !== "string" || !Object.hasOwn(FIELDS, kind)) return "unknown ledger event discriminator";
243
+ const allowed = FIELDS[kind as EventKind];
244
+ if (Object.keys(event).some((field) => !allowed.has(field))) return "ledger v3 contains an unsupported field";
245
+ if (kind === "workflow_fact") return validateWorkflowFact(event);
246
+ const base = validateBase(event);
247
+ if (base) return base;
248
+ if (kind === "capability_decision") return validateCapabilityDecision(event);
249
+ if (kind === "workspace_lease") return validateWorkspaceLease(event);
250
+ if (kind === "child_lifecycle") return validateChildLifecycle(event);
251
+ return validateCheckReceipt(event);
252
+ }
253
+
254
+ /** Ensure a public builder cannot emit bytes the closed v3 reader would reject. */
255
+ export function assertLedgerV3Wire<T extends object>(event: T): T {
256
+ let wire: unknown;
257
+ try {
258
+ wire = JSON.parse(JSON.stringify(event));
259
+ } catch (error) {
260
+ throw new TypeError(`invalid ledger v3 event: ${String(error)}`);
261
+ }
262
+ if (!isLedgerObject(wire)) throw new TypeError("invalid ledger v3 event: event is not a JSON object");
263
+ const invalid = validateLedgerV3Event(wire);
264
+ if (invalid) throw new TypeError(`invalid ledger v3 event: ${invalid}`);
265
+ return event;
266
+ }
package/src/ledger.ts CHANGED
@@ -32,27 +32,33 @@ import type { CorrelationMetadata } from "./correlation.ts";
32
32
  import type { StructuredRefusal } from "./refusals.ts";
33
33
  // Type-only, so the cycle with ./ledger-events.ts is erased at runtime.
34
34
  import type { RuntimeLedgerEvent } from "./ledger-events.ts";
35
+ import { assertExecutionId } from "./execution-id.ts";
36
+ import { assertLedgerV3Wire } from "./ledger-v3-validation.ts";
35
37
 
36
- export const LEDGER_VERSION = 2 as const;
38
+ export const LEDGER_VERSION = 3 as const;
37
39
  export const LEDGER_EVENT_KINDS = [
38
- "capability_decision", "workspace_lease", "child_lifecycle", "check_receipt",
40
+ "capability_decision", "workspace_lease", "child_lifecycle", "check_receipt", "workflow_fact",
39
41
  ] as const;
40
42
  export type LedgerEventKind = typeof LEDGER_EVENT_KINDS[number];
41
43
  export const LEDGER_GATE_OUTCOMES = ["declined", "dismissed", "no-ui", "error"] as const;
42
44
  export type LedgerGateOutcome = typeof LEDGER_GATE_OUTCOMES[number];
43
45
 
44
46
  export interface LedgerEventBase {
45
- /** Optional only on the legacy-compatible `GrantRecord` public type; every v2 event builder writes it. */
47
+ /** Optional only on the legacy-compatible `GrantRecord` public type; every v3 event builder writes it. */
46
48
  ledgerVersion?: typeof LEDGER_VERSION;
47
49
  event?: LedgerEventKind;
48
50
  ts: string;
51
+ /** Unique execution occurrence. Optional only for legacy, unversioned GrantRecord values. */
52
+ executionId?: string;
53
+ /** Explicit execution parent; null means the delegating session is not itself a governed child. */
54
+ parentExecutionId?: string | null;
49
55
  childId?: string;
50
56
  correlation?: CorrelationMetadata;
51
57
  }
52
58
 
53
59
  export interface GrantRecord extends LedgerEventBase {
54
60
  /**
55
- * Present only when a trusted `taskDigest` was supplied — that is what makes a line v2. `buildRecord`
61
+ * Present only when a trusted `taskDigest` was supplied — that is what makes a line v3. `buildRecord`
56
62
  * does NOT always emit it, and the comment here used to say it did while the emission site 200 lines
57
63
  * down said the opposite. Two comments asserting contradictory facts about one field is R-28's shape.
58
64
  */
@@ -165,6 +171,8 @@ export interface GrantRecord extends LedgerEventBase {
165
171
  * `definitionDigest` names its instructions, and neither says where the TASK came from.
166
172
  */
167
173
  taskFrom?: string;
174
+ /** Unique occurrence whose output composed this task; taskFrom remains the readable logical position. */
175
+ taskFromExecutionId?: string;
168
176
  /** Trusted SHA-256 of the exact task; task text remains forbidden (ADR-0034). */
169
177
  taskDigest?: string;
170
178
  /** Stable machine-readable refusal accompanying `reason`. */
@@ -189,6 +197,10 @@ export interface LedgerOptions {
189
197
  }
190
198
 
191
199
  export function buildRecord(args: {
200
+ /** Required whenever taskDigest makes this an explicit v3 event. */
201
+ executionId?: string;
202
+ /** Required (including explicit null) whenever taskDigest makes this an explicit v3 event. */
203
+ parentExecutionId?: string | null;
192
204
  parentId: string;
193
205
  childId: string;
194
206
  depth: number;
@@ -208,15 +220,21 @@ export function buildRecord(args: {
208
220
  definitionDigest?: DefinitionDigest;
209
221
  /** Where the child ran (ADR-0031). Required: the probe's answer survives nowhere else. */
210
222
  executor: ExecutorKind;
211
- /** The child whose output composed this task (ADR-0033). Absent for anything but a chain step. */
223
+ /** The logical child whose output composed this task (ADR-0033). */
212
224
  taskFrom?: string;
225
+ /** The unique occurrence whose output composed this task. */
226
+ taskFromExecutionId?: string;
213
227
  taskDigest?: string;
214
228
  correlation?: CorrelationMetadata;
215
229
  refusal?: StructuredRefusal;
216
230
  now: Date;
217
231
  }): GrantRecord {
218
- if (args.taskDigest !== undefined && !/^[a-f0-9]{64}$/i.test(args.taskDigest)) {
219
- throw new TypeError("taskDigest must be a SHA-256 hex digest");
232
+ if (args.taskDigest !== undefined) {
233
+ if (!/^[a-f0-9]{64}$/i.test(args.taskDigest)) throw new TypeError("taskDigest must be a SHA-256 hex digest");
234
+ assertExecutionId(args.executionId);
235
+ if (args.parentExecutionId !== null) assertExecutionId(args.parentExecutionId, "parentExecutionId");
236
+ if (args.parentExecutionId === args.executionId) throw new TypeError("an execution cannot be its own parent");
237
+ if (args.taskFromExecutionId !== undefined) assertExecutionId(args.taskFromExecutionId, "taskFromExecutionId");
220
238
  }
221
239
  if (args.definitionDigest && !/^[a-f0-9]{64}$/i.test(args.definitionDigest.sha256)) {
222
240
  throw new TypeError("definitionDigest.sha256 must be a SHA-256 hex digest");
@@ -227,10 +245,17 @@ export function buildRecord(args: {
227
245
  const distinct = [...new Set(Object.values(sources))];
228
246
  const scopes = args.approvalScopes ?? {};
229
247
  const distinctScopes = [...new Set(Object.values(scopes))];
230
- return {
231
- // A trusted task digest is what distinguishes a v2 capability event from the legacy construction API.
248
+ const record: GrantRecord = {
249
+ // A trusted task digest is what distinguishes a v3 capability event from the legacy construction API.
232
250
  // Production delegation always supplies it; old TypeScript callers remain able to append legacy lines.
233
- ...(args.taskDigest !== undefined ? { ledgerVersion: LEDGER_VERSION, event: "capability_decision" as const } : {}),
251
+ ...(args.taskDigest !== undefined
252
+ ? {
253
+ ledgerVersion: LEDGER_VERSION,
254
+ event: "capability_decision" as const,
255
+ executionId: args.executionId,
256
+ parentExecutionId: args.parentExecutionId,
257
+ }
258
+ : {}),
234
259
  ts: args.now.toISOString(),
235
260
  parentId: args.parentId,
236
261
  childId: args.childId,
@@ -238,6 +263,7 @@ export function buildRecord(args: {
238
263
  agentType: args.agentType,
239
264
  executor: args.executor,
240
265
  ...(args.taskFrom ? { taskFrom: args.taskFrom } : {}),
266
+ ...(args.taskFromExecutionId ? { taskFromExecutionId: args.taskFromExecutionId } : {}),
241
267
  ...(args.taskDigest !== undefined ? { taskDigest: args.taskDigest } : {}),
242
268
  ...(args.correlation ? { correlation: structuredClone(args.correlation) } : {}),
243
269
  ...(args.refusal ? { refusal: structuredClone(args.refusal) } : {}),
@@ -265,6 +291,8 @@ export function buildRecord(args: {
265
291
  ...(args.gateOutcome && args.gateOutcome !== "granted" ? { gateOutcome: args.gateOutcome } : {}),
266
292
  ...(args.definitionDigest ? { definitionDigest: args.definitionDigest } : {}),
267
293
  };
294
+ if (args.taskDigest !== undefined) assertLedgerV3Wire(record);
295
+ return record;
268
296
  }
269
297
 
270
298
  // R-49: the lock moved to `src/file-lock.ts` so the approvals store could use the SAME one rather than
@@ -334,3 +362,14 @@ export {
334
362
  type WorkspaceLeaseOutcome,
335
363
  type WorkspaceRecovery,
336
364
  } from "./ledger-events.ts";
365
+
366
+ export {
367
+ WORKFLOW_FACT_KINDS,
368
+ WORKFLOW_FACT_PROVENANCE,
369
+ WORKFLOW_FACT_STATES,
370
+ buildWorkflowFactEvent,
371
+ type WorkflowFactEvent,
372
+ type WorkflowFactKind,
373
+ type WorkflowFactProvenance,
374
+ type WorkflowFactState,
375
+ } from "./workflow-facts.ts";
@@ -46,6 +46,8 @@ export const ENV_FANOUT = "PI_GRANTS_FANOUT";
46
46
  * Without it every level restarts at `d0` and the ledger cannot be joined into a tree across processes.
47
47
  */
48
48
  export const ENV_PARENT_ID = "PI_GRANTS_PARENT_ID";
49
+ /** Unique identity of this governed execution occurrence; unlike ENV_PARENT_ID it is never reused. */
50
+ export const ENV_EXECUTION_ID = "PI_GRANTS_EXECUTION_ID";
49
51
  export const ENV_DEPTH = "PI_GRANTS_DEPTH";
50
52
  export const ENV_MAX_DEPTH = "PI_GRANTS_MAX_DEPTH";
51
53
  export const ENV_GATED = "PI_GRANTS_GATED";
@@ -68,6 +70,7 @@ export const GRANT_ENV_KEYS = [
68
70
  ENV_APPROVED,
69
71
  ENV_FANOUT,
70
72
  ENV_PARENT_ID,
73
+ ENV_EXECUTION_ID,
71
74
  ] as const;
72
75
 
73
76
  export const parseList = (raw: string | undefined): Capability[] =>
package/src/run-child.ts CHANGED
@@ -13,6 +13,15 @@ import { spawn } from "node:child_process";
13
13
  import { StringDecoder } from "node:string_decoder";
14
14
  import { parseBound } from "./propagation.ts";
15
15
 
16
+ interface RunChildTestControl { hardDeadlineAtAfterSpawn(): number }
17
+ const TEST_CONTROL = Symbol.for("pi-daddy.internal.run-child-test-control");
18
+
19
+ function currentRunChildTestControl(): RunChildTestControl | undefined {
20
+ if (process.env.NODE_TEST_CONTEXT !== "child-v8") return undefined;
21
+ const storage = (globalThis as Record<symbol, { getStore(): RunChildTestControl | undefined }>)[TEST_CONTROL];
22
+ return storage?.getStore();
23
+ }
24
+
16
25
  /**
17
26
  * The longest prefix of `text` that fits in `budget` BYTES, never splitting a character.
18
27
  *
@@ -64,9 +73,11 @@ export interface ChildRunRequest {
64
73
  signal?: AbortSignal;
65
74
  /** Hard cap on captured output. Beyond it the child is killed and the result flagged. */
66
75
  maxOutputBytes?: number;
67
- /** Wall-clock cap. On expiry: SIGTERM, then SIGKILL after `killGraceMs`. */
76
+ /** Soft wall-clock cap. On expiry: SIGTERM, then SIGKILL after `killGraceMs`. */
68
77
  timeoutMs?: number;
69
78
  killGraceMs?: number;
79
+ /** Absolute epoch-millisecond hard cap: SIGKILL fires here even when the SIGTERM grace has not elapsed. */
80
+ hardDeadlineAt?: number;
70
81
  /**
71
82
  * Called with each chunk as it arrives, for the parent's progress display (ADR-0032).
72
83
  *
@@ -106,9 +117,11 @@ export const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
106
117
  export const DEFAULT_KILL_GRACE_MS = 5000;
107
118
 
108
119
  export function runChild(request: ChildRunRequest): Promise<ChildRunResult> {
120
+ const testControl = currentRunChildTestControl();
109
121
  const maxOutputBytes = request.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
110
122
  const timeoutMs = request.timeoutMs ?? DEFAULT_TIMEOUT_MS;
111
123
  const killGraceMs = request.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
124
+ const hardDeadlineAt = request.hardDeadlineAt;
112
125
 
113
126
  // A-R3: checked BEFORE spawning. `AbortSignal` does not replay, so a listener attached after an
114
127
  // `await` cannot observe an abort that already happened — and the child would then run to completion
@@ -160,6 +173,16 @@ export function runChild(request: ChildRunRequest): Promise<ChildRunResult> {
160
173
  timers.push(setTimeout(() => child.kill("SIGKILL"), killGraceMs));
161
174
  };
162
175
 
176
+ // A timer phase may run before libuv delivers an exit that the OS already observed, especially after the
177
+ // controller event loop was blocked across the deadline. Give pending child-process events one turn to
178
+ // publish exitCode/signalCode before deciding that a governed PID is still live.
179
+ const controlIfRunning = (control: () => void) => {
180
+ setImmediate(() => {
181
+ if (done || child.exitCode !== null || child.signalCode !== null) return;
182
+ control();
183
+ });
184
+ };
185
+
163
186
  /** How much of `text` the progress display has already been shown. */
164
187
  let emittedUpTo = 0;
165
188
 
@@ -225,11 +248,20 @@ export function runChild(request: ChildRunRequest): Promise<ChildRunResult> {
225
248
  child.stderr?.on("data", capture);
226
249
 
227
250
  timers.push(
228
- setTimeout(() => {
251
+ setTimeout(() => controlIfRunning(() => {
229
252
  timedOut = true;
230
253
  stop();
231
- }, timeoutMs),
254
+ }), timeoutMs),
232
255
  );
256
+ const activeHardDeadlineAt = testControl?.hardDeadlineAtAfterSpawn() ?? hardDeadlineAt;
257
+ if (activeHardDeadlineAt !== undefined) {
258
+ // Independent of the soft timer and measured from the recorded epoch, not from `spawn`: neither a
259
+ // delayed soft callback nor spawn/setup time may start a fresh grace period beyond the lifecycle bound.
260
+ timers.push(setTimeout(() => controlIfRunning(() => {
261
+ timedOut = true;
262
+ child.kill("SIGKILL");
263
+ }), Math.max(0, activeHardDeadlineAt - Date.now())));
264
+ }
233
265
 
234
266
  const onAbort = () => {
235
267
  aborted = true;
package/src/run-herdr.ts CHANGED
@@ -98,6 +98,8 @@ export interface HerdrRunRequest {
98
98
  * name a human can switch to **while the child is alive**, which is the whole point of the pane surviving.
99
99
  */
100
100
  onPane?: (paneId: string, agentName: string) => void;
101
+ /** Called only after agent start and prompt both succeed; pane creation alone is still `starting`. */
102
+ onRunning?: (paneId: string, agentName: string) => void;
101
103
  /** Security hook for attaching a writer lease to the tab. Unlike display callbacks, errors fail the run. */
102
104
  onTab?: (tabId: string) => void;
103
105
  /** Close even a settled pane before releasing a writer lease; no post-lease prompt may remain live. */
@@ -300,6 +302,13 @@ export async function runHerdrPane(request: HerdrRunRequest): Promise<ChildRunRe
300
302
 
301
303
  const prompted = parseReply(await exec(["agent", "prompt", agentName, request.prompt]));
302
304
  if (prompted.error) return { ...empty, spawnError: `herdr agent prompt failed: ${prompted.error}` };
305
+ if (request.onRunning) {
306
+ try {
307
+ request.onRunning(paneId, agentName);
308
+ } catch {
309
+ /* display/observation only */
310
+ }
311
+ }
303
312
 
304
313
  const settled = await waitForSettled(exec, { ...request, name: agentName }, before, deadline, maxOutputBytes);
305
314
  if (settled.aborted || settled.timedOut) {
@@ -0,0 +1,16 @@
1
+ const WORKFLOW_FACT_ID_RE = /^fact:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
2
+ const WORKFLOW_IDENTIFIER_RE = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
3
+
4
+ export function isWorkflowFactId(value: unknown): value is string {
5
+ return typeof value === "string" && WORKFLOW_FACT_ID_RE.test(value);
6
+ }
7
+
8
+ export function isWorkflowIdentifier(value: unknown): value is string {
9
+ return typeof value === "string" && WORKFLOW_IDENTIFIER_RE.test(value);
10
+ }
11
+
12
+ export function workflowFactStateMatches(provenance: unknown, state: unknown): boolean {
13
+ return (provenance === "planned" && state === "pending") ||
14
+ (provenance === "observed" && state === "observed") ||
15
+ (provenance === "controller_validated" && ["started", "completed", "blocked"].includes(String(state)));
16
+ }
@@ -0,0 +1,65 @@
1
+ import { LEDGER_VERSION } from "./ledger.ts";
2
+ import { normaliseCorrelation, type CorrelationMetadata } from "./correlation.ts";
3
+ import { isWorkflowFactId, isWorkflowIdentifier, workflowFactStateMatches } from "./workflow-fact-id.ts";
4
+ import { assertLedgerV3Wire } from "./ledger-v3-validation.ts";
5
+
6
+ export const WORKFLOW_FACT_PROVENANCE = ["planned", "observed", "controller_validated"] as const;
7
+ export type WorkflowFactProvenance = typeof WORKFLOW_FACT_PROVENANCE[number];
8
+ export const WORKFLOW_FACT_KINDS = ["workflow_phase", "inline_skill", "transition"] as const;
9
+ export type WorkflowFactKind = typeof WORKFLOW_FACT_KINDS[number];
10
+ export const WORKFLOW_FACT_STATES = ["pending", "observed", "started", "completed", "blocked"] as const;
11
+ export type WorkflowFactState = typeof WORKFLOW_FACT_STATES[number];
12
+
13
+ export interface WorkflowFactEvent {
14
+ ledgerVersion: typeof LEDGER_VERSION;
15
+ event: "workflow_fact";
16
+ ts: string;
17
+ factId: string;
18
+ /** Package/controller identifier, not free-form display text. */
19
+ source: string;
20
+ provenance: WorkflowFactProvenance;
21
+ kind: WorkflowFactKind;
22
+ /** Phase, skill, or transition identifier. Never task text. */
23
+ subject: string;
24
+ state: WorkflowFactState;
25
+ /** run_id is required by the builder; other values remain non-authoritative labels. */
26
+ correlation: CorrelationMetadata;
27
+ }
28
+
29
+ export function buildWorkflowFactEvent(args: {
30
+ factId: string;
31
+ source: string;
32
+ provenance: WorkflowFactProvenance;
33
+ kind: WorkflowFactKind;
34
+ subject: string;
35
+ state: WorkflowFactState;
36
+ correlation: CorrelationMetadata;
37
+ now: Date;
38
+ }): WorkflowFactEvent {
39
+ if (!isWorkflowFactId(args.factId)) throw new TypeError("factId must be a pi-daddy workflow fact id");
40
+ if (!isWorkflowIdentifier(args.source)) throw new TypeError("workflow fact source must be an identifier");
41
+ if (!WORKFLOW_FACT_PROVENANCE.includes(args.provenance)) throw new TypeError("workflow fact provenance is not recognised");
42
+ if (!WORKFLOW_FACT_KINDS.includes(args.kind)) throw new TypeError("workflow fact kind is not recognised");
43
+ if (!isWorkflowIdentifier(args.subject)) throw new TypeError("workflow fact subject must be an identifier, not task text");
44
+ if (!WORKFLOW_FACT_STATES.includes(args.state)) throw new TypeError("workflow fact state is not recognised");
45
+ const correlation = normaliseCorrelation(args.correlation);
46
+ if (!correlation?.run_id) throw new TypeError("workflow facts require correlation.run_id");
47
+ if (!workflowFactStateMatches(args.provenance, args.state)) {
48
+ const expected = args.provenance === "planned" ? "pending" : args.provenance === "observed"
49
+ ? "observed" : "started, completed, or blocked";
50
+ throw new TypeError(`${args.provenance.replaceAll("_", "-")} workflow facts must use ${expected}`);
51
+ }
52
+ const event: WorkflowFactEvent = {
53
+ ledgerVersion: LEDGER_VERSION,
54
+ event: "workflow_fact",
55
+ ts: args.now.toISOString(),
56
+ factId: args.factId,
57
+ source: args.source,
58
+ provenance: args.provenance,
59
+ kind: args.kind,
60
+ subject: args.subject,
61
+ state: args.state,
62
+ correlation,
63
+ };
64
+ return assertLedgerV3Wire(event);
65
+ }