@topy-ai/maggie 0.7.38 → 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 (95) hide show
  1. package/README-zh-TW.md +82 -6
  2. package/README.md +99 -7
  3. package/bin/maggie.js +30 -3
  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/css-utility-evidence-v1.schema.json +10 -0
  9. package/bundled-contracts/maggie-design/dashboard-surface-v1.schema.json +11 -0
  10. package/bundled-contracts/maggie-design/progressive-enhancement-v1.schema.json +12 -0
  11. package/bundled-contracts/maggie-design/sample-surface-v1.schema.json +13 -0
  12. package/bundled-contracts/maggie-design/style-editing-v1.schema.json +35 -0
  13. package/bundled-contracts/maggie-media/image-generation-policy-v1.json +28 -0
  14. package/bundled-contracts/maggie-media/video-generation-policy-v1.json +40 -0
  15. package/bundled-contracts/maggie-media/video-job-v1.schema.json +20 -0
  16. package/bundled-contracts/maggie-media/video-playback-evidence-v1.schema.json +15 -0
  17. package/bundled-contracts/maggie-ops/npm11-preflight-v1.schema.json +17 -0
  18. package/bundled-contracts/maggie-scaffold/host-scaffold-v1.schema.json +25 -0
  19. package/bundled-contracts/maggie-seo/gsc-readiness-v1.schema.json +19 -0
  20. package/bundled-contracts/maggie-seo/privacy-origin-evidence-v1.schema.json +11 -0
  21. package/bundled-contracts/maggie-service-booking/delivery-provider-default-v1.json +8 -0
  22. package/bundled-contracts/maggie-service-booking/delivery-provider-v1.schema.json +16 -0
  23. package/bundled-contracts/maggiedash/booking-access-v1.json +5 -4
  24. package/bundled-contracts/maggiedash/booking-customer-surface-v1.json +12 -1
  25. package/bundled-contracts/maggiedash/booking-email-templates-v1.json +2 -0
  26. package/bundled-contracts/maggiedash/booking-host-adapter-v1.json +14 -2
  27. package/bundled-contracts/maggiedash/booking-runtime.v1.json +41 -0
  28. package/bundled-contracts/maggiedash/browser-session-v1.schema.json +18 -0
  29. package/bundled-contracts/maggiedash/content-overrides-v1.schema.json +19 -0
  30. package/bundled-contracts/maggiedash/execution-board.json +526 -26
  31. package/bundled-contracts/maggiedash/host-capabilities-v1.schema.json +10 -0
  32. package/bundled-contracts/maggiedash/public-session-cache-v1.schema.json +17 -0
  33. package/bundled-contracts/maggiedash/site-structure-v1.schema.json +13 -0
  34. package/bundled-references/browser-inspection.md +21 -0
  35. package/bundled-references/maggiedash-booking/ARCHITECTURE.md +218 -0
  36. package/bundled-references/maggiedash-booking/CURRENT-STATE.md +92 -0
  37. package/bundled-references/maggiedash-booking/DATA-FLOW.md +143 -0
  38. package/bundled-references/maggiedash-booking/DATA-MODEL.md +367 -0
  39. package/bundled-references/maggiedash-booking/DECISIONS.md +94 -0
  40. package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +2387 -0
  41. package/bundled-references/maggiedash-booking/HOST-ADAPTER.md +314 -0
  42. package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +227 -0
  43. package/bundled-references/maggiedash-booking/PAYMENT-GATEWAY.md +267 -0
  44. package/bundled-references/maggiedash-booking/PRD.md +228 -0
  45. package/bundled-references/maggiedash-booking/PROGRESS.md +2434 -0
  46. package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +235 -0
  47. package/bundled-references/maggiedash-booking/README.md +271 -0
  48. package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +152 -0
  49. package/bundled-references/maggiedash-booking/SECURITY-COMPLIANCE.md +158 -0
  50. package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +542 -0
  51. package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +129 -0
  52. package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +107 -0
  53. package/bundled-references/maggiedash-booking/TASKS.md +137 -0
  54. package/bundled-references/maggiedash-booking/USER-JOURNEYS.md +224 -0
  55. package/bundled-references/maggiedash-booking/diagrams/booking-dfd.excalidraw +1 -0
  56. package/bundled-references/maggiedash-booking/diagrams/booking-dfd.mmd +16 -0
  57. package/bundled-references/maggiedash-booking/diagrams/booking-dfd.png +0 -0
  58. package/bundled-references/maggiedash-booking/diagrams/booking-dfd.svg +1 -0
  59. package/bundled-references/maggiedash-booking/diagrams/booking-state-machine.mmd +19 -0
  60. package/bundled-references/maggiedash-booking/diagrams/booking-state-machine.png +0 -0
  61. package/bundled-references/maggiedash-booking/diagrams/booking-state-machine.svg +1 -0
  62. package/bundled-references/maggiedash-booking/diagrams/manager-journey.excalidraw +1 -0
  63. package/bundled-references/maggiedash-booking/diagrams/manager-journey.mmd +11 -0
  64. package/bundled-references/maggiedash-booking/diagrams/manager-journey.png +0 -0
  65. package/bundled-references/maggiedash-booking/diagrams/manager-journey.svg +1 -0
  66. package/bundled-references/maggiedash-booking/diagrams/payment-sequence.mmd +20 -0
  67. package/bundled-references/maggiedash-booking/diagrams/payment-sequence.png +0 -0
  68. package/bundled-references/maggiedash-booking/diagrams/payment-sequence.svg +1 -0
  69. package/bundled-references/maggiedash-booking/diagrams/system-context.excalidraw +1 -0
  70. package/bundled-references/maggiedash-booking/diagrams/system-context.mmd +10 -0
  71. package/bundled-references/maggiedash-booking/diagrams/system-context.png +0 -0
  72. package/bundled-references/maggiedash-booking/diagrams/system-context.svg +1 -0
  73. package/bundled-skills/maggie-blog/SKILL.md +12 -0
  74. package/bundled-skills/maggie-blog-bootstrap/SKILL.md +39 -0
  75. package/bundled-skills/maggie-booking/SKILL.md +112 -18
  76. package/bundled-skills/maggie-clone/SKILL.md +13 -0
  77. package/bundled-skills/maggie-deployment/SKILL.md +6 -0
  78. package/bundled-skills/maggie-design/SKILL.md +64 -3
  79. package/bundled-skills/maggie-ops/SKILL.md +12 -0
  80. package/bundled-skills/maggie-seo-geo/SKILL.md +45 -0
  81. package/bundled-skills/maggie-service-booking/SKILL.md +14 -0
  82. package/bundled-tools/clis/maggie_analytics.py +43 -1
  83. package/bundled-tools/clis/maggie_booking.py +161 -26
  84. package/bundled-tools/clis/maggie_browser_audit.py +99 -3
  85. package/bundled-tools/clis/maggie_clone.py +46 -1
  86. package/bundled-tools/clis/maggie_contracts.py +393 -0
  87. package/bundled-tools/clis/maggie_dash.py +22 -6
  88. package/bundled-tools/clis/maggie_design.py +84 -12
  89. package/bundled-tools/clis/maggie_service_booking.py +50 -1
  90. package/bundled-tools/clis/maggie_workflows.py +387 -0
  91. package/bundled-tools/clis/site_audit.py +77 -1
  92. package/bundled-tools/integrations/analytics.md +14 -0
  93. package/bundled-tools/runtime/site_baseline.py +3 -0
  94. package/package.json +1 -1
  95. package/references/browser-inspection.md +21 -0
