@topy-ai/maggie 0.6.4 → 0.6.7

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/README.md CHANGED
@@ -154,11 +154,33 @@ The canary report requires a screenshot and zero console errors, missing
154
154
  assets, and visual placeholders for every route. It records safe cache headers
155
155
  only and never stores response bodies, cookies, or credentials.
156
156
 
157
+ ### Localization and analytics release gates
158
+
159
+ Extract real page strings before creating a localization job, then require a
160
+ clean rendered report before approval:
161
+
162
+ ```bash
163
+ maggie localization extract --project . --source-dir src \
164
+ --output .maggie/localization/source.json
165
+ maggie localization plan --project . --content content.json \
166
+ --source .maggie/localization/source.json --source-lang en \
167
+ --target-lang zh-Hant --locale zh-Hant-TW --market global --operation translate
168
+ maggie localization validate .maggie/localization/<job>.json \
169
+ --source .maggie/localization/source.json \
170
+ --render-report .maggie/localization/rendered.json
171
+ ```
172
+
173
+ For analytics, the opt-in end-to-end gate combines the event contract,
174
+ consent/network evidence, provider readback, browser rendering, referral
175
+ redaction, and production smoke report. It fails closed on missing or unsafe
176
+ evidence; see the repository analytics integration guide for the versioned
177
+ artifact schemas.
178
+
157
179
  Recommended upgrade sequence for the current release:
158
180
 
159
181
  ```bash
160
- npx @topy-ai/maggie@0.6.4 update --project . --force
161
- npx @topy-ai/maggie@0.6.4 cleanup --project .
182
+ npx @topy-ai/maggie@0.6.7 update --project . --force
183
+ npx @topy-ai/maggie@0.6.7 cleanup --project .
162
184
  ```
163
185
 
164
186
  ## MaggieDash lifecycle
package/README.zh-TW.md CHANGED
@@ -8,7 +8,7 @@ Codex、Claude Code 與相容的 coding agents。
8
8
  ## 安裝
9
9
 
10
10
  ```bash
11
- npx @topy-ai/maggie@0.6.4 init --agent all
11
+ npx @topy-ai/maggie@0.6.7 init --agent all
12
12
  npx @topy-ai/maggie doctor --project .
13
13
  ```
14
14
 
@@ -2,11 +2,17 @@
2
2
  name: maggie-content-localization
3
3
  description: Manage translation, polish, rewrite, market localization, review, stale detection, and publishing for pages, guides, posts, services, products, and categories.
4
4
  metadata:
5
- version: 1.0.0
5
+ version: 1.2.0
6
6
  ---
7
7
 
8
8
  # Maggie Content Localization
9
9
 
10
+ Localization job filenames are derived from content identity, but content IDs
11
+ are data, not paths. The CLI sanitizes separators and adds a short identity
12
+ hash when needed, so IDs such as `site.example/static-pages` always produce a
13
+ single addressable file under `.maggie/localization/`. Never construct a job
14
+ path by interpolating an unsanitized content ID.
15
+
10
16
  Use the shared [Content Localization Contract](../../references/content-localization-contract.md).
11
17
  Use the shared [memory hook](../../references/memory-hook.md) to read prior
12
18
  locale preferences and record confirmed terminology or translation failures.
@@ -34,8 +40,16 @@ advisory and never bypasses validation or owner/Admin approval.
34
40
  python3 tools/clis/maggie_localization.py plan \
35
41
  --project . --content content.json --source-lang en --target-lang zh-Hans \
36
42
  --market uk --operation translate --locale zh-Hans-GB
43
+ python3 tools/clis/maggie_localization.py extract --project . --source-dir src \
44
+ --routes-file docs/routes.tsv --output .maggie/localization/source.json
45
+ python3 tools/clis/maggie_localization.py plan --project . --content content.json \
46
+ --source .maggie/localization/source.json --source-lang en --target-lang zh-Hans \
47
+ --market uk --operation translate --locale zh-Hans-GB
37
48
  python3 tools/clis/maggie_localization.py preview .maggie/localization/<job>.json
