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.
Files changed (98) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/CONTRIBUTING.md +23 -4
  3. package/DATA-FLOWS.md +8 -2
  4. package/INSTALL_FOR_AGENTS.md +13 -4
  5. package/README.md +53 -1
  6. package/SECURITY.md +27 -3
  7. package/dist/acquireCheckpoint.d.ts +33 -0
  8. package/dist/acquireCheckpoint.js +149 -0
  9. package/dist/acquireLinkedIn.d.ts +2 -0
  10. package/dist/acquireLinkedIn.js +1 -1
  11. package/dist/cli/audit.js +10 -7
  12. package/dist/cli/auth.js +8 -4
  13. package/dist/cli/enrich.js +283 -79
  14. package/dist/cli/fix.js +20 -7
  15. package/dist/cli/help.js +23 -15
  16. package/dist/cli/market.js +180 -2
  17. package/dist/cli/plans.js +154 -9
  18. package/dist/cli/shared.d.ts +3 -1
  19. package/dist/cli/shared.js +2 -2
  20. package/dist/cli/ui.d.ts +10 -5
  21. package/dist/cli/ui.js +18 -5
  22. package/dist/cli.js +1 -1
  23. package/dist/config.d.ts +13 -1
  24. package/dist/config.js +23 -2
  25. package/dist/connectors/linkedin.d.ts +2 -0
  26. package/dist/connectors/linkedin.js +5 -0
  27. package/dist/connectors/prospectSources.d.ts +23 -0
  28. package/dist/connectors/prospectSources.js +25 -14
  29. package/dist/connectors/salesforce.js +12 -5
  30. package/dist/connectors/salesforceAuth.d.ts +9 -0
  31. package/dist/connectors/salesforceAuth.js +47 -5
  32. package/dist/connectors/theirstack.d.ts +0 -19
  33. package/dist/connectors/theirstack.js +3 -10
  34. package/dist/credentials.js +36 -26
  35. package/dist/enrich.d.ts +44 -1
  36. package/dist/hostedAcquireCheckpoint.d.ts +43 -0
  37. package/dist/hostedAcquireCheckpoint.js +129 -0
  38. package/dist/hostedPatchPlan.d.ts +87 -0
  39. package/dist/hostedPatchPlan.js +270 -0
  40. package/dist/icp.js +13 -2
  41. package/dist/index.d.ts +7 -3
  42. package/dist/index.js +6 -2
  43. package/dist/market.d.ts +1 -1
  44. package/dist/market.js +7 -127
  45. package/dist/marketClassify.d.ts +10 -0
  46. package/dist/marketClassify.js +52 -1
  47. package/dist/marketSourcing.js +7 -45
  48. package/dist/mcp.js +67 -115
  49. package/dist/planStore.d.ts +42 -1
  50. package/dist/planStore.js +268 -49
  51. package/dist/progress.d.ts +2 -2
  52. package/dist/progress.js +2 -2
  53. package/dist/providerError.d.ts +21 -0
  54. package/dist/providerError.js +30 -0
  55. package/dist/publicHttp.d.ts +28 -0
  56. package/dist/publicHttp.js +143 -0
  57. package/dist/secureFile.d.ts +15 -0
  58. package/dist/secureFile.js +164 -0
  59. package/dist/types.d.ts +1 -1
  60. package/docs/api.md +53 -2
  61. package/docs/architecture.md +22 -2
  62. package/llms.txt +7 -0
  63. package/package.json +5 -3
  64. package/skills/fullstackgtm/SKILL.md +8 -2
  65. package/src/acquireCheckpoint.ts +186 -0
  66. package/src/acquireLinkedIn.ts +3 -1
  67. package/src/cli/audit.ts +10 -7
  68. package/src/cli/auth.ts +8 -4
  69. package/src/cli/enrich.ts +322 -78
  70. package/src/cli/fix.ts +28 -7
  71. package/src/cli/help.ts +24 -15
  72. package/src/cli/market.ts +181 -2
  73. package/src/cli/plans.ts +159 -9
  74. package/src/cli/shared.ts +2 -1
  75. package/src/cli/ui.ts +19 -9
  76. package/src/cli.ts +1 -1
  77. package/src/config.ts +39 -1
  78. package/src/connectors/linkedin.ts +6 -0
  79. package/src/connectors/prospectSources.ts +50 -15
  80. package/src/connectors/salesforce.ts +12 -5
  81. package/src/connectors/salesforceAuth.ts +46 -6
  82. package/src/connectors/theirstack.ts +3 -10
  83. package/src/credentials.ts +47 -28
  84. package/src/enrich.ts +47 -1
  85. package/src/hostedAcquireCheckpoint.ts +156 -0
  86. package/src/hostedPatchPlan.ts +286 -0
  87. package/src/icp.ts +14 -2
  88. package/src/index.ts +24 -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 +93 -136
  93. package/src/planStore.ts +322 -57
  94. package/src/progress.ts +2 -2
  95. package/src/providerError.ts +37 -0
  96. package/src/publicHttp.ts +147 -0
  97. package/src/secureFile.ts +169 -0
  98. 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` (requires explicit `approvedOperationIds`),
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.
@@ -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,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 / 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
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.48.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
- "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",
@@ -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: resolve-first deduped `create_record` plans, capped by a per-profile windowed meter (records + spend); owner-stamped via `acquire.assign`/`--assign-owner` (never born ownerless); links each lead to a domain-stamped, signal-watchable account; `acquire.create.company` acquires accounts (ABM); `--source linkedin --list <id>` reads a HeyReach lead list (Phase 1 discovery, read-only — never sends); never auto-writes |
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` (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.
@@ -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
+ }
@@ -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 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(