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/docs/api.md CHANGED
@@ -58,7 +58,9 @@ release.
58
58
  ## CLI
59
59
 
60
60
  Commands: `login` / `logout`, `snapshot`, `audit`, `report`, `diff`, `merge`, `plans`,
61
- `apply`, `rules`, `profiles`, `doctor`.
61
+ `apply`, `suggest`, `call` (`parse` / `score` / `link` / `plan`), `resolve`,
62
+ `market` (`init` / `capture` / `classify` / `worksheet` / `observe` / `fronts` /
63
+ `axes` / `report` / `refresh`), `rules`, `profiles`, `doctor`.
62
64
  Exit codes: `0` success · `1` error · `2` findings/regressions at the requested gate
63
65
  (`--fail-on`, `--fail-on-new-findings`). `--json` everywhere; JSON output shapes are stable.
64
66
 
@@ -78,7 +80,32 @@ deliverable in markdown or self-contained HTML: severity counts, prose summary,
78
80
  per-rule detail with capped examples, and next steps. `auditReportToMarkdown` /
79
81
  `auditReportToHtml` expose the same rendering programmatically.
80
82
 
83
+ ## Market map
84
+
85
+ Newer surface (0.16–0.18); shapes are settling toward the 1.0 contract. A live
86
+ model of the competitive category: claim taxonomy + vendor registry as a
87
+ reviewable `market.config.json` (`MarketConfig`, `MarketClaim`, `MarketVendor`,
88
+ `MarketAxis`), content-addressed page captures (`captureMarket`,
89
+ `loadCaptureTexts`), append-only observations (`ObservationSet`,
90
+ `MarketObservation`, `ObservationStore` / `createFileObservationStore` —
91
+ profile-scoped under `<home>/market/<category>`), and deterministic
92
+ derivations: `computeFrontStates` / `diffFrontStates` (front rule v1),
93
+ `assessAxes` / `pcaTop2` / `axisPosition` (axis discovery), and
94
+ `marketMapToMarkdown` / `marketMapToHtml` (the field report; renders the
95
+ primary strategic 2×2 when `axes` / `primaryAxes` are configured).
96
+
97
+ Intensity readings are proposals: `classifyMarket` (LLM, bring-your-own-key,
98
+ provenance-marked) or `buildWorksheet` + `market observe` (agent/human). Every
99
+ quoted evidence span is mechanically verified verbatim
100
+ (`verifyEvidenceSpans`; whitespace and punctuation-spacing normalized) against
101
+ the stored capture it cites before a set is accepted; failed captures read as
102
+ `unobservable`, never `absent`.
103
+
81
104
  ## MCP
82
105
 
83
106
  Tools: `fullstackgtm_audit`, `fullstackgtm_rules`, `fullstackgtm_apply`
84
- (requires explicit `approvedOperationIds`). Input schemas are stable.
107
+ (requires explicit `approvedOperationIds`), `fullstackgtm_suggest`,
108
+ `fullstackgtm_call_parse`, `fullstackgtm_resolve`,
109
+ `fullstackgtm_market_worksheet`, `fullstackgtm_market_observe` (validates,
110
+ verifies quoted spans against the stored captures, appends, returns front
111
+ states). Input schemas are stable.
package/llms.txt CHANGED
@@ -31,6 +31,22 @@ coaching scorecards; `call link` suggests the deal with confidence + reason;
31
31
  `call plan` proposes governed next-step writes through the standard
32
32
  approve/apply lifecycle.
33
33
 
34
+ ## Key invariants (market map)
35
+
36
+ `fullstackgtm market` models the competitive category: vendors + claim
37
+ taxonomy in `market.config.json`; `capture` stores vendor pages
38
+ content-addressed; `classify` (BYO key, same ladder as calls) or
39
+ `worksheet` + `observe` (agent/human channel) propose LOUD/QUIET/ABSENT
40
+ intensity readings per vendor × claim. Every quoted evidence span is
41
+ mechanically verified verbatim against the stored capture it cites;
42
+ unverifiable quotes are rejected (`--unverified` only when captures live
43
+ elsewhere). Failed captures read UNOBSERVABLE, never ABSENT. `fronts --diff`
44
+ = deterministic front states + drift between runs; `axes` = PCA axis
45
+ discovery + orthogonality screen; `report` = self-contained HTML field
46
+ report; `refresh` = capture → classify → drift → report in one command.
47
+ Storage is profile-scoped under `<home>/market/<category>`. MCP:
48
+ `fullstackgtm_market_worksheet`, `fullstackgtm_market_observe`.
49
+
34
50
  ## Key invariants
