@agentsfleet/orly 0.6.8 → 0.7.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/src/cli.ts CHANGED
@@ -7,8 +7,11 @@ import { GateReport, isGateName, recordOverride, runGate, runGates } from "./gat
7
7
  import { install, InstallResult } from "./install";
8
8
  import { isString, OrlyError, readJsonObject, RulesModel } from "./model";
9
9
  import { Renderer } from "./render";
10
+ import { beginTelemetry, installedOrlyVersion, isTelemetrySkill, recordTelemetry } from "./telemetry";
10
11
  import { verifyRenders, writeEvidence } from "./verify";
11
12
 
13
+ type CliResult = { exitCode: number; gate?: string; failedCriterion?: string };
14
+
12
15
  const PASS_RESULT = "pass";
13
16
  const NOT_REQUIRED_RESULT = "not-required";
14
17
  const ACCEPT_DIRTY_FLAG = "--accept-dirty";
@@ -26,42 +29,61 @@ const FAIL_GLYPH = "🔴";
26
29
  const PR_GATE = "pr";
27
30
  const ENGINE_MARKER = "evals/install/run.sh";
28
31
  const VERIFY_COMMAND = "verify";
32
+ const SKILL_EVENT_COMMAND = "skill-event";
33
+ const TELEMETRY_CONFIG_KEY = "telemetry";
34
+ const ANONYMOUS_TELEMETRY_CONFIG = JSON.stringify({ [TELEMETRY_CONFIG_KEY]: "anonymous" });
35
+ const OFF_TELEMETRY_CONFIG = JSON.stringify({ [TELEMETRY_CONFIG_KEY]: "off" });
29
36
 
30
37
  const { root, arguments: commandArguments } = parseRoot(Bun.argv.slice(2));
38
+ const startedAt = performance.now();
39
+ const telemetry = await beginTelemetry(commandArguments);
40
+ const result = await execute(root, commandArguments);
41
+ await recordTelemetry(telemetry, {
42
+ ...(result.gate ? { gate: result.gate } : {}),
43
+ outcome: result.exitCode === 0 ? "success" : "error",
44
+ ...(result.failedCriterion ? { failedCriterion: result.failedCriterion } : {}),
45
+ durationMs: performance.now() - startedAt,
46
+ version: await installedOrlyVersion(root),
47
+ });
48
+ process.exit(result.exitCode);
31
49
 
