@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
package/CHANGELOG.md CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.345.160] — 2026-10-22
6
+ - The bundled sync engine now uses hq-cloud 6.18.98. When HQ pauses an account because of usage, `hq sync` now shows the server's usage-pause message and a Billing link instead of a generic failure (hq-cloud #1036).
7
+ - `hq billing usage` now shows the amounts on HQ's usage statement, which is what the invoice bills. Before this it re-priced the meters itself, so it could show a different figure than the statement, for example charging the vault fee during the trial when the statement waived it.
8
+ - When HQ pauses an account because of usage (spend limit reached, card needed, or trial cap hit), `hq` now shows the server's own message and a Billing link, with no plan-limit or Upgrade wording and no old plan names (Starter, Workforce, Individual). This covers sync, skills, agent enroll, meetings, members, secrets, integrations and remote databases. `hq billing upgrade` now opens the usage-pricing switch page instead of a Workforce checkout. `hq billing usage` now shows the company's usage view, and tells a member to use `--personal` for their own usage. Plan and trial reads use the server's shared plan status when it is available, and older servers keep working as before. (US-024)
9
+
10
+ - `hq files acl` and `hq access` show a path whose most-specific ACL row has no grants and is not open as locked by that row (`lockedBy` in `--json`), instead of as inheriting a broader `*` grant. An open empty row is not a lock: broader grants still apply. An empty ancestor row, whose open flag the server does not report, reads as locked unless that row is open.
11
+ - `hq billing upgrade` no longer dead-ends on the closed HQ Workforce checkout. When hq-pro answers that Workforce is closed to the company (`workforce_closed_to_new_signups`), the command explains that the company stays on Starter unless it switches to usage pricing, says to add a card first with `hq billing checkout`, and opens the console's usage-switch page for the company (`--no-browser` prints it; `--json` returns `{ url, legacyPlansClosed: true, message }`). The open-checkout line no longer quotes a plan price. The over-limit notice (`hq whoami`) and the plan-limit refusal printed by `hq secrets`, `hq members` and sync now read hq-pro's `legacyPlansClosed` field: while Workforce is closed they offer "Switch to usage pricing" with the switch link and never the free Workforce month or an "Upgrade to HQ Workforce" line. Older servers keep today's wording.
12
+ - `hq-hook` gains the US-021 bridge used by master-hook.sh: `hq-hook bridge-plan`
13
+ lists the registry hooks whose `hook-engine-port-<id>` flag is `shadow` or
14
+ `on`, `hq-hook bridge <id>` runs one hook natively (shadow compares with bash
15
+ and returns the bash result; on returns the native result; a missing port or
16
+ a declined payload falls back to bash), and `hq-hook --self-check` reports the
17
+ engine version and bridge schema. The local kill switch `HQ_HOOK_ENGINE=off`
18
+ or the file `~/.hq/hook-engine.off` forces bash before any flag is read.
19
+ Shadow comparisons are appended to `~/.hq/logs/hook-engine.jsonl` with hook
20
+ ids, hashes, exit codes and timings only.
21
+ - `hq-hook run derive-trigger-facts` and `hq-hook run eval-trigger` are native
22
+ ports of the two policy-trigger helpers (US-008, first slice), byte-identical
23
+ to the bundled shell on the parity corpus, with git facts read in-process.
24
+ - `hq core hook-engine-path` now runs the engine's `--self-check` before
25
+ printing its path, and writes `~/.hq/bin/hq-hook/current` so hooks can find
26
+ the pinned engine without starting Node. A binary that fails its self-check
27
+ exits non-zero and removes that pointer, keeping bash hooks active.
28
+
5
29
  ## [5.345.159] — 2026-10-22
6
30
  - `hq lanes link read` and `wait` now require the selected conductor session to own the requested link, preventing accidental cross-link report consumption while preserving explicit session resume/delegation and child inbox access.
7
31
  - `hq lanes resume` now uses the canonical lane ID after resolving a short ID. When low disk space queues a resume, it keeps the requested instruction for later admission and reports the lane as queued rather than started.
@@ -1393,7 +1393,7 @@ export declare const COMMAND_CATALOG: readonly [{
1393
1393
  readonly hidden: false;
1394
1394
  }, {
1395
1395
  readonly flags: "--company <slug>";
1396
- readonly description: "Also report this company's workspace plan state (Starter nag)";
1396
+ readonly description: "Also report this company's billing and usage notice";
1397
1397
  readonly hidden: false;
1398
1398
  }];
1399
1399
  readonly subcommands: readonly [];
