@topy-ai/maggie 0.7.54 → 0.7.55

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 (36) hide show
  1. package/README-zh-TW.md +22 -22
  2. package/README.md +62 -67
  3. package/bin/maggie.js +10 -2
  4. package/bundled-contracts/maggie-assistant-tools/booking-receptionist-example-v1.json +18 -2
  5. package/bundled-contracts/maggie-assistant-tools/report-v1.schema.json +43 -0
  6. package/bundled-contracts/maggie-assistant-tools/tool-registry-example-v1.json +249 -11
  7. package/bundled-contracts/maggie-assistant-tools/tool-registry-v1.schema.json +175 -0
  8. package/bundled-contracts/maggie-ops/agent-routines-v1.schema.json +48 -0
  9. package/bundled-skills/catalog.json +1 -1
  10. package/bundled-skills/maggie-assistant-tools/SKILL.md +34 -1
  11. package/bundled-skills/maggie-assistant-tools/references/report-inbox.md +45 -0
  12. package/bundled-skills/maggie-assistant-tools/references/unattended-agents.md +70 -0
  13. package/bundled-skills/maggie-blog/SKILL.md +9 -2
  14. package/bundled-skills/maggie-booking/SKILL.md +1 -1
  15. package/bundled-skills/maggie-dash/SKILL.md +9 -0
  16. package/bundled-skills/maggie-deployment/SKILL.md +77 -1
  17. package/bundled-skills/maggie-deployment/references/vps.md +5 -0
  18. package/bundled-skills/maggie-marketplace/SKILL.md +21 -1
  19. package/bundled-skills/maggie-ops/SKILL.md +28 -0
  20. package/bundled-skills/maggie-ops/references/agents-repository.md +108 -0
  21. package/bundled-skills/maggie-ops/references/ops-autopilot.md +65 -0
  22. package/bundled-skills/maggie-ops/references/routine-mode.md +92 -0
  23. package/bundled-skills/maggie-seo-geo/SKILL.md +9 -1
  24. package/bundled-skills/maggie-social-share/SKILL.md +20 -0
  25. package/bundled-tools/clis/maggie.py +63 -3
  26. package/bundled-tools/clis/maggie_assistant_tools.py +225 -1
  27. package/bundled-tools/clis/maggie_host_runtime.py +458 -0
  28. package/bundled-tools/clis/maggie_live_urls.py +11 -2
  29. package/bundled-tools/clis/maggie_ops.py +2 -0
  30. package/bundled-tools/clis/maggie_ops_routines.py +352 -0
  31. package/bundled-tools/clis/maggie_query_ideas.py +154 -0
  32. package/bundled-tools/clis/maggie_skill_catalog.py +16 -2
  33. package/bundled-tools/clis/maggie_social_share.py +101 -1
  34. package/bundled-tools/clis/maggie_version_sources.py +93 -0
  35. package/bundled-tools/integrations/maggie-api-pull.md +28 -1
  36. package/package.json +1 -1
@@ -14,7 +14,7 @@ import time
14
14
  from datetime import datetime, timezone
15
15
  from pathlib import Path
16
16
  from urllib.error import HTTPError, URLError
17
- from urllib.parse import quote
17
+ from urllib.parse import quote, urlsplit
18
18
  from urllib.request import Request, urlopen
19
19
 
20
20
 
@@ -478,13 +478,73 @@ def command_api_lifecycle(args: argparse.Namespace) -> int:
478
478
  def record(step: str, value: object):
479
479
  state["steps"][step] = {"status":"passed","completed_at":utc_now(),"summary":response_summary(value)}
480
480
  if step in {"posts","updates"}:
481
- items = value.get("items", []) if isinstance(value, dict) else value if isinstance(value, list) else []
481
+ items = delivered_items(value, step)
482
+ if step == "updates":
483
+ state["steps"][step]["summary"]["item_count"] = len(items)
482
484
  for item in items:
483
485
  if not isinstance(item, dict): continue
