fullstackgtm 0.34.0 → 0.38.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 +141 -0
- package/INSTALL_FOR_AGENTS.md +8 -0
- package/README.md +39 -0
- package/dist/acquireLinkedIn.d.ts +41 -0
- package/dist/acquireLinkedIn.js +57 -0
- package/dist/acquireMeter.d.ts +67 -0
- package/dist/acquireMeter.js +145 -0
- package/dist/acquireSeen.d.ts +5 -0
- package/dist/acquireSeen.js +54 -0
- package/dist/assign.d.ts +83 -0
- package/dist/assign.js +146 -0
- package/dist/bin.js +14 -2
- package/dist/cli.js +817 -26
- package/dist/connectors/hubspot.js +140 -0
- package/dist/connectors/linkedin.d.ts +78 -0
- package/dist/connectors/linkedin.js +199 -0
- package/dist/connectors/prospectSources.d.ts +91 -0
- package/dist/connectors/prospectSources.js +227 -0
- package/dist/enrich.d.ts +107 -0
- package/dist/enrich.js +315 -5
- package/dist/format.d.ts +3 -1
- package/dist/format.js +14 -2
- package/dist/health.d.ts +71 -0
- package/dist/health.js +172 -0
- package/dist/icp.d.ts +96 -0
- package/dist/icp.js +256 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/mappings.js +3 -0
- package/dist/reassign.d.ts +11 -2
- package/dist/reassign.js +13 -6
- package/dist/runReport.d.ts +9 -0
- package/dist/runReport.js +66 -0
- package/dist/types.d.ts +25 -1
- package/docs/api.md +53 -3
- package/docs/architecture.md +11 -1
- package/docs/dx-punch-list.md +87 -0
- package/docs/linkedin-connector-spec.md +87 -0
- package/llms.txt +38 -1
- package/package.json +1 -1
- package/skills/fullstackgtm/SKILL.md +5 -3
- package/src/acquireLinkedIn.ts +83 -0
- package/src/acquireMeter.ts +186 -0
- package/src/acquireSeen.ts +57 -0
- package/src/assign.ts +193 -0
- package/src/bin.ts +17 -4
- package/src/cli.ts +965 -25
- package/src/connectors/hubspot.ts +145 -0
- package/src/connectors/linkedin.ts +272 -0
- package/src/connectors/prospectSources.ts +324 -0
- package/src/enrich.ts +411 -5
- package/src/format.ts +20 -2
- package/src/health.ts +238 -0
- package/src/icp.ts +313 -0
- package/src/index.ts +18 -0
- package/src/mappings.ts +3 -0
- package/src/reassign.ts +24 -8
- package/src/runReport.ts +76 -0
- package/src/types.ts +32 -0
package/dist/cli.js
CHANGED
|
@@ -12,6 +12,7 @@ import { pollSalesforceDeviceLogin, startSalesforceDeviceLogin, validateSalesfor
|
|
|
12
12
|
import { activeProfile, credentialsPath, DEFAULT_PROFILE, deleteCredential, getCredential, listProfiles, resolveHubspotConnection, resolveSalesforceConnection, setActiveProfile, storeCredential, } from "./credentials.js";
|
|
13
13
|
import { generateDemoSnapshot } from "./demo.js";
|
|
14
14
|
import { formatPatchPlanRun, patchPlanToMarkdown } from "./format.js";
|
|
15
|
+
import { appendHealthEntry, computeHealth, healthToMarkdown, readHealthTimeline, saveWorkspaceSnapshot, summarizeHealth, activeWorkspaceProfile, } from "./health.js";
|
|
15
16
|
import { mergeSnapshots } from "./merge.js";
|
|
16
17
|
import { verifyApprovalDigests } from "./integrity.js";
|
|
17
18
|
import { buildAuditLog, verifyAuditLog } from "./auditLog.js";
|
|
@@ -29,7 +30,13 @@ import { suggestMarketConfig } from "./marketTaxonomy.js";
|
|
|
29
30
|
import { buildWorksheet, classifyMarket } from "./marketClassify.js";
|
|
30
31
|
import { marketMapToHtml, marketMapToMarkdown } from "./marketReport.js";
|
|
31
32
|
import { DEFAULT_RUBRIC, classifyCallLlm, detectProviderFromKey, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, } from "./llm.js";
|
|
32
|
-
import { buildEnrichPlan, createFileEnrichRunStore, DEFAULT_STALE_DAYS, ENRICH_CONFIG_FILE_NAME, enrichRunId, inferIngestObjectType, latestStamps, loadEnrichConfig, parseCsv, resolveCrmField, selectStaleWork, stagedSourceRecords, staleDaysFor, } from "./enrich.js";
|
|
33
|
+
import { buildAcquirePlan, buildEnrichPlan, createFileEnrichRunStore, DEFAULT_STALE_DAYS, ENRICH_CONFIG_FILE_NAME, builtinAcquirePreset, builtinEnrichPreset, enrichRunId, inferIngestObjectType, latestStamps, loadEnrichConfig, parseCsv, resolveCrmField, selectStaleWork, stagedSourceRecords, staleDaysFor, } from "./enrich.js";
|
|
34
|
+
import { loadMeter, recordConsumption, remaining, } from "./acquireMeter.js";
|
|
35
|
+
import { crmContactKeys, fetchExploriumProspects, fetchPipe0CrustdataProspects, partitionFreshProspects, pipe0ResolveWorkEmails, prospectIdentityKeys, } from "./connectors/prospectSources.js";
|
|
36
|
+
import { loadSeen, recordSeen } from "./acquireSeen.js";
|
|
37
|
+
import { reportCounts, reportEvent } from "./runReport.js";
|
|
38
|
+
import { createLinkedInProvider, discoverLinkedInProspects } from "./acquireLinkedIn.js";
|
|
39
|
+
import { fitThreshold, icpFromAnswers, icpToCrustdataFilters, icpToExploriumFilters, parseIcp, scoreProspectAgainstIcp, INTERVIEW_SPEC, } from "./icp.js";
|
|
33
40
|
import { apolloPullKeysForAppend, apolloPullKeysForRefresh, createApolloClient, pullApolloRecords, } from "./enrichApollo.js";
|
|
34
41
|
import { computeMissedFirings, createFileScheduleRunStore, createFileScheduleStore, nextCronFiring, parseCron, renderManagedBlock, replaceManagedBlock, assertSingleLineLabel, hasControlChar, scheduleId, systemCrontabIo, tokenizeCommand, validateSchedulableArgv, } from "./schedule.js";
|
|
35
42
|
import { resolveRecord } from "./resolve.js";
|
|
@@ -49,7 +56,7 @@ Usage:
|
|
|
49
56
|
fullstackgtm login salesforce --instance-url <url> [--no-validate]
|
|
50
57
|
fullstackgtm login stripe [--no-validate]
|
|
51
58
|
fullstackgtm login anthropic | openai store an LLM API key for call parse/score
|
|
52
|
-
fullstackgtm login apollo store an Apollo API key for enrich pulls\n fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|apollo|broker>
|
|
59
|
+
fullstackgtm login apollo store an Apollo API key for enrich pulls\n fullstackgtm login pipe0 | explorium store a discovery-provider key for enrich acquire\n fullstackgtm login heyreach store a HeyReach key for enrich acquire --source linkedin\n fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|apollo|pipe0|explorium|heyreach|broker>
|
|
53
60
|
|
|
54
61
|
Secrets (tokens, client secrets) are NEVER passed as flags — they leak via
|
|
55
62
|
the process list and shell history. Pipe them on stdin or enter them at the
|
|
@@ -124,9 +131,10 @@ Usage:
|
|
|
124
131
|
deterministic survivor (richest = most populated data
|
|
125
132
|
fields, ties to lowest id; oldest = lowest id). Approve and
|
|
126
133
|
apply like any plan; merges are IRREVERSIBLE on apply.
|
|
127
|
-
fullstackgtm reassign --from <ownerId> --to <ownerId> [--objects account,contact,deal] [--where <expr> …] [--except-deal-stage <stage>] [--include-closed-deals] [source options] [--save] [--json] [--out <path>]
|
|
134
|
+
fullstackgtm reassign (--from <ownerId> | --assign-unowned) --to <ownerId> [--objects account,contact,deal] [--where <expr> …] [--except-deal-stage <stage>] [--include-closed-deals] [source options] [--save] [--json] [--out <path>]
|
|
128
135
|
ownership handoff playbook: one bulk-update-style plan per
|
|
129
|
-
object type (ownerId=<from> → <to>).
|
|
136
|
+
object type (ownerId=<from> → <to>). --assign-unowned instead
|
|
137
|
+
claims every ownerless record (ownerId:empty) for --to. Extra --where scoping
|
|
130
138
|
is account-lifted for deals/contacts (domain~.de becomes
|
|
131
139
|
account.domain~.de); --except-deal-stage <stage> excludes
|
|
132
140
|
deals in that stage AND every record whose account has an
|
|
@@ -232,6 +240,267 @@ Safety:
|
|
|
232
240
|
Audits are read-only. Apply writes only operations you explicitly approve,
|
|
233
241
|
and never writes requires_human_* placeholders without a --value override.`;
|
|
234
242
|
}
|
|
243
|
+
const HELP = {
|
|
244
|
+
// Setup & health
|
|
245
|
+
login: {
|
|
246
|
+
summary: "connect a provider or LLM key (secrets via stdin/env, never argv)",
|
|
247
|
+
phase: "Setup",
|
|
248
|
+
synopsis: [
|
|
249
|
+
"fullstackgtm login --via <hosted url> pair with a team deployment",
|
|
250
|
+
"fullstackgtm login hubspot | salesforce | stripe",
|
|
251
|
+
"fullstackgtm login anthropic | openai | apollo",
|
|
252
|
+
],
|
|
253
|
+
detail: "Secrets are NEVER passed as flags (they leak via the process list and shell history) — pipe on stdin or enter at the prompt: `echo \"$TOKEN\" | fullstackgtm login hubspot`.",
|
|
254
|
+
seeAlso: ["doctor", "logout", "profiles"],
|
|
255
|
+
},
|
|
256
|
+
logout: {
|
|
257
|
+
summary: "remove stored credentials for a provider",
|
|
258
|
+
phase: "Setup",
|
|
259
|
+
synopsis: ["fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|apollo|pipe0|explorium|broker>"],
|
|
260
|
+
seeAlso: ["login", "doctor"],
|
|
261
|
+
},
|
|
262
|
+
doctor: {
|
|
263
|
+
summary: "check install, credentials, and the next step",
|
|
264
|
+
phase: "Setup",
|
|
265
|
+
synopsis: ["fullstackgtm doctor [--json]"],
|
|
266
|
+
detail: "Verifies Node version, the credential store, MCP peers, and prints what to run next.",
|
|
267
|
+
seeAlso: ["login", "audit"],
|
|
268
|
+
},
|
|
269
|
+
profiles: {
|
|
270
|
+
summary: "list credential profiles (one per client org)",
|
|
271
|
+
phase: "Setup",
|
|
272
|
+
synopsis: ["fullstackgtm profiles [--json]"],
|
|
273
|
+
detail: "`--profile <name>` (or FULLSTACKGTM_PROFILE) scopes credentials AND stored plans per org, so a plan proposed against one CRM can't be applied through another's credentials.",
|
|
274
|
+
seeAlso: ["login", "plans", "health"],
|
|
275
|
+
},
|
|
276
|
+
health: {
|
|
277
|
+
summary: "CRM health score + trend for the active profile (read-only)",
|
|
278
|
+
phase: "Detect",
|
|
279
|
+
synopsis: ["fullstackgtm health [--json]"],
|
|
280
|
+
detail: "The engagement-workspace rollup. Each `audit --save` stamps a deterministic 0–100 hygiene score (100 / (1 + severity-weighted findings per record)) onto the profile's timeline; `health` reports the current score, the change since the last audit, and per-rule deltas — turning episodic audits into a continuous record. Scope per client with `--profile <name>`.",
|
|
281
|
+
seeAlso: ["audit", "profiles", "report"],
|
|
282
|
+
},
|
|
283
|
+
// Detect — read-only
|
|
284
|
+
snapshot: {
|
|
285
|
+
summary: "pull a canonical GTM snapshot (read-only)",
|
|
286
|
+
phase: "Detect",
|
|
287
|
+
synopsis: ["fullstackgtm snapshot [source options] [--since <iso>] [--out <path> | --archive <dir>]"],
|
|
288
|
+
detail: "Materializes the provider's records as canonical JSON — the input every audit and write verb reads.",
|
|
289
|
+
options: [
|
|
290
|
+
["--provider <name>", "hubspot | salesforce | stripe (read-only)"],
|
|
291
|
+
["--demo", "realistic generated CRM with injected hygiene issues"],
|
|
292
|
+
["--out <path>", "write the snapshot JSON to a file"],
|
|
293
|
+
],
|
|
294
|
+
seeAlso: ["audit", "diff"],
|
|
295
|
+
},
|
|
296
|
+
audit: {
|
|
297
|
+
summary: "read-only hygiene audit → reviewable dry-run patch plan",
|
|
298
|
+
phase: "Detect",
|
|
299
|
+
synopsis: ["fullstackgtm audit [source options] [audit options] [--save]"],
|
|
300
|
+
detail: "The Detect layer of the loop. Runs deterministic rules over a snapshot and proposes a typed patch plan — nothing is written. Prints a summary (rule table + counts) by default; `--full` shows every operation. `--save` persists the plan for the suggest → approve → apply spine. For a client-ready writeup use `report`; for machine output add `--json`.",
|
|
301
|
+
options: [
|
|
302
|
+
["--provider <name>", "live snapshot: hubspot | salesforce | stripe"],
|
|
303
|
+
["--demo", "try it with zero credentials on a messy generated CRM"],
|
|
304
|
+
["--full", "show every operation, not just the rule summary"],
|
|
305
|
+
["--rules <ids>", "comma-separated rule ids (default: all; see `rules`)"],
|
|
306
|
+
["--fail-on <sev>", "exit 2 if any finding ≥ info|warning|critical"],
|
|
307
|
+
["--save", "persist the dry-run plan for approve → apply"],
|
|
308
|
+
["--json / --out <p>", "machine-readable plan to stdout / file"],
|
|
309
|
+
],
|
|
310
|
+
seeAlso: ["report", "suggest", "plans", "apply", "diff"],
|
|
311
|
+
},
|
|
312
|
+
report: {
|
|
313
|
+
summary: "render an audit as a client-ready deliverable (md/html)",
|
|
314
|
+
phase: "Detect",
|
|
315
|
+
synopsis: ["fullstackgtm report [source options] [audit options] [report options]"],
|
|
316
|
+
detail: "The human-readable counterpart to `audit` — a clean summary instead of the full per-operation dump.",
|
|
317
|
+
options: [
|
|
318
|
+
["--plan <path>", "render an existing plan JSON instead of re-auditing"],
|
|
319
|
+
["--client <name>", "organization name in the heading/summary"],
|
|
320
|
+
["--format <fmt>", "markdown (default) or self-contained html"],
|
|
321
|
+
["--out <path>", "write to a file (html inferred from .html)"],
|
|
322
|
+
],
|
|
323
|
+
seeAlso: ["audit"],
|
|
324
|
+
},
|
|
325
|
+
diff: {
|
|
326
|
+
summary: "compare two snapshots/plans; gate on new findings",
|
|
327
|
+
phase: "Detect",
|
|
328
|
+
synopsis: ["fullstackgtm diff --before <a.json> --after <b.json> [--json] [--fail-on-new-findings]"],
|
|
329
|
+
detail: "The regression primitive. Exit 2 when a (rule, record) pair fires that didn't before — wire it into CI to catch CRM rot.",
|
|
330
|
+
seeAlso: ["audit", "snapshot", "merge"],
|
|
331
|
+
},
|
|
332
|
+
rules: {
|
|
333
|
+
summary: "list the audit rule registry",
|
|
334
|
+
phase: "Detect",
|
|
335
|
+
synopsis: ["fullstackgtm rules [--json]"],
|
|
336
|
+
seeAlso: ["audit"],
|
|
337
|
+
},
|
|
338
|
+
// Prevent — gate writes before they happen
|
|
339
|
+
resolve: {
|
|
340
|
+
summary: "the create gate: exit 0 = safe to create, 2 = match exists",
|
|
341
|
+
phase: "Prevent",
|
|
342
|
+
synopsis: ["fullstackgtm resolve <account|contact|deal> [--name N] [--domain D] [--email E] [source options] [--json]"],
|
|
343
|
+
detail: "Prevention, not cleanup. Call before ANY record creation — a sync job, webhook, agent, or script. Returns exists/ambiguous/safe_to_create with matches and reasons; exit 2 = do not create.",
|
|
344
|
+
seeAlso: ["dedupe", "audit"],
|
|
345
|
+
},
|
|
346
|
+
// Remediate — governed writes (all produce plans; nothing writes outside approve → apply)
|
|
347
|
+
fix: {
|
|
348
|
+
summary: "one-shot composite: audit one rule → suggest → approve → apply",
|
|
349
|
+
phase: "Remediate",
|
|
350
|
+
synopsis: ["fullstackgtm fix --rule <ruleId> --provider <name> [--min-confidence high|low] [--include-creates] [--yes]"],
|
|
351
|
+
detail: "The whole governed loop for a single rule in one command. Without `--yes` it stops after approval and prints the apply command; with `--yes` it applies suggestion-backed operations meeting the confidence bar and prints a stage-by-stage summary.",
|
|
352
|
+
seeAlso: ["audit", "suggest", "plans", "apply"],
|
|
353
|
+
},
|
|
354
|
+
dedupe: {
|
|
355
|
+
summary: "merge duplicate groups by identity key (irreversible on apply)",
|
|
356
|
+
phase: "Remediate",
|
|
357
|
+
synopsis: ["fullstackgtm dedupe <account|contact|deal> --key <domain|email|name> [--keep richest|oldest] [--save] [--json]"],
|
|
358
|
+
detail: "Builds a dry-run plan of one merge_records op per duplicate group with a deterministic survivor (richest = most populated fields; oldest = lowest id). Approve and apply like any plan; merges are IRREVERSIBLE on apply.",
|
|
359
|
+
seeAlso: ["resolve", "plans", "apply"],
|
|
360
|
+
},
|
|
361
|
+
reassign: {
|
|
362
|
+
summary: "ownership handoff: one plan per object type",
|
|
363
|
+
phase: "Remediate",
|
|
364
|
+
synopsis: ["fullstackgtm reassign (--from <ownerId> | --assign-unowned) --to <ownerId> [--objects account,contact,deal] [--where <expr> …] [--save] [--json]"],
|
|
365
|
+
detail: "Extra `--where` scoping is account-lifted for deals/contacts. `--except-deal-stage <stage>` excludes that stage and any record whose account has an open deal in it, re-verified per record at apply.",
|
|
366
|
+
seeAlso: ["bulk-update", "plans", "apply"],
|
|
367
|
+
},
|
|
368
|
+
enrich: {
|
|
369
|
+
summary: "governed third-party enrichment (Apollo/Clay), fill-blanks-only",
|
|
370
|
+
phase: "Remediate",
|
|
371
|
+
synopsis: ["fullstackgtm enrich append|refresh|ingest|status … (run `enrich --help` for full options)"],
|
|
372
|
+
detail: "Pull (Apollo) or stage (Clay) data, match it to CRM records deterministically, and emit a fill-blanks-only patch plan through the normal dry-run → approve → apply gate. `refresh` re-checks stale stamped fields.",
|
|
373
|
+
seeAlso: ["plans", "apply", "schedule"],
|
|
374
|
+
},
|
|
375
|
+
// Calls → evidence
|
|
376
|
+
call: {
|
|
377
|
+
summary: "transcripts → evidence, rubric scores, deal links, governed writes",
|
|
378
|
+
phase: "Remediate",
|
|
379
|
+
synopsis: ["fullstackgtm call parse|classify|score|link|plan … (run `call --help` for full options)"],
|
|
380
|
+
detail: "`parse` normalizes any transcript into canonical segments + evidence (LLM by default, bring your own key; `--deterministic` for the free baseline). `classify` picks the call type, `score` rates it against the type's rubric, `link` finds the deal, `plan` proposes governed next-step writes.",
|
|
381
|
+
seeAlso: ["plans", "apply"],
|
|
382
|
+
},
|
|
383
|
+
// Govern — the plan/apply spine
|
|
384
|
+
suggest: {
|
|
385
|
+
summary: "derive values for requires_human_* placeholders (evidence-backed)",
|
|
386
|
+
phase: "Govern",
|
|
387
|
+
synopsis: ["fullstackgtm suggest --plan-id <id> | --plan <path> [source options] [--json] [--out <path>]"],
|
|
388
|
+
detail: "Read-only. Proposes concrete values with confidence + reasons from snapshot evidence; feed the output to `plans approve --values-from`. Never guesses — low/create/none-confidence entries are left to the human.",
|
|
389
|
+
seeAlso: ["audit", "plans", "apply"],
|
|
390
|
+
},
|
|
391
|
+
plans: {
|
|
392
|
+
summary: "plan lifecycle: list / show / approve / reject saved plans",
|
|
393
|
+
phase: "Govern",
|
|
394
|
+
synopsis: [
|
|
395
|
+
"fullstackgtm plans list [--status <s>] | show <id> | reject <id>",
|
|
396
|
+
"fullstackgtm plans approve <id> --operations <ids|all> [--value <opId>=<v>]",
|
|
397
|
+
"fullstackgtm plans approve <id> --values-from <suggestions.json> [--min-confidence high|low]",
|
|
398
|
+
],
|
|
399
|
+
detail: "Approval is explicit and per-operation; placeholders need a concrete --value or a suggestion to be approvable.",
|
|
400
|
+
seeAlso: ["audit", "suggest", "apply"],
|
|
401
|
+
},
|
|
402
|
+
apply: {
|
|
403
|
+
summary: "write ONLY explicitly approved operations to a provider",
|
|
404
|
+
phase: "Govern / Verify",
|
|
405
|
+
synopsis: [
|
|
406
|
+
"fullstackgtm apply --plan-id <id> --provider <name>",
|
|
407
|
+
"fullstackgtm apply --plan <path> --provider <name> --approve <ids|all> [--value <opId>=<v>]",
|
|
408
|
+
],
|
|
409
|
+
detail: "The only verb that mutates a CRM. Writes only operations approved via `plans approve` or `--approve`, with compare-and-set and readback. Never writes requires_human_* placeholders without a --value override.",
|
|
410
|
+
seeAlso: ["plans", "suggest", "audit-log"],
|
|
411
|
+
},
|
|
412
|
+
"audit-log": {
|
|
413
|
+
summary: "tamper-evident record of apply runs (export / verify)",
|
|
414
|
+
phase: "Verify",
|
|
415
|
+
synopsis: ["fullstackgtm audit-log export [--out <path>] | verify --in <path>"],
|
|
416
|
+
detail: "The Verify/Attribute layer — a signed, append-only record of every applied write.",
|
|
417
|
+
seeAlso: ["apply"],
|
|
418
|
+
},
|
|
419
|
+
merge: {
|
|
420
|
+
summary: "merge multiple plan/snapshot JSONs into one",
|
|
421
|
+
phase: "Govern",
|
|
422
|
+
synopsis: ["fullstackgtm merge --input <a.json> --input <b.json> [...] --out <merged.json> [--json]"],
|
|
423
|
+
seeAlso: ["diff", "audit"],
|
|
424
|
+
},
|
|
425
|
+
// Continuous
|
|
426
|
+
schedule: {
|
|
427
|
+
summary: "declare a cadence for read/plan-side commands (never auto-approves)",
|
|
428
|
+
phase: "Continuous",
|
|
429
|
+
synopsis: ["fullstackgtm schedule add|list|remove|enable|disable|run|install|status … (run `schedule --help` for full options)"],
|
|
430
|
+
detail: "Makes the loop continuous instead of episodic. Read/plan-side allowlist only (audit, snapshot, enrich, market, suggest, report, doctor). Scheduling NEVER auto-approves: apply is schedulable only as `apply --plan-id <id>`, re-checked approved at run time.",
|
|
431
|
+
seeAlso: ["audit", "enrich", "apply"],
|
|
432
|
+
},
|
|
433
|
+
// Market intelligence
|
|
434
|
+
market: {
|
|
435
|
+
summary: "live competitive category map (capture → classify → drift → report)",
|
|
436
|
+
phase: "Intelligence",
|
|
437
|
+
synopsis: ["fullstackgtm market init|capture|classify|worksheet|observe|fronts|axes|overlay|scale|report|refresh … (run `market --help` for full options)"],
|
|
438
|
+
detail: "Capture vendor pages (content-addressed), classify intensity per claim (LLM bring-your-own-key, or fill the worksheet with any agent), then compute deterministic front states and drift. Every quoted span is verified verbatim against the stored capture before it's accepted.",
|
|
439
|
+
seeAlso: [],
|
|
440
|
+
},
|
|
441
|
+
};
|
|
442
|
+
// Verbs that print their own richer multi-subcommand help; runCli routes their
|
|
443
|
+
// `--help` to themselves, so commandHelp() only renders these via `help <verb>`.
|
|
444
|
+
const BESPOKE_HELP = ["call", "market", "enrich", "bulk-update", "schedule"];
|
|
445
|
+
// Lifecycle-grouped front door. One line per verb, organized by the
|
|
446
|
+
// Prevent→Detect→Remediate→Verify loop so a new user sees ~6 jobs, not 22 verbs.
|
|
447
|
+
function shortUsage() {
|
|
448
|
+
const groups = [
|
|
449
|
+
["Setup & health", ["login", "logout", "doctor", "profiles", "health"]],
|
|
450
|
+
["Detect — read-only", ["audit", "report", "snapshot", "diff", "rules"]],
|
|
451
|
+
["Prevent — gate writes", ["resolve"]],
|
|
452
|
+
["Remediate — governed writes", ["fix", "bulk-update", "dedupe", "reassign", "enrich"]],
|
|
453
|
+
["Calls → evidence", ["call"]],
|
|
454
|
+
["Govern — the plan/apply spine", ["suggest", "plans", "apply", "audit-log", "merge"]],
|
|
455
|
+
["Market intelligence", ["market"]],
|
|
456
|
+
["Schedule — make it continuous", ["schedule"]],
|
|
457
|
+
];
|
|
458
|
+
const pad = Math.max(...Object.keys(HELP).map((k) => k.length)) + 2;
|
|
459
|
+
const lines = [
|
|
460
|
+
"FullStackGTM — plan/apply for your GTM stack.",
|
|
461
|
+
"Audit CRM data, propose reviewable patch plans, apply only what you approve.",
|
|
462
|
+
"",
|
|
463
|
+
"Usage: fullstackgtm <command> [options]",
|
|
464
|
+
"",
|
|
465
|
+
];
|
|
466
|
+
for (const [title, cmds] of groups) {
|
|
467
|
+
lines.push(`${title}:`);
|
|
468
|
+
for (const cmd of cmds) {
|
|
469
|
+
const entry = HELP[cmd];
|
|
470
|
+
if (!entry)
|
|
471
|
+
continue;
|
|
472
|
+
lines.push(` ${cmd.padEnd(pad)}${entry.summary}`);
|
|
473
|
+
}
|
|
474
|
+
lines.push("");
|
|
475
|
+
}
|
|
476
|
+
lines.push("Zoom in: fullstackgtm <command> --help focused help for one command", "Full ref: fullstackgtm help --full every flag and option", "Try it: fullstackgtm audit --demo zero-credential demo CRM", "", "Safety: audits are read-only; apply writes only operations you approve.");
|
|
477
|
+
return lines.join("\n");
|
|
478
|
+
}
|
|
479
|
+
// Focused help for a single command. Falls back to shortUsage() for anything
|
|
480
|
+
// unknown so `--help` never dead-ends on the full wall.
|
|
481
|
+
function commandHelp(command) {
|
|
482
|
+
const entry = HELP[command];
|
|
483
|
+
if (!entry)
|
|
484
|
+
return shortUsage();
|
|
485
|
+
const lines = [`fullstackgtm ${command} — ${entry.summary}`, "", "Usage:"];
|
|
486
|
+
for (const s of entry.synopsis)
|
|
487
|
+
lines.push(` ${s}`);
|
|
488
|
+
if (entry.detail)
|
|
489
|
+
lines.push("", entry.detail);
|
|
490
|
+
if (entry.options?.length) {
|
|
491
|
+
lines.push("", "Options:");
|
|
492
|
+
const pad = Math.max(...entry.options.map(([f]) => f.length)) + 2;
|
|
493
|
+
for (const [flag, desc] of entry.options)
|
|
494
|
+
lines.push(` ${flag.padEnd(pad)}${desc}`);
|
|
495
|
+
}
|
|
496
|
+
lines.push("", `Lifecycle phase: ${entry.phase}`);
|
|
497
|
+
if (entry.seeAlso?.length)
|
|
498
|
+
lines.push(`See also: ${entry.seeAlso.join(", ")}`);
|
|
499
|
+
if (BESPOKE_HELP.includes(command))
|
|
500
|
+
lines.push("", `Run \`fullstackgtm ${command} --help\` for the full subcommand reference.`);
|
|
501
|
+
lines.push("", "Full reference: fullstackgtm help --full");
|
|
502
|
+
return lines.join("\n");
|
|
503
|
+
}
|
|
235
504
|
function option(args, name) {
|
|
236
505
|
const index = args.indexOf(name);
|
|
237
506
|
if (index === -1)
|
|
@@ -322,9 +591,14 @@ async function readSnapshot(args) {
|
|
|
322
591
|
today: option(args, "--today") ?? undefined,
|
|
323
592
|
});
|
|
324
593
|
}
|
|
325
|
-
|
|
326
|
-
if (!input || args.includes("--sample"))
|
|
594
|
+
if (args.includes("--sample"))
|
|
327
595
|
return sampleSnapshot;
|
|
596
|
+
const input = option(args, "--input");
|
|
597
|
+
if (!input) {
|
|
598
|
+
throw new Error("No data source. Pass one of: --provider <hubspot|salesforce|stripe> (live, read-only), " +
|
|
599
|
+
"--input <snapshot.json> (a saved snapshot), --demo (a generated messy CRM), or " +
|
|
600
|
+
"--sample (the tiny built-in fixture). Refusing to silently audit sample data.");
|
|
601
|
+
}
|
|
328
602
|
const path = resolve(process.cwd(), input);
|
|
329
603
|
return JSON.parse(readFileSync(path, "utf8"));
|
|
330
604
|
}
|
|
@@ -396,6 +670,49 @@ async function snapshotCommand(args) {
|
|
|
396
670
|
console.log(serialized.trimEnd());
|
|
397
671
|
}
|
|
398
672
|
}
|
|
673
|
+
// #2 (dx-punch-list): every read verb points forward. `audit` is the keystone —
|
|
674
|
+
// `audit --demo` is the most-run first command and used to dead-end on a blank
|
|
675
|
+
// line. Context-aware guidance mirrors doctor's "Next step" and fix's chaining,
|
|
676
|
+
// stepping the user along Detect → Govern → apply. Printed to stderr so stdout
|
|
677
|
+
// stays clean for pipes/--out; suppressed under --json (machine/agent context).
|
|
678
|
+
function auditNextStep(args, plan) {
|
|
679
|
+
const provider = option(args, "--provider");
|
|
680
|
+
const usingLiveData = Boolean(provider) || Boolean(option(args, "--input"));
|
|
681
|
+
const saved = args.includes("--save");
|
|
682
|
+
const count = plan.findings.length;
|
|
683
|
+
if (count === 0) {
|
|
684
|
+
return [
|
|
685
|
+
"✓ No findings — this snapshot is clean. Nothing to apply.",
|
|
686
|
+
" Keep it clean: gate new records with `fullstackgtm resolve`,",
|
|
687
|
+
" and schedule a recurring check with `fullstackgtm schedule add ...`.",
|
|
688
|
+
].join("\n");
|
|
689
|
+
}
|
|
690
|
+
const head = `${count} finding${count === 1 ? "" : "s"} — a dry-run plan. Nothing was written.`;
|
|
691
|
+
if (!usingLiveData) {
|
|
692
|
+
return [
|
|
693
|
+
head,
|
|
694
|
+
"Next:",
|
|
695
|
+
" • Client-ready writeup: fullstackgtm report --demo",
|
|
696
|
+
" • Run on your CRM: fullstackgtm login hubspot → fullstackgtm audit --provider hubspot --save",
|
|
697
|
+
].join("\n");
|
|
698
|
+
}
|
|
699
|
+
if (!saved) {
|
|
700
|
+
return [
|
|
701
|
+
head,
|
|
702
|
+
"Next: persist it with --save, then suggest → approve → apply:",
|
|
703
|
+
` fullstackgtm audit --provider ${provider ?? "<name>"} --save`,
|
|
704
|
+
].join("\n");
|
|
705
|
+
}
|
|
706
|
+
// --save already confirmed the plan id above; chain the governed spine.
|
|
707
|
+
const providerFlag = provider ? ` --provider ${provider}` : "";
|
|
708
|
+
return [
|
|
709
|
+
head,
|
|
710
|
+
"Next: derive values, approve the safe ones, apply:",
|
|
711
|
+
` fullstackgtm suggest --plan-id ${plan.id}${providerFlag} --out suggestions.json`,
|
|
712
|
+
` fullstackgtm plans approve ${plan.id} --values-from suggestions.json`,
|
|
713
|
+
` fullstackgtm apply --plan-id ${plan.id}${providerFlag}`,
|
|
714
|
+
].join("\n");
|
|
715
|
+
}
|
|
399
716
|
async function audit(args) {
|
|
400
717
|
const threshold = failOnThreshold(args);
|
|
401
718
|
const loaded = loadConfig(option(args, "--config") ?? undefined);
|
|
@@ -409,25 +726,62 @@ async function audit(args) {
|
|
|
409
726
|
if (staleDealDays !== undefined)
|
|
410
727
|
policy.staleDealDays = staleDealDays;
|
|
411
728
|
const plan = auditSnapshot(snapshot, policy, rules);
|
|
729
|
+
reportCounts({
|
|
730
|
+
findings: plan.findings.length,
|
|
731
|
+
critical: plan.findings.filter((f) => f.severity === "critical").length,
|
|
732
|
+
warning: plan.findings.filter((f) => f.severity === "warning").length,
|
|
733
|
+
});
|
|
412
734
|
const out = option(args, "--out");
|
|
413
735
|
if (out) {
|
|
414
736
|
writeFileSync(resolve(process.cwd(), out), `${JSON.stringify(plan, null, 2)}\n`);
|
|
415
737
|
}
|
|
416
738
|
if (args.includes("--save")) {
|
|
417
739
|
await createFilePlanStore().save(plan);
|
|
418
|
-
|
|
740
|
+
// Engagement workspace: stamp the per-profile health timeline + snapshot so
|
|
741
|
+
// the org accrues a continuous record from the verb people already run.
|
|
742
|
+
// Honor --today (audit is deterministic under it); fall back to wall-clock.
|
|
743
|
+
const health = computeHealth(plan, snapshot, today ?? new Date().toISOString());
|
|
744
|
+
appendHealthEntry(health);
|
|
745
|
+
saveWorkspaceSnapshot(plan.id, snapshot);
|
|
746
|
+
console.error(`Saved plan ${plan.id}. Health ${health.score}/100 recorded (\`fullstackgtm health\`). ` +
|
|
747
|
+
`Review with \`fullstackgtm plans show ${plan.id}\`, approve with \`fullstackgtm plans approve ${plan.id} --operations <ids|all>\`.`);
|
|
419
748
|
}
|
|
420
749
|
if (args.includes("--json")) {
|
|
421
750
|
console.log(JSON.stringify(plan, null, 2));
|
|
422
751
|
}
|
|
423
752
|
else {
|
|
424
|
-
|
|
753
|
+
// Default to the summary view (rule table + counts); the full per-operation
|
|
754
|
+
// dump is opt-in via --full or, for a deliverable, `report`. (dx-punch-list #3)
|
|
755
|
+
console.log(patchPlanToMarkdown(plan, { summary: !args.includes("--full") }));
|
|
756
|
+
console.error(`\n${auditNextStep(args, plan)}`);
|
|
425
757
|
}
|
|
426
758
|
if (threshold &&
|
|
427
759
|
plan.findings.some((finding) => SEVERITY_RANK[finding.severity] >= SEVERITY_RANK[threshold])) {
|
|
428
760
|
process.exitCode = 2;
|
|
429
761
|
}
|
|
430
762
|
}
|
|
763
|
+
/**
|
|
764
|
+
* Roll up the active profile's health timeline (accrued by `audit --save`):
|
|
765
|
+
* current deterministic score, change since the last audit, and per-rule
|
|
766
|
+
* deltas. Read-only — it only reads `health.jsonl`, never re-audits.
|
|
767
|
+
*/
|
|
768
|
+
function healthCommand(args) {
|
|
769
|
+
const profile = activeWorkspaceProfile();
|
|
770
|
+
const rollup = summarizeHealth(readHealthTimeline(), profile);
|
|
771
|
+
if (!rollup) {
|
|
772
|
+
if (args.includes("--json")) {
|
|
773
|
+
console.log(JSON.stringify({ profile, auditCount: 0 }, null, 2));
|
|
774
|
+
}
|
|
775
|
+
else {
|
|
776
|
+
console.log(`No audits recorded yet for profile "${profile}".\n` +
|
|
777
|
+
"Start the timeline: `fullstackgtm audit --provider <name> --save`" +
|
|
778
|
+
(profile === "default" ? "" : ` --profile ${profile}`) +
|
|
779
|
+
".");
|
|
780
|
+
}
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
console.log(args.includes("--json") ? JSON.stringify(rollup, null, 2) : healthToMarkdown(rollup));
|
|
784
|
+
}
|
|
431
785
|
/**
|
|
432
786
|
* Render an audit as a client-facing deliverable. Same sources and audit
|
|
433
787
|
* options as `audit`; `--plan` instead renders an existing plan JSON without
|
|
@@ -1253,8 +1607,27 @@ enrich append [--source apollo] [--objects companies,contacts] [--save] [--conf
|
|
|
1253
1607
|
enrich refresh [--source apollo] [--stale-days <n>] [--save] [--config <path>]
|
|
1254
1608
|
[source options] [--run-label <label>] [--json]
|
|
1255
1609
|
enrich ingest <file.csv|payload.json> --source clay [--run-label <label>] [--objects companies|contacts] [--config <path>]
|
|
1610
|
+
enrich acquire [--source <id>] [--max <n>] [--list <id>] [--assign-owner <id>] [--save] [--config <path>] [--json]
|
|
1256
1611
|
enrich status [--runs] [--source <id>] [--config <path>] [--json]
|
|
1257
1612
|
|
|
1613
|
+
acquire creates NET-NEW leads from a staged prospect list (ingest first):
|
|
1614
|
+
it dedupes each sourced row against the CRM, skips matches and ambiguities
|
|
1615
|
+
(resolve-first never creates over a possible duplicate), and proposes a
|
|
1616
|
+
\`create_record\` op per confirmed net-new row — capped by the acquire meter's
|
|
1617
|
+
remaining budget (records + spend, per day and per month; whichever is hit
|
|
1618
|
+
first). Approval-gated like every write: \`--save\` → plans approve → apply.
|
|
1619
|
+
The meter is charged only when a create actually lands at apply.
|
|
1620
|
+
Discovery sources (zero-config presets): explorium | pipe0 (ICP-driven search),
|
|
1621
|
+
and linkedin (reads a HeyReach lead list — pass --list <id>, key on the LinkedIn
|
|
1622
|
+
URL, $0/record; \`login heyreach\` or HEYREACH_API_KEY).
|
|
1623
|
+
|
|
1624
|
+
Leads are never born ownerless: set an \`acquire.assign\` policy (fixed /
|
|
1625
|
+
round-robin / territory / account-owner) to stamp an owner at create time, or
|
|
1626
|
+
pass \`--assign-owner <id>\`. With a single-owner portal and no policy, acquire
|
|
1627
|
+
defaults every lead to that owner; with several owners and no policy it warns
|
|
1628
|
+
and leaves them unassigned. Backfill existing ownerless records with
|
|
1629
|
+
\`reassign --assign-unowned --to <ownerId>\`.
|
|
1630
|
+
|
|
1258
1631
|
append pulls from an api source (Apollo — BYO key via \`login apollo\` or
|
|
1259
1632
|
APOLLO_API_KEY) or reads data staged by \`enrich ingest\` (Clay CSV exports,
|
|
1260
1633
|
webhook payload JSON), matches source records to CRM records via the ordered
|
|
@@ -1278,8 +1651,8 @@ re-touches fields its own ledger proves it stamped. system-only and always
|
|
|
1278
1651
|
are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
|
|
1279
1652
|
return;
|
|
1280
1653
|
}
|
|
1281
|
-
if (!["append", "refresh", "ingest", "status"].includes(subcommand)) {
|
|
1282
|
-
throw new Error(`Unknown enrich subcommand: ${subcommand} (try: append, refresh, ingest, status)`);
|
|
1654
|
+
if (!["append", "refresh", "ingest", "status", "acquire"].includes(subcommand)) {
|
|
1655
|
+
throw new Error(`Unknown enrich subcommand: ${subcommand} (try: append, refresh, ingest, status, acquire)`);
|
|
1283
1656
|
}
|
|
1284
1657
|
const configPath = () => resolve(process.cwd(), option(rest, "--config") ?? ENRICH_CONFIG_FILE_NAME);
|
|
1285
1658
|
const store = createFileEnrichRunStore();
|
|
@@ -1287,12 +1660,176 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
|
|
|
1287
1660
|
await enrichStatus(store, rest, configPath());
|
|
1288
1661
|
return;
|
|
1289
1662
|
}
|
|
1290
|
-
|
|
1663
|
+
// Config resolution: an explicit --config or an on-disk default always wins
|
|
1664
|
+
// (and validates). Only when neither exists do we fall back to a built-in
|
|
1665
|
+
// preset for the source (e.g. `--source clay`), so the common Mode-A loop
|
|
1666
|
+
// needs no hand-authored config. A present-but-invalid config still errors.
|
|
1667
|
+
const explicitConfig = option(rest, "--config");
|
|
1668
|
+
const configFile = configPath();
|
|
1669
|
+
// No config file + no --config → fall back to a built-in preset so the common
|
|
1670
|
+
// paths need zero hand-authored config: `acquire` gets the zero-config acquire
|
|
1671
|
+
// preset (targeted/deduped/metered out the gate); other verbs get the source
|
|
1672
|
+
// preset (e.g. clay ingest). An explicit/on-disk config always wins.
|
|
1673
|
+
const presetFor = (src) => subcommand === "acquire" ? builtinAcquirePreset(src) : builtinEnrichPreset(src);
|
|
1674
|
+
const config = !explicitConfig && !existsSync(configFile)
|
|
1675
|
+
? (presetFor(option(rest, "--source") ?? undefined) ?? loadEnrichConfig(configFile))
|
|
1676
|
+
: loadEnrichConfig(configFile);
|
|
1677
|
+
// `enrich ingest` stages rows. If the same command also names a CRM source
|
|
1678
|
+
// (--input/--provider), collapse the two-step into one: stage, then run the
|
|
1679
|
+
// append against the just-staged data so `enrich ingest clay.csv --source clay
|
|
1680
|
+
// --input snap.json` returns the hygiene verdict end-to-end. Without a CRM
|
|
1681
|
+
// source it stays stage-only (the existing two-step is preserved).
|
|
1291
1682
|
if (subcommand === "ingest") {
|
|
1292
|
-
|
|
1683
|
+
const autoAppend = Boolean(option(rest, "--provider") || option(rest, "--input"));
|
|
1684
|
+
await enrichIngest(store, config, rest, autoAppend);
|
|
1685
|
+
if (!autoAppend)
|
|
1686
|
+
return;
|
|
1687
|
+
}
|
|
1688
|
+
if (subcommand === "acquire") {
|
|
1689
|
+
if (!config.acquire) {
|
|
1690
|
+
throw new Error('enrich acquire: config has no "acquire" section. Add e.g. { "acquire": { "create": { "contact": { "matchKey": "email", "properties": { "email": "email", "firstname": "first_name", "lastname": "last_name" } } }, "budget": { "records": { "perDay": 50, "perMonth": 500 } }, "costPerRecord": { "clay": 0.10 } } } to enrich.config.json.');
|
|
1691
|
+
}
|
|
1692
|
+
const source = resolveEnrichSource(config, rest);
|
|
1693
|
+
const sourceConfig = config.sources[source];
|
|
1694
|
+
const save = rest.includes("--save");
|
|
1695
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
1696
|
+
// Prospects come from an API source (net-new discovery, e.g. Explorium +
|
|
1697
|
+
// pipe0 work-email) or a staged ingest list (Clay/CSV/webhook).
|
|
1698
|
+
const snapshot = await readSnapshot(rest);
|
|
1699
|
+
const icp = loadIcp(rest);
|
|
1700
|
+
if (sourceConfig.kind === "api" && !icp) {
|
|
1701
|
+
console.error("⚠ No ICP loaded — discovery will use the raw discovery.filters and skip fit scoring. " +
|
|
1702
|
+
"Develop one with `fullstackgtm icp interview` (or pass --icp <path>) so leads map to your ICP.");
|
|
1703
|
+
}
|
|
1704
|
+
// Recommend the strong dedup key when the CRM carries no LinkedIn URLs:
|
|
1705
|
+
// without them, pre-email dedup falls back to the weaker name+domain match.
|
|
1706
|
+
if (sourceConfig.kind === "api" && !(snapshot.contacts ?? []).some((c) => c.linkedin)) {
|
|
1707
|
+
console.error("⚠ No LinkedIn URLs found on your contacts — pre-email dedup falls back to name+domain (weaker). " +
|
|
1708
|
+
"Populate the HubSpot \"LinkedIn URL\" (hs_linkedin_url) property for exact dedup; " +
|
|
1709
|
+
"acquire writes it on every new contact it creates, so coverage grows automatically.");
|
|
1710
|
+
}
|
|
1711
|
+
const seen = loadSeen();
|
|
1712
|
+
let records;
|
|
1713
|
+
let apiSkippedCrm = 0;
|
|
1714
|
+
let apiSkippedSeen = 0;
|
|
1715
|
+
let apiProcessedKeys = [];
|
|
1716
|
+
if (sourceConfig.kind === "api") {
|
|
1717
|
+
const api = await acquireFromApi(config, source, rest, icp, snapshot, seen);
|
|
1718
|
+
records = api.records;
|
|
1719
|
+
apiSkippedCrm = api.skippedCrm;
|
|
1720
|
+
apiSkippedSeen = api.skippedSeen;
|
|
1721
|
+
apiProcessedKeys = api.processedKeys;
|
|
1722
|
+
}
|
|
1723
|
+
else {
|
|
1724
|
+
const stagedLabel = option(rest, "--staged-run");
|
|
1725
|
+
const stagedRun = stagedLabel
|
|
1726
|
+
? await store.get(stagedLabel)
|
|
1727
|
+
: await store.latest({ source, mode: "ingest" });
|
|
1728
|
+
if (!stagedRun || stagedRun.mode !== "ingest") {
|
|
1729
|
+
throw new Error(`No staged data for source "${source}". Stage prospects first: fullstackgtm enrich ingest <prospects.csv|payload.json> --source ${source}`);
|
|
1730
|
+
}
|
|
1731
|
+
records = stagedSourceRecords(config, source, stagedRun);
|
|
1732
|
+
}
|
|
1733
|
+
// Meter: how many MORE leads may we create right now? --max is an
|
|
1734
|
+
// additional per-run ceiling, never a way to exceed the budget.
|
|
1735
|
+
const now = new Date();
|
|
1736
|
+
const costPerRecord = config.acquire.costPerRecord?.[source] ?? 0;
|
|
1737
|
+
if (apiSkippedCrm || apiSkippedSeen) {
|
|
1738
|
+
console.error(`Pre-email dedup: skipped ${apiSkippedCrm} already-in-CRM + ${apiSkippedSeen} seen before paying for emails ` +
|
|
1739
|
+
`(≈$${((apiSkippedCrm + apiSkippedSeen) * costPerRecord).toFixed(2)} enrichment saved).`);
|
|
1740
|
+
}
|
|
1741
|
+
const headroom = remaining(loadMeter(now), config.acquire.budget, costPerRecord, now);
|
|
1742
|
+
const explicitMax = numericOption(rest, "--max");
|
|
1743
|
+
let cap = headroom.maxRecords;
|
|
1744
|
+
if (explicitMax !== undefined)
|
|
1745
|
+
cap = cap === null ? explicitMax : Math.min(cap, explicitMax);
|
|
1746
|
+
// Assignment: never create an ownerless lead. An explicit `acquire.assign`
|
|
1747
|
+
// policy wins; a `--assign-owner <id>` flag is a quick fixed override;
|
|
1748
|
+
// otherwise, when the portal has exactly one active owner, default every
|
|
1749
|
+
// lead to them. With multiple owners and no policy we refuse to guess —
|
|
1750
|
+
// leads are left unassigned and the operator is told to configure a rule.
|
|
1751
|
+
const assignOwnerFlag = option(rest, "--assign-owner");
|
|
1752
|
+
if (!config.acquire.assign) {
|
|
1753
|
+
if (assignOwnerFlag) {
|
|
1754
|
+
config.acquire.assign = { strategy: "fixed", ownerId: assignOwnerFlag };
|
|
1755
|
+
}
|
|
1756
|
+
else {
|
|
1757
|
+
const activeOwners = (snapshot.users ?? []).filter((u) => u.active !== false);
|
|
1758
|
+
if (activeOwners.length === 1) {
|
|
1759
|
+
const sole = activeOwners[0].crmId ?? activeOwners[0].id;
|
|
1760
|
+
config.acquire.assign = { strategy: "fixed", ownerId: sole };
|
|
1761
|
+
console.error(`Assignment: no policy set — defaulting every lead to the portal's sole owner ` +
|
|
1762
|
+
`${activeOwners[0].name} (${sole}). Configure "acquire.assign" to route across reps.`);
|
|
1763
|
+
}
|
|
1764
|
+
else if (activeOwners.length > 1) {
|
|
1765
|
+
console.error(`⚠ Assignment: ${activeOwners.length} active owners and no "acquire.assign" policy — ` +
|
|
1766
|
+
`leads will be created OWNERLESS. Add an acquire.assign rule (fixed / round-robin / territory) ` +
|
|
1767
|
+
`or pass --assign-owner <id> so every lead lands with an owner.`);
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
const result = buildAcquirePlan({
|
|
1772
|
+
config,
|
|
1773
|
+
source,
|
|
1774
|
+
snapshot,
|
|
1775
|
+
records,
|
|
1776
|
+
runLabel: option(rest, "--run-label") ?? `acquire-${source}-${today}`,
|
|
1777
|
+
maxRecords: cap,
|
|
1778
|
+
});
|
|
1779
|
+
if (result.counts.unassigned > 0 && result.counts.created > 0) {
|
|
1780
|
+
console.error(`⚠ ${result.counts.unassigned}/${result.counts.created} proposed lead(s) have no owner ` +
|
|
1781
|
+
`(policy could not place them). They will be created ownerless.`);
|
|
1782
|
+
}
|
|
1783
|
+
// Observability: headline metrics for the web run timeline (paired users).
|
|
1784
|
+
reportCounts({
|
|
1785
|
+
sourced: result.counts.fetched,
|
|
1786
|
+
created: result.counts.created,
|
|
1787
|
+
withheldByMeter: result.counts.withheldByMeter,
|
|
1788
|
+
skippedInCrm: apiSkippedCrm,
|
|
1789
|
+
skippedSeen: apiSkippedSeen,
|
|
1790
|
+
estCostUsd: result.estCostUsd,
|
|
1791
|
+
});
|
|
1792
|
+
const meterLine = formatAcquireMeter(headroom, costPerRecord);
|
|
1793
|
+
if (!save) {
|
|
1794
|
+
if (rest.includes("--json")) {
|
|
1795
|
+
console.log(JSON.stringify({ plan: result.plan, counts: result.counts, estCostUsd: result.estCostUsd, meter: headroom }, null, 2));
|
|
1796
|
+
}
|
|
1797
|
+
else {
|
|
1798
|
+
console.log(patchPlanToMarkdown(result.plan));
|
|
1799
|
+
console.log(meterLine);
|
|
1800
|
+
console.log("\nDry run — nothing written. Re-run with --save to persist the plan, then approve + apply.");
|
|
1801
|
+
}
|
|
1802
|
+
return;
|
|
1803
|
+
}
|
|
1804
|
+
const run = await openEnrichRun(store, source, "append", option(rest, "--run-label"), today);
|
|
1805
|
+
const planIds = [];
|
|
1806
|
+
if (result.plan.operations.length > 0) {
|
|
1807
|
+
await createFilePlanStore().save(result.plan);
|
|
1808
|
+
planIds.push(result.plan.id);
|
|
1809
|
+
reportEvent("plan_saved", result.plan.id);
|
|
1810
|
+
}
|
|
1811
|
+
await store.update({
|
|
1812
|
+
...run,
|
|
1813
|
+
completedAt: new Date().toISOString(),
|
|
1814
|
+
cursor: null,
|
|
1815
|
+
planIds: [...(run.planIds ?? []), ...planIds],
|
|
1816
|
+
});
|
|
1817
|
+
// Remember everyone we email-resolved this run so the next run skips them
|
|
1818
|
+
// pre-email (cross-run credit saver). Committed (--save) runs only.
|
|
1819
|
+
if (apiProcessedKeys.length > 0)
|
|
1820
|
+
recordSeen(apiProcessedKeys, now);
|
|
1821
|
+
console.log(meterLine);
|
|
1822
|
+
if (planIds.length > 0) {
|
|
1823
|
+
console.log(`Saved plan ${result.plan.id} — ${result.counts.created} net-new lead(s), est. $${result.estCostUsd.toFixed(2)}. ` +
|
|
1824
|
+
`Review \`fullstackgtm plans show ${result.plan.id}\`, approve \`fullstackgtm plans approve ${result.plan.id} --operations all\`, ` +
|
|
1825
|
+
`then \`fullstackgtm apply --plan-id ${result.plan.id} --provider hubspot\`. The meter is charged only when a create lands at apply.`);
|
|
1826
|
+
}
|
|
1827
|
+
else {
|
|
1828
|
+
console.log("No net-new leads to create (everything sourced already matched, or was withheld by the meter).");
|
|
1829
|
+
}
|
|
1293
1830
|
return;
|
|
1294
1831
|
}
|
|
1295
|
-
const mode = subcommand;
|
|
1832
|
+
const mode = subcommand === "refresh" ? "refresh" : "append";
|
|
1296
1833
|
const source = resolveEnrichSource(config, rest);
|
|
1297
1834
|
const sourceConfig = config.sources[source];
|
|
1298
1835
|
const save = rest.includes("--save");
|
|
@@ -1381,6 +1918,12 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
|
|
|
1381
1918
|
// Pull keys the source had no data for count as fetched-but-unmatched.
|
|
1382
1919
|
result.counts.fetched += missCount;
|
|
1383
1920
|
result.counts.unmatched += missCount;
|
|
1921
|
+
reportCounts({
|
|
1922
|
+
fetched: result.counts.fetched,
|
|
1923
|
+
matched: result.counts.matched,
|
|
1924
|
+
unmatched: result.counts.unmatched,
|
|
1925
|
+
opsEmitted: result.counts.opsEmitted,
|
|
1926
|
+
});
|
|
1384
1927
|
if (!save) {
|
|
1385
1928
|
if (rest.includes("--json")) {
|
|
1386
1929
|
console.log(JSON.stringify(result.plan, null, 2));
|
|
@@ -1423,6 +1966,175 @@ function formatEnrichCounts(counts, ambiguities) {
|
|
|
1423
1966
|
`${counts.unmatched} unmatched · ${counts.ambiguous} ambiguous (${ambiguities} collision(s) recorded) · ` +
|
|
1424
1967
|
`${counts.opsEmitted} operation(s) proposed`);
|
|
1425
1968
|
}
|
|
1969
|
+
/** Best-effort enrich-config load for apply-time acquire-budget enforcement. */
|
|
1970
|
+
/** Provider API key: env override first, then the credential store (`login`). */
|
|
1971
|
+
function providerKey(provider) {
|
|
1972
|
+
const envName = provider === "explorium" ? "EXPLORIUM_API_KEY" : provider === "pipe0" ? "PIPE0_API_KEY" : "HEYREACH_API_KEY";
|
|
1973
|
+
if (process.env[envName])
|
|
1974
|
+
return process.env[envName];
|
|
1975
|
+
const stored = getCredential(provider);
|
|
1976
|
+
if (stored)
|
|
1977
|
+
return stored.accessToken;
|
|
1978
|
+
throw new Error(`No ${provider} credentials. Run \`echo "$KEY" | fullstackgtm login ${provider}\`, or set ${envName}.`);
|
|
1979
|
+
}
|
|
1980
|
+
/** Load the active ICP: --icp <path>, else ./icp.json. Undefined if none. */
|
|
1981
|
+
function loadIcp(args) {
|
|
1982
|
+
const explicit = option(args, "--icp");
|
|
1983
|
+
const path = resolve(process.cwd(), explicit ?? "icp.json");
|
|
1984
|
+
if (!existsSync(path)) {
|
|
1985
|
+
if (explicit)
|
|
1986
|
+
throw new Error(`--icp ${explicit}: file not found`);
|
|
1987
|
+
return undefined;
|
|
1988
|
+
}
|
|
1989
|
+
return parseIcp(readFileSync(path, "utf8"));
|
|
1990
|
+
}
|
|
1991
|
+
/**
|
|
1992
|
+
* `icp` — develop and inspect the Ideal Customer Profile that targets acquire.
|
|
1993
|
+
* The CLI can't run AskUserQuestion itself; `icp interview` emits the question
|
|
1994
|
+
* spec an agent (Claude Code / Codex) drives with its AskUserQuestion tool, then
|
|
1995
|
+
* `icp set` writes icp.json from the collected answers.
|
|
1996
|
+
*/
|
|
1997
|
+
async function icpCommand(args) {
|
|
1998
|
+
const [sub, ...rest] = args;
|
|
1999
|
+
if (!sub || sub === "--help" || sub === "-h") {
|
|
2000
|
+
console.log(`Usage:
|
|
2001
|
+
fullstackgtm icp interview emit the interview spec (an agent drives it with AskUserQuestion)
|
|
2002
|
+
fullstackgtm icp set <answers.json> [--name <n>] [--out <path>] write icp.json from interview answers
|
|
2003
|
+
fullstackgtm icp show [--icp <path>] render the ICP + the discovery filters it produces
|
|
2004
|
+
|
|
2005
|
+
The ICP makes \`enrich acquire\` targeted, not random: it generates each
|
|
2006
|
+
provider's discovery filters (Explorium, pipe0/Crustdata) AND scores every
|
|
2007
|
+
discovered prospect for fit — only above-threshold leads become create_record
|
|
2008
|
+
ops. Develop one by interview, then \`enrich acquire\` picks up ./icp.json.`);
|
|
2009
|
+
return;
|
|
2010
|
+
}
|
|
2011
|
+
if (sub === "interview") {
|
|
2012
|
+
console.log(JSON.stringify({
|
|
2013
|
+
questions: INTERVIEW_SPEC,
|
|
2014
|
+
instructions: "Ask each question with AskUserQuestion (multiSelect per .multiSelect). For each answer, collect the chosen options' .value arrays and concat them under the question's .id. Then run `fullstackgtm icp set <answers.json>` with that flattened object.",
|
|
2015
|
+
}, null, 2));
|
|
2016
|
+
return;
|
|
2017
|
+
}
|
|
2018
|
+
if (sub === "set") {
|
|
2019
|
+
const file = rest.find((a) => !a.startsWith("--"));
|
|
2020
|
+
if (!file)
|
|
2021
|
+
throw new Error("Usage: fullstackgtm icp set <answers.json> [--name <n>] [--out <path>]");
|
|
2022
|
+
const answers = JSON.parse(readFileSync(resolve(process.cwd(), file), "utf8"));
|
|
2023
|
+
const icp = icpFromAnswers(option(rest, "--name") ?? "ICP", answers);
|
|
2024
|
+
const out = resolve(process.cwd(), option(rest, "--out") ?? "icp.json");
|
|
2025
|
+
writeFileSync(out, `${JSON.stringify(icp, null, 2)}\n`);
|
|
2026
|
+
console.log(`Wrote ${out} — ${icp.persona.titleKeywords?.length ?? 0} title keyword(s), ` +
|
|
2027
|
+
`${icp.firmographics.employeeBands?.length ?? 0} size band(s), fit threshold ${fitThreshold(icp)}.`);
|
|
2028
|
+
return;
|
|
2029
|
+
}
|
|
2030
|
+
if (sub === "show") {
|
|
2031
|
+
const icp = loadIcp(rest);
|
|
2032
|
+
if (!icp)
|
|
2033
|
+
throw new Error("No ICP found (icp.json in cwd, or pass --icp <path>). Build one: fullstackgtm icp interview.");
|
|
2034
|
+
console.log(JSON.stringify({
|
|
2035
|
+
icp,
|
|
2036
|
+
exploriumFilters: icpToExploriumFilters(icp),
|
|
2037
|
+
crustdataFilters: icpToCrustdataFilters(icp),
|
|
2038
|
+
fitThreshold: fitThreshold(icp),
|
|
2039
|
+
}, null, 2));
|
|
2040
|
+
return;
|
|
2041
|
+
}
|
|
2042
|
+
throw new Error(`Unknown icp subcommand: ${sub} (try: interview, set, show)`);
|
|
2043
|
+
}
|
|
2044
|
+
/**
|
|
2045
|
+
* Pull net-new prospects from an API acquire source into source records the
|
|
2046
|
+
* acquire builder dedupes + turns into create_record ops. Explorium discovers;
|
|
2047
|
+
* pipe0 (when resolveEmailsWith=pipe0) fills real work emails. Only rows that
|
|
2048
|
+
* carry the dedupe key (email) survive — you cannot resolve-first without it.
|
|
2049
|
+
*/
|
|
2050
|
+
async function acquireFromApi(config, source, rest, icp, snapshot, seen) {
|
|
2051
|
+
const acquire = config.acquire;
|
|
2052
|
+
const disc = acquire.discovery?.[source];
|
|
2053
|
+
if (!disc) {
|
|
2054
|
+
throw new Error(`enrich acquire: api source "${source}" needs an "acquire.discovery.${source}" config (provider + size).`);
|
|
2055
|
+
}
|
|
2056
|
+
const matchKey = acquire.create.contact?.matchKey ?? "email";
|
|
2057
|
+
const maxOverride = numericOption(rest, "--max");
|
|
2058
|
+
const size = maxOverride !== undefined ? Math.min(maxOverride, disc.size ?? 25) : disc.size ?? 25;
|
|
2059
|
+
// 1. Discover. Filters come from the ICP when one is loaded (the whole point —
|
|
2060
|
+
// targeted, not random); otherwise from the hand-written disc.filters.
|
|
2061
|
+
let prospects;
|
|
2062
|
+
if (disc.provider === "explorium") {
|
|
2063
|
+
const filters = icp
|
|
2064
|
+
? icpToExploriumFilters(icp)
|
|
2065
|
+
: (disc.filters ?? {});
|
|
2066
|
+
prospects = await fetchExploriumProspects({ apiKey: providerKey("explorium"), filters, size });
|
|
2067
|
+
}
|
|
2068
|
+
else if (disc.provider === "pipe0") {
|
|
2069
|
+
const filters = icp ? icpToCrustdataFilters(icp) : (disc.filters ?? {});
|
|
2070
|
+
prospects = await fetchPipe0CrustdataProspects({ apiKey: providerKey("pipe0"), filters, limit: size });
|
|
2071
|
+
}
|
|
2072
|
+
else if (disc.provider === "linkedin" || disc.provider === "heyreach") {
|
|
2073
|
+
// LinkedIn reads a pre-populated lead list (not an ICP-driven query); the ICP
|
|
2074
|
+
// scores the pulled list below. List id: disc.listId or --list <id>.
|
|
2075
|
+
const listId = disc.listId ?? option(rest, "--list") ?? undefined;
|
|
2076
|
+
if (!listId) {
|
|
2077
|
+
throw new Error("enrich acquire --source linkedin needs a HeyReach lead-list id: pass --list <id> or set acquire.discovery.linkedin.listId.");
|
|
2078
|
+
}
|
|
2079
|
+
const provider = createLinkedInProvider(disc.provider, providerKey("heyreach"));
|
|
2080
|
+
prospects = await discoverLinkedInProspects(provider, { sourceId: listId, max: size });
|
|
2081
|
+
}
|
|
2082
|
+
else {
|
|
2083
|
+
throw new Error(`enrich acquire: unknown discovery provider "${disc.provider}" (explorium | pipe0 | linkedin).`);
|
|
2084
|
+
}
|
|
2085
|
+
// 2. ICP fit scoring BEFORE paying for emails — only above-threshold prospects
|
|
2086
|
+
// proceed to (credit-spending) email resolution.
|
|
2087
|
+
if (icp) {
|
|
2088
|
+
const threshold = fitThreshold(icp);
|
|
2089
|
+
prospects = prospects
|
|
2090
|
+
.map((p) => ({ ...p, fitScore: scoreProspectAgainstIcp(p, icp).score }))
|
|
2091
|
+
.filter((p) => (p.fitScore ?? 0) >= threshold);
|
|
2092
|
+
}
|
|
2093
|
+
// 2.5 Pre-email dedup — drop prospects already in the CRM (name+domain match
|
|
2094
|
+
// vs the snapshot) or already processed in a prior run (the seen cache),
|
|
2095
|
+
// BEFORE paying pipe0 for emails. The email-level dedup (buildAcquirePlan)
|
|
2096
|
+
// and apply-time resolve-first remain the precise backstop.
|
|
2097
|
+
const { fresh, skippedCrm, skippedSeen } = partitionFreshProspects(prospects, crmContactKeys(snapshot), seen);
|
|
2098
|
+
prospects = fresh;
|
|
2099
|
+
// 3. Resolve real work emails (both providers need it: Explorium's email is
|
|
2100
|
+
// hashed, Crustdata search returns none). pipe0 waterfall, chunked.
|
|
2101
|
+
if (matchKey === "email") {
|
|
2102
|
+
prospects = await pipe0ResolveWorkEmails({ apiKey: providerKey("pipe0"), prospects });
|
|
2103
|
+
}
|
|
2104
|
+
const processedKeys = prospects.flatMap(prospectIdentityKeys);
|
|
2105
|
+
const records = prospects
|
|
2106
|
+
.map((p) => {
|
|
2107
|
+
const keyValue = p[matchKey];
|
|
2108
|
+
return {
|
|
2109
|
+
id: p.sourceId ?? `${source}:${keyValue ?? p.fullName ?? ""}`,
|
|
2110
|
+
objectType: "contact",
|
|
2111
|
+
keys: { [matchKey]: keyValue },
|
|
2112
|
+
payload: p,
|
|
2113
|
+
};
|
|
2114
|
+
})
|
|
2115
|
+
.filter((record) => Boolean(record.keys[matchKey]));
|
|
2116
|
+
return { records, skippedCrm, skippedSeen, processedKeys };
|
|
2117
|
+
}
|
|
2118
|
+
function tryLoadAcquireConfig(args) {
|
|
2119
|
+
try {
|
|
2120
|
+
const path = resolve(process.cwd(), option(args, "--config") ?? ENRICH_CONFIG_FILE_NAME);
|
|
2121
|
+
if (!existsSync(path))
|
|
2122
|
+
return undefined;
|
|
2123
|
+
return loadEnrichConfig(path);
|
|
2124
|
+
}
|
|
2125
|
+
catch {
|
|
2126
|
+
return undefined;
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
function formatAcquireMeter(headroom, costPerRecord) {
|
|
2130
|
+
const n = (v) => (v === null ? "∞" : String(v));
|
|
2131
|
+
const money = (v) => (v === null ? "∞" : `$${v.toFixed(2)}`);
|
|
2132
|
+
const max = headroom.maxRecords === null ? "unlimited" : `${headroom.maxRecords}`;
|
|
2133
|
+
return (`Acquire meter — creatable now: ${max} lead(s). ` +
|
|
2134
|
+
`Records left: ${n(headroom.records.day)}/day, ${n(headroom.records.month)}/month · ` +
|
|
2135
|
+
`Spend left: ${money(headroom.spendUsd.day)}/day, ${money(headroom.spendUsd.month)}/month ` +
|
|
2136
|
+
`(≈$${costPerRecord.toFixed(2)}/lead).`);
|
|
2137
|
+
}
|
|
1426
2138
|
function resolveEnrichSource(config, rest) {
|
|
1427
2139
|
const requested = option(rest, "--source");
|
|
1428
2140
|
const declared = Object.keys(config.sources);
|
|
@@ -1496,7 +2208,7 @@ async function openEnrichRun(store, source, mode, requestedLabel, today) {
|
|
|
1496
2208
|
stamps: [],
|
|
1497
2209
|
});
|
|
1498
2210
|
}
|
|
1499
|
-
async function enrichIngest(store, config, rest) {
|
|
2211
|
+
async function enrichIngest(store, config, rest, autoAppend = false) {
|
|
1500
2212
|
const file = rest.find((arg) => !arg.startsWith("--") && !isOptionValue(rest, arg));
|
|
1501
2213
|
if (!file)
|
|
1502
2214
|
throw new Error("Usage: fullstackgtm enrich ingest <file.csv|payload.json> --source <id> [--run-label <label>]");
|
|
@@ -1560,8 +2272,10 @@ async function enrichIngest(store, config, rest) {
|
|
|
1560
2272
|
staged: rows,
|
|
1561
2273
|
stagedObjectType: objectType,
|
|
1562
2274
|
});
|
|
1563
|
-
console.log(
|
|
1564
|
-
`
|
|
2275
|
+
console.log(autoAppend
|
|
2276
|
+
? `Staged ${rows.length} ${objectType} row(s) from ${file} as run ${label}; matching against the CRM…`
|
|
2277
|
+
: `Staged ${rows.length} ${objectType} row(s) from ${file} as run ${label}. ` +
|
|
2278
|
+
`Next: fullstackgtm enrich append --source ${source} [source options] [--save]`);
|
|
1565
2279
|
}
|
|
1566
2280
|
function parseSingleObjectType(value) {
|
|
1567
2281
|
const normalized = value.trim().toLowerCase();
|
|
@@ -2166,8 +2880,9 @@ async function dedupeCommand(args) {
|
|
|
2166
2880
|
async function reassignCommand(args) {
|
|
2167
2881
|
const from = option(args, "--from");
|
|
2168
2882
|
const to = option(args, "--to");
|
|
2169
|
-
|
|
2170
|
-
|
|
2883
|
+
const assignUnowned = args.includes("--assign-unowned");
|
|
2884
|
+
if (!to || (!from && !assignUnowned)) {
|
|
2885
|
+
throw new Error("Usage: fullstackgtm reassign (--from <ownerId> | --assign-unowned) --to <ownerId> [--objects account,contact,deal] [--where <expr> …] [--except-deal-stage <stage>] [--include-closed-deals] [source options] [--reason <text>] [--max-operations <n>] [--save] [--out <path>] [--json]\n\n--assign-unowned claims every OWNERLESS record (ownerId:empty) for --to — the backfill twin of `enrich acquire`'s create-time assignment.");
|
|
2171
2886
|
}
|
|
2172
2887
|
const objects = option(args, "--objects")
|
|
2173
2888
|
?.split(",")
|
|
@@ -2175,8 +2890,9 @@ async function reassignCommand(args) {
|
|
|
2175
2890
|
.filter(Boolean);
|
|
2176
2891
|
const snapshot = await readSnapshot(args);
|
|
2177
2892
|
const plans = buildReassignPlans(snapshot, {
|
|
2178
|
-
fromOwnerId: from,
|
|
2893
|
+
fromOwnerId: from ?? undefined,
|
|
2179
2894
|
toOwnerId: to,
|
|
2895
|
+
assignUnowned,
|
|
2180
2896
|
objects,
|
|
2181
2897
|
where: repeatedOption(args, "--where"),
|
|
2182
2898
|
exceptDealStage: option(args, "--except-deal-stage") ?? undefined,
|
|
@@ -2473,6 +3189,34 @@ async function apply(args) {
|
|
|
2473
3189
|
: approve.split(",").map((id) => id.trim()).filter(Boolean);
|
|
2474
3190
|
valueOverrides = parseValueOverrides(args);
|
|
2475
3191
|
}
|
|
3192
|
+
// Acquire meter: create_record ops are budgeted. Refuse up-front if the
|
|
3193
|
+
// approved creates would exceed the current budget window (the plan was
|
|
3194
|
+
// capped when `enrich acquire` ran, but the budget may have been spent down
|
|
3195
|
+
// since), then charge the meter for what actually lands.
|
|
3196
|
+
const approvedSet = new Set(approvedOperationIds);
|
|
3197
|
+
const createOps = plan.operations.filter((op) => op.operation === "create_record" && approvedSet.has(op.id));
|
|
3198
|
+
const createSpend = (op) => (op.afterValue?.estCostUsd) ?? 0;
|
|
3199
|
+
if (createOps.length > 0) {
|
|
3200
|
+
const acquireConfig = tryLoadAcquireConfig(args)?.acquire;
|
|
3201
|
+
if (acquireConfig?.budget) {
|
|
3202
|
+
const now = new Date();
|
|
3203
|
+
const head = remaining(loadMeter(now), acquireConfig.budget, 0, now);
|
|
3204
|
+
const approvedSpend = createOps.reduce((sum, op) => sum + createSpend(op), 0);
|
|
3205
|
+
const refusals = [];
|
|
3206
|
+
if (head.records.day !== null && createOps.length > head.records.day)
|
|
3207
|
+
refusals.push(`${createOps.length} creates > ${head.records.day} record(s) left today`);
|
|
3208
|
+
if (head.records.month !== null && createOps.length > head.records.month)
|
|
3209
|
+
refusals.push(`${createOps.length} creates > ${head.records.month} record(s) left this month`);
|
|
3210
|
+
if (head.spendUsd.day !== null && approvedSpend > head.spendUsd.day)
|
|
3211
|
+
refusals.push(`$${approvedSpend.toFixed(2)} > $${head.spendUsd.day.toFixed(2)} spend left today`);
|
|
3212
|
+
if (head.spendUsd.month !== null && approvedSpend > head.spendUsd.month)
|
|
3213
|
+
refusals.push(`$${approvedSpend.toFixed(2)} > $${head.spendUsd.month.toFixed(2)} spend left this month`);
|
|
3214
|
+
if (refusals.length > 0) {
|
|
3215
|
+
throw new Error(`Refusing to apply: acquire budget exceeded (${refusals.join("; ")}). ` +
|
|
3216
|
+
"Re-run `fullstackgtm enrich acquire` to re-cap to the remaining budget, or wait for the window to reset.");
|
|
3217
|
+
}
|
|
3218
|
+
}
|
|
3219
|
+
}
|
|
2476
3220
|
const connector = await connectorFor(provider, args);
|
|
2477
3221
|
const run = await applyPatchPlan(connector, plan, {
|
|
2478
3222
|
approvedOperationIds,
|
|
@@ -2481,6 +3225,20 @@ async function apply(args) {
|
|
|
2481
3225
|
if (planId && store) {
|
|
2482
3226
|
await store.recordRun(planId, run);
|
|
2483
3227
|
}
|
|
3228
|
+
// Charge the acquire meter for the creates that actually landed.
|
|
3229
|
+
if (createOps.length > 0) {
|
|
3230
|
+
const appliedIds = new Set(run.results.filter((result) => result.status === "applied").map((result) => result.operationId));
|
|
3231
|
+
const landed = createOps.filter((op) => appliedIds.has(op.id));
|
|
3232
|
+
if (landed.length > 0) {
|
|
3233
|
+
const spend = landed.reduce((sum, op) => sum + createSpend(op), 0);
|
|
3234
|
+
recordConsumption(new Date(), landed.length, spend);
|
|
3235
|
+
}
|
|
3236
|
+
}
|
|
3237
|
+
// Observability: apply-outcome tallies for the web run timeline.
|
|
3238
|
+
const applyTally = { applied: 0, conflict: 0, skipped: 0, failed: 0 };
|
|
3239
|
+
for (const result of run.results)
|
|
3240
|
+
applyTally[result.status] = (applyTally[result.status] ?? 0) + 1;
|
|
3241
|
+
reportCounts(applyTally);
|
|
2484
3242
|
if (args.includes("--json")) {
|
|
2485
3243
|
console.log(JSON.stringify(run, null, 2));
|
|
2486
3244
|
}
|
|
@@ -2925,8 +3683,20 @@ async function login(args) {
|
|
|
2925
3683
|
console.log(`Stored Apollo API key in ${credentialsPath()}. \`fullstackgtm enrich append|refresh\` use it automatically.`);
|
|
2926
3684
|
return;
|
|
2927
3685
|
}
|
|
3686
|
+
if (provider === "pipe0" || provider === "explorium" || provider === "heyreach") {
|
|
3687
|
+
rejectArgvSecret(args, "--token", "--key", "--api-key");
|
|
3688
|
+
const key = await readSecret(`${provider} API key`);
|
|
3689
|
+
if (!key)
|
|
3690
|
+
throw new Error(`No ${provider} key provided.`);
|
|
3691
|
+
// No free auth-health endpoint; validating would spend credits, so the key
|
|
3692
|
+
// is stored as-is and validated on the first `enrich acquire` pull.
|
|
3693
|
+
const stamp = new Date().toISOString();
|
|
3694
|
+
storeCredential(provider, { kind: "api_key", accessToken: key, createdAt: stamp, updatedAt: stamp });
|
|
3695
|
+
console.log(`Stored ${provider} API key in ${credentialsPath()}. \`fullstackgtm enrich acquire\` uses it automatically (validated on first pull).`);
|
|
3696
|
+
return;
|
|
3697
|
+
}
|
|
2928
3698
|
if (provider !== "hubspot") {
|
|
2929
|
-
throw new Error("login supports: hubspot, salesforce, stripe, anthropic, openai, apollo, or --via <hosted url>. Usage: fullstackgtm login <provider> | fullstackgtm login --via https://gtm.example.com");
|
|
3699
|
+
throw new Error("login supports: hubspot, salesforce, stripe, anthropic, openai, apollo, pipe0, explorium, or --via <hosted url>. Usage: fullstackgtm login <provider> | fullstackgtm login --via https://gtm.example.com");
|
|
2930
3700
|
}
|
|
2931
3701
|
const now = new Date().toISOString();
|
|
2932
3702
|
if (args.includes("--oauth")) {
|
|
@@ -3123,19 +3893,32 @@ function profilesCommand(args) {
|
|
|
3123
3893
|
}
|
|
3124
3894
|
export async function runCli(argv) {
|
|
3125
3895
|
const [command, ...args] = extractProfile(argv);
|
|
3896
|
+
// Front door: the lifecycle-grouped map by default, the full reference on
|
|
3897
|
+
// --full. `help <command>` zooms into one verb. (docs/dx-punch-list.md #1)
|
|
3126
3898
|
if (!command || command === "--help" || command === "-h") {
|
|
3127
|
-
console.log(usage());
|
|
3899
|
+
console.log(args.includes("--full") ? usage() : shortUsage());
|
|
3900
|
+
return;
|
|
3901
|
+
}
|
|
3902
|
+
if (command === "help") {
|
|
3903
|
+
const [topic, ...rest] = args;
|
|
3904
|
+
if (topic && topic !== "--full" && !topic.startsWith("-")) {
|
|
3905
|
+
console.log(commandHelp(topic));
|
|
3906
|
+
}
|
|
3907
|
+
else {
|
|
3908
|
+
console.log(args.includes("--full") || topic === "--full" ? usage() : shortUsage());
|
|
3909
|
+
}
|
|
3128
3910
|
return;
|
|
3129
3911
|
}
|
|
3130
3912
|
if (command === "--version" || command === "-v" || command === "version") {
|
|
3131
3913
|
console.log(readPackageInfo().version);
|
|
3132
3914
|
return;
|
|
3133
3915
|
}
|
|
3134
|
-
// Commands without bespoke help
|
|
3135
|
-
// instead of executing (audit used to silently run the sample audit)
|
|
3136
|
-
// call/market/enrich/bulk-update/schedule print
|
|
3137
|
-
|
|
3138
|
-
|
|
3916
|
+
// Commands without bespoke help get focused per-command help on --help
|
|
3917
|
+
// instead of executing (audit used to silently run the sample audit) or
|
|
3918
|
+
// dumping the whole surface. call/market/enrich/bulk-update/schedule print
|
|
3919
|
+
// their own richer help. `--full` always escapes to the complete reference.
|
|
3920
|
+
if (!BESPOKE_HELP.includes(command) && (args.includes("--help") || args.includes("-h"))) {
|
|
3921
|
+
console.log(args.includes("--full") ? usage() : commandHelp(command));
|
|
3139
3922
|
return;
|
|
3140
3923
|
}
|
|
3141
3924
|
if (command === "login") {
|
|
@@ -3166,6 +3949,10 @@ export async function runCli(argv) {
|
|
|
3166
3949
|
doctorCommand(args);
|
|
3167
3950
|
return;
|
|
3168
3951
|
}
|
|
3952
|
+
if (command === "health") {
|
|
3953
|
+
healthCommand(args);
|
|
3954
|
+
return;
|
|
3955
|
+
}
|
|
3169
3956
|
if (command === "suggest") {
|
|
3170
3957
|
await suggest(args);
|
|
3171
3958
|
return;
|
|
@@ -3202,6 +3989,10 @@ export async function runCli(argv) {
|
|
|
3202
3989
|
await enrichCommand(args);
|
|
3203
3990
|
return;
|
|
3204
3991
|
}
|
|
3992
|
+
if (command === "icp") {
|
|
3993
|
+
await icpCommand(args);
|
|
3994
|
+
return;
|
|
3995
|
+
}
|
|
3205
3996
|
if (command === "schedule") {
|
|
3206
3997
|
await scheduleCommand(args);
|
|
3207
3998
|
return;
|