@topy-ai/maggie 0.7.31 → 0.7.33

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.
Files changed (30) hide show
  1. package/README-zh-TW.md +2 -2
  2. package/README.md +101 -19
  3. package/bin/maggie.js +6 -0
  4. package/bundled-references/google-integrations-runbook.md +26 -0
  5. package/bundled-skills/maggie-blog/SKILL.md +18 -1
  6. package/bundled-skills/maggie-dash/SKILL.md +18 -3
  7. package/bundled-skills/maggie-deployment/SKILL.md +72 -0
  8. package/bundled-skills/maggie-deployment/references/vps.md +26 -0
  9. package/bundled-skills/maggie-design/SKILL.md +16 -0
  10. package/bundled-skills/maggie-feedback/SKILL.md +11 -2
  11. package/bundled-skills/maggie-seo-geo/SKILL.md +22 -1
  12. package/bundled-tools/clis/maggie.py +12 -0
  13. package/bundled-tools/clis/maggie_analytics.py +34 -0
  14. package/bundled-tools/clis/maggie_blog.py +13 -0
  15. package/bundled-tools/clis/maggie_dash.py +17 -0
  16. package/bundled-tools/clis/maggie_deployment.py +91 -7
  17. package/bundled-tools/clis/maggie_feedback.py +18 -4
  18. package/bundled-tools/clis/maggie_icon_release_gate.py +79 -0
  19. package/bundled-tools/clis/maggie_migration.py +71 -0
  20. package/bundled-tools/clis/maggie_release_manifest.py +81 -0
  21. package/bundled-tools/clis/maggie_runtime_preflight.py +76 -0
  22. package/bundled-tools/clis/site_audit.py +78 -5
  23. package/bundled-tools/integrations/analytics.md +20 -0
  24. package/bundled-tools/runtime/maggie_blog_publish.py +38 -0
  25. package/bundled-tools/runtime/maggie_dash_panels.py +111 -0
  26. package/bundled-tools/runtime/maggie_quality.py +1 -1
  27. package/bundled-tools/runtime/maggie_sections.py +39 -1
  28. package/bundled-tools/runtime/site_baseline.py +23 -2
  29. package/package.json +1 -1
  30. package/references/google-integrations-runbook.md +26 -0
@@ -9,7 +9,7 @@ import json
9
9
  import re
10
10
  import sys
11
11
  from pathlib import Path
12
- from urllib.parse import urljoin, urlparse
12
+ from urllib.parse import urljoin, urlparse, urlunparse
13
13
  from html.parser import HTMLParser
14
14
  from urllib.request import Request, urlopen
15
15
 
@@ -233,7 +233,16 @@ def hreflang_check(url: str, page: PageParser, expected: set[str]) -> dict:
233
233
  return {"ok": not missing and not reciprocal_errors, "missing": missing, "reciprocal_errors": reciprocal_errors, "links": links}
234
234
 
235
235
 
236
- def sitemap_urls(base: str) -> tuple[list[str], list[dict[str, str]]]:
236
+ def sitemap_target(base: str, value: str, *, preserve_origin: bool = False) -> str:
237
+ """Resolve a sitemap loc while keeping crawl traffic on the requested origin."""
238
+ parsed = urlparse(value)
239
+ if preserve_origin or not parsed.scheme or not parsed.netloc:
240
+ return urljoin(base + "/", value)
241
+ requested = urlparse(base)
242
+ return urlunparse((requested.scheme, requested.netloc, parsed.path or "/", "", parsed.query, ""))
243
+
244
+
245
+ def sitemap_urls(base: str, *, preserve_origin: bool = False) -> tuple[list[str], list[dict[str, str]]]:
237
246
  """Resolve a sitemap index and return page URLs plus raw loc violations."""
238
247
  pending = [urljoin(base + "/", "sitemap.xml")]
239
248
  pages = []
@@ -249,7 +258,7 @@ def sitemap_urls(base: str) -> tuple[list[str], list[dict[str, str]]]:
249
258
  for value in raw_locs:
250
259
  if not re.match(r"^https?://[^\s]+$", value, re.I):
251
260
  violations.append({"sitemap": sitemap_url, "loc": value, "reason": "sitemap loc must be an absolute HTTP(S) URL"})
252
- locs = [urljoin(base + "/", value) for value in raw_locs]
261
+ locs = [sitemap_target(base, value, preserve_origin=preserve_origin) for value in raw_locs]
253
262
  if re.search(r"<sitemapindex\b", body, re.I):
254
263
  pending.extend(locs)
255
264
  else:
@@ -280,9 +289,13 @@ def main() -> int:
280
289
  parser.add_argument("--check-translation-completeness", action="store_true")
281
290
  parser.add_argument("--access-log", type=Path, help="optional local access log for crawler-request diagnosis")
282
291
  parser.add_argument("--require-sitemap-request", action="store_true", help="fail unless the supplied access log contains a sitemap request")
292
+ parser.add_argument("--preserve-sitemap-origin", action="store_true", help="opt in to crawling absolute sitemap URLs on their declared origins")
283
293
  baseline_args = parser.add_mutually_exclusive_group()
284
294
  baseline_args.add_argument("--save-baseline", type=Path, help="create a new reviewed contract from a passing complete crawl")
285
295
  baseline_args.add_argument("--baseline", type=Path, help="fail on differences from a reviewed contract")
296
+ parser.add_argument("--recapture-baseline", type=Path, help="write a reviewed versioned baseline after reviewing every drift")
297
+ parser.add_argument("--baseline-id", help="required stable ID for --recapture-baseline")
298
+ parser.add_argument("--reason", action="append", default=[], metavar="URL=REASON", help="reviewer-approved reason for one drifting URL; repeat for every drift")
286
299
  parser.add_argument("--reviewer", help="required for --save-baseline")
287
300
  args = parser.parse_args()
288
301
  if args.max_pages < 1:
@@ -291,6 +304,17 @@ def main() -> int:
291
304
  parser.error("baseline operations require --crawl")
292
305
  if args.save_baseline and not (args.reviewer or "").strip():
293
306
  parser.error("save-baseline requires --reviewer")
307
+ if args.recapture_baseline:
308
+ if not args.baseline:
309
+ parser.error("--recapture-baseline requires --baseline")
310
+ if not args.crawl:
311
+ parser.error("baseline recapture requires --crawl")
312
+ if not (args.reviewer or "").strip():
313
+ parser.error("baseline recapture requires --reviewer")
314
+ if not args.baseline_id or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,79}", args.baseline_id):
315
+ parser.error("baseline recapture requires a safe --baseline-id")
316
+ if args.save_baseline and args.recapture_baseline:
317
+ parser.error("--save-baseline cannot be combined with --recapture-baseline")
294
318
  try:
295
319
  expected_languages = language_set(args.languages)
296
320
  markets = {item.strip() for item in (args.markets or "").split(",") if item.strip()}
@@ -349,7 +373,7 @@ def main() -> int:
349
373
  crawl = {"enabled": args.crawl, "passed": True, "pages": []}
350
374
  if args.crawl:
351
375
  try:
352
- urls, sitemap_violations = sitemap_urls(base)
376
+ urls, sitemap_violations = sitemap_urls(base, preserve_origin=args.preserve_sitemap_origin)
353
377
  crawl["discovered_url_count"] = len(urls)
354
378
  crawl["complete"] = len(urls) <= args.max_pages and not sitemap_violations
355
379
  urls = urls[: args.max_pages]
@@ -371,6 +395,8 @@ def main() -> int:
371
395
  page_checks = {"url": page_url, "passed": False, "error": type(exc).__name__}
372
396
  crawl["pages"].append(page_checks)
373
397
  crawl["sitemap_loc_violations"] = sitemap_violations
398
+ crawl["requestedOrigin"] = base
399
+ crawl["sitemapOriginPolicy"] = "declared-origin" if args.preserve_sitemap_origin else "requested-origin"
374
400
  crawl["passed"] = crawl["complete"] and bool(urls) and len(crawl["pages"]) == len(urls) and all(item["passed"] for item in crawl["pages"])
375
401
  crawl["url_count"] = len(urls)
376
402
  except Exception as exc:
@@ -380,9 +406,56 @@ def main() -> int:
380
406
  result["evidence"] = {"structural": "captured", "rendered": "not_run", "behavioral": "not_run"}
381
407
  crawl["summary"] = summarize_crawl(crawl["pages"])
382
408
  try:
409
+ current_passed = result["passed"]
383
410
  if args.baseline:
384
- result["baseline"] = site_baseline.compare(json.loads(args.baseline.read_text()), result)
411
+ previous_baseline = json.loads(args.baseline.read_text())
412
+ result["baseline"] = site_baseline.compare(previous_baseline, result)
385
413
  result["passed"] = result["passed"] and result["baseline"]["passed"]
414
+ if args.recapture_baseline:
415
+ comparison = result["baseline"]
416
+ drift_urls = set(comparison.get("added", [])) | set(comparison.get("removed", []))
417
+ drift_urls |= {item["url"] for item in comparison.get("changed", []) if isinstance(item, dict) and item.get("url")}
418
+ drift_urls |= {item["url"] for item in comparison.get("contentChanged", []) if isinstance(item, dict) and item.get("url")}
419
+ reasons: dict[str, str] = {}
420
+ for value in args.reason:
421
+ url, separator, reason = value.partition("=")
422
+ if not separator or not url.strip() or not reason.strip():
423
+ raise ValueError("each --reason must use URL=REASON")
424
+ url = url.strip()
425
+ if url in reasons:
426
+ raise ValueError("duplicate baseline change reason")
427
+ if url not in drift_urls:
428
+ raise ValueError("baseline change reason references a URL without a detected drift")
429
+ reasons[url] = reason.strip()
430
+ missing_reasons = sorted(drift_urls - reasons.keys())
431
+ if missing_reasons:
432
+ raise ValueError("every drifting URL requires a reviewer reason")
433
+ if not current_passed:
434
+ raise ValueError("baseline recapture requires a passing current crawl")
435
+ recapture_report = dict(result)
436
+ # The comparison intentionally makes the aggregate result
437
+ # fail while the current crawl itself is still valid. Snapshot
438
+ # validates the latter, not whether the old baseline drifted.
439
+ recapture_report["passed"] = current_passed
440
+ recaptured = site_baseline.snapshot(
441
+ recapture_report,
442
+ args.reviewer,
443
+ baseline_id=args.baseline_id,
444
+ supersedes=previous_baseline.get("baselineId") or site_baseline.baseline_fingerprint(previous_baseline),
445
+ change_reasons=reasons,
446
+ )
447
+ site_baseline.save(args.recapture_baseline, recaptured)
448
+ result["baselineRecapture"] = {
449
+ "passed": True,
450
+ "saved": str(args.recapture_baseline),
451
+ "baselineId": args.baseline_id,
452
+ "supersedes": recaptured["supersedes"],
453
+ "reasonCount": len(reasons),
454
+ }
455
+ # A reviewed recapture resolves the old-baseline diff; the
456
+ # command's exit status should reflect the current crawl and
457
+ # the completed review gate.
458
+ result["passed"] = current_passed
386
459
  if args.save_baseline:
387
460
  site_baseline.save(args.save_baseline, site_baseline.snapshot(result, args.reviewer))
388
461
  result["baseline"] = {"passed": True, "saved": str(args.save_baseline)}
@@ -25,6 +25,26 @@ booking click is only an outbound intent signal; it is not a confirmed booking
25
25
  or revenue event. Add conversion events only when the host project defines a
26
26
  verified provider callback or reconciliation source.
27
27
 
28
+ ## Consent ordering and first-party scope
29
+
30
+ Consent Mode is an ordering contract, not only a boolean configuration:
31
+
32
+ 1. Push the default consent state inline into the data layer before loading the
33
+ tag manager/container snippet.
34
+ 2. Let vendor tags follow Consent Mode. Do not remove the default data-layer
35
+ event merely because analytics is disabled before consent.
36
+ 3. Stop first-party analytics storage when consent is denied, or declare and
37
+ evidence an explicit degraded mode. Do not silently keep identifiers.
38
+ 4. Treat an intentional first-party form submission as a separate privacy
39
+ decision. If it is recorded without analytics consent, declare
40
+ `intentionalFormMode: "record"` in the contract and document the lawful
41
+ basis and retention policy in the host project; otherwise use `"omit"`.
42
+
43
+ The release contract must declare `defaultBeforeContainer`,
44
+ `firstPartyStorageMode` (`stop` or `degrade`), and `intentionalFormMode`
45
+ (`record` or `omit`). Browser evidence must prove the ordering and the
46
+ first-party behavior; a missing observation is `unknown`, never `false`.
47
+
28
48
  ## GSC
29
49
 
30
50
  Prefer DNS verification for production. If HTML verification is needed, use a
