pi-gauntlet 8.2.1 → 8.4.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 +8 -0
- package/README.md +5 -5
- package/agents/loop-implementer.md +19 -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/plan-check.test.ts +10 -0
- package/extensions/lib/plan-check.ts +2 -2
- 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/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 +3 -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/reconstruction.md +6 -6
- package/skills/gauntlet-resume/reference/seed.md +1 -6
- package/skills/loop-implementation/SKILL.md +40 -21
- package/skills/loop-implementation/reference/action-spend.md +10 -0
- package/skills/loop-implementation/reference/coordinated-legs.md +5 -5
- package/skills/loop-implementation/reference/interrupted-recovery.md +10 -0
- package/skills/loop-implementation/reference/unreachable-returns.md +13 -0
- package/skills/shape-ticket/SKILL.md +1 -1
- package/skills/shape-ticket/reference/repo-audit.md +2 -2
- package/skills/verification-before-completion/reference/conformance-check.md +1 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { after, test } from "node:test";
|
|
3
|
+
import { mkdtempSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { parse } from "yaml";
|
|
7
|
+
import registerTelemetry, { type Deps } from "./telemetry.ts";
|
|
8
|
+
import { resolveTelemetry } from "./lib/gauntlet-settings.ts";
|
|
9
|
+
import type { LegRecord } 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
|
+
const root = mkdtempSync(join(tmpdir(), "telemetry-trackers-"));
|
|
19
|
+
const overrides = (tracker: string) => `# Project overrides\n\n## Issue tracker\n\n- tracker: ${tracker}\n- refs: prose, not a regular expression\n\n## Other settings\n- tracker: none\n`;
|
|
20
|
+
|
|
21
|
+
async function settle(skill: string, rows: string[], files: Record<string, string>) {
|
|
22
|
+
const handlers = new Map<string, ((event: any, ctx: any) => unknown)[]>();
|
|
23
|
+
const publications = new Map<string, string>();
|
|
24
|
+
const warnings: unknown[] = [];
|
|
25
|
+
const forbiddenCalls: unknown[] = [];
|
|
26
|
+
const forbidden = (...args: unknown[]): never => {
|
|
27
|
+
forbiddenCalls.push(args);
|
|
28
|
+
throw new Error("telemetry must not call a tracker or write to the checkout");
|
|
29
|
+
};
|
|
30
|
+
const deps: Deps = {
|
|
31
|
+
fs: {
|
|
32
|
+
readFile: (path) => files[path], exists: (path) => path in files,
|
|
33
|
+
writeFile: forbidden, rename: forbidden, mkdirp: forbidden, unlink: forbidden,
|
|
34
|
+
},
|
|
35
|
+
now: () => "2026-11-06T12:34:56Z",
|
|
36
|
+
settings: () => ({ telemetry: resolveTelemetry({}), specDirs: [], planDirs: [], errors: [], versions: { "pi-gauntlet": "8.2.1" } }),
|
|
37
|
+
git: async (args, cwd) => {
|
|
38
|
+
assert.equal(cwd, root);
|
|
39
|
+
if (args[0] === "rev-parse") return { code: 0, stdout: `${root}\n${root}/.git\n${root}/.git\n`, stderr: "" };
|
|
40
|
+
if (args[0] === "config" && args.length === 2 && ["user.name", "user.email"].includes(args[1])) {
|
|
41
|
+
return { code: 0, stdout: "Tester\n", stderr: "" };
|
|
42
|
+
}
|
|
43
|
+
return forbidden(args);
|
|
44
|
+
},
|
|
45
|
+
telemetryRepo: () => "team/product",
|
|
46
|
+
syncTelemetryBranch: (cwd, incoming) => {
|
|
47
|
+
assert.equal(cwd, root);
|
|
48
|
+
for (const [path, bytes] of incoming) {
|
|
49
|
+
assert.equal(publications.has(path), false, "one publication per settlement");
|
|
50
|
+
publications.set(path, bytes);
|
|
51
|
+
}
|
|
52
|
+
return { ok: true, committed: true, pushed: true };
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
registerTelemetry({
|
|
56
|
+
on: (name: string, handler: (event: any, ctx: any) => unknown) => handlers.set(name, [...(handlers.get(name) ?? []), handler]),
|
|
57
|
+
exec: forbidden,
|
|
58
|
+
sendMessage: (message: unknown) => warnings.push(message),
|
|
59
|
+
} as any, deps);
|
|
60
|
+
const ctx = { cwd: root, sessionManager: { getBranch: () => [], getSessionId: () => "tracker-session" } };
|
|
61
|
+
const emit = async (name: string, event: unknown = {}) => {
|
|
62
|
+
for (const handler of handlers.get(name) ?? []) await handler(event, ctx);
|
|
63
|
+
};
|
|
64
|
+
await emit("session_start");
|
|
65
|
+
await emit("input", { source: "interactive", text: `/skill:${skill} ${rows.join("\n")}` });
|
|
66
|
+
await emit("message_end", { message: {
|
|
67
|
+
role: "assistant", provider: "fixture", model: "model", usage: { input: 12, output: 4, cost: 0.125 },
|
|
68
|
+
content: [{ type: "text", text: ["gauntlet: done settled", ...rows, "worktree: done", "#### heading: written", "Closing prose."].join("\n") }],
|
|
69
|
+
} });
|
|
70
|
+
assert.equal(publications.size, 0);
|
|
71
|
+
await emit("agent_settled", { aborted: false });
|
|
72
|
+
await emit("agent_settled", { aborted: false });
|
|
73
|
+
assert.deepEqual(forbiddenCalls, [], "tracker commands and non-telemetry writes are forbidden");
|
|
74
|
+
assert.deepEqual(warnings, []);
|
|
75
|
+
return [...publications].map(([path, bytes]) => ({ path, record: parse(bytes) as LegRecord }));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const [skill, outcome] of [["shape-ticket", "written"], ["gatekeep-pr", "approved"], ["check-delivery", "delivered"]] as const) {
|
|
79
|
+
test(`${skill} differs only in item refs across GitHub, Linear and no tracker`, async () => {
|
|
80
|
+
let baseline: unknown;
|
|
81
|
+
for (const [tracker, ref] of [["github", "#42"], ["linear", "OPS-42"], ["none", "local/backlog: rollout 7"]]) {
|
|
82
|
+
const pr = skill === "gatekeep-pr";
|
|
83
|
+
const key = pr ? "#9@abc123" : ref;
|
|
84
|
+
const row = `${key}: ${outcome}${pr ? ` (${ref})` : ""}`;
|
|
85
|
+
const records = await settle(skill, [row], { [`${root}/.pi/gauntlet-overrides.md`]: overrides(tracker) });
|
|
86
|
+
assert.equal(records.length, 1, `${tracker} must publish the leg`);
|
|
87
|
+
const { record } = records[0];
|
|
88
|
+
assert.deepEqual(record.items, [{ kind: pr ? "pr" : "ticket", key, ...(pr ? { ticket: ref } : {}), outcome }]);
|
|
89
|
+
assert.equal(record.cost, 0.125);
|
|
90
|
+
assert.equal(record.tokens.input, 12);
|
|
91
|
+
const normalized = { ...records[0], record: { ...record, items: record.items.map(({ key, ticket, ...item }) => item) } };
|
|
92
|
+
if (baseline) assert.deepEqual(normalized, baseline, "tracker selection must not alter measured fields or record path");
|
|
93
|
+
else baseline = normalized;
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (const [name, files, acceptsTypedRef] of [
|
|
99
|
+
["root fallback", { "gauntlet-overrides.md": overrides("none") }, true],
|
|
100
|
+
["doc fallback", { "doc/gauntlet-overrides.md": overrides("none") }, true],
|
|
101
|
+
["first file wins", { ".pi/gauntlet-overrides.md": overrides("github"), "gauntlet-overrides.md": overrides("none") }, false],
|
|
102
|
+
["first file lacks tracker section", { ".pi/gauntlet-overrides.md": "## Other settings\n- tracker: none\n", "doc/gauntlet-overrides.md": overrides("none") }, false],
|
|
103
|
+
["no overrides", {}, false],
|
|
104
|
+
] as const) {
|
|
105
|
+
test(`tracker selection: ${name}`, async () => {
|
|
106
|
+
const records = await settle("check-delivery", ["#42: delivered", "local/backlog: rollout 7: delivered"],
|
|
107
|
+
Object.fromEntries(Object.entries(files).map(([path, content]) => [`${root}/${path}`, content])));
|
|
108
|
+
assert.equal(records.length, 1);
|
|
109
|
+
assert.deepEqual(records[0].record.items, [
|
|
110
|
+
{ kind: "ticket", key: "#42", outcome: "delivered" },
|
|
111
|
+
...(acceptsTypedRef ? [{ kind: "ticket", key: "local/backlog: rollout 7", outcome: "delivered" }] : []),
|
|
112
|
+
]);
|
|
113
|
+
});
|
|
114
|
+
}
|