@topy-ai/maggie 0.7.23 → 0.7.25

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.
@@ -2,7 +2,7 @@
2
2
  name: maggie-service-booking
3
3
  description: Import, synchronise, validate, and design SPA service pages from a booking provider such as Fresha. Use for service catalogues, treatment variants, prices, durations, booking links, payment links, and booking-aware page generation.
4
4
  metadata:
5
- version: 1.2.0
5
+ version: 1.3.0
6
6
  ---
7
7
 
8
8
  # Maggie Service Booking
@@ -136,6 +136,13 @@ python3 tools/clis/maggie_service_booking.py retirement-audit \
136
136
  --project . \
137
137
  --catalogue .maggie/booking/services.json \
138
138
  --evidence .maggie/booking/retirement-evidence.json
139
+
140
+ # Validate independent, pollable onboarding jobs before provider research or
141
+ # public service generation. The manifest contains no credentials or payloads.
142
+ maggie service orchestration-validate \
143
+ --project . \
144
+ --manifest .maggie/booking/onboarding-orchestration.json \
145
+ --output docs/service-onboarding-orchestration.json
139
146
  ```
140
147
 
141
148
  `import` creates the first catalogue. `sync` compares the newly imported
@@ -159,6 +166,33 @@ evidence artifact and validates the selected ending (`pending`, `redirect`,
159
166
  `tombstone`, or `gone`), HTTP status, booking suppression, unavailable/noindex
160
167
  signals, and sitemap exclusion. It never stores response bodies or decides a
161
168
  redirect destination for the host.
169
+
170
+ ## Asynchronous onboarding contract
171
+
172
+ Provider onboarding is three independent state machines: `import` owns the
173
+ provider catalogue, `search` owns local discovery results, and `research` owns
174
+ website research. Each job must have a unique `jobId`, unique
175
+ `idempotencyKey`, bounded `attempt`/`maxAttempts`, a current state and history,
176
+ an owner, a pollable `statusRef`, and an explicit `nextAction`. State history
177
+ is validated so retries cannot silently become successes. `fieldOwnership`
178
+ ensures that one workflow cannot overwrite another workflow's fields; the host
179
+ adapter remains responsible for actual persistence and queue execution.
180
+
181
+ Validate the redacted orchestration snapshot before provider research and
182
+ again before public generation:
183
+
184
+ ```bash
185
+ maggie service orchestration-validate \
186
+ --project . \
187
+ --manifest .maggie/booking/onboarding-orchestration.json \
188
+ --output docs/service-onboarding-orchestration.json
189
+ ```
190
+
191
+ The contract is documented in
192
+ [`onboarding-orchestration-v1.schema.json`](../../bundled-contracts/maggie-service-booking/onboarding-orchestration-v1.schema.json).
193
+ It is provider-neutral and does not accept credentials, provider response
194
+ bodies, or arbitrary filesystem paths in the poll reference.
195
+
162
196
  `convert-page` imports a normal page as a draft service without inventing
163
197
  booking facts. `match-pages` reads filesystem pages plus `docs/pages.json` and
164
198
  `docs/page-content.json`, writes candidate evidence to both `.maggie/booking`
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env python3
2
+ """Validate provider-neutral worker/browser runtime parity evidence before canary traffic."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import re
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+
14
+ SCHEMA = "maggie-deployment-runtime-parity.v1"
15
+ ENVIRONMENTS = {"development", "staging", "production"}
16
+ SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$")
17
+
18
+
19
+ def load(path: Path) -> dict[str, Any]:
20
+ try:
21
+ value = json.loads(path.read_text(encoding="utf-8"))
22
+ except (OSError, json.JSONDecodeError) as error:
23
+ raise ValueError(f"cannot read parity evidence: {error}") from error
24
+ if not isinstance(value, dict):
25
+ raise ValueError("parity evidence root must be an object")
26
+ return value
27
+
28
+
29
+ def non_empty_strings(value: object, field: str, errors: list[str]) -> list[str]:
30
+ if not isinstance(value, list) or any(not isinstance(item, str) or not item.strip() for item in value):
31
+ errors.append(f"{field} must be an array of non-empty strings")
32
+ return []
33
+ return value
34
+
35
+
36
+ def validate(payload: dict[str, Any]) -> list[str]:
37
+ errors: list[str] = []
38
+ if payload.get("schemaVersion") != SCHEMA:
39
+ errors.append(f"schemaVersion must be {SCHEMA}")
40
+ if payload.get("environment") not in ENVIRONMENTS:
41
+ errors.append("environment must be development, staging, or production")
42
+ if payload.get("passed") is not True:
43
+ errors.append("parity evidence is not marked passed")
44
+
45
+ worker = payload.get("worker")
46
+ if not isinstance(worker, dict):
47
+ errors.append("worker evidence must be an object")
48
+ worker = {}
49
+ config = worker.get("config")
50
+ if not isinstance(config, dict):
51
+ errors.append("worker.config evidence must be an object")
52
+ config = {}
53
+ required = non_empty_strings(config.get("required"), "worker.config.required", errors)
54
+ missing = non_empty_strings(config.get("missing"), "worker.config.missing", errors)
55
+ if not required:
56
+ errors.append("worker.config.required must declare at least one requirement")
57
+ if missing:
58
+ errors.append("worker.config has missing requirements")
59
+ if config.get("passed") is not True:
60
+ errors.append("worker.config did not pass")
61
+
62
+ migration = worker.get("migration")
63
+ privilege = migration.get("privilegeCheck") if isinstance(migration, dict) else None
64
+ if not isinstance(privilege, dict) or privilege.get("passed") is not True:
65
+ errors.append("worker.migration.privilegeCheck did not pass")
66
+
67
+ routes = worker.get("routeApis")
68
+ if not isinstance(routes, list) or not routes:
69
+ errors.append("worker.routeApis must contain at least one route check")
70
+ routes = []
71
+ for index, route in enumerate(routes):
72
+ prefix = f"worker.routeApis[{index}]"
73
+ if not isinstance(route, dict):
74
+ errors.append(f"{prefix} must be an object")
75
+ continue
76
+ method = route.get("method")
77
+ path = route.get("path")
78
+ status = route.get("status")
79
+ if not isinstance(method, str) or not re.fullmatch(r"[A-Z]{3,10}", method):
80
+ errors.append(f"{prefix}.method must be an uppercase HTTP method")
81
+ if not isinstance(path, str) or not path.startswith("/") or "?" in path or "#" in path:
82
+ errors.append(f"{prefix}.path must be a local route path without query or fragment")
83
+ if not isinstance(status, int) or isinstance(status, bool) or not 200 <= status < 400:
84
+ errors.append(f"{prefix}.status must be a successful HTTP status")
85
+ if route.get("passed") is not True:
86
+ errors.append(f"{prefix} did not pass")
87
+
88
+ processes = worker.get("residentProcesses")
89
+ if not isinstance(processes, dict):
90
+ errors.append("worker.residentProcesses evidence must be an object")
91
+ processes = {}
92
+ for field in ("unexpected", "stale"):
93
+ values = non_empty_strings(processes.get(field), f"worker.residentProcesses.{field}", errors)
94
+ if values:
95
+ errors.append(f"worker.residentProcesses contains {field} processes")
96
+ if processes.get("checked") is not True or processes.get("passed") is not True:
97
+ errors.append("worker.residentProcesses did not pass")
98
+
99
+ jobs = worker.get("sourceJobs")
100
+ if not isinstance(jobs, list):
101
+ errors.append("worker.sourceJobs must be an array")
102
+ jobs = []
103
+ for index, job in enumerate(jobs):
104
+ prefix = f"worker.sourceJobs[{index}]"
105
+ if not isinstance(job, dict):
106
+ errors.append(f"{prefix} must be an object")
107
+ continue
108
+ job_id = job.get("jobId")
109
+ if not isinstance(job_id, str) or not SAFE_ID.fullmatch(job_id):
110
+ errors.append(f"{prefix}.jobId must be a non-empty safe identifier")
111
+ if job.get("passed") is not True:
112
+ errors.append(f"{prefix} did not pass")
113
+
114
+ browser = payload.get("browser")
115
+ if not isinstance(browser, dict):
116
+ errors.append("browser evidence must be an object")
117
+ browser = {}
118
+ for field in ("browserVersion", "driverVersion"):
119
+ if not isinstance(browser.get(field), str) or not browser[field].strip():
120
+ errors.append(f"browser.{field} is required")
121
+ if browser.get("compatible") is not True:
122
+ errors.append("browser and driver versions are not compatible")
123
+ return errors
124
+
125
+
126
+ def parity(evidence: Path, output: Path) -> int:
127
+ try:
128
+ payload = load(evidence)
129
+ errors = validate(payload)
130
+ except ValueError as error:
131
+ errors = [str(error)]
132
+ report = {
133
+ "schemaVersion": SCHEMA,
134
+ "environment": payload.get("environment") if "payload" in locals() else None,
135
+ "status": "passed" if not errors else "failed",
136
+ "passed": not errors,
137
+ "checks": {
138
+ "workerConfig": "validated",
139
+ "migrationPrivileges": "validated",
140
+ "routeApis": "validated",
141
+ "residentProcesses": "validated",
142
+ "sourceJobIds": "validated",
143
+ "browserDriverCompatibility": "validated",
144
+ },
145
+ "errors": errors,
146
+ }
147
+ output.parent.mkdir(parents=True, exist_ok=True)
148
+ output.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
149
+ print(json.dumps({"status": report["status"], "report": str(output.resolve()), "errors": errors}, indent=2, ensure_ascii=False))
150
+ return 0 if not errors else 1
151
+
152
+
153
+ def main() -> int:
154
+ parser = argparse.ArgumentParser(description=__doc__)
155
+ parser.add_argument("--project", type=Path, default=Path.cwd())
156
+ parser.add_argument("--evidence", type=Path, required=True, help="sanitized host-adapter parity evidence JSON")
157
+ parser.add_argument("--output", type=Path, default=Path(".maggie/deployment-runtime-parity.json"))
158
+ args = parser.parse_args()
159
+ project = args.project.resolve()
160
+ evidence = args.evidence if args.evidence.is_absolute() else project / args.evidence
161
+ output = args.output if args.output.is_absolute() else project / args.output
162
+ try:
163
+ return parity(evidence, output)
164
+ except OSError as error:
165
+ print(f"BLOCKED: maggie deployment parity: {error}", file=sys.stderr)
166
+ return 2
167
+
168
+
169
+ if __name__ == "__main__":
170
+ raise SystemExit(main())
@@ -82,12 +82,23 @@ def validate_preflight(payload: dict[str, Any]) -> tuple[bool, str]:
82
82
  return True, "deployment preflight gates passed"
83
83
 
84
84
 
85
+ def validate_runtime_parity(payload: dict[str, Any]) -> tuple[bool, str]:
86
+ if payload.get("schemaVersion") != "maggie-deployment-runtime-parity.v1":
87
+ return False, "runtime parity evidence schemaVersion is unsupported"
88
+ if payload.get("status") != "passed" or payload.get("passed") is not True:
89
+ return False, "worker/browser runtime parity did not pass"
90
+ if payload.get("errors") not in ([], None):
91
+ return False, "runtime parity evidence contains errors"
92
+ return True, "worker/browser runtime parity passed"
93
+
94
+
85
95
  VALIDATORS: dict[str, Callable[[dict[str, Any]], tuple[bool, str]]] = {
86
96
  "unitRegression": validate_unit,
87
97
  "packageSmoke": validate_package,
88
98
  "browserEvidence": validate_browser,
89
99
  "renderedCanary": validate_canary,
90
100
  "deploymentPreflight": validate_preflight,
101
+ "runtimeParity": validate_runtime_parity,
91
102
  }
92
103
 
93
104
 
@@ -138,6 +149,7 @@ def main() -> int:
138
149
  parser.add_argument("--browser-report", type=Path, default=Path(".maggie/verification/browser-evidence.json"))
139
150
  parser.add_argument("--rendered-canary", type=Path, default=Path(".maggie/deployment-canary.json"))
140
151
  parser.add_argument("--deployment-preflight", type=Path, default=Path(".maggie/release-preflight.json"))
152
+ parser.add_argument("--runtime-parity", type=Path, help="validated worker/browser parity report from maggie deployment parity")
141
153
  parser.add_argument("--output", type=Path)
142
154
  args = parser.parse_args()
143
155
  project = args.project.resolve()
@@ -147,6 +159,8 @@ def main() -> int:
147
159
  name: (getattr(args, option).resolve() if getattr(args, option).is_absolute() else project / getattr(args, option))
148
160
  for name, option in CHECKS
149
161
  }
162
+ if args.runtime_parity:
163
+ paths["runtimeParity"] = args.runtime_parity.resolve() if args.runtime_parity.is_absolute() else project / args.runtime_parity
150
164
  try:
151
165
  return readiness(project, paths, args.output)
152
166
  except (OSError, ValueError) as error:
@@ -157,6 +157,114 @@ def validate_content_ui(project: Path, plan_path: Path, rendered_dir: Path | Non
157
157
  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
158
158
 
159
159
 
160
+ def app_surface_init(project: Path, route: str, auth_mode: str, force: bool, confirm: bool) -> int:
161
+ """Create a host-owned mobile app surface contract and QA manifest."""
162
+ project = project.resolve()
163
+ if not route.startswith("/") or "?" in route or "#" in route:
164
+ raise ValueError("route must be a path beginning with / and must not contain a query or hash")
165
+ if auth_mode not in {"email-password", "existing-session", "none"}:
166
+ raise ValueError("auth mode must be email-password, existing-session, or none")
167
+ contract = require_design_contract(project)
168
+ relative = route.strip("/")
169
+ candidates = [
170
+ project / relative,
171
+ project / "src" / "pages" / relative,
172
+ project / "src" / "pages" / f"{relative}.astro",
173
+ project / "src" / "pages" / f"{relative}.html",
174
+ project / "src" / "app" / relative,
175
+ project / "src" / "app" / relative / "page.tsx",
176
+ ]
177
+ source = next((path for path in candidates if path.is_file()), None)
178
+ digest = hashlib.sha256((str(project) + "\n" + route + "\n" + auth_mode).encode()).hexdigest()[:12]
179
+ output_dir = project / ".maggie" / "design" / "app-view" / f"app-{digest}"
180
+ if output_dir.exists() and not force:
181
+ print((output_dir / "plan.json").read_text(encoding="utf-8"), end="")
182
+ return 0
183
+ output_dir.mkdir(parents=True, exist_ok=True)
184
+ viewports = {"desktop": REVIEW_VIEWPORTS["desktop"], "tablet": REVIEW_VIEWPORTS["tablet"], "mobile": REVIEW_VIEWPORTS["mobile"]}
185
+ plan = {
186
+ "schemaVersion": "maggie-mobile-app-surface.v1",
187
+ "id": f"app-{digest}",
188
+ "workflow": "maggie-design",
189
+ "mode": "app-view-init",
190
+ "phase": "ready",
191
+ "route": route,
192
+ "routeSource": str(source) if source else None,
193
+ "designContract": contract,
194
+ "auth": {"mode": auth_mode, "required": auth_mode != "none", "credentialPolicy": "use-host-auth-without-recording-credentials"},
195
+ "shell": {"fixedHeader": True, "fixedFooter": True, "oneScrollOwner": True, "cameraMayUseFullScreenViewport": True, "hostShellSourceOfTruth": "existing homepage shell"},
196
+ "camera": {"requiredStates": ["permission", "ready", "gallery", "scan", "unavailable"], "controls": ["close", "gallery", "shutter", "mode"], "fallback": "show an actionable unavailable state; never leave a black screen"},
197
+ "decisionCriticalUi": ["card normalization", "graded-price fallback", "grade-9-to-10 economics", "scan-to-inventory or valuation transition"],
198
+ "viewports": viewports,
199
+ "requiredSteps": ["inspect-host-shell", "define-auth-gate", "define-camera-states", "capture-desktop-tablet-mobile", "signed-in-browser-qa", "accessibility-check", "route-validation", "explicit-approval"],
200
+ "provenance": {"hostOwnsAuthAndCamera": True, "designContractReused": True, "privateDataCopied": False},
201
+ "publicWrite": False,
202
+ "createdAt": datetime.now(timezone.utc).isoformat(),
203
+ }
204
+ evidence = {
205
+ "schemaVersion": "maggie-mobile-app-evidence.v1",
206
+ "route": route,
207
+ "auth": plan["auth"],
208
+ "shell": plan["shell"],
209
+ "camera": plan["camera"],
210
+ "viewports": {name: {"screenshot": f"{name}.png", "captured": False} for name in viewports},
211
+ "status": "pending",
212
+ }
213
+ qa = {"schemaVersion": "maggie-mobile-app-qa.v1", "route": route, "auth": plan["auth"], "scenarios": [
214
+ {"id": "shell-scroll", "viewport": "mobile", "assertions": ["fixed header and footer remain usable", "exactly one scroll owner"]},
215
+ {"id": "camera-permission", "viewport": "mobile", "assertions": ["permission state is explained", "denied permission shows fallback"]},
216
+ {"id": "camera-scan", "viewport": "mobile", "assertions": ["gallery/shutter/mode/close controls are reachable", "scan result can enter inventory or valuation"]},
217
+ {"id": "valuation-auth", "viewport": "desktop", "assertions": ["signed-in route does not redirect unexpectedly", "grade economics and fallback price remain legible"]},
218
+ ]}
219
+ (output_dir / "plan.json").write_text(json.dumps(plan, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
220
+ (output_dir / "qa-scenarios.json").write_text(json.dumps(qa, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
221
+ (output_dir / "validation.json").write_text(json.dumps(evidence, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
222
+ (output_dir / "screenshots").mkdir(exist_ok=True)
223
+ 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))
224
+ return 0
225
+
226
+
227
+ def validate_app_surface(project: Path, plan_path: Path, rendered_dir: Path | None, runtime_evidence: Path | None, confirm: bool) -> int:
228
+ """Validate signed-in app evidence and responsive screenshots."""
229
+ project = project.resolve(); plan_path = plan_path.resolve()
230
+ if not plan_path.exists(): raise ValueError(f"app surface plan not found: {plan_path}")
231
+ plan = json.loads(plan_path.read_text(encoding="utf-8")); errors = []
232
+ if plan.get("workflow") != "maggie-design" or plan.get("mode") != "app-view-init": errors.append("invalid mobile app surface plan")
233
+ if plan.get("publicWrite") is not False: errors.append("publicWrite must remain false before approval")
234
+ if not plan.get("shell", {}).get("oneScrollOwner"): errors.append("app surface must define one scroll owner")
235
+ if not plan.get("auth", {}).get("credentialPolicy"): errors.append("app surface must define a credential policy")
236
+ try: require_design_contract(project)
237
+ except ValueError as error: errors.append(str(error))
238
+ evidence = {}
239
+ evidence_path = runtime_evidence.resolve() if runtime_evidence else plan_path.parent / "runtime-evidence.json"
240
+ if evidence_path.is_file():
241
+ try: evidence = json.loads(evidence_path.read_text(encoding="utf-8"))
242
+ except json.JSONDecodeError: errors.append("runtime evidence is not valid JSON")
243
+ else:
244
+ errors.append("runtime evidence file is required")
245
+ if evidence:
246
+ if evidence.get("schemaVersion") != "maggie-mobile-app-evidence.v1": errors.append("runtime evidence schemaVersion is invalid")
247
+ if evidence.get("route") != plan.get("route"): errors.append("runtime evidence route does not match plan")
248
+ auth = evidence.get("auth", {})
249
+ if plan.get("auth", {}).get("required") and auth.get("signedIn") is not True: errors.append("signed-in evidence is required for this app route")
250
+ shell = evidence.get("shell", {})
251
+ 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")
252
+ camera = evidence.get("camera", {})
253
+ camera_states = set(camera.get("states", [])) if isinstance(camera, dict) else set()
254
+ if not set(plan.get("camera", {}).get("requiredStates", [])).issubset(camera_states): errors.append("runtime evidence is missing a required camera state")
255
+ screenshot_status = {}
256
+ for name in ("desktop", "tablet", "mobile"):
257
+ path = (rendered_dir / f"{name}.png") if rendered_dir else plan_path.parent / "screenshots" / f"{name}.png"
258
+ valid = path.is_file() and path.read_bytes()[:8] == b"\x89PNG\r\n\x1a\n"
259
+ screenshot_status[name] = {"path": str(path), "validPng": valid}
260
+ if not valid: errors.append(f"missing valid {name} screenshot")
261
+ 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()}
262
+ if confirm:
263
+ plan_path.parent.joinpath("validation.json").write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
264
+ print(json.dumps(result, indent=2, ensure_ascii=False))
265
+ return 0 if not errors else 1
266
+
267
+
160
268
  def rebrand_template(args: argparse.Namespace) -> int:
161
269
  """Apply an explicit brand identity to a packaged homepage template."""
162
270
  template = args.template.resolve()
@@ -547,6 +655,7 @@ def in_place_job(project: Path, routes: list[str], force: bool = False, content_
547
655
  project / relative,
548
656
  project / "src" / "pages" / relative,
549
657
  project / "src" / "app" / relative,
658
+ project / "src" / "pages" / f"{relative}.astro",
550
659
  project / "src" / "pages" / f"{relative}.html",
551
660
  project / "src" / "app" / relative / "page.tsx",
552
661
  ]
@@ -680,7 +789,7 @@ commands:
680
789
  status show a design job and its per-step progress
681
790
  resume restart a failed design workflow
682
791
  author plan an original first-party page
683
- reference-ui, init, validate-ui, rebrand, review
792
+ reference-ui, init, validate-ui, app-init, app-validate, rebrand, review
684
793
  """)
685
794
  return 0
686
795
  if len(sys.argv) > 1 and sys.argv[1] == "reference-ui":
@@ -719,6 +828,30 @@ commands:
719
828
  return validate_content_ui(args.project, args.plan, args.rendered_dir, args.confirm)
720
829
  except (OSError, ValueError, json.JSONDecodeError) as error:
721
830
  print(f"BLOCKED: maggie-design validate-ui: {error}", file=sys.stderr); return 1
831
+ if len(sys.argv) > 1 and sys.argv[1] == "app-init":
832
+ command = argparse.ArgumentParser(description="Create a mobile app surface contract with signed-in QA scenarios.")
833
+ command.add_argument("--project", type=Path, default=Path.cwd())
834
+ command.add_argument("--route", required=True)
835
+ command.add_argument("--auth-mode", choices=["email-password", "existing-session", "none"], default="email-password")
836
+ command.add_argument("--force", action="store_true")
837
+ command.add_argument("--confirm", action="store_true")
838
+ args = command.parse_args(sys.argv[2:])
839
+ try:
840
+ return app_surface_init(args.project, args.route, args.auth_mode, args.force, args.confirm)
841
+ except (OSError, ValueError, json.JSONDecodeError) as error:
842
+ print(f"BLOCKED: maggie-design app-init: {error}", file=sys.stderr); return 1
843
+ if len(sys.argv) > 1 and sys.argv[1] == "app-validate":
844
+ command = argparse.ArgumentParser(description="Validate mobile app runtime evidence and responsive screenshots.")
845
+ command.add_argument("--project", type=Path, default=Path.cwd())
846
+ command.add_argument("--plan", type=Path, required=True)
847
+ command.add_argument("--rendered-dir", type=Path)
848
+ command.add_argument("--runtime-evidence", type=Path)
849
+ command.add_argument("--confirm", action="store_true")
850
+ args = command.parse_args(sys.argv[2:])
851
+ try:
852
+ return validate_app_surface(args.project, args.plan, args.rendered_dir, args.runtime_evidence, args.confirm)
853
+ except (OSError, ValueError, json.JSONDecodeError) as error:
854
+ print(f"BLOCKED: maggie-design app-validate: {error}", file=sys.stderr); return 1
722
855
  if len(sys.argv) > 1 and sys.argv[1] == "rebrand":
723
856
  rebrand = argparse.ArgumentParser(description="Apply an explicit brand identity to a packaged marketplace template.")
724
857
  rebrand.add_argument("--template", type=Path, required=True)
@@ -27,6 +27,7 @@ LOCAL_PATH_RE = re.compile(
27
27
  r"\\\\[^\\/\s,;:)\]}]+(?:[\\/]+[^\\/\s,;:)\]}]+)+)"
28
28
  )
29
29
  VERSION_RE = re.compile(r"\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?")
30
+ SAFE_BATCH_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$")
30
31
  ACKNOWLEDGEMENT_KEYS = ("status", "feedbackId", "requestId")
31
32
  REPO_ROOT = Path(__file__).resolve().parents[2]
32
33
 
@@ -45,10 +46,25 @@ def project_fingerprint(project: Path) -> str:
45
46
 
46
47
 
47
48
  def installed_version(project: Path) -> str:
48
- """Resolve the exact runtime version and fail closed on source drift."""
49
+ """Resolve the exact runtime version, preferring the running package."""
49
50
  if os.environ.get("MAGGIE_VERSION"):
50
51
  value = safe_text(os.environ["MAGGIE_VERSION"])
51
52
  return value if VERSION_RE.fullmatch(value) else "unknown"
53
+
54
+ # A consumer project can retain an old .maggie/install.json after npm
55
+ # upgrade. The package actually being executed is stronger evidence.
56
+ package_versions = []
57
+ for parent in (project.resolve(), *project.resolve().parents):
58
+ package_path = parent / "node_modules" / "@topy-ai" / "maggie" / "package.json"
59
+ try:
60
+ value = json.loads(package_path.read_text(encoding="utf-8")).get("version", "")
61
+ if VERSION_RE.fullmatch(str(value)):
62
+ package_versions.append(str(value))
63
+ except (OSError, ValueError, json.JSONDecodeError, AttributeError):
64
+ continue
65
+ if package_versions:
66
+ return package_versions[0] if len(set(package_versions)) == 1 else "unknown"
67
+
52
68
  install_path = project / ".maggie" / "install.json"
53
69
  try:
54
70
  value = json.loads(install_path.read_text(encoding="utf-8")).get("version", "")
@@ -102,6 +118,20 @@ def collect(args: argparse.Namespace) -> int:
102
118
  validation = safe_text(args.validation or report.get("validation"))
103
119
  if fixed and (not resolution or not validation):
104
120
  raise ValueError("fixed feedback requires non-empty --resolution and --validation")
121
+ batch_id = safe_text(getattr(args, "batch_id", ""))
122
+ batch_index = getattr(args, "batch_index", None)
123
+ batch_size = getattr(args, "batch_size", None)
124
+ if batch_id:
125
+ if not SAFE_BATCH_ID_RE.fullmatch(batch_id):
126
+ raise ValueError("batch ID is invalid")
127
+ if not isinstance(batch_index, int) or isinstance(batch_index, bool) or batch_index < 0:
128
+ raise ValueError("batch index is required when batch ID is provided")
129
+ if not isinstance(batch_size, int) or isinstance(batch_size, bool) or batch_size < 1 or batch_index >= batch_size:
130
+ raise ValueError("batch size must be positive and greater than batch index")
131
+ priority = safe_text(getattr(args, "priority", "") or "normal").lower()
132
+ if priority not in {"low", "normal", "high", "critical"}:
133
+ raise ValueError("priority must be low, normal, high, or critical")
134
+ affected_cli = safe_text(getattr(args, "affected_cli", ""))
105
135
  context = {"projectFingerprint": project_fingerprint(project)}
106
136
  if args.allow_project_context and args.context_note:
107
137
  context["note"] = safe_text(args.context_note)
@@ -124,12 +154,16 @@ def collect(args: argparse.Namespace) -> int:
124
154
  "fixed": fixed,
125
155
  "resolution": resolution,
126
156
  "validation": validation,
157
+ "priority": priority,
158
+ "affectedCli": affected_cli,
127
159
  "attachments": [attachment(value) for value in args.screenshot],
128
160
  "environment": {"os": platform.system().lower(), "python": platform.python_version()},
129
161
  "privacy": {"secretsRedacted": True, "projectContextAllowed": bool(args.allow_project_context)},
130
162
  "projectContext": context if args.allow_project_context else None,
131
163
  "submission": {"status": "draft"},
132
164
  }
165
+ if batch_id:
166
+ data["batch"] = {"batchId": batch_id, "index": batch_index, "size": batch_size}
133
167
  directory = project / ".maggie" / "feedback"
134
168
  directory.mkdir(parents=True, exist_ok=True)
135
169
  path = directory / f"{data['feedbackId']}.json"
@@ -144,7 +178,11 @@ def collect(args: argparse.Namespace) -> int:
144
178
 
145
179
 
146
180
  def to_markdown(data: dict) -> str:
147
- lines = [f"# Maggie feedback: {data.get('summary') or data.get('type')}", "", "<!-- Generated by maggie feedback. Secrets and local paths are omitted. -->", "", "## Run", "", f"- Skill: `{data.get('skill')}`", f"- Maggie version: `{data.get('maggieVersion')}`", f"- Run ID: `{data.get('runId') or 'not provided'}`", f"- Phase: `{data.get('phase') or 'not provided'}`", f"- Status: `{data.get('status')}`", f"- Error fingerprint: `{data.get('errorFingerprint') or 'not provided'}`", "", "## Report", "", f"**Summary:** {data.get('summary') or 'Not provided'}", "", f"**Expected:** {data.get('expected') or 'Not provided'}", "", f"**Actual:** {data.get('actual') or 'Not provided'}", ""]
181
+ batch = data.get("batch") or {}
182
+ lines = [f"# Maggie feedback: {data.get('summary') or data.get('type')}", "", "<!-- Generated by maggie feedback. Secrets and local paths are omitted. -->", "", "## Run", "", f"- Skill: `{data.get('skill')}`", f"- Maggie version: `{data.get('maggieVersion')}`", f"- Run ID: `{data.get('runId') or 'not provided'}`", f"- Phase: `{data.get('phase') or 'not provided'}`", f"- Status: `{data.get('status')}`", f"- Priority: `{data.get('priority') or 'normal'}`", f"- Affected CLI: `{data.get('affectedCli') or 'not provided'}`", f"- Error fingerprint: `{data.get('errorFingerprint') or 'not provided'}`"]
183
+ if batch:
184
+ lines.append(f"- Batch: `{batch.get('batchId')}` ({batch.get('index', 0) + 1}/{batch.get('size')})")
185
+ lines += ["", "## Report", "", f"**Summary:** {data.get('summary') or 'Not provided'}", "", f"**Expected:** {data.get('expected') or 'Not provided'}", "", f"**Actual:** {data.get('actual') or 'Not provided'}", ""]
148
186
  if data.get("stepsToReproduce"):
149
187
  lines += ["**Steps to reproduce:**", ""] + [f"{index}. {step}" for index, step in enumerate(data["stepsToReproduce"], 1)] + [""]
150
188
  lines += [f"**Fixed:** {'yes' if data.get('fixed') else 'no'}", f"**Resolution:** {data.get('resolution') or 'Not provided'}", f"**Validation:** {data.get('validation') or 'Not provided'}", "", "## Privacy", "", f"- Project context allowed: `{bool(data.get('privacy', {}).get('projectContextAllowed'))}`", f"- Attachments: `{len(data.get('attachments', []))}` metadata-only reference(s)", ""]
@@ -211,6 +249,57 @@ def list_feedback(args: argparse.Namespace) -> int:
211
249
  return 0
212
250
 
213
251
 
252
+ def collect_batch(args: argparse.Namespace) -> int:
253
+ """Create one privacy-safe draft per item while retaining shared batch context."""
254
+ manifest = load_json(Path(args.batch_file).expanduser().resolve())
255
+ batch_id = safe_text(manifest.get("batchId"))
256
+ if not SAFE_BATCH_ID_RE.fullmatch(batch_id):
257
+ raise ValueError("batch file requires a safe batchId")
258
+ items = manifest.get("items")
259
+ if not isinstance(items, list) or not items or len(items) > 50:
260
+ raise ValueError("batch file requires between 1 and 50 items")
261
+ shared = {
262
+ "skill": safe_text(manifest.get("skill")),
263
+ "run_id": safe_text(manifest.get("runId")),
264
+ "phase": safe_text(manifest.get("phase")),
265
+ "priority": safe_text(manifest.get("priority") or "normal"),
266
+ "affected_cli": safe_text(manifest.get("affectedCli")),
267
+ }
268
+ paths = []
269
+ for index, item in enumerate(items):
270
+ if not isinstance(item, dict):
271
+ raise ValueError(f"items[{index}] must be an object")
272
+ values = {
273
+ "project": args.project,
274
+ "run_report": None,
275
+ "skill": safe_text(item.get("skill") or shared["skill"]),
276
+ "run_id": safe_text(item.get("runId") or shared["run_id"]),
277
+ "type": item.get("type", "bug"),
278
+ "phase": safe_text(item.get("phase") or shared["phase"]),
279
+ "summary": item.get("summary", ""),
280
+ "expected": item.get("expected", ""),
281
+ "actual": item.get("actual", ""),
282
+ "error_fingerprint": item.get("errorFingerprint", ""),
283
+ "reproduce": item.get("stepsToReproduce", []),
284
+ "fixed": item.get("fixed") is True,
285
+ "resolution": item.get("resolution", ""),
286
+ "validation": item.get("validation", ""),
287
+ "screenshot": item.get("screenshots", []),
288
+ "allow_project_context": False,
289
+ "context_note": "",
290
+ "batch_id": batch_id,
291
+ "batch_index": index,
292
+ "batch_size": len(items),
293
+ "priority": item.get("priority") or shared["priority"],
294
+ "affected_cli": item.get("affectedCli") or shared["affected_cli"],
295
+ }
296
+ if not isinstance(values["reproduce"], list): values["reproduce"] = []
297
+ if not isinstance(values["screenshot"], list): values["screenshot"] = []
298
+ paths.append(collect(argparse.Namespace(**values)))
299
+ print(json.dumps({"batchId": batch_id, "count": len(paths), "status": "drafts-created"}, ensure_ascii=False))
300
+ return 0
301
+
302
+
214
303
  def main() -> int:
215
304
  parser = argparse.ArgumentParser(description=__doc__)
216
305
  parser.add_argument("--project", default=".")
@@ -235,6 +324,14 @@ def main() -> int:
235
324
  collect_parser.add_argument("--screenshot", action="append", default=[])
236
325
  collect_parser.add_argument("--allow-project-context", action="store_true")
237
326
  collect_parser.add_argument("--context-note", default="")
327
+ collect_parser.add_argument("--batch-id", default="")
328
+ collect_parser.add_argument("--batch-index", type=int)
329
+ collect_parser.add_argument("--batch-size", type=int)
330
+ collect_parser.add_argument("--priority", choices=("low", "normal", "high", "critical"), default="normal")
331
+ collect_parser.add_argument("--affected-cli", default="")
332
+ batch_parser = sub.add_parser("batch")
333
+ batch_parser.add_argument("--project", default=argparse.SUPPRESS)
334
+ batch_parser.add_argument("--batch-file", required=True)
238
335
  preview_parser = sub.add_parser("preview")
239
336
  preview_parser.add_argument("feedback")
240
337
  preview_parser.add_argument("--format", choices=("json", "markdown"), default="json")
@@ -246,6 +343,7 @@ def main() -> int:
246
343
  args = parser.parse_args()
247
344
  try:
248
345
  if args.command == "collect": return collect(args)
346
+ if args.command == "batch": return collect_batch(args)
249
347
  if args.command == "preview": return preview(args)
250
348
  if args.command == "submit": return submit(args)
251
349
  return list_feedback(args)
@@ -23,7 +23,7 @@ DEFAULT_SCENARIOS = Path(".maggie") / "scenario-manifest.json"
23
23
  VALID_STATUSES = {"pending", "pass", "fail", "blocked", "inconclusive"}
24
24
  VALID_PHASES = {"test", "fix", "retest"}
25
25
  ASSERTION_SCHEMA = "maggie.qa-assertions.v1"
26
- RUNTIME_ASSERTION_TYPES = {"http-status", "final-url", "text-present", "text-absent", "meta", "link-absent", "redirect", "scroll-owner-count"}
26
+ RUNTIME_ASSERTION_TYPES = {"http-status", "final-url", "text-present", "text-absent", "meta", "link-absent", "redirect", "scroll-owner-count", "icon-rendered"}
27
27
  SOURCE_ASSERTION_TYPES = {"class-present", "class-absent", "markup-present", "markup-absent", "source-text"}
28
28
  SECRET_RE = re.compile(
29
29
  r"(?i)(bearer\s+|(?:api[_-]?key|token|secret|password|authorization|cookie)\s*[=:]\s*)[^\s,;]+"
@@ -384,6 +384,19 @@ def assertion_audit(args: argparse.Namespace) -> int:
384
384
  item_errors.append(f"checks[{check_index}].expected must be 1 scroll owner")
385
385
  if isinstance(check.get("actual"), int) and not isinstance(check.get("actual"), bool) and check.get("actual") != 1:
386
386
  item_errors.append(f"checks[{check_index}] found more or fewer than one scroll owner")
387
+ if check_type == "icon-rendered":
388
+ actual = check.get("actual")
389
+ if not isinstance(actual, dict):
390
+ item_errors.append(f"checks[{check_index}].actual must be an icon runtime measurement object")
391
+ else:
392
+ if actual.get("visible") is not True:
393
+ item_errors.append(f"checks[{check_index}] did not find a visible icon glyph")
394
+ for dimension in ("width", "height"):
395
+ value = actual.get(dimension)
396
+ if not isinstance(value, (int, float)) or isinstance(value, bool) or value <= 0:
397
+ item_errors.append(f"checks[{check_index}].actual.{dimension} must be a positive rendered dimension")
398
+ if actual.get("accessibleLabel") is not True:
399
+ item_errors.append(f"checks[{check_index}] icon control is missing an accessible label")
387
400
  if any(isinstance(check, dict) and check.get("passed") is False for check in checks):
388
401
  item_errors.append("one or more runtime checks failed")
389
402
  audited.append({"id": identifier, "passed": not item_errors, "errors": item_errors})