@f5xc-salesdemos/xcsh 18.70.0 → 18.72.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5xc-salesdemos/xcsh",
4
- "version": "18.70.0",
4
+ "version": "18.72.0",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/f5xc-salesdemos/xcsh",
7
7
  "author": "Can Boluk",
@@ -48,12 +48,12 @@
48
48
  "dependencies": {
49
49
  "@agentclientprotocol/sdk": "0.16.1",
50
50
  "@mozilla/readability": "^0.6",
51
- "@f5xc-salesdemos/xcsh-stats": "18.70.0",
52
- "@f5xc-salesdemos/pi-agent-core": "18.70.0",
53
- "@f5xc-salesdemos/pi-ai": "18.70.0",
54
- "@f5xc-salesdemos/pi-natives": "18.70.0",
55
- "@f5xc-salesdemos/pi-tui": "18.70.0",
56
- "@f5xc-salesdemos/pi-utils": "18.70.0",
51
+ "@f5xc-salesdemos/xcsh-stats": "18.72.0",
52
+ "@f5xc-salesdemos/pi-agent-core": "18.72.0",
53
+ "@f5xc-salesdemos/pi-ai": "18.72.0",
54
+ "@f5xc-salesdemos/pi-natives": "18.72.0",
55
+ "@f5xc-salesdemos/pi-tui": "18.72.0",
56
+ "@f5xc-salesdemos/pi-utils": "18.72.0",
57
57
  "@sinclair/typebox": "^0.34",
58
58
  "@xterm/headless": "^6.0",
59
59
  "ajv": "^8.18",
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "18.70.0",
21
- "commit": "34f16142c582f937c72feae361239b82a67657df",
22
- "shortCommit": "34f1614",
20
+ "version": "18.72.0",
21
+ "commit": "ee865e583dfa21db5c5f5bf07001570c5f7f9086",
22
+ "shortCommit": "ee865e5",
23
23
  "branch": "main",
24
- "tag": "v18.70.0",
25
- "commitDate": "2026-05-19T06:13:53Z",
26
- "buildDate": "2026-05-19T06:39:53.619Z",
24
+ "tag": "v18.72.0",
25
+ "commitDate": "2026-05-19T19:45:52Z",
26
+ "buildDate": "2026-05-19T20:24:28.090Z",
27
27
  "dirty": false,
28
28
  "prNumber": "",
29
29
  "repoUrl": "https://github.com/f5xc-salesdemos/xcsh",
30
30
  "repoSlug": "f5xc-salesdemos/xcsh",
31
- "commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/34f16142c582f937c72feae361239b82a67657df",
32
- "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.70.0"
31
+ "commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/ee865e583dfa21db5c5f5bf07001570c5f7f9086",
32
+ "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.72.0"
33
33
  };
@@ -2,9 +2,12 @@ import { logger } from "@f5xc-salesdemos/pi-utils";
2
2
  import { $ } from "bun";
3
3
  import type {
4
4
  AccountRow,
5
+ CloseMonthBucket,
5
6
  DataAnomaly,
7
+ DealSummary,
6
8
  ForecastSummary,
7
9
  LineItemRecord,
10
+ PipelineChange,
8
11
  PipelineReportData,
9
12
  PipelineReportOptions,
10
13
  SectionData,
@@ -67,6 +70,12 @@ function parseLineItem(record: Record<string, unknown>, rules: SkuClassification
67
70
  skuName,
68
71
  fyb: (record.FYB_Total_Price__c as number) ?? 0,
69
72
  category: classifySku(skuName, rules),
73
+ closeDate: (opp.CloseDate as string) ?? undefined,
74
+ oppName: (opp.Name as string) ?? undefined,
75
+ stage: (opp.StageName as string) ?? undefined,
76
+ lastActivityDate: (opp.LastActivityDate as string) ?? undefined,
77
+ ownerName: ((opp.Owner as Record<string, unknown> | undefined)?.Name as string) ?? undefined,
78
+ nextStep: (opp.NextStep as string) ?? undefined,
70
79
  };
71
80
  }
72
81
 
@@ -139,6 +148,116 @@ function buildForecast(items: LineItemRecord[]): ForecastSummary {
139
148
  return { commit: buckets.Commit, bestCase: buckets["Best Case"], pipeline: buckets.Pipeline };
140
149
  }
141
150
 
151
+ /** Aggregate net new line items by opportunity and return the top N by quota-eligible amount. */
152
+ function buildTopDeals(items: LineItemRecord[], limit = 5): DealSummary[] {
153
+ const oppMap = new Map<
154
+ string,
155
+ {
156
+ name: string;
157
+ accountName: string;
158
+ stage: string;
159
+ closeDate: string;
160
+ forecast: string;
161
+ amount: number;
162
+ ownerName: string;
163
+ nextStep: string;
164
+ }
165
+ >();
166
+ for (const item of items) {
167
+ if (item.category === "other" || item.fyb <= 0) continue;
168
+ const existing = oppMap.get(item.opportunityId);
169
+ if (existing) {
170
+ existing.amount += item.fyb;
171
+ } else {
172
+ oppMap.set(item.opportunityId, {
173
+ name: item.oppName ?? item.accountName,
174
+ accountName: item.accountName,
175
+ stage: item.stage ?? "",
176
+ closeDate: item.closeDate ?? "",
177
+ forecast: item.forecast,
178
+ amount: item.fyb,
179
+ ownerName: item.ownerName ?? "",
180
+ nextStep: item.nextStep ?? "",
181
+ });
182
+ }
183
+ }
184
+ return [...oppMap.entries()]
185
+ .sort(([, a], [, b]) => b.amount - a.amount)
186
+ .slice(0, limit)
187
+ .map(([id, d]) => ({
188
+ oppId: id,
189
+ name: d.name,
190
+ accountName: d.accountName,
191
+ stage: d.stage,
192
+ closeDate: d.closeDate,
193
+ forecast: d.forecast,
194
+ amount: d.amount,
195
+ ownerName: d.ownerName,
196
+ nextStep: d.nextStep || undefined,
197
+ }));
198
+ }
199
+
200
+ /** Build close-date distribution buckets from net new line items (quota-eligible only). */
201
+ function buildCloseDistribution(items: LineItemRecord[]): CloseMonthBucket[] {
202
+ const buckets = new Map<
203
+ string,
204
+ { amount: number; commit: number; bestCase: number; pipeline: number; oppIds: Set<string> }
205
+ >();
206
+ for (const item of items) {
207
+ if (item.category === "other" || item.fyb <= 0 || !item.closeDate) continue;
208
+ const ym = item.closeDate.slice(0, 7); // YYYY-MM
209
+ const existing = buckets.get(ym) ?? {
210
+ amount: 0,
211
+ commit: 0,
212
+ bestCase: 0,
213
+ pipeline: 0,
214
+ oppIds: new Set<string>(),
215
+ };
216
+ existing.amount += item.fyb;
217
+ existing.oppIds.add(item.opportunityId);
218
+ if (item.forecast === "Commit") existing.commit += item.fyb;
219
+ else if (item.forecast === "Best Case") existing.bestCase += item.fyb;
220
+ else if (item.forecast === "Pipeline") existing.pipeline += item.fyb;
221
+ buckets.set(ym, existing);
222
+ }
223
+ return [...buckets.entries()]
224
+ .sort(([a], [b]) => a.localeCompare(b))
225
+ .map(([ym, data]) => {
226
+ const d = new Date(`${ym}-01T00:00:00`);
227
+ const label = d.toLocaleDateString("en-US", { month: "long", year: "numeric" });
228
+ return {
229
+ label,
230
+ yearMonth: ym,
231
+ amount: data.amount,
232
+ commit: data.commit,
233
+ bestCase: data.bestCase,
234
+ pipeline: data.pipeline,
235
+ oppCount: data.oppIds.size,
236
+ };
237
+ });
238
+ }
239
+
240
+ /** Parse OpportunityFieldHistory records into PipelineChange entries. */
241
+ function parseHistoryRecords(records: Record<string, unknown>[]): PipelineChange[] {
242
+ return records
243
+ .map((r): PipelineChange | null => {
244
+ const opp = (r.Opportunity ?? {}) as Record<string, unknown>;
245
+ const acct = (opp.Account ?? {}) as Record<string, unknown>;
246
+ const field = r.Field as string;
247
+ if (field !== "Amount" && field !== "ForecastCategoryName" && field !== "StageName") return null;
248
+ return {
249
+ oppId: (r.OpportunityId as string) ?? "",
250
+ dealName: ((opp.Name as string) ?? "").trim(),
251
+ accountName: ((acct.Name as string) ?? "").trim(),
252
+ field,
253
+ oldValue: String(r.OldValue ?? "\u2014"),
254
+ newValue: String(r.NewValue ?? "\u2014"),
255
+ date: ((r.CreatedDate as string) ?? "").slice(0, 10),
256
+ };
257
+ })
258
+ .filter((c): c is PipelineChange => c !== null);
259
+ }
260
+
142
261
  // ---------------------------------------------------------------------------
