@topy-ai/maggie 0.7.46 → 0.7.48

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 (63) hide show
  1. package/README-zh-TW.md +12 -4
  2. package/README.md +55 -11
  3. package/bin/maggie.js +26 -4
  4. package/bundled-contracts/maggie-design/palette-roles-v1.schema.json +47 -0
  5. package/bundled-contracts/maggie-marketplace/template-content-v1.schema.json +103 -0
  6. package/bundled-contracts/maggiedash/booking-browser-evidence-v1.json +21 -0
  7. package/bundled-contracts/maggiedash/booking-browser-evidence-v2.json +191 -0
  8. package/bundled-contracts/maggiedash/booking-email-templates-v1.json +3 -1
  9. package/bundled-contracts/maggiedash/booking-voucher-lifecycle-v1.json +85 -0
  10. package/bundled-contracts/maggiedash/execution-board.json +4629 -1553
  11. package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +1 -0
  12. package/bundled-marketplace/README.md +17 -0
  13. package/bundled-marketplace/catalog.json +56 -7
  14. package/bundled-references/browser-inspection.md +25 -0
  15. package/bundled-references/maggiedash-booking/CURRENT-STATE.md +128 -10
  16. package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +4629 -1553
  17. package/bundled-references/maggiedash-booking/HOST-ADAPTER.md +5 -0
  18. package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +64 -21
  19. package/bundled-references/maggiedash-booking/PRD.md +9 -1
  20. package/bundled-references/maggiedash-booking/PROGRESS.md +244 -2399
  21. package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +37 -6
  22. package/bundled-references/maggiedash-booking/README.md +23 -6
  23. package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +64 -8
  24. package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +11 -2
  25. package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +66 -2
  26. package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +1 -1
  27. package/bundled-references/maggiedash-booking/TASKS.md +44 -3
  28. package/bundled-references/maggiedash-booking/VOUCHERS.md +47 -0
  29. package/bundled-references/memory-hook.md +18 -5
  30. package/bundled-references/provider-adapter-contract.md +33 -0
  31. package/bundled-skills/maggie-booking/SKILL.md +8 -0
  32. package/bundled-skills/maggie-clone-to-template/SKILL.md +26 -0
  33. package/bundled-skills/maggie-deployment/SKILL.md +59 -1
  34. package/bundled-skills/maggie-deployment/references/vps.md +41 -0
  35. package/bundled-skills/maggie-design/SKILL.md +99 -0
  36. package/bundled-skills/maggie-feedback/SKILL.md +41 -2
  37. package/bundled-skills/maggie-marketplace/SKILL.md +91 -0
  38. package/bundled-skills/maggie-memory/SKILL.md +10 -1
  39. package/bundled-skills/maggie-qa-workflow/SKILL.md +41 -1
  40. package/bundled-skills/maggie-service-booking/SKILL.md +23 -0
  41. package/bundled-skills/maggie-social-share/SKILL.md +37 -5
  42. package/bundled-skills/maggie-social-share/references/queue-and-connections.md +101 -0
  43. package/bundled-skills/maggie-template/SKILL.md +57 -3
  44. package/bundled-skills/maggie-template/references/template-content-v1.example.json +34 -0
  45. package/bundled-tools/clis/maggie_booking.py +199 -26
  46. package/bundled-tools/clis/maggie_booking_vouchers.py +185 -0
  47. package/bundled-tools/clis/maggie_clone_to_template.py +14 -0
  48. package/bundled-tools/clis/maggie_deployment.py +295 -0
  49. package/bundled-tools/clis/maggie_design.py +9 -0
  50. package/bundled-tools/clis/maggie_design_lint.py +277 -0
  51. package/bundled-tools/clis/maggie_feedback.py +107 -3
  52. package/bundled-tools/clis/maggie_marketplace.py +199 -5
  53. package/bundled-tools/clis/maggie_memory.py +10 -3
  54. package/bundled-tools/clis/maggie_palette.py +280 -0
  55. package/bundled-tools/clis/maggie_qa_workflow.py +156 -1
  56. package/bundled-tools/clis/maggie_social_queue.py +338 -0
  57. package/bundled-tools/clis/maggie_social_share.py +42 -9
  58. package/bundled-tools/clis/maggie_template_package.py +1141 -0
  59. package/bundled-tools/runtime/maggie_memory.py +17 -2
  60. package/package.json +1 -1
  61. package/references/browser-inspection.md +25 -0
  62. package/references/memory-hook.md +18 -5
  63. package/references/provider-adapter-contract.md +33 -0
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import argparse
6
6
  import hashlib
7
7
  import json
8
+ import os
8
9
  import re
9
10
  import shutil
10
11
  import subprocess
@@ -17,6 +18,9 @@ from html.parser import HTMLParser
17
18
  from html import escape, unescape
18
19
  from pathlib import Path
19
20
 
21
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
22
+ import maggie_template_package as package # noqa: E402
23
+
20
24
 
21
25
  ID_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
22
26
  REQUIRED_METADATA = ("id", "name", "status", "framework", "industry", "source")
@@ -382,7 +386,7 @@ def catalog_entries(root: Path) -> list[dict]:
382
386
 
383
387
  def update_catalog(root: Path, metadata: dict) -> None:
384
388
  entries = [item for item in catalog_entries(root) if item.get("id") != metadata.get("id")]