@@ -0,0 +1,393 @@
1
+ #!/usr/bin/env python3
2
+ """Small, deterministic validators for the cross-skill design/SEO contracts.
3
+
4
+ The commands consume sanitized evidence produced by a browser/build adapter. They
5
+ never fetch a third-party site and never write host source files implicitly.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import hashlib
12
+ import html as html_lib
13
+ import json
14
+ import re
15
+ import sys
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+ from urllib.parse import urlparse
19
+
20
+
21
+ def now() -> str:
22
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
23
+
24
+
25
+ def read_json(path: Path) -> dict:
26
+ value = json.loads(path.read_text(encoding="utf-8"))
27
+ if not isinstance(value, dict):
28
+ raise ValueError(f"{path} must contain a JSON object")
29
+ return value
30
+
31
+
32
+ def write_json(path: Path | None, value: dict) -> None:
33
+ if path:
34
+ path.parent.mkdir(parents=True, exist_ok=True)
35
+ path.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
36
+
37
+
38
+ def result(schema: str, errors: list[str], **details: object) -> dict:
39
+ return {"schemaVersion": schema, "passed": not errors, "errors": errors, **details}
40
+
41
+
42
+ def emit_and_exit(value: dict, output: Path | None = None) -> int:
43
+ write_json(output, value)
44
+ print(json.dumps(value, indent=2, ensure_ascii=False))
45
+ return 0 if value.get("passed") else 1
46
+
47
+
48
+ def require_confirm(args: argparse.Namespace) -> None:
49
+ if not args.confirm:
50
+ raise ValueError("CONFIRMATION_REQUIRED: rerun with --confirm")
51
+
52
+
53
+ def slug(value: str) -> str:
54
+ return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") or "contract"
55
+
56
+
57
+ def init_plan(args: argparse.Namespace, mode: str, defaults: dict) -> int:
58
+ require_confirm(args)
59
+ project = Path(args.project).resolve()
60
+ seed = f"{project}:{mode}:{getattr(args, 'route', '')}:{now()}".encode()
61
+ ident = f"{mode}-{hashlib.sha256(seed).hexdigest()[:12]}"
62
+ output = project / ".maggie" / "design" / mode / ident / "plan.json"
63
+ plan = {"schemaVersion": f"maggie-{mode}-surface.v1", "id": ident, "mode": mode, "createdAt": now(), **defaults}
64
+ output.parent.mkdir(parents=True, exist_ok=True)
65
+ output.write_text(json.dumps(plan, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
66
+ print(json.dumps({"plan": str(output), "id": ident, "mode": mode}, indent=2))
67
+ return 0
68
+
69
+
70
+ def validate_progressive(args: argparse.Namespace) -> int:
71
+ evidence = read_json(Path(args.evidence))
72
+ errors: list[str] = []
73
+ if evidence.get("schemaVersion") != "maggie-progressive-enhancement.v1":
74
+ errors.append("evidence schemaVersion must be maggie-progressive-enhancement.v1")
75
+ region = evidence.get("region") if isinstance(evidence.get("region"), dict) else {}
76
+ if not region.get("id") or not region.get("selector"):
77
+ errors.append("region id and selector are required")
78
+ if region.get("swapScope") != "smallest-changed-region":
79
+ errors.append("swapScope must be smallest-changed-region")
80
+ initial = evidence.get("initial") if isinstance(evidence.get("initial"), dict) else {}
81
+ initial_requests = set(initial.get("thirdPartyRequests") or [])
82
+ initial_ids = initial.get("identities") if isinstance(initial.get("identities"), dict) else {}
83
+ states = evidence.get("states")
84
+ if not isinstance(states, list) or not states:
85
+ errors.append("states must be a non-empty array")
86
+ states = []
87
+ for state in states:
88
+ if not isinstance(state, dict):
89
+ errors.append("each state must be an object")
90
+ continue
91
+ new_requests = sorted(set(state.get("thirdPartyRequests") or []) - initial_requests)
92
+ if new_requests:
93
+ errors.append(f"state {state.get('name', 'unknown')} introduced third-party requests: {', '.join(new_requests)}")
94
+ identities = state.get("identities") if isinstance(state.get("identities"), dict) else {}
95
+ for identity, token in initial_ids.items():
96
+ if identities.get(identity) != token:
97
+ errors.append(f"state {state.get('name', 'unknown')} changed identity {identity}")
98
+ if state.get("outsideRegionStable") is not True:
99
+ errors.append(f"state {state.get('name', 'unknown')} lacks outsideRegionStable=true")
100
+ return emit_and_exit(result("maggie-progressive-enhancement.v1", errors, checkedStates=len(states), region=region), Path(args.output) if args.output else None)
101
+
102
+
103
+ def css_escape(token: str) -> str:
104
+ return "".join((f"\\{char}" if char in r":[]()./%#" else char) for char in token)
105
+
106
+
107
+ def validate_css(args: argparse.Namespace) -> int:
108
+ errors: list[str] = []
109
+ missing: list[str] = []
110
+ classes: list[str] = []
111
+ if args.evidence:
112
+ evidence = read_json(Path(args.evidence))
113
+ if evidence.get("schemaVersion") != "maggie-css-utility-evidence.v1":
114
+ errors.append("evidence schemaVersion must be maggie-css-utility-evidence.v1")
115
+ for element in evidence.get("elements") or []:
116
+ if not isinstance(element, dict):
117
+ errors.append("each CSS evidence element must be an object")
118
+ continue
119
+ for declaration in element.get("declarations") or []:
120
+ winner = declaration.get("winningSource") if isinstance(declaration.get("winningSource"), dict) else {}
121
+ if winner.get("layer") == "unlayered" or winner.get("projectRule") is True:
122
+ errors.append(f"utility declaration {declaration.get('property', 'unknown')} is won by unlayered project CSS")
123
+ else:
124
+ source_html = Path(args.html).read_text(encoding="utf-8")
125
+ source_css = Path(args.css).read_text(encoding="utf-8")
126
+ for raw in re.findall(r'class=["\']([^"\']+)', source_html, re.I):
127
+ classes.extend(item for item in html_lib.unescape(raw).split() if ":" in item or "[" in item)
128
+ classes.extend(args.required_class or [])
129
+ for token in dict.fromkeys(classes):
130
+ if token not in source_css and css_escape(token) not in source_css:
131
+ missing.append(token)
132
+ if missing:
133
+ errors.append("compiled CSS is missing required utility classes: " + ", ".join(missing))
134
+ return emit_and_exit(result("maggie-css-utility-evidence.v1", errors, missingClasses=missing, checkedClasses=list(dict.fromkeys(classes))), Path(args.output) if args.output else None)
135
+
136
+
137
+ def validate_dashboard(args: argparse.Namespace) -> int:
138
+ plan = read_json(Path(args.plan))
139
+ evidence = read_json(Path(args.evidence))
140
+ errors: list[str] = []
141
+ required = {"desktop", "tablet", "mobile", "collapsed-rail", "mobile-drawer", "modal"}
142
+ observed = set(evidence.get("screenshots") or []) | set(evidence.get("states") or [])
143
+ if plan.get("schemaVersion") != "maggie-dashboard-surface.v1":
144
+ errors.append("dashboard plan schemaVersion is invalid")
145
+ if not required <= observed:
146
+ errors.append("dashboard evidence is missing: " + ", ".join(sorted(required - observed)))
147
+ if evidence.get("route") != plan.get("route"):
148
+ errors.append("dashboard evidence route does not match plan")
149
+ return emit_and_exit(result("maggie-dashboard-surface.v1", errors, observed=sorted(observed)), Path(args.output) if args.output else None)
150
+
151
+
152
+ def validate_sample(args: argparse.Namespace) -> int:
153
+ plan = read_json(Path(args.plan))
154
+ evidence = read_json(Path(args.evidence))
155
+ errors: list[str] = []
156
+ if plan.get("schemaVersion") != "maggie-sample-surface.v1":
157
+ errors.append("sample plan schemaVersion is invalid")
158
+ if evidence.get("noindex") is not True:
159
+ errors.append("sample route must be noindex")
160
+ if evidence.get("sitemapIncluded") is not False:
161
+ errors.append("sample route must be excluded from the sitemap")
162
+ if not str(evidence.get("banner") or "").strip():
163
+ errors.append("sample route needs an explicit layout-sample banner")
164
+ if not isinstance(evidence.get("placeholders"), list) or not evidence.get("placeholders"):
165
+ errors.append("sample evidence needs placeholder lines")
166
+ if not isinstance(evidence.get("finishChecklist"), list) or not evidence.get("finishChecklist"):
167
+ errors.append("sample evidence needs a finish checklist")
168
+ return emit_and_exit(result("maggie-sample-surface.v1", errors, route=plan.get("route")), Path(args.output) if args.output else None)
169
+
170
+
171
+ def validate_privacy(args: argparse.Namespace) -> int:
172
+ evidence = read_json(Path(args.evidence))
173
+ errors: list[str] = []
174
+ if evidence.get("schemaVersion") != "maggie-privacy-origin-evidence.v1":
175
+ errors.append("evidence schemaVersion must be maggie-privacy-origin-evidence.v1")
176
+ observed: set[str] = set()
177
+ for page in evidence.get("pages") or []:
178
+ observed.update(str(origin) for origin in (page.get("origins") or []))
179
+ declared = set(str(origin) for origin in evidence.get("policyOrigins") or [])
180
+ if observed - declared:
181
+ errors.append("unlisted third-party origins: " + ", ".join(sorted(observed - declared)))
182
+ if declared - observed and evidence.get("requireObservedPolicyOrigins", True):
183
+ errors.append("privacy policy origins not observed in rendered pages: " + ", ".join(sorted(declared - observed)))
184
+ return emit_and_exit(result("maggie-privacy-origin-evidence.v1", errors, observedOrigins=sorted(observed), policyOrigins=sorted(declared)), Path(args.output) if args.output else None)
185
+
186
+
187
+ def validate_structure(args: argparse.Namespace) -> int:
188
+ model = read_json(Path(args.model))
189
+ evidence = read_json(Path(args.evidence))
190
+ errors: list[str] = []
191
+ if model.get("schemaVersion") != "maggie-site-structure.v1":
192
+ errors.append("model schemaVersion must be maggie-site-structure.v1")
193
+ entries = {str(item.get("route")): item for item in model.get("pages") or [] if isinstance(item, dict) and item.get("route")}
194
+ seen = set()
195
+ for item in evidence.get("pages") or []:
196
+ if not isinstance(item, dict) or not item.get("route"):
197
+ errors.append("each rendered page evidence row needs a route")
198
+ continue
199
+ route = str(item["route"])
200
+ seen.add(route)
201
+ expected = entries.get(route)
202
+ if not expected:
203
+ errors.append(f"rendered route is absent from site structure model: {route}")
204
+ continue
205
+ for field in ("template", "source"):
206
+ if expected.get(field) != item.get(field):
207
+ errors.append(f"{route}: {field} does not match the model")
208
+ if set(expected.get("bands") or []) != set(item.get("bands") or []):
209
+ errors.append(f"{route}: band identities do not match the model")
210
+ for route in sorted(set(entries) - seen):
211
+ errors.append(f"model route has no rendered evidence: {route}")
212
+ return emit_and_exit(result("maggie-site-structure.v1", errors, checkedRoutes=sorted(seen)), Path(args.output) if args.output else None)
213
+
214
+
215
+ def validate_capabilities(args: argparse.Namespace) -> int:
216
+ manifest = read_json(Path(args.manifest))
217
+ errors: list[str] = []
218
+ if manifest.get("schemaVersion") != "maggie-host-capabilities.v1":
219
+ errors.append("manifest schemaVersion must be maggie-host-capabilities.v1")
220
+ endpoints = manifest.get("endpoints")
221
+ if not isinstance(endpoints, list) or not endpoints:
222
+ errors.append("endpoints must be a non-empty array")
223
+ endpoints = []
224
+ seen: set[str] = set()
225
+ for endpoint in endpoints:
226
+ if not isinstance(endpoint, dict):
227
+ errors.append("each capability endpoint must be an object")
228
+ continue
229
+ ident = str(endpoint.get("id") or "")
230
+ if not ident or ident in seen:
231
+ errors.append("capability endpoint ids must be non-empty and unique")
232
+ seen.add(ident)
233
+ if not isinstance(endpoint.get("enabled"), bool):
234
+ errors.append(f"{ident or 'unknown'}: enabled must be boolean")
235
+ if not str(endpoint.get("path") or "").startswith("/"):
236
+ errors.append(f"{ident or 'unknown'}: path must start with /")
237
+ return emit_and_exit(result("maggie-host-capabilities.v1", errors, enabled=sum(1 for item in endpoints if isinstance(item, dict) and item.get("enabled") is True), endpointCount=len(endpoints)), Path(args.output) if args.output else None)
238
+
239
+
240
+ def validate_style_editing(args: argparse.Namespace) -> int:
241
+ """Validate a closed, host-rendered style editing contract."""
242
+ manifest = read_json(Path(args.manifest))
243
+ errors: list[str] = []
244
+ if manifest.get("schemaVersion") != "maggie-style-editing.v1":
245
+ errors.append("manifest schemaVersion must be maggie-style-editing.v1")
246
+ properties = manifest.get("properties")
247
+ if not isinstance(properties, list) or not properties:
248
+ errors.append("properties must be a non-empty array")
249
+ properties = []
250
+ allowed: set[str] = set()
251
+ patterns: dict[str, str] = {}
252
+ dangerous = re.compile(r"(?:;|url\s*\(|expression\s*\(|<|>|javascript:)", re.I)
253
+ for item in properties:
254
+ if not isinstance(item, dict):
255
+ errors.append("each style property must be an object")
256
+ continue
257
+ name = str(item.get("name") or "")
258
+ pattern = str(item.get("pattern") or "")
259
+ if not re.fullmatch(r"[A-Za-z][A-Za-z0-9-]*", name):
260
+ errors.append(f"invalid style property name: {name or 'unknown'}")
261
+ if name in allowed:
262
+ errors.append(f"duplicate style property: {name}")
263
+ allowed.add(name)
264
+ if not pattern:
265
+ errors.append(f"style property {name or 'unknown'} needs a value pattern")
266
+ else:
267
+ try:
268
+ re.compile(pattern)
269
+ patterns[name] = pattern
270
+ except re.error:
271
+ errors.append(f"style property {name or 'unknown'} has an invalid value pattern")
272
+ for template in manifest.get("templates") or []:
273
+ if not isinstance(template, dict) or not str(template.get("id") or ""):
274
+ errors.append("each style template needs an id")
275
+ continue
276
+ for name, value in (template.get("styles") or {}).items():
277
+ if name not in allowed:
278
+ errors.append(f"style template {template['id']} uses undeclared property: {name}")
279
+ continue
280
+ if not isinstance(value, str) or dangerous.search(value) or not re.fullmatch(patterns[name], value):
281
+ errors.append(f"style template {template['id']} has an unsafe value for {name}")
282
+ return emit_and_exit(result("maggie-style-editing.v1", errors, properties=sorted(allowed), templates=len(manifest.get("templates") or [])), Path(args.output) if args.output else None)
283
+
284
+
285
+ def validate_content_overrides(args: argparse.Namespace) -> int:
286
+ manifest = read_json(Path(args.manifest))
287
+ errors: list[str] = []
288
+ if manifest.get("schemaVersion") != "maggie-content-overrides.v1":
289
+ errors.append("manifest schemaVersion must be maggie-content-overrides.v1")
290
+ expected = {
291
+ "readAtRequestTime": True,
292
+ "sourceLiteralIsFallback": True,
293
+ "writesCreateRevision": True,
294
+ "conflictRequiresSourceRevision": True,
295
+ "orphanPolicy": "report-and-never-publish",
296
+ }
297
+ for key, value in expected.items():
298
+ if manifest.get(key) != value:
299
+ errors.append(f"{key} must be {value!r}")
300
+ if not isinstance(manifest.get("protectedFields"), list) or not manifest.get("protectedFields"):
301
+ errors.append("protectedFields must list factual fields that cannot be overridden")
302
+ return emit_and_exit(result("maggie-content-overrides.v1", errors, protectedFields=manifest.get("protectedFields", [])), Path(args.output) if args.output else None)
303
+
304
+
305
+ def validate_session_contract(args: argparse.Namespace) -> int:
306
+ manifest = read_json(Path(args.manifest))
307
+ errors: list[str] = []
308
+ if manifest.get("schemaVersion") != "maggie-browser-session.v1":
309
+ errors.append("manifest schemaVersion must be maggie-browser-session.v1")
310
+ if manifest.get("mintingBoundary") != "host-adapter":
311
+ errors.append("mintingBoundary must be host-adapter")
312
+ if manifest.get("passwordHandling") != "never-exposed":
313
+ errors.append("passwordHandling must be never-exposed")
314
+ ttl = manifest.get("ttlMinutes")
315
+ if not isinstance(ttl, int) or not 1 <= ttl <= 240:
316
+ errors.append("ttlMinutes must be between 1 and 240")
317
+ for key in ("mintRoute", "revokeRoute"):
318
+ if not str(manifest.get(key) or "").startswith("/"):
319
+ errors.append(f"{key} must be a relative host route")
320
+ return emit_and_exit(result("maggie-browser-session.v1", errors, ttlMinutes=ttl), Path(args.output) if args.output else None)
321
+
322
+
323
+ def validate_cache_boundary(args: argparse.Namespace) -> int:
324
+ evidence = read_json(Path(args.evidence))
325
+ errors: list[str] = []
326
+ if evidence.get("schemaVersion") != "maggie-public-session-cache.v1":
327
+ errors.append("evidence schemaVersion must be maggie-public-session-cache.v1")
328
+ if evidence.get("publicHtmlVariesBySession") is not False:
329
+ errors.append("publicHtmlVariesBySession must be false")
330
+ if evidence.get("sessionCookieHttpOnly") is not True:
331
+ errors.append("sessionCookieHttpOnly must be true")
332
+ if evidence.get("identityHintCookieReadable") is not True:
333
+ errors.append("identityHintCookieReadable must be true")
334
+ if evidence.get("identityRequestWhenHintAbsent") is not False:
335
+ errors.append("identityRequestWhenHintAbsent must be false")
336
+ return emit_and_exit(result("maggie-public-session-cache.v1", errors), Path(args.output) if args.output else None)
337
+
338
+
339
+ def main() -> int:
340
+ parser = argparse.ArgumentParser(prog="maggie contracts")
341
+ sub = parser.add_subparsers(dest="command", required=True)
342
+ progressive = sub.add_parser("progressive-check")
343
+ progressive.add_argument("--evidence", required=True); progressive.add_argument("--output")
344
+ progressive.set_defaults(func=validate_progressive)
345
+ css = sub.add_parser("css-check")
346
+ css.add_argument("--evidence"); css.add_argument("--html"); css.add_argument("--css"); css.add_argument("--required-class", action="append", default=[]); css.add_argument("--output")
347
+ css.set_defaults(func=validate_css)
348
+ cascade = sub.add_parser("css-cascade-check")
349
+ cascade.add_argument("--evidence", required=True); cascade.add_argument("--output")
350
+ cascade.set_defaults(func=validate_css)
351
+ dash_init = sub.add_parser("dash-init")
352
+ dash_init.add_argument("--project", default="."); dash_init.add_argument("--route", required=True); dash_init.add_argument("--confirm", action="store_true")
353
+ dash_init.set_defaults(func=lambda args: init_plan(args, "dashboard", {"route": args.route, "operatorJourneys": ["first-person task completion"], "shell": {"rail": True, "workArea": True, "tabs": True, "modal": True}, "requiredEvidence": ["desktop", "tablet", "mobile", "collapsed-rail", "mobile-drawer", "modal"], "publicWrite": False}))
354
+ dash_validate = sub.add_parser("dash-validate")
355
+ dash_validate.add_argument("--plan", required=True); dash_validate.add_argument("--evidence", required=True); dash_validate.add_argument("--output")
356
+ dash_validate.set_defaults(func=validate_dashboard)
357
+ sample_init = sub.add_parser("sample-init")
358
+ sample_init.add_argument("--project", default="."); sample_init.add_argument("--route", required=True); sample_init.add_argument("--purpose", required=True); sample_init.add_argument("--confirm", action="store_true")
359
+ sample_init.set_defaults(func=lambda args: init_plan(args, "sample", {"route": args.route, "purpose": args.purpose, "noindex": True, "sitemapIncluded": False, "bannerRequired": True, "placeholderPolicy": "unwritten lines must remain explicit placeholders", "finishChecklist": ["replace placeholder copy", "replace media", "run accessibility and SEO checks"], "publicWrite": False}))
360
+ sample_validate = sub.add_parser("sample-validate")
361
+ sample_validate.add_argument("--plan", required=True); sample_validate.add_argument("--evidence", required=True); sample_validate.add_argument("--output")
362
+ sample_validate.set_defaults(func=validate_sample)
363
+ privacy = sub.add_parser("privacy-check")
364
+ privacy.add_argument("--evidence", required=True); privacy.add_argument("--output")
365
+ privacy.set_defaults(func=validate_privacy)
366
+ structure = sub.add_parser("structure-validate")
367
+ structure.add_argument("--model", required=True); structure.add_argument("--evidence", required=True); structure.add_argument("--output")
368
+ structure.set_defaults(func=validate_structure)
369
+ capabilities = sub.add_parser("capabilities-validate")
370
+ capabilities.add_argument("--manifest", required=True); capabilities.add_argument("--output")
371
+ capabilities.set_defaults(func=validate_capabilities)
372
+ style = sub.add_parser("style-check")
373
+ style.add_argument("--manifest", required=True); style.add_argument("--output")
374
+ style.set_defaults(func=validate_style_editing)
375
+ overrides = sub.add_parser("content-overrides-check")
376
+ overrides.add_argument("--manifest", required=True); overrides.add_argument("--output")
377
+ overrides.set_defaults(func=validate_content_overrides)
378
+ session = sub.add_parser("session-check")
379
+ session.add_argument("--manifest", required=True); session.add_argument("--output")
380
+ session.set_defaults(func=validate_session_contract)
381
+ cache = sub.add_parser("cache-boundary-check")
382
+ cache.add_argument("--evidence", required=True); cache.add_argument("--output")
383
+ cache.set_defaults(func=validate_cache_boundary)
384
+ args = parser.parse_args()
385
+ try:
386
+ return args.func(args)
387
+ except (OSError, ValueError, json.JSONDecodeError) as error:
388
+ print(f"BLOCKED: maggie contract validation: {error}", file=sys.stderr)
389
+ return 1
390
+
391
+
392
+ if __name__ == "__main__":
393
+ raise SystemExit(main())
@@ -192,9 +192,10 @@ def merge_astro_middleware(path: Path) -> dict[str, object]:
192
192
  """Add the stable Maggie paths to an existing conventional Astro middleware.
