fullstackgtm 0.13.1 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,61 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and the project adheres to [Semantic Versioning](https://semver.org/).
6
6
  The path to 1.0 is planned in [docs/roadmap-to-1.0.md](./docs/roadmap-to-1.0.md).
7
7
 
8
+ ## [0.14.1] — 2026-06-11
9
+
10
+ Fixes from the 0.14.0 journey verification (4 agents, 21 checks).
11
+
12
+ ### Fixed
13
+
14
+ - **`call score` no longer suggests a flag it rejects**: the keyless error
15
+ said "pass --deterministic" but score has no non-LLM mode — its error now
16
+ says exactly that. Rubric files are also validated *before* the
17
+ credential check (keyless users see rubric errors), and a non-JSON rubric
18
+ names the file and the expected shape instead of a raw parse error.
19
+ - **NDJSON rows carry `extractor`** — LLM and deterministic rows were
20
+ per-row indistinguishable in warehouse loads, contradicting the
21
+ provenance claim. (Docs wording was right at the parse-result and
22
+ evidence level; now it is true per-row too.)
23
+ - **`call … --help` prints help** instead of being shadowed by argument
24
+ and credential checks.
25
+ - `login anthropic|openai` gets the same explicit argv-secret rejection
26
+ (`--token`/`--key`/`--api-key`) as the CRM providers.
27
+
28
+ ## [0.14.0] — 2026-06-11
29
+
30
+ LLM-powered call intelligence, bring-your-own-key. The dry-run replications
31
+ of two real client pipelines proved the deterministic baseline is an
32
+ analytics floor, not what call workflows actually run on — so the no-LLM
33
+ guardrail is consciously retired for the `call` commands (and only there).
34
+
35
+ ### Added
36
+
37
+ - **LLM extraction is the default for `call parse`** — constrained tool
38
+ calls against Anthropic (default claude-haiku-4-5) or OpenAI (default
39
+ gpt-4o-mini) via raw fetch, no SDK dependency. Mandatory verbatim-quote
40
+ evidence; next steps carry owner/deadline/commitment; every insight is
41
+ provenance-marked (`extractor: "llm:<provider>:<model>"` vs
42
+ `"deterministic"`). `--deterministic` keeps the free keyword baseline.
43
+ - **First-touch key onboarding**: the first LLM command without a key (TTY)
44
+ explains, captures the key via muted prompt/stdin, auto-detects the
45
+ provider from the prefix, validates it against the provider's API, and
46
+ stores it 0600 in the credential store. Non-interactive contexts get an
47
+ actionable error, never a prompt. Also: `fullstackgtm login anthropic|openai`
48
+ and `logout`, and a `doctor` row showing LLM key status.
49
+ - **`fullstackgtm call score`**: rubric-driven coaching scorecards —
50
+ built-in five-dimension rubric or `--rubric rubric.json`
51
+ ({ scale, dimensions: [{ name, weight, rubric }] }); per-dimension
52
+ verbatim evidence + coaching notes; the weighted overall is computed
53
+ deterministically client-side. Markdown table or `--json`.
54
+ - **MCP `fullstackgtm_call_parse` gains `extractor: auto|llm|deterministic`**
55
+ (auto uses a key when the server has one, else the baseline) and `--model`.
56
+
57
+ ### Security
58
+
59
+ - LLM keys follow the same discipline as CRM secrets: stdin/prompt only,
60
+ never argv; 0600 store; provider error bodies never echoed (status line
61
+ only); keys go directly to api.anthropic.com / api.openai.com.
62
+
8
63
  ## [0.13.1] — 2026-06-11
9
64
 
10
65
  ### Fixed
@@ -49,6 +49,12 @@ In an agent sandbox, prefer rung 1 or 2. Never echo tokens into argv —
49
49
  environments — login flows then print verification URLs instead of opening
50
50
  the OS browser.
51
51
 
52
+ LLM calls (`call parse`, `call score`): set `ANTHROPIC_API_KEY` or
53
+ `OPENAI_API_KEY` in the environment, or have the human run
54
+ `echo "$KEY" | fullstackgtm login anthropic` once. Without a key, use
55
+ `call parse --deterministic` (free keyword baseline, no prompt). In
56
+ non-interactive contexts the CLI never prompts — it fails with this guidance.
57
+
52
58
  Provider prerequisites (what the human must create, and which scopes) are in
53
59
  the README's **"Connect your CRM"** section: HubSpot needs a private app with
54
60
  four `crm.objects.*.read` scopes (plus write scopes only for `apply`);
package/README.md CHANGED
@@ -49,22 +49,30 @@ Nothing is ever written without an explicit `--approve`. Operations whose value
49
49
 
50
50
  ## Call workflows: calls become governed evidence
51
51
 
52
- Calls are where pipeline truth lives. `call parse` normalizes any transcript dialect — `Speaker: text` lines (Fathom, Gong exports), `[Speaker]:` labels, or raw Granola utterance JSON — into canonical segments, deterministic keyword-derived insights (next steps, objections, pricing, risks, competitor mentions…), and `GtmEvidence` records, all LLM-free and byte-stable per transcript. `call link` answers "which deal was this call about" from attendee domains (account domain or contact emails → open deals, most recent activity first, with confidence + reason). `call plan` turns next-step insights into the same governed plan lifecycle as everything else.
52
+ Calls are where pipeline truth lives. `call parse` normalizes any transcript dialect — `Speaker: text` lines (Fathom, Gong exports), `[Speaker]:` labels, or raw Granola utterance JSON — into canonical segments, insights, and `GtmEvidence` records.
53
+
54
+ **Extraction is LLM-powered by default, with your own key.** The first time you run `call parse` or `call score`, the CLI asks for an Anthropic or OpenAI API key (auto-detected from the prefix), validates it against the provider, and stores it in the 0600 credential store — same treatment as CRM logins. Or skip the prompt: set `ANTHROPIC_API_KEY`/`OPENAI_API_KEY`, or `echo "$KEY" | fullstackgtm login anthropic` (or `openai`). The key talks directly to your provider — raw fetch, no SDK, no middleman. `--model` overrides the defaults (claude-haiku-4-5 / gpt-4o-mini). LLM insights carry verbatim-quote evidence and, for next steps, owner/deadline/commitment. Pass `--deterministic` for the free, instant, byte-stable keyword baseline (no key needed — right for CI and warehouse bulk loads). Every insight is provenance-marked (`extractor: "llm:anthropic:…"` vs `"deterministic"`).
55
+
56
+ **`call score`** rates the call against a coaching rubric — five built-in dimensions (discovery, next steps, stakeholders, value, objections) or your own via `--rubric rubric.json` (`{ scale, dimensions: [{ name, weight, rubric }] }`); the weighted overall is computed deterministically client-side, every dimension score is evidence-quoted, and the rubric file is where your client-specific coaching framework lives.
57
+
58
+ `call link` answers "which deal was this call about" from attendee domains (account domain or contact emails → open deals, most recent activity first, with confidence + reason). `call plan` turns next-step insights into the same governed plan lifecycle as everything else.
53
59
 
54
60
  ```bash
55
- # Coaching/score pipeline (Slack + CRM): parse → link → govern the writeback
56
- fullstackgtm call parse --transcript call.txt --title "Acme disco" --out parsed.json
57
- fullstackgtm call link --attendees jane@acme.com --provider hubspot # deal id + reason
61
+ # Coaching pipeline (Slack + CRM): parse → score → link → govern the writeback
62
+ fullstackgtm call parse --transcript call.txt --title "Acme disco" --out parsed.json # LLM extraction
63
+ fullstackgtm call score --call parsed.json --rubric team-rubric.json # evidence-quoted scorecard
64
+ fullstackgtm call link --attendees jane@acme.com --provider hubspot # → deal id + reason
58
65
  fullstackgtm call plan --call parsed.json --deal 123 --provider hubspot --save
59
66
  # review → plans approve → apply: deal.next_step + follow-up tasks, compare-and-set protected
60
- # (LLM scoring/extraction stays in your own pipeline; pipe parsed.json into it and into Slack)
67
+ # (pipe the parse/score JSON into Slack/Notion however you like)
61
68
 
62
69
  # Analytics pipeline (warehouse): one flat NDJSON row per insight
63
- for t in transcripts/*; do fullstackgtm call parse --transcript "$t" --ndjson; done > insights.ndjson
70
+ for t in transcripts/*; do fullstackgtm call parse --transcript "$t" --ndjson --deterministic; done > insights.ndjson
71
+ # free keyword baseline for bulk loads; drop --deterministic for LLM-quality rows
64
72
  # COPY into your warehouse (stable call/evidence ids make reloads idempotent)
65
73
  ```
66
74
 
67
- The deliberate boundary: parsing, linking, and governed writeback are deterministic CLI primitives; LLM scoring, rubric extraction, and Slack/Notion/warehouse sinks are *your* pipeline, composed around the JSON.
75
+ The boundary that remains: Slack/Notion/warehouse sinks are *your* pipeline, composed around the JSON — and your rubrics and keys stay yours.
68
76
 
69
77
  ## From findings to fixes: the suggest chain
70
78
 
package/dist/calls.d.ts CHANGED
@@ -36,6 +36,8 @@ export type ParsedCall = {
36
36
  id: string;
37
37
  title?: string;
38
38
  sourceSystem: GtmEvidenceSourceSystem;
39
+ /** What produced the insights: "deterministic" or "llm:<provider>:<model>". */
40
+ extractor: string;
39
41
  segments: ParsedTranscriptSegment[];
40
42
  insights: ExtractedCallInsight[];
41
43
  evidence: GtmEvidence[];
@@ -52,6 +54,9 @@ export declare function parseCall(raw: string, options?: {
52
54
  title?: string;
53
55
  sourceSystem?: GtmEvidenceSourceSystem;
54
56
  capturedAt?: string;
57
+ /** Pre-extracted insights (e.g. LLM); skips the deterministic extractor. */
58
+ insights?: ExtractedCallInsight[];
59
+ extractor?: string;
55
60
  }): ParsedCall;
56
61
  export type CallDealSuggestion = {
57
62
  dealId: string | null;
package/dist/calls.js CHANGED
@@ -246,7 +246,7 @@ export function normalizeTranscript(raw) {
246
246
  export function parseCall(raw, options = {}) {
247
247
  const normalized = normalizeTranscript(raw);
248
248
  const segments = parseTranscript(normalized);
249
- const insights = extractCallInsights(normalized, segments);
249
+ const insights = options.insights ?? extractCallInsights(normalized, segments);
250
250
  const sourceSystem = options.sourceSystem ?? "manual";
251
251
  const id = `call_${callHash(normalized)}`;
252
252
  const evidence = insights.map((insight, index) => ({
@@ -258,6 +258,7 @@ export function parseCall(raw, options = {}) {
258
258
  text: insight.evidence,
259
259
  capturedAt: options.capturedAt,
260
260
  metadata: {
261
+ extractor: options.extractor ?? "deterministic",
261
262
  insightType: insight.type,
262
263
  speaker: insight.speaker,
263
264
  confidence: insight.confidence,
@@ -269,6 +270,7 @@ export function parseCall(raw, options = {}) {
269
270
  id,
270
271
  title: options.title,
271
272
  sourceSystem,
273
+ extractor: options.extractor ?? "deterministic",
272
274
  segments,
273
275
  insights,
274
276
  evidence,
package/dist/cli.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type LlmProvider } from "./llm.ts";
1
2
  type ProviderDoctorStatus = {
2
3
  source: "env" | "stored" | "broker" | "none";
3
4
  detail: string;
@@ -29,6 +30,17 @@ export declare function doctorReport(env?: Record<string, string | undefined>):
29
30
  paired: boolean;
30
31
  baseUrl?: undefined;
31
32
  };
33
+ llm: {
34
+ configured: boolean;
35
+ provider: LlmProvider;
36
+ source: "env" | "stored";
37
+ detail?: undefined;
38
+ } | {
39
+ configured: boolean;
40
+ detail: string;
41
+ provider?: undefined;
42
+ source?: undefined;
43
+ };
32
44
  mcp: {
33
45
  peersInstalled: boolean;
34
46
  missing: string[];
package/dist/cli.js CHANGED
@@ -17,7 +17,8 @@ import { createFilePlanStore } from "./planStore.js";
17
17
  import { auditReportToHtml, auditReportToMarkdown } from "./report.js";
18
18
  import { builtinAuditRules } from "./rules.js";
19
19
  import { sampleSnapshot } from "./sampleData.js";
20
- import { parseCall, suggestCallDeal } from "./calls.js";
20
+ import { normalizeTranscript, parseCall, suggestCallDeal } from "./calls.js";
21
+ import { DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, } from "./llm.js";
21
22
  import { suggestValues } from "./suggest.js";
22
23
  function usage() {
23
24
  return `FullStackGTM — audit GTM data across providers, propose reviewable patch plans,
@@ -30,7 +31,7 @@ Usage:
30
31
  fullstackgtm login salesforce --device --client-id <consumer key> [--login-url <url>]
31
32
  fullstackgtm login salesforce --instance-url <url> [--no-validate]
32
33
  fullstackgtm login stripe [--no-validate]
33
- fullstackgtm logout <hubspot|salesforce|stripe|broker>
34
+ fullstackgtm login anthropic | openai store an LLM API key for call parse/score\n fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|broker>
34
35
 
35
36
  Secrets (tokens, client secrets) are NEVER passed as flags — they leak via
36
37
  the process list and shell history. Pipe them on stdin or enter them at the
@@ -41,11 +42,15 @@ Usage:
41
42
  fullstackgtm report [source options] [audit options] [report options]
42
43
  fullstackgtm diff --before <a.json> --after <b.json> [--json] [--fail-on-new-findings]
43
44
  fullstackgtm merge --input <a.json> --input <b.json> [...] --out <merged.json> [--json]
44
- fullstackgtm call parse --transcript <file> [--title t] [--source fathom|granola|...] [--json|--ndjson] [--out <path>]
45
+ fullstackgtm call parse --transcript <file> [--title t] [--source fathom|granola|...] [--model m] [--deterministic] [--json|--ndjson] [--out <path>]
46
+ fullstackgtm call score --transcript <file>|--call <parsed.json> [--rubric <rubric.json>] [--model m] [--json|--out <path>]
45
47
  fullstackgtm call link --attendees <a@x.com,...> | --domain <x.com> [source options] [--json]
46
48
  fullstackgtm call plan --transcript <file>|--call <parsed.json> --deal <id> [source options] [--save|--json]
47
- calls become evidence: parse dialects (Speaker:/[Me]/Granola JSON),
48
- link to the right deal, and propose governed next-step writes
49
+ calls become evidence: LLM extraction by default (bring your own
50
+ Anthropic or OpenAI key captured once on first use, or
51
+ ANTHROPIC_API_KEY/OPENAI_API_KEY, or \`login anthropic|openai\`);
52
+ --deterministic uses the free keyword baseline. Then link the call
53
+ to its deal and propose governed next-step writes.
49
54
  fullstackgtm suggest --plan-id <id> | --plan <path> [source options] [--json] [--out <path>]
50
55
  derive values for requires_human_* placeholders
51
56
  from snapshot evidence, with confidence + reasons
@@ -413,7 +418,18 @@ function parseValueOverrides(args) {
413
418
  }
414
419
  async function callCommand(args) {
415
420
  const [subcommand, ...rest] = args;
416
- const loadParsedCall = () => {
421
+ if (args.includes("--help") || args.includes("-h")) {
422
+ console.log(`call parse --transcript <file> [--title t] [--source s] [--model m] [--deterministic] [--json|--ndjson] [--out <path>]
423
+ call score --transcript <file>|--call <parsed.json> [--rubric <rubric.json>] [--model m] [--json|--out <path>]
424
+ call link --attendees <a@x.com,...> | --domain <x.com> [source options] [--json]
425
+ call plan --transcript <file>|--call <parsed.json> --deal <id> [source options] [--save|--json]
426
+
427
+ parse/score default to LLM extraction (Anthropic or OpenAI key via env,
428
+ \`login anthropic|openai\`, or a one-time prompt). parse --deterministic is
429
+ the free keyword baseline; score always needs a key (scoring is LLM work).`);
430
+ return;
431
+ }
432
+ const loadParsedCall = async () => {
417
433
  const callPath = option(rest, "--call");
418
434
  if (callPath) {
419
435
  return JSON.parse(readFileSync(resolve(process.cwd(), callPath), "utf8"));
@@ -423,14 +439,30 @@ async function callCommand(args) {
423
439
  throw new Error(`call ${subcommand} requires --transcript <file> or --call <parsed.json>`);
424
440
  const raw = readFileSync(resolve(process.cwd(), transcriptPath), "utf8");
425
441
  const source = option(rest, "--source");
426
- return parseCall(raw, {
442
+ const base = {
427
443
  title: option(rest, "--title") ?? undefined,
428
444
  sourceSystem: source,
429
445
  capturedAt: new Date().toISOString(),
446
+ };
447
+ if (rest.includes("--deterministic")) {
448
+ return parseCall(raw, base);
449
+ }
450
+ // LLM extraction is the default: bring-your-own-key (Anthropic or OpenAI).
451
+ const credential = await requireLlmCredential();
452
+ const normalized = normalizeTranscript(raw);
453
+ const { insights, model } = await extractInsightsLlm(normalized, {
454
+ ...credential,
455
+ model: option(rest, "--model") ?? undefined,
456
+ title: base.title,
457
+ });
458
+ return parseCall(raw, {
459
+ ...base,
460
+ insights,
461
+ extractor: `llm:${credential.provider}:${model}`,
430
462
  });
431
463
  };
432
464
  if (subcommand === "parse") {
433
- const parsed = loadParsedCall();
465
+ const parsed = await loadParsedCall();
434
466
  const outPath = option(rest, "--out");
435
467
  if (outPath)
436
468
  writeFileSync(resolve(process.cwd(), outPath), `${JSON.stringify(parsed, null, 2)}\n`);
@@ -441,6 +473,7 @@ async function callCommand(args) {
441
473
  call_id: parsed.id,
442
474
  call_title: parsed.title ?? null,
443
475
  source_system: parsed.sourceSystem,
476
+ extractor: parsed.extractor,
444
477
  type: insight.type,
445
478
  title: insight.title,
446
479
  text: insight.text,
@@ -490,7 +523,7 @@ async function callCommand(args) {
490
523
  const dealId = option(rest, "--deal");
491
524
  if (!dealId)
492
525
  throw new Error("call plan requires --deal <dealId> (use `call link` to find it)");
493
- const parsed = loadParsedCall();
526
+ const parsed = await loadParsedCall();
494
527
  const snapshot = await readSnapshot(rest);
495
528
  const deal = snapshot.deals.find((row) => row.id === dealId);
496
529
  if (!deal)
@@ -512,7 +545,105 @@ async function callCommand(args) {
512
545
  console.log(rest.includes("--json") ? JSON.stringify(plan, null, 2) : patchPlanToMarkdown(plan));
513
546
  return;
514
547
  }
515
- throw new Error(`call supports: parse, link, plan (got ${subcommand ?? "nothing"})`);
548
+ if (subcommand === "score") {
549
+ // Rubric problems surface before any credential or API work.
550
+ const rubricPath = option(rest, "--rubric");
551
+ let rubric = DEFAULT_RUBRIC;
552
+ if (rubricPath) {
553
+ const rubricRaw = readFileSync(resolve(process.cwd(), rubricPath), "utf8");
554
+ try {
555
+ rubric = parseRubric(rubricRaw);
556
+ }
557
+ catch (error) {
558
+ throw new Error(`${rubricPath} is not a valid rubric: ${error instanceof Error ? error.message : String(error)} Expected JSON like { "scale": 5, "dimensions": [{ "name": "...", "weight": 1, "rubric": "..." }] }.`);
559
+ }
560
+ }
561
+ const credential = await requireLlmCredential("score");
562
+ const transcriptPath = option(rest, "--transcript");
563
+ let transcriptText;
564
+ let title = option(rest, "--title") ?? undefined;
565
+ if (transcriptPath) {
566
+ transcriptText = normalizeTranscript(readFileSync(resolve(process.cwd(), transcriptPath), "utf8"));
567
+ }
568
+ else {
569
+ const callPath = option(rest, "--call");
570
+ if (!callPath)
571
+ throw new Error("call score requires --transcript <file> or --call <parsed.json>");
572
+ const parsed = JSON.parse(readFileSync(resolve(process.cwd(), callPath), "utf8"));
573
+ transcriptText = parsed.segments
574
+ .map((segment) => (segment.speaker ? `${segment.speaker}: ${segment.text}` : segment.text))
575
+ .join("\n");
576
+ title = title ?? parsed.title;
577
+ }
578
+ const scorecard = await scoreCallLlm(transcriptText, rubric, {
579
+ ...credential,
580
+ model: option(rest, "--model") ?? undefined,
581
+ title,
582
+ });
583
+ const outPath = option(rest, "--out");
584
+ if (outPath)
585
+ writeFileSync(resolve(process.cwd(), outPath), `${JSON.stringify(scorecard, null, 2)}\n`);
586
+ if (rest.includes("--json")) {
587
+ console.log(JSON.stringify(scorecard, null, 2));
588
+ return;
589
+ }
590
+ console.log(renderScorecard(scorecard, title));
591
+ return;
592
+ }
593
+ throw new Error(`call supports: parse, link, plan, score (got ${subcommand ?? "nothing"})`);
594
+ }
595
+ /**
596
+ * First-touch key onboarding: env vars win, then the credential store; on a
597
+ * TTY a missing key is captured once (validated, stored 0600 like provider
598
+ * logins). Non-interactive contexts get an actionable error instead.
599
+ */
600
+ async function requireLlmCredential(command = "parse") {
601
+ const resolved = resolveLlmCredential();
602
+ if (resolved)
603
+ return resolved;
604
+ // Scoring is inherently LLM work — there is no keyword fallback to suggest.
605
+ const fallbackHint = command === "parse" ? ", or pass --deterministic for the free keyword baseline" : " (call score has no non-LLM mode)";
606
+ if (!process.stdin.isTTY) {
607
+ throw new Error(`LLM ${command === "score" ? "scoring" : "extraction"} needs an API key. Set ANTHROPIC_API_KEY or OPENAI_API_KEY, or run \`echo "$KEY" | fullstackgtm login anthropic\` (or \`login openai\`) once${fallbackHint}.`);
608
+ }
609
+ console.error("LLM parsing needs an API key (Anthropic or OpenAI) — yours, used directly with the provider.");
610
+ console.error(`Paste it once; it is validated and stored at ${credentialsPath()} (file mode 0600), like CRM logins.`);
611
+ console.error("(Alternatives: set ANTHROPIC_API_KEY / OPENAI_API_KEY, or pass --deterministic for the free keyword baseline.)\n");
612
+ const apiKey = await readSecret("API key (sk-ant-... or sk-...): ");
613
+ const provider = detectProviderFromKey(apiKey);
614
+ const validation = await validateLlmKey(provider, apiKey);
615
+ if (!validation.ok)
616
+ throw new Error(`${provider} rejected the key: ${validation.detail}`);
617
+ const now = new Date().toISOString();
618
+ storeCredential(provider, { kind: "api_key", accessToken: apiKey, createdAt: now, updatedAt: now });
619
+ console.error(`Stored ${provider} key (${validation.detail}). Future runs use it automatically; remove with \`fullstackgtm logout ${provider}\`.\n`);
620
+ return { provider, apiKey };
621
+ }
622
+ function renderScorecard(scorecard, title) {
623
+ const lines = [
624
+ `# Coaching Scorecard${title ? ` — ${title}` : ""}`,
625
+ "",
626
+ `**Overall: ${scorecard.overallScore}/${scorecard.scale}** (model: ${scorecard.model})`,
627
+ "",
628
+ "| Dimension | Score | | Coaching note |",
629
+ "| --- | --- | --- | --- |",
630
+ ];
631
+ for (const dim of scorecard.dimensions) {
632
+ const filled = Math.round((dim.score / dim.maxScore) * 5);
633
+ const bar = "█".repeat(filled) + "░".repeat(5 - filled);
634
+ lines.push(`| ${dim.name} | ${dim.score}/${dim.maxScore} | ${bar} | ${dim.coachingNote} |`);
635
+ }
636
+ if (scorecard.highlights.length) {
637
+ lines.push("", "**Highlights**");
638
+ for (const h of scorecard.highlights)
639
+ lines.push(`- ${h}`);
640
+ }
641
+ if (scorecard.missedItems.length) {
642
+ lines.push("", "**Missed**");
643
+ for (const m of scorecard.missedItems)
644
+ lines.push(`- ${m}`);
645
+ }
646
+ return lines.join("\n");
516
647
  }
517
648
  function buildCallPlan(parsed, deal, proposed, current, extraNextSteps) {
518
649
  const findings = [];
@@ -1067,8 +1198,24 @@ async function login(args) {
1067
1198
  console.log(`Logged in to Stripe. Credentials stored in ${credentialsPath()}.`);
1068
1199
  return;
1069
1200
  }
1201
+ if (provider === "anthropic" || provider === "openai") {
1202
+ rejectArgvSecret(args, "--token", "--key", "--api-key");
1203
+ const key = await readSecret(`${provider} API key (${provider === "anthropic" ? "sk-ant-..." : "sk-..."})`);
1204
+ if (!key)
1205
+ throw new Error(`No ${provider} key provided.`);
1206
+ if (!args.includes("--no-validate")) {
1207
+ const validation = await validateLlmKey(provider, key);
1208
+ if (!validation.ok)
1209
+ throw new Error(`${provider} rejected the key: ${validation.detail}`);
1210
+ console.log(validation.detail);
1211
+ }
1212
+ const stamp = new Date().toISOString();
1213
+ storeCredential(provider, { kind: "api_key", accessToken: key, createdAt: stamp, updatedAt: stamp });
1214
+ console.log(`Stored ${provider} API key in ${credentialsPath()}. \`fullstackgtm call parse\` and \`call score\` use it automatically.`);
1215
+ return;
1216
+ }
1070
1217
  if (provider !== "hubspot") {
1071
- throw new Error("login supports: hubspot, salesforce, stripe, or --via <hosted url>. Usage: fullstackgtm login <provider> | fullstackgtm login --via https://gtm.example.com");
1218
+ throw new Error("login supports: hubspot, salesforce, stripe, anthropic, openai, or --via <hosted url>. Usage: fullstackgtm login <provider> | fullstackgtm login --via https://gtm.example.com");
1072
1219
  }
1073
1220
  const now = new Date().toISOString();
1074
1221
  if (args.includes("--oauth")) {
@@ -1150,6 +1297,7 @@ export function doctorReport(env = process.env) {
1150
1297
  ? { source: "env", detail: "STRIPE_SECRET_KEY" }
1151
1298
  : providerStatus("stripe", broker),
1152
1299
  };
1300
+ const llm = resolveLlmCredential(env);
1153
1301
  const missingPeers = ["@modelcontextprotocol/sdk", "zod"].filter((name) => {
1154
1302
  try {
1155
1303
  import.meta.resolve(name);
@@ -1174,6 +1322,9 @@ export function doctorReport(env = process.env) {
1174
1322
  config: { path: configPath, exists: existsSync(configPath) },
1175
1323
  providers,
1176
1324
  broker: broker ? { paired: true, baseUrl: broker.baseUrl ?? "unknown" } : { paired: false },
1325
+ llm: llm
1326
+ ? { configured: true, provider: llm.provider, source: llm.source }
1327
+ : { configured: false, detail: "call parse/score will prompt once, or set ANTHROPIC_API_KEY / OPENAI_API_KEY" },
1177
1328
  mcp: { peersInstalled: missingPeers.length === 0, missing: missingPeers },
1178
1329
  nextSteps,
1179
1330
  };
@@ -1215,6 +1366,7 @@ function doctorCommand(args) {
1215
1366
  "Providers:",
1216
1367
  ...Object.entries(report.providers).map(([provider, status]) => ` ${provider.padEnd(11)} ${status.source === "none" ? `not connected (${status.detail})` : `${status.source}: ${status.detail}`}`),
1217
1368
  ` ${"broker".padEnd(11)} ${report.broker.paired ? `paired with ${report.broker.baseUrl}` : "not paired (fullstackgtm login --via <hosted url>)"}`,
1369
+ ` ${"llm".padEnd(11)} ${report.llm.configured ? `${report.llm.provider} key (${report.llm.source}) — call parse/score ready` : `not configured (${report.llm.detail})`}`,
1218
1370
  "",
1219
1371
  report.mcp.peersInstalled
1220
1372
  ? "MCP: peers installed — `fullstackgtm-mcp` is ready"
@@ -23,7 +23,7 @@ export declare function baseHomeDir(): string;
23
23
  */
24
24
  export declare function listProfiles(): string[];
25
25
  export type StoredCredential = {
26
- kind: "private_app" | "oauth" | "broker";
26
+ kind: "private_app" | "oauth" | "broker" | "api_key";
27
27
  accessToken: string;
28
28
  refreshToken?: string;
29
29
  /** Epoch ms when the access token expires (oauth only). */
package/dist/index.d.ts CHANGED
@@ -17,5 +17,6 @@ export { HUBSPOT_DEFAULT_FIELD_MAPPINGS, SALESFORCE_DEFAULT_FIELD_MAPPINGS, mapp
17
17
  export { accountSingleSourceRule, activeDealAccountWithoutContactsRule, auditFindingId, buildSnapshotIndex, builtinAuditRules, closingSoonInactiveRule, duplicateAccountDomainRule, duplicateContactEmailRule, duplicateOpenDealRule, missingDealAccountRule, missingDealAmountRule, missingDealOwnerRule, orphanAccountRule, pastCloseDateRule, patchOperationId, requiresHumanInput, staleDealRule, } from "./rules.ts";
18
18
  export { extractCallInsights, normalizeTranscript, parseCall, parseTranscript, suggestCallDeal, summarizeInsights, type CallDealSuggestion, type CallInsightType, type ExtractedCallInsight, type ParsedCall, type ParsedTranscriptSegment, } from "./calls.ts";
19
19
  export { sampleSnapshot } from "./sampleData.ts";
20
+ export { DEFAULT_MODELS, DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, type CallScorecard, type LlmCredential, type LlmExtractedInsight, type LlmProvider, type Rubric, type ScoredDimension, } from "./llm.ts";
20
21
  export { suggestValues, type SuggestionConfidence, type ValueSuggestion } from "./suggest.ts";
21
22
  export type { ApprovalStatus, AuditFinding, AuditFindingSeverity, CanonicalAccount, CanonicalActivity, CanonicalContact, CanonicalDeal, CanonicalGtmSnapshot, CanonicalUser, CrmProvider, GtmAuditRule, GtmConnector, GtmEvidence, GtmEvidenceSourceSystem, GtmObjectType, GtmPolicy, GtmRuleContext, GtmRuleResult, GtmSnapshotIndex, PatchOperation, PatchOperationResult, PatchOperationType, PatchPlan, PatchPlanRun, PatchPlanRunStatus, PatchVerification, PipelineFinding, PipelineFindingStatus, PipelineFindingType, ProviderIdentity, RiskLevel, SourceFreshness, } from "./types.ts";
package/dist/index.js CHANGED
@@ -17,4 +17,5 @@ export { HUBSPOT_DEFAULT_FIELD_MAPPINGS, SALESFORCE_DEFAULT_FIELD_MAPPINGS, mapp
17
17
  export { accountSingleSourceRule, activeDealAccountWithoutContactsRule, auditFindingId, buildSnapshotIndex, builtinAuditRules, closingSoonInactiveRule, duplicateAccountDomainRule, duplicateContactEmailRule, duplicateOpenDealRule, missingDealAccountRule, missingDealAmountRule, missingDealOwnerRule, orphanAccountRule, pastCloseDateRule, patchOperationId, requiresHumanInput, staleDealRule, } from "./rules.js";
18
18
  export { extractCallInsights, normalizeTranscript, parseCall, parseTranscript, suggestCallDeal, summarizeInsights, } from "./calls.js";
19
19
  export { sampleSnapshot } from "./sampleData.js";
20
+ export { DEFAULT_MODELS, DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, } from "./llm.js";
20
21
  export { suggestValues } from "./suggest.js";
package/dist/llm.d.ts ADDED
@@ -0,0 +1,71 @@
1
+ import type { ExtractedCallInsight } from "./calls.ts";
2
+ /**
3
+ * LLM-powered call extraction and scoring. Bring-your-own-key, two providers
4
+ * (Anthropic, OpenAI), raw fetch — no SDK dependency, mirroring how the CRM
5
+ * connectors talk to their APIs. Constrained tool calls keep the output in
6
+ * the same canonical insight shape as the deterministic engine, with
7
+ * mandatory verbatim-quote evidence; every insight is provenance-marked with
8
+ * the extractor that produced it.
9
+ */
10
+ export type LlmProvider = "anthropic" | "openai";
11
+ export type LlmCredential = {
12
+ provider: LlmProvider;
13
+ apiKey: string;
14
+ source: "env" | "stored";
15
+ };
16
+ export declare const DEFAULT_MODELS: Record<LlmProvider, string>;
17
+ export declare function detectProviderFromKey(apiKey: string): LlmProvider;
18
+ /** Env first (ANTHROPIC_API_KEY, then OPENAI_API_KEY), then the credential store. */
19
+ export declare function resolveLlmCredential(env?: Record<string, string | undefined>): LlmCredential | null;
20
+ export type LlmCallOptions = {
21
+ provider: LlmProvider;
22
+ apiKey: string;
23
+ model?: string;
24
+ fetchImpl?: typeof fetch;
25
+ };
26
+ export type LlmExtractedInsight = ExtractedCallInsight & {
27
+ owner?: string;
28
+ deadline?: string;
29
+ commitment?: "firm" | "tentative" | "exploratory";
30
+ };
31
+ export declare function extractInsightsLlm(transcript: string, options: LlmCallOptions & {
32
+ title?: string;
33
+ }): Promise<{
34
+ insights: LlmExtractedInsight[];
35
+ model: string;
36
+ }>;
37
+ export type Rubric = {
38
+ scale: number;
39
+ dimensions: Array<{
40
+ name: string;
41
+ weight: number;
42
+ rubric: string;
43
+ }>;
44
+ };
45
+ export declare const DEFAULT_RUBRIC: Rubric;
46
+ export type ScoredDimension = {
47
+ name: string;
48
+ score: number;
49
+ maxScore: number;
50
+ weight: number;
51
+ evidence: string[];
52
+ coachingNote: string;
53
+ };
54
+ export type CallScorecard = {
55
+ dimensions: ScoredDimension[];
56
+ /** Weighted average, computed deterministically client-side. */
57
+ overallScore: number;
58
+ scale: number;
59
+ highlights: string[];
60
+ missedItems: string[];
61
+ model: string;
62
+ };
63
+ export declare function scoreCallLlm(transcript: string, rubric: Rubric, options: LlmCallOptions & {
64
+ title?: string;
65
+ }): Promise<CallScorecard>;
66
+ export declare function parseRubric(json: string): Rubric;
67
+ /** Cheap key validation against the provider's model-list endpoint. Status line only. */
68
+ export declare function validateLlmKey(provider: LlmProvider, apiKey: string, fetchImpl?: typeof fetch): Promise<{
69
+ ok: boolean;
70
+ detail: string;
71
+ }>;