fullstackgtm 0.33.0 → 0.37.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 +102 -0
- package/README.md +25 -0
- package/dist/acquireMeter.d.ts +67 -0
- package/dist/acquireMeter.js +145 -0
- package/dist/acquireSeen.d.ts +5 -0
- package/dist/acquireSeen.js +54 -0
- package/dist/cli.js +732 -21
- package/dist/connectors/hubspot.js +135 -0
- package/dist/connectors/prospectSources.d.ts +91 -0
- package/dist/connectors/prospectSources.js +227 -0
- package/dist/enrich.d.ts +87 -0
- package/dist/enrich.js +188 -4
- package/dist/format.d.ts +3 -1
- package/dist/format.js +14 -2
- package/dist/health.d.ts +71 -0
- package/dist/health.js +172 -0
- package/dist/icp.d.ts +96 -0
- package/dist/icp.js +256 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/mappings.js +3 -0
- package/dist/marketSourcing.d.ts +25 -0
- package/dist/marketSourcing.js +82 -0
- package/dist/types.d.ts +17 -1
- package/docs/api.md +29 -2
- package/docs/architecture.md +8 -1
- package/docs/dx-punch-list.md +87 -0
- package/llms.txt +16 -0
- package/package.json +1 -1
- package/skills/fullstackgtm/SKILL.md +2 -1
- package/src/acquireMeter.ts +186 -0
- package/src/acquireSeen.ts +57 -0
- package/src/cli.ts +870 -20
- package/src/connectors/hubspot.ts +140 -0
- package/src/connectors/prospectSources.ts +324 -0
- package/src/enrich.ts +275 -3
- package/src/format.ts +20 -2
- package/src/health.ts +238 -0
- package/src/icp.ts +313 -0
- package/src/index.ts +11 -0
- package/src/mappings.ts +3 -0
- package/src/marketSourcing.ts +104 -0
- package/src/types.ts +24 -0
package/dist/icp.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ICP — the Ideal Customer Profile that makes `enrich acquire` targeted instead
|
|
3
|
+
* of random. One structured artifact drives two things:
|
|
4
|
+
* 1. discovery filters — translated per provider (Explorium, pipe0/Crustdata)
|
|
5
|
+
* so the pull only returns ICP-shaped companies + people, and
|
|
6
|
+
* 2. fit scoring — every discovered prospect is scored against the persona so
|
|
7
|
+
* only above-threshold leads become create_record ops.
|
|
8
|
+
*
|
|
9
|
+
* Develop one via interview: an agent (Claude Code / Codex) reads INTERVIEW_SPEC,
|
|
10
|
+
* asks the questions with its AskUserQuestion tool, and writes the answers into
|
|
11
|
+
* an Icp (CLI: `icp interview` emits the spec, `icp show` renders the result).
|
|
12
|
+
*
|
|
13
|
+
* Zero runtime deps; pure functions (translation + scoring take plain data).
|
|
14
|
+
*/
|
|
15
|
+
export const DEFAULT_FIT_THRESHOLD = 0.5;
|
|
16
|
+
const COUNTRY_NAMES = {
|
|
17
|
+
us: "United States",
|
|
18
|
+
ca: "Canada",
|
|
19
|
+
gb: "United Kingdom",
|
|
20
|
+
uk: "United Kingdom",
|
|
21
|
+
};
|
|
22
|
+
export function parseIcp(raw) {
|
|
23
|
+
let parsed;
|
|
24
|
+
try {
|
|
25
|
+
parsed = JSON.parse(raw);
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
throw new Error(`icp: not valid JSON (${error instanceof Error ? error.message : String(error)})`);
|
|
29
|
+
}
|
|
30
|
+
if (!parsed || typeof parsed !== "object")
|
|
31
|
+
throw new Error("icp: expected a JSON object");
|
|
32
|
+
const icp = parsed;
|
|
33
|
+
if (!icp.name || typeof icp.name !== "string")
|
|
34
|
+
throw new Error('icp: missing "name"');
|
|
35
|
+
if (!icp.persona || (!icp.persona.titleKeywords?.length && !icp.persona.jobLevels?.length)) {
|
|
36
|
+
throw new Error('icp: "persona" needs at least titleKeywords or jobLevels (without them, scoring cannot rank fit)');
|
|
37
|
+
}
|
|
38
|
+
return icp;
|
|
39
|
+
}
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Discovery-filter translation
|
|
42
|
+
/** Explorium /v1/prospects filters from the ICP. */
|
|
43
|
+
export function icpToExploriumFilters(icp) {
|
|
44
|
+
const f = { has_email: { value: true } };
|
|
45
|
+
if (icp.persona.jobLevels?.length)
|
|
46
|
+
f.job_level = { values: icp.persona.jobLevels };
|
|
47
|
+
if (icp.persona.departments?.length)
|
|
48
|
+
f.job_department = { values: icp.persona.departments };
|
|
49
|
+
if (icp.firmographics.employeeBands?.length)
|
|
50
|
+
f.company_size = { values: icp.firmographics.employeeBands };
|
|
51
|
+
if (icp.firmographics.geos?.length)
|
|
52
|
+
f.company_country_code = { values: icp.firmographics.geos };
|
|
53
|
+
if (icp.firmographics.naics?.length)
|
|
54
|
+
f.naics_category = { values: icp.firmographics.naics };
|
|
55
|
+
return f;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Crustdata / LinkedIn seniority vocab. ICP job levels are normalized lowercase;
|
|
59
|
+
* Crustdata expects these exact capitalized strings (verified: "CXO",
|
|
60
|
+
* "Vice President", "Director" appear in Crustdata's docs/examples).
|
|
61
|
+
*/
|
|
62
|
+
const CRUSTDATA_SENIORITY = {
|
|
63
|
+
cxo: "CXO",
|
|
64
|
+
"c-suite": "CXO",
|
|
65
|
+
c_suite: "CXO",
|
|
66
|
+
founder: "Owner",
|
|
67
|
+
owner: "Owner",
|
|
68
|
+
partner: "Partner",
|
|
69
|
+
vp: "Vice President",
|
|
70
|
+
"vice president": "Vice President",
|
|
71
|
+
head: "Director",
|
|
72
|
+
director: "Director",
|
|
73
|
+
manager: "Manager",
|
|
74
|
+
senior: "Senior",
|
|
75
|
+
entry: "Entry",
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* ICP industry keyword → LinkedIn industry names Crustdata filters on. Includes
|
|
79
|
+
* the current LinkedIn-v2 names; for software we send the cluster (dev + IT
|
|
80
|
+
* services + internet) so an OR match isn't overly narrow.
|
|
81
|
+
*/
|
|
82
|
+
const CRUSTDATA_INDUSTRY = {
|
|
83
|
+
software: ["Software Development", "Information Technology and Services", "Internet"],
|
|
84
|
+
saas: ["Software Development", "Information Technology and Services"],
|
|
85
|
+
"information technology & services": ["Information Technology and Services"],
|
|
86
|
+
"information technology and services": ["Information Technology and Services"],
|
|
87
|
+
internet: ["Internet"],
|
|
88
|
+
fintech: ["Financial Services"],
|
|
89
|
+
"financial services": ["Financial Services"],
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* pipe0 Crustdata people-search config.filters from the ICP. `current_job_titles`
|
|
93
|
+
* matches real LinkedIn title strings (case-sensitive), so keywords are Title
|
|
94
|
+
* Cased. Seniority + industry are mapped to Crustdata's controlled vocab via the
|
|
95
|
+
* tables above. NOTE: the exact pipe0→Crustdata value set could not be re-run
|
|
96
|
+
* live (pipe0 credits were exhausted) — validate when credits refill; fit
|
|
97
|
+
* scoring is the safety net for persona precision regardless.
|
|
98
|
+
*/
|
|
99
|
+
export function icpToCrustdataFilters(icp) {
|
|
100
|
+
const f = {};
|
|
101
|
+
if (icp.persona.titleKeywords?.length)
|
|
102
|
+
f.current_job_titles = icp.persona.titleKeywords.map(titleCase);
|
|
103
|
+
if (icp.firmographics.geos?.length) {
|
|
104
|
+
f.locations = icp.firmographics.geos.map((g) => COUNTRY_NAMES[g.toLowerCase()] ?? g);
|
|
105
|
+
}
|
|
106
|
+
if (icp.persona.jobLevels?.length) {
|
|
107
|
+
const include = [...new Set(icp.persona.jobLevels.map((l) => CRUSTDATA_SENIORITY[l.toLowerCase()]).filter(Boolean))];
|
|
108
|
+
if (include.length)
|
|
109
|
+
f.current_seniority_levels = { include, exclude: [] };
|
|
110
|
+
}
|
|
111
|
+
if (icp.firmographics.industries?.length) {
|
|
112
|
+
const inds = [
|
|
113
|
+
...new Set(icp.firmographics.industries.flatMap((i) => CRUSTDATA_INDUSTRY[i.toLowerCase()] ?? [titleCase(i)])),
|
|
114
|
+
];
|
|
115
|
+
if (inds.length)
|
|
116
|
+
f.current_employers_linkedin_industries = inds;
|
|
117
|
+
}
|
|
118
|
+
return f;
|
|
119
|
+
}
|
|
120
|
+
const ACRONYMS = new Set(["cro", "coo", "ceo", "cfo", "vp", "crm", "gtm", "saas"]);
|
|
121
|
+
function titleCase(value) {
|
|
122
|
+
return value
|
|
123
|
+
.split(/\s+/)
|
|
124
|
+
.map((w) => (ACRONYMS.has(w.toLowerCase()) ? w.toUpperCase() : w.charAt(0).toUpperCase() + w.slice(1)))
|
|
125
|
+
.join(" ");
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Score a prospect's PERSONA fit 0..1. Title-keyword match is the strongest
|
|
129
|
+
* signal (it's what defines the buyer); job level and department add to it.
|
|
130
|
+
* Firmographics aren't re-scored here — discovery already filtered on them.
|
|
131
|
+
*/
|
|
132
|
+
export function scoreProspectAgainstIcp(prospect, icp) {
|
|
133
|
+
const reasons = [];
|
|
134
|
+
const title = (prospect.jobTitle ?? "").toLowerCase();
|
|
135
|
+
const keywords = (icp.persona.titleKeywords ?? []).map((k) => k.toLowerCase());
|
|
136
|
+
const levels = (icp.persona.jobLevels ?? []).map((l) => l.toLowerCase());
|
|
137
|
+
const depts = (icp.persona.departments ?? []).map((d) => d.toLowerCase());
|
|
138
|
+
let score = 0;
|
|
139
|
+
let weightSum = 0;
|
|
140
|
+
if (keywords.length) {
|
|
141
|
+
weightSum += 0.6;
|
|
142
|
+
const hit = keywords.find((k) => title.includes(k));
|
|
143
|
+
if (hit) {
|
|
144
|
+
score += 0.6;
|
|
145
|
+
reasons.push(`title matches ICP keyword "${hit}"`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (levels.length) {
|
|
149
|
+
weightSum += 0.25;
|
|
150
|
+
const level = (prospect.jobLevel ?? "").toLowerCase();
|
|
151
|
+
if (level && levels.some((l) => level.includes(l))) {
|
|
152
|
+
score += 0.25;
|
|
153
|
+
reasons.push(`seniority "${prospect.jobLevel}" in ICP levels`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (depts.length) {
|
|
157
|
+
weightSum += 0.15;
|
|
158
|
+
const dept = (prospect.jobDepartment ?? "").toLowerCase();
|
|
159
|
+
if (dept && depts.some((d) => dept.includes(d))) {
|
|
160
|
+
score += 0.15;
|
|
161
|
+
reasons.push(`department "${prospect.jobDepartment}" in ICP`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const normalized = weightSum > 0 ? score / weightSum : 0;
|
|
165
|
+
if (reasons.length === 0)
|
|
166
|
+
reasons.push("no persona signal matched the ICP");
|
|
167
|
+
return { score: Number(normalized.toFixed(3)), reasons };
|
|
168
|
+
}
|
|
169
|
+
export function fitThreshold(icp) {
|
|
170
|
+
return icp.scoring?.threshold ?? DEFAULT_FIT_THRESHOLD;
|
|
171
|
+
}
|
|
172
|
+
export const INTERVIEW_SPEC = [
|
|
173
|
+
{
|
|
174
|
+
id: "industries",
|
|
175
|
+
header: "Target market",
|
|
176
|
+
question: "What company profile are you targeting? (Drives firmographic discovery filters.)",
|
|
177
|
+
multiSelect: true,
|
|
178
|
+
options: [
|
|
179
|
+
{ label: "B2B SaaS / software", value: ["software", "saas"], description: "Software & internet — classic messy-CRM, RevOps-equipped buyer." },
|
|
180
|
+
{ label: "Broader tech-enabled B2B", value: ["software", "information technology & services", "financial services"], description: "Any B2B running a real sales motion on a CRM." },
|
|
181
|
+
{ label: "Any B2B with a CRM", value: [], description: "Vertical-agnostic — qualify on persona, not industry." },
|
|
182
|
+
],
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
id: "employeeBands",
|
|
186
|
+
header: "Company size",
|
|
187
|
+
question: "Company size (employee bands) that fit?",
|
|
188
|
+
multiSelect: true,
|
|
189
|
+
options: [
|
|
190
|
+
{ label: "Seed–Series A (1–50)", value: ["1-10", "11-50"], description: "Early; often no dedicated RevOps yet." },
|
|
191
|
+
{ label: "Series B–C (51–500)", value: ["51-200", "201-500"], description: "Sweet spot: messy CRM, RevOps exists, budget exists." },
|
|
192
|
+
{ label: "Growth (501–2000)", value: ["501-1000", "1001-5000"], description: "Established RevOps, many CRM writers." },
|
|
193
|
+
{ label: "Enterprise (2000+)", value: ["1001-5000", "5001-10000", "10001+"], description: "Up-market; longer cycles." },
|
|
194
|
+
],
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
id: "titleKeywords",
|
|
198
|
+
header: "Buyer persona",
|
|
199
|
+
question: "Which buyer persona(s) should discovery target and scoring reward?",
|
|
200
|
+
multiSelect: true,
|
|
201
|
+
options: [
|
|
202
|
+
{ label: "RevOps leadership", value: ["revenue operations", "revops", "head of revenue operations"], description: "VP/Head/Dir Revenue Operations." },
|
|
203
|
+
{ label: "Sales/Marketing Ops", value: ["sales operations", "gtm operations", "marketing operations", "revenue ops"], description: "Hands-on the CRM daily." },
|
|
204
|
+
{ label: "Exec buyers", value: ["chief revenue officer", "cro", "chief operating officer", "vp of sales"], description: "Economic sign-off." },
|
|
205
|
+
{ label: "CRM/SF admins", value: ["salesforce admin", "hubspot admin", "crm manager", "revops analyst"], description: "Feel the pain, champion the fix." },
|
|
206
|
+
],
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
id: "geos",
|
|
210
|
+
header: "Geography",
|
|
211
|
+
question: "Geography?",
|
|
212
|
+
multiSelect: true,
|
|
213
|
+
options: [
|
|
214
|
+
{ label: "United States", value: ["us"], description: "US-only (best data coverage)." },
|
|
215
|
+
{ label: "US + Canada", value: ["us", "ca"], description: "North America." },
|
|
216
|
+
{ label: "US + UK/EU", value: ["us", "gb"], description: "English-speaking + Western Europe (mind GDPR)." },
|
|
217
|
+
{ label: "Global", value: [], description: "No geo filter." },
|
|
218
|
+
],
|
|
219
|
+
},
|
|
220
|
+
];
|
|
221
|
+
/** Assemble an Icp from flattened interview answers. */
|
|
222
|
+
export function icpFromAnswers(name, answers) {
|
|
223
|
+
const levelsFromTitles = inferLevels(answers.titleKeywords);
|
|
224
|
+
return {
|
|
225
|
+
name,
|
|
226
|
+
firmographics: {
|
|
227
|
+
industries: dedupe(answers.industries),
|
|
228
|
+
naics: answers.industries.some((i) => /software|saas/i.test(i)) ? ["5112"] : undefined,
|
|
229
|
+
employeeBands: dedupe(answers.employeeBands),
|
|
230
|
+
geos: dedupe(answers.geos),
|
|
231
|
+
},
|
|
232
|
+
persona: {
|
|
233
|
+
jobLevels: answers.jobLevels?.length ? dedupe(answers.jobLevels) : levelsFromTitles,
|
|
234
|
+
departments: answers.departments?.length ? dedupe(answers.departments) : ["sales", "marketing", "operations"],
|
|
235
|
+
titleKeywords: dedupe(answers.titleKeywords),
|
|
236
|
+
},
|
|
237
|
+
scoring: { threshold: DEFAULT_FIT_THRESHOLD },
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
function inferLevels(titles) {
|
|
241
|
+
const levels = new Set();
|
|
242
|
+
for (const t of titles.map((x) => x.toLowerCase())) {
|
|
243
|
+
if (/chief|^c[a-z]o$|cro|coo/.test(t))
|
|
244
|
+
levels.add("cxo");
|
|
245
|
+
if (/vp|vice president|head of/.test(t))
|
|
246
|
+
levels.add("vp");
|
|
247
|
+
if (/director/.test(t))
|
|
248
|
+
levels.add("director");
|
|
249
|
+
if (/manager|admin|analyst|operations/.test(t))
|
|
250
|
+
levels.add("manager");
|
|
251
|
+
}
|
|
252
|
+
return levels.size ? [...levels] : ["cxo", "vp", "director", "manager"];
|
|
253
|
+
}
|
|
254
|
+
function dedupe(values) {
|
|
255
|
+
return [...new Set((values ?? []).filter(Boolean))];
|
|
256
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -19,6 +19,7 @@ export { createFilePlanStore, type PlanStore, type StoredPlan } from "./planStor
|
|
|
19
19
|
export { computeApprovalDigests, loadOrCreateSigningKey, loadSigningKey, signApproval, verifyApprovalDigests, type ApprovalVerification, } from "./integrity.ts";
|
|
20
20
|
export { buildAuditLog, verifyAuditLog, type AuditLogEntry, type AuditLogExport, type AuditLogVerification, } from "./auditLog.ts";
|
|
21
21
|
export { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
|
|
22
|
+
export { computeHealth, summarizeHealth, healthToMarkdown, type HealthEntry, type HealthRollup, type HealthRuleDelta, } from "./health.ts";
|
|
22
23
|
export { auditReportToHtml, auditReportToMarkdown, type ReportOptions } from "./report.ts";
|
|
23
24
|
export { HUBSPOT_DEFAULT_FIELD_MAPPINGS, SALESFORCE_DEFAULT_FIELD_MAPPINGS, mappedField, mappedFields, normalizeFieldMappings, readMappedValue, type CrmObjectType, type FieldMappings, } from "./mappings.ts";
|
|
24
25
|
export { accountSingleSourceRule, activeDealAccountWithoutContactsRule, auditFindingId, buildSnapshotIndex, builtinAuditRules, closingSoonInactiveRule, duplicateAccountDomainRule, duplicateContactEmailRule, duplicateOpenDealRule, missingDealAccountRule, missingDealAmountRule, missingDealOwnerRule, orphanAccountRule, pastCloseDateRule, patchOperationId, provenanceSummary, requiresHumanInput, staleDealRule, } from "./rules.ts";
|
|
@@ -34,7 +35,7 @@ export { buildWorksheet, classifyMarket, type ClassifyMarketOptions, type Classi
|
|
|
34
35
|
export { computeDirectives, computeOverlayStats, directivesToPlan, overlayToMarkdown, type CallDocument, type ClaimMentionStats, type DirectiveStat, type DirectiveType, type MarketDirective, type OverlayOptions, type OverlayStats, type VendorMentionStats, } from "./marketOverlay.ts";
|
|
35
36
|
export { computeScaleIndex, dimensionForMetric, scaleReportToText, type ScaleDimension, type ScaleReport, type SignalEstimate, type VendorScale, } from "./marketScale.ts";
|
|
36
37
|
export { marketMapToHtml, marketMapToMarkdown } from "./marketReport.ts";
|
|
37
|
-
export { registrableDomain, categoryKeywords, pickCategoryPage, extractLogoUrl, resolveFinalUrl, detectDrift, findCategoryPageInSitemap, findCategoryPage, fetchLogoDataUri, type FetchText, type FetchBytes, type ResolveUrl, } from "./marketSourcing.ts";
|
|
38
|
+
export { registrableDomain, categoryKeywords, pickCategoryPage, extractLogoUrl, resolveFinalUrl, detectDrift, findCategoryPageInSitemap, findCategoryPage, fetchLogoDataUri, discoverCompetitors, type FetchText, type FetchBytes, type ResolveUrl, type DiscoveredVendor, type DiscoverCompetitorsOptions, } from "./marketSourcing.ts";
|
|
38
39
|
export { computeMissedFirings, createFileScheduleRunStore, createFileScheduleStore, cronMatches, crontabSentinels, expectedFirings, nextCronFiring, parseCron, renderManagedBlock, replaceManagedBlock, scheduleId, scheduleRunsDir, schedulesPath, systemCrontabIo, tokenizeCommand, validateSchedulableArgv, type CronExpression, type CrontabIo, type ScheduleEntry, type ScheduleProvider, type ScheduleRunRecord, type ScheduleRunStore, type ScheduleRunTrigger, type ScheduleStore, } from "./schedule.ts";
|
|
39
40
|
export { suggestValues, type SuggestionConfidence, type ValueSuggestion } from "./suggest.ts";
|
|
40
41
|
export type { ApprovalStatus, AuditFinding, AuditFindingSeverity, CanonicalAccount, CanonicalActivity, CanonicalContact, CanonicalDeal, CanonicalGtmSnapshot, CanonicalUser, CrmProvider, GtmAuditRule, GtmConnector, GtmEvidence, GtmEvidenceSourceSystem, GtmObjectType, GtmPolicy, GtmRuleContext, GtmRuleResult, GtmSnapshotIndex, PatchOperation, PatchOperationResult, PatchOperationType, PatchPlan, PatchPlanRun, PatchPlanRunStatus, PatchVerification, PipelineFinding, PipelineFindingStatus, PipelineFindingType, ProviderIdentity, RiskLevel, SourceFreshness, } from "./types.ts";
|
package/dist/index.js
CHANGED
|
@@ -19,6 +19,7 @@ export { createFilePlanStore } from "./planStore.js";
|
|
|
19
19
|
export { computeApprovalDigests, loadOrCreateSigningKey, loadSigningKey, signApproval, verifyApprovalDigests, } from "./integrity.js";
|
|
20
20
|
export { buildAuditLog, verifyAuditLog, } from "./auditLog.js";
|
|
21
21
|
export { formatPatchPlanRun, patchPlanToMarkdown } from "./format.js";
|
|
22
|
+
export { computeHealth, summarizeHealth, healthToMarkdown, } from "./health.js";
|
|
22
23
|
export { auditReportToHtml, auditReportToMarkdown } from "./report.js";
|
|
23
24
|
export { HUBSPOT_DEFAULT_FIELD_MAPPINGS, SALESFORCE_DEFAULT_FIELD_MAPPINGS, mappedField, mappedFields, normalizeFieldMappings, readMappedValue, } from "./mappings.js";
|
|
24
25
|
export { accountSingleSourceRule, activeDealAccountWithoutContactsRule, auditFindingId, buildSnapshotIndex, builtinAuditRules, closingSoonInactiveRule, duplicateAccountDomainRule, duplicateContactEmailRule, duplicateOpenDealRule, missingDealAccountRule, missingDealAmountRule, missingDealOwnerRule, orphanAccountRule, pastCloseDateRule, patchOperationId, provenanceSummary, requiresHumanInput, staleDealRule, } from "./rules.js";
|
|
@@ -34,6 +35,6 @@ export { buildWorksheet, classifyMarket, } from "./marketClassify.js";
|
|
|
34
35
|
export { computeDirectives, computeOverlayStats, directivesToPlan, overlayToMarkdown, } from "./marketOverlay.js";
|
|
35
36
|
export { computeScaleIndex, dimensionForMetric, scaleReportToText, } from "./marketScale.js";
|
|
36
37
|
export { marketMapToHtml, marketMapToMarkdown } from "./marketReport.js";
|
|
37
|
-
export { registrableDomain, categoryKeywords, pickCategoryPage, extractLogoUrl, resolveFinalUrl, detectDrift, findCategoryPageInSitemap, findCategoryPage, fetchLogoDataUri, } from "./marketSourcing.js";
|
|
38
|
+
export { registrableDomain, categoryKeywords, pickCategoryPage, extractLogoUrl, resolveFinalUrl, detectDrift, findCategoryPageInSitemap, findCategoryPage, fetchLogoDataUri, discoverCompetitors, } from "./marketSourcing.js";
|
|
38
39
|
export { computeMissedFirings, createFileScheduleRunStore, createFileScheduleStore, cronMatches, crontabSentinels, expectedFirings, nextCronFiring, parseCron, renderManagedBlock, replaceManagedBlock, scheduleId, scheduleRunsDir, schedulesPath, systemCrontabIo, tokenizeCommand, validateSchedulableArgv, } from "./schedule.js";
|
|
39
40
|
export { suggestValues } from "./suggest.js";
|
package/dist/mappings.js
CHANGED
|
@@ -13,6 +13,9 @@ export const HUBSPOT_DEFAULT_FIELD_MAPPINGS = {
|
|
|
13
13
|
email: "email",
|
|
14
14
|
phone: "phone",
|
|
15
15
|
title: "jobtitle",
|
|
16
|
+
// HubSpot-standard "LinkedIn URL"; safe to request everywhere (HubSpot
|
|
17
|
+
// ignores unknown properties rather than erroring). Powers strong dedup.
|
|
18
|
+
linkedin: "hs_linkedin_url",
|
|
16
19
|
ownerId: "hubspot_owner_id",
|
|
17
20
|
},
|
|
18
21
|
deals: {
|
package/dist/marketSourcing.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type LlmCallOptions } from "./llm.ts";
|
|
1
2
|
/** Fetch a text resource (sitemap, robots.txt); null on any failure. */
|
|
2
3
|
export type FetchText = (url: string) => Promise<string | null>;
|
|
3
4
|
/** Fetch raw bytes (a logo image); null on any failure. */
|
|
@@ -64,3 +65,27 @@ export declare function findCategoryPage(homepageHtml: string, homepageUrl: stri
|
|
|
64
65
|
* Bounded to small raster/SVG (≤50KB).
|
|
65
66
|
*/
|
|
66
67
|
export declare function fetchLogoDataUri(homepageUrl: string, html?: string, fetchBytes?: FetchBytes): Promise<string | null>;
|
|
68
|
+
/** A vendor proposed by `discoverCompetitors`. */
|
|
69
|
+
export type DiscoveredVendor = {
|
|
70
|
+
name: string;
|
|
71
|
+
/** Canonical homepage. */
|
|
72
|
+
url: string;
|
|
73
|
+
/** The page most specific to the category — the product page for multi-product
|
|
74
|
+
* companies, else the homepage. Use as the capture seed. */
|
|
75
|
+
productUrl: string;
|
|
76
|
+
};
|
|
77
|
+
export type DiscoverCompetitorsOptions = {
|
|
78
|
+
llm: LlmCallOptions;
|
|
79
|
+
/** The user's own company: competitors are listed for it and it's excluded. */
|
|
80
|
+
anchorUrl?: string;
|
|
81
|
+
/** Vendor hosts already in the set, to exclude from results. */
|
|
82
|
+
exclude?: string[];
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* Propose the real vendor set for a category via the LLM — so a cold-start map
|
|
86
|
+
* needs only a category, not a hand-built vendor list. Returns canonical homepages
|
|
87
|
+
* plus a category-specific `productUrl` per vendor; excludes the anchor + any
|
|
88
|
+
* supplied hosts, de-dupes by registrable domain, and instructs the model to skip
|
|
89
|
+
* acquired/defunct brands. BYOK via the package's `forcedToolCall`.
|
|
90
|
+
*/
|
|
91
|
+
export declare function discoverCompetitors(category: string, options: DiscoverCompetitorsOptions): Promise<DiscoveredVendor[]>;
|
package/dist/marketSourcing.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* headless browser, so the default fetch is sufficient even in the hosted layer.
|
|
18
18
|
*/
|
|
19
19
|
import { assertPublicUrl } from "./market.js";
|
|
20
|
+
import { DEFAULT_MODELS, forcedToolCall } from "./llm.js";
|
|
20
21
|
const USER_AGENT = "fullstackgtm-market/0 (+https://github.com/fullstackgtm/core)";
|
|
21
22
|
const FETCH_TIMEOUT_MS = 15_000;
|
|
22
23
|
const MAX_REDIRECTS = 5;
|
|
@@ -403,3 +404,84 @@ export async function fetchLogoDataUri(homepageUrl, html, fetchBytes = defaultFe
|
|
|
403
404
|
}
|
|
404
405
|
return null;
|
|
405
406
|
}
|
|
407
|
+
const DISCOVERY_SCHEMA = {
|
|
408
|
+
type: "object",
|
|
409
|
+
required: ["competitors"],
|
|
410
|
+
properties: {
|
|
411
|
+
competitors: {
|
|
412
|
+
type: "array",
|
|
413
|
+
description: "Real vendors competing in this category today, each with its canonical https homepage URL.",
|
|
414
|
+
items: {
|
|
415
|
+
type: "object",
|
|
416
|
+
required: ["name", "url"],
|
|
417
|
+
properties: {
|
|
418
|
+
name: { type: "string" },
|
|
419
|
+
url: { type: "string", description: "Canonical homepage, https://domain, no tracking path." },
|
|
420
|
+
productUrl: {
|
|
421
|
+
type: "string",
|
|
422
|
+
description: "URL of the page on THIS vendor's site most specific to the category. For a multi-product company (e.g. SAP, Oracle) this is the product/solution page for this category, NOT the corporate homepage. For a focused single-product vendor, repeat the homepage.",
|
|
423
|
+
},
|
|
424
|
+
},
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
},
|
|
428
|
+
};
|
|
429
|
+
/**
|
|
430
|
+
* Propose the real vendor set for a category via the LLM — so a cold-start map
|
|
431
|
+
* needs only a category, not a hand-built vendor list. Returns canonical homepages
|
|
432
|
+
* plus a category-specific `productUrl` per vendor; excludes the anchor + any
|
|
433
|
+
* supplied hosts, de-dupes by registrable domain, and instructs the model to skip
|
|
434
|
+
* acquired/defunct brands. BYOK via the package's `forcedToolCall`.
|
|
435
|
+
*/
|
|
436
|
+
export async function discoverCompetitors(category, options) {
|
|
437
|
+
const model = options.llm.model ?? DEFAULT_MODELS[options.llm.provider];
|
|
438
|
+
const anchorHost = options.anchorUrl ? hostOf(options.anchorUrl) : null;
|
|
439
|
+
const anchorNote = anchorHost
|
|
440
|
+
? `The user's own company is ${anchorHost} — list its direct competitors and do NOT include ${anchorHost} itself.`
|
|
441
|
+
: "";
|
|
442
|
+
const prompt = `List the most significant vendors competing in the category "${category}" today.
|
|
443
|
+
${anchorNote}
|
|
444
|
+
Rules:
|
|
445
|
+
- Real companies only, each with its canonical https homepage URL (domain root, no tracking path).
|
|
446
|
+
- 7-9 vendors: a mix of established leaders and notable challengers.
|
|
447
|
+
- EXCLUDE vendors that have been acquired and folded into another brand, or are defunct — i.e. anything whose own site now redirects to a different company. (Name the current independent players instead.)
|
|
448
|
+
- For each, also give productUrl: the page most specific to "${category}". For a big multi-product company, that's its product/solution page for THIS category, not the corporate homepage.
|
|
449
|
+
- No duplicates.`;
|
|
450
|
+
const result = (await forcedToolCall(prompt, "discover_competitors", DISCOVERY_SCHEMA, model, options.llm));
|
|
451
|
+
const excluded = new Set((options.exclude ?? []).map(hostOf).filter((h) => Boolean(h)));
|
|
452
|
+
if (anchorHost)
|
|
453
|
+
excluded.add(anchorHost);
|
|
454
|
+
const seenDomain = new Set();
|
|
455
|
+
const out = [];
|
|
456
|
+
for (const c of result?.competitors ?? []) {
|
|
457
|
+
let u;
|
|
458
|
+
try {
|
|
459
|
+
u = new URL(String(c.url));
|
|
460
|
+
}
|
|
461
|
+
catch {
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
if (u.protocol !== "http:" && u.protocol !== "https:")
|
|
465
|
+
continue;
|
|
466
|
+
const host = u.hostname.replace(/^www\./, "");
|
|
467
|
+
if (excluded.has(host))
|
|
468
|
+
continue;
|
|
469
|
+
const dom = registrableDomain(host);
|
|
470
|
+
if (seenDomain.has(dom))
|
|
471
|
+
continue;
|
|
472
|
+
seenDomain.add(dom);
|
|
473
|
+
let productUrl = u.toString();
|
|
474
|
+
if (c.productUrl) {
|
|
475
|
+
try {
|
|
476
|
+
const p = new URL(String(c.productUrl));
|
|
477
|
+
if (p.protocol === "http:" || p.protocol === "https:")
|
|
478
|
+
productUrl = p.toString();
|
|
479
|
+
}
|
|
480
|
+
catch {
|
|
481
|
+
/* keep homepage */
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
out.push({ name: c.name || host, url: u.toString(), productUrl });
|
|
485
|
+
}
|
|
486
|
+
return out;
|
|
487
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -10,7 +10,21 @@ 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
12
|
export type GtmEvidenceSourceSystem = "salesforce" | "hubspot" | "gong" | "chorus" | "fathom" | "manual" | "csv" | "mock" | "web" | "unknown";
|
|
13
|
-
export type PatchOperationType = "set_field" | "clear_field" | "link_record" | "archive_record" | "create_task" | "merge_records";
|
|
13
|
+
export type PatchOperationType = "set_field" | "clear_field" | "link_record" | "archive_record" | "create_task" | "create_record" | "merge_records";
|
|
14
|
+
/**
|
|
15
|
+
* The afterValue of a `create_record` operation. The connector re-resolves on
|
|
16
|
+
* `matchKey`/`matchValue` at apply time and creates only on a confirmed miss.
|
|
17
|
+
* `estCostUsd` is the acquire meter's per-record charge, recorded against the
|
|
18
|
+
* budget on a successful create.
|
|
19
|
+
*/
|
|
20
|
+
export type CreateRecordPayload = {
|
|
21
|
+
properties: Record<string, string>;
|
|
22
|
+
matchKey: string;
|
|
23
|
+
matchValue: string;
|
|
24
|
+
source: string;
|
|
25
|
+
estCostUsd?: number;
|
|
26
|
+
associateCompanyName?: string;
|
|
27
|
+
};
|
|
14
28
|
export type AuditFindingSeverity = "info" | "warning" | "critical";
|
|
15
29
|
/**
|
|
16
30
|
* One claim that a canonical record exists in an external system. A record
|
|
@@ -125,6 +139,8 @@ export type CanonicalContact = {
|
|
|
125
139
|
email?: string;
|
|
126
140
|
phone?: string;
|
|
127
141
|
title?: string;
|
|
142
|
+
/** LinkedIn profile URL — the strongest cross-system identity key for dedup. */
|
|
143
|
+
linkedin?: string;
|
|
128
144
|
ownerId?: string;
|
|
129
145
|
lastActivityAt?: string;
|
|
130
146
|
lastSyncAt?: string;
|
package/docs/api.md
CHANGED
|
@@ -40,7 +40,7 @@ release.
|
|
|
40
40
|
- `GtmConnector` — `{ provider, fetchSnapshot(), applyOperation?, readField?, fetchChanges? }`.
|
|
41
41
|
- Connectors never silently drop unresolvable records; audits surface them.
|
|
42
42
|
- `fetchChanges(sinceIso)` returns a partial snapshot; change feeds may omit associations.
|
|
43
|
-
- `createHubspotConnector(options)` — read/write/readField/fetchChanges. `applyOperation` implements every `PatchOperationType`: `set_field`, `clear_field`, `link_record`, `create_task`, `archive_record`, `merge_records` (HubSpot v3 merge — pairwise, irreversible; survivor must belong to the duplicate group). The Salesforce connector skips `merge_records` honestly (
|
|
43
|
+
- `createHubspotConnector(options)` — read/write/readField/fetchChanges. `applyOperation` implements every `PatchOperationType`: `set_field`, `clear_field`, `link_record`, `create_task`, `create_record` (resolve-first net-new contact/company create — re-checks the dedupe key at apply, never double-creates), `archive_record`, `merge_records` (HubSpot v3 merge — pairwise, irreversible; survivor must belong to the duplicate group). The Salesforce connector skips `merge_records` and `create_record` honestly (platform-specific).
|
|
44
44
|
- `createSalesforceConnector(options)` — read/write/readField/fetchChanges; probabilities normalized to 0..1; `applyOperation` implements every operation type.
|
|
45
45
|
- `createStripeConnector(options)` — read-only billing by design (`applyOperation` returns `skipped`); email domains are the cross-system merge keys. Implements `fetchChanges` (incremental via `created[gte]`).
|
|
46
46
|
|
|
@@ -62,7 +62,8 @@ Commands: `login` / `logout`, `snapshot`, `audit`, `report`, `diff`, `merge`, `p
|
|
|
62
62
|
`bulk-update`, `dedupe`, `reassign`, `fix`,
|
|
63
63
|
`market` (`init` / `capture` / `classify` / `worksheet` / `observe` / `fronts` /
|
|
64
64
|
`axes` / `overlay` / `scale` / `report` / `refresh`),
|
|
65
|
-
`enrich` (`append` / `refresh` / `ingest` / `status`),
|
|
65
|
+
`enrich` (`append` / `refresh` / `ingest` / `acquire` / `status`),
|
|
66
|
+
`icp` (`interview` / `set` / `show`),
|
|
66
67
|
`schedule` (`add` / `list` / `remove` / `enable` / `disable` / `run` /
|
|
67
68
|
`install` / `uninstall` / `status`), `rules`, `profiles`, `doctor`.
|
|
68
69
|
Exit codes: `0` success · `1` error · `2` findings/regressions at the requested gate
|
|
@@ -120,6 +121,32 @@ dependency-free CSV intake; the Apollo client (`createApolloClient`,
|
|
|
120
121
|
`pullApolloRecords`, 429-aware with `Retry-After`) is the first `api`-kind
|
|
121
122
|
source.
|
|
122
123
|
|
|
124
|
+
## Acquire (net-new lead generation)
|
|
125
|
+
|
|
126
|
+
`buildAcquirePlan` turns sourced-but-unmatched prospects into `create_record`
|
|
127
|
+
operations (matched / ambiguous are skipped — resolve-first never creates over
|
|
128
|
+
a possible dup), capped by the meter's headroom. `builtinAcquirePreset` is the
|
|
129
|
+
zero-config `EnrichConfig.acquire` (budget, provider, create-mapping) so
|
|
130
|
+
`enrich acquire` runs with just an `icp.json`.
|
|
131
|
+
|
|
132
|
+
- **ICP** (`icp.ts`): `Icp` type, `parseIcp`, `icpToExploriumFilters` /
|
|
133
|
+
`icpToCrustdataFilters` (per-provider discovery filters), `scoreProspectAgainstIcp`
|
|
134
|
+
(persona fit 0..1), `fitThreshold`, `INTERVIEW_SPEC` + `icpFromAnswers` (the
|
|
135
|
+
agent-driven interview).
|
|
136
|
+
- **Prospect sources** (`connectors/prospectSources.ts`, `Prospect`):
|
|
137
|
+
`fetchExploriumProspects` (discovery), `pipe0ResolveWorkEmails` (work-email
|
|
138
|
+
waterfall, chunked, surfaces upstream errors), `fetchPipe0CrustdataProspects`
|
|
139
|
+
(people search). `prospectIdentityKeys` / `crmContactKeys` /
|
|
140
|
+
`partitionFreshProspects` power the pre-email dedup.
|
|
141
|
+
- **Meter** (`acquireMeter.ts`): `AcquireBudget`, `loadMeter` / `remaining` /
|
|
142
|
+
`recordConsumption` — per-profile windowed record+spend budget, charged only
|
|
143
|
+
for landed creates.
|
|
144
|
+
- **Seen cache** (`acquireSeen.ts`): `loadSeen` / `recordSeen` — cross-run
|
|
145
|
+
memory so a re-run never re-pays to enrich a known prospect.
|
|
146
|
+
|
|
147
|
+
`CanonicalContact.linkedin` (mapped from HubSpot `hs_linkedin_url`) is the
|
|
148
|
+
strong dedup key across all of the above.
|
|
149
|
+
|
|
123
150
|
## Schedule
|
|
124
151
|
|
|
125
152
|
The horizontal scheduler: a declarative schedule-entry store, a
|
package/docs/architecture.md
CHANGED
|
@@ -72,7 +72,14 @@ provider ──fetchSnapshot()──► CanonicalGtmSnapshot
|
|
|
72
72
|
- `market*.ts` (`market.ts`, `marketClassify.ts`, `marketAxes.ts`,
|
|
73
73
|
`marketOverlay.ts`, `marketScale.ts`, `marketReport.ts`) — the competitive
|
|
74
74
|
market-map layer; classifications are verbatim-verified against captures.
|
|
75
|
-
- `enrich.ts` + `enrichApollo.ts` — third-party data enrichment (fill-blanks)
|
|
75
|
+
- `enrich.ts` + `enrichApollo.ts` — third-party data enrichment (fill-blanks),
|
|
76
|
+
plus `buildAcquirePlan` / `builtinAcquirePreset` for net-new lead generation.
|
|
77
|
+
- `icp.ts` — the Ideal Customer Profile artifact: per-provider discovery-filter
|
|
78
|
+
translation, prospect fit scoring, and the agent-driven interview spec.
|
|
79
|
+
- `acquireMeter.ts` — per-profile windowed budget (records + spend) for acquire.
|
|
80
|
+
- `acquireSeen.ts` — cross-run "seen" cache so re-runs don't re-pay for dupes.
|
|
81
|
+
- `connectors/prospectSources.ts` — API prospect sources (Explorium discovery,
|
|
82
|
+
pipe0 work-email waterfall, pipe0/Crustdata search) + the pre-email dedup keys.
|
|
76
83
|
|
|
77
84
|
**Surfaces & infra**
|
|
78
85
|
- `cli.ts` — one async function per command, flat dispatch in `runCli`;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# DX / adoption punch list
|
|
2
|
+
|
|
3
|
+
Audit date: 2026-06-19. Method: ran the CLI as a new adopter would (bare
|
|
4
|
+
invoke → `doctor` → `audit --demo` → per-command `--help`) and measured the
|
|
5
|
+
friction, rather than reading the source. Companion to the security/strategic
|
|
6
|
+
findings that drove the 0.25.1–0.28.0 hardening releases — same format, same
|
|
7
|
+
intent: a tracked list that drives a release.
|
|
8
|
+
|
|
9
|
+
## Framing
|
|
10
|
+
|
|
11
|
+
The **agent path** (SKILL.md verb map, MCP tools, `--json` everywhere,
|
|
12
|
+
gate-shaped exit codes 0/1/2) is cohesive and adopts cleanly. The **human
|
|
13
|
+
path** is the weak side — and the human is a primary user (the consultant-CLI,
|
|
14
|
+
dogfood-on-client-work direction). Every fix below either *deepens an existing
|
|
15
|
+
verb* or *connects two existing verbs*; none adds a new top-level noun. The
|
|
16
|
+
adoption work **is** the "deeper not broader" work.
|
|
17
|
+
|
|
18
|
+
`doctor` (ends with "Next step") and `fix` (chains audit→suggest→approve→apply)
|
|
19
|
+
already do the right thing. Cohesion here = make every verb behave like
|
|
20
|
+
`doctor` and `fix` already do.
|
|
21
|
+
|
|
22
|
+
## Findings (evidence-backed)
|
|
23
|
+
|
|
24
|
+
| # | Finding | Evidence | Impact |
|
|
25
|
+
| --- | --- | --- | --- |
|
|
26
|
+
| F1 | **Front door is a firehose** | bare invoke = 194 lines; 22 verbs / 87 distinct flags; `cli.ts` is 3,615 lines | New user can't find the ~6 things they actually do |
|
|
27
|
+
| F2 | **Credential-free entry point dead-ends** | `audit --demo` (the README's "try it first") dumps **1,467 lines** to stdout and ends on a blank line — no "next step," unlike `doctor` | The single most-run first command strands you |
|
|
28
|
+
| F3 | **No per-command help** | `dedupe --help`, `audit --help` fall through to the same 194-line global wall | A stuck user gets the firehose, not the answer |
|
|
29
|
+
| F4 | **No altitude control on reads** | `audit` has only `--json` / `--out` / `--format`; no `--summary` / `--top` / `--quiet`. Human mode *is* the 1,467-line dump | Default human output is unreadable yet not machine-parseable |
|
|
30
|
+
| F5 | **Tone undercuts positioning** | `audit --demo` footer: "This prototype is dry-run only" | Reads as toy vs. the beta/production-safety-invariant framing |
|
|
31
|
+
| F6 | **No guided mode** | `readline` is used only for the login secret prompt | Every workflow is hand-assembled from flags; nothing scaffolds the loop |
|
|
32
|
+
|
|
33
|
+
## Fix sequence (highest leverage first)
|
|
34
|
+
|
|
35
|
+
### 1. Progressive-disclosure help — IN PROGRESS
|
|
36
|
+
Fixes **F1, F3**. The full `usage()` becomes the explicit full reference
|
|
37
|
+
(`fullstackgtm help --full`). Add:
|
|
38
|
+
- `shortUsage()` — a ~30-line map of the verbs grouped by lifecycle phase
|
|
39
|
+
(Setup · Detect · Prevent · Remediate · Calls · Govern · Market · Schedule),
|
|
40
|
+
one line each, ending with `<command> --help` and `help --full` pointers.
|
|
41
|
+
Becomes the default for bare invoke and `--help`.
|
|
42
|
+
- `commandHelp(command)` — focused per-verb help (summary, synopsis, where it
|
|
43
|
+
sits in the loop, key options, see-also). `<verb> --help` zooms in instead
|
|
44
|
+
of dumping the surface. `call`/`market`/`enrich`/`bulk-update`/`schedule`
|
|
45
|
+
keep their existing bespoke help.
|
|
46
|
+
- `help [command] [--full]` command.
|
|
47
|
+
|
|
48
|
+
### 2. Every verb hands you the next step — DONE (audit keystone)
|
|
49
|
+
Fixes **F2**. `auditNextStep()` gives `audit` context-aware forward guidance on
|
|
50
|
+
stderr (stdout stays clean for pipes/`--out`; suppressed under `--json`):
|
|
51
|
+
- **demo/sample** → "Client-ready writeup: `report --demo`" + "Run on your CRM:
|
|
52
|
+
`login hubspot` → `audit --provider hubspot --save`"
|
|
53
|
+
- **live + `--save`** → the governed spine: `suggest` → `plans approve
|
|
54
|
+
--values-from` → `apply`, with the real plan id
|
|
55
|
+
- **0 findings** → "clean — gate new records with `resolve`, schedule a check"
|
|
56
|
+
|
|
57
|
+
The lifecycle spine made *experiential* — each command knows its phase and
|
|
58
|
+
points forward (`doctor`/`fix` were the templates). `audit` is the keystone
|
|
59
|
+
(the documented dead-end); the helper pattern now generalizes to the other read
|
|
60
|
+
verbs (`report`, `resolve`, `dedupe` previews) in a follow-up.
|
|
61
|
+
|
|
62
|
+
### 3. Altitude control on the firehose — DONE
|
|
63
|
+
Fixes **F4**. `patchPlanToMarkdown(plan, { summary })` gains a summary view
|
|
64
|
+
(header + Findings-by-Rule table + operation count + pointers to `--full` and
|
|
65
|
+
`report`). `audit` defaults to it — **1,467 → 24 lines** on the demo — with
|
|
66
|
+
`--full` to opt back into the per-operation dump. The default stays *full* for
|
|
67
|
+
every other caller (bulk-update, fix, dedupe, plans show, MCP): write-preview
|
|
68
|
+
verbs keep full operation detail because you approve specific ops there. The
|
|
69
|
+
principled split — audit = "what's wrong" (summary → `report`); write verbs =
|
|
70
|
+
"exactly what I'll change" (full) — is the real win.
|
|
71
|
+
|
|
72
|
+
### 4. Tone pass — DONE
|
|
73
|
+
Fixes **F5**. The "This prototype is dry-run only" footer is replaced
|
|
74
|
+
everywhere with safety-invariant framing matching README/SKILL: "Dry-run plan —
|
|
75
|
+
read-only. No provider write happens until you approve specific operations and
|
|
76
|
+
run `apply`; placeholder values are refused without an explicit override. These
|
|
77
|
+
safety invariants are not beta." No "prototype" string remains in user output.
|
|
78
|
+
|
|
79
|
+
## Deferred (bigger bets, gated on 1–4 landing)
|
|
80
|
+
- **Engagement workspace**: per-client `.fullstackgtm/` state that accumulates
|
|
81
|
+
snapshots, plans, evidence, and a health-over-time score — turns episodic
|
|
82
|
+
audits into a continuously-operated system.
|
|
83
|
+
- **Journey commands**: 2–3 opinionated composites (onboard-a-client,
|
|
84
|
+
health-rollup) sequencing existing verbs into revops jobs.
|
|
85
|
+
|
|
86
|
+
There is no point building the engagement workspace while the front door dumps
|
|
87
|
+
1,467 lines. Land the DX fixes first.
|
package/llms.txt
CHANGED
|
@@ -115,6 +115,22 @@ artifacts: plan ids / enrich run labels, trigger cron|manual) land under
|
|
|
115
115
|
firings (visibility only — local cron has no catch-up). Providers beyond
|
|
116
116
|
`local` are not yet implemented (future: codegen scaffolds, same contract).
|
|
117
117
|
|
|
118
|
+
## Key invariants (engagement workspace / health)
|
|
119
|
+
|
|
120
|
+
`fullstackgtm health` rolls up a per-profile CRM-health timeline that accrues
|
|
121
|
+
from the verb teams already run: every `audit --save` stamps a deterministic
|
|
122
|
+
0–100 hygiene score plus a snapshot onto the active profile. The score is
|
|
123
|
+
`100 / (1 + severity-weighted findings per record)` (info ×1, warning ×3,
|
|
124
|
+
critical ×10) — no LLM, so it is byte-stable in CI and comparable run-over-run;
|
|
125
|
+
0 findings ⇒ 100, and the same findings over fewer clean records score lower.
|
|
126
|
+
State is profile-scoped and owner-only (0600) under the same secured home as
|
|
127
|
+
plans: `health.jsonl` (append-only, one entry per saved audit) and
|
|
128
|
+
`snapshots/<planId>.json`. `health` is READ-ONLY — it only reads the timeline,
|
|
129
|
+
never re-audits or writes; `--json` emits the rollup (current score, delta since
|
|
130
|
+
the last audit, dated trend, per-rule deltas). Scope per client with
|
|
131
|
+
`--profile <name>`; an empty timeline returns a pointer, never an error.
|
|
132
|
+
Library: `computeHealth` / `summarizeHealth` / `healthToMarkdown`.
|
|
133
|
+
|
|
118
134
|
## Key invariants
|
|
119
135
|
|
|
120
136
|
- Reads are safe by default; nothing is written without explicit `--approve`
|