fullstackgtm 0.49.0 → 0.50.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.
Files changed (78) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/DATA-FLOWS.md +1 -0
  3. package/README.md +48 -0
  4. package/dist/acquireCheckpoint.d.ts +33 -0
  5. package/dist/acquireCheckpoint.js +149 -0
  6. package/dist/acquireLinkedIn.d.ts +2 -0
  7. package/dist/acquireLinkedIn.js +1 -1
  8. package/dist/cli/audit.js +6 -1
  9. package/dist/cli/auth.js +25 -0
  10. package/dist/cli/backfill.js +4 -0
  11. package/dist/cli/call.js +28 -6
  12. package/dist/cli/draft.js +11 -1
  13. package/dist/cli/enrich.js +286 -81
  14. package/dist/cli/fix.js +6 -3
  15. package/dist/cli/help.js +30 -26
  16. package/dist/cli/icp.js +15 -1
  17. package/dist/cli/market.js +14 -1
  18. package/dist/cli/planOutput.d.ts +5 -0
  19. package/dist/cli/planOutput.js +27 -0
  20. package/dist/cli/plans.js +269 -24
  21. package/dist/cli/schedule.js +22 -11
  22. package/dist/cli/shared.d.ts +3 -1
  23. package/dist/cli/shared.js +2 -2
  24. package/dist/cli/signals.js +22 -3
  25. package/dist/cli/tam.js +10 -1
  26. package/dist/cli/ui.d.ts +10 -5
  27. package/dist/cli/ui.js +28 -9
  28. package/dist/connectors/hubspot.d.ts +4 -0
  29. package/dist/connectors/hubspot.js +36 -18
  30. package/dist/connectors/linkedin.d.ts +2 -0
  31. package/dist/connectors/linkedin.js +5 -0
  32. package/dist/connectors/prospectSources.d.ts +23 -0
  33. package/dist/connectors/prospectSources.js +21 -3
  34. package/dist/enrich.d.ts +44 -1
  35. package/dist/hostedAcquireCheckpoint.d.ts +43 -0
  36. package/dist/hostedAcquireCheckpoint.js +129 -0
  37. package/dist/hostedPatchPlan.d.ts +87 -0
  38. package/dist/hostedPatchPlan.js +275 -0
  39. package/dist/icp.js +13 -2
  40. package/dist/index.d.ts +4 -1
  41. package/dist/index.js +3 -0
  42. package/dist/planStore.d.ts +14 -0
  43. package/dist/planStore.js +73 -0
  44. package/dist/progress.d.ts +2 -2
  45. package/dist/progress.js +2 -2
  46. package/docs/api.md +24 -0
  47. package/docs/architecture.md +9 -0
  48. package/package.json +1 -1
  49. package/skills/fullstackgtm/SKILL.md +1 -1
  50. package/src/acquireCheckpoint.ts +186 -0
  51. package/src/acquireLinkedIn.ts +3 -1
  52. package/src/cli/audit.ts +5 -1
  53. package/src/cli/auth.ts +24 -0
  54. package/src/cli/backfill.ts +3 -0
  55. package/src/cli/call.ts +25 -6
  56. package/src/cli/draft.ts +9 -1
  57. package/src/cli/enrich.ts +325 -80
  58. package/src/cli/fix.ts +5 -3
  59. package/src/cli/help.ts +31 -26
  60. package/src/cli/icp.ts +13 -1
  61. package/src/cli/market.ts +12 -1
  62. package/src/cli/planOutput.ts +30 -0
  63. package/src/cli/plans.ts +259 -25
  64. package/src/cli/schedule.ts +21 -15
  65. package/src/cli/shared.ts +2 -1
  66. package/src/cli/signals.ts +22 -3
  67. package/src/cli/tam.ts +8 -1
  68. package/src/cli/ui.ts +31 -13
  69. package/src/connectors/hubspot.ts +41 -17
  70. package/src/connectors/linkedin.ts +6 -0
  71. package/src/connectors/prospectSources.ts +46 -4
  72. package/src/enrich.ts +47 -1
  73. package/src/hostedAcquireCheckpoint.ts +156 -0
  74. package/src/hostedPatchPlan.ts +291 -0
  75. package/src/icp.ts +14 -2
  76. package/src/index.ts +20 -0
  77. package/src/planStore.ts +87 -0
  78. package/src/progress.ts +2 -2