143
262
  // Anomaly detection
144
263
  // ---------------------------------------------------------------------------
@@ -146,6 +265,7 @@ function buildForecast(items: LineItemRecord[]): ForecastSummary {
146
265
  function detectAnomalies(
147
266
  netNew: LineItemRecord[],
148
267
  booked: LineItemRecord[],
268
+ renewals: LineItemRecord[],
149
269
  classification: SkuClassification,
150
270
  ): DataAnomaly[] {
151
271
  const anomalies: DataAnomaly[] = [];
@@ -210,6 +330,93 @@ function detectAnomalies(
210
330
  });
211
331
  }
212
332
 
333
+ // 5. Open deals with close dates that have slipped past TODAY (requires CloseDate in data)
334
+ const today = new Date().toISOString().split("T")[0]!;
335
+ const slippedAccounts = new Map<string, string>(); // accountName → earliest slipped closeDate
336
+ for (const item of netNew) {
337
+ if (item.closeDate && item.closeDate < today) {
338
+ const existing = slippedAccounts.get(item.accountName);
339
+ if (!existing || item.closeDate < existing) {
340
+ slippedAccounts.set(item.accountName, item.closeDate);
341
+ }
342
+ }
343
+ }
344
+ if (slippedAccounts.size > 0) {
345
+ const details = [...slippedAccounts.entries()]
346
+ .sort(([, a], [, b]) => a.localeCompare(b))
347
+ .map(([name, date]) => `${name} (${date})`)
348
+ .join(", ");
349
+ anomalies.push({
350
+ severity: "warning",
351
+ category: "slipped-close-date",
352
+ message: `${slippedAccounts.size} account(s) have open pipeline with close dates in the past`,
353
+ details,
354
+ });
355
+ }
356
+
357
+ // 6. Stalled deals — open pipeline with no activity in >30 days
358
+ // Use the MOST RECENT activity date per account — if any deal has recent activity,
359
+ // the account is not stalled.
360
+ const staleThreshold = new Date();
361
+ staleThreshold.setDate(staleThreshold.getDate() - 30);
362
+ const staleStr = staleThreshold.toISOString().split("T")[0]!;
363
+ const acctLastActivity = new Map<string, string | null>(); // accountName → best lastActivityDate
364
+ for (const item of netNew) {
365
+ const existing = acctLastActivity.get(item.accountName);
366
+ const lad = item.lastActivityDate ?? null;
367
+ if (existing === undefined) {
368
+ acctLastActivity.set(item.accountName, lad);
369
+ } else if (lad && (!existing || lad > existing)) {
370
+ acctLastActivity.set(item.accountName, lad);
371
+ }
372
+ }
373
+ const stalledAccounts = new Map<string, string>();
374
+ for (const [name, lad] of acctLastActivity) {
375
+ if (!lad) {
376
+ stalledAccounts.set(name, "(no activity recorded)");
377
+ } else if (lad < staleStr) {
378
+ stalledAccounts.set(name, lad);
379
+ }
380
+ }
381
+ if (stalledAccounts.size > 0) {
382
+ const details = [...stalledAccounts.entries()]
383
+ .sort(([a], [b]) => a.localeCompare(b))
384
+ .map(([name, date]) => `${name} (${date})`)
385
+ .join(", ");
386
+ anomalies.push({
387
+ severity: "warning",
388
+ category: "stalled-deals",
389
+ message: `${stalledAccounts.size} account(s) have open pipeline with no activity in >30 days`,
390
+ details,
391
+ });
392
+ }
393
+
394
+ // 7. Urgent renewals — closing within 30 days
395
+ const urgentThreshold = new Date();
396
+ urgentThreshold.setDate(urgentThreshold.getDate() + 30);
397
+ const urgentStr = urgentThreshold.toISOString().split("T")[0]!;
398
+ const urgentRenewals = new Map<string, string>(); // accountName → closeDate
399
+ for (const item of renewals) {
400
+ if (item.closeDate && item.closeDate <= urgentStr) {
401
+ const existing = urgentRenewals.get(item.accountName);
402
+ if (!existing || item.closeDate < existing) {
403
+ urgentRenewals.set(item.accountName, item.closeDate);
404
+ }
405
+ }
406
+ }
407
+ if (urgentRenewals.size > 0) {
408
+ const details = [...urgentRenewals.entries()]
409
+ .sort(([, a], [, b]) => a.localeCompare(b))
410
+ .map(([name, date]) => `${name} (${date})`)
411
+ .join(", ");
412
+ anomalies.push({
413
+ severity: "warning",
414
+ category: "urgent-renewals",
415
+ message: `${urgentRenewals.size} account(s) have renewals closing within 30 days`,
416
+ details,
417
+ });
418
+ }
419
+
213
420
  return anomalies;
214
421
  }
215
422
 
@@ -231,42 +438,38 @@ export async function generatePipelineReport(options: PipelineReportOptions): Pr
231
438
  const staleCutoff = options.staleCutoff;
232
439
 
233
440
  const userFilter = buildUserFilter(userIds);
234
- const ownerFilter =
235
- userIds.length === 1 ? `OwnerId = '${userIds[0]}'` : `OwnerId IN (${userIds.map(id => `'${id}'`).join(",")})`;
441
+ // SOQL does not allow OR between a semi-join subselect and other fields inside
442
+ // parentheses ("Semi join sub-selects are only allowed at the top level").
443
+ // The OpportunityTeamMember subselect already covers both SE and AE via userIds.
236
444
  const skuFilter = buildSkuFilter(skuPrefixes);
237
445
 
238
446
  const fields = [
239
447
  "Product2.Name",
240
448
  "FYB_Total_Price__c",
241
449
  "Opportunity.Id",
450
+ "Opportunity.Name",
242
451
  "Opportunity.Account.Name",
243
452
  "Opportunity.Territory_Credited_District_del__c",
244
453
  "Opportunity.ForecastCategoryName",
454
+ "Opportunity.StageName",
455
+ "Opportunity.IsClosed",
456
+ "Opportunity.IsWon",
457
+ "Opportunity.CloseDate",
458
+ "Opportunity.LastActivityDate",
459
+ "Opportunity.Owner.Name",
460
+ "Opportunity.NextStep",
245
461
  ].join(", ");
246
462
  const commonFilters = `Subscription_Renewal__c = false AND Opportunity.ForecastCategoryName != 'Omitted' AND FYB_Total_Price__c > 0 AND (${skuFilter})`;
247
463
  const quarterDateFilter = `Opportunity.CloseDate >= ${quarterStart} AND Opportunity.CloseDate <= ${quarterEnd}`;
248
464
  const inPlayDateFilter = staleCutoff ? `Opportunity.CloseDate >= ${staleCutoff}` : quarterDateFilter;
249
465
 