38
49
  python3 tools/clis/maggie_localization.py validate .maggie/localization/<job>.json
50
+ python3 tools/clis/maggie_localization.py validate .maggie/localization/<job>.json \
51
+ --source .maggie/localization/source.json \
52
+ --render-report .maggie/localization/rendered.json
39
53
  python3 tools/clis/maggie_localization.py review .maggie/localization/<job>.json \
40
54
  --reviewer owner@example.com --decision approve
41
55
  python3 tools/clis/maggie_localization.py publish .maggie/localization/<job>.json --confirm
@@ -53,3 +67,12 @@ Never change price, currency, rating, provider facts, booking URLs, legal or
53
67
  health claims, slug, canonical ownership, or content identity during a copy
54
68
  operation without structured approval. Incomplete or fallback output must be
55
69
  `isIndexable: false`. High-risk content requires Admin approval before publish.
70
+
71
+ The extractor is read-only and uses a conservative markup adapter for Astro,
72
+ HTML, JSX/TSX, Vue, and Svelte. It records stable string IDs, source hashes,
73
+ line numbers, routes, and attribute/text-node kinds while skipping scripts,
74
+ styles, SVG, and dynamic expressions. Supplying source and render reports to
75
+ `validate` makes the checks fail closed; the prepared `content.json` flow
76
+ remains backward-compatible without those artifacts. See
77
+ [`docs/localization-extraction-render-prd.md`](../../docs/localization-extraction-render-prd.md)
78
+ for the artifact contract and limitations.
@@ -7,6 +7,7 @@ import argparse
7
7
  import json
8
8
  import os
9
9
  import re
10
+ import subprocess
10
11
  from pathlib import Path
11
12
  from urllib.parse import urlsplit
12
13
 
@@ -14,6 +15,7 @@ from urllib.parse import urlsplit
14
15
  GA4_ID = re.compile(r"^G-[A-Z0-9]+$", re.I)
15
16
  ALLOWED_EVENTS = {"page_view", "blog_post_view", "booking_click"}
16
17
  FORBIDDEN_EVENTS = re.compile(r"(?:purchase|revenue|confirmed[_-]?booking|completed[_-]?treatment|transaction_id)", re.I)
18
+ SENSITIVE_FIELDS = re.compile(r"(?:email|phone|name|address|ip|user[_-]?id|client[_-]?id|query|referrer)", re.I)
17
19
 
18
20
 
19
21
  def parse_env(path: Path) -> dict[str, str]:
@@ -33,13 +35,94 @@ def check(name: str, passed: bool, evidence: str, checks: dict) -> None:
33
35
  checks[name] = {"passed": bool(passed), "evidence": evidence}
34
36
 
35
37
 
