@topy-ai/maggie 0.7.53 → 0.7.55

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 (65) hide show
  1. package/README-zh-TW.md +28 -50
  2. package/README.md +80 -64
  3. package/bin/maggie.js +61 -9
  4. package/bundled-contracts/maggie-assistant-tools/booking-receptionist-example-v1.json +18 -2
  5. package/bundled-contracts/maggie-assistant-tools/report-v1.schema.json +43 -0
  6. package/bundled-contracts/maggie-assistant-tools/tool-registry-example-v1.json +249 -11
  7. package/bundled-contracts/maggie-assistant-tools/tool-registry-v1.schema.json +175 -0
  8. package/bundled-contracts/maggie-ops/agent-routines-v1.schema.json +48 -0
  9. package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +1 -1
  10. package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +2 -2
  11. package/bundled-skills/catalog.json +1 -1
  12. package/bundled-skills/maggie-assistant-tools/SKILL.md +34 -1
  13. package/bundled-skills/maggie-assistant-tools/references/report-inbox.md +45 -0
  14. package/bundled-skills/maggie-assistant-tools/references/unattended-agents.md +70 -0
  15. package/bundled-skills/maggie-blog/SKILL.md +15 -0
  16. package/bundled-skills/maggie-booking/SKILL.md +20 -12
  17. package/bundled-skills/maggie-content-localization/SKILL.md +26 -0
  18. package/bundled-skills/maggie-dash/SKILL.md +37 -2
  19. package/bundled-skills/maggie-deployment/SKILL.md +104 -1
  20. package/bundled-skills/maggie-deployment/references/vps.md +5 -0
  21. package/bundled-skills/maggie-design/SKILL.md +81 -0
  22. package/bundled-skills/maggie-feedback/SKILL.md +10 -2
  23. package/bundled-skills/maggie-marketplace/SKILL.md +22 -2
  24. package/bundled-skills/maggie-marketplace/references/directory-search.md +1 -1
  25. package/bundled-skills/maggie-marketplace/references/directory.mjs +2 -2
  26. package/bundled-skills/maggie-mobile-app/SKILL.md +29 -0
  27. package/bundled-skills/maggie-ops/SKILL.md +28 -0
  28. package/bundled-skills/maggie-ops/references/agents-repository.md +108 -0
  29. package/bundled-skills/maggie-ops/references/ops-autopilot.md +65 -0
  30. package/bundled-skills/maggie-ops/references/routine-mode.md +92 -0
  31. package/bundled-skills/maggie-seo-geo/SKILL.md +9 -1
  32. package/bundled-skills/maggie-social-share/SKILL.md +50 -0
  33. package/bundled-tools/clis/maggie.py +63 -3
  34. package/bundled-tools/clis/maggie_assistant_tools.py +225 -1
  35. package/bundled-tools/clis/maggie_booking.py +9 -8
  36. package/bundled-tools/clis/maggie_dash.py +173 -3
  37. package/bundled-tools/clis/maggie_deployment_ops.py +64 -1
  38. package/bundled-tools/clis/maggie_design.py +7 -1
  39. package/bundled-tools/clis/maggie_design_bulk.py +290 -0
  40. package/bundled-tools/clis/maggie_design_lint.py +131 -1
  41. package/bundled-tools/clis/maggie_feedback.py +41 -13
  42. package/bundled-tools/clis/maggie_host_runtime.py +458 -0
  43. package/bundled-tools/clis/maggie_live_urls.py +147 -0
  44. package/bundled-tools/clis/maggie_localization.py +3 -0
  45. package/bundled-tools/clis/maggie_localization_keys.py +151 -0
  46. package/bundled-tools/clis/maggie_media_generate.py +174 -0
  47. package/bundled-tools/clis/maggie_mobile.py +84 -1
  48. package/bundled-tools/clis/maggie_ops.py +2 -0
  49. package/bundled-tools/clis/maggie_ops_routines.py +352 -0
  50. package/bundled-tools/clis/maggie_product_release.py +49 -1
  51. package/bundled-tools/clis/maggie_query_ideas.py +154 -0
  52. package/bundled-tools/clis/maggie_skill_catalog.py +16 -2
  53. package/bundled-tools/clis/maggie_social_share.py +223 -2
  54. package/bundled-tools/clis/maggie_version_sources.py +93 -0
  55. package/bundled-tools/integrations/maggie-api-pull.md +49 -0
  56. package/package.json +1 -1
  57. package/bundled-contracts/maggiedash/execution-board.json +0 -5463
  58. package/bundled-references/maggiedash-booking/CURRENT-STATE.md +0 -210
  59. package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +0 -5463
  60. package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +0 -270
  61. package/bundled-references/maggiedash-booking/PROGRESS.md +0 -279
  62. package/bundled-references/maggiedash-booking/README.md +0 -288
  63. package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +0 -551
  64. package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +0 -107
  65. package/bundled-references/maggiedash-booking/TASKS.md +0 -178
