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.
- package/CHANGELOG.md +378 -1
- package/README.md +37 -14
- package/dist/audit.d.ts +3 -1
- package/dist/audit.js +29 -2
- package/dist/cli/audit.d.ts +15 -0
- package/dist/cli/audit.js +392 -0
- package/dist/cli/auth.d.ts +80 -0
- package/dist/cli/auth.js +500 -0
- package/dist/cli/call.d.ts +1 -0
- package/dist/cli/call.js +373 -0
- package/dist/cli/capabilities.d.ts +30 -0
- package/dist/cli/capabilities.js +197 -0
- package/dist/cli/draft.d.ts +7 -0
- package/dist/cli/draft.js +87 -0
- package/dist/cli/enrich.d.ts +8 -0
- package/dist/cli/enrich.js +788 -0
- package/dist/cli/fix.d.ts +33 -0
- package/dist/cli/fix.js +344 -0
- package/dist/cli/help.d.ts +25 -0
- package/dist/cli/help.js +609 -0
- package/dist/cli/icp.d.ts +7 -0
- package/dist/cli/icp.js +229 -0
- package/dist/cli/init.d.ts +7 -0
- package/dist/cli/init.js +58 -0
- package/dist/cli/market.d.ts +1 -0
- package/dist/cli/market.js +391 -0
- package/dist/cli/plans.d.ts +5 -0
- package/dist/cli/plans.js +454 -0
- package/dist/cli/schedule.d.ts +8 -0
- package/dist/cli/schedule.js +479 -0
- package/dist/cli/shared.d.ts +70 -0
- package/dist/cli/shared.js +331 -0
- package/dist/cli/signals.d.ts +7 -0
- package/dist/cli/signals.js +412 -0
- package/dist/cli/suggest.d.ts +49 -0
- package/dist/cli/suggest.js +135 -0
- package/dist/cli/tam.d.ts +9 -0
- package/dist/cli/tam.js +387 -0
- package/dist/cli/ui.d.ts +121 -0
- package/dist/cli/ui.js +375 -0
- package/dist/cli.d.ts +1 -57
- package/dist/cli.js +104 -4556
- package/dist/connector.d.ts +15 -0
- package/dist/connector.js +35 -0
- package/dist/connectors/hubspot.d.ts +3 -1
- package/dist/connectors/hubspot.js +10 -3
- package/dist/connectors/outboxChannel.d.ts +64 -0
- package/dist/connectors/outboxChannel.js +170 -0
- package/dist/connectors/prospectSources.d.ts +22 -0
- package/dist/connectors/prospectSources.js +43 -0
- package/dist/connectors/salesforce.d.ts +3 -1
- package/dist/connectors/salesforce.js +12 -5
- package/dist/connectors/signalSources.d.ts +105 -0
- package/dist/connectors/signalSources.js +264 -0
- package/dist/connectors/theirstack.d.ts +84 -0
- package/dist/connectors/theirstack.js +125 -0
- package/dist/icp.d.ts +52 -4
- package/dist/icp.js +112 -35
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/init.js +3 -0
- package/dist/judge.d.ts +2 -0
- package/dist/judge.js +6 -0
- package/dist/judgeEval.d.ts +7 -0
- package/dist/judgeEval.js +8 -1
- package/dist/marketClassify.d.ts +2 -0
- package/dist/marketClassify.js +7 -1
- package/dist/mcp-bin.js +2 -2
- package/dist/mcp.js +259 -166
- package/dist/runReport.d.ts +26 -0
- package/dist/runReport.js +15 -0
- package/dist/schedule.d.ts +80 -2
- package/dist/schedule.js +272 -5
- package/dist/signals.d.ts +54 -0
- package/dist/signals.js +64 -0
- package/dist/spoolFiles.d.ts +44 -0
- package/dist/spoolFiles.js +114 -0
- package/dist/tam.d.ts +225 -0
- package/dist/tam.js +470 -0
- package/dist/types.d.ts +11 -0
- package/docs/api.md +91 -11
- package/docs/outbox-format.md +92 -0
- package/docs/recipes.md +37 -0
- package/docs/roadmap-to-1.0.md +31 -1
- package/docs/signal-spool-format.md +175 -0
- package/docs/tam.md +195 -0
- package/llms.txt +83 -11
- package/package.json +1 -1
- package/skills/fullstackgtm/SKILL.md +4 -3
- package/src/audit.ts +27 -1
- package/src/cli/audit.ts +447 -0
- package/src/cli/auth.ts +549 -0
- package/src/cli/call.ts +398 -0
- package/src/cli/capabilities.ts +215 -0
- package/src/cli/draft.ts +101 -0
- package/src/cli/enrich.ts +885 -0
- package/src/cli/fix.ts +372 -0
- package/src/cli/help.ts +664 -0
- package/src/cli/icp.ts +265 -0
- package/src/cli/init.ts +65 -0
- package/src/cli/market.ts +423 -0
- package/src/cli/plans.ts +523 -0
- package/src/cli/schedule.ts +526 -0
- package/src/cli/shared.ts +375 -0
- package/src/cli/signals.ts +434 -0
- package/src/cli/suggest.ts +151 -0
- package/src/cli/tam.ts +435 -0
- package/src/cli/ui.ts +426 -0
- package/src/cli.ts +103 -5170
- package/src/connector.ts +46 -0
- package/src/connectors/hubspot.ts +14 -2
- package/src/connectors/outboxChannel.ts +202 -0
- package/src/connectors/prospectSources.ts +57 -0
- package/src/connectors/salesforce.ts +18 -2
- package/src/connectors/signalSources.ts +314 -0
- package/src/connectors/theirstack.ts +170 -0
- package/src/icp.ts +120 -34
- package/src/index.ts +32 -0
- package/src/init.ts +3 -0
- package/src/judge.ts +7 -0
- package/src/judgeEval.ts +8 -1
- package/src/marketClassify.ts +8 -1
- package/src/mcp-bin.ts +2 -2
- package/src/mcp.ts +130 -57
- package/src/runReport.ts +39 -0
- package/src/schedule.ts +330 -7
- package/src/signals.ts +90 -0
- package/src/spoolFiles.ts +116 -0
- package/src/tam.ts +654 -0
- package/src/types.ts +12 -0
package/dist/cli.js
CHANGED
|
@@ -1,4551 +1,22 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
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() :
|
|
62
|
+
console.log(args.includes("--full") ? usage() : styledShortUsage());
|
|
4586
63
|
return;
|
|
4587
64
|
}
|
|
4588
65
|
if (command === "help") {
|
|
4589
|
-
const [topic
|
|
66
|
+
const [topic] = args;
|
|
4590
67
|
if (topic && topic !== "--full" && !topic.startsWith("-")) {
|
|
4591
|
-
|
|
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() :
|
|
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
|
-
|
|
4723
|
-
|
|
4724
|
-
|
|
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";
|