@indigoai-us/hq-cli 5.345.159 → 5.345.160

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 (54) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/command-catalog.generated.d.ts +4 -4
  3. package/dist/command-catalog.generated.js +4 -4
  4. package/dist/commands/access.d.ts +6 -0
  5. package/dist/commands/access.js +18 -1
  6. package/dist/commands/acl-lock.d.ts +41 -0
  7. package/dist/commands/acl-lock.js +28 -0
  8. package/dist/commands/agent-enroll.d.ts +2 -0
  9. package/dist/commands/agent-enroll.js +9 -4
  10. package/dist/commands/agents.js +5 -11
  11. package/dist/commands/billing-sponsor.d.ts +4 -4
  12. package/dist/commands/billing-sponsor.js +11 -11
  13. package/dist/commands/billing-usage.d.ts +22 -8
  14. package/dist/commands/billing-usage.js +57 -22
  15. package/dist/commands/billing.d.ts +7 -7
  16. package/dist/commands/billing.js +13 -13
  17. package/dist/commands/cloud-provision.js +2 -2
  18. package/dist/commands/cloud.js +7 -7
  19. package/dist/commands/db-remote-deps.js +12 -1
  20. package/dist/commands/files.js +19 -7
  21. package/dist/commands/meetings.d.ts +4 -4
  22. package/dist/commands/meetings.js +17 -8
  23. package/dist/commands/skill.js +6 -6
  24. package/dist/commands/whoami.js +1 -1
  25. package/dist/hook-engine-path.js +13 -2
  26. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +982 -426
  27. package/dist/hq-cloud-sync-runner/package.json +1 -1
  28. package/dist/lib/billing/plan-limit-refusals.d.ts +13 -3
  29. package/dist/lib/billing/plan-limit-refusals.js +26 -3
  30. package/dist/lib/billing/plan-limit-upgrade-url.d.ts +8 -0
  31. package/dist/lib/billing/plan-limit-upgrade-url.js +18 -0
  32. package/dist/lib/billing/plan-lock.d.ts +22 -6
  33. package/dist/lib/billing/plan-lock.js +58 -30
  34. package/dist/lib/billing/usage-cost.d.ts +24 -2
  35. package/dist/lib/billing/usage-cost.js +103 -3
  36. package/dist/lib/billing/usage-only.d.ts +37 -1
  37. package/dist/lib/billing/usage-only.js +89 -4
  38. package/dist/lib/billing/usage-pause.d.ts +27 -0
  39. package/dist/lib/billing/usage-pause.js +50 -0
  40. package/dist/lib/db/control-plane.d.ts +5 -0
  41. package/dist/lib/db/control-plane.js +10 -0
  42. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  43. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  44. package/dist/lib/hook-engine/resolve.d.ts +14 -0
  45. package/dist/lib/hook-engine/resolve.js +55 -1
  46. package/dist/lib/plan-limit-nag.d.ts +0 -16
  47. package/dist/lib/plan-limit-nag.js +17 -18
  48. package/dist/main.d.ts +0 -2
  49. package/dist/main.js +19 -57
  50. package/dist/utils/plan-gate-error.d.ts +30 -14
  51. package/dist/utils/plan-gate-error.js +60 -46
  52. package/dist/utils/team-upgrade.d.ts +19 -6
  53. package/dist/utils/team-upgrade.js +37 -39
  54. package/package.json +8 -8
@@ -1 +1 @@
1
- {"name":"hq-cli-daemon-sync-runner","type":"module","version":"6.18.97","bundleSha256":"7cb110257acf96c8d91c57f238f7f01be43620ed66ae28dfe74dcc972be64153","formatVersion":1}
1
+ {"name":"hq-cli-daemon-sync-runner","type":"module","version":"6.18.98","bundleSha256":"70fe5497fffdbb678370515f8b7df63438d7a59a95467102307459e78f9f4715","formatVersion":1}
@@ -18,6 +18,12 @@
18
18
  * stops re-sending paused keys. The `path`/`paths`/`count` reads below consume
19
19
  * that event; the `error`-message parsing stays for runners older than
20
20
  * 6.18.17.
21
+ *
22
+ * US-024: the server's sentence wins. A `plan-limit` event carries the 402
23
+ * body's `message` (for a usage pause, the `usagePause` message hq-pro writes
24
+ * for the person who hit it), and a runner that forwards `usagePause` gives
25
+ * its message and `actionUrl` directly. The CLI adds no plan name and labels
26
+ * the link `Billing:`, never "Upgrade".
21
27
  */
22
28
  import { type SyncProgressEvent } from "@indigoai-us/hq-cloud";
23
29
  /** What one push leg was refused. `null` from the tracker means nothing was. */
@@ -29,10 +35,12 @@ export interface PlanLimitRefusalSummary {
29
35
  count: number;
30
36
  /** Vault-relative paths the runner named, de-duplicated, in report order. */
31
37
  paths: string[];
32
- /** Server sentence from a refused file's error, when one was carried. */
38
+ /** Server sentence (usage pause message, event message, or a refused file's error). */
33
39
  message?: string;
34
- /** Server-selected (or company billing fallback) upgrade link. */
40
+ /** Server-selected (or company billing fallback) billing link. */
35
41
  upgradeUrl?: string;
42
+ /** True when the server said this is a usage pause (`usagePause`). */
43
+ usagePause?: boolean;
36
44
  }