@@ -0,0 +1,38 @@
1
+ """Validate an explicit, evidence-backed automatic blog publication opt-in."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ REQUIRED = ("localeVariants", "featureImage", "seo", "sitemap", "indexingNotification", "audit")
9
+
10
+
11
+ def validate_auto_publish(value: object) -> dict[str, Any]:
12
+ errors: list[str] = []
13
+ if not isinstance(value, dict):
14
+ return {"schemaVersion": "maggie-blog-auto-publish-result.v1", "passed": False, "errors": ["evidence must be an object"], "mutation": "not executed"}
15
+ if value.get("schemaVersion") != "maggie-blog-auto-publish.v1":
16
+ errors.append("schemaVersion must be maggie-blog-auto-publish.v1")
17
+ if value.get("optIn") is not True:
18
+ errors.append("optIn must be true for automatic publishing")
19
+ if value.get("requireReview") is not False or value.get("reviewDisabledExplicitly") is not True:
20
+ errors.append("automatic publishing requires explicit review disablement")
21
+ checks: dict[str, bool] = {}
22
+ for name in REQUIRED:
23
+ item = value.get(name)
24
+ passed = isinstance(item, dict) and item.get("passed") is True
25
+ checks[name] = passed
26
+ if not passed:
27
+ errors.append(f"{name}.passed must be true")
28
+ return {
29
+ "schemaVersion": "maggie-blog-auto-publish-result.v1",
30
+ "passed": not errors,
31
+ "errors": sorted(set(errors)),
32
+ "optIn": value.get("optIn") is True,
33
+ "requireReview": value.get("requireReview"),
34
+ "reviewDisabledExplicitly": value.get("reviewDisabledExplicitly") is True,
35
+ "checks": checks,
36
+ "mutation": "not executed",
37
+ "nextAction": "host may call its explicit publish path only after this gate passes" if not errors else "keep content in human review",
38
+ }
@@ -0,0 +1,111 @@
1
+ """Validate source and freshness evidence for independent dashboard panels."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import Any
7
+
8
+
9
+ SCHEMA = "maggiedash-measurement-panels.v1"
10
+ MODES = {"live", "snapshot"}
11
+ STATUSES = {"pass", "stale", "error"}
12
+
13
+
14
+ def _timestamp(value: object) -> bool:
15
+ if not isinstance(value, str) or not value.strip():
16
+ return False
17
+ try:
18
+ datetime.fromisoformat(value.replace("Z", "+00:00"))
19
+ except ValueError:
20
+ return False
21
+ return True
22
+
23
+
24
+ def validate_panel_report(value: object) -> dict[str, Any]:
25
+ errors: list[str] = []
26
+ warnings: list[str] = []
27
+ normalized: list[dict[str, Any]] = []
28
+ if not isinstance(value, dict):
29
+ return {"schemaVersion": SCHEMA, "passed": False, "errors": ["report must be an object"], "panels": [], "failedPanels": [], "warnings": []}
30
+ if value.get("schemaVersion") != SCHEMA:
31
+ errors.append(f"schemaVersion must be {SCHEMA}")
32
+ panels = value.get("panels")
33
+ if not isinstance(panels, list) or not panels:
34
+ errors.append("panels must be a non-empty list")
35
+ panels = []
36
+ seen: set[str] = set()
37
+ failed: list[str] = []
38
+ for index, panel in enumerate(panels):
39
+ at = f"panels[{index}]"
40
+ if not isinstance(panel, dict):
41
+ errors.append(f"{at} must be an object")
42
+ continue
43
+ panel_id = panel.get("id")
44
+ if not isinstance(panel_id, str) or not panel_id.strip():
45
+ errors.append(f"{at}.id must be a non-empty string")
46
+ panel_id = f"panel-{index}"
47
+ if panel_id in seen:
48
+ errors.append(f"{at}.id is duplicated")
49
+ seen.add(panel_id)
50
+ source = panel.get("source")
51
+ source_name = ""
52
+ if isinstance(source, dict):
53
+ source_name = str(source.get("name") or source.get("system") or "").strip()
54
+ elif isinstance(source, str):
55
+ source_name = source.strip()
56
+ if not source_name:
57
+ errors.append(f"{at}.source must identify a data source")
58
+ mode = panel.get("mode")
59
+ if mode not in MODES:
60
+ errors.append(f"{at}.mode must be live or snapshot")
61
+ observed_at = panel.get("observedAt")
62
+ if not _timestamp(observed_at):
63
+ errors.append(f"{at}.observedAt must be an ISO-8601 timestamp")
64
+ max_age = panel.get("maxAgeSeconds")
65
+ if not isinstance(max_age, int) or isinstance(max_age, bool) or max_age < 1:
66
+ errors.append(f"{at}.maxAgeSeconds must be a positive integer")
67
+ max_age = 0
68
+ age = panel.get("ageSeconds")
69
+ if not isinstance(age, int) or isinstance(age, bool) or age < 0:
70
+ errors.append(f"{at}.ageSeconds must be a non-negative integer")
71
+ age = 0
72
+ status = panel.get("status")
73
+ if status not in STATUSES:
74
+ errors.append(f"{at}.status must be pass, stale, or error")
75
+ status = "error"
76
+ warning = str(panel.get("warning") or "").strip()
77
+ error = panel.get("error")
78
+ if status == "stale":
79
+ if not warning:
80
+ errors.append(f"{at}.warning is required for stale panels")
81
+ warnings.append(panel_id)
82
+ if status == "error":
83
+ failed.append(panel_id)
84
+ if not isinstance(error, dict) or not str(error.get("code") or "").strip() or not str(error.get("remediation") or "").strip():
85
+ errors.append(f"{at}.error requires a code and remediation")
86
+ if status == "stale" or status == "error":
87
+ failed.append(panel_id)
88
+ if status == "pass" and age > max_age:
89
+ errors.append(f"{at}.status cannot be pass when ageSeconds exceeds maxAgeSeconds")
90
+ failed.append(panel_id)
91
+ normalized.append({
92
+ "id": panel_id,
93
+ "source": source_name,
94
+ "mode": mode,
95
+ "observedAt": observed_at,
96
+ "maxAgeSeconds": max_age,
97
+ "ageSeconds": age,
98
+ "status": status,
99
+ "warning": bool(warning),
100
+ "errorCode": str(error.get("code")) if isinstance(error, dict) and error.get("code") else None,
101
+ })
102
+ failed = sorted(set(failed))
103
+ return {
104
+ "schemaVersion": "maggiedash-measurement-panels-result.v1",
105
+ "passed": not errors and not failed and bool(normalized),
106
+ "errors": sorted(set(errors)),
107
+ "panels": normalized,
108
+ "failedPanels": failed,
109
+ "warnings": sorted(set(warnings)),
110
+ "mutation": "not executed",
111
+ }
@@ -244,7 +244,7 @@ def validate_reconcile_contract(contract: object) -> dict[str, Any]:
244
244
  if contract.get("writesOnlyWhenChanged") is not True:
245
245
  errors.append("writesOnlyWhenChanged must be true")
246
246
  report = contract.get("report") if isinstance(contract.get("report"), dict) else {}
247
- for key in ("changed", "unchanged"):
247
+ for key in ("changed", "unchanged", "writes"):
248
248
  if report.get(key) is not True:
249
249
  errors.append(f"report.{key} evidence is required")
250
250
  second = contract.get("secondRun") if isinstance(contract.get("secondRun"), dict) else {}
@@ -430,4 +430,42 @@ def reconcile_fields(current: object, desired: object) -> dict[str, Any]:
430
430
  else:
431
431
  result[key] = copy.deepcopy(value)
432
432
  changed.append(str(key))
433
- return {"schemaVersion": RECONCILE_SCHEMA, "passed": True, "changed": changed, "alreadyCorrect": already_correct, "result": result, "convergent": True}
433
+ return {
434
+ "schemaVersion": RECONCILE_SCHEMA,
435
+ "passed": True,
436
+ "changed": changed,
437
+ "alreadyCorrect": already_correct,
438
+ "writeCount": len(changed),
439
+ "unchangedCount": len(already_correct),
440
+ "writeOutcome": {"wrote": bool(changed), "reason": "changed-fields" if changed else "already-correct"},
441
+ "result": result,
442
+ "convergent": True,
443
+ }
444
+
445
+
446
+ def summarize_write_outcomes(outcomes: object) -> dict[str, Any]:
447
+ """Summarize step-level compare-and-set outcomes by actual writes.
448
+
449
+ A step is not a write. Callers must report a boolean ``wrote`` outcome so
450
+ a successful no-op cannot inflate release or migration write counts.
451
+ """
452
+ if not isinstance(outcomes, list) or not outcomes:
453
+ return {"schemaVersion": "maggiedash-write-outcomes.v1", "passed": False, "errors": ["outcomes must be a non-empty list"]}
454
+ errors: list[str] = []
455
+ normalized: list[dict[str, Any]] = []
456
+ for index, outcome in enumerate(outcomes):
457
+ if not isinstance(outcome, dict) or not isinstance(outcome.get("wrote"), bool):
458
+ errors.append(f"outcomes[{index}].wrote must be boolean")
459
+ continue
460
+ normalized.append({"step": str(outcome.get("step") or index), "wrote": outcome["wrote"]})
461
+ writes = sum(1 for item in normalized if item["wrote"])
462
+ return {
463
+ "schemaVersion": "maggiedash-write-outcomes.v1",
464
+ "passed": not errors,
465
+ "errors": errors,
466
+ "steps": len(normalized),
467
+ "writes": writes,
468
+ "unchanged": len(normalized) - writes,
469
+ "outcomes": normalized,
470
+ "convergent": not errors,
471
+ }
@@ -2,12 +2,26 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import json
5
+ import hashlib
5
6
  from datetime import datetime, timezone
