pi-daddy 0.41.1 → 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 (133) hide show
  1. package/CHANGELOG.md +31 -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/definitions.d.ts +5 -0
  43. package/dist/kernel/definitions.d.ts.map +1 -1
  44. package/dist/kernel/definitions.js +30 -1
  45. package/dist/kernel/definitions.js.map +1 -1
  46. package/dist/kernel/delegate-types.d.ts +4 -0
  47. package/dist/kernel/delegate-types.d.ts.map +1 -1
  48. package/dist/kernel/delegate-types.js.map +1 -1
  49. package/dist/kernel/delegate.d.ts.map +1 -1
  50. package/dist/kernel/delegate.js +20 -5
  51. package/dist/kernel/delegate.js.map +1 -1
  52. package/dist/kernel/env-names.d.ts +5 -0
  53. package/dist/kernel/env-names.d.ts.map +1 -1
  54. package/dist/kernel/env-names.js +9 -0
  55. package/dist/kernel/env-names.js.map +1 -1
  56. package/dist/kernel/grant-env.d.ts +2 -0
  57. package/dist/kernel/grant-env.d.ts.map +1 -1
  58. package/dist/kernel/grant-env.js +1 -0
  59. package/dist/kernel/grant-env.js.map +1 -1
  60. package/dist/kernel/propagation.d.ts +2 -2
  61. package/dist/kernel/propagation.d.ts.map +1 -1
  62. package/dist/kernel/propagation.js +7 -2
  63. package/dist/kernel/propagation.js.map +1 -1
  64. package/dist/products/dashboard-cli.d.ts +4 -0
  65. package/dist/products/dashboard-cli.d.ts.map +1 -1
  66. package/dist/products/dashboard-cli.js +32 -4
  67. package/dist/products/dashboard-cli.js.map +1 -1
  68. package/dist/products/dashboard-handshake.d.ts +8 -0
  69. package/dist/products/dashboard-handshake.d.ts.map +1 -1
  70. package/dist/products/dashboard-handshake.js +3 -0
  71. package/dist/products/dashboard-handshake.js.map +1 -1
  72. package/dist/products/dashboard-herdr.d.ts +4 -0
  73. package/dist/products/dashboard-herdr.d.ts.map +1 -1
  74. package/dist/products/dashboard-herdr.js +10 -2
  75. package/dist/products/dashboard-herdr.js.map +1 -1
  76. package/dist/products/dashboard-projection.d.ts.map +1 -1
  77. package/dist/products/dashboard-projection.js +13 -6
  78. package/dist/products/dashboard-projection.js.map +1 -1
  79. package/dist/products/dashboard-render.d.ts +10 -0
  80. package/dist/products/dashboard-render.d.ts.map +1 -1
  81. package/dist/products/dashboard-render.js +9 -0
  82. package/dist/products/dashboard-render.js.map +1 -1
  83. package/dist/products/dashboard-session-client.d.ts +17 -0
  84. package/dist/products/dashboard-session-client.d.ts.map +1 -0
  85. package/dist/products/dashboard-session-client.js +28 -0
  86. package/dist/products/dashboard-session-client.js.map +1 -0
  87. package/dist/products/episode-outcomes.d.ts +32 -0
  88. package/dist/products/episode-outcomes.d.ts.map +1 -0
  89. package/dist/products/episode-outcomes.js +280 -0
  90. package/dist/products/episode-outcomes.js.map +1 -0
  91. package/dist/products/episode-report.d.ts +59 -0
  92. package/dist/products/episode-report.d.ts.map +1 -0
  93. package/dist/products/episode-report.js +311 -0
  94. package/dist/products/episode-report.js.map +1 -0
  95. package/extensions/chain-ledger.ts +2 -0
  96. package/extensions/chain-plan.ts +4 -2
  97. package/extensions/dashboard-session-server.ts +86 -0
  98. package/extensions/definition-runtime.ts +93 -0
  99. package/extensions/delegate-chain.ts +31 -2
  100. package/extensions/delegation-ledger.ts +2 -0
  101. package/extensions/delegation.ts +24 -0
  102. package/extensions/effort-advice.ts +12 -0
  103. package/extensions/execute-child.ts +148 -9
  104. package/extensions/grants-command.ts +32 -2
  105. package/extensions/grants.ts +23 -1
  106. package/extensions/pruning-advice.ts +3 -1
  107. package/extensions/run-delegation.ts +54 -6
  108. package/extensions/session-model-prompt.ts +179 -0
  109. package/extensions/session.ts +104 -3
  110. package/package.json +1 -1
  111. package/src/advisors/advisor.ts +45 -2
  112. package/src/advisors/decider.ts +8 -0
  113. package/src/advisors/jev.ts +34 -1
  114. package/src/cli.ts +90 -2
  115. package/src/executors/activity-session.ts +56 -2
  116. package/src/governance/episode-cost-gate.ts +128 -0
  117. package/src/governance/ledger-events.ts +173 -1
  118. package/src/governance/ledger-v3-validation.ts +146 -0
  119. package/src/governance/ledger.ts +29 -1
  120. package/src/kernel/definitions.ts +32 -1
  121. package/src/kernel/delegate-types.ts +4 -0
  122. package/src/kernel/delegate.ts +22 -5
  123. package/src/kernel/env-names.ts +10 -0
  124. package/src/kernel/grant-env.ts +3 -0
  125. package/src/kernel/propagation.ts +7 -0
  126. package/src/products/dashboard-cli.ts +32 -4
  127. package/src/products/dashboard-handshake.ts +5 -0
  128. package/src/products/dashboard-herdr.ts +15 -1
  129. package/src/products/dashboard-projection.ts +13 -6
  130. package/src/products/dashboard-render.ts +11 -0
  131. package/src/products/dashboard-session-client.ts +35 -0
  132. package/src/products/episode-outcomes.ts +317 -0
  133. package/src/products/episode-report.ts +372 -0
