fullstackgtm 0.34.0 → 0.37.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/src/cli.ts CHANGED
@@ -33,6 +33,15 @@ import {
33
33
  } from "./credentials.ts";
34
34
  import { generateDemoSnapshot } from "./demo.ts";
35
35
  import { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
36
+ import {
37
+ appendHealthEntry,
38
+ computeHealth,
39
+ healthToMarkdown,
40
+ readHealthTimeline,
41
+ saveWorkspaceSnapshot,
42
+ summarizeHealth,
43
+ activeWorkspaceProfile,
44
+ } from "./health.ts";
36
45
  import { mergeSnapshots } from "./merge.ts";
37
46
  import { verifyApprovalDigests } from "./integrity.ts";
38
47
  import { buildAuditLog, verifyAuditLog } from "./auditLog.ts";
@@ -87,10 +96,13 @@ import {
87
96
  type Rubric,
88
97
  } from "./llm.ts";
89
98
  import {
99
+ buildAcquirePlan,
90
100
  buildEnrichPlan,
91
101
  createFileEnrichRunStore,
92
102
  DEFAULT_STALE_DAYS,
93
103
  ENRICH_CONFIG_FILE_NAME,
104
+ builtinAcquirePreset,
105
+ builtinEnrichPreset,
94
106
  enrichRunId,
95
107
  inferIngestObjectType,
96
108
  latestStamps,
@@ -107,6 +119,32 @@ import {
107
119
  type EnrichRunStore,
108
120
  type EnrichSourceRecord,
109
121
  } from "./enrich.ts";
122
+ import {
123
+ loadMeter,
124
+ recordConsumption,
125
+ remaining,
126
+ type AcquireRemaining,
127
+ } from "./acquireMeter.ts";
128
+ import {
129
+ crmContactKeys,
130
+ fetchExploriumProspects,
131
+ fetchPipe0CrustdataProspects,
132
+ partitionFreshProspects,
133
+ pipe0ResolveWorkEmails,
134
+ prospectIdentityKeys,
135
+ type Prospect,
136
+ } from "./connectors/prospectSources.ts";
137
+ import { loadSeen, recordSeen } from "./acquireSeen.ts";
138
+ import {
139
+ fitThreshold,
140
+ icpFromAnswers,
141
+ icpToCrustdataFilters,
142
+ icpToExploriumFilters,
143
+ parseIcp,
144
+ scoreProspectAgainstIcp,
145
+ INTERVIEW_SPEC,
146
+ type Icp,
147
+ } from "./icp.ts";
110
148
  import {
111
149
  apolloPullKeysForAppend,
112
150
  apolloPullKeysForRefresh,
@@ -141,7 +179,9 @@ import type { FieldMappings } from "./mappings.ts";
141
179
  import type {
142
180
  AuditFindingSeverity,
143
181
  CanonicalGtmSnapshot,
182
+ CreateRecordPayload,
144
183
  GtmConnector,
184
+ PatchOperation,
145
185
  PatchPlan,
146
186
  } from "./types.ts";
147
187
 
@@ -157,7 +197,7 @@ Usage:
157
197
  fullstackgtm login salesforce --instance-url <url> [--no-validate]
158
198
  fullstackgtm login stripe [--no-validate]
159
199
  fullstackgtm login anthropic | openai store an LLM API key for call parse/score
160
- fullstackgtm login apollo store an Apollo API key for enrich pulls\n fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|apollo|broker>
200
+ 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 logout <hubspot|salesforce|stripe|anthropic|openai|apollo|pipe0|explorium|broker>
161
201
 
162
202
  Secrets (tokens, client secrets) are NEVER passed as flags — they leak via
163
203
  the process list and shell history. Pipe them on stdin or enter them at the
@@ -341,6 +381,309 @@ Safety:
341
381
  and never writes requires_human_* placeholders without a --value override.`;
342
382
  }
343
383
 
384
+ // ── Progressive-disclosure help ─────────────────────────────────────────────
385
+ // usage() above is the full reference (`fullstackgtm help --full`). The default
386
+ // front door is shortUsage() — a lifecycle-grouped map, one line per verb — and
387
+ // commandHelp() gives focused per-verb help so `<verb> --help` zooms in instead
388
+ // of dumping the whole 22-verb / 87-flag surface. See docs/dx-punch-list.md
389
+ // (F1/F3). Commands with their own rich help (call/market/enrich/bulk-update/
390
+ // schedule) print it themselves; here they carry a one-line pointer.
391
+
392
+ type HelpEntry = {
393
+ summary: string; // one line, shown in the grouped map
394
+ phase: string; // where the verb sits in Prevent→Detect→Remediate→Verify
395
+ synopsis: string[]; // invocation form(s)
396
+ detail?: string; // a sentence or two on behavior
397
+ options?: Array<[string, string]>; // the flags that matter most
398
+ seeAlso?: string[]; // related verbs to chain
399
+ };
400
+
401
+ const HELP: Record<string, HelpEntry> = {
402
+ // Setup & health
403
+ login: {
404
+ summary: "connect a provider or LLM key (secrets via stdin/env, never argv)",
405
+ phase: "Setup",
406
+ synopsis: [
407
+ "fullstackgtm login --via <hosted url> pair with a team deployment",
408
+ "fullstackgtm login hubspot | salesforce | stripe",
409
+ "fullstackgtm login anthropic | openai | apollo",
410
+ ],
411
+ detail:
412
+ "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`.",
413
+ seeAlso: ["doctor", "logout", "profiles"],
414
+ },
415
+ logout: {
416
+ summary: "remove stored credentials for a provider",
417
+ phase: "Setup",
418
+ synopsis: ["fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|apollo|pipe0|explorium|broker>"],
419
+ seeAlso: ["login", "doctor"],
420
+ },
421
+ doctor: {
422
+ summary: "check install, credentials, and the next step",
423
+ phase: "Setup",
424
+ synopsis: ["fullstackgtm doctor [--json]"],
425
+ detail: "Verifies Node version, the credential store, MCP peers, and prints what to run next.",
426
+ seeAlso: ["login", "audit"],
427
+ },
428
+ profiles: {
429
+ summary: "list credential profiles (one per client org)",
430
+ phase: "Setup",
431
+ synopsis: ["fullstackgtm profiles [--json]"],
432
+ detail:
433
+ "`--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.",
434
+ seeAlso: ["login", "plans", "health"],
435
+ },
436
+ health: {
437
+ summary: "CRM health score + trend for the active profile (read-only)",
438
+ phase: "Detect",
439
+ synopsis: ["fullstackgtm health [--json]"],
440
+ detail:
441
+ "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>`.",
442
+ seeAlso: ["audit", "profiles", "report"],
443
+ },
444
+
445
+ // Detect — read-only
446
+ snapshot: {
447
+ summary: "pull a canonical GTM snapshot (read-only)",
448
+ phase: "Detect",
449
+ synopsis: ["fullstackgtm snapshot [source options] [--since <iso>] [--out <path> | --archive <dir>]"],
450
+ detail: "Materializes the provider's records as canonical JSON — the input every audit and write verb reads.",
451
+ options: [
452
+ ["--provider <name>", "hubspot | salesforce | stripe (read-only)"],
453
+ ["--demo", "realistic generated CRM with injected hygiene issues"],
454
+ ["--out <path>", "write the snapshot JSON to a file"],
455
+ ],
456
+ seeAlso: ["audit", "diff"],
457
+ },
458
+ audit: {
459
+ summary: "read-only hygiene audit → reviewable dry-run patch plan",
460
+ phase: "Detect",
461
+ synopsis: ["fullstackgtm audit [source options] [audit options] [--save]"],
462
+ detail:
463
+ "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`.",
464
+ options: [
465
+ ["--provider <name>", "live snapshot: hubspot | salesforce | stripe"],
466
+ ["--demo", "try it with zero credentials on a messy generated CRM"],
467
+ ["--full", "show every operation, not just the rule summary"],
468
+ ["--rules <ids>", "comma-separated rule ids (default: all; see `rules`)"],
469
+ ["--fail-on <sev>", "exit 2 if any finding ≥ info|warning|critical"],
470
+ ["--save", "persist the dry-run plan for approve → apply"],
471
+ ["--json / --out <p>", "machine-readable plan to stdout / file"],
472
+ ],
473
+ seeAlso: ["report", "suggest", "plans", "apply", "diff"],
474
+ },
475
+ report: {
476
+ summary: "render an audit as a client-ready deliverable (md/html)",
477
+ phase: "Detect",
478
+ synopsis: ["fullstackgtm report [source options] [audit options] [report options]"],
479
+ detail: "The human-readable counterpart to `audit` — a clean summary instead of the full per-operation dump.",
480
+ options: [
481
+ ["--plan <path>", "render an existing plan JSON instead of re-auditing"],
482
+ ["--client <name>", "organization name in the heading/summary"],
483
+ ["--format <fmt>", "markdown (default) or self-contained html"],
484
+ ["--out <path>", "write to a file (html inferred from .html)"],
485
+ ],
486
+ seeAlso: ["audit"],
487
+ },
488
+ diff: {
489
+ summary: "compare two snapshots/plans; gate on new findings",
490
+ phase: "Detect",
491
+ synopsis: ["fullstackgtm diff --before <a.json> --after <b.json> [--json] [--fail-on-new-findings]"],
492
+ detail:
493
+ "The regression primitive. Exit 2 when a (rule, record) pair fires that didn't before — wire it into CI to catch CRM rot.",
494
+ seeAlso: ["audit", "snapshot", "merge"],
495
+ },
496
+ rules: {
497
+ summary: "list the audit rule registry",
498
+ phase: "Detect",
499
+ synopsis: ["fullstackgtm rules [--json]"],
500
+ seeAlso: ["audit"],
501
+ },
502
+
503
+ // Prevent — gate writes before they happen
504
+ resolve: {
505
+ summary: "the create gate: exit 0 = safe to create, 2 = match exists",
506
+ phase: "Prevent",
507
+ synopsis: ["fullstackgtm resolve <account|contact|deal> [--name N] [--domain D] [--email E] [source options] [--json]"],
508
+ detail:
509
+ "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.",
510
+ seeAlso: ["dedupe", "audit"],
511
+ },
512
+
513
+ // Remediate — governed writes (all produce plans; nothing writes outside approve → apply)
514
+ fix: {
515
+ summary: "one-shot composite: audit one rule → suggest → approve → apply",
516
+ phase: "Remediate",
517
+ synopsis: ["fullstackgtm fix --rule <ruleId> --provider <name> [--min-confidence high|low] [--include-creates] [--yes]"],
518
+ detail:
519
+ "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.",
520
+ seeAlso: ["audit", "suggest", "plans", "apply"],
521
+ },
522
+ dedupe: {
523
+ summary: "merge duplicate groups by identity key (irreversible on apply)",
524
+ phase: "Remediate",
525
+ synopsis: ["fullstackgtm dedupe <account|contact|deal> --key <domain|email|name> [--keep richest|oldest] [--save] [--json]"],
526
+ detail:
527
+ "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.",
528
+ seeAlso: ["resolve", "plans", "apply"],
529
+ },
530
+ reassign: {
531
+ summary: "ownership handoff: one plan per object type",
532
+ phase: "Remediate",
533
+ synopsis: ["fullstackgtm reassign --from <ownerId> --to <ownerId> [--objects account,contact,deal] [--where <expr> …] [--save] [--json]"],
534
+ detail:
535
+ "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.",
536
+ seeAlso: ["bulk-update", "plans", "apply"],
537
+ },
538
+ enrich: {
539
+ summary: "governed third-party enrichment (Apollo/Clay), fill-blanks-only",
540
+ phase: "Remediate",
541
+ synopsis: ["fullstackgtm enrich append|refresh|ingest|status … (run `enrich --help` for full options)"],
542
+ detail:
543
+ "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.",
544
+ seeAlso: ["plans", "apply", "schedule"],
545
+ },
546
+
547
+ // Calls → evidence
548
+ call: {
549
+ summary: "transcripts → evidence, rubric scores, deal links, governed writes",
550
+ phase: "Remediate",
551
+ synopsis: ["fullstackgtm call parse|classify|score|link|plan … (run `call --help` for full options)"],
552
+ detail:
553
+ "`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.",
554
+ seeAlso: ["plans", "apply"],
555
+ },
556
+
557
+ // Govern — the plan/apply spine
558
+ suggest: {
559
+ summary: "derive values for requires_human_* placeholders (evidence-backed)",
560
+ phase: "Govern",
561
+ synopsis: ["fullstackgtm suggest --plan-id <id> | --plan <path> [source options] [--json] [--out <path>]"],
562
+ detail:
563
+ "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.",
564
+ seeAlso: ["audit", "plans", "apply"],
565
+ },
566
+ plans: {
567
+ summary: "plan lifecycle: list / show / approve / reject saved plans",
568
+ phase: "Govern",
569
+ synopsis: [
570
+ "fullstackgtm plans list [--status <s>] | show <id> | reject <id>",
571
+ "fullstackgtm plans approve <id> --operations <ids|all> [--value <opId>=<v>]",
572
+ "fullstackgtm plans approve <id> --values-from <suggestions.json> [--min-confidence high|low]",
573
+ ],
574
+ detail: "Approval is explicit and per-operation; placeholders need a concrete --value or a suggestion to be approvable.",
575
+ seeAlso: ["audit", "suggest", "apply"],
576
+ },
577
+ apply: {
578
+ summary: "write ONLY explicitly approved operations to a provider",
579
+ phase: "Govern / Verify",
580
+ synopsis: [
581
+ "fullstackgtm apply --plan-id <id> --provider <name>",
582
+ "fullstackgtm apply --plan <path> --provider <name> --approve <ids|all> [--value <opId>=<v>]",
583
+ ],
584
+ detail:
585
+ "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.",
586
+ seeAlso: ["plans", "suggest", "audit-log"],
587
+ },
588
+ "audit-log": {
589
+ summary: "tamper-evident record of apply runs (export / verify)",
590
+ phase: "Verify",
591
+ synopsis: ["fullstackgtm audit-log export [--out <path>] | verify --in <path>"],
592
+ detail: "The Verify/Attribute layer — a signed, append-only record of every applied write.",
593
+ seeAlso: ["apply"],
594
+ },
595
+ merge: {
596
+ summary: "merge multiple plan/snapshot JSONs into one",
597
+ phase: "Govern",
598
+ synopsis: ["fullstackgtm merge --input <a.json> --input <b.json> [...] --out <merged.json> [--json]"],
599
+ seeAlso: ["diff", "audit"],
600
+ },
601
+
602
+ // Continuous
603
+ schedule: {
604
+ summary: "declare a cadence for read/plan-side commands (never auto-approves)",
605
+ phase: "Continuous",
606
+ synopsis: ["fullstackgtm schedule add|list|remove|enable|disable|run|install|status … (run `schedule --help` for full options)"],
607
+ detail:
608
+ "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.",
609
+ seeAlso: ["audit", "enrich", "apply"],
610
+ },
611
+
612
+ // Market intelligence
613
+ market: {
614
+ summary: "live competitive category map (capture → classify → drift → report)",
615
+ phase: "Intelligence",
616
+ synopsis: ["fullstackgtm market init|capture|classify|worksheet|observe|fronts|axes|overlay|scale|report|refresh … (run `market --help` for full options)"],
617
+ detail:
618
+ "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.",
619
+ seeAlso: [],
620
+ },
621
+ };
622
+
623
+ // Verbs that print their own richer multi-subcommand help; runCli routes their
624
+ // `--help` to themselves, so commandHelp() only renders these via `help <verb>`.
625
+ const BESPOKE_HELP = ["call", "market", "enrich", "bulk-update", "schedule"];
626
+
627
+ // Lifecycle-grouped front door. One line per verb, organized by the
628
+ // Prevent→Detect→Remediate→Verify loop so a new user sees ~6 jobs, not 22 verbs.
629
+ function shortUsage() {
630
+ const groups: Array<[string, string[]]> = [
631
+ ["Setup & health", ["login", "logout", "doctor", "profiles", "health"]],
632
+ ["Detect — read-only", ["audit", "report", "snapshot", "diff", "rules"]],
633
+ ["Prevent — gate writes", ["resolve"]],
634
+ ["Remediate — governed writes", ["fix", "bulk-update", "dedupe", "reassign", "enrich"]],
635
+ ["Calls → evidence", ["call"]],
636
+ ["Govern — the plan/apply spine", ["suggest", "plans", "apply", "audit-log", "merge"]],
637
+ ["Market intelligence", ["market"]],
638
+ ["Schedule — make it continuous", ["schedule"]],
639
+ ];
640
+ const pad = Math.max(...Object.keys(HELP).map((k) => k.length)) + 2;
641
+ const lines = [
642
+ "FullStackGTM — plan/apply for your GTM stack.",
643
+ "Audit CRM data, propose reviewable patch plans, apply only what you approve.",
644
+ "",
645
+ "Usage: fullstackgtm <command> [options]",
646
+ "",
647
+ ];
648
+ for (const [title, cmds] of groups) {
649
+ lines.push(`${title}:`);
650
+ for (const cmd of cmds) {
651
+ const entry = HELP[cmd];
652
+ if (!entry) continue;
653
+ lines.push(` ${cmd.padEnd(pad)}${entry.summary}`);
654
+ }
655
+ lines.push("");
656
+ }
657
+ lines.push(
658
+ "Zoom in: fullstackgtm <command> --help focused help for one command",
659
+ "Full ref: fullstackgtm help --full every flag and option",
660
+ "Try it: fullstackgtm audit --demo zero-credential demo CRM",
661
+ "",
662
+ "Safety: audits are read-only; apply writes only operations you approve.",
663
+ );
664
+ return lines.join("\n");
665
+ }
666
+
667
+ // Focused help for a single command. Falls back to shortUsage() for anything
668
+ // unknown so `--help` never dead-ends on the full wall.
669
+ function commandHelp(command: string) {
670
+ const entry = HELP[command];
671
+ if (!entry) return shortUsage();
672
+ const lines = [`fullstackgtm ${command} — ${entry.summary}`, "", "Usage:"];
673
+ for (const s of entry.synopsis) lines.push(` ${s}`);
674
+ if (entry.detail) lines.push("", entry.detail);
675
+ if (entry.options?.length) {
676
+ lines.push("", "Options:");
677
+ const pad = Math.max(...entry.options.map(([f]) => f.length)) + 2;
678
+ for (const [flag, desc] of entry.options) lines.push(` ${flag.padEnd(pad)}${desc}`);
679
+ }
680
+ lines.push("", `Lifecycle phase: ${entry.phase}`);
681
+ if (entry.seeAlso?.length) lines.push(`See also: ${entry.seeAlso.join(", ")}`);
682
+ if (BESPOKE_HELP.includes(command)) lines.push("", `Run \`fullstackgtm ${command} --help\` for the full subcommand reference.`);
683
+ lines.push("", "Full reference: fullstackgtm help --full");
684
+ return lines.join("\n");
685
+ }
686
+
344
687
  function option(args: string[], name: string) {
345
688
  const index = args.indexOf(name);
346
689
  if (index === -1) return null;
@@ -443,8 +786,15 @@ async function readSnapshot(args: string[]): Promise<CanonicalGtmSnapshot> {
443
786
  today: option(args, "--today") ?? undefined,
444
787
  });
445
788
  }
