fullstackgtm 0.43.0 → 0.45.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 (130) hide show
  1. package/CHANGELOG.md +378 -1
  2. package/README.md +37 -14
  3. package/dist/audit.d.ts +3 -1
  4. package/dist/audit.js +29 -2
  5. package/dist/cli/audit.d.ts +15 -0
  6. package/dist/cli/audit.js +392 -0
  7. package/dist/cli/auth.d.ts +80 -0
  8. package/dist/cli/auth.js +500 -0
  9. package/dist/cli/call.d.ts +1 -0
  10. package/dist/cli/call.js +373 -0
  11. package/dist/cli/capabilities.d.ts +30 -0
  12. package/dist/cli/capabilities.js +197 -0
  13. package/dist/cli/draft.d.ts +7 -0
  14. package/dist/cli/draft.js +87 -0
  15. package/dist/cli/enrich.d.ts +8 -0
  16. package/dist/cli/enrich.js +788 -0
  17. package/dist/cli/fix.d.ts +33 -0
  18. package/dist/cli/fix.js +344 -0
  19. package/dist/cli/help.d.ts +25 -0
  20. package/dist/cli/help.js +609 -0
  21. package/dist/cli/icp.d.ts +7 -0
  22. package/dist/cli/icp.js +229 -0
  23. package/dist/cli/init.d.ts +7 -0
  24. package/dist/cli/init.js +58 -0
  25. package/dist/cli/market.d.ts +1 -0
  26. package/dist/cli/market.js +391 -0
  27. package/dist/cli/plans.d.ts +5 -0
  28. package/dist/cli/plans.js +454 -0
  29. package/dist/cli/schedule.d.ts +8 -0
  30. package/dist/cli/schedule.js +479 -0
  31. package/dist/cli/shared.d.ts +70 -0
  32. package/dist/cli/shared.js +331 -0
  33. package/dist/cli/signals.d.ts +7 -0
  34. package/dist/cli/signals.js +412 -0
  35. package/dist/cli/suggest.d.ts +49 -0
  36. package/dist/cli/suggest.js +135 -0
  37. package/dist/cli/tam.d.ts +9 -0
  38. package/dist/cli/tam.js +387 -0
  39. package/dist/cli/ui.d.ts +121 -0
  40. package/dist/cli/ui.js +375 -0
  41. package/dist/cli.d.ts +1 -57
  42. package/dist/cli.js +104 -4556
  43. package/dist/connector.d.ts +15 -0
  44. package/dist/connector.js +35 -0
  45. package/dist/connectors/hubspot.d.ts +3 -1
  46. package/dist/connectors/hubspot.js +10 -3
  47. package/dist/connectors/outboxChannel.d.ts +64 -0
  48. package/dist/connectors/outboxChannel.js +170 -0
  49. package/dist/connectors/prospectSources.d.ts +22 -0
  50. package/dist/connectors/prospectSources.js +43 -0
  51. package/dist/connectors/salesforce.d.ts +3 -1
  52. package/dist/connectors/salesforce.js +12 -5
  53. package/dist/connectors/signalSources.d.ts +105 -0
  54. package/dist/connectors/signalSources.js +264 -0
  55. package/dist/connectors/theirstack.d.ts +84 -0
  56. package/dist/connectors/theirstack.js +125 -0
  57. package/dist/icp.d.ts +52 -4
  58. package/dist/icp.js +112 -35
  59. package/dist/index.d.ts +1 -0
  60. package/dist/index.js +1 -0
  61. package/dist/init.js +3 -0
  62. package/dist/judge.d.ts +2 -0
  63. package/dist/judge.js +6 -0
  64. package/dist/judgeEval.d.ts +7 -0
  65. package/dist/judgeEval.js +8 -1
  66. package/dist/marketClassify.d.ts +2 -0
  67. package/dist/marketClassify.js +7 -1
  68. package/dist/mcp-bin.js +2 -2
  69. package/dist/mcp.js +259 -166
  70. package/dist/runReport.d.ts +26 -0
  71. package/dist/runReport.js +15 -0
  72. package/dist/schedule.d.ts +80 -2
  73. package/dist/schedule.js +272 -5
  74. package/dist/signals.d.ts +54 -0
  75. package/dist/signals.js +64 -0
  76. package/dist/spoolFiles.d.ts +44 -0
  77. package/dist/spoolFiles.js +114 -0
  78. package/dist/tam.d.ts +225 -0
  79. package/dist/tam.js +470 -0
  80. package/dist/types.d.ts +11 -0
  81. package/docs/api.md +91 -11
  82. package/docs/outbox-format.md +92 -0
  83. package/docs/recipes.md +37 -0
  84. package/docs/roadmap-to-1.0.md +31 -1
  85. package/docs/signal-spool-format.md +175 -0
  86. package/docs/tam.md +195 -0
  87. package/llms.txt +83 -11
  88. package/package.json +1 -1
  89. package/skills/fullstackgtm/SKILL.md +4 -3
  90. package/src/audit.ts +27 -1
  91. package/src/cli/audit.ts +447 -0
  92. package/src/cli/auth.ts +549 -0
  93. package/src/cli/call.ts +398 -0
  94. package/src/cli/capabilities.ts +215 -0
  95. package/src/cli/draft.ts +101 -0
  96. package/src/cli/enrich.ts +885 -0
  97. package/src/cli/fix.ts +372 -0
  98. package/src/cli/help.ts +664 -0
  99. package/src/cli/icp.ts +265 -0
  100. package/src/cli/init.ts +65 -0
  101. package/src/cli/market.ts +423 -0
  102. package/src/cli/plans.ts +523 -0
  103. package/src/cli/schedule.ts +526 -0
  104. package/src/cli/shared.ts +375 -0
  105. package/src/cli/signals.ts +434 -0
  106. package/src/cli/suggest.ts +151 -0
  107. package/src/cli/tam.ts +435 -0
  108. package/src/cli/ui.ts +426 -0
  109. package/src/cli.ts +103 -5170
  110. package/src/connector.ts +46 -0
  111. package/src/connectors/hubspot.ts +14 -2
  112. package/src/connectors/outboxChannel.ts +202 -0
  113. package/src/connectors/prospectSources.ts +57 -0
  114. package/src/connectors/salesforce.ts +18 -2
  115. package/src/connectors/signalSources.ts +314 -0
  116. package/src/connectors/theirstack.ts +170 -0
  117. package/src/icp.ts +120 -34
  118. package/src/index.ts +32 -0
  119. package/src/init.ts +3 -0
  120. package/src/judge.ts +7 -0
  121. package/src/judgeEval.ts +8 -1
  122. package/src/marketClassify.ts +8 -1
  123. package/src/mcp-bin.ts +2 -2
  124. package/src/mcp.ts +130 -57
  125. package/src/runReport.ts +39 -0
  126. package/src/schedule.ts +330 -7
  127. package/src/signals.ts +90 -0
  128. package/src/spoolFiles.ts +116 -0
  129. package/src/tam.ts +654 -0
  130. package/src/types.ts +12 -0