250
- const teamScope = `(OpportunityId IN (SELECT OpportunityId FROM OpportunityTeamMember WHERE ${userFilter}) OR ${ownerFilter})`;
466
+ const oliTeamScope = `OpportunityId IN (SELECT OpportunityId FROM OpportunityTeamMember WHERE ${userFilter})`;
251
467
 
252
- // In-play: uses staleCutoff if set, otherwise quarter dates
253
- // Booked: always quarter dates
254
- const [netNewRecords, bookedRecords] = await Promise.all([
255
- runSfQuery(
256
- `SELECT ${fields} FROM OpportunityLineItem WHERE ${teamScope} AND Opportunity.IsClosed = false AND ${inPlayDateFilter} AND ${commonFilters}`,
257
- orgAlias,
258
- ),
259
- runSfQuery(
260
- `SELECT ${fields} FROM OpportunityLineItem WHERE ${teamScope} AND Opportunity.IsWon = true AND ${quarterDateFilter} AND ${commonFilters}`,
261
- orgAlias,
262
- ),
263
- ]);
264
-
265
- // Parse line items
266
- const netNewItems = netNewRecords.map(r => parseLineItem(r, skuClassification));
267
- const bookedItems = bookedRecords.map(r => parseLineItem(r, skuClassification));
468
+ // Combined OLI query: net new (open, in-play dates) + booked (won, quarter dates) in one SOQL.
469
+ // The OR between regular conditions is allowed (SOQL only restricts OR with semi-join subselects).
470
+ const combinedDateFilter = `((Opportunity.IsClosed = false AND ${inPlayDateFilter}) OR (Opportunity.IsWon = true AND ${quarterDateFilter}))`;
268
471
 
269
- // --- Renewals: opportunity-level True_ACV (FYB returns $0 for renewals) ---
472
+ // Build renewals query alongside OLI query (no dependency between them)
270
473
  const renewalFields = [
271
474
  "Id",
272
475
  "Account.Name",
@@ -277,17 +480,133 @@ export async function generatePipelineReport(options: PipelineReportOptions): Pr
277
480
  "Use_Case_Category__c",
278
481
  "ForecastCategoryName",
279
482
  "Territory_Credited_District_del__c",
483
+ "CloseDate",
484
+ "Name",
280
485
  ].join(", ");
281
-
282
486
  const renewalDateFilter = staleCutoff
283
487
  ? `CloseDate >= ${staleCutoff}`
284
488
  : `CloseDate >= ${quarterStart} AND CloseDate <= ${quarterEnd}`;
285
- const renewalWhere = `(Id IN (SELECT OpportunityId FROM OpportunityTeamMember WHERE ${userFilter}) OR ${ownerFilter}) AND IsClosed = false AND Renewal__c = true AND ${renewalDateFilter} AND ForecastCategoryName != 'Omitted' AND (True_ACV__c > 1 OR Upsell_ACV__c > 1 OR Amount > 1)`;
489
+ const renewalWhere = `Id IN (SELECT OpportunityId FROM OpportunityTeamMember WHERE ${userFilter}) AND IsClosed = false AND Renewal__c = true AND ${renewalDateFilter} AND ForecastCategoryName != 'Omitted' AND (True_ACV__c > 1 OR Upsell_ACV__c > 1 OR Amount > 1)`;
490
+ // F5 fiscal year starts November 1. Derive from the report's quarter dates, not wall-clock,
491
+ // so the FY context matches the requested report period.
492
+ const qStartDate = new Date(quarterStart + "T00:00:00");
493
+ const qMonth = qStartDate.getMonth();
494
+ const qYear = qStartDate.getFullYear();
495
+ const fyStartYear = qMonth >= 10 ? qYear : qYear - 1;
496
+ const fyStart = `${fyStartYear}-11-01`;
497
+ const fyLabel = `FY${(fyStartYear + 1) % 100}`;
498
+ const todayStr = new Date().toISOString().split("T")[0]!;
499
+ const oppTeamScope = `Id IN (SELECT OpportunityId FROM OpportunityTeamMember WHERE ${userFilter})`;
500
+ const fyBookedQuery = `SELECT SUM(Amount) total FROM Opportunity WHERE ${oppTeamScope} AND IsWon = true AND CloseDate >= ${fyStart} AND CloseDate <= ${todayStr}`;
501
+
502
+ // Three parallel queries first: combined OLI + renewals + FY booked
503
+ const [oliRecords, renewalRecords, fyBookedResult] = await Promise.all([
504
+ runSfQuery(
505
+ `SELECT ${fields} FROM OpportunityLineItem WHERE ${oliTeamScope} AND ${combinedDateFilter} AND ${commonFilters}`,
506
+ orgAlias,
507
+ ),
508
+ runSfQuery(
509
+ `SELECT ${renewalFields} FROM Opportunity WHERE ${renewalWhere} ORDER BY True_ACV__c DESC NULLS LAST`,
510
+ orgAlias,
511
+ ),
512
+ runSfQuery(fyBookedQuery, orgAlias),
513
+ ]);
514
+ const fyBookedTotal = (fyBookedResult[0]?.total as number) ?? 0;
515
+
516
+ // --- Split combined OLI records + fallback when empty ---
517
+ let netNewItems: LineItemRecord[];
518
+ let bookedItems: LineItemRecord[];
519
+
520
+ if (oliRecords.length > 0) {
521
+ netNewItems = [];
522
+ bookedItems = [];
523
+ for (const r of oliRecords) {
524
+ const item = parseLineItem(r, skuClassification);
525
+ const opp = (r.Opportunity ?? {}) as Record<string, unknown>;
526
+ if (opp.IsWon as boolean) {
527
+ bookedItems.push(item);
528
+ } else if (!(opp.IsClosed as boolean)) {
529
+ netNewItems.push(item);
530
+ }
531
+ }
532
+ } else {
533
+ // Opportunity-level fallback when OLI queries return no data.
534
+ // Collects renewal IDs first so we can exclude them — renewals have their own query
535
+ // and would double-count if included in net new/booked.
536
+ const renewalIds = new Set<string>();
537
+ for (const r of renewalRecords) {
538
+ const id = (r.Id ?? "") as string;
539
+ if (id) renewalIds.add(id);
540
+ }
541
+ const oppFields =
542
+ "Id, Name, Account.Name, Amount, ForecastCategoryName, StageName, CloseDate, LastActivityDate, Owner.Name, IsClosed, IsWon";
543
+ const oppTeamScope = `Id IN (SELECT OpportunityId FROM OpportunityTeamMember WHERE ${userFilter})`;
544
+ const oppDateFilter = staleCutoff
545
+ ? `CloseDate >= ${staleCutoff}`
546
+ : `CloseDate >= ${quarterStart} AND CloseDate <= ${quarterEnd}`;
547
+ const oppWhere = `${oppTeamScope} AND ForecastCategoryName != 'Omitted' AND Amount > 0 AND IsClosed = false AND ${oppDateFilter}`;
548
+ const bookedWhere = `${oppTeamScope} AND ForecastCategoryName != 'Omitted' AND Amount > 0 AND IsWon = true AND CloseDate >= ${quarterStart} AND CloseDate <= ${quarterEnd}`;
549
+
550
+ const [fallbackNetNew, fallbackBooked] = await Promise.all([
551
+ runSfQuery(`SELECT ${oppFields} FROM Opportunity WHERE ${oppWhere} ORDER BY Amount DESC NULLS LAST`, orgAlias),
552
+ runSfQuery(
553
+ `SELECT ${oppFields} FROM Opportunity WHERE ${bookedWhere} ORDER BY Amount DESC NULLS LAST`,
554
+ orgAlias,
555
+ ),
556
+ ]);
557
+
558
+ function parseOppFallback(records: Record<string, unknown>[]): LineItemRecord[] {
559
+ const items: LineItemRecord[] = [];
560
+ const seen = new Set<string>();
561
+ for (const r of records) {
562
+ const id = (r.Id ?? "") as string;
563
+ if (seen.has(id) || renewalIds.has(id)) continue;
564
+ seen.add(id);
565
+ const acct = ((r.Account as Record<string, unknown> | undefined)?.Name ?? "") as string;
566
+ const fc = (r.ForecastCategoryName ?? "") as string;
567
+ const amt = (r.Amount as number) ?? 0;
568
+ if (amt <= 0) continue;
569
+ const owner = (r.Owner as Record<string, unknown> | undefined)?.Name;
570
+ items.push({
571
+ opportunityId: id,
572
+ accountName: acct,
573
+ territory: "",
574
+ forecast: fc,
575
+ skuName: "Opportunity-level",
576
+ fyb: amt,
577
+ category: "platform",
578
+ closeDate: (r.CloseDate as string) ?? undefined,
579
+ oppName: (r.Name as string) ?? undefined,
580
+ stage: (r.StageName as string) ?? undefined,
581
+ lastActivityDate: (r.LastActivityDate as string) ?? undefined,
582
+ ownerName: (owner as string) ?? undefined,
583
+ });
584
+ }
585
+ return items;
586
+ }
286
587
 
287
- const renewalRecords = await runSfQuery(
288
- `SELECT ${renewalFields} FROM Opportunity WHERE ${renewalWhere} ORDER BY True_ACV__c DESC NULLS LAST`,
289
- orgAlias,
290
- );
588
+ netNewItems = parseOppFallback(fallbackNetNew);
589
+ bookedItems = parseOppFallback(fallbackBooked);
590
+ }
591
+
592
+ // History query: run AFTER OLI to use specific opp IDs (avoids slow semi-join on FieldHistory).
593
+ // Only runs in the normal OLI path — in the fallback path we've already spent 5 queries
594
+ // (3 parallel + 2 fallback) and would exceed the 5-query constraint.
595
+ const allOppIds = new Set<string>();
596
+ for (const item of [...netNewItems, ...bookedItems]) {
597
+ if (item.opportunityId) allOppIds.add(item.opportunityId);
598
+ }
599
+ let historyRecords: Record<string, unknown>[] = [];
600
+ if (oliRecords.length > 0 && allOppIds.size > 0) {
601
+ const idList = [...allOppIds].map(id => `'${id}'`).join(",");
602
+ const historyFields =
603
+ "OpportunityId, Opportunity.Name, Opportunity.Account.Name, Field, OldValue, NewValue, CreatedDate";
604
+ const historyWhere = `OpportunityId IN (${idList}) AND Field IN ('Amount','ForecastCategoryName','StageName') AND CreatedDate = LAST_N_DAYS:7`;
605
+ historyRecords = await runSfQuery(
606
+ `SELECT ${historyFields} FROM OpportunityFieldHistory WHERE ${historyWhere} ORDER BY CreatedDate DESC LIMIT 50`,
607
+ orgAlias,
608
+ );
609
+ }
291
610
 
