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/dist/resolve.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { normalizeDomain } from "./merge.js";
|
|
2
|
+
export function resolveRecord(snapshot, candidate) {
|
|
3
|
+
if (candidate.objectType === "account")
|
|
4
|
+
return resolveAccount(snapshot, candidate);
|
|
5
|
+
if (candidate.objectType === "contact")
|
|
6
|
+
return resolveContact(snapshot, candidate);
|
|
7
|
+
return resolveDeal(snapshot, candidate);
|
|
8
|
+
}
|
|
9
|
+
function resolveAccount(snapshot, c) {
|
|
10
|
+
const base = { objectType: "account" };
|
|
11
|
+
const domain = normalizeDomain(c.domain);
|
|
12
|
+
if (domain) {
|
|
13
|
+
const matches = snapshot.accounts
|
|
14
|
+
.filter((a) => normalizeDomain(a.domain) === domain)
|
|
15
|
+
.map((a) => match(a.id, a.name, "domain", `account domain ${a.domain} normalizes to ${domain}`));
|
|
16
|
+
if (matches.length === 1) {
|
|
17
|
+
return { ...base, verdict: "exists", matches, reason: `An account with domain ${domain} already exists: "${matches[0].name}" (${matches[0].id}). Link to it instead of creating.` };
|
|
18
|
+
}
|
|
19
|
+
if (matches.length > 1) {
|
|
20
|
+
return { ...base, verdict: "ambiguous", matches, reason: `${matches.length} accounts already share domain ${domain} — that's a duplicate group; merge it before adding more. Do not create.` };
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (c.name) {
|
|
24
|
+
const key = normalizeName(c.name);
|
|
25
|
+
const matches = snapshot.accounts
|
|
26
|
+
.filter((a) => normalizeName(a.name) === key)
|
|
27
|
+
.map((a) => match(a.id, a.name, "name", `account name matches "${c.name}" (domain: ${a.domain ?? "none"})`));
|
|
28
|
+
if (matches.length > 0) {
|
|
29
|
+
// Name alone is suggestive, not identity — two real companies can share
|
|
30
|
+
// a name (the merge engine treats this the same way).
|
|
31
|
+
return {
|
|
32
|
+
...base,
|
|
33
|
+
verdict: "ambiguous",
|
|
34
|
+
matches,
|
|
35
|
+
reason: `${matches.length} account(s) named "${c.name}" exist but ${domain ? `none share domain ${domain}` : "no domain was supplied to confirm identity"}. Confirm before creating — supply a domain to disambiguate.`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (!domain && !c.name) {
|
|
40
|
+
return { ...base, verdict: "ambiguous", matches: [], reason: "Supply --domain and/or --name to resolve an account." };
|
|
41
|
+
}
|
|
42
|
+
return { ...base, verdict: "safe_to_create", matches: [], reason: `No account matches ${domain ? `domain ${domain}` : `name "${c.name}"`}. Safe to create.` };
|
|
43
|
+
}
|
|
44
|
+
function resolveContact(snapshot, c) {
|
|
45
|
+
const base = { objectType: "contact" };
|
|
46
|
+
const email = c.email?.trim().toLowerCase();
|
|
47
|
+
if (email) {
|
|
48
|
+
const matches = snapshot.contacts
|
|
49
|
+
.filter((row) => row.email?.trim().toLowerCase() === email)
|
|
50
|
+
.map((row) => match(row.id, contactName(row), "email", `contact email matches ${email}`));
|
|
51
|
+
if (matches.length === 1) {
|
|
52
|
+
return { ...base, verdict: "exists", matches, reason: `A contact with email ${email} already exists: "${matches[0].name}" (${matches[0].id}). Update it instead of creating.` };
|
|
53
|
+
}
|
|
54
|
+
if (matches.length > 1) {
|
|
55
|
+
return { ...base, verdict: "ambiguous", matches, reason: `${matches.length} contacts already share ${email} — a duplicate group; merge before adding more. Do not create.` };
|
|
56
|
+
}
|
|
57
|
+
return { ...base, verdict: "safe_to_create", matches: [], reason: `No contact matches ${email}. Safe to create.` };
|
|
58
|
+
}
|
|
59
|
+
if (c.name) {
|
|
60
|
+
const key = normalizeName(c.name);
|
|
61
|
+
const matches = snapshot.contacts
|
|
62
|
+
.filter((row) => normalizeName(contactName(row)) === key)
|
|
63
|
+
.map((row) => match(row.id, contactName(row), "name", `contact name matches "${c.name}" (email: ${row.email ?? "none"})`));
|
|
64
|
+
if (matches.length > 0) {
|
|
65
|
+
return { ...base, verdict: "ambiguous", matches, reason: `${matches.length} contact(s) named "${c.name}" exist; names are not identity. Supply --email to resolve definitively.` };
|
|
66
|
+
}
|
|
67
|
+
return { ...base, verdict: "safe_to_create", matches: [], reason: `No contact named "${c.name}". Safe to create — but prefer resolving by email.` };
|
|
68
|
+
}
|
|
69
|
+
return { ...base, verdict: "ambiguous", matches: [], reason: "Supply --email (preferred) or --name to resolve a contact." };
|
|
70
|
+
}
|
|
71
|
+
function resolveDeal(snapshot, c) {
|
|
72
|
+
const base = { objectType: "deal" };
|
|
73
|
+
if (!c.name) {
|
|
74
|
+
return { ...base, verdict: "ambiguous", matches: [], reason: "Supply --name (and ideally --account-id) to resolve a deal." };
|
|
75
|
+
}
|
|
76
|
+
const nameKey = normalizeName(c.name);
|
|
77
|
+
const open = snapshot.deals.filter((d) => d.isClosed !== true && d.isWon !== true);
|
|
78
|
+
if (c.accountId) {
|
|
79
|
+
const key = `${c.accountId}:${nameKey}`;
|
|
80
|
+
const matches = open
|
|
81
|
+
.filter((d) => `${d.accountId ?? "unlinked"}:${normalizeName(d.name)}` === key)
|
|
82
|
+
.map((d) => match(d.id, d.name, "deal_key", `open deal with the same name on account ${c.accountId}`));
|
|
83
|
+
if (matches.length > 0) {
|
|
84
|
+
return {
|
|
85
|
+
...base,
|
|
86
|
+
verdict: "exists",
|
|
87
|
+
matches,
|
|
88
|
+
reason: `${matches.length} open deal(s) already match "${c.name}" on account ${c.accountId} — creating another would double-count pipeline. Update the existing deal.`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
const closedSameName = snapshot.deals.filter((d) => (d.isClosed === true || d.isWon === true) &&
|
|
92
|
+
d.accountId === c.accountId &&
|
|
93
|
+
normalizeName(d.name) === nameKey);
|
|
94
|
+
return {
|
|
95
|
+
...base,
|
|
96
|
+
verdict: "safe_to_create",
|
|
97
|
+
matches: [],
|
|
98
|
+
reason: closedSameName.length > 0
|
|
99
|
+
? `No open deal matches on account ${c.accountId}; ${closedSameName.length} closed deal(s) on it share the name (a re-open/renewal may be intended). Safe to create.`
|
|
100
|
+
: `No open deal matches "${c.name}" on account ${c.accountId}. Safe to create.`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
// No account scope: name-only matches across ALL open deals are ambiguous,
|
|
104
|
+
// never safe — a gate that ignores name collisions protects nobody.
|
|
105
|
+
const sameName = open
|
|
106
|
+
.filter((d) => normalizeName(d.name) === nameKey)
|
|
107
|
+
.map((d) => match(d.id, d.name, "name", `open deal with the same name on ${d.accountId ? `account ${d.accountId}` : "no account"}`));
|
|
108
|
+
if (sameName.length > 0) {
|
|
109
|
+
return {
|
|
110
|
+
...base,
|
|
111
|
+
verdict: "ambiguous",
|
|
112
|
+
matches: sameName,
|
|
113
|
+
reason: `${sameName.length} open deal(s) named "${c.name}" exist (no --account-id supplied to scope the check). Confirm before creating — supply --account-id to resolve definitively.`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return { ...base, verdict: "safe_to_create", matches: [], reason: `No open deal named "${c.name}" anywhere. Safe to create.` };
|
|
117
|
+
}
|
|
118
|
+
function contactName(row) {
|
|
119
|
+
return [row.firstName, row.lastName].filter(Boolean).join(" ") || "(unnamed)";
|
|
120
|
+
}
|
|
121
|
+
function normalizeName(value) {
|
|
122
|
+
return value.trim().toLowerCase().replace(/\s+/g, " ");
|
|
123
|
+
}
|
|
124
|
+
function match(id, name, matchedBy, detail) {
|
|
125
|
+
return { id, name, matchedBy, detail };
|
|
126
|
+
}
|
package/dist/rules.d.ts
CHANGED
|
@@ -6,6 +6,18 @@ import type { CanonicalGtmSnapshot, GtmAuditRule, GtmSnapshotIndex } from "./typ
|
|
|
6
6
|
*/
|
|
7
7
|
export declare const REQUIRES_HUMAN_PREFIX = "requires_human_";
|
|
8
8
|
export declare function requiresHumanInput(value: unknown): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Attribution for duplicate groups: when the provider exposes record-source
|
|
11
|
+
* provenance (RecordProvenance), name the writer(s) that created the group —
|
|
12
|
+
* the fix for recurring dupes is upstream in the writer, not in the records.
|
|
13
|
+
*/
|
|
14
|
+
export declare function provenanceSummary(records: Array<{
|
|
15
|
+
provenance?: {
|
|
16
|
+
source?: string;
|
|
17
|
+
sourceLabel?: string;
|
|
18
|
+
sourceId?: string;
|
|
19
|
+
};
|
|
20
|
+
}>): string;
|
|
9
21
|
export declare function auditFindingId(ruleId: string, objectId: string): string;
|
|
10
22
|
export declare function patchOperationId(ruleId: string, objectId: string): string;
|
|
11
23
|
export declare function stableHash(value: string): string;
|
package/dist/rules.js
CHANGED
|
@@ -8,6 +8,28 @@ export const REQUIRES_HUMAN_PREFIX = "requires_human_";
|
|
|
8
8
|
export function requiresHumanInput(value) {
|
|
9
9
|
return typeof value === "string" && value.startsWith(REQUIRES_HUMAN_PREFIX);
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* Attribution for duplicate groups: when the provider exposes record-source
|
|
13
|
+
* provenance (RecordProvenance), name the writer(s) that created the group —
|
|
14
|
+
* the fix for recurring dupes is upstream in the writer, not in the records.
|
|
15
|
+
*/
|
|
16
|
+
export function provenanceSummary(records) {
|
|
17
|
+
const counts = new Map();
|
|
18
|
+
for (const record of records) {
|
|
19
|
+
const p = record.provenance;
|
|
20
|
+
if (!p)
|
|
21
|
+
continue;
|
|
22
|
+
const label = p.sourceLabel ?? p.source ?? "unknown source";
|
|
23
|
+
const key = p.sourceId ? `${label} (${p.sourceId})` : label;
|
|
24
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
25
|
+
}
|
|
26
|
+
if (counts.size === 0)
|
|
27
|
+
return "";
|
|
28
|
+
const parts = [...counts.entries()]
|
|
29
|
+
.sort((a, b) => b[1] - a[1])
|
|
30
|
+
.map(([key, count]) => (count > 1 ? `${key} ×${count}` : key));
|
|
31
|
+
return ` Created by: ${parts.join(", ")}.`;
|
|
32
|
+
}
|
|
11
33
|
export function auditFindingId(ruleId, objectId) {
|
|
12
34
|
return `finding_${stableHash(`${ruleId}:${objectId}`)}`;
|
|
13
35
|
}
|
|
@@ -326,7 +348,7 @@ export const duplicateAccountDomainRule = {
|
|
|
326
348
|
ruleId: "duplicate-account-domain",
|
|
327
349
|
title: "Accounts share the same domain",
|
|
328
350
|
severity: "warning",
|
|
329
|
-
summary: `${accounts.length} accounts share ${domain}: ${accounts.map((account) => account.name).join(", ")}
|
|
351
|
+
summary: `${accounts.length} accounts share ${domain}: ${accounts.map((account) => account.name).join(", ")}.${provenanceSummary(accounts)}`,
|
|
330
352
|
recommendation: "Review the group and merge duplicates so activity and deals roll up once.",
|
|
331
353
|
});
|
|
332
354
|
operations.push({
|
|
@@ -363,7 +385,7 @@ export const duplicateContactEmailRule = {
|
|
|
363
385
|
ruleId: "duplicate-contact-email",
|
|
364
386
|
title: "Contacts share the same email",
|
|
365
387
|
severity: "warning",
|
|
366
|
-
summary: `${contacts.length} contacts share ${email}
|
|
388
|
+
summary: `${contacts.length} contacts share ${email}.${provenanceSummary(contacts)}`,
|
|
367
389
|
recommendation: "Merge the duplicates so engagement history and routing stay coherent.",
|
|
368
390
|
});
|
|
369
391
|
operations.push({
|
|
@@ -410,7 +432,7 @@ export const duplicateOpenDealRule = {
|
|
|
410
432
|
ruleId: "duplicate-open-deal",
|
|
411
433
|
title: "Open deals duplicate the same opportunity",
|
|
412
434
|
severity: "warning",
|
|
413
|
-
summary: `${deals.length} open deals named "${anchor.name}"${anchor.accountId ? " on the same account" : ""}: ${deals.map((deal) => deal.id).join(", ")}
|
|
435
|
+
summary: `${deals.length} open deals named "${anchor.name}"${anchor.accountId ? " on the same account" : ""}: ${deals.map((deal) => deal.id).join(", ")}.${provenanceSummary(deals)}`,
|
|
414
436
|
recommendation: "Keep one deal, archive the copies, and fix the integration that is re-creating them.",
|
|
415
437
|
});
|
|
416
438
|
operations.push({
|
package/dist/types.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ export type CrmProvider = "salesforce" | "hubspot" | "mock" | "unknown" | (strin
|
|
|
9
9
|
export type RiskLevel = "low" | "medium" | "high";
|
|
10
10
|
export type ApprovalStatus = "draft" | "needs_approval" | "approved" | "rejected" | "applied";
|
|
11
11
|
export type GtmObjectType = "account" | "contact" | "deal" | "user" | "activity";
|
|
12
|
-
export type GtmEvidenceSourceSystem = "salesforce" | "hubspot" | "gong" | "chorus" | "fathom" | "manual" | "csv" | "mock" | "unknown";
|
|
12
|
+
export type GtmEvidenceSourceSystem = "salesforce" | "hubspot" | "gong" | "chorus" | "fathom" | "manual" | "csv" | "mock" | "web" | "unknown";
|
|
13
13
|
export type PatchOperationType = "set_field" | "clear_field" | "link_record" | "archive_record" | "create_task" | "merge_records";
|
|
14
14
|
export type AuditFindingSeverity = "info" | "warning" | "critical";
|
|
15
15
|
/**
|
|
@@ -83,11 +83,25 @@ export type CanonicalUser = {
|
|
|
83
83
|
title?: string;
|
|
84
84
|
active?: boolean;
|
|
85
85
|
};
|
|
86
|
+
/**
|
|
87
|
+
* Who created a record, per the provider's read-only record-source fields
|
|
88
|
+
* (HubSpot: hs_object_source / _label / _id). Populated on read; used to
|
|
89
|
+
* attribute duplicate findings to the writer that produced them.
|
|
90
|
+
*/
|
|
91
|
+
export type RecordProvenance = {
|
|
92
|
+
/** Provider source code, e.g. INTEGRATION, API, CRM_UI, IMPORT, FORM. */
|
|
93
|
+
source?: string;
|
|
94
|
+
/** Human label, e.g. an integration's name. */
|
|
95
|
+
sourceLabel?: string;
|
|
96
|
+
/** Provider-side id of the source (e.g. app id, import id). */
|
|
97
|
+
sourceId?: string;
|
|
98
|
+
};
|
|
86
99
|
export type CanonicalAccount = {
|
|
87
100
|
id: string;
|
|
88
101
|
provider?: CrmProvider;
|
|
89
102
|
crmId?: string;
|
|
90
103
|
identities?: ProviderIdentity[];
|
|
104
|
+
provenance?: RecordProvenance;
|
|
91
105
|
name: string;
|
|
92
106
|
domain?: string;
|
|
93
107
|
industry?: string;
|
|
@@ -104,6 +118,7 @@ export type CanonicalContact = {
|
|
|
104
118
|
provider?: CrmProvider;
|
|
105
119
|
crmId?: string;
|
|
106
120
|
identities?: ProviderIdentity[];
|
|
121
|
+
provenance?: RecordProvenance;
|
|
107
122
|
accountId?: string;
|
|
108
123
|
firstName?: string;
|
|
109
124
|
lastName?: string;
|
|
@@ -121,6 +136,7 @@ export type CanonicalDeal = {
|
|
|
121
136
|
provider?: CrmProvider;
|
|
122
137
|
crmId?: string;
|
|
123
138
|
identities?: ProviderIdentity[];
|
|
139
|
+
provenance?: RecordProvenance;
|
|
124
140
|
accountId?: string;
|
|
125
141
|
ownerId?: string;
|
|
126
142
|
name: string;
|
|
@@ -41,12 +41,11 @@ fix the faucet instead of mopping the puddle.
|
|
|
41
41
|
a unique match, refuse on ambiguity, create only on a confirmed miss.
|
|
42
42
|
HubSpot's search API is eventually consistent (~5–10s), so same-run
|
|
43
43
|
creations are deduped in memory, not via search.
|
|
44
|
-
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
(account + normalized name).
|
|
44
|
+
- The **`resolve` gate** (shipped 0.15): `fullstackgtm resolve
|
|
45
|
+
<account|contact|deal>` returns exists/ambiguous/safe_to_create with
|
|
46
|
+
matches and reasons; exit 0 = safe, exit 2 = do not create. Same identity
|
|
47
|
+
keys as the audit/merge engines. Also `resolveRecord()` and MCP
|
|
48
|
+
`fullstackgtm_resolve`.
|
|
50
49
|
- **Stamp provenance on our own creates** (HubSpot allows integrations to
|
|
51
50
|
set `hs_object_source_detail_2/3` at create time).
|
|
52
51
|
- Recommend native config in `doctor`/audit: Salesforce duplicate rules
|
|
@@ -61,10 +60,10 @@ fix the faucet instead of mopping the puddle.
|
|
|
61
60
|
(ruleId, objectId).
|
|
62
61
|
- **The nightly watch recipe** ("CRM CI"): scheduled
|
|
63
62
|
`snapshot → audit → diff` against yesterday's snapshot, alert on exit 2.
|
|
64
|
-
- **Attribution** (
|
|
65
|
-
`hs_object_source
|
|
66
|
-
|
|
67
|
-
|
|
63
|
+
- **Attribution** (shipped 0.15): snapshots capture HubSpot's read-only
|
|
64
|
+
`hs_object_source*` into `RecordProvenance`; duplicate findings append
|
|
65
|
+
*"Created by: …"* naming the writer(s). The fix for recurring dupes is
|
|
66
|
+
upstream, in the writer.
|
|
68
67
|
- Incremental reads (`snapshot --since`) exist for all three connectors;
|
|
69
68
|
caveats: HubSpot deltas carry no associations and cap at 10k per object,
|
|
70
69
|
Stripe deltas catch creations only.
|
|
@@ -131,5 +130,6 @@ Lessons from auditing our own apply path:
|
|
|
131
130
|
| --- | --- |
|
|
132
131
|
| 0.11.1 | Fix our own faucet: resolve-first `create:` + plan-scoped dedup, HubSpot association-aware CAS for `link_record`, domain normalization in `duplicate-account-domain`, `create_task` idempotency token |
|
|
133
132
|
| 0.12 (shipped) | `merge_records` (HubSpot contacts/companies/deals) + survivor suggestions capped at low confidence; the three duplicate rules emit governed merges instead of review tasks |
|
|
134
|
-
| 0.
|
|
133
|
+
| 0.15 (shipped) | `resolve` gate (CLI/lib/MCP, gate exit codes), provenance capture (`hs_object_source*` → `RecordProvenance`) + attribution in duplicate findings, self-stamped creates |
|
|
134
|
+
| 0.16 | prevention-posture checks (native duplicate rules active? unique-value properties defined?) · live targeted resolve lookups |
|
|
135
135
|
| docs | The nightly watch recipe (existing flags, documented as CRM CI) |
|
package/llms.txt
CHANGED
|
@@ -18,6 +18,10 @@ at/above `--fail-on`.
|
|
|
18
18
|
- [CRM-health lifecycle](https://github.com/fullstackgtm/core/blob/main/docs/crm-health-lifecycle.md): the Prevent → Detect → Remediate → Verify/Attribute model; no-new-dupes design
|
|
19
19
|
- [CHANGELOG](https://github.com/fullstackgtm/core/blob/main/CHANGELOG.md): release history
|
|
20
20
|
|
|
21
|
+
`fullstackgtm resolve <type>` is the create gate: exit 0 = safe to create,
|
|
22
|
+
exit 2 = exists/ambiguous (never blind-create); duplicate findings carry
|
|
23
|
+
"Created by:" writer attribution when the provider exposes record source.
|
|
24
|
+
|
|
21
25
|
## Key invariants (calls)
|
|
22
26
|
|
|
23
27
|
`fullstackgtm call parse` defaults to LLM extraction (BYO Anthropic/OpenAI
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullstackgtm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Full Stack GTM",
|
package/src/cli.ts
CHANGED
|
@@ -39,6 +39,17 @@ import { auditReportToHtml, auditReportToMarkdown, type ReportOptions } from "./
|
|
|
39
39
|
import { builtinAuditRules } from "./rules.ts";
|
|
40
40
|
import { sampleSnapshot } from "./sampleData.ts";
|
|
41
41
|
import { normalizeTranscript, parseCall, suggestCallDeal, type ExtractedCallInsight, type ParsedCall } from "./calls.ts";
|
|
42
|
+
import {
|
|
43
|
+
captureMarket,
|
|
44
|
+
computeFrontStates,
|
|
45
|
+
createFileObservationStore,
|
|
46
|
+
diffFrontStates,
|
|
47
|
+
loadMarketConfig,
|
|
48
|
+
starterMarketConfig,
|
|
49
|
+
validateObservationSet,
|
|
50
|
+
type ObservationSet,
|
|
51
|
+
} from "./market.ts";
|
|
52
|
+
import { marketMapToHtml, marketMapToMarkdown } from "./marketReport.ts";
|
|
42
53
|
import {
|
|
43
54
|
DEFAULT_RUBRIC,
|
|
44
55
|
detectProviderFromKey,
|
|
@@ -50,6 +61,7 @@ import {
|
|
|
50
61
|
type CallScorecard,
|
|
51
62
|
type LlmProvider,
|
|
52
63
|
} from "./llm.ts";
|
|
64
|
+
import { resolveRecord, type ResolveCandidate } from "./resolve.ts";
|
|
53
65
|
import { suggestValues, type ValueSuggestion } from "./suggest.ts";
|
|
54
66
|
import type { FieldMappings } from "./mappings.ts";
|
|
55
67
|
import type {
|
|
@@ -90,6 +102,18 @@ Usage:
|
|
|
90
102
|
ANTHROPIC_API_KEY/OPENAI_API_KEY, or \`login anthropic|openai\`);
|
|
91
103
|
--deterministic uses the free keyword baseline. Then link the call
|
|
92
104
|
to its deal and propose governed next-step writes.
|
|
105
|
+
fullstackgtm resolve <account|contact|deal> [--name N] [--domain D] [--email E] [--account-id A] [source options] [--json]
|
|
106
|
+
the create gate: exit 0 = safe to create, exit 2 = match
|
|
107
|
+
found (exists/ambiguous) — call before ANY record creation
|
|
108
|
+
fullstackgtm market init --category <name> start a market map: vendors + claim taxonomy as reviewable config
|
|
109
|
+
fullstackgtm market capture [--config <path>] [--run <label>]
|
|
110
|
+
fullstackgtm market observe --from <observations.json>
|
|
111
|
+
fullstackgtm market fronts [--run <label>] [--diff <prior-run>] [--json]
|
|
112
|
+
fullstackgtm market report [--run <label>] [--format md|html] [--out <path>]
|
|
113
|
+
the live competitive map: capture vendor pages (content-addressed),
|
|
114
|
+
ingest intensity readings with verbatim-quote evidence, compute
|
|
115
|
+
deterministic front states (open/contested/owned/saturated) and
|
|
116
|
+
drift between runs, render the client-ready field report
|
|
93
117
|
fullstackgtm suggest --plan-id <id> | --plan <path> [source options] [--json] [--out <path>]
|
|
94
118
|
derive values for requires_human_* placeholders
|
|
95
119
|
from snapshot evidence, with confidence + reasons
|
|
@@ -797,6 +821,157 @@ function buildCallPlan(
|
|
|
797
821
|
};
|
|
798
822
|
}
|
|
799
823
|
|
|
824
|
+
/**
|
|
825
|
+
* The market map: claim taxonomy in a reviewable config file, page captures
|
|
826
|
+
* and append-only observations under the profile home, deterministic front
|
|
827
|
+
* states and reports computed from the store. Classification (LLM intensity
|
|
828
|
+
* readings) lands in a later change; until then `market observe --from`
|
|
829
|
+
* ingests proposal files produced by an agent or a human.
|
|
830
|
+
*/
|
|
831
|
+
async function marketCommand(args: string[]) {
|
|
832
|
+
const [subcommand, ...rest] = args;
|
|
833
|
+
const configPath = () => resolve(process.cwd(), option(rest, "--config") ?? "market.config.json");
|
|
834
|
+
|
|
835
|
+
if (!subcommand || subcommand === "--help") {
|
|
836
|
+
console.log(`Usage:
|
|
837
|
+
market init --category <name> [--out <path>] write a starter market.config.json
|
|
838
|
+
market capture [--config <path>] [--run <label>]
|
|
839
|
+
market observe --from <observations.json> [--config <path>]
|
|
840
|
+
market fronts [--config <path>] [--run <label>] [--diff <prior-run>] [--json]
|
|
841
|
+
market report [--config <path>] [--run <label>] [--format md|html] [--out <path>]
|
|
842
|
+
|
|
843
|
+
The taxonomy (vendors + claims) is config you review and version; captures
|
|
844
|
+
and observations live under ~/.fullstackgtm/market/<category> (profile-scoped,
|
|
845
|
+
one client's category intel never bleeds into another's). Front states are
|
|
846
|
+
recomputed deterministically on every invocation — never stored.`);
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
if (subcommand === "init") {
|
|
851
|
+
const category = option(rest, "--category");
|
|
852
|
+
if (!category) throw new Error("market init requires --category <name>");
|
|
853
|
+
const outPath = resolve(process.cwd(), option(rest, "--out") ?? "market.config.json");
|
|
854
|
+
if (existsSync(outPath)) throw new Error(`${outPath} already exists — refusing to overwrite`);
|
|
855
|
+
writeFileSync(outPath, `${JSON.stringify(starterMarketConfig(category), null, 2)}\n`);
|
|
856
|
+
console.log(`Wrote ${outPath}. Fill in vendors and claims, then: fullstackgtm market capture`);
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const config = loadMarketConfig(configPath());
|
|
861
|
+
const store = createFileObservationStore(config.category);
|
|
862
|
+
|
|
863
|
+
if (subcommand === "capture") {
|
|
864
|
+
const result = await captureMarket(config, { runLabel: option(rest, "--run") ?? "run-1" });
|
|
865
|
+
for (const entry of result.entries) {
|
|
866
|
+
const flag = entry.captureHash && entry.textChars > 500 ? "" : " <-- thin/empty";
|
|
867
|
+
console.log(
|
|
868
|
+
`${entry.vendorId.padEnd(16)} ${entry.kind.padEnd(8)} ${String(entry.httpStatus ?? "ERR").padEnd(4)} ${String(entry.textChars).padStart(7)} chars ${entry.url}${flag}`,
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
console.log(`\nmanifest: ${result.manifestPath}`);
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
if (subcommand === "observe") {
|
|
876
|
+
const fromPath = option(rest, "--from");
|
|
877
|
+
if (!fromPath) throw new Error("market observe requires --from <observations.json>");
|
|
878
|
+
const set = JSON.parse(readFileSync(resolve(process.cwd(), fromPath), "utf8")) as ObservationSet;
|
|
879
|
+
const problems = validateObservationSet(config, set);
|
|
880
|
+
if (problems.length > 0) {
|
|
881
|
+
console.error(`Rejected: ${problems.length} problem(s)`);
|
|
882
|
+
for (const problem of problems.slice(0, 20)) console.error(` - ${problem}`);
|
|
883
|
+
process.exitCode = 1;
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
await store.append(set);
|
|
887
|
+
console.log(`Appended ${set.runLabel}: ${set.observations.length} observations (${set.extractor})`);
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const loadSet = async (): Promise<ObservationSet> => {
|
|
892
|
+
const runLabel = option(rest, "--run");
|
|
893
|
+
const set = runLabel ? await store.get(runLabel) : await store.latest();
|
|
894
|
+
if (!set) {
|
|
895
|
+
throw new Error(
|
|
896
|
+
runLabel
|
|
897
|
+
? `No observation run "${runLabel}" for ${config.category}`
|
|
898
|
+
: `No observations stored for ${config.category} — run market observe --from <file> first`,
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
return set;
|
|
902
|
+
};
|
|
903
|
+
|
|
904
|
+
if (subcommand === "fronts") {
|
|
905
|
+
const set = await loadSet();
|
|
906
|
+
const fronts = computeFrontStates(config, set);
|
|
907
|
+
const priorLabel = option(rest, "--diff");
|
|
908
|
+
const prior = priorLabel ? await store.get(priorLabel) : null;
|
|
909
|
+
if (priorLabel && !prior) throw new Error(`No observation run "${priorLabel}" to diff against`);
|
|
910
|
+
const drift = prior ? diffFrontStates(computeFrontStates(config, prior), fronts) : null;
|
|
911
|
+
if (rest.includes("--json")) {
|
|
912
|
+
console.log(JSON.stringify({ runLabel: set.runLabel, fronts, drift }, null, 2));
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
for (const front of fronts) {
|
|
916
|
+
const owner = front.state === "owned" ? ` → ${front.loudVendorIds[0]}` : "";
|
|
917
|
+
console.log(`${front.state.toUpperCase().padEnd(10)} ${front.claimId}${owner}`);
|
|
918
|
+
}
|
|
919
|
+
if (drift) {
|
|
920
|
+
console.log("");
|
|
921
|
+
if (drift.length === 0) console.log(`No front changes since ${priorLabel}.`);
|
|
922
|
+
for (const change of drift) console.log(`CHANGED ${change.claimId}: ${change.before} → ${change.after}`);
|
|
923
|
+
}
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
if (subcommand === "report") {
|
|
928
|
+
const set = await loadSet();
|
|
929
|
+
const format = option(rest, "--format") ?? "md";
|
|
930
|
+
const output = format === "html" ? marketMapToHtml(config, set) : marketMapToMarkdown(config, set);
|
|
931
|
+
const outPath = option(rest, "--out");
|
|
932
|
+
if (outPath) {
|
|
933
|
+
writeFileSync(resolve(process.cwd(), outPath), output);
|
|
934
|
+
console.log(`Wrote ${outPath}`);
|
|
935
|
+
} else {
|
|
936
|
+
console.log(output);
|
|
937
|
+
}
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
throw new Error(`Unknown market subcommand: ${subcommand} (try: init, capture, observe, fronts, report)`);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* The resolve gate: exit 0 = safe to create, exit 2 = match found (exists or
|
|
946
|
+
* ambiguous — do NOT blind-create), exit 1 = error. Built for sync jobs and
|
|
947
|
+
* webhook handlers to call before any record creation.
|
|
948
|
+
*/
|
|
949
|
+
async function resolveCommand(args: string[]) {
|
|
950
|
+
const [objectType, ...rest] = args;
|
|
951
|
+
if (!objectType || !["account", "contact", "deal"].includes(objectType)) {
|
|
952
|
+
throw new Error("Usage: fullstackgtm resolve <account|contact|deal> [--name N] [--domain D] [--email E] [--account-id A] [source options] [--json]");
|
|
953
|
+
}
|
|
954
|
+
const candidate: ResolveCandidate = {
|
|
955
|
+
objectType: objectType as ResolveCandidate["objectType"],
|
|
956
|
+
name: option(rest, "--name") ?? undefined,
|
|
957
|
+
domain: option(rest, "--domain") ?? undefined,
|
|
958
|
+
email: option(rest, "--email") ?? undefined,
|
|
959
|
+
accountId: option(rest, "--account-id") ?? undefined,
|
|
960
|
+
};
|
|
961
|
+
const snapshot = await readSnapshot(rest);
|
|
962
|
+
const result = resolveRecord(snapshot, candidate);
|
|
963
|
+
if (rest.includes("--json")) {
|
|
964
|
+
console.log(JSON.stringify(result, null, 2));
|
|
965
|
+
} else {
|
|
966
|
+
const marker = result.verdict === "safe_to_create" ? "✓" : result.verdict === "exists" ? "=" : "?";
|
|
967
|
+
console.log(`${marker} [${result.verdict}] ${result.reason}`);
|
|
968
|
+
for (const m of result.matches) {
|
|
969
|
+
console.log(` ${m.id} "${m.name}" — matched by ${m.matchedBy}: ${m.detail}`);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
if (result.verdict !== "safe_to_create") process.exitCode = 2;
|
|
973
|
+
}
|
|
974
|
+
|
|
800
975
|
async function suggest(args: string[]) {
|
|
801
976
|
const planId = option(args, "--plan-id");
|
|
802
977
|
const planPath = option(args, "--plan");
|
|
@@ -1626,6 +1801,14 @@ export async function runCli(argv: string[]) {
|
|
|
1626
1801
|
await callCommand(args);
|
|
1627
1802
|
return;
|
|
1628
1803
|
}
|
|
1804
|
+
if (command === "resolve") {
|
|
1805
|
+
await resolveCommand(args);
|
|
1806
|
+
return;
|
|
1807
|
+
}
|
|
1808
|
+
if (command === "market") {
|
|
1809
|
+
await marketCommand(args);
|
|
1810
|
+
return;
|
|
1811
|
+
}
|
|
1629
1812
|
if (command === "profiles") {
|
|
1630
1813
|
profilesCommand(args);
|
|
1631
1814
|
return;
|