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,142 @@
1
+ /**
2
+ * The shared progress-event vocabulary — ONE set of semantics for every
3
+ * long-running process, rendered natively by each surface:
4
+ *
5
+ * CLI → ui.ts (spinner / checklist / apply-ticker; TTY-only styling)
6
+ * app → Convex heartbeat patches → reactive ProgressChip / StageTimeline
7
+ * both → optional broker streaming (a paired CLI POSTs heartbeats to the
8
+ * hosted app under its clientRunId, so long local runs tick live in
9
+ * the org's activity feed)
10
+ *
11
+ * Renderer-agnostic by contract: no event assumes persistence (the CLI drops
12
+ * them after painting) or ANSI (the app can't use it). Listeners are
13
+ * best-effort — a throwing renderer must never fail the work — and the
14
+ * throttle policy lives HERE so both surfaces feel identical.
15
+ *
16
+ * Standing convention: if a verb loops, it emits. New long-running verbs wire
17
+ * an emitter at the package layer; surfaces get feedback for free.
18
+ */
19
+ const DEFAULT_THROTTLE_MS = 2000;
20
+ /**
21
+ * Throttled emitter: `items` heartbeats coalesce to at most one delivery per
22
+ * throttle window (terminal `done === total` always delivers); `stage`,
23
+ * `opResult`, `note`, and `meter` are structural and always deliver.
24
+ */
25
+ export function createProgressEmitter(listener, options = {}) {
26
+ const throttleMs = options.throttleMs ?? DEFAULT_THROTTLE_MS;
27
+ const now = options.now ?? Date.now;
28
+ const state = {
29
+ opsApplied: 0,
30
+ opsSkipped: 0,
31
+ opsFailed: 0,
32
+ updatedAt: now(),
33
+ };
34
+ let lastItemsDelivery = 0;
35
+ let pendingItems = false;
36
+ function deliver(event) {
37
+ state.updatedAt = now();
38
+ try {
39
+ listener(event, { ...state });
40
+ }
41
+ catch {
42
+ // Renderers are presentation-only; never fail the work.
43
+ }
44
+ }
45
+ return {
46
+ stage(stage, stageIndex, stageCount) {
47
+ state.stage = stage;
48
+ state.stageIndex = stageIndex;
49
+ state.stageCount = stageCount;
50
+ state.itemsDone = undefined;
51
+ state.itemsTotal = undefined;
52
+ state.note = undefined;
53
+ pendingItems = false;
54
+ deliver({ kind: "stage", stage, stageIndex, stageCount });
55
+ },
56
+ items(done, total) {
57
+ state.itemsDone = done;
58
+ state.itemsTotal = total;
59
+ const terminal = total !== undefined && done >= total;
60
+ const due = now() - lastItemsDelivery >= throttleMs;
61
+ if (terminal || due) {
62
+ lastItemsDelivery = now();
63
+ pendingItems = false;
64
+ deliver({ kind: "items", done, total });
65
+ }
66
+ else {
67
+ pendingItems = true;
68
+ }
69
+ },
70
+ note(note) {
71
+ state.note = note;
72
+ deliver({ kind: "note", note });
73
+ },
74
+ opResult(opId, status, detail) {
75
+ if (status === "applied")
76
+ state.opsApplied += 1;
77
+ else if (status === "skipped")
78
+ state.opsSkipped += 1;
79
+ else
80
+ state.opsFailed += 1;
81
+ deliver({ kind: "opResult", opId, status, detail });
82
+ },
83
+ meter(spent, budget, unit) {
84
+ deliver({ kind: "meter", spent, budget, unit });
85
+ },
86
+ snapshot() {
87
+ return { ...state, updatedAt: now() };
88
+ },
89
+ flush() {
90
+ if (pendingItems && state.itemsDone !== undefined) {
91
+ lastItemsDelivery = now();
92
+ pendingItems = false;
93
+ deliver({ kind: "items", done: state.itemsDone, total: state.itemsTotal });
94
+ }
95
+ },
96
+ };
97
+ }
98
+ /**
99
+ * Fan one event stream out to several listeners (e.g. the CLI's local renderer
100
+ * plus the broker heartbeat streamer). Each listener is isolated: one throwing
101
+ * never starves the others, matching the emitter's own best-effort contract.
102
+ */
103
+ export function composeListeners(...listeners) {
104
+ const active = listeners.filter((listener) => Boolean(listener));
105
+ return (event, snapshot) => {
106
+ for (const listener of active) {
107
+ try {
108
+ listener(event, snapshot);
109
+ }
110
+ catch {
111
+ // Listeners are presentation/observability only; never fail the work.
112
+ }
113
+ }
114
+ };
115
+ }
116
+ // ── Shared stage registries ─────────────────────────────────────────────────
117
+ // Package constants so the CLI checklist and the app StageTimeline render
118
+ // LITERALLY the same stages in the same order — one mental model everywhere.
119
+ export const CRM_SYNC_STAGES = [
120
+ "owners",
121
+ "accounts",
122
+ "contacts",
123
+ "deals",
124
+ "counters",
125
+ "health",
126
+ ];
127
+ /** A connector snapshot pull — the CRM-sync stages that happen CLI-side. */
128
+ export const SNAPSHOT_PULL_STAGES = ["owners", "accounts", "contacts", "deals"];
129
+ /** The Stripe connector's snapshot pull (billing systems have no owners). */
130
+ export const STRIPE_SNAPSHOT_STAGES = ["customers", "subscriptions"];
131
+ export const CALL_SYNC_STAGES = ["notes", "transcripts", "insights"];
132
+ export const BACKFILL_STRIPE_STAGES = ["invoices", "snapshot", "matching", "plan"];
133
+ /** `backfill runs` — replaying local plan runs + health history to the broker. */
134
+ export const RUNS_REPLAY_STAGES = ["runs", "health"];
135
+ export const APPLY_STAGES = ["preflight", "operations", "results"];
136
+ /** `enrich acquire` — routing sourced candidate rows into a create plan. */
137
+ export const ACQUIRE_STAGES = ["candidates"];
138
+ export const MARKET_CAPTURE_STAGES = ["sources", "capture", "classify", "persist"];
139
+ /** No-op emitter for callers that don't care (keeps signatures simple). */
140
+ export function nullProgressEmitter() {
141
+ return createProgressEmitter(() => { });
142
+ }
@@ -1,3 +1,4 @@
1
+ import type { ProgressListener } from "./progress.ts";
1
2
  /**
2
3
  * Per-row finding for the hosted run timeline. IDs + issue type ONLY — no field
3
4
  * values ever leave the CLI, keeping the audit's row data on the operator's side
@@ -28,6 +29,29 @@ export declare function reportFindings(values: RunFinding[]): void;
28
29
  export declare function reportCrm(value: RunCrm): void;
29
30
  /** A command annotates a structured event (plan saved, meter charged, …). */
