@topy-ai/maggie 0.6.9 → 0.7.1

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 (46) hide show
  1. package/README.md +56 -4
  2. package/bin/maggie.js +11 -7
  3. package/bundled-references/blog-translation-ingestion.md +52 -0
  4. package/bundled-references/maggiedash-dashboard-ui.md +28 -0
  5. package/bundled-skills/maggie-blog/SKILL.md +28 -0
  6. package/bundled-skills/maggie-content-localization/SKILL.md +49 -0
  7. package/bundled-skills/maggie-dash/SKILL.md +52 -0
  8. package/bundled-skills/maggie-deployment/SKILL.md +17 -0
  9. package/bundled-skills/maggie-design/SKILL.md +11 -0
  10. package/bundled-skills/maggie-memory/SKILL.md +7 -0
  11. package/bundled-skills/maggie-ops/SKILL.md +27 -0
  12. package/bundled-skills/maggie-seo-geo/SKILL.md +95 -1
  13. package/bundled-skills/maggie-service-booking/SKILL.md +16 -0
  14. package/bundled-templates/maggiedash/README.md +4 -0
  15. package/bundled-templates/maggiedash/dashboard-ui-contract.json +31 -0
  16. package/bundled-tools/clis/maggie_analytics.py +14 -1
  17. package/bundled-tools/clis/maggie_blog.py +19 -1
  18. package/bundled-tools/clis/maggie_browser_audit.py +78 -0
  19. package/bundled-tools/clis/maggie_dash.py +101 -0
  20. package/bundled-tools/clis/maggie_deployment.py +43 -0
  21. package/bundled-tools/clis/maggie_feedback.py +16 -1
  22. package/bundled-tools/clis/maggie_localization.py +31 -0
  23. package/bundled-tools/clis/maggie_memory.py +4 -1
  24. package/bundled-tools/clis/maggie_ops.py +21 -1
  25. package/bundled-tools/clis/maggie_service_booking.py +6 -3
  26. package/bundled-tools/clis/maggie_sitemap.py +16 -2
  27. package/bundled-tools/clis/site_audit.py +93 -9
  28. package/bundled-tools/runtime/analytics_traffic.py +30 -0
  29. package/bundled-tools/runtime/browser_behavior.py +35 -0
  30. package/bundled-tools/runtime/browser_geometry.js +31 -0
  31. package/bundled-tools/runtime/content_localization.py +63 -1
  32. package/bundled-tools/runtime/dependency_lock.py +43 -0
  33. package/bundled-tools/runtime/integration_state.py +17 -0
  34. package/bundled-tools/runtime/localization_runner.py +113 -0
  35. package/bundled-tools/runtime/maggie_blog.py +66 -1
  36. package/bundled-tools/runtime/maggie_dash_store.py +150 -6
  37. package/bundled-tools/runtime/maggie_dash_ui.py +60 -0
  38. package/bundled-tools/runtime/maggie_memory.py +7 -2
  39. package/bundled-tools/runtime/maggie_sitemap.py +50 -4
  40. package/bundled-tools/runtime/route_imports.py +51 -0
  41. package/bundled-tools/runtime/seed_evidence.py +25 -0
  42. package/bundled-tools/runtime/service_variants.py +152 -0
  43. package/bundled-tools/runtime/site_baseline.py +60 -0
  44. package/package.json +1 -1
  45. package/references/blog-translation-ingestion.md +52 -0
  46. package/references/maggiedash-dashboard-ui.md +28 -0
@@ -0,0 +1,31 @@
1
+ {
2
+ "schemaVersion": "maggiedash-dashboard-ui.v1",
3
+ "shell": {
4
+ "reference": "users-workspace",
5
+ "navigation": "sidebar-plus-content-tabs",
6
+ "rules": [
7
+ "render one workspace bar per route",
8
+ "render one content tab bar per route",
9
+ "do not duplicate sidebar navigation in page content",
10
+ "do not hide duplicate navigation with CSS"
11
+ ]
12
+ },
13
+ "components": [
14
+ {
15
+ "name": "WorkspaceBar",
16
+ "props": ["workspaceName", "activeSection", "actions", "children"],
17
+ "forbiddenProps": []
18
+ },
19
+ {
20
+ "name": "WorkspaceCard",
21
+ "props": ["title", "children"],
22
+ "forbiddenProps": []
23
+ },
24
+ {
25
+ "name": "ContentTabs",
26
+ "props": ["tabs", "active", "actions", "children"],
27
+ "forbiddenProps": ["title", "description"]
28
+ }
29
+ ],
30
+ "accessibility": ["active tab exposes aria-selected", "actions have accessible names", "workspace navigation has a landmark"]
31
+ }
@@ -10,6 +10,9 @@ import re
10
10
  import subprocess
11
11
  from pathlib import Path
12
12
  from urllib.parse import urlsplit
13
+ import sys
14
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
15
+ from analytics_traffic import audit_events # noqa: E402
13
16
 
14
17
 
15
18
  GA4_ID = re.compile(r"^G-[A-Z0-9]+$", re.I)
@@ -107,7 +110,7 @@ def release_gate(args: argparse.Namespace) -> int:
107
110
 
108
111
  def main() -> int:
109
112
  parser = argparse.ArgumentParser()
110
- parser.add_argument("command", nargs="?", choices=("release-gate",))
113
+ parser.add_argument("command", nargs="?", choices=("release-gate", "traffic-audit"))
111
114
  parser.add_argument("--project", default=".")
112
115
  parser.add_argument("--environment", choices=("development", "staging", "production"), default="staging")
