@topy-ai/maggie 0.7.49 → 0.7.51

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 (33) hide show
  1. package/README-zh-TW.md +3 -3
  2. package/README.md +58 -8
  3. package/bin/maggie.js +33 -14
  4. package/bundled-contracts/maggie-assistant-tools/tool-registry-example-v1.json +19 -0
  5. package/bundled-contracts/maggie-assistant-tools/tool-registry-v1.schema.json +50 -0
  6. package/bundled-contracts/maggie-deployment/bridge-host-example-v1.json +29 -0
  7. package/bundled-contracts/maggie-design/mobile-app-evidence-v1.schema.json +54 -0
  8. package/bundled-contracts/maggie-design/mobile-app-surface-v1.schema.json +137 -16
  9. package/bundled-skills/catalog.json +8 -0
  10. package/bundled-skills/maggie-assistant-tools/SKILL.md +93 -0
  11. package/bundled-skills/maggie-assistant-tools/references/approvals-and-audit.md +54 -0
  12. package/bundled-skills/maggie-assistant-tools/references/bridge-host.md +56 -0
  13. package/bundled-skills/maggie-assistant-tools/references/product-release.md +57 -0
  14. package/bundled-skills/maggie-deployment/SKILL.md +13 -0
  15. package/bundled-skills/maggie-design/SKILL.md +55 -21
  16. package/bundled-skills/maggie-design/references/app-ui-patterns.md +66 -0
  17. package/bundled-skills/maggie-feedback/SKILL.md +20 -3
  18. package/bundled-skills/maggie-marketplace/SKILL.md +28 -0
  19. package/bundled-skills/maggie-mobile-app/SKILL.md +122 -0
  20. package/bundled-skills/maggie-qa-workflow/SKILL.md +30 -0
  21. package/bundled-tools/clis/maggie_app_collect.py +236 -0
  22. package/bundled-tools/clis/maggie_assistant_tools.py +302 -0
  23. package/bundled-tools/clis/maggie_bridge_host.py +212 -0
  24. package/bundled-tools/clis/maggie_design.py +190 -29
  25. package/bundled-tools/clis/maggie_feedback.py +89 -37
  26. package/bundled-tools/clis/maggie_mobile.py +221 -0
  27. package/bundled-tools/clis/maggie_product_release.py +303 -0
  28. package/bundled-tools/clis/maggie_qa_app_preflight.py +248 -0
  29. package/bundled-tools/clis/maggie_qa_workflow.py +5 -0
  30. package/bundled-tools/clis/maggie_skill_catalog.py +262 -0
  31. package/bundled-tools/runtime/assistant_approval.py +111 -0
  32. package/bundled-tools/runtime/cdp_client.py +246 -0
  33. package/package.json +1 -1
