fullstackgtm 0.44.0 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/CHANGELOG.md +293 -1
  2. package/INSTALL_FOR_AGENTS.md +13 -11
  3. package/README.md +45 -24
  4. package/dist/audit.d.ts +3 -1
  5. package/dist/audit.js +29 -2
  6. package/dist/backfill.d.ts +86 -0
  7. package/dist/backfill.js +251 -0
  8. package/dist/bin.js +4 -1
  9. package/dist/cli/audit.d.ts +15 -0
  10. package/dist/cli/audit.js +392 -0
  11. package/dist/cli/auth.d.ts +82 -0
  12. package/dist/cli/auth.js +607 -0
  13. package/dist/cli/backfill.d.ts +1 -0
  14. package/dist/cli/backfill.js +125 -0
  15. package/dist/cli/backfillRuns.d.ts +1 -0
  16. package/dist/cli/backfillRuns.js +187 -0
  17. package/dist/cli/call.d.ts +1 -0
  18. package/dist/cli/call.js +387 -0
  19. package/dist/cli/capabilities.d.ts +30 -0
  20. package/dist/cli/capabilities.js +197 -0
  21. package/dist/cli/draft.d.ts +7 -0
  22. package/dist/cli/draft.js +87 -0
  23. package/dist/cli/enrich.d.ts +8 -0
  24. package/dist/cli/enrich.js +806 -0
  25. package/dist/cli/fix.d.ts +33 -0
  26. package/dist/cli/fix.js +346 -0
  27. package/dist/cli/help.d.ts +25 -0
  28. package/dist/cli/help.js +646 -0
  29. package/dist/cli/icp.d.ts +7 -0
  30. package/dist/cli/icp.js +229 -0
  31. package/dist/cli/init.d.ts +7 -0
  32. package/dist/cli/init.js +58 -0
  33. package/dist/cli/market.d.ts +1 -0
  34. package/dist/cli/market.js +391 -0
  35. package/dist/cli/plans.d.ts +5 -0
  36. package/dist/cli/plans.js +456 -0
  37. package/dist/cli/schedule.d.ts +8 -0
  38. package/dist/cli/schedule.js +479 -0
  39. package/dist/cli/shared.d.ts +71 -0
  40. package/dist/cli/shared.js +342 -0
  41. package/dist/cli/signals.d.ts +7 -0
  42. package/dist/cli/signals.js +412 -0
  43. package/dist/cli/suggest.d.ts +49 -0
  44. package/dist/cli/suggest.js +135 -0
  45. package/dist/cli/tam.d.ts +9 -0
  46. package/dist/cli/tam.js +387 -0
  47. package/dist/cli/ui.d.ts +142 -0
  48. package/dist/cli/ui.js +427 -0
  49. package/dist/cli.d.ts +1 -57
  50. package/dist/cli.js +123 -5157
  51. package/dist/connector.d.ts +23 -0
  52. package/dist/connector.js +47 -0
  53. package/dist/connectors/hubspot.d.ts +10 -1
  54. package/dist/connectors/hubspot.js +244 -10
  55. package/dist/connectors/hubspotAuth.js +5 -1
  56. package/dist/connectors/linkedin.js +14 -14
  57. package/dist/connectors/salesforce.d.ts +10 -1
  58. package/dist/connectors/salesforce.js +39 -6
  59. package/dist/connectors/signalSources.js +7 -59
  60. package/dist/connectors/stripe.d.ts +37 -0
  61. package/dist/connectors/stripe.js +103 -31
  62. package/dist/enrich.d.ts +7 -0
  63. package/dist/enrich.js +7 -0
  64. package/dist/health.d.ts +11 -69
  65. package/dist/health.js +4 -134
  66. package/dist/healthScore.d.ts +71 -0
  67. package/dist/healthScore.js +143 -0
  68. package/dist/icp.d.ts +15 -11
  69. package/dist/icp.js +19 -36
  70. package/dist/index.d.ts +3 -1
  71. package/dist/index.js +3 -1
  72. package/dist/judge.d.ts +2 -0
  73. package/dist/judge.js +6 -0
  74. package/dist/llm.d.ts +29 -0
  75. package/dist/llm.js +206 -0
  76. package/dist/market.d.ts +39 -1
  77. package/dist/market.js +116 -44
  78. package/dist/marketClassify.d.ts +11 -1
  79. package/dist/marketClassify.js +17 -2
  80. package/dist/marketTaxonomy.d.ts +6 -1
  81. package/dist/marketTaxonomy.js +4 -2
  82. package/dist/mcp-bin.js +31 -2
  83. package/dist/mcp.js +372 -166
  84. package/dist/progress.d.ts +96 -0
  85. package/dist/progress.js +142 -0
  86. package/dist/runReport.d.ts +24 -0
  87. package/dist/runReport.js +139 -4
  88. package/dist/schedule.d.ts +80 -2
  89. package/dist/schedule.js +254 -1
  90. package/dist/spoolFiles.d.ts +44 -0
  91. package/dist/spoolFiles.js +114 -0
  92. package/dist/types.d.ts +29 -1
  93. package/docs/api.md +75 -8
  94. package/docs/architecture.md +2 -0
  95. package/docs/linkedin-connector-spec.md +1 -1
  96. package/docs/signal-spool-format.md +13 -0
  97. package/llms.txt +18 -9
  98. package/package.json +10 -3
  99. package/skills/fullstackgtm/SKILL.md +2 -1
  100. package/src/audit.ts +27 -1
  101. package/src/backfill.ts +340 -0
  102. package/src/bin.ts +5 -1
  103. package/src/cli/audit.ts +447 -0
  104. package/src/cli/auth.ts +669 -0
  105. package/src/cli/backfill.ts +156 -0
  106. package/src/cli/backfillRuns.ts +198 -0
  107. package/src/cli/call.ts +414 -0
  108. package/src/cli/capabilities.ts +215 -0
  109. package/src/cli/draft.ts +101 -0
  110. package/src/cli/enrich.ts +908 -0
  111. package/src/cli/fix.ts +373 -0
  112. package/src/cli/help.ts +702 -0
  113. package/src/cli/icp.ts +265 -0
  114. package/src/cli/init.ts +65 -0
  115. package/src/cli/market.ts +423 -0
  116. package/src/cli/plans.ts +524 -0
  117. package/src/cli/schedule.ts +526 -0
  118. package/src/cli/shared.ts +392 -0
  119. package/src/cli/signals.ts +434 -0
  120. package/src/cli/suggest.ts +151 -0
  121. package/src/cli/tam.ts +435 -0
  122. package/src/cli/ui.ts +497 -0
  123. package/src/cli.ts +122 -5855
  124. package/src/connector.ts +66 -0
  125. package/src/connectors/hubspot.ts +273 -9
  126. package/src/connectors/hubspotAuth.ts +5 -1
  127. package/src/connectors/linkedin.ts +14 -14
  128. package/src/connectors/salesforce.ts +51 -3
  129. package/src/connectors/signalSources.ts +7 -56
  130. package/src/connectors/stripe.ts +154 -34
  131. package/src/enrich.ts +13 -0
  132. package/src/health.ts +14 -213
  133. package/src/healthScore.ts +223 -0
  134. package/src/icp.ts +19 -35
  135. package/src/index.ts +28 -1
  136. package/src/judge.ts +7 -0
  137. package/src/llm.ts +239 -0
  138. package/src/market.ts +157 -44
  139. package/src/marketClassify.ts +26 -2
  140. package/src/marketTaxonomy.ts +10 -2
  141. package/src/mcp-bin.ts +34 -2
  142. package/src/mcp.ts +270 -63
  143. package/src/progress.ts +197 -0
  144. package/src/runReport.ts +159 -4
  145. package/src/schedule.ts +310 -3
  146. package/src/spoolFiles.ts +116 -0
  147. package/src/types.ts +32 -2
  148. package/docs/dx-punch-list.md +0 -87
  149. package/docs/roadmap-to-1.0.md +0 -211