@@ -0,0 +1,186 @@
1
+ import { createHash } from "node:crypto";
2
+ import { chmodSync, mkdirSync, readdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { credentialsDir, ensureSecureHomeDir } from "./credentials.ts";
5
+ import {
6
+ assertNoSymlinkComponents,
7
+ readSecureRegularFile,
8
+ writeSecureFileAtomic,
9
+ } from "./secureFile.ts";
10
+
11
+ /** The complete identity of one independently traversable provider audience. */
12
+ export type AcquireCheckpointKey = {
13
+ provider: string;
14
+ source: string;
15
+ listId: string | null;
16
+ queryFingerprint: string;
17
+ };
18
+
19
+ export type AcquireContinuation = {
20
+ cursor?: string | null;
21
+ offset?: number | null;
22
+ exhausted: boolean;
23
+ };
24
+
25
+ /** Versioned wire format shared by local persistence and hosted sync. */
26
+ export type AcquireCheckpoint = {
27
+ version: 1;
28
+ id: string;
29
+ key: AcquireCheckpointKey;
30
+ continuation: AcquireContinuation;
31
+ updatedAt: string;
32
+ };
33
+
34
+ const COMPONENT_MAX = 512;
35
+
36
+ function requireComponent(value: unknown, name: string): string {
37
+ if (typeof value !== "string" || value.length === 0 || value.length > COMPONENT_MAX || /[\u0000-\u001f]/.test(value)) {
38
+ throw new Error(`Invalid acquisition checkpoint ${name}`);
39
+ }
40
+ return value;
41
+ }
42
+
43
+ export function validateAcquireCheckpointKey(value: unknown): AcquireCheckpointKey {
44
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
45
+ throw new Error("Invalid acquisition checkpoint key");
46
+ }
47
+ const candidate = value as Partial<AcquireCheckpointKey>;
48
+ const listId = candidate.listId;
49
+ if (listId !== null && listId !== undefined) requireComponent(listId, "listId");
50
+ return {
51
+ provider: requireComponent(candidate.provider, "provider"),
52
+ source: requireComponent(candidate.source, "source"),
53
+ listId: listId ?? null,
54
+ queryFingerprint: requireComponent(candidate.queryFingerprint, "queryFingerprint"),
55
+ };
56
+ }
57
+
58
+ /** Stable, non-secret filename/key suitable for both filesystem and hosted records. */
59
+ export function acquireCheckpointId(key: AcquireCheckpointKey): string {
60
+ const valid = validateAcquireCheckpointKey(key);
61
+ const canonical = JSON.stringify([valid.provider, valid.source, valid.listId, valid.queryFingerprint]);
62
+ return `acqcp_${createHash("sha256").update(canonical).digest("hex")}`;
63
+ }
64
+
65
+ function validateContinuation(value: unknown): AcquireContinuation {
66
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
67
+ throw new Error("Invalid acquisition checkpoint continuation");
68
+ }
69
+ const candidate = value as Partial<AcquireContinuation>;
70
+ if (typeof candidate.exhausted !== "boolean") throw new Error("Invalid acquisition checkpoint exhausted flag");
71
+ if (candidate.cursor !== undefined && candidate.cursor !== null) requireComponent(candidate.cursor, "cursor");
72
+ if (
73
+ candidate.offset !== undefined && candidate.offset !== null &&
74
+ (!Number.isSafeInteger(candidate.offset) || candidate.offset < 0)
75
+ ) {
76
+ throw new Error("Invalid acquisition checkpoint offset");
77
+ }
78
+ return {
79
+ ...(candidate.cursor !== undefined ? { cursor: candidate.cursor } : {}),
80
+ ...(candidate.offset !== undefined ? { offset: candidate.offset } : {}),
81
+ exhausted: candidate.exhausted,
82
+ };
83
+ }
84
+
85
+ export function validateAcquireCheckpoint(value: unknown): AcquireCheckpoint {
86
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
87
+ throw new Error("Invalid acquisition checkpoint");
88
+ }
89
+ const candidate = value as Partial<AcquireCheckpoint>;
90
+ if (candidate.version !== 1) throw new Error(`Unsupported acquisition checkpoint version: ${String(candidate.version)}`);
91
+ const key = validateAcquireCheckpointKey(candidate.key);
92
+ const id = acquireCheckpointId(key);
93
+ if (candidate.id !== id) throw new Error("Acquisition checkpoint id does not match its key");
94
+ if (typeof candidate.updatedAt !== "string" || !Number.isFinite(Date.parse(candidate.updatedAt))) {
95
+ throw new Error("Invalid acquisition checkpoint updatedAt");
96
+ }
97
+ return {
98
+ version: 1,
99
+ id,
100
+ key,
101
+ continuation: validateContinuation(candidate.continuation),
102
+ updatedAt: candidate.updatedAt,
103
+ };
104
+ }
105
+
106
+ export function acquireCheckpointsDir(baseDir?: string): string {
107
+ return join(baseDir ?? credentialsDir(), "acquire", "checkpoints");
108
+ }
109
+
110
+ export interface AcquireCheckpointStore {
111
+ get(key: AcquireCheckpointKey): Promise<AcquireCheckpoint | null>;
112
+ put(key: AcquireCheckpointKey, continuation: AcquireContinuation, updatedAt?: Date): Promise<AcquireCheckpoint>;
113
+ /** Import a validated local/hosted record without changing its timestamp. */
114
+ putRecord(record: AcquireCheckpoint): Promise<AcquireCheckpoint>;
115
+ list(): Promise<AcquireCheckpoint[]>;
116
+ }
117
+
118
+ export function createFileAcquireCheckpointStore(baseDir?: string): AcquireCheckpointStore {
119
+ const directory = acquireCheckpointsDir(baseDir);
120
+
121
+ function fileForId(id: string): string {
122
+ return join(directory, `${id}.json`);
123
+ }
124
+
125
+ function readId(id: string): AcquireCheckpoint | null {
126
+ try {
127
+ return validateAcquireCheckpoint(JSON.parse(readSecureRegularFile(fileForId(id), { tightenMode: 0o600 })));
128
+ } catch (error) {
129
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
130
+ throw error;
131
+ }
132
+ }
133
+
134
+ function ensureDirectory(): void {
135
+ if (baseDir === undefined) ensureSecureHomeDir();
136
+ // Tighten both managed levels. mkdir's mode does not affect a directory
137
+ // restored or pre-created with broader permissions.
138
+ const levels = [join(baseDir ?? credentialsDir(), "acquire"), directory];
139
+ for (const level of levels) {
140
+ assertNoSymlinkComponents(level);
141
+ mkdirSync(level, { recursive: true, mode: 0o700 });
142
+ assertNoSymlinkComponents(level);
143
+ try { chmodSync(level, 0o700); } catch { /* chmod is unavailable on some filesystems. */ }
144
+ }
145
+ }
146
+
147
+ function writeRecord(value: AcquireCheckpoint): AcquireCheckpoint {
148
+ const record = validateAcquireCheckpoint(value);
149
+ ensureDirectory();
150
+ writeSecureFileAtomic(fileForId(record.id), `${JSON.stringify(record, null, 2)}\n`);
151
+ return record;
152
+ }
153
+
154
+ return {
155
+ async get(key) {
156
+ return readId(acquireCheckpointId(key));
157
+ },
158
+ async put(key, continuation, updatedAt = new Date()) {
159
+ const validKey = validateAcquireCheckpointKey(key);
160
+ return writeRecord({
161
+ version: 1,
162
+ id: acquireCheckpointId(validKey),
163
+ key: validKey,
164
+ continuation: validateContinuation(continuation),
165
+ updatedAt: updatedAt.toISOString(),
166
+ });
167
+ },
168
+ async putRecord(record) {
169
+ return writeRecord(record);
170
+ },
171
+ async list() {
172
+ let names: string[];
173
+ try {
174
+ assertNoSymlinkComponents(directory);
175
+ names = readdirSync(directory).filter((name) => /^acqcp_[a-f0-9]{64}\.json$/.test(name));
176
+ } catch (error) {
177
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
178
+ throw error;
179
+ }
180
+ return names
181
+ .map((name) => readId(name.slice(0, -5)))
182
+ .filter((record): record is AcquireCheckpoint => record !== null)
183
+ .sort((a, b) => a.updatedAt.localeCompare(b.updatedAt));
184
+ },
185
+ };
186
+ }
@@ -49,6 +49,8 @@ export type DiscoverLinkedInOptions = {
49
49
  sourceId?: string;
50
50
  /** Hard cap on prospects pulled. */
51
51
  max?: number;
52
+ /** Opaque provider continuation cursor. */
53
+ cursor?: string;
52
54
  };