193
193
 
194
194
  The installer must preserve host-owned auth/redirect logic. This narrow
195
- merge only handles the common `onRequest = defineMiddleware((context,
196
- next) => { ... })` shape and is idempotent. Unknown middleware shapes are
197
- reported to the caller instead of being rewritten heuristically.
195
+ merge handles the common `onRequest = defineMiddleware((context, next) =>
196
+ { ... })` shape and the equivalent destructured context form. Unknown
197
+ middleware shapes are reported to the caller instead of being rewritten
198
+ heuristically.
198
199
  """
199
200
  try:
200
201
  content = path.read_text(encoding="utf-8")
@@ -209,8 +210,6 @@ def merge_astro_middleware(path: Path) -> dict[str, object]:
209
210
  r"export\s+const\s+onRequest\s*=\s*defineMiddleware\(\s*\(\s*context\s*,\s*next\s*\)\s*=>\s*\{",
210
211
  content,
211
212
  )
212
- if not match:
213
- return {"status": "manual", "reason": "middleware is not the supported defineMiddleware((context, next) => {}) shape"}
214
213
  block = r'''
215
214
  // maggie-auto-rewrite-v1: generated by `maggie booking install`; keep host auth below.
216
215
  const maggieRewrites: Array<[RegExp, (path: string) => string]> = [
@@ -228,7 +227,24 @@ def merge_astro_middleware(path: Path) -> dict[str, object]:
228
227
  }
229
228
  }
230
229
  '''
231
- merged = content[:match.end()] + block + content[match.end():]
230
+ if match:
231
+ merged = content[:match.end()] + block + content[match.end():]
232
+ else:
233
+ # Astro examples commonly destructure the request context in the
234
+ # parameter list: `(({ url, request }, next) => { ... })`. Normalize
235
+ # only this explicit shape so the generated rewrite block can use the
236
+ # full context while preserving the host's existing variable names.
237
+ destructured = re.search(
238
+ r"(?P<prefix>export\s+const\s+onRequest\s*=\s*defineMiddleware\(\s*\()\s*\{(?P<properties>[^{}]+)\}\s*,\s*(?P<next>[A-Za-z_$][\w$]*)\s*\)\s*=>\s*\{",
239
+ content,
240
+ )
241
+ if not destructured:
242
+ return {"status": "manual", "reason": "middleware is not a supported defineMiddleware((context, next) => {}) shape"}
243
+ properties = destructured.group("properties").strip()
244
+ next_name = destructured.group("next")
245
+ replacement = f"{destructured.group('prefix')}context, {next_name}) => {{\n const {{ {properties} }} = context;"
246
+ normalized = content[:destructured.start()] + replacement + content[destructured.end():]
247
+ merged = normalized[:destructured.start() + len(replacement)] + block + normalized[destructured.start() + len(replacement):]
232
248
  try:
233
249
  path.write_text(merged, encoding="utf-8")
234
250
  except OSError as error:
@@ -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))
@@ -532,9 +577,31 @@ def design_job_path(project: Path, job_id: str) -> Path:
532
577
  return project / ".maggie" / "design-jobs" / f"{job_id}.json"
533
578
 
534
579
 
535
- def save_design_job(project: Path, job: dict[str, object]) -> None:
580
+ def design_job_candidates(project: Path, job_id: str) -> list[Path]:
581
+ """Return every supported location for a resumable design job.
582
+
583
+ In-place jobs historically live under ``design-jobs`` while first-party
584
+ author jobs are briefs under ``.maggie/design``. Both are Maggie design
585
+ jobs and the progress commands must treat them identically.
586
+ """
587
+ root = project.resolve() / ".maggie"
588
+ return [
589
+ root / "design-jobs" / f"{job_id}.json",
590
+ root / "design" / f"{job_id}.json",
591
+ ]
592
+
593
+
594
+ def find_design_job_path(project: Path, job_id: str) -> Path:
595
+ for candidate in design_job_candidates(project, job_id):
596
+ if candidate.is_file():
597
+ return candidate
598
+ expected = design_job_path(project.resolve(), job_id)
599
+ raise ValueError(f"design job not found: {expected}")
600
+
601
+
602
+ def save_design_job(project: Path, job: dict[str, object], path: Path | None = None) -> None:
536
603
  job["updated_at"] = datetime.now(timezone.utc).isoformat()
537
- path = design_job_path(project, str(job["id"]))
604
+ path = path or design_job_path(project, str(job["id"]))
538
605
  path.parent.mkdir(parents=True, exist_ok=True)
539
606
  path.write_text(json.dumps(job, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
540
607
 
@@ -599,17 +666,13 @@ def run_design_job(urls: list[str], project: Path, clone_run: str, force: bool =
599
666
 
600
667
 
601
668
  def design_status(project: Path, job_id: str) -> int:
602
- path = design_job_path(project.resolve(), job_id)
603
- if not path.exists():
604
- raise ValueError(f"design job not found: {path}")
669
+ path = find_design_job_path(project, job_id)
605
670
  print(path.read_text(encoding="utf-8"), end="")
606
671
  return 0
607
672
 
608
673
 
609
674
  def design_resume(project: Path, job_id: str) -> int:
610
- path = design_job_path(project.resolve(), job_id)
611
- if not path.exists():
612
- raise ValueError(f"design job not found: {path}")
675
+ path = find_design_job_path(project, job_id)
613
676
  job = json.loads(path.read_text(encoding="utf-8"))
614
677
  return run_design_job(job["urls"], project, job["clone_run"], force=True)[0]
615
678
 
@@ -620,9 +683,7 @@ def _step_records(names: list[str]) -> list[dict[str, object]]:
620
683
 
621
684
  def design_step(project: Path, job_id: str, step: str, evidence: str) -> int:
622
685
  """Record one piece of implementation evidence and make progress durable."""
623
- path = design_job_path(project.resolve(), job_id)
624
- if not path.exists():
625
- raise ValueError(f"design job not found: {path}")
686
+ path = find_design_job_path(project, job_id)
626
687
  job = json.loads(path.read_text(encoding="utf-8"))
627
688
  steps = job.get("steps", [])
628
689
  target = next((item for item in steps if item.get("name") == step), None)
@@ -774,8 +835,9 @@ def author_job(project: Path, route: str, purpose: str, audience: str, brief_fil
774
835
  brief = {"schemaVersion": "maggie-page-brief.v1", "route": route, "purpose": purpose.strip() or source_brief["purpose"], "audience": audience.strip() or source_brief["audience"], "contentBrief": source_brief.get("contentBrief", ""), "shell": "homepage-canonical", "status": "draft", "sourceEvidence": None, "approval": {"status": "pending", "actor": None}, "createdAt": datetime.now(timezone.utc).isoformat()}
775
836
  digest = hashlib.sha256((str(project) + "\n" + route).encode()).hexdigest()[:12]
776
837
  output = project / ".maggie" / "design" / "briefs" / f"{relative.replace('/', '-')}.json"; output.parent.mkdir(parents=True, exist_ok=True); output.write_text(json.dumps(brief, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
838
+ required_steps = ["inspect-shell", "implement-page", "capture-responsive-screenshots", "accessibility-check", "build-check", "approval"]
777
839
  plan_path = project / ".maggie" / "design" / f"author-{digest}.json"
778
- plan_path.write_text(json.dumps({"id": f"author-{digest}", "workflow": "maggie-design", "mode": "author", "phase": "ready", "brief": str(output), "designContract": contract, "sourceUrlRequired": False, "shellSourceOfTruth": "homepage-canonical", "approvalRequired": True, "requiredSteps": ["inspect-shell", "implement-page", "capture-responsive-screenshots", "accessibility-check", "build-check", "approval"]}, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
840
+ plan_path.write_text(json.dumps({"id": f"author-{digest}", "workflow": "maggie-design", "mode": "author", "phase": "ready", "brief": str(output), "designContract": contract, "sourceUrlRequired": False, "shellSourceOfTruth": "homepage-canonical", "approvalRequired": True, "requiredSteps": required_steps, "required_steps": required_steps, "steps": _step_records(required_steps), "history": [{"phase": "ready", "at": datetime.now(timezone.utc).isoformat()}]}, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
779
841
  print(json.dumps({"brief": str(output), "plan": str(plan_path), "phase": "ready"}, indent=2)); return 0
780
842
 
781
843
 
@@ -791,6 +853,7 @@ commands:
791
853
  status show a design job and its per-step progress
792
854
  resume restart a failed design workflow
793
855
  author plan an original first-party page
856
+ markup-check validate optional style attributes, content keys, and entities
794
857
  reference-ui, init, validate-ui, app-init, app-validate, rebrand, review
795
858
  """)
796
859
  return 0
@@ -806,6 +869,15 @@ commands:
806
869
  return reference_ui(args.project, args.reference, _surface_list(args.surface), args.screenshots_dir, args.confirm)
807
870
  except (OSError, ValueError, json.JSONDecodeError) as error:
808
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
809
881
  if len(sys.argv) > 1 and sys.argv[1] == "init":
810
882
  command = argparse.ArgumentParser(description="Initialize a native blog or service UI plan from a sanitized reference manifest.")
811
883
  command.add_argument("--project", type=Path, default=Path.cwd())