@topy-ai/maggie 0.7.37 → 0.7.40

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 (67) hide show
  1. package/README-zh-TW.md +65 -7
  2. package/README.md +80 -11
  3. package/bin/maggie.js +10 -2
  4. package/bundled-contracts/maggie-design/css-utility-evidence-v1.schema.json +10 -0
  5. package/bundled-contracts/maggie-design/dashboard-surface-v1.schema.json +11 -0
  6. package/bundled-contracts/maggie-design/progressive-enhancement-v1.schema.json +12 -0
  7. package/bundled-contracts/maggie-design/sample-surface-v1.schema.json +13 -0
  8. package/bundled-contracts/maggie-seo/privacy-origin-evidence-v1.schema.json +11 -0
  9. package/bundled-contracts/maggiedash/README.md +1 -1
  10. package/bundled-contracts/maggiedash/booking-access-v1.json +5 -4
  11. package/bundled-contracts/maggiedash/booking-customer-surface-v1.json +12 -1
  12. package/bundled-contracts/maggiedash/booking-email-templates-v1.json +2 -0
  13. package/bundled-contracts/maggiedash/booking-host-adapter-v1.json +16 -2
  14. package/bundled-contracts/maggiedash/booking-runtime.v1.json +41 -0
  15. package/bundled-contracts/maggiedash/execution-board.json +529 -28
  16. package/bundled-contracts/maggiedash/host-capabilities-v1.schema.json +10 -0
  17. package/bundled-contracts/maggiedash/site-structure-v1.schema.json +13 -0
  18. package/bundled-references/maggiedash-booking/ARCHITECTURE.md +218 -0
  19. package/bundled-references/maggiedash-booking/CURRENT-STATE.md +92 -0
  20. package/bundled-references/maggiedash-booking/DATA-FLOW.md +143 -0
  21. package/bundled-references/maggiedash-booking/DATA-MODEL.md +367 -0
  22. package/bundled-references/maggiedash-booking/DECISIONS.md +94 -0
  23. package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +2387 -0
  24. package/bundled-references/maggiedash-booking/HOST-ADAPTER.md +314 -0
  25. package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +227 -0
  26. package/bundled-references/maggiedash-booking/PAYMENT-GATEWAY.md +267 -0
  27. package/bundled-references/maggiedash-booking/PRD.md +228 -0
  28. package/bundled-references/maggiedash-booking/PROGRESS.md +2434 -0
  29. package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +235 -0
  30. package/bundled-references/maggiedash-booking/README.md +271 -0
  31. package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +152 -0
  32. package/bundled-references/maggiedash-booking/SECURITY-COMPLIANCE.md +158 -0
  33. package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +542 -0
  34. package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +129 -0
  35. package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +107 -0
  36. package/bundled-references/maggiedash-booking/TASKS.md +137 -0
  37. package/bundled-references/maggiedash-booking/USER-JOURNEYS.md +224 -0
  38. package/bundled-references/maggiedash-booking/diagrams/booking-dfd.excalidraw +1 -0
  39. package/bundled-references/maggiedash-booking/diagrams/booking-dfd.mmd +16 -0
  40. package/bundled-references/maggiedash-booking/diagrams/booking-dfd.png +0 -0
  41. package/bundled-references/maggiedash-booking/diagrams/booking-dfd.svg +1 -0
  42. package/bundled-references/maggiedash-booking/diagrams/booking-state-machine.mmd +19 -0
  43. package/bundled-references/maggiedash-booking/diagrams/booking-state-machine.png +0 -0
  44. package/bundled-references/maggiedash-booking/diagrams/booking-state-machine.svg +1 -0
  45. package/bundled-references/maggiedash-booking/diagrams/manager-journey.excalidraw +1 -0
  46. package/bundled-references/maggiedash-booking/diagrams/manager-journey.mmd +11 -0
  47. package/bundled-references/maggiedash-booking/diagrams/manager-journey.png +0 -0
  48. package/bundled-references/maggiedash-booking/diagrams/manager-journey.svg +1 -0
  49. package/bundled-references/maggiedash-booking/diagrams/payment-sequence.mmd +20 -0
  50. package/bundled-references/maggiedash-booking/diagrams/payment-sequence.png +0 -0
  51. package/bundled-references/maggiedash-booking/diagrams/payment-sequence.svg +1 -0
  52. package/bundled-references/maggiedash-booking/diagrams/system-context.excalidraw +1 -0
  53. package/bundled-references/maggiedash-booking/diagrams/system-context.mmd +10 -0
  54. package/bundled-references/maggiedash-booking/diagrams/system-context.png +0 -0
  55. package/bundled-references/maggiedash-booking/diagrams/system-context.svg +1 -0
  56. package/bundled-skills/maggie-blog-bootstrap/SKILL.md +16 -0
  57. package/bundled-skills/maggie-booking/SKILL.md +103 -22
  58. package/bundled-skills/maggie-design/SKILL.md +35 -0
  59. package/bundled-skills/maggie-seo-geo/SKILL.md +12 -0
  60. package/bundled-skills/maggie-service-booking/SKILL.md +14 -0
  61. package/bundled-tools/clis/maggie_booking.py +231 -31
  62. package/bundled-tools/clis/maggie_contracts.py +282 -0
  63. package/bundled-tools/clis/maggie_dash.py +28 -6
  64. package/bundled-tools/clis/maggie_design.py +29 -12
  65. package/bundled-tools/clis/maggie_service_booking.py +50 -1
  66. package/bundled-tools/clis/site_audit.py +49 -0
  67. package/package.json +1 -1
