@indigoai-us/hq-cli 5.119.8 → 5.119.10

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.
@@ -1,14 +1,19 @@
1
1
  /**
2
- * `plan-lock` (starter-plan-hard-limits / US-011) the CLI-side read + render
3
- * of the workspace plan lock.
2
+ * `plan-lock` (starter-plan-hard-limits / US-011, rewritten for US-036) the
3
+ * CLI-side read + render of a workspace's Starter plan-limit state.
4
4
  *
5
- * Starter (free) workspaces are capped on four locking dimensions — members,
6
- * integrations, secrets and agents (owner decision 7, 2026-09-17; deployments
7
- * and storage nag but never lock). Going over locks the workspace immediately: it becomes read-only until the owner
8
- * trims back under the caps or upgrades to HQ Workforce. The lock decision is
9
- * NOT made here hq-pro's `src/billing/plan-lock.ts` is the single source of
10
- * truth and ships the answer on `GET /membership/me` as a per-company
11
- * `planLock` object. This module only reads that field and renders it.
5
+ * Starter (free) workspaces are capped on members, integrations, secrets and
6
+ * agents (deployments and storage nag but never arm a stop). Going over does
7
+ * NOT stop the workspace working (US-033 owner decision 12): everything that
8
+ * exists keeps working, and the only two things that pause are adding new
9
+ * files to the vault and adding new secrets (owner decision 13). Every string
10
+ * this module renders is a nag, never a claim that HQ has stopped. The state
11
+ * is NOT decided here hq-pro is the single source of truth and ships the
12
+ * answer on `GET /membership/me` as a per-company `planLock` object. This
13
+ * module only reads that field and renders it.
14
+ *
15
+ * Copy rule (US-036): no string a customer reads may contain "locked" or
16
+ * "read-only". `plan-lock.test.ts` scans every rendered string for both.
12
17
  *
13
18
  * Member counts are decoration, never a second opinion: the count comes from
14
19
  * `GET /v1/billing/usage-limits` on a best-effort basis and its absence only
@@ -20,7 +25,7 @@ import chalk from "chalk";
20
25
  import { vaultApiFetch } from "../../utils/vault-api.js";
21
26
  /**
22
27
  * Mirror of hq-pro's `PlanLockReason`. Owner decision 7 (2026-09-17) fixes the
23
- * locking set at these four: `deployments` and `storageBytes` are nag-only and
28
+ * nagging set at these four: `deployments` and `storageBytes` are nag-only and
24
29
  * never appear here. An unrecognised reason is dropped by `parsePlanLock`, so a
25
30
  * server that adds a fifth dimension renders as the generic line rather than as
26
31
  * a false claim about members.
@@ -204,7 +209,7 @@ export async function fetchPlanLockStatus(token, companyRef, opts = {}) {
204
209
  checkedAt: new Date().toISOString(),
205
210
  };
206
211
  }
207
- /** Why the workspace locked, one clause per reason. */
212
+ /** Why the workspace is over, one clause per reason. */
208
213
  export function reasonLabel(reason) {
209
214
  switch (reason) {
210
215
  case "users":
@@ -261,23 +266,34 @@ function reasonDetail(reason, status) {
261
266
  return "agents are not included on Starter";
262
267
  }
263
268
  }
269
+ /**
270
+ * The one sentence every over-limit surface says about the two hard stops
271
+ * (US-033 §3.3). Written once so the CLI, the console and the emails cannot
272
+ * drift into three different promises.
273
+ */
274
+ export const HARD_STOP_SENTENCE = "Nothing has been deleted and everything keeps working, except that new " +
275
+ "files and new secrets are paused until this is sorted.";
276
+ const HARD_STOP_SENTENCE_LINES = [
277
+ " Nothing has been deleted and everything keeps working, except that new",
278
+ " files and new secrets are paused until this is sorted.",
279
+ ];
264
280
  function capitalize(text) {
265
281
  return text.charAt(0).toUpperCase() + text.slice(1);
266
282
  }
267
283
  /**
268
- * The full WORKSPACE LOCKED block: why it locked, where the workspace stands
284
+ * The full over-limit block: why the workspace is over, where it stands
269
285
  * against the cap, and the ways out. Plain text — colour is applied by the
270
286
  * caller so scripts capturing stdout get a clean block.
271
287
  *
272
- * Every line is derived from `lock.reasons`. A workspace locked on secrets is
288
+ * Every line is derived from `lock.reasons`. A workspace over on secrets is
273
289
  * never told it has too many members, and an empty reason list (a server
274
290
  * dimension this CLI does not know) renders a generic line rather than a claim
275
291
  * about a dimension nobody measured.
276
292
  */
277
- export function renderPlanLockNotice(status) {
293
+ export function renderPlanLimitNotice(status) {
278
294
  const { lock, members, companySlug } = status;
279
295
  const lines = [];
280
- lines.push(`WORKSPACE LOCKED — ${companySlug} is over its Starter plan.`);
296
+ lines.push(`HQ Starter — ${companySlug} is over its plan limits.`);
281
297
  const reasons = lock.reasons.length
282
298
  ? lock.reasons.map(reasonLabel).join("; ")
283
299
  : "over the Starter plan limits";
@@ -297,9 +313,9 @@ export function renderPlanLockNotice(status) {
297
313
  if (lock.reasons.includes("agents")) {
298
314
  lines.push(` Agents allowed on Starter: ${agentTarget(lock)}.`);
299
315
  }
300
- lines.push(" Nothing has been deleted. This workspace is read-only until it is fixed:");
301
- lines.push(" you can still delete things, but you cannot create, invite or upload.");
302
- lines.push(" Two ways to fix it:");
316
+ lines.push(HARD_STOP_SENTENCE_LINES[0]);
317
+ lines.push(HARD_STOP_SENTENCE_LINES[1]);
318
+ lines.push(" Two ways to sort it:");
303
319
  const remedy = lock.reasons.length
304
320
  ? lock.reasons.map((reason) => reasonRemedy(reason, lock)).join(", and ")
305
321
  : "come back under the Starter plan limits";
@@ -309,17 +325,17 @@ export function renderPlanLockNotice(status) {
309
325
  return lines.join("\n");
310
326
  }
311
327
  /**
312
- * The one-line form injected on every turn while the workspace stays locked.
328
+ * The one-line form injected on every turn while the workspace stays over.
313
329
  * Kept to a single line on purpose — it repeats each turn.
314
330
  */
315
- export function renderPlanLockLine(status) {
331
+ export function renderPlanLimitLine(status) {
316
332
  const detail = status.lock.reasons.length
317
333
  ? status.lock.reasons
318
334
  .map((reason) => reasonDetail(reason, status))
319
335
  .join("; ")
320
336
  : "over the Starter plan limits";
321
- return (`Company ${status.companySlug} is locked on Starter (${detail}). ` +
322
- `Writes to HQ cloud will fail until fixed: ${status.lock.upgradeUrl}`);
337
+ return (`Company ${status.companySlug} is over its Starter limits (${detail}). ` +
338
+ `New files and new secrets are paused until this is sorted: ${status.lock.upgradeUrl}`);
323
339
  }
324
340
  /**
325
341
  * The `Plan: …` orientation line. Starter only: a paid or enterprise
@@ -327,7 +343,7 @@ export function renderPlanLockLine(status) {
327
343
  */
328
344
  export function renderPlanLine(status) {
329
345
  if (status.lock.locked)
330
- return "Plan: Starter — LOCKED";
346
+ return "Plan: Starter — over its limits";
331
347
  if (status.plan !== "free")
332
348
  return null;
333
349
  const target = status.lock.fixOptions.removeMembersTo;
@@ -336,8 +352,12 @@ export function renderPlanLine(status) {
336
352
  }
337
353
  return `Plan: Starter — ${target} members included`;
338
354
  }
339
- /** Colourised block for interactive output. */
340
- export function colorizePlanLockNotice(notice) {
341
- return chalk.red(notice);
355
+ /**
356
+ * Colourised block for interactive output. Yellow, not red: this is a nag, and
357
+ * an error colour would read as "HQ has stopped working", which is the exact
358
+ * impression US-033 removes.
359
+ */
360
+ export function colorizePlanLimitNotice(notice) {
361
+ return chalk.yellow(notice);
342
362
  }
343
363
  //# sourceMappingURL=plan-lock.js.map
@@ -5,17 +5,41 @@
5
5
  * some 2xx JSON response bodies (US-012). This module:
6
6
  *
7
7
  * 1. Best-effort records the last-seen status from decoded JSON bodies
8
- * (`recordPlanLimitStatus`).
8
+ * (`recordPlanLimitStatus`), and prints the one-time threshold notice
9
+ * right there — see below.
9
10
  * 2. Emits a stderr nag at command completion (`emitPlanLimitNag`):
10
11
  * - entries present, none over (≥80% warning): one-line yellow warning,
11
12
  * once per process session
12
- * - any resource over: boxed notice, at most once per day (persisted in
13
- * `~/.hq/plan-limit-nag.json`) and once per session
13
+ * - any resource over: one-line notice, at most once every 6 hours
14
+ * (persisted in `~/.hq/plan-limit-nag.json`) and once per session
15
+ *
16
+ * The one-time threshold notice (US-035) is the third surface and the only one
17
+ * that does NOT wait for command completion. hq-pro attaches
18
+ * `thresholdCrossings: [{ resource, band, firstSeenAt, used, limit }]` beside
19
+ * `planLimits`, populated from the server's own threshold state, and this
20
+ * module prints each `{resource}:{band}` exactly once per EPISODE — persisted
21
+ * in `~/.hq/plan-limit-nag.json` under `notifiedThresholds`. It prints from the
22
+ * record path so a session that is already running surfaces the notice on its
23
+ * next API call rather than on its next launch, which is the whole point of
24
+ * carrying the crossing on the wire instead of recomputing it locally.
25
+ *
26
+ * "Once per episode", not once per lifetime: the stored value is the server's
27
+ * `firstSeenAt`, so a dimension that drops back under the threshold and climbs
28
+ * again arrives with a new timestamp and notifies again. The server decides
29
+ * when an episode ends; this module only remembers what it has printed.
14
30
  *
15
31
  * Additive only — never throws, never touches `process.exitCode`, never
16
- * writes to stdout. Env off-switch: `HQ_NO_PLAN_LIMIT_NAG=1`.
32
+ * writes to stdout. Env off-switch: `HQ_NO_PLAN_LIMIT_NAG=1` silences all
33
+ * three surfaces, the threshold notice included.
17
34
  */
18
35
  export declare const PLAN_LIMIT_UPGRADE_URL = "https://hq.computer/billing/upgrade";
36
+ /**
37
+ * Cadence of the over-limit per-turn line (US-036). Was once a day; the nag
38
+ * model wants it more often than that and still not on every turn, so the
39
+ * window is six hours — up to four lines in a day for someone working all day,
40
+ * and one for someone who runs a single command.
41
+ */
42
+ export declare const OVER_NAG_INTERVAL_MS: number;
19
43
  /**
20
44
  * Plain-English name for each plan-limit resource key, so a nag line says which
21
45
  * dimension is tight rather than only the wire key. The key itself stays in the
@@ -31,12 +55,54 @@ export interface PlanLimitEntry {
31
55
  }
32
56
  /** Validated map of resource key → entry. */
33
57
  export type PlanLimitsMap = Record<string, PlanLimitEntry>;
58
+ /** The two bands hq-pro reports. Anything else on the wire is ignored. */
59
+ export declare const PLAN_LIMIT_BANDS: readonly ["warn", "over"];
60
+ export type PlanLimitBand = (typeof PLAN_LIMIT_BANDS)[number];
61
+ /** One server-announced threshold crossing, as it arrives on the wire. */
62
+ export interface PlanLimitThresholdCrossing {
63
+ resource: string;
64
+ band: PlanLimitBand;
65
+ /** ISO time the server first recorded this band — the episode identity. */
66
+ firstSeenAt: string;
67
+ used: number;
68
+ limit: number;
69
+ }
34
70
  /**
35
71
  * Record plan-limit status from a decoded JSON response body.
36
72
  * Never throws. Overwrites the last-seen cell when a well-formed
37
73
  * `planLimits` object is present; ignores malformed / absent payloads.
38
74
  */
39
- export declare function recordPlanLimitStatus(body: unknown): void;
75
+ export declare function recordPlanLimitStatus(body: unknown, opts?: {
76
+ write?: (s: string) => void;
77
+ statePath?: string;
78
+ }): void;
79
+ /**
80
+ * The over-limit per-turn line (US-036 §2.2). One line, not the old box: it
81
+ * now repeats up to four times a day, and a multi-line box at that cadence
82
+ * reads as breakage rather than as a nag. It states the two paused things and
83
+ * never claims the workspace has stopped working.
84
+ */
85
+ export declare function buildOverLine(overEntries: Array<[string, PlanLimitEntry]>, upgradeUrl: string): string;
86
+ /**
87
+ * One line per crossing.
88
+ *
89
+ * The copy states the fact and the exit, nothing else. It deliberately does
90
+ * not describe consequences that have not shipped: the hard stops arrive with
91
+ * their own story, and a notice that announces a pause nobody is experiencing
92
+ * reads as a bug.
93
+ */
94
+ export declare function renderThresholdNotice(crossing: PlanLimitThresholdCrossing, upgradeUrl: string): string;
95
+ /**
96
+ * Print every crossing this machine has not already printed for this episode.
97
+ *
98
+ * Called from the record path, so a session already running prints on its next
99
+ * API call. Never throws; a state file it cannot read or write degrades to
100
+ * per-process dedupe rather than to silence or to repetition.
101
+ */
102
+ export declare function emitPlanLimitThresholdNotice(crossings: readonly PlanLimitThresholdCrossing[], upgradeUrl: string | null, opts?: {
103
+ write?: (s: string) => void;
104
+ statePath?: string;
105
+ }): void;
40
106
  /**
41
107
  * Emit a plan-limit nag to stderr at command completion.
42
108
  *
@@ -5,22 +5,45 @@
5
5
  * some 2xx JSON response bodies (US-012). This module:
6
6
  *
7
7
  * 1. Best-effort records the last-seen status from decoded JSON bodies
8
- * (`recordPlanLimitStatus`).
8
+ * (`recordPlanLimitStatus`), and prints the one-time threshold notice
9
+ * right there — see below.
9
10
  * 2. Emits a stderr nag at command completion (`emitPlanLimitNag`):
10
11
  * - entries present, none over (≥80% warning): one-line yellow warning,
11
12
  * once per process session
12
- * - any resource over: boxed notice, at most once per day (persisted in
13
- * `~/.hq/plan-limit-nag.json`) and once per session
13
+ * - any resource over: one-line notice, at most once every 6 hours
14
+ * (persisted in `~/.hq/plan-limit-nag.json`) and once per session
15
+ *
16
+ * The one-time threshold notice (US-035) is the third surface and the only one
17
+ * that does NOT wait for command completion. hq-pro attaches
18
+ * `thresholdCrossings: [{ resource, band, firstSeenAt, used, limit }]` beside
19
+ * `planLimits`, populated from the server's own threshold state, and this
20
+ * module prints each `{resource}:{band}` exactly once per EPISODE — persisted
21
+ * in `~/.hq/plan-limit-nag.json` under `notifiedThresholds`. It prints from the
22
+ * record path so a session that is already running surfaces the notice on its
23
+ * next API call rather than on its next launch, which is the whole point of
24
+ * carrying the crossing on the wire instead of recomputing it locally.
25
+ *
26
+ * "Once per episode", not once per lifetime: the stored value is the server's
27
+ * `firstSeenAt`, so a dimension that drops back under the threshold and climbs
28
+ * again arrives with a new timestamp and notifies again. The server decides
29
+ * when an episode ends; this module only remembers what it has printed.
14
30
  *
15
31
  * Additive only — never throws, never touches `process.exitCode`, never
16
- * writes to stdout. Env off-switch: `HQ_NO_PLAN_LIMIT_NAG=1`.
32
+ * writes to stdout. Env off-switch: `HQ_NO_PLAN_LIMIT_NAG=1` silences all
33
+ * three surfaces, the threshold notice included.
17
34
  */
18
35
  import chalk from "chalk";
19
36
  import * as fs from "node:fs";
20
37
  import * as os from "node:os";
21
38
  import * as path from "node:path";
22
39
  export const PLAN_LIMIT_UPGRADE_URL = "https://hq.computer/billing/upgrade";
23
- const DAY_MS = 24 * 60 * 60 * 1000;
40
+ /**
41
+ * Cadence of the over-limit per-turn line (US-036). Was once a day; the nag
42
+ * model wants it more often than that and still not on every turn, so the
43
+ * window is six hours — up to four lines in a day for someone working all day,
44
+ * and one for someone who runs a single command.
45
+ */
46
+ export const OVER_NAG_INTERVAL_MS = 6 * 60 * 60 * 1000;
24
47
  /**
25
48
  * Plain-English name for each plan-limit resource key, so a nag line says which
26
49
  * dimension is tight rather than only the wire key. The key itself stays in the
@@ -40,12 +63,22 @@ function dimensionSuffix(key) {
40
63
  const label = PLAN_LIMIT_DIMENSION_LABELS[key];
41
64
  return label && label !== key ? ` (${label})` : "";
42
65
  }
66
+ /** The two bands hq-pro reports. Anything else on the wire is ignored. */
67
+ export const PLAN_LIMIT_BANDS = ["warn", "over"];
43
68
  /** Module-level last-seen cell — overwritten by each successful parse. */
44
69
  let lastSeen = null;
45
70
  /** Session dedupe for the ≥80% one-line warning. */
46
71
  let warningShownThisSession = false;
47
- /** Session dedupe for the over-limit boxed notice. */
72
+ /** Session dedupe for the over-limit line. */
48
73
  let overShownThisSession = false;
74
+ /**
75
+ * `"{resource}:{band}"` → `firstSeenAt` already printed in THIS process.
76
+ *
77
+ * The persisted file is the durable dedupe; this map exists so a process that
78
+ * cannot write `~/.hq` (read-only home, sandboxed agent) still prints each
79
+ * crossing once rather than on every single API call.
80
+ */
81
+ const thresholdsShownThisSession = new Map();
49
82
  function defaultStatePath() {
50
83
  return path.join(os.homedir(), ".hq", "plan-limit-nag.json");
51
84
  }
@@ -122,18 +155,67 @@ function parsePlanLimits(body) {
122
155
  }
123
156
  : null;
124
157
  }
158
+ /**
159
+ * Defensively parse the top-level `thresholdCrossings` array.
160
+ *
161
+ * Every field is validated and a malformed ROW is dropped rather than failing
162
+ * the whole array — an older CLI must stay useful against a newer server that
163
+ * has added a band or a field. An unrecognised band is dropped for the same
164
+ * reason it is not guessed at: printing "you have reached critical of your
165
+ * secrets" is worse than printing nothing.
166
+ */
167
+ function parseThresholdCrossings(body) {
168
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
169
+ return [];
170
+ }
171
+ const raw = body.thresholdCrossings;
172
+ if (!Array.isArray(raw))
173
+ return [];
174
+ const out = [];
175
+ for (const value of raw) {
176
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
177
+ continue;
178
+ }
179
+ const rec = value;
180
+ if (typeof rec.resource !== "string" || rec.resource.length === 0)
181
+ continue;
182
+ if (typeof rec.band !== "string" ||
183
+ !PLAN_LIMIT_BANDS.includes(rec.band)) {
184
+ continue;
185
+ }
186
+ if (typeof rec.firstSeenAt !== "string" || rec.firstSeenAt.length === 0) {
187
+ continue;
188
+ }
189
+ if (typeof rec.used !== "number" || !Number.isFinite(rec.used))
190
+ continue;
191
+ if (typeof rec.limit !== "number" || !Number.isFinite(rec.limit))
192
+ continue;
193
+ out.push({
194
+ resource: rec.resource,
195
+ band: rec.band,
196
+ firstSeenAt: rec.firstSeenAt,
197
+ used: rec.used,
198
+ limit: rec.limit,
199
+ });
200
+ }
201
+ return out;
202
+ }
125
203
  /**
126
204
  * Record plan-limit status from a decoded JSON response body.
127
205
  * Never throws. Overwrites the last-seen cell when a well-formed
128
206
  * `planLimits` object is present; ignores malformed / absent payloads.
129
207
  */
130
- export function recordPlanLimitStatus(body) {
208
+ export function recordPlanLimitStatus(body, opts = {}) {
131
209
  try {
132
210
  const status = parsePlanLimits(body);
133
- if (status === null)
134
- return;
135
- const anyOver = Object.values(status.limits).some((e) => e.over);
136
- lastSeen = { ...status, anyOver };
211
+ if (status !== null) {
212
+ const anyOver = Object.values(status.limits).some((e) => e.over);
213
+ lastSeen = { ...status, anyOver };
214
+ }
215
+ // Independent of `planLimits`: the server can announce a crossing on a
216
+ // response whose nag map this CLI could not parse, and the notice is the
217
+ // one surface that must land inside a session that is already running.
218
+ emitPlanLimitThresholdNotice(parseThresholdCrossings(body), status?.upgradeUrl ?? null, opts);
137
219
  }
138
220
  catch {
139
221
  // Never throw from record path.
@@ -163,44 +245,148 @@ function formatPct(entry) {
163
245
  function formatEntryLine(key, entry) {
164
246
  return `${key} at ${entry.used}/${entry.limit} (${formatPct(entry)})${dimensionSuffix(key)}`;
165
247
  }
166
- function readShownAt(statePath) {
248
+ /**
249
+ * Read the whole state file. A missing, unreadable or malformed file is an
250
+ * empty state — the nag re-arms rather than being silenced by a bad write.
251
+ */
252
+ function readNagState(statePath) {
167
253
  try {
168
254
  const raw = fs.readFileSync(statePath, "utf-8");
169
255
  const parsed = JSON.parse(raw);
170
- if (typeof parsed.shownAt !== "number" || !Number.isFinite(parsed.shownAt)) {
171
- return null;
256
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
257
+ return {};
172
258
  }
173
- return parsed.shownAt;
259
+ const rec = parsed;
260
+ const state = {};
261
+ if (typeof rec.shownAt === "number" && Number.isFinite(rec.shownAt)) {
262
+ state.shownAt = rec.shownAt;
263
+ }
264
+ if (rec.notifiedThresholds !== null &&
265
+ typeof rec.notifiedThresholds === "object" &&
266
+ !Array.isArray(rec.notifiedThresholds)) {
267
+ const entries = Object.entries(rec.notifiedThresholds).filter((pair) => typeof pair[1] === "string");
268
+ if (entries.length > 0) {
269
+ state.notifiedThresholds = Object.fromEntries(entries);
270
+ }
271
+ }
272
+ return state;
174
273
  }
175
274
  catch {
176
- return null;
275
+ return {};
177
276
  }
178
277
  }
179
- function writeShownAt(statePath, shownAt) {
278
+ /**
279
+ * Merge `patch` into the state file and write it back.
280
+ *
281
+ * Read-modify-write rather than overwrite, so the over-limit box's `shownAt`
282
+ * and the threshold notice's `notifiedThresholds` — written by different
283
+ * surfaces at different moments — cannot erase each other.
284
+ */
285
+ function updateNagState(statePath, patch) {
180
286
  try {
287
+ const current = readNagState(statePath);
288
+ const next = {
289
+ ...current,
290
+ ...patch,
291
+ ...(patch.notifiedThresholds
292
+ ? {
293
+ notifiedThresholds: {
294
+ ...current.notifiedThresholds,
295
+ ...patch.notifiedThresholds,
296
+ },
297
+ }
298
+ : {}),
299
+ };
181
300
  fs.mkdirSync(path.dirname(statePath), { recursive: true });
182
- const payload = { shownAt };
183
- fs.writeFileSync(statePath, JSON.stringify(payload));
301
+ fs.writeFileSync(statePath, JSON.stringify(next));
184
302
  }
185
303
  catch {
186
304
  // best-effort; never break the CLI on cache write failure
187
305
  }
188
306
  }
189
- function withinDayWindow(shownAt, nowMs) {
190
- return nowMs - shownAt < DAY_MS;
307
+ function readShownAt(statePath) {
308
+ return readNagState(statePath).shownAt ?? null;
309
+ }
310
+ function writeShownAt(statePath, shownAt) {
311
+ updateNagState(statePath, { shownAt });
312
+ }
313
+ function withinNagWindow(shownAt, nowMs) {
314
+ return nowMs - shownAt < OVER_NAG_INTERVAL_MS;
315
+ }
316
+ /**
317
+ * The over-limit per-turn line (US-036 §2.2). One line, not the old box: it
318
+ * now repeats up to four times a day, and a multi-line box at that cadence
319
+ * reads as breakage rather than as a nag. It states the two paused things and
320
+ * never claims the workspace has stopped working.
321
+ */
322
+ export function buildOverLine(overEntries, upgradeUrl) {
323
+ const facts = overEntries
324
+ .map(([key, entry]) => `${PLAN_LIMIT_DIMENSION_LABELS[key] ?? key} ${entry.used} of ${entry.limit}`)
325
+ .join(", ");
326
+ return (`⚠ HQ Starter: ${facts}. New files and new secrets are paused. ` +
327
+ `Upgrade: ${upgradeUrl}`);
328
+ }
329
+ /** `"{resource}:{band}"` — the persisted key, matching the server's own. */
330
+ function crossingKey(crossing) {
331
+ return `${crossing.resource}:${crossing.band}`;
332
+ }
333
+ /** Human dimension name, falling back to the wire key for an unknown resource. */
334
+ function dimensionName(resource) {
335
+ return PLAN_LIMIT_DIMENSION_LABELS[resource] ?? resource;
336
+ }
337
+ /**
338
+ * One line per crossing.
339
+ *
340
+ * The copy states the fact and the exit, nothing else. It deliberately does
341
+ * not describe consequences that have not shipped: the hard stops arrive with
342
+ * their own story, and a notice that announces a pause nobody is experiencing
343
+ * reads as a bug.
344
+ */
345
+ export function renderThresholdNotice(crossing, upgradeUrl) {
346
+ const name = dimensionName(crossing.resource);
347
+ const counts = `${crossing.used} of ${crossing.limit}`;
348
+ const headline = crossing.band === "over"
349
+ ? `HQ Starter: you are past your ${name} limit (${counts}).`
350
+ : `HQ Starter: you have reached 80% of your ${name} (${counts}).`;
351
+ return `${headline} Upgrade: ${upgradeUrl}`;
191
352
  }
192
- function buildOverBox(overEntries, upgradeUrl) {
193
- const title = "⚠ HQ Starter plan limit exceeded";
194
- const upgrade = `Upgrade: ${upgradeUrl}`;
195
- const resourceLines = overEntries.map(([key, entry]) => ` ${key}: ${entry.used}/${entry.limit}${dimensionSuffix(key)}`);
196
- const contentLines = [title, "", ...resourceLines, "", upgrade];
197
- const innerWidth = Math.max(...contentLines.map((l) => l.length), 40);
198
- const top = `┌${"─".repeat(innerWidth + 2)}┐`;
199
- const bot = `└${"─".repeat(innerWidth + 2)}┘`;
200
- const mid = contentLines
201
- .map((l) => `│ ${l.padEnd(innerWidth)} │`)
202
- .join("\n");
203
- return `${top}\n${mid}\n${bot}`;
353
+ /**
354
+ * Print every crossing this machine has not already printed for this episode.
355
+ *
356
+ * Called from the record path, so a session already running prints on its next
357
+ * API call. Never throws; a state file it cannot read or write degrades to
358
+ * per-process dedupe rather than to silence or to repetition.
359
+ */
360
+ export function emitPlanLimitThresholdNotice(crossings, upgradeUrl, opts = {}) {
361
+ try {
362
+ if (!isPlanLimitNagEnabled())
363
+ return;
364
+ if (crossings.length === 0)
365
+ return;
366
+ const write = opts.write ?? ((s) => process.stderr.write(s));
367
+ const statePath = opts.statePath ?? defaultStatePath();
368
+ const stored = readNagState(statePath).notifiedThresholds ?? {};
369
+ const resolvedUpgradeUrl = upgradeUrl ?? PLAN_LIMIT_UPGRADE_URL;
370
+ const printed = {};
371
+ for (const crossing of crossings) {
372
+ const key = crossingKey(crossing);
373
+ // A DIFFERENT firstSeenAt is a different episode and prints again; the
374
+ // same one has already been said.
375
+ if (stored[key] === crossing.firstSeenAt)
376
+ continue;
377
+ if (thresholdsShownThisSession.get(key) === crossing.firstSeenAt)
378
+ continue;
379
+ thresholdsShownThisSession.set(key, crossing.firstSeenAt);
380
+ printed[key] = crossing.firstSeenAt;
381
+ write(chalk.yellow(renderThresholdNotice(crossing, resolvedUpgradeUrl)) + "\n");
382
+ }
383
+ if (Object.keys(printed).length > 0) {
384
+ updateNagState(statePath, { notifiedThresholds: printed });
385
+ }
386
+ }
387
+ catch {
388
+ // Never throw from the notice path.
389
+ }
204
390
  }
205
391
  /**
206
392
  * Emit a plan-limit nag to stderr at command completion.
@@ -227,12 +413,12 @@ export function emitPlanLimitNag(opts = {}) {
227
413
  return;
228
414
  const nowMs = now().getTime();
229
415
  const prev = readShownAt(statePath);
230
- if (prev !== null && withinDayWindow(prev, nowMs))
416
+ if (prev !== null && withinNagWindow(prev, nowMs))
231
417
  return;
232
418
  overShownThisSession = true;
233
419
  const overEntries = entries.filter(([, e]) => e.over);
234
- const box = buildOverBox(overEntries, resolvedUpgradeUrl);
235
- write(chalk.yellow(box) + "\n");
420
+ const line = buildOverLine(overEntries, resolvedUpgradeUrl);
421
+ write(chalk.yellow(line) + "\n");
236
422
  writeShownAt(statePath, nowMs);
237
423
  return;
238
424
  }
@@ -243,7 +429,7 @@ export function emitPlanLimitNag(opts = {}) {
243
429
  if (worst === null)
244
430
  return;
245
431
  warningShownThisSession = true;
246
- const line = `⚠ HQ Starter plan: ${formatEntryLine(worst.key, worst.entry)}. Upgrade: ${resolvedUpgradeUrl}`;
432
+ const line = `⚠ HQ Starter: ${formatEntryLine(worst.key, worst.entry)}. Add headroom before you hit the cap: ${resolvedUpgradeUrl}`;
247
433
  write(chalk.yellow(line) + "\n");
248
434
  }
249
435
  catch {
@@ -255,5 +441,6 @@ export function _resetForTests() {
255
441
  lastSeen = null;
256
442
  warningShownThisSession = false;
257
443
  overShownThisSession = false;
444
+ thresholdsShownThisSession.clear();
258
445
  }
259
446
  //# sourceMappingURL=plan-limit-nag.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.119.8",
3
+ "version": "5.119.10",
4
4
  "description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {