pi-daddy 0.41.0 → 0.43.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 (138) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/contracts/ledger-record/v1/fixtures/capability-decision.json +3 -1
  3. package/contracts/ledger-record/v1/fixtures/child-lifecycle.json +25 -0
  4. package/contracts/ledger-record/v1/fixtures/episode-cost-gate.json +14 -0
  5. package/contracts/ledger-record/v1/fixtures/episode-outcome.json +12 -0
  6. package/contracts/ledger-record/v1/fixtures/ledger-record.jsonl +6 -3
  7. package/contracts/ledger-record/v1/fixtures/session-config.json +14 -0
  8. package/contracts/ledger-record/v1/governance-event.schema.json +356 -0
  9. package/dist/advisors/advisor.d.ts +24 -1
  10. package/dist/advisors/advisor.d.ts.map +1 -1
  11. package/dist/advisors/advisor.js +19 -1
  12. package/dist/advisors/advisor.js.map +1 -1
  13. package/dist/advisors/decider.d.ts +11 -0
  14. package/dist/advisors/decider.d.ts.map +1 -1
  15. package/dist/advisors/decider.js.map +1 -1
  16. package/dist/advisors/jev.d.ts.map +1 -1
  17. package/dist/advisors/jev.js +32 -1
  18. package/dist/advisors/jev.js.map +1 -1
  19. package/dist/cli.d.ts +7 -1
  20. package/dist/cli.d.ts.map +1 -1
  21. package/dist/cli.js +87 -1
  22. package/dist/cli.js.map +1 -1
  23. package/dist/executors/activity-session.d.ts +13 -0
  24. package/dist/executors/activity-session.d.ts.map +1 -1
  25. package/dist/executors/activity-session.js +44 -2
  26. package/dist/executors/activity-session.js.map +1 -1
  27. package/dist/governance/episode-cost-gate.d.ts +31 -0
  28. package/dist/governance/episode-cost-gate.d.ts.map +1 -0
  29. package/dist/governance/episode-cost-gate.js +112 -0
  30. package/dist/governance/episode-cost-gate.js.map +1 -0
  31. package/dist/governance/ledger-events.d.ts +108 -1
  32. package/dist/governance/ledger-events.d.ts.map +1 -1
  33. package/dist/governance/ledger-events.js +64 -0
  34. package/dist/governance/ledger-events.js.map +1 -1
  35. package/dist/governance/ledger-v3-validation.d.ts.map +1 -1
  36. package/dist/governance/ledger-v3-validation.js +124 -0
  37. package/dist/governance/ledger-v3-validation.js.map +1 -1
  38. package/dist/governance/ledger.d.ts +8 -2
  39. package/dist/governance/ledger.d.ts.map +1 -1
  40. package/dist/governance/ledger.js +18 -2
  41. package/dist/governance/ledger.js.map +1 -1
  42. package/dist/kernel/context-handoff.d.ts.map +1 -1
  43. package/dist/kernel/context-handoff.js +4 -1
  44. package/dist/kernel/context-handoff.js.map +1 -1
  45. package/dist/kernel/definitions.d.ts +5 -0
  46. package/dist/kernel/definitions.d.ts.map +1 -1
  47. package/dist/kernel/definitions.js +30 -1
  48. package/dist/kernel/definitions.js.map +1 -1
  49. package/dist/kernel/delegate-types.d.ts +4 -0
  50. package/dist/kernel/delegate-types.d.ts.map +1 -1
  51. package/dist/kernel/delegate-types.js.map +1 -1
  52. package/dist/kernel/delegate.d.ts.map +1 -1
  53. package/dist/kernel/delegate.js +20 -5
  54. package/dist/kernel/delegate.js.map +1 -1
  55. package/dist/kernel/env-names.d.ts +5 -0
  56. package/dist/kernel/env-names.d.ts.map +1 -1
  57. package/dist/kernel/env-names.js +9 -0
  58. package/dist/kernel/env-names.js.map +1 -1
  59. package/dist/kernel/grant-env.d.ts +2 -0
  60. package/dist/kernel/grant-env.d.ts.map +1 -1
  61. package/dist/kernel/grant-env.js +1 -0
  62. package/dist/kernel/grant-env.js.map +1 -1
  63. package/dist/kernel/propagation.d.ts +2 -2
  64. package/dist/kernel/propagation.d.ts.map +1 -1
  65. package/dist/kernel/propagation.js +7 -2
  66. package/dist/kernel/propagation.js.map +1 -1
  67. package/dist/products/dashboard-cli.d.ts +4 -0
  68. package/dist/products/dashboard-cli.d.ts.map +1 -1
  69. package/dist/products/dashboard-cli.js +32 -4
  70. package/dist/products/dashboard-cli.js.map +1 -1
  71. package/dist/products/dashboard-handshake.d.ts +8 -0
  72. package/dist/products/dashboard-handshake.d.ts.map +1 -1
  73. package/dist/products/dashboard-handshake.js +3 -0
  74. package/dist/products/dashboard-handshake.js.map +1 -1
  75. package/dist/products/dashboard-herdr.d.ts +4 -0
  76. package/dist/products/dashboard-herdr.d.ts.map +1 -1
  77. package/dist/products/dashboard-herdr.js +10 -2
  78. package/dist/products/dashboard-herdr.js.map +1 -1
  79. package/dist/products/dashboard-projection.d.ts.map +1 -1
  80. package/dist/products/dashboard-projection.js +13 -6
  81. package/dist/products/dashboard-projection.js.map +1 -1
  82. package/dist/products/dashboard-render.d.ts +10 -0
  83. package/dist/products/dashboard-render.d.ts.map +1 -1
  84. package/dist/products/dashboard-render.js +9 -0
  85. package/dist/products/dashboard-render.js.map +1 -1
  86. package/dist/products/dashboard-session-client.d.ts +17 -0
  87. package/dist/products/dashboard-session-client.d.ts.map +1 -0
  88. package/dist/products/dashboard-session-client.js +28 -0
  89. package/dist/products/dashboard-session-client.js.map +1 -0
  90. package/dist/products/episode-outcomes.d.ts +32 -0
  91. package/dist/products/episode-outcomes.d.ts.map +1 -0
  92. package/dist/products/episode-outcomes.js +280 -0
  93. package/dist/products/episode-outcomes.js.map +1 -0
  94. package/dist/products/episode-report.d.ts +59 -0
  95. package/dist/products/episode-report.d.ts.map +1 -0
  96. package/dist/products/episode-report.js +311 -0
  97. package/dist/products/episode-report.js.map +1 -0
  98. package/extensions/chain-ledger.ts +2 -0
  99. package/extensions/chain-plan.ts +4 -2
  100. package/extensions/context-shape.ts +19 -4
  101. package/extensions/dashboard-session-server.ts +86 -0
  102. package/extensions/definition-runtime.ts +93 -0
  103. package/extensions/delegate-chain.ts +31 -2
  104. package/extensions/delegation-ledger.ts +2 -0
  105. package/extensions/delegation.ts +24 -0
  106. package/extensions/effort-advice.ts +12 -0
  107. package/extensions/execute-child.ts +148 -9
  108. package/extensions/grants-command.ts +32 -2
  109. package/extensions/grants.ts +23 -1
  110. package/extensions/pruning-advice.ts +3 -1
  111. package/extensions/run-delegation.ts +54 -6
  112. package/extensions/session-model-prompt.ts +179 -0
  113. package/extensions/session.ts +104 -3
  114. package/package.json +1 -1
  115. package/src/advisors/advisor.ts +45 -2
  116. package/src/advisors/decider.ts +8 -0
  117. package/src/advisors/jev.ts +34 -1
  118. package/src/cli.ts +90 -2
  119. package/src/executors/activity-session.ts +56 -2
  120. package/src/governance/episode-cost-gate.ts +128 -0
  121. package/src/governance/ledger-events.ts +173 -1
  122. package/src/governance/ledger-v3-validation.ts +146 -0
  123. package/src/governance/ledger.ts +29 -1
  124. package/src/kernel/context-handoff.ts +5 -1
  125. package/src/kernel/definitions.ts +32 -1
  126. package/src/kernel/delegate-types.ts +4 -0
  127. package/src/kernel/delegate.ts +22 -5
  128. package/src/kernel/env-names.ts +10 -0
  129. package/src/kernel/grant-env.ts +3 -0
  130. package/src/kernel/propagation.ts +7 -0
  131. package/src/products/dashboard-cli.ts +32 -4
  132. package/src/products/dashboard-handshake.ts +5 -0
  133. package/src/products/dashboard-herdr.ts +15 -1
  134. package/src/products/dashboard-projection.ts +13 -6
  135. package/src/products/dashboard-render.ts +11 -0
  136. package/src/products/dashboard-session-client.ts +35 -0
  137. package/src/products/episode-outcomes.ts +317 -0
  138. package/src/products/episode-report.ts +372 -0