113
116
  parser.add_argument("--env-file", help="optional env file; values are never printed")
@@ -117,12 +120,22 @@ def main() -> int:
117
120
  parser.add_argument("--network-report", help="redacted network evidence for release-gate")
118
121
  parser.add_argument("--provider-report", help="read-only provider evidence for release-gate")
119
122
  parser.add_argument("--smoke-report", help="production smoke evidence for release-gate")
123
+ parser.add_argument("--events", help="redacted JSON array of analytics events for traffic-audit")
120
124
  args = parser.parse_args()
121
125
  if args.command == "release-gate":
122
126
  required = ("contract", "render_report", "network_report", "provider_report", "smoke_report")
123
127
  if any(not getattr(args, name) for name in required):
124
128
  parser.error("release-gate requires --contract, --render-report, --network-report, --provider-report, and --smoke-report")
125
129
  return release_gate(args)
130
+ if args.command == "traffic-audit":
131
+ if not args.events:
132
+ parser.error("traffic-audit requires --events")
133
+ value = json.loads(Path(args.events).read_text(encoding="utf-8"))
134
+ if not isinstance(value, list):
135
+ parser.error("--events must contain a JSON array")
136
+ result = audit_events(value)
137
+ print(json.dumps(result, indent=2, ensure_ascii=False))
138
+ return 0
126
139
  project = Path(args.project).resolve()
127
140
  env = parse_env(Path(args.env_file).resolve()) if args.env_file else parse_env(project / ".env.example")
128
141
  env.update({key: value for key, value in os.environ.items() if key in {"PUBLIC_GA4_MEASUREMENT_ID", "PUBLIC_ANALYTICS_ENABLED", "PUBLIC_ANALYTICS_CONSENT_REQUIRED", "GSC_SITE_URL", "GSC_VERIFICATION_TOKEN"}})
@@ -10,11 +10,19 @@ from pathlib import Path
10
10
 
11
11
  sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
12
12
  from maggie_blog import BlogStore # noqa: E402
13
+ from localization_runner import process_adapter
14
+ from integration_state import integration_state # noqa: E402
13
15
 
14
16
 
15
17
  def main() -> int:
16
18
  parser = argparse.ArgumentParser(prog="maggie blog")
17
19
  sub = parser.add_subparsers(dest="command", required=True)
20
+ translate = sub.add_parser("translate-pending")
21
+ translate.add_argument("--project", type=Path, default=Path.cwd())
22
+ translate.add_argument("--adapter-command", required=True, help="trusted provider JSON argv array")
23
+ translate.add_argument("--timeout", type=int, default=120)
24
+ translate.add_argument("--character-budget", type=int, default=8000)
25
+ translate.add_argument("--confirm", action="store_true")
18
26
  init = sub.add_parser("init"); init.add_argument("--project", type=Path, default=Path.cwd()); init.add_argument("--base-path", default="/our-blogs"); init.add_argument("--posts-per-page", type=int, default=9); init.add_argument("--confirm", action="store_true")
19
27
  inspect = sub.add_parser("inspect"); inspect.add_argument("--project", type=Path, default=Path.cwd())
20
28
  ingest = sub.add_parser("ingest"); ingest.add_argument("--project", type=Path, default=Path.cwd()); ingest.add_argument("--input", type=Path, required=True); ingest.add_argument("--source", default="local"); ingest.add_argument("--confirm", action="store_true")
@@ -22,13 +30,21 @@ def main() -> int:
22
30
  publish = sub.add_parser("publish"); publish.add_argument("--project", type=Path, default=Path.cwd()); publish.add_argument("--slug", required=True); publish.add_argument("--actor", required=True); publish.add_argument("--reason", required=True); publish.add_argument("--confirm", action="store_true")
23
31
  sitemap = sub.add_parser("sitemap"); sitemap.add_argument("--project", type=Path, default=Path.cwd())
24
32
  settings = sub.add_parser("settings"); settings.add_argument("--project", type=Path, default=Path.cwd())
33
+ state = sub.add_parser("integration-state"); state.add_argument("--configured", action="store_true"); state.add_argument("--consent-required", action="store_true"); state.add_argument("--consent", action="store_true"); state.add_argument("--authorized", action="store_true"); state.add_argument("--error")
25
34
  rollback = sub.add_parser("rollback"); rollback.add_argument("--project", type=Path, default=Path.cwd()); rollback.add_argument("--backup"); rollback.add_argument("--confirm", action="store_true")
26
35
  args = parser.parse_args()
36
+ if args.command == "integration-state":
37
+ result = integration_state(configured=args.configured, consent_required=args.consent_required, consent=args.consent, authorized=args.authorized, error=args.error)
38
+ print(json.dumps(result, indent=2, ensure_ascii=False))
39
+ return 0 if result["status"] != "error" else 1
27
40
  store = BlogStore(args.project.resolve())
28
- if args.command in {"init", "ingest", "publish", "rollback"} and not args.confirm:
41
+ if args.command in {"init", "ingest", "publish", "rollback", "translate-pending"} and not args.confirm:
29
42
  print("CONFIRMATION_REQUIRED: rerun with --confirm", file=sys.stderr); return 2
30
43
  try:
31
44
  if args.command == "init": result = store.init(args.base_path, args.posts_per_page)
45
+ elif args.command == "translate-pending":
46
+ adapter = process_adapter(json.loads(args.adapter_command), args.project, args.timeout)
47
+ result = store.translate_pending(adapter, args.character_budget)
32
48
  elif args.command == "inspect": result = {"settings": store.settings(), "posts": store.posts(), "runs": json.loads(store.runs_path.read_text(encoding="utf-8")) if store.runs_path.exists() else []}