@@ -0,0 +1,179 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { appendLedgerEvent, buildSessionConfigEvent } from "../src/governance/ledger.ts";
3
+ import { readRecords } from "../src/governance/record.ts";
4
+ import {
5
+ parseConfiguredModel,
6
+ parseThinkingLevel,
7
+ resolveDefinitionRuntime,
8
+ THINKING_LEVELS,
9
+ type DefinitionRuntimeChoice,
10
+ type DefinitionRuntimeSettings,
11
+ } from "./definition-runtime.ts";
12
+
13
+ export interface SessionModelPromptState {
14
+ episodeId: string;
15
+ ledgerPath?: string;
16
+ definitions: Map<string, { name: string }>;
17
+ definitionRuntimeSettings: DefinitionRuntimeSettings;
18
+ definitionRuntimeOverrides: Map<string, DefinitionRuntimeChoice>;
19
+ sessionModelPrompt: "ask" | "never";
20
+ sessionModelPrompted: boolean;
21
+ sessionModelPromptInFlight?: Promise<void>;
22
+ }
23
+ export interface SessionModelPromptUI {
24
+ hasUI: boolean;
25
+ input(title: string, placeholder?: string): Promise<string | undefined>;
26
+ notify(message: string, type?: "info" | "warning" | "error"): void;
27
+ }
28
+
29
+ async function historicalDefinitions(path: string | undefined): Promise<string[]> {
30
+ if (!path) return [];
31
+ try {
32
+ const parsed = readRecords(await readFile(path, "utf8"));
33
+ return parsed.records
34
+ .map((record) => record.body as { event?: unknown; agentType?: unknown })
35
+ .filter((body) => body.event === "capability_decision" && typeof body.agentType === "string")
36
+ .map((body) => body.agentType as string)
37
+ .filter((name) => name !== "delegate");
38
+ } catch (error) {
39
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
40
+ throw error;
41
+ }
42
+ }
43
+
44
+ export function sessionModelRows(state: SessionModelPromptState, names: readonly string[]) {
45
+ return names.map((name) => {
46
+ const selected = resolveDefinitionRuntime({
47
+ definition: name,
48
+ explicit: {},
49
+ session: state.definitionRuntimeOverrides,
50
+ settings: state.definitionRuntimeSettings,
51
+ });
52
+ return {
53
+ definition: name,
54
+ model: selected.model ?? "pi default",
55
+ thinking: selected.thinking ?? "pi default",
56
+ source: `${selected.modelSource}/${selected.thinkingSource}`,
57
+ };
58
+ });
59
+ }
60
+
61
+ function table(state: SessionModelPromptState, names: readonly string[]): string {
62
+ const rows = sessionModelRows(state, names).map(
63
+ (row) => `${row.definition}\t${row.model}\t${row.thinking}\t${row.source}`,
64
+ );
65
+ return ["definition\tmodel\tthinking\tsource", ...rows].join("\n");
66
+ }
67
+
68
+ export function applySessionModelEdits(
69
+ raw: string,
70
+ names: readonly string[],
71
+ target: Map<string, DefinitionRuntimeChoice>,
72
+ ): string | null {
73
+ const next = new Map(target);
74
+ for (const line of raw
75
+ .split(/\r?\n/)
76
+ .map((value) => value.trim())
77
+ .filter(Boolean)) {
78
+ const parts = line.split(/\s+/);
79
+ if (parts.length !== 3) return "Each edit must be: <definition> <provider:model> <thinking>.";
80
+ const [subject, rawModel, rawThinking] = parts;
81
+ const model = parseConfiguredModel(rawModel);
82
+ const thinking = parseThinkingLevel(rawThinking);
83
+ if (!model || !thinking)
84
+ return `Invalid model or thinking. Model must be provider:model; thinking must be one of: ${THINKING_LEVELS.join(", ")}.`;
85
+ const selected = subject === "all" ? [...names] : [subject];
86
+ if (selected.some((name) => !names.includes(name)))
87
+ return `Unknown definition ${subject}. Known definitions: ${names.join(", ") || "(none)"}.`;
88
+ for (const name of selected) next.set(name, { model, thinking });
89
+ }
90
+ target.clear();
91
+ for (const [name, value] of next) target.set(name, value);
92
+ return null;
93
+ }
94
+
95
+ async function record(
96
+ state: SessionModelPromptState,
97
+ outcome: "kept" | "changed",
98
+ trigger: "first-delegation" | "grants-models",
99
+ ): Promise<void> {
100
+ if (!state.ledgerPath) return;
101
+ await appendLedgerEvent(
102
+ { path: state.ledgerPath, strict: true },
103
+ buildSessionConfigEvent({
104
+ episodeId: state.episodeId,
105
+ outcome,
106
+ trigger,
107
+ overrides: state.definitionRuntimeOverrides,
108
+ now: new Date(),
109
+ }),
110
+ );
111
+ }
112
+
113
+ async function choose(
114
+ state: SessionModelPromptState,
115
+ current: readonly string[],
116
+ ui: SessionModelPromptUI,
117
+ trigger: "first-delegation" | "grants-models",
118
+ ): Promise<void> {
119
+ const names = [...new Set([...(await historicalDefinitions(state.ledgerPath)), ...current])].sort();
120
+ if (state.sessionModelPrompt === "never" || !ui.hasUI) {
121
+ await record(state, "kept", trigger);
122
+ return;
123
+ }
124
+ while (true) {
125
+ const answer = await ui.input(
126
+ `Child model defaults\n\n${table(state, names)}\n\nPress Enter to keep, or enter edits one per line.`,
127
+ "<definition> <provider:model> <thinking> | all <provider:model> <thinking>",
128
+ );
129
+ if (answer === undefined) throw new Error("session model selection was dismissed; no child was spawned");
130
+ if (!answer.trim()) {
131
+ await record(state, "kept", trigger);
132
+ return;
133
+ }
134
+ const invalid = await saveSessionModelEdits(state, answer, names, trigger);
135
+ if (invalid) {
136
+ ui.notify(invalid, "error");
137
+ continue;
138
+ }
139
+ return;
140
+ }
141
+ }
142
+
143
+ export async function saveSessionModelEdits(
144
+ state: SessionModelPromptState,
145
+ raw: string,
146
+ names: readonly string[],
147
+ trigger: "first-delegation" | "grants-models",
148
+ ): Promise<string | null> {
149
+ const invalid = applySessionModelEdits(raw, names, state.definitionRuntimeOverrides);
150
+ if (invalid) return invalid;
151
+ await record(state, "changed", trigger);
152
+ return null;
153
+ }
154
+
155
+ export async function ensureSessionModelPrompt(
156
+ state: SessionModelPromptState,
157
+ current: readonly string[],
158
+ ui: SessionModelPromptUI,
159
+ ): Promise<void> {
160
+ if (state.sessionModelPrompted) return;
161
+ if (!state.sessionModelPromptInFlight) {
162
+ state.sessionModelPromptInFlight = choose(state, current, ui, "first-delegation").then(() => {
163
+ state.sessionModelPrompted = true;
164
+ });
165
+ }
166
+ try {
167
+ await state.sessionModelPromptInFlight;
168
+ } finally {
169
+ state.sessionModelPromptInFlight = undefined;
170
+ }
171
+ }
172
+
173
+ export async function changeSessionModels(state: SessionModelPromptState, ui: SessionModelPromptUI): Promise<void> {
174
+ await choose(state, [...state.definitions.keys()], ui, "grants-models");
175
+ }
176
+
177
+ export function renderSessionModels(state: SessionModelPromptState): string {
178
+ return table(state, [...state.definitions.keys()].sort());
179
+ }
@@ -67,6 +67,14 @@ import {
67
67
  } from "../src/kernel/workspace-pin.ts";
