@topy-ai/maggie 0.7.40 → 0.7.41

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 (40) hide show
  1. package/README-zh-TW.md +29 -4
  2. package/README.md +35 -1
  3. package/bin/maggie.js +24 -5
  4. package/bundled-contracts/maggie-clone/interaction-state-v1.schema.json +26 -0
  5. package/bundled-contracts/maggie-content/provenance-v1.schema.json +20 -0
  6. package/bundled-contracts/maggie-design/brand-kit-v1.schema.json +18 -0
  7. package/bundled-contracts/maggie-design/browser-interactions-v1.schema.json +27 -0
  8. package/bundled-contracts/maggie-design/style-editing-v1.schema.json +35 -0
  9. package/bundled-contracts/maggie-media/image-generation-policy-v1.json +28 -0
  10. package/bundled-contracts/maggie-media/video-generation-policy-v1.json +40 -0
  11. package/bundled-contracts/maggie-media/video-job-v1.schema.json +20 -0
  12. package/bundled-contracts/maggie-media/video-playback-evidence-v1.schema.json +15 -0
  13. package/bundled-contracts/maggie-ops/npm11-preflight-v1.schema.json +17 -0
  14. package/bundled-contracts/maggie-scaffold/host-scaffold-v1.schema.json +25 -0
  15. package/bundled-contracts/maggie-seo/gsc-readiness-v1.schema.json +19 -0
  16. package/bundled-contracts/maggie-service-booking/delivery-provider-default-v1.json +8 -0
  17. package/bundled-contracts/maggie-service-booking/delivery-provider-v1.schema.json +16 -0
  18. package/bundled-contracts/maggiedash/browser-session-v1.schema.json +18 -0
  19. package/bundled-contracts/maggiedash/content-overrides-v1.schema.json +19 -0
  20. package/bundled-contracts/maggiedash/public-session-cache-v1.schema.json +17 -0
  21. package/bundled-references/browser-inspection.md +21 -0
  22. package/bundled-skills/maggie-blog/SKILL.md +12 -0
  23. package/bundled-skills/maggie-blog-bootstrap/SKILL.md +23 -0
  24. package/bundled-skills/maggie-booking/SKILL.md +15 -0
  25. package/bundled-skills/maggie-clone/SKILL.md +13 -0
  26. package/bundled-skills/maggie-deployment/SKILL.md +6 -0
  27. package/bundled-skills/maggie-design/SKILL.md +29 -3
  28. package/bundled-skills/maggie-ops/SKILL.md +12 -0
  29. package/bundled-skills/maggie-seo-geo/SKILL.md +33 -0
  30. package/bundled-tools/clis/maggie_analytics.py +43 -1
  31. package/bundled-tools/clis/maggie_browser_audit.py +99 -3
  32. package/bundled-tools/clis/maggie_clone.py +46 -1
  33. package/bundled-tools/clis/maggie_contracts.py +111 -0
  34. package/bundled-tools/clis/maggie_design.py +55 -0
  35. package/bundled-tools/clis/maggie_workflows.py +387 -0
  36. package/bundled-tools/clis/site_audit.py +28 -1
  37. package/bundled-tools/integrations/analytics.md +14 -0
  38. package/bundled-tools/runtime/site_baseline.py +3 -0
  39. package/package.json +1 -1
  40. package/references/browser-inspection.md +21 -0
@@ -27,6 +27,27 @@ interaction states and triggers
27
27
  responsive differences