@@ -5,9 +5,11 @@ from __future__ import annotations
5
5
  import argparse
6
6
  import json
7
7
  import sys
8
- from datetime import datetime, timezone
8
+ from datetime import datetime, timedelta, timezone
9
9
  from pathlib import Path
10
+ from urllib.error import HTTPError, URLError
10
11
  from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
12
+ from urllib.request import Request, urlopen
11
13
 
12
14
  sys.path.insert(0, str(Path(__file__).resolve().parent))
13
15
  from maggie_social_queue import check_campaigns, connection_health, parse_instant, plan_queue, reconcile, validate_adapters # noqa: E402
@@ -43,6 +45,66 @@ def tracked_url(url: str, channel: str) -> str:
43
45
  return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, urlencode(query), parsed.fragment))
44
46
 
45
47
 
48
+ # Only a definite "gone" answer drops an article. Blocked (403/429), failing
49
+ # (5xx) or unreachable pages are kept: a firewall or an outage must never empty
50
+ # the list of articles to share.
51
+ GONE = {404, 410}
52
+
53
+
54
+ def fetch_status(url: str, timeout: float = 10.0) -> int | None:
55
+ """Final HTTP status after redirects, or None when the page could not be reached."""
56
+ for method in ("HEAD", "GET"):
57
+ request = Request(url, method=method, headers={"User-Agent": "MaggieSocialShare/1.0 (+link check)", "Accept": "text/html,*/*"})
58
+ try:
59
+ with urlopen(request, timeout=timeout) as response:
60
+ return int(response.status)
61
+ except HTTPError as error:
62
+ # Some servers refuse HEAD; ask again with GET before deciding.
63
+ if method == "HEAD" and error.code in {400, 403, 405, 501}:
64
+ continue
65
+ return int(error.code)
66
+ except (URLError, TimeoutError, OSError):
67
+ return None
68
+ return None
69
+
70
+
71
+ def link_verdict(status: int | None) -> str:
72
+ if status in GONE:
73
+ return "gone"
74
+ if status is not None and 200 <= status < 400:
75
+ return "live"
76
+ return "unknown"
77
+
78
+
79
+ def check_links(urls: list[str], cache_path: Path | None, ttl_hours: float, now: datetime, fetch=fetch_status) -> dict:
80
+ cache: dict = {}
81
+ if cache_path and cache_path.is_file():
82
+ try:
83
+ cache = json.loads(cache_path.read_text(encoding="utf-8"))
84
+ except (OSError, json.JSONDecodeError):
85
+ cache = {}
86
+ results = []
87
+ for url in dict.fromkeys(urls):
88
+ cached = cache.get(url)
89
+ fresh = False
90
+ if isinstance(cached, dict) and cached.get("checked_at"):
91
+ try:
92
+ fresh = (now - parse_instant(cached["checked_at"], "checked_at")).total_seconds() < ttl_hours * 3600
93
+ except ValueError:
94
+ fresh = False
95
+ if fresh:
96
+ status, source = cached.get("status"), "cache"
97
+ else:
98
+ status, source = fetch(url), "fetched"
99
+ cache[url] = {"status": status, "checked_at": now.isoformat().replace("+00:00", "Z")}
100
+ results.append({"url": url, "status": status, "verdict": link_verdict(status), "source": source})
101
+ if cache_path:
102
+ cache_path.parent.mkdir(parents=True, exist_ok=True)
103
+ cache_path.write_text(json.dumps(cache, indent=2) + "\n", encoding="utf-8")
104
+ gone = [item for item in results if item["verdict"] == "gone"]
105
+ return {"valid": True, "checked": len(results), "skip": gone, "keep": [item for item in results if item["verdict"] != "gone"], "results": results}
106
+
107
+
46
108
  def plan(content: dict, channels: list[str], workspace_domain: str | None = None) -> dict:
