@topy-ai/maggie 0.7.45 → 0.7.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README-zh-TW.md +15 -2
  2. package/README.md +55 -3
  3. package/bin/maggie.js +22 -4
  4. package/bundled-contracts/google-integrations/capability-report-v2.schema.json +76 -0
  5. package/bundled-contracts/google-integrations/external-write-readback-v1.schema.json +36 -0
  6. package/bundled-contracts/maggie-deployment/deployer-delegation-v1.schema.json +20 -0
  7. package/bundled-contracts/maggie-deployment/release-profile-v1.schema.json +34 -0
  8. package/bundled-contracts/maggie-design/browser-capability-v1.schema.json +33 -0
  9. package/bundled-contracts/maggie-feedback/evidence-bundle-v1.schema.json +38 -0
  10. package/bundled-references/browser-inspection.md +17 -0
  11. package/bundled-references/google-integrations-runbook.md +8 -1
  12. package/bundled-references/memory-hook.md +18 -5
  13. package/bundled-skills/maggie-clone/SKILL.md +7 -0
  14. package/bundled-skills/maggie-deployment/SKILL.md +55 -15
  15. package/bundled-skills/maggie-feedback/SKILL.md +26 -1
  16. package/bundled-skills/maggie-memory/SKILL.md +10 -1
  17. package/bundled-skills/maggie-ops/SKILL.md +22 -0
  18. package/bundled-skills/maggie-seo-geo/SKILL.md +4 -1
  19. package/bundled-tools/clis/maggie_analytics.py +8 -0
  20. package/bundled-tools/clis/maggie_browser_audit.py +13 -2
  21. package/bundled-tools/clis/maggie_deployment.py +214 -6
  22. package/bundled-tools/clis/maggie_feedback.py +116 -1
  23. package/bundled-tools/clis/maggie_memory.py +10 -3
  24. package/bundled-tools/clis/maggie_ops.py +33 -0
  25. package/bundled-tools/clis/maggie_release.py +124 -19
  26. package/bundled-tools/runtime/browser_capability.py +143 -0
  27. package/bundled-tools/runtime/external_write.py +122 -0
  28. package/bundled-tools/runtime/google_capabilities.py +37 -5
  29. package/bundled-tools/runtime/maggie_memory.py +17 -2
  30. package/package.json +1 -1
  31. package/references/browser-inspection.md +17 -0
  32. package/references/google-integrations-runbook.md +8 -1
  33. package/references/memory-hook.md +18 -5
@@ -30,8 +30,15 @@ VERSION_RE = re.compile(r"\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?")
30
30
  SAFE_BATCH_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$")
31
31
  SAFE_ROUTE_RE = re.compile(r"^/[A-Za-z0-9._~!$&'()*+,;=:@%/?#-]{1,240}$")
32
32
  SAFE_EVIDENCE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/=-]{0,159}$")
33
+ SAFE_FEEDBACK_ID_RE = re.compile(r"^fb-[A-Za-z0-9_-]{1,159}$")
33
34
  MAX_ROUTE_IDS = 20
34
35
  MAX_VALIDATION_EVIDENCE = 20
36
+ MAX_EVIDENCE_REFERENCES = 20
37
+ MAX_RELATIONSHIPS = 20
38
+ MAX_GRAPH_NODES = 200
39
+ MAX_GRAPH_EDGES = 300
40
+ EVIDENCE_KINDS = {"test", "report", "screenshot", "command", "deployment", "issue", "commit", "artifact"}
41
+ RELATION_KINDS = {"related", "same-run", "same-fingerprint", "duplicates", "validates", "remediates", "caused-by"}
35
42
  ACKNOWLEDGEMENT_KEYS = ("status", "feedbackId", "requestId")
36
43
  REPO_ROOT = Path(__file__).resolve().parents[2]
37
44
 
@@ -134,6 +141,64 @@ def safe_validation_evidence(values: object) -> list[str]:
134
141
  return sorted(set(result))
135
142
 
136
143
 
