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.
Files changed (100) hide show
  1. package/CHANGELOG.md +76 -6
  2. package/CONTRIBUTING.md +23 -4
  3. package/DATA-FLOWS.md +7 -2
  4. package/INSTALL_FOR_AGENTS.md +15 -6
  5. package/README.md +28 -9
  6. package/SECURITY.md +27 -3
  7. package/dist/audit.js +7 -0
  8. package/dist/backfill.js +2 -16
  9. package/dist/cli/audit.js +10 -7
  10. package/dist/cli/auth.js +8 -4
  11. package/dist/cli/fix.d.ts +3 -0
  12. package/dist/cli/fix.js +90 -8
  13. package/dist/cli/help.d.ts +6 -0
  14. package/dist/cli/help.js +81 -3
  15. package/dist/cli/market.js +180 -2
  16. package/dist/cli/plans.js +56 -5
  17. package/dist/cli/suggest.d.ts +2 -1
  18. package/dist/cli/suggest.js +49 -3
  19. package/dist/cli/ui.js +2 -0
  20. package/dist/cli.js +41 -16
  21. package/dist/config.d.ts +13 -1
  22. package/dist/config.js +23 -2
  23. package/dist/connectors/hubspot.d.ts +2 -1
  24. package/dist/connectors/prospectSources.js +4 -11
  25. package/dist/connectors/salesforce.d.ts +2 -1
  26. package/dist/connectors/salesforce.js +12 -5
  27. package/dist/connectors/salesforceAuth.d.ts +9 -0
  28. package/dist/connectors/salesforceAuth.js +47 -5
  29. package/dist/connectors/signalSources.js +2 -11
  30. package/dist/connectors/theirstack.d.ts +0 -19
  31. package/dist/connectors/theirstack.js +3 -10
  32. package/dist/credentials.js +36 -26
  33. package/dist/format.js +31 -5
  34. package/dist/freeEmailDomains.d.ts +1 -0
  35. package/dist/freeEmailDomains.js +14 -0
  36. package/dist/hierarchy.d.ts +29 -0
  37. package/dist/hierarchy.js +164 -0
  38. package/dist/index.d.ts +9 -4
  39. package/dist/index.js +8 -3
  40. package/dist/market.d.ts +1 -1
  41. package/dist/market.js +7 -127
  42. package/dist/marketClassify.d.ts +10 -0
  43. package/dist/marketClassify.js +52 -1
  44. package/dist/marketSourcing.js +7 -45
  45. package/dist/mcp.js +86 -130
  46. package/dist/planStore.d.ts +28 -1
  47. package/dist/planStore.js +195 -49
  48. package/dist/providerError.d.ts +21 -0
  49. package/dist/providerError.js +30 -0
  50. package/dist/publicHttp.d.ts +28 -0
  51. package/dist/publicHttp.js +143 -0
  52. package/dist/relationships.d.ts +39 -0
  53. package/dist/relationships.js +101 -0
  54. package/dist/route.d.ts +46 -0
  55. package/dist/route.js +228 -0
  56. package/dist/rules.d.ts +1 -0
  57. package/dist/rules.js +172 -12
  58. package/dist/secureFile.d.ts +15 -0
  59. package/dist/secureFile.js +164 -0
  60. package/dist/types.d.ts +16 -1
  61. package/docs/api.md +49 -5
  62. package/docs/architecture.md +18 -4
  63. package/llms.txt +7 -0
  64. package/package.json +5 -3
  65. package/skills/fullstackgtm/SKILL.md +9 -3
  66. package/src/audit.ts +7 -0
  67. package/src/backfill.ts +2 -17
  68. package/src/cli/audit.ts +10 -7
  69. package/src/cli/auth.ts +8 -4
  70. package/src/cli/fix.ts +96 -8
  71. package/src/cli/help.ts +88 -3
  72. package/src/cli/market.ts +181 -2
  73. package/src/cli/plans.ts +66 -5
  74. package/src/cli/suggest.ts +58 -4
  75. package/src/cli/ui.ts +1 -0
  76. package/src/cli.ts +39 -14
  77. package/src/config.ts +39 -1
  78. package/src/connectors/hubspot.ts +3 -1
  79. package/src/connectors/prospectSources.ts +4 -11
  80. package/src/connectors/salesforce.ts +15 -6
  81. package/src/connectors/salesforceAuth.ts +46 -6
  82. package/src/connectors/signalSources.ts +2 -12
  83. package/src/connectors/theirstack.ts +3 -10
  84. package/src/credentials.ts +47 -28
  85. package/src/format.ts +33 -5
  86. package/src/freeEmailDomains.ts +14 -0
  87. package/src/hierarchy.ts +185 -0
  88. package/src/index.ts +29 -0
  89. package/src/market.ts +7 -111
  90. package/src/marketClassify.ts +61 -1
  91. package/src/marketSourcing.ts +7 -43
  92. package/src/mcp.ts +115 -152
  93. package/src/planStore.ts +235 -57
  94. package/src/providerError.ts +37 -0
  95. package/src/publicHttp.ts +147 -0
  96. package/src/relationships.ts +115 -0
  97. package/src/route.ts +278 -0
  98. package/src/rules.ts +192 -12
  99. package/src/secureFile.ts +169 -0
  100. 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
- `bulk-update`, `dedupe`, `reassign`, `fix`, `health`,
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 one
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` (requires explicit `approvedOperationIds`),
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.
@@ -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 (the audit record; auditLog.ts exports it)
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 / record runs), 0600.
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/duplicate/ownership
61
- plan builders. `merge.ts` snapshot diff/merge + entity resolution.
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.47.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
- "build": "rm -rf dist && tsc -p tsconfig.build.json",
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
- "prepublishOnly": "npm run build && npm pack --dry-run && node dist/bin.js --help && node dist/mcp-bin.js --help"
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` (requires `approvedOperationIds`),
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/packages/fullstackgtm/docs/recipes.md)
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/packages/fullstackgtm/docs/recipes.md) — five composable GTM plays over the primitives (cold-start, outbound loop, scheduled, ABM, hygiene-gated)
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 && !FREE_MAIL.has(domain) ? domain : undefined;
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 loaded = loadConfig(option(args, "--config") ?? undefined);
178
- const rules = selectedRules(args, await resolveConfiguredRules(loaded));
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 loaded = loadConfig(option(args, "--config") ?? undefined);
375
- const configuredRules = await resolveConfiguredRules(loaded);
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 loaded = loadConfig(option(args, "--config") ?? undefined);
428
- const rules = await resolveConfiguredRules(loaded);
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 loginUrl = option(args, "--login-url") ?? undefined;
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 instanceUrl = option(args, "--instance-url");
286
- if (!instanceUrl) {
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")) {