68
68
  import { loadWorkspaceRegistry } from "../src/kernel/workspace.ts";
69
69
  import { reconcileAcceptedWorkspaces } from "../src/governance/workspace-acceptance.ts";
70
+ import { EpisodeCostGate, episodeCostCeilingFromSettings } from "../src/governance/episode-cost-gate.ts";
71
+ import {
72
+ definitionRuntimeSettingsFrom,
73
+ parseConfiguredModel,
74
+ parseThinkingLevel,
75
+ type DefinitionRuntimeChoice,
76
+ type DefinitionRuntimeSettings,
77
+ } from "./definition-runtime.ts";
70
78
 
71
79
  /**
72
80
  * Run governed children in herdr panes instead of captured child processes.
@@ -101,7 +109,13 @@ import {
101
109
  ENV_ACTIVITY_ROOT,
102
110
  ENV_ACTIVITY_TASK,
103
111
  } from "../src/products/activity-timeline.ts";
104
- import { ENV_HERDR_KEEP_PANE, ENV_GOVERNANCE, ENV_ADVISOR, ENV_EPISODE_ID } from "../src/kernel/env-names.ts";
112
+ import {
113
+ ENV_HERDR_KEEP_PANE,
114
+ ENV_GOVERNANCE,
115
+ ENV_ADVISOR,
116
+ ENV_EPISODE_ID,
117
+ ENV_EPISODE_COST_CEILING,
118
+ } from "../src/kernel/env-names.ts";
105
119
  export { ENV_HERDR_KEEP_PANE, ENV_GOVERNANCE } from "../src/kernel/env-names.ts";
106
120
  import { adoptLegacyEnvironment } from "../src/kernel/env-names.ts";
107
121
 
@@ -161,6 +175,13 @@ export interface GrantsSession extends NativeSessionHost {
161
175
  allowUnresolvedModels: boolean;
162
176
  /** Results from pi's synchronous model catalogue, shared by every delegation in this session. */