35
51
 
36
52
  - Reads are safe by default; nothing is written without explicit `--approve`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullstackgtm",
3
- "version": "0.16.0",
3
+ "version": "0.18.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,15 @@ 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 { assessAxes, axesReportToText } from "./marketAxes.ts";
55
+ import { buildWorksheet, classifyMarket } from "./marketClassify.ts";
52
56
  import { marketMapToHtml, marketMapToMarkdown } from "./marketReport.ts";
53
57
  import {
54
58
  DEFAULT_RUBRIC,
@@ -107,13 +111,19 @@ Usage:
107
111
  found (exists/ambiguous) — call before ANY record creation
108
112
  fullstackgtm market init --category <name> start a market map: vendors + claim taxonomy as reviewable config
109
113
  fullstackgtm market capture [--config <path>] [--run <label>]
110
- fullstackgtm market observe --from <observations.json>
114
+ fullstackgtm market classify [--run <label>] [--vendor <id>] [--model m] [--out <path>]
115
+ fullstackgtm market worksheet --vendor <id> [--out <path>]
116
+ fullstackgtm market observe --from <observations.json> [--unverified]
111
117
  fullstackgtm market fronts [--run <label>] [--diff <prior-run>] [--json]
118
+ fullstackgtm market axes [--run <label>] [--json]
112
119
  fullstackgtm market report [--run <label>] [--format md|html] [--out <path>]
120
+ fullstackgtm market refresh [--run <label>] [--model m]
113
121
  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
122
+ classify intensity per claim (LLM bring-your-own-key, or fill the
123
+ worksheet with any agent) — every quoted span is verified verbatim
124
+ against the stored capture it cites before it's accepted — then
125
+ compute deterministic front states and drift, render the field
126
+ report. refresh = capture → classify → drift → report in one step
117
127
  fullstackgtm suggest --plan-id <id> | --plan <path> [source options] [--json] [--out <path>]
118
128
  derive values for requires_human_* placeholders
119
129
  from snapshot evidence, with confidence + reasons
@@ -696,15 +706,22 @@ the free keyword baseline; score always needs a key (scoring is LLM work).`);
696
706
  * TTY a missing key is captured once (validated, stored 0600 like provider
697
707
  * logins). Non-interactive contexts get an actionable error instead.
698
708
  */
699
- async function requireLlmCredential(command: "parse" | "score" = "parse"): Promise<{ provider: LlmProvider; apiKey: string }> {
709
+ async function requireLlmCredential(
710
+ command: "parse" | "score" | "market classify" = "parse",
711
+ ): Promise<{ provider: LlmProvider; apiKey: string }> {
700
712
  const resolved = resolveLlmCredential();
701
713
  if (resolved) return resolved;
702
714
  // Scoring is inherently LLM work — there is no keyword fallback to suggest.
703
715
  const fallbackHint =
704
- command === "parse" ? ", or pass --deterministic for the free keyword baseline" : " (call score has no non-LLM mode)";
716
+ command === "parse"
717
+ ? ", or pass --deterministic for the free keyword baseline"
718
+ : command === "score"
719
+ ? " (call score has no non-LLM mode)"
720
+ : ", or classify by hand: `market worksheet --vendor <id>` then `market observe --from`";
721
+ const work = command === "score" ? "scoring" : command === "parse" ? "extraction" : "classification";
705
722
  if (!process.stdin.isTTY) {
706
723
  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}.`,
724
+ `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
725
  );
709
726
  }
710
727
  console.error("LLM parsing needs an API key (Anthropic or OpenAI) — yours, used directly with the provider.");
@@ -824,9 +841,11 @@ function buildCallPlan(
824
841
  /**
825
842
  * The market map: claim taxonomy in a reviewable config file, page captures
826
843
  * 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.
844
+ * states and reports computed from the store. Intensity readings enter as
845
+ * proposals through two channels `classify` (LLM, bring-your-own-key, the
846
+ * call-intelligence pattern) and `worksheet`/`observe` (an agent or human
847
+ * fills the worksheet) — and BOTH pass the same mechanical gate: every quoted
848
+ * span is verified verbatim against the stored capture it cites.
830
849
  */
831
850
  async function marketCommand(args: string[]) {
832
851
  const [subcommand, ...rest] = args;
@@ -836,9 +855,26 @@ async function marketCommand(args: string[]) {
836
855
  console.log(`Usage:
837
856
  market init --category <name> [--out <path>] write a starter market.config.json
838
857
  market capture [--config <path>] [--run <label>]
839
- market observe --from <observations.json> [--config <path>]
858
+ market classify [--run <label>] [--capture-run <label>] [--vendor <id>] [--model m] [--out <path>]
859
+ market worksheet --vendor <id> [--capture-run <label>] [--out <path>]
860
+ market observe --from <observations.json> [--unverified]
840
861
  market fronts [--config <path>] [--run <label>] [--diff <prior-run>] [--json]
862
+ market axes [--config <path>] [--run <label>] [--json]
841
863
  market report [--config <path>] [--run <label>] [--format md|html] [--out <path>]
864
+ market refresh [--run <label>] [--model m] capture → classify → fronts drift → HTML report
865
+
866
+ axes runs the axis-discovery math: PCA over the vendor × claim intensity
867
+ matrix (PC1 = the category's primary axis, PC2 = the max-differentiation
868
+ direction orthogonal to it), triangulation of configured axes against the
869
+ PCs, and an orthogonality screen (|r|>0.75 = one axis twice). Axes live in
870
+ the config as claim-scoring rubrics; the report's strategic map and axis
871
+ lab render from them.
872
+
873
+ classify uses your Anthropic/OpenAI key (like call parse) to read the stored
874
+ captures and propose intensity readings; worksheet is the no-key path (an
875
+ agent or human fills it, submits via observe). Either way, every quoted span
876
+ is verified character-for-character against the capture it cites before the
877
+ observation is accepted — quotes that aren't on the page bounce.
842
878
 
843
879
  The taxonomy (vendors + claims) is config you review and version; captures
844
880
  and observations live under ~/.fullstackgtm/market/<category> (profile-scoped,
@@ -883,11 +919,100 @@ recomputed deterministically on every invocation — never stored.`);
883
919
  process.exitCode = 1;
884
920
  return;
885
921
  }
922
+ if (!rest.includes("--unverified")) {
923
+ const { textByHash } = loadCaptureTexts(config.category);
924
+ const failures = verifyEvidenceSpans(set.observations, textByHash);
925
+ if (failures.length > 0) {
926
+ console.error(`Rejected: ${failures.length} evidence span(s) failed verification against the stored captures`);
927
+ for (const failure of failures.slice(0, 20)) {
928
+ console.error(` - ${failure.vendorId} × ${failure.claimId}: ${failure.problem}`);
929
+ }
930
+ console.error("Quotes must be copied verbatim from the captured pages. (--unverified skips this gate when the captures genuinely live elsewhere.)");
931
+ process.exitCode = 1;
932
+ return;
933
+ }
934
+ }
886
935
  await store.append(set);
887
936
  console.log(`Appended ${set.runLabel}: ${set.observations.length} observations (${set.extractor})`);
888
937
  return;
889
938
  }
890
939
 
940
+ if (subcommand === "worksheet") {
941
+ const vendorId = option(rest, "--vendor");
942
+ if (!vendorId) throw new Error("market worksheet requires --vendor <id>");
943
+ const worksheet = buildWorksheet(config, vendorId, { captureRun: option(rest, "--capture-run") ?? undefined });
944
+ const outPath = option(rest, "--out");
945
+ const payload = `${JSON.stringify(worksheet, null, 2)}\n`;
946
+ if (outPath) {
947
+ writeFileSync(resolve(process.cwd(), outPath), payload);
948
+ console.log(`Wrote ${outPath} (${worksheet.pages.length} captured pages, ${worksheet.claims.length} claims)`);
949
+ } else {
950
+ console.log(payload);
951
+ }
952
+ return;
953
+ }
954
+
955
+ if (subcommand === "classify") {
956
+ const credential = await requireLlmCredential("market classify");
957
+ const vendorFilter = option(rest, "--vendor");
958
+ const outPath = option(rest, "--out");
959
+ if (vendorFilter && !outPath) {
960
+ throw new Error(
961
+ "market classify --vendor produces a partial set (coverage validation would reject it) — pass --out <path> to inspect/merge it by hand",
962
+ );
963
+ }
964
+ const result = await classifyMarket(config, {
965
+ llm: { ...credential, model: option(rest, "--model") ?? undefined },
966
+ runLabel: option(rest, "--run") ?? option(rest, "--capture-run") ?? "run-1",
967
+ captureRun: option(rest, "--capture-run") ?? undefined,
968
+ vendors: vendorFilter ? [vendorFilter] : undefined,
969
+ });
970
+ if (result.retriedVendorIds.length > 0) {
971
+ console.error(`Span verification bounced ${result.retriedVendorIds.join(", ")} once; retry passed.`);
972
+ }
973
+ if (outPath) {
974
+ writeFileSync(resolve(process.cwd(), outPath), `${JSON.stringify(result.set, null, 2)}\n`);
975
+ console.log(`Wrote ${outPath}: ${result.set.observations.length} verified observations (${result.set.extractor})`);
976
+ return;
977
+ }
978
+ const problems = validateObservationSet(config, result.set);
979
+ if (problems.length > 0) {
980
+ throw new Error(`Classified set failed validation: ${problems.slice(0, 5).join("; ")}`);
981
+ }
982
+ await store.append(result.set);
983
+ console.log(
984
+ `Appended ${result.set.runLabel}: ${result.set.observations.length} observations, every span verified (${result.set.extractor})`,
985
+ );
986
+ return;
987
+ }
988
+
989
+ if (subcommand === "refresh") {
990
+ const credential = await requireLlmCredential("market classify");
991
+ const runLabel = option(rest, "--run") ?? `run-${new Date().toISOString().slice(0, 10)}`;
992
+ const prior = await store.latest();
993
+ console.log(`Capturing ${config.vendors.length} vendors as ${runLabel}…`);
994
+ const captured = await captureMarket(config, { runLabel });
995
+ const failed = captured.entries.filter((entry) => !entry.captureHash);
996
+ if (failed.length > 0) console.log(`${failed.length} page(s) failed/empty — affected cells will verify against remaining pages or read unobservable.`);
997
+ console.log(`Classifying with ${credential.provider}…`);
998
+ const result = await classifyMarket(config, {
999
+ llm: { ...credential, model: option(rest, "--model") ?? undefined },
1000
+ runLabel,
1001
+ captureRun: runLabel,
1002
+ });
1003
+ await store.append(result.set);
1004
+ const fronts = computeFrontStates(config, result.set);
1005
+ if (prior) {
1006
+ const drift = diffFrontStates(computeFrontStates(config, prior), fronts);
1007
+ if (drift.length === 0) console.log(`No front changes since ${prior.runLabel}.`);
1008
+ for (const change of drift) console.log(`CHANGED ${change.claimId}: ${change.before} → ${change.after}`);
1009
+ }
1010
+ const outPath = option(rest, "--out") ?? `${config.category}-${runLabel}.html`;
1011
+ writeFileSync(resolve(process.cwd(), outPath), marketMapToHtml(config, result.set));
1012
+ console.log(`Wrote ${outPath}`);
1013
+ return;
1014
+ }
1015
+
891
1016
  const loadSet = async (): Promise<ObservationSet> => {
892
1017
  const runLabel = option(rest, "--run");
893
1018
  const set = runLabel ? await store.get(runLabel) : await store.latest();
@@ -938,7 +1063,20 @@ recomputed deterministically on every invocation — never stored.`);
938
1063
  return;
939
1064
  }
