fullstackgtm 0.16.0 → 0.17.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/dist/mcp.js CHANGED
@@ -47,6 +47,8 @@ import { builtinAuditRules } from "./rules.js";
47
47
  import { sampleSnapshot } from "./sampleData.js";
48
48
  import { normalizeTranscript, parseCall } from "./calls.js";
49
49
  import { extractInsightsLlm, resolveLlmCredential } from "./llm.js";
50
+ import { computeFrontStates, createFileObservationStore, loadCaptureTexts, loadMarketConfig, validateObservationSet, verifyEvidenceSpans, } from "./market.js";
51
+ import { buildWorksheet } from "./marketClassify.js";
50
52
  import { resolveRecord } from "./resolve.js";
51
53
  import { suggestValues } from "./suggest.js";
52
54
  function content(value) {
@@ -244,6 +246,49 @@ export async function startMcpServer() {
244
246
  });
245
247
  return content(output === "markdown" ? formatPatchPlanRun(run) : run);
246
248
  });
249
+ server.registerTool("fullstackgtm_market_worksheet", {
250
+ title: "Market Map Classification Worksheet",
251
+ description: "Get everything needed to classify ONE vendor's messaging intensity for a market map: " +
252
+ "the claim taxonomy with judging definitions, the surface rule, and the captured page " +
253
+ "texts. Read each claim's definition, judge loud/quiet/absent from the page texts only, " +
254
+ "and quote verbatim spans (≤300 chars) for every loud/quiet reading. Submit the full " +
255
+ "ObservationSet via fullstackgtm_market_observe — quotes are verified character-for-" +
256
+ "character against the captures, so never paraphrase.",
257
+ inputSchema: {
258
+ vendorId: z.string(),
259
+ configPath: z.string().optional().describe("Path to market.config.json (default ./market.config.json)"),
260
+ captureRun: z.string().optional(),
261
+ },
262
+ }, async ({ vendorId, configPath, captureRun }) => {
263
+ const config = loadMarketConfig(resolve(process.cwd(), configPath ?? "market.config.json"));
264
+ return content(buildWorksheet(config, vendorId, { captureRun }));
265
+ });
266
+ server.registerTool("fullstackgtm_market_observe", {
267
+ title: "Submit Market Map Observations",
268
+ description: "Submit a complete ObservationSet (every vendor × claim cell) for a market map run. " +
269
+ "Validates coverage, the verbatim-evidence rule, and mechanically verifies every quoted " +
270
+ "span against the stored capture it cites. Returns problems if rejected; nothing is " +
271
+ "stored unless the whole set passes. Observations are append-only — use a new runLabel.",
272
+ inputSchema: {
273
+ observationsPath: z.string().describe("Path to the ObservationSet JSON file"),
274
+ configPath: z.string().optional().describe("Path to market.config.json (default ./market.config.json)"),
275
+ },
276
+ }, async ({ observationsPath, configPath }) => {
277
+ const config = loadMarketConfig(resolve(process.cwd(), configPath ?? "market.config.json"));
278
+ const set = JSON.parse(readFileSync(resolve(process.cwd(), observationsPath), "utf8"));
279
+ const problems = validateObservationSet(config, set);
280
+ const failures = verifyEvidenceSpans(set.observations, loadCaptureTexts(config.category).textByHash);
281
+ if (problems.length > 0 || failures.length > 0) {
282
+ return content({
283
+ accepted: false,
284
+ problems,
285
+ spanFailures: failures.map((failure) => `${failure.vendorId} × ${failure.claimId}: ${failure.problem}`),
286
+ });
287
+ }
288
+ await createFileObservationStore(config.category).append(set);
289
+ const fronts = computeFrontStates(config, set);
290
+ return content({ accepted: true, runLabel: set.runLabel, observations: set.observations.length, fronts });
291
+ });
247
292
  const transport = new StdioServerTransport();
248
293
  await server.connect(transport);
249
294
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullstackgtm",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Full Stack GTM",
package/src/cli.ts CHANGED
@@ -44,11 +44,14 @@ import {
44
44
  computeFrontStates,
45
45
  createFileObservationStore,
46
46
  diffFrontStates,
47
+ loadCaptureTexts,
47
48
  loadMarketConfig,
48
49
  starterMarketConfig,
49
50
  validateObservationSet,
51
+ verifyEvidenceSpans,
50
52
  type ObservationSet,
51
53
  } from "./market.ts";
54
+ import { buildWorksheet, classifyMarket } from "./marketClassify.ts";
52
55
  import { marketMapToHtml, marketMapToMarkdown } from "./marketReport.ts";
