fullstackgtm 0.45.0 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/CHANGELOG.md +109 -1
  2. package/INSTALL_FOR_AGENTS.md +13 -11
  3. package/README.md +27 -18
  4. package/dist/backfill.d.ts +86 -0
  5. package/dist/backfill.js +251 -0
  6. package/dist/bin.js +4 -1
  7. package/dist/cli/auth.d.ts +2 -0
  8. package/dist/cli/auth.js +119 -12
  9. package/dist/cli/backfill.d.ts +1 -0
  10. package/dist/cli/backfill.js +125 -0
  11. package/dist/cli/backfillRuns.d.ts +1 -0
  12. package/dist/cli/backfillRuns.js +187 -0
  13. package/dist/cli/call.js +23 -9
  14. package/dist/cli/enrich.js +28 -10
  15. package/dist/cli/fix.js +9 -7
  16. package/dist/cli/help.js +60 -23
  17. package/dist/cli/plans.js +13 -11
  18. package/dist/cli/shared.d.ts +4 -3
  19. package/dist/cli/shared.js +31 -20
  20. package/dist/cli/ui.d.ts +21 -0
  21. package/dist/cli/ui.js +53 -1
  22. package/dist/cli.js +37 -41
  23. package/dist/connector.d.ts +8 -0
  24. package/dist/connector.js +22 -10
  25. package/dist/connectors/hubspot.d.ts +7 -0
  26. package/dist/connectors/hubspot.js +236 -9
  27. package/dist/connectors/hubspotAuth.js +5 -1
  28. package/dist/connectors/linkedin.js +14 -14
  29. package/dist/connectors/salesforce.d.ts +7 -0
  30. package/dist/connectors/salesforce.js +28 -2
  31. package/dist/connectors/stripe.d.ts +37 -0
  32. package/dist/connectors/stripe.js +103 -31
  33. package/dist/enrich.d.ts +7 -0
  34. package/dist/enrich.js +7 -0
  35. package/dist/health.d.ts +11 -69
  36. package/dist/health.js +4 -134
  37. package/dist/healthScore.d.ts +71 -0
  38. package/dist/healthScore.js +143 -0
  39. package/dist/index.d.ts +3 -1
  40. package/dist/index.js +3 -1
  41. package/dist/llm.d.ts +29 -0
  42. package/dist/llm.js +206 -0
  43. package/dist/market.d.ts +39 -1
  44. package/dist/market.js +116 -44
  45. package/dist/marketClassify.d.ts +9 -1
  46. package/dist/marketClassify.js +10 -1
  47. package/dist/marketTaxonomy.d.ts +6 -1
  48. package/dist/marketTaxonomy.js +4 -2
  49. package/dist/mcp-bin.js +29 -0
  50. package/dist/mcp.js +117 -4
  51. package/dist/progress.d.ts +96 -0
  52. package/dist/progress.js +142 -0
  53. package/dist/runReport.d.ts +24 -0
  54. package/dist/runReport.js +139 -4
  55. package/dist/types.d.ts +18 -1
  56. package/docs/api.md +2 -1
  57. package/docs/architecture.md +2 -0
  58. package/docs/linkedin-connector-spec.md +1 -1
  59. package/llms.txt +3 -3
  60. package/package.json +10 -3
  61. package/skills/fullstackgtm/SKILL.md +1 -0
  62. package/src/backfill.ts +340 -0
  63. package/src/bin.ts +5 -1
  64. package/src/cli/auth.ts +135 -15
  65. package/src/cli/backfill.ts +156 -0
  66. package/src/cli/backfillRuns.ts +198 -0
  67. package/src/cli/call.ts +26 -10
  68. package/src/cli/enrich.ts +33 -10
  69. package/src/cli/fix.ts +11 -10
  70. package/src/cli/help.ts +61 -23
  71. package/src/cli/plans.ts +15 -14
  72. package/src/cli/shared.ts +44 -27
  73. package/src/cli/ui.ts +72 -1
  74. package/src/cli.ts +38 -41
  75. package/src/connector.ts +29 -9
  76. package/src/connectors/hubspot.ts +261 -9
  77. package/src/connectors/hubspotAuth.ts +5 -1
  78. package/src/connectors/linkedin.ts +14 -14
  79. package/src/connectors/salesforce.ts +34 -2
  80. package/src/connectors/stripe.ts +154 -34
  81. package/src/enrich.ts +13 -0
  82. package/src/health.ts +14 -213
  83. package/src/healthScore.ts +223 -0
  84. package/src/index.ts +28 -1
  85. package/src/llm.ts +239 -0
  86. package/src/market.ts +157 -44
  87. package/src/marketClassify.ts +18 -1
  88. package/src/marketTaxonomy.ts +10 -2
  89. package/src/mcp-bin.ts +32 -0
  90. package/src/mcp.ts +140 -6
  91. package/src/progress.ts +197 -0
  92. package/src/runReport.ts +159 -4
  93. package/src/types.ts +20 -2
  94. package/docs/dx-punch-list.md +0 -87
  95. package/docs/roadmap-to-1.0.md +0 -211
package/CHANGELOG.md CHANGED
@@ -3,7 +3,115 @@
3
3
  All notable changes to the `fullstackgtm` package are documented here.
