fullstackgtm 0.50.0 → 0.51.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +7 -0
  3. package/dist/cli/audit.js +6 -1
  4. package/dist/cli/auth.js +49 -4
  5. package/dist/cli/backfill.js +4 -0
  6. package/dist/cli/call.js +28 -6
  7. package/dist/cli/draft.js +11 -1
  8. package/dist/cli/enrich.js +124 -57
  9. package/dist/cli/fix.js +6 -3
  10. package/dist/cli/help.js +31 -28
  11. package/dist/cli/icp.js +15 -1
  12. package/dist/cli/init.js +3 -3
  13. package/dist/cli/market.js +14 -1
  14. package/dist/cli/planOutput.d.ts +5 -0
  15. package/dist/cli/planOutput.js +27 -0
  16. package/dist/cli/plans.js +184 -33
  17. package/dist/cli/schedule.js +22 -11
  18. package/dist/cli/signals.js +22 -3
  19. package/dist/cli/tam.d.ts +1 -1
  20. package/dist/cli/tam.js +14 -2
  21. package/dist/cli/ui.js +14 -6
  22. package/dist/connectors/clay.d.ts +33 -0
  23. package/dist/connectors/clay.js +123 -0
  24. package/dist/connectors/hubspot.d.ts +4 -0
  25. package/dist/connectors/hubspot.js +36 -18
  26. package/dist/connectors/prospectSources.d.ts +12 -0
  27. package/dist/connectors/prospectSources.js +1 -1
  28. package/dist/contactProviders.d.ts +44 -0
  29. package/dist/contactProviders.js +100 -0
  30. package/dist/enrich.d.ts +7 -1
  31. package/dist/enrich.js +46 -1
  32. package/dist/hostedPatchPlan.js +6 -1
  33. package/dist/icp.d.ts +2 -0
  34. package/dist/icp.js +49 -0
  35. package/dist/index.d.ts +2 -0
  36. package/dist/index.js +2 -0
  37. package/dist/init.d.ts +1 -1
  38. package/dist/init.js +1 -1
  39. package/docs/api.md +18 -1
  40. package/package.json +1 -1
  41. package/src/cli/audit.ts +5 -1
  42. package/src/cli/auth.ts +46 -4
  43. package/src/cli/backfill.ts +3 -0
  44. package/src/cli/call.ts +25 -6
  45. package/src/cli/draft.ts +9 -1
  46. package/src/cli/enrich.ts +132 -56
  47. package/src/cli/fix.ts +5 -3
  48. package/src/cli/help.ts +31 -28
  49. package/src/cli/icp.ts +13 -1
  50. package/src/cli/init.ts +3 -3
  51. package/src/cli/market.ts +12 -1
  52. package/src/cli/planOutput.ts +30 -0
  53. package/src/cli/plans.ts +174 -29
  54. package/src/cli/schedule.ts +21 -15
  55. package/src/cli/signals.ts +22 -3
  56. package/src/cli/tam.ts +12 -3
  57. package/src/cli/ui.ts +15 -6
  58. package/src/connectors/clay.ts +155 -0
  59. package/src/connectors/hubspot.ts +41 -17
  60. package/src/connectors/prospectSources.ts +7 -1
  61. package/src/contactProviders.ts +141 -0
  62. package/src/enrich.ts +51 -2
  63. package/src/hostedPatchPlan.ts +6 -1
  64. package/src/icp.ts +51 -0
  65. package/src/index.ts +25 -0
  66. package/src/init.ts +2 -2
package/src/cli/plans.ts CHANGED
@@ -11,7 +11,7 @@ import { formatPatchPlanRun, patchPlanToMarkdown } from "../format.ts";
11
11
  import { mergeSnapshots } from "../merge.ts";
12
12
  import { verifyApprovalDigests } from "../integrity.ts";
13
13
  import { buildAuditLog, verifyAuditLog } from "../auditLog.ts";
14
- import { createFilePlanStore } from "../planStore.ts";
14
+ import { createFilePlanStore, type StoredPlan } from "../planStore.ts";
15
15
  import { ENRICH_CONFIG_FILE_NAME, loadEnrichConfig, type EnrichConfig } from "../enrich.ts";
