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.
@@ -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
- - A standalone **`resolve` gate** (planned, 0.13): given a candidate
45
- record, return existing match(es) or "safe to create" — for the CLI, the
46
- library, MCP, and any external writer (sync jobs, agents, webhook
47
- handlers). Identity keys are the ones the package already uses:
48
- contact email, normalized account domain, and the open-deal key
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** (planned, 0.13): capture HubSpot's read-only
65
- `hs_object_source`, `hs_object_source_label`, `hs_object_source_id` into
66
- the canonical model so duplicate findings can say *"all five created by
67
- integration X"*. The fix for recurring dupes is upstream, in the writer.
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.13 | `resolve` gate (CLI/lib/MCP), provenance capture + attribution in findings, prevention-posture checks |
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.14.1",
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;