@topy-ai/maggie 0.7.40 → 0.7.42

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 (41) hide show
  1. package/README-zh-TW.md +34 -4
  2. package/README.md +41 -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-media/video-provider-evidence-v1.schema.json +25 -0
  14. package/bundled-contracts/maggie-ops/npm11-preflight-v1.schema.json +17 -0
  15. package/bundled-contracts/maggie-scaffold/host-scaffold-v1.schema.json +25 -0
  16. package/bundled-contracts/maggie-seo/gsc-readiness-v1.schema.json +19 -0
  17. package/bundled-contracts/maggie-service-booking/delivery-provider-default-v1.json +8 -0
  18. package/bundled-contracts/maggie-service-booking/delivery-provider-v1.schema.json +16 -0
  19. package/bundled-contracts/maggiedash/browser-session-v1.schema.json +18 -0
  20. package/bundled-contracts/maggiedash/content-overrides-v1.schema.json +19 -0
  21. package/bundled-contracts/maggiedash/public-session-cache-v1.schema.json +17 -0
  22. package/bundled-references/browser-inspection.md +21 -0
  23. package/bundled-skills/maggie-blog/SKILL.md +12 -0
  24. package/bundled-skills/maggie-blog-bootstrap/SKILL.md +23 -0
  25. package/bundled-skills/maggie-booking/SKILL.md +15 -0
  26. package/bundled-skills/maggie-clone/SKILL.md +13 -0
  27. package/bundled-skills/maggie-deployment/SKILL.md +6 -0
  28. package/bundled-skills/maggie-design/SKILL.md +29 -3
  29. package/bundled-skills/maggie-ops/SKILL.md +12 -0
  30. package/bundled-skills/maggie-seo-geo/SKILL.md +33 -0
  31. package/bundled-tools/clis/maggie_analytics.py +43 -1
  32. package/bundled-tools/clis/maggie_browser_audit.py +99 -3
  33. package/bundled-tools/clis/maggie_clone.py +46 -1
  34. package/bundled-tools/clis/maggie_contracts.py +111 -0
  35. package/bundled-tools/clis/maggie_design.py +55 -0
  36. package/bundled-tools/clis/maggie_workflows.py +439 -0
  37. package/bundled-tools/clis/site_audit.py +28 -1
  38. package/bundled-tools/integrations/analytics.md +14 -0
  39. package/bundled-tools/runtime/site_baseline.py +3 -0
  40. package/package.json +1 -1
  41. package/references/browser-inspection.md +21 -0