53
55
 
54
56
  /**
@@ -60,7 +62,7 @@ export async function discoverLinkedInProspects(
60
62
  provider: LinkedInProvider,
61
63
  options: DiscoverLinkedInOptions = {},
62
64
  ): Promise<Prospect[]> {
63
- const raw = await provider.fetchProspects({ sourceId: options.sourceId, max: options.max });
65
+ const raw = await provider.fetchProspects({ sourceId: options.sourceId, max: options.max, cursor: options.cursor });
64
66
  return raw.map(linkedInProspectToProspect);
65
67
  }
66
68
 
package/src/cli/audit.ts CHANGED
@@ -13,6 +13,7 @@ import { reportCounts, reportCrm, reportFindings } from "../runReport.ts";
13
13
  import type { AuditFindingSeverity, CanonicalGtmSnapshot, PatchPlan } from "../types.ts";
14
14
  import { connectorFor, numericOption, option, readSnapshot, saveRequested, selectedRules } from "./shared.ts";
15
15
  import { colorEnabled, createChecklist, formatCount, paint, scoreColor, sparkline, stylizePlanMarkdown, table, type Paint } from "./ui.ts";
16
+ import { compactPlan, verbosePlanRequested } from "./planOutput.ts";
16
17
 
17
18
 
18
19
  const SEVERITY_RANK: Record<AuditFindingSeverity, number> = {
@@ -258,13 +259,16 @@ export async function audit(args: string[]) {
258
259
  }
259
260
  if (args.includes("--json")) {
260
261
  console.log(JSON.stringify(plan, null, 2));
262
+ } else if (!verbosePlanRequested(args)) {
263
+ console.log(compactPlan(plan, { saved: saveRequested(args) }));
264
+ console.error(`\n${auditNextStep(args, plan)}`);
261
265
  } else {
262
266
  // Default to the summary view (rule table + counts); the full per-operation
263
267
  // dump is opt-in via --full or, for a deliverable, `report`. (dx-punch-list #3)
264
268
  // Interactive terminals get the styled rendering; piped output is unchanged.
265
269
  console.log(
266
270
  stylizePlanMarkdown(
267
- patchPlanToMarkdown(plan, { summary: !args.includes("--full") }),
271
+ patchPlanToMarkdown(plan, { summary: false }),
268
272
  paint(colorEnabled(process.stdout)),
269
273
  ),
270
274
  );
package/src/cli/auth.ts CHANGED
@@ -638,6 +638,30 @@ export async function doctorCommand(args: string[]) {
638
638
  workspace.auditCount === 0
639
639
  ? p.dim("no saved audits yet (fullstackgtm audit --provider <name> --save starts the timeline)")
640
640
  : `${scoreColor(workspace.healthScore ?? 0, p)}/100${delta} — ${workspace.auditCount} audit(s) saved, last ${workspace.lastAuditAt}${healthSparkline(workspace.profile, p.enabled)}`;
641
+ const connectedProviders = Object.entries(report.providers).filter(([, provider]) => provider.source !== "none");
642
+ const blockers = [
643
+ ...(!report.node.ok ? [`Node v${report.node.version} is unsupported; install ${report.node.required}.`] : []),
644
+ ...(connectedProviders.length === 0 ? ["No CRM connected."] : []),
645
+ ...(workspace.pendingPlans.length > 0 ? [`${workspace.pendingPlans.length} plan${workspace.pendingPlans.length === 1 ? "" : "s"} awaiting approval.`] : []),
646
+ ];
647
+ if (!args.includes("--verbose")) {
648
+ const ready = report.node.ok && connectedProviders.length > 0;
649
+ const compact = [
650
+ `${ready ? p.green("Ready") : p.yellow("Setup needed")} · ${report.package.name} ${report.package.version} · profile ${report.profile}`,
651
+ `CRM ${connectedProviders.length > 0 ? connectedProviders.map(([name]) => name).join(", ") : "not connected"}`,
652
+ `Health ${healthLine}`,
653
+ `Plans ${workspace.pendingPlans.length === 0 ? "none awaiting approval" : `${workspace.pendingPlans.length} awaiting approval · ${workspace.pendingPlans[0].id}`}`,
654
+ ...(blockers.length > 0 ? ["", "Attention", ...blockers.map((blocker) => ` ${blocker}`)] : []),
655
+ "",
656
+ "Next",
657
+ ...nextSteps.map((step) => ` ${step}`),
658
+ "",
659
+ "Run `fullstackgtm doctor --verbose` for credential paths and optional tooling checks.",
660
+ ];
661
+ console.log(p.enabled ? box(compact, p, "Workspace readiness").join("\n") : compact.join("\n"));
662
+ if (!report.node.ok) process.exitCode = 1;
663
+ return;
664
+ }
641
665
  const lines = [
642
666
  `Package: ${p.bold(`${report.package.name} ${report.package.version}`)}`,
643
667
  `Node: v${report.node.version} (${report.node.required} required) ${mark(report.node.ok)}`,
@@ -15,6 +15,7 @@ import { progressReporter } from "../runReport.ts";
15
15
  import { unknownSubcommandError } from "./suggest.ts";
16
16
  import { option, readSnapshot, saveRequested } from "./shared.ts";
17
17
  import { colorEnabled, createProgressRenderer, paint, stylizePlanMarkdown, table } from "./ui.ts";
18
+ import { compactPlan, verbosePlanRequested } from "./planOutput.ts";
18
19
 
19
20
  /** STRIPE_SECRET_KEY env ∨ stored `login stripe` credential (same ladder as `--provider stripe`). */
