@topy-ai/maggie 0.7.44 → 0.7.46

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 (39) hide show
  1. package/README-zh-TW.md +29 -6
  2. package/README.md +86 -12
  3. package/bin/maggie.js +207 -58
  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-skills/maggie-blog-bootstrap/SKILL.md +7 -7
  13. package/bundled-skills/maggie-clone/SKILL.md +21 -14
  14. package/bundled-skills/maggie-clone-to-template/SKILL.md +11 -11
  15. package/bundled-skills/maggie-clone-to-template/references/workflow.md +2 -2
  16. package/bundled-skills/maggie-content-localization/SKILL.md +10 -10
  17. package/bundled-skills/maggie-deployment/SKILL.md +57 -23
  18. package/bundled-skills/maggie-deployment/references/vps.md +4 -4
  19. package/bundled-skills/maggie-design/SKILL.md +12 -12
  20. package/bundled-skills/maggie-feedback/SKILL.md +23 -0
  21. package/bundled-skills/maggie-marketplace/SKILL.md +12 -12
  22. package/bundled-skills/maggie-ops/SKILL.md +28 -6
  23. package/bundled-skills/maggie-project-context/SKILL.md +1 -1
  24. package/bundled-skills/maggie-seo-geo/SKILL.md +6 -3
  25. package/bundled-skills/maggie-service-booking/SKILL.md +24 -24
  26. package/bundled-skills/maggie-social-share/SKILL.md +2 -2
  27. package/bundled-skills/maggie-template/SKILL.md +5 -5
  28. package/bundled-tools/clis/maggie_analytics.py +8 -0
  29. package/bundled-tools/clis/maggie_browser_audit.py +13 -2
  30. package/bundled-tools/clis/maggie_deployment.py +167 -6
  31. package/bundled-tools/clis/maggie_feedback.py +115 -1
  32. package/bundled-tools/clis/maggie_ops.py +33 -0
  33. package/bundled-tools/clis/maggie_release.py +124 -19
  34. package/bundled-tools/runtime/browser_capability.py +143 -0
  35. package/bundled-tools/runtime/external_write.py +122 -0
  36. package/bundled-tools/runtime/google_capabilities.py +37 -5
  37. package/package.json +1 -1
  38. package/references/browser-inspection.md +17 -0
  39. package/references/google-integrations-runbook.md +8 -1
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import argparse
6
6
  import json
7
7
  import re
8
+ import shlex
8
9
  import subprocess
9
10
  import sys
10
11
  from datetime import datetime, timezone
@@ -181,7 +182,103 @@ def worker_secret_preflight_main(argv: list[str]) -> int:
181
182
  return 0 if result["passed"] else 1
182
183
 
183
184
 