33
49
  elif args.command == "ingest":
34
50
  payload = json.loads(args.input.read_text(encoding="utf-8"));
@@ -42,6 +58,8 @@ def main() -> int:
42
58
  except (OSError, ValueError, json.JSONDecodeError) as error:
43
59
  print(f"maggie-blog: {error}", file=sys.stderr); return 1
44
60
  print(json.dumps(result, indent=2, ensure_ascii=False))
61
+ if args.command in {"ingest", "translate-pending"} and result.get("status") == "partial":
62
+ return 1
45
63
  return 0 if args.command != "validate" or result.get("valid") else 1
46
64
 
47
65
 
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env python3
2
+ """Capture real viewport/scroll evidence using an installed gstack browse CLI."""
3
+ import argparse
4
+ import json
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+ from urllib.parse import urlparse
9
+
10
+ RUNTIME = Path(__file__).resolve().parents[1] / "runtime"
11
+ sys.path.insert(0, str(RUNTIME))
12
+ from browser_behavior import validate_samples
13
+ from localization_runner import checkpoint
14
+
15
+
16
+ def audit(args):
17
+ if urlparse(args.url).scheme not in {"http", "https", "file"}:
18
+ raise ValueError("URL must use http, https or file")
19
+ if not args.required:
20
+ raise ValueError("at least one --required selector is necessary")
21
+ output = args.output.resolve()
22
+ output.mkdir(parents=True, exist_ok=True)
23
+ def call(*command):
24
+ result = subprocess.run([str(args.browse), *command], capture_output=True, text=True, timeout=45)
25
+ if result.returncode:
26
+ raise ValueError("browser command failed: " + command[0])
27
+ return result.stdout
28
+ report = {"schemaVersion": "maggie-browser-audit.v1", "url": args.url,
29
+ "passed": False, "viewports": [], "evidence": "browser-captured"}
30
+ try:
31
+ for index, viewport in enumerate(args.viewport or ["390x844", "768x1024", "1440x900"]):
32
+ width, height = [int(value) for value in viewport.split("x")]
33
+ if min(width, height) < 100:
34
+ raise ValueError("viewport dimensions must be at least 100")
35
+ call("viewport", viewport)
36
+ call("goto", args.url)
37
+ selectors = list(dict.fromkeys(args.required + args.sticky))
38
+ call("js", "window.__maggieAuditSelectors=" + json.dumps(selectors))
39
+ samples = []
40
+ for step, fraction in enumerate([0, 0.5, 0.9]):
41
+ call("js", f"window.scrollTo(0, (document.documentElement.scrollHeight-innerHeight)*{fraction})")
42
+ # Two animation frames allow layout/scroll handlers to settle.
43
+ call("js", "new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve(true))))")
44
+ sample = output / f"viewport-{index}-sample-{step}.json"
45
+ call("eval", str(RUNTIME / "browser_geometry.js"), "--out", str(sample), "--raw")
46
+ samples.append(json.loads(sample.read_text()))
47
+ screenshot = output / f"viewport-{index}.png"
48
+ call("screenshot", "--viewport", str(screenshot))
49
+ if not screenshot.is_file():
50
+ raise ValueError("browser did not create screenshot")
51
+ finding = validate_samples(samples, args.required, args.sticky)
52
+ report["viewports"].append({"viewport": viewport, "samples": samples,
53
+ "screenshot": str(screenshot), **finding})
54
+ report["passed"] = all(item["passed"] for item in report["viewports"])
55
+ finally:
56
+ checkpoint(output / "report.json", report)
57
+ print(json.dumps(report, indent=2))
58
+ return 0 if report["passed"] else 1
59
+
60
+
61
+ def main():
62
+ parser = argparse.ArgumentParser(description=__doc__)
63
+ parser.add_argument("url")
64
+ parser.add_argument("--browse", type=Path, required=True)
65
+ parser.add_argument("--output", type=Path, required=True, help="new directory for evidence")
66
+ parser.add_argument("--viewport", action="append")
67
+ parser.add_argument("--required", action="append", default=[])
68
+ parser.add_argument("--sticky", action="append", default=[])
69
+ args = parser.parse_args()
70
+ try:
71
+ return audit(args)
72
+ except (ValueError, OSError, subprocess.TimeoutExpired) as error:
73
+ print(f"browser-audit: {error}", file=sys.stderr)
74
+ return 1
75
+
76
+
77
+ if __name__ == "__main__":
78
+ raise SystemExit(main())
@@ -16,6 +16,8 @@ from pathlib import Path
16
16
 
17
17
  sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
18
18
  from maggie_dash_store import MaggieDashStore # noqa: E402
19
+ from service_variants import ServiceVariantStore # noqa: E402
20
+ from maggie_dash_ui import load_and_validate # noqa: E402
19
21
 
20
22
 
21
23
  def project_root(args: argparse.Namespace) -> Path:
@@ -92,6 +94,72 @@ def command_transition(args: argparse.Namespace) -> int:
92
94
  store.close()
93
95
 
94
96
 