20
21
  function resolveStripeKey(): string {
@@ -93,6 +94,8 @@ export async function backfillCommand(args: string[]) {
93
94
  2,
94
95
  ),
95
96
  );
97
+ } else if (!verbosePlanRequested(rest)) {
98
+ console.log(compactPlan(result.plan, { saved: save && result.plan.operations.length > 0 }));
96
99
  } else {
97
100
  // TTY-only styling; piped output stays byte-identical plain text.
98
101
  console.log(
package/src/cli/call.ts CHANGED
@@ -177,11 +177,11 @@ score always needs a key (scoring is LLM work).`);
177
177
  if (!outPath) console.log(JSON.stringify(parsed, null, 2));
178
178
  return;
179
179
  }
180
- console.log(`Call ${parsed.id}${parsed.title ? ` — ${parsed.title}` : ""} (${parsed.sourceSystem})`);
181
- console.log(`${parsed.segments.length} segments · ${parsed.insights.length} insights (${parsed.summary.highImportance} high-importance)\n`);
182
- for (const insight of parsed.insights) {
183
- console.log(`[${insight.type}] (importance ${insight.importance}) ${insight.text}`);
184
- }
180
+ console.log(`Call ${parsed.id}${parsed.title ? ` — ${parsed.title}` : ""}`);
181
+ console.log(`${parsed.segments.length} segments · ${parsed.insights.length} insights · ${parsed.summary.highImportance} high priority`);
182
+ const visible = rest.includes("--verbose") ? parsed.insights : parsed.insights.filter((insight) => insight.importance >= 3);
183
+ for (const insight of visible) console.log(`\n${insight.type.replaceAll("_", " ")} · priority ${insight.importance}\n ${insight.text.replace(/\s+/g, " ").trim()}`);
184
+ if (!rest.includes("--verbose") && visible.length < parsed.insights.length) console.log(`\n${parsed.insights.length - visible.length} lower-priority insight(s) hidden; use --verbose to show all.`);
185
185
  return;
186
186
  }
187
187
 
@@ -301,13 +301,32 @@ score always needs a key (scoring is LLM work).`);
301
301
  console.log(JSON.stringify(scorecard, null, 2));
302
302
  return;
303
303
  }
