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.
- package/CHANGELOG.md +115 -1
- package/INSTALL_FOR_AGENTS.md +13 -11
- package/README.md +27 -18
- package/dist/backfill.d.ts +86 -0
- package/dist/backfill.js +251 -0
- package/dist/bin.js +4 -1
- package/dist/cli/auth.d.ts +2 -0
- package/dist/cli/auth.js +119 -12
- package/dist/cli/backfill.d.ts +1 -0
- package/dist/cli/backfill.js +125 -0
- package/dist/cli/backfillRuns.d.ts +1 -0
- package/dist/cli/backfillRuns.js +187 -0
- package/dist/cli/call.js +23 -9
- package/dist/cli/enrich.js +28 -10
- package/dist/cli/fix.js +9 -7
- package/dist/cli/help.js +60 -23
- package/dist/cli/plans.js +13 -11
- package/dist/cli/shared.d.ts +4 -3
- package/dist/cli/shared.js +31 -20
- package/dist/cli/ui.d.ts +21 -0
- package/dist/cli/ui.js +53 -1
- package/dist/cli.js +37 -41
- package/dist/connector.d.ts +8 -0
- package/dist/connector.js +104 -21
- package/dist/connectors/hubspot.d.ts +8 -1
- package/dist/connectors/hubspot.js +406 -13
- package/dist/connectors/hubspotAuth.js +5 -1
- package/dist/connectors/linkedin.js +14 -14
- package/dist/connectors/salesforce.d.ts +8 -1
- package/dist/connectors/salesforce.js +163 -2
- package/dist/connectors/stripe.d.ts +37 -0
- package/dist/connectors/stripe.js +103 -31
- package/dist/enrich.d.ts +7 -0
- package/dist/enrich.js +7 -0
- package/dist/health.d.ts +11 -69
- package/dist/health.js +4 -134
- package/dist/healthScore.d.ts +71 -0
- package/dist/healthScore.js +143 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/llm.d.ts +29 -0
- package/dist/llm.js +206 -0
- package/dist/market.d.ts +39 -1
- package/dist/market.js +116 -44
- package/dist/marketClassify.d.ts +9 -1
- package/dist/marketClassify.js +10 -1
- package/dist/marketTaxonomy.d.ts +6 -1
- package/dist/marketTaxonomy.js +4 -2
- package/dist/mcp-bin.js +29 -0
- package/dist/mcp.js +117 -4
- package/dist/progress.d.ts +96 -0
- package/dist/progress.js +142 -0
- package/dist/runReport.d.ts +24 -0
- package/dist/runReport.js +139 -4
- package/dist/types.d.ts +33 -1
- package/docs/api.md +4 -2
- package/docs/architecture.md +2 -0
- package/docs/linkedin-connector-spec.md +1 -1
- package/llms.txt +3 -3
- package/package.json +10 -3
- package/skills/fullstackgtm/SKILL.md +1 -0
- package/src/backfill.ts +340 -0
- package/src/bin.ts +5 -1
- package/src/cli/auth.ts +135 -15
- package/src/cli/backfill.ts +156 -0
- package/src/cli/backfillRuns.ts +198 -0
- package/src/cli/call.ts +26 -10
- package/src/cli/enrich.ts +33 -10
- package/src/cli/fix.ts +11 -10
- package/src/cli/help.ts +61 -23
- package/src/cli/plans.ts +15 -14
- package/src/cli/shared.ts +44 -27
- package/src/cli/ui.ts +72 -1
- package/src/cli.ts +38 -41
- package/src/connector.ts +110 -16
- package/src/connectors/hubspot.ts +423 -14
- package/src/connectors/hubspotAuth.ts +5 -1
- package/src/connectors/linkedin.ts +14 -14
- package/src/connectors/salesforce.ts +168 -3
- package/src/connectors/stripe.ts +154 -34
- package/src/enrich.ts +13 -0
- package/src/health.ts +14 -213
- package/src/healthScore.ts +223 -0
- package/src/index.ts +28 -1
- package/src/llm.ts +239 -0
- package/src/market.ts +157 -44
- package/src/marketClassify.ts +18 -1
- package/src/marketTaxonomy.ts +10 -2
- package/src/mcp-bin.ts +32 -0
- package/src/mcp.ts +140 -6
- package/src/progress.ts +197 -0
- package/src/runReport.ts +159 -4
- package/src/types.ts +35 -2
- package/docs/dx-punch-list.md +0 -87
- package/docs/roadmap-to-1.0.md +0 -211
package/dist/progress.js
ADDED
|
@@ -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
|
+
}
|
package/dist/runReport.d.ts
CHANGED
|
@@ -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
|
-
|
|
52
|
-
|
|
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:
|
|
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
|
|
package/docs/architecture.md
CHANGED
|
@@ -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
|
|
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
|
|
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/
|
|
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/
|
|
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.
|
|
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 |
|