385
- entries.append({key: metadata[key] for key in ("id", "name", "status", "framework", "industry", "pageTypes", "styleTags", "source", "supports") if key in metadata})
389
+ entries.append({key: metadata[key] for key in ("id", "name", "displayName", "displayDescription", "status", "framework", "industry", "pageTypes", "styleTags", "source", "supports") if key in metadata})
386
390
  entries.sort(key=lambda item: item.get("id", ""))
387
391
  save_catalog(root, entries)
388
392
 
@@ -1185,6 +1189,8 @@ header:first-of-type a[class*="border-white"], #home a[class*="border-white"] {
1185
1189
  metadata["generatedAssets"] = {"logo": generated[0]["path"], "hero": generated[1]["path"], "careTeam": generated[2]["path"], "provider": "maggie-marketplace-pipeline", "generatedAt": now()}
1186
1190
  metadata["sourceMetadata"]["description"] = description
1187
1191
  write_json(destination / "template.json", metadata)
1192
+ finalise_package_identity(root, destination, getattr(args, "display_name", None), getattr(args, "display_description", None))
1193
+ metadata = read_json(destination / "template.json")
1188
1194
  (destination / "README.md").write_text(f"# {metadata['name']}\n\nA UK-localised Astro homepage template derived from {display_name}. Includes clone evidence, responsive screenshots, locally mapped assets, SEO metadata, Phosphor icons, and a DESIGN.md contract.\n\nThis entry remains a draft until source reuse and asset licensing are reviewed.\n", encoding="utf-8")
1189
1195
  cleanup_template_assets(destination)
1190
1196
  scrub_local_export_references(destination, args.preview_url)
@@ -1192,6 +1198,29 @@ header:first-of-type a[class*="border-white"], #home a[class*="border-white"] {
1192
1198
  update_catalog(root, read_json(destination / "template.json"))
1193
1199
 
1194
1200
 
1201
+ def finalise_package_identity(root: Path, destination: Path, display_name: str | None = None, display_description: str | None = None, rewrite_design: bool = True) -> dict:
1202
+ """Measure design tokens, record a neutral display identity, and review screenshots."""
1203
+ metadata = read_json(destination / "template.json")
1204
+ measured = package.measure_design_tokens((destination / "homepage.html").read_text(encoding="utf-8", errors="replace"))
1205
+ if rewrite_design and (destination / "DESIGN.md").exists():
1206
+ package.write_measured_design(destination, measured)
1207
+ metadata["designTokens"] = {"palette": measured["roles"], "textSecondary": measured.get("textSecondary"), "border": measured["border"],
1208
+ "fonts": {key: measured["fonts"][key] for key in ("display", "body", "missingFontLinks")}, "measuredFrom": "homepage.html", "measuredAt": now()}
1209
+ if not metadata.get("styleTags"):
1210
+ metadata["styleTags"] = package.derived_style_tags(measured)
1211
+ taken = {str(entry.get("displayName", "")).lower() for entry in catalog_entries(root) if entry.get("id") != metadata.get("id")}
1212
+ if display_name or not metadata.get("displayName"):
1213
+ identity = package.generate_display_identity(metadata, measured, taken)
1214
+ metadata["displayName"] = display_name or identity["displayName"]
1215
+ metadata["displayDescription"] = display_description or (metadata.get("displayDescription") if display_name else None) or identity["displayDescription"]
1216
+ elif display_description:
1217
+ metadata["displayDescription"] = display_description
1218
+ metadata["screenshotReview"] = package.screenshot_report(destination)
1219
+ write_json(destination / "template.json", metadata)
1220
+ update_catalog(root, metadata)
1221
+ return metadata
1222
+
1223
+
1195
1224
  def standardize_phosphor_icons(value: str) -> tuple[str, list[dict]]:
1196
1225
  """Inventory source icon semantics, then replace each with a Phosphor class."""
1197
1226
  mapping = {
@@ -1676,7 +1705,12 @@ def audit_image_replacements(path: Path) -> list[str]:
1676
1705
  Path(src.split("?", 1)[0]).name
1677
1706
  for src in re.findall(r"assets/[^\s,\"'?#]+", value, re.I)
1678
1707
  }
1679
- leaked = sorted(path_name for path_name in source_paths if path_name in homepage_paths)
1708
+ # repair-assets may add a sniffed suffix to an extensionless capture, so a
1709
+ # suffix-less source name also matches its renamed `<name>.<ext>` file.
1710
+ renamed = {Path(name).stem: name for name in homepage_paths if Path(name).suffix}
1711
+ leaked = sorted({path_name if path_name in homepage_paths else renamed[path_name]
1712
+ for path_name in source_paths
1713
+ if path_name in homepage_paths or (not Path(path_name).suffix and path_name in renamed)})
1680
1714
  errors = [f"homepage still references captured source image: assets/{name}" for name in leaked]
