@topy-ai/maggie 0.7.51 → 0.7.53

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 (57) hide show
  1. package/README-zh-TW.md +48 -2
  2. package/README.md +66 -45
  3. package/bin/maggie.js +18 -7
  4. package/bundled-contracts/maggie-assistant-tools/booking-receptionist-example-v1.json +161 -0
  5. package/bundled-contracts/maggie-assistant-tools/tool-registry-v1.schema.json +268 -20
  6. package/bundled-contracts/maggie-deployment/bridge-host-example-v1.json +49 -7
  7. package/bundled-contracts/maggiedash/booking-email-templates-v1.json +181 -13
  8. package/bundled-contracts/maggiedash/booking-rules-v1.json +1699 -0
  9. package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +398 -39
  10. package/bundled-references/maggiedash-booking/PRD.md +22 -11
  11. package/bundled-references/maggiedash-booking/RULES.md +167 -0
  12. package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +42 -4
  13. package/bundled-references/provider-adapter-contract.md +16 -3
  14. package/bundled-skills/catalog.json +1 -1
  15. package/bundled-skills/maggie-assistant-tools/SKILL.md +54 -3
  16. package/bundled-skills/maggie-assistant-tools/references/approvals-and-audit.md +27 -0
  17. package/bundled-skills/maggie-assistant-tools/references/booking-receptionist.md +45 -0
  18. package/bundled-skills/maggie-assistant-tools/references/bridge-host.md +54 -0
  19. package/bundled-skills/maggie-auth-reference/SKILL.md +18 -0
  20. package/bundled-skills/maggie-booking/SKILL.md +47 -3
  21. package/bundled-skills/maggie-deployment/SKILL.md +21 -0
  22. package/bundled-skills/maggie-deployment/references/cloudflare.md +19 -0
  23. package/bundled-skills/maggie-deployment/references/vps.md +92 -0
  24. package/bundled-skills/maggie-design/SKILL.md +30 -3
  25. package/bundled-skills/maggie-design/references/app-ui-patterns.md +79 -0
  26. package/bundled-skills/maggie-feedback/SKILL.md +17 -0
  27. package/bundled-skills/maggie-marketplace/SKILL.md +12 -0
  28. package/bundled-skills/maggie-marketplace/references/directory-search.md +51 -0
  29. package/bundled-skills/maggie-marketplace/references/directory.mjs +112 -0
  30. package/bundled-skills/maggie-mobile-app/SKILL.md +52 -5
  31. package/bundled-skills/maggie-mobile-app/references/payments-and-push.md +78 -0
  32. package/bundled-skills/maggie-qa-workflow/SKILL.md +60 -0
  33. package/bundled-skills/maggie-seo-geo/SKILL.md +33 -0
  34. package/bundled-skills/maggie-service-booking/SKILL.md +28 -3
  35. package/bundled-skills/maggie-social-share/SKILL.md +26 -4
  36. package/bundled-skills/maggie-social-share/references/queue-and-connections.md +28 -0
  37. package/bundled-tools/clis/maggie_assistant_tools.py +84 -3
  38. package/bundled-tools/clis/maggie_booking.py +15 -4
  39. package/bundled-tools/clis/maggie_booking_connect_qa.py +136 -0
  40. package/bundled-tools/clis/maggie_booking_rules.py +476 -0
  41. package/bundled-tools/clis/maggie_bridge_host.py +77 -1
  42. package/bundled-tools/clis/maggie_deployment_ops.py +461 -0
  43. package/bundled-tools/clis/maggie_design.py +11 -2
  44. package/bundled-tools/clis/maggie_design_lint.py +142 -3
  45. package/bundled-tools/clis/maggie_feedback.py +97 -15
  46. package/bundled-tools/clis/maggie_mobile.py +154 -8
  47. package/bundled-tools/clis/maggie_qa_app_preflight.py +122 -4
  48. package/bundled-tools/clis/maggie_qa_fit.py +217 -0
  49. package/bundled-tools/clis/maggie_qa_workflow.py +5 -0
  50. package/bundled-tools/clis/maggie_schema_complexity.py +84 -0
  51. package/bundled-tools/clis/maggie_seo_routes.py +154 -0
  52. package/bundled-tools/clis/maggie_service_migration.py +272 -0
  53. package/bundled-tools/clis/maggie_social_queue.py +2 -0
  54. package/bundled-tools/clis/maggie_social_share.py +52 -6
  55. package/bundled-tools/runtime/assistant_approval.py +98 -0
  56. package/package.json +1 -1
  57. package/references/provider-adapter-contract.md +16 -3
@@ -7,11 +7,21 @@ pressable in JSX/TSX. React Native Web renders ``Pressable`` and the
7
7
  remove control becomes ``<button>`` inside ``<button>``: invalid DOM, and taps
8
8
  land on the wrong control.
9
9
 
10
- ``layer-check`` flags unlayered CSS that competes with Tailwind v4 utilities.
10
+ ``layer-check`` flags unlayered CSS that competes with Tailwind v4 utilities,
11
+ and warns about ``width: max-content`` (or ``w-max``) strips that can push a
12
+ 390 px page sideways. ``textinput-check`` flags React Native Web multiline
13
+ inputs without a height and grow-only auto-grow.
11
14
  Tailwind v4 emits utilities inside ``@layer utilities``; any unlayered rule
12
15
  wins over every layer regardless of specificity, so an unlayered
13
16
  ``h1 { font-family }`` beats ``font-*`` and ``.ph { display: inline-block }``
14
17
  beats ``hidden`` and ``group-open:inline``.