@@ -18,9 +18,10 @@
18
18
  * the caller), it must never sink a multi-source run — the per-provider
19
19
  * try/catch idiom of atsBoards/prospectSources.
20
20
  */
21
- import { readFileSync, readdirSync, statSync } from "node:fs";
22
- import { join, resolve } from "node:path";
21
+ import { readFileSync, statSync } from "node:fs";
22
+ import { resolve } from "node:path";
23
23
  import { SIGNAL_BUCKETS } from "../signals.js";
24
+ import { parseSpoolText, spoolFilesIn } from "../spoolFiles.js";
24
25
  // ---------------------------------------------------------------------------
25
26
  // file — local JSON / JSONL intake (also the webhook landing-zone reader)
26
27
  /**
@@ -59,6 +60,9 @@ export const fileSource = {
59
60
  catch {
60
61
  return []; // missing path: an empty source, not a crash
61
62
  }
63
+ // Container parsing lives in the shared spool reader (src/spoolFiles.ts) so
64
+ // `signals fetch --from`, `enrich ingest`, and `market observe --from` read
65
+ // the same convention; the "file source" label keeps error text unchanged.
62
66
  const files = isDir ? spoolFilesIn(abs) : [abs];
63
67
  const rows = [];
64
68
  for (const file of files) {
@@ -69,67 +73,11 @@ export const fileSource = {
69
73
  catch {
70
74
  continue; // one unreadable file in a spool dir must not sink the rest
71
75
  }
72
- const trimmed = raw.trim();
73
- if (!trimmed)
74
- continue;
75
- const parsed = trimmed.startsWith("[") ? parseJsonArray(trimmed, file) : parseJsonl(trimmed, file);
76
- rows.push(...parsed);
76
+ rows.push(...parseSpoolText(raw, file, "file source"));
77
77
  }
78
78
  return rows.map((row) => coerceRow(row, this.bucket));
79
79
  },
80
80
  };
81
- /** Spool files in a landing-zone directory: `*.jsonl` / `*.json`, name-sorted. */
82
- function spoolFilesIn(dir) {
83
- let names;
84
- try {
85
- names = readdirSync(dir);
86
- }
87
- catch {
88
- return [];
89
- }
90
- return names
91
- .filter((name) => name.endsWith(".jsonl") || name.endsWith(".json"))
92
- .sort()
93
- .map((name) => join(dir, name));
94
- }
95
- function parseJsonArray(raw, path) {
96
- let parsed;
97
- try {
98
- parsed = JSON.parse(raw);
99
- }
100
- catch (error) {
101
- throw new Error(`file source ${path}: not valid JSON (${error instanceof Error ? error.message : String(error)}).`);
102
- }
103
- if (!Array.isArray(parsed))
104
- throw new Error(`file source ${path}: expected a JSON array of staged signal rows.`);
105
- return parsed.map((entry, index) => {
106
- if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
107
- throw new Error(`file source ${path}: row ${index} is not an object.`);
108
- }
109
- return entry;
110
- });
111
- }
112
- function parseJsonl(raw, path) {
113
- const out = [];
114
- const lines = raw.split("\n");
115
- lines.forEach((line, index) => {
116
- const t = line.trim();
117
- if (!t)
118
- return;
119
- let parsed;
120
- try {
121
- parsed = JSON.parse(t);
122
- }
123
- catch (error) {
124
- throw new Error(`file source ${path}: line ${index} is not valid JSON (${error instanceof Error ? error.message : String(error)}).`);
125
- }
126
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
127
- throw new Error(`file source ${path}: line ${index} is not a JSON object.`);
128
- }
129
- out.push(parsed);
130
- });
131
- return out;
132
- }
133
81
  // ---------------------------------------------------------------------------
