@topy-ai/maggie 0.7.40 → 0.7.41

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 (40) hide show
  1. package/README-zh-TW.md +29 -4
  2. package/README.md +35 -1
  3. package/bin/maggie.js +24 -5
  4. package/bundled-contracts/maggie-clone/interaction-state-v1.schema.json +26 -0
  5. package/bundled-contracts/maggie-content/provenance-v1.schema.json +20 -0
  6. package/bundled-contracts/maggie-design/brand-kit-v1.schema.json +18 -0
  7. package/bundled-contracts/maggie-design/browser-interactions-v1.schema.json +27 -0
  8. package/bundled-contracts/maggie-design/style-editing-v1.schema.json +35 -0
  9. package/bundled-contracts/maggie-media/image-generation-policy-v1.json +28 -0
  10. package/bundled-contracts/maggie-media/video-generation-policy-v1.json +40 -0
  11. package/bundled-contracts/maggie-media/video-job-v1.schema.json +20 -0
  12. package/bundled-contracts/maggie-media/video-playback-evidence-v1.schema.json +15 -0
  13. package/bundled-contracts/maggie-ops/npm11-preflight-v1.schema.json +17 -0
  14. package/bundled-contracts/maggie-scaffold/host-scaffold-v1.schema.json +25 -0
  15. package/bundled-contracts/maggie-seo/gsc-readiness-v1.schema.json +19 -0
  16. package/bundled-contracts/maggie-service-booking/delivery-provider-default-v1.json +8 -0
  17. package/bundled-contracts/maggie-service-booking/delivery-provider-v1.schema.json +16 -0
  18. package/bundled-contracts/maggiedash/browser-session-v1.schema.json +18 -0
  19. package/bundled-contracts/maggiedash/content-overrides-v1.schema.json +19 -0
  20. package/bundled-contracts/maggiedash/public-session-cache-v1.schema.json +17 -0
  21. package/bundled-references/browser-inspection.md +21 -0
  22. package/bundled-skills/maggie-blog/SKILL.md +12 -0
  23. package/bundled-skills/maggie-blog-bootstrap/SKILL.md +23 -0
  24. package/bundled-skills/maggie-booking/SKILL.md +15 -0
  25. package/bundled-skills/maggie-clone/SKILL.md +13 -0
  26. package/bundled-skills/maggie-deployment/SKILL.md +6 -0
  27. package/bundled-skills/maggie-design/SKILL.md +29 -3
  28. package/bundled-skills/maggie-ops/SKILL.md +12 -0
  29. package/bundled-skills/maggie-seo-geo/SKILL.md +33 -0
  30. package/bundled-tools/clis/maggie_analytics.py +43 -1
  31. package/bundled-tools/clis/maggie_browser_audit.py +99 -3
  32. package/bundled-tools/clis/maggie_clone.py +46 -1
  33. package/bundled-tools/clis/maggie_contracts.py +111 -0
  34. package/bundled-tools/clis/maggie_design.py +55 -0
  35. package/bundled-tools/clis/maggie_workflows.py +387 -0
  36. package/bundled-tools/clis/site_audit.py +28 -1
  37. package/bundled-tools/integrations/analytics.md +14 -0
  38. package/bundled-tools/runtime/site_baseline.py +3 -0
  39. package/package.json +1 -1
  40. package/references/browser-inspection.md +21 -0
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
 
11
11
  import argparse
12
12
  import hashlib
13
+ import html as html_lib
13
14
  import json
14
15
  import re
15
16
  import shutil
@@ -82,6 +83,50 @@ def rendered_asset_preflight(template: Path) -> list[str]:
82
83
  return errors
83
84
 
84
85
 
86
+ def source_anchor_start(source: str, tag: str, reported_offset: int) -> int:
87
+ """Resolve an Astro compiler offset to the opening bracket of an element."""
88
+ if not re.fullmatch(r"[A-Za-z][A-Za-z0-9:-]*", tag):
89
+ raise ValueError("tag name is invalid")
90
+ bound = max(0, min(len(source), int(reported_offset) + 1))
91
+ start = source.rfind("<" + tag, 0, bound)
92
+ if start < 0:
93
+ raise ValueError(f"opening tag not found near compiler offset: {tag}")
94
+ return start
95
+
96
+
97
+ def decode_content_text(value: str) -> str:
98
+ """Decode markup entities once and refuse unknown/double-escaped entities."""
99
+ decoded = html_lib.unescape(value)
100
+ if re.search(r"&(?:[A-Za-z][A-Za-z0-9]+|#\d+|#x[0-9A-Fa-f]+);", decoded):
101
+ raise ValueError("text contains an unknown or double-escaped HTML entity")
102
+ return decoded
103
+
104
+
105
+ def markup_check(html_path: Path, require_keys: bool) -> int:
106
+ source = html_path.read_text(encoding="utf-8")
107
+ errors: list[str] = []
108
+ if re.search(r"\bstyle\s*=\s*([\"'])\s*\1", source, re.I):
109
+ errors.append("empty style attributes must be omitted")
110
+ keys = re.findall(r"\bdata-maggie-content-key\s*=\s*[\"']([^\"']+)[\"']", source, re.I)
111
+ duplicates = sorted({key for key in keys if keys.count(key) > 1})
112
+ if duplicates:
113
+ errors.append("duplicate content keys: " + ", ".join(duplicates))
114
+ if require_keys:
115
+ for match in re.finditer(r"<(p|h[1-6]|li|button|a|span)\b([^>]*)>([^<>]+)</\1>", source, re.I | re.S):
116
+ attrs, text = match.group(2), " ".join(match.group(3).split())
117
+ if text and not re.search(r"\bdata-maggie-content-key\s*=", attrs, re.I):
118
+ errors.append(f"editable text node lacks data-maggie-content-key: {match.group(1)}")
119
+ try:
120
+ decode_content_text(match.group(3))
121
+ except ValueError as error:
122
+ errors.append(str(error))
123
+ result = {"schemaVersion": "maggie-design-markup.v1", "passed": not errors,
124
+ "errors": errors, "contentKeyCount": len(keys), "requireKeys": require_keys,
125
+ "source": str(html_path.resolve())}
126
+ print(json.dumps(result, indent=2, ensure_ascii=False))
127
+ return 0 if not errors else 1
128
+
129
+
85
130
  def _surface_list(value: str) -> list[str]:
86
131
  surfaces = [item.strip().lower() for item in value.split(",") if item.strip()]
87
132
  invalid = sorted(set(surfaces) - set(REFERENCE_SURFACES))
@@ -808,6 +853,7 @@ commands:
808
853
  status show a design job and its per-step progress
809
854
  resume restart a failed design workflow
810
855
  author plan an original first-party page
856
+ markup-check validate optional style attributes, content keys, and entities
811
857
  reference-ui, init, validate-ui, app-init, app-validate, rebrand, review
812
858
  """)
813
859
  return 0
@@ -823,6 +869,15 @@ commands:
823
869
  return reference_ui(args.project, args.reference, _surface_list(args.surface), args.screenshots_dir, args.confirm)
824
870
  except (OSError, ValueError, json.JSONDecodeError) as error:
825
871
  print(f"BLOCKED: maggie-design reference-ui: {error}", file=sys.stderr); return 1
872
+ if len(sys.argv) > 1 and sys.argv[1] == "markup-check":
873
+ command = argparse.ArgumentParser(description="Validate generated markup before it is made editable.")
874
+ command.add_argument("--html", type=Path, required=True)
875
+ command.add_argument("--require-content-keys", action="store_true")
876
+ args = command.parse_args(sys.argv[2:])
877
+ try:
878
+ return markup_check(args.html, args.require_content_keys)
879
+ except (OSError, ValueError) as error:
880
+ print(f"BLOCKED: maggie-design markup-check: {error}", file=sys.stderr); return 1
826
881
  if len(sys.argv) > 1 and sys.argv[1] == "init":
827
882
  command = argparse.ArgumentParser(description="Initialize a native blog or service UI plan from a sanitized reference manifest.")
828
883
  command.add_argument("--project", type=Path, default=Path.cwd())
@@ -0,0 +1,387 @@
1
+ #!/usr/bin/env python3
2
+ """Stable, read-only policy and evidence gates for deferred Maggie workflows."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import hashlib
8
+ import html
9
+ import json
10
+ import shutil
11
+ import subprocess
12
+ import tempfile
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+
16
+
17
+ ROOT = Path(__file__).resolve().parents[2]
18
+
19
+
20
+ def contract_root() -> Path:
21
+ tool_path = Path(__file__).resolve()
22
+ bundled_tools = next((path for path in tool_path.parents if path.name == "bundled-tools"), None)
23
+ if bundled_tools:
24
+ return bundled_tools.parent / "bundled-contracts"
25
+ return ROOT / "contracts"
26
+
27
+
28
+ def now() -> str:
29
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
30
+
31
+
32
+ def load(path: Path) -> dict:
33
+ value = json.loads(path.read_text(encoding="utf-8"))
34
+ if not isinstance(value, dict):
35
+ raise ValueError(f"{path} must contain a JSON object")
36
+ return value
37
+
38
+
39
+ def emit(value: dict, output: str | None = None) -> int:
40
+ if output:
41
+ target = Path(output).expanduser().resolve()
42
+ target.parent.mkdir(parents=True, exist_ok=True)
43
+ target.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
44
+ print(json.dumps(value, indent=2, ensure_ascii=False))
45
+ return 0 if value.get("passed") is True else 1
46
+
47
+
48
+ def validate_media(args: argparse.Namespace) -> int:
49
+ policy = load(Path(args.policy).expanduser().resolve())
50
+ kind = args.kind
51
+ expected = f"maggie-{kind}-generation-policy.v1"
52
+ errors: list[str] = []
53
+ if policy.get("schemaVersion") != expected:
54
+ errors.append(f"schemaVersion must be {expected}")
55
+ if policy.get("provider") != "google-gemini":
56
+ errors.append("provider must be google-gemini")
57
+ if not str(policy.get("model", "")).startswith("gemini-"):
58
+ errors.append("model must be a Gemini model id")
59
+ if not str(policy.get("fallbackModel", "")).startswith(("gemini-", "veo-")):
60
+ errors.append("fallbackModel must be a Gemini or Veo model id")
61
+ if policy.get("endpointEnv") != "GEMINI_API_ENDPOINT" or policy.get("apiKeyEnv") != "GEMINI_API_KEY":
62
+ errors.append("Gemini endpoint and key must remain host environment names")
63
+ rights = policy.get("rights") if isinstance(policy.get("rights"), dict) else {}
64
+ moderation = policy.get("moderation") if isinstance(policy.get("moderation"), dict) else {}
65
+ for key in ("inputRightsRequired", "outputReviewRequired"):
66
+ if rights.get(key) is not True:
67
+ errors.append(f"rights.{key} must be true")
68
+ for key in ("preflightRequired", "postGenerationReviewRequired"):
69
+ if moderation.get(key) is not True:
70
+ errors.append(f"moderation.{key} must be true")
71
+ if kind == "image":
72
+ outputs = policy.get("outputs") if isinstance(policy.get("outputs"), dict) else {}
73
+ if not outputs.get("allowedMimeTypes") or not outputs.get("allowedSizes"):
74
+ errors.append("image outputs must declare mime types and sizes")
75
+ else:
76
+ cost = policy.get("costPolicy") if isinstance(policy.get("costPolicy"), dict) else {}
77
+ storage = policy.get("storage") if isinstance(policy.get("storage"), dict) else {}
78
+ if cost.get("budgetRequired") is not True or cost.get("duplicateChargeProtection") != "idempotency-key":
79
+ errors.append("video cost policy must require a budget and idempotency protection")
80
+ if storage.get("adapter") != "host-owned-object-storage":
81
+ errors.append("video storage must remain host-owned-object-storage")
82
+ provenance = policy.get("provenance")
83
+ if not isinstance(provenance, list) or not {"model", "sourceRevision", "createdAt"}.issubset(provenance):
84
+ errors.append("policy provenance must include model, sourceRevision, and createdAt")
85
+ return emit({"schemaVersion": expected, "passed": not errors, "kind": kind, "provider": policy.get("provider"), "model": policy.get("model"), "fallbackModel": policy.get("fallbackModel"), "errors": errors, "credentialsPrinted": False}, args.output)
86
+
87
+
88
+ def validate_video_job(args: argparse.Namespace) -> int:
89
+ job = load(Path(args.job).expanduser().resolve())
90
+ errors: list[str] = []
91
+ if job.get("schemaVersion") != "maggie-video-job.v1":
92
+ errors.append("schemaVersion must be maggie-video-job.v1")
93
+ for key in ("jobId", "idempotencyKey", "sourceRevision", "provenance"):
94
+ if not job.get(key):
95
+ errors.append(f"{key} is required")
96
+ state = job.get("state")
97
+ attempt = job.get("attempt")
98
+ if state == "retrying" and (not isinstance(attempt, int) or attempt < 1 or attempt > 3):
99
+ errors.append("retrying jobs need attempt between 1 and 3")
100
+ if state == "dead-letter" and job.get("errorClass") not in {"provider-permanent", "moderation", "rights", "validation", "cost-limit", "unknown"}:
101
+ errors.append("dead-letter jobs need a terminal errorClass")
102
+ if state == "completed" and not isinstance(job.get("storage"), dict):
103
+ errors.append("completed jobs need host storage evidence")
104
+ return emit({"schemaVersion": "maggie-video-job.v1", "passed": not errors, "jobId": job.get("jobId"), "state": state, "errors": errors, "rawProviderPayloadIncluded": False}, args.output)
105
+
106
+
107
+ def git_value(project: Path, *args: str) -> str:
108
+ result = subprocess.run(["git", "-C", str(project), *args], capture_output=True, text=True, check=False)
109
+ if result.returncode:
110
+ raise ValueError(result.stderr.strip() or "not a git worktree")
111
+ return result.stdout.strip()
112
+
113
+
114
+ def capture_provenance(args: argparse.Namespace) -> int:
115
+ project = Path(args.project).expanduser().resolve()
116
+ source = Path(args.source).expanduser().resolve() if args.source else project
117
+ try:
118
+ relative = source.relative_to(project).as_posix() or "."
119
+ except ValueError as error:
120
+ raise ValueError("--source must be inside --project") from error
121
+ if any(part in {".env", ".git", "node_modules"} for part in source.parts):
122
+ raise ValueError("source cannot be a secret, git metadata, or dependency path")
123
+ commit = git_value(project, "rev-parse", "HEAD")
124
+ branch = git_value(project, "branch", "--show-current") or "detached"
125
+ dirty = bool(git_value(project, "status", "--porcelain", "--", relative))
126
+ tracked = subprocess.run(["git", "-C", str(project), "ls-files", "--error-unmatch", relative], capture_output=True, text=True, check=False).returncode == 0
127
+ value = {"schemaVersion": "maggie-content-provenance.v1", "commit": commit, "branch": branch, "dirty": dirty, "sourceRevision": commit, "sourcePath": relative, "authoringBoundary": args.boundary, "tracked": tracked, "staleWritePolicy": "reject-unless-source-revision-matches", "capturedAt": now()}
128
+ # capturedAt is useful in a report but not part of the contract artifact.
129
+ return emit({**value, "passed": True}, args.output)
130
+
131
+
132
+ FRAMEWORK_PACKAGES = {
133
+ "astro": ["astro"],
134
+ "nextjs": ["next", "react", "react-dom"],
135
+ "sveltekit": ["@sveltejs/kit"],
136
+ "nuxt": ["nuxt"],
137
+ "vite-react": ["vite", "react", "react-dom"],
138
+ "vite-vue": ["vite", "vue"],
139
+ }
140
+
141
+ NPM_SUPPORT_MATRIX = [
142
+ {"packageManager": "npm", "supportedVersions": ["11.x"], "installCommand": "npm install", "lockfile": "package-lock-v3", "lifecyclePolicy": "ignore-scripts-smoke-first"},
143
+ {"packageManager": "pnpm", "supportedVersions": ["9.x", "10.x"], "installCommand": "pnpm install", "lockfile": "pnpm-lock.yaml", "lifecyclePolicy": "ignore-scripts-smoke-first"},
144
+ {"packageManager": "yarn", "supportedVersions": ["1.x", "4.x"], "installCommand": "yarn install", "lockfile": "yarn.lock", "lifecyclePolicy": "ignore-scripts-smoke-first"},
145
+ {"packageManager": "bun", "supportedVersions": ["1.x"], "installCommand": "bun install", "lockfile": "bun.lock", "lifecyclePolicy": "ignore-scripts-smoke-first"},
146
+ ]
147
+
148
+
149
+ def scaffold_files(framework: str, language: str) -> dict[str, str]:
150
+ typed = language == "typescript"
151
+ if framework == "astro":
152
+ return {
153
+ "astro.config.mjs": "import { defineConfig } from 'astro/config';\n\nexport default defineConfig();\n",
154
+ f"src/pages/index.{'astro'}": "---\nconst title = 'Maggie project';\n---\n<html lang=\"en\">\n <head><meta charset=\"utf-8\" /><meta name=\"viewport\" content=\"width=device-width\" /><title>{title}</title></head>\n <body><main><h1>{title}</h1><p>Generated by Maggie.</p></main></body>\n</html>\n",
155
+ }
156
+ if framework == "nextjs":
157
+ extension = "tsx" if typed else "jsx"
158
+ return {
159
+ f"app/layout.{extension}": "export default function RootLayout({ children }) { return <html lang=\"en\"><body>{children}</body></html>; }\n",
160
+ f"app/page.{extension}": "export default function Home() { return <main><h1>Maggie project</h1><p>Generated by Maggie.</p></main>; }\n",
161
+ "next.config.mjs": "/** @type {import('next').NextConfig} */\nconst nextConfig = {};\nexport default nextConfig;\n",
162
+ }
163
+ if framework == "sveltekit":
164
+ return {
165
+ "src/routes/+page.svelte": "<svelte:head><title>Maggie project</title></svelte:head>\n<main><h1>Maggie project</h1><p>Generated by Maggie.</p></main>\n",
166
+ "svelte.config.js": "import adapter from '@sveltejs/adapter-auto';\nexport default { kit: { adapter: adapter() } };\n",
167
+ "vite.config.js": "import { sveltekit } from '@sveltejs/kit/vite';\nimport { defineConfig } from 'vite';\nexport default defineConfig({ plugins: [sveltekit()] });\n",
168
+ }
169
+ if framework == "nuxt":
170
+ extension = "ts" if typed else "js"
171
+ return {
172
+ f"pages/index.vue": "<template><main><h1>Maggie project</h1><p>Generated by Maggie.</p></main></template>\n",
173
+ f"nuxt.config.{extension}": "export default defineNuxtConfig({});\n",
174
+ }
175
+ if framework == "vite-react":
176
+ extension = "tsx" if typed else "jsx"
177
+ return {
178
+ "index.html": "<div id=\"root\"></div><script type=\"module\" src=\"/src/main.%s\"></script>\n" % extension,
179
+ f"src/main.{extension}": "import React from 'react';\nimport { createRoot } from 'react-dom/client';\nimport App from './App';\nimport './style.css';\ncreateRoot(document.getElementById('root')).render(<React.StrictMode><App /></React.StrictMode>);\n",
180
+ f"src/App.{extension}": "export default function App() { return <main><h1>Maggie project</h1><p>Generated by Maggie.</p></main>; }\n",
181
+ "src/style.css": "body { margin: 0; font-family: system-ui, sans-serif; } main { padding: 3rem; }\n",
182
+ }
183
+ if framework == "vite-vue":
184
+ return {
185
+ "index.html": "<div id=\"app\"></div><script type=\"module\" src=\"/src/main.js\"></script>\n",
186
+ "src/main.js": "import { createApp } from 'vue';\nimport App from './App.vue';\nimport './style.css';\ncreateApp(App).mount('#app');\n",
187
+ "src/App.vue": "<template><main><h1>Maggie project</h1><p>Generated by Maggie.</p></main></template>\n",
188
+ "src/style.css": "body { margin: 0; font-family: system-ui, sans-serif; } main { padding: 3rem; }\n",
189
+ }
190
+ raise ValueError(f"unsupported scaffold framework: {framework}")
191
+
192
+
193
+ def package_manifest(project: Path, framework: str, language: str, package_manager: str, packages: list[str]) -> dict:
194
+ extension = "ts" if language == "typescript" else "js"
195
+ scripts = {"dev": "astro dev", "build": "astro build", "preview": "astro preview"} if framework == "astro" else {
196
+ "dev": "next dev", "build": "next build", "start": "next start"} if framework == "nextjs" else {
197
+ "dev": "vite dev", "build": "vite build", "preview": "vite preview"}
198
+ if framework == "sveltekit": scripts = {"dev": "vite dev", "build": "vite build", "preview": "vite preview"}
199
+ if framework == "nuxt": scripts = {"dev": "nuxt dev", "build": "nuxt build", "preview": "nuxt preview"}
200
+ dev_dependencies = {package: "latest" for package in packages}
201
+ return {"name": project.name.lower().replace("_", "-") or "maggie-project", "private": True, "type": "module", "scripts": scripts, "packageManager": package_manager, "maggie": {"framework": framework, "language": language, "entryExtension": extension}, "devDependencies": dev_dependencies}
202
+
203
+
204
+ def scaffold(args: argparse.Namespace) -> int:
205
+ project = Path(args.project).expanduser().resolve()
206
+ packages = FRAMEWORK_PACKAGES[args.framework]
207
+ generated = scaffold_files(args.framework, args.language)
208
+ manifest = {"schemaVersion": "maggie-host-scaffold.v1", "framework": args.framework, "language": args.language, "packageManager": args.package_manager, "installPolicy": {"requiresConfirm": True, "secretsByDefault": "never", "migrationsByDefault": "never", "installDependencies": bool(args.install_dependencies)}, "hostHandoff": {"generatedFiles": [".maggie/scaffold/manifest.json", *sorted(generated), "package.json"], "rollbackPlan": "remove generated files only and revert only the explicit dependency diff", "nextCommands": [f"maggie doctor --project {project}", "maggie ops preflight --project ."]}, "packages": packages, "createdAt": now()}
209
+ errors: list[str] = []
210
+ if args.install_dependencies and not args.confirm:
211
+ errors.append("--install-dependencies requires --confirm")
212
+ if args.write:
213
+ if errors:
214
+ return emit({"schemaVersion": "maggie-host-scaffold.v1", "passed": False, "errors": errors, "mutation": "not executed"}, args.output)
215
+ existing = [str(project / relative) for relative in [*generated, "package.json"] if (project / relative).exists()]
216
+ if existing and not args.force:
217
+ errors.append(f"refusing to overwrite existing scaffold files; use --force: {', '.join(existing[:5])}")
218
+ return emit({**manifest, "passed": False, "mutation": "not executed", "errors": errors}, args.output)
219
+ project.mkdir(parents=True, exist_ok=True)
220
+ package_path = project / "package.json"
221
+ package_path.write_text(json.dumps(package_manifest(project, args.framework, args.language, args.package_manager, packages), indent=2) + "\n", encoding="utf-8")
222
+ for relative, content in generated.items():
223
+ target = project / relative
224
+ target.parent.mkdir(parents=True, exist_ok=True)
225
+ target.write_text(content, encoding="utf-8")
226
+ target = project / ".maggie" / "scaffold" / "manifest.json"
227
+ target.parent.mkdir(parents=True, exist_ok=True)
228
+ target.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
229
+ if args.install_dependencies:
230
+ install_cmd = [args.package_manager, "add" if args.package_manager in {"pnpm", "yarn", "bun"} else "install", *packages]
231
+ if args.package_manager == "npm":
232
+ install_cmd = ["npm", "install", *packages]
233
+ subprocess.run(install_cmd, cwd=project, check=True)
234
+ return emit({**manifest, "passed": not errors, "mutation": "executed" if args.write else "plan-only", "errors": errors}, args.output)
235
+
236
+
237
+ def validate_brand(args: argparse.Namespace) -> int:
238
+ kit = load(Path(args.manifest).expanduser().resolve())
239
+ errors: list[str] = []
240
+ required = {"schemaVersion": "maggie-brand-kit.v1", "designBoundary": "DESIGN.md-and-host-tokens-are-source-of-truth"}
241
+ for key, expected in required.items():
242
+ if kit.get(key) != expected:
243
+ errors.append(f"{key} must be {expected}")
244
+ for key in ("designTokens", "logo", "typography", "icons", "assetManifest", "preview"):
245
+ if not kit.get(key):
246
+ errors.append(f"{key} is required")
247
+ if isinstance(kit.get("icons"), dict) and kit["icons"].get("policy") not in {"library-first", "host-defined"}:
248
+ errors.append("icons.policy must declare library-first or host-defined")
249
+ return emit({"schemaVersion": "maggie-brand-kit.v1", "passed": not errors, "errors": errors, "designTokensMutated": False}, args.output)
250
+
251
+
252
+ def token_values(design_path: Path, token_path: Path | None) -> dict:
253
+ if token_path:
254
+ return load(token_path)
255
+ content = design_path.read_text(encoding="utf-8")
256
+ found = {name: value.strip() for name, value in __import__("re").findall(r"(--[A-Za-z0-9_-]+)\s*:\s*([^;\n]+)", content)}
257
+ return found or {"source": str(design_path), "contentHash": hashlib.sha256(content.encode()).hexdigest()}
258
+
259
+
260
+ def brand_kit(args: argparse.Namespace) -> int:
261
+ project = Path(args.project).expanduser().resolve()
262
+ design_path = Path(args.design).expanduser().resolve() if args.design else project / "DESIGN.md"
263
+ token_path = Path(args.tokens).expanduser().resolve() if args.tokens else None
264
+ errors: list[str] = []
265
+ if not design_path.is_file(): errors.append(f"DESIGN.md is missing: {design_path}")
266
+ if token_path and not token_path.is_file(): errors.append(f"tokens file is missing: {token_path}")
267
+ if errors: return emit({"schemaVersion": "maggie-brand-kit.v1", "passed": False, "errors": errors, "designTokensMutated": False}, args.output)
268
+ design_tokens = token_values(design_path, token_path)
269
+ assets: list[str] = []
270
+ if args.assets:
271
+ asset_root = Path(args.assets).expanduser().resolve()
272
+ if not asset_root.is_dir(): errors.append(f"asset directory is missing: {asset_root}")
273
+ else: assets = [str(path.relative_to(project)) if path.is_relative_to(project) else str(path) for path in sorted(asset_root.rglob("*")) if path.is_file()]
274
+ kit = {"schemaVersion": "maggie-brand-kit.v1", "designTokens": design_tokens, "logo": {"primary": args.logo or "host-defined", "variants": ["primary", "monochrome"]}, "typography": {"families": [args.font or "host-defined"], "weights": [400, 500, 600, 700]}, "icons": {"library": args.icon_library, "policy": "library-first"}, "assetManifest": ".maggie/brand-kit/assets.json", "preview": ".maggie/brand-kit/preview.html", "designBoundary": "DESIGN.md-and-host-tokens-are-source-of-truth", "sources": {"design": str(design_path), "tokens": str(token_path) if token_path else None}}
275
+ if args.write:
276
+ if not args.confirm: errors.append("--write requires --confirm")
277
+ if not errors:
278
+ target = project / ".maggie" / "brand-kit"
279
+ target.mkdir(parents=True, exist_ok=True)
280
+ (target / "brand-kit.json").write_text(json.dumps(kit, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
281
+ (target / "assets.json").write_text(json.dumps({"schemaVersion": "maggie-brand-assets.v1", "assets": assets, "redacted": True}, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
282
+ swatches = "".join(f"<li><code>{html.escape(str(key))}</code><span style=\"background:{html.escape(str(value))}\"></span><small>{html.escape(str(value))}</small></li>" for key, value in design_tokens.items() if isinstance(value, (str, int, float)))
283
+ preview = "<!doctype html><meta charset=\"utf-8\"><title>Maggie brand kit preview</title><style>body{font:16px system-ui,sans-serif;max-width:60rem;margin:2rem auto;padding:0 1rem;color:#172033}ul{list-style:none;padding:0}li{display:flex;gap:.75rem;align-items:center;padding:.5rem 0;border-bottom:1px solid #ddd}li span{width:2rem;height:2rem;border:1px solid #aaa;border-radius:.35rem}small{color:#5b6472}</style><main><h1>Maggie brand kit</h1><p>Read-only preview generated from the approved DESIGN source.</p><h2>Design tokens</h2><ul>" + swatches + f"</ul><p>Logo: <code>{html.escape(str(kit['logo']['primary']))}</code></p><p>Icon library: <code>{html.escape(args.icon_library)}</code></p><p>Assets listed: {len(assets)}</p></main>\n"
284
+ (target / "preview.html").write_text(preview, encoding="utf-8")
285
+ return emit({**kit, "passed": not errors, "mutation": "executed" if args.write and not errors else "plan-only", "errors": errors, "designTokensMutated": False}, args.output)
286
+
287
+
288
+ def npm11(args: argparse.Namespace) -> int:
289
+ project = Path(args.project).expanduser().resolve()
290
+ package_path = project / "package.json"
291
+ errors: list[str] = []
292
+ package: dict = {}
293
+ if not package_path.is_file():
294
+ errors.append("package.json is missing")
295
+ else:
296
+ package = load(package_path)
297
+ engines = package.get("engines") if isinstance(package.get("engines"), dict) else {}
298
+ scripts = package.get("scripts") if isinstance(package.get("scripts"), dict) else {}
299
+ node_version = subprocess.run(["node", "--version"], capture_output=True, text=True, check=False).stdout.strip()
300
+ npm_version = subprocess.run(["npm", "--version"], capture_output=True, text=True, check=False).stdout.strip()
301
+ try:
302
+ npm_major = int(npm_version.split(".", 1)[0])
303
+ except (ValueError, IndexError):
304
+ npm_major = None
305
+ required_node = str(engines.get("node") or ">=18")
306
+ lifecycle_scripts = sorted(set(scripts).intersection({"install", "preinstall", "postinstall"}))
307
+ if lifecycle_scripts and not (args.smoke and args.confirm):
308
+ errors.append("install lifecycle scripts require an explicit audited release decision")
309
+ if npm_major is not None and npm_major < 11:
310
+ errors.append("npm 11 is required for this preflight")
311
+ smoke = {"requested": bool(args.smoke), "ignoreScripts": True, "passed": not errors, "command": None, "stderr": None}
312
+ if args.smoke:
313
+ if not args.confirm: errors.append("--smoke requires --confirm because it performs an isolated dependency install")
314
+ elif not package_path.is_file(): errors.append("--smoke requires package.json")
315
+ else:
316
+ with tempfile.TemporaryDirectory(prefix="maggie-npm11-") as temporary:
317
+ sandbox = Path(temporary)
318
+ shutil.copy2(package_path, sandbox / "package.json")
319
+ if (project / "package-lock.json").is_file(): shutil.copy2(project / "package-lock.json", sandbox / "package-lock.json")
320
+ command = ["npm", "install", "--ignore-scripts", "--no-audit", "--no-fund", "--package-lock=false"]
321
+ proc = subprocess.run(command, cwd=sandbox, capture_output=True, text=True, check=False, timeout=300)
322
+ smoke.update({"command": "npm install --ignore-scripts --no-audit --no-fund --package-lock=false", "exitCode": proc.returncode, "stderr": proc.stderr[-500:] if proc.stderr else None, "passed": proc.returncode == 0})
323
+ if proc.returncode: errors.append("isolated npm install smoke failed")
324
+ result = {"schemaVersion": "maggie-npm11-preflight.v1", "supportMatrix": NPM_SUPPORT_MATRIX, "node": {"supportedMajor": required_node, "observedMajor": node_version}, "npm": {"supportedMajor": 11, "observedMajor": npm_version}, "installScriptPolicy": "ignore-scripts-smoke-first", "lockfilePolicy": "package-lock-v3" if (project / "package-lock.json").exists() else "host-package-manager", "smoke": smoke, "scripts": sorted(scripts), "lifecycleScripts": lifecycle_scripts, "errors": errors, "passed": not errors}
325
+ return emit(result, args.output)
326
+
327
+
328
+ def validate_booking_delivery(args: argparse.Namespace) -> int:
329
+ policy = load(Path(args.policy).expanduser().resolve())
330
+ errors: list[str] = []
331
+ if policy.get("schemaVersion") != "maggie-booking-delivery-provider.v1":
332
+ errors.append("schemaVersion must be maggie-booking-delivery-provider.v1")
333
+ if policy.get("confirmationRule") != "provider-acknowledged-only":
334
+ errors.append("confirmationRule must be provider-acknowledged-only")
335
+ email = policy.get("email") if isinstance(policy.get("email"), dict) else {}
336
+ sms = policy.get("sms") if isinstance(policy.get("sms"), dict) else {}
337
+ if email.get("provider") not in {"resend", "provider-neutral"} or email.get("channel") != "email":
338
+ errors.append("email must use Resend or provider-neutral email")
339
+ if sms.get("provider") not in {"twilio", "provider-neutral"} or sms.get("channel") != "sms":
340
+ errors.append("sms must use Twilio or provider-neutral SMS")
341
+ retry = policy.get("retryPolicy") if isinstance(policy.get("retryPolicy"), dict) else {}
342
+ if retry.get("idempotency") != "booking-event-and-channel" or not isinstance(retry.get("maxAttempts"), int):
343
+ errors.append("retry policy must be idempotent and bounded")
344
+ dead = policy.get("deadLetterPolicy") if isinstance(policy.get("deadLetterPolicy"), dict) else {}
345
+ if dead.get("afterMaxAttempts") != "dead-letter" or dead.get("replayRequiresExplicitAction") is not True:
346
+ errors.append("dead-letter policy must require explicit replay")
347
+ return emit({"schemaVersion": "maggie-booking-delivery-provider.v1", "passed": not errors, "emailProvider": email.get("provider"), "smsProvider": sms.get("provider"), "errors": errors, "credentialsPrinted": False}, args.output)
348
+
349
+
350
+ def validate_playback(args: argparse.Namespace) -> int:
351
+ evidence = load(Path(args.evidence).expanduser().resolve())
352
+ errors: list[str] = []
353
+ if evidence.get("schemaVersion") != "maggie-video-playback-evidence.v1": errors.append("schemaVersion must be maggie-video-playback-evidence.v1")
354
+ if not evidence.get("sourceRevision") or not evidence.get("jobId"): errors.append("jobId and sourceRevision are required")
355
+ viewports = evidence.get("viewports") if isinstance(evidence.get("viewports"), list) else []
356
+ required = {"desktop", "tablet", "mobile"}
357
+ if not required.issubset({item.get("viewport") for item in viewports if isinstance(item, dict)}): errors.append("desktop, tablet, and mobile playback evidence are required")
358
+ for item in viewports:
359
+ if not isinstance(item, dict) or item.get("status") != "passed": errors.append("every viewport playback check must pass")
360
+ if isinstance(item, dict) and not item.get("posterVisible"): errors.append("posterVisible is required for each viewport")
361
+ if evidence.get("rawProviderPayloadIncluded") is not False: errors.append("raw provider payloads must not be included")
362
+ return emit({"schemaVersion": "maggie-video-playback-evidence.v1", "passed": not errors, "jobId": evidence.get("jobId"), "viewports": [item.get("viewport") for item in viewports if isinstance(item, dict)], "errors": errors, "rawProviderPayloadIncluded": False}, args.output)
363
+
364
+
365
+ def main() -> int:
366
+ parser = argparse.ArgumentParser(prog="maggie workflows")
367
+ sub = parser.add_subparsers(dest="command", required=True)
368
+ media = sub.add_parser("media"); media_sub = media.add_subparsers(dest="media_command", required=True)
369
+ for kind, relative in (("image", "maggie-media/image-generation-policy-v1.json"), ("video", "maggie-media/video-generation-policy-v1.json")):
370
+ command = media_sub.add_parser(f"{kind}-policy"); command.add_argument("--policy", default=str(contract_root() / relative)); command.add_argument("--output"); command.set_defaults(func=validate_media, kind=kind)
371
+ job = media_sub.add_parser("video-job"); job.add_argument("--job", required=True); job.add_argument("--output"); job.set_defaults(func=validate_video_job)
372
+ provenance = sub.add_parser("provenance"); provenance.add_argument("--project", default="."); provenance.add_argument("--source"); provenance.add_argument("--boundary", choices=["git-source", "host-runtime", "generated-artifact"], default="git-source"); provenance.add_argument("--output"); provenance.set_defaults(func=capture_provenance)
373
+ scaffold_parser = sub.add_parser("scaffold"); scaffold_parser.add_argument("--project", default="."); scaffold_parser.add_argument("--framework", choices=sorted(FRAMEWORK_PACKAGES), required=True); scaffold_parser.add_argument("--language", choices=["typescript", "javascript"], default="typescript"); scaffold_parser.add_argument("--package-manager", choices=["npm", "pnpm", "yarn", "bun"], default="npm"); scaffold_parser.add_argument("--install-dependencies", action="store_true"); scaffold_parser.add_argument("--write", action="store_true"); scaffold_parser.add_argument("--confirm", action="store_true"); scaffold_parser.add_argument("--force", action="store_true"); scaffold_parser.add_argument("--output"); scaffold_parser.set_defaults(func=scaffold)
374
+ brand = sub.add_parser("brand-kit"); brand.add_argument("--manifest"); brand.add_argument("--project", default="."); brand.add_argument("--design"); brand.add_argument("--tokens"); brand.add_argument("--logo"); brand.add_argument("--font"); brand.add_argument("--icon-library", default="host-icon-library"); brand.add_argument("--assets"); brand.add_argument("--write", action="store_true"); brand.add_argument("--confirm", action="store_true"); brand.add_argument("--output"); brand.set_defaults(func=lambda args: validate_brand(args) if args.manifest else brand_kit(args))
375
+ npm = sub.add_parser("npm11"); npm.add_argument("--project", default="."); npm.add_argument("--smoke", action="store_true"); npm.add_argument("--confirm", action="store_true"); npm.add_argument("--output"); npm.set_defaults(func=npm11)
376
+ booking = sub.add_parser("booking-delivery"); booking.add_argument("--policy", default=str(contract_root() / "maggie-service-booking/delivery-provider-default-v1.json")); booking.add_argument("--output"); booking.set_defaults(func=validate_booking_delivery)
377
+ playback = sub.add_parser("video-playback"); playback.add_argument("--evidence", required=True); playback.add_argument("--output"); playback.set_defaults(func=validate_playback)
378
+ args = parser.parse_args()
379
+ try:
380
+ return args.func(args)
381
+ except (OSError, ValueError, json.JSONDecodeError, subprocess.CalledProcessError) as error:
382
+ print(f"BLOCKED: maggie workflow: {error}", file=__import__("sys").stderr)
383
+ return 1
384
+
385
+
386
+ if __name__ == "__main__":
387
+ raise SystemExit(main())
@@ -8,6 +8,7 @@ import hashlib
8
8
  import json
9
9
  import re
10
10
  import sys
11
+ from datetime import datetime, timedelta, timezone
11
12
  from pathlib import Path
12
13
  from urllib.parse import urljoin, urlparse, urlunparse
13
14
  from html.parser import HTMLParser
@@ -176,6 +177,20 @@ def fetch(url: str, evidence: dict | None = None) -> tuple[int, str, str]:
176
177
  return response.status, response.headers.get_content_type(), body
177
178
 
178
179
 
180
+ def indexability_if_indexed(page: PageParser, robots: list[str]) -> dict:
181
+ """Explain what a noindex page would fail if it became indexable."""
182
+ tokens = [token.strip().rsplit(":", 1)[-1] for directive in robots for token in directive.split(",")]
183
+ blocked = sorted(set(tokens) & {"noindex", "none"})
184
+ checks = {
185
+ "title": bool(page.title),
186
+ "description": bool(page.meta.get("description")),
187
+ "canonical": bool(page.canonical) and urlparse(page.canonical).fragment == "",
188
+ "headings": bool(page.headings) and page.h1 == 1,
189
+ "jsonld": page.jsonld > 0 and all(item is not None for item in page.jsonld_values),
190
+ }
191
+ return {"applicable": bool(blocked), "blockedBy": blocked, "wouldPass": all(checks.values()) if blocked else None, "checks": checks if blocked else {}}
192
+
193
+
179
194
  def audit_page(url: str, html: str, status: int, content_type: str, expected_languages: set[str] | None = None, response_evidence: dict | None = None) -> dict:
180
195
  page = PageParser()
181
196
  page.feed(html)
@@ -184,6 +199,7 @@ def audit_page(url: str, html: str, status: int, content_type: str, expected_lan
184
199
  robots = page.robots_directives + [value.lower() for value in response_evidence["xRobotsTag"]]
185
200
  robots_tokens = [token.strip() for directive in robots for token in directive.split(",")]
186
201
  robots_tokens = [token.rsplit(":", 1)[-1].strip() for token in robots_tokens]
202
+ hypothetical = indexability_if_indexed(page, robots)
187
203
  return {
188
204
  "url": url,
189
205
  "status": status,
@@ -236,6 +252,7 @@ def audit_page(url: str, html: str, status: int, content_type: str, expected_lan
236
252
  "robots_directives": robots,
237
253
  "robots_conflict": len({token for token in robots_tokens if token in {"index", "noindex", "follow", "nofollow", "none"}} & {"index", "noindex"}) > 1 or len({token for token in robots_tokens if token in {"follow", "nofollow", "none"}} & {"follow", "nofollow"}) > 1,
238
254
  "primary_navigation_links": page.primary_navigation_links,
255
+ "indexabilityIfIndexed": hypothetical,
239
256
  }
240
257
 
241
258
 
@@ -448,6 +465,12 @@ def parse_baseline_reasons(values: list[str], drift_urls: set[str], reason_all:
448
465
  return reasons
449
466
 
450
467
 
468
+ def baseline_ack_expiry(days: int) -> str:
469
+ if not 1 <= days <= 3650:
470
+ raise ValueError("baseline acknowledgement TTL must be between 1 and 3650 days")
471
+ return (datetime.now(timezone.utc) + timedelta(days=days)).isoformat().replace("+00:00", "Z")
472
+
473
+
451
474
  def main() -> int:
452
475
  parser = argparse.ArgumentParser()
453
476
  parser.add_argument("url")
@@ -471,6 +494,7 @@ def main() -> int:
471
494
  parser.add_argument("--baseline-id", help="required stable ID for --recapture-baseline")
472
495
  parser.add_argument("--reason", action="append", default=[], metavar="URL=REASON", help="reviewer-approved reason for one drifting URL; repeat for every drift")
473
496
  parser.add_argument("--reason-all", help="reviewer-approved reason applied to every drifting URL during baseline recapture")
497
+ parser.add_argument("--reason-ttl-days", type=int, default=30, help="expiry for a reviewed baseline drift acknowledgement (default: 30)")
474
498
  parser.add_argument("--reviewer", help="required for --save-baseline")
475
499
  args = parser.parse_args()
476
500
  if args.max_pages < 1:
@@ -503,7 +527,8 @@ def main() -> int:
503
527
  base = args.url.rstrip("/")
504
528
  checks = {}
505
529
  try:
506
- status, content_type, html = fetch(base)
530
+ homepage_response = {}
531
+ status, content_type, html = fetch(base, homepage_response)
507
532
  page = PageParser()
508
533
  page.feed(html)
509
534
  checks["homepage"] = {"ok": status == 200 and content_type == "text/html", "status": status, "content_type": content_type}
@@ -522,6 +547,7 @@ def main() -> int:
522
547
  robots_tokens = [token.strip() for directive in page.robots_directives for token in directive.split(",")]
523
548
  checks["robots_directive"] = {"ok": not page.robots_directives or not any(token in {"noindex", "none", "nofollow"} for token in robots_tokens), "directives": page.robots_directives}
524
549
  checks["robots_conflict"] = {"ok": not (len({token for token in robots_tokens if token in {"index", "noindex"}}) > 1 or len({token for token in robots_tokens if token in {"follow", "nofollow"}}) > 1), "directives": page.robots_directives}
550
+ checks["indexability_if_indexed"] = {"ok": True, **indexability_if_indexed(page, page.robots_directives + [value.lower() for value in homepage_response.get("xRobotsTag", [])])}
525
551
  if args.check_hreflang or args.check_translation_completeness:
526
552
  checks["hreflang"] = hreflang_check(base, page, expected_languages) if args.check_hreflang else {"ok": True, "links": page.hreflang}
527
553
  if args.check_translation_completeness and expected_languages:
@@ -614,6 +640,7 @@ def main() -> int:
614
640
  baseline_id=args.baseline_id,
615
641
  supersedes=previous_baseline.get("baselineId") or site_baseline.baseline_fingerprint(previous_baseline),
616
642
  change_reasons=reasons,
643
+ approval_expires_at=baseline_ack_expiry(args.reason_ttl_days),
617
644
  )
618
645
  site_baseline.save(args.recapture_baseline, recaptured)
619
646
  result["baselineRecapture"] = {
@@ -60,6 +60,20 @@ GSC data is read-only in this starter. Any query, page, or indexing report must
60
60
  show its date window and property so an agent does not confuse an empty result
61
61
  with a failed connection.
62
62
 
63
+ The GSC readiness evidence contract is explicit and can be checked before the
64
+ combined release gate:
65
+
66
+ ```bash
67
+ maggie analytics gsc-readiness \
68
+ --gsc-evidence .maggie/gsc-readiness.json
69
+ ```
70
+
71
+ The evidence must cover property/verification, canonical origin, robots,
72
+ sitemap, read-only authorization, query/readback, and production smoke. Use
73
+ `--gsc-evidence ... --require-gsc` with `maggie analytics release-gate` to make
74
+ those checks blocking. The command does not authorize a property or mutate
75
+ Search Console.
76
+
63
77
  Run the deterministic configuration gate before enabling production tracking:
64
78
 
65
79
  ```bash
@@ -21,6 +21,7 @@ def snapshot(
21
21
  baseline_id: str | None = None,
22
22
  supersedes: str | None = None,
23
23
  change_reasons: dict[str, str] | None = None,
24
+ approval_expires_at: str | None = None,
24
25
  ) -> dict:
25
26
  if not reviewer.strip():
26
27
  raise ValueError("baseline requires a reviewer")
@@ -58,6 +59,8 @@ def snapshot(
58
59
  result["supersedes"] = supersedes
59
60
  if change_reasons:
60
61
  result["approvedChangeReasons"] = {key: change_reasons[key] for key in sorted(change_reasons)}
62
+ if approval_expires_at:
63
+ result["approval"] = {"type": "reviewed-drift-acknowledgement", "expiresAt": approval_expires_at}
61
64
  return result
62
65
 
63
66
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topy-ai/maggie",
3
- "version": "0.7.40",
3
+ "version": "0.7.41",
4
4
  "description": "Install and manage Maggie Skills for AI coding agents",
5
5
  "license": "MIT",
6
6
  "type": "module",