789
+ if (args.includes("--sample")) return sampleSnapshot;
446
790
  const input = option(args, "--input");
447
- if (!input || args.includes("--sample")) return sampleSnapshot;
791
+ if (!input) {
792
+ throw new Error(
793
+ "No data source. Pass one of: --provider <hubspot|salesforce|stripe> (live, read-only), " +
794
+ "--input <snapshot.json> (a saved snapshot), --demo (a generated messy CRM), or " +
795
+ "--sample (the tiny built-in fixture). Refusing to silently audit sample data.",
796
+ );
797
+ }
448
798
  const path = resolve(process.cwd(), input);
449
799
  return JSON.parse(readFileSync(path, "utf8")) as CanonicalGtmSnapshot;
450
800
  }
@@ -520,6 +870,52 @@ async function snapshotCommand(args: string[]) {
520
870
  }
521
871
  }
522
872
 
873
+ // #2 (dx-punch-list): every read verb points forward. `audit` is the keystone —
874
+ // `audit --demo` is the most-run first command and used to dead-end on a blank
875
+ // line. Context-aware guidance mirrors doctor's "Next step" and fix's chaining,
876
+ // stepping the user along Detect → Govern → apply. Printed to stderr so stdout
877
+ // stays clean for pipes/--out; suppressed under --json (machine/agent context).
878
+ function auditNextStep(args: string[], plan: PatchPlan): string {
879
+ const provider = option(args, "--provider");
880
+ const usingLiveData = Boolean(provider) || Boolean(option(args, "--input"));
881
+ const saved = args.includes("--save");
882
+ const count = plan.findings.length;
883
+
884
+ if (count === 0) {
885
+ return [
886
+ "✓ No findings — this snapshot is clean. Nothing to apply.",
887
+ " Keep it clean: gate new records with `fullstackgtm resolve`,",
888
+ " and schedule a recurring check with `fullstackgtm schedule add ...`.",
889
+ ].join("\n");
890
+ }
891
+
892
+ const head = `${count} finding${count === 1 ? "" : "s"} — a dry-run plan. Nothing was written.`;
893
+ if (!usingLiveData) {
894
+ return [
895
+ head,
896
+ "Next:",
897
+ " • Client-ready writeup: fullstackgtm report --demo",
898
+ " • Run on your CRM: fullstackgtm login hubspot → fullstackgtm audit --provider hubspot --save",
899
+ ].join("\n");
900
+ }
901
+ if (!saved) {
902
+ return [
903
+ head,
904
+ "Next: persist it with --save, then suggest → approve → apply:",
905
+ ` fullstackgtm audit --provider ${provider ?? "<name>"} --save`,
906
+ ].join("\n");
907
+ }
908
+ // --save already confirmed the plan id above; chain the governed spine.
909
+ const providerFlag = provider ? ` --provider ${provider}` : "";
910
+ return [
911
+ head,
912
+ "Next: derive values, approve the safe ones, apply:",
913
+ ` fullstackgtm suggest --plan-id ${plan.id}${providerFlag} --out suggestions.json`,
914
+ ` fullstackgtm plans approve ${plan.id} --values-from suggestions.json`,
915
+ ` fullstackgtm apply --plan-id ${plan.id}${providerFlag}`,
916
+ ].join("\n");
917
+ }
918
+
523
919
  async function audit(args: string[]) {
524
920
  const threshold = failOnThreshold(args);
525
921
  const loaded = loadConfig(option(args, "--config") ?? undefined);
@@ -539,14 +935,24 @@ async function audit(args: string[]) {
539
935
  }
540
936
  if (args.includes("--save")) {
541
937
  await createFilePlanStore().save(plan);
938
+ // Engagement workspace: stamp the per-profile health timeline + snapshot so
939
+ // the org accrues a continuous record from the verb people already run.
940
+ // Honor --today (audit is deterministic under it); fall back to wall-clock.
941
+ const health = computeHealth(plan, snapshot, today ?? new Date().toISOString());
942
+ appendHealthEntry(health);
943
+ saveWorkspaceSnapshot(plan.id, snapshot);
542
944
  console.error(
543
- `Saved plan ${plan.id}. Review with \`fullstackgtm plans show ${plan.id}\`, approve with \`fullstackgtm plans approve ${plan.id} --operations <ids|all>\`.`,
945
+ `Saved plan ${plan.id}. Health ${health.score}/100 recorded (\`fullstackgtm health\`). ` +
946
+ `Review with \`fullstackgtm plans show ${plan.id}\`, approve with \`fullstackgtm plans approve ${plan.id} --operations <ids|all>\`.`,
544
947
  );
545
948
  }
546
949
  if (args.includes("--json")) {
547
950
  console.log(JSON.stringify(plan, null, 2));
548
951
  } else {
549
- console.log(patchPlanToMarkdown(plan));
952
+ // Default to the summary view (rule table + counts); the full per-operation
953
+ // dump is opt-in via --full or, for a deliverable, `report`. (dx-punch-list #3)
954
+ console.log(patchPlanToMarkdown(plan, { summary: !args.includes("--full") }));
955
+ console.error(`\n${auditNextStep(args, plan)}`);
550
956
  }
551
957
 
552
958
  if (
@@ -557,6 +963,30 @@ async function audit(args: string[]) {
557
963
  }
558
964
  }
559
965
 
966
+ /**
967
+ * Roll up the active profile's health timeline (accrued by `audit --save`):
968
+ * current deterministic score, change since the last audit, and per-rule
969
+ * deltas. Read-only — it only reads `health.jsonl`, never re-audits.
970
+ */
971
+ function healthCommand(args: string[]) {
972
+ const profile = activeWorkspaceProfile();
973
+ const rollup = summarizeHealth(readHealthTimeline(), profile);
974
+ if (!rollup) {
975
+ if (args.includes("--json")) {
976
+ console.log(JSON.stringify({ profile, auditCount: 0 }, null, 2));
977
+ } else {
978
+ console.log(
979
+ `No audits recorded yet for profile "${profile}".\n` +
980
+ "Start the timeline: `fullstackgtm audit --provider <name> --save`" +
981
+ (profile === "default" ? "" : ` --profile ${profile}`) +
982
+ ".",
983
+ );
984
+ }
985
+ return;
986
+ }
987
+ console.log(args.includes("--json") ? JSON.stringify(rollup, null, 2) : healthToMarkdown(rollup));
988
+ }
989
+
560
990
  /**
561
991
  * Render an audit as a client-facing deliverable. Same sources and audit
562
992
  * options as `audit`; `--plan` instead renders an existing plan JSON without
@@ -1447,8 +1877,17 @@ enrich append [--source apollo] [--objects companies,contacts] [--save] [--conf
1447
1877
  enrich refresh [--source apollo] [--stale-days <n>] [--save] [--config <path>]
1448
1878
  [source options] [--run-label <label>] [--json]
1449
1879
  enrich ingest <file.csv|payload.json> --source clay [--run-label <label>] [--objects companies|contacts] [--config <path>]
1880
+ enrich acquire [--source <id>] [--max <n>] [--save] [--config <path>] [--json]
1450
1881
  enrich status [--runs] [--source <id>] [--config <path>] [--json]
1451
1882
 
1883
+ acquire creates NET-NEW leads from a staged prospect list (ingest first):
1884
+ it dedupes each sourced row against the CRM, skips matches and ambiguities
1885
+ (resolve-first never creates over a possible duplicate), and proposes a
1886
+ \`create_record\` op per confirmed net-new row — capped by the acquire meter's
1887
+ remaining budget (records + spend, per day and per month; whichever is hit
1888
+ first). Approval-gated like every write: \`--save\` → plans approve → apply.
1889
+ The meter is charged only when a create actually lands at apply.
1890
+
1452
1891
  append pulls from an api source (Apollo — BYO key via \`login apollo\` or
1453
1892
  APOLLO_API_KEY) or reads data staged by \`enrich ingest\` (Clay CSV exports,
1454
1893
  webhook payload JSON), matches source records to CRM records via the ordered
@@ -1473,8 +1912,8 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
1473
1912
  return;
1474
1913
  }
1475
1914
 
1476
- if (!["append", "refresh", "ingest", "status"].includes(subcommand)) {
1477
- throw new Error(`Unknown enrich subcommand: ${subcommand} (try: append, refresh, ingest, status)`);
1915
+ if (!["append", "refresh", "ingest", "status", "acquire"].includes(subcommand)) {
1916
+ throw new Error(`Unknown enrich subcommand: ${subcommand} (try: append, refresh, ingest, status, acquire)`);
1478
1917
  }
1479
1918
 
1480
1919
  const configPath = () => resolve(process.cwd(), option(rest, "--config") ?? ENRICH_CONFIG_FILE_NAME);
@@ -1485,14 +1924,159 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
1485
1924
  return;
1486
1925
  }
1487
1926
 
1488
- const config = loadEnrichConfig(configPath());
1489
-
1927
+ // Config resolution: an explicit --config or an on-disk default always wins
1928
+ // (and validates). Only when neither exists do we fall back to a built-in
1929
+ // preset for the source (e.g. `--source clay`), so the common Mode-A loop
1930
+ // needs no hand-authored config. A present-but-invalid config still errors.
1931
+ const explicitConfig = option(rest, "--config");
1932
+ const configFile = configPath();
1933
+ // No config file + no --config → fall back to a built-in preset so the common
1934
+ // paths need zero hand-authored config: `acquire` gets the zero-config acquire
1935
+ // preset (targeted/deduped/metered out the gate); other verbs get the source
1936
+ // preset (e.g. clay ingest). An explicit/on-disk config always wins.
1937
+ const presetFor = (src: string | undefined) =>
1938
+ subcommand === "acquire" ? builtinAcquirePreset(src) : builtinEnrichPreset(src);
1939
+ const config =
1940
+ !explicitConfig && !existsSync(configFile)
1941
+ ? (presetFor(option(rest, "--source") ?? undefined) ?? loadEnrichConfig(configFile))
1942
+ : loadEnrichConfig(configFile);
1943
+
1944
+ // `enrich ingest` stages rows. If the same command also names a CRM source
1945
+ // (--input/--provider), collapse the two-step into one: stage, then run the
1946
+ // append against the just-staged data so `enrich ingest clay.csv --source clay
1947
+ // --input snap.json` returns the hygiene verdict end-to-end. Without a CRM
1948
+ // source it stays stage-only (the existing two-step is preserved).
1490
1949
  if (subcommand === "ingest") {
1491
- await enrichIngest(store, config, rest);
1950
+ const autoAppend = Boolean(option(rest, "--provider") || option(rest, "--input"));
1951
+ await enrichIngest(store, config, rest, autoAppend);
1952
+ if (!autoAppend) return;
1953
+ }
1954
+
1955
+ if (subcommand === "acquire") {
1956
+ if (!config.acquire) {
1957
+ throw new Error(
1958
+ '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.',
1959
+ );
1960
+ }
1961
+ const source = resolveEnrichSource(config, rest);
1962
+ const sourceConfig = config.sources[source];
1963
+ const save = rest.includes("--save");
1964
+ const today = new Date().toISOString().slice(0, 10);
1965
+
1966
+ // Prospects come from an API source (net-new discovery, e.g. Explorium +
1967
+ // pipe0 work-email) or a staged ingest list (Clay/CSV/webhook).
1968
+ const snapshot = await readSnapshot(rest);
1969
+ const icp = loadIcp(rest);
1970
+ if (sourceConfig.kind === "api" && !icp) {
1971
+ console.error(
1972
+ "⚠ No ICP loaded — discovery will use the raw discovery.filters and skip fit scoring. " +
1973
+ "Develop one with `fullstackgtm icp interview` (or pass --icp <path>) so leads map to your ICP.",
1974
+ );
1975
+ }
1976
+ // Recommend the strong dedup key when the CRM carries no LinkedIn URLs:
1977
+ // without them, pre-email dedup falls back to the weaker name+domain match.
1978
+ if (sourceConfig.kind === "api" && !(snapshot.contacts ?? []).some((c) => c.linkedin)) {
1979
+ console.error(
1980
+ "⚠ No LinkedIn URLs found on your contacts — pre-email dedup falls back to name+domain (weaker). " +
1981
+ "Populate the HubSpot \"LinkedIn URL\" (hs_linkedin_url) property for exact dedup; " +
1982
+ "acquire writes it on every new contact it creates, so coverage grows automatically.",
1983
+ );
1984
+ }
1985
+ const seen = loadSeen();
1986
+ let records: EnrichSourceRecord[];
1987
+ let apiSkippedCrm = 0;
1988
+ let apiSkippedSeen = 0;
1989
+ let apiProcessedKeys: string[] = [];
1990
+ if (sourceConfig.kind === "api") {
1991
+ const api = await acquireFromApi(config, source, rest, icp, snapshot, seen);
1992
+ records = api.records;
1993
+ apiSkippedCrm = api.skippedCrm;
1994
+ apiSkippedSeen = api.skippedSeen;
1995
+ apiProcessedKeys = api.processedKeys;
1996
+ } else {
1997
+ const stagedLabel = option(rest, "--staged-run");
1998
+ const stagedRun = stagedLabel
1999
+ ? await store.get(stagedLabel)
2000
+ : await store.latest({ source, mode: "ingest" });
2001
+ if (!stagedRun || stagedRun.mode !== "ingest") {
2002
+ throw new Error(
2003
+ `No staged data for source "${source}". Stage prospects first: fullstackgtm enrich ingest <prospects.csv|payload.json> --source ${source}`,
2004
+ );
2005
+ }
2006
+ records = stagedSourceRecords(config, source, stagedRun);
2007
+ }
2008
+
2009
+ // Meter: how many MORE leads may we create right now? --max is an
2010
+ // additional per-run ceiling, never a way to exceed the budget.
2011
+ const now = new Date();
2012
+ const costPerRecord = config.acquire.costPerRecord?.[source] ?? 0;
2013
+ if (apiSkippedCrm || apiSkippedSeen) {
2014
+ console.error(
2015
+ `Pre-email dedup: skipped ${apiSkippedCrm} already-in-CRM + ${apiSkippedSeen} seen before paying for emails ` +
2016
+ `(≈$${((apiSkippedCrm + apiSkippedSeen) * costPerRecord).toFixed(2)} enrichment saved).`,
2017
+ );
2018
+ }
2019
+ const headroom = remaining(loadMeter(now), config.acquire.budget, costPerRecord, now);
2020
+ const explicitMax = numericOption(rest, "--max");
2021
+ let cap = headroom.maxRecords;
2022
+ if (explicitMax !== undefined) cap = cap === null ? explicitMax : Math.min(cap, explicitMax);
2023
+
2024
+ const result = buildAcquirePlan({
2025
+ config,
2026
+ source,
2027
+ snapshot,
2028
+ records,
2029
+ runLabel: option(rest, "--run-label") ?? `acquire-${source}-${today}`,
2030
+ maxRecords: cap,
2031
+ });
2032
+
2033
+ const meterLine = formatAcquireMeter(headroom, costPerRecord);
2034
+ if (!save) {
2035
+ if (rest.includes("--json")) {
2036
+ console.log(
2037
+ JSON.stringify(
2038
+ { plan: result.plan, counts: result.counts, estCostUsd: result.estCostUsd, meter: headroom },
2039
+ null,
2040
+ 2,
2041
+ ),
2042
+ );
2043
+ } else {
2044
+ console.log(patchPlanToMarkdown(result.plan));
2045
+ console.log(meterLine);
2046
+ console.log("\nDry run — nothing written. Re-run with --save to persist the plan, then approve + apply.");
2047
+ }
2048
+ return;
2049
+ }
2050
+
2051
+ const run = await openEnrichRun(store, source, "append", option(rest, "--run-label"), today);
2052
+ const planIds: string[] = [];
2053
+ if (result.plan.operations.length > 0) {
2054
+ await createFilePlanStore().save(result.plan);
2055
+ planIds.push(result.plan.id);
2056
+ }
2057
+ await store.update({
2058
+ ...run,
2059
+ completedAt: new Date().toISOString(),
2060
+ cursor: null,
2061
+ planIds: [...(run.planIds ?? []), ...planIds],
2062
+ });
2063
+ // Remember everyone we email-resolved this run so the next run skips them
2064
+ // pre-email (cross-run credit saver). Committed (--save) runs only.
2065
+ if (apiProcessedKeys.length > 0) recordSeen(apiProcessedKeys, now);
2066
+ console.log(meterLine);
2067
+ if (planIds.length > 0) {
2068
+ console.log(
2069
+ `Saved plan ${result.plan.id} — ${result.counts.created} net-new lead(s), est. $${result.estCostUsd.toFixed(2)}. ` +
2070
+ `Review \`fullstackgtm plans show ${result.plan.id}\`, approve \`fullstackgtm plans approve ${result.plan.id} --operations all\`, ` +
2071
+ `then \`fullstackgtm apply --plan-id ${result.plan.id} --provider hubspot\`. The meter is charged only when a create lands at apply.`,
2072
+ );
2073
+ } else {
2074
+ console.log("No net-new leads to create (everything sourced already matched, or was withheld by the meter).");
2075
+ }
1492
2076
  return;
1493
2077
  }
1494
2078
 
1495
- const mode = subcommand as "append" | "refresh";
2079
+ const mode: "append" | "refresh" = subcommand === "refresh" ? "refresh" : "append";
1496
2080
  const source = resolveEnrichSource(config, rest);
1497
2081
  const sourceConfig = config.sources[source];
1498
2082
  const save = rest.includes("--save");
@@ -1641,6 +2225,191 @@ function formatEnrichCounts(counts: EnrichCounts, ambiguities: number) {
1641
2225
  );
1642
2226
  }
1643
2227
 
2228
+ /** Best-effort enrich-config load for apply-time acquire-budget enforcement. */
2229
+ /** Provider API key: env override first, then the credential store (`login`). */
2230
+ function providerKey(provider: "explorium" | "pipe0"): string {
2231
+ const envName = provider === "explorium" ? "EXPLORIUM_API_KEY" : "PIPE0_API_KEY";
2232
+ if (process.env[envName]) return process.env[envName] as string;
2233
+ const stored = getCredential(provider);
2234
+ if (stored) return stored.accessToken;
2235
+ throw new Error(`No ${provider} credentials. Run \`echo "$KEY" | fullstackgtm login ${provider}\`, or set ${envName}.`);
2236
+ }
2237
+
2238
+ /** Load the active ICP: --icp <path>, else ./icp.json. Undefined if none. */
2239
+ function loadIcp(args: string[]): Icp | undefined {
2240
+ const explicit = option(args, "--icp");
2241
+ const path = resolve(process.cwd(), explicit ?? "icp.json");
2242
+ if (!existsSync(path)) {
2243
+ if (explicit) throw new Error(`--icp ${explicit}: file not found`);
2244
+ return undefined;
2245
+ }
2246
+ return parseIcp(readFileSync(path, "utf8"));
2247
+ }
2248
+
2249
+ /**
2250
+ * `icp` — develop and inspect the Ideal Customer Profile that targets acquire.
2251
+ * The CLI can't run AskUserQuestion itself; `icp interview` emits the question
2252
+ * spec an agent (Claude Code / Codex) drives with its AskUserQuestion tool, then
2253
+ * `icp set` writes icp.json from the collected answers.
2254
+ */
2255
+ async function icpCommand(args: string[]) {
2256
+ const [sub, ...rest] = args;
2257
+ if (!sub || sub === "--help" || sub === "-h") {
2258
+ console.log(`Usage:
2259
+ fullstackgtm icp interview emit the interview spec (an agent drives it with AskUserQuestion)
2260
+ fullstackgtm icp set <answers.json> [--name <n>] [--out <path>] write icp.json from interview answers
2261
+ fullstackgtm icp show [--icp <path>] render the ICP + the discovery filters it produces
2262
+
2263
+ The ICP makes \`enrich acquire\` targeted, not random: it generates each
2264
+ provider's discovery filters (Explorium, pipe0/Crustdata) AND scores every
2265
+ discovered prospect for fit — only above-threshold leads become create_record
2266
+ ops. Develop one by interview, then \`enrich acquire\` picks up ./icp.json.`);
2267
+ return;
2268
+ }
2269
+ if (sub === "interview") {
2270
+ console.log(
2271
+ JSON.stringify(
2272
+ {
2273
+ questions: INTERVIEW_SPEC,
2274
+ instructions:
2275
+ "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.",
2276
+ },
2277
+ null,
2278
+ 2,
2279
+ ),
2280
+ );
2281
+ return;
2282
+ }
2283
+ if (sub === "set") {
2284
+ const file = rest.find((a) => !a.startsWith("--"));
2285
+ if (!file) throw new Error("Usage: fullstackgtm icp set <answers.json> [--name <n>] [--out <path>]");
2286
+ const answers = JSON.parse(readFileSync(resolve(process.cwd(), file), "utf8"));
2287
+ const icp = icpFromAnswers(option(rest, "--name") ?? "ICP", answers);
2288
+ const out = resolve(process.cwd(), option(rest, "--out") ?? "icp.json");
2289
+ writeFileSync(out, `${JSON.stringify(icp, null, 2)}\n`);
2290
+ console.log(
2291
+ `Wrote ${out} — ${icp.persona.titleKeywords?.length ?? 0} title keyword(s), ` +
2292
+ `${icp.firmographics.employeeBands?.length ?? 0} size band(s), fit threshold ${fitThreshold(icp)}.`,
2293
+ );
2294
+ return;
2295
+ }
2296
+ if (sub === "show") {
2297
+ const icp = loadIcp(rest);
2298
+ if (!icp) throw new Error("No ICP found (icp.json in cwd, or pass --icp <path>). Build one: fullstackgtm icp interview.");
2299
+ console.log(
2300
+ JSON.stringify(
2301
+ {
2302
+ icp,
2303
+ exploriumFilters: icpToExploriumFilters(icp),
2304
+ crustdataFilters: icpToCrustdataFilters(icp),
2305
+ fitThreshold: fitThreshold(icp),
2306
+ },
2307
+ null,
2308
+ 2,
2309
+ ),
2310
+ );
2311
+ return;
2312
+ }
2313
+ throw new Error(`Unknown icp subcommand: ${sub} (try: interview, set, show)`);
2314
+ }
2315
+
2316
+ /**
2317
+ * Pull net-new prospects from an API acquire source into source records the
2318
+ * acquire builder dedupes + turns into create_record ops. Explorium discovers;
2319
+ * pipe0 (when resolveEmailsWith=pipe0) fills real work emails. Only rows that
2320
+ * carry the dedupe key (email) survive — you cannot resolve-first without it.
2321
+ */
2322
+ async function acquireFromApi(
2323
+ config: EnrichConfig,
2324
+ source: string,
2325
+ rest: string[],
2326
+ icp: Icp | undefined,
2327
+ snapshot: CanonicalGtmSnapshot,
2328
+ seen: Set<string>,
2329
+ ): Promise<{ records: EnrichSourceRecord[]; skippedCrm: number; skippedSeen: number; processedKeys: string[] }> {
2330
+ const acquire = config.acquire!;
2331
+ const disc = acquire.discovery?.[source];
2332
+ if (!disc) {
2333
+ throw new Error(`enrich acquire: api source "${source}" needs an "acquire.discovery.${source}" config (provider + size).`);
2334
+ }
2335
+ const matchKey = acquire.create.contact?.matchKey ?? "email";
2336
+ const maxOverride = numericOption(rest, "--max");
2337
+ const size = maxOverride !== undefined ? Math.min(maxOverride, disc.size ?? 25) : disc.size ?? 25;
2338
+
2339
+ // 1. Discover. Filters come from the ICP when one is loaded (the whole point —
2340
+ // targeted, not random); otherwise from the hand-written disc.filters.
2341
+ let prospects: Prospect[];
2342
+ if (disc.provider === "explorium") {
2343
+ const filters = icp
2344
+ ? icpToExploriumFilters(icp)
2345
+ : ((disc.filters ?? {}) as Record<string, { values?: string[]; value?: boolean }>);
2346
+ prospects = await fetchExploriumProspects({ apiKey: providerKey("explorium"), filters, size });
2347
+ } else if (disc.provider === "pipe0") {
2348
+ const filters = icp ? icpToCrustdataFilters(icp) : (disc.filters ?? {});
2349
+ prospects = await fetchPipe0CrustdataProspects({ apiKey: providerKey("pipe0"), filters, limit: size });
2350
+ } else {
2351
+ throw new Error(`enrich acquire: unknown discovery provider "${disc.provider}" (explorium | pipe0).`);
2352
+ }
2353
+
2354
+ // 2. ICP fit scoring BEFORE paying for emails — only above-threshold prospects
2355
+ // proceed to (credit-spending) email resolution.
2356
+ if (icp) {
2357
+ const threshold = fitThreshold(icp);
2358
+ prospects = prospects
2359
+ .map((p) => ({ ...p, fitScore: scoreProspectAgainstIcp(p, icp).score }))
2360
+ .filter((p) => (p.fitScore ?? 0) >= threshold);
2361
+ }
2362
+
2363
+ // 2.5 Pre-email dedup — drop prospects already in the CRM (name+domain match
2364
+ // vs the snapshot) or already processed in a prior run (the seen cache),
2365
+ // BEFORE paying pipe0 for emails. The email-level dedup (buildAcquirePlan)
2366
+ // and apply-time resolve-first remain the precise backstop.
2367
+ const { fresh, skippedCrm, skippedSeen } = partitionFreshProspects(prospects, crmContactKeys(snapshot), seen);
2368
+ prospects = fresh;
2369
+
2370
+ // 3. Resolve real work emails (both providers need it: Explorium's email is
2371
+ // hashed, Crustdata search returns none). pipe0 waterfall, chunked.
2372
+ if (matchKey === "email") {
2373
+ prospects = await pipe0ResolveWorkEmails({ apiKey: providerKey("pipe0"), prospects });
2374
+ }
2375
+
2376
+ const processedKeys = prospects.flatMap(prospectIdentityKeys);
2377
+ const records = prospects
2378
+ .map((p) => {
2379
+ const keyValue = (p as unknown as Record<string, unknown>)[matchKey] as string | undefined;
2380
+ return {
2381
+ id: p.sourceId ?? `${source}:${keyValue ?? p.fullName ?? ""}`,
2382
+ objectType: "contact" as const,
2383
+ keys: { [matchKey]: keyValue },
2384
+ payload: p as unknown as Record<string, unknown>,
2385
+ };
2386
+ })
2387
+ .filter((record) => Boolean(record.keys[matchKey]));
2388
+ return { records, skippedCrm, skippedSeen, processedKeys };
2389
+ }
2390
+
2391
+ function tryLoadAcquireConfig(args: string[]): EnrichConfig | undefined {
2392
+ try {
2393
+ const path = resolve(process.cwd(), option(args, "--config") ?? ENRICH_CONFIG_FILE_NAME);
2394
+ if (!existsSync(path)) return undefined;
2395
+ return loadEnrichConfig(path);
2396
+ } catch {
2397
+ return undefined;
2398
+ }
2399
+ }
2400
+
2401
+ function formatAcquireMeter(headroom: AcquireRemaining, costPerRecord: number): string {
2402
+ const n = (v: number | null) => (v === null ? "∞" : String(v));
2403
+ const money = (v: number | null) => (v === null ? "∞" : `$${v.toFixed(2)}`);
2404
+ const max = headroom.maxRecords === null ? "unlimited" : `${headroom.maxRecords}`;
2405
+ return (
2406
+ `Acquire meter — creatable now: ${max} lead(s). ` +
2407
+ `Records left: ${n(headroom.records.day)}/day, ${n(headroom.records.month)}/month · ` +
2408
+ `Spend left: ${money(headroom.spendUsd.day)}/day, ${money(headroom.spendUsd.month)}/month ` +
2409
+ `(≈$${costPerRecord.toFixed(2)}/lead).`
2410
+ );
2411
+ }
2412
+
1644
2413
  function resolveEnrichSource(config: EnrichConfig, rest: string[]): string {
1645
2414
  const requested = option(rest, "--source");
1646
2415
  const declared = Object.keys(config.sources);
@@ -1723,7 +2492,7 @@ async function openEnrichRun(
1723
2492
  });
1724
2493
  }
