fullstackgtm 0.48.0 → 0.50.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 +91 -0
- package/CONTRIBUTING.md +23 -4
- package/DATA-FLOWS.md +8 -2
- package/INSTALL_FOR_AGENTS.md +13 -4
- package/README.md +53 -1
- package/SECURITY.md +27 -3
- package/dist/acquireCheckpoint.d.ts +33 -0
- package/dist/acquireCheckpoint.js +149 -0
- package/dist/acquireLinkedIn.d.ts +2 -0
- package/dist/acquireLinkedIn.js +1 -1
- package/dist/cli/audit.js +10 -7
- package/dist/cli/auth.js +8 -4
- package/dist/cli/enrich.js +283 -79
- package/dist/cli/fix.js +20 -7
- package/dist/cli/help.js +23 -15
- package/dist/cli/market.js +180 -2
- package/dist/cli/plans.js +154 -9
- package/dist/cli/shared.d.ts +3 -1
- package/dist/cli/shared.js +2 -2
- package/dist/cli/ui.d.ts +10 -5
- package/dist/cli/ui.js +18 -5
- package/dist/cli.js +1 -1
- package/dist/config.d.ts +13 -1
- package/dist/config.js +23 -2
- package/dist/connectors/linkedin.d.ts +2 -0
- package/dist/connectors/linkedin.js +5 -0
- package/dist/connectors/prospectSources.d.ts +23 -0
- package/dist/connectors/prospectSources.js +25 -14
- package/dist/connectors/salesforce.js +12 -5
- package/dist/connectors/salesforceAuth.d.ts +9 -0
- package/dist/connectors/salesforceAuth.js +47 -5
- package/dist/connectors/theirstack.d.ts +0 -19
- package/dist/connectors/theirstack.js +3 -10
- package/dist/credentials.js +36 -26
- package/dist/enrich.d.ts +44 -1
- package/dist/hostedAcquireCheckpoint.d.ts +43 -0
- package/dist/hostedAcquireCheckpoint.js +129 -0
- package/dist/hostedPatchPlan.d.ts +87 -0
- package/dist/hostedPatchPlan.js +270 -0
- package/dist/icp.js +13 -2
- package/dist/index.d.ts +7 -3
- package/dist/index.js +6 -2
- package/dist/market.d.ts +1 -1
- package/dist/market.js +7 -127
- package/dist/marketClassify.d.ts +10 -0
- package/dist/marketClassify.js +52 -1
- package/dist/marketSourcing.js +7 -45
- package/dist/mcp.js +67 -115
- package/dist/planStore.d.ts +42 -1
- package/dist/planStore.js +268 -49
- package/dist/progress.d.ts +2 -2
- package/dist/progress.js +2 -2
- package/dist/providerError.d.ts +21 -0
- package/dist/providerError.js +30 -0
- package/dist/publicHttp.d.ts +28 -0
- package/dist/publicHttp.js +143 -0
- package/dist/secureFile.d.ts +15 -0
- package/dist/secureFile.js +164 -0
- package/dist/types.d.ts +1 -1
- package/docs/api.md +53 -2
- package/docs/architecture.md +22 -2
- package/llms.txt +7 -0
- package/package.json +5 -3
- package/skills/fullstackgtm/SKILL.md +8 -2
- package/src/acquireCheckpoint.ts +186 -0
- package/src/acquireLinkedIn.ts +3 -1
- package/src/cli/audit.ts +10 -7
- package/src/cli/auth.ts +8 -4
- package/src/cli/enrich.ts +322 -78
- package/src/cli/fix.ts +28 -7
- package/src/cli/help.ts +24 -15
- package/src/cli/market.ts +181 -2
- package/src/cli/plans.ts +159 -9
- package/src/cli/shared.ts +2 -1
- package/src/cli/ui.ts +19 -9
- package/src/cli.ts +1 -1
- package/src/config.ts +39 -1
- package/src/connectors/linkedin.ts +6 -0
- package/src/connectors/prospectSources.ts +50 -15
- package/src/connectors/salesforce.ts +12 -5
- package/src/connectors/salesforceAuth.ts +46 -6
- package/src/connectors/theirstack.ts +3 -10
- package/src/credentials.ts +47 -28
- package/src/enrich.ts +47 -1
- package/src/hostedAcquireCheckpoint.ts +156 -0
- package/src/hostedPatchPlan.ts +286 -0
- package/src/icp.ts +14 -2
- package/src/index.ts +24 -0
- package/src/market.ts +7 -111
- package/src/marketClassify.ts +61 -1
- package/src/marketSourcing.ts +7 -43
- package/src/mcp.ts +93 -136
- package/src/planStore.ts +322 -57
- package/src/progress.ts +2 -2
- package/src/providerError.ts +37 -0
- package/src/publicHttp.ts +147 -0
- package/src/secureFile.ts +169 -0
- package/src/types.ts +1 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { chmodSync, closeSync, constants, fsyncSync, fchmodSync, fstatSync, lstatSync, openSync, renameSync, unlinkSync, writeFileSync, readFileSync, } from "node:fs";
|
|
3
|
+
import { dirname, isAbsolute, parse, resolve } from "node:path";
|
|
4
|
+
export class UnsafeManagedPathError extends Error {
|
|
5
|
+
constructor(message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "UnsafeManagedPathError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export function assertNoSymlinkComponents(path) {
|
|
11
|
+
const absolute = isAbsolute(path) ? path : resolve(path);
|
|
12
|
+
const root = parse(absolute).root;
|
|
13
|
+
const relative = absolute.slice(root.length);
|
|
14
|
+
let current = root;
|
|
15
|
+
for (const component of relative.split(/[\\/]+/).filter(Boolean)) {
|
|
16
|
+
current = resolve(current, component);
|
|
17
|
+
let stat;
|
|
18
|
+
try {
|
|
19
|
+
stat = lstatSync(current);
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error.code === "ENOENT")
|
|
23
|
+
return;
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
if (stat.isSymbolicLink()) {
|
|
27
|
+
throw new UnsafeManagedPathError(`Refusing to use unsafe path containing a symbolic link: ${current}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Read a managed file through a no-follow descriptor and require a regular file. */
|
|
32
|
+
export function readSecureRegularFile(path, options = {}) {
|
|
33
|
+
const absolute = resolve(path);
|
|
34
|
+
assertNoSymlinkComponents(dirname(absolute));
|
|
35
|
+
try {
|
|
36
|
+
if (lstatSync(absolute).isSymbolicLink()) {
|
|
37
|
+
throw new UnsafeManagedPathError(`Refusing to read symbolic link: ${absolute}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (error.code !== "ENOENT")
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
const noFollow = "O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0;
|
|
45
|
+
let fd;
|
|
46
|
+
try {
|
|
47
|
+
fd = openSync(absolute, constants.O_RDONLY | noFollow);
|
|
48
|
+
const stat = fstatSync(fd);
|
|
49
|
+
if (!stat.isFile()) {
|
|
50
|
+
throw new UnsafeManagedPathError(`Refusing to read non-regular file: ${absolute}`);
|
|
51
|
+
}
|
|
52
|
+
if (options.tightenMode !== undefined) {
|
|
53
|
+
const previousMode = stat.mode & 0o777;
|
|
54
|
+
if ((previousMode & ~options.tightenMode) !== 0) {
|
|
55
|
+
try {
|
|
56
|
+
fchmodSync(fd, options.tightenMode);
|
|
57
|
+
options.onModeTightened?.(previousMode);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Windows and some non-POSIX filesystems do not implement chmod.
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return readFileSync(fd, "utf8");
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (error.code === "ELOOP") {
|
|
68
|
+
throw new UnsafeManagedPathError(`Refusing to read symbolic link: ${absolute}`);
|
|
69
|
+
}
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
if (fd !== undefined)
|
|
74
|
+
closeSync(fd);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Atomically replace a private file without ever opening the destination.
|
|
79
|
+
* Existing destination and parent symlinks are rejected. The temporary file
|
|
80
|
+
* is created exclusively beside the destination so rename remains atomic.
|
|
81
|
+
*/
|
|
82
|
+
export function writeSecureFileAtomic(path, contents) {
|
|
83
|
+
const destination = resolve(path);
|
|
84
|
+
const parent = dirname(destination);
|
|
85
|
+
assertNoSymlinkComponents(parent);
|
|
86
|
+
try {
|
|
87
|
+
if (lstatSync(destination).isSymbolicLink()) {
|
|
88
|
+
throw new Error(`Refusing to replace symbolic link: ${destination}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
if (error.code !== "ENOENT")
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
let temporary = "";
|
|
96
|
+
let fd;
|
|
97
|
+
try {
|
|
98
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
99
|
+
temporary = `${destination}.tmp-${process.pid}-${randomBytes(8).toString("hex")}`;
|
|
100
|
+
try {
|
|
101
|
+
const noFollow = "O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0;
|
|
102
|
+
fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollow, 0o600);
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (error.code !== "EEXIST")
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (fd === undefined)
|
|
111
|
+
throw new Error(`Unable to create an exclusive temporary file for ${destination}`);
|
|
112
|
+
writeFileSync(fd, contents);
|
|
113
|
+
fsyncSync(fd);
|
|
114
|
+
closeSync(fd);
|
|
115
|
+
fd = undefined;
|
|
116
|
+
try {
|
|
117
|
+
chmodSync(temporary, 0o600);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// Windows and some non-POSIX filesystems do not implement chmod.
|
|
121
|
+
}
|
|
122
|
+
// Recheck immediately before replacement. rename replaces a symlink rather
|
|
123
|
+
// than following it, but rejecting one makes managed-path policy explicit.
|
|
124
|
+
try {
|
|
125
|
+
if (lstatSync(destination).isSymbolicLink()) {
|
|
126
|
+
throw new Error(`Refusing to replace symbolic link: ${destination}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
if (error.code !== "ENOENT")
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
assertNoSymlinkComponents(parent);
|
|
134
|
+
renameSync(temporary, destination);
|
|
135
|
+
temporary = "";
|
|
136
|
+
// Persist the directory entry where supported. Opening/fsyncing a
|
|
137
|
+
// directory is not available on Windows, so this durability enhancement is
|
|
138
|
+
// deliberately best-effort there.
|
|
139
|
+
let directoryFd;
|
|
140
|
+
try {
|
|
141
|
+
directoryFd = openSync(parent, constants.O_RDONLY);
|
|
142
|
+
fsyncSync(directoryFd);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
// Unsupported by the platform/filesystem.
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
if (directoryFd !== undefined)
|
|
149
|
+
closeSync(directoryFd);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
if (fd !== undefined)
|
|
154
|
+
closeSync(fd);
|
|
155
|
+
if (temporary) {
|
|
156
|
+
try {
|
|
157
|
+
unlinkSync(temporary);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
// Best-effort cleanup after a failed write.
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
export type CrmProvider = "salesforce" | "hubspot" | "mock" | "unknown" | (string & {});
|
|
9
9
|
export type RiskLevel = "low" | "medium" | "high";
|
|
10
|
-
export type ApprovalStatus = "draft" | "needs_approval" | "approved" | "rejected" | "applied";
|
|
10
|
+
export type ApprovalStatus = "draft" | "needs_approval" | "approved" | "applying" | "rejected" | "applied";
|
|
11
11
|
export type GtmObjectType = "account" | "contact" | "deal" | "user" | "activity";
|
|
12
12
|
export type GtmEvidenceSourceSystem = "salesforce" | "hubspot" | "gong" | "chorus" | "fathom" | "manual" | "csv" | "mock" | "web" | "unknown";
|
|
13
13
|
export type PatchOperationType = "set_field" | "clear_field" | "link_record" | "archive_record" | "create_task" | "create_record" | "merge_records";
|
package/docs/api.md
CHANGED
|
@@ -62,6 +62,12 @@ release.
|
|
|
62
62
|
|
|
63
63
|
- `fullstackgtm.config.json`: `{ policy?, rules?: { enabled?, disabled? }, rulePackages? }`.
|
|
64
64
|
- Rule packages export `rules: GtmAuditRule[]`.
|
|
65
|
+
- Rule packages are executable code and are fail-closed. CLI execution requires
|
|
66
|
+
an explicit `--config <path> --allow-plugins`; `--no-plugins` applies only
|
|
67
|
+
declarative policy/rule filters. An implicitly discovered config never grants
|
|
68
|
+
plugin trust.
|
|
69
|
+
- MCP never executes rule packages. Mutable tool-call paths are not accepted as
|
|
70
|
+
a durable code-trust boundary; use the reviewed CLI plugin flow instead.
|
|
65
71
|
- Precedence: CLI flags > config file > defaults.
|
|
66
72
|
|
|
67
73
|
## CLI
|
|
@@ -223,6 +229,15 @@ webhook landing-zone format (docs/signal-spool-format.md).
|
|
|
223
229
|
|
|
224
230
|
## Acquire (net-new lead generation)
|
|
225
231
|
|
|
232
|
+
API discovery is continuation-aware. Completed acquire runs persist a
|
|
233
|
+
provider/source/list/query-keyed Pipe0 cursor, Explorium page, or HeyReach list
|
|
234
|
+
offset, plus the discovered → qualified → deduped → resolved → proposed funnel.
|
|
235
|
+
Each list progresses independently. If a broker credential exists, GET/POST
|
|
236
|
+
`/api/cli/acquisition-checkpoint` synchronizes the opaque checkpoint inside the
|
|
237
|
+
token's organization using numeric compare-and-swap revisions; unpaired and
|
|
238
|
+
offline CLIs remain local-first. A changed ICP/list starts a new traversal. CLI
|
|
239
|
+
`--max` sets desired new leads while `--scan-limit` bounds raw candidates.
|
|
240
|
+
|
|
226
241
|
`buildAcquirePlan` turns sourced-but-unmatched prospects into `create_record`
|
|
227
242
|
operations (matched / ambiguous are skipped — resolve-first never creates over
|
|
228
243
|
a possible dup), capped by the meter's headroom. `builtinAcquirePreset` is the
|
|
@@ -283,8 +298,40 @@ domain stamped — so the acquired account is immediately signal-watchable
|
|
|
283
298
|
`CanonicalContact.linkedin` (mapped from HubSpot `hs_linkedin_url`) is the
|
|
284
299
|
strong dedup key across all of the above.
|
|
285
300
|
|
|
301
|
+
## Apply recovery
|
|
302
|
+
|
|
303
|
+
Store-backed applies persist an apply-attempt claim before provider I/O. If the
|
|
304
|
+
process exits without a completed run, the plan remains `applying`: the CLI
|
|
305
|
+
cannot know whether a remote write landed and will not replay it. Inspect the
|
|
306
|
+
attempt with `fullstackgtm plans show <id>` and reconcile the named provider's
|
|
307
|
+
records. Only then run:
|
|
308
|
+
|
|
309
|
+
```bash
|
|
310
|
+
fullstackgtm plans recover <id> --acknowledge-uncertain-writes
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
Recovery performs no provider calls. It clears the old approvals and returns
|
|
314
|
+
the plan to `needs_approval`, requiring a fresh review and signed approval
|
|
315
|
+
before another apply. Provider APIs do not all expose idempotency keys, so this
|
|
316
|
+
operator reconciliation remains necessary after a timeout or crash.
|
|
317
|
+
|
|
286
318
|
## Schedule
|
|
287
319
|
|
|
320
|
+
## Hosted patch-plan mirror
|
|
321
|
+
|
|
322
|
+
`uploadHostedPatchPlan(stored)` mirrors a bounded review document when the
|
|
323
|
+
active profile has a secure broker pairing. `readHostedPatchPlan(planId)` reads
|
|
324
|
+
the org-scoped hosted decision, and `reportHostedPlanLifecycle(stored)` reflects
|
|
325
|
+
local approval/rejection/application. Upload is local-first and best-effort;
|
|
326
|
+
immutable hash conflicts fail closed. Hosted approval does not carry local
|
|
327
|
+
signing material: `apply --plan-id` verifies the hash and operation set before
|
|
328
|
+
`PlanStore.approveOperations` generates machine-local approval digests.
|
|
329
|
+
|
|
330
|
+
The hosted transport intentionally excludes findings, evidence, signing
|
|
331
|
+
digests/keys, apply claims and attempt notes, provider results, and raw run
|
|
332
|
+
errors. Operation before/after values are included because the paired
|
|
333
|
+
organization needs them to review the proposed CRM changes.
|
|
334
|
+
|
|
288
335
|
The horizontal scheduler: a declarative schedule-entry store, a
|
|
289
336
|
dependency-free 5-field cron parser, and the read/plan-side `SCHEDULABLE`
|
|
290
337
|
allowlist. `validateSchedulableArgv` enforces the allowlist at `schedule add`
|
|
@@ -347,8 +394,12 @@ the stored capture it cites before a set is accepted; failed captures read as
|
|
|
347
394
|
Tools: `fullstackgtm_capabilities` (the machine-readable server contract —
|
|
348
395
|
tool inventory with per-tool CRM-write flags, derived from the server's own
|
|
349
396
|
registration table), `fullstackgtm_audit`, `fullstackgtm_rules`,
|
|
350
|
-
`fullstackgtm_apply` (
|
|
397
|
+
`fullstackgtm_apply` (accepts only a stored `planId`; approved operation ids and
|
|
398
|
+
placeholder values come exclusively from the HMAC-signed plan store),
|
|
351
399
|
`fullstackgtm_suggest`, `fullstackgtm_call_parse`, `fullstackgtm_resolve`,
|
|
352
400
|
`fullstackgtm_market_worksheet`, `fullstackgtm_market_observe` (validates,
|
|
353
401
|
verifies quoted spans against the stored captures, appends, returns front
|
|
354
|
-
states). Input schemas are stable.
|
|
402
|
+
states). Input schemas are stable. MCP never executes rule packages and cannot
|
|
403
|
+
accept external plan files or caller-supplied approvals/value overrides. An
|
|
404
|
+
uncertain apply is reconciled with the CLI `plans recover` workflow and is
|
|
405
|
+
never replayed automatically.
|
package/docs/architecture.md
CHANGED
|
@@ -27,14 +27,22 @@ provider ──fetchSnapshot()──► CanonicalGtmSnapshot
|
|
|
27
27
|
plans approve (planStore.ts + integrity.ts: HMAC-signed)
|
|
28
28
|
│
|
|
29
29
|
▼
|
|
30
|
+
atomic apply claim + durable attempt journal
|
|
31
|
+
│
|
|
32
|
+
▼
|
|
30
33
|
applyPatchPlan (connector.ts): for each APPROVED op —
|
|
31
34
|
compare-and-set vs live value, precondition + guard recheck,
|
|
32
35
|
irreversible-op drift guard → connector.applyOperation()
|
|
33
36
|
│
|
|
34
37
|
▼
|
|
35
|
-
PatchPlanRun (
|
|
38
|
+
PatchPlanRun (claim-bound audit record)
|
|
36
39
|
```
|
|
37
40
|
|
|
41
|
+
An interrupted provider attempt stays `applying`/uncertain. Recovery never
|
|
42
|
+
replays automatically: reconcile provider state, then `plans recover ...
|
|
43
|
+
--acknowledge-uncertain-writes` clears approvals and returns the plan to
|
|
44
|
+
`needs_approval` for a fresh signed review.
|
|
45
|
+
|
|
38
46
|
## Module map (`src/`)
|
|
39
47
|
|
|
40
48
|
**Contracts & model**
|
|
@@ -49,12 +57,15 @@ provider ──fetchSnapshot()──► CanonicalGtmSnapshot
|
|
|
49
57
|
- `audit.ts` — runs rules over a snapshot into a `PatchPlan`.
|
|
50
58
|
- `suggest.ts` — derives concrete values for `requires_human_*` placeholders from
|
|
51
59
|
snapshot evidence, with confidence + reasons.
|
|
52
|
-
- `planStore.ts` — durable plan lifecycle (save / approve /
|
|
60
|
+
- `planStore.ts` — durable plan lifecycle (save / approve / atomic claim /
|
|
61
|
+
attempt journal / no-replay recovery / claim-bound runs), atomic 0600 files.
|
|
53
62
|
- `integrity.ts` — HMAC-signs approvals (per-install key) and verifies at apply.
|
|
54
63
|
- `connector.ts` — `applyPatchPlan`: the safety contract (approval filter,
|
|
55
64
|
placeholder refusal, CAS, precondition/guard recheck, irreversible-op drift
|
|
56
65
|
guard). Provider-agnostic.
|
|
57
66
|
- `auditLog.ts` — hash-chained, signed export of every apply run.
|
|
67
|
+
- `secureFile.ts` — atomic no-follow sensitive-file reads/writes.
|
|
68
|
+
- `publicHttp.ts` — DNS-pinned, redirect-validating, bounded public-only HTTP.
|
|
58
69
|
|
|
59
70
|
**Governed write verbs (each builds a plan; never writes directly)**
|
|
60
71
|
- `bulkUpdate.ts`, `dedupe.ts`, `reassign.ts`, `route.ts` — filtered,
|
|
@@ -83,6 +94,15 @@ provider ──fetchSnapshot()──► CanonicalGtmSnapshot
|
|
|
83
94
|
translation, prospect fit scoring, and the agent-driven interview spec.
|
|
84
95
|
- `acquireMeter.ts` — per-profile windowed budget (records + spend) for acquire.
|
|
85
96
|
- `acquireSeen.ts` — cross-run "seen" cache so re-runs don't re-pay for dupes.
|
|
97
|
+
- `acquireCheckpoint.ts` — secure profile-scoped, provider/source/list/query-keyed
|
|
98
|
+
continuation store; each audience advances independently.
|
|
99
|
+
- `hostedAcquireCheckpoint.ts` — optional broker-authenticated checkpoint
|
|
100
|
+
transport. Organization identity is resolved server-side; numeric CAS
|
|
101
|
+
revisions prevent stale workers from overwriting newer continuation.
|
|
102
|
+
- `hostedPatchPlan.ts` — best-effort paired-CLI plan mirroring and decision
|
|
103
|
+
reconciliation. Review documents are immutable/hash-bound; hosted approval
|
|
104
|
+
becomes executable only after the CLI verifies it and generates local HMAC
|
|
105
|
+
signatures. Hosted never owns the apply lease for CLI-origin plans.
|
|
86
106
|
- `assign.ts` — `AssignmentPolicy` (fixed / round-robin / territory /
|
|
87
107
|
account-owner): the pure owner-routing rule `buildAcquirePlan` stamps onto new
|
|
88
108
|
leads (never born ownerless) and `reassign --assign-unowned` reuses to backfill.
|
package/llms.txt
CHANGED
|
@@ -10,6 +10,13 @@ CLI quick check: `fullstackgtm doctor --json`. Zero-credential demo:
|
|
|
10
10
|
explicitly approved operation ids. Exit codes: 0 success, 1 error, 2 findings
|
|
11
11
|
at/above `--fail-on`.
|
|
12
12
|
|
|
13
|
+
MCP apply accepts only a stored `planId`; approvals and concrete values come
|
|
14
|
+
from the HMAC-signed plan store, never the tool caller. MCP never executes rule
|
|
15
|
+
packages or external plans. Store-backed apply is single-owner and journaled;
|
|
16
|
+
an interrupted provider attempt remains uncertain and is never auto-replayed.
|
|
17
|
+
After reconciling provider state, `plans recover <id>
|
|
18
|
+
--acknowledge-uncertain-writes` clears approvals for a fresh human review.
|
|
19
|
+
|
|
13
20
|
## Docs
|
|
14
21
|
|
|
15
22
|
- [README](https://github.com/fullstackgtm/core/blob/main/README.md): install, five-minute loop, auth ladder, MCP setup, programmatic use
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullstackgtm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.50.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)",
|
|
@@ -45,10 +45,12 @@
|
|
|
45
45
|
"DATA-FLOWS.md"
|
|
46
46
|
],
|
|
47
47
|
"scripts": {
|
|
48
|
-
"
|
|
48
|
+
"clean": "node scripts/clean.mjs",
|
|
49
|
+
"build": "npm run clean && tsc -p tsconfig.build.json",
|
|
49
50
|
"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; }",
|
|
50
51
|
"test": "node --experimental-strip-types --test tests/*.test.ts",
|
|
51
|
-
"
|
|
52
|
+
"verify:package": "npm run build && node scripts/verify-packed-install.mjs --with-peers",
|
|
53
|
+
"prepublishOnly": "npm run verify:package"
|
|
52
54
|
},
|
|
53
55
|
"devDependencies": {
|
|
54
56
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
@@ -63,7 +63,7 @@ credentials AND stored plans per client org.
|
|
|
63
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) |
|
|
64
64
|
| `call parse\|score\|link\|plan` | Transcripts → evidence-quoted insights, rubric scorecards, deal linking, governed next-step writes |
|
|
65
65
|
| `enrich append\|refresh\|ingest\|status` | Governed enrichment (Apollo pull / Clay ingest), fill-blanks-only plans |
|
|
66
|
-
| `enrich acquire [--source explorium\|pipe0\|linkedin]` | Net-new ICP-targeted lead gen:
|
|
66
|
+
| `enrich acquire [--source explorium\|pipe0\|linkedin] [--max <new>] [--scan-limit <raw>]` | Net-new ICP-targeted lead gen: independent per-provider/list/query checkpoints traverse full audiences instead of rereading page one; paired CLIs CAS-sync opaque, non-PII continuation to the hosted org; resolve-first deduped `create_record` plans, meter-capped and owner-stamped; `enrich status --runs` exposes funnel + continuation; never auto-writes |
|
|
67
67
|
| `signals fetch\|list\|outcome\|weights` | Detect-side timing layer: capture fresh buying triggers into a profile-scoped ledger (free Greenhouse/Lever/Ashby ATS in the box; `--from` staged files; `--connector file\|serpapi-news\|hubspot-forms` to pull connected platforms / read the webhook spool `<home>/signals/spool`, secrets via the credential ladder), ranked by learned weights. Writes NOTHING to the CRM; `outcome --account <domain> --contact <id> --result …` re-weights which triggers earn a touch |
|
|
68
68
|
| `icp interview\|set\|show\|judge\|eval` | Build the ICP, then `judge` ranks fresh signals into send/nurture/skip — pass a snapshot (`--provider`/`--input`) and each decision resolves its CRM target (`accountId` + the `contact`/`contacts` to reach). `eval` is the calibration gate (exit 2 below the bar) |
|
|
69
69
|
| `draft [--from-judge latest] [--channel email\|linkedin\|task]` | One trigger-grounded opener per hot account → a governed `create_task` targeting the resolved `contact.id` (or `accountId`); rejects a domain-only decision ("acquire it first"). **Never sends** — after `plans approve`, `apply --provider <crm>` logs it as a CRM task, or `apply --channel outbox` renders it to `<home>/signals/outbox/<channel>.jsonl` for a downstream sender to drain; the CLI transmits nothing |
|
|
@@ -85,10 +85,16 @@ npx -y fullstackgtm-mcp
|
|
|
85
85
|
|
|
86
86
|
Tools over stdio: `fullstackgtm_audit` (read-only), `fullstackgtm_rules`,
|
|
87
87
|
`fullstackgtm_suggest`, `fullstackgtm_call_parse`,
|
|
88
|
-
`fullstackgtm_apply` (
|
|
88
|
+
`fullstackgtm_apply` (stored `planId` only; approvals and values come from the
|
|
89
|
+
HMAC-signed plan store and cannot be supplied by the MCP caller),
|
|
89
90
|
`fullstackgtm_resolve`, `fullstackgtm_market_worksheet`,
|
|
90
91
|
`fullstackgtm_market_observe`.
|
|
91
92
|
|
|
93
|
+
MCP never executes rule packages or external plans. An uncertain apply is
|
|
94
|
+
never replayed automatically: reconcile provider state, then use
|
|
95
|
+
`fullstackgtm plans recover <id> --acknowledge-uncertain-writes`, which clears
|
|
96
|
+
approvals and requires a fresh review.
|
|
97
|
+
|
|
92
98
|
## Composing the primitives into plays
|
|
93
99
|
|
|
94
100
|
This CLI ships governed **primitives** — there is no `outbound` mega-command.
|
|
@@ -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
|
+
}
|
package/src/acquireLinkedIn.ts
CHANGED
|
@@ -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
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { resolve } from "node:path";
|
|
5
5
|
import { auditSnapshot, defaultPolicy } from "../audit.ts";
|
|
6
|
-
import { loadConfig, mergePolicy, resolveConfiguredRules } from "../config.ts";
|
|
6
|
+
import { loadConfig, mergePolicy, resolveConfiguredRules, rulePackageTrustFromCli } from "../config.ts";
|
|
7
7
|
import { getCredential, resolveHubspotConnection } from "../credentials.ts";
|
|
8
8
|
import { patchPlanToMarkdown } from "../format.ts";
|
|
9
9
|
import { appendHealthEntry, computeHealth, healthToMarkdown, readHealthTimeline, saveWorkspaceSnapshot, summarizeHealth, activeWorkspaceProfile, type HealthRollup } from "../health.ts";
|
|
@@ -174,8 +174,9 @@ async function registerHubspotWithBroker(): Promise<void> {
|
|
|
174
174
|
|
|
175
175
|
export async function audit(args: string[]) {
|
|
176
176
|
const threshold = failOnThreshold(args);
|
|
177
|
-
const
|
|
178
|
-
const
|
|
177
|
+
const explicitConfig = option(args, "--config") ?? undefined;
|
|
178
|
+
const loaded = loadConfig(explicitConfig);
|
|
179
|
+
const rules = selectedRules(args, await resolveConfiguredRules(loaded, undefined, rulePackageTrustFromCli(args, explicitConfig)));
|
|
179
180
|
const snapshot = await readSnapshot(args);
|
|
180
181
|
|
|
181
182
|
const policy = mergePolicy(defaultPolicy(), loaded?.config);
|
|
@@ -371,8 +372,9 @@ function shortDay(at: string): string {
|
|
|
371
372
|
* re-fetching (useful for a plan produced earlier or by another machine).
|
|
372
373
|
*/
|
|
373
374
|
export async function reportCommand(args: string[]) {
|
|
374
|
-
const
|
|
375
|
-
const
|
|
375
|
+
const explicitConfig = option(args, "--config") ?? undefined;
|
|
376
|
+
const loaded = loadConfig(explicitConfig);
|
|
377
|
+
const configuredRules = await resolveConfiguredRules(loaded, undefined, rulePackageTrustFromCli(args, explicitConfig));
|
|
376
378
|
|
|
377
379
|
let plan: PatchPlan;
|
|
378
380
|
let snapshot: CanonicalGtmSnapshot | undefined;
|
|
@@ -424,8 +426,9 @@ export async function reportCommand(args: string[]) {
|
|
|
424
426
|
}
|
|
425
427
|
|
|
426
428
|
export async function rulesCommand(args: string[]) {
|
|
427
|
-
const
|
|
428
|
-
const
|
|
429
|
+
const explicitConfig = option(args, "--config") ?? undefined;
|
|
430
|
+
const loaded = loadConfig(explicitConfig);
|
|
431
|
+
const rules = await resolveConfiguredRules(loaded, undefined, rulePackageTrustFromCli(args, explicitConfig));
|
|
429
432
|
if (args.includes("--json")) {
|
|
430
433
|
console.log(
|
|
431
434
|
JSON.stringify(
|