fullstackgtm 0.44.0 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/CHANGELOG.md +185 -1
  2. package/README.md +19 -7
  3. package/dist/audit.d.ts +3 -1
  4. package/dist/audit.js +29 -2
  5. package/dist/cli/audit.d.ts +15 -0
  6. package/dist/cli/audit.js +392 -0
  7. package/dist/cli/auth.d.ts +80 -0
  8. package/dist/cli/auth.js +500 -0
  9. package/dist/cli/call.d.ts +1 -0
  10. package/dist/cli/call.js +373 -0
  11. package/dist/cli/capabilities.d.ts +30 -0
  12. package/dist/cli/capabilities.js +197 -0
  13. package/dist/cli/draft.d.ts +7 -0
  14. package/dist/cli/draft.js +87 -0
  15. package/dist/cli/enrich.d.ts +8 -0
  16. package/dist/cli/enrich.js +788 -0
  17. package/dist/cli/fix.d.ts +33 -0
  18. package/dist/cli/fix.js +344 -0
  19. package/dist/cli/help.d.ts +25 -0
  20. package/dist/cli/help.js +609 -0
  21. package/dist/cli/icp.d.ts +7 -0
  22. package/dist/cli/icp.js +229 -0
  23. package/dist/cli/init.d.ts +7 -0
  24. package/dist/cli/init.js +58 -0
  25. package/dist/cli/market.d.ts +1 -0
  26. package/dist/cli/market.js +391 -0
  27. package/dist/cli/plans.d.ts +5 -0
  28. package/dist/cli/plans.js +454 -0
  29. package/dist/cli/schedule.d.ts +8 -0
  30. package/dist/cli/schedule.js +479 -0
  31. package/dist/cli/shared.d.ts +70 -0
  32. package/dist/cli/shared.js +331 -0
  33. package/dist/cli/signals.d.ts +7 -0
  34. package/dist/cli/signals.js +412 -0
  35. package/dist/cli/suggest.d.ts +49 -0
  36. package/dist/cli/suggest.js +135 -0
  37. package/dist/cli/tam.d.ts +9 -0
  38. package/dist/cli/tam.js +387 -0
  39. package/dist/cli/ui.d.ts +121 -0
  40. package/dist/cli/ui.js +375 -0
  41. package/dist/cli.d.ts +1 -57
  42. package/dist/cli.js +100 -5130
  43. package/dist/connector.d.ts +15 -0
  44. package/dist/connector.js +35 -0
  45. package/dist/connectors/hubspot.d.ts +3 -1
  46. package/dist/connectors/hubspot.js +10 -3
  47. package/dist/connectors/salesforce.d.ts +3 -1
  48. package/dist/connectors/salesforce.js +12 -5
  49. package/dist/connectors/signalSources.js +7 -59
  50. package/dist/icp.d.ts +15 -11
  51. package/dist/icp.js +19 -36
  52. package/dist/judge.d.ts +2 -0
  53. package/dist/judge.js +6 -0
  54. package/dist/marketClassify.d.ts +2 -0
  55. package/dist/marketClassify.js +7 -1
  56. package/dist/mcp-bin.js +2 -2
  57. package/dist/mcp.js +259 -166
  58. package/dist/schedule.d.ts +80 -2
  59. package/dist/schedule.js +254 -1
  60. package/dist/spoolFiles.d.ts +44 -0
  61. package/dist/spoolFiles.js +114 -0
  62. package/dist/types.d.ts +11 -0
  63. package/docs/api.md +73 -7
  64. package/docs/signal-spool-format.md +13 -0
  65. package/llms.txt +15 -6
  66. package/package.json +1 -1
  67. package/skills/fullstackgtm/SKILL.md +1 -1
  68. package/src/audit.ts +27 -1
  69. package/src/cli/audit.ts +447 -0
  70. package/src/cli/auth.ts +549 -0
  71. package/src/cli/call.ts +398 -0
  72. package/src/cli/capabilities.ts +215 -0
  73. package/src/cli/draft.ts +101 -0
  74. package/src/cli/enrich.ts +885 -0
  75. package/src/cli/fix.ts +372 -0
  76. package/src/cli/help.ts +664 -0
  77. package/src/cli/icp.ts +265 -0
  78. package/src/cli/init.ts +65 -0
  79. package/src/cli/market.ts +423 -0
  80. package/src/cli/plans.ts +523 -0
  81. package/src/cli/schedule.ts +526 -0
  82. package/src/cli/shared.ts +375 -0
  83. package/src/cli/signals.ts +434 -0
  84. package/src/cli/suggest.ts +151 -0
  85. package/src/cli/tam.ts +435 -0
  86. package/src/cli/ui.ts +426 -0
  87. package/src/cli.ts +99 -5829
  88. package/src/connector.ts +46 -0
  89. package/src/connectors/hubspot.ts +14 -2
  90. package/src/connectors/salesforce.ts +18 -2
  91. package/src/connectors/signalSources.ts +7 -56
  92. package/src/icp.ts +19 -35
  93. package/src/judge.ts +7 -0
  94. package/src/marketClassify.ts +8 -1
  95. package/src/mcp-bin.ts +2 -2
  96. package/src/mcp.ts +130 -57
  97. package/src/schedule.ts +310 -3
  98. package/src/spoolFiles.ts +116 -0
  99. package/src/types.ts +12 -0