47
109
  errors = [f"missing {key}" for key in REQUIRED if not content.get(key)]
48
110
  invalid = [channel for channel in channels if channel not in LIMITS]
@@ -73,6 +135,130 @@ def plan(content: dict, channels: list[str], workspace_domain: str | None = None
73
135
  }
74
136
 
75
137
 
138
+ def shared_picture_errors(posts: list[dict], label: str) -> list[str]:
139
+ """Posts written together for several channels share one picture.
140
+
141
+ A channel may differ only when a person picked its picture
142
+ (`image_source: "user"`)."""
143
+ automatic = [post for post in posts if post.get("image_source") != "user"]
144
+ pictures = {post.get("image_url") or None for post in automatic}
145
+ if len(pictures) <= 1:
146
+ return []
147
+ by_picture: dict[str, list[str]] = {}
148
+ for post in automatic:
149
+ by_picture.setdefault(post.get("image_url") or "(none)", []).append(str(post.get("channel") or post.get("name") or post.get("id")))
150
+ detail = "; ".join(f"{', '.join(names)}: {picture}" for picture, names in by_picture.items())
151
+ return [f"{label}: channels written together must share one picture ({detail}); set image_source user on a channel whose picture a person chose"]
152
+
153
+
154
+ def picture_groups(value: dict) -> dict:
155
+ """Check that drafts written from one idea share one picture across channels."""
156
+ drafts = value.get("drafts", []) if isinstance(value, dict) else value
157
+ groups: dict[str, list[dict]] = {}
158
+ for draft in drafts:
159
+ key = draft.get("idea_id") or draft.get("group_id")
160
+ if key:
161
+ groups.setdefault(str(key), []).append(draft)
162
+ errors: list[str] = []
163
+ report = []
164
+ for key, items in sorted(groups.items()):
165
+ problems = shared_picture_errors(items, f"idea {key}")
166
+ errors.extend(problems)
167
+ automatic = [item.get("image_url") for item in items if item.get("image_source") != "user"]
168
+ report.append({"idea_id": key, "channels": [item.get("channel") for item in items], "picture": automatic[0] if automatic and not problems else None, "shared": not problems})
169
+ return {"valid": not errors, "errors": errors, "ideas": report}
170
+
171
+
172
+ REUSE_DAYS = 30
173
+ SHARED_STATUSES = {"approved", "scheduled", "publishing", "published"}
174
+ OPEN_STATUSES = {"draft", "queued", "held", "needs_review", "pending"}
175
+
176
+
177
+ def article_key(item: dict) -> str:
178
+ """The article a post shares: its source id, else its address without tracking parameters."""
179
+ if item.get("source_content_id"):
180
+ return "id:" + str(item["source_content_id"])
181
+ url = str(item.get("canonical_url") or item.get("url") or "")
182
+ parts = urlsplit(url)
183
+ query = urlencode([(key, value) for key, value in parse_qsl(parts.query) if not key.startswith("utm_")])
184
+ return "url:" + urlunsplit((parts.scheme, parts.netloc.lower().removeprefix("www."), parts.path.rstrip("/"), query, ""))
185
+
186
+
187
+ def shared_on(history: list[dict], now: datetime, days: int = REUSE_DAYS) -> dict[str, dict[str, str]]:
188
+ """Article key -> {channel: last shared instant} within the rolling window."""
189
+ used: dict[str, dict[str, str]] = {}
190
+ for post in history:
191
+ if post.get("status", "published") not in SHARED_STATUSES:
192
+ continue
193
+ stamp = post.get("posted_at") or post.get("published_at") or post.get("scheduled_at")
194
+ if not stamp or not post.get("channel"):
195
+ continue
196
+ when = parse_instant(stamp, "history posted_at")
197
+ if (now - when).total_seconds() > days * 86400 or when > now + timedelta(days=days):
198
+ continue
199
+ channels = used.setdefault(article_key(post), {})
200
+ channel = str(post["channel"])
201
+ channels[channel] = max(channels.get(channel, ""), stamp)
202
+ return used
203
+
204
+
205
+ def sources_used_on(document: dict, now: datetime) -> dict:
206
+ """Each article with the channels it was shared to in the last 30 days (rolling)."""
207
+ used = shared_on(document.get("history") or [], now, int(document.get("reuse_days") or REUSE_DAYS))
208
+ articles = []
209
+ for article in document.get("articles") or []:
210
+ channels = used.get(article_key(article), {})
211
+ articles.append({**article, "used_on": sorted(channels), "last_shared": channels})
212
+ return {"valid": True, "reuse_days": int(document.get("reuse_days") or REUSE_DAYS), "articles": articles}
213
+
214
+
215
+ def queue_check(document: dict, now: datetime) -> dict:
216
+ """What an automatic run and "Approve all" may do with the open posts.
217
+
218
+ * reuse is per channel: an article shared on a channel in the last 30 days
219
+ (rolling) is not shared there again — other channels are free;
220
+ * every new post carries a picture;
221
+ * in auto mode a hold raised only by optional checks does not block a post;
222
+ a failing required check always does;
223
+ * "Approve all" covers every open post, held ones included.
224
+ """
225
+ mode = str(document.get("approval_mode") or "manual")
226
+ if mode not in {"auto", "manual"}:
227
+ raise ValueError("approval_mode must be auto or manual")
228
+ days = int(document.get("reuse_days") or REUSE_DAYS)
229
+ used = shared_on(document.get("history") or [], now, days)
230
+ planned: dict[tuple[str, str], str] = {}
231
+ posts, approve_all, blocked = [], [], []
232
+ for post in document.get("posts") or []:
233
+ if post.get("status", "draft") not in OPEN_STATUSES:
234
+ continue
235
+ pid, channel, key = str(post.get("id")), str(post.get("channel") or ""), article_key(post)
236
+ reasons, notes = [], []
237
+ last = used.get(key, {}).get(channel)
238
+ if last:
239
+ reasons.append(f"this article was shared on {channel} at {last}, within {days} days")
240
+ elif (channel, key) in planned:
241
+ reasons.append(f"post {planned[(channel, key)]} already shares this article on {channel}")
242
+ planned.setdefault((channel, key), pid)
243
+ if not post.get("image_url"):
244
+ reasons.append("needs a picture: every new post carries one (use the picture of its idea)")
245
+ for hold in post.get("holds") or []:
246
+ name = str(hold.get("check") or hold.get("reason") or "check")
247
+ if hold.get("required"):
248
+ reasons.append(f"required check failed: {name}")
249
+ elif mode == "auto":
250
+ notes.append(f"optional check {name} does not hold the post in auto mode")
251
+ else:
252
+ notes.append(f"held for review by optional check {name}; Approve all includes it")
253
+ held = bool(post.get("holds")) or post.get("status") in {"held", "needs_review"}
254
+ entry = {"id": pid, "channel": channel, "held": held, "blocked": bool(reasons), "reasons": reasons, "notes": notes}
255
+ if mode == "auto":
256
+ entry["auto_publish"] = not reasons
257
+ posts.append(entry)
258
+ (blocked if reasons else approve_all).append(pid)
259
+ return {"valid": not blocked, "approval_mode": mode, "reuse_days": days, "approve_all": approve_all, "blocked": blocked, "posts": posts}
260
+
261
+
76
262
  def validate(value: dict) -> list[str]:
77
263
  errors = [f"missing {key}" for key in REQUIRED if not value.get(key)]
78
264
  if value.get("status") not in {"draft", "approved", "scheduled", "publishing", "published", "failed", "cancelled"}:
@@ -84,7 +270,10 @@ def validate(value: dict) -> list[str]:
84
270
  errors.append(f"canonical_url is on {host_of(value['canonical_url'])}, not the workspace domain {domain}")
85
271
  elif not domain and value.get("status") in LIVE_STATUSES:
86
272
  errors.append("workspace_domain is required before approval: the source must be published on the workspace's own domain")
87
- for channel in value.get("channels", []):
273
+ channels = value.get("channels", [])
274
+ if value.get("image_url"):
275
+ errors.extend(shared_picture_errors([{**channel, "image_url": channel.get("image_url", value["image_url"])} for channel in channels], "draft"))
276
+ for channel in channels:
88
277
  name = channel.get("name")
89
278
  if name not in LIMITS:
90
279
  errors.append(f"unsupported channel: {name}")
@@ -114,6 +303,19 @@ def main() -> int:
114
303
  create.add_argument("--channels", default="linkedin,facebook")
115
304
  create.add_argument("--workspace-domain", help="the workspace's own site domain; the source must be published there")
116
305
  create.add_argument("--output")