6
7
  from pathlib import Path
7
8
  from urllib.parse import urlparse
8
9
 
9
10
 
10
- def snapshot(report: dict, reviewer: str) -> dict:
11
+ def baseline_fingerprint(baseline: dict) -> str:
12
+ """Return a stable identity for legacy baselines without baselineId."""
13
+ encoded = json.dumps(baseline, sort_keys=True, ensure_ascii=False, separators=(",", ":")).encode()
14
+ return "sha256:" + hashlib.sha256(encoded).hexdigest()[:16]
15
+
16
+
17
+ def snapshot(
18
+ report: dict,
19
+ reviewer: str,
20
+ *,
21
+ baseline_id: str | None = None,
22
+ supersedes: str | None = None,
23
+ change_reasons: dict[str, str] | None = None,
24
+ ) -> dict:
11
25
  if not reviewer.strip():
12
26
  raise ValueError("baseline requires a reviewer")
13
27
  crawl = report.get("crawl", {})
@@ -32,10 +46,17 @@ def snapshot(report: dict, reviewer: str) -> dict:
32
46
  content[page["url"]] = page["contentContract"]
33
47
  if not contracts:
34
48
  raise ValueError("baseline has no non-query page contracts")
35
- return {"schemaVersion": "maggie-site-baseline.v1", "url": report["url"],
49
+ result = {"schemaVersion": "maggie-site-baseline.v1", "url": report["url"],
36
50
  "reviewer": reviewer.strip(), "createdAt": datetime.now(timezone.utc).isoformat(),
37
51
  "pages": contracts, "content": content,
38
52
  "excludedQueryUrls": sorted(excluded_query_urls)}
53
+ if baseline_id:
54
+ result["baselineId"] = baseline_id
55
+ if supersedes:
56
+ result["supersedes"] = supersedes
57
+ if change_reasons:
58
+ result["approvedChangeReasons"] = {key: change_reasons[key] for key in sorted(change_reasons)}
59
+ return result
39
60
 
40
61
 
41
62
  def compare(baseline: dict, report: dict) -> dict:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topy-ai/maggie",
3
- "version": "0.7.31",
3
+ "version": "0.7.33",
4
4
  "description": "Install and manage Maggie Skills for AI coding agents",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -52,6 +52,32 @@ The validator accepts the complete provider allowlist, but a capability is
52
52
  verified only when its corresponding required scope is present. Scope presence
53
53
  alone never proves product access.
54
54
 
55
+ ### GTM operation scope matrix
56
+
57
+ Request the union of the rows needed by one operation; do not use a broad
58
+ scope merely because a later operation might need it:
59
+
60
+ | GTM operation | Minimum OAuth scope |
61
+ |---|---|
62
+ | List/read account, container, tag, trigger, or variable | `https://www.googleapis.com/auth/tagmanager.readonly` |
63
+ | Edit container configuration | `https://www.googleapis.com/auth/tagmanager.edit.containers` |
64
+ | Create or update a container version | `https://www.googleapis.com/auth/tagmanager.edit.containerversions` |
65
+ | Publish a container version | `https://www.googleapis.com/auth/tagmanager.publish` |
66
+
67
+ An operation that edits a container and creates a version must request both
68
+ edit scopes; publishing additionally requires the publish scope. Record the
69
+ operation name and exact scope union in the capability evidence so a successful
70
+ read cannot be mistaken for edit or publish access.
71
+
72
+ ### Missing fields are unknown
73
+
74
+ Google APIs can omit fields, especially when a proto3 field is absent from the
75
+ response or was not selected by the request. An omitted boolean is not
76
+ `false`, and an absent schema field is not evidence that the capability is
77
+ disabled. Reports must use `unknown`/`not_observed` in the provider adapter's
78
+ evidence and obtain a corroborating read or time-series observation before
79
+ making a negative claim. Never silently coerce a missing field to `false`.
80
+
55
81
  ## Provider setup and read-only preflight
56
82
 
57
83
  1. Select one provider and one resource. Enable only that provider API in the