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.
@@ -6,6 +6,7 @@ import type {
6
6
  ObservationSet,
7
7
  } from "./market.ts";
8
8
  import { computeFrontStates } from "./market.ts";
9
+ import { assessAxes, messageBreadth, type AxesReport } from "./marketAxes.ts";
9
10
 
10
11
  /**
11
12
  * Render a market map as a client-ready deliverable: markdown for terminals
@@ -104,6 +105,127 @@ export function marketMapToMarkdown(config: MarketConfig, set: ObservationSet):
104
105
  return `${lines.join("\n")}\n`;
105
106
  }
106
107
 
108
+ type ScatterPoint = { vendorId: string; name: string; x: number; y: number; loud: number };
109
+ type ScatterAxis = { label: string; negativePole: string; positivePole: string; signed: boolean };
110
+
111
+ function svgScatter(
112
+ points: ScatterPoint[],
113
+ ax: ScatterAxis,
114
+ ay: ScatterAxis,
115
+ anchor: string | undefined,
116
+ mini: boolean,
117
+ ): string {
118
+ const W = mini ? 330 : 700;
119
+ const H = mini ? 250 : 460;
120
+ const PAD = mini ? 34 : 56;
121
+ const range = (axis: ScatterAxis, values: number[]): [number, number] => {
122
+ if (axis.signed) return [-1.1, 1.1];
123
+ if (values.length === 0) return [0, 1];
124
+ return [Math.min(0, Math.min(...values) - 0.05), Math.max(...values) + 0.08];
125
+ };
126
+ const [xLo, xHi] = range(ax, points.map((p) => p.x));
127
+ const [yLo, yHi] = range(ay, points.map((p) => p.y));
128
+ const sx = (x: number) => PAD + ((x - xLo) / (xHi - xLo)) * (W - 2 * PAD);
129
+ const sy = (y: number) => H - PAD - ((y - yLo) / (yHi - yLo)) * (H - 2 * PAD);
130
+ const fsLabel = mini ? 8.5 : 10.5;
131
+ const fsAx = mini ? 8 : 10;
132
+ const e = escapeHtml;
133
+ const dots = points
134
+ .map((p) => {
135
+ const r = mini ? 3 + p.loud * 0.8 : 6 + p.loud * 1.6;
136
+ const cls = p.vendorId === anchor ? "dot-anchor" : "dot";
137
+ return (
138
+ `<circle class="${cls}" cx="${sx(p.x).toFixed(1)}" cy="${sy(p.y).toFixed(1)}" r="${r.toFixed(1)}"/>` +
139
+ `<text class="dot-label" style="font-size:${fsLabel}px" x="${sx(p.x).toFixed(1)}" y="${(sy(p.y) - r - 4).toFixed(1)}">${e(p.name)}</text>`
140
+ );
141
+ })
142
+ .join("");
143
+ const midX = ax.signed ? `<line class="axis-mid" x1="${sx(0).toFixed(0)}" y1="${PAD}" x2="${sx(0).toFixed(0)}" y2="${H - PAD}"/>` : "";
144
+ const midY = ay.signed ? `<line class="axis-mid" x1="${PAD}" y1="${sy(0).toFixed(0)}" x2="${W - PAD}" y2="${sy(0).toFixed(0)}"/>` : "";
145
+ return `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="${e(ax.label)} vs ${e(ay.label)}">
146
+ <line class="axis" x1="${PAD}" y1="${H - PAD}" x2="${W - PAD}" y2="${H - PAD}"/>
147
+ <line class="axis" x1="${PAD}" y1="${PAD}" x2="${PAD}" y2="${H - PAD}"/>${midX}${midY}
148
+ <text class="ax-label" style="font-size:${fsAx}px" x="${PAD}" y="${H - 14}">&#8592; ${e(ax.negativePole)}</text>
149
+ <text class="ax-label" style="font-size:${fsAx}px" x="${W - PAD}" y="${H - 14}" text-anchor="end">${e(ax.positivePole)} &#8594;</text>
150
+ <text class="ax-label" style="font-size:${fsAx}px" x="${PAD}" y="${PAD - 10}">&#8593; ${e(ay.positivePole)}${ay.signed ? ` &#183; &#8595; ${e(ay.negativePole)}` : ""}</text>
151
+ ${dots}</svg>`;
152
+ }
153
+
154
+ function axisSectionsHtml(
155
+ config: MarketConfig,
156
+ set: ObservationSet,
157
+ ): { strategicMap: string; report: AxesReport | null } {
158
+ const axes = config.axes ?? [];
159
+ if (axes.length === 0) return { strategicMap: "", report: null };
160
+ const e = escapeHtml;
161
+ const report = assessAxes(config, set);
162
+ const vendorNames = new Map(config.vendors.map((vendor) => [vendor.id, vendor.name]));
163
+ const loudCounts = new Map(report.vendors.map((vendorId) => [vendorId, messageBreadth(vendorId, set.observations).loudCount]));
164
+
165
+ const breadthAxis: ScatterAxis & { id: string } = {
166
+ id: "breadth",
167
+ label: "Message breadth",
168
+ negativePole: "FOCUSED",
169
+ positivePole: "BROAD (share of claims voiced)",
170
+ signed: false,
171
+ };
172
+ const axisInfo = new Map<string, ScatterAxis & { id: string }>([
173
+ ...axes.map((axis) => [axis.id, { id: axis.id, label: axis.label, negativePole: axis.negativePole, positivePole: axis.positivePole, signed: true }] as const),
174
+ [breadthAxis.id, breadthAxis],
175
+ ]);
176
+ const positions = new Map<string, Map<string, number>>();
177
+ for (const assessment of report.assessments) {
178
+ positions.set(
179
+ assessment.axis.id,
180
+ new Map(
181
+ assessment.positions
182
+ .filter((entry): entry is { vendorId: string; position: number } => entry.position !== null)
183
+ .map((entry) => [entry.vendorId, entry.position]),
184
+ ),
185
+ );
186
+ }
187
+ const breadthMap = new Map<string, number>();
188
+ for (const vendorId of report.vendors) {
189
+ const { breadth } = messageBreadth(vendorId, set.observations);
190
+ if (breadth !== null) breadthMap.set(vendorId, breadth);
191
+ }
192
+ positions.set("breadth", breadthMap);
193
+
194
+ const pointsFor = (xId: string, yId: string): ScatterPoint[] => {
195
+ const xs = positions.get(xId);
196
+ const ys = positions.get(yId);
197
+ if (!xs || !ys) return [];
198
+ return report.vendors
199
+ .filter((vendorId) => xs.has(vendorId) && ys.has(vendorId))
200
+ .map((vendorId) => ({
201
+ vendorId,
202
+ name: vendorNames.get(vendorId) ?? vendorId,
203
+ x: xs.get(vendorId) as number,
204
+ y: ys.get(vendorId) as number,
205
+ loud: loudCounts.get(vendorId) ?? 0,
206
+ }));
207
+ };
208
+
209
+ const [px, py] = config.primaryAxes ?? [axes[0].id, axes[1]?.id ?? "breadth"];
210
+ const axInfo = axisInfo.get(px) as ScatterAxis & { id: string };
211
+ const ayInfo = axisInfo.get(py) as ScatterAxis & { id: string };
212
+ const statusOf = (id: string) => axes.find((axis) => axis.id === id)?.status ?? (id === "breadth" ? "derived" : "");
213
+ const strategicMap = `<section>
214
+ <h2><span class="no">03</span> Strategic map — ${e(axInfo.label)} &#215; ${e(ayInfo.label)}</h2>
215
+ <figure>${svgScatter(pointsFor(px, py), axInfo, ayInfo, config.anchorVendor, false)}
216
+ <figcaption>Positions computed from run ${e(set.runLabel)} observations: each axis is a per-claim scoring rubric
217
+ in the market config; a vendor sits at the intensity-weighted mean (loud=1, quiet=&#189;) of the claims it
218
+ voices. Dot size = LOUD count. Axis status — ${e(axInfo.label)}: ${e(statusOf(px))}; ${e(ayInfo.label)}: ${e(statusOf(py))}.</figcaption>
219
+ </figure>
220
+ </section>`;
221
+
222
+ // Deliberately no axis-pairing gallery here: the report is the client-facing
223
+ // artifact, best foot forward — one earned 2x2. Axis exploration (PCA,
224
+ // triangulation, the orthogonality screen over every pairing) lives in
225
+ // `market axes` for the analyst or agent doing the iterating.
226
+ return { strategicMap, report };
227
+ }
228
+
107
229
  export function marketMapToHtml(config: MarketConfig, set: ObservationSet): string {
108
230
  const model = buildModel(config, set);
109
231
  const stateByClaim = new Map(model.fronts.map((front) => [front.claimId, front.state]));
@@ -113,6 +235,8 @@ export function marketMapToHtml(config: MarketConfig, set: ObservationSet): stri
113
235
  const unobservable = set.observations.filter((obs) => obs.intensity === "unobservable").length;
114
236
  const anchor = config.anchorVendor;
115
237
  const e = escapeHtml;
238
+ const axisHtml = axisSectionsHtml(config, set);
239
+ const appendixNo = axisHtml.report ? "04" : "03";
116
240
 
117
241
  const matrixRows = model.orderedClaimIds
118
242
  .map((claimId) => {
@@ -223,6 +347,14 @@ tr.front-open th .claim-cap { color:var(--accent); font-weight:600; }
223
347
  .ev-head { font-size:10.5px; letter-spacing:.1em; color:var(--accent); }
224
348
  .ev blockquote { font-style:italic; margin:6px 0; font-size:13.5px; line-height:1.5; }
225
349
  .ev-src { font-size:10px; color:var(--ink-soft); word-break:break-all; }
350
+ figure { margin-top:22px; border:1px solid var(--line); background:rgba(255,255,255,.35); }
351
+ .axis { stroke:var(--ink); stroke-width:1.5; }
352
+ .axis-mid { stroke:var(--line); stroke-dasharray:3 5; }
353
+ .ax-label { letter-spacing:.16em; fill:var(--ink-soft); font-family:"SF Mono",Menlo,Consolas,monospace; }
354
+ .dot { fill:rgba(33,29,22,.78); }
355
+ .dot-anchor { fill:var(--green); stroke:var(--ink); stroke-width:1.5; }
356
+ .dot-label { fill:var(--ink); text-anchor:middle; letter-spacing:.04em; font-family:"SF Mono",Menlo,Consolas,monospace; }
357
+ figcaption { font-size:12px; color:var(--ink-soft); padding:12px 16px 14px; font-style:italic; border-top:1px solid var(--line); line-height:1.5; }
226
358
  footer { margin-top:72px; border-top:3px double var(--ink); padding-top:14px; font-size:11px; color:var(--ink-soft);
227
359
  display:flex; justify-content:space-between; gap:20px; flex-wrap:wrap; }
228
360
  @media print { body { max-width:none; padding:0 8mm; background:white; } section { break-inside:avoid-page; } tr { break-inside:avoid; } }
@@ -260,8 +392,9 @@ footer { margin-top:72px; border-top:3px double var(--ink); padding-top:14px; fo
260
392
  <tbody>${matrixRows}</tbody>
261
393
  </table>
262
394
  </section>
395
+ ${axisHtml.strategicMap}
263
396
  <section>
264
- <h2><span class="no">03</span> Evidence appendix</h2>
397
+ <h2><span class="no">${appendixNo}</span> Evidence appendix</h2>
265
398
  ${appendix}
266
399
  </section>
267
400
  <footer>
package/src/mcp.ts CHANGED
@@ -48,6 +48,16 @@ import { builtinAuditRules } from "./rules.ts";
48
48
  import { sampleSnapshot } from "./sampleData.ts";
49
49
  import { normalizeTranscript, parseCall } from "./calls.ts";
50
50
  import { extractInsightsLlm, resolveLlmCredential } from "./llm.ts";
51
+ import {
52
+ computeFrontStates,
53
+ createFileObservationStore,
54
+ loadCaptureTexts,
55
+ loadMarketConfig,
56
+ validateObservationSet,
57
+ verifyEvidenceSpans,
58
+ type ObservationSet,
59
+ } from "./market.ts";
60
+ import { buildWorksheet } from "./marketClassify.ts";
51
61
  import { resolveRecord } from "./resolve.ts";
52
62
  import { suggestValues } from "./suggest.ts";
53
63
  import type { CanonicalGtmSnapshot, GtmConnector, PatchPlan } from "./types.ts";
@@ -307,6 +317,61 @@ export async function startMcpServer() {
307
317
  },
308
318
  );
309
319
 
320
+ server.registerTool(
321
+ "fullstackgtm_market_worksheet",
322
+ {
323
+ title: "Market Map Classification Worksheet",
324
+ description:
325
+ "Get everything needed to classify ONE vendor's messaging intensity for a market map: " +
326
+ "the claim taxonomy with judging definitions, the surface rule, and the captured page " +
327
+ "texts. Read each claim's definition, judge loud/quiet/absent from the page texts only, " +
328
+ "and quote verbatim spans (≤300 chars) for every loud/quiet reading. Submit the full " +
329
+ "ObservationSet via fullstackgtm_market_observe — quotes are verified character-for-" +
330
+ "character against the captures, so never paraphrase.",
331
+ inputSchema: {
332
+ vendorId: z.string(),
333
+ configPath: z.string().optional().describe("Path to market.config.json (default ./market.config.json)"),
334
+ captureRun: z.string().optional(),
335
+ },
336
+ },
337
+ async ({ vendorId, configPath, captureRun }) => {
338
+ const config = loadMarketConfig(resolve(process.cwd(), configPath ?? "market.config.json"));
339
+ return content(buildWorksheet(config, vendorId, { captureRun }));
340
+ },
341
+ );
342
+
343
+ server.registerTool(
344
+ "fullstackgtm_market_observe",
345
+ {
346
+ title: "Submit Market Map Observations",
347
+ description:
348
+ "Submit a complete ObservationSet (every vendor × claim cell) for a market map run. " +
349
+ "Validates coverage, the verbatim-evidence rule, and mechanically verifies every quoted " +
350
+ "span against the stored capture it cites. Returns problems if rejected; nothing is " +
351
+ "stored unless the whole set passes. Observations are append-only — use a new runLabel.",
352
+ inputSchema: {
353
+ observationsPath: z.string().describe("Path to the ObservationSet JSON file"),
354
+ configPath: z.string().optional().describe("Path to market.config.json (default ./market.config.json)"),
355
+ },
356
+ },
357
+ async ({ observationsPath, configPath }) => {
358
+ const config = loadMarketConfig(resolve(process.cwd(), configPath ?? "market.config.json"));
359
+ const set = JSON.parse(readFileSync(resolve(process.cwd(), observationsPath), "utf8")) as ObservationSet;
360
+ const problems = validateObservationSet(config, set);
361
+ const failures = verifyEvidenceSpans(set.observations, loadCaptureTexts(config.category).textByHash);
362
+ if (problems.length > 0 || failures.length > 0) {
363
+ return content({
364
+ accepted: false,
365
+ problems,
366
+ spanFailures: failures.map((failure) => `${failure.vendorId} × ${failure.claimId}: ${failure.problem}`),
367
+ });
368
+ }
369
+ await createFileObservationStore(config.category).append(set);
370
+ const fronts = computeFrontStates(config, set);
371
+ return content({ accepted: true, runLabel: set.runLabel, observations: set.observations.length, fronts });
372
+ },
373
+ );
374
+
310
375
  const transport = new StdioServerTransport();
311
376
  await server.connect(transport);
312
377
  }