@manyoars/oars 0.4.0 → 0.5.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.
Files changed (185) hide show
  1. package/README.md +82 -32
  2. package/dist/bin.js +3 -1
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +206 -20
  5. package/dist/club.d.ts +19 -0
  6. package/dist/club.js +132 -0
  7. package/dist/gate/hook-fill-main.js +1 -1
  8. package/dist/gate/hook-fill.d.ts +1 -1
  9. package/dist/gate/hook-fill.js +1 -1
  10. package/dist/gate/hook-main.js +7 -5
  11. package/dist/gate/hook.d.ts +12 -1
  12. package/dist/gate/hook.js +38 -7
  13. package/dist/hook-bin.js +23 -4
  14. package/dist/issuer.d.ts +1 -1
  15. package/dist/issuer.js +2 -2
  16. package/dist/main.js +5 -0
  17. package/dist/node-floor.d.ts +10 -0
  18. package/dist/node-floor.js +23 -0
  19. package/dist/oars-hook.d.ts +2 -0
  20. package/dist/oars-hook.js +7 -0
  21. package/dist/sessions/allow.d.ts +1 -0
  22. package/dist/sessions/allow.js +1 -1
  23. package/dist/sessions/call-shape.d.ts +111 -0
  24. package/dist/sessions/call-shape.js +464 -0
  25. package/dist/sessions/candidates.d.ts +65 -0
  26. package/dist/sessions/candidates.js +236 -0
  27. package/dist/sessions/card-command.d.ts +24 -0
  28. package/dist/sessions/card-command.js +71 -0
  29. package/dist/sessions/card-font.d.ts +6 -0
  30. package/dist/sessions/card-font.js +36 -0
  31. package/dist/sessions/card-line.d.ts +30 -0
  32. package/dist/sessions/card-line.js +105 -0
  33. package/dist/sessions/card-render.d.ts +15 -0
  34. package/dist/sessions/card-render.js +146 -0
  35. package/dist/sessions/card.d.ts +59 -0
  36. package/dist/sessions/card.js +107 -0
  37. package/dist/sessions/cell-increments.d.ts +14 -3
  38. package/dist/sessions/cell-increments.js +79 -12
  39. package/dist/sessions/cells.d.ts +42 -4
  40. package/dist/sessions/cells.js +72 -18
  41. package/dist/sessions/codex-hook-bin.d.ts +1 -0
  42. package/dist/sessions/codex-hook-bin.js +24 -0
  43. package/dist/sessions/codex-hook-install.d.ts +21 -0
  44. package/dist/sessions/codex-hook-install.js +47 -0
  45. package/dist/sessions/codex-hook-view.d.ts +20 -0
  46. package/dist/sessions/codex-hook-view.js +20 -0
  47. package/dist/sessions/codex-hook.d.ts +77 -0
  48. package/dist/sessions/codex-hook.js +255 -0
  49. package/dist/sessions/codex.d.ts +9 -0
  50. package/dist/sessions/codex.js +104 -18
  51. package/dist/sessions/command.d.ts +17 -2
  52. package/dist/sessions/command.js +145 -33
  53. package/dist/sessions/copilot.d.ts +3 -0
  54. package/dist/sessions/copilot.js +12 -4
  55. package/dist/sessions/derive.js +2 -1
  56. package/dist/sessions/empty-state.d.ts +17 -0
  57. package/dist/sessions/empty-state.js +38 -0
  58. package/dist/sessions/exclusion.js +48 -3
  59. package/dist/sessions/extract.d.ts +16 -3
  60. package/dist/sessions/extract.js +281 -24
  61. package/dist/sessions/gemini.d.ts +2 -0
  62. package/dist/sessions/gemini.js +29 -2
  63. package/dist/sessions/harness-files.d.ts +27 -0
  64. package/dist/sessions/harness-files.js +109 -0
  65. package/dist/sessions/head-only.d.ts +7 -0
  66. package/dist/sessions/head-only.js +21 -0
  67. package/dist/sessions/hook-calls.js +4 -3
  68. package/dist/sessions/hooks.d.ts +1 -1
  69. package/dist/sessions/hooks.js +6 -2
  70. package/dist/sessions/identity.d.ts +6 -0
  71. package/dist/sessions/injected.d.ts +13 -0
  72. package/dist/sessions/injected.js +212 -0
  73. package/dist/sessions/install.d.ts +31 -20
  74. package/dist/sessions/install.js +80 -82
  75. package/dist/sessions/load-status.d.ts +41 -0
  76. package/dist/sessions/load-status.js +156 -0
  77. package/dist/sessions/machine-child-deps.d.ts +5 -0
  78. package/dist/sessions/machine-child-deps.js +83 -0
  79. package/dist/sessions/machine-child.d.ts +43 -0
  80. package/dist/sessions/machine-child.js +120 -0
  81. package/dist/sessions/machine-credential.d.ts +10 -0
  82. package/dist/sessions/machine-credential.js +20 -0
  83. package/dist/sessions/machine-enroll.d.ts +47 -0
  84. package/dist/sessions/machine-enroll.js +230 -0
  85. package/dist/sessions/machine-hold.d.ts +49 -0
  86. package/dist/sessions/machine-hold.js +88 -0
  87. package/dist/sessions/machine-ids.d.ts +6 -0
  88. package/dist/sessions/machine-ids.js +9 -0
  89. package/dist/sessions/machine-notice.d.ts +21 -0
  90. package/dist/sessions/machine-notice.js +89 -0
  91. package/dist/sessions/machine-pull.d.ts +40 -0
  92. package/dist/sessions/machine-pull.js +220 -0
  93. package/dist/sessions/machine-repo.d.ts +7 -0
  94. package/dist/sessions/machine-repo.js +30 -0
  95. package/dist/sessions/machine-scan-cache.d.ts +12 -0
  96. package/dist/sessions/machine-scan-cache.js +47 -0
  97. package/dist/sessions/machine-send.d.ts +12 -0
  98. package/dist/sessions/machine-send.js +156 -0
  99. package/dist/sessions/machine-stop.d.ts +22 -0
  100. package/dist/sessions/machine-stop.js +53 -0
  101. package/dist/sessions/machine.d.ts +41 -0
  102. package/dist/sessions/machine.js +89 -0
  103. package/dist/sessions/opencode.d.ts +2 -0
  104. package/dist/sessions/opencode.js +6 -0
  105. package/dist/sessions/otel.d.ts +21 -0
  106. package/dist/sessions/otel.js +109 -38
  107. package/dist/sessions/owner-only.d.ts +7 -0
  108. package/dist/sessions/owner-only.js +34 -0
  109. package/dist/sessions/pi.d.ts +13 -1
  110. package/dist/sessions/pi.js +27 -13
  111. package/dist/sessions/pool-books.d.ts +1 -1
  112. package/dist/sessions/pool-books.js +1 -1
  113. package/dist/sessions/pool-choices.d.ts +4 -2
  114. package/dist/sessions/pool-choices.js +4 -2
  115. package/dist/sessions/pool-command.d.ts +46 -6
  116. package/dist/sessions/pool-command.js +577 -133
  117. package/dist/sessions/pool-delta.d.ts +7 -0
  118. package/dist/sessions/pool-delta.js +28 -5
  119. package/dist/sessions/pool-due.d.ts +43 -0
  120. package/dist/sessions/pool-due.js +113 -0
  121. package/dist/sessions/pool-nightly.d.ts +9 -1
  122. package/dist/sessions/pool-nightly.js +65 -21
  123. package/dist/sessions/pool-outbox.d.ts +51 -5
  124. package/dist/sessions/pool-outbox.js +217 -24
  125. package/dist/sessions/pool-print.d.ts +1 -1
  126. package/dist/sessions/pool-raw.js +4 -3
  127. package/dist/sessions/pool-replica.d.ts +4 -4
  128. package/dist/sessions/pool-replica.js +26 -7
  129. package/dist/sessions/pool-send-lock.js +1 -1
  130. package/dist/sessions/pool-sent.d.ts +2 -0
  131. package/dist/sessions/pool-sent.js +21 -4
  132. package/dist/sessions/pool-wire.d.ts +172 -12
  133. package/dist/sessions/pool-wire.js +284 -30
  134. package/dist/sessions/pool.js +4 -2
  135. package/dist/sessions/public-vocabulary.d.ts +5 -0
  136. package/dist/sessions/public-vocabulary.js +16 -0
  137. package/dist/sessions/read-call-shape.js +12 -6
  138. package/dist/sessions/read-fit.d.ts +5 -0
  139. package/dist/sessions/read-fit.js +23 -2
  140. package/dist/sessions/read.d.ts +1 -1
  141. package/dist/sessions/recall-consequence.d.ts +2 -2
  142. package/dist/sessions/recall-consequence.js +14 -9
  143. package/dist/sessions/recall-object.d.ts +9 -2
  144. package/dist/sessions/receipt.d.ts +8 -0
  145. package/dist/sessions/receipt.js +78 -0
  146. package/dist/sessions/recovery-calls.d.ts +3 -2
  147. package/dist/sessions/recovery-calls.js +9 -3
  148. package/dist/sessions/redact.d.ts +2 -2
  149. package/dist/sessions/redact.js +116 -14
  150. package/dist/sessions/reversion.d.ts +8 -0
  151. package/dist/sessions/reversion.js +114 -0
  152. package/dist/sessions/schema.d.ts +78 -4
  153. package/dist/sessions/schema.js +31 -1
  154. package/dist/sessions/session-shape.d.ts +2 -1
  155. package/dist/sessions/session-shape.js +15 -0
  156. package/dist/sessions/sources.d.ts +9 -0
  157. package/dist/sessions/sources.js +16 -5
  158. package/dist/sessions/steps.js +3 -2
  159. package/dist/sessions/store.d.ts +41 -2
  160. package/dist/sessions/store.js +68 -16
  161. package/dist/sessions/try-demo.d.ts +8 -0
  162. package/dist/sessions/try-demo.js +90 -0
  163. package/dist/sessions/try.d.ts +13 -0
  164. package/dist/sessions/try.js +50 -0
  165. package/dist/sessions/vscode-copilot.d.ts +4 -0
  166. package/dist/sessions/vscode-copilot.js +25 -18
  167. package/dist/telemetry/command.js +12 -6
  168. package/dist/telemetry/consent.d.ts +9 -3
  169. package/dist/telemetry/consent.js +25 -9
  170. package/dist/telemetry/fields.d.ts +11 -0
  171. package/dist/telemetry/fields.js +56 -0
  172. package/dist/telemetry/measures.d.ts +13 -0
  173. package/dist/telemetry/measures.js +30 -0
  174. package/dist/telemetry/observe.d.ts +15 -2
  175. package/dist/telemetry/observe.js +34 -7
  176. package/dist/telemetry/region.d.ts +6 -0
  177. package/dist/telemetry/region.js +28 -0
  178. package/dist/telemetry/report.js +1 -1
  179. package/dist/telemetry/schema.d.ts +3 -0
  180. package/dist/telemetry/schema.js +11 -1
  181. package/dist/telemetry/send.d.ts +5 -2
  182. package/dist/telemetry/send.js +15 -7
  183. package/dist/telemetry/words.d.ts +6 -0
  184. package/dist/telemetry/words.js +6 -0
  185. package/package.json +23 -4
