@indigoai-us/hq-cli 5.274.1 → 5.276.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +88 -4
  2. package/README.md +12 -0
  3. package/assets/bot-workers/setup/context/USER-GUIDE.md +4 -4
  4. package/assets/scaffold/core/scripts/checkpoint-stop-gate.sh +74 -18
  5. package/dist/command-catalog.generated.d.ts +34 -1
  6. package/dist/command-catalog.generated.js +43 -1
  7. package/dist/commands/cloud.js +22 -9
  8. package/dist/commands/company.js +66 -3
  9. package/dist/commands/core-checkpoint-gate-setting.d.ts +69 -0
  10. package/dist/commands/core-checkpoint-gate-setting.js +163 -0
  11. package/dist/commands/core-checkpoint.d.ts +4 -2
  12. package/dist/commands/core-checkpoint.js +34 -3
  13. package/dist/commands/core-command-catalog.d.ts +4 -2
  14. package/dist/commands/core-command-catalog.js +5 -2
  15. package/dist/commands/core-ontology.js +3 -3
  16. package/dist/commands/core.js +3 -1
  17. package/dist/commands/daemon.d.ts +1 -0
  18. package/dist/commands/daemon.js +9 -3
  19. package/dist/commands/feedback.d.ts +6 -0
  20. package/dist/commands/feedback.js +86 -37
  21. package/dist/commands/lanes.js +33 -11
  22. package/dist/commands/run-feature-telemetry.d.ts +3 -0
  23. package/dist/commands/run-feature-telemetry.js +6 -0
  24. package/dist/commands/run.js +3 -2
  25. package/dist/commands/secrets.js +7 -1
  26. package/dist/commands/whoami.d.ts +4 -6
  27. package/dist/commands/whoami.js +39 -8
  28. package/dist/fast-core.d.ts +6 -7
  29. package/dist/fast-core.js +22 -8
  30. package/dist/fast-scaffold.d.ts +16 -7
  31. package/dist/fast-scaffold.js +13 -8
  32. package/dist/index.js +16 -1
  33. package/dist/lib/billing/plan-lock.d.ts +3 -1
  34. package/dist/lib/billing/plan-lock.js +7 -1
  35. package/dist/lib/checkpoint-ontology/company.d.ts +8 -4
  36. package/dist/lib/checkpoint-ontology/company.js +15 -7
  37. package/dist/lib/checkpoint-ontology/plan.d.ts +23 -0
  38. package/dist/lib/checkpoint-ontology/plan.js +53 -4
  39. package/dist/lib/checkpoint-ontology/queued-touches.d.ts +32 -0
  40. package/dist/lib/checkpoint-ontology/queued-touches.js +193 -0
  41. package/dist/lib/core-utils/timeout-guard-native.d.ts +7 -0
  42. package/dist/lib/core-utils/timeout-guard-native.js +40 -0
  43. package/dist/lib/core-utils/timeout-guard.d.ts +3 -3
  44. package/dist/lib/core-utils/timeout-guard.js +28 -13
  45. package/dist/lib/daemon/catalog.d.ts +7 -0
  46. package/dist/lib/daemon/catalog.js +6 -0
  47. package/dist/lib/daemon/flag.d.ts +23 -0
  48. package/dist/lib/daemon/flag.js +57 -0
  49. package/dist/lib/daemon/install.js +6 -1
  50. package/dist/lib/daemon/remote-control.d.ts +6 -0
  51. package/dist/lib/daemon/remote-control.js +12 -0
  52. package/dist/lib/daemon/run.js +115 -2
  53. package/dist/lib/daemon/succession.d.ts +116 -0
  54. package/dist/lib/daemon/succession.js +262 -0
  55. package/dist/lib/daemon/supervisor.d.ts +34 -1
  56. package/dist/lib/daemon/supervisor.js +190 -23
  57. package/dist/lib/flag-registry.d.ts +12 -7
  58. package/dist/lib/flag-registry.js +66 -75
  59. package/dist/lib/flag-snapshot-file.d.ts +20 -0
  60. package/dist/lib/flag-snapshot-file.js +91 -0
  61. package/dist/lib/lanes/host-capacity.js +7 -1
  62. package/dist/lib/lanes/models.d.ts +2 -0
  63. package/dist/lib/lanes/models.js +6 -1
  64. package/dist/lib/lanes/reconcile.js +20 -4
  65. package/dist/lib/process-flag-company-context.d.ts +8 -0
  66. package/dist/lib/process-flag-company-context.js +67 -0
  67. package/dist/main.js +3 -1
  68. package/dist/native-fast-route.d.ts +3 -0
  69. package/dist/native-fast-route.js +39 -0
  70. package/dist/utils/cli-telemetry.d.ts +26 -2
  71. package/dist/utils/cli-telemetry.js +178 -3
  72. package/dist/utils/hq-root-resolver.d.ts +13 -0
  73. package/dist/utils/hq-root-resolver.js +103 -0
  74. package/dist/utils/scaffold-fast-import-guard.js +6 -1
  75. package/dist/utils/vault-api.d.ts +8 -0
  76. package/dist/utils/vault-api.js +13 -1
  77. package/package.json +1 -1
@@ -67,6 +67,8 @@ export declare function parseClaudeHelpModels(text: string): {
67
67
  aliases: string[];
68
68
  prefixes: string[];
69
69
  };
