fullstackgtm 0.40.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/CHANGELOG.md +40 -0
- package/README.md +30 -1
- package/dist/cli.js +615 -6
- package/dist/connectors/atsBoards.d.ts +60 -0
- package/dist/connectors/atsBoards.js +179 -0
- package/dist/connectors/prospectSources.d.ts +7 -5
- package/dist/connectors/prospectSources.js +82 -47
- package/dist/draft.d.ts +182 -0
- package/dist/draft.js +333 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/judge.d.ts +209 -0
- package/dist/judge.js +490 -0
- package/dist/judgeEval.d.ts +141 -0
- package/dist/judgeEval.js +331 -0
- package/dist/llm.d.ts +13 -0
- package/dist/llm.js +18 -2
- package/dist/schedule.js +11 -1
- package/dist/signals.d.ts +197 -0
- package/dist/signals.js +515 -0
- package/package.json +1 -1
- package/src/cli.ts +696 -7
- package/src/connectors/atsBoards.ts +242 -0
- package/src/connectors/prospectSources.ts +94 -43
- package/src/draft.ts +463 -0
- package/src/index.ts +94 -0
- package/src/judge.ts +661 -0
- package/src/judgeEval.ts +459 -0
- package/src/llm.ts +31 -2
- package/src/schedule.ts +11 -1
- package/src/signals.ts +685 -0
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
|
-
|
|
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
|
-
|
|
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;
|