@topy-ai/maggie 0.7.47 → 0.7.49

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 (91) hide show
  1. package/README-zh-TW.md +2 -2
  2. package/README.md +76 -15
  3. package/bin/maggie.js +23 -6
  4. package/bundled-contracts/google-integrations/capability-report-v2.schema.json +2 -1
  5. package/bundled-contracts/maggie-content/email-localization-v1.schema.json +89 -0
  6. package/bundled-contracts/maggie-content/lifecycle-email-v1.schema.json +76 -0
  7. package/bundled-contracts/maggie-deployment/readiness-v1.schema.json +6 -1
  8. package/bundled-contracts/maggie-design/palette-roles-v1.schema.json +47 -0
  9. package/bundled-contracts/maggie-marketplace/template-content-v1.schema.json +103 -0
  10. package/bundled-contracts/maggie-ops/marketing-run-v1.schema.json +89 -0
  11. package/bundled-contracts/maggie-ops/notification-catalogue-example-v1.json +941 -0
  12. package/bundled-contracts/maggie-ops/notification-catalogue-v1.schema.json +273 -0
  13. package/bundled-contracts/maggie-service-booking/fact-provenance-v1.schema.json +128 -0
  14. package/bundled-contracts/maggie-service-booking/import-stages-v1.schema.json +191 -0
  15. package/bundled-contracts/maggiedash/booking-browser-evidence-v1.json +21 -0
  16. package/bundled-contracts/maggiedash/booking-browser-evidence-v2.json +191 -0
  17. package/bundled-contracts/maggiedash/booking-email-templates-v1.json +3 -1
  18. package/bundled-contracts/maggiedash/booking-voucher-lifecycle-v1.json +85 -0
  19. package/bundled-contracts/maggiedash/execution-board.json +4629 -1553
  20. package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +1 -0
  21. package/bundled-marketplace/README.md +17 -0
  22. package/bundled-marketplace/catalog.json +56 -7
  23. package/bundled-references/browser-inspection.md +25 -0
  24. package/bundled-references/google-integrations-runbook.md +54 -0
  25. package/bundled-references/maggiedash-booking/CURRENT-STATE.md +128 -10
  26. package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +4629 -1553
  27. package/bundled-references/maggiedash-booking/HOST-ADAPTER.md +5 -0
  28. package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +64 -21
  29. package/bundled-references/maggiedash-booking/PRD.md +9 -1
  30. package/bundled-references/maggiedash-booking/PROGRESS.md +244 -2399
  31. package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +37 -6
  32. package/bundled-references/maggiedash-booking/README.md +23 -6
  33. package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +64 -8
  34. package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +11 -2
  35. package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +66 -2
  36. package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +1 -1
  37. package/bundled-references/maggiedash-booking/TASKS.md +44 -3
  38. package/bundled-references/maggiedash-booking/VOUCHERS.md +47 -0
  39. package/bundled-references/provider-adapter-contract.md +33 -0
  40. package/bundled-skills/maggie-booking/SKILL.md +8 -0
  41. package/bundled-skills/maggie-clone-to-template/SKILL.md +26 -0
  42. package/bundled-skills/maggie-content-localization/SKILL.md +17 -1
  43. package/bundled-skills/maggie-content-localization/references/email-and-lifecycle.md +83 -0
  44. package/bundled-skills/maggie-deployment/SKILL.md +141 -7
  45. package/bundled-skills/maggie-deployment/references/vps.md +74 -1
  46. package/bundled-skills/maggie-design/SKILL.md +154 -0
  47. package/bundled-skills/maggie-feedback/SKILL.md +67 -1
  48. package/bundled-skills/maggie-marketplace/SKILL.md +91 -0
  49. package/bundled-skills/maggie-ops/SKILL.md +37 -0
  50. package/bundled-skills/maggie-ops/references/notifications-operations.md +154 -0
  51. package/bundled-skills/maggie-qa-workflow/SKILL.md +41 -1
  52. package/bundled-skills/maggie-service-booking/SKILL.md +82 -0
  53. package/bundled-skills/maggie-social-share/SKILL.md +56 -7
  54. package/bundled-skills/maggie-social-share/references/marketing-run.md +57 -0
  55. package/bundled-skills/maggie-social-share/references/queue-and-connections.md +152 -0
  56. package/bundled-skills/maggie-template/SKILL.md +57 -3
  57. package/bundled-skills/maggie-template/references/template-content-v1.example.json +34 -0
  58. package/bundled-tools/clis/maggie_booking.py +199 -26
  59. package/bundled-tools/clis/maggie_booking_vouchers.py +185 -0
  60. package/bundled-tools/clis/maggie_clone_to_template.py +14 -0
  61. package/bundled-tools/clis/maggie_deployment.py +307 -18
  62. package/bundled-tools/clis/maggie_deployment_auth_canary.py +216 -0
  63. package/bundled-tools/clis/maggie_deployment_collect.py +225 -0
  64. package/bundled-tools/clis/maggie_deployment_readiness.py +53 -3
  65. package/bundled-tools/clis/maggie_design.py +17 -0
  66. package/bundled-tools/clis/maggie_design_lint.py +277 -0
  67. package/bundled-tools/clis/maggie_design_verify.py +359 -0
  68. package/bundled-tools/clis/maggie_docs.py +82 -4
  69. package/bundled-tools/clis/maggie_email_localization.py +382 -0
  70. package/bundled-tools/clis/maggie_feedback.py +115 -5
  71. package/bundled-tools/clis/maggie_icon_glyph_check.py +382 -0
  72. package/bundled-tools/clis/maggie_icon_release_gate.py +28 -1
  73. package/bundled-tools/clis/maggie_localization.py +5 -0
  74. package/bundled-tools/clis/maggie_marketing_run.py +272 -0
  75. package/bundled-tools/clis/maggie_marketplace.py +199 -5
  76. package/bundled-tools/clis/maggie_notifications.py +528 -0
  77. package/bundled-tools/clis/maggie_ops.py +87 -0
  78. package/bundled-tools/clis/maggie_palette.py +280 -0
  79. package/bundled-tools/clis/maggie_qa_workflow.py +156 -1
  80. package/bundled-tools/clis/maggie_schedule.py +12 -2
  81. package/bundled-tools/clis/maggie_service_import.py +520 -0
  82. package/bundled-tools/clis/maggie_social_queue.py +442 -0
  83. package/bundled-tools/clis/maggie_social_share.py +46 -9
  84. package/bundled-tools/clis/maggie_template_package.py +1141 -0
  85. package/bundled-tools/runtime/google_ads.py +306 -0
  86. package/bundled-tools/runtime/google_capabilities.py +18 -2
  87. package/bundled-tools/runtime/google_remediation.py +152 -0
  88. package/package.json +1 -1
  89. package/references/browser-inspection.md +25 -0
  90. package/references/google-integrations-runbook.md +54 -0
  91. package/references/provider-adapter-contract.md +33 -0