97
+ def command_cms(args: argparse.Namespace) -> int:
98
+ require_confirm(args)
99
+ store = store_for(project_root(args))
100
+ try:
101
+ if args.cms_command == "revisions":
102
+ result = store.list_revisions(args.project_id, args.document_id)
103
+ elif args.cms_command == "trash":
104
+ result = store.trash(args.project_id, args.document_id, args.actor, args.reason)
105
+ elif args.cms_command == "restore":
106
+ result = store.restore(args.project_id, args.document_id, args.actor, args.reason)
107
+ elif args.cms_command == "schedule":
108
+ result = store.schedule_publish(args.project_id, args.document_id, args.publish_at, args.actor, args.reason)
109
+ elif args.cms_command == "duplicate":
110
+ result = store.duplicate(args.project_id, args.document_id, args.new_id, args.new_slug, args.actor, args.reason)
111
+ elif args.cms_command == "redirect":
112
+ result = store.add_redirect(args.project_id, args.from_path, args.to_path, args.actor, args.reason, args.status_code)
113
+ elif args.cms_command == "preview":
114
+ result = store.issue_preview(args.project_id, args.document_id, args.secret, args.ttl)
115
+ else:
116
+ raise ValueError(f"unknown CMS command: {args.cms_command}")
117
+ emit(result)
118
+ return 0
119
+ finally:
120
+ store.close()
121
+
122
+
123
+ def command_ui(args: argparse.Namespace) -> int:
124
+ result = load_and_validate(Path(args.contract).resolve(), [Path(value).resolve() for value in args.source])
125
+ emit(result)
126
+ return 0 if result["passed"] else 1
127
+
128
+
129
+ def variant_store(args: argparse.Namespace) -> ServiceVariantStore:
130
+ return ServiceVariantStore(project_root(args) / ".maggie" / "service-variants.json")
131
+
132
+
133
+ def command_variant(args: argparse.Namespace) -> int:
134
+ require_confirm(args)
135
+ store = variant_store(args)
136
+ if args.variant_command == "create":
137
+ result = store.create(service_id=args.service_id, variant_id=args.variant_id,
138
+ variant_type=args.variant_type, locale=args.locale, market=args.market,
139
+ slug=args.slug, title=args.title, facts=json.loads(args.facts),
140
+ source_revision=args.source_revision, canonical_variant_id=args.canonical_variant_id,
141
+ cluster_links=args.cluster_link, layout_family=args.layout_family)
142
+ elif args.variant_command == "edit":
143
+ result = store.edit(args.variant_id, title=args.title, facts=json.loads(args.facts),
144
+ source_revision=args.source_revision, actor=args.actor, reason=args.reason)
145
+ elif args.variant_command == "translate":
146
+ result = store.translate(args.source_variant_id, variant_id=args.variant_id,
147
+ locale=args.locale, market=args.market, title=args.title, facts=json.loads(args.facts),
148
+ source_revision=args.source_revision, actor=args.actor)
149
+ elif args.variant_command in {"review", "preview", "publish", "archive"}:
150
+ target = {"review": "review", "preview": "preview", "publish": "published", "archive": "archived"}[args.variant_command]
151
+ result = store.transition(args.variant_id, target, args.actor, args.reason, preview_url=getattr(args, "preview_url", None))
152
+ elif args.variant_command == "validate":
153
+ render_report = json.loads(Path(args.render_report).read_text(encoding="utf-8")) if args.render_report else None
154
+ result = store.validate(args.variant_id, args.source_text, render_report)
155
+ elif args.variant_command == "slug-plan":
156
+ result = store.plan_slug_change(args.variant_id, args.new_slug, args.actor)
157
+ else:
158
+ result = [item for item in store.data["variants"] if not args.variant_id or item["id"] == args.variant_id]
159
+ emit(result)
160
+ return 0 if not isinstance(result, dict) or result.get("passed", True) else 1
161
+
162
+
95
163
  def parser() -> argparse.ArgumentParser:
96
164
  parser = argparse.ArgumentParser(prog="maggie dash", description=__doc__)
97
165
  sub = parser.add_subparsers(dest="command", required=True)
@@ -120,6 +188,39 @@ def parser() -> argparse.ArgumentParser:
120
188
  transition.add_argument("--reason", required=True)
121
189
  transition.add_argument("--confirm", action="store_true")
122
190
  transition.set_defaults(func=command_transition)
