fullstackgtm 0.50.0 → 0.50.1

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.
@@ -9,6 +9,7 @@ import { computeMissedFirings, createFileScheduleRunStore, createFileScheduleSto
9
9
  import { runCli } from "../cli.ts";
10
10
  import { isOptionValue, numericOption, option } from "./shared.ts";
11
11
  import { unknownSubcommandError } from "./suggest.ts";
12
+ import { colorEnabled, paint, table } from "./ui.ts";
12
13
 
13
14
 
14
15
  /**
@@ -120,12 +121,19 @@ trigger: manual. status shows next firing and surfaces missed firings
120
121
  console.log('No schedules. Add one with `fullstackgtm schedule add "<command>" --cron "<expr>"`.');
121
122
  return;
122
123
  }
123
- for (const entry of withNext) {
124
- console.log(
125
- `${entry.id} ${entry.enabled ? "on " : "off"} ${entry.cron.padEnd(14)} next ${entry.nextFiring ?? "—"} ${entry.label}: ${entry.argv.join(" ")}`,
126
- );
127
- }
128
- console.log("\nEntries are declarative — `schedule install` materializes enabled ones into the crontab.");
124
+ const p = paint(colorEnabled(process.stdout));
125
+ const verbose = rest.includes("--verbose");
126
+ const rows = [
127
+ ["STATE", "SCHEDULE", "NEXT", ...(verbose ? ["CRON", "COMMAND"] : [])],
128
+ ...withNext.map((entry) => [
129
+ entry.enabled ? "on" : "off",
130
+ `${entry.label} · ${entry.id}`,
131
+ entry.nextFiring ?? "—",
132
+ ...(verbose ? [entry.cron, entry.argv.join(" ")] : []),
133
+ ]),
134
+ ];
135
+ console.log(table(rows, [p.dim, null, null]).join("\n"));
136
+ console.log("\nDeclarative only · run `fullstackgtm schedule install` after changes.");
129
137
  return;
130
138
  }
131
139
 
@@ -272,21 +280,19 @@ trigger: manual. status shows next firing and surfaces missed firings
272
280
  const last = entry.lastRun as { firedAt: string; trigger: string; exitCode: number; noopReason?: string; artifacts: { planIds: string[]; runLabels: string[] } } | null;
273
281
  const streak = entry.streak as { outcome: string; length: number } | null;
274
282
  const missed = entry.missedFirings as string[];
275
- console.log(`${entry.id} ${entry.enabled ? "on " : "off"} ${entry.cron} ${entry.label}: ${(entry.argv as string[]).join(" ")}`);
276
- console.log(` next: ${entry.nextFiring ?? " (disabled)"}`);
283
+ const verbose = rest.includes("--verbose");
284
+ const outcome = !last ? "never run" : last.exitCode === 0 ? (last.noopReason ? `no-op · ${last.noopReason}` : "healthy") : `failed · exit ${last.exitCode}`;
285
+ console.log(`${entry.enabled ? "●" : "○"} ${entry.label} · ${outcome}`);
286
+ console.log(` ${entry.id} · next ${entry.nextFiring ?? "— (disabled)"}`);
287
+ if (verbose) console.log(` ${(entry.argv as string[]).join(" ")} · cron ${entry.cron}`);
277
288
  if (last) {
278
289
  const artifacts = [
279
290
  ...last.artifacts.planIds.map((planId) => `plan ${planId}`),
280
291
  ...last.artifacts.runLabels.map((runLabel) => `run ${runLabel}`),
281
292
  ];
282
- console.log(
283
- ` last: ${last.firedAt} (${last.trigger}) exit ${last.exitCode}` +
284
- (last.noopReason ? ` — no-op: ${last.noopReason}` : "") +
285
- (artifacts.length ? ` — ${artifacts.join(", ")}` : ""),
286
- );
287
- console.log(` streak: ${streak!.length} ${streak!.outcome}(s)`);
293
+ console.log(` last ${last.firedAt} · ${last.trigger} · ${streak!.length} ${streak!.outcome}${streak!.length === 1 ? "" : "s"}${artifacts.length ? ` · ${artifacts.join(", ")}` : ""}`);
288
294
  } else {
289
- console.log(" last: never fired");
295
+ console.log(" last never fired");
290
296
  }
291
297
  if (missed.length > 0) {
292
298
  console.log(
@@ -27,6 +27,21 @@ function resolveSignalsConfig(args: string[]): SignalsConfig {
27
27
  /** A watchlist entry: an account domain plus optional per-source board tokens. */
28
28
  type WatchlistAccount = { domain: string; boards?: Partial<Record<AtsBoardSource, string>> };
29
29
 
30
+ function concise(value: string, max = 72): string {
31
+ const oneLine = value.replace(/\s+/g, " ").trim();
32
+ return oneLine.length <= max ? oneLine : `${oneLine.slice(0, max - 1)}…`;
33
+ }
34
+
35
+ function renderSignals(signals: Signal[]): string {
36
+ if (signals.length === 0) return "No signals matched.";
37
+ const lines = [`Fresh signals (${signals.length})`, ""];
38
+ for (const signal of signals) {
39
+ lines.push(`${signal.weight.toFixed(2).padStart(5)} ${signal.accountDomain} · ${signal.bucket}`);
40
+ lines.push(` ${concise(signal.trigger)}`);
41
+ }
42
+ return lines.join("\n");
43
+ }
44
+
30
45
  /**
31
46
  * Resolve the watchlist of accounts to scan. Sources, in precedence order:
32
47
  * - a --watchlist file (JSON array of {domain, boards?} or bare domain strings),
@@ -228,7 +243,7 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
228
243
  const ranked = [...fresh].sort((a, b) => b.weight - a.weight || a.accountDomain.localeCompare(b.accountDomain));
229
244
 
230
245
  // Ranked fresh signals to stdout; guidance to stderr.
231
- console.log(JSON.stringify(ranked, null, 2));
246
+ console.log(rest.includes("--json") || rest.includes("--verbose") ? JSON.stringify(ranked, null, 2) : renderSignals(ranked));
232
247
  console.error(
233
248
  `Fetched ${fetched} candidate signal(s); ${fresh.length} fresh, ${deduped.length} deduped ` +
234
249
  `(window ${config.dedupWindowDays}d). NO plan emitted — signals are read-only re: CRM.`,
@@ -275,7 +290,7 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
275
290
  return true;
276
291
  });
277
292
  const ranked = filtered.sort((a, b) => b.weight - a.weight || a.accountDomain.localeCompare(b.accountDomain));
278
- console.log(JSON.stringify(ranked, null, 2));
293
+ console.log(rest.includes("--json") || rest.includes("--verbose") ? JSON.stringify(ranked, null, 2) : renderSignals(ranked));
279
294
  console.error(`${ranked.length} signal(s)${unjudgedOnly ? " (unjudged)" : ""}.`);
280
295
  return;
281
296
  }
@@ -305,7 +320,11 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
305
320
  const outcomes = await store.listOutcomes();
306
321
  const signalsById = new Map((await store.allSignals()).map((s) => [s.id, s]));
307
322
  const weights = computeWeights(config, outcomes, signalsById);
308
- console.log(JSON.stringify(weights, null, 2));
323
+ if (rest.includes("--json") || rest.includes("--verbose")) {
324
+ console.log(JSON.stringify(weights, null, 2));
325
+ } else {
326
+ console.log(["Learned signal weights", "", ...SIGNAL_BUCKETS.map((bucket) => `${bucket.padEnd(10)} ${weights[bucket].toFixed(4)}`)].join("\n"));
327
+ }
309
328
  if (rest.includes("--explain")) {
310
329
  // Per-bucket config default vs learned + booked/total over credited signals.
311
330
  const booked = new Map<SignalBucket, number>();
package/src/cli/tam.ts CHANGED
@@ -275,8 +275,15 @@ RevOps universe, with real names. --source explorium is a firmographic count onl
275
275
  if (out) {
276
276
  writeFileSync(resolve(process.cwd(), out), md);
277
277
  console.log(`Wrote ${out}.`);
278
- } else {
278
+ } else if (rest.includes("--verbose")) {
279
279
  console.log(md);
280
+ } else if (timeline.length > 0) {
281
+ console.log(coverageToText(model, timeline.at(-1)!, eta));
282
+ console.log("\nUse --verbose for assumptions, cross-checks, and the full coverage history.");
283
+ } else {
284
+ console.log(`TAM "${model.name}" · ${model.universe.accounts.toLocaleString()} accounts · ${model.universe.contacts.toLocaleString()} buyers · $${Math.round(model.tamUsd).toLocaleString()}`);
285
+ console.log(`ICP ${model.icpName} · ${model.acv.basis}-basis ACV $${Math.round(model.acv.valueUsd).toLocaleString()} (${model.acv.source})`);
286
+ console.log("No coverage readings yet. Run `fullstackgtm tam status --save` to establish one; use --verbose for the full assumptions report.");
280
287
  }
281
288
  return;
282
289
  }
package/src/cli/ui.ts CHANGED
@@ -324,8 +324,12 @@ export function createChecklist(
324
324
  const label = entry.state === "pending" ? p.dim(item.label.padEnd(labelWidth)) : item.label.padEnd(labelWidth);
325
325
  return ` ${glyph} ${label}${note}`;
326
326
  });
327
- const up = painted > 0 ? `\u001b[${painted}A` : "";
328
- stream.write(`${up}${lines.map((line) => `\u001b[2K${line}`).join("\n")}\n`);
327
+ // Keep the cursor on the board's last row while it is live. A trailing
328
+ // newline on every repaint can scroll the old top row into permanent
329
+ // history when the board sits at the bottom of the terminal, producing
330
+ // apparent duplicate/errored frames in terminal captures.
331
+ const up = painted > 1 ? `\u001b[${painted - 1}A` : "";
332
+ stream.write(`${up}${lines.map((line) => `\u001b[2K${line}`).join("\n")}`);
329
333
  painted = lines.length;
330
334
  };
331
335
 
@@ -353,14 +357,19 @@ export function createChecklist(
353
357
  timer = null;
354
358
  if (options.persist) {
355
359
  // The caller's final state update already painted the completed board.
356
- // Freeze that frame in scrollback; repainting here can leave both the
357
- // last live frame and the final frame visible in some terminals.
360
+ // Advance exactly once so subsequent output begins below it.
361
+ if (painted > 0) stream.write("\n");
358
362
  painted = 0;
359
363
  return;
360
364
  }
361
365
  if (painted > 0) {
362
- // Erase the board: cursor up over every painted line, clear each.
363
- stream.write(`\u001b[${painted}A${"\u001b[2K\n".repeat(painted)}\u001b[${painted}A`);
366
+ // Erase in place without linefeeds, which could themselves scroll.
367
+ const up = painted > 1 ? `\u001b[${painted - 1}A` : "";
368
+ const clear = Array.from({ length: painted }, (_, index) =>
369
+ `\u001b[2K${index < painted - 1 ? "\u001b[1B" : ""}`,
370
+ ).join("");
371
+ const restore = painted > 1 ? `\u001b[${painted - 1}A` : "";
372
+ stream.write(`${up}${clear}${restore}`);
364
373
  painted = 0;
365
374
  }
366
375
  },
@@ -42,6 +42,10 @@ export type HubspotConnectorOptions = {
42
42
  * alongside the legacy `onProgress` callback; both are presentation-only.
43
43
  */
44
44
  progress?: ProgressEmitter;
45
+ /** Maximum retries for HubSpot 429/5xx responses (default 5). */
46
+ maxRetries?: number;
47
+ /** Injectable delay for deterministic retry tests. */
48
+ sleep?: (milliseconds: number) => Promise<void>;
45
49
  };
