@topy-ai/maggie 0.7.48 → 0.7.49

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 (49) hide show
  1. package/README-zh-TW.md +2 -2
  2. package/README.md +31 -4
  3. package/bin/maggie.js +17 -4
  4. package/bundled-contracts/google-integrations/capability-report-v2.schema.json +2 -1
  5. package/bundled-contracts/maggie-content/email-localization-v1.schema.json +89 -0
  6. package/bundled-contracts/maggie-content/lifecycle-email-v1.schema.json +76 -0
  7. package/bundled-contracts/maggie-deployment/readiness-v1.schema.json +6 -1
  8. package/bundled-contracts/maggie-ops/marketing-run-v1.schema.json +89 -0
  9. package/bundled-contracts/maggie-ops/notification-catalogue-example-v1.json +941 -0
  10. package/bundled-contracts/maggie-ops/notification-catalogue-v1.schema.json +273 -0
  11. package/bundled-contracts/maggie-service-booking/fact-provenance-v1.schema.json +128 -0
  12. package/bundled-contracts/maggie-service-booking/import-stages-v1.schema.json +191 -0
  13. package/bundled-references/google-integrations-runbook.md +54 -0
  14. package/bundled-skills/maggie-content-localization/SKILL.md +17 -1
  15. package/bundled-skills/maggie-content-localization/references/email-and-lifecycle.md +83 -0
  16. package/bundled-skills/maggie-deployment/SKILL.md +89 -7
  17. package/bundled-skills/maggie-deployment/references/vps.md +38 -6
  18. package/bundled-skills/maggie-design/SKILL.md +55 -0
  19. package/bundled-skills/maggie-feedback/SKILL.md +29 -0
  20. package/bundled-skills/maggie-ops/SKILL.md +37 -0
  21. package/bundled-skills/maggie-ops/references/notifications-operations.md +154 -0
  22. package/bundled-skills/maggie-service-booking/SKILL.md +59 -0
  23. package/bundled-skills/maggie-social-share/SKILL.md +19 -2
  24. package/bundled-skills/maggie-social-share/references/marketing-run.md +57 -0
  25. package/bundled-skills/maggie-social-share/references/queue-and-connections.md +51 -0
  26. package/bundled-tools/clis/maggie_deployment.py +58 -17
  27. package/bundled-tools/clis/maggie_deployment_auth_canary.py +216 -0
  28. package/bundled-tools/clis/maggie_deployment_collect.py +225 -0
  29. package/bundled-tools/clis/maggie_deployment_readiness.py +53 -3
  30. package/bundled-tools/clis/maggie_design.py +8 -0
  31. package/bundled-tools/clis/maggie_design_verify.py +359 -0
  32. package/bundled-tools/clis/maggie_docs.py +82 -4
  33. package/bundled-tools/clis/maggie_email_localization.py +382 -0
  34. package/bundled-tools/clis/maggie_feedback.py +9 -2
  35. package/bundled-tools/clis/maggie_icon_glyph_check.py +382 -0
  36. package/bundled-tools/clis/maggie_icon_release_gate.py +28 -1
  37. package/bundled-tools/clis/maggie_localization.py +5 -0
  38. package/bundled-tools/clis/maggie_marketing_run.py +272 -0
  39. package/bundled-tools/clis/maggie_notifications.py +528 -0
  40. package/bundled-tools/clis/maggie_ops.py +87 -0
  41. package/bundled-tools/clis/maggie_schedule.py +12 -2
  42. package/bundled-tools/clis/maggie_service_import.py +520 -0
  43. package/bundled-tools/clis/maggie_social_queue.py +104 -0
  44. package/bundled-tools/clis/maggie_social_share.py +6 -2
  45. package/bundled-tools/runtime/google_ads.py +306 -0
  46. package/bundled-tools/runtime/google_capabilities.py +18 -2
  47. package/bundled-tools/runtime/google_remediation.py +152 -0
  48. package/package.json +1 -1
  49. package/references/google-integrations-runbook.md +54 -0