144
+ def safe_evidence_references(values: object) -> list[dict]:
145
+ """Normalize bounded evidence pointers without copying artifacts or payloads."""
146
+ if not isinstance(values, list):
147
+ return []
148
+ result = []
149
+ for value in values[:MAX_EVIDENCE_REFERENCES]:
150
+ if isinstance(value, str):
151
+ if ":" not in value:
152
+ continue
153
+ kind, reference = value.split(":", 1)
154
+ status = "observed"
155
+ elif isinstance(value, dict):
156
+ kind = value.get("kind", "")
157
+ reference = value.get("ref", "")
158
+ status = value.get("status", "observed")
159
+ else:
160
+ continue
161
+ kind = safe_text(kind).lower()
162
+ reference = safe_text(reference)
163
+ status = safe_text(status).lower()
164
+ if kind not in EVIDENCE_KINDS or not SAFE_EVIDENCE_RE.fullmatch(reference):
165
+ continue
166
+ if status not in {"passed", "failed", "observed", "not-run", "unknown"}:
167
+ status = "unknown"
168
+ result.append({"kind": kind, "ref": reference, "status": status})
169
+ return sorted({(item["kind"], item["ref"], item["status"]): item for item in result}.values(),
170
+ key=lambda item: (item["kind"], item["ref"], item["status"]))
171
+
172
+
173
+ def safe_relationships(values: object) -> list[dict]:
174
+ """Normalize links to other safe IDs; never accept URLs or arbitrary payloads."""
175
+ if not isinstance(values, list):
176
+ return []
177
+ result = []
178
+ for value in values[:MAX_RELATIONSHIPS]:
179
+ if isinstance(value, str):
180
+ if "=" not in value:
181
+ continue
182
+ kind, target = value.split("=", 1)
183
+ confidence = "medium"
184
+ elif isinstance(value, dict):
185
+ kind = value.get("kind", "")
186
+ target = value.get("target", "")
187
+ confidence = value.get("confidence", "medium")
188
+ else:
189
+ continue
190
+ kind = safe_text(kind).lower()
191
+ target = safe_text(target)
192
+ confidence = safe_text(confidence).lower()
193
+ if kind not in RELATION_KINDS or not SAFE_EVIDENCE_RE.fullmatch(target):
194
+ continue
195
+ if confidence not in {"low", "medium", "high"}:
196
+ confidence = "medium"
197
+ result.append({"kind": kind, "target": target, "confidence": confidence})
198
+ return sorted({(item["kind"], item["target"], item["confidence"]): item for item in result}.values(),
199
+ key=lambda item: (item["kind"], item["target"], item["confidence"]))
200
+
201
+
137
202
  def collect(args: argparse.Namespace) -> int:
138
203
  project = Path(args.project).resolve()
139
204
  report = load_json(Path(args.run_report)) if args.run_report else {}
@@ -171,6 +236,8 @@ def collect(args: argparse.Namespace) -> int:
171
236
  affected_cli = safe_text(getattr(args, "affected_cli", ""))
172
237
  route_ids = safe_route_ids(getattr(args, "route_ids", []))
173
238
  validation_evidence = safe_validation_evidence(getattr(args, "validation_evidence", []))
239
+ evidence = safe_evidence_references(getattr(args, "evidence", []))
240
+ relationships = safe_relationships(getattr(args, "relationships", []))
174
241
  context = {"projectFingerprint": project_fingerprint(project)}
175
242
  if args.allow_project_context and args.context_note:
176
243
  context["note"] = safe_text(args.context_note)
@@ -197,6 +264,8 @@ def collect(args: argparse.Namespace) -> int:
197
264
  "affectedCli": affected_cli,
198
265
  "routeIds": route_ids,
199
266
  "validationEvidence": validation_evidence,
267
+ "evidence": evidence,
268
+ "relationships": relationships,
200
269
  "attachments": [attachment(value) for value in args.screenshot],
201
270
  "environment": {"os": platform.system().lower(), "python": platform.python_version()},
202
271
  "privacy": {"secretsRedacted": True, "projectContextAllowed": bool(args.allow_project_context)},
@@ -226,7 +295,7 @@ def to_markdown(data: dict) -> str:
226
295
  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'}", ""]
227
296
  if data.get("stepsToReproduce"):
228
297
  lines += ["**Steps to reproduce:**", ""] + [f"{index}. {step}" for index, step in enumerate(data["stepsToReproduce"], 1)] + [""]
229
- 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)", ""]
298
+ 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'}", f"**Evidence references:** `{len(safe_evidence_references(data.get('evidence', [])))}`", f"**Relationships:** `{len(safe_relationships(data.get('relationships', [])))}`", "", "## Privacy", "", f"- Project context allowed: `{bool(data.get('privacy', {}).get('projectContextAllowed'))}`", f"- Attachments: `{len(data.get('attachments', []))}` metadata-only reference(s)", ""]
230
299
  return "\n".join(lines)
231
300
 
232
301
 
@@ -307,6 +376,8 @@ def collect_batch(args: argparse.Namespace) -> int:
307
376
  "affected_cli": safe_text(manifest.get("affectedCli")),
308
377
  "route_ids": safe_route_ids(manifest.get("routeIds", [])),
309
378
  "validation_evidence": safe_validation_evidence(manifest.get("validationEvidence", [])),
379
+ "evidence": safe_evidence_references(manifest.get("evidence", [])),
380
+ "relationships": safe_relationships(manifest.get("relationships", [])),
310
381
  }
311
382
  paths = []
312
383
  for index, item in enumerate(items):
@@ -337,11 +408,15 @@ def collect_batch(args: argparse.Namespace) -> int:
337
408
  "affected_cli": item.get("affectedCli") or shared["affected_cli"],
338
409
  "route_ids": item.get("routeIds", shared["route_ids"]),
339
410
  "validation_evidence": item.get("validationEvidence", shared["validation_evidence"]),
411
+ "evidence": item.get("evidence", shared["evidence"]),
412
+ "relationships": item.get("relationships", shared["relationships"]),
340
413
  }
341
414
  if not isinstance(values["reproduce"], list): values["reproduce"] = []
342
415
  if not isinstance(values["screenshot"], list): values["screenshot"] = []
343
416
  values["route_ids"] = safe_route_ids(values["route_ids"])
344
417
  values["validation_evidence"] = safe_validation_evidence(values["validation_evidence"])
418
+ values["evidence"] = safe_evidence_references(values["evidence"])
419
+ values["relationships"] = safe_relationships(values["relationships"])
345
420
  paths.append(collect(argparse.Namespace(**values)))
346
421
  print(json.dumps({"batchId": batch_id, "count": len(paths), "status": "drafts-created"}, ensure_ascii=False))
347
422
  return 0
@@ -380,6 +455,8 @@ def batch_review(args: argparse.Namespace) -> int:
380
455
  "validation": safe_text(data.get("validation")),
381
456
  "routeIds": safe_route_ids(data.get("routeIds", [])),
382
457
  "validationEvidence": safe_validation_evidence(data.get("validationEvidence", [])),
458
+ "evidence": safe_evidence_references(data.get("evidence", [])),
459
+ "relationships": safe_relationships(data.get("relationships", [])),
383
460
  })
384
461
  records.sort(key=lambda item: (item["index"] is None, item["index"] if isinstance(item["index"], int) else 0, item["feedbackId"]))
385
462
  expected_count = max(expected_sizes, default=len(records))
@@ -390,6 +467,39 @@ def batch_review(args: argparse.Namespace) -> int:
390
467
  if key:
391
468
  duplicate_groups.setdefault(key, []).append(item["feedbackId"])
392
469
  duplicates = [{"key": safe_text(key), "feedbackIds": ids} for key, ids in duplicate_groups.items() if len(ids) > 1]
470
+ graph_nodes = [{"id": item["feedbackId"], "kind": "feedback"} for item in records]
471
+ graph_edges = []
472
+ evidence_nodes = {}
473
+ for item in records:
474
+ for evidence in item["evidence"]:
475
+ evidence_key = f"{evidence['kind']}:{evidence['ref']}"
476
+ evidence_id = "evidence-" + hashlib.sha256(evidence_key.encode("utf-8")).hexdigest()[:16]
477
+ evidence_nodes.setdefault(evidence_id, {"id": evidence_id, "kind": "evidence", **evidence})
478
+ graph_edges.append({"from": item["feedbackId"], "to": evidence_id, "kind": "evidenced-by"})
479
+ for relationship in item["relationships"]:
480
+ target = relationship["target"]
481
+ target_id = target if SAFE_FEEDBACK_ID_RE.fullmatch(target) else "reference-" + hashlib.sha256(target.encode("utf-8")).hexdigest()[:16]
482
+ if not any(node["id"] == target_id for node in graph_nodes):
483
+ graph_nodes.append({"id": target_id, "kind": "related-reference"})
484
+ graph_edges.append({"from": item["feedbackId"], "to": target_id, "kind": relationship["kind"], "confidence": relationship["confidence"]})
485
+ graph_nodes.extend(evidence_nodes.values())
486
+ graph_nodes = graph_nodes[:MAX_GRAPH_NODES]
487
+ allowed_node_ids = {node["id"] for node in graph_nodes}
488
+ graph_edges = [edge for edge in graph_edges if edge["from"] in allowed_node_ids and edge["to"] in allowed_node_ids][:MAX_GRAPH_EDGES]
489
+ evidence_usage = {}
490
+ for item in records:
491
+ for evidence in item["evidence"]:
492
+ key = f"{evidence['kind']}:{evidence['ref']}"
493
+ evidence_usage.setdefault(key, []).append(item["feedbackId"])
494
+ remediation_suggestions = []
495
+ if duplicates:
496
+ remediation_suggestions.append("Review duplicate fingerprints or summaries and link one canonical remediation issue before implementation.")
497
+ if len(records) != expected_count:
498
+ remediation_suggestions.append("Collect the missing batch indexes before treating this batch as complete.")
499
+ if any(len(ids) > 1 for ids in evidence_usage.values()):
500
+ remediation_suggestions.append("Inspect shared evidence references for a common root cause and reuse one regression test where appropriate.")
501
+ if not any(item["evidence"] for item in records):
502
+ remediation_suggestions.append("Attach bounded test, report, screenshot, command, or deployment references before release review.")
393
503
  output = Path(args.output) if args.output else project / ".maggie" / "feedback" / "batches" / f"{batch_id}-review.json"
394
504
  if not output.is_absolute(): output = project / output
395
505
  report = {
@@ -403,6 +513,8 @@ def batch_review(args: argparse.Namespace) -> int:
403
513
  "phases": sorted({item["phase"] for item in records if item["phase"]}),
404
514
  "observations": records,
405
515
  "duplicates": duplicates,
516
+ "evidenceGraph": {"nodes": graph_nodes, "edges": graph_edges},
517
+ "remediationSuggestions": remediation_suggestions,
406
518
  "privacy": {"rawPayloadsIncluded": False, "localPathsIncluded": False, "secretsRedacted": True},
407
519
  }
408
520
  output.parent.mkdir(parents=True, exist_ok=True)
@@ -446,6 +558,8 @@ def main() -> int:
446
558
  collect_parser.add_argument("--affected-cli", default="")
447
559
  collect_parser.add_argument("--route-id", dest="route_ids", action="append", default=[])
448
560
  collect_parser.add_argument("--validation-evidence", dest="validation_evidence", action="append", default=[])
561
+ collect_parser.add_argument("--evidence-ref", dest="evidence", action="append", default=[], help="safe kind:reference pointer; no file contents are copied")
562
+ collect_parser.add_argument("--relationship", dest="relationships", action="append", default=[], help="safe relation=target pointer")
449
563
  batch_parser = sub.add_parser("batch")
450
564
  batch_parser.add_argument("--project", default=argparse.SUPPRESS)
451
565
  batch_parser.add_argument("--batch-file", required=True)
@@ -461,6 +575,7 @@ def main() -> int:
461
575
  submit_parser.add_argument("--endpoint", default=DEFAULT_ENDPOINT)
462
576
  submit_parser.add_argument("--confirm", action="store_true")
463
577
  list_parser = sub.add_parser("list")
578
+ list_parser.add_argument("--project", default=argparse.SUPPRESS)
464
579
  args = parser.parse_args()
465
580
  try:
466
581
  if args.command == "collect": return collect(args)
@@ -35,12 +35,15 @@ def main() -> int:
35
35
  listing.add_argument("--status", choices=sorted(memory.STATUSES), default=None)
36
36
  listing.add_argument("--skill", default="")
37
37
  listing.add_argument("--query", default="")
38
+ listing.add_argument("--include-shared", action="store_true", help="explicitly include user/workspace memory entries")
38
39
  search = add_project(sub.add_parser("search"))
39
40
  search.add_argument("query")
40
41
  search.add_argument("--skill", default="")
42
+ search.add_argument("--include-shared", action="store_true", help="explicitly include user/workspace memory entries")
41
43
  context = add_project(sub.add_parser("context"))
42
44
  context.add_argument("--skill", default="")
43
45
  context.add_argument("--query", default="")
46
+ context.add_argument("--include-shared", action="store_true", help="explicitly include user/workspace memory entries")
44
47
  add = add_project(sub.add_parser("add"))
45
48
  add.add_argument("kind", choices=("preferences", "conventions", "lessons"))
46
49
  add.add_argument("--scope", choices=sorted(memory.SCOPES), required=True)
@@ -85,17 +88,21 @@ def main() -> int:
85
88
  output(memory.transition(args.project, args.kind, args.item_id, args.status))
86
89
  elif args.command in {"context", "search", "list"}:
87
90
  query = args.query if args.command == "search" else getattr(args, "query", "")
88
- result = memory.relevant(args.project, skill=getattr(args, "skill", ""), query=query, status=getattr(args, "status", None))
91
+ result = memory.relevant(args.project, skill=getattr(args, "skill", ""), query=query, status=getattr(args, "status", None), include_shared=getattr(args, "include_shared", False))
89
92
  if args.command == "list" and args.kind:
90
93
  result = {args.kind: result[args.kind]}
91
94
  if args.command == "list" and args.status:
92
95
  result = {kind: [item for item in items if item.get("status") == args.status] for kind, items in result.items()}
93
- output(result)
96
+ if args.command == "context":
97
+ ready = memory.initialized(args.project)
98
+ output({"status": "ready" if ready else "uninitialized", "initialized": ready, **result})
99
+ else:
100
+ output(result)
94
101
  elif args.command == "export":
95
102
  payload = {kind: memory.read(args.project, kind) for kind in memory.KINDS if kind != "errors"}
96
103
  if args.public_safe:
97
104
  for value in payload.values():
98
- value["items"] = [item for item in value["items"] if item.get("scope") != "user" and item.get("status") == "active"]
105
+ value["items"] = [item for item in value["items"] if item.get("scope") == "project" and item.get("status") == "active"]
99
106
  destination = Path(args.output)
100
107
  destination.parent.mkdir(parents=True, exist_ok=True)
101
108
  destination.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
@@ -16,6 +16,7 @@ from agent_runtime import preflight as agent_preflight
16
16
  from restart_gate import validate as validate_restart_gate
17
17
  from google_capabilities import validate_report as validate_google_capability_report
18
18
  from maggie_favicon import check_favicon
19
+ from external_write import validate as validate_external_write
19
20
 
20
21
 
21
22
  REQUIRED_ROUTES = (
@@ -176,12 +177,40 @@ def command_google_capabilities(args: argparse.Namespace) -> int:
176
177
  "status": "passed" if result["passed"] else "failed",
177
178
  "report": str(output),
178
179
  "providerCount": len(result["providerResults"]),
180
+ "verifiedTargets": [
181
+ {
182
+ "provider": item["provider"],
183
+ "activeAccount": item["activeAccount"],
184
+ "target": item["target"],
185
+ "scopes": item["scopes"],
186
+ }
187
+ for item in result["providerResults"]
188
+ ],
179
189
  "failedChecks": result["errors"],
180
190
  "mutationsAllowed": False,
181
191
  }, indent=2, ensure_ascii=False))
