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.
package/dist/cli/plans.js CHANGED
@@ -17,7 +17,7 @@ import { progressReporter, reportCounts } from "../runReport.js";
17
17
  import { claimHostedPlanApply, reconcileHostedPatchPlan, releaseHostedPlanApply, reportHostedPlanLifecycle, uploadHostedPatchPlan } from "../hostedPatchPlan.js";
18
18
  import { APPLY_STAGES, composeListeners, createProgressEmitter } from "../progress.js";
19
19
  import { connectorFor, isOptionValue, numericOption, option, repeatedOption, selectedRules } from "./shared.js";
20
- import { colorEnabled, createProgressRenderer, paint, planStatusWord, stylizePlanMarkdown, table, truncateToWidth } from "./ui.js";
20
+ import { box, colorEnabled, createProgressRenderer, createStatusLine, formatDuration, paint, planStatusWord, stylizePlanMarkdown, table, truncateToWidth } from "./ui.js";
21
21
  import { unknownSubcommandError } from "./suggest.js";
22
22
  function parseValueOverrides(args) {
23
23
  const valueOverrides = {};
@@ -29,6 +29,119 @@ function parseValueOverrides(args) {
29
29
  }
30
30
  return valueOverrides;
31
31
  }
32
+ function recordHeadline(operation) {
33
+ const after = operation.afterValue && typeof operation.afterValue === "object" && !Array.isArray(operation.afterValue)
34
+ ? operation.afterValue : {};
35
+ const properties = after.properties && typeof after.properties === "object" && !Array.isArray(after.properties)
36
+ ? after.properties : {};
37
+ const first = typeof properties.firstname === "string" ? properties.firstname : "";
38
+ const last = typeof properties.lastname === "string" ? properties.lastname : "";
39
+ const company = typeof properties.company === "string" ? properties.company
40
+ : typeof after.associateCompanyName === "string" ? after.associateCompanyName : undefined;
41
+ const title = [first, last].filter(Boolean).join(" ") || company || operation.objectId || operation.id;
42
+ const role = typeof properties.jobtitle === "string" ? properties.jobtitle : undefined;
43
+ return { title, ...(role ? { subtitle: role } : {}), ...(company ? { company } : {}) };
44
+ }
45
+ function planEffect(stored) {
46
+ const total = stored.plan.operations.length;
47
+ const approved = stored.approvedOperationIds.length;
48
+ if (stored.status === "applied") {
49
+ const results = stored.runs.at(-1)?.results ?? [];
50
+ const applied = results.filter((result) => result.status === "applied").length;
51
+ const skipped = results.filter((result) => result.status === "skipped").length;
52
+ return `Completed ${applied} operation${applied === 1 ? "" : "s"}${skipped ? `; ${skipped} skipped` : ""}. No further writes will run.`;
53
+ }
54
+ if (stored.status === "approved")
55
+ return `Apply will execute ${approved} selected operation${approved === 1 ? "" : "s"}; ${total - approved} will not run.`;
56
+ if (stored.status === "rejected")
57
+ return "Rejected. No provider writes can run.";
58
+ return `No provider writes can run until operations are approved (0 of ${total} selected).`;
59
+ }
60
+ function printPlanCards(stored, hostedUrl) {
61
+ const p = paint(colorEnabled(process.stdout));
62
+ const total = stored.plan.operations.length;
63
+ const approved = new Set(stored.approvedOperationIds);
64
+ const width = Math.max(56, Math.min(100, (process.stdout.columns ?? 100) - 4));
65
+ const fit = (value) => truncateToWidth(value, width);
66
+ const summary = [
67
+ fit(stored.plan.title),
68
+ `Status ${stored.status.toUpperCase().replaceAll("_", " ")}`,
69
+ `Selection ${approved.size} of ${total} operation${total === 1 ? "" : "s"} approved`,
70
+ `Runs ${stored.runs.length}`,
71
+ fit(`Effect ${planEffect(stored)}`),
72
+ ];
73
+ console.log(box(summary, p, "Plan").join("\n"));
74
+ if (hostedUrl)
75
+ console.log(`Hosted: ${hostedUrl}`);
76
+ console.log("\nOperations");
77
+ let operationIndex = 0;
78
+ const latestResults = new Map((stored.runs.at(-1)?.results ?? []).map((result) => [result.operationId, result.status]));
79
+ for (const operation of stored.plan.operations) {
80
+ const selected = approved.has(operation.id);
81
+ const resultStatus = latestResults.get(operation.id);
82
+ const headline = recordHeadline(operation);
83
+ const action = operation.operation === "create_record"
84
+ ? `Create ${operation.objectType}${headline.company ? ` and resolve/link ${headline.company}` : ""}`
85
+ : `${operation.operation.replaceAll("_", " ")} ${operation.objectType}`;
86
+ const lines = [
87
+ fit(headline.title),
88
+ ...(headline.subtitle || headline.company
89
+ ? [fit([headline.subtitle, headline.company].filter(Boolean).join(" · "))]
90
+ : []),
91
+ fit(`Action ${action}`),
92
+ `Operation ${operation.id}`,
93
+ ];
94
+ if (operationIndex++ > 0)
95
+ console.log("");
96
+ const state = resultStatus === "applied" ? "✓ APPLIED"
97
+ : resultStatus && selected ? `! ${resultStatus.toUpperCase()}`
98
+ : selected ? "✓ APPROVED" : stored.status === "applied" ? "○ EXCLUDED" : "○ NOT APPROVED";
99
+ console.log(box(lines, p, state).join("\n"));
100
+ }
101
+ if (stored.status === "approved") {
102
+ console.log(`\nNext: fullstackgtm apply --plan-id ${stored.plan.id} --provider <hubspot|salesforce>`);
103
+ }
104
+ console.log(`Details: fullstackgtm plans show ${stored.plan.id} --verbose`);
105
+ }
106
+ function printApplyCards(run, plan, approvedOperationIds, planIdStored) {
107
+ const p = paint(colorEnabled(process.stdout));
108
+ const approved = new Set(approvedOperationIds);
109
+ const operations = new Map(plan.operations.map((operation) => [operation.id, operation]));
110
+ const counts = { applied: 0, skipped: 0, failed: 0, conflict: 0 };
111
+ for (const result of run.results)
112
+ counts[result.status] += 1;
113
+ const elapsed = Math.max(0, Date.parse(run.finishedAt) - Date.parse(run.startedAt));
114
+ const summary = [
115
+ `Status ${run.status.toUpperCase()}`,
116
+ `Provider ${run.provider}`,
117
+ `Outcome ${counts.applied} applied · ${run.results.length - approved.size} excluded · ${counts.failed + counts.conflict} failed/conflicted`,
118
+ `Duration ${formatDuration(elapsed)}`,
119
+ ...(planIdStored ? ["Receipt recorded locally; hosted reconciliation requested"] : []),
120
+ ];
121
+ console.log(box(summary, p, "Apply result").join("\n"));
122
+ console.log("\nOperations");
123
+ let index = 0;
124
+ for (const result of run.results) {
125
+ const operation = operations.get(result.operationId);
126
+ if (!operation)
127
+ continue;
128
+ const selected = approved.has(result.operationId);
129
+ const headline = recordHeadline(operation);
130
+ const state = !selected ? "○ EXCLUDED" : result.status === "applied" ? "✓ APPLIED" : `! ${result.status.toUpperCase()}`;
131
+ const lines = [
132
+ headline.title,
133
+ ...(headline.subtitle || headline.company ? [[headline.subtitle, headline.company].filter(Boolean).join(" · ")] : []),
134
+ `Operation ${result.operationId}`,
135
+ ...(result.detail ? [truncateToWidth(`Result ${result.detail}`, 100)] : []),
136
+ ];
137
+ if (index++ > 0)
138
+ console.log("");
139
+ console.log(box(lines, p, state).join("\n"));
140
+ }
141
+ if (planIdStored)
142
+ console.log(`\nInspect: fullstackgtm plans show ${run.planId}`);
143
+ console.log("Details: rerun with --verbose; machine output: --json");
144
+ }
32
145
  function tryLoadAcquireConfig(args) {
33
146
  try {
34
147
  const path = resolve(process.cwd(), option(args, "--config") ?? ENRICH_CONFIG_FILE_NAME);
@@ -322,9 +435,12 @@ export async function apply(args) {
322
435
  if (args.includes("--json")) {
323
436
  console.log(JSON.stringify(run, null, 2));
324
437
  }
325
- else {
438
+ else if (args.includes("--verbose")) {
326
439
  console.log(formatPatchPlanRun(run));
327
440
  }
441
+ else {
442
+ printApplyCards(run, plan, approvedOperationIds, Boolean(planId && store));
443
+ }
328
444
  if (run.status === "failed")
329
445
  process.exitCode = 1;
330
446
  }
@@ -422,31 +538,48 @@ export async function plansCommand(args) {
422
538
  }
423
539
  const p = paint(colorEnabled(process.stdout));
424
540
  if (p.enabled) {
425
- // Interactive terminals get an aligned table with status color-banding;
426
- // the plain per-line format below is unchanged for pipes.
541
+ const statusCounts = new Map();
542
+ for (const stored of plans)
543
+ statusCounts.set(stored.status, (statusCounts.get(stored.status) ?? 0) + 1);
544
+ const awaiting = plans.filter((stored) => stored.status === "needs_approval").length;
545
+ const approved = plans.filter((stored) => stored.status === "approved").length;
546
+ console.log(box([
547
+ `${plans.length} plan${plans.length === 1 ? "" : "s"} · ${awaiting} awaiting approval · ${approved} ready to apply`,
548
+ [...statusCounts.entries()].map(([name, count]) => `${name.replaceAll("_", " ")}: ${count}`).join(" · "),
549
+ ], p, "Change queue").join("\n"));
550
+ console.log("");
551
+ // Interactive terminals get an aligned decision table; the plain
552
+ // per-line format below remains stable for pipes and scripts.
427
553
  const rows = plans.map((stored) => [
428
- stored.plan.id,
429
554
  stored.status,
430
- `${stored.approvedOperationIds.length} approved`,
555
+ stored.plan.title,
556
+ `${stored.approvedOperationIds.length}/${stored.plan.operations.length} selected`,
431
557
  `${stored.runs.length} run${stored.runs.length === 1 ? "" : "s"}`,
432
- stored.plan.summary,
558
+ stored.plan.id,
433
559
  ]);
434
560
  // Long summaries would wrap and break the table's alignment. The summary
435
561
  // is the LAST column: cap it to what's left of the terminal width after
436
562
  // the fixed columns and their two-space gutters (floor of 24 so narrow
437
563
  // terminals still show something useful).
438
564
  const columns = process.stdout.columns ?? 80;
439
- const fixedWidth = [0, 1, 2, 3].reduce((sum, index) => sum + Math.max(...rows.map((row) => row[index].length)), 0) + 2 * 4;
440
- const summaryWidth = Math.max(24, columns - fixedWidth);
565
+ const fixedWidth = [0, 2, 3, 4].reduce((sum, index) => sum + Math.max(...rows.map((row) => row[index].length)), 0) + 2 * 4;
566
+ const titleWidth = Math.max(24, columns - fixedWidth);
441
567
  for (const row of rows)
442
- row[4] = truncateToWidth(row[4], summaryWidth);
568
+ row[1] = truncateToWidth(row[1], titleWidth);
443
569
  const statusPainter = (cell) => {
444
570
  const painted = planStatusWord(cell.trimEnd(), p);
445
571
  return painted + cell.slice(cell.trimEnd().length);
446
572
  };
447
- for (const line of table(rows, [null, statusPainter, p.dim, p.dim, null])) {
573
+ for (const line of table(rows, [statusPainter, null, p.dim, p.dim, p.dim])) {
448
574
  console.log(line);
449
575
  }
576
+ const next = plans.find((stored) => stored.status === "approved") ?? plans.find((stored) => stored.status === "needs_approval");
577
+ if (next) {
578
+ const command = next.status === "approved"
579
+ ? `fullstackgtm apply --plan-id ${next.plan.id} --provider <name>`
580
+ : `fullstackgtm plans show ${next.plan.id}`;
581
+ console.log(`\nNext: ${command}`);
582
+ }
450
583
  return;
451
584
  }
452
585
  for (const stored of plans) {
@@ -473,16 +606,11 @@ export async function plansCommand(args) {
473
606
  console.log(JSON.stringify(stored, null, 2));
474
607
  return;
475
608
  }
476
- const showPaint = paint(colorEnabled(process.stdout));
477
- console.log(`Status: ${planStatusWord(stored.status, showPaint)}`);
478
- console.log(`Approved operations: ${stored.approvedOperationIds.join(", ") || "none"}`);
479
- console.log(`Runs: ${stored.runs.length}`);
480
609
  const mirror = await uploadHostedPatchPlan(stored);
481
- if (mirror.status === "saved")
482
- console.log(`Hosted review: ${mirror.state.url}`);
483
- else if (mirror.status === "unavailable" || mirror.status === "conflict")
484
- console.log(`Hosted review: sync pending (${mirror.reason})`);
485
- if (stored.applyAttempts?.length) {
610
+ printPlanCards(stored, mirror.status === "saved" ? mirror.state.url : undefined);
611
+ if (mirror.status === "unavailable" || mirror.status === "conflict")
612
+ console.log(`Hosted sync pending: ${mirror.reason}`);
613
+ if (rest.includes("--verbose") && stored.applyAttempts?.length) {
486
614
  console.log("Apply attempts:");
487
615
  for (const attempt of stored.applyAttempts) {
488
616
  console.log(` ${attempt.id} ${attempt.status} ${attempt.provider} via ${attempt.source} ${attempt.claimedAt}`);
@@ -490,8 +618,11 @@ export async function plansCommand(args) {
490
618
  console.log(` ${attempt.note}`);
491
619
  }
492
620
  }
493
- console.log("");
494
- console.log(stylizePlanMarkdown(patchPlanToMarkdown(stored.plan), showPaint));
621
+ if (rest.includes("--verbose")) {
622
+ const showPaint = paint(colorEnabled(process.stdout));
623
+ console.log("\nFull plan document\n");
624
+ console.log(stylizePlanMarkdown(patchPlanToMarkdown(stored.plan), showPaint));
625
+ }
495
626
  return;
496
627
  }
497
628
  if (subcommand === "approve") {
@@ -578,19 +709,39 @@ export async function plansCommand(args) {
578
709
  if (subcommand === "sync") {
579
710
  const plans = await store.list();
580
711
  let updated = 0;
712
+ let completed = 0;
581
713
  const warnings = [];
582
- for (const stored of plans) {
583
- const result = await reconcileHostedPatchPlan(store, stored);
584
- if (result.status === "updated")
585
- updated += 1;
586
- else if (result.status === "conflict" || result.status === "unavailable")
587
- warnings.push(`${stored.plan.id}: ${result.reason}`);
588
- if (stored.status === "applied") {
589
- const hostedClaimId = [...(stored.applyAttempts ?? [])].reverse().find((attempt) => attempt.hostedClaimId)?.hostedClaimId;
590
- const pushed = await reportHostedPlanLifecycle(stored, { claimId: hostedClaimId });
591
- if (pushed.status === "conflict" || pushed.status === "unavailable")
592
- warnings.push(`${stored.plan.id} receipt: ${pushed.reason}`);
714
+ const status = createStatusLine();
715
+ status.set(`Synchronizing plan replicas 0/${plans.length}`);
716
+ // A workspace can accumulate hundreds of local plans. Reconcile with a
717
+ // small worker pool so one network round-trip per plan does not turn into
718
+ // a minutes-long silent serial wait, without stampeding the hosted API.
719
+ let next = 0;
720
+ const worker = async () => {
721
+ while (next < plans.length) {
722
+ const stored = plans[next++];
723
+ const result = await reconcileHostedPatchPlan(store, stored);
724
+ if (result.status === "updated")
725
+ updated += 1;
726
+ else if (result.status === "conflict" || result.status === "unavailable")
727
+ warnings.push(`${stored.plan.id}: ${result.reason}`);
728
+ // Historical local-only plans have no remote row to receive a receipt.
729
+ // `backfill runs` is the explicit import path for that old history.
730
+ if (stored.status === "applied" && result.status !== "missing" && (stored.applyAttempts?.length ?? 0) > 0) {
731
+ const hostedClaimId = [...(stored.applyAttempts ?? [])].reverse().find((attempt) => attempt.hostedClaimId)?.hostedClaimId;
732
+ const pushed = await reportHostedPlanLifecycle(stored, { claimId: hostedClaimId });
733
+ if (pushed.status === "conflict" || pushed.status === "unavailable")
734
+ warnings.push(`${stored.plan.id} receipt: ${pushed.reason}`);
735
+ }
736
+ completed += 1;
737
+ status.set(`Synchronizing plan replicas ${completed}/${plans.length}`);
593
738
  }
739
+ };
740
+ try {
741
+ await Promise.all(Array.from({ length: Math.min(6, plans.length) }, () => worker()));
742
+ }
743
+ finally {
744
+ status.done();
594
745
  }
595
746
  console.log(`Plan replicas synchronized: ${updated} updated, ${plans.length - updated} unchanged.`);
596
747
  for (const warning of warnings)
@@ -8,6 +8,7 @@ import { computeMissedFirings, createFileScheduleRunStore, createFileScheduleSto
8
8
  import { runCli } from "../cli.js";
9
9
  import { isOptionValue, numericOption, option } from "./shared.js";
10
10
  import { unknownSubcommandError } from "./suggest.js";
11
+ import { colorEnabled, paint, table } from "./ui.js";
11
12
  /**
12
13
  * The schedule layer: declarative cadences for read/plan-side commands,
13
14
  * materialized through a provider (MVP: the user crontab), with an
@@ -113,10 +114,19 @@ trigger: manual. status shows next firing and surfaces missed firings
113
114
  console.log('No schedules. Add one with `fullstackgtm schedule add "<command>" --cron "<expr>"`.');
114
115
  return;
115
116
  }
116
- for (const entry of withNext) {
117
- console.log(`${entry.id} ${entry.enabled ? "on " : "off"} ${entry.cron.padEnd(14)} next ${entry.nextFiring ?? ""} ${entry.label}: ${entry.argv.join(" ")}`);
118
- }
119
- console.log("\nEntries are declarative `schedule install` materializes enabled ones into the crontab.");
117
+ const p = paint(colorEnabled(process.stdout));
118
+ const verbose = rest.includes("--verbose");
119
+ const rows = [
120
+ ["STATE", "SCHEDULE", "NEXT", ...(verbose ? ["CRON", "COMMAND"] : [])],
121
+ ...withNext.map((entry) => [
122
+ entry.enabled ? "on" : "off",
123
+ `${entry.label} · ${entry.id}`,
124
+ entry.nextFiring ?? "—",
125
+ ...(verbose ? [entry.cron, entry.argv.join(" ")] : []),
126
+ ]),
127
+ ];
128
+ console.log(table(rows, [p.dim, null, null]).join("\n"));
129
+ console.log("\nDeclarative only · run `fullstackgtm schedule install` after changes.");
120
130
  return;
121
131
  }
122
132
  if (subcommand === "remove") {
@@ -251,20 +261,21 @@ trigger: manual. status shows next firing and surfaces missed firings
251
261
  const last = entry.lastRun;
252
262
  const streak = entry.streak;
253
263
  const missed = entry.missedFirings;
254
- console.log(`${entry.id} ${entry.enabled ? "on " : "off"} ${entry.cron} ${entry.label}: ${entry.argv.join(" ")}`);
255
- console.log(` next: ${entry.nextFiring ?? " (disabled)"}`);
264
+ const verbose = rest.includes("--verbose");
265
+ const outcome = !last ? "never run" : last.exitCode === 0 ? (last.noopReason ? `no-op · ${last.noopReason}` : "healthy") : `failed · exit ${last.exitCode}`;
266
+ console.log(`${entry.enabled ? "●" : "○"} ${entry.label} · ${outcome}`);
267
+ console.log(` ${entry.id} · next ${entry.nextFiring ?? "— (disabled)"}`);
268
+ if (verbose)
269
+ console.log(` ${entry.argv.join(" ")} · cron ${entry.cron}`);
256
270
  if (last) {
257
271
  const artifacts = [
258
272
  ...last.artifacts.planIds.map((planId) => `plan ${planId}`),
259
273
  ...last.artifacts.runLabels.map((runLabel) => `run ${runLabel}`),
260
274
  ];
261
- console.log(` last: ${last.firedAt} (${last.trigger}) exit ${last.exitCode}` +
262
- (last.noopReason ? ` — no-op: ${last.noopReason}` : "") +
263
- (artifacts.length ? ` — ${artifacts.join(", ")}` : ""));
264
- console.log(` streak: ${streak.length} ${streak.outcome}(s)`);
275
+ console.log(` last ${last.firedAt} · ${last.trigger} · ${streak.length} ${streak.outcome}${streak.length === 1 ? "" : "s"}${artifacts.length ? ` · ${artifacts.join(", ")}` : ""}`);
265
276
  }
266
277
  else {
267
- console.log(" last: never fired");
278
+ console.log(" last never fired");
268
279
  }
269
280
  if (missed.length > 0) {
270
281
  console.log(` missed: ${missed.length}${entry.missedFiringsCapped ? "+" : ""} expected firing(s) with no run record ` +
@@ -22,6 +22,20 @@ function resolveSignalsConfig(args) {
22
22
  return loadSignalsConfig(local);
23
23
  return DEFAULT_SIGNALS_CONFIG;
24
24
  }
25
+ function concise(value, max = 72) {
26
+ const oneLine = value.replace(/\s+/g, " ").trim();
27
+ return oneLine.length <= max ? oneLine : `${oneLine.slice(0, max - 1)}…`;
28
+ }
29
+ function renderSignals(signals) {
30
+ if (signals.length === 0)
31
+ return "No signals matched.";
32
+ const lines = [`Fresh signals (${signals.length})`, ""];
33
+ for (const signal of signals) {
34
+ lines.push(`${signal.weight.toFixed(2).padStart(5)} ${signal.accountDomain} · ${signal.bucket}`);
35
+ lines.push(` ${concise(signal.trigger)}`);
36
+ }
37
+ return lines.join("\n");
38
+ }
25
39
  /**
26
40
  * Resolve the watchlist of accounts to scan. Sources, in precedence order:
27
41
  * - a --watchlist file (JSON array of {domain, boards?} or bare domain strings),
@@ -211,7 +225,7 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
211
225
  const { fresh, deduped } = dedupeSignals(candidates, priorSignals, config.dedupWindowDays, now);
212
226
  const ranked = [...fresh].sort((a, b) => b.weight - a.weight || a.accountDomain.localeCompare(b.accountDomain));
213
227
  // Ranked fresh signals to stdout; guidance to stderr.
214
- console.log(JSON.stringify(ranked, null, 2));
228
+ console.log(rest.includes("--json") || rest.includes("--verbose") ? JSON.stringify(ranked, null, 2) : renderSignals(ranked));
215
229
  console.error(`Fetched ${fetched} candidate signal(s); ${fresh.length} fresh, ${deduped.length} deduped ` +
216
230
  `(window ${config.dedupWindowDays}d). NO plan emitted — signals are read-only re: CRM.`);
217
231
  const save = saveRequested(rest);
@@ -259,7 +273,7 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
259
273
  return true;
260
274
  });
261
275
  const ranked = filtered.sort((a, b) => b.weight - a.weight || a.accountDomain.localeCompare(b.accountDomain));
262
- console.log(JSON.stringify(ranked, null, 2));
276
+ console.log(rest.includes("--json") || rest.includes("--verbose") ? JSON.stringify(ranked, null, 2) : renderSignals(ranked));
263
277
  console.error(`${ranked.length} signal(s)${unjudgedOnly ? " (unjudged)" : ""}.`);
264
278
  return;
265
279
  }
@@ -286,7 +300,12 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
286
300
  const outcomes = await store.listOutcomes();
287
301
  const signalsById = new Map((await store.allSignals()).map((s) => [s.id, s]));
288
302
  const weights = computeWeights(config, outcomes, signalsById);
289
- console.log(JSON.stringify(weights, null, 2));
303
+ if (rest.includes("--json") || rest.includes("--verbose")) {
304
+ console.log(JSON.stringify(weights, null, 2));
305
+ }
306
+ else {
307
+ console.log(["Learned signal weights", "", ...SIGNAL_BUCKETS.map((bucket) => `${bucket.padEnd(10)} ${weights[bucket].toFixed(4)}`)].join("\n"));
308
+ }
290
309
  if (rest.includes("--explain")) {
291
310
  // Per-bucket config default vs learned + booked/total over credited signals.
292
311
  const booked = new Map();
package/dist/cli/tam.js CHANGED
@@ -253,9 +253,18 @@ RevOps universe, with real names. --source explorium is a firmographic count onl
253
253
  writeFileSync(resolve(process.cwd(), out), md);
254
254
  console.log(`Wrote ${out}.`);
255
255
  }
256
- else {
256
+ else if (rest.includes("--verbose")) {
257
257
  console.log(md);
258
258
  }
259
+ else if (timeline.length > 0) {
260
+ console.log(coverageToText(model, timeline.at(-1), eta));
261
+ console.log("\nUse --verbose for assumptions, cross-checks, and the full coverage history.");
262
+ }
263
+ else {
264
+ console.log(`TAM "${model.name}" · ${model.universe.accounts.toLocaleString()} accounts · ${model.universe.contacts.toLocaleString()} buyers · $${Math.round(model.tamUsd).toLocaleString()}`);
265
+ console.log(`ICP ${model.icpName} · ${model.acv.basis}-basis ACV $${Math.round(model.acv.valueUsd).toLocaleString()} (${model.acv.source})`);
266
+ console.log("No coverage readings yet. Run `fullstackgtm tam status --save` to establish one; use --verbose for the full assumptions report.");
267
+ }
259
268
  return;
260
269
  }
261
270
  if (sub === "populate") {
package/dist/cli/ui.js CHANGED
@@ -262,8 +262,12 @@ export function createChecklist(items, stream = process.stderr, env = process.en
262
262
  const label = entry.state === "pending" ? p.dim(item.label.padEnd(labelWidth)) : item.label.padEnd(labelWidth);
263
263
  return ` ${glyph} ${label}${note}`;
264
264
  });
265
- const up = painted > 0 ? `\u001b[${painted}A` : "";
266
- stream.write(`${up}${lines.map((line) => `\u001b[2K${line}`).join("\n")}\n`);
265
+ // Keep the cursor on the board's last row while it is live. A trailing
266
+ // newline on every repaint can scroll the old top row into permanent
267
+ // history when the board sits at the bottom of the terminal, producing
268
+ // apparent duplicate/errored frames in terminal captures.
269
+ const up = painted > 1 ? `\u001b[${painted - 1}A` : "";
270
+ stream.write(`${up}${lines.map((line) => `\u001b[2K${line}`).join("\n")}`);
267
271
  painted = lines.length;
268
272
  };
269
273
  const start = () => {
@@ -292,14 +296,18 @@ export function createChecklist(items, stream = process.stderr, env = process.en
292
296
  timer = null;
293
297
  if (options.persist) {
294
298
  // The caller's final state update already painted the completed board.
295
- // Freeze that frame in scrollback; repainting here can leave both the
296
- // last live frame and the final frame visible in some terminals.
299
+ // Advance exactly once so subsequent output begins below it.
300
+ if (painted > 0)
301
+ stream.write("\n");
297
302
  painted = 0;
298
303
  return;
299
304
  }
300
305
  if (painted > 0) {
301
- // Erase the board: cursor up over every painted line, clear each.
302
- stream.write(`\u001b[${painted}A${"\u001b[2K\n".repeat(painted)}\u001b[${painted}A`);
306
+ // Erase in place without linefeeds, which could themselves scroll.
307
+ const up = painted > 1 ? `\u001b[${painted - 1}A` : "";
308
+ const clear = Array.from({ length: painted }, (_, index) => `\u001b[2K${index < painted - 1 ? "\u001b[1B" : ""}`).join("");
309
+ const restore = painted > 1 ? `\u001b[${painted - 1}A` : "";
310
+ stream.write(`${up}${clear}${restore}`);
303
311
  painted = 0;
304
312
  }
305
313
  },
@@ -18,6 +18,10 @@ export type HubspotConnectorOptions = {
18
18
  * alongside the legacy `onProgress` callback; both are presentation-only.
19
19
  */
20
20
  progress?: ProgressEmitter;
21
+ /** Maximum retries for HubSpot 429/5xx responses (default 5). */
22
+ maxRetries?: number;
23
+ /** Injectable delay for deterministic retry tests. */
24
+ sleep?: (milliseconds: number) => Promise<void>;
21
25
  };
22
26
  /**
23
27
  * Reference connector for HubSpot.
@@ -30,6 +30,8 @@ const PULL_STAGE_BY_TYPE = {
30
30
  export function createHubspotConnector(options) {
31
31
  const baseUrl = (options.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/$/, "");
32
32
  const fetchImpl = options.fetchImpl ?? fetch;
33
+ const maxRetries = options.maxRetries ?? 5;
34
+ const sleep = options.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
33
35
  const mappings = options.fieldMappings;
34
36
  // create:<Name> dedup within one connector lifetime (one apply run): the
35
37
  // search API is eventually consistent, so a just-created company is
@@ -66,28 +68,44 @@ export function createHubspotConnector(options) {
66
68
  emitter.items(fetched);
67
69
  };
68
70
  async function request(path, init = {}) {
69
- const token = await options.getAccessToken();
70
71
  let response;
71
- try {
72
- response = await fetchImpl(`${baseUrl}${path}`, {
73
- ...init,
74
- headers: {
75
- Authorization: `Bearer ${token}`,
76
- "Content-Type": "application/json",
77
- ...(init.headers ?? {}),
78
- },
79
- });
80
- }
81
- catch (error) {
82
- const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
83
- throw new Error(`Cannot reach HubSpot at ${baseUrl}${cause}. Check network access.`);
84
- }
85
- if (!response.ok) {
72
+ for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
73
+ const token = await options.getAccessToken();
74
+ try {
75
+ response = await fetchImpl(`${baseUrl}${path}`, {
76
+ ...init,
77
+ headers: {
78
+ Authorization: `Bearer ${token}`,
79
+ "Content-Type": "application/json",
80
+ ...(init.headers ?? {}),
81
+ },
82
+ });
83
+ }
84
+ catch (error) {
85
+ const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
86
+ throw new Error(`Cannot reach HubSpot at ${baseUrl}${cause}. Check network access.`);
87
+ }
88
+ const retryable = response.status === 429 || response.status >= 500;
89
+ if (!retryable || attempt === maxRetries)
90
+ break;
91
+ await response.text().catch(() => undefined);
92
+ const retryAfter = response.headers.get("Retry-After");
93
+ const seconds = retryAfter === null ? NaN : Number(retryAfter);
94
+ const dateDelay = retryAfter && !Number.isFinite(seconds) ? Date.parse(retryAfter) - Date.now() : NaN;
95
+ const delayMs = Math.min(30_000, Math.max(0, Number.isFinite(seconds) ? seconds * 1_000 : Number.isFinite(dateDelay) ? dateDelay : 1_000 * 2 ** attempt));
96
+ const reason = response.status === 429 ? "rate limited" : `temporarily unavailable (${response.status})`;
97
+ options.progress?.note(`HubSpot ${reason}; retrying in ${Math.ceil(delayMs / 1_000)}s (${attempt + 1}/${maxRetries})`);
98
+ await sleep(delayMs);
99
+ }
100
+ if (!response || !response.ok) {
86
101
  // Status line only — HubSpot 4xx bodies echo submitted property values
87
102
  // (contact emails, company domains) and the request payload, and these
88
103
  // errors are persisted into scheduled-run records. Never interpolate it.
89
- await response.text().catch(() => undefined);
90
- throw new Error(`HubSpot API error ${response.status}. Check the token scopes and request.`);
104
+ await response?.text().catch(() => undefined);
105
+ if (response?.status === 429) {
106
+ 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.`);
107
+ }
108
+ throw new Error(`HubSpot API error ${response?.status ?? "unknown"}. Check the token scopes and request.`);
91
109
  }
92
110
  // DELETE and some association writes return 204 with an empty body.
93
111
  const text = await response.text();
@@ -191,7 +191,12 @@ export async function reportHostedPlanLifecycle(stored, options = {}) {
191
191
  return { status: "unpaired" };
192
192
  const status = stored.status === "applied" ? "applied" : stored.status === "rejected" ? "rejected" : "approved";
193
193
  const runRecord = status === "applied" ? stored.runs.at(-1) : undefined;
194
- const operationReceipts = runRecord?.results.map((result) => ({
194
+ // The hosted terminal transition is authority-bound to the exact approved
195
+ // subset. Local runs also record excluded operations as `skipped` for a
196
+ // complete human audit trail; those are not execution receipts and must not
197
+ // be echoed as though they were authorized provider work.
198
+ const approved = new Set(stored.approvedOperationIds);
199
+ const operationReceipts = runRecord?.results.filter((result) => approved.has(result.operationId)).map((result) => ({
195
200
  packageOpId: result.operationId,
196
201
  status: result.status,
197
202
  ...(result.detail ? { error: result.detail.slice(0, 1000) } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullstackgtm",
3
- "version": "0.50.0",
3
+ "version": "0.50.1",
4
4
  "description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Full Stack GTM LLC <ryan@fullstackgtm.com> (https://fullstackgtm.com)",
package/src/cli/audit.ts CHANGED
@@ -13,6 +13,7 @@ import { reportCounts, reportCrm, reportFindings } from "../runReport.ts";
13
13
  import type { AuditFindingSeverity, CanonicalGtmSnapshot, PatchPlan } from "../types.ts";
14
14
  import { connectorFor, numericOption, option, readSnapshot, saveRequested, selectedRules } from "./shared.ts";
15
15
  import { colorEnabled, createChecklist, formatCount, paint, scoreColor, sparkline, stylizePlanMarkdown, table, type Paint } from "./ui.ts";
16
+ import { compactPlan, verbosePlanRequested } from "./planOutput.ts";
16
17
 
17
18
 
18
19
  const SEVERITY_RANK: Record<AuditFindingSeverity, number> = {
@@ -258,13 +259,16 @@ export async function audit(args: string[]) {
258
259
  }
259
260
  if (args.includes("--json")) {
260
261
  console.log(JSON.stringify(plan, null, 2));
262
+ } else if (!verbosePlanRequested(args)) {
263
+ console.log(compactPlan(plan, { saved: saveRequested(args) }));
264
+ console.error(`\n${auditNextStep(args, plan)}`);
261
265
  } else {
262
266
  // Default to the summary view (rule table + counts); the full per-operation
263
267
  // dump is opt-in via --full or, for a deliverable, `report`. (dx-punch-list #3)
264
268
  // Interactive terminals get the styled rendering; piped output is unchanged.
265
269
  console.log(
266
270
  stylizePlanMarkdown(
267
- patchPlanToMarkdown(plan, { summary: !args.includes("--full") }),
271
+ patchPlanToMarkdown(plan, { summary: false }),
268
272
  paint(colorEnabled(process.stdout)),
269
273
  ),
270
274
  );
package/src/cli/auth.ts CHANGED
@@ -638,6 +638,30 @@ export async function doctorCommand(args: string[]) {
638
638
  workspace.auditCount === 0
639
639
  ? p.dim("no saved audits yet (fullstackgtm audit --provider <name> --save starts the timeline)")
640
640
  : `${scoreColor(workspace.healthScore ?? 0, p)}/100${delta} — ${workspace.auditCount} audit(s) saved, last ${workspace.lastAuditAt}${healthSparkline(workspace.profile, p.enabled)}`;
641
+ const connectedProviders = Object.entries(report.providers).filter(([, provider]) => provider.source !== "none");
642
+ const blockers = [
643
+ ...(!report.node.ok ? [`Node v${report.node.version} is unsupported; install ${report.node.required}.`] : []),
644
+ ...(connectedProviders.length === 0 ? ["No CRM connected."] : []),
645
+ ...(workspace.pendingPlans.length > 0 ? [`${workspace.pendingPlans.length} plan${workspace.pendingPlans.length === 1 ? "" : "s"} awaiting approval.`] : []),
646
+ ];
647
+ if (!args.includes("--verbose")) {
648
+ const ready = report.node.ok && connectedProviders.length > 0;
649
+ const compact = [
650
+ `${ready ? p.green("Ready") : p.yellow("Setup needed")} · ${report.package.name} ${report.package.version} · profile ${report.profile}`,
651
+ `CRM ${connectedProviders.length > 0 ? connectedProviders.map(([name]) => name).join(", ") : "not connected"}`,
652
+ `Health ${healthLine}`,
653
+ `Plans ${workspace.pendingPlans.length === 0 ? "none awaiting approval" : `${workspace.pendingPlans.length} awaiting approval · ${workspace.pendingPlans[0].id}`}`,
654
+ ...(blockers.length > 0 ? ["", "Attention", ...blockers.map((blocker) => ` ${blocker}`)] : []),
655
+ "",
656
+ "Next",
657
+ ...nextSteps.map((step) => ` ${step}`),
658
+ "",
659
+ "Run `fullstackgtm doctor --verbose` for credential paths and optional tooling checks.",
660
+ ];
661
+ console.log(p.enabled ? box(compact, p, "Workspace readiness").join("\n") : compact.join("\n"));
662
+ if (!report.node.ok) process.exitCode = 1;
663
+ return;
664
+ }
641
665
  const lines = [
642
666
  `Package: ${p.bold(`${report.package.name} ${report.package.version}`)}`,
643
667
  `Node: v${report.node.version} (${report.node.required} required) ${mark(report.node.ok)}`,