@analitikisler/ai-tag-debugger 0.10.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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.11.0 (2026-10-16)
4
+
5
+ - Every saved run is compared with the site's previous run. The report opens with a "Changed since last run" section (`2 events fixed, 1 new problem`) that lists what was fixed and what is new, by plan coverage when both runs used `--against` and by health findings otherwise. The first run says there is nothing to compare yet. No new flag; the result is also in `capture.json` under `changes`.
6
+ - A plain sentence can be a request: `aitd "check my GA tags"`. The sentence becomes a fixed command through the AI provider, or a keyword matcher when no key is set. aitd prints the command and asks before running it; `-y` skips the question. An unclear sentence gets one short question back. A sentence that names no journey, goal or plan asks what to test, and suggests the site's latest plan when it has one.
7
+ - `aitd plan --topic <name>` limits a plan to one kind of event: `custom-events`, `ecommerce`, `forms` or `consent`. Sentences such as "what custom events does my site need" use it.
8
+ - The MCP server has a `make_plan` tool that drafts a tracking plan for a site, with an optional topic. Chat apps use it when a user asks what a site should track, and `run_audit` or `finish_audit` checks the audit against the plan.
9
+
3
10
  ## 0.10.0 (2026-10-16)
4
11
 
5
12
  - Plan, then audit. `aitd plan --site <url>` drafts a measurement plan in its own folder, `aitd/<domain>/plans/<id>/`: the events a site should send, with a description, the page and element that trigger each one, its parameters (custom ones marked) with example values, and suggested user properties. Events the site already sends are marked `seen`. It browses one page of each type (home, a category, a product, cart, first checkout step, search, sign-up and contact forms) without paying or submitting personal details, and prints the page types it found and the ones the site doesn't have.
package/README.md CHANGED
@@ -141,6 +141,33 @@ No API key yet? Replay the fixed steps without any AI model:
141
141
  node src/cli.js run --config examples/demo.config.json --scripted --no-ai --data-dir reports/demo
142
142
  ```
143
143
 
144
+ ## Just ask
145
+
146
+ You don't have to learn the options first. Put a plain sentence in quotes and aitd turns it into the matching command, prints that command and asks `Run this? (Y/n)` before it runs anything:
147
+
148
+ ```bash
149
+ aitd "check my google analytics tags"
150
+ # aitd run --platforms ga4
151
+ # Run this? (Y/n)
152
+ ```
153
+
154
+ | You type | aitd runs |
155
+ |---|---|
156
+ | `"Check my google analytics tags"` | `aitd run --platforms ga4` |
157
+ | `"What changed on my site since the last run"` | `aitd run` (every run already reports what changed) |
158
+ | `"Compare my site against the plan"` | `aitd run --against latest` |
159
+ | `"What custom events does my site need"` | `aitd plan --topic custom-events` |
160
+ | `"Is my Meta pixel firing on checkout?"` | `aitd run --platforms meta --goal "reach checkout"` |
161
+ | `"Test signing up for the newsletter"` | `aitd run --goal "sign up for the newsletter"` |
162
+ | `"Does my purchase event send the right revenue?"` | `aitd run --journey purchase` |
163
+ | `"Make a tracking plan for example.com"` | `aitd plan --site https://example.com` |
164
+ | `"What ecommerce events should an online shop track?"` | `aitd plan --topic ecommerce` |
165
+ | `"Check TikTok and Google Ads on the contact form"` | `aitd run --platforms google_ads,tiktok --goal "submit the contact form"` |
166
+
167
+ Add `-y` to skip the question: `aitd -y "check my google analytics tags"`.
168
+
169
+ If a sentence leaves something out, aitd asks one short question instead of guessing: plan or run, which site, or what to test. Reports can't be filtered from a sentence yet (`"Show me only the errors from the last report"` says so). Without an AI key, a small keyword matcher handles the common phrases. With one, the AI reads any wording, but it can only pick options that exist, so it never invents a flag.
170
+
144
171
  ## Plan, then audit
145
172
 
146
173
  A run checks what a site sends against a **plan**: the events, parameters and user properties you expect. Three steps:
@@ -159,6 +186,12 @@ aitd run --site https://yoursite.com --against latest
159
186
 
160
187
  **Drafting a plan** needs no tracking code. The AI explores one page of each type: the home page, a category, a product, the cart, the first checkout step, search, and any sign-up or contact forms. It never pays, and it never submits a form with personal details. `--provider`, `--model` and `--max-steps` (default 25, the most AI steps) work as they do for `run`. The output prints "Page types found" and "Not present on this site", so you can see which page types the AI reached. A small site, with only 2 pages, is fine.
161
188
 
189
+ To draft a plan for one kind of event only, add `--topic`: `custom-events`, `ecommerce`, `forms` or `consent`. The plan then covers just those events, and the rest of the plan format is unchanged:
190
+
191
+ ```bash
192
+ aitd plan --site https://yoursite.com --topic ecommerce
193
+ ```
194
+
162
195
  The plan is saved in a new folder, `aitd/<domain>/plans/<id>/`, as `measurement-plan.json` and a readable `plan.html`. For each event it lists:
163
196
 
164
197
  - `name` and `platform`. Events specific to your site, such as a size guide or a store locator, are marked `custom: true`.
@@ -224,6 +257,8 @@ aitd/
224
257
 
225
258
  An ID is the local time, `YYYY-MM-DD_HHMM`. When that minute is already taken, seconds are added (`_HHMMSS`). Nothing is ever replaced: each draft, import and run gets a new folder. Use `--data-dir <dir>` to keep the `aitd/` folder somewhere else. `replay` and `--save-site` folders are not affected.
226
259
 
260
+ Each report opens with a "Changed since last run" section that compares the run with the site's previous one in `runs/`, for example `2 events fixed, 1 new problem`, and lists each one. When both runs used `--against`, it compares plan coverage (an event that went from Missing to Implemented is fixed); otherwise it compares the health findings. The first run for a site says there is nothing to compare yet. There is no flag to set. The comparison is also saved under `changes` in `capture.json`.
261
+
227
262
  ## Choose what to test
228
263
 
229
264
  You don't need a config file to start. Pick ready-made journeys, describe your own in plain language, check against a plan, and narrow the check to the events you care about:
@@ -331,7 +366,8 @@ When a journey ran on both viewports, only the first viewport's steps are kept.
331
366
  ## CLI
332
367
 
