fullstackgtm 0.39.0 → 0.41.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/dist/cli.js CHANGED
@@ -37,6 +37,11 @@ import { loadSeen, recordSeen } from "./acquireSeen.js";
37
37
  import { reportCounts, reportEvent } from "./runReport.js";
38
38
  import { createLinkedInProvider, discoverLinkedInProspects } from "./acquireLinkedIn.js";
39
39
  import { fitThreshold, icpFromAnswers, icpToCrustdataFilters, icpToExploriumFilters, parseIcp, scoreProspectAgainstIcp, INTERVIEW_SPEC, } from "./icp.js";
40
+ import { buildSignalsFromAts, computeWeights, createFileSignalStore, DEFAULT_SIGNALS_CONFIG, dedupeSignals, loadSignalsConfig, makeOutcome, normalizeAccountDomain, SIGNAL_BUCKETS, signalRunId, signalId, } from "./signals.js";
41
+ import { fetchAtsJobs } from "./connectors/atsBoards.js";
42
+ import { createFileJudgeStore, DEFAULT_JUDGE_PROMPT, judgeRunId, judgeSignals, } from "./judge.js";
43
+ import { authorOpeners, DEFAULT_DRAFT_PROMPT, DRAFT_CHANNELS, draft, } from "./draft.js";
44
+ import { DEFAULT_GOLDEN_SET, DEFAULT_MIN_ACCURACY, defaultJudgeFn, gradeAgainstOutcomes, gradeJudge, parseGoldenSet, } from "./judgeEval.js";
40
45
  import { apolloPullKeysForAppend, apolloPullKeysForRefresh, createApolloClient, pullApolloRecords, } from "./enrichApollo.js";
41
46
  import { computeMissedFirings, createFileScheduleRunStore, createFileScheduleStore, nextCronFiring, parseCron, renderManagedBlock, replaceManagedBlock, assertSingleLineLabel, hasControlChar, scheduleId, systemCrontabIo, tokenizeCommand, validateSchedulableArgv, } from "./schedule.js";
42
47
  import { resolveRecord } from "./resolve.js";
@@ -106,6 +111,29 @@ Usage:
106
111
  fill-blanks-only patch plan through the normal dry-run →
107
112
  approve → apply gate. refresh re-checks stale stamped fields
108
113
  and proposes updates only where the source value changed.
114
+ fullstackgtm signals fetch [--bucket job,…] [--source greenhouse,lever,ashby] [--watchlist <path|crm:seg>] [--keywords …] [--from <file.json>] [--save]
115
+ fullstackgtm signals list [--since 7d] [--bucket b] [--account d] [--unjudged]
116
+ fullstackgtm signals outcome --account <d> [--touch <id>] --result replied|meeting|bounced|no_reply
117
+ fullstackgtm signals weights [--explain]
118
+ detect fresh buying triggers (ATS hiring scrapes + staged
119
+ funding/company/social ingest), rank them, persist a local
120
+ signal ledger. READ-ONLY re: CRM — fetch NEVER emits a plan;
121
+ --save persists only the signal ledger. outcome feeds the
122
+ learned per-bucket weights.
123
+ fullstackgtm icp interview | set <answers.json> | show
124
+ fullstackgtm icp judge [--signals-from latest|<label>] [--with-history] [--prompt <path>] [--min-score 0] [--save]
125
+ fullstackgtm icp eval [--golden <path>|default] [--against-outcomes] [--min-accuracy 0.8] [--json]
126
+ build the ICP, then judge fresh signals into
127
+ send/nurture/skip (timing × fit × memory). Deterministic
128
+ baseline; LLM why-now + play with a key (gated verbatim).
129
+ eval grades the judge and exits 2 below the bar — the
130
+ probabilistic-judgment gate. Read-only; --save writes only
131
+ the local judge store.
132
+ fullstackgtm draft [--from-judge latest|<label>] [--min-score 80] [--prompt <path>] [--channel email|linkedin|task] [--save]
133
+ author one trigger-grounded opener per hot judge decision as
134
+ a governed create_task plan. First line must contain a
135
+ verbatim span of the why-now or the draft is rejected. Never
136
+ sends — --save stages a needs_approval plan for approve → apply.
109
137
  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>]
110
138
  governed generic writes: filter the snapshot
111
139
  (field=value, field!=value, field~substr, field!~substr,
@@ -438,10 +466,43 @@ const HELP = {
438
466
  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.",
439
467
  seeAlso: [],
440
468
  },
469
+ // Outbound intelligence — signals → judge → draft (the GTM brain)
470
+ signals: {
471
+ summary: "detect fresh buying triggers (ATS hiring + staged ingest), ranked",
472
+ phase: "Detect",
473
+ synopsis: [
474
+ "fullstackgtm signals fetch [--bucket job,…] [--source greenhouse,lever,ashby] [--watchlist <path|crm:seg>] [--keywords …] [--from <file.json>] [--save]",
475
+ "fullstackgtm signals list [--since 7d] [--bucket b] [--account d] [--unjudged]",
476
+ "fullstackgtm signals outcome --account <d> [--touch <id>] --result replied|meeting|bounced|no_reply",
477
+ "fullstackgtm signals weights [--explain]",
478
+ ],
479
+ 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`.",
480
+ seeAlso: ["icp", "draft"],
481
+ },
482
+ icp: {
483
+ summary: "build the ICP, then judge fresh signals into send/nurture/skip",
484
+ phase: "Detect",
485
+ synopsis: [
486
+ "fullstackgtm icp interview | set <answers.json> | show",
487
+ "fullstackgtm icp judge [--signals-from latest|<label>] [--with-history] [--prompt <path>] [--min-score 0] [--save]",
488
+ "fullstackgtm icp eval [--golden <path>|default] [--against-outcomes] [--min-accuracy 0.8] [--json]",
489
+ ],
490
+ 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.",
491
+ seeAlso: ["signals", "draft", "enrich"],
492
+ },
493
+ draft: {
494
+ summary: "author one trigger-grounded opener per hot decision as a governed plan",
495
+ phase: "Remediate",
496
+ synopsis: [
497
+ "fullstackgtm draft [--from-judge latest|<label>] [--min-score 80] [--prompt <path>] [--channel email|linkedin|task] [--save]",
498
+ ],
499
+ 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`.",
500
+ seeAlso: ["icp", "plans", "apply"],
501
+ },
441
502
  };
