fullstackgtm 0.45.0 → 0.47.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 (95) hide show
  1. package/CHANGELOG.md +115 -1
  2. package/INSTALL_FOR_AGENTS.md +13 -11
  3. package/README.md +27 -18
  4. package/dist/backfill.d.ts +86 -0
  5. package/dist/backfill.js +251 -0
  6. package/dist/bin.js +4 -1
  7. package/dist/cli/auth.d.ts +2 -0
  8. package/dist/cli/auth.js +119 -12
  9. package/dist/cli/backfill.d.ts +1 -0
  10. package/dist/cli/backfill.js +125 -0
  11. package/dist/cli/backfillRuns.d.ts +1 -0
  12. package/dist/cli/backfillRuns.js +187 -0
  13. package/dist/cli/call.js +23 -9
  14. package/dist/cli/enrich.js +28 -10
  15. package/dist/cli/fix.js +9 -7
  16. package/dist/cli/help.js +60 -23
  17. package/dist/cli/plans.js +13 -11
  18. package/dist/cli/shared.d.ts +4 -3
  19. package/dist/cli/shared.js +31 -20
  20. package/dist/cli/ui.d.ts +21 -0
  21. package/dist/cli/ui.js +53 -1
  22. package/dist/cli.js +37 -41
  23. package/dist/connector.d.ts +8 -0
  24. package/dist/connector.js +104 -21
  25. package/dist/connectors/hubspot.d.ts +8 -1
  26. package/dist/connectors/hubspot.js +406 -13
  27. package/dist/connectors/hubspotAuth.js +5 -1
  28. package/dist/connectors/linkedin.js +14 -14
  29. package/dist/connectors/salesforce.d.ts +8 -1
  30. package/dist/connectors/salesforce.js +163 -2
  31. package/dist/connectors/stripe.d.ts +37 -0
  32. package/dist/connectors/stripe.js +103 -31
  33. package/dist/enrich.d.ts +7 -0
  34. package/dist/enrich.js +7 -0
  35. package/dist/health.d.ts +11 -69
  36. package/dist/health.js +4 -134
  37. package/dist/healthScore.d.ts +71 -0
  38. package/dist/healthScore.js +143 -0
  39. package/dist/index.d.ts +3 -1
  40. package/dist/index.js +3 -1
  41. package/dist/llm.d.ts +29 -0
  42. package/dist/llm.js +206 -0
  43. package/dist/market.d.ts +39 -1
  44. package/dist/market.js +116 -44
  45. package/dist/marketClassify.d.ts +9 -1
  46. package/dist/marketClassify.js +10 -1
  47. package/dist/marketTaxonomy.d.ts +6 -1
  48. package/dist/marketTaxonomy.js +4 -2
  49. package/dist/mcp-bin.js +29 -0
  50. package/dist/mcp.js +117 -4
  51. package/dist/progress.d.ts +96 -0
  52. package/dist/progress.js +142 -0
  53. package/dist/runReport.d.ts +24 -0
  54. package/dist/runReport.js +139 -4
  55. package/dist/types.d.ts +33 -1
  56. package/docs/api.md +4 -2
  57. package/docs/architecture.md +2 -0
  58. package/docs/linkedin-connector-spec.md +1 -1
  59. package/llms.txt +3 -3
  60. package/package.json +10 -3
  61. package/skills/fullstackgtm/SKILL.md +1 -0
  62. package/src/backfill.ts +340 -0
  63. package/src/bin.ts +5 -1
  64. package/src/cli/auth.ts +135 -15
  65. package/src/cli/backfill.ts +156 -0
  66. package/src/cli/backfillRuns.ts +198 -0
  67. package/src/cli/call.ts +26 -10
  68. package/src/cli/enrich.ts +33 -10
  69. package/src/cli/fix.ts +11 -10
  70. package/src/cli/help.ts +61 -23
  71. package/src/cli/plans.ts +15 -14
  72. package/src/cli/shared.ts +44 -27
  73. package/src/cli/ui.ts +72 -1
  74. package/src/cli.ts +38 -41
  75. package/src/connector.ts +110 -16
  76. package/src/connectors/hubspot.ts +423 -14
  77. package/src/connectors/hubspotAuth.ts +5 -1
  78. package/src/connectors/linkedin.ts +14 -14
  79. package/src/connectors/salesforce.ts +168 -3
  80. package/src/connectors/stripe.ts +154 -34
  81. package/src/enrich.ts +13 -0
  82. package/src/health.ts +14 -213
  83. package/src/healthScore.ts +223 -0
  84. package/src/index.ts +28 -1
  85. package/src/llm.ts +239 -0
  86. package/src/market.ts +157 -44
  87. package/src/marketClassify.ts +18 -1
  88. package/src/marketTaxonomy.ts +10 -2
  89. package/src/mcp-bin.ts +32 -0
  90. package/src/mcp.ts +140 -6
  91. package/src/progress.ts +197 -0
  92. package/src/runReport.ts +159 -4
  93. package/src/types.ts +35 -2
  94. package/docs/dx-punch-list.md +0 -87
  95. package/docs/roadmap-to-1.0.md +0 -211