1725
2494
 
1726
- async function enrichIngest(store: EnrichRunStore, config: EnrichConfig, rest: string[]) {
2495
+ async function enrichIngest(store: EnrichRunStore, config: EnrichConfig, rest: string[], autoAppend = false) {
1727
2496
  const file = rest.find((arg) => !arg.startsWith("--") && !isOptionValue(rest, arg));
1728
2497
  if (!file) throw new Error("Usage: fullstackgtm enrich ingest <file.csv|payload.json> --source <id> [--run-label <label>]");
1729
2498
  const source = option(rest, "--source");
@@ -1784,8 +2553,10 @@ async function enrichIngest(store: EnrichRunStore, config: EnrichConfig, rest: s
1784
2553
  stagedObjectType: objectType,
1785
2554
  });
1786
2555
  console.log(
1787
- `Staged ${rows.length} ${objectType} row(s) from ${file} as run ${label}. ` +
1788
- `Next: fullstackgtm enrich append --source ${source} [source options] [--save]`,
2556
+ autoAppend
2557
+ ? `Staged ${rows.length} ${objectType} row(s) from ${file} as run ${label}; matching against the CRM…`
2558
+ : `Staged ${rows.length} ${objectType} row(s) from ${file} as run ${label}. ` +
2559
+ `Next: fullstackgtm enrich append --source ${source} [source options] [--save]`,
1789
2560
  );
1790
2561
  }