@@ -18,6 +18,16 @@ import { join } from "node:path";
18
18
  export interface ChildUsageObservation {
19
19
  usage?: ChildUsageTotals;
20
20
  unavailable?: "session-missing" | "session-invalid" | "usage-missing";
21
+ resolvedModel?: { provider: string; modelId: string };
22
+ effectiveThinkingLevel?: string;
23
+ tokenDetail?: {
24
+ inputTokens: number | null;
25
+ outputTokens: number | null;
26
+ cacheReadTokens: number | null;
27
+ cacheWriteTokens: number | null;
28
+ reasoningTokens: number | null;
29
+ };
30
+ compactionCount?: number;
21
31
  }
22
32
 
23
33
  export interface ActivitySession {
@@ -127,6 +137,9 @@ async function readChildUsage(path: string | Promise<string | undefined>): Promi
127
137
  let found = false;
128
138
  let reasoning = 0;
129
139
  let sawReasoning = false;
140
+ let resolvedModel: ChildUsageObservation["resolvedModel"];
141
+ let effectiveThinkingLevel: string | undefined;
142
+ let compactionCount = 0;
130
143
  try {
131
144
  const lines = createInterface({ input: createReadStream(resolved, { encoding: "utf8" }), crlfDelay: Infinity });
132
145
  for await (const line of lines) {
@@ -136,16 +149,37 @@ async function readChildUsage(path: string | Promise<string | undefined>): Promi
136
149
  } catch {
137
150
  return { unavailable: "session-invalid" };
138
151
  }
139
- const message = object(object(entry)?.message);
152
+ const parsed = object(entry);
153
+ if (
154
+ parsed?.type === "model_change" &&
155
+ typeof parsed.provider === "string" &&
156
+ typeof parsed.modelId === "string"
157
+ ) {
158
+ resolvedModel = { provider: parsed.provider, modelId: parsed.modelId };
159
+ continue;
160
+ }
161
+ if (parsed?.type === "thinking_level_change" && typeof parsed.thinkingLevel === "string") {
162
+ effectiveThinkingLevel = parsed.thinkingLevel;
163
+ continue;
164
+ }
165
+ if (parsed?.type === "compaction") {
166
+ compactionCount += 1;
167
+ continue;
168
+ }
169
+ const message = object(parsed?.message);
140
170
  if (!message) continue;
141
171
  if (message.role === "user") {
142
172
  Object.assign(totals, emptyUsage());
143
173
  found = false;
144
174
  reasoning = 0;
145
175
  sawReasoning = false;
176
+ compactionCount = 0;
146
177
  continue;
147
178
  }
148
179
  if (message.role !== "assistant" && message.role !== "toolResult") continue;
180
+ if (message.role === "assistant" && typeof message.provider === "string" && typeof message.model === "string") {
181
+ resolvedModel = { provider: message.provider, modelId: message.model };
182
+ }
149
183
  const usage = object(message.usage);
150
184
  if (message.role === "toolResult" && !usage) continue;
151
185
  const cost = object(usage?.cost);
@@ -161,7 +195,26 @@ async function readChildUsage(path: string | Promise<string | undefined>): Promi
161
195
  }
162
196
  found = true;
163
197
  }