333
368
  ```
334
- aitd plan --site <url> [--headed] [--provider <name>] [--model <id>] [--max-steps <n>] [--data-dir <dir>]
369
+ aitd "<sentence>" [-y]
370
+ aitd plan --site <url> [--topic <name>] [--headed] [--provider <name>] [--model <id>] [--max-steps <n>] [--data-dir <dir>]
335
371
  aitd plan list --site <url> [--data-dir <dir>]
336
372
  aitd plan import <file> --site <url> [--data-dir <dir>]
337
373
  aitd plan export <plan> --site <url> [--format xlsx|csv] [--data-dir <dir>]
@@ -411,13 +447,14 @@ Tools the server offers:
411
447
 
412
448
  | Tool | What it does |
413
449
  |---|---|
450
+ | `make_plan` | Drafts a tracking plan for a site (what should be tracked), the same as `aitd plan --site`. Pass `topic` (`custom-events`, `ecommerce`, `forms`, `consent`) to limit it to one kind of event. The plan is saved and its ID can be given as `against` to `finish_audit`. It uses the AI provider configured on the computer that runs the server and takes a few minutes. |
414
451
  | `start_audit` | Opens a fresh browser on a URL and starts recording all tracking. Pass a ready-made journey (`purchase`, `sign_up`, …) to get its goal and expected events, and `providers` to choose the platforms to check. The assistant asks you which platforms to check first. |
415
452
  | `browser_action` | One click, fill, select, goto, scroll or wait. Returns the page and the tracking hits that step sent. |
416
453
  | `get_page` | Shows the current page. |
417
454
  | `finish_audit` | Checks everything against your measurement plan (JSON), a saved one given as `against` (`latest`, `first`, a plan ID, or on a local server a path), or the ready-made journey's events, optionally only the `events` you name, and writes the full HTML report with the timeline. The site comes from the audit. |
418
455
  | `run_audit` | Runs a config file end to end. With `against`, it checks the run against a saved plan and, when the config has no journeys, walks the journeys derived from that plan. Local only for file paths. |
419
456
 
420
- `against` takes the same values as the CLI's `--against`. Over HTTP it takes only `latest`, `first` or a plan ID, never a path. Plans are the ones saved by `aitd plan --site <url>`.
457
+ `against` takes the same values as the CLI's `--against`. Over HTTP it takes only `latest`, `first` or a plan ID, never a path. Plans are the ones saved by `aitd plan --site <url>` or by `make_plan`. Ask for a plan when you want to know what to track, and for an audit when you want to know what the site sends.
421
458
 
422
459
  The browser runs on the computer where the server runs, so it can reach your staging and `localhost` sites. Tracking requests are answered locally as usual.
423
460
 
package/README.tr.md CHANGED
@@ -141,6 +141,33 @@ Henüz bir API anahtarınız yok mu? Sabit adımları hiçbir yapay zekâ modeli
141
141
  node src/cli.js run --config examples/demo.config.json --scripted --no-ai --data-dir reports/demo
142
142
  ```
143
143
 
144
+ ## Sadece sorun
145
+
146
+ Önce tüm seçenekleri öğrenmeniz gerekmez. Tırnak içinde düz bir cümle yazın; aitd bunu eşleşen komuta çevirir, komutu gösterir ve çalıştırmadan önce `Run this? (Y/n)` diye sorar:
147
+
148
+ ```bash
149
+ aitd "check my google analytics tags"
150
+ # aitd run --platforms ga4
151
+ # Run this? (Y/n)
152
+ ```
153
+
154
+ | Yazdığınız | aitd çalıştırır |
155
+ |---|---|
156
+ | `"Check my google analytics tags"` | `aitd run --platforms ga4` |
157
+ | `"What changed on my site since the last run"` | `aitd run` (her çalıştırma neyin değiştiğini zaten raporlar) |
158
+ | `"Compare my site against the plan"` | `aitd run --against latest` |
159
+ | `"What custom events does my site need"` | `aitd plan --topic custom-events` |
160
+ | `"Is my Meta pixel firing on checkout?"` | `aitd run --platforms meta --goal "reach checkout"` |
161
+ | `"Test signing up for the newsletter"` | `aitd run --goal "sign up for the newsletter"` |
162
+ | `"Does my purchase event send the right revenue?"` | `aitd run --journey purchase` |
163
+ | `"Make a tracking plan for example.com"` | `aitd plan --site https://example.com` |
164
+ | `"What ecommerce events should an online shop track?"` | `aitd plan --topic ecommerce` |
165
+ | `"Check TikTok and Google Ads on the contact form"` | `aitd run --platforms google_ads,tiktok --goal "submit the contact form"` |
166
+
167
+ Soruyu atlamak için `-y` ekleyin: `aitd -y "check my google analytics tags"`.
168
+
169
+ Bir cümlede bir şey eksikse aitd tahmin yürütmek yerine tek bir kısa soru sorar: plan mı çalıştırma mı, hangi site, ya da neyin test edileceği. Rapor filtreleme henüz cümleyle yapılamaz (`"Show me only the errors from the last report"` bunu söyler). Yapay zekâ anahtarı yoksa, yaygın ifadeleri küçük bir anahtar sözcük eşleştirici karşılar. Anahtar varsa yapay zekâ her ifadeyi anlar, ama yalnızca var olan seçenekleri seçebilir; bu yüzden var olmayan bir bayrak uydurmaz. Örnek cümleler İngilizcedir, çünkü anahtar sözcük eşleştirici İngilizce ifadeleri tanır.
170
+
144
171
  ## Planla, sonra denetle
145
172
 
146
173
  Bir çalıştırma, sitenin gönderdiklerini bir **plan** ile karşılaştırır: beklediğiniz event'ler, parametreler ve kullanıcı özellikleri. Üç adım:
@@ -159,6 +186,12 @@ aitd run --site https://yoursite.com --against latest
159
186
 
160
187
  **Plan taslağı çıkarmak** takip kodu gerektirmez. Yapay zekâ her sayfa türünden birer sayfayı inceler: ana sayfa, bir kategori, bir ürün, sepet, ilk ödeme adımı, arama ve varsa üyelik ya da iletişim formları. Asla ödeme yapmaz ve kişisel bilgilerle bir formu göndermez. `--provider`, `--model` ve `--max-steps` (varsayılan 25, yapay zekâ için en fazla adım) `run` için olduğu gibi çalışır. Çıktı "Bulunan sayfa türleri" ve "Bu sitede yok" satırlarını yazdırır, böylece yapay zekânın hangi sayfa türlerine ulaştığını görürsünüz. Yalnızca 2 sayfası olan küçük bir site de sorun değildir.
161
188
 
189
+ Yalnızca bir tür event için plan çıkarmak istiyorsanız `--topic` ekleyin: `custom-events`, `ecommerce`, `forms` ya da `consent`. Plan yalnızca bu event'leri kapsar, plan biçimi değişmez:
190
+
191
+ ```bash
192
+ aitd plan --site https://yoursite.com --topic ecommerce
193
+ ```
194
+
162
195
  Plan, yeni bir klasöre, `aitd/<alan adı>/plans/<id>/` içine `measurement-plan.json` ve okunabilir bir `plan.html` olarak kaydedilir. Her event için şunları listeler:
163
196
 
164
197
  - `name` ve `platform`. Sitenize özgü event'ler, örneğin bir beden rehberi ya da mağaza bulucu, `custom: true` ile işaretlenir.
@@ -224,6 +257,8 @@ aitd/
224
257
 
225
258
  Bir kimlik, yerel saat `YYYY-MM-DD_HHMM` biçimindedir. O dakika zaten alınmışsa saniye eklenir (`_HHMMSS`). Hiçbir şey değiştirilmez: her taslak, içe aktarma ve çalıştırma yeni bir klasör alır. `aitd/` klasörünü başka bir yerde tutmak için `--data-dir <dir>` kullanın. `replay` ve `--save-site` klasörleri etkilenmez.
