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
@@ -0,0 +1,156 @@
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
+
8
+ import { buildStripeBackfillPlan, DEFAULT_BACKFILL_MATCH_PROPERTY } from "../backfill.ts";
9
+ import { fetchStripePaidInvoices } from "../connectors/stripe.ts";
10
+ import { getCredential } from "../credentials.ts";
11
+ import { patchPlanToMarkdown } from "../format.ts";
12
+ import { createFilePlanStore } from "../planStore.ts";
13
+ import { BACKFILL_STRIPE_STAGES, composeListeners, createProgressEmitter } from "../progress.ts";
14
+ import { progressReporter } from "../runReport.ts";
15
+ import { unknownSubcommandError } from "./suggest.ts";
16
+ import { option, readSnapshot, saveRequested } from "./shared.ts";
17
+ import { colorEnabled, createProgressRenderer, paint, stylizePlanMarkdown, table } from "./ui.ts";
18
+
19
+ /** STRIPE_SECRET_KEY env ∨ stored `login stripe` credential (same ladder as `--provider stripe`). */
20
+ function resolveStripeKey(): string {
21
+ const key = process.env.STRIPE_SECRET_KEY ?? getCredential("stripe")?.accessToken;
22
+ if (!key) {
23
+ throw new Error(
24
+ "No Stripe credentials. Run `echo \"$STRIPE_KEY\" | fullstackgtm login stripe` or set STRIPE_SECRET_KEY. " +
25
+ "A restricted key with read access to Invoices and Customers is enough. (`fullstackgtm doctor` shows credential status.)",
26
+ );
27
+ }
28
+ return key;
29
+ }
30
+
31
+ export async function backfillCommand(args: string[]) {
32
+ const [subcommand, ...rest] = args;
33
+ if (subcommand === "runs") {
34
+ const { backfillRunsCommand } = await import("./backfillRuns.ts");
35
+ await backfillRunsCommand(rest);
36
+ return;
37
+ }
38
+ if (subcommand !== "stripe") {
39
+ throw unknownSubcommandError("backfill", subcommand ?? "", ["stripe", "runs"]);
40
+ }
41
+
42
+ const since = option(rest, "--since");
43
+ const pipeline = option(rest, "--pipeline") ?? undefined;
44
+ const matchProperty = option(rest, "--match-property") ?? undefined;
45
+ const skipUnmatched = rest.includes("--skip-unmatched");
46
+ const save = saveRequested(rest);
47
+
48
+ // Shared progress: the BACKFILL_STRIPE_STAGES tick on an interactive stderr
49
+ // board (inert when piped) and heartbeat to a paired hosted app on long runs.
50
+ const renderer = createProgressRenderer(BACKFILL_STRIPE_STAGES);
51
+ const progress = createProgressEmitter(
52
+ composeListeners(renderer.listener, progressReporter()),
53
+ );
54
+ const stage = (name: (typeof BACKFILL_STRIPE_STAGES)[number]) =>
55
+ progress.stage(name, BACKFILL_STRIPE_STAGES.indexOf(name), BACKFILL_STRIPE_STAGES.length);
56
+ let result: ReturnType<typeof buildStripeBackfillPlan>;
57
+ try {
58
+ // 1. Revenue truth: paid invoices from Stripe (read-only).
59
+ stage("invoices");
60
+ const stripeKey = resolveStripeKey();
61
+ const invoices = await fetchStripePaidInvoices(
62
+ { getApiKey: () => stripeKey },
63
+ { ...(since ? { sinceIso: since } : {}), onPage: (fetched) => progress.items(fetched) },
64
+ );
65
+
66
+ // 2. The CRM as it stands (same source options as audit: --provider <crm>,
67
+ // --input <snapshot.json>, --demo, --sample). The pull's own stage/items
68
+ // events ride the same emitter (the board ignores the nested stages).
69
+ stage("snapshot");
70
+ const snapshot = await readSnapshot(rest, progress);
71
+
72
+ // 3. Dry-run plan: one closed-won create_record deal per invoice with no
73
+ // deal yet; unmatched customers also get a proposed account (unless
74
+ // --skip-unmatched restores report-only behavior).
75
+ stage("matching");
76
+ result = buildStripeBackfillPlan(invoices, snapshot, {
77
+ pipeline,
78
+ matchProperty,
79
+ source: "stripe:invoices",
80
+ createMissingAccounts: !skipUnmatched,
81
+ });
82
+ stage("plan");
83
+ progress.flush();
84
+ } finally {
85
+ renderer.done();
86
+ }
87
+
88
+ if (rest.includes("--json")) {
89
+ console.log(
90
+ JSON.stringify(
91
+ { plan: result.plan, counts: result.counts, unmatched: result.unmatched, proposedAccounts: result.proposedAccounts },
92
+ null,
93
+ 2,
94
+ ),
95
+ );
96
+ } else {
97
+ // TTY-only styling; piped output stays byte-identical plain text.
98
+ console.log(
99
+ stylizePlanMarkdown(patchPlanToMarkdown(result.plan), paint(colorEnabled(process.stdout))),
100
+ );
101
+ if (result.proposedAccounts.length > 0) {
102
+ console.log(
103
+ `\nProposed new accounts (${result.proposedAccounts.length}) — Stripe customers the CRM doesn't know; ` +
104
+ "created only if this plan is approved and applied:",
105
+ );
106
+ const accountRows = [
107
+ ["account", "domain", "invoices"],
108
+ ...result.proposedAccounts.map((entry) => [
109
+ entry.name,
110
+ entry.domain ?? "—",
111
+ String(entry.invoiceCount),
112
+ ]),
113
+ ];
114
+ for (const line of table(accountRows)) console.log(` ${line}`);
115
+ }
116
+ if (result.unmatched.length > 0) {
117
+ console.log(
118
+ `\nUnmatched invoices (${result.unmatched.length}) — ${
119
+ skipUnmatched
120
+ ? "customer matched no CRM account by domain or name; reported only (--skip-unmatched)"
121
+ : "customer has no usable name or company domain; reported only"
122
+ }:`,
123
+ );
124
+ const rows = [
125
+ ["invoice", "customer", "domain", "amount", "paid"],
126
+ ...result.unmatched.map((entry) => [
127
+ entry.invoiceNumber ?? entry.invoiceId,
128
+ entry.customerName ?? "—",
129
+ entry.customerDomain ?? "—",
130
+ `${entry.amountPaid}${entry.currency ? ` ${entry.currency}` : ""}`,
131
+ entry.paidAt ?? "—",
132
+ ]),
133
+ ];
134
+ for (const line of table(rows)) console.log(` ${line}`);
135
+ }
136
+ }
137
+
138
+ if (!save) {
139
+ console.error(
140
+ "\nDry run — nothing written. Re-run with --save to persist the plan, then approve + apply.",
141
+ );
142
+ return;
143
+ }
144
+ if (result.plan.operations.length === 0) {
145
+ console.error("No deals to backfill (every paid invoice already matched a deal or its customer was unmatched); nothing saved.");
146
+ return;
147
+ }
148
+ await createFilePlanStore().save(result.plan);
149
+ console.error(
150
+ `Saved plan ${result.plan.id} — ${result.counts.planned} closed-won deal(s)` +
151
+ `${result.counts.accountsProposed > 0 ? ` + ${result.counts.accountsProposed} new account(s)` : ""} from ${result.counts.invoices} paid invoice(s). ` +
152
+ `Review \`fullstackgtm plans show ${result.plan.id}\`, approve \`fullstackgtm plans approve ${result.plan.id} --operations all\`, ` +
153
+ `then \`fullstackgtm apply --plan-id ${result.plan.id} --provider hubspot\`. ` +
154
+ `Apply re-checks ${result.plan.operations.length ? `the ${matchProperty ?? DEFAULT_BACKFILL_MATCH_PROPERTY} dedupe key` : "resolve-first"} before creating.`,
155
+ );
156
+ }
@@ -0,0 +1,198 @@
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
+
14
+ import { getCredential } from "../credentials.ts";
15
+ import { readHealthTimeline } from "../health.ts";
16
+ import { createFilePlanStore } from "../planStore.ts";
17
+ import { activeProfile } from "../credentials.ts";
18
+ import { composeListeners, createProgressEmitter, RUNS_REPLAY_STAGES } from "../progress.ts";
19
+ import { progressReporter } from "../runReport.ts";
20
+ import { createProgressRenderer } from "./ui.ts";
21
+ import type { PatchPlanRun } from "../types.ts";
22
+
23
+ function fnv1a(value: string): string {
24
+ let hash = 0x811c9dc5;
25
+ for (let i = 0; i < value.length; i += 1) {
26
+ hash ^= value.charCodeAt(i);
27
+ hash = Math.imul(hash, 0x01000193);
28
+ }
29
+ return (hash >>> 0).toString(16).padStart(8, "0");
30
+ }
31
+
32
+ const RUN_STATUS: Record<PatchPlanRun["status"], "success" | "partial" | "error"> = {
33
+ applied: "success",
34
+ partial: "partial",
35
+ failed: "error",
36
+ rejected: "error",
37
+ };
38
+
39
+ export async function backfillRunsCommand(args: string[]) {
40
+ const dryRun = args.includes("--dry-run");
41
+ const json = args.includes("--json");
42
+
43
+ const broker = getCredential("broker");
44
+ if (!broker?.baseUrl || !broker.accessToken) {
45
+ throw new Error(
46
+ "Not paired with a hosted app. Run `fullstackgtm login --via <url>` first — backfill replays local history to the paired backend.",
47
+ );
48
+ }
49
+ const baseUrl = broker.baseUrl.replace(/\/+$/, "");
50
+ const headers = {
51
+ Authorization: `Bearer ${broker.accessToken}`,
52
+ "Content-Type": "application/json",
53
+ };
54
+ const profile = activeProfile();
55
+
56
+ // 1. Gather: every stored plan's runs[] + the health timeline.
57
+ const plans = await createFilePlanStore().list();
58
+ const runPayloads = plans.flatMap((stored) =>
59
+ stored.runs.map((run, index) => {
60
+ const startedAt = Date.parse(run.startedAt);
61
+ const finishedAt = Date.parse(run.finishedAt);
62
+ const tally = { applied: 0, skipped: 0, conflicts: 0, failed: 0 };
63
+ for (const result of run.results) {
64
+ if (result.status === "applied") tally.applied += 1;
65
+ else if (result.status === "skipped") tally.skipped += 1;
66
+ else if (result.status === "conflict") tally.conflicts += 1;
67
+ else tally.failed += 1;
68
+ }
69
+ return {
70
+ clientRunId: `bkf_${fnv1a(`${stored.plan.id}:${run.startedAt}:${index}`)}`,
71
+ command: `apply --plan-id ${stored.plan.id}`,
72
+ status: RUN_STATUS[run.status] ?? "error",
73
+ profile,
74
+ startedAt: Number.isFinite(startedAt) ? startedAt : Date.now(),
75
+ finishedAt: Number.isFinite(finishedAt) ? finishedAt : startedAt,
76
+ durationMs:
77
+ Number.isFinite(startedAt) && Number.isFinite(finishedAt) && finishedAt >= startedAt
78
+ ? finishedAt - startedAt
79
+ : 0,
80
+ counts: { ...tally, provider: run.provider, backfilled: true },
81
+ };
82
+ }),
83
+ );
84
+ const healthEntries = readHealthTimeline().map((entry) => ({
85
+ at: Date.parse(entry.at),
86
+ score: entry.score,
87
+ findings: entry.findings,
88
+ weightedFindings: entry.weightedFindings,
89
+ records: entry.records,
90
+ severityCounts: entry.severityCounts,
91
+ byObjectType: entry.byObjectType,
92
+ byRule: entry.byRule,
93
+ })).filter((entry) => Number.isFinite(entry.at));
94
+
95
+ if (dryRun) {
96
+ const summary = {
97
+ ok: true,
98
+ dryRun: true,
99
+ plans: plans.length,
100
+ runs: runPayloads.length,
101
+ healthEntries: healthEntries.length,
102
+ target: baseUrl,
103
+ };
104
+ if (json) console.log(JSON.stringify(summary, null, 2));
105
+ else {
106
+ console.log(
107
+ `Dry run — would replay ${runPayloads.length} plan run(s) (from ${plans.length} stored plan(s)) ` +
108
+ `and ${healthEntries.length} health reading(s) to ${baseUrl}. Nothing sent.`,
109
+ );
110
+ }
111
+ return;
112
+ }
113
+
114
+ // Shared progress: replay ticks on an interactive stderr board (inert when
115
+ // piped) and heartbeats to the paired app — this command is paired by
116
+ // definition, so a big replay shows live on the dashboard as it lands.
117
+ const renderer = createProgressRenderer(RUNS_REPLAY_STAGES);
118
+ const progress = createProgressEmitter(
119
+ composeListeners(renderer.listener, progressReporter()),
120
+ );
121
+
122
+ // 2. Replay runs (sequential; server dedupes on clientRunId).
123
+ let reported = 0;
124
+ let failed = 0;
125
+ const errors: string[] = [];
126
+ let healthInserted = 0;
127
+ let healthDeduped = 0;
128
+ try {
129
+ progress.stage(RUNS_REPLAY_STAGES[0], 0, RUNS_REPLAY_STAGES.length);
130
+ for (const payload of runPayloads) {
131
+ try {
132
+ const response = await fetch(`${baseUrl}/api/cli/run`, {
133
+ method: "POST",
134
+ headers,
135
+ body: JSON.stringify(payload),
136
+ signal: AbortSignal.timeout(10_000),
137
+ });
138
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
139
+ reported += 1;
140
+ } catch (error) {
141
+ failed += 1;
142
+ if (errors.length < 5) {
143
+ errors.push(`${payload.command}: ${error instanceof Error ? error.message : String(error)}`);
144
+ }
145
+ }
146
+ progress.items(reported + failed, runPayloads.length);
147
+ }
148
+
149
+ // 3. Replay health history in chunks (server dedupes on timestamp).
150
+ progress.stage(RUNS_REPLAY_STAGES[1], 1, RUNS_REPLAY_STAGES.length);
151
+ for (let i = 0; i < healthEntries.length; i += 200) {
152
+ const chunk = healthEntries.slice(i, i + 200);
153
+ try {
154
+ const response = await fetch(`${baseUrl}/api/cli/health`, {
155
+ method: "POST",
156
+ headers,
157
+ body: JSON.stringify({ entries: chunk }),
158
+ signal: AbortSignal.timeout(15_000),
159
+ });
160
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
161
+ const result = (await response.json()) as { inserted?: number; deduped?: number };
162
+ healthInserted += result.inserted ?? 0;
163
+ healthDeduped += result.deduped ?? 0;
164
+ } catch (error) {
165
+ failed += 1;
166
+ if (errors.length < 5) {
167
+ errors.push(`health chunk: ${error instanceof Error ? error.message : String(error)}`);
168
+ }
169
+ }
170
+ progress.items(Math.min(i + 200, healthEntries.length), healthEntries.length);
171
+ }
172
+ progress.flush();
173
+ } finally {
174
+ renderer.done();
175
+ }
176
+
177
+ const summary = {
178
+ ok: failed === 0,
179
+ runsReported: reported,
180
+ runsFailed: failed,
181
+ healthInserted,
182
+ healthDeduped,
183
+ target: baseUrl,
184
+ ...(errors.length > 0 ? { errors } : {}),
185
+ };
186
+ if (json) {
187
+ console.log(JSON.stringify(summary, null, 2));
188
+ } else {
189
+ console.log(
190
+ `Replayed ${reported}/${runPayloads.length} plan run(s) and ${healthInserted} health reading(s) ` +
191
+ `to ${baseUrl} (${healthDeduped} readings already there — dedupe is server-side, re-running is safe).`,
192
+ );
193
+ for (const line of errors) console.error(` error: ${line}`);
194
+ }
195
+ if (failed > 0) {
196
+ process.exitCode = 1;
197
+ }
198
+ }
package/src/cli/call.ts CHANGED
@@ -6,7 +6,7 @@ import { patchPlanToMarkdown } from "../format.ts";
6
6
  import { createFilePlanStore } from "../planStore.ts";