4
4
  The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and the project adheres to [Semantic Versioning](https://semver.org/).
6
- The path to 1.0 is planned in [docs/roadmap-to-1.0.md](./docs/roadmap-to-1.0.md).
6
+ The path to 1.0 is planned in the roadmap doc in the repository.
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.46.0] — 2026-07-06
11
+
12
+ ### Fixed — launch hardening
13
+
14
+ - **`fullstackgtm-mcp --help`/`--version` now work without optional peers.**
15
+ The MCP entrypoint parsed argv before importing `@modelcontextprotocol/sdk`
16
+ and `zod`, so on a clean install `fullstackgtm-mcp --help` exited 1. It now
17
+ prints usage (and the `npx -y -p fullstackgtm -p @modelcontextprotocol/sdk
18
+ -p zod fullstackgtm-mcp` invocation) and exits 0.
19
+ - **Packaging hygiene.** Narrowed `files` so internal planning docs
20
+ (`dx-punch-list`, `perf-notes`, `roadmap-to-1.0`) no longer ship in the npm
21
+ tarball; strengthened `prepublishOnly` to also run `npm pack --dry-run` and
22
+ smoke both bin `--help` paths.
23
+ - **Doc accuracy.** README Stripe restricted-key guidance now includes
24
+ Invoices:Read (required by `backfill stripe`); schedule allowlist lists
25
+ `enrich acquire --save`; benchmark headline points to the leaderboard
26
+ caveats; `llms.txt` clarifies Stripe is read-only and fixes broken public
27
+ mirror links; CHANGELOG clarifies unmatched Stripe customers get a proposed,
28
+ approval-gated account-create (not silent auto-create).
29
+ - **Public-surface scrub.** Replaced realistic fake LinkedIn prospect fixtures
30
+ with clearly-synthetic example identities and removed private-context
31
+ wording from shipped source/docs.
32
+
33
+ ### Added
34
+
35
+ - **Hosted first-party CRM OAuth for the CLI.** `fullstackgtm login hubspot` and
36
+ `fullstackgtm login salesforce` now default to hosted browser OAuth (explicit
37
+ `--hosted`, `--via`/`FULLSTACKGTM_HOSTED_URL` override), storing the same local
38
+ broker credential used by team pairing while provider tokens are minted
39
+ server-side. BYO app/token paths remain as advanced fallbacks, secrets still
40
+ never travel via argv, and first-party app secrets never ship in the npm
41
+ package. Non-TTY missing-BYO-flag errors print a deterministic hosted-login
42
+ next command.
43
+
44
+ - **Chunked LLM insight extraction (`extractInsightsChunked`) — the TamTone
45
+ "langextract" method.** Transcripts are never sent whole: they are chopped
46
+ into ~1,500-char speaker-turn-respecting chunks (the tested quality lever —
47
+ small chunks yielded ~95% more grounded signals than large ones) and each
48
+ chunk gets its own focused few-shot extraction call. Per-chunk results pass
49
+ the SAME verbatim-evidence and next-step-grounding gates as before (checked
50
+ against the chunk they came from — a quote from another chunk is treated as
51
+ a hallucination), then a quality gate
52
+ (0.25·length + 0.40·specificity + 0.35·confidence, reject < 0.15) filters
53
+ filler before per-call dedupe (richer statement wins) and ranking.
54
+ `call parse --llm` now uses this pipeline; single-shot remains for
55
+ one-chunk transcripts. Bounded: ≤80 chunks/call, 4 concurrent, a failed
56
+ chunk is skipped (all-failed throws).
57
+
58
+ - **`backfill runs` — replay local execution history to the paired hosted
59
+ app.** An engagement that started CLI-only gets its full trail on pairing:
60
+ every stored plan's apply runs (with per-op result counts and ORIGINAL
61
+ timestamps) and the `health.jsonl` hygiene timeline (seeding the hosted
62
+ health trend). Idempotent by construction — deterministic per-run
63
+ `clientRunId`s and server-side timestamp dedupe make re-running safe.
64
+ `--dry-run` counts without sending. Privacy unchanged: only what live
65
+ reporting sends leaves the machine (statuses, counts, timestamps,
66
+ scores — never CRM field values). Live run reports also carry a stable
67
+ `clientRunId` now, so retried reports never duplicate.
68
+
69
+ - **`backfill stripe` — paid Stripe invoices become closed-won CRM deals,
70
+ through the normal governed flow.** For a business whose revenue source of
71
+ truth is Stripe, the CRM can now be retroactively made honest: one
72
+ closed-won deal per paid invoice (amount = invoice total, close date = paid
73
+ date, associated to the customer's company), matched to CRM accounts by
74
+ billing-email domain first, then exact name. Unmatched customers are either
75
+ report-only (when they lack a usable name/domain, or with `--skip-unmatched`)
76
+ or an explicit proposed ACCOUNT create in the same `needs_approval` plan. The
77
+ command only ever proposes: `--save` persists the plan and writes happen
78
+ through `plans approve` → `apply`, where the connector re-resolves each
79
+ invoice id and creates only on a confirmed miss — re-running never
80
+ double-creates. Proposed account creates are one op per customer, use the
81
+ billing-email domain unless freemail, and stay approval-gated like every
82
+ other write.
83
+ - **HubSpot deal creation in `create_record`.** `CreateRecordPayload` gains
84
+ `dealStage: "closed_won"` (a provider-neutral sentinel resolved to the
85
+ target pipeline's real closed-won stage id from pipeline metadata — never
86
+ substring guessing) and `dealPipeline` (id or label; default pipeline when
87
+ absent). The dedupe key is a custom deal property (default
88
+ `stripe_invoice_id`), ensured on demand; if it can't be ensured the create
89
+ is skipped rather than performed without its dedupe key. Salesforce deal
90
+ creation stays explicitly skipped for now.
91
+ - **`fetchStripePaidInvoices`** on the Stripe connector: paginated
92
+ `status=paid` invoice reads with `created[gte]` incremental support,
93
+ cents→major conversion, and paid-date extraction.
94
+ - **Node-free health scoring (`src/healthScore.ts`).** `computeHealth` /
95
+ `summarizeHealth` / `healthToMarkdown` split out of `health.ts` (which keeps
96
+ the fs-backed profile timeline and re-exports everything) so V8 runtimes —
97
+ the hosted app's Convex functions — can score CRM hygiene with the exact
98
+ same deterministic curve the CLI uses.
99
+
100
+ ### Changed
101
+
102
+ - **CLI startup is ~20–30% faster (p50), with zero output changes.** The
103
+ dispatcher now loads verb modules lazily instead of compiling the full
104
+ 78-module graph on every invocation, `node:http` (which transitively
105
+ compiles node's bundled undici on Node 22) is deferred to the OAuth
106
+ loopback login that actually starts a server, and the CRM connector /
107
+ demo-data modules load only when that data source is selected. Measured
108
+ p50 over interleaved A/B runs: `--version` −31%, `capabilities --json`
109
+ −30%, `audit --demo --json` −23%. Every output is byte-identical to the
110
+ previous build (verified against 57 golden outputs across help, audit,
111
+ doctor, rules, dedupe, resolve, snapshot, and error paths) and the public
112
+ import surface of `cli.ts` is unchanged. Profiling method, opportunity
113
+ matrix, and honest non-wins are documented in
114
+ [docs/perf-notes.md](./docs/perf-notes.md).
7
115
 
8
116
  ## [0.45.0] — 2026-07-02
9
117
 
@@ -45,13 +45,15 @@ Credential resolution ladder, first match wins:
45
45
 
46
46
  1. `--token-env <NAME>` — explicit env var for one invocation
47
47
  2. Ambient env: `HUBSPOT_ACCESS_TOKEN`, or `SALESFORCE_ACCESS_TOKEN` + `SALESFORCE_INSTANCE_URL`, or `STRIPE_SECRET_KEY`
48
- 3. Stored login: `fullstackgtm login <provider>` (interactive; a human runs this once)
49
- 4. Broker pairing: `fullstackgtm login --via <hosted url>` (a human approves the pairing code)
48
+ 3. BYO direct login: advanced token/OAuth paths stored locally; these override hosted
49
+ 4. Hosted OAuth / broker: `fullstackgtm login hubspot` or `fullstackgtm login salesforce` (default browser OAuth; stores a broker credential and mints provider tokens server-side) or `login --via <hosted url>`
50
50
 
51
- In an agent sandbox, prefer rung 1 or 2. Never echo tokens into argv
52
- `login` reads secrets from stdin only. Set `FSGTM_NO_BROWSER=1` in headless
51
+ In an agent sandbox, prefer rung 1 or 2. If a human is at a browser, the next
52
+ command is usually `fullstackgtm login hubspot --hosted` or
53
+ `fullstackgtm login salesforce --hosted`. Never echo tokens into argv — BYO
54
+ secret paths read from stdin only. Set `FSGTM_NO_BROWSER=1` in headless
53
55
  environments — login flows then print verification URLs instead of opening
54
- the OS browser.
56
+ the OS browser. First-party app secrets never ship in the npm package.
55
57
 
56
58
  LLM calls (`call parse`, `call score`, `market classify`): set
57
59
  `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` in the environment, or have the human
@@ -77,11 +79,11 @@ count and provider spend per day and per month. It still flows through
77
79
  dry-run → approve → apply and never auto-writes — expect it to refuse rather
78
80
  than silently exceed the meter.
79
81
 
80
- Provider prerequisites (what the human must create, and which scopes) are in
81
- the README's **"Connect your CRM"** section: HubSpot needs a private app with
82
- four `crm.objects.*.read` scopes (plus write scopes only for `apply`);
83
- Salesforce needs an admin-created Connected App with device flow enabled;
84
- Stripe works with a restricted key (Customers + Subscriptions read).
82
+ Provider prerequisites for BYO fallback (what the human must create, and which
83
+ scopes) are in the README's **"Connect your CRM"** section: HubSpot private app
84
+ or BYO OAuth app scopes, Salesforce admin-created Connected App for device
85
+ flow, and Stripe restricted key (Customers + Subscriptions read). Hosted
86
+ HubSpot/Salesforce OAuth is the default when available.
85
87
 
86
88
  ```bash
87
89
  HUBSPOT_ACCESS_TOKEN=$TOKEN fullstackgtm audit --provider hubspot --json --out plan.json
@@ -140,6 +142,6 @@ stored captures before anything is appended).
140
142
  | Symptom | Fix |
141
143
  | --- | --- |
142
144
  | `fullstackgtm: command not found` | Re-run with `npx fullstackgtm`, or check global npm bin is on PATH |
143
- | `No HubSpot credentials` (exit 1) | Set `HUBSPOT_ACCESS_TOKEN` or have a human run `fullstackgtm login hubspot` |
145
+ | `No HubSpot credentials` (exit 1) | Set `HUBSPOT_ACCESS_TOKEN` or have a human run `fullstackgtm login hubspot --hosted` |
144
146
  | MCP server prints peer-dependency help | Install `@modelcontextprotocol/sdk` and `zod` (see step 6) |
145
147
  | Need machine state for orchestration | `fullstackgtm doctor --json` |
package/README.md CHANGED
@@ -8,7 +8,7 @@ Think `terraform plan` for your CRM: agents and scripts may *read* everything, b
8
8
 
9
9
  Licensed under [Apache-2.0](./LICENSE). The boundary is deliberate and stable: the framework, CLI, and MCP server are open source; the hosted Full Stack GTM application (dashboard, sync backend, broker service, team workflows) is a separate, proprietary product built on top of this package. Features never move from open to closed. See [CONTRIBUTING.md](./CONTRIBUTING.md) for how development and mirroring work.
10
10
 
11
- **Status: beta (0.x).** The surfaces in [docs/api.md](./docs/api.md) — the canonical model, rule interface, plan/apply contract, connector contract, merge/diff, config, CLI, and MCP tools — are settling but may still break in minor releases until 1.0; the path there is [docs/roadmap-to-1.0.md](./docs/roadmap-to-1.0.md). The safety invariants (read-only audits, approval-gated writes, placeholder refusal) are not beta and do not change. Connectors: HubSpot (read/write), Salesforce (read/write), Stripe (read-only billing).
11
+ **Status: beta (0.x).** The surfaces in [docs/api.md](./docs/api.md) — the canonical model, rule interface, plan/apply contract, connector contract, merge/diff, config, CLI, and MCP tools — are settling but may still break in minor releases until 1.0. The safety invariants (read-only audits, approval-gated writes, placeholder refusal) are not beta and do not change. Connectors: HubSpot (read/write), Salesforce (read/write), Stripe (read-only billing).
12
12
 
13
13
  ## Install
14
14
 
@@ -136,8 +136,11 @@ fullstackgtm dedupe account --key domain --keep richest --save # one merge_rec
136
136
  fullstackgtm reassign --from 411 --to 902 --except-deal-stage closing --save # ownership handoff playbook
137
137
  fullstackgtm reassign --assign-unowned --to 902 --save # claim every ownerless record for an owner
138
138
  fullstackgtm fix --rule missing-deal-owner --provider hubspot --yes # audit one rule → suggest → approve → apply, one command
139
+ fullstackgtm backfill stripe --save # paid Stripe invoices → proposed closed-won deals, deduped by invoice id
139
140
  ```
140
141
 
142
+ `backfill stripe` makes the CRM honest when Stripe is the revenue source of truth: one closed-won deal per paid invoice (amount = invoice total, close date = paid date, company association by billing-email domain). A customer the CRM doesn't know gets a proposed account create in the same approval-gated plan (freemail billing domains are never used as a company domain; `--skip-unmatched` keeps them report-only), and apply re-resolves each invoice id so re-running never double-creates.
143
+
141
144
  `bulk-update` filters the snapshot (`=`, `!=`, `~` substring, `!~` not-substring, `:empty`/`:notempty`, type-aware comparisons `<` `>` `<=` `>=` where `today` resolves to the policy date — e.g. `closeDate<today` — and date/numeric fields coerce by value form, `|` any-of, relational pseudo-fields like `account.domain` or `openDealStages`) into a dry-run patch plan — and **the full filter is re-verified per record at apply time**, with mid-apply rechecks, so a record that stopped matching between audit and apply is skipped, not clobbered. For date/count hygiene (past close dates, stale deals, missing accounts, duplicates), prefer the rule-backed `fix --rule <id>` — the rule encodes the open-deal + date logic deterministically; use `bulk-update` only when no rule covers the task. Equality filters double as preconditions; `--require` adds explicit ones; `--guard` asserts cross-record conditions; `--max-operations` caps blast radius. `--set field=from:<sourceField>` derives values per record; `--create-task <text>` is the third change mode, emitting approval-gated `create_task` operations instead of field writes; `--archive` refuses records whose identity key (account domain, contact email) is shared with another record — that's a duplicate, and duplicates are merged with `dedupe`, not archived around (`--force-archive-duplicates` overrides that refusal explicitly).
142
145
 
143
146
  `dedupe` finds duplicate groups by normalized identity key and emits one `merge_records` operation per group with a deterministic survivor (`richest` = most populated fields, ties to lowest id; `oldest`). Merges stay irreversible-and-therefore-low-confidence-capped on approval, exactly like merge suggestions from the audit. `reassign` is the ownership-handoff playbook: one plan per object type, extra scoping account-lifted to deals and contacts, and `--except-deal-stage` excludes both deals in that stage and every record whose account has an open deal in it. `fix` is the one-shot composite for a single rule: audit → save → suggest → approve suggestion-backed operations at the confidence bar → with `--yes`, apply and print the stage-by-stage summary; without it, stop after approval and print the apply command.
@@ -260,7 +263,7 @@ fullstackgtm schedule status --runs 5 # last runs, exit codes, artifa
260
263
  fullstackgtm schedule uninstall # remove the managed block, touch nothing else
261
264
  ```
262
265
 
263
- **Scheduling never auto-approves.** Schedulable commands are read/plan-side only — `audit`, `snapshot`, `enrich append|refresh`, `market capture|refresh`, `signals fetch`, `icp judge`, `icp eval`, `draft`, `suggest`, `report`, `doctor` — so unattended runs accumulate *proposals* (plans in the queue, run records, reports), never CRM writes. `apply` is schedulable only as `apply --plan-id <id>`, and every firing re-checks the plan's status is approved: an unapproved plan records a `plan_not_approved` no-op run instead of executing, and no flag relaxes this. Arbitrary shell is not schedulable — an entry's argv must resolve to a known fullstackgtm command (validated at `add` time and re-checked at run time), and the timer line you audit — crontab entry or LaunchAgent `ProgramArguments` — is always `fullstackgtm schedule run <id>` and nothing else.
266
+ **Scheduling never auto-approves.** Schedulable commands are read/plan-side only — `audit`, `snapshot`, `enrich append|refresh`, `enrich acquire --save`, `market capture|refresh`, `signals fetch`, `icp judge`, `icp eval`, `draft`, `suggest`, `report`, `doctor` — so unattended runs accumulate *proposals* (plans in the queue, run records, reports), never CRM writes. `apply` is schedulable only as `apply --plan-id <id>`, and every firing re-checks the plan's status is approved: an unapproved plan records a `plan_not_approved` no-op run instead of executing, and no flag relaxes this. Arbitrary shell is not schedulable — an entry's argv must resolve to a known fullstackgtm command (validated at `add` time and re-checked at run time), and the timer line you audit — crontab entry or LaunchAgent `ProgramArguments` — is always `fullstackgtm schedule run <id>` and nothing else.
264
267
 
265
268
  `install` materializes enabled entries into the system timer; `--timer` defaults by platform. On macOS it writes one LaunchAgent plist per entry (`com.fullstackgtm.<profile>.<id>` in `~/Library/LaunchAgents`, loaded via `launchctl bootstrap`) — macOS gates `crontab` writes behind Full Disk Access, a permission no program can request, while LaunchAgents need none; launchd also coalesces firings missed during sleep into one run on wake. Re-install replaces the profile's plist fleet wholesale and never touches foreign plists. Elsewhere (or with `--timer crontab`) it renders a sentinel-delimited block (`# >>> fullstackgtm <profile> >>>` … `# <<< fullstackgtm <profile> <<<`) in your user crontab; re-install replaces the block wholesale and never touches lines outside it. Honest limitation: cron has no catch-up — a laptop asleep at firing time means a missed run. `schedule status` surfaces missed firings by comparing expected-vs-actual run history, so the gap is at least visible. Entries are provider-agnostic; cloud providers (Modal, AWS) arrive as scaffold generators that call the same `schedule run <id>` contract, and are refused as "not yet implemented" until then.
266
269
 
@@ -312,7 +315,9 @@ fullstackgtm diff --before old.json --after new.json --fail-on-new-findings
312
315
  - `--demo` (with `--seed`) generates a realistic mid-market CRM with injected real-world failure modes — departed owners, unlinked deals, orphan accounts, stale pipeline — so agents and CI can exercise the full snapshot → audit → apply pipeline with zero credentials.
313
316
  - Exit codes: `0` success, `1` error, `2` findings at/above `--fail-on`.
314
317
 
315
- "Built for agents" is measured, not asserted: a 1,892-run benchmark (20 scenarios = 17 synthetic + 3 seeded from an anonymized real portal, × 3 tool-surface arms × up to 4 trials, across nine models from six vendors, deterministic graders over final CRM state, τ-bench-style pass^k) shows the gated CLI surface beating raw CRM-API access on completion-under-policy for every model tested — and the tool-surface effect is monotonic and vendor-independent. Full matrix and methodology: [the leaderboard](./evals/crm/leaderboard/RESULTS.md).
318
+ "Built for agents" is measured, not asserted: a 1,904-run benchmark (17 CRM-operations scenarios × 3 tool-surface arms × up to 4 trials, across ten models from six vendors, deterministic graders over final CRM state, τ-bench-style pass^k) shows the gated CLI surface matching or beating raw CRM-API access on completion-under-policy for every model tested — strictly better in nine of ten — and the effect is vendor-independent. Full matrix and methodology: [the leaderboard](./evals/crm/leaderboard/RESULTS.md).
319
+
320
+ Caveat: the leaderboard documents the Opus reduced protocol and mixed-version Informed arms; treat the headline as a pointer to those exact RESULTS.md conditions.
316
321
 
317
322
  The design is **deterministic apply, governed suggest**: the parts that touch your CRM — the audit rules, the plan/apply contract, compare-and-set, the survivor/merge logic — are deterministic and replayable; the parts that read free text (`call parse`/`score`, `market classify`) are LLM-powered but bounded, with every quoted span mechanically verified against the source before it can drive a writeback. Nondeterministic suggestion, deterministic governance.
318
323
 
@@ -321,9 +326,9 @@ The design is **deterministic apply, governed suggest**: the parts that touch yo
321
326
  Credential resolution is a ladder — the first rung that yields a token wins:
322
327
 
323
328
  1. **`--token-env <NAME>`** — explicit env var for one invocation (agent sandboxes, scripts)
324
- 2. **`HUBSPOT_ACCESS_TOKEN`** — ambient env (CI)
325
- 3. **Stored login** — `fullstackgtm login hubspot`, kept in `~/.fullstackgtm/credentials.json` (0600; override location with `FSGTM_HOME`)
326
- 4. **Broker pairing** — `fullstackgtm login --via <hosted url>`: the team's deployment holds the CRM credentials; the CLI holds only a revocable pairing token
329
+ 2. **`HUBSPOT_ACCESS_TOKEN` / Salesforce env** — ambient env (CI)
330
+ 3. **BYO direct login** — advanced token/OAuth paths stored in `~/.fullstackgtm/credentials.json` (0600; override location with `FSGTM_HOME`); these override hosted
331
+ 4. **Hosted OAuth / broker** — `fullstackgtm login hubspot` or `fullstackgtm login salesforce` opens hosted browser OAuth by default, stores only a broker credential locally, and mints provider tokens server-side
327
332
 
328
333
  ### Teams: auth once, point every CLI at the stored sync credentials
329
334
 
@@ -337,22 +342,26 @@ An admin connects HubSpot **once** in the hosted dashboard (the org's OAuth toke
337
342
 
338
343
  **Run observability (paired CLIs).** Once paired, each command best-effort reports a run record — command, status (`success`/`partial`/`error`), duration, headline counts (e.g. ops emitted, leads created, est. cost), and structured events (plan saved, meter charged) — to the deployment's **run timeline** in the dashboard. It's opt-in by pairing (an unpaired CLI sends nothing), a 4-second-capped POST that swallows every error, and never changes the command's exit code. Setup/inspection verbs (`login`, `doctor`, `help`, `--version`) are skipped.
339
344
 
340
- ### Individuals: no deployment needed
345
+ ### Individuals: hosted OAuth by default, BYO still available
341
346
 
342
347
  ```bash
343
- # HubSpot, zero web flow: paste a private app token once (validated, then stored)
348
+ # Default: hosted first-party OAuth. No provider app or provider secret in the npm package.
344
349
  fullstackgtm login hubspot
350
+ fullstackgtm login salesforce
351
+ # optional: point at a self-hosted/team deployment
352
+ fullstackgtm login hubspot --hosted --via https://gtm.yourco.com
353
+
354
+ # Advanced HubSpot private app token (validated, then stored; token on stdin).
355
+ # Note: hosted HubSpot OAuth covers reads and object writes but cannot create
356
+ # tasks (HubSpot exposes no tasks scope to public apps) — use a private-app
357
+ # token if your workflow relies on `create_task` proposals.
358
+ echo "$HUBSPOT_TOKEN" | fullstackgtm login hubspot --private-token
345
359
 
346
- # HubSpot, bring-your-own-app OAuth. The browser is used exactly oncethe
347
- # consent grant — captured on a 127.0.0.1 loopback (RFC 8252); the CLI
348
- # exchanges the code itself and refreshes silently from then on. The client
349
- # secret is read from stdin or an interactive prompt — never as a flag.
360
+ # Advanced HubSpot BYO-app OAuth. Client secret is read from stdin/promptnever a flag.
350
361
  echo "$CLIENT_SECRET" | fullstackgtm login hubspot --oauth --client-id <id>
351
362
  # (register http://localhost:8763/callback as a redirect URL on your app)
352
363
 
353
- # Salesforce: native device flow — confirm a code on any device, no localhost
354
- # server, no client secret, silent refresh. Needs a Connected App consumer key
355
- # with device flow enabled (see "Connect your CRM" below).
364
+ # Advanced Salesforce BYO Connected App device flow.
356
365
  fullstackgtm login salesforce --device --client-id <consumer key>
357
366
  # ...or a session token directly (token on stdin, never as a flag):
358
367
  echo "$SF_SESSION_TOKEN" | fullstackgtm login salesforce --instance-url https://yourorg.my.salesforce.com
@@ -360,7 +369,7 @@ echo "$SF_SESSION_TOKEN" | fullstackgtm login salesforce --instance-url https://
360
369
  fullstackgtm logout hubspot # or: salesforce | broker
361
370
  ```
362
371
 
363
- A direct `login hubspot` always wins over a broker pairing, so an operator can override the team default. HubSpot does not support the device-authorization grant or secretless public clients, which is why the bring-your-own-app OAuth path requires client credentials; they are stored locally for silent refresh, the same model as `gcloud` and `aws` CLI profiles.
372
+ BYO direct credentials always win over a hosted broker pairing, so an operator can override the team default. First-party app secrets never ship in the npm package; hosted login stores a local broker token and mints provider tokens server-side. HubSpot does not support the device-authorization grant or secretless public clients, which is why the bring-your-own-app OAuth path requires client credentials; they are stored locally for silent refresh, the same model as `gcloud` and `aws` CLI profiles.
364
373
 
365
374
  ## Connect your CRM
366
375
 
@@ -389,7 +398,7 @@ Connectors differ in what the provider's API allows — stated up front so nothi
389
398
  - `crm.objects.contacts.read`
390
399
  - `crm.objects.deals.read`
391
400
  3. If you plan to **apply** approved operations (not just audit), also grant write scopes for the objects you'll let it touch: `crm.objects.deals.write` (covers `deal.next_step` and other deal fields), plus `crm.objects.contacts.write` / `crm.objects.companies.write` for contact/company patches, and the **Tasks** write scope for `create_task` operations (search "tasks" in the scope picker; naming varies by portal).
392
- 4. Create the app, copy the token (`pat-...`), then: `echo "$TOKEN" | fullstackgtm login hubspot`.
401
+ 4. Create the app, copy the token (`pat-...`), then: `echo "$TOKEN" | fullstackgtm login hubspot --private-token`.
393
402
 
394
403
  If a scope is missing you'll see a `403` mid-run whose body names the exact missing scope (`requiredGranularScopes`) — add it to the private app and re-run. Note that `login` only validates the token itself; it can't tell whether every scope you'll need is granted.
395
404
 
@@ -418,7 +427,7 @@ This is a **short-lived session token with no refresh** — ideal for a one-off
418
427
 
419
428
  ### Stripe: a restricted key is enough (read-only connector)
420
429
 
421
- The Stripe connector only reads customers and subscriptions, and `apply` is read-only by construction. Create a **restricted key** with just **Customers: Read** and **Subscriptions: Read** (Developers → API keys → Create restricted key) instead of pasting a full-access secret key: `echo "$KEY" | fullstackgtm login stripe`.
430
+ The Stripe connector reads customers, subscriptions, and paid invoices (`backfill stripe` calls `/v1/invoices`), and `apply` is read-only by construction. Create a **restricted key** with just **Customers: Read**, **Subscriptions: Read**, and **Invoices: Read** (Developers → API keys → Create restricted key) instead of pasting a full-access secret key: `echo "$KEY" | fullstackgtm login stripe`.
422
431
 
423
432
  ## Concepts
424
433
 
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Backfill: Stripe paid invoices → closed-won CRM deals, through the normal
3
+ * governed plan/apply flow.
4
+ *
5
+ * Product assumption this encodes: billing (Stripe) is the revenue source
6
+ * of truth; the CRM should retroactively carry ONE closed-won deal per paid
7
+ * invoice (amount = invoice total, close date = paid date, associated to the
8
+ * customer's company, deduped by invoice id). This module only BUILDS the
9
+ * dry-run plan — nothing here touches a CRM. Writes happen exclusively via
10
+ * `apply` on operations approved through `plans approve`, where the HubSpot
11
+ * connector re-resolves each invoice id (resolve-first) and creates only on
12
+ * a confirmed miss.
13
+ */
14
+ import type { CanonicalGtmSnapshot, PatchPlan } from "./types.ts";
15
+ import type { StripePaidInvoice } from "./connectors/stripe.ts";
16
+ export declare const DEFAULT_BACKFILL_MATCH_PROPERTY = "stripe_invoice_id";
17
+ export type StripeBackfillOptions = {
18
+ /** Pipeline id or case-insensitive label; absent = the portal's default pipeline. */
19
+ pipeline?: string;
20
+ /** Deal dedupe property stamped with the invoice id (default "stripe_invoice_id"). */
21
+ matchProperty?: string;
22
+ /** Source label recorded on payloads/evidence (default "stripe:invoices"). */
23
+ source?: string;
24
+ /**
25
+ * When a customer matches no CRM account, propose creating the account as
26
+ * part of this plan (default true). The account op is explicit and
27
+ * approval-gated like everything else; apply is resolve-first, so an
28
+ * account that appeared in the meantime is reused, not duplicated. False =
29
+ * the conservative original behavior: unmatched customers are reported
30
+ * only and their invoices produce no operations.
31
+ */
32
+ createMissingAccounts?: boolean;
33
+ /** Injectable clock for deterministic tests. */
34
+ now?: () => Date;
35
+ };
36
+ /** One invoice whose customer could not be matched to any CRM account. */
37
+ export type StripeBackfillUnmatched = {
38
+ invoiceId: string;
39
+ invoiceNumber?: string;
40
+ customerName?: string;
41
+ customerDomain?: string;
42
+ amountPaid: number;
43
+ currency?: string;
44
+ paidAt?: string;
45
+ };
46
+ /** One account this plan proposes to create for an unmatched Stripe customer. */
47
+ export type StripeBackfillProposedAccount = {
48
+ name: string;
49
+ domain?: string;
50
+ /** Paid invoices from this customer that the plan attaches to the account. */
51
+ invoiceCount: number;
52
+ };
53
+ export type StripeBackfillCounts = {
54
+ invoices: number;
55
+ /** create_record deal ops emitted. */
56
+ planned: number;
57
+ /** create_record account ops emitted for unmatched customers. */
58
+ accountsProposed: number;
59
+ /** Customers with no CRM match AND no usable name/domain — reported only. */
60
+ unmatched: number;
61
+ /** Plan-time prefilter: a deal with the same name already exists. */
62
+ alreadyInCrm: number;
63
+ };
64
+ export type StripeBackfillResult = {
65
+ plan: PatchPlan;
66
+ counts: StripeBackfillCounts;
67
+ unmatched: StripeBackfillUnmatched[];
68
+ proposedAccounts: StripeBackfillProposedAccount[];
69
+ };
70
+ /**
71
+ * Match each paid invoice's customer to a snapshot account (normalized domain
72
+ * first — the accurate key — then exact case-insensitive name) and emit one
73
+ * create_record deal op per invoice that has no deal yet.
74
+ *
75
+ * Unmatched customers (default): the plan ALSO proposes creating the account —
76
+ * one explicit, approval-gated account create_record op per new customer,
77
+ * with the customer's invoices attached as deals. Freemail billing domains
78
+ * (gmail/outlook/…) are never used as a company domain. A customer with no
79
+ * usable name or domain stays report-only. `createMissingAccounts: false`
80
+ * restores the original conservative behavior (unmatched = reported, no ops).
81
+ *
82
+ * The by-deal-name prefilter here is best-effort plan hygiene; the
83
+ * AUTHORITATIVE duplicate gate is the connector's apply-time resolve-first
84
+ * search on the invoice-id property (and resolve-by-domain/name for accounts).
85
+ */
86
+ export declare function buildStripeBackfillPlan(invoices: StripePaidInvoice[], snapshot: CanonicalGtmSnapshot, opts?: StripeBackfillOptions): StripeBackfillResult;
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Backfill: Stripe paid invoices → closed-won CRM deals, through the normal
3
+ * governed plan/apply flow.
4
+ *
5
+ * Product assumption this encodes: billing (Stripe) is the revenue source
6
+ * of truth; the CRM should retroactively carry ONE closed-won deal per paid
7
+ * invoice (amount = invoice total, close date = paid date, associated to the
8
+ * customer's company, deduped by invoice id). This module only BUILDS the
9
+ * dry-run plan — nothing here touches a CRM. Writes happen exclusively via
10
+ * `apply` on operations approved through `plans approve`, where the HubSpot
11
+ * connector re-resolves each invoice id (resolve-first) and creates only on
12
+ * a confirmed miss.
13
+ */
14
+ import { normalizeDomain } from "./merge.js";
15
+ // Mirrors stableHash in rules.ts (FNV-1a); duplicated to keep backfill.ts
16
+ // importable without pulling the audit engine (the enrich.ts precedent).
17
+ function fnv1a(value) {
18
+ let hash = 0x811c9dc5;
19
+ for (let i = 0; i < value.length; i += 1) {
20
+ hash ^= value.charCodeAt(i);
21
+ hash = Math.imul(hash, 0x01000193);
22
+ }
23
+ return (hash >>> 0).toString(16).padStart(8, "0");
24
+ }
25
+ export const DEFAULT_BACKFILL_MATCH_PROPERTY = "stripe_invoice_id";
26
+ // Freemail domains never become a company domain: stamping "gmail.com" on an
27
+ // account (or resolving a company BY gmail.com) would collapse unrelated
28
+ // customers onto one record. Mirrors FREE_MAIL in connectors/signalSources.ts
29
+ // (duplicated to keep backfill.ts importable without connector code, the
30
+ // fnv1a precedent above).
31
+ const FREE_MAIL = new Set([
32
+ "gmail.com",
33
+ "yahoo.com",
34
+ "hotmail.com",
35
+ "outlook.com",
36
+ "icloud.com",
37
+ "aol.com",
38
+ "proton.me",
39
+ "protonmail.com",
40
+ ]);
41
+ /** `Invoice <number || id> — <customer name>` (the plan-time dedupe name). */
42
+ function backfillDealName(invoice, companyName) {
43
+ return `Invoice ${invoice.number ?? invoice.id} — ${invoice.customerName ?? companyName}`;
44
+ }
45
+ /**
46
+ * Match each paid invoice's customer to a snapshot account (normalized domain
47
+ * first — the accurate key — then exact case-insensitive name) and emit one
48
+ * create_record deal op per invoice that has no deal yet.
49
+ *
50
+ * Unmatched customers (default): the plan ALSO proposes creating the account —
51
+ * one explicit, approval-gated account create_record op per new customer,
52
+ * with the customer's invoices attached as deals. Freemail billing domains
53
+ * (gmail/outlook/…) are never used as a company domain. A customer with no
54
+ * usable name or domain stays report-only. `createMissingAccounts: false`
55
+ * restores the original conservative behavior (unmatched = reported, no ops).
56
+ *
57
+ * The by-deal-name prefilter here is best-effort plan hygiene; the
58
+ * AUTHORITATIVE duplicate gate is the connector's apply-time resolve-first
59
+ * search on the invoice-id property (and resolve-by-domain/name for accounts).
60
+ */
61
+ export function buildStripeBackfillPlan(invoices, snapshot, opts = {}) {
62
+ const nowIso = (opts.now ?? (() => new Date()))().toISOString();
63
+ const matchProperty = opts.matchProperty ?? DEFAULT_BACKFILL_MATCH_PROPERTY;
64
+ const source = opts.source ?? "stripe:invoices";
65
+ // Snapshot lookups. First occurrence wins on a duplicate key — the
66
+ // connector's own resolve/associate step handles ambiguity at apply time.
67
+ const accountsByDomain = new Map();
68
+ const accountsByName = new Map();
69
+ for (const account of snapshot.accounts ?? []) {
70
+ const domain = normalizeDomain(account.domain);
71
+ if (domain && !accountsByDomain.has(domain))
72
+ accountsByDomain.set(domain, account);
73
+ const name = account.name?.trim().toLowerCase();
74
+ if (name && !accountsByName.has(name))
75
+ accountsByName.set(name, account);
76
+ }
77
+ const existingDealNames = new Set((snapshot.deals ?? [])
78
+ .map((deal) => deal.name?.trim().toLowerCase())
79
+ .filter((name) => Boolean(name)));
80
+ const createMissingAccounts = opts.createMissingAccounts ?? true;
81
+ const operations = [];
82
+ const evidence = [];
83
+ const unmatched = [];
84
+ const proposedByKey = new Map();
85
+ const counts = {
86
+ invoices: invoices.length,
87
+ planned: 0,
88
+ accountsProposed: 0,
89
+ unmatched: 0,
90
+ alreadyInCrm: 0,
91
+ };
92
+ for (const invoice of invoices) {
93
+ // Match the customer to a CRM account: domain first, then exact name.
94
+ const domain = normalizeDomain(invoice.customerDomain);
95
+ let account = domain ? accountsByDomain.get(domain) : undefined;
96
+ if (!account && invoice.customerName) {
97
+ account = accountsByName.get(invoice.customerName.trim().toLowerCase());
98
+ }
99
+ // The company the deal will land on: the matched account, or (default) a
100
+ // new account this plan proposes for the unmatched customer.
101
+ let companyName = account?.name;
102
+ let companyDomain = normalizeDomain(account?.domain);
103
+ let accountIsNew = false;
104
+ if (!account && createMissingAccounts) {
105
+ const usableDomain = domain && !FREE_MAIL.has(domain) ? domain : undefined;
106
+ const name = invoice.customerName?.trim() || usableDomain;
107
+ if (name) {
108
+ companyName = name;
109
+ companyDomain = usableDomain;
110
+ accountIsNew = true;
111
+ }
112
+ }
113
+ if (!companyName) {
114
+ counts.unmatched += 1;
115
+ unmatched.push({
116
+ invoiceId: invoice.id,
117
+ invoiceNumber: invoice.number,
118
+ customerName: invoice.customerName,
119
+ customerDomain: invoice.customerDomain,
120
+ amountPaid: invoice.amountPaid,
121
+ currency: invoice.currency,
122
+ paidAt: invoice.paidAt,
123
+ });
124
+ continue;
125
+ }
126
+ // Plan-time dupe prefilter by deal name (best-effort; apply-time
127
+ // resolve-first on the invoice-id property is the authoritative gate).
128
+ const dealName = backfillDealName(invoice, companyName);
129
+ if (existingDealNames.has(dealName.trim().toLowerCase())) {
130
+ counts.alreadyInCrm += 1;
131
+ continue;
132
+ }
133
+ const invoiceLabel = invoice.number ?? invoice.id;
134
+ const amountLabel = `${invoice.amountPaid}${invoice.currency ? ` ${invoice.currency}` : ""}`;
135
+ const recordEvidence = {
136
+ id: `ev_bkf_${fnv1a(`${source}:${invoice.id}`)}`,
137
+ sourceSystem: "web",
138
+ sourceObjectType: "invoice",
139
+ sourceObjectId: invoice.id,
140
+ title: `Stripe paid invoice ${invoiceLabel}`,
141
+ text: `Stripe invoice ${invoiceLabel} (${invoice.id}) for ${invoice.customerName ?? companyName}: ` +
142
+ `${amountLabel} paid${invoice.paidAt ? ` on ${invoice.paidAt}` : ""}.`,
143
+ capturedAt: nowIso,
144
+ metadata: {
145
+ source,
146
+ invoiceId: invoice.id,
147
+ amountPaid: invoice.amountPaid,
148
+ currency: invoice.currency ?? null,
149
+ paidAt: invoice.paidAt ?? null,
150
+ customerId: invoice.customerId ?? null,
151
+ },
152
+ };
153
+ evidence.push(recordEvidence);
154
+ // One explicit account op per NEW customer (not per invoice): the reviewer
155
+ // sees exactly which companies this plan will create. Apply resolves by
156
+ // domain-then-name first, so a concurrently-created account is reused.
157
+ if (accountIsNew) {
158
+ const accountKey = (companyDomain ?? companyName).toLowerCase();
159
+ const existing = proposedByKey.get(accountKey);
160
+ if (existing) {
161
+ existing.invoiceCount += 1;
162
+ }
163
+ else {
164
+ proposedByKey.set(accountKey, {
165
+ name: companyName,
166
+ ...(companyDomain ? { domain: companyDomain } : {}),
167
+ invoiceCount: 1,
168
+ });
169
+ const accountPayload = {
170
+ properties: {
171
+ name: companyName,
172
+ ...(companyDomain ? { domain: companyDomain } : {}),
173
+ },
174
+ matchKey: "name",
175
+ matchValue: companyName,
176
+ source,
177
+ };
178
+ operations.push({
179
+ id: `op_bkf_${fnv1a(`${source}:account:${accountKey}`)}`,
180
+ objectType: "account",
181
+ objectId: `create:stripe:customer:${invoice.customerId ?? accountKey}`,
182
+ operation: "create_record",
183
+ beforeValue: null,
184
+ afterValue: accountPayload,
185
+ reason: `Stripe customer "${companyName}"${companyDomain ? ` (${companyDomain})` : ""} has paid ` +
186
+ `invoice(s) but no CRM account — create it so the backfilled deals have a home. ` +
187
+ `Resolve-first by ${companyDomain ? "domain, then " : ""}name at apply.`,
188
+ sourceRuleOrPolicy: `backfill:${source}`,
189
+ riskLevel: "medium",
190
+ approvalRequired: true,
191
+ rollback: "Archive the created company (it was net-new).",
192
+ evidenceIds: [recordEvidence.id],
193
+ });
194
+ counts.accountsProposed += 1;
195
+ }
196
+ }
197
+ // Prefer the matched ACCOUNT's own name/domain for the association so the
198
+ // connector's resolve-first company step lands on the matched record; for
199
+ // a new customer these are the proposed account's name/domain.
200
+ const payload = {
201
+ properties: {
202
+ dealname: dealName,
203
+ amount: String(invoice.amountPaid),
204
+ ...(invoice.paidAt ? { closedate: invoice.paidAt } : {}),
205
+ ...(invoice.description ? { description: invoice.description } : {}),
206
+ },
207
+ matchKey: matchProperty,
208
+ matchValue: invoice.id,
209
+ source,
210
+ dealStage: "closed_won",
211
+ ...(opts.pipeline ? { dealPipeline: opts.pipeline } : {}),
212
+ associateCompanyName: companyName,
213
+ ...(companyDomain ? { associateCompanyDomain: companyDomain } : {}),
214
+ };
215
+ operations.push({
216
+ id: `op_bkf_${fnv1a(`${source}:deal:${invoice.id}`)}`,
217
+ objectType: "deal",
218
+ objectId: `create:stripe:invoice:${invoice.id}`,
219
+ operation: "create_record",
220
+ beforeValue: null,
221
+ afterValue: payload,
222
+ reason: `Paid Stripe invoice ${invoiceLabel} (${amountLabel}, paid ${invoice.paidAt ?? "date unknown"}) ` +
223
+ `has no CRM deal — backfill as closed-won on ${accountIsNew ? "NEW account" : "account"} ` +
224
+ `"${companyName}"${accountIsNew ? " (created by this plan)" : ""} ` +
225
+ `(deduped by ${matchProperty}=${invoice.id}).`,
226
+ sourceRuleOrPolicy: `backfill:${source}`,
227
+ riskLevel: "medium",
228
+ approvalRequired: true,
229
+ rollback: "Archive the created deal (it was net-new).",
230
+ evidenceIds: [recordEvidence.id],
231
+ });
232
+ counts.planned += 1;
233
+ }
234
+ const plan = {
235
+ id: `patch_plan_backfill_${fnv1a(`${source}:${nowIso}:${invoices.map((invoice) => invoice.id).join(",")}`)}`,
236
+ title: `Backfill closed-won deals — ${source}`,
237
+ createdAt: nowIso,
238
+ status: operations.length > 0 ? "needs_approval" : "draft",
239
+ dryRun: true,
240
+ summary: `${counts.planned} closed-won deal(s) proposed from ${counts.invoices} paid Stripe invoice(s)` +
241
+ `${counts.accountsProposed > 0 ? `, plus ${counts.accountsProposed} new account(s) proposed for customers the CRM doesn't know` : ""} ` +
242
+ `(${counts.alreadyInCrm} already have a matching deal, ${counts.unmatched} ` +
243
+ `${createMissingAccounts ? "customer(s) with no usable name/domain" : "unmatched customer(s)"} reported only` +
244
+ `${createMissingAccounts ? "" : " — account creation disabled"}). ` +
245
+ `Deduped by ${matchProperty} = invoice id; apply re-checks resolve-first before creating.`,
246
+ findings: [],
247
+ evidence,
248
+ operations,
249
+ };
250
+ return { plan, counts, unmatched, proposedAccounts: [...proposedByKey.values()] };
251
+ }
package/dist/bin.js CHANGED
@@ -1,8 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { runCli } from "./cli.js";
3
- import { flushRunReport } from "./runReport.js";
3
+ import { beginRunReport, flushRunReport } from "./runReport.js";
4
4
  const args = process.argv.slice(2);
5
5
  const startedAt = Date.now();
6
+ // Arm live progress heartbeats (paired CLIs stream long runs to the hosted
7
+ // app under the same clientRunId the final flush below will upsert).
8
+ beginRunReport(args, startedAt);
6
9
  runCli(args)
7
10
  .then(async () => {
8
11
  // exitCode 2 (audit findings / create-gate) or 1 (no value) = "partial":