@@ -0,0 +1,212 @@
1
+ #!/usr/bin/env python3
2
+ """Readiness checks for a multi-tenant assistant bridge host (``maggie deployment bridge-*``).
3
+
4
+ A bridge host runs many users' assistant sessions (a coding/assistant CLI
5
+ plus MCP servers and skills) in one container on a VPS. The plan
6
+ (``maggie-bridge-host.v1``) declares isolation, network, capacity, recycling,
7
+ image retention and third-party account terms; three commands check it:
8
+
9
+ - ``bridge-plan``: validates the declared plan;
10
+ - ``bridge-collect-script``: prints a POSIX ``sh`` script that collects the
11
+ observed facts as JSON. Run it with ``ssh host 'sh -s' < collect.sh`` —
12
+ stdin avoids quoting JSON through ssh and zsh's lack of word splitting;
13
+ - ``bridge-check``: compares the observed JSON with the plan.
14
+
15
+ Lessons encoded here: tenant uids cannot reach their folders unless every
16
+ parent is ``0711`` (traverse, no listing), tenant folders are ``0700``, the
17
+ admin API listens on loopback only and is never proxied, idle sessions are
18
+ evicted LRU at a cap, sessions recycle when MCP, skills or the CLI update,
19
+ and images carry a label so only current + previous are kept, with a
20
+ health-checked switch that rolls back.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import json
27
+ import re
28
+ import shlex
29
+ import sys
30
+ from datetime import date, datetime, timezone
31
+ from pathlib import Path
32
+
33
+ SCHEMA = "maggie-bridge-host.v1"
34
+ SECRET_ENV = re.compile(r"TOKEN|SECRET|PASSWORD|API_KEY|PRIVATE|CREDENTIAL|_KEY$", re.I)
35
+ LOOPBACK = re.compile(r"^(?:127\.0\.0\.1|\[::1\]|localhost):\d+$")
36
+ TERMS_MAX_AGE_DAYS = 180
37
+
38
+
39
+ def _mode(value: object) -> int | None:
40
+ text = str(value or "").strip()
41
+ return int(text, 8) if re.fullmatch(r"0?[0-7]{3}", text) else None
42
+
43
+
44
+ def validate_plan(plan: dict, today: date | None = None) -> dict:
45
+ today = today or datetime.now(timezone.utc).date()
46
+ errors: list[str] = []
47
+ warnings: list[str] = []
48
+ if plan.get("schemaVersion") != SCHEMA:
49
+ errors.append(f"schemaVersion must be {SCHEMA}")
50
+ if not str(plan.get("tenantsRoot", "")).startswith("/"):
51
+ errors.append("tenantsRoot must be an absolute path")
52
+ iso = plan.get("isolation", {}) if isinstance(plan.get("isolation"), dict) else {}
53
+ if iso.get("uidPerTenant") is not True:
54
+ errors.append("isolation.uidPerTenant must be true: one OS uid per tenant")
55
+ uid_range = iso.get("uidRange")
56
+ if not (isinstance(uid_range, list) and len(uid_range) == 2 and all(isinstance(v, int) for v in uid_range) and 10000 <= uid_range[0] < uid_range[1]):
57
+ errors.append("isolation.uidRange must be [first, last] at or above 10000 (clear of system and login users)")
58
+ if _mode(iso.get("tenantDirMode")) != 0o700:
59
+ errors.append("isolation.tenantDirMode must be 0700")
60
+ parent = _mode(iso.get("parentDirMode"))
61
+ if parent != 0o711:
62
+ errors.append("isolation.parentDirMode must be 0711: tenants traverse the parents without listing other tenants" + (" (0700 locks tenants out of their own folders)" if parent == 0o700 else ""))
63
+ if iso.get("cleanEnvironment") is not True:
64
+ errors.append("isolation.cleanEnvironment must be true: start each session with an allowlisted environment")
65
+ leaked = [name for name in iso.get("envAllowlist", []) or [] if SECRET_ENV.search(str(name))]
66
+ if leaked:
67
+ errors.append("isolation.envAllowlist passes secret-looking variables to tenant sessions: " + ", ".join(leaked))
68
+ if iso.get("ownerPinning") is not True:
69
+ errors.append("isolation.ownerPinning must be true: a session runs only for the tenant that owns it")
70
+ if iso.get("nonDumpable") is not True:
71
+ warnings.append("isolation.nonDumpable is not set; mark sessions non-dumpable after dropping to the tenant uid")
72
+ net = plan.get("network", {}) if isinstance(plan.get("network"), dict) else {}
73
+ if not LOOPBACK.match(str(net.get("adminBind", ""))):
74
+ errors.append("network.adminBind must be a loopback address:port")
75
+ if net.get("adminProxied") is not False:
76
+ errors.append("network.adminProxied must be false: the admin API is never exposed through the reverse proxy")
77
+ if net.get("proxy") and not LOOPBACK.match(str(net.get("publicBind", ""))):
78
+ errors.append("network.publicBind must be loopback when a reverse proxy fronts the host")
79
+ if net.get("adminBind") and net.get("adminBind") == net.get("publicBind"):
80
+ errors.append("admin and public APIs must listen on different ports")
81
+ cap = plan.get("capacity", {}) if isinstance(plan.get("capacity"), dict) else {}
82
+ if not isinstance(cap.get("maxSessions"), int) or cap["maxSessions"] < 1:
83
+ errors.append("capacity.maxSessions must be a positive integer")
84
+ if cap.get("eviction") not in {"lru-idle"}:
85
+ errors.append("capacity.eviction must be lru-idle: at the cap, stop the least recently used idle session")
86
+ if not isinstance(cap.get("idleTimeoutMinutes"), int) or not 1 <= cap["idleTimeoutMinutes"] <= 240:
87
+ warnings.append("capacity.idleTimeoutMinutes should stop idle sessions within 1-240 minutes")
88
+ recycle = plan.get("recycle", {}) if isinstance(plan.get("recycle"), dict) else {}
89
+ missing = sorted({"mcp", "skills", "cli"} - set(recycle.get("on", []) or []))
90
+ if missing:
91
+ errors.append("recycle.on must include " + ", ".join(missing) + ": sessions keep stale tools until recycled")
92
+ if recycle.get("busySessions") != "after-current-turn":
93
+ warnings.append("recycle.busySessions should be after-current-turn so an update never cuts a reply")
94
+ image = plan.get("image", {}) if isinstance(plan.get("image"), dict) else {}
95
+ if not re.fullmatch(r"[a-z0-9.-]+=[A-Za-z0-9._-]+", str(image.get("label", ""))):
96
+ errors.append("image.label must be key=value so cleanup can find this host's images")
97
+ if not isinstance(image.get("retain"), int) or image["retain"] < 2:
98
+ errors.append("image.retain must be at least 2 (current + previous for rollback)")
99
+ health = image.get("healthcheck", {}) if isinstance(image.get("healthcheck"), dict) else {}
100
+ if not str(health.get("path", "")).startswith("/") or not isinstance(health.get("attempts"), int):
101
+ errors.append("image.healthcheck needs a path and attempts")
102
+ if image.get("rollback") is not True:
103
+ errors.append("image.rollback must be true: a failed health check restores the previous container")
104
+ for key in ("readOnlyRootfs", "capDropAll", "noNewPrivileges"):
105
+ if image.get(key) is not True:
106
+ warnings.append(f"image.{key} is not set")
107
+ providers = plan.get("providers")
108
+ if not isinstance(providers, list) or not providers:
109
+ errors.append("providers must list each third-party AI account type the host runs, with a terms review")
110
+ for provider in providers if isinstance(providers, list) else []:
111
+ name = provider.get("name", "?")
112
+ reviewed = provider.get("termsReviewedAt")
113
+ if not reviewed or not provider.get("termsUrl"):
114
+ errors.append(f"provider {name}: record termsUrl and termsReviewedAt")
115
+ else:
116
+ age = (today - date.fromisoformat(str(reviewed)[:10])).days
117
+ if age > TERMS_MAX_AGE_DAYS:
118
+ warnings.append(f"provider {name}: terms reviewed {age} days ago; review again")
119
+ if provider.get("accountType") == "shared" and provider.get("allowsHostedMultiUser") is not True:
120
+ errors.append(f"provider {name}: a shared account may only serve several users when the terms allow hosted multi-user use")
121
+ if provider.get("accountType") not in {"customer-own", "shared"}:
122
+ errors.append(f"provider {name}: accountType must be customer-own or shared")
123
+ return {"schemaVersion": "maggie-bridge-host-plan-check.v1", "passed": not errors, "errors": errors, "warnings": warnings}
124
+
125
+
126
+ def collect_script(plan: dict) -> str:
127
+ """A POSIX sh collector; values are quoted once here and the script is sent over ssh stdin."""
128
+ root = shlex.quote(str(plan.get("tenantsRoot", "/var/lib/bridge/tenants")))
129
+ label = shlex.quote(str(plan.get("image", {}).get("label", "")))
130
+ ports = " ".join(shlex.quote(str(plan.get("network", {}).get(key, "")).rsplit(":", 1)[-1]) for key in ("publicBind", "adminBind"))
131
+ return f"""#!/bin/sh
132
+ # maggie bridge-host collector: prints JSON facts only (modes, uids, listeners, image count).
133
+ # Run: ssh <host> 'sh -s' < collect.sh > observed.json
134
+ set -eu
135
+ root={root}
136
+ printf '{{"schemaVersion":"maggie-bridge-host-observed.v1","parents":['
137
+ sep=''
138
+ dir="$root"
139
+ while [ "$dir" != "/" ]; do
140
+ printf '%s{{"path":"%s","mode":"%s"}}' "$sep" "$dir" "$(stat -c %a "$dir")"; sep=','
141
+ dir=$(dirname "$dir")
142
+ done
143
+ printf '],"tenants":['
144
+ sep=''
145
+ for t in "$root"/*; do
146
+ [ -d "$t" ] || continue
147
+ printf '%s{{"name":"%s","mode":"%s","uid":%s}}' "$sep" "$(basename "$t")" "$(stat -c %a "$t")" "$(stat -c %u "$t")"; sep=','
148
+ done
149
+ printf '],"listeners":['
150
+ listeners=$(for port in {ports}; do ss -ltnH "sport = :$port" 2>/dev/null | awk '{{print $4}}'; done)
151
+ sep=''
152
+ for local in $listeners; do printf '%s"%s"' "$sep" "$local"; sep=','; done
153
+ printf '],"images":%s}}\\n' "$(docker images --filter label={label} --format '{{{{.ID}}}}' 2>/dev/null | sort -u | wc -l)"
154
+ """
155
+
156
+
157
+ def check_observed(plan: dict, observed: dict) -> dict:
158
+ errors: list[str] = []
159
+ iso = plan.get("isolation", {})
160
+ first, last = (iso.get("uidRange") or [10000, 10**9])[:2]
161
+ parents = observed.get("parents", [])
162
+ for item in parents[1:]: # ancestors only need traverse (x) for other users
163
+ mode = _mode(item.get("mode"))
164
+ if mode is None or not mode & 0o001:
165
+ errors.append(f"parent {item.get('path')} is {item.get('mode')}: tenant uids cannot traverse it (others need x, e.g. 0711)")
166
+ if parents and _mode(parents[0].get("mode")) != _mode(iso.get("parentDirMode", "0711")):
167
+ errors.append(f"tenants root {parents[0].get('path')} is {parents[0].get('mode')}, plan says {iso.get('parentDirMode')}")
168
+ uids: dict[int, str] = {}
169
+ for tenant in observed.get("tenants", []):
170
+ name, uid = tenant.get("name"), tenant.get("uid")
171
+ if _mode(tenant.get("mode")) != 0o700:
172
+ errors.append(f"tenant {name} is {tenant.get('mode')}, not 0700")
173
+ if not isinstance(uid, int) or not first <= uid <= last:
174
+ errors.append(f"tenant {name} uid {uid} is outside {first}-{last}")
175
+ elif uid in uids:
176
+ errors.append(f"tenants {uids[uid]} and {name} share uid {uid}")
177
+ else:
178
+ uids[uid] = name
179
+ admin_port = str(plan.get("network", {}).get("adminBind", "")).rsplit(":", 1)[-1]
180
+ for listener in observed.get("listeners", []):
181
+ host, _, port = str(listener).rpartition(":")
182
+ if port == admin_port and host not in {"127.0.0.1", "[::1]", "::1"}:
183
+ errors.append(f"admin port {port} listens on {host or '*'}, not loopback")
184
+ retain = plan.get("image", {}).get("retain", 2)
185
+ if isinstance(observed.get("images"), int) and observed["images"] > retain:
186
+ errors.append(f"{observed['images']} labelled images on the host; keep {retain} (current + previous)")
187
+ return {"schemaVersion": "maggie-bridge-host-check.v1", "passed": not errors, "tenants": len(observed.get("tenants", [])), "errors": errors}
188
+
189
+
190
+ def main(argv: list[str] | None = None) -> int:
191
+ parser = argparse.ArgumentParser(prog="maggie deployment bridge", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
192
+ sub = parser.add_subparsers(dest="command", required=True)
193
+ for name in ("bridge-plan", "bridge-collect-script", "bridge-check"):
194
+ command = sub.add_parser(name)
195
+ command.add_argument("--plan", type=Path, required=True)
196
+ if name == "bridge-check":
197
+ command.add_argument("--observed", type=Path, required=True)
198
+ args = parser.parse_args(argv)
199
+ try:
200
+ plan = json.loads(args.plan.read_text(encoding="utf-8"))
201
+ if args.command == "bridge-collect-script":
202
+ print(collect_script(plan), end=""); return 0
203
+ report = validate_plan(plan) if args.command == "bridge-plan" else check_observed(plan, json.loads(args.observed.read_text(encoding="utf-8")))
204
+ except (OSError, ValueError, json.JSONDecodeError) as error:
205
+ print(f"BLOCKED: maggie deployment {args.command}: {error}", file=sys.stderr)
206
+ return 2
207
+ print(json.dumps(report, indent=2, ensure_ascii=False))
208
+ return 0 if report["passed"] else 1
209
+
210
+
211
+ if __name__ == "__main__":
212
+ raise SystemExit(main())
@@ -202,13 +202,83 @@ def validate_content_ui(project: Path, plan_path: Path, rendered_dir: Path | Non
202
202
  output = plan_path.parent / "validation.json"; output.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"); print(json.dumps(result, indent=2, ensure_ascii=False)); return 0 if not errors else 1
203
203
 
204
204
 
205
- def app_surface_init(project: Path, route: str, auth_mode: str, force: bool, confirm: bool) -> int:
205
+ APP_CAPABILITY_STATES = {
206
+ "camera": ["permission", "ready", "gallery", "scan", "unavailable"],
207
+ "location": ["permission", "granted", "denied", "unavailable"],
208
+ "notifications": ["permission", "granted", "denied"],
209
+ "media-library": ["permission", "granted", "denied", "empty"],
210
+ }
211
+ APP_PATTERNS = ("tabbed-modal", "editor-sheet-assistant", "usage-hero", "site-switcher")
212
+
213
+
214
+ def parse_app_capabilities(value: str | None) -> list[str]:
215
+ """Parse ``--capabilities`` (``none`` or a comma list); ``None`` keeps the camera default."""
216
+ if value is None:
217
+ return ["camera"]
218
+ names = [item.strip() for item in value.split(",") if item.strip()]
219
+ if names == ["none"] or not names:
220
+ return []
221
+ unknown = sorted(set(names) - set(APP_CAPABILITY_STATES))
222
+ if unknown or "none" in names:
223
+ raise ValueError(f"unsupported app capability: {', '.join(unknown) or 'none mixed with others'} (use none or {', '.join(APP_CAPABILITY_STATES)})")
224
+ return sorted(set(names), key=names.index)
225
+
226
+
227
+ def parse_app_panes(values: list[str] | None) -> list[dict]:
228
+ """Parse ``--pane NAME:MIN_WIDTH:SCROLL_OWNERS`` declarations of intentional multi-pane layouts."""
229
+ panes = []
230
+ for value in values or []:
231
+ match = re.fullmatch(r"([a-z0-9][a-z0-9-]*):(\d{3,4}):([2-4])", value.strip())
232
+ if not match:
233
+ raise ValueError(f"--pane must look like list-detail:1024:2 (name, min width px, 2-4 scroll owners): {value}")
234
+ panes.append({"name": match.group(1), "minWidth": int(match.group(2)), "scrollOwners": int(match.group(3))})
235
+ return panes
236
+
237
+
238
+ def parse_app_viewports(values: list[str] | None) -> dict[str, list[int]]:
239
+ viewports = {name: list(size) for name, size in REVIEW_VIEWPORTS.items()}
240
+ for value in values or []:
241
+ match = re.fullmatch(r"(desktop|tablet|mobile)=(\d{3,4})x(\d{3,4})", value.strip())
242
+ if not match:
243
+ raise ValueError(f"--viewport must look like tablet=768x1024: {value}")
244
+ viewports[match.group(1)] = [int(match.group(2)), int(match.group(3))]
245
+ return viewports
246
+
247
+
248
+ def expected_scroll_owners(plan: dict, width: int) -> int:
249
+ """Return the scroll owners a viewport may have: one, unless a declared pane layout applies."""
250
+ owners = plan.get("shell", {}).get("scrollOwners", {}) if isinstance(plan.get("shell"), dict) else {}
251
+ expected = int(owners.get("default", 1)) if isinstance(owners, dict) else 1
252
+ for pane in (owners.get("intentionalPanes", []) if isinstance(owners, dict) else []):
253
+ if isinstance(pane, dict) and isinstance(pane.get("minWidth"), int) and width >= pane["minWidth"]:
254
+ expected = max(expected, int(pane.get("scrollOwners", 1)))
255
+ return expected
256
+
257
+
258
+ def png_size(path: Path) -> tuple[int, int] | None:
259
+ """Return a PNG's IHDR width and height, or ``None`` when the file is not a PNG."""
260
+ try:
261
+ head = path.read_bytes()[:24]
262
+ except OSError:
263
+ return None
264
+ if len(head) < 24 or head[:8] != b"\x89PNG\r\n\x1a\n" or head[12:16] != b"IHDR":
265
+ return None
266
+ return int.from_bytes(head[16:20], "big"), int.from_bytes(head[20:24], "big")
267
+
268
+
269
+ def app_surface_init(project: Path, route: str, auth_mode: str, force: bool, confirm: bool, capabilities: list[str] | None = None, panes: list[dict] | None = None, viewports: dict[str, list[int]] | None = None, patterns: list[str] | None = None) -> int:
206
270
  """Create a host-owned mobile app surface contract and QA manifest."""
207
271
  project = project.resolve()
208
272
  if not route.startswith("/") or "?" in route or "#" in route:
209
273
  raise ValueError("route must be a path beginning with / and must not contain a query or hash")
210
274
  if auth_mode not in {"email-password", "existing-session", "none"}:
211
275
  raise ValueError("auth mode must be email-password, existing-session, or none")
276
+ capabilities = ["camera"] if capabilities is None else capabilities
277
+ panes = panes or []
278
+ patterns = patterns or []
279
+ unknown_patterns = sorted(set(patterns) - set(APP_PATTERNS))
280
+ if unknown_patterns:
281
+ raise ValueError(f"unsupported app pattern: {', '.join(unknown_patterns)} (use {', '.join(APP_PATTERNS)})")
212
282
  contract = require_design_contract(project)
213
283
  relative = route.strip("/")
214
284
  candidates = [
@@ -218,6 +288,11 @@ def app_surface_init(project: Path, route: str, auth_mode: str, force: bool, con
218
288
  project / "src" / "pages" / f"{relative}.html",
219
289
  project / "src" / "app" / relative,
220
290
  project / "src" / "app" / relative / "page.tsx",
291
+ # Expo Router file routes (app/ or src/app/, with optional (group) folders resolved by the host).
292
+ project / "app" / f"{relative}.tsx",
293
+ project / "app" / relative / "index.tsx",
294
+ project / "src" / "app" / f"{relative}.tsx",
295
+ project / "src" / "app" / relative / "index.tsx",
221
296
  ]
222
297
  source = next((path for path in candidates if path.is_file()), None)
223
298
  digest = hashlib.sha256((str(project) + "\n" + route + "\n" + auth_mode).encode()).hexdigest()[:12]
@@ -226,7 +301,15 @@ def app_surface_init(project: Path, route: str, auth_mode: str, force: bool, con
226
301
  print((output_dir / "plan.json").read_text(encoding="utf-8"), end="")
227
302
  return 0
228
303
  output_dir.mkdir(parents=True, exist_ok=True)
229
- viewports = {"desktop": REVIEW_VIEWPORTS["desktop"], "tablet": REVIEW_VIEWPORTS["tablet"], "mobile": REVIEW_VIEWPORTS["mobile"]}
304
+ viewports = viewports or {name: list(size) for name, size in REVIEW_VIEWPORTS.items()}
305
+ camera = "camera" in capabilities
306
+ capability_contract = {name: {"requiredStates": list(APP_CAPABILITY_STATES[name])} for name in capabilities}
307
+ required_steps = ["inspect-host-shell", "define-auth-gate"]
308
+ if capabilities:
309
+ required_steps.append("define-capability-states")
310
+ if camera:
311
+ required_steps.append("define-camera-states")
312
+ required_steps += ["capture-desktop-tablet-mobile", "signed-in-browser-qa", "accessibility-check", "route-validation", "explicit-approval"]
230
313
  plan = {
231
314
  "schemaVersion": "maggie-mobile-app-surface.v1",
232
315
  "id": f"app-{digest}",
@@ -237,11 +320,17 @@ def app_surface_init(project: Path, route: str, auth_mode: str, force: bool, con
237
320
  "routeSource": str(source) if source else None,
238
321
  "designContract": contract,
239
322
  "auth": {"mode": auth_mode, "required": auth_mode != "none", "credentialPolicy": "use-host-auth-without-recording-credentials"},
240
- "shell": {"fixedHeader": True, "fixedFooter": True, "oneScrollOwner": True, "cameraMayUseFullScreenViewport": True, "hostShellSourceOfTruth": "existing homepage shell"},
241
- "camera": {"requiredStates": ["permission", "ready", "gallery", "scan", "unavailable"], "controls": ["close", "gallery", "shutter", "mode"], "fallback": "show an actionable unavailable state; never leave a black screen"},
242
- "decisionCriticalUi": ["card normalization", "graded-price fallback", "grade-9-to-10 economics", "scan-to-inventory or valuation transition"],
323
+ "shell": {
324
+ "fixedHeader": True, "fixedFooter": True, "oneScrollOwner": not panes,
325
+ "scrollOwners": {"default": 1, "intentionalPanes": panes, "rule": "per-viewport evidence must match; more than one owner only where a declared pane layout applies"},
326
+ "cameraMayUseFullScreenViewport": camera, "hostShellSourceOfTruth": "existing homepage shell",
327
+ },
328
+ "capabilities": capability_contract,
329
+ "camera": {"requiredStates": list(APP_CAPABILITY_STATES["camera"]), "controls": ["close", "gallery", "shutter", "mode"], "fallback": "show an actionable unavailable state; never leave a black screen"} if camera else {"requiredStates": [], "notApplicable": "route declares no camera capability"},
330
+ "patterns": patterns,
331
+ "decisionCriticalUi": [],
243
332
  "viewports": viewports,
244
- "requiredSteps": ["inspect-host-shell", "define-auth-gate", "define-camera-states", "capture-desktop-tablet-mobile", "signed-in-browser-qa", "accessibility-check", "route-validation", "explicit-approval"],
333
+ "requiredSteps": required_steps,
245
334
  "provenance": {"hostOwnsAuthAndCamera": True, "designContractReused": True, "privateDataCopied": False},
246
335
  "publicWrite": False,
247
336
  "createdAt": datetime.now(timezone.utc).isoformat(),
@@ -250,36 +339,57 @@ def app_surface_init(project: Path, route: str, auth_mode: str, force: bool, con
250
339
  "schemaVersion": "maggie-mobile-app-evidence.v1",
251
340
  "route": route,
252
341
  "auth": plan["auth"],
253
- "shell": plan["shell"],
254
- "camera": plan["camera"],
255
- "viewports": {name: {"screenshot": f"{name}.png", "captured": False} for name in viewports},
342
+ "shell": {key: plan["shell"][key] for key in ("fixedHeader", "fixedFooter")},
343
+ "capabilities": {name: {"states": []} for name in capabilities},
344
+ "viewports": {name: {"width": size[0], "height": size[1], "deviceScaleFactor": 1, "scrollOwners": None, "screenshot": f"{name}.png", "captured": False} for name, size in viewports.items()},
256
345
  "status": "pending",
346
+ "collectWith": "maggie design app-collect",
257
347
  }
258
- qa = {"schemaVersion": "maggie-mobile-app-qa.v1", "route": route, "auth": plan["auth"], "scenarios": [
259
- {"id": "shell-scroll", "viewport": "mobile", "assertions": ["fixed header and footer remain usable", "exactly one scroll owner"]},
260
- {"id": "camera-permission", "viewport": "mobile", "assertions": ["permission state is explained", "denied permission shows fallback"]},
261
- {"id": "camera-scan", "viewport": "mobile", "assertions": ["gallery/shutter/mode/close controls are reachable", "scan result can enter inventory or valuation"]},
262
- {"id": "valuation-auth", "viewport": "desktop", "assertions": ["signed-in route does not redirect unexpectedly", "grade economics and fallback price remain legible"]},
263
- ]}
348
+ scenarios = [{"id": "shell-scroll", "viewport": "mobile", "assertions": ["fixed header and footer remain usable", "exactly one scroll owner"]}]
349
+ for pane in panes:
350
+ scenarios.append({"id": f"panes-{pane['name']}", "viewport": "desktop", "assertions": [f"from {pane['minWidth']} px the {pane['name']} layout has {pane['scrollOwners']} independent scroll owners", f"below {pane['minWidth']} px the layout collapses to one scroll owner"]})
351
+ if camera:
352
+ scenarios += [
353
+ {"id": "camera-permission", "viewport": "mobile", "assertions": ["permission state is explained", "denied permission shows fallback"]},
354
+ {"id": "camera-capture", "viewport": "mobile", "assertions": ["gallery/shutter/mode/close controls are reachable", "a capture result reaches the next step of the flow"]},
355
+ ]
356
+ for name in capabilities:
357
+ if name != "camera":
358
+ scenarios.append({"id": f"{name}-permission", "viewport": "mobile", "assertions": [f"{name} permission is explained before the system prompt", "denied permission leaves an actionable fallback"]})
359
+ for pattern in patterns:
360
+ scenarios.append({"id": f"pattern-{pattern}", "viewport": "all", "assertions": APP_PATTERN_ACCEPTANCE[pattern]})
361
+ if auth_mode != "none":
362
+ scenarios.append({"id": "signed-in-route", "viewport": "desktop", "assertions": ["signed-in route does not redirect unexpectedly", "render one app instance at a time when refresh tokens rotate"]})
363
+ qa = {"schemaVersion": "maggie-mobile-app-qa.v1", "route": route, "auth": plan["auth"], "scenarios": scenarios}
264
364
  (output_dir / "plan.json").write_text(json.dumps(plan, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
265
365
  (output_dir / "qa-scenarios.json").write_text(json.dumps(qa, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
266
366
  (output_dir / "validation.json").write_text(json.dumps(evidence, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
267
367
  (output_dir / "screenshots").mkdir(exist_ok=True)
268
- print(json.dumps({"plan": str(output_dir / "plan.json"), "qaScenarios": str(output_dir / "qa-scenarios.json"), "surface": "app", "phase": "ready", "publicWrite": False}, indent=2, ensure_ascii=False))
368
+ print(json.dumps({"plan": str(output_dir / "plan.json"), "qaScenarios": str(output_dir / "qa-scenarios.json"), "surface": "app", "capabilities": capabilities, "panes": panes, "patterns": patterns, "phase": "ready", "publicWrite": False}, indent=2, ensure_ascii=False))
269
369
  return 0
270
370
 
271
371
 
372
+ APP_PATTERN_ACCEPTANCE = {
373
+ "tabbed-modal": ["full screen below 768 px, centred card from 768 px", "the active pill tab scrolls into view", "switching tabs resets the tab body scroll", "close and Escape return focus to the opener"],
374
+ "editor-sheet-assistant": ["'Ask the assistant' turns the same sheet into a short contextual chat", "writes appear as an approval card; nothing changes until approved", "dismissing the chat restores the unsaved edit"],
375
+ "usage-hero": ["plan name, used and remaining quota are visible without scrolling", "the over-limit state names the next action"],
376
+ "site-switcher": ["favicon falls back to the site initial", "search appears from 5 sites", "the current site is marked and keyboard reachable"],
377
+ }
378
+
379
+
272
380
  def validate_app_surface(project: Path, plan_path: Path, rendered_dir: Path | None, runtime_evidence: Path | None, confirm: bool) -> int:
273
- """Validate signed-in app evidence and responsive screenshots."""
381
+ """Validate signed-in app evidence and responsive screenshots against the plan."""
274
382
  project = project.resolve(); plan_path = plan_path.resolve()
275
383
  if not plan_path.exists(): raise ValueError(f"app surface plan not found: {plan_path}")
276
- plan = json.loads(plan_path.read_text(encoding="utf-8")); errors = []
384
+ plan = json.loads(plan_path.read_text(encoding="utf-8")); errors = []; warnings = []
277
385
  if plan.get("workflow") != "maggie-design" or plan.get("mode") != "app-view-init": errors.append("invalid mobile app surface plan")
278
386
  if plan.get("publicWrite") is not False: errors.append("publicWrite must remain false before approval")
279
- if not plan.get("shell", {}).get("oneScrollOwner"): errors.append("app surface must define one scroll owner")
387
+ shell_plan = plan.get("shell", {})
388
+ if not shell_plan.get("oneScrollOwner") and not isinstance(shell_plan.get("scrollOwners"), dict): errors.append("app surface must define its scroll owners")
280
389
  if not plan.get("auth", {}).get("credentialPolicy"): errors.append("app surface must define a credential policy")
281
390
  try: require_design_contract(project)
282
391
  except ValueError as error: errors.append(str(error))
392
+ plan_viewports = {name: list(plan.get("viewports", {}).get(name) or REVIEW_VIEWPORTS[name]) for name in ("desktop", "tablet", "mobile")}
283
393
  evidence = {}
284
394
  evidence_path = runtime_evidence.resolve() if runtime_evidence else plan_path.parent / "runtime-evidence.json"
285
395
  if evidence_path.is_file():
@@ -287,23 +397,60 @@ def validate_app_surface(project: Path, plan_path: Path, rendered_dir: Path | No
287
397
  except json.JSONDecodeError: errors.append("runtime evidence is not valid JSON")
288
398
  else:
289
399
  errors.append("runtime evidence file is required")
400
+ evidence_viewports = evidence.get("viewports", {}) if isinstance(evidence.get("viewports"), dict) else {}
401
+ viewport_checks = {}
290
402
  if evidence:
291
403
  if evidence.get("schemaVersion") != "maggie-mobile-app-evidence.v1": errors.append("runtime evidence schemaVersion is invalid")
292
404
  if evidence.get("route") != plan.get("route"): errors.append("runtime evidence route does not match plan")
293
405
  auth = evidence.get("auth", {})
294
406
  if plan.get("auth", {}).get("required") and auth.get("signedIn") is not True: errors.append("signed-in evidence is required for this app route")
295
407
  shell = evidence.get("shell", {})
296
- if shell.get("fixedHeader") is not True or shell.get("fixedFooter") is not True or shell.get("scrollOwnerCount") != 1: errors.append("runtime evidence must prove fixed shell and one scroll owner")
297
- camera = evidence.get("camera", {})
298
- camera_states = set(camera.get("states", [])) if isinstance(camera, dict) else set()
299
- if not set(plan.get("camera", {}).get("requiredStates", [])).issubset(camera_states): errors.append("runtime evidence is missing a required camera state")
408
+ if "scrollOwnerCount" in shell:
409
+ warnings.append("shell.scrollOwnerCount is ignored; per-viewport scrollOwners are authoritative")
410
+ for name, (width, height) in plan_viewports.items():
411
+ entry = evidence_viewports.get(name)
412
+ check = {"planViewport": [width, height], "expectedScrollOwners": expected_scroll_owners(plan, width)}
413
+ viewport_checks[name] = check
414
+ if not isinstance(entry, dict):
415
+ errors.append(f"runtime evidence has no {name} viewport entry; collect it with `maggie design app-collect`"); continue
416
+ if [entry.get("width"), entry.get("height")] != [width, height]:
417
+ errors.append(f"{name} evidence viewport {entry.get('width')}x{entry.get('height')} does not match plan viewport {width}x{height}")
418
+ owners = entry.get("scrollOwners")
419
+ check["scrollOwners"] = owners
420
+ if not isinstance(owners, int):
421
+ errors.append(f"{name} evidence must record scrollOwners")
422
+ elif owners != check["expectedScrollOwners"]:
423
+ errors.append(f"{name} has {owners} scroll owners; the plan allows {check['expectedScrollOwners']} at {width} px")
424
+ for key in ("fixedHeader", "fixedFooter"):
425
+ value = entry.get(key, shell.get(key))
426
+ if value is not True:
427
+ errors.append(f"runtime evidence must prove fixed shell ({key}) at {name}")
428
+ capability_states = evidence.get("capabilities", {}) if isinstance(evidence.get("capabilities"), dict) else {}
429
+ required = plan.get("capabilities")
430
+ if not isinstance(required, dict): # plans created before capability flags
431
+ required = {"camera": {"requiredStates": plan.get("camera", {}).get("requiredStates", [])}}
432
+ for name, spec in required.items():
433
+ states = capability_states.get(name, {}).get("states", []) if isinstance(capability_states.get(name), dict) else []
434
+ if name == "camera" and not states and isinstance(evidence.get("camera"), dict):
435
+ states = evidence["camera"].get("states", [])
436
+ missing = sorted(set(spec.get("requiredStates", [])) - set(states))
437
+ if missing:
438
+ errors.append(f"runtime evidence is missing a required {name} state: {', '.join(missing)}" if name != "camera" else f"runtime evidence is missing a required camera state: {', '.join(missing)}")
300
439
  screenshot_status = {}
301
- for name in ("desktop", "tablet", "mobile"):
440
+ for name, (width, height) in plan_viewports.items():
302
441
  path = (rendered_dir / f"{name}.png") if rendered_dir else plan_path.parent / "screenshots" / f"{name}.png"
303
- valid = path.is_file() and path.read_bytes()[:8] == b"\x89PNG\r\n\x1a\n"
304
- screenshot_status[name] = {"path": str(path), "validPng": valid}
305
- if not valid: errors.append(f"missing valid {name} screenshot")
306
- result = {"schemaVersion": "maggie-mobile-app-validation.v1", "plan": str(plan_path), "route": plan.get("route"), "screenshots": screenshot_status, "runtimeEvidence": str(evidence_path), "checks": {"fixedShell": not any("fixed shell" in error for error in errors), "signedInRoute": not any("signed-in" in error for error in errors), "cameraStates": not any("camera state" in error for error in errors), "publicWriteBlocked": plan.get("publicWrite") is False}, "status": "passed" if not errors else "failed", "errors": errors, "validatedAt": datetime.now(timezone.utc).isoformat()}
442
+ size = png_size(path) if path.is_file() else None
443
+ entry = evidence_viewports.get(name) if isinstance(evidence_viewports.get(name), dict) else {}
444
+ scale = entry.get("deviceScaleFactor", 1) or 1
445
+ full_page = entry.get("fullPage") is True
446
+ screenshot_status[name] = {"path": str(path), "validPng": size is not None, "size": list(size) if size else None, "deviceScaleFactor": scale}
447
+ if size is None:
448
+ errors.append(f"missing valid {name} screenshot"); continue
449
+ expected_w, expected_h = round(width * scale), round(height * scale)
450
+ if size[0] != expected_w or (size[1] < expected_h if full_page else size[1] != expected_h):
451
+ errors.append(f"{name} screenshot is {size[0]}x{size[1]} but the plan viewport is {width}x{height} at scale {scale} (expected {expected_w}x{expected_h}{' or taller' if full_page else ''}); capture at the real viewport, not a scaled frame")
452
+ result = {"schemaVersion": "maggie-mobile-app-validation.v1", "plan": str(plan_path), "route": plan.get("route"), "screenshots": screenshot_status, "viewports": viewport_checks, "runtimeEvidence": str(evidence_path), "checks": {"fixedShell": not any("fixed shell" in error for error in errors), "scrollOwners": not any("scroll owner" in error for error in errors), "viewportsMatchPlan": not any("does not match plan viewport" in error or "screenshot is" in error for error in errors), "signedInRoute": not any("signed-in" in error for error in errors), "capabilityStates": not any(" state" in error and "required" in error for error in errors), "publicWriteBlocked": plan.get("publicWrite") is False}, "status": "passed" if not errors else "failed", "errors": errors, "warnings": warnings, "validatedAt": datetime.now(timezone.utc).isoformat()}
453
+ result["checks"]["cameraStates"] = not any("camera state" in error for error in errors)
307
454
  if confirm:
308
455
  plan_path.parent.joinpath("validation.json").write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
309
456
  print(json.dumps(result, indent=2, ensure_ascii=False))
@@ -859,6 +1006,7 @@ commands:
859
1006
  palette generate or validate a semantic palette role contract
860
1007
  verify run automatable design-job steps and record evidence
861
1008
  icon-glyph-check render icons and prove font load and glyph paint
1009
+ app-collect render an app route per viewport and write runtime evidence
862
1010
  reference-ui, init, validate-ui, app-init, app-validate, rebrand, review
863
1011
  """)
864
1012
  return 0
@@ -868,6 +1016,9 @@ commands:
868
1016
  if len(sys.argv) > 1 and sys.argv[1] == "verify":
869
1017
  from maggie_design_verify import main as verify_main
870
1018
  return verify_main(sys.argv[2:])
1019
+ if len(sys.argv) > 1 and sys.argv[1] == "app-collect":
1020
+ from maggie_app_collect import main as app_collect_main
1021
+ return app_collect_main(sys.argv[2:])
871
1022
  if len(sys.argv) > 1 and sys.argv[1] == "icon-glyph-check":
872
1023
  from maggie_icon_glyph_check import main as glyph_main
873
1024
  return glyph_main(sys.argv[2:])
@@ -924,11 +1075,21 @@ commands:
924
1075
  command.add_argument("--project", type=Path, default=Path.cwd())
925
1076
  command.add_argument("--route", required=True)
926
1077
  command.add_argument("--auth-mode", choices=["email-password", "existing-session", "none"], default="email-password")
1078
+ command.add_argument("--capabilities", help="none, or a comma list of camera,location,notifications,media-library (default camera)")
1079
+ command.add_argument("--camera", choices=["required", "none"], help="shorthand: --camera none drops camera states")
1080
+ command.add_argument("--pane", action="append", default=[], help="intentional multi-pane layout NAME:MIN_WIDTH:SCROLL_OWNERS, e.g. list-detail:1024:2")
1081
+ command.add_argument("--viewport", action="append", default=[], help="override a plan viewport, e.g. tablet=768x1024")
1082
+ command.add_argument("--pattern", action="append", default=[], choices=list(APP_PATTERNS), help="attach app UI pattern acceptance rules")
927
1083
  command.add_argument("--force", action="store_true")
928
1084
  command.add_argument("--confirm", action="store_true")
929
1085
  args = command.parse_args(sys.argv[2:])
930
1086
  try:
931
- return app_surface_init(args.project, args.route, args.auth_mode, args.force, args.confirm)
1087
+ capabilities = parse_app_capabilities(args.capabilities)
1088
+ if args.camera == "none":
1089
+ capabilities = [name for name in capabilities if name != "camera"]
1090
+ elif args.camera == "required" and "camera" not in capabilities:
1091
+ capabilities = ["camera"] + capabilities
1092
+ return app_surface_init(args.project, args.route, args.auth_mode, args.force, args.confirm, capabilities, parse_app_panes(args.pane), parse_app_viewports(args.viewport), args.pattern)
932
1093
  except (OSError, ValueError, json.JSONDecodeError) as error:
933
1094
  print(f"BLOCKED: maggie-design app-init: {error}", file=sys.stderr); return 1
934
1095
  if len(sys.argv) > 1 and sys.argv[1] == "app-validate":