1681
1715
  generated_assets = [
1682
1716
  item for item in generated_manifest.get("assets", [])
@@ -1834,6 +1868,7 @@ def marketplace_artifact_gate(destination: Path, require_generated_assets: bool
1834
1868
  errors.extend(audit_visual_contrast(destination))
1835
1869
  errors.extend(audit_mobile_navigation(destination))
1836
1870
  errors.extend(audit_image_replacements(destination))
1871
+ errors.extend(package_quality_errors(destination))
1837
1872
  update_template_tasks(destination, "COMPLETED")
1838
1873
  try:
1839
1874
  task_state = read_json(destination / "tasks.json")
@@ -1974,6 +2009,16 @@ def resume_marketplace_job(args: argparse.Namespace) -> int:
1974
2009
  return run_marketplace_job(resumed)
1975
2010
 
1976
2011
 
2012
+ def package_quality_errors(path: Path) -> list[str]:
2013
+ """Screenshot, measured-palette, provenance, identity, and image-review gates."""
2014
+ try:
2015
+ catalog = catalog_entries(path.parent.parent)
2016
+ except ValueError:
2017
+ catalog = []
2018
+ report = package.package_audit(path, catalog)
2019
+ return [error for group in report["checks"].values() for error in group]
2020
+
2021
+
1977
2022
  def validate_template(path: Path, sample: Path | None = None) -> int:
1978
2023
  normalise_phosphor_template(path)
1979
2024
  cleanup_template_assets(path)
@@ -1991,6 +2036,7 @@ def validate_template(path: Path, sample: Path | None = None) -> int:
1991
2036
  errors.extend(audit_visual_contrast(path))
1992
2037
  errors.extend(audit_mobile_navigation(path))
1993
2038
  errors.extend(audit_image_replacements(path))
2039
+ errors.extend(package_quality_errors(path))
1994
2040
  if not metadata_path.exists():
1995
2041
  errors.append("missing template.json")
1996
2042
  metadata = {}
@@ -2136,7 +2182,7 @@ def list_templates(args: argparse.Namespace) -> int:
2136
2182
  if args.published:
2137
2183
  entries = [entry for entry in entries if entry.get("status") in PUBLISHED_STATUSES]
2138
2184
  for entry in entries:
2139
- print(f"{entry.get('id')}\t{entry.get('status', 'unknown')}\t{', '.join(entry.get('industry', []))}\t{', '.join(entry.get('styleTags', []))}")
2185
+ print(f"{entry.get('id')}\t{entry.get('displayName') or '(display name missing)'}\t{entry.get('status', 'unknown')}\t{', '.join(entry.get('industry', []))}\t{', '.join(entry.get('styleTags', []))}")
2140
2186
  return 0
2141
2187
 
2142
2188
 
@@ -2157,7 +2203,7 @@ def suggest_templates(args: argparse.Namespace) -> int:
2157
2203
  ranked = []
2158
2204
  for entry in catalog_entries(marketplace_root(args.marketplace)):
2159
2205
  haystack = set(re.findall(r"[a-z0-9]+", " ".join([
2160
- str(entry.get("name", "")), " ".join(entry.get("industry", [])),
2206
+ str(entry.get("displayName", "")), str(entry.get("displayDescription", "")), " ".join(entry.get("industry", [])),
2161
2207
  " ".join(entry.get("styleTags", [])), " ".join(entry.get("pageTypes", [])),
2162
2208
  ]).lower()))
2163
2209
  score = len(query & haystack)
@@ -2166,7 +2212,7 @@ def suggest_templates(args: argparse.Namespace) -> int:
2166
2212
  ranked.append((score, entry))
2167
2213
  ranked.sort(key=lambda item: (-item[0], item[1].get("id", "")))
2168
2214
  for score, entry in ranked[:args.limit]:
2169
- print(f"{score}\t{entry.get('id')}\t{entry.get('status', 'unknown')}\t{', '.join(entry.get('industry', []))}\t{', '.join(entry.get('styleTags', []))}")
2215
+ print(f"{score}\t{entry.get('id')}\t{entry.get('displayName') or '(display name missing)'}\t{entry.get('status', 'unknown')}\t{', '.join(entry.get('industry', []))}\t{', '.join(entry.get('styleTags', []))}")
2170
2216
  return 0
2171
2217
 
2172
2218
 
@@ -2235,6 +2281,107 @@ def enrichment_report(args: argparse.Namespace) -> int:
2235
2281
  print(json.dumps({"status": report["status"], "candidateId": candidate_id, "report": str(output), "retry": report["retry"]}, ensure_ascii=False)); return 0 if not errors else 1
2236
2282
 
2237
2283
 
2284
+ def _template_dir(args: argparse.Namespace) -> tuple[Path, Path]:
2285
+ root = marketplace_root(args.marketplace)
2286
+ source = template_path(root, args.id)
2287
+ if not (source / "template.json").is_file():
2288
+ raise ValueError(f"template not found: {args.id}")
2289
+ return root, source
2290
+
2291
+
2292
+ def audit_package(args: argparse.Namespace) -> int:
2293
+ """Read-only package-quality audit; --record stores the screenshot review."""
2294
+ root, source = _template_dir(args)
2295
+ report = package.package_audit(source, catalog_entries(root))
2296
+ if args.record:
2297
+ metadata = read_json(source / "template.json")
2298
+ metadata["screenshotReview"] = package.screenshot_report(source)
2299
+ if args.recapture_note:
2300
+ metadata["screenshotReview"]["note"] = args.recapture_note
2301
+ write_json(source / "template.json", metadata)
2302
+ print(json.dumps(report, indent=2, ensure_ascii=False))
2303
+ return 0 if report["passed"] else 1
2304
+
2305
+
2306
+ def measure_design(args: argparse.Namespace) -> int:
2307
+ root, source = _template_dir(args)
2308
+ measured = package.measure_design_tokens((source / "homepage.html").read_text(encoding="utf-8", errors="replace"))
2309
+ if args.palette_roles:
2310
+ palettes = package.load_palettes(None, measured, read_json(source / "template.json").get("id"))
2311
+ args.palette_roles.parent.mkdir(parents=True, exist_ok=True)
2312
+ args.palette_roles.write_text(json.dumps(palettes, indent=2) + "\n", encoding="utf-8")
2313
+ if args.write:
2314
+ finalise_package_identity(root, source, rewrite_design=True)
2315
+ print(json.dumps({key: measured[key] for key in ("roles", "textSecondary", "border", "fonts")}, indent=2, ensure_ascii=False))
2316
+ return 0
2317
+
2318
+
2319
+ def repair_assets(args: argparse.Namespace) -> int:
2320
+ _, source = _template_dir(args)
2321
+ result = package.repair_asset_provenance(source, write=args.write)
2322
+ print(json.dumps({"write": args.write, **result}, indent=2, ensure_ascii=False))
2323
+ return 0
2324
+
2325
+
2326
+ def set_identity(args: argparse.Namespace) -> int:
2327
+ root, source = _template_dir(args)
2328
+ metadata = finalise_package_identity(root, source, args.display_name, args.display_description, rewrite_design=False)
2329
+ errors = package.audit_display_identity(source, catalog_entries(root))
2330
+ print(json.dumps({"displayName": metadata.get("displayName"), "displayDescription": metadata.get("displayDescription"), "errors": errors}, indent=2, ensure_ascii=False))
2331
+ return 0 if not errors else 1
2332
+
2333
+
2334
+ def template_studio(args: argparse.Namespace) -> int:
2335
+ root, source = _template_dir(args)
2336
+ measured = package.measure_design_tokens((source / "homepage.html").read_text(encoding="utf-8", errors="replace"))
2337
+ palettes = package.load_palettes(args.palettes.resolve() if args.palettes else None, measured, args.id)
2338
+ output = (args.output or root / ".maggie" / "template-studio" / args.id).resolve()
2339
+ result = package.build_studio(source, output, palettes)
2340
+ if args.check_overflow:
2341
+ serve_root = Path(os.path.commonpath([str(output), str(source.resolve())]))
2342
+ report = package.check_studio_overflow(serve_root, output / "index.html", palette_id=args.palette)
2343
+ result["overflow"] = report
2344
+ _write = output / "overflow-report.json"
2345
+ _write.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
2346
+ failing = {width: item for width, item in (report.get("viewports") or {}).items() if item.get("overflowPx")}
2347
+ print(json.dumps(result, indent=2, ensure_ascii=False))
2348
+ return 1 if failing or report.get("error") or not report.get("viewports") else 0
2349
+ print(json.dumps(result, indent=2, ensure_ascii=False))
2350
+ print("Serve the common parent over HTTP (python3 -m http.server) to preview; --check-overflow renders it headlessly.")
2351
+ return 0
2352
+
2353
+
2354
+ def content_validate(args: argparse.Namespace) -> int:
2355
+ content = read_json(args.input.resolve())
2356
+ errors = package.validate_content(content)
2357
+ print(json.dumps({"status": "passed" if not errors else "failed", "errors": errors}, indent=2))
2358
+ return 0 if not errors else 1
2359
+
2360
+
2361
+ def content_css(args: argparse.Namespace) -> int:
2362
+ content = read_json(args.input.resolve())
2363
+ roles = (content.get("palette") or {}).get("roles") or {}
2364
+ if not roles:
2365
+ raise ValueError("content has no palette.roles to export")
2366
+ print(package.palette_css_variables(roles), end="")
2367
+ return 0
2368
+
2369
+
2370
+ def image_plan(args: argparse.Namespace) -> int:
2371
+ _, source = _template_dir(args)
2372
+ report = package.plan_image_generation(source, args.unit_cost, args.currency, args.provider)
2373
+ print(json.dumps(report["before"], indent=2))
2374
+ return 0
2375
+
2376
+
2377
+ def image_review(args: argparse.Namespace) -> int:
2378
+ _, source = _template_dir(args)
2379
+ report = package.review_generated_images(source, approve=args.approve, reviewer=args.reviewer or "", flags=args.flag, note=args.note or "")
2380
+ summary = {"after": report["after"], "textReview": {key: report["textReview"][key] for key in ("status", "method", "contactSheet")}}
2381
+ print(json.dumps(summary, indent=2))
2382
+ return 0 if report["textReview"]["status"] == "passed" else 1
2383
+
2384
+
2238
2385
  def main() -> int:
2239
2386
  parser = argparse.ArgumentParser(description=__doc__)
2240
2387
  parser.add_argument("--marketplace", type=Path, default=Path("marketplace"))
@@ -2269,6 +2416,8 @@ def main() -> int:
2269
2416
  add.add_argument("--languages", action="append", default=["en"])
2270
2417
  add.add_argument("--translation-policy", choices=("source-only", "review-required", "fully-localized"), default="review-required")
2271
2418
  add.add_argument("--brand", default="Maggie Studio", help="neutral demo brand used by the completion pipeline")
2419
+ add.add_argument("--display-name", help="neutral customer-facing style name (default: generated from measured design)")
2420
+ add.add_argument("--display-description", help="short neutral description shown in template pickers")
2272
2421
  add.add_argument("--site-url", help="final canonical site URL; defaults to an example.co.uk URL")
2273
2422
  add.add_argument("--sample", type=Path)
2274
2423
  add.add_argument("--force", action="store_true")
@@ -2319,6 +2468,51 @@ def main() -> int:
2319
2468
  enrichment.add_argument("--input", type=Path, required=True)
2320
2469
  enrichment.add_argument("--output", type=Path)
2321
2470
  enrichment.set_defaults(func=enrichment_report)
2471
+ audit = sub.add_parser("audit-package", help="read-only screenshot, palette, provenance, identity, and image-review audit")
2472
+ audit.add_argument("id")
2473
+ audit.add_argument("--record", action="store_true", help="store the screenshot review in template.json")
2474
+ audit.add_argument("--recapture-note")
2475
+ audit.set_defaults(func=audit_package)
2476
+ measure = sub.add_parser("measure-design", help="measure palette roles and fonts from homepage CSS")
2477
+ measure.add_argument("id")
2478
+ measure.add_argument("--write", action="store_true", help="rewrite DESIGN.md palette/typography and template.json designTokens")
2479
+ measure.add_argument("--palette-roles", type=Path, help="write the measured signature as maggie-palette-roles-v1 JSON")
2480
+ measure.set_defaults(func=measure_design)
2481
+ repair = sub.add_parser("repair-assets", help="list unlisted assets with honest provenance; add missing file extensions")
2482
+ repair.add_argument("id")
2483
+ repair.add_argument("--write", action="store_true")
2484
+ repair.set_defaults(func=repair_assets)
2485
+ identity = sub.add_parser("identity", help="record a neutral, unique displayName and description")
2486
+ identity.add_argument("id")
2487
+ identity.add_argument("--display-name")
2488
+ identity.add_argument("--display-description")
2489
+ identity.set_defaults(func=set_identity)
2490
+ studio = sub.add_parser("studio", help="build a template x palette x viewport preview studio")
2491
+ studio.add_argument("id")
2492
+ studio.add_argument("--palettes", type=Path, help="maggie-palette-roles-v1 JSON (default: measured signature only)")
2493
+ studio.add_argument("--output", type=Path)
2494
+ studio.add_argument("--check-overflow", action="store_true", help="render headlessly and fail on horizontal overflow")
2495
+ studio.add_argument("--palette", help="palette id to render for --check-overflow (default: signature)")
2496
+ studio.set_defaults(func=template_studio)
2497
+ cvalidate = sub.add_parser("content-validate", help="validate maggie-template-content-v1 JSON")
2498
+ cvalidate.add_argument("--input", type=Path, required=True)
2499
+ cvalidate.set_defaults(func=content_validate)
2500
+ ccss = sub.add_parser("content-css", help="print palette CSS variables for template content")
2501
+ ccss.add_argument("--input", type=Path, required=True)
2502
+ ccss.set_defaults(func=content_css)
2503
+ iplan = sub.add_parser("image-plan", help="record planned image count and estimated cost before generation")
2504
+ iplan.add_argument("id")
2505
+ iplan.add_argument("--unit-cost", type=float)
2506
+ iplan.add_argument("--currency", default="USD")
2507
+ iplan.add_argument("--provider", default="built-in-image-generation")
2508
+ iplan.set_defaults(func=image_plan)
2509
+ ireview = sub.add_parser("image-review", help="contact sheet, OCR/human no-text review, and post-run cost")
2510
+ ireview.add_argument("id")
2511
+ ireview.add_argument("--approve", action="store_true", help="record a human review that found no text, labels, or watermarks")
2512
+ ireview.add_argument("--reviewer")
2513
+ ireview.add_argument("--flag", action="append", default=[], help="asset path with readable text; repeatable")
2514
+ ireview.add_argument("--note")
2515
+ ireview.set_defaults(func=image_review)
2322
2516
  args = parser.parse_args()
2323
2517
  try:
2324
2518
  return int(args.func(args) or 0)
@@ -35,12 +35,15 @@ def main() -> int:
35
35
  listing.add_argument("--status", choices=sorted(memory.STATUSES), default=None)
36
36
  listing.add_argument("--skill", default="")
37
37
  listing.add_argument("--query", default="")
38
+ listing.add_argument("--include-shared", action="store_true", help="explicitly include user/workspace memory entries")
38
39
  search = add_project(sub.add_parser("search"))
39
40
  search.add_argument("query")
40
41
  search.add_argument("--skill", default="")
42
+ search.add_argument("--include-shared", action="store_true", help="explicitly include user/workspace memory entries")
41
43
  context = add_project(sub.add_parser("context"))
42
44
  context.add_argument("--skill", default="")
43
45
  context.add_argument("--query", default="")
46
+ context.add_argument("--include-shared", action="store_true", help="explicitly include user/workspace memory entries")
44
47
  add = add_project(sub.add_parser("add"))
45
48
  add.add_argument("kind", choices=("preferences", "conventions", "lessons"))
46
49
  add.add_argument("--scope", choices=sorted(memory.SCOPES), required=True)
@@ -85,17 +88,21 @@ def main() -> int:
85
88
  output(memory.transition(args.project, args.kind, args.item_id, args.status))
86
89
  elif args.command in {"context", "search", "list"}:
87
90
  query = args.query if args.command == "search" else getattr(args, "query", "")
88
- result = memory.relevant(args.project, skill=getattr(args, "skill", ""), query=query, status=getattr(args, "status", None))
91
+ result = memory.relevant(args.project, skill=getattr(args, "skill", ""), query=query, status=getattr(args, "status", None), include_shared=getattr(args, "include_shared", False))
89
92
  if args.command == "list" and args.kind:
90
93
  result = {args.kind: result[args.kind]}
91
94
  if args.command == "list" and args.status:
92
95
  result = {kind: [item for item in items if item.get("status") == args.status] for kind, items in result.items()}
93
- output(result)
96
+ if args.command == "context":
97
+ ready = memory.initialized(args.project)
98
+ output({"status": "ready" if ready else "uninitialized", "initialized": ready, **result})
99
+ else:
100
+ output(result)
94
101
  elif args.command == "export":
95
102
  payload = {kind: memory.read(args.project, kind) for kind in memory.KINDS if kind != "errors"}
96
103
  if args.public_safe:
97
104
  for value in payload.values():
98
- value["items"] = [item for item in value["items"] if item.get("scope") != "user" and item.get("status") == "active"]
105
+ value["items"] = [item for item in value["items"] if item.get("scope") == "project" and item.get("status") == "active"]
99
106
  destination = Path(args.output)
100
107
  destination.parent.mkdir(parents=True, exist_ok=True)
101
108
  destination.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
@@ -0,0 +1,280 @@
1
+ #!/usr/bin/env python3
2
+ """Generate and validate a semantic palette role contract for a template.
3
+
4
+ A template exposes colour roles instead of raw hex values in markup. The
5
+ signature palette is the template's own; variants are derived from it by
6
+ moving hue while keeping lightness, capping accent saturation, and forcing
7
+ the contrast rules that make the roles safe to swap without editing markup.
8
+ The command is deterministic and makes no network or model calls.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import colorsys
15
+ import json
16
+ import re
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ SCHEMA_VERSION = "maggie-palette-roles-v1"
21
+ ROLES = ("canvas", "panel", "soft", "text", "accent", "accentText", "onAccent", "dark", "deep")
22
+ HEX = re.compile(r"^#[0-9A-Fa-f]{6}$")
23
+ MIN_TEXT_CONTRAST = 4.5
24
+ DEFAULT_MAX_SATURATION = 0.65
25
+ DEFAULT_VARIANT_COUNT = 17
26
+ MIN_HUE_DISTANCE = 15.0
27
+
28
+ # Named hue anchors used for variants. Order is the output order.
29
+ HUE_ANCHORS = (
30
+ ("rose", "Rose", 350), ("coral", "Coral", 10), ("terracotta", "Terracotta", 20),
31
+ ("amber", "Amber", 36), ("ochre", "Ochre", 45), ("olive", "Olive", 65),
32
+ ("moss", "Moss", 90), ("sage", "Sage", 130), ("jade", "Jade", 155),
33
+ ("teal", "Teal", 178), ("lagoon", "Lagoon", 190), ("ocean", "Ocean", 205),
34
+ ("cobalt", "Cobalt", 220), ("indigo", "Indigo", 240), ("iris", "Iris", 258),
35
+ ("violet", "Violet", 275), ("plum", "Plum", 300), ("orchid", "Orchid", 322),
36
+ )
37
+
38
+
39
+ def parse_hex(value: str) -> tuple[float, float, float]:
40
+ if not isinstance(value, str) or not HEX.match(value):
41
+ raise ValueError(f"colour must be #RRGGBB: {value!r}")
42
+ return tuple(int(value[index:index + 2], 16) / 255 for index in (1, 3, 5)) # type: ignore[return-value]
43
+
44
+
45
+ def to_hex(rgb: tuple[float, float, float]) -> str:
46
+ return "#" + "".join(f"{round(max(0.0, min(1.0, channel)) * 255):02X}" for channel in rgb)
47
+
48
+
49
+ def to_hsl(value: str) -> tuple[float, float, float]:
50
+ hue, lightness, saturation = colorsys.rgb_to_hls(*parse_hex(value))
51
+ return hue * 360, saturation, lightness
52
+
53
+
54
+ def from_hsl(hue: float, saturation: float, lightness: float) -> str:
55
+ return to_hex(colorsys.hls_to_rgb((hue % 360) / 360, max(0.0, min(1.0, lightness)), max(0.0, min(1.0, saturation))))
56
+
57
+
58
+ def luminance(value: str) -> float:
59
+ def linear(channel: float) -> float:
60
+ return channel / 12.92 if channel <= 0.03928 else ((channel + 0.055) / 1.055) ** 2.4
61
+ red, green, blue = (linear(channel) for channel in parse_hex(value))
62
+ return 0.2126 * red + 0.7152 * green + 0.0722 * blue
63
+
64
+
65
+ def contrast(first: str, second: str) -> float:
66
+ high, low = sorted((luminance(first), luminance(second)), reverse=True)
67
+ return (high + 0.05) / (low + 0.05)
68
+
69
+
70
+ def hue_distance(first: float, second: float) -> float:
71
+ delta = abs(first - second) % 360
72
+ return min(delta, 360 - delta)
73
+
74
+
75
+ def mix(first: str, second: str, amount: float) -> str:
76
+ a, b = parse_hex(first), parse_hex(second)
77
+ return to_hex(tuple(x + (y - x) * amount for x, y in zip(a, b))) # type: ignore[arg-type]
78
+
79
+
80
+ def ensure_contrast(colour: str, background: str, minimum: float = MIN_TEXT_CONTRAST) -> str:
81
+ """Move only lightness, away from the background, until contrast passes."""
82
+ if contrast(colour, background) >= minimum:
83
+ return colour
84
+ hue, saturation, lightness = to_hsl(colour)
85
+ step = -0.01 if luminance(background) > 0.18 else 0.01
86
+ candidate = colour
87
+ for _ in range(101):
88
+ lightness = max(0.0, min(1.0, lightness + step))
89
+ candidate = from_hsl(hue, saturation, lightness)
90
+ if contrast(candidate, background) >= minimum:
91
+ return candidate
92
+ return candidate
93
+
94
+
95
+ def pick_on_accent(accent: str, candidates: list[str]) -> str:
96
+ return max(candidates, key=lambda candidate: (round(contrast(candidate, accent), 4), candidate))
97
+
98
+
99
+ def complete_roles(roles: dict[str, str]) -> dict[str, str]:
100
+ """Fill derived roles for a signature; supplied roles are never changed."""
101
+ missing = [name for name in ("canvas", "text", "accent") if not roles.get(name)]
102
+ if missing:
103
+ raise ValueError("signature needs canvas, text and accent; missing " + ", ".join(missing))
104
+ for name, value in roles.items():
105
+ if name not in ROLES:
106
+ raise ValueError(f"unknown palette role: {name}")
107
+ parse_hex(value)
108
+ filled = {name: roles[name].upper() for name in roles}
109
+ canvas, text, accent = filled["canvas"], filled["text"], filled["accent"]
110
+ filled.setdefault("panel", mix(canvas, text, 0.04))
111
+ filled.setdefault("soft", mix(canvas, accent, 0.12))
112
+ # The dark band is the text colour on light canvases and a deeper canvas on dark ones.
113
+ if luminance(canvas) > 0.18:
114
+ filled.setdefault("dark", text)
115
+ else:
116
+ canvas_hue, canvas_saturation, canvas_lightness = to_hsl(canvas)
117
+ filled.setdefault("dark", from_hsl(canvas_hue, canvas_saturation, canvas_lightness * 0.8))
118
+ deep_hue, deep_saturation, deep_lightness = to_hsl(filled["dark"])
119
+ filled.setdefault("deep", from_hsl(deep_hue, deep_saturation, deep_lightness * 0.7))
120
+ filled.setdefault("accentText", ensure_contrast(accent, canvas))
121
+ filled.setdefault("onAccent", pick_on_accent(accent, ["#FFFFFF", filled["deep"], text, canvas]))
122
+ return {name: filled[name] for name in ROLES}
123
+
124
+
125
+ def derive_variant(signature: dict[str, str], anchor: tuple[str, str, int], max_saturation: float) -> dict[str, object]:
126
+ identifier, name, target = anchor
127
+ accent_hue, accent_saturation, accent_lightness = to_hsl(signature["accent"])
128
+ shift = target - accent_hue
129
+ roles: dict[str, str] = {}
130
+ for role in ("canvas", "panel", "soft", "text", "dark", "deep"):
131
+ hue, saturation, lightness = to_hsl(signature[role])
132
+ # Neutrals follow the hue move but never gain chroma.
133
+ roles[role] = from_hsl(hue + shift, min(saturation, max_saturation), lightness)
134
+ roles["text"] = ensure_contrast(roles["text"], roles["canvas"])
135
+ roles["text"] = ensure_contrast(roles["text"], roles["panel"])
136
+ accent = from_hsl(target, min(accent_saturation, max_saturation), accent_lightness)
137
+ candidates = ["#FFFFFF", roles["deep"], roles["text"], roles["canvas"]]
138
+ on_accent = pick_on_accent(accent, candidates)
139
+ if contrast(on_accent, accent) < MIN_TEXT_CONTRAST:
140
+ accent = ensure_contrast(accent, on_accent)
141
+ on_accent = pick_on_accent(accent, candidates)
142
+ roles["accent"] = accent
143
+ roles["onAccent"] = on_accent
144
+ roles["accentText"] = ensure_contrast(accent, roles["canvas"])
145
+ return {"id": identifier, "name": name, "roles": {role: roles[role] for role in ROLES}}
146
+
147
+
148
+ def generate(roles: dict[str, str], *, template: str | None = None, name: str = "Signature",
149
+ count: int = DEFAULT_VARIANT_COUNT, max_saturation: float = DEFAULT_MAX_SATURATION) -> dict[str, object]:
150
+ if not 0 < max_saturation <= 1:
151
+ raise ValueError("max saturation must be in (0, 1]")
152
+ if count < 0:
153
+ raise ValueError("count must not be negative")
154
+ signature = complete_roles(roles)
155
+ accent_hue = to_hsl(signature["accent"])[0]
156
+ anchors = [anchor for anchor in HUE_ANCHORS if hue_distance(anchor[2], accent_hue) >= MIN_HUE_DISTANCE]
157
+ variants = [derive_variant(signature, anchor, max_saturation) for anchor in anchors[:count]]
158
+ return {
159
+ "schemaVersion": SCHEMA_VERSION,
160
+ "template": template,
161
+ "signature": {"id": "signature", "name": name, "roles": signature},
162
+ "variants": variants,
163
+ }
164
+
165
+
166
+ def validate(palette: dict[str, object], max_saturation: float = DEFAULT_MAX_SATURATION) -> dict[str, object]:
167
+ errors: list[str] = []
168
+ warnings: list[str] = []
169
+ if palette.get("schemaVersion") != SCHEMA_VERSION:
170
+ errors.append(f"schemaVersion must be {SCHEMA_VERSION}")
171
+ template = palette.get("template")
172
+ if template is not None and not isinstance(template, str):
173
+ errors.append("template must be a string or null")
174
+ entries: list[tuple[dict, bool]] = []
175
+ if isinstance(palette.get("signature"), dict):
176
+ entries.append((palette["signature"], False)) # type: ignore[arg-type]
177
+ else:
178
+ errors.append("signature must be an object")
179
+ if isinstance(palette.get("variants"), list):
180
+ entries.extend((variant, True) for variant in palette["variants"]) # type: ignore[union-attr]
181
+ else:
182
+ errors.append("variants must be a list")
183
+ seen: set[str] = set()
184
+ checked = 0
185
+ for entry, is_variant in entries:
186
+ if not isinstance(entry, dict):
187
+ errors.append("each palette entry must be an object")
188
+ continue
189
+ identifier = str(entry.get("id") or "")
190
+ label = identifier or "<missing id>"
191
+ if not identifier or not str(entry.get("name") or "").strip():
192
+ errors.append(f"{label}: id and name are required")
193
+ if identifier in seen:
194
+ errors.append(f"{label}: duplicate palette id")
195
+ seen.add(identifier)
196
+ roles = entry.get("roles")
197
+ if not isinstance(roles, dict):
198
+ errors.append(f"{label}: roles must be an object")
199
+ continue
200
+ missing = [role for role in ROLES if role not in roles]
201
+ invalid = [role for role in ROLES if role in roles and not (isinstance(roles[role], str) and HEX.match(roles[role]))]
202
+ if missing:
203
+ errors.append(f"{label}: missing roles " + ", ".join(missing))
204
+ if invalid:
205
+ errors.append(f"{label}: roles must be #RRGGBB: " + ", ".join(invalid))
206
+ if missing or invalid:
207
+ continue
208
+ checked += 1
209
+ for foreground, background in (("text", "canvas"), ("text", "panel"), ("accentText", "canvas"), ("onAccent", "accent")):
210
+ ratio = contrast(roles[foreground], roles[background])
211
+ if ratio < MIN_TEXT_CONTRAST:
212
+ errors.append(f"{label}: {foreground} on {background} is {ratio:.2f}:1; needs {MIN_TEXT_CONTRAST}:1")
213
+ saturation = to_hsl(roles["accent"])[1]
214
+ if is_variant and saturation > max_saturation + 0.01:
215
+ errors.append(f"{label}: accent saturation {saturation:.2f} exceeds cap {max_saturation:.2f}")
216
+ if contrast(roles["text"], roles["soft"]) < MIN_TEXT_CONTRAST:
217
+ warnings.append(f"{label}: text on soft is below {MIN_TEXT_CONTRAST}:1; avoid body copy on soft surfaces")
218
+ return {"schemaVersion": "maggie-palette-validation.v1", "passed": not errors, "checkedPalettes": checked, "errors": errors, "warnings": warnings}
219
+
220
+
221
+ def _load_roles(args: argparse.Namespace) -> dict[str, str]:
222
+ roles: dict[str, str] = {}
223
+ if args.roles:
224
+ data = json.loads(args.roles.read_text(encoding="utf-8"))
225
+ if isinstance(data.get("signature"), dict):
226
+ data = data["signature"].get("roles") or {}
227
+ elif isinstance(data.get("roles"), dict):
228
+ data = data["roles"]
229
+ roles.update({key: value for key, value in data.items() if isinstance(value, str)})
230
+ for role in ROLES:
231
+ value = getattr(args, role, None)
232
+ if value:
233
+ roles[role] = value
234
+ return roles
235
+
236
+
237
+ def _emit(payload: dict[str, object], output: Path | None) -> None:
238
+ encoded = json.dumps(payload, indent=2, ensure_ascii=False) + "\n"
239
+ if output:
240
+ output.parent.mkdir(parents=True, exist_ok=True)
241
+ output.write_text(encoded, encoding="utf-8")
242
+ print(encoded, end="")
243
+
244
+
245
+ def main(argv: list[str] | None = None) -> int:
246
+ parser = argparse.ArgumentParser(prog="maggie design palette", description=__doc__.splitlines()[0])
247
+ sub = parser.add_subparsers(dest="command", required=True)
248
+ generate_parser = sub.add_parser("generate", help="derive contrast-safe variants from a signature palette")
249
+ generate_parser.add_argument("--roles", type=Path, help="JSON roles object, or an existing palette file")
250
+ for role in ROLES:
251
+ generate_parser.add_argument(f"--{role}", dest=role, help=f"{role} colour as #RRGGBB")
252
+ generate_parser.add_argument("--template", help="template id recorded in the contract")
253
+ generate_parser.add_argument("--name", default="Signature", help="signature palette display name")
254
+ generate_parser.add_argument("--count", type=int, default=DEFAULT_VARIANT_COUNT)
255
+ generate_parser.add_argument("--max-saturation", type=float, default=DEFAULT_MAX_SATURATION)
256
+ generate_parser.add_argument("--output", type=Path)
257
+ validate_parser = sub.add_parser("validate", help="check roles, hex values, contrast and saturation cap")
258
+ validate_parser.add_argument("--palette", type=Path, required=True)
259
+ validate_parser.add_argument("--max-saturation", type=float, default=DEFAULT_MAX_SATURATION)
260
+ args = parser.parse_args(argv)
261
+ try:
262
+ if args.command == "generate":
263
+ palette = generate(_load_roles(args), template=args.template, name=args.name, count=args.count, max_saturation=args.max_saturation)
264
+ report = validate(palette, args.max_saturation)
265
+ if not report["passed"]:
266
+ print(json.dumps(report, indent=2), file=sys.stderr)
267
+ print("BLOCKED: maggie-design palette generate: the signature palette fails the contrast contract", file=sys.stderr)
268
+ return 1
269
+ _emit(palette, args.output)
270
+ return 0
271
+ report = validate(json.loads(args.palette.read_text(encoding="utf-8")), args.max_saturation)
272
+ _emit(report, None)
273
+ return 0 if report["passed"] else 1
274
+ except (OSError, ValueError, json.JSONDecodeError) as error:
275
+ print(f"BLOCKED: maggie-design palette {args.command}: {error}", file=sys.stderr)
276
+ return 1
277
+
278
+
279
+ if __name__ == "__main__":
280
+ raise SystemExit(main())