182
192
  return 0 if result["passed"] else 1
183
193
 
184
194
 
195
+ def command_external_write_gate(args: argparse.Namespace) -> int:
196
+ evidence = read_json(Path(args.evidence).resolve())
197
+ result = validate_external_write(evidence)
198
+ output = Path(args.output).resolve() if args.output else root(args) / ".maggie" / "external-write-readback.json"
199
+ write_json(output, result)
200
+ print(json.dumps({
201
+ "status": "passed" if result["passed"] else "failed",
202
+ "report": str(output),
203
+ "provider": result.get("provider"),
204
+ "resource": result.get("resource"),
205
+ "operation": result.get("operation"),
206
+ "idempotencyKey": result.get("idempotencyKey"),
207
+ "readback": result.get("readback"),
208
+ "failedChecks": result["errors"],
209
+ "mutation": "not executed",
210
+ }, indent=2, ensure_ascii=False))
211
+ return 0 if result["passed"] else 1
212
+
213
+
185
214
  def command_favicon_check(args: argparse.Namespace) -> int:
186
215
  result = check_favicon(args.origin, args.declared_url, args.timeout)
187
216
  print(json.dumps(result, indent=2, ensure_ascii=False))
@@ -210,6 +239,10 @@ def main() -> int:
210
239
  google.add_argument("--report", required=True)
211
240
  google.add_argument("--output")
212
241
  google.set_defaults(func=command_google_capabilities)
242
+ external_write = sub.add_parser("external-write-gate", help="validate idempotent provider write/readback evidence")
243
+ external_write.add_argument("--evidence", required=True)
244
+ external_write.add_argument("--output")
245
+ external_write.set_defaults(func=command_external_write_gate)
213
246
  favicon = sub.add_parser("favicon-check", help="fetch /favicon.ico and the declared icon to verify behaviour")
214
247
  favicon.add_argument("--origin", required=True)
215
248
  favicon.add_argument("--declared-url")
@@ -20,6 +20,21 @@ from urllib.parse import urljoin, urlsplit
20
20
 
21
21
 
22
22
  ROOT = Path(__file__).resolve().parent
23
+ PROFILE_SCHEMA = "maggie-release-profile.v1"
24
+ PROFILE_CAPABILITIES = {
25
+ "deployment",
26
+ "scenario-qa",
27
+ "social-card-audit",
28
+ "migration",
29
+ "maggiedash-health",
30
+ "schedule",
31
+ "analytics-contract",
32
+ "analytics-release-gate",
33
+ "service-facts",
34
+ "maggiedash-schema",
35
+ "editorial-review",
36
+ "durable-site-evidence",
37
+ }
23
38
 
24
39
 
25
40
  def safe_tail(value: str, limit: int = 1200) -> str:
@@ -52,6 +67,68 @@ def run_gate(name: str, command: list[str], cwd: Path) -> dict:
52
67
  }
53
68
 
54
69
 
70
+ def load_release_profile(project: Path, requested: str | None) -> tuple[dict, dict]:
71
+ """Load an explicit project capability manifest without guessing scope."""
72
+ path = Path(requested).resolve() if requested else project / ".maggie" / "release-profile.json"
73
+ errors: list[str] = []
74
+ profile: dict = {}
75
+ try:
76
+ profile = json.loads(path.read_text(encoding="utf-8"))
77
+ except (OSError, json.JSONDecodeError) as exc:
78
+ errors.append(f"release profile {path}: {exc}")
79
+ if not isinstance(profile, dict):
80
+ errors.append("release profile must be a JSON object")
81
+ profile = {}
82
+ if profile.get("schemaVersion") != PROFILE_SCHEMA:
83
+ errors.append(f"schemaVersion must be {PROFILE_SCHEMA}")
84
+ name = profile.get("profile")
85
+ if not isinstance(name, str) or not re.fullmatch(r"[a-z][a-z0-9-]{1,63}", name):
86
+ errors.append("profile must be a safe non-empty identifier")
87
+ capabilities = profile.get("capabilities")
88
+ if not isinstance(capabilities, list) or not capabilities:
89
+ errors.append("capabilities must be a non-empty array")
90
+ capabilities = []
91
+ elif len(set(capabilities)) != len(capabilities):
92
+ errors.append("capabilities must not contain duplicates")
93
+ unknown = sorted(set(capabilities) - PROFILE_CAPABILITIES) if isinstance(capabilities, list) else []
94
+ if unknown:
95
+ errors.append("unsupported capabilities: " + ", ".join(unknown))
96
+ if "deployment" not in capabilities:
97
+ errors.append("capabilities must include deployment for a release")
98
+ valid = not errors
99
+ return (
100
+ {
101
+ "path": str(path),
102
+ "profile": name if isinstance(name, str) else None,
103
+ "projectType": profile.get("projectType"),
104
+ "capabilities": capabilities if isinstance(capabilities, list) else [],
105
+ "valid": valid,
106
+ "errors": errors,
107
+ },
108
+ profile if valid else {},
109
+ )
110
+
111
+
112
+ def profile_gate(profile: dict) -> dict:
113
+ """Return the explicit profile validation as a normal release gate."""
114
+ return {
115
+ "name": "release-profile",
116
+ "passed": profile["valid"],
117
+ "exitCode": 0 if profile["valid"] else 1,
118
+ "result": {
119
+ "passed": profile["valid"],
120
+ "schemaVersion": PROFILE_SCHEMA if profile["valid"] else None,
121
+ "profile": profile.get("profile"),
122
+ "projectType": profile.get("projectType"),
123
+ "capabilities": profile.get("capabilities", []),
124
+ "path": profile.get("path"),
125
+ "errors": profile.get("errors", []),
126
+ "mutation": "not executed",
127
+ },
128
+ "stderr": "",
129
+ }
130
+
131
+
55
132
  def build_gate(project: Path) -> dict:
56
133
  """Run the project's declared typecheck and build commands."""
57
134
  package_path = project / "package.json"
@@ -84,19 +161,23 @@ def build_gate(project: Path) -> dict:
84
161
  }
85
162
 
86
163
 
87
- def build_gates(project: Path, environment: str, target: str, include_compatibility: bool) -> list[tuple[str, list[str]]]:
164
+ def build_gates(project: Path, environment: str, target: str, capabilities: set[str]) -> list[tuple[str, list[str]]]:
88
165
  python = sys.executable
89
- gates: list[tuple[str, list[str]]] = [
90
- ("deployment-preflight", [python, str(ROOT / "maggie_deployment.py"), str(project), "--target", target, "--environment", environment]),
91
- ("migration-preflight", [python, str(ROOT / "maggie_migration.py"), "--project", str(project), "--environment", environment]),
92
- ("maggiedash-health", [python, str(ROOT / "maggie_health.py"), "--project", str(project), "--environment", environment]),
93
- ("schedule-manifest", [python, str(ROOT / "maggie_schedule.py"), str(project / ".maggie" / "schedule.json"), "--project", str(project)]),
94
- ("analytics-contract", [python, str(ROOT / "maggie_analytics.py"), "--project", str(project), "--environment", environment]),
95
- ("service-facts", [python, str(ROOT / "maggie_service_booking.py"), "fact-audit", "--project", str(project)]),
96
- ]
97
- if include_compatibility:
98
- gates.append(("maggiedash-schema", [python, str(ROOT / "maggie_migration.py"), "--project", str(project), "--environment", environment]))
99
- return gates
166
+ deployment_command = [python, str(ROOT / "maggie_deployment.py"), str(project), "--target", target, "--environment", environment]
167
+ if target in {"vps", "vps-with-cloudflare-dns", "detected"}:
168
+ deployment_command.append("--require-deployer")
169
+ commands = {
170
+ "deployment": ("deployment-preflight", deployment_command),
171
+ "migration": ("migration-preflight", [python, str(ROOT / "maggie_migration.py"), "--project", str(project), "--environment", environment]),
172
+ "maggiedash-health": ("maggiedash-health", [python, str(ROOT / "maggie_health.py"), "--project", str(project), "--environment", environment]),
173
+ "schedule": ("schedule-manifest", [python, str(ROOT / "maggie_schedule.py"), str(project / ".maggie" / "schedule.json"), "--project", str(project)]),
174
+ "analytics-contract": ("analytics-contract", [python, str(ROOT / "maggie_analytics.py"), "--project", str(project), "--environment", environment]),
175
+ "service-facts": ("service-facts", [python, str(ROOT / "maggie_service_booking.py"), "fact-audit", "--project", str(project)]),
176
+ "maggiedash-schema": ("maggiedash-schema", [python, str(ROOT / "maggie_migration.py"), "--project", str(project), "--environment", environment]),
177
+ }
178
+ return [commands[capability] for capability in (
179
+ "deployment", "migration", "maggiedash-health", "schedule", "analytics-contract", "service-facts", "maggiedash-schema"
180
+ ) if capability in capabilities]
100
181
 
101
182
 
102
183
  def evidence_gate(project: Path, environment: str) -> dict:
@@ -371,6 +452,7 @@ def main() -> int:
371
452
  parser.add_argument("project", nargs="?", default=".")
372
453
  parser.add_argument("--environment", choices=("staging", "production"), default="staging")
373
454
  parser.add_argument("--target", choices=("vps", "vps-with-cloudflare-dns", "cloudflare", "detected"), default="vps-with-cloudflare-dns")
455
+ parser.add_argument("--profile", help="explicit release capability manifest; defaults to .maggie/release-profile.json")
374
456
  parser.add_argument("--output", help="release evidence JSON path")
375
457
  parser.add_argument("--skip-compatibility", action="store_true", help="skip the optional schema rehearsal; not recommended for release")