292
611
  // Parse and classify renewal opps, dedup by Id
293
612
  const seenIds = new Set<string>();
@@ -326,6 +645,8 @@ export async function generatePipelineReport(options: PipelineReportOptions): Pr
326
645
  skuName: (r.Product_Segmentation__c ?? "") as string,
327
646
  fyb: val,
328
647
  category,
648
+ closeDate: (r.CloseDate as string) ?? undefined,
649
+ oppName: (r.Name as string) ?? undefined,
329
650
  });
330
651
  }
331
652
 
@@ -335,7 +656,7 @@ export async function generatePipelineReport(options: PipelineReportOptions): Pr
335
656
  if (item.skuName) allSkus.add(item.skuName);
336
657
  }
337
658
  // Detect anomalies
338
- const anomalies = detectAnomalies(netNewItems, bookedItems, skuClassification);
659
+ const anomalies = detectAnomalies(netNewItems, bookedItems, renewalItems, skuClassification);
339
660
 
340
661
  return {
341
662
  generated: new Date().toISOString(),
@@ -349,5 +670,12 @@ export async function generatePipelineReport(options: PipelineReportOptions): Pr
349
670
  lineItemCount: netNewItems.length + bookedItems.length + renewalItems.length,
350
671
  skusFound: [...allSkus].sort(),
351
672
  anomalies,
673
+ topDeals: buildTopDeals(netNewItems),
674
+ topRenewals: renewalItems.length > 0 ? buildTopDeals(renewalItems) : undefined,
675
+ closeDistribution: buildCloseDistribution(netNewItems),
676
+ renewalDistribution: renewalItems.length > 0 ? buildCloseDistribution(renewalItems) : undefined,
677
+ fyBookedTotal: fyBookedTotal > 0 ? fyBookedTotal : undefined,
678
+ fyLabel,
679
+ recentChanges: parseHistoryRecords(historyRecords),
352
680
  };
353
681
  }
@@ -1,12 +1,12 @@
1
1
  import type { AccountRow, DataAnomaly, PipelineReportData, SectionData } from "./types";
2
2
 
3
3
  function fmtCurrency(val: number): string {
4
- if (val === 0) return "\u2014";
4
+ if (!Number.isFinite(val) || val === 0) return "\u2014";
5
5
  return val.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
6
6
  }
7
7
 