@@ -8,7 +8,8 @@ import { buildAcquirePlan, buildEnrichPlan, createFileEnrichRunStore, DEFAULT_ST
8
8
  import { loadMeter, remaining } from "../acquireMeter.js";
9
9
  import { crmContactKeys, fetchExploriumProspects, fetchPipe0CrustdataProspects, partitionFreshProspects, pipe0ResolveCompanyDomains, pipe0ResolveWorkEmails, prospectIdentityKeys } from "../connectors/prospectSources.js";
10
10
  import { loadSeen, recordSeen } from "../acquireSeen.js";
11
- import { reportCounts, reportEvent } from "../runReport.js";
11
+ import { progressReporter, reportCounts, reportEvent } from "../runReport.js";
12
+ import { ACQUIRE_STAGES, composeListeners, createProgressEmitter } from "../progress.js";
12
13
  import { createLinkedInProvider, discoverLinkedInProspects } from "../acquireLinkedIn.js";
13
14
  import { fitThreshold, icpToCrustdataFilters, icpToExploriumFilters, scoreProspectAgainstIcp } from "../icp.js";
14
15
  import { apolloPullKeysForAppend, apolloPullKeysForRefresh, createApolloClient, pullApolloRecords } from "../enrichApollo.js";
@@ -16,7 +17,7 @@ import { isSpoolPath, readSpoolPath } from "../spoolFiles.js";
16
17
  import { isOptionValue, loadIcp, numericOption, option, readSnapshot, saveRequested } from "./shared.js";
17
18
  import { providerKey } from "./tam.js";
18
19
  import { unknownSubcommandError } from "./suggest.js";
19
- import { colorEnabled, createStatusLine, formatBar, formatDuration, paint } from "./ui.js";
20
+ import { colorEnabled, createProgressRenderer, createStatusLine, formatBar, formatDuration, paint } from "./ui.js";
20
21
  /**
21
22
  * The enrich layer: governed append/refresh of third-party data (Apollo pull,
22
23
  * Clay ingest) into the CRM through the normal dry-run → approval → apply
@@ -197,14 +198,31 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
197
198
  }
198
199
  }
199
200
  }
200
- const result = buildAcquirePlan({
201
- config,
202
- source,
203
- snapshot,
204
- records,
205
- runLabel: option(rest, "--run-label") ?? `acquire-${source}-${today}`,
206
- maxRecords: cap,
207
- });
201
+ // Progress: candidate rows tick on an interactive stderr board (inert when
202
+ // piped) and, via the composed reporter, heartbeat to a paired hosted app.
203
+ // The meter reading (creates vs headroom + budget burn) rides the same
204
+ // emitter, feeding the dashboard's gauge without printing anything new.
205
+ const renderer = createProgressRenderer(ACQUIRE_STAGES);
206
+ const acquireProgress = createProgressEmitter(composeListeners(renderer.listener, progressReporter()));
207
+ let result;
208
+ try {
209
+ acquireProgress.stage(ACQUIRE_STAGES[0], 0, ACQUIRE_STAGES.length);
210
+ if (config.acquire.budget?.records?.perDay && headroom.records.day !== null) {
211
+ acquireProgress.meter(config.acquire.budget.records.perDay - headroom.records.day, config.acquire.budget.records.perDay, "records/day");
212
+ }
213
+ result = buildAcquirePlan({
214
+ config,
215
+ source,
216
+ snapshot,
217
+ records,
218
+ runLabel: option(rest, "--run-label") ?? `acquire-${source}-${today}`,
219
+ maxRecords: cap,
220
+ progress: acquireProgress,
221
+ });
222
+ }
223
+ finally {
224
+ renderer.done();
225
+ }
208
226
  if (result.counts.unassigned > 0 && result.counts.created > 0) {
209
227
  console.error(`⚠ ${result.counts.unassigned}/${result.counts.created} proposed lead(s) have no owner ` +
210
228
  `(policy could not place them). They will be created ownerless.`);
package/dist/cli/fix.js CHANGED
@@ -11,8 +11,10 @@ import { buildBulkUpdatePlan } from "../bulkUpdate.js";
11
11
  import { buildDedupePlan } from "../dedupe.js";
12
12
  import { buildReassignPlans } from "../reassign.js";
13
13
  import { suggestValues } from "../suggest.js";
14
+ import { APPLY_STAGES, composeListeners, createProgressEmitter } from "../progress.js";
15
+ import { progressReporter } from "../runReport.js";
14
16
  import { confirmRequested, connectorFor, isOptionValue, numericOption, option, readSnapshot, repeatedOption, saveRequested } from "./shared.js";
15
- import { box, colorEnabled, createStatusLine, paint } from "./ui.js";
17
+ import { box, colorEnabled, createProgressRenderer, paint } from "./ui.js";
16
18
  /**
17
19
  * The resolve gate: exit 0 = safe to create, exit 2 = match found (exists or
18
20
  * ambiguous — do NOT blind-create), exit 1 = error. Built for sync jobs and
@@ -260,20 +262,20 @@ export async function fixCommand(args) {
260
262
  return;
261
263
  }
262
264
  const connector = await connectorFor(provider, args);
263
- // Live safety ticker on interactive terminals (stderr; inert otherwise).
264
- const ticker = createStatusLine();
265
+ // Live apply board on interactive terminals (stderr; inert otherwise); the
266
+ // same emitter streams heartbeats to the paired hosted app on long runs.
267
+ const renderer = createProgressRenderer(APPLY_STAGES);
268
+ const progress = createProgressEmitter(composeListeners(renderer.listener, progressReporter()));
265
269
  let run;
266
270
  try {
267
271
  run = await applyPatchPlan(connector, plan, {
268
272
  approvedOperationIds: approvedIds,
269
273
  valueOverrides: overrides,
270
- onOperation: ticker.active
271
- ? (progress) => ticker.set(`Applying ${progress.completed}/${progress.total} · ✓ ${progress.applied} applied · ${progress.failed} failed · ${progress.conflicts} conflict${progress.conflicts === 1 ? "" : "s"}`)
272
- : undefined,
274
+ progress,
273
275
  });
274
276
  }
275
277
  finally {
276
- ticker.done();
278
+ renderer.done();
277
279
  }
278
280
  await store.recordRun(plan.id, run);
279
281
  const counts = { applied: 0, conflict: 0, skipped: 0, failed: 0 };
package/dist/cli/help.js CHANGED
@@ -5,11 +5,15 @@ export function usage() {
5
5
  and apply only explicitly approved operations.
6
6
 
7
7
  Usage:
8
- fullstackgtm login --via <hosted url> pair with a team deployment (recommended)
9
- fullstackgtm login hubspot [--no-validate]
10
- fullstackgtm login hubspot --oauth --client-id <id> [--port ${DEFAULT_LOOPBACK_PORT}] [--scopes a,b]
11
- fullstackgtm login salesforce --device --client-id <consumer key> [--login-url <url>]
12
- fullstackgtm login salesforce --instance-url <url> [--no-validate]
8
+ fullstackgtm login hubspot hosted browser OAuth (default; no app needed)
9
+ fullstackgtm login salesforce hosted browser OAuth (default; no app needed)
10
+ fullstackgtm login hubspot --hosted [--via <url>]
11
+ fullstackgtm login salesforce --hosted [--via <url>]
12
+ fullstackgtm login --via <hosted url> pair with a team deployment (broker only)
13
+ fullstackgtm login hubspot --private-token [--no-validate] advanced: paste private app token
14
+ fullstackgtm login hubspot --oauth --client-id <id> [--port ${DEFAULT_LOOPBACK_PORT}] [--scopes a,b] advanced: BYO HubSpot app
15
+ fullstackgtm login salesforce --device --client-id <consumer key> [--login-url <url>] advanced: BYO Connected App
16
+ fullstackgtm login salesforce --instance-url <url> [--no-validate] advanced: paste access token
13
17
  fullstackgtm login stripe [--no-validate]
14
18
  fullstackgtm login anthropic | openai store an LLM API key for call parse/score
15
19
  fullstackgtm login apollo store an Apollo API key for enrich pulls\n fullstackgtm login pipe0 | explorium | theirstack store a discovery-provider key (theirstack = technographic TAM)\n fullstackgtm login heyreach store a HeyReach key for enrich acquire --source linkedin\n fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|apollo|pipe0|explorium|heyreach|broker>
@@ -17,7 +21,7 @@ Usage:
17
21
  Secrets (tokens, client secrets) are NEVER passed as flags — they leak via
18
22
  the process list and shell history. Pipe them on stdin or enter them at the
19
23
  interactive prompt:
20
- echo "$HUBSPOT_TOKEN" | fullstackgtm login hubspot
24
+ echo "$HUBSPOT_TOKEN" | fullstackgtm login hubspot --private-token
21
25
  fullstackgtm init [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]
22
26
  cold start: scaffold icp.json + enrich.config.json + a
23
27
  PLAYBOOK wired for this workspace (the CLI ships primitives,
@@ -27,16 +31,18 @@ Usage:
27
31
  fullstackgtm report [source options] [audit options] [report options]
28
32
  fullstackgtm diff --before <a.json> --after <b.json> [--json] [--fail-on-new-findings]
29
33
  fullstackgtm merge --input <a.json> --input <b.json> [...] --out <merged.json> [--json]
30
- fullstackgtm call parse --transcript <file> [--title t] [--source fathom|granola|...] [--model m] [--deterministic] [--json|--ndjson] [--out <path>]
34
+ fullstackgtm call parse --transcript <file> [--title t] [--source fathom|granola|...] [--model m] [--llm] [--heuristics] [--json|--ndjson] [--out <path>]
31
35
  fullstackgtm call classify --transcript <file>|--call <parsed.json> [--llm] [--deterministic] [--json]
32
36
  fullstackgtm call score --transcript <file>|--call <parsed.json> [--call-type <t>] [--rubric <rubric.json>] [--model m] [--json|--out <path>]
33
37
  fullstackgtm call link --attendees <a@x.com,...> | --domain <x.com> [source options] [--json]
34
38
  fullstackgtm call plan --transcript <file>|--call <parsed.json> --deal <id> [source options] [--save|--json]
35
- calls become evidence: LLM extraction by default (bring your own
36
- Anthropic or OpenAI key captured once on first use, or
37
- ANTHROPIC_API_KEY/OPENAI_API_KEY, or \`login anthropic|openai\`);
38
- --deterministic uses the free keyword baseline. Then link the call
39
- to its deal and propose governed next-step writes.
39
+ calls become evidence: chunked LLM extraction by default when a
40
+ key resolves (ANTHROPIC_API_KEY/OPENAI_API_KEY or \`login
41
+ anthropic|openai\`), free deterministic fallback when none does;
42
+ --heuristics (alias --deterministic) forces the deterministic
43
+ engine, --llm forces the LLM path. Model: --model, else env
44
+ FSGTM_INSIGHTS_MODEL. Then link the call to its deal and propose
45
+ governed next-step writes.
40
46
  fullstackgtm resolve <account|contact|deal> [--name N] [--domain D] [--email E] [--account-id A] [source options] [--json]
41
47
  the create gate: exit 0 = safe to create, exit 2 = match
42
48
  found (exists/ambiguous) — call before ANY record creation
@@ -76,6 +82,15 @@ Usage:
76
82
  fill-blanks-only patch plan through the normal dry-run →
77
83
  approve → apply gate. refresh re-checks stale stamped fields
78
84
  and proposes updates only where the source value changed.
85
+ fullstackgtm backfill stripe|runs [--since <iso>] [--pipeline <id|label>] [--match-property <name>] [--skip-unmatched] [source options] [--save] [--dry-run] [--json]
86
+ revenue truth backfill: one closed-won deal per PAID Stripe
87
+ invoice (amount = invoice total, close date = paid date,
88
+ associated to the matched customer account, deduped by
89
+ invoice id). Reads Stripe + the CRM snapshot, emits a
90
+ dry-run create_record plan through the normal approve →
91
+ apply gate; unmatched customers get a proposed NEW account
92
+ in the same plan (freemail domains never used;
93
+ --skip-unmatched = report-only). HubSpot-only for now.
79
94
  fullstackgtm signals fetch [--bucket job,…] [--source greenhouse,lever,ashby] [--watchlist <path|crm:seg>] [--keywords …] [--from <file.json|spool.jsonl|spool-dir>] [--save]
80
95
  fullstackgtm signals list [--since 7d] [--bucket b] [--account d] [--unjudged]
81
96
  fullstackgtm signals outcome --account <d> [--touch <id>] --result replied|meeting|bounced|no_reply
@@ -184,13 +199,14 @@ Authentication (checked in order):
184
199
  1. --token-env <name> explicit env var for this invocation (hubspot)
185
200
  2. ambient env HUBSPOT_ACCESS_TOKEN, or SALESFORCE_ACCESS_TOKEN +
186
201
  SALESFORCE_INSTANCE_URL (CI, agent sandboxes)
187
- 3. stored provider login fullstackgtm login <provider>; kept in ~/.fullstackgtm
188
- (hubspot: private app token or loopback OAuth;
189
- salesforce: device flow code on any device, no
190
- secret, silent refresh)
191
- 4. broker pairing fullstackgtm login --via <url>: the team connects the
192
- CRM once in the hosted dashboard; every paired CLI
193
- uses those stored sync credentials from then on
202
+ 3. stored provider login BYO direct credentials kept in ~/.fullstackgtm
203
+ (hubspot: --private-token or loopback OAuth;
204
+ salesforce: pasted token or device flow). These
205
+ direct credentials override broker-hosted tokens.
206
+ 4. hosted/broker login fullstackgtm login hubspot|salesforce (or --hosted):
207
+ hosted browser OAuth stores a broker credential;
208
+ provider tokens are minted server-side. \`login --via
209
+ <url>\` pairs with an existing team deployment broker.
194
210
 
195
211
  Source options (snapshot and audit):
196
212
  --sample Built-in minimal mock CRM data (default)
@@ -257,11 +273,12 @@ export const HELP = {
257
273
  summary: "connect a provider or LLM key (secrets via stdin/env, never argv)",
258
274
  phase: "Setup",
259
275
  synopsis: [
276
+ "fullstackgtm login hubspot | salesforce hosted browser OAuth (default)",
260
277
  "fullstackgtm login --via <hosted url> pair with a team deployment",
261
- "fullstackgtm login hubspot | salesforce | stripe",
278
+ "fullstackgtm login stripe",
262
279
  "fullstackgtm login anthropic | openai | apollo",
263
280
  ],
264
- detail: "Secrets are NEVER passed as flags (they leak via the process list and shell history) — pipe on stdin or enter at the prompt: `echo \"$TOKEN\" | fullstackgtm login hubspot`.",
281
+ detail: "HubSpot/Salesforce use hosted browser OAuth by default; BYO app/token paths are advanced. Secrets are NEVER passed as flags — pipe on stdin or enter at the prompt: `echo \"$TOKEN\" | fullstackgtm login hubspot --private-token`.",
265
282
  seeAlso: ["doctor", "logout", "profiles"],
266
283
  },
267
284
  logout: {
@@ -404,6 +421,26 @@ export const HELP = {
404
421
  detail: "Extra `--where` scoping is account-lifted for deals/contacts. `--except-deal-stage <stage>` excludes that stage and any record whose account has an open deal in it, re-verified per record at apply.",
405
422
  seeAlso: ["bulk-update", "plans", "apply"],
406
423
  },
424
+ backfill: {
425
+ summary: "Stripe paid invoices → closed-won deal proposals (governed)",
426
+ phase: "Remediate",
427
+ synopsis: [
428
+ "fullstackgtm backfill stripe|runs [--since <iso>] [--pipeline <id|label>] [--match-property <name>] [--skip-unmatched] [source options] [--save] [--dry-run] [--json]",
429
+ ],
430
+ detail: "`backfill runs` replays LOCAL execution history to the paired hosted app (idempotent: deterministic run ids + server-side dedupe): every stored plan's apply runs and the health.jsonl timeline, so the hosted feed and Home health trend cover the engagement from its CLI-only beginning. Requires `login --via`; only what live reporting sends leaves the machine (statuses, counts, timestamps, scores — never CRM field values). `backfill stripe`: billing is the revenue source of truth — proposes ONE closed-won deal per paid Stripe invoice (amount = invoice total, close date = paid date, associated to the matched customer account, deduped by invoice id). Customers are matched to CRM accounts by domain, then exact name; a customer the CRM doesn't know gets an explicit proposed ACCOUNT create in the same plan (billing-email domain used unless it is freemail; a customer with no usable name/domain stays report-only; `--skip-unmatched` restores report-only for all unmatched). Emits a dry-run create_record plan — nothing is written until `plans approve` → `apply`, where the connector re-resolves each invoice id (and each account by domain/name) and creates only on a confirmed miss. Deal creation is HubSpot-only for now.",
431
+ options: [
432
+ ["--since <iso>", "only invoices created on/after this timestamp"],
433
+ ["--pipeline <id|label>", "target deal pipeline (default: the portal's default pipeline)"],
434
+ ["--match-property <name>", "deal dedupe property stamped with the invoice id (default stripe_invoice_id)"],
435
+ ["--skip-unmatched", "do not propose accounts for unmatched customers (report-only, the pre-1.3 behavior)"],
436
+ ["--provider <name>", "CRM snapshot to match against: hubspot | salesforce"],
437
+ ["--input <path>", "match against a saved snapshot JSON instead of a live pull"],
438
+ ["--save", "persist the dry-run plan for approve → apply"],
439
+ ["--json", "machine-readable {plan, counts, unmatched, proposedAccounts} (stripe) or replay summary (runs)"],
440
+ ["--dry-run", "(runs) count what would be replayed without sending anything"],
441
+ ],
442
+ seeAlso: ["enrich", "plans", "apply", "resolve"],
443
+ },
407
444
  enrich: {
408
445
  summary: "governed third-party enrichment (Apollo/Clay), fill-blanks-only",
409
446
  phase: "Remediate",
@@ -416,7 +453,7 @@ export const HELP = {
416
453
  summary: "transcripts → evidence, rubric scores, deal links, governed writes",
417
454
  phase: "Remediate",
418
455
  synopsis: ["fullstackgtm call parse|classify|score|link|plan … (run `call --help` for full options)"],
419
- detail: "`parse` normalizes any transcript into canonical segments + evidence (LLM by default, bring your own key; `--deterministic` for the free baseline). `classify` picks the call type, `score` rates it against the type's rubric, `link` finds the deal, `plan` proposes governed next-step writes.",
456
+ detail: "`parse` normalizes any transcript into canonical segments + evidence — chunked LLM extraction by default when a key resolves (ANTHROPIC_API_KEY/OPENAI_API_KEY or `login anthropic|openai`), free deterministic fallback when none does; `--heuristics` forces the deterministic engine, `--llm` forces the LLM path, model via `--model` or env FSGTM_INSIGHTS_MODEL. `classify` picks the call type, `score` rates it against the type's rubric, `link` finds the deal, `plan` proposes governed next-step writes.",
420
457
  seeAlso: ["plans", "apply"],
421
458
  },
422
459
  // Govern — the plan/apply spine
@@ -530,7 +567,7 @@ export function shortUsage() {
530
567
  ["Setup & health", ["login", "logout", "doctor", "capabilities", "robot-docs", "profiles", "health"]],
531
568
  ["Detect — read-only", ["audit", "report", "snapshot", "diff", "rules"]],
532
569
  ["Prevent — gate writes", ["resolve"]],
533
- ["Remediate — governed writes", ["fix", "bulk-update", "dedupe", "reassign", "enrich"]],
570
+ ["Remediate — governed writes", ["fix", "bulk-update", "dedupe", "reassign", "enrich", "backfill"]],
534
571
  ["Calls → evidence", ["call"]],
535
572
  ["Govern — the plan/apply spine", ["suggest", "plans", "apply", "audit-log", "merge"]],
536
573
  ["Market intelligence", ["market", "tam"]],
package/dist/cli/plans.js CHANGED
@@ -13,9 +13,10 @@ import { buildAuditLog, verifyAuditLog } from "../auditLog.js";
13
13
  import { createFilePlanStore } from "../planStore.js";
14
14
  import { ENRICH_CONFIG_FILE_NAME, loadEnrichConfig } from "../enrich.js";
15
15
  import { loadMeter, recordConsumption, remaining } from "../acquireMeter.js";
16
- import { reportCounts } from "../runReport.js";
16
+ import { progressReporter, reportCounts } from "../runReport.js";
17
+ import { APPLY_STAGES, composeListeners, createProgressEmitter } from "../progress.js";
17
18
  import { connectorFor, isOptionValue, numericOption, option, repeatedOption, selectedRules } from "./shared.js";
18
- import { colorEnabled, createStatusLine, paint, planStatusWord, stylizePlanMarkdown, table, truncateToWidth } from "./ui.js";
19
+ import { colorEnabled, createProgressRenderer, paint, planStatusWord, stylizePlanMarkdown, table, truncateToWidth } from "./ui.js";
19
20
  import { unknownSubcommandError } from "./suggest.js";
20
21
  function parseValueOverrides(args) {
21
22
  const valueOverrides = {};
@@ -209,21 +210,22 @@ export async function apply(args) {
209
210
  // A channel (e.g. outbox) renders approved ops to a local artifact and
210
211
  // transmits nothing; a CRM provider writes records. Same governed apply path.
211
212
  const connector = channel ? createChannelConnector(channel) : await connectorFor(provider, args);
212
- // Interactive terminals get a live safety ticker on stderr while the run
213
- // executes; the summary line it leaves behind is plain text. Inert otherwise.
214
- const ticker = createStatusLine();
213
+ // Interactive terminals get a live apply board on stderr while the run
214
+ // executes (preflight operations results, with a per-op safety ticker);
215
+ // piped runs render nothing. Either way the emitter streams heartbeats to
216
+ // the paired hosted app when a long run is in flight.
217
+ const renderer = createProgressRenderer(APPLY_STAGES);
218
+ const progress = createProgressEmitter(composeListeners(renderer.listener, progressReporter()));
215
219
  let run;
216
220
  try {
217
221
  run = await applyPatchPlan(connector, plan, {
218
222
  approvedOperationIds,
219
223
  valueOverrides,
220
- onOperation: ticker.active
221
- ? (progress) => ticker.set(`Applying ${progress.completed}/${progress.total} · ✓ ${progress.applied} applied · ${progress.failed} failed · ${progress.conflicts} conflict${progress.conflicts === 1 ? "" : "s"} · ${progress.skipped} skipped`)
222
- : undefined,
224
+ progress,
223
225
  });
224
226
  }
225
227
  finally {
226
- ticker.done();
228
+ renderer.done();
227
229
  }
228
230
  if (planId && store) {
229
231
  await store.recordRun(planId, run);
@@ -242,9 +244,9 @@ export async function apply(args) {
242
244
  for (const result of run.results)
243
245
  applyTally[result.status] = (applyTally[result.status] ?? 0) + 1;
244
246
  reportCounts(applyTally);
245
- // Rich-only closing line on stderr (ticker.active implies an interactive,
247
+ // Rich-only closing line on stderr (renderer.active implies an interactive,
246
248
  // color-safe stderr): the one-glance outcome of the run.
247
- if (ticker.active) {
249
+ if (renderer.active) {
248
250
  const pe = paint(true);
249
251
  const badge = run.status === "applied"
250
252
  ? pe.green(`✓ ${applyTally.applied} operation(s) applied`)
@@ -1,6 +1,7 @@
1
1
  import { type LlmProvider } from "../llm.ts";
2
2
  import { type Icp } from "../icp.ts";
3
- import type { CanonicalGtmSnapshot, GtmConnector, SnapshotProgress } from "../types.ts";
3
+ import type { CanonicalGtmSnapshot, GtmConnector } from "../types.ts";
4
+ import { type ProgressEmitter } from "../progress.ts";
4
5
  export declare function option(args: string[], name: string): string | null;
5
6
  export declare function repeatedOption(args: string[], name: string): string[];
6
7
  /**
@@ -21,8 +22,8 @@ export declare function saveRequested(args: string[]): boolean;
21
22
  */
22
23
  export declare function confirmRequested(args: string[], ...legacyAliases: string[]): boolean;
23
24
  export declare function numericOption(args: string[], name: string): number | undefined;
24
- export declare function connectorFor(provider: string, args: string[], onProgress?: (progress: SnapshotProgress) => void): Promise<GtmConnector>;
25
- export declare function readSnapshot(args: string[]): Promise<CanonicalGtmSnapshot>;
25
+ export declare function connectorFor(provider: string, args: string[], progress?: ProgressEmitter): Promise<GtmConnector>;
26
+ export declare function readSnapshot(args: string[], progress?: ProgressEmitter): Promise<CanonicalGtmSnapshot>;
26
27
  /**
27
28
  * Validate that an --input file actually has the canonical snapshot shape
28
29
  * (the JSON `snapshot --out` writes) instead of blindly casting — a plan or
@@ -1,17 +1,14 @@
1
1
  // Extracted verbatim from src/cli.ts (mechanical split, no behavior change).
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
- import { createHubspotConnector } from "../connectors/hubspot.js";
5
- import { createSalesforceConnector } from "../connectors/salesforce.js";
6
- import { createStripeConnector } from "../connectors/stripe.js";
7
4
  import { credentialsPath, getCredential, resolveHubspotConnection, resolveSalesforceConnection, storeCredential } from "../credentials.js";
8
- import { generateDemoSnapshot } from "../demo.js";
9
5
  import { builtinAuditRules } from "../rules.js";
10
- import { sampleSnapshot } from "../sampleData.js";
11
6
  import { detectProviderFromKey, resolveLlmCredential, validateLlmKey } from "../llm.js";
12
7
  import { parseIcp } from "../icp.js";
13
8
  import { nearest } from "./suggest.js";
14
- import { createStatusLine, formatCount, formatDuration } from "./ui.js";
9
+ import { composeListeners, createProgressEmitter, SNAPSHOT_PULL_STAGES, STRIPE_SNAPSHOT_STAGES, } from "../progress.js";
10
+ import { progressReporter } from "../runReport.js";
11
+ import { createProgressRenderer } from "./ui.js";
15
12
  export function option(args, name) {
16
13
  const index = args.indexOf(name);
17
14
  if (index === -1)
@@ -98,23 +95,28 @@ async function salesforceConnection() {
98
95
  return stored;
99
96
  throw new Error("No Salesforce credentials. Run `fullstackgtm login salesforce`, pair with a hosted deployment via `fullstackgtm login --via <url>`, or set SALESFORCE_ACCESS_TOKEN and SALESFORCE_INSTANCE_URL. (`fullstackgtm doctor` shows credential status; device-flow login needs an admin-created Connected App — see the README's \"Connect your CRM\" section.)");
100
97
  }
101
- export async function connectorFor(provider, args, onProgress) {
98
+ export async function connectorFor(provider, args, progress) {
99
+ // Connector modules are the two largest files in dist (~100KB together) and
100
+ // load lazily here: only live `--provider` runs pay for them, not --demo/
101
+ // --sample/--input runs or the always-eager help/version paths.
102
102
  if (provider === "hubspot") {
103
103
  const connection = await hubspotConnection(args);
104
+ const { createHubspotConnector } = await import("../connectors/hubspot.js");
104
105
  return createHubspotConnector({
105
106
  getAccessToken: () => connection.accessToken,
106
107
  fieldMappings: connection.fieldMappings ?? undefined,
107
108
  // Point at a mock/proxy HubSpot (tests, evals, request-recording).
108
109
  apiBaseUrl: process.env.HUBSPOT_API_BASE_URL,
109
- onProgress,
110
+ progress,
110
111
  });
111
112
  }
112
113
  if (provider === "salesforce") {
113
114
  const connection = await salesforceConnection();
115
+ const { createSalesforceConnector } = await import("../connectors/salesforce.js");
114
116
  return createSalesforceConnector({
115
117
  getConnection: () => connection,
116
118
  fieldMappings: connection.fieldMappings ?? undefined,
117
- onProgress,
119
+ progress,
118
120
  });
119
121
  }
120
122
  if (provider === "stripe") {
@@ -122,38 +124,47 @@ export async function connectorFor(provider, args, onProgress) {
122
124
  if (!key) {
123
125
  throw new Error("No Stripe credentials. Run `echo \"$STRIPE_KEY\" | fullstackgtm login stripe` or set STRIPE_SECRET_KEY. A restricted key with read access to Customers and Subscriptions is enough. (`fullstackgtm doctor` shows credential status.)");
124
126
  }
125
- return createStripeConnector({ getApiKey: () => key });
127
+ const { createStripeConnector } = await import("../connectors/stripe.js");
128
+ return createStripeConnector({ getApiKey: () => key, progress });
126
129
  }
127
130
  const providerSuggestion = nearest(provider.toLowerCase(), ["hubspot", "salesforce", "stripe"], 3);
128
131
  throw new Error(`Unknown provider: ${provider}.${providerSuggestion ? ` Did you mean ${providerSuggestion}?` : ""} ` +
129
132
  "Supported providers: hubspot, salesforce, stripe");
130
133
  }
131
- export async function readSnapshot(args) {
134
+ export async function readSnapshot(args, progress) {
132
135
  const provider = option(args, "--provider");
133
136
  if (provider) {
137
+ // A verb driving its own progress board (e.g. `backfill stripe`) passes
138
+ // its emitter through; the pull's stage/items events flow into it and no
139
+ // second board is painted.
140
+ if (progress) {
141
+ const connector = await connectorFor(provider, args, progress);
142
+ return connector.fetchSnapshot();
143
+ }
134
144
  // Live pulls can page for minutes with nothing on screen. On an
135
- // interactive terminal, a stderr status line shows the running tally;
136
- // piped/CI/agent runs get an inert no-op (zero bytes written).
137
- const status = createStatusLine();
138
- const startedAt = Date.now();
139
- const connector = await connectorFor(provider, args, status.active
140
- ? (progress) => status.set(`Pulling ${progress.objectType}s from ${provider}… ${formatCount(progress.fetched)} fetched · ${formatDuration(Date.now() - startedAt)}`)
141
- : undefined);
145
+ // interactive terminal, a stderr checklist ticks through the pull stages
146
+ // with running tallies; piped/CI/agent runs render nothing (zero bytes
147
+ // written). Either way the emitter feeds the broker heartbeat when the
148
+ // CLI is paired, so long pulls tick live on the hosted dashboard too.
149
+ const renderer = createProgressRenderer(provider === "stripe" ? STRIPE_SNAPSHOT_STAGES : SNAPSHOT_PULL_STAGES);
150
+ const emitter = createProgressEmitter(composeListeners(renderer.listener, progressReporter()));
151
+ const connector = await connectorFor(provider, args, emitter);
142
152
  try {
143
153
  return await connector.fetchSnapshot();
144
154
  }
145
155
  finally {
146
- status.done();
156
+ renderer.done();
147
157
  }
148
158
  }
149
159
  if (args.includes("--demo")) {
160
+ const { generateDemoSnapshot } = await import("../demo.js");
150
161
  return generateDemoSnapshot({
151
162
  seed: numericOption(args, "--seed"),
152
163
  today: option(args, "--today") ?? undefined,
153
164
  });
154
165
  }
155
166
  if (args.includes("--sample"))
156
- return sampleSnapshot;
167
+ return (await import("../sampleData.js")).sampleSnapshot;
157
168
  const input = option(args, "--input");
158
169
  if (!input) {
159
170
  throw new Error("No data source. Pass one of: --provider <hubspot|salesforce|stripe> (live, read-only), " +
package/dist/cli/ui.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { ProgressListener } from "../progress.ts";
1
2
  type StreamLike = {
2
3
  isTTY?: boolean;
3
4
  columns?: number;
@@ -100,6 +101,26 @@ export declare function createChecklist(items: Array<{
100
101
  id: string;
101
102
  label: string;
102
103
  }>, stream?: StreamLike, env?: UiEnv): Checklist;
104
+ export type ProgressRenderer = {
105
+ /**
106
+ * Feed to `createProgressEmitter` (compose with the broker streaming
107
+ * listener via `composeListeners` when the run should also heartbeat).
108
+ */
109
+ listener: ProgressListener;
110
+ /** Stop animating and erase the board (callers print their own summary). */
111
+ done(): void;
112
+ readonly active: boolean;
113
+ };
114
+ /**
115
+ * Adapter from the shared progress vocabulary (src/progress.ts) to the
116
+ * existing TTY primitives: `stage` events advance a live checklist (○ → ⠹ →
117
+ * ✓), while `items` / `note` / `opResult` / `meter` events annotate the
118
+ * running stage ("contacts 1,200/4,800", the apply ticker tallies, a fuel
119
+ * gauge). Outside an interactive TTY this is an inert no-op — piped/CI/agent
120
+ * output stays byte-identical (streaming listeners composed alongside still
121
+ * run; rendering is presentation only).
122
+ */
123
+ export declare function createProgressRenderer(stages: readonly string[], stream?: StreamLike, env?: UiEnv): ProgressRenderer;
103
124
  /** Truncate to `max` display characters, ending in "…" when cut. */