163
177
  readonly modelResolutionCache: Map<string, boolean>;
178
+ /** Committed per-definition and global child runtime defaults. */
179
+ readonly definitionRuntimeSettings: DefinitionRuntimeSettings;
180
+ /** PD-7's future session controls write here; deliberately empty in this commit. */
181
+ readonly definitionRuntimeOverrides: Map<string, DefinitionRuntimeChoice>;
182
+ readonly sessionModelPrompt: "ask" | "never";
183
+ sessionModelPrompted: boolean;
184
+ sessionModelPromptInFlight?: Promise<void>;
164
185
  /** Path to this extension, so a child granted `tool:delegate` can delegate in turn. */
165
186
  readonly extensionPath?: string;
166
187
  /** Hook-only observer path for leaf children; it exposes no tools. */
@@ -192,6 +213,8 @@ export interface GrantsSession extends NativeSessionHost {
192
213
  inheritedApprovals: Map<string, string | undefined>;
193
214
  /** ONE single-flight queue for the whole session — see `obtainApprovals` for why it lives here. */
194
215
  readonly approvalGateFor: ReturnType<typeof createApprovalGateProvider>;
216
+ /** One cumulative cost threshold shared by every child in this episode. */
217
+ readonly episodeCostGate: EpisodeCostGate;
195
218
  /** Set at `session_start`; `process.cwd()` until then. */
196
219
  cwd: string;
197
220
  /** This session's own grant. Starts as the inherited upper bound, tightened once tools are observed. */
@@ -436,7 +459,10 @@ export function createGrantsSession(
436
459
  // G7 / A-S4 + B-I4: strict, three-way parsing that fails CLOSED. A malformed bound used to yield
437
460
  // `NaN`, and every comparison against `NaN` is false, so depth limiting switched itself off.
438
461
  const bounds = depthConfig(environment[ENV_DEPTH], environment[ENV_MAX_DEPTH]);
439
- const { depth, maxDepth } = bounds;
462
+ const costConfig = episodeCostConfiguration(storeCwd, environment[ENV_EPISODE_COST_CEILING]);
463
+ const runtimeConfig = projectDefinitionRuntimeConfiguration(storeCwd);
464
+ const depth = bounds.depth;
465
+ const maxDepth = costConfig.malformed || runtimeConfig.malformed ? 0 : bounds.maxDepth;
440
466
  const emptyCatalog = makeCatalog([]);
441
467
  // ADR-0077. The environment decides whether there is an advisor at all; the project's settings block may only
442
468
  // narrow it. The block IS read — the first version passed `undefined` and every narrowing the release advertised
@@ -456,7 +482,11 @@ export function createGrantsSession(
456
482
  inherited,
457
483
  depth,
458
484
  maxDepth,
459
- malformedBounds: bounds.malformed,
485
+ malformedBounds: [
486
+ ...bounds.malformed,
487
+ ...(costConfig.malformed ? [costConfig.malformed] : []),
488
+ ...(runtimeConfig.malformed ? [runtimeConfig.malformed] : []),
489
+ ],
460
490
  definitionSkips: [],
461
491
  workspacePin: undefined,
462
492
  pinSettled: false,
@@ -496,6 +526,10 @@ export function createGrantsSession(
496
526
  allowUnresolvedModels: environment[ENV_ALLOW_UNRESOLVED_MODELS] === "1",
497
527
  nativeSessionRoot: nativeSessionRootFromEnv(process.env),
498
528
  modelResolutionCache: new Map<string, boolean>(),
529
+ definitionRuntimeSettings: runtimeConfig.settings,
530
+ definitionRuntimeOverrides: new Map<string, DefinitionRuntimeChoice>(),
531
+ sessionModelPrompt: runtimeConfig.prompt,
532
+ sessionModelPrompted: false,
499
533
  extensionPath,
500
534
  observerExtensionPath,
501
535
  activityRootId,
@@ -504,6 +538,7 @@ export function createGrantsSession(
504
538
  sessionApprovalBindings: new Map<string, ApprovalBinding>(),
505
539
  inheritedApprovals: parseInherited(environment[ENV_APPROVED]),
506
540
  approvalGateFor: createApprovalGateProvider(),
541
+ episodeCostGate: new EpisodeCostGate(costConfig.ceiling),
507
542
  cwd: process.cwd(),
508
543
  ownGrant: deriveOwnGrant(inherited, null),
509
544
  observed: false,
@@ -609,6 +644,72 @@ export function reconcileAdvisorSession(session: GrantsSession, environment: Nod
609
644
  });
610
645
  }
611
646
 
647
+ function episodeCostConfiguration(cwd: string, inherited: string | undefined): { ceiling: number; malformed?: string } {
648
+ if (inherited !== undefined) {
649
+ const value = Number(inherited);
650
+ return Number.isFinite(value) && value > 0
651
+ ? { ceiling: value }
652
+ : { ceiling: episodeCostCeilingFromSettings(undefined), malformed: ENV_EPISODE_COST_CEILING };
653
+ }
654
+ const path = projectSettingsPath(cwd);
655
+ try {
656
+ const stats = statSync(path);
657
+ if (!stats.isFile() || stats.size > 1024 * 1024) {
658
+ return { ceiling: episodeCostCeilingFromSettings(undefined), malformed: "settings.json episodeCostCeiling" };
659
+ }
660
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
661
+ if (parsed && typeof parsed === "object" && Object.hasOwn(parsed, "episodeCostCeiling")) {
662
+ const value = (parsed as Record<string, unknown>).episodeCostCeiling;
663
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
664
+ return { ceiling: episodeCostCeilingFromSettings(undefined), malformed: "settings.json episodeCostCeiling" };
665
+ }
666
+ }
667
+ return { ceiling: episodeCostCeilingFromSettings(parsed) };
668
+ } catch (error) {
669
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
670
+ return { ceiling: episodeCostCeilingFromSettings(undefined) };
671
+ }
672
+ return { ceiling: episodeCostCeilingFromSettings(undefined), malformed: "settings.json episodeCostCeiling" };
673
+ }
674
+ }
675
+
676
+ function projectDefinitionRuntimeConfiguration(cwd: string): {
677
+ settings: DefinitionRuntimeSettings;
678
+ prompt: "ask" | "never";
679
+ malformed?: string;
680
+ } {
681
+ const empty = { settings: definitionRuntimeSettingsFrom(undefined), prompt: "ask" as const };
682
+ try {
683
+ const path = projectSettingsPath(cwd);
684
+ const stats = statSync(path);
685
+ if (!stats.isFile() || stats.size > 1024 * 1024) return { ...empty, malformed: "settings.json model defaults" };
686
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
687
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
688
+ return { ...empty, malformed: "settings.json model defaults" };
689
+ const root = parsed as Record<string, unknown>;
690
+ if (root.sessionModelPrompt !== undefined && !["ask", "never"].includes(String(root.sessionModelPrompt)))
691
+ return { ...empty, malformed: "settings.json sessionModelPrompt" };
692
+ const candidates = [root.defaults, ...(Array.isArray(root.definitions) ? root.definitions : [])];
693
+ for (const candidate of candidates) {
694
+ if (candidate === undefined) continue;
695
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
696
+ return { ...empty, malformed: "settings.json model defaults" };
697
+ const value = candidate as Record<string, unknown>;
698
+ if (value.model !== undefined && !parseConfiguredModel(value.model))
699
+ return { ...empty, malformed: "settings.json model" };
700
+ if (value.thinking !== undefined && !parseThinkingLevel(value.thinking))
701
+ return { ...empty, malformed: "settings.json thinking" };
702
+ }
703
+ return {
704
+ settings: definitionRuntimeSettingsFrom(root),
705
+ prompt: root.sessionModelPrompt === "never" ? "never" : "ask",
706
+ };
707
+ } catch (error) {
708
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return empty;
709
+ return { ...empty, malformed: "settings.json model defaults" };
710
+ }
711
+ }
712
+
612
713
  function projectAdvisorBlock(cwd: string, env: NodeJS.ProcessEnv = process.env): unknown {
613
714
  // Only when an advisor could exist at all. This runs in every session including every child, before any hook, and
614
715
  // a child can never use the result because `PI_DADDY_ADVISOR` is stripped from it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-daddy",
3
- "version": "0.41.1",
3
+ "version": "0.43.0",
4
4
  "description": "Capability governance for pi sub-agents: spawn Agent Skills (SKILL.md) definitions whose allowed-tools becomes a grant that can only narrow going down a delegation tree, enforced by pi's own --tools allowlist, with an append-only ledger.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -41,6 +41,17 @@ export interface AdviceRecord {
41
41
  /** Present only when advice came back. */
42
42
  answers?: Readonly<Record<string, { value: string | number | boolean; confidence?: number }>>;
43
43
  model?: string;
44
+ resolvedModel: { provider: string; modelId: string } | null;
45
+ modelSource: "explicit" | "session" | "definition" | "global" | "pi";
46
+ thinkingLevel: string | null;
47
+ thinkingSource: "explicit" | "session" | "advisor" | "definition" | "global" | "pi";
48
+ tokenDetail: {
49
+ inputTokens: number | null;
50
+ outputTokens: number | null;
51
+ cacheReadTokens: number | null;
52
+ cacheWriteTokens: number | null;
53
+ reasoningTokens: number | null;
54
+ };
44
55
  /**
45
56
  * Why there is no advice. `declined` means the advisor answered with nothing; `error` means it could not be
46
57
  * reached or its response was unrecognised; `cancelled` means the CALLER went away, which is not the advisor's
@@ -49,9 +60,22 @@ export interface AdviceRecord {
49
60
  outcome: "answered" | "disabled" | "timeout" | "error" | "declined" | "cancelled";
50
61
  }
51
62
 
63
+ export interface AdviceAttribution {
64
+ resolvedModel: { provider: string; modelId: string } | null;
65
+ modelSource: AdviceRecord["modelSource"];
66
+ thinkingLevel: string | null;
67
+ thinkingSource: AdviceRecord["thinkingSource"];
68
+ }
69
+
52
70
  export interface Advisor {
53
71
  task(task: string): string | TaskDigest;
54
- ask(purpose: string, request: AdviceRequest, signal?: AbortSignal, executionId?: string): Promise<Advice | null>;
72
+ ask(
73
+ purpose: string,
74
+ request: AdviceRequest,
75
+ signal?: AbortSignal,
76
+ executionId?: string,
77
+ attribution?: AdviceAttribution | ((advice: Advice | null) => AdviceAttribution),
78
+ ): Promise<Advice | null>;
55
79
  }
56
80
 
57
81
  export function createAdvisor(input: {
@@ -74,8 +98,17 @@ export function createAdvisor(input: {
74
98
  }
75
99
  return digestTask(task);
76
100
  },
77
- async ask(purpose, request, signal, executionId) {
101
+ async ask(purpose, request, signal, executionId, attribution) {
78
102
  const started = Date.now();
103
+ const attributionFor = (advice: Advice | null): AdviceAttribution =>
104
+ typeof attribution === "function"
105
+ ? attribution(advice)
106
+ : (attribution ?? {
107
+ resolvedModel: advice?.resolvedModel ?? null,
108
+ modelSource: "pi",
109
+ thinkingLevel: null,
110
+ thinkingSource: "pi",
111
+ });
79
112
  const base = {
80
113
  ...(input.episodeId ? { episodeId: input.episodeId } : {}),
81
114
  ...(executionId ? { executionId } : {}),
@@ -83,6 +116,14 @@ export function createAdvisor(input: {
83
116
  purpose,
84
117
  decider: input.decider.name,
85
118
  questions: Object.keys(request.questions),
119
+ ...attributionFor(null),
120
+ tokenDetail: {
121
+ inputTokens: null,
122
+ outputTokens: null,
123
+ cacheReadTokens: null,
124
+ cacheWriteTokens: null,
125
+ reasoningTokens: null,
126
+ },
86
127
  };
87
128
  const write = async (entry: AdviceRecord) => {
88
129
  try {
@@ -125,6 +166,8 @@ export function createAdvisor(input: {
125
166
  ]),
126
167
  ),
127
168
  ...(advice.model ? { model: advice.model } : {}),
169
+ ...attributionFor(advice),
170
+ ...(advice.tokenDetail ? { tokenDetail: advice.tokenDetail } : {}),
128
171
  });
129
172
  return advice;
130
173
  } catch {
@@ -46,6 +46,14 @@ export interface Advice {
46
46
  answers: Readonly<Record<string, Answer>>;
47
47
  /** What actually answered, as the transport reported it — a dated model id, not the one we asked for. */
48
48
  model?: string;
49
+ resolvedModel?: { provider: string; modelId: string };
50
+ tokenDetail?: {
51
+ inputTokens: number | null;
52
+ outputTokens: number | null;
53
+ cacheReadTokens: number | null;
54
+ cacheWriteTokens: number | null;
55
+ reasoningTokens: number | null;
56
+ };
49
57
  }
50
58
 
51
59
  export interface Decider {
@@ -93,9 +93,42 @@ export function parseAdvice(request: AdviceRequest, body: unknown): Advice | nul
93
93
  if (!parsed) return null;
94
94
  answers[key] = parsed;
95
95
  }
96
- return { answers, ...(typeof envelope.model === "string" ? { model: envelope.model } : {}) };
96
+ const model = typeof envelope.model === "string" ? envelope.model : undefined;
97
+ const provider = typeof envelope.provider === "string" ? envelope.provider : undefined;
98
+ const tokenDetail = parseTokenDetail(envelope.usage);
99
+ return {
100
+ answers,
101
+ ...(model ? { model } : {}),
102
+ ...(model && provider ? { resolvedModel: { provider, modelId: model } } : {}),
103
+ ...(tokenDetail ? { tokenDetail } : {}),
104
+ };
97
105
  }
