@topy-ai/maggie 0.7.53 → 0.7.54

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 (42) hide show
  1. package/README-zh-TW.md +28 -50
  2. package/README.md +85 -64
  3. package/bin/maggie.js +51 -7
  4. package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +1 -1
  5. package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +2 -2
  6. package/bundled-skills/maggie-blog/SKILL.md +8 -0
  7. package/bundled-skills/maggie-booking/SKILL.md +20 -12
  8. package/bundled-skills/maggie-content-localization/SKILL.md +26 -0
  9. package/bundled-skills/maggie-dash/SKILL.md +28 -2
  10. package/bundled-skills/maggie-deployment/SKILL.md +27 -0
  11. package/bundled-skills/maggie-design/SKILL.md +81 -0
  12. package/bundled-skills/maggie-feedback/SKILL.md +10 -2
  13. package/bundled-skills/maggie-marketplace/SKILL.md +1 -1
  14. package/bundled-skills/maggie-marketplace/references/directory-search.md +1 -1
  15. package/bundled-skills/maggie-marketplace/references/directory.mjs +2 -2
  16. package/bundled-skills/maggie-mobile-app/SKILL.md +29 -0
  17. package/bundled-skills/maggie-social-share/SKILL.md +30 -0
  18. package/bundled-tools/clis/maggie_booking.py +9 -8
  19. package/bundled-tools/clis/maggie_dash.py +173 -3
  20. package/bundled-tools/clis/maggie_deployment_ops.py +64 -1
  21. package/bundled-tools/clis/maggie_design.py +7 -1
  22. package/bundled-tools/clis/maggie_design_bulk.py +290 -0
  23. package/bundled-tools/clis/maggie_design_lint.py +131 -1
  24. package/bundled-tools/clis/maggie_feedback.py +41 -13
  25. package/bundled-tools/clis/maggie_live_urls.py +138 -0
  26. package/bundled-tools/clis/maggie_localization.py +3 -0
  27. package/bundled-tools/clis/maggie_localization_keys.py +151 -0
  28. package/bundled-tools/clis/maggie_media_generate.py +174 -0
  29. package/bundled-tools/clis/maggie_mobile.py +84 -1
  30. package/bundled-tools/clis/maggie_product_release.py +49 -1
  31. package/bundled-tools/clis/maggie_social_share.py +122 -1
  32. package/bundled-tools/integrations/maggie-api-pull.md +22 -0
  33. package/package.json +1 -1
  34. package/bundled-contracts/maggiedash/execution-board.json +0 -5463
  35. package/bundled-references/maggiedash-booking/CURRENT-STATE.md +0 -210
  36. package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +0 -5463
  37. package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +0 -270
  38. package/bundled-references/maggiedash-booking/PROGRESS.md +0 -279
  39. package/bundled-references/maggiedash-booking/README.md +0 -288
  40. package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +0 -551
  41. package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +0 -107
  42. package/bundled-references/maggiedash-booking/TASKS.md +0 -178
@@ -28,6 +28,16 @@ made from: ``eas.json`` and ``package.json`` usually changed since. ``plan
28
28
  ``eas fingerprint:compare``, publish the update only when it matches, and
29
29
  always put the working copies back (verified by hash), even on failure. ``ota``
30
30
  is a dry run unless ``--confirm`` is given, because the update is published.