134
82
  // serpapi-news — funding/company signals from a news REST query (API key)
135
83
  const SERPAPI_BASE_URL = "https://serpapi.com";
@@ -1,10 +1,17 @@
1
1
  import type { GtmConnector } from "../types.ts";
2
+ import { type ProgressEmitter } from "../progress.ts";
2
3
  export type StripeConnectorOptions = {
3
4
  /** Stripe secret key (sk_...) or restricted key. */
4
5
  getApiKey: () => string | Promise<string>;
5
6
  apiBaseUrl?: string;
6
7
  /** Injectable fetch for testing. */
7
8
  fetchImpl?: typeof fetch;
9
+ /**
10
+ * Shared progress vocabulary (src/progress.ts): the snapshot pull emits a
11
+ * `stage` per collection (customers, subscriptions) and an `items`
12
+ * heartbeat per page. Presentation-only — errors are swallowed.
13
+ */
14
+ progress?: ProgressEmitter;
8
15
  };
9
16
  /**
10
17
  * Read-only billing connector for Stripe — the first non-CRM connector,
@@ -25,3 +32,33 @@ export type StripeConnectorOptions = {
25
32
  * collections are always empty.
26
33
  */
27
34
  export declare function createStripeConnector(options: StripeConnectorOptions): GtmConnector;
35
+ /**
36
+ * One PAID Stripe invoice, normalized for the backfill plan builder
37
+ * (`backfill stripe`). Amounts are major units (Stripe returns cents);
38
+ * `paidAt` is the payment date (YYYY-MM-DD) from status_transitions.paid_at.
39
+ */
40
+ export type StripePaidInvoice = {
41
+ id: string;
42
+ number?: string;
43
+ customerId?: string;
44
+ customerName?: string;
45
+ customerEmail?: string;
46
+ /** Lowercased domain of the customer's billing email — the CRM match key. */
47
+ customerDomain?: string;
48
+ /** Major currency units (amount_paid / 100). */
49
+ amountPaid: number;
50
+ currency?: string;
51
+ /** YYYY-MM-DD from status_transitions.paid_at (unix seconds). */
52
+ paidAt?: string;
53
+ description?: string;
54
+ };
55
+ /**
56
+ * Read-only pull of PAID invoices (GET /v1/invoices?status=paid with the
57
+ * customer expanded), the revenue source of truth the backfill plan builder
58
+ * turns into closed-won deal proposals. Invoices with a zero amount_paid or
59
+ * no id are skipped — a $0 invoice is not a won deal.
60
+ */
61
+ export declare function fetchStripePaidInvoices(options: StripeConnectorOptions, opts?: {
62
+ sinceIso?: string;
63
+ onPage?: (fetched: number) => void;
64
+ }): Promise<StripePaidInvoice[]>;
@@ -1,4 +1,42 @@
1
+ import { STRIPE_SNAPSHOT_STAGES } from "../progress.js";
1
2
  const DEFAULT_API_BASE_URL = "https://api.stripe.com";
