@ai-adoption-score/ai-coding-usage 0.0.0-stage → 1.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AI Adoption Score
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,157 @@
1
- # Temporary Holding Version
1
+ # @ai-adoption-score/ai-coding-usage
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The add-on behind [AI Coding Usage](https://ai-adoption-score.app/product/ai-coding-usage), a survey in AI Adoption
4
+ Score. It counts how a person uses Claude Code and Codex on their Mac and, once connected, shares the numbers with their
5
+ company's survey. Prompts, the AI's answers, code, file names, command text and session titles never leave the Mac.
6
+
7
+ You don't install it yourself: your survey page shows one line with a code,
8
+ `npx @ai-adoption-score/ai-coding-usage@1 connect <code>`, to paste into Claude Code, Codex or Terminal.
9
+
10
+ Connected once, it shares by itself: a hook in Claude Code and Codex syncs after a session's replies, at most every 5
11
+ minutes and always when a session ends, and an automatic ping-back, once an hour and without any numbers, tells the
12
+ report "no AI use today" from "no signal".
13
+
14
+ ## How it works, step by step
15
+
16
+ In short: connect, choose on the survey page, then share. Before the choice, only repository names leave the Mac.
17
+
18
+ 1. **The person pastes the line from their survey page** into Claude Code, Codex or Terminal. The code in it works
19
+ once, for 30 minutes; the server trades it for a token for this Mac.
20
+ 2. **The add-on puts its hooks and the ping-back in place** and copies itself to `~/.ai-adoption-score`. Why: the
21
+ survey gets the numbers after every session without anyone running a command again, and a hook can't run from
22
+ npx's temporary folder. The ping-back carries no numbers; it lets the report tell "no AI use today" from "no signal".
23
+ 3. **It sends the names of the repositories used in the last 30 days, and nothing else.** Why: the person chooses on
24
+ their survey page, and the page needs the names to list them. The choice isn't asked in the terminal, because the
25
+ line usually runs inside Claude Code or Codex, where the add-on can't ask questions. The server keeps the list for
26
+ an hour at most.
27
+ 4. **The person ticks what to share** on their survey page; everything is ticked to start with. When they press
28
+ Share, the list is deleted, and with it the names they didn't tick.
29
+ 5. **Only then do numbers leave the Mac:** the last 30 days of what they ticked, then the new numbers after each
30
+ session. Before every sync the add-on asks what they chose and keeps everything else on the Mac; the server checks
31
+ the same rule again.
32
+ 6. **It removes itself** when the Mac is disconnected on the survey page or the survey closes. `disconnect` does the
33
+ same from the Mac.
34
+
35
+ The person makes their choice on the survey page, so the survey's server keeps it and the add-on follows the server's
36
+ answer. It still checks what it's told:
37
+
38
+ - It reads 30 days back at most, whatever the server asks for.
39
+ - It shares nothing when the choice isn't one of the two kinds the server keeps: what the person ticked, or
40
+ `{ "all": true }` for links made before people could choose.
41
+ - It sends the token over https only. Plain http works only for this Mac's own address (`localhost`), for testing.
42
+
43
+ ## Commands
44
+
45
+ - `connect <code> [--server <url>]`: links this Mac to the survey with the code from the survey page and puts the
46
+ hooks and the ping-back in place. Then it sends the names of the repositories used in the last 30 days, and nothing
47
+ else, so the person can choose on their survey page which to share; a background wait shares the last 30 days of
48
+ those once they press Share. The server defaults to `https://app.ai-adoption-score.app`;
49
+ `AI_ADOPTION_SCORE_SERVER` or `--server http://localhost:3000` point it at a local app; any other server has to be
50
+ https. Run again on a Mac that is connected, it keeps that Mac's link on the same server (the survey page still
51
+ shows one Mac) and lets the person choose again; a link on another server is disconnected, and its token never goes
52
+ to the new one.
53
+ - `sync`: shares now instead of after the next session. It counts the window again and sends the rows that changed
54
+ since the server last accepted them; the server replaces what it had, so nothing counts twice.
55
+ - `status`: which survey this Mac shares with, what the person chose, the hooks and when they last ran, and when it
56
+ last shared.
57
+ - `disconnect`: stops sharing from this Mac and removes everything it put in place.
58
+ - `preview [--days 30] [--company github.com/acme] [--json]`: what would be shared, without sending.
59
+
60
+ The hooks run `hook <tool>` and the ping-back runs `check-in`, its name inside the add-on; people don't.
61
+
62
+ ## What it puts in place
63
+
64
+ - `~/.ai-adoption-score/`, readable only by the Mac's user: `config.json` with the device token, `state.json` with a
65
+ fingerprint of every row the server accepted and when each hook last ran, a short `log.txt`, a copy of the add-on in
66
+ `app/` (a hook can't depend on npx's temporary folder), and `run.sh`, which finds Node for the hooks, because desktop
67
+ apps don't load the shell's PATH.
68
+ - A hook for `SessionStart`, `Stop` and `SessionEnd` in `~/.claude/settings.json` and `~/.codex/hooks.json`, added
69
+ after the hooks already there. The command never changes between versions, because Codex trusts a hook by its exact
70
+ text, its name and its place. Codex runs it only once the person trusts it: the Codex app lists it under Settings →
71
+ Hooks as "AI Adoption Score", a "New hook" with a Trust button, and Codex in Terminal asks when it starts.
72
+ - `~/Library/LaunchAgents/app.ai-adoption-score.check-in.plist`, the ping-back, once an hour at low priority.
73
+
74
+ The settings files are written in their own style (indent, last newline, `\u` escapes), through a link when the file
75
+ is one, so taking the hook out leaves them byte for byte as they were.
76
+
77
+ A hook prints nothing (a `SessionStart` hook's output would go into the conversation, and Codex refuses text from a
78
+ `Stop` hook), returns in about 40 ms and starts the sync in its own low-priority process. One sync runs at a time. A
79
+ sync that can't reach the server keeps the rows changed, so the next one sends them: the Mac is the queue.
80
+
81
+ **Each tool pauses on its own.** After the first sync, a tool's rows go out only while its hook is in place and has
82
+ run since connecting. Declining the Codex hook, or deleting either hook, pauses that tool; when the hook is back, the
83
+ waiting days go out, within the 30 days the records last.
84
+
85
+ **It removes itself** when the survey page disconnects the Mac or the survey closes: the next sync or ping-back hears
86
+ it and takes away the hooks, the ping-back and the folder. If someone deletes the folder by hand, the leftover hooks do
87
+ nothing, and `disconnect` still takes them away.
88
+
89
+ For testing, `AI_ADOPTION_SCORE_HOME`, `AI_ADOPTION_SCORE_CLAUDE_SETTINGS`, `AI_ADOPTION_SCORE_CODEX_HOOKS` and
90
+ `AI_ADOPTION_SCORE_LAUNCH_AGENTS` move each of these elsewhere, and `AI_ADOPTION_SCORE_SKIP_LAUNCHCTL=1` leaves launchd
91
+ alone. `AI_ADOPTION_SCORE_NODE` names a Node for `run.sh` to try.
92
+
93
+ ## Try it
94
+
95
+ Count this Mac's last 30 days and see what would be shared, without connecting and without sending anything:
96
+
97
+ ```bash
98
+ npx @ai-adoption-score/ai-coding-usage@1 preview
99
+ ```
100
+
101
+ Add `--json` to see the exact rows, or `--days 7` for a shorter window. It needs Node.js 18.17 or newer and git, and
102
+ runs on macOS only for now.
103
+
104
+ ## What it reads
105
+
106
+ - **Claude Code:** `~/.claude/projects/**/*.jsonl`, or `$CLAUDE_CONFIG_DIR/projects`. For each line: its kind, time,
107
+ session, folder and branch, the model and token counts of the AI's replies, and the names of the tools it used.
108
+ For the AI's edits, the diff Claude Code stores with each result. Lines that hold prompt text or titles
109
+ (`last-prompt`, `custom-title`, `ai-title`) are skipped.
110
+ - **Codex:** `~/.codex/sessions/**/rollout-*.jsonl`, or `$CODEX_HOME/sessions`. For each line: its kind, time, thread,
111
+ folder, repository and branch, the model, per-reply token records, and tool and connector names.
112
+ `session_index.jsonl`, which holds thread names, is never opened.
113
+ - **The Claude desktop app's connector names:** only `remoteMcpServersConfig` in its session files, so claude.ai
114
+ connectors show as "Notion" rather than an id. Everything else in those files is skipped.
115
+ - **git, in each session's folder:** the remote's address, which branches exist on a remote, and the person's own
116
+ commits by time. No file is opened and nothing is fetched.
117
+
118
+ Text is looked at only to tell a prompt from a tool result and to count changed lines. An edited file's path is
119
+ used only to check that the edit is inside the session's project. Neither is ever kept.
120
+
121
+ ## Which repositories
122
+
123
+ Nothing from the sessions leaves the Mac before the person chooses. The page lists the repositories the add-on found,
124
+ by owner, all ticked to start with; the names of the ones they untick are deleted when they press Share, and sessions
125
+ already shared from them are deleted too. Before every sync the add-on asks what they chose (`GET
126
+ /api/usage/repositories`) and sends only those sessions (`src/choice.mjs`); the server checks the same rule again.
127
+
128
+ A repository that shows up later is shared when its owner (`github.com/acme`) was ticked in full, and so is a new
129
+ folder without a remote when every such folder was ticked. Anything else stays on the Mac until the person runs the line
130
+ again and ticks it. Work outside any repository is one more box.
131
+
132
+ ## What a row holds
133
+
134
+ One row per session, day, repository and branch: the tool, a hash of the session id, the day and the first and last
135
+ moment, the repository and branch and whether the branch is pushed, active minutes, prompts, stops, rejected actions,
136
+ commands, file edits, lines added and removed, connector calls by name, skills, helper agents, commits and pushed
137
+ commits, and per model its replies and tokens. `npx @ai-adoption-score/ai-coding-usage@1 preview --json` prints the exact rows this Mac
138
+ would send.
139
+
140
+ Never shared: prompts, AI answers, code, file names and paths, command text, session titles.
141
+
142
+ ## How it counts
143
+
144
+ - Each Claude Code reply is written as several lines that repeat its usage, so it counts once, by message id and
145
+ request id. A resumed session copies earlier lines into a new file, so each line counts once, by its id.
146
+ - A helper agent's file carries its parent's session id: its tokens and tool calls count in the parent session.
147
+ - A Codex thread that Codex starts itself, such as its automatic reviewer, counts in the session that started it,
148
+ with no session or prompts of its own.
149
+ - Codex's input count includes the cached part; rows keep them apart, as Claude Code does.
150
+ - Connectors are tools someone connected: GitHub, Linear, Notion. The apps' own tools (the desktop app's browser,
151
+ terminal and session tools, Codex's code runners) aren't counted.
152
+ - A removed folder, such as a deleted worktree, counts under the repository around it, when that still exists.
153
+ - An edit counts once Claude Code reports it done, and only inside the session's project: its repository, or its
154
+ folder outside git. Its lines come from the diff Claude Code stores with the result, and a new file counts all its
155
+ lines as added. A failed or turned-down edit, or a scratch file elsewhere, doesn't count.
156
+ - Active minutes add up the gaps under five minutes. A commit counts for the session whose window holds it, from its
157
+ first moment to an hour after its last; each commit counts once.
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ import { runCli } from '../src/cli.mjs'
4
+
5
+ runCli(process.argv.slice(2)).then(
6
+ (code) => {
7
+ process.exitCode = code
8
+ },
9
+ (error) => {
10
+ process.stderr.write(`Something went wrong: ${error instanceof Error ? error.message : String(error)}\n`)
11
+ process.exitCode = 1
12
+ }
13
+ )
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
2
  "name": "@ai-adoption-score/ai-coding-usage",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.1",
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
+ "homepage": "https://ai-adoption-score.app/product/ai-coding-usage",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/ai-adoption-score/ai-coding-usage.git"
9
+ },
10
+ "bugs": {
11
+ "url": "https://github.com/ai-adoption-score/ai-coding-usage/issues",
12
+ "email": "support@ai-adoption-score.app"
13
+ },
14
+ "author": "AI Adoption Score <support@ai-adoption-score.app> (https://ai-adoption-score.app)",
15
+ "keywords": [
16
+ "ai-adoption-score",
17
+ "claude-code",
18
+ "codex",
19
+ "ai-coding",
20
+ "usage"
21
+ ],
22
+ "type": "module",
23
+ "bin": {
24
+ "ai-coding-usage": "bin/ai-coding-usage.mjs"
25
+ },
26
+ "files": [
27
+ "bin",
28
+ "src",
29
+ "README.md"
30
+ ],
31
+ "engines": {
32
+ "node": ">=18.17"
33
+ },
34
+ "license": "MIT",
35
+ "publishConfig": {
36
+ "access": "public"
37
+ }
38
+ }
package/src/api.mjs ADDED
@@ -0,0 +1,70 @@
1
+ // @ts-check
2
+ import { readFileSync } from 'node:fs'
3
+
4
+ /** Talking to the survey's server. Only JSON with numbers and names goes out; the device token goes in a header. */
5
+
6
+ const TIMEOUT_MS = 20_000
7
+
8
+ export const CLIENT_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
9
+
10
+ /** Plain http goes only to this Mac itself, for testing; the device token goes anywhere else over https. */
11
+ const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]'])
12
+
13
+ /** Why the add-on won't talk to `server`, or null when it will. @param {string} server */
14
+ export function serverProblem(server) {
15
+ let url
16
+ try {
17
+ url = new URL(server)
18
+ } catch {
19
+ return `"${server}" isn't a web address.`
20
+ }
21
+ if (url.protocol === 'https:') return null
22
+ if (url.protocol === 'http:' && LOCAL_HOSTS.has(url.hostname)) return null
23
+ return `"${server}" isn't https. Plain http works only for this Mac's own address (localhost), for testing.`
24
+ }
25
+
26
+ /**
27
+ * @param {string} server @param {string} path @param {unknown} body @param {string} [token]
28
+ * @returns {Promise<{ status: number, body: any, retryAfterSeconds: number | null }>}
29
+ */
30
+ export async function post(server, path, body, token) {
31
+ return request(server, path, 'POST', body, token)
32
+ }
33
+
34
+ /**
35
+ * @param {string} server @param {string} path @param {string} [token]
36
+ * @returns {Promise<{ status: number, body: any, retryAfterSeconds: number | null }>}
37
+ */
38
+ export async function get(server, path, token) {
39
+ return request(server, path, 'GET', undefined, token)
40
+ }
41
+
42
+ /** @param {string} server @param {string} path @param {'GET' | 'POST'} method @param {unknown} body @param {string} [token] */
43
+ async function request(server, path, method, body, token) {
44
+ const problem = serverProblem(server)
45
+ if (problem) throw new Error(problem)
46
+ const controller = new AbortController()
47
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
48
+ try {
49
+ const response = await fetch(new URL(path, server), {
50
+ method,
51
+ headers: {
52
+ ...(method === 'POST' ? { 'content-type': 'application/json' } : {}),
53
+ 'user-agent': `ai-coding-usage/${CLIENT_VERSION}`,
54
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
55
+ },
56
+ ...(method === 'POST' ? { body: JSON.stringify(body) } : {}),
57
+ signal: controller.signal,
58
+ })
59
+ let parsed = null
60
+ try {
61
+ parsed = await response.json()
62
+ } catch {
63
+ parsed = null
64
+ }
65
+ const retryAfter = Number(response.headers.get('retry-after'))
66
+ return { status: response.status, body: parsed, retryAfterSeconds: Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : null }
67
+ } finally {
68
+ clearTimeout(timer)
69
+ }
70
+ }
@@ -0,0 +1,96 @@
1
+ // @ts-check
2
+ import { existsSync, readdirSync, statSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { CLIENT_VERSION, post } from './api.mjs'
5
+ import { isCurrentConfig, readConfig } from './config.mjs'
6
+ import { hookState, uninstallAll } from './install.mjs'
7
+ import { log } from './log.mjs'
8
+ import { paths } from './paths.mjs'
9
+ import { localDay } from './rows.mjs'
10
+ import { readState, updateState } from './state.mjs'
11
+
12
+ /**
13
+ * The hourly check-in (a launchd agent): tells the survey this Mac is there, and whether each tool's hook is still in
14
+ * place and running. It sends no usage numbers. A day with a check-in and no use is a real zero in the report; a day
15
+ * without one is a gap.
16
+ */
17
+
18
+ const NOT_APPROVED_GRACE_MS = 15 * 60_000
19
+
20
+ /** Whether any of a tool's records changed since a moment: only file dates are read, never their contents. */
21
+ function recordsChangedSince(dir, since) {
22
+ if (!existsSync(dir)) return false
23
+ const stack = [dir]
24
+ while (stack.length > 0) {
25
+ const current = /** @type {string} */ (stack.pop())
26
+ let entries
27
+ try {
28
+ entries = readdirSync(current, { withFileTypes: true })
29
+ } catch {
30
+ continue
31
+ }
32
+ for (const entry of entries) {
33
+ const path = join(current, entry.name)
34
+ if (entry.isDirectory()) stack.push(path)
35
+ else if (entry.name.endsWith('.jsonl')) {
36
+ try {
37
+ if (statSync(path).mtimeMs > since) return true
38
+ } catch {
39
+ // Gone between listing and reading.
40
+ }
41
+ }
42
+ }
43
+ }
44
+ return false
45
+ }
46
+
47
+ /**
48
+ * A tool's hook as the check-in reports it: `not-approved` when it is in place but hasn't run since connecting although
49
+ * the tool was used, as with a Codex hook nobody approved yet.
50
+ * @param {'claude-code' | 'codex'} tool
51
+ */
52
+ export function reportedHookState(tool, config, state, env = process.env) {
53
+ const present = hookState(tool, env)
54
+ if (present !== 'installed') return present
55
+ const ran = state.hookRuns?.[tool]
56
+ const connected = Date.parse(config.connectedAt)
57
+ if (ran && Date.parse(ran) >= connected) return 'installed'
58
+ // A session open while connecting keeps writing before its hook first runs: only use well after connecting counts.
59
+ const records = tool === 'claude-code' ? paths(env).claudeRecords : paths(env).codexRecords
60
+ return recordsChangedSince(records, connected + NOT_APPROVED_GRACE_MS) ? 'not-approved' : 'installed'
61
+ }
62
+
63
+ export async function runCheckIn(env = process.env) {
64
+ const config = readConfig(env)
65
+ if (!config) {
66
+ // Settings gone but the check-in left behind: take it away too.
67
+ uninstallAll(env)
68
+ return 0
69
+ }
70
+ const state = readState(env)
71
+ const checkIn = {
72
+ day: localDay(Date.now()),
73
+ claudeHook: reportedHookState('claude-code', config, state, env),
74
+ codexHook: reportedHookState('codex', config, state, env),
75
+ }
76
+ try {
77
+ const response = await post(config.server, '/api/usage/sync', { schemaVersion: 1, clientVersion: CLIENT_VERSION, rows: [], checkIn }, config.token)
78
+ const status = response.body?.status
79
+ if ((status === 'revoked' || status === 'closed') && !isCurrentConfig(config, env)) {
80
+ log(`check-in: ${status} for an earlier connection; this Mac connected again, so nothing is removed`, env)
81
+ return 0
82
+ }
83
+ if (status === 'revoked' || status === 'closed') {
84
+ log(`check-in: ${status}, removing the add-on`, env)
85
+ uninstallAll(env)
86
+ return 0
87
+ }
88
+ updateState((current) => {
89
+ current.lastCheckInAt = new Date().toISOString()
90
+ }, env)
91
+ log(`check-in: ${response.status} ${status ?? ''} claude=${checkIn.claudeHook} codex=${checkIn.codexHook}`, env)
92
+ } catch (error) {
93
+ log(`check-in failed: ${error instanceof Error ? error.message : String(error)}`, env)
94
+ }
95
+ return 0
96
+ }
package/src/choice.mjs ADDED
@@ -0,0 +1,92 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Which repositories the person shares. They choose on their survey page when this Mac connects: the add-on sends the
5
+ * names of the repositories it found, nothing else, and every sync sends only the sessions of what they chose. A new
6
+ * repository is shared when its owner was ticked in full; otherwise it stays on this Mac until they run the line again.
7
+ * The server applies the same rule.
8
+ *
9
+ * @typedef {{ repositories: string[], owners: string[], outsideGit: boolean, localFolders: boolean } | { all: true }} RepositoryChoice
10
+ */
11
+
12
+ /** A git repository without a remote, named after its folder. */
13
+ export const LOCAL_PREFIX = 'local:'
14
+
15
+ /** The owner of `github.com/acme/app`: `github.com/acme`. None for a folder without a remote. @param {string} repository */
16
+ export function repositoryOwner(repository) {
17
+ if (!repository || repository.startsWith(LOCAL_PREFIX)) return null
18
+ const parts = repository.split('/')
19
+ return parts.length >= 3 ? `${parts[0]}/${parts[1]}`.toLowerCase() : null
20
+ }
21
+
22
+ /**
23
+ * The choice in the server's answer, taken only in one of the two shapes the server keeps: what the person ticked, or
24
+ * `{ all: true }` for links made before people could choose. Anything else shares nothing.
25
+ * @param {unknown} value @returns {RepositoryChoice | null}
26
+ */
27
+ export function readChoice(value) {
28
+ if (!value || typeof value !== 'object') return null
29
+ const choice = /** @type {Record<string, unknown>} */ (value)
30
+ if ('all' in choice) return choice.all === true ? { all: true } : null
31
+ const names = (list) => Array.isArray(list) && list.every((name) => typeof name === 'string')
32
+ if (!names(choice.repositories) || !names(choice.owners)) return null
33
+ if (typeof choice.outsideGit !== 'boolean' || typeof choice.localFolders !== 'boolean') return null
34
+ return {
35
+ repositories: /** @type {string[]} */ (choice.repositories),
36
+ owners: /** @type {string[]} */ (choice.owners),
37
+ outsideGit: choice.outsideGit,
38
+ localFolders: choice.localFolders,
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Whether a session in `repository` (null outside any repository) is shared. Nothing is, before the person chooses.
44
+ * @param {RepositoryChoice | null} choice @param {string | null} repository
45
+ */
46
+ export function isShared(choice, repository) {
47
+ if (!choice) return false
48
+ if ('all' in choice) return choice.all === true
49
+ if (!repository) return choice.outsideGit
50
+ if (choice.repositories.includes(repository)) return true
51
+ if (repository.startsWith(LOCAL_PREFIX)) return choice.localFolders
52
+ const owner = repositoryOwner(repository)
53
+ return owner !== null && choice.owners.some((ticked) => ticked.toLowerCase() === owner)
54
+ }
55
+
56
+ /** At most this many names go to the page: the most recently used, if there are more. */
57
+ const MAX_REPOSITORIES = 500
58
+
59
+ /**
60
+ * What the person chooses from: the names of the repositories the rows ran in, and whether any work ran outside a
61
+ * repository. Nothing about the sessions themselves.
62
+ * @param {Array<{ repository: string | null, lastEventAt: string }>} rows
63
+ */
64
+ export function repositoryList(rows) {
65
+ /** @type {Map<string, string>} */
66
+ const lastUse = new Map()
67
+ let outsideGit = false
68
+ for (const row of rows) {
69
+ if (!row.repository) {
70
+ outsideGit = true
71
+ continue
72
+ }
73
+ const seen = lastUse.get(row.repository)
74
+ if (!seen || row.lastEventAt > seen) lastUse.set(row.repository, row.lastEventAt)
75
+ }
76
+ const repositories = [...lastUse.entries()]
77
+ .sort((left, right) => right[1].localeCompare(left[1]))
78
+ .slice(0, MAX_REPOSITORIES)
79
+ .map(([name]) => name)
80
+ .sort((left, right) => left.localeCompare(right))
81
+ return { schemaVersion: /** @type {const} */ (1), repositories, outsideGit }
82
+ }
83
+
84
+ /** What this Mac shares, in a line for `status`. @param {RepositoryChoice | null} choice */
85
+ export function describeChoice(choice) {
86
+ if (!choice) return 'nothing yet: choose your repositories on your survey page'
87
+ if ('all' in choice) return choice.all === true ? 'every repository' : 'nothing'
88
+ const parts = [`${choice.repositories.length} ${choice.repositories.length === 1 ? 'repository' : 'repositories'}`]
89
+ if (choice.owners.length > 0) parts.push(`new ones from ${choice.owners.join(', ')}`)
90
+ if (choice.outsideGit) parts.push('work outside a repository')
91
+ return parts.join(', ')
92
+ }