@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 +7 -7
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/pipeline-report/generator.ts +355 -27
- package/src/pipeline-report/renderer.ts +177 -34
- package/src/pipeline-report/types.ts +64 -0
- package/src/prompts/tools/sf-query.md +20 -17
- package/src/tools/sf/formatters.ts +48 -0
- package/src/tools/sf-renderer.ts +34 -20
- package/src/tools/sf.ts +10 -2
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5xc-salesdemos/xcsh",
|
|
4
|
-
"version": "18.
|
|
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.
|
|
52
|
-
"@f5xc-salesdemos/pi-agent-core": "18.
|
|
53
|
-
"@f5xc-salesdemos/pi-ai": "18.
|
|
54
|
-
"@f5xc-salesdemos/pi-natives": "18.
|
|
55
|
-
"@f5xc-salesdemos/pi-tui": "18.
|
|
56
|
-
"@f5xc-salesdemos/pi-utils": "18.
|
|
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.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "18.72.0",
|
|
21
|
+
"commit": "ee865e583dfa21db5c5f5bf07001570c5f7f9086",
|
|
22
|
+
"shortCommit": "ee865e5",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v18.
|
|
25
|
-
"commitDate": "2026-05-
|
|
26
|
-
"buildDate": "2026-05-
|
|
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/
|
|
32
|
-
"releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.
|
|
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
|
-
|
|
235
|
-
|
|
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
|
|
466
|
+
const oliTeamScope = `OpportunityId IN (SELECT OpportunityId FROM OpportunityTeamMember WHERE ${userFilter})`;
|
|
251
467
|
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
const
|
|
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
|
-
//
|
|
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 = `
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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(`### ${
|
|
103
|
+
lines.push(`### ${title}`);
|
|
82
104
|
lines.push("");
|
|
83
|
-
lines.push("| Account |
|
|
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
|
-
|
|
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
|
|
128
|
+
lines.push(`| ${row.name} | ${fmtCurrency(row.platform)} | ${fmtCurrency(row.shape)} |`);
|
|
100
129
|
}
|
|
101
130
|
}
|
|
102
|
-
lines.push(`| **Total** | **${fmtCurrency(
|
|
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
|
-
|
|
118
|
-
return
|
|
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)
|
|
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)
|
|
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
|
|
76
|
-
2
|
|
77
|
-
3
|
|
78
|
-
4
|
|
79
|
-
5
|
|
80
|
-
6
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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.";
|
package/src/tools/sf-renderer.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
215
|
-
|
|
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 =
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
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(
|