fullstackgtm 0.44.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 +185 -1
- package/README.md +19 -7
- 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 +100 -5130
- 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/salesforce.d.ts +3 -1
- package/dist/connectors/salesforce.js +12 -5
- package/dist/connectors/signalSources.js +7 -59
- package/dist/icp.d.ts +15 -11
- package/dist/icp.js +19 -36
- package/dist/judge.d.ts +2 -0
- package/dist/judge.js +6 -0
- 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/schedule.d.ts +80 -2
- package/dist/schedule.js +254 -1
- package/dist/spoolFiles.d.ts +44 -0
- package/dist/spoolFiles.js +114 -0
- package/dist/types.d.ts +11 -0
- package/docs/api.md +73 -7
- package/docs/signal-spool-format.md +13 -0
- package/llms.txt +15 -6
- package/package.json +1 -1
- package/skills/fullstackgtm/SKILL.md +1 -1
- 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 +99 -5829
- package/src/connector.ts +46 -0
- package/src/connectors/hubspot.ts +14 -2
- package/src/connectors/salesforce.ts +18 -2
- package/src/connectors/signalSources.ts +7 -56
- package/src/icp.ts +19 -35
- package/src/judge.ts +7 -0
- package/src/marketClassify.ts +8 -1
- package/src/mcp-bin.ts +2 -2
- package/src/mcp.ts +130 -57
- package/src/schedule.ts +310 -3
- package/src/spoolFiles.ts +116 -0
- package/src/types.ts +12 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
// Extracted verbatim from src/cli.ts (mechanical split, no behavior change).
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { getCredential, resolveHubspotConnection } from "../credentials.js";
|
|
5
|
+
import { buildSignalsFromAts, computeWeights, createFileSignalStore, DEFAULT_SIGNALS_CONFIG, dedupeSignals, loadSignalsConfig, makeOutcome, normalizeAccountDomain, SIGNAL_BUCKETS, signalRunId, signalsSpoolDir, stagedRowToSignal } from "../signals.js";
|
|
6
|
+
import { fetchAtsJobs } from "../connectors/atsBoards.js";
|
|
7
|
+
import { getSignalSource, listSignalSources } from "../connectors/signalSources.js";
|
|
8
|
+
import { isSpoolPath, readSpoolPath } from "../spoolFiles.js";
|
|
9
|
+
import { option, readSnapshot, repeatedOption, saveRequested } from "./shared.js";
|
|
10
|
+
import { createStatusLine } from "./ui.js";
|
|
11
|
+
import { unknownSubcommandError } from "./suggest.js";
|
|
12
|
+
/**
|
|
13
|
+
* Resolve a signals config: explicit --config, else signals.config.json in cwd,
|
|
14
|
+
* else the zero-config DEFAULT_SIGNALS_CONFIG (preset-first, like enrich).
|
|
15
|
+
*/
|
|
16
|
+
function resolveSignalsConfig(args) {
|
|
17
|
+
const explicit = option(args, "--config");
|
|
18
|
+
if (explicit)
|
|
19
|
+
return loadSignalsConfig(resolve(process.cwd(), explicit));
|
|
20
|
+
const local = resolve(process.cwd(), "signals.config.json");
|
|
21
|
+
if (existsSync(local))
|
|
22
|
+
return loadSignalsConfig(local);
|
|
23
|
+
return DEFAULT_SIGNALS_CONFIG;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Resolve the watchlist of accounts to scan. Sources, in precedence order:
|
|
27
|
+
* - a --watchlist file (JSON array of {domain, boards?} or bare domain strings),
|
|
28
|
+
* - a `crm:<segment>` token (derive account domains from a CRM snapshot, scoped
|
|
29
|
+
* by the segment's `field=value` where-clauses; reuses readSnapshot),
|
|
30
|
+
* - config.watchlist.domains.
|
|
31
|
+
* Board tokens come from the entry's own `boards`, else config.watchlist.boards.
|
|
32
|
+
*/
|
|
33
|
+
async function resolveWatchlist(args, config) {
|
|
34
|
+
const configBoards = config.watchlist.boards ?? {};
|
|
35
|
+
const fromConfig = (domain) => {
|
|
36
|
+
const normalized = normalizeAccountDomain(domain);
|
|
37
|
+
const token = configBoards[normalized] ?? configBoards[domain];
|
|
38
|
+
return token ? { domain: normalized, boards: { greenhouse: token, lever: token, ashby: token } } : { domain: normalized };
|
|
39
|
+
};
|
|
40
|
+
const watchlistArg = option(args, "--watchlist") ?? config.watchlist.source;
|
|
41
|
+
if (watchlistArg && watchlistArg.startsWith("crm:")) {
|
|
42
|
+
const segment = watchlistArg.slice("crm:".length);
|
|
43
|
+
const snapshot = await readSnapshot(args);
|
|
44
|
+
// Segment is an optional `;`-separated list of `field=value` equality
|
|
45
|
+
// clauses over snapshot accounts (the common case; richer filtering lives in
|
|
46
|
+
// bulk-update). Empty segment = every account with a domain.
|
|
47
|
+
const clauses = (segment ? segment.split(";").map((s) => s.trim()).filter(Boolean) : []).map((clause) => {
|
|
48
|
+
const eq = clause.indexOf("=");
|
|
49
|
+
if (eq === -1)
|
|
50
|
+
throw new Error(`--watchlist crm:<segment>: clause "${clause}" must be field=value.`);
|
|
51
|
+
return { field: clause.slice(0, eq).trim(), value: clause.slice(eq + 1).trim() };
|
|
52
|
+
});
|
|
53
|
+
const accounts = (snapshot.accounts ?? []).filter((acct) => clauses.every((c) => {
|
|
54
|
+
const raw = acct[c.field];
|
|
55
|
+
return (raw == null ? "" : String(raw)).toLowerCase() === c.value.toLowerCase();
|
|
56
|
+
}));
|
|
57
|
+
const domains = accounts
|
|
58
|
+
.map((acct) => normalizeAccountDomain(acct.domain ?? ""))
|
|
59
|
+
.filter((d) => Boolean(d));
|
|
60
|
+
return [...new Set(domains)].map(fromConfig);
|
|
61
|
+
}
|
|
62
|
+
if (watchlistArg) {
|
|
63
|
+
const raw = JSON.parse(readFileSync(resolve(process.cwd(), watchlistArg), "utf8"));
|
|
64
|
+
if (!Array.isArray(raw))
|
|
65
|
+
throw new Error(`--watchlist ${watchlistArg}: expected a JSON array of domains or {domain, boards?} objects`);
|
|
66
|
+
return raw.map((entry) => {
|
|
67
|
+
if (typeof entry === "string")
|
|
68
|
+
return fromConfig(entry);
|
|
69
|
+
if (entry && typeof entry === "object" && typeof entry.domain === "string") {
|
|
70
|
+
const e = entry;
|
|
71
|
+
const base = fromConfig(e.domain);
|
|
72
|
+
return e.boards ? { domain: base.domain, boards: { ...base.boards, ...e.boards } } : base;
|
|
73
|
+
}
|
|
74
|
+
throw new Error(`--watchlist ${watchlistArg}: each entry must be a domain string or {domain, boards?} object`);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return (config.watchlist.domains ?? []).map(fromConfig);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* `signals` — detect fresh buying triggers (ATS job-board scrapes + staged
|
|
81
|
+
* funding/company/social ingest), rank them, and persist a local signal ledger.
|
|
82
|
+
* READ-ONLY re: CRM: NEVER emits a PatchPlan, never calls a recording connector.
|
|
83
|
+
* `--save` persists only to the signal store, not a plan.
|
|
84
|
+
*/
|
|
85
|
+
export async function signalsCommand(args) {
|
|
86
|
+
const [sub, ...rest] = args;
|
|
87
|
+
// Help-before-network: catch --help/-h BEFORE any config load or fetch —
|
|
88
|
+
// anywhere in argv (`signals --help` and `signals fetch --help` both land here).
|
|
89
|
+
if (!sub || args.includes("--help") || args.includes("-h")) {
|
|
90
|
+
console.log(`Usage:
|
|
91
|
+
fullstackgtm signals fetch [--bucket job,funding,...] [--source greenhouse,lever,ashby] [--connector file,serpapi-news,hubspot-forms] [--connector-opt k=v ...] [--watchlist <path|crm:segment>] [--keywords "growth,revops"] [--from <file.json|spool.jsonl|spool-dir>] [--config <path>] [--save]
|
|
92
|
+
fullstackgtm signals list [--since 7d] [--bucket b] [--account d] [--unjudged]
|
|
93
|
+
fullstackgtm signals outcome --account <domain> [--touch <id>] --result replied|meeting|bounced|no_reply
|
|
94
|
+
fullstackgtm signals weights [--explain]
|
|
95
|
+
|
|
96
|
+
Detect fresh buying triggers and rank them. \`fetch\` is read-only re: CRM — it
|
|
97
|
+
NEVER emits a patch plan; --save persists only the local signal ledger (used by
|
|
98
|
+
\`icp judge\`). ATS adapters are no-auth. Source connectors (--connector) pull
|
|
99
|
+
from connected platforms: file (local JSON/JSONL spool, no auth), serpapi-news
|
|
100
|
+
(API key via env/login), hubspot-forms (reuses the HubSpot login). Secrets come
|
|
101
|
+
from the credential ladder, never argv; --connector-opt carries non-secret knobs
|
|
102
|
+
(e.g. path=… for file, bucket=company for serpapi-news).
|
|
103
|
+
|
|
104
|
+
\`--connector file\` with no path reads the conventional webhook landing zone
|
|
105
|
+
(${signalsSpoolDir()}) — every *.jsonl in it. Point a webhook receiver there
|
|
106
|
+
(one row per event); see docs/signal-spool-format.md.`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (sub === "fetch") {
|
|
110
|
+
const config = resolveSignalsConfig(rest);
|
|
111
|
+
// Merge --keywords into the job bucket; filter buckets + job sources.
|
|
112
|
+
const keywordsArg = option(rest, "--keywords");
|
|
113
|
+
if (keywordsArg) {
|
|
114
|
+
const merged = keywordsArg.split(",").map((k) => k.trim()).filter(Boolean);
|
|
115
|
+
config.buckets.job = { ...config.buckets.job, keywords: merged };
|
|
116
|
+
}
|
|
117
|
+
const bucketFilter = option(rest, "--bucket");
|
|
118
|
+
// Explicit --bucket narrows EVERYTHING (job scan, --from, connectors). With
|
|
119
|
+
// no --bucket, the JOB SCAN defaults to buckets that have configured sources
|
|
120
|
+
// (its legacy behavior), but staged/connector rows are NOT narrowed — they
|
|
121
|
+
// carry their own bucket and must flow even for buckets with no `sources`
|
|
122
|
+
// (e.g. `demand`, which `hubspot-forms` and form-spool rows produce).
|
|
123
|
+
const explicitBuckets = bucketFilter
|
|
124
|
+
? bucketFilter.split(",").map((b) => b.trim()).filter(Boolean)
|
|
125
|
+
: null;
|
|
126
|
+
const buckets = explicitBuckets ?? SIGNAL_BUCKETS.filter((b) => (config.buckets[b]?.sources.length ?? 0) > 0);
|
|
127
|
+
for (const b of buckets) {
|
|
128
|
+
if (!SIGNAL_BUCKETS.includes(b))
|
|
129
|
+
throw new Error(`Unknown bucket: ${b} (one of ${SIGNAL_BUCKETS.join(", ")})`);
|
|
130
|
+
}
|
|
131
|
+
// Bucket filter for explicitly-provided rows: the --bucket list, else none.
|
|
132
|
+
const rowBuckets = explicitBuckets ?? [];
|
|
133
|
+
const sourceFilter = option(rest, "--source");
|
|
134
|
+
const allJobSources = ["greenhouse", "lever", "ashby"];
|
|
135
|
+
const jobSources = sourceFilter
|
|
136
|
+
? sourceFilter.split(",").map((s) => s.trim()).filter(Boolean).filter((s) => allJobSources.includes(s))
|
|
137
|
+
: config.buckets.job.sources.filter((s) => allJobSources.includes(s));
|
|
138
|
+
const now = new Date();
|
|
139
|
+
const store = createFileSignalStore();
|
|
140
|
+
const priorSignals = await store.allSignals();
|
|
141
|
+
const candidates = [];
|
|
142
|
+
// Job bucket: scan ATS boards for each watchlist account x job source.
|
|
143
|
+
if (buckets.includes("job")) {
|
|
144
|
+
const watchlist = await resolveWatchlist(rest, config);
|
|
145
|
+
// Live tally on interactive terminals (inert no-op otherwise): boards
|
|
146
|
+
// are checked sequentially, so a long watchlist is otherwise silent.
|
|
147
|
+
const status = createStatusLine();
|
|
148
|
+
let hits = 0;
|
|
149
|
+
try {
|
|
150
|
+
for (const [index, account] of watchlist.entries()) {
|
|
151
|
+
status.set(`Checking ATS boards… ${index + 1}/${watchlist.length} domains · ${hits} signal${hits === 1 ? "" : "s"} · ${account.domain}`);
|
|
152
|
+
const rawJobs = [];
|
|
153
|
+
for (const source of jobSources) {
|
|
154
|
+
const boardToken = account.boards?.[source] ?? account.domain;
|
|
155
|
+
const jobs = await fetchAtsJobs({
|
|
156
|
+
source,
|
|
157
|
+
boardToken,
|
|
158
|
+
accountDomain: account.domain,
|
|
159
|
+
keywords: config.buckets.job.keywords,
|
|
160
|
+
});
|
|
161
|
+
for (const job of jobs)
|
|
162
|
+
rawJobs.push({ ...job, source });
|
|
163
|
+
}
|
|
164
|
+
const accountSignals = buildSignalsFromAts(rawJobs, { domain: account.domain }, config, {
|
|
165
|
+
now,
|
|
166
|
+
priorSignals,
|
|
167
|
+
});
|
|
168
|
+
hits += accountSignals.length;
|
|
169
|
+
candidates.push(...accountSignals);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
status.done();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// Staged ingest (any bucket): --from <file.json|spool.jsonl|spool-dir>. Narrowed only by --bucket.
|
|
177
|
+
const fromFile = option(rest, "--from");
|
|
178
|
+
if (fromFile) {
|
|
179
|
+
const ingested = readStagedSignals(resolve(process.cwd(), fromFile), rowBuckets, now);
|
|
180
|
+
candidates.push(...ingested);
|
|
181
|
+
}
|
|
182
|
+
// Source connectors (opt-in, additive): --connector id[,id] [--connector-opt k=v ...].
|
|
183
|
+
// Default behavior is unchanged when no --connector is passed.
|
|
184
|
+
const connectorArg = option(rest, "--connector");
|
|
185
|
+
if (connectorArg) {
|
|
186
|
+
const ids = connectorArg.split(",").map((s) => s.trim()).filter(Boolean);
|
|
187
|
+
const options = {};
|
|
188
|
+
for (const pair of repeatedOption(rest, "--connector-opt")) {
|
|
189
|
+
const eq = pair.indexOf("=");
|
|
190
|
+
if (eq === -1)
|
|
191
|
+
throw new Error(`--connector-opt "${pair}" must be key=value.`);
|
|
192
|
+
options[pair.slice(0, eq).trim()] = pair.slice(eq + 1);
|
|
193
|
+
}
|
|
194
|
+
// The `file` connector defaults to the conventional spool dir (the webhook
|
|
195
|
+
// landing zone) when no explicit path is given, so `--connector file`
|
|
196
|
+
// works with zero args against `<profile home>/signals/spool`.
|
|
197
|
+
if (ids.includes("file") && !options.path && !options.file) {
|
|
198
|
+
options.path = signalsSpoolDir();
|
|
199
|
+
}
|
|
200
|
+
const watchlist = await resolveWatchlist(rest, config);
|
|
201
|
+
const ctx = {
|
|
202
|
+
watchlist: watchlist.map((a) => ({ domain: a.domain })),
|
|
203
|
+
keywords: config.buckets.job.keywords ?? [],
|
|
204
|
+
now,
|
|
205
|
+
getApiKey: resolveSignalSourceKey,
|
|
206
|
+
options,
|
|
207
|
+
};
|
|
208
|
+
candidates.push(...(await runSignalConnectors(ids, ctx, rowBuckets)));
|
|
209
|
+
}
|
|
210
|
+
const fetched = candidates.length;
|
|
211
|
+
const { fresh, deduped } = dedupeSignals(candidates, priorSignals, config.dedupWindowDays, now);
|
|
212
|
+
const ranked = [...fresh].sort((a, b) => b.weight - a.weight || a.accountDomain.localeCompare(b.accountDomain));
|
|
213
|
+
// Ranked fresh signals to stdout; guidance to stderr.
|
|
214
|
+
console.log(JSON.stringify(ranked, null, 2));
|
|
215
|
+
console.error(`Fetched ${fetched} candidate signal(s); ${fresh.length} fresh, ${deduped.length} deduped ` +
|
|
216
|
+
`(window ${config.dedupWindowDays}d). NO plan emitted — signals are read-only re: CRM.`);
|
|
217
|
+
const save = saveRequested(rest);
|
|
218
|
+
if (save) {
|
|
219
|
+
const runLabel = option(rest, "--label") ?? `signals-${now.toISOString().slice(0, 10)}`;
|
|
220
|
+
const startedAt = now.toISOString();
|
|
221
|
+
await store.appendRun({
|
|
222
|
+
id: signalRunId(runLabel),
|
|
223
|
+
runLabel,
|
|
224
|
+
startedAt,
|
|
225
|
+
completedAt: new Date().toISOString(),
|
|
226
|
+
buckets,
|
|
227
|
+
counts: { fetched, new: fresh.length, deduped: deduped.length },
|
|
228
|
+
signals: ranked,
|
|
229
|
+
});
|
|
230
|
+
console.error(`Saved signal run "${runLabel}" (${fresh.length} fresh). Next: \`fullstackgtm icp judge --save\`.`);
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
console.error("(not saved — re-run with --save to persist this run to the signal ledger)");
|
|
234
|
+
}
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (sub === "list") {
|
|
238
|
+
const store = createFileSignalStore();
|
|
239
|
+
const all = await store.allSignals();
|
|
240
|
+
const sinceArg = option(rest, "--since");
|
|
241
|
+
const sinceMs = sinceArg ? parseSinceWindowMs(sinceArg) : undefined;
|
|
242
|
+
const now = Date.now();
|
|
243
|
+
const bucket = option(rest, "--bucket");
|
|
244
|
+
const accountArg = option(rest, "--account");
|
|
245
|
+
const account = accountArg ? normalizeAccountDomain(accountArg) : undefined;
|
|
246
|
+
const unjudgedOnly = rest.includes("--unjudged");
|
|
247
|
+
const filtered = all.filter((s) => {
|
|
248
|
+
if (sinceMs !== undefined) {
|
|
249
|
+
const seen = Date.parse(s.firstSeen);
|
|
250
|
+
if (!Number.isFinite(seen) || now - seen > sinceMs)
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
if (bucket && s.bucket !== bucket)
|
|
254
|
+
return false;
|
|
255
|
+
if (account && s.accountDomain !== account)
|
|
256
|
+
return false;
|
|
257
|
+
if (unjudgedOnly && s.judgedBy)
|
|
258
|
+
return false;
|
|
259
|
+
return true;
|
|
260
|
+
});
|
|
261
|
+
const ranked = filtered.sort((a, b) => b.weight - a.weight || a.accountDomain.localeCompare(b.accountDomain));
|
|
262
|
+
console.log(JSON.stringify(ranked, null, 2));
|
|
263
|
+
console.error(`${ranked.length} signal(s)${unjudgedOnly ? " (unjudged)" : ""}.`);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (sub === "outcome") {
|
|
267
|
+
const accountArg = option(rest, "--account");
|
|
268
|
+
if (!accountArg)
|
|
269
|
+
throw new Error("signals outcome: --account <domain> is required.");
|
|
270
|
+
const result = option(rest, "--result");
|
|
271
|
+
const valid = ["replied", "meeting", "bounced", "no_reply"];
|
|
272
|
+
if (!result || !valid.includes(result)) {
|
|
273
|
+
throw new Error(`signals outcome: --result must be one of ${valid.join(", ")}.`);
|
|
274
|
+
}
|
|
275
|
+
const touchId = option(rest, "--touch") ?? undefined;
|
|
276
|
+
const contactId = option(rest, "--contact") ?? undefined;
|
|
277
|
+
const outcome = makeOutcome({ accountDomain: accountArg, touchId, contactId, result: result });
|
|
278
|
+
await createFileSignalStore().appendOutcome(outcome);
|
|
279
|
+
console.error(`Recorded outcome "${outcome.result}" for ${outcome.accountDomain} (${outcome.id}). ` +
|
|
280
|
+
`Re-run \`fullstackgtm signals weights\` to see the learned shift.`);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (sub === "weights") {
|
|
284
|
+
const config = resolveSignalsConfig(rest);
|
|
285
|
+
const store = createFileSignalStore();
|
|
286
|
+
const outcomes = await store.listOutcomes();
|
|
287
|
+
const signalsById = new Map((await store.allSignals()).map((s) => [s.id, s]));
|
|
288
|
+
const weights = computeWeights(config, outcomes, signalsById);
|
|
289
|
+
console.log(JSON.stringify(weights, null, 2));
|
|
290
|
+
if (rest.includes("--explain")) {
|
|
291
|
+
// Per-bucket config default vs learned + booked/total over credited signals.
|
|
292
|
+
const booked = new Map();
|
|
293
|
+
const total = new Map();
|
|
294
|
+
for (const outcome of outcomes) {
|
|
295
|
+
for (const id of outcome.creditedSignals) {
|
|
296
|
+
const signal = signalsById.get(id);
|
|
297
|
+
if (!signal)
|
|
298
|
+
continue;
|
|
299
|
+
total.set(signal.bucket, (total.get(signal.bucket) ?? 0) + 1);
|
|
300
|
+
if (outcome.result === "meeting")
|
|
301
|
+
booked.set(signal.bucket, (booked.get(signal.bucket) ?? 0) + 1);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
console.error("Per-bucket weight (config default vs learned, booked/total credited):");
|
|
305
|
+
for (const b of SIGNAL_BUCKETS) {
|
|
306
|
+
console.error(` ${b.padEnd(8)} default ${config.buckets[b].weight.toFixed(2)} -> learned ${weights[b].toFixed(4)} ` +
|
|
307
|
+
`(${booked.get(b) ?? 0}/${total.get(b) ?? 0} booked)`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
throw unknownSubcommandError("signals", sub, ["fetch", "list", "outcome", "weights"]);
|
|
313
|
+
}
|
|
314
|
+
/** Parse a "7d"/"30d"/"12h" recency window into milliseconds. */
|
|
315
|
+
function parseSinceWindowMs(value) {
|
|
316
|
+
const match = /^(\d+)\s*([dhwm])$/i.exec(value.trim());
|
|
317
|
+
if (!match)
|
|
318
|
+
throw new Error(`--since: expected a window like 7d, 24h, 2w (got "${value}").`);
|
|
319
|
+
const n = Number(match[1]);
|
|
320
|
+
const unit = match[2].toLowerCase();
|
|
321
|
+
const ms = unit === "h" ? 3600_000 : unit === "w" ? 7 * 86_400_000 : unit === "m" ? 30 * 86_400_000 : 86_400_000;
|
|
322
|
+
return n * ms;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Read staged funding/company/social signals from a JSON file (array of partial
|
|
326
|
+
* signals). Also accepts the spool convention (docs/signal-spool-format.md):
|
|
327
|
+
* a `*.jsonl` file (one row per line) or a directory of `*.jsonl` / `*.json`
|
|
328
|
+
* spool files, via the shared reader — so a webhook landing zone can be pointed
|
|
329
|
+
* at directly with `--from`, same as `--connector file`. Each row is validated
|
|
330
|
+
* against the signal schema + the quote gate (a non-empty verbatim quote is
|
|
331
|
+
* required); source = "ingest". Plain `.json` files parse exactly as before.
|
|
332
|
+
*/
|
|
333
|
+
function readStagedSignals(path, buckets, now) {
|
|
334
|
+
const staged = [];
|
|
335
|
+
if (isSpoolPath(path)) {
|
|
336
|
+
for (const { file, index, row } of readSpoolPath(path, "--from")) {
|
|
337
|
+
staged.push({ entry: row, label: `--from ${file}: row ${index}` });
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
else {
|
|
341
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
342
|
+
if (!Array.isArray(raw))
|
|
343
|
+
throw new Error(`--from ${path}: expected a JSON array of staged signals.`);
|
|
344
|
+
raw.forEach((entry, index) => staged.push({ entry, label: `--from ${path}: row ${index}` }));
|
|
345
|
+
}
|
|
346
|
+
const out = [];
|
|
347
|
+
staged.forEach(({ entry, label }) => {
|
|
348
|
+
if (!entry || typeof entry !== "object")
|
|
349
|
+
throw new Error(`${label} is not an object.`);
|
|
350
|
+
const e = entry;
|
|
351
|
+
// Filter a --bucket-excluded row out BEFORE validation so it never errors;
|
|
352
|
+
// an unknown bucket falls through to stagedRowToSignal, which rejects it.
|
|
353
|
+
const bucket = String(e.bucket ?? "");
|
|
354
|
+
if (buckets.length && SIGNAL_BUCKETS.includes(bucket) && !buckets.includes(bucket)) {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
out.push(stagedRowToSignal(e, { now, source: "ingest", errorLabel: label }));
|
|
358
|
+
});
|
|
359
|
+
return out;
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Run the selected source connectors and return their candidate signals,
|
|
363
|
+
* funneled through the SAME staged-row gate as `--from` (so evidence-gating,
|
|
364
|
+
* dedup, and weighting are identical regardless of intake). Connectors are
|
|
365
|
+
* resilient by contract; a connector that still throws (e.g. a malformed file
|
|
366
|
+
* it was explicitly pointed at) surfaces with its id for context. `--bucket`
|
|
367
|
+
* filtering is applied after the row is validated.
|
|
368
|
+
*/
|
|
369
|
+
async function runSignalConnectors(ids, ctx, buckets) {
|
|
370
|
+
const out = [];
|
|
371
|
+
for (const id of ids) {
|
|
372
|
+
const connector = getSignalSource(id);
|
|
373
|
+
if (!connector) {
|
|
374
|
+
const known = listSignalSources().map((c) => c.id).join(", ");
|
|
375
|
+
throw new Error(`Unknown --connector "${id}" (one of: ${known}).`);
|
|
376
|
+
}
|
|
377
|
+
let rows;
|
|
378
|
+
try {
|
|
379
|
+
rows = await connector.fetch(ctx);
|
|
380
|
+
}
|
|
381
|
+
catch (error) {
|
|
382
|
+
throw new Error(`signals source "${id}": ${error instanceof Error ? error.message : String(error)}`);
|
|
383
|
+
}
|
|
384
|
+
rows.forEach((row, index) => {
|
|
385
|
+
const e = row;
|
|
386
|
+
const bucket = String(e.bucket ?? "");
|
|
387
|
+
if (buckets.length && SIGNAL_BUCKETS.includes(bucket) && !buckets.includes(bucket)) {
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
out.push(stagedRowToSignal(e, { now: ctx.now, source: id, errorLabel: `signals source "${id}": row ${index}` }));
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
return out;
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Credential-ladder lookup for source connectors. Env wins (CI / agent
|
|
397
|
+
* sandboxes never touch the filesystem), then the stored login. HubSpot reuses
|
|
398
|
+
* the existing connection resolver (private-app / OAuth-refresh / broker) so a
|
|
399
|
+
* forms source needs no separate login. Returns null when nothing is configured
|
|
400
|
+
* — the connector then treats itself as inactive (returns []), never argv.
|
|
401
|
+
*/
|
|
402
|
+
async function resolveSignalSourceKey(provider) {
|
|
403
|
+
const envKey = `FSGTM_${provider.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_API_KEY`;
|
|
404
|
+
const fromEnv = process.env[envKey] ?? process.env[`${provider.toUpperCase()}_API_KEY`];
|
|
405
|
+
if (fromEnv)
|
|
406
|
+
return fromEnv;
|
|
407
|
+
if (provider === "hubspot") {
|
|
408
|
+
const connection = await resolveHubspotConnection();
|
|
409
|
+
return connection?.accessToken ?? null;
|
|
410
|
+
}
|
|
411
|
+
return getCredential(provider)?.accessToken ?? null;
|
|
412
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export declare function levenshtein(a: string, b: string): number;
|
|
2
|
+
/** Nearest candidate within maxDistance edits; ties break alphabetically. */
|
|
3
|
+
export declare function nearest(input: string, candidates: Iterable<string>, maxDistance: number): string | null;
|
|
4
|
+
/**
|
|
5
|
+
* Every `--flag` documented anywhere in help.ts: the full usage() reference
|
|
6
|
+
* plus each HELP entry's synopsis lines and options table. Cached — help text
|
|
7
|
+
* is static per process.
|
|
8
|
+
*/
|
|
9
|
+
export declare function documentedFlags(): Set<string>;
|
|
10
|
+
export type FlagTypo = {
|
|
11
|
+
given: string;
|
|
12
|
+
/** Human-readable correction, e.g. `--json` or `--rules missing-deal-owner`. */
|
|
13
|
+
suggestion: string;
|
|
14
|
+
/** The argv tokens that replace `given` in the corrected command. */
|
|
15
|
+
replacement: string[];
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* First argv token that is (a) flag-shaped, (b) documented nowhere in
|
|
19
|
+
* help.ts, and (c) either within one edit of a documented flag after
|
|
20
|
+
* lowercasing and `_`→`-` normalization (`--jsn`, `--dry_run`, `--jason`),
|
|
21
|
+
* or a GNU-style `--flag=value` spelling of a documented flag (this CLI
|
|
22
|
+
* takes space-separated values, so `--rules=x` was silently dropped too).
|
|
23
|
+
*
|
|
24
|
+
* Near-miss-only on purpose: verbs parse their own flags, so an unknown flag
|
|
25
|
+
* with no near-miss may be a legitimately undocumented one (audited
|
|
26
|
+
* 2026-07-01: all 12 parsed-but-undocumented flags sit ≥ 2 edits from every
|
|
27
|
+
* documented flag, so distance ≤ 1 cannot collide with a real flag today).
|
|
28
|
+
* Without this check a typo'd flag was silently dropped — `audit --demo
|
|
29
|
+
* --jsn` exited 0 and printed markdown where the agent asked for JSON.
|
|
30
|
+
*/
|
|
31
|
+
export declare function detectFlagTypo(args: string[]): FlagTypo | null;
|
|
32
|
+
/** Nearest known command, derived from the same HELP table. */
|
|
33
|
+
export declare function suggestCommand(command: string): string | null;
|
|
34
|
+
/**
|
|
35
|
+
* Shared unknown-subcommand error for the bespoke multi-verb commands
|
|
36
|
+
* (`enrich apend` → "did you mean append?"). Keeps every dispatcher's
|
|
37
|
+
* message in one shape: the near-miss first, then the full list.
|
|
38
|
+
*/
|
|
39
|
+
export declare function unknownSubcommandError(parent: string, given: string, subcommands: string[]): Error;
|
|
40
|
+
/** The exact corrected invocation to print — copy-pasteable. */
|
|
41
|
+
export declare function correctedCommand(command: string, args: string[], typo: FlagTypo): string;
|
|
42
|
+
export declare function unknownFlagEnvelope(command: string, args: string[], typo: FlagTypo): {
|
|
43
|
+
ok: false;
|
|
44
|
+
error: {
|
|
45
|
+
code: "UNKNOWN_FLAG";
|
|
46
|
+
message: string;
|
|
47
|
+
hints: string[];
|
|
48
|
+
};
|
|
49
|
+
};
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Intent-inference helpers: nearest-match suggestions for unknown flags and
|
|
2
|
+
// commands.
|
|
3
|
+
//
|
|
4
|
+
// DESIGN RULE — same as capabilities.ts: every suggestion list is DERIVED at
|
|
5
|
+
// runtime from the HELP table in help.ts (the CLI's single source of truth),
|
|
6
|
+
// never hand-maintained. The documented-flag set is the union of every flag
|
|
7
|
+
// token in usage() and in each HELP entry's synopsis/options, so it stays in
|
|
8
|
+
// sync with the docs by construction.
|
|
9
|
+
//
|
|
10
|
+
// SAFETY RULE — suggestions never auto-execute. A near-miss flag is an error
|
|
11
|
+
// (exit 1) that prints the exact corrected command for the agent to run
|
|
12
|
+
// itself; this holds uniformly for read and write verbs, so a typo can never
|
|
13
|
+
// silently change what a write-shaped invocation stages.
|
|
14
|
+
import { HELP, usage } from "./help.js";
|
|
15
|
+
export function levenshtein(a, b) {
|
|
16
|
+
const dp = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0));
|
|
17
|
+
for (let i = 0; i <= a.length; i += 1)
|
|
18
|
+
dp[i][0] = i;
|
|
19
|
+
for (let j = 0; j <= b.length; j += 1)
|
|
20
|
+
dp[0][j] = j;
|
|
21
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
22
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
23
|
+
dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return dp[a.length][b.length];
|
|
27
|
+
}
|
|
28
|
+
/** Nearest candidate within maxDistance edits; ties break alphabetically. */
|
|
29
|
+
export function nearest(input, candidates, maxDistance) {
|
|
30
|
+
let best = null;
|
|
31
|
+
let bestDistance = maxDistance + 1;
|
|
32
|
+
for (const candidate of [...candidates].sort()) {
|
|
33
|
+
const distance = levenshtein(input, candidate);
|
|
34
|
+
if (distance < bestDistance) {
|
|
35
|
+
best = candidate;
|
|
36
|
+
bestDistance = distance;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return bestDistance <= maxDistance ? best : null;
|
|
40
|
+
}
|
|
41
|
+
let cachedDocumentedFlags = null;
|
|
42
|
+
/**
|
|
43
|
+
* Every `--flag` documented anywhere in help.ts: the full usage() reference
|
|
44
|
+
* plus each HELP entry's synopsis lines and options table. Cached — help text
|
|
45
|
+
* is static per process.
|
|
46
|
+
*/
|
|
47
|
+
export function documentedFlags() {
|
|
48
|
+
if (cachedDocumentedFlags)
|
|
49
|
+
return cachedDocumentedFlags;
|
|
50
|
+
const flags = new Set(usage().match(/--[a-z][a-z0-9-]*/g) ?? []);
|
|
51
|
+
for (const entry of Object.values(HELP)) {
|
|
52
|
+
const text = [...entry.synopsis, ...(entry.options ?? []).map(([flag]) => flag)].join(" ");
|
|
53
|
+
for (const flag of text.match(/--[a-z][a-z0-9-]*/g) ?? [])
|
|
54
|
+
flags.add(flag);
|
|
55
|
+
}
|
|
56
|
+
cachedDocumentedFlags = flags;
|
|
57
|
+
return flags;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* First argv token that is (a) flag-shaped, (b) documented nowhere in
|
|
61
|
+
* help.ts, and (c) either within one edit of a documented flag after
|
|
62
|
+
* lowercasing and `_`→`-` normalization (`--jsn`, `--dry_run`, `--jason`),
|
|
63
|
+
* or a GNU-style `--flag=value` spelling of a documented flag (this CLI
|
|
64
|
+
* takes space-separated values, so `--rules=x` was silently dropped too).
|
|
65
|
+
*
|
|
66
|
+
* Near-miss-only on purpose: verbs parse their own flags, so an unknown flag
|
|
67
|
+
* with no near-miss may be a legitimately undocumented one (audited
|
|
68
|
+
* 2026-07-01: all 12 parsed-but-undocumented flags sit ≥ 2 edits from every
|
|
69
|
+
* documented flag, so distance ≤ 1 cannot collide with a real flag today).
|
|
70
|
+
* Without this check a typo'd flag was silently dropped — `audit --demo
|
|
71
|
+
* --jsn` exited 0 and printed markdown where the agent asked for JSON.
|
|
72
|
+
*/
|
|
73
|
+
export function detectFlagTypo(args) {
|
|
74
|
+
const known = documentedFlags();
|
|
75
|
+
for (const token of args) {
|
|
76
|
+
if (!token.startsWith("--") || token === "--")
|
|
77
|
+
continue;
|
|
78
|
+
if (known.has(token))
|
|
79
|
+
continue;
|
|
80
|
+
const equalsIndex = token.indexOf("=");
|
|
81
|
+
if (equalsIndex !== -1) {
|
|
82
|
+
// `--flag=value` → suggest the space-separated form this CLI parses.
|
|
83
|
+
const base = token.slice(0, equalsIndex).toLowerCase().replace(/_/g, "-");
|
|
84
|
+
const value = token.slice(equalsIndex + 1);
|
|
85
|
+
const resolved = known.has(base) ? base : nearest(base, known, 1);
|
|
86
|
+
if (resolved) {
|
|
87
|
+
const replacement = value === "" ? [resolved] : [resolved, value];
|
|
88
|
+
return { given: token, suggestion: replacement.join(" "), replacement };
|
|
89
|
+
}
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const normalized = token.toLowerCase().replace(/_/g, "-");
|
|
93
|
+
const suggestion = known.has(normalized) ? normalized : nearest(normalized, known, 1);
|
|
94
|
+
if (suggestion)
|
|
95
|
+
return { given: token, suggestion, replacement: [suggestion] };
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
/** Nearest known command, derived from the same HELP table. */
|
|
100
|
+
export function suggestCommand(command) {
|
|
101
|
+
if (!command)
|
|
102
|
+
return null;
|
|
103
|
+
return nearest(command.toLowerCase(), Object.keys(HELP), 3);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Shared unknown-subcommand error for the bespoke multi-verb commands
|
|
107
|
+
* (`enrich apend` → "did you mean append?"). Keeps every dispatcher's
|
|
108
|
+
* message in one shape: the near-miss first, then the full list.
|
|
109
|
+
*/
|
|
110
|
+
export function unknownSubcommandError(parent, given, subcommands) {
|
|
111
|
+
const suggestion = nearest(given.toLowerCase(), subcommands, 2);
|
|
112
|
+
return new Error(`Unknown ${parent} subcommand: ${given}.` +
|
|
113
|
+
(suggestion ? ` Did you mean: fullstackgtm ${parent} ${suggestion}?` : "") +
|
|
114
|
+
` Subcommands: ${subcommands.join(", ")}.`);
|
|
115
|
+
}
|
|
116
|
+
/** The exact corrected invocation to print — copy-pasteable. */
|
|
117
|
+
export function correctedCommand(command, args, typo) {
|
|
118
|
+
const fixed = args.flatMap((arg) => (arg === typo.given ? typo.replacement : [arg]));
|
|
119
|
+
return ["fullstackgtm", command, ...fixed]
|
|
120
|
+
.map((part) => (/[\s"'$]/.test(part) ? JSON.stringify(part) : part))
|
|
121
|
+
.join(" ");
|
|
122
|
+
}
|
|
123
|
+
export function unknownFlagEnvelope(command, args, typo) {
|
|
124
|
+
return {
|
|
125
|
+
ok: false,
|
|
126
|
+
error: {
|
|
127
|
+
code: "UNKNOWN_FLAG",
|
|
128
|
+
message: `Unknown flag: ${typo.given}`,
|
|
129
|
+
hints: [
|
|
130
|
+
`Did you mean: ${typo.suggestion}`,
|
|
131
|
+
`Try: ${correctedCommand(command, args, typo)}`,
|
|
132
|
+
],
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Best-effort enrich-config load for apply-time acquire-budget enforcement. */
|
|
2
|
+
/** Provider API key: env override first, then the credential store (`login`). */
|
|
3
|
+
export declare function providerKey(provider: "explorium" | "pipe0" | "heyreach" | "theirstack"): string;
|
|
4
|
+
/**
|
|
5
|
+
* `tam` — Total Addressable Market mapping. Estimate a defensible universe from
|
|
6
|
+
* the ICP, then iteratively populate it via scheduled governed acquire runs and
|
|
7
|
+
* track coverage over time. Subcommands: estimate, status, report, populate.
|
|
8
|
+
*/
|
|
9
|
+
export declare function tamCommand(args: string[]): Promise<void>;
|