184
- def preflight(project: Path, target: str, environment: str) -> dict:
185
+ def deployer_delegation_preflight(
186
+ host: str,
187
+ user: str,
188
+ release_root: str,
189
+ service: str,
190
+ ssh_command: list[str] | None = None,
191
+ ) -> dict:
192
+ """Read-only SSH check for release-directory and systemd delegation."""
193
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,253}", host):
194
+ raise ValueError("deployer host must be a hostname, address, or IPv6-safe token")
195
+ if not re.fullmatch(r"[a-z_][a-z0-9_-]{0,31}\$?", user):
196
+ raise ValueError("deployer user must be a safe local account name")
197
+ if not release_root.startswith("/") or "\n" in release_root or "\r" in release_root:
198
+ raise ValueError("release root must be an absolute path without newlines")
199
+ if not re.fullmatch(r"[A-Za-z0-9_.@-]+", service):
200
+ raise ValueError("service must contain only safe systemd name characters")
201
+
202
+ root = release_root.rstrip("/") or "/"
203
+ releases = f"{root}/releases" if root != "/" else "/releases"
204
+ remote_script = "\n".join((
205
+ "set -eu",
206
+ f"test -d {shlex.quote(root)} && test -w {shlex.quote(root)}",
207
+ f"test -d {shlex.quote(releases)} && test -w {shlex.quote(releases)}",
208
+ f"sudo -n -l /bin/systemctl restart {shlex.quote(service)} >/dev/null 2>&1",
209
+ f"sudo -n -l /bin/systemctl is-active {shlex.quote(service)} >/dev/null 2>&1",
210
+ )) + "\n"
211
+ command = list(ssh_command or ["ssh"])
212
+ command.extend(["-o", "BatchMode=yes", "-o", "ConnectTimeout=8", f"{user}@{host}", "sh", "-s"])
213
+ try:
214
+ completed = subprocess.run(command, input=remote_script, capture_output=True, text=True, check=False, timeout=20)
215
+ except (OSError, subprocess.SubprocessError) as error:
216
+ return {
217
+ "schemaVersion": "maggie-deployment-delegation.v1",
218
+ "host": host,
219
+ "user": user,
220
+ "releaseRoot": root,
221
+ "service": service,
222
+ "state": "transient",
223
+ "passed": False,
224
+ "checks": {},
225
+ "errors": [f"SSH delegation check could not start: {type(error).__name__}"],
226
+ "mutation": "not executed",
227
+ }
228
+ stderr = (completed.stderr or "").lower()
229
+ transient = completed.returncode in {124, 255} or any(marker in stderr for marker in (
230
+ "timed out", "connection refused", "connection reset", "no route", "could not resolve", "temporary failure",
231
+ ))
232
+ state = "passed" if completed.returncode == 0 else "transient" if transient else "failed"
233
+ return {
234
+ "schemaVersion": "maggie-deployment-delegation.v1",
235
+ "host": host,
236
+ "user": user,
237
+ "releaseRoot": root,
238
+ "service": service,
239
+ "state": state,
240
+ "passed": completed.returncode == 0,
241
+ "checks": {
242
+ "sshConnection": completed.returncode == 0,
243
+ "releaseRootWritable": completed.returncode == 0,
244
+ "releasesDirectoryWritable": completed.returncode == 0,
245
+ "sudoSystemctlRestart": completed.returncode == 0,
246
+ "sudoSystemctlStatus": completed.returncode == 0,
247
+ },
248
+ "errors": [] if completed.returncode == 0 else [
249
+ "deploy-user delegation check is transient; retry after SSH/host readiness" if transient
250
+ else "deploy-user cannot write the release directory or use the exact systemd allowlist"
251
+ ],
252
+ "mutation": "not executed",
253
+ }
254
+
255
+
256
+ def validate_deployer_delegation(value: object, expected_root: str | None = None, expected_service: str | None = None) -> dict:
257
+ if not isinstance(value, dict):
258
+ return {"passed": False, "state": "failed", "errors": ["delegation evidence must be an object"]}
259
+ errors: list[str] = []
260
+ if value.get("schemaVersion") != "maggie-deployment-delegation.v1":
261
+ errors.append("delegation evidence schemaVersion is unsupported")
262
+ if value.get("state") != "passed" or value.get("passed") is not True:
263
+ errors.append("deploy-user delegation evidence is not passed")
264
+ if expected_root and value.get("releaseRoot") != expected_root.rstrip("/"):
265
+ errors.append("delegation releaseRoot does not match the deployment plan")
266
+ if expected_service and value.get("service") != expected_service:
267
+ errors.append("delegation service does not match the deployment plan")
268
+ checks = value.get("checks") if isinstance(value.get("checks"), dict) else {}
269
+ for name in ("sshConnection", "releaseRootWritable", "releasesDirectoryWritable", "sudoSystemctlRestart", "sudoSystemctlStatus"):
270
+ if checks.get(name) is not True:
271
+ errors.append(f"delegation check is not passed: {name}")
272
+ return {"passed": not errors, "state": "passed" if not errors else "failed", "errors": errors}
273
+
274
+
275
+ def preflight(
276
+ project: Path,
277
+ target: str,
278
+ environment: str,
279
+ require_deployer: bool = False,
280
+ delegation_evidence: Path | None = None,
281
+ ) -> dict:
185
282
  package_path = project / "package.json"
186
283
  package = json.loads(package_path.read_text(encoding="utf-8")) if package_path.exists() else {}
187
284
  scripts = package.get("scripts") or {}
@@ -240,10 +337,24 @@ def preflight(project: Path, target: str, environment: str) -> dict:
240
337
  data_required = bool(migration.get("dataDependencies"))
241
338
  data_checkpoint = validate_data_checkpoint(data_path) if data_path.exists() else {"passed": not data_required, "declared": False, "errors": ["data-release.json is required for a data-dependent release"] if data_required else []}
242
339
  retention_ok = bool(plan.get("retention", {}).get("keep") in (2, 3, 4, 5) and plan.get("retention", {}).get("prune") and plan.get("retention", {}).get("preserve"))
