fullstackgtm 0.42.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +273 -0
  2. package/README.md +18 -7
  3. package/dist/calls.d.ts +13 -0
  4. package/dist/calls.js +14 -3
  5. package/dist/cli.js +718 -60
  6. package/dist/connectors/hubspot.js +58 -24
  7. package/dist/connectors/outboxChannel.d.ts +64 -0
  8. package/dist/connectors/outboxChannel.js +170 -0
  9. package/dist/connectors/prospectSources.d.ts +22 -0
  10. package/dist/connectors/prospectSources.js +43 -0
  11. package/dist/connectors/salesforce.js +40 -15
  12. package/dist/connectors/signalSources.d.ts +105 -0
  13. package/dist/connectors/signalSources.js +316 -0
  14. package/dist/connectors/theirstack.d.ts +84 -0
  15. package/dist/connectors/theirstack.js +125 -0
  16. package/dist/draft.js +27 -5
  17. package/dist/enrich.d.ts +6 -0
  18. package/dist/enrich.js +47 -1
  19. package/dist/health.d.ts +12 -0
  20. package/dist/health.js +29 -2
  21. package/dist/icp.d.ts +47 -3
  22. package/dist/icp.js +105 -11
  23. package/dist/index.d.ts +2 -0
  24. package/dist/index.js +2 -0
  25. package/dist/init.d.ts +47 -0
  26. package/dist/init.js +143 -0
  27. package/dist/judge.d.ts +36 -3
  28. package/dist/judge.js +46 -10
  29. package/dist/judgeEval.d.ts +7 -0
  30. package/dist/judgeEval.js +8 -1
  31. package/dist/runReport.d.ts +26 -0
  32. package/dist/runReport.js +15 -0
  33. package/dist/schedule.js +18 -4
  34. package/dist/signals.d.ts +58 -0
  35. package/dist/signals.js +65 -0
  36. package/dist/tam.d.ts +225 -0
  37. package/dist/tam.js +470 -0
  38. package/dist/types.d.ts +12 -0
  39. package/docs/api.md +26 -4
  40. package/docs/outbox-format.md +92 -0
  41. package/docs/recipes.md +195 -0
  42. package/docs/roadmap-to-1.0.md +31 -1
  43. package/docs/signal-spool-format.md +162 -0
  44. package/docs/tam.md +195 -0
  45. package/llms.txt +89 -1
  46. package/package.json +1 -1
  47. package/skills/fullstackgtm/SKILL.md +17 -1
  48. package/src/calls.ts +24 -3
  49. package/src/cli.ts +809 -55
  50. package/src/connectors/hubspot.ts +55 -23
  51. package/src/connectors/outboxChannel.ts +202 -0
  52. package/src/connectors/prospectSources.ts +57 -0
  53. package/src/connectors/salesforce.ts +39 -14
  54. package/src/connectors/signalSources.ts +363 -0
  55. package/src/connectors/theirstack.ts +170 -0
  56. package/src/draft.ts +26 -5
  57. package/src/enrich.ts +51 -1
  58. package/src/health.ts +47 -2
  59. package/src/icp.ts +113 -11
  60. package/src/index.ts +42 -0
  61. package/src/init.ts +166 -0
  62. package/src/judge.ts +85 -11
  63. package/src/judgeEval.ts +8 -1
  64. package/src/runReport.ts +39 -0
  65. package/src/schedule.ts +20 -4
  66. package/src/signals.ts +95 -0
  67. package/src/tam.ts +654 -0
  68. package/src/types.ts +12 -0
package/src/health.ts CHANGED
@@ -35,6 +35,18 @@ export type HealthEntry = {
35
35
  byRule: Record<string, number>;
36
36
  /** Finding count per severity. */
37
37
  severityCounts: Record<AuditFindingSeverity, number>;
38
+ /**
39
+ * Per-object-type breakdown — so "is my contact data clean but my pipeline
40
+ * messy?" is answerable without re-auditing. Each type carries its own
41
+ * record-normalized score (same curve as the overall score, scoped to that
42
+ * type's records + findings).
43
+ */
44
+ byObjectType: Record<"account" | "contact" | "deal", {
45
+ records: number;
46
+ findings: number;
47
+ weightedFindings: number;
48
+ score: number;
49
+ }>;
38
50
  };
39
51
 