226
259
 
260
+ Her rapor, çalıştırmayı sitenin `runs/` içindeki bir önceki çalıştırmasıyla karşılaştıran "Son çalıştırmadan bu yana değişenler" bölümüyle açılır, örneğin `2 olay düzeldi, 1 yeni sorun`, ve her birini listeler. İki çalıştırma da `--against` kullandıysa plan kapsamı karşılaştırılır (Eksik iken Uygulanmış olan bir olay düzelmiş sayılır); aksi halde sağlık bulguları karşılaştırılır. Bir sitenin ilk çalıştırmasında karşılaştırılacak bir şey olmadığı yazar. Ayarlanacak bir bayrak yoktur. Karşılaştırma `capture.json` içinde `changes` altında da kaydedilir.
261
+
227
262
  ## Ne test edileceğini seçin
228
263
 
229
264
  Başlamak için bir ayar dosyasına ihtiyacınız yok. Hazır yolculukları seçin, kendi yolculuğunuzu sade bir dille tarif edin, bir plana karşı kontrol edin ve kontrolü yalnızca ilgilendiğiniz event'lerle sınırlayın:
@@ -331,7 +366,8 @@ Bir yolculuk her iki görünümde de çalıştıysa yalnızca ilk görünümün
331
366
  ## CLI
332
367
 