@@ -0,0 +1,359 @@
1
+ #!/usr/bin/env python3
2
+ """Orchestrate evidence for a Maggie design job and record completed steps.
3
+
4
+ ``maggie design verify JOB_ID`` runs every *automatable* step of an in-place,
5
+ section-scoped, or author design job against a host adapter — a running
6
+ server (``--base-url``) or a static build (``--dist``) — writes durable
7
+ evidence under ``.maggie/design-evidence/<job>/``, and records a step as done
8
+ only when its check passed. Steps that need an implementer or a person
9
+ (``apply-design-contract``, ``implement-*``, ``visual-review``, ``approval``)
10
+ are never completed here; record them with ``maggie design step`` and real
11
+ evidence. A job therefore cannot reach ``done`` without evidence.
12
+
13
+ Section jobs are two-stage: run ``--stage before`` before editing (captures the
14
+ section snapshot) and the default ``--stage after`` once implemented.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import hashlib
21
+ import json
22
+ import re
23
+ import subprocess
24
+ import sys
25
+ from datetime import datetime, timezone
26
+ from html.parser import HTMLParser
27
+ from pathlib import Path
28
+ from urllib.parse import urlsplit
29
+
30
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
31
+
32
+ from maggie_design import REVIEW_VIEWPORTS, find_design_job_path, validate_section_evidence # noqa: E402
33
+ from maggie_icon_glyph_check import _ProbeServer, find_chrome # noqa: E402
34
+
35
+ SCHEMA = "maggie-design-verify.v1"
36
+ MANUAL_STEPS = {"apply-design-contract", "implement-section", "implement-page", "visual-review", "approval"}
37
+ INSPECT_STEPS = {"inspect-route", "inspect-shell", "inspect-section"}
38
+ PLACEHOLDERS = re.compile(r"\{\{[^}]*\}\}|\[object Object\]|\bundefined\b|lorem ipsum", re.I)
39
+ VOID_TAGS = {"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"}
40
+
41
+
42
+ def now() -> str:
43
+ return datetime.now(timezone.utc).isoformat()
44
+
45
+
46
+ class PageFacts(HTMLParser):
47
+ """Collect bounded structural facts and per-section hashes from HTML."""
48
+
49
+ def __init__(self) -> None:
50
+ super().__init__(convert_charrefs=True)
51
+ self.title = ""; self._in_title = False; self.lang = ""
52
+ self.h1 = 0; self.landmarks: set[str] = set(); self.ids: set[str] = set()
53
+ self.images_without_alt = 0; self.unnamed_controls = 0; self.unlabelled_inputs = 0
54
+ self.robots_noindex = False; self.text_parts: list[str] = []
55
+ self._labels_for: set[str] = set(); self._input_ids: list[tuple[str, bool]] = []
56
+ self._control_stack: list[dict] = []; self._label_depth = 0
57
+ self.sections: dict[str, list[str]] = {}; self._open_sections: list[tuple[str, int]] = []; self._depth = 0
58
+
59
+ def _section_id(self, tag: str, attrs: dict[str, str]) -> str | None:
60
+ explicit = attrs.get("data-section-id") or attrs.get("data-maggie-section")
61
+ if explicit:
62
+ return explicit
63
+ return attrs.get("id") if tag == "section" and attrs.get("id") else None
64
+
65
+ def _record(self, token: str) -> None:
66
+ for section_id, _depth in self._open_sections:
67
+ self.sections[section_id].append(token)
68
+
69
+ def handle_starttag(self, tag: str, attr_list: list[tuple[str, str | None]]) -> None:
70
+ attrs = {name: value or "" for name, value in attr_list}
71
+ void = tag in VOID_TAGS
72
+ if not void:
73
+ self._depth += 1
74
+ section_id = self._section_id(tag, attrs)
75
+ if section_id and not void:
76
+ self.sections.setdefault(section_id, [])
77
+ self._open_sections.append((section_id, self._depth))
78
+ self._record("<" + tag + "".join(f" {k}={v}" for k, v in sorted(attrs.items()) if k not in {"nonce"}) + ">")
79
+ if attrs.get("id"):
80
+ self.ids.add(attrs["id"])
81
+ if tag == "html":
82
+ self.lang = attrs.get("lang", "")
83
+ elif tag == "title":
84
+ self._in_title = True
85
+ elif tag == "h1":
86
+ self.h1 += 1
87
+ elif tag in {"header", "main", "footer", "nav"}:
88
+ self.landmarks.add(tag)
89
+ elif tag == "meta" and attrs.get("name", "").lower() == "robots" and "noindex" in attrs.get("content", "").lower():
90
+ self.robots_noindex = True
91
+ elif tag == "img" and "alt" not in attrs:
92
+ self.images_without_alt += 1
93
+ elif tag == "label":
94
+ self._label_depth += 1
95
+ if attrs.get("for"):
96
+ self._labels_for.add(attrs["for"])
97
+ elif tag in {"input", "select", "textarea"} and attrs.get("type", "").lower() not in {"hidden", "submit", "button", "reset", "image"}:
98
+ named = bool(attrs.get("aria-label") or attrs.get("aria-labelledby") or attrs.get("title") or self._label_depth)
99
+ self._input_ids.append((attrs.get("id", ""), named))
100
+ if tag in {"a", "button"} and not void:
101
+ named = bool(attrs.get("aria-label") or attrs.get("aria-labelledby") or attrs.get("title"))
102
+ self._control_stack.append({"tag": tag, "named": named, "text": ""})
103
+ if tag == "img" and self._control_stack and attrs.get("alt"):
104
+ self._control_stack[-1]["named"] = True
105
+
106
+ def handle_endtag(self, tag: str) -> None:
107
+ self._record("</" + tag + ">")
108
+ if tag == "title":
109
+ self._in_title = False
110
+ elif tag == "label" and self._label_depth:
111
+ self._label_depth -= 1
112
+ if tag in {"a", "button"} and self._control_stack and self._control_stack[-1]["tag"] == tag:
113
+ control = self._control_stack.pop()
114
+ if not control["named"] and not control["text"].strip():
115
+ self.unnamed_controls += 1
116
+ if tag not in VOID_TAGS:
117
+ while self._open_sections and self._open_sections[-1][1] >= self._depth:
118
+ self._open_sections.pop()
119
+ self._depth = max(0, self._depth - 1)
120
+
121
+ def handle_data(self, data: str) -> None:
122
+ if self._in_title:
123
+ self.title += data
124
+ text = " ".join(data.split())
125
+ if text:
126
+ self._record(text)
127
+ self.text_parts.append(text)
128
+ for control in self._control_stack:
129
+ control["text"] += text
130
+
131
+ def finish(self) -> dict:
132
+ unlabelled = sum(1 for input_id, named in self._input_ids if not named and not (input_id and input_id in self._labels_for))
133
+ return {
134
+ "title": self.title.strip()[:200], "lang": self.lang, "h1Count": self.h1, "landmarks": sorted(self.landmarks),
135
+ "imagesWithoutAlt": self.images_without_alt, "unnamedControls": self.unnamed_controls, "unlabelledInputs": unlabelled,
136
+ "robotsNoindex": self.robots_noindex,
137
+ "placeholders": sorted({match.group(0)[:40] for match in PLACEHOLDERS.finditer(" ".join(self.text_parts))}),
138
+ "sections": [{"id": section_id, "hash": hashlib.sha256("\n".join(tokens).encode("utf-8")).hexdigest(), "tokens": len(tokens)} for section_id, tokens in sorted(self.sections.items())],
139
+ }
140
+
141
+
142
+ def page_facts(document: str) -> dict:
143
+ parser = PageFacts()
144
+ parser.feed(document)
145
+ parser.close()
146
+ return parser.finish()
147
+
148
+
149
+ def job_routes(job: dict) -> list[str]:
150
+ if isinstance(job.get("routes"), list) and job["routes"]:
151
+ return [item["route"] if isinstance(item, dict) else str(item) for item in job["routes"]]
152
+ if job.get("route"):
153
+ return [str(job["route"])]
154
+ if job.get("brief") and Path(job["brief"]).is_file():
155
+ return [json.loads(Path(job["brief"]).read_text(encoding="utf-8"))["route"]]
156
+ raise ValueError("design job has no routes to verify")
157
+
158
+
159
+ class Verifier:
160
+ def __init__(self, project: Path, job_id: str, server: _ProbeServer, chrome: str | None, stage: str, build_command: str | None, budget_ms: int) -> None:
161
+ self.project = project; self.job_id = job_id; self.server = server; self.chrome = chrome
162
+ self.stage = stage; self.build_command = build_command; self.budget_ms = budget_ms
163
+ self.job_path = find_design_job_path(project, job_id)
164
+ self.job = json.loads(self.job_path.read_text(encoding="utf-8"))
165
+ self.routes = job_routes(self.job)
166
+ self.evidence_dir = project / ".maggie" / "design-evidence" / job_id
167
+ self.evidence_dir.mkdir(parents=True, exist_ok=True)
168
+ self._pages: dict[str, tuple[int, str, str]] = {}
169
+
170
+ def page(self, route: str) -> tuple[int, str, str]:
171
+ if route not in self._pages:
172
+ status, content_type, body = self.server.fetch(route)
173
+ self._pages[route] = (status, content_type, body.decode("utf-8", errors="replace"))
174
+ return self._pages[route]
175
+
176
+ def rendered(self, route: str) -> str:
177
+ if not self.chrome:
178
+ return self.page(route)[2]
179
+ proc = subprocess.run([self.chrome, "--headless=new", "--no-sandbox", "--disable-gpu", f"--virtual-time-budget={self.budget_ms}", "--dump-dom", self.server.origin + route], capture_output=True, text=True, timeout=180, check=False)
180
+ return proc.stdout if proc.returncode == 0 and proc.stdout.strip() else self.page(route)[2]
181
+
182
+ def write(self, name: str, payload: dict) -> Path:
183
+ path = self.evidence_dir / f"{name}.json"
184
+ path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
185
+ return path
186
+
187
+ # -- step checks: return (passed, evidence payload) ----------------------
188
+
189
+ def inspect(self, step: str) -> tuple[bool, dict]:
190
+ results, errors = [], []
191
+ for route in self.routes:
192
+ status, content_type, document = self.page(route)
193
+ facts = page_facts(document) if "html" in content_type else {}
194
+ results.append({"route": route, "status": status, "contentType": content_type.split(";")[0], "title": facts.get("title"), "landmarks": facts.get("landmarks"), "sectionIds": [item["id"] for item in facts.get("sections", [])], "sha256": hashlib.sha256(document.encode("utf-8")).hexdigest()})
195
+ if not 200 <= status <= 299 or "html" not in content_type:
196
+ errors.append(f"{route}: HTTP {status} {content_type.split(';')[0]}")
197
+ if step == "inspect-shell" and not {"header", "footer"} <= set(facts.get("landmarks", [])):
198
+ errors.append(f"{route}: canonical shell needs header and footer landmarks")
199
+ if step == "inspect-section" and self.job.get("sectionId") not in {item["id"] for item in facts.get("sections", [])}:
200
+ errors.append(f"{route}: section {self.job.get('sectionId')!r} not found (use <section id> or data-section-id)")
201
+ return not errors, {"routes": results, "errors": errors}
202
+
203
+ def declare_content_source(self) -> tuple[bool, dict]:
204
+ routes = [item for item in self.job.get("routes", []) if isinstance(item, dict)]
205
+ unproven = [item["route"] for item in routes if item.get("resolution") == "unproven"]
206
+ return not unproven and bool(routes), {"routes": [{key: item.get(key) for key in ("route", "resolution", "contentSource")} for item in routes], "errors": [f"{route}: catch-all content source not declared; recreate the job with --content-source" for route in unproven]}
207
+
208
+ def snapshot(self, label: str) -> tuple[bool, dict]:
209
+ route = self.routes[0]
210
+ status, _content_type, _document = self.page(route)
211
+ facts = page_facts(self.rendered(route))
212
+ payload = {"schemaVersion": "maggie-section-snapshot.v1", "route": route, "status": status, "label": label, "sections": [{"id": item["id"], "hash": item["hash"]} for item in facts["sections"]]}
213
+ errors = [] if 200 <= status <= 299 and payload["sections"] else [f"{route}: no sections captured (HTTP {status})"]
214
+ payload["errors"] = errors
215
+ return not errors, payload
216
+
217
+ def unchanged_rest(self) -> tuple[bool, dict]:
218
+ before = self.evidence_dir / "capture-before.json"
219
+ after = self.evidence_dir / "capture-after.json"
220
+ if not before.is_file() or not after.is_file():
221
+ return False, {"errors": ["capture-before and capture-after evidence are both required; run --stage before first"]}
222
+ result = validate_section_evidence(before, after, str(self.job.get("sectionId")))
223
+ return bool(result["passed"]), result
224
+
225
+ def screenshots(self) -> tuple[bool, dict]:
226
+ if not self.chrome:
227
+ return False, {"errors": ["google-chrome or chromium is required for screenshots"]}
228
+ shots, errors = [], []
229
+ for route in self.routes:
230
+ slug = re.sub(r"[^a-z0-9]+", "-", route.lower()).strip("-") or "home"
231
+ for viewport, (width, height) in REVIEW_VIEWPORTS.items():
232
+ target = self.evidence_dir / "screenshots" / f"{slug}-{viewport}.png"
233
+ target.parent.mkdir(parents=True, exist_ok=True)
234
+ proc = subprocess.run([self.chrome, "--headless=new", "--no-sandbox", "--disable-gpu", "--hide-scrollbars", f"--window-size={width},{height}", f"--virtual-time-budget={self.budget_ms}", f"--screenshot={target}", self.server.origin + route], capture_output=True, text=True, timeout=180, check=False)
235
+ if proc.returncode != 0 or not target.is_file() or target.stat().st_size == 0:
236
+ errors.append(f"{route} @ {viewport}: screenshot failed")
237
+ continue
238
+ shots.append({"route": route, "viewport": viewport, "size": [width, height], "path": str(target), "sha256": hashlib.sha256(target.read_bytes()).hexdigest()})
239
+ return not errors and bool(shots), {"screenshots": shots, "errors": errors}
240
+
241
+ def accessibility(self) -> tuple[bool, dict]:
242
+ results, errors = [], []
243
+ for route in self.routes:
244
+ facts = page_facts(self.rendered(route))
245
+ item = {key: facts[key] for key in ("lang", "h1Count", "imagesWithoutAlt", "unnamedControls", "unlabelledInputs", "landmarks")}
246
+ item["route"] = route
247
+ results.append(item)
248
+ if not facts["lang"]: errors.append(f"{route}: <html> has no lang")
249
+ if facts["h1Count"] != 1: errors.append(f"{route}: expected exactly one h1, found {facts['h1Count']}")
250
+ if facts["imagesWithoutAlt"]: errors.append(f"{route}: {facts['imagesWithoutAlt']} image(s) without alt")
251
+ if facts["unnamedControls"]: errors.append(f"{route}: {facts['unnamedControls']} link/button(s) without an accessible name")
252
+ if facts["unlabelledInputs"]: errors.append(f"{route}: {facts['unlabelledInputs']} form field(s) without a label")
253
+ if "main" not in facts["landmarks"]: errors.append(f"{route}: no <main> landmark")
254
+ return not errors, {"routes": results, "errors": errors, "scope": "static rendered-DOM checks; not a substitute for assistive-technology testing"}
255
+
256
+ def route_validation(self) -> tuple[bool, dict]:
257
+ results, errors = [], []
258
+ for route in self.routes:
259
+ status, content_type, _document = self.page(route)
260
+ facts = page_facts(self.rendered(route)) if "html" in content_type else {"title": "", "h1Count": 0, "placeholders": [], "robotsNoindex": False}
261
+ results.append({"route": route, "status": status, "title": facts["title"], "h1Count": facts["h1Count"], "placeholders": facts["placeholders"], "robotsNoindex": facts["robotsNoindex"]})
262
+ if not 200 <= status <= 299: errors.append(f"{route}: HTTP {status}")
263
+ if not facts["title"]: errors.append(f"{route}: empty <title>")
264
+ if facts["h1Count"] < 1: errors.append(f"{route}: no h1")
265
+ if facts["placeholders"]: errors.append(f"{route}: unresolved placeholder text {facts['placeholders']}")
266
+ if facts["robotsNoindex"]: errors.append(f"{route}: robots noindex present")
267
+ return not errors, {"routes": results, "errors": errors}
268
+
269
+ def build(self) -> tuple[bool, dict] | None:
270
+ if not self.build_command:
271
+ return None
272
+ proc = subprocess.run(self.build_command, shell=True, cwd=self.project, capture_output=True, text=True, timeout=1800, check=False)
273
+ tail = (proc.stdout + proc.stderr)[-2000:]
274
+ return proc.returncode == 0, {"command": self.build_command, "exitCode": proc.returncode, "outputTail": tail, "errors": [] if proc.returncode == 0 else [f"build exited {proc.returncode}"]}
275
+
276
+ def check(self, step: str) -> tuple[bool, dict] | None:
277
+ if step in INSPECT_STEPS: return self.inspect(step)
278
+ if step == "declare-content-source": return self.declare_content_source()
279
+ if step == "capture-before": return self.snapshot("before") if self.stage == "before" else None
280
+ if step == "capture-after": return self.snapshot("after") if self.stage == "after" else None
281
+ if step == "assert-unchanged-rest": return self.unchanged_rest() if self.stage == "after" else None
282
+ if step == "capture-responsive-screenshots": return self.screenshots()
283
+ if step == "accessibility-check": return self.accessibility()
284
+ if step == "route-validation": return self.route_validation()
285
+ if step == "build-check": return self.build()
286
+ return None
287
+
288
+ def run(self, recheck: bool) -> dict:
289
+ steps = self.job.get("steps") or []
290
+ report_steps = []
291
+ for step in steps:
292
+ name = str(step.get("name"))
293
+ if step.get("status") == "done" and not recheck:
294
+ report_steps.append({"step": name, "status": "done", "evidence": step.get("evidence"), "action": "kept"})
295
+ continue
296
+ if name in MANUAL_STEPS:
297
+ report_steps.append({"step": name, "status": step.get("status", "pending"), "action": "requires implementer or reviewer evidence: maggie design step " + self.job_id + f" --step {name} --evidence FILE"})
298
+ continue
299
+ outcome = self.check(name)
300
+ if outcome is None:
301
+ reason = "not part of this stage" if name in {"capture-before", "capture-after", "assert-unchanged-rest"} else ("pass --build-command to run the host build" if name == "build-check" else "no automated check for this step")
302
+ report_steps.append({"step": name, "status": step.get("status", "pending"), "action": reason})
303
+ continue
304
+ passed, payload = outcome
305
+ payload = {"schemaVersion": "maggie-design-step-evidence.v1", "jobId": self.job_id, "step": name, "passed": passed, "checkedAt": now(), **payload}
306
+ path = self.write(name, payload)
307
+ if passed:
308
+ step.update({"status": "done", "evidence": str(path), "completedAt": now(), "verifiedBy": "maggie design verify"})
309
+ self.job.setdefault("history", []).append({"event": "step-completed", "step": name, "evidence": str(path), "at": now(), "verifiedBy": "maggie design verify"})
310
+ else:
311
+ step["lastCheck"] = {"passed": False, "evidence": str(path), "at": now()}
312
+ report_steps.append({"step": name, "status": step.get("status", "pending"), "action": "verified" if passed else "failed", "evidence": str(path), "errors": payload.get("errors", [])[:10]})
313
+ self.job["phase"] = "done" if steps and all(item.get("status") == "done" for item in steps) else "ready"
314
+ self.job["updated_at"] = now()
315
+ self.job_path.write_text(json.dumps(self.job, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
316
+ failed = [item["step"] for item in report_steps if item.get("action") == "failed"]
317
+ pending = [item["step"] for item in report_steps if item.get("status") != "done"]
318
+ return {"schemaVersion": SCHEMA, "jobId": self.job_id, "mode": self.job.get("mode"), "stage": self.stage, "routes": self.routes, "phase": self.job["phase"], "passed": not failed, "failedSteps": failed, "pendingSteps": pending, "steps": report_steps, "evidenceDir": str(self.evidence_dir), "mutation": "job step records only"}
319
+
320
+
321
+ def verify(project: Path, job_id: str, base_url: str | None, dist: Path | None, stage: str, build_command: str | None, recheck: bool, budget_ms: int, require_browser: bool) -> dict:
322
+ if bool(base_url) == bool(dist):
323
+ raise ValueError("exactly one of --base-url or --dist is required")
324
+ if base_url and urlsplit(base_url).scheme not in {"http", "https"}:
325
+ raise ValueError("--base-url must be http(s)")
326
+ try:
327
+ chrome: str | None = find_chrome()
328
+ except RuntimeError:
329
+ if require_browser:
330
+ raise
331
+ chrome = None
332
+ with _ProbeServer("", base_url=base_url, dist=dist) as server:
333
+ verifier = Verifier(project.resolve(), job_id, server, chrome, stage, build_command, budget_ms)
334
+ return verifier.run(recheck)
335
+
336
+
337
+ def main(argv: list[str] | None = None) -> int:
338
+ parser = argparse.ArgumentParser(prog="maggie design verify", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
339
+ parser.add_argument("job_id")
340
+ parser.add_argument("--project", type=Path, default=Path.cwd())
341
+ parser.add_argument("--base-url", help="running host origin (GET-only)")
342
+ parser.add_argument("--dist", type=Path, help="static build directory")
343
+ parser.add_argument("--stage", choices=("before", "after"), default="after", help="section jobs: capture before editing, then verify after")
344
+ parser.add_argument("--build-command", help="explicit host build command for the build-check step")
345
+ parser.add_argument("--recheck", action="store_true", help="re-run steps that are already done")
346
+ parser.add_argument("--budget-ms", type=int, default=6000)
347
+ parser.add_argument("--require-browser", action="store_true", help="fail instead of falling back to static HTML when Chrome is unavailable")
348
+ args = parser.parse_args(argv)
349
+ try:
350
+ report = verify(args.project, args.job_id, args.base_url, args.dist, args.stage, args.build_command, args.recheck, args.budget_ms, args.require_browser)
351
+ except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as error:
352
+ print(f"BLOCKED: maggie-design verify: {error}", file=sys.stderr)
353
+ return 2
354
+ print(json.dumps(report, indent=2, ensure_ascii=False))
355
+ return 0 if report["passed"] else 1
356
+
357
+
358
+ if __name__ == "__main__":
359
+ raise SystemExit(main())
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- """Audit project documentation for stale trackers and unreferenced files."""
2
+ """Audit project documentation for stale trackers, unreferenced files, and conflicting rollout status."""
3
3
 
4
4
  from __future__ import annotations
5
5
 
@@ -126,16 +126,94 @@ def audit(project: Path, docs_dir: str = "docs") -> dict:
126
126
  }
127
127
 
128
128
 
129
+ HISTORICAL_RE = re.compile(r"documentation-status:\s*historical-snapshot", re.I)
130
+ ROLLOUT_STATES = ("planned", "in_progress", "blocked", "complete")
131
+ CLAIM_PATTERNS = (
132
+ ("complete", re.compile(r"(?i)^\s*[-*]\s*\[x\]|\b(?:complete|completed|done|shipped|live|resolved)\b")),
133
+ ("open", re.compile(r"(?i)^\s*[-*]\s*\[ \]|\b(?:in[ -]progress|pending|ongoing|planned|not started|blocked|todo|unchecked)\b")),
134
+ )
135
+
136
+
137
+ def _claim(line: str) -> str | None:
138
+ """Return complete/open for a status line, or None when absent or ambiguous."""
139
+ found = {state for state, pattern in CLAIM_PATTERNS if pattern.search(line)}
140
+ return found.pop() if len(found) == 1 else None
141
+
142
+
143
+ def rollout_status(project: Path, docs_dir: str = "docs", status_file: str = "docs/rollout-status.json", id_pattern: str = r"\b[A-Z][A-Z0-9]+-\d+\b") -> dict:
144
+ """Find documents whose rollout claims disagree with each other or with the canonical status file."""
145
+ root = project.resolve()
146
+ docs = (root / docs_dir).resolve()
147
+ errors: list[str] = []
148
+ canonical: dict[str, dict] = {}
149
+ canonical_path = root / status_file
150
+ if canonical_path.is_file():
151
+ data = json.loads(canonical_path.read_text(encoding="utf-8"))
152
+ if data.get("schemaVersion") != "maggie-rollout-status.v1" or not isinstance(data.get("items"), list):
153
+ errors.append(f"{status_file} must use schemaVersion maggie-rollout-status.v1 with an items list")
154
+ for item in data.get("items") or []:
155
+ item_id, state = str(item.get("id") or ""), item.get("status")
156
+ if not item_id or state not in ROLLOUT_STATES:
157
+ errors.append(f"{status_file}: item {item_id or '?'} needs an id and a status in {', '.join(ROLLOUT_STATES)}")
158
+ continue
159
+ if state == "complete" and not item.get("evidence"):
160
+ errors.append(f"{status_file}: {item_id} is complete without evidence")
161
+ canonical[item_id] = item
162
+ pattern = re.compile(id_pattern)
163
+ claims: dict[str, list[dict]] = {}
164
+ historical: list[str] = []
165
+ for path in _files(docs) if docs.is_dir() else []:
166
+ if path.suffix.casefold() not in {".md", ".mdx"}:
167
+ continue
168
+ text = path.read_text(encoding="utf-8", errors="replace")
169
+ relative = str(path.relative_to(root))
170
+ if HISTORICAL_RE.search(text):
171
+ historical.append(relative)
172
+ continue
173
+ for number, line in enumerate(text.splitlines(), 1):
174
+ ids = set(pattern.findall(line))
175
+ claim = _claim(line) if len(ids) == 1 else None
176
+ if claim:
177
+ claims.setdefault(ids.pop(), []).append({"path": relative, "line": number, "claim": claim})
178
+ conflicts = []
179
+ for item_id, found in sorted(claims.items()):
180
+ if item_id in canonical:
181
+ expected = "complete" if canonical[item_id]["status"] == "complete" else "open"
182
+ wrong = [claim for claim in found if claim["claim"] != expected]
183
+ if wrong:
184
+ conflicts.append({"id": item_id, "canonical": canonical[item_id]["status"], "claims": wrong})
185
+ elif len({claim["claim"] for claim in found}) > 1:
186
+ conflicts.append({"id": item_id, "canonical": None, "claims": found})
187
+ errors.extend(f"{item['id']}: conflicting rollout status in {', '.join(sorted({c['path'] for c in item['claims']}))}" for item in conflicts)
188
+ return {
189
+ "schemaVersion": "maggie-rollout-status-audit.v1",
190
+ "passed": not errors,
191
+ "canonical": str(canonical_path.relative_to(root)) if canonical_path.is_file() else None,
192
+ "itemCount": len(claims),
193
+ "historicalSnapshots": historical,
194
+ "conflicts": conflicts,
195
+ "errors": errors,
196
+ }
197
+
198
+
129
199
  def main() -> int:
130
200
  parser = argparse.ArgumentParser(description=__doc__)
131
- parser.add_argument("audit", nargs="?", choices=("audit",), default="audit")
201
+ parser.add_argument("command", nargs="?", choices=("audit", "rollout-status"), default="audit")
132
202
  parser.add_argument("--project", default=".")
133
203
  parser.add_argument("--docs-dir", default="docs")
134
204
  parser.add_argument("--fail-on-findings", action="store_true")
205
+ parser.add_argument("--status-file", default="docs/rollout-status.json", help="canonical maggie-rollout-status.v1 file (rollout-status)")
206
+ parser.add_argument("--id-pattern", default=r"\b[A-Z][A-Z0-9]+-\d+\b", help="regex for rollout item IDs such as MSG-14 (rollout-status)")
135
207
  parser.add_argument("--output")
136
208
  args = parser.parse_args()
137
- result = audit(Path(args.project), args.docs_dir)
138
- if args.fail_on_findings and result.get("findingCount"):
209
+ if args.command == "rollout-status":
210
+ try:
211
+ result = rollout_status(Path(args.project), args.docs_dir, args.status_file, args.id_pattern)
212
+ except (OSError, ValueError, json.JSONDecodeError, re.error) as error:
213
+ result = {"schemaVersion": "maggie-rollout-status-audit.v1", "passed": False, "errors": [str(error)]}
214
+ else:
215
+ result = audit(Path(args.project), args.docs_dir)
216
+ if args.command == "audit" and args.fail_on_findings and result.get("findingCount"):
139
217
  result["passed"] = False
140
218
  result.setdefault("errors", []).append("documentation findings require review")
141
219
  if args.output: