fullstackgtm 0.15.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/src/cli.ts CHANGED
@@ -39,6 +39,20 @@ import { auditReportToHtml, auditReportToMarkdown, type ReportOptions } from "./
39
39
  import { builtinAuditRules } from "./rules.ts";
40
40
  import { sampleSnapshot } from "./sampleData.ts";
41
41
  import { normalizeTranscript, parseCall, suggestCallDeal, type ExtractedCallInsight, type ParsedCall } from "./calls.ts";
42
+ import {
43
+ captureMarket,
44
+ computeFrontStates,
45
+ createFileObservationStore,
46
+ diffFrontStates,
47
+ loadCaptureTexts,
48
+ loadMarketConfig,
49
+ starterMarketConfig,
50
+ validateObservationSet,
51
+ verifyEvidenceSpans,
52
+ type ObservationSet,
53
+ } from "./market.ts";
54
+ import { buildWorksheet, classifyMarket } from "./marketClassify.ts";
55
+ import { marketMapToHtml, marketMapToMarkdown } from "./marketReport.ts";
42
56
  import {
43
57
  DEFAULT_RUBRIC,
44
58
  detectProviderFromKey,
@@ -94,6 +108,20 @@ Usage:
94
108
  fullstackgtm resolve <account|contact|deal> [--name N] [--domain D] [--email E] [--account-id A] [source options] [--json]
95
109
  the create gate: exit 0 = safe to create, exit 2 = match
96
110
  found (exists/ambiguous) — call before ANY record creation
111
+ fullstackgtm market init --category <name> start a market map: vendors + claim taxonomy as reviewable config
112
+ fullstackgtm market capture [--config <path>] [--run <label>]
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]
116
+ fullstackgtm market fronts [--run <label>] [--diff <prior-run>] [--json]
117
+ fullstackgtm market report [--run <label>] [--format md|html] [--out <path>]
118
+ fullstackgtm market refresh [--run <label>] [--model m]
119
+ the live competitive map: capture vendor pages (content-addressed),
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
97
125
  fullstackgtm suggest --plan-id <id> | --plan <path> [source options] [--json] [--out <path>]
98
126
  derive values for requires_human_* placeholders
99
127
  from snapshot evidence, with confidence + reasons