191
+ cms = sub.add_parser("cms", help="manage revisions, trash, scheduling, redirects and previews")
192
+ cms_sub = cms.add_subparsers(dest="cms_command", required=True)
193
+ for name in ("revisions", "trash", "restore"):
194
+ command = cms_sub.add_parser(name)
195
+ command.add_argument("--project", default="."); command.add_argument("--project-id", default="local-project")
196
+ command.add_argument("--document-id", required=True); command.add_argument("--actor", default="cli"); command.add_argument("--reason", default="CMS operation"); command.add_argument("--confirm", action="store_true")
197
+ schedule = cms_sub.add_parser("schedule")
198
+ schedule.add_argument("--project", default="."); schedule.add_argument("--project-id", default="local-project"); schedule.add_argument("--document-id", required=True); schedule.add_argument("--publish-at", required=True); schedule.add_argument("--actor", default="cli"); schedule.add_argument("--reason", required=True); schedule.add_argument("--confirm", action="store_true")
199
+ duplicate = cms_sub.add_parser("duplicate")
200
+ duplicate.add_argument("--project", default="."); duplicate.add_argument("--project-id", default="local-project"); duplicate.add_argument("--document-id", required=True); duplicate.add_argument("--new-id", required=True); duplicate.add_argument("--new-slug", required=True); duplicate.add_argument("--actor", default="cli"); duplicate.add_argument("--reason", required=True); duplicate.add_argument("--confirm", action="store_true")
201
+ redirect = cms_sub.add_parser("redirect")
202
+ redirect.add_argument("--project", default="."); redirect.add_argument("--project-id", default="local-project"); redirect.add_argument("--from-path", required=True); redirect.add_argument("--to-path", required=True); redirect.add_argument("--status-code", type=int, default=301); redirect.add_argument("--actor", default="cli"); redirect.add_argument("--reason", required=True); redirect.add_argument("--confirm", action="store_true")
203
+ preview = cms_sub.add_parser("preview")
204
+ preview.add_argument("--project", default="."); preview.add_argument("--project-id", default="local-project"); preview.add_argument("--document-id", required=True); preview.add_argument("--secret", required=True); preview.add_argument("--ttl", type=int, default=900); preview.add_argument("--confirm", action="store_true")
205
+ cms.set_defaults(func=command_cms)
206
+ ui = sub.add_parser("ui", help="validate MaggieDash dashboard chrome")
207
+ ui_sub = ui.add_subparsers(dest="ui_command", required=True)
208
+ ui_validate = ui_sub.add_parser("validate")
209
+ ui_validate.add_argument("--contract", required=True)
210
+ ui_validate.add_argument("--source", action="append", default=[])
211
+ ui_validate.set_defaults(func=command_ui)
212
+ variant = sub.add_parser("variant", help="manage service variant lifecycle")
213
+ variant_sub = variant.add_subparsers(dest="variant_command", required=True)
214
+ create = variant_sub.add_parser("create"); create.add_argument("--project", default="."); create.add_argument("--service-id", required=True); create.add_argument("--variant-id", required=True); create.add_argument("--variant-type", required=True); create.add_argument("--locale", required=True); create.add_argument("--market", required=True); create.add_argument("--slug", required=True); create.add_argument("--title", required=True); create.add_argument("--facts", required=True); create.add_argument("--source-revision", required=True); create.add_argument("--canonical-variant-id"); create.add_argument("--cluster-link", action="append", default=[]); create.add_argument("--layout-family", default="service-default"); create.add_argument("--confirm", action="store_true")
215
+ edit = variant_sub.add_parser("edit"); edit.add_argument("--project", default="."); edit.add_argument("--variant-id", required=True); edit.add_argument("--title", required=True); edit.add_argument("--facts", required=True); edit.add_argument("--source-revision", required=True); edit.add_argument("--actor", required=True); edit.add_argument("--reason", required=True); edit.add_argument("--confirm", action="store_true")
216
+ translate = variant_sub.add_parser("translate"); translate.add_argument("--project", default="."); translate.add_argument("--source-variant-id", required=True); translate.add_argument("--variant-id", required=True); translate.add_argument("--locale", required=True); translate.add_argument("--market", required=True); translate.add_argument("--title", required=True); translate.add_argument("--facts", required=True); translate.add_argument("--source-revision", required=True); translate.add_argument("--actor", required=True); translate.add_argument("--confirm", action="store_true")
217
+ for name in ("review", "preview", "publish", "archive"):
218
+ command = variant_sub.add_parser(name); command.add_argument("--project", default="."); command.add_argument("--variant-id", required=True); command.add_argument("--actor", required=True); command.add_argument("--reason", required=True); command.add_argument("--confirm", action="store_true")
219
+ if name == "preview": command.add_argument("--preview-url", required=True)
220
+ validate = variant_sub.add_parser("validate"); validate.add_argument("--project", default="."); validate.add_argument("--variant-id", required=True); validate.add_argument("--source-text"); validate.add_argument("--render-report"); validate.add_argument("--confirm", action="store_true")
221
+ plan_slug = variant_sub.add_parser("slug-plan"); plan_slug.add_argument("--project", default="."); plan_slug.add_argument("--variant-id", required=True); plan_slug.add_argument("--new-slug", required=True); plan_slug.add_argument("--actor", required=True); plan_slug.add_argument("--confirm", action="store_true")
222
+ inspect = variant_sub.add_parser("inspect"); inspect.add_argument("--project", default="."); inspect.add_argument("--variant-id"); inspect.add_argument("--confirm", action="store_true")
223
+ variant.set_defaults(func=command_variant)
123
224
  return parser
124
225
 
125
226
 
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import argparse
6
6
  import json
7
7
  import re
8
+ import subprocess
8
9
  from datetime import datetime, timezone
9
10
  from pathlib import Path
10
11
 
@@ -106,6 +107,7 @@ def vps_plan(domain: str, service: str, release_root: str, node_port: int) -> di
106
107
  "restart": f"systemctl restart {service}",
107
108
  "verify": f"systemctl is-active {service} && curl -fsS https://{domain}/robots.txt && curl -fsS https://{domain}/sitemap.xml",
108
109
  "rollback": f"ln -sfn {root}/releases/<previous-release> {current} && systemctl restart {service}",
110
+ "data_rollback": "restore the matching data checkpoint before restarting the previous code release",
109
111
  },
