fullstackgtm 0.14.1 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +70 -0
- package/README.md +14 -0
- package/dist/cli.js +169 -0
- package/dist/connectors/hubspot.js +62 -7
- package/dist/diff.js +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +4 -1
- package/dist/market.d.ts +147 -0
- package/dist/market.js +319 -0
- package/dist/marketReport.d.ts +3 -0
- package/dist/marketReport.js +233 -0
- package/dist/mcp.js +20 -0
- package/dist/merge.js +1 -1
- package/dist/resolve.d.ts +37 -0
- package/dist/resolve.js +126 -0
- package/dist/rules.d.ts +12 -0
- package/dist/rules.js +25 -3
- package/dist/types.d.ts +17 -1
- package/docs/crm-health-lifecycle.md +11 -11
- package/llms.txt +4 -0
- package/package.json +1 -1
- package/src/cli.ts +183 -0
- package/src/connectors/hubspot.ts +68 -10
- package/src/diff.ts +1 -1
- package/src/index.ts +29 -0
- package/src/market.ts +467 -0
- package/src/marketReport.ts +272 -0
- package/src/mcp.ts +26 -0
- package/src/merge.ts +1 -1
- package/src/resolve.ts +177 -0
- package/src/rules.ts +24 -3
- package/src/types.ts +18 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,76 @@ 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
6
|
The path to 1.0 is planned in [docs/roadmap-to-1.0.md](./docs/roadmap-to-1.0.md).
|
|
7
7
|
|
|
8
|
+
## [0.16.0] — 2026-06-11
|
|
9
|
+
|
|
10
|
+
The market map: a live model of the competitive category a company sells
|
|
11
|
+
into — claim taxonomy as reviewable config, append-only observations with
|
|
12
|
+
verbatim-quote evidence, deterministic front states and drift.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **`fullstackgtm market`** — `init` scaffolds a `market.config.json`
|
|
17
|
+
(vendor registry + claim taxonomy + the LOUD/QUIET/ABSENT surface rule);
|
|
18
|
+
`capture` fetches vendor pages into a content-addressed text cache (the
|
|
19
|
+
change detector, replay buffer, and evidence chain); `observe --from`
|
|
20
|
+
ingests intensity readings after validating full coverage and the
|
|
21
|
+
verbatim-evidence rule (a loud/quiet reading with no quote is rejected);
|
|
22
|
+
`fronts` computes deterministic front states (open / contested / owned /
|
|
23
|
+
saturated / vacant) and `--diff` reports drift between runs; `report`
|
|
24
|
+
renders the claim × vendor matrix as markdown or a self-contained
|
|
25
|
+
printable HTML field report with an evidence appendix.
|
|
26
|
+
- **Division of labor matches call intelligence:** intensity readings are
|
|
27
|
+
proposals (provenance-marked extractor, always with quoted evidence);
|
|
28
|
+
everything downstream is deterministic over the stored observations —
|
|
29
|
+
same observations, same map. A failed capture reads as UNOBSERVABLE,
|
|
30
|
+
never as absence.
|
|
31
|
+
- **Profile-scoped storage:** captures and observations live under
|
|
32
|
+
`~/.fullstackgtm/market/<category>` (or the active profile's home), so one
|
|
33
|
+
client org's category intel never bleeds into another's.
|
|
34
|
+
- `ObservationStore` contract + file implementation (append-only, one JSON
|
|
35
|
+
document per run) — like the plan store, the file layout and the hosted
|
|
36
|
+
backend are two implementations of the same contract.
|
|
37
|
+
- `"web"` joined `GtmEvidenceSourceSystem`: market observations carry
|
|
38
|
+
standard `GtmEvidence` with `metadata.url` + `metadata.captureHash`.
|
|
39
|
+
|
|
40
|
+
## [0.15.1] — 2026-06-11
|
|
41
|
+
|
|
42
|
+
Fixes from the 0.15.0 journey verification (4 agents, 26 checks).
|
|
43
|
+
|
|
44
|
+
### Fixed
|
|
45
|
+
|
|
46
|
+
- **Name-only deal resolution is gated**: `resolve deal --name X` without
|
|
47
|
+
`--account-id` returned safe_to_create even when open deals named X
|
|
48
|
+
existed — a gate that ignores name collisions protects nobody. It now
|
|
49
|
+
returns ambiguous with the colliding open deals and tells the caller to
|
|
50
|
+
supply `--account-id` for a definitive answer.
|
|
51
|
+
- The closed-deals-share-name note is account-scoped (it previously counted
|
|
52
|
+
closed deals on *other* accounts).
|
|
53
|
+
- The `hs_object_source_detail_2` stamp on CLI-created companies now
|
|
54
|
+
carries the operation id for precise attribution.
|
|
55
|
+
|
|
56
|
+
## [0.15.0] — 2026-06-11
|
|
57
|
+
|
|
58
|
+
The Prevent layer: stop creating duplicates, and name the writer that does.
|
|
59
|
+
|
|
60
|
+
### Added
|
|
61
|
+
|
|
62
|
+
- **`fullstackgtm resolve <account|contact|deal>`** — the create gate.
|
|
63
|
+
Deterministic verdicts (exists / ambiguous / safe_to_create) with matches
|
|
64
|
+
and reasons, using the same identity keys as the audit/merge engines:
|
|
65
|
+
normalized account domain, contact email, open-deal key. Names alone are
|
|
66
|
+
never identity (ambiguous, with candidates). Gate-shaped exit codes for
|
|
67
|
+
scripts: 0 = safe to create, 2 = match found, 1 = error. Exposed as
|
|
68
|
+
`resolveRecord()` and MCP `fullstackgtm_resolve`.
|
|
69
|
+
- **Record provenance** (`RecordProvenance` on accounts/contacts/deals):
|
|
70
|
+
HubSpot snapshots capture the read-only `hs_object_source`,
|
|
71
|
+
`hs_object_source_label`, `hs_object_source_id` fields, and the three
|
|
72
|
+
duplicate rules append writer attribution to findings — "Created by:
|
|
73
|
+
Gojiberry (app-123) ×2, CRM_UI" — so recurring dupes are fixed at the
|
|
74
|
+
faucet. Provenance is exempt from merge conflicts and diff drift, and
|
|
75
|
+
records created by the CLI's own `create:` path stamp
|
|
76
|
+
`hs_object_source_detail_2` (best-effort).
|
|
77
|
+
|
|
8
78
|
## [0.14.1] — 2026-06-11
|
|
9
79
|
|
|
10
80
|
Fixes from the 0.14.0 journey verification (4 agents, 21 checks).
|
package/README.md
CHANGED
|
@@ -74,6 +74,20 @@ for t in transcripts/*; do fullstackgtm call parse --transcript "$t" --ndjson --
|
|
|
74
74
|
|
|
75
75
|
The boundary that remains: Slack/Notion/warehouse sinks are *your* pipeline, composed around the JSON — and your rubrics and keys stay yours.
|
|
76
76
|
|
|
77
|
+
## The create gate: no new dupes
|
|
78
|
+
|
|
79
|
+
Detection cleans up yesterday's duplicates; the **resolve gate** prevents tomorrow's. Before any writer — a sync job, a webhook handler, an agent, your own script — creates a record, ask the gate:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
fullstackgtm resolve contact --email jane@acme.com --input snap.json # exit 0 = safe to create
|
|
83
|
+
fullstackgtm resolve account --domain acme.com --provider hubspot # exit 2 = exists/ambiguous: do NOT create
|
|
84
|
+
fullstackgtm resolve deal --name "Acme Expansion" --account-id 123 --input snap.json
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Identity keys match the audit/merge engines exactly: account domain (normalized), contact email, and the open-deal key (account + normalized name). Names alone are never identity — they return `ambiguous` with the candidates, not a guess. Exit codes are gate-shaped for scripts: `0` safe to create, `2` match found, `1` error. For high-volume writers, pair it with a cron-refreshed snapshot file rather than a live `--provider` fetch per call. Also exposed as `resolveRecord()` and the MCP tool `fullstackgtm_resolve`.
|
|
88
|
+
|
|
89
|
+
**Provenance attribution** closes the loop on recurring dupes: snapshots now capture each record's source (HubSpot's read-only `hs_object_source*` fields), and duplicate findings name the writer — `"3 accounts share acme.com … Created by: Gojiberry (app-123) ×2, CRM_UI"` — so you fix the integration, not just the records. Records created by this CLI stamp their own provenance (`hs_object_source_detail_2`, best-effort).
|
|
90
|
+
|
|
77
91
|
## From findings to fixes: the suggest chain
|
|
78
92
|
|
|
79
93
|
Most placeholder answers are already derivable from your own CRM data. `suggest` computes them deterministically — account-name matching cross-checked against contact associations — with a confidence level and a written reason per operation, so you (or an agent) approve evidence, not guesses:
|
package/dist/cli.js
CHANGED
|
@@ -18,7 +18,10 @@ import { auditReportToHtml, auditReportToMarkdown } from "./report.js";
|
|
|
18
18
|
import { builtinAuditRules } from "./rules.js";
|
|
19
19
|
import { sampleSnapshot } from "./sampleData.js";
|
|
20
20
|
import { normalizeTranscript, parseCall, suggestCallDeal } from "./calls.js";
|
|
21
|
+
import { captureMarket, computeFrontStates, createFileObservationStore, diffFrontStates, loadMarketConfig, starterMarketConfig, validateObservationSet, } from "./market.js";
|
|
22
|
+
import { marketMapToHtml, marketMapToMarkdown } from "./marketReport.js";
|
|
21
23
|
import { DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, } from "./llm.js";
|
|
24
|
+
import { resolveRecord } from "./resolve.js";
|
|
22
25
|
import { suggestValues } from "./suggest.js";
|
|
23
26
|
function usage() {
|
|
24
27
|
return `FullStackGTM — audit GTM data across providers, propose reviewable patch plans,
|
|
@@ -51,6 +54,18 @@ Usage:
|
|
|
51
54
|
ANTHROPIC_API_KEY/OPENAI_API_KEY, or \`login anthropic|openai\`);
|
|
52
55
|
--deterministic uses the free keyword baseline. Then link the call
|
|
53
56
|
to its deal and propose governed next-step writes.
|
|
57
|
+
fullstackgtm resolve <account|contact|deal> [--name N] [--domain D] [--email E] [--account-id A] [source options] [--json]
|
|
58
|
+
the create gate: exit 0 = safe to create, exit 2 = match
|
|
59
|
+
found (exists/ambiguous) — call before ANY record creation
|
|
60
|
+
fullstackgtm market init --category <name> start a market map: vendors + claim taxonomy as reviewable config
|
|
61
|
+
fullstackgtm market capture [--config <path>] [--run <label>]
|
|
62
|
+
fullstackgtm market observe --from <observations.json>
|
|
63
|
+
fullstackgtm market fronts [--run <label>] [--diff <prior-run>] [--json]
|
|
64
|
+
fullstackgtm market report [--run <label>] [--format md|html] [--out <path>]
|
|
65
|
+
the live competitive map: capture vendor pages (content-addressed),
|
|
66
|
+
ingest intensity readings with verbatim-quote evidence, compute
|
|
67
|
+
deterministic front states (open/contested/owned/saturated) and
|
|
68
|
+
drift between runs, render the client-ready field report
|
|
54
69
|
fullstackgtm suggest --plan-id <id> | --plan <path> [source options] [--json] [--out <path>]
|
|
55
70
|
derive values for requires_human_* placeholders
|
|
56
71
|
from snapshot evidence, with confidence + reasons
|
|
@@ -711,6 +726,152 @@ function buildCallPlan(parsed, deal, proposed, current, extraNextSteps) {
|
|
|
711
726
|
operations,
|
|
712
727
|
};
|
|
713
728
|
}
|
|
729
|
+
/**
|
|
730
|
+
* The market map: claim taxonomy in a reviewable config file, page captures
|
|
731
|
+
* and append-only observations under the profile home, deterministic front
|
|
732
|
+
* states and reports computed from the store. Classification (LLM intensity
|
|
733
|
+
* readings) lands in a later change; until then `market observe --from`
|
|
734
|
+
* ingests proposal files produced by an agent or a human.
|
|
735
|
+
*/
|
|
736
|
+
async function marketCommand(args) {
|
|
737
|
+
const [subcommand, ...rest] = args;
|
|
738
|
+
const configPath = () => resolve(process.cwd(), option(rest, "--config") ?? "market.config.json");
|
|
739
|
+
if (!subcommand || subcommand === "--help") {
|
|
740
|
+
console.log(`Usage:
|
|
741
|
+
market init --category <name> [--out <path>] write a starter market.config.json
|
|
742
|
+
market capture [--config <path>] [--run <label>]
|
|
743
|
+
market observe --from <observations.json> [--config <path>]
|
|
744
|
+
market fronts [--config <path>] [--run <label>] [--diff <prior-run>] [--json]
|
|
745
|
+
market report [--config <path>] [--run <label>] [--format md|html] [--out <path>]
|
|
746
|
+
|
|
747
|
+
The taxonomy (vendors + claims) is config you review and version; captures
|
|
748
|
+
and observations live under ~/.fullstackgtm/market/<category> (profile-scoped,
|
|
749
|
+
one client's category intel never bleeds into another's). Front states are
|
|
750
|
+
recomputed deterministically on every invocation — never stored.`);
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
if (subcommand === "init") {
|
|
754
|
+
const category = option(rest, "--category");
|
|
755
|
+
if (!category)
|
|
756
|
+
throw new Error("market init requires --category <name>");
|
|
757
|
+
const outPath = resolve(process.cwd(), option(rest, "--out") ?? "market.config.json");
|
|
758
|
+
if (existsSync(outPath))
|
|
759
|
+
throw new Error(`${outPath} already exists — refusing to overwrite`);
|
|
760
|
+
writeFileSync(outPath, `${JSON.stringify(starterMarketConfig(category), null, 2)}\n`);
|
|
761
|
+
console.log(`Wrote ${outPath}. Fill in vendors and claims, then: fullstackgtm market capture`);
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
const config = loadMarketConfig(configPath());
|
|
765
|
+
const store = createFileObservationStore(config.category);
|
|
766
|
+
if (subcommand === "capture") {
|
|
767
|
+
const result = await captureMarket(config, { runLabel: option(rest, "--run") ?? "run-1" });
|
|
768
|
+
for (const entry of result.entries) {
|
|
769
|
+
const flag = entry.captureHash && entry.textChars > 500 ? "" : " <-- thin/empty";
|
|
770
|
+
console.log(`${entry.vendorId.padEnd(16)} ${entry.kind.padEnd(8)} ${String(entry.httpStatus ?? "ERR").padEnd(4)} ${String(entry.textChars).padStart(7)} chars ${entry.url}${flag}`);
|
|
771
|
+
}
|
|
772
|
+
console.log(`\nmanifest: ${result.manifestPath}`);
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
if (subcommand === "observe") {
|
|
776
|
+
const fromPath = option(rest, "--from");
|
|
777
|
+
if (!fromPath)
|
|
778
|
+
throw new Error("market observe requires --from <observations.json>");
|
|
779
|
+
const set = JSON.parse(readFileSync(resolve(process.cwd(), fromPath), "utf8"));
|
|
780
|
+
const problems = validateObservationSet(config, set);
|
|
781
|
+
if (problems.length > 0) {
|
|
782
|
+
console.error(`Rejected: ${problems.length} problem(s)`);
|
|
783
|
+
for (const problem of problems.slice(0, 20))
|
|
784
|
+
console.error(` - ${problem}`);
|
|
785
|
+
process.exitCode = 1;
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
await store.append(set);
|
|
789
|
+
console.log(`Appended ${set.runLabel}: ${set.observations.length} observations (${set.extractor})`);
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
const loadSet = async () => {
|
|
793
|
+
const runLabel = option(rest, "--run");
|
|
794
|
+
const set = runLabel ? await store.get(runLabel) : await store.latest();
|
|
795
|
+
if (!set) {
|
|
796
|
+
throw new Error(runLabel
|
|
797
|
+
? `No observation run "${runLabel}" for ${config.category}`
|
|
798
|
+
: `No observations stored for ${config.category} — run market observe --from <file> first`);
|
|
799
|
+
}
|
|
800
|
+
return set;
|
|
801
|
+
};
|
|
802
|
+
if (subcommand === "fronts") {
|
|
803
|
+
const set = await loadSet();
|
|
804
|
+
const fronts = computeFrontStates(config, set);
|
|
805
|
+
const priorLabel = option(rest, "--diff");
|
|
806
|
+
const prior = priorLabel ? await store.get(priorLabel) : null;
|
|
807
|
+
if (priorLabel && !prior)
|
|
808
|
+
throw new Error(`No observation run "${priorLabel}" to diff against`);
|
|
809
|
+
const drift = prior ? diffFrontStates(computeFrontStates(config, prior), fronts) : null;
|
|
810
|
+
if (rest.includes("--json")) {
|
|
811
|
+
console.log(JSON.stringify({ runLabel: set.runLabel, fronts, drift }, null, 2));
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
for (const front of fronts) {
|
|
815
|
+
const owner = front.state === "owned" ? ` → ${front.loudVendorIds[0]}` : "";
|
|
816
|
+
console.log(`${front.state.toUpperCase().padEnd(10)} ${front.claimId}${owner}`);
|
|
817
|
+
}
|
|
818
|
+
if (drift) {
|
|
819
|
+
console.log("");
|
|
820
|
+
if (drift.length === 0)
|
|
821
|
+
console.log(`No front changes since ${priorLabel}.`);
|
|
822
|
+
for (const change of drift)
|
|
823
|
+
console.log(`CHANGED ${change.claimId}: ${change.before} → ${change.after}`);
|
|
824
|
+
}
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
if (subcommand === "report") {
|
|
828
|
+
const set = await loadSet();
|
|
829
|
+
const format = option(rest, "--format") ?? "md";
|
|
830
|
+
const output = format === "html" ? marketMapToHtml(config, set) : marketMapToMarkdown(config, set);
|
|
831
|
+
const outPath = option(rest, "--out");
|
|
832
|
+
if (outPath) {
|
|
833
|
+
writeFileSync(resolve(process.cwd(), outPath), output);
|
|
834
|
+
console.log(`Wrote ${outPath}`);
|
|
835
|
+
}
|
|
836
|
+
else {
|
|
837
|
+
console.log(output);
|
|
838
|
+
}
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
throw new Error(`Unknown market subcommand: ${subcommand} (try: init, capture, observe, fronts, report)`);
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* The resolve gate: exit 0 = safe to create, exit 2 = match found (exists or
|
|
845
|
+
* ambiguous — do NOT blind-create), exit 1 = error. Built for sync jobs and
|
|
846
|
+
* webhook handlers to call before any record creation.
|
|
847
|
+
*/
|
|
848
|
+
async function resolveCommand(args) {
|
|
849
|
+
const [objectType, ...rest] = args;
|
|
850
|
+
if (!objectType || !["account", "contact", "deal"].includes(objectType)) {
|
|
851
|
+
throw new Error("Usage: fullstackgtm resolve <account|contact|deal> [--name N] [--domain D] [--email E] [--account-id A] [source options] [--json]");
|
|
852
|
+
}
|
|
853
|
+
const candidate = {
|
|
854
|
+
objectType: objectType,
|
|
855
|
+
name: option(rest, "--name") ?? undefined,
|
|
856
|
+
domain: option(rest, "--domain") ?? undefined,
|
|
857
|
+
email: option(rest, "--email") ?? undefined,
|
|
858
|
+
accountId: option(rest, "--account-id") ?? undefined,
|
|
859
|
+
};
|
|
860
|
+
const snapshot = await readSnapshot(rest);
|
|
861
|
+
const result = resolveRecord(snapshot, candidate);
|
|
862
|
+
if (rest.includes("--json")) {
|
|
863
|
+
console.log(JSON.stringify(result, null, 2));
|
|
864
|
+
}
|
|
865
|
+
else {
|
|
866
|
+
const marker = result.verdict === "safe_to_create" ? "✓" : result.verdict === "exists" ? "=" : "?";
|
|
867
|
+
console.log(`${marker} [${result.verdict}] ${result.reason}`);
|
|
868
|
+
for (const m of result.matches) {
|
|
869
|
+
console.log(` ${m.id} "${m.name}" — matched by ${m.matchedBy}: ${m.detail}`);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
if (result.verdict !== "safe_to_create")
|
|
873
|
+
process.exitCode = 2;
|
|
874
|
+
}
|
|
714
875
|
async function suggest(args) {
|
|
715
876
|
const planId = option(args, "--plan-id");
|
|
716
877
|
const planPath = option(args, "--plan");
|
|
@@ -1456,6 +1617,14 @@ export async function runCli(argv) {
|
|
|
1456
1617
|
await callCommand(args);
|
|
1457
1618
|
return;
|
|
1458
1619
|
}
|
|
1620
|
+
if (command === "resolve") {
|
|
1621
|
+
await resolveCommand(args);
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
if (command === "market") {
|
|
1625
|
+
await marketCommand(args);
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1459
1628
|
if (command === "profiles") {
|
|
1460
1629
|
profilesCommand(args);
|
|
1461
1630
|
return;
|
|
@@ -84,7 +84,10 @@ export function createHubspotConnector(options) {
|
|
|
84
84
|
email: stringOrUndefined(owner.email),
|
|
85
85
|
active: owner.archived !== true,
|
|
86
86
|
}));
|
|
87
|
-
|
|
87
|
+
// Read-only record-source fields power duplicate-finding attribution
|
|
88
|
+
// ("all five created by integration X") — see RecordProvenance.
|
|
89
|
+
const PROVENANCE_PROPERTIES = "hs_object_source,hs_object_source_label,hs_object_source_id";
|
|
90
|
+
const companyProperties = `${mappedFields(mappings, "accounts", HUBSPOT_DEFAULT_FIELD_MAPPINGS.accounts).join(",")},${PROVENANCE_PROPERTIES}`;
|
|
88
91
|
const companies = await fetchObjects("companies", companyProperties, false);
|
|
89
92
|
const accounts = companies
|
|
90
93
|
.filter((company) => company.id)
|
|
@@ -101,11 +104,12 @@ export function createHubspotConnector(options) {
|
|
|
101
104
|
employeeCount: numberOrUndefined(readMapped(props, "accounts", "employeeCount", "numberofemployees")),
|
|
102
105
|
annualRevenue: numberOrUndefined(readMapped(props, "accounts", "annualRevenue", "annualrevenue")),
|
|
103
106
|
ownerId: stringOrUndefined(readMapped(props, "accounts", "ownerId", "hubspot_owner_id")),
|
|
107
|
+
provenance: provenanceFrom(props),
|
|
104
108
|
lastSyncAt: stringOrUndefined(company.updatedAt),
|
|
105
109
|
raw: company,
|
|
106
110
|
};
|
|
107
111
|
});
|
|
108
|
-
const contactProperties = mappedFields(mappings, "contacts", HUBSPOT_DEFAULT_FIELD_MAPPINGS.contacts).join(",")
|
|
112
|
+
const contactProperties = `${mappedFields(mappings, "contacts", HUBSPOT_DEFAULT_FIELD_MAPPINGS.contacts).join(",")},${PROVENANCE_PROPERTIES}`;
|
|
109
113
|
const hubspotContacts = await fetchObjects("contacts", contactProperties, true);
|
|
110
114
|
const contacts = hubspotContacts
|
|
111
115
|
.filter((contact) => contact.id)
|
|
@@ -124,11 +128,12 @@ export function createHubspotConnector(options) {
|
|
|
124
128
|
phone: stringOrUndefined(readMapped(props, "contacts", "phone", "phone")),
|
|
125
129
|
title: stringOrUndefined(readMapped(props, "contacts", "title", "jobtitle")),
|
|
126
130
|
ownerId: stringOrUndefined(readMapped(props, "contacts", "ownerId", "hubspot_owner_id")),
|
|
131
|
+
provenance: provenanceFrom(props),
|
|
127
132
|
lastSyncAt: stringOrUndefined(contact.updatedAt),
|
|
128
133
|
raw: contact,
|
|
129
134
|
};
|
|
130
135
|
});
|
|
131
|
-
const dealProperties = mappedFields(mappings, "deals", HUBSPOT_DEFAULT_FIELD_MAPPINGS.deals).join(",")
|
|
136
|
+
const dealProperties = `${mappedFields(mappings, "deals", HUBSPOT_DEFAULT_FIELD_MAPPINGS.deals).join(",")},${PROVENANCE_PROPERTIES}`;
|
|
132
137
|
const hubspotDeals = await fetchObjects("deals", dealProperties, true);
|
|
133
138
|
const deals = hubspotDeals
|
|
134
139
|
.filter((deal) => deal.id)
|
|
@@ -152,6 +157,7 @@ export function createHubspotConnector(options) {
|
|
|
152
157
|
identities: [{ provider: "hubspot", externalId: String(deal.id) }],
|
|
153
158
|
accountId: companyId ? String(companyId) : undefined,
|
|
154
159
|
ownerId: stringOrUndefined(readMapped(props, "deals", "ownerId", "hubspot_owner_id")),
|
|
160
|
+
provenance: provenanceFrom(props),
|
|
155
161
|
name: stringOrFallback(readMapped(props, "deals", "name", "dealname"), "Untitled Deal"),
|
|
156
162
|
amount: numberOrUndefined(readMapped(props, "deals", "amount", "amount")),
|
|
157
163
|
stage,
|
|
@@ -323,10 +329,23 @@ export function createHubspotConnector(options) {
|
|
|
323
329
|
createdCompaniesByName.set(nameKey, companyId);
|
|
324
330
|
}
|
|
325
331
|
else {
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
332
|
+
let created;
|
|
333
|
+
try {
|
|
334
|
+
created = await request(`/crm/v3/objects/companies`, {
|
|
335
|
+
method: "POST",
|
|
336
|
+
body: JSON.stringify({
|
|
337
|
+
properties: { name, hs_object_source_detail_2: `fullstackgtm create: (${operation.id})` },
|
|
338
|
+
}),
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
// Some portals reject writes to source-detail properties — the
|
|
343
|
+
// provenance stamp is best-effort, the create is not.
|
|
344
|
+
created = await request(`/crm/v3/objects/companies`, {
|
|
345
|
+
method: "POST",
|
|
346
|
+
body: JSON.stringify({ properties: { name } }),
|
|
347
|
+
});
|
|
348
|
+
}
|
|
330
349
|
companyId = String(created.id);
|
|
331
350
|
createdCompanyName = name;
|
|
332
351
|
createdCompaniesByName.set(nameKey, companyId);
|
|
@@ -394,6 +413,34 @@ export function createHubspotConnector(options) {
|
|
|
394
413
|
catch {
|
|
395
414
|
// fall through to create
|
|
396
415
|
}
|
|
416
|
+
// A live CRM often already carries a human-created follow-up for the same
|
|
417
|
+
// record (a previous partial run, or a rep's own task). Creating another
|
|
418
|
+
// on top is duplicate noise — skip when the object already has an open
|
|
419
|
+
// task, regardless of who created it. Fail-open: a lookup hiccup must
|
|
420
|
+
// not block the apply.
|
|
421
|
+
try {
|
|
422
|
+
const objectPath = OBJECT_PATHS[operation.objectType];
|
|
423
|
+
const assoc = await request(`/crm/v4/objects/${objectPath}/${encodeURIComponent(operation.objectId)}/associations/tasks?limit=20`);
|
|
424
|
+
const taskIds = (assoc?.results ?? [])
|
|
425
|
+
.map((row) => String(row.toObjectId ?? ""))
|
|
426
|
+
.filter(Boolean)
|
|
427
|
+
.slice(0, 10);
|
|
428
|
+
for (const taskId of taskIds) {
|
|
429
|
+
const existingTask = await request(`/crm/v3/objects/tasks/${encodeURIComponent(taskId)}?properties=hs_task_status`);
|
|
430
|
+
const status = String(existingTask?.properties?.hs_task_status ?? "");
|
|
431
|
+
if (status !== "COMPLETED" && status !== "DELETED") {
|
|
432
|
+
return {
|
|
433
|
+
operationId: operation.id,
|
|
434
|
+
status: "skipped",
|
|
435
|
+
detail: `An open task (task ${taskId}) already exists on ${operation.objectType}/${operation.objectId}; not creating a duplicate follow-up.`,
|
|
436
|
+
providerData: { id: taskId, existing: true },
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
catch {
|
|
442
|
+
// fall through to create
|
|
443
|
+
}
|
|
397
444
|
const body = `${String(operation.afterValue ?? operation.reason ?? "")}\n\n[${token}]`;
|
|
398
445
|
const response = await request(`/crm/v3/objects/tasks`, {
|
|
399
446
|
method: "POST",
|
|
@@ -567,6 +614,14 @@ export function createHubspotConnector(options) {
|
|
|
567
614
|
readField,
|
|
568
615
|
};
|
|
569
616
|
}
|
|
617
|
+
function provenanceFrom(props) {
|
|
618
|
+
const source = stringOrUndefined(props.hs_object_source);
|
|
619
|
+
const sourceLabel = stringOrUndefined(props.hs_object_source_label);
|
|
620
|
+
const sourceId = stringOrUndefined(props.hs_object_source_id);
|
|
621
|
+
if (!source && !sourceLabel && !sourceId)
|
|
622
|
+
return undefined;
|
|
623
|
+
return { source, sourceLabel, sourceId };
|
|
624
|
+
}
|
|
570
625
|
function stringOrUndefined(value) {
|
|
571
626
|
if (value === undefined || value === null || value === "")
|
|
572
627
|
return undefined;
|
package/dist/diff.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* appeared, what disappeared, what changed — and whether hygiene regressed.
|
|
5
5
|
*/
|
|
6
6
|
// Fields that change on every sync without semantic meaning.
|
|
7
|
-
const IGNORED_FIELDS = new Set(["raw", "lastSyncAt", "identities"]);
|
|
7
|
+
const IGNORED_FIELDS = new Set(["raw", "lastSyncAt", "identities", "provenance"]);
|
|
8
8
|
function labelOf(record) {
|
|
9
9
|
return record.name ?? record.email ?? record.id;
|
|
10
10
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -14,9 +14,12 @@ export { createFilePlanStore, type PlanStore, type StoredPlan } from "./planStor
|
|
|
14
14
|
export { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
|
|
15
15
|
export { auditReportToHtml, auditReportToMarkdown, type ReportOptions } from "./report.ts";
|
|
16
16
|
export { HUBSPOT_DEFAULT_FIELD_MAPPINGS, SALESFORCE_DEFAULT_FIELD_MAPPINGS, mappedField, mappedFields, normalizeFieldMappings, readMappedValue, type CrmObjectType, type FieldMappings, } from "./mappings.ts";
|
|
17
|
-
export { accountSingleSourceRule, activeDealAccountWithoutContactsRule, auditFindingId, buildSnapshotIndex, builtinAuditRules, closingSoonInactiveRule, duplicateAccountDomainRule, duplicateContactEmailRule, duplicateOpenDealRule, missingDealAccountRule, missingDealAmountRule, missingDealOwnerRule, orphanAccountRule, pastCloseDateRule, patchOperationId, requiresHumanInput, staleDealRule, } from "./rules.ts";
|
|
17
|
+
export { accountSingleSourceRule, activeDealAccountWithoutContactsRule, auditFindingId, buildSnapshotIndex, builtinAuditRules, closingSoonInactiveRule, duplicateAccountDomainRule, duplicateContactEmailRule, duplicateOpenDealRule, missingDealAccountRule, missingDealAmountRule, missingDealOwnerRule, orphanAccountRule, pastCloseDateRule, patchOperationId, provenanceSummary, requiresHumanInput, staleDealRule, } from "./rules.ts";
|
|
18
18
|
export { extractCallInsights, normalizeTranscript, parseCall, parseTranscript, suggestCallDeal, summarizeInsights, type CallDealSuggestion, type CallInsightType, type ExtractedCallInsight, type ParsedCall, type ParsedTranscriptSegment, } from "./calls.ts";
|
|
19
19
|
export { sampleSnapshot } from "./sampleData.ts";
|
|
20
20
|
export { DEFAULT_MODELS, DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, type CallScorecard, type LlmCredential, type LlmExtractedInsight, type LlmProvider, type Rubric, type ScoredDimension, } from "./llm.ts";
|
|
21
|
+
export { resolveRecord, type ResolveCandidate, type ResolveMatch, type ResolveResult } from "./resolve.ts";
|
|
22
|
+
export { captureMarket, computeFrontStates, createFileObservationStore, diffFrontStates, extractReadableText, loadMarketConfig, marketHome, observationId, parseMarketConfig, starterMarketConfig, validateObservationSet, type CaptureEntry, type CaptureOptions, type ClaimFront, type ClaimIntensity, type FrontDrift, type FrontState, type MarketClaim, type MarketConfig, type MarketObservation, type MarketVendor, type ObservationConfidence, type ObservationSet, type ObservationStore, } from "./market.ts";
|
|
23
|
+
export { marketMapToHtml, marketMapToMarkdown } from "./marketReport.ts";
|
|
21
24
|
export { suggestValues, type SuggestionConfidence, type ValueSuggestion } from "./suggest.ts";
|
|
22
25
|
export type { ApprovalStatus, AuditFinding, AuditFindingSeverity, CanonicalAccount, CanonicalActivity, CanonicalContact, CanonicalDeal, CanonicalGtmSnapshot, CanonicalUser, CrmProvider, GtmAuditRule, GtmConnector, GtmEvidence, GtmEvidenceSourceSystem, GtmObjectType, GtmPolicy, GtmRuleContext, GtmRuleResult, GtmSnapshotIndex, PatchOperation, PatchOperationResult, PatchOperationType, PatchPlan, PatchPlanRun, PatchPlanRunStatus, PatchVerification, PipelineFinding, PipelineFindingStatus, PipelineFindingType, ProviderIdentity, RiskLevel, SourceFreshness, } from "./types.ts";
|
package/dist/index.js
CHANGED
|
@@ -14,8 +14,11 @@ export { createFilePlanStore } from "./planStore.js";
|
|
|
14
14
|
export { formatPatchPlanRun, patchPlanToMarkdown } from "./format.js";
|
|
15
15
|
export { auditReportToHtml, auditReportToMarkdown } from "./report.js";
|
|
16
16
|
export { HUBSPOT_DEFAULT_FIELD_MAPPINGS, SALESFORCE_DEFAULT_FIELD_MAPPINGS, mappedField, mappedFields, normalizeFieldMappings, readMappedValue, } from "./mappings.js";
|
|
17
|
-
export { accountSingleSourceRule, activeDealAccountWithoutContactsRule, auditFindingId, buildSnapshotIndex, builtinAuditRules, closingSoonInactiveRule, duplicateAccountDomainRule, duplicateContactEmailRule, duplicateOpenDealRule, missingDealAccountRule, missingDealAmountRule, missingDealOwnerRule, orphanAccountRule, pastCloseDateRule, patchOperationId, requiresHumanInput, staleDealRule, } from "./rules.js";
|
|
17
|
+
export { accountSingleSourceRule, activeDealAccountWithoutContactsRule, auditFindingId, buildSnapshotIndex, builtinAuditRules, closingSoonInactiveRule, duplicateAccountDomainRule, duplicateContactEmailRule, duplicateOpenDealRule, missingDealAccountRule, missingDealAmountRule, missingDealOwnerRule, orphanAccountRule, pastCloseDateRule, patchOperationId, provenanceSummary, requiresHumanInput, staleDealRule, } from "./rules.js";
|
|
18
18
|
export { extractCallInsights, normalizeTranscript, parseCall, parseTranscript, suggestCallDeal, summarizeInsights, } from "./calls.js";
|
|
19
19
|
export { sampleSnapshot } from "./sampleData.js";
|
|
20
20
|
export { DEFAULT_MODELS, DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, } from "./llm.js";
|
|
21
|
+
export { resolveRecord } from "./resolve.js";
|
|
22
|
+
export { captureMarket, computeFrontStates, createFileObservationStore, diffFrontStates, extractReadableText, loadMarketConfig, marketHome, observationId, parseMarketConfig, starterMarketConfig, validateObservationSet, } from "./market.js";
|
|
23
|
+
export { marketMapToHtml, marketMapToMarkdown } from "./marketReport.js";
|
|
21
24
|
export { suggestValues } from "./suggest.js";
|
package/dist/market.d.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { GtmEvidence } from "./types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* The Market Map: a live model of the competitive category a company sells
|
|
4
|
+
* into. Vendors publish claims constantly (pricing pages, feature pages,
|
|
5
|
+
* hero copy); each (vendor × claim) cell gets a messaging-intensity reading,
|
|
6
|
+
* and each claim row gets a derived front state. Observations are
|
|
7
|
+
* append-only — history is the product; "what changed since last run" is a
|
|
8
|
+
* first-class question.
|
|
9
|
+
*
|
|
10
|
+
* Division of labor mirrors call intelligence: intensity readings are
|
|
11
|
+
* *proposals* (LLM or human, always with verbatim quoted evidence), while
|
|
12
|
+
* everything downstream — front states, drift, the report — is deterministic
|
|
13
|
+
* over the stored observations. Same stored observations, same map.
|
|
14
|
+
*
|
|
15
|
+
* The claim taxonomy and vendor registry live in a reviewable config file
|
|
16
|
+
* (git-friendly, analyst-edited); captures and observations live under the
|
|
17
|
+
* profile home so one client's category intel never bleeds into another's.
|
|
18
|
+
*/
|
|
19
|
+
export type ClaimIntensity = "loud" | "quiet" | "absent" | "unobservable";
|
|
20
|
+
export type ObservationConfidence = "high" | "medium" | "low";
|
|
21
|
+
export type FrontState = "open" | "contested" | "owned" | "saturated" | "vacant";
|
|
22
|
+
export type MarketClaim = {
|
|
23
|
+
id: string;
|
|
24
|
+
/** The capability being claimed, precise enough to judge loud/quiet/absent. */
|
|
25
|
+
capability: string;
|
|
26
|
+
/** Which ICP the claim cell addresses (category-specific vocabulary). */
|
|
27
|
+
icp: string;
|
|
28
|
+
/** Which pricing structure the claim cell implies (category-specific). */
|
|
29
|
+
pricingStructure: string;
|
|
30
|
+
/** Operational definition: how a reader judges LOUD vs QUIET vs ABSENT. */
|
|
31
|
+
definition: string;
|
|
32
|
+
};
|
|
33
|
+
export type MarketVendor = {
|
|
34
|
+
id: string;
|
|
35
|
+
name: string;
|
|
36
|
+
urls: {
|
|
37
|
+
home: string;
|
|
38
|
+
/** null is itself an observation: no public pricing surface. */
|
|
39
|
+
pricing: string | null;
|
|
40
|
+
product: string[];
|
|
41
|
+
};
|
|
42
|
+
notes?: string;
|
|
43
|
+
};
|
|
44
|
+
export type MarketConfig = {
|
|
45
|
+
category: string;
|
|
46
|
+
anchorVendor?: string;
|
|
47
|
+
vendors: MarketVendor[];
|
|
48
|
+
claims: MarketClaim[];
|
|
49
|
+
/** The LOUD/QUIET/ABSENT/UNOBSERVABLE judging rule, stated for reviewers. */
|
|
50
|
+
surfaceRule?: string;
|
|
51
|
+
};
|
|
52
|
+
export type MarketObservation = {
|
|
53
|
+
/** stableHash(category, runLabel, vendorId, claimId) — deterministic. */
|
|
54
|
+
id: string;
|
|
55
|
+
vendorId: string;
|
|
56
|
+
claimId: string;
|
|
57
|
+
observedAt: string;
|
|
58
|
+
intensity: ClaimIntensity;
|
|
59
|
+
confidence: ObservationConfidence;
|
|
60
|
+
/** Reviewer-facing: why the reading is what it is. */
|
|
61
|
+
reason: string;
|
|
62
|
+
/**
|
|
63
|
+
* Verbatim quoted spans grounding any non-absent reading
|
|
64
|
+
* (sourceSystem "web", metadata.url + metadata.captureHash).
|
|
65
|
+
*/
|
|
66
|
+
evidence: GtmEvidence[];
|
|
67
|
+
};
|
|
68
|
+
export type ObservationSet = {
|
|
69
|
+
id: string;
|
|
70
|
+
category: string;
|
|
71
|
+
runLabel: string;
|
|
72
|
+
runAt: string;
|
|
73
|
+
/** What produced the readings: "manual" or "llm:<provider>:<model>". */
|
|
74
|
+
extractor: string;
|
|
75
|
+
observations: MarketObservation[];
|
|
76
|
+
};
|
|
77
|
+
export type CaptureEntry = {
|
|
78
|
+
runLabel: string;
|
|
79
|
+
vendorId: string;
|
|
80
|
+
kind: "home" | "pricing" | "product";
|
|
81
|
+
url: string;
|
|
82
|
+
fetchedAt: string;
|
|
83
|
+
httpStatus: number | null;
|
|
84
|
+
/** sha256 of the extracted text; null when the fetch failed or was empty. */
|
|
85
|
+
captureHash: string | null;
|
|
86
|
+
textChars: number;
|
|
87
|
+
};
|
|
88
|
+
export declare function observationId(category: string, runLabel: string, vendorId: string, claimId: string): string;
|
|
89
|
+
export declare function parseMarketConfig(raw: string): MarketConfig;
|
|
90
|
+
export declare function loadMarketConfig(path: string): MarketConfig;
|
|
91
|
+
export declare function starterMarketConfig(category: string): MarketConfig;
|
|
92
|
+
export declare function marketHome(category: string, baseDir?: string): string;
|
|
93
|
+
export declare function extractReadableText(html: string): string;
|
|
94
|
+
export type FetchPage = (url: string) => Promise<{
|
|
95
|
+
status: number;
|
|
96
|
+
body: string;
|
|
97
|
+
}>;
|
|
98
|
+
export type CaptureOptions = {
|
|
99
|
+
/** Directory for captures; defaults to <marketHome>/captures. */
|
|
100
|
+
dir?: string;
|
|
101
|
+
runLabel?: string;
|
|
102
|
+
/** Injectable for tests; defaults to global fetch. */
|
|
103
|
+
fetchPage?: FetchPage;
|
|
104
|
+
now?: () => Date;
|
|
105
|
+
};
|
|
106
|
+
export type CaptureResult = {
|
|
107
|
+
entries: CaptureEntry[];
|
|
108
|
+
manifestPath: string;
|
|
109
|
+
};
|
|
110
|
+
export declare function captureMarket(config: MarketConfig, options?: CaptureOptions): Promise<CaptureResult>;
|
|
111
|
+
export interface ObservationStore {
|
|
112
|
+
append(set: ObservationSet): Promise<ObservationSet>;
|
|
113
|
+
get(runLabel: string): Promise<ObservationSet | null>;
|
|
114
|
+
list(): Promise<Array<{
|
|
115
|
+
runLabel: string;
|
|
116
|
+
runAt: string;
|
|
117
|
+
observations: number;
|
|
118
|
+
}>>;
|
|
119
|
+
latest(): Promise<ObservationSet | null>;
|
|
120
|
+
}
|
|
121
|
+
export declare function createFileObservationStore(category: string, directory?: string): ObservationStore;
|
|
122
|
+
/**
|
|
123
|
+
* Validate a proposed observation set against the config before it enters
|
|
124
|
+
* the store: known vendors/claims, full coverage, legal readings, and the
|
|
125
|
+
* verbatim-evidence rule (non-absent readings must quote something).
|
|
126
|
+
* Returns problems; an empty array means accept.
|
|
127
|
+
*/
|
|
128
|
+
export declare function validateObservationSet(config: MarketConfig, set: ObservationSet): string[];
|
|
129
|
+
export type ClaimFront = {
|
|
130
|
+
claimId: string;
|
|
131
|
+
state: FrontState;
|
|
132
|
+
loudVendorIds: string[];
|
|
133
|
+
quietVendorIds: string[];
|
|
134
|
+
};
|
|
135
|
+
/**
|
|
136
|
+
* Front rule v1: 0 loud → open (if anyone is quiet) or vacant; 1 loud →
|
|
137
|
+
* owned; 2–3 loud → contested; ≥4 loud → saturated. Unobservable cells are
|
|
138
|
+
* excluded — a failed capture never reads as absence.
|
|
139
|
+
*/
|
|
140
|
+
export declare function computeFrontStates(config: MarketConfig, set: ObservationSet): ClaimFront[];
|
|
141
|
+
export type FrontDrift = {
|
|
142
|
+
claimId: string;
|
|
143
|
+
before: FrontState;
|
|
144
|
+
after: FrontState;
|
|
145
|
+
};
|
|
146
|
+
/** What changed in the category between two runs — the refresh's whole point. */
|
|
147
|
+
export declare function diffFrontStates(before: ClaimFront[], after: ClaimFront[]): FrontDrift[];
|