@@ -1,11 +1,14 @@
1
1
  // Whether telemetry records and may send, and the one-time notice. The off switches are `oars telemetry off` (a saved setting), `OARS_TELEMETRY=0`, and
2
- // `DO_NOT_TRACK=1`; the environment wins over a saved `on`. `telemetrySendAllowed` is the check the sender must call before any byte leaves.
2
+ // `DO_NOT_TRACK=1`, and a machine whose time zone is in the EU, the EEA, or the UK (`region.ts`), which no switch overrides; the environment wins over a saved `on`. `telemetrySendAllowed` is the check the sender must call before any byte leaves.
3
3
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
4
+ import { isClosedZone, machineZone } from './region.js';
4
5
  import { spoolPaths } from './spool.js';
6
+ /** The version of the notice below. `markNoticeShown` stores it and each batch is stamped with the stored version; raise it whenever the notice changes in meaning. */
7
+ export const NOTICE_VERSION = 2;
5
8
  /** Printed once on stderr, on the first run that is not a hook; also what `oars telemetry on` prints. */
6
9
  export const NOTICE = [
7
- 'oars records how it is used, on this machine: one event per command with its path, flag names (never their values), how long it took, the exit code, and the class of any error; one per hook fire, recall, deposit, and fetch, as counts and closed words (which hook, how long, hit or miss, how many bytes, how many sessions a deposit counted and how many each gate kept out, never which); each with a random install id, the oars version, the OS, and the time. Once a night it also records counts and sizes of the session store and what the run cost. A crash is recorded as the error\'s class and a hash of its stack (never its message). Each pool deposit records, per batch, which fields it held and how many bytes each took, and each send (a wire-send event) its HTTP status, the pool\'s refusal code, and its size.',
8
- 'It never records session content, prompts, file paths, URLs, arguments, or anything you typed. oars sends the events in batches to the pool server in OARS_POOL_URL, else to the Many Oars pool (pool.manyoars.com, not deployed yet: until it is, a send fails and the events stay on this machine), and nowhere else.',
10
+ 'oars records how it is used, on this machine: one event per command with its path, flag names (never their values), how long it took, the exit code, and the class of any error; one per hook fire, recall, deposit, and fetch, as counts and closed words (which hook, how long, hit or miss, how many bytes, the harness name and version and the model name, how many sessions a deposit counted and how many each gate kept out, never which); each with a random install id, the oars version, the OS, and the time. Each time the background sender runs, at most about once an hour, it also records counts and sizes of the session store, the lists of harness versions and model names it holds, and what the run cost. Events are sent in a batch at most once a day. A crash is recorded as the error\'s class and a hash of its stack (never its message). Each pool deposit records, per batch, which fields it held and how many bytes each took, and each send (a wire-send event) its HTTP status, the pool\'s refusal code, and its size. It also records one event on the first run with whether it ran in CI and how this copy was installed, as closed words; whether each command ran in CI, as a closed word; and the covenant version each time the covenant screen is shown at a terminal.',
11
+ 'It never records session content, prompts, file paths, URLs, arguments, or anything you typed. oars sends the events in batches to the pool server in OARS_POOL_URL, else to the Many Oars pool (pool.manyoars.com, which is live). Many Oars Co. may license and sell Usage Data. oars sends no telemetry from a machine whose time zone is in the EU, the EEA, or the UK.',
9
12
  'See exactly what is kept: oars telemetry show. Turn it off: oars telemetry off, or set OARS_TELEMETRY=0 or DO_NOT_TRACK=1. More: docs/telemetry.md.',
10
13
  ].join('\n');