40
52
  export type HealthRuleDelta = {
@@ -68,11 +80,21 @@ export type HealthRollup = {
68
80
  export function computeHealth(plan: PatchPlan, snapshot: CanonicalGtmSnapshot, at: string): HealthEntry {
69
81
  const byRule: Record<string, number> = {};
70
82
  const severityCounts: Record<AuditFindingSeverity, number> = { info: 0, warning: 0, critical: 0 };
83
+ const typeTally: Record<"account" | "contact" | "deal", { findings: number; weightedFindings: number }> = {
84
+ account: { findings: 0, weightedFindings: 0 },
85
+ contact: { findings: 0, weightedFindings: 0 },
86
+ deal: { findings: 0, weightedFindings: 0 },
87
+ };
71
88
  let weightedFindings = 0;
72
89
  for (const finding of plan.findings) {
73
90
  byRule[finding.ruleId] = (byRule[finding.ruleId] ?? 0) + 1;
74
91
  severityCounts[finding.severity] += 1;
75
92
  weightedFindings += SEVERITY_WEIGHT[finding.severity];
93
+ const t = finding.objectType;
94
+ if (t === "account" || t === "contact" || t === "deal") {
95
+ typeTally[t].findings += 1;
96
+ typeTally[t].weightedFindings += SEVERITY_WEIGHT[finding.severity];
97
+ }
76
98
  }
77
99
 
78
100
  const records = {
@@ -82,8 +104,23 @@ export function computeHealth(plan: PatchPlan, snapshot: CanonicalGtmSnapshot, a
82
104
  total: snapshot.accounts.length + snapshot.contacts.length + snapshot.deals.length,
83
105
  };
84
106
 
85
- const penalty = weightedFindings / Math.max(records.total, 1);
86
- const score = Math.round(100 / (1 + penalty));
107
+ const scoreFor = (weighted: number, recordCount: number) =>
108
+ Math.round(100 / (1 + weighted / Math.max(recordCount, 1)));
109
+ const recordsByType = { account: records.accounts, contact: records.contacts, deal: records.deals };
110
+ const byObjectType = (["account", "contact", "deal"] as const).reduce(
111
+ (acc, t) => {
112
+ acc[t] = {
113
+ records: recordsByType[t],
114
+ findings: typeTally[t].findings,
115
+ weightedFindings: typeTally[t].weightedFindings,
116
+ score: scoreFor(typeTally[t].weightedFindings, recordsByType[t]),
117
+ };
118
+ return acc;
119
+ },
120
+ {} as HealthEntry["byObjectType"],
121
+ );
122
+
123
+ const score = scoreFor(weightedFindings, records.total);
87
124
 
88
125
  return {
89
126
  at,
@@ -94,6 +131,7 @@ export function computeHealth(plan: PatchPlan, snapshot: CanonicalGtmSnapshot, a
94
131
  records,
95
132
  byRule,
96
133
  severityCounts,
134
+ byObjectType,
97
135
  };
98
136
  }
99
137
 
@@ -155,6 +193,13 @@ export function healthToMarkdown(rollup: HealthRollup): string {
155
193
  "",
156
194
  );
157
195
 
196
+ lines.push("## By object type", "", "| Type | Score | Findings | Records |", "| --- | --- | --- | --- |");
197
+ for (const t of ["account", "contact", "deal"] as const) {
198
+ const b = current.byObjectType[t];
199
+ lines.push(`| ${t} | ${b.score}/100 | ${b.findings} | ${b.records} |`);
200
+ }
201
+ lines.push("");
202
+
158
203
  if (rollup.history.length > 1) {
159
204
  lines.push("## Trend", "");
160
205
  for (const point of rollup.history) {
package/src/icp.ts CHANGED
@@ -24,6 +24,10 @@ export type Icp = {
24
24
  employeeBands?: string[];
25
25
  /** ISO country codes, lowercased, e.g. ["us"] */
26
26
  geos?: string[];
27
+ /** technographic targeting: technology slugs the account must use, e.g.
28
+ * ["salesforce","hubspot","pipedrive"] — the real CRM/MAP buying signal,
29
+ * consumed by TheirStack company search. OR-matched. */
30
+ technologies?: string[];
27
31
  };
28
32
  persona: {
29
33
  /** seniority: "cxo","vp","director","manager","owner","senior" */
@@ -79,6 +83,81 @@ export function icpToExploriumFilters(icp: Icp): Record<string, { values?: strin
79
83
  return f;
80
84
  }
81
85
 
86
+ /**
87
+ * Explorium /v1/businesses filters from the ICP — the COMPANY (account) side, for
88
+ * sizing the account universe (TAM). Firmographics only, no persona: the count is
89
+ * of matching companies. Field names differ from /v1/prospects (verified live):
90
+ * `country_code` (not company_country_code), `company_size` (same employee bands),
91
+ * `naics_category`. `/v1/businesses` total_results is a real count, capped at
92
+ * 60,000 (see EXPLORIUM_BUSINESS_COUNT_CAP in the connector).
93
+ */
94
+ export function icpToExploriumBusinessFilters(icp: Icp): Record<string, { values?: string[] }> {
95
+ const f: Record<string, { values?: string[] }> = {};
96
+ if (icp.firmographics.geos?.length) f.country_code = { values: icp.firmographics.geos };
97
+ if (icp.firmographics.employeeBands?.length) f.company_size = { values: icp.firmographics.employeeBands };
98
+ if (icp.firmographics.naics?.length) f.naics_category = { values: icp.firmographics.naics };
99
+ return f;
100
+ }
101
+
102
+ /**
103
+ * Collapse provider-agnostic employee bands ("51-200","10001+") into a single
104
+ * {min,max} envelope for APIs that take integer bounds (TheirStack). An open
105
+ * top band ("10001+") leaves max undefined.
106
+ */
107
+ export function employeeBandsToRange(bands: string[] | undefined): { min?: number; max?: number } {
108
+ if (!bands?.length) return {};
109
+ let min: number | undefined;
110
+ let max: number | undefined;
111
+ let openTop = false;
112
+ for (const band of bands) {
113
+ const plus = /^(\d+)\+$/.exec(band.trim());
114
+ if (plus) {
115
+ const lo = Number(plus[1]);
116
+ if (min === undefined || lo < min) min = lo;
117
+ openTop = true;
118
+ continue;
119
+ }
120
+ const range = /^(\d+)\s*-\s*(\d+)$/.exec(band.trim());
121
+ if (range) {
122
+ const lo = Number(range[1]);
123
+ const hi = Number(range[2]);
124
+ if (min === undefined || lo < min) min = lo;
125
+ if (max === undefined || hi > max) max = hi;
126
+ }
127
+ }
128
+ return { min, max: openTop ? undefined : max };
129
+ }
130
+
131
+ /**
132
+ * TheirStack company-search filter from the ICP: the technographic targeting that
133
+ * Explorium can't do. `company_technology_slug_or` is the CRM/MAP buying signal
134
+ * (firmographics.technologies); employee bands become min/max bounds; geos become
135
+ * ISO2 codes (uppercased).
136
+ */
137
+ export function icpToTheirStackFilters(icp: Icp): {
138
+ company_technology_slug_or?: string[];
139
+ min_employee_count?: number;
140
+ max_employee_count?: number;
141
+ company_country_code_or?: string[];
142
+ } {
143
+ const f: {
144
+ company_technology_slug_or?: string[];
145
+ min_employee_count?: number;
146
+ max_employee_count?: number;
147
+ company_country_code_or?: string[];
148
+ } = {};
149
+ if (icp.firmographics.technologies?.length) {
150
+ f.company_technology_slug_or = icp.firmographics.technologies.map((t) => t.trim().toLowerCase());
151
+ }
152
+ if (icp.firmographics.geos?.length) {
153
+ f.company_country_code_or = icp.firmographics.geos.map((g) => g.trim().toUpperCase());
154
+ }
155
+ const range = employeeBandsToRange(icp.firmographics.employeeBands);
156
+ if (range.min !== undefined) f.min_employee_count = range.min;
157
+ if (range.max !== undefined) f.max_employee_count = range.max;
158
+ return f;
159
+ }
160
+
82
161
  /**
83
162
  * Crustdata / LinkedIn seniority vocab. ICP job levels are normalized lowercase;
84
163
  * Crustdata expects these exact capitalized strings (verified: "CXO",
@@ -101,16 +180,31 @@ const CRUSTDATA_SENIORITY: Record<string, string> = {
101
180
  };
102
181
 
103
182
  /**
104
- * ICP industry keyword → LinkedIn industry names Crustdata filters on. Includes
105
- * the current LinkedIn-v2 names; for software we send the cluster (dev + IT
106
- * services + internet) so an OR match isn't overly narrow.
183
+ * ICP industry keyword → LinkedIn industry names Crustdata filters on.
184
+ *
185
+ * LinkedIn renamed its industry taxonomy (v1 v2, ~2022), and data vendors
186
+ * normalize to one generation or the other. Sending ONLY the v2 names risks a
187
+ * zero-match when Crustdata stores v1 (and vice-versa) — observed live: a RevOps
188
+ * ICP whose only firmographic constraint was the v2 names returned 0 even though
189
+ * the title-only search returned plenty. So each cluster sends BOTH generations
190
+ * (OR-matched within the field): v2 ("Software Development", "IT Services and IT
191
+ * Consulting", "Technology, Information and Internet") AND v1 ("Computer
192
+ * Software", "Information Technology & Services", "Internet").
107
193
  */
108
194
  const CRUSTDATA_INDUSTRY: Record<string, string[]> = {
109
- software: ["Software Development", "Information Technology and Services", "Internet"],
110
- saas: ["Software Development", "Information Technology and Services"],
111
- "information technology & services": ["Information Technology and Services"],
112
- "information technology and services": ["Information Technology and Services"],
113
- internet: ["Internet"],
195
+ software: [
196
+ "Software Development",
197
+ "Computer Software",
198
+ "IT Services and IT Consulting",
199
+ "Information Technology & Services",
200
+ "Information Technology and Services",
201
+ "Technology, Information and Internet",
202
+ "Internet",
203
+ ],
204
+ saas: ["Software Development", "Computer Software", "IT Services and IT Consulting", "Information Technology & Services"],
205
+ "information technology & services": ["Information Technology & Services", "IT Services and IT Consulting"],
206
+ "information technology and services": ["Information Technology & Services", "IT Services and IT Consulting"],
207
+ internet: ["Internet", "Technology, Information and Internet"],
114
208
  fintech: ["Financial Services"],
115
209
  "financial services": ["Financial Services"],
116
210
  };
@@ -119,9 +213,17 @@ const CRUSTDATA_INDUSTRY: Record<string, string[]> = {
119
213
  * pipe0 Crustdata people-search config.filters from the ICP. `current_job_titles`
120
214
  * matches real LinkedIn title strings (case-sensitive), so keywords are Title
121
215
  * Cased. Seniority + industry are mapped to Crustdata's controlled vocab via the
122
- * tables above. NOTE: the exact pipe0→Crustdata value set could not be re-run
123
- * live (pipe0 credits were exhausted) — validate when credits refill; fit
124
- * scoring is the safety net for persona precision regardless.
216
+ * tables above.
217
+ *
218
+ * LIVE FINDINGS (2026-06-26): `current_job_titles` is confirmed working (a
219
+ * titles-only RevOps search returns results); `current_title` is rejected (422).
220
+ * The full ICP filter returned 0 — the prime suspect is the industry vocab
221
+ * (LinkedIn v1/v2 taxonomy mismatch), now hedged by sending BOTH generations in
222
+ * CRUSTDATA_INDUSTRY above. NOT yet re-confirmed end-to-end (pipe0 credits were
223
+ * exhausted mid-investigation) — re-run `enrich acquire --source pipe0` once
224
+ * credits refill; if it still returns 0, the next suspects are the
225
+ * `current_seniority_levels` shape/values and `locations`. Note fit-scoring backs
226
+ * up PERSONA precision but NOT industry, so the industry filter is load-bearing.
125
227
  */
126
228
  export function icpToCrustdataFilters(icp: Icp): Record<string, unknown> {
127
229
  const f: Record<string, unknown> = {};
package/src/index.ts CHANGED
@@ -97,6 +97,48 @@ export {
97
97
  type EnrichWorkItem,
98
98
  type MatchOutcome,
99
99
  } from "./enrich.ts";
100
+ export {
101
+ scaffoldWorkspace,
102
+ starterEnrichConfig,
103
+ starterIcp,
104
+ starterPlaybook,
105
+ type InitProvider,
106
+ type InitSource,
107
+ type ScaffoldFile,
108
+ type ScaffoldOptions,
109
+ } from "./init.ts";
110
+ export {
111
+ appendCoverage,
112
+ classifyAccount,
113
+ classifyCoverage,
114
+ computeCoverage,
115
+ coverageCountsFromSnapshot,
116
+ coveredAccounts,
117
+ coverageToText,
118
+ crmCheckableCriteria,
119
+ deriveAcvFromClosedWon,
120
+ deriveBuyersPerAccount,
121
+ estimateTam,
122
+ loadTamModel,
123
+ projectEta,
124
+ readCoverageTimeline,
125
+ saveTamModel,
126
+ tamDir,
127
+ tamReportToMarkdown,
128
+ type AccountTamClass,
129
+ type AcvBasis,
130
+ type DerivedAcv,
131
+ type DerivedBuyers,
132
+ type EstimateTamInput,
133
+ type TamClassified,
134
+ type TamCoverage,
135
+ type TamCoverageCounts,
136
+ type TamCrossCheck,
137
+ type TamEta,
138
+ type TamModel,
139
+ type TamTargeting,
140
+ type TamUniverse,
141
+ } from "./tam.ts";
100
142
  export {
101
143
  apolloPullKeysForAppend,
102
144
  apolloPullKeysForRefresh,
package/src/init.ts ADDED
@@ -0,0 +1,166 @@
1
+ /**
2
+ * `fullstackgtm init` — scaffold a GTM workspace from cold scratch.
3
+ *
4
+ * The product thesis (see docs/recipes.md): the CLI ships governed PRIMITIVES;
5
+ * the user's coding agent is the orchestrator. There is deliberately no
6
+ * `outbound` mega-verb. `init` is the one piece of scaffolding that earns its
7
+ * keep — it writes the three files a workspace needs so the very first
8
+ * `enrich acquire` / `signals` / `judge` / `draft` commands work, plus a
9
+ * PLAYBOOK that points at the recipes wired with THIS workspace's source and
10
+ * provider.
11
+ *
12
+ * It is a pure file-writer: no network, no credentials, never overwrites
13
+ * without `--force`. The starter ICP is a valid (editable) example so
14
+ * `icp show` / `enrich acquire` run immediately; re-run `icp interview` to
15
+ * replace it with a real one.
16
+ */
17
+
18
+ import { builtinAcquirePreset, ENRICH_CONFIG_FILE_NAME, type EnrichConfig } from "./enrich.ts";
19
+ import { DEFAULT_FIT_THRESHOLD, type Icp } from "./icp.ts";
20
+
21
+ export type InitProvider = "hubspot" | "salesforce";
22
+ export type InitSource = "pipe0" | "explorium" | "linkedin";
23
+
24
+ export type ScaffoldOptions = {
25
+ /** discovery source the acquire preset + playbook are wired for. Default "pipe0". */
26
+ source?: InitSource;
27
+ /** CRM the playbook writes against. Default "hubspot". */
28
+ provider?: InitProvider;
29
+ /** profile the playbook scopes commands to (omitted = default profile). */
30
+ profile?: string;
31
+ };
32
+
33
+ export type ScaffoldFile = { path: string; content: string };
34
+
35
+ /** A placeholder owner id that can never match a real one, so the assign block
36
+ * is VISIBLE (leads route through it) yet safe: an unknown owner resolves to
37
+ * unassigned with a warning, never a wrong owner. Replace before acquiring. */
38
+ const PLACEHOLDER_OWNER = "REPLACE_WITH_OWNER_ID";
39
+
40
+ /** A generic, valid starter ICP. Edit it, or replace via `icp interview`. */
41
+ export function starterIcp(): Icp {
42
+ return {
43
+ name: "Example ICP — edit me (or rebuild with `fullstackgtm icp interview`)",
44
+ firmographics: {
45
+ industries: ["software", "saas"],
46
+ employeeBands: ["51-200", "201-500", "501-1000"],
47
+ geos: ["us"],
48
+ // Technographic targeting (the real RevOps signal): companies that USE a
49
+ // CRM/MAP. Drives `tam estimate|accounts --source theirstack`.
50
+ technologies: ["salesforce", "hubspot", "pipedrive"],
51
+ },
52
+ persona: {
53
+ jobLevels: ["vp", "director", "manager"],
54
+ departments: ["sales", "operations"],
55
+ titleKeywords: ["revenue operations", "revops", "sales operations", "gtm operations"],
56
+ },
57
+ scoring: { threshold: DEFAULT_FIT_THRESHOLD },
58
+ };
59
+ }
60
+
61
+ /** The acquire preset for the chosen source, with an explicit (placeholder)
62
+ * assign policy so the seam is visible — leads are never silently ownerless. */
63
+ export function starterEnrichConfig(source: InitSource): EnrichConfig {
64
+ const preset = builtinAcquirePreset(source);
65
+ if (!preset?.acquire) {
66
+ // builtinAcquirePreset covers pipe0/explorium/linkedin, so this is unreachable
67
+ // for the typed InitSource set — guard anyway rather than emit a broken file.
68
+ throw new Error(`init: no acquire preset for source "${source}"`);
69
+ }
70
+ return {
71
+ ...preset,
72
+ acquire: { ...preset.acquire, assign: { strategy: "fixed", ownerId: PLACEHOLDER_OWNER } },
73
+ };
74
+ }
75
+
76
+ function profileFlag(profile?: string): string {
77
+ return profile ? ` --profile ${profile}` : "";
78
+ }
79
+
80
+ /** The workspace PLAYBOOK: the cold-start + outbound-loop recipes wired with the
81
+ * chosen source/provider/profile, pointing at docs/recipes.md for the rest. */
82
+ export function starterPlaybook(opts: Required<Pick<ScaffoldOptions, "source" | "provider">> & { profile?: string }): string {
83
+ const { source, provider, profile } = opts;
84
+ const p = profileFlag(profile);
85
+ const loginSource = source === "linkedin" ? "heyreach" : source;
86
+ return `# Workspace playbook
87
+
88
+ Scaffolded by \`fullstackgtm init\` for **${provider}**, discovery via **${source}**${
89
+ profile ? `, profile **${profile}**` : ""
90
+ }.
91
+
92
+ This CLI ships governed **primitives** — there is no \`outbound\` command. **You
93
+ (the coding agent) are the orchestrator:** chain the verbs into the play the
94
+ operator wants, surface the one approve gate, and bridge the last mile to the
95
+ sender. **The package never sends** — \`draft\` produces an approved task/opener;
96
+ the actual send happens in the operator's own channel tool.
97
+
98
+ Full recipe set: **docs/recipes.md** (cold-start, the trigger→judge→draft
99
+ outbound loop, scheduled-continuous, ABM-from-companies, hygiene-gated).
100
+
101
+ ## 0. Connect (secrets via stdin/env, never argv)
102
+
103
+ \`\`\`bash
104
+ echo "$${provider === "hubspot" ? "HUBSPOT_TOKEN" : "SALESFORCE_ACCESS_TOKEN"}" | fullstackgtm login ${provider}${p}
105
+ echo "$${loginSource.toUpperCase()}_API_KEY" | fullstackgtm login ${loginSource}${p}
106
+ \`\`\`
107
+
108
+ ## 1. Edit your targeting + assignment
109
+
110
+ - \`icp.json\` — the starter ICP. Edit it, or rebuild: \`fullstackgtm icp interview\`
111
+ (an agent drives the questions) → \`fullstackgtm icp set answers.json\`.
112
+ - \`${ENRICH_CONFIG_FILE_NAME}\` — set \`acquire.assign.ownerId\` (currently
113
+ \`${PLACEHOLDER_OWNER}\`) so acquired leads are never ownerless, or pass
114
+ \`--assign-owner <id>\` per run. Tune \`acquire.budget\` (records + spend caps).
115
+
116
+ ## 2. Cold start — fill the CRM with targeted, owned, emailed leads
117
+
118
+ \`\`\`bash
119
+ fullstackgtm enrich acquire --source ${source} --provider ${provider}${p} --json # dry-run plan, writes NOTHING
120
+ fullstackgtm enrich acquire --source ${source} --provider ${provider}${p} --save # persist as needs_approval
121
+ fullstackgtm plans approve <plan-id>${p} --operations all
122
+ fullstackgtm apply --plan-id <plan-id> --provider ${provider}${p}
123
+ \`\`\`
124
+
125
+ Each lead lands owner-stamped and linked to a domain-stamped **account**, so the
126
+ signals/judge layer can watch it.
127
+
128
+ ## 3. Outbound loop — reach an account the week something changes
129
+
130
+ \`\`\`bash
131
+ fullstackgtm signals fetch --bucket job,funding --watchlist crm:<segment>${p} --save
132
+ fullstackgtm icp judge --signals-from latest --provider ${provider}${p} --save --json # pass the snapshot → resolves accountId + contact
133
+ fullstackgtm draft --from-judge latest --channel email${p} --save --json # one grounded opener per hot account, as a create_task
134
+ fullstackgtm plans approve <plan-id>${p} --operations all
135
+ fullstackgtm apply --plan-id <plan-id> --provider ${provider}${p}
136
+ # → the agent sends the approved opener via the operator's channel tool, then:
137
+ fullstackgtm signals outcome --account <domain> --contact <contactId> --result replied${p}
138
+ \`\`\`
139
+
140
+ A domain-only judge decision (account not yet in the CRM) is rejected by \`draft\`
141
+ with "acquire it first" — run step 2 for that account, then re-judge.
142
+
143
+ ## The boundary
144
+
145
+ - **Read freely. Write only through \`plans approve → apply\`.**
146
+ - **The package never sends.** \`draft\` is the last governed step.
147
+ - **You are the orchestrator.** These are starting points — compose them.
148
+ `;
149
+ }
150
+
151
+ /**
152
+ * Build the set of files `init` would write (pure — no IO). The caller decides
153
+ * which to actually write (skipping existing files unless --force).
154
+ */
155
+ export function scaffoldWorkspace(opts: ScaffoldOptions = {}): ScaffoldFile[] {
156
+ const source: InitSource = opts.source ?? "pipe0";
157
+ const provider: InitProvider = opts.provider ?? "hubspot";
158
+ return [
159
+ { path: "icp.json", content: `${JSON.stringify(starterIcp(), null, 2)}\n` },
160
+ {
161
+ path: ENRICH_CONFIG_FILE_NAME,
162
+ content: `${JSON.stringify(starterEnrichConfig(source), null, 2)}\n`,
163
+ },
164
+ { path: "PLAYBOOK.md", content: starterPlaybook({ source, provider, profile: opts.profile }) },
165
+ ];
166
+ }
package/src/judge.ts CHANGED
@@ -35,6 +35,23 @@ export type JudgeDecisionKind = "send" | "nurture" | "skip";
35
35
 
36
36
  export type JudgeDecision = {
37
37
  accountDomain: string;
38
+ /**
39
+ * The CRM account this domain resolves to, when a snapshot was provided.
40
+ * Absent = the account is not in the CRM (a net-new domain) — downstream
41
+ * verbs must acquire it before they can write against it.
42
+ */
43
+ accountId?: string;
44
+ /**
45
+ * The contact at the account to reach, when resolvable from the snapshot —
46
+ * the answer to "who do I message at this hot account". `draft` targets this
47
+ * contact's id; absent contact + present accountId targets the account.
48
+ */
49
+ contact?: ContactRef;
50
+ /**
51
+ * All in-CRM contacts at the account (primary first), capped — so an agent can
52
+ * multi-thread beyond the single primary. `contact` is `contacts[0]`.
53
+ */
54
+ contacts?: ContactRef[];
38
55
  /** 0-100. */
39
56
  score: number;
40
57
  decision: JudgeDecisionKind;
@@ -333,22 +350,75 @@ export function accountRecentlyTouched(
333
350
  }
334
351
 
335
352
  /**
336
- * Find the best-matching contact for an account from the snapshot, for fit
337
- * scoring: the account's contacts, preferring one with a title (a title is what
338
- * fit scores on). Returns undefined when the account/contact isn't in snapshot.
353
+ * Resolve a signal's account domain to the CRM account record and the best
354
+ * contact at it (preferring one with a title). The single domain→account→contact
355
+ * join used by both fit scoring and target surfacing so "who do I message at
356
+ * this account" is computed once and consistently.
339
357
  */
340
- export function bestContactForAccount(
358
+ function findAccountAndBestContact(
341
359
  accountDomain: string,
342
360
  snapshot: CanonicalGtmSnapshot,
343
- ): { jobTitle?: string; jobLevel?: string; jobDepartment?: string; headline?: string } | undefined {
361
+ ): {
362
+ account: CanonicalGtmSnapshot["accounts"][number];
363
+ contact?: CanonicalGtmSnapshot["contacts"][number];
364
+ contacts: CanonicalGtmSnapshot["contacts"];
365
+ } | undefined {
344
366
  const domain = normalizeAccountDomain(accountDomain);
345
367
  if (!domain) return undefined;
346
368
  const account = (snapshot.accounts ?? []).find((a) => normalizeAccountDomain(a.domain ?? "") === domain);
347
369
  if (!account) return undefined;
348
- const contacts = (snapshot.contacts ?? []).filter((c) => c.accountId === account.id);
349
- const withTitle = contacts.find((c) => c.title) ?? contacts[0];
350
- if (!withTitle) return undefined;
351
- return { jobTitle: withTitle.title };
370
+ // Titled contacts first (a title is what fit scores on); the first is primary.
371
+ const contacts = (snapshot.contacts ?? [])
372
+ .filter((c) => c.accountId === account.id)
373
+ .sort((a, b) => (b.title ? 1 : 0) - (a.title ? 1 : 0));
374
+ return { account, contact: contacts[0], contacts };
375
+ }
376
+
377
+ /**
378
+ * Best-matching contact for an account, shaped for fit scoring (the title is
379
+ * what `scoreProspectAgainstIcp` reads). Returns undefined when the
380
+ * account/contact isn't in the snapshot.
381
+ */
382
+ export function bestContactForAccount(
383
+ accountDomain: string,
384
+ snapshot: CanonicalGtmSnapshot,
385
+ ): { jobTitle?: string; jobLevel?: string; jobDepartment?: string; headline?: string } | undefined {
386
+ const found = findAccountAndBestContact(accountDomain, snapshot);
387
+ if (!found?.contact) return undefined;
388
+ return { jobTitle: found.contact.title };
389
+ }
390
+
391
+ /**
392
+ * Resolve the CRM target for an account domain: its `accountId` (when the
393
+ * account exists in the snapshot) and the best `contact` to reach (id + email +
394
+ * title). This is what `draft` writes against — a real record id, never the
395
+ * domain. `{}` when the account is not in the CRM (a net-new domain).
396
+ */
397
+ export type ContactRef = { id: string; email?: string; title?: string };
398
+
399
+ /** Cap on candidate contacts surfaced per account (the rest stay in the CRM). */
400
+ const MAX_CANDIDATE_CONTACTS = 10;
401
+
402
+ const toContactRef = (c: CanonicalGtmSnapshot["contacts"][number]): ContactRef => ({
403
+ id: c.id,
404
+ ...(c.email ? { email: c.email } : {}),
405
+ ...(c.title ? { title: c.title } : {}),
406
+ });
407
+
408
+ export function resolveAccountTarget(
409
+ accountDomain: string,
410
+ snapshot: CanonicalGtmSnapshot,
411
+ ): { accountId?: string; contact?: ContactRef; contacts?: ContactRef[] } {
412
+ const found = findAccountAndBestContact(accountDomain, snapshot);
413
+ if (!found) return {};
414
+ const { account, contact, contacts } = found;
415
+ return {
416
+ accountId: account.id,
417
+ ...(contact ? { contact: toContactRef(contact) } : {}),
418
+ // The candidate contacts at the account, so an agent can multi-thread
419
+ // beyond the single primary. Capped; primary is contacts[0].
420
+ ...(contacts.length ? { contacts: contacts.slice(0, MAX_CANDIDATE_CONTACTS).map(toContactRef) } : {}),
421
+ };
352
422
  }
353
423
 
354
424
  // ---------------------------------------------------------------------------
@@ -559,10 +629,11 @@ export async function judgeSignals(opts: {
559
629
 
560
630
  const decisions: JudgeDecision[] = [];
561
631
  for (const [domain, signals] of byAccount) {
632
+ // Memory + fit stay gated on --with-history (scoring semantics unchanged).
562
633
  const recentlyTouched =
563
634
  opts.withHistory && opts.snapshot ? accountRecentlyTouched(domain, opts.snapshot, now) : false;
564
635
  const bestContact =
565
- opts.icp && opts.snapshot ? bestContactForAccount(domain, opts.snapshot) : undefined;
636
+ opts.withHistory && opts.icp && opts.snapshot ? bestContactForAccount(domain, opts.snapshot) : undefined;
566
637
  const score = scoreAccount({
567
638
  accountDomain: domain,
568
639
  signals,
@@ -578,7 +649,10 @@ export async function judgeSignals(opts: {
578
649
  opts.llm && opts.promptTemplate
579
650
  ? await judgeDecisionLlm(score, opts.promptTemplate, opts.llm)
580
651
  : deterministicDecision(score);
581
- decisions.push(decision);
652
+ // Surface the CRM target (accountId + best contact) whenever a snapshot is
653
+ // present — independent of --with-history. This is what makes a decision
654
+ // actionable: draft writes against contact.id / accountId, never the domain.
655
+ decisions.push(opts.snapshot ? { ...decision, ...resolveAccountTarget(domain, opts.snapshot) } : decision);
582
656
  }
583
657
 
584
658
  return decisions.sort((a, b) => b.score - a.score || a.accountDomain.localeCompare(b.accountDomain));
package/src/judgeEval.ts CHANGED
@@ -205,7 +205,14 @@ export function defaultJudgeFn(opts: { config?: SignalsConfig; icp?: Icp; now?:
205
205
  // ---------------------------------------------------------------------------
206
206
  // The default golden set (ships inline so `icp eval --golden default` is free)
207
207
 
208
- const GOLD_NOW_ISO = "2026-06-23T12:00:00.000Z";
208
+ /**
209
+ * The default golden set's clock. Every `firstSeen` below is relative to this
210
+ * instant, so grading MUST pin `now: new Date(DEFAULT_GOLDEN_NOW_ISO)` —
211
+ * grading against wall time lets freshness decay rot the "fresh → send" rows,
212
+ * and the gate starts failing on a calendar date instead of a code change.
213
+ */
214
+ export const DEFAULT_GOLDEN_NOW_ISO = "2026-06-23T12:00:00.000Z";
215
+ const GOLD_NOW_ISO = DEFAULT_GOLDEN_NOW_ISO;
209
216
 
210
217
  function goldSignal(over: {
211
218
  accountDomain: string;
package/src/runReport.ts CHANGED
@@ -11,7 +11,34 @@ import { getCredential } from "./credentials.ts";
11
11
 
12
12
  type RunEvent = { ts: number; type: string; detail?: string };
13
13
 
14
+ /**
15
+ * Per-row finding for the hosted run timeline. IDs + issue type ONLY — no field
16
+ * values ever leave the CLI, keeping the audit's row data on the operator's side
17
+ * while still giving the dashboard navigable, filterable detail.
18
+ */
19
+ export type RunFinding = {
20
+ objectType: string;
21
+ objectId: string;
22
+ severity: string;
23
+ ruleId: string;
24
+ field?: string;
25
+ operation?: string;
26
+ };
27
+
28
+ /**
29
+ * Where a run's findings live in the source CRM, so the dashboard can deep-link
30
+ * each record. `recordUrlBase` is the provider's record URL prefix; the
31
+ * dashboard appends the per-object path. Just an URL prefix — no record data.
32
+ */
33
+ export type RunCrm = { provider: string; recordUrlBase: string };
34
+
35
+ // Bound the fire-and-forget POST: a pathological audit could surface thousands
36
+ // of findings; cap what we ship (counts still carry the true total).
37
+ const MAX_REPORTED_FINDINGS = 2000;
38
+
14
39
  let counts: Record<string, unknown> | undefined;
40
+ let findings: RunFinding[] | undefined;
41
+ let crm: RunCrm | undefined;
15
42
  const events: RunEvent[] = [];
16
43
 
17
44
  /** A command annotates its headline metrics (merged). */
@@ -19,6 +46,16 @@ export function reportCounts(values: Record<string, unknown>): void {
19
46
  counts = { ...(counts ?? {}), ...values };
20
47
  }
21
48
 
49
+ /** A command reports per-row findings (IDs + issue type, no values). */
50
+ export function reportFindings(values: RunFinding[]): void {
51
+ findings = values.slice(0, MAX_REPORTED_FINDINGS);
52
+ }
53
+
54
+ /** A command reports the source CRM's record-URL base for dashboard deep-links. */
55
+ export function reportCrm(value: RunCrm): void {
56
+ crm = value;
57
+ }
58
+
22
59
  /** A command annotates a structured event (plan saved, meter charged, …). */
23
60
  export function reportEvent(type: string, detail?: string): void {
24
61
  events.push({ ts: Date.now(), type, detail });
@@ -65,6 +102,8 @@ export async function flushRunReport(
65
102
  finishedAt,
66
103
  durationMs: finishedAt - startedAt,
67
104
  counts,
105
+ findings: findings?.length ? findings : undefined,
106
+ crm,
68
107
  events: events.length ? events : undefined,
69
108
  error,
70
109
  }),