164
- return found ? { usage: { ...totals, ...(sawReasoning ? { reasoning } : {}) } } : { unavailable: "usage-missing" };
198
+ if (!found)
199
+ return {
200
+ unavailable: "usage-missing",
201
+ ...(resolvedModel ? { resolvedModel } : {}),
202
+ ...(effectiveThinkingLevel ? { effectiveThinkingLevel } : {}),
203
+ compactionCount,
204
+ };
205
+ return {
206
+ usage: { ...totals, ...(sawReasoning ? { reasoning } : {}) },
207
+ ...(resolvedModel ? { resolvedModel } : {}),
208
+ ...(effectiveThinkingLevel ? { effectiveThinkingLevel } : {}),
209
+ tokenDetail: {
210
+ inputTokens: positiveOrNull(totals.input),
211
+ outputTokens: positiveOrNull(totals.output),
212
+ cacheReadTokens: positiveOrNull(totals.cacheRead),
213
+ cacheWriteTokens: positiveOrNull(totals.cacheWrite),
214
+ reasoningTokens: sawReasoning ? positiveOrNull(reasoning) : null,
215
+ },
216
+ compactionCount,
217
+ };
165
218
  } catch {
166
219
  return { unavailable: "session-missing" };
167
220
  }
@@ -182,3 +235,4 @@ const object = (value: unknown): Record<string, unknown> | undefined =>
182
235
  value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
183
236
  const nonNegative = (value: unknown): value is number =>
184
237
  typeof value === "number" && Number.isFinite(value) && value >= 0;