110
112
  "files": {
111
113
  "systemd": f"{service}.service",
@@ -130,10 +132,36 @@ def validate_data_checkpoint(path: Path) -> dict:
130
132
  if isinstance(required, list) and isinstance(current, dict):
131
133
  missing = [table for table in required if table not in current]
132
134
  if missing: errors.append("currentCounts is missing required tables")
135
+ empty = [table for table in required if table in current and (not isinstance(current[table], int) or current[table] <= 0)]
136
+ if empty: errors.append("currentCounts has empty required tables: " + ", ".join(empty))
133
137
  if not value.get("release") or not value.get("checkedAt"): errors.append("release and checkedAt are required")
138
+ rollback = value.get("rollback")
139
+ if not isinstance(rollback, dict) or not rollback.get("backupId") or not rollback.get("restoreCommand"):
140
+ errors.append("rollback.backupId and rollback.restoreCommand are required for data rollback")
134
141
  return {"passed": not errors, "declared": True, "errors": errors}
135
142
 
136
143
 
144
+ def validate_scheduler_request(method: str, origin: str | None, has_auth: bool) -> dict:
145
+ errors = []
146
+ if method.upper() == "POST":
147
+ if not origin or not re.fullmatch(r"https?://[^/\s]+", origin): errors.append("POST scheduler requests require an explicit Origin header")
148
+ if not has_auth: errors.append("POST scheduler requests require the configured auth contract")
149
+ return {"schemaVersion": "maggie-deployment-request.v1", "method": method.upper(), "originPresent": bool(origin), "authPresent": bool(has_auth), "passed": not errors, "errors": errors}
150
+
151
+
152
+ def verify_infrastructure(units_dir: Path | None, service: str, timer: str | None = None) -> dict:
153
+ timer_name = timer or f"maggie-{service}.timer"
154
+ service_name = f"maggie-{service}.service" if not service.endswith(".service") else service
155
+ declared = bool(units_dir and (units_dir / service_name).is_file() and (units_dir / timer_name).is_file())
156
+ systemd = {"service": False, "timer": False}
157
+ for unit, key in ((service_name, "service"), (timer_name, "timer")):
158
+ try:
159
+ systemd[key] = subprocess.run(["systemctl", "is-enabled", unit], capture_output=True, text=True, check=False, timeout=5).returncode == 0
160
+ except (OSError, subprocess.SubprocessError):
161
+ systemd[key] = False
162
+ return {"schemaVersion": "maggie-deployment-infra.v1", "service": service_name, "timer": timer_name, "declaredArtifacts": declared, "systemdEnabled": systemd, "passed": declared or all(systemd.values()), "errors": [] if declared or all(systemd.values()) else ["systemd service/timer are not declared or enabled"]}
163
+
164
+
137
165
  def retention_plan(release_root: str, current_link: str, keep: int) -> dict:
138
166
  if keep < 2 or keep > 5:
139
167
  raise ValueError("keep must be between 2 and 5")
@@ -206,6 +234,13 @@ def main() -> int:
206
234
  parser.add_argument("--retention-plan", action="store_true", help="create a read-only release prune candidate plan")
207
235
  parser.add_argument("--current-link", help="current symlink for --retention-plan")
208
236
  parser.add_argument("--keep-releases", type=int, default=2)
237
+ parser.add_argument("--verify-infra", action="store_true", help="verify declared or installed systemd service/timer")
238
+ parser.add_argument("--units-dir", help="directory containing generated .service and .timer units")
239
+ parser.add_argument("--timer", help="systemd timer unit name")
240
+ parser.add_argument("--validate-request", action="store_true", help="validate a scheduler request contract")
241
+ parser.add_argument("--method", default="POST")
242
+ parser.add_argument("--origin")
243
+ parser.add_argument("--has-auth", action="store_true")
209
244
  args = parser.parse_args()
210
245
  try:
211
246
  if args.vps_plan:
@@ -226,6 +261,14 @@ def main() -> int:
226
261
  output.write_text(json.dumps(plan, indent=2) + "\n", encoding="utf-8")
227
262
  print(json.dumps(plan, indent=2, ensure_ascii=False))
228
263
  return 0
264
+ if args.validate_request:
265
+ result = validate_scheduler_request(args.method, args.origin, args.has_auth)
266
+ print(json.dumps(result, indent=2, ensure_ascii=False))
267
+ return 0 if result["passed"] else 1
268
+ if args.verify_infra:
269
+ result = verify_infrastructure(Path(args.units_dir).resolve() if args.units_dir else None, args.service, args.timer)
270
+ print(json.dumps(result, indent=2, ensure_ascii=False))
271
+ return 0 if result["passed"] else 1
229
272
  result = preflight(Path(args.project).resolve(), args.target, args.environment)
230
273
  if args.output:
231
274
  Path(args.output).parent.mkdir(parents=True, exist_ok=True)
@@ -34,6 +34,21 @@ def project_fingerprint(project: Path) -> str:
34
34
  return hashlib.sha256(str(project.resolve()).encode()).hexdigest()[:16]
35
35
 
36
36
 
37
+ def installed_version(project: Path) -> str:
38
+ """Resolve the version from the runtime wrapper or project install state."""
39
+ if os.environ.get("MAGGIE_VERSION"):
40
+ return safe_text(os.environ["MAGGIE_VERSION"])
41
+ for path in (project / ".maggie" / "install.json", project / "VERSION"):
42
+ try:
43
+ value = json.loads(path.read_text(encoding="utf-8")) if path.suffix == ".json" else path.read_text(encoding="utf-8").strip()
44
+ if isinstance(value, dict): value = value.get("version", "")
45
+ if re.fullmatch(r"\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?", str(value)):
46
+ return str(value)
47
+ except (OSError, ValueError, json.JSONDecodeError):
48
+ continue
49
+ return "unknown"
50
+
51
+
37
52
  def feedback_id(project: Path) -> str:
38
53
  timestamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
39
54
  return f"fb-{timestamp}-{project_fingerprint(project)[:6]}-{secrets.token_hex(3)}"
@@ -68,7 +83,7 @@ def collect(args: argparse.Namespace) -> int:
68
83
  "feedbackId": feedback_id(project),
69
84
  "createdAt": now(),
70
85
  "source": "maggie-cli",
71
- "maggieVersion": os.environ.get("MAGGIE_VERSION", "unknown"),
86
+ "maggieVersion": installed_version(project),
72
87
  "type": args.type,
73
88
  "skill": skill,
74
89
  "runId": run_id,
@@ -13,6 +13,27 @@ from pathlib import Path
13
13
 
14
14
  sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
15
15
  from content_localization import LANGUAGES, MARKETS, OPERATIONS, parse_locale, protected_field_changes, stale, valid_locale, validate_translation
16
+ from localization_runner import generate, process_adapter
17
+
18
+
19
+ def generate_job(args: argparse.Namespace) -> int:
20
+ if not args.confirm:
21
+ raise ValueError("generate requires --confirm: the adapter receives source text and may incur provider costs")
22
+ command = json.loads(args.adapter_command)
23
+ adapter = process_adapter(command, args.project, args.timeout)
24
+ job = load(args.job)
25
+ errors = validate_job(args.job, args.source)["errors"]
26
+ if errors:
27
+ raise ValueError("; ".join(errors))
28
+ source = load(args.source)
29
+ contract = {key: job.get(key) for key in (
30
+ "jobId", "contentId", "sourceRevision", "sourceLanguage", "targetLanguage",
31
+ "targetLocale", "market", "operation", "generationContract")}
32
+
33
+ state = generate(source["strings"], contract, args.output, adapter, args.character_budget)
34
+ print(json.dumps({"status": state["status"], "strings": len(state["translations"]),
35
+ "output": str(args.output), "publish": False}))
36
+ return 0
16
37
 
17
38
 
18
39
  def now() -> str:
@@ -318,6 +339,15 @@ def glossary(project: Path) -> int:
318
339
  def main() -> int:
319
340
  parser = argparse.ArgumentParser(description=__doc__)
320
341
  sub = parser.add_subparsers(dest="command", required=True)
342
+ generate_parser = sub.add_parser("generate", help="Generate resumable draft strings using a trusted provider process")
343
+ generate_parser.add_argument("job", type=Path)
344
+ generate_parser.add_argument("--project", type=Path, default=Path("."))
345
+ generate_parser.add_argument("--source", type=Path, required=True)
346
+ generate_parser.add_argument("--output", type=Path, required=True)
347
+ generate_parser.add_argument("--adapter-command", required=True, help="JSON argv array; executed without a shell")
348
+ generate_parser.add_argument("--character-budget", type=int, default=8000)
349
+ generate_parser.add_argument("--timeout", type=int, default=120)
350
+ generate_parser.add_argument("--confirm", action="store_true")
321
351
  plan_parser = sub.add_parser("plan")
322
352
  plan_parser.add_argument("--project", type=Path, default=Path("."))
323
353
  plan_parser.add_argument("--content", type=Path, required=True)
@@ -346,6 +376,7 @@ def main() -> int:
346
376
  command = sub.add_parser(name); command.add_argument("--project", type=Path, default=Path("."))
347
377
  args = parser.parse_args()
348
378
  try:
379
+ if args.command == "generate": return generate_job(args)
349
380
  if args.command == "plan": return plan_job(args)
350
381
  if args.command == "preview": return preview(args.job)
351
382
  if args.command == "validate":
@@ -13,6 +13,9 @@ from runtime import maggie_memory as memory
13
13
 
14
14
 
15
15
  def output(value: object) -> None:
16
+ if isinstance(value, dict) and value.get("status") == "candidate" and value.get("id"):
17
+ value = dict(value)
18
+ value["notice"] = "Saved as candidate; excluded from search/context until reviewed and transitioned to active. Inspect with: maggie memory list --status candidate"
16
19
  print(json.dumps(value, indent=2, ensure_ascii=False))
17
20
 
18
21
 
@@ -82,7 +85,7 @@ def main() -> int:
82
85
  output(memory.transition(args.project, args.kind, args.item_id, args.status))
83
86
  elif args.command in {"context", "search", "list"}:
84
87
  query = args.query if args.command == "search" else getattr(args, "query", "")
85
- result = memory.relevant(args.project, skill=getattr(args, "skill", ""), query=query)
88
+ result = memory.relevant(args.project, skill=getattr(args, "skill", ""), query=query, status=getattr(args, "status", None))
86
89
  if args.command == "list" and args.kind:
87
90
  result = {args.kind: result[args.kind]}
88
91
  if args.command == "list" and args.status:
@@ -9,6 +9,10 @@ import sys
9
9
  from datetime import datetime, timezone
10
10
  from pathlib import Path
11
11
 
12
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
13
+ from dependency_lock import audit as audit_lockfiles
14
+ from seed_evidence import validate_manifest
15
+
12
16
 
13
17
  REQUIRED_ROUTES = (
14
18
  "/ops", "/ops/posts", "/ops/sitemap", "/ops/reports",
@@ -132,14 +136,30 @@ def command_verify(args: argparse.Namespace) -> int:
132
136
  return 0 if result["checks"]["ready"] and result["stateFile"] else 1
133
137
 
134
138
 
139
+ def command_lockfiles(args: argparse.Namespace) -> int:
140
+ result = audit_lockfiles(root(args))
141
+ print(json.dumps(result, indent=2, ensure_ascii=False))
142
+ return 0 if result["passed"] else 1
143
+
144
+
145
+ def command_seed_manifest(args: argparse.Namespace) -> int:
146
+ value = read_json(Path(args.manifest).resolve())
147
+ result = validate_manifest(value)
148
+ print(json.dumps(result, indent=2, ensure_ascii=False))
149
+ return 0 if result["passed"] else 1
150
+
151
+
135
152
  def main() -> int:
136
153
  parser = argparse.ArgumentParser(description=__doc__)
137
154
  parser.add_argument("--project", default=".")
138
155
  sub = parser.add_subparsers(dest="command", required=True)
139
- for name, handler in (("audit", command_audit), ("preflight", command_preflight), ("status", command_status), ("verify", command_verify)):
156
+ for name, handler in (("audit", command_audit), ("preflight", command_preflight), ("status", command_status), ("verify", command_verify), ("lockfiles", command_lockfiles)):
140
157
  command = sub.add_parser(name)
141
158
  if name == "preflight": command.add_argument("--write", action="store_true")
142
159
  command.set_defaults(func=handler)
160
+ seed = sub.add_parser("seed-manifest", help="validate explicit sanitized development fixtures")
161
+ seed.add_argument("--manifest", required=True)
162
+ seed.set_defaults(func=command_seed_manifest)
143
163
  record = sub.add_parser("record", help="record an explicitly authorised operation")
144
164
  record.add_argument("operation", choices=("sync", "sitemap-match", "rewrite-queue", "rewrite-approve", "publish", "migration"))
145
165
  record.add_argument("--dry-run", action="store_true")
@@ -10,6 +10,9 @@ from pathlib import Path
10
10
  from urllib.parse import quote, urljoin, urlsplit
11
11
  from urllib.request import Request, urlopen
12
12
 
13
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
14
+ from route_imports import imported_components # noqa: E402
15
+
13
16
  NOW = lambda: datetime.now(timezone.utc).isoformat()
14
17
  MONEY = re.compile(r"(?:£|GBP\s*)\s*([0-9]+(?:[.,][0-9]{1,2})?)", re.I)
15
18
  DURATION = re.compile(r"(\d{2,3})\s*(?:min|mins|minutes?)", re.I)
@@ -751,7 +754,7 @@ def inventory_candidates(project, pages_root):
751
754
  found={}
752
755
  for page in pages_root.rglob("*") if pages_root.exists() else []:
753
756
  if page.is_file() and page.suffix.lower() in {".astro",".md",".mdx",".html"} and page.stem not in {"index","404"}:
754
- found["/"+str(page.relative_to(project)).replace("\\","/")]={"path":"/"+str(page.relative_to(project)).replace("\\","/"),"text":page_text(page),"canonical":None,"pageType":"filesystem"}
757
+ found["/"+str(page.relative_to(project)).replace("\\","/")]={"path":"/"+str(page.relative_to(project)).replace("\\","/"),"text":page_text(page),"canonical":None,"pageType":"filesystem","imports":imported_components(page)}
755
758
  for name in ("pages.json","page-content.json"):
756
759
  source=project/"docs"/name
757
760
  if not source.exists(): continue
@@ -762,7 +765,7 @@ def inventory_candidates(project, pages_root):
762
765
  if not isinstance(item,dict): continue
763
766
  value=item.get("path") or item.get("url") or item.get("route")
764
767
  if not value: continue
765
- current=found.setdefault(value,{"path":value,"text":"","canonical":None,"pageType":"inventory"}); current["text"]+=" "+str(item.get("content") or item.get("text") or item.get("title") or ""); current["canonical"]=item.get("canonicalUrl") or item.get("canonical") or current["canonical"]; current["pageType"]=item.get("pageType") or current["pageType"]
768
+ current=found.setdefault(value,{"path":value,"text":"","canonical":None,"pageType":"inventory","imports":[]}); current["text"]+=" "+str(item.get("content") or item.get("text") or item.get("title") or ""); current["canonical"]=item.get("canonicalUrl") or item.get("canonical") or current["canonical"]; current["pageType"]=item.get("pageType") or current["pageType"]
766
769
  return list(found.values())
767
770
  def cmd_match_pages_review(args):
768
771
  project=root(args); data=load(project); candidates=inventory_candidates(project,(project/args.pages_dir).resolve()); aliases={"lymphatic drainage":{"lymphatic","lymph drainage","lymphdrainage","manual lymphatic","ml d"},"deep tissue":{"deep tissue","therapeutic massage"},"thai":{"thai massage","thai"},"postpartum":{"postpartum","new mom"},"facial":{"facial","hydra facial","hydrafacial"},"massage":{"massage"}}
@@ -779,7 +782,7 @@ def cmd_match_pages_review(args):
779
782
  matches=[]
780
783
  for page in candidates:
781
784
  path_slug=slug(page["path"].rsplit("/",1)[-1].rsplit(".",1)[0]); page_words=page_tokens(path_slug+" "+page["text"][:2000]); exact=path_slug==service.get("slug"); overlap=len(expanded & page_words)/max(1,len(expanded)); generic=bool(concepts & {"massage","facial","acupuncture","waxing"}) and overlap<0.75; score=1.0 if exact else min(0.49 if generic else overlap,0.99)
782
- if score>=0.45: matches.append({"path":page["path"],"role":"supporting","pageType":page["pageType"],"canonicalUrl":page["canonical"],"matchedConcepts":sorted(expanded & page_words),"method":"slug" if exact else "semantic-concepts","confidence":round(score,3)})
785
+ if score>=0.45: matches.append({"path":page["path"],"role":"supporting","pageType":page["pageType"],"canonicalUrl":page["canonical"],"matchedConcepts":sorted(expanded & page_words),"imports":page.get("imports",[]),"method":"slug" if exact else "semantic-concepts","confidence":round(score,3)})
783
786
  matches.sort(key=lambda x:(-x["confidence"],x["path"])); report.append({"serviceId":service["id"],"title":service["title"],"candidates":matches})
784
787
  for path_value in selections.get(service["id"], []):
785
788
  if path_value in {m["path"] for m in matches}: selected.append((service,path_value))