package/src/connector.ts CHANGED
@@ -105,6 +105,22 @@ export type ApplyPatchPlanOptions = {
105
105
  * edited mid-apply is conflicted out instead of overwritten. Default 25.
106
106
  */
107
107
  recheckEvery?: number;
108
+ /**
109
+ * Per-operation progress as the run executes (presentation only — a
110
+ * throwing callback never affects the run). `completed` counts every
111
+ * resolved operation including skips and conflicts; `total` is the plan's
112
+ * full operation count.
113
+ */
114
+ onOperation?: (progress: ApplyProgress) => void;
115
+ };
116
+
117
+ export type ApplyProgress = {
118
+ completed: number;
119
+ total: number;
120
+ applied: number;
121
+ failed: number;
122
+ conflicts: number;
123
+ skipped: number;
108
124
  };
109
125
 
110
126
  const FIELD_WRITE_OPERATIONS = new Set(["set_field", "clear_field", "link_record"]);
@@ -313,7 +329,36 @@ export async function applyPatchPlan(
313
329
  }
314
330
  }
315
331
 
332
+ // Progress ticker state: every loop iteration below pushes at most one
333
+ // result; `lastNotified` guards the rare early-throw path so an iteration
334
+ // that pushed nothing reports nothing.
335
+ const progressCounts = { applied: 0, failed: 0, conflicts: 0, skipped: 0 };
336
+ const resultsBefore = results.length;
337
+ let lastNotified = results.length;
338
+ const notifyProgress = () => {
339
+ if (!options.onOperation || results.length === lastNotified) return;
340
+ lastNotified = results.length;
341
+ const last = results[results.length - 1];
342
+ if (last.status === "applied") progressCounts.applied += 1;
343
+ else if (last.status === "failed") progressCounts.failed += 1;
344
+ else if (last.status === "conflict") progressCounts.conflicts += 1;
345
+ else progressCounts.skipped += 1;
346
+ try {
347
+ options.onOperation({
348
+ completed: results.length - resultsBefore,
349
+ total: plan.operations.length,
350
+ ...progressCounts,
351
+ });
352
+ } catch {
353
+ // progress is presentation-only
354
+ }
355
+ };
356
+
316
357
  for (const operation of plan.operations) {
358
+ // Report the previous iteration's result (guarded: no-op if it pushed
359
+ // nothing). One-iteration lag keeps this a two-line hook instead of a
360
+ // notify after all ten push sites; the loop-exit call below flushes the last.
361
+ notifyProgress();
317
362
  const batchedResult = batched.get(operation.id);
318
363
  if (batchedResult) {
319
364
  results.push(batchedResult);
@@ -403,6 +448,7 @@ export async function applyPatchPlan(
403
448
  });
404
449
  }