31
+
32
+ ``ota-guard`` checks the runtime an update would target against the runtimes
33
+ of builds people have installed (a runtimes file kept with the app) and
34
+ refuses when none matches. In a pnpm monorepo the fingerprint also hashes
35
+ pnpm's virtual-store paths (``node_modules/.pnpm/<pkg>_<peer hash>``), so a
36
+ dependency change in any workspace package can move the runtime;
37
+ ``ignorePaths`` and ``fileHookTransform`` cannot help because native module
38
+ folders are added by their real path. With ``--publish --confirm`` it runs
39
+ ``eas update`` after a match and checks that the published runtime is the one
40
+ that was checked.
31
41
  """
32
42
 
33
43
  from __future__ import annotations
@@ -180,6 +190,9 @@ def preflight(project: Path, app_dir: Path | None, config_json: Path | None, pro
180
190
  package = _load(app / "package.json") if (app / "package.json").is_file() else {}
181
191
  ignore = (app / ".fingerprintignore").read_text(encoding="utf-8") if (app / ".fingerprintignore").is_file() else ""
182
192
  errors, warnings, facts = check_profile(expo, eas, package, profile, platforms, auto_submit, raw, ignore)
193
+ if is_pnpm_monorepo(app) and any(runtime_policy(expo, platform)[0] == {"policy": "fingerprint"} or runtime_policy(expo, platform)[0] == "fingerprint" for platform in platforms):
194
+ warnings.append("pnpm monorepo with a fingerprint runtime: pnpm's virtual-store paths are part of the fingerprint, so a dependency change in another workspace package can move the runtime. Publish updates through `maggie mobile ota-guard --runtimes FILE` and set an explicit runtimeVersion (plus a native-change check) at the next native build")
195
+ facts["pnpmMonorepo"] = True
183
196
  return {
184
197
  "schemaVersion": SCHEMA,
185
198
  "appDir": str(app.relative_to(project)) if app.is_relative_to(project) else ".",
@@ -304,10 +317,70 @@ def ota(app: Path, build_ref: str, build_id: str, channel: str, environment: str
304
317
  report["steps"] = steps
305
318
 
306
319
 
320
+ def is_pnpm_monorepo(app: Path) -> bool:
321
+ for folder in (app, *app.parents):
322
+ if (folder / "pnpm-workspace.yaml").is_file():
323
+ return True
324
+ if (folder / ".git").exists():
325
+ break
326
+ return False
327
+
328
+
329
+ def load_runtimes(path: Path, platform: str) -> list[dict]:
330
+ """`maggie-mobile-runtimes.v1` ({"ios": [{"build", "eas_build_id", "runtime"}], "android": [...]})."""
331
+ data = _load(path)
332
+ builds = data.get(platform) or []
333
+ if not isinstance(builds, list) or not all(isinstance(item, dict) and item.get("runtime") for item in builds):
334
+ raise ValueError(f"{path} has no `{platform}` list of {{build, runtime}} entries")
335
+ return builds
336
+
337
+
338
+ def fingerprint_command(platform: str) -> list[str]:
339
+ return shlex.split(os.environ.get("MAGGIE_MOBILE_FINGERPRINT", f"npx expo-updates fingerprint:generate --platform {platform}"))
340
+
341
+
342
+ def ota_guard(app: Path, runtimes: Path, platform: str, runtime: str | None, publish: bool, channel: str | None, environment: str, message: str | None, confirm: bool) -> dict:
343
+ builds = load_runtimes(runtimes, platform)
344
+ report: dict = {"schemaVersion": "maggie-mobile-ota-guard.v1", "platform": platform, "installedBuilds": [{"build": item.get("build"), "runtime": item["runtime"]} for item in builds], "executed": False}
345
+ if not runtime:
346
+ result = subprocess.run(fingerprint_command(platform), cwd=app, capture_output=True, text=True, check=False)
347
+ try:
348
+ runtime = str(json.loads(result.stdout[result.stdout.find("{"):])["hash"]) if result.returncode == 0 else None
349
+ except (ValueError, KeyError):
350
+ runtime = None
351
+ if not runtime:
352
+ return {**report, "status": "failed", "reason": "could not compute the fingerprint (expo-updates fingerprint:generate)"}
353
+ report["runtime"] = runtime
354
+ match = next((item for item in builds if item["runtime"] == runtime), None)
355
+ if not match:
356
+ report.update(status="failed", reason=f"no installed build has runtime {runtime}; an update published now would reach nobody",
357
+ fix=["the change needs a native build (new native module, permission, native config): build, then add its runtime to the runtimes file",
358
+ "or only install paths moved (a dependency change elsewhere in the monorepo): publish from a temporary worktree with the build's lockfile and workspace file, after a frozen install, and confirm the fingerprint equals the build's runtime",
359
+ "next native build: set an explicit runtimeVersion and keep a native-change check, so unrelated workspace changes cannot move it"])
360
+ return report
361
+ report["matchedBuild"] = match.get("build")
362
+ if not publish:
363
+ return {**report, "status": "passed"}
364
+ if not (channel and message):
365
+ raise ValueError("--publish needs an EAS Update channel on the profile and --message")
366
+ update = eas_command() + ["update", "--channel", channel, "--environment", environment, "--platform", platform, "--message", message, "--non-interactive"]
367
+ report["command"] = shlex.join(update)
368
+ if not confirm:
369
+ return {**report, "status": "dry-run", "note": "runtime matches; re-run with --confirm to publish"}
370
+ result = subprocess.run(update, cwd=app, capture_output=True, text=True, check=False)
371
+ published = re.search(r"Runtime version\s+([0-9a-f]{8,})", result.stdout or "")
372
+ report.update(executed=True, publishedRuntime=published.group(1) if published else None)
373
+ if result.returncode:
374
+ return {**report, "status": "failed", "reason": "eas update failed; see its output"}
375
+ if not published or published.group(1) != runtime:
376
+ return {**report, "status": "failed", "reason": "the published runtime differs from the checked one; check `eas update:list`"}
377
+ return {**report, "status": "published"}
378
+
379
+
307
380
  def main(argv: list[str] | None = None) -> int:
308
381
  parser = argparse.ArgumentParser(prog="maggie mobile", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
309
382
  sub = parser.add_subparsers(dest="command", required=True)
310
- for name in ("preflight", "plan", "ota"):
383
+ for name in ("preflight", "plan", "ota", "ota-guard"):
311
384
  command = sub.add_parser(name)
312
385
  command.add_argument("--project", type=Path, default=Path("."))
313
386
  command.add_argument("--app-dir", type=Path, help="Expo app folder inside a monorepo, e.g. apps/app")
@@ -319,6 +392,12 @@ def main(argv: list[str] | None = None) -> int:
319
392
  command.add_argument("--output", type=Path)
320
393
  if name in {"plan", "ota"}:
321
394
  command.add_argument("--build-ref", help="git commit or tag the installed store build was made from")
395
+ if name == "ota-guard":
396
+ command.add_argument("--runtimes", type=Path, required=True, help="maggie-mobile-runtimes.v1 JSON: installed builds' runtimes per platform")
397
+ command.add_argument("--runtime", help="use this runtime instead of computing the fingerprint")
398
+ command.add_argument("--publish", action="store_true", help="publish the update after a match")
399
+ command.add_argument("--message")
400
+ command.add_argument("--confirm", action="store_true")
322
401
  if name == "ota":
323
402
  command.add_argument("--build-id", required=True, help="EAS build id of the installed build (for fingerprint:compare)")
324
403
  command.add_argument("--message", required=True)
@@ -341,6 +420,10 @@ def main(argv: list[str] | None = None) -> int:
341
420
  report = {"schemaVersion": "maggie-mobile-release-plan.v1", "preflight": {key: report[key] for key in ("status", "errors", "warnings")}, "commands": plan_commands(args.profile, platforms, args.auto_submit, channel, args.environment, args.build_ref, drift), "costNotice": report["costNotice"], "executed": False}
342
421
  if drift is not None:
343
422
  report["buildConfig"] = [{"file": item["file"], "differsFromBuild": item["differs"]} for item in drift]
423
+ elif args.command == "ota-guard":
424
+ if args.platform == "all":
425
+ raise ValueError("--platform ios or android: runtimes differ per platform")
426
+ report = ota_guard(app, (project / args.runtimes) if not args.runtimes.is_absolute() else args.runtimes, args.platform, args.runtime, args.publish, channel, environment, args.message, args.confirm)
344
427
  elif args.command == "ota":
345
428
  if report["status"] == "failed":
346
429
  raise ValueError("preflight failed: " + "; ".join(report["errors"]))
@@ -27,6 +27,7 @@ import json
27
27
  import re
28
28
  import shutil
29
29
  import subprocess
30
+ import time
30
31
  import sys
31
32
  import tempfile
32
33
  from datetime import date, datetime, timezone
@@ -264,6 +265,44 @@ def review_status(reviews_path: Path, package_version: str | None, chatgpt_plugi
264
265
  return {"schemaVersion": "maggie-product-review-status.v1", "passed": not errors, "reviews": rows, "errors": errors, "warnings": warnings}
265
266
 
266
267
 
268
+ STAGED_ERROR = re.compile(r"cannot publish over (?:the )?previously (?:staged|published) version", re.I)
269
+
270
+
271
+ def npm_view(package: str, npm_bin: str) -> dict:
272
+ completed = subprocess.run([npm_bin, "view", package, "dist-tags", "versions", "--json"], capture_output=True, text=True, check=False, timeout=60)
273
+ if completed.returncode:
274
+ return {}
275
+ try:
276
+ value = json.loads(completed.stdout or "{}")
277
+ except json.JSONDecodeError:
278
+ return {}
279
+ return value if isinstance(value, dict) else {}
280
+
281
+
282
+ def published(package: str, version: str, tag: str, wait: int, interval: float, publish_log: Path | None, view=None, sleep=time.sleep) -> dict:
283
+ """Wait until `version` is the registry's `tag` (latest). A staged publish can lag for minutes."""
284
+ npm_bin = shutil.which("npm") or "npm"
285
+ view = view or (lambda: npm_view(package, npm_bin))
286
+ log_says_staged = bool(publish_log and publish_log.is_file() and STAGED_ERROR.search(publish_log.read_text(encoding="utf-8", errors="replace")))
287
+ deadline = time.monotonic() + wait
288
+ checks = 0
289
+ while True:
290
+ checks += 1
291
+ data = view()
292
+ tags = data.get("dist-tags") or {}
293
+ versions = data.get("versions") or []
294
+ listed = version in (versions if isinstance(versions, list) else [versions])
295
+ current = tags.get(tag)
296
+ if current == version:
297
+ return {"passed": True, "package": package, "version": version, "tag": tag, "state": "live", "checks": checks,
298
+ "note": "the earlier publish attempt was already accepted (staged), not a failure" if log_says_staged else None}
299
+ if time.monotonic() >= deadline:
300
+ state = "staged" if listed or log_says_staged else "missing"
301
+ return {"passed": False, "package": package, "version": version, "tag": tag, "state": state, "registryTag": current, "checks": checks,
302
+ "note": "still propagating: check again before reporting it" if state == "staged" else "not on the registry: the publish did not happen"}
303
+ sleep(interval)
304
+
305
+
267
306
  def main(argv: list[str] | None = None) -> int:
268
307
  parser = argparse.ArgumentParser(prog="maggie product", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
269
308
  sub = parser.add_subparsers(dest="command", required=True)
@@ -281,9 +320,18 @@ def main(argv: list[str] | None = None) -> int:
281
320
  review.add_argument("--reviews", type=Path, required=True, help="maggie-product-reviews.v1 JSON")
282
321
  review.add_argument("--package", type=Path)
283
322
  review.add_argument("--chatgpt-plugin", type=Path, help="ChatGPT app plugin.json with review.test_cases")
323
+ pub = sub.add_parser("published", help="wait until a just-published version is the registry's latest (staged publishes lag)")
324
+ pub.add_argument("--package-name", required=True)
325
+ pub.add_argument("--version", required=True)
326
+ pub.add_argument("--tag", default="latest")
327
+ pub.add_argument("--wait", type=int, default=600, help="seconds to keep checking (default 600)")
328
+ pub.add_argument("--interval", type=float, default=20)
329
+ pub.add_argument("--publish-log", type=Path, help="output of the publish command; 'Cannot publish over previously staged version' means it was already accepted")
284
330
  args = parser.parse_args(argv)
285
331
  try:
286
- if args.command == "release-check":
332
+ if args.command == "published":
333
+ report = published(args.package_name, args.version, args.tag, args.wait, args.interval, args.publish_log)
334
+ elif args.command == "release-check":
287
335
  report = release_check(args.package.resolve(), args.server_json, args.changelog, args.pack)
288
336
  elif args.command == "smoke":
289
337
  report = smoke(args.package.resolve(), args.timeout)
@@ -7,7 +7,9 @@ import json
7
7
  import sys
8
8
  from datetime import datetime, 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,40 @@ 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
+
76
172
  def validate(value: dict) -> list[str]:
77
173
  errors = [f"missing {key}" for key in REQUIRED if not value.get(key)]
78
174
  if value.get("status") not in {"draft", "approved", "scheduled", "publishing", "published", "failed", "cancelled"}:
@@ -84,7 +180,10 @@ def validate(value: dict) -> list[str]:
84
180
  errors.append(f"canonical_url is on {host_of(value['canonical_url'])}, not the workspace domain {domain}")
85
181
  elif not domain and value.get("status") in LIVE_STATUSES:
86
182
  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", []):
183
+ channels = value.get("channels", [])
184
+ if value.get("image_url"):
185
+ errors.extend(shared_picture_errors([{**channel, "image_url": channel.get("image_url", value["image_url"])} for channel in channels], "draft"))
186
+ for channel in channels:
88
187
  name = channel.get("name")
89
188
  if name not in LIMITS:
90
189
  errors.append(f"unsupported channel: {name}")
@@ -114,6 +213,13 @@ def main() -> int:
114
213
  create.add_argument("--channels", default="linkedin,facebook")
115
214
  create.add_argument("--workspace-domain", help="the workspace's own site domain; the source must be published there")
116
215
  create.add_argument("--output")
216
+ create.add_argument("--check-link", action="store_true", help="fetch canonical_url first and refuse a definite 404/410")
217
+ pictures = sub.add_parser("pictures", help="check that drafts written from one idea share one picture on every channel")
218
+ pictures.add_argument("input", help='JSON: {"drafts": [{"id", "idea_id", "channel", "image_url", "image_source"?}]}')
219
+ links = sub.add_parser("link-check", help="drop articles whose link is gone (404/410) before planning posts; keep blocked or failing pages")
220
+ links.add_argument("input", help="JSON: a list of URLs or article objects with canonical_url, or {\"articles\": [...]}")
221
+ links.add_argument("--cache", default=".maggie/social-link-cache.json", help="verdict cache (default: .maggie/social-link-cache.json); 'none' disables it")
222
+ links.add_argument("--ttl-hours", type=float, default=6.0)
117
223
  check = sub.add_parser("validate", help="validate a draft channel contract")
118
224
  check.add_argument("input")
119
225
  adapters = sub.add_parser("adapters", help="check canonical OAuth scope sets and shared OAuth clients")
@@ -134,6 +240,15 @@ def main() -> int:
134
240
  schema.add_argument("input")
135
241
  args = parser.parse_args()
136
242
  try:
243
+ if args.command == "link-check":
244
+ raw = json.loads(Path(args.input).read_text(encoding="utf-8"))
245
+ articles = raw.get("articles", []) if isinstance(raw, dict) else raw
246
+ urls = [item if isinstance(item, str) else item.get("canonical_url") or item.get("url") for item in articles]
247
+ 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))
248
+ by_url = {item["url"]: item for item in result["results"]}
249
+ 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"]
250
+ print(json.dumps(result, indent=2, ensure_ascii=False))
251
+ return 0
137
252
  value = read(Path(args.input))