@@ -0,0 +1,282 @@
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 main() -> int:
241
+ parser = argparse.ArgumentParser(prog="maggie contracts")
242
+ sub = parser.add_subparsers(dest="command", required=True)
243
+ progressive = sub.add_parser("progressive-check")
244
+ progressive.add_argument("--evidence", required=True); progressive.add_argument("--output")
245
+ progressive.set_defaults(func=validate_progressive)
246
+ css = sub.add_parser("css-check")
247
+ css.add_argument("--evidence"); css.add_argument("--html"); css.add_argument("--css"); css.add_argument("--required-class", action="append", default=[]); css.add_argument("--output")
248
+ css.set_defaults(func=validate_css)
249
+ cascade = sub.add_parser("css-cascade-check")
250
+ cascade.add_argument("--evidence", required=True); cascade.add_argument("--output")
251
+ cascade.set_defaults(func=validate_css)
252
+ dash_init = sub.add_parser("dash-init")
253
+ dash_init.add_argument("--project", default="."); dash_init.add_argument("--route", required=True); dash_init.add_argument("--confirm", action="store_true")
254
+ 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}))
255
+ dash_validate = sub.add_parser("dash-validate")
256
+ dash_validate.add_argument("--plan", required=True); dash_validate.add_argument("--evidence", required=True); dash_validate.add_argument("--output")
257
+ dash_validate.set_defaults(func=validate_dashboard)
258
+ sample_init = sub.add_parser("sample-init")
259
+ 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")
260
+ 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}))
261
+ sample_validate = sub.add_parser("sample-validate")
262
+ sample_validate.add_argument("--plan", required=True); sample_validate.add_argument("--evidence", required=True); sample_validate.add_argument("--output")
263
+ sample_validate.set_defaults(func=validate_sample)
264
+ privacy = sub.add_parser("privacy-check")
265
+ privacy.add_argument("--evidence", required=True); privacy.add_argument("--output")
266
+ privacy.set_defaults(func=validate_privacy)
267
+ structure = sub.add_parser("structure-validate")
268
+ structure.add_argument("--model", required=True); structure.add_argument("--evidence", required=True); structure.add_argument("--output")
269
+ structure.set_defaults(func=validate_structure)
270
+ capabilities = sub.add_parser("capabilities-validate")
271
+ capabilities.add_argument("--manifest", required=True); capabilities.add_argument("--output")
272
+ capabilities.set_defaults(func=validate_capabilities)
273
+ args = parser.parse_args()
274
+ try:
275
+ return args.func(args)
276
+ except (OSError, ValueError, json.JSONDecodeError) as error:
277
+ print(f"BLOCKED: maggie contract validation: {error}", file=sys.stderr)
278
+ return 1
279
+
280
+
281
+ if __name__ == "__main__":
282
+ 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:
@@ -262,6 +278,12 @@ def manifest_host_file_pairs(source: Path, manifest: dict[str, object], root: Pa
262
278
  for path in sorted(source_item.rglob("*")):
263
279
  if path.is_file():
264
280
  child = path.relative_to(source_item)
281
+ # A deployment config is host-owned. The default Node config is
282
+ # useful for a blank Astro project, but adding a second config to
283
+ # a host that already has astro.config.ts/cloudflare/etc. can make
284
+ # Astro ambiguous or change its deployment target.
285
+ if framework == "astro" and child.name == "astro.config.mjs" and any(root.glob("astro.config.*")):
286
+ continue
265
287
  pairs.append((path, root / target_relative / child, source_relative / child))
266
288
  return pairs
267
289
 
@@ -532,9 +532,31 @@ def design_job_path(project: Path, job_id: str) -> Path:
532
532
  return project / ".maggie" / "design-jobs" / f"{job_id}.json"
533
533
 
534
534
 
535
- def save_design_job(project: Path, job: dict[str, object]) -> None:
535
+ def design_job_candidates(project: Path, job_id: str) -> list[Path]:
536
+ """Return every supported location for a resumable design job.
537
+
538
+ In-place jobs historically live under ``design-jobs`` while first-party
539
+ author jobs are briefs under ``.maggie/design``. Both are Maggie design
540
+ jobs and the progress commands must treat them identically.
541
+ """
542
+ root = project.resolve() / ".maggie"
543
+ return [
544
+ root / "design-jobs" / f"{job_id}.json",
545
+ root / "design" / f"{job_id}.json",
546
+ ]
547
+
548
+
549
+ def find_design_job_path(project: Path, job_id: str) -> Path:
550
+ for candidate in design_job_candidates(project, job_id):
551
+ if candidate.is_file():
552
+ return candidate
553
+ expected = design_job_path(project.resolve(), job_id)
554
+ raise ValueError(f"design job not found: {expected}")
555
+
556
+
557
+ def save_design_job(project: Path, job: dict[str, object], path: Path | None = None) -> None:
536
558
  job["updated_at"] = datetime.now(timezone.utc).isoformat()
537
- path = design_job_path(project, str(job["id"]))
559
+ path = path or design_job_path(project, str(job["id"]))
538
560
  path.parent.mkdir(parents=True, exist_ok=True)
539
561
  path.write_text(json.dumps(job, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
540
562
 
@@ -599,17 +621,13 @@ def run_design_job(urls: list[str], project: Path, clone_run: str, force: bool =
599
621
 
600
622
 
601
623
  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}")
624
+ path = find_design_job_path(project, job_id)
605
625
  print(path.read_text(encoding="utf-8"), end="")
606
626
  return 0
607
627
 
608
628
 
609
629
  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}")
630
+ path = find_design_job_path(project, job_id)
613
631
  job = json.loads(path.read_text(encoding="utf-8"))
614
632
  return run_design_job(job["urls"], project, job["clone_run"], force=True)[0]
615
633
 
@@ -620,9 +638,7 @@ def _step_records(names: list[str]) -> list[dict[str, object]]:
620
638
 
621
639
  def design_step(project: Path, job_id: str, step: str, evidence: str) -> int:
622
640
  """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}")
641
+ path = find_design_job_path(project, job_id)
626
642
  job = json.loads(path.read_text(encoding="utf-8"))
627
643
  steps = job.get("steps", [])
628
644
  target = next((item for item in steps if item.get("name") == step), None)
@@ -774,8 +790,9 @@ def author_job(project: Path, route: str, purpose: str, audience: str, brief_fil
774
790
  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
791
  digest = hashlib.sha256((str(project) + "\n" + route).encode()).hexdigest()[:12]
776
792
  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")
793
+ required_steps = ["inspect-shell", "implement-page", "capture-responsive-screenshots", "accessibility-check", "build-check", "approval"]
777
794
  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")
795
+ 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
796
  print(json.dumps({"brief": str(output), "plan": str(plan_path), "phase": "ready"}, indent=2)); return 0
780
797
 
781
798
 
@@ -222,7 +222,24 @@ def make_record(provider, source, pid, name, description, variants, booking, lin
222
222
  if not booking:
223
223
  for href,_ in links:
224
224
  if any(x in href.lower() for x in ("book","appointment","checkout")): booking=urljoin(source,href); break
225
- return {"id":f"{provider}:{pid}","provider":provider,"providerServiceId":pid,"slug":slug(name),"title":name,"description":description.strip(),"sourceUrl":source,"category":{"level1":"Uncategorised","level2":None},"variants":variants,"bookingUrl":booking,"paymentUrl":None,"status":"active","supplyState":"live","displayState":"published","firstSeenAt":NOW(),"lastSeenAt":NOW()}
225
+ record = {"id":f"{provider}:{pid}","provider":provider,"providerServiceId":pid,"slug":slug(name),"title":name,"description":description.strip(),"sourceUrl":source,"category":{"level1":"Uncategorised","level2":None},"variants":variants,"bookingUrl":booking,"paymentUrl":None,"status":"active","supplyState":"live","displayState":"published","firstSeenAt":NOW(),"lastSeenAt":NOW()}
226
+ # A treatment is one catalogue entry. Duration/price choices belong to
227
+ # variants so the same treatment is not presented as four products.
228
+ durations = [int(item["durationMinutes"]) for item in variants if isinstance(item, dict) and isinstance(item.get("durationMinutes"), (int, float)) and item["durationMinutes"] > 0]
229
+ priced = [item.get("price", {}) for item in variants if isinstance(item, dict) and isinstance(item.get("price"), dict) and isinstance(item.get("price", {}).get("amountMinor"), int)]
230
+ currencies = {item.get("currency") for item in priced if item.get("currency")}
231
+ from_price = None
232
+ if priced and len(currencies) == 1:
233
+ lowest = min(priced, key=lambda item: item["amountMinor"])
234
+ from_price = {"amountMinor": lowest["amountMinor"], "currency": lowest["currency"]}
235
+ record["treatmentGroup"] = {
236
+ "id": record["id"],
237
+ "title": name,
238
+ "variantCount": len(variants),
239
+ "durationRange": {"minMinutes": min(durations), "maxMinutes": max(durations)} if durations else None,
240
+ "fromPrice": from_price,
241
+ }
242
+ return record
226
243
 
227
244
  def root(args): return Path(args.project).resolve()
228
245
  def path(project): return project/".maggie"/"booking"/"services.json"
@@ -1263,6 +1280,36 @@ def cmd_resolver_audit(args):
1263
1280
  print(json.dumps({"status": report["status"], "report": str(output), "counts": counts, "errors": errors}, indent=2)); return 0 if not errors else 1
1264
1281
 
1265
1282
 
1283
+ def cmd_treatment_group_audit(args):
1284
+ """Require one treatment record with explicit duration/price variants."""
1285
+ data = load(root(args))
1286
+ errors = []
1287
+ groups = {}
1288
+ for service in data.get("services", []):
1289
+ group = service.get("treatmentGroup") if isinstance(service.get("treatmentGroup"), dict) else {}
1290
+ group_id = str(group.get("id") or "")
1291
+ if not group_id:
1292
+ errors.append(f"{service.get('id', 'unknown')}: missing treatmentGroup.id")
1293
+ continue
1294
+ if group_id in groups:
1295
+ errors.append(f"duplicate treatment group: {group_id}")
1296
+ groups[group_id] = service.get("id")
1297
+ variants = service.get("variants") if isinstance(service.get("variants"), list) else []
1298
+ if not variants:
1299
+ errors.append(f"{service.get('id', 'unknown')}: treatment group has no variants")
1300
+ if group.get("variantCount") != len(variants):
1301
+ errors.append(f"{service.get('id', 'unknown')}: treatmentGroup.variantCount is stale")
1302
+ durations = [item.get("durationMinutes") for item in variants if isinstance(item, dict) and isinstance(item.get("durationMinutes"), (int, float))]
1303
+ duration_range = group.get("durationRange")
1304
+ if durations and (not isinstance(duration_range, dict) or duration_range.get("minMinutes") != min(durations) or duration_range.get("maxMinutes") != max(durations)):
1305
+ errors.append(f"{service.get('id', 'unknown')}: treatmentGroup.durationRange is stale")
1306
+ report = {"schemaVersion": "maggie-treatment-groups.v1", "passed": not errors, "errors": errors, "treatments": len(groups), "variants": sum(len(item.get("variants", [])) for item in data.get("services", []))}
1307
+ if args.output:
1308
+ output = (root(args) / args.output).resolve() if not Path(args.output).is_absolute() else Path(args.output)
1309
+ output.parent.mkdir(parents=True, exist_ok=True); output.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
1310
+ print(json.dumps(report, indent=2, ensure_ascii=False))
1311
+ return 0 if report["passed"] else 1
1312
+
1266
1313
  def main():
1267
1314
  p=argparse.ArgumentParser(); sub=p.add_subparsers(dest="command",required=True)
1268
1315
  for name in ("import","sync","run"):
@@ -1279,6 +1326,7 @@ def main():
1279
1326
  q=sub.add_parser("category-hash"); q.add_argument("--project",default="."); q.add_argument("--copy-data",required=True); q.add_argument("--apply",action="store_true",help="backfill deterministic provenance hashes in this generated artifact")
1280
1327
  q=sub.add_parser("fact-audit"); q.add_argument("--project",default="."); q.add_argument("--backfill-source",action="store_true",help="copy the catalogue sourceUrl into records that have no sourceUrl"); q.add_argument("--overrides",help="JSON of human-approved, evidenced descriptions for provider gaps"); q.add_argument("--apply-overrides",action="store_true",help="apply only approved fact overrides to the draft catalogue")
1281
1328
  q=sub.add_parser("catalogue-check"); q.add_argument("source"); q.add_argument("--project",default="."); q.add_argument("--provider",default="fresha",choices=["fresha"]); q.add_argument("--treatment",action="append",required=True,help="provider treatment name; repeat for multiple checks")
1329
+ q=sub.add_parser("treatment-group-audit", help="ensure each treatment is one entry with duration/price variants"); q.add_argument("--project",default="."); q.add_argument("--output")
1282
1330
  q=sub.add_parser("retirement-audit"); q.add_argument("--project",default="."); q.add_argument("--catalogue",default=".maggie/booking/services.json"); q.add_argument("--evidence",required=True,help="sanitized runtime retirement evidence JSON"); q.add_argument("--output",default="docs/service-retirement-audit.json")
1283
1331
  q=sub.add_parser("capability-audit", help="validate provider variant declarations and fixture evidence"); q.add_argument("--project",default="."); q.add_argument("--provider"); q.add_argument("--catalogue",default=".maggie/booking/services.json"); q.add_argument("--capabilities-file",default=".maggie/booking/provider-capabilities.json"); q.add_argument("--fixture",help="sanitized provider fixture JSON"); q.add_argument("--output")
1284
1332
  q=sub.add_parser("category-context"); q.add_argument("--project",default="."); q.add_argument("--output")
@@ -1306,6 +1354,7 @@ def main():
1306
1354
  if a.command=="category-hash": return cmd_category_hash(a)
1307
1355
  if a.command=="fact-audit": return cmd_fact_audit(a)
1308
1356
  if a.command=="catalogue-check": return cmd_catalogue_check(a)
1357
+ if a.command=="treatment-group-audit": return cmd_treatment_group_audit(a)
1309
1358
  if a.command=="retirement-audit": return cmd_retirement_audit(a)
1310
1359
  if a.command=="capability-audit": return cmd_capability_audit(a)
1311
1360
  if a.command=="category-context": return cmd_category_context(a)
@@ -69,9 +69,20 @@ class PageParser(HTMLParser):
69
69
  self.paragraphs = []
70
70
  self.links = []
71
71
  self.fragment_ids = set()
72
+ self.primary_navigation_links = []
73
+ self._header_depth = 0
74
+ self._nav_depth = 0
75
+ self._primary_nav_depth = 0
72
76
 
73
77
  def handle_starttag(self, tag, attrs):
74
78
  data = dict(attrs)
79
+ if tag == "header":
80
+ self._header_depth += 1
81
+ if tag == "nav":
82
+ self._nav_depth += 1
83
+ label = f"{data.get('aria-label', '')} {data.get('id', '')} {data.get('class', '')}".lower()
84
+ if self._header_depth or any(token in label for token in ("primary", "main", "navigation")):
85
+ self._primary_nav_depth += 1
75
86
  if data.get("id"):
76
87
  self.fragment_ids.add(data["id"])
77
88
  if tag == "a" and data.get("name"):
@@ -106,6 +117,8 @@ class PageParser(HTMLParser):
106
117
  self._jsonld = []
107
118
  elif tag == "a" and data.get("href"):
108
119
  self.anchors += 1
120
+ if self._primary_nav_depth:
121
+ self.primary_navigation_links.append(data.get("href", ""))
109
122
  elif tag == "img":
110
123
  self.images.append({"src": data.get("src", ""), "alt": data.get("alt")})
111
124
 
@@ -132,6 +145,12 @@ class PageParser(HTMLParser):
132
145
  except json.JSONDecodeError:
133
146
  self.jsonld_values.append(None)
134
147
  self._jsonld = None
148
+ if tag == "nav":
149
+ self._nav_depth = max(0, self._nav_depth - 1)
150
+ if self._primary_nav_depth:
151
+ self._primary_nav_depth -= 1
152
+ elif tag == "header":
153
+ self._header_depth = max(0, self._header_depth - 1)
135
154
 
136
155
  def handle_data(self, data):
137
156
  if not self.hidden_depth and data.strip():
@@ -193,6 +212,7 @@ def audit_page(url: str, html: str, status: int, content_type: str, expected_lan
193
212
  "lang": page.lang, "hreflang": page.hreflang, "robots": robots,
194
213
  "jsonld": page.jsonld_values, "images": page.images,
195
214
  "fragmentIds": sorted(page.fragment_ids),
215
+ "primaryNavigationLinks": page.primary_navigation_links,
196
216
  "structureHash": hashlib.sha256(json.dumps(stable_structure(page.structure), sort_keys=True).encode()).hexdigest(),
197
217
  "textHash": hashlib.sha256(" ".join(page.visible_text).encode()).hexdigest(),
198
218
  },
@@ -215,9 +235,37 @@ def audit_page(url: str, html: str, status: int, content_type: str, expected_lan
215
235
  "robots": not robots or not any(token in {"noindex", "none", "nofollow"} for token in robots_tokens),
216
236
  "robots_directives": robots,
217
237
  "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
+ "primary_navigation_links": page.primary_navigation_links,
218
239
  }
219
240
 
220
241
 
242
+ def primary_navigation_check(base: str, page: PageParser, fetcher=fetch) -> dict:
243
+ """Ensure links presented as primary navigation are indexable destinations."""
244
+ origin = urlparse(base).netloc
245
+ routes = []
246
+ for href in page.primary_navigation_links:
247
+ target = urljoin(base + "/", href)
248
+ parsed = urlparse(target)
249
+ if parsed.scheme not in {"http", "https"} or parsed.netloc != origin:
250
+ continue
251
+ target = urlunparse((parsed.scheme, parsed.netloc, parsed.path or "/", "", "", ""))
252
+ if target not in routes:
253
+ routes.append(target)
254
+ violations = []
255
+ for target in routes:
256
+ try:
257
+ status, content_type, body = fetcher(target)
258
+ destination = PageParser(); destination.feed(body)
259
+ directives = [item.lower() for item in destination.robots_directives]
260
+ tokens = [token.strip().rsplit(":", 1)[-1] for directive in directives for token in directive.split(",")]
261
+ blocked = sorted(set(tokens) & {"noindex", "nofollow", "none"})
262
+ if blocked:
263
+ violations.append({"url": target, "status": status, "directives": directives, "blocked": blocked})
264
+ except Exception as exc:
265
+ violations.append({"url": target, "error": type(exc).__name__})
266
+ return {"ok": not violations, "links": routes, "violations": violations}
267
+
268
+
221
269
  def summarize_crawl(pages: list[dict]) -> dict:
222
270
  """Count captured evidence, preserving regional/script locale distinctions."""
223
271
  groups = {}
@@ -470,6 +518,7 @@ def main() -> int:
470
518
  checks["jsonld"] = {"ok": page.jsonld > 0 and all(item is not None for item in page.jsonld_values), "count": page.jsonld}
471
519
  checks["entity_jsonld"] = {"ok": any(isinstance(item, dict) and item.get("@type") and (item.get("url") or item.get("@id")) for item in page.jsonld_values), "count": page.jsonld}
472
520
  checks["crawlable_links"] = {"ok": page.anchors > 0, "count": page.anchors}
521
+ checks["primary_navigation_indexability"] = primary_navigation_check(base, page)
473
522
  robots_tokens = [token.strip() for directive in page.robots_directives for token in directive.split(",")]
474
523
  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}
475
524
  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}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topy-ai/maggie",
3
- "version": "0.7.37",
3
+ "version": "0.7.40",
4
4
  "description": "Install and manage Maggie Skills for AI coding agents",
5
5
  "license": "MIT",
6
6
  "type": "module",