3
+ // Module-scope request/list (parameterized by options) so the connector
4
+ // factory and fetchStripePaidInvoices share one implementation.
5
+ async function stripeRequest(options, path) {
6
+ const baseUrl = (options.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/$/, "");
7
+ const fetchImpl = options.fetchImpl ?? fetch;
8
+ const apiKey = await options.getApiKey();
9
+ const response = await fetchImpl(`${baseUrl}${path}`, {
10
+ headers: { Authorization: `Bearer ${apiKey}` },
11
+ });
12
+ if (!response.ok) {
13
+ // Status line only — the body can echo request details bound to a live
14
+ // billing key, and these errors land in scheduled-run records.
15
+ await response.text().catch(() => undefined);
16
+ throw new Error(`Stripe API error ${response.status}. Check the restricted key and request.`);
17
+ }
18
+ return response.json();
19
+ }
20
+ /** Stripe list pagination: follow `has_more` with `starting_after=<last id>`. */
21
+ async function stripeList(options, path, onPage) {
22
+ const results = [];
23
+ let startingAfter;
24
+ do {
25
+ const separator = path.includes("?") ? "&" : "?";
26
+ const data = await stripeRequest(options, `${path}${startingAfter ? `${separator}starting_after=${encodeURIComponent(startingAfter)}` : ""}`);
27
+ const page = data.data ?? [];
28
+ results.push(...page);
29
+ try {
30
+ onPage?.(results.length);
31
+ }
32
+ catch {
33
+ // progress is presentation-only; never let it fail a pull
34
+ }
35
+ startingAfter =
36
+ data.has_more === true && page.length > 0 ? String(page.at(-1).id) : undefined;
37
+ } while (startingAfter);
38
+ return results;
39
+ }
2
40
  /**
3
41
  * Read-only billing connector for Stripe — the first non-CRM connector,
4
42
  * proving the GtmConnector contract generalizes to billing systems.
@@ -18,40 +56,16 @@ const DEFAULT_API_BASE_URL = "https://api.stripe.com";
18
56
  * collections are always empty.
19
57
  */
20
58
  export function createStripeConnector(options) {
21
- const baseUrl = (options.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/$/, "");
22
- const fetchImpl = options.fetchImpl ?? fetch;
23
- async function request(path) {
24
- const apiKey = await options.getApiKey();
25
- const response = await fetchImpl(`${baseUrl}${path}`, {
26
- headers: { Authorization: `Bearer ${apiKey}` },
27
- });
28
- if (!response.ok) {
29
- // Status line only — the body can echo request details bound to a live
30
- // billing key, and these errors land in scheduled-run records.
31
- await response.text().catch(() => undefined);
32
- throw new Error(`Stripe API error ${response.status}. Check the restricted key and request.`);
33
- }
34
- return response.json();
35
- }
36
- /** Stripe list pagination: follow `has_more` with `starting_after=<last id>`. */
37
- async function list(path) {
38
- const results = [];
39
- let startingAfter;
40
- do {
41
- const separator = path.includes("?") ? "&" : "?";
42
- const data = await request(`${path}${startingAfter ? `${separator}starting_after=${encodeURIComponent(startingAfter)}` : ""}`);
43
- const page = data.data ?? [];
44
- results.push(...page);
45
- startingAfter =
46
- data.has_more === true && page.length > 0 ? String(page.at(-1).id) : undefined;
47
- } while (startingAfter);
48
- return results;
49
- }
59
+ const list = (path, onPage) => stripeList(options, path, onPage);
60
+ // Shared progress vocabulary: stage per collection, items per page.
61
+ const pullStage = (stage) => options.progress?.stage(stage, STRIPE_SNAPSHOT_STAGES.indexOf(stage), STRIPE_SNAPSHOT_STAGES.length);
62
+ const pullItems = (fetched) => options.progress?.items(fetched);
50
63
  async function assembleSnapshot(createdGte) {
51
64
  // Stripe filters list endpoints on the integer `created` field via
52
65
  // created[gte]=<unix seconds>; omitted on a full snapshot.
53
66
  const createdFilter = createdGte === undefined ? "" : `&created[gte]=${createdGte}`;
54
- const customers = await list(`/v1/customers?limit=100${createdFilter}`);
67
+ pullStage("customers");
68
+ const customers = await list(`/v1/customers?limit=100${createdFilter}`, pullItems);
55
69
  const accounts = [];
56
70
  const contacts = [];
57
71
  for (const customer of customers) {
@@ -86,7 +100,8 @@ export function createStripeConnector(options) {
86
100
  });
87
101
  }
88
102
  }
89
- const subscriptions = await list(`/v1/subscriptions?status=all&limit=100${createdFilter}`);
103
+ pullStage("subscriptions");
104
+ const subscriptions = await list(`/v1/subscriptions?status=all&limit=100${createdFilter}`, pullItems);
90
105
  const deals = subscriptions
91
106
  .filter((subscription) => subscription.id)
92
107
  .map((subscription) => {
@@ -125,6 +140,8 @@ export function createStripeConnector(options) {
125
140
  raw: subscription,
126
141
  };
127
142
  });
143
+ // Deliver any throttled trailing items heartbeat before the pull returns.
144
+ options.progress?.flush();
128
145
  return {
129
146
  generatedAt: new Date().toISOString(),
130
147
  provider: "stripe",
@@ -165,6 +182,61 @@ export function createStripeConnector(options) {
165
182
  applyOperation,
166
183
  };
167
184
  }