30
31
  export declare function reportEvent(type: string, detail?: string): void;
32
+ /**
33
+ * Arm heartbeat streaming for this invocation. Call once from the entry point
34
+ * (alongside capturing `startedAt`); without it, progress listeners from
35
+ * `progressReporter()` are inert. Safe to call for any command — skip-listed
36
+ * commands simply never stream.
37
+ */
38
+ export declare function beginRunReport(args: string[], startedAt: number): void;
39
+ /**
40
+ * A ProgressListener that streams the run's progress snapshot to the paired
41
+ * hosted app. Verbs compose it with their local renderer:
42
+ *
43
+ * createProgressEmitter(composeListeners(renderer.listener, progressReporter()))
44
+ *
45
+ * Throttled to one POST per ~5s, first no earlier than ~5s into the run,
46
+ * 2s-capped and fire-and-forget. Privacy: stage names and counts only — notes
47
+ * must stay generic ("batch 4/12"); CRM field values never leave the machine.
48
+ */
49
+ export declare function progressReporter(overrides?: {
50
+ now?: () => number;
51
+ intervalMs?: number;
52
+ minRunMs?: number;
53
+ fetchImpl?: typeof fetch;
54
+ }): ProgressListener;
31
55
  /**
32
56
  * Send the run record if the CLI is paired. Best-effort: a 4s-capped POST that
33
57
  * swallows every error. Call once per process from the entry point.
package/dist/runReport.js CHANGED
@@ -31,6 +31,15 @@ export function reportCrm(value) {
31
31
  export function reportEvent(type, detail) {
32
32
  events.push({ ts: Date.now(), type, detail });
33
33
  }
34
+ // FNV-1a — the stable-id hash used across the package (see backfill.ts).
35
+ function fnv1a(value) {
36
+ let hash = 0x811c9dc5;
37
+ for (let i = 0; i < value.length; i += 1) {
38
+ hash ^= value.charCodeAt(i);
39
+ hash = Math.imul(hash, 0x01000193);
40
+ }
41
+ return (hash >>> 0).toString(16).padStart(8, "0");
42
+ }
34
43
  // Setup/inspection verbs aren't interesting as "runs" — skip the noise.
35
44
  const SKIP_COMMANDS = new Set([
36
45
  "login",
@@ -43,25 +52,151 @@ const SKIP_COMMANDS = new Set([
43
52
  "--version",
44
53
  "-v",
45
54
  ]);
55
+ /** The stable per-invocation identity flushRunReport and heartbeats share. */
56
+ function runIdentity(args, startedAt) {
57
+ const command = args[0];
58
+ if (!command || SKIP_COMMANDS.has(command))
59
+ return null;
60
+ const sub = args[1] && !args[1].startsWith("-") ? ` ${args[1]}` : "";
61
+ const full = `${command}${sub}`;
62
+ return { command: full, clientRunId: `run_${fnv1a(`${full}:${startedAt}:${process.pid}`)}` };
63
+ }
64
+ // ── Live progress streaming ─────────────────────────────────────────────────
65
+ // While a long command runs, a paired CLI streams heartbeats to the hosted
66
+ // app: POST /api/cli/run with the SAME clientRunId the final flushRunReport
67
+ // will use, status "in_progress", and the progress emitter's snapshot. The
68
+ // server upserts by clientRunId, so the terminal flush simply wins. Same
69
+ // invariants as flushRunReport: opt-in (paired only), fire-and-forget,
70
+ // time-capped, never affects the command's outcome or output.
71
+ const HEARTBEAT_INTERVAL_MS = 5000;
72
+ /** No heartbeat before the run is ~this old — fast commands never stream. */
73
+ const HEARTBEAT_MIN_RUN_MS = 5000;
74
+ const HEARTBEAT_TIMEOUT_MS = 2000;
75
+ let runContext = null;
76
+ let lastHeartbeatAt = 0;
77
+ // Broker credential resolved once per run (undefined = not looked up yet;
78
+ // null = looked up, not paired).
79
+ let heartbeatBroker;
80
+ let inflightHeartbeat = null;
81
+ /**
82
+ * Arm heartbeat streaming for this invocation. Call once from the entry point
83
+ * (alongside capturing `startedAt`); without it, progress listeners from
84
+ * `progressReporter()` are inert. Safe to call for any command — skip-listed
85
+ * commands simply never stream.
86
+ */
87
+ export function beginRunReport(args, startedAt) {
88
+ lastHeartbeatAt = 0;
89
+ heartbeatBroker = undefined;
90
+ inflightHeartbeat = null;
91
+ const identity = runIdentity(args, startedAt);
92
+ runContext = identity ? { ...identity, startedAt } : null;
93
+ }
94
+ /**
95
+ * A ProgressListener that streams the run's progress snapshot to the paired
96
+ * hosted app. Verbs compose it with their local renderer:
97
+ *
98
+ * createProgressEmitter(composeListeners(renderer.listener, progressReporter()))
99
+ *
100
+ * Throttled to one POST per ~5s, first no earlier than ~5s into the run,
101
+ * 2s-capped and fire-and-forget. Privacy: stage names and counts only — notes
102
+ * must stay generic ("batch 4/12"); CRM field values never leave the machine.
103
+ */
104
+ export function progressReporter(overrides = {}) {
105
+ const now = overrides.now ?? Date.now;
106
+ const intervalMs = overrides.intervalMs ?? HEARTBEAT_INTERVAL_MS;
107
+ const minRunMs = overrides.minRunMs ?? HEARTBEAT_MIN_RUN_MS;
108
+ const fetchImpl = overrides.fetchImpl ?? fetch;
109
+ return (_event, snapshot) => {
110
+ try {
111
+ if (!runContext)
112
+ return;
113
+ const at = now();
114
+ if (at - runContext.startedAt < minRunMs)
115
+ return;
116
+ if (at - lastHeartbeatAt < intervalMs)
117
+ return;
118
+ if (heartbeatBroker === undefined) {
119
+ const broker = getCredential("broker");
120
+ heartbeatBroker =
121
+ broker?.baseUrl && broker.accessToken
122
+ ? { baseUrl: broker.baseUrl.replace(/\/+$/, ""), accessToken: broker.accessToken }
123
+ : null; // opt-in: only when paired
124
+ }
125
+ if (!heartbeatBroker)
126
+ return;
127
+ lastHeartbeatAt = at;
128
+ sendHeartbeat(fetchImpl, heartbeatBroker, runContext, snapshot, at);
129
+ }
130
+ catch {
131
+ // Observability is best-effort; never affect the command.
132
+ }
133
+ };
134
+ }
135
+ function sendHeartbeat(fetchImpl, broker, context, snapshot, at) {
136
+ // One heartbeat in flight at a time: a stalled POST is superseded, and the
137
+ // terminal flush aborts any straggler so it can never delay process exit.
138
+ inflightHeartbeat?.abort();
139
+ const controller = new AbortController();
140
+ inflightHeartbeat = controller;
141
+ const timer = setTimeout(() => controller.abort(), HEARTBEAT_TIMEOUT_MS);
142
+ timer.unref?.();
143
+ void fetchImpl(`${broker.baseUrl}/api/cli/run`, {
144
+ method: "POST",
145
+ headers: { Authorization: `Bearer ${broker.accessToken}`, "Content-Type": "application/json" },
146
+ body: JSON.stringify({
147
+ command: context.command,
148
+ clientRunId: context.clientRunId,
149
+ status: "in_progress",
150
+ startedAt: context.startedAt,
151
+ finishedAt: at,
152
+ durationMs: at - context.startedAt,
153
+ progress: {
154
+ // Stage names + counts only — never CRM field values.
155
+ stage: snapshot.stage,
156
+ stageIndex: snapshot.stageIndex,
157
+ stageCount: snapshot.stageCount,
158
+ itemsDone: snapshot.itemsDone,
159
+ itemsTotal: snapshot.itemsTotal,
160
+ note: snapshot.note,
161
+ heartbeatAt: at,
162
+ },
163
+ }),
164
+ signal: controller.signal,
165
+ })
166
+ .catch(() => {
167
+ // fire-and-forget
168
+ })
169
+ .finally(() => {
170
+ clearTimeout(timer);
171
+ if (inflightHeartbeat === controller)
172
+ inflightHeartbeat = null;
173
+ });
174
+ }
46
175
  /**
47
176
  * Send the run record if the CLI is paired. Best-effort: a 4s-capped POST that
48
177
  * swallows every error. Call once per process from the entry point.
49
178
  */
