@topy-ai/maggie 0.6.0 → 0.6.2

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.md CHANGED
@@ -70,6 +70,8 @@ maggie seo performance ... # sampled PageSpeed/CWV report and baseline
70
70
  maggie seo images ... # inventory, variants, confirmation, validate
71
71
  maggie seo sitemap ... # typed plan, validate, apply, rollback
72
72
  maggie deployment | migration | release | analytics | schedule
73
+ maggie deployment canary --asset URL=SHA256 --render-report report.json
74
+ maggie design icon-inventory --source-dir src --runtime assets/icons.css
73
75
  maggie api lifecycle | site-audit | ops audit
74
76
  ```
75
77
 
@@ -107,11 +109,34 @@ Read the [performance PRD](https://github.com/TOPY-AI-LTD/ai-cmo-skills/blob/mai
107
109
  and [image/sitemap PRD](https://github.com/TOPY-AI-LTD/ai-cmo-skills/blob/main/docs/image-sitemap-structure-prd.md)
108
110
  for adapter, privacy, apply, rollback, and release gates.
109
111
 
112
+ ### Design and deployment release gates
113
+
114
+ Check source-to-runtime icon coverage before shipping a design:
115
+
116
+ ```bash
117
+ maggie design icon-inventory --project . --source-dir src \
118
+ --runtime public/assets/icons.css --output docs/icon-inventory.json
119
+ ```
120
+
121
+ After deployment, compare an immutable asset fingerprint and validate the
122
+ browser adapter's rendered report:
123
+
124
+ ```bash
125
+ maggie deployment canary --project . \
126
+ --asset https://example.com/assets/app.abc123.js=<sha256> \
127
+ --render-report .maggie/rendered-canary.json \
128
+ --output docs/deployment-canary.json
129
+ ```
130
+
131
+ The canary report requires a screenshot and zero console errors, missing
132
+ assets, and visual placeholders for every route. It records safe cache headers
133
+ only and never stores response bodies, cookies, or credentials.
134
+
110
135
  Recommended upgrade sequence for the current release:
111
136
 
112
137
  ```bash