11
14
  const readSettings = (dir) => {
@@ -15,7 +18,12 @@ const readSettings = (dir) => {
15
18
  return {};
16
19
  const enabled = Reflect.get(parsed, 'enabled');
17
20
  const shown = Reflect.get(parsed, 'noticeShownAt');
18
- return { ...(typeof enabled === 'boolean' ? { enabled } : {}), ...(typeof shown === 'string' ? { noticeShownAt: shown } : {}) };
21
+ const version = Reflect.get(parsed, 'noticeVersion');
22
+ return {
23
+ ...(typeof enabled === 'boolean' ? { enabled } : {}),
24
+ ...(typeof shown === 'string' ? { noticeShownAt: shown } : {}),
25
+ ...(typeof version === 'number' ? { noticeVersion: version } : {}),
26
+ };
19
27
  }
20
28
  catch {
21
29
  return {};
@@ -31,9 +39,13 @@ const writeSettings = (dir, settings) => {
31
39
  };
32
40
  const OFF_WORDS = new Set(['0', 'false', 'off', 'no']);
33
41
  const DO_NOT_TRACK_WORDS = new Set(['1', 'true']);
34
- export const consentOf = (env, dir) => {
42
+ export const consentOf = (env, dir, zone = machineZone()) => {
35
43
  const settings = readSettings(dir);
36
- const noticeShown = settings.noticeShownAt !== undefined;
44
+ // An install that saw an older notice (no stored version, or another one) has not seen this one: it sees it again, and sends nothing until then.
45
+ const noticeShown = settings.noticeShownAt !== undefined && settings.noticeVersion === NOTICE_VERSION;
46
+ // First, so that no setting or `oars telemetry on` turns it back on in a closed region.
47
+ if (isClosedZone(zone))
48
+ return { enabled: false, reason: 'region', noticeShown };
37
49
  if (OFF_WORDS.has((env.OARS_TELEMETRY ?? '').toLowerCase()))
38
50
  return { enabled: false, reason: 'OARS_TELEMETRY', noticeShown };
39
51
  if (DO_NOT_TRACK_WORDS.has((env.DO_NOT_TRACK ?? '').toLowerCase()))
@@ -49,15 +61,19 @@ export const setEnabled = (dir, enabled) => {
49
61
  /** Records that the notice was shown; `false` when it could not be recorded (nothing is recorded or sent from a folder that cannot be written). */
50
62
  export const markNoticeShown = (dir, now) => {
51
63
  try {
52
- writeSettings(dir, { ...readSettings(dir), noticeShownAt: new Date(now()).toISOString() });
64
+ writeSettings(dir, { ...readSettings(dir), noticeShownAt: new Date(now()).toISOString(), noticeVersion: NOTICE_VERSION });
53
65
  return true;
54
66
  }
55
67
  catch {
56
68
  return false;
57
69
  }
58
70
  };
71
+ /** Whether this install has ever been shown any notice (an older version included). */
72
+ export const everShownNotice = (dir) => readSettings(dir).noticeShownAt !== undefined;
73
+ /** The notice version this install was shown, or `undefined` when none (or an unreadable file). */
74
+ export const shownNoticeVersion = (dir) => readSettings(dir).noticeVersion;
59
75
  /** Whether the sender (t875) may send: telemetry on, and the notice shown. Off, or notice not yet shown, means nothing sends. */
60
- export const telemetrySendAllowed = (env, dir) => {
61
- const consent = consentOf(env, dir);
76
+ export const telemetrySendAllowed = (env, dir, zone = machineZone()) => {
77
+ const consent = consentOf(env, dir, zone);
62
78
  return consent.enabled && consent.noticeShown;
63
79
  };
@@ -1,4 +1,5 @@
1
1
  import type { Recall } from '../sessions/recall-object.js';
2
+ import type { CiWord, SurfaceWord } from './words.js';
2
3
  /** The harness every hook oars installs runs under (Claude Code); one spelling for the session hooks and the gate hook, so the two emitters cannot drift. */
3
4
  export declare const HOOK_HARNESS = "claude-code";
4
5
  /** The harness a hook ran under, as the closed list names it, else `other`. */
@@ -14,3 +15,13 @@ export declare const frameWords: (harness: string | undefined, harnessVersion: s
14
15
  };
15
16
  /** A recall as counts: how it was asked, which families it served, how many reads and evidence rows, the size of the answer, and its arm. Never the query text, a name, or the rendered answer. */
16
17
  export declare const recallFields: (recall: Recall, families: readonly string[], legacy?: boolean) => object;
18
+ /** Whether this run is in CI, as a closed word read from the environment's variable names. Only the name decides the word: the value is tested and dropped, never returned. */
19
+ export declare const ciWord: (env: NodeJS.ProcessEnv) => CiWord;
20
+ /**
21
+ * How this copy arrived, as a closed word, from the folder oars runs in and `npm_config_user_agent` and from nothing else. A harness store is read first (a plugin can carry its own
22
+ * `node_modules`), then a package runner, then a global install; a folder that fits none reads `unknown`, never a guess. The agent only confirms a runner: a package manager that started the
23
+ * process says its name there, and a folder called `dlx` with no such agent is not a runner.
24
+ */
25
+ export declare const surfaceWord: (installPath: string, userAgent: string | undefined) => SurfaceWord;
26
+ /** The folder this module runs from: inside the package wherever it was installed (a symlinked bin resolves to its real folder). */
27
+ export declare const installFolder: () => string;
@@ -1,4 +1,5 @@
1
1
  import { HARNESSES, OTHER } from '../sessions/pool-wire.js';
2
+ import { fileURLToPath } from 'node:url';
2
3
  import { modelWord, versionWord } from './schema.js';
3
4
  /** The harness every hook oars installs runs under (Claude Code); one spelling for the session hooks and the gate hook, so the two emitters cannot drift. */
4
5
  export const HOOK_HARNESS = 'claude-code';
@@ -24,3 +25,58 @@ export const recallFields = (recall, families, legacy = false) => ({
24
25
  tokens: recall.answer.tokens,
25
26
  arm: recall.arm,
26
27
  });
28
+ /** A provider's own variable and the word it is read as. Order is the precedence: most providers also set `CI`, which on its own reads `other`. */
29
+ const CI_PROVIDERS = [
30
+ ['GITHUB_ACTIONS', 'github-actions'],
31
+ ['GITLAB_CI', 'gitlab-ci'],
32
+ ['CIRCLECI', 'circleci'],
33
+ ['TRAVIS', 'travis-ci'],
34
+ ['JENKINS_URL', 'jenkins'],
35
+ ['BUILDKITE', 'buildkite'],
36
+ ['TF_BUILD', 'azure-pipelines'],
37
+ ['BITBUCKET_BUILD_NUMBER', 'bitbucket'],
38
+ ['TEAMCITY_VERSION', 'teamcity'],
39
+ ['APPVEYOR', 'appveyor'],
40
+ ['CODEBUILD_BUILD_ID', 'codebuild'],
41
+ ];
42
+ /** A variable counts when it holds anything but the words a shell uses for no (an empty value, `0`, `false`, `no`, `off`). */
43
+ const NOT_SET = new Set(['', '0', 'false', 'no', 'off']);
44
+ const isSet = (value) => value !== undefined && !NOT_SET.has(value.toLowerCase());
45
+ /** Whether this run is in CI, as a closed word read from the environment's variable names. Only the name decides the word: the value is tested and dropped, never returned. */
46
+ export const ciWord = (env) => {
47
+ for (const [variable, word] of CI_PROVIDERS)
48
+ if (isSet(env[variable]))
49
+ return word;
50
+ return isSet(env.CI) ? 'other' : 'none';
51
+ };
52
+ /** The harness stores a plugin or extension lands in, matched on the folder names the harness uses under its home. */
53
+ const HARNESS_STORES = [
54
+ ['/.claude/plugins/', 'claude-plugin'],
55
+ ['/.codex/plugins/', 'codex-plugin'],
56
+ ['/.gemini/extensions/', 'gemini-extension'],
57
+ ];
58
+ /** Where a package manager keeps a global install: npm and Node's own prefix (`lib/node_modules`, Windows `npm/node_modules`), pnpm, yarn, and bun. */
59
+ const GLOBAL_FOLDERS = ['/lib/node_modules/', '/npm/node_modules/', '/pnpm/global/', '/yarn/global/node_modules/', '/.bun/install/global/'];
60
+ /** npm's own folder for a package run once (`npx`), which no other tool uses. */
61
+ const NPX_FOLDER = '/_npx/';
62
+ /** The folders pnpm (`dlx`) and bun (`bunx-<id>-<package>`) run a package from: names common enough that only a package manager's own agent makes them count. */
63
+ const RUNNER_FOLDERS = /\/(dlx|bunx-[^/]*)\//;
64
+ const PACKAGE_MANAGER_AGENT = /^(npm|pnpm|yarn|bun)\//;
65
+ /**
66
+ * How this copy arrived, as a closed word, from the folder oars runs in and `npm_config_user_agent` and from nothing else. A harness store is read first (a plugin can carry its own
67
+ * `node_modules`), then a package runner, then a global install; a folder that fits none reads `unknown`, never a guess. The agent only confirms a runner: a package manager that started the
68
+ * process says its name there, and a folder called `dlx` with no such agent is not a runner.
69
+ */
70
+ export const surfaceWord = (installPath, userAgent) => {
71
+ const path = `/${installPath.split(String.fromCharCode(92)).join('/').toLowerCase()}`;
72
+ const store = HARNESS_STORES.find(([folder]) => path.includes(folder));
73
+ if (store !== undefined)
74
+ return store[1];
75
+ if (path.includes(NPX_FOLDER))
76
+ return 'npx';
77
+ if (RUNNER_FOLDERS.test(path) && userAgent !== undefined && PACKAGE_MANAGER_AGENT.test(userAgent))
78
+ return 'npx';
79
+ return GLOBAL_FOLDERS.some((folder) => path.includes(folder)) ? 'npm-global' : 'unknown';
80
+ };
81
+ /** The folder this module runs from: inside the package wherever it was installed (a symlinked bin resolves to its real folder). */
82
+ export const installFolder = () => fileURLToPath(import.meta.url);
@@ -15,6 +15,19 @@ export declare const WINDOWS: readonly number[];
15
15
  export declare const HOOK_NAMES: readonly string[];
16
16
  export declare const OTHER = "other";
17
17
  export declare const UNRECORDED = "unrecorded";
18
+ /**
19
+ * The cell of a `first-run` event: one yes (the first run was in CI) or no under the oars version, in a metric named for the listing surface (`first-run.npx`), so the pool's rollup holds each install's
20
+ * first-run day, its surface, and whether it ran in CI. `measuresOf` leaves it out on purpose: a rate over one event is no measure of the program, so the user's own report has no use for it, and the pool's fleet read skips it for the same reason. A word off
21
+ * the closed lists reads as `unknown` or not-CI, and the install is still counted.
22
+ */
23
+ export declare const firstRunMeasures: (event: unknown) => Measure[];
24
+ /**
25
+ * The cell of a `covenant-shown` event: one yes under the oars version, in a metric named for the covenant version the CLI told the operator to read (`covenant-shown.2026-11-10`), so the pool's rollup counts shows by version and
26
+ * day. Raw batches are swept at 90 days and a cell added later cannot count older shows, so the cell is kept from the first event. `measuresOf` leaves it out and the fleet read skips it (`isRecordOnlyMetric`): a count of shows is
27
+ * for the operator's query, not a measure of the program. A version that is not a word reads `other`, and a missing one `unrecorded`; the show is still counted.
28
+ */
29
+ export declare const covenantShownMeasures: (event: unknown) => Measure[];
30
+ export declare const isRecordOnlyMetric: (metric: string) => boolean;
18
31
  /** What one event measures; an event of a type nothing measures, or that is not an object, measures nothing. */
19
32
  export declare const measuresOf: (event: unknown) => Measure[];
20
33
  /** Every cell is named by these three together. */
@@ -2,6 +2,7 @@
2
2
  // A measure is one observation in a cell: a latency in milliseconds, or a yes/no that rates are made from. The cell is (metric, dimension, value): `hook.pre-tool.latency` at harness
3
3
  // `claude-code`, version `2.1.5`. The reader is tolerant on purpose: the pool reads batches kept whole from any client, so a value that does not fit is skipped, never trusted.
4
4
  import { isHarness } from '../sessions/pool-wire.js';
5
+ import { CI_WORDS, SURFACE_WORDS } from './words.js';
5
6
  /** The windows the report shows and the pool serves, in days. */
6
7
  export const WINDOWS = [7, 30];
7
8
  const DAY_MS = 86_400_000;
@@ -73,6 +74,35 @@ const storeMeasures = (event, data) => {
73
74
  found.push(rate('store.verifyFailure', 'cli', version, failed > 0));
74
75
  return found;
75
76
  };
77
+ /**
78
+ * The cell of a `first-run` event: one yes (the first run was in CI) or no under the oars version, in a metric named for the listing surface (`first-run.npx`), so the pool's rollup holds each install's
79
+ * first-run day, its surface, and whether it ran in CI. `measuresOf` leaves it out on purpose: a rate over one event is no measure of the program, so the user's own report has no use for it, and the pool's fleet read skips it for the same reason. A word off
80
+ * the closed lists reads as `unknown` or not-CI, and the install is still counted.
81
+ */
82
+ export const firstRunMeasures = (event) => {
83
+ if (fieldOf(event, 'type') !== 'first-run')
84
+ return [];
85
+ const data = fieldOf(event, 'data');
86
+ const surface = fieldOf(data, 'surface');
87
+ const ci = fieldOf(data, 'ci');
88
+ const known = typeof surface === 'string' && SURFACE_WORDS.some((word) => word === surface) ? surface : 'unknown';
89
+ const inCi = typeof ci === 'string' && ci !== 'none' && CI_WORDS.some((word) => word === ci);
90
+ return [rate(`first-run.${known}`, 'cli', valueOf(fieldOf(event, 'cli')), inCi)];
91
+ };
92
+ /**
93
+ * The cell of a `covenant-shown` event: one yes under the oars version, in a metric named for the covenant version the CLI told the operator to read (`covenant-shown.2026-11-10`), so the pool's rollup counts shows by version and
94
+ * day. Raw batches are swept at 90 days and a cell added later cannot count older shows, so the cell is kept from the first event. `measuresOf` leaves it out and the fleet read skips it (`isRecordOnlyMetric`): a count of shows is
95
+ * for the operator's query, not a measure of the program. A version that is not a word reads `other`, and a missing one `unrecorded`; the show is still counted.
96
+ */
97
+ export const covenantShownMeasures = (event) => {
98
+ if (fieldOf(event, 'type') !== 'covenant-shown')
99
+ return [];
100
+ const version = valueOf(fieldOf(fieldOf(event, 'data'), 'version'));
101
+ return [rate(`covenant-shown.${version}`, 'cli', valueOf(fieldOf(event, 'cli')), true)];
102
+ };
103
+ /** The metrics the rollup records for the operator's queries and the fleet read never serves: a cell of one event or one count per install measures nothing across installs. */
104
+ const RECORD_ONLY_PREFIXES = ['first-run.', 'covenant-shown.'];
105
+ export const isRecordOnlyMetric = (metric) => RECORD_ONLY_PREFIXES.some((prefix) => metric.startsWith(prefix));
76
106
  /** What one event measures; an event of a type nothing measures, or that is not an object, measures nothing. */
77
107
  export const measuresOf = (event) => {
78
108
  const type = fieldOf(event, 'type');
@@ -1,13 +1,26 @@
1
1
  import type { Command } from 'commander';
2
2
  import type { RunDeps } from '../cli.js';
3
3
  import type { ExitCode } from '../errors.js';
4
+ import type { CardLineArm } from './schema.js';
5
+ import { type TelemetryRuntime } from './record.js';
4
6
  export interface Observed {
5
7
  /** The command path, such as `['pool', 'fetch']`; empty when no command ran (`--help`, `--version`, a usage error). */
6
8
  path: string[];
7
9
  /** The long names (as commander keys them) of the options given on the command line. */
8
10
  flags: string[];
11
+ /** What the weekly card line did on this command (`sessions/card-line.ts`); absent when it was never asked. */
12
+ cardLine?: CardLineArm;
9
13
  }
10
14
  /** Called by commander just before an action runs. */
11
15
  export declare const observeCommand: (observed: Observed, action: Command) => void;
12
- /** Prints the notice on the first run that is not a hook, then records the event. Never throws and never changes what the command did. */
13
- export declare const finishTelemetry: (deps: RunDeps, argv: string[], observed: Observed, startedAt: number, exit: ExitCode) => void;
16
+ export declare const isHook: (argv: string[], observed: Observed) => boolean;
17
+ /**
18
+ * The `first-run` event, from the one place that shows the notice. `installPath` is where this copy runs from; only a test names it, and it is read here because it is needed on a first run only.
19
+ * Never throws: `oars telemetry on`, which shows the notice itself, calls it outside `finishTelemetry`'s guard.
20
+ */
21
+ export declare const recordFirstRun: (deps: RunDeps, runtime: TelemetryRuntime, installPath?: string) => void;
22
+ /**
23
+ * Prints the notice on the first run that is not a hook, then records the event. Never throws and never changes what the command did. `installPath` is where this copy runs from; only a test names it,
24
+ * and it is read inside the guard because it is needed on a first run only.
25
+ */
26
+ export declare const finishTelemetry: (deps: RunDeps, argv: string[], observed: Observed, startedAt: number, exit: ExitCode, installPath?: string) => void;
@@ -1,6 +1,7 @@
1
- import { consentOf, markNoticeShown, NOTICE } from './consent.js';
1
+ import { consentOf, everShownNotice, markNoticeShown, NOTICE } from './consent.js';
2
2
  import { recordEvent } from './record.js';
3
3
  import { startDailySend } from './send.js';
4
+ import { ciWord, installFolder, surfaceWord } from './fields.js';
4
5
  import { errorClassOf } from './schema.js';
5
6
  const pathOf = (command) => {
6
7
  const names = [];
@@ -18,12 +19,29 @@ export const observeCommand = (observed, action) => {
18
19
  const given = action.optsWithGlobals();
19
20
  observed.flags = Object.keys(given).filter((key) => action.getOptionValueSourceWithGlobals(key) === 'cli');
20
21
  };
21
- const isHook = (argv, observed) => {
22
+ export const isHook = (argv, observed) => {
22
23
  const gateHook = (words) => words[0] === 'gate' && words[1] === 'hook';
23
24
  return gateHook(observed.path) || gateHook(argv);
24
25
  };
25
- /** Prints the notice on the first run that is not a hook, then records the event. Never throws and never changes what the command did. */
26
- export const finishTelemetry = (deps, argv, observed, startedAt, exit) => {
26
+ /**
27
+ * The `first-run` event, from the one place that shows the notice. `installPath` is where this copy runs from; only a test names it, and it is read here because it is needed on a first run only.
28
+ * Never throws: `oars telemetry on`, which shows the notice itself, calls it outside `finishTelemetry`'s guard.
29
+ */
30
+ export const recordFirstRun = (deps, runtime, installPath) => {
31
+ try {
32
+ recordEvent({ ...runtime, version: deps.version, now: deps.now }, 'first-run', { ci: ciWord(deps.env), surface: surfaceWord(installPath ?? installFolder(), deps.env.npm_config_user_agent) });
33
+ }
34
+ catch {
35
+ // Telemetry is never allowed to change what a command does.
36
+ }
37
+ };
38
+ /** `--version` and `--help` end the parse before any action runs (so the path is empty); they stay a plain answer and leave the notice for the next command. */
39
+ const isInfoOnly = (argv, observed) => observed.path.length === 0 && argv.some((word) => ['--version', '-V', '--help', '-h'].includes(word));
40
+ /**
41
+ * Prints the notice on the first run that is not a hook, then records the event. Never throws and never changes what the command did. `installPath` is where this copy runs from; only a test names it,
42
+ * and it is read inside the guard because it is needed on a first run only.
43
+ */
44
+ export const finishTelemetry = (deps, argv, observed, startedAt, exit, installPath) => {
27
45
  try {
28
46
  const runtime = deps.telemetry;
29
47
  if (runtime === undefined)
@@ -31,17 +49,26 @@ export const finishTelemetry = (deps, argv, observed, startedAt, exit) => {
31
49
  const consent = consentOf(deps.env, runtime.dir);
32
50
  if (!consent.enabled)
33
51
  return;
34
- if (!consent.noticeShown && !isHook(argv, observed) && markNoticeShown(runtime.dir, deps.now))
35
- deps.io.stderr(`${NOTICE}\n`);
52
+ if (!consent.noticeShown && !isHook(argv, observed) && !isInfoOnly(argv, observed)) {
53
+ const seenBefore = everShownNotice(runtime.dir);
54
+ if (markNoticeShown(runtime.dir, deps.now)) {
55
+ deps.io.stderr(`${NOTICE}\n`);
56
+ // Once per install: an install that saw an older notice sees this one but records no second first-run; nothing is recorded or sent from a folder that could not claim it.
57
+ if (!seenBefore)
58
+ recordFirstRun(deps, runtime, installPath);
59
+ }
60
+ }
36
61
  const errorClass = errorClassOf(exit);
37
62
  recordEvent({ ...runtime, version: deps.version, now: deps.now }, 'command', {
38
63
  path: observed.path,
39
64
  flags: observed.flags,
40
65
  durationMs: Math.max(0, deps.now() - startedAt),
41
66
  exitCode: exit,
67
+ ci: ciWord(deps.env),
68
+ ...(observed.cardLine === undefined ? {} : { cardLine: observed.cardLine }),
42
69
  ...(errorClass === undefined ? {} : { errorClass }),
43
70
  });
44
- // Never from a hook: a hook has a time budget, and the send is for the nightly task and the commands a person runs.
71
+ // Never from a hook: a hook has a time budget, and the send is for the detached sender and the commands a person runs.
45
72
  if (!isHook(argv, observed))
46
73
  startDailySend(runtime, deps.env, deps.now());
47
74
  }
@@ -0,0 +1,6 @@
1
+ /** IANA zones of the EU, the EEA (Iceland, Liechtenstein, Norway), and the UK, with the outermost regions that sit outside `Europe/*` and `Europe/Mariehamn` (Åland). */
2
+ export declare const CLOSED_ZONES: ReadonlySet<string>;
3
+ /** True when the zone is on the closed list or cannot be read (absent or blank). */
4
+ export declare const isClosedZone: (zone: string | undefined) => boolean;
5
+ /** The machine's resolved IANA zone, or `undefined` when the runtime cannot say. */
6
+ export declare const machineZone: () => string | undefined;
@@ -0,0 +1,28 @@
1
+ // Where telemetry is off: a machine whose time zone is in the EU, the EEA, or the UK records and sends nothing, with no prompt (P97). The zone is the machine's own fact, so no network call
2
+ // is made. An unresolvable zone counts as closed: the rule fails toward sending nothing.
3
+ /** IANA zones of the EU, the EEA (Iceland, Liechtenstein, Norway), and the UK, with the outermost regions that sit outside `Europe/*` and `Europe/Mariehamn` (Åland). */
4
+ export const CLOSED_ZONES = new Set([
5
+ // EU member states
6
+ 'Europe/Vienna', 'Europe/Brussels', 'Europe/Sofia', 'Europe/Zagreb', 'Asia/Nicosia', 'Asia/Famagusta', 'Europe/Nicosia', 'Europe/Prague', 'Europe/Copenhagen',
7
+ 'Europe/Tallinn', 'Europe/Helsinki', 'Europe/Mariehamn', 'Europe/Paris', 'Europe/Berlin', 'Europe/Busingen', 'Europe/Athens', 'Europe/Budapest', 'Europe/Dublin',
8
+ 'Europe/Rome', 'Europe/Riga', 'Europe/Vilnius', 'Europe/Luxembourg', 'Europe/Malta', 'Europe/Amsterdam', 'Europe/Warsaw', 'Europe/Lisbon', 'Europe/Bucharest',
9
+ 'Europe/Bratislava', 'Europe/Ljubljana', 'Europe/Madrid', 'Europe/Stockholm',
10
+ // EEA outside the EU, and the UK (with its legacy aliases). Europe/Jersey, Europe/Guernsey, and Europe/Isle_of_Man (Crown dependencies) and
11
+ // America/St_Barthelemy (outside the EU since 2012) are kept on purpose, to fail closed.
12
+ 'Atlantic/Reykjavik', 'Iceland', 'Europe/Vaduz', 'Europe/Oslo', 'Arctic/Longyearbyen', 'Atlantic/Jan_Mayen', 'WET', 'CET', 'MET', 'EET', 'Europe/London', 'Europe/Belfast', 'Europe/Jersey', 'Europe/Guernsey', 'Europe/Isle_of_Man',
13
+ 'GB', 'GB-Eire', 'Eire', 'Portugal', 'Poland',
14
+ // Outermost regions and island zones of member states
15
+ 'Atlantic/Canary', 'Atlantic/Madeira', 'Atlantic/Azores', 'Africa/Ceuta', 'America/Martinique', 'America/Guadeloupe', 'America/Cayenne', 'Indian/Reunion', 'Indian/Mayotte',
16
+ 'America/St_Barthelemy', 'America/Marigot',
17
+ ]);
18
+ /** True when the zone is on the closed list or cannot be read (absent or blank). */
19
+ export const isClosedZone = (zone) => zone === undefined || zone.trim() === '' || zone.trim() === 'Etc/Unknown' || CLOSED_ZONES.has(zone.trim());
20
+ /** The machine's resolved IANA zone, or `undefined` when the runtime cannot say. */
21
+ export const machineZone = () => {
22
+ try {
23
+ return Intl.DateTimeFormat().resolvedOptions().timeZone;
24
+ }
25
+ catch {
26
+ return undefined;
27
+ }
28
+ };
@@ -167,7 +167,7 @@ export const textOf = (report, dir) => {
167
167
  const floor = fleet.status === 'answered' ? fleet.floor : 0;
168
168
  const out = [`Your telemetry, from the spool in ${dir}.`];
169
169
  if (report.windows.every((window) => window.cells.length === 0) && report.store === undefined)
170
- out.push('Nothing is measured in the spool yet: the hooks and nightly task record as they run.');
170
+ out.push('Nothing is measured in the spool yet: the hooks and the detached sender record as they run.');
171
171
  for (const window of report.windows) {
172
172
  if (window.cells.length === 0)
173
173
  continue;
@@ -18,6 +18,9 @@ export declare const HOOK_OUTCOMES: readonly ["hit", "miss", "held", "error"];
18
18
  export declare const RECALL_FORMS: readonly ["pattern", "entity", "question", "text", "legacy"];
19
19
  export declare const RECALL_MODES: readonly ["pull", "push"];
20
20
  export declare const RECALL_ARMS: readonly ["shown", "held"];
21
+ /** What the weekly card line did on a command: printed, held back by the holdback, or not due. */
22
+ export declare const CARD_LINE_ARMS: readonly ["shown", "held", "not_due"];
23
+ export type CardLineArm = (typeof CARD_LINE_ARMS)[number];
21
24
  /** The read families a recall can serve, as the program names them. */
22
25
  export declare const RECALL_FAMILY_NAMES: readonly ["recovery", "fit", "cost", "change", "live", "adoption", "method", "next-step", "tool-day", "version-boundary", "substitution", "interaction", "day-move", "entity"];
23
26
  export declare const FETCH_HARNESSES: readonly ["claude-code", "cursor", "codex", "copilot", "gemini", "grok", "opencode", "pi", "vscode-copilot", "otel", "other"];
@@ -6,6 +6,7 @@ import { ExitCode } from '../errors.js';
6
6
  import { FIELD_ROWS } from '../sessions/pool-outbox.js';
7
7
  import { OTHER, HARNESSES } from '../sessions/pool-wire.js';
8
8
  import { LITERAL, publicModel, publicVersion } from '../sessions/public-vocabulary.js';
9
+ import { CI_WORDS, SURFACE_WORDS } from './words.js';
9
10
  /** Bumped when an event type or field changes meaning; every spooled event carries it. */
10
11
  export const SCHEMA_VERSION = 1;
11
12
  /** A command or option name as the program defines it: letters, digits, and hyphens. No space, slash, dot, colon, or quote. */
@@ -36,6 +37,9 @@ const hex = (digits) => (value) => typeof value === 'string' && new RegExp(`^[0-
36
37
  /** A problem code as a server names it: lowercase words joined with underscores. No space, slash, dot, colon, or quote, so no path, URL, or sentence fits. */
37
38
  const CODE = /^[a-z][a-z0-9_]{0,39}$/;
38
39
  const code = (value) => (typeof value === 'string' && CODE.test(value) ? value : undefined);
40
+ /** A covenant version as the pool names it: a short word of letters, digits, dots, underscores, and hyphens. A text off that shape (the pool is any server the operator names) is dropped, not trimmed. */
41
+ const COVENANT_VERSION = /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/;
42
+ const covenantVersion = (value) => (typeof value === 'string' && COVENANT_VERSION.test(value) ? value : undefined);
39
43
  const rules = (fields) => new Map(Object.entries(fields));
40
44
  const DAY_MS = 86_400_000;
41
45
  /** What went wrong, as a closed word chosen from the exit code; the error's own text never comes here. */
@@ -48,6 +52,8 @@ export const HOOK_OUTCOMES = ['hit', 'miss', 'held', 'error'];
48
52
  export const RECALL_FORMS = ['pattern', 'entity', 'question', 'text', 'legacy'];
49
53
  export const RECALL_MODES = ['pull', 'push'];
50
54
  export const RECALL_ARMS = ['shown', 'held'];
55
+ /** What the weekly card line did on a command: printed, held back by the holdback, or not due. */
56
+ export const CARD_LINE_ARMS = ['shown', 'held', 'not_due'];
51
57
  /** The read families a recall can serve, as the program names them. */
52
58
  export const RECALL_FAMILY_NAMES = ['recovery', 'fit', 'cost', 'change', 'live', 'adoption', 'method', 'next-step', 'tool-day', 'version-boundary', 'substitution', 'interaction', 'day-move', 'entity'];
53
59
  export const FETCH_HARNESSES = ['claude-code', 'cursor', 'codex', 'copilot', 'gemini', 'grok', 'opencode', 'pi', 'vscode-copilot', 'otel', 'other'];
@@ -63,7 +69,7 @@ const WIRE_HARNESSES = [...HARNESSES, OTHER];
63
69
  const WIRE_LENGTH = WIRE_NAMES.size;
64
70
  /** One line per event type. */
65
71
  const EVENTS = new Map([
66
- ['command', rules({ path: tokens(4), flags: tokens(40), durationMs: whole(DAY_MS), exitCode: whole(255), errorClass: oneOf(ERROR_CLASSES) })],
72
+ ['command', rules({ path: tokens(4), flags: tokens(40), durationMs: whole(DAY_MS), exitCode: whole(255), errorClass: oneOf(ERROR_CLASSES), ci: oneOf(CI_WORDS), cardLine: oneOf(CARD_LINE_ARMS) })],
67
73
  ['hook', rules({ name: oneOf(HOOK_NAMES), latencyMs: whole(DAY_MS), outcome: oneOf(HOOK_OUTCOMES), bytes: whole(BYTES), harness: oneOf(WIRE_HARNESSES), harnessVersion: MODEL_OR_VERSION(versionWord), model: MODEL_OR_VERSION(modelWord) })],
68
74
  [
69
75
  'recall',
@@ -128,6 +134,10 @@ const EVENTS = new Map([
128
134
  ],
129
135
  // One send of one batch: whether the pool took it, the HTTP status, and the code of its refusal.
130
136
  ['wire-send', rules({ sent: whole(1), status: whole(599), code, bytes: whole(HUGE) })],
137
+ // The install's first run, sent once where the first-run notice is shown (docs/telemetry.md).
138
+ ['first-run', rules({ ci: oneOf(CI_WORDS), surface: oneOf(SURFACE_WORDS) })],
139
+ // The CLI told the operator which covenant to read and accept: the version and nothing else.
140
+ ['covenant-shown', rules({ version: covenantVersion })],
131
141
  ]);
132
142
  export const EVENT_TYPES = [...EVENTS.keys()];
133
143
  /** The field names a type admits, so a test can feed every one. */
@@ -14,7 +14,7 @@ export interface SendDeps {
14
14
  timeoutMs?: number;
15
15
  }
16
16
  export interface SendResult {
17
- outcome: 'sent' | 'failed' | 'empty' | 'not-allowed' | 'no-server';
17
+ outcome: 'sent' | 'failed' | 'empty' | 'not-allowed' | 'no-server' | 'not-due';
18
18
  /** Events the server took (and that were cleared from the spool). */
19
19
  sent: number;
20
20
  /** Requests made, including one that failed. */
@@ -25,7 +25,10 @@ export declare const writeMark: (dir: string, now: number) => void;
25
25
  export declare const dueForSend: (dir: string, now: number) => boolean;
26
26
  /** Sends what the spool holds. Never throws. */
27
27
  export declare const sendSpool: (deps: SendDeps) => Promise<SendResult>;
28
- /** The nightly task's send, in the telemetry folder under the config folder. */
28
+ /**
29
+ * The detached sender's telemetry send, in the telemetry folder under the config folder. The sender runs up to about hourly, but telemetry goes at most once a
30
+ * day, on the same mark a command's daily send uses; events recorded by the runs in between wait in the spool for the next day's batch.
31
+ */
29
32
  export declare const sendNightly: (env: NodeJS.ProcessEnv, platform: NodeJS.Platform, home: string, now: () => number) => Promise<SendResult>;
30
33
  /**
31
34
  * Called after an interactive command: starts one detached send, at most once a day, when sending is allowed. The mark is written first, so two commands at
@@ -1,4 +1,4 @@
1
- // The sender: the only code that sends telemetry. It runs from the nightly task, and at most once a day from an interactive command as a detached process; never on a hook path, and
1
+ // The sender: the only code that sends telemetry. It runs from the detached sender (hook-bin.js nightly), and at most once a day from an interactive command as a detached process; never on a hook path, and
2
2
  // nothing waits for it. Before any byte leaves it calls `telemetrySendAllowed` (telemetry on, notice shown) and sends to the pool server in `OARS_POOL_URL`, else the Many Oars pool, the same default `oars pool`
3
3
  // reads and with the same rules (https, or http to this machine).
4
4
  //
@@ -9,7 +9,7 @@ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
9
9
  import { join } from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
11
  import { defaultConfigDir } from '../credentials.js';
12
- import { telemetrySendAllowed } from './consent.js';
12
+ import { shownNoticeVersion, telemetrySendAllowed } from './consent.js';
13
13
  import { installId, readSpool, spoolPaths } from './spool.js';
14
14
  /** The pool route takes at most this many events (Analytics Engine's per-request write limit). */
15
15
  export const BATCH_EVENTS = 250;
@@ -41,12 +41,12 @@ const eventOf = (line) => {
41
41
  }
42
42
  };
43
43
  /** True only for a 2xx. A redirect is not followed: a pool never redirects a POST, and a 3xx from anything else is not the pool. */
44
- const post = async (url, install, events, deps) => {
44
+ const post = async (url, install, notice, events, deps) => {
45
45
  try {
46
46
  const response = await (deps.fetch ?? fetch)(url, {
47
47
  method: 'POST',
48
48
  headers: { 'content-type': 'application/json' },
49
- body: JSON.stringify({ install, events }),
49
+ body: JSON.stringify({ install, notice, events }),
50
50
  redirect: 'manual',
51
51
  signal: AbortSignal.timeout(deps.timeoutMs ?? SEND_TIMEOUT_MS),
52
52
  });
@@ -129,7 +129,7 @@ export const sendSpool = async (deps) => {
129
129
  for (let at = 0; at < events.length && !failed; at += BATCH_EVENTS) {
130
130
  const batch = events.slice(at, at + BATCH_EVENTS);
131
131
  tally.requests += 1;
132
- if (await post(`${server}/v1/telemetry`, install, batch.map(({ event }) => event), deps)) {
132
+ if (await post(`${server}/v1/telemetry`, install, shownNoticeVersion(deps.dir), batch.map(({ event }) => event), deps)) {
133
133
  tally.sent += batch.length;
134
134
  sent.push(...batch.map(({ line }) => line));
135
135
  }
@@ -145,8 +145,16 @@ export const sendSpool = async (deps) => {
145
145
  return finish('failed');
146
146
  }
147
147
  };
148
- /** The nightly task's send, in the telemetry folder under the config folder. */
149
- export const sendNightly = (env, platform, home, now) => sendSpool({ env, dir: join(defaultConfigDir(env, platform, home), 'oars', 'telemetry'), now });
148
+ /**
149
+ * The detached sender's telemetry send, in the telemetry folder under the config folder. The sender runs up to about hourly, but telemetry goes at most once a
150
+ * day, on the same mark a command's daily send uses; events recorded by the runs in between wait in the spool for the next day's batch.
151
+ */
152
+ export const sendNightly = async (env, platform, home, now) => {
153
+ const dir = join(defaultConfigDir(env, platform, home), 'oars', 'telemetry');
154
+ if (!dueForSend(dir, now()))
155
+ return { outcome: 'not-due', sent: 0, requests: 0 };
156
+ return sendSpool({ env, dir, now });
157
+ };
150
158
  /**
151
159
  * Called after an interactive command: starts one detached send, at most once a day, when sending is allowed. The mark is written first, so two commands at
152
160
  * once start one. Nothing here throws or waits, and the run has no way to start a send unless `main.ts` gave it one.
@@ -0,0 +1,6 @@
1
+ /** Whether a run was in CI and by which provider: the word is chosen from the provider's own variable (`ciWord` in `fields.ts`), so the variable's value never reaches the spool. `other` is a bare `CI`. */
2
+ export declare const CI_WORDS: readonly ["none", "github-actions", "gitlab-ci", "circleci", "travis-ci", "jenkins", "buildkite", "azure-pipelines", "bitbucket", "teamcity", "appveyor", "codebuild", "other"];
3
+ export type CiWord = (typeof CI_WORDS)[number];
4
+ /** How this copy of oars arrived, from the folder it runs in and the package manager that started it (`surfaceWord` in `fields.ts`); the path itself never reaches the spool. A folder it cannot place is `unknown`. */
5
+ export declare const SURFACE_WORDS: readonly ["npm-global", "npx", "claude-plugin", "codex-plugin", "gemini-extension", "unknown"];
6
+ export type SurfaceWord = (typeof SURFACE_WORDS)[number];
@@ -0,0 +1,6 @@
1
+ // The two closed word lists the pool's rollup reads (`measures.ts`) beside the schema that checks them (`schema.ts`). A leaf on purpose: it imports nothing, so a Worker that imports it takes no
2
+ // file-system module along (`schema.ts` reaches `node:fs/promises` through the session outbox).
3
+ /** Whether a run was in CI and by which provider: the word is chosen from the provider's own variable (`ciWord` in `fields.ts`), so the variable's value never reaches the spool. `other` is a bare `CI`. */
4
+ export const CI_WORDS = ['none', 'github-actions', 'gitlab-ci', 'circleci', 'travis-ci', 'jenkins', 'buildkite', 'azure-pipelines', 'bitbucket', 'teamcity', 'appveyor', 'codebuild', 'other'];
5
+ /** How this copy of oars arrived, from the folder it runs in and the package manager that started it (`surfaceWord` in `fields.ts`); the path itself never reaches the spool. A folder it cannot place is `unknown`. */
6
+ export const SURFACE_WORDS = ['npm-global', 'npx', 'claude-plugin', 'codex-plugin', 'gemini-extension', 'unknown'];
package/package.json CHANGED
@@ -1,7 +1,25 @@
1
1
  {
2
2
  "name": "@manyoars/oars",
3
- "version": "0.4.0",
4
- "description": "The oars CLI: sign in, manage API keys, and keep a local memory of your own coding-agent sessions on your machine. The session commands upload nothing except what `gold arms` sends through your own Claude subscription; telemetry (on by default) and the nightly pool send after you accept a pool covenant are described in the README",
3
+ "version": "0.5.1",
4
+ "mcpName": "io.github.manyoars/oars",
5
+ "description": "A local, searchable record of your coding agent sessions. Reads Claude Code, Cursor, Codex, Copilot CLI, Gemini CLI, and more, and keeps the record on your machine.",
6
+ "homepage": "https://manyoars.com/",
7
+ "keywords": [
8
+ "claude-code",
9
+ "codex",
10
+ "cursor",
11
+ "gemini-cli",
12
+ "coding-agents",
13
+ "agent-memory",
14
+ "session-history",
15
+ "mcp",
16
+ "mcp-server",
17
+ "claude-code-plugin",
18
+ "codex-plugin",
19
+ "gemini-cli-extension",
20
+ "coding-agent",
21
+ "cli"
22
+ ],
5
23
  "license": "Apache-2.0",
6
24
  "repository": {
7
25
  "type": "git",
@@ -10,10 +28,11 @@
10
28
  },
11
29
  "type": "module",
12
30
  "engines": {
13
- "node": ">=24"
31
+ "node": ">=22.23.4"
14
32
  },
15
33
  "bin": {
16
- "oars": "./dist/bin.js"
34
+ "oars": "./dist/bin.js",
35
+ "oars-hook": "./dist/oars-hook.js"
17
36
  },
18
37
  "files": [
19
38
  "dist",