fullstackgtm 0.17.0 → 0.19.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
@@ -335,7 +335,7 @@ export async function startMcpServer() {
335
335
  },
336
336
  },
337
337
  async ({ vendorId, configPath, captureRun }) => {
338
- const config = loadMarketConfig(resolve(process.cwd(), configPath ?? "market.config.json"));
338
+ const config = loadMarketConfigOrHint(resolve(process.cwd(), configPath ?? "market.config.json"));
339
339
  return content(buildWorksheet(config, vendorId, { captureRun }));
340
340
  },
341
341
  );
@@ -355,7 +355,7 @@ export async function startMcpServer() {
355
355
  },
356
356
  },
357
357
  async ({ observationsPath, configPath }) => {
358
- const config = loadMarketConfig(resolve(process.cwd(), configPath ?? "market.config.json"));
358
+ const config = loadMarketConfigOrHint(resolve(process.cwd(), configPath ?? "market.config.json"));
359
359
  const set = JSON.parse(readFileSync(resolve(process.cwd(), observationsPath), "utf8")) as ObservationSet;
360
360
  const problems = validateObservationSet(config, set);
361
361
  const failures = verifyEvidenceSpans(set.observations, loadCaptureTexts(config.category).textByHash);
@@ -375,3 +375,16 @@ export async function startMcpServer() {
375
375
  const transport = new StdioServerTransport();
376
376
  await server.connect(transport);
377
377
  }
378
+
379
+ function loadMarketConfigOrHint(path: string): ReturnType<typeof loadMarketConfig> {
380
+ try {
381
+ return loadMarketConfig(path);
382
+ } catch (error) {
383
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
384
+ throw new Error(
385
+ `No market config at ${path} — run \`fullstackgtm market init --category <name>\` in that directory first, or pass configPath.`,
386
+ );
387
+ }
388
+ throw error;
389
+ }
390
+ }
package/src/types.ts CHANGED
@@ -289,6 +289,20 @@ export type PatchOperation = {
289
289
  evidenceIds?: string[];
290
290
  findingIds?: string[];
291
291
  verification?: PatchVerification;
292
+ /**
293
+ * Compare-and-set guards beyond the written field: each precondition is
294
+ * re-read at apply time and a mismatch turns the operation into a
295
+ * conflict instead of a write. Guards against a record drifting on a
296
+ * DIFFERENT field than the one being written (e.g. stage changed while
297
+ * an owner write was pending).
298
+ */
299
+ preconditions?: Array<{ field: string; expectedValue: unknown }>;
300
+ /**
301
+ * Operations sharing a groupId are all-or-nothing at apply time: a
302
+ * conflict (beforeValue or precondition) on any member skips every
303
+ * member of the group.
304
+ */
305
+ groupId?: string;
292
306
  };
293
307
 
294
308
  /**
@@ -306,6 +320,31 @@ export type PatchPlan = {
306
320
  pipelineFindings?: PipelineFinding[];
307
321
  evidence?: GtmEvidence[];
308
322
  operations: PatchOperation[];
323
+ /**
324
+ * The filter this plan's operations were selected by. Re-evaluated per
325
+ * record against a FRESH snapshot at apply time: any operation whose
326
+ * record no longer matches is reported as a conflict instead of applied.
327
+ * Unlike per-operation preconditions, this enforces the FULL filter —
328
+ * negations and relational pseudo-fields included.
329
+ */
330
+ filter?: { objectType: "account" | "contact" | "deal"; where: string[] };
331
+ /**
332
+ * Plan-level guards re-evaluated against a FRESH snapshot at apply time.
333
+ * If any guard fails, NO operation in the plan is applied. This is how a
334
+ * plan expresses cross-record eligibility ("apply only while the account
335
+ * still has no open deal in contractsent") that per-operation
336
+ * preconditions cannot reach.
337
+ */
338
+ guards?: PlanGuard[];
339
+ };
340
+
341
+ export type PlanGuard = {
342
+ objectType: "account" | "contact" | "deal";
343
+ /** filter expressions in bulk-update --where grammar, AND-ed */
344
+ where: string[];
345
+ /** none: guard passes when ZERO records match; some: when at least one matches */
346
+ expect: "none" | "some";
347
+ description?: string;
309
348
  };
310
349
 
311
350
  // ── Audit rule engine ──────────────────────────────────────────