@aligndottech/cli 0.37.1 → 0.38.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 (179) hide show
  1. package/README.md +16 -10
  2. package/dist/cli.d.ts +28 -0
  3. package/dist/cli.d.ts.map +1 -0
  4. package/dist/cli.js +98 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/commands/check.d.ts +1 -1
  7. package/dist/commands/check.d.ts.map +1 -1
  8. package/dist/commands/check.js +44 -2
  9. package/dist/commands/check.js.map +1 -1
  10. package/dist/commands/connect-agents.d.ts +1 -0
  11. package/dist/commands/connect-agents.d.ts.map +1 -1
  12. package/dist/commands/connect-agents.js +10 -6
  13. package/dist/commands/connect-agents.js.map +1 -1
  14. package/dist/commands/connect.d.ts +31 -0
  15. package/dist/commands/connect.d.ts.map +1 -0
  16. package/dist/commands/connect.js +92 -0
  17. package/dist/commands/connect.js.map +1 -0
  18. package/dist/commands/context.d.ts.map +1 -1
  19. package/dist/commands/context.js +52 -10
  20. package/dist/commands/context.js.map +1 -1
  21. package/dist/commands/decisions/index.d.ts.map +1 -1
  22. package/dist/commands/decisions/index.js +5 -0
  23. package/dist/commands/decisions/index.js.map +1 -1
  24. package/dist/commands/default-action.d.ts +3 -1
  25. package/dist/commands/default-action.d.ts.map +1 -1
  26. package/dist/commands/default-action.js +29 -27
  27. package/dist/commands/default-action.js.map +1 -1
  28. package/dist/commands/export.d.ts.map +1 -1
  29. package/dist/commands/export.js +3 -0
  30. package/dist/commands/export.js.map +1 -1
  31. package/dist/commands/import/sessions.d.ts +10 -2
  32. package/dist/commands/import/sessions.d.ts.map +1 -1
  33. package/dist/commands/import/sessions.js +98 -3
  34. package/dist/commands/import/sessions.js.map +1 -1
  35. package/dist/commands/import.d.ts +7 -0
  36. package/dist/commands/import.d.ts.map +1 -1
  37. package/dist/commands/import.js +42 -2
  38. package/dist/commands/import.js.map +1 -1
  39. package/dist/commands/invite.d.ts +25 -0
  40. package/dist/commands/invite.d.ts.map +1 -0
  41. package/dist/commands/invite.js +68 -0
  42. package/dist/commands/invite.js.map +1 -0
  43. package/dist/commands/links.d.ts.map +1 -1
  44. package/dist/commands/links.js +5 -0
  45. package/dist/commands/links.js.map +1 -1
  46. package/dist/commands/mcp.d.ts +64 -35
  47. package/dist/commands/mcp.d.ts.map +1 -1
  48. package/dist/commands/mcp.js +94 -41
  49. package/dist/commands/mcp.js.map +1 -1
  50. package/dist/commands/ratify.d.ts.map +1 -1
  51. package/dist/commands/ratify.js +21 -0
  52. package/dist/commands/ratify.js.map +1 -1
  53. package/dist/commands/registry.d.ts +30 -0
  54. package/dist/commands/registry.d.ts.map +1 -0
  55. package/dist/commands/registry.js +70 -0
  56. package/dist/commands/registry.js.map +1 -0
  57. package/dist/commands/setup.d.ts +45 -1
  58. package/dist/commands/setup.d.ts.map +1 -1
  59. package/dist/commands/setup.js +197 -88
  60. package/dist/commands/setup.js.map +1 -1
  61. package/dist/commands/status.d.ts.map +1 -1
  62. package/dist/commands/status.js +10 -22
  63. package/dist/commands/status.js.map +1 -1
  64. package/dist/commands/telemetry.d.ts +7 -3
  65. package/dist/commands/telemetry.d.ts.map +1 -1
  66. package/dist/commands/telemetry.js +12 -7
  67. package/dist/commands/telemetry.js.map +1 -1
  68. package/dist/commands/why.d.ts.map +1 -1
  69. package/dist/commands/why.js +52 -10
  70. package/dist/commands/why.js.map +1 -1
  71. package/dist/index.js +4 -90
  72. package/dist/index.js.map +1 -1
  73. package/dist/lib/agent-rules.d.ts +1 -0
  74. package/dist/lib/agent-rules.d.ts.map +1 -1
  75. package/dist/lib/agent-rules.js +60 -0
  76. package/dist/lib/agent-rules.js.map +1 -1
  77. package/dist/lib/commands-doc.d.ts +15 -0
  78. package/dist/lib/commands-doc.d.ts.map +1 -0
  79. package/dist/lib/commands-doc.js +85 -0
  80. package/dist/lib/commands-doc.js.map +1 -0
  81. package/dist/lib/config.d.ts +7 -2
  82. package/dist/lib/config.d.ts.map +1 -1
  83. package/dist/lib/config.js.map +1 -1
  84. package/dist/lib/connect-prompt.d.ts +1 -1
  85. package/dist/lib/connect-prompt.js +2 -2
  86. package/dist/lib/connect-prompt.js.map +1 -1
  87. package/dist/lib/decision-links.d.ts +39 -0
  88. package/dist/lib/decision-links.d.ts.map +1 -1
  89. package/dist/lib/decision-links.js +51 -0
  90. package/dist/lib/decision-links.js.map +1 -1
  91. package/dist/lib/first-decision.d.ts +22 -0
  92. package/dist/lib/first-decision.d.ts.map +1 -0
  93. package/dist/lib/first-decision.js +22 -0
  94. package/dist/lib/first-decision.js.map +1 -0
  95. package/dist/lib/gateway-client.d.ts +14 -1
  96. package/dist/lib/gateway-client.d.ts.map +1 -1
  97. package/dist/lib/gateway-client.js +14 -0
  98. package/dist/lib/gateway-client.js.map +1 -1
  99. package/dist/lib/git.d.ts +28 -0
  100. package/dist/lib/git.d.ts.map +1 -1
  101. package/dist/lib/git.js +42 -0
  102. package/dist/lib/git.js.map +1 -1
  103. package/dist/lib/hook-context.d.ts +7 -0
  104. package/dist/lib/hook-context.d.ts.map +1 -0
  105. package/dist/lib/hook-context.js +37 -0
  106. package/dist/lib/hook-context.js.map +1 -0
  107. package/dist/lib/hook-payload.d.ts +7 -0
  108. package/dist/lib/hook-payload.d.ts.map +1 -1
  109. package/dist/lib/hook-payload.js +56 -8
  110. package/dist/lib/hook-payload.js.map +1 -1
  111. package/dist/lib/invite-prompt.d.ts +46 -0
  112. package/dist/lib/invite-prompt.d.ts.map +1 -0
  113. package/dist/lib/invite-prompt.js +63 -0
  114. package/dist/lib/invite-prompt.js.map +1 -0
  115. package/dist/lib/local-gateway-client.d.ts +0 -1
  116. package/dist/lib/local-gateway-client.d.ts.map +1 -1
  117. package/dist/lib/local-gateway-client.js +5 -2
  118. package/dist/lib/local-gateway-client.js.map +1 -1
  119. package/dist/lib/mcp-instructions.shared.d.ts +40 -0
  120. package/dist/lib/mcp-instructions.shared.d.ts.map +1 -0
  121. package/dist/lib/mcp-instructions.shared.js +56 -0
  122. package/dist/lib/mcp-instructions.shared.js.map +1 -0
  123. package/dist/lib/mcp-setup.d.ts +32 -2
  124. package/dist/lib/mcp-setup.d.ts.map +1 -1
  125. package/dist/lib/mcp-setup.js +92 -6
  126. package/dist/lib/mcp-setup.js.map +1 -1
  127. package/dist/lib/next-step.d.ts +92 -0
  128. package/dist/lib/next-step.d.ts.map +1 -0
  129. package/dist/lib/next-step.js +116 -0
  130. package/dist/lib/next-step.js.map +1 -0
  131. package/dist/lib/personal-email-domains.d.ts +8 -0
  132. package/dist/lib/personal-email-domains.d.ts.map +1 -0
  133. package/dist/lib/personal-email-domains.js +39 -0
  134. package/dist/lib/personal-email-domains.js.map +1 -0
  135. package/dist/lib/personal-import.d.ts +2 -0
  136. package/dist/lib/personal-import.d.ts.map +1 -1
  137. package/dist/lib/personal-import.js +4 -1
  138. package/dist/lib/personal-import.js.map +1 -1
  139. package/dist/lib/read-value-rollup.d.ts +18 -0
  140. package/dist/lib/read-value-rollup.d.ts.map +1 -0
  141. package/dist/lib/read-value-rollup.js +33 -0
  142. package/dist/lib/read-value-rollup.js.map +1 -0
  143. package/dist/lib/sessions/import-summary.d.ts +39 -0
  144. package/dist/lib/sessions/import-summary.d.ts.map +1 -0
  145. package/dist/lib/sessions/import-summary.js +41 -0
  146. package/dist/lib/sessions/import-summary.js.map +1 -0
  147. package/dist/lib/sessions/memory-files.d.ts +85 -0
  148. package/dist/lib/sessions/memory-files.d.ts.map +1 -0
  149. package/dist/lib/sessions/memory-files.js +235 -0
  150. package/dist/lib/sessions/memory-files.js.map +1 -0
  151. package/dist/lib/sessions/source-url.d.ts +11 -0
  152. package/dist/lib/sessions/source-url.d.ts.map +1 -1
  153. package/dist/lib/sessions/source-url.js +15 -0
  154. package/dist/lib/sessions/source-url.js.map +1 -1
  155. package/dist/lib/setup-funnel.d.ts +41 -0
  156. package/dist/lib/setup-funnel.d.ts.map +1 -0
  157. package/dist/lib/setup-funnel.js +28 -0
  158. package/dist/lib/setup-funnel.js.map +1 -0
  159. package/dist/lib/telemetry-consent.d.ts +17 -8
  160. package/dist/lib/telemetry-consent.d.ts.map +1 -1
  161. package/dist/lib/telemetry-consent.js +21 -6
  162. package/dist/lib/telemetry-consent.js.map +1 -1
  163. package/dist/lib/telemetry-env.d.ts +19 -0
  164. package/dist/lib/telemetry-env.d.ts.map +1 -0
  165. package/dist/lib/telemetry-env.js +13 -0
  166. package/dist/lib/telemetry-env.js.map +1 -0
  167. package/dist/lib/usage-telemetry.d.ts +108 -13
  168. package/dist/lib/usage-telemetry.d.ts.map +1 -1
  169. package/dist/lib/usage-telemetry.js +227 -55
  170. package/dist/lib/usage-telemetry.js.map +1 -1
  171. package/dist/lib/user-hooks.d.ts +15 -0
  172. package/dist/lib/user-hooks.d.ts.map +1 -0
  173. package/dist/lib/user-hooks.js +144 -0
  174. package/dist/lib/user-hooks.js.map +1 -0
  175. package/dist/lib/value-rollup.d.ts +10 -3
  176. package/dist/lib/value-rollup.d.ts.map +1 -1
  177. package/dist/lib/value-rollup.js +11 -4
  178. package/dist/lib/value-rollup.js.map +1 -1
  179. package/package.json +2 -1