7
7
  import { normalizeTranscript, parseCall, suggestCallDeal, type ExtractedCallInsight, type ParsedCall } from "../calls.ts";
8
8
  import { classifyCall, rubricForCallType, rubricPresets, CALL_TYPES, CALL_TYPE_IDS, type CallType } from "../callTypes.ts";
9
- import { DEFAULT_RUBRIC, classifyCallLlm, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, type CallScorecard, type Rubric } from "../llm.ts";
9
+ import { DEFAULT_RUBRIC, classifyCallLlm, extractInsightsChunked, parseRubric, resolveLlmCredential, scoreCallLlm, type CallScorecard, type Rubric } from "../llm.ts";
10
10
  import type { PatchPlan } from "../types.ts";
11
11
  import { option, readSnapshot, requireLlmCredential, saveRequested } from "./shared.ts";
12
12
  import { startElapsedStatus } from "./ui.ts";
@@ -15,7 +15,7 @@ import { startElapsedStatus } from "./ui.ts";
15
15
  export async function callCommand(args: string[]) {
16
16
  const [subcommand, ...rest] = args;
17
17
  if (args.includes("--help") || args.includes("-h")) {
18
- console.log(`call parse --transcript <file> [--title t] [--source s] [--model m] [--deterministic] [--json|--ndjson] [--out <path>]
18
+ console.log(`call parse --transcript <file> [--title t] [--source s] [--model m] [--llm] [--heuristics] [--json|--ndjson] [--out <path>]
19
19
  call classify --transcript <file>|--call <parsed.json> [--llm] [--deterministic] [--json] [--list]
20
20
  call score --transcript <file>|--call <parsed.json> [--call-type <t>] [--rubric <rubric.json>] [--model m] [--json|--out <path>] [--list-rubrics]
21
21
  call link --attendees <a@x.com,...> | --domain <x.com> [source options] [--json]
@@ -25,9 +25,12 @@ classify picks the call type (deterministic signals; --llm for a model tiebreak)
25
25
  score auto-selects the type-specific rubric from that classification unless you
26
26
  pass --call-type or --rubric. Call types: ${CALL_TYPE_IDS.join(", ")}.
27
27
 
28
- parse/score default to LLM extraction (Anthropic or OpenAI key via env,
29
- \`login anthropic|openai\`, or a one-time prompt). parse --deterministic is the
30
- free keyword baseline and classify --deterministic needs no key.
28
+ parse defaults to chunked LLM extraction when a key resolves (ANTHROPIC_API_KEY/
29
+ OPENAI_API_KEY or \`login anthropic|openai\`) and falls back to the free
30
+ deterministic engine when none does. --heuristics (alias --deterministic)
31
+ forces the deterministic engine; --llm forces the LLM path (one-time key prompt
32
+ on a TTY). Model: --model, else env FSGTM_INSIGHTS_MODEL, else the provider
33
+ default. classify --deterministic needs no key.
31
34
  score always needs a key (scoring is LLM work).`);
32
35
  return;
33
36
  }
@@ -46,21 +49,34 @@ score always needs a key (scoring is LLM work).`);
46
49
  sourceSystem: source,
47
50
  capturedAt: new Date().toISOString(),
48
51
  };
49
- if (rest.includes("--deterministic")) {
52
+ if (rest.includes("--heuristics") || rest.includes("--deterministic")) {
53
+ return parseCall(raw, base);
54
+ }
55
+ // Engine parity with the hosted app: the chunked LLM pipeline runs by
56
+ // default whenever a key resolves (env or stored login), and the free
57
+ // deterministic engine runs when none does. --llm keeps the strict path
58
+ // for scripts (one-time TTY key prompt / actionable error, never silent).
59
+ if (!rest.includes("--llm") && !resolveLlmCredential()) {
60
+ console.error(
61
+ "No LLM key — using deterministic extraction. `fullstackgtm login anthropic|openai` or set ANTHROPIC_API_KEY/OPENAI_API_KEY for chunked LLM insights.",
62
+ );
50
63
  return parseCall(raw, base);
51
64
  }
52
- // LLM extraction is the default: bring-your-own-key (Anthropic or OpenAI).
53
65
  const credential = await requireLlmCredential();
54
66
  const normalized = normalizeTranscript(raw);
55
67
  // Elapsed-time spinner on interactive terminals while the model works.
56
68
  const wait = startElapsedStatus(
57
69
  (elapsed) => `Extracting insights · ${credential.provider} · ${elapsed}`,
58
70
  );
59
- let extraction: Awaited<ReturnType<typeof extractInsightsLlm>>;
71
+ let extraction: Awaited<ReturnType<typeof extractInsightsChunked>>;
60
72
  try {
61
- extraction = await extractInsightsLlm(normalized, {
73
+ // Chunked langextract-style pipeline: small per-chunk extraction calls
74
+ // beat one whole-transcript pass on long calls (single-shot truncates).
75
+ extraction = await extractInsightsChunked(normalized, {
62
76
  ...credential,
63
- model: option(rest, "--model") ?? undefined,
77
+ // --model wins, then the hosted app's env contract
78
+ // (FSGTM_INSIGHTS_MODEL), then the provider default downstream.
79
+ model: option(rest, "--model") ?? (process.env.FSGTM_INSIGHTS_MODEL || undefined),
64
80
  title: base.title,
65
81
  });
66
82
  } finally {
package/src/cli/enrich.ts CHANGED
@@ -9,7 +9,8 @@ import { buildAcquirePlan, buildEnrichPlan, createFileEnrichRunStore, DEFAULT_ST
9
9
  import { loadMeter, remaining, type AcquireRemaining } from "../acquireMeter.ts";
10
10
  import { crmContactKeys, fetchExploriumProspects, fetchPipe0CrustdataProspects, partitionFreshProspects, pipe0ResolveCompanyDomains, pipe0ResolveWorkEmails, prospectIdentityKeys, type Prospect } from "../connectors/prospectSources.ts";
11
11
  import { loadSeen, recordSeen } from "../acquireSeen.ts";
12
- import { reportCounts, reportEvent } from "../runReport.ts";
12
+ import { progressReporter, reportCounts, reportEvent } from "../runReport.ts";
13
+ import { ACQUIRE_STAGES, composeListeners, createProgressEmitter } from "../progress.ts";
13
14
  import { createLinkedInProvider, discoverLinkedInProspects } from "../acquireLinkedIn.ts";
14
15
  import { fitThreshold, icpToCrustdataFilters, icpToExploriumFilters, scoreProspectAgainstIcp, type Icp } from "../icp.ts";
15
16
  import { apolloPullKeysForAppend, apolloPullKeysForRefresh, createApolloClient, pullApolloRecords, type ApolloPullKey } from "../enrichApollo.ts";
@@ -18,7 +19,7 @@ import { isSpoolPath, readSpoolPath } from "../spoolFiles.ts";
18
19
  import { isOptionValue, loadIcp, numericOption, option, readSnapshot, saveRequested } from "./shared.ts";
19
20
  import { providerKey } from "./tam.ts";
20
21
  import { unknownSubcommandError } from "./suggest.ts";
21
- import { colorEnabled, createStatusLine, formatBar, formatDuration, paint, type Paint } from "./ui.ts";
22
+ import { colorEnabled, createProgressRenderer, createStatusLine, formatBar, formatDuration, paint, type Paint } from "./ui.ts";
22
23
  import type { AcquireBudget } from "../acquireMeter.ts";
23
24
 
24
25
 
@@ -224,14 +225,36 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
224
225
  }
225
226
  }
226
227
 
227
- const result = buildAcquirePlan({
228
- config,
229
- source,
230
- snapshot,
231
- records,
232
- runLabel: option(rest, "--run-label") ?? `acquire-${source}-${today}`,
233
- maxRecords: cap,
234
- });
228
+ // Progress: candidate rows tick on an interactive stderr board (inert when
229
+ // piped) and, via the composed reporter, heartbeat to a paired hosted app.
230
+ // The meter reading (creates vs headroom + budget burn) rides the same
231
+ // emitter, feeding the dashboard's gauge without printing anything new.
232
+ const renderer = createProgressRenderer(ACQUIRE_STAGES);
233
+ const acquireProgress = createProgressEmitter(
234
+ composeListeners(renderer.listener, progressReporter()),
235
+ );
236
+ let result: ReturnType<typeof buildAcquirePlan>;
237
+ try {
238
+ acquireProgress.stage(ACQUIRE_STAGES[0], 0, ACQUIRE_STAGES.length);
239
+ if (config.acquire.budget?.records?.perDay && headroom.records.day !== null) {
240
+ acquireProgress.meter(
241
+ config.acquire.budget.records.perDay - headroom.records.day,
242
+ config.acquire.budget.records.perDay,
243
+ "records/day",
244
+ );
245
+ }
246
+ result = buildAcquirePlan({
247
+ config,
248
+ source,
249
+ snapshot,
250
+ records,
251
+ runLabel: option(rest, "--run-label") ?? `acquire-${source}-${today}`,
252
+ maxRecords: cap,
253
+ progress: acquireProgress,
254
+ });
255
+ } finally {
256
+ renderer.done();
257
+ }
235
258
 
236
259
  if (result.counts.unassigned > 0 && result.counts.created > 0) {
237
260
  console.error(
package/src/cli/fix.ts CHANGED
@@ -12,9 +12,11 @@ import { buildBulkUpdatePlan } from "../bulkUpdate.ts";
12
12
  import { buildDedupePlan, type DedupeOptions } from "../dedupe.ts";
13
13
  import { buildReassignPlans, type ReassignObjectType } from "../reassign.ts";
14
14
  import { suggestValues } from "../suggest.ts";
15
+ import { APPLY_STAGES, composeListeners, createProgressEmitter } from "../progress.ts";
16
+ import { progressReporter } from "../runReport.ts";
15
17
  import type { PatchPlan, PatchPlanRun } from "../types.ts";
16
18
  import { confirmRequested, connectorFor, isOptionValue, numericOption, option, readSnapshot, repeatedOption, saveRequested } from "./shared.ts";
17
- import { box, colorEnabled, createStatusLine, paint } from "./ui.ts";
19
+ import { box, colorEnabled, createProgressRenderer, paint } from "./ui.ts";
18
20
 
19
21
 
20
22
  /**
@@ -281,22 +283,21 @@ export async function fixCommand(args: string[]) {
281
283
  return;
282
284
  }
283
285
  const connector = await connectorFor(provider, args);
284
- // Live safety ticker on interactive terminals (stderr; inert otherwise).
285
- const ticker = createStatusLine();
286
+ // Live apply board on interactive terminals (stderr; inert otherwise); the
287
+ // same emitter streams heartbeats to the paired hosted app on long runs.
288
+ const renderer = createProgressRenderer(APPLY_STAGES);
289
+ const progress = createProgressEmitter(
290
+ composeListeners(renderer.listener, progressReporter()),
291
+ );
286
292
  let run: PatchPlanRun;
287
293
  try {
288
294
  run = await applyPatchPlan(connector, plan, {
289
295
  approvedOperationIds: approvedIds,
290
296
  valueOverrides: overrides,
291
- onOperation: ticker.active
292
- ? (progress) =>
293
- ticker.set(
294
- `Applying ${progress.completed}/${progress.total} · ✓ ${progress.applied} applied · ${progress.failed} failed · ${progress.conflicts} conflict${progress.conflicts === 1 ? "" : "s"}`,
295
- )
296
- : undefined,
297
+ progress,
297
298
  });
298
299
  } finally {
299
- ticker.done();
300
+ renderer.done();
300
301
  }
301
302
  await store.recordRun(plan.id, run);
302
303
  const counts: Record<string, number> = { applied: 0, conflict: 0, skipped: 0, failed: 0 };