333
368
  ```
334
- aitd plan --site <url> [--headed] [--provider <ad>] [--model <id>] [--max-steps <n>] [--data-dir <dir>]
369
+ aitd "<cümle>" [-y]
370
+ aitd plan --site <url> [--topic <ad>] [--headed] [--provider <ad>] [--model <id>] [--max-steps <n>] [--data-dir <dir>]
335
371
  aitd plan list --site <url> [--data-dir <dir>]
336
372
  aitd plan import <dosya> --site <url> [--data-dir <dir>]
337
373
  aitd plan export <plan> --site <url> [--format xlsx|csv] [--data-dir <dir>]
@@ -411,13 +447,14 @@ Sunucunun sunduğu araçlar:
411
447
 
412
448
  | Araç | Ne yapar |
413
449
  |---|---|
450
+ | `make_plan` | Bir site için takip planı çıkarır (neyin takip edilmesi gerektiği), `aitd plan --site` ile aynıdır. Yalnızca bir tür event için sınırlamak üzere `topic` verin (`custom-events`, `ecommerce`, `forms`, `consent`). Plan kaydedilir ve kimliği `finish_audit` için `against` olarak verilebilir. Sunucuyu çalıştıran bilgisayarda ayarlı yapay zekâ sağlayıcısını kullanır ve birkaç dakika sürer. |
414
451
  | `start_audit` | Bir URL'de yeni bir tarayıcı açar ve tüm takibi kaydetmeye başlar. Hedefini ve beklenen event'lerini almak için hazır bir yolculuk (`purchase`, `sign_up`, …) verin; kontrol edilecek platformları seçmek için `providers` verin. Asistan önce hangi platformların kontrol edileceğini size sorar. |
415
452
  | `browser_action` | Tek bir tıklama, doldurma, seçme, gitme, kaydırma ya da bekleme. Sayfayı ve o adımın gönderdiği takip isteklerini döndürür. |
416
453
  | `get_page` | Geçerli sayfayı gösterir. |
417
454
  | `finish_audit` | Her şeyi ölçümleme planınıza (JSON) ya da `against` olarak verilen kayıtlı bir plana (`latest`, `first`, bir plan kimliği ya da yerel bir sunucuda bir yol) ya da hazır yolculuğun event'lerine karşı kontrol eder; isterseniz yalnızca adını verdiğiniz `events` için, ve zaman akışıyla birlikte tam HTML raporunu yazar. Site denetimden alınır. |
418
455
  | `run_audit` | Bir ayar dosyasını baştan sona çalıştırır. `against` ile çalıştırmayı kayıtlı bir plana karşı kontrol eder; ayar dosyasında yolculuk yoksa, plandan türetilen yolculukları gezer. Dosya yolları yalnızca yerelde çalışır. |
419
456
 
420
- `against`, CLI'deki `--against` ile aynı değerleri alır. HTTP üzerinden yalnızca `latest`, `first` ya da bir plan kimliği kabul edilir, asla bir yol değil. Planlar, `aitd plan --site <url>` ile kaydedilmiş olanlardır.
457
+ `against`, CLI'deki `--against` ile aynı değerleri alır. HTTP üzerinden yalnızca `latest`, `first` ya da bir plan kimliği kabul edilir, asla bir yol değil. Planlar, `aitd plan --site <url>` ya da `make_plan` ile kaydedilmiş olanlardır. Neyin takip edileceğini öğrenmek için plan, sitenin ne gönderdiğini öğrenmek için denetim isteyin.
421
458
 
422
459
  Tarayıcı, sunucunun çalıştığı bilgisayarda çalışır; bu yüzden staging ve `localhost` sitelerinize erişebilir. Takip istekleri her zamanki gibi yerelde yanıtlanır.
423
460
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@analitikisler/ai-tag-debugger",
3
3
  "mcpName": "io.github.analitikisler/ai-tag-debugger",
4
- "version": "0.10.0",
4
+ "version": "0.11.0",
5
5
  "description": "Open-source AI agent that QA-tests your website's analytics, ad and consent tracking (GA4, GTM, Google Ads, Meta, TikTok). Bring your own model (Claude, OpenAI, Gemini) or use it from your chat app over MCP.",
6
6
  "type": "module",
7
7
  "bin": {
package/src/ask.js ADDED
@@ -0,0 +1,123 @@
1
+ // Turns a plain sentence like "check my GA tags" into an aitd command.
2
+ // The AI provider fills a fixed intent schema, so it can never invent options;
3
+ // without an AI key a small keyword matcher handles the common phrases.
4
+ import { PROVIDERS as PLATFORMS } from "./parsers.js";
5
+
6
+ const PLATFORM_KEYS = Object.keys(PLATFORMS);
7
+ // The same values as plan --topic (TOPICS in discover.js, kept in step by a test).
8
+ export const TOPIC_KEYS = ["custom-events", "ecommerce", "forms", "consent"];
9
+
10
+ export const INTENT_SCHEMA = {
11
+ type: "object",
12
+ additionalProperties: false,
13
+ required: ["action", "site", "against", "platforms", "goal", "journey", "topic", "question"],
14
+ properties: {
15
+ action: { type: "string", enum: ["plan", "run", "report", "unclear"], description: "plan drafts what a site should track; run audits what the site sends; report re-reads an existing report; unclear when you cannot tell plan from run." },
16
+ site: { type: ["string", "null"], description: "The site's domain or URL if the sentence names one." },
17
+ against: { type: ["string", "null"], enum: ["plan", "latest", null], description: "run only: compare with the saved plan, or with the latest run (\"what changed\", \"since the last run\", \"after the deploy\")." },
18
+ platforms: { type: "array", items: { type: "string", enum: PLATFORM_KEYS }, description: "Only the tracking platforms the sentence names; empty for all." },
19
+ goal: { type: ["string", "null"], description: "run only: a short user journey to try, in plain words, e.g. \"submit the contact form\"." },
20
+ journey: { type: ["string", "null"], enum: ["purchase", null], description: "run only: purchase when the sentence is about the purchase event or revenue." },
21
+ topic: { type: ["string", "null"], enum: [...TOPIC_KEYS, null], description: "plan only: limit the plan to one kind of event when the sentence asks about it." },
22
+ question: { type: ["string", "null"], description: "One short question to ask back when the sentence is ambiguous; otherwise null." },
23
+ },
24
+ };
25
+
26
+ const SYSTEM = `You translate a request about website tracking (analytics, ads, consent tags) into options for the aitd command line tool. Fill the schema with only what the sentence says; leave the rest null or empty. Use action "unclear" and a short question when you cannot tell whether the user wants a tracking plan or an audit of the live site.`;
27
+
28
+ const has = (s, re) => re.test(s);
29
+
30
+ /** The intent for a sentence from keywords alone, for when no AI key is set. */
31
+ export function matchKeywords(sentence) {
32
+ const s = ` ${sentence.toLowerCase()} `;
33
+ const intent = { action: null, site: null, against: null, platforms: [], goal: null, journey: null, topic: null, question: null };
34
+ const site = sentence.match(/\b(?:https?:\/\/)?((?:[a-z0-9-]+\.)+[a-z]{2,})(?:\/\S*)?/i);
35
+ if (site) intent.site = site[1].toLowerCase();
36
+
37
+ if (has(s, /google analytics|\bga4?\b/)) intent.platforms.push("ga4");
38
+ if (has(s, /google ads|adwords/)) intent.platforms.push("google_ads");
39
+ if (has(s, /\bmeta\b|facebook/)) intent.platforms.push("meta");
40
+ if (has(s, /tiktok/)) intent.platforms.push("tiktok");
41
+
42
+ if (has(s, /only the (errors|failures)|from the (last|latest) report/)) intent.action = "report";
43
+ else if (has(s, /tracking plan|make a plan|should .* track|does my site need|events .* need/)) intent.action = "plan";
44
+ else {
45
+ if (has(s, /since (the )?last run|what changed|\bbreak|\bbroke|deploy/)) intent.against = "latest";
46
+ else if (has(s, /(against|in) the plan/)) intent.against = "plan";
47
+ if (has(s, /revenue|purchase event/)) intent.journey = "purchase";
48
+ else if (has(s, /newsletter/)) intent.goal = "sign up for the newsletter";
49
+ else if (has(s, /accept(ing)? (the )?cookies|consent/)) intent.goal = "browse without accepting cookies";
50
+ else if (has(s, /contact form/)) intent.goal = "submit the contact form";
51
+ else if (has(s, /checkout/)) intent.goal = "reach checkout";
52
+ if (intent.platforms.length || intent.against || intent.goal || intent.journey || has(s, /\b(check|test|audit|firing|fire|working)\b/)) intent.action = "run";
53
+ }
54
+ if (intent.action === "plan") {
55
+ if (has(s, /custom events?/)) intent.topic = "custom-events";
56
+ else if (has(s, /ecommerce|e-commerce|online shop|\bshop\b|\bstore\b/)) intent.topic = "ecommerce";
57
+ else if (has(s, /\bforms?\b/)) intent.topic = "forms";
58
+ else if (has(s, /consent|cookies?/)) intent.topic = "consent";
59
+ }
60
+ if (!intent.action) {
61
+ intent.action = "unclear";
62
+ intent.question = "Do you want a tracking plan or a check of what the site sends? (plan/run)";
63
+ }
64
+ return intent;
65
+ }
66
+
67
+ /** Asks the provider for the intent of a sentence; falls back to keywords without one. */
68
+ export async function translate(sentence, { provider } = {}) {
69
+ if (!provider) return { intent: matchKeywords(sentence), by: "keywords" };
70
+ const result = await provider.json({ system: SYSTEM, texts: [sentence], schema: INTENT_SCHEMA });
71
+ if (result.error) return { intent: matchKeywords(sentence), by: "keywords" };
72
+ return { intent: normalize(result.value), by: "ai" };
73
+ }
74
+
75
+ /** Keeps only schema values, so an odd reply can never become an option. */
76
+ export function normalize(v = {}) {
77
+ const str = (x) => (typeof x === "string" && x.trim() ? x.trim() : null);
78
+ return {
79
+ action: ["plan", "run", "report", "unclear"].includes(v.action) ? v.action : "unclear",
80
+ site: str(v.site),
81
+ against: ["plan", "latest"].includes(v.against) ? v.against : null,
82
+ platforms: [...new Set((Array.isArray(v.platforms) ? v.platforms : []).filter((p) => PLATFORM_KEYS.includes(p)))],
83
+ goal: str(v.goal),
84
+ journey: v.journey === "purchase" ? "purchase" : null,
85
+ topic: TOPIC_KEYS.includes(v.topic) ? v.topic : null,
86
+ question: str(v.question),
87
+ };
88
+ }
89
+
90
+ const siteUrl = (site) => (/^https?:\/\//i.test(site) ? site : `https://${site}`);
91
+
92
+ /** The aitd arguments for an intent, or { question } when one more answer is needed. */
93
+ export function intentToArgs(intent) {
94
+ if (intent.action === "unclear") return { question: intent.question ?? "Do you want a tracking plan or a check of what the site sends? (plan/run)" };
95
+ if (intent.action === "report") return { unsupported: "Filtering an existing report is not available yet. Open the latest report in aitd/<domain>/runs/." };
96
+ const args = [intent.action];
97
+ if (intent.site) args.push("--site", siteUrl(intent.site));
98
+ if (intent.action === "plan" && intent.topic) args.push("--topic", intent.topic);
99
+ if (intent.action === "run") {
100
+ // --against takes a saved plan. Every run already reports what changed since the previous
101
+ // run, so "since the last run" needs no option of its own.
102
+ if (intent.against === "plan") args.push("--against", "latest");
103
+ if (intent.platforms.length) args.push("--platforms", intent.platforms.join(","));
104
+ if (intent.journey) args.push("--journey", intent.journey);
105
+ if (intent.goal) args.push("--goal", intent.goal);
106
+ }
107
+ return { args };
108
+ }
109
+
110
+ /** True when a run has nothing to test yet: no plan to check against, no journey, no goal. */
111
+ export function needsWhatToTest(args) {
112
+ return args[0] === "run" && !["--against", "--journey", "--goal"].some((o) => args.includes(o));
113
+ }
114
+
115
+ /** The command as the user would type it. */
116
+ export function commandText(args) {
117
+ return ["aitd", ...args.map((a) => (/^[\w.,:/@=-]+$/.test(a) ? a : `"${a.replace(/(["\\$`])/g, "\\$1")}"`))].join(" ");
118
+ }
119
+
120
+ /** True when the arguments are one sentence rather than a command. */
121
+ export function isSentence(positionals) {
122
+ return positionals.length === 1 && /\s/.test(positionals[0].trim());
123
+ }
package/src/audit.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // A full audit from a config file: run every journey, check, explain, write the reports.
2
2
  // Shared by the CLI and the MCP server's run_audit tool.