@@ -1,13 +1,23 @@
1
1
  import * as p from '@clack/prompts';
2
+ import { telemetryDisabledByEnv } from './telemetry-env.js';
2
3
  /**
3
4
  * ALI-618 D3: the one-time local-mode consent prompt, shown from `runLocalSetup()`.
4
5
  *
5
- * Never asks twice - a decision already on disk (granted OR declined) is left alone. Never
6
- * prompts without a TTY on both streams: a piped `setup --local` run hangs on a prompt it
7
- * cannot answer, and a closed stdin crashes clack's raw-mode init AFTER the real setup work has
8
- * already succeeded (the align-cli#118 lesson, `setup-local-non-tty.test.ts`). A non-interactive
9
- * run leaves consent UNSET rather than implicitly declined - a scripted first run may be a CI
10
- * smoke test, not a real user's choice, and it can still be asked on a later interactive run.
6
+ * Never asks twice - a decision already on disk (granted, declined, or `align telemetry off`)
7
+ * is left alone. Never prompts without a TTY on both streams: a piped `setup --local` run
8
+ * hangs on a prompt it cannot answer, and a closed stdin crashes clack's raw-mode init AFTER
9
+ * the real setup work has already succeeded (the align-cli#118 lesson,
10
+ * `setup-local-non-tty.test.ts`). A non-interactive run leaves consent UNSET rather than
11
+ * implicitly declined - a scripted first run may be a CI smoke test, not a real user's choice,
12
+ * and it can still be asked on a later interactive run.
13
+ *
14
+ * ALI-954: an env var that already turns everything off (`DO_NOT_TRACK=1`, `ALIGN_TELEMETRY=0`)
15
+ * skips the question with a one-line note - asking would imply the answer matters - and
16
+ * leaves the decision unset for the same reason as the non-interactive case.
17
+ *
18
+ * The question is about USAGE (which commands you run). The two anonymous counts the docs
19
+ * describe (install, setup completed) are not what is being asked about here and send either
20
+ * way; `align telemetry off` is what stops those (docs/telemetry.md).
11
21
  *
12
22
  * Default is No: anything other than an explicit yes - the default answer, or Ctrl-C - leaves
13
23
  * telemetry off (D3).
@@ -17,6 +27,11 @@ export async function maybeRequestTelemetryConsent(config, interactive) {
17
27
  return;
18
28
  if (!interactive)
19
29
  return;
30
+ const envSwitch = telemetryDisabledByEnv();
31
+ if (envSwitch !== undefined) {
32
+ p.log.info(`Telemetry is off (${envSwitch} is set), so nothing is sent and you won't be asked.`);
33
+ return;
34
+ }
20
35
  const answer = await p.confirm({
21
36
  message: 'Help improve Align? Send an anonymous count of which commands you run - no code, no ' +
22
37
  'decisions, no file names, ever. You can change this any time with `align telemetry off`.',
@@ -1 +1 @@
1
- {"version":3,"file":"telemetry-consent.js","sourceRoot":"","sources":["../../src/lib/telemetry-consent.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,CAAC,MAAM,gBAAgB,CAAC;AAWpC;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,KAAK,UAAU,4BAA4B,CAChD,MAA6B,EAC7B,WAAoB;IAEpB,IAAI,MAAM,CAAC,mBAAmB,EAAE,KAAK,SAAS;QAAE,OAAO;IACvD,IAAI,CAAC,WAAW;QAAE,OAAO;IAEzB,MAAM,MAAM,GAAG,MAAM,CAAC,CAAC,OAAO,CAAC;QAC7B,OAAO,EACL,sFAAsF;YACtF,0FAA0F;QAC5F,YAAY,EAAE,KAAK;KACpB,CAAC,CAAC;IAEH,MAAM,CAAC,mBAAmB,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,MAAM,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC;AACrF,CAAC"}
1
+ {"version":3,"file":"telemetry-consent.js","sourceRoot":"","sources":["../../src/lib/telemetry-consent.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,CAAC,MAAM,gBAAgB,CAAC;AACpC,OAAO,EAAE,sBAAsB,EAAE,MAAM,oBAAoB,CAAC;AAW5D;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,CAAC,KAAK,UAAU,4BAA4B,CAChD,MAA6B,EAC7B,WAAoB;IAEpB,IAAI,MAAM,CAAC,mBAAmB,EAAE,KAAK,SAAS;QAAE,OAAO;IACvD,IAAI,CAAC,WAAW;QAAE,OAAO;IAEzB,MAAM,SAAS,GAAG,sBAAsB,EAAE,CAAC;IAC3C,IAAI,SAAS,KAAK,SAAS,EAAE,CAAC;QAC5B,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,qBAAqB,SAAS,sDAAsD,CAAC,CAAC;QACjG,OAAO;IACT,CAAC;IAED,MAAM,MAAM,GAAG,MAAM,CAAC,CAAC,OAAO,CAAC;QAC7B,OAAO,EACL,sFAAsF;YACtF,0FAA0F;QAC5F,YAAY,EAAE,KAAK;KACpB,CAAC,CAAC;IAEH,MAAM,CAAC,mBAAmB,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,MAAM,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC;AACrF,CAAC"}
@@ -0,0 +1,19 @@
1
+ /**
2
+ * ALI-954: the two environment variables that turn ALL telemetry off - both tiers, the
3
+ * default-on beacons included. Its own module so the consent prompt (telemetry-consent.ts)
4
+ * can ask the same question without importing the emitter, which setup.test.ts mocks down to
5
+ * one function.
6
+ *
7
+ * The two read in opposite directions, each erring toward sending nothing:
8
+ * - `ALIGN_TELEMETRY` (ours, ALI-618): set and not recognisably ON means OFF. Enumerating the
9
+ * falsy words instead guesses at what a user will type, and every miss is a live send by
10
+ * someone who believes they opted out.
11
+ * - `DO_NOT_TRACK` (consoledonottrack.com, the cross-tool convention): set and not
12
+ * recognisably OFF means "do not track". The convention says `1`; a user who exports `true`
13
+ * means the same thing, and `DO_NOT_TRACK=0` is the one spelling that means nothing.
14
+ * Both trimmed, because a trailing newline comes free from a `.env` file or a here-doc.
15
+ */
16
+ export type TelemetryEnvSwitch = 'DO_NOT_TRACK' | 'ALIGN_TELEMETRY';
17
+ /** The variable that disables telemetry, or undefined when neither does. */
18
+ export declare function telemetryDisabledByEnv(): TelemetryEnvSwitch | undefined;
19
+ //# sourceMappingURL=telemetry-env.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"telemetry-env.d.ts","sourceRoot":"","sources":["../../src/lib/telemetry-env.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,MAAM,MAAM,kBAAkB,GAAG,cAAc,GAAG,iBAAiB,CAAC;AAKpE,4EAA4E;AAC5E,wBAAgB,sBAAsB,IAAI,kBAAkB,GAAG,SAAS,CAMvE"}
@@ -0,0 +1,13 @@
1
+ const ON_VALUES = new Set(['1', 'true', 'yes', 'on']);
2
+ const OFF_VALUES = new Set(['0', 'false', 'no', 'off']);
3
+ /** The variable that disables telemetry, or undefined when neither does. */
4
+ export function telemetryDisabledByEnv() {
5
+ const dnt = process.env['DO_NOT_TRACK']?.trim().toLowerCase();
6
+ if (dnt !== undefined && dnt !== '' && !OFF_VALUES.has(dnt))
7
+ return 'DO_NOT_TRACK';
8
+ const ours = process.env['ALIGN_TELEMETRY']?.trim().toLowerCase();
9
+ if (ours !== undefined && ours !== '' && !ON_VALUES.has(ours))
10
+ return 'ALIGN_TELEMETRY';
11
+ return undefined;
12
+ }
13
+ //# sourceMappingURL=telemetry-env.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"telemetry-env.js","sourceRoot":"","sources":["../../src/lib/telemetry-env.ts"],"names":[],"mappings":"AAiBA,MAAM,SAAS,GAAG,IAAI,GAAG,CAAC,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,IAAI,CAAC,CAAC,CAAC;AACtD,MAAM,UAAU,GAAG,IAAI,GAAG,CAAC,CAAC,GAAG,EAAE,OAAO,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC,CAAC;AAExD,4EAA4E;AAC5E,MAAM,UAAU,sBAAsB;IACpC,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC,EAAE,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;IAC9D,IAAI,GAAG,KAAK,SAAS,IAAI,GAAG,KAAK,EAAE,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,GAAG,CAAC;QAAE,OAAO,cAAc,CAAC;IACnF,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,iBAAiB,CAAC,EAAE,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;IAClE,IAAI,IAAI,KAAK,SAAS,IAAI,IAAI,KAAK,EAAE,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,IAAI,CAAC;QAAE,OAAO,iBAAiB,CAAC;IACxF,OAAO,SAAS,CAAC;AACnB,CAAC"}
@@ -5,13 +5,20 @@ import { type EnvironmentConfig, type TelemetryConsent } from './config.js';
5
5
  *
