@indigoai-us/hq-cli 5.99.3 → 5.101.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.101.0] — 2026-08-13
6
+
7
+ ### Added
8
+
9
+ - `hq agents provision` now offers the authoritative Basic, Power, and Dev box
10
+ sizes during interactive creation and accepts `--size basic|power|dev` for
11
+ automation. The CLI shows HQ Pro's company-specific monthly quote and
12
+ capacity before confirmation, carries the quote version into creation so a
13
+ stale price is refused, and preserves the server default when `--size` is
14
+ omitted.
15
+
16
+ ## [5.100.0] — 2026-08-13
17
+
18
+ ### Added
19
+
20
+ - `hq core checkpoint` now ends every non-dry-run checkpoint — real writes and
21
+ `--idle` alike — with a REMINDER line telling the calling agent that the
22
+ checkpoint is invisible bookkeeping, not the user-facing reply, and that any
23
+ substance not yet reported to the user must be delivered before the turn ends
24
+ (#374). The reminder is emitted even when sibling startup fails, and the
25
+ bundled Stop-gate prompt was reworded to prescribe the same reply-first
26
+ ordering. Machine-parsed outputs (`--dry-run` JSON, `--gate-probe`) are
27
+ unchanged.
28
+
5
29
  ## [5.99.3] — 2026-08-13
6
30
 
7
31
  ### Fixed
@@ -327,7 +327,7 @@ set -uo pipefail
327
327
 
328
328
  # Built with printf rather than concatenation so the session id can appear in
329
329
  # both commands without re-splitting the message into fragments.
330
- reason="$(printf 'This turn changed something, so it needs an end-of-turn checkpoint. Two different audiences are involved — do not conflate them:\n\n1. THE USER reads your normal chat reply. If you owe them anything — a result, a link, an answer, a status — say it in the reply as usual. The checkpoint is invisible to them and is NOT a message to them; running it does not count as having replied.\n2. THE SIBLING (a background maintenance agent) reads the checkpoint payload. It never sees your chat reply, so anything it needs must go into the flags.\n\nSo: finish whatever you owe the user in the reply first, then run the checkpoint as the FINAL action of the turn and end the turn immediately after it — no further commentary once the command has run.\n\n hq core checkpoint --session-id %s --trigger stop-gate --summary "<what changed, in one line>" [--file <path>] [--decision "<choice and why>"] [--learning "<reusable rule>"] [--next "<outstanding step>"]\n\nOnly --summary is required, and the repeatable flags are what the sibling uses to enrich the record, distil policies and update the indexes — a bare summary gives it almost nothing to work with. Write them as machine record, not prose for the user, and pass each one that genuinely applies:\n --file every path you created or modified this turn\n --decision a choice you made that a reader would otherwise have to reverse-engineer\n --learning a rule that changes how someone acts next time, not a restatement of what just happened\n --next work that is genuinely still outstanding\nOmit a flag rather than padding it: an empty or invented learning is worse than none.\n\nIf this turn only read or inspected things and changed no state, the correct call instead is:\n\n hq core checkpoint --session-id %s --idle' "$session_id" "$session_id")"
330
+ reason="$(printf 'This turn changed something, so it needs an end-of-turn checkpoint. Two different audiences are involved — do not conflate them:\n\n1. THE USER reads your normal chat reply. If you owe them anything — a result, a link, an answer, a status — say it in the reply as usual. The checkpoint is invisible to them and is NOT a message to them; running it does not count as having replied.\n2. THE SIBLING (a background maintenance agent) reads the checkpoint payload. It never sees your chat reply, so anything it needs must go into the flags.\n\nSo: finish whatever you owe the user in the reply first, then run the checkpoint as the FINAL action of the turn and end the turn immediately after it. The checkpoint output ends with a REMINDER restating this contract: if it catches you having skipped the user-facing reply, deliver that overdue reply nothing else — and then end the turn; otherwise add no commentary after the command.\n\n hq core checkpoint --session-id %s --trigger stop-gate --summary "<what changed, in one line>" [--file <path>] [--decision "<choice and why>"] [--learning "<reusable rule>"] [--next "<outstanding step>"]\n\nOnly --summary is required, and the repeatable flags are what the sibling uses to enrich the record, distil policies and update the indexes — a bare summary gives it almost nothing to work with. Write them as machine record, not prose for the user, and pass each one that genuinely applies:\n --file every path you created or modified this turn\n --decision a choice you made that a reader would otherwise have to reverse-engineer\n --learning a rule that changes how someone acts next time, not a restatement of what just happened\n --next work that is genuinely still outstanding\nOmit a flag rather than padding it: an empty or invented learning is worse than none.\n\nIf this turn only read or inspected things and changed no state, the correct call instead is:\n\n hq core checkpoint --session-id %s --idle' "$session_id" "$session_id")"
331
331
 
332
332
  # Codex surfaces a blocked Stop reason as a synthetic user prompt. Preserve
333
333
  # the actionable instruction out-of-band, then use the stable marker covered
@@ -31,6 +31,8 @@ export declare const VALID_TIERS: Set<string>;
31
31
  export declare const VALID_PROVIDERS: Set<string>;
32
32
  /** Auth modes hq-pro accepts on `POST /v1/agents` (`CodexAuthMode`). */
33
33
  export declare const VALID_AUTH_MODES: Set<string>;
34
+ /** Customer-facing agent size keys served by hq-pro's authoritative catalog. */
35
+ export declare const VALID_AGENT_SIZE_KEYS: Set<string>;
34
36
  /**
35
37
  * Resolve a closed-set option value, or exit(1) with a message naming the
36
38
  * offending input and the legal set.
@@ -132,7 +134,47 @@ export interface ProvisionAgentInput {
132
134
  idempotencyKey: string;
133
135
  title?: string;
134
136
  description?: string;
137
+ /** Omitted to preserve hq-pro's existing default. */
138
+ desiredInstanceType?: string;
139
+ /** Server quote assertion; hq-pro re-prices and refuses a stale amount. */
140
+ quotedNetMonthlyCents?: number;
141
+ quoteCatalogVersion?: string;
135
142
  }
