fullstackgtm 0.47.0 → 0.49.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 +76 -6
- package/CONTRIBUTING.md +23 -4
- package/DATA-FLOWS.md +7 -2
- package/INSTALL_FOR_AGENTS.md +15 -6
- package/README.md +28 -9
- package/SECURITY.md +27 -3
- package/dist/audit.js +7 -0
- package/dist/backfill.js +2 -16
- package/dist/cli/audit.js +10 -7
- package/dist/cli/auth.js +8 -4
- package/dist/cli/fix.d.ts +3 -0
- package/dist/cli/fix.js +90 -8
- package/dist/cli/help.d.ts +6 -0
- package/dist/cli/help.js +81 -3
- package/dist/cli/market.js +180 -2
- package/dist/cli/plans.js +56 -5
- package/dist/cli/suggest.d.ts +2 -1
- package/dist/cli/suggest.js +49 -3
- package/dist/cli/ui.js +2 -0
- package/dist/cli.js +41 -16
- package/dist/config.d.ts +13 -1
- package/dist/config.js +23 -2
- package/dist/connectors/hubspot.d.ts +2 -1
- package/dist/connectors/prospectSources.js +4 -11
- package/dist/connectors/salesforce.d.ts +2 -1
- 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/signalSources.js +2 -11
- package/dist/connectors/theirstack.d.ts +0 -19
- package/dist/connectors/theirstack.js +3 -10
- package/dist/credentials.js +36 -26
- package/dist/format.js +31 -5
- package/dist/freeEmailDomains.d.ts +1 -0
- package/dist/freeEmailDomains.js +14 -0
- package/dist/hierarchy.d.ts +29 -0
- package/dist/hierarchy.js +164 -0
- package/dist/index.d.ts +9 -4
- package/dist/index.js +8 -3
- 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 +86 -130
- package/dist/planStore.d.ts +28 -1
- package/dist/planStore.js +195 -49
- 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/relationships.d.ts +39 -0
- package/dist/relationships.js +101 -0
- package/dist/route.d.ts +46 -0
- package/dist/route.js +228 -0
- package/dist/rules.d.ts +1 -0
- package/dist/rules.js +172 -12
- package/dist/secureFile.d.ts +15 -0
- package/dist/secureFile.js +164 -0
- package/dist/types.d.ts +16 -1
- package/docs/api.md +49 -5
- package/docs/architecture.md +18 -4
- package/llms.txt +7 -0
- package/package.json +5 -3
- package/skills/fullstackgtm/SKILL.md +9 -3
- package/src/audit.ts +7 -0
- package/src/backfill.ts +2 -17
- package/src/cli/audit.ts +10 -7
- package/src/cli/auth.ts +8 -4
- package/src/cli/fix.ts +96 -8
- package/src/cli/help.ts +88 -3
- package/src/cli/market.ts +181 -2
- package/src/cli/plans.ts +66 -5
- package/src/cli/suggest.ts +58 -4
- package/src/cli/ui.ts +1 -0
- package/src/cli.ts +39 -14
- package/src/config.ts +39 -1
- package/src/connectors/hubspot.ts +3 -1
- package/src/connectors/prospectSources.ts +4 -11
- package/src/connectors/salesforce.ts +15 -6
- package/src/connectors/salesforceAuth.ts +46 -6
- package/src/connectors/signalSources.ts +2 -12
- package/src/connectors/theirstack.ts +3 -10
- package/src/credentials.ts +47 -28
- package/src/format.ts +33 -5
- package/src/freeEmailDomains.ts +14 -0
- package/src/hierarchy.ts +185 -0
- package/src/index.ts +29 -0
- package/src/market.ts +7 -111
- package/src/marketClassify.ts +61 -1
- package/src/marketSourcing.ts +7 -43
- package/src/mcp.ts +115 -152
- package/src/planStore.ts +235 -57
- package/src/providerError.ts +37 -0
- package/src/publicHttp.ts +147 -0
- package/src/relationships.ts +115 -0
- package/src/route.ts +278 -0
- package/src/rules.ts +192 -12
- package/src/secureFile.ts +169 -0
- package/src/types.ts +18 -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";
|
|
@@ -58,6 +58,13 @@ export type CreateRecordPayload = {
|
|
|
58
58
|
dealPipeline?: string;
|
|
59
59
|
};
|
|
60
60
|
export type AuditFindingSeverity = "info" | "warning" | "critical";
|
|
61
|
+
export type PatchPlanAssumptionConfidence = "derived" | "heuristic" | "guess";
|
|
62
|
+
export type PatchPlanAssumption = {
|
|
63
|
+
id: string;
|
|
64
|
+
text: string;
|
|
65
|
+
source?: string;
|
|
66
|
+
confidence?: PatchPlanAssumptionConfidence;
|
|
67
|
+
};
|
|
61
68
|
/**
|
|
62
69
|
* One claim that a canonical record exists in an external system. A record
|
|
63
70
|
* merged from several providers carries one identity per provider.
|
|
@@ -323,6 +330,10 @@ export type PatchPlan = {
|
|
|
323
330
|
* Account/contact hygiene findings are NOT here; read `findings` for those. */
|
|
324
331
|
pipelineFindings?: PipelineFinding[];
|
|
325
332
|
evidence?: GtmEvidence[];
|
|
333
|
+
/** Assumptions used to turn audit observations into findings and operations. */
|
|
334
|
+
assumptions?: PatchPlanAssumption[];
|
|
335
|
+
/** Human decisions or unresolved questions that affect how the plan should be applied. */
|
|
336
|
+
openQuestions?: string[];
|
|
326
337
|
operations: PatchOperation[];
|
|
327
338
|
/**
|
|
328
339
|
* The filter this plan's operations were selected by. Re-evaluated per
|
|
@@ -374,6 +385,8 @@ export type GtmRuleContext = {
|
|
|
374
385
|
export type GtmRuleResult = {
|
|
375
386
|
findings: AuditFinding[];
|
|
376
387
|
operations: PatchOperation[];
|
|
388
|
+
/** Rule-level assumptions that explain the policy or heuristic behind returned findings. */
|
|
389
|
+
assumptions?: PatchPlanAssumption[];
|
|
377
390
|
};
|
|
378
391
|
/**
|
|
379
392
|
* A deterministic audit rule. Rules read the snapshot through the context and
|
|
@@ -385,6 +398,8 @@ export type GtmAuditRule = {
|
|
|
385
398
|
description: string;
|
|
386
399
|
/** Grouping for docs and discovery, e.g. "hygiene", "forecast", "coverage". */
|
|
387
400
|
category?: string;
|
|
401
|
+
/** False for findings-only rules whose evaluator intentionally emits no patch operations. */
|
|
402
|
+
emitsOperations?: boolean;
|
|
388
403
|
evaluate: (context: GtmRuleContext) => GtmRuleResult;
|
|
389
404
|
};
|
|
390
405
|
export type PatchOperationResult = {
|
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
|
|
@@ -69,7 +75,8 @@ release.
|
|
|
69
75
|
Commands: `init`, `login` / `logout`, `snapshot`, `audit`, `report`, `diff`, `merge`, `plans`,
|
|
70
76
|
`apply`, `suggest`, `audit-log` (`export` / `verify`),
|
|
71
77
|
`call` (`parse` / `classify` / `score` / `link` / `plan`), `resolve`,
|
|
72
|
-
`
|
|
78
|
+
`hierarchy` (`report`), `relationships` (`account`),
|
|
79
|
+
`route` (`leads`), `bulk-update`, `dedupe`, `reassign`, `fix`, `health`,
|
|
73
80
|
`market` (`init` / `capture` / `classify` / `worksheet` / `observe` / `fronts` /
|
|
74
81
|
`axes` / `overlay` / `scale` / `report` / `refresh`),
|
|
75
82
|
`tam` (`estimate` / `accounts` / `status` / `report` / `populate`),
|
|
@@ -160,8 +167,8 @@ per-rule detail with capped examples, and next steps. `auditReportToMarkdown` /
|
|
|
160
167
|
|
|
161
168
|
## Governed write verbs
|
|
162
169
|
|
|
163
|
-
Plan builders behind `bulk-update`, `dedupe`, and `reassign` — every
|
|
164
|
-
emits a standard dry-run `PatchPlan` for the normal approve → apply chain:
|
|
170
|
+
Plan builders behind `route`, `bulk-update`, `dedupe`, and `reassign` — every
|
|
171
|
+
one emits a standard dry-run `PatchPlan` for the normal approve → apply chain:
|
|
165
172
|
|
|
166
173
|
- `buildBulkUpdatePlan(snapshot, options: BulkUpdateOptions)` with
|
|
167
174
|
`parseWhere` (filter expressions: `=`, `!=`, `~`, `!~`, `:empty`,
|
|
@@ -179,6 +186,22 @@ emits a standard dry-run `PatchPlan` for the normal approve → apply chain:
|
|
|
179
186
|
`assignUnowned` (CLI `--assign-unowned`) it targets ownerless records
|
|
180
187
|
(`ownerId:empty`) and claims them for `--to` — the backfill twin of
|
|
181
188
|
`enrich acquire`'s create-time assignment, for clearing existing ownerless debt.
|
|
189
|
+
- `buildLeadRoutePlan(snapshot, options: LeadRouteOptions)` — matches contacts
|
|
190
|
+
to accounts by email domain and optional company-name fallback, skips free
|
|
191
|
+
email domains and ambiguous matches, then emits approval-gated account-link
|
|
192
|
+
and owner-assignment operations. Owner routing can inherit active account
|
|
193
|
+
owners for ownerless contacts or use the shared deterministic
|
|
194
|
+
`AssignmentPolicy`; existing owners are preserved unless
|
|
195
|
+
`reassignExistingOwner` (CLI `--reassign-owned`) is set.
|
|
196
|
+
|
|
197
|
+
Read-only prevention reports:
|
|
198
|
+
|
|
199
|
+
- `buildAccountHierarchy(snapshot)` / `accountHierarchyToMarkdown(report)` —
|
|
200
|
+
provider parent hints plus subdomain inference, with duplicate-domain,
|
|
201
|
+
ambiguous-parent, and parent-cycle conflicts surfaced rather than written.
|
|
202
|
+
- `buildRelationshipMap(snapshot, { accountId | domain })` /
|
|
203
|
+
`relationshipMapToMarkdown(map)` — account stakeholders, inferred roles,
|
|
204
|
+
activity sentiment evidence, open deals, and missing-role gaps.
|
|
182
205
|
|
|
183
206
|
`fix` is CLI-only composition of existing surfaces (audit → suggest →
|
|
184
207
|
approve → apply for one rule).
|
|
@@ -266,6 +289,23 @@ domain stamped — so the acquired account is immediately signal-watchable
|
|
|
266
289
|
`CanonicalContact.linkedin` (mapped from HubSpot `hs_linkedin_url`) is the
|
|
267
290
|
strong dedup key across all of the above.
|
|
268
291
|
|
|
292
|
+
## Apply recovery
|
|
293
|
+
|
|
294
|
+
Store-backed applies persist an apply-attempt claim before provider I/O. If the
|
|
295
|
+
process exits without a completed run, the plan remains `applying`: the CLI
|
|
296
|
+
cannot know whether a remote write landed and will not replay it. Inspect the
|
|
297
|
+
attempt with `fullstackgtm plans show <id>` and reconcile the named provider's
|
|
298
|
+
records. Only then run:
|
|
299
|
+
|
|
300
|
+
```bash
|
|
301
|
+
fullstackgtm plans recover <id> --acknowledge-uncertain-writes
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
Recovery performs no provider calls. It clears the old approvals and returns
|
|
305
|
+
the plan to `needs_approval`, requiring a fresh review and signed approval
|
|
306
|
+
before another apply. Provider APIs do not all expose idempotency keys, so this
|
|
307
|
+
operator reconciliation remains necessary after a timeout or crash.
|
|
308
|
+
|
|
269
309
|
## Schedule
|
|
270
310
|
|
|
271
311
|
The horizontal scheduler: a declarative schedule-entry store, a
|
|
@@ -330,8 +370,12 @@ the stored capture it cites before a set is accepted; failed captures read as
|
|
|
330
370
|
Tools: `fullstackgtm_capabilities` (the machine-readable server contract —
|
|
331
371
|
tool inventory with per-tool CRM-write flags, derived from the server's own
|
|
332
372
|
registration table), `fullstackgtm_audit`, `fullstackgtm_rules`,
|
|
333
|
-
`fullstackgtm_apply` (
|
|
373
|
+
`fullstackgtm_apply` (accepts only a stored `planId`; approved operation ids and
|
|
374
|
+
placeholder values come exclusively from the HMAC-signed plan store),
|
|
334
375
|
`fullstackgtm_suggest`, `fullstackgtm_call_parse`, `fullstackgtm_resolve`,
|
|
335
376
|
`fullstackgtm_market_worksheet`, `fullstackgtm_market_observe` (validates,
|
|
336
377
|
verifies quoted spans against the stored captures, appends, returns front
|
|
337
|
-
states). Input schemas are stable.
|
|
378
|
+
states). Input schemas are stable. MCP never executes rule packages and cannot
|
|
379
|
+
accept external plan files or caller-supplied approvals/value overrides. An
|
|
380
|
+
uncertain apply is reconciled with the CLI `plans recover` workflow and is
|
|
381
|
+
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,17 +57,23 @@ 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
|
-
- `bulkUpdate.ts`, `dedupe.ts`, `reassign.ts` — filtered
|
|
61
|
-
|
|
71
|
+
- `bulkUpdate.ts`, `dedupe.ts`, `reassign.ts`, `route.ts` — filtered,
|
|
72
|
+
duplicate, ownership handoff, and lead-to-account/owner routing plan builders.
|
|
73
|
+
`merge.ts` — snapshot diff/merge + entity resolution.
|
|
62
74
|
- `resolve.ts` — the create-gate (exists / ambiguous / safe_to_create).
|
|
75
|
+
- `hierarchy.ts`, `relationships.ts` — read-only account hierarchy and
|
|
76
|
+
stakeholder relationship-map reports for planning/prevention workflows.
|
|
63
77
|
|
|
64
78
|
**Connectors** (`connectors/`)
|
|
65
79
|
- `hubspot.ts`, `salesforce.ts`, `stripe.ts` + their `*Auth.ts` OAuth/device
|
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.49.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",
|
|
@@ -85,16 +85,22 @@ 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.
|
|
95
101
|
**You (the agent) are the orchestrator:** chain these verbs into the play the
|
|
96
102
|
operator wants, surface the one approve gate, and bridge the last mile to the
|
|
97
|
-
sender (**the package never sends**). [docs/recipes.md](https://github.com/fullstackgtm/core/blob/main/
|
|
103
|
+
sender (**the package never sends**). [docs/recipes.md](https://github.com/fullstackgtm/core/blob/main/docs/recipes.md)
|
|
98
104
|
has five worked plays — cold-start lead-fill, the trigger→judge→draft outbound
|
|
99
105
|
loop, scheduled-continuous, ABM-from-companies, and hygiene-gated outbound.
|
|
100
106
|
`fullstackgtm init` scaffolds a workspace (icp.json + enrich config + a PLAYBOOK
|
|
@@ -102,7 +108,7 @@ pointing at those recipes) to start from.
|
|
|
102
108
|
|
|
103
109
|
## Going deeper
|
|
104
110
|
|
|
105
|
-
- [docs/recipes.md](https://github.com/fullstackgtm/core/blob/main/
|
|
111
|
+
- [docs/recipes.md](https://github.com/fullstackgtm/core/blob/main/docs/recipes.md) — five composable GTM plays over the primitives (cold-start, outbound loop, scheduled, ABM, hygiene-gated)
|
|
106
112
|
- [llms.txt](https://github.com/fullstackgtm/core/blob/main/llms.txt) — the full invariant map per layer (calls, market, write verbs, enrich, schedule, engagement/health)
|
|
107
113
|
- [INSTALL_FOR_AGENTS.md](https://github.com/fullstackgtm/core/blob/main/INSTALL_FOR_AGENTS.md) — deterministic install-and-verify with expected outputs
|
|
108
114
|
- [docs/api.md](https://github.com/fullstackgtm/core/blob/main/docs/api.md) — semver-covered surfaces: canonical model, rule interface, plan/apply contract, connectors, config, CLI, MCP
|
package/src/audit.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
PipelineFindingType,
|
|
10
10
|
PatchOperation,
|
|
11
11
|
PatchPlan,
|
|
12
|
+
PatchPlanAssumption,
|
|
12
13
|
SourceFreshness,
|
|
13
14
|
} from "./types.ts";
|
|
14
15
|
|
|
@@ -53,6 +54,7 @@ export function auditSnapshot(
|
|
|
53
54
|
const context = { snapshot, policy, index: buildSnapshotIndex(snapshot) };
|
|
54
55
|
const findings: AuditFinding[] = [];
|
|
55
56
|
const operations: PatchOperation[] = [];
|
|
57
|
+
const assumptionsById = new Map<string, PatchPlanAssumption>();
|
|
56
58
|
|
|
57
59
|
for (const rule of rules) {
|
|
58
60
|
try {
|
|
@@ -63,6 +65,9 @@ export function auditSnapshot(
|
|
|
63
65
|
const result = rule.evaluate(context);
|
|
64
66
|
findings.push(...result.findings);
|
|
65
67
|
operations.push(...result.operations);
|
|
68
|
+
for (const assumption of result.assumptions ?? []) {
|
|
69
|
+
if (!assumptionsById.has(assumption.id)) assumptionsById.set(assumption.id, assumption);
|
|
70
|
+
}
|
|
66
71
|
try {
|
|
67
72
|
onRule?.(rule.id, "done", result.findings.length);
|
|
68
73
|
} catch {
|
|
@@ -70,6 +75,7 @@ export function auditSnapshot(
|
|
|
70
75
|
}
|
|
71
76
|
}
|
|
72
77
|
|
|
78
|
+
const assumptions = Array.from(assumptionsById.values());
|
|
73
79
|
|
|
74
80
|
const evidence = buildEvidence(snapshot, findings, policy.today);
|
|
75
81
|
const pipelineFindings = buildPipelineFindings(findings, operations, evidence, policy.today);
|
|
@@ -85,6 +91,7 @@ export function auditSnapshot(
|
|
|
85
91
|
findings,
|
|
86
92
|
pipelineFindings,
|
|
87
93
|
evidence,
|
|
94
|
+
assumptions: assumptions.length > 0 ? assumptions : undefined,
|
|
88
95
|
operations,
|
|
89
96
|
};
|
|
90
97
|
}
|
package/src/backfill.ts
CHANGED
|
@@ -21,6 +21,7 @@ import type {
|
|
|
21
21
|
PatchPlan,
|
|
22
22
|
} from "./types.ts";
|
|
23
23
|
import type { StripePaidInvoice } from "./connectors/stripe.ts";
|
|
24
|
+
import { FREE_EMAIL_DOMAINS } from "./freeEmailDomains.ts";
|
|
24
25
|
import { normalizeDomain } from "./merge.ts";
|
|
25
26
|
|
|
26
27
|
// Mirrors stableHash in rules.ts (FNV-1a); duplicated to keep backfill.ts
|
|
@@ -36,22 +37,6 @@ function fnv1a(value: string): string {
|
|
|
36
37
|
|
|
37
38
|
export const DEFAULT_BACKFILL_MATCH_PROPERTY = "stripe_invoice_id";
|
|
38
39
|
|
|
39
|
-
// Freemail domains never become a company domain: stamping "gmail.com" on an
|
|
40
|
-
// account (or resolving a company BY gmail.com) would collapse unrelated
|
|
41
|
-
// customers onto one record. Mirrors FREE_MAIL in connectors/signalSources.ts
|
|
42
|
-
// (duplicated to keep backfill.ts importable without connector code, the
|
|
43
|
-
// fnv1a precedent above).
|
|
44
|
-
const FREE_MAIL = new Set([
|
|
45
|
-
"gmail.com",
|
|
46
|
-
"yahoo.com",
|
|
47
|
-
"hotmail.com",
|
|
48
|
-
"outlook.com",
|
|
49
|
-
"icloud.com",
|
|
50
|
-
"aol.com",
|
|
51
|
-
"proton.me",
|
|
52
|
-
"protonmail.com",
|
|
53
|
-
]);
|
|
54
|
-
|
|
55
40
|
export type StripeBackfillOptions = {
|
|
56
41
|
/** Pipeline id or case-insensitive label; absent = the portal's default pipeline. */
|
|
57
42
|
pipeline?: string;
|
|
@@ -183,7 +168,7 @@ export function buildStripeBackfillPlan(
|
|
|
183
168
|
let companyDomain = normalizeDomain(account?.domain);
|
|
184
169
|
let accountIsNew = false;
|
|
185
170
|
if (!account && createMissingAccounts) {
|
|
186
|
-
const usableDomain = domain && !
|
|
171
|
+
const usableDomain = domain && !FREE_EMAIL_DOMAINS.has(domain) ? domain : undefined;
|
|
187
172
|
const name = invoice.customerName?.trim() || usableDomain;
|
|
188
173
|
if (name) {
|
|
189
174
|
companyName = name;
|
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(
|
package/src/cli/auth.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
4
|
import { resolve } from "node:path";
|
|
5
5
|
import { DEFAULT_LOOPBACK_PORT, openInBrowser, runHubspotLoopbackLogin, validateHubspotToken } from "../connectors/hubspotAuth.ts";
|
|
6
|
-
import { pollSalesforceDeviceLogin, startSalesforceDeviceLogin, validateSalesforceToken } from "../connectors/salesforceAuth.ts";
|
|
6
|
+
import { pollSalesforceDeviceLogin, startSalesforceDeviceLogin, validateSalesforceOrigin, validateSalesforceToken } from "../connectors/salesforceAuth.ts";
|
|
7
7
|
import { activeProfile, credentialsPath, DEFAULT_PROFILE, deleteCredential, getCredential, storeCredential, type StoredCredential } from "../credentials.ts";
|
|
8
8
|
import { activeWorkspaceProfile, readHealthTimeline, summarizeHealth } from "../health.ts";
|
|
9
9
|
import { resolveLlmCredential, validateLlmKey } from "../llm.ts";
|
|
@@ -252,7 +252,10 @@ async function salesforceLogin(args: string[]) {
|
|
|
252
252
|
await guidedProviderLogin("salesforce", args, "--device requires --client-id (the consumer key of a Connected App with device flow enabled).");
|
|
253
253
|
return;
|
|
254
254
|
}
|
|
255
|
-
const
|
|
255
|
+
const loginUrlInput = option(args, "--login-url") ?? undefined;
|
|
256
|
+
const loginUrl = loginUrlInput
|
|
257
|
+
? validateSalesforceOrigin(loginUrlInput, "Salesforce --login-url")
|
|
258
|
+
: undefined;
|
|
256
259
|
const authorization = await startSalesforceDeviceLogin({ clientId, loginUrl });
|
|
257
260
|
console.error(
|
|
258
261
|
`\nPairing code: ${authorization.userCode}\n\nConfirm this code at:\n\n ${authorization.verificationUri}\n`,
|
|
@@ -282,8 +285,8 @@ async function salesforceLogin(args: string[]) {
|
|
|
282
285
|
return;
|
|
283
286
|
}
|
|
284
287
|
|
|
285
|
-
const
|
|
286
|
-
if (!
|
|
288
|
+
const instanceUrlInput = option(args, "--instance-url");
|
|
289
|
+
if (!instanceUrlInput) {
|
|
287
290
|
await guidedProviderLogin(
|
|
288
291
|
"salesforce",
|
|
289
292
|
args,
|
|
@@ -291,6 +294,7 @@ async function salesforceLogin(args: string[]) {
|
|
|
291
294
|
);
|
|
292
295
|
return;
|
|
293
296
|
}
|
|
297
|
+
const instanceUrl = validateSalesforceOrigin(instanceUrlInput, "Salesforce --instance-url");
|
|
294
298
|
const token = await readSecret("Salesforce access token");
|
|
295
299
|
if (!token) throw new Error("No access token provided.");
|
|
296
300
|
if (!args.includes("--no-validate")) {
|