138
253
  now = datetime.now(timezone.utc)
139
254
  if args.command == "schema-check":
@@ -143,6 +258,8 @@ def main() -> int:
143
258
  result = validate_adapters(value)
144
259
  elif args.command == "connections":
145
260
  result = connection_health(value, parse_instant(args.now, "--now") if args.now else now)
261
+ elif args.command == "pictures":
262
+ result = picture_groups(value if isinstance(value, dict) else {"drafts": value})
146
263
  elif args.command == "reconcile":
147
264
  result = reconcile(value)
148
265
  elif args.command == "campaigns":
@@ -156,6 +273,10 @@ def main() -> int:
156
273
  return 0
157
274
  else:
158
275
  if args.command == "plan":
276
+ if args.check_link and value.get("canonical_url"):
277
+ status = fetch_status(value["canonical_url"])
278
+ if link_verdict(status) == "gone":
279
+ 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
280
  value = plan(value, [item.strip() for item in args.channels.split(",") if item.strip()], args.workspace_domain)
160
281
  if args.output:
161
282
  Path(args.output).parent.mkdir(parents=True, exist_ok=True)
@@ -74,6 +74,28 @@ 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
+ Matching is **additive**: a post that already has a live URL keeps it, and a
93
+ post this run did not find is left as it was — never marked unmatched — so the
94
+ match can run daily without retiring earlier matches. When translated copies
95
+ share the slug (`/blog/x`, `/zh-hans/blog/x`), the URL **without a locale
96
+ segment** wins. The same two rules apply to the platform's own sitemap
97
+ matching for API Pull destinations.
98
+
77
99
  ## Project context synchronization
78
100
 
79
101
  `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.54",
4
4
  "description": "Install and manage Maggie Skills for AI coding agents",
5
5
  "license": "MIT",
6
6
  "type": "module",