70
+ /** Prefix of every Claude model full name, e.g. `claude-sonnet-5-5`. */
71
+ export declare const CLAUDE_FULL_NAME_PREFIX = "claude-";
70
72
  export declare function claudeKnownModels(probe?: ProbeCommand, env?: NodeJS.ProcessEnv): KnownModels | null;
71
73
  export declare function knownModels(provider: LaneProvider, home: string, deps?: KnownModelsDeps): KnownModels | null;
72
74
  export declare function modelIsKnown(known: KnownModels, model: string): boolean;
@@ -154,6 +154,8 @@ export function parseClaudeHelpModels(text) {
154
154
  }
155
155
  return { aliases, prefixes: [...prefixes] };
156
156
  }
157
+ /** Prefix of every Claude model full name, e.g. `claude-sonnet-5-5`. */
158
+ export const CLAUDE_FULL_NAME_PREFIX = "claude-";
157
159
  export function claudeKnownModels(probe = defaultProbe, env = process.env) {
158
160
  const source = "claude --help";
159
161
  const result = probe("claude", ["--help"], env);
@@ -166,7 +168,10 @@ export function claudeKnownModels(probe = defaultProbe, env = process.env) {
166
168
  process.stderr.write(`hq lanes: \`${source}\` named no --model aliases (exit ${result.status ?? "spawn error"}); --model is not validated for claude\n`);
167
169
  return null;
168
170
  }
169
- return { provider: "claude", ids: parsed.aliases, prefixes: parsed.prefixes, source };
171
+ // Every Claude model's full name starts with `claude-`. Accept it even when
172
+ // this help text carries no full-name example, as some releases' help does.
173
+ const prefixes = [...new Set([CLAUDE_FULL_NAME_PREFIX, ...parsed.prefixes])];
174
+ return { provider: "claude", ids: parsed.aliases, prefixes, source };
170
175
  }