104
125
  export declare function truncateToWidth(text: string, max: number): string;
105
126
  /** critical → red, warning → yellow, info → dim. */
package/dist/cli/ui.js CHANGED
@@ -297,6 +297,59 @@ export function createChecklist(items, stream = process.stderr, env = process.en
297
297
  active: true,
298
298
  };
299
299
  }
300
+ const NOOP_RENDERER = { listener: () => { }, done() { }, active: false };
301
+ /**
302
+ * Adapter from the shared progress vocabulary (src/progress.ts) to the
303
+ * existing TTY primitives: `stage` events advance a live checklist (○ → ⠹ →
304
+ * ✓), while `items` / `note` / `opResult` / `meter` events annotate the
305
+ * running stage ("contacts 1,200/4,800", the apply ticker tallies, a fuel
306
+ * gauge). Outside an interactive TTY this is an inert no-op — piped/CI/agent
307
+ * output stays byte-identical (streaming listeners composed alongside still
308
+ * run; rendering is presentation only).
309
+ */
310
+ export function createProgressRenderer(stages, stream = process.stderr, env = process.env) {
311
+ const board = createChecklist(stages.map((stage) => ({ id: stage, label: stage })), stream, env);
312
+ if (!board.active)
313
+ return NOOP_RENDERER;
314
+ let current = null;
315
+ const noteFor = (event, snapshot) => {
316
+ if (event.kind === "items") {
317
+ return event.total !== undefined
318
+ ? `${formatCount(event.done)}/${formatCount(event.total)}`
319
+ : formatCount(event.done);
320
+ }
321
+ if (event.kind === "note")
322
+ return event.note;
323
+ if (event.kind === "opResult") {
324
+ return `${GLYPH.ok} ${formatCount(snapshot.opsApplied)} applied · ${formatCount(snapshot.opsFailed)} failed · ${formatCount(snapshot.opsSkipped)} skipped`;
325
+ }
326
+ if (event.kind === "meter") {
327
+ const fraction = event.budget > 0 ? event.spent / event.budget : 0;
328
+ return `${formatBar(fraction, 10)} ${formatCount(event.spent)}/${formatCount(event.budget)} ${event.unit}`;
329
+ }
330
+ return undefined;
331
+ };
332
+ return {
333
+ listener(event, snapshot) {
334
+ if (event.kind === "stage") {
335
+ // Advance the board: close out the previous stage, spin the new one.
336
+ if (current && current !== event.stage)
337
+ board.update(current, "ok");
338
+ current = event.stage;
339
+ board.update(current, "running");
340
+ return;
341
+ }
342
+ // Events for a stage the board doesn't know (e.g. a nested pull inside a
343
+ // larger flow) are ignored by createChecklist's unknown-id no-op.
344
+ if (current)
345
+ board.update(current, "running", noteFor(event, snapshot));
346
+ },
347
+ done() {
348
+ board.done();
349
+ },
350
+ active: true,
351
+ };
352
+ }
300
353
  /** Truncate to `max` display characters, ending in "…" when cut. */
301
354
  export function truncateToWidth(text, max) {
302
355
  if (max <= 0)
@@ -370,6 +423,5 @@ export function stylizePlanMarkdown(text, p) {
370
423
  }
371
424
  /** Strip ANSI escape sequences (test helper + safety net for width math). */
372
425
  export function stripAnsi(text) {
373
- // eslint-disable-next-line no-control-regex
374
426
  return text.replace(/\u001b\[[0-9;]*[A-Za-z]/g, "");
375
427
  }