1791
2562
 
@@ -2775,6 +3546,40 @@ async function apply(args: string[]) {
2775
3546
  valueOverrides = parseValueOverrides(args);
2776
3547
  }
2777
3548
 
3549
+ // Acquire meter: create_record ops are budgeted. Refuse up-front if the
3550
+ // approved creates would exceed the current budget window (the plan was
3551
+ // capped when `enrich acquire` ran, but the budget may have been spent down
3552
+ // since), then charge the meter for what actually lands.
3553
+ const approvedSet = new Set(approvedOperationIds);
3554
+ const createOps = plan.operations.filter(
3555
+ (op) => op.operation === "create_record" && approvedSet.has(op.id),
3556
+ );
3557
+ const createSpend = (op: PatchOperation) =>
3558
+ ((op.afterValue as CreateRecordPayload | undefined)?.estCostUsd) ?? 0;
3559
+ if (createOps.length > 0) {
3560
+ const acquireConfig = tryLoadAcquireConfig(args)?.acquire;
3561
+ if (acquireConfig?.budget) {
3562
+ const now = new Date();
3563
+ const head = remaining(loadMeter(now), acquireConfig.budget, 0, now);
3564
+ const approvedSpend = createOps.reduce((sum, op) => sum + createSpend(op), 0);
3565
+ const refusals: string[] = [];
3566
+ if (head.records.day !== null && createOps.length > head.records.day)
3567
+ refusals.push(`${createOps.length} creates > ${head.records.day} record(s) left today`);
3568
+ if (head.records.month !== null && createOps.length > head.records.month)
3569
+ refusals.push(`${createOps.length} creates > ${head.records.month} record(s) left this month`);
3570
+ if (head.spendUsd.day !== null && approvedSpend > head.spendUsd.day)
3571
+ refusals.push(`$${approvedSpend.toFixed(2)} > $${head.spendUsd.day.toFixed(2)} spend left today`);
3572
+ if (head.spendUsd.month !== null && approvedSpend > head.spendUsd.month)
3573
+ refusals.push(`$${approvedSpend.toFixed(2)} > $${head.spendUsd.month.toFixed(2)} spend left this month`);
3574
+ if (refusals.length > 0) {
3575
+ throw new Error(
3576
+ `Refusing to apply: acquire budget exceeded (${refusals.join("; ")}). ` +
3577
+ "Re-run `fullstackgtm enrich acquire` to re-cap to the remaining budget, or wait for the window to reset.",
3578
+ );
3579
+ }
3580
+ }
3581
+ }
3582
+
2778
3583
  const connector = await connectorFor(provider, args);
