session-orchestrator 3.16.0 → 3.17.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.
Files changed (52) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/CHANGELOG.md +25 -0
  5. package/README.md +13 -11
  6. package/docs/README.md +2 -1
  7. package/docs/components.md +2 -2
  8. package/docs/pi-setup.md +1 -1
  9. package/docs/session-config-reference.md +65 -0
  10. package/docs/session-config-template.md +27 -0
  11. package/docs/telemetry/telemetry-claims.md +204 -0
  12. package/docs/telemetry.md +158 -0
  13. package/hooks/hooks-codex.json +1 -1
  14. package/hooks/hooks.json +1 -1
  15. package/hooks/skill-invocation-telemetry.mjs +109 -10
  16. package/package.json +12 -2
  17. package/scripts/compute-grounding-injection.sh +18 -3
  18. package/scripts/dialectic-deriver.mjs +7 -2
  19. package/scripts/lib/auto-dialectic.mjs +11 -2
  20. package/scripts/lib/auto-dream.mjs +16 -5
  21. package/scripts/lib/build-live-signals.mjs +7 -4
  22. package/scripts/lib/config/context-coverage.mjs +82 -0
  23. package/scripts/lib/config/moc-staleness.mjs +98 -0
  24. package/scripts/lib/config/worktree-orphans.mjs +138 -0
  25. package/scripts/lib/config.mjs +15 -0
  26. package/scripts/lib/context-coverage-banner.mjs +223 -0
  27. package/scripts/lib/dispatcher/enumerate.mjs +151 -31
  28. package/scripts/lib/dispatcher/rank.mjs +22 -8
  29. package/scripts/lib/evolve/autonomy-verdict.mjs +5 -0
  30. package/scripts/lib/evolve/autopilot-effectiveness.mjs +54 -7
  31. package/scripts/lib/harness-audit/categories/category4.mjs +13 -2
  32. package/scripts/lib/moc-staleness-banner.mjs +267 -0
  33. package/scripts/lib/session-end/worktree-orphan-sweep.mjs +252 -0
  34. package/scripts/lib/session-schema/filters.mjs +88 -0
  35. package/scripts/lib/session-schema.mjs +1 -0
  36. package/scripts/lib/skill-health/join.mjs +35 -9
  37. package/scripts/lib/telemetry/anon-id.mjs +141 -0
  38. package/scripts/lib/telemetry/consent.mjs +299 -0
  39. package/scripts/lib/telemetry/paths.mjs +27 -0
  40. package/scripts/lib/telemetry/queue.mjs +287 -0
  41. package/scripts/lib/telemetry/schema.mjs +384 -0
  42. package/scripts/lib/telemetry/sync.mjs +312 -0
  43. package/scripts/lib/vault-status/board-writer.mjs +63 -5
  44. package/scripts/lib/vault-status/narrative-mirror.mjs +13 -7
  45. package/scripts/mcp-server.sh +15 -3
  46. package/scripts/telemetry.mjs +250 -0
  47. package/skills/npm-publish/SKILL.md +81 -0
  48. package/skills/session-end/SKILL.md +74 -1
  49. package/skills/session-start/SKILL.md +77 -1
  50. package/skills/vault-sync/SKILL.md +1 -1
  51. package/skills/vault-sync/package-lock.json +3 -3
  52. package/skills/vault-sync/validator.mjs +121 -34