243
- result["checks"].update({"node_runtime_declared": bool(package.get("engines", {}).get("node") or package.get("dependencies", {}).get("astro")), "start_command_or_runtime": "start" in scripts or "preview" in scripts, "stateless_or_migration_plan": True, "release_layout_declared": True, "rollback_command_declared": True, "release_retention_policy": retention_ok, "data_release_checkpoint": data_checkpoint["passed"], "remote_verification_pending": True, "release_plan_artifact": bool(plan_path and plan.get("release_layout") and plan.get("current_release")), "systemd_artifact": bool(systemd_text and "ExecStart=" in systemd_text and "EnvironmentFile=" in systemd_text and "NoNewPrivileges=true" in systemd_text), "nginx_artifact": bool(nginx_text and "proxy_pass" in nginx_text and "server_name" in nginx_text), "deployment_artifacts_secret_free": secret_free, "rollback_evidence": rollback_passed if environment == "production" else True})
340
+ delegation = {"passed": True, "state": "not-required", "errors": []}
341
+ delegation_path = delegation_evidence or project / ".maggie" / "deployment" / "deployer-delegation.json"
342
+ if require_deployer:
343
+ try:
344
+ delegation_value = json.loads(delegation_path.read_text(encoding="utf-8"))
345
+ except (OSError, json.JSONDecodeError) as error:
346
+ delegation_value = {}
347
+ delegation = {"passed": False, "state": "inconclusive", "errors": [f"delegation evidence is unavailable: {error}"]}
348
+ else:
349
+ delegation = validate_deployer_delegation(delegation_value, plan.get("release_root"), plan.get("service"))
350
+ delegation["path"] = str(delegation_path)
351
+ result["checks"].update({"node_runtime_declared": bool(package.get("engines", {}).get("node") or package.get("dependencies", {}).get("astro")), "start_command_or_runtime": "start" in scripts or "preview" in scripts, "stateless_or_migration_plan": True, "release_layout_declared": True, "rollback_command_declared": True, "release_retention_policy": retention_ok, "data_release_checkpoint": data_checkpoint["passed"], "remote_verification_pending": not delegation["passed"], "deployer_delegation": delegation["passed"], "release_plan_artifact": bool(plan_path and plan.get("release_layout") and plan.get("current_release")), "systemd_artifact": bool(systemd_text and "ExecStart=" in systemd_text and "EnvironmentFile=" in systemd_text and "NoNewPrivileges=true" in systemd_text), "nginx_artifact": bool(nginx_text and "proxy_pass" in nginx_text and "server_name" in nginx_text), "deployment_artifacts_secret_free": secret_free, "rollback_evidence": rollback_passed if environment == "production" else True})
352
+ result["delegation"] = delegation
244
353
  result["vps"] = {"runtime":"Node.js standalone","reverseProxy":"Nginx","processManager":"systemd","releaseLayout":"/var/www/<site>/releases/<release> + current symlink","dns":"Cloudflare DNS or registrar DNS; verify apex and www separately","remoteCommands":["npm ci","npm run build","systemctl restart <service>","systemctl is-active <service>","curl -fsS https://<domain>/robots.txt","curl -fsS https://<domain>/sitemap.xml"],"handoverFields":["host","sshUser","domain","service","release","previousRelease","nginxConfig","migrationVersion","verification","rollback"]}
245
354
  result["next_action"] = "review VPS host, SSH user, domain/DNS, systemd service, Nginx config, release path, migrations, and rollback owner before execute"
246
355
  result["blocking_checks"] = ["package_manifest", "build_command", "env_example", "node_runtime_declared", "start_command_or_runtime", "stateless_or_migration_plan", "release_layout_declared", "rollback_command_declared", "release_retention_policy", "data_release_checkpoint", "release_plan_artifact", "systemd_artifact", "nginx_artifact", "deployment_artifacts_secret_free", "rollback_evidence"]
356
+ if require_deployer:
357
+ result["blocking_checks"].append("deployer_delegation")
247
358
  else:
248
359
  result["blocking_checks"] = ["package_manifest", "build_command", "env_example"]
249
360
  return result
@@ -471,9 +582,38 @@ if [ -d "$CURRENT/.claude" ]; then cp -a "$CURRENT/.claude" "$RELEASE/.claude";
471
582
 
472
583
  ln -sfn "$RELEASE" "$CURRENT"
473
584
  sudo -n /bin/systemctl restart "{service}"