376
458
  parser.add_argument("--base-url", help="optional live/staging URL for homepage, admin redirect and 404 smoke")
@@ -385,14 +467,24 @@ def main() -> int:
385
467
  if not project.is_dir():
386
468
  parser.error(f"project directory does not exist: {project}")
387
469
 
388
- gates = []
470
+ profile, _profile_payload = load_release_profile(project, args.profile)
471
+ capabilities = set(profile.get("capabilities", [])) if profile["valid"] else set()
472
+ if args.skip_compatibility:
473
+ capabilities.discard("maggiedash-schema")
474
+ gates = [profile_gate(profile)]
389
475
  gates.append(changed_surface_gate(project))
390
- gates.append(qa_gate(project, args.environment, args.base_url))
476
+ if "scenario-qa" in capabilities:
477
+ gates.append(qa_gate(project, args.environment, args.base_url))
478
+ else:
479
+ gates.append({"name": "scenario-qa", "passed": True, "exitCode": 0, "result": {"passed": True, "state": "skipped", "reason": "scenario-qa is not declared by the release profile"}, "stderr": ""})
391
480
  gates.append(build_gate(project))
392
- gates.append(social_card_gate(project))
393
- for name, command in build_gates(project, args.environment, args.target, not args.skip_compatibility):
481
+ if "social-card-audit" in capabilities:
482
+ gates.append(social_card_gate(project))
483
+ else:
484
+ gates.append({"name": "social-card-audit", "passed": True, "exitCode": 0, "result": {"passed": True, "state": "skipped", "reason": "social-card-audit is not declared by the release profile"}, "stderr": ""})
485
+ for name, command in build_gates(project, args.environment, args.target, capabilities):
394
486
  gates.append(run_gate(name, command, project))
