pi-daddy 0.40.0 → 0.41.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 (87) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/contracts/ledger-record/v1/fixtures/capability-decision.json +1 -0
  3. package/contracts/ledger-record/v1/fixtures/child-lifecycle.json +16 -0
  4. package/contracts/ledger-record/v1/fixtures/ledger-record.jsonl +3 -3
  5. package/contracts/ledger-record/v1/fixtures/workspace-lease.json +1 -0
  6. package/contracts/ledger-record/v1/governance-event.schema.json +100 -0
  7. package/dist/advisors/advisor.d.ts +19 -1
  8. package/dist/advisors/advisor.d.ts.map +1 -1
  9. package/dist/advisors/advisor.js +62 -2
  10. package/dist/advisors/advisor.js.map +1 -1
  11. package/dist/advisors/decider.d.ts +2 -4
  12. package/dist/advisors/decider.d.ts.map +1 -1
  13. package/dist/advisors/decider.js.map +1 -1
  14. package/dist/advisors/settings.d.ts +3 -1
  15. package/dist/advisors/settings.d.ts.map +1 -1
  16. package/dist/advisors/settings.js +4 -3
  17. package/dist/advisors/settings.js.map +1 -1
  18. package/dist/executors/activity-session.d.ts +7 -0
  19. package/dist/executors/activity-session.d.ts.map +1 -1
  20. package/dist/executors/activity-session.js +101 -3
  21. package/dist/executors/activity-session.js.map +1 -1
  22. package/dist/governance/ledger-events.d.ts +23 -0
  23. package/dist/governance/ledger-events.d.ts.map +1 -1
  24. package/dist/governance/ledger-events.js +7 -0
  25. package/dist/governance/ledger-events.js.map +1 -1
  26. package/dist/governance/ledger-v3-validation.d.ts.map +1 -1
  27. package/dist/governance/ledger-v3-validation.js +29 -0
  28. package/dist/governance/ledger-v3-validation.js.map +1 -1
  29. package/dist/governance/ledger.d.ts +5 -1
  30. package/dist/governance/ledger.d.ts.map +1 -1
  31. package/dist/governance/ledger.js +4 -0
  32. package/dist/governance/ledger.js.map +1 -1
  33. package/dist/kernel/delegate-types.d.ts +2 -0
  34. package/dist/kernel/delegate-types.d.ts.map +1 -1
  35. package/dist/kernel/delegate-types.js.map +1 -1
  36. package/dist/kernel/delegate.d.ts.map +1 -1
  37. package/dist/kernel/delegate.js +3 -1
  38. package/dist/kernel/delegate.js.map +1 -1
  39. package/dist/kernel/env-names.d.ts +3 -0
  40. package/dist/kernel/env-names.d.ts.map +1 -1
  41. package/dist/kernel/env-names.js +5 -0
  42. package/dist/kernel/env-names.js.map +1 -1
  43. package/dist/kernel/episode-id.d.ts +6 -0
  44. package/dist/kernel/episode-id.d.ts.map +1 -0
  45. package/dist/kernel/episode-id.js +13 -0
  46. package/dist/kernel/episode-id.js.map +1 -0
  47. package/dist/kernel/ledger-identifiers.d.ts.map +1 -1
  48. package/dist/kernel/ledger-identifiers.js +20 -1
  49. package/dist/kernel/ledger-identifiers.js.map +1 -1
  50. package/dist/kernel/propagation.d.ts +4 -2
  51. package/dist/kernel/propagation.d.ts.map +1 -1
  52. package/dist/kernel/propagation.js +6 -2
  53. package/dist/kernel/propagation.js.map +1 -1
  54. package/dist/products/activity-timeline.d.ts +3 -0
  55. package/dist/products/activity-timeline.d.ts.map +1 -1
  56. package/dist/products/activity-timeline.js +6 -0
  57. package/dist/products/activity-timeline.js.map +1 -1
  58. package/extensions/activity-timeline.ts +11 -5
  59. package/extensions/advisor-session.ts +3 -0
  60. package/extensions/chain-ledger.ts +1 -0
  61. package/extensions/delegation-ledger.ts +1 -0
  62. package/extensions/effort-advice.ts +6 -5
  63. package/extensions/execute-child.ts +16 -0
  64. package/extensions/grant-store-refusal.ts +1 -0
  65. package/extensions/grants-command.ts +4 -4
  66. package/extensions/grants.ts +4 -1
  67. package/extensions/pruning-advice.ts +37 -6
  68. package/extensions/reload-environment.ts +1 -0
  69. package/extensions/run-delegation.ts +10 -1
  70. package/extensions/session-environment.ts +9 -0
  71. package/extensions/session.ts +27 -4
  72. package/extensions/workspace-runtime.ts +5 -0
  73. package/package.json +1 -1
  74. package/src/advisors/advisor.ts +83 -3
  75. package/src/advisors/decider.ts +2 -4
  76. package/src/advisors/settings.ts +6 -4
  77. package/src/executors/activity-session.ts +105 -3
  78. package/src/governance/ledger-events.ts +35 -1
  79. package/src/governance/ledger-v3-validation.ts +44 -0
  80. package/src/governance/ledger.ts +8 -0
  81. package/src/kernel/delegate-types.ts +2 -0
  82. package/src/kernel/delegate.ts +2 -0
  83. package/src/kernel/env-names.ts +5 -0
  84. package/src/kernel/episode-id.ts +18 -0
  85. package/src/kernel/ledger-identifiers.ts +21 -1
  86. package/src/kernel/propagation.ts +8 -0
  87. package/src/products/activity-timeline.ts +8 -0