@@ -0,0 +1,439 @@
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 validate_video_provider_evidence(args: argparse.Namespace) -> int:
108
+ evidence = load(Path(args.evidence).expanduser().resolve())
109
+ errors: list[str] = []
110
+ allowed = {
111
+ "schemaVersion", "environment", "provider", "model", "status", "hostVerified",
112
+ "acknowledgement", "idempotency", "moderation", "storage", "retry",
113
+ "rawProviderPayloadIncluded", "inputAssetDataIncluded", "sourceRevision", "generatedAt",
114
+ }
115
+ for key in evidence:
116
+ if key not in allowed:
117
+ errors.append(f"evidence.{key} is not allowed")
118
+ nested_allowed = {
119
+ "acknowledgement": {"accepted", "providerReferencePresent"},
120
+ "idempotency": {"replayAccepted", "duplicateChargePrevented", "assetDeduplicated"},
121
+ "moderation": {"preflightPassed", "postGenerationReviewPassed"},
122
+ "storage": {"uploadAccepted", "metadataPersisted", "readbackPassed", "signedOrApprovedUrlPresent"},
123
+ "retry": {"transientFailureObserved", "bounded", "attempts"},
124
+ "sourceRevision": {"commit", "branch", "dirty"},
125
+ }
126
+ for parent, keys in nested_allowed.items():
127
+ value = evidence.get(parent)
128
+ if isinstance(value, dict):
129
+ for key in value:
130
+ if key not in keys:
131
+ errors.append(f"{parent}.{key} is not allowed")
132
+ if evidence.get("schemaVersion") != "maggie-video-provider-evidence.v1": errors.append("schemaVersion must be maggie-video-provider-evidence.v1")
133
+ if evidence.get("environment") not in {"test", "staging", "production"}: errors.append("environment is unsupported")
134
+ if evidence.get("provider") != "google-gemini": errors.append("provider must be google-gemini")
135
+ if not str(evidence.get("model", "")).startswith(("gemini-", "veo-")): errors.append("model must be a Gemini or Veo model id")
136
+ if evidence.get("status") != "passed": errors.append("status must be passed")
137
+ if args.require_host and evidence.get("hostVerified") is not True: errors.append("hostVerified must be true")
138
+ acknowledgement = evidence.get("acknowledgement") if isinstance(evidence.get("acknowledgement"), dict) else {}
139
+ if acknowledgement.get("accepted") is not True or acknowledgement.get("providerReferencePresent") is not True: errors.append("provider acknowledgement is incomplete")
140
+ idempotency = evidence.get("idempotency") if isinstance(evidence.get("idempotency"), dict) else {}
141
+ if any(idempotency.get(key) is not True for key in ("replayAccepted", "duplicateChargePrevented", "assetDeduplicated")): errors.append("video idempotency evidence is incomplete")
142
+ moderation = evidence.get("moderation") if isinstance(evidence.get("moderation"), dict) else {}
143
+ if moderation.get("preflightPassed") is not True or moderation.get("postGenerationReviewPassed") is not True: errors.append("moderation evidence is incomplete")
144
+ storage = evidence.get("storage") if isinstance(evidence.get("storage"), dict) else {}
145
+ if any(storage.get(key) is not True for key in ("uploadAccepted", "metadataPersisted", "readbackPassed", "signedOrApprovedUrlPresent")): errors.append("storage readback evidence is incomplete")
146
+ retry = evidence.get("retry") if isinstance(evidence.get("retry"), dict) else {}
147
+ attempts = retry.get("attempts")
148
+ if retry.get("transientFailureObserved") is not True or retry.get("bounded") is not True or not isinstance(attempts, int) or attempts < 1 or attempts > 3: errors.append("retry evidence is incomplete")
149
+ if evidence.get("rawProviderPayloadIncluded") is not False: errors.append("raw provider payloads must be excluded")
150
+ if evidence.get("inputAssetDataIncluded") is not False: errors.append("input asset data must be excluded")
151
+ source = evidence.get("sourceRevision") if isinstance(evidence.get("sourceRevision"), dict) else {}
152
+ if not isinstance(source.get("commit"), str) or not isinstance(source.get("branch"), str) or not isinstance(source.get("dirty"), bool): errors.append("sourceRevision is incomplete")
153
+ if args.environment and evidence.get("environment") != args.environment: errors.append("environment does not match workflow input")
154
+ if args.model and evidence.get("model") != args.model: errors.append("model does not match workflow input")
155
+ return emit({"schemaVersion": "maggie-video-provider-evidence.v1", "passed": not errors, "environment": evidence.get("environment"), "provider": evidence.get("provider"), "model": evidence.get("model"), "errors": errors, "rawProviderPayloadIncluded": False, "redacted": True}, args.output)
156
+
157
+
158
+ def git_value(project: Path, *args: str) -> str:
159
+ result = subprocess.run(["git", "-C", str(project), *args], capture_output=True, text=True, check=False)
160
+ if result.returncode:
161
+ raise ValueError(result.stderr.strip() or "not a git worktree")
162
+ return result.stdout.strip()
163
+
164
+
165
+ def capture_provenance(args: argparse.Namespace) -> int:
166
+ project = Path(args.project).expanduser().resolve()
167
+ source = Path(args.source).expanduser().resolve() if args.source else project
168
+ try:
169
+ relative = source.relative_to(project).as_posix() or "."
170
+ except ValueError as error:
171
+ raise ValueError("--source must be inside --project") from error
172
+ if any(part in {".env", ".git", "node_modules"} for part in source.parts):
173
+ raise ValueError("source cannot be a secret, git metadata, or dependency path")
174
+ commit = git_value(project, "rev-parse", "HEAD")
175
+ branch = git_value(project, "branch", "--show-current") or "detached"
176
+ dirty = bool(git_value(project, "status", "--porcelain", "--", relative))
177
+ tracked = subprocess.run(["git", "-C", str(project), "ls-files", "--error-unmatch", relative], capture_output=True, text=True, check=False).returncode == 0
178
+ 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()}
179
+ # capturedAt is useful in a report but not part of the contract artifact.
180
+ return emit({**value, "passed": True}, args.output)
181
+
182
+
183
+ FRAMEWORK_PACKAGES = {
184
+ "astro": ["astro"],
185
+ "nextjs": ["next", "react", "react-dom"],
186
+ "sveltekit": ["@sveltejs/kit"],
187
+ "nuxt": ["nuxt"],
188
+ "vite-react": ["vite", "react", "react-dom"],
189
+ "vite-vue": ["vite", "vue"],
190
+ }
191
+
192
+ NPM_SUPPORT_MATRIX = [
193
+ {"packageManager": "npm", "supportedVersions": ["11.x"], "installCommand": "npm install", "lockfile": "package-lock-v3", "lifecyclePolicy": "ignore-scripts-smoke-first"},
194
+ {"packageManager": "pnpm", "supportedVersions": ["9.x", "10.x"], "installCommand": "pnpm install", "lockfile": "pnpm-lock.yaml", "lifecyclePolicy": "ignore-scripts-smoke-first"},
195
+ {"packageManager": "yarn", "supportedVersions": ["1.x", "4.x"], "installCommand": "yarn install", "lockfile": "yarn.lock", "lifecyclePolicy": "ignore-scripts-smoke-first"},
196
+ {"packageManager": "bun", "supportedVersions": ["1.x"], "installCommand": "bun install", "lockfile": "bun.lock", "lifecyclePolicy": "ignore-scripts-smoke-first"},
197
+ ]
198
+
199
+
200
+ def scaffold_files(framework: str, language: str) -> dict[str, str]:
201
+ typed = language == "typescript"
202
+ if framework == "astro":
203
+ return {
204
+ "astro.config.mjs": "import { defineConfig } from 'astro/config';\n\nexport default defineConfig();\n",
205
+ 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",
206
+ }
207
+ if framework == "nextjs":
208
+ extension = "tsx" if typed else "jsx"
209
+ return {
210
+ f"app/layout.{extension}": "export default function RootLayout({ children }) { return <html lang=\"en\"><body>{children}</body></html>; }\n",
211
+ f"app/page.{extension}": "export default function Home() { return <main><h1>Maggie project</h1><p>Generated by Maggie.</p></main>; }\n",
212
+ "next.config.mjs": "/** @type {import('next').NextConfig} */\nconst nextConfig = {};\nexport default nextConfig;\n",
213
+ }
214
+ if framework == "sveltekit":
215
+ return {
216
+ "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",
217
+ "svelte.config.js": "import adapter from '@sveltejs/adapter-auto';\nexport default { kit: { adapter: adapter() } };\n",
218
+ "vite.config.js": "import { sveltekit } from '@sveltejs/kit/vite';\nimport { defineConfig } from 'vite';\nexport default defineConfig({ plugins: [sveltekit()] });\n",
219
+ }
220
+ if framework == "nuxt":
221
+ extension = "ts" if typed else "js"
222
+ return {
223
+ f"pages/index.vue": "<template><main><h1>Maggie project</h1><p>Generated by Maggie.</p></main></template>\n",
224
+ f"nuxt.config.{extension}": "export default defineNuxtConfig({});\n",
225
+ }
226
+ if framework == "vite-react":
227
+ extension = "tsx" if typed else "jsx"
228
+ return {
229
+ "index.html": "<div id=\"root\"></div><script type=\"module\" src=\"/src/main.%s\"></script>\n" % extension,
230
+ 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",
231
+ f"src/App.{extension}": "export default function App() { return <main><h1>Maggie project</h1><p>Generated by Maggie.</p></main>; }\n",
232
+ "src/style.css": "body { margin: 0; font-family: system-ui, sans-serif; } main { padding: 3rem; }\n",
233
+ }
234
+ if framework == "vite-vue":
235
+ return {
236
+ "index.html": "<div id=\"app\"></div><script type=\"module\" src=\"/src/main.js\"></script>\n",
237
+ "src/main.js": "import { createApp } from 'vue';\nimport App from './App.vue';\nimport './style.css';\ncreateApp(App).mount('#app');\n",
238
+ "src/App.vue": "<template><main><h1>Maggie project</h1><p>Generated by Maggie.</p></main></template>\n",
239
+ "src/style.css": "body { margin: 0; font-family: system-ui, sans-serif; } main { padding: 3rem; }\n",
240
+ }
241
+ raise ValueError(f"unsupported scaffold framework: {framework}")
242
+
243
+
244
+ def package_manifest(project: Path, framework: str, language: str, package_manager: str, packages: list[str]) -> dict:
245
+ extension = "ts" if language == "typescript" else "js"
246
+ scripts = {"dev": "astro dev", "build": "astro build", "preview": "astro preview"} if framework == "astro" else {
247
+ "dev": "next dev", "build": "next build", "start": "next start"} if framework == "nextjs" else {
248
+ "dev": "vite dev", "build": "vite build", "preview": "vite preview"}
249
+ if framework == "sveltekit": scripts = {"dev": "vite dev", "build": "vite build", "preview": "vite preview"}
250
+ if framework == "nuxt": scripts = {"dev": "nuxt dev", "build": "nuxt build", "preview": "nuxt preview"}
251
+ dev_dependencies = {package: "latest" for package in packages}
252
+ 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}
253
+
254
+
255
+ def scaffold(args: argparse.Namespace) -> int:
256
+ project = Path(args.project).expanduser().resolve()
257
+ packages = FRAMEWORK_PACKAGES[args.framework]
258
+ generated = scaffold_files(args.framework, args.language)
259
+ 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()}
260
+ errors: list[str] = []
261
+ if args.install_dependencies and not args.confirm:
262
+ errors.append("--install-dependencies requires --confirm")
263
+ if args.write:
264
+ if errors:
265
+ return emit({"schemaVersion": "maggie-host-scaffold.v1", "passed": False, "errors": errors, "mutation": "not executed"}, args.output)
266
+ existing = [str(project / relative) for relative in [*generated, "package.json"] if (project / relative).exists()]
267
+ if existing and not args.force:
268
+ errors.append(f"refusing to overwrite existing scaffold files; use --force: {', '.join(existing[:5])}")
269
+ return emit({**manifest, "passed": False, "mutation": "not executed", "errors": errors}, args.output)
270
+ project.mkdir(parents=True, exist_ok=True)
271
+ package_path = project / "package.json"
272
+ package_path.write_text(json.dumps(package_manifest(project, args.framework, args.language, args.package_manager, packages), indent=2) + "\n", encoding="utf-8")
273
+ for relative, content in generated.items():
274
+ target = project / relative
275
+ target.parent.mkdir(parents=True, exist_ok=True)
276
+ target.write_text(content, encoding="utf-8")
277
+ target = project / ".maggie" / "scaffold" / "manifest.json"
278
+ target.parent.mkdir(parents=True, exist_ok=True)
279
+ target.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
280
+ if args.install_dependencies:
281
+ install_cmd = [args.package_manager, "add" if args.package_manager in {"pnpm", "yarn", "bun"} else "install", *packages]
282
+ if args.package_manager == "npm":
283
+ install_cmd = ["npm", "install", *packages]
284
+ subprocess.run(install_cmd, cwd=project, check=True)
285
+ return emit({**manifest, "passed": not errors, "mutation": "executed" if args.write else "plan-only", "errors": errors}, args.output)
286
+
287
+
288
+ def validate_brand(args: argparse.Namespace) -> int:
289
+ kit = load(Path(args.manifest).expanduser().resolve())
290
+ errors: list[str] = []
291
+ required = {"schemaVersion": "maggie-brand-kit.v1", "designBoundary": "DESIGN.md-and-host-tokens-are-source-of-truth"}
292
+ for key, expected in required.items():
293
+ if kit.get(key) != expected:
294
+ errors.append(f"{key} must be {expected}")
295
+ for key in ("designTokens", "logo", "typography", "icons", "assetManifest", "preview"):
296
+ if not kit.get(key):
297
+ errors.append(f"{key} is required")
298
+ if isinstance(kit.get("icons"), dict) and kit["icons"].get("policy") not in {"library-first", "host-defined"}:
299
+ errors.append("icons.policy must declare library-first or host-defined")
300
+ return emit({"schemaVersion": "maggie-brand-kit.v1", "passed": not errors, "errors": errors, "designTokensMutated": False}, args.output)
301
+
302
+
303
+ def token_values(design_path: Path, token_path: Path | None) -> dict:
304
+ if token_path:
305
+ return load(token_path)
306
+ content = design_path.read_text(encoding="utf-8")
307
+ found = {name: value.strip() for name, value in __import__("re").findall(r"(--[A-Za-z0-9_-]+)\s*:\s*([^;\n]+)", content)}
308
+ return found or {"source": str(design_path), "contentHash": hashlib.sha256(content.encode()).hexdigest()}
309
+
310
+
311
+ def brand_kit(args: argparse.Namespace) -> int:
312
+ project = Path(args.project).expanduser().resolve()
313
+ design_path = Path(args.design).expanduser().resolve() if args.design else project / "DESIGN.md"
314
+ token_path = Path(args.tokens).expanduser().resolve() if args.tokens else None
315
+ errors: list[str] = []
316
+ if not design_path.is_file(): errors.append(f"DESIGN.md is missing: {design_path}")
317
+ if token_path and not token_path.is_file(): errors.append(f"tokens file is missing: {token_path}")
318
+ if errors: return emit({"schemaVersion": "maggie-brand-kit.v1", "passed": False, "errors": errors, "designTokensMutated": False}, args.output)
319
+ design_tokens = token_values(design_path, token_path)
320
+ assets: list[str] = []
321
+ if args.assets:
322
+ asset_root = Path(args.assets).expanduser().resolve()
323
+ if not asset_root.is_dir(): errors.append(f"asset directory is missing: {asset_root}")
324
+ 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()]
325
+ 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}}
326
+ if args.write:
327
+ if not args.confirm: errors.append("--write requires --confirm")
328
+ if not errors:
329
+ target = project / ".maggie" / "brand-kit"
330
+ target.mkdir(parents=True, exist_ok=True)
331
+ (target / "brand-kit.json").write_text(json.dumps(kit, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
332
+ (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")
333
+ 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)))
334
+ 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"
335
+ (target / "preview.html").write_text(preview, encoding="utf-8")
336
+ return emit({**kit, "passed": not errors, "mutation": "executed" if args.write and not errors else "plan-only", "errors": errors, "designTokensMutated": False}, args.output)
337
+
338
+
339
+ def npm11(args: argparse.Namespace) -> int:
340
+ project = Path(args.project).expanduser().resolve()
341
+ package_path = project / "package.json"
342
+ errors: list[str] = []
343
+ package: dict = {}
344
+ if not package_path.is_file():
345
+ errors.append("package.json is missing")
346
+ else:
347
+ package = load(package_path)
348
+ engines = package.get("engines") if isinstance(package.get("engines"), dict) else {}
349
+ scripts = package.get("scripts") if isinstance(package.get("scripts"), dict) else {}
350
+ node_version = subprocess.run(["node", "--version"], capture_output=True, text=True, check=False).stdout.strip()
351
+ npm_version = subprocess.run(["npm", "--version"], capture_output=True, text=True, check=False).stdout.strip()
352
+ try:
353
+ npm_major = int(npm_version.split(".", 1)[0])
354
+ except (ValueError, IndexError):
355
+ npm_major = None
356
+ required_node = str(engines.get("node") or ">=18")
357
+ lifecycle_scripts = sorted(set(scripts).intersection({"install", "preinstall", "postinstall"}))
358
+ if lifecycle_scripts and not (args.smoke and args.confirm):
359
+ errors.append("install lifecycle scripts require an explicit audited release decision")
360
+ if npm_major is not None and npm_major < 11:
361
+ errors.append("npm 11 is required for this preflight")
362
+ smoke = {"requested": bool(args.smoke), "ignoreScripts": True, "passed": not errors, "command": None, "stderr": None}
363
+ if args.smoke:
364
+ if not args.confirm: errors.append("--smoke requires --confirm because it performs an isolated dependency install")
365
+ elif not package_path.is_file(): errors.append("--smoke requires package.json")
366
+ else:
367
+ with tempfile.TemporaryDirectory(prefix="maggie-npm11-") as temporary:
368
+ sandbox = Path(temporary)
369
+ shutil.copy2(package_path, sandbox / "package.json")
370
+ if (project / "package-lock.json").is_file(): shutil.copy2(project / "package-lock.json", sandbox / "package-lock.json")
371
+ command = ["npm", "install", "--ignore-scripts", "--no-audit", "--no-fund", "--package-lock=false"]
372
+ proc = subprocess.run(command, cwd=sandbox, capture_output=True, text=True, check=False, timeout=300)
373
+ 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})
374
+ if proc.returncode: errors.append("isolated npm install smoke failed")
375
+ 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}
376
+ return emit(result, args.output)
377
+
378
+
379
+ def validate_booking_delivery(args: argparse.Namespace) -> int:
380
+ policy = load(Path(args.policy).expanduser().resolve())
381
+ errors: list[str] = []
382
+ if policy.get("schemaVersion") != "maggie-booking-delivery-provider.v1":
383
+ errors.append("schemaVersion must be maggie-booking-delivery-provider.v1")
384
+ if policy.get("confirmationRule") != "provider-acknowledged-only":
385
+ errors.append("confirmationRule must be provider-acknowledged-only")
386
+ email = policy.get("email") if isinstance(policy.get("email"), dict) else {}
387
+ sms = policy.get("sms") if isinstance(policy.get("sms"), dict) else {}
388
+ if email.get("provider") not in {"resend", "provider-neutral"} or email.get("channel") != "email":
389
+ errors.append("email must use Resend or provider-neutral email")
390
+ if sms.get("provider") not in {"twilio", "provider-neutral"} or sms.get("channel") != "sms":
391
+ errors.append("sms must use Twilio or provider-neutral SMS")
392
+ retry = policy.get("retryPolicy") if isinstance(policy.get("retryPolicy"), dict) else {}
393
+ if retry.get("idempotency") != "booking-event-and-channel" or not isinstance(retry.get("maxAttempts"), int):
394
+ errors.append("retry policy must be idempotent and bounded")
395
+ dead = policy.get("deadLetterPolicy") if isinstance(policy.get("deadLetterPolicy"), dict) else {}
396
+ if dead.get("afterMaxAttempts") != "dead-letter" or dead.get("replayRequiresExplicitAction") is not True:
397
+ errors.append("dead-letter policy must require explicit replay")
398
+ 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)
399
+
400
+
401
+ def validate_playback(args: argparse.Namespace) -> int:
402
+ evidence = load(Path(args.evidence).expanduser().resolve())
403
+ errors: list[str] = []
404
+ if evidence.get("schemaVersion") != "maggie-video-playback-evidence.v1": errors.append("schemaVersion must be maggie-video-playback-evidence.v1")
405
+ if not evidence.get("sourceRevision") or not evidence.get("jobId"): errors.append("jobId and sourceRevision are required")
406
+ viewports = evidence.get("viewports") if isinstance(evidence.get("viewports"), list) else []
407
+ required = {"desktop", "tablet", "mobile"}
408
+ 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")
409
+ for item in viewports:
410
+ if not isinstance(item, dict) or item.get("status") != "passed": errors.append("every viewport playback check must pass")
411
+ if isinstance(item, dict) and not item.get("posterVisible"): errors.append("posterVisible is required for each viewport")
412
+ if evidence.get("rawProviderPayloadIncluded") is not False: errors.append("raw provider payloads must not be included")
413
+ 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)
414
+
415
+
416
+ def main() -> int:
417
+ parser = argparse.ArgumentParser(prog="maggie workflows")
418
+ sub = parser.add_subparsers(dest="command", required=True)
419
+ media = sub.add_parser("media"); media_sub = media.add_subparsers(dest="media_command", required=True)
420
+ for kind, relative in (("image", "maggie-media/image-generation-policy-v1.json"), ("video", "maggie-media/video-generation-policy-v1.json")):
421
+ 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)
422
+ job = media_sub.add_parser("video-job"); job.add_argument("--job", required=True); job.add_argument("--output"); job.set_defaults(func=validate_video_job)
423
+ video_provider = media_sub.add_parser("video-provider-evidence"); video_provider.add_argument("--evidence", required=True); video_provider.add_argument("--environment", choices=["test", "staging", "production"]); video_provider.add_argument("--model"); video_provider.add_argument("--require-host", action="store_true"); video_provider.add_argument("--output"); video_provider.set_defaults(func=validate_video_provider_evidence)
424
+ 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)
425
+ 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)
426
+ 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))
427
+ 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)
428
+ 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)
429
+ playback = sub.add_parser("video-playback"); playback.add_argument("--evidence", required=True); playback.add_argument("--output"); playback.set_defaults(func=validate_playback)
430
+ args = parser.parse_args()
431
+ try:
432
+ return args.func(args)
433
+ except (OSError, ValueError, json.JSONDecodeError, subprocess.CalledProcessError) as error:
434
+ print(f"BLOCKED: maggie workflow: {error}", file=__import__("sys").stderr)
435
+ return 1
436
+
437
+
438
+ if __name__ == "__main__":
439
+ 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.42",
4
4
  "description": "Install and manage Maggie Skills for AI coding agents",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -27,6 +27,27 @@ interaction states and triggers