238
+ const positiveOrNull = (value: number): number | null => (value > 0 ? value : null);
@@ -0,0 +1,128 @@
1
+ /** Per-episode cost threshold state shared by every child started by one session. */
2
+ import { readFile } from "node:fs/promises";
3
+ import { readRecords } from "./record.ts";
4
+
5
+ export const DEFAULT_EPISODE_COST_CEILING = 5;
6
+
7
+ export type EpisodeCostGateAnswer =
8
+ | { cost: number; ceiling: number; outcome: "continued"; newCeiling: number }
9
+ | { cost: number; ceiling: number; outcome: "stopped" };
10
+
11
+ export interface EpisodeCostGateHooks {
12
+ warn(message: string): void;
13
+ pause?(): Promise<unknown> | unknown;
14
+ ask?(): Promise<number | null>;
15
+ resume?(): Promise<unknown> | unknown;
16
+ stop?(): Promise<unknown> | unknown;
17
+ gate(event: EpisodeCostGateAnswer): Promise<unknown> | unknown;
18
+ }
19
+
20
+ export function episodeCostCeilingFromSettings(value: unknown): number {
21
+ if (!value || typeof value !== "object" || Array.isArray(value)) return DEFAULT_EPISODE_COST_CEILING;
22
+ const ceiling = (value as Record<string, unknown>).episodeCostCeiling;
23
+ return typeof ceiling === "number" && Number.isFinite(ceiling) && ceiling > 0
24
+ ? ceiling
25
+ : DEFAULT_EPISODE_COST_CEILING;
26
+ }
27
+
28
+ export class EpisodeCostGate {
29
+ #ceiling: number;
30
+ #costs = new Map<string, number>();
31
+ #warned = false;
32
+ #usageWarning = false;
33
+ #decision?: Promise<"continue" | "stop">;
34
+
35
+ constructor(ceiling: number) {
36
+ this.#ceiling = ceiling;
37
+ }
38
+
39
+ get ceiling(): number {
40
+ return this.#ceiling;
41
+ }
42
+
43
+ get cost(): number {
44
+ return [...this.#costs.values()].reduce((sum, value) => sum + value, 0);
45
+ }
46
+
47
+ setCeiling(ceiling: number): void {
48
+ if (Number.isFinite(ceiling) && ceiling > 0 && !this.#decision) this.#ceiling = ceiling;
49
+ }
50
+
51
+ seedCost(executionId: string, cost: number): void {
52
+ if (Number.isFinite(cost) && cost >= 0) this.#costs.set(executionId, cost);
53
+ }
54
+
55
+ async observe(
56
+ executionId: string,
57
+ cost: number | undefined,
58
+ terminal: boolean,
59
+ hooks: EpisodeCostGateHooks,
60
+ ): Promise<"continue" | "stop"> {
61
+ if (cost === undefined) {
62
+ if (terminal && !this.#usageWarning) {
63
+ this.#usageWarning = true;
64
+ hooks.warn("pi-daddy: provider reported no token usage; the episode cost gate cannot fire");
65
+ }
66
+ return "continue";
67
+ }
68
+ this.#costs.set(executionId, cost);
69
+ const total = [...this.#costs.values()].reduce((sum, value) => sum + value, 0);
70
+ if (!this.#warned && total >= this.#ceiling / 2) {
71
+ this.#warned = true;
72
+ hooks.warn(`pi-daddy: episode cost reached 50% of the $${this.#ceiling} ceiling`);
73
+ }
74
+ if (total <= this.#ceiling) return "continue";
75
+ if (this.#decision) {
76
+ const decision = await this.#decision;
77
+ if (decision === "stop") await hooks.stop?.();
78
+ return decision;
79
+ }
80
+ this.#decision = this.#decide(total, hooks);
81
+ return this.#decision;
82
+ }
83
+
84
+ async #decide(cost: number, hooks: EpisodeCostGateHooks): Promise<"continue" | "stop"> {
85
+ await hooks.pause?.();
86
+ const answer = await hooks.ask?.();
87
+ if (answer !== undefined && answer !== null && Number.isFinite(answer) && answer > cost) {
88
+ const prior = this.#ceiling;
89
+ this.#ceiling = answer;
90
+ try {
91
+ await hooks.gate({ cost, ceiling: prior, outcome: "continued", newCeiling: answer });
92
+ } catch (error) {
93
+ await hooks.stop?.();
94
+ throw error;
95
+ }
96
+ await hooks.resume?.();
97
+ return "continue";
98
+ }
99
+ try {
100
+ await hooks.gate({ cost, ceiling: this.#ceiling, outcome: "stopped" });
101
+ } finally {
102
+ await hooks.stop?.();
103
+ }
104
+ return "stop";
105
+ }
106
+ }
107
+
108
+ export async function loadEpisodeCosts(path: string, episodeId: string): Promise<Map<string, number>> {
109
+ try {
110
+ const parsed = readRecords(await readFile(path, "utf8"));
111
+ if (parsed.damage) return new Map();
112
+ const costs = new Map<string, number>();
113
+ for (const record of parsed.records) {
114
+ const body = record.body as Record<string, unknown>;
115
+ if (body?.event !== "child_lifecycle" || body.episodeId !== episodeId) continue;
116
+ if (body.state !== "completed" && body.state !== "failed") continue;
117
+ const usage = body.usage as { cost?: { total?: unknown } } | undefined;
118
+ const cost = usage?.cost?.total;
119
+ if (typeof body.executionId === "string" && typeof cost === "number" && Number.isFinite(cost) && cost >= 0) {
120
+ costs.set(body.executionId, cost);
121
+ }
122
+ }
123
+ return costs;
124
+ } catch (error) {
125
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return new Map();
126
+ throw error;
127
+ }
128
+ }
@@ -63,6 +63,14 @@ export const CHILD_PROCESS_SIGNALS = [
63
63
  ] as const satisfies readonly NodeJS.Signals[];
64
64
  export type ChildProcessSignal = (typeof CHILD_PROCESS_SIGNALS)[number];
65
65
 
66
+ export interface ChildTokenDetail {
67
+ inputTokens: number | null;
68
+ outputTokens: number | null;
69
+ cacheReadTokens: number | null;
70
+ cacheWriteTokens: number | null;
71
+ reasoningTokens: number | null;
72
+ }
73
+
66
74
  export interface ChildUsageTotals {
67
75
  input: number;
68
76
  output: number;
@@ -140,10 +148,29 @@ export interface ChildLifecycleEvent extends LedgerEventBase {
140
148
  reason?: string;
141
149
  /** The inactivity bound (ms) that governed this child beside the `deadlineAt` ceiling (PR 3e). */
142
150
  idleTimeoutMs?: number;
151
+ /** The provider/model selected for the child, confirmed from its persisted assistant message when available. */
152
+ resolvedModel?: { provider: string; modelId: string } | null;
153
+ modelSource?: "explicit" | "session" | "definition" | "global" | "pi";
154
+ /** The persisted effective level and its selection source, when pi reports one. */
155
+ thinkingLevel?: {
156
+ level: string;
157
+ source: "explicit" | "session" | "advisor" | "definition" | "global" | "pi";
158
+ } | null;
159
+ thinkingSource?: "explicit" | "session" | "advisor" | "definition" | "global" | "pi";
160
+ /** Provider-reported token dimensions; zero is normalised to null rather than invented as reported usage. */
161
+ tokenDetail?: ChildTokenDetail;
143
162
  /** Aggregate model usage read from the child's pi session file after it stopped. */
144
163
  usage?: ChildUsageTotals;
164
+ /** Number of compaction entries in the child's current turn, when the session file was readable. */
165
+ compactionCount?: number;
145
166
  /** Why totals could not be read; a fixed code, never transcript content. */
146
167
  usageUnavailable?: "session-missing" | "session-invalid" | "usage-missing";
168
+ /** Child-facing identity variables exported at the process boundary. */
169
+ exportedEnvironment?: string[];
170
+ /** SHA-256 of the complete SKILL.md used by this child. */
171
+ definitionHash?: string;
172
+ /** Installed package version exposed for the definition, when available. */
173
+ definitionPackageVersion?: string;
147
174
  }
148
175
 
149
176
  export type CapabilityDecisionEvent = GrantRecord & {
@@ -154,7 +181,120 @@ export type CapabilityDecisionEvent = GrantRecord & {
154
181
  taskDigest: string;
155
182
  };
156
183
 
157
- export type RuntimeLedgerEvent = CapabilityDecisionEvent | WorkspaceLeaseEvent | ChildLifecycleEvent;
184
+ export interface EpisodeOutcomeEvent extends Omit<LedgerEventBase, "correlation"> {
185
+ ledgerVersion: typeof LEDGER_VERSION;
186
+ event: "episode_outcome";
187
+ episodeId: string;
188
+ commit: string;
189
+ survived: boolean;
190
+ ci: "green" | "red" | "none";
191
+ amended: boolean;
192
+ corrected: boolean;
193
+ label: "positive" | "negative" | "unknown";
194
+ }
195
+
196
+ export interface EpisodeCostGateEvent extends Omit<LedgerEventBase, "episodeId" | "correlation"> {
197
+ ledgerVersion: typeof LEDGER_VERSION;
198
+ event: "cost_gate";
199
+ episodeId: string;
200
+ executionId: string;
201
+ parentExecutionId: string | null;
202
+ childId: string;
203
+ gate: "episode_cost";
204
+ cost: number;
205
+ ceiling: number;
206
+ outcome: "continued" | "stopped";
207
+ newCeiling?: number;
208
+ }
209
+
210
+ export interface SessionConfigEvent extends Omit<LedgerEventBase, "correlation"> {
211
+ ledgerVersion: typeof LEDGER_VERSION;
212
+ event: "session_config";
213
+ episodeId: string;
214
+ outcome: "kept" | "changed";
215
+ trigger: "first-delegation" | "grants-models";
216
+ overrides: Record<string, { model: string; thinking: string }>;
217
+ }
218
+
219
+ export type RuntimeLedgerEvent =
220
+ | CapabilityDecisionEvent
221
+ | WorkspaceLeaseEvent
222
+ | ChildLifecycleEvent
223
+ | EpisodeCostGateEvent
224
+ | SessionConfigEvent
225
+ | EpisodeOutcomeEvent;
226
+
227
+ export function buildSessionConfigEvent(args: {
228
+ episodeId: string;
229
+ outcome: "kept" | "changed";
230
+ trigger: "first-delegation" | "grants-models";
231
+ overrides: ReadonlyMap<string, { model?: string; thinking?: string }>;
232
+ now: Date;
233
+ }): SessionConfigEvent {
234
+ return assertLedgerV3Wire({
235
+ ledgerVersion: LEDGER_VERSION,
236
+ event: "session_config",
237
+ ts: args.now.toISOString(),
238
+ episodeId: args.episodeId,
239
+ outcome: args.outcome,
240
+ trigger: args.trigger,
241
+ overrides: Object.fromEntries(
242
+ [...args.overrides].map(([name, value]) => [name, { model: value.model!, thinking: value.thinking! }]),
243
+ ),
244
+ });
245
+ }
246
+
247
+ export function buildEpisodeOutcomeEvent(args: {
248
+ episodeId: string;
249
+ commit: string;
250
+ survived: boolean;
251
+ ci: "green" | "red" | "none";
252
+ amended: boolean;
253
+ corrected: boolean;
254
+ label: "positive" | "negative" | "unknown";
255
+ now: Date;
256
+ }): EpisodeOutcomeEvent {
257
+ return assertLedgerV3Wire({
258
+ ledgerVersion: LEDGER_VERSION,
259
+ event: "episode_outcome",
260
+ ts: args.now.toISOString(),
261
+ episodeId: args.episodeId,
262
+ commit: args.commit,
263
+ survived: args.survived,
264
+ ci: args.ci,
265
+ amended: args.amended,
266
+ corrected: args.corrected,
267
+ label: args.label,
268
+ });
269
+ }
270
+
271
+ export function buildEpisodeCostGateEvent(args: {
272
+ episodeId: string;
273
+ executionId: string;
274
+ parentExecutionId: string | null;
275
+ childId: string;
276
+ cost: number;
277
+ ceiling: number;
278
+ outcome: "continued" | "stopped";
279
+ newCeiling?: number;
280
+ now: Date;
281
+ }): EpisodeCostGateEvent {
282
+ assertEventIdentity(args);
283
+ return assertLedgerV3Wire({
284
+ ledgerVersion: LEDGER_VERSION,
285
+ event: "cost_gate",
286
+ ts: args.now.toISOString(),
287
+ episodeId: args.episodeId,
288
+ executionId: args.executionId,
289
+ parentExecutionId: args.parentExecutionId,
290
+ childId: args.childId,
291
+ gate: "episode_cost",
292
+ cost: args.cost,
293
+ ceiling: args.ceiling,
294
+ outcome: args.outcome,
295
+ ...(args.newCeiling !== undefined ? { newCeiling: args.newCeiling } : {}),
296
+ });
297
+ }
158
298
 
159
299
  export function buildWorkspaceLeaseEvent(args: {
160
300
  episodeId?: string;
@@ -212,12 +352,24 @@ export function buildChildLifecycleEvent(args: {
212
352
  aborted?: boolean;
213
353
  truncated?: boolean;
214
354
  reason?: string;
355
+ resolvedModel?: { provider: string; modelId: string };
356
+ modelSource?: "explicit" | "session" | "definition" | "global" | "pi";
357
+ effectiveThinkingLevel?: string;
358
+ thinkingSource?: "explicit" | "session" | "advisor" | "definition" | "global" | "pi";
359
+ tokenDetail?: ChildTokenDetail;
215
360
  usage?: ChildUsageTotals;
361
+ compactionCount?: number;
216
362
  usageUnavailable?: "session-missing" | "session-invalid" | "usage-missing";
363
+ exportedEnvironment?: readonly string[];
364
+ definitionHash?: string;
365
+ definitionPackageVersion?: string;
217
366
  correlation?: CorrelationMetadata;
218
367
  now: Date;
219
368
  }): ChildLifecycleEvent {
220
369
  assertEventIdentity(args);
370
+ if (args.definitionHash && !/^[a-f0-9]{64}$/i.test(args.definitionHash)) {
371
+ throw new TypeError("definitionHash must be a SHA-256 hex digest");
372
+ }
221
373
  return assertLedgerV3Wire({
222
374
  ledgerVersion: LEDGER_VERSION,
223
375
  event: "child_lifecycle",
@@ -238,8 +390,28 @@ export function buildChildLifecycleEvent(args: {
238
390
  ...(args.aborted ? { aborted: true } : {}),
239
391
  ...(args.truncated ? { truncated: true } : {}),
240
392
  ...(args.reason ? { reason: args.reason } : {}),
393
+ resolvedModel: args.resolvedModel ? structuredClone(args.resolvedModel) : null,
394
+ ...(args.modelSource ? { modelSource: args.modelSource } : {}),
395
+ thinkingLevel:
396
+ args.effectiveThinkingLevel && args.thinkingSource
397
+ ? { level: args.effectiveThinkingLevel, source: args.thinkingSource }
398
+ : null,
399
+ ...(args.thinkingSource ? { thinkingSource: args.thinkingSource } : {}),
400
+ tokenDetail: args.tokenDetail
401
+ ? structuredClone(args.tokenDetail)
402
+ : {
403
+ inputTokens: null,
404
+ outputTokens: null,
405
+ cacheReadTokens: null,
406
+ cacheWriteTokens: null,
407
+ reasoningTokens: null,
408
+ },
241
409
  ...(args.usage ? { usage: structuredClone(args.usage) } : {}),
410
+ ...(args.compactionCount !== undefined ? { compactionCount: args.compactionCount } : {}),
242
411
  ...(args.usageUnavailable ? { usageUnavailable: args.usageUnavailable } : {}),
412
+ ...(args.exportedEnvironment ? { exportedEnvironment: [...args.exportedEnvironment] } : {}),
413
+ ...(args.definitionHash ? { definitionHash: args.definitionHash } : {}),
414
+ ...(args.definitionPackageVersion ? { definitionPackageVersion: args.definitionPackageVersion } : {}),
243
415
  ...(args.correlation ? { correlation: structuredClone(args.correlation) } : {}),
244
416
  });
245
417
  }
@@ -3,6 +3,8 @@ import { isExecutionId } from "../kernel/execution-id.ts";
3
3
  import { isEpisodeId } from "../kernel/episode-id.ts";
4
4
  import { REFUSAL_CODES } from "../kernel/refusals.ts";
5
5
  import { isLedgerCapabilityIdentifier, isLedgerDisplayIdentifier } from "../kernel/ledger-identifiers.ts";
6
+ import { CHILD_ATTRIBUTION_ENV_KEYS } from "../kernel/env-names.ts";
7
+ import { isDefinitionPackageVersion } from "../kernel/definitions.ts";
6
8
 
7
9
  export type LedgerV3Object = Record<string, unknown>;
8
10
 
@@ -103,6 +105,8 @@ const FIELDS = {
103
105
  "humanDenied",
104
106
  "gateOutcome",
105
107
  "definitionDigest",
108
+ "definitionHash",
109
+ "definitionPackageVersion",
106
110
  "handoff",
107
111
  "executor",
108
112
  "taskFrom",
@@ -128,6 +132,33 @@ const FIELDS = {
128
132
  "refusal",
129
133
  "correlation",
130
134
  ]),
135
+ cost_gate: new Set([
136
+ "ledgerVersion",
137
+ "event",
138
+ "ts",
139
+ "episodeId",
140
+ "executionId",
141
+ "parentExecutionId",
142
+ "childId",
143
+ "gate",
144
+ "cost",
145
+ "ceiling",
146
+ "outcome",
147
+ "newCeiling",
148
+ ]),
149
+ session_config: new Set(["ledgerVersion", "event", "ts", "episodeId", "outcome", "trigger", "overrides"]),
150
+ episode_outcome: new Set([
151
+ "ledgerVersion",
152
+ "event",
153
+ "ts",
154
+ "episodeId",
155
+ "commit",
156
+ "survived",
157
+ "ci",
158
+ "amended",
159
+ "corrected",
160
+ "label",
161
+ ]),
131
162
  child_lifecycle: new Set([
132
163
  "ledgerVersion",
133
164
  "event",
@@ -146,8 +177,17 @@ const FIELDS = {
146
177
  "reason",
147
178
  "deadlineAt",
148
179
  "idleTimeoutMs",
180
+ "resolvedModel",
181
+ "modelSource",
182
+ "thinkingLevel",
183
+ "thinkingSource",
184
+ "tokenDetail",
149
185
  "usage",
186
+ "compactionCount",
150
187
  "usageUnavailable",
188
+ "exportedEnvironment",
189
+ "definitionHash",
190
+ "definitionPackageVersion",
151
191
  "herdrPaneId",
152
192
  "herdrAgentName",
153
193
  "correlation",
@@ -297,6 +337,8 @@ function validateCapabilityDecision(event: LedgerV3Object): string | null {
297
337
  !optional(event, "humanDenied", (value) => value === true) ||
298
338
  !optional(event, "gateOutcome", (value) => GATE_OUTCOMES.has(String(value))) ||
299
339
  !optional(event, "definitionDigest", validDefinitionDigest) ||
340
+ !optional(event, "definitionHash", (value) => SHA256_RE.test(String(value))) ||
341
+ !optional(event, "definitionPackageVersion", isDefinitionPackageVersion) ||
300
342
  !optional(event, "taskFrom", isLedgerDisplayIdentifier) ||
301
343
  !optional(event, "taskFromExecutionId", isExecutionId) ||
302
344
  !optional(event, "correlation", validCorrelation) ||
@@ -354,6 +396,81 @@ function validUsage(value: unknown): boolean {
354
396
  );
355
397
  }
356
398
 
399
+ function validResolvedModel(value: unknown): boolean {
400
+ return (
401
+ value === null ||
402
+ (isLedgerObject(value) &&
403
+ Object.keys(value).every((key) => ["provider", "modelId"].includes(key)) &&
404
+ isNonEmptyString(value.provider) &&
405
+ isNonEmptyString(value.modelId))
406
+ );
407
+ }
408
+
409
+ const MODEL_SOURCES = ["explicit", "session", "definition", "global", "pi"];
410
+ const THINKING_SOURCES = [...MODEL_SOURCES, "advisor"];
411
+ function validThinkingLevel(value: unknown): boolean {
412
+ return (
413
+ value === null ||
414
+ (isLedgerObject(value) &&
415
+ Object.keys(value).every((key) => ["level", "source"].includes(key)) &&
416
+ isNonEmptyString(value.level) &&
417
+ THINKING_SOURCES.includes(String(value.source)))
418
+ );
419
+ }
420
+
421
+ function validTokenDetail(value: unknown): boolean {
422
+ if (!isLedgerObject(value)) return false;
423
+ const fields = ["inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "reasoningTokens"];
424
+ return (
425
+ Object.keys(value).length === fields.length &&
426
+ fields.every((field) => value[field] === null || (nonNegativeNumber(value[field]) && (value[field] as number) > 0))
427
+ );
428
+ }
429
+
430
+ function validExportedEnvironment(value: unknown): boolean {
431
+ return (
432
+ Array.isArray(value) &&
433
+ value.length === CHILD_ATTRIBUTION_ENV_KEYS.length &&
434
+ CHILD_ATTRIBUTION_ENV_KEYS.every((name) => value.filter((entry) => entry === name).length === 1)
435
+ );
436
+ }
437
+
438
+ function validateCostGate(event: LedgerV3Object): string | null {
439
+ if (
440
+ event.gate !== "episode_cost" ||
441
+ !nonNegativeNumber(event.cost) ||
442
+ !nonNegativeNumber(event.ceiling) ||
443
+ (event.ceiling as number) <= 0 ||
444
+ !["continued", "stopped"].includes(String(event.outcome))
445
+ ) {
446
+ return "cost gate required fields are invalid";
447
+ }
448
+ if (
449
+ !optional(event, "newCeiling", (value) => nonNegativeNumber(value) && (value as number) > (event.cost as number))
450
+ ) {
451
+ return "cost gate new ceiling is invalid";
452
+ }
453
+ if ((event.outcome === "continued") !== Object.hasOwn(event, "newCeiling")) {
454
+ return "continued cost gate requires exactly one new ceiling";
455
+ }
456
+ return null;
457
+ }
458
+
459
+ function validateEpisodeOutcome(event: LedgerV3Object): string | null {
460
+ if (
461
+ !isTimestamp(event.ts) ||
462
+ !isEpisodeId(event.episodeId) ||
463
+ !/^[a-f0-9]{40}$/i.test(String(event.commit)) ||
464
+ typeof event.survived !== "boolean" ||
465
+ !["green", "red", "none"].includes(String(event.ci)) ||
466
+ typeof event.amended !== "boolean" ||
467
+ typeof event.corrected !== "boolean" ||
468
+ !["positive", "negative", "unknown"].includes(String(event.label))
469
+ )
470
+ return "episode outcome fields are invalid";
471
+ return null;
472
+ }
473
+
357
474
  function validateChildLifecycle(event: LedgerV3Object): string | null {
358
475
  if (
359
476
  !["starting", "running", "completed", "failed"].includes(String(event.state)) ||
@@ -373,10 +490,19 @@ function validateChildLifecycle(event: LedgerV3Object): string | null {
373
490
  !optional(event, "aborted", (value) => value === true) ||
374
491
  !optional(event, "truncated", (value) => value === true) ||
375
492
  !optional(event, "reason", (value) => typeof value === "string") ||
493
+ !optional(event, "resolvedModel", validResolvedModel) ||
494
+ !optional(event, "modelSource", (value) => MODEL_SOURCES.includes(String(value))) ||
495
+ !optional(event, "thinkingLevel", validThinkingLevel) ||
496
+ !optional(event, "thinkingSource", (value) => THINKING_SOURCES.includes(String(value))) ||
497
+ !optional(event, "tokenDetail", validTokenDetail) ||
376
498
  !optional(event, "usage", validUsage) ||
499
+ !optional(event, "compactionCount", (value) => Number.isInteger(value) && (value as number) >= 0) ||
377
500
  !optional(event, "usageUnavailable", (value) =>
378
501
  ["session-missing", "session-invalid", "usage-missing"].includes(String(value)),
379
502
  ) ||
503
+ !optional(event, "exportedEnvironment", validExportedEnvironment) ||
504
+ !optional(event, "definitionHash", (value) => SHA256_RE.test(String(value))) ||
505
+ !optional(event, "definitionPackageVersion", isDefinitionPackageVersion) ||
380
506
  !optional(event, "correlation", validCorrelation)
381
507
  ) {
382
508
  return "child lifecycle optional fields are invalid";
@@ -407,10 +533,30 @@ export function validateLedgerV3Event(event: LedgerV3Object): string | null {
407
533
  if (typeof kind !== "string" || !Object.hasOwn(FIELDS, kind)) return "unknown ledger event discriminator";
408
534
  const allowed = FIELDS[kind as EventKind];
409
535
  if (Object.keys(event).some((field) => !allowed.has(field))) return "ledger v3 contains an unsupported field";
536
+ if (kind === "session_config") {
537
+ if (
538
+ !isEpisodeId(event.episodeId) ||
539
+ !["kept", "changed"].includes(String(event.outcome)) ||
540
+ !["first-delegation", "grants-models"].includes(String(event.trigger)) ||
541
+ !isLedgerObject(event.overrides) ||
542
+ !Object.entries(event.overrides).every(
543
+ ([name, value]) =>
544
+ isNonEmptyString(name) &&
545
+ isLedgerObject(value) &&
546
+ Object.keys(value).length === 2 &&
547
+ isNonEmptyString(value.model) &&
548
+ isNonEmptyString(value.thinking),
549
+ )
550
+ )
551
+ return "session config fields are invalid";
552
+ return null;
553
+ }
554
+ if (kind === "episode_outcome") return validateEpisodeOutcome(event);
410
555
  const base = validateBase(event);
411
556
  if (base) return base;
412
557
  if (kind === "capability_decision") return validateCapabilityDecision(event);
413
558
  if (kind === "workspace_lease") return validateWorkspaceLease(event);
559
+ if (kind === "cost_gate") return validateCostGate(event);
414
560
  return validateChildLifecycle(event);
415
561
  }
416
562