53
56
  import {
54
57
  DEFAULT_RUBRIC,
@@ -107,13 +110,18 @@ Usage:
107
110
  found (exists/ambiguous) — call before ANY record creation
108
111
  fullstackgtm market init --category <name> start a market map: vendors + claim taxonomy as reviewable config
109
112
  fullstackgtm market capture [--config <path>] [--run <label>]
110
- fullstackgtm market observe --from <observations.json>
113
+ fullstackgtm market classify [--run <label>] [--vendor <id>] [--model m] [--out <path>]
114
+ fullstackgtm market worksheet --vendor <id> [--out <path>]
115
+ fullstackgtm market observe --from <observations.json> [--unverified]
111
116
  fullstackgtm market fronts [--run <label>] [--diff <prior-run>] [--json]
112
117
  fullstackgtm market report [--run <label>] [--format md|html] [--out <path>]
118
+ fullstackgtm market refresh [--run <label>] [--model m]
113
119
  the live competitive map: capture vendor pages (content-addressed),
114
- ingest intensity readings with verbatim-quote evidence, compute
115
- deterministic front states (open/contested/owned/saturated) and
116
- drift between runs, render the client-ready field report
120
+ classify intensity per claim (LLM bring-your-own-key, or fill the
121
+ worksheet with any agent) — every quoted span is verified verbatim
122
+ against the stored capture it cites before it's accepted — then
123
+ compute deterministic front states and drift, render the field
124
+ report. refresh = capture → classify → drift → report in one step
117
125
  fullstackgtm suggest --plan-id <id> | --plan <path> [source options] [--json] [--out <path>]
118
126
  derive values for requires_human_* placeholders
119
127
  from snapshot evidence, with confidence + reasons
@@ -696,15 +704,22 @@ the free keyword baseline; score always needs a key (scoring is LLM work).`);
696
704
  * TTY a missing key is captured once (validated, stored 0600 like provider
697
705
  * logins). Non-interactive contexts get an actionable error instead.
698
706
  */
699
- async function requireLlmCredential(command: "parse" | "score" = "parse"): Promise<{ provider: LlmProvider; apiKey: string }> {
707
+ async function requireLlmCredential(
708
+ command: "parse" | "score" | "market classify" = "parse",
709
+ ): Promise<{ provider: LlmProvider; apiKey: string }> {
700
710
  const resolved = resolveLlmCredential();
701
711
  if (resolved) return resolved;
702
712
  // Scoring is inherently LLM work — there is no keyword fallback to suggest.
703
713
  const fallbackHint =
704
- command === "parse" ? ", or pass --deterministic for the free keyword baseline" : " (call score has no non-LLM mode)";
714
+ command === "parse"
715
+ ? ", or pass --deterministic for the free keyword baseline"
716
+ : command === "score"
717
+ ? " (call score has no non-LLM mode)"
718
+ : ", or classify by hand: `market worksheet --vendor <id>` then `market observe --from`";
719
+ const work = command === "score" ? "scoring" : command === "parse" ? "extraction" : "classification";
705
720
  if (!process.stdin.isTTY) {
706
721
  throw new Error(
707
- `LLM ${command === "score" ? "scoring" : "extraction"} needs an API key. Set ANTHROPIC_API_KEY or OPENAI_API_KEY, or run \`echo "$KEY" | fullstackgtm login anthropic\` (or \`login openai\`) once${fallbackHint}.`,
722
+ `LLM ${work} needs an API key. Set ANTHROPIC_API_KEY or OPENAI_API_KEY, or run \`echo "$KEY" | fullstackgtm login anthropic\` (or \`login openai\`) once${fallbackHint}.`,
708
723
  );
709
724
  }
710
725
  console.error("LLM parsing needs an API key (Anthropic or OpenAI) — yours, used directly with the provider.");