940
1065
 
941
- throw new Error(`Unknown market subcommand: ${subcommand} (try: init, capture, observe, fronts, report)`);
1066
+ if (subcommand === "axes") {
1067
+ const set = await loadSet();
1068
+ const report = assessAxes(config, set);
1069
+ if (rest.includes("--json")) {
1070
+ console.log(JSON.stringify(report, null, 2));
1071
+ return;
1072
+ }
1073
+ console.log(axesReportToText(report));
1074
+ return;
1075
+ }
1076
+
1077
+ throw new Error(
1078
+ `Unknown market subcommand: ${subcommand} (try: init, capture, classify, worksheet, observe, fronts, axes, report, refresh)`,
1079
+ );
942
1080
  }
943
1081
 
944
1082
  /**
package/src/index.ts CHANGED
@@ -136,18 +136,22 @@ 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,
148
151
  type ClaimIntensity,
149
152
  type FrontDrift,
150
153
  type FrontState,
154
+ type MarketAxis,
151
155
  type MarketClaim,
152
156
  type MarketConfig,
153
157
  type MarketObservation,
@@ -155,7 +159,27 @@ export {
155
159
  type ObservationConfidence,
156
160
  type ObservationSet,
157
161
  type ObservationStore,
162
+ type SpanVerificationFailure,
158
163
  } from "./market.ts";
164
+ export {
165
+ assessAxes,
166
+ axesReportToText,
167
+ axisPosition,
168
+ messageBreadth,
169
+ pcaTop2,
170
+ pearson,
171
+ type AxesReport,
172
+ type AxisAssessment,
173
+ type AxisPairing,
174
+ type PrincipalComponent,
175
+ } from "./marketAxes.ts";
176
+ export {
177
+ buildWorksheet,
178
+ classifyMarket,
179
+ type ClassifyMarketOptions,
180
+ type ClassifyMarketResult,
181
+ type MarketWorksheet,
182
+ } from "./marketClassify.ts";
159
183
  export { marketMapToHtml, marketMapToMarkdown } from "./marketReport.ts";
160
184
  export { suggestValues, type SuggestionConfidence, type ValueSuggestion } from "./suggest.ts";
161
185
  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
@@ -52,6 +52,19 @@ export type MarketVendor = {
52
52
  notes?: string;
53
53
  };
54
54
 
55
+ export type MarketAxis = {
56
+ id: string;
57
+ label: string;
58
+ negativePole: string;
59
+ positivePole: string;
60
+ /** How a human scores a claim on this axis — the axis IS this rubric. */
61
+ rubric: string;
62
+ /** e.g. "validated", "proposal", "proposal (PC2-validated)". Reviewer-facing. */
63
+ status?: string;
64
+ /** claimId → score in [-1, 1]; null = the axis does not apply to this claim. */
65
+ claimScores: Record<string, number | null>;
66
+ };
67
+
55
68
  export type MarketConfig = {
56
69
  category: string;
57
70
  anchorVendor?: string;
@@ -59,6 +72,10 @@ export type MarketConfig = {
59
72
  claims: MarketClaim[];
60
73
  /** The LOUD/QUIET/ABSENT/UNOBSERVABLE judging rule, stated for reviewers. */
61
74
  surfaceRule?: string;
75
+ /** Strategic axes as claim-scoring rubrics — config, not code. */
76
+ axes?: MarketAxis[];
77
+ /** [xAxisId, yAxisId] for the report's strategic map. */
78
+ primaryAxes?: [string, string];
62
79
  };
63
80
 
64
81
  export type MarketObservation = {
@@ -148,6 +165,27 @@ export function parseMarketConfig(raw: string): MarketConfig {
148
165
  if (config.anchorVendor && !config.vendors.some((v) => v.id === config.anchorVendor)) {
149
166
  throw new Error(`market config: anchorVendor "${config.anchorVendor}" is not in vendors`);
150
167
  }
168
+ if (config.axes) {
169
+ const claimIds = new Set(config.claims.map((claim) => claim.id));
170
+ const axisIds = new Set<string>();
171
+ for (const axis of config.axes) {
172
+ if (!axis.id) throw new Error("market config: axis missing id");
173
+ if (axisIds.has(axis.id)) throw new Error(`market config: duplicate axis id "${axis.id}"`);
174
+ axisIds.add(axis.id);
175
+ for (const claimId of Object.keys(axis.claimScores ?? {})) {
176
+ if (!claimIds.has(claimId)) {
177
+ throw new Error(`market config: axis "${axis.id}" scores unknown claim "${claimId}"`);
178
+ }
179
+ }
180
+ }
181
+ if (config.primaryAxes) {
182
+ if (config.primaryAxes.length !== 2 || config.primaryAxes.some((id) => !axisIds.has(id))) {
183
+ throw new Error(`market config: primaryAxes must name two configured axes (got ${JSON.stringify(config.primaryAxes)})`);
184
+ }
185
+ }
186
+ } else if (config.primaryAxes) {
187
+ throw new Error("market config: primaryAxes set but no axes configured");
188
+ }
151
189
  return config;
152
190
  }
153
191
 
@@ -408,6 +446,98 @@ export function validateObservationSet(config: MarketConfig, set: ObservationSet
408
446
  return problems;
409
447
  }
410
448
 
449
+ // ---------------------------------------------------------------------------
450
+ // Evidence span verification — the deterministic gate that makes the
451
+ // verbatim-quote rule mechanical instead of a prompt instruction. Because the
452
+ // source documents are *stored* (unlike call transcripts, which pass through),
453
+ // every quoted span can be checked against the capture it cites before the
454
+ // observation is accepted. Comparison is whitespace-normalized only: case and
455
+ // wording must match the page exactly.
456
+
457
+ export function loadCaptureTexts(
458
+ category: string,
459
+ directory?: string,
460
+ ): { entries: CaptureEntry[]; textByHash: Map<string, string> } {
461
+ const dir = directory ?? join(marketHome(category), "captures");
462
+ const manifestPath = join(dir, "manifest.json");
463
+ const entries: CaptureEntry[] = existsSync(manifestPath)
464
+ ? (JSON.parse(readFileSync(manifestPath, "utf8")) as CaptureEntry[])
465
+ : [];
466
+ const textByHash = new Map<string, string>();
467
+ for (const entry of entries) {
468
+ if (entry.captureHash && !textByHash.has(entry.captureHash)) {
469
+ try {
470
+ textByHash.set(entry.captureHash, readFileSync(join(dir, `${entry.captureHash}.txt`), "utf8"));
471
+ } catch {
472
+ // Missing capture file: verification of anything citing it will fail loudly.
473
+ }
474
+ }
475
+ }
476
+ return { entries, textByHash };
477
+ }
478
+
479
+ /**
480
+ * Whitespace-only normalization for span matching, plus one extraction
481
+ * artifact: the HTML-to-text step can emit a line break before punctuation
482
+ * that follows an inline tag ("placements\n. Districts"), which no honest
483
+ * quoter would reproduce — so whitespace *before* punctuation is dropped
484
+ * too. Words, casing, and characters must still match the page exactly.
485
+ */
486
+ export function normalizeForMatch(value: string): string {
487
+ return value
488
+ .replace(/\s+([.,;:!?])/g, "$1")
489
+ .replace(/\s+/g, " ")
490
+ .trim();
491
+ }
492
+
493
+ export type SpanVerificationFailure = {
494
+ vendorId: string;
495
+ claimId: string;
496
+ quote: string;
497
+ problem: string;
498
+ };
499
+
500
+ export function verifyEvidenceSpans(
501
+ observations: MarketObservation[],
502
+ textByHash: Map<string, string>,
503
+ ): SpanVerificationFailure[] {
504
+ const failures: SpanVerificationFailure[] = [];
505
+ for (const obs of observations) {
506
+ for (const evidence of obs.evidence) {
507
+ const quote = evidence.text ?? "";
508
+ const hash = String(evidence.metadata?.captureHash ?? "");
509
+ if (!hash) {
510
+ failures.push({
511
+ vendorId: obs.vendorId,
512
+ claimId: obs.claimId,
513
+ quote,
514
+ problem: "evidence has no captureHash — spans must cite a stored capture",
515
+ });
516
+ continue;
517
+ }
518
+ const captureText = textByHash.get(hash);
519
+ if (captureText === undefined) {
520
+ failures.push({
521
+ vendorId: obs.vendorId,
522
+ claimId: obs.claimId,
523
+ quote,
524
+ problem: `capture ${hash.slice(0, 12)} not found — evidence must stay resolvable`,
525
+ });
526
+ continue;
527
+ }
528
+ if (!normalizeForMatch(captureText).includes(normalizeForMatch(quote))) {
529
+ failures.push({
530
+ vendorId: obs.vendorId,
531
+ claimId: obs.claimId,
532
+ quote,
533
+ problem: `quote not found verbatim in capture ${hash.slice(0, 12)}`,
534
+ });
535
+ }
536
+ }
537
+ }
538
+ return failures;
539
+ }
540
+
411
541
  // ---------------------------------------------------------------------------
412
542
  // Front states — deterministic, recomputed every time, never stored.
413
543