@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 +24 -0
- package/assets/scaffold/core/scripts/checkpoint-stop-gate.sh +1 -1
- package/dist/commands/agents.d.ts +42 -0
- package/dist/commands/agents.js +142 -10
- package/dist/commands/core-checkpoint.d.ts +13 -0
- package/dist/commands/core-checkpoint.js +35 -6
- package/dist/commands/db-provision.js +4 -0
- package/dist/lib/db/control-plane.js +10 -12
- package/dist/main.js +8 -0
- package/dist/utils/plan-gate-error.d.ts +30 -0
- package/dist/utils/plan-gate-error.js +93 -0
- package/dist/utils/vault-api.js +8 -1
- package/package.json +1 -1
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
|
|
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;
|
package/dist/commands/agents.js
CHANGED
|
@@ -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 {
|
|
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 (
|
|
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("--
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
|
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
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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
|
|
30
|
+
let payload;
|
|
34
31
|
try {
|
|
35
|
-
|
|
36
|
-
if (
|
|
37
|
-
msg =
|
|
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
|
package/dist/utils/vault-api.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|