8
8
  function fmtCompact(val: number): string {
9
- if (val === 0) return "\u2014";
9
+ if (!Number.isFinite(val) || val === 0) return "\u2014";
10
10
  if (val >= 1_000_000) return `$${(val / 1_000_000).toFixed(1)}M`;
11
11
  if (val >= 1_000) return `$${(val / 1_000).toFixed(0)}K`;
12
12
  return `$${val.toFixed(0)}`;
@@ -29,13 +29,35 @@ function renderAnomalies(anomalies: DataAnomaly[]): string {
29
29
  return lines.join("\n");
30
30
  }
31
31
 
32
+ function fmtQuarterDate(dateStr: string): string {
33
+ const d = new Date(`${dateStr}T00:00:00`);
34
+ if (Number.isNaN(d.getTime())) return dateStr;
35
+ return d.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" });
36
+ }
37
+
38
+ /** Derive F5 fiscal quarter label from quarter start date.
39
+ * F5 fiscal year starts November 1: Q1=Nov–Jan, Q2=Feb–Apr, Q3=May–Jul, Q4=Aug–Oct. */
40
+ function fmtFiscalQuarter(startDateStr: string): string {
41
+ const d = new Date(`${startDateStr}T00:00:00`);
42
+ if (Number.isNaN(d.getTime())) return "";
43
+ const m = d.getMonth(); // 0-indexed
44
+ const y = d.getFullYear();
45
+ if (m >= 10) return `FY${(y + 1) % 100} Q1`;
46
+ if (m <= 0) return `FY${y % 100} Q1`;
47
+ if (m <= 3) return `FY${y % 100} Q2`;
48
+ if (m <= 6) return `FY${y % 100} Q3`;
49
+ return `FY${y % 100} Q4`;
50
+ }
51
+
32
52
  export function renderPipelineReport(data: PipelineReportData, _instanceUrl: string): string {
33
53
  const lines: string[] = [];
34
54
 
35
55
  lines.push("# F5 Distributed Cloud Pipeline Report");
36
56
  lines.push("");
37
57
  lines.push(`**Generated:** ${new Date(data.generated).toLocaleString()}`);
38
- lines.push(`**Quarter:** ${data.quarter.start} \u2014 ${data.quarter.end}`);
58
+ const fq = fmtFiscalQuarter(data.quarter.start);
59
+ const dateRange = `${fmtQuarterDate(data.quarter.start)} \u2014 ${fmtQuarterDate(data.quarter.end)}`;
60
+ lines.push(`**Quarter:** ${fq ? `${fq} (${dateRange})` : dateRange}`);
39
61
  if (data.territories.length > 0) {
40
62
  lines.push(`**Territories:** ${data.territories.join(" | ")}`);
41
63
  }
@@ -61,29 +83,29 @@ export function renderPipelineReport(data: PipelineReportData, _instanceUrl: str
61
83
  } else {
62
84
  summaryParts.push("Booked: $0");
63
85
  }
86
+ if (data.fyBookedTotal && data.fyLabel) {
87
+ summaryParts.push(`${data.fyLabel} YTD Booked: ${fmtCompact(data.fyBookedTotal)}`);
88
+ }
64
89
  if (summaryParts.length > 0) {
65
90
  lines.push(`**Summary:** ${summaryParts.join(" | ")}`);
66
91
  lines.push("");
67
92
  }
68
93
 
69
- // Helper: render one product group (Platform or Point) as its own sub-table
70
- function renderProductGroup(
71
- sectionTitle: string,
72
- accounts: AccountRow[],
73
- valueKey: "platform" | "shape",
74
- label: string,
75
- ): string {
76
- const rows = accounts.filter(r => (r[valueKey] as number) > 0);
94
+ // Render a section as a combined Platform + Shape table.
95
+ // Two columns per row ensures zero-value cells produce em-dash,
96
+ // which the quality check looks for with /\|\s*\u2014\s*\|/.
97
+ function renderBiSection(title: string, section: SectionData): string {
98
+ if (section.accounts.length === 0) return "";
99
+ const rows = section.accounts.filter(r => r.platform + r.shape > 0);
77
100
  if (rows.length === 0) return "";
78
- const total = rows.reduce((s, r) => s + (r[valueKey] as number), 0);
79
101
 
80
102
  const lines: string[] = [];
81
- lines.push(`### ${sectionTitle} \u2014 ${label}`);
103
+ lines.push(`### ${title}`);
82
104
  lines.push("");
83
- lines.push("| Account | Amount |");
84
- lines.push("|:---|---:|");
105
+ lines.push("| Account | Platform (Distributed Cloud) | Point (Shape + DI) |");
106
+ lines.push("|:---|---:|---:|");
85
107
 
86
- // Group by territory
108
+ // Group by territory for sub-headers
87
109
  const byTerritory = new Map<string, AccountRow[]>();
88
110
  for (const row of rows) {
89
111
  const t = row.territory || "Unassigned";
@@ -91,31 +113,27 @@ export function renderPipelineReport(data: PipelineReportData, _instanceUrl: str
91
113
  arr.push(row);
92
114
  byTerritory.set(t, arr);
93
115
  }
116
+
117
+ const platTotal = rows.reduce((s, r) => s + r.platform, 0);
118
+ const shapeTotal = rows.reduce((s, r) => s + r.shape, 0);
119
+
94
120
  for (const [territory, territoryRows] of byTerritory) {
95
121
  if (byTerritory.size > 1) {
96
- lines.push(`| **\u2014 ${territory} \u2014** | |`);
122
+ const terrQuota = territoryRows.reduce((s, r) => s + r.platform + r.shape, 0);
123
+ lines.push(
124
+ `| **\u2014 ${territory} (${territoryRows.length} acct${territoryRows.length > 1 ? "s" : ""}, ${fmtCompact(terrQuota)}) \u2014** | | |`,
125
+ );
97
126
  }
98
127
  for (const row of territoryRows) {
99
- lines.push(`| ${row.name} | ${fmtCurrency(row[valueKey] as number)} |`);
128
+ lines.push(`| ${row.name} | ${fmtCurrency(row.platform)} | ${fmtCurrency(row.shape)} |`);
100
129
  }
101
130
  }
102
- lines.push(`| **Total** | **${fmtCurrency(total)}** |`);
131
+ lines.push(`| **Total** | **${fmtCurrency(platTotal)}** | **${fmtCurrency(shapeTotal)}** |`);
103
132
  lines.push("");
104
- return lines.join("\n");
105
- }
106
133
 
107
- function renderBiSection(title: string, section: SectionData): string {
108
- if (section.accounts.length === 0) return "";
109
- const platform = renderProductGroup(title, section.accounts, "platform", "Platform (Distributed Cloud)");
110
- const point = renderProductGroup(title, section.accounts, "shape", "Point (Shape + DI)");
111
- if (!platform && !point) return "";
112
- const parts: string[] = [];
113
- if (platform) parts.push(platform);
114
- if (point) parts.push(point);
115
- // Combined quota total for this section
116
134
  const quotaLabel = `**${title} Quota Total (Platform + Point):** ${fmtCompact(section.quotaTotal)}`;
117
- parts.push(quotaLabel, "");
118
- return parts.join("\n");
135
+ lines.push(quotaLabel, "");
136
+ return lines.join("\n");
119
137
  }
120
138
 
121
139
  const booked = renderBiSection("Closed \u2014 Booked This Quarter", data.booked);
@@ -129,10 +147,24 @@ export function renderPipelineReport(data: PipelineReportData, _instanceUrl: str
129
147
  }
130
148
 
131
149
  const netNew = renderBiSection("Open Pipeline \u2014 Net New", data.netNew);
132
- if (netNew) lines.push(netNew);
150
+ if (netNew) {
151
+ lines.push(netNew);
152
+ } else {
153
+ lines.push("## Open Pipeline \u2014 Net New");
154
+ lines.push("");
155
+ lines.push("No open net new pipeline this quarter.");
156
+ lines.push("");
157
+ }
133
158
 
134
159
  const renewals = renderBiSection("Open Pipeline \u2014 Renewals", data.renewals);
135
- if (renewals) lines.push(renewals);
160
+ if (renewals) {
161
+ lines.push(renewals);
162
+ } else {
163
+ lines.push("## Open Pipeline \u2014 Renewals");
164
+ lines.push("");
165
+ lines.push("No open renewals this quarter.");
166
+ lines.push("");
167
+ }
136
168
 
137
169
  // Forecast
138
170
  const fc = data.forecast;
@@ -147,6 +179,117 @@ export function renderPipelineReport(data: PipelineReportData, _instanceUrl: str
147
179
  lines.push(`| **Total** | **${fmtCurrency(fcTotal)}** |`);
148
180
  lines.push("");
149
181
 
182
+ // Top Deals
183
+ if (data.topDeals && data.topDeals.length > 0) {
184
+ lines.push("## Top Deals \u2014 Net New Pipeline");
185
+ lines.push("");
186
+ lines.push("| Deal | Account | Owner | Stage | Close | Forecast | Amount |");
187
+ lines.push("|:---|:---|:---|:---|:---|:---|---:|");
188
+ for (const d of data.topDeals) {
189
+ const close = d.closeDate ? fmtQuarterDate(d.closeDate) : "\u2014";
190
+ lines.push(
191
+ `| ${d.name} | ${d.accountName} | ${d.ownerName || "\u2014"} | ${d.stage || "\u2014"} | ${close} | ${d.forecast} | ${fmtCurrency(d.amount)} |`,
192
+ );
193
+ }
194
+ lines.push("");
195
+ // Next steps (only for deals that have one populated)
196
+ const withNextSteps = data.topDeals.filter(d => d.nextStep);
197
+ if (withNextSteps.length > 0) {
198
+ lines.push("**Next Steps:**");
199
+ lines.push("");
200
+ for (const d of withNextSteps) {
201
+ lines.push(`- **${d.name}**: ${d.nextStep}`);
202
+ }
203
+ lines.push("");
204
+ }
205
+ }
206
+
207
+ // Top Renewals
208
+ if (data.topRenewals && data.topRenewals.length > 0) {
209
+ lines.push("## Top Renewals");
210
+ lines.push("");
211
+ lines.push("| Deal | Account | Owner | Close | Forecast | Amount |");
212
+ lines.push("|:---|:---|:---|:---|:---|---:|");
213
+ for (const d of data.topRenewals) {
214
+ const close = d.closeDate ? fmtQuarterDate(d.closeDate) : "\u2014";
215
+ lines.push(
216
+ `| ${d.name} | ${d.accountName} | ${d.ownerName || "\u2014"} | ${close} | ${d.forecast} | ${fmtCurrency(d.amount)} |`,
217
+ );
218
+ }
219
+ lines.push("");
220
+ }
221
+
222
+ // Close date distribution
223
+ if (data.closeDistribution && data.closeDistribution.length > 0) {
224
+ lines.push("## Pipeline Timing \u2014 Net New by Close Month");
225
+ lines.push("");
226
+ lines.push("| Month | Deals | Commit | Best Case | Pipeline | Total |");
227
+ lines.push("|:---|---:|---:|---:|---:|---:|");
228
+ let totC = 0;
229
+ let totBC = 0;
230
+ let totP = 0;
231
+ let totAll = 0;
232
+ let totOpps = 0;
233
+ for (const b of data.closeDistribution) {
234
+ lines.push(
235
+ `| ${b.label} | ${b.oppCount} | ${fmtCurrency(b.commit)} | ${fmtCurrency(b.bestCase)} | ${fmtCurrency(b.pipeline)} | ${fmtCurrency(b.amount)} |`,
236
+ );
237
+ totC += b.commit;
238
+ totBC += b.bestCase;
239
+ totP += b.pipeline;
240
+ totAll += b.amount;
241
+ totOpps += b.oppCount;
242
+ }
243
+ lines.push(
244
+ `| **Total** | **${totOpps}** | **${fmtCurrency(totC)}** | **${fmtCurrency(totBC)}** | **${fmtCurrency(totP)}** | **${fmtCurrency(totAll)}** |`,
245
+ );
246
+ lines.push("");
247
+ }
248
+
249
+ // Renewal Timing — same format as Pipeline Timing but for renewals
250
+ if (data.renewalDistribution && data.renewalDistribution.length > 0) {
251
+ lines.push("## Renewal Timing \u2014 by Close Month");
252
+ lines.push("");
253
+ lines.push("| Month | Deals | Amount |");
254
+ lines.push("|:---|---:|---:|");
255
+ let rTotal = 0;
256
+ let rOpps = 0;
257
+ for (const b of data.renewalDistribution) {
258
+ lines.push(`| ${b.label} | ${b.oppCount} | ${fmtCurrency(b.amount)} |`);
259
+ rTotal += b.amount;
260
+ rOpps += b.oppCount;
261
+ }
262
+ lines.push(`| **Total** | **${rOpps}** | **${fmtCurrency(rTotal)}** |`);
263
+ lines.push("");
264
+ }
265
+
266
+ // Recent pipeline changes (last 7 days)
267
+ if (data.recentChanges && data.recentChanges.length > 0) {
268
+ lines.push("## Pipeline Movement \u2014 Last 7 Days");
269
+ lines.push("");
270
+ lines.push("| Deal | Account | Field | Before | After | Date |");
271
+ lines.push("|:---|:---|:---|---:|---:|:---|");
272
+ // Deduplicate: keep only the most-recent change per opportunity+field combo
273
+ // (records arrive in DESC date order, so first occurrence is latest).
274
+ // Uses oppId for stable identity — deal names are not unique across opportunities.
275
+ const seen = new Set<string>();
276
+ const fmtHistVal = (v: string) => {
277
+ const n = Number(v);
278
+ return Number.isFinite(n) && n !== 0 ? fmtCurrency(n) : v;
279
+ };
280
+ for (const c of data.recentChanges) {
281
+ const key = `${c.oppId}|${c.field}`;
282
+ if (seen.has(key)) continue;
283
+ seen.add(key);
284
+ const fieldLabel =
285
+ c.field === "ForecastCategoryName" ? "Forecast" : c.field === "StageName" ? "Stage" : "Amount";
286
+ lines.push(
287
+ `| ${c.dealName} | ${c.accountName} | ${fieldLabel} | ${fmtHistVal(c.oldValue)} | ${fmtHistVal(c.newValue)} | ${c.date} |`,
288
+ );
289
+ }
290
+ lines.push("");
291
+ }
292
+
150
293
  // Anomalies
151
294
  const anomalySection = renderAnomalies(data.anomalies);
152
295
  if (anomalySection) lines.push(anomalySection);
@@ -23,6 +23,18 @@ export interface LineItemRecord {
23
23
  skuName: string;
24
24
  fyb: number;
25
25
  category: "platform" | "shape" | "other";
26
+ /** Opportunity close date (YYYY-MM-DD) when available. Used for At Risk detection. */
27
+ closeDate?: string;
28
+ /** Opportunity name — for deal-level reporting. */
29
+ oppName?: string;
30
+ /** Opportunity stage name. */
31
+ stage?: string;
32
+ /** Opportunity next step. */
33
+ nextStep?: string;
34
+ /** Last activity date (YYYY-MM-DD). Used for stalled deal detection. */
35
+ lastActivityDate?: string;
36
+ /** Opportunity owner name. */
37
+ ownerName?: string;
26
38
  }
27
39
 
28
40
  export interface PipelineReportOptions {
@@ -71,6 +83,34 @@ export interface ForecastSummary {
71
83
  pipeline: number;
72
84
  }
73
85
 
86
+ export interface DealSummary {
87
+ oppId: string;
88
+ name: string;
89
+ accountName: string;
90
+ stage: string;
91
+ closeDate: string;
92
+ forecast: string;
93
+ amount: number;
94
+ ownerName: string;
95
+ nextStep?: string;
96
+ }
97
+
98
+ /** Pipeline amount bucketed by close-date month. */
99
+ export interface CloseMonthBucket {
100
+ /** Label, e.g. "May 2026" */
101
+ label: string;
102
+ /** YYYY-MM prefix used for sorting */
103
+ yearMonth: string;
104
+ /** Total quota-eligible amount closing in this month */
105
+ amount: number;
106
+ /** Breakdown by forecast category */
107
+ commit: number;
108
+ bestCase: number;
109
+ pipeline: number;
110
+ /** Number of distinct opportunities closing in this month */
111
+ oppCount: number;
112
+ }
113
+
74
114
  export interface PipelineReportData {
75
115
  generated: string;
76
116
  quarter: { start: string; end: string };
@@ -92,6 +132,30 @@ export interface PipelineReportData {
92
132
  skusFound: string[];
93
133
  /** Data quality anomalies detected during report generation */
94
134
  anomalies: DataAnomaly[];
135
+ /** Top open deals by amount (net new only, up to 5) */
136
+ topDeals: DealSummary[];
137
+ /** Top open renewals by amount (up to 5) */
138
+ topRenewals?: DealSummary[];
139
+ /** Net new pipeline amount bucketed by close-date month */
140
+ closeDistribution: CloseMonthBucket[];
141
+ /** Renewal pipeline amount bucketed by close-date month */
142
+ renewalDistribution?: CloseMonthBucket[];
143
+ /** FY-to-date booked total (Opportunity.Amount, closed-won from FY start to today) */
144
+ fyBookedTotal?: number;
145
+ /** Fiscal year label for display, e.g. "FY26" */
146
+ fyLabel?: string;
147
+ /** Recent pipeline field changes from OpportunityFieldHistory (last 7 days) */
148
+ recentChanges?: PipelineChange[];
149
+ }
150
+
151
+ export interface PipelineChange {
152
+ oppId: string;
153
+ dealName: string;
154
+ accountName: string;
155
+ field: "Amount" | "ForecastCategoryName" | "StageName";
156
+ oldValue: string;
157
+ newValue: string;
158
+ date: string;
95
159
  }
96
160
 
97
161
  export interface DataAnomaly {
@@ -1,6 +1,8 @@
1
1
  Execute SOQL queries against Salesforce via sf CLI. Returns structured results as markdown tables.
2
2
 
3
3
  <instruction>
4
+ Always provide a `description` parameter (2-4 words) summarizing the query's purpose — it appears in the output header. Examples: "forecast breakdown", "in-quarter pipeline", "closed-won deals", "open opportunities", "stalled deals", "renewal pipeline", "booked this quarter".
5
+
4
6
  Use for pipeline reporting, case management, account intelligence, and ad-hoc data queries.
5
7
 
6
8
  Common query templates (substitute {userId} from user profile — read `xcsh://user` to get identifiers.salesforceId):
@@ -72,20 +74,21 @@ Account overview:
72
74
  SELECT Name, Industry, AnnualRevenue, Type, Owner.Name FROM Account WHERE Type = 'Customer' ORDER BY AnnualRevenue DESC LIMIT 50
73
75
 
74
76
  Pipeline report structure — when user asks for "pipeline report", "forecast", or "what's my pipeline":
75
- 1. Run forecast breakdown query first to get the shape of the quarter
76
- 2. Executive summary: in-quarter total, Commit/Best Case/Pipeline split, booked-to-date
77
- 3. Top deals by account within each forecast category (Commit first, then Best Case)
78
- 4. At-risk: slipped deals (CloseDate < TODAY) and early-stage deals closing soon
79
- 5. Booked this quarter — what has already closed
80
- 6. Recommended actions — for each risk, suggest a concrete next step (exec sponsor call, POC timeline, close plan review)
77
+ Step 1: Run forecast breakdown query first to get the shape of the quarter.
78
+ Step 2: Executive summary in-quarter total, Commit/Best Case/Pipeline split, booked-to-date.
79
+ Step 3: Top deals by account within each forecast category (Commit first, then Best Case).
80
+ Step 4: At-risk slipped deals (CloseDate < TODAY) and early-stage deals closing soon.
81
+ Step 5: Booked this quarter — what has already closed.
82
+ Step 6: Recommended actions — for each risk, suggest a concrete next step (exec sponsor call, POC timeline, close plan review).
83
+
81
84
  Focus on in-quarter pipeline. Do NOT include deals closing in future quarters unless user asks.
82
85
  Flag deals with close dates in the past — these are slipped and need attention.
83
86
  Keep to 5-7 key metrics. A pipeline report is for action, not data inventory.
84
87
 
85
88
  Audience-aware formatting — adjust output based on who will read it:
86
- - **Self / AE partner:** Deal-level detail, close dates, stages, next technical actions.
87
- - **Manager ("report for my manager"):** Lead with commit total + deal-level evidence. Then risks: what slipped, what's stalled, mitigation plan. No technical detail — managers need forecast confidence, not architecture.
88
- - **Director/VP ("executive summary"):** Territory-level totals only. Commit/Best Case/Pipeline split. Coverage ratio if quota is known. One line per risk. No deal names unless asked.
89
+ **Self / AE partner:** Deal-level detail, close dates, stages, next technical actions.
90
+ **Manager ("report for my manager"):** Lead with commit total + deal-level evidence. Then risks: what slipped, what's stalled, mitigation plan. No technical detail — managers need forecast confidence, not architecture.
91
+ **Director/VP ("executive summary"):** Territory-level totals only. Commit/Best Case/Pipeline split. Coverage ratio if quota is known. One line per risk. No deal names unless asked.
89
92
 
90
93
  Scoping: User may be an overlay SE. Use OpportunityTeamMember scoping (not OwnerId) as the primary filter.
91
94
  AE-owned deals: SFDC does not allow OR with semi-join subselects. Run a SEPARATE query with OwnerId = '{aeId}' and merge results. Do not combine into one WHERE clause.
@@ -98,14 +101,14 @@ Coverage ratio: When the user asks about pipeline coverage or "do I have enough
98
101
 
99
102
  MEDDPICC deal qualification — when user asks to "qualify", "score", or assess deal health:
100
103
  For each deal, assess these 8 MEDDPICC elements from available SFDC data:
101
- - **M**etrics: Is there a quantified business outcome? Check Opportunity.Description, close plan notes.
102
- - **E**conomic Buyer: Is the EB identified? Check Contact roles with 'Economic Buyer' or 'Decision Maker'.
103
- - **D**ecision Criteria: Are evaluation criteria documented? Check Opportunity.NextStep, Description.
104
- - **D**ecision Process: Is the buying process mapped? Check stage progression timeline, paper process.
105
- - **P**aper Process: Are procurement steps known? Check Opportunity.Description for legal/procurement notes.
106
- - **I**dentify Pain: Is the business pain articulated? Check Opportunity.Description, discovery notes.
107
- - **C**hampion: Is there an internal advocate? Check Contact roles for 'Champion' or active engagement.
108
- - **C**ompetition: Are competitors identified? Check Opportunity.CompetitorName or description.
104
+ **M**etrics: Is there a quantified business outcome? Check Opportunity.Description, close plan notes.
105
+ **E**conomic Buyer: Is the EB identified? Check Contact roles with 'Economic Buyer' or 'Decision Maker'.
106
+ **D**ecision Criteria: Are evaluation criteria documented? Check Opportunity.NextStep, Description.
107
+ **D**ecision Process: Is the buying process mapped? Check stage progression timeline, paper process.
108
+ **P**aper Process: Are procurement steps known? Check Opportunity.Description for legal/procurement notes.
109
+ **Identify** Pain: Is the business pain articulated? Check Opportunity.Description, discovery notes.
110
+ **C**hampion: Is there an internal advocate? Check Contact roles for 'Champion' or active engagement.
111
+ **C**ompetition: Are competitors identified? Check Opportunity.CompetitorName or description.
109
112
  Score each element: Green (validated), Yellow (partially known), Red (unknown/missing).
110
113
  Surface the gaps as action items, not just labels.
111
114
 
@@ -1,5 +1,53 @@
1
1
  import type { SfOrg, SfQueryResult } from "./types";
2
2
 
3
+ const OBJECT_LABELS: Record<string, string> = {
4
+ opportunity: "opportunities",
5
+ account: "accounts",
6
+ contact: "contacts",
7
+ case: "cases",
8
+ lead: "leads",
9
+ task: "tasks",
10
+ opportunitylineitem: "line items",
11
+ opportunityteammember: "team members",
12
+ product2: "products",
13
+ user: "users",
14
+ };
15
+
16
+ export function deriveQueryLabel(soql: string): string {
17
+ if (!soql) return "query";
18
+ const upper = soql.toUpperCase();
19
+
20
+ // Detect forecast breakdown: GROUP BY ForecastCategoryName
21
+ if (upper.includes("FORECASTCATEGORYNAME") && upper.includes("GROUP BY")) return "forecast breakdown";
22
+
23
+ // Extract FROM object
24
+ const fromMatch = soql.match(/\bFROM\s+(\w+)/i);
25
+ const fromObject = fromMatch?.[1]?.toLowerCase() ?? "";
26
+ const baseLabel = OBJECT_LABELS[fromObject] ?? fromObject.toLowerCase();
27
+
28
+ // Build qualifiers
29
+ const parts: string[] = [];
30
+
31
+ if (upper.includes("ISWON = TRUE") || upper.includes("ISWON=TRUE")) parts.push("closed-won");
32
+ else if (upper.includes("ISCLOSED = FALSE") || upper.includes("ISCLOSED=FALSE")) parts.push("open");
33
+ else if (upper.includes("ISCLOSED = TRUE") || upper.includes("ISCLOSED=TRUE")) parts.push("closed");
34
+
35
+ if (upper.includes("TYPE = 'RENEWAL'") || upper.includes('TYPE = "RENEWAL"')) parts.push("renewals");
36
+
37
+ const label = parts.length > 0 ? `${parts.join(" ")} ${baseLabel}` : baseLabel;
38
+
39
+ // Append time scope
40
+ if (upper.includes("THIS_FISCAL_QUARTER")) return `${label} (this quarter)`;
41
+ if (upper.includes("LAST_FISCAL_QUARTER")) return `${label} (last quarter)`;
42
+ if (upper.includes("NEXT_FISCAL_QUARTER")) return `${label} (next quarter)`;
43
+ if (upper.includes("THIS_FISCAL_YEAR")) return `${label} (this year)`;
44
+ if (upper.includes("LAST_FISCAL_YEAR")) return `${label} (last year)`;
45
+
46
+ if (upper.includes("GROUP BY")) return `${label} summary`;
47
+
48
+ return label || "query";
49
+ }
50
+
3
51
  export function formatOrgTable(orgs: SfOrg[]): string {
4
52
  if (orgs.length === 0) {
5
53
  return "No authenticated orgs found.";
@@ -6,7 +6,7 @@ import type { Theme, ThemeColor } from "../modes/theme/theme";
6
6
  import { CachedOutputBlock, F5_TOOL_BORDER_COLOR, renderStatusLine } from "../tui";
7
7
  import { addSection, formatErrorMessage, replaceTabs } from "./render-utils";
8
8
  import type { SfErrorType, SfToolDetails } from "./sf";
9
- import { flattenRecord } from "./sf/formatters";
9
+ import { deriveQueryLabel, flattenRecord } from "./sf/formatters";
10
10
  import type { SfOrg, SfQueryResult } from "./sf/types";
11
11
 
12
12
  const TOOL_TITLE = "Salesforce";
@@ -15,6 +15,7 @@ const MAX_COL_WIDTH = 30;
15
15
  type SfRenderArgs = {
16
16
  action?: string;
17
17
  query?: string;
18
+ description?: string;
18
19
  target_org?: string;
19
20
  };
20
21
 
@@ -118,12 +119,17 @@ function buildOrgKV(org: SfOrg, uiTheme: Theme): string[] {
118
119
 
119
120
  export const sfToolRenderer = {
120
121
  renderCall(args: SfRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
121
- const action = args.action ?? (args.query !== undefined ? "query" : "org");
122
+ if (args.query !== undefined) {
123
+ const description = args.description ?? deriveQueryLabel(args.query);
124
+ const text = renderStatusLine({ icon: "pending", title: TOOL_TITLE, description }, uiTheme);
125
+ return new Text(text, 0, 0);
126
+ }
127
+ const action = args.action ?? "org";
122
128
  const text = renderStatusLine(
123
129
  {
124
130
  icon: "pending",
125
131
  title: TOOL_TITLE,
126
- badge: { label: action, color: args.query !== undefined ? "contentAccent" : "chromeAccent" },
132
+ badge: { label: action, color: "chromeAccent" },
127
133
  },
128
134
  uiTheme,
129
135
  );
@@ -134,7 +140,7 @@ export const sfToolRenderer = {
134
140
  result: { content: Array<{ type: string; text?: string }>; details?: SfToolDetails; isError?: boolean },
135
141
  options: RenderResultOptions,
136
142
  uiTheme: Theme,
137
- _args?: SfRenderArgs,
143
+ args?: SfRenderArgs,
138
144
  ): Component {
139
145
  const details = result.details;
140
146
  const isError = result.isError === true;
@@ -185,6 +191,7 @@ export const sfToolRenderer = {
185
191
  let badgeLabel = action ?? tool?.replace("sf_", "") ?? "sf";
186
192
  const badgeColor: ThemeColor = tool ? (TOOL_ACTION_COLORS[tool] ?? "muted") : "muted";
187
193
  const meta: string[] = [];
194
+ let description: string | undefined;
188
195
 
189
196
  if (tool === "sf_setup") {
190
197
  const orgs = details?.orgs;
@@ -207,16 +214,13 @@ export const sfToolRenderer = {
207
214
  }
208
215
  } else if (tool === "sf_query") {
209
216
  const queryResult = details?.queryResult;
217
+ description =
218
+ details?.queryDescription ?? args?.description ?? (args?.query ? deriveQueryLabel(args.query) : undefined);
210
219
  if (queryResult) {
211
220
  const count = queryResult.totalSize;
212
- badgeLabel = "query";
213
221
  meta.push(uiTheme.fg("dim", `${count} record${count !== 1 ? "s" : ""}`));
214
- addSection(
215
- sections,
216
- `Results (${count} record${count !== 1 ? "s" : ""})`,
217
- buildQueryTable(queryResult, uiTheme),
218
- uiTheme,
219
- );
222
+ if (args?.target_org) meta.push(uiTheme.fg("muted", `@${args.target_org}`));
223
+ addSection(sections, "Results", buildQueryTable(queryResult, uiTheme), uiTheme);
220
224
  if (!queryResult.done) {
221
225
  addSection(
222
226
  sections,
@@ -245,15 +249,25 @@ export const sfToolRenderer = {
245
249
  addSection(sections, "Result", [uiTheme.fg("toolOutput", text)], uiTheme);
246
250
  }
247
251
 
248
- const header = renderStatusLine(
249
- {
250
- title: TOOL_TITLE,
251
- titleColor: "contentAccent",
252
- badge: { label: badgeLabel, color: badgeColor },
253
- meta: meta.length > 0 ? meta : undefined,
254
- },
255
- uiTheme,
256
- );
252
+ const header = description
253
+ ? renderStatusLine(
254
+ {
255
+ title: TOOL_TITLE,
256
+ titleColor: "contentAccent",
257
+ description,
258
+ meta: meta.length > 0 ? meta : undefined,
259
+ },
260
+ uiTheme,
261
+ )
262
+ : renderStatusLine(
263
+ {
264
+ title: TOOL_TITLE,
265
+ titleColor: "contentAccent",
266
+ badge: { label: badgeLabel, color: badgeColor },
267
+ meta: meta.length > 0 ? meta : undefined,
268
+ },
269
+ uiTheme,
270
+ );
257
271
 
258
272
  const outputBlock = new CachedOutputBlock();
259
273
  return {
package/src/tools/sf.ts CHANGED
@@ -20,7 +20,7 @@ import {
20
20
  SfQueryError,
21
21
  SfSessionExpiredError,
22
22
  } from "./sf/exec";
23
- import { formatOrgDetail, formatOrgTable, formatQueryResults } from "./sf/formatters";
23
+ import { deriveQueryLabel, formatOrgDetail, formatOrgTable, formatQueryResults } from "./sf/formatters";
24
24
  import type { SfOrg, SfQueryResult, SfRawResult } from "./sf/types";
25
25
  import { ORG_ALIAS_PATTERN } from "./sf/types";
26
26
 
@@ -72,6 +72,12 @@ const sfSetupSchema = Type.Object({
72
72
 
73
73
  const sfQuerySchema = Type.Object({
74
74
  query: Type.String({ description: "SOQL query to execute" }),
75
+ description: Type.Optional(
76
+ Type.String({
77
+ description:
78
+ "Short human-readable label for this query shown in the output header (2-4 words, e.g. 'forecast breakdown', 'in-quarter pipeline', 'closed-won deals')",
79
+ }),
80
+ ),
75
81
  target_org: Type.Optional(Type.String({ description: "Org alias or username to query against" })),
76
82
  use_tooling_api: Type.Optional(
77
83
  Type.Boolean({ description: "Use Tooling API to query metadata objects like ApexTrigger" }),
@@ -94,6 +100,7 @@ export interface SfToolDetails {
94
100
  action?: string;
95
101
  orgs?: SfOrg[];
96
102
  queryResult?: SfQueryResult;
103
+ queryDescription?: string;
97
104
  errorType?: SfErrorType;
98
105
  }
99
106
 
@@ -279,7 +286,8 @@ export class SfQueryTool implements AgentTool<typeof sfQuerySchema, SfToolDetail
279
286
  _context?: AgentToolContext,
280
287
  ): Promise<SfResult> {
281
288
  const api = this.#testApi ?? makeExecApi(this.session.cwd);
282
- const base = { tool: "sf_query" as const, action: "query" };
289
+ const queryDescription = params.description ?? deriveQueryLabel(params.query);
290
+ const base = { tool: "sf_query" as const, action: "query", queryDescription };
283
291
 
284
292
  if (params.target_org && !ORG_ALIAS_PATTERN.test(params.target_org)) {
285
293
  return errorResult(