@@ -16,7 +16,21 @@ import type { Advice, AdviceRequest, Decider } from "./decider.ts";
16
16
  /** Two seconds. An advisor is on the path of a decision a human is waiting for; it is not worth more than that. */
17
17
  export const DEFAULT_ADVICE_TIMEOUT_MS = 2000;
18
18
 
19
+ export type TaskEgressMode = "digest" | "raw";
20
+
21
+ export interface TaskDigest {
22
+ length: number;
23
+ language: string;
24
+ referencedFiles: { count: number; extensions: string[] };
25
+ }
26
+
19
27
  export interface AdviceRecord {
28
+ /** Stable root episode; absent on records written before episode identity shipped. */
29
+ episodeId?: string;
30
+ /** The governed child this advice applied to; absent on records written before attribution shipped. */
31
+ executionId?: string;
32
+ /** Which task representation was sent; absent on records written before the egress split shipped. */
33
+ taskEgress?: TaskEgressMode;
20
34
  /** Which decision this advice was for, from the caller's own closed list. */
21
35
  purpose: string;
22
36
  decider: string;
@@ -36,7 +50,8 @@ export interface AdviceRecord {
36
50
  }
37
51
 
38
52
  export interface Advisor {
39
- ask(purpose: string, request: AdviceRequest, signal?: AbortSignal): Promise<Advice | null>;
53
+ task(task: string): string | TaskDigest;
54
+ ask(purpose: string, request: AdviceRequest, signal?: AbortSignal, executionId?: string): Promise<Advice | null>;
40
55
  }
41
56
 
42
57
  export function createAdvisor(input: {
@@ -46,12 +61,29 @@ export function createAdvisor(input: {
46
61
  timeoutMs?: number;
47
62
  /** Absent or false means the null decider is used whatever `decider` says. */
48
63
  enabled?: boolean;
64
+ episodeId?: string;
65
+ taskEgress?: TaskEgressMode;
49
66
  }): Advisor {
50
67
  const timeoutMs = input.timeoutMs ?? DEFAULT_ADVICE_TIMEOUT_MS;
68
+ const taskEgress = input.taskEgress ?? "digest";
51
69
  return {
52
- async ask(purpose, request, signal) {
70
+ task(task) {
71
+ if (taskEgress === "raw") {
72
+ warnRawTaskEgress();
73
+ return task;
74
+ }
75
+ return digestTask(task);
76
+ },
77
+ async ask(purpose, request, signal, executionId) {
53
78
  const started = Date.now();
54
- const base = { purpose, decider: input.decider.name, questions: Object.keys(request.questions) };
79
+ const base = {
80
+ ...(input.episodeId ? { episodeId: input.episodeId } : {}),
81
+ ...(executionId ? { executionId } : {}),
82
+ taskEgress,
83
+ purpose,
84
+ decider: input.decider.name,
85
+ questions: Object.keys(request.questions),
86
+ };
55
87
  const write = async (entry: AdviceRecord) => {
56
88
  try {
57
89
  await input.record(entry);
@@ -111,6 +143,54 @@ export function createAdvisor(input: {
111
143
  };
112
144
  }
113
145
 
146
+ let rawTaskEgressWarned = false;
147
+
148
+ function warnRawTaskEgress(): void {
149
+ if (rawTaskEgressWarned) return;
150
+ rawTaskEgressWarned = true;
151
+ console.warn("pi-daddy: raw advisor task egress is enabled");
152
+ }
153
+
154
+ function digestTask(task: string): TaskDigest {
155
+ const files = [...task.matchAll(/\b(?:[\w.-]+\/)*[\w-]+\.[A-Za-z0-9]{1,10}\b/g)].map((match) => match[0]);
156
+ const extensions = [
157
+ ...new Set(files.map((file) => `.${file.slice(file.lastIndexOf(".") + 1).toLowerCase()}`)),
158
+ ].sort();
159
+ const languages = new Set(extensions.map(languageForExtension).filter((language) => language !== "unknown"));
160
+ return {
161
+ length: task.length,
162
+ language: languages.size === 0 ? "unknown" : languages.size === 1 ? [...languages][0] : "mixed",
163
+ referencedFiles: { count: files.length, extensions },
164
+ };
165
+ }
166
+
167
+ function languageForExtension(extension: string): string {
168
+ const languages: Readonly<Record<string, string>> = {
169
+ ".c": "c",
170
+ ".cpp": "cpp",
171
+ ".css": "css",
172
+ ".go": "go",
173
+ ".html": "html",
174
+ ".java": "java",
175
+ ".js": "javascript",
176
+ ".jsx": "javascript",
177
+ ".json": "json",
178
+ ".md": "markdown",
179
+ ".php": "php",
180
+ ".py": "python",
181
+ ".rb": "ruby",
182
+ ".rs": "rust",
183
+ ".sh": "shell",
184
+ ".sql": "sql",
185
+ ".swift": "swift",
186
+ ".ts": "typescript",
187
+ ".tsx": "typescript",
188
+ ".yaml": "yaml",
189
+ ".yml": "yaml",
190
+ };
191
+ return languages[extension] ?? "unknown";
192
+ }
193
+
114
194
  /** The bound fired, the caller went away, or neither — three different facts a reviewer needs to tell apart. */
115
195
  function outcomeFor(
116
196
  timer: AbortSignal,
@@ -35,10 +35,8 @@ export interface AdviceRequest {
35
35
  * What the advisor is told about the situation, composed by the caller.
36
36
  *
37
37
  * **Sent, never recorded.** The task is never STORED (ADR-0021) and that still holds — `createAdvisor` writes the
38
- * question keys and the answers and never this object. But an advisor cannot judge a task it cannot see, so a
39
- * caller that needs one judged does send it, and the operator's consent for that is the advisor being off by
40
- * default. An earlier draft of this paragraph said the raw task "must not be shipped to a third party either",
41
- * which the first decision point then did; the rule that survived review is the narrower and true one.
38
+ * question keys and the answers and never this object. Raw task text crosses only when the independent egress
39
+ * switch permits it; otherwise callers send a structural digest. Neither representation is copied into the record.
42
40
  */
43
41
  state: Readonly<Record<string, unknown>>;
44
42
  questions: Readonly<Record<string, Question>>;
@@ -25,9 +25,9 @@
25
25
 
26
26
  // Spelled once, in the kernel's table, so this layer cannot drift from the list `childEnv` refuses to write.
27
27
  export { ENV_ADVISOR_KEY as ADVISOR_KEY_ENV } from "../kernel/env-names.ts";
28
- import { ENV_ADVISOR, ENV_ADVISOR_KEY, ENV_ADVISOR_MODEL } from "../kernel/env-names.ts";
29
- import { DEFAULT_ADVICE_TIMEOUT_MS } from "./advisor.ts";
30
- export { ENV_ADVISOR_MODEL } from "../kernel/env-names.ts";
28
+ import { ENV_ADVISOR, ENV_ADVISOR_KEY, ENV_ADVISOR_MODEL, ENV_ADVISOR_TASK_EGRESS } from "../kernel/env-names.ts";
29
+ import { DEFAULT_ADVICE_TIMEOUT_MS, type TaskEgressMode } from "./advisor.ts";
30
+ export { ENV_ADVISOR_MODEL, ENV_ADVISOR_TASK_EGRESS } from "../kernel/env-names.ts";
31
31
  export { ENV_ADVISOR } from "../kernel/env-names.ts";
32
32
 
33
33
  export interface AdvisorSettings {
@@ -37,11 +37,12 @@ export interface AdvisorSettings {
37
37
  /** Overrides the adapter's pinned model id; absent means the adapter's own default. */
38
38
  model?: string;
39
39
  timeoutMs?: number;
40
+ taskEgress: TaskEgressMode;
40
41
  /** Why an advisor is off when the settings asked for one on — reported, never silently applied. */
41
42
  refusal?: string;
42
43
  }
43
44
 
44
- export const ADVISOR_OFF: AdvisorSettings = Object.freeze({ enabled: false, decider: "none" });
45
+ export const ADVISOR_OFF: AdvisorSettings = Object.freeze({ enabled: false, decider: "none", taskEgress: "digest" });
45
46
 
46
47
  /**
47
48
  * Read the `advisor` block of a project settings file. Absent is off; malformed is off WITH a reason.
@@ -103,6 +104,7 @@ export function advisorSettingsFrom(raw: unknown, env: NodeJS.ProcessEnv = proce
103
104
  return {
104
105
  enabled: true,
105
106
  decider: "jev",
107
+ taskEgress: env[ENV_ADVISOR_TASK_EGRESS]?.trim() === "raw" ? "raw" : "digest",
106
108
  ...(model ? { model } : {}),
107
109
  // Clamped, never raised: a longer bound is not a narrowing either, and a child-writable 30s would be a stall on
108
110
  // every delegation.
@@ -8,10 +8,18 @@
8
8
  * already carries `--session` (native-session retention), that file is the probe; otherwise a private temporary
9
9
  * file is allocated for the run and removed afterwards.
10
10
  */
11
- import { mkdtemp, rm, stat } from "node:fs/promises";
11
+ import { createReadStream } from "node:fs";
12
+ import { mkdtemp, readdir, rm, stat } from "node:fs/promises";
13
+ import { createInterface } from "node:readline";
14
+ import type { ChildUsageTotals } from "../governance/ledger-events.ts";
12
15
  import { tmpdir } from "node:os";
13
16
  import { join } from "node:path";
14
17
 
18
+ export interface ChildUsageObservation {
19
+ usage?: ChildUsageTotals;
20
+ unavailable?: "session-missing" | "session-invalid" | "usage-missing";
21
+ }
22
+
15
23
  export interface ActivitySession {
16
24
  /** The child argv, with `--session <file>` in place of `--no-session` when a file was allocated here. */
17
25
  readonly args: string[];
@@ -19,6 +27,8 @@ export interface ActivitySession {
19
27
  readonly path: string;
20
28
  /** A marker that changes whenever pi appended to the file; `undefined` until the file exists. */
21
29
  probe(): Promise<string | undefined>;
30
+ /** Aggregate only the current child turn's usage; no transcript content leaves this reader. */
31
+ usage(): Promise<ChildUsageObservation>;
22
32
  /** Remove the temporary file, if this run allocated one. Never removes a retention target. */
23
33
  dispose(): Promise<void>;
24
34
  }
@@ -50,7 +60,13 @@ export async function activitySessionFor(planArgs: string[], executionId: string
50
60
  const fork = planArgs.indexOf("--fork");
51
61
  if (fork >= 0) {
52
62
  const dir = planArgs[planArgs.indexOf("--session-dir") + 1];
53
- return { args: planArgs, path: dir, probe: probeDirectory(dir), dispose: async () => undefined };
63
+ return {
64
+ args: planArgs,
65
+ path: dir,
66
+ probe: probeDirectory(dir),
67
+ usage: () => readChildUsage(newestSessionFile(dir)),
68
+ dispose: async () => undefined,
69
+ };
54
70
  }
55
71
  const flag = planArgs.indexOf("--session");
56
72
  const probeFor = (path: string) => async () => {
@@ -63,7 +79,13 @@ export async function activitySessionFor(planArgs: string[], executionId: string
63
79
  };
64
80
  if (flag >= 0 && planArgs[flag + 1]) {
65
81
  const path = planArgs[flag + 1];
66
- return { args: planArgs, path, probe: probeFor(path), dispose: async () => undefined };
82
+ return {
83
+ args: planArgs,
84
+ path,
85
+ probe: probeFor(path),
86
+ usage: () => readChildUsage(path),
87
+ dispose: async () => undefined,
88
+ };
67
89
  }
68
90
  // Private to this uid (mkdtemp is 0o700) and named by the execution so a leaked directory is attributable.
69
91
  const directory = await mkdtemp(join(tmpdir(), `pi-daddy-${executionId.replace(/[^a-zA-Z0-9_-]/g, "_")}-`));
@@ -77,6 +99,86 @@ export async function activitySessionFor(planArgs: string[], executionId: string
77
99
  args,
78
100
  path,
79
101
  probe: probeFor(path),
102
+ usage: () => readChildUsage(path),
80
103
  dispose: () => rm(directory, { recursive: true, force: true }).catch(() => undefined),
81
104
  };
82
105
  }
106
+
107
+ async function newestSessionFile(directory: string): Promise<string | undefined> {
108
+ try {
109
+ const entries = await Promise.all(
110
+ (await readdir(directory))
111
+ .filter((name) => name.endsWith(".jsonl"))
112
+ .map(async (name) => ({ path: join(directory, name), modified: (await stat(join(directory, name))).mtimeMs })),
113
+ );
114
+ return entries.sort((a, b) => b.modified - a.modified)[0]?.path;
115
+ } catch {
116
+ return undefined;
117
+ }
118
+ }
119
+
120
+ const TOKEN_FIELDS = ["input", "output", "cacheRead", "cacheWrite", "totalTokens"] as const;
121
+ const COST_FIELDS = ["input", "output", "cacheRead", "cacheWrite", "total"] as const;
122
+
123
+ async function readChildUsage(path: string | Promise<string | undefined>): Promise<ChildUsageObservation> {
124
+ const resolved = await path;
125
+ if (!resolved) return { unavailable: "session-missing" };
126
+ const totals = emptyUsage();
127
+ let found = false;
128
+ let reasoning = 0;
129
+ let sawReasoning = false;
130
+ try {
131
+ const lines = createInterface({ input: createReadStream(resolved, { encoding: "utf8" }), crlfDelay: Infinity });
132
+ for await (const line of lines) {
133
+ let entry: unknown;
134
+ try {
135
+ entry = JSON.parse(line);
136
+ } catch {
137
+ return { unavailable: "session-invalid" };
138
+ }
139
+ const message = object(object(entry)?.message);
140
+ if (!message) continue;
141
+ if (message.role === "user") {
142
+ Object.assign(totals, emptyUsage());
143
+ found = false;
144
+ reasoning = 0;
145
+ sawReasoning = false;
146
+ continue;
147
+ }
148
+ if (message.role !== "assistant" && message.role !== "toolResult") continue;
149
+ const usage = object(message.usage);
150
+ if (message.role === "toolResult" && !usage) continue;
151
+ const cost = object(usage?.cost);
152
+ if (!usage || !cost) return { unavailable: "session-invalid" };
153
+ if (!TOKEN_FIELDS.every((field) => nonNegative(usage[field]))) return { unavailable: "session-invalid" };
154
+ if (!COST_FIELDS.every((field) => nonNegative(cost[field]))) return { unavailable: "session-invalid" };
155
+ if (usage.reasoning !== undefined && !nonNegative(usage.reasoning)) return { unavailable: "session-invalid" };
156
+ for (const field of TOKEN_FIELDS) totals[field] += usage[field] as number;
157
+ for (const field of COST_FIELDS) totals.cost[field] += cost[field] as number;
158
+ if (usage.reasoning !== undefined) {
159
+ reasoning += usage.reasoning as number;
160
+ sawReasoning = true;
161
+ }
162
+ found = true;
163
+ }
164
+ return found ? { usage: { ...totals, ...(sawReasoning ? { reasoning } : {}) } } : { unavailable: "usage-missing" };
165
+ } catch {
166
+ return { unavailable: "session-missing" };
167
+ }
168
+ }
169
+
170
+ function emptyUsage(): ChildUsageTotals {
171
+ return {
172
+ input: 0,
173
+ output: 0,
174
+ cacheRead: 0,
175
+ cacheWrite: 0,
176
+ totalTokens: 0,
177
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
178
+ };
179
+ }
180
+
181
+ const object = (value: unknown): Record<string, unknown> | undefined =>
182
+ value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
183
+ const nonNegative = (value: unknown): value is number =>
184
+ typeof value === "number" && Number.isFinite(value) && value >= 0;
@@ -9,6 +9,7 @@ import type { ExecutorKind } from "../kernel/delegate-types.ts";
9
9
  import type { CorrelationMetadata } from "../kernel/correlation.ts";
10
10
  import type { StructuredRefusal } from "../kernel/refusals.ts";
11
11
  import { assertExecutionId } from "../kernel/execution-id.ts";
12
+ import { assertEpisodeId } from "../kernel/episode-id.ts";
12
13
  import { assertLedgerV3Wire } from "./ledger-v3-validation.ts";
13
14
 
14
15
  export const WORKSPACE_ACCESSES = ["read", "write"] as const;
@@ -62,6 +63,22 @@ export const CHILD_PROCESS_SIGNALS = [
62
63
  ] as const satisfies readonly NodeJS.Signals[];
63
64
  export type ChildProcessSignal = (typeof CHILD_PROCESS_SIGNALS)[number];
64
65
 
66
+ export interface ChildUsageTotals {
67
+ input: number;
68
+ output: number;
69
+ cacheRead: number;
70
+ cacheWrite: number;
71
+ reasoning?: number;
72
+ totalTokens: number;
73
+ cost: {
74
+ input: number;
75
+ output: number;
76
+ cacheRead: number;
77
+ cacheWrite: number;
78
+ total: number;
79
+ };
80
+ }
81
+
65
82
  /**
66
83
  * `released` is a handover this owner performed. FOUR members were added by the 0.18.0 review pass, and
67
84
  * they were not all previously recorded the same way — `uncontended` was recorded as an *acquisition*, and
@@ -123,6 +140,10 @@ export interface ChildLifecycleEvent extends LedgerEventBase {
123
140
  reason?: string;
124
141
  /** The inactivity bound (ms) that governed this child beside the `deadlineAt` ceiling (PR 3e). */
125
142
  idleTimeoutMs?: number;
143
+ /** Aggregate model usage read from the child's pi session file after it stopped. */
144
+ usage?: ChildUsageTotals;
145
+ /** Why totals could not be read; a fixed code, never transcript content. */
146
+ usageUnavailable?: "session-missing" | "session-invalid" | "usage-missing";
126
147
  }
127
148
 
128
149
  export type CapabilityDecisionEvent = GrantRecord & {
@@ -136,6 +157,7 @@ export type CapabilityDecisionEvent = GrantRecord & {
136
157
  export type RuntimeLedgerEvent = CapabilityDecisionEvent | WorkspaceLeaseEvent | ChildLifecycleEvent;
137
158
 
138
159
  export function buildWorkspaceLeaseEvent(args: {
160
+ episodeId?: string;
139
161
  executionId: string;
140
162
  parentExecutionId: string | null;
141
163
  childId: string;
@@ -154,6 +176,7 @@ export function buildWorkspaceLeaseEvent(args: {
154
176
  ledgerVersion: LEDGER_VERSION,
155
177
  event: "workspace_lease",
156
178
  ts: args.now.toISOString(),
179
+ ...(args.episodeId ? { episodeId: args.episodeId } : {}),
157
180
  executionId: args.executionId,
158
181
  parentExecutionId: args.parentExecutionId,
159
182
  childId: args.childId,
@@ -173,6 +196,7 @@ export function buildWorkspaceLeaseEvent(args: {
173
196
  }
174
197
 
175
198
  export function buildChildLifecycleEvent(args: {
199
+ episodeId?: string;
176
200
  executionId: string;
177
201
  parentExecutionId: string | null;
178
202
  childId: string;
@@ -188,6 +212,8 @@ export function buildChildLifecycleEvent(args: {
188
212
  aborted?: boolean;
189
213
  truncated?: boolean;
190
214
  reason?: string;
215
+ usage?: ChildUsageTotals;
216
+ usageUnavailable?: "session-missing" | "session-invalid" | "usage-missing";
191
217
  correlation?: CorrelationMetadata;
192
218
  now: Date;
193
219
  }): ChildLifecycleEvent {
@@ -196,6 +222,7 @@ export function buildChildLifecycleEvent(args: {
196
222
  ledgerVersion: LEDGER_VERSION,
197
223
  event: "child_lifecycle",
198
224
  ts: args.now.toISOString(),
225
+ ...(args.episodeId ? { episodeId: args.episodeId } : {}),
199
226
  executionId: args.executionId,
200
227
  parentExecutionId: args.parentExecutionId,
201
228
  childId: args.childId,
@@ -211,11 +238,18 @@ export function buildChildLifecycleEvent(args: {
211
238
  ...(args.aborted ? { aborted: true } : {}),
212
239
  ...(args.truncated ? { truncated: true } : {}),
213
240
  ...(args.reason ? { reason: args.reason } : {}),
241
+ ...(args.usage ? { usage: structuredClone(args.usage) } : {}),
242
+ ...(args.usageUnavailable ? { usageUnavailable: args.usageUnavailable } : {}),
214
243
  ...(args.correlation ? { correlation: structuredClone(args.correlation) } : {}),
215
244
  });
216
245
  }
217
246
 
218
- function assertEventIdentity(args: { executionId: string; parentExecutionId: string | null }): void {
247
+ function assertEventIdentity(args: {
248
+ episodeId?: string;
249
+ executionId: string;
250
+ parentExecutionId: string | null;
251
+ }): void {
252
+ if (args.episodeId !== undefined) assertEpisodeId(args.episodeId);
219
253
  assertExecutionId(args.executionId);
220
254
  if (args.parentExecutionId !== null) assertExecutionId(args.parentExecutionId, "parentExecutionId");
221
255
  if (args.parentExecutionId === args.executionId) throw new TypeError("an execution cannot be its own parent");
@@ -1,5 +1,6 @@
1
1
  import { normaliseCorrelation, type CorrelationMetadata } from "../kernel/correlation.ts";
2
2
  import { isExecutionId } from "../kernel/execution-id.ts";
3
+ import { isEpisodeId } from "../kernel/episode-id.ts";
3
4
  import { REFUSAL_CODES } from "../kernel/refusals.ts";
4
5
  import { isLedgerCapabilityIdentifier, isLedgerDisplayIdentifier } from "../kernel/ledger-identifiers.ts";
5
6
 
@@ -77,6 +78,7 @@ const FIELDS = {
77
78
  "ledgerVersion",
78
79
  "event",
79
80
  "ts",
81
+ "episodeId",
80
82
  "executionId",
81
83
  "parentExecutionId",
82
84
  "parentId",
@@ -113,6 +115,7 @@ const FIELDS = {
113
115
  "ledgerVersion",
114
116
  "event",
115
117
  "ts",
118
+ "episodeId",
116
119
  "executionId",
117
120
  "parentExecutionId",
118
121
  "childId",
@@ -129,6 +132,7 @@ const FIELDS = {
129
132
  "ledgerVersion",
130
133
  "event",
131
134
  "ts",
135
+ "episodeId",
132
136
  "executionId",
133
137
  "parentExecutionId",
134
138
  "childId",
@@ -142,6 +146,8 @@ const FIELDS = {
142
146
  "reason",
143
147
  "deadlineAt",
144
148
  "idleTimeoutMs",
149
+ "usage",
150
+ "usageUnavailable",
145
151
  "herdrPaneId",
146
152
  "herdrAgentName",
147
153
  "correlation",
@@ -159,6 +165,9 @@ export function isNonEmptyString(value: unknown): value is string {
159
165
  return typeof value === "string" && value.length > 0;
160
166
  }
161
167
 
168
+ const nonNegativeNumber = (value: unknown): value is number =>
169
+ typeof value === "number" && Number.isFinite(value) && value >= 0;
170
+
162
171
  /** Ledger timestamp profile: JSON Schema `date-time`, narrowed to seconds 00-59 for JS date arithmetic. */
163
172
  export function isTimestamp(value: unknown): value is string {
164
173
  if (typeof value !== "string") return false;
@@ -245,6 +254,7 @@ function validApprovalUse(value: unknown): boolean {
245
254
 
246
255
  function validateBase(event: LedgerV3Object): string | null {
247
256
  if (!isTimestamp(event.ts)) return "ts must be an RFC 3339 timestamp";
257
+ if (!optional(event, "episodeId", isEpisodeId)) return "episodeId is invalid";
248
258
  if (!isExecutionId(event.executionId)) return "executionId is missing or invalid";
249
259
  if (event.parentExecutionId !== null && !isExecutionId(event.parentExecutionId)) {
250
260
  return "parentExecutionId must be an execution id or null";
@@ -317,6 +327,33 @@ function validateWorkspaceLease(event: LedgerV3Object): string | null {
317
327
  return null;
318
328
  }
319
329
 
330
+ function validUsage(value: unknown): boolean {
331
+ if (
332
+ !isLedgerObject(value) ||
333
+ Object.keys(value).some(
334
+ (key) => !["input", "output", "cacheRead", "cacheWrite", "reasoning", "totalTokens", "cost"].includes(key),
335
+ )
336
+ )
337
+ return false;
338
+ const tokenFields = ["input", "output", "cacheRead", "cacheWrite", "totalTokens"];
339
+ if (
340
+ !tokenFields.every(
341
+ (field) => typeof value[field] === "number" && Number.isFinite(value[field]) && (value[field] as number) >= 0,
342
+ )
343
+ )
344
+ return false;
345
+ if (!optional(value, "reasoning", nonNegativeNumber)) return false;
346
+ const cost = value.cost;
347
+ if (
348
+ !isLedgerObject(cost) ||
349
+ Object.keys(cost).some((key) => !["input", "output", "cacheRead", "cacheWrite", "total"].includes(key))
350
+ )
351
+ return false;
352
+ return ["input", "output", "cacheRead", "cacheWrite", "total"].every(
353
+ (field) => typeof cost[field] === "number" && Number.isFinite(cost[field]) && (cost[field] as number) >= 0,
354
+ );
355
+ }
356
+
320
357
  function validateChildLifecycle(event: LedgerV3Object): string | null {
321
358
  if (
322
359
  !["starting", "running", "completed", "failed"].includes(String(event.state)) ||
@@ -336,10 +373,17 @@ function validateChildLifecycle(event: LedgerV3Object): string | null {
336
373
  !optional(event, "aborted", (value) => value === true) ||
337
374
  !optional(event, "truncated", (value) => value === true) ||
338
375
  !optional(event, "reason", (value) => typeof value === "string") ||
376
+ !optional(event, "usage", validUsage) ||
377
+ !optional(event, "usageUnavailable", (value) =>
378
+ ["session-missing", "session-invalid", "usage-missing"].includes(String(value)),
379
+ ) ||
339
380
  !optional(event, "correlation", validCorrelation)
340
381
  ) {
341
382
  return "child lifecycle optional fields are invalid";
342
383
  }
384
+ if (Object.hasOwn(event, "usage") && Object.hasOwn(event, "usageUnavailable")) {
385
+ return "child lifecycle usage and usageUnavailable are mutually exclusive";
386
+ }
343
387
  if (
344
388
  !optional(event, "herdrPaneId", isLedgerDisplayIdentifier) ||
345
389
  !optional(event, "herdrAgentName", isLedgerDisplayIdentifier)
@@ -31,6 +31,7 @@ import type { StructuredRefusal } from "../kernel/refusals.ts";
31
31
  // Type-only, so the cycle with ./ledger-events.ts is erased at runtime.
32
32
  import type { RuntimeLedgerEvent } from "./ledger-events.ts";
33
33
  import { assertExecutionId } from "../kernel/execution-id.ts";
34
+ import { assertEpisodeId } from "../kernel/episode-id.ts";
34
35
  import { assertLedgerV3Wire } from "./ledger-v3-validation.ts";
35
36
 
36
37
  export const LEDGER_VERSION = 3 as const;
@@ -44,6 +45,8 @@ export interface LedgerEventBase {
44
45
  ledgerVersion?: typeof LEDGER_VERSION;
45
46
  event?: LedgerEventKind;
46
47
  ts: string;
48
+ /** Stable root episode; absent only on records written before episode identity shipped. */
49
+ episodeId?: string;
47
50
  /** Unique execution occurrence. Optional only for legacy, unversioned GrantRecord values. */
48
51
  executionId?: string;
49
52
  /** Explicit execution parent; null means the delegating session is not itself a governed child. */
@@ -206,6 +209,8 @@ export interface LedgerOptions {
206
209
  }
207
210
 
208
211
  export function buildRecord(args: {
212
+ /** Stable root episode; optional only for source compatibility with historical callers. */
213
+ episodeId?: string;
209
214
  /** Required whenever taskDigest makes this an explicit v3 event. */
210
215
  executionId?: string;
211
216
  /** Required (including explicit null) whenever taskDigest makes this an explicit v3 event. */
@@ -239,6 +244,7 @@ export function buildRecord(args: {
239
244
  refusal?: StructuredRefusal;
240
245
  now: Date;
241
246
  }): GrantRecord {
247
+ if (args.episodeId !== undefined) assertEpisodeId(args.episodeId);
242
248
  if (args.taskDigest !== undefined) {
243
249
  if (!/^[a-f0-9]{64}$/i.test(args.taskDigest)) throw new TypeError("taskDigest must be a SHA-256 hex digest");
244
250
  assertExecutionId(args.executionId);
@@ -267,6 +273,7 @@ export function buildRecord(args: {
267
273
  }
268
274
  : {}),
269
275
  ts: args.now.toISOString(),
276
+ ...(args.episodeId ? { episodeId: args.episodeId } : {}),
270
277
  parentId: args.parentId,
271
278
  childId: args.childId,
272
279
  depth: args.depth,
@@ -388,6 +395,7 @@ export {
388
395
  type ChildLifecycleEvent,
389
396
  type ChildLifecycleState,
390
397
  type ChildProcessSignal,
398
+ type ChildUsageTotals,
391
399
  type RuntimeLedgerEvent,
392
400
  type WorkspaceAccess,
393
401
  type WorkspaceLeaseEvent,
@@ -59,6 +59,8 @@ export interface DelegationRequest {
59
59
 
60
60
  export interface DelegationContext {
61
61
  ownGrant: Capability[];
62
+ /** Stable identity shared by the root session and every descendant. */
63
+ episodeId?: string;
62
64
  depth: number;
63
65
  maxDepth: number;
64
66
  gated: Capability[];
@@ -17,6 +17,7 @@ import {
17
17
  ENV_APPROVED,
18
18
  ENV_DEPTH,
19
19
  ENV_EXECUTION_ID,
20
+ ENV_EPISODE_ID,
20
21
  ENV_FANOUT,
21
22
  ENV_GATED,
22
23
  ENV_GRANT,
@@ -389,6 +390,7 @@ export function planDelegation(request: DelegationRequest, ctx: DelegationContex
389
390
  if (ctx.fanoutBudget !== undefined) env[ENV_FANOUT] = String(ctx.fanoutBudget);
390
391
  if (ctx.childSpawnId) env[ENV_PARENT_ID] = ctx.childSpawnId;
391
392
  if (ctx.childExecutionId) env[ENV_EXECUTION_ID] = ctx.childExecutionId;
393
+ if (ctx.episodeId) env[ENV_EPISODE_ID] = ctx.episodeId;
392
394
  if (ctx.gated.length > 0) env[ENV_GATED] = ctx.gated.join(",");
393
395
  // Approvals ride down with the grant, but only ever for what this child actually received — so
394
396
  // `approved ⊆ grant` holds at every level (ADR-0010). Written even when empty, so this object states
@@ -23,6 +23,7 @@ export const ENV_APPROVED = "PI_DADDY_APPROVED";
23
23
  export const ENV_FANOUT = "PI_DADDY_FANOUT";
24
24
  export const ENV_PARENT_ID = "PI_DADDY_PARENT_ID";
25
25
  export const ENV_EXECUTION_ID = "PI_DADDY_EXECUTION_ID";
26
+ export const ENV_EPISODE_ID = "PI_DADDY_EPISODE_ID";
26
27
 
27
28
  // Operator preferences a child inherits unchanged. Still governance: they decide executor, deadlines, gates.
28
29
  export const ENV_HERDR = "PI_DADDY_HERDR";
@@ -64,6 +65,8 @@ export const ENV_ADVISOR_KEY = "PI_DADDY_ADVISOR_KEY";
64
65
  export const ENV_ADVISOR = "PI_DADDY_ADVISOR";
65
66
  /** Overrides the adapter's pinned model. In the environment, never the workspace file: a model is a destination. */
66
67
  export const ENV_ADVISOR_MODEL = "PI_DADDY_ADVISOR_MODEL";
68
+ /** Exact `raw` opts into sending task text; absent or any other value uses a structural digest. */
69
+ export const ENV_ADVISOR_TASK_EGRESS = "PI_DADDY_ADVISOR_TASK_EGRESS";
67
70
 
68
71
  /** Every variable that shapes governance. The `childEnv` hook may set none of these. */
69
72
  export const GOVERNANCE_ENV_KEYS: readonly string[] = Object.freeze([
@@ -76,6 +79,7 @@ export const GOVERNANCE_ENV_KEYS: readonly string[] = Object.freeze([
76
79
  ENV_FANOUT,
77
80
  ENV_PARENT_ID,
78
81
  ENV_EXECUTION_ID,
82
+ ENV_EPISODE_ID,
79
83
  ENV_HERDR,
80
84
  ENV_HERDR_WORKSPACE,
81
85
  ENV_HERDR_KEEP_PANE,
@@ -93,6 +97,7 @@ export const GOVERNANCE_ENV_KEYS: readonly string[] = Object.freeze([
93
97
  ENV_ADVISOR_KEY,
94
98
  ENV_ADVISOR,
95
99
  ENV_ADVISOR_MODEL,
100
+ ENV_ADVISOR_TASK_EGRESS,
96
101
  ]);
97
102
 
98
103
  /**
@@ -0,0 +1,18 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ /** Stable identity for one root session and every governed descendant it starts. */
4
+ export type EpisodeId = `episode:${string}`;
5
+
6
+ const EPISODE_ID_RE = /^episode:[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 isEpisodeId(value: unknown): value is EpisodeId {
9
+ return typeof value === "string" && EPISODE_ID_RE.test(value);
10
+ }
11
+
12
+ export function newEpisodeId(): EpisodeId {
13
+ return `episode:${randomUUID()}`;
14
+ }
15
+
16
+ export function assertEpisodeId(value: unknown, field = "episodeId"): asserts value is EpisodeId {
17
+ if (!isEpisodeId(value)) throw new TypeError(`${field} must be a pi-daddy episode id`);
18
+ }