@@ -824,9 +839,11 @@ function buildCallPlan(
824
839
  /**
825
840
  * The market map: claim taxonomy in a reviewable config file, page captures
826
841
  * and append-only observations under the profile home, deterministic front
827
- * states and reports computed from the store. Classification (LLM intensity
828
- * readings) lands in a later change; until then `market observe --from`
829
- * ingests proposal files produced by an agent or a human.
842
+ * states and reports computed from the store. Intensity readings enter as
843
+ * proposals through two channels `classify` (LLM, bring-your-own-key, the
844
+ * call-intelligence pattern) and `worksheet`/`observe` (an agent or human
845
+ * fills the worksheet) — and BOTH pass the same mechanical gate: every quoted
846
+ * span is verified verbatim against the stored capture it cites.
830
847
  */
831
848
  async function marketCommand(args: string[]) {
832
849
  const [subcommand, ...rest] = args;
@@ -836,9 +853,18 @@ async function marketCommand(args: string[]) {
836
853
  console.log(`Usage:
837
854
  market init --category <name> [--out <path>] write a starter market.config.json
838
855
  market capture [--config <path>] [--run <label>]
839
- market observe --from <observations.json> [--config <path>]
856
+ market classify [--run <label>] [--capture-run <label>] [--vendor <id>] [--model m] [--out <path>]
857
+ market worksheet --vendor <id> [--capture-run <label>] [--out <path>]
858
+ market observe --from <observations.json> [--unverified]
840
859
  market fronts [--config <path>] [--run <label>] [--diff <prior-run>] [--json]
841
860
  market report [--config <path>] [--run <label>] [--format md|html] [--out <path>]
861
+ market refresh [--run <label>] [--model m] capture → classify → fronts drift → HTML report
862
+
863
+ classify uses your Anthropic/OpenAI key (like call parse) to read the stored
864
+ captures and propose intensity readings; worksheet is the no-key path (an
865
+ agent or human fills it, submits via observe). Either way, every quoted span
866
+ is verified character-for-character against the capture it cites before the
867
+ observation is accepted — quotes that aren't on the page bounce.
842
868
 
843
869
  The taxonomy (vendors + claims) is config you review and version; captures
844
870
  and observations live under ~/.fullstackgtm/market/<category> (profile-scoped,
@@ -883,11 +909,100 @@ recomputed deterministically on every invocation — never stored.`);
883
909
  process.exitCode = 1;
884
910
  return;
885
911
  }
912
+ if (!rest.includes("--unverified")) {
913
+ const { textByHash } = loadCaptureTexts(config.category);
914
+ const failures = verifyEvidenceSpans(set.observations, textByHash);
915
+ if (failures.length > 0) {
916
+ console.error(`Rejected: ${failures.length} evidence span(s) failed verification against the stored captures`);
917
+ for (const failure of failures.slice(0, 20)) {
918
+ console.error(` - ${failure.vendorId} × ${failure.claimId}: ${failure.problem}`);
919
+ }
920
+ console.error("Quotes must be copied verbatim from the captured pages. (--unverified skips this gate when the captures genuinely live elsewhere.)");
921
+ process.exitCode = 1;
922
+ return;
923
+ }
924
+ }
886
925
  await store.append(set);
887
926
  console.log(`Appended ${set.runLabel}: ${set.observations.length} observations (${set.extractor})`);
888
927
  return;
889
928
  }
890
929
 
930
+ if (subcommand === "worksheet") {
931
+ const vendorId = option(rest, "--vendor");
932
+ if (!vendorId) throw new Error("market worksheet requires --vendor <id>");
933
+ const worksheet = buildWorksheet(config, vendorId, { captureRun: option(rest, "--capture-run") ?? undefined });
934
+ const outPath = option(rest, "--out");
935
+ const payload = `${JSON.stringify(worksheet, null, 2)}\n`;
936
+ if (outPath) {
937
+ writeFileSync(resolve(process.cwd(), outPath), payload);
938
+ console.log(`Wrote ${outPath} (${worksheet.pages.length} captured pages, ${worksheet.claims.length} claims)`);
939
+ } else {
940
+ console.log(payload);
941
+ }
942
+ return;
943
+ }
944
+
945
+ if (subcommand === "classify") {
946
+ const credential = await requireLlmCredential("market classify");
947
+ const vendorFilter = option(rest, "--vendor");
948
+ const outPath = option(rest, "--out");
949
+ if (vendorFilter && !outPath) {
950
+ throw new Error(
951
+ "market classify --vendor produces a partial set (coverage validation would reject it) — pass --out <path> to inspect/merge it by hand",
952
+ );
953
+ }
954
+ const result = await classifyMarket(config, {
955
+ llm: { ...credential, model: option(rest, "--model") ?? undefined },
956
+ runLabel: option(rest, "--run") ?? option(rest, "--capture-run") ?? "run-1",
957
+ captureRun: option(rest, "--capture-run") ?? undefined,
958
+ vendors: vendorFilter ? [vendorFilter] : undefined,
959
+ });
960
+ if (result.retriedVendorIds.length > 0) {
961
+ console.error(`Span verification bounced ${result.retriedVendorIds.join(", ")} once; retry passed.`);
962
+ }
963
+ if (outPath) {
964
+ writeFileSync(resolve(process.cwd(), outPath), `${JSON.stringify(result.set, null, 2)}\n`);
965
+ console.log(`Wrote ${outPath}: ${result.set.observations.length} verified observations (${result.set.extractor})`);
966
+ return;
967
+ }
968
+ const problems = validateObservationSet(config, result.set);
969
+ if (problems.length > 0) {
970
+ throw new Error(`Classified set failed validation: ${problems.slice(0, 5).join("; ")}`);
971
+ }
972
+ await store.append(result.set);
973
+ console.log(
974
+ `Appended ${result.set.runLabel}: ${result.set.observations.length} observations, every span verified (${result.set.extractor})`,
975
+ );
976
+ return;
977
+ }
978
+
979
+ if (subcommand === "refresh") {
980
+ const credential = await requireLlmCredential("market classify");
981
+ const runLabel = option(rest, "--run") ?? `run-${new Date().toISOString().slice(0, 10)}`;
982
+ const prior = await store.latest();
983
+ console.log(`Capturing ${config.vendors.length} vendors as ${runLabel}…`);
984
+ const captured = await captureMarket(config, { runLabel });
985
+ const failed = captured.entries.filter((entry) => !entry.captureHash);
986
+ if (failed.length > 0) console.log(`${failed.length} page(s) failed/empty — affected cells will verify against remaining pages or read unobservable.`);
987
+ console.log(`Classifying with ${credential.provider}…`);
988
+ const result = await classifyMarket(config, {
989
+ llm: { ...credential, model: option(rest, "--model") ?? undefined },
990
+ runLabel,
991
+ captureRun: runLabel,
992
+ });
993
+ await store.append(result.set);
994
+ const fronts = computeFrontStates(config, result.set);
995
+ if (prior) {
996
+ const drift = diffFrontStates(computeFrontStates(config, prior), fronts);
997
+ if (drift.length === 0) console.log(`No front changes since ${prior.runLabel}.`);
998
+ for (const change of drift) console.log(`CHANGED ${change.claimId}: ${change.before} → ${change.after}`);
999
+ }
1000
+ const outPath = option(rest, "--out") ?? `${config.category}-${runLabel}.html`;
1001
+ writeFileSync(resolve(process.cwd(), outPath), marketMapToHtml(config, result.set));
1002
+ console.log(`Wrote ${outPath}`);
1003
+ return;
1004
+ }
1005
+
891
1006
  const loadSet = async (): Promise<ObservationSet> => {
892
1007
  const runLabel = option(rest, "--run");
893
1008
  const set = runLabel ? await store.get(runLabel) : await store.latest();
@@ -938,7 +1053,9 @@ recomputed deterministically on every invocation — never stored.`);
938
1053
  return;
939
1054
  }
940
1055
 
941
- throw new Error(`Unknown market subcommand: ${subcommand} (try: init, capture, observe, fronts, report)`);
1056
+ throw new Error(
1057
+ `Unknown market subcommand: ${subcommand} (try: init, capture, classify, worksheet, observe, fronts, report, refresh)`,
1058
+ );
942
1059
  }
943
1060
 
944
1061
  /**
package/src/index.ts CHANGED
@@ -136,12 +136,15 @@ export {
136
136
  createFileObservationStore,
137
137
  diffFrontStates,
138
138
  extractReadableText,
139
+ loadCaptureTexts,
139
140
  loadMarketConfig,
140
141
  marketHome,
142
+ normalizeForMatch,
141
143
  observationId,
142
144
  parseMarketConfig,
143
145
  starterMarketConfig,
144
146
  validateObservationSet,
147
+ verifyEvidenceSpans,
145
148
  type CaptureEntry,
146
149
  type CaptureOptions,
147
150
  type ClaimFront,
@@ -155,7 +158,15 @@ export {
155
158
  type ObservationConfidence,
156
159
  type ObservationSet,
157
160
  type ObservationStore,
161
+ type SpanVerificationFailure,
158
162
  } from "./market.ts";
163
+ export {
164
+ buildWorksheet,
165
+ classifyMarket,
166
+ type ClassifyMarketOptions,
167
+ type ClassifyMarketResult,
168
+ type MarketWorksheet,
169
+ } from "./marketClassify.ts";
159
170
  export { marketMapToHtml, marketMapToMarkdown } from "./marketReport.ts";
160
171
  export { suggestValues, type SuggestionConfidence, type ValueSuggestion } from "./suggest.ts";
161
172
  export type {
package/src/llm.ts CHANGED
@@ -239,7 +239,13 @@ export function parseRubric(json: string): Rubric {
239
239
 
240
240
  // ── Provider plumbing (raw fetch, forced tool calls) ───────────────────────
241
241
 
242
- async function forcedToolCall(
242
+ /**
243
+ * Shared constrained-tool-call plumbing: force the model to answer through a
244
+ * single tool whose input_schema is the output contract. Exported for other
245
+ * semi-deterministic features (market classification) — every LLM feature in
246
+ * the package goes through this one seam.
247
+ */
248
+ export async function forcedToolCall(
243
249
  prompt: string,
244
250
  toolName: string,
245
251
  schema: object,
package/src/market.ts CHANGED
@@ -408,6 +408,98 @@ export function validateObservationSet(config: MarketConfig, set: ObservationSet
408
408
  return problems;
409
409
  }
410
410
 
411
+ // ---------------------------------------------------------------------------
412
+ // Evidence span verification — the deterministic gate that makes the
413
+ // verbatim-quote rule mechanical instead of a prompt instruction. Because the
414
+ // source documents are *stored* (unlike call transcripts, which pass through),
415
+ // every quoted span can be checked against the capture it cites before the
416
+ // observation is accepted. Comparison is whitespace-normalized only: case and
417
+ // wording must match the page exactly.
418
+
419
+ export function loadCaptureTexts(
420
+ category: string,
421
+ directory?: string,
422
+ ): { entries: CaptureEntry[]; textByHash: Map<string, string> } {
423
+ const dir = directory ?? join(marketHome(category), "captures");
424
+ const manifestPath = join(dir, "manifest.json");
425
+ const entries: CaptureEntry[] = existsSync(manifestPath)
426
+ ? (JSON.parse(readFileSync(manifestPath, "utf8")) as CaptureEntry[])
427
+ : [];
428
+ const textByHash = new Map<string, string>();
429
+ for (const entry of entries) {
430
+ if (entry.captureHash && !textByHash.has(entry.captureHash)) {
431
+ try {
432
+ textByHash.set(entry.captureHash, readFileSync(join(dir, `${entry.captureHash}.txt`), "utf8"));
433
+ } catch {
434
+ // Missing capture file: verification of anything citing it will fail loudly.
435
+ }
436
+ }
437
+ }
438
+ return { entries, textByHash };
439
+ }
440
+
441
+ /**
442
+ * Whitespace-only normalization for span matching, plus one extraction
443
+ * artifact: the HTML-to-text step can emit a line break before punctuation
444
+ * that follows an inline tag ("placements\n. Districts"), which no honest
445
+ * quoter would reproduce — so whitespace *before* punctuation is dropped
446
+ * too. Words, casing, and characters must still match the page exactly.
447
+ */
448
+ export function normalizeForMatch(value: string): string {
449
+ return value
450
+ .replace(/\s+([.,;:!?])/g, "$1")
451
+ .replace(/\s+/g, " ")
452
+ .trim();
453
+ }
454
+
455
+ export type SpanVerificationFailure = {
456
+ vendorId: string;
457
+ claimId: string;
458
+ quote: string;
459
+ problem: string;
460
+ };
461
+
462
+ export function verifyEvidenceSpans(
463
+ observations: MarketObservation[],
464
+ textByHash: Map<string, string>,
465
+ ): SpanVerificationFailure[] {
466
+ const failures: SpanVerificationFailure[] = [];
467
+ for (const obs of observations) {
468
+ for (const evidence of obs.evidence) {
469
+ const quote = evidence.text ?? "";
470
+ const hash = String(evidence.metadata?.captureHash ?? "");
471
+ if (!hash) {
472
+ failures.push({
473
+ vendorId: obs.vendorId,
474
+ claimId: obs.claimId,
475
+ quote,
476
+ problem: "evidence has no captureHash — spans must cite a stored capture",
477
+ });
478
+ continue;
479
+ }
480
+ const captureText = textByHash.get(hash);
481
+ if (captureText === undefined) {
482
+ failures.push({
483
+ vendorId: obs.vendorId,
484
+ claimId: obs.claimId,
485
+ quote,
486
+ problem: `capture ${hash.slice(0, 12)} not found — evidence must stay resolvable`,
487
+ });
488
+ continue;
489
+ }
490
+ if (!normalizeForMatch(captureText).includes(normalizeForMatch(quote))) {
491
+ failures.push({
492
+ vendorId: obs.vendorId,
493
+ claimId: obs.claimId,
494
+ quote,
495
+ problem: `quote not found verbatim in capture ${hash.slice(0, 12)}`,
496
+ });
497
+ }
498
+ }
499
+ }
500
+ return failures;
501
+ }
502
+
411
503
  // ---------------------------------------------------------------------------
412
504
  // Front states — deterministic, recomputed every time, never stored.
413
505