3
3
  import { newRunDir } from "./workspace.js";
4
+ import { compareRuns, previousRun } from "./changes.js";
4
5
  import { readFile, mkdir, rm, writeFile } from "node:fs/promises";
5
6
  import path from "node:path";
6
7
  import { failedRun, runJourney } from "./agent.js";
@@ -34,18 +35,23 @@ export async function loadConfig(configFile) {
34
35
  }
35
36
 
36
37
 
37
- /** Writes report.md, report.html, timeline.csv and capture.json, plus plan-check.json with --against. */
38
- export async function writeReports({ outDir, config, plan, runs, findings, explained, lang, against = null, branding = config.branding !== false }) {
38
+ /**
39
+ * Writes report.md, report.html, timeline.csv and capture.json, plus plan-check.json with --against.
40
+ * previous: the site's last saved run (see previousRun), null when there is none, or undefined to skip
41
+ * the "Changed since last run" section.
42
+ */
43
+ export async function writeReports({ outDir, config, plan, runs, findings, explained, lang, against = null, branding = config.branding !== false, previous = undefined }) {
39
44
  await mkdir(outDir, { recursive: true });
40
45
  const title = messages(lang).ui.title(new URL(config.site).hostname);
41
46
  const generatedAt = new Date().toISOString().slice(0, 16).replace("T", " ") + " UTC";
42
47
  const check = against ? { plan_id: against.id ?? null, ...planCheck(plan, runs, findings) } : null;
43
- const report = { title, generatedAt, findings, explained, runs, plan, lang, branding, against: against?.label ?? null, planCheck: check };
48
+ const changes = previous === undefined ? null : compareRuns(previous, { findings, planCheck: check });
49
+ const report = { title, generatedAt, findings, explained, runs, plan, lang, branding, against: against?.label ?? null, planCheck: check, changes };
44
50
  await writeFile(path.join(outDir, "report.md"), renderMarkdown(report));
45
51
  await writeFile(path.join(outDir, "report.html"), renderHtml(report));
46
52
  await writeFile(path.join(outDir, "timeline.csv"), renderCsv(report));
47
53
  if (check) await writeFile(path.join(outDir, "plan-check.json"), JSON.stringify(check, null, 2));
48
- await writeFile(path.join(outDir, "capture.json"), JSON.stringify({ config: { ...config, plan }, ...(against ? { against: { plan_id: against.id, file: against.file } } : {}), runs, findings, explained }, null, 2));
54
+ await writeFile(path.join(outDir, "capture.json"), JSON.stringify({ config: { ...config, plan }, ...(against ? { against: { plan_id: against.id, file: against.file } } : {}), runs, findings, explained, ...(changes ? { changes } : {}) }, null, 2));
49
55
  return path.join(outDir, "report.html");
50
56
  }
51
57
 
@@ -130,8 +136,10 @@ export async function runAudit(opts) {
130
136
  explained = await explainFindings({ findings, plan, runs, gtmContainer, provider, lang, log });
131
137
  }
132
138
 
139
+ // Runs saved in the site's runs folder are compared with the one before; a run sent to --out is not.
140
+ const previous = opts.outDir ? undefined : await previousRun(opts.dataDir, config.site);
133
141
  const outDir = path.resolve(opts.outDir ?? (await newRunDir(opts.dataDir, config.site)).dir);
134
- const reportPath = await writeReports({ outDir, config, plan, runs, findings, explained, lang, against, branding: opts.branding ?? config.branding !== false });
142
+ const reportPath = await writeReports({ outDir, config, plan, runs, findings, explained, lang, against, branding: opts.branding ?? config.branding !== false, previous });
135
143
  const counts = summarize(groupFindings(findings));
136
144
  return { journeys, plan, runs, findings, explained, counts, reportPath, outDir, lang, ...(against ? { againstId: against.id } : {}), ...(saveError ? { saveError } : {}) };
137
145
  }
package/src/changes.js ADDED
@@ -0,0 +1,66 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { listRunIds, runsDir } from "./workspace.js";
4
+
5
+ const PROBLEMS = new Set(["broken", "risk", "warning"]);
6
+ const PLAN_PROBLEMS = new Set(["missing", "missing_params", "wrong_values"]);
7
+
8
+ const readJson = (file) => readFile(file, "utf8").then(JSON.parse).catch(() => null);
9
+
10
+ /** The site's newest saved run before this one, with its capture and plan check, or null. */
11
+ export async function previousRun(dataDir, site, before = null) {
12
+ const ids = (await listRunIds(dataDir, site)).filter((id) => !before || id < before);
13
+ for (const id of ids.reverse()) {
14
+ const dir = path.join(runsDir(dataDir, site), id);
15
+ const capture = await readJson(path.join(dir, "capture.json"));
16
+ if (capture) return { id, capture, planCheck: await readJson(path.join(dir, "plan-check.json")) };
17
+ }
18
+ return null;
19
+ }
20
+
21
+ /** One entry per problem, keyed so the same problem matches across runs whatever its wording. */
22
+ function findingProblems(findings = []) {
23
+ const out = new Map();
24
+ for (const f of findings) {
25
+ if (!PROBLEMS.has(f.severity)) continue;
26
+ const k = [f.check, f.platform ?? "", f.event ?? ""].join("|");
27
+ if (!out.has(k)) out.set(k, { check: f.check, platform: f.platform ?? null, event: f.event ?? null, severity: f.severity, detail: f.detail });
28
+ }
29
+ return out;
30
+ }
31
+
32
+ function planProblems(pc) {
33
+ const out = new Map();
34
+ for (const e of pc?.events ?? []) out.set(`${e.platform}|${e.event}`, { platform: e.platform, event: e.event, bucket: e.bucket });
35
+ return out;
36
+ }
37
+
38
+ /**
39
+ * What changed since the previous run of the same site. Compares plan coverage when both runs were
40
+ * checked against a plan, and the health findings otherwise.
41
+ * Returns { previous: null } when there is no earlier run, else
42
+ * { previous, mode: "plan" | "findings", fixed: [...], new: [...] }.
43
+ */
44
+ export function compareRuns(prev, { findings, planCheck = null }) {
45
+ if (!prev) return { previous: null };
46
+ if (planCheck && prev.planCheck) {
47
+ const before = planProblems(prev.planCheck);
48
+ const now = planProblems(planCheck);
49
+ const fixed = [];
50
+ const added = [];
51
+ for (const [k, e] of now) {
52
+ const was = before.get(k)?.bucket;
53
+ if (e.bucket === "implemented" && PLAN_PROBLEMS.has(was)) fixed.push({ ...e, was });
54
+ if (PLAN_PROBLEMS.has(e.bucket) && !PLAN_PROBLEMS.has(was)) added.push({ ...e, was: was ?? null });
55
+ }
56
+ return { previous: prev.id, mode: "plan", fixed, new: added };
57
+ }
58
+ const before = findingProblems(prev.capture?.findings);
59
+ const now = findingProblems(findings);
60
+ return {
61
+ previous: prev.id,
62
+ mode: "findings",
63
+ fixed: [...before].filter(([k]) => !now.has(k)).map(([, f]) => f),
64
+ new: [...now].filter(([k]) => !before.has(k)).map(([, f]) => f),
65
+ };
66
+ }
package/src/cli.js CHANGED
@@ -8,11 +8,13 @@ import { helpText } from "./help.js";
8
8
  import { replaySavedSite, writeSavedSteps } from "./replay.js";
