@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.
- package/README-zh-TW.md +15 -2
- package/README.md +55 -3
- package/bin/maggie.js +22 -4
- package/bundled-contracts/google-integrations/capability-report-v2.schema.json +76 -0
- package/bundled-contracts/google-integrations/external-write-readback-v1.schema.json +36 -0
- package/bundled-contracts/maggie-deployment/deployer-delegation-v1.schema.json +20 -0
- package/bundled-contracts/maggie-deployment/release-profile-v1.schema.json +34 -0
- package/bundled-contracts/maggie-design/browser-capability-v1.schema.json +33 -0
- package/bundled-contracts/maggie-feedback/evidence-bundle-v1.schema.json +38 -0
- package/bundled-references/browser-inspection.md +17 -0
- package/bundled-references/google-integrations-runbook.md +8 -1
- package/bundled-references/memory-hook.md +18 -5
- package/bundled-skills/maggie-clone/SKILL.md +7 -0
- package/bundled-skills/maggie-deployment/SKILL.md +55 -15
- package/bundled-skills/maggie-feedback/SKILL.md +26 -1
- package/bundled-skills/maggie-memory/SKILL.md +10 -1
- package/bundled-skills/maggie-ops/SKILL.md +22 -0
- package/bundled-skills/maggie-seo-geo/SKILL.md +4 -1
- package/bundled-tools/clis/maggie_analytics.py +8 -0
- package/bundled-tools/clis/maggie_browser_audit.py +13 -2
- package/bundled-tools/clis/maggie_deployment.py +214 -6
- package/bundled-tools/clis/maggie_feedback.py +116 -1
- package/bundled-tools/clis/maggie_memory.py +10 -3
- package/bundled-tools/clis/maggie_ops.py +33 -0
- package/bundled-tools/clis/maggie_release.py +124 -19
- package/bundled-tools/runtime/browser_capability.py +143 -0
- package/bundled-tools/runtime/external_write.py +122 -0
- package/bundled-tools/runtime/google_capabilities.py +37 -5
- package/bundled-tools/runtime/maggie_memory.py +17 -2
- package/package.json +1 -1
- package/references/browser-inspection.md +17 -0
- package/references/google-integrations-runbook.md +8 -1
- 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
|
-
|
|
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")
|
|
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,
|
|
164
|
+
def build_gates(project: Path, environment: str, target: str, capabilities: set[str]) -> list[tuple[str, list[str]]]:
|
|
88
165
|
python = sys.executable
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
("
|
|
95
|
-
("
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
393
|
-
|
|
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
|
-
|
|
411
|
-
|
|
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
|