@ai-adoption-score/ai-coding-usage 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,15 +21,19 @@ In short: connect, choose on the survey page, then share. Before the choice, onl
21
21
  2. **The add-on puts its hooks and the ping-back in place** and copies itself to `~/.ai-adoption-score`. Why: the
22
22
  survey gets the numbers after every session without anyone running a command again, and a hook can't run from
23
23
  npx's temporary folder. The ping-back carries no numbers; it lets the report tell "no AI use today" from "no signal".
24
- 3. **It sends the names of the repositories used in the last 30 days, and nothing else.** Why: the person chooses on
24
+ 3. **It sends the names of the repositories used in the last 90 days, and nothing else.** Why: the person chooses on
25
25
  their survey page, and the page needs the names to list them. The choice isn't asked in the terminal, because the
26
26
  line usually runs inside Claude Code or Codex, where the add-on can't ask questions. The server keeps the list for
27
27
  an hour at most.
28
28
  4. **The person ticks what to share** on their survey page; everything is ticked to start with. When they press
29
29
  Share, the list is deleted, and with it the names they didn't tick.
30
- 5. **Only then do numbers leave the Mac:** the last 30 days of what they ticked, then the new numbers after each
30
+ 5. **Only then do numbers leave the Mac:** the last 90 days of what they ticked, then the new numbers after each
31
31
  session. Before every sync the add-on asks what they chose and keeps everything else on the Mac; the server checks
32
- the same rule again.
32
+ the same rule again. After that first sync, each sync reads the last 30 days only, where rows can still change.
33
+ Why 90: the survey's report can show three months. Claude Code deletes its own records after 30 days unless its
34
+ `cleanupPeriodDays` setting says otherwise, so the older days hold what is left of them; Codex keeps its records.
35
+ A Mac connected with an earlier version, which sent 30 days, sends the 60 before them once, after it updates
36
+ itself and the server says it takes 90.
33
37
  6. **It keeps itself up to date.** Once a day the ping-back asks npm for the latest version. A newer one is
34
38
  downloaded, checked against the checksum npm lists for it, started once to see that it runs on this Mac's Node, and
35
39
  put in place of the add-on's copy; the hooks, the ping-back and the settings stay as they are. Why: a fix or a new
@@ -41,7 +45,7 @@ In short: connect, choose on the survey page, then share. Before the choice, onl
41
45
  The person makes their choice on the survey page, so the survey's server keeps it and the add-on follows the server's
42
46
  answer. It still checks what it's told:
43
47
 
44
- - It reads 30 days back at most, whatever the server asks for.
48
+ - It reads 90 days back at most, whatever the server asks for, and 30 when the server names no number.
45
49
  - It shares nothing when the choice isn't one of the two kinds the server keeps: what the person ticked, or
46
50
  `{ "all": true }` for links made before people could choose.
47
51
  - It sends the token over https only. Plain http works only for this Mac's own address (`localhost`), for testing.
@@ -49,8 +53,8 @@ answer. It still checks what it's told:
49
53
  ## Commands
50
54
 
51
55
  - `connect <code> [--server <url>]`: links this Mac to the survey with the code from the survey page and puts the
52
- hooks and the ping-back in place. Then it sends the names of the repositories used in the last 30 days, and nothing
53
- else, so the person can choose on their survey page which to share; a background wait shares the last 30 days of
56
+ hooks and the ping-back in place. Then it sends the names of the repositories used in the last 90 days, and nothing
57
+ else, so the person can choose on their survey page which to share; a background wait shares the last 90 days of
54
58
  those once they press Share. The server defaults to `https://app.ai-adoption-score.app`;
55
59
  `AI_ADOPTION_SCORE_SERVER` or `--server http://localhost:3000` point it at a local app; any other server has to be
56
60
  https. Run again on a Mac that is connected, it keeps that Mac's link on the same server (the survey page still
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-adoption-score/ai-coding-usage",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Shares how you use Claude Code and Codex with your company's AI Adoption Score survey. Never prompts, AI answers, code or file names.",
5
5
  "homepage": "https://ai-adoption-score.app/product/ai-coding-usage",
6
6
  "repository": {
package/src/checkin.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  import { existsSync, readdirSync, statSync } from 'node:fs'
3
3
  import { join } from 'node:path'
4
4
  import { CLIENT_VERSION, post } from './api.mjs'
5
- import { isCurrentConfig, readConfig } from './config.mjs'
5
+ import { isCurrentConfig, readConfig, takeHistoryDays } from './config.mjs'
6
6
  import { hookState, uninstallAll } from './install.mjs'
7
7
  import { log } from './log.mjs'
8
8
  import { paths } from './paths.mjs'
@@ -89,6 +89,8 @@ export async function runCheckIn(env = process.env) {
89
89
  updateState((current) => {
90
90
  current.lastCheckInAt = new Date().toISOString()
91
91
  }, env)
92
+ // A server that now takes more days back than these settings say: the next sync sends the days in between once.
93
+ if (status === 'active') takeHistoryDays(config, response.body, env)
92
94
  log(`check-in: ${response.status} ${status ?? ''} claude=${checkIn.claudeHook} codex=${checkIn.codexHook}`, env)
93
95
  } catch (error) {
94
96
  log(`check-in failed: ${error instanceof Error ? error.message : String(error)}`, env)
package/src/cli.mjs CHANGED
@@ -10,7 +10,7 @@ import { DEFAULT_SERVER, readConfig, writeConfig } from './config.mjs'
10
10
  import { runHook } from './hook.mjs'
11
11
  import { hookState, installAll, uninstallAll } from './install.mjs'
12
12
  import { paths } from './paths.mjs'
13
- import { daysToRead, scanUsage } from './scan.mjs'
13
+ import { MAX_HISTORY_DAYS, daysToRead, scanUsage } from './scan.mjs'
14
14
  import { timeByHour } from './rows.mjs'
15
15
  import { readState } from './state.mjs'
16
16
  import { describeChoice, repositoryList } from './choice.mjs'
@@ -28,15 +28,15 @@ Usage
28
28
  ai-coding-usage preview [--days 30] [--company github.com/acme] [--json]
29
29
 
30
30
  connect Link this Mac to your survey with the code from your survey page. It adds a hook to Claude Code and
31
- Codex and an automatic ping-back, and sends the names of the repositories you used in the last 30 days, so
32
- you can choose on your survey page which to share. Then it shares the last 30 days of those, and after
31
+ Codex and an automatic ping-back, and sends the names of the repositories you used in the last 90 days, so
32
+ you can choose on your survey page which to share. Then it shares the last 90 days of those, and after
33
33
  that, after each session. Run it again to change your choice. Once a day the ping-back looks for a
34
34
  newer version of the add-on and puts it in place.
35
35
  status Show which survey this Mac shares with, what you chose, its hooks, when it last shared, and its version.
36
36
  sync Share now, instead of after the next session.
37
37
  disconnect Stop sharing from this Mac: removes the hooks, the ping-back and the add-on's settings.
38
38
  preview Count this Mac's last 30 days and show what would be shared. Nothing is sent.
39
- --days How many days back, 1 to 30.
39
+ --days How many days back, 1 to 90.
40
40
  --company An account whose repositories are your company's, to mark them. Repeat for more.
41
41
  --json Print the exact rows instead of the summary.
42
42
  `
@@ -131,7 +131,7 @@ async function connect(args) {
131
131
  return fail(response.body?.error ?? `Couldn't connect: the server answered ${response.status}.`)
132
132
  }
133
133
  const { token, companyName, syncIntervalSeconds } = response.body
134
- // However far back the survey asks for, the add-on reads 30 days at most.
134
+ // However far back the survey asks for, the add-on reads 90 days at most (config.mjs).
135
135
  const historyDays = daysToRead(response.body.historyDays)
136
136
  // A new connection starts with nothing sent.
137
137
  rmSync(paths().state, { force: true })
@@ -159,7 +159,7 @@ async function connect(args) {
159
159
  const found = list.repositories.length
160
160
  say(`Found ${found} ${found === 1 ? 'repository' : 'repositories'}${list.outsideGit ? ', and work outside a repository' : ''}, in the last ${historyDays} days.`)
161
161
  say('Now choose on your survey page which to share. Nothing from your sessions is sent until you do.')
162
- say('Then the last 30 days of what you chose are shared, and after that, your numbers after each session.')
162
+ say(`Then the last ${historyDays} days of what you chose are shared, and after that, your numbers after each session.`)
163
163
  // Waits in the background, so this line returns at once in Claude Code or Codex too.
164
164
  const waiter = spawn(process.execPath, [paths().appBin, 'sync', '--background', '--wait-for-choice'], {
165
165
  detached: true,
@@ -259,7 +259,7 @@ async function preview(rest) {
259
259
  if (option === '--json') json = true
260
260
  else if (option === '--days') {
261
261
  days = Number(rest[++index])
262
- if (!Number.isInteger(days) || days < 1 || days > 30) return fail('--days needs a whole number from 1 to 30.')
262
+ if (!Number.isInteger(days) || days < 1 || days > MAX_HISTORY_DAYS) return fail(`--days needs a whole number from 1 to ${MAX_HISTORY_DAYS}.`)
263
263
  } else if (option === '--company') {
264
264
  const account = rest[++index]
265
265
  if (!account) return fail('--company needs an account, like github.com/acme.')
package/src/config.mjs CHANGED
@@ -7,12 +7,44 @@ import { join } from 'node:path'
7
7
  * The add-on's own folder, `~/.ai-adoption-score`: the connection's settings and its device token. Only the person's
8
8
  * own account can read it. `AI_ADOPTION_SCORE_HOME` points it elsewhere, for testing.
9
9
  *
10
+ * `historyDays` is how many days back the server takes, and `historyDaysSent` how many of them went out in one sync:
11
+ * fewer, and the next sync reads them all once (`sync.mjs`).
12
+ *
10
13
  * @typedef {{ server: string, token: string, companyName: string, connectedAt: string, syncIntervalSeconds: number,
11
- * historyDays: number, lastSyncAt?: string, initialSyncDone?: boolean }} UsageConfig
14
+ * historyDays: number, historyDaysSent?: number, lastSyncAt?: string, initialSyncDone?: boolean }} UsageConfig
12
15
  */
13
16
 
14
17
  export const DEFAULT_SERVER = 'https://app.ai-adoption-score.app'
15
18
 
19
+ /** The add-on never reads further back than this, whatever the server asks for. */
20
+ export const MAX_HISTORY_DAYS = 90
21
+ /** What a server that names no number takes: every server before add-on 1.2 took 30 days. */
22
+ const DEFAULT_HISTORY_DAYS = 30
23
+
24
+ /** How many days to read: a whole number from 1 to 90, and 30 when `days` isn't one. @param {unknown} days */
25
+ export function daysToRead(days) {
26
+ const whole = Math.trunc(Number(days))
27
+ return Number.isFinite(whole) && whole >= 1 ? Math.min(whole, MAX_HISTORY_DAYS) : DEFAULT_HISTORY_DAYS
28
+ }
29
+
30
+ /**
31
+ * A server's answer to a sync or a check-in that takes more days than this Mac's settings say (`historyDays`: 30 before,
32
+ * 90 since add-on 1.2): the settings take the new number, so the next sync sends the days in between once. Never fewer,
33
+ * so a Mac never sends a day its server would refuse. Returns the settings as they are now.
34
+ * @param {UsageConfig} config
35
+ * @param {unknown} answer
36
+ * @returns {UsageConfig}
37
+ */
38
+ export function takeHistoryDays(config, answer, env = process.env) {
39
+ const named = answer && typeof answer === 'object' && 'historyDays' in answer ? answer.historyDays : undefined
40
+ if (named === undefined) return config
41
+ const offered = daysToRead(named)
42
+ if (offered <= (config.historyDays ?? 0) || !isCurrentConfig(config, env)) return config
43
+ const next = { ...(readConfig(env) ?? config), historyDays: offered }
44
+ writeConfig(next, env)
45
+ return next
46
+ }
47
+
16
48
  export function configDir(env = process.env) {
17
49
  return env.AI_ADOPTION_SCORE_HOME || join(homedir(), '.ai-adoption-score')
18
50
  }
package/src/scan.mjs CHANGED
@@ -4,22 +4,18 @@ import { readCodex } from './codex.mjs'
4
4
  import { loadDesktopConnectorNames } from './connectors.mjs'
5
5
  import { assignCommits, createGitInfo } from './git.mjs'
6
6
  import { createRowSet, finishRow, hasUse, windowStart } from './rows.mjs'
7
+ import { daysToRead } from './config.mjs'
7
8
 
8
- /** The add-on never reads further back than this, whatever the server asks for. */
9
- export const MAX_HISTORY_DAYS = 30
10
-
11
- /** How many days to read: a whole number from 1 to 30, and 30 when `days` isn't one. @param {unknown} days */
12
- export function daysToRead(days) {
13
- const whole = Math.trunc(Number(days))
14
- return Number.isFinite(whole) && whole >= 1 ? Math.min(whole, MAX_HISTORY_DAYS) : MAX_HISTORY_DAYS
15
- }
9
+ export { MAX_HISTORY_DAYS, daysToRead } from './config.mjs'
16
10
 
17
11
  /**
18
- * Reads this Mac's Claude Code and Codex records for the last `days` calendar days, 30 at most, and returns the rows
19
- * that would be sent, with how much of the records could be read. Nothing is written and nothing leaves the Mac.
12
+ * Reads this Mac's Claude Code and Codex records for the last `days` calendar days (30 unless asked, 90 at most) and
13
+ * returns the rows that would be sent, with how much of the records could be read. Nothing is written and nothing leaves
14
+ * the Mac. Claude Code deletes its own records after 30 days unless its `cleanupPeriodDays` says otherwise, so older days
15
+ * hold what is left of them; Codex keeps its records.
20
16
  * @param {{ days?: number, now?: number, env?: NodeJS.ProcessEnv }} [options]
21
17
  */
22
- export async function scanUsage({ days = MAX_HISTORY_DAYS, now = Date.now(), env = process.env } = {}) {
18
+ export async function scanUsage({ days = 30, now = Date.now(), env = process.env } = {}) {
23
19
  const since = windowStart(now, daysToRead(days))
24
20
  const rows = createRowSet()
25
21
  const gitInfo = createGitInfo()
package/src/sync.mjs CHANGED
@@ -5,7 +5,7 @@ import { constants, setPriority } from 'node:os'
5
5
  import { get, post, CLIENT_VERSION } from './api.mjs'
6
6
  import { reportedHookState } from './checkin.mjs'
7
7
  import { isShared, readChoice } from './choice.mjs'
8
- import { isCurrentConfig, writeConfig } from './config.mjs'
8
+ import { isCurrentConfig, readConfig, takeHistoryDays, writeConfig } from './config.mjs'
9
9
  import { hookState, uninstallAll } from './install.mjs'
10
10
  import { log } from './log.mjs'
11
11
  import { paths } from './paths.mjs'
@@ -18,6 +18,12 @@ const MAX_ROWS = 400
18
18
  const MAX_BYTES = 200 * 1024
19
19
  /** A sync that started this long ago and still holds the lock has died. */
20
20
  const STALE_LOCK_MS = 15 * 60_000
21
+ /**
22
+ * The days every sync reads once all the days the server takes went out: the rows that can still change (today's, a
23
+ * branch pushed later, a commit made after a session) are in them, and reading more would only take longer (about 20
24
+ * seconds for 30 days on a busy Mac).
25
+ */
26
+ export const SYNC_DAYS = 30
21
27
 
22
28
  /** @param {import('./rows.mjs').UsageRow[]} rows */
23
29
  export function batches(rows) {
@@ -136,7 +142,11 @@ export async function syncNow(config, { background = false, reason = 'manual', e
136
142
  }
137
143
  const { choice } = asked
138
144
 
139
- const scan = await scanUsage({ days: config.historyDays, env })
145
+ // All the days the server takes go out in one sync: the first one, and once more when the server comes to take
146
+ // more than before (90 instead of 30 since add-on 1.2). Every other sync reads the last 30 days.
147
+ const wholeWindow = (config.historyDaysSent ?? 0) < config.historyDays
148
+ const days = wholeWindow ? config.historyDays : Math.min(config.historyDays, SYNC_DAYS)
149
+ const scan = await scanUsage({ days, env })
140
150
  // Time by hour among the rows that go out only, so a session that stays on the Mac takes no share of it.
141
151
  const included = timeByHour(scan.rows.filter((row) => sendsTool(row.tool, config, state, env) && isShared(choice, row.repository)))
142
152
  const changed = included.filter((row) => state.sent[rowKey(row)] !== rowHash(row))
@@ -148,9 +158,12 @@ export async function syncNow(config, { background = false, reason = 'manual', e
148
158
  const groups = changed.length > 0 ? batches(changed) : [[]]
149
159
  let accepted = 0
150
160
  let status = asked.status === 'revoked' || asked.status === 'closed' ? asked.status : 'active'
161
+ /** @type {unknown} */
162
+ let answer = null
151
163
  for (const [index, rows] of status === 'active' ? groups.entries() : []) {
152
164
  const body = { schemaVersion: 1, clientVersion: CLIENT_VERSION, rows, ...(index === 0 ? { checkIn } : {}) }
153
165
  const response = await post(config.server, '/api/usage/sync', body, config.token)
166
+ answer = response.body
154
167
  status = response.body?.status ?? (response.status === 401 ? 'revoked' : 'error')
155
168
  if (response.status === 429 || status === 'paused') {
156
169
  const wait = (response.retryAfterSeconds ?? response.body?.nextSyncAfterSeconds ?? 600) * 1000
@@ -183,9 +196,19 @@ export async function syncNow(config, { background = false, reason = 'manual', e
183
196
  updateState((current) => {
184
197
  current.sent = Object.fromEntries(Object.entries(current.sent).filter(([key]) => keep.has(key)))
185
198
  }, env)
186
- // The first sync is the one with the person's choice: it sends the last 30 days of every tool.
187
- if (choice && !config.initialSyncDone && isCurrentConfig(config, env)) writeConfig({ ...config, initialSyncDone: true, lastSyncAt: new Date().toISOString() }, env)
188
- log(`sync (${reason}): sent ${accepted} of ${included.length} rows`, env)
199
+ // The first sync is the one with the person's choice: it sends every tool's days. Once all the days the server takes
200
+ // went out, the next syncs read the last 30.
201
+ if (choice && (!config.initialSyncDone || wholeWindow) && isCurrentConfig(config, env)) {
202
+ const current = readConfig(env) ?? config
203
+ writeConfig({
204
+ ...current,
205
+ ...(config.initialSyncDone ? {} : { initialSyncDone: true, lastSyncAt: new Date().toISOString() }),
206
+ ...(wholeWindow ? { historyDaysSent: days } : {}),
207
+ }, env)
208
+ }
209
+ // A server that takes more days than these settings say: the next sync sends the days in between.
210
+ takeHistoryDays(config, answer, env)
211
+ log(`sync (${reason}): sent ${accepted} of ${included.length} rows${wholeWindow ? `, reading ${days} days` : ''}`, env)
189
212
  } else {
190
213
  log(`sync (${reason}): ${status}`, env)
191
214
  }
@@ -205,9 +228,9 @@ export async function syncNow(config, { background = false, reason = 'manual', e
205
228
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
206
229
 
207
230
  /**
208
- * Right after connecting, in the background: waits while the person chooses on their survey page, then sends the last
209
- * 30 days of what they chose. It gives up when the list on their page expires (an hour); the next sync after they
210
- * choose sends them all the same.
231
+ * Right after connecting, in the background: waits while the person chooses on their survey page, then sends the days
232
+ * the server takes (90) of what they chose. It gives up when the list on their page expires (an hour); the next sync
233
+ * after they choose sends them all the same.
211
234
  * @param {import('./config.mjs').UsageConfig} config
212
235
  * @param {{ env?: NodeJS.ProcessEnv, timeoutMs?: number }} [options]
213
236
  */