442
503
  // Verbs that print their own richer multi-subcommand help; runCli routes their
443
504
  // `--help` to themselves, so commandHelp() only renders these via `help <verb>`.
444
- const BESPOKE_HELP = ["call", "market", "enrich", "bulk-update", "schedule"];
505
+ const BESPOKE_HELP = ["call", "market", "enrich", "bulk-update", "schedule", "signals", "icp", "draft"];
445
506
  // Lifecycle-grouped front door. One line per verb, organized by the
446
507
  // Prevent→Detect→Remediate→Verify loop so a new user sees ~6 jobs, not 22 verbs.
447
508
  function shortUsage() {
@@ -453,6 +514,7 @@ function shortUsage() {
453
514
  ["Calls → evidence", ["call"]],
454
515
  ["Govern — the plan/apply spine", ["suggest", "plans", "apply", "audit-log", "merge"]],
455
516
  ["Market intelligence", ["market"]],
517
+ ["Outbound — signals → judge → draft", ["signals", "icp", "draft"]],
456
518
  ["Schedule — make it continuous", ["schedule"]],
457
519
  ];
458
520
  const pad = Math.max(...Object.keys(HELP).map((k) => k.length)) + 2;
@@ -1127,9 +1189,13 @@ score always needs a key (scoring is LLM work).`);
1127
1189
  * logins). Non-interactive contexts get an actionable error instead.
1128
1190
  */
1129
1191
  async function requireLlmCredential(command = "parse") {
1192
+ // Base-URL overrides are resolved here (not inside forcedToolCall) so the LLM
1193
+ // seam stays pure/injectable. Spread into LlmCallOptions at every call site
1194
+ // via `...credential`; unset env leaves the upstream defaults untouched.
1195
+ const baseUrls = resolveLlmBaseUrls();
1130
1196
  const resolved = resolveLlmCredential();
1131
1197
  if (resolved)
1132
- return resolved;
1198
+ return { ...resolved, ...baseUrls };
1133
1199
  // Scoring is inherently LLM work — there is no keyword fallback to suggest.
1134
1200
  const fallbackHint = command === "parse"
1135
1201
  ? ", or pass --deterministic for the free keyword baseline"
@@ -1151,7 +1217,21 @@ async function requireLlmCredential(command = "parse") {
1151
1217
  const now = new Date().toISOString();
1152
1218
  storeCredential(provider, { kind: "api_key", accessToken: apiKey, createdAt: now, updatedAt: now });
1153
1219
  console.error(`Stored ${provider} key (${validation.detail}). Future runs use it automatically; remove with \`fullstackgtm logout ${provider}\`.\n`);
1154
- return { provider, apiKey };
1220
+ return { provider, apiKey, ...baseUrls };
1221
+ }
1222
+ /**
1223
+ * Read optional LLM base-URL overrides from env so the package can run against
1224
+ * an Anthropic/OpenAI-compatible gateway (GLM-5.2, z.ai, Ollama) without code
1225
+ * changes. Returns only the keys that are set; an empty object when neither is,
1226
+ * so spreading into LlmCallOptions is a no-op by default.
1227
+ */
1228
+ function resolveLlmBaseUrls(env = process.env) {
1229
+ const out = {};
1230
+ if (env.ANTHROPIC_API_BASE_URL)
1231
+ out.anthropicBaseUrl = env.ANTHROPIC_API_BASE_URL;
1232
+ if (env.OPENAI_API_BASE_URL)
1233
+ out.openaiBaseUrl = env.OPENAI_API_BASE_URL;
1234
+ return out;
1155
1235
  }
1156
1236
  function renderScorecard(scorecard, title) {
1157
1237
  const bandText = scorecard.band ? ` — ${scorecard.band.label}` : "";
@@ -1988,6 +2068,385 @@ function loadIcp(args) {
1988
2068
  }
1989
2069
  return parseIcp(readFileSync(path, "utf8"));
1990
2070
  }