484
486
  remote_id = item.get("content_id") or item.get("contentId") or item.get("id") or item.get("post_id") or item.get("postId")
485
487
  if not remote_id: continue
486
488
  body = item.get("content") or item.get("body") or item.get("html") or item.get("content_markdown") or item.get("content_html") or ""
487
- state["content"][str(remote_id)] = {"remote_content_id":str(remote_id),"version":item.get("version") or item.get("updated_at") or item.get("updatedAt"),"slug":item.get("slug"),"canonical_url":item.get("canonical_url") or item.get("canonicalUrl") or item.get("url"),"title":item.get("title"),"content":body,"content_markdown":item.get("content_markdown") or item.get("contentMarkdown"),"content_html":item.get("content_html") or item.get("contentHtml"),"content_portable_text":item.get("content_portable_text") or item.get("portable_text") or item.get("contentPortableText"),"status":item.get("status") or "pulled","sha256":hashlib.sha256(json.dumps(item,sort_keys=True,ensure_ascii=False).encode()).hexdigest(),"source_step":step,"pulled_at":utc_now()}
489
+ fields = {"version":item.get("version") or item.get("version_id") or item.get("updated_at") or item.get("updatedAt"),"content":body,"content_markdown":item.get("content_markdown") or item.get("contentMarkdown"),"content_html":item.get("content_html") or item.get("contentHtml"),"content_portable_text":item.get("content_portable_text") or item.get("portable_text") or item.get("contentPortableText"),"sha256":hashlib.sha256(json.dumps(item,sort_keys=True,ensure_ascii=False).encode()).hexdigest(),"source_step":step,"pulled_at":utc_now()}
490
+ if step == "updates":
491
+ apply_rewrite(item, str(remote_id), fields)
492
+ continue
493
+ previous = state["content"].get(str(remote_id), {})
494
+ record_value = {"remote_content_id":str(remote_id),"slug":item.get("slug"),"canonical_url":item.get("canonical_url") or item.get("canonicalUrl") or item.get("url"),"title":item.get("title"),**fields,"status":item.get("status") or "pulled"}
495
+ # A re-delivered post keeps where it went live and its rewrite history.
496
+ for key in ("live_url","live_url_source","live_url_matched_at","rewrites","previous_slugs"):
497
+ if key in previous: record_value[key] = previous[key]
498
+ state["content"][str(remote_id)] = record_value
499
+
500
+ def find_rewrite_target(item: dict, remote_id: str) -> str | None:
501
+ """The delivered post a rewrite is for: by the id /posts delivered it
502
+ under, else by its slug or address (previous slugs too)."""
503
+ source = item.get("source_content_id") or item.get("sourceContentId") or item.get("original_content_id")
504
+ for key in (source, remote_id):
505
+ if key is not None and str(key) in state["content"]:
506
+ return str(key)
507
+ slugs = {str(item.get("slug") or "").strip("/")}
508
+ address = str(item.get("canonical_url") or item.get("canonicalUrl") or item.get("url") or "")
509
+ if address:
510
+ slugs.add(urlsplit(address).path.rstrip("/").rsplit("/", 1)[-1])
511
+ slugs.discard("")
512
+ for key, existing in state["content"].items():
513
+ if not isinstance(existing, dict): continue
514
+ known = {str(existing.get("slug") or "").strip("/"), *map(str, existing.get("previous_slugs") or [])}
515
+ if existing.get("live_url"):
516
+ known.add(urlsplit(str(existing["live_url"])).path.rstrip("/").rsplit("/", 1)[-1])
517
+ if slugs & known:
518
+ return key
519
+ return None
520
+
521
+ def apply_rewrite(item: dict, remote_id: str, fields: dict) -> None:
522
+ target = find_rewrite_target(item, remote_id)
523
+ held = str(item.get("publication_status") or "").lower() == "draft"
524
+ entry = {"rewrite_content_id": remote_id, "version": fields["version"], "change_reason": str(item.get("change_reason") or "")[:500], "sha256": fields["sha256"], "pulled_at": fields["pulled_at"]}
525
+ if target is None or not str(fields["content"]).strip():
526
+ # Never create a new post from a rewrite: keep it for review instead.
527
+ state.setdefault("rewrites_unmatched", []).append({**entry, "slug": item.get("slug"), "status": "unmatched"})
528
+ return
529
+ record_value = state["content"][target]
530
+ entry["status"] = "held" if held else "pending-apply"
531
+ if not held:
532
+ # Only the body changes; title, slug, status and the live URL stay.
533
+ entry["previous_sha256"] = record_value.get("sha256")
534
+ record_value.update({key: fields[key] for key in ("version","content","content_markdown","content_html","content_portable_text","sha256","pulled_at")})
535
+ record_value["rewrite_pending"] = True
536
+ record_value.setdefault("rewrites", []).append(entry)
537
+
538
+ def delivered_items(value: object, step: str) -> list:
539
+ """/posts and /updates answer {items: [...]}, {posts: [...]} or {updates: [...]}."""
540
+ if isinstance(value, list):
541
+ return value
542
+ if not isinstance(value, dict):
543
+ return []
544
+ for key in ("items", step, "posts", "updates"):
545
+ if isinstance(value.get(key), list):
546
+ return value[key]
547
+ return []
488
548
 
489
549
  def record_read(step: str, value: object):
490
550
  state["steps"][step] = {"status":"passed","completed_at":utc_now(),"summary":response_summary(value)}
@@ -6,7 +6,8 @@ server and ChatGPT/Claude apps. The product exports its registry as JSON
6
6
  (``maggie-assistant-tool-registry.v1``); these commands never import product
7
7
  code or call a model.
8
8
 
9
- - ``registry-validate``: names, risk levels, gates, scopes, channels, and the
9
+ - ``registry-validate``: names, risk levels, gates, scopes, channels, clipped
10
+ lists, the report inbox, unattended-caller caps and sign-in defaults, and the
10
11
  user-facing copy (label, detail, confirm sentence) kept separate from the
11
12
  model-facing description. User copy may not contain snake_case tool names,
12
13
  "the user", or "(from some_tool)" references.
@@ -17,6 +18,10 @@ code or call a model.
17
18
  - ``audit-check``: every write in a recorded session has one audit entry, with
18
19
  a readable summary (never a bare UUID) and the right scope (account-level
19
20
  writes are not filed under the open site).
21
+ - ``list-check``: a recorded list result marks every item whose declared
22
+ field was shortened (``clipped``) and carries the id its get tool needs.
23
+ - ``report-check``: a report an agent is about to save to the report inbox
24
+ (title, one-line summary, Markdown body) fits the limits and holds no secret.
20
25
  - ``approval-selftest``: exercises the reference signed one-time approval
21
26
  token and standing workflow approvals (``tools/runtime/assistant_approval.py``).
22
27
  """
@@ -187,10 +192,222 @@ def validate_registry(registry: dict) -> dict:
187
192
  errors.append("visitorPolicy.factsOnly must be true: answers come from the business's own facts, never general knowledge about prices or health")
188
193
  if policy.get("cardDetails") != "never":
189
194
  errors.append("visitorPolicy.cardDetails must be never: the chat never asks for or accepts card numbers")
195
+ by_name = {str(t.get("name")): t for t in tools if isinstance(t, dict)}
196
+ check_clips(by_name, errors, warnings)
197
+ check_unattended(registry, by_name, servers, errors, warnings)
198
+ check_report_inbox(registry, by_name, errors)
190
199
  public_tools = [t.get("name") for t in tools if isinstance(t, dict) and any(servers.get(s, {}).get("auth") == "none" for s in t.get("mcpServers", []) or [])]
191
200
  return {"schemaVersion": "maggie-assistant-registry-validation.v1", "passed": not errors, "errors": errors, "warnings": warnings, "counts": {"tools": len(tools), **counts, "cards": sum(1 for t in tools if isinstance(t, dict) and t.get("card")), "publicMcpTools": len(public_tools)}}
192
201
 
193
202
 
203
+ CLIP_WORDS_RE = re.compile(r"\b(?:clipped|shortened|truncated|cut to|first \d+ characters|~?\d+-character)\b", re.I)
204
+ WRITE_RISKS = ("write-reversible", "write-spends", "write-public")
205
+
206
+
207
+ def check_clips(by_name: dict[str, dict], errors: list[str], warnings: list[str]) -> None:
208
+ """A list tool that shortens long fields says so per item and names a get tool with the full text.
209
+
210
+ An agent checking what will be published must read the full text; a list that
211
+ silently clips makes it report "cut off mid-sentence" for text that is whole.
212
+ """
213
+ for name, tool in by_name.items():
214
+ clips = tool.get("clips")
215
+ if clips is None:
216
+ if risk_of(tool) == "read" and CLIP_WORDS_RE.search(str(tool.get("description", ""))):
217
+ warnings.append(f"{name}: the description says results are shortened; declare clips (fields, maxChars, getTool) so each item is marked and the full text has a get tool")
218
+ continue
219
+ if not isinstance(clips, dict):
220
+ errors.append(f"{name}: clips must be an object with fields, maxChars and getTool"); continue
221
+ if risk_of(tool) != "read":
222
+ errors.append(f"{name}: clips belongs to a read (list) tool")
223
+ fields = clips.get("fields")
224
+ if not isinstance(fields, list) or not fields or not all(isinstance(f, str) and f for f in fields):
225
+ errors.append(f"{name}: clips.fields must name the shortened fields")
226
+ if not isinstance(clips.get("maxChars"), int) or clips["maxChars"] < 1:
227
+ errors.append(f"{name}: clips.maxChars must be a positive integer")
228
+ get_name = str(clips.get("getTool", ""))
229
+ get_tool = by_name.get(get_name)
230
+ if get_tool is None:
231
+ errors.append(f"{name}: clips.getTool {get_name or '<missing>'!r} is not in the registry; a clipped list needs a get tool that returns the full text"); continue
232
+ if risk_of(get_tool) != "read":
233
+ errors.append(f"{name}: clips.getTool {get_name} must be a read tool")
234
+ if get_tool.get("scope") != tool.get("scope"):
235
+ errors.append(f"{name}: clips.getTool {get_name} must have the same scope as the list")
236
+ missing = [c for c in tool.get("channels") or [] if c not in (get_tool.get("channels") or [])]
237
+ if missing:
238
+ errors.append(f"{name}: clips.getTool {get_name} is not on {', '.join(missing)}; every caller of the list must be able to read the full text")
239
+
240
+
241
+ def oauth_full_set(by_name: dict[str, dict], server: str | None) -> set[str]:
242
+ """The tools a signed-in caller's role can reach on MCP (the server filters by role at call time)."""
243
+ names = set()
244
+ for name, tool in by_name.items():
245
+ if risk_of(tool) == "never" or "mcp" not in (tool.get("channels") or []):
246
+ continue
247
+ if server and tool.get("mcpServers") and server not in tool["mcpServers"]:
248
+ continue
249
+ names.add(name)
250
+ return names
251
+
252
+
253
+ def check_unattended(registry: dict, by_name: dict[str, dict], servers: dict, errors: list[str], warnings: list[str]) -> None:
254
+ """Limits for callers no person watches: agent keys and sign-in (OAuth) grants used by routines.
255
+
256
+ A prompt is not a limit. Every write such a caller can reach needs a daily cap
257
+ that the server counts; the OAuth default tool set is the role's whole set (a
258
+ narrower default needs a reason); an agent key cannot confirm previews.
259
+ """
260
+ policy = registry.get("unattended")
261
+ oauth_servers = [name for name, server in servers.items() if server.get("auth") == "oauth"]
262
+ if policy is None:
263
+ if oauth_servers and any(risk_of(by_name[n]) in WRITE_RISKS for n in oauth_full_set(by_name, oauth_servers[0])):
264
+ warnings.append("a signed-in MCP server exposes writes but registry.unattended is missing; declare server-enforced daily caps for agent keys and OAuth grants")
265
+ return
266
+ if not isinstance(policy, dict):
267
+ errors.append("unattended must be an object"); return
268
+ if policy.get("enforcedBy") != "server":
269
+ errors.append("unattended.enforcedBy must be server: caps kept only in a routine's prompt are not limits")
270
+ keys = policy.get("agentKeys")
271
+ if keys is not None:
272
+ if not isinstance(keys, dict):
273
+ errors.append("unattended.agentKeys must be an object")
274
+ else:
275
+ if keys.get("canConfirmPreviews") is not False:
276
+ errors.append("unattended.agentKeys.canConfirmPreviews must be false: an agent key runs only the writes its standing permission covers")
277
+ if keys.get("refusalReturnsReason") is not True:
278
+ errors.append("unattended.agentKeys.refusalReturnsReason must be true: a refused write says why (e.g. today's limit is used) instead of returning a preview nobody can confirm")
279
+ standing = keys.get("standingTools") if isinstance(keys.get("standingTools"), dict) else {}
280
+ for name, cap in standing.items():
281
+ tool = by_name.get(name)
282
+ if tool is None:
283
+ errors.append(f"unattended.agentKeys.standingTools: {name} is not in the registry"); continue
284
+ if risk_of(tool) not in ("write-reversible", "write-spends"):
285
+ errors.append(f"unattended.agentKeys.standingTools: {name} is {risk_of(tool)}; standing permissions cover reversible or spending writes only, never public or irreversible ones")
286
+ if not isinstance(cap, dict) or not isinstance(cap.get("perDay"), int) or cap["perDay"] < 1:
287
+ errors.append(f"unattended.agentKeys.standingTools: {name} needs perDay, the daily cap the server counts")
288
+ oauth = policy.get("oauth")
289
+ if oauth is None:
290
+ if oauth_servers:
291
+ errors.append("unattended.oauth is required when a signed-in (oauth) MCP server is declared: a sign-in grant acts as the full person")
292
+ return
293
+ if not isinstance(oauth, dict):
294
+ errors.append("unattended.oauth must be an object"); return
295
+ server = oauth.get("server") or (oauth_servers[0] if oauth_servers else None)
296
+ if server and server not in servers:
297
+ errors.append(f"unattended.oauth.server {server!r} is not declared in registry.mcpServers")
298
+ full = oauth_full_set(by_name, server)
299
+ default = oauth.get("defaultTools", "role")
300
+ if default == "role":
301
+ reachable = full
302
+ elif isinstance(default, list):
303
+ unknown = sorted(set(map(str, default)) - full)
304
+ if unknown:
305
+ errors.append(f"unattended.oauth.defaultTools names tools the role can't reach on MCP: {', '.join(unknown)}")
306
+ reachable = set(map(str, default)) & full
307
+ left_out = sorted(full - reachable)
308
+ if left_out and not str(oauth.get("narrowedReason", "")).strip():
309
+ errors.append(f"unattended.oauth.defaultTools exposes {len(reachable)} of the role's {len(full)} tools (missing e.g. {', '.join(left_out[:5])}); a routine connected by sign-in can't reach the rest. Use \"role\" or give narrowedReason")
310
+ else:
311
+ errors.append("unattended.oauth.defaultTools must be \"role\" (the caller's role's full set) or a list of tools"); reachable = full
312
+ per_tool = oauth.get("perToolPerDay") if isinstance(oauth.get("perToolPerDay"), dict) else {}
313
+ total = oauth.get("dailyWriteLimit")
314
+ has_total = isinstance(total, int) and total >= 1
315
+ for name in sorted(reachable):
316
+ if risk_of(by_name[name]) not in WRITE_RISKS:
317
+ continue
318
+ cap = per_tool.get(name)
319
+ if not (isinstance(cap, int) and cap >= 1) and not has_total:
320
+ errors.append(f"unattended.oauth: {name} is a write reachable by a sign-in grant with no server cap; set perToolPerDay.{name} or dailyWriteLimit")
321
+
322
+
323
+ REPORT_LIMITS = {"titleMaxChars": 120, "summaryMaxChars": 500, "bodyMaxChars": 50000}
324
+
325
+
326
+ def check_report_inbox(registry: dict, by_name: dict[str, dict], errors: list[str]) -> None:
327
+ """One place where scheduled agents leave their results: save, list, get; capped; bodies untrusted."""
328
+ inbox = registry.get("reportInbox")
329
+ if inbox is None:
330
+ return
331
+ if not isinstance(inbox, dict):
332
+ errors.append("reportInbox must be an object"); return
333
+ names = inbox.get("tools") if isinstance(inbox.get("tools"), dict) else {}
334
+ expected = {"save": ("write-reversible",), "list": ("read",), "get": ("read",)}
335
+ for role, risks in expected.items():
336
+ name = str(names.get(role, ""))
337
+ tool = by_name.get(name)
338
+ if tool is None:
339
+ errors.append(f"reportInbox.tools.{role} {name or '<missing>'!r} is not in the registry"); continue
340
+ if risk_of(tool) not in risks:
341
+ errors.append(f"reportInbox.tools.{role} {name} must be {risks[0]} (saving a report changes nothing else)")
342
+ if tool.get("scope") != "workspace":
343
+ errors.append(f"reportInbox.tools.{role} {name}: reports belong to one website (scope workspace)")
344
+ if not isinstance(inbox.get("perSitePerDay"), int) or inbox["perSitePerDay"] < 1:
345
+ errors.append("reportInbox.perSitePerDay is required: the server caps agent reports per website per day")
346
+ for key, ceiling in REPORT_LIMITS.items():
347
+ value = inbox.get(key, ceiling)
348
+ if not isinstance(value, int) or value < 1:
349
+ errors.append(f"reportInbox.{key} must be a positive integer")
350
+ if not isinstance(inbox.get("retentionDays"), int) or inbox["retentionDays"] < 1:
351
+ errors.append("reportInbox.retentionDays is required")
352
+ untrusted = set(inbox.get("untrusted") or [])
353
+ missing = sorted({"title", "summary", "body"} - untrusted)
354
+ if missing:
355
+ errors.append(f"reportInbox.untrusted must include {', '.join(missing)}: an agent wrote them, so readers treat them as data, never instructions")
356
+
357
+
358
+ def list_check(registry: dict, tool_name: str, result: dict) -> dict:
359
+ """Check a recorded list result: every item whose declared field was shortened says so."""
360
+ tool = next((t for t in registry.get("tools", []) if isinstance(t, dict) and t.get("name") == tool_name), None)
361
+ errors: list[str] = []
362
+ if tool is None or not isinstance(tool.get("clips"), dict):
363
+ return {"schemaVersion": "maggie-assistant-list-check.v1", "passed": False, "errors": [f"{tool_name}: not in the registry or declares no clips"], "items": 0, "clipped": 0}
364
+ clips = tool["clips"]
365
+ id_field = str(clips.get("idField", "id"))
366
+ max_chars = int(clips.get("maxChars", 0) or 0)
367
+ items = result.get("items") if isinstance(result.get("items"), list) else result.get("result", {}).get("items", []) if isinstance(result.get("result"), dict) else []
368
+ clipped = 0
369
+ for index, item in enumerate(items):
370
+ if not isinstance(item, dict):
371
+ continue
372
+ where = f"item {index} ({item.get(id_field, 'no ' + id_field)})"
373
+ if item.get(id_field) in (None, ""):
374
+ errors.append(f"{where}: has no {id_field}, so the full text can't be fetched with {clips.get('getTool')}")
375
+ marker = item.get("clipped")
376
+ for field in clips.get("fields", []):
377
+ text = item.get(field)
378
+ if not isinstance(text, str):
379
+ continue
380
+ shortened = (max_chars and len(text) >= max_chars) or text.rstrip().endswith(("…", "..."))
381
+ flagged = marker is True or (isinstance(marker, list) and field in marker)
382
+ if shortened and not flagged:
383
+ errors.append(f"{where}: {field} looks shortened ({len(text)} characters) but the item does not say clipped")
384
+ if flagged:
385
+ clipped += 1
386
+ return {"schemaVersion": "maggie-assistant-list-check.v1", "passed": not errors, "errors": errors, "items": len(items), "clipped": clipped, "getTool": clips.get("getTool")}
387
+
388
+
389
+ def report_check(report: dict, inbox: dict | None = None) -> dict:
390
+ """Check a report an agent is about to save (maggie-assistant-report.v1)."""
391
+ limits = {**REPORT_LIMITS, **{k: v for k, v in (inbox or {}).items() if k in REPORT_LIMITS and isinstance(v, int)}}
392
+ errors: list[str] = []
393
+ title = str(report.get("title", "")).strip()
394
+ summary = str(report.get("summary", "")).strip()
395
+ body = str(report.get("body_md", report.get("body", "")))
396
+ if not title:
397
+ errors.append("title is required")
398
+ elif len(title) > limits["titleMaxChars"]:
399
+ errors.append(f"title is over {limits['titleMaxChars']} characters")
400
+ if not summary:
401
+ errors.append("summary is required: one or two sentences shown in the inbox")
402
+ elif len(summary) > limits["summaryMaxChars"]:
403
+ errors.append(f"summary is over {limits['summaryMaxChars']} characters")
404
+ if len(body) > limits["bodyMaxChars"]:
405
+ errors.append(f"body is over {limits['bodyMaxChars']} characters")
406
+ if re.search(r"(?i)\b(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{8,}|\bBearer\s+[A-Za-z0-9._-]{20,}|-----BEGIN [A-Z ]*PRIVATE KEY", title + summary + body):
407
+ errors.append("the report contains something that looks like a secret; remove it before saving")
408
+ return {"schemaVersion": "maggie-assistant-report-check.v1", "passed": not errors, "errors": errors, "chars": {"title": len(title), "summary": len(summary), "body": len(body)}}
409
+
410
+
194
411
  def annotations_for(tool: dict) -> dict:
195
412
  risk = risk_of(tool)
196
413
  title = str(tool.get("user", {}).get("label", "")).strip() or None
@@ -348,14 +565,21 @@ def main(argv: list[str] | None = None) -> int:
348
565
  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")
349
566
  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)")
350
567
  sub.add_parser("approval-selftest")
568
+ lc = sub.add_parser("list-check"); lc.add_argument("--registry", type=Path, required=True); lc.add_argument("--tool", required=True); lc.add_argument("--result", type=Path, required=True, help="a recorded result of the list tool")
569
+ rc = sub.add_parser("report-check"); rc.add_argument("--report", type=Path, required=True, help="the report about to be saved (title, summary, body_md)"); rc.add_argument("--registry", type=Path, help="use this registry's reportInbox limits")
351
570
  args = parser.parse_args(argv)
352
571
  try:
353
572
  if args.command == "approval-selftest":
354
573
  report = approval_selftest()
574
+ elif args.command == "report-check":
575
+ inbox = _read(args.registry).get("reportInbox") if args.registry else None
576
+ report = report_check(_read(args.report), inbox if isinstance(inbox, dict) else None)
355
577
  else:
356
578
  registry = _read(args.registry)
357
579
  if args.command == "registry-validate":
358
580
  report = validate_registry(registry)
581
+ elif args.command == "list-check":
582
+ report = list_check(registry, args.tool, _read(args.result))
359
583
  elif args.command == "annotations":
360
584
  report = derive_annotations(registry, _read(args.mcp_tools) if args.mcp_tools else None)
361
585
  elif args.command == "capability-page":