@@ -8478,7 +8478,7 @@ export declare const COMMAND_CATALOG: readonly [{
8478
8478
  readonly subcommands: readonly [];
8479
8479
  }, {
8480
8480
  readonly name: "upgrade";
8481
- readonly description: "Open Stripe Checkout to upgrade a company on an earlier plan (usage-pricing companies have no plan to upgrade)";
8481
+ readonly description: "Open the switch to usage pricing for a company on an earlier plan (usage-pricing companies have nothing to change)";
8482
8482
  readonly aliases: readonly [];
8483
8483
  readonly hidden: false;
8484
8484
  readonly helpOption: true;
@@ -8492,11 +8492,11 @@ export declare const COMMAND_CATALOG: readonly [{
8492
8492
  readonly hidden: false;
8493
8493
  }, {
8494
8494
  readonly flags: "--no-browser";
8495
- readonly description: "Print the Stripe Checkout URL instead of opening it";
8495
+ readonly description: "Print the URL instead of opening it";
8496
8496
  readonly hidden: false;
8497
8497
  }, {
8498
8498
  readonly flags: "--json";
8499
- readonly description: "Emit the Checkout URL as JSON";
8499
+ readonly description: "Emit the URL as JSON";
8500
8500
  readonly hidden: false;
8501
8501
  }];
8502
8502
  readonly subcommands: readonly [];
@@ -1674,7 +1674,7 @@ export const COMMAND_CATALOG = [
1674
1674
  },
1675
1675
  {
1676
1676
  "flags": "--company <slug>",
1677
- "description": "Also report this company's workspace plan state (Starter nag)",
1677
+ "description": "Also report this company's billing and usage notice",
1678
1678
  "hidden": false
1679
1679
  }
1680
1680
  ],
@@ -10339,7 +10339,7 @@ export const COMMAND_CATALOG = [
10339
10339
  },
10340
10340
  {
10341
10341
  "name": "upgrade",
10342
- "description": "Open Stripe Checkout to upgrade a company on an earlier plan (usage-pricing companies have no plan to upgrade)",
10342
+ "description": "Open the switch to usage pricing for a company on an earlier plan (usage-pricing companies have nothing to change)",
10343
10343
  "aliases": [],
10344
10344
  "hidden": false,
10345
10345
  "helpOption": true,
@@ -10355,12 +10355,12 @@ export const COMMAND_CATALOG = [
10355
10355
  },
10356
10356
  {
10357
10357
  "flags": "--no-browser",
10358
- "description": "Print the Stripe Checkout URL instead of opening it",
10358
+ "description": "Print the URL instead of opening it",
10359
10359
  "hidden": false
10360
10360
  },
10361
10361
  {
10362
10362
  "flags": "--json",
10363
- "description": "Emit the Checkout URL as JSON",
10363
+ "description": "Emit the URL as JSON",
10364
10364
  "hidden": false
10365
10365
  }
10366
10366
  ],
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import { Command } from "commander";
17
17
  import { type CompanyBrowseClientFactory, type FilesBrowseVaultClient, type RunGetResult } from "./files-browse.js";
18
+ import { type AclLock } from "./acl-lock.js";
18
19
  export type AccessOutcome = "never-existed" | "local" | "not-synced" | "no-access" | "ambiguous" | "pending-confirmation";
19
20
  export interface AccessAclTree {
20
21
  prefix: string;
@@ -145,6 +146,11 @@ export interface RunAccessResult {
145
146
  requestSentAt?: string;
146
147
  alreadyAskedAt?: string;
147
148
  requestNote?: string;
149
+ /**
150
+ * Most-specific ACL row covering the path when it has no grants. Broader
151
+ * grants (including a root `*` company-wide grant) do not apply.
152
+ */
153
+ lockedBy?: AclLock;
148
154
  }
149
155
  export interface AccessAsResult {
150
156
  personUid: string;
@@ -23,6 +23,7 @@ import { getCompanyUid, vaultApiFetch, resolveCallerUid } from "../utils/vault-a
23
23
  import { parseCompanySlugFromPath, classifyAclSource, resolveBrowseSession, makeCompanyPresignFactory, runGet, toBucketRelative, } from "./files-browse.js";
24
24
  import { listJournals, syncDoctor, } from "@indigoai-us/hq-cloud";
25
25
  import { listActiveMembers } from "./members.js";
26
+ import { aclLockedBy } from "./acl-lock.js";
26
27
  import { buildDmBody } from "./dm.js";
27
28
  import { peekIdToken } from "../utils/id-token.js";
28
29
  import { findRecentAccessRequest, formatTimeAgo, recordAccessRequest, } from "../utils/access-requests.js";
@@ -458,10 +459,23 @@ async function completeTarget(input, deps, located) {
458
459
  const { resolvedKey, directory, vendDenied, listDenied, verdict, steps } = located;
459
460
  const resolvedPath = `companies/${slug}/${resolvedKey}`;
460
461
  let noAccess = vendDenied || listDenied;
462
+ // Name the empty fencing row only when the tree denies access. A role
463
+ // bypass or creator grant still reads the same as the acl/resolve fast
464
+ // path, which never fetches the tree.
465
+ const lockRow = verdict.kind === "tree" && aclDenied(verdict.tree) ? aclLockedBy(verdict.tree) : null;
466
+ const lockedBy = lockRow
467
+ ? { ...lockRow, path: `companies/${slug}/${toBucketRelative(lockRow.path, slug)}` }
468
+ : undefined;
469
+ if (lockedBy) {
470
+ steps.push(lockedBy.readUnknown
471
+ ? `acl: locked by ${lockedBy.path} unless that row is open (it has no grants; broader grants such as * apply only if it is open)`
472
+ : `acl: locked by ${lockedBy.path} (that row has no grants and is not open; broader grants such as * do not apply)`);
473
+ }
461
474
  if (verdict.kind === "resolved") {
462
475
  steps.push(`acl: ${verdict.permission}`);
463
476
  }
464
- else if (verdict.tree == null || treeIsEmpty(verdict.tree)) {
477
+ else if (verdict.tree == null ||
478
+ (treeIsEmpty(verdict.tree) && !verdict.tree.coveringAclPath)) {
465
479
  steps.push("acl: no record (membership-distributed)");
466
480
  }
467
481
  else if (aclDenied(verdict.tree)) {
@@ -687,6 +701,7 @@ async function completeTarget(input, deps, located) {
687
701
  ...(requestSentAt ? { requestSentAt } : {}),
688
702
  ...(alreadyAskedAt ? { alreadyAskedAt } : {}),
689
703
  ...(requestNote ? { requestNote } : {}),
704
+ ...(lockedBy ? { lockedBy } : {}),
690
705
  }, finishCtx);
691
706
  return result;
692
707
  }
@@ -834,6 +849,8 @@ function jsonPayload(result) {
834
849
  payload.requestSentAt = result.requestSentAt;
835
850
  if (result.alreadyAskedAt)
836
851
  payload.alreadyAskedAt = result.alreadyAskedAt;
852
+ if (result.lockedBy)
853
+ payload.lockedBy = result.lockedBy;
837
854
  return payload;
838
855
  }
839
856
  function emit(input, result, log) {
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Empty-row lock detection for ACL tree responses (feedback dd287b55).
3
+ *
4
+ * The server resolves a path against its most-specific matching FILE_ACL row
5
+ * (`coveringAclPath` on the ACL tree response). Enforcement (files-presign,
6
+ * files-list, content) runs resolveEffectivePermission in hq-pro-core
7
+ * src/tenant-contracts/acl-permission.ts with `unionGrants: true`: grants on
8
+ * every matching row are unioned, except that an EMPTY, NON-OPEN winning row
9
+ * fences the path and grants nothing (only the row's creator and the
10
+ * owner/admin role bypass keep access). An OPEN empty winning row is not a
11
+ * fence: it floors every member at read and broader grants still apply.
12
+ */
13
+ export interface AclLockTree {
14
+ prefix: string;
15
+ direct: unknown[];
16
+ inherited: Array<{
17
+ sourcePrefix: string;
18
+ }>;
19
+ directRow: {
20
+ open: boolean;
21
+ } | null;
22
+ coveringAclPath?: string | null;
23
+ }
24
+ export interface AclLock {
25
+ /** Path of the empty most-specific row. */
26
+ path: string;
27
+ /**
28
+ * True when the row's `open` flag is unknown. The tree reports `open` only
29
+ * for the exact prefix row (directRow), not for ancestor rows. Such a row is
30
+ * a lock if it is not open; if it is open, broader grants still apply.
31
+ */
32
+ readUnknown: boolean;
33
+ }
34
+ /**
35
+ * The empty, non-open row that fences `tree.prefix`, or null when the
36
+ * most-specific row grants something, is open, or no row covers the path.
37
+ */
38
+ export declare function aclLockedBy(tree: AclLockTree | null | undefined): AclLock | null;
39
+ /** One-line description of the lock for human output. */
40
+ export declare function describeAclLock(lock: AclLock, display: string): string;
41
+ //# sourceMappingURL=acl-lock.d.ts.map
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The empty, non-open row that fences `tree.prefix`, or null when the
3
+ * most-specific row grants something, is open, or no row covers the path.
4
+ */
5
+ export function aclLockedBy(tree) {
6
+ const covering = tree?.coveringAclPath;
7
+ if (!tree || typeof covering !== "string" || covering === "")
8
+ return null;
9
+ if (covering === tree.prefix) {
10
+ if (!tree.directRow || tree.directRow.open === true)
11
+ return null;
12
+ if (!Array.isArray(tree.direct) || tree.direct.length > 0)
13
+ return null;
14
+ return { path: covering, readUnknown: false };
15
+ }
16
+ const inherited = Array.isArray(tree.inherited) ? tree.inherited : [];
17
+ if (inherited.some((entry) => entry.sourcePrefix === covering))
18
+ return null;
19
+ return { path: covering, readUnknown: true };
20
+ }
21
+ /** One-line description of the lock for human output. */
22
+ export function describeAclLock(lock, display) {
23
+ if (lock.readUnknown) {
24
+ return `Locked by ${display} unless that row is open: it has no grants, so grants on broader prefixes apply only if it is open.`;
25
+ }
26
+ return `Locked by ${display}: that ACL row has no grants and is not open, so grants on broader prefixes do not apply. Only the row's creator and the owner/admin role bypass have access.`;
27
+ }
28
+ //# sourceMappingURL=acl-lock.js.map
@@ -60,6 +60,8 @@ export declare function mapEnrollFailure(status: number, body: {
60
60
  attemptsRemaining?: unknown;
61
61
  upgradeUrl?: unknown;
62
62
  pricingPlan?: unknown;
63
+ usagePause?: unknown;
64
+ billingState?: unknown;
63
65
  }, headers?: {
64
66
  retryAfter?: string | null;
65
67
  }): EnrollError;
@@ -21,7 +21,7 @@ import * as path from "node:path";
21
21
  import { CLI_VERSION } from "../cli-version.js";
22
22
  import { DEFAULT_VAULT_API_URL, personTokenCacheFile, } from "../utils/cognito-session.js";
23
23
  import { HQ_CLIENT_NAME } from "../utils/vault-api.js";
24
- import { isUsageOnly, pricingPlanFromBody, usageOnlyCardRequiredLines, } from "../lib/billing/usage-only.js";
24
+ import { isUsageOnly, pricingPlanFromBody, usageOnlyCardRequiredLines, usagePauseFromBody, } from "../lib/billing/usage-only.js";
25
25
  import { queueCliLimitNoticeEngagement, queueCliLimitNoticeExposure, } from "../utils/cli-telemetry.js";
26
26
  import { networkTransportErrorCode } from "../utils/network-transport-error.js";
27
27
  import { canOfferTeamUpgrade, formatUpgradeRefusal, isPlanGateError, offerUpgradeLink, planGateErrorFromPayload, } from "../utils/plan-gate-error.js";
@@ -139,6 +139,11 @@ export function mapEnrollFailure(status, body, headers) {
139
139
  return new EnrollError(`This agent was revoked; its enrollment code no longer works. An admin must ` +
140
140
  `create a new external agent or rotate this one.${suffix}`, status, code);
141
141
  }
142
+ // US-024: a usage pause is the server's own answer: its message and link.
143
+ const usagePause = status === 402 ? usagePauseFromBody(body) : null;
144
+ if (usagePause) {
145
+ return new EnrollError(usagePause.message, status, code, usagePause.actionUrl);
146
+ }
142
147
  if ((status === 402 || code === "billing_required") &&
143
148
  isUsageOnly(pricingPlanFromBody(body))) {
144
149
  // Usage pricing (US-012): no paid plan to upgrade to. A company admin adds
@@ -146,8 +151,8 @@ export function mapEnrollFailure(status, body, headers) {
146
151
  return new EnrollError(usageOnlyCardRequiredLines("An external agent").join(" ") + suffix, status, code);
147
152
  }
148
153
  if (status === 402 || code === "billing_required") {
149
- return new EnrollError(`External agents are available on paid HQ plans only. A company admin must ` +
150
- `upgrade the plan (\`hq billing\` or the console billing page) before enrolling.${suffix}`, status, code, typeof body.upgradeUrl === "string" ? body.upgradeUrl : undefined);
154
+ return new EnrollError(`External agents need billing set up for this company. A company admin can ` +
155
+ `add a card with \`hq billing checkout\` or on the console Billing page, then enroll again.${suffix}`, status, code, typeof body.upgradeUrl === "string" ? body.upgradeUrl : undefined);
151
156
  }
152
157
  if (status === 429 || code === "rate_limited") {
153
158
  const retryAfter = headers?.retryAfter?.trim();
@@ -425,7 +430,7 @@ export function registerAgentEnrollCommand(agent) {
425
430
  }
426
431
  catch (openErr) {
427
432
  const message = openErr instanceof Error ? openErr.message : String(openErr);
428
- console.error(chalk.dim(`Could not open the upgrade link automatically: ${message}`));
433
+ console.error(chalk.dim(`Could not open the billing link automatically: ${message}`));
429
434
  }
430
435
  }
431
436
  }
@@ -131,12 +131,6 @@ export function agentCreatePlanLimitFromPayload(status, body) {
131
131
  : {}),
132
132
  };
133
133
  }
134
- function formatServerPrice(amountMinor, currency) {
135
- return new Intl.NumberFormat("en-US", {
136
- style: "currency",
137
- currency,
138
- }).format(amountMinor / 100);
139
- }
140
134
  async function offerAgentCheckout(url) {
141
135
  const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
142
136
  try {
@@ -146,7 +140,7 @@ async function offerAgentCheckout(url) {
146
140
  });
147
141
  }
148
142
  catch {
149
- console.error("Could not open the upgrade page automatically. Use the URL above.");
143
+ console.error("Could not open the billing page automatically. Use the URL above.");
150
144
  return false;
151
145
  }
152
146
  finally {
@@ -156,7 +150,7 @@ async function offerAgentCheckout(url) {
156
150
  /** Print a server-priced plan block without minting or rewriting its URL. */
157
151
  export async function surfaceAgentCreatePlanLimit(planLimit, opts = {}) {
158
152
  if (isUsageOnly(opts.pricingPlan)) {
159
- // Usage pricing (US-012): no Workforce plan to buy. The company adds a
153
+ // Usage pricing (US-012): no flat plan to buy. The company adds a
160
154
  // card and each agent box bills by size from the usage card.
161
155
  const card = opts.usageCard ?? BUNDLED_USAGE_CARD;
162
156
  for (const line of usageOnlyCardRequiredLines("An agent box", card)) {
@@ -166,13 +160,13 @@ export async function surfaceAgentCreatePlanLimit(planLimit, opts = {}) {
166
160
  queueCliLimitNoticeExposure("agents", "refusal", opts.companyUid);
167
161
  return;
168
162
  }
169
- const price = `${formatServerPrice(planLimit.amountMinor, planLimit.currency)}/month`;
170
- const reason = `HQ Workforce plan required to create an agent (${price}).`;
163
+ // The server's price was the flat plan's, which is closed: name no plan or price.
164
+ const reason = "Agents are not included on this company's current billing.";
171
165
  const argv = opts.json ? [...process.argv, "--json"] : process.argv;
172
166
  const interactive = canOfferTeamUpgrade(argv);
173
167
  if (!planLimit.checkoutUrl) {
174
168
  console.error(reason);
175
- console.error("Ask a company owner to upgrade before creating an agent.");
169
+ console.error("Ask a company owner to check the company's Billing page before creating an agent.");
176
170
  queueCliLimitNoticeExposure("agents", "refusal", opts.companyUid);
177
171
  return;
178
172
  }
@@ -2,9 +2,9 @@
2
2
  * `hq billing sponsor` — the individual-pull half of plan sponsorship, from the
3
3
  * terminal ("ask a company to sponsor me", hq-pro #3609).
4
4
  *
5
- * A person who is over a personal limit has exactly three ways out: pay $50
6
- * themselves, reduce what they store, or ask a company to cover the $50 plan
7
- * line. This command is the third one, plus the admin side that answers it.
5
+ * A person who is over a personal limit has exactly three ways out: pay for
6
+ * the personal plan themselves, reduce what they store, or ask a company to
7
+ * cover it. This command is the third one, plus the admin side that answers it.
8
8
  *
9
9
  * hq billing sponsor request <company> — ask that company to cover my plan
10
10
  * hq billing sponsor list — the queue (admins), or my own
@@ -36,7 +36,7 @@ export declare const PLAN_RESOURCE: "plan";
36
36
  /** What a person is told when hq-pro's plan-sponsorship switch is off. */
37
37
  export declare const FEATURE_OFF_MESSAGE = "Asking a company to sponsor your plan isn't available yet.";
38
38
  /** What v1 sponsorship covers, stated wherever a person confirms or reads it. */
39
- export declare const COVERAGE_SENTENCE = "Your company pays your $50 plan. Storage above your plan's allowance is still yours.";
39
+ export declare const COVERAGE_SENTENCE = "Your company pays your personal plan. Storage above your plan's allowance is still yours.";
40
40
  /** What a person is told while an approval waits on the sponsor's card. */
41
41
  export declare const HELD_FOR_CARD_SENTENCE = "Your company said yes and is adding a card. Your limits still apply until then.";
42
42
  /** One request row as hq-pro persists and returns it. */
@@ -2,9 +2,9 @@
2
2
  * `hq billing sponsor` — the individual-pull half of plan sponsorship, from the
3
3
  * terminal ("ask a company to sponsor me", hq-pro #3609).
4
4
  *
5
- * A person who is over a personal limit has exactly three ways out: pay $50
6
- * themselves, reduce what they store, or ask a company to cover the $50 plan
7
- * line. This command is the third one, plus the admin side that answers it.
5
+ * A person who is over a personal limit has exactly three ways out: pay for
6
+ * the personal plan themselves, reduce what they store, or ask a company to
7
+ * cover it. This command is the third one, plus the admin side that answers it.
8
8
  *
9
9
  * hq billing sponsor request <company> — ask that company to cover my plan
10
10
  * hq billing sponsor list — the queue (admins), or my own
@@ -41,7 +41,7 @@ export const PLAN_RESOURCE = "plan";
41
41
  /** What a person is told when hq-pro's plan-sponsorship switch is off. */
42
42
  export const FEATURE_OFF_MESSAGE = "Asking a company to sponsor your plan isn't available yet.";
43
43
  /** What v1 sponsorship covers, stated wherever a person confirms or reads it. */
44
- export const COVERAGE_SENTENCE = "Your company pays your $50 plan. Storage above your plan's allowance is still yours.";
44
+ export const COVERAGE_SENTENCE = "Your company pays your personal plan. Storage above your plan's allowance is still yours.";
45
45
  /** What a person is told while an approval waits on the sponsor's card. */
46
46
  export const HELD_FOR_CARD_SENTENCE = "Your company said yes and is adding a card. Your limits still apply until then.";
47
47
  /**
@@ -85,7 +85,7 @@ export function messageForError(status, body) {
85
85
  if (status === 401)
86
86
  return "Your sign-in has expired. Run `hq login` and try again.";
87
87
  if (status === 403 && code === "SPONSOR_PLAN_REQUIRED") {
88
- return "That company's plan can't cover an individual plan. Only HQ Workforce and Enterprise companies can.";
88
+ return "That company can't cover a personal plan on its current billing. Ask an owner of that company to check its Billing page.";
89
89
  }
90
90
  if (status === 403) {
91
91
  return "You're not allowed to do that. Only an owner or admin of the company can answer a sponsorship request.";
@@ -209,7 +209,7 @@ export function registerBillingSponsorCommands(billing) {
209
209
  process.stdout.write(JSON.stringify(data.request, null, 2) + "\n");
210
210
  return;
211
211
  }
212
- console.log(`Asked ${company} to cover your $50 HQ plan.`);
212
+ console.log(`Asked ${company} to cover your personal HQ plan.`);
213
213
  console.log(COVERAGE_SENTENCE);
214
214
  console.log(`An owner or admin there answers next. Check with \`hq billing sponsor list\`.`);
215
215
  }
@@ -276,11 +276,11 @@ export function registerBillingSponsorCommands(billing) {
276
276
  }
277
277
  const where = named ?? "that company";
278
278
  if (!state.active) {
279
- console.log(`No company is covering your $50 HQ plan right now.`);
279
+ console.log(`No company is covering your personal HQ plan right now.`);
280
280
  console.log(`If you asked ${where}, they haven't answered yet — an owner or admin there answers next.`);
281
281
  return;
282
282
  }
283
- console.log(`Your $50 HQ plan: ${statusLabel(state.status ?? "active")}`);
283
+ console.log(`Your personal HQ plan: ${statusLabel(state.status ?? "active")}`);
284
284
  if (state.requestId)
285
285
  console.log(` ${chalk.bold(state.requestId)}`);
286
286
  console.log(COVERAGE_SENTENCE);
@@ -328,7 +328,7 @@ export function registerBillingSponsorCommands(billing) {
328
328
  }
329
329
  console.log((await companyIsUsageOnly(result.companyUid))
330
330
  ? "Approved. Their personal plan is now on the company bill."
331
- : "Approved. Their $50 plan is now on the company bill.");
331
+ : "Approved. Their personal plan is now on the company bill.");
332
332
  }
333
333
  catch (err) {
334
334
  fail(err);
@@ -370,7 +370,7 @@ export function registerBillingSponsorCommands(billing) {
370
370
  }
371
371
  console.log((await companyIsUsageOnly(result.companyUid))
372
372
  ? "Ended. Their personal plan goes back to them from the next bill."
373
- : "Ended. Their $50 plan goes back to them from the next bill.");
373
+ : "Ended. Their personal plan goes back to them from the next bill.");
374
374
  }
375
375
  catch (err) {
376
376
  fail(err);
@@ -396,7 +396,7 @@ export function registerBillingSponsorCommands(billing) {
396
396
  console.log(usageOnlySponsorLines()[2]);
397
397
  return;
398
398
  }
399
- console.log("Withdrawn. You can pay the $50 yourself, reduce what you store, or ask another company.");
399
+ console.log("Withdrawn. You can pay for your plan yourself, reduce what you store, or ask another company.");
400
400
  }
401
401
  catch (err) {
402
402
  fail(err);
@@ -12,18 +12,18 @@
12
12
  *
13
13
  * Tenant boundary: the company comes from the caller's own active memberships
14
14
  * (`/membership/me`); a slug outside them is refused before any read. The
15
- * usage-limits routes are member-gated on the server as well. With no cloud
15
+ * company statement is owner-gated on the server as well. With no cloud
16
16
  * company (or `--personal`) it reads the caller's personal scope.
17
17
  *
18
- * Usage numbers come through `readMonthUsage` (src/lib/billing/usage-cost.ts),
19
- * the single seam to switch to the US-005 daily rollup once it has a read route.
18
+ * Usage numbers come through `readMonthUsage` (src/lib/billing/usage-cost.ts)
19
+ * from the usage statement, `GET /v1/billing/usage` (hq-pro US-006). Only a
20
+ * server without that route (404) falls back to the usage-limits readings.
20
21
  *
21
22
  * Only an account on usage pricing gets costs. The plan comes from the
22
- * server: `pricingPlan` / `usageOnly` on the usage-limits body (hq-pro #5042,
23
- * computed with `isOnUsagePricing`). Only when that body does not say, the
24
- * billing summary the command already reads for the trial. A flat-plan account (for example a Workforce
25
- * company that subscribed before the 2026-10-16 cutover) and an account the
26
- * server says nothing about get readings only, with no rate, cost or
23
+ * server's shared plan status (`billingState`, hq-pro #5088) on the billing
24
+ * summary, with the legacy `pricingPlan` / `usageOnly` fields for a server
25
+ * that does not send it. An account on an earlier flat plan and an account
26
+ * the server says nothing about get readings only, with no rate, cost or
27
27
  * projected bill.
28
28
  */
29
29
  import type { Command } from "commander";
@@ -34,8 +34,19 @@ export interface MembershipRef {
34
34
  slug: string;
35
35
  role?: string;
36
36
  }
37
+ /** One `GET /v1/billing/usage` answer: the HTTP status and the decoded body. */
38
+ export interface UsageStatementResponse {
39
+ status: number;
40
+ body: unknown | null;
41
+ }
37
42
  export interface BillingUsageDeps {
38
43
  listCompanies(): Promise<MembershipRef[]>;
44
+ /**
45
+ * `GET /v1/billing/usage` for the scope (US-015). Absent, or a 404 from a
46
+ * server without the route, falls back to {@link fetchUsageLimits}.
47
+ */
48
+ fetchUsageStatement?: (scope: UsageScope) => Promise<UsageStatementResponse>;
49
+ /** Legacy readings for a server without the statement route. */
39
50
  fetchUsageLimits: ReadMonthUsageDeps["fetchUsageLimits"];
40
51
  readStorageSplit?: (company: MembershipRef, opts: {
41
52
  fresh: boolean;
@@ -77,6 +88,9 @@ export declare function pricingPlanFromSummary(body: unknown): PricingPlan | nul
77
88
  * only when usage-limits does not say.
78
89
  */
79
90
  export declare function resolveOnUsagePricing(limitsPlan: PricingPlan | null, summaryPlan: PricingPlan | null): boolean | null;
91
+ /** Thrown when the company statement is refused to a caller who is not its owner. */
92
+ export declare class UsageStatementForbiddenError extends Error {
93
+ }
80
94
  export declare function runBillingUsage(opts: BillingUsageOptions, deps: BillingUsageDeps): Promise<BillingUsageReport>;
81
95
  /**
82
96
  * `hq billing usage --card`: the card from GET /v1/pricing, or the bundled
@@ -12,27 +12,27 @@
12
12
  *
13
13
  * Tenant boundary: the company comes from the caller's own active memberships
14
14
  * (`/membership/me`); a slug outside them is refused before any read. The
15
- * usage-limits routes are member-gated on the server as well. With no cloud
15
+ * company statement is owner-gated on the server as well. With no cloud
16
16
  * company (or `--personal`) it reads the caller's personal scope.
17
17
  *
18
- * Usage numbers come through `readMonthUsage` (src/lib/billing/usage-cost.ts),
19
- * the single seam to switch to the US-005 daily rollup once it has a read route.
18
+ * Usage numbers come through `readMonthUsage` (src/lib/billing/usage-cost.ts)
19
+ * from the usage statement, `GET /v1/billing/usage` (hq-pro US-006). Only a
20
+ * server without that route (404) falls back to the usage-limits readings.
20
21
  *
21
22
  * Only an account on usage pricing gets costs. The plan comes from the
22
- * server: `pricingPlan` / `usageOnly` on the usage-limits body (hq-pro #5042,
23
- * computed with `isOnUsagePricing`). Only when that body does not say, the
24
- * billing summary the command already reads for the trial. A flat-plan account (for example a Workforce
25
- * company that subscribed before the 2026-10-16 cutover) and an account the
26
- * server says nothing about get readings only, with no rate, cost or
23
+ * server's shared plan status (`billingState`, hq-pro #5088) on the billing
24
+ * summary, with the legacy `pricingPlan` / `usageOnly` fields for a server
25
+ * that does not send it. An account on an earlier flat plan and an account
26
+ * the server says nothing about get readings only, with no rate, cost or
27
27
  * projected bill.
28
28
  */
29
29
  import chalk from "chalk";
30
30
  import { ensureCognitoToken } from "../utils/cognito-session.js";
31
31
  import { vaultApiFetch, vaultApiFetchPublic } from "../utils/vault-api.js";
32
- import { buildReadingsReport, buildReport, formatReport, readMonthUsage, } from "../lib/billing/usage-cost.js";
32
+ import { buildReadingsReport, buildReport, formatReport, readMonthUsage, usageLimitsSource, usageStatementSource, } from "../lib/billing/usage-cost.js";
33
33
  import { ratesFromPricingStatement } from "../lib/billing/usage-rate-card.js";
34
34
  import { fetchUsageCard, formatUsageCardLines } from "../lib/billing/usage-card.js";
35
- import { fetchCompanyBillingSummary, pricingPlanFromBody, usageTrialActiveFromBody, } from "../lib/billing/usage-only.js";
35
+ import { fetchCompanyBillingSummary, pricingPlanFromBody, hasBillingStateField, usageTrialActiveFromBody, } from "../lib/billing/usage-only.js";
36
36
  export class UsageScopeError extends Error {
37
37
  }
38
38
  /** Pick the scope. Never reaches outside the caller's own memberships. */
@@ -67,7 +67,7 @@ function asRecord(value) {
67
67
  /** The plan on a usage-limits body: `pricingPlan`, else the `usageOnly` / `onUsagePricing` boolean. */
68
68
  export function pricingPlanFromUsageLimits(body) {
69
69
  const plan = pricingPlanFromBody(body);
70
- if (plan)
70
+ if (plan || hasBillingStateField(body))
71
71
  return plan;
72
72
  const rec = asRecord(body);
73
73
  const flag = typeof rec?.onUsagePricing === "boolean" ? rec.onUsagePricing : rec?.usageOnly;
@@ -85,9 +85,12 @@ export function pricingPlanFromSummary(body) {
85
85
  const rec = asRecord(body);
86
86
  if (!rec)
87
87
  return null;
88
- // `plan: "usage"` only means "no Workforce subscription", not usage pricing,
89
- // so it never turns pricing on. Without pricingPlan, show no cost.
90
- return pricingPlanFromBody(rec) ?? "current";
88
+ // `plan: "usage"` only means "no flat-plan subscription", not usage pricing,
89
+ // so it never turns pricing on. A server that sends billingState answers
90
+ // from it alone (unknown stays unknown); an older one without pricingPlan
91
+ // shows no cost.
92
+ const plan = pricingPlanFromBody(rec);
93
+ return hasBillingStateField(rec) ? plan : plan ?? "current";
91
94
  }
92
95
  /**
93
96
  * True: price the meters. False: flat plan. Null: unknown, show no cost.
@@ -101,20 +104,44 @@ export function resolveOnUsagePricing(limitsPlan, summaryPlan) {
101
104
  return null;
102
105
  return plan === "usage-only";
103
106
  }
107
+ /** Thrown when the company statement is refused to a caller who is not its owner. */
108
+ export class UsageStatementForbiddenError extends Error {
109
+ }
110
+ /**
111
+ * Read the statement, or say the caller should use the legacy readings: null
112
+ * means the server has no statement route (404) or no reader was given.
113
+ */
114
+ async function readStatementBody(scope, deps) {
115
+ if (!deps.fetchUsageStatement)
116
+ return null;
117
+ const res = await deps.fetchUsageStatement(scope);
118
+ if (res.status === 404)
119
+ return null;
120
+ if (res.status === 403 && scope.kind === "company") {
121
+ throw new UsageStatementForbiddenError(`Only the owner of '${scope.companySlug}' can see its usage statement. ` +
122
+ "Run `hq billing usage --personal` for your own usage.");
123
+ }
124
+ return { body: res.status >= 200 && res.status < 300 ? res.body : null };
125
+ }
104
126
  export async function runBillingUsage(opts, deps) {
105
127
  const memberships = await deps.listCompanies();
106
128
  const scope = resolveUsageScope(memberships, opts);
107
129
  const membership = scope.kind === "company" ? memberships.find((m) => m.uid === scope.companyUid) : undefined;
108
130
  let limitsBody = null;
131
+ const statement = await readStatementBody(scope, deps);
132
+ const usageDeps = {
133
+ fetchUsageLimits: async (s) => (limitsBody = await deps.fetchUsageLimits(s)),
134
+ now: deps.now,
135
+ ...(deps.readTrialActive ? { readTrialActive: deps.readTrialActive } : {}),
136
+ ...(opts.storageScan !== false && deps.readStorageSplit && membership
137
+ ? { readStorageSplit: () => deps.readStorageSplit(membership, { fresh: opts.fresh === true }) }
138
+ : {}),
139
+ };
140
+ const source = statement
141
+ ? usageStatementSource(async () => statement.body)
142
+ : usageLimitsSource(usageDeps);
109
143
  const [usage, pricing] = await Promise.all([
110
- readMonthUsage(scope, {
111
- fetchUsageLimits: async (s) => (limitsBody = await deps.fetchUsageLimits(s)),
112
- now: deps.now,
113
- ...(deps.readTrialActive ? { readTrialActive: deps.readTrialActive } : {}),
114
- ...(opts.storageScan !== false && deps.readStorageSplit && membership
115
- ? { readStorageSplit: () => deps.readStorageSplit(membership, { fresh: opts.fresh === true }) }
116
- : {}),
117
- }),
144
+ readMonthUsage(scope, usageDeps, source),
118
145
  deps.fetchPricing().catch(() => null),
119
146
  ]);
120
147
  const limitsPlan = pricingPlanFromUsageLimits(limitsBody);
@@ -142,6 +169,14 @@ function productionDeps() {
142
169
  const { companiesFromMemberships } = await import("./storage.js");
143
170
  return companiesFromMemberships(await res.json());
144
171
  },
172
+ async fetchUsageStatement(scope) {
173
+ const res = await vaultApiFetch(scope.kind === "company"
174
+ ? { token: await token(), path: "/v1/billing/usage", query: { companyUid: scope.companyUid } }
175
+ : { token: await token(), path: "/v1/billing/usage", query: { scope: "person" } });
176
+ if (!res.ok)
177
+ return { status: res.status, body: null };
178
+ return { status: res.status, body: await res.json().catch(() => null) };
179
+ },
145
180
  async fetchUsageLimits(scope) {
146
181
  const res = await vaultApiFetch(scope.kind === "company"
147
182
  ? { token: await token(), path: "/v1/billing/usage-limits", query: { companyUid: scope.companyUid } }