2779
3584
  const run = await applyPatchPlan(connector, plan, {
2780
3585
  approvedOperationIds,
@@ -2784,6 +3589,18 @@ async function apply(args: string[]) {
2784
3589
  await store.recordRun(planId, run);
2785
3590
  }
2786
3591
 
3592
+ // Charge the acquire meter for the creates that actually landed.
3593
+ if (createOps.length > 0) {
3594
+ const appliedIds = new Set(
3595
+ run.results.filter((result) => result.status === "applied").map((result) => result.operationId),
3596
+ );
3597
+ const landed = createOps.filter((op) => appliedIds.has(op.id));
3598
+ if (landed.length > 0) {
3599
+ const spend = landed.reduce((sum, op) => sum + createSpend(op), 0);
3600
+ recordConsumption(new Date(), landed.length, spend);
3601
+ }
3602
+ }
3603
+
2787
3604
  if (args.includes("--json")) {
2788
3605
  console.log(JSON.stringify(run, null, 2));
2789
3606
  } else {
@@ -3280,9 +4097,22 @@ async function login(args: string[]) {
3280
4097
  console.log(`Stored Apollo API key in ${credentialsPath()}. \`fullstackgtm enrich append|refresh\` use it automatically.`);
3281
4098
  return;
3282
4099
  }
4100
+ if (provider === "pipe0" || provider === "explorium") {
4101
+ rejectArgvSecret(args, "--token", "--key", "--api-key");
4102
+ const key = await readSecret(`${provider} API key`);
4103
+ if (!key) throw new Error(`No ${provider} key provided.`);
4104
+ // No free auth-health endpoint; validating would spend credits, so the key
4105
+ // is stored as-is and validated on the first `enrich acquire` pull.
4106
+ const stamp = new Date().toISOString();
4107
+ storeCredential(provider, { kind: "api_key", accessToken: key, createdAt: stamp, updatedAt: stamp });
4108
+ console.log(
4109
+ `Stored ${provider} API key in ${credentialsPath()}. \`fullstackgtm enrich acquire\` uses it automatically (validated on first pull).`,
4110
+ );
4111
+ return;
4112
+ }
3283
4113
  if (provider !== "hubspot") {
3284
4114
  throw new Error(
3285
- "login supports: hubspot, salesforce, stripe, anthropic, openai, apollo, or --via <hosted url>. Usage: fullstackgtm login <provider> | fullstackgtm login --via https://gtm.example.com",
4115
+ "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",
3286
4116
  );
3287
4117
  }
3288
4118
  const now = new Date().toISOString();
@@ -3499,19 +4329,31 @@ function profilesCommand(args: string[]) {
3499
4329
 
3500
4330
  export async function runCli(argv: string[]) {
3501
4331
  const [command, ...args] = extractProfile(argv);
4332
+ // Front door: the lifecycle-grouped map by default, the full reference on
4333
+ // --full. `help <command>` zooms into one verb. (docs/dx-punch-list.md #1)
3502
4334
  if (!command || command === "--help" || command === "-h") {
3503
- console.log(usage());
4335
+ console.log(args.includes("--full") ? usage() : shortUsage());
4336
+ return;
4337
+ }
4338
+ if (command === "help") {
4339
+ const [topic, ...rest] = args;
4340
+ if (topic && topic !== "--full" && !topic.startsWith("-")) {
4341
+ console.log(commandHelp(topic));
4342
+ } else {
4343
+ console.log(args.includes("--full") || topic === "--full" ? usage() : shortUsage());
4344
+ }
3504
4345
  return;
3505
4346
  }
3506
4347
  if (command === "--version" || command === "-v" || command === "version") {
3507
4348
  console.log(readPackageInfo().version);
3508
4349
  return;
3509
4350
  }
3510
- // Commands without bespoke help fall back to the top-level usage on --help
3511
- // instead of executing (audit used to silently run the sample audit).
3512
- // call/market/enrich/bulk-update/schedule print their own richer help.
3513
- if (!["call", "market", "enrich", "bulk-update", "schedule"].includes(command) && (args.includes("--help") || args.includes("-h"))) {
3514
- console.log(usage());
4351
+ // Commands without bespoke help get focused per-command help on --help
4352
+ // instead of executing (audit used to silently run the sample audit) or
4353
+ // dumping the whole surface. call/market/enrich/bulk-update/schedule print
4354
+ // their own richer help. `--full` always escapes to the complete reference.
4355
+ if (!BESPOKE_HELP.includes(command) && (args.includes("--help") || args.includes("-h"))) {
4356
+ console.log(args.includes("--full") ? usage() : commandHelp(command));
3515
4357
  return;
3516
4358
  }
3517
4359
 
@@ -3543,6 +4385,10 @@ export async function runCli(argv: string[]) {
3543
4385
  doctorCommand(args);
3544
4386
  return;
3545
4387
  }
4388
+ if (command === "health") {
4389
+ healthCommand(args);
4390
+ return;
4391
+ }
3546
4392
  if (command === "suggest") {
3547
4393
  await suggest(args);
3548
4394
  return;
@@ -3579,6 +4425,10 @@ export async function runCli(argv: string[]) {
3579
4425
  await enrichCommand(args);
3580
4426
  return;
3581
4427
  }
4428
+ if (command === "icp") {
4429
+ await icpCommand(args);
4430
+ return;
4431
+ }
3582
4432
  if (command === "schedule") {
3583
4433
  await scheduleCommand(args);
3584
4434
  return;