2071
+ /**
2072
+ * Resolve a signals config: explicit --config, else signals.config.json in cwd,
2073
+ * else the zero-config DEFAULT_SIGNALS_CONFIG (preset-first, like enrich).
2074
+ */
2075
+ function resolveSignalsConfig(args) {
2076
+ const explicit = option(args, "--config");
2077
+ if (explicit)
2078
+ return loadSignalsConfig(resolve(process.cwd(), explicit));
2079
+ const local = resolve(process.cwd(), "signals.config.json");
2080
+ if (existsSync(local))
2081
+ return loadSignalsConfig(local);
2082
+ return DEFAULT_SIGNALS_CONFIG;
2083
+ }
2084
+ /**
2085
+ * Resolve the watchlist of accounts to scan. Sources, in precedence order:
2086
+ * - a --watchlist file (JSON array of {domain, boards?} or bare domain strings),
2087
+ * - a `crm:<segment>` token (derive account domains from a CRM snapshot, scoped
2088
+ * by the segment's `field=value` where-clauses; reuses readSnapshot),
2089
+ * - config.watchlist.domains.
2090
+ * Board tokens come from the entry's own `boards`, else config.watchlist.boards.
2091
+ */
2092
+ async function resolveWatchlist(args, config) {
2093
+ const configBoards = config.watchlist.boards ?? {};
2094
+ const fromConfig = (domain) => {
2095
+ const normalized = normalizeAccountDomain(domain);
2096
+ const token = configBoards[normalized] ?? configBoards[domain];
2097
+ return token ? { domain: normalized, boards: { greenhouse: token, lever: token, ashby: token } } : { domain: normalized };
2098
+ };
2099
+ const watchlistArg = option(args, "--watchlist") ?? config.watchlist.source;
2100
+ if (watchlistArg && watchlistArg.startsWith("crm:")) {
2101
+ const segment = watchlistArg.slice("crm:".length);
2102
+ const snapshot = await readSnapshot(args);
2103
+ // Segment is an optional `;`-separated list of `field=value` equality
2104
+ // clauses over snapshot accounts (the common case; richer filtering lives in
2105
+ // bulk-update). Empty segment = every account with a domain.
2106
+ const clauses = (segment ? segment.split(";").map((s) => s.trim()).filter(Boolean) : []).map((clause) => {
2107
+ const eq = clause.indexOf("=");
2108
+ if (eq === -1)
2109
+ throw new Error(`--watchlist crm:<segment>: clause "${clause}" must be field=value.`);
2110
+ return { field: clause.slice(0, eq).trim(), value: clause.slice(eq + 1).trim() };
2111
+ });
2112
+ const accounts = (snapshot.accounts ?? []).filter((acct) => clauses.every((c) => {
2113
+ const raw = acct[c.field];
2114
+ return (raw == null ? "" : String(raw)).toLowerCase() === c.value.toLowerCase();
2115
+ }));
2116
+ const domains = accounts
2117
+ .map((acct) => normalizeAccountDomain(acct.domain ?? ""))
2118
+ .filter((d) => Boolean(d));
2119
+ return [...new Set(domains)].map(fromConfig);
2120
+ }
2121
+ if (watchlistArg) {
2122
+ const raw = JSON.parse(readFileSync(resolve(process.cwd(), watchlistArg), "utf8"));
2123
+ if (!Array.isArray(raw))
2124
+ throw new Error(`--watchlist ${watchlistArg}: expected a JSON array of domains or {domain, boards?} objects`);
2125
+ return raw.map((entry) => {
2126
+ if (typeof entry === "string")
2127
+ return fromConfig(entry);
2128
+ if (entry && typeof entry === "object" && typeof entry.domain === "string") {
2129
+ const e = entry;
2130
+ const base = fromConfig(e.domain);
2131
+ return e.boards ? { domain: base.domain, boards: { ...base.boards, ...e.boards } } : base;
2132
+ }
2133
+ throw new Error(`--watchlist ${watchlistArg}: each entry must be a domain string or {domain, boards?} object`);
2134
+ });
2135
+ }
2136
+ return (config.watchlist.domains ?? []).map(fromConfig);
2137
+ }
2138
+ /**
2139
+ * `signals` — detect fresh buying triggers (ATS job-board scrapes + staged
2140
+ * funding/company/social ingest), rank them, and persist a local signal ledger.
2141
+ * READ-ONLY re: CRM: NEVER emits a PatchPlan, never calls a recording connector.
2142
+ * `--save` persists only to the signal store, not a plan.
2143
+ */
2144
+ async function signalsCommand(args) {
2145
+ const [sub, ...rest] = args;
2146
+ // Help-before-network: catch --help/-h BEFORE any config load or fetch —
2147
+ // anywhere in argv (`signals --help` and `signals fetch --help` both land here).
2148
+ if (!sub || args.includes("--help") || args.includes("-h")) {
2149
+ console.log(`Usage:
2150
+ fullstackgtm signals fetch [--bucket job,funding,...] [--source greenhouse,lever,ashby] [--watchlist <path|crm:segment>] [--keywords "growth,revops"] [--from <file.json>] [--config <path>] [--save]
2151
+ fullstackgtm signals list [--since 7d] [--bucket b] [--account d] [--unjudged]
2152
+ fullstackgtm signals outcome --account <domain> [--touch <id>] --result replied|meeting|bounced|no_reply
2153
+ fullstackgtm signals weights [--explain]
2154
+
2155
+ Detect fresh buying triggers and rank them. \`fetch\` is read-only re: CRM — it
2156
+ NEVER emits a patch plan; --save persists only the local signal ledger (used by
2157
+ \`icp judge\`). ATS adapters are no-auth, so no credential flags exist.`);
2158
+ return;
2159
+ }
2160
+ if (sub === "fetch") {
2161
+ const config = resolveSignalsConfig(rest);
2162
+ // Merge --keywords into the job bucket; filter buckets + job sources.
2163
+ const keywordsArg = option(rest, "--keywords");
2164
+ if (keywordsArg) {
2165
+ const merged = keywordsArg.split(",").map((k) => k.trim()).filter(Boolean);
2166
+ config.buckets.job = { ...config.buckets.job, keywords: merged };
2167
+ }
2168
+ const bucketFilter = option(rest, "--bucket");
2169
+ const buckets = bucketFilter
2170
+ ? bucketFilter.split(",").map((b) => b.trim()).filter(Boolean)
2171
+ : SIGNAL_BUCKETS.filter((b) => (config.buckets[b]?.sources.length ?? 0) > 0);
2172
+ for (const b of buckets) {
2173
+ if (!SIGNAL_BUCKETS.includes(b))
2174
+ throw new Error(`Unknown bucket: ${b} (one of ${SIGNAL_BUCKETS.join(", ")})`);
2175
+ }
2176
+ const sourceFilter = option(rest, "--source");
2177
+ const allJobSources = ["greenhouse", "lever", "ashby"];
2178
+ const jobSources = sourceFilter
2179
+ ? sourceFilter.split(",").map((s) => s.trim()).filter(Boolean).filter((s) => allJobSources.includes(s))
2180
+ : config.buckets.job.sources.filter((s) => allJobSources.includes(s));
2181
+ const now = new Date();
2182
+ const store = createFileSignalStore();
2183
+ const priorSignals = await store.allSignals();
2184
+ const candidates = [];
2185
+ // Job bucket: scan ATS boards for each watchlist account x job source.
2186
+ if (buckets.includes("job")) {
2187
+ const watchlist = await resolveWatchlist(rest, config);
2188
+ for (const account of watchlist) {
2189
+ const rawJobs = [];
2190
+ for (const source of jobSources) {
2191
+ const boardToken = account.boards?.[source] ?? account.domain;
2192
+ const jobs = await fetchAtsJobs({
2193
+ source,
2194
+ boardToken,
2195
+ accountDomain: account.domain,
2196
+ keywords: config.buckets.job.keywords,
2197
+ });
2198
+ for (const job of jobs)
2199
+ rawJobs.push({ ...job, source });
2200
+ }
2201
+ const accountSignals = buildSignalsFromAts(rawJobs, { domain: account.domain }, config, {
2202
+ now,
2203
+ priorSignals,
2204
+ });
2205
+ candidates.push(...accountSignals);
2206
+ }
2207
+ }
2208
+ // Staged ingest (funding/company/social): --from <file.json>.
2209
+ const fromFile = option(rest, "--from");
2210
+ if (fromFile) {
2211
+ const ingested = readStagedSignals(resolve(process.cwd(), fromFile), buckets, now);
2212
+ candidates.push(...ingested);
2213
+ }
2214
+ const fetched = candidates.length;
2215
+ const { fresh, deduped } = dedupeSignals(candidates, priorSignals, config.dedupWindowDays, now);
2216
+ const ranked = [...fresh].sort((a, b) => b.weight - a.weight || a.accountDomain.localeCompare(b.accountDomain));
2217
+ // Ranked fresh signals to stdout; guidance to stderr.
2218
+ console.log(JSON.stringify(ranked, null, 2));
2219
+ console.error(`Fetched ${fetched} candidate signal(s); ${fresh.length} fresh, ${deduped.length} deduped ` +
2220
+ `(window ${config.dedupWindowDays}d). NO plan emitted — signals are read-only re: CRM.`);
2221
+ const save = rest.includes("--save");
2222
+ if (save) {
2223
+ const runLabel = option(rest, "--label") ?? `signals-${now.toISOString().slice(0, 10)}`;
2224
+ const startedAt = now.toISOString();
2225
+ await store.appendRun({
2226
+ id: signalRunId(runLabel),
2227
+ runLabel,
2228
+ startedAt,
2229
+ completedAt: new Date().toISOString(),
2230
+ buckets,
2231
+ counts: { fetched, new: fresh.length, deduped: deduped.length },
2232
+ signals: ranked,
2233
+ });
2234
+ console.error(`Saved signal run "${runLabel}" (${fresh.length} fresh). Next: \`fullstackgtm icp judge --save\`.`);
2235
+ }
2236
+ else {
2237
+ console.error("(not saved — re-run with --save to persist this run to the signal ledger)");
2238
+ }
2239
+ return;
2240
+ }
2241
+ if (sub === "list") {
2242
+ const store = createFileSignalStore();
2243
+ const all = await store.allSignals();
2244
+ const sinceArg = option(rest, "--since");
2245
+ const sinceMs = sinceArg ? parseSinceWindowMs(sinceArg) : undefined;
2246
+ const now = Date.now();
2247
+ const bucket = option(rest, "--bucket");
2248
+ const accountArg = option(rest, "--account");
2249
+ const account = accountArg ? normalizeAccountDomain(accountArg) : undefined;
2250
+ const unjudgedOnly = rest.includes("--unjudged");
2251
+ const filtered = all.filter((s) => {
2252
+ if (sinceMs !== undefined) {
2253
+ const seen = Date.parse(s.firstSeen);
2254
+ if (!Number.isFinite(seen) || now - seen > sinceMs)
2255
+ return false;
2256
+ }
2257
+ if (bucket && s.bucket !== bucket)
2258
+ return false;
2259
+ if (account && s.accountDomain !== account)
2260
+ return false;
2261
+ if (unjudgedOnly && s.judgedBy)
2262
+ return false;
2263
+ return true;
2264
+ });
2265
+ const ranked = filtered.sort((a, b) => b.weight - a.weight || a.accountDomain.localeCompare(b.accountDomain));
2266
+ console.log(JSON.stringify(ranked, null, 2));
2267
+ console.error(`${ranked.length} signal(s)${unjudgedOnly ? " (unjudged)" : ""}.`);
2268
+ return;
2269
+ }
2270
+ if (sub === "outcome") {
2271
+ const accountArg = option(rest, "--account");
2272
+ if (!accountArg)
2273
+ throw new Error("signals outcome: --account <domain> is required.");
2274
+ const result = option(rest, "--result");
2275
+ const valid = ["replied", "meeting", "bounced", "no_reply"];
2276
+ if (!result || !valid.includes(result)) {
2277
+ throw new Error(`signals outcome: --result must be one of ${valid.join(", ")}.`);
2278
+ }
2279
+ const touchId = option(rest, "--touch") ?? undefined;
2280
+ const outcome = makeOutcome({ accountDomain: accountArg, touchId, result: result });
2281
+ await createFileSignalStore().appendOutcome(outcome);
2282
+ console.error(`Recorded outcome "${outcome.result}" for ${outcome.accountDomain} (${outcome.id}). ` +
2283
+ `Re-run \`fullstackgtm signals weights\` to see the learned shift.`);
2284
+ return;
2285
+ }
2286
+ if (sub === "weights") {
2287
+ const config = resolveSignalsConfig(rest);
2288
+ const store = createFileSignalStore();
2289
+ const outcomes = await store.listOutcomes();
2290
+ const signalsById = new Map((await store.allSignals()).map((s) => [s.id, s]));
2291
+ const weights = computeWeights(config, outcomes, signalsById);
2292
+ console.log(JSON.stringify(weights, null, 2));
2293
+ if (rest.includes("--explain")) {
2294
+ // Per-bucket config default vs learned + booked/total over credited signals.
2295
+ const booked = new Map();
2296
+ const total = new Map();
2297
+ for (const outcome of outcomes) {
2298
+ for (const id of outcome.creditedSignals) {
2299
+ const signal = signalsById.get(id);
2300
+ if (!signal)
2301
+ continue;
2302
+ total.set(signal.bucket, (total.get(signal.bucket) ?? 0) + 1);
2303
+ if (outcome.result === "meeting")
2304
+ booked.set(signal.bucket, (booked.get(signal.bucket) ?? 0) + 1);
2305
+ }
2306
+ }
2307
+ console.error("Per-bucket weight (config default vs learned, booked/total credited):");
2308
+ for (const b of SIGNAL_BUCKETS) {
2309
+ console.error(` ${b.padEnd(8)} default ${config.buckets[b].weight.toFixed(2)} -> learned ${weights[b].toFixed(4)} ` +
2310
+ `(${booked.get(b) ?? 0}/${total.get(b) ?? 0} booked)`);
2311
+ }
2312
+ }
2313
+ return;
2314
+ }
2315
+ throw new Error(`Unknown signals subcommand: ${sub} (try: fetch, list, outcome, weights)`);
2316
+ }
2317
+ /** Parse a "7d"/"30d"/"12h" recency window into milliseconds. */
2318
+ function parseSinceWindowMs(value) {
2319
+ const match = /^(\d+)\s*([dhwm])$/i.exec(value.trim());
2320
+ if (!match)
2321
+ throw new Error(`--since: expected a window like 7d, 24h, 2w (got "${value}").`);
2322
+ const n = Number(match[1]);
2323
+ const unit = match[2].toLowerCase();
2324
+ const ms = unit === "h" ? 3600_000 : unit === "w" ? 7 * 86_400_000 : unit === "m" ? 30 * 86_400_000 : 86_400_000;
2325
+ return n * ms;
2326
+ }
2327
+ /**
2328
+ * Read staged funding/company/social signals from a JSON file (array of partial
2329
+ * signals). Each row is validated against the signal schema + the quote gate
2330
+ * (a non-empty verbatim quote is required); source = "ingest".
2331
+ */
2332
+ function readStagedSignals(path, buckets, now) {
2333
+ const raw = JSON.parse(readFileSync(path, "utf8"));
2334
+ if (!Array.isArray(raw))
2335
+ throw new Error(`--from ${path}: expected a JSON array of staged signals.`);
2336
+ const nowIso = now.toISOString();
2337
+ const out = [];
2338
+ raw.forEach((entry, index) => {
2339
+ if (!entry || typeof entry !== "object")
2340
+ throw new Error(`--from ${path}: row ${index} is not an object.`);
2341
+ const e = entry;
2342
+ const bucket = String(e.bucket ?? "");
2343
+ if (!SIGNAL_BUCKETS.includes(bucket)) {
2344
+ throw new Error(`--from ${path}: row ${index} has unknown bucket "${bucket}" (one of ${SIGNAL_BUCKETS.join(", ")}).`);
2345
+ }
2346
+ if (buckets.length && !buckets.includes(bucket))
2347
+ return; // filtered out by --bucket
2348
+ const accountDomain = normalizeAccountDomain(String(e.accountDomain ?? e.domain ?? ""));
2349
+ if (!accountDomain)
2350
+ throw new Error(`--from ${path}: row ${index} is missing accountDomain.`);
2351
+ const trigger = String(e.trigger ?? "").trim();
2352
+ if (!trigger)
2353
+ throw new Error(`--from ${path}: row ${index} is missing trigger.`);
2354
+ const quote = String(e.quote ?? "").trim();
2355
+ if (!quote)
2356
+ throw new Error(`--from ${path}: row ${index} is missing the verbatim quote (the evidence anchor).`);
2357
+ const base = { accountDomain, bucket: bucket, trigger };
2358
+ const firstSeen = typeof e.firstSeen === "string" && e.firstSeen ? e.firstSeen : nowIso;
2359
+ out.push({
2360
+ id: signalId(base),
2361
+ accountDomain,
2362
+ bucket: bucket,
2363
+ trigger,
2364
+ quote,
2365
+ sourceUrl: String(e.sourceUrl ?? ""),
2366
+ firstSeen,
2367
+ weight: typeof e.weight === "number" ? e.weight : DEFAULT_SIGNALS_CONFIG.buckets[bucket].weight,
2368
+ source: "ingest",
2369
+ judgedBy: null,
2370
+ });
2371
+ });
2372
+ return out;
2373
+ }
2374
+ /**
2375
+ * `draft` — author ONE trigger-grounded opener per hot judge decision as a
2376
+ * governed create_task plan. Structurally a proposal: never sends, never writes
2377
+ * a CRM record. With --save the plan is staged needs_approval for plans approve
2378
+ * -> apply.
2379
+ */
2380
+ async function draftCommand(args) {
2381
+ // Help-before-network: catch --help/-h BEFORE any config/credential/LLM call.
2382
+ if (args.includes("--help") || args.includes("-h")) {
2383
+ console.log(`Usage:
2384
+ fullstackgtm draft [--from-judge latest|<runLabel>] [--min-score 80] [--prompt <path>] [--channel email|linkedin|task] [--save]
2385
+
2386
+ Author one trigger-grounded opener per hot judge decision (decision=send,
2387
+ score>=min) as a governed create_task plan. The opener's first line must contain
2388
+ a verbatim span of the why-now trigger or the draft is rejected. Never sends —
2389
+ --save stages a needs_approval plan for \`plans approve\` -> \`apply\`. Without an
2390
+ LLM key it emits a clearly-labeled stub rather than fake authored copy.`);
2391
+ return;
2392
+ }
2393
+ const fromJudge = option(args, "--from-judge") ?? "latest";
2394
+ const minScore = numericOption(args, "--min-score") ?? 80;
2395
+ const promptPath = option(args, "--prompt");
2396
+ const channelArg = option(args, "--channel") ?? "task";
2397
+ if (!DRAFT_CHANNELS.includes(channelArg)) {
2398
+ throw new Error(`--channel must be one of ${DRAFT_CHANNELS.join(", ")} (got "${channelArg}").`);
2399
+ }
2400
+ const channel = channelArg;
2401
+ const save = args.includes("--save");
2402
+ // Load the JudgeRun.
2403
+ const judgeStore = createFileJudgeStore();
2404
+ const judgeRun = fromJudge === "latest" ? await judgeStore.latestRun() : await judgeStore.getRun(fromJudge);
2405
+ if (!judgeRun) {
2406
+ throw new Error(`No judge run "${fromJudge}" — run \`fullstackgtm icp judge --save\` first.`);
2407
+ }
2408
+ // Rebuild the signalsById map the openers ground in: from the referenced signal
2409
+ // run, falling back to every stored signal.
2410
+ const signalStore = createFileSignalStore();
2411
+ const signalRun = await signalStore.getRun(judgeRun.signalRunLabel);
2412
+ const signals = signalRun ? signalRun.signals : await signalStore.allSignals();
2413
+ const signalsById = new Map(signals.map((s) => [s.id, s]));
2414
+ // LLM resolution (optional — no-key path is the honestly-degraded stub).
2415
+ const cred = resolveLlmCredential();
2416
+ const model = option(args, "--model") ?? undefined;
2417
+ const llm = cred
2418
+ ? { ...cred, ...resolveLlmBaseUrls(), ...(model ? { model } : {}) }
2419
+ : undefined;
2420
+ const promptTemplate = promptPath ? readFileSync(resolve(process.cwd(), promptPath), "utf8") : DEFAULT_DRAFT_PROMPT;
2421
+ const openers = await authorOpeners({
2422
+ decisions: judgeRun.decisions,
2423
+ signalsById,
2424
+ minScore,
2425
+ promptTemplate,
2426
+ llm,
2427
+ });
2428
+ const { plan, drafts, rejected } = draft({
2429
+ decisions: judgeRun.decisions,
2430
+ signalsById,
2431
+ minScore,
2432
+ channel,
2433
+ openers,
2434
+ });
2435
+ console.log(JSON.stringify({ drafts, rejected }, null, 2));
2436
+ const stale = drafts.filter((d) => d.staleTrigger);
2437
+ console.error(`${drafts.length} opener(s) staged as create_task proposals (channel ${channel}, min score ${minScore})` +
2438
+ `${rejected.length ? `; ${rejected.length} rejected (ungrounded first line)` : ""}` +
2439
+ `${stale.length ? `; ${stale.length} flagged staleTrigger` : ""}` +
2440
+ `${llm ? "" : " — DETERMINISTIC stub (no LLM key)"}. A draft is a proposal; nothing is sent.`);
2441
+ if (save) {
2442
+ await createFilePlanStore().save(plan);
2443
+ console.error(`Saved plan ${plan.id} (status ${plan.status}). Review: \`fullstackgtm plans show ${plan.id}\`, ` +
2444
+ `then \`fullstackgtm plans approve ${plan.id} --operations all\` and \`fullstackgtm apply --plan-id ${plan.id} --provider <name>\`.`);
2445
+ }
2446
+ else {
2447
+ console.error("(not saved — re-run with --save to stage the plan for plans approve -> apply)");
2448
+ }
2449
+ }
1991
2450
  /**
1992
2451
  * `icp` — develop and inspect the Ideal Customer Profile that targets acquire.
1993
2452
  * The CLI can't run AskUserQuestion itself; `icp interview` emits the question
@@ -1996,16 +2455,22 @@ function loadIcp(args) {
1996
2455
  */