306
+ create.add_argument("--check-link", action="store_true", help="fetch canonical_url first and refuse a definite 404/410")
307
+ pictures = sub.add_parser("pictures", help="check that drafts written from one idea share one picture on every channel")
308
+ pictures.add_argument("input", help='JSON: {"drafts": [{"id", "idea_id", "channel", "image_url", "image_source"?}]}')
309
+ links = sub.add_parser("link-check", help="drop articles whose link is gone (404/410) before planning posts; keep blocked or failing pages")
310
+ links.add_argument("input", help="JSON: a list of URLs or article objects with canonical_url, or {\"articles\": [...]}")
311
+ links.add_argument("--cache", default=".maggie/social-link-cache.json", help="verdict cache (default: .maggie/social-link-cache.json); 'none' disables it")
312
+ links.add_argument("--ttl-hours", type=float, default=6.0)
313
+ used_on = sub.add_parser("used-on", help="list, per article, the channels it was shared to in the last 30 days (rolling)")
314
+ used_on.add_argument("input", help='JSON: {"articles": [...], "history": [{"channel", "source_content_id"|"canonical_url", "posted_at", "status"}]}')
315
+ used_on.add_argument("--now")
316
+ qcheck = sub.add_parser("queue-check", help="decide what auto mode and Approve all may do: per-channel 30-day reuse, a picture on every post, optional holds")
317
+ qcheck.add_argument("input", help='JSON: {"approval_mode": "auto"|"manual", "posts": [...], "history": [...]}')
318
+ qcheck.add_argument("--now")
117
319
  check = sub.add_parser("validate", help="validate a draft channel contract")
118
320
  check.add_argument("input")
119
321
  adapters = sub.add_parser("adapters", help="check canonical OAuth scope sets and shared OAuth clients")
@@ -134,6 +336,15 @@ def main() -> int:
134
336
  schema.add_argument("input")
135
337
  args = parser.parse_args()
136
338
  try:
339
+ if args.command == "link-check":
340
+ raw = json.loads(Path(args.input).read_text(encoding="utf-8"))
341
+ articles = raw.get("articles", []) if isinstance(raw, dict) else raw
342
+ urls = [item if isinstance(item, str) else item.get("canonical_url") or item.get("url") for item in articles]
343
+ result = check_links([url for url in urls if url], None if args.cache == "none" else Path(args.cache), args.ttl_hours, datetime.now(timezone.utc))
344
+ by_url = {item["url"]: item for item in result["results"]}
345
+ result["articles"] = [item for item in articles if by_url.get(item if isinstance(item, str) else (item.get("canonical_url") or item.get("url")), {}).get("verdict") != "gone"]
346
+ print(json.dumps(result, indent=2, ensure_ascii=False))
347
+ return 0
137
348
  value = read(Path(args.input))
138
349
  now = datetime.now(timezone.utc)
139
350
  if args.command == "schema-check":
@@ -143,6 +354,12 @@ def main() -> int:
143
354
  result = validate_adapters(value)
144
355
  elif args.command == "connections":
145
356
  result = connection_health(value, parse_instant(args.now, "--now") if args.now else now)
357
+ elif args.command == "used-on":
358
+ result = sources_used_on(value, parse_instant(args.now, "--now") if args.now else now)
359
+ elif args.command == "queue-check":
360
+ result = queue_check(value, parse_instant(args.now, "--now") if args.now else now)
361
+ elif args.command == "pictures":
362
+ result = picture_groups(value if isinstance(value, dict) else {"drafts": value})
146
363
  elif args.command == "reconcile":
147
364
  result = reconcile(value)
148
365
  elif args.command == "campaigns":
@@ -156,6 +373,10 @@ def main() -> int:
156
373
  return 0
157
374
  else:
158
375
  if args.command == "plan":
376
+ if args.check_link and value.get("canonical_url"):
377
+ status = fetch_status(value["canonical_url"])
378
+ if link_verdict(status) == "gone":
379
+ raise ValueError(f"canonical_url answers HTTP {status}: the article is gone; pick a live article (blocked or failing pages are not treated as gone)")
159
380
  value = plan(value, [item.strip() for item in args.channels.split(",") if item.strip()], args.workspace_domain)
