@analitikisler/ai-tag-debugger 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/i18n.js CHANGED
@@ -85,6 +85,18 @@ const en = {
85
85
  tabScope: "Scope and plan",
86
86
  sections: "Report sections",
87
87
  headerMeta: (journeys, providers) => `${journeys} ${journeys === 1 ? "journey" : "journeys"} · ${providers} ${providers === 1 ? "provider" : "providers"}`,
88
+ checkedAgainst: (id) => `Checked against plan ${id}`,
89
+ planVsSite: "Plan vs. site",
90
+ changesTitle: "Changed since last run",
91
+ changesLead: (id, mode) => `Compared with run ${id}, by ${mode === "plan" ? "plan coverage" : "health findings"}`,
92
+ changesLine: (ch) => ch.fixed.length || ch.new.length ? [ch.fixed.length ? `${ch.fixed.length} ${ch.mode === "plan" ? (ch.fixed.length === 1 ? "event" : "events") : (ch.fixed.length === 1 ? "problem" : "problems")} fixed` : "", ch.new.length ? `${ch.new.length} new ${ch.new.length === 1 ? "problem" : "problems"}` : ""].filter(Boolean).join(", ") : "No changes since the last run",
93
+ changeFixed: "Fixed",
94
+ changeNew: "New",
95
+ noPreviousRun: "This is the first saved run for this site, so there is nothing to compare yet.",
96
+ planVsSiteLead: "Each planned event, and what the site actually sent",
97
+ coverageLine: (c) => `Coverage ${c.implemented} of ${c.checkable}${c.percent === null ? "" : ` (${c.percent}%)`}${c.not_checked ? `, ${c.not_checked} ${c.not_checked === 1 ? "event" : "events"} not checked` : ""}`,
98
+ bucket: { implemented: "Implemented", missing: "Missing", missing_params: "Missing params", wrong_values: "Wrong values", unplanned: "Unplanned", not_checked: "Not checked" },
99
+ bucketCol: "Result",
88
100
  summaryLead: "AI summary, most important first",
89
101
  noSummary: "Run without --no-ai for an AI summary of the findings.",
90
102
  seeAllFindings: "See all findings →",
@@ -232,6 +244,18 @@ const tr = {
232
244
  tabScope: "Kapsam ve plan",
233
245
  sections: "Rapor bölümleri",
234
246
  headerMeta: (journeys, providers) => `${journeys} yolculuk · ${providers} sağlayıcı`,
247
+ checkedAgainst: (id) => `${id} planına göre kontrol edildi`,
248
+ planVsSite: "Plan ve site",
249
+ changesTitle: "Son çalıştırmadan bu yana değişenler",
250
+ changesLead: (id, mode) => `${id} çalıştırmasıyla ${mode === "plan" ? "plan kapsamına" : "sağlık bulgularına"} göre karşılaştırıldı`,
251
+ changesLine: (ch) => ch.fixed.length || ch.new.length ? [ch.fixed.length ? `${ch.fixed.length} ${ch.mode === "plan" ? "olay" : "sorun"} düzeldi` : "", ch.new.length ? `${ch.new.length} yeni sorun` : ""].filter(Boolean).join(", ") : "Son çalıştırmadan bu yana değişiklik yok",
252
+ changeFixed: "Düzeldi",
253
+ changeNew: "Yeni",
254
+ noPreviousRun: "Bu site için kayıtlı ilk çalıştırma bu, karşılaştırılacak bir şey henüz yok.",
255
+ planVsSiteLead: "Plandaki her olay ve sitenin gerçekte gönderdiği",
256
+ coverageLine: (c) => `Kapsam ${c.checkable} olaydan ${c.implemented}${c.percent === null ? "" : ` (%${c.percent})`}${c.not_checked ? `, ${c.not_checked} olay kontrol edilmedi` : ""}`,
257
+ bucket: { implemented: "Uygulanmış", missing: "Eksik", missing_params: "Eksik parametre", wrong_values: "Hatalı değer", unplanned: "Planda yok", not_checked: "Kontrol edilmedi" },
258
+ bucketCol: "Sonuç",
235
259
  summaryLead: "Yapay zekâ özeti, öncelik sırasıyla",
236
260
  noSummary: "Bulguların yapay zekâ özeti için --no-ai olmadan çalıştırın.",
237
261
  seeAllFindings: "Tüm bulguları gör →",
package/src/journeys.js CHANGED
@@ -64,11 +64,13 @@ export function presetFor(name) {
64
64
  * Adds a journey's preset events to a plan (a copy): events the plan already
65
65
  * has are expected on this journey too; missing ones are added for this journey.
66
66
  */
67
- export function withPresetEvents(plan, journeyId, preset) {
67
+ export function withPresetEvents(plan, journeyId, preset, { addMissing = true } = {}) {
68
68
  const events = plan.events.map((e) => ({ ...e }));
69
69
  for (const pe of preset.events) {
70
- const existing = events.find((e) => e.platform === pe.platform && e.name === pe.name);
71
- if (!existing) events.push({ ...pe, journeys: [journeyId] });
70
+ const existing = events.find((e) => String(e.platform).toLowerCase() === pe.platform && e.name === pe.name);
71
+ if (!existing) {
72
+ if (addMissing) events.push({ ...pe, journeys: [journeyId] });
73
+ }
72
74
  else if (existing.journeys && !existing.journeys.includes(journeyId)) existing.journeys = [...existing.journeys, journeyId];
73
75
  }
74
76
  return { ...plan, events };
@@ -80,10 +82,20 @@ export function withPresetEvents(plan, journeyId, preset) {
80
82
  * picks: journey ids from the config or preset names. goals: journeys described in plain language.
81
83
  * events: only check these event names.
82
84
  */
83
- export function planRun({ config = {}, plan, picks = [], goals = [], events = [] }) {
84
- // Copied, because the loops below add journeys to events.
85
- let runPlan = { ...(plan ?? { events: [] }), events: (plan?.events ?? []).map((e) => ({ ...e })) };
85
+ export function planRun({ config = {}, plan, picks = [], goals = [], events = [], against = false }) {
86
86
  const configJourneys = config.journeys ?? [];
87
+ // --against alone: the plan says what to walk.
88
+ if (against && !picks.length && !goals.length && !configJourneys.length) {
89
+ const derived = journeysFromPlan(plan);
90
+ return { journeys: derived.journeys, plan: onlyEvents(derived.plan, events) };
91
+ }
92
+ // --against with --journey or --goal: only plan events expected on those flows are checked;
93
+ // the rest are expected nowhere, so they are not checked rather than reported missing.
94
+ const scoped = against && (picks.length || goals.length);
95
+ let runPlan = {
96
+ ...(plan ?? { events: [] }),
97
+ events: (plan?.events ?? []).map((e) => (scoped ? { ...e, journeys: (e.journeys ?? []).filter((j) => picks.includes(j)) } : { ...e })),
98
+ };
87
99
  const journeys = [];
88
100
 
89
101
  for (const pick of picks) {
@@ -99,13 +111,13 @@ export function planRun({ config = {}, plan, picks = [], goals = [], events = []
99
111
  }
100
112
  if (journeys.some((j) => j.id === preset.id)) continue;
101
113
  journeys.push({ id: preset.id, goal: preset.goal });
102
- runPlan = withPresetEvents(runPlan, preset.id, preset);
114
+ runPlan = withPresetEvents(runPlan, preset.id, preset, { addMissing: !scoped });
103
115
  }
104
116
 
105
117
  goals.forEach((goal, i) => {
106
118
  const id = goals.length > 1 ? `custom-${i + 1}` : "custom";
107
119
  journeys.push({ id, goal });
108
- runPlan = expectEvents(runPlan, id, events);
120
+ runPlan = expectEvents(runPlan, id, scoped ? [...events, ...eventsNamedIn(goal, runPlan)] : events);
109
121
  });
110
122
 
111
123
  if (!picks.length && !goals.length) journeys.push(...configJourneys);
@@ -140,3 +152,70 @@ export function filterFindings(findings, events = []) {
140
152
  const wanted = new Set(events.map((e) => e.toLowerCase()));
141
153
  return findings.filter((f) => (f.event ? wanted.has(f.event.toLowerCase()) : f.check !== "unplanned_events"));
142
154
  }
155
+
156
+ const words = (text) => String(text ?? "").toLowerCase().replace(/[_-]+/g, " ");
157
+
158
+ /** Plan events a plain-language goal mentions, by event name ("add to cart" finds add_to_cart) or "<trigger page> page". */
159
+ export function eventsNamedIn(goal, plan) {
160
+ const text = words(goal);
161
+ return plan.events
162
+ .filter((e) => text.includes(words(e.name)) || (e.trigger?.page && text.includes(`${words(e.trigger.page)} page`)))
163
+ .map((e) => e.name);
164
+ }
165
+
166
+ /** Which ready-made journey covers a group of plan events, from the standard GA4 shop and lead events in it. */
167
+ const PRESET_BY_EVENT = [
168
+ ["purchase", ["purchase", "add_payment_info", "add_shipping_info", "begin_checkout"]],
169
+ ["add_to_cart", ["add_to_cart", "view_cart"]],
170
+ ["sign_up", ["sign_up"]],
171
+ ["lead", ["generate_lead"]],
172
+ ["search", ["search", "view_search_results"]],
173
+ ];
174
+
175
+ export const MAX_PLAN_JOURNEYS = 6;
176
+
177
+ const slug = (text) => words(text).trim().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "") || "page";
178
+
179
+ function goalFor(page, events) {
180
+ const actions = [...new Set(events.map((e) => e.trigger?.element).filter(Boolean))];
181
+ const where = page ? `go to a ${page} page` : "browse the site";
182
+ const doing = actions.length ? ` and use ${actions.map((a) => `"${a}"`).join(", ")}` : "";
183
+ return `Accept cookies, ${where}${doing}. Never enter real payment details.`;
184
+ }
185
+
186
+ /**
187
+ * Journeys for `run --against` used alone: the plan's events grouped by the page they are triggered on
188
+ * (trigger.page), at most six, each with one goal built from the plan's trigger text (standard shop events
189
+ * reuse the ready-made journeys) and the group's events expected on it. Returns the journeys and the plan
190
+ * with each event's "journeys" set.
191
+ */
192
+ export function journeysFromPlan(plan, max = MAX_PLAN_JOURNEYS) {
193
+ const groups = new Map();
194
+ for (const e of plan?.events ?? []) {
195
+ const page = e.trigger?.page ? String(e.trigger.page).trim() : "";
196
+ if (!groups.has(page)) groups.set(page, []);
197
+ groups.get(page).push(e);
198
+ }
199
+ // The biggest groups get their own journey; the rest share the last one.
200
+ let list = [...groups].map(([page, evs]) => ({ page, events: evs })).sort((a, b) => b.events.length - a.events.length);
201
+ if (list.length > max) {
202
+ const rest = list.slice(max - 1);
203
+ list = [...list.slice(0, max - 1), { page: rest.map((g) => g.page).filter(Boolean).join(", "), events: rest.flatMap((g) => g.events), merged: rest }];
204
+ }
205
+ const journeys = [];
206
+ const ids = new Map();
207
+ for (const group of list) {
208
+ const names = new Set(group.events.map((e) => e.name));
209
+ const presetId = PRESET_BY_EVENT.find(([, evs]) => evs.some((n) => names.has(n)))?.[0];
210
+ const base = presetId && !journeys.some((j) => j.id === presetId) ? presetId : slug(group.page);
211
+ let id = base;
212
+ for (let n = 2; journeys.some((j) => j.id === id); n++) id = `${base}_${n}`;
213
+ const goal = presetId === id ? JOURNEY_PRESETS[presetId].goal : group.merged ? group.merged.map((g) => goalFor(g.page, g.events)).join(" Then ") : goalFor(group.page, group.events);
214
+ journeys.push({ id, goal, planEvents: group.events.map((e) => e.name) });
215
+ for (const e of group.events) ids.set(e, id);
216
+ }
217
+ return {
218
+ journeys,
219
+ plan: { ...plan, events: (plan?.events ?? []).map((e) => ({ ...e, journeys: [ids.get(e)] })) },
220
+ };
221
+ }
package/src/mcp.js CHANGED
@@ -20,6 +20,10 @@ import { collect, pageView, SYSTEM_PROMPT } from "./agent.js";
20
20
  import { checkRun, groupFindings, summarize } from "./checks.js";
21
21
  import { LANGUAGES, messages } from "./i18n.js";
22
22
  import { loadConfig, readJson, runAudit, writeReports } from "./audit.js";
23
+ import { DEFAULT_DATA_DIR, resolvePlan } from "./workspace.js";
24
+ import { checkTopic, discoverPlan, TOPICS } from "./discover.js";
25
+ import { createProvider } from "./providers.js";
26
+ import { savePlan } from "./plan.js";
23
27
  import { DEFAULT_PROVIDERS, PROVIDERS as TRACKING_PROVIDERS } from "./parsers.js";
24
28
  import { expectEvents, filterFindings, JOURNEY_PRESETS, onlyEvents, presetFor, withPresetEvents } from "./journeys.js";
25
29
 
@@ -31,9 +35,11 @@ const MIN_TOKEN_LENGTH = 16;
31
35
 
32
36
  const INSTRUCTIONS = `ai-tag-debugger checks a website's analytics and ad tracking (GA4, Google Ads, Meta Pixel, TikTok Pixel, Consent Mode).
33
37
 
38
+ Two kinds of request: a PLAN is what should be tracked, an AUDIT is what the site actually sends. If the user wants to know what to track, which events a site needs, or asks for a tracking or measurement plan, call make_plan (with topic when they only care about one kind of event: ${Object.keys(TOPICS).join(", ")}). If they want to know whether tracking works, what a site sends, or what changed, run an audit. To audit against the plan afterwards, pass against to finish_audit.
39
+
34
40
  Before starting, ask the user which platforms to check and suggest the default (${DEFAULT_PROVIDERS.map((k) => TRACKING_PROVIDERS[k]).join(", ")}); pass their choice as providers.
35
41
 
36
- To audit a journey yourself: call start_audit with the site URL and a journey (a ready-made one: ${Object.keys(JOURNEY_PRESETS).join(", ")}, or the user's own description), then browser_action one step at a time until the journey is done (each result shows the page and the tracking hits that step sent), then finish_audit with the measurement plan to get the findings and a full HTML report. Explain the findings to the user in plain language.
42
+ To audit a journey yourself: call start_audit with the site URL and a journey (a ready-made one: ${Object.keys(JOURNEY_PRESETS).join(", ")}, or the user's own description), then browser_action one step at a time until the journey is done (each result shows the page and the tracking hits that step sent), then finish_audit (with against to check the run against a saved plan) to get the findings and the report. Explain the findings to the user in plain language.
37
43
 
38
44
  While browsing:
39
45
  ${SYSTEM_PROMPT.split("How to work:\n")[1]}`;
@@ -100,11 +106,29 @@ async function readPlan(plan, allowFiles) {
100
106
  return readJson(trimmed);
101
107
  }
102
108
 
109
+ /** finish_audit refuses a bad plan without closing the audit, so the captured run is not lost. */
110
+ const STILL_OPEN = "The audit is still open: call finish_audit again with a corrected plan (or none).";
111
+
112
+ const AGAINST_DOC = "A saved plan to check against: latest (or last), first, a plan ID such as 2026-10-16_0627, or on a local server a path to a plan file or folder. Plans are the ones saved by `aitd plan --site <url>`.";
113
+
114
+ /** Finds the saved plan named by `against`. Over HTTP only latest, first and plan IDs are allowed, never paths. */
115
+ async function loadAgainst(ref, site, opts) {
116
+ if (!opts.allowFiles && !/^(latest|last|first|\d{4}-\d{2}-\d{2}_\d{4}(\d{2})?)$/.test(ref.trim())) {
117
+ throw new Error("Over HTTP, against takes latest, first or a plan ID, not a path.");
118
+ }
119
+ const found = await resolvePlan(ref, { dataDir: opts.dataDir ?? DEFAULT_DATA_DIR, site });
120
+ const plan = await readJson(found.file);
121
+ if (!Array.isArray(plan?.events)) throw new Error(`${found.file} is not a plan: it needs an "events" list.`);
122
+ return { found, plan };
123
+ }
124
+
103
125
  /**
104
- * @param {{ allowFiles: boolean, blockPrivate?: boolean, reportsDir: string, reportsUrl?: string }} opts
126
+ * @param {{ allowFiles: boolean, blockPrivate?: boolean, reportsDir: string, reportsUrl?: string, dataDir?: string }} opts
105
127
  * allowFiles: local (stdio) servers may read config and plan files; HTTP servers may not.
106
128
  * blockPrivate: the browser refuses loopback, private and link-local addresses (HTTP servers, by default).
129
+ * dataDir: where saved plans live (default ./aitd), used by `against`.
107
130
  * reportsUrl: where an HTTP server serves finished reports, if it knows its public URL.
131
+ * provider: an AI provider for make_plan (default: the one configured on this computer; tests pass a fake).
108
132
  */
109
133
  export function buildServer(opts) {
110
134
  const server = new McpServer({ name: "ai-tag-debugger", version: VERSION }, { instructions: INSTRUCTIONS });
@@ -214,7 +238,7 @@ export function buildServer(opts) {
214
238
  {
215
239
  title: "Finish and check the audit",
216
240
  description:
217
- "Closes the browser, checks every captured hit against the measurement plan (missing events, missing parameters, duplicates, hits before consent, Consent Mode defaults, emails sent in plain text, unplanned events) and writes a full HTML report with the event timeline. Returns the findings.",
241
+ "Closes the browser, checks every captured hit against the plan (missing events, missing parameters, duplicates, hits before consent, Consent Mode defaults, emails sent in plain text, unplanned events) and writes the report with the event timeline. Returns the findings. The plan is what should be tracked: pass a saved one with against, or write one inline with measurement_plan.",
218
242
  inputSchema: {
219
243
  audit_id: z.string(),
220
244
  completed: z.boolean().describe("Whether the journey reached its goal."),
@@ -223,21 +247,28 @@ export function buildServer(opts) {
223
247
  .string()
224
248
  .optional()
225
249
  .describe(
226
- 'The measurement plan as JSON text, e.g. {"consent_mode": true, "events": [{"platform": "ga4", "name": "purchase", "required_params": ["transaction_id", "value", "currency"]}]}. On a local server this can also be a file path. Without a plan, a ready-made journey checks its own events; otherwise only consent, duplicate and personal data checks run.',
250
+ 'The plan (what should be tracked) as JSON text, e.g. {"consent_mode": true, "events": [{"platform": "ga4", "name": "purchase", "required_params": ["transaction_id", "value", "currency"]}]}. On a local server this can also be a file path. Without a plan, a ready-made journey checks its own events; otherwise only consent, duplicate and personal data checks run.',
227
251
  ),
252
+ against: z.string().optional().describe(`${AGAINST_DOC} The site comes from this audit. Use either against or measurement_plan, not both.`),
228
253
  events: z.array(z.string()).optional().describe("Only check these events, e.g. [\"purchase\", \"add_to_cart\"]. On your own journey, these events are also expected to fire."),
229
254
  language: z.enum(Object.keys(LANGUAGES)).optional().describe("Report language. Default en."),
230
255
  branding: z.boolean().optional().describe("Set false to leave the Analitik İşler logo and credit line out of the report."),
231
256
  },
232
257
  },
233
- async ({ audit_id, completed, note, measurement_plan, events = [], language, branding }) => {
258
+ async ({ audit_id, completed, note, measurement_plan, against, events = [], language, branding }) => {
234
259
  const audit = getAudit(audit_id);
260
+ if (against && measurement_plan) return fail(`Use either against or measurement_plan, not both. ${STILL_OPEN}`);
235
261
  let plan;
262
+ let againstPlan = null;
236
263
  try {
237
- plan = await readPlan(measurement_plan, opts.allowFiles);
264
+ if (against) {
265
+ const loaded = await loadAgainst(against, audit.site, opts);
266
+ plan = loaded.plan;
267
+ againstPlan = { ...loaded.found, label: loaded.found.id ?? path.relative(process.cwd(), loaded.found.file) };
268
+ } else plan = await readPlan(measurement_plan, opts.allowFiles);
238
269
  if (!Array.isArray(plan.events)) throw new Error('The plan needs an "events" list.');
239
270
  } catch (err) {
240
- return fail(`Could not read the measurement plan: ${err.message}`);
271
+ return fail(`Could not read the plan: ${err.message} ${STILL_OPEN}`);
241
272
  }
242
273
  if (audit.preset) plan = withPresetEvents(plan, audit.journey, audit.preset);
243
274
  else plan = expectEvents(plan, audit.journey, events);
@@ -251,10 +282,11 @@ export function buildServer(opts) {
251
282
  const grouped = groupFindings(findings);
252
283
  const counts = summarize(grouped);
253
284
  const outDir = path.join(opts.reportsDir, `${new Date().toISOString().replace(/[:.]/g, "-")}-${audit.journey}`);
254
- const reportPath = await writeReports({ outDir, config: { site: audit.site }, plan, runs: [run], findings, explained: null, lang, branding: branding !== false });
285
+ const reportPath = await writeReports({ outDir, config: { site: audit.site }, plan, runs: [run], findings, explained: null, lang, against: againstPlan, branding: branding !== false });
255
286
  const m = messages(lang);
256
287
  const lines = [
257
288
  m.ui.counts(counts),
289
+ ...(againstPlan ? [m.ui.checkedAgainst(againstPlan.label)] : []),
258
290
  "",
259
291
  ...grouped.filter((f) => f.severity !== "ok").map((f) => `- ${m.severity[f.severity]}: ${f.detail}${f.evidence ? ` (captured: ${f.evidence})` : ""} [${f.where.map((w) => m.ui.whereStep(w.journey, w.viewport, w.step)).join("; ")}]`),
260
292
  "",
@@ -272,15 +304,63 @@ export function buildServer(opts) {
272
304
  },
273
305
  );
274
306
 
307
+ server.registerTool(
308
+ "make_plan",
309
+ {
310
+ title: "Draft a tracking plan",
311
+ description:
312
+ "Explores a website and drafts its measurement plan: the events, parameters and user properties it should track. Same as `aitd plan --site`. The plan is saved (use its ID as against in finish_audit to check an audit against it) and the events are returned. Uses the AI provider configured on the computer running this server (ANTHROPIC_API_KEY, OPENAI_API_KEY or GEMINI_API_KEY) and takes a few minutes. To see what the site sends today, run an audit instead.",
313
+ inputSchema: {
314
+ site: z.string().describe("The site to plan, e.g. https://www.example.com/"),
315
+ topic: z.enum(Object.keys(TOPICS)).optional().describe(`Limit the plan to one kind of event: ${Object.keys(TOPICS).join(", ")}. Default: the whole plan.`),
316
+ },
317
+ },
318
+ async ({ site, topic }) => {
319
+ let url;
320
+ try {
321
+ url = new URL(site);
322
+ if (!["http:", "https:"].includes(url.protocol)) throw new Error();
323
+ } catch {
324
+ return fail("site must be an http or https URL.");
325
+ }
326
+ if (opts.blockPrivate) {
327
+ const refused = await navigationError(url.href, { isPrivate: privateHostChecker() });
328
+ if (refused) return fail(`${refused} Over HTTP, plans are limited to public websites.`);
329
+ }
330
+ try {
331
+ checkTopic(topic);
332
+ const provider = opts.provider ?? (await createProvider());
333
+ const { plan } = await discoverPlan({ site: url.href, topic, provider, blockPrivate: opts.blockPrivate });
334
+ const { id, dir } = await savePlan(plan, { dataDir: opts.dataDir ?? DEFAULT_DATA_DIR, site: url.href });
335
+ const custom = plan.events.filter((e) => e.custom).length;
336
+ return text(
337
+ [
338
+ `Saved plan ${id}${topic ? ` (${topic} only)` : ""}: ${plan.events.length} events (${custom} custom) and ${plan.user_properties.length} user properties${opts.allowFiles ? ` in ${dir}` : ""}.`,
339
+ "",
340
+ ...plan.events.map((e) => `- ${e.platform} ${e.name}${e.custom ? " (custom)" : ""}${e.trigger?.page ? `, on ${e.trigger.page}` : ""}${e.required_params?.length ? `, params: ${e.required_params.join(", ")}` : ""}${e.description ? `: ${e.description}` : ""}`),
341
+ ...(plan.user_properties.length ? ["", `User properties: ${plan.user_properties.map((p) => p.name).join(", ")}`] : []),
342
+ "",
343
+ `Check a site against it with an audit: finish_audit with against "${id}".`,
344
+ ].join("\n"),
345
+ );
346
+ } catch (err) {
347
+ // The CLI wording (--no-ai) means nothing to a chat app, so say it in this tool's terms.
348
+ if (err.code === "NO_API_KEY") return fail("No AI provider key (ANTHROPIC_API_KEY, OPENAI_API_KEY or GEMINI_API_KEY) is set on the computer running this server, so it can't draft a plan. Set one and restart the server, or write the plan yourself from what an audit shows and pass it to finish_audit as measurement_plan.");
349
+ return fail(`Could not draft the plan: ${err.message}`);
350
+ }
351
+ },
352
+ );
353
+
275
354
  if (opts.allowFiles) {
276
355
  server.registerTool(
277
356
  "run_audit",
278
357
  {
279
358
  title: "Run a configured audit",
280
359
  description:
281
- "Runs every journey in a ai-tag-debugger config file. With scripted true, it replays each journey's fixed steps and needs no API key. Otherwise the journeys are browsed by the AI provider configured on this computer (ANTHROPIC_API_KEY, OPENAI_API_KEY or GEMINI_API_KEY). Writes the reports and returns the findings.",
360
+ "Runs every journey in a ai-tag-debugger config file and writes the report. With against, the run is checked against a saved plan and, if the config has no journeys, walks the journeys derived from that plan. With scripted true, it replays each journey's fixed steps and needs no API key. Otherwise the journeys are browsed by the AI provider configured on this computer (ANTHROPIC_API_KEY, OPENAI_API_KEY or GEMINI_API_KEY). Returns the findings.",
282
361
  inputSchema: {
283
362
  config: z.string().describe("Path to the config file, e.g. ai-tag-debugger.config.json"),
363
+ against: z.string().optional().describe(`${AGAINST_DOC} The site comes from the config.`),
284
364
  scripted: z.boolean().optional().describe("Replay fixed steps instead of browsing with an AI model. Default true."),
285
365
  language: z.enum(Object.keys(LANGUAGES)).optional(),
286
366
  branding: z.boolean().optional().describe("Set false to leave the Analitik İşler logo and credit line out of the report."),
@@ -290,12 +370,26 @@ export function buildServer(opts) {
290
370
  .describe(`Tracking providers to check. Default ${DEFAULT_PROVIDERS.join(", ")}. Requests to the others are still answered locally, but they are only counted, not shown or checked.`),
291
371
  },
292
372
  },
293
- async ({ config: configFile, scripted, language, branding, providers }) => {
373
+ async ({ config: configFile, against, scripted, language, branding, providers }) => {
294
374
  const loaded = await loadConfig(configFile);
375
+ let againstPlan = null;
376
+ if (against) {
377
+ try {
378
+ const found = await loadAgainst(against, loaded.config.site, opts);
379
+ if (!loaded.config.site) {
380
+ if (!found.plan.site) throw new Error(`${found.found.file} has no "site" and the config has none either.`);
381
+ loaded.config = { ...loaded.config, site: found.plan.site };
382
+ }
383
+ loaded.plan = found.plan;
384
+ againstPlan = found.found;
385
+ } catch (err) {
386
+ return fail(`Could not read the plan: ${err.message}`);
387
+ }
388
+ }
295
389
  const replay = scripted !== false;
296
390
  let result;
297
391
  try {
298
- result = await runAudit({ ...loaded, scripted: replay, ai: !replay, lang: language, branding, providers, outDir: path.join(opts.reportsDir, new Date().toISOString().replace(/[:.]/g, "-")) });
392
+ result = await runAudit({ ...loaded, scripted: replay, ai: !replay, lang: language, branding, providers, against: againstPlan, outDir: path.join(opts.reportsDir, new Date().toISOString().replace(/[:.]/g, "-")) });
299
393
  } catch (err) {
300
394
  // The CLI wording (--scripted --no-ai) means nothing to a chat app, so say it in this tool's terms.
301
395
  if (err.code !== "NO_API_KEY") throw err;
@@ -0,0 +1,76 @@
1
+ import { CSS, VERSION, escapeHtml, logo, platformLabel } from "./report.js";
2
+ import { LOGOS } from "./brand.js";
3
+
4
+ const e = escapeHtml;
5
+ const text = (v) => (v === undefined || v === null || v === "" ? `<span class="muted">–</span>` : e(v));
6
+
7
+ const PLAN_CSS = `
8
+ .pl-event{padding:16px 20px}
9
+ .pl-event+.pl-event{border-top:1px solid var(--border)}
10
+ .pl-head{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
11
+ .pl-head code{font-size:14px}
12
+ .pl-desc{margin:6px 0 10px}
13
+ .pl-trigger{font-size:12px;color:var(--muted);margin-bottom:10px}
14
+ .pl-trigger b{font-weight:500;color:var(--fg)}
15
+ .flag{display:inline-flex;align-items:center;font:500 12px/1 var(--font-sans);padding:4px 8px;border-radius:4px;background:var(--muted-bg);color:var(--muted)}
16
+ .flag.on{background:var(--accent-bg);color:var(--accent-fg)}
17
+ .flag.req{background:var(--warn-bg);color:var(--warn)}
18
+ table.dt td,table.dt th{vertical-align:top}
19
+ `;
20
+
21
+ const flag = (label, cls = "") => `<span class="flag ${cls}">${e(label)}</span>`;
22
+
23
+ function paramsTable(event) {
24
+ const params = Object.entries(event.parameters ?? {});
25
+ if (!params.length) return `<p class="muted xs">No parameters.</p>`;
26
+ const required = new Set(event.required_params ?? []);
27
+ const rows = params
28
+ .map(([name, p]) => `<tr><td><code>${e(name)}</code>${p?.custom ? ` ${flag("custom")}` : ""}</td><td>${required.has(name) ? flag("required", "req") : `<span class="muted">optional</span>`}</td><td class="mono">${text(p?.example)}</td><td>${text(p?.description)}</td></tr>`)
29
+ .join("");
30
+ return `<div class="tscroll"><table class="dt"><thead><tr><th>Parameter</th><th>Required</th><th>Example</th><th>Description</th></tr></thead><tbody>${rows}</tbody></table></div>`;
31
+ }
32
+
33
+ function eventCard(event) {
34
+ const trigger = event.trigger ?? {};
35
+ return `<div class="pl-event">
36
+ <div class="pl-head"><span class="ptag">${e(platformLabel(event.platform ?? "ga4"))}</span><code>${e(event.name)}</code>${flag(event.custom ? "custom event" : "standard event", event.custom ? "on" : "")}</div>
37
+ <p class="pl-desc">${text(event.description)}</p>
38
+ <p class="pl-trigger"><b>Trigger</b> · page: ${text(trigger.page)} · element: ${text(trigger.element)}</p>
39
+ ${paramsTable(event)}
40
+ </div>`;
41
+ }
42
+
43
+ function userPropertiesCard(props) {
44
+ const body = props.length
45
+ ? `<div class="tscroll"><table class="dt"><thead><tr><th>Name</th><th>Example</th><th>Description</th></tr></thead><tbody>${props
46
+ .map((p) => (typeof p === "string" ? { name: p } : p))
47
+ .map((p) => `<tr><td><code>${e(p.name)}</code></td><td class="mono">${text(p.example)}</td><td>${text(p.description)}</td></tr>`)
48
+ .join("")}</tbody></table></div>`
49
+ : `<p class="muted xs">No user properties.</p>`;
50
+ return `<section class="card"><div class="card-h"><div class="card-t">User properties</div></div><div class="card-c">${body}</div></section>`;
51
+ }
52
+
53
+ /** A single self-contained, readable page for a measurement plan. Respects branding false like the report. */
54
+ export function renderPlanHtml(plan, { id = "", generatedAt = "", branding = true } = {}) {
55
+ const events = plan.events ?? [];
56
+ const custom = events.filter((ev) => ev.custom).length;
57
+ const title = `Measurement plan${plan.site ? `: ${plan.site}` : ""}`;
58
+ const meta = [id && `Plan ${id}`, generatedAt, `${events.length} events (${custom} custom)`, `ai-tag-debugger ${VERSION}`].filter(Boolean).join(" · ");
59
+ const consent = plan.consent_mode ? flag("Consent mode on", "on") : flag("Consent mode off");
60
+ const footer = branding
61
+ ? `<div class="wrap"><a href="https://analitikisler.com" class="logo-link" aria-label="analitikisler.com" rel="noopener noreferrer">${logo("stacked", "Analitik İşler", 56, "logo-s")}</a><div><div class="f-links"><a href="https://analitikisler.com" rel="noopener noreferrer">analitikisler.com</a><a href="https://github.com/analitikisler/ai-tag-debugger" rel="noopener noreferrer">GitHub</a></div></div><div class="f-meta">v${VERSION}</div></div>`
62
+ : `<div class="wrap plain"><div class="f-meta">AI Tag Debugger</div><div class="f-meta">v${VERSION}</div></div>`;
63
+ return `<!doctype html>
64
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
65
+ <title>${e(title)}</title><link rel="icon" type="image/png" href="${LOGOS.favicon}">
66
+ <style>${CSS}${PLAN_CSS}</style></head><body>
67
+ <div class="topbar"><div class="wrap"><span class="product-logo">${logo("atd-lockup", "AI Tag Debugger", 26, "logo-atd")}</span>${branding ? `<a href="https://analitikisler.com" class="byline" rel="noopener noreferrer"><span>by</span>${logo("horizontal", "Analitik İşler", 18, "logo-h")}</a>` : ""}</div></div>
68
+ <header class="wrap title"><div class="eyebrow">Measurement plan</div><h1>${e(title)}</h1><div class="meta">${e(meta)}</div></header>
69
+ <main class="wrap" style="padding-top:24px;padding-bottom:64px"><div class="stack">
70
+ <div>${consent}</div>
71
+ <section class="card"><div class="card-h"><div class="card-t">Events</div><div class="card-d">What should be tracked, where it fires and which parameters it carries.</div></div><div class="card-c tight">${events.length ? events.map(eventCard).join("") : `<p class="muted xs" style="padding:12px">No events.</p>`}</div></section>
72
+ ${userPropertiesCard(plan.user_properties ?? [])}
73
+ </div></main>
74
+ <footer class="footer">${footer}</footer>
75
+ </body></html>`;
76
+ }
package/src/plan.js ADDED
@@ -0,0 +1,14 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { renderPlanHtml } from "./plan-html.js";
4
+ import { PLAN_FILE, PLAN_HTML_FILE, newPlanDir } from "./workspace.js";
5
+
6
+ /** Saves a plan (measurement-plan.json and plan.html) in a new aitd/<domain>/plans/<id>/ folder and returns where. */
7
+ export async function savePlan(plan, { dataDir, site, now, branding = true } = {}) {
8
+ const { id, dir } = await newPlanDir(dataDir, site, now);
9
+ await mkdir(dir, { recursive: true });
10
+ const file = path.join(dir, PLAN_FILE);
11
+ await writeFile(file, `${JSON.stringify(plan, null, 2)}\n`);
12
+ await writeFile(path.join(dir, PLAN_HTML_FILE), renderPlanHtml(plan, { id, generatedAt: (now ?? new Date()).toISOString().slice(0, 16).replace("T", " "), branding }));
13
+ return { id, dir, file };
14
+ }
package/src/providers.js CHANGED
@@ -28,7 +28,7 @@ export function resolveProviderName(name, env = process.env) {
28
28
  return "anthropic";
29
29
  }
30
30
 
31
- const NO_API_KEY = `No AI provider key found. Set one of ANTHROPIC_API_KEY, OPENAI_API_KEY or GEMINI_API_KEY (or OPENAI_COMPATIBLE_BASE_URL for an OpenAI-compatible API), or pick one with --provider. To run without any AI model, replay the fixed steps with --scripted --no-ai.`;
31
+ const NO_API_KEY = `No AI provider key found. Set one of ANTHROPIC_API_KEY, OPENAI_API_KEY or GEMINI_API_KEY (or OPENAI_COMPATIBLE_BASE_URL for an OpenAI-compatible API), or pick one with --provider. To run without any AI model, replay saved steps with --no-ai.`;
32
32
 
33
33
  function hasApiKey(env) {
34
34
  return Boolean(env.OPENAI_COMPATIBLE_BASE_URL || env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN || env.OPENAI_API_KEY || env.GEMINI_API_KEY || env.GOOGLE_API_KEY);
package/src/report.js CHANGED
@@ -6,7 +6,7 @@ import { decodeGa4Item, isEventParam, paramTip } from "./glossary.js";
6
6
  import { LOGOS } from "./brand.js";
7
7
  import { readFileSync } from "node:fs";
8
8
 
9
- const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
9
+ export const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
10
10
 
11
11
  const EXPLAIN_SYSTEM = `You are a senior digital analytics consultant reviewing an automated tracking audit. You receive findings from deterministic checks, the measurement plan, the hits captured on each step of each journey, and optionally a Google Tag Manager container export.
12
12
 
@@ -120,7 +120,7 @@ export async function explainFindings({ findings, plan, runs, gtmContainer, prov
120
120
  }
121
121
  }
122
122
 
123
- const escapeHtml = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
123
+ export const escapeHtml = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
124
124
 
125
125
  function attachExplanations(findings, explained) {
126
126
  const byIndex = new Map((explained?.explanations ?? []).map((e) => [e.finding, e]));
@@ -149,11 +149,13 @@ const formatPush = (value) => (isConsentEntry(value) ? `gtag(${value.map((v) =>
149
149
  const mdText = (v) => String(v ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
150
150
  const mdCode = (v) => String(v ?? "").replace(/`/g, "'").replace(/\n/g, " ");
151
151
 
152
- export function renderMarkdown({ title, generatedAt, findings, explained, runs, lang = "en", branding = true }) {
152
+ export function renderMarkdown({ title, generatedAt, findings, explained, runs, lang = "en", branding = true, against = null, planCheck = null, changes = null }) {
153
153
  const m = messages(lang);
154
154
  const rows = attachExplanations(findings, explained);
155
155
  const counts = summarize(rows);
156
- const lines = [`# ${mdText(title)}`, "", m.ui.generated(generatedAt), "", `**${m.ui.counts(counts)}**`, ""];
156
+ const lines = [`# ${mdText(title)}`, "", m.ui.generated(generatedAt), "", ...(against ? [mdText(m.ui.checkedAgainst(against)), ""] : []), `**${m.ui.counts(counts)}**`, ""];
157
+ if (changes) lines.push(...changesMd(m, changes));
158
+ if (planCheck) lines.push(...planVsSiteMd(m, planCheck));
157
159
  const sum = summaryParts(explained?.summary);
158
160
  if (sum) {
159
161
  lines.push(`## ${m.ui.summary}`, "", sum.text ? mdText(sum.text) : `**${mdText(sum.headline)}**`, "");
@@ -193,7 +195,7 @@ export function renderMarkdown({ title, generatedAt, findings, explained, runs,
193
195
  }
194
196
 
195
197
  const PLATFORM_ORDER = ["ga4", "google_ads", "meta", "tiktok", "x", "pinterest", "snapchat", "klaviyo", "hotjar"];
196
- const platformLabel = (p) => PROVIDERS[p] ?? String(p).replace(/[_-]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
198
+ export const platformLabel = (p) => PROVIDERS[p] ?? String(p).replace(/[_-]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
197
199
  const platformRank = (p) => (PLATFORM_ORDER.includes(p) ? PLATFORM_ORDER.indexOf(p) : PLATFORM_ORDER.length);
198
200
 
199
201
  // Pixel event names and their GA4 / dataLayer counterparts, so a Meta or TikTok hit can be shown next to the push behind it.
@@ -455,7 +457,7 @@ const sevIcon = (sev) => ICON[sev === "broken" ? "broken" : sev === "info" ? "in
455
457
  const sec = (name, label) => `<div class="sec-h">${ic(ICON[name], 14)}<span>${label}</span></div>`;
456
458
  const badge = (sev, text) => `<span class="badge ${sev}">${escapeHtml(text)}</span>`;
457
459
  /** A logo with its light and dark versions, switched by the viewer's color scheme. */
458
- const logo = (key, alt, height, cls) => `<picture class="${cls}"><source media="(prefers-color-scheme: dark)" srcset="${LOGOS[`${key}-dark`]}"><img src="${LOGOS[`${key}-light`]}" alt="${alt}" height="${height}"></picture>`;
460
+ export const logo = (key, alt, height, cls) => `<picture class="${cls}"><source media="(prefers-color-scheme: dark)" srcset="${LOGOS[`${key}-dark`]}"><img src="${LOGOS[`${key}-light`]}" alt="${alt}" height="${height}"></picture>`;
459
461
 
460
462
  /** The journey and step a finding's place points at. Steps are named by label; when labels repeat, prefer the step where the finding's event fired. */
461
463
  function whereTarget(runs, w, f) {
@@ -610,6 +612,52 @@ ${provs.length ? `<div class="card"><div class="card-h"><div class="card-t">${m.
610
612
  }
611
613
 
612
614
  /** Each planned event × each journey: sent, not sent, or not expected there (or its provider not checked). */
615
+ const BUCKET_SEV = { implemented: "ok", missing: "broken", missing_params: "broken", wrong_values: "warning", unplanned: "info", not_checked: "muted" };
616
+ const coverageLines = (m, pc) => [
617
+ m.ui.coverageLine(pc.coverage),
618
+ ...sortPlatforms(Object.keys(pc.coverage.platforms)).map((p) => `${platformLabel(p)}: ${m.ui.coverageLine(pc.coverage.platforms[p])}`),
619
+ ];
620
+ const pcSort = (pc) => [...pc.events].sort((a, b) => BUCKET_ORDER.indexOf(a.bucket) - BUCKET_ORDER.indexOf(b.bucket) || platformRank(a.platform) - platformRank(b.platform));
621
+ const BUCKET_ORDER = ["missing", "missing_params", "wrong_values", "implemented", "unplanned", "not_checked"];
622
+
623
+ /** "Changed since last run": one headline, then what was fixed and what is new. */
624
+ const changeLabel = (c) => `${platformLabel(c.platform ?? "")} ${c.event ?? ""}`.trim() || c.check;
625
+ const changeItems = (m, ch) => [
626
+ ...ch.fixed.map((c) => ({ sev: "ok", tag: m.ui.changeFixed, text: `${changeLabel(c)} (${ch.mode === "findings" ? c.detail : m.ui.bucket[c.was]})` })),
627
+ ...ch.new.map((c) => ({ sev: "broken", tag: m.ui.changeNew, text: `${changeLabel(c)} (${ch.mode === "findings" ? c.detail : m.ui.bucket[c.bucket]})` })),
628
+ ];
629
+
630
+ function changesMd(m, ch) {
631
+ const lines = [`## ${m.ui.changesTitle}`, ""];
632
+ if (!ch.previous) return [...lines, m.ui.noPreviousRun, ""];
633
+ lines.push(`**${mdText(m.ui.changesLine(ch))}**`, "");
634
+ const items = changeItems(m, ch);
635
+ if (items.length) lines.push(...items.map((i) => `- ${i.tag}: ${mdText(i.text)}`), "");
636
+ return lines;
637
+ }
638
+
639
+ function changesCard(m, ch) {
640
+ const head = `<div class="card-h"><div class="card-t">${m.ui.changesTitle}</div>${ch.previous ? `<div class="card-d">${escapeHtml(m.ui.changesLead(ch.previous, ch.mode))}</div>` : ""}</div>`;
641
+ if (!ch.previous) return `<div class="card" id="changes">${head}<div class="card-c"><p class="muted">${m.ui.noPreviousRun}</p></div></div>`;
642
+ const items = changeItems(m, ch);
643
+ return `<div class="card" id="changes">${head}<div class="card-c"><p><strong>${escapeHtml(m.ui.changesLine(ch))}</strong></p>${items.length ? `<ul class="changes">${items.map((i) => `<li>${badge(i.sev, i.tag)} ${escapeHtml(i.text)}</li>`).join("")}</ul>` : ""}</div></div>`;
644
+ }
645
+
646
+ function planVsSiteMd(m, pc) {
647
+ const [overall, ...perPlatform] = coverageLines(m, pc);
648
+ const lines = [`## ${m.ui.planVsSite}`, "", `**${mdText(overall)}**`, "", ...perPlatform.map((l) => `- ${mdText(l)}`), ...(perPlatform.length ? [""] : [])];
649
+ lines.push(`| ${m.ui.event} | ${m.ui.bucketCol} | |`, "|---|---|---|");
650
+ for (const e of pcSort(pc)) lines.push(`| ${mdText(platformLabel(e.platform))} \`${mdCode(e.event)}\` | ${m.ui.bucket[e.bucket]} | ${mdText((e.details ?? []).join(" ")).replace(/\|/g, "\\|")} |`);
651
+ lines.push("");
652
+ return lines;
653
+ }
654
+
655
+ function planVsSiteCard(m, pc) {
656
+ const [overall, ...perPlatform] = coverageLines(m, pc);
657
+ const rows = pcSort(pc).map((e) => `<tr><td><span class="ptag p-${safeKey(e.platform)}">${escapeHtml(platformLabel(e.platform))}</span> <code>${escapeHtml(e.event)}</code></td><td>${badge(BUCKET_SEV[e.bucket], m.ui.bucket[e.bucket])}</td><td class="muted xs">${(e.details ?? []).map(detailHtml).join("<br>")}</td></tr>`).join("");
658
+ return `<div class="card" id="plan-vs-site"><div class="card-h"><div class="card-t">${m.ui.planVsSite}</div><div class="card-d">${m.ui.planVsSiteLead}</div></div><div class="card-c"><p><strong>${escapeHtml(overall)}</strong></p>${perPlatform.length ? `<p class="muted xs">${perPlatform.map(escapeHtml).join("<br>")}</p>` : ""}<div class="tscroll"><table class="dt"><thead><tr><th>${m.ui.event}</th><th>${m.ui.bucketCol}</th><th></th></tr></thead><tbody>${rows}</tbody></table></div></div></div>`;
659
+ }
660
+
613
661
  function coverageCard(m, plan, runs) {
614
662
  const head = `<div class="card-h"><div class="card-t">${m.ui.planCoverage}</div><div class="card-d">${m.ui.planCoverageLead}</div></div>`;
615
663
  if (!plan?.events?.length || !runs.length) return `<div class="card">${head}<div class="card-c"><p class="muted">${m.ui.noPlan}</p></div></div>`;
@@ -679,7 +727,7 @@ function navCss(runs) {
679
727
  return out.join("\n");
680
728
  }
681
729
 
682
- const CSS = `
730
+ export const CSS = `
683
731
  :root{
684
732
  --bg:#FFFFFF;--page:#F7F9FC;--fg:#0A0E1A;--muted:#5A6480;--muted-bg:#F1F4F9;--border:#E3E7EF;--input:#E3E7EF;
685
733
  --accent:#4F8EF7;--accent-fg:#2F6FDB;--accent-bg:#EBF1FE;--orange:#FF6D33;
@@ -754,6 +802,7 @@ table.dt a{color:var(--fg)}table.dt a:hover{color:var(--accent-fg)}
754
802
  .fsr{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}
755
803
  .sevfilter label{padding:4px 10px;border-radius:4px;font-weight:500;font-size:12px;color:var(--muted);cursor:pointer}
756
804
  .filter-host{position:relative}
805
+ .changes{list-style:none;margin:8px 0 0;padding:0;display:grid;gap:6px}
757
806
  .flist{list-style:none;margin:0;padding:0;display:grid;gap:8px}
758
807
  .finding{--sev:var(--warn-dot);--sev-fg:var(--warn);--sev-bg:var(--warn-bg);background:var(--bg);border:1px solid var(--border);border-left:4px solid var(--sev);border-radius:var(--radius);box-shadow:var(--shadow-sm)}
759
808
  .finding.sev-broken{--sev:var(--danger-dot);--sev-fg:var(--danger);--sev-bg:var(--danger-bg)}
@@ -916,7 +965,7 @@ pre{margin:8px 0 0;font-size:12px;line-height:1.5;background:var(--muted-bg);bor
916
965
  @media print{.toptabs,.jtabs,.stepnav,.filter>label,.filter>input,.tabs.seg>label,.sevfilter{display:none}.tabpanel,.jpanel,.sdetail,.tabs.seg>.panel{display:block!important}.jpanel{margin-bottom:32px}.sdetail{margin-bottom:16px;break-inside:avoid-page}details>*{display:block}}
917
966
  `;
918
967
 
919
- export function renderHtml({ title, generatedAt, findings, explained, runs, plan = null, lang = "en", branding = true }) {
968
+ export function renderHtml({ title, generatedAt, findings, explained, runs, plan = null, lang = "en", branding = true, against = null, planCheck = null, changes = null }) {
920
969
  const m = messages(lang);
921
970
  const rows = attachExplanations(findings, explained);
922
971
  const counts = summarize(rows);
@@ -938,12 +987,12 @@ ${navCss(runs)}</style></head><body>
938
987
  <header class="wrap title">
939
988
  <div class="eyebrow">${m.ui.eyebrow}</div>
940
989
  <h1>${escapeHtml(title)}</h1>
941
- <div class="meta">${escapeHtml(generatedAt)} · ${escapeHtml(m.ui.headerMeta(runs.length, checkedProviders(runs).length))} · ai-tag-debugger ${VERSION}</div>
990
+ <div class="meta">${escapeHtml(generatedAt)} · ${escapeHtml(m.ui.headerMeta(runs.length, checkedProviders(runs).length))} · ai-tag-debugger ${VERSION}</div>${against ? `\n<div class="meta">${escapeHtml(m.ui.checkedAgainst(against))}</div>` : ""}
942
991
  </header>
943
992
  <nav class="toptabs" aria-label="${m.ui.sections}"><div class="wrap">
944
993
  <a href="#overview">${m.ui.tabOverview}</a><a href="#findings">${m.ui.findings} <span class="n">${problems}</span></a><a href="#journeys">${m.ui.tabJourneys} <span class="n">${runs.length}</span></a><a href="#scope">${m.ui.tabScope}</a></div></nav>
945
994
  <main class="wrap">
946
- <section class="tabpanel" id="overview" aria-label="${m.ui.tabOverview}">${overviewTab(m, findings, rows, counts, explained, runs)}</section>
995
+ <section class="tabpanel" id="overview" aria-label="${m.ui.tabOverview}">${changes ? changesCard(m, changes) : ""}${planCheck ? planVsSiteCard(m, planCheck) : ""}${overviewTab(m, findings, rows, counts, explained, runs)}</section>
947
996
  <section class="tabpanel" id="findings" aria-label="${m.ui.findings}">${findingsTab(m, runs, rows)}</section>
948
997
  <section class="tabpanel" id="journeys" aria-label="${m.ui.tabJourneys}">${journeysTab(m, lang, findings, runs, flagged)}</section>
949
998
  <section class="tabpanel" id="scope" aria-label="${m.ui.tabScope}"><div class="stack">${coverageCard(m, plan, runs)}${providersCard(m, runs)}</div></section>