1997
2456
  async function icpCommand(args) {
1998
2457
  const [sub, ...rest] = args;
1999
- if (!sub || sub === "--help" || sub === "-h") {
2458
+ // Help-before-network: catch --help/-h anywhere in argv (so `icp --help`,
2459
+ // `icp judge --help`, `icp eval --help` all print usage before any work).
2460
+ if (!sub || args.includes("--help") || args.includes("-h")) {
2000
2461
  console.log(`Usage:
2001
2462
  fullstackgtm icp interview emit the interview spec (an agent drives it with AskUserQuestion)
2002
2463
  fullstackgtm icp set <answers.json> [--name <n>] [--out <path>] write icp.json from interview answers
2003
2464
  fullstackgtm icp show [--icp <path>] render the ICP + the discovery filters it produces
2465
+ 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)
2466
+ 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
2004
2467
 
2005
2468
  The ICP makes \`enrich acquire\` targeted, not random: it generates each
2006
2469
  provider's discovery filters (Explorium, pipe0/Crustdata) AND scores every
2007
2470
  discovered prospect for fit — only above-threshold leads become create_record
2008
- ops. Develop one by interview, then \`enrich acquire\` picks up ./icp.json.`);
2471
+ ops. Develop one by interview, then \`enrich acquire\` picks up ./icp.json.
2472
+ \`icp judge\` ranks fresh signals (from \`signals fetch --save\`) into send/nurture/skip;
2473
+ \`icp eval\` is the probabilistic-judgment gate before any apply.`);
2009
2474
  return;
2010
2475
  }
2011
2476
  if (sub === "interview") {
@@ -2039,7 +2504,143 @@ ops. Develop one by interview, then \`enrich acquire\` picks up ./icp.json.`);
2039
2504
  }, null, 2));