38
+ def load_json(path: Path, label: str) -> tuple[dict | None, list[str]]:
39
+ try:
40
+ value = json.loads(path.read_text(encoding="utf-8"))
41
+ except (OSError, json.JSONDecodeError) as exc:
42
+ return None, [f"{label}: {exc}"]
43
+ if not isinstance(value, dict):
44
+ return None, [f"{label}: JSON root must be an object"]
45
+ return value, []
46
+
47
+
48
+ def release_gate(args: argparse.Namespace) -> int:
49
+ project = Path(args.project).resolve()
50
+ checks: dict[str, dict] = {}
51
+ errors: list[str] = []
52
+ contract, contract_errors = load_json(Path(args.contract).resolve(), "contract")
53
+ errors.extend(contract_errors)
54
+ if contract:
55
+ check("contract-schema", contract.get("schemaVersion") == "maggie-analytics-contract.v1", "versioned analytics contract", checks)
56
+ events = contract.get("events")
57
+ valid_events = isinstance(events, list) and bool(events) and all(isinstance(event, str) and re.fullmatch(r"[a-z][a-z0-9_]{1,63}", event) and not FORBIDDEN_EVENTS.search(event) for event in events)
58
+ check("event-contract", valid_events, "event names are lowercase, bounded, and do not claim revenue", checks)
59
+ destinations = contract.get("destinations")
60
+ check("destination-contract", isinstance(destinations, list) and bool(destinations) and all(isinstance(item, str) and item in {"ga4", "gtm", "gsc"} for item in destinations), "destinations are from the supported provider set", checks)
61
+ fields = contract.get("allowedParameters", [])
62
+ check("parameter-privacy", isinstance(fields, list) and all(isinstance(field, str) and not SENSITIVE_FIELDS.search(field) for field in fields), "allowed event parameters exclude identity and raw acquisition signals", checks)
63
+ if isinstance(contract.get("referralSources"), list):
64
+ check("referral-contract", set(contract["referralSources"]).issubset({"search", "partner", "social", "email", "direct", "unknown"}), "referral categories use the redacted taxonomy", checks)
65
+ else:
66
+ check("referral-contract", False, "contract must declare referralSources", checks)
67
+ browser, browser_errors = load_json(Path(args.render_report).resolve(), "render-report")
68
+ errors.extend(browser_errors)
69
+ network, network_errors = load_json(Path(args.network_report).resolve(), "network-report")
70
+ errors.extend(network_errors)
71
+ provider, provider_errors = load_json(Path(args.provider_report).resolve(), "provider-report")
72
+ errors.extend(provider_errors)
73
+ smoke, smoke_errors = load_json(Path(args.smoke_report).resolve(), "smoke-report")
74
+ errors.extend(smoke_errors)
75
+ if browser:
76
+ check("browser-schema", browser.get("schemaVersion") == "maggie-analytics-browser.v1", "versioned browser evidence", checks)
77
+ check("browser-render", browser.get("passed") is True and isinstance(browser.get("routes"), list) and bool(browser["routes"]), "routes rendered without a browser failure", checks)
78
+ check("browser-console", isinstance(browser.get("consoleErrors"), list) and not browser["consoleErrors"], "no browser console errors", checks)
79
+ if network:
80
+ pre = network.get("preConsentRequests")
81
+ check("pre-consent-network", isinstance(pre, list) and not pre, "no analytics requests before consent", checks)
82
+ check("network-redaction", network.get("redacted") is True, "network evidence is explicitly redacted", checks)
83
+ check("consented-network", isinstance(network.get("consentedRequests"), list), "consented network evidence is present", checks)
84
+ if provider:
85
+ check("provider-read-only", provider.get("schemaVersion") == "maggie-analytics-provider.v1" and provider.get("readOnly") is True and provider.get("authorized") is True and provider.get("passed") is True, "provider inspection is authorized, read-only, and passed", checks)
86
+ if smoke:
87
+ check("production-smoke", smoke.get("schemaVersion") == "maggie-analytics-smoke.v1" and smoke.get("passed") is True and bool(smoke.get("route")) and bool(smoke.get("release")) and bool(smoke.get("cache")), "public route, release, and cache smoke evidence", checks)
88
+ if contract and isinstance(contract.get("events"), list) and browser and isinstance(browser.get("consentedEvents"), list):
89
+ check("event-runtime-coverage", set(contract["events"]).issubset(set(browser["consentedEvents"])), "every contract event is present in consented browser evidence", checks)
90
+ failed = [name for name, item in checks.items() if not item["passed"]]
91
+ errors.extend(failed)
92
+ report = {
93
+ "schemaVersion": "maggie-analytics-release-gate.v1",
94
+ "environment": args.environment,
95
+ "project": str(project),
96
+ "checks": checks,
97
+ "passed": not errors,
98
+ "publish": False,
99
+ "mutation": "not executed",
100
+ }
101
+ output = project / ".maggie" / "analytics-release-gate.json"
102
+ output.parent.mkdir(parents=True, exist_ok=True)
103
+ output.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
104
+ print(json.dumps({"status": "passed" if report["passed"] else "failed", "environment": args.environment, "report": str(output), "failedChecks": failed, "publish": False}, indent=2))
105
+ return 0 if report["passed"] else 1
106
+
107
+
36
108
  def main() -> int:
37
109
  parser = argparse.ArgumentParser()
110
+ parser.add_argument("command", nargs="?", choices=("release-gate",))
38
111
  parser.add_argument("--project", default=".")
39
112
  parser.add_argument("--environment", choices=("development", "staging", "production"), default="staging")
40
113
  parser.add_argument("--env-file", help="optional env file; values are never printed")
41
114
  parser.add_argument("--html", help="optional rendered HTML snapshot to inspect")
115
+ parser.add_argument("--contract", help="versioned analytics contract for release-gate")
116
+ parser.add_argument("--render-report", help="redacted browser/render evidence for release-gate")
117
+ parser.add_argument("--network-report", help="redacted network evidence for release-gate")
118
+ parser.add_argument("--provider-report", help="read-only provider evidence for release-gate")
119
+ parser.add_argument("--smoke-report", help="production smoke evidence for release-gate")
42
120
  args = parser.parse_args()
121
+ if args.command == "release-gate":
122
+ required = ("contract", "render_report", "network_report", "provider_report", "smoke_report")
123
+ if any(not getattr(args, name) for name in required):
124
+ parser.error("release-gate requires --contract, --render-report, --network-report, --provider-report, and --smoke-report")
125
+ return release_gate(args)
43
126
  project = Path(args.project).resolve()
44
127
  env = parse_env(Path(args.env_file).resolve()) if args.env_file else parse_env(project / ".env.example")
45
128
  env.update({key: value for key, value in os.environ.items() if key in {"PUBLIC_GA4_MEASUREMENT_ID", "PUBLIC_ANALYTICS_ENABLED", "PUBLIC_ANALYTICS_CONSENT_REQUIRED", "GSC_SITE_URL", "GSC_VERIFICATION_TOKEN"}})
@@ -4,7 +4,9 @@
4
4
  from __future__ import annotations
5
5
 
6
6
  import argparse
7
+ import hashlib
7
8
  import json
9
+ import re
8
10
  import sys
9
11
  from datetime import datetime, timezone
10
12
  from pathlib import Path
@@ -24,8 +26,144 @@ def load(path: Path) -> dict:
24
26
  return value
25
27
 
26
28
 