405
450
  }
451
+ notifyProgress();
406
452
 
407
453
  return {
408
454
  planId: plan.id,
@@ -18,6 +18,7 @@ import type {
18
18
  GtmObjectType,
19
19
  PatchOperation,
20
20
  PatchOperationResult,
21
+ SnapshotProgress,
21
22
  } from "../types.ts";
22
23
 
23
24
  const DEFAULT_API_BASE_URL = "https://api.hubapi.com";
@@ -30,6 +31,8 @@ export type HubspotConnectorOptions = {
30
31
  apiBaseUrl?: string;
31
32
  /** Injectable fetch for testing. */
32
33
  fetchImpl?: typeof fetch;
34
+ /** Per-page snapshot-pull progress (presentation only — errors are swallowed). */
35
+ onProgress?: (progress: SnapshotProgress) => void;
33
36
  };
34
37
 
35
38
  const OBJECT_PATHS: Partial<Record<GtmObjectType, string>> = {
@@ -123,7 +126,7 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
123
126
  return map;
124
127
  }
125
128
 
126
- async function list(path: string): Promise<any[]> {
129
+ async function list(path: string, onPage?: (fetched: number) => void): Promise<any[]> {
127
130
  const results: any[] = [];
128
131
  let after: string | undefined;
129
132
  const seen = new Set<string>();
@@ -137,6 +140,11 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
137
140
  const separator = path.includes("?") ? "&" : "?";
138
141
  const data = await request(`${path}${after ? `${separator}after=${encodeURIComponent(after)}` : ""}`);
139
142
  results.push(...(data.results ?? []));
143
+ try {
144
+ onPage?.(results.length);
145
+ } catch {
146
+ // progress is presentation-only; never let it fail a pull
147
+ }
140
148
  after = data.paging?.next?.after;
141
149
  } while (after);
142
150
  return results;
@@ -149,7 +157,9 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
149
157
  withAssociations: boolean,
150
158
  ) => Promise<any[]>,
151
159
  ): Promise<CanonicalGtmSnapshot> {
152
- const owners = await list("/crm/v3/owners?limit=100");
160
+ const owners = await list("/crm/v3/owners?limit=100", (fetched) =>
161
+ options.onProgress?.({ objectType: "user", fetched }),
162
+ );
153
163
  const users: CanonicalUser[] = owners
154
164
  .filter((owner) => owner.id)
155
165
  .map((owner) => ({
@@ -310,9 +320,11 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
310
320
  }
311
321
 
312
322
  async function fetchSnapshot(): Promise<CanonicalGtmSnapshot> {
323
+ const canonicalType = { companies: "account", contacts: "contact", deals: "deal" } as const;
313
324
  return assembleSnapshot((objectType, properties, withAssociations) =>
314
325
  list(
315
326
  `/crm/v3/objects/${objectType}?limit=100&properties=${properties}${withAssociations ? "&associations=companies" : ""}`,
327
+ (fetched) => options.onProgress?.({ objectType: canonicalType[objectType], fetched }),
316
328
  ),
317
329
  );
318
330
  }
@@ -17,6 +17,7 @@ import type {
17
17
  GtmObjectType,
18
18
  PatchOperation,
19
19
  PatchOperationResult,
20
+ SnapshotProgress,
20
21
  } from "../types.ts";
21
22
 
22
23
  const DEFAULT_API_VERSION = "v59.0";
@@ -35,6 +36,8 @@ export type SalesforceConnectorOptions = {
35
36
  apiVersion?: string;
36
37
  /** Injectable fetch for testing. */
37
38
  fetchImpl?: typeof fetch;
39
+ /** Per-page snapshot-pull progress (presentation only — errors are swallowed). */
40
+ onProgress?: (progress: SnapshotProgress) => void;
38
41
  };
39
42
 
40
43
  const SOBJECT_TYPES: Partial<Record<GtmObjectType, string>> = {
@@ -161,7 +164,7 @@ export function createSalesforceConnector(
161
164
  return text ? JSON.parse(text) : null;
162
165
  }
163
166
 
164
- async function query(soql: string): Promise<any[]> {
167
+ async function query(soql: string, onPage?: (fetched: number) => void): Promise<any[]> {
165
168
  const records: any[] = [];
166
169
  let next: string | undefined = `/services/data/${apiVersion}/query?q=${encodeURIComponent(soql)}`;
167
170
  const seen = new Set<string>();
@@ -171,6 +174,11 @@ export function createSalesforceConnector(
171
174
  seen.add(next);
172
175
  const data = await request(next);
173
176
  records.push(...(data?.records ?? []));
177
+ try {
178
+ onPage?.(records.length);
179
+ } catch {
180
+ // progress is presentation-only; never let it fail a pull
181
+ }
174
182
  next = data?.nextRecordsUrl ?? undefined;
175
183
  }
176
184
  return records;
@@ -194,7 +202,12 @@ export function createSalesforceConnector(
194
202
  }
195
203
 
196
204
  async function assembleSnapshot(whereClause: string): Promise<CanonicalGtmSnapshot> {
197
- const sfUsers = await query(`SELECT ${selectFields("owners")} FROM User${whereClause}`);
205
+ const progressFor = (objectType: SnapshotProgress["objectType"]) => (fetched: number) =>
206
+ options.onProgress?.({ objectType, fetched });
207
+ const sfUsers = await query(
208
+ `SELECT ${selectFields("owners")} FROM User${whereClause}`,
209
+ progressFor("user"),
210
+ );
198
211
  const users: CanonicalUser[] = sfUsers.map((user) => {
199
212
  const id = String(readMapped(user, "owners", "id", "Id"));
200
213
  const email = stringOrUndefined(readMapped(user, "owners", "email", "Email"));
@@ -212,6 +225,7 @@ export function createSalesforceConnector(
212
225
 
213
226
  const sfAccounts = await query(
214
227
  `SELECT ${selectFields("accounts")} FROM Account${whereClause}`,
228
+ progressFor("account"),
215
229
  );
216
230
  const accounts: CanonicalAccount[] = sfAccounts.map((account) => {
217
231
  const id = String(readMapped(account, "accounts", "id", "Id"));
@@ -236,6 +250,7 @@ export function createSalesforceConnector(
236
250
 
237
251
  const sfContacts = await query(
238
252
  `SELECT ${selectFields("contacts")} FROM Contact${whereClause}`,
253
+ progressFor("contact"),
239
254
  );
240
255
  const contacts: CanonicalContact[] = sfContacts.map((contact) => {
241
256
  const id = String(readMapped(contact, "contacts", "id", "Id"));
@@ -257,6 +272,7 @@ export function createSalesforceConnector(
257
272
 
258
273
  const sfOpportunities = await query(
259
274
  `SELECT ${selectFields("deals")} FROM Opportunity${whereClause}`,
275
+ progressFor("deal"),
260
276
  );
261
277
  const deals: CanonicalDeal[] = sfOpportunities.map((opportunity) => {
262
278
  const id = String(readMapped(opportunity, "deals", "id", "Id"));
@@ -19,10 +19,11 @@
19
19
  * try/catch idiom of atsBoards/prospectSources.
20
20
  */
21
21
 
22
- import { readFileSync, readdirSync, statSync } from "node:fs";
23
- import { join, resolve } from "node:path";
22
+ import { readFileSync, statSync } from "node:fs";
23
+ import { resolve } from "node:path";
24
24
  import type { SignalBucket, StagedSignalRow } from "../signals.ts";
25
25
  import { SIGNAL_BUCKETS } from "../signals.ts";
26
+ import { parseSpoolText, spoolFilesIn } from "../spoolFiles.ts";
26
27
 
27
28
  export type SignalSourceShape = "pull" | "push";
28
29
  export type SignalSourceAuth = "none" | "api_key" | "oauth";
@@ -99,6 +100,9 @@ export const fileSource: SignalSourceConnector = {
99
100
  } catch {
100
101
  return []; // missing path: an empty source, not a crash
101
102
  }
103
+ // Container parsing lives in the shared spool reader (src/spoolFiles.ts) so
104
+ // `signals fetch --from`, `enrich ingest`, and `market observe --from` read
105
+ // the same convention; the "file source" label keeps error text unchanged.
102
106
  const files = isDir ? spoolFilesIn(abs) : [abs];
103
107
  const rows: Record<string, unknown>[] = [];
104
108
  for (const file of files) {
@@ -108,65 +112,12 @@ export const fileSource: SignalSourceConnector = {
108
112
  } catch {
109
113
  continue; // one unreadable file in a spool dir must not sink the rest
110
114
  }
111
- const trimmed = raw.trim();
112
- if (!trimmed) continue;
113
- const parsed = trimmed.startsWith("[") ? parseJsonArray(trimmed, file) : parseJsonl(trimmed, file);
114
- rows.push(...parsed);
115
+ rows.push(...parseSpoolText(raw, file, "file source"));
115
116
  }
116
117
  return rows.map((row) => coerceRow(row, this.bucket));
117
118
  },
118
119
  };
119
120
 
120
- /** Spool files in a landing-zone directory: `*.jsonl` / `*.json`, name-sorted. */
121
- function spoolFilesIn(dir: string): string[] {
122
- let names: string[];
123
- try {
124
- names = readdirSync(dir);
125
- } catch {
126
- return [];
127
- }
128
- return names
129
- .filter((name) => name.endsWith(".jsonl") || name.endsWith(".json"))
130
- .sort()
131
- .map((name) => join(dir, name));
132
- }
133
-
134
- function parseJsonArray(raw: string, path: string): Record<string, unknown>[] {
135
- let parsed: unknown;
136
- try {
137
- parsed = JSON.parse(raw);
138
- } catch (error) {
139
- throw new Error(`file source ${path}: not valid JSON (${error instanceof Error ? error.message : String(error)}).`);
140
- }
141
- if (!Array.isArray(parsed)) throw new Error(`file source ${path}: expected a JSON array of staged signal rows.`);
142
- return parsed.map((entry, index) => {
143
- if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
144
- throw new Error(`file source ${path}: row ${index} is not an object.`);
145
- }
146
- return entry as Record<string, unknown>;
147
- });
148
- }
149
-
150
- function parseJsonl(raw: string, path: string): Record<string, unknown>[] {
151
- const out: Record<string, unknown>[] = [];
152
- const lines = raw.split("\n");
153
- lines.forEach((line, index) => {
154
- const t = line.trim();
155
- if (!t) return;
156
- let parsed: unknown;
157
- try {
158
- parsed = JSON.parse(t);
159
- } catch (error) {
160
- throw new Error(`file source ${path}: line ${index} is not valid JSON (${error instanceof Error ? error.message : String(error)}).`);
161
- }
162
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
163
- throw new Error(`file source ${path}: line ${index} is not a JSON object.`);
164
- }
165
- out.push(parsed as Record<string, unknown>);
166
- });
167
- return out;
168
- }
169
-
170
121
  // ---------------------------------------------------------------------------
171
122
  // serpapi-news — funding/company signals from a news REST query (API key)
172
123
 
package/src/icp.ts CHANGED
@@ -158,26 +158,10 @@ export function icpToTheirStackFilters(icp: Icp): {
158
158
  return f;
159
159
  }
160
160
 
161
- /**
162
- * Crustdata / LinkedIn seniority vocab. ICP job levels are normalized lowercase;
163
- * Crustdata expects these exact capitalized strings (verified: "CXO",
164
- * "Vice President", "Director" appear in Crustdata's docs/examples).
165
- */
166
- const CRUSTDATA_SENIORITY: Record<string, string> = {
167
- cxo: "CXO",
168
- "c-suite": "CXO",
169
- c_suite: "CXO",
170
- founder: "Owner",
171
- owner: "Owner",
172
- partner: "Partner",
173
- vp: "Vice President",
174
- "vice president": "Vice President",
175
- head: "Director",
176
- director: "Director",
177
- manager: "Manager",
178
- senior: "Senior",
179
- entry: "Entry",
180
- };
161
+ // NOTE: a CRUSTDATA_SENIORITY vocab table lived here until 2026-07-02 — the
162
+ // `current_seniority_levels` filter is broken upstream (see the live findings
163
+ // on icpToCrustdataFilters below), so job levels are no longer sent to the
164
+ // provider at all. Resurrect the table from git history if pipe0 fixes it.
181
165
 
182
166
  /**
183
167
  * ICP industry keyword → LinkedIn industry names Crustdata filters on.
@@ -212,18 +196,22 @@ const CRUSTDATA_INDUSTRY: Record<string, string[]> = {
212
196
  /**
213
197
  * pipe0 Crustdata people-search config.filters from the ICP. `current_job_titles`
214
198
  * matches real LinkedIn title strings (case-sensitive), so keywords are Title
215
- * Cased. Seniority + industry are mapped to Crustdata's controlled vocab via the
216
- * tables above.
199
+ * Cased. Industry is mapped to Crustdata's controlled vocab via the table above.
217
200
  *
218
- * LIVE FINDINGS (2026-06-26): `current_job_titles` is confirmed working (a
219
- * titles-only RevOps search returns results); `current_title` is rejected (422).
220
- * The full ICP filter returned 0 — the prime suspect is the industry vocab
221
- * (LinkedIn v1/v2 taxonomy mismatch), now hedged by sending BOTH generations in
222
- * CRUSTDATA_INDUSTRY above. NOT yet re-confirmed end-to-end (pipe0 credits were
223
- * exhausted mid-investigation) re-run `enrich acquire --source pipe0` once
224
- * credits refill; if it still returns 0, the next suspects are the
225
- * `current_seniority_levels` shape/values and `locations`. Note fit-scoring backs
226
- * up PERSONA precision but NOT industry, so the industry filter is load-bearing.
201
+ * LIVE FINDINGS (2026-06-26, re-confirmed 2026-07-02 with fresh credits):
202
+ * `current_job_titles` works; `current_title` is rejected (422). The culprit
203
+ * that zeroed the full ICP filter is `current_seniority_levels`: ANY non-empty
204
+ * `include` returns 0 results through pipe0's people:profiles:crustdata@1
205
+ * including Crustdata's own documented values ("CXO", "Director",
206
+ * "Vice President"), lowercase, and SNAKE_CASE variants while the identical
207
+ * search without it returns results. (An array instead of the
208
+ * {include, exclude} object is a 422, so the shape was right; the filter is
209
+ * broken upstream.) So job levels are NOT sent to the provider: persona
210
+ * seniority is enforced by fit scoring (scoreProspectAgainstIcp weights
211
+ * jobLevels), which was always the precision backstop. `locations` and
212
+ * `current_employers_linkedin_industries` (both-taxonomy hedge) are live-
213
+ * verified working in combination with titles. Note fit-scoring backs up
214
+ * PERSONA precision but NOT industry, so the industry filter is load-bearing.
227
215
  */
228
216
  export function icpToCrustdataFilters(icp: Icp): Record<string, unknown> {
229
217
  const f: Record<string, unknown> = {};
@@ -231,10 +219,6 @@ export function icpToCrustdataFilters(icp: Icp): Record<string, unknown> {
231
219
  if (icp.firmographics.geos?.length) {
232
220
  f.locations = icp.firmographics.geos.map((g) => COUNTRY_NAMES[g.toLowerCase()] ?? g);
233
221
  }
234
- if (icp.persona.jobLevels?.length) {
235
- const include = [...new Set(icp.persona.jobLevels.map((l) => CRUSTDATA_SENIORITY[l.toLowerCase()]).filter(Boolean))];
236
- if (include.length) f.current_seniority_levels = { include, exclude: [] };
237
- }
238
222
  if (icp.firmographics.industries?.length) {
239
223
  const inds = [
240
224
  ...new Set(icp.firmographics.industries.flatMap((i) => CRUSTDATA_INDUSTRY[i.toLowerCase()] ?? [titleCase(i)])),
package/src/judge.ts CHANGED
@@ -612,6 +612,8 @@ export async function judgeSignals(opts: {
612
612
  promptTemplate?: string;
613
613
  llm?: LlmCallOptions;
614
614
  now?: Date;
615
+ /** Per-account progress (presentation only — a throwing callback never fails the run). */
616
+ onAccount?: (done: number, total: number, domain: string) => void;
615
617
  }): Promise<JudgeDecision[]> {
616
618
  const now = opts.now ?? new Date();
617
619
  const signalsById = new Map(opts.signals.map((s) => [s.id, s]));
@@ -629,6 +631,11 @@ export async function judgeSignals(opts: {
629
631
 
630
632
  const decisions: JudgeDecision[] = [];
631
633
  for (const [domain, signals] of byAccount) {
634
+ try {
635
+ opts.onAccount?.(decisions.length, byAccount.size, domain);
636
+ } catch {
637
+ // progress is presentation-only
638
+ }
632
639
  // Memory + fit stay gated on --with-history (scoring semantics unchanged).
633
640
  const recentlyTouched =
634
641
  opts.withHistory && opts.snapshot ? accountRecentlyTouched(domain, opts.snapshot, now) : false;
@@ -113,6 +113,8 @@ export type ClassifyMarketOptions = {
113
113
  /** Captures directory override (tests); defaults to the profile market home. */
114
114
  capturesDir?: string;
115
115
  now?: () => Date;
116
+ /** Per-vendor progress (presentation only — a throwing callback never fails the run). */
117
+ onVendor?: (done: number, total: number, vendorId: string) => void;
116
118
  };
117
119
 
118
120
  export type ClassifyMarketResult = {
@@ -143,7 +145,12 @@ export async function classifyMarket(
143
145
  const observations: MarketObservation[] = [];
144
146
  const retriedVendorIds: string[] = [];
145
147
 
146
- for (const vendorId of vendorIds) {
148
+ for (const [vendorIndex, vendorId] of vendorIds.entries()) {
149
+ try {
150
+ options.onVendor?.(vendorIndex, vendorIds.length, vendorId);
151
+ } catch {
152
+ // progress is presentation-only
153
+ }
147
154
  const vendor = config.vendors.find((candidate) => candidate.id === vendorId);
148
155
  if (!vendor) throw new Error(`Unknown vendor "${vendorId}"`);
149
156
  const vendorEntries = runEntries.filter((entry) => entry.vendorId === vendorId);
package/src/mcp-bin.ts CHANGED
@@ -9,8 +9,8 @@ const MISSING_PEER_HELP = `The MCP server needs the optional peer dependencies @
9
9
  In a project:
10
10
  npm install fullstackgtm @modelcontextprotocol/sdk zod
11
11
 
12
- Zero-install:
13
- npx -p fullstackgtm -p @modelcontextprotocol/sdk -p zod fullstackgtm-mcp`;
12
+ Zero-install (the fullstackgtm-mcp wrapper package bundles the peers):
13
+ npx -y fullstackgtm-mcp`;
14
14
 
15
15
  function isMissingPeerError(error: unknown): boolean {
16
16
  if (!(error instanceof Error)) return false;