185
+ /**
186
+ * Read-only pull of PAID invoices (GET /v1/invoices?status=paid with the
187
+ * customer expanded), the revenue source of truth the backfill plan builder
188
+ * turns into closed-won deal proposals. Invoices with a zero amount_paid or
189
+ * no id are skipped — a $0 invoice is not a won deal.
190
+ */
191
+ export async function fetchStripePaidInvoices(options, opts = {}) {
192
+ let createdFilter = "";
193
+ if (opts.sinceIso !== undefined) {
194
+ const sinceMs = Date.parse(opts.sinceIso);
195
+ if (!Number.isFinite(sinceMs))
196
+ throw new Error(`Invalid since timestamp: ${opts.sinceIso}`);
197
+ createdFilter = `&created[gte]=${Math.floor(sinceMs / 1000)}`;
198
+ }
199
+ const invoices = await stripeList(options, `/v1/invoices?status=paid&limit=100&expand[]=data.customer${createdFilter}`, opts.onPage);
200
+ const paid = [];
201
+ for (const invoice of invoices) {
202
+ if (!invoice?.id)
203
+ continue;
204
+ const amountPaidCents = numberOrUndefined(invoice.amount_paid);
205
+ if (amountPaidCents === undefined || amountPaidCents <= 0)
206
+ continue;
207
+ // customer is the expanded object when expand[]=data.customer was honored,
208
+ // else the plain id string — handle both.
209
+ const customer = invoice.customer;
210
+ const customerId = typeof customer === "string" ? customer : stringOrUndefined(customer?.id);
211
+ // Invoices also carry flat customer_name/customer_email copies — use them
212
+ // when the customer object wasn't expanded (or lacks the field).
213
+ const customerName = (typeof customer === "object" && customer !== null
214
+ ? stringOrUndefined(customer.name)
215
+ : undefined) ?? stringOrUndefined(invoice.customer_name);
216
+ const customerEmail = typeof customer === "object" && customer !== null
217
+ ? stringOrUndefined(customer.email) ?? stringOrUndefined(invoice.customer_email)
218
+ : stringOrUndefined(invoice.customer_email);
219
+ const customerDomain = customerEmail?.includes("@")
220
+ ? customerEmail.split("@").at(-1).toLowerCase()
221
+ : undefined;
222
+ const paidAtSeconds = numberOrUndefined(invoice.status_transitions?.paid_at);
223
+ paid.push({
224
+ id: String(invoice.id),
225
+ number: stringOrUndefined(invoice.number),
226
+ customerId,
227
+ customerName,
228
+ customerEmail,
229
+ customerDomain,
230
+ amountPaid: amountPaidCents / 100,
231
+ currency: stringOrUndefined(invoice.currency)?.toUpperCase(),
232
+ paidAt: paidAtSeconds !== undefined
233
+ ? new Date(paidAtSeconds * 1000).toISOString().split("T")[0]
234
+ : undefined,
235
+ description: stringOrUndefined(invoice.description),
236
+ });
237
+ }
238
+ return paid;
239
+ }
168
240
  function stringOrUndefined(value) {
169
241
  if (value === undefined || value === null || value === "")
170
242
  return undefined;
package/dist/enrich.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { AcquireBudget } from "./acquireMeter.ts";
2
+ import type { ProgressEmitter } from "./progress.ts";
2
3
  import type { AssignmentContext, AssignmentPolicy } from "./assign.ts";
3
4
  import type { CanonicalGtmSnapshot, PatchPlan } from "./types.ts";
4
5
  /**
@@ -250,6 +251,12 @@ export type BuildAcquirePlanOptions = {
250
251
  /** Meter ceiling: max create ops to emit. null/undefined = unlimited. */
251
252
  maxRecords?: number | null;
252
253
  now?: () => Date;
254
+ /**
255
+ * Shared progress vocabulary (src/progress.ts): `items` over the candidate
256
+ * rows as they are routed, plus a `meter` reading of creates vs the meter
257
+ * ceiling. Presentation-only — never changes what the plan proposes.
258
+ */
259
+ progress?: ProgressEmitter;
253
260
  };