29
+ MARKUP_EXTENSIONS = {".astro", ".html", ".htm", ".jsx", ".tsx", ".vue", ".svelte"}
30
+ SKIP_TAGS = re.compile(r"<(?:script|style|noscript|svg)\b[^>]*>.*?</(?:script|style|noscript|svg)\s*>", re.I | re.S)
31
+ TAG_PATTERN = re.compile(r"<[^>]*>")
32
+ ATTR_PATTERN = re.compile(r"\b(?:alt|title|aria-label|placeholder)\s*=\s*([\"'])(.*?)\1", re.I | re.S)
33
+
34
+
35
+ def source_revision(files: list[Path], root: Path) -> str:
36
+ digest = hashlib.sha256()
37
+ for path in sorted(files):
38
+ digest.update(str(path.relative_to(root)).encode("utf-8"))
39
+ digest.update(path.read_bytes())
40
+ return digest.hexdigest()[:16]
41
+
42
+
43
+ def extract_file(path: Path, root: Path, route: str) -> list[dict]:
44
+ raw = path.read_text(encoding="utf-8", errors="replace")
45
+ cleaned = SKIP_TAGS.sub("", raw)
46
+ strings: list[dict] = []
47
+ occupied: list[tuple[int, int]] = []
48
+
49
+ def add(value: str, start: int, kind: str) -> None:
50
+ value = re.sub(r"\s+", " ", value).strip()
51
+ if len(value) < 2 or "{" in value or "}" in value or re.fullmatch(r"[\W_]+", value or ""):
52
+ return
53
+ line = raw.count("\n", 0, start) + 1
54
+ relative = str(path.relative_to(root))
55
+ identity = f"{relative}:{line}:{kind}:{value}"
56
+ strings.append({
57
+ "id": "src-" + hashlib.sha256(identity.encode("utf-8")).hexdigest()[:16],
58
+ "file": relative,
59
+ "line": line,
60
+ "route": route,
61
+ "kind": kind,
62
+ "text": value,
63
+ "sourceHash": hashlib.sha256(value.encode("utf-8")).hexdigest()[:16],
64
+ "translatable": True,
65
+ })
66
+
67
+ for match in TAG_PATTERN.finditer(cleaned):
68
+ tag = match.group(0)
69
+ for attr in ATTR_PATTERN.finditer(tag):
70
+ offset = match.start() + attr.start(2)
71
+ add(attr.group(2), offset, "attribute")
72
+ occupied.append((match.start(), match.end()))
73
+ last = 0
74
+ for start, end in occupied:
75
+ if start > last:
76
+ add(cleaned[last:start], last, "text")
77
+ last = end
78
+ if last < len(cleaned):
79
+ add(cleaned[last:], last, "text")
80
+ return strings
81
+
82
+
83
+ def extract_source(args: argparse.Namespace) -> int:
84
+ project = args.project.resolve()
85
+ source_dir = (project / args.source_dir).resolve()
86
+ if not source_dir.is_dir() or project not in source_dir.parents and source_dir != project:
87
+ raise ValueError("source-dir must be an existing directory inside project")
88
+ routes: dict[str, str] = {}
89
+ if args.routes_file:
90
+ route_path = (project / args.routes_file).resolve()
91
+ if route_path.exists():
92
+ for line in route_path.read_text(encoding="utf-8", errors="replace").splitlines():
93
+ parts = line.split("\t", 1)
94
+ if len(parts) == 2 and parts[0].strip() and parts[1].strip():
95
+ routes[parts[0].strip()] = parts[1].strip()
96
+ files = [path for path in source_dir.rglob("*") if path.is_file() and path.suffix.lower() in MARKUP_EXTENSIONS]
97
+ strings: list[dict] = []
98
+ for path in sorted(files):
99
+ relative = str(path.relative_to(project))
100
+ strings.extend(extract_file(path, project, routes.get(relative, "unknown")))
101
+ revision = source_revision(files, project)
102
+ output = (project / args.output).resolve()
103
+ output.parent.mkdir(parents=True, exist_ok=True)
104
+ artifact = {
105
+ "schemaVersion": "maggie-localization-source.v1",
106
+ "sourceRevision": revision,
107
+ "sourceRoot": str(source_dir.relative_to(project)),
108
+ "files": [str(path.relative_to(project)) for path in files],
109
+ "strings": strings,
110
+ "extraction": {"adapter": "conservative-markup", "readOnly": True, "supportedExtensions": sorted(MARKUP_EXTENSIONS)},
111
+ }
112
+ output.write_text(json.dumps(artifact, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
113
+ print(json.dumps({"status": "passed", "output": str(output), "files": len(files), "strings": len(strings), "sourceRevision": revision}, indent=2))
114
+ return 0
115
+
116
+
117
+ def validate_source(path: Path, expected_revision: str | None) -> list[str]:
118
+ source = load(path)
119
+ errors: list[str] = []
120
+ if source.get("schemaVersion") != "maggie-localization-source.v1":
121
+ errors.append("source artifact schemaVersion is unsupported")
122
+ strings = source.get("strings")
123
+ if not isinstance(strings, list) or not strings:
124
+ errors.append("source artifact has no extracted strings")
125
+ seen: set[str] = set()
126
+ for item in strings if isinstance(strings, list) else []:
127
+ if not isinstance(item, dict) or not item.get("id") or item.get("id") in seen:
128
+ errors.append("source strings must have unique IDs")
129
+ continue
130
+ seen.add(item["id"])
131
+ if any(marker in str(item.get("text", "")) for marker in ("{", "}")):
132
+ errors.append(f"source string contains an expression: {item.get('id')}")
133
+ if not item.get("file") or not item.get("line") or not item.get("sourceHash"):
134
+ errors.append(f"source string metadata is incomplete: {item.get('id')}")
135
+ if expected_revision and source.get("sourceRevision") != expected_revision:
136
+ errors.append("source revision does not match localization job")
137
+ return errors
138
+
139
+
140
+ def validate_render_report(path: Path) -> list[str]:
141
+ report = load(path)
142
+ errors: list[str] = []
143
+ if report.get("schemaVersion") != "maggie-localization-render.v1":
144
+ errors.append("render report schemaVersion is unsupported")
145
+ if report.get("passed") is not True:
146
+ errors.append("render report is not passed")
147
+ for field in ("routes", "consoleErrors", "failedAssets", "untranslatedRequired"):
148
+ if field not in report:
149
+ errors.append(f"render report is missing {field}")
150
+ for field in ("consoleErrors", "failedAssets", "untranslatedRequired"):
151
+ if isinstance(report.get(field), list) and report[field]:
152
+ errors.append(f"render report has {field}")
153
+ return errors
154
+
155
+
156
+ def safe_job_content_token(content_id: str) -> str:
157
+ """Make a content identity safe for a single filename component."""
158
+ token = re.sub(r"[^A-Za-z0-9._-]+", "-", str(content_id)).strip(".-_") or "content"
159
+ if token != content_id:
160
+ token = f"{token}-{hashlib.sha256(str(content_id).encode('utf-8')).hexdigest()[:8]}"
161
+ return token[:180]
162
+
163
+
27
164
  def plan_job(args: argparse.Namespace) -> int:
28
165
  content = load(args.content)
166
+ source_artifact = load(args.source) if getattr(args, "source", None) else None
29
167
  if args.source_lang not in LANGUAGES or args.target_lang not in LANGUAGES:
30
168
  raise ValueError("source-lang and target-lang must be in the supported language registry")
31
169
  if args.market not in MARKETS:
@@ -39,7 +177,7 @@ def plan_job(args: argparse.Namespace) -> int:
39
177
  if not content_id:
40
178
  raise ValueError("contentId is required in content identity")
41
179
  job = {
42
- "jobId": f"localize-{content_id}-{args.target_lang.lower()}-{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}",
180
+ "jobId": f"localize-{safe_job_content_token(content_id)}-{args.target_lang.lower()}-{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}",
43
181
  "content": content,
44
182
  "contentId": content_id,
45
183
  "contentType": identity.get("contentType", "page"),
@@ -49,11 +187,18 @@ def plan_job(args: argparse.Namespace) -> int:
49
187
  "targetLocale": args.locale,
50
188
  "operation": args.operation,
51
189
  "translationGroupId": identity.get("translationGroupId", f"tg-{content_id}"),
52
- "sourceRevision": args.source_revision or content.get("sourceRevision", "unknown"),
190
+ "sourceRevision": args.source_revision or (source_artifact or {}).get("sourceRevision") or content.get("sourceRevision", "unknown"),
53
191
  "status": "draft",
54
192
  "translation": {"translationStatus": "draft", "isIndexable": False},
55
193
  "createdAt": now(),
56
194
  }
195
+ if source_artifact:
196
+ if source_artifact.get("schemaVersion") != "maggie-localization-source.v1":
197
+ raise ValueError("source artifact schemaVersion is unsupported")
198
+ source_strings = source_artifact.get("strings", [])
199
+ if not isinstance(source_strings, list) or not source_strings:
200
+ raise ValueError("source artifact has no extracted strings")
201
+ job["translation"]["sourceStringIds"] = [item.get("id") for item in source_strings if isinstance(item, dict) and item.get("id")]
57
202
  output = Path(args.project) / ".maggie" / "localization" / f"{job['jobId']}.json"
58
203
  output.parent.mkdir(parents=True, exist_ok=True)
59
204
  output.write_text(json.dumps(job, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
@@ -61,7 +206,7 @@ def plan_job(args: argparse.Namespace) -> int:
61
206
  return 0
62
207
 
63
208
 
64
- def validate_job(path: Path) -> dict:
209
+ def validate_job(path: Path, source_path: Path | None = None, render_path: Path | None = None) -> dict:
65
210
  job = load(path)
66
211
  errors = []
67
212
  if job.get("operation") not in OPERATIONS:
@@ -83,6 +228,15 @@ def validate_job(path: Path) -> dict:
83
228
  })
84
229
  }, expected_lang=job.get("targetLanguage")))