6
6
  * Cloud mode is opt-out: a cloud user is already on an authenticated connection to our
7
7
  * gateway, so an event about a call already being made is not a new phone-home. Local-embedded
8
- * mode (ALI-618) is opt-in instead: `--local` users have no account and no tenant, so there is
9
- * nothing to authenticate an event against, and nothing is sent until the user explicitly
10
- * consents (see `config.ts`'s `getTelemetryConsent`, set by the one-time prompt in
11
- * `commands/setup.ts`). Both modes send only a command name, never arguments or content.
8
+ * mode has two tiers (ALI-954, superseding the ALI-618 "nothing by default" for local mode):
9
+ * - Two anonymous BEACONS send by default: `cli.funnel.install` (once, on the first ever run,
10
+ * see recordInstallBeacon) and `cli.funnel.setup_completed`. They exist so the funnel has a
11
+ * denominator - opt-in usage alone cannot tell a 10% consent rate from 90%.
12
+ * - Everything else (`cli.command`, the other funnel stages) sends only with the stored
13
+ * consent (`config.ts`'s `getTelemetryConsent`, set by the one-time prompt at the end of
14
+ * setup, default No). `--local` users have no account and no tenant, so there is nothing
15
+ * to authenticate an event against - the consent is the gate.
16
+ * Both modes send only a command name, never arguments or content. docs/telemetry.md lists
17
+ * every event and field, and telemetry-docs-parity.test.ts keeps that page true.
12
18
  *