474
- sudo -n /bin/systemctl is-active --quiet "{service}"
475
- curl -fsS "https://{domain}/robots.txt" >/dev/null
476
- curl -fsS "https://{domain}/sitemap.xml" >/dev/null
585
+
586
+ # A restarted service can be healthy at the origin before Nginx/Cloudflare is
587
+ # ready. Retry only bounded transient states; fail immediately on permanent
588
+ # HTTP responses so an outage is not hidden as a startup delay.
589
+ wait_for_active() {{
590
+ local attempt
591
+ for attempt in $(seq 1 12); do
592
+ if sudo -n /bin/systemctl is-active --quiet "{service}"; then return 0; fi
593
+ if [ "$attempt" -lt 12 ]; then sleep 2; fi
594
+ done
595
+ echo "permanent service readiness failure: {service}" >&2
596
+ return 1
597
+ }}
598
+ wait_for_http() {{
599
+ local url="$1" attempt status
600
+ for attempt in $(seq 1 12); do
601
+ status="$(curl --silent --show-error --output /dev/null --write-out '%{{http_code}}' "$url" 2>/dev/null || printf '000')"
602
+ case "$status" in
603
+ 2??|3??) return 0 ;;
604
+ 000|502|503|504)
605
+ if [ "$attempt" -lt 12 ]; then sleep 2; continue; fi
606
+ echo "transient readiness exhausted: $url returned $status" >&2
607
+ return 1 ;;
608
+ *)
609
+ echo "permanent readiness failure: $url returned $status" >&2
610
+ return 1 ;;
611
+ esac
612
+ done
613
+ }}
614
+ wait_for_active
615
+ wait_for_http "https://{domain}/robots.txt"
616
+ wait_for_http "https://{domain}/sitemap.xml"
477
617
 
478
618
  # Keep current plus one rollback candidate, and prune only after health checks.
479
619
  mapfile -t RELEASES < <(find "$RELEASE_ROOT/releases" -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\\n' | sort -nr | cut -d' ' -f2-)
@@ -507,6 +647,10 @@ def main() -> int:
507
647
  parser.add_argument("--release-root", default="/var/www/maggie-site", help="immutable release root")
508
648
  parser.add_argument("--node-port", type=int, required=False, default=None, help="unused host port; required with --vps-plan")
509
649
  parser.add_argument("--deployer-user", default="maggie-deploy", help="dedicated least-privilege SSH/deploy account")
650
+ parser.add_argument("--deployer-host", help="SSH host for --verify-deployer")
651
+ parser.add_argument("--verify-deployer", action="store_true", help="run a read-only SSH delegation check before writing VPS artifacts")
652
+ parser.add_argument("--require-deployer", action="store_true", help="require passed delegation evidence during deployment preflight")
653
+ parser.add_argument("--deployer-evidence", help="delegation evidence JSON; defaults to .maggie/deployment/deployer-delegation.json")
510
654
  parser.add_argument("--plan-dir", help="directory for generated VPS artifacts")
511
655
  parser.add_argument("--runner-output", help="write a reviewable ordered VPS release runner")
512
656
  parser.add_argument("--retention-plan", action="store_true", help="create a read-only release prune candidate plan")
@@ -521,6 +665,17 @@ def main() -> int:
521
665
  parser.add_argument("--has-auth", action="store_true")
522
666
  args = parser.parse_args()
523
667
  try:
668
+ if args.verify_deployer:
669
+ if not args.deployer_host:
670
+ parser.error("--deployer-host is required with --verify-deployer")
671
+ delegation = deployer_delegation_preflight(args.deployer_host, args.deployer_user, args.release_root, args.service)
672
+ if args.output:
673
+ output = Path(args.output).resolve()
674
+ output.parent.mkdir(parents=True, exist_ok=True)
675
+ output.write_text(json.dumps(delegation, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
676
+ print(json.dumps(delegation, indent=2, ensure_ascii=False))
677
+ if not delegation["passed"]:
678
+ return 1
524
679
  if args.vps_plan:
525
680
  if not args.domain:
526
681
  parser.error("--domain is required with --vps-plan")
@@ -549,7 +704,13 @@ def main() -> int:
549
704
  result = verify_infrastructure(Path(args.units_dir).resolve() if args.units_dir else None, args.service, args.timer)
550
705
  print(json.dumps(result, indent=2, ensure_ascii=False))
551
706
  return 0 if result["passed"] else 1
552
- result = preflight(Path(args.project).resolve(), args.target, args.environment)
707
+ result = preflight(
708
+ Path(args.project).resolve(),
709
+ args.target,
710
+ args.environment,
711
+ args.require_deployer,
712
+ Path(args.deployer_evidence).expanduser().resolve() if args.deployer_evidence else None,
713
+ )
553
714
  if args.output:
554
715
  Path(args.output).parent.mkdir(parents=True, exist_ok=True)
555
716
  Path(args.output).write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
@@ -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)
@@ -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")