171
176
  export function knownModels(provider, home, deps = {}) {
172
177
  switch (provider) {
@@ -1812,7 +1812,7 @@ function resumeKeepAlive(hqRoot, lanes, questions, summary, deps, now, flagByCom
1812
1812
  const rechecked = deps.readKeepAliveRecheckLane?.(hqRoot, lane.lane_id) ?? readLane(hqRoot, lane.lane_id);
1813
1813
  // Dry-run backfills only in memory. Carry that simulated legacy timestamp
1814
1814
  // through the fresh read when the stored lane version has not changed.
1815
- const current = deps.dryRun &&
1815
+ let current = deps.dryRun &&
1816
1816
  initialState === "interrupted" &&
1817
1817
  originalLane.interrupted_at === undefined &&
1818
1818
  lane.interrupted_at !== undefined &&
@@ -1907,6 +1907,19 @@ function resumeKeepAlive(hqRoot, lanes, questions, summary, deps, now, flagByCom
1907
1907
  recordKeepAliveSkip(hqRoot, current, currentExclusion, summary, deps);
1908
1908
  continue;
1909
1909
  }
1910
+ if (current.keep_alive_blocked_reason === "superseded") {
1911
+ // The fresh exclusion check found no active successor. Clear the stale
1912
+ // reason before any later exit (backoff, queue full, retry cap, hire
1913
+ // refusal) so the watcher keeps treating the lane as resumable.
1914
+ const cleared = casLaneIf(hqRoot, current.lane_id, (latestLane) => latestLane.version === current.version &&
1915
+ latestLane.state === current.state &&
1916
+ latestLane.keep_alive === true &&
1917
+ latestLane.keep_alive_opted_in_at === current.keep_alive_opted_in_at &&
1918
+ latestLane.keep_alive_blocked_reason === "superseded", (latestLane) => ({ ...latestLane, keep_alive_blocked_reason: undefined }), deps.dryRun);
1919
+ if (!cleared.changed)
1920
+ continue;
1921
+ current = cleared.record;
1922
+ }
1910
1923
  const currentSessionId = current.provider_session_id ?? current.last_resume?.session_id;
1911
1924
  if (!providerSupportsSessionResume(current.provider) ||
1912
1925
  typeof currentSessionId !== "string" ||
@@ -1934,9 +1947,12 @@ function resumeKeepAlive(hqRoot, lanes, questions, summary, deps, now, flagByCom
1934
1947
  continue;
1935
1948
  }
1936
1949
  const attempt = attempts + 1;
1950
+ // A done turn ends no later than its done envelope. Bookkeeping writes
1951
+ // (such as clearing a stale block) move updated_at but not the envelope,
1952
+ // so they cannot push the retry clock forward.
1937
1953
  const terminalAtMs = initialState === "done"
1938
- ? Date.parse(current.updated_at)
1939
- : Date.parse(current.interrupted_at ?? "");
1954
+ ? Math.min(Date.parse(current.updated_at), currentEligibilityAtMs)
1955
+ : currentEligibilityAtMs;
1940
1956
  const attemptEligibleMs = terminalAtMs + leadLaneKeepAliveBackoffMs(attempt);
1941
1957
  const storedNextMs = resetByHuman
1942
1958
  ? Number.NaN
@@ -1967,7 +1983,7 @@ function resumeKeepAlive(hqRoot, lanes, questions, summary, deps, now, flagByCom
1967
1983
  });
1968
1984
  continue;
1969
1985
  }
1970
- const resumedFrom = current.state;
1986
+ const resumedFrom = current.state === "done" ? "done" : "interrupted";
1971
1987
  const resumeText = resumedFrom === "interrupted"
1972
1988
  ? LEAD_LANE_KEEP_ALIVE_INTERRUPTED_RESUME_TEXT
1973
1989
  : LEAD_LANE_KEEP_ALIVE_RESUME_TEXT;
@@ -0,0 +1,8 @@
1
+ /** Company scope shared by the full flag registry and its fast-path reader. */
2
+ export interface ProcessFlagCompanyContext {
3
+ companyUid?: string;
4
+ companySlug?: string;
5
+ }
6
+ /** Resolve the same company identity the process-wide flag reader uses. */
7
+ export declare function resolveProcessFlagCompanyContext(env?: NodeJS.ProcessEnv): ProcessFlagCompanyContext;
8
+ //# sourceMappingURL=process-flag-company-context.d.ts.map
@@ -0,0 +1,67 @@
1
+ /** Company scope shared by the full flag registry and its fast-path reader. */
2
+ import { readMachineCompanyBinding } from "./work-context/company.js";
3
+ import { readSessionState } from "./work-context/state.js";
4
+ import { workContextRoot } from "./work-context/paths.js";
5
+ import { readCompaniesManifestMap } from "./work-context/repo-remote.js";
6
+ import { resolveDefaultHqRoot } from "../utils/hq-root-resolver.js";
7
+ const COMPANY_UID_RE = /^cmp_[A-Za-z0-9]{3,128}$/;
8
+ const COMPANY_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
9
+ function normalizedCompanyUid(value) {
10
+ const candidate = value?.trim() ?? "";
11
+ return COMPANY_UID_RE.test(candidate) ? candidate : undefined;
12
+ }
13
+ function normalizedCompanySlug(value) {
14
+ const candidate = value?.trim() ?? "";
15
+ return COMPANY_SLUG_RE.test(candidate) ? candidate : undefined;
16
+ }
17
+ /** Resolve explicit, machine, or bound-session company scope without defaults. */
18
+ function processCompanyContext(env = process.env) {
19
+ const explicitUid = normalizedCompanyUid(env.HQ_COMPANY_UID);
20
+ const explicitSlug = normalizedCompanySlug(env.HQ_COMPANY_SLUG);
21
+ if (explicitUid || explicitSlug) {
22
+ return { companyUid: explicitUid, companySlug: explicitSlug };
23
+ }
24
+ const machine = readMachineCompanyBinding(env);
25
+ const spawnValue = env.HQ_SPAWN_COMPANY?.trim();
26
+ const spawnSlug = normalizedCompanySlug(spawnValue);
27
+ const machineContext = spawnValue && COMPANY_UID_RE.test(spawnValue)
28
+ ? { companyUid: spawnValue }
29
+ : spawnSlug
30
+ ? { companySlug: spawnSlug }
31
+ : {
32
+ companyUid: normalizedCompanyUid(machine?.uid),
33
+ companySlug: normalizedCompanySlug(machine?.slug),
34
+ };
35
+ if (machineContext.companyUid || machineContext.companySlug)
36
+ return machineContext;
37
+ const sessionId = env.HQ_SESSION_ID?.trim();
38
+ if (!sessionId)
39
+ return {};
40
+ const session = readSessionState(sessionId, workContextRoot(undefined, env));
41
+ if (session?.contextStatus !== "bound" || session.companyConfidence === "device_default") {
42
+ return {};
43
+ }
44
+ return {
45
+ companyUid: normalizedCompanyUid(session.companyUid),
46
+ companySlug: normalizedCompanySlug(session.companySlug),
47
+ };
48
+ }
49
+ function companyUidForContext(context, env = process.env) {
50
+ if (context.companyUid)
51
+ return context.companyUid;
52
+ if (!context.companySlug)
53
+ return undefined;
54
+ const hqRoot = resolveDefaultHqRoot({ env });
55
+ const manifestUid = readCompaniesManifestMap(hqRoot)?.[context.companySlug]?.cloud_uid;
56
+ return typeof manifestUid === "string" ? normalizedCompanyUid(manifestUid) : undefined;
57
+ }
58
+ /** Resolve the same company identity the process-wide flag reader uses. */
59
+ export function resolveProcessFlagCompanyContext(env = process.env) {
60
+ const context = processCompanyContext(env);
61
+ const companyUid = companyUidForContext(context, env);
62
+ return {
63
+ ...(companyUid ? { companyUid } : {}),
64
+ ...(context.companySlug ? { companySlug: context.companySlug } : {}),
65
+ };
66
+ }
67
+ //# sourceMappingURL=process-flag-company-context.js.map
package/dist/main.js CHANGED
@@ -43,7 +43,7 @@ import { enforceVersionGate, shouldSkipGate, } from "./utils/version-gate.js";
43
43
  import { autoUpdateAndReexec } from "./utils/self-update.js";
44
44
  import { CLI_VERSION } from "./cli-version.js";
45
45
  import { findLazyCommand, registerCommandCatalog } from "./lazy-commands.js";
46
- import { emitCliFirstSyncNextStepOutcome, emitCliSessionStarted, takeFirstSyncStepOutcomes, maybeShowFirstSyncNextStep, } from "./utils/cli-telemetry.js";
46
+ import { emitCliFirstSyncNextStepOutcome, emitCliSessionStarted, emitPlanLimitInviteTeammateExposure, takeFirstSyncStepOutcomes, takePlanLimitInviteTeammateExposures, maybeShowFirstSyncNextStep, } from "./utils/cli-telemetry.js";
47
47
  import { reportCliClientHealthInvocation } from "./utils/client-health.js";
48
48
  import { settleWithin } from "./utils/settle-with-timeout.js";
49
49
  import { emitPlanLimitNag } from "./lib/plan-limit-nag.js";
@@ -294,10 +294,12 @@ export async function runCli() {
294
294
  // slice even when the command itself was long-running.
295
295
  const releaseHealthTimeoutMs = foregroundNetworkBudget.timeoutFor(FINALIZATION_FOREGROUND_WAIT_MS);
296
296
  const firstSyncOutcomeFlushes = takeFirstSyncStepOutcomes().map((outcome) => emitCliFirstSyncNextStepOutcome(outcome, releaseHealthTimeoutMs));
297
+ const inviteTeammateExposureFlushes = takePlanLimitInviteTeammateExposures().map((companyUid) => emitPlanLimitInviteTeammateExposure(companyUid, releaseHealthTimeoutMs));
297
298
  await settleWithin([
298
299
  refreshVersionCache({ timeoutMs: releaseHealthTimeoutMs }),
299
300
  Sentry.flush(releaseHealthTimeoutMs),
300
301
  ...firstSyncOutcomeFlushes,
302
+ ...inviteTeammateExposureFlushes,
301
303
  ], releaseHealthTimeoutMs);
302
304
  });
303
305
  // Last, so it wins over anything the (skipped) command path would have
@@ -0,0 +1,3 @@
1
+ /** Default-off flag check for native entrypoint routes. */
2
+ export declare function nativeFastRouteEnabled(argv: readonly string[], env?: NodeJS.ProcessEnv): Promise<boolean>;
3
+ //# sourceMappingURL=native-fast-route.d.ts.map
@@ -0,0 +1,39 @@
1
+ /** Default-off flag check for native entrypoint routes. */
2
+ import { localOverrideEnvVar, readLocalOverride } from "@indigoai-us/hq-flags-client";
3
+ import { parseFastCore } from "./fast-scaffold.js";
4
+ export async function nativeFastRouteEnabled(argv, env = process.env) {
5
+ const parsed = parseFastCore(argv);
6
+ if (!parsed || parsed.entry.kind !== "native")
7
+ return false;
8
+ const override = nativeRouteOverride(parsed.entry.flagKey, env);
9
+ if (override !== null)
10
+ return override;
11
+ try {
12
+ const [{ resolveProcessFlagCompanyContext }, { readFlagSnapshotFile }] = await Promise.all([
13
+ import("./lib/process-flag-company-context.js"),
14
+ import("./lib/flag-snapshot-file.js"),
15
+ ]);
16
+ const { companyUid } = resolveProcessFlagCompanyContext(env);
17
+ if (!companyUid)
18
+ return false;
19
+ return readFlagSnapshotFile(companyUid, { env })?.flags[parsed.entry.flagKey] === true;
20
+ }
21
+ catch {
22
+ // The new path is a rollout optimization. Any unavailable local scope or
23
+ // cache data leaves the established full-graph route in control.
24
+ return false;
25
+ }
26
+ }
27
+ /** The route's documented CLI override also accepts the conventional 1/0. */
28
+ function nativeRouteOverride(flagKey, env) {
29
+ const override = readLocalOverride(flagKey, env);
30
+ if (override !== null)
31
+ return override;
32
+ const raw = env[localOverrideEnvVar(flagKey)];
33
+ if (raw === "1")
34
+ return true;
35
+ if (raw === "0")
36
+ return false;
37
+ return null;
38
+ }
39
+ //# sourceMappingURL=native-fast-route.js.map
@@ -3,16 +3,39 @@ export { CLI_FIRST_SYNC_NEXT_STEP_FLAG, FIRST_SYNC_CONFIRMATION_COPY, FIRST_SYNC
3
3
  export declare const TELEMETRY_TIMEOUT_MS = 1200;
4
4
  /**
5
5
  * Exact stderr line printed once on the first signed-in TTY run that is not
6
- * `--json`. Keep this notice and its marker stable for the default telemetry
7
- * path; first-sync fields have a separate opt-in disclosure below.
6
+ * `--json`. Keep this notice and its marker stable for session telemetry;
7
+ * first-sync and feature-used fields have separate opt-in disclosures below.
8
8
  */
9
9
  export declare const TELEMETRY_DISCLOSURE_LINE = "HQ sends a usage event when you run a signed-in command (CLI version and a timestamp, never command names, file contents, or secrets) to improve the CLI. Set HQ_TELEMETRY=0 to turn this off.";
10
10
  export declare const FIRST_SYNC_TELEMETRY_DISCLOSURE_LINE = "HQ sends a usage event when you run a signed-in command (CLI version and a timestamp; when enabled, first-sync guidance shown or followed and the flag key cli.first-sync-next-step; never command names, file contents, or secrets) to improve the CLI. Set HQ_TELEMETRY=0 to turn this off.";
11
+ export declare const CLI_FEATURE_USED_TELEMETRY_FLAG = "cli.feature-used-telemetry-v1";
12
+ export declare const CLI_FEATURE_USED_TELEMETRY_DISCLOSURE_LINE = "HQ sends a feature-use event when you run a supported CLI feature (feature kind and outcome, plus your company UID when available; never command names, file contents, or secrets). The hq-flags key cli.feature-used-telemetry-v1 controls these events. Set HQ_TELEMETRY=0 to turn this off.";
13
+ /** Queue one exposure after the CLI has printed the invite suggestion. */
14
+ export declare function queuePlanLimitInviteTeammateExposure(companyUid: string): void;
15
+ /** Drain invite-suggestion exposures for the bounded command-finalization flush. */
16
+ export declare function takePlanLimitInviteTeammateExposures(): string[];
17
+ export type CliFeatureKind = "secret_read" | "lane_create" | "lane_resume";
18
+ export type CliFeatureOutcome = "success" | "failure";
19
+ export interface CliFeatureUsage {
20
+ kind: CliFeatureKind;
21
+ outcome: CliFeatureOutcome;
22
+ /** Only company UIDs are accepted; names and slugs are never telemetry fields. */
23
+ companyUid?: string;
24
+ }
25
+ /**
26
+ * Emit one bounded, privacy-limited feature-use event with a cached identity.
27
+ * It never refreshes credentials or turns telemetry failure into command failure.
28
+ */
29
+ export declare function emitCliFeatureUsed(usage: CliFeatureUsage, timeoutMs?: number): Promise<void>;
30
+ /** Run one action and emit exactly one success/failure event without masking errors. */
31
+ export declare function withCliFeatureUsed<T>(kind: CliFeatureKind, action: () => Promise<T>, companyUid?: string): Promise<T>;
11
32
  /**
12
33
  * `HQ_TELEMETRY=0` (also `false` and `off`, case-insensitive) disables every
13
34
  * CLI POST to `/v1/telemetry/events`. `HQ_TELEMETRY_TIMEOUT_MS` is unchanged.
14
35
  */
15
36
  export declare function isCliTelemetryDisabled(env?: NodeJS.ProcessEnv): boolean;
37
+ /** Feature-use collection stays off until the HQ flag is explicitly enabled. */
38
+ export declare function isCliFeatureUsedTelemetryEnabled(): boolean;
16
39
  export declare function telemetryDisclosureMarkerPath(): string;
17
40
  /**
18
41
  * Print the one-line disclosure at most once per machine. Returns whether
@@ -31,4 +54,5 @@ export declare function maybeDiscloseCliTelemetry(stderr?: {
31
54
  export declare function emitCliSessionStarted(timeoutMs?: number): Promise<void>;
32
55
  /** Emit one authenticated measurement for a first-sync prompt outcome. */
33
56
  export declare function emitCliFirstSyncNextStepOutcome(outcome: FirstSyncStepOutcome, timeoutMs?: number): Promise<void>;
57
+ export declare function emitPlanLimitInviteTeammateExposure(companyUid: string, timeoutMs?: number): Promise<void>;
34
58
  //# sourceMappingURL=cli-telemetry.d.ts.map
@@ -1,23 +1,117 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import * as fs from "node:fs";
2
3
  import * as os from "node:os";
3
4
  import * as path from "node:path";
4
5
  import { CLI_VERSION } from "../cli-version.js";
5
- import { isExpiring, isMachineIdentity, loadCachedTokens, } from "./cognito-session.js";
6
+ import { isExpiring, isMachineIdentity, loadCachedTokens, loadMachineCachedTokens, } from "./cognito-session.js";
7
+ import { resolveProcessFlagGate } from "../lib/flag-registry.js";
6
8
  import { vaultApiFetch } from "./vault-api.js";
7
9
  import { firstSyncStepTelemetryProperties, isFirstSyncNextStepEnabled, isHumanInteractiveCliSession, } from "../lib/first-sync-next-step.js";
8
10
  export { CLI_FIRST_SYNC_NEXT_STEP_FLAG, FIRST_SYNC_CONFIRMATION_COPY, FIRST_SYNC_NEXT_STEP_COPY, claimFirstSyncStepMarker, firstSyncStepMarkerPaths, firstSyncStepTelemetryProperties, isFirstSyncNextStepEnabled, isHumanInteractiveCliSession, isWorkBearingSyncResult, maybeShowFirstSyncNextStep, recordFirstWorkBearingSync, shouldConfirmFirstWorkBearingSync, shouldShowFirstSyncNextStep, takeFirstSyncStepOutcomes, } from "../lib/first-sync-next-step.js";
9
11
  export const TELEMETRY_TIMEOUT_MS = 1_200;
10
12
  /**
11
13
  * Exact stderr line printed once on the first signed-in TTY run that is not
12
- * `--json`. Keep this notice and its marker stable for the default telemetry
13
- * path; first-sync fields have a separate opt-in disclosure below.
14
+ * `--json`. Keep this notice and its marker stable for session telemetry;
15
+ * first-sync and feature-used fields have separate opt-in disclosures below.
14
16
  */
15
17
  export const TELEMETRY_DISCLOSURE_LINE = "HQ sends a usage event when you run a signed-in command (CLI version and a timestamp, never command names, file contents, or secrets) to improve the CLI. Set HQ_TELEMETRY=0 to turn this off.";
16
18
  export const FIRST_SYNC_TELEMETRY_DISCLOSURE_LINE = "HQ sends a usage event when you run a signed-in command (CLI version and a timestamp; when enabled, first-sync guidance shown or followed and the flag key cli.first-sync-next-step; never command names, file contents, or secrets) to improve the CLI. Set HQ_TELEMETRY=0 to turn this off.";
19
+ export const CLI_FEATURE_USED_TELEMETRY_FLAG = "cli.feature-used-telemetry-v1";
20
+ export const CLI_FEATURE_USED_TELEMETRY_DISCLOSURE_LINE = "HQ sends a feature-use event when you run a supported CLI feature (feature kind and outcome, plus your company UID when available; never command names, file contents, or secrets). The hq-flags key cli.feature-used-telemetry-v1 controls these events. Set HQ_TELEMETRY=0 to turn this off.";
17
21
  const TELEMETRY_DISABLED_VALUES = new Set(["0", "false", "off"]);
18
22
  const DISCLOSURE_MARKER = "cli-telemetry-disclosed";
19
23
  const FIRST_SYNC_DISCLOSURE_MARKER = "cli-telemetry-disclosed-first-sync-v1";
24
+ const FEATURE_USED_DISCLOSURE_MARKER = "cli-telemetry-disclosed-feature-used-v1";
20
25
  let cliSessionStartedPromise;
26
+ const pendingPlanLimitInviteTeammateExposures = [];
27
+ /** Queue one exposure after the CLI has printed the invite suggestion. */
28
+ export function queuePlanLimitInviteTeammateExposure(companyUid) {
29
+ const normalizedCompanyUid = companyUid.trim();
30
+ if (normalizedCompanyUid.length === 0)
31
+ return;
32
+ pendingPlanLimitInviteTeammateExposures.push(normalizedCompanyUid);
33
+ }
34
+ /** Drain invite-suggestion exposures for the bounded command-finalization flush. */
35
+ export function takePlanLimitInviteTeammateExposures() {
36
+ return pendingPlanLimitInviteTeammateExposures.splice(0, pendingPlanLimitInviteTeammateExposures.length);
37
+ }
38
+ const COMPANY_UID_PATTERN = /^cmp_[A-Za-z0-9_-]{1,128}$/;
39
+ /**
40
+ * Emit one bounded, privacy-limited feature-use event with a cached identity.
41
+ * It never refreshes credentials or turns telemetry failure into command failure.
42
+ */
43
+ export async function emitCliFeatureUsed(usage, timeoutMs = TELEMETRY_TIMEOUT_MS) {
44
+ try {
45
+ // Unit tests may run with a real cached user/agent token on the host. Never
46
+ // let their command fixtures write telemetry to a live account.
47
+ if (process.env.NODE_ENV === "test")
48
+ return;
49
+ if (isCliTelemetryDisabled() || !isCliFeatureUsedTelemetryEnabled())
50
+ return;
51
+ const machineIdentity = isMachineIdentity();
52
+ const cached = machineIdentity ? loadMachineCachedTokens() : loadCachedTokens();
53
+ if (!cached || isExpiring(cached, 120))
54
+ return;
55
+ const token = machineIdentity ? cached.idToken : cached.accessToken;
56
+ if (!token)
57
+ return;
58
+ const consentBasis = maybeDiscloseFeatureUsedTelemetry() ? "disclosed" : "no-consent";
59
+ const controller = new AbortController();
60
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
61
+ const companyUid = usage.companyUid && COMPANY_UID_PATTERN.test(usage.companyUid)
62
+ ? usage.companyUid
63
+ : undefined;
64
+ try {
65
+ await vaultApiFetch({
66
+ token,
67
+ path: "/v1/telemetry/events",
68
+ method: "POST",
69
+ body: {
70
+ events: [
71
+ {
72
+ eventName: "cli_feature_used",
73
+ app: "hq-cli",
74
+ source: "cli",
75
+ occurredAt: new Date().toISOString(),
76
+ consentBasis,
77
+ schemaVersion: 1,
78
+ ...(companyUid ? { companyUid } : {}),
79
+ properties: { kind: usage.kind, outcome: usage.outcome },
80
+ },
81
+ ],
82
+ },
83
+ signal: controller.signal,
84
+ });
85
+ }
86
+ catch {
87
+ // Feature telemetry is best effort and must never affect the CLI action.
88
+ }
89
+ finally {
90
+ clearTimeout(timeout);
91
+ }
92
+ }
93
+ catch {
94
+ // Cached-token reads are also best effort (for example, a malformed cache).
95
+ }
96
+ }
97
+ /** Run one action and emit exactly one success/failure event without masking errors. */
98
+ export async function withCliFeatureUsed(kind, action, companyUid) {
99
+ let outcome = "success";
100
+ try {
101
+ return await action();
102
+ }
103
+ catch (err) {
104
+ outcome = "failure";
105
+ throw err;
106
+ }
107
+ finally {
108
+ await emitCliFeatureUsed({
109
+ kind,
110
+ outcome,
111
+ ...(companyUid ? { companyUid } : {}),
112
+ });
113
+ }
114
+ }
21
115
  /**
22
116
  * `HQ_TELEMETRY=0` (also `false` and `off`, case-insensitive) disables every
23
117
  * CLI POST to `/v1/telemetry/events`. `HQ_TELEMETRY_TIMEOUT_MS` is unchanged.
@@ -28,6 +122,10 @@ export function isCliTelemetryDisabled(env = process.env) {
28
122
  return false;
29
123
  return TELEMETRY_DISABLED_VALUES.has(raw);
30
124
  }
125
+ /** Feature-use collection stays off until the HQ flag is explicitly enabled. */
126
+ export function isCliFeatureUsedTelemetryEnabled() {
127
+ return resolveProcessFlagGate(CLI_FEATURE_USED_TELEMETRY_FLAG, { fallback: false }, () => false);
128
+ }
31
129
  function hqConfigDir() {
32
130
  const home = process.env.HOME?.trim() || os.homedir();
33
131
  return path.join(home, ".hq");
@@ -38,6 +136,9 @@ export function telemetryDisclosureMarkerPath() {
38
136
  function firstSyncTelemetryDisclosureMarkerPath() {
39
137
  return path.join(hqConfigDir(), FIRST_SYNC_DISCLOSURE_MARKER);
40
138
  }
139
+ function featureUsedTelemetryDisclosureMarkerPath() {
140
+ return path.join(hqConfigDir(), FEATURE_USED_DISCLOSURE_MARKER);
141
+ }
41
142
  function wantsJsonOutput(argv = process.argv) {
42
143
  return argv.includes("--json");
43
144
  }
@@ -75,6 +176,9 @@ export function maybeDiscloseCliTelemetry(stderr = process.stderr, stdout = proc
75
176
  function maybeDiscloseFirstSyncTelemetry() {
76
177
  return maybeDiscloseTelemetry(FIRST_SYNC_TELEMETRY_DISCLOSURE_LINE, firstSyncTelemetryDisclosureMarkerPath());
77
178
  }
179
+ function maybeDiscloseFeatureUsedTelemetry() {
180
+ return maybeDiscloseTelemetry(CLI_FEATURE_USED_TELEMETRY_DISCLOSURE_LINE, featureUsedTelemetryDisclosureMarkerPath());
181
+ }
78
182
  function maybeDiscloseTelemetry(line, markerPath, stderr = process.stderr, stdout = process.stdout, argv = process.argv) {
79
183
  if (hasShownDisclosure(markerPath))
80
184
  return true;
@@ -207,4 +311,75 @@ export async function emitCliFirstSyncNextStepOutcome(outcome, timeoutMs = TELEM
207
311
  // Cached-token reads are also best effort (for example, a malformed cache).
208
312
  }
209
313
  }
314
+ /** Emit one company-scoped event for a printed invite-teammate suggestion. */
315
+ function logPlanLimitInviteTeammateExposureFailure(cause, status) {
316
+ const errorClass = cause instanceof Error ? cause.name : "unknown";
317
+ const result = status === undefined ? errorClass : `http_${status}`;
318
+ console.error(`hq: Could not send invite-teammate exposure telemetry (${result}).`);
319
+ }
320
+ export async function emitPlanLimitInviteTeammateExposure(companyUid, timeoutMs = TELEMETRY_TIMEOUT_MS) {
321
+ try {
322
+ if (isCliTelemetryDisabled())
323
+ return;
324
+ const cached = loadCachedTokens();
325
+ const machineIdentity = isMachineIdentity();
326
+ if (!cached ||
327
+ isExpiring(cached, 120) ||
328
+ !isHumanInteractiveCliSession({
329
+ stdoutIsTTY: Boolean(process.stdout.isTTY),
330
+ argv: process.argv,
331
+ env: process.env,
332
+ machineIdentity,
333
+ })) {
334
+ return;
335
+ }
336
+ const token = cached.accessToken;
337
+ if (!token)
338
+ return;
339
+ const controller = new AbortController();
340
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
341
+ try {
342
+ const response = await vaultApiFetch({
343
+ token,
344
+ path: "/v1/telemetry/events",
345
+ method: "POST",
346
+ body: {
347
+ events: [
348
+ {
349
+ eventName: "plan_limit_prompt_exposed",
350
+ app: "hq-cli",
351
+ source: "cli",
352
+ occurredAt: new Date().toISOString(),
353
+ consentBasis: "no-consent",
354
+ schemaVersion: 2,
355
+ companyUid,
356
+ idempotencyKey: randomUUID(),
357
+ properties: {
358
+ surface: "cli_whoami_plan_limit",
359
+ resourceKind: "users",
360
+ presentationKind: "advisory",
361
+ exposureId: randomUUID(),
362
+ },
363
+ },
364
+ ],
365
+ },
366
+ signal: controller.signal,
367
+ });
368
+ if (!response.ok) {
369
+ logPlanLimitInviteTeammateExposureFailure(undefined, response.status);
370
+ }
371
+ }
372
+ catch (error) {
373
+ // This measurement is best-effort and must not affect the CLI command.
374
+ logPlanLimitInviteTeammateExposureFailure(error);
375
+ }
376
+ finally {
377
+ clearTimeout(timeout);
378
+ }
379
+ }
380
+ catch (error) {
381
+ // Cached-token reads and identity checks are best-effort too.
382
+ logPlanLimitInviteTeammateExposureFailure(error);
383
+ }
384
+ }
210
385
  //# sourceMappingURL=cli-telemetry.js.map
@@ -0,0 +1,13 @@
1
+ /** Lightweight HQ-root resolver shared by cloud commands and fast flag scope. */
2
+ /**
3
+ * Resolve the HQ tree root using the same precedence as the Cognito helpers:
4
+ * explicit HQ_ROOT, nearest cwd root, installer records, known locations, then
5
+ * the historical ~/hq fallback (or an error when requested).
6
+ */
7
+ export declare function resolveDefaultHqRoot(opts?: {
8
+ onMissing?: "throw" | "fallback";
9
+ env?: NodeJS.ProcessEnv;
10
+ cwd?: string;
11
+ home?: string;
12
+ }): string;
13
+ //# sourceMappingURL=hq-root-resolver.d.ts.map
@@ -0,0 +1,103 @@
1
+ /** Lightweight HQ-root resolver shared by cloud commands and fast flag scope. */
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import * as yaml from "js-yaml";
6
+ /**
7
+ * Resolve the HQ tree root using the same precedence as the Cognito helpers:
8
+ * explicit HQ_ROOT, nearest cwd root, installer records, known locations, then
9
+ * the historical ~/hq fallback (or an error when requested).
10
+ */
11
+ export function resolveDefaultHqRoot(opts = {}) {
12
+ const env = opts.env ?? process.env;
13
+ const home = opts.home ?? os.homedir();
14
+ if (env.HQ_ROOT)
15
+ return path.resolve(env.HQ_ROOT);
16
+ let cur = path.resolve(opts.cwd ?? process.cwd());
17
+ while (cur !== path.dirname(cur)) {
18
+ if (isHqRoot(cur))
19
+ return cur;
20
+ cur = path.dirname(cur);
21
+ }
22
+ const fromConfig = readHqRootFromHqConfig(path.join(home, ".hq"));
23
+ if (fromConfig)
24
+ return path.resolve(fromConfig);
25
+ const discovered = discoverHqRootViaCoreYaml(home);
26
+ if (discovered)
27
+ return discovered;
28
+ if (opts.onMissing === "throw") {
29
+ throw new Error("Could not find HQ root. Run this command from within your HQ directory " +
30
+ "(must contain core.yaml AND a companies/ subdirectory), set $HQ_ROOT, " +
31
+ "or install HQ via the installer/menubar so ~/.hq/menubar.json records " +
32
+ "its location.");
33
+ }
34
+ return path.join(home, "hq");
35
+ }
36
+ function readHqRootFromHqConfig(hqConfigDir) {
37
+ const fromMenubar = readJsonStringField(path.join(hqConfigDir, "menubar.json"), "hqPath");
38
+ if (fromMenubar)
39
+ return fromMenubar;
40
+ return readJsonStringField(path.join(hqConfigDir, "config.json"), "hqFolderPath");
41
+ }
42
+ function readJsonStringField(file, field) {
43
+ try {
44
+ const parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
45
+ const value = parsed[field];
46
+ return typeof value === "string" && value.length > 0 ? value : undefined;
47
+ }
48
+ catch {
49
+ return undefined;
50
+ }
51
+ }
52
+ function hqDiscoveryCandidates(home) {
53
+ return [
54
+ path.join(home, "HQ"),
55
+ path.join(home, "hq"),
56
+ path.join(home, "Documents", "HQ"),
57
+ path.join(home, "Documents", "hq"),
58
+ path.join(home, "Desktop", "HQ"),
59
+ path.join(home, "Desktop", "hq"),
60
+ ];
61
+ }
62
+ function discoverHqRootViaCoreYaml(home) {
63
+ return hqDiscoveryCandidates(home).find(isValidHqRootSignature);
64
+ }
65
+ function isValidHqRootSignature(dir) {
66
+ const canonical = path.join(dir, "core", "core.yaml");
67
+ const legacy = path.join(dir, "core.yaml");
68
+ let file;
69
+ if (fileExists(canonical))
70
+ file = canonical;
71
+ else if (fileExists(legacy))
72
+ file = legacy;
73
+ else
74
+ return false;
75
+ try {
76
+ const parsed = yaml.load(fs.readFileSync(file, "utf-8"));
77
+ return !!parsed && typeof parsed === "object" && "version" in parsed && "hqVersion" in parsed;
78
+ }
79
+ catch {
80
+ return false;
81
+ }
82
+ }
83
+ function fileExists(file) {
84
+ try {
85
+ return fs.statSync(file).isFile();
86
+ }
87
+ catch {
88
+ return false;
89
+ }
90
+ }
91
+ function isHqRoot(dir) {
92
+ const hasCoreYaml = fileExists(path.join(dir, "core", "core.yaml")) ||
93
+ fileExists(path.join(dir, "core.yaml"));
94
+ if (!hasCoreYaml)
95
+ return false;
96
+ try {
97
+ return fs.statSync(path.join(dir, "companies")).isDirectory();
98
+ }
99
+ catch {
100
+ return false;
101
+ }
102
+ }
103
+ //# sourceMappingURL=hq-root-resolver.js.map
@@ -4,6 +4,8 @@ export function forbiddenFastPathModules(loadedModules, distDirectory) {
4
4
  const dist = path.resolve(distDirectory);
5
5
  const main = path.join(dist, "main.js");
6
6
  const commands = `${path.join(dist, "commands")}${path.sep}`;
7
+ const flagRegistry = path.join(dist, "lib", "flag-registry.js");
8
+ const coreCommand = path.join(dist, "commands", "core.js");
7
9
  return [...new Set(loadedModules.map((loaded) => {
8
10
  if (loaded.startsWith("file:")) {
9
11
  try {
@@ -16,7 +18,10 @@ export function forbiddenFastPathModules(loadedModules, distDirectory) {
16
18
  return path.resolve(loaded);
17
19
  }))]
18
20
  .filter((loaded) => path.resolve(loaded) === main ||
19
- path.resolve(loaded).startsWith(commands))
21
+ path.resolve(loaded).startsWith(commands) ||
22
+ path.resolve(loaded) === flagRegistry ||
23
+ path.resolve(loaded) === coreCommand ||
24
+ path.resolve(loaded).includes(`${path.sep}node_modules${path.sep}@sentry${path.sep}node${path.sep}`))
20
25
  .sort();
21
26
  }
22
27
  export function assertFastPathStaysCommandGraphFree(loadedModules, distDirectory) {