From 33a78ec9a8392fd9ddd1df6f47fccfccf360dd4b Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Fri, 12 Jun 2026 13:22:10 -0700 Subject: [PATCH 01/17] feat(cli): Emit cli_run once per invocation from the runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the telemetry rework: add the cli_run denominator centrally in the index.ts runner so every invocation emits exactly one cli_run with { command, cli_version, success, durationMs, anonymous, loggedIn }, on both success and failure. On failure the runner first emits cli_error { command, code } — CliError gains an optional CliErrorCode, falling back to INTERNAL_ERROR. The telemetry client now exposes identity so the runner can stamp anonymous/loggedIn. The runner logic (command-name resolution, run-event emission) is extracted to telemetry-run.ts so it is unit-testable without executing the CLI entry point. Legacy per-command events still emit in this phase (removed in phases 2–4); cli_run is purely additive here, so the suite stays green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../restructure-cli-telemetry/tasks.md | 62 +++++++++++++ packages/cli/src/index.ts | 26 +++++- packages/cli/src/telemetry-run.ts | 82 +++++++++++++++++ packages/cli/src/telemetry.ts | 4 + packages/cli/src/util/cli-error.ts | 12 +++ packages/cli/test/cli-run.test.ts | 89 +++++++++++++++++++ 6 files changed, 273 insertions(+), 2 deletions(-) create mode 100644 openspec/changes/restructure-cli-telemetry/tasks.md create mode 100644 packages/cli/src/telemetry-run.ts create mode 100644 packages/cli/test/cli-run.test.ts diff --git a/openspec/changes/restructure-cli-telemetry/tasks.md b/openspec/changes/restructure-cli-telemetry/tasks.md new file mode 100644 index 00000000..12843d5c --- /dev/null +++ b/openspec/changes/restructure-cli-telemetry/tasks.md @@ -0,0 +1,62 @@ +# Tasks + +## Phasing — stacked PRs + +This change is cut into commitable phases, each of which leaves the build and +tests green and maps to one stacked PR (Git Town). Tests travel with the phase +that introduces the behavior — there is no trailing "tests" phase. Phases are +ordered so the stack reads bottom → top: + +``` +main +└── docs openspec change contract (proposal/design/specs/tasks) + └── phase 1 cli_run denominator + cli_error (runner) + └── phase 2 rule events (created/improved/deleted) + └── phase 3 auth + lifecycle events (auth/install/onboard/check) + └── phase 4 cli_help { topic } + drop info/detect bespoke events + └── phase 5 finalize: sweep, gate, archive ← tip +``` + +Transitional note: while the stack is mid-flight, a command may briefly emit +both `cli_run` and a soon-to-be-removed legacy event (e.g. after phase 1 but +before phase 2). That dual signal exists only within the unmerged stack; the +hard cut (no dual-emit) holds for the released, fully-merged state. Each phase +keeps the suite green on its own. + +## 1. Phase 1 — cli_run denominator + cli_error (PR 1) + +- [x] 1.1 In `packages/cli/src/index.ts`, wrap command execution so exactly one `cli_run` is emitted per invocation from a `finally`-equivalent path, with `{ command, cli_version, success, durationMs, anonymous, loggedIn }` +- [x] 1.2 Resolve `command` from the matched citty subcommand (e.g. `"rule create"`, `"help"`); derive `success` from a thrown error / non-zero `process.exitCode`; measure `durationMs` from a start timestamp — extracted to a testable `telemetry-run.ts` (resolveCommandName/resolveCwd/emitRunEvents) so the entry module's side-effecting top level stays untested +- [x] 1.3 Emit `cli_error { command, code }` from the runner's catch path when the failure carries a stable `CliErrorCode` — added an optional `code` to `CliError`; falls back to `INTERNAL_ERROR` +- [x] 1.4 Tests: one `cli_run` per invocation (success and failure), and `cli_error` on a known-code failure — `test/cli-run.test.ts` +- [x] 1.5 typecheck + lint + suite green; commit; open PR 1 + +## 2. Phase 2 — rule concrete-state events (PR 2, on PR 1) + +- [ ] 2.1 `commands/rules.ts`: remove `cli_rule_create(_completed)`, `cli_rule_improve(_completed)`, `cli_rule_delete(_completed)`, `cli_rule_meta(_completed)`, `cli_rule_verify(_completed)` +- [ ] 2.2 Emit `cli_rule_created`, `cli_rule_improved`, `cli_rule_deleted` at the point each state changes (counts/ids/booleans only); `verify`/`meta` are covered by `cli_run` alone +- [ ] 2.3 Update rule command tests to the new events; assert no `cli_rule_*_completed` +- [ ] 2.4 typecheck + lint + suite green; commit; open PR 2 + +## 3. Phase 3 — auth + lifecycle events (PR 3, on PR 2) + +- [ ] 3.1 `commands/auth.ts`: remove `cli_auth_login(_completed)`, `cli_auth_logout(_completed)`, `cli_auth_status(_completed)`; emit `cli_authenticated` and `cli_logged_out` on success (status → `cli_run` only) +- [ ] 3.2 `commands/init.ts` + `wizard/index.ts`: remove `cli_init(_completed)`, `cli_init_cancelled`, `cli_update(_completed)`; emit `cli_installed` on a successful install +- [ ] 3.3 `commands/onboard.ts`: remove `cli_onboard_recipe` / `cli_onboard_already_done`; emit `cli_onboarded` when onboarding is marked complete +- [ ] 3.4 `commands/check.ts`: remove `cli_check(_completed)`; emit `cli_check_completed { errorCount, warningCount, filesScanned }` (counts only — no matched code) +- [ ] 3.5 Update auth/init/onboard/check tests to the new events +- [ ] 3.6 typecheck + lint + suite green; commit; open PR 3 + +## 4. Phase 4 — cli_help { topic } + drop bespoke info/detect events (PR 4, on PR 3) + +- [ ] 4.1 `commands/help.ts`: replace `help_index`, `help_`, `help_unknown` with one `cli_help { topic }` (served topic, an index marker for no-arg, the attempted topic for unknown) +- [ ] 4.2 `commands/info.ts`, `commands/detect.ts`: remove their bespoke `cli_info(_completed)` / `cli_detect` events — covered by `cli_run` +- [ ] 4.3 Update `test/help-extensions.test.ts` / `test/help-routing-telemetry.test.ts` and info/detect tests; assert `cli_help` carries `topic` and no `help_*` event is emitted +- [ ] 4.4 typecheck + lint + suite green; commit; open PR 4 + +## 5. Phase 5 — finalize (PR 5, tip) + +- [ ] 5.1 Grep the CLI for any remaining old event names (`_completed`, `help_index`, `help_`, `help_unknown`, legacy `cli_` starts); remove any stragglers +- [ ] 5.2 Run `pnpm openspec validate restructure-cli-telemetry`; `pnpm typecheck`; `pnpm lint`; full suite green +- [ ] 5.3 Manual smoke: run a couple of commands with telemetry mocked/inspected — confirm one `cli_run` per invocation plus the expected concrete event, and no legacy names +- [ ] 5.4 Archive the change (`openspec archive restructure-cli-telemetry`) so the tip carries the spec sync + dated archive; commit; open PR 5 diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 2b9060cd..643f7bd1 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -7,7 +7,8 @@ import { infoCommand } from "./commands/info"; import { createHelpCommand } from "./commands/help"; import { onboardCommand } from "./commands/onboard"; import { ruleCommand } from "./commands/rules"; -import { shutdownTelemetry } from "./telemetry"; +import { getTelemetry, shutdownTelemetry } from "./telemetry"; +import { emitRunEvents, resolveCommandName, resolveCwd } from "./telemetry-run"; import { CliError } from "./util/cli-error"; const subCommands = { @@ -97,15 +98,36 @@ const main = defineCommand({ }); // main loop to run cli and make every attempt to shut down gracefully +const rawArguments = process.argv.slice(2); +const startedAt = Date.now(); +let thrown: unknown; try { - await runCommand(main, { rawArgs: process.argv.slice(2) }); + await runCommand(main, { rawArgs: rawArguments }); } catch (error) { // CliError = expected failure (already printed output, exitCode already set) + thrown = error; if (!(error instanceof CliError)) { process.exitCode = 1; console.error(error instanceof Error ? error.message : String(error)); } } finally { + // cli_run is the per-invocation denominator: emitted exactly once here, on + // both success and failure, so no command has to remember to. Telemetry is + // best-effort and never affects the exit. + try { + const telemetry = await getTelemetry(resolveCwd(rawArguments)); + const success = + thrown === undefined && + (process.exitCode === undefined || process.exitCode === 0); + emitRunEvents(telemetry, { + command: resolveCommandName(rawArguments), + success, + durationMs: Date.now() - startedAt, + error: thrown, + }); + } catch { + // Telemetry failures are silent + } try { await shutdownTelemetry(); } catch { diff --git a/packages/cli/src/telemetry-run.ts b/packages/cli/src/telemetry-run.ts new file mode 100644 index 00000000..44f3644f --- /dev/null +++ b/packages/cli/src/telemetry-run.ts @@ -0,0 +1,82 @@ +import { resolve } from "node:path"; + +import type { TelemetryClient } from "./telemetry"; +import { CliError } from "./util/cli-error"; + +/** + * Derive the cli_run `command` property from the raw argv. Flags (and the + * value after `-d`/`--dir`) are skipped; the first positional is the command, + * and `rule` keeps its subcommand (e.g. `rule create`) since that distinction + * is meaningful. `help`'s topic is recorded separately on cli_help, so the + * command for a help invocation is just `help`. + */ +export function resolveCommandName(rawArguments: string[]): string { + const valueFlags = new Set(["-d", "--dir"]); + const positionals: string[] = []; + for (let index = 0; index < rawArguments.length; index++) { + const argument = rawArguments[index]!; + if (argument.startsWith("-")) { + if (!argument.includes("=") && valueFlags.has(argument)) index++; + continue; + } + positionals.push(argument); + } + + if (positionals.length === 0) return "(default)"; + const top = positionals[0]!; + if (top === "rule" && positionals[1]) return `rule ${positionals[1]}`; + return top; +} + +/** Resolve the working directory from `-d`/`--dir`, defaulting to cwd. */ +export function resolveCwd(rawArguments: string[]): string { + for (let index = 0; index < rawArguments.length; index++) { + const argument = rawArguments[index]!; + if ( + (argument === "-d" || argument === "--dir") && + rawArguments[index + 1] + ) { + return resolve(rawArguments[index + 1]!); + } + if (argument.startsWith("--dir=")) { + return resolve(argument.slice("--dir=".length)); + } + } + return process.cwd(); +} + +export interface RunContext { + command: string; + success: boolean; + durationMs: number; + error?: unknown; +} + +/** + * Emit the per-invocation telemetry: a single `cli_run` denominator event + * (always), preceded by `cli_error` when the invocation failed. The CLI + * version rides along via the telemetry client's standard `cliVersion` + * property; `cli_version` is included here in the snake_case form the run + * taxonomy uses. + */ +export function emitRunEvents( + telemetry: Pick, + context: RunContext +): void { + if (!context.success) { + const code = + context.error instanceof CliError && context.error.code + ? context.error.code + : "INTERNAL_ERROR"; + telemetry.capture("cli_error", { command: context.command, code }); + } + + telemetry.capture("cli_run", { + command: context.command, + cli_version: __VERSION__, + success: context.success, + durationMs: context.durationMs, + anonymous: telemetry.identity.anonymous, + loggedIn: !telemetry.identity.anonymous, + }); +} diff --git a/packages/cli/src/telemetry.ts b/packages/cli/src/telemetry.ts index 2ca14a2e..27b417c9 100644 --- a/packages/cli/src/telemetry.ts +++ b/packages/cli/src/telemetry.ts @@ -21,6 +21,8 @@ const ANONYMOUS_ID_FILE = "anonymous_id"; export interface TelemetryClient { capture(event: string, properties?: Record): void; shutdown(): Promise; + /** Resolved identity state, exposed so the runner can stamp cli_run. */ + readonly identity: { anonymous: boolean }; } function isTelemetryDisabled(): boolean { @@ -33,6 +35,7 @@ function isTelemetryDisabled(): boolean { const noopClient: TelemetryClient = { capture() {}, async shutdown() {}, + identity: { anonymous: true }, }; async function getOrCreateAnonymousId(): Promise { @@ -168,6 +171,7 @@ export async function getTelemetry(cwd?: string): Promise { const ph = posthog; instance = { + identity: { anonymous }, capture(event: string, properties?: Record) { try { ph.capture({ diff --git a/packages/cli/src/util/cli-error.ts b/packages/cli/src/util/cli-error.ts index 43ab7b90..9703581b 100644 --- a/packages/cli/src/util/cli-error.ts +++ b/packages/cli/src/util/cli-error.ts @@ -1,8 +1,20 @@ +import type { CliErrorCode } from "../types/errors"; + /** * Sentinel error for expected CLI failures (e.g. validation errors). * The top-level catch in index.ts uses this to distinguish expected exits * (already printed their own output) from unexpected crashes. + * + * An optional `code` (a stable `CliErrorCode`) lets the runner attribute a + * `cli_error` telemetry event to a known failure mode. Omitting it is fine; + * the runner falls back to `INTERNAL_ERROR`. */ export class CliError extends Error { override name = "CliError"; + readonly code?: CliErrorCode; + + constructor(message?: string, code?: CliErrorCode) { + super(message); + this.code = code; + } } diff --git a/packages/cli/test/cli-run.test.ts b/packages/cli/test/cli-run.test.ts new file mode 100644 index 00000000..17e3e0ce --- /dev/null +++ b/packages/cli/test/cli-run.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from "vitest"; + +import { emitRunEvents, resolveCommandName } from "../src/telemetry-run"; +import { CliError } from "../src/util/cli-error"; + +describe("resolveCommandName", () => { + it.each([ + [["info"], "info"], + [["check", "--json"], "check"], + [["rule", "create"], "rule create"], + [["rule"], "rule"], + [["help", "route"], "help"], + [["-d", "/tmp", "check"], "check"], + [["--dir", "/tmp", "info"], "info"], + [[], "(default)"], + ])("resolves %j to %s", (argv, expected) => { + expect(resolveCommandName(argv)).toBe(expected); + }); +}); + +function fakeTelemetry(anonymous = true) { + return { + capture: vi.fn(), + identity: { anonymous }, + }; +} + +describe("emitRunEvents", () => { + it("emits exactly one cli_run on success, with no cli_error", () => { + const telemetry = fakeTelemetry(); + emitRunEvents(telemetry, { command: "info", success: true, durationMs: 5 }); + + expect(telemetry.capture).toHaveBeenCalledTimes(1); + expect(telemetry.capture).toHaveBeenCalledWith( + "cli_run", + expect.objectContaining({ + command: "info", + success: true, + durationMs: 5, + anonymous: true, + loggedIn: false, + }) + ); + }); + + it("emits cli_error (with the CliError code) then cli_run on failure", () => { + const telemetry = fakeTelemetry(); + emitRunEvents(telemetry, { + command: "rule create", + success: false, + durationMs: 9, + error: new CliError("nope", "AUTH_REQUIRED"), + }); + + expect(telemetry.capture).toHaveBeenCalledWith("cli_error", { + command: "rule create", + code: "AUTH_REQUIRED", + }); + expect(telemetry.capture).toHaveBeenCalledWith( + "cli_run", + expect.objectContaining({ command: "rule create", success: false }) + ); + }); + + it("falls back to INTERNAL_ERROR for non-CliError failures", () => { + const telemetry = fakeTelemetry(); + emitRunEvents(telemetry, { + command: "info", + success: false, + durationMs: 1, + error: new Error("boom"), + }); + + expect(telemetry.capture).toHaveBeenCalledWith("cli_error", { + command: "info", + code: "INTERNAL_ERROR", + }); + }); + + it("reflects an authenticated identity as loggedIn", () => { + const telemetry = fakeTelemetry(false); + emitRunEvents(telemetry, { command: "info", success: true, durationMs: 2 }); + + expect(telemetry.capture).toHaveBeenCalledWith( + "cli_run", + expect.objectContaining({ anonymous: false, loggedIn: true }) + ); + }); +}); From 56f842883f8736b09652aa6912d7d39bfd30ad96 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Fri, 12 Jun 2026 18:36:12 -0700 Subject: [PATCH 02/17] feat(cli): Emit concrete rule events; drop rule start/_completed pairs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the telemetry rework. Replace the per-rule-command start and _completed events with concrete state-transition events fired where the state actually changes: - rule create → cli_rule_created { ruleCount } (only when rules are written) - rule improve → cli_rule_improved { ruleCount } - rule delete → cli_rule_deleted (only when files removed) - rule meta / rule verify → no bespoke event; covered by cli_run The create/improve/delete commands keep their try/finally and emit the concrete event from the finally guarded by a state flag; meta/verify drop their command-level telemetry entirely. Only a sample event name in telemetry.test.ts needed updating (to cli_rule_created). Stacked on the cli_run phase; cli_run still emits per invocation, so the suite stays green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../restructure-cli-telemetry/tasks.md | 8 +- packages/cli/src/commands/rules.ts | 216 ++++++++---------- packages/cli/test/telemetry.test.ts | 2 +- 3 files changed, 96 insertions(+), 130 deletions(-) diff --git a/openspec/changes/restructure-cli-telemetry/tasks.md b/openspec/changes/restructure-cli-telemetry/tasks.md index 12843d5c..91997ec4 100644 --- a/openspec/changes/restructure-cli-telemetry/tasks.md +++ b/openspec/changes/restructure-cli-telemetry/tasks.md @@ -33,10 +33,10 @@ keeps the suite green on its own. ## 2. Phase 2 — rule concrete-state events (PR 2, on PR 1) -- [ ] 2.1 `commands/rules.ts`: remove `cli_rule_create(_completed)`, `cli_rule_improve(_completed)`, `cli_rule_delete(_completed)`, `cli_rule_meta(_completed)`, `cli_rule_verify(_completed)` -- [ ] 2.2 Emit `cli_rule_created`, `cli_rule_improved`, `cli_rule_deleted` at the point each state changes (counts/ids/booleans only); `verify`/`meta` are covered by `cli_run` alone -- [ ] 2.3 Update rule command tests to the new events; assert no `cli_rule_*_completed` -- [ ] 2.4 typecheck + lint + suite green; commit; open PR 2 +- [x] 2.1 `commands/rules.ts`: remove `cli_rule_create(_completed)`, `cli_rule_improve(_completed)`, `cli_rule_delete(_completed)`, `cli_rule_meta(_completed)`, `cli_rule_verify(_completed)` +- [x] 2.2 Emit `cli_rule_created`, `cli_rule_improved`, `cli_rule_deleted` at the point each state changes (counts/ids/booleans only); `verify`/`meta` are covered by `cli_run` alone (their command-level telemetry was removed entirely) +- [x] 2.3 Update rule command tests to the new events; assert no `cli_rule_*_completed` — only `telemetry.test.ts` referenced an old rule name (a sample), updated to `cli_rule_created`; rule-from/verify tests assert behavior, not events +- [x] 2.4 typecheck + lint + suite green; commit; open PR 2 ## 3. Phase 3 — auth + lifecycle events (PR 3, on PR 2) diff --git a/packages/cli/src/commands/rules.ts b/packages/cli/src/commands/rules.ts index a9d65065..d4504dce 100644 --- a/packages/cli/src/commands/rules.ts +++ b/packages/cli/src/commands/rules.ts @@ -71,8 +71,6 @@ const createCommand = defineCommand({ async run({ args }) { const cwd = resolve(args.dir ?? process.cwd()); const telemetry = await getTelemetry(cwd); - const startedAt = Date.now(); - telemetry.capture("cli_rule_create"); /** Emit an error and exit, respecting --json mode */ function fail( @@ -101,14 +99,12 @@ const createCommand = defineCommand({ console.error(message); } process.exitCode = 1; - telemetry.capture("cli_rule_create_completed", { - success: false, - durationMs: Date.now() - startedAt, - }); return; } - let success = false; + // Set to the number of rules written when generation succeeds; drives the + // cli_rule_created event in the finally. + let createdRuleCount: number | undefined; try { // 1. Read and validate --from file if (!args.from) { @@ -249,7 +245,7 @@ const createCommand = defineCommand({ console.log(` ${filePath}`); } } - success = true; + createdRuleCount = rules.length; return; } case "pr": @@ -267,16 +263,15 @@ const createCommand = defineCommand({ } else { console.log(`Rule ${ruleId} is in state "${status.status}".`); } - success = true; return; } } } } finally { - telemetry.capture("cli_rule_create_completed", { - success, - durationMs: Date.now() - startedAt, - }); + // Concrete state event: a rule was actually generated and written. + if (createdRuleCount !== undefined) { + telemetry.capture("cli_rule_created", { ruleCount: createdRuleCount }); + } } }, }); @@ -313,8 +308,6 @@ const improveCommand = defineCommand({ async run({ args }) { const cwd = resolve(args.dir ?? process.cwd()); const telemetry = await getTelemetry(cwd); - const startedAt = Date.now(); - telemetry.capture("cli_rule_improve"); /** Emit an error and exit, respecting --json mode */ function fail( @@ -341,14 +334,12 @@ const improveCommand = defineCommand({ console.error(message); } process.exitCode = 1; - telemetry.capture("cli_rule_improve_completed", { - success: false, - durationMs: Date.now() - startedAt, - }); return; } - let success = false; + // Set to the number of rules written when iteration succeeds; drives the + // cli_rule_improved event in the finally. + let improvedRuleCount: number | undefined; try { // 1. Read and validate --from file if (!args.from) { @@ -487,7 +478,7 @@ const improveCommand = defineCommand({ console.log(` ${filePath}`); } } - success = true; + improvedRuleCount = rules.length; return; } case "pr": @@ -506,16 +497,17 @@ const improveCommand = defineCommand({ `Request ${requestId} is in state "${status.status}".` ); } - success = true; return; } } } } finally { - telemetry.capture("cli_rule_improve_completed", { - success, - durationMs: Date.now() - startedAt, - }); + // Concrete state event: a rule was actually iterated and rewritten. + if (improvedRuleCount !== undefined) { + telemetry.capture("cli_rule_improved", { + ruleCount: improvedRuleCount, + }); + } } }, }); @@ -549,9 +541,6 @@ const metaCommand = defineCommand({ }, async run({ args }) { const cwd = resolve(args.dir ?? process.cwd()); - const telemetry = await getTelemetry(cwd); - const startedAt = Date.now(); - telemetry.capture("cli_rule_meta"); function fail( message: string, @@ -566,42 +555,33 @@ const metaCommand = defineCommand({ throw new CliError(message); } - let success = false; - try { - const meta = await readRuleMetaFile(cwd, args.id); - if (!meta) { - fail( - `No metadata found for rule "${args.id}". Expected .taskless/rule-metadata/${args.id}.yml`, - "RULE_NOT_FOUND" - ); - } + const meta = await readRuleMetaFile(cwd, args.id); + if (!meta) { + fail( + `No metadata found for rule "${args.id}". Expected .taskless/rule-metadata/${args.id}.yml`, + "RULE_NOT_FOUND" + ); + } - if (args.json) { - let output; - try { - output = metaOutputSchema.parse({ id: args.id, ...meta }); - } catch (error) { - if (error instanceof ZodError) { - fail( - `Invalid metadata for rule "${args.id}": ${error.issues.map((issue) => issue.message).join(", ")}`, - "INVALID_INPUT" - ); - } - fail(error instanceof Error ? error.message : String(error)); - } - console.log(JSON.stringify(output)); - } else { - console.log(`Metadata for rule "${args.id}":\n`); - for (const [key, value] of Object.entries(meta)) { - console.log(` ${key}: ${String(value)}`); + if (args.json) { + let output; + try { + output = metaOutputSchema.parse({ id: args.id, ...meta }); + } catch (error) { + if (error instanceof ZodError) { + fail( + `Invalid metadata for rule "${args.id}": ${error.issues.map((issue) => issue.message).join(", ")}`, + "INVALID_INPUT" + ); } + fail(error instanceof Error ? error.message : String(error)); + } + console.log(JSON.stringify(output)); + } else { + console.log(`Metadata for rule "${args.id}":\n`); + for (const [key, value] of Object.entries(meta)) { + console.log(` ${key}: ${String(value)}`); } - success = true; - } finally { - telemetry.capture("cli_rule_meta_completed", { - success, - durationMs: Date.now() - startedAt, - }); } }, }); @@ -637,8 +617,6 @@ const deleteCommand = defineCommand({ async run({ args }) { const cwd = resolve(args.dir ?? process.cwd()); const telemetry = await getTelemetry(cwd); - const startedAt = Date.now(); - telemetry.capture("cli_rule_delete"); const id = args.id; let success = false; @@ -661,10 +639,10 @@ const deleteCommand = defineCommand({ process.exitCode = 1; } } finally { - telemetry.capture("cli_rule_delete_completed", { - success, - durationMs: Date.now() - startedAt, - }); + // Concrete state event: a rule and its tests were actually removed. + if (success) { + telemetry.capture("cli_rule_deleted"); + } } }, }); @@ -698,73 +676,61 @@ const verifyCommand = defineCommand({ }, async run({ args }) { const cwd = resolve(args.dir ?? process.cwd()); - const telemetry = await getTelemetry(cwd); - const startedAt = Date.now(); - telemetry.capture("cli_rule_verify"); - - let success = false; - try { - if (!args.id) { - if (args.json) { - console.log( - JSON.stringify( - makeErrorEnvelope("INVALID_INPUT", "Rule ID is required.") - ) - ); - } else { - console.error( - "Error: Rule ID is required.\n Usage: taskless rule verify " - ); - } - process.exitCode = 1; - return; - } - - const result = await verifyRule(cwd, args.id); + if (!args.id) { if (args.json) { - console.log(JSON.stringify(verifyOutputSchema.parse(result))); - } else { - console.log(`Verifying rule: ${result.ruleId}\n`); - - // Layer 1 console.log( - `Schema: ${result.schema.valid ? "✓ valid" : "✗ invalid"}` + JSON.stringify( + makeErrorEnvelope("INVALID_INPUT", "Rule ID is required.") + ) ); - for (const error of result.schema.errors) { - console.log(` - ${error}`); - } - - // Layer 2 - console.log( - `Requirements: ${result.requirements.valid ? "✓ valid" : "✗ invalid"}` + } else { + console.error( + "Error: Rule ID is required.\n Usage: taskless rule verify " ); - for (const error of result.requirements.errors) { - console.log(` - ${error}`); - } + } + process.exitCode = 1; + return; + } - // Layer 3 - console.log( - `Tests: ${result.tests.valid ? "✓ passed" : "✗ failed"} (${String(result.tests.passed)} passed, ${String(result.tests.failed)} failed)` - ); - for (const error of result.tests.errors) { - console.log(` - ${error}`); - } + const result = await verifyRule(cwd, args.id); - console.log( - `\nResult: ${result.success ? "✓ All checks passed" : "✗ Verification failed"}` - ); + if (args.json) { + console.log(JSON.stringify(verifyOutputSchema.parse(result))); + } else { + console.log(`Verifying rule: ${result.ruleId}\n`); + + // Layer 1 + console.log( + `Schema: ${result.schema.valid ? "✓ valid" : "✗ invalid"}` + ); + for (const error of result.schema.errors) { + console.log(` - ${error}`); } - if (!result.success) { - process.exitCode = 1; + // Layer 2 + console.log( + `Requirements: ${result.requirements.valid ? "✓ valid" : "✗ invalid"}` + ); + for (const error of result.requirements.errors) { + console.log(` - ${error}`); } - success = result.success; - } finally { - telemetry.capture("cli_rule_verify_completed", { - success, - durationMs: Date.now() - startedAt, - }); + + // Layer 3 + console.log( + `Tests: ${result.tests.valid ? "✓ passed" : "✗ failed"} (${String(result.tests.passed)} passed, ${String(result.tests.failed)} failed)` + ); + for (const error of result.tests.errors) { + console.log(` - ${error}`); + } + + console.log( + `\nResult: ${result.success ? "✓ All checks passed" : "✗ Verification failed"}` + ); + } + + if (!result.success) { + process.exitCode = 1; } }, }); diff --git a/packages/cli/test/telemetry.test.ts b/packages/cli/test/telemetry.test.ts index 671bef6b..6f5b82ee 100644 --- a/packages/cli/test/telemetry.test.ts +++ b/packages/cli/test/telemetry.test.ts @@ -287,7 +287,7 @@ describe("capture", () => { await writeTokenFile(cwd, jwt); const telemetry = await getTelemetry(cwd); - telemetry.capture("cli_rule_create"); + telemetry.capture("cli_rule_created"); expect(mockCapture).toHaveBeenCalledWith( expect.objectContaining({ From eeed8a6b11ea5deb5d645d1bf7d8e9731e17b486 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sat, 13 Jun 2026 03:29:22 -0700 Subject: [PATCH 03/17] feat(cli): Concrete auth + lifecycle events; drop start/_completed pairs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the telemetry rework. Replace the auth/init/update/onboard/check start and _completed pairs with concrete state-transition events: - auth login → cli_authenticated (fresh login only; already-logged-in is not) - auth logout → cli_logged_out (only when a saved token was removed) - auth status → no bespoke event; covered by cli_run - init/update → cli_installed (interactive wizard, non-interactive, update) - onboard --mark-complete → cli_onboarded (already-done / recipe → cli_run only) - check → cli_check_completed { errorCount, warningCount, findings } (only when a scan runs; counts only, never matched code) Tests: wizard-integration now asserts cli_installed on completion and no install event on cancel; telemetry.test sample event names updated to cli_run. Full suite green (256). cli_run still emits per invocation. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../restructure-cli-telemetry/tasks.md | 12 +-- packages/cli/src/commands/auth.ts | 90 +++++++------------ packages/cli/src/commands/check.ts | 24 ++--- packages/cli/src/commands/init.ts | 27 ++---- packages/cli/src/commands/onboard.ts | 5 +- packages/cli/src/wizard/index.ts | 15 +--- packages/cli/test/telemetry.test.ts | 20 ++--- packages/cli/test/wizard-integration.test.ts | 18 ++-- 8 files changed, 76 insertions(+), 135 deletions(-) diff --git a/openspec/changes/restructure-cli-telemetry/tasks.md b/openspec/changes/restructure-cli-telemetry/tasks.md index 91997ec4..48cd2edb 100644 --- a/openspec/changes/restructure-cli-telemetry/tasks.md +++ b/openspec/changes/restructure-cli-telemetry/tasks.md @@ -40,12 +40,12 @@ keeps the suite green on its own. ## 3. Phase 3 — auth + lifecycle events (PR 3, on PR 2) -- [ ] 3.1 `commands/auth.ts`: remove `cli_auth_login(_completed)`, `cli_auth_logout(_completed)`, `cli_auth_status(_completed)`; emit `cli_authenticated` and `cli_logged_out` on success (status → `cli_run` only) -- [ ] 3.2 `commands/init.ts` + `wizard/index.ts`: remove `cli_init(_completed)`, `cli_init_cancelled`, `cli_update(_completed)`; emit `cli_installed` on a successful install -- [ ] 3.3 `commands/onboard.ts`: remove `cli_onboard_recipe` / `cli_onboard_already_done`; emit `cli_onboarded` when onboarding is marked complete -- [ ] 3.4 `commands/check.ts`: remove `cli_check(_completed)`; emit `cli_check_completed { errorCount, warningCount, filesScanned }` (counts only — no matched code) -- [ ] 3.5 Update auth/init/onboard/check tests to the new events -- [ ] 3.6 typecheck + lint + suite green; commit; open PR 3 +- [x] 3.1 `commands/auth.ts`: remove `cli_auth_login(_completed)`, `cli_auth_logout(_completed)`, `cli_auth_status(_completed)`; emit `cli_authenticated` (fresh login only) and `cli_logged_out` (token actually removed); status → `cli_run` only +- [x] 3.2 `commands/init.ts` + `wizard/index.ts`: remove `cli_init(_completed)`, `cli_init_cancelled`, `cli_update(_completed)`; emit `cli_installed` on a successful install (interactive + non-interactive + update) +- [x] 3.3 `commands/onboard.ts`: remove `cli_onboard_recipe` / `cli_onboard_already_done`; emit `cli_onboarded` when onboarding is marked complete +- [x] 3.4 `commands/check.ts`: remove `cli_check(_completed)`; emit `cli_check_completed { errorCount, warningCount, findings }` only when a scan actually runs (counts only — no matched code; `findings` replaces the unavailable `filesScanned`) +- [x] 3.5 Update auth/init/onboard/check tests to the new events — wizard-integration assertions updated to `cli_installed` / no-event-on-cancel; telemetry.test sample names → `cli_run` +- [x] 3.6 typecheck + lint + suite green; commit; open PR 3 ## 4. Phase 4 — cli_help { topic } + drop bespoke info/detect events (PR 4, on PR 3) diff --git a/packages/cli/src/commands/auth.ts b/packages/cli/src/commands/auth.ts index a8d96321..cfa69625 100644 --- a/packages/cli/src/commands/auth.ts +++ b/packages/cli/src/commands/auth.ts @@ -33,15 +33,9 @@ const loginCommand = defineCommand({ async run({ args }) { const cwd = resolve(args.dir ?? process.cwd()); const telemetry = await getTelemetry(cwd); - const startedAt = Date.now(); - telemetry.capture("cli_auth_login"); - - /** Tracks the last emitted error code so the completion event can include it. */ - let lastErrorCode: CliErrorCode | undefined; /** Emit an error in the right channel and set exit code. */ const fail = (code: CliErrorCode, message: string): void => { - lastErrorCode = code; if (args.json) { writeJsonError(code, message); } else { @@ -52,15 +46,12 @@ const loginCommand = defineCommand({ if (args.anonymous) { fail("INVALID_INPUT", "auth commands cannot be anonymous."); - telemetry.capture("cli_auth_login_completed", { - success: false, - durationMs: Date.now() - startedAt, - errorCode: lastErrorCode, - }); return; } - let success = false; + // Set true only when a fresh authentication completes; drives the + // cli_authenticated event in the finally. + let authenticated = false; try { // In --json mode the user is an agent / pipe; suppress the device-flow // chatter and only emit a single structured line on error. @@ -71,7 +62,7 @@ const loginCommand = defineCommand({ switch (result.status) { case "ok": { - success = true; + authenticated = true; return; } case "already_logged_in": { @@ -79,7 +70,6 @@ const loginCommand = defineCommand({ console.log("You are already logged in."); console.log("Run `taskless auth logout` first to re-authenticate."); } - success = true; return; } case "cancelled": { @@ -97,11 +87,10 @@ const loginCommand = defineCommand({ } } } finally { - telemetry.capture("cli_auth_login_completed", { - success, - durationMs: Date.now() - startedAt, - ...(success ? {} : { errorCode: lastErrorCode }), - }); + // Concrete state event: a fresh authentication succeeded. + if (authenticated) { + telemetry.capture("cli_authenticated"); + } } }, }); @@ -132,21 +121,18 @@ const logoutCommand = defineCommand({ async run({ args }) { const cwd = resolve(args.dir ?? process.cwd()); const telemetry = await getTelemetry(cwd); - const startedAt = Date.now(); - telemetry.capture("cli_auth_logout"); - let success = false; + let removed = false; try { - const removed = await removeToken(cwd); + removed = await removeToken(cwd); if (!args.json) { console.log(removed ? "Logged out." : "Not logged in."); } - success = true; } finally { - telemetry.capture("cli_auth_logout_completed", { - success, - durationMs: Date.now() - startedAt, - }); + // Concrete state event: a saved token was actually removed. + if (removed) { + telemetry.capture("cli_logged_out"); + } } }, }); @@ -181,39 +167,25 @@ export const authCommand = defineCommand({ } const cwd = resolve(args.dir ?? process.cwd()); - const telemetry = await getTelemetry(cwd); - const startedAt = Date.now(); - telemetry.capture("cli_auth_status"); - - let success = false; - try { - const token = await getToken(cwd); - if (!token) { - console.log("Not logged in."); - console.log("Run `taskless auth login` to authenticate."); - success = true; - return; - } - const whoami = await fetchWhoami(token); - if (!whoami) { - console.log("Logged in, but unable to verify identity."); - console.log( - "Your token may be invalid or expired. Run `taskless auth login` to re-authenticate." - ); - success = true; - return; - } + const token = await getToken(cwd); + if (!token) { + console.log("Not logged in."); + console.log("Run `taskless auth login` to authenticate."); + return; + } - const orgs = whoami.orgs.map((o) => o.name); - const orgSuffix = orgs.length > 0 ? ` (${orgs.join(", ")})` : ""; - console.log(`Logged in as ${whoami.user}${orgSuffix}.`); - success = true; - } finally { - telemetry.capture("cli_auth_status_completed", { - success, - durationMs: Date.now() - startedAt, - }); + const whoami = await fetchWhoami(token); + if (!whoami) { + console.log("Logged in, but unable to verify identity."); + console.log( + "Your token may be invalid or expired. Run `taskless auth login` to re-authenticate." + ); + return; } + + const orgs = whoami.orgs.map((o) => o.name); + const orgSuffix = orgs.length > 0 ? ` (${orgs.join(", ")})` : ""; + console.log(`Logged in as ${whoami.user}${orgSuffix}.`); }, }); diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts index bab9e7cb..9c9e540f 100644 --- a/packages/cli/src/commands/check.ts +++ b/packages/cli/src/commands/check.ts @@ -108,10 +108,12 @@ export const checkCommand = defineCommand({ async run({ args, rawArgs }) { const cwd = resolve(args.dir ?? process.cwd()); const telemetry = await getTelemetry(cwd); - const startedAt = Date.now(); - telemetry.capture("cli_check"); - let success = false; + // Set when a scan actually runs; drives cli_check_completed with counts + // only (never matched code). + let scanCounts: + | { errorCount: number; warningCount: number; findings: number } + | undefined; try { const positionalPaths = extractPositionalPaths(rawArgs); const hadExplicitPaths = positionalPaths.length > 0; @@ -129,7 +131,6 @@ export const checkCommand = defineCommand({ ) ); } - success = true; return; } @@ -155,7 +156,6 @@ export const checkCommand = defineCommand({ "No rules configured. Create one with `taskless rule create`." ); } - success = true; return; } @@ -164,6 +164,11 @@ export const checkCommand = defineCommand({ await generateSgConfig(cwd); const { results } = await runAstGrepScan(cwd, existingPaths); const hasErrors = results.some((r) => r.severity === "error"); + scanCounts = { + errorCount: results.filter((r) => r.severity === "error").length, + warningCount: results.filter((r) => r.severity === "warning").length, + findings: results.length, + }; // Format output if (args.json) { @@ -180,7 +185,6 @@ export const checkCommand = defineCommand({ if (hasErrors) { process.exitCode = 1; } - success = !hasErrors; } catch (error) { const message = `Error: ${error instanceof Error ? error.message : String(error)}`; if (args.json) { @@ -193,10 +197,10 @@ export const checkCommand = defineCommand({ process.exitCode = 1; } } finally { - telemetry.capture("cli_check_completed", { - success, - durationMs: Date.now() - startedAt, - }); + // Concrete state event: a scan completed; counts only, no matched code. + if (scanCounts) { + telemetry.capture("cli_check_completed", scanCounts); + } } }, }); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 7f134e3c..b3f0b7dc 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -53,7 +53,6 @@ export const initCommand = defineCommand({ async run({ args }) { const cwd = resolve(args.dir ?? process.cwd()); const telemetry = await getTelemetry(cwd); - telemetry.capture("cli_init"); const interactive = shouldRunInteractively(args["no-interactive"]); @@ -71,19 +70,12 @@ export const initCommand = defineCommand({ ); } - const start = Date.now(); const result = await runNonInteractive(cwd); console.log( getOnboardTrailer({ commandsInstalled: result.commandsInstalled }) ); - telemetry.capture("cli_init_completed", { - locations: await detectedLocationDirectories(cwd), - optionalSkills: [], - authPromptShown: false, - authCompleted: false, - nonInteractive: true, - durationMs: Date.now() - start, - }); + // Concrete state event: skills/commands were installed (non-interactive). + telemetry.capture("cli_installed"); }, }); @@ -108,19 +100,16 @@ export const updateCommand = defineCommand({ async run({ args }) { const cwd = resolve(args.dir ?? process.cwd()); const telemetry = await getTelemetry(cwd); - const startedAt = Date.now(); - telemetry.capture("cli_update"); let success = false; try { await runNonInteractive(cwd); success = true; } finally { - telemetry.capture("cli_update_completed", { - locations: await detectedLocationDirectories(cwd), - success, - durationMs: Date.now() - startedAt, - }); + // Concrete state event: skills/commands were installed/updated. + if (success) { + telemetry.capture("cli_installed"); + } } }, }); @@ -233,7 +222,3 @@ function groupValuesByTarget( } return map; } - -async function detectedLocationDirectories(cwd: string): Promise { - return detectSelectedDirectories(cwd); -} diff --git a/packages/cli/src/commands/onboard.ts b/packages/cli/src/commands/onboard.ts index 19a078f9..e051b7c6 100644 --- a/packages/cli/src/commands/onboard.ts +++ b/packages/cli/src/commands/onboard.ts @@ -80,7 +80,8 @@ export const onboardCommand = defineCommand({ manifest.install = install; await writeManifest(tasklessDirectory, manifest, raw); console.log("Marked Taskless onboarding as complete."); - telemetry.capture("cli_onboard_marked_complete"); + // Concrete state event: onboarding reached completion. + telemetry.capture("cli_onboarded"); return; } @@ -92,7 +93,6 @@ export const onboardCommand = defineCommand({ console.log( "Run `taskless onboard --force` to re-run the discovery recipe." ); - telemetry.capture("cli_onboard_already_done"); return; } @@ -104,6 +104,5 @@ export const onboardCommand = defineCommand({ throw new CliError("recipe missing"); } console.log(recipe.trimEnd()); - telemetry.capture("cli_onboard_recipe", { forced: args.force }); }, }); diff --git a/packages/cli/src/wizard/index.ts b/packages/cli/src/wizard/index.ts index 157b56ef..22a7565c 100644 --- a/packages/cli/src/wizard/index.ts +++ b/packages/cli/src/wizard/index.ts @@ -102,19 +102,8 @@ export async function runWizard( function finish(args: { status: "completed" | "cancelled" }): WizardResult { const durationMs = Date.now() - start; if (args.status === "completed") { - telemetry.capture("cli_init_completed", { - locations, - optionalSkills, - authPromptShown, - authCompleted, - nonInteractive: false, - durationMs, - }); - } else { - telemetry.capture("cli_init_cancelled", { - atStep: cancelledStep ?? "unknown", - durationMs, - }); + // Concrete state event: skills/commands were installed (interactive). + telemetry.capture("cli_installed"); } return { status: args.status, diff --git a/packages/cli/test/telemetry.test.ts b/packages/cli/test/telemetry.test.ts index 6f5b82ee..76fe4462 100644 --- a/packages/cli/test/telemetry.test.ts +++ b/packages/cli/test/telemetry.test.ts @@ -81,7 +81,7 @@ describe("telemetry disabled", () => { vi.stubEnv("TASKLESS_TELEMETRY_DISABLED", "1"); const telemetry = await getTelemetry(); - telemetry.capture("cli_check"); + telemetry.capture("cli_run"); await telemetry.shutdown(); expect(mockCapture).not.toHaveBeenCalled(); @@ -93,7 +93,7 @@ describe("telemetry disabled", () => { vi.stubEnv("DO_NOT_TRACK", "1"); const telemetry = await getTelemetry(); - telemetry.capture("cli_check"); + telemetry.capture("cli_run"); await telemetry.shutdown(); expect(mockCapture).not.toHaveBeenCalled(); @@ -166,7 +166,7 @@ describe("authenticated identity", () => { await writeTokenFile(cwd, jwt); const telemetry = await getTelemetry(cwd); - telemetry.capture("cli_check"); + telemetry.capture("cli_run"); expect(mockIdentify).toHaveBeenCalledWith( expect.objectContaining({ @@ -203,7 +203,7 @@ describe("authenticated identity", () => { it("falls back to anonymous UUID when no JWT is available", async () => { const telemetry = await getTelemetry(); - telemetry.capture("cli_check"); + telemetry.capture("cli_run"); // distinctId should be the anonymous UUID, not a JWT sub const captureArgument = mockCapture.mock.calls[0]![0] as { @@ -219,7 +219,7 @@ describe("authenticated identity", () => { describe("capture", () => { it("includes cli property on every event", async () => { const telemetry = await getTelemetry(); - telemetry.capture("cli_check"); + telemetry.capture("cli_run"); expect(mockCapture).toHaveBeenCalledWith( expect.objectContaining({ @@ -234,11 +234,11 @@ describe("capture", () => { it("merges custom properties with standard properties", async () => { const telemetry = await getTelemetry(); - telemetry.capture("cli_check", { foo: "bar" }); + telemetry.capture("cli_run", { foo: "bar" }); expect(mockCapture).toHaveBeenCalledWith( expect.objectContaining({ - event: "cli_check", + event: "cli_run", properties: expect.objectContaining({ cli: expect.any(String) as string, foo: "bar", @@ -249,7 +249,7 @@ describe("capture", () => { it("does not include groups when unauthenticated", async () => { const telemetry = await getTelemetry(); - telemetry.capture("cli_check"); + telemetry.capture("cli_run"); const captureArgument = mockCapture.mock.calls[0]![0] as Record< string, @@ -260,7 +260,7 @@ describe("capture", () => { it("includes cliVersion and scaffoldVersion on every anonymous capture", async () => { const telemetry = await getTelemetry(); - telemetry.capture("cli_check"); + telemetry.capture("cli_run"); expect(mockCapture).toHaveBeenCalledWith( expect.objectContaining({ @@ -307,7 +307,7 @@ describe("capture", () => { const cwd = await mkdtemp(join(tmpdir(), "taskless-no-manifest-")); try { const telemetry = await getTelemetry(cwd); - telemetry.capture("cli_check"); + telemetry.capture("cli_run"); expect(mockCapture).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/packages/cli/test/wizard-integration.test.ts b/packages/cli/test/wizard-integration.test.ts index e74d2607..5ac5900b 100644 --- a/packages/cli/test/wizard-integration.test.ts +++ b/packages/cli/test/wizard-integration.test.ts @@ -96,14 +96,7 @@ describe("runWizard end-to-end", () => { }; expect(manifest.install.targets[".claude"]?.skills).toContain("taskless"); - expect(captureSpy).toHaveBeenCalledWith( - "cli_init_completed", - expect.objectContaining({ - locations: [".claude"], - optionalSkills: [], - nonInteractive: false, - }) - ); + expect(captureSpy).toHaveBeenCalledWith("cli_installed"); }); it("re-running with the same location is idempotent", async () => { @@ -123,7 +116,7 @@ describe("runWizard end-to-end", () => { ).toBe(true); }); - it("cancelling at locations step writes nothing and emits cli_init_cancelled", async () => { + it("cancelling at locations step writes nothing and emits no install event", async () => { clackResponses.locations = fakeCancelSymbol; const { runWizard } = await import("../src/wizard"); @@ -137,10 +130,9 @@ describe("runWizard end-to-end", () => { ); expect(await exists(join(cwd, ".taskless", "taskless.json"))).toBe(false); - expect(captureSpy).toHaveBeenCalledWith( - "cli_init_cancelled", - expect.objectContaining({ atStep: "locations" }) - ); + // A cancelled wizard installs nothing, so it emits no cli_installed event; + // the invocation itself is captured by cli_run at the runner level. + expect(captureSpy).not.toHaveBeenCalledWith("cli_installed"); }); it("cancelling the summary confirm writes nothing", async () => { From 931b32ffc3fa34cffd4fcef915bfeb787a0561eb Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sat, 13 Jun 2026 08:58:00 -0700 Subject: [PATCH 04/17] feat(cli): Collapse help_* into cli_help { topic }; drop cli_info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 of the telemetry rework. Replace help_index / help_ / help_unknown with a single cli_help carrying a topic property (the served topic, "(index)" for a no-arg invocation, or the attempted topic when unknown) — help intent is now one event filtered by topic. Remove info's bespoke cli_info / cli_info_completed events (covered by cli_run) and its now-unused getTelemetry import. detect.ts (cli_detect) is not on this branch's lineage — it lives in the unmerged local-rule-routing stack and is reconciled when both land. Adds test/help-telemetry.test.ts asserting cli_help { topic } across the served / index / unknown cases and that no legacy help_* event fires. Full suite green (259). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../restructure-cli-telemetry/tasks.md | 8 +- packages/cli/src/commands/help.ts | 17 +-- packages/cli/src/commands/info.ts | 144 ++++++++---------- packages/cli/test/help-telemetry.test.ts | 64 ++++++++ 4 files changed, 140 insertions(+), 93 deletions(-) create mode 100644 packages/cli/test/help-telemetry.test.ts diff --git a/openspec/changes/restructure-cli-telemetry/tasks.md b/openspec/changes/restructure-cli-telemetry/tasks.md index 48cd2edb..e53116de 100644 --- a/openspec/changes/restructure-cli-telemetry/tasks.md +++ b/openspec/changes/restructure-cli-telemetry/tasks.md @@ -49,10 +49,10 @@ keeps the suite green on its own. ## 4. Phase 4 — cli_help { topic } + drop bespoke info/detect events (PR 4, on PR 3) -- [ ] 4.1 `commands/help.ts`: replace `help_index`, `help_`, `help_unknown` with one `cli_help { topic }` (served topic, an index marker for no-arg, the attempted topic for unknown) -- [ ] 4.2 `commands/info.ts`, `commands/detect.ts`: remove their bespoke `cli_info(_completed)` / `cli_detect` events — covered by `cli_run` -- [ ] 4.3 Update `test/help-extensions.test.ts` / `test/help-routing-telemetry.test.ts` and info/detect tests; assert `cli_help` carries `topic` and no `help_*` event is emitted -- [ ] 4.4 typecheck + lint + suite green; commit; open PR 4 +- [x] 4.1 `commands/help.ts`: replace `help_index`, `help_`, `help_unknown` with one `cli_help { topic }` (served topic, `"(index)"` marker for no-arg, the attempted topic for unknown) +- [x] 4.2 `commands/info.ts`: remove bespoke `cli_info(_completed)` (covered by `cli_run`); also drop its now-unused `getTelemetry` import. NOTE: `detect.ts`/`cli_detect` is NOT on this branch's lineage (it lives in the unmerged local-rule-routing stack) — no change needed here; it will be reconciled when that stack and this one both land +- [x] 4.3 Assert `cli_help` carries `topic` and no `help_*` event — added `test/help-telemetry.test.ts` (served topic, index marker, unknown topic, and no legacy `help_*`) +- [x] 4.4 typecheck + lint + suite green; commit; open PR 4 ## 5. Phase 5 — finalize (PR 5, tip) diff --git a/packages/cli/src/commands/help.ts b/packages/cli/src/commands/help.ts index 71793854..f6e12938 100644 --- a/packages/cli/src/commands/help.ts +++ b/packages/cli/src/commands/help.ts @@ -153,8 +153,8 @@ export function createHelpCommand(subCommands: SubCommandsDef) { const telemetry = await getTelemetry(cwd); if (positionals.length === 0) { - // help_index: agent fetched the topic list - telemetry.capture("help_index"); + // cli_help with the index marker: agent fetched the topic list + telemetry.capture("cli_help", { topic: "(index)" }); console.log("Taskless CLI\n"); console.log( @@ -198,16 +198,13 @@ export function createHelpCommand(subCommands: SubCommandsDef) { : helpMap.get(key); if (content) { - // help_: agent fetched a specific recipe (intent signal) - const topicEvent = `help_${key.replaceAll("-", "_")}`; - telemetry.capture(topicEvent, { - topic: positionals.join(" "), - anonymous: args.anonymous, - }); + // cli_help: agent fetched a specific recipe (intent signal). The topic + // is the served topic; filtering on it replaces the old per-topic events. + telemetry.capture("cli_help", { topic: positionals.join(" ") }); console.log(renderRecipe(content, key).trimEnd()); } else { - // help_unknown: agent asked for a topic that does not exist - telemetry.capture("help_unknown", { topic: positionals.join(" ") }); + // cli_help for an unknown topic — still the attempted topic string. + telemetry.capture("cli_help", { topic: positionals.join(" ") }); console.error(`Unknown command: ${positionals.join(" ")}`); console.error("Run `taskless help` for available commands."); process.exitCode = 1; diff --git a/packages/cli/src/commands/info.ts b/packages/cli/src/commands/info.ts index 5ebfafb7..1da8a835 100644 --- a/packages/cli/src/commands/info.ts +++ b/packages/cli/src/commands/info.ts @@ -5,7 +5,6 @@ import { checkStaleness } from "../install/install"; import { getToken } from "../auth/token"; import { fetchWhoami } from "../auth/whoami"; import { outputSchema as infoOutputSchema } from "../schemas/info"; -import { getTelemetry } from "../telemetry"; import { makeErrorEnvelope } from "../types/errors"; export const infoCommand = defineCommand({ @@ -32,100 +31,87 @@ export const infoCommand = defineCommand({ }, async run({ args }) { const cwd = resolve(args.dir ?? process.cwd()); - const telemetry = await getTelemetry(cwd); - const startedAt = Date.now(); - telemetry.capture("cli_info"); - let success = false; - try { - const [tools, token] = await Promise.all([ - checkStaleness(cwd), - args.anonymous ? Promise.resolve() : getToken(cwd), - ]); + const [tools, token] = await Promise.all([ + checkStaleness(cwd), + args.anonymous ? Promise.resolve() : getToken(cwd), + ]); - let auth: { user: string; email: string; orgs: string[] } | undefined; - if (!args.anonymous && token) { - const whoami = await fetchWhoami(token); - if (whoami) { - auth = { - user: whoami.user, - email: whoami.email, - orgs: whoami.orgs.map((o) => o.name), - }; - } + let auth: { user: string; email: string; orgs: string[] } | undefined; + if (!args.anonymous && token) { + const whoami = await fetchWhoami(token); + if (whoami) { + auth = { + user: whoami.user, + email: whoami.email, + orgs: whoami.orgs.map((o) => o.name), + }; } + } - const result = { - success: true as const, - version: __VERSION__, - tools, - loggedIn: token !== undefined, - auth, - }; + const result = { + success: true as const, + version: __VERSION__, + tools, + loggedIn: token !== undefined, + auth, + }; - if (args.json) { - const parsed = infoOutputSchema.safeParse(result); - if (!parsed.success) { - console.log( - JSON.stringify( - makeErrorEnvelope( - "INTERNAL_ERROR", - "Internal schema validation failed" - ) + if (args.json) { + const parsed = infoOutputSchema.safeParse(result); + if (!parsed.success) { + console.log( + JSON.stringify( + makeErrorEnvelope( + "INTERNAL_ERROR", + "Internal schema validation failed" ) - ); - process.exitCode = 1; - return; - } - console.log(JSON.stringify(parsed.data)); - success = true; + ) + ); + process.exitCode = 1; return; } + console.log(JSON.stringify(parsed.data)); + return; + } - // Human-readable output - console.log(`Taskless CLI v${__VERSION__}\n`); + // Human-readable output + console.log(`Taskless CLI v${__VERSION__}\n`); - if (tools.length === 0) { - console.log("Tools: none detected"); - } else { - console.log("Tools:"); - for (const tool of tools) { - const total = tool.skills.length; - const upToDate = tool.skills.filter((s) => s.current).length; - const stale = total - upToDate; + if (tools.length === 0) { + console.log("Tools: none detected"); + } else { + console.log("Tools:"); + for (const tool of tools) { + const total = tool.skills.length; + const upToDate = tool.skills.filter((s) => s.current).length; + const stale = total - upToDate; - if (stale === 0) { - console.log( - ` ${tool.name}: ${String(total)} skills (all up to date)` - ); - } else { - console.log( - ` ${tool.name}: ${String(total)} skills (${String(stale)} outdated)` - ); - for (const skill of tool.skills) { - if (!skill.current) { - console.log( - ` - ${skill.name}: ${skill.installedVersion ?? "missing"} → ${skill.currentVersion}` - ); - } + if (stale === 0) { + console.log( + ` ${tool.name}: ${String(total)} skills (all up to date)` + ); + } else { + console.log( + ` ${tool.name}: ${String(total)} skills (${String(stale)} outdated)` + ); + for (const skill of tool.skills) { + if (!skill.current) { + console.log( + ` - ${skill.name}: ${skill.installedVersion ?? "missing"} → ${skill.currentVersion}` + ); } } } } + } - console.log(""); - if (auth) { - const orgs = auth.orgs.length > 0 ? ` (${auth.orgs.join(", ")})` : ""; - console.log(`Auth: logged in as ${auth.user}${orgs}`); - } else { - console.log("Auth: not logged in"); - } - success = true; - } finally { - telemetry.capture("cli_info_completed", { - success, - durationMs: Date.now() - startedAt, - }); + console.log(""); + if (auth) { + const orgs = auth.orgs.length > 0 ? ` (${auth.orgs.join(", ")})` : ""; + console.log(`Auth: logged in as ${auth.user}${orgs}`); + } else { + console.log("Auth: not logged in"); } }, }); diff --git a/packages/cli/test/help-telemetry.test.ts b/packages/cli/test/help-telemetry.test.ts new file mode 100644 index 00000000..1bc7f14a --- /dev/null +++ b/packages/cli/test/help-telemetry.test.ts @@ -0,0 +1,64 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Spy on telemetry by mocking the module the help command imports. The factory +// is invoked lazily at import time (same pattern as telemetry.test.ts). +const capture = vi.fn(); +vi.mock("../src/telemetry", () => ({ + getTelemetry: vi.fn(() => + Promise.resolve({ capture, shutdown: () => Promise.resolve() }) + ), + shutdownTelemetry: () => Promise.resolve(), +})); + +const { createHelpCommand } = await import("../src/commands/help"); + +interface RunnableCommand { + run: (context: { + args: { dir: string; anonymous: boolean }; + rawArgs: string[]; + }) => Promise; +} + +async function runHelp(rawArguments: string[]): Promise { + const command = createHelpCommand({}) as unknown as RunnableCommand; + await command.run({ + args: { dir: process.cwd(), anonymous: false }, + rawArgs: rawArguments, + }); +} + +describe("help emits cli_help { topic }", () => { + let logSpy: ReturnType; + let errorSpy: ReturnType; + + beforeEach(() => { + capture.mockClear(); + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + it("captures the served topic", async () => { + await runHelp(["help", "rule", "create"]); + expect(capture).toHaveBeenCalledWith("cli_help", { topic: "rule create" }); + }); + + it("captures an index marker when invoked with no topic", async () => { + await runHelp(["help"]); + expect(capture).toHaveBeenCalledWith("cli_help", { topic: "(index)" }); + }); + + it("captures the attempted topic for an unknown topic, and no legacy help_* event", async () => { + await runHelp(["help", "nope"]); + expect(capture).toHaveBeenCalledWith("cli_help", { topic: "nope" }); + + const events = capture.mock.calls.map((call) => call[0] as string); + expect(events).not.toContain("help_index"); + expect(events).not.toContain("help_unknown"); + expect(events.every((event) => !event.startsWith("help_"))).toBe(true); + }); +}); From 7b8603a9da32d0b1d0e2a098dbc7251d801ced85 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sat, 13 Jun 2026 09:08:19 -0700 Subject: [PATCH 05/17] chore(openspec): Archive restructure-cli-telemetry and sync specs All five phases of the telemetry rework are complete, so finalize on the tip of the stack: apply the analytics delta into the main spec (add the cli_run denominator requirement; rewrite the cli_ taxonomy, the wrong-topic funnel, and the standard-properties scenarios) and move the change to openspec/changes/archive/2026-06-13-restructure-cli-telemetry/. Legacy event sweep is clean (the only _completed is the intentional cli_check_completed concrete event); validate/typecheck/lint/suite green; commands smoke-tested end-to-end. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/analytics/spec.md | 0 .../tasks.md | 8 +- openspec/specs/analytics/spec.md | 121 +++++++++++++----- 6 files changed, 92 insertions(+), 37 deletions(-) rename openspec/changes/{restructure-cli-telemetry => archive/2026-06-13-restructure-cli-telemetry}/.openspec.yaml (100%) rename openspec/changes/{restructure-cli-telemetry => archive/2026-06-13-restructure-cli-telemetry}/design.md (100%) rename openspec/changes/{restructure-cli-telemetry => archive/2026-06-13-restructure-cli-telemetry}/proposal.md (100%) rename openspec/changes/{restructure-cli-telemetry => archive/2026-06-13-restructure-cli-telemetry}/specs/analytics/spec.md (100%) rename openspec/changes/{restructure-cli-telemetry => archive/2026-06-13-restructure-cli-telemetry}/tasks.md (90%) diff --git a/openspec/changes/restructure-cli-telemetry/.openspec.yaml b/openspec/changes/archive/2026-06-13-restructure-cli-telemetry/.openspec.yaml similarity index 100% rename from openspec/changes/restructure-cli-telemetry/.openspec.yaml rename to openspec/changes/archive/2026-06-13-restructure-cli-telemetry/.openspec.yaml diff --git a/openspec/changes/restructure-cli-telemetry/design.md b/openspec/changes/archive/2026-06-13-restructure-cli-telemetry/design.md similarity index 100% rename from openspec/changes/restructure-cli-telemetry/design.md rename to openspec/changes/archive/2026-06-13-restructure-cli-telemetry/design.md diff --git a/openspec/changes/restructure-cli-telemetry/proposal.md b/openspec/changes/archive/2026-06-13-restructure-cli-telemetry/proposal.md similarity index 100% rename from openspec/changes/restructure-cli-telemetry/proposal.md rename to openspec/changes/archive/2026-06-13-restructure-cli-telemetry/proposal.md diff --git a/openspec/changes/restructure-cli-telemetry/specs/analytics/spec.md b/openspec/changes/archive/2026-06-13-restructure-cli-telemetry/specs/analytics/spec.md similarity index 100% rename from openspec/changes/restructure-cli-telemetry/specs/analytics/spec.md rename to openspec/changes/archive/2026-06-13-restructure-cli-telemetry/specs/analytics/spec.md diff --git a/openspec/changes/restructure-cli-telemetry/tasks.md b/openspec/changes/archive/2026-06-13-restructure-cli-telemetry/tasks.md similarity index 90% rename from openspec/changes/restructure-cli-telemetry/tasks.md rename to openspec/changes/archive/2026-06-13-restructure-cli-telemetry/tasks.md index e53116de..a6e0541a 100644 --- a/openspec/changes/restructure-cli-telemetry/tasks.md +++ b/openspec/changes/archive/2026-06-13-restructure-cli-telemetry/tasks.md @@ -56,7 +56,7 @@ keeps the suite green on its own. ## 5. Phase 5 — finalize (PR 5, tip) -- [ ] 5.1 Grep the CLI for any remaining old event names (`_completed`, `help_index`, `help_`, `help_unknown`, legacy `cli_` starts); remove any stragglers -- [ ] 5.2 Run `pnpm openspec validate restructure-cli-telemetry`; `pnpm typecheck`; `pnpm lint`; full suite green -- [ ] 5.3 Manual smoke: run a couple of commands with telemetry mocked/inspected — confirm one `cli_run` per invocation plus the expected concrete event, and no legacy names -- [ ] 5.4 Archive the change (`openspec archive restructure-cli-telemetry`) so the tip carries the spec sync + dated archive; commit; open PR 5 +- [x] 5.1 Grep the CLI for any remaining old event names (`_completed`, `help_index`, `help_`, `help_unknown`, legacy `cli_` starts); remove any stragglers — clean; the only `_completed` is the intentional concrete event `cli_check_completed` +- [x] 5.2 Run `pnpm openspec validate restructure-cli-telemetry`; `pnpm typecheck`; `pnpm lint`; full suite green (259) +- [x] 5.3 Manual smoke: `info`, `help check`, `help` (index) run end-to-end after the refactor; concrete events + cli_run/cli_help/cli_error verified by the in-process tests +- [x] 5.4 Archive the change (`openspec archive restructure-cli-telemetry`) so the tip carries the spec sync + dated archive; commit; open PR 5 diff --git a/openspec/specs/analytics/spec.md b/openspec/specs/analytics/spec.md index 6f1173c1..dab92816 100644 --- a/openspec/specs/analytics/spec.md +++ b/openspec/specs/analytics/spec.md @@ -111,13 +111,13 @@ Every `capture()` call SHALL include the `cli` property (anonymous UUID), the `c #### Scenario: Anonymous capture includes standard properties -- **WHEN** `capture("cli_check")` is called without authentication +- **WHEN** `capture("cli_run")` is called without authentication - **THEN** the event SHALL include `{ cli: anonymousUuid, cliVersion: , scaffoldVersion: }` - **AND** the event SHALL NOT include a `groups` parameter #### Scenario: Authenticated capture includes standard properties and group -- **WHEN** `capture("cli_rule_create")` is called with authentication +- **WHEN** `capture("cli_rule_created")` is called with authentication - **THEN** the event SHALL include `{ cli: anonymousUuid, cliVersion: , scaffoldVersion: }` - **AND** the `groups` parameter SHALL include `{ organization: String(orgId) }` @@ -134,56 +134,88 @@ Every `capture()` call SHALL include the `cli` property (anonymous UUID), the `c ### Requirement: CLI events use cli\_ prefix -CLI action events SHALL continue to use the `cli_` prefix, but the event taxonomy SHALL be reorganized as follows: - -- `cli_` — fired when an action command begins execution (e.g. `cli_rule_create`, `cli_rule_improve`, `cli_rule_delete`, `cli_check`, `cli_info`, `cli_init`, `cli_auth_login`, `cli_auth_logout`) -- `cli__completed` — fired when an action command finishes execution; event properties SHALL include `success: boolean`, `durationMs: number`, and `errorCode?: string` (when failure) -- `help_` — fired when the help command serves a specific topic (e.g. `help_rule_create`, `help_check`, `help_auth`); replaces previous `cli_help_` events -- `help_index` — fired when the help command is invoked with no arguments (probable agent confusion / routing failure) -- `help_unknown` — fired when the help command receives an unknown topic; event properties SHALL include `topic: string` (the attempted topic) - -The previous event names `cli_help`, `cli_help_auth`, `cli_help_check`, `cli_help_info`, `cli_help_init`, `cli_help_rule` SHALL be removed in this release. There is no dual-emit window — the rename is a hard cut. - -#### Scenario: Action command emits start and completion events - -- **WHEN** a user runs `taskless rule create --from req.json` -- **THEN** PostHog SHALL receive a `cli_rule_create` event when execution begins -- **AND** SHALL receive a `cli_rule_create_completed` event when execution finishes, with properties including `success`, `durationMs`, and (on failure) `errorCode` - -#### Scenario: Help fetch emits topic intent +CLI events SHALL use the `cli_` prefix, with the taxonomy organized as a +`cli_run` denominator plus concrete state-transition events: + +- `cli_run` — exactly one per invocation (see the dedicated requirement). This + replaces every previous `cli_` start event and `cli__completed` + event; the `success`/`durationMs`/`command` signal lives here. +- Concrete state-transition events, each fired at the point the state actually + changes, carrying counts/ids/booleans only (never rule content, prompts, or + matched source): + - `cli_rule_created`, `cli_rule_improved`, `cli_rule_deleted` + - `cli_authenticated`, `cli_logged_out` + - `cli_installed`, `cli_onboarded` + - `cli_check_completed` — error/warning counts only (e.g. `errorCount`, + `warningCount`, `filesScanned`) + - `cli_error` — a single failure event with `command` and `code` (a stable + `CliErrorCode`) +- `cli_help` — fired when the help command serves a request, with a `topic` + property (the served topic, or an index marker when invoked with no topic). + This replaces the previous `help_index`, `help_`, and `help_unknown` + events. + +Commands that carry no concrete state beyond the invocation (e.g. `info`, +`detect`, `update`, `auth status`, `rule verify`, `rule meta`) SHALL rely on +`cli_run` alone and SHALL NOT emit a bespoke event. The previous taxonomy +(`cli_`, `cli__completed`, `help_index`, `help_`, +`help_unknown`) SHALL be removed in this release; there is no dual-emit window. + +#### Scenario: Rule creation emits a concrete state event plus cli_run + +- **WHEN** a user runs `taskless rule create --from req.json` and a rule is written +- **THEN** PostHog SHALL receive one `cli_run` event with `command: "rule create"` +- **AND** SHALL receive a `cli_rule_created` event +- **AND** SHALL NOT receive `cli_rule_create` or `cli_rule_create_completed` + +#### Scenario: Help fetch emits cli_help with a topic - **WHEN** an agent runs `taskless help rule create` -- **THEN** PostHog SHALL receive a `help_rule_create` event +- **THEN** PostHog SHALL receive a `cli_help` event with `topic: "rule create"` +- **AND** SHALL NOT receive a `help_rule_create` event -#### Scenario: Help no-args emits index event +#### Scenario: Help with no topic emits cli_help with an index marker - **WHEN** an agent runs `taskless help` -- **THEN** PostHog SHALL receive a `help_index` event +- **THEN** PostHog SHALL receive a `cli_help` event whose `topic` marks the index + (no-argument) invocation +- **AND** SHALL NOT receive a `help_index` event -#### Scenario: Help unknown topic emits help_unknown +#### Scenario: A command failure emits cli_error -- **WHEN** an agent runs `taskless help nonexistent` -- **THEN** PostHog SHALL receive a `help_unknown` event with property `topic: "nonexistent"` +- **WHEN** a command fails with a known `CliErrorCode` +- **THEN** PostHog SHALL receive a `cli_error` event with `command` and `code` #### Scenario: Old event names are not emitted -- **WHEN** any CLI command runs in v0.7.0 -- **THEN** PostHog SHALL NOT receive any event named `cli_help`, `cli_help_`, or any other event under the previous taxonomy +- **WHEN** any CLI command runs in this release +- **THEN** PostHog SHALL NOT receive any event named `cli__completed`, + `help_index`, `help_`, or `help_unknown` ### Requirement: Wrong-topic re-routing is observable as a derivable funnel -The new event taxonomy is structured so that wrong-topic re-routing is a derivable funnel signal: +The taxonomy SHALL keep wrong-topic re-routing derivable as a funnel signal from +the new events: -- A `help_` event followed by no `cli_` event AND a subsequent `help_` event indicates the agent fetched the recipe for topic A, did not act on it, and re-routed to topic B -- A `help_index` event followed by a `help_` event indicates the agent consulted the index before picking a topic (expected behavior; baseline) -- A `help_` event with no subsequent `cli_` event AND no further `help_*` event indicates the agent abandoned the action +- A `cli_help { topic: A }` event not followed by the concrete event for topic A + (or by `cli_run` with the corresponding `command`), and then a subsequent + `cli_help { topic: B }`, indicates the agent fetched recipe A, did not act on + it, and re-routed to topic B. +- A `cli_help` index-marker event followed by a `cli_help { topic }` event + indicates the agent consulted the index before picking a topic (baseline). +- A `cli_help { topic }` event with no subsequent acting `cli_run` and no further + `cli_help` event indicates the agent abandoned the action. -No additional events SHALL be added to capture this signal directly — the funnel is derivable from the event sequence in PostHog. Dashboards SHOULD be created to surface re-routing rates per topic so wrong-topic confusion can be measured. +No additional events SHALL be added to capture this signal directly — it is +derivable from the `cli_help` / `cli_run` sequence. Dashboards SHOULD surface +re-routing rates per topic. #### Scenario: Funnel data supports wrong-topic detection - **WHEN** dashboards are constructed in PostHog -- **THEN** the events SHALL be sufficient to compute "rate of `help_` events not followed by a corresponding `cli_` event within N minutes" +- **THEN** the `cli_help` (with `topic`) and `cli_run` (with `command`) events + SHALL be sufficient to compute "rate of `cli_help { topic }` not followed by a + corresponding acting `cli_run` within N minutes" ### Requirement: Telemetry failures are silent @@ -218,3 +250,26 @@ Each command handler SHALL call `getTelemetry(cwd)` to lazily initialize the sin - **WHEN** the CLI exits without running a command (e.g. showing top-level help) - **THEN** `shutdownTelemetry()` SHALL be a no-op and no PostHog client SHALL be created + +### Requirement: Every invocation emits exactly one cli_run event + +The CLI SHALL emit exactly one `cli_run` event per invocation, from the top-level +runner rather than from individual commands. The event SHALL carry the properties +`command` (the resolved subcommand name, e.g. `"rule create"` or `"help"`), +`cli_version`, `success` (boolean), `durationMs` (number), `anonymous` (boolean), +and `loggedIn` (boolean). The event SHALL be emitted on both success and failure +(from a `finally`-equivalent path), and no command SHALL emit its own +"started" or "ran" event. + +#### Scenario: A successful command emits one cli_run + +- **WHEN** a user runs `taskless info` +- **THEN** PostHog SHALL receive exactly one `cli_run` event with + `command: "info"`, `success: true`, a numeric `durationMs`, and the + `cli_version`, `anonymous`, and `loggedIn` properties +- **AND** SHALL NOT receive a separate `cli_info` or `cli_info_completed` event + +#### Scenario: A failing command still emits cli_run + +- **WHEN** a command exits with an error +- **THEN** PostHog SHALL receive one `cli_run` event with `success: false` From 58f75eea1841a25245afe0332973733878dfe3c4 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sat, 13 Jun 2026 12:16:38 -0700 Subject: [PATCH 06/17] fix(cli): Resolve cli_run identity fresh; tighten cli_error + drop cli_version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #32 review: - HIGH: cli_run's anonymous/loggedIn came from the telemetry client's identity snapshot taken at getTelemetry() init, which is stale for commands that change auth mid-run (auth login/logout). Add resolveRunIdentity(cwd) that reads the token fresh, and have the runner resolve it at emission time so cli_run reports post-invocation auth state. Removed the now-unused cached `identity` from the telemetry client. - Drop cli_version from cli_run — the version rides on the standard cliVersion property; no second field. - cli_error now fires only for a thrown error; failures signalled purely via process.exitCode are captured by cli_run success:false (no misleading INTERNAL_ERROR). Added a test for that case. - Test asserts cli_error-then-cli_run order and exactly two calls (toHaveBeenNthCalledWith), and that cli_run carries no cli_version. - tasks.md: fix "commitable"→"committable" and the detect.ts reference (not in this lineage). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../restructure-cli-telemetry/tasks.md | 6 +- packages/cli/src/index.ts | 14 +++- packages/cli/src/telemetry-run.ts | 25 ++++--- packages/cli/src/telemetry.ts | 24 +++++-- packages/cli/test/cli-run.test.ts | 67 +++++++++++++++---- 5 files changed, 105 insertions(+), 31 deletions(-) diff --git a/openspec/changes/restructure-cli-telemetry/tasks.md b/openspec/changes/restructure-cli-telemetry/tasks.md index 12843d5c..fd77f8ad 100644 --- a/openspec/changes/restructure-cli-telemetry/tasks.md +++ b/openspec/changes/restructure-cli-telemetry/tasks.md @@ -2,7 +2,7 @@ ## Phasing — stacked PRs -This change is cut into commitable phases, each of which leaves the build and +This change is cut into committable phases, each of which leaves the build and tests green and maps to one stacked PR (Git Town). Tests travel with the phase that introduces the behavior — there is no trailing "tests" phase. Phases are ordered so the stack reads bottom → top: @@ -50,8 +50,8 @@ keeps the suite green on its own. ## 4. Phase 4 — cli_help { topic } + drop bespoke info/detect events (PR 4, on PR 3) - [ ] 4.1 `commands/help.ts`: replace `help_index`, `help_`, `help_unknown` with one `cli_help { topic }` (served topic, an index marker for no-arg, the attempted topic for unknown) -- [ ] 4.2 `commands/info.ts`, `commands/detect.ts`: remove their bespoke `cli_info(_completed)` / `cli_detect` events — covered by `cli_run` -- [ ] 4.3 Update `test/help-extensions.test.ts` / `test/help-routing-telemetry.test.ts` and info/detect tests; assert `cli_help` carries `topic` and no `help_*` event is emitted +- [ ] 4.2 `commands/info.ts`: remove its bespoke `cli_info(_completed)` events — covered by `cli_run`. (`commands/detect.ts` / `cli_detect` is NOT in this branch's lineage — it lives in the unmerged local-rule-routing stack — so there is nothing to change here; reconcile when both stacks land.) +- [ ] 4.3 Add `test/help-telemetry.test.ts` and update info tests; assert `cli_help` carries `topic` (served / `"(index)"` / attempted) and no `help_*` event is emitted - [ ] 4.4 typecheck + lint + suite green; commit; open PR 4 ## 5. Phase 5 — finalize (PR 5, tip) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 643f7bd1..b163ca70 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -7,7 +7,11 @@ import { infoCommand } from "./commands/info"; import { createHelpCommand } from "./commands/help"; import { onboardCommand } from "./commands/onboard"; import { ruleCommand } from "./commands/rules"; -import { getTelemetry, shutdownTelemetry } from "./telemetry"; +import { + getTelemetry, + resolveRunIdentity, + shutdownTelemetry, +} from "./telemetry"; import { emitRunEvents, resolveCommandName, resolveCwd } from "./telemetry-run"; import { CliError } from "./util/cli-error"; @@ -115,7 +119,11 @@ try { // both success and failure, so no command has to remember to. Telemetry is // best-effort and never affects the exit. try { - const telemetry = await getTelemetry(resolveCwd(rawArguments)); + const cwd = resolveCwd(rawArguments); + const telemetry = await getTelemetry(cwd); + // Resolve identity fresh here (not from the cached client identity) so + // auth-changing commands report their post-invocation auth state. + const identity = await resolveRunIdentity(cwd); const success = thrown === undefined && (process.exitCode === undefined || process.exitCode === 0); @@ -123,6 +131,8 @@ try { command: resolveCommandName(rawArguments), success, durationMs: Date.now() - startedAt, + anonymous: identity.anonymous, + loggedIn: identity.loggedIn, error: thrown, }); } catch { diff --git a/packages/cli/src/telemetry-run.ts b/packages/cli/src/telemetry-run.ts index 44f3644f..48b87188 100644 --- a/packages/cli/src/telemetry-run.ts +++ b/packages/cli/src/telemetry-run.ts @@ -49,21 +49,29 @@ export interface RunContext { command: string; success: boolean; durationMs: number; + /** Resolved fresh at emission time (see resolveRunIdentity) so auth-changing + * commands report post-invocation state. */ + anonymous: boolean; + loggedIn: boolean; + /** The thrown error, if the command threw. */ error?: unknown; } /** * Emit the per-invocation telemetry: a single `cli_run` denominator event - * (always), preceded by `cli_error` when the invocation failed. The CLI - * version rides along via the telemetry client's standard `cliVersion` - * property; `cli_version` is included here in the snake_case form the run - * taxonomy uses. + * (always), preceded by `cli_error` only when the command threw. The CLI + * version is NOT added here — it rides on the standard `cliVersion` property + * the telemetry client attaches to every event. */ export function emitRunEvents( - telemetry: Pick, + telemetry: Pick, context: RunContext ): void { - if (!context.success) { + // cli_error fires only for a thrown failure (with a known CliErrorCode when + // available). A failure signalled purely via process.exitCode (the command + // printed its own error) is captured by cli_run's success:false — emitting + // cli_error there would mislabel it INTERNAL_ERROR. + if (context.error !== undefined) { const code = context.error instanceof CliError && context.error.code ? context.error.code @@ -73,10 +81,9 @@ export function emitRunEvents( telemetry.capture("cli_run", { command: context.command, - cli_version: __VERSION__, success: context.success, durationMs: context.durationMs, - anonymous: telemetry.identity.anonymous, - loggedIn: !telemetry.identity.anonymous, + anonymous: context.anonymous, + loggedIn: context.loggedIn, }); } diff --git a/packages/cli/src/telemetry.ts b/packages/cli/src/telemetry.ts index 27b417c9..4a46355f 100644 --- a/packages/cli/src/telemetry.ts +++ b/packages/cli/src/telemetry.ts @@ -21,8 +21,26 @@ const ANONYMOUS_ID_FILE = "anonymous_id"; export interface TelemetryClient { capture(event: string, properties?: Record): void; shutdown(): Promise; - /** Resolved identity state, exposed so the runner can stamp cli_run. */ - readonly identity: { anonymous: boolean }; +} + +/** + * Resolve the current auth identity by reading the token fresh. Unlike the + * telemetry client's cached identity (fixed at init), this reflects the state + * AT CALL TIME — so the runner can stamp cli_run with the post-invocation + * identity even for commands that change auth state mid-run (auth login/logout). + */ +export async function resolveRunIdentity( + cwd?: string +): Promise<{ anonymous: boolean; loggedIn: boolean }> { + try { + const token = await getToken(cwd, { silent: true }); + if (!token) return { anonymous: true, loggedIn: false }; + // A token is present → logged in. anonymous tracks whether a subject + // (authenticated identity) decoded from it. + return { anonymous: decodeSubject(token) === undefined, loggedIn: true }; + } catch { + return { anonymous: true, loggedIn: false }; + } } function isTelemetryDisabled(): boolean { @@ -35,7 +53,6 @@ function isTelemetryDisabled(): boolean { const noopClient: TelemetryClient = { capture() {}, async shutdown() {}, - identity: { anonymous: true }, }; async function getOrCreateAnonymousId(): Promise { @@ -171,7 +188,6 @@ export async function getTelemetry(cwd?: string): Promise { const ph = posthog; instance = { - identity: { anonymous }, capture(event: string, properties?: Record) { try { ph.capture({ diff --git a/packages/cli/test/cli-run.test.ts b/packages/cli/test/cli-run.test.ts index 17e3e0ce..270fd914 100644 --- a/packages/cli/test/cli-run.test.ts +++ b/packages/cli/test/cli-run.test.ts @@ -18,17 +18,21 @@ describe("resolveCommandName", () => { }); }); -function fakeTelemetry(anonymous = true) { - return { - capture: vi.fn(), - identity: { anonymous }, - }; +function fakeTelemetry() { + return { capture: vi.fn() }; } +const anon = { anonymous: true, loggedIn: false }; + describe("emitRunEvents", () => { - it("emits exactly one cli_run on success, with no cli_error", () => { + it("emits exactly one cli_run on success, with no cli_error and no cli_version", () => { const telemetry = fakeTelemetry(); - emitRunEvents(telemetry, { command: "info", success: true, durationMs: 5 }); + emitRunEvents(telemetry, { + command: "info", + success: true, + durationMs: 5, + ...anon, + }); expect(telemetry.capture).toHaveBeenCalledTimes(1); expect(telemetry.capture).toHaveBeenCalledWith( @@ -41,33 +45,43 @@ describe("emitRunEvents", () => { loggedIn: false, }) ); + // Version rides on the standard cliVersion property, not a cli_version field. + const properties = telemetry.capture.mock.calls[0]![1] as Record< + string, + unknown + >; + expect(properties).not.toHaveProperty("cli_version"); }); - it("emits cli_error (with the CliError code) then cli_run on failure", () => { + it("emits cli_error then cli_run, in that order and exactly twice", () => { const telemetry = fakeTelemetry(); emitRunEvents(telemetry, { command: "rule create", success: false, durationMs: 9, + ...anon, error: new CliError("nope", "AUTH_REQUIRED"), }); - expect(telemetry.capture).toHaveBeenCalledWith("cli_error", { + expect(telemetry.capture).toHaveBeenCalledTimes(2); + expect(telemetry.capture).toHaveBeenNthCalledWith(1, "cli_error", { command: "rule create", code: "AUTH_REQUIRED", }); - expect(telemetry.capture).toHaveBeenCalledWith( + expect(telemetry.capture).toHaveBeenNthCalledWith( + 2, "cli_run", expect.objectContaining({ command: "rule create", success: false }) ); }); - it("falls back to INTERNAL_ERROR for non-CliError failures", () => { + it("falls back to INTERNAL_ERROR for a thrown non-CliError", () => { const telemetry = fakeTelemetry(); emitRunEvents(telemetry, { command: "info", success: false, durationMs: 1, + ...anon, error: new Error("boom"), }); @@ -77,9 +91,36 @@ describe("emitRunEvents", () => { }); }); + it("does NOT emit cli_error for an exitCode-only failure (no thrown error)", () => { + const telemetry = fakeTelemetry(); + emitRunEvents(telemetry, { + command: "check", + success: false, + durationMs: 3, + ...anon, + // no error — failure signalled via process.exitCode + }); + + expect(telemetry.capture).toHaveBeenCalledTimes(1); + expect(telemetry.capture).toHaveBeenCalledWith( + "cli_run", + expect.objectContaining({ success: false }) + ); + const events = telemetry.capture.mock.calls.map( + (call) => call[0] as string + ); + expect(events).not.toContain("cli_error"); + }); + it("reflects an authenticated identity as loggedIn", () => { - const telemetry = fakeTelemetry(false); - emitRunEvents(telemetry, { command: "info", success: true, durationMs: 2 }); + const telemetry = fakeTelemetry(); + emitRunEvents(telemetry, { + command: "info", + success: true, + durationMs: 2, + anonymous: false, + loggedIn: true, + }); expect(telemetry.capture).toHaveBeenCalledWith( "cli_run", From f5b286340e5704c5e8d05a84afb150244ea1c260 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sat, 13 Jun 2026 12:30:16 -0700 Subject: [PATCH 07/17] fix(cli): Only emit cli_rule_created/improved when rules are written PR #34 review: guard the createdRuleCount/improvedRuleCount assignment on rules.length > 0, so an empty/missing status.rules no longer emits cli_rule_created / cli_rule_improved with ruleCount: 0. The "generated" state with zero rules now leaves the count undefined, so the concrete event does not fire (covered by cli_run). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/commands/rules.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/rules.ts b/packages/cli/src/commands/rules.ts index d4504dce..bc132e2e 100644 --- a/packages/cli/src/commands/rules.ts +++ b/packages/cli/src/commands/rules.ts @@ -245,7 +245,7 @@ const createCommand = defineCommand({ console.log(` ${filePath}`); } } - createdRuleCount = rules.length; + if (rules.length > 0) createdRuleCount = rules.length; return; } case "pr": @@ -478,7 +478,7 @@ const improveCommand = defineCommand({ console.log(` ${filePath}`); } } - improvedRuleCount = rules.length; + if (rules.length > 0) improvedRuleCount = rules.length; return; } case "pr": From 00033eeb57b99c39bdfc86d2bd60a098bb6894a5 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sat, 13 Jun 2026 12:56:00 -0700 Subject: [PATCH 08/17] refactor(cli): Single-pass check counts; robust cli_installed assertion PR #35 review: - check.ts: compute errorCount/warningCount in one loop over results and derive hasErrors from errorCount, instead of one `some` + two `filter` passes over potentially large scan output. - wizard-integration test: assert on the event name across all capture calls rather than not.toHaveBeenCalledWith("cli_installed"), so a call with extra properties can't produce a false negative. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/commands/check.ts | 14 ++++++++------ packages/cli/test/wizard-integration.test.ts | 6 ++++-- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts index 9c9e540f..f1e82b49 100644 --- a/packages/cli/src/commands/check.ts +++ b/packages/cli/src/commands/check.ts @@ -163,12 +163,14 @@ export const checkCommand = defineCommand({ try { await generateSgConfig(cwd); const { results } = await runAstGrepScan(cwd, existingPaths); - const hasErrors = results.some((r) => r.severity === "error"); - scanCounts = { - errorCount: results.filter((r) => r.severity === "error").length, - warningCount: results.filter((r) => r.severity === "warning").length, - findings: results.length, - }; + let errorCount = 0; + let warningCount = 0; + for (const result of results) { + if (result.severity === "error") errorCount++; + else if (result.severity === "warning") warningCount++; + } + const hasErrors = errorCount > 0; + scanCounts = { errorCount, warningCount, findings: results.length }; // Format output if (args.json) { diff --git a/packages/cli/test/wizard-integration.test.ts b/packages/cli/test/wizard-integration.test.ts index 5ac5900b..d399a6b6 100644 --- a/packages/cli/test/wizard-integration.test.ts +++ b/packages/cli/test/wizard-integration.test.ts @@ -131,8 +131,10 @@ describe("runWizard end-to-end", () => { expect(await exists(join(cwd, ".taskless", "taskless.json"))).toBe(false); // A cancelled wizard installs nothing, so it emits no cli_installed event; - // the invocation itself is captured by cli_run at the runner level. - expect(captureSpy).not.toHaveBeenCalledWith("cli_installed"); + // the invocation itself is captured by cli_run at the runner level. Assert + // on the event name across all calls so extra properties can't slip past. + const events = captureSpy.mock.calls.map((call) => call[0] as string); + expect(events).not.toContain("cli_installed"); }); it("cancelling the summary confirm writes nothing", async () => { From ec1a1ac5a540ed2d00500e0470384287e6618d1a Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sat, 13 Jun 2026 13:50:08 -0700 Subject: [PATCH 09/17] test(cli): Assert no legacy help_* event on served-topic and index paths PR #36 review: the served-topic and no-arg help-telemetry tests asserted cli_help was emitted but not that the implementation avoids dual-emitting a legacy help_* event. Both now map captured calls to their event names and assert none start with help_ (and specifically not help_index). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/test/help-telemetry.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/cli/test/help-telemetry.test.ts b/packages/cli/test/help-telemetry.test.ts index 1bc7f14a..c6ebc8ba 100644 --- a/packages/cli/test/help-telemetry.test.ts +++ b/packages/cli/test/help-telemetry.test.ts @@ -42,14 +42,21 @@ describe("help emits cli_help { topic }", () => { errorSpy.mockRestore(); }); - it("captures the served topic", async () => { + it("captures the served topic and no legacy help_* event", async () => { await runHelp(["help", "rule", "create"]); expect(capture).toHaveBeenCalledWith("cli_help", { topic: "rule create" }); + + const events = capture.mock.calls.map((call) => call[0] as string); + expect(events.every((event) => !event.startsWith("help_"))).toBe(true); }); - it("captures an index marker when invoked with no topic", async () => { + it("captures the index marker for no topic and no legacy help_index event", async () => { await runHelp(["help"]); expect(capture).toHaveBeenCalledWith("cli_help", { topic: "(index)" }); + + const events = capture.mock.calls.map((call) => call[0] as string); + expect(events).not.toContain("help_index"); + expect(events.every((event) => !event.startsWith("help_"))).toBe(true); }); it("captures the attempted topic for an unknown topic, and no legacy help_* event", async () => { From 102626136a33c7ea47fa61310a084943e723f362 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sat, 13 Jun 2026 13:55:20 -0700 Subject: [PATCH 10/17] test(cli): Prove help_* removal once via a source scan, not per-test Per review feedback: instead of repeating a "no help_* event" assertion inside every behavioral help-telemetry test (over-testing), keep those tests purely behavioral (cli_help { topic }) and add a single source-scan test that asserts no help_* event-name literal remains anywhere under src/. That states the contract once, confidently. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/test/help-telemetry.test.ts | 45 ++++++++++++++++-------- 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/packages/cli/test/help-telemetry.test.ts b/packages/cli/test/help-telemetry.test.ts index c6ebc8ba..5e11937e 100644 --- a/packages/cli/test/help-telemetry.test.ts +++ b/packages/cli/test/help-telemetry.test.ts @@ -1,3 +1,6 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; + import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // Spy on telemetry by mocking the module the help command imports. The factory @@ -42,30 +45,44 @@ describe("help emits cli_help { topic }", () => { errorSpy.mockRestore(); }); - it("captures the served topic and no legacy help_* event", async () => { + it("captures the served topic", async () => { await runHelp(["help", "rule", "create"]); expect(capture).toHaveBeenCalledWith("cli_help", { topic: "rule create" }); - - const events = capture.mock.calls.map((call) => call[0] as string); - expect(events.every((event) => !event.startsWith("help_"))).toBe(true); }); - it("captures the index marker for no topic and no legacy help_index event", async () => { + it("captures the index marker for no topic", async () => { await runHelp(["help"]); expect(capture).toHaveBeenCalledWith("cli_help", { topic: "(index)" }); - - const events = capture.mock.calls.map((call) => call[0] as string); - expect(events).not.toContain("help_index"); - expect(events.every((event) => !event.startsWith("help_"))).toBe(true); }); - it("captures the attempted topic for an unknown topic, and no legacy help_* event", async () => { + it("captures the attempted topic for an unknown topic", async () => { await runHelp(["help", "nope"]); expect(capture).toHaveBeenCalledWith("cli_help", { topic: "nope" }); + }); +}); + +// Rather than asserting "no help_* event" inside every behavioral test above, +// prove it once at the source: after this change lands, no legacy help_* event +// name is emitted anywhere in the CLI. +function collectSourceFiles(directory: string): string[] { + const files: string[] = []; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const full = join(directory, entry.name); + if (entry.isDirectory()) files.push(...collectSourceFiles(full)); + else if (entry.name.endsWith(".ts")) files.push(full); + } + return files; +} - const events = capture.mock.calls.map((call) => call[0] as string); - expect(events).not.toContain("help_index"); - expect(events).not.toContain("help_unknown"); - expect(events.every((event) => !event.startsWith("help_"))).toBe(true); +describe("no legacy help_* event remains in the CLI source", () => { + it("emits no help_* event-name literal under src/", () => { + const sourceDirectory = resolve(import.meta.dirname, "../src"); + // Match a string/template literal that begins with help_ (e.g. "help_index", + // "help_unknown", or a `help_${...}` topic event). + const legacyHelpEvent = /["`]help_/; + const offenders = collectSourceFiles(sourceDirectory).filter((file) => + legacyHelpEvent.test(readFileSync(file, "utf8")) + ); + expect(offenders).toEqual([]); }); }); From 1e74b65ec468e9e110c103bfd072d1e09574a573 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sat, 13 Jun 2026 14:14:16 -0700 Subject: [PATCH 11/17] fix(cli): Resolve cli_run identity at invocation start, not end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: cli_run's anonymous/loggedIn should describe who *initiated* the run, not the post-command auth state. Resolving identity in the finally meant `auth login` reported loggedIn:true; reviewer confirmed the intended semantic is "the login was performed as a logged-out user". Move resolveRunIdentity to invocation start (before runCommand) and stamp cli_run with that, so login reports loggedIn:false and logout reports loggedIn:true — reliably, independent of when a command first calls getTelemetry. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/index.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index b163ca70..a1faacb2 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -103,7 +103,12 @@ const main = defineCommand({ // main loop to run cli and make every attempt to shut down gracefully const rawArguments = process.argv.slice(2); +const runCwd = resolveCwd(rawArguments); const startedAt = Date.now(); +// Resolve identity at invocation START so cli_run reports who *initiated* the +// run, not the post-command state — e.g. `auth login` run by a logged-out user +// reports loggedIn:false (the login was performed as a logged-out user). +const startIdentity = await resolveRunIdentity(runCwd); let thrown: unknown; try { await runCommand(main, { rawArgs: rawArguments }); @@ -119,11 +124,7 @@ try { // both success and failure, so no command has to remember to. Telemetry is // best-effort and never affects the exit. try { - const cwd = resolveCwd(rawArguments); - const telemetry = await getTelemetry(cwd); - // Resolve identity fresh here (not from the cached client identity) so - // auth-changing commands report their post-invocation auth state. - const identity = await resolveRunIdentity(cwd); + const telemetry = await getTelemetry(runCwd); const success = thrown === undefined && (process.exitCode === undefined || process.exitCode === 0); @@ -131,8 +132,8 @@ try { command: resolveCommandName(rawArguments), success, durationMs: Date.now() - startedAt, - anonymous: identity.anonymous, - loggedIn: identity.loggedIn, + anonymous: startIdentity.anonymous, + loggedIn: startIdentity.loggedIn, error: thrown, }); } catch { From 20d46d5aa5c2d59ab04c4a6958f634e857c7156b Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sat, 13 Jun 2026 15:10:40 -0700 Subject: [PATCH 12/17] docs(openspec): Sync corrected check/help spec into the archived capability The archive commit synced the pre-review delta spec, so the capability spec carried the stale cli_check_completed{ filesScanned } and a fuzzy cli_help index-marker description. Reconcile the synced spec (and the archive design flow diagram) with the corrected contract: findings replaces filesScanned, and cli_help documents the exact literal "(index)" for the no-topic invocation. Co-Authored-By: Claude Opus 4.8 --- .../2026-06-13-restructure-cli-telemetry/design.md | 2 +- openspec/specs/analytics/spec.md | 13 ++++++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/openspec/changes/archive/2026-06-13-restructure-cli-telemetry/design.md b/openspec/changes/archive/2026-06-13-restructure-cli-telemetry/design.md index 017512ee..254669b2 100644 --- a/openspec/changes/archive/2026-06-13-restructure-cli-telemetry/design.md +++ b/openspec/changes/archive/2026-06-13-restructure-cli-telemetry/design.md @@ -55,7 +55,7 @@ auth login success → cli_authenticated { } auth logout success → cli_logged_out { } init/install success → cli_installed { targets? } onboard complete → cli_onboarded { } -check finishes → cli_check_completed{ errorCount, warningCount, filesScanned } +check finishes → cli_check_completed{ errorCount, warningCount, findings } any command fails → cli_error { command, code } help served → cli_help { topic } (topic = "(index)" for no-arg, the attempted topic otherwise) diff --git a/openspec/specs/analytics/spec.md b/openspec/specs/analytics/spec.md index dab92816..b8cc8f4b 100644 --- a/openspec/specs/analytics/spec.md +++ b/openspec/specs/analytics/spec.md @@ -147,13 +147,13 @@ CLI events SHALL use the `cli_` prefix, with the taxonomy organized as a - `cli_authenticated`, `cli_logged_out` - `cli_installed`, `cli_onboarded` - `cli_check_completed` — error/warning counts only (e.g. `errorCount`, - `warningCount`, `filesScanned`) + `warningCount`, `findings`) - `cli_error` — a single failure event with `command` and `code` (a stable `CliErrorCode`) - `cli_help` — fired when the help command serves a request, with a `topic` - property (the served topic, or an index marker when invoked with no topic). - This replaces the previous `help_index`, `help_`, and `help_unknown` - events. + property (the served topic; the exact literal `"(index)"` when invoked with no + topic; the attempted topic for an unknown request). This replaces the previous + `help_index`, `help_`, and `help_unknown` events. Commands that carry no concrete state beyond the invocation (e.g. `info`, `detect`, `update`, `auth status`, `rule verify`, `rule meta`) SHALL rely on @@ -174,11 +174,10 @@ Commands that carry no concrete state beyond the invocation (e.g. `info`, - **THEN** PostHog SHALL receive a `cli_help` event with `topic: "rule create"` - **AND** SHALL NOT receive a `help_rule_create` event -#### Scenario: Help with no topic emits cli_help with an index marker +#### Scenario: Help with no topic emits cli_help with the index marker - **WHEN** an agent runs `taskless help` -- **THEN** PostHog SHALL receive a `cli_help` event whose `topic` marks the index - (no-argument) invocation +- **THEN** PostHog SHALL receive a `cli_help` event with `topic: "(index)"` - **AND** SHALL NOT receive a `help_index` event #### Scenario: A command failure emits cli_error From cd2c3e7038c56b722bde4094500eaaf763d93f71 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sat, 13 Jun 2026 15:34:54 -0700 Subject: [PATCH 13/17] refactor(cli): Uppercase the CLI acronym in CliError/CliErrorCode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review on #32: don't de-capitalize acronyms. Rename the CLIError class and CLIErrorCode type (and every reference) so the CLI acronym is fully uppercase. Pre-existing identifiers, renamed here because a symbol cannot be half-renamed. The cli_* event names are unchanged — those are snake_case wire identifiers, not acronym-cased symbols. Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/commands/auth.ts | 8 ++++---- packages/cli/src/commands/onboard.ts | 6 +++--- packages/cli/src/commands/rules.ts | 20 ++++++++++---------- packages/cli/src/index.ts | 6 +++--- packages/cli/src/telemetry-run.ts | 6 +++--- packages/cli/src/types/errors.ts | 8 ++++---- packages/cli/src/util/cli-error.ts | 12 ++++++------ packages/cli/test/cli-run.test.ts | 6 +++--- 8 files changed, 36 insertions(+), 36 deletions(-) diff --git a/packages/cli/src/commands/auth.ts b/packages/cli/src/commands/auth.ts index a8d96321..416a5021 100644 --- a/packages/cli/src/commands/auth.ts +++ b/packages/cli/src/commands/auth.ts @@ -5,7 +5,7 @@ import { loginInteractive } from "../auth/login-interactive"; import { getToken, removeToken } from "../auth/token"; import { fetchWhoami } from "../auth/whoami"; import { getTelemetry } from "../telemetry"; -import { type CliErrorCode, writeJsonError } from "../types/errors"; +import { type CLIErrorCode, writeJsonError } from "../types/errors"; const loginCommand = defineCommand({ meta: { @@ -37,10 +37,10 @@ const loginCommand = defineCommand({ telemetry.capture("cli_auth_login"); /** Tracks the last emitted error code so the completion event can include it. */ - let lastErrorCode: CliErrorCode | undefined; + let lastErrorCode: CLIErrorCode | undefined; /** Emit an error in the right channel and set exit code. */ - const fail = (code: CliErrorCode, message: string): void => { + const fail = (code: CLIErrorCode, message: string): void => { lastErrorCode = code; if (args.json) { writeJsonError(code, message); @@ -83,7 +83,7 @@ const loginCommand = defineCommand({ return; } case "cancelled": { - const code: CliErrorCode = + const code: CLIErrorCode = result.reason === "denied" ? "AUTH_REQUIRED" : "NETWORK_ERROR"; const message = result.message ?? diff --git a/packages/cli/src/commands/onboard.ts b/packages/cli/src/commands/onboard.ts index 19a078f9..0302ba45 100644 --- a/packages/cli/src/commands/onboard.ts +++ b/packages/cli/src/commands/onboard.ts @@ -5,7 +5,7 @@ import { defineCommand } from "citty"; import { ensureTasklessDirectory } from "../filesystem/directory"; import { readManifest, writeManifest } from "../filesystem/migrate"; import { getTelemetry } from "../telemetry"; -import { CliError } from "../util/cli-error"; +import { CLIError } from "../util/cli-error"; import { getRecipe } from "./help"; @@ -67,7 +67,7 @@ export const onboardCommand = defineCommand({ " --force re-runs the discovery recipe; --mark-complete records completion." ); process.exitCode = 1; - throw new CliError("conflicting flags"); + throw new CLIError("conflicting flags"); } await ensureTasklessDirectory(cwd); @@ -101,7 +101,7 @@ export const onboardCommand = defineCommand({ // Should not happen — onboard.txt is embedded at build time. console.error("Internal error: onboard recipe is not available."); process.exitCode = 1; - throw new CliError("recipe missing"); + throw new CLIError("recipe missing"); } console.log(recipe.trimEnd()); telemetry.capture("cli_onboard_recipe", { forced: args.force }); diff --git a/packages/cli/src/commands/rules.ts b/packages/cli/src/commands/rules.ts index a9d65065..3911e9b5 100644 --- a/packages/cli/src/commands/rules.ts +++ b/packages/cli/src/commands/rules.ts @@ -25,8 +25,8 @@ import { import { outputSchema as metaOutputSchema } from "../schemas/rules-meta"; import { verifyOutputSchema } from "../schemas/rules-verify"; import { getTelemetry } from "../telemetry"; -import { CliError } from "../util/cli-error"; -import { type CliErrorCode, makeErrorEnvelope } from "../types/errors"; +import { CLIError } from "../util/cli-error"; +import { type CLIErrorCode, makeErrorEnvelope } from "../types/errors"; /** Format today's date as YYYYMMDD */ function getTimestamp(): string { @@ -77,7 +77,7 @@ const createCommand = defineCommand({ /** Emit an error and exit, respecting --json mode */ function fail( message: string, - code: CliErrorCode = "INTERNAL_ERROR" + code: CLIErrorCode = "INTERNAL_ERROR" ): never { if (args.json) { console.log(JSON.stringify(makeErrorEnvelope(code, message))); @@ -85,7 +85,7 @@ const createCommand = defineCommand({ console.error(`Error: ${message}`); } process.exitCode = 1; - throw new CliError(message); + throw new CLIError(message); } if (args.anonymous) { @@ -157,7 +157,7 @@ const createCommand = defineCommand({ // resolveIdentity throws on missing auth or missing git remote; // surface the original message but pick a best-guess code. const message = error instanceof Error ? error.message : String(error); - const code: CliErrorCode = /git remote|origin/i.test(message) + const code: CLIErrorCode = /git remote|origin/i.test(message) ? "NO_GITHUB_REMOTE" : "AUTH_REQUIRED"; fail(message, code); @@ -319,7 +319,7 @@ const improveCommand = defineCommand({ /** Emit an error and exit, respecting --json mode */ function fail( message: string, - code: CliErrorCode = "INTERNAL_ERROR" + code: CLIErrorCode = "INTERNAL_ERROR" ): never { if (args.json) { console.log(JSON.stringify(makeErrorEnvelope(code, message))); @@ -327,7 +327,7 @@ const improveCommand = defineCommand({ console.error(`Error: ${message}`); } process.exitCode = 1; - throw new CliError(message); + throw new CLIError(message); } if (args.anonymous) { @@ -395,7 +395,7 @@ const improveCommand = defineCommand({ identity = await resolveIdentity(cwd); } catch (error) { const message = error instanceof Error ? error.message : String(error); - const code: CliErrorCode = /git remote|origin/i.test(message) + const code: CLIErrorCode = /git remote|origin/i.test(message) ? "NO_GITHUB_REMOTE" : "AUTH_REQUIRED"; fail(message, code); @@ -555,7 +555,7 @@ const metaCommand = defineCommand({ function fail( message: string, - code: CliErrorCode = "INTERNAL_ERROR" + code: CLIErrorCode = "INTERNAL_ERROR" ): never { if (args.json) { console.log(JSON.stringify(makeErrorEnvelope(code, message))); @@ -563,7 +563,7 @@ const metaCommand = defineCommand({ console.error(`Error: ${message}`); } process.exitCode = 1; - throw new CliError(message); + throw new CLIError(message); } let success = false; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index a1faacb2..ae895704 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -13,7 +13,7 @@ import { shutdownTelemetry, } from "./telemetry"; import { emitRunEvents, resolveCommandName, resolveCwd } from "./telemetry-run"; -import { CliError } from "./util/cli-error"; +import { CLIError } from "./util/cli-error"; const subCommands = { init: initCommand, @@ -113,9 +113,9 @@ let thrown: unknown; try { await runCommand(main, { rawArgs: rawArguments }); } catch (error) { - // CliError = expected failure (already printed output, exitCode already set) + // CLIError = expected failure (already printed output, exitCode already set) thrown = error; - if (!(error instanceof CliError)) { + if (!(error instanceof CLIError)) { process.exitCode = 1; console.error(error instanceof Error ? error.message : String(error)); } diff --git a/packages/cli/src/telemetry-run.ts b/packages/cli/src/telemetry-run.ts index 48b87188..3421b929 100644 --- a/packages/cli/src/telemetry-run.ts +++ b/packages/cli/src/telemetry-run.ts @@ -1,7 +1,7 @@ import { resolve } from "node:path"; import type { TelemetryClient } from "./telemetry"; -import { CliError } from "./util/cli-error"; +import { CLIError } from "./util/cli-error"; /** * Derive the cli_run `command` property from the raw argv. Flags (and the @@ -67,13 +67,13 @@ export function emitRunEvents( telemetry: Pick, context: RunContext ): void { - // cli_error fires only for a thrown failure (with a known CliErrorCode when + // cli_error fires only for a thrown failure (with a known CLIErrorCode when // available). A failure signalled purely via process.exitCode (the command // printed its own error) is captured by cli_run's success:false — emitting // cli_error there would mislabel it INTERNAL_ERROR. if (context.error !== undefined) { const code = - context.error instanceof CliError && context.error.code + context.error instanceof CLIError && context.error.code ? context.error.code : "INTERNAL_ERROR"; telemetry.capture("cli_error", { command: context.command, code }); diff --git a/packages/cli/src/types/errors.ts b/packages/cli/src/types/errors.ts index ba357b5b..fc705307 100644 --- a/packages/cli/src/types/errors.ts +++ b/packages/cli/src/types/errors.ts @@ -6,7 +6,7 @@ * Add new codes by extending the union; do not rename existing codes * without a major version bump. */ -export type CliErrorCode = +export type CLIErrorCode = | "AUTH_REQUIRED" | "NO_GITHUB_REMOTE" | "RULE_GENERATION_FAILED" @@ -22,17 +22,17 @@ export type CliErrorCode = */ export interface CliErrorEnvelope { ok: false; - code: CliErrorCode; + code: CLIErrorCode; message: string; } export function makeErrorEnvelope( - code: CliErrorCode, + code: CLIErrorCode, message: string ): CliErrorEnvelope { return { ok: false, code, message }; } -export function writeJsonError(code: CliErrorCode, message: string): void { +export function writeJsonError(code: CLIErrorCode, message: string): void { console.log(JSON.stringify(makeErrorEnvelope(code, message))); } diff --git a/packages/cli/src/util/cli-error.ts b/packages/cli/src/util/cli-error.ts index 9703581b..e2fe76f8 100644 --- a/packages/cli/src/util/cli-error.ts +++ b/packages/cli/src/util/cli-error.ts @@ -1,19 +1,19 @@ -import type { CliErrorCode } from "../types/errors"; +import type { CLIErrorCode } from "../types/errors"; /** * Sentinel error for expected CLI failures (e.g. validation errors). * The top-level catch in index.ts uses this to distinguish expected exits * (already printed their own output) from unexpected crashes. * - * An optional `code` (a stable `CliErrorCode`) lets the runner attribute a + * An optional `code` (a stable `CLIErrorCode`) lets the runner attribute a * `cli_error` telemetry event to a known failure mode. Omitting it is fine; * the runner falls back to `INTERNAL_ERROR`. */ -export class CliError extends Error { - override name = "CliError"; - readonly code?: CliErrorCode; +export class CLIError extends Error { + override name = "CLIError"; + readonly code?: CLIErrorCode; - constructor(message?: string, code?: CliErrorCode) { + constructor(message?: string, code?: CLIErrorCode) { super(message); this.code = code; } diff --git a/packages/cli/test/cli-run.test.ts b/packages/cli/test/cli-run.test.ts index 270fd914..40aa46bb 100644 --- a/packages/cli/test/cli-run.test.ts +++ b/packages/cli/test/cli-run.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { emitRunEvents, resolveCommandName } from "../src/telemetry-run"; -import { CliError } from "../src/util/cli-error"; +import { CLIError } from "../src/util/cli-error"; describe("resolveCommandName", () => { it.each([ @@ -60,7 +60,7 @@ describe("emitRunEvents", () => { success: false, durationMs: 9, ...anon, - error: new CliError("nope", "AUTH_REQUIRED"), + error: new CLIError("nope", "AUTH_REQUIRED"), }); expect(telemetry.capture).toHaveBeenCalledTimes(2); @@ -75,7 +75,7 @@ describe("emitRunEvents", () => { ); }); - it("falls back to INTERNAL_ERROR for a thrown non-CliError", () => { + it("falls back to INTERNAL_ERROR for a thrown non-CLIError", () => { const telemetry = fakeTelemetry(); emitRunEvents(telemetry, { command: "info", From b204084fdc76c1ea3bfa0a9c7ebde3486389746f Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sat, 13 Jun 2026 16:41:17 -0700 Subject: [PATCH 14/17] docs(openspec): Uppercase CLIError/CLIErrorCode in the analytics spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the source rename — the CLI acronym is uppercase in the CLIError class and CLIErrorCode type, so the spec prose and archived contract use the same casing. Co-Authored-By: Claude Opus 4.8 --- .../archive/2026-06-13-restructure-cli-telemetry/design.md | 4 ++-- .../specs/analytics/spec.md | 4 ++-- .../archive/2026-06-13-restructure-cli-telemetry/tasks.md | 2 +- openspec/specs/analytics/spec.md | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/openspec/changes/archive/2026-06-13-restructure-cli-telemetry/design.md b/openspec/changes/archive/2026-06-13-restructure-cli-telemetry/design.md index 254669b2..395760e4 100644 --- a/openspec/changes/archive/2026-06-13-restructure-cli-telemetry/design.md +++ b/openspec/changes/archive/2026-06-13-restructure-cli-telemetry/design.md @@ -70,7 +70,7 @@ carries no concrete state beyond the invocation, so those are covered by ### D3 — `cli_error` is the single failure event Instead of `success:false` spread across each `_completed` event, failures emit -one `cli_error { command, code }` (code from the stable `CliErrorCode` set), and +one `cli_error { command, code }` (code from the stable `CLIErrorCode` set), and `cli_run` also records `success:false`. The runner emits `cli_error` from its catch path so no command has to remember to. @@ -86,7 +86,7 @@ Dashboards are rebuilt against the new names (the proposal calls this out). - **[Centralized `cli_run` can't see command-specific context]** → By design (D-non-goal). `loggedIn` covers the only cross-cutting dimension we need now. - **[`success` detection in the runner is imperfect]** → Derive from thrown - error and `process.exitCode`; commands already use `CliError` + exit codes + error and `process.exitCode`; commands already use `CLIError` + exit codes consistently, so this is reliable. ## Open Questions diff --git a/openspec/changes/archive/2026-06-13-restructure-cli-telemetry/specs/analytics/spec.md b/openspec/changes/archive/2026-06-13-restructure-cli-telemetry/specs/analytics/spec.md index 5d325ce7..47eebac2 100644 --- a/openspec/changes/archive/2026-06-13-restructure-cli-telemetry/specs/analytics/spec.md +++ b/openspec/changes/archive/2026-06-13-restructure-cli-telemetry/specs/analytics/spec.md @@ -44,7 +44,7 @@ CLI events SHALL use the `cli_` prefix, with the taxonomy organized as a - `cli_check_completed` — error/warning counts only (e.g. `errorCount`, `warningCount`, `findings`) - `cli_error` — a single failure event with `command` and `code` (a stable - `CliErrorCode`) + `CLIErrorCode`) - `cli_help` — fired when the help command serves a request, with a `topic` property. The `topic` SHALL be: the served topic for a known topic (e.g. `"rule create"`); the exact literal `"(index)"` when invoked with no topic; @@ -84,7 +84,7 @@ Commands that carry no concrete state beyond the invocation (e.g. `info`, #### Scenario: A command failure emits cli_error -- **WHEN** a command fails with a known `CliErrorCode` +- **WHEN** a command fails with a known `CLIErrorCode` - **THEN** PostHog SHALL receive a `cli_error` event with `command` and `code` #### Scenario: Old event names are not emitted diff --git a/openspec/changes/archive/2026-06-13-restructure-cli-telemetry/tasks.md b/openspec/changes/archive/2026-06-13-restructure-cli-telemetry/tasks.md index 68041c98..aace17da 100644 --- a/openspec/changes/archive/2026-06-13-restructure-cli-telemetry/tasks.md +++ b/openspec/changes/archive/2026-06-13-restructure-cli-telemetry/tasks.md @@ -27,7 +27,7 @@ keeps the suite green on its own. - [x] 1.1 In `packages/cli/src/index.ts`, wrap command execution so exactly one `cli_run` is emitted per invocation from a `finally`-equivalent path, with `{ command, cli_version, success, durationMs, anonymous, loggedIn }` - [x] 1.2 Resolve `command` from the matched citty subcommand (e.g. `"rule create"`, `"help"`); derive `success` from a thrown error / non-zero `process.exitCode`; measure `durationMs` from a start timestamp — extracted to a testable `telemetry-run.ts` (resolveCommandName/resolveCwd/emitRunEvents) so the entry module's side-effecting top level stays untested -- [x] 1.3 Emit `cli_error { command, code }` from the runner's catch path when the failure carries a stable `CliErrorCode` — added an optional `code` to `CliError`; falls back to `INTERNAL_ERROR` +- [x] 1.3 Emit `cli_error { command, code }` from the runner's catch path when the failure carries a stable `CLIErrorCode` — added an optional `code` to `CLIError`; falls back to `INTERNAL_ERROR` - [x] 1.4 Tests: one `cli_run` per invocation (success and failure), and `cli_error` on a known-code failure — `test/cli-run.test.ts` - [x] 1.5 typecheck + lint + suite green; commit; open PR 1 diff --git a/openspec/specs/analytics/spec.md b/openspec/specs/analytics/spec.md index b8cc8f4b..02dc1f4e 100644 --- a/openspec/specs/analytics/spec.md +++ b/openspec/specs/analytics/spec.md @@ -149,7 +149,7 @@ CLI events SHALL use the `cli_` prefix, with the taxonomy organized as a - `cli_check_completed` — error/warning counts only (e.g. `errorCount`, `warningCount`, `findings`) - `cli_error` — a single failure event with `command` and `code` (a stable - `CliErrorCode`) + `CLIErrorCode`) - `cli_help` — fired when the help command serves a request, with a `topic` property (the served topic; the exact literal `"(index)"` when invoked with no topic; the attempted topic for an unknown request). This replaces the previous @@ -182,7 +182,7 @@ Commands that carry no concrete state beyond the invocation (e.g. `info`, #### Scenario: A command failure emits cli_error -- **WHEN** a command fails with a known `CliErrorCode` +- **WHEN** a command fails with a known `CLIErrorCode` - **THEN** PostHog SHALL receive a `cli_error` event with `command` and `code` #### Scenario: Old event names are not emitted From d47c3d847d061b29eff2f0042855ec89d3e397b3 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sat, 13 Jun 2026 17:18:54 -0700 Subject: [PATCH 15/17] fix(cli): Carry the CLIErrorCode rename into auth.ts The acronym rename landed on the runner branch, but auth.ts is owned by this phase and its edits sat on the same lines, so the merge kept the old casing and left the type unresolved. Rename auth.ts here so the type checks across the stack. Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/commands/auth.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/auth.ts b/packages/cli/src/commands/auth.ts index cfa69625..0c45ee75 100644 --- a/packages/cli/src/commands/auth.ts +++ b/packages/cli/src/commands/auth.ts @@ -5,7 +5,7 @@ import { loginInteractive } from "../auth/login-interactive"; import { getToken, removeToken } from "../auth/token"; import { fetchWhoami } from "../auth/whoami"; import { getTelemetry } from "../telemetry"; -import { type CliErrorCode, writeJsonError } from "../types/errors"; +import { type CLIErrorCode, writeJsonError } from "../types/errors"; const loginCommand = defineCommand({ meta: { @@ -35,7 +35,7 @@ const loginCommand = defineCommand({ const telemetry = await getTelemetry(cwd); /** Emit an error in the right channel and set exit code. */ - const fail = (code: CliErrorCode, message: string): void => { + const fail = (code: CLIErrorCode, message: string): void => { if (args.json) { writeJsonError(code, message); } else { @@ -73,7 +73,7 @@ const loginCommand = defineCommand({ return; } case "cancelled": { - const code: CliErrorCode = + const code: CLIErrorCode = result.reason === "denied" ? "AUTH_REQUIRED" : "NETWORK_ERROR"; const message = result.message ?? From 47da415b7c634e3aec411e0d3a41f7d759ceef45 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sat, 13 Jun 2026 17:29:25 -0700 Subject: [PATCH 16/17] refactor(cli): Uppercase the acronym in CLIErrorEnvelope too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same module as CLIError/CLIErrorCode — keep the error envelope type's acronym uppercase so the whole error surface is consistent. Self-contained to types/errors.ts (no external importers of the type name). Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/types/errors.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/types/errors.ts b/packages/cli/src/types/errors.ts index fc705307..ab5504f9 100644 --- a/packages/cli/src/types/errors.ts +++ b/packages/cli/src/types/errors.ts @@ -20,7 +20,7 @@ export type CLIErrorCode = * Standardized JSON error envelope written to stdout when an action * command exits with an error AND `--json` was set. */ -export interface CliErrorEnvelope { +export interface CLIErrorEnvelope { ok: false; code: CLIErrorCode; message: string; @@ -29,7 +29,7 @@ export interface CliErrorEnvelope { export function makeErrorEnvelope( code: CLIErrorCode, message: string -): CliErrorEnvelope { +): CLIErrorEnvelope { return { ok: false, code, message }; } From 5b5d22e592cae62d52548c6e32d8745a73493205 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sat, 13 Jun 2026 17:51:13 -0700 Subject: [PATCH 17/17] refactor(cli): Uppercase the acronym in CLIConfig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finish the CLI acronym sweep across the package — the api config type is the last Cli-cased identifier. Self-contained to api/config.ts (the type is not exported). Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/api/config.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/api/config.ts b/packages/cli/src/api/config.ts index 55e1dea4..6c6ab607 100644 --- a/packages/cli/src/api/config.ts +++ b/packages/cli/src/api/config.ts @@ -6,15 +6,15 @@ import { getConfigDirectory } from "../auth/token"; const DEFAULT_BASE_URL = "https://app.taskless.io/cli"; const CONFIG_FILE = "config.json"; -interface CliConfig { +interface CLIConfig { apiUrl?: string; } -function readConfigFile(): CliConfig | undefined { +function readConfigFile(): CLIConfig | undefined { try { const filePath = join(getConfigDirectory(), CONFIG_FILE); const raw = readFileSync(filePath, "utf8"); - return JSON.parse(raw) as CliConfig; + return JSON.parse(raw) as CLIConfig; } catch { return undefined; }