pi-gauntlet 5.7.0 → 5.8.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 +5 -0
- package/README.md +4 -3
- package/extensions/lib/gauntlet-settings.test.ts +79 -4
- package/extensions/lib/gauntlet-settings.ts +69 -0
- package/extensions/lib/telemetry-collect.test.ts +50 -0
- package/extensions/lib/telemetry-collect.ts +59 -0
- package/extensions/lib/telemetry-paths.test.ts +98 -0
- package/extensions/lib/telemetry-paths.ts +145 -0
- package/extensions/lib/telemetry-record.test.ts +173 -0
- package/extensions/lib/telemetry-record.ts +181 -0
- package/extensions/lib/telemetry-ship.test.ts +23 -0
- package/extensions/lib/telemetry-ship.ts +17 -0
- package/extensions/telemetry.test.ts +686 -0
- package/extensions/telemetry.ts +780 -0
- package/extensions/verify-before-ship.ts +6 -16
- package/package.json +6 -2
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { STMT_START } from "./phase-tracker-helpers.ts";
|
|
4
|
+
import {
|
|
5
|
+
capEvents, derive, diffPhases, emptyAccumulators, emptyPhases, foldAccumulators, implementAutoCompletes,
|
|
6
|
+
newRecord, parseRecord, serializeRecord, type Accumulators, type TelemetryEvent,
|
|
7
|
+
} from "./telemetry-record.ts";
|
|
8
|
+
import { matchShipStatement } from "./telemetry-paths.ts";
|
|
9
|
+
|
|
10
|
+
const ev = (over: Partial<TelemetryEvent> & { kind: string }): TelemetryEvent =>
|
|
11
|
+
({ ts: "2026-09-17T10:00:00Z", session: "s1", phase: "unphased", ...over }) as TelemetryEvent;
|
|
12
|
+
|
|
13
|
+
test("diffPhases: start/complete/skip emit one transition per changed phase; status/substep/grant emit none; reset emits reset", () => {
|
|
14
|
+
const a = emptyPhases();
|
|
15
|
+
const b = { ...a, brainstorm: { status: "in_progress" as const } };
|
|
16
|
+
assert.deepEqual(diffPhases(a, b, "start"), [{ action: "start", name: "brainstorm" }]);
|
|
17
|
+
const c = { ...b, brainstorm: { status: "complete" as const } };
|
|
18
|
+
assert.deepEqual(diffPhases(b, c, "complete"), [{ action: "complete", name: "brainstorm" }]);
|
|
19
|
+
assert.deepEqual(diffPhases(b, { ...b, brainstorm: { status: "in_progress", substep: "gather" } }, "substep"), []);
|
|
20
|
+
assert.deepEqual(diffPhases(b, b, "status"), []);
|
|
21
|
+
assert.deepEqual(diffPhases(b, b, "grant_fix_rounds"), []);
|
|
22
|
+
assert.deepEqual(diffPhases(c, a, "reset"), [{ action: "reset" }]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("implementAutoCompletes only when implement is in progress and every task is complete|skipped", () => {
|
|
26
|
+
const p = { ...emptyPhases(), implement: { status: "in_progress" as const } };
|
|
27
|
+
assert.equal(implementAutoCompletes(p, [{ status: "complete" }, { status: "skipped" }]), true);
|
|
28
|
+
assert.equal(implementAutoCompletes(p, [{ status: "complete" }, { status: "failed" }]), false);
|
|
29
|
+
assert.equal(implementAutoCompletes(p, []), false);
|
|
30
|
+
assert.equal(implementAutoCompletes(emptyPhases(), [{ status: "complete" }]), false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("foldAccumulators sums counts/tokens, maxes peak_context, takes last_sha256 from the later block, and is associative", () => {
|
|
34
|
+
const a: Accumulators = { ...emptyAccumulators(), amendments: 1, phases: { plan: { tokens: { input: 10, output: 1, cache_read: 0, cache_write: 0, cost: 0.5 }, peak_context: 100, user_messages: 2 } }, spec_writes: { brainstorm: { count: 2, last_sha256: "aa" } }, personas: { implementer: { dispatches: 1, models: ["m1"] } } };
|
|
35
|
+
const b: Accumulators = { ...emptyAccumulators(), amendments: 2, phases: { plan: { tokens: { input: 5, output: 1, cache_read: 2, cache_write: 0, cost: 0.25 }, peak_context: 50, compactions: 1 } }, spec_writes: { brainstorm: { count: 1, last_sha256: "bb" } }, personas: { implementer: { dispatches: 2, models: ["m1", "m2"] } } };
|
|
36
|
+
const c: Accumulators = { ...emptyAccumulators(), gates: { spec_rounds: 1, plan_rounds: 0, fix_round_grants: 0, task_reopens: 0 } };
|
|
37
|
+
const ab = foldAccumulators(a, b);
|
|
38
|
+
assert.equal(ab.amendments, 3);
|
|
39
|
+
assert.deepEqual(ab.phases.plan, { tokens: { input: 15, output: 2, cache_read: 2, cache_write: 0, cost: 0.75 }, peak_context: 100, user_messages: 2, compactions: 1 });
|
|
40
|
+
assert.deepEqual(ab.spec_writes.brainstorm, { count: 3, last_sha256: "bb" });
|
|
41
|
+
assert.deepEqual(ab.personas.implementer, { dispatches: 3, models: ["m1", "m2"] });
|
|
42
|
+
assert.deepEqual(foldAccumulators(ab, c), foldAccumulators(a, foldAccumulators(b, c)));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("capEvents drops oldest non-lifecycle events first and reports the drop count", () => {
|
|
46
|
+
const events: TelemetryEvent[] = [ev({ kind: "phase", action: "start", name: "brainstorm" } as any)];
|
|
47
|
+
for (let i = 0; i < 305; i++) events.push(ev({ kind: "dispatch", agent: `a${i}` } as any));
|
|
48
|
+
events.push(ev({ kind: "ship", option: "squash" } as any));
|
|
49
|
+
const { events: kept, dropped } = capEvents(events, 300);
|
|
50
|
+
assert.equal(kept.length, 300);
|
|
51
|
+
assert.equal(dropped, 7);
|
|
52
|
+
assert.equal(kept[0].kind, "phase");
|
|
53
|
+
assert.equal((kept[1] as any).agent, "a7");
|
|
54
|
+
assert.equal(kept.at(-1)!.kind, "ship");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("capEvents never drops lifecycle events and leaves derive unchanged when lifecycle events exceed the cap", () => {
|
|
58
|
+
const rec = newRecord({ spec: "doc/specs/a.md", session: "s1", now: "2026-09-17T10:00:00Z", runId: "r1" });
|
|
59
|
+
rec.events = [
|
|
60
|
+
...Array.from({ length: 305 }, (_, i) => ev({
|
|
61
|
+
kind: "phase",
|
|
62
|
+
action: i % 2 === 0 ? "start" : "complete",
|
|
63
|
+
name: "brainstorm",
|
|
64
|
+
ts: new Date(Date.parse("2026-09-17T10:00:00Z") + i * 1000).toISOString(),
|
|
65
|
+
} as any)),
|
|
66
|
+
...Array.from({ length: 10 }, (_, i) => ev({ kind: "dispatch", agent: `a${i}` } as any)),
|
|
67
|
+
];
|
|
68
|
+
const before = JSON.stringify(derive(rec, "2026-09-17T12:00:00Z"));
|
|
69
|
+
const capped = capEvents(rec.events, 300);
|
|
70
|
+
rec.events = capped.events;
|
|
71
|
+
assert.equal(capped.events.length, 305);
|
|
72
|
+
assert.ok(capped.events.every((event) => event.kind === "phase"));
|
|
73
|
+
assert.equal(capped.dropped, 10);
|
|
74
|
+
assert.equal(JSON.stringify(derive(rec, "2026-09-17T12:00:00Z")), before);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("derive: phase timing from events, counts from accumulators, ship_option from the live ship event; unchanged by the cap", () => {
|
|
78
|
+
const rec = newRecord({ spec: "doc/specs/a.md", session: "s1", now: "2026-09-17T10:00:00Z", runId: "r1" });
|
|
79
|
+
rec.events.push(
|
|
80
|
+
ev({ kind: "phase", action: "start", name: "brainstorm", model: "p/m", thinking: "high", ts: "2026-09-17T10:00:00Z" } as any),
|
|
81
|
+
ev({ kind: "phase", action: "complete", name: "brainstorm", phase: "brainstorm", ts: "2026-09-17T10:10:00Z" } as any),
|
|
82
|
+
ev({ kind: "phase", action: "start", name: "ship", ts: "2026-09-17T11:00:00Z" } as any),
|
|
83
|
+
ev({ kind: "ship", option: "pr", phase: "ship", ts: "2026-09-17T11:05:00Z" } as any),
|
|
84
|
+
ev({ kind: "ship_failed", phase: "ship", ts: "2026-09-17T11:06:00Z" } as any),
|
|
85
|
+
ev({ kind: "ship", option: "squash", phase: "ship", ts: "2026-09-17T11:07:00Z" } as any),
|
|
86
|
+
);
|
|
87
|
+
rec.accumulators.s1 = { ...emptyAccumulators(), amendments: 1, events_dropped: 3, phases: { brainstorm: { tokens: { input: 1, output: 2, cache_read: 0, cache_write: 0, cost: 0.1 }, compactions: 1 }, unphased: { tokens: { input: 9, output: 0, cache_read: 0, cache_write: 0, cost: 0 } } }, gates: { spec_rounds: 2, plan_rounds: 1, fix_round_grants: 0, task_reopens: 0 } };
|
|
88
|
+
rec.approved_at = "2026-09-17T10:10:00Z";
|
|
89
|
+
rec.shipped_at = "2026-09-17T11:07:00Z";
|
|
90
|
+
const d = derive(rec, "2026-09-17T12:00:00Z");
|
|
91
|
+
assert.equal(d.duration_s, 67 * 60);
|
|
92
|
+
assert.deepEqual(d.phases.brainstorm, { started_at: "2026-09-17T10:00:00Z", completed_at: "2026-09-17T10:10:00Z", duration_s: 600, model: "p/m", thinking: "high", tokens: { input: 1, output: 2, cache_read: 0, cache_write: 0, cost: 0.1 }, compactions: 1 });
|
|
93
|
+
assert.deepEqual(d.phases.unphased, { tokens: { input: 9, output: 0, cache_read: 0, cache_write: 0, cost: 0 } });
|
|
94
|
+
assert.equal(d.phases.ship?.started_at, "2026-09-17T11:00:00Z");
|
|
95
|
+
assert.equal(d.phases.ship?.completed_at, undefined);
|
|
96
|
+
assert.deepEqual(d.gates, { spec_rounds: 2, plan_rounds: 1, fix_round_grants: 0, task_reopens: 0, ship_option: "squash" });
|
|
97
|
+
assert.equal(d.amendments, 1);
|
|
98
|
+
assert.equal(d.spec_edits_after_ship, 0);
|
|
99
|
+
assert.equal(d.events_dropped, 3);
|
|
100
|
+
assert.equal("plan" in d, false);
|
|
101
|
+
assert.equal("tests" in d, false);
|
|
102
|
+
const before = JSON.stringify(d);
|
|
103
|
+
rec.events = capEvents([...rec.events, ...Array.from({ length: 400 }, (_, i) => ev({ kind: "dispatch", agent: `x${i}`, phase: "plan" } as any))], 300).events;
|
|
104
|
+
assert.equal(JSON.stringify(derive(rec, "2026-09-17T12:00:00Z")).replace(/"personas":\{[^}]*\}/, ""), before.replace(/"personas":\{[^}]*\}/, ""));
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("serializeRecord -> parseRecord round-trips run_id, events, accumulators, and writes derived before accumulators/events", () => {
|
|
108
|
+
const rec = newRecord({ spec: "doc/specs/a.md", session: "s1", now: "2026-09-17T10:00:00Z", runId: "r1" });
|
|
109
|
+
rec.events.push(ev({ kind: "phase", action: "start", name: "brainstorm" } as any));
|
|
110
|
+
rec.accumulators.s1 = { ...emptyAccumulators(), conformance_open_gaps: 2, amendments: 2 };
|
|
111
|
+
rec.derived = derive(rec, "2026-09-17T10:01:00Z");
|
|
112
|
+
const text = serializeRecord(rec);
|
|
113
|
+
assert.ok(text.startsWith("schema: 1\nspec: doc/specs/a.md\nrun_id: r1\n"), text.slice(0, 80));
|
|
114
|
+
assert.ok(text.indexOf("derived:") < text.indexOf("accumulators:") && text.indexOf("accumulators:") < text.indexOf("events:"));
|
|
115
|
+
assert.match(text, /^ - \{ ts: .*kind: phase.*\}$/m);
|
|
116
|
+
assert.doesNotMatch(text, /null/);
|
|
117
|
+
const back = parseRecord(text)!;
|
|
118
|
+
assert.equal(back.run_id, "r1");
|
|
119
|
+
assert.deepEqual(back.events, rec.events);
|
|
120
|
+
assert.deepEqual(back.accumulators, rec.accumulators);
|
|
121
|
+
assert.equal(back.derived.conformance_open_gaps, 2);
|
|
122
|
+
assert.ok(text.indexOf("conformance_loops:") < text.indexOf("conformance_open_gaps:"));
|
|
123
|
+
assert.equal(parseRecord("not: [valid"), undefined);
|
|
124
|
+
assert.equal(parseRecord("schema: 2\nspec: x"), undefined);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("serializeRecord keeps a populated 300-event record under 450 lines with leaf objects in flow style", () => {
|
|
128
|
+
const tokens = { input: 100, output: 20, cache_read: 30, cache_write: 4, cost: 0.5 };
|
|
129
|
+
const phases = Object.fromEntries(["brainstorm", "plan", "implement", "verify", "ship"].map((phase) =>
|
|
130
|
+
[phase, { tokens, peak_context: 20_000, user_messages: 3 }]
|
|
131
|
+
));
|
|
132
|
+
const personas = Object.fromEntries(["brainstormer", "spec_reviewer", "planner", "plan_reviewer", "implementer", "reviewer", "verifier"].map((persona) =>
|
|
133
|
+
[persona, { dispatches: 2, models: ["provider/model-a", "provider/model-b"], tokens }]
|
|
134
|
+
));
|
|
135
|
+
const reviews = Object.fromEntries(["spec", "plan", "code"].map((review) =>
|
|
136
|
+
[review, { dispatches: 2, nonzero_exit: 1, findings: { blocker: 0, major: 1, minor: 2 } }]
|
|
137
|
+
));
|
|
138
|
+
const populated = (): Accumulators => ({
|
|
139
|
+
...emptyAccumulators(), phases, personas, reviews,
|
|
140
|
+
gates: { spec_rounds: 2, plan_rounds: 2, fix_round_grants: 1, task_reopens: 1 },
|
|
141
|
+
} as Accumulators);
|
|
142
|
+
const rec = newRecord({ spec: "doc/specs/populated.md", session: "s1", now: "2026-09-17T10:00:00Z", runId: "populated", branch: "main" });
|
|
143
|
+
rec.accumulators = { s1: populated(), s2: populated() };
|
|
144
|
+
rec.derived = {
|
|
145
|
+
...rec.derived, phases, personas, reviews,
|
|
146
|
+
gates: { spec_rounds: 4, plan_rounds: 4, fix_round_grants: 2, task_reopens: 2 },
|
|
147
|
+
plan: { tasks: 10, complete: 8, failed: 1, skipped: 1 }, tests: { command: "node --test", result: "pass" },
|
|
148
|
+
diff: { base: "main", commits: 3, buckets: { source: { files: 10, insertions: 100, deletions: 20 }, tests: { files: 5, insertions: 50, deletions: 5 }, docs: { files: 5, insertions: 25, deletions: 2 } } },
|
|
149
|
+
modified_files: Array.from({ length: 20 }, (_, i) => `extensions/file-${i}.ts`),
|
|
150
|
+
};
|
|
151
|
+
rec.events = Array.from({ length: 300 }, (_, i) => ev({ kind: "dispatch", agent: `agent-${i}`, phase: "implement" } as any));
|
|
152
|
+
|
|
153
|
+
const text = serializeRecord(rec);
|
|
154
|
+
assert.ok(text.split("\n").length < 450, `serialized record has ${text.split("\n").length} lines`);
|
|
155
|
+
assert.match(text, /^ implementer: \{ dispatches: /m);
|
|
156
|
+
assert.deepEqual(parseRecord(text), rec);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("parseRecord drops malformed accumulator blocks and fills missing fields", () => {
|
|
160
|
+
const rec = newRecord({ spec: "doc/specs/a.md", session: "s1", now: "2026-09-17T10:00:00Z", runId: "r1" });
|
|
161
|
+
const text = serializeRecord(rec).replace("accumulators: {}", "accumulators: { s1: null, s2: 5, s3: { amendments: 2 } }");
|
|
162
|
+
const parsed = parseRecord(text)!;
|
|
163
|
+
assert.deepEqual(Object.keys(parsed.accumulators), ["s3"]);
|
|
164
|
+
assert.equal(parsed.accumulators.s3.amendments, 2);
|
|
165
|
+
assert.deepEqual(parsed.accumulators.s3.phases, {});
|
|
166
|
+
assert.doesNotThrow(() => derive(parsed, "2026-09-17T10:01:00Z"));
|
|
167
|
+
assert.equal(derive(parsed, "2026-09-17T10:01:00Z").amendments, 2);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("STMT_START-anchored matcher is the one used for ship detection", () => {
|
|
171
|
+
assert.ok(new RegExp(STMT_START + "git\\s+push").test("cd x && git push"));
|
|
172
|
+
assert.equal(matchShipStatement('rg "git push"'), undefined);
|
|
173
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Telemetry record model (#33): phases, events, accumulators, derive, YAML.
|
|
2
|
+
import { Document, isCollection, isMap, isSeq, parse as parseYaml, stringify as stringifyYaml, type YAMLMap } from "yaml";
|
|
3
|
+
import type { BucketStat, ShipOption } from "./telemetry-paths.ts";
|
|
4
|
+
|
|
5
|
+
export const PHASES = ["brainstorm", "plan", "implement", "verify", "ship"] as const;
|
|
6
|
+
export type Phase = (typeof PHASES)[number];
|
|
7
|
+
export type PhaseKey = Phase | "unphased";
|
|
8
|
+
export type PhaseStatus = "pending" | "in_progress" | "complete" | "skipped";
|
|
9
|
+
export type PhaseMap = Record<Phase, { status: PhaseStatus; substep?: string; reason?: string }>;
|
|
10
|
+
export const emptyPhases = (): PhaseMap => Object.fromEntries(PHASES.map((p) => [p, { status: "pending" }])) as PhaseMap;
|
|
11
|
+
export const currentPhase = (phases: PhaseMap): PhaseKey => PHASES.find((p) => phases[p].status === "in_progress") ?? "unphased";
|
|
12
|
+
export type PhaseAction = "start" | "complete" | "skip" | "reset";
|
|
13
|
+
export interface PhaseTransition { action: PhaseAction; name?: Phase }
|
|
14
|
+
export function diffPhases(prev: PhaseMap, next: PhaseMap, action: string): PhaseTransition[] {
|
|
15
|
+
if (action === "reset") return [{ action: "reset" }];
|
|
16
|
+
if (action !== "start" && action !== "complete" && action !== "skip") return [];
|
|
17
|
+
return PHASES.filter((p) => prev[p].status !== next[p].status).map((name) => ({ action, name }));
|
|
18
|
+
}
|
|
19
|
+
export const implementAutoCompletes = (phases: PhaseMap, tasks: { status: string }[] | undefined): boolean =>
|
|
20
|
+
phases.implement.status === "in_progress" && Array.isArray(tasks) && tasks.length > 0 && tasks.every((t) => t.status === "complete" || t.status === "skipped");
|
|
21
|
+
|
|
22
|
+
export interface BaseEvent { ts: string; session: string; phase: PhaseKey }
|
|
23
|
+
export type TelemetryEvent = BaseEvent & (
|
|
24
|
+
| { kind: "phase"; action: PhaseAction; name?: Phase; model?: string; thinking?: string }
|
|
25
|
+
| { kind: "plan_check"; pass: boolean; spec?: string; plan?: string }
|
|
26
|
+
| { kind: "dispatch"; agent: string; model?: string; exit?: number }
|
|
27
|
+
| { kind: "gate"; gate: "fix_round_grant" | "task_reopen"; rounds?: number }
|
|
28
|
+
| { kind: "config_change"; model?: string; thinking?: string }
|
|
29
|
+
| { kind: "ship"; option: ShipOption | "keep" | "discard"; command?: string }
|
|
30
|
+
| { kind: "ship_failed" }
|
|
31
|
+
| { kind: "spec_renamed"; from: string; to: string }
|
|
32
|
+
| { kind: "warning"; message: string }
|
|
33
|
+
);
|
|
34
|
+
// Lifecycle events are never dropped because derive depends on their complete history.
|
|
35
|
+
const LIFECYCLE_KINDS = new Set(["phase", "ship", "ship_failed", "spec_renamed"]);
|
|
36
|
+
export const EVENT_CAP = 300;
|
|
37
|
+
export function capEvents(events: TelemetryEvent[], cap = EVENT_CAP): { events: TelemetryEvent[]; dropped: number } {
|
|
38
|
+
let kept = events, dropped = 0;
|
|
39
|
+
if (kept.length > cap) {
|
|
40
|
+
let excess = kept.length - cap;
|
|
41
|
+
kept = kept.filter((e) => {
|
|
42
|
+
if (excess > 0 && !LIFECYCLE_KINDS.has(e.kind)) { excess--; dropped++; return false; }
|
|
43
|
+
return true;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return { events: kept, dropped };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface Tokens { input: number; output: number; cache_read: number; cache_write: number; cost: number }
|
|
50
|
+
export interface PhaseAcc { tokens?: Tokens; compactions?: number; user_messages?: number; peak_context?: number }
|
|
51
|
+
export interface PersonaAcc { dispatches: number; models: string[]; tokens?: Tokens }
|
|
52
|
+
export interface ReviewAcc { dispatches: number; nonzero_exit: number; findings?: { blocker: number; major: number; minor: number } }
|
|
53
|
+
export interface Gates { spec_rounds: number; plan_rounds: number; fix_round_grants: number; task_reopens: number }
|
|
54
|
+
export interface Accumulators {
|
|
55
|
+
phases: Partial<Record<PhaseKey, PhaseAcc>>; personas: Record<string, PersonaAcc>; reviews: Record<string, ReviewAcc>;
|
|
56
|
+
spec_writes: Partial<Record<PhaseKey, { count: number; last_sha256: string }>>; gates: Gates;
|
|
57
|
+
conformance_loops: number; conformance_open_gaps?: number; amendments: number; spec_edits_after_ship: number; events_dropped: number;
|
|
58
|
+
}
|
|
59
|
+
export const emptyAccumulators = (): Accumulators => ({ phases: {}, personas: {}, reviews: {}, spec_writes: {}, gates: { spec_rounds: 0, plan_rounds: 0, fix_round_grants: 0, task_reopens: 0 }, conformance_loops: 0, amendments: 0, spec_edits_after_ship: 0, events_dropped: 0 });
|
|
60
|
+
export const zeroTokens = (): Tokens => ({ input: 0, output: 0, cache_read: 0, cache_write: 0, cost: 0 });
|
|
61
|
+
export const addTokens = (a: Tokens | undefined, b: Tokens): Tokens => ({ input: (a?.input ?? 0) + b.input, output: (a?.output ?? 0) + b.output, cache_read: (a?.cache_read ?? 0) + b.cache_read, cache_write: (a?.cache_write ?? 0) + b.cache_write, cost: (a?.cost ?? 0) + b.cost });
|
|
62
|
+
const optSum = (a?: number, b?: number): number | undefined => a === undefined && b === undefined ? undefined : (a ?? 0) + (b ?? 0);
|
|
63
|
+
const optMax = (a?: number, b?: number): number | undefined => a === undefined ? b : b === undefined ? a : Math.max(a, b);
|
|
64
|
+
export const compact = <T extends object>(o: T): T => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)) as T;
|
|
65
|
+
export function foldAccumulators(a: Accumulators, b: Accumulators): Accumulators {
|
|
66
|
+
const out = emptyAccumulators();
|
|
67
|
+
for (const k of new Set([...Object.keys(a.phases), ...Object.keys(b.phases)]) as Set<PhaseKey>) {
|
|
68
|
+
const x = a.phases[k] ?? {}, y = b.phases[k] ?? {};
|
|
69
|
+
out.phases[k] = compact({ tokens: x.tokens && y.tokens ? addTokens(x.tokens, y.tokens) : x.tokens ?? y.tokens, compactions: optSum(x.compactions, y.compactions), user_messages: optSum(x.user_messages, y.user_messages), peak_context: optMax(x.peak_context, y.peak_context) });
|
|
70
|
+
}
|
|
71
|
+
for (const k of new Set([...Object.keys(a.personas), ...Object.keys(b.personas)])) {
|
|
72
|
+
const x = a.personas[k], y = b.personas[k];
|
|
73
|
+
out.personas[k] = compact({ dispatches: (x?.dispatches ?? 0) + (y?.dispatches ?? 0), models: [...new Set([...(x?.models ?? []), ...(y?.models ?? [])])], tokens: x?.tokens && y?.tokens ? addTokens(x.tokens, y.tokens) : x?.tokens ?? y?.tokens });
|
|
74
|
+
}
|
|
75
|
+
for (const k of new Set([...Object.keys(a.reviews), ...Object.keys(b.reviews)])) {
|
|
76
|
+
const x = a.reviews[k], y = b.reviews[k];
|
|
77
|
+
const findings = x?.findings || y?.findings ? { blocker: (x?.findings?.blocker ?? 0) + (y?.findings?.blocker ?? 0), major: (x?.findings?.major ?? 0) + (y?.findings?.major ?? 0), minor: (x?.findings?.minor ?? 0) + (y?.findings?.minor ?? 0) } : undefined;
|
|
78
|
+
out.reviews[k] = compact({ dispatches: (x?.dispatches ?? 0) + (y?.dispatches ?? 0), nonzero_exit: (x?.nonzero_exit ?? 0) + (y?.nonzero_exit ?? 0), findings });
|
|
79
|
+
}
|
|
80
|
+
for (const k of new Set([...Object.keys(a.spec_writes), ...Object.keys(b.spec_writes)]) as Set<PhaseKey>) {
|
|
81
|
+
const x = a.spec_writes[k], y = b.spec_writes[k]; out.spec_writes[k] = { count: (x?.count ?? 0) + (y?.count ?? 0), last_sha256: y?.last_sha256 ?? x?.last_sha256 ?? "" };
|
|
82
|
+
}
|
|
83
|
+
for (const g of Object.keys(out.gates) as (keyof Gates)[]) out.gates[g] = a.gates[g] + b.gates[g];
|
|
84
|
+
out.conformance_loops = a.conformance_loops + b.conformance_loops; out.conformance_open_gaps = b.conformance_open_gaps ?? a.conformance_open_gaps; out.amendments = a.amendments + b.amendments;
|
|
85
|
+
out.spec_edits_after_ship = a.spec_edits_after_ship + b.spec_edits_after_ship; out.events_dropped = a.events_dropped + b.events_dropped;
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface DiffSummary { base: string; commits: number; buckets: Record<string, BucketStat> }
|
|
90
|
+
export interface Derived {
|
|
91
|
+
duration_s: number; phases: Partial<Record<PhaseKey, PhaseAcc & { started_at?: string; completed_at?: string; duration_s?: number; model?: string; thinking?: string }>>;
|
|
92
|
+
personas: Record<string, PersonaAcc>; reviews?: Record<string, ReviewAcc>; conformance_loops: number; conformance_open_gaps?: number; gates: Gates & { ship_option?: string };
|
|
93
|
+
plan?: { tasks: number; complete: number; failed: number; skipped: number }; tests?: { command: string; result: "pass" | "fail" }; amendments: number;
|
|
94
|
+
spec_edits_after_ship: number; diff?: DiffSummary; modified_files?: string[]; spec_writes: Accumulators["spec_writes"]; events_dropped: number;
|
|
95
|
+
}
|
|
96
|
+
export type RecordStatus = "in_progress" | "shipped" | "abandoned";
|
|
97
|
+
export interface TelemetryRecord {
|
|
98
|
+
schema: 1; spec: string; run_id: string; branch?: string; status: RecordStatus; created_at: string; approved_at?: string; shipped_at?: string; abandoned_at?: string;
|
|
99
|
+
supersedes?: string[]; fixes?: string[]; versions?: Record<string, string>; author?: { name?: string; email?: string }; agent_overrides?: Record<string, unknown>;
|
|
100
|
+
sessions: string[]; derived: Derived; accumulators: Record<string, Accumulators>; events: TelemetryEvent[];
|
|
101
|
+
}
|
|
102
|
+
export function newRecord(o: { spec: string; session: string; now: string; runId: string; branch?: string }): TelemetryRecord {
|
|
103
|
+
return { schema: 1, spec: o.spec, run_id: o.runId, branch: o.branch, status: "in_progress", created_at: o.now, sessions: [o.session], derived: { duration_s: 0, phases: {}, personas: {}, conformance_loops: 0, gates: { spec_rounds: 0, plan_rounds: 0, fix_round_grants: 0, task_reopens: 0 }, amendments: 0, spec_edits_after_ship: 0, spec_writes: {}, events_dropped: 0 }, accumulators: {}, events: [] };
|
|
104
|
+
}
|
|
105
|
+
export const totalAccumulators = (rec: TelemetryRecord): Accumulators => Object.values(rec.accumulators).reduce<Accumulators>((acc, block) => foldAccumulators(acc, block), emptyAccumulators());
|
|
106
|
+
const seconds = (a: string, b: string): number => Math.max(0, Math.round((Date.parse(b) - Date.parse(a)) / 1000));
|
|
107
|
+
export function liveShipEvent(events: TelemetryEvent[]): (TelemetryEvent & { kind: "ship" }) | undefined {
|
|
108
|
+
let live: (TelemetryEvent & { kind: "ship" }) | undefined;
|
|
109
|
+
for (const e of events) { if (e.kind === "ship") live = e; else if (e.kind === "ship_failed") live = undefined; }
|
|
110
|
+
return live;
|
|
111
|
+
}
|
|
112
|
+
export function derive(rec: TelemetryRecord, now: string): Derived {
|
|
113
|
+
const acc = totalAccumulators(rec), phases: Derived["phases"] = {};
|
|
114
|
+
for (const e of rec.events) {
|
|
115
|
+
if (e.kind !== "phase") continue;
|
|
116
|
+
if (e.action === "reset") { for (const p of PHASES) if (phases[p]) phases[p] = compact({ ...phases[p], started_at: undefined, completed_at: undefined, duration_s: undefined, model: undefined, thinking: undefined }); continue; }
|
|
117
|
+
if (!e.name) continue;
|
|
118
|
+
const cur = phases[e.name] ?? {};
|
|
119
|
+
if (e.action === "start") phases[e.name] = compact({ ...cur, started_at: e.ts, completed_at: undefined, duration_s: undefined, model: e.model, thinking: e.thinking });
|
|
120
|
+
else if (cur.started_at) phases[e.name] = { ...cur, completed_at: e.ts, duration_s: seconds(cur.started_at, e.ts) };
|
|
121
|
+
}
|
|
122
|
+
for (const k of Object.keys(acc.phases) as PhaseKey[]) phases[k] = compact({ ...(phases[k] ?? {}), ...acc.phases[k] });
|
|
123
|
+
const live = liveShipEvent(rec.events);
|
|
124
|
+
return compact({ duration_s: seconds(rec.created_at, rec.shipped_at ?? rec.abandoned_at ?? now), phases, personas: acc.personas, reviews: Object.keys(acc.reviews).length ? acc.reviews : undefined, conformance_loops: acc.conformance_loops, conformance_open_gaps: acc.conformance_open_gaps, gates: compact({ ...acc.gates, ship_option: live?.option }), plan: rec.derived.plan, tests: rec.derived.tests, amendments: acc.amendments, spec_edits_after_ship: acc.spec_edits_after_ship, diff: rec.derived.diff, modified_files: rec.derived.modified_files, spec_writes: acc.spec_writes, events_dropped: acc.events_dropped }) as Derived;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const stripUndefined = (v: unknown): unknown => {
|
|
128
|
+
if (Array.isArray(v)) return v.map(stripUndefined);
|
|
129
|
+
if (v && typeof v === "object") return Object.fromEntries(Object.entries(v as object).filter(([, x]) => x !== undefined).map(([k, x]) => [k, stripUndefined(x)]));
|
|
130
|
+
return v;
|
|
131
|
+
};
|
|
132
|
+
const flowLeafChildren = (map: YAMLMap, blockLists: Set<string> = new Set()): void => {
|
|
133
|
+
for (const pair of map.items) {
|
|
134
|
+
const key = String(pair.key);
|
|
135
|
+
const child = pair.value;
|
|
136
|
+
if (!isCollection(child) || blockLists.has(key)) continue;
|
|
137
|
+
if (isMap(child) && child.items.length > 0 && child.items.every((item) => isMap(item.value))) {
|
|
138
|
+
for (const item of child.items) if (isCollection(item.value)) item.value.flow = true;
|
|
139
|
+
} else {
|
|
140
|
+
child.flow = true;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
const flowModelLists = (node: unknown): void => {
|
|
145
|
+
if (isMap(node)) {
|
|
146
|
+
for (const pair of node.items) {
|
|
147
|
+
if (String(pair.key) === "models" && isSeq(pair.value)) pair.value.flow = true;
|
|
148
|
+
flowModelLists(pair.value);
|
|
149
|
+
}
|
|
150
|
+
} else if (isSeq(node)) {
|
|
151
|
+
for (const item of node.items) flowModelLists(item);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
export function serializeRecord(rec: TelemetryRecord): string {
|
|
155
|
+
const { derived, accumulators, events, ...head } = rec;
|
|
156
|
+
const body = stripUndefined({ ...head, derived, accumulators }) as Record<string, unknown>;
|
|
157
|
+
const doc = new Document(body);
|
|
158
|
+
const derivedNode = doc.get("derived", true);
|
|
159
|
+
if (isMap(derivedNode)) flowLeafChildren(derivedNode, new Set(["modified_files"]));
|
|
160
|
+
const accumulatorNode = doc.get("accumulators", true);
|
|
161
|
+
if (isMap(accumulatorNode)) {
|
|
162
|
+
for (const session of accumulatorNode.items) if (isMap(session.value)) flowLeafChildren(session.value);
|
|
163
|
+
}
|
|
164
|
+
flowModelLists(doc.contents);
|
|
165
|
+
const bodyText = doc.toString({ lineWidth: 0 });
|
|
166
|
+
const eventLines = (events as unknown as Record<string, unknown>[]).map((e) => " - " + stringifyYaml(stripUndefined(e), { collectionStyle: "flow", lineWidth: 0 }).trim());
|
|
167
|
+
return bodyText + "events:\n" + (eventLines.length ? eventLines.join("\n") + "\n" : "");
|
|
168
|
+
}
|
|
169
|
+
export function parseRecord(text: string): TelemetryRecord | undefined {
|
|
170
|
+
let doc: unknown;
|
|
171
|
+
try { doc = parseYaml(text); } catch { return undefined; }
|
|
172
|
+
if (!doc || typeof doc !== "object") return undefined;
|
|
173
|
+
const d = doc as Partial<TelemetryRecord>;
|
|
174
|
+
if (d.schema !== 1 || typeof d.spec !== "string" || typeof d.run_id !== "string") return undefined;
|
|
175
|
+
const accumulators = Object.fromEntries(Object.entries(d.accumulators ?? {}).flatMap(([session, block]) =>
|
|
176
|
+
block && typeof block === "object" && !Array.isArray(block)
|
|
177
|
+
? [[session, { ...emptyAccumulators(), ...block }]]
|
|
178
|
+
: []
|
|
179
|
+
)) as Record<string, Accumulators>;
|
|
180
|
+
return { ...(d as TelemetryRecord), sessions: Array.isArray(d.sessions) ? d.sessions : [], derived: d.derived ?? newRecord({ spec: d.spec, session: "", now: d.created_at ?? "", runId: d.run_id }).derived, accumulators, events: Array.isArray(d.events) ? d.events : [] };
|
|
181
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { BASE_REFS, guardReason, modifiedFilesFrom } from "./telemetry-ship.ts";
|
|
4
|
+
|
|
5
|
+
test("guardReason names spec, timestamp and record path", () => {
|
|
6
|
+
assert.equal(
|
|
7
|
+
guardReason("doc/specs/a.md", "2026-09-17T12:00:00Z", ".pi/gauntlet/telemetry/doc/specs/a.yaml"),
|
|
8
|
+
"spec doc/specs/a.md shipped at 2026-09-17T12:00:00Z (record .pi/gauntlet/telemetry/doc/specs/a.yaml). Write a new date-slugged spec that supersedes it instead of reusing this file.",
|
|
9
|
+
);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("modifiedFilesFrom drops the spec, doc/plans/**, and the telemetry dir; sorts", () => {
|
|
13
|
+
const names = "extensions/telemetry.ts\nextensions/telemetry.test.ts\ndoc/specs/a.md\ndoc/plans/a.md\nsvc/doc/plans/b.md\n.pi/gauntlet/telemetry/doc/specs/a.yaml\nREADME.md\n\n";
|
|
14
|
+
assert.deepEqual(modifiedFilesFrom(names, "doc/specs/a.md", ".pi/gauntlet/telemetry"), ["README.md", "extensions/telemetry.test.ts", "extensions/telemetry.ts"]);
|
|
15
|
+
assert.deepEqual(modifiedFilesFrom("", "doc/specs/a.md", "t/"), []);
|
|
16
|
+
// Filenames with leading or trailing whitespace must be kept verbatim
|
|
17
|
+
const namesWithWhitespace = " lead.ts\ntrail.ts \nREADME.md\n";
|
|
18
|
+
assert.deepEqual(modifiedFilesFrom(namesWithWhitespace, "doc/specs/a.md", "t/"), [" lead.ts", "README.md", "trail.ts "]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("BASE_REFS order is origin/HEAD, main, master", () => {
|
|
22
|
+
assert.deepEqual(BASE_REFS, ["origin/HEAD", "main", "master"]);
|
|
23
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Pure ship-time helpers for the telemetry extension (#33): diff file filter,
|
|
2
|
+
// base-ref candidates, guard block reason.
|
|
3
|
+
|
|
4
|
+
export const BASE_REFS = ["origin/HEAD", "main", "master"] as const;
|
|
5
|
+
|
|
6
|
+
export const guardReason = (spec: string, shippedAt: string, recordRel: string): string =>
|
|
7
|
+
`spec ${spec} shipped at ${shippedAt} (record ${recordRel}). Write a new date-slugged spec that supersedes it instead of reusing this file.`;
|
|
8
|
+
|
|
9
|
+
// derived.modified_files: `git diff --name-only <base>...HEAD` output minus the bound
|
|
10
|
+
// spec, doc/plans/**, and <dir>/**; sorted repo-relative paths (renames -> new path).
|
|
11
|
+
export function modifiedFilesFrom(nameOnly: string, spec: string, dir: string): string[] {
|
|
12
|
+
const dirPrefix = dir.replace(/\/+$/, "") + "/";
|
|
13
|
+
return nameOnly
|
|
14
|
+
.split("\n")
|
|
15
|
+
.filter((f) => f && f !== spec && !/(^|\/)doc\/plans\//.test(f) && !f.startsWith(dirPrefix))
|
|
16
|
+
.sort();
|
|
17
|
+
}
|