fullstackgtm 0.47.0 → 0.49.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 +76 -6
- package/CONTRIBUTING.md +23 -4
- package/DATA-FLOWS.md +7 -2
- package/INSTALL_FOR_AGENTS.md +15 -6
- package/README.md +28 -9
- package/SECURITY.md +27 -3
- package/dist/audit.js +7 -0
- package/dist/backfill.js +2 -16
- package/dist/cli/audit.js +10 -7
- package/dist/cli/auth.js +8 -4
- package/dist/cli/fix.d.ts +3 -0
- package/dist/cli/fix.js +90 -8
- package/dist/cli/help.d.ts +6 -0
- package/dist/cli/help.js +81 -3
- package/dist/cli/market.js +180 -2
- package/dist/cli/plans.js +56 -5
- package/dist/cli/suggest.d.ts +2 -1
- package/dist/cli/suggest.js +49 -3
- package/dist/cli/ui.js +2 -0
- package/dist/cli.js +41 -16
- package/dist/config.d.ts +13 -1
- package/dist/config.js +23 -2
- package/dist/connectors/hubspot.d.ts +2 -1
- package/dist/connectors/prospectSources.js +4 -11
- package/dist/connectors/salesforce.d.ts +2 -1
- package/dist/connectors/salesforce.js +12 -5
- package/dist/connectors/salesforceAuth.d.ts +9 -0
- package/dist/connectors/salesforceAuth.js +47 -5
- package/dist/connectors/signalSources.js +2 -11
- package/dist/connectors/theirstack.d.ts +0 -19
- package/dist/connectors/theirstack.js +3 -10
- package/dist/credentials.js +36 -26
- package/dist/format.js +31 -5
- package/dist/freeEmailDomains.d.ts +1 -0
- package/dist/freeEmailDomains.js +14 -0
- package/dist/hierarchy.d.ts +29 -0
- package/dist/hierarchy.js +164 -0
- package/dist/index.d.ts +9 -4
- package/dist/index.js +8 -3
- package/dist/market.d.ts +1 -1
- package/dist/market.js +7 -127
- package/dist/marketClassify.d.ts +10 -0
- package/dist/marketClassify.js +52 -1
- package/dist/marketSourcing.js +7 -45
- package/dist/mcp.js +86 -130
- package/dist/planStore.d.ts +28 -1
- package/dist/planStore.js +195 -49
- package/dist/providerError.d.ts +21 -0
- package/dist/providerError.js +30 -0
- package/dist/publicHttp.d.ts +28 -0
- package/dist/publicHttp.js +143 -0
- package/dist/relationships.d.ts +39 -0
- package/dist/relationships.js +101 -0
- package/dist/route.d.ts +46 -0
- package/dist/route.js +228 -0
- package/dist/rules.d.ts +1 -0
- package/dist/rules.js +172 -12
- package/dist/secureFile.d.ts +15 -0
- package/dist/secureFile.js +164 -0
- package/dist/types.d.ts +16 -1
- package/docs/api.md +49 -5
- package/docs/architecture.md +18 -4
- package/llms.txt +7 -0
- package/package.json +5 -3
- package/skills/fullstackgtm/SKILL.md +9 -3
- package/src/audit.ts +7 -0
- package/src/backfill.ts +2 -17
- package/src/cli/audit.ts +10 -7
- package/src/cli/auth.ts +8 -4
- package/src/cli/fix.ts +96 -8
- package/src/cli/help.ts +88 -3
- package/src/cli/market.ts +181 -2
- package/src/cli/plans.ts +66 -5
- package/src/cli/suggest.ts +58 -4
- package/src/cli/ui.ts +1 -0
- package/src/cli.ts +39 -14
- package/src/config.ts +39 -1
- package/src/connectors/hubspot.ts +3 -1
- package/src/connectors/prospectSources.ts +4 -11
- package/src/connectors/salesforce.ts +15 -6
- package/src/connectors/salesforceAuth.ts +46 -6
- package/src/connectors/signalSources.ts +2 -12
- package/src/connectors/theirstack.ts +3 -10
- package/src/credentials.ts +47 -28
- package/src/format.ts +33 -5
- package/src/freeEmailDomains.ts +14 -0
- package/src/hierarchy.ts +185 -0
- package/src/index.ts +29 -0
- package/src/market.ts +7 -111
- package/src/marketClassify.ts +61 -1
- package/src/marketSourcing.ts +7 -43
- package/src/mcp.ts +115 -152
- package/src/planStore.ts +235 -57
- package/src/providerError.ts +37 -0
- package/src/publicHttp.ts +147 -0
- package/src/relationships.ts +115 -0
- package/src/route.ts +278 -0
- package/src/rules.ts +192 -12
- package/src/secureFile.ts +169 -0
- package/src/types.ts +18 -0
package/src/marketClassify.ts
CHANGED
|
@@ -276,9 +276,68 @@ export type MarketWorksheet = {
|
|
|
276
276
|
vendor: { id: string; name: string };
|
|
277
277
|
claims: MarketClaim[];
|
|
278
278
|
pages: Array<{ kind: CaptureEntry["kind"]; url: string; captureHash: string; text: string }>;
|
|
279
|
+
/** Deterministic retrieval hints: page lines that contain claim terms. These are not labels. */
|
|
280
|
+
claimHints: Array<{
|
|
281
|
+
claimId: string;
|
|
282
|
+
matches: Array<{ url: string; captureHash: string; term: string; quote: string }>;
|
|
283
|
+
}>;
|
|
279
284
|
instructions: string;
|
|
280
285
|
};
|
|
281
286
|
|
|
287
|
+
const HINT_STOPWORDS = new Set([
|
|
288
|
+
"the", "and", "for", "with", "from", "that", "this", "into", "your", "you", "are", "but", "not", "when", "how", "use", "uses", "using",
|
|
289
|
+
"claim", "claims", "vendor", "vendors", "page", "pages", "loud", "quiet", "absent", "capability", "specific", "differentiating",
|
|
290
|
+
"unknown", "buyer", "buyers", "pricing", "structure", "category", "support", "supported", "primary", "secondary", "mentioned", "mentions",
|
|
291
|
+
]);
|
|
292
|
+
|
|
293
|
+
function normalizeHintText(value: string): string {
|
|
294
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim().replace(/\s+/g, " ");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function claimHintTerms(claim: MarketClaim): string[] {
|
|
298
|
+
const exact = [claim.id.replace(/[-_]+/g, " "), claim.capability, ...(claim.terms ?? [])]
|
|
299
|
+
.map(normalizeHintText)
|
|
300
|
+
.filter((term) => term.length >= 3);
|
|
301
|
+
const tokenSource = normalizeHintText(`${claim.capability} ${claim.definition} ${(claim.terms ?? []).join(" ")}`)
|
|
302
|
+
.split(" ")
|
|
303
|
+
.filter((term) => term.length >= 4 && !HINT_STOPWORDS.has(term));
|
|
304
|
+
return Array.from(new Set([...exact, ...tokenSource])).slice(0, 24);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function lineSnippet(line: string, term: string): string {
|
|
308
|
+
const clean = line.trim().replace(/\s+/g, " ");
|
|
309
|
+
if (clean.length <= 300) return clean;
|
|
310
|
+
const lower = clean.toLowerCase();
|
|
311
|
+
const idx = lower.indexOf(term.toLowerCase().split(" ")[0] ?? "");
|
|
312
|
+
const start = Math.max(0, idx < 0 ? 0 : idx - 120);
|
|
313
|
+
return clean.slice(start, start + 300).trim();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function buildClaimHints(
|
|
317
|
+
claims: MarketClaim[],
|
|
318
|
+
pages: Array<{ kind: CaptureEntry["kind"]; url: string; captureHash: string; text: string }>,
|
|
319
|
+
): MarketWorksheet["claimHints"] {
|
|
320
|
+
return claims.map((claim) => {
|
|
321
|
+
const terms = claimHintTerms(claim);
|
|
322
|
+
const matches: Array<{ url: string; captureHash: string; term: string; quote: string }> = [];
|
|
323
|
+
for (const page of pages) {
|
|
324
|
+
const lines = page.text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
325
|
+
for (const term of terms) {
|
|
326
|
+
const normalizedTerm = normalizeHintText(term);
|
|
327
|
+
const line = lines.find((candidate) => normalizeHintText(candidate).includes(normalizedTerm));
|
|
328
|
+
if (!line) continue;
|
|
329
|
+
const quote = lineSnippet(line, term);
|
|
330
|
+
if (quote && !matches.some((m) => m.quote === quote && m.url === page.url)) {
|
|
331
|
+
matches.push({ url: page.url, captureHash: page.captureHash, term, quote });
|
|
332
|
+
}
|
|
333
|
+
if (matches.length >= 4) break;
|
|
334
|
+
}
|
|
335
|
+
if (matches.length >= 4) break;
|
|
336
|
+
}
|
|
337
|
+
return { claimId: claim.id, matches };
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
282
341
|
export function buildWorksheet(
|
|
283
342
|
config: MarketConfig,
|
|
284
343
|
vendorId: string,
|
|
@@ -304,7 +363,8 @@ export function buildWorksheet(
|
|
|
304
363
|
vendor: { id: vendor.id, name: vendor.name },
|
|
305
364
|
claims: config.claims,
|
|
306
365
|
pages,
|
|
366
|
+
claimHints: buildClaimHints(config.claims, pages),
|
|
307
367
|
instructions:
|
|
308
|
-
"Produce one observation per claim
|
|
368
|
+
"Produce one observation per claim from these pages only. Intensity rubric: loud = hero copy, primary positioning, or a named/dedicated product area; quiet = supported by a verbatim page quote but secondary, qualified, or merely available; absent = the pages do not support the claim; unobservable = no usable captured page exists for this vendor, not a synonym for absent. claimHints are deterministic retrieval hints only: use them to find candidate support, but do not mark a claim loud/quiet unless the quote truly supports the claim. Every loud/quiet reading must quote a verbatim span (≤300 chars) from a page's text, with that page's url and captureHash in evidence metadata. Submit as an ObservationSet via `market observe --from <file>` — quotes are mechanically verified against the captures.",
|
|
309
369
|
};
|
|
310
370
|
}
|
package/src/marketSourcing.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* Sitemaps / redirects / logo bytes are plain resources — they never need a
|
|
17
17
|
* headless browser, so the default fetch is sufficient even in the hosted layer.
|
|
18
18
|
*/
|
|
19
|
-
import {
|
|
19
|
+
import { publicHttpGet } from "./publicHttp.ts";
|
|
20
20
|
import { DEFAULT_MODELS, forcedToolCall, type LlmCallOptions } from "./llm.ts";
|
|
21
21
|
|
|
22
22
|
const USER_AGENT = "fullstackgtm-market/0 (+https://github.com/fullstackgtm/core)";
|
|
@@ -161,27 +161,9 @@ export function extractLogoUrl(html: string, baseUrl: string): string | null {
|
|
|
161
161
|
|
|
162
162
|
/** Manual-redirect fetch that re-validates every hop against the SSRF guard. */
|
|
163
163
|
async function guardedFetch(url: string): Promise<Response | null> {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
const res = await fetch(current, {
|
|
168
|
-
redirect: "manual",
|
|
169
|
-
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
170
|
-
headers: { "User-Agent": USER_AGENT },
|
|
171
|
-
});
|
|
172
|
-
const location = res.headers.get("location");
|
|
173
|
-
if (res.status >= 300 && res.status < 400 && location) {
|
|
174
|
-
try {
|
|
175
|
-
await res.body?.cancel();
|
|
176
|
-
} catch {
|
|
177
|
-
/* ignore */
|
|
178
|
-
}
|
|
179
|
-
current = new URL(location, current).toString();
|
|
180
|
-
continue;
|
|
181
|
-
}
|
|
182
|
-
return res;
|
|
183
|
-
}
|
|
184
|
-
return null;
|
|
164
|
+
const res = await publicHttpGet(url, { maxRedirects: MAX_REDIRECTS, timeoutMs: FETCH_TIMEOUT_MS,
|
|
165
|
+
headers: { "User-Agent": USER_AGENT } });
|
|
166
|
+
return new Response(res.body.length ? Buffer.from(res.body) : null, { status: res.status, headers: res.headers });
|
|
185
167
|
}
|
|
186
168
|
|
|
187
169
|
const defaultFetchText: FetchText = async (url) => {
|
|
@@ -210,27 +192,9 @@ const defaultFetchBytes: FetchBytes = async (url) => {
|
|
|
210
192
|
* + status WITHOUT downloading the body. Used to detect identity drift.
|
|
211
193
|
*/
|
|
212
194
|
export const resolveFinalUrl: ResolveUrl = async (rawUrl) => {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const res = await fetch(current, {
|
|
217
|
-
redirect: "manual",
|
|
218
|
-
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
219
|
-
headers: { "User-Agent": USER_AGENT },
|
|
220
|
-
});
|
|
221
|
-
try {
|
|
222
|
-
await res.body?.cancel();
|
|
223
|
-
} catch {
|
|
224
|
-
/* ignore */
|
|
225
|
-
}
|
|
226
|
-
const location = res.headers.get("location");
|
|
227
|
-
if (res.status >= 300 && res.status < 400 && location) {
|
|
228
|
-
current = new URL(location, current).toString();
|
|
229
|
-
continue;
|
|
230
|
-
}
|
|
231
|
-
return { finalUrl: current, status: res.status };
|
|
232
|
-
}
|
|
233
|
-
return { finalUrl: current, status: 0 };
|
|
195
|
+
const res = await publicHttpGet(rawUrl, { maxRedirects: MAX_REDIRECTS, timeoutMs: FETCH_TIMEOUT_MS,
|
|
196
|
+
maxBytes: 0, headers: { "User-Agent": USER_AGENT } });
|
|
197
|
+
return { finalUrl: res.finalUrl, status: res.status };
|
|
234
198
|
};
|
|
235
199
|
|
|
236
200
|
// ── Fetching helpers ─────────────────────────────────────────────────────────
|
package/src/mcp.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ZodRawShape } from "zod/v4";
|
|
1
2
|
import { readFileSync } from "node:fs";
|
|
2
3
|
import { createRequire } from "node:module";
|
|
3
4
|
import { join, resolve } from "node:path";
|
|
@@ -54,11 +55,12 @@ import { generateDemoSnapshot } from "./demo.ts";
|
|
|
54
55
|
import type { FieldMappings } from "./mappings.ts";
|
|
55
56
|
import { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
|
|
56
57
|
import { verifyApprovalDigests } from "./integrity.ts";
|
|
57
|
-
import { createFilePlanStore
|
|
58
|
+
import { createFilePlanStore } from "./planStore.ts";
|
|
58
59
|
import { builtinAuditRules } from "./rules.ts";
|
|
59
60
|
import { sampleSnapshot } from "./sampleData.ts";
|
|
60
61
|
import { normalizeTranscript, parseCall } from "./calls.ts";
|
|
61
62
|
import { extractInsightsLlm, resolveLlmCredential } from "./llm.ts";
|
|
63
|
+
|
|
62
64
|
import {
|
|
63
65
|
computeFrontStates,
|
|
64
66
|
createFileObservationStore,
|
|
@@ -159,13 +161,17 @@ function packageVersion() {
|
|
|
159
161
|
}
|
|
160
162
|
}
|
|
161
163
|
|
|
164
|
+
function strictSchema<T extends ZodRawShape>(shape: T) {
|
|
165
|
+
return z.object(shape).strict();
|
|
166
|
+
}
|
|
167
|
+
|
|
162
168
|
type ToolDefinition = {
|
|
163
169
|
name: string;
|
|
164
170
|
// Whether the tool can write to a CRM. Everything else reads, or writes
|
|
165
171
|
// only local workspace state (market_observe appends to the local
|
|
166
172
|
// observation store).
|
|
167
173
|
writesCrm: boolean;
|
|
168
|
-
config: { title: string; description: string; inputSchema?:
|
|
174
|
+
config: { title: string; description: string; inputSchema?: unknown };
|
|
169
175
|
// Args are validated by the SDK against config.inputSchema before the
|
|
170
176
|
// handler runs; the loose typing here is what lets one table drive a
|
|
171
177
|
// registration loop instead of per-call generics.
|
|
@@ -187,7 +193,7 @@ const toolDefinitions: ToolDefinition[] = [
|
|
|
187
193
|
"Run a dry-run GTM hygiene audit and return a reviewable patch plan. " +
|
|
188
194
|
"Sources: the realistic zero-credential demo CRM (provider: \"demo\" — richest test data), " +
|
|
189
195
|
"the minimal sample dataset, a snapshot file, or a live provider.",
|
|
190
|
-
inputSchema: {
|
|
196
|
+
inputSchema: strictSchema({
|
|
191
197
|
provider: z.enum(["sample", "demo", "hubspot", "salesforce", "stripe"]).optional(),
|
|
192
198
|
inputPath: z.string().optional(),
|
|
193
199
|
configPath: z.string().optional(),
|
|
@@ -195,14 +201,23 @@ const toolDefinitions: ToolDefinition[] = [
|
|
|
195
201
|
output: z.enum(["json", "markdown"]).optional(),
|
|
196
202
|
today: z.string().optional(),
|
|
197
203
|
staleDealDays: z.number().int().positive().optional(),
|
|
198
|
-
},
|
|
204
|
+
}),
|
|
199
205
|
},
|
|
200
206
|
handler: async ({ provider, inputPath, configPath, rules, output, today, staleDealDays }) => {
|
|
201
207
|
const loaded = configPath ? loadConfig(configPath) : null;
|
|
208
|
+
if (loaded?.config.rulePackages?.length) {
|
|
209
|
+
throw new Error(
|
|
210
|
+
"MCP refuses executable rulePackages from tool-call configPath. " +
|
|
211
|
+
"MCP plugin execution is disabled because mutable tool-call paths are not a durable code-trust boundary. " +
|
|
212
|
+
"Run reviewed plugins through the CLI with --config <path> --allow-plugins instead.",
|
|
213
|
+
);
|
|
214
|
+
}
|
|
202
215
|
const policy = mergePolicy(defaultPolicy(today), loaded?.config);
|
|
203
216
|
if (today) policy.today = today;
|
|
204
217
|
if (staleDealDays !== undefined) policy.staleDealDays = staleDealDays;
|
|
205
|
-
const ruleSet = await resolveConfiguredRules(loaded
|
|
218
|
+
const ruleSet = await resolveConfiguredRules(loaded, undefined, {
|
|
219
|
+
disableRulePackages: true,
|
|
220
|
+
});
|
|
206
221
|
const selected = rules?.length
|
|
207
222
|
? ruleSet.filter((rule: { id: string }) => rules.includes(rule.id))
|
|
208
223
|
: ruleSet;
|
|
@@ -219,11 +234,11 @@ const toolDefinitions: ToolDefinition[] = [
|
|
|
219
234
|
"Derive values for a plan's requires_human_* placeholder operations from snapshot " +
|
|
220
235
|
"evidence (account-name matching, contact associations), with confidence levels and " +
|
|
221
236
|
"reasons. Read-only; feed accepted values into fullstackgtm_apply's valueOverrides.",
|
|
222
|
-
inputSchema: {
|
|
237
|
+
inputSchema: strictSchema({
|
|
223
238
|
planPath: z.string(),
|
|
224
239
|
provider: z.enum(["sample", "demo", "hubspot", "salesforce", "stripe"]).optional(),
|
|
225
240
|
inputPath: z.string().optional(),
|
|
226
|
-
},
|
|
241
|
+
}),
|
|
227
242
|
},
|
|
228
243
|
handler: async ({ planPath, provider, inputPath }) => {
|
|
229
244
|
const plan = JSON.parse(readFileSync(resolve(process.cwd(), planPath), "utf8")) as PatchPlan;
|
|
@@ -242,14 +257,14 @@ const toolDefinitions: ToolDefinition[] = [
|
|
|
242
257
|
"uses LLM extraction when an Anthropic/OpenAI key is configured in the server " +
|
|
243
258
|
"environment or credential store, else the free deterministic keyword baseline; " +
|
|
244
259
|
"'llm' and 'deterministic' force either. Read-only; every insight is provenance-marked.",
|
|
245
|
-
inputSchema: {
|
|
260
|
+
inputSchema: strictSchema({
|
|
246
261
|
transcript: z.string().optional(),
|
|
247
262
|
transcriptPath: z.string().optional(),
|
|
248
263
|
title: z.string().optional(),
|
|
249
264
|
source: z.enum(["gong", "chorus", "fathom", "manual", "csv", "unknown"]).optional(),
|
|
250
265
|
extractor: z.enum(["auto", "llm", "deterministic"]).optional(),
|
|
251
266
|
model: z.string().optional(),
|
|
252
|
-
},
|
|
267
|
+
}),
|
|
253
268
|
},
|
|
254
269
|
handler: async ({ transcript, transcriptPath, title, source, extractor, model }) => {
|
|
255
270
|
const raw =
|
|
@@ -287,7 +302,7 @@ const toolDefinitions: ToolDefinition[] = [
|
|
|
287
302
|
"(exists | ambiguous | safe_to_create) with matches and a reason, using the same " +
|
|
288
303
|
"identity keys as the audit/merge engines (account domain, contact email, open-deal " +
|
|
289
304
|
"key). Read-only. Never create on 'exists' or 'ambiguous'.",
|
|
290
|
-
inputSchema: {
|
|
305
|
+
inputSchema: strictSchema({
|
|
291
306
|
objectType: z.enum(["account", "contact", "deal"]),
|
|
292
307
|
name: z.string().optional(),
|
|
293
308
|
domain: z.string().optional(),
|
|
@@ -295,7 +310,7 @@ const toolDefinitions: ToolDefinition[] = [
|
|
|
295
310
|
accountId: z.string().optional(),
|
|
296
311
|
provider: z.enum(["sample", "demo", "hubspot", "salesforce", "stripe"]).optional(),
|
|
297
312
|
inputPath: z.string().optional(),
|
|
298
|
-
},
|
|
313
|
+
}),
|
|
299
314
|
},
|
|
300
315
|
handler: async ({ objectType, name, domain, email, accountId, provider, inputPath }) => {
|
|
301
316
|
const snapshot = await readSnapshot(provider, inputPath);
|
|
@@ -308,7 +323,7 @@ const toolDefinitions: ToolDefinition[] = [
|
|
|
308
323
|
config: {
|
|
309
324
|
title: "List Audit Rules",
|
|
310
325
|
description: "List the built-in deterministic audit rules with ids and descriptions.",
|
|
311
|
-
inputSchema: {},
|
|
326
|
+
inputSchema: strictSchema({}),
|
|
312
327
|
},
|
|
313
328
|
handler: async () => {
|
|
314
329
|
return content(
|
|
@@ -322,143 +337,104 @@ const toolDefinitions: ToolDefinition[] = [
|
|
|
322
337
|
config: {
|
|
323
338
|
title: "Apply Approved Patch Operations",
|
|
324
339
|
description:
|
|
325
|
-
"Apply
|
|
326
|
-
"
|
|
327
|
-
"
|
|
328
|
-
"
|
|
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 " +
|
|
340
|
+
"Apply a stored, human-approved patch plan through a provider connector. The plan, " +
|
|
341
|
+
"approved operation ids, and concrete placeholder values are loaded exclusively from " +
|
|
342
|
+
"the local plan store and verified against its HMAC approval digests. Callers cannot " +
|
|
343
|
+
"supply or widen approvals. Every apply run is recorded so `plans show` and " +
|
|
331
344
|
"`audit-log export` include it.",
|
|
332
|
-
inputSchema: {
|
|
345
|
+
inputSchema: strictSchema({
|
|
333
346
|
provider: z.enum(["hubspot", "salesforce"]),
|
|
334
|
-
|
|
335
|
-
approvedOperationIds: z.array(z.string()).min(1),
|
|
336
|
-
valueOverrides: z.record(z.string(), z.string()).optional(),
|
|
347
|
+
planId: z.string().regex(/^[\w.-]+$/),
|
|
337
348
|
output: z.enum(["json", "markdown"]).optional(),
|
|
338
|
-
},
|
|
349
|
+
}),
|
|
339
350
|
},
|
|
340
|
-
handler: async ({ provider,
|
|
341
|
-
// The
|
|
342
|
-
//
|
|
343
|
-
|
|
344
|
-
|
|
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.
|
|
351
|
+
handler: async ({ provider, planId, output }) => {
|
|
352
|
+
// The store is the only authority accepted over MCP. In particular, do
|
|
353
|
+
// not accept a plan body/path, approval ids, or value overrides from the
|
|
354
|
+
// agent call: combining proposal and approval in one call defeats the
|
|
355
|
+
// human approval boundary the plan lifecycle is meant to enforce.
|
|
354
356
|
const store = createFilePlanStore();
|
|
355
|
-
const stored =
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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,
|
|
357
|
+
const stored = await store.get(planId);
|
|
358
|
+
if (!stored) {
|
|
359
|
+
throw new Error(
|
|
360
|
+
`No stored plan with id ${planId}. Save it with \`fullstackgtm audit --save\`, then approve it with \`fullstackgtm plans approve\`.`,
|
|
400
361
|
);
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
362
|
+
}
|
|
363
|
+
if (stored.status !== "approved") {
|
|
364
|
+
throw new Error(
|
|
365
|
+
`Plan ${planId} is ${stored.status}; approve operations first with ` +
|
|
366
|
+
`\`fullstackgtm plans approve ${planId} --operations <ids|all>\`.`,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
if (stored.approvedOperationIds.length === 0) {
|
|
370
|
+
throw new Error(`Plan ${planId} has no approved operations.`);
|
|
371
|
+
}
|
|
372
|
+
if (!stored.approvalDigests) {
|
|
373
|
+
throw new Error(
|
|
374
|
+
`Refusing to apply plan ${planId}: 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 ${planId} --operations <ids|all>\`.`,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
const verification = verifyApprovalDigests(
|
|
380
|
+
stored.plan.operations,
|
|
381
|
+
stored.approvedOperationIds,
|
|
382
|
+
stored.valueOverrides,
|
|
383
|
+
stored.approvalDigests,
|
|
384
|
+
);
|
|
385
|
+
if (!verification.ok) {
|
|
386
|
+
const detail =
|
|
387
|
+
verification.reason === "no_key"
|
|
388
|
+
? "the plan-signing key is missing (was this plan approved on another machine?). Re-approve it here with `fullstackgtm plans approve`."
|
|
389
|
+
: `these operations differ from what was approved: ${verification.tampered.join(", ")}. ` +
|
|
390
|
+
"The stored plan or approved values changed after approval — review and re-approve.";
|
|
391
|
+
throw new Error(`Refusing to apply plan ${planId}: ${detail}`);
|
|
424
392
|
}
|
|
425
393
|
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
394
|
+
const connector = await connectorFor(provider);
|
|
395
|
+
// Claim only after all preflight checks and credential resolution. The
|
|
396
|
+
// atomic store transition prevents a second CLI/MCP process from
|
|
397
|
+
// applying the same approval concurrently.
|
|
398
|
+
const claimed = await store.claimApply(planId, { provider, source: "mcp" });
|
|
399
|
+
const claimedVerification = verifyApprovalDigests(
|
|
400
|
+
claimed.stored.plan.operations,
|
|
401
|
+
claimed.stored.approvedOperationIds,
|
|
402
|
+
claimed.stored.valueOverrides,
|
|
403
|
+
claimed.stored.approvalDigests,
|
|
404
|
+
);
|
|
405
|
+
if (!claimedVerification.ok) {
|
|
406
|
+
await store.abortApplyPreflight(
|
|
407
|
+
planId,
|
|
408
|
+
claimed.claimId,
|
|
409
|
+
"Approval integrity verification failed after the claim and before provider I/O.",
|
|
410
|
+
);
|
|
411
|
+
throw new Error(
|
|
412
|
+
`Refusing to apply plan ${planId}: the claimed plan differs from what was approved: ` +
|
|
413
|
+
`${claimedVerification.tampered.join(", ") || "signing key unavailable"}.`,
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
let run;
|
|
417
|
+
try {
|
|
418
|
+
run = await applyPatchPlan(connector, claimed.stored.plan, {
|
|
419
|
+
approvedOperationIds: claimed.stored.approvedOperationIds,
|
|
420
|
+
valueOverrides: claimed.stored.valueOverrides,
|
|
421
|
+
});
|
|
422
|
+
} catch (error) {
|
|
423
|
+
await store.markApplyUncertain(planId, claimed.claimId);
|
|
424
|
+
throw error;
|
|
438
425
|
}
|
|
426
|
+
await store.recordRun(claimed.stored.plan.id, run, claimed.claimId);
|
|
439
427
|
const governance = {
|
|
440
|
-
planSource:
|
|
441
|
-
approvalDigestsVerified:
|
|
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
|
-
}),
|
|
428
|
+
planSource: "store" as const,
|
|
429
|
+
approvalDigestsVerified: true,
|
|
430
|
+
runRecorded: true,
|
|
452
431
|
};
|
|
453
432
|
if (output === "markdown") {
|
|
454
433
|
return content(
|
|
455
434
|
`${formatPatchPlanRun(run)}\n\nGovernance: plan source ${governance.planSource}; ` +
|
|
456
|
-
(
|
|
457
|
-
? "approval digests verified; run recorded on the stored plan (audit-log export includes it)."
|
|
458
|
-
: governance.note!),
|
|
435
|
+
"approval digests verified; run recorded on the stored plan (audit-log export includes it).",
|
|
459
436
|
);
|
|
460
437
|
}
|
|
461
|
-
// Backward compatible: the run's own fields stay top-level; governance is additive.
|
|
462
438
|
return content({ ...run, governance });
|
|
463
439
|
},
|
|
464
440
|
},
|
|
@@ -474,11 +450,11 @@ const toolDefinitions: ToolDefinition[] = [
|
|
|
474
450
|
"and quote verbatim spans (≤300 chars) for every loud/quiet reading. Submit the full " +
|
|
475
451
|
"ObservationSet via fullstackgtm_market_observe — quotes are verified character-for-" +
|
|
476
452
|
"character against the captures, so never paraphrase.",
|
|
477
|
-
inputSchema: {
|
|
453
|
+
inputSchema: strictSchema({
|
|
478
454
|
vendorId: z.string(),
|
|
479
455
|
configPath: z.string().optional().describe("Path to market.config.json (default ./market.config.json)"),
|
|
480
456
|
captureRun: z.string().optional(),
|
|
481
|
-
},
|
|
457
|
+
}),
|
|
482
458
|
},
|
|
483
459
|
handler: async ({ vendorId, configPath, captureRun }) => {
|
|
484
460
|
const config = loadMarketConfigOrHint(resolve(process.cwd(), configPath ?? "market.config.json"));
|
|
@@ -495,10 +471,10 @@ const toolDefinitions: ToolDefinition[] = [
|
|
|
495
471
|
"Validates coverage, the verbatim-evidence rule, and mechanically verifies every quoted " +
|
|
496
472
|
"span against the stored capture it cites. Returns problems if rejected; nothing is " +
|
|
497
473
|
"stored unless the whole set passes. Observations are append-only — use a new runLabel.",
|
|
498
|
-
inputSchema: {
|
|
474
|
+
inputSchema: strictSchema({
|
|
499
475
|
observationsPath: z.string().describe("Path to the ObservationSet JSON file"),
|
|
500
476
|
configPath: z.string().optional().describe("Path to market.config.json (default ./market.config.json)"),
|
|
501
|
-
},
|
|
477
|
+
}),
|
|
502
478
|
},
|
|
503
479
|
handler: async ({ observationsPath, configPath }) => {
|
|
504
480
|
const config = loadMarketConfigOrHint(resolve(process.cwd(), configPath ?? "market.config.json"));
|
|
@@ -531,6 +507,7 @@ toolDefinitions.unshift({
|
|
|
531
507
|
"Machine-readable contract for this MCP server: the tool inventory (derived from the " +
|
|
532
508
|
"server's own registration table, with per-tool CRM-write flags), safety defaults, and " +
|
|
533
509
|
"the CLI entrypoints for everything the tools don't cover.",
|
|
510
|
+
inputSchema: strictSchema({}),
|
|
534
511
|
},
|
|
535
512
|
handler: async () =>
|
|
536
513
|
content({
|
|
@@ -545,7 +522,7 @@ toolDefinitions.unshift({
|
|
|
545
522
|
})),
|
|
546
523
|
safety:
|
|
547
524
|
"Only tools flagged writesCrm can reach a CRM; fullstackgtm_apply writes only operations " +
|
|
548
|
-
"
|
|
525
|
+
"from a stored plan whose ids and values were human-approved and HMAC-verified.",
|
|
549
526
|
server: { command: "npx -y fullstackgtm-mcp" },
|
|
550
527
|
cli: {
|
|
551
528
|
command: "npx fullstackgtm <command> [--json]",
|
|
@@ -576,20 +553,6 @@ export async function startMcpServer() {
|
|
|
576
553
|
await server.connect(transport);
|
|
577
554
|
}
|
|
578
555
|
|
|
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
|
-
|
|
593
556
|
function loadMarketConfigOrHint(path: string): ReturnType<typeof loadMarketConfig> {
|
|
594
557
|
try {
|
|
595
558
|
return loadMarketConfig(path);
|