@@ -15,6 +15,9 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
15
15
  from content_localization import LANGUAGES, MARKETS, OPERATIONS, parse_locale, protected_field_changes, stale, valid_locale, validate_operation_quality, validate_translation
16
16
  from localization_runner import generate, process_adapter
17
17
 
18
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
19
+ import maggie_email_localization as email_localization
20
+
18
21
 
19
22
  def generate_job(args: argparse.Namespace) -> int:
20
23
  if not args.confirm:
@@ -395,6 +398,7 @@ def main() -> int:
395
398
  publish_parser.add_argument("job", type=Path); publish_parser.add_argument("--confirm", action="store_true")
396
399
  for name in ("stale", "glossary"):
397
400
  command = sub.add_parser(name); command.add_argument("--project", type=Path, default=Path("."))
401
+ email_localization.add_parsers(sub)
398
402
  args = parser.parse_args()
399
403
  try:
400
404
  if args.command == "generate": return generate_job(args)
@@ -406,6 +410,7 @@ def main() -> int:
406
410
  if args.command == "review": return review(args.job, args.reviewer, args.decision)
407
411
  if args.command == "publish": return publish(args.job, args.confirm)
408
412
  if args.command == "stale": return stale_jobs(args.project)
413
+ if args.command in ("email-matrix", "lifecycle"): return email_localization.run(args)
409
414
  return glossary(args.project)