9
9
  import { savePlan } from "./plan.js";
10
10
  import { DEFAULT_DATA_DIR } from "./workspace.js";
11
+ import { commandText, intentToArgs, isSentence, needsWhatToTest, translate } from "./ask.js";
11
12
 
12
13
  const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
13
14
 
14
- async function main() {
15
+ async function main(args = process.argv.slice(2)) {
15
16
  const { values, positionals } = parseArgs({
17
+ args,
16
18
  allowPositionals: true,
17
19
  options: {
18
20
  config: { type: "string" },
@@ -49,8 +51,10 @@ async function main() {
49
51
  "data-dir": { type: "string" },
50
52
  against: { type: "string" },
51
53
  from: { type: "string" },
54
+ topic: { type: "string" },
52
55
  format: { type: "string" },
53
56
  force: { type: "boolean", default: false },
57
+ yes: { type: "boolean", short: "y", default: false },
54
58
  help: { type: "boolean", short: "h", default: false },
55
59
  version: { type: "boolean", short: "v", default: false },
56
60
  },
@@ -61,6 +65,11 @@ async function main() {
61
65
  return;
62
66
  }
63
67
 
68
+ if (isSentence(positionals) && !values.help) {
69
+ const next = await fromSentence(positionals[0], values, args);
70
+ if (next) await main(next);
71
+ return;
72
+ }
64
73
  if (command === "mcp" && !values.help) {
65
74
  const { startMcp } = await import("./mcp.js");
66
75
  await startMcp({ http: values.http, port: values.port ? Number(values.port) : undefined, host: values.host, token: values.token, reportsDir: values.reports, publicUrl: values["public-url"], allowPrivateNetwork: values["allow-private-network"] });
@@ -105,6 +114,7 @@ async function main() {
105
114
  if (values.out) throw new Error("plan --out was removed. Plans are saved in aitd/<domain>/plans/<id>/; use --data-dir <dir> to keep them elsewhere.");
106
115
  if (values.force) throw new Error("--force was removed. Each plan is saved in a new folder, so nothing is replaced.");
107
116
  if (!values.site) throw new Error("plan needs --site <url>: aitd plan --site https://www.example.com");
117
+ if (values.topic !== undefined) (await import("./discover.js")).checkTopic(values.topic);
108
118
  const dataDir = values["data-dir"] ?? DEFAULT_DATA_DIR;
109
119
  const { domainOf } = await import("./workspace.js");
110
120
  domainOf(values.site); // Checked before the browser starts.
@@ -114,7 +124,7 @@ async function main() {
114
124
  const { discoverPlan } = await import("./discover.js");
115
125
  const provider = await createProvider({ name: values.provider, model: values.model, baseUrl: values["base-url"] });
116
126
  console.log(`Exploring ${values.site} to draft its measurement plan…`);
117
- const { plan } = await discoverPlan({ site: values.site, provider, maxSteps, headed: values.headed, allowedHosts: values["allow-host"], log: console.log });
127
+ const { plan } = await discoverPlan({ site: values.site, topic: values.topic, provider, maxSteps, headed: values.headed, allowedHosts: values["allow-host"], log: console.log });
118
128
  const { id, dir } = await savePlan(plan, { dataDir, site: values.site, branding: !values["no-branding"] });
119
129
  const custom = plan.events.filter((e) => e.custom).length;
120
130
  const rel = path.relative(process.cwd(), dir);
@@ -212,6 +222,67 @@ async function main() {
212
222
  }
213
223
  }
214
224
 
225
+ // aitd "check my GA tags": shows the matching command and runs it once confirmed.
226
+ async function fromSentence(sentence, values, extra) {
227
+ const { createProvider } = await import("./providers.js");
228
+ const provider = await createProvider({ name: values.provider, model: values.model, baseUrl: values["base-url"] }).catch((err) => {
229
+ if (err.code === "NO_API_KEY") return null;
230
+ throw err;
231
+ });
232
+ let { intent } = await translate(sentence, { provider });
233
+ let r = intentToArgs(intent);
234
+ if (r.question) {
235
+ const answer = (await ask(`${r.question} `)).toLowerCase();
236
+ if (!["plan", "run"].includes(answer)) throw new Error('Answer "plan" or "run", or type the command, e.g. aitd run --help.');
237
+ r = intentToArgs({ ...intent, action: answer });
238
+ intent = { ...intent, action: answer };
239
+ }
240
+ if (r.unsupported) throw new Error(r.unsupported);
241
+ let args = r.args;
242
+ const needsSite = !values.site && !args.includes("--site") && (args[0] === "plan" || !args.includes("--against"));
243
+ if (needsSite) {
244
+ const site = await ask("Which site? (e.g. https://www.example.com) ");
245
+ if (!site) throw new Error("A site is needed. Add it to the sentence or pass --site <url>.");
246
+ r = intentToArgs({ ...intent, site });
247
+ args = r.args;
248
+ }
249
+ if (needsWhatToTest(args)) args = await whatToTest(args, values);
250
+ // Options typed next to the sentence (--site, --provider, --headed…) carry over.
251
+ args = [...args, ...extra.filter((a) => a !== sentence && a !== "-y" && a !== "--yes")];
252
+ console.log(commandText(args));
253
+ if (!values.yes) {
254
+ const answer = (await ask("Run this? (Y/n) ")).toLowerCase();
255
+ if (answer && !["y", "yes"].includes(answer)) return null;
256
+ }
257
+ return args;
258
+ }
259
+
260
+ // A run needs something to test: suggest the site's latest plan, else ask for a goal.
261
+ async function whatToTest(args, values) {
262
+ const site = values.site ?? args[args.indexOf("--site") + 1];
263
+ const { listPlanIds } = await import("./workspace.js");
264
+ const hasPlan = (await listPlanIds(values["data-dir"] ?? DEFAULT_DATA_DIR, site).catch(() => [])).length > 0;
265
+ if (hasPlan) {
266
+ const answer = values.yes ? "" : (await ask("Check against the latest plan? (Y/n) ")).toLowerCase();
267
+ if (!answer || ["y", "yes"].includes(answer)) return [...args, "--against", "latest"];
268
+ } else if (values.yes) {
269
+ throw new Error('Nothing to test yet. Say what to check, e.g. aitd "check my GA tags on the contact form", or draft a plan first.');
270
+ }
271
+ const goal = await ask("What should I check, e.g. the contact form? ");
272
+ if (!goal) throw new Error('Nothing to test. Say what to check, e.g. aitd "check my GA tags on the contact form".');
273
+ return [...args, "--goal", goal];
274
+ }
275
+
276
+ async function ask(question) {
277
+ const { createInterface } = await import("node:readline/promises");
278
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
279
+ try {
280
+ return (await rl.question(question)).trim();
281
+ } finally {
282
+ rl.close();
283
+ }
284
+ }
285
+
215
286
  async function planSheet(action, arg, values) {
216
287
  const dataDir = values["data-dir"] ?? DEFAULT_DATA_DIR;
217
288
  const shown = (p) => {
package/src/discover.js CHANGED
@@ -15,6 +15,39 @@ Propose the events this site should track:
15
15
  - Suggested user properties (for example customer_type, logged_in, membership_level), with a description and example.
16
16
  Only propose what the pages show is possible. Prefer fewer, useful events over many trivial ones.`;
17
17
 
18
+ /** Kinds of events a plan can be limited to with --topic: what to explore, what to propose, and which events to keep. */
19
+ const ECOMMERCE = new Set(["view_item_list", "select_item", "view_item", "add_to_cart", "remove_from_cart", "view_cart", "add_to_wishlist", "begin_checkout", "add_shipping_info", "add_payment_info", "purchase", "refund", "view_promotion", "select_promotion"]);
20
+ const FORMS = new Set(["sign_up", "login", "generate_lead"]);
21
+ export const TOPICS = {
22
+ "custom-events": {
23
+ explore: "the pages with interactions specific to this site (tools, filters, videos, downloads, configurators, locators)",
24
+ propose: "custom events for interactions specific to this site. Do not propose GA4 recommended events",
25
+ keep: (ev) => Boolean(ev.custom),
26
+ },
27
+ ecommerce: {
28
+ explore: "the home page, one listing page, one product page, the cart (add one item first) and the first checkout step",
29
+ propose: "the GA4 ecommerce events (view_item_list, select_item, view_item, add_to_cart, remove_from_cart, view_cart, begin_checkout, add_shipping_info, add_payment_info, purchase, and promotions) with their items parameters",
30
+ keep: (ev) => ECOMMERCE.has(ev.name),
31
+ },
32
+ forms: {
33
+ explore: "the sign-up, login, contact and newsletter forms",
34
+ propose: "form events: sign_up, login, generate_lead, and custom form events (for example form_start, form_submit, form_error, newsletter_signup). Never propose ecommerce events",
35
+ keep: (ev) => FORMS.has(ev.name) || /form|newsletter|subscribe|signup|sign_up|lead|contact/.test(ev.name),
36
+ },
37
+ consent: {
38
+ explore: "the cookie or consent banner and any privacy settings page. Open the banner's settings before accepting it",
39
+ propose: "consent events: the banner shown, accepted, rejected and customised (for example consent_banner_view, consent_update), with the chosen categories as parameters. Never propose ecommerce or form events",
40
+ keep: (ev) => /consent|cookie|privacy|gdpr/.test(ev.name),
41
+ },
42
+ };
43
+
44
+ /** Checks a --topic value; undefined means no topic (the whole plan). */
45
+ export function checkTopic(topic) {
46
+ if (topic === undefined) return undefined;
47
+ if (!Object.hasOwn(TOPICS, topic)) throw new Error(`Unknown --topic "${topic}". Use one of: ${Object.keys(TOPICS).join(", ")}`);
48
+ return topic;
49
+ }
50
+
18
51
  const str = { type: "string" };
19
52
  const PLAN_SCHEMA = {
20
53
  type: "object",
@@ -106,11 +139,14 @@ export function toPlan(site, answer, hits = []) {
106
139
 
107
140
  /**
108
141
  * Lets the AI explore the site (one page per type), then asks it for a measurement plan.
109
- * @param {{ site: string, provider: any, maxSteps?: number, headed?: boolean, allowedHosts?: string[], viewport?: string, log?: (msg: string) => void }} opts
142
+ * With `topic`, exploring and the plan are limited to that kind of event (see TOPICS).
143
+ * @param {{ site: string, topic?: string, provider: any, maxSteps?: number, headed?: boolean, allowedHosts?: string[], viewport?: string, blockPrivate?: boolean, log?: (msg: string) => void }} opts
110
144
  */
111
145
  export async function discoverPlan(opts) {
112
146
  const log = opts.log ?? (() => {});
113
- const session = new AuditSession({ viewport: opts.viewport ?? "desktop", site: opts.site, allowedHosts: opts.allowedHosts, headless: !opts.headed });
147
+ const topic = TOPICS[checkTopic(opts.topic)];
148
+ const goal = topic ? `${DISCOVER_GOAL}\nThis plan covers only ${opts.topic}: visit just ${topic.explore}, and skip other kinds of page.` : DISCOVER_GOAL;
149
+ const session = new AuditSession({ viewport: opts.viewport ?? "desktop", site: opts.site, allowedHosts: opts.allowedHosts, headless: !opts.headed, blockPrivate: opts.blockPrivate });
114
150
  const snapshots = [];
115
151
  let outcome;
116
152
  try {
@@ -123,7 +159,7 @@ export async function discoverPlan(opts) {
123
159
  snapshots.push(s);
124
160
  return s;
125
161
  };
126
- outcome = await runAgentJourney(session, { id: "discover", goal: DISCOVER_GOAL }, { provider: opts.provider, maxSteps: opts.maxSteps ?? 25, log }).catch((err) => {
162
+ outcome = await runAgentJourney(session, { id: "discover", goal }, { provider: opts.provider, maxSteps: opts.maxSteps ?? 25, log }).catch((err) => {
127
163
  if (err instanceof StepTimeout) return { completed: false, note: err.message };
128
164
  throw err;
129
165
  });
@@ -135,12 +171,13 @@ export async function discoverPlan(opts) {
135
171
  log(`\nExplored ${pages.length} pages${outcome.completed ? "" : ` (${outcome.note})`}. Drafting the plan…`);
136
172
  const existing = [...new Set(session.hits.map((h) => `${h.platform}:${h.name}`))];
137
173
  const result = await opts.provider.json({
138
- system: PLAN_SYSTEM,
174
+ system: topic ? `${PLAN_SYSTEM}\nThis plan covers only ${opts.topic}. Propose only ${topic.propose}.` : PLAN_SYSTEM,
139
175
  texts: [`Site: ${opts.site}`, `Pages visited:\n${JSON.stringify(pages, null, 1)}`, `Tracking hits the site already sends: ${existing.length ? existing.join(", ") : "none"}`],
140
176
  schema: PLAN_SCHEMA,
141
177
  });
142
178
  if (result.error) throw new Error(`The model did not return a plan (${result.error}).`);
143
179
  const plan = toPlan(opts.site, result.value, session.hits);
180
+ if (topic) plan.events = plan.events.filter(topic.keep);
144
181
  const coverage = pageTypeCoverage(plan.pages);
145
182
  log(`Page types found: ${coverage.found.join(", ") || "none"}`);
146
183
  if (coverage.absent.length) log(`Not present on this site: ${coverage.absent.join(", ")}`);
package/src/help.js CHANGED
@@ -18,13 +18,15 @@ Examples:
18
18
  aitd plan --site https://example.com
19
19
  aitd run --site https://example.com --against latest
20
20
  aitd run --site https://example.com --journey purchase
21
+ aitd "check my GA tags on example.com" (shows the command first; -y skips the question)
21
22
 
22
23
  Run "aitd <command> --help" for that command's options. -h, --help -v, --version`;
23
24
 
24
25
  const RUN = `Usage: aitd run --site <url> (--against <plan> | --journey <id> | --goal "<text>")
25
26
 
26
27
  Audit what a site actually sends while an AI agent walks its journeys. Needs an AI key
27
- (${Object.values(PROVIDERS).map((p) => p.env).join(", ")}) unless --no-ai.
28
+ (${Object.values(PROVIDERS).map((p) => p.env).join(", ")}) unless --no-ai. Each run is saved in
29
+ aitd/<domain>/runs/<id>/ and its report says what changed since the site's previous run.
28
30
 
29
31
  What to test (at least one):
30
32
  --against <plan> Check against a plan: latest, first, a plan ID or a plan file or folder
@@ -62,6 +64,7 @@ Commands:
62
64
 
63
65
  Options:
64
66
  --site <url> The site (required)
67
+ --topic <name> Only plan one kind of event: custom-events, ecommerce, forms or consent
65
68
  --headed Show the browser window
66
69
 
67
70
  AI: --provider <name> --model <id> (default: whichever API key is set)
package/src/i18n.js CHANGED
@@ -87,6 +87,12 @@ const en = {
87
87
  headerMeta: (journeys, providers) => `${journeys} ${journeys === 1 ? "journey" : "journeys"} · ${providers} ${providers === 1 ? "provider" : "providers"}`,
88
88
  checkedAgainst: (id) => `Checked against plan ${id}`,
89
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.",
90
96
  planVsSiteLead: "Each planned event, and what the site actually sent",
91
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` : ""}`,
92
98
  bucket: { implemented: "Implemented", missing: "Missing", missing_params: "Missing params", wrong_values: "Wrong values", unplanned: "Unplanned", not_checked: "Not checked" },
@@ -240,6 +246,12 @@ const tr = {
240
246
  headerMeta: (journeys, providers) => `${journeys} yolculuk · ${providers} sağlayıcı`,
241
247
  checkedAgainst: (id) => `${id} planına göre kontrol edildi`,
242
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.",
243
255
  planVsSiteLead: "Plandaki her olay ve sitenin gerçekte gönderdiği",
244
256
  coverageLine: (c) => `Kapsam ${c.checkable} olaydan ${c.implemented}${c.percent === null ? "" : ` (%${c.percent})`}${c.not_checked ? `, ${c.not_checked} olay kontrol edilmedi` : ""}`,
245
257
  bucket: { implemented: "Uygulanmış", missing: "Eksik", missing_params: "Eksik parametre", wrong_values: "Hatalı değer", unplanned: "Planda yok", not_checked: "Kontrol edilmedi" },
package/src/mcp.js CHANGED
@@ -21,6 +21,9 @@ 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
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";
24
27
  import { DEFAULT_PROVIDERS, PROVIDERS as TRACKING_PROVIDERS } from "./parsers.js";
25
28
  import { expectEvents, filterFindings, JOURNEY_PRESETS, onlyEvents, presetFor, withPresetEvents } from "./journeys.js";
26
29
 
@@ -32,6 +35,8 @@ const MIN_TOKEN_LENGTH = 16;
32
35
 
33
36
  const INSTRUCTIONS = `ai-tag-debugger checks a website's analytics and ad tracking (GA4, Google Ads, Meta Pixel, TikTok Pixel, Consent Mode).
34
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
+
35
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.
36
41
 
37
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.
@@ -123,6 +128,7 @@ async function loadAgainst(ref, site, opts) {
123
128
  * blockPrivate: the browser refuses loopback, private and link-local addresses (HTTP servers, by default).
124
129
  * dataDir: where saved plans live (default ./aitd), used by `against`.
125
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).
126
132
  */
127
133
  export function buildServer(opts) {
128
134
  const server = new McpServer({ name: "ai-tag-debugger", version: VERSION }, { instructions: INSTRUCTIONS });
@@ -298,6 +304,53 @@ export function buildServer(opts) {
298
304
  },
299
305
  );
300
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
+
301
354
  if (opts.allowFiles) {
302
355
  server.registerTool(
303
356
  "run_audit",
package/src/report.js CHANGED
@@ -149,11 +149,12 @@ 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, against = null, planCheck = null }) {
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
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));
157
158
  if (planCheck) lines.push(...planVsSiteMd(m, planCheck));
158
159
  const sum = summaryParts(explained?.summary);
159
160
  if (sum) {
@@ -619,6 +620,29 @@ const coverageLines = (m, pc) => [
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));
620
621
  const BUCKET_ORDER = ["missing", "missing_params", "wrong_values", "implemented", "unplanned", "not_checked"];
621
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
+
622
646
  function planVsSiteMd(m, pc) {
623
647
  const [overall, ...perPlatform] = coverageLines(m, pc);
624
648
  const lines = [`## ${m.ui.planVsSite}`, "", `**${mdText(overall)}**`, "", ...perPlatform.map((l) => `- ${mdText(l)}`), ...(perPlatform.length ? [""] : [])];
@@ -778,6 +802,7 @@ table.dt a{color:var(--fg)}table.dt a:hover{color:var(--accent-fg)}
778
802
  .fsr{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}
779
803
  .sevfilter label{padding:4px 10px;border-radius:4px;font-weight:500;font-size:12px;color:var(--muted);cursor:pointer}
780
804
  .filter-host{position:relative}
805
+ .changes{list-style:none;margin:8px 0 0;padding:0;display:grid;gap:6px}
781
806
  .flist{list-style:none;margin:0;padding:0;display:grid;gap:8px}
782
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)}
783
808
  .finding.sev-broken{--sev:var(--danger-dot);--sev-fg:var(--danger);--sev-bg:var(--danger-bg)}
@@ -940,7 +965,7 @@ pre{margin:8px 0 0;font-size:12px;line-height:1.5;background:var(--muted-bg);bor
940
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}}
941
966
  `;
942
967
 
943
- export function renderHtml({ title, generatedAt, findings, explained, runs, plan = null, lang = "en", branding = true, against = null, planCheck = null }) {
968
+ export function renderHtml({ title, generatedAt, findings, explained, runs, plan = null, lang = "en", branding = true, against = null, planCheck = null, changes = null }) {
944
969
  const m = messages(lang);
945
970
  const rows = attachExplanations(findings, explained);
946
971
  const counts = summarize(rows);
@@ -967,7 +992,7 @@ ${navCss(runs)}</style></head><body>
967
992
  <nav class="toptabs" aria-label="${m.ui.sections}"><div class="wrap">
968
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>
969
994
  <main class="wrap">
970
- <section class="tabpanel" id="overview" aria-label="${m.ui.tabOverview}">${planCheck ? planVsSiteCard(m, planCheck) : ""}${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>
971
996
  <section class="tabpanel" id="findings" aria-label="${m.ui.findings}">${findingsTab(m, runs, rows)}</section>
972
997
  <section class="tabpanel" id="journeys" aria-label="${m.ui.tabJourneys}">${journeysTab(m, lang, findings, runs, flagged)}</section>
973
998
  <section class="tabpanel" id="scope" aria-label="${m.ui.tabScope}"><div class="stack">${coverageCard(m, plan, runs)}${providersCard(m, runs)}</div></section>