@@ -676,15 +704,22 @@ the free keyword baseline; score always needs a key (scoring is LLM work).`);
676
704
  * TTY a missing key is captured once (validated, stored 0600 like provider
677
705
  * logins). Non-interactive contexts get an actionable error instead.
678
706
  */
679
- 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 }> {
680
710
  const resolved = resolveLlmCredential();
681
711
  if (resolved) return resolved;
682
712
  // Scoring is inherently LLM work — there is no keyword fallback to suggest.
683
713
  const fallbackHint =
684
- 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";
685
720
  if (!process.stdin.isTTY) {
686
721
  throw new Error(
687
- `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}.`,
688
723
  );
689
724
  }
690
725
  console.error("LLM parsing needs an API key (Anthropic or OpenAI) — yours, used directly with the provider.");
@@ -801,6 +836,228 @@ function buildCallPlan(
801
836
  };
802
837
  }
803
838
 
839
+ /**
840
+ * The market map: claim taxonomy in a reviewable config file, page captures
841
+ * and append-only observations under the profile home, deterministic front
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.
847
+ */
848
+ async function marketCommand(args: string[]) {
849
+ const [subcommand, ...rest] = args;
850
+ const configPath = () => resolve(process.cwd(), option(rest, "--config") ?? "market.config.json");
851
+
852
+ if (!subcommand || subcommand === "--help") {
853
+ console.log(`Usage:
854
+ market init --category <name> [--out <path>] write a starter market.config.json
855
+ market capture [--config <path>] [--run <label>]
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]
859
+ market fronts [--config <path>] [--run <label>] [--diff <prior-run>] [--json]
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.
868
+
869
+ The taxonomy (vendors + claims) is config you review and version; captures
870
+ and observations live under ~/.fullstackgtm/market/<category> (profile-scoped,
871
+ one client's category intel never bleeds into another's). Front states are
872
+ recomputed deterministically on every invocation — never stored.`);
873
+ return;
874
+ }
875
+
876
+ if (subcommand === "init") {
877
+ const category = option(rest, "--category");
878
+ if (!category) throw new Error("market init requires --category <name>");
879
+ const outPath = resolve(process.cwd(), option(rest, "--out") ?? "market.config.json");
880
+ if (existsSync(outPath)) throw new Error(`${outPath} already exists — refusing to overwrite`);
881
+ writeFileSync(outPath, `${JSON.stringify(starterMarketConfig(category), null, 2)}\n`);
882
+ console.log(`Wrote ${outPath}. Fill in vendors and claims, then: fullstackgtm market capture`);
883
+ return;
884
+ }
885
+
886
+ const config = loadMarketConfig(configPath());
887
+ const store = createFileObservationStore(config.category);
888
+
889
+ if (subcommand === "capture") {
890
+ const result = await captureMarket(config, { runLabel: option(rest, "--run") ?? "run-1" });
891
+ for (const entry of result.entries) {
892
+ const flag = entry.captureHash && entry.textChars > 500 ? "" : " <-- thin/empty";
893
+ console.log(
894
+ `${entry.vendorId.padEnd(16)} ${entry.kind.padEnd(8)} ${String(entry.httpStatus ?? "ERR").padEnd(4)} ${String(entry.textChars).padStart(7)} chars ${entry.url}${flag}`,
895
+ );
896
+ }
897
+ console.log(`\nmanifest: ${result.manifestPath}`);
898
+ return;
899
+ }
900
+
901
+ if (subcommand === "observe") {
902
+ const fromPath = option(rest, "--from");
903
+ if (!fromPath) throw new Error("market observe requires --from <observations.json>");
904
+ const set = JSON.parse(readFileSync(resolve(process.cwd(), fromPath), "utf8")) as ObservationSet;
905
+ const problems = validateObservationSet(config, set);
906
+ if (problems.length > 0) {
907
+ console.error(`Rejected: ${problems.length} problem(s)`);
908
+ for (const problem of problems.slice(0, 20)) console.error(` - ${problem}`);
909
+ process.exitCode = 1;
910
+ return;
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
+ }
925
+ await store.append(set);
926
+ console.log(`Appended ${set.runLabel}: ${set.observations.length} observations (${set.extractor})`);
927
+ return;
928
+ }
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
+
1006
+ const loadSet = async (): Promise<ObservationSet> => {
1007
+ const runLabel = option(rest, "--run");
1008
+ const set = runLabel ? await store.get(runLabel) : await store.latest();
1009
+ if (!set) {
1010
+ throw new Error(
1011
+ runLabel
1012
+ ? `No observation run "${runLabel}" for ${config.category}`
1013
+ : `No observations stored for ${config.category} — run market observe --from <file> first`,
1014
+ );
1015
+ }
1016
+ return set;
1017
+ };
1018
+
1019
+ if (subcommand === "fronts") {
1020
+ const set = await loadSet();
1021
+ const fronts = computeFrontStates(config, set);
1022
+ const priorLabel = option(rest, "--diff");
1023
+ const prior = priorLabel ? await store.get(priorLabel) : null;
1024
+ if (priorLabel && !prior) throw new Error(`No observation run "${priorLabel}" to diff against`);
1025
+ const drift = prior ? diffFrontStates(computeFrontStates(config, prior), fronts) : null;
1026
+ if (rest.includes("--json")) {
1027
+ console.log(JSON.stringify({ runLabel: set.runLabel, fronts, drift }, null, 2));
1028
+ return;
1029
+ }
1030
+ for (const front of fronts) {
1031
+ const owner = front.state === "owned" ? ` → ${front.loudVendorIds[0]}` : "";
1032
+ console.log(`${front.state.toUpperCase().padEnd(10)} ${front.claimId}${owner}`);
1033
+ }
1034
+ if (drift) {
1035
+ console.log("");
1036
+ if (drift.length === 0) console.log(`No front changes since ${priorLabel}.`);
1037
+ for (const change of drift) console.log(`CHANGED ${change.claimId}: ${change.before} → ${change.after}`);
1038
+ }
1039
+ return;
1040
+ }
1041
+
1042
+ if (subcommand === "report") {
1043
+ const set = await loadSet();
1044
+ const format = option(rest, "--format") ?? "md";
1045
+ const output = format === "html" ? marketMapToHtml(config, set) : marketMapToMarkdown(config, set);
1046
+ const outPath = option(rest, "--out");
1047
+ if (outPath) {
1048
+ writeFileSync(resolve(process.cwd(), outPath), output);
1049
+ console.log(`Wrote ${outPath}`);
1050
+ } else {
1051
+ console.log(output);
1052
+ }
1053
+ return;
1054
+ }
1055
+
1056
+ throw new Error(
1057
+ `Unknown market subcommand: ${subcommand} (try: init, capture, classify, worksheet, observe, fronts, report, refresh)`,
1058
+ );
1059
+ }
1060
+
804
1061
  /**
805
1062
  * The resolve gate: exit 0 = safe to create, exit 2 = match found (exists or
806
1063
  * ambiguous — do NOT blind-create), exit 1 = error. Built for sync jobs and
@@ -1665,6 +1922,10 @@ export async function runCli(argv: string[]) {
1665
1922
  await resolveCommand(args);
1666
1923
  return;
1667
1924
  }
1925
+ if (command === "market") {
1926
+ await marketCommand(args);
1927
+ return;
1928
+ }
1668
1929
  if (command === "profiles") {
1669
1930
  profilesCommand(args);
1670
1931
  return;
@@ -431,7 +431,7 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
431
431
  created = await request(`/crm/v3/objects/companies`, {
432
432
  method: "POST",
433
433
  body: JSON.stringify({
434
- properties: { name, hs_object_source_detail_2: "fullstackgtm create: operation" },
434
+ properties: { name, hs_object_source_detail_2: `fullstackgtm create: (${operation.id})` },
435
435
  }),
436
436
  });
437
437
  } catch {
package/src/index.ts CHANGED
@@ -130,6 +130,44 @@ export {
130
130
  type ScoredDimension,
131
131
  } from "./llm.ts";
132
132
  export { resolveRecord, type ResolveCandidate, type ResolveMatch, type ResolveResult } from "./resolve.ts";
133
+ export {
134
+ captureMarket,
135
+ computeFrontStates,
136
+ createFileObservationStore,
137
+ diffFrontStates,
138
+ extractReadableText,
139
+ loadCaptureTexts,
140
+ loadMarketConfig,
141
+ marketHome,
142
+ normalizeForMatch,
143
+ observationId,
144
+ parseMarketConfig,
145
+ starterMarketConfig,
146
+ validateObservationSet,
147
+ verifyEvidenceSpans,
148
+ type CaptureEntry,
149
+ type CaptureOptions,
150
+ type ClaimFront,
151
+ type ClaimIntensity,
152
+ type FrontDrift,
153
+ type FrontState,
154
+ type MarketClaim,
155
+ type MarketConfig,
156
+ type MarketObservation,
157
+ type MarketVendor,
158
+ type ObservationConfidence,
159
+ type ObservationSet,
160
+ type ObservationStore,
161
+ type SpanVerificationFailure,
162
+ } from "./market.ts";
163
+ export {
164
+ buildWorksheet,
165
+ classifyMarket,
166
+ type ClassifyMarketOptions,
167
+ type ClassifyMarketResult,
168
+ type MarketWorksheet,
169
+ } from "./marketClassify.ts";
170
+ export { marketMapToHtml, marketMapToMarkdown } from "./marketReport.ts";
133
171
  export { suggestValues, type SuggestionConfidence, type ValueSuggestion } from "./suggest.ts";
134
172
  export type {
135
173
  ApprovalStatus,
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,