18
+
19
+ ``safe-area-check`` flags ``<SafeAreaView>`` used as the inset source inside an
20
+ Expo Router modal route (``presentation: "transparentModal"``, ``"modal"``,
21
+ ``"fullScreenModal"``, ...) or a React Native ``<Modal>``. On iOS those
22
+ surfaces can measure a 0 top inset, so the header and Close button sit under
23
+ the notch or Dynamic Island; web screenshots cannot show it. Read insets with
24
+ ``useSafeAreaInsets()`` from the root ``SafeAreaProvider`` instead.
15
25
  """
16
26
 
17
27
  from __future__ import annotations
@@ -232,12 +242,41 @@ def _rule_findings(source: str, start: int, end: int, layered: bool, display: st
232
242
  index = close + 1
233
243
 
234
244
 
245
+ MAX_CONTENT_RULE = re.compile(r"([^{}]+)\{([^{}]*\b(?:min-)?width\s*:\s*max-content[^{}]*)\}", re.I)
246
+ MAX_CONTENT_CLASS = re.compile(r"class(?:Name)?\s*=\s*[\"'`{]([^\"'`}]*\bw-max\b[^\"'`}]*)", re.I)
247
+ OVERFLOW_GUARD = re.compile(r"\b(?:max-width|overflow(?:-x)?)\s*:", re.I)
248
+
249
+
250
+ def overflow_findings(project: Path, css_sources: dict[Path, str], markup: list[Path]) -> list[dict[str, object]]:
251
+ """width: max-content on a tab strip or toolbar makes a phone page scroll sideways unless it can shrink or scroll."""
252
+ findings: list[dict[str, object]] = []
253
+ for path, source in css_sources.items():
254
+ for match in MAX_CONTENT_RULE.finditer(source):
255
+ body = match.group(2)
256
+ if OVERFLOW_GUARD.search(body):
257
+ continue
258
+ selector = " ".join(match.group(1).split())[-80:]
259
+ findings.append({"file": _display(path, project), "line": source.count("\n", 0, match.start(2)) + 1, "selector": selector, "kind": "max-content", "severity": "warning",
260
+ "message": "width: max-content without max-width/overflow can push a 390 px page sideways; give the strip overflow-x: auto inside a min-width: 0 parent, or max-width: 100%"})
261
+ for path in markup:
262
+ source = path.read_text(encoding="utf-8", errors="replace")
263
+ for match in MAX_CONTENT_CLASS.finditer(source):
264
+ classes = match.group(1)
265
+ if re.search(r"\b(?:overflow-x-auto|overflow-auto|overflow-x-scroll|max-w-[\w\[\]%-]+)\b", classes):
266
+ continue
267
+ findings.append({"file": _display(path, project), "line": source.count("\n", 0, match.start()) + 1, "selector": classes.strip()[:80], "kind": "max-content", "severity": "warning",
268
+ "message": "w-max without overflow-x-auto or a max-w-* can make the page scroll sideways at 390 px"})
269
+ return findings
270
+
271
+
235
272
  def layer_check(project: Path, paths: list[Path], force_v4: bool) -> dict[str, object]:
236
273
  files = iter_files(paths or [project], (".css", ".pcss", ".postcss"))
237
274
  sources = {path.resolve(): _strip_css_comments(path.read_text(encoding="utf-8", errors="replace")) for path in files}
275
+ markup = iter_files(paths or [project], (".tsx", ".jsx", ".astro", ".vue", ".svelte", ".html"))
276
+ overflow = overflow_findings(project, sources, markup)
238
277
  tailwind_v4 = force_v4 or any(TAILWIND_V4_IMPORT.search(source) for source in sources.values())
239
278
  if not tailwind_v4:
240
- return {"schemaVersion": "maggie-design-layer-check.v1", "passed": True, "status": "skipped", "reason": "no Tailwind v4 entry (@import \"tailwindcss\") found", "checkedFiles": len(files), "findings": []}
279
+ return {"schemaVersion": "maggie-design-layer-check.v1", "passed": True, "status": "skipped", "reason": "no Tailwind v4 entry (@import \"tailwindcss\") found", "checkedFiles": len(files), "findings": overflow, "warnings": len(overflow)}
241
280
  layered_files: set[Path] = set()
242
281
  for path, source in sources.items():
243
282
  for target in LAYERED_IMPORT.findall(source):
@@ -245,10 +284,100 @@ def layer_check(project: Path, paths: list[Path], force_v4: bool) -> dict[str, o
245
284
  findings: list[dict[str, object]] = []
246
285
  for path, source in sources.items():
247
286
  _rule_findings(source, 0, len(source), path in layered_files, _display(path, project), findings)
287
+ findings += overflow
248
288
  errors = [item for item in findings if item["severity"] == "error"]
249
289
  return {"schemaVersion": "maggie-design-layer-check.v1", "passed": not errors, "status": "checked", "checkedFiles": len(files), "errors": len(errors), "warnings": len(findings) - len(errors), "findings": findings}
250
290
 
251
291
 
292
+ MODAL_PRESENTATIONS = ("transparentModal", "containedTransparentModal", "modal", "containedModal", "fullScreenModal", "formSheet")
293
+ SAFE_AREA_ALLOW = "maggie-allow-safe-area-view"
294
+ SCREEN_TAG = re.compile(r"<(?:Stack|Tabs|Drawer)\.Screen\b(?:[^<>]|=>|\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\})*?/?>", re.S)
295
+ PRESENTATION = re.compile(r"presentation\s*:\s*[\"'](" + "|".join(MODAL_PRESENTATIONS) + r")[\"']")
296
+
297
+
298
+ def _route_file(layout: Path, name: str) -> Path | None:
299
+ for candidate in (layout.parent / f"{name}.tsx", layout.parent / f"{name}.jsx", layout.parent / name / "index.tsx", layout.parent / name / "index.jsx"):
300
+ if candidate.is_file():
301
+ return candidate
302
+ return None
303
+
304
+
305
+ def _line(source: str, index: int) -> int:
306
+ return source.count("\n", 0, index) + 1
307
+
308
+
309
+ def safe_area_check(project: Path, paths: list[Path]) -> dict[str, object]:
310
+ project = project.resolve()
311
+ files = iter_files(paths or [project], (".tsx", ".jsx"))
312
+ modal_routes: dict[Path, str] = {}
313
+ for path in files:
314
+ source = _strip_jsx_comments(path.read_text(encoding="utf-8", errors="replace"))
315
+ for tag in SCREEN_TAG.finditer(source):
316
+ presentation = PRESENTATION.search(tag.group(0))
317
+ if not presentation:
318
+ continue
319
+ name = re.search(r"\bname\s*=\s*[\"']([^\"']+)[\"']", tag.group(0))
320
+ target = _route_file(path, name.group(1)) if name and path.stem == "_layout" else (path if not name else None)
321
+ if target:
322
+ modal_routes[target.resolve()] = presentation.group(1)
323
+ findings: list[dict[str, object]] = []
324
+ for path in files:
325
+ raw = path.read_text(encoding="utf-8", errors="replace")
326
+ if SAFE_AREA_ALLOW in raw or "SafeAreaView" not in raw:
327
+ continue
328
+ source = _strip_jsx_comments(raw)
329
+ display = _display(path, project)
330
+ presentation = modal_routes.get(path.resolve())
331
+ if presentation:
332
+ match = re.search(r"<SafeAreaView\b", source)
333
+ if match:
334
+ findings.append({"file": display, "line": _line(source, match.start()), "kind": "modal-route", "presentation": presentation, "message": f"route presented as {presentation} uses <SafeAreaView>; on iOS it can measure a 0 top inset, so pad with useSafeAreaInsets() instead"})
335
+ continue
336
+ if re.search(r"import\s*\{[^}]*\bModal\b[^}]*\}\s*from\s*[\"']react-native[\"']", source):
337
+ for modal in re.finditer(r"<Modal\b", source):
338
+ end = source.find("</Modal>", modal.end())
339
+ inner = source[modal.end(): end if end >= 0 else len(source)]
340
+ safe = re.search(r"<SafeAreaView\b", inner)
341
+ if safe:
342
+ findings.append({"file": display, "line": _line(source, modal.end() + safe.start()), "kind": "rn-modal", "message": "<SafeAreaView> inside a React Native <Modal> can report a 0 top inset on iOS; use useSafeAreaInsets() padding"})
343
+ return {"schemaVersion": "maggie-design-safe-area-check.v1", "passed": not findings, "checkedFiles": len(files), "modalRoutes": sorted(_display(path, project) for path in modal_routes), "findings": findings,
344
+ "recommendation": "Wrap the app once in SafeAreaProvider; in modals read const insets = useSafeAreaInsets() and pad the header/footer. Verify on a notched iPhone (or simulator) — web screenshots cannot show the inset."}
345
+
346
+
347
+ TEXTINPUT_TAG = re.compile(r"<TextInput\b(?:[^<>]|=>|\{[^{}]*(?:\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}[^{}]*)*\})*?/?>", re.S)
348
+ TEXTINPUT_ALLOW = "maggie-allow-textinput"
349
+
350
+
351
+ def textinput_check(project: Path, paths: list[Path]) -> dict[str, object]:
352
+ """React Native Web renders a multiline TextInput as a 2-row textarea; flag inputs without a height and grow-only auto-grow."""
353
+ project = project.resolve()
354
+ files = iter_files(paths or [project], (".tsx", ".jsx"))
355
+ findings: list[dict[str, object]] = []
356
+ for path in files:
357
+ raw = path.read_text(encoding="utf-8", errors="replace")
358
+ if "TextInput" not in raw or TEXTINPUT_ALLOW in raw:
359
+ continue
360
+ source = _strip_jsx_comments(raw)
361
+ display = _display(path, project)
362
+ for tag in TEXTINPUT_TAG.finditer(source):
363
+ text = tag.group(0)
364
+ if not re.search(r"\bmultiline\b(?!\s*=\s*\{\s*false\s*\})", text):
365
+ continue
366
+ line = _line(source, tag.start())
367
+ sized = re.search(r"\bnumberOfLines\s*=|\brows\s*=|\b(?:height|minHeight)\s*:", text)
368
+ style_ref = re.search(r"\bstyle\s*=\s*\{\s*(?:\[)?\s*(?:styles|s)\.(\w+)", text)
369
+ if not sized and style_ref and re.search(rf"\b{style_ref.group(1)}\s*:\s*\{{[^{{}}]*\b(?:height|minHeight)\s*:", source):
370
+ sized = True
371
+ if not sized:
372
+ findings.append({"file": display, "line": line, "kind": "multiline-without-height", "severity": "error",
373
+ "message": "multiline TextInput without numberOfLines or an explicit height renders a 2-row textarea on web (placeholder high, buttons misaligned); set one line of height and grow by measuring"})
374
+ if "onContentSizeChange" in text and "scrollHeight" not in source:
375
+ findings.append({"file": display, "line": line, "kind": "grow-only-autogrow", "severity": "warning",
376
+ "message": "onContentSizeChange never reports a smaller size on web, so this composer grows but never shrinks; on web collapse to the minimum, read scrollHeight, then set the height"})
377
+ errors = [item for item in findings if item["severity"] == "error"]
378
+ return {"schemaVersion": "maggie-design-textinput-check.v1", "passed": not errors, "checkedFiles": len(files), "findings": findings}
379
+
380
+
252
381
  def main(argv: list[str] | None = None) -> int:
253
382
  parser = argparse.ArgumentParser(prog="maggie design", description="Static design checks that run before browser QA.")
254
383
  sub = parser.add_subparsers(dest="command", required=True)
@@ -260,9 +389,19 @@ def main(argv: list[str] | None = None) -> int:
260
389
  layers.add_argument("--project", type=Path, default=Path.cwd())
261
390
  layers.add_argument("--css", type=Path, action="append", default=[], help="CSS file or directory to scan; defaults to the project")
262
391
  layers.add_argument("--tailwind-v4", action="store_true", help="check even when no @import \"tailwindcss\" entry is found")
392
+ safe = sub.add_parser("safe-area-check", help="flag SafeAreaView inside Expo Router modal routes and React Native Modal (.tsx/.jsx)")
393
+ safe.add_argument("--project", type=Path, default=Path.cwd())
394
+ safe.add_argument("--path", type=Path, action="append", default=[], help="file or directory to scan; defaults to the project")
395
+ textinput = sub.add_parser("textinput-check", help="flag multiline TextInput without a height and grow-only auto-grow on React Native Web (.tsx/.jsx)")
396
+ textinput.add_argument("--project", type=Path, default=Path.cwd())
397
+ textinput.add_argument("--path", type=Path, action="append", default=[], help="file or directory to scan; defaults to the project")
263
398
  args = parser.parse_args(argv)
264
399
  try:
265
- if args.command == "pressable-check":
400
+ if args.command == "textinput-check":
401
+ report = textinput_check(args.project, args.path)
402
+ elif args.command == "safe-area-check":
403
+ report = safe_area_check(args.project, args.path)
404
+ elif args.command == "pressable-check":
266
405
  report = pressable_check(args.project, args.path, args.pressable)
267
406
  else:
268
407
  report = layer_check(args.project, args.css, args.tailwind_v4)
@@ -459,6 +459,12 @@ def collect_batch(args: argparse.Namespace) -> int:
459
459
  return 0
460
460
 
461
461
 
462
+ AREA_SUBJECT_RE = re.compile(r"^([A-Za-z][A-Za-z0-9 /&._-]{0,30}):\s+(\S.*)$")
463
+ SCOPE_RE = re.compile(r"^[a-z]+\(([^)]{1,40})\)!?:", re.IGNORECASE)
464
+ SKILL_HINT_RE = re.compile(r"\b(?:maggie[\w-]*|skills?|cli|mcp|codex|claude)\b", re.IGNORECASE)
465
+ SUMMARY_ITEM_CAP = 49
466
+
467
+
462
468
  def classify_subject(subject: str, include_all: bool) -> tuple[str, str] | None:
463
469
  """Return (type, text) for one commit subject, or ``None`` to skip it."""
464
470
  merge = MERGE_PR_RE.match(subject)
@@ -474,22 +480,58 @@ def classify_subject(subject: str, include_all: bool) -> tuple[str, str] | None:
474
480
  return "bug", subject
475
481
  if PLAIN_FEATURE_RE.search(subject):
476
482
  return "improvement", subject
483
+ # "Area: sentence" is how many product teams describe a change; it is a
484
+ # change in that area even when the sentence has no recognised verb.
485
+ if AREA_SUBJECT_RE.match(subject):
486
+ return "improvement", subject
477
487
  return ("general", subject) if include_all else None
478
488
 
479
489
 
480
- def _summary_repos(values: list[str]) -> list[tuple[str, Path]]:
481
- """Parse ``--repo [LABEL=]PATH`` values; unlabeled repositories become repo1, repo2, ..."""
490
+ def subject_scope(subject: str) -> str:
491
+ """The conventional-commit scope or ``Area:`` prefix of a subject, lower-cased; ``""`` when absent."""
492
+ match = SCOPE_RE.match(subject)
493
+ if match:
494
+ return match.group(1).strip().lower()
495
+ if FIX_SUBJECT_RE.match(subject) or FEATURE_SUBJECT_RE.match(subject) or CHORE_SUBJECT_RE.match(subject):
496
+ return ""
497
+ area = AREA_SUBJECT_RE.match(subject)
498
+ return area.group(1).strip().lower() if area else ""
499
+
500
+
501
+ def _summary_repos(values: list[str], default_rev: str = "") -> list[tuple[str, Path, str]]:
502
+ """Parse ``--repo [LABEL=]PATH[@REV]`` values; unlabeled repositories become repo1, repo2, ..."""
482
503
  repos = []
483
504
  for index, value in enumerate(values or ["."], start=1):
484
505
  label, sep, path = value.partition("=")
485
506
  if not sep or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,40}", label):
486
507
  label, path = f"repo{index}", value
487
- repos.append((label, Path(path).expanduser().resolve()))
488
- if len({label for label, _ in repos}) != len(repos):
508
+ rev = default_rev
509
+ head, at, tail = path.rpartition("@")
510
+ if at and head and not Path(path).expanduser().exists():
511
+ path, rev = head, tail
512
+ if rev and (rev.startswith("-") or not re.fullmatch(r"[A-Za-z0-9._/@{}~^:-]{1,200}", rev)):
513
+ raise ValueError("revision must be a branch, tag or commit such as origin/main")
514
+ repos.append((label, Path(path).expanduser().resolve(), rev or "HEAD"))
515
+ if len({label for label, _, _ in repos}) != len(repos):
489
516
  raise ValueError("--repo labels must be unique")
490
517
  return repos
491
518
 
492
519
 
520
+ def _behind_upstream(repo: Path, rev: str) -> tuple[str, int] | None:
521
+ """When reading HEAD, report (upstream, commits HEAD lacks) from local refs; never fetches."""
522
+ if rev != "HEAD":
523
+ return None
524
+ upstream = subprocess.run(["git", "-C", str(repo), "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], capture_output=True, text=True, check=False)
525
+ if upstream.returncode or not upstream.stdout.strip():
526
+ return None
527
+ count = subprocess.run(["git", "-C", str(repo), "rev-list", "--count", "HEAD..@{u}"], capture_output=True, text=True, check=False)
528
+ try:
529
+ behind = int(count.stdout.strip() or 0)
530
+ except ValueError:
531
+ return None
532
+ return (upstream.stdout.strip(), behind) if behind else None
533
+
534
+
493
535
  def session_summary(args: argparse.Namespace) -> int:
494
536
  """Draft a reviewable batch file from commit subjects; nothing is collected or submitted."""
495
537
  since = str(args.since or "").strip()
@@ -503,32 +545,53 @@ def session_summary(args: argparse.Namespace) -> int:
503
545
  batch_id = safe_text(args.batch_id) or f"session-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}"
504
546
  if not SAFE_BATCH_ID_RE.fullmatch(batch_id):
505
547
  raise ValueError("batch ID is invalid")
506
- repos = _summary_repos(args.repo)
548
+ repos = _summary_repos(args.repo, str(getattr(args, "rev", "") or "").strip())
507
549
  include_all = bool(getattr(args, "all", False))
508
- items = []
550
+ skill_relevant = bool(getattr(args, "skill_relevant", False))
551
+ skill_hint = safe_text(args.skill).lower().removeprefix("maggie-")
552
+ candidates = []
509
553
  skipped = 0
554
+ project_commits = 0
555
+ seen = 0
510
556
  per_repo = {}
511
- for label, repo in repos:
557
+ warnings = []
558
+ for label, repo, rev in repos:
512
559
  command = ["git", "-C", str(repo), "log", "--format=%h%x1f%s"]
513
560
  if not getattr(args, "merges", False):
514
561
  command.append("--no-merges")
515
- command += [f"--since={since_date}", "HEAD"] if since_date else [f"{since}..HEAD"]
562
+ command += [f"--since={since_date}", rev] if since_date else [f"{since}..{rev}"]
516
563
  completed = subprocess.run(command, capture_output=True, text=True, check=False)
517
564
  if completed.returncode:
518
- raise ValueError(f"git log failed for {label}; check --repo and --since")
565
+ raise ValueError(f"git log failed for {label}; check --repo, --rev and --since")
566
+ lag = _behind_upstream(repo, rev)
567
+ if lag:
568
+ warnings.append(f"{label}: local HEAD is {lag[1]} commit(s) behind {lag[0]}; commits pushed from worktrees are missing. Re-run with --repo {label}=PATH@{lag[0]} (git fetch first).")
519
569
  counted = 0
520
570
  for line in completed.stdout.splitlines():
521
571
  sha, _, subject = line.partition("\x1f")
522
572
  subject = safe_text(subject)[:200]
523
573
  if not sha or not subject:
524
574
  continue
575
+ seen += 1
525
576
  classified = classify_subject(subject, include_all)
526
577
  if classified is None:
527
578
  skipped += 1
528
579
  continue
580
+ scope = subject_scope(subject)
581
+ if skill_relevant and not (SKILL_HINT_RE.search(subject) or (skill_hint and skill_hint in subject.lower())):
582
+ project_commits += 1
583
+ continue
529
584
  kind, text = classified
530
585
  text = text[:1].upper() + text[1:] if text else subject
531
586
  commit = f"{label}@{sha}" if len(repos) > 1 else sha
587
+ candidates.append({"kind": kind, "text": text, "commit": commit, "scope": scope or label})
588
+ counted += 1
589
+ per_repo[label] = counted
590
+ grouped = len(candidates) > SUMMARY_ITEM_CAP
591
+ items = []
592
+ if not grouped:
593
+ for candidate in candidates:
594
+ commit, text, kind = candidate["commit"], candidate["text"], candidate["kind"]
532
595
  item = {
533
596
  "type": kind,
534
597
  "summary": text,
@@ -540,10 +603,24 @@ def session_summary(args: argparse.Namespace) -> int:
540
603
  if kind in {"bug", "improvement"}:
541
604
  item.update({"fixed": True, "resolution": text})
542
605
  items.append(item)
543
- counted += 1
544
- if len(items) >= 49:
545
- break
546
- per_repo[label] = counted
606
+ else:
607
+ # Too many commits for one batch: one item per scope, so nothing is dropped silently.
608
+ scopes: dict[str, list[dict]] = {}
609
+ for candidate in candidates:
610
+ scopes.setdefault(candidate["scope"], []).append(candidate)
611
+ ordered = sorted(scopes.items(), key=lambda pair: (-len(pair[1]), pair[0]))
612
+ for scope, entries in ordered[:SUMMARY_ITEM_CAP]:
613
+ bugs = sum(1 for entry in entries if entry["kind"] == "bug")
614
+ items.append({
615
+ "type": "bug" if bugs * 2 > len(entries) else "improvement",
616
+ "summary": f"{scope}: {len(entries)} commit(s) ({bugs} fix) — keep only if a Maggie skill or CLI fell short",
617
+ "expected": f"{REVIEW_MARKER} what the skill or CLI should have done",
618
+ "actual": f"{REVIEW_MARKER} summarise the {scope} commits that came from a skill gap",
619
+ "validation": f"{REVIEW_MARKER} the test or check that verified the {scope} commits",
620
+ "evidence": [f"commit:{entry['commit']}" for entry in entries[:20]],
621
+ })
622
+ if len(ordered) > SUMMARY_ITEM_CAP:
623
+ warnings.append(f"{len(ordered) - SUMMARY_ITEM_CAP} scope(s) did not fit in one batch; narrow --since-date or use --skill-relevant.")
547
624
  items.append({
548
625
  "type": "feature",
549
626
  "summary": f"{REVIEW_MARKER} describe an open ask from this session, or delete this item",
@@ -557,6 +634,7 @@ def session_summary(args: argparse.Namespace) -> int:
557
634
  "phase": safe_text(args.phase) or "session-summary",
558
635
  "reviewRequired": True,
559
636
  "note": "Draft only. Remove items unrelated to Maggie skills or CLIs, replace every REVIEW: and delete the open-ask placeholder if unused. `maggie feedback batch` rejects unedited REVIEW: fields.",
637
+ "commits": {"seen": seen, "drafted": len(candidates), "skipped": skipped, "projectOnly": project_commits, "groupedByScope": grouped},
560
638
  "items": items,
561
639
  }
562
640
  project = Path(args.project).resolve()
@@ -567,7 +645,9 @@ def session_summary(args: argparse.Namespace) -> int:
567
645
  raise ValueError("output already exists; choose another --output or --batch-id")
568
646
  output.parent.mkdir(parents=True, exist_ok=True)
569
647
  output.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
570
- print(json.dumps({"status": "draft-written", "batchId": batch_id, "items": len(items), "skippedCommits": skipped, "repositories": per_repo, "output": str(output), "submitted": False}, ensure_ascii=False))
648
+ if grouped:
649
+ warnings.append(f"{len(candidates)} commits exceed the {SUMMARY_ITEM_CAP}-item batch limit, so they were grouped into one item per scope.")
650
+ print(json.dumps({"status": "draft-written", "batchId": batch_id, "items": len(items), "commitsSeen": seen, "draftedCommits": len(candidates), "skippedCommits": skipped, "projectOnlyCommits": project_commits, "groupedByScope": grouped, "repositories": per_repo, "warnings": warnings, "output": str(output), "submitted": False}, ensure_ascii=False))
571
651
  return 0
572
652
 
573
653
 
@@ -716,7 +796,9 @@ def main() -> int:
716
796
  summary_parser.add_argument("--project", default=argparse.SUPPRESS)
717
797
  summary_parser.add_argument("--since", default="", help="git revision; commits after it are summarized")
718
798
  summary_parser.add_argument("--since-date", default="", help="ISO date/time; use across several repositories")
719
- summary_parser.add_argument("--repo", action="append", default=[], help="[LABEL=]PATH of a git repository; repeat for app, backend, edge (default: current directory)")
799
+ summary_parser.add_argument("--repo", action="append", default=[], help="[LABEL=]PATH[@REV] of a git repository; repeat for app, backend, edge (default: current directory at HEAD)")
800
+ summary_parser.add_argument("--rev", default="", help="revision to read in every repository without its own @REV, e.g. origin/main")
801
+ summary_parser.add_argument("--skill-relevant", action="store_true", help="keep only commits that mention Maggie, a skill, a CLI or MCP (or the --skill name); count the rest as project-only")
720
802
  summary_parser.add_argument("--all", action="store_true", help="also draft unclassified and chore subjects as general items")
721
803
  summary_parser.add_argument("--merges", action="store_true", help="include merge commits (PR branch names are classified)")
722
804
  summary_parser.add_argument("--batch-id", default="")
@@ -20,19 +20,32 @@ fails. It checks, per build profile and platform:
20
20
 
21
21
  ``plan`` prints the build / submit / update commands with a cost notice and
22
22
  never runs them. Nothing is uploaded and no credentials are read.
23
+
24
+ An OTA update must be fingerprinted with the config files the store build was
25
+ made from: ``eas.json`` and ``package.json`` usually changed since. ``plan
26
+ --build-ref <git ref>`` adds the swap steps, and ``ota --build-ref REF
27
+ --build-id ID`` performs them: put the build commit's copies in place, run
28
+ ``eas fingerprint:compare``, publish the update only when it matches, and
29
+ always put the working copies back (verified by hash), even on failure. ``ota``
30
+ is a dry run unless ``--confirm`` is given, because the update is published.
23
31
  """
