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.
- package/CHANGELOG.md +37 -0
- package/contracts/ledger-record/v1/fixtures/capability-decision.json +3 -1
- package/contracts/ledger-record/v1/fixtures/child-lifecycle.json +25 -0
- package/contracts/ledger-record/v1/fixtures/episode-cost-gate.json +14 -0
- package/contracts/ledger-record/v1/fixtures/episode-outcome.json +12 -0
- package/contracts/ledger-record/v1/fixtures/ledger-record.jsonl +6 -3
- package/contracts/ledger-record/v1/fixtures/session-config.json +14 -0
- package/contracts/ledger-record/v1/governance-event.schema.json +356 -0
- package/dist/advisors/advisor.d.ts +24 -1
- package/dist/advisors/advisor.d.ts.map +1 -1
- package/dist/advisors/advisor.js +19 -1
- package/dist/advisors/advisor.js.map +1 -1
- package/dist/advisors/decider.d.ts +11 -0
- package/dist/advisors/decider.d.ts.map +1 -1
- package/dist/advisors/decider.js.map +1 -1
- package/dist/advisors/jev.d.ts.map +1 -1
- package/dist/advisors/jev.js +32 -1
- package/dist/advisors/jev.js.map +1 -1
- package/dist/cli.d.ts +7 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +87 -1
- package/dist/cli.js.map +1 -1
- package/dist/executors/activity-session.d.ts +13 -0
- package/dist/executors/activity-session.d.ts.map +1 -1
- package/dist/executors/activity-session.js +44 -2
- package/dist/executors/activity-session.js.map +1 -1
- package/dist/governance/episode-cost-gate.d.ts +31 -0
- package/dist/governance/episode-cost-gate.d.ts.map +1 -0
- package/dist/governance/episode-cost-gate.js +112 -0
- package/dist/governance/episode-cost-gate.js.map +1 -0
- package/dist/governance/ledger-events.d.ts +108 -1
- package/dist/governance/ledger-events.d.ts.map +1 -1
- package/dist/governance/ledger-events.js +64 -0
- package/dist/governance/ledger-events.js.map +1 -1
- package/dist/governance/ledger-v3-validation.d.ts.map +1 -1
- package/dist/governance/ledger-v3-validation.js +124 -0
- package/dist/governance/ledger-v3-validation.js.map +1 -1
- package/dist/governance/ledger.d.ts +8 -2
- package/dist/governance/ledger.d.ts.map +1 -1
- package/dist/governance/ledger.js +18 -2
- package/dist/governance/ledger.js.map +1 -1
- package/dist/kernel/context-handoff.d.ts.map +1 -1
- package/dist/kernel/context-handoff.js +4 -1
- package/dist/kernel/context-handoff.js.map +1 -1
- package/dist/kernel/definitions.d.ts +5 -0
- package/dist/kernel/definitions.d.ts.map +1 -1
- package/dist/kernel/definitions.js +30 -1
- package/dist/kernel/definitions.js.map +1 -1
- package/dist/kernel/delegate-types.d.ts +4 -0
- package/dist/kernel/delegate-types.d.ts.map +1 -1
- package/dist/kernel/delegate-types.js.map +1 -1
- package/dist/kernel/delegate.d.ts.map +1 -1
- package/dist/kernel/delegate.js +20 -5
- package/dist/kernel/delegate.js.map +1 -1
- package/dist/kernel/env-names.d.ts +5 -0
- package/dist/kernel/env-names.d.ts.map +1 -1
- package/dist/kernel/env-names.js +9 -0
- package/dist/kernel/env-names.js.map +1 -1
- package/dist/kernel/grant-env.d.ts +2 -0
- package/dist/kernel/grant-env.d.ts.map +1 -1
- package/dist/kernel/grant-env.js +1 -0
- package/dist/kernel/grant-env.js.map +1 -1
- package/dist/kernel/propagation.d.ts +2 -2
- package/dist/kernel/propagation.d.ts.map +1 -1
- package/dist/kernel/propagation.js +7 -2
- package/dist/kernel/propagation.js.map +1 -1
- package/dist/products/dashboard-cli.d.ts +4 -0
- package/dist/products/dashboard-cli.d.ts.map +1 -1
- package/dist/products/dashboard-cli.js +32 -4
- package/dist/products/dashboard-cli.js.map +1 -1
- package/dist/products/dashboard-handshake.d.ts +8 -0
- package/dist/products/dashboard-handshake.d.ts.map +1 -1
- package/dist/products/dashboard-handshake.js +3 -0
- package/dist/products/dashboard-handshake.js.map +1 -1
- package/dist/products/dashboard-herdr.d.ts +4 -0
- package/dist/products/dashboard-herdr.d.ts.map +1 -1
- package/dist/products/dashboard-herdr.js +10 -2
- package/dist/products/dashboard-herdr.js.map +1 -1
- package/dist/products/dashboard-projection.d.ts.map +1 -1
- package/dist/products/dashboard-projection.js +13 -6
- package/dist/products/dashboard-projection.js.map +1 -1
- package/dist/products/dashboard-render.d.ts +10 -0
- package/dist/products/dashboard-render.d.ts.map +1 -1
- package/dist/products/dashboard-render.js +9 -0
- package/dist/products/dashboard-render.js.map +1 -1
- package/dist/products/dashboard-session-client.d.ts +17 -0
- package/dist/products/dashboard-session-client.d.ts.map +1 -0
- package/dist/products/dashboard-session-client.js +28 -0
- package/dist/products/dashboard-session-client.js.map +1 -0
- package/dist/products/episode-outcomes.d.ts +32 -0
- package/dist/products/episode-outcomes.d.ts.map +1 -0
- package/dist/products/episode-outcomes.js +280 -0
- package/dist/products/episode-outcomes.js.map +1 -0
- package/dist/products/episode-report.d.ts +59 -0
- package/dist/products/episode-report.d.ts.map +1 -0
- package/dist/products/episode-report.js +311 -0
- package/dist/products/episode-report.js.map +1 -0
- package/extensions/chain-ledger.ts +2 -0
- package/extensions/chain-plan.ts +4 -2
- package/extensions/context-shape.ts +19 -4
- package/extensions/dashboard-session-server.ts +86 -0
- package/extensions/definition-runtime.ts +93 -0
- package/extensions/delegate-chain.ts +31 -2
- package/extensions/delegation-ledger.ts +2 -0
- package/extensions/delegation.ts +24 -0
- package/extensions/effort-advice.ts +12 -0
- package/extensions/execute-child.ts +148 -9
- package/extensions/grants-command.ts +32 -2
- package/extensions/grants.ts +23 -1
- package/extensions/pruning-advice.ts +3 -1
- package/extensions/run-delegation.ts +54 -6
- package/extensions/session-model-prompt.ts +179 -0
- package/extensions/session.ts +104 -3
- package/package.json +1 -1
- package/src/advisors/advisor.ts +45 -2
- package/src/advisors/decider.ts +8 -0
- package/src/advisors/jev.ts +34 -1
- package/src/cli.ts +90 -2
- package/src/executors/activity-session.ts +56 -2
- package/src/governance/episode-cost-gate.ts +128 -0
- package/src/governance/ledger-events.ts +173 -1
- package/src/governance/ledger-v3-validation.ts +146 -0
- package/src/governance/ledger.ts +29 -1
- package/src/kernel/context-handoff.ts +5 -1
- package/src/kernel/definitions.ts +32 -1
- package/src/kernel/delegate-types.ts +4 -0
- package/src/kernel/delegate.ts +22 -5
- package/src/kernel/env-names.ts +10 -0
- package/src/kernel/grant-env.ts +3 -0
- package/src/kernel/propagation.ts +7 -0
- package/src/products/dashboard-cli.ts +32 -4
- package/src/products/dashboard-handshake.ts +5 -0
- package/src/products/dashboard-herdr.ts +15 -1
- package/src/products/dashboard-projection.ts +13 -6
- package/src/products/dashboard-render.ts +11 -0
- package/src/products/dashboard-session-client.ts +35 -0
- package/src/products/episode-outcomes.ts +317 -0
- package/src/products/episode-report.ts +372 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/** Compute durable episode outcomes from Git, CI metadata, and the next recorded operator turn. */
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { appendLedgerEvent, buildEpisodeOutcomeEvent } from "../governance/ledger.ts";
|
|
6
|
+
import { readRecords } from "../governance/record.ts";
|
|
7
|
+
import { isEpisodeId } from "../kernel/episode-id.ts";
|
|
8
|
+
import { readActivityContent, type ContentReference } from "./activity-timeline.ts";
|
|
9
|
+
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
const FORTY_EIGHT_HOURS = 48 * 60 * 60 * 1_000;
|
|
12
|
+
const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1_000;
|
|
13
|
+
export const CORRECTION_PREFIXES = ["no,", "that's wrong", "undo", "revert"] as const;
|
|
14
|
+
|
|
15
|
+
type CiSignal = "green" | "red" | "none";
|
|
16
|
+
type OutcomeLabel = "positive" | "negative" | "unknown";
|
|
17
|
+
export interface EpisodeOutcomeSignals {
|
|
18
|
+
episodeId: string;
|
|
19
|
+
commit: string;
|
|
20
|
+
survived: boolean;
|
|
21
|
+
ci: CiSignal;
|
|
22
|
+
amended: boolean;
|
|
23
|
+
corrected: boolean;
|
|
24
|
+
label: OutcomeLabel;
|
|
25
|
+
}
|
|
26
|
+
interface CommitFact {
|
|
27
|
+
sha: string;
|
|
28
|
+
parents: string[];
|
|
29
|
+
authoredMs: number;
|
|
30
|
+
message: string;
|
|
31
|
+
episodes: string[];
|
|
32
|
+
}
|
|
33
|
+
export interface GitCommandResult {
|
|
34
|
+
stdout: string;
|
|
35
|
+
code: number;
|
|
36
|
+
}
|
|
37
|
+
export type GitRunner = (args: string[]) => Promise<GitCommandResult>;
|
|
38
|
+
export type GhRunner = (sha: string) => Promise<string | null>;
|
|
39
|
+
|
|
40
|
+
async function command(cwd: string, file: string, args: string[]): Promise<GitCommandResult> {
|
|
41
|
+
try {
|
|
42
|
+
const result = await execFileAsync(file, args, { cwd, maxBuffer: 32 * 1024 * 1024 });
|
|
43
|
+
return { stdout: result.stdout, code: 0 };
|
|
44
|
+
} catch (error) {
|
|
45
|
+
const failure = error as { stdout?: string; code?: number | string };
|
|
46
|
+
return { stdout: failure.stdout ?? "", code: typeof failure.code === "number" ? failure.code : 127 };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function commitsFromLog(log: string): CommitFact[] {
|
|
51
|
+
const commits: CommitFact[] = [];
|
|
52
|
+
for (const entry of log.split("\u001e")) {
|
|
53
|
+
const [sha, parentText, authored, ...messageParts] = entry.trim().split("\u001f");
|
|
54
|
+
if (!/^[a-f0-9]{40}$/i.test(sha ?? "")) continue;
|
|
55
|
+
const parents = parentText.split(" ").filter((parent) => /^[a-f0-9]{40}$/i.test(parent));
|
|
56
|
+
const message = messageParts.join("\u001f");
|
|
57
|
+
const episodes = [...message.matchAll(/^Pi-Episode:\s*(\S+)\s*$/gim)]
|
|
58
|
+
.map((match) => (match[1].startsWith("episode:") ? match[1] : `episode:${match[1]}`))
|
|
59
|
+
.filter(isEpisodeId);
|
|
60
|
+
commits.push({ sha, parents, authoredMs: Date.parse(authored), message, episodes });
|
|
61
|
+
}
|
|
62
|
+
return commits;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function absentOrThrow(result: GitCommandResult, operation: string): boolean {
|
|
66
|
+
if (result.code === 1) return true;
|
|
67
|
+
if (result.code !== 0) throw new Error(`pi-daddy outcomes: git ${operation} failed`);
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function defaultBranch(runGit: GitRunner): Promise<string> {
|
|
72
|
+
const remote = await runGit(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]);
|
|
73
|
+
if (remote.code === 0 && remote.stdout.trim()) return remote.stdout.trim();
|
|
74
|
+
absentOrThrow(remote, "symbolic-ref");
|
|
75
|
+
for (const name of ["main", "master"]) {
|
|
76
|
+
const local = await runGit(["show-ref", "--verify", "--quiet", `refs/heads/${name}`]);
|
|
77
|
+
if (local.code === 0) return name;
|
|
78
|
+
absentOrThrow(local, "show-ref");
|
|
79
|
+
}
|
|
80
|
+
const current = await runGit(["symbolic-ref", "--quiet", "--short", "HEAD"]);
|
|
81
|
+
if (current.code === 0 && current.stdout.trim()) return current.stdout.trim();
|
|
82
|
+
absentOrThrow(current, "symbolic-ref");
|
|
83
|
+
throw new Error("pi-daddy outcomes: no default branch could be resolved");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function gitIsAncestor(
|
|
87
|
+
cwd: string,
|
|
88
|
+
older: string,
|
|
89
|
+
newer: string,
|
|
90
|
+
runGit: GitRunner = (args) => command(cwd, "git", args),
|
|
91
|
+
): Promise<boolean> {
|
|
92
|
+
const result = await runGit(["merge-base", "--is-ancestor", older, newer]);
|
|
93
|
+
if (result.code === 0) return true;
|
|
94
|
+
if (result.code === 1) return false;
|
|
95
|
+
throw new Error(`pi-daddy outcomes: git merge-base failed for ${older} and ${newer}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function filesFor(runGit: GitRunner, sha: string): Promise<Set<string>> {
|
|
99
|
+
const result = await runGit(["diff-tree", "--root", "--no-commit-id", "--name-only", "-r", sha]);
|
|
100
|
+
if (result.code !== 0) throw new Error(`pi-daddy outcomes: could not read files for ${sha}`);
|
|
101
|
+
return new Set(result.stdout.split("\n").filter(Boolean));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function diffFor(runGit: GitRunner, from: string, to: string): Promise<string> {
|
|
105
|
+
const result = await runGit(["diff", "--binary", from, to, "--"]);
|
|
106
|
+
if (result.code !== 0) throw new Error(`pi-daddy outcomes: git diff failed for ${from} and ${to}`);
|
|
107
|
+
return result.stdout;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function commitDiff(runGit: GitRunner, commit: CommitFact): Promise<string> {
|
|
111
|
+
if (commit.parents[0]) return diffFor(runGit, commit.parents[0], commit.sha);
|
|
112
|
+
const result = await runGit(["show", "--format=", "--binary", "--root", commit.sha, "--"]);
|
|
113
|
+
if (result.code !== 0) throw new Error(`pi-daddy outcomes: git show failed for ${commit.sha}`);
|
|
114
|
+
return result.stdout;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function commitChildren(commits: CommitFact[]): Map<string, string[]> {
|
|
118
|
+
const children = new Map<string, string[]>();
|
|
119
|
+
for (const commit of commits)
|
|
120
|
+
for (const parent of commit.parents) children.set(parent, [...(children.get(parent) ?? []), commit.sha]);
|
|
121
|
+
return children;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function laterDescendants(
|
|
125
|
+
candidate: CommitFact,
|
|
126
|
+
commits: Map<string, CommitFact>,
|
|
127
|
+
children: Map<string, string[]>,
|
|
128
|
+
): CommitFact[] {
|
|
129
|
+
const later: CommitFact[] = [];
|
|
130
|
+
const pending = [...(children.get(candidate.sha) ?? [])];
|
|
131
|
+
const visited = new Set<string>();
|
|
132
|
+
while (pending.length > 0) {
|
|
133
|
+
const sha = pending.pop()!;
|
|
134
|
+
if (visited.has(sha)) continue;
|
|
135
|
+
visited.add(sha);
|
|
136
|
+
const commit = commits.get(sha);
|
|
137
|
+
if (commit) later.push(commit);
|
|
138
|
+
pending.push(...(children.get(sha) ?? []));
|
|
139
|
+
}
|
|
140
|
+
return later;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function cached<T>(cache: Map<string, Promise<T>>, key: string, load: () => Promise<T>): Promise<T> {
|
|
144
|
+
const existing = cache.get(key);
|
|
145
|
+
if (existing) return existing;
|
|
146
|
+
const value = load();
|
|
147
|
+
cache.set(key, value);
|
|
148
|
+
return value;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function wasReverted(
|
|
152
|
+
runGit: GitRunner,
|
|
153
|
+
candidate: CommitFact,
|
|
154
|
+
later: CommitFact[],
|
|
155
|
+
commitDiffs: Map<string, Promise<string>>,
|
|
156
|
+
inverseDiffs: Map<string, Promise<string>>,
|
|
157
|
+
): Promise<boolean> {
|
|
158
|
+
const named = later.some((commit) => {
|
|
159
|
+
const lower = commit.message.toLowerCase();
|
|
160
|
+
return lower.includes(candidate.sha.toLowerCase()) || lower.includes(candidate.sha.slice(0, 12).toLowerCase());
|
|
161
|
+
});
|
|
162
|
+
if (named) return true;
|
|
163
|
+
const original = await cached(commitDiffs, candidate.sha, () => commitDiff(runGit, candidate));
|
|
164
|
+
for (const commit of later) {
|
|
165
|
+
const inverse = await cached(inverseDiffs, commit.sha, () => diffFor(runGit, commit.sha, `${commit.sha}^`));
|
|
166
|
+
if (inverse === original) return true;
|
|
167
|
+
}
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function wasAmended(
|
|
172
|
+
runGit: GitRunner,
|
|
173
|
+
candidate: CommitFact,
|
|
174
|
+
later: CommitFact[],
|
|
175
|
+
filesByCommit: Map<string, Promise<Set<string>>>,
|
|
176
|
+
): Promise<boolean> {
|
|
177
|
+
const files = await cached(filesByCommit, candidate.sha, () => filesFor(runGit, candidate.sha));
|
|
178
|
+
if (files.size === 0) return false;
|
|
179
|
+
for (const commit of later) {
|
|
180
|
+
const elapsed = commit.authoredMs - candidate.authoredMs;
|
|
181
|
+
if (elapsed < 0 || elapsed > TWENTY_FOUR_HOURS) continue;
|
|
182
|
+
if (!/^(fix|fixup|amend)(?:\b|:)/i.test(commit.message.trim())) continue;
|
|
183
|
+
const touched = await cached(filesByCommit, commit.sha, () => filesFor(runGit, commit.sha));
|
|
184
|
+
if ([...touched].some((file) => files.has(file))) return true;
|
|
185
|
+
}
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function defaultGh(cwd: string, sha: string): Promise<string | null> {
|
|
190
|
+
if ((await command(cwd, "gh", ["--version"])).code !== 0) return null;
|
|
191
|
+
const result = await command(cwd, "gh", ["run", "list", "--commit", sha, "--json", "conclusion", "--limit", "100"]);
|
|
192
|
+
if (result.code !== 0) throw new Error(`pi-daddy outcomes: gh run list failed for ${sha}`);
|
|
193
|
+
return result.stdout;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function ciFrom(value: string | null): CiSignal {
|
|
197
|
+
if (value === null) return "none";
|
|
198
|
+
try {
|
|
199
|
+
const conclusions = (JSON.parse(value) as Array<{ conclusion?: unknown }>).map((run) =>
|
|
200
|
+
String(run.conclusion ?? ""),
|
|
201
|
+
);
|
|
202
|
+
if (
|
|
203
|
+
conclusions.some((item) =>
|
|
204
|
+
["failure", "cancelled", "timed_out", "action_required", "startup_failure"].includes(item),
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
return "red";
|
|
208
|
+
return conclusions.some((item) => ["success", "neutral", "skipped"].includes(item)) ? "green" : "none";
|
|
209
|
+
} catch {
|
|
210
|
+
throw new Error("pi-daddy outcomes: gh run list returned invalid JSON");
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function corrections(activityPath: string): Promise<Set<string>> {
|
|
215
|
+
let text: string;
|
|
216
|
+
try {
|
|
217
|
+
text = await readFile(activityPath, "utf8");
|
|
218
|
+
} catch (error) {
|
|
219
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return new Set();
|
|
220
|
+
throw error;
|
|
221
|
+
}
|
|
222
|
+
const parsed = readRecords(text);
|
|
223
|
+
if (parsed.damage) throw new Error(`activity ledger damaged at line ${parsed.damage.line}: ${parsed.damage.reason}`);
|
|
224
|
+
const turns = new Map<string, Array<{ at: number; prompt?: ContentReference }>>();
|
|
225
|
+
for (const record of parsed.records) {
|
|
226
|
+
const body = record.body as Record<string, unknown>;
|
|
227
|
+
if (record.kind !== "activity" || body.kind !== "task_started" || body.agentId !== undefined) continue;
|
|
228
|
+
if (!isEpisodeId(body.episodeId)) continue;
|
|
229
|
+
const list = turns.get(body.episodeId) ?? [];
|
|
230
|
+
list.push({ at: Date.parse(String(body.at)), prompt: body.prompt as ContentReference | undefined });
|
|
231
|
+
turns.set(body.episodeId, list);
|
|
232
|
+
}
|
|
233
|
+
const corrected = new Set<string>();
|
|
234
|
+
for (const [episode, entries] of turns) {
|
|
235
|
+
const next = entries.sort((a, b) => a.at - b.at)[1];
|
|
236
|
+
if (!next?.prompt?.ref) continue;
|
|
237
|
+
const prompt = (await readActivityContent(activityPath, next.prompt)).trimStart().toLowerCase();
|
|
238
|
+
if (CORRECTION_PREFIXES.some((prefix) => prompt.startsWith(prefix))) corrected.add(episode);
|
|
239
|
+
}
|
|
240
|
+
return corrected;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function priorSignals(ledgerText: string): Map<string, EpisodeOutcomeSignals> {
|
|
244
|
+
const parsed = readRecords(ledgerText);
|
|
245
|
+
if (parsed.damage) throw new Error(`grants ledger damaged at line ${parsed.damage.line}: ${parsed.damage.reason}`);
|
|
246
|
+
const prior = new Map<string, EpisodeOutcomeSignals>();
|
|
247
|
+
for (const record of parsed.records) {
|
|
248
|
+
const body = record.body as Partial<EpisodeOutcomeSignals> & { event?: unknown };
|
|
249
|
+
if (body.event === "episode_outcome" && body.episodeId) prior.set(body.episodeId, body as EpisodeOutcomeSignals);
|
|
250
|
+
}
|
|
251
|
+
return prior;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function sameSignals(left: EpisodeOutcomeSignals | undefined, right: EpisodeOutcomeSignals): boolean {
|
|
255
|
+
return (
|
|
256
|
+
left?.survived === right.survived &&
|
|
257
|
+
left.ci === right.ci &&
|
|
258
|
+
left.amended === right.amended &&
|
|
259
|
+
left.corrected === right.corrected &&
|
|
260
|
+
left.label === right.label
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export async function updateEpisodeOutcomes(options: {
|
|
265
|
+
cwd: string;
|
|
266
|
+
ledgerPath: string;
|
|
267
|
+
activityPath: string;
|
|
268
|
+
now?: Date;
|
|
269
|
+
runGh?: GhRunner;
|
|
270
|
+
runGit?: GitRunner;
|
|
271
|
+
}): Promise<{ appended: number; outcomes: EpisodeOutcomeSignals[] }> {
|
|
272
|
+
const now = options.now ?? new Date();
|
|
273
|
+
const runGit = options.runGit ?? ((args: string[]) => command(options.cwd, "git", args));
|
|
274
|
+
let ledgerText = "";
|
|
275
|
+
try {
|
|
276
|
+
ledgerText = await readFile(options.ledgerPath, "utf8");
|
|
277
|
+
} catch (error) {
|
|
278
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
279
|
+
}
|
|
280
|
+
const log = await runGit(["log", "--all", "--reflog", "--format=%H%x1f%P%x1f%aI%x1f%B%x1e"]);
|
|
281
|
+
if (log.code !== 0) throw new Error("pi-daddy outcomes: git history could not be read");
|
|
282
|
+
const commits = commitsFromLog(log.stdout);
|
|
283
|
+
const branch = await defaultBranch(runGit);
|
|
284
|
+
const correctedEpisodes = await corrections(options.activityPath);
|
|
285
|
+
const commitsBySha = new Map(commits.map((commit) => [commit.sha, commit]));
|
|
286
|
+
const children = commitChildren(commits);
|
|
287
|
+
const latest = new Map<string, CommitFact>();
|
|
288
|
+
for (const commit of commits)
|
|
289
|
+
for (const episode of commit.episodes) if (!latest.has(episode)) latest.set(episode, commit);
|
|
290
|
+
const outcomes: EpisodeOutcomeSignals[] = [];
|
|
291
|
+
const commitDiffs = new Map<string, Promise<string>>();
|
|
292
|
+
const inverseDiffs = new Map<string, Promise<string>>();
|
|
293
|
+
const filesByCommit = new Map<string, Promise<Set<string>>>();
|
|
294
|
+
for (const [episodeId, candidate] of latest) {
|
|
295
|
+
const later = laterDescendants(candidate, commitsBySha, children);
|
|
296
|
+
const reverted = await wasReverted(runGit, candidate, later, commitDiffs, inverseDiffs);
|
|
297
|
+
const survived = (await gitIsAncestor(options.cwd, candidate.sha, branch, runGit)) && !reverted;
|
|
298
|
+
const ci = ciFrom(await (options.runGh ?? ((sha) => defaultGh(options.cwd, sha)))(candidate.sha));
|
|
299
|
+
const amended = await wasAmended(runGit, candidate, later, filesByCommit);
|
|
300
|
+
const corrected = correctedEpisodes.has(episodeId);
|
|
301
|
+
const oldEnough = now.getTime() - candidate.authoredMs >= FORTY_EIGHT_HOURS;
|
|
302
|
+
const label: OutcomeLabel = !oldEnough
|
|
303
|
+
? "unknown"
|
|
304
|
+
: !survived || ci === "red" || corrected
|
|
305
|
+
? "negative"
|
|
306
|
+
: "positive";
|
|
307
|
+
outcomes.push({ episodeId, commit: candidate.sha, survived, ci, amended, corrected, label });
|
|
308
|
+
}
|
|
309
|
+
const prior = priorSignals(ledgerText);
|
|
310
|
+
let appended = 0;
|
|
311
|
+
for (const outcome of outcomes) {
|
|
312
|
+
if (sameSignals(prior.get(outcome.episodeId), outcome)) continue;
|
|
313
|
+
await appendLedgerEvent({ path: options.ledgerPath }, buildEpisodeOutcomeEvent({ ...outcome, now }));
|
|
314
|
+
appended += 1;
|
|
315
|
+
}
|
|
316
|
+
return { appended, outcomes };
|
|
317
|
+
}
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/** Read-only episode accounting over the governance ledger and activity timeline. */
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import { readRecords } from "../governance/record.ts";
|
|
5
|
+
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
const TERMINAL = new Set(["completed", "failed"]);
|
|
8
|
+
|
|
9
|
+
export type EpisodeGroupBy = "definition" | "model" | "thinking";
|
|
10
|
+
export interface EpisodeReportOptions {
|
|
11
|
+
since?: string;
|
|
12
|
+
definition?: string;
|
|
13
|
+
model?: string;
|
|
14
|
+
groupBy?: EpisodeGroupBy;
|
|
15
|
+
}
|
|
16
|
+
export interface EpisodeReportRow {
|
|
17
|
+
episode: string;
|
|
18
|
+
started: string;
|
|
19
|
+
definition: string;
|
|
20
|
+
definitionHash: string;
|
|
21
|
+
resolvedModel: string;
|
|
22
|
+
modelSource: string;
|
|
23
|
+
thinkingLevel: string;
|
|
24
|
+
thinkingSource: string;
|
|
25
|
+
inputTokens: number;
|
|
26
|
+
outputTokens: number;
|
|
27
|
+
cacheReadTokens: number;
|
|
28
|
+
reasoningTokens: number;
|
|
29
|
+
cost: number;
|
|
30
|
+
compactions: number;
|
|
31
|
+
turns: number;
|
|
32
|
+
durationMs: number;
|
|
33
|
+
children: number;
|
|
34
|
+
commit: string;
|
|
35
|
+
outcome: "" | "positive" | "negative" | "unknown";
|
|
36
|
+
}
|
|
37
|
+
export interface EpisodeReportGroup {
|
|
38
|
+
value: string;
|
|
39
|
+
count: number;
|
|
40
|
+
inputTokens: number;
|
|
41
|
+
outputTokens: number;
|
|
42
|
+
cacheReadTokens: number;
|
|
43
|
+
reasoningTokens: number;
|
|
44
|
+
cost: number;
|
|
45
|
+
}
|
|
46
|
+
export interface EpisodeReport {
|
|
47
|
+
rows: EpisodeReportRow[];
|
|
48
|
+
groups?: EpisodeReportGroup[];
|
|
49
|
+
groupBy?: EpisodeGroupBy;
|
|
50
|
+
totals?: { episodes: number; cost: number; tokens: number; p50EpisodeCost: number; p95EpisodeCost: number };
|
|
51
|
+
}
|
|
52
|
+
interface EpisodeAccumulator {
|
|
53
|
+
episode: string;
|
|
54
|
+
startedMs: number;
|
|
55
|
+
endedMs: number;
|
|
56
|
+
definitions: Set<string>;
|
|
57
|
+
hashes: Set<string>;
|
|
58
|
+
models: Set<string>;
|
|
59
|
+
modelIds: Set<string>;
|
|
60
|
+
modelSources: Set<string>;
|
|
61
|
+
thinkingLevels: Set<string>;
|
|
62
|
+
thinkingSources: Set<string>;
|
|
63
|
+
inputTokens: number;
|
|
64
|
+
outputTokens: number;
|
|
65
|
+
cacheReadTokens: number;
|
|
66
|
+
reasoningTokens: number;
|
|
67
|
+
cost: number;
|
|
68
|
+
compactions: number;
|
|
69
|
+
turns: number;
|
|
70
|
+
children: Set<string>;
|
|
71
|
+
terminalExecutions: Set<string>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const object = (value: unknown): value is Record<string, unknown> =>
|
|
75
|
+
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
76
|
+
const number = (value: unknown): number => (typeof value === "number" && Number.isFinite(value) ? value : 0);
|
|
77
|
+
const text = (value: unknown): string => (typeof value === "string" ? value : "");
|
|
78
|
+
const joined = (values: Set<string>): string => [...values].filter(Boolean).sort().join(",");
|
|
79
|
+
function timestamp(value: unknown, fallback: string): number {
|
|
80
|
+
const parsed = Date.parse(typeof value === "string" ? value : fallback);
|
|
81
|
+
return Number.isFinite(parsed) ? parsed : Date.parse(fallback);
|
|
82
|
+
}
|
|
83
|
+
function episodeOf(episodes: Map<string, EpisodeAccumulator>, id: string, at: number): EpisodeAccumulator {
|
|
84
|
+
const existing = episodes.get(id);
|
|
85
|
+
if (existing) {
|
|
86
|
+
existing.startedMs = Math.min(existing.startedMs, at);
|
|
87
|
+
existing.endedMs = Math.max(existing.endedMs, at);
|
|
88
|
+
return existing;
|
|
89
|
+
}
|
|
90
|
+
const episode: EpisodeAccumulator = {
|
|
91
|
+
episode: id,
|
|
92
|
+
startedMs: at,
|
|
93
|
+
endedMs: at,
|
|
94
|
+
definitions: new Set(),
|
|
95
|
+
hashes: new Set(),
|
|
96
|
+
models: new Set(),
|
|
97
|
+
modelIds: new Set(),
|
|
98
|
+
modelSources: new Set(),
|
|
99
|
+
thinkingLevels: new Set(),
|
|
100
|
+
thinkingSources: new Set(),
|
|
101
|
+
inputTokens: 0,
|
|
102
|
+
outputTokens: 0,
|
|
103
|
+
cacheReadTokens: 0,
|
|
104
|
+
reasoningTokens: 0,
|
|
105
|
+
cost: 0,
|
|
106
|
+
compactions: 0,
|
|
107
|
+
turns: 0,
|
|
108
|
+
children: new Set(),
|
|
109
|
+
terminalExecutions: new Set(),
|
|
110
|
+
};
|
|
111
|
+
episodes.set(id, episode);
|
|
112
|
+
return episode;
|
|
113
|
+
}
|
|
114
|
+
function percentile(values: number[], fraction: number): number {
|
|
115
|
+
if (values.length === 0) return 0;
|
|
116
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
117
|
+
return sorted[Math.ceil(sorted.length * fraction) - 1] ?? 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function reportEpisodes(input: {
|
|
121
|
+
ledgerText: string;
|
|
122
|
+
activityText: string;
|
|
123
|
+
commitByEpisode: ReadonlyMap<string, string>;
|
|
124
|
+
options: EpisodeReportOptions;
|
|
125
|
+
}): EpisodeReport {
|
|
126
|
+
const episodes = new Map<string, EpisodeAccumulator>();
|
|
127
|
+
const outcomes = new Map<string, "positive" | "negative" | "unknown">();
|
|
128
|
+
const ledger = readRecords(input.ledgerText);
|
|
129
|
+
if (ledger.damage) throw new Error(`grants ledger damaged at line ${ledger.damage.line}: ${ledger.damage.reason}`);
|
|
130
|
+
for (const record of ledger.records) {
|
|
131
|
+
if (!object(record.body)) continue;
|
|
132
|
+
const body = record.body;
|
|
133
|
+
const episodeId = text(body.episodeId);
|
|
134
|
+
if (!episodeId) continue;
|
|
135
|
+
if (body.event === "episode_outcome") {
|
|
136
|
+
if (["positive", "negative", "unknown"].includes(text(body.label)))
|
|
137
|
+
outcomes.set(episodeId, body.label as "positive" | "negative" | "unknown");
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const episode = episodeOf(episodes, episodeId, timestamp(body.ts, record.at));
|
|
141
|
+
if (text(body.definitionHash)) episode.hashes.add(text(body.definitionHash).slice(0, 12));
|
|
142
|
+
if (body.event === "capability_decision") {
|
|
143
|
+
if (body.agentType !== "delegate") episode.definitions.add(text(body.agentType));
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (body.event !== "child_lifecycle") continue;
|
|
147
|
+
const executionId = text(body.executionId);
|
|
148
|
+
if (executionId) episode.children.add(executionId);
|
|
149
|
+
if (!TERMINAL.has(text(body.state)) || episode.terminalExecutions.has(executionId)) continue;
|
|
150
|
+
episode.terminalExecutions.add(executionId);
|
|
151
|
+
if (object(body.resolvedModel)) {
|
|
152
|
+
const provider = text(body.resolvedModel.provider),
|
|
153
|
+
modelId = text(body.resolvedModel.modelId);
|
|
154
|
+
if (modelId) {
|
|
155
|
+
episode.modelIds.add(modelId);
|
|
156
|
+
episode.models.add(provider ? `${provider}/${modelId}` : modelId);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (text(body.modelSource)) episode.modelSources.add(text(body.modelSource));
|
|
160
|
+
if (object(body.thinkingLevel)) {
|
|
161
|
+
if (text(body.thinkingLevel.level)) episode.thinkingLevels.add(text(body.thinkingLevel.level));
|
|
162
|
+
if (text(body.thinkingLevel.source)) episode.thinkingSources.add(text(body.thinkingLevel.source));
|
|
163
|
+
}
|
|
164
|
+
if (text(body.thinkingSource)) episode.thinkingSources.add(text(body.thinkingSource));
|
|
165
|
+
const usage = object(body.usage) ? body.usage : undefined;
|
|
166
|
+
if (object(body.tokenDetail)) {
|
|
167
|
+
episode.inputTokens += number(body.tokenDetail.inputTokens);
|
|
168
|
+
episode.outputTokens += number(body.tokenDetail.outputTokens);
|
|
169
|
+
episode.cacheReadTokens += number(body.tokenDetail.cacheReadTokens);
|
|
170
|
+
episode.reasoningTokens += number(body.tokenDetail.reasoningTokens);
|
|
171
|
+
} else if (usage) {
|
|
172
|
+
episode.inputTokens += number(usage.input);
|
|
173
|
+
episode.outputTokens += number(usage.output);
|
|
174
|
+
episode.cacheReadTokens += number(usage.cacheRead);
|
|
175
|
+
episode.reasoningTokens += number(usage.reasoning);
|
|
176
|
+
}
|
|
177
|
+
if (usage && object(usage.cost)) episode.cost += number(usage.cost.total);
|
|
178
|
+
episode.compactions += number(body.compactionCount);
|
|
179
|
+
}
|
|
180
|
+
const activity = readRecords(input.activityText);
|
|
181
|
+
if (activity.damage)
|
|
182
|
+
throw new Error(`activity ledger damaged at line ${activity.damage.line}: ${activity.damage.reason}`);
|
|
183
|
+
for (const record of activity.records) {
|
|
184
|
+
if (record.kind !== "activity" || !object(record.body)) continue;
|
|
185
|
+
const event = record.body;
|
|
186
|
+
const episodeId = text(event.episodeId);
|
|
187
|
+
if (!episodeId) continue;
|
|
188
|
+
const episode = episodeOf(episodes, episodeId, timestamp(event.at, record.at));
|
|
189
|
+
if (event.kind === "task_started" && !text(event.agentId)) episode.turns += 1;
|
|
190
|
+
}
|
|
191
|
+
const since = input.options.since ? Date.parse(input.options.since) : Number.NEGATIVE_INFINITY;
|
|
192
|
+
let rows: EpisodeReportRow[] = [...episodes.values()]
|
|
193
|
+
.map((episode): EpisodeReportRow => ({
|
|
194
|
+
episode: episode.episode,
|
|
195
|
+
started: new Date(episode.startedMs).toISOString(),
|
|
196
|
+
definition: joined(episode.definitions),
|
|
197
|
+
definitionHash: joined(episode.hashes),
|
|
198
|
+
resolvedModel: joined(episode.models),
|
|
199
|
+
modelSource: joined(episode.modelSources),
|
|
200
|
+
thinkingLevel: joined(episode.thinkingLevels),
|
|
201
|
+
thinkingSource: joined(episode.thinkingSources),
|
|
202
|
+
inputTokens: episode.inputTokens,
|
|
203
|
+
outputTokens: episode.outputTokens,
|
|
204
|
+
cacheReadTokens: episode.cacheReadTokens,
|
|
205
|
+
reasoningTokens: episode.reasoningTokens,
|
|
206
|
+
cost: episode.cost,
|
|
207
|
+
compactions: episode.compactions,
|
|
208
|
+
turns: episode.turns,
|
|
209
|
+
durationMs: Math.max(0, episode.endedMs - episode.startedMs),
|
|
210
|
+
children: episode.children.size,
|
|
211
|
+
commit: input.commitByEpisode.get(episode.episode) ?? "",
|
|
212
|
+
outcome: outcomes.get(episode.episode) ?? "",
|
|
213
|
+
}))
|
|
214
|
+
.filter((row) => Date.parse(row.started) >= since)
|
|
215
|
+
.filter((row) => !input.options.definition || row.definition.split(",").includes(input.options.definition))
|
|
216
|
+
.filter((row) => {
|
|
217
|
+
if (!input.options.model) return true;
|
|
218
|
+
const source = episodes.get(row.episode)!;
|
|
219
|
+
return source.modelIds.has(input.options.model) || source.models.has(input.options.model);
|
|
220
|
+
})
|
|
221
|
+
.sort((a, b) => Date.parse(a.started) - Date.parse(b.started) || a.episode.localeCompare(b.episode));
|
|
222
|
+
if (input.options.groupBy) {
|
|
223
|
+
const field =
|
|
224
|
+
input.options.groupBy === "thinking"
|
|
225
|
+
? "thinkingLevel"
|
|
226
|
+
: input.options.groupBy === "model"
|
|
227
|
+
? "resolvedModel"
|
|
228
|
+
: "definition";
|
|
229
|
+
const groups = new Map<string, EpisodeReportGroup>();
|
|
230
|
+
for (const row of rows) {
|
|
231
|
+
const value = row[field] || "(none)";
|
|
232
|
+
const group = groups.get(value) ?? {
|
|
233
|
+
value,
|
|
234
|
+
count: 0,
|
|
235
|
+
inputTokens: 0,
|
|
236
|
+
outputTokens: 0,
|
|
237
|
+
cacheReadTokens: 0,
|
|
238
|
+
reasoningTokens: 0,
|
|
239
|
+
cost: 0,
|
|
240
|
+
};
|
|
241
|
+
group.count += 1;
|
|
242
|
+
group.inputTokens += row.inputTokens;
|
|
243
|
+
group.outputTokens += row.outputTokens;
|
|
244
|
+
group.cacheReadTokens += row.cacheReadTokens;
|
|
245
|
+
group.reasoningTokens += row.reasoningTokens;
|
|
246
|
+
group.cost += row.cost;
|
|
247
|
+
groups.set(value, group);
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
rows: [],
|
|
251
|
+
groups: [...groups.values()].sort((a, b) => a.value.localeCompare(b.value)),
|
|
252
|
+
groupBy: input.options.groupBy,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
const costs = rows.map((row) => row.cost);
|
|
256
|
+
return {
|
|
257
|
+
rows,
|
|
258
|
+
totals: {
|
|
259
|
+
episodes: rows.length,
|
|
260
|
+
cost: costs.reduce((sum, value) => sum + value, 0),
|
|
261
|
+
tokens: rows.reduce((sum, row) => sum + row.inputTokens + row.outputTokens, 0),
|
|
262
|
+
p50EpisodeCost: percentile(costs, 0.5),
|
|
263
|
+
p95EpisodeCost: percentile(costs, 0.95),
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const escapeCell = (value: unknown): string => String(value).replaceAll("|", "\\|").replaceAll("\n", " ");
|
|
269
|
+
const cost = (value: number): string => value.toFixed(6).replace(/\.?0+$/, "") || "0";
|
|
270
|
+
function duration(ms: number): string {
|
|
271
|
+
const seconds = Math.round(ms / 1000);
|
|
272
|
+
const minutes = Math.floor(seconds / 60);
|
|
273
|
+
return minutes ? `${minutes}m ${seconds % 60}s` : `${seconds}s`;
|
|
274
|
+
}
|
|
275
|
+
function table(headers: string[], rows: unknown[][]): string {
|
|
276
|
+
return [
|
|
277
|
+
`| ${headers.join(" | ")} |`,
|
|
278
|
+
`| ${headers.map(() => "---").join(" | ")} |`,
|
|
279
|
+
...rows.map((row) => `| ${row.map(escapeCell).join(" | ")} |`),
|
|
280
|
+
].join("\n");
|
|
281
|
+
}
|
|
282
|
+
export function renderEpisodeReport(report: EpisodeReport, json: boolean): string {
|
|
283
|
+
if (json) return `${JSON.stringify(report, null, 2)}\n`;
|
|
284
|
+
if (report.groups && report.groupBy) {
|
|
285
|
+
const headers = [
|
|
286
|
+
report.groupBy,
|
|
287
|
+
"count",
|
|
288
|
+
"inputTokens",
|
|
289
|
+
"outputTokens",
|
|
290
|
+
"cacheReadTokens",
|
|
291
|
+
"reasoningTokens",
|
|
292
|
+
"cost",
|
|
293
|
+
];
|
|
294
|
+
return `${table(
|
|
295
|
+
headers,
|
|
296
|
+
report.groups.map((group) => [
|
|
297
|
+
group.value,
|
|
298
|
+
group.count,
|
|
299
|
+
group.inputTokens,
|
|
300
|
+
group.outputTokens,
|
|
301
|
+
group.cacheReadTokens,
|
|
302
|
+
group.reasoningTokens,
|
|
303
|
+
cost(group.cost),
|
|
304
|
+
]),
|
|
305
|
+
)}\n`;
|
|
306
|
+
}
|
|
307
|
+
const headers = [
|
|
308
|
+
"episode",
|
|
309
|
+
"started",
|
|
310
|
+
"definition",
|
|
311
|
+
"definitionHash",
|
|
312
|
+
"resolvedModel",
|
|
313
|
+
"modelSource",
|
|
314
|
+
"thinkingLevel",
|
|
315
|
+
"thinkingSource",
|
|
316
|
+
"inputTokens",
|
|
317
|
+
"outputTokens",
|
|
318
|
+
"cacheReadTokens",
|
|
319
|
+
"reasoningTokens",
|
|
320
|
+
"cost",
|
|
321
|
+
"compactions",
|
|
322
|
+
"turns",
|
|
323
|
+
"duration",
|
|
324
|
+
"children",
|
|
325
|
+
"commit",
|
|
326
|
+
"outcome",
|
|
327
|
+
];
|
|
328
|
+
const rows = report.rows.map((row) => [
|
|
329
|
+
row.episode,
|
|
330
|
+
row.started,
|
|
331
|
+
row.definition,
|
|
332
|
+
row.definitionHash,
|
|
333
|
+
row.resolvedModel,
|
|
334
|
+
row.modelSource,
|
|
335
|
+
row.thinkingLevel,
|
|
336
|
+
row.thinkingSource,
|
|
337
|
+
row.inputTokens,
|
|
338
|
+
row.outputTokens,
|
|
339
|
+
row.cacheReadTokens,
|
|
340
|
+
row.reasoningTokens,
|
|
341
|
+
cost(row.cost),
|
|
342
|
+
row.compactions,
|
|
343
|
+
row.turns,
|
|
344
|
+
duration(row.durationMs),
|
|
345
|
+
row.children,
|
|
346
|
+
row.commit,
|
|
347
|
+
row.outcome,
|
|
348
|
+
]);
|
|
349
|
+
const totals = report.totals!;
|
|
350
|
+
const totalsTable = table(
|
|
351
|
+
["episodes", "cost", "tokens", "p50 episode cost", "p95 episode cost"],
|
|
352
|
+
[[totals.episodes, cost(totals.cost), totals.tokens, cost(totals.p50EpisodeCost), cost(totals.p95EpisodeCost)]],
|
|
353
|
+
);
|
|
354
|
+
return `${table(headers, rows)}\n\n${totalsTable}\n`;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export function commitsFromGitLog(log: string): Map<string, string> {
|
|
358
|
+
const commits = new Map<string, string>();
|
|
359
|
+
for (const entry of log.split("\u001e")) {
|
|
360
|
+
const [sha, ...body] = entry.trim().split("\n");
|
|
361
|
+
if (!/^[a-f0-9]{8,40}$/i.test(sha ?? "")) continue;
|
|
362
|
+
for (const match of body.join("\n").matchAll(/^Pi-Episode:\s*(\S+)\s*$/gim)) {
|
|
363
|
+
const episode = match[1].startsWith("episode:") ? match[1] : `episode:${match[1]}`;
|
|
364
|
+
if (!commits.has(episode)) commits.set(episode, sha.slice(0, 8));
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return commits;
|
|
368
|
+
}
|
|
369
|
+
export async function commitsForRepo(cwd: string): Promise<Map<string, string>> {
|
|
370
|
+
const { stdout } = await execFileAsync("git", ["log", "--format=%H%n%B%x1e"], { cwd, maxBuffer: 16 * 1024 * 1024 });
|
|
371
|
+
return commitsFromGitLog(stdout);
|
|
372
|
+
}
|