410
415
  except (OSError, ValueError, json.JSONDecodeError) as error:
411
416
  print(f"ERROR: {error}", file=sys.stderr); return 1
@@ -0,0 +1,272 @@
1
+ #!/usr/bin/env python3
2
+ """Validate provider-neutral Maggie marketing runs and their named schedule layers.
3
+
4
+ A marketing run (`maggie-marketing-run-v1`) is one project-scoped record that
5
+ joins content, Social Share, Ads, and measurement work. It references source
6
+ revisions, capability evidence, approvals, mutations, and readbacks by ID; it
7
+ never copies provider secrets or large payloads.
8
+
9
+ Schedule layers keep four different meanings of "when" apart:
10
+
11
+ - `infrastructure`: when a worker wakes up (cron, systemd timer, queue);
12
+ validated by `maggie_schedule.py`.
13
+ - `content_calendar`: when reviewed content or a social post may publish.
14
+ - `campaign_flight`: when paid campaigns may run and spend.
15
+ - `measurement_window`: the data collection and attribution period, including
16
+ conversion lag.
17
+
18
+ No function in this module calls a provider, a model, or the network.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import json
24
+ import re
25
+ import sys
26
+ from datetime import date, datetime
27
+ from pathlib import Path
28
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
29
+
30
+
31
+ SCHEMA_VERSION = "maggie-marketing-run-v1"
32
+ SCHEDULE_LAYERS = {
33
+ "infrastructure": "Operations: when a worker wakes up (cron/systemd/queue); never a publish time.",
34
+ "content_calendar": "Editor/marketer: when a reviewed content or social item may publish.",
35
+ "campaign_flight": "Ads owner: when a paid campaign may run and spend.",
36
+ "measurement_window": "Analytics: data collection and attribution period, including conversion lag.",
37
+ }
38
+ LAYER_STATUSES = {"planned", "active", "paused", "completed", "cancelled"}
39
+ RUN_MODES = {"draft", "scheduled", "executing", "completed", "cancelled"}
40
+ APPROVAL_STATES = {"pending", "approved", "rejected", "not_required"}
41
+ MUTATION_STATUSES = {"planned", "pending", "succeeded", "failed", "cancelled"}
42
+ MUTATION_KINDS = {"external_publication", "ads_change", "content_delivery", "draft_creation", "configuration"}
43
+ # Workflow stages a regulated-content policy may annotate. Only external
44
+ # publication can be gated by the policy review; everything else continues.
45
+ STAGES = ("delivery", "draft", "seo_geo", "measurement", "external_publication")
46
+ POLICY_GATED_STAGES = {"external_publication"}
47
+ REVIEW_STATES = {"not_required", "pending", "approved", "rejected"}
48
+ SECRET_KEY = re.compile(r"(?:^|_)(?:secret|token|password|api_?key|authorization|cookie)s?$", re.I)
49
+ SECRET_VALUE = re.compile(r"^(?:Bearer\s|sk_(?:live|test)_|ya29\.|AIza)", re.I)
50
+ RUN_ID = re.compile(r"^[a-z0-9][a-z0-9._-]{2,127}$")
51
+
52
+
53
+ def _day(value: object) -> date | None:
54
+ if not isinstance(value, str):
55
+ return None
56
+ try:
57
+ return datetime.fromisoformat(value.replace("Z", "+00:00")).date() if "T" in value else date.fromisoformat(value)
58
+ except ValueError:
59
+ return None
60
+
61
+
62
+ def _zone(value: object) -> bool:
63
+ if not isinstance(value, str) or not value:
64
+ return False
65
+ try:
66
+ ZoneInfo(value)
67
+ except (ZoneInfoNotFoundError, ValueError):
68
+ return False
69
+ return True
70
+
71
+
72
+ def _secrets(value: object, path: str, found: list[str]) -> None:
73
+ if isinstance(value, dict):
74
+ for key, item in value.items():
75
+ if SECRET_KEY.search(str(key)) and not str(key).endswith(("_ref", "Ref")):
76
+ found.append(f"{path}.{key}")
77
+ _secrets(item, f"{path}.{key}", found)
78
+ elif isinstance(value, list):
79
+ for index, item in enumerate(value):
80
+ _secrets(item, f"{path}[{index}]", found)
81
+ elif isinstance(value, str) and SECRET_VALUE.search(value):
82
+ found.append(path)
83
+
84
+
85
+ def validate_layers(layers: object, *, label: str = "scheduleLayers") -> tuple[list[str], list[str]]:
86
+ """Validate named schedule layers shared by maggie-schedule, Social Share, Ads, and analytics."""
87
+ errors: list[str] = []
88
+ warnings: list[str] = []
89
+ if not isinstance(layers, list) or not layers:
90
+ return [f"{label} must be a non-empty list"], warnings
91
+ seen: set[tuple[str, str]] = set()
92
+ for index, layer in enumerate(layers):
93
+ prefix = f"{label}[{index}]"
94
+ if not isinstance(layer, dict):
95
+ errors.append(f"{prefix} must be an object")
96
+ continue
97
+ name = layer.get("layer")
98
+ if name not in SCHEDULE_LAYERS:
99
+ errors.append(f"{prefix}.layer must be one of {', '.join(SCHEDULE_LAYERS)}")
100
+ continue
101
+ key = (name, str(layer.get("id") or ""))
102
+ if key in seen:
103
+ errors.append(f"{prefix} duplicates layer {name} id {key[1] or '(none)'}")
104
+ seen.add(key)
105
+ if not _zone(layer.get("timezone")):
106
+ errors.append(f"{prefix}.timezone must be an IANA time zone")
107
+ for field in ("owner", "reconciliation"):
108
+ if not isinstance(layer.get(field), str) or not layer[field].strip():
109
+ errors.append(f"{prefix}.{field} is required")
110
+ if layer.get("status") not in LAYER_STATUSES:
111
+ errors.append(f"{prefix}.status must be one of {', '.join(sorted(LAYER_STATUSES))}")
112
+ if name == "infrastructure":
113
+ if not layer.get("scheduleRef"):
114
+ errors.append(f"{prefix}.scheduleRef must point at the maggie-schedule job (infrastructure has no publish time)")
115
+ if "start" in layer or "end" in layer:
116
+ warnings.append(f"{prefix} infrastructure layers describe wake-up intervals; start/end are ignored")
117
+ continue
118
+ start, end = _day(layer.get("start")), _day(layer.get("end"))
119
+ if not start:
120
+ errors.append(f"{prefix}.start must be an ISO date or date-time")
121
+ if name != "content_calendar" and not end:
122
+ errors.append(f"{prefix}.end is required for {name}")
123
+ if start and end and end < start:
124
+ errors.append(f"{prefix}.end is before start")
125
+ if name == "measurement_window" and not isinstance(layer.get("lagDays"), int):
126
+ warnings.append(f"{prefix}.lagDays is not set; attribution lag will be ignored")
127
+ return errors, warnings
128
+
129
+
130
+ def validate_policy(policy: object, stages: object, mutations: list) -> tuple[list[str], list[str]]:
131
+ """Regulated-content review gates only external publication (default off, backward compatible)."""
132
+ errors: list[str] = []
133
+ warnings: list[str] = []
134
+ if policy is None:
135
+ policy = {}
136
+ if not isinstance(policy, dict):
137
+ return ["policy must be an object"], warnings
138
+ required = bool(policy.get("regulatedContent") and policy.get("reviewRequired", True))
139
+ review = policy.get("reviewState", "pending" if required else "not_required")
140
+ if review not in REVIEW_STATES:
141
+ errors.append(f"policy.reviewState must be one of {', '.join(sorted(REVIEW_STATES))}")
142
+ if isinstance(stages, dict):
143
+ for stage, state in stages.items():
144
+ if stage not in STAGES:
145
+ errors.append(f"stages.{stage} is not a known stage ({', '.join(STAGES)})")
146
+ continue
147
+ blocked = isinstance(state, dict) and state.get("status") == "blocked" and state.get("reason") == "policy_review"
148
+ if blocked and stage not in POLICY_GATED_STAGES:
149
+ errors.append(f"stages.{stage} is blocked by policy review; regulated-content review may only gate "
150
+ "external publication — delivery, drafts, SEO/GEO and measurement must continue")
151
+ elif stages is not None:
152
+ errors.append("stages must be an object keyed by stage")
153
+ if required and review != "approved":
154
+ for mutation in mutations:
155
+ if isinstance(mutation, dict) and mutation.get("kind") == "external_publication" and mutation.get("status") in {"pending", "succeeded"}:
156
+ errors.append(f"mutation {mutation.get('mutationId')} publishes regulated content before policy review is approved")
157
+ if review == "pending":
158
+ warnings.append("regulated-content review is pending; external publication stays held, other stages continue")
159
+ return errors, warnings
160
+
161
+
162
+ def validate_run(run: object) -> dict:
163
+ errors: list[str] = []
164
+ warnings: list[str] = []
165
+ if not isinstance(run, dict):
166
+ return {"valid": False, "errors": ["marketing run must be a JSON object"], "warnings": []}
167
+ if run.get("schemaVersion") != SCHEMA_VERSION:
168
+ errors.append(f"schemaVersion must be {SCHEMA_VERSION}")
169
+ if not isinstance(run.get("runId"), str) or not RUN_ID.match(run["runId"]):
170
+ errors.append("runId must be a lowercase slug (3-128 chars)")
171
+ for field in ("projectRef", "objective"):
172
+ if not isinstance(run.get(field), str) or not run[field].strip():
173
+ errors.append(f"{field} is required")
174
+ conversion = run.get("conversion")
175
+ if not isinstance(conversion, dict) or not conversion.get("event") or not conversion.get("source"):
176
+ errors.append("conversion needs event and source")
177
+ channels = run.get("channels")
178
+ if not isinstance(channels, list) or not channels or not all(isinstance(item, str) and item for item in channels):
179
+ errors.append("channels must be a non-empty list of channel names")
180
+ if not _zone(run.get("timezone")):
181
+ errors.append("timezone must be an IANA time zone")
182
+ if run.get("mode") not in RUN_MODES:
183
+ errors.append(f"mode must be one of {', '.join(sorted(RUN_MODES))}")
184
+ if "scheduled_at" in run or "scheduledAt" in run:
185
+ errors.append("a run must not carry a single scheduled_at; use named scheduleLayers")
186
+ for index, revision in enumerate(run.get("sourceRevisions") or []):
187
+ if not isinstance(revision, dict) or not revision.get("sourceId") or not revision.get("revision"):
188
+ errors.append(f"sourceRevisions[{index}] needs sourceId and revision")
189
+ for index, ref in enumerate(run.get("capabilityEvidence") or []):
190
+ if not isinstance(ref, str) or not ref:
191
+ errors.append(f"capabilityEvidence[{index}] must be an evidence ID or path reference")
192
+ approval = run.get("approval")
193
+ if not isinstance(approval, dict) or approval.get("state") not in APPROVAL_STATES:
194
+ errors.append(f"approval.state must be one of {', '.join(sorted(APPROVAL_STATES))}")
195
+ approval = {}
196
+ if approval.get("state") == "approved" and not (approval.get("actor") and approval.get("approvalId")):
197
+ errors.append("an approved run needs approval.actor and approval.approvalId")
198
+ layer_errors, layer_warnings = validate_layers(run.get("scheduleLayers"))
199
+ errors += layer_errors
200
+ warnings += layer_warnings
201
+ mutations = run.get("mutations") or []
202
+ if not isinstance(mutations, list):
203
+ errors.append("mutations must be a list")
204
+ mutations = []
205
+ mutation_ids: set[str] = set()
206
+ for index, mutation in enumerate(mutations):
207
+ prefix = f"mutations[{index}]"
208
+ if not isinstance(mutation, dict) or not mutation.get("mutationId"):
209
+ errors.append(f"{prefix} needs mutationId")
210
+ continue
211
+ mutation_ids.add(mutation["mutationId"])
212
+ if mutation.get("kind") not in MUTATION_KINDS:
213
+ errors.append(f"{prefix}.kind must be one of {', '.join(sorted(MUTATION_KINDS))}")
214
+ if mutation.get("status") not in MUTATION_STATUSES:
215
+ errors.append(f"{prefix}.status must be one of {', '.join(sorted(MUTATION_STATUSES))}")
216
+ if mutation.get("kind") in {"external_publication", "ads_change"}:
217
+ if not mutation.get("idempotencyKey"):
218
+ errors.append(f"{prefix} external mutation needs an idempotencyKey")
219
+ if mutation.get("status") in {"pending", "succeeded"} and approval.get("state") != "approved":
220
+ errors.append(f"{prefix} executed before the run was approved")
221
+ readback_refs = set()
222
+ for index, readback in enumerate(run.get("readbacks") or []):
223
+ if not isinstance(readback, dict) or not readback.get("readbackId"):
224
+ errors.append(f"readbacks[{index}] needs readbackId")
225
+ continue
226
+ ref = readback.get("mutationRef")
227
+ if ref and ref not in mutation_ids:
228
+ errors.append(f"readbacks[{index}].mutationRef {ref} does not match a mutation")
229
+ readback_refs.add(ref)
230
+ for mutation in mutations:
231
+ if isinstance(mutation, dict) and mutation.get("status") == "succeeded" and mutation.get("mutationId") not in readback_refs:
232
+ message = f"mutation {mutation.get('mutationId')} succeeded without a readback reference"
233
+ (errors if run.get("mode") == "completed" else warnings).append(message)
234
+ policy_errors, policy_warnings = validate_policy(run.get("policy"), run.get("stages"), mutations)
235
+ errors += policy_errors
236
+ warnings += policy_warnings
237
+ leaked: list[str] = []
238
+ _secrets(run, "run", leaked)
239
+ if leaked:
240
+ errors.append("run must reference secrets, not contain them: " + ", ".join(leaked))
241
+ return {"valid": not errors, "errors": errors, "warnings": warnings,
242
+ "runId": run.get("runId"), "layers": sorted({layer.get("layer") for layer in run.get("scheduleLayers") or [] if isinstance(layer, dict)} - {None})}
243
+
244
+
245
+ def main() -> int:
246
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
247
+ sub = parser.add_subparsers(dest="command", required=True)
248
+ check = sub.add_parser("validate", help="validate a maggie-marketing-run-v1 document")
249
+ check.add_argument("input")
250
+ layers = sub.add_parser("layers", help="validate a scheduleLayers list, or print the layer model with no input")
251
+ layers.add_argument("input", nargs="?")
252
+ args = parser.parse_args()
253
+ try:
254
+ if args.command == "layers" and not args.input:
255
+ print(json.dumps({"layers": SCHEDULE_LAYERS}, indent=2))
256
+ return 0
257
+ value = json.loads(Path(args.input).read_text(encoding="utf-8"))
258
+ except (OSError, json.JSONDecodeError) as error:
259
+ parser.error(str(error))
260
+ return 2
261
+ if args.command == "validate":
262
+ result = validate_run(value)
263
+ else:
264
+ items = value.get("scheduleLayers") if isinstance(value, dict) else value
265
+ errors, warnings = validate_layers(items)
266
+ result = {"valid": not errors, "errors": errors, "warnings": warnings}
267
+ print(json.dumps(result, indent=2, ensure_ascii=False))
268
+ return 0 if result["valid"] else 1
269
+
270
+
271
+ if __name__ == "__main__":
272
+ raise SystemExit(main())
@@ -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)