24
32
 
25
33
  from __future__ import annotations
26
34
 
27
35
  import argparse
36
+ import hashlib
28
37
  import json
38
+ import os
29
39
  import re
40
+ import shlex
41
+ import subprocess
30
42
  import sys
31
43
  from datetime import datetime, timezone
32
44
  from pathlib import Path
33
45
 
34
46
  SCHEMA = "maggie-mobile-release-preflight.v1"
35
47
  PLATFORMS = ("ios", "android")
48
+ BUILD_CONFIG_FILES = ("eas.json", "package.json")
36
49
  VERSION_POLICIES = {"appVersion", "nativeVersion", "sdkVersion"}
37
50
 
38
51
 
@@ -68,7 +81,7 @@ def runtime_policy(expo: dict, platform: str) -> tuple[object, str]:
68
81
  return expo.get("runtimeVersion"), "expo.runtimeVersion"
69
82
 
70
83
 
71
- def check_profile(expo: dict, eas: dict, package: dict, profile: str, platforms: list[str], auto_submit: bool, raw_app_json: str | None) -> tuple[list[str], list[str], dict]:
84
+ def check_profile(expo: dict, eas: dict, package: dict, profile: str, platforms: list[str], auto_submit: bool, raw_app_json: str | None, app_dir_ignore_text: str = "") -> tuple[list[str], list[str], dict]:
72
85
  errors: list[str] = []