32
- try {
33
- const model = await RulesModel.load(root);
34
- const exitCode = await run(model, commandArguments);
35
- process.exit(exitCode);
36
- } catch (error) {
37
- const message = error instanceof Error ? error.message : String(error);
38
- console.error(`orly: ${message}`);
39
- process.exit(1);
50
+ async function execute(engineRoot: string, args: string[]): Promise<CliResult> {
51
+ try {
52
+ return await run(await RulesModel.load(engineRoot), args);
53
+ } catch (error) {
54
+ const message = error instanceof Error ? error.message : String(error);
55
+ console.error(`orly: ${message}`);
56
+ return cliResult(1);
57
+ }
40
58
  }
41
59
 
42
- async function run(model: RulesModel, args: string[]): Promise<number> {
60
+ async function run(model: RulesModel, args: string[]): Promise<CliResult> {
43
61
  const [command, ...rest] = args;
44
62
  if (!command || command === "--help" || command === "-h") {
45
63
  printHelp();
46
- return command ? 0 : 1;
64
+ return cliResult(command ? 0 : 1);
47
65
  }
48
- if (command === "--version" || command === "-v") return printVersion(model);
66
+ if (command === "--version" || command === "-v") return cliResult(await printVersion(model));
49
67
  if (command === "doctor") {
50
68
  model.validate();
51
69
  requireNoArguments(rest, "doctor checks the installed ruleset: orly doctor");
52
- return doctorGlobal(model);
70
+ return cliResult(await doctorGlobal(model));
53
71
  }
54
- if (command === "init") return materialise(model, rest, true);
55
- if (command === "update") return materialise(model, rest, false);
56
- if (command === VERIFY_COMMAND) return verify(model, rest);
72
+ if (command === "init") return cliResult(await materialise(model, rest, true));
73
+ if (command === "update") return cliResult(await materialise(model, rest, false));
74
+ if (command === VERIFY_COMMAND) return cliResult(await verify(model, rest));
57
75
  if (command === "gate") return gate(model, rest);
58
- if (command === "override") return override(rest);
76
+ if (command === "override") return cliResult(override(rest));
77
+ if (command === SKILL_EVENT_COMMAND) {
78
+ if (rest.length !== 1 || !isTelemetrySkill(rest[0])) throw new OrlyError("skill-event requires a packaged Orly skill name");
79
+ return cliResult(0);
80
+ }
59
81
  throw new OrlyError(`unknown command: ${command}`);
60
82
  }
61
83
 
62
84
  // Read-only: run every gate in order (stop at the first red group), or one
63
85
  // named gate. Nothing is ever written.
64
- function gate(model: RulesModel, args: string[]): number {
86
+ function gate(model: RulesModel, args: string[]): CliResult {
65
87
  const acceptDirty = args.includes(ACCEPT_DIRTY_FLAG);
66
88
  const named = args.find((argument) => !argument.startsWith("-"));
67
89
  if (named !== undefined && !isGateName(named)) throw new OrlyError(`unknown gate: ${named} (work, verify, pr)`);
@@ -69,7 +91,14 @@ function gate(model: RulesModel, args: string[]): number {
69
91
  for (const report of reports) printGate(report);
70
92
  const allGreen = reports.every((report) => report.ok);
71
93
  if (allGreen && (named === undefined || named === PR_GATE)) console.log(`${PASS_GLYPH} PR boundary open — CHORE(close) is the next motion`);
72
- return allGreen ? 0 : 1;
94
+ const failedReport = reports.find((report) => !report.ok);
95
+ const failedCriterion = failedReport?.results.find((result) => !result.ok)?.name;
96
+ const selectedGate = named ?? failedReport?.gate;
97
+ return {
98
+ exitCode: allGreen ? 0 : 1,
99
+ ...(selectedGate ? { gate: selectedGate } : {}),
100
+ ...(failedCriterion ? { failedCriterion } : {}),
101
+ };
73
102
  }
74
103
 
75
104
  function override(args: string[]): number {
@@ -254,6 +283,10 @@ function optionalValues(args: string[], name: string): string[] {
254
283
  return values;
255
284
  }
256
285
 
286
+ function cliResult(exitCode: number): CliResult {
287
+ return { exitCode };
288
+ }
289
+
257
290
  function printHelp(): void {
258
291
  console.log(`orly — prove the boundary; carry the rules
259
292
 
@@ -281,6 +314,17 @@ Install (the repository is the unit — no checkout of this package required):
281
314
  against what orly would write today
282
315
  orly --version the installed package version
283
316
 
317
+ Usage telemetry (off by default):
318
+ Consent file: ~/.config/agentsfleet/orly/.orly.json
319
+ State-root override: AGENTSFLEET_STATE_DIR
320
+ Set ${ANONYMOUS_TELEMETRY_CONFIG} to send command, gate, and packaged Orly skill
321
+ names, outcome, failed criterion, duration, Orly version, operating system, architecture,
322
+ invocation type, timestamps, and random event, session, and installation IDs.
323
+ Set ${OFF_TELEMETRY_CONFIG} to write and send nothing.
324
+ ORLY_TELEMETRY=off|anonymous changes one process; ORLY_TELEMETRY_OFF=1 wins.
325
+ Orly never collects source, paths, repositories, branches, arguments, prompts,
326
+ output, environment values, raw errors, or personal and account details.
327
+
284
328
  Ruleset authoring (an orly checkout only — refused from an installed package):
285
329
  orly verify the rules render the same twice, and the
286
330
  committed copy matches that render`);
@@ -237,7 +237,7 @@ describe("install", () => {
237
237
  expect(existsSync(join(repo, "AGENTS.md"))).toBe(false);
238
238
  });
239
239
 
240
- test("--force replaces a hook orly did not write", async () => {
240
+ test("generated_hooks_mark_telemetry_invocation", async () => {
241
241
  const model = await RulesModel.load(ROOT);
242
242
  const repo = newRepository();
243
243
  await Bun.write(join(repo, ".githooks/pre-commit"), "#!/usr/bin/env bash\necho 'ours'\n");
@@ -245,7 +245,8 @@ describe("install", () => {
245
245
  const result = await install(model, { targetRoot: repo, force: true, installHooks: true, orlyVersion: "0.4.0" });
246
246
 
247
247
  expect(result.ok).toBe(true);
248
- expect(await Bun.file(join(repo, ".githooks/pre-commit")).text()).toContain("orly gate work");
248
+ expect(await Bun.file(join(repo, ".githooks/pre-commit")).text()).toContain("export ORLY_INVOCATION=hook");
249
+ expect(await Bun.file(join(repo, ".githooks/pre-push")).text()).toContain("export ORLY_INVOCATION=hook");
249
250
  });
250
251
 
251
252
  test("--no-hooks installs the rules over an existing hook without touching it", async () => {
package/src/install.ts CHANGED
@@ -6,6 +6,7 @@ import { CONFIG_PATH, readConfig, seedConfig, selectPacks, writeConfig } from ".
6
6
  import { applyMode, assertWritableInside, hashContent, isBelow, isString, JsonObject, modeLabel, objectArray, objectValue, OrlyError, RulesModel, stringArray } from "./model";
7
7
  import { referenceClosureErrors, renderProfileText } from "./references";
8
8
  import { Renderer } from "./render";
9
+ import { HOOK_INVOCATION_ENV, HOOK_INVOCATION_VALUE } from "./telemetry";
9
10
 
10
11
  const AGENTS_FILENAME = "AGENTS.md";
11
12
  const ORLY_AGENTS_FILENAME = "AGENTS.orly.md";
@@ -277,12 +278,11 @@ function hookScript(gate: string): string {
277
278
  " exit 1",
278
279
  "fi",
279
280
  "",
281
+ `export ${HOOK_INVOCATION_ENV}=${HOOK_INVOCATION_VALUE}`,
280
282
  `exec orly gate ${gate}`,
281
283
  "",
282
284
  ].join("\n");
283
285
  }
284
-
285
-
286
286
  // One entry per target, so two packs naming the same file collapse instead of
287
287
  // racing. Two packs naming DIFFERENT sources for one target is a registry bug
288
288
  // and stops the install rather than letting pack order decide the winner.
@@ -0,0 +1,308 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync, statSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import type { TelemetryEvent } from "./telemetry";
7
+ import {
8
+ beginTelemetry,
9
+ parseTelemetryEvent,
10
+ readTelemetrySpool,
11
+ recordTelemetry,
12
+ replaceTelemetrySpool,
13
+ resolveTelemetryConsent,
14
+ TELEMETRY_PROMPT,
15
+ withSpoolLock,
16
+ } from "./telemetry";
17
+ import { syncTelemetry } from "./telemetry_sync";
18
+
19
+ const FIXED_TIME = new Date("2026-08-25T12:00:00.000Z");
20
+ const FIVE_MINUTES_MILLISECONDS = 5 * 60 * 1_000;
21
+ const MAX_SPOOL_BYTES = 10 * 1024 * 1024;
22
+ const STATE_ENV = "AGENTSFLEET_STATE_DIR";
23
+ const ANONYMOUS_ENV = "ORLY_TELEMETRY";
24
+ const PRIVATE_SENTINEL = "private-context-sentinel";
25
+ const temporaryRoots: string[] = [];
26
+
27
+ afterEach(() => {
28
+ for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true });
29
+ });
30
+
31
+ describe("telemetry consent", () => {
32
+ test("resolves_telemetry_consent_in_precedence_order", () => {
33
+ expect(resolveTelemetryConsent({ ORLY_TELEMETRY_OFF: "1", ORLY_TELEMETRY: "anonymous" }, "anonymous")).toBe("off");
34
+ expect(resolveTelemetryConsent({ ORLY_TELEMETRY: "off" }, "anonymous")).toBe("off");
35
+ expect(resolveTelemetryConsent({ ORLY_TELEMETRY: "anonymous" }, "off")).toBe("anonymous");
36
+ expect(resolveTelemetryConsent({ ORLY_TELEMETRY: "invalid" }, "anonymous")).toBe("off");
37
+ expect(resolveTelemetryConsent({}, "anonymous")).toBe("anonymous");
38
+ expect(resolveTelemetryConsent({})).toBe("off");
39
+ });
40
+
41
+ test("prompts_once_and_marks_aborted_prompt", async () => {
42
+ const root = temporaryRoot();
43
+ let prompts = 0;
44
+ const options = {
45
+ env: { [STATE_ENV]: root }, stdinIsTty: true, stdoutIsTty: true,
46
+ ask: () => { prompts += 1; return null; },
47
+ };
48
+ expect(await beginTelemetry(["doctor"], options)).toBeUndefined();
49
+ expect(await beginTelemetry(["doctor"], options)).toBeUndefined();
50
+ expect(prompts).toBe(1);
51
+ expect(await Bun.file(join(root, "orly/.telemetry-prompted")).exists()).toBe(true);
52
+ expect(await Bun.file(join(root, "orly/.orly.json")).json()).toEqual({ telemetry: "off" });
53
+ });
54
+
55
+ test("anonymous_opt_in_persists_identity_and_does_not_reprompt", async () => {
56
+ const root = temporaryRoot();
57
+ let prompts = 0;
58
+ const options = {
59
+ env: { [STATE_ENV]: root }, stdinIsTty: true, stdoutIsTty: true,
60
+ ask: () => { prompts += 1; return "2"; }, randomId: idSequence(40, 41, 42),
61
+ };
62
+ const first = await beginTelemetry(["doctor"], options);
63
+ const second = await beginTelemetry(["doctor"], options);
64
+
65
+ expect(prompts).toBe(1);
66
+ expect(first).toMatchObject({ command: "doctor", invocation: "direct", installationId: uuid(41) });
67
+ expect(second?.installationId).toBe(first?.installationId);
68
+ expect(await Bun.file(join(root, "orly/.orly.json")).json()).toEqual({ telemetry: "anonymous" });
69
+ expect(await Bun.file(join(root, "orly/installation-id")).text()).toBe(`${uuid(41)}\n`);
70
+ });
71
+
72
+ test("automation_never_prompts_for_telemetry", async () => {
73
+ const root = temporaryRoot();
74
+ let prompts = 0;
75
+ const ask = () => { prompts += 1; return "2"; };
76
+ await beginTelemetry(["gate"], { env: { [STATE_ENV]: root, ORLY_INVOCATION: "hook" }, stdinIsTty: true, stdoutIsTty: true, ask });
77
+ await beginTelemetry(["gate"], { env: { [STATE_ENV]: root, CI: "true" }, stdinIsTty: true, stdoutIsTty: true, ask });
78
+ await beginTelemetry(["gate"], { env: { [STATE_ENV]: root }, stdinIsTty: false, stdoutIsTty: false, ask });
79
+ await beginTelemetry(["skill-event", "orly-spec-new"], { env: { [STATE_ENV]: root, ORLY_INVOCATION: "skill" }, stdinIsTty: true, stdoutIsTty: true, ask });
80
+ expect(prompts).toBe(0);
81
+ });
82
+
83
+ test("consent_prompt_discloses_complete_collection_boundary", () => {
84
+ for (const phrase of [
85
+ "command, gate, and packaged Orly skill names", "failed gate criterion", "duration, Orly version", "installation IDs", "persists",
86
+ "source code, file contents, prompts", "file paths, working directory", "environment variables", "raw error messages",
87
+ "name, email, username, hostname", "Off", "default", "Anonymous",
88
+ ]) expect(TELEMETRY_PROMPT).toContain(phrase);
89
+ });
90
+ });
91
+
92
+ describe("telemetry events", () => {
93
+ test("appends_one_valid_event_per_command", async () => {
94
+ const root = temporaryRoot();
95
+ const session = await anonymousSession(root, idSequence(1, 2));
96
+ let launches = 0;
97
+ await recordTelemetry(session, observation(), { now: () => FIXED_TIME, randomId: () => uuid(3), launchSync: () => { launches += 1; } });
98
+ const events = await readTelemetrySpool(join(root, "orly"), FIXED_TIME);
99
+ expect(events).toHaveLength(1);
100
+ expect(events[0]).toMatchObject({ command: "gate", gate: "verify", outcome: "error", failed_criterion: "verify.unit" });
101
+ expect(launches).toBe(1);
102
+ });
103
+
104
+ test("anonymous_installation_identity_is_stable_and_random", async () => {
105
+ const root = temporaryRoot();
106
+ const first = await anonymousSession(root, idSequence(10, 11));
107
+ const second = await anonymousSession(root, idSequence(12, 13));
108
+ expect(first?.installationId).toBe(uuid(11));
109
+ expect(second?.installationId).toBe(first?.installationId);
110
+ expect(first?.installationId).not.toContain(process.env.USER ?? PRIVATE_SENTINEL);
111
+ });
112
+
113
+ test("event_schema_rejects_private_context", async () => {
114
+ const root = temporaryRoot();
115
+ const session = await beginTelemetry(["gate", PRIVATE_SENTINEL], {
116
+ env: { [STATE_ENV]: root, [ANONYMOUS_ENV]: "anonymous", PRIVATE_VALUE: PRIVATE_SENTINEL },
117
+ stdinIsTty: false, stdoutIsTty: false, randomId: idSequence(20, 21),
118
+ });
119
+ await recordTelemetry(session, observation(), { now: () => FIXED_TIME, randomId: () => uuid(22), launchSync: () => undefined });
120
+ const text = await Bun.file(join(root, "orly/analytics/orly-usage.jsonl")).text();
121
+ expect(text).not.toContain(PRIVATE_SENTINEL);
122
+ const value = JSON.parse(text);
123
+ expect(parseTelemetryEvent(JSON.stringify({ ...value, repository: PRIVATE_SENTINEL }))).toBeUndefined();
124
+ });
125
+
126
+ test("packaged_skill_uses_the_command_spool", async () => {
127
+ const root = temporaryRoot();
128
+ const session = await beginTelemetry(["skill-event", "orly-write-unit-test"], {
129
+ env: { [STATE_ENV]: root, [ANONYMOUS_ENV]: "anonymous", ORLY_INVOCATION: "skill" },
130
+ stdinIsTty: true, stdoutIsTty: true, randomId: idSequence(30, 31),
131
+ });
132
+ await recordTelemetry(session, { outcome: "success", durationMs: 1, version: "0.6.9" }, {
133
+ now: () => FIXED_TIME, randomId: () => uuid(32), launchSync: () => undefined,
134
+ });
135
+ const events = await readTelemetrySpool(join(root, "orly"), FIXED_TIME);
136
+ expect(events).toHaveLength(1);
137
+ expect(events[0]).toMatchObject({ command: "skill", skill: "orly-write-unit-test", invocation: "skill" });
138
+ expect(parseTelemetryEvent(JSON.stringify({ ...events[0], skill: "unknown-skill" }))).toBeUndefined();
139
+ });
140
+
141
+ test("spool_lock_drops_contention_without_losing_the_owner_write", async () => {
142
+ const root = join(temporaryRoot(), "orly");
143
+ let contenderResult: boolean | undefined;
144
+ const ownerResult = await withSpoolLock(root, async () => {
145
+ contenderResult = await withSpoolLock(root, async () => {
146
+ throw new Error("contending writer must not run");
147
+ });
148
+ await replaceTelemetrySpool(root, [event(450)], FIXED_TIME);
149
+ });
150
+
151
+ expect(ownerResult).toBe(true);
152
+ expect(contenderResult).toBe(false);
153
+ expect((await readTelemetrySpool(root, FIXED_TIME)).map((entry) => entry.event_id)).toEqual([uuid(450)]);
154
+ });
155
+ });
156
+
157
+ describe("telemetry synchronization", () => {
158
+ test("sync_is_consent_gated_and_schema_safe", async () => {
159
+ const root = temporaryRoot();
160
+ const stateRoot = join(root, "orly");
161
+ await replaceTelemetrySpool(stateRoot, [event(1)], FIXED_TIME);
162
+ const bodies: unknown[] = [];
163
+ const fetchImpl = captureFetch(bodies);
164
+ await syncTelemetry({ env: { [STATE_ENV]: root, ORLY_TELEMETRY_OFF: "1" }, now: () => FIXED_TIME, fetchImpl });
165
+ expect(bodies).toHaveLength(0);
166
+ await syncTelemetry({ env: { [STATE_ENV]: root, [ANONYMOUS_ENV]: "anonymous" }, now: () => FIXED_TIME, fetchImpl });
167
+ const sent = postHogBatch(bodies[0]);
168
+ expect(sent).toHaveLength(1);
169
+ const properties = postHogProperties(sent[0]);
170
+ expect(Object.keys(properties).sort()).toEqual([
171
+ "$insert_id", "arch", "command", "distinct_id", "duration_ms", "event_id", "installation_id", "invocation",
172
+ "orly_version", "os", "outcome", "schema_version", "session_id",
173
+ ]);
174
+ });
175
+
176
+ test("sync_spaces_attempts_and_bounds_batches", async () => {
177
+ const root = temporaryRoot();
178
+ const stateRoot = join(root, "orly");
179
+ await replaceTelemetrySpool(stateRoot, Array.from({ length: 101 }, (_, index) => event(index + 1)), FIXED_TIME);
180
+ const bodies: unknown[] = [];
181
+ const options = { env: { [STATE_ENV]: root, [ANONYMOUS_ENV]: "anonymous" }, now: () => FIXED_TIME, fetchImpl: captureFetch(bodies) };
182
+ await syncTelemetry(options);
183
+ await syncTelemetry(options);
184
+ expect(bodies).toHaveLength(1);
185
+ expect(postHogBatch(bodies[0])).toHaveLength(100);
186
+ expect((await readTelemetrySpool(stateRoot, FIXED_TIME)).map((entry) => entry.event_id)).toEqual([uuid(101)]);
187
+ expect(await Bun.file(join(stateRoot, "analytics/.last-sync-time")).text()).toBe(`${FIXED_TIME.toISOString()}\n`);
188
+ });
189
+
190
+ test("sync_retries_without_losing_or_renaming_events", async () => {
191
+ const root = temporaryRoot();
192
+ const stateRoot = join(root, "orly");
193
+ const original = [event(201), event(202)];
194
+ await replaceTelemetrySpool(stateRoot, original, FIXED_TIME);
195
+ const failedBodies: unknown[] = [];
196
+ const env = { [STATE_ENV]: root, [ANONYMOUS_ENV]: "anonymous" };
197
+ await syncTelemetry({ env, now: () => FIXED_TIME, fetchImpl: failingFetch(failedBodies) });
198
+ expect((await readTelemetrySpool(stateRoot, FIXED_TIME)).map((entry) => entry.event_id)).toEqual(original.map((entry) => entry.event_id));
199
+ const retryTime = new Date(FIXED_TIME.getTime() + FIVE_MINUTES_MILLISECONDS + 1);
200
+ const successBodies: unknown[] = [];
201
+ await syncTelemetry({ env, now: () => retryTime, fetchImpl: captureFetch(successBodies) });
202
+ expect(postHogIds(successBodies[0])).toEqual(postHogIds(failedBodies[0]));
203
+ expect(await readTelemetrySpool(stateRoot, retryTime)).toEqual([]);
204
+ });
205
+
206
+ test("sync_retains_events_when_fetch_rejects", async () => {
207
+ const root = temporaryRoot();
208
+ const stateRoot = join(root, "orly");
209
+ const original = [event(250)];
210
+ await replaceTelemetrySpool(stateRoot, original, FIXED_TIME);
211
+
212
+ await syncTelemetry({
213
+ env: { [STATE_ENV]: root, [ANONYMOUS_ENV]: "anonymous" },
214
+ now: () => FIXED_TIME,
215
+ fetchImpl: async () => { throw new Error("network unavailable"); },
216
+ });
217
+
218
+ expect((await readTelemetrySpool(stateRoot, FIXED_TIME)).map((entry) => entry.event_id)).toEqual(original.map((entry) => entry.event_id));
219
+ });
220
+
221
+ test("sync_compacts_and_bounds_local_spool", async () => {
222
+ const root = temporaryRoot();
223
+ const stateRoot = join(root, "orly");
224
+ const old = new Date(FIXED_TIME.getTime() - 8 * 24 * 60 * 60 * 1_000).toISOString();
225
+ const large = Array.from({ length: 20 }, (_, index) => ({ ...event(index + 300), orly_version: "x".repeat(600_000) }));
226
+ await replaceTelemetrySpool(stateRoot, [event(299, old), ...large], FIXED_TIME);
227
+ const path = join(stateRoot, "analytics/orly-usage.jsonl");
228
+ expect(statSync(path).size).toBeLessThanOrEqual(MAX_SPOOL_BYTES);
229
+ const events = await readTelemetrySpool(stateRoot, FIXED_TIME);
230
+ expect(events.some((entry) => entry.event_id === uuid(299))).toBe(false);
231
+ expect(events.at(-1)?.event_id).toBe(uuid(319));
232
+ });
233
+
234
+ test("sync_names_skill_events_separately", async () => {
235
+ const root = temporaryRoot();
236
+ const stateRoot = join(root, "orly");
237
+ await replaceTelemetrySpool(stateRoot, [{ ...event(400), command: "skill", skill: "orly-spec-new", invocation: "skill" }], FIXED_TIME);
238
+ const bodies: unknown[] = [];
239
+ await syncTelemetry({
240
+ env: { [STATE_ENV]: root, [ANONYMOUS_ENV]: "anonymous" }, now: () => FIXED_TIME, fetchImpl: captureFetch(bodies),
241
+ });
242
+ const sent = postHogBatch(bodies[0]);
243
+ expect(isRecord(sent[0]) ? sent[0].event : undefined).toBe("orly_skill_run");
244
+ expect(postHogProperties(sent[0]).skill).toBe("orly-spec-new");
245
+ });
246
+ });
247
+
248
+ function observation() {
249
+ return { gate: "verify", outcome: "error" as const, failedCriterion: "verify.unit", durationMs: 42, version: "0.6.9" };
250
+ }
251
+
252
+ async function anonymousSession(root: string, randomId: () => string) {
253
+ return beginTelemetry(["gate", "verify"], {
254
+ env: { [STATE_ENV]: root, [ANONYMOUS_ENV]: "anonymous" }, stdinIsTty: false, stdoutIsTty: false, randomId,
255
+ });
256
+ }
257
+
258
+ function event(index: number, timestamp = FIXED_TIME.toISOString()): TelemetryEvent {
259
+ return {
260
+ schema_version: 1, event_id: uuid(index), timestamp, command: "gate", outcome: "success", duration_ms: index,
261
+ orly_version: "0.6.9", os: "darwin", arch: "arm64", invocation: "direct", session_id: uuid(9_000 + index), installation_id: uuid(8_000),
262
+ };
263
+ }
264
+
265
+ function temporaryRoot(): string {
266
+ const root = mkdtempSync(join(tmpdir(), "orly-telemetry-test-"));
267
+ temporaryRoots.push(root);
268
+ return root;
269
+ }
270
+
271
+ function idSequence(...values: number[]): () => string {
272
+ let index = 0;
273
+ return () => uuid(values[index++] ?? 999_999);
274
+ }
275
+
276
+ function uuid(value: number): string {
277
+ return `00000000-0000-4000-8000-${value.toString(16).padStart(12, "0")}`;
278
+ }
279
+
280
+ function captureFetch(bodies: unknown[]) {
281
+ return async (_input: string | URL | Request, init?: RequestInit): Promise<Response> => {
282
+ bodies.push(JSON.parse(String(init?.body)));
283
+ return new Response("ok", { status: 200 });
284
+ };
285
+ }
286
+
287
+ function failingFetch(bodies: unknown[]) {
288
+ return async (_input: string | URL | Request, init?: RequestInit): Promise<Response> => {
289
+ bodies.push(JSON.parse(String(init?.body)));
290
+ return new Response("unavailable", { status: 503 });
291
+ };
292
+ }
293
+
294
+ function postHogBatch(body: unknown): unknown[] {
295
+ return isRecord(body) && Array.isArray(body.batch) ? body.batch : [];
296
+ }
297
+
298
+ function postHogProperties(value: unknown): Record<string, unknown> {
299
+ return isRecord(value) && isRecord(value.properties) ? value.properties : {};
300
+ }
301
+
302
+ function postHogIds(body: unknown): unknown[] {
303
+ return postHogBatch(body).map((entry) => postHogProperties(entry).event_id);
304
+ }
305
+
306
+ function isRecord(value: unknown): value is Record<string, unknown> {
307
+ return typeof value === "object" && value !== null && !Array.isArray(value);
308
+ }