85
230
  errors.extend(f"protected field changed without approval: {field}" for field in protected_field_changes(job.get("content", {}), translation))
231
+ if source_path:
232
+ errors.extend(validate_source(source_path, job.get("sourceRevision")))
233
+ source = load(source_path)
234
+ source_ids = {item.get("id") for item in source.get("strings", []) if isinstance(item, dict)}
235
+ linked_ids = translation.get("sourceStringIds")
236
+ if not isinstance(linked_ids, list) or set(linked_ids) != source_ids:
237
+ errors.append("translation sourceStringIds must link every extracted source string")
238
+ if render_path:
239
+ errors.extend(validate_render_report(render_path))
86
240
  return {"passed": not errors, "errors": errors, "jobId": job.get("jobId"), "status": job.get("status")}
87
241
 
88
242
 
@@ -162,8 +316,17 @@ def main() -> int:
162
316
  plan_parser.add_argument("--market", required=True)
163
317
  plan_parser.add_argument("--operation", required=True)
164
318
  plan_parser.add_argument("--source-revision")
165
- for name in ("preview", "validate"):
166
- sub.add_parser(name).add_argument("job", type=Path)
319
+ plan_parser.add_argument("--source", type=Path, help="optional extracted source artifact")
320
+ sub.add_parser("preview").add_argument("job", type=Path)
321
+ validate_parser = sub.add_parser("validate")
322
+ validate_parser.add_argument("job", type=Path)
323
+ validate_parser.add_argument("--source", type=Path)
324
+ validate_parser.add_argument("--render-report", type=Path)
325
+ extract_parser = sub.add_parser("extract")
326
+ extract_parser.add_argument("--project", type=Path, default=Path("."))
327
+ extract_parser.add_argument("--source-dir", default="src")
328
+ extract_parser.add_argument("--routes-file")
329
+ extract_parser.add_argument("--output", default=".maggie/localization/source.json")
167
330
  review_parser = sub.add_parser("review")