395
- if args.analytics_release_gate:
487
+ if args.analytics_release_gate or "analytics-release-gate" in capabilities:
396
488
  evidence = {
397
489
  "--contract": args.analytics_contract,
398
490
  "--render-report": args.analytics_render_report,
@@ -407,8 +499,14 @@ def main() -> int:
407
499
  for name, value in evidence.items():
408
500
  analytics_command.extend([name, value])
409
501
  gates.append(run_gate("analytics-release-gate", analytics_command, project))
410
- gates.append(editorial_gate(project))
411
- gates.append(evidence_gate(project, args.environment))
502
+ if "editorial-review" in capabilities:
503
+ gates.append(editorial_gate(project))
504
+ else:
505
+ gates.append({"name": "editorial-review", "passed": True, "exitCode": 0, "result": {"passed": True, "state": "skipped", "reason": "editorial-review is not declared by the release profile"}, "stderr": ""})
506
+ if "durable-site-evidence" in capabilities:
507
+ gates.append(evidence_gate(project, args.environment))
508
+ else:
509
+ gates.append({"name": "durable-site-evidence", "passed": True, "exitCode": 0, "result": {"passed": True, "state": "skipped", "reason": "durable-site-evidence is not declared by the release profile"}, "stderr": ""})
412
510
  if args.base_url:
413
511
  gates.append(runtime_smoke(args.base_url))
414
512
 
@@ -418,6 +516,13 @@ def main() -> int:
418
516
  "project": str(project),
419
517
  "environment": args.environment,
420
518
  "target": args.target,
519
+ "profile": {
520
+ "path": profile["path"],
521
+ "name": profile.get("profile"),
522
+ "projectType": profile.get("projectType"),
523
+ "capabilities": profile.get("capabilities", []),
524
+ "valid": profile["valid"],
525
+ },
421
526
  "mutation": "not executed",
422
527
  "passed": all(gate["passed"] for gate in gates),
423
528
  "gates": gates,
@@ -0,0 +1,143 @@
1
+ """Detect whether a configured browser adapter can run Maggie audits."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ from pathlib import Path
10
+
11
+
12
+ SCHEMA_VERSION = "maggie-browser-capability.v1"
13
+ REQUIRED_COMMANDS = ("goto", "viewport", "screenshot", "js", "eval")
14
+ INTERACTION_COMMANDS = ("click", "fill", "type", "press", "wait", "is")
15
+ HELP_TIMEOUT_SECONDS = 5
16
+
17
+
18
+ def _safe_name(value: object) -> str:
19
+ text = Path(str(value or "")).name
20
+ return text if text and text not in {".", ".."} else "unknown"
21
+
22
+
23
+ def _path_fingerprint(value: str) -> str:
24
+ return "sha256:" + hashlib.sha256(value.encode("utf-8")).hexdigest()
25
+
26
+
27
+ def _guidance(status: str) -> list[str]:
28
+ if status == "missing":
29
+ return [
30
+ "Install or enable an authorized Chrome/Playwright/gstack browser adapter.",
31
+ "Pass its executable path explicitly with --browse <adapter-path>.",
32
+ "If using Playwright locally, install the browser runtime with: npx playwright install chromium.",
33
+ ]
34
+ return [
35
+ "Pass the executable for the intended browser adapter, not a desktop URL/file opener.",
36
+ "The adapter must expose --help and support goto, viewport, screenshot, js, and eval.",
37
+ "If using gstack, pass the absolute path to gstack browse/dist/browse.",
38
+ ]
39
+
40
+
41
+ def discover_browser_adapter(requested: str | Path) -> dict:
42
+ """Return a redacted, actionable capability report without opening a page."""
43
+ requested_text = str(requested or "").strip()
44
+ requested_name = _safe_name(requested_text)
45
+ report = {
46
+ "schemaVersion": SCHEMA_VERSION,
47
+ "adapter": {
48
+ "requested": requested_name,
49
+ "requestedPathFingerprint": _path_fingerprint(requested_text) if requested_text else None,
50
+ },
51
+ "status": "missing",
52
+ "checks": {
53
+ "resolved": False,
54
+ "regularFile": False,
55
+ "executable": False,
56
+ "help": False,
57
+ "requiredCommands": False,
58
+ "interactionCommands": False,
59
+ },
60
+ "requiredCommands": list(REQUIRED_COMMANDS),
61
+ "fallback": _guidance("missing"),
62
+ }
63
+ if not requested_text:
64
+ report["errorCode"] = "adapter-not-found"
65
+ report["message"] = "No browser adapter was provided."
66
+ return report
67
+
68
+ resolved = shutil.which(requested_text)
69
+ if resolved is None:
70
+ candidate = Path(requested_text).expanduser()
71
+ if candidate.exists():
72
+ resolved = str(candidate.resolve())
73
+ if not resolved:
74
+ report["errorCode"] = "adapter-not-found"
75
+ report["message"] = "The requested browser adapter could not be resolved."
76
+ return report
77
+
78
+ path = Path(resolved)
79
+ report["checks"]["resolved"] = True
80
+ report["adapter"]["name"] = _safe_name(path)
81
+ report["adapter"]["resolvedPathFingerprint"] = _path_fingerprint(str(path.resolve()))
82
+ if not path.is_file():
83
+ report["errorCode"] = "adapter-not-file"
84
+ report["message"] = "The resolved browser adapter is not a regular executable file."
85
+ report["fallback"] = _guidance("incompatible")
86
+ report["status"] = "incompatible"
87
+ return report
88
+ report["checks"]["regularFile"] = True
89
+ if not os.access(path, os.X_OK):
90
+ report["errorCode"] = "adapter-not-executable"
91
+ report["message"] = "The resolved browser adapter is not executable."
92
+ report["fallback"] = _guidance("incompatible")
93
+ report["status"] = "incompatible"
94
+ return report
95
+ report["checks"]["executable"] = True
96
+ try:
97
+ completed = subprocess.run(
98
+ [str(path), "--help"],
99
+ capture_output=True,
100
+ text=True,
101
+ timeout=HELP_TIMEOUT_SECONDS,
102
+ check=False,
103
+ )
104
+ except (OSError, subprocess.TimeoutExpired):
105
+ completed = None
106
+ help_text = "" if completed is None else (completed.stdout + "\n" + completed.stderr).lower()
107
+ if completed is None or completed.returncode != 0 or not help_text:
108
+ report["errorCode"] = "adapter-help-failed"
109
+ report["message"] = "The browser adapter did not provide a usable --help response."
110
+ report["fallback"] = _guidance("incompatible")
111
+ report["status"] = "incompatible"
112
+ return report
113
+ report["checks"]["help"] = True
114
+ missing = [command for command in REQUIRED_COMMANDS if command not in help_text]
115
+ report["missingCommands"] = missing
116
+ if missing:
117
+ report["errorCode"] = "adapter-command-contract-mismatch"
118
+ report["message"] = "The browser adapter does not expose Maggie's required command contract."
119
+ report["fallback"] = _guidance("incompatible")
120
+ report["status"] = "incompatible"
121
+ return report
122
+ report["checks"]["requiredCommands"] = True
123
+ report["checks"]["interactionCommands"] = all(command in help_text for command in INTERACTION_COMMANDS)
124
+ if not report["checks"]["interactionCommands"]:
125
+ report["errorCode"] = "adapter-interaction-contract-mismatch"
126
+ report["message"] = "The browser adapter can audit pages but cannot replay the interaction contract."
127
+ report["fallback"] = _guidance("incompatible")
128
+ report["status"] = "incompatible"
129
+ return report
130
+ report["status"] = "ready"
131
+ report["message"] = "Browser adapter capability preflight passed."
132
+ report["fallback"] = []
133
+ return report
134
+
135
+
136
+ def require_browser_adapter(requested: str | Path) -> dict:
137
+ report = discover_browser_adapter(requested)
138
+ if report["status"] != "ready":
139
+ code = report.get("errorCode", "adapter-unavailable")
140
+ message = report.get("message", "Browser adapter is unavailable.")
141
+ guidance = " ".join(report.get("fallback", []))
142
+ raise ValueError(f"{code}: {message} {guidance}".strip())
143
+ return report