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.
@@ -0,0 +1,459 @@
1
+ import {
2
+ deterministicDecision,
3
+ scoreAccount,
4
+ type JudgeDecision,
5
+ type JudgeDecisionKind,
6
+ } from "./judge.ts";
7
+ import {
8
+ DEFAULT_SIGNALS_CONFIG,
9
+ computeWeights,
10
+ normalizeAccountDomain,
11
+ type Signal,
12
+ type SignalOutcome,
13
+ type SignalsConfig,
14
+ } from "./signals.ts";
15
+ import type { Icp } from "./icp.ts";
16
+
17
+ /**
18
+ * The self-grading gate (`icp eval`). The judge (`icp judge`) makes a
19
+ * PROBABILISTIC claim — "this account is hot." Every other layer's correctness
20
+ * is a code review (audit rules are deterministic); the judge's is not. This
21
+ * module is the part every "AI SDR" tool skips: proving the judge is calibrated
22
+ * BEFORE it sends.
23
+ *
24
+ * Two gates, both read-only and free to run offline:
25
+ *
26
+ * 1. `gradeJudge(rows, judgeFn)` — run the judge over a labeled golden set
27
+ * ({signals, history, expectedDecision: send|skip}) and score agreement
28
+ * (accuracy + precision/recall on the binary send-vs-skip call, with a
29
+ * confusion matrix). `nurture` collapses to the non-send class: the live
30
+ * question the gate guards is "do we interrupt this account cold?" — and a
31
+ * nurture is a not-now, i.e. not a send. Ships a `DEFAULT_GOLDEN_SET` inline
32
+ * so `icp eval --golden default` runs with no file and no key.
33
+ *
34
+ * 2. `gradeAgainstOutcomes(decisions, outcomes)` — the EMPIRICAL gate: once real
35
+ * `signals outcome` results exist, do accounts the judge scored hot (>=80)
36
+ * book more than the ones it scored cold? `calibrated` is true iff
37
+ * hotBookRate > coldBookRate. "If it cannot prove hot beats cold, it does not
38
+ * send."
39
+ *
40
+ * The CLI wires both as exit-2 gates (the audit/`diff` convention) so they
41
+ * compose in front of any `apply`. This file is pure grading — no store, no
42
+ * network, no `process.exitCode` (the CLI owns the exit code).
43
+ */
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Types
47
+
48
+ /** Account CRM memory for a golden row — `touched` caps a hot account to not-now. */
49
+ export type GoldenHistory = {
50
+ /** True if the account was touched within the judge's memory window. */
51
+ recentlyTouched?: boolean;
52
+ };
53
+
54
+ /**
55
+ * One labeled example: the signals that fired on an account and the decision a
56
+ * human says the judge SHOULD reach (the binary send-vs-not the gate guards).
57
+ */
58
+ export type GoldenRow = {
59
+ /** Optional human label, for readable failure output. */
60
+ label?: string;
61
+ signals: Signal[];
62
+ history?: GoldenHistory;
63
+ /** The ground-truth call: do we interrupt this account cold, or not? */
64
+ expectedDecision: "send" | "skip";
65
+ };
66
+
67
+ /**
68
+ * A judge under test: given a row's signals + history, return the judge's call.
69
+ * Accepts a `JudgeDecision`, a bare `JudgeDecisionKind`, or a `Promise` of
70
+ * either — so the real (async, LLM-capable) judge AND a one-line deterministic
71
+ * stub both fit. `nurture` is treated as not-send when graded.
72
+ */
73
+ export type EvalJudgeFn = (
74
+ row: GoldenRow,
75
+ ) =>
76
+ | JudgeDecision
77
+ | JudgeDecisionKind
78
+ | Promise<JudgeDecision | JudgeDecisionKind>;
79
+
80
+ /** 2x2 confusion matrix on the binary send-vs-skip call. */
81
+ export type Confusion = {
82
+ /** Predicted send, expected send. */
83
+ truePositive: number;
84
+ /** Predicted send, expected skip (a cold mail off a bad target). */
85
+ falsePositive: number;
86
+ /** Predicted skip, expected send (a missed hot account). */
87
+ falseNegative: number;
88
+ /** Predicted skip, expected skip (correctly held). */
89
+ trueNegative: number;
90
+ };
91
+
92
+ export type GradeResult = {
93
+ /** (tp + tn) / total — overall agreement. */
94
+ accuracy: number;
95
+ /** tp / (tp + fp) — of the accounts we'd mail, how many should we? 1 when none predicted send. */
96
+ precision: number;
97
+ /** tp / (tp + fn) — of the accounts we should mail, how many did we catch? 1 when none expected send. */
98
+ recall: number;
99
+ confusion: Confusion;
100
+ /** Rows graded (== rows.length). */
101
+ total: number;
102
+ };
103
+
104
+ export type OutcomeCalibration = {
105
+ /** Booked-meeting rate among decisions scored hot (>=80). */
106
+ hotBookRate: number;
107
+ /** Booked-meeting rate among decisions scored cold (<80). */
108
+ coldBookRate: number;
109
+ /** True iff hotBookRate > coldBookRate — the "hot beats cold" claim holds. */
110
+ calibrated: boolean;
111
+ /** Decisions in each bucket that had a recorded outcome (the denominators). */
112
+ hotCount: number;
113
+ coldCount: number;
114
+ };
115
+
116
+ /** Hot threshold for the empirical gate — mirrors the judge's strong band (>=80). */
117
+ export const HOT_SCORE = 80;
118
+
119
+ /** Default golden-set pass bar (`--min-accuracy`). */
120
+ export const DEFAULT_MIN_ACCURACY = 0.8;
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Grading a judge against a golden set
124
+
125
+ /** Collapse the three-way decision to the binary send-vs-not the gate guards. */
126
+ function isSend(decision: JudgeDecisionKind): boolean {
127
+ return decision === "send";
128
+ }
129
+
130
+ function decisionKindOf(result: JudgeDecision | JudgeDecisionKind): JudgeDecisionKind {
131
+ return typeof result === "string" ? result : result.decision;
132
+ }
133
+
134
+ /**
135
+ * Run `judgeFn` over each golden row and score agreement on the binary
136
+ * send-vs-skip call (nurture counts as not-send). Deterministic given a
137
+ * deterministic `judgeFn`; the only async is the judge itself (so an LLM judge
138
+ * can be graded with the same harness). Empty `rows` → perfect-but-vacuous
139
+ * scores (accuracy 1) — the CLI guards against an empty set separately.
140
+ */
141
+ export async function gradeJudge(
142
+ rows: GoldenRow[],
143
+ judgeFn: EvalJudgeFn,
144
+ ): Promise<GradeResult> {
145
+ const confusion: Confusion = {
146
+ truePositive: 0,
147
+ falsePositive: 0,
148
+ falseNegative: 0,
149
+ trueNegative: 0,
150
+ };
151
+ for (const row of rows) {
152
+ const predicted = isSend(decisionKindOf(await judgeFn(row)));
153
+ const expected = row.expectedDecision === "send";
154
+ if (predicted && expected) confusion.truePositive += 1;
155
+ else if (predicted && !expected) confusion.falsePositive += 1;
156
+ else if (!predicted && expected) confusion.falseNegative += 1;
157
+ else confusion.trueNegative += 1;
158
+ }
159
+ const total = rows.length;
160
+ const correct = confusion.truePositive + confusion.trueNegative;
161
+ const predictedSend = confusion.truePositive + confusion.falsePositive;
162
+ const actualSend = confusion.truePositive + confusion.falseNegative;
163
+ return {
164
+ accuracy: total === 0 ? 1 : round4(correct / total),
165
+ // A judge that never says send is vacuously precise; one with no positives
166
+ // to find has perfect recall — the standard 0-denominator conventions.
167
+ precision: predictedSend === 0 ? 1 : round4(confusion.truePositive / predictedSend),
168
+ recall: actualSend === 0 ? 1 : round4(confusion.truePositive / actualSend),
169
+ confusion,
170
+ total,
171
+ };
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // The default deterministic judge fn (offline, free, no LLM)
176
+
177
+ /**
178
+ * The deterministic judge wrapped as an `EvalJudgeFn`: group a row's signals by
179
+ * account, score the (single, by construction) account on timing × fit × memory
180
+ * via `scoreAccount`, and return the deterministic decision. This is the SAME
181
+ * scorer `icp judge` runs key-free, so `gradeJudge(rows, defaultJudgeFn())`
182
+ * grades the real baseline — not a re-implementation. A row with no signals is
183
+ * a skip (nothing changed → nothing to reach out about).
184
+ */
185
+ export function defaultJudgeFn(opts: { config?: SignalsConfig; icp?: Icp; now?: Date } = {}): EvalJudgeFn {
186
+ const config = opts.config ?? DEFAULT_SIGNALS_CONFIG;
187
+ return (row: GoldenRow): JudgeDecisionKind => {
188
+ if (row.signals.length === 0) return "skip";
189
+ // A golden row is one account; take the first signal's domain as the anchor.
190
+ const domain = normalizeAccountDomain(row.signals[0].accountDomain);
191
+ const signals = row.signals.filter((s) => normalizeAccountDomain(s.accountDomain) === domain);
192
+ const weights = computeWeights(config, [], new Map(signals.map((s) => [s.id, s])));
193
+ const score = scoreAccount({
194
+ accountDomain: domain,
195
+ signals,
196
+ weights,
197
+ icp: opts.icp,
198
+ recentlyTouched: row.history?.recentlyTouched ?? false,
199
+ now: opts.now,
200
+ });
201
+ return deterministicDecision(score).decision;
202
+ };
203
+ }
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // The default golden set (ships inline so `icp eval --golden default` is free)
207
+
208
+ const GOLD_NOW_ISO = "2026-06-23T12:00:00.000Z";
209
+
210
+ function goldSignal(over: {
211
+ accountDomain: string;
212
+ bucket: Signal["bucket"];
213
+ trigger: string;
214
+ quote: string;
215
+ reposted?: boolean;
216
+ firstSeen?: string;
217
+ }): Signal {
218
+ const domain = normalizeAccountDomain(over.accountDomain);
219
+ return {
220
+ id: `gold_${domain}_${over.bucket}_${over.trigger}`.replace(/[^\w]+/g, "_").toLowerCase(),
221
+ accountDomain: domain,
222
+ bucket: over.bucket,
223
+ trigger: over.trigger,
224
+ quote: over.quote,
225
+ sourceUrl: "https://example.com/golden",
226
+ firstSeen: over.firstSeen ?? GOLD_NOW_ISO,
227
+ weight: 1,
228
+ source: "ingest",
229
+ judgedBy: null,
230
+ ...(over.reposted ? { reposted: true } : {}),
231
+ };
232
+ }
233
+
234
+ /**
235
+ * A starter labeled set covering the four rubric corners, so `icp eval --golden
236
+ * default` runs offline and the deterministic baseline passes it (>= the default
237
+ * min-accuracy). Each row is a single account.
238
+ *
239
+ * - SEND: a fresh clustered account (funding + reposted live req) — strong band.
240
+ * - SEND: a single high-weight demand/funding signal that still clears the bar.
241
+ * - SKIP: a lone low-intent social blip — weak band, first-class no.
242
+ * - SKIP: a single company-news mention with no in-persona trigger — weak.
243
+ * - SKIP: a hot account TOUCHED within the memory window — don't pile on.
244
+ */
245
+ export const DEFAULT_GOLDEN_SET: GoldenRow[] = [
246
+ {
247
+ label: "clustered funding + reposted role -> send",
248
+ expectedDecision: "send",
249
+ signals: [
250
+ goldSignal({
251
+ accountDomain: "apexnorth.agency",
252
+ bucket: "funding",
253
+ trigger: "raised Series B",
254
+ quote: "ApexNorth raised a $40M Series B led by Acme Ventures this week.",
255
+ }),
256
+ goldSignal({
257
+ accountDomain: "apexnorth.agency",
258
+ bucket: "job",
259
+ trigger: "reposted: Head of Growth",
260
+ quote: "Head of Growth reopened — own demand gen and marketing ops for the next stage of growth.",
261
+ reposted: true,
262
+ }),
263
+ ],
264
+ },
265
+ {
266
+ label: "fresh funding + live req -> send",
267
+ expectedDecision: "send",
268
+ signals: [
269
+ goldSignal({
270
+ accountDomain: "brightloop.io",
271
+ bucket: "funding",
272
+ trigger: "raised Series A",
273
+ quote: "Brightloop closed a $12M Series A to expand its go-to-market team this quarter.",
274
+ }),
275
+ goldSignal({
276
+ accountDomain: "brightloop.io",
277
+ bucket: "job",
278
+ trigger: "hiring: VP Marketing",
279
+ quote: "VP Marketing — build the demand engine and own pipeline targets from scratch.",
280
+ }),
281
+ ],
282
+ },
283
+ {
284
+ label: "lone social blip -> skip",
285
+ expectedDecision: "skip",
286
+ signals: [
287
+ goldSignal({
288
+ accountDomain: "quietco.io",
289
+ bucket: "social",
290
+ trigger: "liked a post",
291
+ quote: "Their VP of Sales liked a post about pipeline hygiene last Tuesday.",
292
+ }),
293
+ ],
294
+ },
295
+ {
296
+ label: "single low-intent company mention -> skip",
297
+ expectedDecision: "skip",
298
+ signals: [
299
+ goldSignal({
300
+ accountDomain: "stillwater.co",
301
+ bucket: "social",
302
+ trigger: "shared an article",
303
+ quote: "Stillwater's account exec shared an industry article on LinkedIn over the weekend.",
304
+ }),
305
+ ],
306
+ },
307
+ {
308
+ label: "hot but touched within 7 days -> skip (don't pile on)",
309
+ expectedDecision: "skip",
310
+ history: { recentlyTouched: true },
311
+ signals: [
312
+ goldSignal({
313
+ accountDomain: "veridian.com",
314
+ bucket: "social",
315
+ trigger: "liked a post",
316
+ quote: "A Veridian director liked a post about outbound sequencing yesterday afternoon.",
317
+ }),
318
+ ],
319
+ },
320
+ ];
321
+
322
+ /**
323
+ * Parse a golden set from JSON or JSONL (one row per line). Validates each row
324
+ * up front and names the offending entry (the enrich/signals parse idiom). The
325
+ * literal "default" resolves to `DEFAULT_GOLDEN_SET` (handled by the CLI before
326
+ * reading a file; this parses an actual file's contents).
327
+ */
328
+ export function parseGoldenSet(raw: string): GoldenRow[] {
329
+ const trimmed = raw.trim();
330
+ if (!trimmed) throw new Error("golden set: empty file (expected a JSON array or JSONL of rows)");
331
+ let candidates: unknown[];
332
+ if (trimmed.startsWith("[")) {
333
+ let parsed: unknown;
334
+ try {
335
+ parsed = JSON.parse(trimmed);
336
+ } catch (error) {
337
+ throw new Error(`golden set: not valid JSON (${error instanceof Error ? error.message : String(error)})`);
338
+ }
339
+ if (!Array.isArray(parsed)) throw new Error("golden set: expected a JSON array of rows");
340
+ candidates = parsed;
341
+ } else {
342
+ // JSONL: one row object per non-empty line.
343
+ candidates = [];
344
+ const lines = trimmed.split("\n");
345
+ for (let i = 0; i < lines.length; i += 1) {
346
+ const line = lines[i].trim();
347
+ if (!line) continue;
348
+ try {
349
+ candidates.push(JSON.parse(line));
350
+ } catch (error) {
351
+ throw new Error(
352
+ `golden set: line ${i + 1} is not valid JSON (${error instanceof Error ? error.message : String(error)})`,
353
+ );
354
+ }
355
+ }
356
+ }
357
+ return candidates.map((row, i) => validateGoldenRow(row, i));
358
+ }
359
+
360
+ function validateGoldenRow(row: unknown, index: number): GoldenRow {
361
+ const where = `golden set: row ${index}`;
362
+ if (!row || typeof row !== "object" || Array.isArray(row)) {
363
+ throw new Error(`${where} must be an object with signals + expectedDecision`);
364
+ }
365
+ const r = row as Partial<GoldenRow> & { signals?: unknown; expectedDecision?: unknown };
366
+ if (r.expectedDecision !== "send" && r.expectedDecision !== "skip") {
367
+ throw new Error(`${where}: expectedDecision must be "send" or "skip"`);
368
+ }
369
+ if (!Array.isArray(r.signals)) {
370
+ throw new Error(`${where}: signals must be an array of Signal objects`);
371
+ }
372
+ for (let s = 0; s < r.signals.length; s += 1) {
373
+ const sig = r.signals[s] as Partial<Signal>;
374
+ if (!sig || typeof sig !== "object") throw new Error(`${where} signal ${s} must be an object`);
375
+ if (typeof sig.accountDomain !== "string" || !sig.accountDomain) {
376
+ throw new Error(`${where} signal ${s}: accountDomain must be a non-empty string`);
377
+ }
378
+ if (typeof sig.bucket !== "string") throw new Error(`${where} signal ${s}: bucket must be a string`);
379
+ if (typeof sig.quote !== "string") throw new Error(`${where} signal ${s}: quote must be a string`);
380
+ }
381
+ const history =
382
+ r.history && typeof r.history === "object" && !Array.isArray(r.history)
383
+ ? { recentlyTouched: Boolean((r.history as GoldenHistory).recentlyTouched) }
384
+ : undefined;
385
+ return {
386
+ ...(typeof r.label === "string" ? { label: r.label } : {}),
387
+ signals: r.signals as Signal[],
388
+ ...(history ? { history } : {}),
389
+ expectedDecision: r.expectedDecision,
390
+ };
391
+ }
392
+
393
+ // ---------------------------------------------------------------------------
394
+ // The empirical gate: do hot scores book more than cold scores?
395
+
396
+ /**
397
+ * Grade the judge's decisions against accumulated `signals outcome` results: of
398
+ * the decisions scored hot (>=80) vs cold (<80), which books meetings more
399
+ * often? A decision is matched to outcomes by account domain (and an outcome
400
+ * "books" when its result is "meeting"). Accounts with no recorded outcome are
401
+ * excluded from the denominators. `calibrated` is true iff hot strictly beats
402
+ * cold — absent or inverted correlation is NOT calibrated (the CLI exits
403
+ * nonzero on it). Deterministic.
404
+ */
405
+ export function gradeAgainstOutcomes(
406
+ decisions: JudgeDecision[],
407
+ outcomes: SignalOutcome[],
408
+ ): OutcomeCalibration {
409
+ // Per-account: did any credited touch book a meeting? (Domain-level booking.)
410
+ const booked = new Map<string, boolean>();
411
+ const sawOutcome = new Map<string, boolean>();
412
+ for (const outcome of outcomes) {
413
+ const domain = normalizeAccountDomain(outcome.accountDomain);
414
+ if (!domain) continue;
415
+ sawOutcome.set(domain, true);
416
+ if (outcome.result === "meeting") booked.set(domain, true);
417
+ else if (!booked.has(domain)) booked.set(domain, false);
418
+ }
419
+
420
+ let hotBooked = 0;
421
+ let hotCount = 0;
422
+ let coldBooked = 0;
423
+ let coldCount = 0;
424
+ // One decision per account; if a domain repeats, the highest score wins the
425
+ // bucket assignment so a single domain isn't double-counted across buckets.
426
+ const scoreByDomain = new Map<string, number>();
427
+ for (const decision of decisions) {
428
+ const domain = normalizeAccountDomain(decision.accountDomain);
429
+ if (!domain || !sawOutcome.get(domain)) continue;
430
+ const prior = scoreByDomain.get(domain);
431
+ if (prior === undefined || decision.score > prior) scoreByDomain.set(domain, decision.score);
432
+ }
433
+ for (const [domain, score] of scoreByDomain) {
434
+ const didBook = booked.get(domain) === true ? 1 : 0;
435
+ if (score >= HOT_SCORE) {
436
+ hotCount += 1;
437
+ hotBooked += didBook;
438
+ } else {
439
+ coldCount += 1;
440
+ coldBooked += didBook;
441
+ }
442
+ }
443
+
444
+ const hotBookRate = hotCount === 0 ? 0 : round4(hotBooked / hotCount);
445
+ const coldBookRate = coldCount === 0 ? 0 : round4(coldBooked / coldCount);
446
+ return {
447
+ hotBookRate,
448
+ coldBookRate,
449
+ calibrated: hotBookRate > coldBookRate,
450
+ hotCount,
451
+ coldCount,
452
+ };
453
+ }
454
+
455
+ // ---------------------------------------------------------------------------
456
+
457
+ function round4(value: number): number {
458
+ return Math.round(value * 10000) / 10000;
459
+ }
package/src/llm.ts CHANGED
@@ -22,6 +22,20 @@ export const DEFAULT_MODELS: Record<LlmProvider, string> = {
22
22
 
23
23
  const ANTHROPIC_URL = "https://api.anthropic.com/v1/messages";
24
24
  const OPENAI_URL = "https://api.openai.com/v1/chat/completions";
25
+
26
+ /**
27
+ * Resolve the effective endpoint, honoring an optional base-URL override.
28
+ * Mirrors the `prospectSources.ts` idiom (`(base ?? default).replace(/\/$/, "")`):
29
+ * trailing-slash-stripped; if the configured base already ends in the known
30
+ * path suffix it is used as-is, otherwise the suffix is appended so callers can
31
+ * pass either a bare origin (`https://glm.example`) or a full endpoint URL.
32
+ * Unset override → the upstream default, so behavior is unchanged.
33
+ */
34
+ function resolveLlmUrl(override: string | undefined, defaultUrl: string, pathSuffix: string): string {
35
+ if (!override) return defaultUrl;
36
+ const base = override.replace(/\/$/, "");
37
+ return base.endsWith(pathSuffix) ? base : `${base}${pathSuffix}`;
38
+ }
25
39
  // Bound cost and context: long calls keep the head and tail.
26
40
  const MAX_TRANSCRIPT_CHARS = 28_000;
27
41
 
@@ -92,6 +106,19 @@ export type LlmCallOptions = {
92
106
  apiKey: string;
93
107
  model?: string;
94
108
  fetchImpl?: typeof fetch;
109
+ /**
110
+ * Override the Anthropic Messages endpoint — point the package at an
111
+ * OpenAI/Anthropic-compatible gateway (GLM-5.2, z.ai, a local proxy) without
112
+ * code changes. An origin (`https://...`) gets `/v1/messages` appended; a base
113
+ * already ending in `/messages` is used verbatim. Threaded from
114
+ * `ANTHROPIC_API_BASE_URL` at the LlmCallOptions-construction layer (cli.ts),
115
+ * never read inside `forcedToolCall`. Unset → default api.anthropic.com.
116
+ */
117
+ anthropicBaseUrl?: string;
118
+ /** OpenAI equivalent of `anthropicBaseUrl` (e.g. an Ollama OpenAI-compatible
119
+ * endpoint). Origin → `/v1/chat/completions` appended; a base ending in
120
+ * `/chat/completions` is used verbatim. Threaded from `OPENAI_API_BASE_URL`. */
121
+ openaiBaseUrl?: string;
95
122
  };
96
123
 
97
124
  export type LlmExtractedInsight = ExtractedCallInsight & {
@@ -398,7 +425,8 @@ export async function forcedToolCall(
398
425
  ): Promise<unknown> {
399
426
  const fetchImpl = options.fetchImpl ?? fetch;
400
427
  if (options.provider === "anthropic") {
401
- const response = await llmFetch(fetchImpl, ANTHROPIC_URL, {
428
+ const anthropicUrl = resolveLlmUrl(options.anthropicBaseUrl, ANTHROPIC_URL, "/v1/messages");
429
+ const response = await llmFetch(fetchImpl, anthropicUrl, {
402
430
  method: "POST",
403
431
  headers: {
404
432
  "x-api-key": options.apiKey,
@@ -419,7 +447,8 @@ export async function forcedToolCall(
419
447
  if (!block?.input) throw new Error("Anthropic returned no tool call — try again or a different --model.");
420
448
  return block.input;
421
449
  }
422
- const response = await llmFetch(fetchImpl, OPENAI_URL, {
450
+ const openaiUrl = resolveLlmUrl(options.openaiBaseUrl, OPENAI_URL, "/v1/chat/completions");
451
+ const response = await llmFetch(fetchImpl, openaiUrl, {
423
452
  method: "POST",
424
453
  headers: { Authorization: `Bearer ${options.apiKey}`, "Content-Type": "application/json" },
425
454
  body: JSON.stringify({
package/src/schedule.ts CHANGED
@@ -86,10 +86,20 @@ const SCHEDULABLE: Record<string, string[] | null> = {
86
86
  doctor: null,
87
87
  enrich: ["append", "refresh"],
88
88
  market: ["capture", "refresh"],
89
+ // The GTM brain. `signals fetch` is read-only re: CRM (--save persists only
90
+ // the local signal ledger). `icp judge`/`icp eval` are read-only/grade-only
91
+ // (--save writes only the local judge store, never a plan). `draft` is
92
+ // plan-side — it only stages a needs_approval plan, never applies — so the
93
+ // whole verb is safely schedulable (apply stays `apply --plan-id` only and
94
+ // re-checks `approved` at run time, so a scheduled draft still cannot send).
95
+ signals: ["fetch"],
96
+ icp: ["judge", "eval"],
97
+ draft: null,
89
98
  };
90
99
 
91
100
  const ALLOWLIST_SUMMARY =
92
- "audit, snapshot, enrich append|refresh, market capture|refresh, suggest, report, doctor — " +
101
+ "audit, snapshot, enrich append|refresh, market capture|refresh, signals fetch, " +
102
+ "icp judge|eval, draft (stages a plan), suggest, report, doctor — " +
93
103
  "plus apply --plan-id <id> (re-checked approved at every firing)";
94
104
 
95
105
  /**