package/dist/cli.js CHANGED
@@ -1,4551 +1,22 @@
1
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
- import { resolve } from "node:path";
3
- import { auditSnapshot, defaultPolicy } from "./audit.js";
4
- import { loadConfig, mergePolicy, resolveConfiguredRules } from "./config.js";
5
- import { applyPatchPlan } from "./connector.js";
6
- import { diffFindings, diffSnapshots, diffToMarkdown } from "./diff.js";
7
- import { createHubspotConnector } from "./connectors/hubspot.js";
8
- import { DEFAULT_LOOPBACK_PORT, openInBrowser, runHubspotLoopbackLogin, validateHubspotToken, } from "./connectors/hubspotAuth.js";
9
- import { createSalesforceConnector } from "./connectors/salesforce.js";
10
- import { createStripeConnector } from "./connectors/stripe.js";
11
- import { pollSalesforceDeviceLogin, startSalesforceDeviceLogin, validateSalesforceToken, } from "./connectors/salesforceAuth.js";
12
- import { activeProfile, credentialsPath, DEFAULT_PROFILE, deleteCredential, getCredential, listProfiles, resolveHubspotConnection, resolveSalesforceConnection, setActiveProfile, storeCredential, } from "./credentials.js";
13
- import { generateDemoSnapshot } from "./demo.js";
14
- import { formatPatchPlanRun, patchPlanToMarkdown } from "./format.js";
15
- import { appendHealthEntry, computeHealth, healthToMarkdown, readHealthTimeline, saveWorkspaceSnapshot, summarizeHealth, activeWorkspaceProfile, } from "./health.js";
16
- import { mergeSnapshots } from "./merge.js";
17
- import { verifyApprovalDigests } from "./integrity.js";
18
- import { buildAuditLog, verifyAuditLog } from "./auditLog.js";
19
- import { createFilePlanStore } from "./planStore.js";
20
- import { auditReportToHtml, auditReportToMarkdown } from "./report.js";
21
- import { builtinAuditRules } from "./rules.js";
22
- import { sampleSnapshot } from "./sampleData.js";
23
- import { normalizeTranscript, parseCall, suggestCallDeal } from "./calls.js";
24
- import { classifyCall, rubricForCallType, rubricPresets, CALL_TYPES, CALL_TYPE_IDS, } from "./callTypes.js";
25
- import { captureMarket, computeFrontStates, createFileObservationStore, diffFrontStates, loadCaptureTexts, loadMarketConfig, starterMarketConfig, validateObservationSet, verifyEvidenceSpans, } from "./market.js";
26
- import { assessAxes, axesReportToText } from "./marketAxes.js";
27
- import { computeDirectives, computeOverlayStats, directivesToPlan, overlayToMarkdown, } from "./marketOverlay.js";
28
- import { computeScaleIndex, scaleReportToText } from "./marketScale.js";
29
- import { suggestMarketConfig } from "./marketTaxonomy.js";
30
- import { buildWorksheet, classifyMarket } from "./marketClassify.js";
31
- import { marketMapToHtml, marketMapToMarkdown } from "./marketReport.js";
32
- import { DEFAULT_RUBRIC, classifyCallLlm, detectProviderFromKey, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, } from "./llm.js";
33
- import { buildAcquirePlan, buildEnrichPlan, createFileEnrichRunStore, DEFAULT_STALE_DAYS, ENRICH_CONFIG_FILE_NAME, builtinAcquirePreset, builtinEnrichPreset, enrichRunId, inferIngestObjectType, latestStamps, loadEnrichConfig, parseCsv, resolveCrmField, selectStaleWork, stagedSourceRecords, staleDaysFor, } from "./enrich.js";
34
- import { loadMeter, recordConsumption, remaining, } from "./acquireMeter.js";
35
- import { scaffoldWorkspace, } from "./init.js";
36
- import { crmContactKeys, fetchExploriumProspects, fetchPipe0CrustdataProspects, partitionFreshProspects, pipe0ResolveCompanyDomains, pipe0ResolveWorkEmails, prospectIdentityKeys, } from "./connectors/prospectSources.js";
37
- import { loadSeen, recordSeen } from "./acquireSeen.js";
38
- import { reportCounts, reportEvent } from "./runReport.js";
39
- import { createLinkedInProvider, discoverLinkedInProspects } from "./acquireLinkedIn.js";
40
- import { fitThreshold, icpFromAnswers, icpToCrustdataFilters, icpToExploriumFilters, parseIcp, scoreProspectAgainstIcp, INTERVIEW_SPEC, } from "./icp.js";
41
- import { buildSignalsFromAts, computeWeights, createFileSignalStore, DEFAULT_SIGNALS_CONFIG, dedupeSignals, loadSignalsConfig, makeOutcome, normalizeAccountDomain, SIGNAL_BUCKETS, signalRunId, signalId, } from "./signals.js";
42
- import { fetchAtsJobs } from "./connectors/atsBoards.js";
43
- import { createFileJudgeStore, DEFAULT_JUDGE_PROMPT, judgeRunId, judgeSignals, } from "./judge.js";
44
- import { authorOpeners, DEFAULT_DRAFT_PROMPT, DRAFT_CHANNELS, draft, } from "./draft.js";
45
- import { DEFAULT_GOLDEN_SET, DEFAULT_MIN_ACCURACY, defaultJudgeFn, gradeAgainstOutcomes, gradeJudge, parseGoldenSet, } from "./judgeEval.js";
46
- import { apolloPullKeysForAppend, apolloPullKeysForRefresh, createApolloClient, pullApolloRecords, } from "./enrichApollo.js";
47
- import { computeMissedFirings, createFileScheduleRunStore, createFileScheduleStore, nextCronFiring, parseCron, renderManagedBlock, replaceManagedBlock, assertSingleLineLabel, hasControlChar, scheduleId, systemCrontabIo, tokenizeCommand, validateSchedulableArgv, } from "./schedule.js";
48
- import { resolveRecord } from "./resolve.js";
49
- import { buildBulkUpdatePlan } from "./bulkUpdate.js";
50
- import { buildDedupePlan } from "./dedupe.js";
51
- import { buildReassignPlans } from "./reassign.js";
52
- import { suggestValues } from "./suggest.js";
53
- function usage() {
54
- return `FullStackGTM — audit GTM data across providers, propose reviewable patch plans,
55
- and apply only explicitly approved operations.
56
-
57
- Usage:
58
- fullstackgtm login --via <hosted url> pair with a team deployment (recommended)
59
- fullstackgtm login hubspot [--no-validate]
60
- fullstackgtm login hubspot --oauth --client-id <id> [--port ${DEFAULT_LOOPBACK_PORT}] [--scopes a,b]
61
- fullstackgtm login salesforce --device --client-id <consumer key> [--login-url <url>]
62
- fullstackgtm login salesforce --instance-url <url> [--no-validate]
63
- fullstackgtm login stripe [--no-validate]
64
- fullstackgtm login anthropic | openai store an LLM API key for call parse/score
65
- fullstackgtm login apollo store an Apollo API key for enrich pulls\n fullstackgtm login pipe0 | explorium store a discovery-provider key for enrich acquire\n fullstackgtm login heyreach store a HeyReach key for enrich acquire --source linkedin\n fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|apollo|pipe0|explorium|heyreach|broker>
66
-
67
- Secrets (tokens, client secrets) are NEVER passed as flags — they leak via
68
- the process list and shell history. Pipe them on stdin or enter them at the
69
- interactive prompt:
70
- echo "$HUBSPOT_TOKEN" | fullstackgtm login hubspot
71
- fullstackgtm init [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]
72
- cold start: scaffold icp.json + enrich.config.json + a
73
- PLAYBOOK wired for this workspace (the CLI ships primitives,
74
- your agent is the orchestrator — see docs/recipes.md)
75
- fullstackgtm snapshot [source options] [--since <iso>] [--out <path> | --archive <dir>]
76
- fullstackgtm audit [source options] [audit options] [--save]
77
- fullstackgtm report [source options] [audit options] [report options]
78
- fullstackgtm diff --before <a.json> --after <b.json> [--json] [--fail-on-new-findings]
79
- fullstackgtm merge --input <a.json> --input <b.json> [...] --out <merged.json> [--json]
80
- fullstackgtm call parse --transcript <file> [--title t] [--source fathom|granola|...] [--model m] [--deterministic] [--json|--ndjson] [--out <path>]
81
- fullstackgtm call classify --transcript <file>|--call <parsed.json> [--llm] [--deterministic] [--json]
82
- fullstackgtm call score --transcript <file>|--call <parsed.json> [--call-type <t>] [--rubric <rubric.json>] [--model m] [--json|--out <path>]
83
- fullstackgtm call link --attendees <a@x.com,...> | --domain <x.com> [source options] [--json]
84
- fullstackgtm call plan --transcript <file>|--call <parsed.json> --deal <id> [source options] [--save|--json]
85
- calls become evidence: LLM extraction by default (bring your own
86
- Anthropic or OpenAI key — captured once on first use, or
87
- ANTHROPIC_API_KEY/OPENAI_API_KEY, or \`login anthropic|openai\`);
88
- --deterministic uses the free keyword baseline. Then link the call
89
- to its deal and propose governed next-step writes.
90
- fullstackgtm resolve <account|contact|deal> [--name N] [--domain D] [--email E] [--account-id A] [source options] [--json]
91
- the create gate: exit 0 = safe to create, exit 2 = match
92
- found (exists/ambiguous) — call before ANY record creation
93
- fullstackgtm market init --category <name> start a market map: vendors + claim taxonomy as reviewable config
94
- fullstackgtm market capture [--config <path>] [--run <label>]
95
- fullstackgtm market classify [--run <label>] [--vendor <id>] [--model m] [--out <path>]
96
- fullstackgtm market worksheet --vendor <id> [--out <path>]
97
- fullstackgtm market observe --from <observations.json> [--unverified]
98
- fullstackgtm market fronts [--run <label>] [--diff <prior-run>] [--json]
99
- fullstackgtm market axes [--run <label>] [--json]
100
- fullstackgtm market report [--run <label>] [--format md|html] [--out <path>]
101
- fullstackgtm market overlay --snapshot <crm.json> [--calls <files>] [--save]
102
- fullstackgtm market scale [--json]
103
- fullstackgtm market refresh [--run <label>] [--model m]
104
- the live competitive map: capture vendor pages (content-addressed),
105
- classify intensity per claim (LLM bring-your-own-key, or fill the
106
- worksheet with any agent) — every quoted span is verified verbatim
107
- against the stored capture it cites before it's accepted — then
108
- compute deterministic front states and drift, render the field
109
- report. refresh = capture → classify → drift → report in one step
110
- fullstackgtm enrich append [--source apollo] [--objects companies,contacts] [--save] [--config <path>] [source options]
111
- fullstackgtm enrich refresh [--source apollo] [--stale-days <n>] [--save] [--config <path>] [source options]
112
- fullstackgtm enrich ingest <file.csv|payload.json> --source clay [--run-label <label>]
113
- fullstackgtm enrich status [--runs] [--source <id>] [--json]
114
- governed enrichment: pull (Apollo) or stage (Clay) third-party
115
- data, match it to CRM records deterministically, and emit a
116
- fill-blanks-only patch plan through the normal dry-run →
117
- approve → apply gate. refresh re-checks stale stamped fields
118
- and proposes updates only where the source value changed.
119
- fullstackgtm signals fetch [--bucket job,…] [--source greenhouse,lever,ashby] [--watchlist <path|crm:seg>] [--keywords …] [--from <file.json>] [--save]
120
- fullstackgtm signals list [--since 7d] [--bucket b] [--account d] [--unjudged]
121
- fullstackgtm signals outcome --account <d> [--touch <id>] --result replied|meeting|bounced|no_reply
122
- fullstackgtm signals weights [--explain]
123
- detect fresh buying triggers (ATS hiring scrapes + staged
124
- funding/company/social ingest), rank them, persist a local
125
- signal ledger. READ-ONLY re: CRM — fetch NEVER emits a plan;
126
- --save persists only the signal ledger. outcome feeds the
127
- learned per-bucket weights.
128
- fullstackgtm icp interview | set <answers.json> | show
129
- fullstackgtm icp judge [--signals-from latest|<label>] [--with-history] [--prompt <path>] [--min-score 0] [--save]
130
- fullstackgtm icp eval [--golden <path>|default] [--against-outcomes] [--min-accuracy 0.8] [--json]
131
- build the ICP, then judge fresh signals into
132
- send/nurture/skip (timing × fit × memory). Deterministic
133
- baseline; LLM why-now + play with a key (gated verbatim).
134
- eval grades the judge and exits 2 below the bar — the
135
- probabilistic-judgment gate. Read-only; --save writes only
136
- the local judge store.
137
- fullstackgtm draft [--from-judge latest|<label>] [--min-score 80] [--prompt <path>] [--channel email|linkedin|task] [--save]
138
- author one trigger-grounded opener per hot judge decision as
139
- a governed create_task plan. First line must contain a
140
- verbatim span of the why-now or the draft is rejected. Never
141
- sends — --save stages a needs_approval plan for approve → apply.
142
- fullstackgtm bulk-update <account|contact|deal> --where <expr> [--where …] (--set <field>=<value> [--set …] | --archive [--force-archive-duplicates] | --create-task <text>) [--require <field>=<value> …] [--guard <object>:<where>[;<where>]:<none|some> …] [source options] [--save] [--json] [--out <path>]
143
- governed generic writes: filter the snapshot
144
- (field=value, field!=value, field~substr, field!~substr,
145
- field:empty, field:notempty, '|' = any-of; canonical fields
146
- like ownerId, stage, closeDate, amount; relational
147
- pseudo-fields account.name/domain/ownerId/contactCount/
148
- openDealStages on deals and contacts, contactCount/
149
- openDealCount/openDealStages on accounts) into a dry-run
150
- patch plan. The full filter is re-verified per record at
151
- apply time (incl. mid-apply rechecks); equality filters
152
- double as preconditions; per-record ops apply
153
- all-or-nothing; guards assert cross-record conditions.
154
- --set <field>=from:<sourceField> derives the value PER
155
- RECORD from the snapshot (relational sources like
156
- account.ownerId included); records whose source is empty
157
- are skipped and counted, never guessed. --archive refuses
158
- records that share their identity key (account domain /
159
- contact email) with another record — merge those with
160
- \`dedupe\` instead, or --force-archive-duplicates.
161
- fullstackgtm dedupe <account|contact|deal> --key <domain|email|name> [--keep richest|oldest] [source options] [--reason <text>] [--max-operations <n>] [--save] [--json] [--out <path>]
162
- find duplicate groups by normalized identity key and build
163
- a dry-run plan of merge_records operations — one per group,
164
- deterministic survivor (richest = most populated data
165
- fields, ties to lowest id; oldest = lowest id). Approve and
166
- apply like any plan; merges are IRREVERSIBLE on apply.
167
- fullstackgtm reassign (--from <ownerId> | --assign-unowned) --to <ownerId> [--objects account,contact,deal] [--where <expr> …] [--except-deal-stage <stage>] [--include-closed-deals] [source options] [--save] [--json] [--out <path>]
168
- ownership handoff playbook: one bulk-update-style plan per
169
- object type (ownerId=<from> → <to>). --assign-unowned instead
170
- claims every ownerless record (ownerId:empty) for --to. Extra --where scoping
171
- is account-lifted for deals/contacts (domain~.de becomes
172
- account.domain~.de); --except-deal-stage <stage> excludes
173
- deals in that stage AND every record whose account has an
174
- open deal in it, re-verified per record at apply time.
175
- Deal plans cover open deals only unless
176
- --include-closed-deals.
177
- fullstackgtm fix --rule <ruleId> --provider <name> [--min-confidence high|low] [--include-creates] [--today <iso>] [--yes]
178
- one-shot composite: audit ONE rule → save the plan →
179
- suggest values → approve only suggestion-backed operations
180
- meeting the confidence bar (plus operations that need no
181
- value) → with --yes, apply through the provider and print a
182
- stage-by-stage summary. Without --yes it stops after
183
- approval and prints the apply command.
184
- fullstackgtm schedule add "<command>" --cron "<expr>" [--label <name>] [--provider local]
185
- fullstackgtm schedule list | remove <id> | enable <id> | disable <id> | run <id>
186
- fullstackgtm schedule install | uninstall [--provider local]
187
- fullstackgtm schedule status [<id>] [--runs <n>]
188
- declare a cadence for any read/plan-side command (audit,
189
- snapshot, enrich append|refresh, market capture|refresh,
190
- suggest, report, doctor); install materializes enabled
191
- entries into a sentinel-delimited managed crontab block.
192
- Scheduling never auto-approves: apply is schedulable only
193
- as apply --plan-id <id>, re-checked approved at run time.
194
- fullstackgtm suggest --plan-id <id> | --plan <path> [source options] [--json] [--out <path>]
195
- derive values for requires_human_* placeholders
196
- from snapshot evidence, with confidence + reasons
197
- fullstackgtm plans list [--status <s>] | show <id> | reject <id>
198
- fullstackgtm plans approve <id> --operations <ids|all> [--value <opId>=<v>]
199
- fullstackgtm plans approve <id> --values-from <suggestions.json> [--min-confidence high|low] [--include-creates]
200
- fullstackgtm apply --plan-id <id> --provider <name>
201
- fullstackgtm apply --plan <path> --provider <name> --approve <ids|all> [options]
202
- fullstackgtm audit-log export [--out <path>] | verify --in <path> tamper-evident apply-run record
203
- fullstackgtm rules [--json]
204
- fullstackgtm profiles [--json] list credential profiles
205
- fullstackgtm doctor [--json] check install, credentials, and next step
206
-
207
- Profiles (multi-organization use):
208
- --profile <name> Scope credentials AND stored plans to a named profile
209
- (also: FULLSTACKGTM_PROFILE). One profile per client
210
- org keeps logins isolated and prevents a plan proposed
211
- against one CRM from being applied through another's
212
- credentials. Omitted = the default profile.
213
-
214
- Plan lifecycle:
215
- audit --save persists the dry-run plan to ~/.fullstackgtm/plans. Approve
216
- specific operations (optionally with --value <opId>=<v> for placeholders),
217
- then apply by id — the store enforces approval and records every run.
218
-
219
- Authentication (checked in order):
220
- 1. --token-env <name> explicit env var for this invocation (hubspot)
221
- 2. ambient env HUBSPOT_ACCESS_TOKEN, or SALESFORCE_ACCESS_TOKEN +
222
- SALESFORCE_INSTANCE_URL (CI, agent sandboxes)
223
- 3. stored provider login fullstackgtm login <provider>; kept in ~/.fullstackgtm
224
- (hubspot: private app token or loopback OAuth;
225
- salesforce: device flow — code on any device, no
226
- secret, silent refresh)
227
- 4. broker pairing fullstackgtm login --via <url>: the team connects the
228
- CRM once in the hosted dashboard; every paired CLI
229
- uses those stored sync credentials from then on
230
-
231
- Source options (snapshot and audit):
232
- --sample Built-in minimal mock CRM data (default)
233
- --demo Realistic generated mid-market CRM with injected hygiene issues
234
- --seed <n> Demo PRNG seed (default 7; same seed, same data)
235
- --input <path> Canonical GTM snapshot JSON file
236
- --provider <name> Live provider snapshot: hubspot | salesforce | stripe (read-only)
237
- --token-env <name> Env var holding the provider access token
238
-
239
- Audit options:
240
- --config <path> Config file (default: ./fullstackgtm.config.json if present)
241
- { "policy": {...}, "rules": {"enabled":[],"disabled":[]},
242
- "rulePackages": ["./team-rules.mjs"] }
243
- --rules <ids> Comma-separated rule ids to run (default: all; see \`rules\`)
244
- --json Print the JSON patch plan instead of markdown
245
- --out <path> Also write the JSON patch plan to a file
246
- --today <date> Override today's date for deterministic audits
247
- --stale-days <n> Days without activity before an open deal is stale
248
- --fail-on <severity> Exit 2 if any finding is at or above info|warning|critical
249
-
250
- Report options (report renders the audit as a client-ready deliverable):
251
- --plan <path> Render an existing plan JSON instead of re-auditing
252
- (add --input <snapshot.json> for record counts)
253
- --client <name> Organization name shown in the heading and summary
254
- --title <text> Report heading (default "GTM Data Health Report")
255
- --prepared-by <name> Attribution shown in the footer
256
- --format <fmt> markdown (default) or html (self-contained, printable;
257
- inferred from an --out path ending in .html)
258
- --max-examples <n> Example records listed per rule (default 10)
259
- --out <path> Write the report to a file instead of stdout
260
-
261
- Apply options:
262
- --plan <path> Patch plan JSON produced by \`audit --out\`
263
- --provider hubspot Connector to apply through
264
- --token-env <name> Env var holding the provider access token
265
- --approve <ids|all> Comma-separated operation ids to apply, or "all"
266
- --value <opId>=<v> Concrete value for a requires_human_* placeholder (repeatable)
267
- --json Print the JSON run record instead of markdown
268
-
269
- Exit codes:
270
- 0 success · 1 error · 2 audit findings at/above the --fail-on threshold
271
-
272
- Safety:
273
- Audits are read-only. Apply writes only operations you explicitly approve,
274
- and never writes requires_human_* placeholders without a --value override.`;
275
- }
276
- const HELP = {
277
- // Get started
278
- init: {
279
- summary: "scaffold a workspace (icp.json + enrich.config.json + PLAYBOOK)",
280
- phase: "Setup",
281
- synopsis: [
282
- "fullstackgtm init [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]",
283
- ],
284
- detail: "Cold start: writes a starter ICP, an acquire-ready enrich.config.json (with a visible assign seam so leads are never ownerless), and a PLAYBOOK wired with the cold-start + outbound-loop recipes for this workspace. Pure file-writer — no network, keeps existing files unless --force. The CLI ships governed primitives; your coding agent is the orchestrator (see docs/recipes.md).",
285
- seeAlso: ["icp", "enrich", "signals"],
286
- },
287
- // Setup & health
288
- login: {
289
- summary: "connect a provider or LLM key (secrets via stdin/env, never argv)",
290
- phase: "Setup",
291
- synopsis: [
292
- "fullstackgtm login --via <hosted url> pair with a team deployment",
293
- "fullstackgtm login hubspot | salesforce | stripe",
294
- "fullstackgtm login anthropic | openai | apollo",
295
- ],
296
- detail: "Secrets are NEVER passed as flags (they leak via the process list and shell history) — pipe on stdin or enter at the prompt: `echo \"$TOKEN\" | fullstackgtm login hubspot`.",
297
- seeAlso: ["doctor", "logout", "profiles"],
298
- },
299
- logout: {
300
- summary: "remove stored credentials for a provider",
301
- phase: "Setup",
302
- synopsis: ["fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|apollo|pipe0|explorium|broker>"],
303
- seeAlso: ["login", "doctor"],
304
- },
305
- doctor: {
306
- summary: "check install, credentials, and the next step",
307
- phase: "Setup",
308
- synopsis: ["fullstackgtm doctor [--json]"],
309
- detail: "Verifies Node version, the credential store, MCP peers, and prints what to run next.",
310
- seeAlso: ["login", "audit"],
311
- },
312
- profiles: {
313
- summary: "list credential profiles (one per client org)",
314
- phase: "Setup",
315
- synopsis: ["fullstackgtm profiles [--json]"],
316
- detail: "`--profile <name>` (or FULLSTACKGTM_PROFILE) scopes credentials AND stored plans per org, so a plan proposed against one CRM can't be applied through another's credentials.",
317
- seeAlso: ["login", "plans", "health"],
318
- },
319
- health: {
320
- summary: "CRM health score + trend for the active profile (read-only)",
321
- phase: "Detect",
322
- synopsis: ["fullstackgtm health [--json]"],
323
- detail: "The engagement-workspace rollup. Each `audit --save` stamps a deterministic 0–100 hygiene score (100 / (1 + severity-weighted findings per record)) onto the profile's timeline; `health` reports the current score, the change since the last audit, and per-rule deltas — turning episodic audits into a continuous record. Scope per client with `--profile <name>`.",
324
- seeAlso: ["audit", "profiles", "report"],
325
- },
326
- // Detect — read-only
327
- snapshot: {
328
- summary: "pull a canonical GTM snapshot (read-only)",
329
- phase: "Detect",
330
- synopsis: ["fullstackgtm snapshot [source options] [--since <iso>] [--out <path> | --archive <dir>]"],
331
- detail: "Materializes the provider's records as canonical JSON — the input every audit and write verb reads.",
332
- options: [
333
- ["--provider <name>", "hubspot | salesforce | stripe (read-only)"],
334
- ["--demo", "realistic generated CRM with injected hygiene issues"],
335
- ["--out <path>", "write the snapshot JSON to a file"],
336
- ],
337
- seeAlso: ["audit", "diff"],
338
- },
339
- audit: {
340
- summary: "read-only hygiene audit → reviewable dry-run patch plan",
341
- phase: "Detect",
342
- synopsis: ["fullstackgtm audit [source options] [audit options] [--save]"],
343
- detail: "The Detect layer of the loop. Runs deterministic rules over a snapshot and proposes a typed patch plan — nothing is written. Prints a summary (rule table + counts) by default; `--full` shows every operation. `--save` persists the plan for the suggest → approve → apply spine. For a client-ready writeup use `report`; for machine output add `--json`.",
344
- options: [
345
- ["--provider <name>", "live snapshot: hubspot | salesforce | stripe"],
346
- ["--demo", "try it with zero credentials on a messy generated CRM"],
347
- ["--full", "show every operation, not just the rule summary"],
348
- ["--rules <ids>", "comma-separated rule ids (default: all; see `rules`)"],
349
- ["--fail-on <sev>", "exit 2 if any finding ≥ info|warning|critical"],
350
- ["--save", "persist the dry-run plan for approve → apply"],
351
- ["--json / --out <p>", "machine-readable plan to stdout / file"],
352
- ],
353
- seeAlso: ["report", "suggest", "plans", "apply", "diff"],
354
- },
355
- report: {
356
- summary: "render an audit as a client-ready deliverable (md/html)",
357
- phase: "Detect",
358
- synopsis: ["fullstackgtm report [source options] [audit options] [report options]"],
359
- detail: "The human-readable counterpart to `audit` — a clean summary instead of the full per-operation dump.",
360
- options: [
361
- ["--plan <path>", "render an existing plan JSON instead of re-auditing"],
362
- ["--client <name>", "organization name in the heading/summary"],
363
- ["--format <fmt>", "markdown (default) or self-contained html"],
364
- ["--out <path>", "write to a file (html inferred from .html)"],
365
- ],
366
- seeAlso: ["audit"],
367
- },
368
- diff: {
369
- summary: "compare two snapshots/plans; gate on new findings",
370
- phase: "Detect",
371
- synopsis: ["fullstackgtm diff --before <a.json> --after <b.json> [--json] [--fail-on-new-findings]"],
372
- detail: "The regression primitive. Exit 2 when a (rule, record) pair fires that didn't before — wire it into CI to catch CRM rot.",
373
- seeAlso: ["audit", "snapshot", "merge"],
374
- },
375
- rules: {
376
- summary: "list the audit rule registry",
377
- phase: "Detect",
378
- synopsis: ["fullstackgtm rules [--json]"],
379
- seeAlso: ["audit"],
380
- },
381
- // Prevent — gate writes before they happen
382
- resolve: {
383
- summary: "the create gate: exit 0 = safe to create, 2 = match exists",
384
- phase: "Prevent",
385
- synopsis: ["fullstackgtm resolve <account|contact|deal> [--name N] [--domain D] [--email E] [source options] [--json]"],
386
- detail: "Prevention, not cleanup. Call before ANY record creation — a sync job, webhook, agent, or script. Returns exists/ambiguous/safe_to_create with matches and reasons; exit 2 = do not create.",
387
- seeAlso: ["dedupe", "audit"],
388
- },
389
- // Remediate — governed writes (all produce plans; nothing writes outside approve → apply)
390
- fix: {
391
- summary: "one-shot composite: audit one rule → suggest → approve → apply",
392
- phase: "Remediate",
393
- synopsis: ["fullstackgtm fix --rule <ruleId> --provider <name> [--min-confidence high|low] [--include-creates] [--yes]"],
394
- detail: "The whole governed loop for a single rule in one command. Without `--yes` it stops after approval and prints the apply command; with `--yes` it applies suggestion-backed operations meeting the confidence bar and prints a stage-by-stage summary.",
395
- seeAlso: ["audit", "suggest", "plans", "apply"],
396
- },
397
- dedupe: {
398
- summary: "merge duplicate groups by identity key (irreversible on apply)",
399
- phase: "Remediate",
400
- synopsis: ["fullstackgtm dedupe <account|contact|deal> --key <domain|email|name> [--keep richest|oldest] [--save] [--json]"],
401
- detail: "Builds a dry-run plan of one merge_records op per duplicate group with a deterministic survivor (richest = most populated fields; oldest = lowest id). Approve and apply like any plan; merges are IRREVERSIBLE on apply.",
402
- seeAlso: ["resolve", "plans", "apply"],
403
- },
404
- reassign: {
405
- summary: "ownership handoff: one plan per object type",
406
- phase: "Remediate",
407
- synopsis: ["fullstackgtm reassign (--from <ownerId> | --assign-unowned) --to <ownerId> [--objects account,contact,deal] [--where <expr> …] [--save] [--json]"],
408
- detail: "Extra `--where` scoping is account-lifted for deals/contacts. `--except-deal-stage <stage>` excludes that stage and any record whose account has an open deal in it, re-verified per record at apply.",
409
- seeAlso: ["bulk-update", "plans", "apply"],
410
- },
411
- enrich: {
412
- summary: "governed third-party enrichment (Apollo/Clay), fill-blanks-only",
413
- phase: "Remediate",
414
- synopsis: ["fullstackgtm enrich append|refresh|ingest|status … (run `enrich --help` for full options)"],
415
- detail: "Pull (Apollo) or stage (Clay) data, match it to CRM records deterministically, and emit a fill-blanks-only patch plan through the normal dry-run → approve → apply gate. `refresh` re-checks stale stamped fields.",
416
- seeAlso: ["plans", "apply", "schedule"],
417
- },
418
- // Calls → evidence
419
- call: {
420
- summary: "transcripts → evidence, rubric scores, deal links, governed writes",
421
- phase: "Remediate",
422
- synopsis: ["fullstackgtm call parse|classify|score|link|plan … (run `call --help` for full options)"],
423
- detail: "`parse` normalizes any transcript into canonical segments + evidence (LLM by default, bring your own key; `--deterministic` for the free baseline). `classify` picks the call type, `score` rates it against the type's rubric, `link` finds the deal, `plan` proposes governed next-step writes.",
424
- seeAlso: ["plans", "apply"],
425
- },
426
- // Govern — the plan/apply spine
427
- suggest: {
428
- summary: "derive values for requires_human_* placeholders (evidence-backed)",
429
- phase: "Govern",
430
- synopsis: ["fullstackgtm suggest --plan-id <id> | --plan <path> [source options] [--json] [--out <path>]"],
431
- detail: "Read-only. Proposes concrete values with confidence + reasons from snapshot evidence; feed the output to `plans approve --values-from`. Never guesses — low/create/none-confidence entries are left to the human.",
432
- seeAlso: ["audit", "plans", "apply"],
433
- },
434
- plans: {
435
- summary: "plan lifecycle: list / show / approve / reject saved plans",
436
- phase: "Govern",
437
- synopsis: [
438
- "fullstackgtm plans list [--status <s>] | show <id> | reject <id>",
439
- "fullstackgtm plans approve <id> --operations <ids|all> [--value <opId>=<v>]",
440
- "fullstackgtm plans approve <id> --values-from <suggestions.json> [--min-confidence high|low]",
441
- ],
442
- detail: "Approval is explicit and per-operation; placeholders need a concrete --value or a suggestion to be approvable.",
443
- seeAlso: ["audit", "suggest", "apply"],
444
- },
445
- apply: {
446
- summary: "write ONLY explicitly approved operations to a provider",
447
- phase: "Govern / Verify",
448
- synopsis: [
449
- "fullstackgtm apply --plan-id <id> --provider <name>",
450
- "fullstackgtm apply --plan <path> --provider <name> --approve <ids|all> [--value <opId>=<v>]",
451
- ],
452
- detail: "The only verb that mutates a CRM. Writes only operations approved via `plans approve` or `--approve`, with compare-and-set and readback. Never writes requires_human_* placeholders without a --value override.",
453
- seeAlso: ["plans", "suggest", "audit-log"],
454
- },
455
- "audit-log": {
456
- summary: "tamper-evident record of apply runs (export / verify)",
457
- phase: "Verify",
458
- synopsis: ["fullstackgtm audit-log export [--out <path>] | verify --in <path>"],
459
- detail: "The Verify/Attribute layer — a signed, append-only record of every applied write.",
460
- seeAlso: ["apply"],
461
- },
462
- merge: {
463
- summary: "merge multiple plan/snapshot JSONs into one",
464
- phase: "Govern",
465
- synopsis: ["fullstackgtm merge --input <a.json> --input <b.json> [...] --out <merged.json> [--json]"],
466
- seeAlso: ["diff", "audit"],
467
- },
468
- // Continuous
469
- schedule: {
470
- summary: "declare a cadence for read/plan-side commands (never auto-approves)",
471
- phase: "Continuous",
472
- synopsis: ["fullstackgtm schedule add|list|remove|enable|disable|run|install|status … (run `schedule --help` for full options)"],
473
- detail: "Makes the loop continuous instead of episodic. Read/plan-side allowlist only (audit, snapshot, enrich, market, suggest, report, doctor). Scheduling NEVER auto-approves: apply is schedulable only as `apply --plan-id <id>`, re-checked approved at run time.",
474
- seeAlso: ["audit", "enrich", "apply"],
475
- },
476
- // Market intelligence
477
- market: {
478
- summary: "live competitive category map (capture → classify → drift → report)",
479
- phase: "Intelligence",
480
- synopsis: ["fullstackgtm market init|capture|classify|worksheet|observe|fronts|axes|overlay|scale|report|refresh … (run `market --help` for full options)"],
481
- detail: "Capture vendor pages (content-addressed), classify intensity per claim (LLM bring-your-own-key, or fill the worksheet with any agent), then compute deterministic front states and drift. Every quoted span is verified verbatim against the stored capture before it's accepted.",
482
- seeAlso: [],
483
- },
484
- // Outbound intelligence — signals → judge → draft (the GTM brain)
485
- signals: {
486
- summary: "detect fresh buying triggers (ATS hiring + staged ingest), ranked",
487
- phase: "Detect",
488
- synopsis: [
489
- "fullstackgtm signals fetch [--bucket job,…] [--source greenhouse,lever,ashby] [--watchlist <path|crm:seg>] [--keywords …] [--from <file.json>] [--save]",
490
- "fullstackgtm signals list [--since 7d] [--bucket b] [--account d] [--unjudged]",
491
- "fullstackgtm signals outcome --account <d> [--touch <id>] --result replied|meeting|bounced|no_reply",
492
- "fullstackgtm signals weights [--explain]",
493
- ],
494
- detail: "Read-only re: CRM — `fetch` NEVER emits a patch plan; `--save` persists only the local signal ledger. ATS adapters are no-auth. `outcome` feeds the learned per-bucket `weights`.",
495
- seeAlso: ["icp", "draft"],
496
- },
497
- icp: {
498
- summary: "build the ICP, then judge fresh signals into send/nurture/skip",
499
- phase: "Detect",
500
- synopsis: [
501
- "fullstackgtm icp interview | set <answers.json> | show",
502
- "fullstackgtm icp judge [--signals-from latest|<label>] [--with-history] [--prompt <path>] [--min-score 0] [--save]",
503
- "fullstackgtm icp eval [--golden <path>|default] [--against-outcomes] [--min-accuracy 0.8] [--json]",
504
- ],
505
- detail: "`judge` ranks unjudged signals by timing × fit × memory (deterministic baseline; LLM why-now + play with a key, gated verbatim). `eval` is the probabilistic-judgment gate — exits 2 below the bar. Read-only; --save writes only the local judge store.",
506
- seeAlso: ["signals", "draft", "enrich"],
507
- },
508
- draft: {
509
- summary: "author one trigger-grounded opener per hot decision as a governed plan",
510
- phase: "Remediate",
511
- synopsis: [
512
- "fullstackgtm draft [--from-judge latest|<label>] [--min-score 80] [--prompt <path>] [--channel email|linkedin|task] [--save]",
513
- ],
514
- detail: "Takes hot judge decisions (send, score>=min) and stages ONE create_task opener each. The first line must contain a verbatim span of the why-now or the draft is rejected. Never sends — --save stages a needs_approval plan for `plans approve` -> `apply`.",
515
- seeAlso: ["icp", "plans", "apply"],
516
- },
517
- };
518
- // Verbs that print their own richer multi-subcommand help; runCli routes their
519
- // `--help` to themselves, so commandHelp() only renders these via `help <verb>`.
520
- const BESPOKE_HELP = ["init", "call", "market", "enrich", "bulk-update", "schedule", "signals", "icp", "draft"];
521
- // Lifecycle-grouped front door. One line per verb, organized by the
522
- // Prevent→Detect→Remediate→Verify loop so a new user sees ~6 jobs, not 22 verbs.
523
- function shortUsage() {
524
- const groups = [
525
- ["Get started", ["init"]],
526
- ["Setup & health", ["login", "logout", "doctor", "profiles", "health"]],
527
- ["Detect — read-only", ["audit", "report", "snapshot", "diff", "rules"]],
528
- ["Prevent — gate writes", ["resolve"]],
529
- ["Remediate — governed writes", ["fix", "bulk-update", "dedupe", "reassign", "enrich"]],
530
- ["Calls → evidence", ["call"]],
531
- ["Govern — the plan/apply spine", ["suggest", "plans", "apply", "audit-log", "merge"]],
532
- ["Market intelligence", ["market"]],
533
- ["Outbound — signals → judge → draft", ["signals", "icp", "draft"]],
534
- ["Schedule — make it continuous", ["schedule"]],
535
- ];
536
- const pad = Math.max(...Object.keys(HELP).map((k) => k.length)) + 2;
537
- const lines = [
538
- "FullStackGTM — plan/apply for your GTM stack.",
539
- "Audit CRM data, propose reviewable patch plans, apply only what you approve.",
540
- "",
541
- "Usage: fullstackgtm <command> [options]",
542
- "",
543
- ];
544
- for (const [title, cmds] of groups) {
545
- lines.push(`${title}:`);
546
- for (const cmd of cmds) {
547
- const entry = HELP[cmd];
548
- if (!entry)
549
- continue;
550
- lines.push(` ${cmd.padEnd(pad)}${entry.summary}`);
551
- }
552
- lines.push("");
553
- }
554
- lines.push("Zoom in: fullstackgtm <command> --help focused help for one command", "Full ref: fullstackgtm help --full every flag and option", "Try it: fullstackgtm audit --demo zero-credential demo CRM", "", "Safety: audits are read-only; apply writes only operations you approve.");
555
- return lines.join("\n");
556
- }
557
- // Focused help for a single command. Falls back to shortUsage() for anything
558
- // unknown so `--help` never dead-ends on the full wall.
559
- function commandHelp(command) {
560
- const entry = HELP[command];
561
- if (!entry)
562
- return shortUsage();
563
- const lines = [`fullstackgtm ${command} — ${entry.summary}`, "", "Usage:"];
564
- for (const s of entry.synopsis)
565
- lines.push(` ${s}`);
566
- if (entry.detail)
567
- lines.push("", entry.detail);
568
- if (entry.options?.length) {
569
- lines.push("", "Options:");
570
- const pad = Math.max(...entry.options.map(([f]) => f.length)) + 2;
571
- for (const [flag, desc] of entry.options)
572
- lines.push(` ${flag.padEnd(pad)}${desc}`);
573
- }
574
- lines.push("", `Lifecycle phase: ${entry.phase}`);
575
- if (entry.seeAlso?.length)
576
- lines.push(`See also: ${entry.seeAlso.join(", ")}`);
577
- if (BESPOKE_HELP.includes(command))
578
- lines.push("", `Run \`fullstackgtm ${command} --help\` for the full subcommand reference.`);
579
- lines.push("", "Full reference: fullstackgtm help --full");
580
- return lines.join("\n");
581
- }
582
- function option(args, name) {
583
- const index = args.indexOf(name);
584
- if (index === -1)
585
- return null;
586
- return args[index + 1] ?? null;
587
- }
588
- function repeatedOption(args, name) {
589
- const values = [];
590
- for (let index = 0; index < args.length; index += 1) {
591
- if (args[index] === name && args[index + 1] !== undefined) {
592
- values.push(args[index + 1]);
593
- }
594
- }
595
- return values;
596
- }
597
- function numericOption(args, name) {
598
- const value = option(args, name);
599
- if (value === null)
600
- return undefined;
601
- const parsed = Number(value);
602
- if (!Number.isFinite(parsed))
603
- throw new Error(`${name} must be a number`);
604
- return parsed;
605
- }
606
- async function hubspotConnection(args) {
607
- const tokenEnv = option(args, "--token-env");
608
- if (tokenEnv) {
609
- const token = process.env[tokenEnv];
610
- if (!token)
611
- throw new Error(`${tokenEnv} is not set.`);
612
- return { accessToken: token };
613
- }
614
- if (process.env.HUBSPOT_ACCESS_TOKEN) {
615
- return { accessToken: process.env.HUBSPOT_ACCESS_TOKEN };
616
- }
617
- const stored = await resolveHubspotConnection();
618
- if (stored)
619
- return stored;
620
- throw new Error("No HubSpot credentials. Run `fullstackgtm login hubspot`, pair with a hosted deployment via `fullstackgtm login --via <url>`, or set HUBSPOT_ACCESS_TOKEN. (`fullstackgtm doctor` shows credential status; see the README's \"Connect your CRM\" section for the private-app scopes to grant.)");
621
- }
622
- async function salesforceConnection() {
623
- if (process.env.SALESFORCE_ACCESS_TOKEN && process.env.SALESFORCE_INSTANCE_URL) {
624
- return {
625
- accessToken: process.env.SALESFORCE_ACCESS_TOKEN,
626
- instanceUrl: process.env.SALESFORCE_INSTANCE_URL,
627
- };
628
- }
629
- const stored = await resolveSalesforceConnection();
630
- if (stored)
631
- return stored;
632
- throw new Error("No Salesforce credentials. Run `fullstackgtm login salesforce`, pair with a hosted deployment via `fullstackgtm login --via <url>`, or set SALESFORCE_ACCESS_TOKEN and SALESFORCE_INSTANCE_URL. (`fullstackgtm doctor` shows credential status; device-flow login needs an admin-created Connected App — see the README's \"Connect your CRM\" section.)");
633
- }
634
- async function connectorFor(provider, args) {
635
- if (provider === "hubspot") {
636
- const connection = await hubspotConnection(args);
637
- return createHubspotConnector({
638
- getAccessToken: () => connection.accessToken,
639
- fieldMappings: connection.fieldMappings ?? undefined,
640
- // Point at a mock/proxy HubSpot (tests, evals, request-recording).
641
- apiBaseUrl: process.env.HUBSPOT_API_BASE_URL,
642
- });
643
- }
644
- if (provider === "salesforce") {
645
- const connection = await salesforceConnection();
646
- return createSalesforceConnector({
647
- getConnection: () => connection,
648
- fieldMappings: connection.fieldMappings ?? undefined,
649
- });
650
- }
651
- if (provider === "stripe") {
652
- const key = process.env.STRIPE_SECRET_KEY ?? getCredential("stripe")?.accessToken;
653
- if (!key) {
654
- throw new Error("No Stripe credentials. Run `echo \"$STRIPE_KEY\" | fullstackgtm login stripe` or set STRIPE_SECRET_KEY. A restricted key with read access to Customers and Subscriptions is enough. (`fullstackgtm doctor` shows credential status.)");
655
- }
656
- return createStripeConnector({ getApiKey: () => key });
657
- }
658
- throw new Error(`Unknown provider: ${provider}. Supported providers: hubspot, salesforce, stripe`);
659
- }
660
- async function readSnapshot(args) {
661
- const provider = option(args, "--provider");
662
- if (provider) {
663
- const connector = await connectorFor(provider, args);
664
- return connector.fetchSnapshot();
665
- }
666
- if (args.includes("--demo")) {
667
- return generateDemoSnapshot({
668
- seed: numericOption(args, "--seed"),
669
- today: option(args, "--today") ?? undefined,
670
- });
671
- }
672
- if (args.includes("--sample"))
673
- return sampleSnapshot;
674
- const input = option(args, "--input");
675
- if (!input) {
676
- throw new Error("No data source. Pass one of: --provider <hubspot|salesforce|stripe> (live, read-only), " +
677
- "--input <snapshot.json> (a saved snapshot), --demo (a generated messy CRM), or " +
678
- "--sample (the tiny built-in fixture). Refusing to silently audit sample data.");
679
- }
680
- const path = resolve(process.cwd(), input);
681
- return JSON.parse(readFileSync(path, "utf8"));
682
- }
683
- function selectedRules(args, baseRules = builtinAuditRules) {
684
- const requested = option(args, "--rules");
685
- if (!requested)
686
- return baseRules;
687
- const ids = requested.split(",").map((id) => id.trim()).filter(Boolean);
688
- const known = new Map(baseRules.map((rule) => [rule.id, rule]));
689
- const rules = ids.map((id) => {
690
- const rule = known.get(id);
691
- if (!rule) {
692
- throw new Error(`Unknown rule: ${id}. Available rules: ${baseRules.map((r) => r.id).join(", ")}`);
693
- }
694
- return rule;
695
- });
696
- return rules;
697
- }
698
- const SEVERITY_RANK = {
699
- info: 0,
700
- warning: 1,
701
- critical: 2,
702
- };
703
- function failOnThreshold(args) {
704
- const value = option(args, "--fail-on");
705
- if (!value)
706
- return null;
707
- if (value !== "info" && value !== "warning" && value !== "critical") {
708
- throw new Error("--fail-on must be one of: info, warning, critical");
709
- }
710
- return value;
711
- }
712
- async function snapshotCommand(args) {
713
- const since = option(args, "--since");
714
- let snapshot;
715
- if (since) {
716
- const provider = option(args, "--provider");
717
- if (!provider)
718
- throw new Error("--since requires --provider <name>");
719
- const connector = await connectorFor(provider, args);
720
- if (!connector.fetchChanges) {
721
- throw new Error(`The ${provider} connector does not support incremental fetch.`);
722
- }
723
- snapshot = await connector.fetchChanges(since);
724
- }
725
- else {
726
- snapshot = await readSnapshot(args);
727
- }
728
- const serialized = `${JSON.stringify(snapshot, null, 2)}\n`;
729
- const archive = option(args, "--archive");
730
- if (archive) {
731
- const dir = resolve(process.cwd(), archive);
732
- const { mkdirSync } = await import("node:fs");
733
- mkdirSync(dir, { recursive: true });
734
- const fileName = `${snapshot.provider}-${snapshot.generatedAt.replace(/[:.]/g, "-")}.json`;
735
- const path = resolve(dir, fileName);
736
- writeFileSync(path, serialized);
737
- console.log(`Archived snapshot to ${path}`);
738
- return;
739
- }
740
- const out = option(args, "--out");
741
- if (out) {
742
- writeFileSync(resolve(process.cwd(), out), serialized);
743
- console.log(`Wrote snapshot (${snapshot.users.length} users, ${snapshot.accounts.length} accounts, ` +
744
- `${snapshot.contacts.length} contacts, ${snapshot.deals.length} deals, ` +
745
- `${snapshot.activities.length} activities) to ${out}`);
746
- }
747
- else {
748
- console.log(serialized.trimEnd());
749
- }
750
- }
751
- // #2 (dx-punch-list): every read verb points forward. `audit` is the keystone —
752
- // `audit --demo` is the most-run first command and used to dead-end on a blank
753
- // line. Context-aware guidance mirrors doctor's "Next step" and fix's chaining,
754
- // stepping the user along Detect → Govern → apply. Printed to stderr so stdout
755
- // stays clean for pipes/--out; suppressed under --json (machine/agent context).
756
- function auditNextStep(args, plan) {
757
- const provider = option(args, "--provider");
758
- const usingLiveData = Boolean(provider) || Boolean(option(args, "--input"));
759
- const saved = args.includes("--save");
760
- const count = plan.findings.length;
761
- if (count === 0) {
762
- return [
763
- "✓ No findings — this snapshot is clean. Nothing to apply.",
764
- " Keep it clean: gate new records with `fullstackgtm resolve`,",
765
- " and schedule a recurring check with `fullstackgtm schedule add ...`.",
766
- ].join("\n");
767
- }
768
- const head = `${count} finding${count === 1 ? "" : "s"} — a dry-run plan. Nothing was written.`;
769
- if (!usingLiveData) {
770
- return [
771
- head,
772
- "Next:",
773
- " • Client-ready writeup: fullstackgtm report --demo",
774
- " • Run on your CRM: fullstackgtm login hubspot → fullstackgtm audit --provider hubspot --save",
775
- ].join("\n");
776
- }
777
- if (!saved) {
778
- return [
779
- head,
780
- "Next: persist it with --save, then suggest → approve → apply:",
781
- ` fullstackgtm audit --provider ${provider ?? "<name>"} --save`,
782
- ].join("\n");
783
- }
784
- // --save already confirmed the plan id above; chain the governed spine.
785
- const providerFlag = provider ? ` --provider ${provider}` : "";
786
- return [
787
- head,
788
- "Next: derive values, approve the safe ones, apply:",
789
- ` fullstackgtm suggest --plan-id ${plan.id}${providerFlag} --out suggestions.json`,
790
- ` fullstackgtm plans approve ${plan.id} --values-from suggestions.json`,
791
- ` fullstackgtm apply --plan-id ${plan.id}${providerFlag}`,
792
- ].join("\n");
793
- }
794
- async function audit(args) {
795
- const threshold = failOnThreshold(args);
796
- const loaded = loadConfig(option(args, "--config") ?? undefined);
797
- const rules = selectedRules(args, await resolveConfiguredRules(loaded));
798
- const snapshot = await readSnapshot(args);
799
- const policy = mergePolicy(defaultPolicy(), loaded?.config);
800
- const today = option(args, "--today");
801
- if (today)
802
- policy.today = today;
803
- const staleDealDays = numericOption(args, "--stale-days");
804
- if (staleDealDays !== undefined)
805
- policy.staleDealDays = staleDealDays;
806
- const plan = auditSnapshot(snapshot, policy, rules);
807
- reportCounts({
808
- findings: plan.findings.length,
809
- critical: plan.findings.filter((f) => f.severity === "critical").length,
810
- warning: plan.findings.filter((f) => f.severity === "warning").length,
811
- });
812
- const out = option(args, "--out");
813
- if (out) {
814
- writeFileSync(resolve(process.cwd(), out), `${JSON.stringify(plan, null, 2)}\n`);
815
- }
816
- if (args.includes("--save")) {
817
- await createFilePlanStore().save(plan);
818
- // Engagement workspace: stamp the per-profile health timeline + snapshot so
819
- // the org accrues a continuous record from the verb people already run.
820
- // Honor --today (audit is deterministic under it); fall back to wall-clock.
821
- const health = computeHealth(plan, snapshot, today ?? new Date().toISOString());
822
- appendHealthEntry(health);
823
- saveWorkspaceSnapshot(plan.id, snapshot);
824
- console.error(`Saved plan ${plan.id}. Health ${health.score}/100 recorded (\`fullstackgtm health\`). ` +
825
- `Review with \`fullstackgtm plans show ${plan.id}\`, approve with \`fullstackgtm plans approve ${plan.id} --operations <ids|all>\`.`);
826
- }
827
- if (args.includes("--json")) {
828
- console.log(JSON.stringify(plan, null, 2));
829
- }
830
- else {
831
- // Default to the summary view (rule table + counts); the full per-operation
832
- // dump is opt-in via --full or, for a deliverable, `report`. (dx-punch-list #3)
833
- console.log(patchPlanToMarkdown(plan, { summary: !args.includes("--full") }));
834
- console.error(`\n${auditNextStep(args, plan)}`);
835
- }
836
- if (threshold &&
837
- plan.findings.some((finding) => SEVERITY_RANK[finding.severity] >= SEVERITY_RANK[threshold])) {
838
- process.exitCode = 2;
839
- }
840
- }
841
- /**
842
- * Roll up the active profile's health timeline (accrued by `audit --save`):
843
- * current deterministic score, change since the last audit, and per-rule
844
- * deltas. Read-only — it only reads `health.jsonl`, never re-audits.
845
- */
846
- function healthCommand(args) {
847
- const profile = activeWorkspaceProfile();
848
- const rollup = summarizeHealth(readHealthTimeline(), profile);
849
- if (!rollup) {
850
- if (args.includes("--json")) {
851
- console.log(JSON.stringify({ profile, auditCount: 0 }, null, 2));
852
- }
853
- else {
854
- console.log(`No audits recorded yet for profile "${profile}".\n` +
855
- "Start the timeline: `fullstackgtm audit --provider <name> --save`" +
856
- (profile === "default" ? "" : ` --profile ${profile}`) +
857
- ".");
858
- }
859
- return;
860
- }
861
- console.log(args.includes("--json") ? JSON.stringify(rollup, null, 2) : healthToMarkdown(rollup));
862
- }
863
- /**
864
- * Render an audit as a client-facing deliverable. Same sources and audit
865
- * options as `audit`; `--plan` instead renders an existing plan JSON without
866
- * re-fetching (useful for a plan produced earlier or by another machine).
867
- */
868
- async function reportCommand(args) {
869
- const loaded = loadConfig(option(args, "--config") ?? undefined);
870
- const configuredRules = await resolveConfiguredRules(loaded);
871
- let plan;
872
- let snapshot;
873
- const planPath = option(args, "--plan");
874
- if (planPath) {
875
- plan = JSON.parse(readFileSync(resolve(process.cwd(), planPath), "utf8"));
876
- const input = option(args, "--input");
877
- if (input) {
878
- snapshot = JSON.parse(readFileSync(resolve(process.cwd(), input), "utf8"));
879
- }
880
- }
881
- else {
882
- snapshot = await readSnapshot(args);
883
- const policy = mergePolicy(defaultPolicy(), loaded?.config);
884
- const today = option(args, "--today");
885
- if (today)
886
- policy.today = today;
887
- const staleDealDays = numericOption(args, "--stale-days");
888
- if (staleDealDays !== undefined)
889
- policy.staleDealDays = staleDealDays;
890
- plan = auditSnapshot(snapshot, policy, selectedRules(args, configuredRules));
891
- }
892
- const reportOptions = {
893
- title: option(args, "--title") ?? undefined,
894
- clientName: option(args, "--client") ?? undefined,
895
- preparedBy: option(args, "--prepared-by") ?? undefined,
896
- date: option(args, "--today") ?? undefined,
897
- maxExamplesPerRule: numericOption(args, "--max-examples"),
898
- rules: configuredRules,
899
- snapshot,
900
- };
901
- const out = option(args, "--out");
902
- const format = option(args, "--format") ?? (out?.endsWith(".html") ? "html" : "markdown");
903
- if (format !== "markdown" && format !== "html") {
904
- throw new Error("--format must be markdown or html");
905
- }
906
- const rendered = format === "html"
907
- ? auditReportToHtml(plan, reportOptions)
908
- : auditReportToMarkdown(plan, reportOptions);
909
- if (out) {
910
- writeFileSync(resolve(process.cwd(), out), rendered);
911
- console.log(`Wrote ${format} report (${plan.findings.length} findings) to ${out}`);
912
- }
913
- else {
914
- console.log(rendered.trimEnd());
915
- }
916
- }
917
- async function rulesCommand(args) {
918
- const loaded = loadConfig(option(args, "--config") ?? undefined);
919
- const rules = await resolveConfiguredRules(loaded);
920
- if (args.includes("--json")) {
921
- console.log(JSON.stringify(rules.map(({ id, title, description, category }) => ({
922
- id,
923
- title,
924
- description,
925
- category: category ?? "uncategorized",
926
- })), null, 2));
927
- return;
928
- }
929
- for (const rule of rules) {
930
- console.log(`${rule.id} [${rule.category ?? "uncategorized"}]\n ${rule.title}\n ${rule.description}\n`);
931
- }
932
- }
933
- /**
934
- * Provider error bodies can echo request parameters (including secrets) and
935
- * land in logs. Surface only the status and, when present, a known-safe error
936
- * description field — never the raw body.
937
- */
938
- function safeStatus(response) {
939
- return `HTTP ${response.status} ${response.statusText}`.trim();
940
- }
941
- function parseValueOverrides(args) {
942
- const valueOverrides = {};
943
- for (const pair of repeatedOption(args, "--value")) {
944
- const separator = pair.indexOf("=");
945
- if (separator === -1)
946
- throw new Error(`--value must look like <operationId>=<value>`);
947
- valueOverrides[pair.slice(0, separator)] = pair.slice(separator + 1);
948
- }
949
- return valueOverrides;
950
- }
951
- async function callCommand(args) {
952
- const [subcommand, ...rest] = args;
953
- if (args.includes("--help") || args.includes("-h")) {
954
- console.log(`call parse --transcript <file> [--title t] [--source s] [--model m] [--deterministic] [--json|--ndjson] [--out <path>]
955
- call classify --transcript <file>|--call <parsed.json> [--llm] [--deterministic] [--json] [--list]
956
- call score --transcript <file>|--call <parsed.json> [--call-type <t>] [--rubric <rubric.json>] [--model m] [--json|--out <path>] [--list-rubrics]
957
- call link --attendees <a@x.com,...> | --domain <x.com> [source options] [--json]
958
- call plan --transcript <file>|--call <parsed.json> --deal <id> [source options] [--save|--json]
959
-
960
- classify picks the call type (deterministic signals; --llm for a model tiebreak).
961
- score auto-selects the type-specific rubric from that classification unless you
962
- pass --call-type or --rubric. Call types: ${CALL_TYPE_IDS.join(", ")}.
963
-
964
- parse/score default to LLM extraction (Anthropic or OpenAI key via env,
965
- \`login anthropic|openai\`, or a one-time prompt). parse --deterministic is the
966
- free keyword baseline and classify --deterministic needs no key.
967
- score always needs a key (scoring is LLM work).`);
968
- return;
969
- }
970
- const loadParsedCall = async () => {
971
- const callPath = option(rest, "--call");
972
- if (callPath) {
973
- return JSON.parse(readFileSync(resolve(process.cwd(), callPath), "utf8"));
974
- }
975
- const transcriptPath = option(rest, "--transcript");
976
- if (!transcriptPath)
977
- throw new Error(`call ${subcommand} requires --transcript <file> or --call <parsed.json>`);
978
- const raw = readFileSync(resolve(process.cwd(), transcriptPath), "utf8");
979
- const source = option(rest, "--source");
980
- const base = {
981
- title: option(rest, "--title") ?? undefined,
982
- sourceSystem: source,
983
- capturedAt: new Date().toISOString(),
984
- };
985
- if (rest.includes("--deterministic")) {
986
- return parseCall(raw, base);
987
- }
988
- // LLM extraction is the default: bring-your-own-key (Anthropic or OpenAI).
989
- const credential = await requireLlmCredential();
990
- const normalized = normalizeTranscript(raw);
991
- const { insights, model } = await extractInsightsLlm(normalized, {
992
- ...credential,
993
- model: option(rest, "--model") ?? undefined,
994
- title: base.title,
995
- });
996
- return parseCall(raw, {
997
- ...base,
998
- insights,
999
- extractor: `llm:${credential.provider}:${model}`,
1000
- });
1001
- };
1002
- // Reconstruct plain transcript text from either a --transcript file (any
1003
- // dialect, normalized) or a parsed --call JSON. Shared by classify + score.
1004
- const loadTranscriptText = () => {
1005
- const transcriptPath = option(rest, "--transcript");
1006
- if (transcriptPath) {
1007
- return normalizeTranscript(readFileSync(resolve(process.cwd(), transcriptPath), "utf8"));
1008
- }
1009
- const callPath = option(rest, "--call");
1010
- if (!callPath)
1011
- throw new Error(`call ${subcommand} requires --transcript <file> or --call <parsed.json>`);
1012
- const parsed = JSON.parse(readFileSync(resolve(process.cwd(), callPath), "utf8"));
1013
- return parsed.segments.map((s) => (s.speaker ? `${s.speaker}: ${s.text}` : s.text)).join("\n");
1014
- };
1015
- if (subcommand === "classify") {
1016
- if (rest.includes("--list")) {
1017
- const lines = CALL_TYPES.map((d) => `${d.id.padEnd(22)} ${d.name} — ${d.definition}`);
1018
- console.log(rest.includes("--json") ? JSON.stringify(CALL_TYPES, null, 2) : lines.join("\n"));
1019
- return;
1020
- }
1021
- const transcriptText = loadTranscriptText();
1022
- const title = option(rest, "--title") ?? undefined;
1023
- const deterministic = classifyCall(transcriptText);
1024
- // LLM tiebreak: explicit --llm, or auto when the deterministic pass is unsure
1025
- // and a key is available (never required — deterministic always answers).
1026
- const wantLlm = rest.includes("--llm") || (!rest.includes("--deterministic") && deterministic.confidence !== "high" && Boolean(resolveLlmCredential()));
1027
- let result = deterministic;
1028
- if (wantLlm) {
1029
- const credential = await requireLlmCredential("score");
1030
- const llm = await classifyCallLlm(transcriptText, CALL_TYPES, {
1031
- ...credential,
1032
- model: option(rest, "--model") ?? undefined,
1033
- title,
1034
- });
1035
- result = { type: llm.type, confidence: "high", reason: llm.reason, method: "llm", model: llm.model };
1036
- }
1037
- if (rest.includes("--json")) {
1038
- console.log(JSON.stringify(result, null, 2));
1039
- return;
1040
- }
1041
- const def = CALL_TYPES.find((d) => d.id === result.type);
1042
- console.log(`Call type: ${def?.name ?? result.type} (${result.type})`);
1043
- console.log(`Confidence: ${result.confidence} · via ${result.method}${result.model ? ` (${result.model})` : ""}`);
1044
- console.log(`Why: ${result.reason}`);
1045
- if (result.method === "deterministic" && deterministic.candidates.length > 1) {
1046
- const others = deterministic.candidates.slice(1, 4).map((c) => `${c.type} (${c.score})`).join(", ");
1047
- console.log(`Other matches: ${others}`);
1048
- }
1049
- console.log(`\nScore it with this rubric: fullstackgtm call score ${option(rest, "--transcript") ? `--transcript ${option(rest, "--transcript")}` : `--call ${option(rest, "--call")}`} --call-type ${result.type}`);
1050
- return;
1051
- }
1052
- if (subcommand === "parse") {
1053
- const parsed = await loadParsedCall();
1054
- const outPath = option(rest, "--out");
1055
- if (outPath)
1056
- writeFileSync(resolve(process.cwd(), outPath), `${JSON.stringify(parsed, null, 2)}\n`);
1057
- if (rest.includes("--ndjson")) {
1058
- // One flat row per insight — warehouse-friendly (e.g. Snowflake COPY).
1059
- for (const insight of parsed.insights) {
1060
- console.log(JSON.stringify({
1061
- call_id: parsed.id,
1062
- call_title: parsed.title ?? null,
1063
- source_system: parsed.sourceSystem,
1064
- extractor: parsed.extractor,
1065
- type: insight.type,
1066
- title: insight.title,
1067
- text: insight.text,
1068
- evidence: insight.evidence,
1069
- speaker: insight.speaker ?? null,
1070
- confidence: insight.confidence,
1071
- importance: insight.importance,
1072
- }));
1073
- }
1074
- return;
1075
- }
1076
- if (rest.includes("--json") || outPath) {
1077
- if (!outPath)
1078
- console.log(JSON.stringify(parsed, null, 2));
1079
- return;
1080
- }
1081
- console.log(`Call ${parsed.id}${parsed.title ? ` — ${parsed.title}` : ""} (${parsed.sourceSystem})`);
1082
- console.log(`${parsed.segments.length} segments · ${parsed.insights.length} insights (${parsed.summary.highImportance} high-importance)\n`);
1083
- for (const insight of parsed.insights) {
1084
- console.log(`[${insight.type}] (importance ${insight.importance}) ${insight.text}`);
1085
- }
1086
- return;
1087
- }
1088
- if (subcommand === "link") {
1089
- const attendees = option(rest, "--attendees");
1090
- const domain = option(rest, "--domain");
1091
- if (!attendees && !domain)
1092
- throw new Error("call link requires --attendees <emails,comma-separated> and/or --domain <example.com>");
1093
- const snapshot = await readSnapshot(rest);
1094
- const suggestion = suggestCallDeal(snapshot, {
1095
- attendeeEmails: attendees?.split(",").map((e) => e.trim()).filter(Boolean),
1096
- domain: domain ?? undefined,
1097
- });
1098
- if (rest.includes("--json")) {
1099
- console.log(JSON.stringify(suggestion, null, 2));
1100
- }
1101
- else {
1102
- const marker = suggestion.confidence === "high" ? "✓" : suggestion.confidence === "low" ? "~" : "✗";
1103
- console.log(`${marker} [${suggestion.confidence}] ${suggestion.dealId ?? "no match"}${suggestion.dealName ? ` — ${suggestion.dealName}` : ""}`);
1104
- console.log(` ${suggestion.reason}`);
1105
- }
1106
- if (suggestion.confidence === "none")
1107
- process.exitCode = 1;
1108
- return;
1109
- }
1110
- if (subcommand === "plan") {
1111
- const dealId = option(rest, "--deal");
1112
- if (!dealId)
1113
- throw new Error("call plan requires --deal <dealId> (use `call link` to find it)");
1114
- const parsed = await loadParsedCall();
1115
- const snapshot = await readSnapshot(rest);
1116
- const deal = snapshot.deals.find((row) => row.id === dealId);
1117
- if (!deal)
1118
- throw new Error(`Deal ${dealId} is not in the snapshot — check the id or the snapshot source.`);
1119
- const nextSteps = parsed.insights.filter((insight) => insight.type === "next_step");
1120
- if (nextSteps.length === 0) {
1121
- console.log("No next-step insights in this call — nothing to plan. (Other insight types are evidence, not writes.)");
1122
- return;
1123
- }
1124
- const [top, ...others] = nextSteps;
1125
- const proposed = top.text.trim().slice(0, 255);
1126
- const current = deal.nextStep?.trim() ?? "";
1127
- const plan = buildCallPlan(parsed, deal, proposed, current, others.slice(0, 3));
1128
- if (rest.includes("--save")) {
1129
- await createFilePlanStore().save(plan);
1130
- console.log(`Saved plan ${plan.id}. Review with \`fullstackgtm plans show ${plan.id}\`, approve with \`fullstackgtm plans approve ${plan.id} --operations <ids|all>\`, then \`fullstackgtm apply --plan-id ${plan.id} --provider <name>\`.`);
1131
- return;
1132
- }
1133
- console.log(rest.includes("--json") ? JSON.stringify(plan, null, 2) : patchPlanToMarkdown(plan));
1134
- return;
1135
- }
1136
- if (subcommand === "score") {
1137
- if (rest.includes("--list-rubrics")) {
1138
- console.log(JSON.stringify(rubricPresets(), null, 2));
1139
- return;
1140
- }
1141
- // Explicit-rubric problems surface before any credential or API work.
1142
- const rubricPath = option(rest, "--rubric");
1143
- let rubric;
1144
- if (rubricPath) {
1145
- const rubricRaw = readFileSync(resolve(process.cwd(), rubricPath), "utf8");
1146
- try {
1147
- rubric = parseRubric(rubricRaw);
1148
- }
1149
- catch (error) {
1150
- throw new Error(`${rubricPath} is not a valid rubric: ${error instanceof Error ? error.message : String(error)} Expected JSON like { "scale": 5, "dimensions": [{ "name": "...", "weight": 1, "rubric": "..." }] }.`);
1151
- }
1152
- }
1153
- const callTypeOpt = option(rest, "--call-type");
1154
- if (callTypeOpt && !CALL_TYPE_IDS.includes(callTypeOpt)) {
1155
- throw new Error(`Unknown --call-type "${callTypeOpt}". One of: ${CALL_TYPE_IDS.join(", ")}.`);
1156
- }
1157
- const credential = await requireLlmCredential("score");
1158
- const transcriptPath = option(rest, "--transcript");
1159
- let transcriptText;
1160
- let title = option(rest, "--title") ?? undefined;
1161
- if (transcriptPath) {
1162
- transcriptText = normalizeTranscript(readFileSync(resolve(process.cwd(), transcriptPath), "utf8"));
1163
- }
1164
- else {
1165
- const callPath = option(rest, "--call");
1166
- if (!callPath)
1167
- throw new Error("call score requires --transcript <file> or --call <parsed.json>");
1168
- const parsed = JSON.parse(readFileSync(resolve(process.cwd(), callPath), "utf8"));
1169
- transcriptText = parsed.segments
1170
- .map((segment) => (segment.speaker ? `${segment.speaker}: ${segment.text}` : segment.text))
1171
- .join("\n");
1172
- title = title ?? parsed.title;
1173
- }
1174
- // Rubric selection: explicit --rubric wins, then --call-type, else the
1175
- // deterministic classifier picks the type-specific preset. No generic
1176
- // discovery rubric silently applied to a renewal anymore.
1177
- if (!rubric) {
1178
- const type = callTypeOpt ?? classifyCall(transcriptText).type;
1179
- rubric = rubricForCallType(type, DEFAULT_RUBRIC);
1180
- if (!rest.includes("--json")) {
1181
- const how = callTypeOpt ? `--call-type ${callTypeOpt}` : `auto-classified as ${type}`;
1182
- console.error(`Scoring with the "${rubric.name ?? "Generic"}" rubric (${how}). Override with --rubric <file> or --call-type <type>.`);
1183
- }
1184
- }
1185
- const scorecard = await scoreCallLlm(transcriptText, rubric, {
1186
- ...credential,
1187
- model: option(rest, "--model") ?? undefined,
1188
- title,
1189
- });
1190
- const outPath = option(rest, "--out");
1191
- if (outPath)
1192
- writeFileSync(resolve(process.cwd(), outPath), `${JSON.stringify(scorecard, null, 2)}\n`);
1193
- if (rest.includes("--json")) {
1194
- console.log(JSON.stringify(scorecard, null, 2));
1195
- return;
1196
- }
1197
- console.log(renderScorecard(scorecard, title));
1198
- return;
1199
- }
1200
- throw new Error(`call supports: parse, classify, link, plan, score (got ${subcommand ?? "nothing"})`);
1201
- }
1202
- /**
1203
- * First-touch key onboarding: env vars win, then the credential store; on a
1204
- * TTY a missing key is captured once (validated, stored 0600 like provider
1205
- * logins). Non-interactive contexts get an actionable error instead.
1206
- */
1207
- async function requireLlmCredential(command = "parse") {
1208
- // Base-URL overrides are resolved here (not inside forcedToolCall) so the LLM
1209
- // seam stays pure/injectable. Spread into LlmCallOptions at every call site
1210
- // via `...credential`; unset env leaves the upstream defaults untouched.
1211
- const baseUrls = resolveLlmBaseUrls();
1212
- const resolved = resolveLlmCredential();
1213
- if (resolved)
1214
- return { ...resolved, ...baseUrls };
1215
- // Scoring is inherently LLM work — there is no keyword fallback to suggest.
1216
- const fallbackHint = command === "parse"
1217
- ? ", or pass --deterministic for the free keyword baseline"
1218
- : command === "score"
1219
- ? " (call score has no non-LLM mode)"
1220
- : ", or classify by hand: `market worksheet --vendor <id>` then `market observe --from`";
1221
- const work = command === "score" ? "scoring" : command === "parse" ? "extraction" : "classification";
1222
- if (!process.stdin.isTTY) {
1223
- throw new Error(`LLM ${work} needs an API key. Set ANTHROPIC_API_KEY or OPENAI_API_KEY, or run \`echo "$KEY" | fullstackgtm login anthropic\` (or \`login openai\`) once${fallbackHint}.`);
1224
- }
1225
- console.error("LLM parsing needs an API key (Anthropic or OpenAI) — yours, used directly with the provider.");
1226
- console.error(`Paste it once; it is validated and stored at ${credentialsPath()} (file mode 0600), like CRM logins.`);
1227
- console.error("(Alternatives: set ANTHROPIC_API_KEY / OPENAI_API_KEY, or pass --deterministic for the free keyword baseline.)\n");
1228
- const apiKey = await readSecret("API key (sk-ant-... or sk-...): ");
1229
- const provider = detectProviderFromKey(apiKey);
1230
- const validation = await validateLlmKey(provider, apiKey);
1231
- if (!validation.ok)
1232
- throw new Error(`${provider} rejected the key: ${validation.detail}`);
1233
- const now = new Date().toISOString();
1234
- storeCredential(provider, { kind: "api_key", accessToken: apiKey, createdAt: now, updatedAt: now });
1235
- console.error(`Stored ${provider} key (${validation.detail}). Future runs use it automatically; remove with \`fullstackgtm logout ${provider}\`.\n`);
1236
- return { provider, apiKey, ...baseUrls };
1237
- }
1238
- /**
1239
- * Read optional LLM base-URL overrides from env so the package can run against
1240
- * an Anthropic/OpenAI-compatible gateway (GLM-5.2, z.ai, Ollama) without code
1241
- * changes. Returns only the keys that are set; an empty object when neither is,
1242
- * so spreading into LlmCallOptions is a no-op by default.
1243
- */
1244
- function resolveLlmBaseUrls(env = process.env) {
1245
- const out = {};
1246
- if (env.ANTHROPIC_API_BASE_URL)
1247
- out.anthropicBaseUrl = env.ANTHROPIC_API_BASE_URL;
1248
- if (env.OPENAI_API_BASE_URL)
1249
- out.openaiBaseUrl = env.OPENAI_API_BASE_URL;
1250
- return out;
1251
- }
1252
- function renderScorecard(scorecard, title) {
1253
- const bandText = scorecard.band ? ` — ${scorecard.band.label}` : "";
1254
- const rubricLine = scorecard.rubricName ? `Rubric: ${scorecard.rubricName}${scorecard.callType ? ` (${scorecard.callType})` : ""}` : "";
1255
- const lines = [
1256
- `# Coaching Scorecard${title ? ` — ${title}` : ""}`,
1257
- "",
1258
- `**Overall: ${scorecard.overallScore}/${scorecard.scale}${bandText}** (model: ${scorecard.model})`,
1259
- ...(scorecard.band?.meaning ? [`> ${scorecard.band.meaning}`] : []),
1260
- ...(rubricLine ? ["", `_${rubricLine}_`] : []),
1261
- "",
1262
- "| Dimension | Score | | Coaching note |",
1263
- "| --- | --- | --- | --- |",
1264
- ];
1265
- for (const dim of scorecard.dimensions) {
1266
- const filled = Math.round((dim.score / dim.maxScore) * 5);
1267
- const bar = "█".repeat(filled) + "░".repeat(5 - filled);
1268
- lines.push(`| ${dim.name} | ${dim.score}/${dim.maxScore} | ${bar} | ${dim.coachingNote} |`);
1269
- }
1270
- if (scorecard.highlights.length) {
1271
- lines.push("", "**Highlights**");
1272
- for (const h of scorecard.highlights)
1273
- lines.push(`- ${h}`);
1274
- }
1275
- if (scorecard.missedItems.length) {
1276
- lines.push("", "**Missed**");
1277
- for (const m of scorecard.missedItems)
1278
- lines.push(`- ${m}`);
1279
- }
1280
- return lines.join("\n");
1281
- }
1282
- function buildCallPlan(parsed, deal, proposed, current, extraNextSteps) {
1283
- const findings = [];
1284
- const operations = [];
1285
- const nextStepEvidence = parsed.evidence.filter((item) => item.metadata?.insightType === "next_step");
1286
- const evidenceIds = nextStepEvidence.map((item) => item.id);
1287
- if (current.toLowerCase() !== proposed.toLowerCase()) {
1288
- findings.push({
1289
- id: `finding_${parsed.id.replace(/^call_/, "")}_${deal.id}`,
1290
- objectType: "deal",
1291
- objectId: deal.id,
1292
- ruleId: "call-next-step-not-reflected-in-crm",
1293
- type: "call_next_step_not_reflected_in_crm",
1294
- title: "Call agreed a next step the CRM does not reflect",
1295
- severity: "warning",
1296
- summary: current
1297
- ? `The call produced "${proposed}" but ${deal.name}'s next step still reads "${current}".`
1298
- : `The call produced "${proposed}" but ${deal.name} has no next step set.`,
1299
- recommendation: "Review the evidence and approve the next-step update.",
1300
- evidenceIds,
1301
- currentCrmValue: current || null,
1302
- proposedValue: proposed,
1303
- });
1304
- operations.push({
1305
- id: `op_${parsed.id.replace(/^call_/, "")}_next`,
1306
- objectType: "deal",
1307
- objectId: deal.id,
1308
- operation: "set_field",
1309
- field: "nextStep",
1310
- beforeValue: current || null,
1311
- afterValue: proposed,
1312
- reason: `Call evidence: ${nextStepEvidence[0]?.text.slice(0, 200) ?? proposed}`,
1313
- sourceRuleOrPolicy: "call_intelligence.next_step",
1314
- riskLevel: "high",
1315
- approvalRequired: true,
1316
- rollback: "Restore the previous deal next step (the before value) if the update is wrong.",
1317
- evidenceIds,
1318
- });
1319
- }
1320
- for (const [index, extra] of extraNextSteps.entries()) {
1321
- operations.push({
1322
- id: `op_${parsed.id.replace(/^call_/, "")}_task${index}`,
1323
- objectType: "deal",
1324
- objectId: deal.id,
1325
- operation: "create_task",
1326
- field: "follow_up_task",
1327
- beforeValue: null,
1328
- afterValue: extra.text.trim().slice(0, 255),
1329
- reason: `Additional commitment from the call: ${extra.evidence.slice(0, 160)}`,
1330
- sourceRuleOrPolicy: "call_intelligence.follow_up",
1331
- riskLevel: "low",
1332
- approvalRequired: true,
1333
- rollback: "Close or delete the created task.",
1334
- });
1335
- }
1336
- return {
1337
- id: `patch_plan_${parsed.id.replace(/^call_/, "")}${deal.id.slice(-4)}`,
1338
- title: `Call evidence plan${parsed.title ? ` — ${parsed.title}` : ""} → ${deal.name}`,
1339
- createdAt: new Date().toISOString(),
1340
- status: "needs_approval",
1341
- dryRun: true,
1342
- summary: `${findings.length} finding(s) and ${operations.length} proposed operation(s) from call ${parsed.id}.`,
1343
- findings,
1344
- evidence: parsed.evidence,
1345
- operations,
1346
- };
1347
- }
1348
- /**
1349
- * The market map: claim taxonomy in a reviewable config file, page captures
1350
- * and append-only observations under the profile home, deterministic front
1351
- * states and reports computed from the store. Intensity readings enter as
1352
- * proposals through two channels — `classify` (LLM, bring-your-own-key, the
1353
- * call-intelligence pattern) and `worksheet`/`observe` (an agent or human
1354
- * fills the worksheet) — and BOTH pass the same mechanical gate: every quoted
1355
- * span is verified verbatim against the stored capture it cites.
1356
- */
1357
- async function marketCommand(args) {
1358
- const [subcommand, ...rest] = args;
1359
- const configPath = () => resolve(process.cwd(), option(rest, "--config") ?? "market.config.json");
1360
- // Catch --help anywhere before loadMarketConfig/credential checks run —
1361
- // several subcommands (capture, refresh) have side effects on bare invocation.
1362
- if (!subcommand || subcommand === "--help" || subcommand === "-h" || rest.includes("--help") || rest.includes("-h")) {
1363
- console.log(`Usage:
1364
- market init --category <name> [--out <path>] write a starter market.config.json
1365
- market init --category <name> --auto --vendor <url> [--vendor <url>...] [--anchor <url>] [--max-claims n]
1366
- LLM-propose vendors + claim taxonomy from seed pages (needs an API key)
1367
- market capture [--config <path>] [--run <label>]
1368
- market classify [--run <label>] [--capture-run <label>] [--vendor <id>] [--model m] [--out <path>]
1369
- market worksheet --vendor <id> [--capture-run <label>] [--out <path>]
1370
- market observe --from <observations.json> [--unverified]
1371
- market fronts [--config <path>] [--run <label>] [--diff <prior-run>] [--json]
1372
- market axes [--config <path>] [--run <label>] [--json]
1373
- market overlay --snapshot <crm.json> [--calls <parsed.json|manifest.json>]... [--prior-run <label>]
1374
- [--min-mentions N] [--promote-lift X] [--json] [--save --task-account <id>|--task-deal <id>]
1375
- market scale [--config <path>] [--json]
1376
- market report [--config <path>] [--run <label>] [--format md|html] [--out <path>]
1377
- market refresh [--run <label>] [--model m] capture → classify → fronts drift → HTML report
1378
-
1379
- overlay is the directive layer: joins the map to YOUR CRM ground truth and
1380
- emits OCCUPY / PROMOTE / URGENT / RETREAT directives, each carrying ≥1
1381
- observation and ≥1 CRM statistic with its sample size. Claim mentions are
1382
- deterministic word-boundary matches of each claim's "terms" against call
1383
- documents (call parse output); small samples refuse to become strategy
1384
- (--min-mentions, default 3). --save turns directives into approval-gated
1385
- create_task operations through the normal plans → approve → apply gate.
1386
-
1387
- scale prints the relative scale index that sizes the report's bubbles when
1388
- vendors carry scaleSignals (citable review counts / headcount / revenue —
1389
- a within-set index, never "market share" unqualified).
1390
-
1391
- axes runs the axis-discovery math: PCA over the vendor × claim intensity
1392
- matrix (PC1 = the category's primary axis, PC2 = the max-differentiation
1393
- direction orthogonal to it), triangulation of configured axes against the
1394
- PCs, and an orthogonality screen (|r|>0.75 = one axis twice). Axes live in
1395
- the config as claim-scoring rubrics; the report's strategic map and axis
1396
- lab render from them.
1397
-
1398
- classify uses your Anthropic/OpenAI key (like call parse) to read the stored
1399
- captures and propose intensity readings; worksheet is the no-key path (an
1400
- agent or human fills it, submits via observe). Either way, every quoted span
1401
- is verified character-for-character against the capture it cites before the
1402
- observation is accepted — quotes that aren't on the page bounce.
1403
-
1404
- The taxonomy (vendors + claims) is config you review and version; captures
1405
- and observations live under ~/.fullstackgtm/market/<category> (profile-scoped,
1406
- one client's category intel never bleeds into another's). Front states are
1407
- recomputed deterministically on every invocation — never stored.`);
1408
- return;
1409
- }
1410
- if (subcommand === "init") {
1411
- const category = option(rest, "--category");
1412
- if (!category)
1413
- throw new Error("market init requires --category <name>");
1414
- const outPath = resolve(process.cwd(), option(rest, "--out") ?? "market.config.json");
1415
- if (existsSync(outPath))
1416
- throw new Error(`${outPath} already exists — refusing to overwrite`);
1417
- if (rest.includes("--auto")) {
1418
- const vendorUrls = repeatedOption(rest, "--vendor");
1419
- if (vendorUrls.length === 0) {
1420
- throw new Error("market init --auto requires at least one --vendor <url> (the competitor homepages to seed from)");
1421
- }
1422
- const anchorUrl = option(rest, "--anchor");
1423
- const credential = await requireLlmCredential("market classify");
1424
- console.error(`Capturing ${vendorUrls.length} seed page(s) and proposing a claim taxonomy with ${credential.provider}…`);
1425
- const { config, unreadableVendorIds, model } = await suggestMarketConfig({
1426
- category,
1427
- vendors: vendorUrls.map((url) => ({ url, anchor: anchorUrl ? url === anchorUrl : false })),
1428
- llm: { ...credential, model: option(rest, "--model") ?? undefined },
1429
- maxClaims: numericOption(rest, "--max-claims"),
1430
- });
1431
- writeFileSync(outPath, `${JSON.stringify(config, null, 2)}\n`);
1432
- if (unreadableVendorIds.length > 0) {
1433
- console.error(`Note: no readable text for ${unreadableVendorIds.join(", ")} — excluded from taxonomy grounding.`);
1434
- }
1435
- console.log(`Wrote ${outPath}: ${config.vendors.length} vendors, ${config.claims.length} proposed claims (${model}). Review it, then: fullstackgtm market refresh`);
1436
- return;
1437
- }
1438
- writeFileSync(outPath, `${JSON.stringify(starterMarketConfig(category), null, 2)}\n`);
1439
- console.log(`Wrote ${outPath}. Fill in vendors and claims, then: fullstackgtm market capture`);
1440
- return;
1441
- }
1442
- const config = loadMarketConfig(configPath());
1443
- const store = createFileObservationStore(config.category);
1444
- if (subcommand === "capture") {
1445
- const result = await captureMarket(config, { runLabel: option(rest, "--run") ?? "run-1" });
1446
- for (const entry of result.entries) {
1447
- const flag = entry.captureHash && entry.textChars > 500 ? "" : " <-- thin/empty";
1448
- console.log(`${entry.vendorId.padEnd(16)} ${entry.kind.padEnd(8)} ${String(entry.httpStatus ?? "ERR").padEnd(4)} ${String(entry.textChars).padStart(7)} chars ${entry.url}${flag}`);
1449
- }
1450
- console.log(`\nmanifest: ${result.manifestPath}`);
1451
- return;
1452
- }
1453
- if (subcommand === "observe") {
1454
- const fromPath = option(rest, "--from");
1455
- if (!fromPath)
1456
- throw new Error("market observe requires --from <observations.json>");
1457
- const set = JSON.parse(readFileSync(resolve(process.cwd(), fromPath), "utf8"));
1458
- const problems = validateObservationSet(config, set);
1459
- if (problems.length > 0) {
1460
- console.error(`Rejected: ${problems.length} problem(s)`);
1461
- for (const problem of problems.slice(0, 20))
1462
- console.error(` - ${problem}`);
1463
- process.exitCode = 1;
1464
- return;
1465
- }
1466
- if (!rest.includes("--unverified")) {
1467
- const { textByHash } = loadCaptureTexts(config.category);
1468
- const failures = verifyEvidenceSpans(set.observations, textByHash);
1469
- if (failures.length > 0) {
1470
- console.error(`Rejected: ${failures.length} evidence span(s) failed verification against the stored captures`);
1471
- for (const failure of failures.slice(0, 20)) {
1472
- console.error(` - ${failure.vendorId} × ${failure.claimId}: ${failure.problem}`);
1473
- }
1474
- console.error("Quotes must be copied verbatim from the captured pages. (--unverified skips this gate when the captures genuinely live elsewhere.)");
1475
- process.exitCode = 1;
1476
- return;
1477
- }
1478
- }
1479
- await store.append(set);
1480
- console.log(`Appended ${set.runLabel}: ${set.observations.length} observations (${set.extractor})`);
1481
- return;
1482
- }
1483
- if (subcommand === "worksheet") {
1484
- const vendorId = option(rest, "--vendor");
1485
- if (!vendorId)
1486
- throw new Error("market worksheet requires --vendor <id>");
1487
- const worksheet = buildWorksheet(config, vendorId, { captureRun: option(rest, "--capture-run") ?? undefined });
1488
- const outPath = option(rest, "--out");
1489
- const payload = `${JSON.stringify(worksheet, null, 2)}\n`;
1490
- if (outPath) {
1491
- writeFileSync(resolve(process.cwd(), outPath), payload);
1492
- console.log(`Wrote ${outPath} (${worksheet.pages.length} captured pages, ${worksheet.claims.length} claims)`);
1493
- }
1494
- else {
1495
- console.log(payload);
1496
- }
1497
- return;
1498
- }
1499
- if (subcommand === "classify") {
1500
- const credential = await requireLlmCredential("market classify");
1501
- const vendorFilter = option(rest, "--vendor");
1502
- const outPath = option(rest, "--out");
1503
- if (vendorFilter && !outPath) {
1504
- throw new Error("market classify --vendor produces a partial set (coverage validation would reject it) — pass --out <path> to inspect/merge it by hand");
1505
- }
1506
- const result = await classifyMarket(config, {
1507
- llm: { ...credential, model: option(rest, "--model") ?? undefined },
1508
- runLabel: option(rest, "--run") ?? option(rest, "--capture-run") ?? "run-1",
1509
- captureRun: option(rest, "--capture-run") ?? undefined,
1510
- vendors: vendorFilter ? [vendorFilter] : undefined,
1511
- });
1512
- if (result.retriedVendorIds.length > 0) {
1513
- console.error(`Span verification bounced ${result.retriedVendorIds.join(", ")} once; retry passed.`);
1514
- }
1515
- if (outPath) {
1516
- writeFileSync(resolve(process.cwd(), outPath), `${JSON.stringify(result.set, null, 2)}\n`);
1517
- console.log(`Wrote ${outPath}: ${result.set.observations.length} verified observations (${result.set.extractor})`);
1518
- return;
1519
- }
1520
- const problems = validateObservationSet(config, result.set);
1521
- if (problems.length > 0) {
1522
- throw new Error(`Classified set failed validation: ${problems.slice(0, 5).join("; ")}`);
1523
- }
1524
- await store.append(result.set);
1525
- console.log(`Appended ${result.set.runLabel}: ${result.set.observations.length} observations, every span verified (${result.set.extractor})`);
1526
- return;
1527
- }
1528
- if (subcommand === "refresh") {
1529
- const credential = await requireLlmCredential("market classify");
1530
- const runLabel = option(rest, "--run") ?? `run-${new Date().toISOString().slice(0, 10)}`;
1531
- const prior = await store.latest();
1532
- console.log(`Capturing ${config.vendors.length} vendors as ${runLabel}…`);
1533
- const captured = await captureMarket(config, { runLabel });
1534
- const failed = captured.entries.filter((entry) => !entry.captureHash);
1535
- if (failed.length > 0)
1536
- console.log(`${failed.length} page(s) failed/empty — affected cells will verify against remaining pages or read unobservable.`);
1537
- console.log(`Classifying with ${credential.provider}…`);
1538
- const result = await classifyMarket(config, {
1539
- llm: { ...credential, model: option(rest, "--model") ?? undefined },
1540
- runLabel,
1541
- captureRun: runLabel,
1542
- });
1543
- await store.append(result.set);
1544
- const fronts = computeFrontStates(config, result.set);
1545
- if (prior) {
1546
- const drift = diffFrontStates(computeFrontStates(config, prior), fronts);
1547
- if (drift.length === 0)
1548
- console.log(`No front changes since ${prior.runLabel}.`);
1549
- for (const change of drift)
1550
- console.log(`CHANGED ${change.claimId}: ${change.before} → ${change.after}`);
1551
- }
1552
- const outPath = option(rest, "--out") ?? `${config.category}-${runLabel}.html`;
1553
- writeFileSync(resolve(process.cwd(), outPath), marketMapToHtml(config, result.set));
1554
- console.log(`Wrote ${outPath}`);
1555
- return;
1556
- }
1557
- const loadSet = async () => {
1558
- const runLabel = option(rest, "--run");
1559
- const set = runLabel ? await store.get(runLabel) : await store.latest();
1560
- if (!set) {
1561
- throw new Error(runLabel
1562
- ? `No observation run "${runLabel}" for ${config.category}`
1563
- : `No observations stored for ${config.category} — run market observe --from <file> first`);
1564
- }
1565
- return set;
1566
- };
1567
- if (subcommand === "fronts") {
1568
- const set = await loadSet();
1569
- const fronts = computeFrontStates(config, set);
1570
- const priorLabel = option(rest, "--diff");
1571
- const prior = priorLabel ? await store.get(priorLabel) : null;
1572
- if (priorLabel && !prior)
1573
- throw new Error(`No observation run "${priorLabel}" to diff against`);
1574
- const drift = prior ? diffFrontStates(computeFrontStates(config, prior), fronts) : null;
1575
- if (rest.includes("--json")) {
1576
- console.log(JSON.stringify({ runLabel: set.runLabel, fronts, drift }, null, 2));
1577
- return;
1578
- }
1579
- for (const front of fronts) {
1580
- const owner = front.state === "owned" ? ` → ${front.loudVendorIds[0]}` : "";
1581
- console.log(`${front.state.toUpperCase().padEnd(10)} ${front.claimId}${owner}`);
1582
- }
1583
- if (drift) {
1584
- console.log("");
1585
- if (drift.length === 0)
1586
- console.log(`No front changes since ${priorLabel}.`);
1587
- for (const change of drift)
1588
- console.log(`CHANGED ${change.claimId}: ${change.before} → ${change.after}`);
1589
- }
1590
- return;
1591
- }
1592
- if (subcommand === "report") {
1593
- const set = await loadSet();
1594
- const format = option(rest, "--format") ?? "md";
1595
- const output = format === "html" ? marketMapToHtml(config, set) : marketMapToMarkdown(config, set);
1596
- const outPath = option(rest, "--out");
1597
- if (outPath) {
1598
- writeFileSync(resolve(process.cwd(), outPath), output);
1599
- console.log(`Wrote ${outPath}`);
1600
- }
1601
- else {
1602
- console.log(output);
1603
- }
1604
- return;
1605
- }
1606
- if (subcommand === "axes") {
1607
- const set = await loadSet();
1608
- const report = assessAxes(config, set);
1609
- if (rest.includes("--json")) {
1610
- console.log(JSON.stringify(report, null, 2));
1611
- return;
1612
- }
1613
- console.log(axesReportToText(report));
1614
- return;
1615
- }
1616
- if (subcommand === "scale") {
1617
- const report = computeScaleIndex(config);
1618
- if (rest.includes("--json")) {
1619
- console.log(JSON.stringify(report, null, 2));
1620
- return;
1621
- }
1622
- console.log(scaleReportToText(config, report));
1623
- return;
1624
- }
1625
- if (subcommand === "overlay") {
1626
- const set = await loadSet();
1627
- const snapshotPath = option(rest, "--snapshot");
1628
- if (!snapshotPath) {
1629
- throw new Error("market overlay requires --snapshot <canonical-snapshot.json> (fullstackgtm snapshot --out it first) — directives need CRM ground truth");
1630
- }
1631
- const snapshot = JSON.parse(readFileSync(resolve(process.cwd(), snapshotPath), "utf8"));
1632
- // --calls accepts ParsedCall JSON files (from `call parse --out`) and/or
1633
- // manifest arrays [{path, dealId?}] linking calls to deals. Repeatable.
1634
- const documents = [];
1635
- const addParsedCall = (parsedPath, dealId) => {
1636
- const parsed = JSON.parse(readFileSync(resolve(process.cwd(), parsedPath), "utf8"));
1637
- const text = [
1638
- ...(parsed.segments ?? []).map((segment) => segment.text ?? ""),
1639
- ...(parsed.insights ?? []).map((insight) => `${insight.text ?? ""} ${insight.evidence ?? ""}`),
1640
- ].join("\n");
1641
- documents.push({ id: parsed.id ?? parsedPath, text, dealId, occurredAt: parsed.evidence?.[0]?.capturedAt });
1642
- };
1643
- for (let i = 0; i < rest.length; i += 1) {
1644
- if (rest[i] !== "--calls")
1645
- continue;
1646
- const callsPath = rest[i + 1];
1647
- if (!callsPath)
1648
- throw new Error("--calls needs a path");
1649
- const raw = JSON.parse(readFileSync(resolve(process.cwd(), callsPath), "utf8"));
1650
- if (Array.isArray(raw)) {
1651
- for (const entry of raw)
1652
- addParsedCall(entry.path, entry.dealId);
1653
- }
1654
- else {
1655
- addParsedCall(callsPath);
1656
- }
1657
- }
1658
- const priorLabel = option(rest, "--prior-run");
1659
- const priorSet = priorLabel ? await store.get(priorLabel) : null;
1660
- if (priorLabel && !priorSet)
1661
- throw new Error(`No observation run "${priorLabel}" for URGENT drift`);
1662
- const stats = computeOverlayStats(config, snapshot, documents);
1663
- const directives = computeDirectives(config, set, stats, {
1664
- minMentions: numericOption(rest, "--min-mentions") ?? undefined,
1665
- promoteLift: numericOption(rest, "--promote-lift") ?? undefined,
1666
- priorSet: priorSet ?? undefined,
1667
- });
1668
- if (rest.includes("--json")) {
1669
- console.log(JSON.stringify({ stats, directives }, null, 2));
1670
- return;
1671
- }
1672
- console.log(overlayToMarkdown(stats, directives));
1673
- if (rest.includes("--save")) {
1674
- const taskAccount = option(rest, "--task-account");
1675
- const taskDeal = option(rest, "--task-deal");
1676
- if (!taskAccount && !taskDeal) {
1677
- throw new Error("--save needs --task-account <id> or --task-deal <id>: directives become approval-gated create_task operations, and the CRM needs a record to hang them on (your own company's account record works well)");
1678
- }
1679
- const plan = directivesToPlan(config, set, directives, taskDeal ? { objectType: "deal", objectId: taskDeal } : { objectType: "account", objectId: taskAccount });
1680
- const stored = await createFilePlanStore().save(plan);
1681
- console.log(`Saved plan ${stored.plan.id} (${directives.length} directive task(s); approve via \`plans approve\`)`);
1682
- }
1683
- return;
1684
- }
1685
- throw new Error(`Unknown market subcommand: ${subcommand} (try: init, capture, classify, worksheet, observe, fronts, axes, overlay, scale, report, refresh)`);
1686
- }
1687
- /**
1688
- * The enrich layer: governed append/refresh of third-party data (Apollo pull,
1689
- * Clay ingest) into the CRM through the normal dry-run → approval → apply
1690
- * contract. State lives in the profile-scoped run store (checkpoint,
1691
- * staleness ledger, observability in one); scheduling belongs to the
1692
- * horizontal scheduler — enrich owns no cron logic.
1693
- */
1694
- async function enrichCommand(args) {
1695
- const [subcommand, ...rest] = args;
1696
- // Catch --help BEFORE config load, credential resolution, or any network
1697
- // call (the 0.14.1/0.18 bug class — `enrich append --help` executing a
1698
- // paid Apollo pull would be its worst recurrence).
1699
- if (!subcommand || subcommand === "--help" || subcommand === "-h" || rest.includes("--help") || rest.includes("-h")) {
1700
- console.log(`Usage:
1701
- enrich append [--source apollo] [--objects companies,contacts] [--save] [--config <path>]
1702
- [source options] [--run-label <label>] [--json]
1703
- enrich refresh [--source apollo] [--stale-days <n>] [--save] [--config <path>]
1704
- [source options] [--run-label <label>] [--json]
1705
- enrich ingest <file.csv|payload.json> --source clay [--run-label <label>] [--objects companies|contacts] [--config <path>]
1706
- enrich acquire [--source <id>] [--max <n>] [--list <id>] [--assign-owner <id>] [--save] [--config <path>] [--json]
1707
- enrich status [--runs] [--source <id>] [--config <path>] [--json]
1708
-
1709
- acquire creates NET-NEW leads from a staged prospect list (ingest first):
1710
- it dedupes each sourced row against the CRM, skips matches and ambiguities
1711
- (resolve-first never creates over a possible duplicate), and proposes a
1712
- \`create_record\` op per confirmed net-new row — capped by the acquire meter's
1713
- remaining budget (records + spend, per day and per month; whichever is hit
1714
- first). Approval-gated like every write: \`--save\` → plans approve → apply.
1715
- The meter is charged only when a create actually lands at apply.
1716
- Discovery sources (zero-config presets): explorium | pipe0 (ICP-driven search),
1717
- and linkedin (reads a HeyReach lead list — pass --list <id>, key on the LinkedIn
1718
- URL, $0/record; \`login heyreach\` or HEYREACH_API_KEY).
1719
-
1720
- Leads are never born ownerless: set an \`acquire.assign\` policy (fixed /
1721
- round-robin / territory / account-owner) to stamp an owner at create time, or
1722
- pass \`--assign-owner <id>\`. With a single-owner portal and no policy, acquire
1723
- defaults every lead to that owner; with several owners and no policy it warns
1724
- and leaves them unassigned. Backfill existing ownerless records with
1725
- \`reassign --assign-unowned --to <ownerId>\`.
1726
-
1727
- append pulls from an api source (Apollo — BYO key via \`login apollo\` or
1728
- APOLLO_API_KEY) or reads data staged by \`enrich ingest\` (Clay CSV exports,
1729
- webhook payload JSON), matches source records to CRM records via the ordered
1730
- match keys in enrich.config.json (unique hit wins; zero hits falls through to
1731
- the next key; multiple hits skip or flow into the suggest chain, per
1732
- onAmbiguous), and emits a fill-blanks-only patch plan. Without --save it
1733
- prints the dry-run diff and writes NOTHING; with --save the plan lands in the
1734
- plan store as needs_approval and the run (counts, per-field enrichedAt stamps,
1735
- resume cursor) lands in the profile's enrich run store. From there the normal
1736
- chain takes over: plans approve → apply.
1737
-
1738
- refresh computes its work set from the run-store stamps — fields enrich
1739
- itself wrote, opted in with "refresh": true, older than the staleness window
1740
- (--stale-days overrides per-field staleDays and policy.defaultStaleDays) —
1741
- re-fetches the source, and proposes updates only where the source value
1742
- actually changed. Every operation carries beforeValue, so apply-time
1743
- compare-and-set rejects writes over a CRM that moved underneath the plan.
1744
-
1745
- Conflict policy (MVP): "never" — enrich only fills blank fields and only
1746
- re-touches fields its own ledger proves it stamped. system-only and always
1747
- are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
1748
- return;
1749
- }
1750
- if (!["append", "refresh", "ingest", "status", "acquire"].includes(subcommand)) {
1751
- throw new Error(`Unknown enrich subcommand: ${subcommand} (try: append, refresh, ingest, status, acquire)`);
1752
- }
1753
- const configPath = () => resolve(process.cwd(), option(rest, "--config") ?? ENRICH_CONFIG_FILE_NAME);
1754
- const store = createFileEnrichRunStore();
1755
- if (subcommand === "status") {
1756
- await enrichStatus(store, rest, configPath());
1757
- return;
1758
- }
1759
- // Config resolution: an explicit --config or an on-disk default always wins
1760
- // (and validates). Only when neither exists do we fall back to a built-in
1761
- // preset for the source (e.g. `--source clay`), so the common Mode-A loop
1762
- // needs no hand-authored config. A present-but-invalid config still errors.
1763
- const explicitConfig = option(rest, "--config");
1764
- const configFile = configPath();
1765
- // No config file + no --config → fall back to a built-in preset so the common
1766
- // paths need zero hand-authored config: `acquire` gets the zero-config acquire
1767
- // preset (targeted/deduped/metered out the gate); other verbs get the source
1768
- // preset (e.g. clay ingest). An explicit/on-disk config always wins.
1769
- const presetFor = (src) => subcommand === "acquire" ? builtinAcquirePreset(src) : builtinEnrichPreset(src);
1770
- const config = !explicitConfig && !existsSync(configFile)
1771
- ? (presetFor(option(rest, "--source") ?? undefined) ?? loadEnrichConfig(configFile))
1772
- : loadEnrichConfig(configFile);
1773
- // `enrich ingest` stages rows. If the same command also names a CRM source
1774
- // (--input/--provider), collapse the two-step into one: stage, then run the
1775
- // append against the just-staged data so `enrich ingest clay.csv --source clay
1776
- // --input snap.json` returns the hygiene verdict end-to-end. Without a CRM
1777
- // source it stays stage-only (the existing two-step is preserved).
1778
- if (subcommand === "ingest") {
1779
- const autoAppend = Boolean(option(rest, "--provider") || option(rest, "--input"));
1780
- await enrichIngest(store, config, rest, autoAppend);
1781
- if (!autoAppend)
1782
- return;
1783
- }
1784
- if (subcommand === "acquire") {
1785
- if (!config.acquire) {
1786
- throw new Error('enrich acquire: config has no "acquire" section. Add e.g. { "acquire": { "create": { "contact": { "matchKey": "email", "properties": { "email": "email", "firstname": "first_name", "lastname": "last_name" } } }, "budget": { "records": { "perDay": 50, "perMonth": 500 } }, "costPerRecord": { "clay": 0.10 } } } to enrich.config.json.');
1787
- }
1788
- const source = resolveEnrichSource(config, rest);
1789
- const sourceConfig = config.sources[source];
1790
- const save = rest.includes("--save");
1791
- const today = new Date().toISOString().slice(0, 10);
1792
- // Prospects come from an API source (net-new discovery, e.g. Explorium +
1793
- // pipe0 work-email) or a staged ingest list (Clay/CSV/webhook).
1794
- const snapshot = await readSnapshot(rest);
1795
- const icp = loadIcp(rest);
1796
- if (sourceConfig.kind === "api" && !icp) {
1797
- console.error("⚠ No ICP loaded — discovery will use the raw discovery.filters and skip fit scoring. " +
1798
- "Develop one with `fullstackgtm icp interview` (or pass --icp <path>) so leads map to your ICP.");
1799
- }
1800
- // Recommend the strong dedup key when the CRM carries no LinkedIn URLs:
1801
- // without them, pre-email dedup falls back to the weaker name+domain match.
1802
- if (sourceConfig.kind === "api" && !(snapshot.contacts ?? []).some((c) => c.linkedin)) {
1803
- console.error("⚠ No LinkedIn URLs found on your contacts — pre-email dedup falls back to name+domain (weaker). " +
1804
- "Populate the HubSpot \"LinkedIn URL\" (hs_linkedin_url) property for exact dedup; " +
1805
- "acquire writes it on every new contact it creates, so coverage grows automatically.");
1806
- }
1807
- const seen = loadSeen();
1808
- let records;
1809
- let apiSkippedCrm = 0;
1810
- let apiSkippedSeen = 0;
1811
- let apiProcessedKeys = [];
1812
- if (sourceConfig.kind === "api") {
1813
- const api = await acquireFromApi(config, source, rest, icp, snapshot, seen);
1814
- records = api.records;
1815
- apiSkippedCrm = api.skippedCrm;
1816
- apiSkippedSeen = api.skippedSeen;
1817
- apiProcessedKeys = api.processedKeys;
1818
- }
1819
- else {
1820
- const stagedLabel = option(rest, "--staged-run");
1821
- const stagedRun = stagedLabel
1822
- ? await store.get(stagedLabel)
1823
- : await store.latest({ source, mode: "ingest" });
1824
- if (!stagedRun || stagedRun.mode !== "ingest") {
1825
- throw new Error(`No staged data for source "${source}". Stage prospects first: fullstackgtm enrich ingest <prospects.csv|payload.json> --source ${source}`);
1826
- }
1827
- records = stagedSourceRecords(config, source, stagedRun);
1828
- }
1829
- // Meter: how many MORE leads may we create right now? --max is an
1830
- // additional per-run ceiling, never a way to exceed the budget.
1831
- const now = new Date();
1832
- const costPerRecord = config.acquire.costPerRecord?.[source] ?? 0;
1833
- if (apiSkippedCrm || apiSkippedSeen) {
1834
- console.error(`Pre-email dedup: skipped ${apiSkippedCrm} already-in-CRM + ${apiSkippedSeen} seen before paying for emails ` +
1835
- `(≈$${((apiSkippedCrm + apiSkippedSeen) * costPerRecord).toFixed(2)} enrichment saved).`);
1836
- }
1837
- const headroom = remaining(loadMeter(now), config.acquire.budget, costPerRecord, now);
1838
- const explicitMax = numericOption(rest, "--max");
1839
- let cap = headroom.maxRecords;
1840
- if (explicitMax !== undefined)
1841
- cap = cap === null ? explicitMax : Math.min(cap, explicitMax);
1842
- // Assignment: never create an ownerless lead. An explicit `acquire.assign`
1843
- // policy wins; a `--assign-owner <id>` flag is a quick fixed override;
1844
- // otherwise, when the portal has exactly one active owner, default every
1845
- // lead to them. With multiple owners and no policy we refuse to guess —
1846
- // leads are left unassigned and the operator is told to configure a rule.
1847
- const assignOwnerFlag = option(rest, "--assign-owner");
1848
- if (!config.acquire.assign) {
1849
- if (assignOwnerFlag) {
1850
- config.acquire.assign = { strategy: "fixed", ownerId: assignOwnerFlag };
1851
- }
1852
- else {
1853
- const activeOwners = (snapshot.users ?? []).filter((u) => u.active !== false);
1854
- if (activeOwners.length === 1) {
1855
- const sole = activeOwners[0].crmId ?? activeOwners[0].id;
1856
- config.acquire.assign = { strategy: "fixed", ownerId: sole };
1857
- console.error(`Assignment: no policy set — defaulting every lead to the portal's sole owner ` +
1858
- `${activeOwners[0].name} (${sole}). Configure "acquire.assign" to route across reps.`);
1859
- }
1860
- else if (activeOwners.length > 1) {
1861
- console.error(`⚠ Assignment: ${activeOwners.length} active owners and no "acquire.assign" policy — ` +
1862
- `leads will be created OWNERLESS. Add an acquire.assign rule (fixed / round-robin / territory) ` +
1863
- `or pass --assign-owner <id> so every lead lands with an owner.`);
1864
- }
1865
- }
1866
- }
1867
- const result = buildAcquirePlan({
1868
- config,
1869
- source,
1870
- snapshot,
1871
- records,
1872
- runLabel: option(rest, "--run-label") ?? `acquire-${source}-${today}`,
1873
- maxRecords: cap,
1874
- });
1875
- if (result.counts.unassigned > 0 && result.counts.created > 0) {
1876
- console.error(`⚠ ${result.counts.unassigned}/${result.counts.created} proposed lead(s) have no owner ` +
1877
- `(policy could not place them). They will be created ownerless.`);
1878
- }
1879
- // Observability: headline metrics for the web run timeline (paired users).
1880
- reportCounts({
1881
- sourced: result.counts.fetched,
1882
- created: result.counts.created,
1883
- withheldByMeter: result.counts.withheldByMeter,
1884
- skippedInCrm: apiSkippedCrm,
1885
- skippedSeen: apiSkippedSeen,
1886
- estCostUsd: result.estCostUsd,
1887
- });
1888
- const meterLine = formatAcquireMeter(headroom, costPerRecord);
1889
- if (!save) {
1890
- if (rest.includes("--json")) {
1891
- console.log(JSON.stringify({ plan: result.plan, counts: result.counts, estCostUsd: result.estCostUsd, meter: headroom }, null, 2));
1892
- }
1893
- else {
1894
- console.log(patchPlanToMarkdown(result.plan));
1895
- console.log(meterLine);
1896
- console.log("\nDry run — nothing written. Re-run with --save to persist the plan, then approve + apply.");
1897
- }
1898
- return;
1899
- }
1900
- const run = await openEnrichRun(store, source, "append", option(rest, "--run-label"), today);
1901
- const planIds = [];
1902
- if (result.plan.operations.length > 0) {
1903
- await createFilePlanStore().save(result.plan);
1904
- planIds.push(result.plan.id);
1905
- reportEvent("plan_saved", result.plan.id);
1906
- }
1907
- await store.update({
1908
- ...run,
1909
- completedAt: new Date().toISOString(),
1910
- cursor: null,
1911
- planIds: [...(run.planIds ?? []), ...planIds],
1912
- });
1913
- // Remember everyone we email-resolved this run so the next run skips them
1914
- // pre-email (cross-run credit saver). Committed (--save) runs only.
1915
- if (apiProcessedKeys.length > 0)
1916
- recordSeen(apiProcessedKeys, now);
1917
- console.log(meterLine);
1918
- if (planIds.length > 0) {
1919
- console.log(`Saved plan ${result.plan.id} — ${result.counts.created} net-new lead(s), est. $${result.estCostUsd.toFixed(2)}. ` +
1920
- `Review \`fullstackgtm plans show ${result.plan.id}\`, approve \`fullstackgtm plans approve ${result.plan.id} --operations all\`, ` +
1921
- `then \`fullstackgtm apply --plan-id ${result.plan.id} --provider hubspot\`. The meter is charged only when a create lands at apply.`);
1922
- }
1923
- else {
1924
- console.log("No net-new leads to create (everything sourced already matched, or was withheld by the meter).");
1925
- }
1926
- return;
1927
- }
1928
- const mode = subcommand === "refresh" ? "refresh" : "append";
1929
- const source = resolveEnrichSource(config, rest);
1930
- const sourceConfig = config.sources[source];
1931
- const save = rest.includes("--save");
1932
- const today = new Date().toISOString().slice(0, 10);
1933
- // Refresh work set comes from the staleness ledger, before any fetch.
1934
- const allRuns = await store.list();
1935
- let workSet = [];
1936
- if (mode === "refresh") {
1937
- const staleDaysOverride = numericOption(rest, "--stale-days");
1938
- workSet = selectStaleWork(config, allRuns, source, { staleDaysOverride });
1939
- if (workSet.length === 0) {
1940
- const stamped = latestStamps(allRuns, source).size;
1941
- console.log(stamped === 0
1942
- ? `Nothing to refresh: no ${source} enrichment stamps yet. Run \`enrich append --source ${source} --save\` first.`
1943
- : `Nothing to refresh: all ${stamped} stamped field(s) from ${source} are within their staleness window.`);
1944
- return;
1945
- }
1946
- }
1947
- const snapshot = await readSnapshot(rest);
1948
- // Assemble source records: api pull (checkpointed when --save) or staged ingest data.
1949
- let run = null;
1950
- let records;
1951
- let missCount = 0;
1952
- if (sourceConfig.kind === "api") {
1953
- const objectTypes = parseEnrichObjects(rest, config, source);
1954
- const fieldsFor = (objectType) => (config.fields[objectType] ?? []).filter((field) => field.from[source] !== undefined);
1955
- const pullKeys = mode === "append"
1956
- ? apolloPullKeysForAppend(snapshot, objectTypes, (objectType, record) => fieldsFor(objectType).some((field) => {
1957
- const value = record[resolveCrmField(objectType, field.crm)];
1958
- return value === undefined || value === null || String(value).trim() === "";
1959
- }))
1960
- : apolloPullKeysForRefresh(snapshot, workSet);
1961
- if (pullKeys.length === 0) {
1962
- console.log(mode === "append"
1963
- ? "Nothing to enrich: no records with a blank mapped field and a pull key (companies need a domain, contacts an email)."
1964
- : "Nothing to refresh: no stale records carry a pull key (companies need a domain, contacts an email).");
1965
- return;
1966
- }
1967
- const client = createApolloClient({
1968
- getApiKey: () => apolloApiKey(),
1969
- apiBaseUrl: process.env.APOLLO_API_BASE_URL,
1970
- });
1971
- if (save) {
1972
- run = await openEnrichRun(store, source, mode, option(rest, "--run-label"), today);
1973
- if (run.cursor) {
1974
- console.error(`Resuming interrupted run ${run.runLabel} from cursor ${run.cursor} (${run.pulled?.length ?? 0} record(s) already pulled).`);
1975
- }
1976
- }
1977
- const result = await pullApolloRecords(client, pullKeys, {
1978
- resumeAfter: run?.cursor ?? null,
1979
- onProgress: run
1980
- ? async (progress) => {
1981
- run.cursor = progress.lastKeyValue;
1982
- if (progress.record)
1983
- run.pulled = [...(run.pulled ?? []), progress.record];
1984
- if (progress.miss)
1985
- run.missedKeys = [...(run.missedKeys ?? []), progress.miss.value];
1986
- await store.update(run);
1987
- }
1988
- : undefined,
1989
- });
1990
- records = run ? [...(run.pulled ?? [])] : result.records;
1991
- missCount = run ? (run.missedKeys?.length ?? 0) : result.misses.length;
1992
- }
1993
- else {
1994
- const stagedLabel = option(rest, "--staged-run");
1995
- const stagedRun = stagedLabel
1996
- ? await store.get(stagedLabel)
1997
- : await store.latest({ source, mode: "ingest" });
1998
- if (!stagedRun || stagedRun.mode !== "ingest") {
1999
- throw new Error(`No staged data for source "${source}". Stage it first: fullstackgtm enrich ingest <file.csv|payload.json> --source ${source}`);
2000
- }
2001
- records = stagedSourceRecords(config, source, stagedRun);
2002
- if (save)
2003
- run = await openEnrichRun(store, source, mode, option(rest, "--run-label"), today);
2004
- }
2005
- const result = buildEnrichPlan({
2006
- config,
2007
- source,
2008
- mode,
2009
- snapshot,
2010
- records,
2011
- workSet: mode === "refresh" ? workSet : undefined,
2012
- runLabel: run?.runLabel ?? `${mode}-${source}-${today}`,
2013
- });
2014
- // Pull keys the source had no data for count as fetched-but-unmatched.
2015
- result.counts.fetched += missCount;
2016
- result.counts.unmatched += missCount;
2017
- reportCounts({
2018
- fetched: result.counts.fetched,
2019
- matched: result.counts.matched,
2020
- unmatched: result.counts.unmatched,
2021
- opsEmitted: result.counts.opsEmitted,
2022
- });
2023
- if (!save) {
2024
- if (rest.includes("--json")) {
2025
- console.log(JSON.stringify(result.plan, null, 2));
2026
- }
2027
- else {
2028
- console.log(patchPlanToMarkdown(result.plan));
2029
- console.log(formatEnrichCounts(result.counts, result.ambiguities.length));
2030
- console.log("\nDry run — nothing written. Re-run with --save to persist the plan and the run record.");
2031
- }
2032
- return;
2033
- }
2034
- // --save: persist the plan (when it proposes anything) and finalize the run.
2035
- const planIds = [];
2036
- if (result.plan.operations.length > 0) {
2037
- await createFilePlanStore().save(result.plan);
2038
- planIds.push(result.plan.id);
2039
- }
2040
- const finalized = {
2041
- ...run,
2042
- completedAt: new Date().toISOString(),
2043
- cursor: null,
2044
- counts: result.counts,
2045
- planIds: [...(run.planIds ?? []), ...planIds],
2046
- stamps: [...(run.stamps ?? []), ...result.stamps],
2047
- ambiguities: result.ambiguities,
2048
- };
2049
- await store.update(finalized);
2050
- console.log(formatEnrichCounts(result.counts, result.ambiguities.length));
2051
- if (planIds.length > 0) {
2052
- console.log(`Saved plan ${result.plan.id} (run ${finalized.runLabel}). Review with \`fullstackgtm plans show ${result.plan.id}\`, ` +
2053
- `approve with \`fullstackgtm plans approve ${result.plan.id} --operations <ids|all>\`, then ` +
2054
- `\`fullstackgtm apply --plan-id ${result.plan.id} --provider <name>\`.`);
2055
- }
2056
- else {
2057
- console.log(`Run ${finalized.runLabel} recorded; no operations to propose.`);
2058
- }
2059
- }
2060
- function formatEnrichCounts(counts, ambiguities) {
2061
- return (`Source records: ${counts.fetched} fetched · ${counts.matched} matched · ` +
2062
- `${counts.unmatched} unmatched · ${counts.ambiguous} ambiguous (${ambiguities} collision(s) recorded) · ` +
2063
- `${counts.opsEmitted} operation(s) proposed`);
2064
- }
2065
- /** Best-effort enrich-config load for apply-time acquire-budget enforcement. */
2066
- /** Provider API key: env override first, then the credential store (`login`). */
2067
- function providerKey(provider) {
2068
- const envName = provider === "explorium" ? "EXPLORIUM_API_KEY" : provider === "pipe0" ? "PIPE0_API_KEY" : "HEYREACH_API_KEY";
2069
- if (process.env[envName])
2070
- return process.env[envName];
2071
- const stored = getCredential(provider);
2072
- if (stored)
2073
- return stored.accessToken;
2074
- throw new Error(`No ${provider} credentials. Run \`echo "$KEY" | fullstackgtm login ${provider}\`, or set ${envName}.`);
2075
- }
2076
- /** Load the active ICP: --icp <path>, else ./icp.json. Undefined if none. */
2077
- function loadIcp(args) {
2078
- const explicit = option(args, "--icp");
2079
- const path = resolve(process.cwd(), explicit ?? "icp.json");
2080
- if (!existsSync(path)) {
2081
- if (explicit)
2082
- throw new Error(`--icp ${explicit}: file not found`);
2083
- return undefined;
2084
- }
2085
- return parseIcp(readFileSync(path, "utf8"));
2086
- }
2087
- /**
2088
- * Resolve a signals config: explicit --config, else signals.config.json in cwd,
2089
- * else the zero-config DEFAULT_SIGNALS_CONFIG (preset-first, like enrich).
2090
- */
2091
- function resolveSignalsConfig(args) {
2092
- const explicit = option(args, "--config");
2093
- if (explicit)
2094
- return loadSignalsConfig(resolve(process.cwd(), explicit));
2095
- const local = resolve(process.cwd(), "signals.config.json");
2096
- if (existsSync(local))
2097
- return loadSignalsConfig(local);
2098
- return DEFAULT_SIGNALS_CONFIG;
2099
- }
2100
- /**
2101
- * Resolve the watchlist of accounts to scan. Sources, in precedence order:
2102
- * - a --watchlist file (JSON array of {domain, boards?} or bare domain strings),
2103
- * - a `crm:<segment>` token (derive account domains from a CRM snapshot, scoped
2104
- * by the segment's `field=value` where-clauses; reuses readSnapshot),
2105
- * - config.watchlist.domains.
2106
- * Board tokens come from the entry's own `boards`, else config.watchlist.boards.
2107
- */
2108
- async function resolveWatchlist(args, config) {
2109
- const configBoards = config.watchlist.boards ?? {};
2110
- const fromConfig = (domain) => {
2111
- const normalized = normalizeAccountDomain(domain);
2112
- const token = configBoards[normalized] ?? configBoards[domain];
2113
- return token ? { domain: normalized, boards: { greenhouse: token, lever: token, ashby: token } } : { domain: normalized };
2114
- };
2115
- const watchlistArg = option(args, "--watchlist") ?? config.watchlist.source;
2116
- if (watchlistArg && watchlistArg.startsWith("crm:")) {
2117
- const segment = watchlistArg.slice("crm:".length);
2118
- const snapshot = await readSnapshot(args);
2119
- // Segment is an optional `;`-separated list of `field=value` equality
2120
- // clauses over snapshot accounts (the common case; richer filtering lives in
2121
- // bulk-update). Empty segment = every account with a domain.
2122
- const clauses = (segment ? segment.split(";").map((s) => s.trim()).filter(Boolean) : []).map((clause) => {
2123
- const eq = clause.indexOf("=");
2124
- if (eq === -1)
2125
- throw new Error(`--watchlist crm:<segment>: clause "${clause}" must be field=value.`);
2126
- return { field: clause.slice(0, eq).trim(), value: clause.slice(eq + 1).trim() };
2127
- });
2128
- const accounts = (snapshot.accounts ?? []).filter((acct) => clauses.every((c) => {
2129
- const raw = acct[c.field];
2130
- return (raw == null ? "" : String(raw)).toLowerCase() === c.value.toLowerCase();
2131
- }));
2132
- const domains = accounts
2133
- .map((acct) => normalizeAccountDomain(acct.domain ?? ""))
2134
- .filter((d) => Boolean(d));
2135
- return [...new Set(domains)].map(fromConfig);
2136
- }
2137
- if (watchlistArg) {
2138
- const raw = JSON.parse(readFileSync(resolve(process.cwd(), watchlistArg), "utf8"));
2139
- if (!Array.isArray(raw))
2140
- throw new Error(`--watchlist ${watchlistArg}: expected a JSON array of domains or {domain, boards?} objects`);
2141
- return raw.map((entry) => {
2142
- if (typeof entry === "string")
2143
- return fromConfig(entry);
2144
- if (entry && typeof entry === "object" && typeof entry.domain === "string") {
2145
- const e = entry;
2146
- const base = fromConfig(e.domain);
2147
- return e.boards ? { domain: base.domain, boards: { ...base.boards, ...e.boards } } : base;
2148
- }
2149
- throw new Error(`--watchlist ${watchlistArg}: each entry must be a domain string or {domain, boards?} object`);
2150
- });
2151
- }
2152
- return (config.watchlist.domains ?? []).map(fromConfig);
2153
- }
2154
- /**
2155
- * `signals` — detect fresh buying triggers (ATS job-board scrapes + staged
2156
- * funding/company/social ingest), rank them, and persist a local signal ledger.
2157
- * READ-ONLY re: CRM: NEVER emits a PatchPlan, never calls a recording connector.
2158
- * `--save` persists only to the signal store, not a plan.
2159
- */
2160
- async function signalsCommand(args) {
2161
- const [sub, ...rest] = args;
2162
- // Help-before-network: catch --help/-h BEFORE any config load or fetch —
2163
- // anywhere in argv (`signals --help` and `signals fetch --help` both land here).
2164
- if (!sub || args.includes("--help") || args.includes("-h")) {
2165
- console.log(`Usage:
2166
- fullstackgtm signals fetch [--bucket job,funding,...] [--source greenhouse,lever,ashby] [--watchlist <path|crm:segment>] [--keywords "growth,revops"] [--from <file.json>] [--config <path>] [--save]
2167
- fullstackgtm signals list [--since 7d] [--bucket b] [--account d] [--unjudged]
2168
- fullstackgtm signals outcome --account <domain> [--touch <id>] --result replied|meeting|bounced|no_reply
2169
- fullstackgtm signals weights [--explain]
2170
-
2171
- Detect fresh buying triggers and rank them. \`fetch\` is read-only re: CRM — it
2172
- NEVER emits a patch plan; --save persists only the local signal ledger (used by
2173
- \`icp judge\`). ATS adapters are no-auth, so no credential flags exist.`);
2174
- return;
2175
- }
2176
- if (sub === "fetch") {
2177
- const config = resolveSignalsConfig(rest);
2178
- // Merge --keywords into the job bucket; filter buckets + job sources.
2179
- const keywordsArg = option(rest, "--keywords");
2180
- if (keywordsArg) {
2181
- const merged = keywordsArg.split(",").map((k) => k.trim()).filter(Boolean);
2182
- config.buckets.job = { ...config.buckets.job, keywords: merged };
2183
- }
2184
- const bucketFilter = option(rest, "--bucket");
2185
- const buckets = bucketFilter
2186
- ? bucketFilter.split(",").map((b) => b.trim()).filter(Boolean)
2187
- : SIGNAL_BUCKETS.filter((b) => (config.buckets[b]?.sources.length ?? 0) > 0);
2188
- for (const b of buckets) {
2189
- if (!SIGNAL_BUCKETS.includes(b))
2190
- throw new Error(`Unknown bucket: ${b} (one of ${SIGNAL_BUCKETS.join(", ")})`);
2191
- }
2192
- const sourceFilter = option(rest, "--source");
2193
- const allJobSources = ["greenhouse", "lever", "ashby"];
2194
- const jobSources = sourceFilter
2195
- ? sourceFilter.split(",").map((s) => s.trim()).filter(Boolean).filter((s) => allJobSources.includes(s))
2196
- : config.buckets.job.sources.filter((s) => allJobSources.includes(s));
2197
- const now = new Date();
2198
- const store = createFileSignalStore();
2199
- const priorSignals = await store.allSignals();
2200
- const candidates = [];
2201
- // Job bucket: scan ATS boards for each watchlist account x job source.
2202
- if (buckets.includes("job")) {
2203
- const watchlist = await resolveWatchlist(rest, config);
2204
- for (const account of watchlist) {
2205
- const rawJobs = [];
2206
- for (const source of jobSources) {
2207
- const boardToken = account.boards?.[source] ?? account.domain;
2208
- const jobs = await fetchAtsJobs({
2209
- source,
2210
- boardToken,
2211
- accountDomain: account.domain,
2212
- keywords: config.buckets.job.keywords,
2213
- });
2214
- for (const job of jobs)
2215
- rawJobs.push({ ...job, source });
2216
- }
2217
- const accountSignals = buildSignalsFromAts(rawJobs, { domain: account.domain }, config, {
2218
- now,
2219
- priorSignals,
2220
- });
2221
- candidates.push(...accountSignals);
2222
- }
2223
- }
2224
- // Staged ingest (funding/company/social): --from <file.json>.
2225
- const fromFile = option(rest, "--from");
2226
- if (fromFile) {
2227
- const ingested = readStagedSignals(resolve(process.cwd(), fromFile), buckets, now);
2228
- candidates.push(...ingested);
2229
- }
2230
- const fetched = candidates.length;
2231
- const { fresh, deduped } = dedupeSignals(candidates, priorSignals, config.dedupWindowDays, now);
2232
- const ranked = [...fresh].sort((a, b) => b.weight - a.weight || a.accountDomain.localeCompare(b.accountDomain));
2233
- // Ranked fresh signals to stdout; guidance to stderr.
2234
- console.log(JSON.stringify(ranked, null, 2));
2235
- console.error(`Fetched ${fetched} candidate signal(s); ${fresh.length} fresh, ${deduped.length} deduped ` +
2236
- `(window ${config.dedupWindowDays}d). NO plan emitted — signals are read-only re: CRM.`);
2237
- const save = rest.includes("--save");
2238
- if (save) {
2239
- const runLabel = option(rest, "--label") ?? `signals-${now.toISOString().slice(0, 10)}`;
2240
- const startedAt = now.toISOString();
2241
- await store.appendRun({
2242
- id: signalRunId(runLabel),
2243
- runLabel,
2244
- startedAt,
2245
- completedAt: new Date().toISOString(),
2246
- buckets,
2247
- counts: { fetched, new: fresh.length, deduped: deduped.length },
2248
- signals: ranked,
2249
- });
2250
- console.error(`Saved signal run "${runLabel}" (${fresh.length} fresh). Next: \`fullstackgtm icp judge --save\`.`);
2251
- }
2252
- else {
2253
- console.error("(not saved — re-run with --save to persist this run to the signal ledger)");
2254
- }
2255
- return;
2256
- }
2257
- if (sub === "list") {
2258
- const store = createFileSignalStore();
2259
- const all = await store.allSignals();
2260
- const sinceArg = option(rest, "--since");
2261
- const sinceMs = sinceArg ? parseSinceWindowMs(sinceArg) : undefined;
2262
- const now = Date.now();
2263
- const bucket = option(rest, "--bucket");
2264
- const accountArg = option(rest, "--account");
2265
- const account = accountArg ? normalizeAccountDomain(accountArg) : undefined;
2266
- const unjudgedOnly = rest.includes("--unjudged");
2267
- const filtered = all.filter((s) => {
2268
- if (sinceMs !== undefined) {
2269
- const seen = Date.parse(s.firstSeen);
2270
- if (!Number.isFinite(seen) || now - seen > sinceMs)
2271
- return false;
2272
- }
2273
- if (bucket && s.bucket !== bucket)
2274
- return false;
2275
- if (account && s.accountDomain !== account)
2276
- return false;
2277
- if (unjudgedOnly && s.judgedBy)
2278
- return false;
2279
- return true;
2280
- });
2281
- const ranked = filtered.sort((a, b) => b.weight - a.weight || a.accountDomain.localeCompare(b.accountDomain));
2282
- console.log(JSON.stringify(ranked, null, 2));
2283
- console.error(`${ranked.length} signal(s)${unjudgedOnly ? " (unjudged)" : ""}.`);
2284
- return;
2285
- }
2286
- if (sub === "outcome") {
2287
- const accountArg = option(rest, "--account");
2288
- if (!accountArg)
2289
- throw new Error("signals outcome: --account <domain> is required.");
2290
- const result = option(rest, "--result");
2291
- const valid = ["replied", "meeting", "bounced", "no_reply"];
2292
- if (!result || !valid.includes(result)) {
2293
- throw new Error(`signals outcome: --result must be one of ${valid.join(", ")}.`);
2294
- }
2295
- const touchId = option(rest, "--touch") ?? undefined;
2296
- const contactId = option(rest, "--contact") ?? undefined;
2297
- const outcome = makeOutcome({ accountDomain: accountArg, touchId, contactId, result: result });
2298
- await createFileSignalStore().appendOutcome(outcome);
2299
- console.error(`Recorded outcome "${outcome.result}" for ${outcome.accountDomain} (${outcome.id}). ` +
2300
- `Re-run \`fullstackgtm signals weights\` to see the learned shift.`);
2301
- return;
2302
- }
2303
- if (sub === "weights") {
2304
- const config = resolveSignalsConfig(rest);
2305
- const store = createFileSignalStore();
2306
- const outcomes = await store.listOutcomes();
2307
- const signalsById = new Map((await store.allSignals()).map((s) => [s.id, s]));
2308
- const weights = computeWeights(config, outcomes, signalsById);
2309
- console.log(JSON.stringify(weights, null, 2));
2310
- if (rest.includes("--explain")) {
2311
- // Per-bucket config default vs learned + booked/total over credited signals.
2312
- const booked = new Map();
2313
- const total = new Map();
2314
- for (const outcome of outcomes) {
2315
- for (const id of outcome.creditedSignals) {
2316
- const signal = signalsById.get(id);
2317
- if (!signal)
2318
- continue;
2319
- total.set(signal.bucket, (total.get(signal.bucket) ?? 0) + 1);
2320
- if (outcome.result === "meeting")
2321
- booked.set(signal.bucket, (booked.get(signal.bucket) ?? 0) + 1);
2322
- }
2323
- }
2324
- console.error("Per-bucket weight (config default vs learned, booked/total credited):");
2325
- for (const b of SIGNAL_BUCKETS) {
2326
- console.error(` ${b.padEnd(8)} default ${config.buckets[b].weight.toFixed(2)} -> learned ${weights[b].toFixed(4)} ` +
2327
- `(${booked.get(b) ?? 0}/${total.get(b) ?? 0} booked)`);
2328
- }
2329
- }
2330
- return;
2331
- }
2332
- throw new Error(`Unknown signals subcommand: ${sub} (try: fetch, list, outcome, weights)`);
2333
- }
2334
- /** Parse a "7d"/"30d"/"12h" recency window into milliseconds. */
2335
- function parseSinceWindowMs(value) {
2336
- const match = /^(\d+)\s*([dhwm])$/i.exec(value.trim());
2337
- if (!match)
2338
- throw new Error(`--since: expected a window like 7d, 24h, 2w (got "${value}").`);
2339
- const n = Number(match[1]);
2340
- const unit = match[2].toLowerCase();
2341
- const ms = unit === "h" ? 3600_000 : unit === "w" ? 7 * 86_400_000 : unit === "m" ? 30 * 86_400_000 : 86_400_000;
2342
- return n * ms;
2343
- }
2344
- /**
2345
- * Read staged funding/company/social signals from a JSON file (array of partial
2346
- * signals). Each row is validated against the signal schema + the quote gate
2347
- * (a non-empty verbatim quote is required); source = "ingest".
2348
- */
2349
- function readStagedSignals(path, buckets, now) {
2350
- const raw = JSON.parse(readFileSync(path, "utf8"));
2351
- if (!Array.isArray(raw))
2352
- throw new Error(`--from ${path}: expected a JSON array of staged signals.`);
2353
- const nowIso = now.toISOString();
2354
- const out = [];
2355
- raw.forEach((entry, index) => {
2356
- if (!entry || typeof entry !== "object")
2357
- throw new Error(`--from ${path}: row ${index} is not an object.`);
2358
- const e = entry;
2359
- const bucket = String(e.bucket ?? "");
2360
- if (!SIGNAL_BUCKETS.includes(bucket)) {
2361
- throw new Error(`--from ${path}: row ${index} has unknown bucket "${bucket}" (one of ${SIGNAL_BUCKETS.join(", ")}).`);
2362
- }
2363
- if (buckets.length && !buckets.includes(bucket))
2364
- return; // filtered out by --bucket
2365
- const accountDomain = normalizeAccountDomain(String(e.accountDomain ?? e.domain ?? ""));
2366
- if (!accountDomain)
2367
- throw new Error(`--from ${path}: row ${index} is missing accountDomain.`);
2368
- const trigger = String(e.trigger ?? "").trim();
2369
- if (!trigger)
2370
- throw new Error(`--from ${path}: row ${index} is missing trigger.`);
2371
- const quote = String(e.quote ?? "").trim();
2372
- if (!quote)
2373
- throw new Error(`--from ${path}: row ${index} is missing the verbatim quote (the evidence anchor).`);
2374
- const base = { accountDomain, bucket: bucket, trigger };
2375
- const firstSeen = typeof e.firstSeen === "string" && e.firstSeen ? e.firstSeen : nowIso;
2376
- out.push({
2377
- id: signalId(base),
2378
- accountDomain,
2379
- bucket: bucket,
2380
- trigger,
2381
- quote,
2382
- sourceUrl: String(e.sourceUrl ?? ""),
2383
- firstSeen,
2384
- weight: typeof e.weight === "number" ? e.weight : DEFAULT_SIGNALS_CONFIG.buckets[bucket].weight,
2385
- source: "ingest",
2386
- judgedBy: null,
2387
- });
2388
- });
2389
- return out;
2390
- }
2391
- /**
2392
- * `draft` — author ONE trigger-grounded opener per hot judge decision as a
2393
- * governed create_task plan. Structurally a proposal: never sends, never writes
2394
- * a CRM record. With --save the plan is staged needs_approval for plans approve
2395
- * -> apply.
2396
- */
2397
- async function draftCommand(args) {
2398
- // Help-before-network: catch --help/-h BEFORE any config/credential/LLM call.
2399
- if (args.includes("--help") || args.includes("-h")) {
2400
- console.log(`Usage:
2401
- fullstackgtm draft [--from-judge latest|<runLabel>] [--min-score 80] [--prompt <path>] [--channel email|linkedin|task] [--save]
2402
-
2403
- Author one trigger-grounded opener per hot judge decision (decision=send,
2404
- score>=min) as a governed create_task plan. The opener's first line must contain
2405
- a verbatim span of the why-now trigger or the draft is rejected. Never sends —
2406
- --save stages a needs_approval plan for \`plans approve\` -> \`apply\`. Without an
2407
- LLM key it emits a clearly-labeled stub rather than fake authored copy.`);
2408
- return;
2409
- }
2410
- const fromJudge = option(args, "--from-judge") ?? "latest";
2411
- const minScore = numericOption(args, "--min-score") ?? 80;
2412
- const promptPath = option(args, "--prompt");
2413
- const channelArg = option(args, "--channel") ?? "task";
2414
- if (!DRAFT_CHANNELS.includes(channelArg)) {
2415
- throw new Error(`--channel must be one of ${DRAFT_CHANNELS.join(", ")} (got "${channelArg}").`);
2416
- }
2417
- const channel = channelArg;
2418
- const save = args.includes("--save");
2419
- // Load the JudgeRun.
2420
- const judgeStore = createFileJudgeStore();
2421
- const judgeRun = fromJudge === "latest" ? await judgeStore.latestRun() : await judgeStore.getRun(fromJudge);
2422
- if (!judgeRun) {
2423
- throw new Error(`No judge run "${fromJudge}" — run \`fullstackgtm icp judge --save\` first.`);
2424
- }
2425
- // Rebuild the signalsById map the openers ground in: from the referenced signal
2426
- // run, falling back to every stored signal.
2427
- const signalStore = createFileSignalStore();
2428
- const signalRun = await signalStore.getRun(judgeRun.signalRunLabel);
2429
- const signals = signalRun ? signalRun.signals : await signalStore.allSignals();
2430
- const signalsById = new Map(signals.map((s) => [s.id, s]));
2431
- // LLM resolution (optional — no-key path is the honestly-degraded stub).
2432
- const cred = resolveLlmCredential();
2433
- const model = option(args, "--model") ?? undefined;
2434
- const llm = cred
2435
- ? { ...cred, ...resolveLlmBaseUrls(), ...(model ? { model } : {}) }
2436
- : undefined;
2437
- const promptTemplate = promptPath ? readFileSync(resolve(process.cwd(), promptPath), "utf8") : DEFAULT_DRAFT_PROMPT;
2438
- const openers = await authorOpeners({
2439
- decisions: judgeRun.decisions,
2440
- signalsById,
2441
- minScore,
2442
- promptTemplate,
2443
- llm,
2444
- });
2445
- const { plan, drafts, rejected } = draft({
2446
- decisions: judgeRun.decisions,
2447
- signalsById,
2448
- minScore,
2449
- channel,
2450
- openers,
2451
- });
2452
- console.log(JSON.stringify({ drafts, rejected }, null, 2));
2453
- const stale = drafts.filter((d) => d.staleTrigger);
2454
- console.error(`${drafts.length} opener(s) staged as create_task proposals (channel ${channel}, min score ${minScore})` +
2455
- `${rejected.length ? `; ${rejected.length} rejected (ungrounded first line)` : ""}` +
2456
- `${stale.length ? `; ${stale.length} flagged staleTrigger` : ""}` +
2457
- `${llm ? "" : " — DETERMINISTIC stub (no LLM key)"}. A draft is a proposal; nothing is sent.`);
2458
- if (save) {
2459
- await createFilePlanStore().save(plan);
2460
- console.error(`Saved plan ${plan.id} (status ${plan.status}). Review: \`fullstackgtm plans show ${plan.id}\`, ` +
2461
- `then \`fullstackgtm plans approve ${plan.id} --operations all\` and \`fullstackgtm apply --plan-id ${plan.id} --provider <name>\`.`);
2462
- }
2463
- else {
2464
- console.error("(not saved — re-run with --save to stage the plan for plans approve -> apply)");
2465
- }
2466
- }
2467
- /**
2468
- * `init` — scaffold a GTM workspace from cold scratch: a starter icp.json, an
2469
- * enrich.config.json (acquire preset + a visible assign seam), and a PLAYBOOK
2470
- * wired with the chosen source/provider that points at the recipes. Pure
2471
- * file-writer: no network, never overwrites without --force.
2472
- */
2473
- function initCommand(args) {
2474
- if (args.includes("--help") || args.includes("-h")) {
2475
- console.log(`Usage:
2476
- fullstackgtm init [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]
2477
-
2478
- Scaffolds a workspace so the first acquire/signals/judge/draft commands work:
2479
- icp.json starter ICP (edit, or rebuild with \`icp interview\`)
2480
- enrich.config.json acquire preset for --source + a placeholder assign policy
2481
- PLAYBOOK.md the cold-start + outbound-loop recipes wired for this workspace
2482
-
2483
- The CLI ships governed primitives; your coding agent is the orchestrator. See
2484
- docs/recipes.md for the full play set. Existing files are kept unless --force.`);
2485
- return;
2486
- }
2487
- const source = (option(args, "--source") ?? "pipe0");
2488
- if (!["pipe0", "explorium", "linkedin"].includes(source)) {
2489
- throw new Error(`init: --source must be pipe0|explorium|linkedin (got "${source}")`);
2490
- }
2491
- const provider = (option(args, "--provider") ?? "hubspot");
2492
- if (!["hubspot", "salesforce"].includes(provider)) {
2493
- throw new Error(`init: --provider must be hubspot|salesforce (got "${provider}")`);
2494
- }
2495
- const outDir = resolve(process.cwd(), option(args, "--out") ?? ".");
2496
- const force = args.includes("--force");
2497
- const current = activeProfile();
2498
- const profile = current === DEFAULT_PROFILE ? undefined : current;
2499
- const files = scaffoldWorkspace({ source, provider, profile });
2500
- const wrote = [];
2501
- const kept = [];
2502
- for (const file of files) {
2503
- const path = resolve(outDir, file.path);
2504
- if (existsSync(path) && !force) {
2505
- kept.push(file.path);
2506
- continue;
2507
- }
2508
- writeFileSync(path, file.content);
2509
- wrote.push(file.path);
2510
- }
2511
- console.log(`Scaffolded workspace (${provider}, discovery via ${source}${profile ? `, profile ${profile}` : ""}).`);
2512
- if (wrote.length)
2513
- console.log(` wrote: ${wrote.join(", ")}`);
2514
- if (kept.length)
2515
- console.log(` kept (use --force to overwrite): ${kept.join(", ")}`);
2516
- console.log("\nNext: edit icp.json + set acquire.assign.ownerId in enrich.config.json, then follow PLAYBOOK.md\n" +
2517
- "(full recipe set: docs/recipes.md).");
2518
- }
2519
- /**
2520
- * `icp` — develop and inspect the Ideal Customer Profile that targets acquire.
2521
- * The CLI can't run AskUserQuestion itself; `icp interview` emits the question
2522
- * spec an agent (Claude Code / Codex) drives with its AskUserQuestion tool, then
2523
- * `icp set` writes icp.json from the collected answers.
2524
- */
2525
- async function icpCommand(args) {
2526
- const [sub, ...rest] = args;
2527
- // Help-before-network: catch --help/-h anywhere in argv (so `icp --help`,
2528
- // `icp judge --help`, `icp eval --help` all print usage before any work).
2529
- if (!sub || args.includes("--help") || args.includes("-h")) {
2530
- console.log(`Usage:
2531
- fullstackgtm icp interview emit the interview spec (an agent drives it with AskUserQuestion)
2532
- fullstackgtm icp set <answers.json> [--name <n>] [--out <path>] write icp.json from interview answers
2533
- fullstackgtm icp show [--icp <path>] render the ICP + the discovery filters it produces
2534
- fullstackgtm icp judge [--signals-from latest|<label>] [--with-history] [--prompt <path>] [--min-score 0] [--save] rank unjudged signals into send/nurture/skip decisions (timing x fit x memory)
2535
- fullstackgtm icp eval [--golden <path>|default] [--against-outcomes] [--min-accuracy 0.8] [--json] grade the judge: golden-set accuracy and/or hot>cold calibration; exits 2 below the bar
2536
-
2537
- The ICP makes \`enrich acquire\` targeted, not random: it generates each
2538
- provider's discovery filters (Explorium, pipe0/Crustdata) AND scores every
2539
- discovered prospect for fit — only above-threshold leads become create_record
2540
- ops. Develop one by interview, then \`enrich acquire\` picks up ./icp.json.
2541
- \`icp judge\` ranks fresh signals (from \`signals fetch --save\`) into send/nurture/skip;
2542
- \`icp eval\` is the probabilistic-judgment gate before any apply.`);
2543
- return;
2544
- }
2545
- if (sub === "interview") {
2546
- console.log(JSON.stringify({
2547
- questions: INTERVIEW_SPEC,
2548
- instructions: "Ask each question with AskUserQuestion (multiSelect per .multiSelect). For each answer, collect the chosen options' .value arrays and concat them under the question's .id. Then run `fullstackgtm icp set <answers.json>` with that flattened object.",
2549
- }, null, 2));
2550
- return;
2551
- }
2552
- if (sub === "set") {
2553
- const file = rest.find((a) => !a.startsWith("--"));
2554
- if (!file)
2555
- throw new Error("Usage: fullstackgtm icp set <answers.json> [--name <n>] [--out <path>]");
2556
- const answers = JSON.parse(readFileSync(resolve(process.cwd(), file), "utf8"));
2557
- const icp = icpFromAnswers(option(rest, "--name") ?? "ICP", answers);
2558
- const out = resolve(process.cwd(), option(rest, "--out") ?? "icp.json");
2559
- writeFileSync(out, `${JSON.stringify(icp, null, 2)}\n`);
2560
- console.log(`Wrote ${out} — ${icp.persona.titleKeywords?.length ?? 0} title keyword(s), ` +
2561
- `${icp.firmographics.employeeBands?.length ?? 0} size band(s), fit threshold ${fitThreshold(icp)}.`);
2562
- return;
2563
- }
2564
- if (sub === "show") {
2565
- const icp = loadIcp(rest);
2566
- if (!icp)
2567
- throw new Error("No ICP found (icp.json in cwd, or pass --icp <path>). Build one: fullstackgtm icp interview.");
2568
- console.log(JSON.stringify({
2569
- icp,
2570
- exploriumFilters: icpToExploriumFilters(icp),
2571
- crustdataFilters: icpToCrustdataFilters(icp),
2572
- fitThreshold: fitThreshold(icp),
2573
- }, null, 2));
2574
- return;
2575
- }
2576
- if (sub === "judge") {
2577
- // The parent icpCommand already caught --help/-h before any work, so we can
2578
- // flag-parse then act (no network until judgeSignals, and only with a key).
2579
- const signalsFrom = option(rest, "--signals-from") ?? "latest";
2580
- const withHistory = rest.includes("--with-history");
2581
- const promptPath = option(rest, "--prompt");
2582
- const minScore = numericOption(rest, "--min-score") ?? 0;
2583
- const save = rest.includes("--save");
2584
- // 1) Load signals from the signal store.
2585
- const signalStore = createFileSignalStore();
2586
- const signalRun = signalsFrom === "latest" ? await signalStore.latestRun() : await signalStore.getRun(signalsFrom);
2587
- if (!signalRun) {
2588
- throw new Error(`No signal run "${signalsFrom}" — run \`fullstackgtm signals fetch --save\` first.`);
2589
- }
2590
- const unjudged = signalRun.signals.filter((s) => !s.judgedBy);
2591
- if (unjudged.length === 0)
2592
- throw new Error(`Signal run "${signalRun.runLabel}" has no unjudged signals.`);
2593
- // 2) Optional inputs: ICP (may be undefined), snapshot, outcomes + config.
2594
- // The snapshot is loaded whenever a source is given (--provider/--input/
2595
- // --demo/--sample) — it resolves each decision's CRM target (accountId +
2596
- // best contact) so `draft` can write against a real record. --with-history
2597
- // additionally enables the memory + fit scoring inputs.
2598
- const icp = loadIcp(rest);
2599
- const config = DEFAULT_SIGNALS_CONFIG;
2600
- const outcomes = await signalStore.listOutcomes();
2601
- const hasSnapshotSource = Boolean(option(rest, "--provider")) ||
2602
- Boolean(option(rest, "--input")) ||
2603
- rest.includes("--demo") ||
2604
- rest.includes("--sample");
2605
- const snapshot = withHistory || hasSnapshotSource ? await readSnapshot(rest) : undefined;
2606
- // 3) Resolve the LLM seam ONLY if a key already exists (deterministic baseline
2607
- // otherwise — never PROMPT here; judge must run key-free).
2608
- const cred = resolveLlmCredential();
2609
- const baseUrls = resolveLlmBaseUrls();
2610
- const model = option(rest, "--model") ?? undefined;
2611
- const llm = cred
2612
- ? { ...cred, ...baseUrls, ...(model ? { model } : {}) }
2613
- : undefined;
2614
- const promptTemplate = llm
2615
- ? promptPath
2616
- ? readFileSync(resolve(process.cwd(), promptPath), "utf8")
2617
- : DEFAULT_JUDGE_PROMPT
2618
- : undefined;
2619
- // 4) Judge.
2620
- const decisions = (await judgeSignals({
2621
- signals: unjudged,
2622
- outcomes,
2623
- config,
2624
- icp,
2625
- snapshot,
2626
- withHistory,
2627
- promptTemplate,
2628
- llm,
2629
- })).filter((d) => d.score >= minScore);
2630
- // 5) Ranked decisions to stdout (JSON), guidance to stderr.
2631
- console.log(JSON.stringify(decisions, null, 2));
2632
- console.error(`Judged ${unjudged.length} signal(s) across ${new Set(decisions.map((d) => d.accountDomain)).size} account(s): ` +
2633
- `${decisions.filter((d) => d.decision === "send").length} send, ` +
2634
- `${decisions.filter((d) => d.decision === "nurture").length} nurture, ` +
2635
- `${decisions.filter((d) => d.decision === "skip").length} skip` +
2636
- `${llm ? "" : " (deterministic — no LLM key; run `login anthropic|openai` for why-now + play)"}.`);
2637
- // 6) --save: persist a JudgeRun + stamp consumed signals judgedBy.
2638
- if (save) {
2639
- const runLabel = option(rest, "--label") ?? `judge-${new Date().toISOString().slice(0, 10)}`;
2640
- const store = createFileJudgeStore();
2641
- await store.appendRun({
2642
- id: judgeRunId(runLabel),
2643
- runLabel,
2644
- signalRunLabel: signalRun.runLabel,
2645
- createdAt: new Date().toISOString(),
2646
- decisions,
2647
- });
2648
- const judgedIds = new Set(decisions.flatMap((d) => d.evidence));
2649
- for (const s of signalRun.signals)
2650
- if (judgedIds.has(s.id))
2651
- s.judgedBy = runLabel;
2652
- await signalStore.updateRun(signalRun);
2653
- console.error(`Saved judge run "${runLabel}" (${decisions.length} decisions); stamped ${judgedIds.size} signals judgedBy. ` +
2654
- `Next: \`fullstackgtm draft --from-judge ${runLabel} --save\`.`);
2655
- }
2656
- else {
2657
- console.error("(not saved — re-run with --save to persist a judge run and stamp the consumed signals)");
2658
- }
2659
- return;
2660
- }
2661
- if (sub === "eval") {
2662
- // The parent icpCommand already caught --help/-h before any work. The whole
2663
- // command is read-only: it grades, never emits a plan, never touches a provider.
2664
- const goldenArg = option(rest, "--golden") ?? "default";
2665
- const againstOutcomes = rest.includes("--against-outcomes");
2666
- const minAccuracy = numericOption(rest, "--min-accuracy") ?? DEFAULT_MIN_ACCURACY;
2667
- const asJson = rest.includes("--json");
2668
- if (againstOutcomes) {
2669
- // The empirical gate: hot decisions must book strictly more than cold.
2670
- const judgeStore = createFileJudgeStore();
2671
- const labelArg = option(rest, "--from-judge");
2672
- const judgeRun = labelArg ? await judgeStore.getRun(labelArg) : await judgeStore.latestRun();
2673
- if (!judgeRun) {
2674
- throw new Error(`No judge run ${labelArg ? `"${labelArg}"` : "to grade"} — run \`fullstackgtm icp judge --save\` first.`);
2675
- }
2676
- const outcomes = await createFileSignalStore().listOutcomes();
2677
- const cal = gradeAgainstOutcomes(judgeRun.decisions, outcomes);
2678
- if (asJson) {
2679
- console.log(JSON.stringify(cal, null, 2));
2680
- }
2681
- else {
2682
- console.log(`Outcome calibration (judge run "${judgeRun.runLabel}"):\n` +
2683
- ` hot book rate: ${(cal.hotBookRate * 100).toFixed(1)}% over ${cal.hotCount} hot account(s)\n` +
2684
- ` cold book rate: ${(cal.coldBookRate * 100).toFixed(1)}% over ${cal.coldCount} cold account(s)\n` +
2685
- ` calibrated: ${cal.calibrated ? "yes (hot > cold)" : "NO (hot did not strictly beat cold)"}`);
2686
- }
2687
- console.error(cal.calibrated
2688
- ? "Calibrated: hot scores book strictly more than cold — the judge earns its interrupt."
2689
- : "NOT calibrated: hot did not strictly beat cold. Tune the rubric before scheduling apply behind this gate.");
2690
- if (!cal.calibrated)
2691
- process.exitCode = 2;
2692
- return;
2693
- }
2694
- // The golden-set gate (default). Resolve "default" BEFORE any file read.
2695
- const rows = goldenArg === "default"
2696
- ? DEFAULT_GOLDEN_SET
2697
- : parseGoldenSet(readFileSync(resolve(process.cwd(), goldenArg), "utf8"));
2698
- const result = await gradeJudge(rows, defaultJudgeFn({ icp: loadIcp(rest), now: new Date() }));
2699
- if (asJson) {
2700
- console.log(JSON.stringify(result, null, 2));
2701
- }
2702
- else {
2703
- const c = result.confusion;
2704
- console.log(`Golden-set grade (${rows.length} row(s)):\n` +
2705
- ` accuracy: ${(result.accuracy * 100).toFixed(1)}% (bar ${(minAccuracy * 100).toFixed(0)}%)\n` +
2706
- ` precision: ${(result.precision * 100).toFixed(1)}%\n` +
2707
- ` recall: ${(result.recall * 100).toFixed(1)}%\n` +
2708
- ` confusion: tp=${c.truePositive} fp=${c.falsePositive} tn=${c.trueNegative} fn=${c.falseNegative}`);
2709
- }
2710
- if (result.accuracy < minAccuracy) {
2711
- console.error(`Judge accuracy ${(result.accuracy * 100).toFixed(1)}% is below the ${(minAccuracy * 100).toFixed(0)}% bar — exit 2.`);
2712
- process.exitCode = 2;
2713
- }
2714
- else {
2715
- console.error(`Judge passes the golden-set gate (${(result.accuracy * 100).toFixed(1)}% >= ${(minAccuracy * 100).toFixed(0)}%).`);
2716
- }
2717
- return;
2718
- }
2719
- throw new Error(`Unknown icp subcommand: ${sub} (try: interview, set, show, judge, eval)`);
2720
- }
2721
- /**
2722
- * Pull net-new prospects from an API acquire source into source records the
2723
- * acquire builder dedupes + turns into create_record ops. Explorium discovers;
2724
- * pipe0 (when resolveEmailsWith=pipe0) fills real work emails. Only rows that
2725
- * carry the dedupe key (email) survive — you cannot resolve-first without it.
2726
- */
2727
- async function acquireFromApi(config, source, rest, icp, snapshot, seen) {
2728
- const acquire = config.acquire;
2729
- const disc = acquire.discovery?.[source];
2730
- if (!disc) {
2731
- throw new Error(`enrich acquire: api source "${source}" needs an "acquire.discovery.${source}" config (provider + size).`);
2732
- }
2733
- const matchKey = acquire.create.contact?.matchKey ?? "email";
2734
- const maxOverride = numericOption(rest, "--max");
2735
- const size = maxOverride !== undefined ? Math.min(maxOverride, disc.size ?? 25) : disc.size ?? 25;
2736
- // 1. Discover. Filters come from the ICP when one is loaded (the whole point —
2737
- // targeted, not random); otherwise from the hand-written disc.filters.
2738
- let prospects;
2739
- if (disc.provider === "explorium") {
2740
- const filters = icp
2741
- ? icpToExploriumFilters(icp)
2742
- : (disc.filters ?? {});
2743
- prospects = await fetchExploriumProspects({ apiKey: providerKey("explorium"), filters, size });
2744
- }
2745
- else if (disc.provider === "pipe0") {
2746
- const filters = icp ? icpToCrustdataFilters(icp) : (disc.filters ?? {});
2747
- prospects = await fetchPipe0CrustdataProspects({ apiKey: providerKey("pipe0"), filters, limit: size });
2748
- }
2749
- else if (disc.provider === "linkedin" || disc.provider === "heyreach") {
2750
- // LinkedIn reads a pre-populated lead list (not an ICP-driven query); the ICP
2751
- // scores the pulled list below. List id: disc.listId or --list <id>.
2752
- const listId = disc.listId ?? option(rest, "--list") ?? undefined;
2753
- if (!listId) {
2754
- throw new Error("enrich acquire --source linkedin needs a HeyReach lead-list id: pass --list <id> or set acquire.discovery.linkedin.listId.");
2755
- }
2756
- const provider = createLinkedInProvider(disc.provider, providerKey("heyreach"));
2757
- prospects = await discoverLinkedInProspects(provider, { sourceId: listId, max: size });
2758
- }
2759
- else {
2760
- throw new Error(`enrich acquire: unknown discovery provider "${disc.provider}" (explorium | pipe0 | linkedin).`);
2761
- }
2762
- // 2. ICP fit scoring BEFORE paying for emails — only above-threshold prospects
2763
- // proceed to (credit-spending) email resolution.
2764
- if (icp) {
2765
- const threshold = fitThreshold(icp);
2766
- prospects = prospects
2767
- .map((p) => ({ ...p, fitScore: scoreProspectAgainstIcp(p, icp).score }))
2768
- .filter((p) => (p.fitScore ?? 0) >= threshold);
2769
- }
2770
- // 2.5 Pre-email dedup — drop prospects already in the CRM (name+domain match
2771
- // vs the snapshot) or already processed in a prior run (the seen cache),
2772
- // BEFORE paying pipe0 for emails. The email-level dedup (buildAcquirePlan)
2773
- // and apply-time resolve-first remain the precise backstop.
2774
- const { fresh, skippedCrm, skippedSeen } = partitionFreshProspects(prospects, crmContactKeys(snapshot), seen);
2775
- prospects = fresh;
2776
- // 3. Resolve real work emails. Triggered either when email IS the dedupe key
2777
- // (Explorium's email is hashed, Crustdata returns none) or when a source
2778
- // that keys on something else opts in via `resolveEmailsWith: "pipe0"`
2779
- // (e.g. LinkedIn keys on the profile URL but still wants outreach emails).
2780
- // pipe0 waterfall, chunked; resolves from name + company domain/name.
2781
- if (matchKey === "email" || disc.resolveEmailsWith === "pipe0") {
2782
- // The waterfall needs a company DOMAIN; LinkedIn/HeyReach lists carry only
2783
- // names. Resolve domains first (pipe0 company:identity) so resolution can
2784
- // actually land — without it, name-only resolution fails for every lead.
2785
- if (prospects.some((p) => !p.companyDomain && p.companyName)) {
2786
- prospects = await pipe0ResolveCompanyDomains({ apiKey: providerKey("pipe0"), prospects });
2787
- }
2788
- prospects = await pipe0ResolveWorkEmails({ apiKey: providerKey("pipe0"), prospects });
2789
- }
2790
- const processedKeys = prospects.flatMap(prospectIdentityKeys);
2791
- const records = prospects
2792
- .map((p) => {
2793
- const keyValue = p[matchKey];
2794
- return {
2795
- id: p.sourceId ?? `${source}:${keyValue ?? p.fullName ?? ""}`,
2796
- objectType: "contact",
2797
- keys: { [matchKey]: keyValue },
2798
- payload: p,
2799
- };
2800
- })
2801
- .filter((record) => Boolean(record.keys[matchKey]));
2802
- return { records, skippedCrm, skippedSeen, processedKeys };
2803
- }
2804
- function tryLoadAcquireConfig(args) {
2805
- try {
2806
- const path = resolve(process.cwd(), option(args, "--config") ?? ENRICH_CONFIG_FILE_NAME);
2807
- if (!existsSync(path))
2808
- return undefined;
2809
- return loadEnrichConfig(path);
2810
- }
2811
- catch {
2812
- return undefined;
2813
- }
2814
- }
2815
- function formatAcquireMeter(headroom, costPerRecord) {
2816
- const n = (v) => (v === null ? "∞" : String(v));
2817
- const money = (v) => (v === null ? "∞" : `$${v.toFixed(2)}`);
2818
- const max = headroom.maxRecords === null ? "unlimited" : `${headroom.maxRecords}`;
2819
- return (`Acquire meter — creatable now: ${max} lead(s). ` +
2820
- `Records left: ${n(headroom.records.day)}/day, ${n(headroom.records.month)}/month · ` +
2821
- `Spend left: ${money(headroom.spendUsd.day)}/day, ${money(headroom.spendUsd.month)}/month ` +
2822
- `(≈$${costPerRecord.toFixed(2)}/lead).`);
2823
- }
2824
- function resolveEnrichSource(config, rest) {
2825
- const requested = option(rest, "--source");
2826
- const declared = Object.keys(config.sources);
2827
- if (requested) {
2828
- if (!config.sources[requested]) {
2829
- throw new Error(`Unknown enrich source "${requested}" (declared: ${declared.join(", ")})`);
2830
- }
2831
- return requested;
2832
- }
2833
- if (declared.length === 1)
2834
- return declared[0];
2835
- if (config.sources.apollo)
2836
- return "apollo";
2837
- throw new Error(`Multiple sources declared (${declared.join(", ")}) — pass --source <id>`);
2838
- }
2839
- function parseEnrichObjects(rest, config, source) {
2840
- const configured = ["company", "contact"].filter((objectType) => (config.fields[objectType] ?? []).some((field) => field.from[source] !== undefined));
2841
- const flag = option(rest, "--objects");
2842
- if (!flag) {
2843
- if (configured.length === 0) {
2844
- throw new Error(`No fields map from source "${source}" — add "from": { "${source}": ... } entries to the config.`);
2845
- }
2846
- return configured;
2847
- }
2848
- const requested = Array.from(new Set(flag.split(",").map((part) => parseSingleObjectType(part))));
2849
- for (const objectType of requested) {
2850
- if (!configured.includes(objectType)) {
2851
- throw new Error(`--objects ${flag}: no ${objectType} fields map from source "${source}" in the config.`);
2852
- }
2853
- }
2854
- return requested;
2855
- }
2856
- function apolloApiKey() {
2857
- if (process.env.APOLLO_API_KEY)
2858
- return process.env.APOLLO_API_KEY;
2859
- const stored = getCredential("apollo");
2860
- if (stored)
2861
- return stored.accessToken;
2862
- throw new Error('No Apollo credentials. Run `echo "$APOLLO_API_KEY" | fullstackgtm login apollo` once, or set APOLLO_API_KEY.');
2863
- }
2864
- /**
2865
- * Open (or resume) a saved run. An interrupted run — same label, same source
2866
- * and mode, never completed — is resumed from its cursor; a completed run
2867
- * with the default label gets a -2/-3 suffix (runs are append-only).
2868
- */
2869
- async function openEnrichRun(store, source, mode, requestedLabel, today) {
2870
- const baseLabel = requestedLabel ?? `${mode}-${source}-${today}`;
2871
- let label = baseLabel;
2872
- for (let suffix = 2;; suffix += 1) {
2873
- const existing = await store.get(label);
2874
- if (!existing)
2875
- break;
2876
- if (existing.source === source && existing.mode === mode && existing.completedAt === null) {
2877
- return existing; // resume the interrupted run
2878
- }
2879
- if (requestedLabel) {
2880
- throw new Error(`Run "${requestedLabel}" already exists and is completed — enrich runs are append-only.`);
2881
- }
2882
- label = `${baseLabel}-${suffix}`;
2883
- }
2884
- return store.append({
2885
- id: enrichRunId(source, label),
2886
- runLabel: label,
2887
- source,
2888
- mode,
2889
- startedAt: new Date().toISOString(),
2890
- completedAt: null,
2891
- cursor: null,
2892
- counts: { fetched: 0, matched: 0, unmatched: 0, ambiguous: 0, opsEmitted: 0 },
2893
- planIds: [],
2894
- stamps: [],
2895
- });
2896
- }
2897
- async function enrichIngest(store, config, rest, autoAppend = false) {
2898
- const file = rest.find((arg) => !arg.startsWith("--") && !isOptionValue(rest, arg));
2899
- if (!file)
2900
- throw new Error("Usage: fullstackgtm enrich ingest <file.csv|payload.json> --source <id> [--run-label <label>]");
2901
- const source = option(rest, "--source");
2902
- if (!source)
2903
- throw new Error("enrich ingest requires --source <id> (the ingest source the data belongs to)");
2904
- const sourceConfig = config.sources[source];
2905
- if (!sourceConfig) {
2906
- throw new Error(`Unknown enrich source "${source}" (declared: ${Object.keys(config.sources).join(", ")})`);
2907
- }
2908
- if (sourceConfig.kind !== "ingest") {
2909
- throw new Error(`Source "${source}" is kind "${sourceConfig.kind}" — only ingest sources accept staged data.`);
2910
- }
2911
- const raw = readFileSync(resolve(process.cwd(), file), "utf8");
2912
- let rows;
2913
- const isCsv = file.toLowerCase().endsWith(".csv") || sourceConfig.format === "csv";
2914
- if (isCsv && !file.toLowerCase().endsWith(".json")) {
2915
- rows = parseCsv(raw);
2916
- }
2917
- else {
2918
- const parsed = JSON.parse(raw);
2919
- if (Array.isArray(parsed))
2920
- rows = parsed;
2921
- else if (parsed && typeof parsed === "object" && Array.isArray(parsed.rows)) {
2922
- rows = parsed.rows;
2923
- }
2924
- else if (parsed && typeof parsed === "object") {
2925
- rows = [parsed];
2926
- }
2927
- else {
2928
- throw new Error(`${file}: expected a JSON array, an object, or { "rows": [...] }`);
2929
- }
2930
- }
2931
- if (rows.length === 0)
2932
- throw new Error(`${file}: no rows to stage`);
2933
- const objectsFlag = option(rest, "--objects");
2934
- const objectType = objectsFlag
2935
- ? parseSingleObjectType(objectsFlag)
2936
- : inferIngestObjectType(config, source, rows);
2937
- const today = new Date().toISOString().slice(0, 10);
2938
- const baseLabel = option(rest, "--run-label") ?? `ingest-${source}-${today}`;
2939
- let label = baseLabel;
2940
- for (let suffix = 2; await store.get(label); suffix += 1) {
2941
- if (option(rest, "--run-label")) {
2942
- throw new Error(`Run "${baseLabel}" already exists — enrich runs are append-only; pick a new --run-label.`);
2943
- }
2944
- label = `${baseLabel}-${suffix}`;
2945
- }
2946
- const now = new Date().toISOString();
2947
- await store.append({
2948
- id: enrichRunId(source, label),
2949
- runLabel: label,
2950
- source,
2951
- mode: "ingest",
2952
- startedAt: now,
2953
- completedAt: now,
2954
- cursor: null,
2955
- counts: { fetched: rows.length, matched: 0, unmatched: 0, ambiguous: 0, opsEmitted: 0 },
2956
- planIds: [],
2957
- stamps: [],
2958
- staged: rows,
2959
- stagedObjectType: objectType,
2960
- });
2961
- console.log(autoAppend
2962
- ? `Staged ${rows.length} ${objectType} row(s) from ${file} as run ${label}; matching against the CRM…`
2963
- : `Staged ${rows.length} ${objectType} row(s) from ${file} as run ${label}. ` +
2964
- `Next: fullstackgtm enrich append --source ${source} [source options] [--save]`);
2965
- }
2966
- function parseSingleObjectType(value) {
2967
- const normalized = value.trim().toLowerCase();
2968
- if (normalized === "companies" || normalized === "company")
2969
- return "company";
2970
- if (normalized === "contacts" || normalized === "contact")
2971
- return "contact";
2972
- throw new Error(`--objects must be companies or contacts (got "${value}")`);
2973
- }
2974
- async function enrichStatus(store, rest, configFile) {
2975
- const sourceFilter = option(rest, "--source");
2976
- const allRuns = (await store.list()).filter((run) => !sourceFilter || run.source === sourceFilter);
2977
- if (allRuns.length === 0) {
2978
- console.log(sourceFilter
2979
- ? `No enrich runs for source "${sourceFilter}".`
2980
- : "No enrich runs yet. Start with `fullstackgtm enrich append --save` or stage data with `enrich ingest`.");
2981
- return;
2982
- }
2983
- // Staleness windows come from the config when one is readable; status must
2984
- // not REQUIRE a config (the run store alone is enough to report on).
2985
- let config = null;
2986
- if (existsSync(configFile)) {
2987
- try {
2988
- config = loadEnrichConfig(configFile);
2989
- }
2990
- catch {
2991
- config = null;
2992
- }
2993
- }
2994
- const now = Date.now();
2995
- const sources = Array.from(new Set(allRuns.map((run) => run.source)));
2996
- const report = sources.map((source) => {
2997
- const runs = allRuns.filter((run) => run.source === source);
2998
- const last = runs[runs.length - 1];
2999
- const interrupted = runs.filter((run) => run.completedAt === null);
3000
- const stamps = Array.from(latestStamps(runs, source).values());
3001
- const ages = stamps.map((stamp) => (now - Date.parse(stamp.enrichedAt)) / 86_400_000);
3002
- const staleness = stamps.map((stamp, index) => {
3003
- const windowDays = config
3004
- ? staleDaysFor(config, stamp.objectType, stamp.field)
3005
- : DEFAULT_STALE_DAYS;
3006
- return ages[index] > windowDays;
3007
- });
3008
- return {
3009
- source,
3010
- runs: runs.length,
3011
- lastRun: {
3012
- runLabel: last.runLabel,
3013
- mode: last.mode,
3014
- startedAt: last.startedAt,
3015
- completedAt: last.completedAt,
3016
- counts: last.counts,
3017
- planIds: last.planIds,
3018
- },
3019
- interrupted: interrupted.map((run) => ({ runLabel: run.runLabel, cursor: run.cursor })),
3020
- stamps: {
3021
- total: stamps.length,
3022
- stale: staleness.filter(Boolean).length,
3023
- oldestDays: ages.length ? Math.round(Math.max(...ages)) : null,
3024
- newestDays: ages.length ? Math.round(Math.min(...ages)) : null,
3025
- windowSource: config ? "enrich.config.json" : `default ${DEFAULT_STALE_DAYS}d`,
3026
- },
3027
- };
3028
- });
3029
- if (rest.includes("--json")) {
3030
- console.log(JSON.stringify({ sources: report, runs: rest.includes("--runs") ? allRuns : undefined }, null, 2));
3031
- return;
3032
- }
3033
- for (const entry of report) {
3034
- const last = entry.lastRun;
3035
- console.log(`${entry.source} — ${entry.runs} run(s)`);
3036
- console.log(` last: ${last.runLabel} (${last.mode}) ${last.completedAt ? `completed ${last.completedAt}` : "INTERRUPTED"}` +
3037
- ` · ${last.counts.fetched} fetched, ${last.counts.matched} matched, ${last.counts.unmatched} unmatched,` +
3038
- ` ${last.counts.ambiguous} ambiguous, ${last.counts.opsEmitted} ops` +
3039
- (last.planIds.length ? ` · plans: ${last.planIds.join(", ")}` : ""));
3040
- for (const run of entry.interrupted) {
3041
- console.log(` interrupted: ${run.runLabel} at cursor ${run.cursor ?? "(start)"} — re-run with --save to resume`);
3042
- }
3043
- console.log(` stamps: ${entry.stamps.total} field(s) enriched · ${entry.stamps.stale} stale (window: ${entry.stamps.windowSource})` +
3044
- (entry.stamps.total ? ` · age ${entry.stamps.newestDays}–${entry.stamps.oldestDays}d` : ""));
3045
- }
3046
- if (rest.includes("--runs")) {
3047
- console.log("");
3048
- for (const run of allRuns) {
3049
- console.log(`${run.runLabel} ${run.source.padEnd(8)} ${run.mode.padEnd(8)} ${run.completedAt ? "done" : "interrupted"}` +
3050
- ` ${run.counts.opsEmitted} ops ${run.stamps.length} stamps${run.staged ? ` ${run.staged.length} staged` : ""}`);
3051
- }
3052
- }
3053
- }
3054
- /**
3055
- * The schedule layer: declarative cadences for read/plan-side commands,
3056
- * materialized through a provider (MVP: the user crontab), with an
3057
- * append-only run history. The governance invariant — scheduling never
3058
- * auto-approves — is enforced at `add` time and re-checked at `run` time.
3059
- * Spec: docs/schedule.md (monorepo).
3060
- */
3061
- async function scheduleCommand(args) {
3062
- const [subcommand, ...rest] = args;
3063
- // Catch --help BEFORE any store read, credential access, or crontab
3064
- // round-trip (the enrich/market early-catch pattern — `schedule install
3065
- // --help` must never touch the crontab).
3066
- if (!subcommand || subcommand === "--help" || subcommand === "-h" || rest.includes("--help") || rest.includes("-h")) {
3067
- console.log(`Usage:
3068
- schedule add "<fullstackgtm command>" --cron "<expr>" [--label <name>] [--provider local]
3069
- schedule list [--json]
3070
- schedule remove <id>
3071
- schedule enable <id> | disable <id>
3072
- schedule run <id> execute now; the single entry point every provider's timer calls
3073
- schedule install [--provider local] render enabled entries into the managed crontab block
3074
- schedule uninstall [--provider local] remove the managed block (nothing else)
3075
- schedule status [<id>] [--runs <n>] [--json]
3076
-
3077
- add validates the command against the schedulable allowlist — read/plan-side
3078
- only: audit, snapshot, enrich append|refresh, market capture|refresh,
3079
- suggest, report, doctor — and parses the 5-field cron expression (*, lists,
3080
- ranges, steps), but touches NO crontab: entries are declarative until install
3081
- materializes them (the plan/apply split — declare, then deliberately
3082
- activate).
3083
-
3084
- Scheduling never auto-approves. apply is schedulable ONLY as
3085
- \`apply --plan-id <id>\`, and every firing re-checks the plan's status is
3086
- approved — an unapproved plan records a plan_not_approved no-op run instead
3087
- of executing. There is no flag that relaxes this. Unattended runs accumulate
3088
- proposals (plans, run records, reports), never surprise CRM writes.
3089
-
3090
- install renders all enabled entries into a sentinel-delimited block
3091
- (# >>> fullstackgtm <profile> >>> ... # <<< fullstackgtm <profile> <<<) in
3092
- the user crontab; each line invokes \`schedule run <id> --profile <profile>\`
3093
- and nothing else. Re-install replaces the block wholesale and never touches
3094
- lines outside the sentinels. Providers other than local (modal, aws) are not
3095
- yet implemented — they will be scaffold generators calling the same
3096
- \`schedule run\` contract.
3097
-
3098
- run executes the command in-process and records the outcome (exit code,
3099
- output tail, artifacts: plan ids / enrich run labels) under the profile's
3100
- schedule/runs/<id>/; the exit code propagates. Manual runs record
3101
- trigger: manual. status shows next firing and surfaces missed firings
3102
- (laptop asleep = cron skips silently; visibility only, no catch-up).`);
3103
- return;
3104
- }
3105
- const store = createFileScheduleStore();
3106
- const runStore = createFileScheduleRunStore();
3107
- if (subcommand === "add") {
3108
- const commandText = rest.find((arg) => !arg.startsWith("--") && !isOptionValue(rest, arg));
3109
- if (!commandText) {
3110
- throw new Error('Usage: fullstackgtm schedule add "<fullstackgtm command>" --cron "<expr>" [--label <name>] [--provider local]');
3111
- }
3112
- const cronText = option(rest, "--cron");
3113
- if (!cronText)
3114
- throw new Error('schedule add requires --cron "<minute hour day-of-month month day-of-week>"');
3115
- requireLocalScheduleProvider(option(rest, "--provider") ?? "local");
3116
- let argv = tokenizeCommand(commandText);
3117
- if (argv[0] === "fullstackgtm")
3118
- argv = argv.slice(1);
3119
- validateSchedulableArgv(argv);
3120
- const cron = parseCron(cronText);
3121
- const next = nextCronFiring(cron, new Date()); // also rejects never-firing expressions
3122
- const createdAt = new Date().toISOString();
3123
- const label = option(rest, "--label") ??
3124
- argv.filter((arg) => !arg.startsWith("--")).slice(0, 2).join("-").replace(/[^\w.-]+/g, "-");
3125
- assertSingleLineLabel(label);
3126
- const entry = {
3127
- id: scheduleId(label, cron.source, argv, createdAt),
3128
- label,
3129
- argv,
3130
- cron: cron.source,
3131
- provider: "local",
3132
- enabled: true,
3133
- createdAt,
3134
- };
3135
- await store.add(entry);
3136
- console.log(`Added ${entry.id} "${label}": \`${argv.join(" ")}\` at \`${cron.source}\` (next firing: ${next.toISOString()}).`);
3137
- console.log("Declarative until materialized — activate with `fullstackgtm schedule install`.");
3138
- return;
3139
- }
3140
- if (subcommand === "list") {
3141
- const entries = await store.list();
3142
- const now = new Date();
3143
- const withNext = entries.map((entry) => ({
3144
- ...entry,
3145
- nextFiring: entry.enabled ? safeNextFiring(entry.cron, now) : null,
3146
- }));
3147
- if (rest.includes("--json")) {
3148
- console.log(JSON.stringify(withNext, null, 2));
3149
- return;
3150
- }
3151
- if (entries.length === 0) {
3152
- console.log('No schedules. Add one with `fullstackgtm schedule add "<command>" --cron "<expr>"`.');
3153
- return;
3154
- }
3155
- for (const entry of withNext) {
3156
- console.log(`${entry.id} ${entry.enabled ? "on " : "off"} ${entry.cron.padEnd(14)} next ${entry.nextFiring ?? "—"} ${entry.label}: ${entry.argv.join(" ")}`);
3157
- }
3158
- console.log("\nEntries are declarative — `schedule install` materializes enabled ones into the crontab.");
3159
- return;
3160
- }
3161
- if (subcommand === "remove") {
3162
- const id = positionalArg(rest, "Usage: fullstackgtm schedule remove <id>");
3163
- if (!(await store.remove(id)))
3164
- throw new Error(`No schedule with id ${id}.`);
3165
- console.log(`Removed ${id}. Run \`fullstackgtm schedule install\` to update the crontab block.`);
3166
- return;
3167
- }
3168
- if (subcommand === "enable" || subcommand === "disable") {
3169
- const id = positionalArg(rest, `Usage: fullstackgtm schedule ${subcommand} <id>`);
3170
- const entry = await store.setEnabled(id, subcommand === "enable");
3171
- console.log(`${subcommand === "enable" ? "Enabled" : "Disabled"} ${entry.id} "${entry.label}". ` +
3172
- "Run `fullstackgtm schedule install` to update the crontab block.");
3173
- return;
3174
- }
3175
- if (subcommand === "run") {
3176
- const id = positionalArg(rest, "Usage: fullstackgtm schedule run <id>");
3177
- const entry = await store.get(id);
3178
- if (!entry)
3179
- throw new Error(`No schedule with id ${id} in profile "${activeProfile()}".`);
3180
- if (!entry.enabled) {
3181
- throw new Error(`Schedule ${id} is disabled — enable it with \`fullstackgtm schedule enable ${id}\`.`);
3182
- }
3183
- const trigger = (option(rest, "--trigger") ?? "manual");
3184
- if (trigger !== "cron" && trigger !== "manual") {
3185
- throw new Error("--trigger must be cron or manual");
3186
- }
3187
- const run = await executeScheduledRun(entry, trigger);
3188
- if (run.exitCode !== 0)
3189
- process.exitCode = run.exitCode;
3190
- return;
3191
- }
3192
- if (subcommand === "install" || subcommand === "uninstall") {
3193
- requireLocalScheduleProvider(option(rest, "--provider") ?? "local");
3194
- const profile = activeProfile();
3195
- const io = systemCrontabIo();
3196
- const existing = io.read();
3197
- if (subcommand === "uninstall") {
3198
- io.write(replaceManagedBlock(existing, profile, null));
3199
- console.log(`Removed the fullstackgtm managed crontab block for profile "${profile}" (lines outside the sentinels untouched).`);
3200
- return;
3201
- }
3202
- const enabled = (await store.list()).filter((entry) => entry.enabled && entry.provider === "local");
3203
- if (enabled.length === 0) {
3204
- io.write(replaceManagedBlock(existing, profile, null));
3205
- console.log(`No enabled schedules for profile "${profile}" — removed the managed block.`);
3206
- return;
3207
- }
3208
- const block = renderManagedBlock(profile, enabled, scheduleCliInvocation());
3209
- io.write(replaceManagedBlock(existing, profile, block));
3210
- console.log(`Installed ${enabled.length} schedule(s) into the managed crontab block for profile "${profile}". ` +
3211
- "Re-running install replaces the block wholesale; `schedule uninstall` removes it.");
3212
- return;
3213
- }
3214
- if (subcommand === "status") {
3215
- const id = rest.find((arg) => !arg.startsWith("--") && !isOptionValue(rest, arg));
3216
- const entries = await store.list();
3217
- const selected = id ? entries.filter((entry) => entry.id === id) : entries;
3218
- if (id && selected.length === 0)
3219
- throw new Error(`No schedule with id ${id}.`);
3220
- if (selected.length === 0) {
3221
- console.log('No schedules. Add one with `fullstackgtm schedule add "<command>" --cron "<expr>"`.');
3222
- return;
3223
- }
3224
- const showRuns = numericOption(rest, "--runs");
3225
- const now = new Date();
3226
- const report = [];
3227
- for (const entry of selected) {
3228
- const runs = await runStore.list(entry.id);
3229
- const last = runs.length > 0 ? runs[runs.length - 1] : null;
3230
- let streak = 0;
3231
- for (let index = runs.length - 1; index >= 0; index -= 1) {
3232
- if ((runs[index].exitCode === 0) !== (last.exitCode === 0))
3233
- break;
3234
- streak += 1;
3235
- }
3236
- const missed = computeMissedFirings(entry, runs, now);
3237
- report.push({
3238
- id: entry.id,
3239
- label: entry.label,
3240
- argv: entry.argv,
3241
- cron: entry.cron,
3242
- enabled: entry.enabled,
3243
- nextFiring: entry.enabled ? safeNextFiring(entry.cron, now) : null,
3244
- lastRun: last
3245
- ? {
3246
- firedAt: last.firedAt,
3247
- trigger: last.trigger,
3248
- exitCode: last.exitCode,
3249
- noopReason: last.noopReason,
3250
- artifacts: last.artifacts,
3251
- }
3252
- : null,
3253
- streak: last ? { outcome: last.exitCode === 0 ? "success" : "failure", length: streak } : null,
3254
- missedFirings: missed.missed.map((firing) => firing.toISOString()),
3255
- missedFiringsCapped: missed.capped,
3256
- runs: showRuns !== undefined ? runs.slice(-showRuns) : undefined,
3257
- });
3258
- }
3259
- if (rest.includes("--json")) {
3260
- console.log(JSON.stringify(report, null, 2));
3261
- return;
3262
- }
3263
- for (const entry of report) {
3264
- const last = entry.lastRun;
3265
- const streak = entry.streak;
3266
- const missed = entry.missedFirings;
3267
- console.log(`${entry.id} ${entry.enabled ? "on " : "off"} ${entry.cron} ${entry.label}: ${entry.argv.join(" ")}`);
3268
- console.log(` next: ${entry.nextFiring ?? "— (disabled)"}`);
3269
- if (last) {
3270
- const artifacts = [
3271
- ...last.artifacts.planIds.map((planId) => `plan ${planId}`),
3272
- ...last.artifacts.runLabels.map((runLabel) => `run ${runLabel}`),
3273
- ];
3274
- console.log(` last: ${last.firedAt} (${last.trigger}) exit ${last.exitCode}` +
3275
- (last.noopReason ? ` — no-op: ${last.noopReason}` : "") +
3276
- (artifacts.length ? ` — ${artifacts.join(", ")}` : ""));
3277
- console.log(` streak: ${streak.length} ${streak.outcome}(s)`);
3278
- }
3279
- else {
3280
- console.log(" last: never fired");
3281
- }
3282
- if (missed.length > 0) {
3283
- console.log(` missed: ${missed.length}${entry.missedFiringsCapped ? "+" : ""} expected firing(s) with no run record ` +
3284
- `(latest: ${missed[missed.length - 1]}) — local cron has no catch-up; this is visibility only`);
3285
- }
3286
- const runs = entry.runs;
3287
- if (runs) {
3288
- for (const run of runs) {
3289
- console.log(` ${run.firedAt} ${run.trigger.padEnd(6)} exit ${run.exitCode}${run.noopReason ? ` no-op: ${run.noopReason}` : ""}`);
3290
- }
3291
- }
3292
- }
3293
- return;
3294
- }
3295
- throw new Error(`Unknown schedule subcommand: ${subcommand} (try: add, list, remove, enable, disable, run, install, uninstall, status)`);
3296
- }
3297
- function positionalArg(rest, usage) {
3298
- const value = rest.find((arg) => !arg.startsWith("--") && !isOptionValue(rest, arg));
3299
- if (!value)
3300
- throw new Error(usage);
3301
- return value;
3302
- }
3303
- function safeNextFiring(cron, now) {
3304
- try {
3305
- return nextCronFiring(parseCron(cron), now).toISOString();
3306
- }
3307
- catch {
3308
- return null;
3309
- }
3310
- }
3311
- /** The provider seam: local ships now; modal/aws arrive as scaffold generators. */
3312
- function requireLocalScheduleProvider(provider) {
3313
- if (provider === "local")
3314
- return;
3315
- if (provider === "modal" || provider === "aws") {
3316
- throw new Error(`Provider "${provider}" is not yet implemented — it will be a scaffold generator that emits a ` +
3317
- "deploy-ready artifact whose runner calls the same `schedule run <id>` contract. MVP provider: local.");
3318
- }
3319
- throw new Error(`Unknown provider "${provider}" — not yet implemented. MVP provider: local.`);
3320
- }
3321
- /**
3322
- * Resolve how cron should invoke this CLI: the current node binary plus the
3323
- * entry script (source checkouts need --experimental-strip-types), with
3324
- * FSGTM_HOME pinned when set so the cron environment sees the same store.
3325
- */
3326
- function scheduleCliInvocation() {
3327
- const script = process.argv[1] ? resolve(process.argv[1]) : null;
3328
- if (!script || !existsSync(script)) {
3329
- throw new Error("Cannot resolve the fullstackgtm entry point for crontab lines (process.argv[1] is missing).");
3330
- }
3331
- // A newline/control char in any of these flows verbatim into the crontab
3332
- // executable line; single-quote escaping defends the shell, not cron's line
3333
- // parser. Refuse early with a clear message (renderManagedBlock re-checks).
3334
- for (const [name, value] of [
3335
- ["FSGTM_HOME", process.env.FSGTM_HOME],
3336
- ["the node executable path", process.execPath],
3337
- ["the CLI script path", script],
3338
- ]) {
3339
- if (value && hasControlChar(value)) {
3340
- throw new Error(`Cannot install schedules: ${name} contains a newline or control character.`);
3341
- }
3342
- }
3343
- const quote = (value) => `'${value.replace(/'/g, `'\\''`)}'`;
3344
- const parts = [quote(process.execPath)];
3345
- if (script.endsWith(".ts"))
3346
- parts.push("--experimental-strip-types");
3347
- parts.push(quote(script));
3348
- const home = process.env.FSGTM_HOME ? `FSGTM_HOME=${quote(process.env.FSGTM_HOME)} ` : "";
3349
- // cron treats an unescaped `%` in the command field as a newline/stdin split.
3350
- // Escape it as `\%` so a stray `%` in a path can't truncate the managed line.
3351
- return (home + parts.join(" ")).replace(/%/g, "\\%");
3352
- }
3353
- /**
3354
- * The single provider entry point: execute the scheduled command in-process
3355
- * (dispatch into the CLI router — never a shell), capture the output tail and
3356
- * exit code as a run record, and hand the exit code back to the caller. Cron
3357
- * calls it, cloud providers will call it, and a human running it manually
3358
- * produces the same record (trigger: manual).
3359
- *
3360
- * Governance re-checks happen here, not just at `add` time: the allowlist is
3361
- * re-validated (schedules.json is user-editable), and a scheduled apply
3362
- * hard-checks the plan is approved — an unapproved plan records a
3363
- * plan_not_approved no-op run and does NOT execute. Scheduling never
3364
- * auto-approves.
3365
- */
3366
- async function executeScheduledRun(entry, trigger) {
3367
- const runStore = createFileScheduleRunStore();
3368
- const firedAt = new Date().toISOString();
3369
- const noop = async (reason, exitCode, message) => {
3370
- const run = {
3371
- scheduleId: entry.id,
3372
- firedAt,
3373
- completedAt: new Date().toISOString(),
3374
- trigger,
3375
- exitCode,
3376
- outputTail: message,
3377
- artifacts: { planIds: [], runLabels: [] },
3378
- noopReason: reason,
3379
- };
3380
- await runStore.record(run);
3381
- console.error(message);
3382
- return run;
3383
- };
3384
- try {
3385
- validateSchedulableArgv(entry.argv);
3386
- }
3387
- catch (error) {
3388
- return noop("not_schedulable", 1, error instanceof Error ? error.message : String(error));
3389
- }
3390
- if (entry.argv[0] === "apply") {
3391
- const planId = option(entry.argv.slice(1), "--plan-id");
3392
- const stored = await createFilePlanStore().get(planId);
3393
- const status = stored ? stored.status : "missing";
3394
- if (status !== "approved") {
3395
- // The governance gate holding is not a machinery failure: exit 0, with
3396
- // the refusal recorded on the run for `schedule status` to surface.
3397
- return noop("plan_not_approved", 0, `Plan ${planId} is ${status}, not approved — scheduled apply refuses to execute. ` +
3398
- `Scheduling never auto-approves; review and approve with \`fullstackgtm plans approve ${planId} --operations <ids|all>\`.`);
3399
- }
3400
- }
3401
- // Artifact attribution by store diff: anything new in the plan store or the
3402
- // enrich run store after the command ran was produced by this firing.
3403
- const planStore = createFilePlanStore();
3404
- const plansBefore = new Set((await planStore.list()).map((stored) => stored.plan.id));
3405
- const enrichStore = createFileEnrichRunStore();
3406
- const enrichBefore = new Set((await enrichStore.list()).map((run) => run.runLabel));
3407
- const lines = [];
3408
- const originalLog = console.log;
3409
- const originalError = console.error;
3410
- const tee = (original) => (...args) => {
3411
- for (const line of args.map(String).join(" ").split("\n"))
3412
- lines.push(line);
3413
- original(...args);
3414
- };
3415
- console.log = tee(originalLog);
3416
- console.error = tee(originalError);
3417
- const priorExitCode = process.exitCode;
3418
- process.exitCode = undefined;
3419
- let exitCode = 0;
3420
- try {
3421
- await runCli([...entry.argv]);
3422
- exitCode = typeof process.exitCode === "number" ? process.exitCode : 0;
3423
- }
3424
- catch (error) {
3425
- exitCode = 1;
3426
- lines.push(error instanceof Error ? error.message : String(error));
3427
- }
3428
- finally {
3429
- console.log = originalLog;
3430
- console.error = originalError;
3431
- process.exitCode = priorExitCode;
3432
- }
3433
- const planIds = (await planStore.list())
3434
- .map((stored) => stored.plan.id)
3435
- .filter((planId) => !plansBefore.has(planId));
3436
- const runLabels = (await enrichStore.list())
3437
- .map((run) => run.runLabel)
3438
- .filter((runLabel) => !enrichBefore.has(runLabel));
3439
- const run = {
3440
- scheduleId: entry.id,
3441
- firedAt,
3442
- completedAt: new Date().toISOString(),
3443
- trigger,
3444
- exitCode,
3445
- outputTail: lines.slice(-50).join("\n"),
3446
- artifacts: { planIds, runLabels },
3447
- };
3448
- await runStore.record(run);
3449
- return run;
3450
- }
3451
- /**
3452
- * The resolve gate: exit 0 = safe to create, exit 2 = match found (exists or
3453
- * ambiguous — do NOT blind-create), exit 1 = error. Built for sync jobs and
3454
- * webhook handlers to call before any record creation.
3455
- */
3456
- async function resolveCommand(args) {
3457
- const [objectType, ...rest] = args;
3458
- if (!objectType || !["account", "contact", "deal"].includes(objectType)) {
3459
- throw new Error("Usage: fullstackgtm resolve <account|contact|deal> [--name N] [--domain D] [--email E] [--account-id A] [source options] [--json]");
3460
- }
3461
- const candidate = {
3462
- objectType: objectType,
3463
- name: option(rest, "--name") ?? undefined,
3464
- domain: option(rest, "--domain") ?? undefined,
3465
- email: option(rest, "--email") ?? undefined,
3466
- accountId: option(rest, "--account-id") ?? undefined,
3467
- };
3468
- const snapshot = await readSnapshot(rest);
3469
- const result = resolveRecord(snapshot, candidate);
3470
- if (rest.includes("--json")) {
3471
- console.log(JSON.stringify(result, null, 2));
3472
- }
3473
- else {
3474
- const marker = result.verdict === "safe_to_create" ? "✓" : result.verdict === "exists" ? "=" : "?";
3475
- console.log(`${marker} [${result.verdict}] ${result.reason}`);
3476
- for (const m of result.matches) {
3477
- console.log(` ${m.id} "${m.name}" — matched by ${m.matchedBy}: ${m.detail}`);
3478
- }
3479
- }
3480
- if (result.verdict !== "safe_to_create")
3481
- process.exitCode = 2;
3482
- }
3483
- /**
3484
- * Governed generic writes: build a dry-run patch plan from a snapshot filter
3485
- * plus field assignments (or --archive). Never writes — approve and apply the
3486
- * plan like any audit plan; compare-and-set protects every operation.
3487
- */
3488
- async function bulkUpdateCommand(args) {
3489
- const [objectType, ...rest] = args;
3490
- if (!objectType || !["account", "contact", "deal"].includes(objectType)) {
3491
- throw new Error("Usage: fullstackgtm bulk-update <account|contact|deal> --where <field=value|field!=value|field~substr|field:empty|field:notempty> [--where …] (--set <field>=<value> [--set …] | --archive) [source options] [--reason <text>] [--max-operations <n>] [--save] [--out <path>] [--json]");
3492
- }
3493
- const where = repeatedOption(rest, "--where");
3494
- const set = {};
3495
- for (const pair of repeatedOption(rest, "--set")) {
3496
- const separator = pair.indexOf("=");
3497
- if (separator === -1)
3498
- throw new Error(`--set must look like <field>=<value>, got "${pair}"`);
3499
- set[pair.slice(0, separator)] = pair.slice(separator + 1);
3500
- }
3501
- const snapshot = await readSnapshot(rest);
3502
- const plan = buildBulkUpdatePlan(snapshot, {
3503
- objectType: objectType,
3504
- where,
3505
- set: Object.keys(set).length > 0 ? set : undefined,
3506
- archive: rest.includes("--archive"),
3507
- forceArchiveDuplicates: rest.includes("--force-archive-duplicates"),
3508
- createTask: option(rest, "--create-task") ?? undefined,
3509
- require: repeatedOption(rest, "--require"),
3510
- guard: repeatedOption(rest, "--guard"),
3511
- reason: option(rest, "--reason") ?? undefined,
3512
- maxOperations: numericOption(rest, "--max-operations"),
3513
- // --today resolves the comparison `today` literal (e.g. closeDate<today);
3514
- // defaults to the system date inside buildBulkUpdatePlan when omitted.
3515
- today: option(rest, "--today") ?? undefined,
3516
- });
3517
- await emitPlan(plan, rest);
3518
- }
3519
- /** Shared plan output plumbing: --out, --save (with the approve/apply hint), --json or markdown. */
3520
- async function emitPlan(plan, args) {
3521
- const out = option(args, "--out");
3522
- if (out) {
3523
- writeFileSync(resolve(process.cwd(), out), `${JSON.stringify(plan, null, 2)}\n`);
3524
- }
3525
- if (args.includes("--save")) {
3526
- await createFilePlanStore().save(plan);
3527
- console.error(`Saved plan ${plan.id} (${plan.operations.length} operations). Review with \`fullstackgtm plans show ${plan.id}\`, approve with \`fullstackgtm plans approve ${plan.id} --operations <ids|all>\`, then \`fullstackgtm apply --plan-id ${plan.id} --provider <name>\`.`);
3528
- }
3529
- if (args.includes("--json")) {
3530
- console.log(JSON.stringify(plan, null, 2));
3531
- }
3532
- else {
3533
- console.log(patchPlanToMarkdown(plan));
3534
- }
3535
- }
3536
- /**
3537
- * Governed duplicate cleanup: group by a normalized identity key, propose one
3538
- * merge_records per duplicate group with a deterministic survivor. Never
3539
- * writes — approve and apply the plan like any audit plan.
3540
- */
3541
- async function dedupeCommand(args) {
3542
- const [objectType, ...rest] = args;
3543
- if (!objectType || !["account", "contact", "deal"].includes(objectType)) {
3544
- throw new Error("Usage: fullstackgtm dedupe <account|contact|deal> --key <domain|email|name> [--keep richest|oldest] [source options] [--reason <text>] [--max-operations <n>] [--save] [--out <path>] [--json]");
3545
- }
3546
- const key = option(rest, "--key");
3547
- if (!key || !["domain", "email", "name"].includes(key)) {
3548
- throw new Error("dedupe requires --key <domain|email|name> (the identity field duplicates share).");
3549
- }
3550
- const keep = option(rest, "--keep") ?? undefined;
3551
- const snapshot = await readSnapshot(rest);
3552
- const plan = buildDedupePlan(snapshot, {
3553
- objectType: objectType,
3554
- key: key,
3555
- keep: (keep ?? undefined),
3556
- reason: option(rest, "--reason") ?? undefined,
3557
- maxOperations: numericOption(rest, "--max-operations"),
3558
- });
3559
- await emitPlan(plan, rest);
3560
- }
3561
- /**
3562
- * Ownership handoff playbook: compile one bulk-update-style plan per object
3563
- * type. Each plan carries its full filter, so eligibility (including the
3564
- * --except-deal-stage exclusion) is re-verified per record at apply time.
3565
- */
3566
- async function reassignCommand(args) {
3567
- const from = option(args, "--from");
3568
- const to = option(args, "--to");
3569
- const assignUnowned = args.includes("--assign-unowned");
3570
- if (!to || (!from && !assignUnowned)) {
3571
- throw new Error("Usage: fullstackgtm reassign (--from <ownerId> | --assign-unowned) --to <ownerId> [--objects account,contact,deal] [--where <expr> …] [--except-deal-stage <stage>] [--include-closed-deals] [source options] [--reason <text>] [--max-operations <n>] [--save] [--out <path>] [--json]\n\n--assign-unowned claims every OWNERLESS record (ownerId:empty) for --to — the backfill twin of `enrich acquire`'s create-time assignment.");
3572
- }
3573
- const objects = option(args, "--objects")
3574
- ?.split(",")
3575
- .map((value) => value.trim())
3576
- .filter(Boolean);
3577
- const snapshot = await readSnapshot(args);
3578
- const plans = buildReassignPlans(snapshot, {
3579
- fromOwnerId: from ?? undefined,
3580
- toOwnerId: to,
3581
- assignUnowned,
3582
- objects,
3583
- where: repeatedOption(args, "--where"),
3584
- exceptDealStage: option(args, "--except-deal-stage") ?? undefined,
3585
- includeClosedDeals: args.includes("--include-closed-deals"),
3586
- reason: option(args, "--reason") ?? undefined,
3587
- maxOperations: numericOption(args, "--max-operations"),
3588
- });
3589
- const out = option(args, "--out");
3590
- if (out) {
3591
- writeFileSync(resolve(process.cwd(), out), `${JSON.stringify(plans, null, 2)}\n`);
3592
- }
3593
- if (args.includes("--json")) {
3594
- console.log(JSON.stringify(plans, null, 2));
3595
- return;
3596
- }
3597
- const store = args.includes("--save") ? createFilePlanStore() : null;
3598
- for (const plan of plans) {
3599
- if (store)
3600
- await store.save(plan);
3601
- console.log(`${plan.id} ${String(plan.operations.length).padStart(3)} operation(s) ${plan.title}`);
3602
- console.log(` ${plan.summary}`);
3603
- }
3604
- if (store) {
3605
- console.log(`\nSaved ${plans.length} plan(s). For each: \`fullstackgtm plans show <id>\`, \`fullstackgtm plans approve <id> --operations <ids|all>\`, then \`fullstackgtm apply --plan-id <id> --provider <name>\`.`);
3606
- }
3607
- else {
3608
- console.log("\nDry run only — re-run with --save to store the plans for approval.");
3609
- }
3610
- }
3611
- /**
3612
- * One-shot composite for a single audit rule: audit → save → suggest →
3613
- * approve only suggestion-backed operations meeting the confidence bar (plus
3614
- * operations that carry concrete values and need no human input) → apply
3615
- * (only with --yes). Every stage goes through the same gates as the manual
3616
- * chain; placeholder values below the bar stay unapproved.
3617
- */
3618
- async function fixCommand(args) {
3619
- const ruleId = option(args, "--rule");
3620
- const provider = option(args, "--provider");
3621
- if (!ruleId || !provider) {
3622
- throw new Error("Usage: fullstackgtm fix --rule <ruleId> --provider <name> [--min-confidence high|low] [--include-creates] [--today <iso>] [--yes]");
3623
- }
3624
- const minConfidence = option(args, "--min-confidence") ?? "high";
3625
- if (!["high", "low"].includes(minConfidence)) {
3626
- throw new Error("--min-confidence must be high or low");
3627
- }
3628
- const includeCreates = args.includes("--include-creates");
3629
- const loaded = loadConfig(option(args, "--config") ?? undefined);
3630
- const configured = await resolveConfiguredRules(loaded);
3631
- const rule = configured.find((candidate) => candidate.id === ruleId);
3632
- if (!rule) {
3633
- throw new Error(`Unknown rule: ${ruleId}. Available rules: ${configured.map((r) => r.id).join(", ")}`);
3634
- }
3635
- const policy = mergePolicy(defaultPolicy(), loaded?.config);
3636
- const today = option(args, "--today");
3637
- if (today)
3638
- policy.today = today;
3639
- const snapshot = await readSnapshot(args);
3640
- const plan = auditSnapshot(snapshot, policy, [rule]);
3641
- if (plan.operations.length === 0) {
3642
- console.log(`fix ${ruleId}: audit proposed 0 operations — nothing to fix.`);
3643
- return;
3644
- }
3645
- const store = createFilePlanStore();
3646
- await store.save(plan);
3647
- const suggestions = suggestValues(plan, snapshot);
3648
- const accepted = new Set(minConfidence === "low" ? ["high", "low"] : ["high"]);
3649
- const overrides = {};
3650
- let belowBar = 0;
3651
- for (const suggestion of suggestions) {
3652
- if (suggestion.suggestedValue &&
3653
- (accepted.has(suggestion.confidence) || (includeCreates && suggestion.confidence === "create"))) {
3654
- overrides[suggestion.operationId] = suggestion.suggestedValue;
3655
- }
3656
- else {
3657
- belowBar += 1;
3658
- }
3659
- }
3660
- // Approve operations whose placeholder got a qualifying suggested value,
3661
- // plus operations that already carry a concrete value (no human input
3662
- // needed — nothing to guess). Everything else stays unapproved.
3663
- const placeholderIds = new Set(suggestions.map((suggestion) => suggestion.operationId));
3664
- const approvedIds = plan.operations
3665
- .map((operation) => operation.id)
3666
- .filter((id) => overrides[id] !== undefined || !placeholderIds.has(id));
3667
- const lines = [
3668
- `fix ${ruleId} via ${provider}:`,
3669
- ` proposed: ${plan.operations.length} operation(s) — plan ${plan.id} (saved)`,
3670
- ` suggested: ${Object.keys(overrides).length} value(s) at ${minConfidence}+ confidence${includeCreates ? " (creates included)" : ""}${belowBar > 0 ? `; ${belowBar} below the bar (left unapproved)` : ""}`,
3671
- ` approved: ${approvedIds.length} of ${plan.operations.length}`,
3672
- ];
3673
- if (approvedIds.length === 0) {
3674
- lines.push(" applied: 0 — no operation met the confidence bar");
3675
- console.log(lines.join("\n"));
3676
- console.log(`\nWiden with --min-confidence low / --include-creates, or approve manually: \`fullstackgtm plans approve ${plan.id} --operations <ids> --value <opId>=<value>\`.`);
3677
- return;
3678
- }
3679
- await store.approveOperations(plan.id, approvedIds, overrides);
3680
- if (!args.includes("--yes")) {
3681
- lines.push(" applied: 0 (stopped before apply — pass --yes to write)");
3682
- console.log(lines.join("\n"));
3683
- console.log(`\nApply with:\n fullstackgtm apply --plan-id ${plan.id} --provider ${provider}`);
3684
- return;
3685
- }
3686
- const connector = await connectorFor(provider, args);
3687
- const run = await applyPatchPlan(connector, plan, {
3688
- approvedOperationIds: approvedIds,
3689
- valueOverrides: overrides,
3690
- });
3691
- await store.recordRun(plan.id, run);
3692
- const counts = { applied: 0, conflict: 0, skipped: 0, failed: 0 };
3693
- for (const result of run.results)
3694
- counts[result.status] = (counts[result.status] ?? 0) + 1;
3695
- lines.push(` applied: ${counts.applied} · conflicts: ${counts.conflict} · skipped: ${counts.skipped} · failed: ${counts.failed}`);
3696
- console.log(lines.join("\n"));
3697
- if (run.status === "failed")
3698
- process.exitCode = 1;
3699
- }
3700
- async function suggest(args) {
3701
- const planId = option(args, "--plan-id");
3702
- const planPath = option(args, "--plan");
3703
- if (!planId && !planPath)
3704
- throw new Error("suggest requires --plan <path> or --plan-id <id>");
3705
- let plan;
3706
- if (planId) {
3707
- const stored = await createFilePlanStore().get(planId);
3708
- if (!stored)
3709
- throw new Error(`No stored plan with id ${planId}.`);
3710
- plan = stored.plan;
3711
- }
3712
- else {
3713
- plan = JSON.parse(readFileSync(resolve(process.cwd(), planPath), "utf8"));
3714
- }
3715
- const snapshot = await readSnapshot(args);
3716
- const suggestions = suggestValues(plan, snapshot);
3717
- const payload = { planId: planId ?? planPath, suggestions };
3718
- const outPath = option(args, "--out");
3719
- if (outPath)
3720
- writeFileSync(resolve(process.cwd(), outPath), `${JSON.stringify(payload, null, 2)}\n`);
3721
- if (args.includes("--json")) {
3722
- console.log(JSON.stringify(payload, null, 2));
3723
- return;
3724
- }
3725
- if (suggestions.length === 0) {
3726
- console.log("No requires_human_* placeholder operations in this plan — nothing to suggest.");
3727
- return;
3728
- }
3729
- const byConfidence = {};
3730
- for (const s of suggestions)
3731
- byConfidence[s.confidence] = (byConfidence[s.confidence] ?? 0) + 1;
3732
- console.log(`Suggestions for ${suggestions.length} placeholder operation(s):\n`);
3733
- for (const s of suggestions) {
3734
- const marker = s.confidence === "high" ? "✓" : s.confidence === "low" ? "~" : s.confidence === "create" ? "+" : "✗";
3735
- console.log(`${marker} [${s.confidence}] ${s.operationId} ${s.objectName ?? s.objectId}`);
3736
- console.log(` ${s.suggestedValue ? `→ ${s.suggestedValue}` : "(no suggestion)"} — ${s.reason}`);
3737
- }
3738
- console.log(`\n${Object.entries(byConfidence).map(([k, v]) => `${k}: ${v}`).join(" · ")}`);
3739
- if (planId && (byConfidence.high ?? 0) > 0 && !outPath) {
3740
- console.log(`\nChain it:\n fullstackgtm suggest --plan-id ${planId} ${snapshotSourceHint(args)}--out suggestions.json\n fullstackgtm plans approve ${planId} --values-from suggestions.json\n fullstackgtm apply --plan-id ${planId} --provider <name>`);
3741
- }
3742
- }
3743
- function snapshotSourceHint(args) {
3744
- const provider = option(args, "--provider");
3745
- if (provider)
3746
- return `--provider ${provider} `;
3747
- const input = option(args, "--input");
3748
- if (input)
3749
- return `--input ${input} `;
3750
- return "";
3751
- }
3752
- function readSuggestionValues(path, minConfidence, includeCreates) {
3753
- const raw = JSON.parse(readFileSync(resolve(process.cwd(), path), "utf8"));
3754
- if (!Array.isArray(raw.suggestions)) {
3755
- throw new Error(`${path} is not a suggestions file (expected { suggestions: [...] } from \`fullstackgtm suggest --out\`).`);
3756
- }
3757
- const accepted = new Set(minConfidence === "low" ? ["high", "low"] : ["high"]);
3758
- const overrides = {};
3759
- let skipped = 0;
3760
- for (const s of raw.suggestions) {
3761
- if (!s.suggestedValue)
3762
- continue;
3763
- if (accepted.has(s.confidence) || (includeCreates && s.confidence === "create")) {
3764
- overrides[s.operationId] = s.suggestedValue;
3765
- }
3766
- else {
3767
- skipped += 1;
3768
- }
3769
- }
3770
- return { overrides, skipped };
3771
- }
3772
- async function auditLogCommand(args) {
3773
- const [sub, ...rest] = args;
3774
- if (!sub || sub === "--help" || sub === "-h" || (sub !== "export" && sub !== "verify")) {
3775
- console.log(`Usage:
3776
- audit-log export [--out <path>] [--json] hash-chained, signed record of every apply run
3777
- audit-log verify [--in <path>] re-check an exported log's chain and signature
3778
-
3779
- export flattens every apply run across all stored plans (this profile) into a
3780
- tamper-evident chain — each entry carries the prior entry's hash, and the chain
3781
- head is HMAC-signed with this install's key — so a change-management process can
3782
- archive one file and later prove it was not edited. verify recomputes the chain
3783
- and (if the signing key is present) the signature.`);
3784
- return;
3785
- }
3786
- if (sub === "export") {
3787
- const plans = await createFilePlanStore().list();
3788
- const log = buildAuditLog(plans, new Date().toISOString());
3789
- const payload = `${JSON.stringify(log, null, 2)}\n`;
3790
- const outPath = option(rest, "--out");
3791
- if (outPath) {
3792
- writeFileSync(resolve(process.cwd(), outPath), payload);
3793
- console.log(`Wrote ${outPath}: ${log.entryCount} run(s), chain head ${log.chainHead.slice(0, 12)}${log.signature ? " (signed)" : " (unsigned — no signing key on this install)"}.`);
3794
- }
3795
- else if (rest.includes("--json")) {
3796
- console.log(payload);
3797
- }
3798
- else {
3799
- console.log(`${log.entryCount} apply run(s); chain head ${log.chainHead.slice(0, 12)}${log.signature ? ", signed" : ", unsigned"}. Pass --out <path> to archive, or --json to print.`);
3800
- }
3801
- return;
3802
- }
3803
- // verify
3804
- const inPath = option(rest, "--in");
3805
- if (!inPath)
3806
- throw new Error("audit-log verify requires --in <exported-log.json>");
3807
- const log = JSON.parse(readFileSync(resolve(process.cwd(), inPath), "utf8"));
3808
- const result = verifyAuditLog(log);
3809
- if (rest.includes("--json")) {
3810
- console.log(JSON.stringify(result, null, 2));
3811
- }
3812
- else {
3813
- console.log(result.ok ? `OK — ${result.detail}` : `TAMPERED — ${result.detail}`);
3814
- }
3815
- if (!result.ok)
3816
- process.exitCode = 2;
3817
- }
3818
- async function apply(args) {
3819
- const provider = option(args, "--provider");
3820
- if (!provider)
3821
- throw new Error("apply requires --provider <name>");
3822
- const planId = option(args, "--plan-id");
3823
- const planPath = option(args, "--plan");
3824
- if (!planId && !planPath)
3825
- throw new Error("apply requires --plan <path> or --plan-id <id>");
3826
- let plan;
3827
- let approvedOperationIds;
3828
- let valueOverrides;
3829
- const store = planId ? createFilePlanStore() : null;
3830
- if (planId && store) {
3831
- const stored = await store.get(planId);
3832
- if (!stored)
3833
- throw new Error(`No stored plan with id ${planId}.`);
3834
- if (stored.status !== "approved") {
3835
- throw new Error(`Plan ${planId} is ${stored.status}; approve operations first with \`fullstackgtm plans approve ${planId} --operations <ids|all>\`.`);
3836
- }
3837
- plan = stored.plan;
3838
- approvedOperationIds = stored.approvedOperationIds;
3839
- // Downgrade guard: an approved plan with no signatures is either pre-0.26
3840
- // (re-approve to gain them) or had its approvalDigests stripped to skip the
3841
- // integrity check. Either way, refuse rather than fall back to trusting the
3842
- // file. (A plan with zero approved operations has nothing to apply anyway.)
3843
- if (stored.approvedOperationIds.length > 0 && !stored.approvalDigests) {
3844
- throw new Error(`Refusing to apply plan ${planId}: it was approved without integrity signatures ` +
3845
- "(approved before 0.26.0, or its signatures were removed). Re-approve it with " +
3846
- `\`fullstackgtm plans approve ${planId} --operations <ids|all>\`.`);
3847
- }
3848
- // Integrity gate: the plan file is re-read from disk, so verify each approved
3849
- // operation still matches what was signed at approval. Verify against the
3850
- // EFFECTIVE overrides (stored ∪ apply-time --value): the invariant is "what
3851
- // gets written must equal what was signed", so an apply-time --value that
3852
- // changes a value the human did not approve is treated as tamper, not a live
3853
- // override. A mismatch means the plan/overrides were edited after approval —
3854
- // refuse the whole apply rather than write an unapproved value.
3855
- valueOverrides = { ...stored.valueOverrides, ...parseValueOverrides(args) };
3856
- const verification = verifyApprovalDigests(stored.plan.operations, stored.approvedOperationIds, valueOverrides, stored.approvalDigests);
3857
- if (!verification.ok) {
3858
- const detail = verification.reason === "no_key"
3859
- ? "the plan-signing key is missing (was this plan approved on another machine?). Re-approve it here with `fullstackgtm plans approve`."
3860
- : `these operations differ from what was approved: ${verification.tampered.join(", ")}. ` +
3861
- "If you changed a value at apply time, set it at approval instead (`plans approve --value <op>=<v>`) and re-approve; " +
3862
- "otherwise the plan was edited after approval — review and re-approve.";
3863
- throw new Error(`Refusing to apply plan ${planId}: ${detail}`);
3864
- }
3865
- }
3866
- else {
3867
- const approve = option(args, "--approve");
3868
- if (!approve) {
3869
- throw new Error('apply requires --approve <ids|all>; nothing is written without approval');
3870
- }
3871
- plan = JSON.parse(readFileSync(resolve(process.cwd(), planPath), "utf8"));
3872
- approvedOperationIds =
3873
- approve === "all"
3874
- ? plan.operations.map((operation) => operation.id)
3875
- : approve.split(",").map((id) => id.trim()).filter(Boolean);
3876
- valueOverrides = parseValueOverrides(args);
3877
- }
3878
- // Acquire meter: create_record ops are budgeted. Refuse up-front if the
3879
- // approved creates would exceed the current budget window (the plan was
3880
- // capped when `enrich acquire` ran, but the budget may have been spent down
3881
- // since), then charge the meter for what actually lands.
3882
- const approvedSet = new Set(approvedOperationIds);
3883
- const createOps = plan.operations.filter((op) => op.operation === "create_record" && approvedSet.has(op.id));
3884
- const createSpend = (op) => (op.afterValue?.estCostUsd) ?? 0;
3885
- if (createOps.length > 0) {
3886
- const acquireConfig = tryLoadAcquireConfig(args)?.acquire;
3887
- if (acquireConfig?.budget) {
3888
- const now = new Date();
3889
- const head = remaining(loadMeter(now), acquireConfig.budget, 0, now);
3890
- const approvedSpend = createOps.reduce((sum, op) => sum + createSpend(op), 0);
3891
- const refusals = [];
3892
- if (head.records.day !== null && createOps.length > head.records.day)
3893
- refusals.push(`${createOps.length} creates > ${head.records.day} record(s) left today`);
3894
- if (head.records.month !== null && createOps.length > head.records.month)
3895
- refusals.push(`${createOps.length} creates > ${head.records.month} record(s) left this month`);
3896
- if (head.spendUsd.day !== null && approvedSpend > head.spendUsd.day)
3897
- refusals.push(`$${approvedSpend.toFixed(2)} > $${head.spendUsd.day.toFixed(2)} spend left today`);
3898
- if (head.spendUsd.month !== null && approvedSpend > head.spendUsd.month)
3899
- refusals.push(`$${approvedSpend.toFixed(2)} > $${head.spendUsd.month.toFixed(2)} spend left this month`);
3900
- if (refusals.length > 0) {
3901
- throw new Error(`Refusing to apply: acquire budget exceeded (${refusals.join("; ")}). ` +
3902
- "Re-run `fullstackgtm enrich acquire` to re-cap to the remaining budget, or wait for the window to reset.");
3903
- }
3904
- }
3905
- }
3906
- const connector = await connectorFor(provider, args);
3907
- const run = await applyPatchPlan(connector, plan, {
3908
- approvedOperationIds,
3909
- valueOverrides,
3910
- });
3911
- if (planId && store) {
3912
- await store.recordRun(planId, run);
3913
- }
3914
- // Charge the acquire meter for the creates that actually landed.
3915
- if (createOps.length > 0) {
3916
- const appliedIds = new Set(run.results.filter((result) => result.status === "applied").map((result) => result.operationId));
3917
- const landed = createOps.filter((op) => appliedIds.has(op.id));
3918
- if (landed.length > 0) {
3919
- const spend = landed.reduce((sum, op) => sum + createSpend(op), 0);
3920
- recordConsumption(new Date(), landed.length, spend);
3921
- }
3922
- }
3923
- // Observability: apply-outcome tallies for the web run timeline.
3924
- const applyTally = { applied: 0, conflict: 0, skipped: 0, failed: 0 };
3925
- for (const result of run.results)
3926
- applyTally[result.status] = (applyTally[result.status] ?? 0) + 1;
3927
- reportCounts(applyTally);
3928
- if (args.includes("--json")) {
3929
- console.log(JSON.stringify(run, null, 2));
3930
- }
3931
- else {
3932
- console.log(formatPatchPlanRun(run));
3933
- }
3934
- if (run.status === "failed")
3935
- process.exitCode = 1;
3936
- }
3937
- async function diffCommand(args) {
3938
- const beforePath = option(args, "--before");
3939
- const afterPath = option(args, "--after");
3940
- if (!beforePath || !afterPath) {
3941
- throw new Error("diff requires --before <snapshot.json> and --after <snapshot.json>");
3942
- }
3943
- const before = JSON.parse(readFileSync(resolve(process.cwd(), beforePath), "utf8"));
3944
- const after = JSON.parse(readFileSync(resolve(process.cwd(), afterPath), "utf8"));
3945
- const loaded = loadConfig(option(args, "--config") ?? undefined);
3946
- const rules = selectedRules(args, await resolveConfiguredRules(loaded));
3947
- const policy = mergePolicy(defaultPolicy(), loaded?.config);
3948
- const today = option(args, "--today");
3949
- if (today)
3950
- policy.today = today;
3951
- const staleDealDays = numericOption(args, "--stale-days");
3952
- if (staleDealDays !== undefined)
3953
- policy.staleDealDays = staleDealDays;
3954
- const diff = diffSnapshots(before, after);
3955
- const drift = diffFindings(auditSnapshot(before, policy, rules), auditSnapshot(after, policy, rules));
3956
- if (args.includes("--json")) {
3957
- console.log(JSON.stringify({ diff, drift }, null, 2));
3958
- }
3959
- else {
3960
- console.log(diffToMarkdown(diff, drift));
3961
- }
3962
- if (args.includes("--fail-on-new-findings") && drift.newFindings.length > 0) {
3963
- process.exitCode = 2;
3964
- }
3965
- }
3966
- async function mergeCommand(args) {
3967
- const inputs = repeatedOption(args, "--input");
3968
- if (inputs.length < 2) {
3969
- throw new Error("merge requires at least two --input <snapshot.json> sources");
3970
- }
3971
- const out = option(args, "--out");
3972
- if (!out)
3973
- throw new Error("merge requires --out <merged.json>");
3974
- const snapshots = inputs.map((input) => JSON.parse(readFileSync(resolve(process.cwd(), input), "utf8")));
3975
- const { snapshot, report } = mergeSnapshots(snapshots);
3976
- writeFileSync(resolve(process.cwd(), out), `${JSON.stringify(snapshot, null, 2)}\n`);
3977
- if (args.includes("--json")) {
3978
- console.log(JSON.stringify(report, null, 2));
3979
- return;
3980
- }
3981
- console.log(`Merged ${report.sources.join(" + ")} into ${out}.`);
3982
- for (const type of ["user", "account", "contact"]) {
3983
- const count = report.matches.filter((match) => match.type === type).length;
3984
- console.log(` ${type} merges: ${count}`);
3985
- }
3986
- console.log(` conflicts: ${report.conflicts.length}`);
3987
- for (const conflict of report.conflicts) {
3988
- console.log(` ${conflict.type}/${conflict.recordId} ${conflict.field}: ${conflict.values
3989
- .map((entry) => `${entry.provider}=${JSON.stringify(entry.value)}`)
3990
- .join(" vs ")}`);
3991
- }
3992
- if (report.suggestions.length > 0) {
3993
- console.log(` review suggestions (same name, not auto-merged): ${report.suggestions.length}`);
3994
- for (const suggestion of report.suggestions) {
3995
- console.log(` "${suggestion.name}": ${suggestion.recordIds.join(", ")}`);
3996
- }
3997
- }
3998
- console.log(`Audit the merged view with \`fullstackgtm audit --input ${out}\`.`);
3999
- }
4000
- async function plansCommand(args) {
4001
- const store = createFilePlanStore();
4002
- const [subcommand, ...rest] = args;
4003
- if (subcommand === "list" || subcommand === undefined) {
4004
- const status = option(rest, "--status");
4005
- const plans = await store.list(status ?? undefined);
4006
- if (rest.includes("--json") || args.includes("--json")) {
4007
- console.log(JSON.stringify(plans.map((stored) => ({
4008
- id: stored.plan.id,
4009
- status: stored.status,
4010
- summary: stored.plan.summary,
4011
- approvedOperations: stored.approvedOperationIds.length,
4012
- runs: stored.runs.length,
4013
- createdAt: stored.createdAt,
4014
- })), null, 2));
4015
- return;
4016
- }
4017
- if (plans.length === 0) {
4018
- console.log("No stored plans. Create one with `fullstackgtm audit ... --save`.");
4019
- return;
4020
- }
4021
- for (const stored of plans) {
4022
- console.log(`${stored.plan.id} ${stored.status.padEnd(14)} ${stored.plan.summary} (${stored.approvedOperationIds.length} approved, ${stored.runs.length} runs)`);
4023
- }
4024
- return;
4025
- }
4026
- if (subcommand === "show") {
4027
- const planId = rest.find((arg) => !arg.startsWith("--"));
4028
- if (!planId)
4029
- throw new Error("Usage: fullstackgtm plans show <planId>");
4030
- const stored = await store.get(planId);
4031
- if (!stored)
4032
- throw new Error(`No stored plan with id ${planId}.`);
4033
- if (rest.includes("--json")) {
4034
- console.log(JSON.stringify(stored, null, 2));
4035
- return;
4036
- }
4037
- console.log(`Status: ${stored.status}`);
4038
- console.log(`Approved operations: ${stored.approvedOperationIds.join(", ") || "none"}`);
4039
- console.log(`Runs: ${stored.runs.length}`);
4040
- console.log("");
4041
- console.log(patchPlanToMarkdown(stored.plan));
4042
- return;
4043
- }
4044
- if (subcommand === "approve") {
4045
- const planId = rest.find((arg) => !arg.startsWith("--") && !isOptionValue(rest, arg));
4046
- if (!planId)
4047
- throw new Error("Usage: fullstackgtm plans approve <planId> --operations <ids|all> | --values-from <suggestions.json>");
4048
- const operations = option(rest, "--operations");
4049
- const valuesFrom = option(rest, "--values-from");
4050
- if (!operations && !valuesFrom) {
4051
- throw new Error("plans approve requires --operations <ids|all> and/or --values-from <suggestions.json>");
4052
- }
4053
- const stored = await store.get(planId);
4054
- if (!stored)
4055
- throw new Error(`No stored plan with id ${planId}.`);
4056
- // Values from a `fullstackgtm suggest --out` file. High-confidence only by
4057
- // default; widen with --min-confidence low, opt into record-creating
4058
- // values (create:<Name>) with --include-creates. Explicit --value wins.
4059
- let fileOverrides = {};
4060
- if (valuesFrom) {
4061
- const minConfidence = option(rest, "--min-confidence") ?? "high";
4062
- if (!["high", "low"].includes(minConfidence)) {
4063
- throw new Error("--min-confidence must be high or low");
4064
- }
4065
- const { overrides, skipped } = readSuggestionValues(valuesFrom, minConfidence, rest.includes("--include-creates"));
4066
- fileOverrides = overrides;
4067
- if (Object.keys(overrides).length === 0) {
4068
- throw new Error(`No suggestions in ${valuesFrom} meet the confidence bar (${skipped} below it). Re-run with --min-confidence low or --include-creates, or pass explicit --value overrides.`);
4069
- }
4070
- if (skipped > 0) {
4071
- console.log(`Skipped ${skipped} suggestion(s) below the confidence bar (widen with --min-confidence low / --include-creates).`);
4072
- }
4073
- }
4074
- const explicitOverrides = parseValueOverrides(rest);
4075
- const operationIds = operations === "all"
4076
- ? stored.plan.operations.map((operation) => operation.id)
4077
- : operations
4078
- ? operations.split(",").map((id) => id.trim()).filter(Boolean)
4079
- : Object.keys(fileOverrides);
4080
- const updated = await store.approveOperations(planId, operationIds, {
4081
- ...fileOverrides,
4082
- ...explicitOverrides,
4083
- });
4084
- console.log(`Approved ${updated.approvedOperationIds.length} operation(s) on ${planId}. Apply with \`fullstackgtm apply --plan-id ${planId} --provider <name>\`.`);
4085
- return;
4086
- }
4087
- if (subcommand === "reject") {
4088
- const planId = rest.find((arg) => !arg.startsWith("--"));
4089
- if (!planId)
4090
- throw new Error("Usage: fullstackgtm plans reject <planId>");
4091
- await store.reject(planId);
4092
- console.log(`Rejected ${planId}.`);
4093
- return;
4094
- }
4095
- throw new Error(`Unknown plans subcommand: ${subcommand}. Use list, show, approve, or reject.`);
4096
- }
4097
- /**
4098
- * Read a secret without exposing it on the command line. Secrets passed as
4099
- * argv leak through the process list (`ps`), `/proc`, and shell history, so
4100
- * the CLI never accepts them as flags. Instead:
4101
- * - non-interactive: pipe it on stdin (docker `--password-stdin` style),
4102
- * e.g. `echo "$TOKEN" | fullstackgtm login hubspot`
4103
- * - interactive: prompted on the TTY with the input muted
4104
- */
4105
- async function readSecret(label) {
4106
- if (!process.stdin.isTTY) {
4107
- const chunks = [];
4108
- for await (const chunk of process.stdin) {
4109
- chunks.push(chunk);
4110
- }
4111
- const piped = Buffer.concat(chunks).toString("utf8").trim();
4112
- if (!piped) {
4113
- throw new Error(`No secret provided. Pipe it on stdin (e.g. \`echo "$SECRET" | fullstackgtm login ...\`) ` +
4114
- `or run interactively. Secrets are never accepted as CLI arguments — they leak via the ` +
4115
- `process list and shell history.`);
4116
- }
4117
- // Multi-line stdin (rare) — take the first non-empty line.
4118
- return piped.split(/\r?\n/)[0].trim();
4119
- }
4120
- const readline = await import("node:readline");
4121
- return new Promise((resolveSecret) => {
4122
- const rl = readline.createInterface({
4123
- input: process.stdin,
4124
- output: process.stderr,
4125
- terminal: true,
4126
- });
4127
- let muted = false;
4128
- const mutable = rl;
4129
- mutable._writeToOutput = (value) => {
4130
- if (!muted)
4131
- process.stderr.write(value);
4132
- };
4133
- rl.question(`${label}: `, (answer) => {
4134
- rl.close();
4135
- process.stderr.write("\n");
4136
- resolveSecret(answer.trim());
4137
- });
4138
- muted = true;
4139
- });
4140
- }
4141
- function rejectArgvSecret(args, ...flags) {
4142
- for (const flag of flags) {
4143
- if (args.includes(flag)) {
4144
- throw new Error(`${flag} no longer accepts a value on the command line (argv secrets leak via \`ps\` and ` +
4145
- `shell history). Pipe the secret on stdin instead, e.g. \`echo "$SECRET" | fullstackgtm login ...\`.`);
4146
- }
4147
- }
4148
- }
4149
- /**
4150
- * The broker channel carries a long-lived pairing bearer and receives freshly
4151
- * minted live-CRM tokens, so it must be TLS unless it's an explicit localhost
4152
- * dev target. Refuse http:// (and non-http schemes) otherwise — single-quote
4153
- * shell escaping does nothing for a token sent in cleartext.
4154
- */
4155
- export function assertSecureBrokerUrl(raw) {
4156
- let url;
4157
- try {
4158
- url = new URL(raw);
4159
- }
4160
- catch {
4161
- throw new Error(`--via must be a full URL (e.g. https://gtm.yourco.com), got "${raw}".`);
4162
- }
4163
- const isLocalhost = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1" || url.hostname === "[::1]";
4164
- if (url.protocol === "https:")
4165
- return url;
4166
- if (url.protocol === "http:" && isLocalhost)
4167
- return url; // local dev only
4168
- throw new Error(`Refusing to pair over ${url.protocol}//${url.host}: the broker exchanges a long-lived token and mints live CRM ` +
4169
- "credentials, so it must use https (http is allowed only for localhost dev).");
4170
- }
4171
- async function brokerLogin(baseUrl) {
4172
- const viaUrl = assertSecureBrokerUrl(baseUrl);
4173
- const base = baseUrl.replace(/\/$/, "");
4174
- const os = await import("node:os");
4175
- // Self-reported, shown to the approver so they can recognize this request
4176
- // and refuse one they didn't initiate.
4177
- const requesterLabel = `${os.hostname()} (${process.platform}, ${os.userInfo().username})`;
4178
- let startResponse;
4179
- try {
4180
- startResponse = await fetch(`${base}/api/cli/auth/start`, {
4181
- method: "POST",
4182
- headers: { "Content-Type": "application/json" },
4183
- body: JSON.stringify({ requesterLabel }),
4184
- });
4185
- }
4186
- catch (error) {
4187
- const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
4188
- throw new Error(`Cannot reach the hosted deployment at ${base}${cause}. Check the --via URL and network access.`);
4189
- }
4190
- if (!startResponse.ok) {
4191
- throw new Error(`Could not start pairing with ${base} (${startResponse.status}). Is this a FullStackGTM deployment?`);
4192
- }
4193
- const start = await startResponse.json();
4194
- // Only auto-open a verification URL that belongs to the --via origin the user
4195
- // typed — a malicious/typo'd deployment cannot redirect the browser elsewhere.
4196
- let sameOrigin = false;
4197
- try {
4198
- sameOrigin = new URL(start.verificationUrl).origin === viaUrl.origin;
4199
- }
4200
- catch {
4201
- sameOrigin = false;
4202
- }
4203
- console.error(`\nPairing code: ${start.userCode}\n\nApprove this CLI ("${requesterLabel}") in your dashboard:\n\n ${start.verificationUrl}\n`);
4204
- if (sameOrigin) {
4205
- void openInBrowser(start.verificationUrl);
4206
- }
4207
- else {
4208
- console.error(`(Not auto-opening: the verification URL is not on ${viaUrl.origin}. Open it manually only if you trust it.)`);
4209
- }
4210
- const deadline = Date.now() + (start.expiresInSeconds ?? 600) * 1000;
4211
- const intervalMs = Math.max(0, (start.intervalSeconds ?? 3) * 1000);
4212
- while (Date.now() < deadline) {
4213
- await new Promise((resolveSleep) => setTimeout(resolveSleep, intervalMs));
4214
- const pollResponse = await fetch(`${base}/api/cli/auth/poll`, {
4215
- method: "POST",
4216
- headers: { "Content-Type": "application/json" },
4217
- body: JSON.stringify({ deviceCode: start.deviceCode }),
4218
- });
4219
- if (!pollResponse.ok) {
4220
- throw new Error(`Pairing poll failed (${pollResponse.status}).`);
4221
- }
4222
- const poll = await pollResponse.json();
4223
- if (poll.status === "pending")
4224
- continue;
4225
- if (poll.status === "approved" && poll.cliToken) {
4226
- const now = new Date().toISOString();
4227
- storeCredential("broker", {
4228
- kind: "broker",
4229
- accessToken: poll.cliToken,
4230
- baseUrl: base,
4231
- createdAt: now,
4232
- updatedAt: now,
4233
- });
4234
- console.log(`Paired with ${base}. Credentials stored in ${credentialsPath()}.`);
4235
- console.log("Provider commands now use the organization's stored sync credentials via the deployment.");
4236
- return;
4237
- }
4238
- throw new Error(`Pairing was ${poll.status}.`);
4239
- }
4240
- throw new Error("Pairing timed out before it was approved.");
4241
- }
4242
- function isOptionValue(args, arg) {
4243
- const index = args.indexOf(arg);
4244
- return index > 0 && args[index - 1].startsWith("--");
4245
- }
4246
- async function salesforceLogin(args) {
4247
- const now = new Date().toISOString();
4248
- if (args.includes("--device")) {
4249
- const clientId = option(args, "--client-id");
4250
- if (!clientId) {
4251
- throw new Error("--device requires --client-id (the consumer key of a Connected App with device flow enabled).");
4252
- }
4253
- const loginUrl = option(args, "--login-url") ?? undefined;
4254
- const authorization = await startSalesforceDeviceLogin({ clientId, loginUrl });
4255
- console.error(`\nPairing code: ${authorization.userCode}\n\nConfirm this code at:\n\n ${authorization.verificationUri}\n`);
4256
- void openInBrowser(authorization.verificationUri);
4257
- const tokens = await pollSalesforceDeviceLogin({
4258
- clientId,
4259
- deviceCode: authorization.deviceCode,
4260
- intervalSeconds: authorization.intervalSeconds,
4261
- loginUrl,
4262
- });
4263
- storeCredential("salesforce", {
4264
- kind: "oauth",
4265
- accessToken: tokens.accessToken,
4266
- refreshToken: tokens.refreshToken,
4267
- instanceUrl: tokens.instanceUrl,
4268
- expiresAt: tokens.expiresAt,
4269
- clientId,
4270
- loginUrl,
4271
- createdAt: now,
4272
- updatedAt: now,
4273
- });
4274
- console.log(`Logged in to Salesforce (${tokens.instanceUrl}). Credentials stored in ${credentialsPath()}.`);
4275
- console.log("Tokens refresh silently; no further browser interaction is needed.");
4276
- return;
4277
- }
4278
- rejectArgvSecret(args, "--token");
4279
- const instanceUrl = option(args, "--instance-url");
4280
- if (!instanceUrl) {
4281
- throw new Error("Salesforce login needs --device --client-id <consumer key>, or --instance-url " +
4282
- "<https://yourorg.my.salesforce.com> with the access token piped on stdin.");
4283
- }
4284
- const token = await readSecret("Salesforce access token");
4285
- if (!token)
4286
- throw new Error("No access token provided.");
4287
- if (!args.includes("--no-validate")) {
4288
- const result = await validateSalesforceToken(token, instanceUrl);
4289
- if (!result.ok)
4290
- throw new Error(result.detail);
4291
- console.log(result.detail);
4292
- }
4293
- storeCredential("salesforce", {
4294
- kind: "private_app",
4295
- accessToken: token,
4296
- instanceUrl,
4297
- createdAt: now,
4298
- updatedAt: now,
4299
- });
4300
- console.log(`Logged in to Salesforce. Credentials stored in ${credentialsPath()}.`);
4301
- }
4302
- async function login(args) {
4303
- const via = option(args, "--via");
4304
- if (via) {
4305
- await brokerLogin(via);
4306
- return;
4307
- }
4308
- const provider = args.find((arg) => !arg.startsWith("--") && !isOptionValue(args, arg));
4309
- if (provider === "salesforce") {
4310
- await salesforceLogin(args);
4311
- return;
4312
- }
4313
- if (provider === "stripe") {
4314
- rejectArgvSecret(args, "--token");
4315
- const key = await readSecret("Stripe secret key (sk_...)");
4316
- if (!key)
4317
- throw new Error("No Stripe key provided.");
4318
- if (!args.includes("--no-validate")) {
4319
- const response = await fetch("https://api.stripe.com/v1/customers?limit=1", {
4320
- headers: { Authorization: `Bearer ${key}` },
4321
- });
4322
- if (!response.ok) {
4323
- throw new Error(`Stripe rejected the key (${response.status}): ${safeStatus(response)}`);
4324
- }
4325
- console.log("Key accepted by the Stripe API.");
4326
- }
4327
- const stamp = new Date().toISOString();
4328
- storeCredential("stripe", {
4329
- kind: "private_app",
4330
- accessToken: key,
4331
- createdAt: stamp,
4332
- updatedAt: stamp,
4333
- });
4334
- console.log(`Logged in to Stripe. Credentials stored in ${credentialsPath()}.`);
4335
- return;
4336
- }
4337
- if (provider === "anthropic" || provider === "openai") {
4338
- rejectArgvSecret(args, "--token", "--key", "--api-key");
4339
- const key = await readSecret(`${provider} API key (${provider === "anthropic" ? "sk-ant-..." : "sk-..."})`);
4340
- if (!key)
4341
- throw new Error(`No ${provider} key provided.`);
4342
- if (!args.includes("--no-validate")) {
4343
- const validation = await validateLlmKey(provider, key);
4344
- if (!validation.ok)
4345
- throw new Error(`${provider} rejected the key: ${validation.detail}`);
4346
- console.log(validation.detail);
4347
- }
4348
- const stamp = new Date().toISOString();
4349
- storeCredential(provider, { kind: "api_key", accessToken: key, createdAt: stamp, updatedAt: stamp });
4350
- console.log(`Stored ${provider} API key in ${credentialsPath()}. \`fullstackgtm call parse\` and \`call score\` use it automatically.`);
4351
- return;
4352
- }
4353
- if (provider === "apollo") {
4354
- rejectArgvSecret(args, "--token", "--key", "--api-key");
4355
- const key = await readSecret("Apollo API key");
4356
- if (!key)
4357
- throw new Error("No Apollo key provided.");
4358
- if (!args.includes("--no-validate")) {
4359
- const response = await fetch("https://api.apollo.io/api/v1/auth/health", {
4360
- headers: { "X-Api-Key": key, Accept: "application/json" },
4361
- });
4362
- if (!response.ok) {
4363
- throw new Error(`Apollo rejected the key: ${safeStatus(response)}`);
4364
- }
4365
- console.log("Key accepted by the Apollo API.");
4366
- }
4367
- const stamp = new Date().toISOString();
4368
- storeCredential("apollo", { kind: "api_key", accessToken: key, createdAt: stamp, updatedAt: stamp });
4369
- console.log(`Stored Apollo API key in ${credentialsPath()}. \`fullstackgtm enrich append|refresh\` use it automatically.`);
4370
- return;
4371
- }
4372
- if (provider === "pipe0" || provider === "explorium" || provider === "heyreach") {
4373
- rejectArgvSecret(args, "--token", "--key", "--api-key");
4374
- const key = await readSecret(`${provider} API key`);
4375
- if (!key)
4376
- throw new Error(`No ${provider} key provided.`);
4377
- // No free auth-health endpoint; validating would spend credits, so the key
4378
- // is stored as-is and validated on the first `enrich acquire` pull.
4379
- const stamp = new Date().toISOString();
4380
- storeCredential(provider, { kind: "api_key", accessToken: key, createdAt: stamp, updatedAt: stamp });
4381
- console.log(`Stored ${provider} API key in ${credentialsPath()}. \`fullstackgtm enrich acquire\` uses it automatically (validated on first pull).`);
4382
- return;
4383
- }
4384
- if (provider !== "hubspot") {
4385
- throw new Error("login supports: hubspot, salesforce, stripe, anthropic, openai, apollo, pipe0, explorium, or --via <hosted url>. Usage: fullstackgtm login <provider> | fullstackgtm login --via https://gtm.example.com");
4386
- }
4387
- const now = new Date().toISOString();
4388
- if (args.includes("--oauth")) {
4389
- rejectArgvSecret(args, "--client-secret");
4390
- const clientId = option(args, "--client-id");
4391
- if (!clientId) {
4392
- throw new Error("--oauth requires --client-id from your own HubSpot app " +
4393
- `(register http://localhost:${numericOption(args, "--port") ?? DEFAULT_LOOPBACK_PORT}/callback as a redirect URL). ` +
4394
- "The client secret is read from stdin or an interactive prompt.");
4395
- }
4396
- const clientSecret = await readSecret("HubSpot app client secret");
4397
- if (!clientSecret)
4398
- throw new Error("No client secret provided.");
4399
- const scopes = option(args, "--scopes")?.split(",").map((scope) => scope.trim());
4400
- const tokens = await runHubspotLoopbackLogin({
4401
- clientId,
4402
- clientSecret,
4403
- port: numericOption(args, "--port"),
4404
- scopes,
4405
- });
4406
- storeCredential("hubspot", {
4407
- kind: "oauth",
4408
- accessToken: tokens.accessToken,
4409
- refreshToken: tokens.refreshToken,
4410
- expiresAt: tokens.expiresAt,
4411
- clientId,
4412
- clientSecret,
4413
- scopes,
4414
- createdAt: now,
4415
- updatedAt: now,
4416
- });
4417
- console.log(`Logged in to HubSpot via OAuth. Credentials stored in ${credentialsPath()}.`);
4418
- console.log("Tokens refresh silently; no further browser interaction is needed.");
4419
- return;
4420
- }
4421
- rejectArgvSecret(args, "--token");
4422
- const token = await readSecret("HubSpot private app token");
4423
- if (!token)
4424
- throw new Error("No token provided.");
4425
- if (!args.includes("--no-validate")) {
4426
- const result = await validateHubspotToken(token);
4427
- if (!result.ok)
4428
- throw new Error(result.detail);
4429
- console.log(result.detail);
4430
- }
4431
- storeCredential("hubspot", {
4432
- kind: "private_app",
4433
- accessToken: token,
4434
- createdAt: now,
4435
- updatedAt: now,
4436
- });
4437
- console.log(`Logged in to HubSpot. Credentials stored in ${credentialsPath()}.`);
4438
- }
4439
- function logout(args) {
4440
- const provider = args.find((arg) => !arg.startsWith("--"));
4441
- if (!provider)
4442
- throw new Error("Usage: fullstackgtm logout hubspot");
4443
- if (!getCredential(provider)) {
4444
- console.log(`No stored credentials for ${provider}.`);
4445
- return;
4446
- }
4447
- deleteCredential(provider);
4448
- console.log(`Removed stored ${provider} credentials.`);
4449
- }
4450
- export function doctorReport(env = process.env) {
4451
- const packageInfo = readPackageInfo();
4452
- const nodeMajor = Number(process.versions.node.split(".")[0]);
4453
- const storePath = credentialsPath();
4454
- const configPath = resolve("fullstackgtm.config.json");
4455
- const broker = getCredential("broker");
4456
- const providers = {
4457
- hubspot: env.HUBSPOT_ACCESS_TOKEN
4458
- ? { source: "env", detail: "HUBSPOT_ACCESS_TOKEN" }
4459
- : providerStatus("hubspot", broker),
4460
- salesforce: env.SALESFORCE_ACCESS_TOKEN && env.SALESFORCE_INSTANCE_URL
4461
- ? { source: "env", detail: "SALESFORCE_ACCESS_TOKEN + SALESFORCE_INSTANCE_URL" }
4462
- : providerStatus("salesforce", broker),
4463
- stripe: env.STRIPE_SECRET_KEY
4464
- ? { source: "env", detail: "STRIPE_SECRET_KEY" }
4465
- : providerStatus("stripe", broker),
4466
- };
4467
- const llm = resolveLlmCredential(env);
4468
- const missingPeers = ["@modelcontextprotocol/sdk", "zod"].filter((name) => {
4469
- try {
4470
- import.meta.resolve(name);
4471
- return false;
4472
- }
4473
- catch {
4474
- return true;
4475
- }
4476
- });
4477
- const connected = Object.entries(providers).filter(([, status]) => status.source !== "none");
4478
- const nextSteps = connected.length === 0
4479
- ? [
4480
- "fullstackgtm audit --demo # no credentials needed",
4481
- "fullstackgtm login hubspot # connect your CRM (or: login --via <hosted url>)",
4482
- ]
4483
- : [`fullstackgtm audit --provider ${connected[0][0]}`];
4484
- return {
4485
- package: packageInfo,
4486
- node: { version: process.versions.node, ok: nodeMajor >= 20, required: ">=20" },
4487
- profile: activeProfile(),
4488
- credentialStore: { path: storePath, exists: existsSync(storePath) },
4489
- config: { path: configPath, exists: existsSync(configPath) },
4490
- providers,
4491
- broker: broker ? { paired: true, baseUrl: broker.baseUrl ?? "unknown" } : { paired: false },
4492
- llm: llm
4493
- ? { configured: true, provider: llm.provider, source: llm.source }
4494
- : { configured: false, detail: "call parse/score will prompt once, or set ANTHROPIC_API_KEY / OPENAI_API_KEY" },
4495
- mcp: { peersInstalled: missingPeers.length === 0, missing: missingPeers },
4496
- nextSteps,
4497
- };
4498
- }
4499
- function providerStatus(provider, broker) {
4500
- const stored = getCredential(provider);
4501
- if (stored) {
4502
- return { source: "stored", detail: `${stored.kind} login, updated ${stored.updatedAt}` };
4503
- }
4504
- if (broker) {
4505
- return { source: "broker", detail: `via ${broker.baseUrl ?? "hosted deployment"}` };
4506
- }
4507
- return { source: "none", detail: `fullstackgtm login ${provider}` };
4508
- }
4509
- function readPackageInfo() {
4510
- try {
4511
- const raw = readFileSync(new URL("../package.json", import.meta.url), "utf8");
4512
- const parsed = JSON.parse(raw);
4513
- return { name: parsed.name ?? "fullstackgtm", version: parsed.version ?? "unknown" };
4514
- }
4515
- catch {
4516
- return { name: "fullstackgtm", version: "unknown" };
4517
- }
4518
- }
4519
- function doctorCommand(args) {
4520
- const report = doctorReport();
4521
- if (args.includes("--json")) {
4522
- console.log(JSON.stringify(report, null, 2));
4523
- return;
4524
- }
4525
- const mark = (ok) => (ok ? "ok" : "MISSING");
4526
- const lines = [
4527
- `Package: ${report.package.name} ${report.package.version}`,
4528
- `Node: v${report.node.version} (${report.node.required} required) ${mark(report.node.ok)}`,
4529
- `Profile: ${report.profile}${report.profile === DEFAULT_PROFILE ? "" : " (named profile — credentials and plans are scoped to it)"}`,
4530
- `Cred store: ${report.credentialStore.path} (${report.credentialStore.exists ? "present" : "not created yet — created on first login"})`,
4531
- `Config: ${report.config.exists ? report.config.path : "none — defaults apply"}`,
4532
- "",
4533
- "Providers:",
4534
- ...Object.entries(report.providers).map(([provider, status]) => ` ${provider.padEnd(11)} ${status.source === "none" ? `not connected (${status.detail})` : `${status.source}: ${status.detail}`}`),
4535
- ` ${"broker".padEnd(11)} ${report.broker.paired ? `paired with ${report.broker.baseUrl}` : "not paired (fullstackgtm login --via <hosted url>)"}`,
4536
- ` ${"llm".padEnd(11)} ${report.llm.configured ? `${report.llm.provider} key (${report.llm.source}) — call parse/score ready` : `not configured (${report.llm.detail})`}`,
4537
- "",
4538
- report.mcp.peersInstalled
4539
- ? "MCP: peers installed — `fullstackgtm-mcp` is ready"
4540
- : `MCP: optional peers missing (${report.mcp.missing.join(", ")}) — needed only for \`fullstackgtm-mcp\``,
4541
- "",
4542
- "Next step:",
4543
- ...report.nextSteps.map((step) => ` ${step}`),
4544
- ];
4545
- console.log(lines.join("\n"));
4546
- if (!report.node.ok)
4547
- process.exitCode = 1;
4548
- }
1
+ import { activeProfile, listProfiles, setActiveProfile } from "./credentials.js";
2
+ import { audit, healthCommand, reportCommand, rulesCommand, snapshotCommand } from "./cli/audit.js";
3
+ import { doctorCommand, login, logout } from "./cli/auth.js";
4
+ import { capabilitiesCommand, printCommandHelpJson, robotDocsCommand, unknownCommandEnvelope } from "./cli/capabilities.js";
5
+ import { callCommand } from "./cli/call.js";
6
+ import { draftCommand } from "./cli/draft.js";
7
+ import { enrichCommand } from "./cli/enrich.js";
8
+ import { bulkUpdateCommand, dedupeCommand, fixCommand, reassignCommand, resolveCommand, suggest } from "./cli/fix.js";
9
+ import { BESPOKE_HELP, commandHelp, HELP, shortUsage, stylizeShortUsage, usage } from "./cli/help.js";
10
+ import { icpCommand } from "./cli/icp.js";
11
+ import { initCommand } from "./cli/init.js";
12
+ import { marketCommand } from "./cli/market.js";
13
+ import { apply, auditLogCommand, diffCommand, mergeCommand, plansCommand } from "./cli/plans.js";
14
+ import { scheduleCommand } from "./cli/schedule.js";
15
+ import { readPackageInfo } from "./cli/shared.js";
16
+ import { correctedCommand, detectFlagTypo, suggestCommand, unknownFlagEnvelope } from "./cli/suggest.js";
17
+ import { colorEnabled, paint } from "./cli/ui.js";
18
+ import { signalsCommand } from "./cli/signals.js";
19
+ import { tamCommand } from "./cli/tam.js";
4549
20
  /**
4550
21
  * Pull the global `--profile <name>` flag out of argv (it may appear before
4551
22
  * or after the command) and activate it. Stripping it keeps positional
@@ -4581,17 +52,28 @@ export async function runCli(argv) {
4581
52
  const [command, ...args] = extractProfile(argv);
4582
53
  // Front door: the lifecycle-grouped map by default, the full reference on
4583
54
  // --full. `help <command>` zooms into one verb. (docs/dx-punch-list.md #1)
55
+ // Interactive terminals get the styled front door (dim brand header, bold
56
+ // command names); piped/CI/agent output is the plain text, byte-identical.
57
+ const styledShortUsage = () => stylizeShortUsage(shortUsage(), paint(colorEnabled(process.stdout)), {
58
+ version: readPackageInfo().version,
59
+ profile: activeProfile(),
60
+ });
4584
61
  if (!command || command === "--help" || command === "-h") {
4585
- console.log(args.includes("--full") ? usage() : shortUsage());
62
+ console.log(args.includes("--full") ? usage() : styledShortUsage());
4586
63
  return;
4587
64
  }
4588
65
  if (command === "help") {
4589
- const [topic, ...rest] = args;
66
+ const [topic] = args;
4590
67
  if (topic && topic !== "--full" && !topic.startsWith("-")) {
4591
- console.log(commandHelp(topic));
68
+ // `help <cmd> --json` → machine-readable help derived from the same
69
+ // HELP entry the plain text renders from. Plain output is unchanged.
70
+ if (args.includes("--json"))
71
+ printCommandHelpJson(topic);
72
+ else
73
+ console.log(commandHelp(topic));
4592
74
  }
4593
75
  else {
4594
- console.log(args.includes("--full") || topic === "--full" ? usage() : shortUsage());
76
+ console.log(args.includes("--full") || topic === "--full" ? usage() : styledShortUsage());
4595
77
  }
4596
78
  return;
4597
79
  }
@@ -4599,6 +81,13 @@ export async function runCli(argv) {
4599
81
  console.log(readPackageInfo().version);
4600
82
  return;
4601
83
  }
84
+ // `<cmd> --help --json` → machine-readable help for EVERY command,
85
+ // including the bespoke-help verbs (their plain --help still routes to
86
+ // their own richer text reference below). Plain --help output is unchanged.
87
+ if ((args.includes("--help") || args.includes("-h")) && args.includes("--json")) {
88
+ printCommandHelpJson(command);
89
+ return;
90
+ }
4602
91
  // Commands without bespoke help get focused per-command help on --help
4603
92
  // instead of executing (audit used to silently run the sample audit) or
4604
93
  // dumping the whole surface. call/market/enrich/bulk-update/schedule print
@@ -4607,6 +96,35 @@ export async function runCli(argv) {
4607
96
  console.log(args.includes("--full") ? usage() : commandHelp(command));
4608
97
  return;
4609
98
  }
99
+ // Flag typos used to be silently dropped by the per-verb parsers —
100
+ // `audit --demo --jsn` exited 0 and printed markdown where the agent asked
101
+ // for JSON. Near-miss unknown flags (≤ 1 edit from a flag documented in
102
+ // help.ts) now stop with the exact corrected command. Suggest-only: never
103
+ // auto-execute the correction, so a typo can never change what a
104
+ // write-shaped invocation stages.
105
+ if (command in HELP) {
106
+ const typo = detectFlagTypo(args);
107
+ if (typo) {
108
+ if (args.includes("--json")) {
109
+ console.log(JSON.stringify(unknownFlagEnvelope(command, args, typo), null, 2));
110
+ }
111
+ else {
112
+ console.error(`Unknown flag: ${typo.given}`);
113
+ console.error(`Did you mean: ${typo.suggestion}`);
114
+ console.error(`Try: ${correctedCommand(command, args, typo)}`);
115
+ }
116
+ process.exitCode = 1;
117
+ return;
118
+ }
119
+ }
120
+ if (command === "capabilities") {
121
+ capabilitiesCommand(args);
122
+ return;
123
+ }
124
+ if (command === "robot-docs") {
125
+ robotDocsCommand(args);
126
+ return;
127
+ }
4610
128
  if (command === "login") {
4611
129
  await login(args);
4612
130
  return;
@@ -4632,7 +150,7 @@ export async function runCli(argv) {
4632
150
  return;
4633
151
  }
4634
152
  if (command === "doctor") {
4635
- doctorCommand(args);
153
+ await doctorCommand(args);
4636
154
  return;
4637
155
  }
4638
156
  if (command === "health") {
@@ -4679,6 +197,10 @@ export async function runCli(argv) {
4679
197
  initCommand(args);
4680
198
  return;
4681
199
  }
200
+ if (command === "tam") {
201
+ await tamCommand(args);
202
+ return;
203
+ }
4682
204
  if (command === "icp") {
4683
205
  await icpCommand(args);
4684
206
  return;
@@ -4719,8 +241,34 @@ export async function runCli(argv) {
4719
241
  await apply(args);
4720
242
  return;
4721
243
  }
4722
- console.error(`Unknown command: ${command}`);
4723
- console.error("");
4724
- console.error(usage());
244
+ // Machine callers get a structured envelope with a did-you-mean hint
245
+ // (derived from the HELP table) instead of the full-usage text wall.
246
+ if (args.includes("--json")) {
247
+ console.log(JSON.stringify(unknownCommandEnvelope(command), null, 2));
248
+ process.exitCode = 1;
249
+ return;
250
+ }
251
+ // Text callers get the same hint instead of the ~200-line usage() wall the
252
+ // error path used to dump — `plan`, `dedup`, `healthcheck` are one nudge
253
+ // away from the right verb. `--help` still prints the full map on request.
254
+ if (command.startsWith("-")) {
255
+ // A flag-shaped first token is a different mistake: the flag came before
256
+ // (or without) a command. Say that, and resolve the flag itself when it
257
+ // is a near-miss of a documented one.
258
+ console.error(`${command} is a flag — a command must come first (e.g. fullstackgtm audit --json).`);
259
+ const flagTypo = detectFlagTypo([command]);
260
+ if (flagTypo)
261
+ console.error(`Did you mean: ${flagTypo.suggestion} (after a command)`);
262
+ }
263
+ else {
264
+ console.error(`Unknown command: ${command}`);
265
+ const suggestion = suggestCommand(command);
266
+ if (suggestion)
267
+ console.error(`Did you mean: fullstackgtm ${suggestion}`);
268
+ }
269
+ console.error("Run `fullstackgtm --help` for the command map, or `fullstackgtm capabilities --json` for the machine-readable contract.");
4725
270
  process.exitCode = 1;
4726
271
  }
272
+ // Public re-exports: cli.ts remains the stable import surface for the
273
+ // symbols tests and embedders use (mechanical split, no behavior change).
274
+ export { assertSecureBrokerUrl, doctorReport } from "./cli/auth.js";