50
179
  export async function flushRunReport(args, status, startedAt, error) {
51
- const command = args[0];
52
- if (!command || SKIP_COMMANDS.has(command))
180
+ // The terminal upsert supersedes any streaming heartbeat still in flight.
181
+ inflightHeartbeat?.abort();
182
+ inflightHeartbeat = null;
183
+ // Stable per-invocation identity: the server dedupes on clientRunId, so a
184
+ // retried report, an "in_progress" heartbeat, or a later `backfill runs`
185
+ // replay of local history never duplicates.
186
+ const identity = runIdentity(args, startedAt);
187
+ if (!identity)
53
188
  return;
54
189
  const broker = getCredential("broker");
55
190
  if (!broker?.baseUrl || !broker.accessToken)
56
191
  return; // opt-in: only when paired
57
- const sub = args[1] && !args[1].startsWith("-") ? ` ${args[1]}` : "";
58
192
  const finishedAt = Date.now();
59
193
  try {
60
194
  await fetch(`${broker.baseUrl.replace(/\/+$/, "")}/api/cli/run`, {
61
195
  method: "POST",
62
196
  headers: { Authorization: `Bearer ${broker.accessToken}`, "Content-Type": "application/json" },
63
197
  body: JSON.stringify({
64
- command: `${command}${sub}`,
198
+ command: identity.command,
199
+ clientRunId: identity.clientRunId,
65
200
  status,
66
201
  startedAt,
67
202
  finishedAt,
package/dist/types.d.ts CHANGED
@@ -15,7 +15,8 @@ export type PatchOperationType = "set_field" | "clear_field" | "link_record" | "
15
15
  * The afterValue of a `create_record` operation. The connector re-resolves on
16
16
  * `matchKey`/`matchValue` at apply time and creates only on a confirmed miss.
17
17
  * `estCostUsd` is the acquire meter's per-record charge, recorded against the
18
- * budget on a successful create.
18
+ * budget on a successful create. Emitted by `enrich acquire` (metered) and
19
+ * `backfill stripe` (closed-won deals from paid invoices, unmetered).
19
20
  */
20
21
  export type CreateRecordPayload = {
21
22
  properties: Record<string, string>;
@@ -39,6 +40,22 @@ export type CreateRecordPayload = {
39
40
  ownerId?: string;
40
41
  /** Audit label for how the owner was chosen (e.g. "fixed", "territory:0"). */
41
42
  assignedBy?: string;
43
+ /**
44
+ * Deal creates only: provider-neutral stage sentinel. "closed_won" tells the
45
+ * connector to resolve the target pipeline's REAL closed-won stage id from
46
+ * the provider's pipeline metadata at apply time (HubSpot: stage with
47
+ * `metadata.isClosed` true and probability 1) — never by substring-guessing
48
+ * a stage name. If no such stage is resolvable the operation is skipped.
49
+ * Plain deal properties (amount, closedate, dealname, …) travel in
50
+ * `properties` as provider property names, like every other create.
51
+ */
52
+ dealStage?: "closed_won";
53
+ /**
54
+ * Deal creates only: which pipeline to create the deal in — a pipeline id
55
+ * or a case-insensitive pipeline label. Absent = the portal's default
56
+ * pipeline (lowest displayOrder).
57
+ */
58
+ dealPipeline?: string;
42
59
  };
43
60
  export type AuditFindingSeverity = "info" | "warning" | "critical";
44
61
  /**
@@ -418,6 +435,21 @@ export type GtmConnector = {
418
435
  * result per input op. Optional: connectors without it use `applyOperation`.
419
436
  */
420
437
  applyCreateContactsBatch?: (operations: PatchOperation[]) => Promise<PatchOperationResult[]>;
438
+ /**
439
+ * Optional bulk apply path for homogeneous operation runs selected by
440
+ * `applyPatchPlan` (currently consecutive `set_field` ops on one object type
441
+ * and consecutive `archive_record` ops on one object type). Implementations
442
+ * MUST return exactly one result per input operation, preserve per-operation
443
+ * CAS/conflict semantics for field writes, and must not write operations that
444
+ * would have conflicted under the serial `readField` + `applyOperation` path.
445
+ * Connectors without it use `applyOperation` per operation.
446
+ */
447
+ applyBatch?: (operations: PatchOperation[]) => Promise<PatchOperationResult[]>;
448
+ /**
449
+ * Maximum number of operations `applyPatchPlan` should pass to one
450
+ * `applyBatch` call. Defaults to 200 when unspecified.
451
+ */
452
+ applyBatchLimit?: number;
421
453
  /**
422
454
  * Read the live value of one canonical field, used for compare-and-set:
423
455
  * apply orchestration refuses to write over values that drifted since the
package/docs/api.md CHANGED
@@ -37,9 +37,10 @@ release.
37
37
 
38
38
  ## Connectors
39
39
 
40
- - `GtmConnector` — `{ provider, fetchSnapshot(), applyOperation?, applyCreateContactsBatch?, readField?, fetchChanges? }`.
40
+ - `GtmConnector` — `{ provider, fetchSnapshot(), applyOperation?, applyCreateContactsBatch?, applyBatch?, applyBatchLimit?, readField?, fetchChanges? }`.
41
41
  - Connectors never silently drop unresolvable records; audits surface them.
42
42
  - `fetchChanges(sinceIso)` returns a partial snapshot; change feeds may omit associations.
43
+ - `applyBatch?(operations)` — optional bulk apply path for consecutive homogeneous `set_field`, `clear_field`, or `archive_record` runs selected by `applyPatchPlan`. It must return one result per input operation; field-write implementations preserve CAS by batch-reading live target values first and writing only clean records. Connectors without it stay on the sequential `applyOperation` path. `applyBatchLimit?` declares max operations per `applyBatch` call (default 200; Salesforce 200, HubSpot 100). Salesforce uses SOQL `IN` CAS reads plus Composite sObject Collections update/delete (`allOrNone:false`); HubSpot uses batch/read + batch/update/archive.
43
44
  - `applyCreateContactsBatch?(operations)` — optional bulk fast-path for
44
45
  independent `create_record` contact ops. `applyPatchPlan` routes safe-to-batch
45
46
  creates here (batched resolve-first + batched create — ~N/100 calls instead of
@@ -47,9 +48,10 @@ release.
47
48
  for any op that isn't batch-safe (grouped, value-overridden, company-
48
49
  associated, conflicted). HubSpot: `search IN` + `batch/create`; Salesforce:
49
50
  SOQL `IN` + Composite sObject Collections (`allOrNone:false`).
50
- - `createHubspotConnector(options)` — read/write/readField/fetchChanges. `applyOperation` implements every `PatchOperationType`: `set_field`, `clear_field`, `link_record`, `create_task`, `create_record` (resolve-first net-new contact/company create — re-checks the dedupe key at apply, never double-creates), `archive_record`, `merge_records` (HubSpot v3 merge — pairwise, irreversible; survivor must belong to the duplicate group). (The Salesforce connector implements the same operation set, with the platform-specific constraints noted below.)
51
+ - `createHubspotConnector(options)` — read/write/readField/fetchChanges. `applyOperation` implements every `PatchOperationType`: `set_field`, `clear_field`, `link_record`, `create_task`, `create_record` (resolve-first net-new contact/company/**deal** create — re-checks the dedupe key at apply, never double-creates; a deal create requires `dealStage: "closed_won"`, resolved to the target pipeline's real closed-won stage id from pipeline metadata, and its dedupe key is a custom deal property ensured on demand), `archive_record`, `merge_records` (HubSpot v3 merge — pairwise, irreversible; survivor must belong to the duplicate group). (The Salesforce connector implements the same operation set, with the platform-specific constraints noted below.)
51
52
  - `createSalesforceConnector(options)` — read/write/readField/fetchChanges; probabilities normalized to 0..1. `applyOperation` implements every operation type, with two platform constraints: `merge_records` covers **Accounts and Contacts** only (Salesforce exposes no Opportunity merge), and `create_record` resolve-first creates **contacts/accounts** (SOQL search on the match key, create only on a confirmed miss; stamps the canonical `ownerId` onto `OwnerId`).
52
53
  - `createStripeConnector(options)` — read-only billing by design (`applyOperation` returns `skipped`); email domains are the cross-system merge keys. Implements `fetchChanges` (incremental via `created[gte]`).
54
+ - `fetchStripePaidInvoices(options, { sinceIso? })` — paginated `status=paid` invoice reads (`created[gte]` incremental, cents→major, paid-date extraction); feeds `buildStripeBackfillPlan(invoices, snapshot, opts)` (`src/backfill.ts`), which proposes one closed-won deal `create_record` per paid invoice, matched to CRM accounts by billing-email domain then exact name — an unmatched customer gets an explicit proposed account `create_record` in the same plan (freemail domains never used as company domain; `createMissingAccounts: false` restores report-only). Surfaced as the `backfill stripe` verb (plan-only; `--save` → approve → `apply`).
53
55
 
54
56
  ## Multi-system operations
55
57
 
@@ -72,6 +72,8 @@ provider ──fetchSnapshot()──► CanonicalGtmSnapshot
72
72
  - `market*.ts` (`market.ts`, `marketClassify.ts`, `marketAxes.ts`,
73
73
  `marketOverlay.ts`, `marketScale.ts`, `marketReport.ts`) — the competitive
74
74
  market-map layer; classifications are verbatim-verified against captures.
75
+ - `backfill.ts` — Stripe paid invoices → proposed closed-won deal creates
76
+ (domain-then-name account matching; unmatched reported, never auto-created).
75
77
  - `enrich.ts` + `enrichApollo.ts` — third-party data enrichment (fill-blanks),
76
78
  plus `buildAcquirePlan` / `builtinAcquirePreset` for net-new lead generation.
77
79
  - `icp.ts` — the Ideal Customer Profile artifact: per-provider discovery-filter
@@ -75,7 +75,7 @@ unstable base. Therefore:
75
75
  fake.
76
76
  2. **After the acquire workstream commits/lands:** wire `enrich acquire --source
77
77
  linkedin` end-to-end and add the dry-run integration test.
78
- 3. **Live validation** needs a HeyReach trial API key (Ryan provides) — the fake
78
+ 3. **Live validation** needs a HeyReach trial API key supplied by the operator — the fake
79
79
  covers everything up to the real network call.
80
80
 
81
81
  ## Phase 2 (future, recorded for continuity)
package/llms.txt CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > Plan/apply for your GTM stack: canonical CRM/GTM data model, deterministic
4
4
  > hygiene audits, reviewable dry-run patch plans, and approval-gated
5
- > write-back to HubSpot, Salesforce, and Stripe (read-only). Think
5
+ > write-back to HubSpot and Salesforce; Stripe is read-only. Think
6
6
  > `terraform plan` for your CRM. Apache-2.0.
7
7
 
8
8
  CLI quick check: `fullstackgtm doctor --json`. Zero-credential demo:
@@ -15,7 +15,7 @@ at/above `--fail-on`.
15
15
  - [README](https://github.com/fullstackgtm/core/blob/main/README.md): install, five-minute loop, auth ladder, MCP setup, programmatic use
16
16
  - [INSTALL_FOR_AGENTS](https://github.com/fullstackgtm/core/blob/main/INSTALL_FOR_AGENTS.md): deterministic install-and-verify steps with expected outputs
17
17
  - [Agent skill](https://github.com/fullstackgtm/core/blob/main/skills/fullstackgtm/SKILL.md): compact operating guide, installable via `npx skills add fullstackgtm/core`
18
- - [Recipes](https://github.com/fullstackgtm/core/blob/main/packages/fullstackgtm/docs/recipes.md): five composable GTM plays over the primitives (cold-start lead-fill, the trigger→judge→draft outbound loop, scheduled-continuous, ABM-from-companies, hygiene-gated outbound) — the CLI ships primitives, your agent is the orchestrator, the package never sends
18
+ - [Recipes](https://github.com/fullstackgtm/core/blob/main/docs/recipes.md): five composable GTM plays over the primitives (cold-start lead-fill, the trigger→judge→draft outbound loop, scheduled-continuous, ABM-from-companies, hygiene-gated outbound) — the CLI ships primitives, your agent is the orchestrator, the package never sends
19
19
  - [Architecture](https://github.com/fullstackgtm/core/blob/main/docs/architecture.md): module map + snapshot → audit → plan → apply data flow; "where do I add a rule/connector/operation"
20
20
  - [Contributing](https://github.com/fullstackgtm/core/blob/main/CONTRIBUTING.md): dev setup, the open-core mirror model, the release ritual
21
21
  - [API reference](https://github.com/fullstackgtm/core/blob/main/docs/api.md): semver-covered surfaces — canonical model, rule interface, plan/apply contract, connector contract, config, CLI, MCP tools
@@ -202,7 +202,7 @@ outbox is the send-side mirror of the spool; the always-on sender is hosted/opt-
202
202
  --contact <id> --result replied|...` credits the contact reached so weights
203
203
  re-learn. The contact↔account join (`contact.accountId` + `account.domain`) is
204
204
  surfaced at every hop, so the loop is contact-coherent end to end — see the
205
- [recipes](https://github.com/fullstackgtm/core/blob/main/packages/fullstackgtm/docs/recipes.md).
205
+ [recipes](https://github.com/fullstackgtm/core/blob/main/docs/recipes.md).
206
206
 
207
207
  ## Key invariants (schedule)
208
208
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullstackgtm",
3
- "version": "0.45.0",
3
+ "version": "0.47.0",
4
4
  "description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Full Stack GTM LLC <ryan@fullstackgtm.com> (https://fullstackgtm.com)",
@@ -25,7 +25,14 @@
25
25
  "files": [
26
26
  "dist",
27
27
  "src",
28
- "docs",
28
+ "docs/api.md",
29
+ "docs/architecture.md",
30
+ "docs/crm-health-lifecycle.md",
31
+ "docs/linkedin-connector-spec.md",
32
+ "docs/outbox-format.md",
33
+ "docs/recipes.md",
34
+ "docs/signal-spool-format.md",
35
+ "docs/tam.md",
29
36
  "README.md",
30
37
  "CHANGELOG.md",
31
38
  "INSTALL_FOR_AGENTS.md",
@@ -41,7 +48,7 @@
41
48
  "build": "rm -rf dist && tsc -p tsconfig.build.json",
42
49
  "pretest": "test -d tests || { echo 'No tests/ in this directory. In the published mirror, tests live here and `npm test` works. In the monorepo, run package tests from the repo ROOT: `node --experimental-strip-types --test tests/fullstackgtm*.test.ts` (the tests live at the monorepo root tests/).' >&2; exit 1; }",
43
50
  "test": "node --experimental-strip-types --test tests/*.test.ts",
44
- "prepublishOnly": "npm run build"
51
+ "prepublishOnly": "npm run build && npm pack --dry-run && node dist/bin.js --help && node dist/mcp-bin.js --help"
45
52
  },
46
53
  "devDependencies": {
47
54
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -60,6 +60,7 @@ credentials AND stored plans per client org.
60
60
  | `bulk-update <object> --where … --set\|--archive\|--create-task` | Filtered dry-run plan; filter re-verified per record at apply time |
61
61
  | `reassign --from <owner> --to <owner>` | Ownership handoff plans per object type; `--assign-unowned --to <id>` backfills every ownerless record |
62
62
  | `fix --rule <id>` | audit one rule → suggest → approve at the confidence bar → apply only with `--yes` |
63
+ | `backfill stripe\|runs [--since <iso>] [--pipeline <id\|label>]` | Paid Stripe invoices → proposed closed-won deals (amount = invoice total, close date = paid date, company matched by billing-email domain then name); deduped by a `stripe_invoice_id` deal property re-resolved at apply, so re-running never double-creates; a customer the CRM doesn't know gets a proposed account create in the same plan (freemail domains never used; `--skip-unmatched` = report-only); `--save` → approve → `apply`. `backfill runs` replays LOCAL run history + health timeline to the paired hosted app (idempotent; `--dry-run` to preview) |
63
64
  | `call parse\|score\|link\|plan` | Transcripts → evidence-quoted insights, rubric scorecards, deal linking, governed next-step writes |
64
65
  | `enrich append\|refresh\|ingest\|status` | Governed enrichment (Apollo pull / Clay ingest), fill-blanks-only plans |
65
66
  | `enrich acquire [--source explorium\|pipe0\|linkedin]` | Net-new ICP-targeted lead gen: resolve-first deduped `create_record` plans, capped by a per-profile windowed meter (records + spend); owner-stamped via `acquire.assign`/`--assign-owner` (never born ownerless); links each lead to a domain-stamped, signal-watchable account; `acquire.create.company` acquires accounts (ABM); `--source linkedin --list <id>` reads a HeyReach lead list (Phase 1 discovery, read-only — never sends); never auto-writes |