Opt-in, privacy-preserving AI usage census: /telemetry on|off|preview

A proof of concept for measuring how AI coding agents and ideation frameworks (like BMAD) change the way work gets done, without identifying individuals. Measure the work, not the worker.
Tracking epic: #1
/telemetry on. /telemetry off takes effect immediately and discards anything not yet sent.| Path | What it is |
|---|---|
plugins/census-telemetry/ | The Claude Code mod that runs on each developer's machine |
schema/daily-rollup.schema.json | JSON Schema for the one record a device sends per day |
docs/rollup-schema.md | What each rollup field means, and what is deliberately left out |
The mod is a Claude Code plugin. To load it into a session from a checkout:
claude --plugin-dir plugins/census-telemetry
Then, inside the session:
/telemetry preview shows the exact record that would be sent for today/telemetry on opts in/telemetry off opts out and drops anything still buffered/telemetry with no argument shows the current statusAt this stage the mod only handles consent and preview. It doesn't capture or send anything yet.
Early PoC, sandbox only. No real employee data is in scope. See the epic for the workstreams and open questions (the value of k, the noise level, reporting cadence and the choice of on-device model).
hooks/register.ts 94 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { ROLES, emptyRollup, utcDay } from './rollup'
4import type { DailyRollup, Role } from './rollup'
5
6// $.store keys. `rollup:<day>` holds the not-yet-sent rollup for that day.
7const ENABLED = 'enabled'
8const ROLE = 'role'
9const ROLLUP_PREFIX = 'rollup:'
10
11const USAGE = 'Usage: /telemetry on | off | preview'
12
13async function isEnabled($: EngineInterface): Promise<boolean> {
14 return (await $.store.get(ENABLED)) === true
15}
16
17async function role($: EngineInterface): Promise<Role> {
18 const stored = await $.store.get(ROLE)
19 return ROLES.find(one => one === stored) ?? 'other'
20}
21
22async function todaysRollup($: EngineInterface): Promise<DailyRollup> {
23 const day = utcDay(await $.clock.now())
24 const buffered = await $.store.get(ROLLUP_PREFIX + day)
25 return (buffered as DailyRollup | undefined) ?? emptyRollup(day, await role($))
26}
27
28async function dropBuffered($: EngineInterface): Promise<number> {
29 const keys = (await $.store.keys()).filter(key => key.startsWith(ROLLUP_PREFIX))
30 await Promise.all(keys.map(key => $.store.delete(key)))
31 return keys.length
32}
33
34function showStatus($: EngineInterface, enabled: boolean) {
35 $.ui.status(enabled ? 'census: on' : undefined)
36}
37
38export const register: Register = on => {
39 on('session.start', async ($, e, next) => {
40 await $.command.register({
41 name: 'telemetry',
42 description: 'AI usage census: opt in, opt out, or preview what would be sent',
43 argumentHint: 'on | off | preview',
44 })
45 showStatus($, await isEnabled($))
46
47 return next(e)
48 })
49
50 on('command.run', { command: 'telemetry' }, async ($, e) => {
51 // A bare `/telemetry` can arrive with no args at all.
52 const arg = (e.args ?? '').trim().toLowerCase()
53
54 if (arg === 'on') {
55 await $.store.set(ENABLED, true)
56 showStatus($, true)
57 return {
58 text:
59 'Telemetry is on. Only a daily rollup of labels and counts leaves this machine, ' +
60 'with no prompt text and no user or ticket IDs. Run /telemetry preview to see it, ' +
61 'or /telemetry off to stop at any time.',
62 }
63 }
64
65 if (arg === 'off') {
66 await $.store.set(ENABLED, false)
67 const dropped = await dropBuffered($)
68 showStatus($, false)
69 return {
70 text:
71 'Telemetry is off. Nothing more will be collected' +
72 (dropped > 0 ? `, and ${dropped} unsent rollup(s) were deleted.` : '.'),
73 }
74 }
75
76 if (arg === 'preview') {
77 const rollup = await todaysRollup($)
78 const state = (await isEnabled($)) ? 'on' : 'off'
79 return {
80 text:
81 `Telemetry is ${state}. This is the exact record that would be sent for ${rollup.day}:\n\n` +
82 JSON.stringify(rollup, null, 2),
83 }
84 }
85
86 if (arg === '') {
87 const state = (await isEnabled($)) ? 'on' : 'off'
88 return { text: `Telemetry is ${state}. ${USAGE}` }
89 }
90
91 return { text: USAGE }
92 })
93}
94hooks/rollup.ts 52 lines1// Mirrors schema/daily-rollup.schema.json at the repository root. Keep the two in step.
2
3export const SCHEMA_VERSION = '0.1'
4
5export const ROLES = ['engineer', 'qa', 'design', 'product', 'other'] as const
6export type Role = (typeof ROLES)[number]
7
8type Counts<K extends string> = Record<K, number>
9
10export type DailyRollup = {
11 schema_version: typeof SCHEMA_VERSION
12 day: string
13 org: string
14 role: Role
15 privacy: { mechanism: 'k-rr'; p_truth: number }
16 activity: {
17 tickets_worked: number
18 turns_per_ticket: Counts<'1-5' | '6-15' | '16-40' | '41+'>
19 work_type: Counts<'feature' | 'bug' | 'chore' | 'spike' | 'unknown'>
20 prompt_class: Counts<'initial' | 'clarification' | 'refinement' | 'correction' | 'unclassified'>
21 hard_signals: Counts<'interrupts' | 'permission_rejections' | 'reverts'>
22 agents: Counts<'internal' | 'third_party'>
23 bmad_phases: Counts<'analysis' | 'planning' | 'solutioning' | 'implementation'>
24 }
25}
26
27// Placeholder until the noise level is chosen (see docs/rollup-schema.md).
28export const DEFAULT_P_TRUTH = 0.75
29
30export function utcDay(epochMs: number): string {
31 return new Date(epochMs).toISOString().slice(0, 10)
32}
33
34export function emptyRollup(day: string, role: Role, org = 'unknown'): DailyRollup {
35 return {
36 schema_version: SCHEMA_VERSION,
37 day,
38 org,
39 role,
40 privacy: { mechanism: 'k-rr', p_truth: DEFAULT_P_TRUTH },
41 activity: {
42 tickets_worked: 0,
43 turns_per_ticket: { '1-5': 0, '6-15': 0, '16-40': 0, '41+': 0 },
44 work_type: { feature: 0, bug: 0, chore: 0, spike: 0, unknown: 0 },
45 prompt_class: { initial: 0, clarification: 0, refinement: 0, correction: 0, unclassified: 0 },
46 hard_signals: { interrupts: 0, permission_rejections: 0, reverts: 0 },
47 agents: { internal: 0, third_party: 0 },
48 bmad_phases: { analysis: 0, planning: 0, solutioning: 0, implementation: 0 },
49 },
50 }
51}
52