13
- * `ALIGN_TELEMETRY=0` (or `false` / `no` / `off`) is the single global off switch and wins in
14
- * both modes, over a granted local consent included (ALI-618 D3b - one consent model, not two).
19
+ * `ALIGN_TELEMETRY=0` and `DO_NOT_TRACK=1` (telemetry-env.ts) turn everything off, both tiers,
20
+ * in both modes, over a granted local consent included (ALI-618 D3b - one consent model, not
21
+ * two). `align telemetry off` stores 'off', which does the same thing without an env var.
15
22
  */
16
23
  export declare const TELEMETRY_TIMEOUT_MS = 2000;
17
24
  export interface TelemetryStatus {
@@ -22,8 +29,9 @@ export interface TelemetryStatus {
22
29
  * ALI-618 D3b: what `align telemetry status` prints. Takes the consent decision as a plain
23
30
  * argument rather than reading `config.ts` itself, so the two consent MODELS stay visibly
24
31
  * distinct in one function a reader can hold in their head - cloud's opt-out default and
25
- * local's stored opt-in decision - with `ALIGN_TELEMETRY=0` as the one thing that overrides
26
- * both, checked first.
32
+ * local's stored decision (ALI-954: three values, and the two default-on beacons named where
33
+ * usage is off but they are not) - with the env switches as the thing that overrides both,
34
+ * checked first.
27
35
  */
28
36
  export declare function getTelemetryStatus(env: EnvironmentConfig, localConsent: TelemetryConsent | undefined): TelemetryStatus;
29
37
  export declare function recordCommandUsage(env: EnvironmentConfig, command: string): Promise<void>;
@@ -31,20 +39,107 @@ export declare function recordCommandUsage(env: EnvironmentConfig, command: stri
31
39
  * ALI-795: the activation-funnel stages, matching the gateway's closed enum
32
40
  * (telemetryAnonymousRoutes.ts). Repeat-use and D7 are deliberately absent - both derive
33
41
  * server-side from install_id timestamps, so a client event would be a second writer.
42
+ *
43
+ * ALI-938: `teammate_requested` is the bottom-up-thesis signal - fired by `align invite`
44
+ * for every genuine attempt to bring a teammate in, whether or not the invite is
45
+ * actually sent (a member blocked by the org_admin gate is still demand). `align invite`
46
+ * refuses local-embedded mode outright (it needs a real cloud tenant to invite anyone
47
+ * into), so in practice this ALWAYS reaches recordFunnelStage with a cloud env and takes
48
+ * the /telemetry/ingest path, which accepts any eventName as a string. The gateway's
49
+ * FUNNEL_STAGES enum lists it too since ALI-954, so the local path no longer 400s either.
50
+ *
51
+ * ALI-954: `install` is the once-per-install beacon and has its own emitter
52
+ * (recordInstallBeacon) - it is not offered to recordFunnelStage, whose `command` argument
53
+ * is a provenance the install beacon must not carry.
54
+ */
55
+ /**
56
+ * ALI-835: the stages the gateway ACCEPTS a measurement on. Permission, not obligation - three
57
+ * of the four come from one `align import sessions` run and carry a count and an agent name,
58
+ * while `decisions_ratified` comes from `align ratify` and deliberately carries neither, since
59
+ * a ratification is a person standing behind a claim rather than anything an agent counted.
60
+ *
61
+ * Session import is the first funnel event whose value IS a number; every earlier stage only
62
+ * had to happen.
63
+ *
64
+ * Declared above FUNNEL_STAGES and spread into it rather than listed in both, so the four
65
+ * strings have one writer here as they now do on the gateway side (align-stack #2237).
34
66
  */
35
- export type FunnelStage = 'setup_started' | 'setup_completed' | 'import_completed' | 'mcp_wired' | 'first_useful_decision';
67
+ export declare const SESSION_IMPORT_STAGES: readonly ["sessions_scanned", "candidates_found", "candidates_confirmed", "decisions_ratified"];
68
+ export declare const FUNNEL_STAGES: readonly ["setup_started", "setup_completed", "import_completed", "mcp_wired", "first_useful_decision", "teammate_requested", "sessions_scanned", "candidates_found", "candidates_confirmed", "decisions_ratified"];
69
+ /**
70
+ * What a session-import stage reports. A count and the agent's name, and nothing else - never a
71
+ * repo, a path, a session id or a line of any transcript. The gateway refuses both fields on
72
+ * every other stage.
73
+ */
74
+ export interface FunnelMeasurement {
75
+ count: number;
76
+ agent: string;
77
+ }
78
+ export type FunnelStage = (typeof FUNNEL_STAGES)[number];
79
+ /**
80
+ * ALI-954: the two stages that send BY DEFAULT in local mode, with no consent - the
81
+ * denominator of the funnel. `install` goes through recordInstallBeacon; `setup_completed`
82
+ * goes through recordFunnelStage like every other stage and is the one stage there that
83
+ * does not read the consent decision. Adding a stage here is a documented promise changing:
84
+ * docs/telemetry.md lists this set, and telemetry-docs-parity.test.ts checks it.
85
+ */
86
+ export declare const BEACON_STAGES: readonly ["install", "setup_completed"];
36
87
  /**
37
88
  * The single funnel-stage emitter. Same consent model as recordCommandUsage, no new
38
- * consent surface: cloud is opt-out behind a token, local is opt-in behind the stored
39
- * decision, ALIGN_TELEMETRY=0 beats both. `command` is the stage's provenance
40
- * ("first_useful_decision via ask"), never arguments or content.
89
+ * consent surface: cloud is opt-out behind a token, local is behind the stored decision
90
+ * except for the beacon tier (BEACON_STAGES), and the env switches beat everything.
91
+ * `command` is the stage's provenance ("first_useful_decision via ask"), never arguments
92
+ * or content.
41
93
  *
42
94
  * first_useful_decision is once-per-install, guarded HERE rather than at call sites so
43
95
  * there is exactly one enforcement point. Marked before the send, deliberately: a ping
44
96
  * lost to a timeout costs one funnel row in the undercount direction, while marking
45
97
  * after would re-send forever on a machine that cannot reach the gateway.
98
+ *
99
+ * Resolves `true` when a send was made (not necessarily delivered - see the once-mark
100
+ * reasoning above, the same trade), `false` when this call could not send: opted out, no
101
+ * consent, no token, the store threw, or - for first_useful_decision only, the one
102
+ * once-per-install stage - already recorded on this install. ALI-949: the setup wizard
103
+ * offers setup_started at several checkpoints because local-mode consent arrives
104
+ * mid-wizard, and this is how it knows which offer landed (lib/setup-funnel.ts).
105
+ */
106
+ export declare function recordFunnelStage(env: EnvironmentConfig, stage: FunnelStage, command: string, measurement?: FunnelMeasurement): Promise<boolean>;
107
+ /**
108
+ * ALI-954: the install beacon - the funnel's denominator. Sent from the preAction hook in
109
+ * index.ts, so it goes out BEFORE any prompt on the very first run of this install id, and
110
+ * never again. The payload is the CLI version, the OS (`process.platform`, a closed enum the
111
+ * gateway also constrains), the install id, and the literal command `align` - the endpoint
112
+ * requires a command and this one must not carry which command was run first, so it sends
113
+ * the program's own name, which carries no information. Nothing about the repo, the graph,
114
+ * or the user.
115
+ *
116
+ * "First ever run" is a moment, not a state, so the install is marked recorded on that run
117
+ * whether or not the beacon went out: `DO_NOT_TRACK=1` or `ALIGN_TELEMETRY=0` set before the
118
+ * first run means it is NEVER sent, by construction, even if the variable is later unset.
119
+ * That is the opposite trade from first_useful_decision's once-mark (which waits for
120
+ * sendability so a later opt-in still emits), and it is deliberate: a beacon on the fifth
121
+ * run would not be the install it claims to be, and "never" is the promise the docs make.
122
+ *
123
+ * Two cases do not consume the first run: `align telemetry ...` as the user's first command
124
+ * (the off switch must not be raced by the thing it switches off), and a run that already
125
+ * holds a cloud token (cloud mode is unchanged by this ticket - its funnel is the authed
126
+ * one). Resolves true when a send was made, false otherwise; never throws, like every other
127
+ * emitter here.
128
+ */
129
+ export declare function recordInstallBeacon(commandPath: string): Promise<boolean>;
130
+ /**
131
+ * The command path the postAction hook reports: the full path ("local ask", "import git")
132
+ * so recordCommandUsage can exclude the offline `local` group, and never the leaf alone.
133
+ *
134
+ * ALI-949: the ROOT command has no parent. Bare `align` (`program.action(runDefaultAction)`,
135
+ * ALI-773) is the primary first-run path, and walking `.parent` from the root yielded '' -
136
+ * so every bare invocation was reported as no command at all and the funnel's "activated"
137
+ * stage could not see it. The root reports under the program's own name.
46
138
  */
47
- export declare function recordFunnelStage(env: EnvironmentConfig, stage: FunnelStage, command: string): Promise<void>;
139
+ export declare function invocationCommandPath(actionCommand: {
140
+ name(): string;
141
+ parent: unknown;
142
+ }): string;
48
143
  /**
49
144
  * The `--env` the user actually typed, including one Commander handed to a parent.
50
145
  *
@@ -1 +1 @@
1
- {"version":3,"file":"usage-telemetry.d.ts","sourceRoot":"","sources":["../../src/lib/usage-telemetry.ts"],"names":[],"mappings":"AAAA,OAAO,EAA4B,KAAK,iBAAiB,EAAE,KAAK,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAGtG;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,oBAAoB,OAAQ,CAAC;AA6C1C,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAChC,GAAG,EAAE,iBAAiB,EACtB,YAAY,EAAE,gBAAgB,GAAG,SAAS,GACzC,eAAe,CAcjB;AAED,wBAAsB,kBAAkB,CAAC,GAAG,EAAE,iBAAiB,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CA8B/F;AAgDD;;;;GAIG;AACH,MAAM,MAAM,WAAW,GACnB,eAAe,GACf,iBAAiB,GACjB,kBAAkB,GAClB,WAAW,GACX,uBAAuB,CAAC;AAE5B;;;;;;;;;;GAUG;AACH,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,iBAAiB,EACtB,KAAK,EAAE,WAAW,EAClB,OAAO,EAAE,MAAM,GACd,OAAO,CAAC,IAAI,CAAC,CA4Df;AAED;;;;;;;GAOG;AACH,wBAAgB,SAAS,CAAC,GAAG,EAAE;IAAE,eAAe,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,GAAG,MAAM,GAAG,SAAS,CASjG;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAsB,qBAAqB,CACzC,OAAO,EAAE,MAAM,GAAG,SAAS,EAC3B,OAAO,EAAE,MAAM,GACd,OAAO,CAAC,IAAI,CAAC,CAMf"}
1
+ {"version":3,"file":"usage-telemetry.d.ts","sourceRoot":"","sources":["../../src/lib/usage-telemetry.ts"],"names":[],"mappings":"AAAA,OAAO,EAA4B,KAAK,iBAAiB,EAAE,KAAK,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAKtG;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,oBAAoB,OAAQ,CAAC;AAkC1C,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAChC,GAAG,EAAE,iBAAiB,EACtB,YAAY,EAAE,gBAAgB,GAAG,SAAS,GACzC,eAAe,CAyBjB;AAED,wBAAsB,kBAAkB,CAAC,GAAG,EAAE,iBAAiB,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CA+B/F;AAwCD;;;;;;;;;;;;;;;;GAgBG;AACH;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,qBAAqB,iGAExB,CAAC;AAEX,eAAO,MAAM,aAAa,qNAUhB,CAAC;AAEX;;;;GAIG;AACH,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;CACf;AACD,MAAM,MAAM,WAAW,GAAG,CAAC,OAAO,aAAa,CAAC,CAAC,MAAM,CAAC,CAAC;AAEzD;;;;;;GAMG;AACH,eAAO,MAAM,aAAa,yCAA0C,CAAC;AAErE;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,iBAAiB,EACtB,KAAK,EAAE,WAAW,EAClB,OAAO,EAAE,MAAM,EACf,WAAW,CAAC,EAAE,iBAAiB,GAC9B,OAAO,CAAC,OAAO,CAAC,CA8ElB;AA2BD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAsB,mBAAmB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAyB/E;AAED;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CAAC,aAAa,EAAE;IAAE,IAAI,IAAI,MAAM,CAAC;IAAC,MAAM,EAAE,OAAO,CAAA;CAAE,GAAG,MAAM,CAKhG;AAED;;;;;;;GAOG;AACH,wBAAgB,SAAS,CAAC,GAAG,EAAE;IAAE,eAAe,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,GAAG,MAAM,GAAG,SAAS,CASjG;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAsB,qBAAqB,CACzC,OAAO,EAAE,MAAM,GAAG,SAAS,EAC3B,OAAO,EAAE,MAAM,GACd,OAAO,CAAC,IAAI,CAAC,CAMf"}
@@ -1,4 +1,6 @@
1
1
  import { ALIGN_HOSTED_GATEWAY_URL } from './config.js';
2
+ import { telemetryDisabledByEnv } from './telemetry-env.js';
3
+ import { inHookContext } from './hook-context.js';
2
4
  import pkg from '../../package.json' with { type: 'json' };
3
5
  /**
4
6
  * ALI-403: emit one `cli.command` event per invocation so cloud CLI activation and weekly
@@ -6,28 +8,24 @@ import pkg from '../../package.json' with { type: 'json' };
6
8
  *
7
9
  * Cloud mode is opt-out: a cloud user is already on an authenticated connection to our
8
10
  * gateway, so an event about a call already being made is not a new phone-home. Local-embedded
9
- * mode (ALI-618) is opt-in instead: `--local` users have no account and no tenant, so there is
10
- * nothing to authenticate an event against, and nothing is sent until the user explicitly
11
- * consents (see `config.ts`'s `getTelemetryConsent`, set by the one-time prompt in
12
- * `commands/setup.ts`). Both modes send only a command name, never arguments or content.
11
+ * mode has two tiers (ALI-954, superseding the ALI-618 "nothing by default" for local mode):
12
+ * - Two anonymous BEACONS send by default: `cli.funnel.install` (once, on the first ever run,
13
+ * see recordInstallBeacon) and `cli.funnel.setup_completed`. They exist so the funnel has a
14
+ * denominator - opt-in usage alone cannot tell a 10% consent rate from 90%.
15
+ * - Everything else (`cli.command`, the other funnel stages) sends only with the stored
16
+ * consent (`config.ts`'s `getTelemetryConsent`, set by the one-time prompt at the end of
17
+ * setup, default No). `--local` users have no account and no tenant, so there is nothing
18
+ * to authenticate an event against - the consent is the gate.
19
+ * Both modes send only a command name, never arguments or content. docs/telemetry.md lists
20
+ * every event and field, and telemetry-docs-parity.test.ts keeps that page true.
13
21
  *
14
- * `ALIGN_TELEMETRY=0` (or `false` / `no` / `off`) is the single global off switch and wins in
15
- * both modes, over a granted local consent included (ALI-618 D3b - one consent model, not two).
22
+ * `ALIGN_TELEMETRY=0` and `DO_NOT_TRACK=1` (telemetry-env.ts) turn everything off, both tiers,
23
+ * in both modes, over a granted local consent included (ALI-618 D3b - one consent model, not
24
+ * two). `align telemetry off` stores 'off', which does the same thing without an env var.
16
25
  */
17
26
  export const TELEMETRY_TIMEOUT_MS = 2_000;
18
- /**
19
- * Set and not recognisably ON means OFF. Enumerating the falsy words instead - `0|false|no|off` -
20
- * guesses at what a user will type, and every guess that misses is a live send by someone who
21
- * believes they opted out: `disabled` and `n` both sent. Trimmed, because a trailing space or
22
- * newline comes free from a `.env` file or a here-doc. Unset is ON, which is the documented
23
- * cloud default and the only value this cannot see.
24
- */
25
- const OPT_IN_VALUES = new Set(['1', 'true', 'yes', 'on']);
26
27
  function telemetryOptedOut() {
27
- const raw = process.env['ALIGN_TELEMETRY'];
28
- if (raw === undefined || raw.trim() === '')
29
- return false;
30
- return !OPT_IN_VALUES.has(raw.trim().toLowerCase());
28
+ return telemetryDisabledByEnv() !== undefined;
31
29
  }
32
30
  /**
33
31
  * POST with a hard timeout, and never throw - telemetry must never fail or delay a command. A
@@ -61,21 +59,33 @@ async function postWithTimeout(url, init) {
61
59
  * ALI-618 D3b: what `align telemetry status` prints. Takes the consent decision as a plain
62
60
  * argument rather than reading `config.ts` itself, so the two consent MODELS stay visibly
63
61
  * distinct in one function a reader can hold in their head - cloud's opt-out default and
64
- * local's stored opt-in decision - with `ALIGN_TELEMETRY=0` as the one thing that overrides
65
- * both, checked first.
62
+ * local's stored decision (ALI-954: three values, and the two default-on beacons named where
63
+ * usage is off but they are not) - with the env switches as the thing that overrides both,
64
+ * checked first.
66
65
  */
67
66
  export function getTelemetryStatus(env, localConsent) {
68
- if (telemetryOptedOut()) {
69
- return { enabled: false, reason: 'off: ALIGN_TELEMETRY is set to an opt-out value' };
67
+ const envSwitch = telemetryDisabledByEnv();
68
+ if (envSwitch === 'DO_NOT_TRACK') {
69
+ return { enabled: false, reason: 'off: DO_NOT_TRACK is set - nothing is sent' };
70
+ }
71
+ if (envSwitch === 'ALIGN_TELEMETRY') {
72
+ return { enabled: false, reason: 'off: ALIGN_TELEMETRY is set to an opt-out value - nothing is sent' };
70
73
  }
71
74
  if (env.mode === 'local-embedded') {
75
+ // ALI-954: "off" here is about usage. The two beacons still send unless the user ran
76
+ // `align telemetry off` (stored 'off') - and the line has to say so, or "off" would be
77
+ // read as "nothing is sent" over a beacon that still is.
78
+ const beacons = ' - the two anonymous counts (install, setup completed) still send; `align telemetry off` stops those too';
72
79
  if (localConsent === 'granted') {
73
80
  return { enabled: true, reason: 'on: local mode, you opted in when asked' };
74
81
  }
75
82
  if (localConsent === 'declined') {
76
- return { enabled: false, reason: 'off: local mode, you declined when asked' };
83
+ return { enabled: false, reason: `off: local mode, you declined when asked${beacons}` };
77
84
  }
78
- return { enabled: false, reason: 'off: local mode, you have not been asked yet' };
85
+ if (localConsent === 'off') {
86
+ return { enabled: false, reason: 'off: local mode, you ran `align telemetry off` - nothing is sent' };
87
+ }
88
+ return { enabled: false, reason: `off: local mode, you have not been asked yet${beacons}` };
79
89
  }
80
90
  return { enabled: true, reason: 'on: cloud mode, opt-out default' };
81
91
  }
@@ -87,9 +97,10 @@ export async function recordCommandUsage(env, command) {
87
97
  // is not enough on its own.
88
98
  if (command === 'local' || command.startsWith('local '))
89
99
  return;
90
- // The mode is the consent boundary (PR #77: cloud is opt-out; ALI-618: local is opt-in via a
91
- // stored consent decision, never a phone-home by default). It has to gate on its own because
92
- // a token can be in scope without cloud consent: ALIGN_TOKEN exported into the shell, or a
100
+ // The mode is the consent boundary (PR #77: cloud is opt-out; ALI-618: local usage sends
101
+ // only with the stored consent decision - the ALI-954 beacons are the two named exceptions,
102
+ // and this per-command ping is not one of them). It has to gate on its own because a token
103
+ // can be in scope without cloud consent: ALIGN_TOKEN exported into the shell, or a
93
104
  // logged-in default env resolved by the caller.
94
105
  if (env.mode === 'local-embedded') {
95
106
  await recordAnonymousCommandUsage(command);
@@ -131,14 +142,7 @@ async function recordAnonymousCommandUsage(command) {
131
142
  const config = createConfigStore();
132
143
  if (config.getTelemetryConsent() !== 'granted')
133
144
  return;
134
- const installId = config.getInstallId();
135
- const commandPath = commandPathOf(command);
136
- const target = process.env['ALIGN_GATEWAY_URL'] || ALIGN_HOSTED_GATEWAY_URL;
137
- await postWithTimeout(`${target}/telemetry/anonymous`, {
138
- method: 'POST',
139
- headers: { 'Content-Type': 'application/json' },
140
- body: JSON.stringify({ installId, command: commandPath, cliVersion: pkg.version }),
141
- });
145
+ await postAnonymous({ installId: config.getInstallId(), command: commandPathOf(command), cliVersion: pkg.version });
142
146
  }
143
147
  /**
144
148
  * ALI-795: the command groups whose second word is a SUBCOMMAND, mirroring the gateway's
@@ -155,18 +159,77 @@ function commandPathOf(command) {
155
159
  return `${top} ${sub}`;
156
160
  return top ?? command;
157
161
  }
162
+ /**
163
+ * ALI-795: the activation-funnel stages, matching the gateway's closed enum
164
+ * (telemetryAnonymousRoutes.ts). Repeat-use and D7 are deliberately absent - both derive
165
+ * server-side from install_id timestamps, so a client event would be a second writer.
166
+ *
167
+ * ALI-938: `teammate_requested` is the bottom-up-thesis signal - fired by `align invite`
168
+ * for every genuine attempt to bring a teammate in, whether or not the invite is
169
+ * actually sent (a member blocked by the org_admin gate is still demand). `align invite`
170
+ * refuses local-embedded mode outright (it needs a real cloud tenant to invite anyone
171
+ * into), so in practice this ALWAYS reaches recordFunnelStage with a cloud env and takes
172
+ * the /telemetry/ingest path, which accepts any eventName as a string. The gateway's
173
+ * FUNNEL_STAGES enum lists it too since ALI-954, so the local path no longer 400s either.
174
+ *
175
+ * ALI-954: `install` is the once-per-install beacon and has its own emitter
176
+ * (recordInstallBeacon) - it is not offered to recordFunnelStage, whose `command` argument
177
+ * is a provenance the install beacon must not carry.
178
+ */
179
+ /**
180
+ * ALI-835: the stages the gateway ACCEPTS a measurement on. Permission, not obligation - three
181
+ * of the four come from one `align import sessions` run and carry a count and an agent name,
182
+ * while `decisions_ratified` comes from `align ratify` and deliberately carries neither, since
183
+ * a ratification is a person standing behind a claim rather than anything an agent counted.
184
+ *
185
+ * Session import is the first funnel event whose value IS a number; every earlier stage only
186
+ * had to happen.
187
+ *
188
+ * Declared above FUNNEL_STAGES and spread into it rather than listed in both, so the four
189
+ * strings have one writer here as they now do on the gateway side (align-stack #2237).
190
+ */
191
+ export const SESSION_IMPORT_STAGES = [
192
+ 'sessions_scanned', 'candidates_found', 'candidates_confirmed', 'decisions_ratified',
193
+ ];
194
+ export const FUNNEL_STAGES = [
195
+ 'setup_started',
196
+ 'setup_completed',
197
+ 'import_completed',
198
+ 'mcp_wired',
199
+ 'first_useful_decision',
200
+ 'teammate_requested',
201
+ // The gateway's own FUNNEL_STAGES must list these too or every ping 400s; that half
202
+ // shipped first, deliberately (align-stack #2237, the ALI-790 lesson).
203
+ ...SESSION_IMPORT_STAGES,
204
+ ];
205
+ /**
206
+ * ALI-954: the two stages that send BY DEFAULT in local mode, with no consent - the
207
+ * denominator of the funnel. `install` goes through recordInstallBeacon; `setup_completed`
208
+ * goes through recordFunnelStage like every other stage and is the one stage there that
209
+ * does not read the consent decision. Adding a stage here is a documented promise changing:
210
+ * docs/telemetry.md lists this set, and telemetry-docs-parity.test.ts checks it.
211
+ */
212
+ export const BEACON_STAGES = ['install', 'setup_completed'];
158
213
  /**
159
214
  * The single funnel-stage emitter. Same consent model as recordCommandUsage, no new
160
- * consent surface: cloud is opt-out behind a token, local is opt-in behind the stored
161
- * decision, ALIGN_TELEMETRY=0 beats both. `command` is the stage's provenance
162
- * ("first_useful_decision via ask"), never arguments or content.
215
+ * consent surface: cloud is opt-out behind a token, local is behind the stored decision
216
+ * except for the beacon tier (BEACON_STAGES), and the env switches beat everything.
217
+ * `command` is the stage's provenance ("first_useful_decision via ask"), never arguments
218
+ * or content.
163
219
  *
164
220
  * first_useful_decision is once-per-install, guarded HERE rather than at call sites so
165
221
  * there is exactly one enforcement point. Marked before the send, deliberately: a ping
166
222
  * lost to a timeout costs one funnel row in the undercount direction, while marking
167
223
  * after would re-send forever on a machine that cannot reach the gateway.
224
+ *
225
+ * Resolves `true` when a send was made (not necessarily delivered - see the once-mark
226
+ * reasoning above, the same trade), `false` when this call could not send: opted out, no
227
+ * consent, no token, the store threw, or - for first_useful_decision only, the one
228
+ * once-per-install stage - already recorded on this install. ALI-949: the setup wizard
229
+ * offers setup_started at several checkpoints because local-mode consent arrives
230
+ * mid-wizard, and this is how it knows which offer landed (lib/setup-funnel.ts).
168
231
  */
169
- export async function recordFunnelStage(env, stage, command) {
232
+ export async function recordFunnelStage(env, stage, command, measurement) {
170
233
  // The whole body is guarded: telemetry must never fail or delay a command (the same
171
234
  // invariant postWithTimeout enforces for the network half, extended to the config
172
235
  // half). The concrete case: an emitter call site inside a command's try block plus a
@@ -174,11 +237,18 @@ export async function recordFunnelStage(env, stage, command) {
174
237
  // in a file this change never touched said so (tdd.md's hand-built-fake rule).
175
238
  try {
176
239
  if (telemetryOptedOut())
177
- return;
240
+ return false;
241
+ // ALI-835: never from inside an agent hook. A hook runs on the agent's clock, many times a
242
+ // session, with nobody watching - so a ping from there would measure an editing loop rather
243
+ // than a person choosing to do something, which is what every funnel stage means. Guarded
244
+ // HERE for the same reason first_useful_decision's once-check is: one enforcement point
245
+ // rather than one per call site.
246
+ if (inHookContext())
247
+ return false;
178
248
  const { createConfigStore } = await import('./config.js');
179
249
  const config = createConfigStore();
180
250
  if (stage === 'first_useful_decision' && config.wasFunnelStageRecorded(stage))
181
- return;
251
+ return false;
182
252
  // Whether this call CAN send, checked before the once-mark (Copilot on #215):
183
253
  // marking an unsendable call permanently burned the stage for exactly the opt-in
184
254
  // cohort - a not-yet-consented user's first real answer marked the install, and
@@ -187,26 +257,34 @@ export async function recordFunnelStage(env, stage, command) {
187
257
  // re-sending forever.
188
258
  const isLocal = env.mode === 'local-embedded';
189
259
  const canSend = isLocal
190
- ? config.getTelemetryConsent() === 'granted'
260
+ ? localTierAllows(config.getTelemetryConsent(), stage)
191
261
  : Boolean(env.authToken && env.tenantId);
192
262
  if (!canSend)
193
- return;
263
+ return false;
194
264
  if (stage === 'first_useful_decision')
195
265
  config.markFunnelStageRecorded(stage);
196
266
  const commandPath = commandPathOf(command);
267
+ // ALI-835 (Copilot on #286): the measurement is admitted only on the stages the gateway
268
+ // permits it on. Gated HERE rather than at the two payload sites below, for the same
269
+ // reason first_useful_decision's once-check is: one enforcement point. The gateway's
270
+ // schema is `.strict()` with a superRefine refusing `count` and `agent` on every other
271
+ // stage, so a future call site passing a measurement alongside, say, `mcp_wired` would
272
+ // 400 the whole ping - and the catch below swallows that, so the stage would go silently
273
+ // missing rather than fail loudly. Dropping the two fields keeps the stage itself.
274
+ const measured = measurement && SESSION_IMPORT_STAGES.includes(stage)
275
+ ? { count: measurement.count, agent: measurement.agent }
276
+ : {};
197
277
  if (isLocal) {
198
- const target = process.env['ALIGN_GATEWAY_URL'] || ALIGN_HOSTED_GATEWAY_URL;
199
- await postWithTimeout(`${target}/telemetry/anonymous`, {
200
- method: 'POST',
201
- headers: { 'Content-Type': 'application/json' },
202
- body: JSON.stringify({
203
- installId: config.getInstallId(),
204
- command: commandPath,
205
- cliVersion: pkg.version,
206
- stage,
207
- }),
278
+ await postAnonymous({
279
+ installId: config.getInstallId(),
280
+ command: commandPath,
281
+ cliVersion: pkg.version,
282
+ stage,
283
+ // Spread rather than set: the gateway's schema is `.strict()`, and an explicit
284
+ // undefined key is still a key.
285
+ ...measured,
208
286
  });
209
- return;
287
+ return true;
210
288
  }
211
289
  await postWithTimeout(`${env.gatewayUrl}/telemetry/ingest`, {
212
290
  method: 'POST',
@@ -219,14 +297,108 @@ export async function recordFunnelStage(env, stage, command) {
219
297
  eventName: `cli.funnel.${stage}`,
220
298
  category: 'engagement',
221
299
  platform: 'cli',
222
- properties: { command: commandPath },
300
+ properties: { command: commandPath, ...measured },
223
301
  }),
224
302
  });
303
+ return true;
225
304
  }
226
305
  catch {
227
306
  // Swallowed for the reason above. The funnel loses one row; the command survives.
307
+ return false;
228
308
  }
229
309
  }
310
+ /**
311
+ * ALI-954: whether a local-mode stage may send, given the stored decision. A beacon stage
312
+ * sends unless the user ran `align telemetry off` (stored 'off'); every other stage needs
313
+ * a granted consent. A prompt-declined consent ('declined') is a decision about USAGE and
314
+ * leaves the beacons on - that split is the ticket's decision, and docs/telemetry.md says
315
+ * it in the user's words.
316
+ */
317
+ function localTierAllows(consent, stage) {
318
+ if (BEACON_STAGES.includes(stage))
319
+ return consent !== 'off';
320
+ return consent === 'granted';
321
+ }
322
+ /**
323
+ * The anonymous payload, the one place its shape is spelled. Targets ALIGN_HOSTED_GATEWAY_URL,
324
+ * never a `gatewayUrl` off the env - see recordAnonymousCommandUsage for why.
325
+ */
326
+ async function postAnonymous(payload) {
327
+ const target = process.env['ALIGN_GATEWAY_URL'] || ALIGN_HOSTED_GATEWAY_URL;
328
+ await postWithTimeout(`${target}/telemetry/anonymous`, {
329
+ method: 'POST',
330
+ headers: { 'Content-Type': 'application/json' },
331
+ body: JSON.stringify(payload),
332
+ });
333
+ }
334
+ /**
335
+ * ALI-954: the install beacon - the funnel's denominator. Sent from the preAction hook in
336
+ * index.ts, so it goes out BEFORE any prompt on the very first run of this install id, and
337
+ * never again. The payload is the CLI version, the OS (`process.platform`, a closed enum the
338
+ * gateway also constrains), the install id, and the literal command `align` - the endpoint
339
+ * requires a command and this one must not carry which command was run first, so it sends
340
+ * the program's own name, which carries no information. Nothing about the repo, the graph,
341
+ * or the user.
342
+ *
343
+ * "First ever run" is a moment, not a state, so the install is marked recorded on that run
344
+ * whether or not the beacon went out: `DO_NOT_TRACK=1` or `ALIGN_TELEMETRY=0` set before the
345
+ * first run means it is NEVER sent, by construction, even if the variable is later unset.
346
+ * That is the opposite trade from first_useful_decision's once-mark (which waits for
347
+ * sendability so a later opt-in still emits), and it is deliberate: a beacon on the fifth
348
+ * run would not be the install it claims to be, and "never" is the promise the docs make.
349
+ *
350
+ * Two cases do not consume the first run: `align telemetry ...` as the user's first command
351
+ * (the off switch must not be raced by the thing it switches off), and a run that already
352
+ * holds a cloud token (cloud mode is unchanged by this ticket - its funnel is the authed
353
+ * one). Resolves true when a send was made, false otherwise; never throws, like every other
354
+ * emitter here.
355
+ */
356
+ export async function recordInstallBeacon(commandPath) {
357
+ try {
358
+ if (commandPath === 'telemetry' || commandPath.startsWith('telemetry '))
359
+ return false;
360
+ const { createConfigStore } = await import('./config.js');
361
+ const { resolveEnv } = await import('./resolve-env.js');
362
+ const config = createConfigStore();
363
+ if (config.wasFunnelStageRecorded('install'))
364
+ return false;
365
+ const env = config.getEnvironment(resolveEnv(undefined, { preferLocalEmbedded: true }));
366
+ if (env.authToken)
367
+ return false;
368
+ config.markFunnelStageRecorded('install');
369
+ if (telemetryOptedOut())
370
+ return false;
371
+ if (!localTierAllows(config.getTelemetryConsent(), 'install'))
372
+ return false;
373
+ await postAnonymous({
374
+ installId: config.getInstallId(),
375
+ command: 'align',
376
+ cliVersion: pkg.version,
377
+ stage: 'install',
378
+ os: process.platform,
379
+ });
380
+ return true;
381
+ }
382
+ catch {
383
+ // Telemetry must never fail or delay a command; the funnel loses one row.
384
+ return false;
385
+ }
386
+ }
387
+ /**
388
+ * The command path the postAction hook reports: the full path ("local ask", "import git")
389
+ * so recordCommandUsage can exclude the offline `local` group, and never the leaf alone.
390
+ *
391
+ * ALI-949: the ROOT command has no parent. Bare `align` (`program.action(runDefaultAction)`,
392
+ * ALI-773) is the primary first-run path, and walking `.parent` from the root yielded '' -
393
+ * so every bare invocation was reported as no command at all and the funnel's "activated"
394
+ * stage could not see it. The root reports under the program's own name.
395
+ */
396
+ export function invocationCommandPath(actionCommand) {
397
+ const parts = [];
398
+ for (let c = actionCommand; c?.parent; c = c.parent)
399
+ parts.unshift(c.name());
400
+ return parts.length > 0 ? parts.join(' ') : actionCommand.name();
401
+ }
230
402
  /**
231
403
  * The `--env` the user actually typed, including one Commander handed to a parent.
232
404
  *