16
16
  import { loadMeter, recordConsumption, remaining } from "../acquireMeter.ts";
17
17
  import { progressReporter, reportCounts } from "../runReport.ts";
@@ -20,7 +20,7 @@ import { APPLY_STAGES, composeListeners, createProgressEmitter } from "../progre
20
20
  import type { ValueSuggestion } from "../suggest.ts";
21
21
  import type { CanonicalGtmSnapshot, CreateRecordPayload, PatchOperation, PatchPlan, PatchPlanRun } from "../types.ts";
22
22
  import { connectorFor, isOptionValue, numericOption, option, repeatedOption, selectedRules } from "./shared.ts";
23
- import { colorEnabled, createProgressRenderer, paint, planStatusWord, stylizePlanMarkdown, table, truncateToWidth } from "./ui.ts";
23
+ import { box, colorEnabled, createProgressRenderer, createStatusLine, formatDuration, paint, planStatusWord, stylizePlanMarkdown, table, truncateToWidth } from "./ui.ts";
24
24
  import { unknownSubcommandError } from "./suggest.ts";
25
25
 
26
26
 
@@ -34,6 +34,115 @@ function parseValueOverrides(args: string[]) {
34
34
  return valueOverrides;
35
35
  }
36
36
 
37
+ function recordHeadline(operation: PatchOperation): { title: string; subtitle?: string; company?: string } {
38
+ const after = operation.afterValue && typeof operation.afterValue === "object" && !Array.isArray(operation.afterValue)
39
+ ? operation.afterValue as Record<string, unknown> : {};
40
+ const properties = after.properties && typeof after.properties === "object" && !Array.isArray(after.properties)
41
+ ? after.properties as Record<string, unknown> : {};
42
+ const first = typeof properties.firstname === "string" ? properties.firstname : "";
43
+ const last = typeof properties.lastname === "string" ? properties.lastname : "";
44
+ const company = typeof properties.company === "string" ? properties.company
45
+ : typeof after.associateCompanyName === "string" ? after.associateCompanyName : undefined;
46
+ const title = [first, last].filter(Boolean).join(" ") || company || operation.objectId || operation.id;
47
+ const role = typeof properties.jobtitle === "string" ? properties.jobtitle : undefined;
48
+ return { title, ...(role ? { subtitle: role } : {}), ...(company ? { company } : {}) };
49
+ }
50
+
51
+ function planEffect(stored: StoredPlan): string {
52
+ const total = stored.plan.operations.length;
53
+ const approved = stored.approvedOperationIds.length;
54
+ if (stored.status === "applied") {
55
+ const results = stored.runs.at(-1)?.results ?? [];
56
+ const applied = results.filter((result) => result.status === "applied").length;
57
+ const skipped = results.filter((result) => result.status === "skipped").length;
58
+ return `Completed ${applied} operation${applied === 1 ? "" : "s"}${skipped ? `; ${skipped} skipped` : ""}. No further writes will run.`;
59
+ }
60
+ if (stored.status === "approved") return `Apply will execute ${approved} selected operation${approved === 1 ? "" : "s"}; ${total - approved} will not run.`;
61
+ if (stored.status === "rejected") return "Rejected. No provider writes can run.";
62
+ return `No provider writes can run until operations are approved (0 of ${total} selected).`;
63
+ }
64
+
65
+ function printPlanCards(stored: StoredPlan, hostedUrl?: string) {
66
+ const p = paint(colorEnabled(process.stdout));
67
+ const total = stored.plan.operations.length;
68
+ const approved = new Set(stored.approvedOperationIds);
69
+ const width = Math.max(56, Math.min(100, (process.stdout.columns ?? 100) - 4));
70
+ const fit = (value: string) => truncateToWidth(value, width);
71
+ const summary = [
72
+ fit(stored.plan.title),
73
+ `Status ${stored.status.toUpperCase().replaceAll("_", " ")}`,
74
+ `Selection ${approved.size} of ${total} operation${total === 1 ? "" : "s"} approved`,
75
+ `Runs ${stored.runs.length}`,
76
+ fit(`Effect ${planEffect(stored)}`),
77
+ ];
78
+ console.log(box(summary, p, "Plan").join("\n"));
79
+ if (hostedUrl) console.log(`Hosted: ${hostedUrl}`);
80
+ console.log("\nOperations");
81
+ let operationIndex = 0;
82
+ const latestResults = new Map((stored.runs.at(-1)?.results ?? []).map((result) => [result.operationId, result.status]));
83
+ for (const operation of stored.plan.operations) {
84
+ const selected = approved.has(operation.id);
85
+ const resultStatus = latestResults.get(operation.id);
86
+ const headline = recordHeadline(operation);
87
+ const action = operation.operation === "create_record"
88
+ ? `Create ${operation.objectType}${headline.company ? ` and resolve/link ${headline.company}` : ""}`
89
+ : `${operation.operation.replaceAll("_", " ")} ${operation.objectType}`;
90
+ const lines = [
91
+ fit(headline.title),
92
+ ...(headline.subtitle || headline.company
93
+ ? [fit([headline.subtitle, headline.company].filter(Boolean).join(" · "))]
94
+ : []),
95
+ fit(`Action ${action}`),
96
+ `Operation ${operation.id}`,
97
+ ];
98
+ if (operationIndex++ > 0) console.log("");
99
+ const state = resultStatus === "applied" ? "✓ APPLIED"
100
+ : resultStatus && selected ? `! ${resultStatus.toUpperCase()}`
101
+ : selected ? "✓ APPROVED" : stored.status === "applied" ? "○ EXCLUDED" : "○ NOT APPROVED";
102
+ console.log(box(lines, p, state).join("\n"));
103
+ }
104
+ if (stored.status === "approved") {
105
+ console.log(`\nNext: fullstackgtm apply --plan-id ${stored.plan.id} --provider <hubspot|salesforce>`);
106
+ }
107
+ console.log(`Details: fullstackgtm plans show ${stored.plan.id} --verbose`);
108
+ }
109
+
110
+ function printApplyCards(run: PatchPlanRun, plan: PatchPlan, approvedOperationIds: string[], planIdStored: boolean) {
111
+ const p = paint(colorEnabled(process.stdout));
112
+ const approved = new Set(approvedOperationIds);
113
+ const operations = new Map(plan.operations.map((operation) => [operation.id, operation]));
114
+ const counts = { applied: 0, skipped: 0, failed: 0, conflict: 0 };
115
+ for (const result of run.results) counts[result.status] += 1;
116
+ const elapsed = Math.max(0, Date.parse(run.finishedAt) - Date.parse(run.startedAt));
117
+ const summary = [
118
+ `Status ${run.status.toUpperCase()}`,
119
+ `Provider ${run.provider}`,
120
+ `Outcome ${counts.applied} applied · ${run.results.length - approved.size} excluded · ${counts.failed + counts.conflict} failed/conflicted`,
121
+ `Duration ${formatDuration(elapsed)}`,
122
+ ...(planIdStored ? ["Receipt recorded locally; hosted reconciliation requested"] : []),
123
+ ];
124
+ console.log(box(summary, p, "Apply result").join("\n"));
125
+ console.log("\nOperations");
126
+ let index = 0;
127
+ for (const result of run.results) {
128
+ const operation = operations.get(result.operationId);
129
+ if (!operation) continue;
130
+ const selected = approved.has(result.operationId);
131
+ const headline = recordHeadline(operation);
132
+ const state = !selected ? "○ EXCLUDED" : result.status === "applied" ? "✓ APPLIED" : `! ${result.status.toUpperCase()}`;
133
+ const lines = [
134
+ headline.title,
135
+ ...(headline.subtitle || headline.company ? [[headline.subtitle, headline.company].filter(Boolean).join(" · ")] : []),
136
+ `Operation ${result.operationId}`,
137
+ ...(result.detail ? [truncateToWidth(`Result ${result.detail}`, 100)] : []),
138
+ ];
139
+ if (index++ > 0) console.log("");
140
+ console.log(box(lines, p, state).join("\n"));
141
+ }
142
+ if (planIdStored) console.log(`\nInspect: fullstackgtm plans show ${run.planId}`);
143
+ console.log("Details: rerun with --verbose; machine output: --json");
144
+ }
145
+
37
146
  function tryLoadAcquireConfig(args: string[]): EnrichConfig | undefined {
38
147
  try {
39
148
  const path = resolve(process.cwd(), option(args, "--config") ?? ENRICH_CONFIG_FILE_NAME);
@@ -361,8 +470,10 @@ export async function apply(args: string[]) {
361
470
 
362
471
  if (args.includes("--json")) {
363
472
  console.log(JSON.stringify(run, null, 2));
364
- } else {
473
+ } else if (args.includes("--verbose")) {
365
474
  console.log(formatPatchPlanRun(run));
475
+ } else {
476
+ printApplyCards(run, plan, approvedOperationIds, Boolean(planId && store));
366
477
  }
367
478
  if (run.status === "failed") process.exitCode = 1;
368
479
  }
@@ -484,14 +595,23 @@ export async function plansCommand(args: string[]) {
484
595
  }
485
596
  const p = paint(colorEnabled(process.stdout));
486
597
  if (p.enabled) {
487
- // Interactive terminals get an aligned table with status color-banding;
488
- // the plain per-line format below is unchanged for pipes.
598
+ const statusCounts = new Map<string, number>();
599
+ for (const stored of plans) statusCounts.set(stored.status, (statusCounts.get(stored.status) ?? 0) + 1);
600
+ const awaiting = plans.filter((stored) => stored.status === "needs_approval").length;
601
+ const approved = plans.filter((stored) => stored.status === "approved").length;
602
+ console.log(box([
603
+ `${plans.length} plan${plans.length === 1 ? "" : "s"} · ${awaiting} awaiting approval · ${approved} ready to apply`,
604
+ [...statusCounts.entries()].map(([name, count]) => `${name.replaceAll("_", " ")}: ${count}`).join(" · "),
605
+ ], p, "Change queue").join("\n"));
606
+ console.log("");
607
+ // Interactive terminals get an aligned decision table; the plain
608
+ // per-line format below remains stable for pipes and scripts.
489
609
  const rows = plans.map((stored) => [
490
- stored.plan.id,
491
610
  stored.status,
492
- `${stored.approvedOperationIds.length} approved`,
611
+ stored.plan.title,
612
+ `${stored.approvedOperationIds.length}/${stored.plan.operations.length} selected`,
493
613
  `${stored.runs.length} run${stored.runs.length === 1 ? "" : "s"}`,
494
- stored.plan.summary,
614
+ stored.plan.id,
495
615
  ]);
496
616
  // Long summaries would wrap and break the table's alignment. The summary
497
617
  // is the LAST column: cap it to what's left of the terminal width after
@@ -499,19 +619,26 @@ export async function plansCommand(args: string[]) {
499
619
  // terminals still show something useful).
500
620
  const columns = process.stdout.columns ?? 80;
501
621
  const fixedWidth =
502
- [0, 1, 2, 3].reduce(
622
+ [0, 2, 3, 4].reduce(
503
623
  (sum, index) => sum + Math.max(...rows.map((row) => row[index].length)),
504
624
  0,
505
625
  ) + 2 * 4;
506
- const summaryWidth = Math.max(24, columns - fixedWidth);
507
- for (const row of rows) row[4] = truncateToWidth(row[4], summaryWidth);
626
+ const titleWidth = Math.max(24, columns - fixedWidth);
627
+ for (const row of rows) row[1] = truncateToWidth(row[1], titleWidth);
508
628
  const statusPainter = (cell: string) => {
509
629
  const painted = planStatusWord(cell.trimEnd(), p);
510
630
  return painted + cell.slice(cell.trimEnd().length);
511
631
  };
512
- for (const line of table(rows, [null, statusPainter, p.dim, p.dim, null])) {
632
+ for (const line of table(rows, [statusPainter, null, p.dim, p.dim, p.dim])) {
513
633
  console.log(line);
514
634
  }
635
+ const next = plans.find((stored) => stored.status === "approved") ?? plans.find((stored) => stored.status === "needs_approval");
636
+ if (next) {
637
+ const command = next.status === "approved"
638
+ ? `fullstackgtm apply --plan-id ${next.plan.id} --provider <name>`
639
+ : `fullstackgtm plans show ${next.plan.id}`;
640
+ console.log(`\nNext: ${command}`);
641
+ }
515
642
  return;
516
643
  }
517
644
  for (const stored of plans) {
@@ -538,22 +665,21 @@ export async function plansCommand(args: string[]) {
538
665
  console.log(JSON.stringify(stored, null, 2));
539
666
  return;
540
667
  }
541
- const showPaint = paint(colorEnabled(process.stdout));
542
- console.log(`Status: ${planStatusWord(stored.status, showPaint)}`);
543
- console.log(`Approved operations: ${stored.approvedOperationIds.join(", ") || "none"}`);
544
- console.log(`Runs: ${stored.runs.length}`);
545
668
  const mirror = await uploadHostedPatchPlan(stored);
546
- if (mirror.status === "saved") console.log(`Hosted review: ${mirror.state.url}`);
547
- else if (mirror.status === "unavailable" || mirror.status === "conflict") console.log(`Hosted review: sync pending (${mirror.reason})`);
548
- if (stored.applyAttempts?.length) {
669
+ printPlanCards(stored, mirror.status === "saved" ? mirror.state.url : undefined);
670
+ if (mirror.status === "unavailable" || mirror.status === "conflict") console.log(`Hosted sync pending: ${mirror.reason}`);
671
+ if (rest.includes("--verbose") && stored.applyAttempts?.length) {
549
672
  console.log("Apply attempts:");
550
673
  for (const attempt of stored.applyAttempts) {
551
674
  console.log(` ${attempt.id} ${attempt.status} ${attempt.provider} via ${attempt.source} ${attempt.claimedAt}`);
552
675
  if (attempt.note) console.log(` ${attempt.note}`);
553
676
  }
554
677
  }
555
- console.log("");
556
- console.log(stylizePlanMarkdown(patchPlanToMarkdown(stored.plan), showPaint));
678
+ if (rest.includes("--verbose")) {
679
+ const showPaint = paint(colorEnabled(process.stdout));
680
+ console.log("\nFull plan document\n");
681
+ console.log(stylizePlanMarkdown(patchPlanToMarkdown(stored.plan), showPaint));
682
+ }
557
683
  return;
558
684
  }
559
685
 
@@ -654,16 +780,35 @@ export async function plansCommand(args: string[]) {
654
780
  if (subcommand === "sync") {
655
781
  const plans = await store.list();
656
782
  let updated = 0;
783
+ let completed = 0;
657
784
  const warnings: string[] = [];
658
- for (const stored of plans) {
659
- const result = await reconcileHostedPatchPlan(store, stored);
660
- if (result.status === "updated") updated += 1;
661
- else if (result.status === "conflict" || result.status === "unavailable") warnings.push(`${stored.plan.id}: ${result.reason}`);
662
- if (stored.status === "applied") {
663
- const hostedClaimId = [...(stored.applyAttempts ?? [])].reverse().find((attempt) => attempt.hostedClaimId)?.hostedClaimId;
664
- const pushed = await reportHostedPlanLifecycle(stored, { claimId: hostedClaimId });
665
- if (pushed.status === "conflict" || pushed.status === "unavailable") warnings.push(`${stored.plan.id} receipt: ${pushed.reason}`);
785
+ const status = createStatusLine();
786
+ status.set(`Synchronizing plan replicas 0/${plans.length}`);
787
+ // A workspace can accumulate hundreds of local plans. Reconcile with a
788
+ // small worker pool so one network round-trip per plan does not turn into
789
+ // a minutes-long silent serial wait, without stampeding the hosted API.
790
+ let next = 0;
791
+ const worker = async () => {
792
+ while (next < plans.length) {
793
+ const stored = plans[next++];
794
+ const result = await reconcileHostedPatchPlan(store, stored);
795
+ if (result.status === "updated") updated += 1;
796
+ else if (result.status === "conflict" || result.status === "unavailable") warnings.push(`${stored.plan.id}: ${result.reason}`);
797
+ // Historical local-only plans have no remote row to receive a receipt.
798
+ // `backfill runs` is the explicit import path for that old history.
799
+ if (stored.status === "applied" && result.status !== "missing" && (stored.applyAttempts?.length ?? 0) > 0) {
800
+ const hostedClaimId = [...(stored.applyAttempts ?? [])].reverse().find((attempt) => attempt.hostedClaimId)?.hostedClaimId;
801
+ const pushed = await reportHostedPlanLifecycle(stored, { claimId: hostedClaimId });
802
+ if (pushed.status === "conflict" || pushed.status === "unavailable") warnings.push(`${stored.plan.id} receipt: ${pushed.reason}`);
803
+ }
804
+ completed += 1;
805
+ status.set(`Synchronizing plan replicas ${completed}/${plans.length}`);
666
806
  }
807
+ };
808
+ try {
809
+ await Promise.all(Array.from({ length: Math.min(6, plans.length) }, () => worker()));
810
+ } finally {
811
+ status.done();
667
812
  }
668
813
  console.log(`Plan replicas synchronized: ${updated} updated, ${plans.length - updated} unchanged.`);
669
814
  for (const warning of warnings) console.error(` ${warning}`);
@@ -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
@@ -6,6 +6,7 @@ import { getCredential } from "../credentials.ts";
6
6
  import { appendCoverage, computeCoverage, coverageCountsFromSnapshot, classifyCoverage, coverageToText, deriveAcvFromClosedWon, deriveBuyersPerAccount, estimateTam, loadTamModel, projectEta, readCoverageTimeline, saveTamModel, tamReportToMarkdown, type AcvBasis, type TamCrossCheck } from "../tam.ts";
7
7
  import { probeExploriumBusinessCount } from "../connectors/prospectSources.ts";
8
8
  import { theirStackCountCompanies, theirStackPullCost, theirStackSearchCompanies } from "../connectors/theirstack.ts";
9
+ import { clayApiKey } from "../connectors/clay.ts";
9
10
  import { icpToExploriumBusinessFilters, icpToTheirStackFilters } from "../icp.ts";
10
11
  import { scheduleCommand } from "./schedule.ts";
11
12
  import { loadIcp, numericOption, option, readSnapshot, saveRequested } from "./shared.ts";
@@ -14,7 +15,8 @@ import { unknownSubcommandError } from "./suggest.ts";
14
15
 
15
16
  /** Best-effort enrich-config load for apply-time acquire-budget enforcement. */
16
17
  /** Provider API key: env override first, then the credential store (`login`). */
17
- export function providerKey(provider: "explorium" | "pipe0" | "heyreach" | "theirstack"): string {
18
+ export function providerKey(provider: "explorium" | "pipe0" | "clay" | "heyreach" | "theirstack"): string {
19
+ if (provider === "clay") return clayApiKey();
18
20
  const envName =
19
21
  provider === "explorium"
20
22
  ? "EXPLORIUM_API_KEY"
@@ -44,7 +46,7 @@ export async function tamCommand(args: string[]) {
44
46
  fullstackgtm tam accounts [--name <n>] [--icp <path>] --source theirstack [--max <n>] [--dry-run | --confirm] [--max-credits <n>] [--usd-per-credit <r>] [--out <file.csv> | --json]
45
47
  fullstackgtm tam status [--name <n>] <source options> [--save] [--json]
46
48
  fullstackgtm tam report [--name <n>] [--out <path>]
47
- fullstackgtm tam populate [--name <n>] --cron "<expr>" [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--label <l>]
49
+ fullstackgtm tam populate [--name <n>] --cron "<expr>" [--source pipe0|explorium|clay|linkedin] [--provider hubspot|salesforce] [--label <l>]
48
50
 
49
51
  Estimate the reachable market FROM your ICP: a real account count × a confirmed
50
52
  ANNUAL ACV (--acv <annual-usd>, or --acv-from-crm --deal-period monthly|quarterly|
@@ -275,8 +277,15 @@ RevOps universe, with real names. --source explorium is a firmographic count onl
275
277
  if (out) {
276
278
  writeFileSync(resolve(process.cwd(), out), md);
277
279
  console.log(`Wrote ${out}.`);
278
- } else {
280
+ } else if (rest.includes("--verbose")) {
279
281
  console.log(md);
282
+ } else if (timeline.length > 0) {
283
+ console.log(coverageToText(model, timeline.at(-1)!, eta));
284
+ console.log("\nUse --verbose for assumptions, cross-checks, and the full coverage history.");
285
+ } else {
286
+ console.log(`TAM "${model.name}" · ${model.universe.accounts.toLocaleString()} accounts · ${model.universe.contacts.toLocaleString()} buyers · $${Math.round(model.tamUsd).toLocaleString()}`);
287
+ console.log(`ICP ${model.icpName} · ${model.acv.basis}-basis ACV $${Math.round(model.acv.valueUsd).toLocaleString()} (${model.acv.source})`);
288
+ console.log("No coverage readings yet. Run `fullstackgtm tam status --save` to establish one; use --verbose for the full assumptions report.");
280
289
  }
281
290
  return;
282
291
  }
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
  },
@@ -0,0 +1,155 @@
1
+ import { getCredential } from "../credentials.ts";
2
+ import { ProviderHttpError } from "../providerError.ts";
3
+ import type { Prospect } from "./prospectSources.ts";
4
+
5
+ export const CLAY_PUBLIC_API_BASE = "https://api.clay.com/public/v0";
6
+
7
+ export type ClayKeyValidation = {
8
+ ok: boolean;
9
+ detail: string;
10
+ workspaceId?: string;
11
+ };
12
+
13
+ export type ClaySearchSourceType = "people" | "companies";
14
+
15
+ export type ClayPeopleSearchPage = {
16
+ prospects: Prospect[];
17
+ hasMore: boolean;
18
+ };
19
+
20
+ function clayHeaders(apiKey: string): Record<string, string> {
21
+ return { "clay-api-key": apiKey, Accept: "application/json", "Content-Type": "application/json" };
22
+ }
23
+
24
+ /** Create a forward-only Clay search iterator. This call does not fetch a page. */
25
+ export async function createClaySearch(opts: {
26
+ apiKey: string;
27
+ sourceType: ClaySearchSourceType;
28
+ filters: Record<string, unknown>;
29
+ fetchImpl?: typeof fetch;
30
+ apiBaseUrl?: string;
31
+ }): Promise<string> {
32
+ const fetchImpl = opts.fetchImpl ?? fetch;
33
+ const base = (opts.apiBaseUrl ?? CLAY_PUBLIC_API_BASE).replace(/\/$/, "");
34
+ const response = await fetchImpl(`${base}/search/filters-mode`, {
35
+ method: "POST",
36
+ headers: clayHeaders(opts.apiKey),
37
+ body: JSON.stringify({ source_type: opts.sourceType, filters: opts.filters }),
38
+ });
39
+ if (!response.ok) throw new ProviderHttpError("Clay", "create search", response.status);
40
+ const body = await response.json() as { search_id?: unknown; searchId?: unknown };
41
+ const searchId = body.search_id ?? body.searchId;
42
+ if (typeof searchId !== "string" || !searchId) throw new Error("Clay create search returned no search_id.");
43
+ return searchId;
44
+ }
45
+
46
+ /** Consume the next people page from a Clay search iterator. */
47
+ export async function runClayPeopleSearchPage(opts: {
48
+ apiKey: string;
49
+ searchId: string;
50
+ limit?: number;
51
+ fetchImpl?: typeof fetch;
52
+ apiBaseUrl?: string;
53
+ }): Promise<ClayPeopleSearchPage> {
54
+ const fetchImpl = opts.fetchImpl ?? fetch;
55
+ const base = (opts.apiBaseUrl ?? CLAY_PUBLIC_API_BASE).replace(/\/$/, "");
56
+ const limit = Math.max(1, Math.min(opts.limit ?? 25, 100));
57
+ const response = await fetchImpl(`${base}/search/filters-mode/${encodeURIComponent(opts.searchId)}/run`, {
58
+ method: "POST",
59
+ headers: clayHeaders(opts.apiKey),
60
+ body: JSON.stringify({ limit }),
61
+ });
62
+ if (!response.ok) throw new ProviderHttpError("Clay", "run people search", response.status);
63
+ const body = await response.json() as { data?: unknown; has_more?: unknown; hasMore?: unknown };
64
+ if (!Array.isArray(body.data)) throw new Error("Clay people search returned an invalid data array.");
65
+ const hasMore = body.has_more ?? body.hasMore;
66
+ if (typeof hasMore !== "boolean") throw new Error("Clay people search returned no has_more flag.");
67
+ return { prospects: body.data.map(normalizeClayPerson), hasMore };
68
+ }
69
+
70
+ export function normalizeClayPerson(value: unknown): Prospect {
71
+ const row = value && typeof value === "object" ? value as Record<string, unknown> : {};
72
+ const location = row.structured_location && typeof row.structured_location === "object"
73
+ ? row.structured_location as Record<string, unknown>
74
+ : {};
75
+ const fullName = stringValue(row.name);
76
+ return {
77
+ firstName: stringValue(row.first_name),
78
+ lastName: stringValue(row.last_name),
79
+ fullName,
80
+ jobTitle: stringValue(row.latest_experience_title),
81
+ companyName: stringValue(row.latest_experience_company),
82
+ companyDomain: bareDomain(stringValue(row.domain)),
83
+ linkedin: normalizeLinkedin(stringValue(row.url)),
84
+ headline: undefined,
85
+ sourceId: normalizeLinkedin(stringValue(row.url)) ?? fullName,
86
+ location: {
87
+ city: stringValue(location.city),
88
+ state: stringValue(location.state),
89
+ region: stringValue(location.region),
90
+ country: stringValue(location.country),
91
+ countryCode: stringValue(location.country_iso),
92
+ },
93
+ };
94
+ }
95
+
96
+ function stringValue(value: unknown): string | undefined {
97
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
98
+ }
99
+
100
+ function bareDomain(value: string | undefined): string | undefined {
101
+ return value?.replace(/^https?:\/\//i, "").replace(/^www\./i, "").replace(/\/.*$/, "").toLowerCase() || undefined;
102
+ }
103
+
104
+ function normalizeLinkedin(value: string | undefined): string | undefined {
105
+ if (!value) return undefined;
106
+ const normalized = value.startsWith("http") ? value : `https://${value.replace(/^\/+/, "")}`;
107
+ return normalized.replace(/\/$/, "");
108
+ }
109
+
110
+ /** Validate a Clay Public API key without creating a search or spending enrichment credits. */
111
+ export async function validateClayApiKey(
112
+ apiKey: string,
113
+ fetchImpl: typeof fetch = fetch,
114
+ apiBaseUrl = CLAY_PUBLIC_API_BASE,
115
+ ): Promise<ClayKeyValidation> {
116
+ let response: Response;
117
+ try {
118
+ response = await fetchImpl(`${apiBaseUrl.replace(/\/$/, "")}/me`, {
119
+ headers: { "clay-api-key": apiKey, Accept: "application/json" },
120
+ });
121
+ } catch (error) {
122
+ const detail = error instanceof Error ? error.message : String(error);
123
+ return { ok: false, detail: `Cannot reach the Clay Public API: ${detail}` };
124
+ }
125
+ if (!response.ok) return { ok: false, detail: `Clay rejected the key: HTTP ${response.status}` };
126
+ let body: unknown;
127
+ try {
128
+ body = await response.json();
129
+ } catch {
130
+ return { ok: false, detail: "Clay accepted the request but returned an invalid JSON response." };
131
+ }
132
+ const record = body && typeof body === "object" ? body as Record<string, unknown> : {};
133
+ const workspace = record.workspace && typeof record.workspace === "object"
134
+ ? record.workspace as Record<string, unknown>
135
+ : undefined;
136
+ const rawWorkspaceId = record.workspace_id ?? record.workspaceId ?? workspace?.id;
137
+ const workspaceId = typeof rawWorkspaceId === "string" || typeof rawWorkspaceId === "number"
138
+ ? String(rawWorkspaceId)
139
+ : undefined;
140
+ return {
141
+ ok: true,
142
+ detail: workspaceId ? `Key accepted by Clay workspace ${workspaceId}.` : "Key accepted by the Clay Public API.",
143
+ workspaceId,
144
+ };
145
+ }
146
+
147
+ /** Resolve Clay credentials using the standard env -> profile-scoped store ladder. */
148
+ export function clayApiKey(env: Record<string, string | undefined> = process.env): string {
149
+ const key = env.CLAY_API_KEY ?? getCredential("clay")?.accessToken;
150
+ if (key) return key;
151
+ throw new Error(
152
+ 'No Clay credentials. Run `clay api-keys create --name "fullstackgtm"`, then pipe the key to ' +
153
+ '`fullstackgtm login clay`, or set CLAY_API_KEY.',
154
+ );
155
+ }