37
45
  export interface PlanLimitRefusalTracker {
38
46
  observe(event: SyncProgressEvent): void;
@@ -57,9 +65,11 @@ export declare function createPlanLimitRefusalTracker(opts?: {
57
65
  export declare function trackingProgressRecorder(tracker: PlanLimitRefusalTracker, company: string | null): (event: SyncProgressEvent) => void;
58
66
  /** Add a later leg's refusals to an earlier total. */
59
67
  export declare function mergePlanLimitRefusals(a: PlanLimitRefusalSummary | null | undefined, b: PlanLimitRefusalSummary | null | undefined): PlanLimitRefusalSummary | null;
68
+ /** The sentence when the server sent none. Names no plan. */
69
+ export declare const REFUSAL_FALLBACK_SENTENCE = "New files are paused for this account. Open its Billing page to see why.";
60
70
  /**
61
71
  * Plain lines describing a refusal: what was not uploaded, why, and the
62
- * upgrade link. Colour and the leading status line are the caller's.
72
+ * billing link. Colour and the leading status line are the caller's.
63
73
  */
64
74
  export declare function renderPlanLimitRefusalLines(summary: PlanLimitRefusalSummary, indent?: string): string[];
65
75
  /** "1 new file" / "3 new files", or "new files" when only a notice said so. */
@@ -18,9 +18,16 @@
18
18
  * stops re-sending paused keys. The `path`/`paths`/`count` reads below consume
19
19
  * that event; the `error`-message parsing stays for runners older than
20
20
  * 6.18.17.
21
+ *
22
+ * US-024: the server's sentence wins. A `plan-limit` event carries the 402
23
+ * body's `message` (for a usage pause, the `usagePause` message hq-pro writes
24
+ * for the person who hit it), and a runner that forwards `usagePause` gives
25
+ * its message and `actionUrl` directly. The CLI adds no plan name and labels
26
+ * the link `Billing:`, never "Upgrade".
21
27
  */
22
28
  import { createSyncProgressRecorder, } from "@indigoai-us/hq-cloud";
23
29
  import { planLimitUpgradeFallbackUrl } from "./plan-limit-upgrade-url.js";
30
+ import { usagePauseFromBody } from "./usage-pause.js";
24
31
  /** Upgrade destination when neither the event nor the error named one. */
25
32
  const FALLBACK_UPGRADE_URL = "https://hq.computer/billing";
26
33
  const PLAN_LIMIT_CODE_TOKEN = /\bcode=(?:PLAN_LIMIT_EXCEEDED|PLAN_LIMIT_REACHED|plan_limit_reached)\b/;
@@ -72,6 +79,7 @@ export function createPlanLimitRefusalTracker(opts = {}) {
72
79
  let reportedCount = 0;
73
80
  let message;
74
81
  let upgradeUrl;
82
+ let usagePaused = false;
75
83
  const addPath = (value) => {
76
84
  if (typeof value === "string" && value && !paths.includes(value)) {
77
85
  paths.push(value);
@@ -82,7 +90,17 @@ export function createPlanLimitRefusalTracker(opts = {}) {
82
90
  const record = event;
83
91
  if (event.type === "plan-limit") {
84
92
  noticeSeen = true;
93
+ const pause = usagePauseFromBody(record);
94
+ if (pause) {
95
+ usagePaused = true;
96
+ message = pause.message;
97
+ if (pause.actionUrl)
98
+ upgradeUrl = pause.actionUrl;
99
+ }
85
100
  upgradeUrl ??= safeHttpsUrl(record.upgradeUrl);
101
+ if (typeof record.message === "string" && record.message.trim()) {
102
+ message ??= record.message.trim();
103
+ }
86
104
  addPath(record.path);
87
105
  if (Array.isArray(record.paths))
88
106
  record.paths.forEach(addPath);
@@ -111,6 +129,7 @@ export function createPlanLimitRefusalTracker(opts = {}) {
111
129
  paths: [...paths],
112
130
  ...(message ? { message } : {}),
113
131
  ...(url ? { upgradeUrl: url } : {}),
132
+ ...(usagePaused ? { usagePause: true } : {}),
114
133
  };
115
134
  },
116
135
  };
@@ -141,22 +160,26 @@ export function mergePlanLimitRefusals(a, b) {
141
160
  paths.push(p);
142
161
  const message = a.message ?? b.message;
143
162
  const upgradeUrl = a.upgradeUrl ?? b.upgradeUrl;
163
+ const usagePause = a.usagePause === true || b.usagePause === true;
144
164
  return {
145
165
  count: a.count + b.count,
146
166
  paths,
147
167
  ...(message ? { message } : {}),
148
168
  ...(upgradeUrl ? { upgradeUrl } : {}),
169
+ ...(usagePause ? { usagePause } : {}),
149
170
  };
150
171
  }
151
172
  /** How many named paths to print before summarising the rest. */
152
173
  const MAX_LISTED_PATHS = 20;
174
+ /** The sentence when the server sent none. Names no plan. */
175
+ export const REFUSAL_FALLBACK_SENTENCE = "New files are paused for this account. Open its Billing page to see why.";
153
176
  /**
154
177
  * Plain lines describing a refusal: what was not uploaded, why, and the
155
- * upgrade link. Colour and the leading status line are the caller's.
178
+ * billing link. Colour and the leading status line are the caller's.
156
179
  */
157
180
  export function renderPlanLimitRefusalLines(summary, indent = " ") {
158
181
  const lines = [];
159
- lines.push(`${indent}${summary.message ?? "New files are paused by your plan limit."}`);
182
+ lines.push(`${indent}${summary.message ?? REFUSAL_FALLBACK_SENTENCE}`);
160
183
  if (summary.paths.length > 0) {
161
184
  lines.push(`${indent}Not uploaded:`);
162
185
  for (const p of summary.paths.slice(0, MAX_LISTED_PATHS)) {
@@ -170,7 +193,7 @@ export function renderPlanLimitRefusalLines(summary, indent = " ") {
170
193
  lines.push(`${indent}New files stay on this machine and were not uploaded.`);
171
194
  }
172
195
  if (summary.upgradeUrl)
173
- lines.push(`${indent}Upgrade: ${summary.upgradeUrl}`);
196
+ lines.push(`${indent}Billing: ${summary.upgradeUrl}`);
174
197
  return lines;
175
198
  }
176
199
  /** "1 new file" / "3 new files", or "new files" when only a notice said so. */
@@ -3,6 +3,14 @@ export interface PlanLimitUpgradeUrlChoices {
3
3
  nestedUrl?: string | null;
4
4
  legacyUrl: string;
5
5
  }
6
+ /**
7
+ * The console's self-serve switch to usage pricing for one company. While HQ
8
+ * Workforce is closed to companies not already on it (hq-pro
9
+ * `billing.legacy-plans-closed`, owner decision 2026-10-19) this is where an
10
+ * upgrade leads; the Workforce checkout refuses. Null when no console origin
11
+ * can be derived; without a slug the generic billing page.
12
+ */
13
+ export declare function usageSwitchPageUrl(companySlug?: string): string | null;
6
14
  /** Resolve and attribute the final selected company billing destination. */
7
15
  export declare function selectPlanLimitUpgradeUrl(companySlug: string | undefined, choices: PlanLimitUpgradeUrlChoices): string;
8
16
  export declare function withCliLimitEntrySurface(value: string): string;
@@ -11,6 +11,24 @@ function companyBillingUrl(companySlug) {
11
11
  : "/billing";
12
12
  return `${origin}${route}`;
13
13
  }
14
+ /**
15
+ * The console's self-serve switch to usage pricing for one company. While HQ
16
+ * Workforce is closed to companies not already on it (hq-pro
17
+ * `billing.legacy-plans-closed`, owner decision 2026-10-19) this is where an
18
+ * upgrade leads; the Workforce checkout refuses. Null when no console origin
19
+ * can be derived; without a slug the generic billing page.
20
+ */
21
+ export function usageSwitchPageUrl(companySlug) {
22
+ const controlPlaneUrl = process.env.HQ_VAULT_API_URL?.trim() || DEFAULT_VAULT_API_URL;
23
+ const origin = consoleOrigin(controlPlaneUrl);
24
+ if (!origin)
25
+ return null;
26
+ const slug = companySlug?.trim();
27
+ const route = slug
28
+ ? `/companies/${encodeURIComponent(slug)}/billing/usage-switch?entrySurface=cli_limit`
29
+ : "/billing";
30
+ return `${origin}${route}`;
31
+ }
14
32
  /** Resolve and attribute the final selected company billing destination. */
15
33
  export function selectPlanLimitUpgradeUrl(companySlug, choices) {
16
34
  const topLevelUrl = choices.topLevelUrl?.trim() || undefined;
@@ -17,6 +17,13 @@
17
17
  * Copy rule (US-036): no string a customer reads may contain "locked" or
18
18
  * "read-only". `plan-lock.test.ts` scans every rendered string for both.
19
19
  *
20
+ * Copy rule (US-024): the flat plans are closed, so nothing here offers the
21
+ * Workforce plan, its price or the free month, and nothing says "Upgrade".
22
+ * The plan is named (Starter) only when hq-pro said the company is on an
23
+ * earlier plan (`billingState` or `pricingPlan: "current"`); an unknown plan
24
+ * gets copy that names no plan, and the way out besides trimming is the
25
+ * switch to usage pricing or the company's Billing page.
26
+ *
20
27
  * An absent field is UNKNOWN, so nothing here ever infers over-limit from
21
28
  * missing data (hq-absent-field-never-means-constraining-value).
22
29
  */
@@ -58,8 +65,18 @@ export interface PlanLimitStatus {
58
65
  * which keeps the legacy copy.
59
66
  */
60
67
  pricingPlan?: PricingPlan;
68
+ /**
69
+ * True when hq-pro says HQ Workforce is closed to this company (hq-flags
70
+ * `billing.legacy-plans-closed`, owner decision 2026-10-19). `upgradeUrl`
71
+ * is then the console's usage-pricing switch page, the free month never
72
+ * stands, and the renderers offer the switch instead of a Workforce
73
+ * upgrade. Absent keeps the legacy copy (older servers).
74
+ */
75
+ legacyPlansClosed?: boolean;
61
76
  checkedAt: string;
62
77
  }
78
+ /** The way out while HQ Workforce is closed. Copy lives in ONE place. */
79
+ export declare const USAGE_SWITCH_LABEL = "Switch to usage pricing";
63
80
  /** Starter member cap quoted when the server did not send a users.limit. */
64
81
  export declare const STARTER_MEMBER_TARGET = 5;
65
82
  /**
@@ -71,10 +88,8 @@ export declare const STARTER_INTEGRATION_TARGET = 3;
71
88
  export declare const STARTER_SECRET_TARGET = 10;
72
89
  /** Starter agent cap quoted when the server did not send an agents.limit. */
73
90
  export declare const STARTER_AGENT_TARGET = 0;
74
- /** Upgrade destination quoted when the server did not send one. */
91
+ /** Billing destination quoted when the server did not send one. */
75
92
  export declare const DEFAULT_UPGRADE_URL = "https://hq.computer/billing";
76
- /** The paid plan the nag sends owners to. Copy lives in ONE place. */
77
- export declare const WORKFORCE_PLAN_LABEL = "HQ Workforce ($500/mo)";
78
93
  /**
79
94
  * Pick the membership row for `companySlug` (or `cmp_…` uid) out of a decoded
80
95
  * `/membership/me` body. No matching row is UNKNOWN. Does not read `planLock`.
@@ -133,9 +148,10 @@ export declare function renderPlanLimitNotice(status: PlanLimitStatus, opts?: {
133
148
  */
134
149
  export declare function renderPlanLimitLine(status: PlanLimitStatus): string;
135
150
  /**
136
- * The `Plan: …` orientation line. Starter only: a paid or enterprise
137
- * workspace — and an UNKNOWN plan — gets no line at all. A usage-pricing
138
- * company gets the usage line instead.
151
+ * The `Plan: …` orientation line. Starter only, and only when hq-pro said the
152
+ * company is on an earlier plan: a paid or enterprise workspace gets no line,
153
+ * and an UNKNOWN pricing plan names no plan. A usage-pricing company gets the
154
+ * usage line instead.
139
155
  */
140
156
  export declare function renderPlanLine(status: PlanLimitStatus): string | null;
141
157
  /**
@@ -17,6 +17,13 @@
17
17
  * Copy rule (US-036): no string a customer reads may contain "locked" or
18
18
  * "read-only". `plan-lock.test.ts` scans every rendered string for both.
19
19
  *
20
+ * Copy rule (US-024): the flat plans are closed, so nothing here offers the
21
+ * Workforce plan, its price or the free month, and nothing says "Upgrade".
22
+ * The plan is named (Starter) only when hq-pro said the company is on an
23
+ * earlier plan (`billingState` or `pricingPlan: "current"`); an unknown plan
24
+ * gets copy that names no plan, and the way out besides trimming is the
25
+ * switch to usage pricing or the company's Billing page.
26
+ *
20
27
  * An absent field is UNKNOWN, so nothing here ever infers over-limit from
21
28
  * missing data (hq-absent-field-never-means-constraining-value).
22
29
  */
@@ -24,7 +31,7 @@ import chalk from "chalk";
24
31
  import { diagnosticsEnabled } from "../diagnostics.js";
25
32
  import { vaultApiFetch } from "../../utils/vault-api.js";
26
33
  import { parseFreeMonth } from "./free-month.js";
27
- import { selectPlanLimitUpgradeUrl } from "./plan-limit-upgrade-url.js";
34
+ import { selectPlanLimitUpgradeUrl, usageSwitchPageUrl, } from "./plan-limit-upgrade-url.js";
28
35
  import { fetchCompanyPricingPlan, isUsageOnly, pricingPlanFromBody, pricingPlanFromMembership, usageOnlyPlanLine, usageOnlyUpgradeLines, } from "./usage-only.js";
29
36
  // The free-month parser lives in a leaf module so the plan-gate 402 decoder can
30
37
  // share it without importing the HTTP layer; re-exported so existing callers
@@ -41,6 +48,8 @@ export const PLAN_LIMIT_REASONS = [
41
48
  "secrets",
42
49
  "agents",
43
50
  ];
51
+ /** The way out while HQ Workforce is closed. Copy lives in ONE place. */
52
+ export const USAGE_SWITCH_LABEL = "Switch to usage pricing";
44
53
  /** Starter member cap quoted when the server did not send a users.limit. */
45
54
  export const STARTER_MEMBER_TARGET = 5;
46
55
  /**
@@ -52,10 +61,8 @@ export const STARTER_INTEGRATION_TARGET = 3;
52
61
  export const STARTER_SECRET_TARGET = 10;
53
62
  /** Starter agent cap quoted when the server did not send an agents.limit. */
54
63
  export const STARTER_AGENT_TARGET = 0;
55
- /** Upgrade destination quoted when the server did not send one. */
64
+ /** Billing destination quoted when the server did not send one. */
56
65
  export const DEFAULT_UPGRADE_URL = "https://hq.computer/billing";
57
- /** The paid plan the nag sends owners to. Copy lives in ONE place. */
58
- export const WORKFORCE_PLAN_LABEL = "HQ Workforce ($500/mo)";
59
66
  function finiteNumber(value) {
60
67
  return typeof value === "number" && Number.isFinite(value) ? value : null;
61
68
  }
@@ -157,6 +164,17 @@ function selectUpgradeUrl(body, companySlug) {
157
164
  const nestedUrl = typeof nested?.upgradeUrl === "string" && nested.upgradeUrl.trim().length > 0
158
165
  ? nested.upgradeUrl.trim()
159
166
  : undefined;
167
+ // While HQ Workforce is closed the way out is the usage-pricing switch, not
168
+ // the company billing page the generated URL would land on. Build it from
169
+ // the slug when known; otherwise keep the server's own switch URL.
170
+ if (rec?.legacyPlansClosed === true) {
171
+ const slug = companySlug?.trim();
172
+ return (slug ? usageSwitchPageUrl(slug) : undefined) ??
173
+ topLevelUrl ??
174
+ nestedUrl ??
175
+ usageSwitchPageUrl(undefined) ??
176
+ DEFAULT_UPGRADE_URL;
177
+ }
160
178
  return selectPlanLimitUpgradeUrl(companySlug, {
161
179
  topLevelUrl,
162
180
  nestedUrl,
@@ -210,6 +228,7 @@ function statusFromUsage(companySlug, companyUid, body, upgradeCompanySlug) {
210
228
  upgradeUrl: selectUpgradeUrl(body, upgradeCompanySlug),
211
229
  freeMonth: parseFreeMonth(rec?.freeMonth),
212
230
  plan: selectPlan(body) ?? undefined,
231
+ ...(rec?.legacyPlansClosed === true ? { legacyPlansClosed: true } : {}),
213
232
  checkedAt: new Date().toISOString(),
214
233
  }, pricingPlan);
215
234
  }
@@ -295,6 +314,10 @@ export async function fetchPlanLimitStatus(token, companyRef, opts = {}) {
295
314
  return null;
296
315
  }
297
316
  }
317
+ /** True when hq-pro said the company is on an earlier (flat) plan. */
318
+ function knownEarlierPlan(status) {
319
+ return status.pricingPlan === "current";
320
+ }
298
321
  /** Why the workspace is over, one clause per reason. */
299
322
  export function reasonLabel(reason) {
300
323
  switch (reason) {
@@ -305,7 +328,7 @@ export function reasonLabel(reason) {
305
328
  case "secrets":
306
329
  return "too many secrets";
307
330
  case "agents":
308
- return "agents are not included on Starter";
331
+ return "agents are not included";
309
332
  }
310
333
  }
311
334
  function usageTarget(reason, status) {
@@ -357,7 +380,7 @@ function reasonDetail(reason, status) {
357
380
  ? `${row.used} of ${target} secrets`
358
381
  : `over its ${target}-secret limit`;
359
382
  case "agents":
360
- return "agents are not included on Starter";
383
+ return "agents are not included";
361
384
  }
362
385
  }
363
386
  function usageLine(reason, status) {
@@ -368,20 +391,20 @@ function usageLine(reason, status) {
368
391
  const members = row ?? status.members;
369
392
  return members
370
393
  ? ` Members: ${members.used} of ${target}.`
371
- : ` Members allowed on Starter: ${target}.`;
394
+ : ` Members allowed: ${target}.`;
372
395
  }
373
396
  case "integrations":
374
397
  return row
375
398
  ? ` Integrations: ${row.used} of ${target}.`
376
- : ` Integrations allowed on Starter: ${target}.`;
399
+ : ` Integrations allowed: ${target}.`;
377
400
  case "secrets":
378
401
  return row
379
402
  ? ` Secrets: ${row.used} of ${target}.`
380
- : ` Secrets allowed on Starter: ${target}.`;
403
+ : ` Secrets allowed: ${target}.`;
381
404
  case "agents":
382
405
  return row
383
406
  ? ` Agents: ${row.used} of ${target}.`
384
- : ` Agents allowed on Starter: ${target}.`;
407
+ : ` Agents allowed: ${target}.`;
385
408
  }
386
409
  }
387
410
  /**
@@ -409,15 +432,17 @@ function capitalize(text) {
409
432
  * about a dimension nobody measured.
410
433
  */
411
434
  export function renderPlanLimitNotice(status, opts = {}) {
412
- const { companySlug, freeMonth } = status;
435
+ const { companySlug } = status;
413
436
  const lines = [];
414
437
  if (isUsageOnly(status.pricingPlan)) {
415
438
  return [`${companySlug}:`, ...usageOnlyUpgradeLines().map((l) => ` ${l}`)].join("\n");
416
439
  }
417
- lines.push(`HQ Starter — ${companySlug} is over its plan limits.`);
440
+ lines.push(knownEarlierPlan(status)
441
+ ? `HQ Starter — ${companySlug} is over its Starter limits.`
442
+ : `${companySlug} is over its limits.`);
418
443
  const reasons = status.reasons.length
419
444
  ? status.reasons.map(reasonLabel).join("; ")
420
- : "over the Starter plan limits";
445
+ : "over its limits";
421
446
  lines.push(` Why: ${reasons}.`);
422
447
  for (const reason of status.reasons) {
423
448
  lines.push(usageLine(reason, status));
@@ -426,21 +451,20 @@ export function renderPlanLimitNotice(status, opts = {}) {
426
451
  lines.push(HARD_STOP_SENTENCE_LINES[1]);
427
452
  const remedy = status.reasons.length
428
453
  ? status.reasons.map((reason) => reasonRemedy(reason, status)).join(", and ")
429
- : "come back under the Starter plan limits";
430
- const freeMonthUrl = freeMonth?.url;
431
- const hasFreeMonth = Boolean(freeMonth?.eligible && freeMonthUrl);
432
- if (hasFreeMonth) {
433
- lines.push(" Three ways to sort it:");
434
- lines.push(` 1. Start your free month — add a card, first month free: ${freeMonthUrl}`);
435
- lines.push(` 2. ${capitalize(remedy)}.`);
436
- lines.push(` 3. Upgrade to ${WORKFORCE_PLAN_LABEL}.`);
454
+ : "come back under its limits";
455
+ // The flat plans are closed: no Workforce offer and no free month (it led
456
+ // to the closed checkout). The second way out is the switch to usage
457
+ // pricing when the server said the company is on an earlier plan or that
458
+ // the flat plans are closed to it; otherwise the company's Billing page.
459
+ lines.push(" Two ways to sort it:");
460
+ lines.push(` 1. ${capitalize(remedy)}.`);
461
+ if (status.legacyPlansClosed || knownEarlierPlan(status)) {
462
+ lines.push(` 2. ${USAGE_SWITCH_LABEL}: no plan fee, and these limits stop applying.`);
437
463
  }
438
464
  else {
439
- lines.push(" Two ways to sort it:");
440
- lines.push(` 1. ${capitalize(remedy)}.`);
441
- lines.push(` 2. Upgrade to ${WORKFORCE_PLAN_LABEL}.`);
465
+ lines.push(" 2. Open the company's Billing page.");
442
466
  }
443
- lines.push(` Upgrade: ${status.upgradeUrl}`);
467
+ lines.push(` ${status.legacyPlansClosed ? "Switch" : "Billing"}: ${status.upgradeUrl}`);
444
468
  if (opts.inviteTeammateEnabled === true &&
445
469
  status.plan === "free" &&
446
470
  status.over &&
@@ -459,18 +483,22 @@ export function renderPlanLimitLine(status) {
459
483
  }
460
484
  const detail = status.reasons.length
461
485
  ? status.reasons.map((reason) => reasonDetail(reason, status)).join("; ")
462
- : "over the Starter plan limits";
463
- return (`Company ${status.companySlug} is over its Starter limits (${detail}). ` +
486
+ : "over its limits";
487
+ const limits = knownEarlierPlan(status) ? "its Starter limits" : "its limits";
488
+ return (`Company ${status.companySlug} is over ${limits} (${detail}). ` +
464
489
  `New files and new secrets are paused until this is sorted: ${status.upgradeUrl}`);
465
490
  }
466
491
  /**
467
- * The `Plan: …` orientation line. Starter only: a paid or enterprise
468
- * workspace — and an UNKNOWN plan — gets no line at all. A usage-pricing
469
- * company gets the usage line instead.
492
+ * The `Plan: …` orientation line. Starter only, and only when hq-pro said the
493
+ * company is on an earlier plan: a paid or enterprise workspace gets no line,
494
+ * and an UNKNOWN pricing plan names no plan. A usage-pricing company gets the
495
+ * usage line instead.
470
496
  */
471
497
  export function renderPlanLine(status) {
472
498
  if (isUsageOnly(status.pricingPlan))
473
499
  return usageOnlyPlanLine();
500
+ if (!knownEarlierPlan(status))
501
+ return status.over ? "Plan: over its limits" : null;
474
502
  if (status.over)
475
503
  return "Plan: Starter — over its limits";
476
504
  if (status.plan !== "free")
@@ -13,6 +13,8 @@
13
13
  * - optionally, the live cloud file-history listing `hq storage status`
14
14
  * uses, to split storage into current files and old versions and to see a
15
15
  * prune right away (Storage Lens, behind usage-limits, updates daily).
16
+ * With the usage statement, the listing is a separate current-storage
17
+ * breakdown only; the statement's month-average storage meter is billed.
16
18
  * usage-limits also answers `vaults` when the server counts them; without it
17
19
  * the vault meter is `not-available`.
18
20
  * The call-count meters (syncs, gateway calls, HQ MCP calls, agent runs) have
@@ -45,6 +47,16 @@ export interface MeterReading {
45
47
  /** In billed units (GB for storage). Null unless measured. */
46
48
  value: number | null;
47
49
  state: MeterState;
50
+ /**
51
+ * The server's own priced amounts for this line (usage statement only).
52
+ * When present they are the bill: the statement is what is invoiced to the
53
+ * cent, so the line is never re-priced from the rate card.
54
+ */
55
+ accrued?: {
56
+ monthToDateUsd: number;
57
+ projectedUsd: number;
58
+ waived?: boolean;
59
+ };
48
60
  }
49
61
  export interface StorageSplit {
50
62
  /** Bytes in current files. */
@@ -60,7 +72,7 @@ export interface StorageSplit {
60
72
  export interface MonthUsage {
61
73
  scope: UsageScope;
62
74
  /** Which source answered; lets a reader tell interim numbers from the rollup. */
63
- source: "usage-limits" | "daily-rollup";
75
+ source: "usage-statement" | "usage-limits" | "daily-rollup";
64
76
  /** Month-to-date window, UTC. */
65
77
  period: {
66
78
  start: string;
@@ -71,6 +83,8 @@ export interface MonthUsage {
71
83
  storage?: StorageSplit;
72
84
  /** True when the account's usage trial is running (meters waived in trial bill $0). Absent = unknown. */
73
85
  trialActive?: boolean;
86
+ /** The included usage the usage statement applied. Absent: use the rate card's. */
87
+ allowanceUsd?: number;
74
88
  }
75
89
  /** One way to read a month of usage. The rollup becomes a second implementation. */
76
90
  export interface MonthUsageSource {
@@ -119,6 +133,14 @@ export declare function meterReadingsFromDailyRows(rows: readonly UsageDailyRowL
119
133
  * {@link readMonthUsage} once that route ships; the company scope only.
120
134
  */
121
135
  export declare function dailyRollupSource(fetchRows: (scope: UsageScope, from: string, to: string) => Promise<readonly UsageDailyRowLike[] | null>): MonthUsageSource;
136
+ export declare function meterReadingsFromStatement(body: unknown): MeterReading[] | null;
137
+ /**
138
+ * The usage statement as a source (`GET /v1/billing/usage`, US-015). The
139
+ * company statement is owner-gated on the server; a person statement is the
140
+ * caller's own. `fetchStatement` resolves the decoded body, or null when the
141
+ * server did not answer.
142
+ */
143
+ export declare function usageStatementSource(fetchStatement: (scope: UsageScope) => Promise<unknown | null>): MonthUsageSource;
122
144
  /**
123
145
  * The one function every caller uses to read a month of usage. Switch the
124
146
  * default source here when the US-005 rollup has a read route.
@@ -151,7 +173,7 @@ export interface UsageBill {
151
173
  daysElapsed: number;
152
174
  daysInMonth: number;
153
175
  }
154
- export declare function computeBill(usage: MonthUsage, rates?: readonly UsageMeterRate[], allowanceUsd?: number): UsageBill;
176
+ export declare function computeBill(usage: MonthUsage, rates?: readonly UsageMeterRate[], cardAllowanceUsd?: number): UsageBill;
155
177
  export type FixId = "prune-old-versions" | "remove-unused-secrets" | "exclude-folders" | "turn-sync-off";
156
178
  /**
157
179
  * A fix is a pair of command lists. `preview` only reads (dry runs, listings);
@@ -13,6 +13,8 @@
13
13
  * - optionally, the live cloud file-history listing `hq storage status`
14
14
  * uses, to split storage into current files and old versions and to see a
15
15
  * prune right away (Storage Lens, behind usage-limits, updates daily).
16
+ * With the usage statement, the listing is a separate current-storage
17
+ * breakdown only; the statement's month-average storage meter is billed.
16
18
  * usage-limits also answers `vaults` when the server counts them; without it
17
19
  * the vault meter is `not-available`.
18
20
  * The call-count meters (syncs, gateway calls, HQ MCP calls, agent runs) have
@@ -145,6 +147,83 @@ export function dailyRollupSource(fetchRows) {
145
147
  },
146
148
  };
147
149
  }
150
+ /** Statement line key (hq-pro `UsageStatementLine.key`) -> rate-card key. */
151
+ const STATEMENT_KEYS = new Set(BUNDLED_USAGE_METER_RATES.map(({ key }) => key));
152
+ /**
153
+ * Meter readings from a `GET /v1/billing/usage` statement body (hq-pro
154
+ * src/billing/usage-statement.ts, US-006). `usage` is the natural amount:
155
+ * counters are the month's total, gauges the mean daily level. Storage comes
156
+ * in bytes and is converted with the statement's own `storageBytesPerGb`.
157
+ * A line that is unavailable, not billed yet, or missing is `not-available`,
158
+ * never zero. Null when the body is not a statement.
159
+ */
160
+ function centsField(v) {
161
+ return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null;
162
+ }
163
+ export function meterReadingsFromStatement(body) {
164
+ const rec = body !== null && typeof body === "object" && !Array.isArray(body)
165
+ ? body
166
+ : null;
167
+ if (!rec || !Array.isArray(rec.lines))
168
+ return null;
169
+ const bytesPerGb = typeof rec.storageBytesPerGb === "number" && rec.storageBytesPerGb > 0
170
+ ? rec.storageBytesPerGb
171
+ : BYTES_PER_GB;
172
+ const byKey = new Map();
173
+ for (const raw of rec.lines) {
174
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
175
+ continue;
176
+ const line = raw;
177
+ if (typeof line.key !== "string" || !STATEMENT_KEYS.has(line.key))
178
+ continue;
179
+ const key = line.key;
180
+ const measured = line.status === "measured" || line.status === "partial";
181
+ const usage = line.usage;
182
+ if (!measured || typeof usage !== "number" || !Number.isFinite(usage) || usage < 0) {
183
+ byKey.set(key, { key, value: null, state: "not-available" });
184
+ continue;
185
+ }
186
+ const reading = { key, value: key === "storage" ? usage / bytesPerGb : usage, state: "measured" };
187
+ const cost = centsField(line.costCents);
188
+ const projected = centsField(line.projectedCostCents);
189
+ if (cost !== null && projected !== null) {
190
+ reading.accrued = {
191
+ monthToDateUsd: cost / 100,
192
+ projectedUsd: projected / 100,
193
+ ...(line.waived === true ? { waived: true } : {}),
194
+ };
195
+ }
196
+ byKey.set(key, reading);
197
+ }
198
+ return BUNDLED_USAGE_METER_RATES.map(({ key }) => byKey.get(key) ?? { key, value: null, state: "not-available" });
199
+ }
200
+ /**
201
+ * The usage statement as a source (`GET /v1/billing/usage`, US-015). The
202
+ * company statement is owner-gated on the server; a person statement is the
203
+ * caller's own. `fetchStatement` resolves the decoded body, or null when the
204
+ * server did not answer.
205
+ */
206
+ export function usageStatementSource(fetchStatement) {
207
+ return {
208
+ name: "usage-statement",
209
+ async read(scope, now) {
210
+ const { start, end } = monthWindow(now);
211
+ const body = await fetchStatement(scope);
212
+ const meters = meterReadingsFromStatement(body);
213
+ if (meters === null) {
214
+ throw new Error("Could not read usage from HQ right now. Try again, or run `hq login` if you are signed out.");
215
+ }
216
+ const allowanceCents = centsField(body.allowanceCents);
217
+ return {
218
+ scope,
219
+ source: "usage-statement",
220
+ period: { start: start.toISOString(), end: end.toISOString(), asOf: now.toISOString() },
221
+ meters,
222
+ ...(allowanceCents !== null ? { allowanceUsd: allowanceCents / 100 } : {}),
223
+ };
224
+ },
225
+ };
226
+ }
148
227
  /**
149
228
  * The one function every caller uses to read a month of usage. Switch the
150
229
  * default source here when the US-005 rollup has a read route.
@@ -159,8 +238,15 @@ export async function readMonthUsage(scope, deps, source = usageLimitsSource(dep
159
238
  const split = await deps.readStorageSplit(scope).catch(() => null);
160
239
  if (!split)
161
240
  return base;
162
- // The live listing is fresher than the daily Storage Lens figure, so a prune
163
- // shows up in the very next read.
241
+ // An accrued source (the usage statement, the daily rollup) bills storage as
242
+ // the month's average. That meter is authoritative: the live listing is a
243
+ // point-in-time figure, kept only as the separate `storage` breakdown that
244
+ // explains what is taking space. It never replaces or re-prices the meter.
245
+ if (base.source !== "usage-limits")
246
+ return { ...base, storage: split };
247
+ // usage-limits storage is itself a point-in-time reading (Storage Lens,
248
+ // updated daily). The live listing measures the same thing more freshly, so
249
+ // a prune shows up in the very next read.
164
250
  const liveGb = (split.currentBytes + split.oldVersionBytes) / BYTES_PER_GB;
165
251
  return {
166
252
  ...base,
@@ -169,7 +255,8 @@ export async function readMonthUsage(scope, deps, source = usageLimitsSource(dep
169
255
  };
170
256
  }
171
257
  const round2 = (n) => Math.round(n * 100) / 100;
172
- export function computeBill(usage, rates = BUNDLED_USAGE_METER_RATES, allowanceUsd = USAGE_MONTHLY_ALLOWANCE_USD) {
258
+ export function computeBill(usage, rates = BUNDLED_USAGE_METER_RATES, cardAllowanceUsd = USAGE_MONTHLY_ALLOWANCE_USD) {
259
+ const allowanceUsd = usage.allowanceUsd ?? cardAllowanceUsd;
173
260
  const asOf = new Date(usage.period.asOf);
174
261
  const { start, end } = monthWindow(asOf);
175
262
  const dayMs = 86_400_000;
@@ -187,6 +274,19 @@ export function computeBill(usage, rates = BUNDLED_USAGE_METER_RATES, allowanceU
187
274
  incomplete = true;
188
275
  return { ...pick(rate), value: null, state: reading.state, monthToDateUsd: null, projectedUsd: null };
189
276
  }
277
+ if (reading.accrued) {
278
+ const { monthToDateUsd: m, projectedUsd: p, waived } = reading.accrued;
279
+ mtd += m;
280
+ projected += p;
281
+ return {
282
+ ...pick(rate),
283
+ value: reading.value,
284
+ state: "measured",
285
+ monthToDateUsd: round2(m),
286
+ projectedUsd: round2(p),
287
+ ...(waived ? { waivedInTrial: true } : {}),
288
+ };
289
+ }
190
290
  if (rate.waivedInTrial && usage.trialActive === true) {
191
291
  return { ...pick(rate), value: reading.value, state: "measured", monthToDateUsd: 0, projectedUsd: 0, waivedInTrial: true };
192
292
  }