254
261
  /**
255
262
  * Lift a prospect/source payload into the routing attributes a territory rule
package/dist/enrich.js CHANGED
@@ -816,7 +816,10 @@ export function buildAcquirePlan(options) {
816
816
  }
817
817
  }
818
818
  }
819
+ let routed = 0;
819
820
  for (const record of records) {
821
+ // Progress heartbeat over the candidate rows (throttled by the emitter).
822
+ options.progress?.items((routed += 1), records.length);
820
823
  const createMap = acquire.create[record.objectType];
821
824
  const match = config.match[record.objectType];
822
825
  // No create mapping or no match config for this type — can neither create
@@ -923,6 +926,10 @@ export function buildAcquirePlan(options) {
923
926
  counts.created += 1;
924
927
  estCostUsd += costPerRecord;
925
928
  }
929
+ options.progress?.flush();
930
+ // Meter reading: creates proposed this run against the meter's ceiling.
931
+ if (cap !== null)
932
+ options.progress?.meter(counts.created, cap, "creates");
926
933
  const plan = {
927
934
  id: `patch_plan_acq_${fnv1a(`${source}:${runLabel}:${nowIso}`)}`,
928
935
  title: `Acquire leads — ${source}`,
package/dist/health.d.ts CHANGED
@@ -1,74 +1,16 @@
1
- import type { AuditFindingSeverity, CanonicalGtmSnapshot, PatchPlan } from "./types.ts";
2
- export type HealthEntry = {
3
- /** ISO timestamp of the audit that produced this entry. */
4
- at: string;
5
- /** The saved plan this entry summarizes (correlates to plans/ and snapshots/). */
6
- planId: string;
7
- /** Deterministic 0–100 hygiene score (higher is healthier). */
8
- score: number;
9
- /** Total finding count across all rules. */
10
- findings: number;
11
- /** Severity-weighted finding total (drives the score). */
12
- weightedFindings: number;
13
- /** Record counts at audit time — the denominator that normalizes the score. */
14
- records: {
15
- accounts: number;
16
- contacts: number;
17
- deals: number;
18
- total: number;
19
- };
20
- /** Finding count per rule id. */
21
- byRule: Record<string, number>;
22
- /** Finding count per severity. */
23
- severityCounts: Record<AuditFindingSeverity, number>;
24
- /**
25
- * Per-object-type breakdown — so "is my contact data clean but my pipeline
26
- * messy?" is answerable without re-auditing. Each type carries its own
27
- * record-normalized score (same curve as the overall score, scoped to that
28
- * type's records + findings).
29
- */
30
- byObjectType: Record<"account" | "contact" | "deal", {
31
- records: number;
32
- findings: number;
33
- weightedFindings: number;
34
- score: number;
35
- }>;
36
- };
37
- export type HealthRuleDelta = {
38
- ruleId: string;
39
- current: number;
40
- previous: number;
41
- /** current − previous: positive = more findings (worse), negative = fewer (better). */
42
- delta: number;
43
- };
44
- export type HealthRollup = {
45
- profile: string;
46
- auditCount: number;
47
- first: string;
48
- latest: string;
49
- current: HealthEntry;
50
- previous: HealthEntry | null;
51
- /** current.score − previous.score: positive = improving. null on the first audit. */
52
- scoreDelta: number | null;
53
- ruleDeltas: HealthRuleDelta[];
54
- history: Array<{
55
- at: string;
56
- score: number;
57
- findings: number;
58
- }>;
59
- };
1
+ import type { CanonicalGtmSnapshot } from "./types.ts";
2
+ import type { HealthEntry } from "./healthScore.ts";
3
+ export { computeHealth, summarizeHealth, healthToMarkdown, type HealthEntry, type HealthRuleDelta, type HealthRollup, } from "./healthScore.ts";
60
4
  /**
61
- * Deterministically score a saved audit. score = 100 / (1 + weighted findings
62
- * per record): 0 findings 100; one weighted finding per record → 50; the
63
- * curve is bounded (0, 100], monotonic, and needs no clamping or magic
64
- * constants. A messy 50-record CRM scores worse than the same finding count
65
- * across 5,000 records, which is the point.
5
+ * The engagement workspace: a per-client health timeline that accrues from the
6
+ * verb people already run (`audit --save`). State lives in the profile dir
7
+ * (`$FSGTM_HOME[/profiles/<name>]`) alongside credentials and plans, so a
8
+ * consultant working `--profile <client>` builds a continuous record of one
9
+ * org's CRM health over time not just episodic one-off audits.
10
+ *
11
+ * The score is DETERMINISTIC (no LLM): same plan + same snapshot → same score,
12
+ * so it is stable in CI and comparable run-over-run.
66
13
  */
67
- export declare function computeHealth(plan: PatchPlan, snapshot: CanonicalGtmSnapshot, at: string): HealthEntry;
68
- /** Roll a timeline up into the current state, the change since last time, and per-rule deltas. */
69
- export declare function summarizeHealth(entries: HealthEntry[], profile: string): HealthRollup | null;
70
- /** Human-readable rollup: headline score + trend, then per-rule change since last audit. */
71
- export declare function healthToMarkdown(rollup: HealthRollup): string;
72
14
  /** `$FSGTM_HOME[/profiles/<name>]/health.jsonl` — append-only, one entry per audit-save. */
73
15
  export declare function healthFilePath(): string;
74
16
  /** `$FSGTM_HOME[/profiles/<name>]/snapshots/` — canonical snapshots correlated by plan id. */
package/dist/health.js CHANGED
@@ -1,6 +1,10 @@
1
1
  import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { activeProfile, credentialsDir, ensureSecureHomeDir, writeSecureFile } from "./credentials.js";
4
+ // Pure scoring lives in healthScore.ts (node-free, importable from V8 runtimes
5
+ // like the hosted app's Convex queries); this module owns the fs-backed
6
+ // profile timeline. Re-exported so existing imports keep working.
7
+ export { computeHealth, summarizeHealth, healthToMarkdown, } from "./healthScore.js";
4
8
  /**
5
9
  * The engagement workspace: a per-client health timeline that accrues from the
6
10
  * verb people already run (`audit --save`). State lives in the profile dir
@@ -11,140 +15,6 @@ import { activeProfile, credentialsDir, ensureSecureHomeDir, writeSecureFile } f
11
15
  * The score is DETERMINISTIC (no LLM): same plan + same snapshot → same score,
12
16
  * so it is stable in CI and comparable run-over-run.
13
17
  */
