fullstackgtm 0.16.0 → 0.18.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 CHANGED
@@ -5,6 +5,75 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and the project adheres to [Semantic Versioning](https://semver.org/).
6
6
  The path to 1.0 is planned in [docs/roadmap-to-1.0.md](./docs/roadmap-to-1.0.md).
7
7
 
8
+ ## [0.18.0] — 2026-06-11
9
+
10
+ Axis discovery: earn a strategic 2×2 from the observations instead of
11
+ asserting one.
12
+
13
+ ### Added
14
+
15
+ - **Axes as config** — `axes` in `market.config.json`: each axis is a
16
+ claim-scoring rubric (`{ id, label, poles, rubric, status, claimScores }`,
17
+ null = axis doesn't apply to that claim); a vendor's position is the
18
+ intensity-weighted mean (loud=1, quiet=½) of the claims it voices.
19
+ `primaryAxes: [x, y]` picks the report's strategic map. Config validation
20
+ rejects axes scoring unknown claims.
21
+ - **`fullstackgtm market axes`** — the discovery math, pure and
22
+ dependency-free: PCA (power iteration) over the vendor × claim intensity
23
+ matrix — PC1 is the category's own primary axis, PC2 the
24
+ maximum-differentiation direction orthogonal to it; triangulation of every
25
+ configured axis against the PCs (a real axis is *derivable* from the data,
26
+ not just felt); and an orthogonality screen (|r| ≥ 0.75 = one axis twice —
27
+ sometimes the finding: the category couples the ideas and the empty
28
+ quadrant is the white space). Fully-unobservable vendors are excluded,
29
+ never zeroed.
30
+ - **Report: strategic map** — section 03 renders the primary 2×2 (positions
31
+ computed, not asserted; dot size = LOUD count; axis status in the caption)
32
+ when axes are configured; the evidence appendix renumbers accordingly. The
33
+ report deliberately carries only the one earned 2×2 — best foot forward
34
+ for the client; axis exploration (every pairing, r, verdicts) is `market
35
+ axes` territory for the analyst or agent doing the iterating.
36
+ - **Golden regression**: the 280-cell creative-intelligence validation
37
+ dataset ships as a test fixture — PCA must recover the buyer axis as PC1
38
+ (|r| ≥ 0.9) and value-mode as PC2 (|r| ≥ 0.85), and flag the documented
39
+ buyer × operating-model redundancy.
40
+
41
+ ## [0.17.0] — 2026-06-11
42
+
43
+ Market map classification: intensity readings become a one-command step, and
44
+ the verbatim-quote rule becomes a mechanical gate instead of a prompt
45
+ instruction.
46
+
47
+ ### Added
48
+
49
+ - **`fullstackgtm market classify`** — LLM intensity readings for every
50
+ vendor × claim cell from the stored captures, through the same
51
+ bring-your-own-key constrained-tool-call seam as `call parse`
52
+ (provenance `extractor: "llm:<provider>:<model>"`). Vendors with no
53
+ usable captures score UNOBSERVABLE deterministically, without an LLM
54
+ call. `--vendor` classifies one vendor to `--out` for hand-merging;
55
+ `--model` overrides the provider default.
56
+ - **Mechanical span verification** — because market sources are *stored*
57
+ captures (unlike transcripts, which pass through), every quoted evidence
58
+ span is checked character-for-character (whitespace-normalized) against
59
+ the capture it cites. Readings that fail bounce back to the model once
60
+ with the failures named; persistent failures abort with nothing stored.
61
+ The same gate now guards `market observe` (escape hatch: `--unverified`,
62
+ for sets whose captures genuinely live elsewhere) and the MCP submission
63
+ path — every proposal channel passes the same gate.
64
+ - **`fullstackgtm market worksheet --vendor <id>`** — the no-key channel:
65
+ a self-contained packet (claims with judging definitions, surface rule,
66
+ captured page texts) for an agent or human to classify by hand and
67
+ submit via `observe`.
68
+ - **`fullstackgtm market refresh`** — capture → classify → front drift →
69
+ HTML field report, one command. The weekly refresh is now a single
70
+ invocation (schedule it however you schedule things).
71
+ - **MCP**: `fullstackgtm_market_worksheet` and `fullstackgtm_market_observe`
72
+ (validates + verifies + appends; returns the computed front states on
73
+ acceptance).
74
+ - `forcedToolCall` exported from `llm.ts` — the one seam every LLM feature
75
+ in the package goes through.
76
+
8
77
  ## [0.16.0] — 2026-06-11
9
78
 
10
79
  The market map: a live model of the competitive category a company sells
@@ -49,11 +49,16 @@ In an agent sandbox, prefer rung 1 or 2. Never echo tokens into argv —
49
49
  environments — login flows then print verification URLs instead of opening
50
50
  the OS browser.
51
51
 
52
- LLM calls (`call parse`, `call score`): set `ANTHROPIC_API_KEY` or
53
- `OPENAI_API_KEY` in the environment, or have the human run
54
- `echo "$KEY" | fullstackgtm login anthropic` once. Without a key, use
55
- `call parse --deterministic` (free keyword baseline, no prompt). In
56
- non-interactive contexts the CLI never prompts it fails with this guidance.
52
+ LLM calls (`call parse`, `call score`, `market classify`): set
53
+ `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` in the environment, or have the human
54
+ run `echo "$KEY" | fullstackgtm login anthropic` once. Without a key, use
55
+ `call parse --deterministic` (free keyword baseline, no prompt) — or, for the
56
+ market map, classify it yourself: `fullstackgtm market worksheet --vendor <id>`
57
+ returns the claims, judging rules, and captured page texts; submit your
58
+ readings via `market observe --from <file>`. Quote evidence VERBATIM from the
59
+ page texts — every span is checked character-for-character against the stored
60
+ capture, and paraphrased quotes are rejected. In non-interactive contexts the
61
+ CLI never prompts — it fails with this guidance.
57
62
 
58
63
  Provider prerequisites (what the human must create, and which scopes) are in
59
64
  the README's **"Connect your CRM"** section: HubSpot needs a private app with
package/README.md CHANGED
@@ -109,6 +109,23 @@ npx fullstackgtm report --provider hubspot --client "Acme" --out acme-health.htm
109
109
 
110
110
  `report` renders the same audit as a deliverable — severity counts up front, a prose summary, per-rule detail with example records, and next steps — as markdown or self-contained HTML (printable, emailable, no external assets).
111
111
 
112
+ ## The market map: the category, observed
113
+
114
+ Your CRM records what happened in your own deals; nothing records the shape of the category you sell into. The **market map** does: vendors and a claim taxonomy live in a reviewable `market.config.json`, vendor pages are captured into a content-addressed cache, every vendor × claim cell gets a messaging-intensity reading (LOUD / QUIET / ABSENT — with UNOBSERVABLE for failed captures, never a fake absence), and deterministic front states fall out per claim: open, contested, owned, saturated.
115
+
116
+ ```bash
117
+ fullstackgtm market init --category creative-intelligence # seed vendors + claims, edit by hand
118
+ fullstackgtm market capture # fetch pages → content-addressed captures
119
+ fullstackgtm market classify # LLM readings (BYO key), every quote verified
120
+ fullstackgtm market fronts --diff run-1 # what changed since last run
121
+ fullstackgtm market report --format html --out map.html # the client-ready field report
122
+ fullstackgtm market refresh # all of the above, weekly, one command
123
+ ```
124
+
125
+ The discipline matches the rest of the tool. Intensity readings are *proposals* — from the LLM (`classify`, same bring-your-own-key seam as `call parse`, provenance-marked) or from any agent/human (`market worksheet` → `market observe`) — and **every quoted evidence span is verified character-for-character against the stored capture it cites** before an observation is accepted. Quotes that aren't on the page bounce. Everything downstream of the store is deterministic: same observations, same map.
126
+
127
+ `market axes` is for earning a strategic 2×2 instead of asserting one: PCA over the intensity matrix (PC1 = the category's own primary axis, PC2 = the most differentiating direction orthogonal to it), triangulation of your configured axes against the data, and an orthogonality screen that flags two axes that are secretly one. Axes are claim-scoring rubrics in the config; the report renders the primary pair as the strategic map. Captures and observations are profile-scoped (`~/.fullstackgtm/market/<category>`), so one client's category intel never bleeds into another's.
128
+
112
129
  ### Working across organizations
113
130
 
114
131
  Consultants and fractional operators hold credentials for several CRMs at once. A profile scopes stored logins *and* stored plans to one organization:
package/dist/cli.js CHANGED
@@ -18,7 +18,9 @@ import { auditReportToHtml, auditReportToMarkdown } from "./report.js";
18
18
  import { builtinAuditRules } from "./rules.js";
19
19
  import { sampleSnapshot } from "./sampleData.js";
20
20
  import { normalizeTranscript, parseCall, suggestCallDeal } from "./calls.js";
21
- import { captureMarket, computeFrontStates, createFileObservationStore, diffFrontStates, loadMarketConfig, starterMarketConfig, validateObservationSet, } from "./market.js";
21
+ import { captureMarket, computeFrontStates, createFileObservationStore, diffFrontStates, loadCaptureTexts, loadMarketConfig, starterMarketConfig, validateObservationSet, verifyEvidenceSpans, } from "./market.js";
22
+ import { assessAxes, axesReportToText } from "./marketAxes.js";
23
+ import { buildWorksheet, classifyMarket } from "./marketClassify.js";
22
24
  import { marketMapToHtml, marketMapToMarkdown } from "./marketReport.js";
23
25
  import { DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, } from "./llm.js";
24
26
  import { resolveRecord } from "./resolve.js";
@@ -59,13 +61,19 @@ Usage:
59
61
  found (exists/ambiguous) — call before ANY record creation
60
62
  fullstackgtm market init --category <name> start a market map: vendors + claim taxonomy as reviewable config
61
63
  fullstackgtm market capture [--config <path>] [--run <label>]
62
- fullstackgtm market observe --from <observations.json>
64
+ fullstackgtm market classify [--run <label>] [--vendor <id>] [--model m] [--out <path>]
65
+ fullstackgtm market worksheet --vendor <id> [--out <path>]
66
+ fullstackgtm market observe --from <observations.json> [--unverified]
63
67
  fullstackgtm market fronts [--run <label>] [--diff <prior-run>] [--json]
68
+ fullstackgtm market axes [--run <label>] [--json]
64
69
  fullstackgtm market report [--run <label>] [--format md|html] [--out <path>]
70
+ fullstackgtm market refresh [--run <label>] [--model m]
65
71
  the live competitive map: capture vendor pages (content-addressed),
66
- ingest intensity readings with verbatim-quote evidence, compute
67
- deterministic front states (open/contested/owned/saturated) and
68
- drift between runs, render the client-ready field report
72
+ classify intensity per claim (LLM bring-your-own-key, or fill the
73
+ worksheet with any agent) — every quoted span is verified verbatim
74
+ against the stored capture it cites before it's accepted — then
75
+ compute deterministic front states and drift, render the field
76
+ report. refresh = capture → classify → drift → report in one step
69
77
  fullstackgtm suggest --plan-id <id> | --plan <path> [source options] [--json] [--out <path>]
70
78
  derive values for requires_human_* placeholders
71
79
  from snapshot evidence, with confidence + reasons
@@ -617,9 +625,14 @@ async function requireLlmCredential(command = "parse") {
617
625
  if (resolved)
618
626
  return resolved;
619
627
  // Scoring is inherently LLM work — there is no keyword fallback to suggest.
620
- const fallbackHint = command === "parse" ? ", or pass --deterministic for the free keyword baseline" : " (call score has no non-LLM mode)";
628
+ const fallbackHint = command === "parse"
629
+ ? ", or pass --deterministic for the free keyword baseline"
630
+ : command === "score"
631
+ ? " (call score has no non-LLM mode)"
632
+ : ", or classify by hand: `market worksheet --vendor <id>` then `market observe --from`";
633
+ const work = command === "score" ? "scoring" : command === "parse" ? "extraction" : "classification";
621
634
  if (!process.stdin.isTTY) {
622
- throw new Error(`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}.`);
635
+ throw new Error(`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}.`);
623
636
  }
624
637
  console.error("LLM parsing needs an API key (Anthropic or OpenAI) — yours, used directly with the provider.");
625
638
  console.error(`Paste it once; it is validated and stored at ${credentialsPath()} (file mode 0600), like CRM logins.`);
@@ -729,9 +742,11 @@ function buildCallPlan(parsed, deal, proposed, current, extraNextSteps) {
729
742
  /**
730
743
  * The market map: claim taxonomy in a reviewable config file, page captures
731
744
  * and append-only observations under the profile home, deterministic front
732
- * states and reports computed from the store. Classification (LLM intensity
733
- * readings) lands in a later change; until then `market observe --from`
734
- * ingests proposal files produced by an agent or a human.
745
+ * states and reports computed from the store. Intensity readings enter as
746
+ * proposals through two channels `classify` (LLM, bring-your-own-key, the
747
+ * call-intelligence pattern) and `worksheet`/`observe` (an agent or human
748
+ * fills the worksheet) — and BOTH pass the same mechanical gate: every quoted
749
+ * span is verified verbatim against the stored capture it cites.
735
750
  */
736
751
  async function marketCommand(args) {
737
752
  const [subcommand, ...rest] = args;
@@ -740,9 +755,26 @@ async function marketCommand(args) {
740
755
  console.log(`Usage:
741
756
  market init --category <name> [--out <path>] write a starter market.config.json
742
757
  market capture [--config <path>] [--run <label>]
743
- market observe --from <observations.json> [--config <path>]
758
+ market classify [--run <label>] [--capture-run <label>] [--vendor <id>] [--model m] [--out <path>]
759
+ market worksheet --vendor <id> [--capture-run <label>] [--out <path>]
760
+ market observe --from <observations.json> [--unverified]
744
761
  market fronts [--config <path>] [--run <label>] [--diff <prior-run>] [--json]
762
+ market axes [--config <path>] [--run <label>] [--json]
745
763
  market report [--config <path>] [--run <label>] [--format md|html] [--out <path>]
764
+ market refresh [--run <label>] [--model m] capture → classify → fronts drift → HTML report
765
+
766
+ axes runs the axis-discovery math: PCA over the vendor × claim intensity
767
+ matrix (PC1 = the category's primary axis, PC2 = the max-differentiation
768
+ direction orthogonal to it), triangulation of configured axes against the
769
+ PCs, and an orthogonality screen (|r|>0.75 = one axis twice). Axes live in
770
+ the config as claim-scoring rubrics; the report's strategic map and axis
771
+ lab render from them.
772
+
773
+ classify uses your Anthropic/OpenAI key (like call parse) to read the stored
774
+ captures and propose intensity readings; worksheet is the no-key path (an
775
+ agent or human fills it, submits via observe). Either way, every quoted span
776
+ is verified character-for-character against the capture it cites before the
777
+ observation is accepted — quotes that aren't on the page bounce.
746
778
 
747
779
  The taxonomy (vendors + claims) is config you review and version; captures
748
780
  and observations live under ~/.fullstackgtm/market/<category> (profile-scoped,
@@ -785,10 +817,97 @@ recomputed deterministically on every invocation — never stored.`);
785
817
  process.exitCode = 1;
786
818
  return;
787
819
  }
820
+ if (!rest.includes("--unverified")) {
821
+ const { textByHash } = loadCaptureTexts(config.category);
822
+ const failures = verifyEvidenceSpans(set.observations, textByHash);
823
+ if (failures.length > 0) {
824
+ console.error(`Rejected: ${failures.length} evidence span(s) failed verification against the stored captures`);
825
+ for (const failure of failures.slice(0, 20)) {
826
+ console.error(` - ${failure.vendorId} × ${failure.claimId}: ${failure.problem}`);
827
+ }
828
+ console.error("Quotes must be copied verbatim from the captured pages. (--unverified skips this gate when the captures genuinely live elsewhere.)");
829
+ process.exitCode = 1;
830
+ return;
831
+ }
832
+ }
788
833
  await store.append(set);
789
834
  console.log(`Appended ${set.runLabel}: ${set.observations.length} observations (${set.extractor})`);
790
835
  return;
791
836
  }
837
+ if (subcommand === "worksheet") {
838
+ const vendorId = option(rest, "--vendor");
839
+ if (!vendorId)
840
+ throw new Error("market worksheet requires --vendor <id>");
841
+ const worksheet = buildWorksheet(config, vendorId, { captureRun: option(rest, "--capture-run") ?? undefined });
842
+ const outPath = option(rest, "--out");
843
+ const payload = `${JSON.stringify(worksheet, null, 2)}\n`;
844
+ if (outPath) {
845
+ writeFileSync(resolve(process.cwd(), outPath), payload);
846
+ console.log(`Wrote ${outPath} (${worksheet.pages.length} captured pages, ${worksheet.claims.length} claims)`);
847
+ }
848
+ else {
849
+ console.log(payload);
850
+ }
851
+ return;
852
+ }
853
+ if (subcommand === "classify") {
854
+ const credential = await requireLlmCredential("market classify");
855
+ const vendorFilter = option(rest, "--vendor");
856
+ const outPath = option(rest, "--out");
857
+ if (vendorFilter && !outPath) {
858
+ throw new Error("market classify --vendor produces a partial set (coverage validation would reject it) — pass --out <path> to inspect/merge it by hand");
859
+ }
860
+ const result = await classifyMarket(config, {
861
+ llm: { ...credential, model: option(rest, "--model") ?? undefined },
862
+ runLabel: option(rest, "--run") ?? option(rest, "--capture-run") ?? "run-1",
863
+ captureRun: option(rest, "--capture-run") ?? undefined,
864
+ vendors: vendorFilter ? [vendorFilter] : undefined,
865
+ });
866
+ if (result.retriedVendorIds.length > 0) {
867
+ console.error(`Span verification bounced ${result.retriedVendorIds.join(", ")} once; retry passed.`);
868
+ }
869
+ if (outPath) {
870
+ writeFileSync(resolve(process.cwd(), outPath), `${JSON.stringify(result.set, null, 2)}\n`);
871
+ console.log(`Wrote ${outPath}: ${result.set.observations.length} verified observations (${result.set.extractor})`);
872
+ return;
873
+ }
874
+ const problems = validateObservationSet(config, result.set);
875
+ if (problems.length > 0) {
876
+ throw new Error(`Classified set failed validation: ${problems.slice(0, 5).join("; ")}`);
877
+ }
878
+ await store.append(result.set);
879
+ console.log(`Appended ${result.set.runLabel}: ${result.set.observations.length} observations, every span verified (${result.set.extractor})`);
880
+ return;
881
+ }
882
+ if (subcommand === "refresh") {
883
+ const credential = await requireLlmCredential("market classify");
884
+ const runLabel = option(rest, "--run") ?? `run-${new Date().toISOString().slice(0, 10)}`;
885
+ const prior = await store.latest();
886
+ console.log(`Capturing ${config.vendors.length} vendors as ${runLabel}…`);
887
+ const captured = await captureMarket(config, { runLabel });
888
+ const failed = captured.entries.filter((entry) => !entry.captureHash);
889
+ if (failed.length > 0)
890
+ console.log(`${failed.length} page(s) failed/empty — affected cells will verify against remaining pages or read unobservable.`);
891
+ console.log(`Classifying with ${credential.provider}…`);
892
+ const result = await classifyMarket(config, {
893
+ llm: { ...credential, model: option(rest, "--model") ?? undefined },
894
+ runLabel,
895
+ captureRun: runLabel,
896
+ });
897
+ await store.append(result.set);
898
+ const fronts = computeFrontStates(config, result.set);
899
+ if (prior) {
900
+ const drift = diffFrontStates(computeFrontStates(config, prior), fronts);
901
+ if (drift.length === 0)
902
+ console.log(`No front changes since ${prior.runLabel}.`);
903
+ for (const change of drift)
904
+ console.log(`CHANGED ${change.claimId}: ${change.before} → ${change.after}`);
905
+ }
906
+ const outPath = option(rest, "--out") ?? `${config.category}-${runLabel}.html`;
907
+ writeFileSync(resolve(process.cwd(), outPath), marketMapToHtml(config, result.set));
908
+ console.log(`Wrote ${outPath}`);
909
+ return;
910
+ }
792
911
  const loadSet = async () => {
793
912
  const runLabel = option(rest, "--run");
794
913
  const set = runLabel ? await store.get(runLabel) : await store.latest();
@@ -838,7 +957,17 @@ recomputed deterministically on every invocation — never stored.`);
838
957
  }
839
958
  return;
840
959
  }
841
- throw new Error(`Unknown market subcommand: ${subcommand} (try: init, capture, observe, fronts, report)`);
960
+ if (subcommand === "axes") {
961
+ const set = await loadSet();
962
+ const report = assessAxes(config, set);
963
+ if (rest.includes("--json")) {
964
+ console.log(JSON.stringify(report, null, 2));
965
+ return;
966
+ }
967
+ console.log(axesReportToText(report));
968
+ return;
969
+ }
970
+ throw new Error(`Unknown market subcommand: ${subcommand} (try: init, capture, classify, worksheet, observe, fronts, axes, report, refresh)`);
842
971
  }
843
972
  /**
844
973
  * The resolve gate: exit 0 = safe to create, exit 2 = match found (exists or
package/dist/index.d.ts CHANGED
@@ -19,7 +19,9 @@ export { extractCallInsights, normalizeTranscript, parseCall, parseTranscript, s
19
19
  export { sampleSnapshot } from "./sampleData.ts";
20
20
  export { DEFAULT_MODELS, DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, type CallScorecard, type LlmCredential, type LlmExtractedInsight, type LlmProvider, type Rubric, type ScoredDimension, } from "./llm.ts";
21
21
  export { resolveRecord, type ResolveCandidate, type ResolveMatch, type ResolveResult } from "./resolve.ts";
22
- export { captureMarket, computeFrontStates, createFileObservationStore, diffFrontStates, extractReadableText, loadMarketConfig, marketHome, observationId, parseMarketConfig, starterMarketConfig, validateObservationSet, type CaptureEntry, type CaptureOptions, type ClaimFront, type ClaimIntensity, type FrontDrift, type FrontState, type MarketClaim, type MarketConfig, type MarketObservation, type MarketVendor, type ObservationConfidence, type ObservationSet, type ObservationStore, } from "./market.ts";
22
+ export { captureMarket, computeFrontStates, createFileObservationStore, diffFrontStates, extractReadableText, loadCaptureTexts, loadMarketConfig, marketHome, normalizeForMatch, observationId, parseMarketConfig, starterMarketConfig, validateObservationSet, verifyEvidenceSpans, type CaptureEntry, type CaptureOptions, type ClaimFront, type ClaimIntensity, type FrontDrift, type FrontState, type MarketAxis, type MarketClaim, type MarketConfig, type MarketObservation, type MarketVendor, type ObservationConfidence, type ObservationSet, type ObservationStore, type SpanVerificationFailure, } from "./market.ts";
23
+ export { assessAxes, axesReportToText, axisPosition, messageBreadth, pcaTop2, pearson, type AxesReport, type AxisAssessment, type AxisPairing, type PrincipalComponent, } from "./marketAxes.ts";
24
+ export { buildWorksheet, classifyMarket, type ClassifyMarketOptions, type ClassifyMarketResult, type MarketWorksheet, } from "./marketClassify.ts";
23
25
  export { marketMapToHtml, marketMapToMarkdown } from "./marketReport.ts";
24
26
  export { suggestValues, type SuggestionConfidence, type ValueSuggestion } from "./suggest.ts";
25
27
  export type { ApprovalStatus, AuditFinding, AuditFindingSeverity, CanonicalAccount, CanonicalActivity, CanonicalContact, CanonicalDeal, CanonicalGtmSnapshot, CanonicalUser, CrmProvider, GtmAuditRule, GtmConnector, GtmEvidence, GtmEvidenceSourceSystem, GtmObjectType, GtmPolicy, GtmRuleContext, GtmRuleResult, GtmSnapshotIndex, PatchOperation, PatchOperationResult, PatchOperationType, PatchPlan, PatchPlanRun, PatchPlanRunStatus, PatchVerification, PipelineFinding, PipelineFindingStatus, PipelineFindingType, ProviderIdentity, RiskLevel, SourceFreshness, } from "./types.ts";
package/dist/index.js CHANGED
@@ -19,6 +19,8 @@ export { extractCallInsights, normalizeTranscript, parseCall, parseTranscript, s
19
19
  export { sampleSnapshot } from "./sampleData.js";
20
20
  export { DEFAULT_MODELS, DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, } from "./llm.js";
21
21
  export { resolveRecord } from "./resolve.js";
22
- export { captureMarket, computeFrontStates, createFileObservationStore, diffFrontStates, extractReadableText, loadMarketConfig, marketHome, observationId, parseMarketConfig, starterMarketConfig, validateObservationSet, } from "./market.js";
22
+ export { captureMarket, computeFrontStates, createFileObservationStore, diffFrontStates, extractReadableText, loadCaptureTexts, loadMarketConfig, marketHome, normalizeForMatch, observationId, parseMarketConfig, starterMarketConfig, validateObservationSet, verifyEvidenceSpans, } from "./market.js";
23
+ export { assessAxes, axesReportToText, axisPosition, messageBreadth, pcaTop2, pearson, } from "./marketAxes.js";
24
+ export { buildWorksheet, classifyMarket, } from "./marketClassify.js";
23
25
  export { marketMapToHtml, marketMapToMarkdown } from "./marketReport.js";
24
26
  export { suggestValues } from "./suggest.js";
package/dist/llm.d.ts CHANGED
@@ -64,6 +64,13 @@ export declare function scoreCallLlm(transcript: string, rubric: Rubric, options
64
64
  title?: string;
65
65
  }): Promise<CallScorecard>;
66
66
  export declare function parseRubric(json: string): Rubric;
67
+ /**
68
+ * Shared constrained-tool-call plumbing: force the model to answer through a
69
+ * single tool whose input_schema is the output contract. Exported for other
70
+ * semi-deterministic features (market classification) — every LLM feature in
71
+ * the package goes through this one seam.
72
+ */
73
+ export declare function forcedToolCall(prompt: string, toolName: string, schema: object, model: string, options: LlmCallOptions): Promise<unknown>;
67
74
  /** Cheap key validation against the provider's model-list endpoint. Status line only. */
68
75
  export declare function validateLlmKey(provider: LlmProvider, apiKey: string, fetchImpl?: typeof fetch): Promise<{
69
76
  ok: boolean;
package/dist/llm.js CHANGED
@@ -158,7 +158,13 @@ export function parseRubric(json) {
158
158
  };
159
159
  }
160
160
  // ── Provider plumbing (raw fetch, forced tool calls) ───────────────────────
161
- async function forcedToolCall(prompt, toolName, schema, model, options) {
161
+ /**
162
+ * Shared constrained-tool-call plumbing: force the model to answer through a
163
+ * single tool whose input_schema is the output contract. Exported for other
164
+ * semi-deterministic features (market classification) — every LLM feature in
165
+ * the package goes through this one seam.
166
+ */
167
+ export async function forcedToolCall(prompt, toolName, schema, model, options) {
162
168
  const fetchImpl = options.fetchImpl ?? fetch;
163
169
  if (options.provider === "anthropic") {
164
170
  const response = await llmFetch(fetchImpl, ANTHROPIC_URL, {
package/dist/market.d.ts CHANGED
@@ -41,6 +41,18 @@ export type MarketVendor = {
41
41
  };
42
42
  notes?: string;
43
43
  };
44
+ export type MarketAxis = {
45
+ id: string;
46
+ label: string;
47
+ negativePole: string;
48
+ positivePole: string;
49
+ /** How a human scores a claim on this axis — the axis IS this rubric. */
50
+ rubric: string;
51
+ /** e.g. "validated", "proposal", "proposal (PC2-validated)". Reviewer-facing. */
52
+ status?: string;
53
+ /** claimId → score in [-1, 1]; null = the axis does not apply to this claim. */
54
+ claimScores: Record<string, number | null>;
55
+ };
44
56
  export type MarketConfig = {
45
57
  category: string;
46
58
  anchorVendor?: string;
@@ -48,6 +60,10 @@ export type MarketConfig = {
48
60
  claims: MarketClaim[];
49
61
  /** The LOUD/QUIET/ABSENT/UNOBSERVABLE judging rule, stated for reviewers. */
50
62
  surfaceRule?: string;
63
+ /** Strategic axes as claim-scoring rubrics — config, not code. */
64
+ axes?: MarketAxis[];
65
+ /** [xAxisId, yAxisId] for the report's strategic map. */
66
+ primaryAxes?: [string, string];
51
67
  };
52
68
  export type MarketObservation = {
53
69
  /** stableHash(category, runLabel, vendorId, claimId) — deterministic. */
@@ -126,6 +142,25 @@ export declare function createFileObservationStore(category: string, directory?:
126
142
  * Returns problems; an empty array means accept.
127
143
  */
128
144
  export declare function validateObservationSet(config: MarketConfig, set: ObservationSet): string[];
145
+ export declare function loadCaptureTexts(category: string, directory?: string): {
146
+ entries: CaptureEntry[];
147
+ textByHash: Map<string, string>;
148
+ };
149
+ /**
150
+ * Whitespace-only normalization for span matching, plus one extraction
151
+ * artifact: the HTML-to-text step can emit a line break before punctuation
152
+ * that follows an inline tag ("placements\n. Districts"), which no honest
153
+ * quoter would reproduce — so whitespace *before* punctuation is dropped
154
+ * too. Words, casing, and characters must still match the page exactly.
155
+ */
156
+ export declare function normalizeForMatch(value: string): string;
157
+ export type SpanVerificationFailure = {
158
+ vendorId: string;
159
+ claimId: string;
160
+ quote: string;
161
+ problem: string;
162
+ };
163
+ export declare function verifyEvidenceSpans(observations: MarketObservation[], textByHash: Map<string, string>): SpanVerificationFailure[];
129
164
  export type ClaimFront = {
130
165
  claimId: string;
131
166
  state: FrontState;
package/dist/market.js CHANGED
@@ -49,6 +49,30 @@ export function parseMarketConfig(raw) {
49
49
  if (config.anchorVendor && !config.vendors.some((v) => v.id === config.anchorVendor)) {
50
50
  throw new Error(`market config: anchorVendor "${config.anchorVendor}" is not in vendors`);
51
51
  }
52
+ if (config.axes) {
53
+ const claimIds = new Set(config.claims.map((claim) => claim.id));
54
+ const axisIds = new Set();
55
+ for (const axis of config.axes) {
56
+ if (!axis.id)
57
+ throw new Error("market config: axis missing id");
58
+ if (axisIds.has(axis.id))
59
+ throw new Error(`market config: duplicate axis id "${axis.id}"`);
60
+ axisIds.add(axis.id);
61
+ for (const claimId of Object.keys(axis.claimScores ?? {})) {
62
+ if (!claimIds.has(claimId)) {
63
+ throw new Error(`market config: axis "${axis.id}" scores unknown claim "${claimId}"`);
64
+ }
65
+ }
66
+ }
67
+ if (config.primaryAxes) {
68
+ if (config.primaryAxes.length !== 2 || config.primaryAxes.some((id) => !axisIds.has(id))) {
69
+ throw new Error(`market config: primaryAxes must name two configured axes (got ${JSON.stringify(config.primaryAxes)})`);
70
+ }
71
+ }
72
+ }
73
+ else if (config.primaryAxes) {
74
+ throw new Error("market config: primaryAxes set but no axes configured");
75
+ }
52
76
  return config;
53
77
  }
54
78
  export function loadMarketConfig(path) {
@@ -270,6 +294,82 @@ export function validateObservationSet(config, set) {
270
294
  }
271
295
  return problems;
272
296
  }
297
+ // ---------------------------------------------------------------------------
298
+ // Evidence span verification — the deterministic gate that makes the
299
+ // verbatim-quote rule mechanical instead of a prompt instruction. Because the
300
+ // source documents are *stored* (unlike call transcripts, which pass through),
301
+ // every quoted span can be checked against the capture it cites before the
302
+ // observation is accepted. Comparison is whitespace-normalized only: case and
303
+ // wording must match the page exactly.
304
+ export function loadCaptureTexts(category, directory) {
305
+ const dir = directory ?? join(marketHome(category), "captures");
306
+ const manifestPath = join(dir, "manifest.json");
307
+ const entries = existsSync(manifestPath)
308
+ ? JSON.parse(readFileSync(manifestPath, "utf8"))
309
+ : [];
310
+ const textByHash = new Map();
311
+ for (const entry of entries) {
312
+ if (entry.captureHash && !textByHash.has(entry.captureHash)) {
313
+ try {
314
+ textByHash.set(entry.captureHash, readFileSync(join(dir, `${entry.captureHash}.txt`), "utf8"));
315
+ }
316
+ catch {
317
+ // Missing capture file: verification of anything citing it will fail loudly.
318
+ }
319
+ }
320
+ }
321
+ return { entries, textByHash };
322
+ }
323
+ /**
324
+ * Whitespace-only normalization for span matching, plus one extraction
325
+ * artifact: the HTML-to-text step can emit a line break before punctuation
326
+ * that follows an inline tag ("placements\n. Districts"), which no honest
327
+ * quoter would reproduce — so whitespace *before* punctuation is dropped
328
+ * too. Words, casing, and characters must still match the page exactly.
329
+ */
330
+ export function normalizeForMatch(value) {
331
+ return value
332
+ .replace(/\s+([.,;:!?])/g, "$1")
333
+ .replace(/\s+/g, " ")
334
+ .trim();
335
+ }
336
+ export function verifyEvidenceSpans(observations, textByHash) {
337
+ const failures = [];
338
+ for (const obs of observations) {
339
+ for (const evidence of obs.evidence) {
340
+ const quote = evidence.text ?? "";
341
+ const hash = String(evidence.metadata?.captureHash ?? "");
342
+ if (!hash) {
343
+ failures.push({
344
+ vendorId: obs.vendorId,
345
+ claimId: obs.claimId,
346
+ quote,
347
+ problem: "evidence has no captureHash — spans must cite a stored capture",
348
+ });
349
+ continue;
350
+ }
351
+ const captureText = textByHash.get(hash);
352
+ if (captureText === undefined) {
353
+ failures.push({
354
+ vendorId: obs.vendorId,
355
+ claimId: obs.claimId,
356
+ quote,
357
+ problem: `capture ${hash.slice(0, 12)} not found — evidence must stay resolvable`,
358
+ });
359
+ continue;
360
+ }
361
+ if (!normalizeForMatch(captureText).includes(normalizeForMatch(quote))) {
362
+ failures.push({
363
+ vendorId: obs.vendorId,
364
+ claimId: obs.claimId,
365
+ quote,
366
+ problem: `quote not found verbatim in capture ${hash.slice(0, 12)}`,
367
+ });
368
+ }
369
+ }
370
+ }
371
+ return failures;
372
+ }
273
373
  /**
274
374
  * Front rule v1: 0 loud → open (if anyone is quiet) or vacant; 1 loud →
275
375
  * owned; 2–3 loud → contested; ≥4 loud → saturated. Unobservable cells are
@@ -0,0 +1,77 @@
1
+ import type { MarketAxis, MarketConfig, MarketObservation, ObservationSet } from "./market.ts";
2
+ /**
3
+ * Axis discovery for a market map — the method that earns a strategic 2x2
4
+ * instead of asserting one. Axes are claim-scoring rubrics in the config
5
+ * (reviewable, versioned); a vendor's position on an axis is the
6
+ * intensity-weighted mean of the scores of claims it voices. Two checks keep
7
+ * axes honest, both computed deterministically from the stored observations:
8
+ *
9
+ * 1. Triangulation — PCA over the vendor × claim intensity matrix gives the
10
+ * category's own top variance directions; a real axis correlates with a
11
+ * principal component (it is derivable from the data, not just felt).
12
+ * 2. Orthogonality — two configured axes that correlate ≥ ~0.75 at the
13
+ * vendor level are one axis twice. Sometimes that redundancy is the
14
+ * finding: the category couples the two ideas, and the empty quadrant is
15
+ * the strategic white space.
16
+ *
17
+ * Everything here is pure math over the store: same observations, same map.
18
+ */
19
+ export declare const VOICE_WEIGHT: Record<string, number>;
20
+ /**
21
+ * Intensity-weighted mean of claim scores over claims the vendor voices.
22
+ * Claims scored null on the axis are excluded; returns null if the vendor
23
+ * voices nothing scoreable (e.g. fully unobservable).
24
+ */
25
+ export declare function axisPosition(vendorId: string, claimScores: Record<string, number | null>, observations: MarketObservation[]): number | null;
26
+ /** Share of the claim space voiced (loud + half-weight quiet) over observable claims. */
27
+ export declare function messageBreadth(vendorId: string, observations: MarketObservation[]): {
28
+ breadth: number | null;
29
+ loudCount: number;
30
+ };
31
+ export declare function pearson(xs: number[], ys: number[]): number;
32
+ export type PrincipalComponent = {
33
+ /** claimId → loading. Sign is arbitrary; read poles from the extremes. */
34
+ loadings: Array<{
35
+ claimId: string;
36
+ loading: number;
37
+ }>;
38
+ /** vendorId → score on this component. */
39
+ scores: Array<{
40
+ vendorId: string;
41
+ score: number;
42
+ }>;
43
+ };
44
+ export declare function pcaTop2(config: MarketConfig, set: ObservationSet): {
45
+ vendors: string[];
46
+ pc1: PrincipalComponent;
47
+ pc2: PrincipalComponent;
48
+ };
49
+ export type AxisVendorPosition = {
50
+ vendorId: string;
51
+ position: number | null;
52
+ };
53
+ export type AxisAssessment = {
54
+ axis: MarketAxis;
55
+ positions: AxisVendorPosition[];
56
+ /** Standard deviation of placeable vendor positions — does the axis separate anyone? */
57
+ spread: number;
58
+ rVsPc1: number;
59
+ rVsPc2: number;
60
+ };
61
+ export type AxisPairing = {
62
+ aId: string;
63
+ bId: string;
64
+ r: number;
65
+ verdict: "near-orthogonal" | "correlated — weak pair" | "redundant — same axis twice";
66
+ };
67
+ export type AxesReport = {
68
+ vendors: string[];
69
+ pc1: PrincipalComponent;
70
+ pc2: PrincipalComponent;
71
+ assessments: AxisAssessment[];
72
+ /** Includes the derived breadth axis in pairings. */
73
+ pairings: AxisPairing[];
74
+ };
75
+ export declare function pairingVerdict(r: number): AxisPairing["verdict"];
76
+ export declare function assessAxes(config: MarketConfig, set: ObservationSet): AxesReport;
77
+ export declare function axesReportToText(report: AxesReport): string;