fullstackgtm 0.44.0 → 0.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +293 -1
- package/INSTALL_FOR_AGENTS.md +13 -11
- package/README.md +45 -24
- package/dist/audit.d.ts +3 -1
- package/dist/audit.js +29 -2
- package/dist/backfill.d.ts +86 -0
- package/dist/backfill.js +251 -0
- package/dist/bin.js +4 -1
- package/dist/cli/audit.d.ts +15 -0
- package/dist/cli/audit.js +392 -0
- package/dist/cli/auth.d.ts +82 -0
- package/dist/cli/auth.js +607 -0
- package/dist/cli/backfill.d.ts +1 -0
- package/dist/cli/backfill.js +125 -0
- package/dist/cli/backfillRuns.d.ts +1 -0
- package/dist/cli/backfillRuns.js +187 -0
- package/dist/cli/call.d.ts +1 -0
- package/dist/cli/call.js +387 -0
- package/dist/cli/capabilities.d.ts +30 -0
- package/dist/cli/capabilities.js +197 -0
- package/dist/cli/draft.d.ts +7 -0
- package/dist/cli/draft.js +87 -0
- package/dist/cli/enrich.d.ts +8 -0
- package/dist/cli/enrich.js +806 -0
- package/dist/cli/fix.d.ts +33 -0
- package/dist/cli/fix.js +346 -0
- package/dist/cli/help.d.ts +25 -0
- package/dist/cli/help.js +646 -0
- package/dist/cli/icp.d.ts +7 -0
- package/dist/cli/icp.js +229 -0
- package/dist/cli/init.d.ts +7 -0
- package/dist/cli/init.js +58 -0
- package/dist/cli/market.d.ts +1 -0
- package/dist/cli/market.js +391 -0
- package/dist/cli/plans.d.ts +5 -0
- package/dist/cli/plans.js +456 -0
- package/dist/cli/schedule.d.ts +8 -0
- package/dist/cli/schedule.js +479 -0
- package/dist/cli/shared.d.ts +71 -0
- package/dist/cli/shared.js +342 -0
- package/dist/cli/signals.d.ts +7 -0
- package/dist/cli/signals.js +412 -0
- package/dist/cli/suggest.d.ts +49 -0
- package/dist/cli/suggest.js +135 -0
- package/dist/cli/tam.d.ts +9 -0
- package/dist/cli/tam.js +387 -0
- package/dist/cli/ui.d.ts +142 -0
- package/dist/cli/ui.js +427 -0
- package/dist/cli.d.ts +1 -57
- package/dist/cli.js +123 -5157
- package/dist/connector.d.ts +23 -0
- package/dist/connector.js +47 -0
- package/dist/connectors/hubspot.d.ts +10 -1
- package/dist/connectors/hubspot.js +244 -10
- package/dist/connectors/hubspotAuth.js +5 -1
- package/dist/connectors/linkedin.js +14 -14
- package/dist/connectors/salesforce.d.ts +10 -1
- package/dist/connectors/salesforce.js +39 -6
- package/dist/connectors/signalSources.js +7 -59
- package/dist/connectors/stripe.d.ts +37 -0
- package/dist/connectors/stripe.js +103 -31
- package/dist/enrich.d.ts +7 -0
- package/dist/enrich.js +7 -0
- package/dist/health.d.ts +11 -69
- package/dist/health.js +4 -134
- package/dist/healthScore.d.ts +71 -0
- package/dist/healthScore.js +143 -0
- package/dist/icp.d.ts +15 -11
- package/dist/icp.js +19 -36
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/judge.d.ts +2 -0
- package/dist/judge.js +6 -0
- package/dist/llm.d.ts +29 -0
- package/dist/llm.js +206 -0
- package/dist/market.d.ts +39 -1
- package/dist/market.js +116 -44
- package/dist/marketClassify.d.ts +11 -1
- package/dist/marketClassify.js +17 -2
- package/dist/marketTaxonomy.d.ts +6 -1
- package/dist/marketTaxonomy.js +4 -2
- package/dist/mcp-bin.js +31 -2
- package/dist/mcp.js +372 -166
- package/dist/progress.d.ts +96 -0
- package/dist/progress.js +142 -0
- package/dist/runReport.d.ts +24 -0
- package/dist/runReport.js +139 -4
- package/dist/schedule.d.ts +80 -2
- package/dist/schedule.js +254 -1
- package/dist/spoolFiles.d.ts +44 -0
- package/dist/spoolFiles.js +114 -0
- package/dist/types.d.ts +29 -1
- package/docs/api.md +75 -8
- package/docs/architecture.md +2 -0
- package/docs/linkedin-connector-spec.md +1 -1
- package/docs/signal-spool-format.md +13 -0
- package/llms.txt +18 -9
- package/package.json +10 -3
- package/skills/fullstackgtm/SKILL.md +2 -1
- package/src/audit.ts +27 -1
- package/src/backfill.ts +340 -0
- package/src/bin.ts +5 -1
- package/src/cli/audit.ts +447 -0
- package/src/cli/auth.ts +669 -0
- package/src/cli/backfill.ts +156 -0
- package/src/cli/backfillRuns.ts +198 -0
- package/src/cli/call.ts +414 -0
- package/src/cli/capabilities.ts +215 -0
- package/src/cli/draft.ts +101 -0
- package/src/cli/enrich.ts +908 -0
- package/src/cli/fix.ts +373 -0
- package/src/cli/help.ts +702 -0
- package/src/cli/icp.ts +265 -0
- package/src/cli/init.ts +65 -0
- package/src/cli/market.ts +423 -0
- package/src/cli/plans.ts +524 -0
- package/src/cli/schedule.ts +526 -0
- package/src/cli/shared.ts +392 -0
- package/src/cli/signals.ts +434 -0
- package/src/cli/suggest.ts +151 -0
- package/src/cli/tam.ts +435 -0
- package/src/cli/ui.ts +497 -0
- package/src/cli.ts +122 -5855
- package/src/connector.ts +66 -0
- package/src/connectors/hubspot.ts +273 -9
- package/src/connectors/hubspotAuth.ts +5 -1
- package/src/connectors/linkedin.ts +14 -14
- package/src/connectors/salesforce.ts +51 -3
- package/src/connectors/signalSources.ts +7 -56
- package/src/connectors/stripe.ts +154 -34
- package/src/enrich.ts +13 -0
- package/src/health.ts +14 -213
- package/src/healthScore.ts +223 -0
- package/src/icp.ts +19 -35
- package/src/index.ts +28 -1
- package/src/judge.ts +7 -0
- package/src/llm.ts +239 -0
- package/src/market.ts +157 -44
- package/src/marketClassify.ts +26 -2
- package/src/marketTaxonomy.ts +10 -2
- package/src/mcp-bin.ts +34 -2
- package/src/mcp.ts +270 -63
- package/src/progress.ts +197 -0
- package/src/runReport.ts +159 -4
- package/src/schedule.ts +310 -3
- package/src/spoolFiles.ts +116 -0
- package/src/types.ts +32 -2
- package/docs/dx-punch-list.md +0 -87
- package/docs/roadmap-to-1.0.md +0 -211
package/src/mcp.ts
CHANGED
|
@@ -38,6 +38,8 @@ const { StdioServerTransport } = await importPeer<typeof import("@modelcontextpr
|
|
|
38
38
|
);
|
|
39
39
|
const { z } = await importPeer<typeof import("zod/v4")>("zod/v4");
|
|
40
40
|
import { auditSnapshot, defaultPolicy } from "./audit.ts";
|
|
41
|
+
import { assertCanonicalSnapshot } from "./cli/shared.ts";
|
|
42
|
+
import { nearest } from "./cli/suggest.ts";
|
|
41
43
|
import { loadConfig, mergePolicy, resolveConfiguredRules } from "./config.ts";
|
|
42
44
|
import { applyPatchPlan } from "./connector.ts";
|
|
43
45
|
import { createHubspotConnector } from "./connectors/hubspot.ts";
|
|
@@ -51,6 +53,8 @@ import {
|
|
|
51
53
|
import { generateDemoSnapshot } from "./demo.ts";
|
|
52
54
|
import type { FieldMappings } from "./mappings.ts";
|
|
53
55
|
import { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
|
|
56
|
+
import { verifyApprovalDigests } from "./integrity.ts";
|
|
57
|
+
import { createFilePlanStore, type StoredPlan } from "./planStore.ts";
|
|
54
58
|
import { builtinAuditRules } from "./rules.ts";
|
|
55
59
|
import { sampleSnapshot } from "./sampleData.ts";
|
|
56
60
|
import { normalizeTranscript, parseCall } from "./calls.ts";
|
|
@@ -123,8 +127,10 @@ async function connectorFor(provider: string): Promise<GtmConnector> {
|
|
|
123
127
|
}
|
|
124
128
|
return createStripeConnector({ getApiKey: () => key });
|
|
125
129
|
}
|
|
130
|
+
const providerSuggestion = nearest(provider.toLowerCase(), ["hubspot", "salesforce", "stripe"], 3);
|
|
126
131
|
throw new Error(
|
|
127
|
-
`Unknown provider: ${provider}
|
|
132
|
+
`Unknown provider: ${provider}.${providerSuggestion ? ` Did you mean ${providerSuggestion}?` : ""} ` +
|
|
133
|
+
"Supported providers: hubspot, salesforce, stripe",
|
|
128
134
|
);
|
|
129
135
|
}
|
|
130
136
|
|
|
@@ -138,9 +144,10 @@ async function readSnapshot(
|
|
|
138
144
|
return connector.fetchSnapshot();
|
|
139
145
|
}
|
|
140
146
|
if (!inputPath) return sampleSnapshot;
|
|
141
|
-
return
|
|
142
|
-
readFileSync(resolve(process.cwd(), inputPath), "utf8"),
|
|
143
|
-
|
|
147
|
+
return assertCanonicalSnapshot(
|
|
148
|
+
JSON.parse(readFileSync(resolve(process.cwd(), inputPath), "utf8")),
|
|
149
|
+
inputPath,
|
|
150
|
+
);
|
|
144
151
|
}
|
|
145
152
|
|
|
146
153
|
function packageVersion() {
|
|
@@ -152,15 +159,29 @@ function packageVersion() {
|
|
|
152
159
|
}
|
|
153
160
|
}
|
|
154
161
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
162
|
+
type ToolDefinition = {
|
|
163
|
+
name: string;
|
|
164
|
+
// Whether the tool can write to a CRM. Everything else reads, or writes
|
|
165
|
+
// only local workspace state (market_observe appends to the local
|
|
166
|
+
// observation store).
|
|
167
|
+
writesCrm: boolean;
|
|
168
|
+
config: { title: string; description: string; inputSchema?: Record<string, unknown> };
|
|
169
|
+
// Args are validated by the SDK against config.inputSchema before the
|
|
170
|
+
// handler runs; the loose typing here is what lets one table drive a
|
|
171
|
+
// registration loop instead of per-call generics.
|
|
172
|
+
handler: (args: any) => Promise<ReturnType<typeof content>> | ReturnType<typeof content>;
|
|
173
|
+
};
|
|
160
174
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
175
|
+
// Single registration table. startMcpServer() registers exactly this list,
|
|
176
|
+
// and fullstackgtm_capabilities reports its names from the same array — the
|
|
177
|
+
// advertised inventory cannot drift from what is actually registered.
|
|
178
|
+
// (A hand-written parallel tool list was a core defect of the first pass at
|
|
179
|
+
// a capabilities tool, which reported 4 of the 8 registered tools.)
|
|
180
|
+
const toolDefinitions: ToolDefinition[] = [
|
|
181
|
+
{
|
|
182
|
+
name: "fullstackgtm_audit",
|
|
183
|
+
writesCrm: false,
|
|
184
|
+
config: {
|
|
164
185
|
title: "GTM Ops Audit",
|
|
165
186
|
description:
|
|
166
187
|
"Run a dry-run GTM hygiene audit and return a reviewable patch plan. " +
|
|
@@ -176,23 +197,23 @@ export async function startMcpServer() {
|
|
|
176
197
|
staleDealDays: z.number().int().positive().optional(),
|
|
177
198
|
},
|
|
178
199
|
},
|
|
179
|
-
async ({ provider, inputPath, configPath, rules, output, today, staleDealDays }) => {
|
|
200
|
+
handler: async ({ provider, inputPath, configPath, rules, output, today, staleDealDays }) => {
|
|
180
201
|
const loaded = configPath ? loadConfig(configPath) : null;
|
|
181
202
|
const policy = mergePolicy(defaultPolicy(today), loaded?.config);
|
|
182
203
|
if (today) policy.today = today;
|
|
183
204
|
if (staleDealDays !== undefined) policy.staleDealDays = staleDealDays;
|
|
184
205
|
const ruleSet = await resolveConfiguredRules(loaded);
|
|
185
206
|
const selected = rules?.length
|
|
186
|
-
? ruleSet.filter((rule) => rules.includes(rule.id))
|
|
207
|
+
? ruleSet.filter((rule: { id: string }) => rules.includes(rule.id))
|
|
187
208
|
: ruleSet;
|
|
188
209
|
const plan = auditSnapshot(await readSnapshot(provider, inputPath), policy, selected);
|
|
189
210
|
return content(output === "markdown" ? patchPlanToMarkdown(plan) : plan);
|
|
190
211
|
},
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
{
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
name: "fullstackgtm_suggest",
|
|
215
|
+
writesCrm: false,
|
|
216
|
+
config: {
|
|
196
217
|
title: "Suggest Placeholder Values",
|
|
197
218
|
description:
|
|
198
219
|
"Derive values for a plan's requires_human_* placeholder operations from snapshot " +
|
|
@@ -204,16 +225,16 @@ export async function startMcpServer() {
|
|
|
204
225
|
inputPath: z.string().optional(),
|
|
205
226
|
},
|
|
206
227
|
},
|
|
207
|
-
async ({ planPath, provider, inputPath }) => {
|
|
228
|
+
handler: async ({ planPath, provider, inputPath }) => {
|
|
208
229
|
const plan = JSON.parse(readFileSync(resolve(process.cwd(), planPath), "utf8")) as PatchPlan;
|
|
209
230
|
const snapshot = await readSnapshot(provider, inputPath);
|
|
210
231
|
return content({ suggestions: suggestValues(plan, snapshot) });
|
|
211
232
|
},
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
{
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
name: "fullstackgtm_call_parse",
|
|
236
|
+
writesCrm: false,
|
|
237
|
+
config: {
|
|
217
238
|
title: "Parse Call Transcript",
|
|
218
239
|
description:
|
|
219
240
|
"Parse a call transcript (Speaker:/[Speaker]: lines or Granola utterance JSON) into " +
|
|
@@ -230,7 +251,7 @@ export async function startMcpServer() {
|
|
|
230
251
|
model: z.string().optional(),
|
|
231
252
|
},
|
|
232
253
|
},
|
|
233
|
-
async ({ transcript, transcriptPath, title, source, extractor, model }) => {
|
|
254
|
+
handler: async ({ transcript, transcriptPath, title, source, extractor, model }) => {
|
|
234
255
|
const raw =
|
|
235
256
|
transcript ??
|
|
236
257
|
(transcriptPath ? readFileSync(resolve(process.cwd(), transcriptPath), "utf8") : null);
|
|
@@ -255,11 +276,11 @@ export async function startMcpServer() {
|
|
|
255
276
|
}
|
|
256
277
|
return content(parseCall(raw, { title, sourceSystem: source }));
|
|
257
278
|
},
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
{
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
name: "fullstackgtm_resolve",
|
|
282
|
+
writesCrm: false,
|
|
283
|
+
config: {
|
|
263
284
|
title: "Resolve Record (create gate)",
|
|
264
285
|
description:
|
|
265
286
|
"Before creating a CRM record, check whether it already exists. Returns a verdict " +
|
|
@@ -276,34 +297,38 @@ export async function startMcpServer() {
|
|
|
276
297
|
inputPath: z.string().optional(),
|
|
277
298
|
},
|
|
278
299
|
},
|
|
279
|
-
async ({ objectType, name, domain, email, accountId, provider, inputPath }) => {
|
|
300
|
+
handler: async ({ objectType, name, domain, email, accountId, provider, inputPath }) => {
|
|
280
301
|
const snapshot = await readSnapshot(provider, inputPath);
|
|
281
302
|
return content(resolveRecord(snapshot, { objectType, name, domain, email, accountId }));
|
|
282
303
|
},
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
{
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
name: "fullstackgtm_rules",
|
|
307
|
+
writesCrm: false,
|
|
308
|
+
config: {
|
|
288
309
|
title: "List Audit Rules",
|
|
289
310
|
description: "List the built-in deterministic audit rules with ids and descriptions.",
|
|
290
311
|
inputSchema: {},
|
|
291
312
|
},
|
|
292
|
-
async () => {
|
|
313
|
+
handler: async () => {
|
|
293
314
|
return content(
|
|
294
315
|
builtinAuditRules.map(({ id, title, description }) => ({ id, title, description })),
|
|
295
316
|
);
|
|
296
317
|
},
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
{
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
name: "fullstackgtm_apply",
|
|
321
|
+
writesCrm: true,
|
|
322
|
+
config: {
|
|
302
323
|
title: "Apply Approved Patch Operations",
|
|
303
324
|
description:
|
|
304
325
|
"Apply explicitly approved operations from a patch plan through a provider " +
|
|
305
326
|
"connector. Operations not listed in approvedOperationIds are never written, " +
|
|
306
|
-
"and requires_human_* placeholders need a value override."
|
|
327
|
+
"and requires_human_* placeholders need a value override. When the plan is in " +
|
|
328
|
+
"the local plan store (saved via `audit --save`), approvals are verified against " +
|
|
329
|
+
"the store's HMAC approval digests — ids not approved with `plans approve` are " +
|
|
330
|
+
"refused — and the run is recorded onto the stored plan so `plans show` and " +
|
|
331
|
+
"`audit-log export` include it.",
|
|
307
332
|
inputSchema: {
|
|
308
333
|
provider: z.enum(["hubspot", "salesforce"]),
|
|
309
334
|
planPath: z.string(),
|
|
@@ -312,21 +337,135 @@ export async function startMcpServer() {
|
|
|
312
337
|
output: z.enum(["json", "markdown"]).optional(),
|
|
313
338
|
},
|
|
314
339
|
},
|
|
315
|
-
async ({ provider, planPath, approvedOperationIds, valueOverrides, output }) => {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
) as
|
|
340
|
+
handler: async ({ provider, planPath, approvedOperationIds, valueOverrides, output }) => {
|
|
341
|
+
// The file may be a raw PatchPlan (audit --out) or a StoredPlan envelope
|
|
342
|
+
// (a file straight out of the plan-store directory).
|
|
343
|
+
const parsed = JSON.parse(readFileSync(resolve(process.cwd(), planPath), "utf8")) as unknown;
|
|
344
|
+
const filePlan = isStoredPlanFile(parsed) ? parsed.plan : (parsed as PatchPlan);
|
|
345
|
+
if (!filePlan || !Array.isArray(filePlan.operations)) {
|
|
346
|
+
throw new Error(`${planPath} is not a patch plan (expected { id, operations: [...] }).`);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Governance parity with CLI apply: when this plan id exists in the local
|
|
350
|
+
// plan store, the STORE is the source of truth — the approved-id set must
|
|
351
|
+
// come from `plans approve` (which HMAC-signed each approved op), the
|
|
352
|
+
// signatures are re-verified here, and the run is recorded back onto the
|
|
353
|
+
// plan so audit-log export sees MCP applies exactly like CLI applies.
|
|
354
|
+
const store = createFilePlanStore();
|
|
355
|
+
const stored =
|
|
356
|
+
typeof filePlan.id === "string" && /^[\w.-]+$/.test(filePlan.id)
|
|
357
|
+
? await store.get(filePlan.id)
|
|
358
|
+
: null;
|
|
359
|
+
|
|
360
|
+
let plan: PatchPlan;
|
|
361
|
+
let effectiveOverrides: Record<string, unknown>;
|
|
362
|
+
if (stored) {
|
|
363
|
+
if (stored.status !== "approved") {
|
|
364
|
+
throw new Error(
|
|
365
|
+
`Plan ${filePlan.id} is ${stored.status}; approve operations first with ` +
|
|
366
|
+
`\`fullstackgtm plans approve ${filePlan.id} --operations <ids|all>\`.`,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
// Downgrade guard (same as CLI apply): an approved plan with no
|
|
370
|
+
// signatures either predates 0.26 or had approvalDigests stripped to
|
|
371
|
+
// skip the integrity check. Refuse rather than trust the file.
|
|
372
|
+
if (stored.approvedOperationIds.length > 0 && !stored.approvalDigests) {
|
|
373
|
+
throw new Error(
|
|
374
|
+
`Refusing to apply plan ${filePlan.id}: it was approved without integrity signatures ` +
|
|
375
|
+
"(approved before 0.26.0, or its signatures were removed). Re-approve it with " +
|
|
376
|
+
`\`fullstackgtm plans approve ${filePlan.id} --operations <ids|all>\`.`,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
// Never widen: every id the tool wants applied must already be
|
|
380
|
+
// store-approved. (A subset is fine — narrowing never writes more.)
|
|
381
|
+
const storeApproved = new Set(stored.approvedOperationIds);
|
|
382
|
+
const unapproved = approvedOperationIds.filter((id: string) => !storeApproved.has(id));
|
|
383
|
+
if (unapproved.length > 0) {
|
|
384
|
+
throw new Error(
|
|
385
|
+
`Refusing to apply plan ${filePlan.id}: operation(s) ${unapproved.join(", ")} were never ` +
|
|
386
|
+
"approved in the plan store. Approve them first with " +
|
|
387
|
+
`\`fullstackgtm plans approve ${filePlan.id} --operations <ids>\`.`,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
// Integrity gate: verify against the EFFECTIVE overrides (stored ∪ tool
|
|
391
|
+
// args), so a tool-supplied value that changes what the human approved
|
|
392
|
+
// is treated as tamper, not a live override — what gets written must
|
|
393
|
+
// equal what was signed.
|
|
394
|
+
effectiveOverrides = { ...stored.valueOverrides, ...(valueOverrides ?? {}) };
|
|
395
|
+
const verification = verifyApprovalDigests(
|
|
396
|
+
stored.plan.operations,
|
|
397
|
+
stored.approvedOperationIds,
|
|
398
|
+
effectiveOverrides,
|
|
399
|
+
stored.approvalDigests,
|
|
400
|
+
);
|
|
401
|
+
if (!verification.ok) {
|
|
402
|
+
const detail =
|
|
403
|
+
verification.reason === "no_key"
|
|
404
|
+
? "the plan-signing key is missing (was this plan approved on another machine?). Re-approve it here with `fullstackgtm plans approve`."
|
|
405
|
+
: `these operations differ from what was approved: ${verification.tampered.join(", ")}. ` +
|
|
406
|
+
"If you want a different value, set it at approval (`plans approve --value <op>=<v>`) and re-approve; " +
|
|
407
|
+
"otherwise the plan was edited after approval — review and re-approve.";
|
|
408
|
+
throw new Error(`Refusing to apply plan ${filePlan.id}: ${detail}`);
|
|
409
|
+
}
|
|
410
|
+
plan = stored.plan; // apply what was signed, not what the file says now
|
|
411
|
+
} else {
|
|
412
|
+
// External plan file, not in the store: no digests exist to verify, but
|
|
413
|
+
// approvals must still reference real operations in the plan content.
|
|
414
|
+
plan = filePlan;
|
|
415
|
+
const known = new Set(plan.operations.map((operation) => operation.id));
|
|
416
|
+
const unknown = approvedOperationIds.filter((id: string) => !known.has(id));
|
|
417
|
+
if (unknown.length > 0) {
|
|
418
|
+
throw new Error(
|
|
419
|
+
`Plan ${planPath} has no operation(s) ${unknown.join(", ")} — approvedOperationIds ` +
|
|
420
|
+
"must reference operations in the plan.",
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
effectiveOverrides = valueOverrides ?? {};
|
|
424
|
+
}
|
|
425
|
+
|
|
319
426
|
const run = await applyPatchPlan(await connectorFor(provider), plan, {
|
|
320
427
|
approvedOperationIds,
|
|
321
|
-
valueOverrides,
|
|
428
|
+
valueOverrides: effectiveOverrides,
|
|
322
429
|
});
|
|
323
|
-
return content(output === "markdown" ? formatPatchPlanRun(run) : run);
|
|
324
|
-
},
|
|
325
|
-
);
|
|
326
430
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
431
|
+
// Persist the run (same data the CLI records) so runs[] and plan status
|
|
432
|
+
// update and audit-log export includes MCP applies. External plan files
|
|
433
|
+
// have no store entry to update — say so instead of silently dropping it.
|
|
434
|
+
let runRecorded = false;
|
|
435
|
+
if (stored) {
|
|
436
|
+
await store.recordRun(plan.id, run);
|
|
437
|
+
runRecorded = true;
|
|
438
|
+
}
|
|
439
|
+
const governance = {
|
|
440
|
+
planSource: stored ? ("store" as const) : ("external" as const),
|
|
441
|
+
approvalDigestsVerified: stored !== null,
|
|
442
|
+
runRecorded,
|
|
443
|
+
...(stored
|
|
444
|
+
? {}
|
|
445
|
+
: {
|
|
446
|
+
note:
|
|
447
|
+
"Plan file is not in the local plan store: approvals came from tool arguments " +
|
|
448
|
+
"(verified against the plan content only — no approval-digest check) and the run " +
|
|
449
|
+
"was not recorded. Use `fullstackgtm audit --save` + `plans approve` for " +
|
|
450
|
+
"store-backed governance.",
|
|
451
|
+
}),
|
|
452
|
+
};
|
|
453
|
+
if (output === "markdown") {
|
|
454
|
+
return content(
|
|
455
|
+
`${formatPatchPlanRun(run)}\n\nGovernance: plan source ${governance.planSource}; ` +
|
|
456
|
+
(runRecorded
|
|
457
|
+
? "approval digests verified; run recorded on the stored plan (audit-log export includes it)."
|
|
458
|
+
: governance.note!),
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
// Backward compatible: the run's own fields stay top-level; governance is additive.
|
|
462
|
+
return content({ ...run, governance });
|
|
463
|
+
},
|
|
464
|
+
},
|
|
465
|
+
{
|
|
466
|
+
name: "fullstackgtm_market_worksheet",
|
|
467
|
+
writesCrm: false,
|
|
468
|
+
config: {
|
|
330
469
|
title: "Market Map Classification Worksheet",
|
|
331
470
|
description:
|
|
332
471
|
"Get everything needed to classify ONE vendor's messaging intensity for a market map: " +
|
|
@@ -341,15 +480,15 @@ export async function startMcpServer() {
|
|
|
341
480
|
captureRun: z.string().optional(),
|
|
342
481
|
},
|
|
343
482
|
},
|
|
344
|
-
async ({ vendorId, configPath, captureRun }) => {
|
|
483
|
+
handler: async ({ vendorId, configPath, captureRun }) => {
|
|
345
484
|
const config = loadMarketConfigOrHint(resolve(process.cwd(), configPath ?? "market.config.json"));
|
|
346
485
|
return content(buildWorksheet(config, vendorId, { captureRun }));
|
|
347
486
|
},
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
{
|
|
487
|
+
},
|
|
488
|
+
{
|
|
489
|
+
name: "fullstackgtm_market_observe",
|
|
490
|
+
writesCrm: false,
|
|
491
|
+
config: {
|
|
353
492
|
title: "Submit Market Map Observations",
|
|
354
493
|
description:
|
|
355
494
|
"Submit a complete ObservationSet (every vendor × claim cell) for a market map run. " +
|
|
@@ -361,7 +500,7 @@ export async function startMcpServer() {
|
|
|
361
500
|
configPath: z.string().optional().describe("Path to market.config.json (default ./market.config.json)"),
|
|
362
501
|
},
|
|
363
502
|
},
|
|
364
|
-
async ({ observationsPath, configPath }) => {
|
|
503
|
+
handler: async ({ observationsPath, configPath }) => {
|
|
365
504
|
const config = loadMarketConfigOrHint(resolve(process.cwd(), configPath ?? "market.config.json"));
|
|
366
505
|
const set = JSON.parse(readFileSync(resolve(process.cwd(), observationsPath), "utf8")) as ObservationSet;
|
|
367
506
|
const problems = validateObservationSet(config, set);
|
|
@@ -377,12 +516,80 @@ export async function startMcpServer() {
|
|
|
377
516
|
const fronts = computeFrontStates(config, set);
|
|
378
517
|
return content({ accepted: true, runLabel: set.runLabel, observations: set.observations.length, fronts });
|
|
379
518
|
},
|
|
380
|
-
|
|
519
|
+
},
|
|
520
|
+
];
|
|
521
|
+
|
|
522
|
+
// Registered first so agents that list tools see the contract entry up top.
|
|
523
|
+
// Its payload closes over the registration table, so the reported inventory
|
|
524
|
+
// is derived, not hand-maintained.
|
|
525
|
+
toolDefinitions.unshift({
|
|
526
|
+
name: "fullstackgtm_capabilities",
|
|
527
|
+
writesCrm: false,
|
|
528
|
+
config: {
|
|
529
|
+
title: "FullStackGTM Capabilities",
|
|
530
|
+
description:
|
|
531
|
+
"Machine-readable contract for this MCP server: the tool inventory (derived from the " +
|
|
532
|
+
"server's own registration table, with per-tool CRM-write flags), safety defaults, and " +
|
|
533
|
+
"the CLI entrypoints for everything the tools don't cover.",
|
|
534
|
+
},
|
|
535
|
+
handler: async () =>
|
|
536
|
+
content({
|
|
537
|
+
ok: true,
|
|
538
|
+
tool: "fullstackgtm",
|
|
539
|
+
version: packageVersion(),
|
|
540
|
+
planFirst: true,
|
|
541
|
+
mcpTools: toolDefinitions.map(({ name, writesCrm, config }) => ({
|
|
542
|
+
name,
|
|
543
|
+
title: config.title,
|
|
544
|
+
writesCrm,
|
|
545
|
+
})),
|
|
546
|
+
safety:
|
|
547
|
+
"Only tools flagged writesCrm can reach a CRM; fullstackgtm_apply writes only operations " +
|
|
548
|
+
"listed in approvedOperationIds and never writes requires_human_* placeholders without a value override.",
|
|
549
|
+
server: { command: "npx -y fullstackgtm-mcp" },
|
|
550
|
+
cli: {
|
|
551
|
+
command: "npx fullstackgtm <command> [--json]",
|
|
552
|
+
capabilities: "npx fullstackgtm capabilities --json",
|
|
553
|
+
agentGuide: "npx fullstackgtm robot-docs",
|
|
554
|
+
},
|
|
555
|
+
examples: [
|
|
556
|
+
"npx -y fullstackgtm-mcp",
|
|
557
|
+
"npx fullstackgtm capabilities --json",
|
|
558
|
+
"npx fullstackgtm audit --demo --json",
|
|
559
|
+
],
|
|
560
|
+
}),
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
export async function startMcpServer() {
|
|
564
|
+
const server = new McpServer({
|
|
565
|
+
name: "fullstackgtm",
|
|
566
|
+
version: packageVersion(),
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
for (const tool of toolDefinitions) {
|
|
570
|
+
// The table is loosely typed so it can drive one loop; the SDK still
|
|
571
|
+
// validates every call against the zod inputSchema at runtime.
|
|
572
|
+
server.registerTool(tool.name, tool.config as never, tool.handler as never);
|
|
573
|
+
}
|
|
381
574
|
|
|
382
575
|
const transport = new StdioServerTransport();
|
|
383
576
|
await server.connect(transport);
|
|
384
577
|
}
|
|
385
578
|
|
|
579
|
+
/**
|
|
580
|
+
* A plan-store file is a StoredPlan envelope ({ plan, status, runs, ... });
|
|
581
|
+
* `audit --out` writes a bare PatchPlan. fullstackgtm_apply accepts either.
|
|
582
|
+
*/
|
|
583
|
+
function isStoredPlanFile(value: unknown): value is StoredPlan {
|
|
584
|
+
if (typeof value !== "object" || value === null || !("plan" in value)) return false;
|
|
585
|
+
const plan = (value as { plan?: unknown }).plan;
|
|
586
|
+
return (
|
|
587
|
+
typeof plan === "object" &&
|
|
588
|
+
plan !== null &&
|
|
589
|
+
Array.isArray((plan as { operations?: unknown }).operations)
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
|
|
386
593
|
function loadMarketConfigOrHint(path: string): ReturnType<typeof loadMarketConfig> {
|
|
387
594
|
try {
|
|
388
595
|
return loadMarketConfig(path);
|
package/src/progress.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared progress-event vocabulary — ONE set of semantics for every
|
|
3
|
+
* long-running process, rendered natively by each surface:
|
|
4
|
+
*
|
|
5
|
+
* CLI → ui.ts (spinner / checklist / apply-ticker; TTY-only styling)
|
|
6
|
+
* app → Convex heartbeat patches → reactive ProgressChip / StageTimeline
|
|
7
|
+
* both → optional broker streaming (a paired CLI POSTs heartbeats to the
|
|
8
|
+
* hosted app under its clientRunId, so long local runs tick live in
|
|
9
|
+
* the org's activity feed)
|
|
10
|
+
*
|
|
11
|
+
* Renderer-agnostic by contract: no event assumes persistence (the CLI drops
|
|
12
|
+
* them after painting) or ANSI (the app can't use it). Listeners are
|
|
13
|
+
* best-effort — a throwing renderer must never fail the work — and the
|
|
14
|
+
* throttle policy lives HERE so both surfaces feel identical.
|
|
15
|
+
*
|
|
16
|
+
* Standing convention: if a verb loops, it emits. New long-running verbs wire
|
|
17
|
+
* an emitter at the package layer; surfaces get feedback for free.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type ProgressEvent =
|
|
21
|
+
| { kind: "stage"; stage: string; stageIndex: number; stageCount: number }
|
|
22
|
+
| { kind: "items"; done: number; total?: number }
|
|
23
|
+
| { kind: "note"; note: string }
|
|
24
|
+
| {
|
|
25
|
+
kind: "opResult";
|
|
26
|
+
opId: string;
|
|
27
|
+
status: "applied" | "skipped" | "failed" | "conflict";
|
|
28
|
+
detail?: string;
|
|
29
|
+
}
|
|
30
|
+
| { kind: "meter"; spent: number; budget: number; unit: string };
|
|
31
|
+
|
|
32
|
+
/** Accumulated state — what a heartbeat persists / a chip renders. */
|
|
33
|
+
export type ProgressSnapshot = {
|
|
34
|
+
stage?: string;
|
|
35
|
+
stageIndex?: number;
|
|
36
|
+
stageCount?: number;
|
|
37
|
+
itemsDone?: number;
|
|
38
|
+
itemsTotal?: number;
|
|
39
|
+
note?: string;
|
|
40
|
+
opsApplied: number;
|
|
41
|
+
opsSkipped: number;
|
|
42
|
+
opsFailed: number;
|
|
43
|
+
updatedAt: number;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type ProgressListener = (event: ProgressEvent, snapshot: ProgressSnapshot) => void;
|
|
47
|
+
|
|
48
|
+
export type ProgressEmitter = {
|
|
49
|
+
stage(stage: string, stageIndex: number, stageCount: number): void;
|
|
50
|
+
items(done: number, total?: number): void;
|
|
51
|
+
note(note: string): void;
|
|
52
|
+
opResult(opId: string, status: "applied" | "skipped" | "failed" | "conflict", detail?: string): void;
|
|
53
|
+
meter(spent: number, budget: number, unit: string): void;
|
|
54
|
+
/** Current accumulated state (always fresh, ignoring the throttle). */
|
|
55
|
+
snapshot(): ProgressSnapshot;
|
|
56
|
+
/** Force-deliver the latest state (call at stage ends / completion). */
|
|
57
|
+
flush(): void;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const DEFAULT_THROTTLE_MS = 2000;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Throttled emitter: `items` heartbeats coalesce to at most one delivery per
|
|
64
|
+
* throttle window (terminal `done === total` always delivers); `stage`,
|
|
65
|
+
* `opResult`, `note`, and `meter` are structural and always deliver.
|
|
66
|
+
*/
|
|
67
|
+
export function createProgressEmitter(
|
|
68
|
+
listener: ProgressListener,
|
|
69
|
+
options: { throttleMs?: number; now?: () => number } = {},
|
|
70
|
+
): ProgressEmitter {
|
|
71
|
+
const throttleMs = options.throttleMs ?? DEFAULT_THROTTLE_MS;
|
|
72
|
+
const now = options.now ?? Date.now;
|
|
73
|
+
const state: ProgressSnapshot = {
|
|
74
|
+
opsApplied: 0,
|
|
75
|
+
opsSkipped: 0,
|
|
76
|
+
opsFailed: 0,
|
|
77
|
+
updatedAt: now(),
|
|
78
|
+
};
|
|
79
|
+
let lastItemsDelivery = 0;
|
|
80
|
+
let pendingItems = false;
|
|
81
|
+
|
|
82
|
+
function deliver(event: ProgressEvent) {
|
|
83
|
+
state.updatedAt = now();
|
|
84
|
+
try {
|
|
85
|
+
listener(event, { ...state });
|
|
86
|
+
} catch {
|
|
87
|
+
// Renderers are presentation-only; never fail the work.
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
stage(stage, stageIndex, stageCount) {
|
|
93
|
+
state.stage = stage;
|
|
94
|
+
state.stageIndex = stageIndex;
|
|
95
|
+
state.stageCount = stageCount;
|
|
96
|
+
state.itemsDone = undefined;
|
|
97
|
+
state.itemsTotal = undefined;
|
|
98
|
+
state.note = undefined;
|
|
99
|
+
pendingItems = false;
|
|
100
|
+
deliver({ kind: "stage", stage, stageIndex, stageCount });
|
|
101
|
+
},
|
|
102
|
+
items(done, total) {
|
|
103
|
+
state.itemsDone = done;
|
|
104
|
+
state.itemsTotal = total;
|
|
105
|
+
const terminal = total !== undefined && done >= total;
|
|
106
|
+
const due = now() - lastItemsDelivery >= throttleMs;
|
|
107
|
+
if (terminal || due) {
|
|
108
|
+
lastItemsDelivery = now();
|
|
109
|
+
pendingItems = false;
|
|
110
|
+
deliver({ kind: "items", done, total });
|
|
111
|
+
} else {
|
|
112
|
+
pendingItems = true;
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
note(note) {
|
|
116
|
+
state.note = note;
|
|
117
|
+
deliver({ kind: "note", note });
|
|
118
|
+
},
|
|
119
|
+
opResult(opId, status, detail) {
|
|
120
|
+
if (status === "applied") state.opsApplied += 1;
|
|
121
|
+
else if (status === "skipped") state.opsSkipped += 1;
|
|
122
|
+
else state.opsFailed += 1;
|
|
123
|
+
deliver({ kind: "opResult", opId, status, detail });
|
|
124
|
+
},
|
|
125
|
+
meter(spent, budget, unit) {
|
|
126
|
+
deliver({ kind: "meter", spent, budget, unit });
|
|
127
|
+
},
|
|
128
|
+
snapshot() {
|
|
129
|
+
return { ...state, updatedAt: now() };
|
|
130
|
+
},
|
|
131
|
+
flush() {
|
|
132
|
+
if (pendingItems && state.itemsDone !== undefined) {
|
|
133
|
+
lastItemsDelivery = now();
|
|
134
|
+
pendingItems = false;
|
|
135
|
+
deliver({ kind: "items", done: state.itemsDone, total: state.itemsTotal });
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Fan one event stream out to several listeners (e.g. the CLI's local renderer
|
|
143
|
+
* plus the broker heartbeat streamer). Each listener is isolated: one throwing
|
|
144
|
+
* never starves the others, matching the emitter's own best-effort contract.
|
|
145
|
+
*/
|
|
146
|
+
export function composeListeners(
|
|
147
|
+
...listeners: Array<ProgressListener | undefined | null>
|
|
148
|
+
): ProgressListener {
|
|
149
|
+
const active = listeners.filter((listener): listener is ProgressListener => Boolean(listener));
|
|
150
|
+
return (event, snapshot) => {
|
|
151
|
+
for (const listener of active) {
|
|
152
|
+
try {
|
|
153
|
+
listener(event, snapshot);
|
|
154
|
+
} catch {
|
|
155
|
+
// Listeners are presentation/observability only; never fail the work.
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Shared stage registries ─────────────────────────────────────────────────
|
|
162
|
+
// Package constants so the CLI checklist and the app StageTimeline render
|
|
163
|
+
// LITERALLY the same stages in the same order — one mental model everywhere.
|
|
164
|
+
|
|
165
|
+
export const CRM_SYNC_STAGES = [
|
|
166
|
+
"owners",
|
|
167
|
+
"accounts",
|
|
168
|
+
"contacts",
|
|
169
|
+
"deals",
|
|
170
|
+
"counters",
|
|
171
|
+
"health",
|
|
172
|
+
] as const;
|
|
173
|
+
|
|
174
|
+
/** A connector snapshot pull — the CRM-sync stages that happen CLI-side. */
|
|
175
|
+
export const SNAPSHOT_PULL_STAGES = ["owners", "accounts", "contacts", "deals"] as const;
|
|
176
|
+
|
|
177
|
+
/** The Stripe connector's snapshot pull (billing systems have no owners). */
|
|
178
|
+
export const STRIPE_SNAPSHOT_STAGES = ["customers", "subscriptions"] as const;
|
|
179
|
+
|
|
180
|
+
export const CALL_SYNC_STAGES = ["notes", "transcripts", "insights"] as const;
|
|
181
|
+
|
|
182
|
+
export const BACKFILL_STRIPE_STAGES = ["invoices", "snapshot", "matching", "plan"] as const;
|
|
183
|
+
|
|
184
|
+
/** `backfill runs` — replaying local plan runs + health history to the broker. */
|
|
185
|
+
export const RUNS_REPLAY_STAGES = ["runs", "health"] as const;
|
|
186
|
+
|
|
187
|
+
export const APPLY_STAGES = ["preflight", "operations", "results"] as const;
|
|
188
|
+
|
|
189
|
+
/** `enrich acquire` — routing sourced candidate rows into a create plan. */
|
|
190
|
+
export const ACQUIRE_STAGES = ["candidates"] as const;
|
|
191
|
+
|
|
192
|
+
export const MARKET_CAPTURE_STAGES = ["sources", "capture", "classify", "persist"] as const;
|
|
193
|
+
|
|
194
|
+
/** No-op emitter for callers that don't care (keeps signatures simple). */
|
|
195
|
+
export function nullProgressEmitter(): ProgressEmitter {
|
|
196
|
+
return createProgressEmitter(() => {});
|
|
197
|
+
}
|