160
381
  if args.output:
161
382
  Path(args.output).parent.mkdir(parents=True, exist_ok=True)
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env python3
2
+ """Check that every version source of a package agrees, and that generated data is current.
3
+
4
+ A packager that writes its own version constant over a hand-bumped manifest
5
+ (or the reverse) ships one version in one place and another elsewhere; a site
6
+ built from generated data that was not regenerated then fails to deploy. This
7
+ check never writes a version: it reads each source and refuses on a mismatch.
8
+
9
+ Sources:
10
+ FILE a plain VERSION file (first line)
11
+ FILE:dotted.key a JSON value, e.g. package.json:version or manifest.json:meta.version
12
+ FILE~NAME a code constant NAME = "x.y.z" / NAME: "x.y.z" / export const NAME = 'x.y.z'
13
+
14
+ Generated data:
15
+ --generated OUT --command "node scripts/gen.mjs {out}"
16
+ runs the generator into a temporary file ({out}) and fails when OUT differs.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import json
22
+ import re
23
+ import shlex
24
+ import subprocess
25
+ import sys
26
+ import tempfile
27
+ from pathlib import Path
28
+
29
+
30
+ def read_source(root: Path, spec: str) -> str:
31
+ if "~" in spec:
32
+ name, constant = spec.split("~", 1)
33
+ text = (root / name).read_text(encoding="utf-8")
34
+ match = re.search(rf"\b{re.escape(constant)}\b\s*[:=]\s*['\"]([^'\"]+)['\"]", text)
35
+ if not match:
36
+ raise ValueError(f"{spec}: constant {constant} not found")
37
+ return match.group(1)
38
+ if ":" in spec and not re.match(r"^[A-Za-z]:[\\/]", spec):
39
+ name, dotted = spec.split(":", 1)
40
+ value: object = json.loads((root / name).read_text(encoding="utf-8"))
41
+ for key in dotted.split("."):
42
+ if not isinstance(value, dict) or key not in value:
43
+ raise ValueError(f"{spec}: key {dotted} not found")
44
+ value = value[key]
45
+ return str(value)
46
+ return (root / spec).read_text(encoding="utf-8").splitlines()[0].strip()
47
+
48
+
49
+ def check_generated(root: Path, output: str, command: str) -> dict:
50
+ with tempfile.TemporaryDirectory() as directory:
51
+ fresh = Path(directory) / Path(output).name
52
+ argv = [part.replace("{out}", str(fresh)) for part in shlex.split(command)]
53
+ completed = subprocess.run(argv, cwd=root, capture_output=True, text=True, check=False)
54
+ if completed.returncode:
55
+ return {"file": output, "current": False, "error": f"generator exited {completed.returncode}: {(completed.stderr or completed.stdout).strip()[-300:]}"}
56
+ if not fresh.is_file():
57
+ return {"file": output, "current": False, "error": "generator did not write {out}"}
58
+ current = (root / output).is_file() and (root / output).read_bytes() == fresh.read_bytes()
59
+ return {"file": output, "current": current, **({} if current else {"error": "out of date: regenerate and commit it before deploying"})}
60
+
61
+
62
+ def main() -> int:
63
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0], formatter_class=argparse.RawDescriptionHelpFormatter, epilog="\n".join(__doc__.splitlines()[2:]))
64
+ parser.add_argument("command", choices=["check"])
65
+ parser.add_argument("--project", default=".")
66
+ parser.add_argument("--source", action="append", default=[], required=True, help="a version source (repeat; the first is the one to follow)")
67
+ parser.add_argument("--generated", action="append", default=[], help="generated file that must be current")
68
+ parser.add_argument("--generator", action="append", default=[], help="command that writes the matching --generated file to {out}")
69
+ args = parser.parse_args()
70
+ root = Path(args.project).resolve()
71
+ errors: list[str] = []
72
+ versions: dict[str, str | None] = {}
73
+ for spec in args.source:
74
+ try:
75
+ versions[spec] = read_source(root, spec)
76
+ except (OSError, ValueError, json.JSONDecodeError, IndexError) as error:
77
+ versions[spec] = None
78
+ errors.append(str(error))
79
+ found = {value for value in versions.values() if value}
80
+ if len(found) > 1:
81
+ first = versions[args.source[0]]
82
+ errors.extend(f"{spec} is {value}; {args.source[0]} is {first}" for spec, value in versions.items() if value and value != first)
83
+ if len(args.generated) != len(args.generator):
84
+ errors.append("give one --generator per --generated file")
85
+ generated = [check_generated(root, output, command) for output, command in zip(args.generated, args.generator)]
86
+ errors.extend(f"{item['file']}: {item['error']}" for item in generated if not item["current"])
87
+ result = {"valid": not errors, "version": versions.get(args.source[0]) if not errors else None, "sources": versions, "generated": generated, "errors": errors}
88
+ print(json.dumps(result, indent=2))
89
+ return 0 if result["valid"] else 1
90
+
91
+
92
+ if __name__ == "__main__":
93
+ raise SystemExit(main())
@@ -74,6 +74,55 @@ The import is draft-only and never publishes. It rejects missing IDs, invalid
74
74
  slugs, and empty content; changed source checksums stop for manual conflict
75
75
  review. Approval and publication are separate MaggieDash transitions.
76
76
 
77
+ ## Where a delivered post went live
78
+
79
+ A pulled post is useful to other channels (social posts that link it,
80
+ internal links) only once its live URL is known; without it a planner falls
81
+ back to an older stored URL, which after a platform move answers 404. So:
82
+
83
+ 1. When the site publishes a delivered post, it reports the URL it went live
84
+ at (`POST /content-tracking/report-state` with the content ID, the URL and
85
+ `published`) and records it locally:
86
+ `maggie blog live-urls record --content-id ID --url URL`. The site's report
87
+ is authoritative.
88
+ 2. A scheduled sitemap match fills the gaps:
89
+ `maggie blog live-urls match --sitemap https://example.com/sitemap.xml --write --report-body .maggie/live-urls-report.json`,
90
+ then send that body with `maggie api report-state --body-file … --execute`.
91
+
92
+ Every post without a live URL is a candidate, including one already tracked
93
+ with an empty value; when the record has no slug, the last path segment of its
94
+ delivered address is used. Matching is **additive**: a post that already has a live URL keeps it, and a
95
+ post this run did not find is left as it was — never marked unmatched — so the
96
+ match can run daily without retiring earlier matches. When translated copies
97
+ share the slug (`/blog/x`, `/zh-hans/blog/x`), the URL **without a locale
98
+ segment** wins. The same two rules apply to the platform's own sitemap
99
+ matching for API Pull destinations.
100
+
101
+ ## Rewrites on pull sites
102
+
103
+ A pull site receives rewrites the same way it receives posts: `GET /updates`
104
+ on the same schedule as `/posts` (one rewrite per call, charged like a post;
105
+ the answer is `{delivered, updates: [...]}`). Without this path a rewrite
106
+ requested for a pulled post never reaches the site. Each rewrite is applied to
107
+ the post it is for, never stored as a new post:
108
+
109
+ 1. Find the post by `source_content_id` (the id `/posts` delivered it under),
110
+ else by its slug or address, previous slugs included.
111
+ 2. Replace only the body. Title, slug, status, topics and the live URL stay as
112
+ the site has them.
113
+ 3. Keep the replaced body (and its checksum) so the rewrite can be undone.
114
+ 4. A rewrite with `publication_status: "draft"` is held for review, and one
115
+ with no matching post here is kept as `unmatched`; neither is applied.
116
+ 5. Translations of a rewritten post are stale: queue them again.
117
+ 6. Report the outcome with `POST /content-tracking/report-state`.
118
+
119
+ `maggie api lifecycle --execute --allow-quota --pull-updates` does steps 1, 2,
120
+ 4 locally: the rewrite lands on the original record in
121
+ `.maggie/ai-cmo-lifecycle.json` (`rewrite_pending`, a `rewrites` history with
122
+ the previous checksum), held ones are listed without changing the body, and
123
+ unmatched ones go to `rewrites_unmatched`. A later `/posts` re-delivery keeps
124
+ the record's live URL and rewrite history.
125
+
77
126
  ## Project context synchronization
78
127
 
79
128
  `GET /project-context` returns sanitized, read-only context scoped by the API
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topy-ai/maggie",
3
- "version": "0.7.53",
3
+ "version": "0.7.55",
4
4
  "description": "Install and manage Maggie Skills for AI coding agents",
5
5
  "license": "MIT",
6
6
  "type": "module",