14
- // Severity weights: a critical finding costs ~3× a warning, ~10× an info.
15
- const SEVERITY_WEIGHT = { info: 1, warning: 3, critical: 10 };
16
- /**
17
- * Deterministically score a saved audit. score = 100 / (1 + weighted findings
18
- * per record): 0 findings → 100; one weighted finding per record → 50; the
19
- * curve is bounded (0, 100], monotonic, and needs no clamping or magic
20
- * constants. A messy 50-record CRM scores worse than the same finding count
21
- * across 5,000 records, which is the point.
22
- */
23
- export function computeHealth(plan, snapshot, at) {
24
- const byRule = {};
25
- const severityCounts = { info: 0, warning: 0, critical: 0 };
26
- const typeTally = {
27
- account: { findings: 0, weightedFindings: 0 },
28
- contact: { findings: 0, weightedFindings: 0 },
29
- deal: { findings: 0, weightedFindings: 0 },
30
- };
31
- let weightedFindings = 0;
32
- for (const finding of plan.findings) {
33
- byRule[finding.ruleId] = (byRule[finding.ruleId] ?? 0) + 1;
34
- severityCounts[finding.severity] += 1;
35
- weightedFindings += SEVERITY_WEIGHT[finding.severity];
36
- const t = finding.objectType;
37
- if (t === "account" || t === "contact" || t === "deal") {
38
- typeTally[t].findings += 1;
39
- typeTally[t].weightedFindings += SEVERITY_WEIGHT[finding.severity];
40
- }
41
- }
42
- const records = {
43
- accounts: snapshot.accounts.length,
44
- contacts: snapshot.contacts.length,
45
- deals: snapshot.deals.length,
46
- total: snapshot.accounts.length + snapshot.contacts.length + snapshot.deals.length,
47
- };
48
- const scoreFor = (weighted, recordCount) => Math.round(100 / (1 + weighted / Math.max(recordCount, 1)));
49
- const recordsByType = { account: records.accounts, contact: records.contacts, deal: records.deals };
50
- const byObjectType = ["account", "contact", "deal"].reduce((acc, t) => {
51
- acc[t] = {
52
- records: recordsByType[t],
53
- findings: typeTally[t].findings,
54
- weightedFindings: typeTally[t].weightedFindings,
55
- score: scoreFor(typeTally[t].weightedFindings, recordsByType[t]),
56
- };
57
- return acc;
58
- }, {});
59
- const score = scoreFor(weightedFindings, records.total);
60
- return {
61
- at,
62
- planId: plan.id,
63
- score,
64
- findings: plan.findings.length,
65
- weightedFindings,
66
- records,
67
- byRule,
68
- severityCounts,
69
- byObjectType,
70
- };
71
- }
72
- /** Roll a timeline up into the current state, the change since last time, and per-rule deltas. */
73
- export function summarizeHealth(entries, profile) {
74
- if (entries.length === 0)
75
- return null;
76
- // Oldest → newest, so `current` is the last entry.
77
- const sorted = [...entries].sort((a, b) => a.at.localeCompare(b.at));
78
- const current = sorted[sorted.length - 1];
79
- const previous = sorted.length > 1 ? sorted[sorted.length - 2] : null;
80
- const ruleIds = new Set([
81
- ...Object.keys(current.byRule),
82
- ...(previous ? Object.keys(previous.byRule) : []),
83
- ]);
84
- const ruleDeltas = [...ruleIds]
85
- .map((ruleId) => {
86
- const cur = current.byRule[ruleId] ?? 0;
87
- const prev = previous?.byRule[ruleId] ?? 0;
88
- return { ruleId, current: cur, previous: prev, delta: cur - prev };
89
- })
90
- .sort((a, b) => b.current - a.current || a.ruleId.localeCompare(b.ruleId));
91
- return {
92
- profile,
93
- auditCount: sorted.length,
94
- first: sorted[0].at,
95
- latest: current.at,
96
- current,
97
- previous,
98
- scoreDelta: previous ? current.score - previous.score : null,
99
- ruleDeltas,
100
- history: sorted.map((entry) => ({ at: entry.at, score: entry.score, findings: entry.findings })),
101
- };
102
- }
103
- // Sign-based and uniform: ▲ = the number went up, ▼ = down. The reader supplies
104
- // the meaning (score up = good; findings up = bad) — mixing arrow direction with
105
- // good/bad reads as contradictory ("▲ -3").
106
- function arrow(delta) {
107
- if (delta === 0)
108
- return "·";
109
- return delta > 0 ? "▲" : "▼";
110
- }
111
- /** Human-readable rollup: headline score + trend, then per-rule change since last audit. */
112
- export function healthToMarkdown(rollup) {
113
- const { current, scoreDelta } = rollup;
114
- const lines = [`# GTM health — profile \`${rollup.profile}\``, ""];
115
- const deltaText = scoreDelta === null
116
- ? "(first audit — no prior reading)"
117
- : `${arrow(scoreDelta)} ${scoreDelta >= 0 ? "+" : ""}${scoreDelta} since the previous audit`;
118
- lines.push(`Score: **${current.score}/100** ${deltaText}`, `Audits: ${rollup.auditCount} over ${shortDate(rollup.first)} → ${shortDate(rollup.latest)}`, `Findings: ${current.findings} (${current.severityCounts.critical} critical, ${current.severityCounts.warning} warning, ${current.severityCounts.info} info)`, `Records: ${current.records.accounts} accounts, ${current.records.contacts} contacts, ${current.records.deals} deals`, "");
119
- lines.push("## By object type", "", "| Type | Score | Findings | Records |", "| --- | --- | --- | --- |");
120
- for (const t of ["account", "contact", "deal"]) {
121
- const b = current.byObjectType[t];
122
- lines.push(`| ${t} | ${b.score}/100 | ${b.findings} | ${b.records} |`);
123
- }
124
- lines.push("");
125
- if (rollup.history.length > 1) {
126
- lines.push("## Trend", "");
127
- for (const point of rollup.history) {
128
- const marker = point.at === rollup.latest ? " ← latest" : "";
129
- lines.push(`- ${shortDate(point.at)} score ${point.score} (${point.findings} findings)${marker}`);
130
- }
131
- lines.push("");
132
- }
133
- if (rollup.ruleDeltas.length > 0) {
134
- lines.push("## By rule (Δ since previous audit)", "", "| Rule | Findings | Δ |", "| --- | --- | --- |");
135
- for (const rule of rollup.ruleDeltas) {
136
- const sign = rule.delta > 0 ? `+${rule.delta}` : `${rule.delta}`;
137
- const cell = rule.delta === 0 ? "·" : `${arrow(rule.delta)} ${sign}`;
138
- lines.push(`| ${rule.ruleId} | ${rule.current} | ${cell} |`);
139
- }
140
- lines.push("");
141
- }
142
- lines.push("> Score is deterministic: 100 / (1 + severity-weighted findings per record). Lower findings or more clean records ⇒ higher score.");
143
- return `${lines.join("\n")}\n`;
144
- }
145
- function shortDate(iso) {
146
- return iso.slice(0, 10);
147
- }
148
18
  // ── Profile-scoped storage ──────────────────────────────────────────────────