@@ -0,0 +1,287 @@
1
+ /**
2
+ * telemetry/queue.mjs — host-local bounded NDJSON offline queue for anonymous
3
+ * usage telemetry (Epic #841, Issue #844 FA3).
4
+ *
5
+ * Persists telemetry batches to a single NDJSON file at
6
+ * `~/.config/session-orchestrator/telemetry-queue.ndjson` (one JSON object per
7
+ * line: `{ queued_at, batch }`) so that a batch collected while the sender is
8
+ * offline (network down, endpoint unreachable) is not lost — it waits in the
9
+ * queue until the next successful `drain()`.
10
+ *
11
+ * The queue is BOUNDED on two independent axes — batch count (`MAX_BATCHES`)
12
+ * and serialized byte size (`MAX_QUEUE_BYTES`) — dropping the OLDEST entries
13
+ * first (FIFO) whenever either cap is exceeded. This prevents an unbounded
14
+ * queue from growing across an extended offline period.
15
+ *
16
+ * Every public function accepts a `{ path }` override (defaulting to
17
+ * `TELEMETRY_QUEUE_PATH`) for test injection, and NEVER throws — filesystem
18
+ * or serialization failures are swallowed and reported via the function's
19
+ * own result shape, mirroring the `scripts/lib/eval/sink.mjs` /
20
+ * `scripts/lib/events-rotation.mjs` "never throw a caller can't route around"
21
+ * convention used elsewhere in this repo.
22
+ *
23
+ * This storage layer imports ONLY the constants-only leaf module ./paths.mjs
24
+ * (for TELEMETRY_QUEUE_PATH) and the generic ../io.mjs helpers — never a policy
25
+ * sibling (consent.mjs) or the schema. Keeping the queue off the consent policy
26
+ * preserves the correct dependency direction: the generic offline queue must not
27
+ * hang off the telemetry consent layer.
28
+ */
29
+
30
+ import { dirname, join } from 'node:path';
31
+ import { mkdirSync, writeFileSync, renameSync, existsSync, statSync } from 'node:fs';
32
+ import { randomBytes } from 'node:crypto';
33
+ import { readJsonlFile } from '../io.mjs';
34
+ import { TELEMETRY_QUEUE_PATH } from './paths.mjs';
35
+
36
+ /** Host-local NDJSON offline queue path — single-sourced from ./paths.mjs (a constants-only leaf module; two independently computed copies can silently drift). */
37
+ export { TELEMETRY_QUEUE_PATH };
38
+
39
+ /** Maximum number of queued batch entries before oldest-first eviction kicks in. */
40
+ export const MAX_BATCHES = 50;
41
+
42
+ /** Maximum serialized queue size in bytes before oldest-first eviction kicks in. */
43
+ export const MAX_QUEUE_BYTES = 256 * 1024;
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Internal helpers
47
+ // ---------------------------------------------------------------------------
48
+
49
+ /**
50
+ * Read all queue entries from disk. Never throws: a missing file yields `[]`
51
+ * and any corrupted/malformed line is silently skipped (readJsonlFile with
52
+ * `skipInvalid: true`) — a corrupted line is dropped for good the next time
53
+ * the queue is rewritten (enqueue/drain/dropOldest/clear all rewrite in full).
54
+ *
55
+ * @param {string} filePath
56
+ * @returns {Array<{queued_at: string, batch: object}>}
57
+ */
58
+ function _readEntries(filePath) {
59
+ try {
60
+ return readJsonlFile(filePath, { skipInvalid: true });
61
+ } catch {
62
+ return [];
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Serialize entries as NDJSON text (one JSON object per line, trailing
68
+ * newline when non-empty; empty string when entries is empty).
69
+ *
70
+ * @param {Array<object>} entries
71
+ * @returns {string}
72
+ */
73
+ function _toNdjson(entries) {
74
+ if (entries.length === 0) return '';
75
+ return `${entries.map((entry) => JSON.stringify(entry)).join('\n')}\n`;
76
+ }
77
+
78
+ /**
79
+ * Byte length of the entries once serialized as NDJSON — used to enforce
80
+ * `MAX_QUEUE_BYTES`.
81
+ *
82
+ * @param {Array<object>} entries
83
+ * @returns {number}
84
+ */
85
+ function _serializedByteLength(entries) {
86
+ return Buffer.byteLength(_toNdjson(entries), 'utf8');
87
+ }
88
+
89
+ /**
90
+ * Atomically replace `filePath` with the NDJSON serialization of `entries`
91
+ * via tmp-file + `renameSync` in the SAME directory (same-filesystem rename
92
+ * is atomic on POSIX — mirrors `writeJsonAtomicSync` in `scripts/lib/io.mjs`,
93
+ * which has no NDJSON-array variant). Creates the parent directory with
94
+ * `mkdirSync(dir, { recursive: true })` first.
95
+ *
96
+ * @param {string} filePath
97
+ * @param {Array<object>} entries
98
+ * @throws {Error} on filesystem failure — callers MUST catch (this helper is
99
+ * intentionally throw-on-failure; the never-throws contract lives at
100
+ * the public-API layer).
101
+ */
102
+ function _writeEntriesAtomicSync(filePath, entries) {
103
+ const dir = dirname(filePath);
104
+ mkdirSync(dir, { recursive: true });
105
+ const tmpSuffix = randomBytes(6).toString('hex');
106
+ const tmpFile = join(dir, `.tmp.${tmpSuffix}`);
107
+ writeFileSync(tmpFile, _toNdjson(entries), 'utf8');
108
+ renameSync(tmpFile, filePath);
109
+ }
110
+
111
+ /**
112
+ * Evict oldest entries (FIFO) until both the batch-count cap and the
113
+ * serialized-byte cap are satisfied.
114
+ *
115
+ * @param {Array<object>} entries
116
+ * @param {number} maxBatches
117
+ * @param {number} maxBytes
118
+ * @returns {{entries: Array<object>, dropped: number}}
119
+ */
120
+ function _enforceCaps(entries, maxBatches, maxBytes) {
121
+ let out = entries;
122
+ let dropped = 0;
123
+
124
+ while (out.length > maxBatches) {
125
+ out = out.slice(1);
126
+ dropped++;
127
+ }
128
+ while (out.length > 0 && _serializedByteLength(out) > maxBytes) {
129
+ out = out.slice(1);
130
+ dropped++;
131
+ }
132
+
133
+ return { entries: out, dropped };
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // Public API
138
+ // ---------------------------------------------------------------------------
139
+
140
+ /**
141
+ * Append a telemetry batch to the offline queue, then evict the oldest
142
+ * entries (FIFO) until both `maxBatches` and `maxBytes` caps are satisfied.
143
+ * Rewrites the queue file atomically. Never throws.
144
+ *
145
+ * @param {object} batch — arbitrary JSON-serializable telemetry batch payload.
146
+ * @param {object} [opts]
147
+ * @param {string} [opts.path] — queue file path override (defaults to `TELEMETRY_QUEUE_PATH`).
148
+ * @param {string} [opts.now] — ISO8601 timestamp stamped as `queued_at` (defaults to `new Date().toISOString()`).
149
+ * @param {number} [opts.maxBatches] — batch-count cap (defaults to `MAX_BATCHES`).
150
+ * @param {number} [opts.maxBytes] — serialized-byte cap (defaults to `MAX_QUEUE_BYTES`).
151
+ * @returns {{ok: true, dropped: number, total: number} | {ok: false, dropped: 0, total: 0, error: string}}
152
+ */
153
+ export function enqueue(batch, opts = {}) {
154
+ const {
155
+ path: filePath = TELEMETRY_QUEUE_PATH,
156
+ now = new Date().toISOString(),
157
+ maxBatches = MAX_BATCHES,
158
+ maxBytes = MAX_QUEUE_BYTES,
159
+ } = opts;
160
+
161
+ try {
162
+ const existing = _readEntries(filePath);
163
+ const entry = { queued_at: now, batch };
164
+ const combined = [...existing, entry];
165
+
166
+ const { entries, dropped } = _enforceCaps(combined, maxBatches, maxBytes);
167
+
168
+ _writeEntriesAtomicSync(filePath, entries);
169
+ return { ok: true, dropped, total: entries.length };
170
+ } catch (err) {
171
+ return { ok: false, dropped: 0, total: 0, error: err?.message ?? String(err) };
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Drain the queue by handing all queued batches to `sender` as a single call.
177
+ * On success (sender's returned promise resolves), the queue is atomically
178
+ * emptied. On failure (sender's promise rejects or throws synchronously), the
179
+ * queue is left byte-identical to before the call. When `sender` is omitted,
180
+ * this is a no-op. Never throws.
181
+ *
182
+ * @param {object} [opts]
183
+ * @param {string} [opts.path] — queue file path override (defaults to `TELEMETRY_QUEUE_PATH`).
184
+ * @param {(batches: object[]) => Promise<void>} [opts.sender] — async callback invoked with all queued batch payloads.
185
+ * @returns {Promise<{sent: number, remaining: number, dropped: number}>}
186
+ */
187
+ export async function drain(opts = {}) {
188
+ const { path: filePath = TELEMETRY_QUEUE_PATH, sender } = opts;
189
+
190
+ const entries = _readEntries(filePath);
191
+
192
+ if (typeof sender !== 'function') {
193
+ return { sent: 0, remaining: entries.length, dropped: 0 };
194
+ }
195
+
196
+ try {
197
+ await sender(entries.map((entry) => entry.batch));
198
+ } catch {
199
+ return { sent: 0, remaining: entries.length, dropped: 0 };
200
+ }
201
+
202
+ try {
203
+ _writeEntriesAtomicSync(filePath, []);
204
+ return { sent: entries.length, remaining: 0, dropped: 0 };
205
+ } catch {
206
+ // Sender succeeded but the queue could not be cleared — report the
207
+ // batches as unsent-safe (still on disk) rather than losing them.
208
+ return { sent: 0, remaining: entries.length, dropped: 0 };
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Read all queue entries without mutating the queue. Never throws.
214
+ *
215
+ * @param {object} [opts]
216
+ * @param {string} [opts.path] — queue file path override (defaults to `TELEMETRY_QUEUE_PATH`).
217
+ * @returns {Array<{queued_at: string, batch: object}>} — `[]` when the queue file does not exist.
218
+ */
219
+ export function peekAll(opts = {}) {
220
+ const { path: filePath = TELEMETRY_QUEUE_PATH } = opts;
221
+ return _readEntries(filePath);
222
+ }
223
+
224
+ /**
225
+ * Drop the `n` oldest entries (FIFO) from the queue and atomically rewrite
226
+ * it. Never throws.
227
+ *
228
+ * @param {number} n — number of oldest entries to drop (clamped to `[0, queue length]`).
229
+ * @param {object} [opts]
230
+ * @param {string} [opts.path] — queue file path override (defaults to `TELEMETRY_QUEUE_PATH`).
231
+ * @returns {{dropped: number, remaining: number}}
232
+ */
233
+ export function dropOldest(n, opts = {}) {
234
+ const { path: filePath = TELEMETRY_QUEUE_PATH } = opts;
235
+
236
+ try {
237
+ const entries = _readEntries(filePath);
238
+ const count = Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
239
+ const dropCount = Math.min(count, entries.length);
240
+ const remaining = entries.slice(dropCount);
241
+
242
+ _writeEntriesAtomicSync(filePath, remaining);
243
+ return { dropped: dropCount, remaining: remaining.length };
244
+ } catch {
245
+ return { dropped: 0, remaining: 0 };
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Empty the queue (atomic truncate to zero entries). Never throws.
251
+ *
252
+ * @param {object} [opts]
253
+ * @param {string} [opts.path] — queue file path override (defaults to `TELEMETRY_QUEUE_PATH`).
254
+ * @returns {{ok: boolean}}
255
+ */
256
+ export function clear(opts = {}) {
257
+ const { path: filePath = TELEMETRY_QUEUE_PATH } = opts;
258
+ try {
259
+ _writeEntriesAtomicSync(filePath, []);
260
+ return { ok: true };
261
+ } catch {
262
+ return { ok: false };
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Report current queue occupancy. Never throws.
268
+ *
269
+ * @param {object} [opts]
270
+ * @param {string} [opts.path] — queue file path override (defaults to `TELEMETRY_QUEUE_PATH`).
271
+ * @returns {{count: number, bytes: number}} — `bytes` is the on-disk file size (`0` when the file does not exist).
272
+ */
273
+ export function queueStats(opts = {}) {
274
+ const { path: filePath = TELEMETRY_QUEUE_PATH } = opts;
275
+ try {
276
+ const entries = _readEntries(filePath);
277
+ const bytes = existsSync(filePath) ? statSync(filePath).size : 0;
278
+ return { count: entries.length, bytes };
279
+ } catch {
280
+ return { count: 0, bytes: 0 };
281
+ }
282
+ }
283
+
284
+ // Exported for atomicity-smoke tests that want to confirm no stray tmp file
285
+ // is left behind after a write (avoids re-deriving the tmp-name pattern
286
+ // baked into `_writeEntriesAtomicSync` above).
287
+ export const _TMP_FILE_PATTERN = /^\.tmp\./;
@@ -0,0 +1,384 @@
1
+ /**
2
+ * telemetry/schema.mjs — usage-ping v1 schema, whitelist projection, roster
3
+ * filter, and payload builder for anonymous usage telemetry (Epic #841, S2 /
4
+ * GitLab #843; PRD docs/prd/2026-07-20-anonymous-usage-telemetry.md §3-FA2).
5
+ *
6
+ * Privacy is the raison d'être of this module. It mirrors the data-minimization
7
+ * pattern of scripts/lib/eval/schema.mjs (SUBMISSION_FIELDS + projectSubmission):
8
+ * a FROZEN field whitelist plus a data-driven projection so nothing outside the
9
+ * whitelist can ever reach the wire — no paths, repo names, prompts, args, git
10
+ * remotes, or hostnames. Skill/command names are additionally projected against
11
+ * the shipped plugin roster: any name not in the roster becomes the opaque token
12
+ * "other", so custom/third-party names never leave the machine.
13
+ *
14
+ * ── USAGE-PING RECORD (schema_version: 1, record_kind: "usage-ping") ─────────
15
+ * record_kind 'usage-ping'
16
+ * schema_version 1
17
+ * anon_id set downstream by ensureAnonId (anon-id.mjs) — NOT here
18
+ * sent_at ISO 8601 string (passed in as `now`)
19
+ * plugin_version package.json version at SO_PLUGIN_ROOT, or 'unknown'
20
+ * platform claude|codex|cursor|pi (+ 'other' fallback)
21
+ * os normalizeOs(process.platform) — closed set, else 'other'
22
+ * arch normalizeArch(process.arch) — closed set, else 'other'
23
+ * node_major integer major version of the running Node
24
+ * ci boolean — running under CI
25
+ * fleet boolean — operator fleet mode (owner.yaml telemetry.enabled)
26
+ * session_type housekeeping|feature|deep (+ 'other' fallback)
27
+ * duration_bucket '<15m'|'15-60m'|'1-3h'|'>3h'
28
+ * skills roster-filtered, deduped, sorted string[] (≤100)
29
+ * commands roster-filtered, deduped, sorted string[] (≤100)
30
+ *
31
+ * The builder returns the record WITHOUT anon_id — the caller sets it via
32
+ * ensureAnonId so the ID-rotation concern stays isolated in anon-id.mjs.
33
+ *
34
+ * This module reads files (package.json, the roster surface dirs) but writes
35
+ * none and holds no mutable runtime state.
36
+ */
37
+
38
+ import { SO_PLATFORM, SO_PLUGIN_ROOT } from '../platform.mjs';
39
+ import { enumerateSurface } from '../sunset/walker.mjs';
40
+ import { readPluginVersionFromPackageJson } from '../bootstrap-lock-freshness.mjs';
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Constants
44
+ // ---------------------------------------------------------------------------
45
+
46
+ /** Current usage-ping schema version. Additive-only within a version. */
47
+ export const USAGE_PING_SCHEMA_VERSION = 1;
48
+
49
+ /**
50
+ * FROZEN whitelist of the ONLY fields a usage-ping may carry. projectUsagePing is
51
+ * fully data-driven from this list: a field absent here is dropped. Widening this
52
+ * list is the intended fake-regression tripwire — adding a leaky field (e.g.
53
+ * 'repo') turns the data-minimization projection test RED.
54
+ */
55
+ export const USAGE_PING_FIELDS = Object.freeze([
56
+ 'record_kind',
57
+ 'schema_version',
58
+ 'anon_id',
59
+ 'sent_at',
60
+ 'plugin_version',
61
+ 'platform',
62
+ 'os',
63
+ 'arch',
64
+ 'node_major',
65
+ 'ci',
66
+ 'fleet',
67
+ 'session_type',
68
+ 'duration_bucket',
69
+ 'skills',
70
+ 'commands',
71
+ ]);
72
+
73
+ /** Exact duration-bucket tokens (ASCII, stable wire values). */
74
+ export const DURATION_BUCKETS = Object.freeze(['<15m', '15-60m', '1-3h', '>3h']);
75
+
76
+ /** Roster-projection guardrails. */
77
+ const ROSTER_OTHER = 'other';
78
+ const MAX_NAME_LENGTH = 64;
79
+ const MAX_NAMES = 100;
80
+
81
+ /** Enum fallbacks. */
82
+ const VALID_PLATFORMS = Object.freeze(['claude', 'codex', 'cursor', 'pi']);
83
+ const VALID_SESSION_TYPES = Object.freeze(['housekeeping', 'feature', 'deep']);
84
+ const PLATFORM_OTHER = 'other';
85
+ const SESSION_TYPE_OTHER = 'other';
86
+
87
+ /**
88
+ * Closed sets for os/arch client-side normalization. A value outside the set —
89
+ * including a future Node os/arch string — degrades to 'other', so the client
90
+ * never sends a value the server's enum would reject with a 400. These MUST
91
+ * mirror the server's OS/ARCH enums (server/validate.mjs); a loong64 client
92
+ * therefore sends 'loong64' (in-set → accepted with a signal), while a
93
+ * hypothetical unknown value degrades safely.
94
+ */
95
+ const OS_VALUES = Object.freeze(['aix', 'darwin', 'freebsd', 'linux', 'openbsd', 'sunos', 'win32', 'android']);
96
+ const ARCH_VALUES = Object.freeze([
97
+ 'arm', 'arm64', 'ia32', 'loong64', 'mips', 'mipsel', 'ppc', 'ppc64', 'riscv64', 's390', 's390x', 'x64',
98
+ ]);
99
+ const OS_OTHER = 'other';
100
+ const ARCH_OTHER = 'other';
101
+
102
+ /** Prefix under which skills are recorded in skill-invocations.jsonl. */
103
+ const SKILL_PREFIX = 'session-orchestrator:';
104
+
105
+ const MS_PER_SECOND = 1000;
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Internal helpers
109
+ // ---------------------------------------------------------------------------
110
+
111
+ function isPlainObject(v) {
112
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
113
+ }
114
+
115
+ function isNonEmptyString(v) {
116
+ return typeof v === 'string' && v.trim().length > 0;
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Whitelist projection (Data-Minimization)
121
+ // ---------------------------------------------------------------------------
122
+
123
+ /**
124
+ * Project an arbitrary object onto the usage-ping whitelist (USAGE_PING_FIELDS).
125
+ * Fully data-driven: any key not on the whitelist — paths, repo names, prompts,
126
+ * args, hostnames, rogue extras — is dropped. Array fields (skills, commands) are
127
+ * copied as NEW arrays so no caller reference leaks into the projection.
128
+ *
129
+ * @param {object} input
130
+ * @returns {object} whitelist-safe projection
131
+ */
132
+ export function projectUsagePing(input) {
133
+ if (!isPlainObject(input)) return {};
134
+ const out = {};
135
+ for (const key of USAGE_PING_FIELDS) {
136
+ if (key in input) {
137
+ const v = input[key];
138
+ out[key] = Array.isArray(v) ? [...v] : v;
139
+ }
140
+ }
141
+ return out;
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Roster loader
146
+ // ---------------------------------------------------------------------------
147
+
148
+ /**
149
+ * Load the shipped plugin roster (skills + commands) from the on-disk surface.
150
+ * Uses enumerateSurface() against SO_PLUGIN_ROOT (never process.cwd()) so the
151
+ * roster reflects the INSTALLED plugin, not the operator's working directory.
152
+ *
153
+ * skills are returned prefixed with 'session-orchestrator:' (matching the names
154
+ * recorded in skill-invocations.jsonl); commands are bare.
155
+ *
156
+ * Fail-closed: if the surface directory is missing (e.g. a partial npm install)
157
+ * or enumeration throws, this returns EMPTY sets and a stderr WARN — an empty
158
+ * roster means every name projects to "other", never a leak.
159
+ *
160
+ * @param {{pluginRoot?: string}} [opts]
161
+ * @returns {{skills: Set<string>, commands: Set<string>}}
162
+ */
163
+ export function loadRoster({ pluginRoot } = {}) {
164
+ const root = (typeof pluginRoot === 'string' && pluginRoot.trim() !== '')
165
+ ? pluginRoot
166
+ : SO_PLUGIN_ROOT;
167
+
168
+ if (!isNonEmptyString(root)) {
169
+ process.stderr.write(
170
+ "[telemetry] WARN: plugin root unresolved — roster empty, all skill/command names project to 'other'\n",
171
+ );
172
+ return { skills: new Set(), commands: new Set() };
173
+ }
174
+
175
+ try {
176
+ const surface = enumerateSurface(root);
177
+ const skills = new Set((surface.skills ?? []).map((name) => `${SKILL_PREFIX}${name}`));
178
+ const commands = new Set(surface.commands ?? []);
179
+ if (skills.size === 0 && commands.size === 0) {
180
+ process.stderr.write(
181
+ `[telemetry] WARN: roster surface empty at ${root} — all skill/command names project to 'other'\n`,
182
+ );
183
+ }
184
+ return { skills, commands };
185
+ } catch (err) {
186
+ process.stderr.write(
187
+ `[telemetry] WARN: roster enumeration failed (${err?.message ?? err}) — all names project to 'other'\n`,
188
+ );
189
+ return { skills: new Set(), commands: new Set() };
190
+ }
191
+ }
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // Roster name filter
195
+ // ---------------------------------------------------------------------------
196
+
197
+ /**
198
+ * Project a list of raw names onto the roster: a name present in `rosterSet` is
199
+ * kept verbatim; anything else (off-roster, non-string, or longer than
200
+ * MAX_NAME_LENGTH chars) becomes the opaque token "other". The result is
201
+ * deduplicated and sorted, "other" appears at most once, and the list is capped
202
+ * at MAX_NAMES entries.
203
+ *
204
+ * @param {string[]} names
205
+ * @param {Set<string>} rosterSet
206
+ * @returns {string[]} deduped, sorted, roster-safe names (≤ MAX_NAMES)
207
+ */
208
+ export function filterRosterNames(names, rosterSet) {
209
+ const roster = rosterSet instanceof Set ? rosterSet : new Set();
210
+ const raw = Array.isArray(names) ? names : [];
211
+ const mapped = [];
212
+ for (const name of raw) {
213
+ if (typeof name !== 'string' || name.length > MAX_NAME_LENGTH) {
214
+ mapped.push(ROSTER_OTHER);
215
+ continue;
216
+ }
217
+ mapped.push(roster.has(name) ? name : ROSTER_OTHER);
218
+ }
219
+ const deduped = [...new Set(mapped)].sort();
220
+ return deduped.slice(0, MAX_NAMES);
221
+ }
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // Duration bucketing
225
+ // ---------------------------------------------------------------------------
226
+
227
+ /**
228
+ * Map a session duration to a coarse bucket. Boundaries (seconds):
229
+ * <900 → '<15m'
230
+ * [900, 3600) → '15-60m'
231
+ * [3600, 10800] → '1-3h' (exactly 1h and exactly 3h both land here)
232
+ * >10800 → '>3h'
233
+ *
234
+ * Fail-safe: an unparsable, missing, or negative duration returns the most
235
+ * conservative bucket '<15m' (never throws).
236
+ *
237
+ * @param {string} startedAtISO
238
+ * @param {string} completedAtISO
239
+ * @returns {'<15m'|'15-60m'|'1-3h'|'>3h'}
240
+ */
241
+ export function deriveDurationBucket(startedAtISO, completedAtISO) {
242
+ const start = typeof startedAtISO === 'string' ? Date.parse(startedAtISO) : NaN;
243
+ const end = typeof completedAtISO === 'string' ? Date.parse(completedAtISO) : NaN;
244
+ if (Number.isNaN(start) || Number.isNaN(end)) return '<15m';
245
+
246
+ const seconds = (end - start) / MS_PER_SECOND;
247
+ if (!Number.isFinite(seconds) || seconds < 0) return '<15m';
248
+ if (seconds < 900) return '<15m';
249
+ if (seconds < 3600) return '15-60m';
250
+ if (seconds <= 10800) return '1-3h';
251
+ return '>3h';
252
+ }
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // Field derivation helpers
256
+ // ---------------------------------------------------------------------------
257
+
258
+ /**
259
+ * Plugin version from package.json at SO_PLUGIN_ROOT; 'unknown' on any failure.
260
+ * Single-sourced through readPluginVersionFromPackageJson (bootstrap-lock-freshness.mjs),
261
+ * whose null return (missing/unparseable package.json or non-string version) maps to 'unknown'.
262
+ */
263
+ function resolvePluginVersion() {
264
+ if (!isNonEmptyString(SO_PLUGIN_ROOT)) return 'unknown';
265
+ return readPluginVersionFromPackageJson(SO_PLUGIN_ROOT) ?? 'unknown';
266
+ }
267
+
268
+ /** Normalize the detected platform to the closed enum (+ 'other' fallback). */
269
+ function normalizePlatform(platform) {
270
+ return VALID_PLATFORMS.includes(platform) ? platform : PLATFORM_OTHER;
271
+ }
272
+
273
+ /** Normalize the session type to the closed enum (+ 'other' fallback). */
274
+ function normalizeSessionType(sessionType) {
275
+ return VALID_SESSION_TYPES.includes(sessionType) ? sessionType : SESSION_TYPE_OTHER;
276
+ }
277
+
278
+ /**
279
+ * Normalize an OS identifier (process.platform) against the closed OS_VALUES set.
280
+ * An in-set value is kept verbatim; anything else degrades to 'other' — mirrors
281
+ * normalizePlatform / normalizeSessionType so the client never emits a raw value
282
+ * the server enum would 400 on.
283
+ * @param {unknown} os
284
+ * @returns {string}
285
+ */
286
+ export function normalizeOs(os) {
287
+ return OS_VALUES.includes(os) ? os : OS_OTHER;
288
+ }
289
+
290
+ /**
291
+ * Normalize a CPU arch identifier (process.arch) against the closed ARCH_VALUES
292
+ * set. In-set kept verbatim (e.g. 'loong64' survives); anything else → 'other'.
293
+ * @param {unknown} arch
294
+ * @returns {string}
295
+ */
296
+ export function normalizeArch(arch) {
297
+ return ARCH_VALUES.includes(arch) ? arch : ARCH_OTHER;
298
+ }
299
+
300
+ /**
301
+ * CI detection from the env: a non-empty CI value that is not '0' and not (case-
302
+ * insensitively) 'false' counts as running under CI.
303
+ * @param {NodeJS.ProcessEnv} env
304
+ * @returns {boolean}
305
+ */
306
+ function deriveCi(env) {
307
+ const raw = env?.CI;
308
+ if (typeof raw !== 'string') return false;
309
+ const v = raw.trim();
310
+ if (v === '' || v === '0' || v.toLowerCase() === 'false') return false;
311
+ return true;
312
+ }
313
+
314
+ /** Distinct non-empty string values of `field` across a list of records. */
315
+ function distinctField(records, field) {
316
+ const seen = new Set();
317
+ for (const rec of records) {
318
+ if (!isPlainObject(rec)) continue;
319
+ const value = rec[field];
320
+ if (isNonEmptyString(value)) seen.add(value);
321
+ }
322
+ return [...seen];
323
+ }
324
+
325
+ // ---------------------------------------------------------------------------
326
+ // Payload builder
327
+ // ---------------------------------------------------------------------------
328
+
329
+ /**
330
+ * Build a usage-ping record from local telemetry inputs. The returned record is
331
+ * whitelist-clean but does NOT carry `anon_id` — the caller sets it via
332
+ * ensureAnonId (anon-id.mjs), keeping ID rotation isolated there.
333
+ *
334
+ * skills are the distinct `.skill` values of `skillInvocations`; commands are the
335
+ * distinct `.command` values of the same records (none in v1 skill-invocations
336
+ * telemetry ⇒ []; the field is honored so a future command-telemetry stream feeds
337
+ * in without a signature change). Both are roster-filtered — off-roster names
338
+ * become "other" — deduped, sorted, and capped. No frequencies are recorded (v1
339
+ * decision).
340
+ *
341
+ * @param {{
342
+ * sessionRecord: object,
343
+ * skillInvocations: object[],
344
+ * ownerConfig?: object,
345
+ * env?: NodeJS.ProcessEnv,
346
+ * now?: string,
347
+ * roster?: {skills: Set<string>, commands: Set<string>}
348
+ * }} args
349
+ * @returns {object} usage-ping record (without anon_id)
350
+ */
351
+ export function buildUsagePing({
352
+ sessionRecord,
353
+ skillInvocations,
354
+ ownerConfig,
355
+ env = process.env,
356
+ now = new Date().toISOString(),
357
+ roster,
358
+ } = {}) {
359
+ const session = isPlainObject(sessionRecord) ? sessionRecord : {};
360
+ const invocations = Array.isArray(skillInvocations) ? skillInvocations : [];
361
+ const rst = roster ?? loadRoster();
362
+ const rosterSkills = rst?.skills instanceof Set ? rst.skills : new Set();
363
+ const rosterCommands = rst?.commands instanceof Set ? rst.commands : new Set();
364
+
365
+ const skillNames = distinctField(invocations, 'skill');
366
+ const commandNames = distinctField(invocations, 'command');
367
+
368
+ return {
369
+ record_kind: 'usage-ping',
370
+ schema_version: USAGE_PING_SCHEMA_VERSION,
371
+ sent_at: now,
372
+ plugin_version: resolvePluginVersion(),
373
+ platform: normalizePlatform(SO_PLATFORM),
374
+ os: normalizeOs(process.platform),
375
+ arch: normalizeArch(process.arch),
376
+ node_major: parseInt(process.versions.node, 10),
377
+ ci: deriveCi(env),
378
+ fleet: ownerConfig?.telemetry?.enabled === true,
379
+ session_type: normalizeSessionType(session.session_type),
380
+ duration_bucket: deriveDurationBucket(session.started_at, session.completed_at),
381
+ skills: filterRosterNames(skillNames, rosterSkills),
382
+ commands: filterRosterNames(commandNames, rosterCommands),
383
+ };
384
+ }