28
28
  ```
29
29
 
30
+ Behavioral checks may be declared instead of hidden in a bespoke script. The
31
+ manifest is safe to commit because input values are not copied into evidence:
32
+
33
+ ```json
34
+ {
35
+ "schemaVersion": "maggie-browser-interactions.v1",
36
+ "steps": [
37
+ {"id": "open", "action": "click", "selector": "[data-menu]"},
38
+ {"id": "visible", "action": "assert-visible", "selector": "[data-drawer]"},
39
+ {"id": "styles", "action": "assert-style", "selector": "[data-drawer]", "property": "display", "value": "block"},
40
+ {"id": "privacy", "action": "assert-no-request", "origin": "third-party.example"}
41
+ ]
42
+ }
43
+ ```
44
+
45
+ Run it with `maggie browser-audit ... --interactions .maggie/interactions.json`.
46
+ Supported actions cover click, fill, type, select, press, wait, visibility and
47
+ state assertions, computed-style assertions, and new-request assertions. The
48
+ browser adapter owns the session and the audit records only step IDs and
49
+ redacted outcomes.
50
+
30
51
  If a target requires login, a bot challenge, a consent interaction, or a
31
52
  private browser profile, stop at that boundary and ask the user to provide
32
53
  authorized access. Do not bypass access controls or record cookies in project
@@ -103,3 +103,15 @@ maggie blog integration-state --configured --authorized # ready
103
103
 
104
104
  Provider adapters should preserve the same `not-configured`,
105
105
  `awaiting-consent`, `awaiting-authorization`, `ready`, and `error` semantics.
106
+
107
+ Content evidence should also capture the Git source revision before an agent
108
+ writes or publishes a post:
109
+
110
+ ```bash
111
+ maggie provenance --project . --source src/content/post.md \
112
+ --output .maggie/content-provenance.json
113
+ ```
114
+
115
+ The report records commit, branch, dirty state, and authoring boundary. Host
116
+ write paths should reject stale writes unless the source revision still
117
+ matches; private paths and secrets are never included.
@@ -116,3 +116,26 @@ The project must have a passing foundation state, a real public route graph,
116
116
  published-only sitemap/canonical metadata, responsive UI evidence, an explicit
117
117
  Ops decision, and a documented rollback/deployment plan. A rendered button or
118
118
  mock editor is not evidence that a backend operation works.
119
+
120
+ ### Authenticated browser QA and CDN safety
121
+
122
+ Authenticated browser evidence must use a host-owned short-lived session
123
+ contract. The host mints and revokes the session, passwords never enter the
124
+ browser harness, TTL is bounded, and the token is never written to Maggie
125
+ artifacts. Validate the contract with:
126
+
127
+ ```bash
128
+ maggie dash session-check --manifest .maggie/browser-session.json
129
+ ```
130
+
131
+ Public HTML must be identical across sessions so a CDN cannot cache an
132
+ administrator control for anonymous visitors. Keep the real session cookie
133
+ `httpOnly`; use a non-authoritative readable hint cookie only to decide whether
134
+ the browser should request identity client-side. Validate evidence with:
135
+
136
+ ```bash
137
+ maggie dash cache-boundary-check --evidence .maggie/public-session-cache.json
138
+ ```
139
+
140
+ If a host cannot provide these routes, record the browser session feature as
141
+ deferred rather than inserting a database session from a shared CLI.
@@ -191,6 +191,21 @@ placeholders, owner/admin/manager access, audit and idempotency requirements,
191
191
  and the server-only provider/recipient privacy boundary. It does not connect to
192
192
  Resend, send a message, or expose customer data.
193
193
 
194
+ The selected transactional delivery boundary is Resend for email and Twilio
195
+ for SMS. Validate the host policy before wiring provider adapters:
196
+
197
+ ```bash
198
+ maggie booking-delivery \
199
+ --policy .maggie/booking-delivery-policy.json
200
+ ```
201
+
202
+ The policy requires provider acknowledgement before a booking can display a
203
+ confirmed state, uses a bounded exponential-jitter retry with an idempotency
204
+ key per booking event and channel, and moves exhausted work to a dead-letter
205
+ queue. Replay is an explicit manager action and must be audited. `RESEND_API_KEY`
206
+ and `TWILIO_AUTH_TOKEN` remain server-only host secrets; this gate never sends
207
+ email or SMS.
208
+
194
209
  After the first owner signs in, the Booking Overview includes the same
195
210
  secret-safe readiness path: Stripe connection status, test/live mode, and a
196
211
  copyable `/api/maggie/booking/webhooks/stripe` URL. The preferred path is still
@@ -41,6 +41,19 @@ Each run persists `manifest.json`, `state.json`, and phase outputs under
41
41
  `failed` and must be resumed after the defect is fixed; an incomplete run must
42
42
  not be presented as a successful clone.
43
43
 
44
+ Replay approved interaction states with the same manifest used by
45
+ `maggie browser-audit`:
46
+
47
+ ```bash
48
+ maggie clone interactions --project <project-root> --run-id <stable-run-id> \
49
+ --browse <browse-cli> --interactions .maggie/browser-interactions.json
50
+ ```
51
+
52
+ The adapter replays at desktop, tablet, and mobile viewports and writes a
53
+ redacted `maggie-clone-interaction-state.v1` evidence file. It never stores
54
+ typed values, credentials, or raw provider payloads. A clone is not
55
+ interaction-complete until every approved step passes at all three viewports.
56
+
44
57
  An exported local HTML file is also a valid source, for example:
45
58
 
46
59
  ```text
@@ -414,3 +414,9 @@ both units being enabled in systemd. Data-dependent releases additionally
414
414
  require `rollback.backupId` and `rollback.restoreCommand` in
415
415
  `.maggie/deployment/data-release.json`, because switching code alone does not
416
416
  restore incompatible data.
417
+
418
+ `maggie update --help` is read-only. Help flags must be handled before project
419
+ discovery, install-manifest writes, or file synchronization. For fixture-backed
420
+ browser QA, the host owns the isolated dev server and data source; document a
421
+ second port/process and fixture mode in the host runbook instead of making the
422
+ shared updater or deployment CLI start an unmanaged daemon.
@@ -95,9 +95,20 @@ fallback. Mobile evidence must not assume that `preload="none"` will emit
95
95
  `canplaythrough` without user interaction. Record poster visibility, playback
96
96
  state, console errors, network errors, and a screenshot per viewport.
97
97
 
98
- This contract is provider-neutral. It does not claim that Maggie can generate
99
- video assets; generated-video planning, continuation, safety review, retries,
100
- and cost approval remain separate product/provider work.
98
+ The playback contract is provider-neutral, while the selected generation
99
+ policy is Google Gemini: `gemini-omni-1.1-flash` for the default hero-video
100
+ route and `veo-3.1-generate-preview` for specialized continuation/frame
101
+ control. Validate the policy and job evidence separately:
102
+
103
+ ```bash
104
+ maggie media video-policy \
105
+ --policy contracts/maggie-media/video-generation-policy-v1.json
106
+ maggie media video-job --job .maggie/video-job.json
107
+ ```
108
+
109
+ Generation remains host-owned. The host must supply cost budget, rights and
110
+ moderation evidence, idempotency, object storage, and a redacted provenance
111
+ record before a generated asset is used in a release.
101
112
 
102
113
  ## Automatic memory hook
103
114
 
@@ -531,3 +542,18 @@ utilities. Dashboard plans require operator journeys and desktop/tablet/mobile,
531
542
  collapsed-rail, mobile-drawer, and modal evidence. Sample plans require
532
543
  noindex, sitemap exclusion, a visible sample banner, placeholders, and a
533
544
  finish checklist.
545
+
546
+ For generated or converted pages, run the markup gate before handing the page
547
+ to a host editor:
548
+
549
+ ```bash
550
+ maggie design markup-check --html src/pages/about.astro --require-content-keys
551
+ maggie design style-check --manifest .maggie/style-editing.json
552
+ ```
553
+
554
+ Editable copy uses stable `data-maggie-content-key` values. Never key prices,
555
+ durations, opening times, contact details, entity identifiers, or mixed markup
556
+ without an explicit host contract. Decode HTML entities exactly once; unknown
557
+ or double-escaped entities stop the check. Optional empty attributes such as
558
+ `style=""` must be omitted. Codemods using compiler positions must anchor at
559
+ the opening `<tag` before slicing source text.
@@ -60,6 +60,18 @@ requires HTTP 200, a readable square image, and an engine-supported ICO, PNG,
60
60
  or GIF response. A logo-shaped source filename is only an unverified source
61
61
  candidate; it is never evidence that the public favicon route works.
62
62
 
63
+ For npm 11 installation or package release work, run the safe preflight before
64
+ an install mutation:
65
+
66
+ ```bash
67
+ maggie npm11 --project . --output .maggie/npm11-preflight.json
68
+ ```
69
+
70
+ The gate records the observed Node/npm versions, lockfile policy, lifecycle
71
+ script policy, and an ignore-scripts-first smoke boundary. A project with
72
+ `install`, `preinstall`, or `postinstall` scripts requires an explicit
73
+ maintainer decision; Maggie never runs an untrusted install script implicitly.
74
+
63
75
  If the user does not name a mode, inspect the project and propose the smallest
64
76
  mode that satisfies the request. Do not rebuild the public blog or change its
65
77
  framework just to add Ops.
@@ -104,6 +104,29 @@ The strict flag is for a review window where a crawler request is expected; it
104
104
  fails only when the supplied log contains no sitemap request. Never upload raw
105
105
  logs or include IPs, credentials, or query data in feedback or reports.
106
106
 
107
+ ### GSC readiness release gate
108
+
109
+ GSC readiness is part of the existing analytics release gate, not an implied
110
+ result of having a sitemap. Produce redacted evidence for the property,
111
+ verification, canonical origin, robots, sitemap, read-only authorization,
112
+ query/readback, and production smoke:
113
+
114
+ ```bash
115
+ maggie analytics gsc-readiness \
116
+ --gsc-evidence .maggie/gsc-readiness.json
117
+ maggie analytics release-gate --project . --environment staging \
118
+ --contract .maggie/analytics-contract.json \
119
+ --render-report .maggie/analytics-browser.json \
120
+ --network-report .maggie/analytics-network.json \
121
+ --provider-report .maggie/analytics-provider.json \
122
+ --smoke-report .maggie/analytics-smoke.json \
123
+ --gsc-evidence .maggie/gsc-readiness.json --require-gsc
124
+ ```
125
+
126
+ Use `contracts/maggie-seo/gsc-readiness-v1.schema.json` as the evidence
127
+ boundary. Credentials and mutation scopes remain host-owned; a passing local
128
+ contract is not proof of property ownership or live readback.
129
+
107
130
  ## Freeze and compare a reviewed site
108
131
 
109
132
  ```bash
@@ -393,3 +416,13 @@ same-origin page linked from primary navigation when it is `noindex`,
393
416
  `nofollow`, or `none`. The privacy check fails in both directions: an embedded
394
417
  origin missing from the policy, or a policy origin never observed in rendered
395
418
  pages, requires review.
419
+
420
+ Noindex pages are still audited structurally. The crawl reports
421
+ `indexabilityIfIndexed` with title, description, canonical, heading, and
422
+ JSON-LD checks that would fail if the route became indexable. This is
423
+ diagnostic evidence only; it does not change robots directives or sitemap
424
+ membership. Baseline drift recapture requires a reviewer reason and records an
425
+ expiring acknowledgement (`--reason-ttl-days`, default 30 days).
426
+
427
+ Internal-link extraction is DOM-only. Text inside `script`, `style`,
428
+ `noscript`, and comments is never treated as an anchor or link target.
@@ -48,6 +48,33 @@ def load_json(path: Path, label: str) -> tuple[dict | None, list[str]]:
48
48
  return value, []
49
49
 
50
50
 
51
+ def gsc_checks(evidence: dict) -> dict[str, bool]:
52
+ required = {
53
+ "property": isinstance(evidence.get("property"), dict) and bool(evidence["property"].get("siteUrl")),
54
+ "verification": isinstance(evidence.get("verification"), dict) and evidence["verification"].get("passed") is True,
55
+ "canonical": isinstance(evidence.get("canonical"), dict) and evidence["canonical"].get("passed") is True,
56
+ "robots": isinstance(evidence.get("robots"), dict) and evidence["robots"].get("passed") is True,
57
+ "sitemap": isinstance(evidence.get("sitemap"), dict) and evidence["sitemap"].get("passed") is True,
58
+ "authorization": isinstance(evidence.get("authorization"), dict) and evidence["authorization"].get("readOnly") is True and evidence["authorization"].get("authorized") is True,
59
+ "query-readback": isinstance(evidence.get("queryReadback"), dict) and evidence["queryReadback"].get("passed") is True,
60
+ "production-smoke": isinstance(evidence.get("productionSmoke"), dict) and evidence["productionSmoke"].get("passed") is True,
61
+ }
62
+ return required
63
+
64
+
65
+ def gsc_readiness(args: argparse.Namespace) -> int:
66
+ evidence, errors = load_json(Path(args.evidence).resolve(), "gsc evidence")
67
+ checks = gsc_checks(evidence or {})
68
+ failed = [name for name, passed in checks.items() if not passed]
69
+ errors.extend(failed)
70
+ if evidence and evidence.get("schemaVersion") != "maggie-gsc-readiness.v1":
71
+ errors.append("schemaVersion must be maggie-gsc-readiness.v1")
72
+ result = {"schemaVersion": "maggie-gsc-readiness-report.v1", "passed": not errors, "checks": checks, "failedChecks": failed, "errors": errors, "readOnly": True}
73
+ return_code = 0 if result["passed"] else 1
74
+ print(json.dumps(result, indent=2, ensure_ascii=False))
75
+ return return_code
76
+
77
+
51
78
  def release_gate(args: argparse.Namespace) -> int:
52
79
  project = Path(args.project).resolve()
53
80
  checks: dict[str, dict] = {}
@@ -87,6 +114,14 @@ def release_gate(args: argparse.Namespace) -> int:
87
114
  errors.extend(provider_errors)
88
115
  smoke, smoke_errors = load_json(Path(args.smoke_report).resolve(), "smoke-report")
89
116
  errors.extend(smoke_errors)
117
+ if args.gsc_evidence:
118
+ gsc, gsc_errors = load_json(Path(args.gsc_evidence).resolve(), "gsc-evidence")
119
+ errors.extend(gsc_errors)
120
+ checks.update({f"gsc-{name}": {"passed": passed, "evidence": "maggie-gsc-readiness.v1"} for name, passed in gsc_checks(gsc or {}).items()})
121
+ if gsc and gsc.get("schemaVersion") != "maggie-gsc-readiness.v1":
122
+ errors.append("gsc-evidence-schema")
123
+ elif args.require_gsc:
124
+ errors.append("gsc-evidence-required")
90
125
  if browser:
91
126
  check("browser-schema", browser.get("schemaVersion") == "maggie-analytics-browser.v1", "versioned browser evidence", checks)
92
127
  check("browser-render", browser.get("passed") is True and isinstance(browser.get("routes"), list) and bool(browser["routes"]), "routes rendered without a browser failure", checks)
@@ -144,7 +179,7 @@ def release_gate(args: argparse.Namespace) -> int:
144
179
 
145
180
  def main() -> int:
146
181
  parser = argparse.ArgumentParser()
147
- parser.add_argument("command", nargs="?", choices=("release-gate", "traffic-audit"))
182
+ parser.add_argument("command", nargs="?", choices=("release-gate", "traffic-audit", "gsc-readiness"))
148
183
  parser.add_argument("--project", default=".")
149
184
  parser.add_argument("--environment", choices=("development", "staging", "production"), default="staging")
150
185
  parser.add_argument("--env-file", help="optional env file; values are never printed")
@@ -154,8 +189,15 @@ def main() -> int:
154
189
  parser.add_argument("--network-report", help="redacted network evidence for release-gate")
155
190
  parser.add_argument("--provider-report", help="read-only provider evidence for release-gate")
156
191
  parser.add_argument("--smoke-report", help="production smoke evidence for release-gate")
192
+ parser.add_argument("--gsc-evidence", help="versioned GSC readiness evidence for release-gate")
193
+ parser.add_argument("--require-gsc", action="store_true", help="require GSC readiness evidence in release-gate")
157
194
  parser.add_argument("--events", help="redacted JSON array of analytics events for traffic-audit")
158
195
  args = parser.parse_args()
196
+ if args.command == "gsc-readiness":
197
+ if not args.gsc_evidence:
198
+ parser.error("gsc-readiness requires --gsc-evidence")
199
+ args.evidence = args.gsc_evidence
200
+ return gsc_readiness(args)
159
201
  if args.command == "release-gate":
160
202
  required = ("contract", "render_report", "network_report", "provider_report", "smoke_report")
161
203
  if any(not getattr(args, name) for name in required):
@@ -13,6 +13,98 @@ from browser_behavior import validate_samples
13
13
  from localization_runner import checkpoint
14
14
 
15
15
 
16
+ INTERACTION_ACTIONS = {
17
+ "click", "fill", "type", "select", "press", "wait",
18
+ "assert-visible", "assert-enabled", "assert-disabled", "assert-checked",
19
+ "assert-editable", "assert-text", "assert-style", "assert-no-request",
20
+ }
21
+
22
+
23
+ def load_interactions(path: Path | None) -> list[dict]:
24
+ if not path:
25
+ return []
26
+ value = json.loads(path.read_text(encoding="utf-8"))
27
+ if not isinstance(value, dict) or value.get("schemaVersion") != "maggie-browser-interactions.v1":
28
+ raise ValueError("interaction manifest schemaVersion must be maggie-browser-interactions.v1")
29
+ steps = value.get("steps")
30
+ if not isinstance(steps, list) or not steps:
31
+ raise ValueError("interaction manifest steps must be a non-empty array")
32
+ for step in steps:
33
+ if not isinstance(step, dict) or step.get("action") not in INTERACTION_ACTIONS:
34
+ raise ValueError("each interaction step must use a supported action")
35
+ if step.get("action") not in {"wait", "press", "assert-no-request"} and not str(step.get("selector") or "").strip():
36
+ raise ValueError("interaction step selector is required")
37
+ return steps
38
+
39
+
40
+ def js_value(call, expression: str):
41
+ raw = call("js", expression).strip()
42
+ try:
43
+ return json.loads(raw)
44
+ except json.JSONDecodeError:
45
+ return raw.strip('"')
46
+
47
+
48
+ def resource_urls(call) -> set[str]:
49
+ value = js_value(call, "JSON.stringify(performance.getEntriesByType('resource').map(entry => entry.name))")
50
+ return set(value if isinstance(value, list) else [])
51
+
52
+
53
+ def run_interactions(call, steps: list[dict]) -> list[dict]:
54
+ baseline = resource_urls(call)
55
+ evidence = []
56
+ for step in steps:
57
+ action = step["action"]
58
+ selector = str(step.get("selector") or "")
59
+ record = {"id": str(step.get("id") or f"step-{len(evidence) + 1}"), "action": action, "selector": selector, "status": "passed"}
60
+ try:
61
+ if action == "click":
62
+ call("click", selector)
63
+ elif action == "fill":
64
+ call("fill", selector, str(step.get("value") or ""))
65
+ elif action == "type":
66
+ call("click", selector)
67
+ call("type", str(step.get("text") or ""))
68
+ elif action == "select":
69
+ call("select", selector, str(step.get("value") or ""))
70
+ elif action == "press":
71
+ call("press", str(step.get("key") or "Enter"))
72
+ elif action == "wait":
73
+ call("wait", str(step.get("target") or "--load"))
74
+ elif action.startswith("assert-") and action in {"assert-visible", "assert-enabled", "assert-disabled", "assert-checked", "assert-editable"}:
75
+ call("is", action.removeprefix("assert-"), selector)
76
+ elif action == "assert-text":
77
+ expected = str(step.get("text") or "")
78
+ expression = f"(() => {{ const node = document.querySelector({json.dumps(selector)}); return Boolean(node && node.textContent.includes({json.dumps(expected)})); }})()"
79
+ if js_value(call, expression) is not True:
80
+ raise ValueError("text assertion failed")
81
+ elif action == "assert-style":
82
+ prop = str(step.get("property") or "")
83
+ expected = str(step.get("value") or "")
84
+ if not prop:
85
+ raise ValueError("style assertion property is required")
86
+ expression = f"(() => {{ const node = document.querySelector({json.dumps(selector)}); return node ? getComputedStyle(node)[{json.dumps(prop)}] : null; }})()"
87
+ actual = js_value(call, expression)
88
+ if actual != expected:
89
+ raise ValueError("style assertion failed")
90
+ elif action == "assert-no-request":
91
+ forbidden = str(step.get("origin") or step.get("url") or "")
92
+ if not forbidden:
93
+ raise ValueError("request assertion origin is required")
94
+ new_urls = sorted(url for url in resource_urls(call) - baseline if forbidden in url)
95
+ if new_urls:
96
+ raise ValueError("third-party request assertion failed")
97
+ else:
98
+ raise ValueError("unsupported interaction action")
99
+ except (OSError, ValueError, subprocess.TimeoutExpired) as error:
100
+ record["status"] = "failed"
101
+ record["error"] = str(error)
102
+ evidence.append(record)
103
+ break
104
+ evidence.append(record)
105
+ return evidence
106
+
107
+
16
108
  def audit(args):
17
109
  if urlparse(args.url).scheme not in {"http", "https", "file"}:
18
110
  raise ValueError("URL must use http, https or file")
@@ -20,13 +112,15 @@ def audit(args):
20
112
  raise ValueError("at least one --required selector is necessary")
21
113
  output = args.output.resolve()
22
114
  output.mkdir(parents=True, exist_ok=True)
115
+ interactions = load_interactions(args.interactions)
23
116
  def call(*command):
24
117
  result = subprocess.run([str(args.browse), *command], capture_output=True, text=True, timeout=45)
25
118
  if result.returncode:
26
119
  raise ValueError("browser command failed: " + command[0])
27
120
  return result.stdout
28
121
  report = {"schemaVersion": "maggie-browser-audit.v1", "url": args.url,
29
- "passed": False, "viewports": [], "evidence": "browser-captured"}
122
+ "passed": False, "viewports": [], "evidence": "browser-captured",
123
+ "interactionManifest": str(args.interactions.resolve()) if args.interactions else None}
30
124
  try:
31
125
  for index, viewport in enumerate(args.viewport or ["390x844", "768x1024", "1440x900"]):
32
126
  width, height = [int(value) for value in viewport.split("x")]
@@ -36,6 +130,7 @@ def audit(args):
36
130
  call("goto", args.url)
37
131
  selectors = list(dict.fromkeys(args.required + args.sticky))
38
132
  call("js", "window.__maggieAuditSelectors=" + json.dumps(selectors))
133
+ interaction_evidence = run_interactions(call, interactions) if interactions else []
39
134
  samples = []
40
135
  for step, fraction in enumerate([0, 0.5, 0.9]):
41
136
  call("js", f"window.scrollTo(0, (document.documentElement.scrollHeight-innerHeight)*{fraction})")
@@ -50,8 +145,8 @@ def audit(args):
50
145
  raise ValueError("browser did not create screenshot")
51
146
  finding = validate_samples(samples, args.required, args.sticky)
52
147
  report["viewports"].append({"viewport": viewport, "samples": samples,
53
- "screenshot": str(screenshot), **finding})
54
- report["passed"] = all(item["passed"] for item in report["viewports"])
148
+ "screenshot": str(screenshot), "interactionEvidence": interaction_evidence, **finding})
149
+ report["passed"] = all(item["passed"] and all(step["status"] == "passed" for step in item.get("interactionEvidence", [])) for item in report["viewports"])
55
150
  finally:
56
151
  checkpoint(output / "report.json", report)
57
152
  print(json.dumps(report, indent=2))
@@ -66,6 +161,7 @@ def main():
66
161
  parser.add_argument("--viewport", action="append")
67
162
  parser.add_argument("--required", action="append", default=[])
68
163
  parser.add_argument("--sticky", action="append", default=[])
164
+ parser.add_argument("--interactions", type=Path, help="declarative interaction/assertion manifest")
69
165
  args = parser.parse_args()
70
166
  try:
71
167
  return audit(args)
@@ -316,6 +316,48 @@ def command_verify(args):
316
316
  return 0
317
317
 
318
318
 
319
+ def command_interactions(args):
320
+ """Replay the shared browser-audit manifest for every clone target/viewport."""
321
+ root, manifest = read_manifest(args.project.resolve(), args.run_id)
322
+ interaction_path = args.interactions.resolve()
323
+ try:
324
+ interaction_manifest = json.loads(interaction_path.read_text(encoding="utf-8"))
325
+ except (OSError, json.JSONDecodeError) as error:
326
+ raise ValueError(f"cannot read interaction manifest: {error}") from error
327
+ if interaction_manifest.get("schemaVersion") != "maggie-browser-interactions.v1" or not isinstance(interaction_manifest.get("steps"), list) or not interaction_manifest["steps"]:
328
+ raise ValueError("interaction manifest must be a non-empty maggie-browser-interactions.v1 document")
329
+ evidence_root = root / "interactions"
330
+ evidence_root.mkdir(parents=True, exist_ok=True)
331
+ reports = []
332
+ states = []
333
+ audit_cli = Path(__file__).with_name("maggie_browser_audit.py")
334
+ for target in manifest["targets"]:
335
+ output = evidence_root / target["page_key"]
336
+ selectors = {str(step.get("selector")) for step in interaction_manifest["steps"] if step.get("selector")}
337
+ command = [sys.executable, str(audit_cli), target.get("capture_url", target["source_url"]), "--browse", str(args.browse), "--output", str(output), "--interactions", str(interaction_path), "--required", "body"]
338
+ for selector in sorted(selectors):
339
+ command.extend(["--required", selector])
340
+ for viewport in VIEWPORTS:
341
+ command.extend(["--viewport", f"{VIEWPORTS[viewport][0]}x{VIEWPORTS[viewport][1]}"])
342
+ completed = subprocess.run(command, capture_output=True, text=True, check=False)
343
+ report_path = output / "report.json"
344
+ reports.append({"pageKey": target["page_key"], "status": "passed" if completed.returncode == 0 else "failed", "report": str(report_path), "stderr": "browser audit failed" if completed.returncode else None})
345
+ try:
346
+ audit_report = json.loads(report_path.read_text(encoding="utf-8"))
347
+ except (OSError, json.JSONDecodeError):
348
+ audit_report = {}
349
+ for viewport, viewport_report in zip(("desktop", "tablet", "mobile"), audit_report.get("viewports") or []):
350
+ for step in interaction_manifest["steps"]:
351
+ step_evidence = next((item for item in viewport_report.get("interactionEvidence", []) if item.get("id") == step.get("id")), {})
352
+ states.append({"viewport": viewport, "stepId": step["id"], "status": step_evidence.get("status", "failed"), "screenshot": viewport_report.get("screenshot"), "error": step_evidence.get("error")})
353
+ evidence = {"schemaVersion": "maggie-clone-interaction-state.v1", "sourceRunId": manifest["run_id"], "interactionManifest": str(interaction_path), "viewports": list(VIEWPORTS), "states": states, "redacted": True, "reports": reports, "passed": bool(reports) and bool(states) and all(item["status"] == "passed" for item in reports) and all(item["status"] == "passed" for item in states)}
354
+ save_json(evidence_root / "evidence.json", evidence)
355
+ manifest["interactionEvidence"] = str(evidence_root / "evidence.json")
356
+ save_json(root / "manifest.json", manifest)
357
+ print(json.dumps(evidence, indent=2, ensure_ascii=False))
358
+ return 0 if evidence["passed"] else 1
359
+
360
+
319
361
  def command_status(args):
320
362
  root = run_root(args.project.resolve(), args.run_id)
321
363
  manifest_path = root / "manifest.json"
@@ -373,13 +415,16 @@ def main():
373
415
  status_parser.add_argument("--project", type=Path, default=Path.cwd())
374
416
  status_parser.add_argument("--run-id", required=True)
375
417
  status_parser.set_defaults(func=command_status)
376
- commands = {"init": command_init, "capture": command_capture, "extract": command_extract, "assets": command_assets, "compare": command_compare, "verify": command_verify}
418
+ commands = {"init": command_init, "capture": command_capture, "extract": command_extract, "assets": command_assets, "compare": command_compare, "verify": command_verify, "interactions": command_interactions}
377
419
  for name, func in commands.items():
378
420
  cmd = sub.add_parser(name); cmd.add_argument("--project", type=Path, default=Path.cwd()); cmd.add_argument("--run-id", required=True); cmd.add_argument("--force", action="store_true")
379
421
  if name == "init": cmd.add_argument("urls", nargs="+")
380
422
  if name == "assets": cmd.add_argument("--max-bytes", type=int, default=20_000_000)
381
423
  if name == "compare": cmd.add_argument("--local-url", required=True)
382
424
  if name == "verify": cmd.add_argument("--allow-uncompared", action="store_true")
425
+ if name == "interactions":
426
+ cmd.add_argument("--browse", type=Path, required=True)
427
+ cmd.add_argument("--interactions", type=Path, required=True, help="shared maggie-browser-interactions.v1 manifest")
383
428
  cmd.set_defaults(func=func)
384
429
  args = parser.parse_args()
385
430
  try: return int(args.func(args) or 0)
@@ -237,6 +237,105 @@ def validate_capabilities(args: argparse.Namespace) -> int:
237
237
  return emit_and_exit(result("maggie-host-capabilities.v1", errors, enabled=sum(1 for item in endpoints if isinstance(item, dict) and item.get("enabled") is True), endpointCount=len(endpoints)), Path(args.output) if args.output else None)
238
238
 
239
239
 
240
+ def validate_style_editing(args: argparse.Namespace) -> int:
241
+ """Validate a closed, host-rendered style editing contract."""
242
+ manifest = read_json(Path(args.manifest))
243
+ errors: list[str] = []
244
+ if manifest.get("schemaVersion") != "maggie-style-editing.v1":
245
+ errors.append("manifest schemaVersion must be maggie-style-editing.v1")
246
+ properties = manifest.get("properties")
247
+ if not isinstance(properties, list) or not properties:
248
+ errors.append("properties must be a non-empty array")
249
+ properties = []
250
+ allowed: set[str] = set()
251
+ patterns: dict[str, str] = {}
252
+ dangerous = re.compile(r"(?:;|url\s*\(|expression\s*\(|<|>|javascript:)", re.I)
253
+ for item in properties:
254
+ if not isinstance(item, dict):
255
+ errors.append("each style property must be an object")
256
+ continue
257
+ name = str(item.get("name") or "")
258
+ pattern = str(item.get("pattern") or "")
259
+ if not re.fullmatch(r"[A-Za-z][A-Za-z0-9-]*", name):
260
+ errors.append(f"invalid style property name: {name or 'unknown'}")
261
+ if name in allowed:
262
+ errors.append(f"duplicate style property: {name}")
263
+ allowed.add(name)
264
+ if not pattern:
265
+ errors.append(f"style property {name or 'unknown'} needs a value pattern")
266
+ else:
267
+ try:
268
+ re.compile(pattern)
269
+ patterns[name] = pattern
270
+ except re.error:
271
+ errors.append(f"style property {name or 'unknown'} has an invalid value pattern")
272
+ for template in manifest.get("templates") or []:
273
+ if not isinstance(template, dict) or not str(template.get("id") or ""):
274
+ errors.append("each style template needs an id")
275
+ continue
276
+ for name, value in (template.get("styles") or {}).items():
277
+ if name not in allowed:
278
+ errors.append(f"style template {template['id']} uses undeclared property: {name}")
279
+ continue
280
+ if not isinstance(value, str) or dangerous.search(value) or not re.fullmatch(patterns[name], value):
281
+ errors.append(f"style template {template['id']} has an unsafe value for {name}")
282
+ return emit_and_exit(result("maggie-style-editing.v1", errors, properties=sorted(allowed), templates=len(manifest.get("templates") or [])), Path(args.output) if args.output else None)
283
+
284
+
285
+ def validate_content_overrides(args: argparse.Namespace) -> int:
286
+ manifest = read_json(Path(args.manifest))
287
+ errors: list[str] = []
288
+ if manifest.get("schemaVersion") != "maggie-content-overrides.v1":
289
+ errors.append("manifest schemaVersion must be maggie-content-overrides.v1")
290
+ expected = {
291
+ "readAtRequestTime": True,
292
+ "sourceLiteralIsFallback": True,
293
+ "writesCreateRevision": True,
294
+ "conflictRequiresSourceRevision": True,
295
+ "orphanPolicy": "report-and-never-publish",
296
+ }
297
+ for key, value in expected.items():
298
+ if manifest.get(key) != value:
299
+ errors.append(f"{key} must be {value!r}")
300
+ if not isinstance(manifest.get("protectedFields"), list) or not manifest.get("protectedFields"):
301
+ errors.append("protectedFields must list factual fields that cannot be overridden")
302
+ return emit_and_exit(result("maggie-content-overrides.v1", errors, protectedFields=manifest.get("protectedFields", [])), Path(args.output) if args.output else None)
303
+
304
+
305
+ def validate_session_contract(args: argparse.Namespace) -> int:
306
+ manifest = read_json(Path(args.manifest))
307
+ errors: list[str] = []
308
+ if manifest.get("schemaVersion") != "maggie-browser-session.v1":
309
+ errors.append("manifest schemaVersion must be maggie-browser-session.v1")
310
+ if manifest.get("mintingBoundary") != "host-adapter":
311
+ errors.append("mintingBoundary must be host-adapter")
312
+ if manifest.get("passwordHandling") != "never-exposed":
313
+ errors.append("passwordHandling must be never-exposed")
314
+ ttl = manifest.get("ttlMinutes")
315
+ if not isinstance(ttl, int) or not 1 <= ttl <= 240:
316
+ errors.append("ttlMinutes must be between 1 and 240")
317
+ for key in ("mintRoute", "revokeRoute"):
318
+ if not str(manifest.get(key) or "").startswith("/"):
319
+ errors.append(f"{key} must be a relative host route")
320
+ return emit_and_exit(result("maggie-browser-session.v1", errors, ttlMinutes=ttl), Path(args.output) if args.output else None)
321
+
322
+
323
+ def validate_cache_boundary(args: argparse.Namespace) -> int:
324
+ evidence = read_json(Path(args.evidence))
325
+ errors: list[str] = []
326
+ if evidence.get("schemaVersion") != "maggie-public-session-cache.v1":
327
+ errors.append("evidence schemaVersion must be maggie-public-session-cache.v1")
328
+ if evidence.get("publicHtmlVariesBySession") is not False:
329
+ errors.append("publicHtmlVariesBySession must be false")
330
+ if evidence.get("sessionCookieHttpOnly") is not True:
331
+ errors.append("sessionCookieHttpOnly must be true")
332
+ if evidence.get("identityHintCookieReadable") is not True:
333
+ errors.append("identityHintCookieReadable must be true")
334
+ if evidence.get("identityRequestWhenHintAbsent") is not False:
335
+ errors.append("identityRequestWhenHintAbsent must be false")
336
+ return emit_and_exit(result("maggie-public-session-cache.v1", errors), Path(args.output) if args.output else None)
337
+
338
+
240
339
  def main() -> int:
241
340
  parser = argparse.ArgumentParser(prog="maggie contracts")
242
341
  sub = parser.add_subparsers(dest="command", required=True)
@@ -270,6 +369,18 @@ def main() -> int:
270
369
  capabilities = sub.add_parser("capabilities-validate")
271
370
  capabilities.add_argument("--manifest", required=True); capabilities.add_argument("--output")
272
371
  capabilities.set_defaults(func=validate_capabilities)
372
+ style = sub.add_parser("style-check")
373
+ style.add_argument("--manifest", required=True); style.add_argument("--output")
374
+ style.set_defaults(func=validate_style_editing)
375
+ overrides = sub.add_parser("content-overrides-check")
376
+ overrides.add_argument("--manifest", required=True); overrides.add_argument("--output")
377
+ overrides.set_defaults(func=validate_content_overrides)
378
+ session = sub.add_parser("session-check")
379
+ session.add_argument("--manifest", required=True); session.add_argument("--output")
380
+ session.set_defaults(func=validate_session_contract)
381
+ cache = sub.add_parser("cache-boundary-check")
382
+ cache.add_argument("--evidence", required=True); cache.add_argument("--output")
383
+ cache.set_defaults(func=validate_cache_boundary)
273
384
  args = parser.parse_args()
274
385
  try:
275
386
  return args.func(args)