@topy-ai/maggie 0.7.49 → 0.7.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README-zh-TW.md +3 -3
  2. package/README.md +58 -8
  3. package/bin/maggie.js +33 -14
  4. package/bundled-contracts/maggie-assistant-tools/tool-registry-example-v1.json +19 -0
  5. package/bundled-contracts/maggie-assistant-tools/tool-registry-v1.schema.json +50 -0
  6. package/bundled-contracts/maggie-deployment/bridge-host-example-v1.json +29 -0
  7. package/bundled-contracts/maggie-design/mobile-app-evidence-v1.schema.json +54 -0
  8. package/bundled-contracts/maggie-design/mobile-app-surface-v1.schema.json +137 -16
  9. package/bundled-skills/catalog.json +8 -0
  10. package/bundled-skills/maggie-assistant-tools/SKILL.md +93 -0
  11. package/bundled-skills/maggie-assistant-tools/references/approvals-and-audit.md +54 -0
  12. package/bundled-skills/maggie-assistant-tools/references/bridge-host.md +56 -0
  13. package/bundled-skills/maggie-assistant-tools/references/product-release.md +57 -0
  14. package/bundled-skills/maggie-deployment/SKILL.md +13 -0
  15. package/bundled-skills/maggie-design/SKILL.md +55 -21
  16. package/bundled-skills/maggie-design/references/app-ui-patterns.md +66 -0
  17. package/bundled-skills/maggie-feedback/SKILL.md +20 -3
  18. package/bundled-skills/maggie-marketplace/SKILL.md +28 -0
  19. package/bundled-skills/maggie-mobile-app/SKILL.md +122 -0
  20. package/bundled-skills/maggie-qa-workflow/SKILL.md +30 -0
  21. package/bundled-tools/clis/maggie_app_collect.py +236 -0
  22. package/bundled-tools/clis/maggie_assistant_tools.py +302 -0
  23. package/bundled-tools/clis/maggie_bridge_host.py +212 -0
  24. package/bundled-tools/clis/maggie_design.py +190 -29
  25. package/bundled-tools/clis/maggie_feedback.py +89 -37
  26. package/bundled-tools/clis/maggie_mobile.py +221 -0
  27. package/bundled-tools/clis/maggie_product_release.py +303 -0
  28. package/bundled-tools/clis/maggie_qa_app_preflight.py +248 -0
  29. package/bundled-tools/clis/maggie_qa_workflow.py +5 -0
  30. package/bundled-tools/clis/maggie_skill_catalog.py +262 -0
  31. package/bundled-tools/runtime/assistant_approval.py +111 -0
  32. package/bundled-tools/runtime/cdp_client.py +246 -0
  33. package/package.json +1 -1
@@ -94,6 +94,36 @@ only after the user explicitly consents and a restore path exists. Overriding
94
94
  requests; use network-layer interception as described in the
95
95
  [browser inspection contract](../../references/browser-inspection.md).
96
96
 
97
+ ## Preflight an app dev server
98
+
99
+ Before browser QA of an Expo / React Native Web app (or any signed-in SPA),
100
+ run:
101
+
102
+ ```bash
103
+ maggie qa app-preflight --project . --app-dir apps/app
104
+ ```
105
+
106
+ It fails when a Metro/Expo dev server was started with `CI=1` or
107
+ `--no-watch` (Metro never rebuilds, so the browser shows the old bundle and
108
+ the change looks broken) or from a folder other than the app (a monorepo root
109
+ or a sibling app serves a different project). In a monorepo with several app
110
+ folders it asks for `--app-dir`.
111
+
112
+ It warns, with environment variable names and host classes only, when:
113
+
114
+ - the app stores refresh tokens. If they rotate, two instances refreshing at
115
+ once revoke the session: render one instance at a time, never parallel
116
+ iframes or tabs of the signed-in app, and park the QA parent tab on a static
117
+ same-origin asset such as `/favicon.ico`. `maggie design app-collect`
118
+ renders viewports sequentially in one tab for this reason;
119
+ - the local app talks to a production-like API. Keep the session read-only:
120
+ answer approval cards No, do not submit forms, use
121
+ `maggie design app-collect --read-only --allow-write <refresh path>`, and
122
+ record the production API in `maggie qa start` so mutating scenarios are
123
+ refused.
124
+
125
+ Use `--rotating-refresh-tokens yes|no` when the code scan cannot tell.
126
+
97
127
  ## Check CORS for every method the client uses
98
128
 
99
129
  Before a release that adds or changes API calls from a browser or webview,
@@ -0,0 +1,236 @@
1
+ #!/usr/bin/env python3
2
+ """Collect signed-in runtime evidence for an app surface (``maggie design app-collect``).
3
+
4
+ ``maggie design app-validate`` checks ``maggie-mobile-app-evidence.v1``; this
5
+ command produces it instead of hand-written browser JavaScript. For each plan
6
+ viewport it renders the route in ONE browser tab, one viewport at a time, at
7
+ the real viewport size (device metrics override, not a scaled iframe), then:
8
+
9
+ - counts scroll owners: visible ``overflow-y: auto|scroll`` containers at least
10
+ 200 px tall. React Native Web nests ScrollView wrappers, so a container that
11
+ holds another owner and does not overflow itself is skipped, and so is a
12
+ same-size wrapper (within 16 px, e.g. a scrollbar gutter) around an owner;
13
+ - proves the fixed shell: the elements at the top and bottom edge keep their
14
+ position after every scroll owner is scrolled by 300 px;
15
+ - records the tab bar position and pinned (fixed/sticky) regions, geometry only;
16
+ - writes ``<viewport>.png`` at the real viewport size.
17
+
18
+ Sessions: ``--cdp-url`` attaches to a running Chrome the operator is already
19
+ signed in to (the host session); ``--profile-dir`` launches headless Chrome
20
+ with a dedicated QA profile so a rotated refresh token persists between
21
+ renders. Rendering is sequential in a single tab because apps that rotate
22
+ refresh tokens revoke the grant when two instances refresh at once.
23
+ ``--read-only`` fails non-GET requests except ``--allow-write`` URL substrings
24
+ (for example the token refresh endpoint) when the app talks to production.
25
+ Credentials, cookies, storage and page text are never recorded.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import base64
32
+ import json
33
+ import sys
34
+ import time
35
+ from datetime import datetime, timezone
36
+ from pathlib import Path
37
+
38
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
39
+ from cdp_client import Browser, CdpError, attach, launch # noqa: E402
40
+
41
+ SCHEMA = "maggie-mobile-app-evidence.v1"
42
+ COLLECTOR = "maggie-design-app-collect"
43
+ WRITE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
44
+
45
+ PROBE = r"""
46
+ (async () => {
47
+ const config = __CONFIG__;
48
+ const minHeight = config.minScrollHeight;
49
+ const visible = (el) => { const r = el.getBoundingClientRect(); const s = getComputedStyle(el); return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none'; };
50
+ const scrollable = (el) => { const s = getComputedStyle(el); return /(auto|scroll)/.test(s.overflowY) && el.clientHeight >= minHeight && visible(el); };
51
+ const candidates = Array.from(document.querySelectorAll('body *')).filter(scrollable);
52
+ const overflows = (el) => el.scrollHeight > el.clientHeight + 1;
53
+ const owners = candidates.filter((el) => {
54
+ const inner = candidates.filter((other) => other !== el && el.contains(other));
55
+ if (!inner.length) return true;
56
+ if (overflows(el)) {
57
+ // A same-size wrapper (scrollbar gutter) around an owner is still a wrapper.
58
+ const r = el.getBoundingClientRect();
59
+ return !inner.some((other) => { const o = other.getBoundingClientRect(); return Math.abs(o.height - r.height) <= 16 && Math.abs(o.width - r.width) <= 16; });
60
+ }
61
+ return false;
62
+ });
63
+ const doc = document.scrollingElement || document.documentElement;
64
+ const documentScrolls = doc.scrollHeight > window.innerHeight + 1 && !/hidden|clip/.test(getComputedStyle(document.body).overflowY + getComputedStyle(document.documentElement).overflowY);
65
+ const pick = (selector, y) => {
66
+ if (selector) return document.querySelector(selector);
67
+ let el = document.elementFromPoint(Math.floor(window.innerWidth / 2), y);
68
+ if (!el || owners.some((o) => o.contains(el))) return null; // content, not shell
69
+ // Climb to the outermost ancestor that still contains no scroll owner: the bar itself.
70
+ while (el.parentElement && el.parentElement !== document.body && !owners.some((o) => el.parentElement.contains(o))) el = el.parentElement;
71
+ return el;
72
+ };
73
+ const header = pick(config.headerSelector, 2);
74
+ const footer = pick(config.footerSelector, window.innerHeight - 3);
75
+ const rect = (el) => { if (!el) return null; const r = el.getBoundingClientRect(); return { top: Math.round(r.top), bottom: Math.round(r.bottom), left: Math.round(r.left), width: Math.round(r.width), height: Math.round(r.height) }; };
76
+ const before = { header: rect(header), footer: rect(footer) };
77
+ const scrolled = [];
78
+ for (const owner of owners) { const start = owner.scrollTop; owner.scrollTop = start + 300; scrolled.push([owner, start]); }
79
+ const winStart = window.scrollY; window.scrollTo(0, winStart + 300);
80
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
81
+ const after = { header: rect(header), footer: rect(footer) };
82
+ for (const [owner, start] of scrolled) owner.scrollTop = start;
83
+ window.scrollTo(0, winStart);
84
+ const same = (a, b) => !!a && !!b && Math.abs(a.top - b.top) <= 1 && a.height > 0;
85
+ const fixedHeader = !!header && before.header.top <= 1 && same(before.header, after.header);
86
+ const fixedFooter = !!footer && before.footer.bottom >= window.innerHeight - 1 && same(before.footer, after.footer);
87
+ const tablist = document.querySelector(config.tabBarSelector || '[role="tablist"], nav[aria-label*="tab" i]');
88
+ let tabBar = 'none';
89
+ if (tablist && visible(tablist)) {
90
+ const r = tablist.getBoundingClientRect();
91
+ tabBar = r.bottom >= window.innerHeight - 2 ? 'bottom' : r.top <= 2 && r.width >= window.innerWidth * 0.6 ? 'top' : r.height >= window.innerHeight * 0.6 ? 'side' : 'inline';
92
+ }
93
+ const pinned = Array.from(document.querySelectorAll('body *')).filter((el) => { const p = getComputedStyle(el).position; return (p === 'fixed' || p === 'sticky') && visible(el); }).slice(0, 20).map((el) => ({ tag: el.tagName.toLowerCase(), role: el.getAttribute('role') || null, position: getComputedStyle(el).position, rect: rect(el) }));
94
+ const path = location.pathname.replace(/\/+$/, '') || '/';
95
+ const want = config.route.replace(/\/+$/, '') || '/';
96
+ const onRoute = path === want;
97
+ const signedInMarker = config.signedInSelector ? !!document.querySelector(config.signedInSelector) : null;
98
+ const signedOutMarker = config.signedOutSelector ? !!document.querySelector(config.signedOutSelector) : false;
99
+ return {
100
+ width: window.innerWidth, height: window.innerHeight, deviceScaleFactor: window.devicePixelRatio,
101
+ scrollOwners: owners.length + (documentScrolls ? 1 : 0), documentScrolls,
102
+ scrollOwnerGeometry: owners.map(rect), skippedWrappers: candidates.length - owners.length,
103
+ fixedHeader, fixedFooter, header: before.header, footer: before.footer, tabBar, pinned,
104
+ onRoute, signedIn: onRoute && signedInMarker !== false && !signedOutMarker,
105
+ };
106
+ })()
107
+ """
108
+
109
+
110
+ def probe_expression(route: str, options: dict) -> str:
111
+ config = {"route": route, "minScrollHeight": 200, **options}
112
+ return PROBE.replace("__CONFIG__", json.dumps(config))
113
+
114
+
115
+ def build_evidence(plan: dict, renders: dict[str, dict], session_mode: str, previous: dict | None, warnings: list[str]) -> dict:
116
+ """Assemble ``maggie-mobile-app-evidence.v1`` from per-viewport probe results."""
117
+ previous = previous or {}
118
+ viewports = {}
119
+ for name, result in renders.items():
120
+ viewports[name] = {key: result.get(key) for key in ("width", "height", "deviceScaleFactor", "scrollOwners", "documentScrolls", "skippedWrappers", "fixedHeader", "fixedFooter", "tabBar", "header", "footer", "pinned", "scrollOwnerGeometry")}
121
+ viewports[name].update({"screenshot": f"{name}.png", "captured": bool(result.get("captured")), "fullPage": False})
122
+ rendered = [result for result in renders.values() if result]
123
+ signed_in = bool(rendered) and all(result.get("signedIn") for result in rendered)
124
+ capabilities = previous.get("capabilities") if isinstance(previous.get("capabilities"), dict) else {name: {"states": []} for name in plan.get("capabilities", {})}
125
+ return {
126
+ "schemaVersion": SCHEMA,
127
+ "collector": COLLECTOR,
128
+ "route": plan.get("route"),
129
+ "auth": {"signedIn": signed_in, "sessionMode": session_mode, "credentialsRecorded": False},
130
+ "shell": {"fixedHeader": bool(rendered) and all(r.get("fixedHeader") for r in rendered), "fixedFooter": bool(rendered) and all(r.get("fixedFooter") for r in rendered), "method": "edge elements keep their position after each scroll owner scrolls 300 px; RN-web wrappers that do not overflow are skipped"},
131
+ "capabilities": capabilities,
132
+ "viewports": viewports,
133
+ "warnings": warnings,
134
+ "status": "collected",
135
+ "collectedAt": datetime.now(timezone.utc).isoformat(),
136
+ "mutation": "read-only" if any("read-only" in w for w in warnings) else "not blocked",
137
+ }
138
+
139
+
140
+ def _install_read_only(browser: Browser, allow: list[str], blocked: list[str]) -> None:
141
+ def paused(params: dict) -> None:
142
+ request = params.get("request", {})
143
+ url = request.get("url", "")
144
+ if request.get("method", "GET").upper() in WRITE_METHODS and not any(token in url for token in allow):
145
+ blocked.append(f"{request.get('method')} {url.split('?')[0][:120]}")
146
+ browser.call("Fetch.failRequest", {"requestId": params["requestId"], "errorReason": "BlockedByClient"})
147
+ else:
148
+ browser.call("Fetch.continueRequest", {"requestId": params["requestId"]})
149
+ browser.handlers["Fetch.requestPaused"] = paused
150
+ browser.call("Fetch.enable", {"patterns": [{"urlPattern": "*", "requestStage": "Request"}]})
151
+
152
+
153
+ def collect(plan_path: Path, base_url: str, browser: Browser, session_mode: str, output_dir: Path, options: dict, read_only: bool, allow_write: list[str], settle_ms: int, budget_ms: int) -> dict:
154
+ plan = json.loads(plan_path.read_text(encoding="utf-8"))
155
+ if plan.get("mode") != "app-view-init":
156
+ raise ValueError("plan is not a maggie design app-init plan")
157
+ route = plan.get("route")
158
+ viewports = {name: tuple(size) for name, size in plan.get("viewports", {}).items()}
159
+ if not viewports:
160
+ raise ValueError("plan has no viewports")
161
+ warnings: list[str] = []
162
+ blocked: list[str] = []
163
+ screenshots = output_dir / "screenshots"
164
+ screenshots.mkdir(parents=True, exist_ok=True)
165
+ renders: dict[str, dict] = {}
166
+ browser.open_tab()
167
+ browser.call("Page.enable")
168
+ if read_only:
169
+ _install_read_only(browser, allow_write, blocked)
170
+ warnings.append("read-only: non-GET requests were failed except --allow-write matches")
171
+ url = base_url.rstrip("/") + route
172
+ for name, (width, height) in viewports.items(): # sequential: one app instance at a time
173
+ browser.call("Emulation.setDeviceMetricsOverride", {"width": width, "height": height, "deviceScaleFactor": 1, "mobile": width < 768})
174
+ browser.call("Page.navigate", {"url": url})
175
+ if browser.wait_for("Page.loadEventFired", budget_ms / 1000) is None:
176
+ warnings.append(f"{name}: load event not seen within {budget_ms} ms")
177
+ browser.pump(settle_ms / 1000)
178
+ result = browser.evaluate(probe_expression(route, options)) or {}
179
+ shot = browser.call("Page.captureScreenshot", {"format": "png", "captureBeyondViewport": False})
180
+ (screenshots / f"{name}.png").write_bytes(base64.b64decode(shot["data"]))
181
+ result["captured"] = True
182
+ renders[name] = result
183
+ if not result.get("onRoute"):
184
+ warnings.append(f"{name}: the app left {route} (likely a sign-in redirect)")
185
+ time.sleep(0.05)
186
+ if blocked:
187
+ warnings.append(f"read-only blocked {len(blocked)} write request(s): " + ", ".join(sorted(set(blocked))[:5]))
188
+ previous_path = output_dir / "runtime-evidence.json"
189
+ previous = json.loads(previous_path.read_text(encoding="utf-8")) if previous_path.is_file() else None
190
+ return build_evidence(plan, renders, session_mode, previous, warnings)
191
+
192
+
193
+ def main(argv: list[str] | None = None) -> int:
194
+ parser = argparse.ArgumentParser(prog="maggie design app-collect", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
195
+ parser.add_argument("--plan", type=Path, required=True, help="plan.json from maggie design app-init")
196
+ parser.add_argument("--base-url", required=True, help="app origin, e.g. http://localhost:8081")
197
+ session = parser.add_mutually_exclusive_group(required=True)
198
+ session.add_argument("--cdp-url", help="attach to a signed-in Chrome started with --remote-debugging-port, e.g. http://127.0.0.1:9222")
199
+ session.add_argument("--profile-dir", type=Path, help="dedicated QA Chrome profile (never your everyday profile)")
200
+ parser.add_argument("--headed", action="store_true", help="with --profile-dir, show the window (sign in once, then rerun headless)")
201
+ parser.add_argument("--chrome", default="", help="Chrome executable (default: google-chrome/chromium on PATH)")
202
+ parser.add_argument("--header-selector"); parser.add_argument("--footer-selector"); parser.add_argument("--tab-bar-selector")
203
+ parser.add_argument("--signed-in-selector", help="element present only when signed in"); parser.add_argument("--signed-out-selector", help="element present only when signed out")
204
+ parser.add_argument("--read-only", action="store_true", help="fail non-GET requests (use when the app talks to production)")
205
+ parser.add_argument("--allow-write", action="append", default=[], help="URL substring allowed to POST under --read-only (e.g. /auth/refresh)")
206
+ parser.add_argument("--settle-ms", type=int, default=1500)
207
+ parser.add_argument("--budget-ms", type=int, default=20000)
208
+ parser.add_argument("--confirm", action="store_true", help="write runtime-evidence.json and screenshots next to the plan")
209
+ args = parser.parse_args(argv)
210
+ plan_path = args.plan.resolve()
211
+ output_dir = plan_path.parent if args.confirm else Path(__import__("tempfile").mkdtemp(prefix="maggie-app-collect-"))
212
+ options = {"headerSelector": args.header_selector, "footerSelector": args.footer_selector, "tabBarSelector": args.tab_bar_selector, "signedInSelector": args.signed_in_selector, "signedOutSelector": args.signed_out_selector}
213
+ browser = None
214
+ try:
215
+ if args.cdp_url:
216
+ browser, mode = attach(args.cdp_url), "attached-host-session"
217
+ else:
218
+ from maggie_icon_glyph_check import find_chrome
219
+ browser, mode = launch(args.chrome or find_chrome(), args.profile_dir.resolve(), headless=not args.headed), "dedicated-profile"
220
+ evidence = collect(plan_path, args.base_url, browser, mode, output_dir, options, args.read_only, args.allow_write, args.settle_ms, args.budget_ms)
221
+ except (OSError, ValueError, CdpError, json.JSONDecodeError, RuntimeError) as error:
222
+ print(f"BLOCKED: maggie-design app-collect: {error}", file=sys.stderr)
223
+ return 2
224
+ finally:
225
+ if browser:
226
+ browser.close()
227
+ target = output_dir / "runtime-evidence.json"
228
+ target.write_text(json.dumps(evidence, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
229
+ summary = {"schemaVersion": SCHEMA, "collector": COLLECTOR, "signedIn": evidence["auth"]["signedIn"], "viewports": {name: {k: v.get(k) for k in ("width", "height", "scrollOwners", "fixedHeader", "fixedFooter", "tabBar")} for name, v in evidence["viewports"].items()}, "warnings": evidence["warnings"], "output": str(target), "written": args.confirm}
230
+ print(json.dumps(summary, indent=2, ensure_ascii=False))
231
+ print("next: maggie design app-validate --plan " + str(plan_path) + (" --runtime-evidence " + str(target) if not args.confirm else ""), file=sys.stderr)
232
+ return 0
233
+
234
+
235
+ if __name__ == "__main__":
236
+ raise SystemExit(main())
@@ -0,0 +1,302 @@
1
+ #!/usr/bin/env python3
2
+ """Assistant tool registry checks (``maggie assistant``).
3
+
4
+ Build an assistant's tools once and expose them to an in-app agent, an MCP
5
+ server and ChatGPT/Claude apps. The product exports its registry as JSON
6
+ (``maggie-assistant-tool-registry.v1``); these commands never import product
7
+ code or call a model.
8
+
9
+ - ``registry-validate``: names, risk levels, gates, scopes, channels, and the
10
+ user-facing copy (label, detail, confirm sentence) kept separate from the
11
+ model-facing description. User copy may not contain snake_case tool names,
12
+ "the user", or "(from some_tool)" references.
13
+ - ``annotations``: MCP tool annotations derived from risk (and checked against
14
+ an exported ``tools/list`` with ``--mcp-tools``).
15
+ - ``capability-page``: the "what the assistant can and can't do" page from user
16
+ copy only, grouped by what needs approval.
17
+ - ``audit-check``: every write in a recorded session has one audit entry, with
18
+ a readable summary (never a bare UUID) and the right scope (account-level
19
+ writes are not filed under the open site).
20
+ - ``approval-selftest``: exercises the reference signed one-time approval
21
+ token (``tools/runtime/assistant_approval.py``).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import json
28
+ import re
29
+ import sys
30
+ from pathlib import Path
31
+
32
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
33
+
34
+ SCHEMA = "maggie-assistant-tool-registry.v1"
35
+ RISKS = ("read", "write-reversible", "write-spends", "write-public", "never")
36
+ RISK_ALIASES = {"R0": "read", "W1": "write-reversible", "W2": "write-spends", "W3": "write-public", "W4": "never"}
37
+ CHANNELS = ("in-app", "mcp", "chatgpt", "claude", "cli")
38
+ SCOPES = ("account", "workspace")
39
+ NAME_RE = re.compile(r"^[a-z][a-z0-9]*(?:_[a-z0-9]+)+$|^[a-z][a-z0-9]+$")
40
+ SNAKE_RE = re.compile(r"\b[a-z][a-z0-9]*_[a-z0-9_]+\b")
41
+ THE_USER_RE = re.compile(r"\bthe user('s)?\b", re.I)
42
+ FROM_TOOL_RE = re.compile(r"\((?:from|via|using) [a-z0-9_]+\)", re.I)
43
+ UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I)
44
+ DESTRUCTIVE_NAME_RE = re.compile(r"_(?:remove|delete|discard|revoke|cancel|unpublish)$")
45
+
46
+
47
+ def risk_of(tool: dict) -> str:
48
+ value = str(tool.get("risk", ""))
49
+ return RISK_ALIASES.get(value, value)
50
+
51
+
52
+ def user_copy_problems(text: str, tool_names: set[str]) -> list[str]:
53
+ problems = []
54
+ if SNAKE_RE.search(text) or any(name in text for name in tool_names if "_" in name):
55
+ problems.append("contains a snake_case tool name")
56
+ if THE_USER_RE.search(text):
57
+ problems.append("says 'the user' (address the reader as 'you')")
58
+ if FROM_TOOL_RE.search(text):
59
+ problems.append("references another tool in parentheses")
60
+ return problems
61
+
62
+
63
+ def validate_registry(registry: dict) -> dict:
64
+ errors: list[str] = []
65
+ warnings: list[str] = []
66
+ if registry.get("schemaVersion") != SCHEMA:
67
+ errors.append(f"schemaVersion must be {SCHEMA}")
68
+ tools = registry.get("tools")
69
+ if not isinstance(tools, list) or not tools:
70
+ return {"schemaVersion": "maggie-assistant-registry-validation.v1", "passed": False, "errors": errors + ["tools must be a non-empty array"], "warnings": warnings, "counts": {}}
71
+ gates = registry.get("gates", {}) if isinstance(registry.get("gates"), dict) else {}
72
+ names = [str(tool.get("name", "")) for tool in tools if isinstance(tool, dict)]
73
+ name_set = set(names)
74
+ for duplicate in sorted({name for name in names if names.count(name) > 1}):
75
+ errors.append(f"duplicate tool name {duplicate}")
76
+ counts = {risk: 0 for risk in RISKS}
77
+ for tool in tools:
78
+ if not isinstance(tool, dict):
79
+ errors.append("each tool must be an object"); continue
80
+ name = str(tool.get("name", ""))
81
+ where = name or "<unnamed>"
82
+ if not NAME_RE.match(name) or len(name) > 64:
83
+ errors.append(f"{where}: name must be lower snake_case, at most 64 characters")
84
+ risk = risk_of(tool)
85
+ if risk not in RISKS:
86
+ errors.append(f"{where}: risk must be one of {', '.join(RISKS)} (or R0/W1/W2/W3/W4)"); continue
87
+ counts[risk] += 1
88
+ description = str(tool.get("description", "")).strip()
89
+ if not description:
90
+ errors.append(f"{where}: model-facing description is required")
91
+ elif len(description) > 1024:
92
+ warnings.append(f"{where}: description is over 1024 characters; models and app reviewers truncate long descriptions")
93
+ if tool.get("scope") not in SCOPES:
94
+ errors.append(f"{where}: scope must be account or workspace")
95
+ channels = tool.get("channels", [])
96
+ if not isinstance(channels, list) or not channels or any(channel not in CHANNELS for channel in channels):
97
+ errors.append(f"{where}: channels must list some of {', '.join(CHANNELS)}")
98
+ channels = []
99
+ for gate in tool.get("gates", []) or []:
100
+ if gate not in gates:
101
+ errors.append(f"{where}: gate {gate!r} is not declared in registry.gates")
102
+ user = tool.get("user") if isinstance(tool.get("user"), dict) else {}
103
+ label = str(user.get("label", "")).strip()
104
+ detail = str(user.get("detail", "")).strip()
105
+ confirm = str(user.get("confirm", "")).strip()
106
+ if not label:
107
+ errors.append(f"{where}: user.label is required (do not derive it from the model description)")
108
+ elif len(label) > 60:
109
+ errors.append(f"{where}: user.label is over 60 characters")
110
+ if detail and len(detail) > 200:
111
+ warnings.append(f"{where}: user.detail is over 200 characters")
112
+ if risk not in {"read", "never"} and not confirm:
113
+ errors.append(f"{where}: writes need user.confirm, the sentence shown on the approval card")
114
+ for field, text in (("label", label), ("detail", detail), ("confirm", confirm)):
115
+ for problem in user_copy_problems(text, name_set):
116
+ errors.append(f"{where}: user.{field} {problem}")
117
+ if risk == "never" and any(channel != "in-app" for channel in channels):
118
+ errors.append(f"{where}: a 'never' tool must not be exposed on {', '.join(c for c in channels if c != 'in-app')}; list it under cantDo instead")
119
+ if risk in {"write-public", "never"} and tool.get("idempotent") is True:
120
+ warnings.append(f"{where}: public/irreversible writes are rarely idempotent; check idempotent: true")
121
+ if DESTRUCTIVE_NAME_RE.search(name) and risk in {"read", "write-reversible"}:
122
+ warnings.append(f"{where}: the name reads as destructive; app reviewers expect destructiveHint, so consider write-public")
123
+ if tool.get("audit", {}).get("scope") not in (None, tool.get("scope")):
124
+ errors.append(f"{where}: audit.scope must equal the tool scope (an account-level write is not audited under the open site)")
125
+ for item in registry.get("cantDo", []) or []:
126
+ if not isinstance(item, dict) or not str(item.get("text", "")).strip():
127
+ errors.append("cantDo entries need text (and ideally 'instead')")
128
+ continue
129
+ for problem in user_copy_problems(str(item.get("text", "")) + " " + str(item.get("instead", "")), name_set):
130
+ errors.append(f"cantDo '{str(item['text'])[:40]}': {problem}")
131
+ return {"schemaVersion": "maggie-assistant-registry-validation.v1", "passed": not errors, "errors": errors, "warnings": warnings, "counts": {"tools": len(tools), **counts}}
132
+
133
+
134
+ def annotations_for(tool: dict) -> dict:
135
+ risk = risk_of(tool)
136
+ title = str(tool.get("user", {}).get("label", "")).strip() or None
137
+ value = {
138
+ "readOnlyHint": risk == "read",
139
+ "destructiveHint": risk in {"write-public", "never"} or bool(DESTRUCTIVE_NAME_RE.search(str(tool.get("name", "")))),
140
+ "idempotentHint": risk == "read" or tool.get("idempotent") is True,
141
+ "openWorldHint": risk == "write-public" or tool.get("openWorld") is True,
142
+ }
143
+ if title:
144
+ value["title"] = title
145
+ return value
146
+
147
+
148
+ def derive_annotations(registry: dict, mcp_tools: dict | None) -> dict:
149
+ tools = [tool for tool in registry.get("tools", []) if isinstance(tool, dict) and "mcp" in (tool.get("channels") or []) and risk_of(tool) != "never"]
150
+ derived = {tool["name"]: annotations_for(tool) for tool in tools}
151
+ report = {"schemaVersion": "maggie-assistant-mcp-annotations.v1", "tools": derived, "drift": []}
152
+ if mcp_tools is not None:
153
+ listed = {item.get("name"): item for item in (mcp_tools.get("tools") or mcp_tools.get("result", {}).get("tools") or []) if isinstance(item, dict)}
154
+ for name, expected in derived.items():
155
+ actual = listed.get(name)
156
+ if actual is None:
157
+ report["drift"].append(f"{name}: in the registry for mcp but not served by the MCP server")
158
+ continue
159
+ for key, want in expected.items():
160
+ got = (actual.get("annotations") or {}).get(key)
161
+ if key != "title" and got != want:
162
+ report["drift"].append(f"{name}: {key} is {got!r}, risk implies {want!r}")
163
+ for name in sorted(set(listed) - set(derived)):
164
+ report["drift"].append(f"{name}: served by the MCP server but not in the registry (or marked never)")
165
+ report["passed"] = not report["drift"]
166
+ return report
167
+
168
+
169
+ def capability_page(registry: dict, title: str) -> tuple[str, dict]:
170
+ groups = {"read": [], "asks": [], "careful": []}
171
+ for tool in registry.get("tools", []):
172
+ risk = risk_of(tool)
173
+ user = tool.get("user", {})
174
+ if risk == "never" or tool.get("hidden"):
175
+ continue
176
+ entry = {"label": user.get("label", ""), "detail": user.get("detail", ""), "gates": [registry.get("gates", {}).get(g, {}).get("label", g) for g in tool.get("gates", []) or []]}
177
+ groups["read" if risk == "read" else "careful" if risk == "write-public" else "asks"].append(entry)
178
+ cant = [{"text": item.get("text", ""), "instead": item.get("instead", "")} for item in registry.get("cantDo", []) or []]
179
+ for tool in registry.get("tools", []):
180
+ if risk_of(tool) == "never":
181
+ cant.append({"text": tool.get("user", {}).get("label", ""), "instead": tool.get("user", {}).get("detail", "")})
182
+ lines = [f"# {title}", ""]
183
+ sections = (("read", "Looks things up for you", "No approval needed; nothing changes."), ("asks", "Changes things after you approve", "You see what will change and approve it first."), ("careful", "Publishes or is hard to undo", "Always asks, and says so clearly."))
184
+ for key, heading, note in sections:
185
+ if not groups[key]:
186
+ continue
187
+ lines += [f"## {heading}", "", note, ""]
188
+ for entry in sorted(groups[key], key=lambda item: item["label"].lower()):
189
+ gates = f" _({', '.join(entry['gates'])})_" if entry["gates"] else ""
190
+ lines.append(f"- **{entry['label']}**" + (f" — {entry['detail']}" if entry["detail"] else "") + gates)
191
+ lines.append("")
192
+ if cant:
193
+ lines += ["## Can't do", ""]
194
+ for item in cant:
195
+ lines.append(f"- {item['text']}" + (f" Instead: {item['instead']}" if item["instead"] else ""))
196
+ lines.append("")
197
+ data = {"schemaVersion": "maggie-assistant-capability-page.v1", "title": title, "groups": groups, "cantDo": cant}
198
+ return "\n".join(lines), data
199
+
200
+
201
+ def audit_check(registry: dict, session: dict) -> dict:
202
+ tools = {tool.get("name"): tool for tool in registry.get("tools", []) if isinstance(tool, dict)}
203
+ errors: list[str] = []
204
+ calls = [call for call in session.get("calls", []) if isinstance(call, dict)]
205
+ entries = [entry for entry in session.get("audit", []) if isinstance(entry, dict)]
206
+ by_action: dict[str, list[dict]] = {}
207
+ for entry in entries:
208
+ for action in entry.get("actions", []) or []:
209
+ by_action.setdefault(str(action.get("actionId", "")), []).append({**action, "scope": entry.get("scope"), "channel": entry.get("channel")})
210
+ for call in calls:
211
+ tool = tools.get(call.get("tool"))
212
+ if tool is None:
213
+ errors.append(f"{call.get('tool')}: called but not in the registry"); continue
214
+ if risk_of(tool) == "read":
215
+ continue
216
+ action_id = str(call.get("actionId", ""))
217
+ matches = [a for a in by_action.get(action_id, []) if a.get("tool") == call.get("tool")]
218
+ if len(matches) != 1:
219
+ errors.append(f"{call.get('tool')} ({action_id or 'no actionId'}): expected one audit entry, found {len(matches)}"); continue
220
+ action = matches[0]
221
+ summary = str(action.get("summary", "")).strip()
222
+ if not summary or UUID_RE.match(summary) or re.search(r"[0-9a-f]{8}-[0-9a-f]{4}-", summary):
223
+ errors.append(f"{call.get('tool')}: audit summary must be readable, not a raw ID (fall back to the tool label)")
224
+ expected_scope = "account" if tool.get("scope") == "account" else call.get("workspace")
225
+ if expected_scope and action.get("scope") != expected_scope:
226
+ errors.append(f"{call.get('tool')}: audited under {action.get('scope')!r}, expected {expected_scope!r}")
227
+ if call.get("ok") is False and not action.get("error"):
228
+ errors.append(f"{call.get('tool')}: failed call has no audit error")
229
+ return {"schemaVersion": "maggie-assistant-audit-check.v1", "passed": not errors, "errors": errors, "writes": sum(1 for c in calls if risk_of(tools.get(c.get("tool"), {})) not in {"read", ""})}
230
+
231
+
232
+ def approval_selftest() -> dict:
233
+ import assistant_approval as approval
234
+ secret = b"selftest-secret-not-for-production"
235
+ calls = [{"name": "post_schedule", "args": {"postId": "p1", "at": "2026-10-12T09:00:00Z"}}]
236
+ results = {}
237
+ token, issued = approval.issue(secret, "key-1", "site-a", calls, now=1000)
238
+ store = approval.TurnStore(); store.propose("turn-1", token)
239
+ results["clientSendsOnlyYes"] = approval.verify(secret, store.consume("turn-1"), "key-1", "site-a", calls, now=1001)["id"] == issued["id"]
240
+ def raises(fn) -> bool:
241
+ try:
242
+ fn(); return False
243
+ except approval.ApprovalError:
244
+ return True
245
+ results["singleUse"] = raises(lambda: store.consume("turn-1"))
246
+ results["expires"] = raises(lambda: approval.verify(secret, token, "key-1", "site-a", calls, now=1000 + 601))
247
+ results["argsBound"] = raises(lambda: approval.verify(secret, token, "key-1", "site-a", [{"name": "post_schedule", "args": {"postId": "p2"}}], now=1001))
248
+ results["principalBound"] = raises(lambda: approval.verify(secret, token, "key-2", "site-a", calls, now=1001))
249
+ results["scopeBound"] = raises(lambda: approval.verify(secret, token, "key-1", "site-b", calls, now=1001))
250
+ results["tamperRejected"] = raises(lambda: approval.verify(secret, token[:-2] + ("AA" if not token.endswith("AA") else "BB"), "key-1", "site-a", calls, now=1001))
251
+ results["idempotencyKeyStable"] = approval.idempotency_key(issued["id"], calls[0]) == approval.idempotency_key(issued["id"], dict(calls[0]))
252
+ return {"schemaVersion": "maggie-assistant-approval-selftest.v1", "passed": all(results.values()), "checks": results}
253
+
254
+
255
+ def _read(path: Path) -> dict:
256
+ value = json.loads(path.read_text(encoding="utf-8"))
257
+ if not isinstance(value, dict):
258
+ raise ValueError(f"{path.name} must be a JSON object")
259
+ return value
260
+
261
+
262
+ def main(argv: list[str] | None = None) -> int:
263
+ parser = argparse.ArgumentParser(prog="maggie assistant", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
264
+ sub = parser.add_subparsers(dest="command", required=True)
265
+ validate = sub.add_parser("registry-validate"); validate.add_argument("--registry", type=Path, required=True)
266
+ annotate = sub.add_parser("annotations"); annotate.add_argument("--registry", type=Path, required=True); annotate.add_argument("--mcp-tools", type=Path, help="exported MCP tools/list JSON to compare")
267
+ page = sub.add_parser("capability-page"); page.add_argument("--registry", type=Path, required=True); page.add_argument("--title", default="What the assistant can and can't do"); page.add_argument("--output", type=Path, help="Markdown file; JSON is written next to it")
268
+ audit = sub.add_parser("audit-check"); audit.add_argument("--registry", type=Path, required=True); audit.add_argument("--session", type=Path, required=True, help="recorded calls + audit entries (maggie-assistant-session.v1)")
269
+ sub.add_parser("approval-selftest")
270
+ args = parser.parse_args(argv)
271
+ try:
272
+ if args.command == "approval-selftest":
273
+ report = approval_selftest()
274
+ else:
275
+ registry = _read(args.registry)
276
+ if args.command == "registry-validate":
277
+ report = validate_registry(registry)
278
+ elif args.command == "annotations":
279
+ report = derive_annotations(registry, _read(args.mcp_tools) if args.mcp_tools else None)
280
+ elif args.command == "capability-page":
281
+ checked = validate_registry(registry)
282
+ if not checked["passed"]:
283
+ print(json.dumps(checked, indent=2, ensure_ascii=False)); return 1
284
+ markdown, data = capability_page(registry, args.title)
285
+ if args.output:
286
+ args.output.parent.mkdir(parents=True, exist_ok=True)
287
+ args.output.write_text(markdown + "\n", encoding="utf-8")
288
+ args.output.with_suffix(".json").write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
289
+ report = {"schemaVersion": data["schemaVersion"], "passed": True, "output": str(args.output), "counts": {k: len(v) for k, v in data["groups"].items()}, "cantDo": len(data["cantDo"])}
290
+ else:
291
+ print(markdown); return 0
292
+ else:
293
+ report = audit_check(registry, _read(args.session))
294
+ except (OSError, ValueError, json.JSONDecodeError) as error:
295
+ print(f"BLOCKED: maggie assistant {args.command}: {error}", file=sys.stderr)
296
+ return 2
297
+ print(json.dumps(report, indent=2, ensure_ascii=False))
298
+ return 0 if report.get("passed") else 1
299
+
300
+
301
+ if __name__ == "__main__":
302
+ raise SystemExit(main())