@topy-ai/maggie 0.7.48 → 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 (49) hide show
  1. package/README-zh-TW.md +2 -2
  2. package/README.md +31 -4
  3. package/bin/maggie.js +17 -4
  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-ops/marketing-run-v1.schema.json +89 -0
  9. package/bundled-contracts/maggie-ops/notification-catalogue-example-v1.json +941 -0
  10. package/bundled-contracts/maggie-ops/notification-catalogue-v1.schema.json +273 -0
  11. package/bundled-contracts/maggie-service-booking/fact-provenance-v1.schema.json +128 -0
  12. package/bundled-contracts/maggie-service-booking/import-stages-v1.schema.json +191 -0
  13. package/bundled-references/google-integrations-runbook.md +54 -0
  14. package/bundled-skills/maggie-content-localization/SKILL.md +17 -1
  15. package/bundled-skills/maggie-content-localization/references/email-and-lifecycle.md +83 -0
  16. package/bundled-skills/maggie-deployment/SKILL.md +89 -7
  17. package/bundled-skills/maggie-deployment/references/vps.md +38 -6
  18. package/bundled-skills/maggie-design/SKILL.md +55 -0
  19. package/bundled-skills/maggie-feedback/SKILL.md +29 -0
  20. package/bundled-skills/maggie-ops/SKILL.md +37 -0
  21. package/bundled-skills/maggie-ops/references/notifications-operations.md +154 -0
  22. package/bundled-skills/maggie-service-booking/SKILL.md +59 -0
  23. package/bundled-skills/maggie-social-share/SKILL.md +19 -2
  24. package/bundled-skills/maggie-social-share/references/marketing-run.md +57 -0
  25. package/bundled-skills/maggie-social-share/references/queue-and-connections.md +51 -0
  26. package/bundled-tools/clis/maggie_deployment.py +58 -17
  27. package/bundled-tools/clis/maggie_deployment_auth_canary.py +216 -0
  28. package/bundled-tools/clis/maggie_deployment_collect.py +225 -0
  29. package/bundled-tools/clis/maggie_deployment_readiness.py +53 -3
  30. package/bundled-tools/clis/maggie_design.py +8 -0
  31. package/bundled-tools/clis/maggie_design_verify.py +359 -0
  32. package/bundled-tools/clis/maggie_docs.py +82 -4
  33. package/bundled-tools/clis/maggie_email_localization.py +382 -0
  34. package/bundled-tools/clis/maggie_feedback.py +9 -2
  35. package/bundled-tools/clis/maggie_icon_glyph_check.py +382 -0
  36. package/bundled-tools/clis/maggie_icon_release_gate.py +28 -1
  37. package/bundled-tools/clis/maggie_localization.py +5 -0
  38. package/bundled-tools/clis/maggie_marketing_run.py +272 -0
  39. package/bundled-tools/clis/maggie_notifications.py +528 -0
  40. package/bundled-tools/clis/maggie_ops.py +87 -0
  41. package/bundled-tools/clis/maggie_schedule.py +12 -2
  42. package/bundled-tools/clis/maggie_service_import.py +520 -0
  43. package/bundled-tools/clis/maggie_social_queue.py +104 -0
  44. package/bundled-tools/clis/maggie_social_share.py +6 -2
  45. package/bundled-tools/runtime/google_ads.py +306 -0
  46. package/bundled-tools/runtime/google_capabilities.py +18 -2
  47. package/bundled-tools/runtime/google_remediation.py +152 -0
  48. package/package.json +1 -1
  49. package/references/google-integrations-runbook.md +54 -0
@@ -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())