304
- console.log(renderScorecard(scorecard, title));
304
+ console.log(rest.includes("--verbose") ? renderScorecard(scorecard, title) : renderCompactScorecard(scorecard, title));
305
305
  return;
306
306
  }
307
307
 
308
308
  throw new Error(`call supports: parse, classify, link, plan, score (got ${subcommand ?? "nothing"})`);
309
309
  }
310
310
 
311
+ function renderCompactScorecard(scorecard: CallScorecard, title?: string): string {
312
+ const lines = [
313
+ `${title ?? "Call"} · ${scorecard.overallScore}/${scorecard.scale}${scorecard.band ? ` · ${scorecard.band.label}` : ""}`,
314
+ scorecard.rubricName ? `${scorecard.rubricName}${scorecard.callType ? ` (${scorecard.callType})` : ""}` : "",
315
+ "",
316
+ ].filter((line, index) => line || index === 2);
317
+ for (const dimension of scorecard.dimensions) {
318
+ lines.push(`${dimension.score}/${dimension.maxScore} ${dimension.name}`);
319
+ lines.push(` ${dimension.coachingNote.replace(/\s+/g, " ").trim()}`);
320
+ }
321
+ if (scorecard.missedItems.length) {
322
+ lines.push("", "Focus next");
323
+ for (const item of scorecard.missedItems) lines.push(` ${item}`);
324
+ }
325
+ if (scorecard.highlights.length) lines.push("", `Strengths: ${scorecard.highlights.join(" · ")}`);
326
+ lines.push("", "Use --verbose for the full coaching scorecard.");
327
+ return lines.join("\n");
328
+ }
329
+
311
330
  function renderScorecard(scorecard: CallScorecard, title?: string): string {
312
331
  const bandText = scorecard.band ? ` — ${scorecard.band.label}` : "";
313
332
  const rubricLine = scorecard.rubricName ? `Rubric: ${scorecard.rubricName}${scorecard.callType ? ` (${scorecard.callType})` : ""}` : "";
package/src/cli/draft.ts CHANGED
@@ -9,6 +9,7 @@ import { createFileJudgeStore } from "../judge.ts";
9
9
  import { authorOpeners, DEFAULT_DRAFT_PROMPT, DRAFT_CHANNELS, draft, type DraftChannel } from "../draft.ts";
10
10
  import type { LlmCallOptions } from "../llm.ts";
11
11
  import { numericOption, option, resolveLlmBaseUrls, saveRequested } from "./shared.ts";
12
+ import { compactPlan, verbosePlanRequested } from "./planOutput.ts";
12
13
 
13
14
 
14
15
  /**
@@ -78,7 +79,14 @@ LLM key it emits a clearly-labeled stub rather than fake authored copy.`);
78
79
  openers,
79
80
  });
80
81
 
81
- console.log(JSON.stringify({ drafts, rejected }, null, 2));
82
+ if (args.includes("--json")) {
83
+ console.log(JSON.stringify({ drafts, rejected }, null, 2));
84
+ } else if (verbosePlanRequested(args)) {
85
+ console.log(JSON.stringify({ drafts, rejected }, null, 2));
86
+ console.log(compactPlan(plan, { saved: save }));
87
+ } else {
88
+ console.log(compactPlan(plan, { saved: save }));
89
+ }
82
90
  const stale = drafts.filter((d) => d.staleTrigger);
83
91
  console.error(
84
92
  `${drafts.length} opener(s) staged as create_task proposals (channel ${channel}, min score ${minScore})` +