143
+ export interface AgentCreateSizeOption {
144
+ key: "basic" | "power" | "dev";
145
+ productName: string;
146
+ instanceType: string;
147
+ listCents: number;
148
+ default: boolean;
149
+ selectable: boolean;
150
+ netMonthlyCents: number | null;
151
+ deltaCents: number | null;
152
+ unavailableReason: string | null;
153
+ notBilled: boolean;
154
+ lanes: number;
155
+ workers: number;
156
+ }
157
+ export interface AgentCreateOptionsView {
158
+ defaultInstanceType: string;
159
+ catalogVersion: string;
160
+ options: AgentCreateSizeOption[];
161
+ }
162
+ export type QuotedAgentCreateSizeOption = AgentCreateSizeOption & {
163
+ netMonthlyCents: number;
164
+ deltaCents: number;
165
+ };
166
+ /** Read hq-pro's company-specific creation prices and capacities. */
167
+ export declare function getAgentCreateOptions(token: string, companyUid: string, idempotencyKey?: string): Promise<AgentCreateOptionsView>;
168
+ /** Resolve a requested size from the server response, never from local prices. */
169
+ export declare function requireQuotedCreateSize(view: AgentCreateOptionsView, sizeKey: string): QuotedAgentCreateSizeOption;
170
+ /** Resolve hq-pro's default quote while leaving the POST default implicit. */
171
+ export declare function requireDefaultAgentCreateSize(view: AgentCreateOptionsView): QuotedAgentCreateSizeOption;
172
+ /** Human-facing quote summary sourced entirely from hq-pro. */
173
+ export declare function formatAgentCreateSize(option: AgentCreateSizeOption): string;
174
+ /** Ask a TTY user to choose one of hq-pro's currently selectable quotes. */
175
+ export declare function promptForAgentCreateSize(view: AgentCreateOptionsView): Promise<QuotedAgentCreateSizeOption>;
176
+ /** Confirm creation using the server quote, preserving $0 as a real answer. */
177
+ export declare function confirmAgentCreateQuoteOrExit(option: QuotedAgentCreateSizeOption, yes?: boolean): void;
136
178
  export declare function provisionAgent(token: string, input: ProvisionAgentInput): Promise<{
137
179
  uid?: string;
138
180
  slug?: string;
@@ -23,10 +23,11 @@
23
23
  */
24
24
  import chalk from "chalk";
25
25
  import { randomUUID } from "node:crypto";
26
+ import * as readline from "node:readline";
26
27
  import { resolveVaultCredential } from "../utils/resolve-vault-credential.js";
27
28
  import { gateApiKeyCapabilities } from "../utils/api-key-command-gate.js";
28
29
  import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
29
- import { AGENT_PRICE_CENTS, confirmChargeOrExit, parseBillingPayload, surfaceBillingBlocked, } from "../utils/billing-gate.js";
30
+ import { confirmChargeOrExit, formatUsd, parseBillingPayload, surfaceBillingBlocked, } from "../utils/billing-gate.js";
30
31
  /** Reasoning-effort values hq-pro accepts on `runtime-config`. */
31
32
  export const VALID_EFFORTS = new Set([
32
33
  "minimal",
@@ -41,6 +42,8 @@ export const VALID_TIERS = new Set(["default", "priority"]);
41
42
  export const VALID_PROVIDERS = new Set(["codex", "grok", "claude"]);
42
43
  /** Auth modes hq-pro accepts on `POST /v1/agents` (`CodexAuthMode`). */
43
44
  export const VALID_AUTH_MODES = new Set(["subscription", "apiKey"]);
45
+ /** Customer-facing agent size keys served by hq-pro's authoritative catalog. */
46
+ export const VALID_AGENT_SIZE_KEYS = new Set(["basic", "power", "dev"]);
44
47
  /**
45
48
  * Resolve a closed-set option value, or exit(1) with a message naming the
46
49
  * offending input and the legal set.
@@ -137,6 +140,115 @@ export function slugifyAgentName(name) {
137
140
  .replace(/[^a-z0-9]+/g, "-")
138
141
  .replace(/^-+|-+$/g, "");
139
142
  }
143
+ /** Read hq-pro's company-specific creation prices and capacities. */
144
+ export async function getAgentCreateOptions(token, companyUid, idempotencyKey) {
145
+ const raw = await agentsRequest({
146
+ token,
147
+ path: "/v1/agents/provision-options",
148
+ query: {
149
+ companyUid,
150
+ ...(idempotencyKey ? { idempotencyKey } : {}),
151
+ },
152
+ });
153
+ if (!raw || typeof raw !== "object") {
154
+ throw new Error("HQ Pro returned an invalid agent size catalog.");
155
+ }
156
+ const view = raw;
157
+ if (typeof view.catalogVersion !== "string" || !view.catalogVersion.trim()) {
158
+ throw new Error("HQ Pro did not return the catalog version required to protect this price quote.");
159
+ }
160
+ if (typeof view.defaultInstanceType !== "string" ||
161
+ !Array.isArray(view.options)) {
162
+ throw new Error("HQ Pro returned an invalid agent size catalog.");
163
+ }
164
+ return view;
165
+ }
166
+ /** Resolve a requested size from the server response, never from local prices. */
167
+ export function requireQuotedCreateSize(view, sizeKey) {
168
+ const option = view.options.find((candidate) => candidate.key === sizeKey);
169
+ if (!option) {
170
+ throw new Error(`HQ Pro did not return a quote for agent size ${sizeKey}.`);
171
+ }
172
+ if (!option.selectable ||
173
+ typeof option.productName !== "string" ||
174
+ !option.productName.trim() ||
175
+ typeof option.instanceType !== "string" ||
176
+ !option.instanceType.trim() ||
177
+ typeof option.notBilled !== "boolean" ||
178
+ option.netMonthlyCents === null ||
179
+ !Number.isSafeInteger(option.netMonthlyCents) ||
180
+ option.netMonthlyCents < 0 ||
181
+ option.deltaCents !== option.netMonthlyCents ||
182
+ (option.notBilled && option.netMonthlyCents !== 0) ||
183
+ !Number.isSafeInteger(option.lanes) ||
184
+ option.lanes < 0 ||
185
+ !Number.isSafeInteger(option.workers) ||
186
+ option.workers < 0) {
187
+ throw new Error(`Agent size ${option.productName} cannot be priced right now` +
188
+ (option.unavailableReason ? ` (${option.unavailableReason})` : "."));
189
+ }
190
+ return option;
191
+ }
192
+ /** Resolve hq-pro's default quote while leaving the POST default implicit. */
193
+ export function requireDefaultAgentCreateSize(view) {
194
+ const option = view.options.find((candidate) => candidate.default);
195
+ if (!option || option.instanceType !== view.defaultInstanceType) {
196
+ throw new Error("HQ Pro did not return a valid default agent size quote.");
197
+ }
198
+ return requireQuotedCreateSize(view, option.key);
199
+ }
200
+ /** Human-facing quote summary sourced entirely from hq-pro. */
201
+ export function formatAgentCreateSize(option) {
202
+ const cost = option.netMonthlyCents === null
203
+ ? "price unavailable"
204
+ : `${formatUsd(option.netMonthlyCents)}/month`;
205
+ return (`${option.productName}: ${cost}; ` +
206
+ `${option.lanes} lanes; ${option.workers} workers`);
207
+ }
208
+ /** Ask a TTY user to choose one of hq-pro's currently selectable quotes. */
209
+ export async function promptForAgentCreateSize(view) {
210
+ for (const option of view.options) {
211
+ console.log(chalk.dim(formatAgentCreateSize(option) +
212
+ (option.selectable && option.netMonthlyCents !== null
213
+ ? ""
214
+ : ` — unavailable${option.unavailableReason ? ` (${option.unavailableReason})` : ""}`)));
215
+ }
216
+ const selectable = view.options.filter((option) => option.selectable && option.netMonthlyCents !== null);
217
+ if (selectable.length === 0) {
218
+ throw new Error("HQ Pro could not price any agent size right now.");
219
+ }
220
+ const legal = selectable.map((option) => option.key).join(" | ");
221
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
222
+ try {
223
+ const answer = await new Promise((resolve) => rl.question(`Choose agent size (${legal}): `, resolve));
224
+ const key = canonicalizeOptionValue(answer, new Set(selectable.map((option) => option.key)));
225
+ if (!key) {
226
+ throw new Error(`Invalid agent size '${answer}': choose ${legal}.`);
227
+ }
228
+ return requireQuotedCreateSize(view, key);
229
+ }
230
+ finally {
231
+ rl.close();
232
+ }
233
+ }
234
+ /** Confirm creation using the server quote, preserving $0 as a real answer. */
235
+ export function confirmAgentCreateQuoteOrExit(option, yes) {
236
+ if (option.notBilled || option.netMonthlyCents === 0) {
237
+ const message = `${option.productName} is ${formatUsd(0)}/month for this company — ` +
238
+ "there is no per-agent charge.";
239
+ if (!yes) {
240
+ console.error(chalk.yellow(`${message}\nRe-run with --yes to confirm agent provisioning.`));
241
+ process.exit(1);
242
+ }
243
+ console.log(chalk.dim(`${message} Provisioning…`));
244
+ return;
245
+ }
246
+ confirmChargeOrExit({
247
+ resource: "agent",
248
+ unitCents: option.netMonthlyCents,
249
+ yes,
250
+ });
251
+ }
140
252
  export async function provisionAgent(token, input) {
141
253
  return agentsRequest({
142
254
  token,
@@ -387,7 +499,7 @@ export function registerAgentsCommand(program) {
387
499
  agents
388
500
  .command("provision <name>")
389
501
  .alias("new")
390
- .description("Provision a new cloud agent ($100/month requires --yes)")
502
+ .description("Provision a new cloud agent (company-specific monthly price shown before creation)")
391
503
  .option("--company <slug>", "Company slug (resolves to companyUid)")
392
504
  .option("--slug <slug>", "Agent slug (defaults to a slug of <name>)")
393
505
  .option("--provider <provider>", "Runtime: codex | grok | claude (default codex). claude is subscription-only")
@@ -395,7 +507,8 @@ export function registerAgentsCommand(program) {
395
507
  .option("--api-key-env <VAR>", "Env var holding the API key for --auth-mode apiKey (never pass the key as a flag)")
396
508
  .option("--title <title>", "Org-chart job title")
397
509
  .option("--description <text>", "Short description / bio")
398
- .option("--yes", "Confirm the $100/month charge (required to provision)")
510
+ .option("--size <size>", "Agent box size: basic | power | dev (omitted keeps the current default)")
511
+ .option("--yes", "Confirm the quoted monthly cost and provision the agent")
399
512
  .action(async function (name, opts) {
400
513
  try {
401
514
  // Both of these previously fell back to their default on an
@@ -430,12 +543,21 @@ export function registerAgentsCommand(program) {
430
543
  }
431
544
  const token = (await resolveVaultCredential()).token;
432
545
  const companyUid = await getCompanyUid(token, companyOf(this));
433
- // Paid gate: print the monthly cost and require --yes before any call.
434
- confirmChargeOrExit({
435
- resource: "agent",
436
- unitCents: AGENT_PRICE_CENTS,
437
- yes: opts.yes,
438
- });
546
+ const idempotencyKey = `hq-cli-${randomUUID()}`;
547
+ const sizeKey = parseEnumOption(opts.size, VALID_AGENT_SIZE_KEYS, "--size");
548
+ const shouldChooseInteractively = !sizeKey && process.stdin.isTTY === true;
549
+ const createOptions = await getAgentCreateOptions(token, companyUid, idempotencyKey);
550
+ const quotedSize = sizeKey
551
+ ? requireQuotedCreateSize(createOptions, sizeKey)
552
+ : shouldChooseInteractively
553
+ ? await promptForAgentCreateSize(createOptions)
554
+ : requireDefaultAgentCreateSize(createOptions);
555
+ if (!shouldChooseInteractively) {
556
+ console.log(chalk.dim(formatAgentCreateSize(quotedSize)));
557
+ }
558
+ // Every path confirms hq-pro's company-specific quote. Omission still
559
+ // leaves the POST size implicit, preserving the server-side default.
560
+ confirmAgentCreateQuoteOrExit(quotedSize, opts.yes);
439
561
  const slug = opts.slug ?? slugifyAgentName(name);
440
562
  try {
441
563
  const result = await provisionAgent(token, {
@@ -445,9 +567,19 @@ export function registerAgentsCommand(program) {
445
567
  codexAuthMode: authMode,
446
568
  ...(provider ? { provider } : {}),
447
569
  ...(codexApiKey ? { codexApiKey } : {}),
448
- idempotencyKey: `hq-cli-${randomUUID()}`,
570
+ idempotencyKey,
449
571
  ...(opts.title ? { title: opts.title } : {}),
450
572
  ...(opts.description ? { description: opts.description } : {}),
573
+ // Leave hq-pro's current default implicit so a Team setup agent
574
+ // still goes through its atomic entitlement claim. Every current
575
+ // CLI path nevertheless echoes the displayed amount and catalog
576
+ // snapshot, so omitted --size cannot silently accept a price that
577
+ // changed between confirmation and creation.
578
+ ...(quotedSize.default
579
+ ? {}
580
+ : { desiredInstanceType: quotedSize.instanceType }),
581
+ quotedNetMonthlyCents: quotedSize.netMonthlyCents,
582
+ quoteCatalogVersion: createOptions.catalogVersion,
451
583
  });
452
584
  const uid = typeof result.uid === "string" ? result.uid : slug;
453
585
  console.log(chalk.green(`Provisioning started for agent "${name}".`));
@@ -9,6 +9,19 @@
9
9
  import { Command } from "commander";
10
10
  type Backend = "claude" | "codex" | "grok" | "none";
11
11
  type SpawnableBackend = Exclude<Backend, "none">;
12
+ /**
13
+ * Printed after every non-dry-run checkpoint, real or --idle. Agents were
14
+ * treating the checkpoint call as the end of the turn, leaving their actual
15
+ * findings only in the checkpoint payload — which the user never sees. This
16
+ * line rides the command output (the one channel guaranteed to reach the
17
+ * calling agent) to force the user-facing reply. Deliberately conditional so
18
+ * it composes with the Stop-gate prompt's "reply first, checkpoint last, then
19
+ * end the turn" ordering: an agent that already replied is told to end the
20
+ * turn, not to add commentary. Mirrors the hq-core policies
21
+ * `checkpoint-is-bookkeeping-not-user-communication` and
22
+ * `checkpoint-is-not-the-user-report`.
23
+ */
24
+ export declare const CHECKPOINT_REPLY_REMINDER: string;
12
25
  /**
13
26
  * Kept in TypeScript rather than in a bundled asset: it is an instruction to
14
27
  * a locally-installed agent, not a scaffold script that should be packaged.
@@ -40,6 +40,23 @@ const SIBLING_LOCK_TTL_MS = 60 * 60 * 1000;
40
40
  const MAX_PENDING_PAYLOADS = 50;
41
41
  class CheckpointUsageError extends Error {
42
42
  }
43
+ /**
44
+ * Printed after every non-dry-run checkpoint, real or --idle. Agents were
45
+ * treating the checkpoint call as the end of the turn, leaving their actual
46
+ * findings only in the checkpoint payload — which the user never sees. This
47
+ * line rides the command output (the one channel guaranteed to reach the
48
+ * calling agent) to force the user-facing reply. Deliberately conditional so
49
+ * it composes with the Stop-gate prompt's "reply first, checkpoint last, then
50
+ * end the turn" ordering: an agent that already replied is told to end the
51
+ * turn, not to add commentary. Mirrors the hq-core policies
52
+ * `checkpoint-is-bookkeeping-not-user-communication` and
53
+ * `checkpoint-is-not-the-user-report`.
54
+ */
55
+ export const CHECKPOINT_REPLY_REMINDER = "checkpoint: REMINDER — this checkpoint is invisible bookkeeping; the user never sees it " +
56
+ "and it does NOT count as your reply. If your reply to the user already contains " +
57
+ "everything of substance from this turn, end the turn now. If anything — results, " +
58
+ "findings, decisions, state changes, anything awaiting their input — exists only in this " +
59
+ "checkpoint or your head, deliver it to the user in plain language before ending the turn.";
43
60
  function printResult(line) {
44
61
  process.stdout.write(`${line}\n`);
45
62
  }
@@ -801,6 +818,9 @@ function runCheckpoint(options, command, group) {
801
818
  }
802
819
  writeStamps(liveRoot, resolveSessionId(options, command, readPayload(options.payload)));
803
820
  printResult("checkpoint: idle (nothing to record)");
821
+ // An idle turn persisted nothing, but a read-only turn can still have
822
+ // produced substantive findings the user has not been told about.
823
+ printResult(CHECKPOINT_REPLY_REMINDER);
804
824
  return;
805
825
  }
806
826
  const payload = readPayload(options.payload);
@@ -860,13 +880,22 @@ function runCheckpoint(options, command, group) {
860
880
  fs.writeFileSync(threadPath, `${JSON.stringify(thread, null, 2)}\n`);
861
881
  writeStamps(liveRoot, input.sessionId);
862
882
  printResult(`checkpoint: ${relativeThreadPath}`);
863
- if (options.agent === false)
864
- return;
865
- if (backend === "none") {
866
- printResult("checkpoint: sibling disabled (backend none)");
867
- return;
883
+ // finally: the thread is already written at this point, so the agent must
884
+ // get the reminder even when sibling startup throws (e.g. an explicitly
885
+ // requested backend that is not installed).
886
+ try {
887
+ if (options.agent !== false) {
888
+ if (backend === "none") {
889
+ printResult("checkpoint: sibling disabled (backend none)");
890
+ }
891
+ else {
892
+ startSibling(liveRoot, input, threadPath, backend);
893
+ }
894
+ }
895
+ }
896
+ finally {
897
+ printResult(CHECKPOINT_REPLY_REMINDER);
868
898
  }
869
- startSibling(liveRoot, input, threadPath, backend);
870
899
  }
871
900
  function reportUsage(error) {
872
901
  printError("Usage: hq core checkpoint --summary <text> [options]");
@@ -4,6 +4,7 @@
4
4
  import chalk from "chalk";
5
5
  import { ControlPlaneDbClient } from "../lib/db/control-plane.js";
6
6
  import { DEFAULT_VAULT_API_URL, ensureCognitoToken, } from "../utils/cognito-session.js";
7
+ import { isPlanGateError } from "../utils/plan-gate-error.js";
7
8
  import { getCompanyUid } from "../utils/vault-api.js";
8
9
  const defaultDeps = () => ({
9
10
  async resolveCompany(slug) {
@@ -59,6 +60,9 @@ export function registerDbProvisionCommand(db, depsFactory = defaultDeps) {
59
60
  console.log(`idempotent: ${result.idempotent ? "yes" : "no"}`);
60
61
  }
61
62
  catch (error) {
63
+ // Let main.ts render the shared, non-stacktrace plan-gate message.
64
+ if (isPlanGateError(error))
65
+ throw error;
62
66
  const msg = error instanceof Error ? error.message : "Unknown error";
63
67
  const status = error.status;
64
68
  if (status === 402 || /PLAN_REQUIRED|Team plan|\$500/i.test(msg)) {
@@ -1,7 +1,4 @@
1
- /**
2
- * Control-plane client for remote vault DB (US-009).
3
- * Injectable fetch for tests — never logs response bodies that might hold secrets.
4
- */
1
+ import { planGateErrorFromPayload } from "../../utils/plan-gate-error.js";
5
2
  function assertNoPostgresUrl(label, text) {
6
3
  if (/postgres:\/\//i.test(text) || /postgresql:\/\//i.test(text)) {
7
4
  throw new Error(`${label}: control plane returned a connection string (refusing to surface)`);
@@ -30,21 +27,22 @@ export class ControlPlaneDbClient {
30
27
  assertNoPostgresUrl("provision", text);
31
28
  if (!res.ok) {
32
29
  let msg = `provision failed (${res.status})`;
33
- let code;
30
+ let payload;
34
31
  try {
35
- const j = JSON.parse(text);
36
- if (j.error)
37
- msg = j.error;
38
- if (j.code)
39
- code = j.code;
32
+ payload = JSON.parse(text);
33
+ if (typeof payload.error === "string")
34
+ msg = payload.error;
40
35
  }
41
36
  catch {
42
37
  /* keep */
43
38
  }
39
+ const planGate = planGateErrorFromPayload(res.status, payload);
40
+ if (planGate)
41
+ throw planGate;
44
42
  const err = new Error(msg);
45
43
  err.status = res.status;
46
- if (code)
47
- err.code = code;
44
+ if (typeof payload?.code === "string")
45
+ err.code = payload.code;
48
46
  throw err;
49
47
  }
50
48
  return JSON.parse(text);
package/dist/main.js CHANGED
@@ -70,6 +70,7 @@ import { isEpipe } from "./utils/epipe.js";
70
70
  import { isInterceptedProcessExit } from "./utils/intercepted-process-exit.js";
71
71
  import { isAuthError } from "./utils/auth-error.js";
72
72
  import { isCompanySelectionError } from "./utils/company-selection-error.js";
73
+ import { formatPlanGateError, isPlanGateError, } from "./utils/plan-gate-error.js";
73
74
  import { refreshVersionCache, staleAgainstCachedLatest, } from "./utils/version-check.js";
74
75
  import { enforceVersionGate, shouldSkipGate, } from "./utils/version-gate.js";
75
76
  import { autoUpdateAndReexec } from "./utils/self-update.js";
@@ -359,6 +360,13 @@ export function handleTopLevelError(err, deps = defaultTopLevelErrorDependencies
359
360
  deps.stderr.write(`hq: ${err.message}\n`);
360
361
  deps.setExitCode(1);
361
362
  }
363
+ else if (isPlanGateError(err)) {
364
+ // hq-pro's plan denials are expected product limits, never a CLI crash.
365
+ // The shared vault client has already decoded and typed the small safe
366
+ // envelope; do not expose its raw body or retry a rejected creation.
367
+ deps.stderr.write(`hq: ${formatPlanGateError(err)}\n`);
368
+ deps.setExitCode(1);
369
+ }
362
370
  else if (isExpectedUserError(err)) {
363
371
  // HQ-CLI-6: a user-facing, client-caused error (a non-owner running
364
372
  // `hq integrations approve`, a stale queueId, a bad --args, an unknown
@@ -0,0 +1,30 @@
1
+ /**
2
+ * A deliberate subscription denial returned by hq-pro. Keeping its structured
3
+ * fields on a typed error lets the CLI boundary render one safe, consistent
4
+ * message instead of every command parsing and printing a server response.
5
+ */
6
+ export type PlanGateCode = "PLAN_LIMIT_EXCEEDED" | "PLAN_REQUIRED";
7
+ export interface PlanGateDetails {
8
+ resource?: string;
9
+ used?: number;
10
+ limit?: number;
11
+ upgradeUrl?: string;
12
+ }
13
+ export declare class PlanGateError extends Error {
14
+ readonly code: PlanGateCode;
15
+ readonly details: PlanGateDetails;
16
+ constructor(code: PlanGateCode, details: PlanGateDetails);
17
+ }
18
+ export declare function isPlanGateError(err: unknown): err is PlanGateError;
19
+ export declare function formatPlanGateError(err: PlanGateError): string;
20
+ /** Decode an already-read JSON envelope from either shared HTTP client. */
21
+ export declare function planGateErrorFromPayload(status: number, body: unknown): PlanGateError | null;
22
+ /**
23
+ * Decode only the two deliberate plan-gate envelopes. Malformed, unrelated,
24
+ * or non-402 responses retain their existing command-specific handling.
25
+ */
26
+ export declare function planGateErrorFromResponse(response: Response): Promise<{
27
+ error: PlanGateError | null;
28
+ response: Response;
29
+ }>;
30
+ //# sourceMappingURL=plan-gate-error.d.ts.map
@@ -0,0 +1,93 @@
1
+ export class PlanGateError extends Error {
2
+ code;
3
+ details;
4
+ constructor(code, details) {
5
+ // Commands with their own expected-error boundary commonly print
6
+ // `err.message`. Keeping the friendly copy here means those boundaries
7
+ // retain the same plan-gate voice as main.ts without per-command handling.
8
+ super(formatPlanGateDetails(code, details));
9
+ this.code = code;
10
+ this.details = details;
11
+ this.name = "PlanGateError";
12
+ }
13
+ }
14
+ export function isPlanGateError(err) {
15
+ return err instanceof PlanGateError;
16
+ }
17
+ /** Plain, stable CLI copy for plan-gated resource creation. */
18
+ function formatPlanGateDetails(code, details) {
19
+ const existingResourcesNote = "Existing resources will keep working.";
20
+ if (code === "PLAN_LIMIT_EXCEEDED" &&
21
+ typeof details.resource === "string" &&
22
+ typeof details.used === "number" &&
23
+ typeof details.limit === "number") {
24
+ const upgrade = typeof details.upgradeUrl === "string"
25
+ ? `Upgrade to HQ Team ($500/mo) to remove limits: ${details.upgradeUrl}`
26
+ : "Upgrade to HQ Team ($500/mo) to remove limits.";
27
+ return [
28
+ `Free plan limit reached: ${details.resource} ${details.used}/${details.limit} used.`,
29
+ upgrade,
30
+ existingResourcesNote,
31
+ ].join("\n");
32
+ }
33
+ const upgrade = typeof details.upgradeUrl === "string"
34
+ ? `Upgrade to HQ Team ($500/mo) to remove limits: ${details.upgradeUrl}`
35
+ : "Upgrade to HQ Team ($500/mo) to remove limits.";
36
+ return [
37
+ "HQ Team plan required for this feature.",
38
+ upgrade,
39
+ existingResourcesNote,
40
+ ].join("\n");
41
+ }
42
+ export function formatPlanGateError(err) {
43
+ return formatPlanGateDetails(err.code, err.details);
44
+ }
45
+ /** Decode an already-read JSON envelope from either shared HTTP client. */
46
+ export function planGateErrorFromPayload(status, body) {
47
+ if (status !== 402 || !body || typeof body !== "object")
48
+ return null;
49
+ const payload = body;
50
+ if (payload.code !== "PLAN_LIMIT_EXCEEDED" && payload.code !== "PLAN_REQUIRED") {
51
+ return null;
52
+ }
53
+ return new PlanGateError(payload.code, {
54
+ ...(typeof payload.resource === "string" ? { resource: payload.resource } : {}),
55
+ ...(typeof payload.used === "number" ? { used: payload.used } : {}),
56
+ ...(typeof payload.limit === "number" ? { limit: payload.limit } : {}),
57
+ ...(typeof payload.upgradeUrl === "string" ? { upgradeUrl: payload.upgradeUrl } : {}),
58
+ });
59
+ }
60
+ /**
61
+ * Decode only the two deliberate plan-gate envelopes. Malformed, unrelated,
62
+ * or non-402 responses retain their existing command-specific handling.
63
+ */
64
+ export async function planGateErrorFromResponse(response) {
65
+ // Leave every non-402 response untouched for its command-specific handler.
66
+ // A non-plan 402 is buffered and re-wrapped below so its useful error
67
+ // payload remains available to the existing command-specific handler.
68
+ if (response.status !== 402)
69
+ return { error: null, response };
70
+ let buffer;
71
+ try {
72
+ buffer = await response.arrayBuffer();
73
+ }
74
+ catch {
75
+ return { error: null, response };
76
+ }
77
+ let body = null;
78
+ try {
79
+ body = JSON.parse(new TextDecoder().decode(buffer));
80
+ }
81
+ catch {
82
+ // Preserve the original non-JSON 402 body below for its existing handler.
83
+ }
84
+ return {
85
+ error: planGateErrorFromPayload(response.status, body),
86
+ response: new Response(buffer, {
87
+ status: response.status,
88
+ statusText: response.statusText,
89
+ headers: response.headers,
90
+ }),
91
+ };
92
+ }
93
+ //# sourceMappingURL=plan-gate-error.js.map
@@ -4,6 +4,7 @@ import { AuthError } from './auth-error.js';
4
4
  import { CompanySelectionError } from './company-selection-error.js';
5
5
  import { recordPlanLimitStatus } from '../lib/plan-limit-nag.js';
6
6
  import { networkTransportErrorCode } from './network-transport-error.js';
7
+ import { planGateErrorFromResponse } from './plan-gate-error.js';
7
8
  /**
8
9
  * Identity / company resolution lookups must never hang forever. These small
9
10
  * GETs run BEFORE a command does its real work (e.g. `hq secrets env` resolves
@@ -183,7 +184,13 @@ export async function vaultApiFetch(opts) {
183
184
  level: "warning",
184
185
  data: { url: safeUrl, status: response.status },
185
186
  });
186
- return response;
187
+ // A plan gate is a normal, user-actionable denial. Decode it at the one
188
+ // shared HTTP seam so every resource-creation command reaches main.ts's
189
+ // friendly renderer without duplicating response parsing or retrying.
190
+ const planGate = await planGateErrorFromResponse(response);
191
+ if (planGate.error)
192
+ throw planGate.error;
193
+ return planGate.response;
187
194
  }
188
195
  return peekPlanLimitStatus(response);
189
196
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.99.3",
3
+ "version": "5.101.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {