@topy-ai/maggie 0.7.45 → 0.7.47

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 +15 -2
  2. package/README.md +55 -3
  3. package/bin/maggie.js +22 -4
  4. package/bundled-contracts/google-integrations/capability-report-v2.schema.json +76 -0
  5. package/bundled-contracts/google-integrations/external-write-readback-v1.schema.json +36 -0
  6. package/bundled-contracts/maggie-deployment/deployer-delegation-v1.schema.json +20 -0
  7. package/bundled-contracts/maggie-deployment/release-profile-v1.schema.json +34 -0
  8. package/bundled-contracts/maggie-design/browser-capability-v1.schema.json +33 -0
  9. package/bundled-contracts/maggie-feedback/evidence-bundle-v1.schema.json +38 -0
  10. package/bundled-references/browser-inspection.md +17 -0
  11. package/bundled-references/google-integrations-runbook.md +8 -1
  12. package/bundled-references/memory-hook.md +18 -5
  13. package/bundled-skills/maggie-clone/SKILL.md +7 -0
  14. package/bundled-skills/maggie-deployment/SKILL.md +55 -15
  15. package/bundled-skills/maggie-feedback/SKILL.md +26 -1
  16. package/bundled-skills/maggie-memory/SKILL.md +10 -1
  17. package/bundled-skills/maggie-ops/SKILL.md +22 -0
  18. package/bundled-skills/maggie-seo-geo/SKILL.md +4 -1
  19. package/bundled-tools/clis/maggie_analytics.py +8 -0
  20. package/bundled-tools/clis/maggie_browser_audit.py +13 -2
  21. package/bundled-tools/clis/maggie_deployment.py +214 -6
  22. package/bundled-tools/clis/maggie_feedback.py +116 -1
  23. package/bundled-tools/clis/maggie_memory.py +10 -3
  24. package/bundled-tools/clis/maggie_ops.py +33 -0
  25. package/bundled-tools/clis/maggie_release.py +124 -19
  26. package/bundled-tools/runtime/browser_capability.py +143 -0
  27. package/bundled-tools/runtime/external_write.py +122 -0
  28. package/bundled-tools/runtime/google_capabilities.py +37 -5
  29. package/bundled-tools/runtime/maggie_memory.py +17 -2
  30. package/package.json +1 -1
  31. package/references/browser-inspection.md +17 -0
  32. package/references/google-integrations-runbook.md +8 -1
  33. package/references/memory-hook.md +18 -5