46
50
 
47
51
  const OBJECT_PATHS: Partial<Record<GtmObjectType, string>> = {
@@ -76,6 +80,8 @@ const PULL_STAGE_BY_TYPE: Record<SnapshotProgress["objectType"], (typeof SNAPSHO
76
80
  export function createHubspotConnector(options: HubspotConnectorOptions): HubspotWritableConnector {
77
81
  const baseUrl = (options.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/$/, "");
78
82
  const fetchImpl = options.fetchImpl ?? fetch;
83
+ const maxRetries = options.maxRetries ?? 5;
84
+ const sleep = options.sleep ?? ((milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)));
79
85
  const mappings = options.fieldMappings;
80
86
  // create:<Name> dedup within one connector lifetime (one apply run): the
81
87
  // search API is eventually consistent, so a just-created company is
@@ -113,27 +119,45 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Hubspo
113
119
  };
114
120
 
115
121
  async function request(path: string, init: RequestInit = {}): Promise<any> {
116
- const token = await options.getAccessToken();
117
- let response: Response;
118
- try {
119
- response = await fetchImpl(`${baseUrl}${path}`, {
120
- ...init,
121
- headers: {
122
- Authorization: `Bearer ${token}`,
123
- "Content-Type": "application/json",
124
- ...(init.headers ?? {}),
125
- },
126
- });
127
- } catch (error) {
128
- const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
129
- throw new Error(`Cannot reach HubSpot at ${baseUrl}${cause}. Check network access.`);
122
+ let response: Response | undefined;
123
+ for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
124
+ const token = await options.getAccessToken();
125
+ try {
126
+ response = await fetchImpl(`${baseUrl}${path}`, {
127
+ ...init,
128
+ headers: {
129
+ Authorization: `Bearer ${token}`,
130
+ "Content-Type": "application/json",
131
+ ...(init.headers ?? {}),
132
+ },
133
+ });
134
+ } catch (error) {
135
+ const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
136
+ throw new Error(`Cannot reach HubSpot at ${baseUrl}${cause}. Check network access.`);
137
+ }
138
+ const retryable = response.status === 429 || response.status >= 500;
139
+ if (!retryable || attempt === maxRetries) break;
140
+ await response.text().catch(() => undefined);
141
+ const retryAfter = response.headers.get("Retry-After");
142
+ const seconds = retryAfter === null ? NaN : Number(retryAfter);
143
+ const dateDelay = retryAfter && !Number.isFinite(seconds) ? Date.parse(retryAfter) - Date.now() : NaN;
144
+ const delayMs = Math.min(
145
+ 30_000,
146
+ Math.max(0, Number.isFinite(seconds) ? seconds * 1_000 : Number.isFinite(dateDelay) ? dateDelay : 1_000 * 2 ** attempt),
147
+ );
148
+ const reason = response.status === 429 ? "rate limited" : `temporarily unavailable (${response.status})`;
149
+ options.progress?.note(`HubSpot ${reason}; retrying in ${Math.ceil(delayMs / 1_000)}s (${attempt + 1}/${maxRetries})`);
150
+ await sleep(delayMs);
130
151
  }
131
- if (!response.ok) {
152
+ if (!response || !response.ok) {
132
153
  // Status line only — HubSpot 4xx bodies echo submitted property values
133
154
  // (contact emails, company domains) and the request payload, and these
134
155
  // errors are persisted into scheduled-run records. Never interpolate it.
135
- await response.text().catch(() => undefined);
136
- throw new Error(`HubSpot API error ${response.status}. Check the token scopes and request.`);
156
+ await response?.text().catch(() => undefined);
157
+ if (response?.status === 429) {
158
+ throw new Error(`HubSpot rate limit (429) persisted after ${maxRetries} retries. Wait for the portal limit to reset, then retry; no CRM writes were made.`);
159
+ }
160
+ throw new Error(`HubSpot API error ${response?.status ?? "unknown"}. Check the token scopes and request.`);
137
161
  }
138
162
  // DELETE and some association writes return 204 with an empty body.
139
163
  const text = await response.text();
@@ -207,7 +207,12 @@ export async function reportHostedPlanLifecycle(stored: StoredPlan, options: Opt
207
207
  if (!paired) return { status: "unpaired" };
208
208
  const status = stored.status === "applied" ? "applied" : stored.status === "rejected" ? "rejected" : "approved";
209
209
  const runRecord = status === "applied" ? stored.runs.at(-1) : undefined;
210
- const operationReceipts = runRecord?.results.map((result) => ({
210
+ // The hosted terminal transition is authority-bound to the exact approved
211
+ // subset. Local runs also record excluded operations as `skipped` for a
212
+ // complete human audit trail; those are not execution receipts and must not
213
+ // be echoed as though they were authorized provider work.
214
+ const approved = new Set(stored.approvedOperationIds);
215
+ const operationReceipts = runRecord?.results.filter((result) => approved.has(result.operationId)).map((result) => ({
211
216
  packageOpId: result.operationId,
212
217
  status: result.status,
213
218
  ...(result.detail ? { error: result.detail.slice(0, 1000) } : {}),