pi-gauntlet 8.3.0 → 8.4.1
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 +8 -0
- package/README.md +4 -4
- package/bin/gauntlet-performance.mjs +477 -64
- package/bin/gauntlet-performance.test.mjs +27 -27
- package/bin/gauntlet-spec-index.mjs +290 -47
- package/bin/gauntlet-spec-index.test.mjs +42 -18
- package/bin/gauntlet-telemetry-seal.mjs +220 -77
- package/bin/gauntlet-telemetry-seal.test.mjs +222 -457
- package/extensions/lib/gauntlet-settings.test.ts +12 -14
- package/extensions/lib/gauntlet-settings.ts +7 -16
- package/extensions/lib/telemetry-branch.test.ts +102 -0
- package/extensions/lib/telemetry-branch.ts +145 -0
- package/extensions/lib/telemetry-diff.test.ts +2 -2
- package/extensions/lib/telemetry-diff.ts +1 -1
- package/extensions/lib/telemetry-leg.ts +163 -0
- package/extensions/lib/telemetry-record.ts +2 -0
- package/extensions/lib/vcs.ts +6 -23
- package/extensions/phase-tracker.test.ts +6 -3
- package/extensions/phase-tracker.ts +2 -0
- package/extensions/plan-tracker.test.ts +4 -2
- package/extensions/plan-tracker.ts +1 -0
- package/extensions/telemetry-leg-failures.test.ts +138 -0
- package/extensions/telemetry-leg-fields.test.ts +130 -0
- package/extensions/telemetry-leg-no-write.test.ts +90 -0
- package/extensions/telemetry-leg-rounds.test.ts +157 -0
- package/extensions/telemetry-leg-trackers.test.ts +114 -0
- package/extensions/telemetry-leg.test.ts +151 -0
- package/extensions/telemetry-settlement-contract.test.ts +67 -0
- package/extensions/telemetry.test.ts +44 -27
- package/extensions/telemetry.ts +68 -48
- package/extensions/test-support/git-env.mjs +13 -0
- package/package.json +1 -1
- package/skills/brainstorming/SKILL.md +1 -1
- package/skills/check-delivery/SKILL.md +2 -0
- package/skills/finishing-a-development-branch/SKILL.md +13 -13
- package/skills/finishing-a-development-branch/reference/verification-set.md +2 -2
- package/skills/gatekeep-pr/reference/batch.md +3 -1
- package/skills/gatekeep-pr/reference/cleanup.md +1 -1
- package/skills/gatekeep-pr/reference/post-selection-loop.md +7 -1
- package/skills/gauntlet-resume/reference/seed.md +1 -6
- package/skills/shape-ticket/SKILL.md +1 -1
- package/skills/shape-ticket/reference/repo-audit.md +2 -2
|
@@ -933,6 +933,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
933
933
|
|
|
934
934
|
pi.registerTool({
|
|
935
935
|
name: "plan_check",
|
|
936
|
+
exposure: "model-only",
|
|
936
937
|
label: "Plan Check",
|
|
937
938
|
description:
|
|
938
939
|
"Deterministically verify an implementation plan against its spec (mechanical checks); " +
|
|
@@ -1088,6 +1089,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1088
1089
|
|
|
1089
1090
|
pi.registerTool({
|
|
1090
1091
|
name: "phase_tracker",
|
|
1092
|
+
exposure: "model-only",
|
|
1091
1093
|
label: "Phase Tracker",
|
|
1092
1094
|
description:
|
|
1093
1095
|
"Track workflow phase progress (brainstorm → plan → implement → verify → ship); " +
|
|
@@ -15,6 +15,7 @@ function harness(branch: unknown[] = [], cwd = process.cwd()) {
|
|
|
15
15
|
const tools: {
|
|
16
16
|
name: string;
|
|
17
17
|
description: string;
|
|
18
|
+
exposure?: string;
|
|
18
19
|
executionMode?: string;
|
|
19
20
|
parameters: any;
|
|
20
21
|
execute: (...args: any[]) => unknown;
|
|
@@ -752,8 +753,9 @@ test("refresh rejects unreadable or malformed ledgers atomically, including abse
|
|
|
752
753
|
}
|
|
753
754
|
});
|
|
754
755
|
|
|
755
|
-
test("registration: sequential, tasks schema is Array<Union<String, Object>>, description carries the contract", () => {
|
|
756
|
-
const { executionMode, parameters, description } = harness();
|
|
756
|
+
test("registration: model-only, sequential, tasks schema is Array<Union<String, Object>>, description carries the contract", () => {
|
|
757
|
+
const { tool, executionMode, parameters, description } = harness();
|
|
758
|
+
assert.equal(tool().exposure, "model-only");
|
|
757
759
|
assert.equal(executionMode(), "sequential");
|
|
758
760
|
const tasksSchema = parameters().args[0].tasks.args[0];
|
|
759
761
|
assert.equal(tasksSchema.kind, "Array");
|
|
@@ -237,6 +237,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
237
237
|
|
|
238
238
|
pi.registerTool({
|
|
239
239
|
name: "plan_tracker",
|
|
240
|
+
exposure: "model-only",
|
|
240
241
|
label: "Plan Tracker",
|
|
241
242
|
description:
|
|
242
243
|
"Track progress while EXECUTING an implementation plan (the implement phase), a verify-phase conformance fix wave, or another bounded gate checklist (e.g. pre-merge PR verification). Statuses: pending (not yet touched), in_progress (started; several at once is fine), complete, failed (terminal-negative: ran and did not pass; never counted done), skipped (terminal: not applicable in this run; counted done). Rule: pending tasks must trail every started or finished task - an update that would leave a pending task ahead of a non-pending one is rejected with the fix. update never sets pending; init and add do. Actions: init (set task list; elements are task-name strings or { name, status } to recreate a list with known statuses), add (append tasks as pending; existing statuses preserved), update (change task status), status (show current state), clear (remove plan), refresh (derive tasks from an artifact path while retaining other tasks). Do NOT use for brainstorming, research, or planning checklists: those phases are open-ended and a bounded task list misrepresents them as a fixed N-step process.",
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { chmodSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { after, test } from "node:test";
|
|
7
|
+
import { parse } from "yaml";
|
|
8
|
+
import type { Deps } from "./telemetry.ts";
|
|
9
|
+
import { isolatedGitEnv } from "./test-support/git-env.mjs";
|
|
10
|
+
import { resolveTelemetry } from "./lib/gauntlet-settings.ts";
|
|
11
|
+
|
|
12
|
+
const gitEnv = isolatedGitEnv(mkdtempSync(join(tmpdir(), "telemetry-leg-failures-env-")));
|
|
13
|
+
const originalGitEnv = Object.fromEntries(Object.keys(gitEnv).map(key => [key, process.env[key]]));
|
|
14
|
+
Object.assign(process.env, gitEnv);
|
|
15
|
+
// vcs.ts snapshots the environment at import time, including publisher subprocess settings.
|
|
16
|
+
const { default: registerTelemetry, realDeps } = await import("./telemetry.ts");
|
|
17
|
+
const { syncTelemetryBranch, TELEMETRY_REF } = await import("./lib/telemetry-branch.ts");
|
|
18
|
+
|
|
19
|
+
const originalDepth = process.env.PI_SUBAGENT_DEPTH;
|
|
20
|
+
process.env.PI_SUBAGENT_DEPTH = "0";
|
|
21
|
+
after(() => {
|
|
22
|
+
for (const [key, value] of Object.entries(originalGitEnv)) {
|
|
23
|
+
if (value === undefined) delete process.env[key];
|
|
24
|
+
else process.env[key] = value;
|
|
25
|
+
}
|
|
26
|
+
if (originalDepth === undefined) delete process.env.PI_SUBAGENT_DEPTH;
|
|
27
|
+
else process.env.PI_SUBAGENT_DEPTH = originalDepth;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function session(root: string, deps: Deps) {
|
|
31
|
+
const handlers = new Map<string, ((event: any, ctx: any) => unknown)[]>();
|
|
32
|
+
const transcript: any[] = [];
|
|
33
|
+
registerTelemetry({
|
|
34
|
+
on: (name: string, handler: (event: any, ctx: any) => unknown) => handlers.set(name, [...(handlers.get(name) ?? []), handler]),
|
|
35
|
+
sendMessage: (message: unknown) => transcript.push(message),
|
|
36
|
+
} as any, deps);
|
|
37
|
+
const ctx = { cwd: root, sessionManager: { getBranch: () => [], getSessionId: () => "failure-session" } };
|
|
38
|
+
const emit = async (name: string, event: unknown = {}) => {
|
|
39
|
+
for (const handler of handlers.get(name) ?? []) await handler(event, ctx);
|
|
40
|
+
};
|
|
41
|
+
const say = async (text: string) => {
|
|
42
|
+
const message = { role: "assistant", content: [{ type: "text", text }], usage: { input: 12, output: 4, cost: 0.125 } };
|
|
43
|
+
transcript.push(message);
|
|
44
|
+
await emit("message_end", { message });
|
|
45
|
+
return message;
|
|
46
|
+
};
|
|
47
|
+
return { emit, say, transcript };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const settings = (enabled: boolean) => ({
|
|
51
|
+
telemetry: resolveTelemetry({ telemetry: { enabled } }), specDirs: [], planDirs: [], errors: [], versions: { "pi-gauntlet": "8.2.1" },
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
for (const [skill, marker, row] of [
|
|
55
|
+
["shape-ticket", "done", "#42: written"],
|
|
56
|
+
["gatekeep-pr", "stuck", "#9@abc123: stuck - checks failed"],
|
|
57
|
+
["check-delivery", "done", "#42: declined"],
|
|
58
|
+
]) {
|
|
59
|
+
test(`${skill} disabled performs no file writes, fetches or pushes`, async () => {
|
|
60
|
+
const calls: unknown[] = [];
|
|
61
|
+
const forbidden = (...args: unknown[]): never => {
|
|
62
|
+
calls.push(args);
|
|
63
|
+
throw new Error("disabled telemetry attempted I/O");
|
|
64
|
+
};
|
|
65
|
+
const h = session(tmpdir(), {
|
|
66
|
+
fs: { readFile: forbidden, exists: forbidden, writeFile: forbidden, rename: forbidden, mkdirp: forbidden, unlink: forbidden },
|
|
67
|
+
git: forbidden, jj: forbidden, telemetryRepo: forbidden, readTelemetryBranch: forbidden, syncTelemetryBranch: forbidden,
|
|
68
|
+
now: () => "2026-11-06T12:34:56Z", settings: () => settings(false),
|
|
69
|
+
});
|
|
70
|
+
await h.emit("session_start");
|
|
71
|
+
await h.emit("input", { source: "interactive", text: `/skill:${skill}` });
|
|
72
|
+
await h.emit("before_agent_start", { messageType: "user", prompt: `<skill name="${skill}" location="/package/skills/${skill}/SKILL.md">` });
|
|
73
|
+
await h.emit("tool_call", { type: "tool_call", toolCallId: "load-skill", toolName: "read", input: { path: `/package/skills/${skill}/SKILL.md` } });
|
|
74
|
+
const message = await h.say(`gauntlet: ${marker} settled\n${row}`);
|
|
75
|
+
await h.emit("agent_settled", { aborted: false });
|
|
76
|
+
await h.emit("session_shutdown");
|
|
77
|
+
assert.deepEqual(calls, []);
|
|
78
|
+
assert.deepEqual(h.transcript, [message]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test(`${skill} rejected push preserves settlement and local record with one retry`, async () => {
|
|
82
|
+
const scratch = mkdtempSync(join(tmpdir(), "telemetry-leg-failure-"));
|
|
83
|
+
const root = join(scratch, "product");
|
|
84
|
+
const origin = join(scratch, "origin.git");
|
|
85
|
+
mkdirSync(root);
|
|
86
|
+
const git = (cwd: string, ...args: string[]) => execFileSync("git", ["-C", cwd, "-c", "commit.gpgsign=false", ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
87
|
+
git(scratch, "init", "--bare", origin);
|
|
88
|
+
git(root, "init", "-b", "feature/example");
|
|
89
|
+
git(root, "config", "user.name", "Leg Tester");
|
|
90
|
+
git(root, "config", "user.email", "leg@example.test");
|
|
91
|
+
git(root, "remote", "add", "origin", "https://example.test/team/product.git");
|
|
92
|
+
git(root, "config", `url.${origin}.insteadOf`, "https://example.test/team/product.git");
|
|
93
|
+
writeFileSync(join(root, "file.txt"), "base\n");
|
|
94
|
+
git(root, "add", "file.txt");
|
|
95
|
+
git(root, "commit", "-m", "initial");
|
|
96
|
+
const head = git(root, "rev-parse", "HEAD");
|
|
97
|
+
const index = readFileSync(join(root, ".git/index"));
|
|
98
|
+
assert.equal(syncTelemetryBranch(root, new Map([["team/product/seed.yaml", "seed: true\n"]])).pushed, true);
|
|
99
|
+
const remoteBefore = git(origin, "rev-parse", TELEMETRY_REF);
|
|
100
|
+
const pushLog = join(scratch, "pushes");
|
|
101
|
+
const fetchLog = join(scratch, "fetches");
|
|
102
|
+
const hook = join(origin, "hooks/pre-receive");
|
|
103
|
+
writeFileSync(hook, `#!/bin/sh\necho push >> '${pushLog}'\necho 'rejected by fixture' >&2\nexit 1\n`);
|
|
104
|
+
chmodSync(hook, 0o755);
|
|
105
|
+
const upload = join(scratch, "upload-pack");
|
|
106
|
+
writeFileSync(upload, `#!/bin/sh\necho fetch >> '${fetchLog}'\nexec git-upload-pack "$@"\n`);
|
|
107
|
+
chmodSync(upload, 0o755);
|
|
108
|
+
git(root, "config", "remote.origin.uploadpack", `'${upload}'`);
|
|
109
|
+
const h = session(root, { ...realDeps, now: () => "2026-11-06T12:34:56Z", settings: () => settings(true) });
|
|
110
|
+
await h.emit("session_start");
|
|
111
|
+
await h.emit("input", { source: "interactive", text: `/skill:${skill}` });
|
|
112
|
+
const text = `gauntlet: ${marker} settled\n${row}`;
|
|
113
|
+
const message = await h.say(text);
|
|
114
|
+
await h.emit("agent_settled", { aborted: false });
|
|
115
|
+
await h.emit("agent_settled", { aborted: false });
|
|
116
|
+
await h.emit("session_shutdown");
|
|
117
|
+
assert.equal(readFileSync(pushLog, "utf8"), "push\npush\n", "initial push and exactly one retry");
|
|
118
|
+
assert.equal(readFileSync(fetchLog, "utf8"), "fetch\nfetch\n", "initial fetch and exactly one replay refresh");
|
|
119
|
+
assert.equal(h.transcript.length, 2);
|
|
120
|
+
assert.deepEqual(h.transcript[0], message);
|
|
121
|
+
assert.equal(message.content[0].text, text, "telemetry must not rewrite the menu outcome");
|
|
122
|
+
assert.equal(h.transcript[1].customType, "telemetry-warning");
|
|
123
|
+
assert.equal(h.transcript[1].display, true);
|
|
124
|
+
assert.match(h.transcript[1].content, /^telemetry: not pushed \(remote: rejected by fixture\s*\)$/);
|
|
125
|
+
const path = `team/product/${skill}/20261106T123456Z-failure-session.yaml`;
|
|
126
|
+
const bytes = git(root, "show", `${TELEMETRY_REF}:${path}`);
|
|
127
|
+
const record = parse(bytes);
|
|
128
|
+
assert.equal(record.cost, 0.125);
|
|
129
|
+
assert.equal(record.items[0].outcome, row.slice(row.indexOf(": ") + 2));
|
|
130
|
+
assert.equal(git(origin, "rev-parse", TELEMETRY_REF), remoteBefore);
|
|
131
|
+
assert.equal(git(root, "rev-parse", "HEAD"), head);
|
|
132
|
+
assert.deepEqual(readFileSync(join(root, ".git/index")), index);
|
|
133
|
+
assert.equal(readFileSync(join(root, "file.txt"), "utf8"), "base\n");
|
|
134
|
+
writeFileSync(hook, "#!/bin/sh\nexit 0\n");
|
|
135
|
+
assert.equal(syncTelemetryBranch(root, new Map()).pushed, true, "next push recovers the retained record without a new settlement");
|
|
136
|
+
assert.equal(git(origin, "show", `${TELEMETRY_REF}:${path}`), bytes);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { after, test } from "node:test";
|
|
6
|
+
import { parse } from "yaml";
|
|
7
|
+
import registerTelemetry, { realFs, type Deps } from "./telemetry.ts";
|
|
8
|
+
import { resolveTelemetry } from "./lib/gauntlet-settings.ts";
|
|
9
|
+
import { settlementItems } from "./lib/telemetry-leg.ts";
|
|
10
|
+
|
|
11
|
+
const originalDepth = process.env.PI_SUBAGENT_DEPTH;
|
|
12
|
+
process.env.PI_SUBAGENT_DEPTH = "0";
|
|
13
|
+
after(() => {
|
|
14
|
+
if (originalDepth === undefined) delete process.env.PI_SUBAGENT_DEPTH;
|
|
15
|
+
else process.env.PI_SUBAGENT_DEPTH = originalDepth;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
function harness() {
|
|
19
|
+
const root = mkdtempSync(join(tmpdir(), "telemetry-fields-"));
|
|
20
|
+
const handlers = new Map<string, ((event: any, ctx: any) => unknown)[]>();
|
|
21
|
+
const writes: { path: string; record: any }[] = [];
|
|
22
|
+
const deps: Deps = {
|
|
23
|
+
fs: realFs,
|
|
24
|
+
now: () => "2026-11-06T12:34:56Z",
|
|
25
|
+
settings: () => ({ telemetry: resolveTelemetry({}), specDirs: [], planDirs: [], errors: [], versions: { "pi-gauntlet": "8.2.1" } }),
|
|
26
|
+
git: async (args) => ({ code: 0, stderr: "", stdout: args[0] === "config"
|
|
27
|
+
? args[1] === "user.name" ? "Observed Author\n" : "author@example.test\n"
|
|
28
|
+
: `${root}\n${root}/.git\n${root}/.git\n` }),
|
|
29
|
+
telemetryRepo: () => "team/product",
|
|
30
|
+
syncTelemetryBranch: (_root, records) => {
|
|
31
|
+
for (const [path, bytes] of records) writes.push({ path, record: parse(bytes) });
|
|
32
|
+
return { ok: true, committed: true, pushed: true };
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
const ctx = { cwd: root, model: { provider: "test", id: "initial" }, sessionManager: { getBranch: () => [], getSessionId: () => "observed-session" } };
|
|
36
|
+
registerTelemetry({
|
|
37
|
+
on: (name: string, handler: (event: any, ctx: any) => unknown) => handlers.set(name, [...(handlers.get(name) ?? []), handler]),
|
|
38
|
+
sendMessage: () => {},
|
|
39
|
+
} as any, deps);
|
|
40
|
+
const emit = async (name: string, event: unknown = {}) => {
|
|
41
|
+
for (const handler of handlers.get(name) ?? []) await handler(event, ctx);
|
|
42
|
+
};
|
|
43
|
+
const say = (text: string, usage?: unknown) => emit("message_end", { message: {
|
|
44
|
+
role: "assistant", provider: "test", model: "response", content: [{ type: "text", text }], usage,
|
|
45
|
+
} });
|
|
46
|
+
return { emit, say, writes };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for (const [skill, line, item] of [
|
|
50
|
+
["shape-ticket", "#42: written", { kind: "ticket", key: "#42", outcome: "written" }],
|
|
51
|
+
["gatekeep-pr", "#42@aBc123: approved (#17)", { kind: "pr", key: "#42@aBc123", ticket: "#17", outcome: "approved" }],
|
|
52
|
+
["check-delivery", "ENG-17: already recorded", { kind: "ticket", key: "ENG-17", outcome: "already recorded" }],
|
|
53
|
+
] as const) {
|
|
54
|
+
test(`${skill} record fields use observations, never assistant claims`, async () => {
|
|
55
|
+
const h = harness();
|
|
56
|
+
await h.emit("session_start");
|
|
57
|
+
await h.say("Not part of this leg", { input: 999, cost: 999 });
|
|
58
|
+
await h.emit("input", { source: "interactive", text: `/skill:${skill}` });
|
|
59
|
+
await h.say("Assessing", { input: 10, output: 2, cacheRead: 30, cacheWrite: 4, cost: { total: 0.25 } });
|
|
60
|
+
await h.emit("model_select", { model: { provider: "test", id: "selected" } });
|
|
61
|
+
await h.say(`gauntlet: done settled\n${line}\nworktree: done\n#### #55\nsession: forged\nauthor: forged\ngauntlet_version: forged\nmodels: forged\nrepo: forged\nts: forged\ntokens: 999\ncost: 999`,
|
|
62
|
+
{ input: 20, output: 3, cacheRead: 40, cacheWrite: 5, cost: 0.5 });
|
|
63
|
+
await h.emit("agent_settled", { aborted: false });
|
|
64
|
+
assert.equal(h.writes.length, 1);
|
|
65
|
+
assert.equal(h.writes[0].path, `team/product/${skill}/20261106T123456Z-observed-session.yaml`);
|
|
66
|
+
assert.deepEqual(h.writes[0].record, {
|
|
67
|
+
skill, session: "observed-session", author: { name: "Observed Author", email: "author@example.test" },
|
|
68
|
+
gauntlet_version: "8.2.1", models: ["test/initial", "test/response", "test/selected"],
|
|
69
|
+
tokens: { input: 30, output: 5, cache_read: 70, cache_write: 9 }, cost: 0.75,
|
|
70
|
+
repo: "team/product", ts: "2026-11-06T12:34:56Z", items: [item],
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
test("shape-ticket includes council member usage once through the run dispatch reducer", async () => {
|
|
76
|
+
const h = harness();
|
|
77
|
+
await h.emit("session_start");
|
|
78
|
+
await h.emit("input", { source: "interactive", text: "/skill:shape-ticket" });
|
|
79
|
+
await h.say("Roasting", { input: 5, output: 2, cost: { total: 0.25 } });
|
|
80
|
+
await h.emit("tool_result", {
|
|
81
|
+
toolName: "subagent", isError: false, content: [], details: { results: [
|
|
82
|
+
{ agent: "spec-council-member", model: "test/member-one", exitCode: 0, usage: { input: 10, output: 3, cacheRead: 7, cacheWrite: 1, cost: { total: 0.5 } } },
|
|
83
|
+
{ agent: "spec-council-member", model: "test/member-two", exitCode: 1, usage: { input: 20, output: 4, cacheRead: 8, cacheWrite: 2, cost: 0.75 } },
|
|
84
|
+
] },
|
|
85
|
+
});
|
|
86
|
+
await h.say("gauntlet: done written\n#42: written", { input: 6, output: 1, cost: 0.125 });
|
|
87
|
+
await h.emit("agent_settled", { aborted: false });
|
|
88
|
+
assert.equal(h.writes.length, 1);
|
|
89
|
+
assert.deepEqual(h.writes[0].record.tokens, { input: 41, output: 10, cache_read: 15, cache_write: 3 });
|
|
90
|
+
assert.equal(h.writes[0].record.cost, 1.625);
|
|
91
|
+
assert.deepEqual(h.writes[0].record.models, ["test/initial", "test/response", "test/member-one", "test/member-two"]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
for (const [skill, lines, keys] of [
|
|
95
|
+
["shape-ticket", ["#11: written", "#12: declined", "#13: no-op"], ["#11", "#12", "#13"]],
|
|
96
|
+
["gatekeep-pr", ["#11@abcd: approved", "#12@dcba: stopped"], ["#11@abcd", "#12@dcba"]],
|
|
97
|
+
["check-delivery", ["ENG-11: delivered", "ENG-12: declined"], ["ENG-11", "ENG-12"]],
|
|
98
|
+
] as const) {
|
|
99
|
+
test(`${skill} batches all settled refs into one cost-conserving record`, async () => {
|
|
100
|
+
const h = harness();
|
|
101
|
+
await h.emit("session_start");
|
|
102
|
+
await h.emit("input", { source: "interactive", text: `/skill:${skill}` });
|
|
103
|
+
await h.say("Assessing the set", { input: 5, cost: 0.25 });
|
|
104
|
+
await h.emit("tool_result", {
|
|
105
|
+
toolName: "subagent", isError: false, content: [], details: { results: [
|
|
106
|
+
{ agent: "reviewer", model: "test/child", exitCode: 0, usage: { output: 7, cost: { total: 0.5 } } },
|
|
107
|
+
] },
|
|
108
|
+
});
|
|
109
|
+
await h.say(`gauntlet: done settled\n${lines.join("\n")}`, { input: 3, output: 2, cost: 0.125 });
|
|
110
|
+
await h.emit("agent_settled", { aborted: false });
|
|
111
|
+
await h.emit("agent_settled", { aborted: false });
|
|
112
|
+
assert.equal(h.writes.length, 1);
|
|
113
|
+
assert.deepEqual(h.writes[0].record.items.map((item: any) => item.key), keys);
|
|
114
|
+
assert.deepEqual(h.writes[0].record.tokens, { input: 8, output: 9, cache_read: 0, cache_write: 0 });
|
|
115
|
+
assert.equal(h.writes[0].record.cost, 0.875);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
test("settlement items preserve identity, ticket annotations and complete outcome tokens", () => {
|
|
120
|
+
for (const [skill, line, expected] of [
|
|
121
|
+
["shape-ticket", "team/product#42: close failed (comment: permission denied)", { kind: "ticket", key: "team/product#42", outcome: "close failed (comment: permission denied)" }],
|
|
122
|
+
["shape-ticket", "Create: a useful title: dropped", { kind: "ticket", outcome: "dropped" }],
|
|
123
|
+
["shape-ticket", "https://example.test/team/product/issues/42: close by hand", { kind: "ticket", key: "https://example.test/team/product/issues/42", outcome: "close by hand" }],
|
|
124
|
+
["gatekeep-pr", "#42@abcd: stuck - check: failed (ENG-17)", { kind: "pr", key: "#42@abcd", ticket: "ENG-17", outcome: "stuck - check: failed" }],
|
|
125
|
+
["gatekeep-pr", "#43@dcba: merged", { kind: "pr", key: "#43@dcba", outcome: "merged" }],
|
|
126
|
+
["check-delivery", "https://linear.app/team/issue/ENG-17/title: failed - deploy: missing", { kind: "ticket", key: "https://linear.app/team/issue/ENG-17/title", outcome: "failed - deploy: missing" }],
|
|
127
|
+
] as const) {
|
|
128
|
+
assert.deepEqual(settlementItems(skill, `gauntlet: done settled\n${line}\nworktree: done\n#### #90\nSome prose: written`), [expected]);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { after, test } from "node:test";
|
|
6
|
+
import { parse } from "yaml";
|
|
7
|
+
import registerTelemetry, { realFs, type Deps } from "./telemetry.ts";
|
|
8
|
+
import { resolveTelemetry } from "./lib/gauntlet-settings.ts";
|
|
9
|
+
|
|
10
|
+
const originalDepth = process.env.PI_SUBAGENT_DEPTH;
|
|
11
|
+
process.env.PI_SUBAGENT_DEPTH = "0";
|
|
12
|
+
after(() => {
|
|
13
|
+
if (originalDepth === undefined) delete process.env.PI_SUBAGENT_DEPTH;
|
|
14
|
+
else process.env.PI_SUBAGENT_DEPTH = originalDepth;
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
for (const [skill, row, expected] of [
|
|
18
|
+
["shape-ticket", "#42: declined", { kind: "ticket", key: "#42", outcome: "declined" }],
|
|
19
|
+
["shape-ticket", "#42: no-op", { kind: "ticket", key: "#42", outcome: "no-op" }],
|
|
20
|
+
["gatekeep-pr", "#42@abc123: stopped", { kind: "pr", key: "#42@abc123", outcome: "stopped" }],
|
|
21
|
+
["check-delivery", "#42: declined", { kind: "ticket", key: "#42", outcome: "declined" }],
|
|
22
|
+
["shape-ticket", "Uncreated ticket: declined", { kind: "ticket", outcome: "declined" }],
|
|
23
|
+
["shape-ticket", "Uncreated ticket: dropped", { kind: "ticket", outcome: "dropped" }],
|
|
24
|
+
] as const) {
|
|
25
|
+
test(`${skill} ${row} records without tracker or checkout writes`, async () => {
|
|
26
|
+
const root = mkdtempSync(join(tmpdir(), "telemetry-no-write-"));
|
|
27
|
+
const trackerPath = join(root, "tracker.json");
|
|
28
|
+
writeFileSync(trackerPath, JSON.stringify({
|
|
29
|
+
ticket: { body: "Ticket body\n", state: "open", labels: ["bug"], reviews: [] },
|
|
30
|
+
pr: { body: "PR body\n", state: "open", labels: ["review"], reviews: [{ state: "COMMENTED", body: "Keep this review\n" }] },
|
|
31
|
+
}, null, 2));
|
|
32
|
+
const before = readFileSync(trackerPath);
|
|
33
|
+
const records = new Map<string, string>();
|
|
34
|
+
const writes: unknown[] = [];
|
|
35
|
+
const warnings: unknown[] = [];
|
|
36
|
+
const handlers = new Map<string, ((event: any, ctx: any) => unknown)[]>();
|
|
37
|
+
const forbidden = (...args: unknown[]): never => {
|
|
38
|
+
writes.push(args);
|
|
39
|
+
throw new Error("no-write settlement attempted a non-telemetry write");
|
|
40
|
+
};
|
|
41
|
+
let pushes = 0;
|
|
42
|
+
const deps: Deps = {
|
|
43
|
+
fs: { ...realFs, writeFileSync: forbidden, renameSync: forbidden, mkdirSync: forbidden, unlinkSync: forbidden },
|
|
44
|
+
now: () => "2026-11-06T12:34:56.123Z",
|
|
45
|
+
settings: () => ({ telemetry: resolveTelemetry({}), specDirs: [], planDirs: [], errors: [], versions: { "pi-gauntlet": "8.2.1" } }),
|
|
46
|
+
git: async (args) => {
|
|
47
|
+
if (args[0] === "rev-parse") return { code: 0, stdout: `${root}\n${root}/.git\n${root}/.git\n`, stderr: "" };
|
|
48
|
+
if (args[0] === "config" && args.length === 2 && ["user.name", "user.email"].includes(args[1])) {
|
|
49
|
+
return { code: 0, stdout: "Tester\n", stderr: "" };
|
|
50
|
+
}
|
|
51
|
+
return forbidden(args);
|
|
52
|
+
},
|
|
53
|
+
telemetryRepo: () => "team/product",
|
|
54
|
+
syncTelemetryBranch: (_root, incoming) => {
|
|
55
|
+
pushes++;
|
|
56
|
+
for (const [path, bytes] of incoming) records.set(path, bytes);
|
|
57
|
+
return { ok: true, committed: true, pushed: true };
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
registerTelemetry({
|
|
61
|
+
on: (name: string, handler: (event: any, ctx: any) => unknown) => handlers.set(name, [...(handlers.get(name) ?? []), handler]),
|
|
62
|
+
exec: forbidden,
|
|
63
|
+
sendMessage: (message: unknown) => warnings.push(message),
|
|
64
|
+
} as any, deps);
|
|
65
|
+
const ctx = { cwd: root, sessionManager: { getBranch: () => [], getSessionId: () => "no-write-session" } };
|
|
66
|
+
const emit = async (name: string, event: unknown = {}) => {
|
|
67
|
+
for (const handler of handlers.get(name) ?? []) await handler(event, ctx);
|
|
68
|
+
};
|
|
69
|
+
await emit("session_start");
|
|
70
|
+
await emit("input", { source: "interactive", text: `/skill:${skill}` });
|
|
71
|
+
await emit("message_end", { message: {
|
|
72
|
+
role: "assistant", content: [{ type: "text", text: `gauntlet: done settled\n${row}` }],
|
|
73
|
+
usage: { input: 12, output: 4, cost: 0.125 },
|
|
74
|
+
} });
|
|
75
|
+
assert.equal(records.size, 0, "only settlement may publish the record");
|
|
76
|
+
await emit("agent_settled", { aborted: false });
|
|
77
|
+
await emit("agent_settled", { aborted: false });
|
|
78
|
+
assert.equal(pushes, 1);
|
|
79
|
+
assert.equal(records.size, 1);
|
|
80
|
+
const path = `team/product/${skill}/20261106T123456.123Z-no-write-session.yaml`;
|
|
81
|
+
const record = parse(records.get(path)!);
|
|
82
|
+
assert.deepEqual(record.items, [expected]);
|
|
83
|
+
assert.equal(record.cost, 0.125);
|
|
84
|
+
assert.equal(record.tokens.input, 12);
|
|
85
|
+
assert.equal(record.tokens.output, 4);
|
|
86
|
+
assert.deepEqual(writes, [], "no tracker command or checkout write may accompany telemetry");
|
|
87
|
+
assert.deepEqual(warnings, []);
|
|
88
|
+
assert.deepEqual(readFileSync(trackerPath), before, "ticket and PR body, state, labels and reviews remain byte-identical");
|
|
89
|
+
});
|
|
90
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { after, test } from "node:test";
|
|
7
|
+
import { parse, stringify } from "yaml";
|
|
8
|
+
import type { Deps } from "./telemetry.ts";
|
|
9
|
+
import { isolatedGitEnv } from "./test-support/git-env.mjs";
|
|
10
|
+
import { resolveTelemetry } from "./lib/gauntlet-settings.ts";
|
|
11
|
+
|
|
12
|
+
const gitEnv = isolatedGitEnv(mkdtempSync(join(tmpdir(), "telemetry-leg-rounds-env-")));
|
|
13
|
+
const originalGitEnv = Object.fromEntries(Object.keys(gitEnv).map(key => [key, process.env[key]]));
|
|
14
|
+
Object.assign(process.env, gitEnv);
|
|
15
|
+
// vcs.ts snapshots the environment at import time, including publisher subprocess settings.
|
|
16
|
+
const { default: registerTelemetry, realFs } = await import("./telemetry.ts");
|
|
17
|
+
|
|
18
|
+
const originalDepth = process.env.PI_SUBAGENT_DEPTH;
|
|
19
|
+
process.env.PI_SUBAGENT_DEPTH = "0";
|
|
20
|
+
after(() => {
|
|
21
|
+
for (const [key, value] of Object.entries(originalGitEnv)) {
|
|
22
|
+
if (value === undefined) delete process.env[key];
|
|
23
|
+
else process.env[key] = value;
|
|
24
|
+
}
|
|
25
|
+
if (originalDepth === undefined) delete process.env.PI_SUBAGENT_DEPTH;
|
|
26
|
+
else process.env.PI_SUBAGENT_DEPTH = originalDepth;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("a plain follow-up can load another skill without losing provisional usage", async () => {
|
|
30
|
+
const root = mkdtempSync(join(tmpdir(), "telemetry-switch-"));
|
|
31
|
+
const records = new Map<string, string>();
|
|
32
|
+
const handlers = new Map<string, ((event: any, ctx: any) => unknown)[]>();
|
|
33
|
+
let round = 0;
|
|
34
|
+
registerTelemetry({
|
|
35
|
+
on: (name: string, handler: (event: any, ctx: any) => unknown) => handlers.set(name, [...(handlers.get(name) ?? []), handler]),
|
|
36
|
+
sendMessage: () => {},
|
|
37
|
+
} as any, {
|
|
38
|
+
fs: realFs, now: () => `2026-11-06T12:34:5${round}Z`,
|
|
39
|
+
settings: () => ({ telemetry: resolveTelemetry({}), specDirs: [], planDirs: [], errors: [], versions: {} }),
|
|
40
|
+
git: async (args) => ({ code: 0, stderr: "", stdout: args[0] === "config" ? "Tester\n" : `${root}\n${root}/.git\n${root}/.git\n` }),
|
|
41
|
+
telemetryRepo: () => "team/product",
|
|
42
|
+
syncTelemetryBranch: (_root, incoming) => {
|
|
43
|
+
for (const [path, bytes] of incoming) records.set(path, bytes);
|
|
44
|
+
return { ok: true, committed: true, pushed: true };
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
const ctx = { cwd: root, sessionManager: { getBranch: () => [], getSessionId: () => "same-session" } };
|
|
48
|
+
const emit = async (name: string, event: unknown = {}) => {
|
|
49
|
+
for (const handler of handlers.get(name) ?? []) await handler(event, ctx);
|
|
50
|
+
};
|
|
51
|
+
const say = (text: string, input: number, cost: number) => emit("message_end", {
|
|
52
|
+
message: { role: "assistant", content: [{ type: "text", text }], usage: { input, cost } },
|
|
53
|
+
});
|
|
54
|
+
await emit("session_start");
|
|
55
|
+
await emit("input", { source: "interactive", text: "/skill:shape-ticket" });
|
|
56
|
+
await say("gauntlet: done settled\n#42: written", 1, 0.1);
|
|
57
|
+
await emit("agent_settled", { aborted: false });
|
|
58
|
+
round++;
|
|
59
|
+
await emit("before_agent_start", { messageType: "user", prompt: "Review PR 7" });
|
|
60
|
+
await say("I'll load the PR review skill.", 2, 0.25);
|
|
61
|
+
await emit("tool_call", { type: "tool_call", toolCallId: "load-skill", toolName: "read", input: { path: "skills/gatekeep-pr/SKILL.md" } });
|
|
62
|
+
await say("gauntlet: done settled\n#7@abc123: approved (#42)", 3, 0.5);
|
|
63
|
+
await emit("agent_settled", { aborted: false });
|
|
64
|
+
const rows = [...records.values()].map(bytes => parse(bytes));
|
|
65
|
+
assert.deepEqual(rows.map(row => row.skill), ["shape-ticket", "gatekeep-pr"]);
|
|
66
|
+
assert.deepEqual(rows[1].items, [{ kind: "pr", key: "#7@abc123", ticket: "#42", outcome: "approved" }]);
|
|
67
|
+
assert.equal(rows[1].tokens.input, 5);
|
|
68
|
+
assert.equal(rows[1].cost, 0.75);
|
|
69
|
+
assert.match([...records.keys()][1], /\/gatekeep-pr\//);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
for (const [skill, section, lines] of [
|
|
73
|
+
["shape-ticket", "tickets", ["#42: written", "team/product#42: no-op", "https://example.test/team/product/issues/42: written"]],
|
|
74
|
+
["gatekeep-pr", "prs", ["#42@abc123: approved", "https://example.test/team/product/pull/42@def456: merged", "#42@fed321: approved"]],
|
|
75
|
+
] as const) {
|
|
76
|
+
test(`${skill} repeats append distinct records and digest chronological rounds`, async () => {
|
|
77
|
+
const root = mkdtempSync(join(tmpdir(), "telemetry-rounds-"));
|
|
78
|
+
const records = new Map<string, string>();
|
|
79
|
+
const handlers = new Map<string, ((event: any, ctx: any) => unknown)[]>();
|
|
80
|
+
let round = 0;
|
|
81
|
+
const timestamps = ["2026-11-06T12:34:56.001Z", "2026-11-06T12:34:56.002Z", "2026-11-06T12:34:56.002Z"];
|
|
82
|
+
const deps: Deps = {
|
|
83
|
+
fs: realFs, now: () => timestamps[round],
|
|
84
|
+
settings: () => ({ telemetry: resolveTelemetry({}), specDirs: [], planDirs: [], errors: [], versions: { "pi-gauntlet": "8.2.1" } }),
|
|
85
|
+
git: async (args) => ({ code: 0, stderr: "", stdout: args[0] === "config" ? "Tester\n" : `${root}\n${root}/.git\n${root}/.git\n` }),
|
|
86
|
+
telemetryRepo: () => "team/product",
|
|
87
|
+
syncTelemetryBranch: (_root, incoming) => {
|
|
88
|
+
for (const [path, bytes] of incoming) records.set(path, bytes);
|
|
89
|
+
return { ok: true, committed: true, pushed: true };
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
registerTelemetry({
|
|
93
|
+
on: (name: string, handler: (event: any, ctx: any) => unknown) => handlers.set(name, [...(handlers.get(name) ?? []), handler]),
|
|
94
|
+
sendMessage: () => {},
|
|
95
|
+
} as any, deps);
|
|
96
|
+
const ctx = { cwd: root, sessionManager: { getBranch: () => [], getSessionId: () => round < 2 ? "same-session" : "third-session" } };
|
|
97
|
+
const emit = async (name: string, event: unknown = {}) => {
|
|
98
|
+
for (const handler of handlers.get(name) ?? []) await handler(event, ctx);
|
|
99
|
+
};
|
|
100
|
+
await emit("session_start");
|
|
101
|
+
for (round = 0; round < lines.length; round++) {
|
|
102
|
+
if (round === 2) {
|
|
103
|
+
await emit("before_agent_start", { messageType: "user", prompt: "What is the weather?" });
|
|
104
|
+
await emit("message_end", { message: { role: "assistant", content: [{ type: "text", text: "It is sunny." }] } });
|
|
105
|
+
await emit("agent_settled", { aborted: false });
|
|
106
|
+
assert.equal(records.size, 2, "an unrelated follow-up without settlement writes nothing");
|
|
107
|
+
await emit("session_switch");
|
|
108
|
+
await emit("before_agent_start", { messageType: "user", prompt: "Check that again" });
|
|
109
|
+
await emit("message_end", { message: { role: "assistant", content: [{ type: "text", text: `gauntlet: done settled\n${lines[round]}` }] } });
|
|
110
|
+
await emit("agent_settled", { aborted: false });
|
|
111
|
+
assert.equal(records.size, 2, "session navigation clears the remembered skill");
|
|
112
|
+
}
|
|
113
|
+
if (round === 1) {
|
|
114
|
+
await emit("input", { source: "interactive", text: "Check that again" });
|
|
115
|
+
await emit("before_agent_start", { messageType: "user", prompt: "Check that again" });
|
|
116
|
+
} else {
|
|
117
|
+
await emit("input", { source: "interactive", text: `/skill:${skill}` });
|
|
118
|
+
}
|
|
119
|
+
await emit("message_end", { message: { role: "assistant", content: [{ type: "text", text: `gauntlet: done settled\n${lines[round]}` }], usage: { input: round + 1, cost: 0.25 } } });
|
|
120
|
+
await emit("agent_settled", { aborted: false });
|
|
121
|
+
assert.equal(records.size, round + 1, "repeating in one second must not overwrite an earlier leg");
|
|
122
|
+
}
|
|
123
|
+
assert.deepEqual([...records.values()].map(bytes => parse(bytes).tokens.input), [1, 2, 3], "each leg starts with fresh counters");
|
|
124
|
+
const write = (path: string, bytes: string) => {
|
|
125
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
126
|
+
writeFileSync(path, bytes);
|
|
127
|
+
};
|
|
128
|
+
// Reverse file order and repeat the corpus to exercise chronological sorting and identity deduplication.
|
|
129
|
+
for (const [i, bytes] of [...records.values()].reverse().entries()) write(join(root, `round-${i}.yaml`), bytes);
|
|
130
|
+
const first = parse([...records.values()][0]);
|
|
131
|
+
write(join(root, "z-duplicate.yaml"), stringify(first));
|
|
132
|
+
const other = { ...first, repo: "other/product", session: "other-session", items: [{ ...first.items[0], key: "#42" }] };
|
|
133
|
+
write(join(root, "a-other-repo.yaml"), stringify(other));
|
|
134
|
+
write(join(root, "bad-leg.yaml"), stringify({ skill, ts: timestamps[0] }));
|
|
135
|
+
const cli = new URL("../src/bins/gauntlet-performance.mjs", import.meta.url).pathname;
|
|
136
|
+
const invoke = (args: string[], entrypoint = cli) => {
|
|
137
|
+
const result = spawnSync(process.execPath, [entrypoint, "--dir", root, ...args], { encoding: "utf8" });
|
|
138
|
+
assert.equal(result.status, 0, result.stderr);
|
|
139
|
+
return result.stdout;
|
|
140
|
+
};
|
|
141
|
+
const digest = JSON.parse(invoke(["--dir", root, "--json"]));
|
|
142
|
+
assert.deepEqual(JSON.parse(invoke(["--dir", root, "--json"], new URL("../bin/gauntlet-performance.mjs", import.meta.url).pathname)), digest);
|
|
143
|
+
assert.equal(digest.runs.length, 0, "legs are not brainstorming runs");
|
|
144
|
+
assert.equal(digest[section].length, 2, "repo-local numbers must not merge across repositories");
|
|
145
|
+
const group = digest[section].find((row: any) => row.repo === "team/product");
|
|
146
|
+
assert.equal(group.key, "team/product#42");
|
|
147
|
+
assert.equal(group.rounds.length, 3, "a different head or session is another round of the same ref");
|
|
148
|
+
assert.deepEqual(group.rounds.map((r: any) => [r.session, r.ts, r.items[0].outcome]), [
|
|
149
|
+
["same-session", timestamps[0], skill === "shape-ticket" ? "written" : "approved"],
|
|
150
|
+
["same-session", timestamps[1], skill === "shape-ticket" ? "no-op" : "merged"],
|
|
151
|
+
["third-session", timestamps[2], skill === "shape-ticket" ? "written" : "approved"],
|
|
152
|
+
]);
|
|
153
|
+
assert.equal(digest.skipped.filter((s: any) => s.reason === "not a leg record").length, 2);
|
|
154
|
+
assert.match(invoke([]), new RegExp(`${section}\\n[\\s\\S]*team/product#42\\s+3`));
|
|
155
|
+
assert.deepEqual(JSON.parse(invoke(["--since", "8.2.2", "--json"]))[section], []);
|
|
156
|
+
});
|
|
157
|
+
}
|