27
27
  responsive differences
28
28
  ```
29
29
 
30
+ Behavioral checks may be declared instead of hidden in a bespoke script. The
31
+ manifest is safe to commit because input values are not copied into evidence:
32
+
33
+ ```json
34
+ {
35
+ "schemaVersion": "maggie-browser-interactions.v1",
36
+ "steps": [
37
+ {"id": "open", "action": "click", "selector": "[data-menu]"},
38
+ {"id": "visible", "action": "assert-visible", "selector": "[data-drawer]"},
39
+ {"id": "styles", "action": "assert-style", "selector": "[data-drawer]", "property": "display", "value": "block"},
40
+ {"id": "privacy", "action": "assert-no-request", "origin": "third-party.example"}
41
+ ]
42
+ }
43
+ ```
44
+
45
+ Run it with `maggie browser-audit ... --interactions .maggie/interactions.json`.
46
+ Supported actions cover click, fill, type, select, press, wait, visibility and
47
+ state assertions, computed-style assertions, and new-request assertions. The
48
+ browser adapter owns the session and the audit records only step IDs and
49
+ redacted outcomes.
50
+
30
51
  If a target requires login, a bot challenge, a consent interaction, or a
31
52
  private browser profile, stop at that boundary and ask the user to provide
32
53
  authorized access. Do not bypass access controls or record cookies in project