149
19
  /** `$FSGTM_HOME[/profiles/<name>]/health.jsonl` — append-only, one entry per audit-save. */
150
20
  export function healthFilePath() {
@@ -0,0 +1,71 @@
1
+ import type { AuditFindingSeverity, CanonicalGtmSnapshot, PatchPlan } from "./types.ts";
2
+ export type HealthEntry = {
3
+ /** ISO timestamp of the audit that produced this entry. */
4
+ at: string;
5
+ /** The saved plan this entry summarizes (correlates to plans/ and snapshots/). */
6
+ planId: string;
7
+ /** Deterministic 0–100 hygiene score (higher is healthier). */
8
+ score: number;
9
+ /** Total finding count across all rules. */
10
+ findings: number;
11
+ /** Severity-weighted finding total (drives the score). */
12
+ weightedFindings: number;
13
+ /** Record counts at audit time — the denominator that normalizes the score. */
14
+ records: {
15
+ accounts: number;
16
+ contacts: number;
17
+ deals: number;
18
+ total: number;
19
+ };
20
+ /** Finding count per rule id. */
21
+ byRule: Record<string, number>;
22
+ /** Finding count per severity. */
23
+ severityCounts: Record<AuditFindingSeverity, number>;
24
+ /**
25
+ * Per-object-type breakdown — so "is my contact data clean but my pipeline
26
+ * messy?" is answerable without re-auditing. Each type carries its own
27
+ * record-normalized score (same curve as the overall score, scoped to that
28
+ * type's records + findings).
29
+ */
30
+ byObjectType: Record<"account" | "contact" | "deal", {
31
+ records: number;
32
+ findings: number;
33
+ weightedFindings: number;
34
+ score: number;
35
+ }>;
36
+ };
37
+ export type HealthRuleDelta = {
38
+ ruleId: string;
39
+ current: number;
40
+ previous: number;
41
+ /** current − previous: positive = more findings (worse), negative = fewer (better). */
42
+ delta: number;
43
+ };
44
+ export type HealthRollup = {
45
+ profile: string;
46
+ auditCount: number;
47
+ first: string;
48
+ latest: string;
49
+ current: HealthEntry;
50
+ previous: HealthEntry | null;
51
+ /** current.score − previous.score: positive = improving. null on the first audit. */
52
+ scoreDelta: number | null;
53
+ ruleDeltas: HealthRuleDelta[];
54
+ history: Array<{
55
+ at: string;
56
+ score: number;
57
+ findings: number;
58
+ }>;
59
+ };
60
+ /**
61
+ * Deterministically score a saved audit. score = 100 / (1 + weighted findings
62
+ * per record): 0 findings → 100; one weighted finding per record → 50; the
63
+ * curve is bounded (0, 100], monotonic, and needs no clamping or magic
64
+ * constants. A messy 50-record CRM scores worse than the same finding count
65
+ * across 5,000 records, which is the point.
66
+ */
67
+ export declare function computeHealth(plan: PatchPlan, snapshot: CanonicalGtmSnapshot, at: string): HealthEntry;
68
+ /** Roll a timeline up into the current state, the change since last time, and per-rule deltas. */
69
+ export declare function summarizeHealth(entries: HealthEntry[], profile: string): HealthRollup | null;
70
+ /** Human-readable rollup: headline score + trend, then per-rule change since last audit. */
71
+ export declare function healthToMarkdown(rollup: HealthRollup): string;