2040
2505
  return;
2041
2506
  }
2042
- throw new Error(`Unknown icp subcommand: ${sub} (try: interview, set, show)`);
2507
+ if (sub === "judge") {
2508
+ // The parent icpCommand already caught --help/-h before any work, so we can
2509
+ // flag-parse then act (no network until judgeSignals, and only with a key).
2510
+ const signalsFrom = option(rest, "--signals-from") ?? "latest";
2511
+ const withHistory = rest.includes("--with-history");
2512
+ const promptPath = option(rest, "--prompt");
2513
+ const minScore = numericOption(rest, "--min-score") ?? 0;
2514
+ const save = rest.includes("--save");
2515
+ // 1) Load signals from the signal store.
2516
+ const signalStore = createFileSignalStore();
2517
+ const signalRun = signalsFrom === "latest" ? await signalStore.latestRun() : await signalStore.getRun(signalsFrom);
2518
+ if (!signalRun) {
2519
+ throw new Error(`No signal run "${signalsFrom}" — run \`fullstackgtm signals fetch --save\` first.`);
2520
+ }
2521
+ const unjudged = signalRun.signals.filter((s) => !s.judgedBy);
2522
+ if (unjudged.length === 0)
2523
+ throw new Error(`Signal run "${signalRun.runLabel}" has no unjudged signals.`);
2524
+ // 2) Optional inputs: ICP (may be undefined), snapshot (only --with-history),
2525
+ // outcomes + config from the signal store / DEFAULT_SIGNALS_CONFIG.
2526
+ const icp = loadIcp(rest);
2527
+ const config = DEFAULT_SIGNALS_CONFIG;
2528
+ const outcomes = await signalStore.listOutcomes();
2529
+ const snapshot = withHistory ? await readSnapshot(rest) : undefined;
2530
+ // 3) Resolve the LLM seam ONLY if a key already exists (deterministic baseline
2531
+ // otherwise — never PROMPT here; judge must run key-free).
2532
+ const cred = resolveLlmCredential();
2533
+ const baseUrls = resolveLlmBaseUrls();
2534
+ const model = option(rest, "--model") ?? undefined;
2535
+ const llm = cred
2536
+ ? { ...cred, ...baseUrls, ...(model ? { model } : {}) }
2537
+ : undefined;
2538
+ const promptTemplate = llm
2539
+ ? promptPath
2540
+ ? readFileSync(resolve(process.cwd(), promptPath), "utf8")
2541
+ : DEFAULT_JUDGE_PROMPT
2542
+ : undefined;
2543
+ // 4) Judge.
2544
+ const decisions = (await judgeSignals({
2545
+ signals: unjudged,
2546
+ outcomes,
2547
+ config,
2548
+ icp,
2549
+ snapshot,
2550
+ withHistory,
2551
+ promptTemplate,
2552
+ llm,
2553
+ })).filter((d) => d.score >= minScore);
2554
+ // 5) Ranked decisions to stdout (JSON), guidance to stderr.
2555
+ console.log(JSON.stringify(decisions, null, 2));
2556
+ console.error(`Judged ${unjudged.length} signal(s) across ${new Set(decisions.map((d) => d.accountDomain)).size} account(s): ` +
2557
+ `${decisions.filter((d) => d.decision === "send").length} send, ` +
2558
+ `${decisions.filter((d) => d.decision === "nurture").length} nurture, ` +
2559
+ `${decisions.filter((d) => d.decision === "skip").length} skip` +
2560
+ `${llm ? "" : " (deterministic — no LLM key; run `login anthropic|openai` for why-now + play)"}.`);
2561
+ // 6) --save: persist a JudgeRun + stamp consumed signals judgedBy.
2562
+ if (save) {
2563
+ const runLabel = option(rest, "--label") ?? `judge-${new Date().toISOString().slice(0, 10)}`;
2564
+ const store = createFileJudgeStore();
2565
+ await store.appendRun({
2566
+ id: judgeRunId(runLabel),
2567
+ runLabel,
2568
+ signalRunLabel: signalRun.runLabel,
2569
+ createdAt: new Date().toISOString(),
2570
+ decisions,
2571
+ });
2572
+ const judgedIds = new Set(decisions.flatMap((d) => d.evidence));
2573
+ for (const s of signalRun.signals)
2574
+ if (judgedIds.has(s.id))
2575
+ s.judgedBy = runLabel;
2576
+ await signalStore.updateRun(signalRun);
2577
+ console.error(`Saved judge run "${runLabel}" (${decisions.length} decisions); stamped ${judgedIds.size} signals judgedBy. ` +
2578
+ `Next: \`fullstackgtm draft --from-judge ${runLabel} --save\`.`);
2579
+ }
2580
+ else {
2581
+ console.error("(not saved — re-run with --save to persist a judge run and stamp the consumed signals)");
2582
+ }
2583
+ return;
2584
+ }
2585
+ if (sub === "eval") {
2586
+ // The parent icpCommand already caught --help/-h before any work. The whole
2587
+ // command is read-only: it grades, never emits a plan, never touches a provider.
2588
+ const goldenArg = option(rest, "--golden") ?? "default";
2589
+ const againstOutcomes = rest.includes("--against-outcomes");
2590
+ const minAccuracy = numericOption(rest, "--min-accuracy") ?? DEFAULT_MIN_ACCURACY;
2591
+ const asJson = rest.includes("--json");
2592
+ if (againstOutcomes) {
2593
+ // The empirical gate: hot decisions must book strictly more than cold.
2594
+ const judgeStore = createFileJudgeStore();
2595
+ const labelArg = option(rest, "--from-judge");
2596
+ const judgeRun = labelArg ? await judgeStore.getRun(labelArg) : await judgeStore.latestRun();
2597
+ if (!judgeRun) {
2598
+ throw new Error(`No judge run ${labelArg ? `"${labelArg}"` : "to grade"} — run \`fullstackgtm icp judge --save\` first.`);
2599
+ }
2600
+ const outcomes = await createFileSignalStore().listOutcomes();
2601
+ const cal = gradeAgainstOutcomes(judgeRun.decisions, outcomes);
2602
+ if (asJson) {
2603
+ console.log(JSON.stringify(cal, null, 2));
2604
+ }
2605
+ else {
2606
+ console.log(`Outcome calibration (judge run "${judgeRun.runLabel}"):\n` +
2607
+ ` hot book rate: ${(cal.hotBookRate * 100).toFixed(1)}% over ${cal.hotCount} hot account(s)\n` +
2608
+ ` cold book rate: ${(cal.coldBookRate * 100).toFixed(1)}% over ${cal.coldCount} cold account(s)\n` +
2609
+ ` calibrated: ${cal.calibrated ? "yes (hot > cold)" : "NO (hot did not strictly beat cold)"}`);
2610
+ }
2611
+ console.error(cal.calibrated
2612
+ ? "Calibrated: hot scores book strictly more than cold — the judge earns its interrupt."
2613
+ : "NOT calibrated: hot did not strictly beat cold. Tune the rubric before scheduling apply behind this gate.");
2614
+ if (!cal.calibrated)
2615
+ process.exitCode = 2;
2616
+ return;
2617
+ }
2618
+ // The golden-set gate (default). Resolve "default" BEFORE any file read.
2619
+ const rows = goldenArg === "default"
2620
+ ? DEFAULT_GOLDEN_SET
2621
+ : parseGoldenSet(readFileSync(resolve(process.cwd(), goldenArg), "utf8"));
2622
+ const result = await gradeJudge(rows, defaultJudgeFn({ icp: loadIcp(rest), now: new Date() }));
2623
+ if (asJson) {
2624
+ console.log(JSON.stringify(result, null, 2));
2625
+ }
2626
+ else {
2627
+ const c = result.confusion;
2628
+ console.log(`Golden-set grade (${rows.length} row(s)):\n` +
2629
+ ` accuracy: ${(result.accuracy * 100).toFixed(1)}% (bar ${(minAccuracy * 100).toFixed(0)}%)\n` +
2630
+ ` precision: ${(result.precision * 100).toFixed(1)}%\n` +
2631
+ ` recall: ${(result.recall * 100).toFixed(1)}%\n` +
2632
+ ` confusion: tp=${c.truePositive} fp=${c.falsePositive} tn=${c.trueNegative} fn=${c.falseNegative}`);
2633
+ }
2634
+ if (result.accuracy < minAccuracy) {
2635
+ console.error(`Judge accuracy ${(result.accuracy * 100).toFixed(1)}% is below the ${(minAccuracy * 100).toFixed(0)}% bar — exit 2.`);
2636
+ process.exitCode = 2;
2637
+ }
2638
+ else {
2639
+ console.error(`Judge passes the golden-set gate (${(result.accuracy * 100).toFixed(1)}% >= ${(minAccuracy * 100).toFixed(0)}%).`);
2640
+ }
2641
+ return;
2642
+ }
2643
+ throw new Error(`Unknown icp subcommand: ${sub} (try: interview, set, show, judge, eval)`);
2043
2644
  }
2044
2645
  /**
2045
2646
  * Pull net-new prospects from an API acquire source into source records the
@@ -4002,6 +4603,14 @@ export async function runCli(argv) {
4002
4603
  await icpCommand(args);
4003
4604
  return;
4004
4605
  }
4606
+ if (command === "signals") {
4607
+ await signalsCommand(args);
4608
+ return;
4609
+ }
4610
+ if (command === "draft") {
4611
+ await draftCommand(args);
4612
+ return;
4613
+ }
4005
4614
  if (command === "schedule") {
4006
4615
  await scheduleCommand(args);
4007
4616
  return;