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
package/dist/bin.js CHANGED
@@ -1,8 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { runCli } from "./cli.js";
3
- import { flushRunReport } from "./runReport.js";
3
+ import { beginRunReport, flushRunReport } from "./runReport.js";
4
4
  const args = process.argv.slice(2);
5
5
  const startedAt = Date.now();
6
+ // Arm live progress heartbeats (paired CLIs stream long runs to the hosted
7
+ // app under the same clientRunId the final flush below will upsert).
8
+ beginRunReport(args, startedAt);
6
9
  runCli(args)
7
10
  .then(async () => {
8
11
  // exitCode 2 (audit findings / create-gate) or 1 (no value) = "partial":
@@ -5,6 +5,8 @@
5
5
  * shell escaping does nothing for a token sent in cleartext.
6
6
  */
7
7
  export declare function assertSecureBrokerUrl(raw: string): URL;
8
+ type CrmProvider = "hubspot" | "salesforce";
9
+ export declare function hostedProviderLogin(provider: CrmProvider, baseUrl: string): Promise<void>;
8
10
  export declare function login(args: string[]): Promise<void>;
9
11
  export declare function logout(args: string[]): void;
10
12
  type ProviderDoctorStatus = {
package/dist/cli/auth.js CHANGED
@@ -47,6 +47,7 @@ export function assertSecureBrokerUrl(raw) {
47
47
  throw new Error(`Refusing to pair over ${url.protocol}//${url.host}: the broker exchanges a long-lived token and mints live CRM ` +
48
48
  "credentials, so it must use https (http is allowed only for localhost dev).");
49
49
  }
50
+ const DEFAULT_HOSTED_BASE_URL = "https://app.fullstackgtm.com";
50
51
  async function brokerLogin(baseUrl) {
51
52
  const viaUrl = assertSecureBrokerUrl(baseUrl);
52
53
  const base = baseUrl.replace(/\/$/, "");
@@ -118,12 +119,115 @@ async function brokerLogin(baseUrl) {
118
119
  }
119
120
  throw new Error("Pairing timed out before it was approved.");
120
121
  }
122
+ export async function hostedProviderLogin(provider, baseUrl) {
123
+ const viaUrl = assertSecureBrokerUrl(baseUrl);
124
+ const base = baseUrl.replace(/\/$/, "");
125
+ const os = await import("node:os");
126
+ const requesterLabel = `${os.hostname()} (${process.platform}, ${os.userInfo().username})`;
127
+ let startResponse;
128
+ try {
129
+ startResponse = await fetch(`${base}/api/cli/oauth/start`, {
130
+ method: "POST",
131
+ headers: { "Content-Type": "application/json" },
132
+ body: JSON.stringify({ provider, requesterLabel }),
133
+ });
134
+ }
135
+ catch (error) {
136
+ const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
137
+ throw new Error(`Cannot reach the hosted deployment at ${base}${cause}. Check the --via URL and network access.`);
138
+ }
139
+ if (!startResponse.ok) {
140
+ throw new Error(`Could not start ${provider} OAuth with ${base} (${startResponse.status}). Is this a FullStackGTM deployment?`);
141
+ }
142
+ const start = await startResponse.json();
143
+ let sameOrigin = false;
144
+ try {
145
+ sameOrigin = new URL(start.verificationUrl).origin === viaUrl.origin;
146
+ }
147
+ catch {
148
+ sameOrigin = false;
149
+ }
150
+ console.error(`\n${provider} OAuth code: ${start.userCode}\n\nApprove this CLI ("${requesterLabel}") in your hosted FullStackGTM dashboard:\n\n ${start.verificationUrl}\n`);
151
+ if (sameOrigin) {
152
+ void openInBrowser(start.verificationUrl);
153
+ }
154
+ else {
155
+ console.error(`(Not auto-opening: the verification URL is not on ${viaUrl.origin}. Open it manually only if you trust it.)`);
156
+ }
157
+ const deadline = Date.now() + (start.expiresInSeconds ?? 600) * 1000;
158
+ const intervalMs = Math.max(0, (start.intervalSeconds ?? 3) * 1000);
159
+ while (Date.now() < deadline) {
160
+ await new Promise((resolveSleep) => setTimeout(resolveSleep, intervalMs));
161
+ const pollResponse = await fetch(`${base}/api/cli/auth/poll`, {
162
+ method: "POST",
163
+ headers: { "Content-Type": "application/json" },
164
+ body: JSON.stringify({ deviceCode: start.deviceCode }),
165
+ });
166
+ if (!pollResponse.ok)
167
+ throw new Error(`Pairing poll failed (${pollResponse.status}).`);
168
+ const poll = await pollResponse.json();
169
+ if (poll.status === "pending")
170
+ continue;
171
+ if (poll.status === "approved" && poll.cliToken) {
172
+ const now = new Date().toISOString();
173
+ storeCredential("broker", {
174
+ kind: "broker",
175
+ accessToken: poll.cliToken,
176
+ baseUrl: base,
177
+ createdAt: now,
178
+ updatedAt: now,
179
+ });
180
+ console.log(`Logged in to ${provider} via hosted OAuth at ${base}. Credentials stored in ${credentialsPath()}.`);
181
+ console.log("Provider tokens are minted server-side by the hosted app; no provider app secret is stored in this CLI.");
182
+ return;
183
+ }
184
+ throw new Error(`Pairing was ${poll.status}.`);
185
+ }
186
+ throw new Error("Pairing timed out before it was approved.");
187
+ }
188
+ function hostedBase(args) {
189
+ return option(args, "--via") ?? process.env.FULLSTACKGTM_HOSTED_URL ?? DEFAULT_HOSTED_BASE_URL;
190
+ }
191
+ async function guidedProviderLogin(provider, args, reason) {
192
+ const command = `fullstackgtm login ${provider} --hosted`;
193
+ if (!process.stdin.isTTY || process.env.CI) {
194
+ console.error(`${reason}\nNext command: ${command}`);
195
+ throw new Error(reason);
196
+ }
197
+ console.error(`\n${provider} login options:\n` +
198
+ ` 1) Hosted OAuth (default): no provider app needed; browser approval via FullStackGTM.\n` +
199
+ ` 2) BYO ${provider === "salesforce" ? "Connected App" : "HubSpot app"}: advanced OAuth/device flow.\n` +
200
+ ` 3) Paste token: read from stdin/prompt; never from argv.\n`);
201
+ const readline = await import("node:readline/promises");
202
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
203
+ const answer = (await rl.question("Choose [1]: ")).trim();
204
+ rl.close();
205
+ if (answer === "" || answer === "1")
206
+ return hostedProviderLogin(provider, hostedBase(args));
207
+ if (answer === "2") {
208
+ throw new Error(provider === "salesforce"
209
+ ? "Advanced BYO Salesforce: run `fullstackgtm login salesforce --device --client-id <consumer key>` or pipe a token with `--instance-url <url>`."
210
+ : `Advanced BYO HubSpot: run \`fullstackgtm login hubspot --oauth --client-id <id>\` (redirect http://localhost:${numericOption(args, "--port") ?? DEFAULT_LOOPBACK_PORT}/callback).`);
211
+ }
212
+ if (answer === "3") {
213
+ throw new Error(provider === "salesforce"
214
+ ? "Paste-token Salesforce: pipe the token with `fullstackgtm login salesforce --instance-url <url>`."
215
+ : "Paste-token HubSpot: pipe the token with `fullstackgtm login hubspot --private-token`.");
216
+ }
217
+ return hostedProviderLogin(provider, hostedBase(args));
218
+ }
121
219
  async function salesforceLogin(args) {
122
220
  const now = new Date().toISOString();
221
+ rejectArgvSecret(args, "--token");
222
+ if (args.includes("--hosted") || (!args.includes("--device") && !args.includes("--instance-url"))) {
223
+ await hostedProviderLogin("salesforce", hostedBase(args));
224
+ return;
225
+ }
123
226
  if (args.includes("--device")) {
124
227
  const clientId = option(args, "--client-id");
125
228
  if (!clientId) {
126
- throw new Error("--device requires --client-id (the consumer key of a Connected App with device flow enabled).");
229
+ await guidedProviderLogin("salesforce", args, "--device requires --client-id (the consumer key of a Connected App with device flow enabled).");
230
+ return;
127
231
  }
128
232
  const loginUrl = option(args, "--login-url") ?? undefined;
129
233
  const authorization = await startSalesforceDeviceLogin({ clientId, loginUrl });
@@ -150,11 +254,10 @@ async function salesforceLogin(args) {
150
254
  console.log("Tokens refresh silently; no further browser interaction is needed.");
151
255
  return;
152
256
  }
153
- rejectArgvSecret(args, "--token");
154
257
  const instanceUrl = option(args, "--instance-url");
155
258
  if (!instanceUrl) {
156
- throw new Error("Salesforce login needs --device --client-id <consumer key>, or --instance-url " +
157
- "<https://yourorg.my.salesforce.com> with the access token piped on stdin.");
259
+ await guidedProviderLogin("salesforce", args, "Salesforce login needs hosted OAuth, --device --client-id <consumer key>, or --instance-url <https://yourorg.my.salesforce.com> with the access token piped on stdin.");
260
+ return;
158
261
  }
159
262
  const token = await readSecret("Salesforce access token");
160
263
  if (!token)
@@ -175,12 +278,12 @@ async function salesforceLogin(args) {
175
278
  console.log(`Logged in to Salesforce. Credentials stored in ${credentialsPath()}.`);
176
279
  }
177
280
  export async function login(args) {
281
+ const provider = args.find((arg) => !arg.startsWith("--") && !isOptionValue(args, arg));
178
282
  const via = option(args, "--via");
179
- if (via) {
283
+ if (via && !provider) {
180
284
  await brokerLogin(via);
181
285
  return;
182
286
  }
183
- const provider = args.find((arg) => !arg.startsWith("--") && !isOptionValue(args, arg));
184
287
  if (provider === "salesforce") {
185
288
  await salesforceLogin(args);
186
289
  return;
@@ -261,13 +364,18 @@ export async function login(args) {
261
364
  throw new Error("login supports: hubspot, salesforce, stripe, anthropic, openai, apollo, pipe0, explorium, or --via <hosted url>. Usage: fullstackgtm login <provider> | fullstackgtm login --via https://gtm.example.com");
262
365
  }
263
366
  const now = new Date().toISOString();
367
+ rejectArgvSecret(args, "--token");
368
+ if (args.includes("--hosted") || (!args.includes("--oauth") && !args.includes("--private-token"))) {
369
+ await hostedProviderLogin("hubspot", hostedBase(args));
370
+ return;
371
+ }
264
372
  if (args.includes("--oauth")) {
265
373
  rejectArgvSecret(args, "--client-secret");
266
374
  const clientId = option(args, "--client-id");
267
375
  if (!clientId) {
268
- throw new Error("--oauth requires --client-id from your own HubSpot app " +
269
- `(register http://localhost:${numericOption(args, "--port") ?? DEFAULT_LOOPBACK_PORT}/callback as a redirect URL). ` +
270
- "The client secret is read from stdin or an interactive prompt.");
376
+ await guidedProviderLogin("hubspot", args, "--oauth requires --client-id from your own HubSpot app " +
377
+ `(register http://localhost:${numericOption(args, "--port") ?? DEFAULT_LOOPBACK_PORT}/callback as a redirect URL).`);
378
+ return;
271
379
  }
272
380
  const clientSecret = await readSecret("HubSpot app client secret");
273
381
  if (!clientSecret)
@@ -294,7 +402,6 @@ export async function login(args) {
294
402
  console.log("Tokens refresh silently; no further browser interaction is needed.");
295
403
  return;
296
404
  }
297
- rejectArgvSecret(args, "--token");
298
405
  const token = await readSecret("HubSpot private app token");
299
406
  if (!token)
300
407
  throw new Error("No token provided.");
@@ -354,7 +461,7 @@ export function doctorReport(env = process.env) {
354
461
  const nextSteps = connected.length === 0
355
462
  ? [
356
463
  "fullstackgtm audit --demo # no credentials needed",
357
- "fullstackgtm login hubspot # connect your CRM (or: login --via <hosted url>)",
464
+ "fullstackgtm login hubspot # hosted browser OAuth (default; or: login salesforce)",
358
465
  ]
359
466
  : [`fullstackgtm audit --provider ${connected[0][0]}`];
360
467
  return {
@@ -380,7 +487,7 @@ function providerStatus(provider, broker) {
380
487
  if (broker) {
381
488
  return { source: "broker", detail: `via ${broker.baseUrl ?? "hosted deployment"}` };
382
489
  }
383
- return { source: "none", detail: `fullstackgtm login ${provider}` };
490
+ return { source: "none", detail: provider === "hubspot" || provider === "salesforce" ? `fullstackgtm login ${provider} (hosted OAuth default)` : `fullstackgtm login ${provider}` };
384
491
  }
385
492
  /**
386
493
  * The workspace slice of doctor: current health + plans awaiting approval.
@@ -0,0 +1 @@
1
+ export declare function backfillCommand(args: string[]): Promise<void>;
@@ -0,0 +1,125 @@
1
+ // `backfill stripe` — paid Stripe invoices → closed-won deal proposals through
2
+ // the governed plan/apply flow. This command NEVER writes to a CRM: it reads
3
+ // paid invoices (Stripe, read-only), reads a CRM snapshot, and builds a
4
+ // dry-run create_record plan. --save persists the plan for the normal
5
+ // plans approve → apply spine, where the HubSpot connector re-resolves each
6
+ // invoice id (resolve-first) and creates only on a confirmed miss.
7
+ import { buildStripeBackfillPlan, DEFAULT_BACKFILL_MATCH_PROPERTY } from "../backfill.js";
8
+ import { fetchStripePaidInvoices } from "../connectors/stripe.js";
9
+ import { getCredential } from "../credentials.js";
10
+ import { patchPlanToMarkdown } from "../format.js";
11
+ import { createFilePlanStore } from "../planStore.js";
12
+ import { BACKFILL_STRIPE_STAGES, composeListeners, createProgressEmitter } from "../progress.js";
13
+ import { progressReporter } from "../runReport.js";
14
+ import { unknownSubcommandError } from "./suggest.js";
15
+ import { option, readSnapshot, saveRequested } from "./shared.js";
16
+ import { colorEnabled, createProgressRenderer, paint, stylizePlanMarkdown, table } from "./ui.js";
17
+ /** STRIPE_SECRET_KEY env ∨ stored `login stripe` credential (same ladder as `--provider stripe`). */
18
+ function resolveStripeKey() {
19
+ const key = process.env.STRIPE_SECRET_KEY ?? getCredential("stripe")?.accessToken;
20
+ if (!key) {
21
+ throw new Error("No Stripe credentials. Run `echo \"$STRIPE_KEY\" | fullstackgtm login stripe` or set STRIPE_SECRET_KEY. " +
22
+ "A restricted key with read access to Invoices and Customers is enough. (`fullstackgtm doctor` shows credential status.)");
23
+ }
24
+ return key;
25
+ }
26
+ export async function backfillCommand(args) {
27
+ const [subcommand, ...rest] = args;
28
+ if (subcommand === "runs") {
29
+ const { backfillRunsCommand } = await import("./backfillRuns.js");
30
+ await backfillRunsCommand(rest);
31
+ return;
32
+ }
33
+ if (subcommand !== "stripe") {
34
+ throw unknownSubcommandError("backfill", subcommand ?? "", ["stripe", "runs"]);
35
+ }
36
+ const since = option(rest, "--since");
37
+ const pipeline = option(rest, "--pipeline") ?? undefined;
38
+ const matchProperty = option(rest, "--match-property") ?? undefined;
39
+ const skipUnmatched = rest.includes("--skip-unmatched");
40
+ const save = saveRequested(rest);
41
+ // Shared progress: the BACKFILL_STRIPE_STAGES tick on an interactive stderr
42
+ // board (inert when piped) and heartbeat to a paired hosted app on long runs.
43
+ const renderer = createProgressRenderer(BACKFILL_STRIPE_STAGES);
44
+ const progress = createProgressEmitter(composeListeners(renderer.listener, progressReporter()));
45
+ const stage = (name) => progress.stage(name, BACKFILL_STRIPE_STAGES.indexOf(name), BACKFILL_STRIPE_STAGES.length);
46
+ let result;
47
+ try {
48
+ // 1. Revenue truth: paid invoices from Stripe (read-only).
49
+ stage("invoices");
50
+ const stripeKey = resolveStripeKey();
51
+ const invoices = await fetchStripePaidInvoices({ getApiKey: () => stripeKey }, { ...(since ? { sinceIso: since } : {}), onPage: (fetched) => progress.items(fetched) });
52
+ // 2. The CRM as it stands (same source options as audit: --provider <crm>,
53
+ // --input <snapshot.json>, --demo, --sample). The pull's own stage/items
54
+ // events ride the same emitter (the board ignores the nested stages).
55
+ stage("snapshot");
56
+ const snapshot = await readSnapshot(rest, progress);
57
+ // 3. Dry-run plan: one closed-won create_record deal per invoice with no
58
+ // deal yet; unmatched customers also get a proposed account (unless
59
+ // --skip-unmatched restores report-only behavior).
60
+ stage("matching");
61
+ result = buildStripeBackfillPlan(invoices, snapshot, {
62
+ pipeline,
63
+ matchProperty,
64
+ source: "stripe:invoices",
65
+ createMissingAccounts: !skipUnmatched,
66
+ });
67
+ stage("plan");
68
+ progress.flush();
69
+ }
70
+ finally {
71
+ renderer.done();
72
+ }
73
+ if (rest.includes("--json")) {
74
+ console.log(JSON.stringify({ plan: result.plan, counts: result.counts, unmatched: result.unmatched, proposedAccounts: result.proposedAccounts }, null, 2));
75
+ }
76
+ else {
77
+ // TTY-only styling; piped output stays byte-identical plain text.
78
+ console.log(stylizePlanMarkdown(patchPlanToMarkdown(result.plan), paint(colorEnabled(process.stdout))));
79
+ if (result.proposedAccounts.length > 0) {
80
+ console.log(`\nProposed new accounts (${result.proposedAccounts.length}) — Stripe customers the CRM doesn't know; ` +
81
+ "created only if this plan is approved and applied:");
82
+ const accountRows = [
83
+ ["account", "domain", "invoices"],
84
+ ...result.proposedAccounts.map((entry) => [
85
+ entry.name,
86
+ entry.domain ?? "—",
87
+ String(entry.invoiceCount),
88
+ ]),
89
+ ];
90
+ for (const line of table(accountRows))
91
+ console.log(` ${line}`);
92
+ }
93
+ if (result.unmatched.length > 0) {
94
+ console.log(`\nUnmatched invoices (${result.unmatched.length}) — ${skipUnmatched
95
+ ? "customer matched no CRM account by domain or name; reported only (--skip-unmatched)"
96
+ : "customer has no usable name or company domain; reported only"}:`);
97
+ const rows = [
98
+ ["invoice", "customer", "domain", "amount", "paid"],
99
+ ...result.unmatched.map((entry) => [
100
+ entry.invoiceNumber ?? entry.invoiceId,
101
+ entry.customerName ?? "—",
102
+ entry.customerDomain ?? "—",
103
+ `${entry.amountPaid}${entry.currency ? ` ${entry.currency}` : ""}`,
104
+ entry.paidAt ?? "—",
105
+ ]),
106
+ ];
107
+ for (const line of table(rows))
108
+ console.log(` ${line}`);
109
+ }
110
+ }
111
+ if (!save) {
112
+ console.error("\nDry run — nothing written. Re-run with --save to persist the plan, then approve + apply.");
113
+ return;
114
+ }
115
+ if (result.plan.operations.length === 0) {
116
+ console.error("No deals to backfill (every paid invoice already matched a deal or its customer was unmatched); nothing saved.");
117
+ return;
118
+ }
119
+ await createFilePlanStore().save(result.plan);
120
+ console.error(`Saved plan ${result.plan.id} — ${result.counts.planned} closed-won deal(s)` +
121
+ `${result.counts.accountsProposed > 0 ? ` + ${result.counts.accountsProposed} new account(s)` : ""} from ${result.counts.invoices} paid invoice(s). ` +
122
+ `Review \`fullstackgtm plans show ${result.plan.id}\`, approve \`fullstackgtm plans approve ${result.plan.id} --operations all\`, ` +
123
+ `then \`fullstackgtm apply --plan-id ${result.plan.id} --provider hubspot\`. ` +
124
+ `Apply re-checks ${result.plan.operations.length ? `the ${matchProperty ?? DEFAULT_BACKFILL_MATCH_PROPERTY} dedupe key` : "resolve-first"} before creating.`);
125
+ }
@@ -0,0 +1 @@
1
+ export declare function backfillRunsCommand(args: string[]): Promise<void>;
@@ -0,0 +1,187 @@
1
+ // `backfill runs` — replay the LOCAL execution history to the paired hosted
2
+ // app, so an engagement that started CLI-only gets its full trail the moment
3
+ // it pairs (instead of a feed that begins mid-engagement):
4
+ // - every stored plan's runs[] (apply attempts, per-op result counts)
5
+ // → POST /api/cli/run, one record per run
6
+ // - the health.jsonl timeline (per-audit hygiene scores)
7
+ // → POST /api/cli/health, chunked
8
+ // Idempotent by construction: each run carries a DETERMINISTIC clientRunId
9
+ // (fnv1a of planId + startedAt + index) and health entries dedupe server-side
10
+ // on their timestamp — re-running backfill never duplicates anything.
11
+ // Privacy: replays only what live reporting would have sent — statuses,
12
+ // counts, timestamps, scores. No CRM field values leave the machine.
13
+ import { getCredential } from "../credentials.js";
14
+ import { readHealthTimeline } from "../health.js";
15
+ import { createFilePlanStore } from "../planStore.js";
16
+ import { activeProfile } from "../credentials.js";
17
+ import { composeListeners, createProgressEmitter, RUNS_REPLAY_STAGES } from "../progress.js";
18
+ import { progressReporter } from "../runReport.js";
19
+ import { createProgressRenderer } from "./ui.js";
20
+ function fnv1a(value) {
21
+ let hash = 0x811c9dc5;
22
+ for (let i = 0; i < value.length; i += 1) {
23
+ hash ^= value.charCodeAt(i);
24
+ hash = Math.imul(hash, 0x01000193);
25
+ }
26
+ return (hash >>> 0).toString(16).padStart(8, "0");
27
+ }
28
+ const RUN_STATUS = {
29
+ applied: "success",
30
+ partial: "partial",
31
+ failed: "error",
32
+ rejected: "error",
33
+ };
34
+ export async function backfillRunsCommand(args) {
35
+ const dryRun = args.includes("--dry-run");
36
+ const json = args.includes("--json");
37
+ const broker = getCredential("broker");
38
+ if (!broker?.baseUrl || !broker.accessToken) {
39
+ throw new Error("Not paired with a hosted app. Run `fullstackgtm login --via <url>` first — backfill replays local history to the paired backend.");
40
+ }
41
+ const baseUrl = broker.baseUrl.replace(/\/+$/, "");
42
+ const headers = {
43
+ Authorization: `Bearer ${broker.accessToken}`,
44
+ "Content-Type": "application/json",
45
+ };
46
+ const profile = activeProfile();
47
+ // 1. Gather: every stored plan's runs[] + the health timeline.
48
+ const plans = await createFilePlanStore().list();
49
+ const runPayloads = plans.flatMap((stored) => stored.runs.map((run, index) => {
50
+ const startedAt = Date.parse(run.startedAt);
51
+ const finishedAt = Date.parse(run.finishedAt);
52
+ const tally = { applied: 0, skipped: 0, conflicts: 0, failed: 0 };
53
+ for (const result of run.results) {
54
+ if (result.status === "applied")
55
+ tally.applied += 1;
56
+ else if (result.status === "skipped")
57
+ tally.skipped += 1;
58
+ else if (result.status === "conflict")
59
+ tally.conflicts += 1;
60
+ else
61
+ tally.failed += 1;
62
+ }
63
+ return {
64
+ clientRunId: `bkf_${fnv1a(`${stored.plan.id}:${run.startedAt}:${index}`)}`,
65
+ command: `apply --plan-id ${stored.plan.id}`,
66
+ status: RUN_STATUS[run.status] ?? "error",
67
+ profile,
68
+ startedAt: Number.isFinite(startedAt) ? startedAt : Date.now(),
69
+ finishedAt: Number.isFinite(finishedAt) ? finishedAt : startedAt,
70
+ durationMs: Number.isFinite(startedAt) && Number.isFinite(finishedAt) && finishedAt >= startedAt
71
+ ? finishedAt - startedAt
72
+ : 0,
73
+ counts: { ...tally, provider: run.provider, backfilled: true },
74
+ };
75
+ }));
76
+ const healthEntries = readHealthTimeline().map((entry) => ({
77
+ at: Date.parse(entry.at),
78
+ score: entry.score,
79
+ findings: entry.findings,
80
+ weightedFindings: entry.weightedFindings,
81
+ records: entry.records,
82
+ severityCounts: entry.severityCounts,
83
+ byObjectType: entry.byObjectType,
84
+ byRule: entry.byRule,
85
+ })).filter((entry) => Number.isFinite(entry.at));
86
+ if (dryRun) {
87
+ const summary = {
88
+ ok: true,
89
+ dryRun: true,
90
+ plans: plans.length,
91
+ runs: runPayloads.length,
92
+ healthEntries: healthEntries.length,
93
+ target: baseUrl,
94
+ };
95
+ if (json)
96
+ console.log(JSON.stringify(summary, null, 2));
97
+ else {
98
+ console.log(`Dry run — would replay ${runPayloads.length} plan run(s) (from ${plans.length} stored plan(s)) ` +
99
+ `and ${healthEntries.length} health reading(s) to ${baseUrl}. Nothing sent.`);
100
+ }
101
+ return;
102
+ }
103
+ // Shared progress: replay ticks on an interactive stderr board (inert when
104
+ // piped) and heartbeats to the paired app — this command is paired by
105
+ // definition, so a big replay shows live on the dashboard as it lands.
106
+ const renderer = createProgressRenderer(RUNS_REPLAY_STAGES);
107
+ const progress = createProgressEmitter(composeListeners(renderer.listener, progressReporter()));
108
+ // 2. Replay runs (sequential; server dedupes on clientRunId).
109
+ let reported = 0;
110
+ let failed = 0;
111
+ const errors = [];
112
+ let healthInserted = 0;
113
+ let healthDeduped = 0;
114
+ try {
115
+ progress.stage(RUNS_REPLAY_STAGES[0], 0, RUNS_REPLAY_STAGES.length);
116
+ for (const payload of runPayloads) {
117
+ try {
118
+ const response = await fetch(`${baseUrl}/api/cli/run`, {
119
+ method: "POST",
120
+ headers,
121
+ body: JSON.stringify(payload),
122
+ signal: AbortSignal.timeout(10_000),
123
+ });
124
+ if (!response.ok)
125
+ throw new Error(`HTTP ${response.status}`);
126
+ reported += 1;
127
+ }
128
+ catch (error) {
129
+ failed += 1;
130
+ if (errors.length < 5) {
131
+ errors.push(`${payload.command}: ${error instanceof Error ? error.message : String(error)}`);
132
+ }
133
+ }
134
+ progress.items(reported + failed, runPayloads.length);
135
+ }
136
+ // 3. Replay health history in chunks (server dedupes on timestamp).
137
+ progress.stage(RUNS_REPLAY_STAGES[1], 1, RUNS_REPLAY_STAGES.length);
138
+ for (let i = 0; i < healthEntries.length; i += 200) {
139
+ const chunk = healthEntries.slice(i, i + 200);
140
+ try {
141
+ const response = await fetch(`${baseUrl}/api/cli/health`, {
142
+ method: "POST",
143
+ headers,
144
+ body: JSON.stringify({ entries: chunk }),
145
+ signal: AbortSignal.timeout(15_000),
146
+ });
147
+ if (!response.ok)
148
+ throw new Error(`HTTP ${response.status}`);
149
+ const result = (await response.json());
150
+ healthInserted += result.inserted ?? 0;
151
+ healthDeduped += result.deduped ?? 0;
152
+ }
153
+ catch (error) {
154
+ failed += 1;
155
+ if (errors.length < 5) {
156
+ errors.push(`health chunk: ${error instanceof Error ? error.message : String(error)}`);
157
+ }
158
+ }
159
+ progress.items(Math.min(i + 200, healthEntries.length), healthEntries.length);
160
+ }
161
+ progress.flush();
162
+ }
163
+ finally {
164
+ renderer.done();
165
+ }
166
+ const summary = {
167
+ ok: failed === 0,
168
+ runsReported: reported,
169
+ runsFailed: failed,
170
+ healthInserted,
171
+ healthDeduped,
172
+ target: baseUrl,
173
+ ...(errors.length > 0 ? { errors } : {}),
174
+ };
175
+ if (json) {
176
+ console.log(JSON.stringify(summary, null, 2));
177
+ }
178
+ else {
179
+ console.log(`Replayed ${reported}/${runPayloads.length} plan run(s) and ${healthInserted} health reading(s) ` +
180
+ `to ${baseUrl} (${healthDeduped} readings already there — dedupe is server-side, re-running is safe).`);
181
+ for (const line of errors)
182
+ console.error(` error: ${line}`);
183
+ }
184
+ if (failed > 0) {
185
+ process.exitCode = 1;
186
+ }
187
+ }
package/dist/cli/call.js CHANGED
@@ -5,13 +5,13 @@ import { patchPlanToMarkdown } from "../format.js";
5
5
  import { createFilePlanStore } from "../planStore.js";
6
6
  import { normalizeTranscript, parseCall, suggestCallDeal } from "../calls.js";
7
7
  import { classifyCall, rubricForCallType, rubricPresets, CALL_TYPES, CALL_TYPE_IDS } from "../callTypes.js";
8
- import { DEFAULT_RUBRIC, classifyCallLlm, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm } from "../llm.js";
8
+ import { DEFAULT_RUBRIC, classifyCallLlm, extractInsightsChunked, parseRubric, resolveLlmCredential, scoreCallLlm } from "../llm.js";
9
9
  import { option, readSnapshot, requireLlmCredential, saveRequested } from "./shared.js";
10
10
  import { startElapsedStatus } from "./ui.js";
11
11
  export async function callCommand(args) {
12
12
  const [subcommand, ...rest] = args;
13
13
  if (args.includes("--help") || args.includes("-h")) {
14
- console.log(`call parse --transcript <file> [--title t] [--source s] [--model m] [--deterministic] [--json|--ndjson] [--out <path>]
14
+ console.log(`call parse --transcript <file> [--title t] [--source s] [--model m] [--llm] [--heuristics] [--json|--ndjson] [--out <path>]
15
15
  call classify --transcript <file>|--call <parsed.json> [--llm] [--deterministic] [--json] [--list]
16
16
  call score --transcript <file>|--call <parsed.json> [--call-type <t>] [--rubric <rubric.json>] [--model m] [--json|--out <path>] [--list-rubrics]
17
17
  call link --attendees <a@x.com,...> | --domain <x.com> [source options] [--json]
@@ -21,9 +21,12 @@ classify picks the call type (deterministic signals; --llm for a model tiebreak)
21
21
  score auto-selects the type-specific rubric from that classification unless you
22
22
  pass --call-type or --rubric. Call types: ${CALL_TYPE_IDS.join(", ")}.
23
23
 
24
- parse/score default to LLM extraction (Anthropic or OpenAI key via env,
25
- \`login anthropic|openai\`, or a one-time prompt). parse --deterministic is the
26
- free keyword baseline and classify --deterministic needs no key.
24
+ parse defaults to chunked LLM extraction when a key resolves (ANTHROPIC_API_KEY/
25
+ OPENAI_API_KEY or \`login anthropic|openai\`) and falls back to the free
26
+ deterministic engine when none does. --heuristics (alias --deterministic)
27
+ forces the deterministic engine; --llm forces the LLM path (one-time key prompt
28
+ on a TTY). Model: --model, else env FSGTM_INSIGHTS_MODEL, else the provider
29
+ default. classify --deterministic needs no key.
27
30
  score always needs a key (scoring is LLM work).`);
28
31
  return;
29
32
  }
@@ -42,19 +45,30 @@ score always needs a key (scoring is LLM work).`);
42
45
  sourceSystem: source,
43
46
  capturedAt: new Date().toISOString(),
44
47
  };
45
- if (rest.includes("--deterministic")) {
48
+ if (rest.includes("--heuristics") || rest.includes("--deterministic")) {
49
+ return parseCall(raw, base);
50
+ }
51
+ // Engine parity with the hosted app: the chunked LLM pipeline runs by
52
+ // default whenever a key resolves (env or stored login), and the free
53
+ // deterministic engine runs when none does. --llm keeps the strict path
54
+ // for scripts (one-time TTY key prompt / actionable error, never silent).
55
+ if (!rest.includes("--llm") && !resolveLlmCredential()) {
56
+ console.error("No LLM key — using deterministic extraction. `fullstackgtm login anthropic|openai` or set ANTHROPIC_API_KEY/OPENAI_API_KEY for chunked LLM insights.");
46
57
  return parseCall(raw, base);
47
58
  }
48
- // LLM extraction is the default: bring-your-own-key (Anthropic or OpenAI).
49
59
  const credential = await requireLlmCredential();
50
60
  const normalized = normalizeTranscript(raw);
51
61
  // Elapsed-time spinner on interactive terminals while the model works.
52
62
  const wait = startElapsedStatus((elapsed) => `Extracting insights · ${credential.provider} · ${elapsed}`);
53
63
  let extraction;
54
64
  try {
55
- extraction = await extractInsightsLlm(normalized, {
65
+ // Chunked langextract-style pipeline: small per-chunk extraction calls
66
+ // beat one whole-transcript pass on long calls (single-shot truncates).
67
+ extraction = await extractInsightsChunked(normalized, {
56
68
  ...credential,
57
- model: option(rest, "--model") ?? undefined,
69
+ // --model wins, then the hosted app's env contract
70
+ // (FSGTM_INSIGHTS_MODEL), then the provider default downstream.
71
+ model: option(rest, "--model") ?? (process.env.FSGTM_INSIGHTS_MODEL || undefined),
58
72
  title: base.title,
59
73
  });
60
74
  }