168
331
  review_parser.add_argument("job", type=Path); review_parser.add_argument("--reviewer", required=True); review_parser.add_argument("--decision", required=True)
169
332
  publish_parser = sub.add_parser("publish")
@@ -175,7 +338,8 @@ def main() -> int:
175
338
  if args.command == "plan": return plan_job(args)
176
339
  if args.command == "preview": return preview(args.job)
177
340
  if args.command == "validate":
178
- result = validate_job(args.job); print(json.dumps(result, indent=2)); return 0 if result["passed"] else 1
341
+ result = validate_job(args.job, args.source, args.render_report); print(json.dumps(result, indent=2)); return 0 if result["passed"] else 1
342
+ if args.command == "extract": return extract_source(args)
179
343
  if args.command == "review": return review(args.job, args.reviewer, args.decision)
180
344
  if args.command == "publish": return publish(args.job, args.confirm)
181
345
  if args.command == "stale": return stale_jobs(args.project)
@@ -212,6 +212,12 @@ def main() -> int:
212
212
  parser.add_argument("--output", help="release evidence JSON path")
213
213
  parser.add_argument("--skip-compatibility", action="store_true", help="skip the optional schema rehearsal; not recommended for release")
214
214
  parser.add_argument("--base-url", help="optional live/staging URL for homepage, admin redirect and 404 smoke")