98
106
 
107
+ function parseTokenDetail(value: unknown): Advice["tokenDetail"] | undefined {
108
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
109
+ const usage = value as Record<string, unknown>;
110
+ const details = object(usage.input_tokens_details ?? usage.prompt_tokens_details);
111
+ const outputDetails = object(usage.output_tokens_details ?? usage.completion_tokens_details);
112
+ const read = (...keys: string[]): number | null => {
113
+ const reported = keys.map((key) => usage[key]).find((entry) => typeof entry === "number" && Number.isFinite(entry));
114
+ return typeof reported === "number" && reported > 0 ? reported : null;
115
+ };
116
+ const nested = (source: Record<string, unknown> | undefined, key: string): number | null => {
117
+ const reported = source?.[key];
118
+ return typeof reported === "number" && Number.isFinite(reported) && reported > 0 ? reported : null;
119
+ };
120
+ return {
121
+ inputTokens: read("input_tokens", "prompt_tokens"),
122
+ outputTokens: read("output_tokens", "completion_tokens"),
123
+ cacheReadTokens: read("cache_read_tokens") ?? nested(details, "cached_tokens"),
124
+ cacheWriteTokens: read("cache_write_tokens"),
125
+ reasoningTokens: read("reasoning_tokens") ?? nested(outputDetails, "reasoning_tokens"),
126
+ };
127
+ }
128
+
129
+ const object = (value: unknown): Record<string, unknown> | undefined =>
130
+ typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
131
+
99
132
  export interface JevConfig {
100
133
  apiKey: string;
101
134
  model?: string;
package/src/cli.ts CHANGED
@@ -36,9 +36,17 @@ import {
36
36
  type SkillPackage,
37
37
  } from "./kernel/skill-packages.ts";
38
38
  import { adoptLegacyEnvironment, legacyEnvironmentWarning } from "./kernel/env-names.ts";
39
- import { PI_PROJECT_DIR, PROJECT_FILES, PROJECT_STATE_DIRNAME } from "./kernel/project-paths.ts";
39
+ import {
40
+ activityTimelinePath,
41
+ PI_PROJECT_DIR,
42
+ PROJECT_FILES,
43
+ PROJECT_STATE_DIRNAME,
44
+ projectLedgerPath,
45
+ } from "./kernel/project-paths.ts";
40
46
  import { repairLedger } from "./governance/record.ts";
41
47
  import { importLegacyLedger } from "./governance/ledger.ts";
48
+ import { commitsForRepo, renderEpisodeReport, reportEpisodes, type EpisodeGroupBy } from "./products/episode-report.ts";
49
+ import { updateEpisodeOutcomes } from "./products/episode-outcomes.ts";
42
50
 
43
51
  /** The reviewable record, as the operator sees it relative to the project (ADR-0076 PR 3c). */
44
52
  const SETTINGS_REL = `${PI_PROJECT_DIR}/${PROJECT_STATE_DIRNAME}/${PROJECT_FILES.settings}`;
@@ -50,6 +58,9 @@ Usage:
50
58
  packages that declare skills (package.json "pi": {"skills": …})
51
59
  pi-daddy ledger repair <path> [--yes] show the damaged tail of a ledger; --yes truncates it (ADR-0076)
52
60
  pi-daddy ledger import <source> <target> copy a pre-format ledger into the record format; the source is untouched
61
+ pi-daddy report [--since <date>] [--definition <name>] [--model <id>]
62
+ [--group-by definition|model|thinking] [--json]
63
+ pi-daddy outcomes append changed episode outcomes from Git and CI
53
64
  pi-daddy --help | --version
54
65
 
55
66
  init references skills already enabled in Pi at their installed or local paths. Legacy unregistered npm
@@ -61,13 +72,18 @@ stays unspawnable. Capabilities that can change your machine
61
72
  It never rewrites ${SETTINGS_REL} — delete that file if you want it regenerated.`;
62
73
 
63
74
  export interface ParsedArgs {
64
- command: "init" | "ledger-repair" | "ledger-import" | "help" | "version";
75
+ command: "init" | "ledger-repair" | "ledger-import" | "report" | "outcomes" | "help" | "version";
65
76
  importTarget?: string;
66
77
  /** `ledger repair <path>`: the ledger file; `yes` applies, otherwise preview only. */
67
78
  ledgerPath?: string;
68
79
  yes?: boolean;
69
80
  dir?: string;
70
81
  force: boolean;
82
+ since?: string;
83
+ definition?: string;
84
+ model?: string;
85
+ groupBy?: EpisodeGroupBy;
86
+ json?: boolean;
71
87
  /** Non-empty means refuse: argv said something this program does not understand. */
72
88
  errors: string[];
73
89
  }
@@ -105,6 +121,39 @@ export function parseArgs(argv: string[]): ParsedArgs {
105
121
  if (unknown.length) return { command: "help", force: false, errors: [`unknown option "${unknown[0]}"`] };
106
122
  return { command: "ledger-repair", force: false, errors: [], ledgerPath: target, yes: flags.includes("--yes") };
107
123
  }
124
+ if (command === "outcomes") {
125
+ return tail.length === 0
126
+ ? { command: "outcomes", force: false, errors: [] }
127
+ : { command: "outcomes", force: false, errors: [`unknown option ${tail[0]}`] };
128
+ }
129
+ if (command === "report") {
130
+ const parsed: ParsedArgs = { command: "report", force: false, errors: [] };
131
+ for (let index = 0; index < tail.length; index += 1) {
132
+ const flag = tail[index];
133
+ if (flag === "--json") {
134
+ parsed.json = true;
135
+ continue;
136
+ }
137
+ if (!["--since", "--definition", "--model", "--group-by"].includes(flag)) {
138
+ parsed.errors.push(`unknown option ${flag}`);
139
+ continue;
140
+ }
141
+ const value = tail[index + 1];
142
+ if (!value || value.startsWith("-")) {
143
+ parsed.errors.push(`${flag} needs a value`);
144
+ continue;
145
+ }
146
+ index += 1;
147
+ if (flag === "--since") {
148
+ if (Number.isNaN(Date.parse(value))) parsed.errors.push("--since needs a valid date");
149
+ else parsed.since = value;
150
+ } else if (flag === "--definition") parsed.definition = value;
151
+ else if (flag === "--model") parsed.model = value;
152
+ else if (["definition", "model", "thinking"].includes(value)) parsed.groupBy = value as EpisodeGroupBy;
153
+ else parsed.errors.push("--group-by needs definition, model, or thinking");
154
+ }
155
+ return parsed;
156
+ }
108
157
  if (command !== "init") return { command: "help", force: false, errors: [`unknown command "${command}"`] };
109
158
  const errors: string[] = [];
110
159
  let force = false;
@@ -325,6 +374,45 @@ export async function main(argv: string[]): Promise<number> {
325
374
  console.log(USAGE);
326
375
  return 0;
327
376
  }
377
+ if (parsed.command === "outcomes") {
378
+ const cwd = process.cwd();
379
+ const result = await updateEpisodeOutcomes({
380
+ cwd,
381
+ ledgerPath: projectLedgerPath(cwd),
382
+ activityPath: activityTimelinePath(cwd),
383
+ });
384
+ console.log(`pi-daddy: ${result.appended} outcome record(s) appended for ${result.outcomes.length} episode(s)`);
385
+ return 0;
386
+ }
387
+ if (parsed.command === "report") {
388
+ const cwd = process.cwd();
389
+ const readOptional = async (path: string): Promise<string> => {
390
+ try {
391
+ return await readFile(path, "utf8");
392
+ } catch (error) {
393
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return "";
394
+ throw error;
395
+ }
396
+ };
397
+ const [ledgerText, activityText, commitByEpisode] = await Promise.all([
398
+ readOptional(projectLedgerPath(cwd)),
399
+ readOptional(activityTimelinePath(cwd)),
400
+ commitsForRepo(cwd),
401
+ ]);
402
+ const output = reportEpisodes({
403
+ ledgerText,
404
+ activityText,
405
+ commitByEpisode,
406
+ options: {
407
+ ...(parsed.since ? { since: parsed.since } : {}),
408
+ ...(parsed.definition ? { definition: parsed.definition } : {}),
409
+ ...(parsed.model ? { model: parsed.model } : {}),
410
+ ...(parsed.groupBy ? { groupBy: parsed.groupBy } : {}),
411
+ },
412
+ });
413
+ console.log(renderEpisodeReport(output, parsed.json === true).trimEnd());
414
+ return 0;
415
+ }
328
416
  if (parsed.command === "ledger-import") {
329
417
  const source = resolvePath(process.cwd(), parsed.ledgerPath!),
330
418
  target = resolvePath(process.cwd(), parsed.importTarget!);