@@ -0,0 +1,122 @@
1
+ """Validate bounded, hash-only evidence for an external provider write."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any
7
+ from urllib.parse import urlsplit
8
+
9
+
10
+ SCHEMA = "maggie-external-write-readback.v1"
11
+ PROVIDERS = {"ga4", "gtm", "gsc", "google-ads", "firebase"}
12
+ STATUSES = {"created", "updated", "no-op"}
13
+ FINGERPRINT = re.compile(r"^sha256:[0-9a-f]{64}$")
14
+
15
+
16
+ def _safe_text(value: Any, field: str, errors: list[str], pattern: str | None = None) -> str | None:
17
+ if not isinstance(value, str) or not value.strip() or len(value) > 200 or "@" in value or re.search(r"(?:token|secret|password|BEGIN PRIVATE)", value, re.I):
18
+ errors.append(f"{field} must be a bounded redacted identifier")
19
+ return None
20
+ if pattern and not re.fullmatch(pattern, value):
21
+ errors.append(f"{field} has an invalid format")
22
+ return None
23
+ return value
24
+
25
+
26
+ def validate(value: Any) -> dict[str, Any]:
27
+ errors: list[str] = []
28
+ if not isinstance(value, dict):
29
+ return {"schemaVersion": "maggie-external-write-readback-result.v1", "passed": False, "errors": ["evidence must be an object"], "mutation": "not executed"}
30
+ if value.get("schemaVersion") != SCHEMA:
31
+ errors.append(f"schemaVersion must be {SCHEMA}")
32
+ if value.get("provider") not in PROVIDERS:
33
+ errors.append("provider is unsupported")
34
+ _safe_text(value.get("generatedAt"), "generatedAt", errors)
35
+ resource = _safe_text(value.get("resource"), "resource", errors)
36
+ _safe_text(value.get("operation"), "operation", errors, r"[a-z][a-z0-9_.-]{1,63}")
37
+ key = _safe_text(value.get("idempotencyKey"), "idempotencyKey", errors, r"[A-Za-z0-9][A-Za-z0-9_.:-]{2,127}")
38
+
39
+ retry = value.get("retry")
40
+ if not isinstance(retry, dict) or set(retry) != {"attempt", "maxAttempts", "sameKeyOnRetry"}:
41
+ errors.append("retry must contain attempt, maxAttempts, and sameKeyOnRetry")
42
+ retry = {}
43
+ else:
44
+ attempt, maximum = retry.get("attempt"), retry.get("maxAttempts")
45
+ if not isinstance(attempt, int) or not 1 <= attempt <= 3:
46
+ errors.append("retry.attempt must be between 1 and 3")
47
+ if not isinstance(maximum, int) or not 1 <= maximum <= 3 or (isinstance(attempt, int) and isinstance(maximum, int) and attempt > maximum):
48
+ errors.append("retry.maxAttempts must be between attempt and 3")
49
+ if retry.get("sameKeyOnRetry") is not True:
50
+ errors.append("retry.sameKeyOnRetry must be true")
51
+
52
+ mutation = value.get("mutation")
53
+ if not isinstance(mutation, dict) or set(mutation) != {"status", "statusCode", "explicitConfirmation"}:
54
+ errors.append("mutation must contain status, statusCode, and explicitConfirmation")
55
+ mutation = {}
56
+ else:
57
+ if mutation.get("status") not in STATUSES:
58
+ errors.append("mutation.status is unsupported")
59
+ if not isinstance(mutation.get("statusCode"), int) or not 200 <= mutation["statusCode"] <= 299:
60
+ errors.append("mutation.statusCode must be a successful HTTP status")
61
+ if mutation.get("explicitConfirmation") is not True:
62
+ errors.append("mutation requires explicit confirmation")
63
+ if value.get("mutationExecuted") is not True:
64
+ errors.append("mutationExecuted must be true")
65
+
66
+ readback = value.get("readback")
67
+ safe_checks: list[dict[str, Any]] = []
68
+ if not isinstance(readback, dict) or set(readback) != {"passed", "endpoint", "checks"}:
69
+ errors.append("readback must contain passed, endpoint, and checks")
70
+ readback = {}
71
+ else:
72
+ if readback.get("passed") is not True:
73
+ errors.append("readback.passed must be true")
74
+ endpoint = readback.get("endpoint")
75
+ parsed = urlsplit(endpoint) if isinstance(endpoint, str) else None
76
+ if not parsed or parsed.scheme != "https" or not parsed.netloc or parsed.query or parsed.fragment or parsed.username or parsed.password:
77
+ errors.append("readback.endpoint must be an HTTPS URL without credentials/query")
78
+ checks = readback.get("checks")
79
+ if not isinstance(checks, list) or not checks:
80
+ errors.append("readback.checks must be a non-empty array")
81
+ checks = []
82
+ for index, check in enumerate(checks):
83
+ prefix = f"readback.checks[{index}]"
84
+ if not isinstance(check, dict) or set(check) != {"name", "expectedFingerprint", "observedFingerprint", "passed"}:
85
+ errors.append(f"{prefix} must contain name and hash-only invariant fields")
86
+ continue
87
+ name = _safe_text(check.get("name"), f"{prefix}.name", errors)
88
+ expected = check.get("expectedFingerprint")
89
+ observed = check.get("observedFingerprint")
90
+ if not isinstance(expected, str) or not FINGERPRINT.fullmatch(expected):
91
+ errors.append(f"{prefix}.expectedFingerprint must be sha256")
92
+ if not isinstance(observed, str) or not FINGERPRINT.fullmatch(observed):
93
+ errors.append(f"{prefix}.observedFingerprint must be sha256")
94
+ if check.get("passed") is not True or expected != observed:
95
+ errors.append(f"{prefix} does not match its expected readback")
96
+ safe_checks.append({"name": name, "passed": check.get("passed") is True})
97
+
98
+ duplicates = value.get("duplicates")
99
+ orphaned = value.get("orphaned")
100
+ if not isinstance(duplicates, list) or duplicates:
101
+ errors.append("duplicates must be an empty array after reconciliation")
102
+ if not isinstance(orphaned, list) or orphaned:
103
+ errors.append("orphaned must be an empty array after cleanup")
104
+ cleanup = value.get("cleanup")
105
+ if not isinstance(cleanup, dict) or set(cleanup) != {"status", "checked"} or cleanup.get("status") not in {"not-needed", "passed"} or cleanup.get("checked") is not True:
106
+ errors.append("cleanup must be checked and not-needed or passed")
107
+
108
+ return {
109
+ "schemaVersion": "maggie-external-write-readback-result.v1",
110
+ "passed": not errors,
111
+ "errors": sorted(set(errors)),
112
+ "provider": value.get("provider"),
113
+ "resource": resource,
114
+ "operation": value.get("operation"),
115
+ "idempotencyKey": key,
116
+ "retry": {"attempt": retry.get("attempt"), "maxAttempts": retry.get("maxAttempts"), "sameKeyOnRetry": retry.get("sameKeyOnRetry") is True},
117
+ "readback": {"passed": readback.get("passed") is True, "checks": safe_checks} if isinstance(readback, dict) else {"passed": False, "checks": []},
118
+ "duplicates": [],
119
+ "orphaned": [],
120
+ "cleanup": cleanup if isinstance(cleanup, dict) else {},
121
+ "mutation": "not executed",
122
+ }
@@ -7,7 +7,7 @@ from typing import Any
7
7
  from urllib.parse import urlsplit
8
8
 
9
9
 
10
- SCHEMA_VERSION = "maggie-google-capability-report.v1"
10
+ SCHEMA_VERSION = "maggie-google-capability-report.v2"
11
11
  CAPABILITY_NAMES = ("read", "report", "edit", "publish")
12
12
  CAPABILITY_STATES = {"verified", "not_tested", "not_available", "blocked"}
13
13
  PROVIDERS = {"gsc", "ga4", "gtm", "google-ads", "firebase"}
@@ -112,6 +112,34 @@ def _write_test(value: Any, field: str, errors: list[str]) -> bool:
112
112
  return valid and not unknown
113
113
 
114
114
 
115
+ def _verified_reference(value: Any, field: str, errors: list[str]) -> dict[str, Any]:
116
+ if not isinstance(value, dict) or set(value) != {"id", "verified"}:
117
+ errors.append(f"{field} must contain only id and verified")
118
+ return {"id": None, "verified": False}
119
+ identifier = value.get("id")
120
+ if not isinstance(identifier, str) or not identifier.strip() or len(identifier) > 200 or re.search(r"[@\s]", identifier):
121
+ errors.append(f"{field}.id must be a redacted non-email identifier")
122
+ if value.get("verified") is not True:
123
+ errors.append(f"{field}.verified must be true")
124
+ return {"id": identifier if isinstance(identifier, str) else None, "verified": value.get("verified") is True}
125
+
126
+
127
+ def _target_reference(value: Any, resource: str, field: str, errors: list[str]) -> dict[str, Any]:
128
+ if not isinstance(value, dict) or set(value) != {"id", "kind", "verified"}:
129
+ errors.append(f"{field} must contain only id, kind, and verified")
130
+ return {"id": None, "kind": None, "verified": False}
131
+ identifier = value.get("id")
132
+ if not isinstance(identifier, str) or not identifier.strip() or len(identifier) > 200 or re.search(r"[@\s]", identifier):
133
+ errors.append(f"{field}.id must be a redacted non-email identifier")
134
+ if identifier != resource:
135
+ errors.append(f"{field}.id must equal the selected resource")
136
+ if value.get("kind") not in {"property", "container", "customer", "project", "site", "account"}:
137
+ errors.append(f"{field}.kind is unsupported")
138
+ if value.get("verified") is not True:
139
+ errors.append(f"{field}.verified must be true")
140
+ return {"id": identifier if isinstance(identifier, str) else None, "kind": value.get("kind"), "verified": value.get("verified") is True}
141
+
142
+
115
143
  def _required_scope(provider: str, capability: str) -> str | None:
116
144
  if capability == "read" or capability == "report":
117
145
  return READ_SCOPES[provider]
@@ -124,9 +152,9 @@ def validate_report(report: Any) -> dict[str, Any]:
124
152
  """Return safe validation output; never returns arbitrary input fields."""
125
153
  errors: list[str] = []
126
154
  if not isinstance(report, dict):
127
- return {"schemaVersion": "maggie-google-capability-report-result.v1", "passed": False, "errors": ["report must be an object"], "providerResults": []}
155
+ return {"schemaVersion": "maggie-google-capability-report-result.v2", "passed": False, "errors": ["report must be an object"], "providerResults": []}
128
156
  if report.get("schemaVersion") != SCHEMA_VERSION:
129
- errors.append("schemaVersion must be maggie-google-capability-report.v1")
157
+ errors.append("schemaVersion must be maggie-google-capability-report.v2; v1 reports lack target guard evidence")
130
158
  if not _string(report.get("generatedAt"), "generatedAt", errors):
131
159
  pass
132
160
  if report.get("mutationsAllowed") is not False:
@@ -143,7 +171,7 @@ def validate_report(report: Any) -> dict[str, Any]:
143
171
  if not isinstance(row, dict):
144
172
  errors.append(f"{prefix} must be an object")
145
173
  continue
146
- required = {"provider", "resource", "authMode", "scopes", "productRole", "evidence", "capabilities", "nextAction"}
174
+ required = {"provider", "resource", "activeAccount", "target", "authMode", "scopes", "productRole", "evidence", "capabilities", "nextAction"}
147
175
  unknown = sorted(set(row) - required)
148
176
  if unknown:
149
177
  errors.append(f"{prefix} contains unsupported fields")
@@ -154,6 +182,8 @@ def validate_report(report: Any) -> dict[str, Any]:
154
182
  continue
155
183
  if not _string(resource, f"{prefix}.resource", errors, max_length=200):
156
184
  continue
185
+ active_account = _verified_reference(row.get("activeAccount"), f"{prefix}.activeAccount", errors)
186
+ target = _target_reference(row.get("target"), resource, f"{prefix}.target", errors)
157
187
  key = (provider, resource)
158
188
  if key in seen:
159
189
  errors.append(f"{prefix} duplicates provider/resource")
@@ -226,6 +256,8 @@ def validate_report(report: Any) -> dict[str, Any]:
226
256
  normalized.append({
227
257
  "provider": provider,
228
258
  "resource": resource,
259
+ "activeAccount": active_account,
260
+ "target": target,
229
261
  "authMode": row.get("authMode"),
230
262
  "scopes": sorted(scopes),
231
263
  "productRole": row.get("productRole"),
@@ -241,7 +273,7 @@ def validate_report(report: Any) -> dict[str, Any]:
241
273
  })
242
274
 
243
275
  return {
244
- "schemaVersion": "maggie-google-capability-report-result.v1",
276
+ "schemaVersion": "maggie-google-capability-report-result.v2",
245
277
  "passed": not errors,
246
278
  "errors": sorted(set(errors)),
247
279
  "providerResults": normalized,
@@ -73,7 +73,16 @@ def write(project: str | Path, kind: str, payload: dict[str, Any]) -> Path:
73
73
 
74
74
 
75
75
  def init(project: str | Path) -> dict[str, str]:
76
- return {kind: str(write(project, kind, read(project, kind))) for kind in KINDS}
76
+ paths: dict[str, str] = {}
77
+ for kind in KINDS:
78
+ path = memory_path(project, kind)
79
+ if path.exists():
80
+ # Validate existing state, but keep its bytes and mtime unchanged.
81
+ read(project, kind)
82
+ else:
83
+ write(project, kind, empty(kind))
84
+ paths[kind] = str(path)
85
+ return paths
77
86
 
78
87
 
79
88
  def _text(value: Any, field: str, required: bool = False) -> str:
@@ -176,11 +185,13 @@ def _active(item: dict[str, Any], project_id: str = "") -> bool:
176
185
  return not expires or expires > now()
177
186
 
178
187
 
179
- def relevant(project: str | Path, *, skill: str = "", query: str = "", project_id: str = "", status: str | None = None) -> dict[str, list[dict[str, Any]]]:
188
+ def relevant(project: str | Path, *, skill: str = "", query: str = "", project_id: str = "", status: str | None = None, include_shared: bool = False) -> dict[str, list[dict[str, Any]]]:
180
189
  terms = set(re.findall(r"[a-z0-9_-]+", f"{skill} {query}".lower()))
181
190
  result: dict[str, list[dict[str, Any]]] = {kind: [] for kind in KINDS}
182
191
  for kind in KINDS:
183
192
  for item in read(project, kind)["items"]:
193
+ if not include_shared and item.get("scope") != "project":
194
+ continue
184
195
  if status is not None:
185
196
  if item.get("status") != status:
186
197
  continue
@@ -194,6 +205,10 @@ def relevant(project: str | Path, *, skill: str = "", query: str = "", project_i
194
205
  return result
195
206
 
196
207
 
208
+ def initialized(project: str | Path) -> bool:
209
+ return all(memory_path(project, kind).is_file() for kind in KINDS)
210
+
211
+
197
212
  def transition(project: str | Path, kind: str, item_id: str, status: str) -> dict[str, Any]:
198
213
  if status not in STATUSES:
199
214
  raise ValueError(f"invalid status: {status}")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topy-ai/maggie",
3
- "version": "0.7.45",
3
+ "version": "0.7.47",
4
4
  "description": "Install and manage Maggie Skills for AI coding agents",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,6 +14,23 @@ The browser capability must support:
14
14
  - slow scrolling, click, hover, keyboard focus, and back/forward navigation;
15
15
  - reading computed CSS, media sources, links, and visible accessibility labels.
16
16
 
17
+ Before navigation, Maggie runs a capability preflight against the configured
18
+ adapter. The report is `maggie-browser-capability.v1` and classifies the
19
+ adapter as `ready`, `missing`, or `incompatible`. This prevents a system
20
+ `browse`/`xdg-open` desktop opener from being mistaken for a browser adapter:
21
+
22
+ ```bash
23
+ maggie browser-audit https://example.com \
24
+ --browse "$HOME/.codex/skills/gstack/browse/dist/browse" \
25
+ --output .maggie/browser-audit --required body --check-browser
26
+ ```
27
+
28
+ The report contains only the adapter name, path fingerprints, capability
29
+ checks, and bounded installation/fallback guidance. If it is `missing` or
30
+ `incompatible`, install or enable an authorized Chrome/Playwright/gstack
31
+ adapter and pass its executable path. Maggie does not silently install a
32
+ browser runtime or use a desktop opener as a fallback.
33
+
17
34
  The minimum extraction result for a target is:
18
35
 
19
36
  ```text
@@ -111,8 +111,13 @@ maggie ops google-capabilities \
111
111
  ```
112
112
 
113
113
  The report contract is
114
- [`capability-report-v1.json`](../bundled-contracts/google-integrations/capability-report-v1.json).
114
+ [`capability-report-v2.schema.json`](../bundled-contracts/google-integrations/capability-report-v2.schema.json).
115
115
  The CLI writes a normalized result and never copies arbitrary input fields.
116
+ Every provider row must also identify the redacted active account and selected
117
+ target resource, both with `verified: true`; the target ID must equal the
118
+ selected `resource`. A report that only proves account discovery, or comes from
119
+ the wrong browser account/property, is rejected before a write workflow can use
120
+ it.
116
121
 
117
122
  ## Capability matrix
118
123
 
@@ -121,6 +126,8 @@ Every provider/resource row must expose this shape:
121
126
  | Field | Meaning |
122
127
  |---|---|
123
128
  | `provider` / `resource` | The Google product and scoped resource being checked |
129
+ | `activeAccount` | Redacted account/principal reference confirmed by the current auth session |
130
+ | `target` | Resource ID and kind confirmed in the current account; must match `resource` |
124
131
  | `authMode` | `desktop-oauth`, `service-account-impersonation`, or `none` |
125
132
  | `scopes` | Exact allowlisted OAuth scopes, never token values |
126
133
  | `productRole` | Role granted in the product, distinct from Cloud IAM |
@@ -1,23 +1,36 @@
1
1
  # Maggie Memory Hook
2
2
 
3
- Every Maggie skill invocation has an automatic memory lifecycle when the host
4
- project contains `tools/clis/maggie_memory.py`.
3
+ Every Maggie skill invocation uses the current project's Maggie Memory by
4
+ default. Project initialization creates `.maggie/memory/` for that repository.
5
5
 
6
6
  ## Before work
7
7
 
8
- Run:
8
+ Use the project-local adapter when it is present:
9
9
 
10
10
  ```bash
11
11
  python3 tools/clis/maggie_memory.py context --project . --skill <current-skill>
12
12
  ```
13
13
 
14
+ If that adapter is absent, use the installed CLI:
15
+
16
+ ```bash
17
+ maggie memory context --project . --skill <current-skill>
18
+ ```
19
+
14
20
  Load the returned active preferences, conventions, lessons, and matching error
15
21
  history into the current decision context. Surface material rules and verify
16
22
  them against current project evidence. Memory never replaces inspection,
17
23
  approval, bootstrap, or production gates.
18
24
 
19
- If the command is unavailable, report that memory is unavailable and continue
20
- with the normal workflow. Do not invent or silently reconstruct memory.
25
+ The context response reports `status: "uninitialized"` when Maggie Memory is
26
+ available but the project has not initialized its store. In that case, explain
27
+ that `maggie init --scope project` initializes it; do not call memory
28
+ unavailable. Report unavailable only when neither the project adapter nor the
29
+ installed `maggie` CLI can run. Do not invent or silently reconstruct memory.
30
+
31
+ Context includes only active project-scope items for the current repository.
32
+ Include user/workspace entries only after a separate explicit opt-in with
33
+ `--include-shared`.
21
34
 
22
35
  ## After work
23
36