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.
Files changed (99) hide show
  1. package/CHANGELOG.md +185 -1
  2. package/README.md +19 -7
  3. package/dist/audit.d.ts +3 -1
  4. package/dist/audit.js +29 -2
  5. package/dist/cli/audit.d.ts +15 -0
  6. package/dist/cli/audit.js +392 -0
  7. package/dist/cli/auth.d.ts +80 -0
  8. package/dist/cli/auth.js +500 -0
  9. package/dist/cli/call.d.ts +1 -0
  10. package/dist/cli/call.js +373 -0
  11. package/dist/cli/capabilities.d.ts +30 -0
  12. package/dist/cli/capabilities.js +197 -0
  13. package/dist/cli/draft.d.ts +7 -0
  14. package/dist/cli/draft.js +87 -0
  15. package/dist/cli/enrich.d.ts +8 -0
  16. package/dist/cli/enrich.js +788 -0
  17. package/dist/cli/fix.d.ts +33 -0
  18. package/dist/cli/fix.js +344 -0
  19. package/dist/cli/help.d.ts +25 -0
  20. package/dist/cli/help.js +609 -0
  21. package/dist/cli/icp.d.ts +7 -0
  22. package/dist/cli/icp.js +229 -0
  23. package/dist/cli/init.d.ts +7 -0
  24. package/dist/cli/init.js +58 -0
  25. package/dist/cli/market.d.ts +1 -0
  26. package/dist/cli/market.js +391 -0
  27. package/dist/cli/plans.d.ts +5 -0
  28. package/dist/cli/plans.js +454 -0
  29. package/dist/cli/schedule.d.ts +8 -0
  30. package/dist/cli/schedule.js +479 -0
  31. package/dist/cli/shared.d.ts +70 -0
  32. package/dist/cli/shared.js +331 -0
  33. package/dist/cli/signals.d.ts +7 -0
  34. package/dist/cli/signals.js +412 -0
  35. package/dist/cli/suggest.d.ts +49 -0
  36. package/dist/cli/suggest.js +135 -0
  37. package/dist/cli/tam.d.ts +9 -0
  38. package/dist/cli/tam.js +387 -0
  39. package/dist/cli/ui.d.ts +121 -0
  40. package/dist/cli/ui.js +375 -0
  41. package/dist/cli.d.ts +1 -57
  42. package/dist/cli.js +100 -5130
  43. package/dist/connector.d.ts +15 -0
  44. package/dist/connector.js +35 -0
  45. package/dist/connectors/hubspot.d.ts +3 -1
  46. package/dist/connectors/hubspot.js +10 -3
  47. package/dist/connectors/salesforce.d.ts +3 -1
  48. package/dist/connectors/salesforce.js +12 -5
  49. package/dist/connectors/signalSources.js +7 -59
  50. package/dist/icp.d.ts +15 -11
  51. package/dist/icp.js +19 -36
  52. package/dist/judge.d.ts +2 -0
  53. package/dist/judge.js +6 -0
  54. package/dist/marketClassify.d.ts +2 -0
  55. package/dist/marketClassify.js +7 -1
  56. package/dist/mcp-bin.js +2 -2
  57. package/dist/mcp.js +259 -166
  58. package/dist/schedule.d.ts +80 -2
  59. package/dist/schedule.js +254 -1
  60. package/dist/spoolFiles.d.ts +44 -0
  61. package/dist/spoolFiles.js +114 -0
  62. package/dist/types.d.ts +11 -0
  63. package/docs/api.md +73 -7
  64. package/docs/signal-spool-format.md +13 -0
  65. package/llms.txt +15 -6
  66. package/package.json +1 -1
  67. package/skills/fullstackgtm/SKILL.md +1 -1
  68. package/src/audit.ts +27 -1
  69. package/src/cli/audit.ts +447 -0
  70. package/src/cli/auth.ts +549 -0
  71. package/src/cli/call.ts +398 -0
  72. package/src/cli/capabilities.ts +215 -0
  73. package/src/cli/draft.ts +101 -0
  74. package/src/cli/enrich.ts +885 -0
  75. package/src/cli/fix.ts +372 -0
  76. package/src/cli/help.ts +664 -0
  77. package/src/cli/icp.ts +265 -0
  78. package/src/cli/init.ts +65 -0
  79. package/src/cli/market.ts +423 -0
  80. package/src/cli/plans.ts +523 -0
  81. package/src/cli/schedule.ts +526 -0
  82. package/src/cli/shared.ts +375 -0
  83. package/src/cli/signals.ts +434 -0
  84. package/src/cli/suggest.ts +151 -0
  85. package/src/cli/tam.ts +435 -0
  86. package/src/cli/ui.ts +426 -0
  87. package/src/cli.ts +99 -5829
  88. package/src/connector.ts +46 -0
  89. package/src/connectors/hubspot.ts +14 -2
  90. package/src/connectors/salesforce.ts +18 -2
  91. package/src/connectors/signalSources.ts +7 -56
  92. package/src/icp.ts +19 -35
  93. package/src/judge.ts +7 -0
  94. package/src/marketClassify.ts +8 -1
  95. package/src/mcp-bin.ts +2 -2
  96. package/src/mcp.ts +130 -57
  97. package/src/schedule.ts +310 -3
  98. package/src/spoolFiles.ts +116 -0
  99. 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>;