113
- npx @topy-ai/maggie@0.6.0 update --project . --force
114
- npx @topy-ai/maggie@0.6.0 cleanup --project .
138
+ npx @topy-ai/maggie@0.6.2 update --project . --force
139
+ npx @topy-ai/maggie@0.6.2 cleanup --project .
115
140
  ```
116
141
 
117
142
  ## MaggieDash lifecycle
package/bin/maggie.js CHANGED
@@ -88,6 +88,7 @@ Usage:
88
88
  maggie design reference-ui --project PATH --reference PATH --surface blog,service --confirm
89
89
  maggie design init --project PATH --surface blog|service --reference-run PATH --confirm
90
90
  maggie design validate-ui --project PATH --plan PATH --rendered-dir PATH --confirm
91
+ maggie design icon-inventory --project PATH --source-dir src --runtime assets/icons.css --output docs/icon-inventory.json
91
92
  maggie design author --project PATH --route /about --purpose TEXT --audience TEXT --confirm
92
93
  maggie auth reference --project PATH --confirm
93
94
  maggie auth check --project PATH [--production]
@@ -102,6 +103,7 @@ Usage:
102
103
  maggie service convert-page <page-path> --project PATH
103
104
  maggie service match-pages --project PATH --pages-dir src/pages
104
105
  maggie deployment --project PATH --target vps-with-cloudflare-dns
106
+ maggie deployment canary --project PATH --asset URL=SHA256 --render-report report.json --output docs/deployment-canary.json
105
107
  maggie migration --project PATH --environment staging
106
108
  maggie schedule PATH/.maggie/schedule.json --project PATH
107
109
  maggie analytics --project PATH --environment staging
@@ -369,12 +371,14 @@ try {
369
371
  else if (command === "marketplace") marketplace(args);
370
372
  else if (command === "clone") workflowCli("maggie_clone.py", args);
371
373
  else if (command === "clone-to-template") workflowCli("maggie_clone_to_template.py", args);
374
+ else if (command === "design" && args[0] === "icon-inventory") workflowCli("maggie_icon_inventory.py", args.slice(1));
372
375
  else if (command === "design") workflowCli("maggie_design.py", args);
373
376
  else if (command === "auth") workflowCli("maggie_auth.py", args);
374
377
  else if (command === "blog") workflowCli("maggie_blog.py", args);
375
378
  else if (command === "service") service(args);
376
379
  else if (command === "seo") seo(args);
377
380
  else if (command === "ops") workflowCli("maggie_ops.py", args);
381
+ else if (command === "deployment" && args[0] === "canary") workflowCli("maggie_deployment_canary.py", args.slice(1));
378
382
  else if (command === "deployment") workflowCli("maggie_deployment.py", args);
379
383
  else if (command === "migration") workflowCli("maggie_migration.py", args);
380
384
  else if (command === "schedule") workflowCli("maggie_schedule.py", args);
@@ -0,0 +1,7 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "Maggie edge and rendered deployment canary",
4
+ "type": "object",
5
+ "required": ["schemaVersion", "assets", "status"],
6
+ "properties": {"schemaVersion": {"const": "maggie-deployment-canary.v1"}, "assets": {"type": "array"}, "rendered": {"type": ["object", "null"]}, "status": {"enum": ["pass", "fail"]}}
7
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "Maggie source-to-runtime icon inventory",
4
+ "type": "object",
5
+ "required": ["schemaVersion", "source", "runtime", "coverage", "missing", "status"],
6
+ "properties": {"schemaVersion": {"const": "maggie-icon-inventory.v1"}, "source": {"type": "object"}, "runtime": {"type": "object"}, "coverage": {"type": "object"}, "missing": {"type": "array", "items": {"type": "string"}}, "status": {"enum": ["pass", "fail"]}}
7
+ }
@@ -2,11 +2,34 @@
2
2
  name: maggie-deployment
3
3
  description: Deploy and operate Maggie blog projects with Cloudflare Workers as the default target, while preserving an adapter boundary for VPS, GCP, and AWS.
4
4
  metadata:
5
- version: 1.0.0
5
+ version: 1.1.0
6
6
  ---
7
7
 
8
8
  # Maggie Deployment
9
9
 
10
+ ## Edge and rendered canary
11
+
12
+ After staging or production deployment, verify that the edge serves the
13
+ expected immutable asset bytes and validate a browser-produced rendered report:
14
+
15
+ ```bash
16
+ maggie deployment canary --project . \
17
+ --asset https://example.com/assets/app.abc123.js=<sha256> \
18
+ --render-report .maggie/rendered-canary.json \
19
+ --output docs/deployment-canary.json
20
+ ```
21
+
22
+ The asset probe records only safe cache headers and compares the response
23
+ SHA-256 with the supplied release fingerprint. The rendered report must have a
24
+ `routes` array; every route needs a screenshot and zero `consoleErrors`,
25
+ `missingAssets`, and `placeholderCount`. A browser adapter (for example
26
+ Playwright in the host project) produces that report; Maggie validates it and
27
+ never claims a static HTML fetch is a rendered browser check. The command is
28
+ read-only and returns non-zero on stale bytes, missing screenshots, console
29
+ errors, missing assets, or visual placeholders. The result follows
30
+ `maggie-deployment-canary.v1`; never include cookies, authorization headers, or
31
+ response bodies in it.
32
+
10
33
  ## Automatic memory hook
11
34
 
12
35
  Follow [Maggie Memory Hook](../../references/memory-hook.md) at invocation and completion.
@@ -2,11 +2,30 @@
2
2
  name: maggie-design
3
3
  description: Design authorized interior pages, review rendered responsive layouts, or explicitly rebrand a packaged homepage/template. Use rebrand only with a named source brand and target brand.
4
4
  metadata:
5
- version: 1.2.0
5
+ version: 1.3.0
6
6
  ---
7
7
 
8
8
  # Maggie Design
9
9
 
10
+ ## Source-to-runtime icon inventory
11
+
12
+ Before release, inventory icon names in source and compare them with the
13
+ runtime CSS/icon map (and any declared font files). The command is read-only
14
+ apart from its explicit report path and fails when a source icon is missing:
15
+
16
+ ```bash
17
+ maggie design icon-inventory --project . --source-dir src \
18
+ --runtime public/assets/icons.css --runtime public/assets/icons.woff2 \
19
+ --output docs/icon-inventory.json
20
+ ```
21
+
22
+ Use `missing`, `unknown`, and `coverage` from the versioned
23
+ `maggie-icon-inventory.v1` report as the release gate. A binary font without a
24
+ name map is reported as evidence only; it must not be treated as proof that a
25
+ glyph exists. Fix the source token or add the runtime definition before
26
+ shipping. Do not paste private source, URLs with credentials, or user data
27
+ into the report.
28
+
10
29
  ## Automatic memory hook
11
30
 
12
31
  Follow [Maggie Memory Hook](../../references/memory-hook.md) at invocation and completion.
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env python3
2
+ """Verify edge asset fingerprints and a browser-produced rendered canary report."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import hashlib
7
+ import json
8
+ import urllib.error
9
+ import urllib.request
10
+ from urllib.parse import urlparse
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+
14
+
15
+ def fetch_asset(url: str, expected_sha256: str | None) -> dict:
16
+ if urlparse(url).scheme not in {"http", "https"}:
17
+ raise ValueError("asset URL must use http or https")
18
+ request = urllib.request.Request(url, headers={"User-Agent": "maggie-deployment-canary/1.0"})
19
+ with urllib.request.urlopen(request, timeout=20) as response:
20
+ body = response.read(); headers = {key.lower(): value for key, value in response.headers.items()}
21
+ actual = hashlib.sha256(body).hexdigest()
22
+ return {"url": url, "status": response.status, "sha256": actual, "expectedSha256": expected_sha256, "match": expected_sha256 is None or actual == expected_sha256, "headers": {key: headers[key] for key in ("cache-control", "etag", "age", "cf-cache-status", "cf-ray", "x-cache", "content-type") if key in headers}}
23
+
24
+
25
+ def validate_render_report(path: Path) -> tuple[bool, dict]:
26
+ report = json.loads(path.read_text(encoding="utf-8"))
27
+ routes = report.get("routes")
28
+ if not isinstance(routes, list) or not routes: raise ValueError("render report requires a non-empty routes array")
29
+ failures = []
30
+ for route in routes:
31
+ if route.get("consoleErrors") or route.get("missingAssets") or route.get("placeholderCount", 0): failures.append(route.get("route", "unknown"))
32
+ if not route.get("screenshot"): failures.append(f"{route.get('route', 'unknown')}:screenshot")
33
+ return not failures, {"path": str(path), "routeCount": len(routes), "failures": failures, "report": report}
34
+
35
+
36
+ def canary(project: Path, asset: list[str], render_report: Path | None, output: Path | None) -> int:
37
+ assets = []
38
+ for item in asset:
39
+ if "=" in item: url, expected = item.split("=", 1)
40
+ else: url, expected = item, None
41
+ assets.append(fetch_asset(url, expected or None))
42
+ rendered = None
43
+ if render_report:
44
+ ok, rendered = validate_render_report(render_report.resolve())
45
+ else: ok = True
46
+ result = {"schemaVersion": "maggie-deployment-canary.v1", "workflow": "maggie-deployment", "project": str(project.resolve()), "assets": assets, "rendered": rendered, "status": "pass" if ok and all(a["match"] for a in assets) else "fail", "generatedAt": datetime.now(timezone.utc).isoformat()}
47
+ encoded = json.dumps(result, indent=2, ensure_ascii=False) + "\n"
48
+ if output: destination = (project / output).resolve() if not output.is_absolute() else output.resolve(); destination.parent.mkdir(parents=True, exist_ok=True); destination.write_text(encoded, encoding="utf-8"); result["output"] = str(destination); encoded = json.dumps(result, indent=2, ensure_ascii=False) + "\n"
49
+ print(encoded, end=""); return 0 if result["status"] == "pass" else 1
50
+
51
+
52
+ def main() -> int:
53
+ parser = argparse.ArgumentParser(description=__doc__); parser.add_argument("--project", type=Path, default=Path.cwd()); parser.add_argument("--asset", action="append", default=[], help="URL or URL=expected-sha256; repeatable"); parser.add_argument("--render-report", type=Path); parser.add_argument("--output", type=Path)
54
+ args = parser.parse_args()
55
+ try: return canary(args.project, args.asset, args.render_report, args.output)
56
+ except (OSError, ValueError, urllib.error.URLError, json.JSONDecodeError) as error: print(f"BLOCKED: maggie-deployment canary: {error}", file=__import__("sys").stderr); return 2
57
+
58
+
59
+ if __name__ == "__main__": raise SystemExit(main())
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env python3
2
+ """Inventory source icon names and compare them with runtime CSS/font maps."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import re
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+
11
+ SOURCE_EXTENSIONS = {".astro", ".css", ".html", ".jsx", ".js", ".json", ".svelte", ".tsx", ".ts", ".vue"}
12
+ IGNORE_NAMES = {"icon", "icons", "true", "false", "null", "undefined"}
13
+
14
+
15
+ def _add(found: dict[str, set[str]], name: str, file: Path, evidence: str) -> None:
16
+ name = name.strip().lower()
17
+ if not name or name in IGNORE_NAMES or len(name) > 80 or not re.fullmatch(r"[a-z0-9][a-z0-9._:/-]*", name):
18
+ return
19
+ found.setdefault(name, set()).add(f"{file.as_posix()}:{evidence}")
20
+
21
+
22
+ def source_icons(root: Path, source_dir: Path) -> dict[str, set[str]]:
23
+ found: dict[str, set[str]] = {}
24
+ base = source_dir.resolve()
25
+ for file in sorted(base.rglob("*")):
26
+ if not file.is_file() or file.suffix.lower() not in SOURCE_EXTENSIONS:
27
+ continue
28
+ text = file.read_text(encoding="utf-8", errors="replace")
29
+ relative = file.relative_to(root.resolve())
30
+ for match in re.finditer(r"(?:icon|glyph|symbol)\s*[:=]\s*[\"']([^\"']+)", text, re.I):
31
+ _add(found, match.group(1), relative, "named-property")
32
+ for match in re.finditer(r"data-(?:icon|glyph)\s*=\s*[\"']([^\"']+)", text, re.I):
33
+ _add(found, match.group(1), relative, "data-attribute")
34
+ for match in re.finditer(r"(?:ph|fa|lucide|icon)-([a-z0-9][a-z0-9-]*)", text, re.I):
35
+ _add(found, match.group(1), relative, "icon-class")
36
+ for match in re.finditer(r"(?:phosphor|lucide|icon)[:/]([a-z0-9][a-z0-9-]*)", text, re.I):
37
+ _add(found, match.group(1), relative, "icon-token")
38
+ return found
39
+
40
+
41
+ def runtime_icons(root: Path, runtime_paths: list[Path]) -> tuple[set[str], dict[str, set[str]], list[str]]:
42
+ names: set[str] = set(); evidence: dict[str, set[str]] = {}; font_files: list[str] = []
43
+ for path in runtime_paths:
44
+ path = path.resolve()
45
+ if not path.exists():
46
+ continue
47
+ if path.suffix.lower() in {".woff", ".woff2", ".ttf", ".otf"}:
48
+ font_files.append(str(path.relative_to(root.resolve())) if path.is_relative_to(root.resolve()) else str(path))
49
+ continue
50
+ text = path.read_text(encoding="utf-8", errors="replace")
51
+ relative = path.relative_to(root.resolve()) if path.is_relative_to(root.resolve()) else path
52
+ for match in re.finditer(r"\.(?:ph|fa|lucide|icon)-([a-z0-9][a-z0-9-]*)\b", text, re.I):
53
+ name = match.group(1).lower(); names.add(name); evidence.setdefault(name, set()).add(f"{relative}:css-class")
54
+ for match in re.finditer(r"(?:data-(?:icon|glyph)|icon)\s*=\s*[\"']([^\"']+)", text, re.I):
55
+ name = match.group(1).lower(); names.add(name); evidence.setdefault(name, set()).add(f"{relative}:runtime-map")
56
+ return names, evidence, font_files
57
+
58
+
59
+ def inventory(project: Path, source_dir: Path, runtime: list[Path], output: Path | None) -> int:
60
+ project = project.resolve(); source = (project / source_dir).resolve() if not source_dir.is_absolute() else source_dir.resolve()
61
+ if not source.is_dir(): raise ValueError(f"source directory not found: {source}")
62
+ runtime_paths = [((project / path) if not path.is_absolute() else path) for path in runtime]
63
+ sources = source_icons(project, source)
64
+ available, runtime_evidence, fonts = runtime_icons(project, runtime_paths)
65
+ source_names = sorted(sources); missing = sorted(set(source_names) - available); matched = sorted(set(source_names) & available)
66
+ report = {
67
+ "schemaVersion": "maggie-icon-inventory.v1", "workflow": "maggie-design",
68
+ "source": {"directory": str(source), "names": [{"name": n, "evidence": sorted(sources[n])} for n in source_names]},
69
+ "runtime": {"paths": [str(p) for p in runtime_paths], "names": [{"name": n, "evidence": sorted(runtime_evidence.get(n, []))} for n in sorted(available)], "fontFiles": fonts},
70
+ "coverage": {"sourceCount": len(source_names), "matchedCount": len(matched), "missingCount": len(missing), "ratio": round(len(matched) / len(source_names), 4) if source_names else 1.0},
71
+ "missing": missing, "unknown": [str(p) for p in runtime_paths if not p.exists()],
72
+ "status": "pass" if not missing and not any(not p.exists() for p in runtime_paths) else "fail",
73
+ "generatedAt": datetime.now(timezone.utc).isoformat(),
74
+ }
75
+ encoded = json.dumps(report, indent=2, ensure_ascii=False) + "\n"
76
+ if output:
77
+ destination = (project / output).resolve() if not output.is_absolute() else output.resolve(); destination.parent.mkdir(parents=True, exist_ok=True); destination.write_text(encoded, encoding="utf-8"); report["output"] = str(destination); encoded = json.dumps(report, indent=2, ensure_ascii=False) + "\n"
78
+ print(encoded, end="")
79
+ return 0 if report["status"] == "pass" else 1
80
+
81
+
82
+ def main() -> int:
83
+ parser = argparse.ArgumentParser(description=__doc__)
84
+ parser.add_argument("--project", type=Path, default=Path.cwd()); parser.add_argument("--source-dir", type=Path, default=Path("src")); parser.add_argument("--runtime", action="append", type=Path, required=True, help="CSS, icon map, or font file; repeatable"); parser.add_argument("--output", type=Path)
85
+ args = parser.parse_args()
86
+ try: return inventory(args.project, args.source_dir, args.runtime, args.output)
87
+ except (OSError, ValueError, json.JSONDecodeError) as error: print(f"BLOCKED: maggie-design icon-inventory: {error}", file=__import__("sys").stderr); return 2
88
+
89
+
90
+ if __name__ == "__main__": raise SystemExit(main())
@@ -20,19 +20,25 @@ def main() -> int:
20
20
  parser = argparse.ArgumentParser(prog="maggie memory", description=__doc__)
21
21
  parser.add_argument("--project", default=".")
22
22
  sub = parser.add_subparsers(dest="command", required=True)
23
- sub.add_parser("init")
24
- listing = sub.add_parser("list")
23
+ def add_project(command: argparse.ArgumentParser) -> argparse.ArgumentParser:
24
+ # SUPPRESS preserves a global --project supplied before the command
25
+ # while also accepting the documented, conventional position after it.
26
+ command.add_argument("--project", default=argparse.SUPPRESS)
27
+ return command
28
+
29
+ add_project(sub.add_parser("init"))
30
+ listing = add_project(sub.add_parser("list"))
25
31
  listing.add_argument("--kind", choices=sorted(memory.KINDS), default=None)
26
32
  listing.add_argument("--status", choices=sorted(memory.STATUSES), default=None)
27
33
  listing.add_argument("--skill", default="")
28
34
  listing.add_argument("--query", default="")
29
- search = sub.add_parser("search")
35
+ search = add_project(sub.add_parser("search"))
30
36
  search.add_argument("query")
31
37
  search.add_argument("--skill", default="")
32
- context = sub.add_parser("context")
38
+ context = add_project(sub.add_parser("context"))
33
39
  context.add_argument("--skill", default="")
34
40
  context.add_argument("--query", default="")
35
- add = sub.add_parser("add")
41
+ add = add_project(sub.add_parser("add"))
36
42
  add.add_argument("kind", choices=("preferences", "conventions", "lessons"))
37
43
  add.add_argument("--scope", choices=sorted(memory.SCOPES), required=True)
38
44
  add.add_argument("--type", dest="item_type", required=True)
@@ -48,7 +54,7 @@ def main() -> int:
48
54
  add.add_argument("--status", choices=sorted(memory.STATUSES), default="candidate")
49
55
  add.add_argument("--expires-at", default="")
50
56
  add.add_argument("--supersedes", default="")
51
- error = sub.add_parser("record-error")
57
+ error = add_project(sub.add_parser("record-error"))
52
58
  error.add_argument("--skill", required=True)
53
59
  error.add_argument("--message", required=True)
54
60
  error.add_argument("--fingerprint", default="")
@@ -57,11 +63,11 @@ def main() -> int:
57
63
  error.add_argument("--validation", default="")
58
64
  error.add_argument("--fixed", action="store_true")
59
65
  error.add_argument("--trigger", action="append", default=[])
60
- transition = sub.add_parser("transition")
66
+ transition = add_project(sub.add_parser("transition"))
61
67
  transition.add_argument("kind", choices=sorted(memory.KINDS))
62
68
  transition.add_argument("item_id")
63
69
  transition.add_argument("status", choices=sorted(memory.STATUSES))
64
- export = sub.add_parser("export")
70
+ export = add_project(sub.add_parser("export"))
65
71
  export.add_argument("--output", required=True)
66
72
  export.add_argument("--public-safe", action="store_true")
67
73
  args = parser.parse_args()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topy-ai/maggie",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Install and manage Maggie Skills for AI coding agents",
5
5
  "license": "MIT",
6
6
  "type": "module",