73
86
  warnings: list[str] = []
74
87
  facts: dict = {"profile": profile, "platforms": platforms}
@@ -140,6 +153,17 @@ def check_profile(expo: dict, eas: dict, package: dict, profile: str, platforms:
140
153
  warnings.append(f"android: submit.{profile}.android.serviceAccountKeyPath is not set; provide it or EXPO_ANDROID_SERVICE_ACCOUNT_KEY_PATH, and keep the key file out of git")
141
154
  if android.get("track", "internal") not in {"internal", "alpha", "beta", "production"}:
142
155
  errors.append("android: submit track must be internal, alpha, beta or production")
156
+ for script_name, command in (package.get("scripts", {}) or {}).items():
157
+ text = str(command)
158
+ if re.search(r"\beas(?:-cli)?\s+update\b(?!:)", text):
159
+ if "--non-interactive" in text and "--environment" not in text:
160
+ errors.append(f"package.json script {script_name!r} runs `eas update --non-interactive` without --environment; EAS refuses it ('The --environment flag must be set'). Add --environment matching the channel")
161
+ if "fingerprint:compare" not in text and uses_updates:
162
+ warnings.append(f"package.json script {script_name!r} publishes an update without `eas fingerprint:compare --build-id <build>`; a fingerprint mismatch sends the update to a runtime no installed build has")
163
+ if uses_updates and any(isinstance(runtime_policy(expo, p)[0], dict) and runtime_policy(expo, p)[0].get("policy") == "fingerprint" for p in platforms):
164
+ ignore = app_dir_ignore_text
165
+ if "eas.json" not in ignore.split():
166
+ warnings.append("runtime policy fingerprint includes eas.json: editing eas.json after a build (for example adding ascAppId) changes the fingerprint and silently stops OTA updates reaching that build. Add eas.json to .fingerprintignore with the next native build, and run `eas fingerprint:compare --build-id <build>` before every update")
143
167
  if raw_app_json and "\t" in raw_app_json:
144
168
  warnings.append("app.json is tab-indented; `eas update:configure` rewrites it with spaces (a large diff) — prefer editing runtimeVersion and updates by hand")
145
169
  return errors, warnings, facts
@@ -154,7 +178,8 @@ def preflight(project: Path, app_dir: Path | None, config_json: Path | None, pro
154
178
  raise ValueError("eas.json not found; run `npx eas-cli build:configure` once, then review the file")
155
179
  eas = _load(eas_path)
156
180
  package = _load(app / "package.json") if (app / "package.json").is_file() else {}
157
- errors, warnings, facts = check_profile(expo, eas, package, profile, platforms, auto_submit, raw)
181
+ ignore = (app / ".fingerprintignore").read_text(encoding="utf-8") if (app / ".fingerprintignore").is_file() else ""
182
+ errors, warnings, facts = check_profile(expo, eas, package, profile, platforms, auto_submit, raw, ignore)
158
183
  return {
159
184
  "schemaVersion": SCHEMA,
160
185
  "appDir": str(app.relative_to(project)) if app.is_relative_to(project) else ".",
@@ -173,7 +198,34 @@ def cost_notice(platforms: list[str], profile: str) -> str:
173
198
  "build minutes even if a later step fails. EAS Update publishes are counted against monthly update users. Confirm before running.")
174
199
 
175
200
 
176
- def plan_commands(profile: str, platforms: list[str], auto_submit: bool, channel: str | None) -> list[dict]:
201
+ def git_root(path: Path) -> Path:
202
+ completed = subprocess.run(["git", "-C", str(path), "rev-parse", "--show-toplevel"], capture_output=True, text=True, check=False)
203
+ if completed.returncode:
204
+ raise ValueError("the app folder is not inside a git repository; --build-ref needs git")
205
+ return Path(completed.stdout.strip()).resolve()
206
+
207
+
208
+ def build_config_drift(app: Path, build_ref: str) -> list[dict]:
209
+ """Config files whose working copy differs from the build commit's copy (git object paths, never contents)."""
210
+ if build_ref.startswith("-") or not re.fullmatch(r"[A-Za-z0-9._/@{}~^:-]{1,200}", build_ref):
211
+ raise ValueError("--build-ref must be a git revision such as a tag or commit")
212
+ root = git_root(app)
213
+ files = []
214
+ for name in BUILD_CONFIG_FILES:
215
+ path = app / name
216
+ rel = path.resolve().relative_to(root).as_posix()
217
+ shown = subprocess.run(["git", "-C", str(root), "show", f"{build_ref}:{rel}"], capture_output=True, check=False)
218
+ if shown.returncode:
219
+ if name == "eas.json":
220
+ raise ValueError(f"{rel} does not exist at {build_ref}; is that the commit the store build was made from?")
221
+ continue
222
+ current = path.read_bytes() if path.is_file() else b""
223
+ files.append({"file": rel, "differs": current != shown.stdout, "buildBytes": shown.stdout, "workingSha256": hashlib.sha256(current).hexdigest()})
224
+ return files
225
+
226
+
227
+ def plan_commands(profile: str, platforms: list[str], auto_submit: bool, channel: str | None, environment: str | None = None, build_ref: str | None = None, drift: list[dict] | None = None) -> list[dict]:
228
+ environment = environment or (profile if profile in {"development", "preview", "production"} else "production")
177
229
  platform_arg = "all" if len(platforms) == 2 else platforms[0]
178
230
  commands = [{"step": "preflight", "command": f"maggie mobile preflight --profile {profile} --platform {platform_arg}" + (" --auto-submit" if auto_submit else ""), "spends": False}]
179
231
  build = f"npx eas-cli build -p {platform_arg} --profile {profile} --non-interactive" + (" --auto-submit" if auto_submit else "")
@@ -181,16 +233,81 @@ def plan_commands(profile: str, platforms: list[str], auto_submit: bool, channel
181
233
  if not auto_submit:
182
234
  for platform in platforms:
183
235
  commands.append({"step": f"submit-{platform}", "command": f"npx eas-cli submit -p {platform} --profile {profile} --latest --non-interactive", "spends": False})
236
+ swapped = [item["file"] for item in (drift or []) if item["differs"]]
237
+ if channel and build_ref:
238
+ commands.append({"step": "ota-from-build-config", "command": f"maggie mobile ota --profile {profile} --platform {platform_arg} --build-ref {build_ref} --build-id <installed build id> --message \"<what changed>\" --confirm", "spends": True,
239
+ "note": ("swaps " + ", ".join(swapped) + f" for the copies at {build_ref}, runs fingerprint:compare and the update, then restores the working copies" if swapped else f"the config files match {build_ref}; no swap needed")})
240
+ return commands
184
241
  if channel:
185
- environment = f" --environment {profile}" if profile in {"development", "preview", "production"} else ""
186
- commands.append({"step": "ota-update", "command": f"npx eas-cli update --channel {channel}{environment} --platform {platform_arg} --message \"<what changed>\" --non-interactive", "spends": True, "note": "only JS/asset changes; a native change needs a new build (the fingerprint policy enforces this)"})
242
+ commands.append({"step": "fingerprint-gate", "command": "npx eas-cli fingerprint:compare --build-id <installed build id>", "spends": False, "note": "must report no difference; editing eas.json, native config or dependencies after the build changes the fingerprint and the update would reach no installed build"})
243
+ commands.append({"step": "ota-update", "command": f"npx eas-cli update --channel {channel} --environment {environment} --platform {platform_arg} --message \"<what changed>\" --non-interactive", "spends": True, "note": "only JS/asset changes; a native change needs a new build (the fingerprint policy enforces this)"})
187
244
  return commands
188
245
 
189
246
 
247
+ def eas_command() -> list[str]:
248
+ """The EAS CLI invocation; MAGGIE_MOBILE_EAS overrides it (tests, a pinned eas-cli)."""
249
+ return shlex.split(os.environ.get("MAGGIE_MOBILE_EAS", "npx eas-cli"))
250
+
251
+
252
+ def fingerprint_hashes(stdout: str) -> tuple[str, str] | None:
253
+ """(build hash, project hash) from `eas fingerprint:compare --json`, or None when the output is not that."""
254
+ start = stdout.find("{")
255
+ try:
256
+ data = json.loads(stdout[start:]) if start >= 0 else {}
257
+ return str(data["fingerprint1"]["hash"]), str(data["fingerprint2"]["hash"])
258
+ except (ValueError, KeyError, TypeError):
259
+ return None
260
+
261
+
262
+ def ota(app: Path, build_ref: str, build_id: str, channel: str, environment: str, platform_arg: str, message: str, confirm: bool) -> dict:
263
+ """Fingerprint and publish an update with the build's own config files; always restore the working copies."""
264
+ if not re.fullmatch(r"[A-Za-z0-9-]{8,64}", build_id):
265
+ raise ValueError("--build-id must be the EAS build id of the installed build")
266
+ if not message.strip():
267
+ raise ValueError("--message is required")
268
+ drift = build_config_drift(app, build_ref)
269
+ root = git_root(app)
270
+ # fingerprint:compare exits 0 even when the fingerprints differ; --json prints both, so compare the hashes.
271
+ compare = eas_command() + ["fingerprint:compare", "--build-id", build_id, "--json", "--non-interactive"]
272
+ update = eas_command() + ["update", "--channel", channel, "--environment", environment, "--platform", platform_arg, "--message", message, "--non-interactive"]
273
+ report = {"buildRef": build_ref, "swapped": [item["file"] for item in drift if item["differs"]], "commands": [shlex.join(compare), shlex.join(update)], "executed": False}
274
+ if not confirm:
275
+ report["status"] = "dry-run"
276
+ report["note"] = "nothing was changed; re-run with --confirm to swap, compare, publish and restore"
277
+ return report
278
+ originals = {item["file"]: (root / item["file"]).read_bytes() for item in drift if item["differs"]}
279
+ steps = []
280
+ try:
281
+ for item in drift:
282
+ if item["differs"]:
283
+ (root / item["file"]).write_bytes(item["buildBytes"])
284
+ result = subprocess.run(compare, cwd=app, capture_output=True, text=True, check=False)
285
+ hashes = fingerprint_hashes(result.stdout)
286
+ steps.append({"step": "fingerprint-compare", "exit": result.returncode, "match": bool(hashes) and hashes[0] == hashes[1]})
287
+ if result.returncode or not hashes:
288
+ report.update(status="failed", reason="fingerprint:compare did not return two fingerprints; nothing was published")
289
+ return report
290
+ if hashes[0] != hashes[1]:
291
+ report.update(status="failed", reason="the fingerprint with the build's config files does not match the installed build; this change needs a new native build")
292
+ return report
293
+ result = subprocess.run(update, cwd=app, capture_output=True, text=True, check=False)
294
+ steps.append({"step": "update", "exit": result.returncode})
295
+ report["executed"] = True
296
+ report["status"] = "published" if result.returncode == 0 else "failed"
297
+ if result.returncode:
298
+ report["reason"] = "eas update failed; see its output"
299
+ return report
300
+ finally:
301
+ for rel, data in originals.items():
302
+ (root / rel).write_bytes(data)
303
+ report["restored"] = {rel: hashlib.sha256((root / rel).read_bytes()).hexdigest() == hashlib.sha256(data).hexdigest() for rel, data in originals.items()}
304
+ report["steps"] = steps
305
+
306
+
190
307
  def main(argv: list[str] | None = None) -> int:
191
308
  parser = argparse.ArgumentParser(prog="maggie mobile", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
192
309
  sub = parser.add_subparsers(dest="command", required=True)
193
- for name in ("preflight", "plan"):
310
+ for name in ("preflight", "plan", "ota"):
194
311
  command = sub.add_parser(name)
195
312
  command.add_argument("--project", type=Path, default=Path("."))
196
313
  command.add_argument("--app-dir", type=Path, help="Expo app folder inside a monorepo, e.g. apps/app")
@@ -198,7 +315,14 @@ def main(argv: list[str] | None = None) -> int:
198
315
  command.add_argument("--profile", default="production")
199
316
  command.add_argument("--platform", choices=("ios", "android", "all"), default="all")
200
317
  command.add_argument("--auto-submit", action="store_true", help="the build will run with --auto-submit --non-interactive")
318
+ command.add_argument("--environment", help="EAS environment for updates (default: the profile when it is development/preview/production)")
201
319
  command.add_argument("--output", type=Path)
320
+ if name in {"plan", "ota"}:
321
+ command.add_argument("--build-ref", help="git commit or tag the installed store build was made from")
322
+ if name == "ota":
323
+ command.add_argument("--build-id", required=True, help="EAS build id of the installed build (for fingerprint:compare)")
324
+ command.add_argument("--message", required=True)
325
+ command.add_argument("--confirm", action="store_true", help="actually swap, compare, publish and restore; without it nothing changes")
202
326
  args = parser.parse_args(argv)
203
327
  platforms = list(PLATFORMS) if args.platform == "all" else [args.platform]
204
328
  try:
@@ -206,14 +330,36 @@ def main(argv: list[str] | None = None) -> int:
206
330
  except (OSError, ValueError) as error:
207
331
  print(f"BLOCKED: maggie mobile {args.command}: {error}", file=sys.stderr)
208
332
  return 2
209
- if args.command == "plan":
210
- 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, report["facts"].get("channel") if report["facts"].get("easUpdate") else None), "costNotice": report["costNotice"], "executed": False}
333
+ project = args.project.resolve()
334
+ app = (project / args.app_dir).resolve() if args.app_dir and not args.app_dir.is_absolute() else (args.app_dir.resolve() if args.app_dir else project)
335
+ channel = report["facts"].get("channel") if report["facts"].get("easUpdate") else None
336
+ environment = args.environment or (args.profile if args.profile in {"development", "preview", "production"} else "production")
337
+ platform_arg = "all" if len(platforms) == 2 else platforms[0]
338
+ try:
339
+ if args.command == "plan":
340
+ drift = build_config_drift(app, args.build_ref) if args.build_ref else None
341
+ 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
+ if drift is not None:
343
+ report["buildConfig"] = [{"file": item["file"], "differsFromBuild": item["differs"]} for item in drift]
344
+ elif args.command == "ota":
345
+ if report["status"] == "failed":
346
+ raise ValueError("preflight failed: " + "; ".join(report["errors"]))
347
+ if not channel:
348
+ raise ValueError("the profile has no EAS Update channel")
349
+ if not args.build_ref:
350
+ raise ValueError("--build-ref is required: the commit the installed build was made from")
351
+ report = {"schemaVersion": "maggie-mobile-ota.v1", **ota(app, args.build_ref, args.build_id, channel, environment, platform_arg, args.message, args.confirm)}
352
+ except (OSError, ValueError) as error:
353
+ print(f"BLOCKED: maggie mobile {args.command}: {error}", file=sys.stderr)
354
+ return 2
211
355
  text = json.dumps(report, indent=2, ensure_ascii=False)
212
356
  if args.output:
213
357
  args.output.parent.mkdir(parents=True, exist_ok=True)
214
358
  args.output.write_text(text + "\n", encoding="utf-8")
215
359
  print(text)
216
360
  status = report["status"] if "status" in report else report["preflight"]["status"]
361
+ if report.get("restored") and not all(report["restored"].values()):
362
+ return 1
217
363
  return 1 if status == "failed" else 0
218
364
 
219
365