215
+ parser.add_argument("--analytics-release-gate", action="store_true", help="require the end-to-end analytics evidence gate")
216
+ parser.add_argument("--analytics-contract")
217
+ parser.add_argument("--analytics-render-report")
218
+ parser.add_argument("--analytics-network-report")
219
+ parser.add_argument("--analytics-provider-report")
220
+ parser.add_argument("--analytics-smoke-report")
215
221
  args = parser.parse_args()
216
222
  project = Path(args.project).resolve()
217
223
  if not project.is_dir():
@@ -221,6 +227,21 @@ def main() -> int:
221
227
  gates.append(build_gate(project))
222
228
  for name, command in build_gates(project, args.environment, args.target, not args.skip_compatibility):
223
229
  gates.append(run_gate(name, command, project))
230
+ if args.analytics_release_gate:
231
+ evidence = {
232
+ "--contract": args.analytics_contract,
233
+ "--render-report": args.analytics_render_report,
234
+ "--network-report": args.analytics_network_report,
235
+ "--provider-report": args.analytics_provider_report,
236
+ "--smoke-report": args.analytics_smoke_report,
237
+ }
238
+ missing = [name for name, value in evidence.items() if not value]
239
+ if missing:
240
+ parser.error("--analytics-release-gate requires " + ", ".join(missing))
241
+ analytics_command = [sys.executable, str(ROOT / "maggie_analytics.py"), "release-gate", "--project", str(project), "--environment", args.environment]
242
+ for name, value in evidence.items():
243
+ analytics_command.extend([name, value])
244
+ gates.append(run_gate("analytics-release-gate", analytics_command, project))
224
245
  gates.append(editorial_gate(project))
225
246
  gates.append(evidence_gate(project, args.environment))
226
247
  if args.base_url:
@@ -46,3 +46,29 @@ Use `--environment production` during release preflight. It fails closed until
46
46
  GA4 is explicitly enabled with a valid measurement ID and `GSC_SITE_URL` is
47
47
  configured. The CLI never prints secret values and does not claim confirmed
48
48
  bookings or revenue from outbound booking clicks.
49
+
50
+ ## End-to-end release gate
51
+
52
+ For a release that has browser and provider evidence, use the opt-in gate:
53
+
54
+ ```bash
55
+ maggie analytics release-gate --project . --environment staging \
56
+ --contract .maggie/analytics-contract.json \
57
+ --render-report .maggie/analytics-browser.json \
58
+ --network-report .maggie/analytics-network.json \
59
+ --provider-report .maggie/analytics-provider.json \
60
+ --smoke-report .maggie/analytics-smoke.json
61
+ ```
62
+
63
+ The contract must use `maggie-analytics-contract.v1` and declare event names,
64
+ supported destinations, privacy-safe parameters, and the redacted referral
65
+ taxonomy. Evidence uses versioned schemas: browser
66
+ `maggie-analytics-browser.v1`, network `maggie-analytics-network.v1`, provider
67
+ `maggie-analytics-provider.v1`, and smoke `maggie-analytics-smoke.v1`.
68
+ Pre-consent requests must be empty; provider evidence must be read-only and
69
+ authorized; rendered routes, cache/release smoke, and all contract events must
70
+ pass. Reports contain metadata only and never raw URLs, query strings, request
71
+ bodies, credentials, or identity fields.
72
+
73
+ To include it in the aggregate release preflight, pass the same five files to
74
+ `maggie release` with `--analytics-release-gate`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topy-ai/maggie",
3
- "version": "0.6.4",
3
+ "version": "0.6.7",
4
4
  "description": "Install and manage Maggie Skills for AI coding agents",
5
5
  "license": "MIT",
6
6
  "type": "module",