@topy-ai/maggie 0.7.4 → 0.7.5

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
@@ -92,6 +92,9 @@ maggie bootstrap interview | phase ...
92
92
  maggie dash install | init | status | migrate | cms ...
93
93
  maggie dash transition ... # explicit content approval transition
94
94
  maggie dash variant ... # service variant create/review/preview/publish
95
+ maggie dash sections ... # validate/catalogue/identity keys
96
+ maggie agent-content write ... # host-authorized content bridge
97
+ maggie verification coverage ... # changed surface/locale evidence gate
95
98
  maggie clone ... # authorized homepage capture
96
99
  maggie design ... # authorized interior-page design
97
100
  maggie clone-to-template ... # URL → validated marketplace template
@@ -191,8 +194,8 @@ artifact schemas.
191
194
  Recommended upgrade sequence for the current release:
192
195
 
193
196
  ```bash
194
- npx @topy-ai/maggie@0.7.4 update --project . --force
195
- npx @topy-ai/maggie@0.7.4 cleanup --project .
197
+ npx @topy-ai/maggie@0.7.5 update --project . --force
198
+ npx @topy-ai/maggie@0.7.5 cleanup --project .
196
199
  ```
197
200
 
198
201
  Maintainers should pass npm credentials through the repository helper, never
@@ -202,15 +205,20 @@ as a command-line argument:
202
205
  node scripts/publish-npm.mjs --maggie-env-file ../.env
203
206
  ```
204
207
 
205
- The 0.7.4 workflow adds the installable MaggieDash admin distribution and
208
+ The 0.7.5 workflow adds the installable MaggieDash admin distribution and
206
209
  audited CMS operations (`cms revisions`,
207
210
  `trash`, `restore`, `schedule`, `publish-due`, `duplicate`, `redirect`, and
208
211
  signed `preview`). It also adds a read-only MaggieDash `--diff`/`--dry-run`
209
212
  migration inventory, body-only dashboard prop validation, dialog accessibility
210
213
  contract checks, 404-only redirect policy, trash/scheduler/preview invariants,
211
- and a shared first-party traffic filter for every measurement channel. The
212
- release retains the existing service matching, localization, seed-manifest,
213
- lockfile/analytics, sitemap, deployment and rollback workflows.
214
+ and a shared first-party traffic filter for every measurement channel. It also
215
+ adds stable section identities and reusable section presets, a machine-readable
216
+ MaggieDash section registry, a short-lived agent content bridge, restart gates
217
+ for out-of-band translation writes, deployment release-runner generation,
218
+ disk-versus-manifest skill inventory checks, and changed-surface/locale
219
+ verification. The release retains the existing service matching,
220
+ localization, seed-manifest, lockfile/analytics, sitemap, deployment and
221
+ rollback workflows.
214
222
 
215
223
  ## MaggieDash lifecycle
216
224
 
package/README.zh-TW.md CHANGED
@@ -8,7 +8,7 @@ Codex、Claude Code 與相容的 coding agents。
8
8
  ## 安裝
9
9
 
10
10
  ```bash
11
- npx @topy-ai/maggie@0.6.9 init --agent all
11
+ npx @topy-ai/maggie@0.7.5 init --agent all
12
12
  npx @topy-ai/maggie doctor --project .
13
13
  ```
14
14
 
@@ -25,6 +25,10 @@ maggie doctor --project . --require-bootstrap --strict
25
25
  deployment、memory、feedback 和 MaggieDash。內容先 draft/review,外部寫入、
26
26
  publish 與 production deployment 需要明確確認。
27
27
 
28
+ MaggieDash 也提供穩定 section identity、可重用 section arrangement、機器可讀
29
+ section registry、短期 agent content bridge,以及 translation out-of-band write
30
+ 後的 restart gate。`maggie doctor` 會比較 install manifest 與磁碟上的實際 skills。
31
+
28
32
  新版 release gates:
29
33
 
30
34
  ```bash
package/bin/maggie.js CHANGED
@@ -66,9 +66,11 @@ Usage:
66
66
  maggie doctor [--project PATH]
67
67
  maggie bootstrap interview [project]
68
68
  maggie dash init|install|status|migrate|transition|variant|cms --project PATH [options]
69
+ maggie dash sections <validate|prompt|keys> [options]
69
70
  maggie dash status --project PATH
70
71
  maggie dash migrate --project PATH --confirm
71
72
  maggie content FILE --source PROVIDER --project PATH --confirm
73
+ maggie agent-content write --url URL --token-env ENV --payload FILE
72
74
  maggie bootstrap phase <start|pass|fail|status> <phase> [project]
73
75
  maggie remove [SKILL ...] [--project PATH] [--agent codex|claude|all]
74
76
  maggie cleanup --project PATH [--confirm]
@@ -117,6 +119,7 @@ Usage:
117
119
  maggie site-audit URL --crawl --save-baseline FILE --reviewer NAME
118
120
  maggie site-audit URL --crawl --baseline FILE
119
121
  maggie browser-audit URL --browse PATH --output DIR --required SELECTOR [--sticky SELECTOR]
122
+ maggie verification coverage --contract FILE --evidence FILE
120
123
  maggie ops audit|preflight|verify|lockfiles|seed-manifest --project PATH
121
124
  maggie ops preflight --project PATH --write
122
125
 
@@ -320,11 +323,22 @@ function doctor(args) {
320
323
  ["claude-skills", existsSync(join(root, ".claude", "skills"))],
321
324
  ];
322
325
  for (const [name, passed] of checks) console.log(`${passed ? "PASS" : "INFO"} ${name}`);
326
+ const manifestPath = join(root, STATE_DIR, "install.json");
327
+ let manifest = null;
328
+ try { if (existsSync(manifestPath)) manifest = JSON.parse(readFileSync(manifestPath, "utf8")); } catch { manifest = null; }
329
+ const expected = Array.isArray(manifest?.skills) ? [...new Set(manifest.skills)] : null;
323
330
  const installed = [];
331
+ const installedNames = new Set();
324
332
  for (const agentRoot of [join(root, ".agents"), join(root, ".claude")]) {
325
- for (const skill of SKILL_NAMES) if (existsSync(join(agentRoot, "skills", skill, "SKILL.md"))) installed.push(`${agentRoot}/${skill}`);
333
+ for (const skill of SKILL_NAMES) if (existsSync(join(agentRoot, "skills", skill, "SKILL.md"))) {
334
+ installed.push(`${agentRoot}/${skill}`);
335
+ installedNames.add(skill);
336
+ }
326
337
  }
327
- console.log(`INFO installed-skills=${installed.length}`);
338
+ const missing = expected ? expected.filter((skill) => !installedNames.has(skill)) : [];
339
+ const inventoryState = expected && missing.length ? "INCOMPLETE" : "PASS";
340
+ console.log(`${inventoryState} skill-inventory manifest=${expected?.length ?? "unknown"} on-disk=${installedNames.size}${missing.length ? ` missing=${missing.join(",")}` : ""}`);
341
+ console.log(`INFO installed-skills=${installedNames.size}`);
328
342
  console.log("INFO doctor is diagnostic; bootstrap completion remains a project decision gate.");
329
343
  }
330
344
 
@@ -394,7 +408,9 @@ try {
394
408
  else if (command === "feedback") workflowCli("maggie_feedback.py", args);
395
409
  else if (command === "site-audit") workflowCli("site_audit.py", args);
396
410
  else if (command === "browser-audit") workflowCli("maggie_browser_audit.py", args);
411
+ else if (command === "verification") workflowCli("maggie_verification.py", args);
397
412
  else if (command === "dash") workflowCli("maggie_dash.py", args);
413
+ else if (command === "agent-content") workflowCli("maggie_agent_content.py", args);
398
414
  else if (command === "content") workflowCli("maggie_content.py", args);
399
415
  else if (command === "init" || command === "install") install(args);
400
416
  else if (command === "update") update(args);
@@ -0,0 +1,14 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://maggiedash.noblox.app/contracts/release-runner-v1.schema.json",
4
+ "title": "Maggie deployment release runner v1",
5
+ "type": "object",
6
+ "required": ["schemaVersion", "orderedSteps", "retention"],
7
+ "properties": {
8
+ "schemaVersion": {"const": "maggie-release-runner.v1"},
9
+ "orderedSteps": {"const": ["install", "typecheck", "migrate", "build", "carry-agent-state", "switch", "restart", "verify", "prune"]},
10
+ "retention": {"const": "exactly-current-plus-one-rollback"},
11
+ "mutation": {"enum": ["reviewable-script", "executed-after-confirmation"]}
12
+ },
13
+ "additionalProperties": false
14
+ }
@@ -18,6 +18,12 @@ frontend framework.
18
18
  self/chain safety for canonical route redirects;
19
19
  - [`telemetry-policy-v1.json`](telemetry-policy-v1.json): first-party/toolchain
20
20
  traffic exclusion for every measurement channel.
21
+ - [`agent-content-v1.json`](agent-content-v1.json): short-lived host bridge for
22
+ validated agent content writes without database credentials;
23
+ - [`section-identity-v1.json`](section-identity-v1.json): stable section IDs
24
+ for translation keys and ordered migration;
25
+ - [`translation-cache-policy-v1.json`](translation-cache-policy-v1.json):
26
+ restart-after-out-of-band-write evidence.
21
27
 
22
28
  MaggieDash owns these contracts. Provider adapters may add namespaced metadata,
23
29
  but they may not change the required identity, status, provenance, or approval
@@ -0,0 +1,15 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://maggiedash.noblox.app/contracts/agent-content-v1.schema.json",
4
+ "title": "MaggieDash agent content bridge v1",
5
+ "type": "object",
6
+ "required": ["method", "authorization", "tokenLifetime", "databaseCredentials"],
7
+ "properties": {
8
+ "method": {"const": "POST"},
9
+ "authorization": {"const": "Bearer short-lived session token"},
10
+ "tokenLifetime": {"const": "terminal session"},
11
+ "databaseCredentials": {"const": "never exposed to agent"},
12
+ "writes": {"type": "array", "items": {"enum": ["validated content operation", "revision", "audit event"]}}
13
+ },
14
+ "additionalProperties": false
15
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://maggiedash.noblox.app/contracts/section-identity-v1.schema.json",
4
+ "title": "MaggieDash stable section identity v1",
5
+ "type": "object",
6
+ "required": ["schemaVersion", "keyPattern", "fallback", "migrationOrder"],
7
+ "properties": {
8
+ "schemaVersion": {"const": "maggiedash-section-identity.v1"},
9
+ "keyPattern": {"const": "page.<id>.sections.<section-id>.<field>"},
10
+ "fallback": {"const": "array-index-until-migrated"},
11
+ "migrationOrder": {"const": "longest-old-prefix-first"}
12
+ },
13
+ "additionalProperties": false
14
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://maggiedash.noblox.app/contracts/translation-cache-policy-v1.schema.json",
4
+ "title": "MaggieDash translation cache policy v1",
5
+ "type": "object",
6
+ "required": ["schemaVersion", "outOfBandWrite", "restartRequired", "restartCommand", "verificationAfterRestart"],
7
+ "properties": {
8
+ "schemaVersion": {"const": "maggie-restart-gate.v1"},
9
+ "outOfBandWrite": {"const": true},
10
+ "restartRequired": {"const": true},
11
+ "restartCommand": {"type": "string", "minLength": 1},
12
+ "verificationAfterRestart": {"const": true}
13
+ },
14
+ "additionalProperties": false
15
+ }
@@ -46,6 +46,21 @@ owns the framework route, email/password session middleware, database, media
46
46
  storage, provider credentials, and `/api/maggie/*` adapter endpoints. Read the
47
47
  MaggieDash host adapter contract before adding a new framework adapter.
48
48
 
49
+ For a built/deployed host, an agent must use the host's validated content
50
+ bridge rather than editing release files or receiving database credentials:
51
+
52
+ ```bash
53
+ maggie agent-content write \
54
+ --url https://example.test/api/maggie/agent-content.json \
55
+ --token-env MAGGIE_CONTENT_TOKEN --payload .maggie/content-operation.json
56
+ ```
57
+
58
+ The host mints a short-lived session token. The bridge uses `POST` with
59
+ `Authorization: Bearer`, delegates to the ordinary save/revision/audit path,
60
+ and returns 401/422 for invalid authority or content. Never put database
61
+ credentials or the short-lived token in the payload or generated reports. See
62
+ the [agent content contract](../../contracts/maggiedash/agent-content-v1.json).
63
+
49
64
  ## Workflow
50
65
 
51
66
  1. Inspect the project with `maggie dash status --project PATH`.
@@ -125,6 +140,11 @@ maggie dash cms publish-due --project . --project-id local-project \
125
140
  --reason "scheduled publish tick" --confirm
126
141
  maggie dash cms duplicate --project . --project-id local-project --document-id <id> \
127
142
  --new-id <new-id> --new-slug <new-slug> --reason "create draft" --confirm
143
+ maggie dash cms preset-save --project . --project-id local-project \
144
+ --preset-id treatment-page --name "Treatment page" \
145
+ --sections-file .maggie/sections.json --confirm
146
+ maggie dash cms preset-insert --project . --project-id local-project \
147
+ --preset-id treatment-page --existing-id hero-1 --reason "add arrangement" --confirm
128
148
  maggie dash cms redirect --project . --project-id local-project --from-path /old --to-path /new \
129
149
  --reason "canonical slug change" --confirm
130
150
  maggie dash cms preview --project . --project-id local-project --document-id <id> \
@@ -142,3 +162,31 @@ are short-lived HMAC-signed tokens; preview responses must use a real preview
142
162
  URL with `noindex`, `X-Robots-Tag: noindex`, and `Cache-Control: no-store`.
143
163
  Never place the secret in source control or generated reports. See the
144
164
  provider-neutral [MaggieDash contract set](../../contracts/maggiedash/README.md).
165
+
166
+ Reusable arrangements are stored without page-specific section IDs and expand
167
+ into ordinary editable sections with fresh IDs on every insert. Use the
168
+ machine-readable section catalogue before asking a model to plan copy:
169
+
170
+ ```bash
171
+ maggie dash sections validate --registry templates/maggiedash/section-registry.json \
172
+ --sections-file .maggie/sections.json
173
+ maggie dash sections prompt --registry templates/maggiedash/section-registry.json
174
+ maggie dash sections keys --registry templates/maggiedash/section-registry.json \
175
+ --page-id <page-id> --sections-file .maggie/sections.json
176
+ ```
177
+
178
+ The catalogue declares purpose, usage, placement, repeatability and layout
179
+ limits. Over-limit copy is an editor note; unknown types fail validation.
180
+ Translation keys use stable section IDs, with an array-index fallback only
181
+ until the ordered migration is complete.
182
+
183
+ After any script or direct adapter write to translation data, invalidate the
184
+ running process before verification. Record the restart and run the rendering
185
+ check afterward:
186
+
187
+ ```bash
188
+ maggie ops restart-gate --manifest .maggie/translation-restart.json
189
+ ```
190
+
191
+ This prevents an in-process cache window from turning correct database state
192
+ into temporarily stale pages and false verification failures.
@@ -63,11 +63,15 @@ Generate a reviewable VPS plan and configuration artifacts locally:
63
63
  python3 tools/clis/maggie_deployment.py /path/to/project \
64
64
  --vps-plan --domain example.co.uk --service example \
65
65
  --release-root /var/www/example \
66
- --plan-dir .maggie/deployment/vps-staging
66
+ --plan-dir .maggie/deployment/vps-staging \
67
+ --runner-output .maggie/deployment/vps-staging/release-runner.sh
67
68
  ```
68
69
 
69
70
  The output contains only the immutable release/current layout, systemd unit,
70
- Nginx reverse-proxy config, health commands and rollback command. It never
71
+ Nginx reverse-proxy config, health commands, rollback command, and an ordered
72
+ reviewable release runner. The runner keeps migrations before the symlink flip,
73
+ carries `.agents`/`.claude` state, restarts before verification, and prunes to
74
+ the current release plus one rollback candidate. It never
71
75
  contains credentials and never SSHs, changes DNS, restarts systemd or deploys.
72
76
 
73
77
  VPS plans include a bounded retention policy: keep two immutable releases,
@@ -136,6 +140,20 @@ MaggieDash schema compatibility, and live runtime security-header checks. It wri
136
140
  `.maggie/release-preflight.json`; a failed gate blocks release. It never
137
141
  deploys, migrates, publishes, or sends provider requests.
138
142
 
143
+ Generate a repeatable VPS runner as part of the plan. A deploy step that exists
144
+ only in an operator's shell history is not a release contract:
145
+
146
+ ```bash
147
+ python3 tools/clis/maggie_deployment.py /path/to/project \
148
+ --vps-plan --domain example.co.uk --service example \
149
+ --release-root /var/www/example \
150
+ --runner-output .maggie/deployment/release-runner.sh
151
+ ```
152
+
153
+ Review the generated runner before execution. Its order is install → typecheck
154
+ → migrate → build → carry agent state → switch → restart → verify → prune;
155
+ retention keeps the current release and one rollback candidate.
156
+
139
157
  Production additionally requires `docs/deployment-rollback-smoke.json` with
140
158
  `passed: true`, `environment: production`, a non-empty `previousRelease`, and
141
159
  `testedAt`. A rollback command in a plan is not proof that the rollback was
@@ -226,3 +226,20 @@ shared contract is in
226
226
  [references/maggiedash-telemetry-contract.md](../../references/maggiedash-telemetry-contract.md);
227
227
  it excludes only explicit tool/test markers and never infers identity from IP
228
228
  or private fields.
229
+
230
+ ## Agent runtime preflight
231
+
232
+ Before starting an agent from a dashboard, run the runtime preflight and show
233
+ its warnings to the operator. Terminal output must be stripped of ANSI escape
234
+ sequences before URL/code parsing. Headless hosts should use device auth and
235
+ state the account prerequisite before starting. Release installs and agent
236
+ conversation state must be carried outside the immutable release directory.
237
+ When continuing a conversation, pass the agent's resume subcommand before
238
+ other arguments; otherwise the CLI may interpret it as a new prompt.
239
+
240
+ ```bash
241
+ maggie ops agent-preflight --output .maggie/agent-runtime-preflight.json
242
+ ```
243
+
244
+ The preflight is advisory about the host environment but its warnings are
245
+ load-bearing operational evidence, not a claim that an agent login succeeded.
@@ -19,6 +19,22 @@ Static audit success
19
19
  does not establish Google indexing, scroll behavior, responsive visibility,
20
20
  or translation quality. Record those as unverified without separate evidence.
21
21
 
22
+ Choose verification from the changed surface. An admin-only change still needs
23
+ the admin routes; an API change needs the API responses; a localized change
24
+ needs every affected locale. Declare the matrix and validate explicit evidence
25
+ before release:
26
+
27
+ ```bash
28
+ maggie verification coverage \
29
+ --contract contracts/maggie-verification/surface-coverage-v1.json \
30
+ --evidence .maggie/surface-coverage.json
31
+ ```
32
+
33
+ The evidence must contain one observation for every declared surface/locale
34
+ pair with a status and `passed: true`. Public-page checks alone cannot close an
35
+ admin or API change, and one source-language rendering cannot close a
36
+ localized change.
37
+
22
38
  Crawl reports include `summary.byLocaleTemplate` with total, passed, failed
23
39
  and failed URLs per declared locale and template. Regions/scripts remain
24
40
  distinct (for example en-GB versus en-US). A template is identified only when
@@ -0,0 +1,12 @@
1
+ {
2
+ "schemaVersion": "maggiedash-section-registry.v1",
3
+ "description": "The machine-readable section vocabulary used by page planners and editors.",
4
+ "sections": [
5
+ {"type": "hero", "purpose": "Says what the page is about and offers the one important action.", "usage": "First on the page; once.", "position": "opening", "repeatable": false, "fields": [{"name": "title", "usage": "Plain words a visitor would search for.", "limit": 70, "required": true}, {"name": "intro", "usage": "Who it is for and what it does.", "limit": 220}, {"name": "ctaLabel", "usage": "The action as a verb.", "limit": 30}]},
6
+ {"type": "prose", "purpose": "Explains a topic at length, optionally beside an image.", "usage": "Use in the body for explanation.", "position": "body", "repeatable": true, "fields": [{"name": "heading", "usage": "The argument in a sentence.", "limit": 90}, {"name": "paragraphs", "usage": "Two or three standalone paragraphs.", "limit": 400, "repeats": {"min": 1, "max": 4}}]},
7
+ {"type": "cards", "purpose": "Presents parallel points side by side.", "usage": "Use for steps or genuinely parallel points.", "position": "body", "repeatable": true, "fields": [{"name": "heading", "usage": "What the cards share.", "limit": 90}, {"name": "items", "usage": "The parallel points.", "limit": 220, "repeats": {"min": 3, "max": 3}}]},
8
+ {"type": "links", "purpose": "Sends the reader to related pages.", "usage": "Near the end after the page has done its job.", "position": "closing", "repeatable": true, "fields": [{"name": "heading", "usage": "What the links have in common.", "limit": 90}, {"name": "items", "usage": "Real related pages on this site.", "limit": 160, "repeats": {"min": 2, "max": 6}}]},
9
+ {"type": "faq", "purpose": "Answers questions a visitor would otherwise ask.", "usage": "Once near the end; use real visitor questions.", "position": "closing", "repeatable": false, "fields": [{"name": "heading", "usage": "The question topic.", "limit": 90}, {"name": "items", "usage": "Direct questions and answers.", "limit": 600, "repeats": {"min": 2, "max": 8}}]},
10
+ {"type": "cta", "purpose": "Makes the closing ask.", "usage": "Last on the page; once.", "position": "closing", "repeatable": false, "fields": [{"name": "heading", "usage": "The invitation in a sentence.", "limit": 90, "required": true}, {"name": "body", "usage": "What happens next.", "limit": 220}, {"name": "label", "usage": "The action as a verb.", "limit": 30, "required": true}]}
11
+ ]
12
+ }
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env python3
2
+ """Use a short-lived MaggieDash host token for validated content writes."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ import sys
10
+ from pathlib import Path
11
+ from urllib.error import HTTPError, URLError
12
+ from urllib.request import Request, urlopen
13
+
14
+
15
+ SENSITIVE_KEYS = {"databaseUrl", "databaseURL", "password", "secret", "token", "apiKey"}
16
+
17
+
18
+ def validate_payload(value: object) -> dict:
19
+ if not isinstance(value, dict):
20
+ raise ValueError("payload must be a JSON object")
21
+ forbidden = sorted(SENSITIVE_KEYS.intersection(value))
22
+ if forbidden:
23
+ raise ValueError("payload must not contain credentials; use the short-lived token environment variable")
24
+ if not value.get("op"):
25
+ raise ValueError("payload.op is required")
26
+ return value
27
+
28
+
29
+ def write(url: str, token_env: str, payload: dict, timeout: int = 30) -> dict:
30
+ token = os.environ.get(token_env, "")
31
+ if not token:
32
+ raise ValueError(f"{token_env} is not set")
33
+ request = Request(url, data=json.dumps(payload, ensure_ascii=False).encode("utf-8"), method="POST", headers={
34
+ "Accept": "application/json", "Content-Type": "application/json", "Authorization": f"Bearer {token}",
35
+ })
36
+ try:
37
+ with urlopen(request, timeout=timeout) as response:
38
+ raw = response.read().decode("utf-8", errors="replace")
39
+ status = response.status
40
+ except HTTPError as error:
41
+ raw = error.read().decode("utf-8", errors="replace")
42
+ status = error.code
43
+ except URLError as error:
44
+ raise RuntimeError("content host could not be reached") from error
45
+ try:
46
+ body = json.loads(raw) if raw else {}
47
+ except json.JSONDecodeError:
48
+ body = {"error": "host returned non-JSON"}
49
+ if isinstance(body, dict):
50
+ for key in SENSITIVE_KEYS:
51
+ body.pop(key, None)
52
+ return {"status": status, "passed": 200 <= status < 300, "response": body}
53
+
54
+
55
+ def main() -> int:
56
+ parser = argparse.ArgumentParser(description=__doc__)
57
+ sub = parser.add_subparsers(dest="command", required=True)
58
+ command = sub.add_parser("write", help="POST a validated content operation")
59
+ command.add_argument("--url", required=True)
60
+ command.add_argument("--token-env", default="MAGGIE_CONTENT_TOKEN")
61
+ command.add_argument("--payload", type=Path, required=True)
62
+ command.add_argument("--timeout", type=int, default=30)
63
+ args = parser.parse_args()
64
+ try:
65
+ payload = validate_payload(json.loads(args.payload.read_text(encoding="utf-8")))
66
+ result = write(args.url, args.token_env, payload, args.timeout)
67
+ print(json.dumps(result, ensure_ascii=False, indent=2))
68
+ return 0 if result["passed"] else 1
69
+ except (OSError, ValueError, RuntimeError) as error:
70
+ print(f"agent-content: {error}", file=sys.stderr)
71
+ return 2
72
+
73
+
74
+ if __name__ == "__main__":
75
+ raise SystemExit(main())
@@ -23,6 +23,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
23
23
  from maggie_dash_store import MaggieDashStore # noqa: E402
24
24
  from service_variants import ServiceVariantStore # noqa: E402
25
25
  from maggie_dash_ui import load_and_validate # noqa: E402
26
+ from maggie_sections import catalogue, section_id_migration, validate_registry, copy_notes # noqa: E402
26
27
 
27
28
 
28
29
  def project_root(args: argparse.Namespace) -> Path:
@@ -296,6 +297,13 @@ def command_cms(args: argparse.Namespace) -> int:
296
297
  result = store.add_redirect(args.project_id, args.from_path, args.to_path, args.actor, args.reason, args.status_code)
297
298
  elif args.cms_command == "preview":
298
299
  result = store.issue_preview(args.project_id, args.document_id, args.secret, args.ttl)
300
+ elif args.cms_command == "preset-save":
301
+ sections = json.loads(Path(args.sections_file).resolve().read_text(encoding="utf-8"))
302
+ result = store.save_section_preset(args.project_id, args.preset_id, args.name, sections, args.actor, args.description)
303
+ elif args.cms_command == "preset-list":
304
+ result = store.list_section_presets(args.project_id)
305
+ elif args.cms_command == "preset-insert":
306
+ result = store.insert_section_preset(args.project_id, args.preset_id, args.existing_id, args.actor, args.reason)
299
307
  else:
300
308
  raise ValueError(f"unknown CMS command: {args.cms_command}")
301
309
  emit(result)
@@ -310,6 +318,25 @@ def command_ui(args: argparse.Namespace) -> int:
310
318
  return 0 if result["passed"] else 1
311
319
 
312
320
 
321
+ def command_sections(args: argparse.Namespace) -> int:
322
+ registry = json.loads(Path(args.registry).resolve().read_text(encoding="utf-8"))
323
+ checked = validate_registry(registry)
324
+ if not checked["passed"]:
325
+ emit(checked)
326
+ return 1
327
+ if args.sections_command == "prompt":
328
+ print(catalogue(registry))
329
+ return 0
330
+ if args.sections_command == "validate":
331
+ sections = json.loads(Path(args.sections_file).resolve().read_text(encoding="utf-8"))
332
+ result = {**checked, "copy": copy_notes(registry, sections)}
333
+ emit(result)
334
+ return 0 if result["copy"]["passed"] else 1
335
+ sections = json.loads(Path(args.sections_file).resolve().read_text(encoding="utf-8"))
336
+ emit(section_id_migration(args.page_id, sections))
337
+ return 0
338
+
339
+
313
340
  def variant_store(args: argparse.Namespace) -> ServiceVariantStore:
314
341
  return ServiceVariantStore(project_root(args) / ".maggie" / "service-variants.json")
315
342
 
@@ -399,6 +426,12 @@ def parser() -> argparse.ArgumentParser:
399
426
  redirect.add_argument("--project", default="."); redirect.add_argument("--project-id", default="local-project"); redirect.add_argument("--from-path", required=True); redirect.add_argument("--to-path", required=True); redirect.add_argument("--status-code", type=int, default=301); redirect.add_argument("--actor", default="cli"); redirect.add_argument("--reason", required=True); redirect.add_argument("--confirm", action="store_true")
400
427
  preview = cms_sub.add_parser("preview")
401
428
  preview.add_argument("--project", default="."); preview.add_argument("--project-id", default="local-project"); preview.add_argument("--document-id", required=True); preview.add_argument("--secret", required=True); preview.add_argument("--ttl", type=int, default=900); preview.add_argument("--confirm", action="store_true")
429
+ preset_save = cms_sub.add_parser("preset-save", help="save a reusable ordered section arrangement")
430
+ preset_save.add_argument("--project", default="."); preset_save.add_argument("--project-id", default="local-project"); preset_save.add_argument("--preset-id", required=True); preset_save.add_argument("--name", required=True); preset_save.add_argument("--sections-file", required=True); preset_save.add_argument("--description", default=""); preset_save.add_argument("--actor", default="cli"); preset_save.add_argument("--confirm", action="store_true")
431
+ preset_list = cms_sub.add_parser("preset-list", help="list reusable section arrangements")
432
+ preset_list.add_argument("--project", default="."); preset_list.add_argument("--project-id", default="local-project"); preset_list.add_argument("--confirm", action="store_true")
433
+ preset_insert = cms_sub.add_parser("preset-insert", help="expand an arrangement with fresh section IDs")
434
+ preset_insert.add_argument("--project", default="."); preset_insert.add_argument("--project-id", default="local-project"); preset_insert.add_argument("--preset-id", required=True); preset_insert.add_argument("--existing-id", action="append", default=[]); preset_insert.add_argument("--actor", default="cli"); preset_insert.add_argument("--reason", required=True); preset_insert.add_argument("--confirm", action="store_true")
402
435
  cms.set_defaults(func=command_cms)
403
436
  ui = sub.add_parser("ui", help="validate MaggieDash dashboard chrome")
404
437
  ui_sub = ui.add_subparsers(dest="ui_command", required=True)
@@ -406,6 +439,17 @@ def parser() -> argparse.ArgumentParser:
406
439
  ui_validate.add_argument("--contract", required=True)
407
440
  ui_validate.add_argument("--source", action="append", default=[])
408
441
  ui_validate.set_defaults(func=command_ui)
442
+ sections = sub.add_parser("sections", help="validate section registry and stable section identities")
443
+ sections_sub = sections.add_subparsers(dest="sections_command", required=True)
444
+ sections_validate = sections_sub.add_parser("validate")
445
+ sections_validate.add_argument("--registry", required=True); sections_validate.add_argument("--sections-file", required=True)
446
+ sections_validate.set_defaults(func=command_sections)
447
+ sections_prompt = sections_sub.add_parser("prompt")
448
+ sections_prompt.add_argument("--registry", required=True)
449
+ sections_prompt.set_defaults(func=command_sections)
450
+ sections_keys = sections_sub.add_parser("keys")
451
+ sections_keys.add_argument("--registry", required=True); sections_keys.add_argument("--sections-file", required=True); sections_keys.add_argument("--page-id", required=True)
452
+ sections_keys.set_defaults(func=command_sections)
409
453
  variant = sub.add_parser("variant", help="manage service variant lifecycle")
410
454
  variant_sub = variant.add_subparsers(dest="variant_command", required=True)
411
455
  create = variant_sub.add_parser("create"); create.add_argument("--project", default="."); create.add_argument("--service-id", required=True); create.add_argument("--variant-id", required=True); create.add_argument("--variant-type", required=True); create.add_argument("--locale", required=True); create.add_argument("--market", required=True); create.add_argument("--slug", required=True); create.add_argument("--title", required=True); create.add_argument("--facts", required=True); create.add_argument("--source-revision", required=True); create.add_argument("--canonical-variant-id"); create.add_argument("--cluster-link", action="append", default=[]); create.add_argument("--layout-family", default="service-default"); create.add_argument("--confirm", action="store_true")
@@ -219,6 +219,54 @@ WantedBy=multi-user.target
219
219
  (output_dir / f"{domain}.conf").write_text(nginx, encoding="utf-8")
220
220
 
221
221
 
222
+ def release_runner(plan: dict) -> str:
223
+ """Render the ordered, reviewable VPS runner for one release."""
224
+ root = plan["release_root"]
225
+ service = plan["service"]
226
+ domain = plan["domain"]
227
+ current = plan["current_release"]
228
+ return f'''#!/usr/bin/env bash
229
+ set -euo pipefail
230
+
231
+ # Migration precedes the symlink flip; later releases carry agent state.
232
+ : "${{RELEASE_ID:?set RELEASE_ID to an immutable release name}}"
233
+ RELEASE_ROOT="{root}"
234
+ CURRENT="{current}"
235
+ RELEASE="$RELEASE_ROOT/releases/$RELEASE_ID"
236
+ PREVIOUS="$(readlink -f "$CURRENT" 2>/dev/null || true)"
237
+ mkdir -p "$RELEASE"
238
+ cd "$RELEASE"
239
+
240
+ npm ci
241
+ npm run typecheck
242
+ npm run migrate
243
+ npm run build
244
+
245
+ if [ -d "$CURRENT/.agents" ]; then cp -a "$CURRENT/.agents" "$RELEASE/.agents"; fi
246
+ if [ -d "$CURRENT/.claude" ]; then cp -a "$CURRENT/.claude" "$RELEASE/.claude"; fi
247
+
248
+ ln -sfn "$RELEASE" "$CURRENT"
249
+ systemctl restart "{service}"
250
+ systemctl is-active --quiet "{service}"
251
+ curl -fsS "https://{domain}/robots.txt" >/dev/null
252
+ curl -fsS "https://{domain}/sitemap.xml" >/dev/null
253
+
254
+ # Keep current plus one rollback candidate, and prune only after health checks.
255
+ mapfile -t RELEASES < <(find "$RELEASE_ROOT/releases" -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\\n' | sort -nr | cut -d' ' -f2-)
256
+ for candidate in "${{RELEASES[@]:2}}"; do
257
+ [ "$candidate" = "$PREVIOUS" ] && continue
258
+ [ "$candidate" = "$RELEASE" ] && continue
259
+ rm -rf -- "$candidate"
260
+ done
261
+ '''
262
+
263
+
264
+ def write_release_runner(path: Path, plan: dict) -> None:
265
+ path.parent.mkdir(parents=True, exist_ok=True)
266
+ path.write_text(release_runner(plan), encoding="utf-8")
267
+ path.chmod(0o750)
268
+
269
+
222
270
  def main() -> int:
223
271
  parser = argparse.ArgumentParser(description=__doc__)
224
272
  parser.add_argument("project", nargs="?", default=".")
@@ -231,6 +279,7 @@ def main() -> int:
231
279
  parser.add_argument("--release-root", default="/var/www/maggie-site", help="immutable release root")
232
280
  parser.add_argument("--node-port", type=int, default=4321)
233
281
  parser.add_argument("--plan-dir", help="directory for generated VPS artifacts")
282
+ parser.add_argument("--runner-output", help="write a reviewable ordered VPS release runner")
234
283
  parser.add_argument("--retention-plan", action="store_true", help="create a read-only release prune candidate plan")
235
284
  parser.add_argument("--current-link", help="current symlink for --retention-plan")
236
285
  parser.add_argument("--keep-releases", type=int, default=2)
@@ -249,6 +298,8 @@ def main() -> int:
249
298
  plan = vps_plan(args.domain, args.service, args.release_root, args.node_port)
250
299
  if args.plan_dir:
251
300
  write_vps_plan(Path(args.plan_dir).resolve(), plan)
301
+ if args.runner_output:
302
+ write_release_runner(Path(args.runner_output).resolve(), plan)
252
303
  print(json.dumps(plan, indent=2, ensure_ascii=False))
253
304
  return 0
254
305
  if args.retention_plan:
@@ -12,6 +12,8 @@ from pathlib import Path
12
12
  sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
13
13
  from dependency_lock import audit as audit_lockfiles
14
14
  from seed_evidence import validate_manifest
15
+ from agent_runtime import preflight as agent_preflight
16
+ from restart_gate import validate as validate_restart_gate
15
17
 
16
18
 
17
19
  REQUIRED_ROUTES = (
@@ -149,6 +151,20 @@ def command_seed_manifest(args: argparse.Namespace) -> int:
149
151
  return 0 if result["passed"] else 1
150
152
 
151
153
 
154
+ def command_agent_preflight(args: argparse.Namespace) -> int:
155
+ result = agent_preflight(release_dir=args.release_dir)
156
+ if args.output:
157
+ write_json(Path(args.output).resolve(), result)
158
+ print(json.dumps(result, indent=2, ensure_ascii=False))
159
+ return 0
160
+
161
+
162
+ def command_restart_gate(args: argparse.Namespace) -> int:
163
+ result = validate_restart_gate(read_json(Path(args.manifest).resolve()))
164
+ print(json.dumps(result, indent=2, ensure_ascii=False))
165
+ return 0 if result["passed"] else 1
166
+
167
+
152
168
  def main() -> int:
153
169
  parser = argparse.ArgumentParser(description=__doc__)
154
170
  parser.add_argument("--project", default=".")
@@ -160,6 +176,13 @@ def main() -> int:
160
176
  seed = sub.add_parser("seed-manifest", help="validate explicit sanitized development fixtures")
161
177
  seed.add_argument("--manifest", required=True)
162
178
  seed.set_defaults(func=command_seed_manifest)
179
+ agent = sub.add_parser("agent-preflight", help="report agent runtime and deployment gotchas")
180
+ agent.add_argument("--output")
181
+ agent.add_argument("--release-dir")
182
+ agent.set_defaults(func=command_agent_preflight)
183
+ restart = sub.add_parser("restart-gate", help="validate restart-after-out-of-band-write evidence")
184
+ restart.add_argument("--manifest", required=True)
185
+ restart.set_defaults(func=command_restart_gate)
163
186
  record = sub.add_parser("record", help="record an explicitly authorised operation")
164
187
  record.add_argument("operation", choices=("sync", "sitemap-match", "rewrite-queue", "rewrite-approve", "publish", "migration"))
165
188
  record.add_argument("--dry-run", action="store_true")
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env python3
2
+ """Validate changed route surfaces and locales from explicit evidence."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ from pathlib import Path
9
+ import sys
10
+
11
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
12
+ from surface_coverage import compare # noqa: E402
13
+
14
+
15
+ def main() -> int:
16
+ parser = argparse.ArgumentParser(description=__doc__)
17
+ sub = parser.add_subparsers(dest="command", required=True)
18
+ coverage = sub.add_parser("coverage")
19
+ coverage.add_argument("--contract", type=Path, required=True)
20
+ coverage.add_argument("--evidence", type=Path, required=True)
21
+ args = parser.parse_args()
22
+ if args.command == "coverage":
23
+ contract = json.loads(args.contract.resolve().read_text(encoding="utf-8"))
24
+ evidence = json.loads(args.evidence.resolve().read_text(encoding="utf-8"))
25
+ observations = evidence.get("observations") if isinstance(evidence, dict) else evidence
26
+ result = compare(contract, observations)
27
+ print(json.dumps(result, ensure_ascii=False, indent=2))
28
+ return 0 if result["passed"] else 1
29
+ return 2
30
+
31
+
32
+ if __name__ == "__main__":
33
+ raise SystemExit(main())
@@ -0,0 +1,38 @@
1
+ """Operational checks for running agent CLIs from a deployed workspace."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from pathlib import Path
8
+
9
+
10
+ ANSI = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
11
+
12
+
13
+ def strip_ansi(value: str) -> str:
14
+ return ANSI.sub("", value)
15
+
16
+
17
+ def preflight(environment: dict[str, str] | None = None, release_dir: str | None = None) -> dict:
18
+ env = environment or dict(os.environ)
19
+ warnings = [
20
+ "strip ANSI terminal escapes before parsing URLs, codes, or stored transcripts",
21
+ "use device authentication on a headless host and confirm the account prerequisite before launch",
22
+ "carry agent installs and conversation state outside immutable release directories",
23
+ "put the resume subcommand before agent flags so the CLI cannot treat it as a new prompt",
24
+ ]
25
+ risks = []
26
+ if env.get("NO_COLOR") != "1" or env.get("TERM") not in {"dumb", ""}:
27
+ risks.append("terminal color may be enabled; parser must still strip ANSI")
28
+ if release_dir and Path(release_dir).name in {"current", "releases"}:
29
+ risks.append("agent state must not be stored only inside an immutable release")
30
+ return {
31
+ "schemaVersion": "maggie-agent-runtime.v1",
32
+ "passed": True,
33
+ "warnings": warnings,
34
+ "risks": risks,
35
+ "auth": "device",
36
+ "resume": {"codex": ["resume", "--last"], "claude": ["--continue"]},
37
+ "statePolicy": "outside-release-directory",
38
+ }
@@ -13,6 +13,8 @@ from datetime import datetime, timedelta, timezone
13
13
  from pathlib import Path
14
14
  from typing import Any
15
15
 
16
+ from maggie_sections import ensure_section_ids
17
+
16
18
 
17
19
  STATUSES = ("draft", "review", "approved", "scheduled", "published", "archived", "trashed")
18
20
  TRANSITIONS = {
@@ -83,6 +85,12 @@ class MaggieDashStore:
83
85
  actor_id TEXT NOT NULL, reason TEXT NOT NULL, correlation_id TEXT NOT NULL,
84
86
  created_at TEXT NOT NULL
85
87
  );
88
+ CREATE TABLE IF NOT EXISTS maggiedash_section_presets (
89
+ id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES maggiedash_projects(id),
90
+ name TEXT NOT NULL, description TEXT, body_json TEXT NOT NULL,
91
+ created_by TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
92
+ UNIQUE(project_id, name)
93
+ );
86
94
  """
87
95
  )
88
96
  self._ensure_column("maggiedash_documents", "trashed_at", "TEXT")
@@ -268,6 +276,49 @@ class MaggieDashStore:
268
276
  self.connection.commit()
269
277
  return result
270
278
 
279
+ def save_section_preset(self, project_id: str, preset_id: str, name: str, sections: list[dict[str, Any]], actor_id: str, description: str = "") -> dict[str, Any]:
280
+ """Save an ordered arrangement without leaking page-specific IDs."""
281
+ name = name.strip()
282
+ if not name:
283
+ raise ValueError("preset name is required")
284
+ if not isinstance(sections, list) or not sections or not all(isinstance(section, dict) and section.get("type") for section in sections):
285
+ raise ValueError("preset sections must be a non-empty typed list")
286
+ body = []
287
+ for section in sections:
288
+ copy = json.loads(json.dumps(section, ensure_ascii=False))
289
+ copy.pop("id", None)
290
+ body.append(copy)
291
+ timestamp = now()
292
+ self.connection.execute(
293
+ "INSERT INTO maggiedash_section_presets (id,project_id,name,description,body_json,created_by,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?) "
294
+ "ON CONFLICT(id) DO UPDATE SET name=excluded.name, description=excluded.description, body_json=excluded.body_json, updated_at=excluded.updated_at",
295
+ (preset_id, project_id, name, description.strip(), json.dumps(body, ensure_ascii=False), actor_id, timestamp, timestamp),
296
+ )
297
+ self._audit(project_id, "section-preset.saved", "section_preset", preset_id, actor_id, "ordered arrangement saved")
298
+ self.connection.commit()
299
+ return self.get_section_preset(project_id, preset_id) or {}
300
+
301
+ def get_section_preset(self, project_id: str, preset_id: str) -> dict[str, Any] | None:
302
+ row = self.connection.execute("SELECT * FROM maggiedash_section_presets WHERE project_id=? AND id=?", (project_id, preset_id)).fetchone()
303
+ if not row:
304
+ return None
305
+ result = dict(row)
306
+ result["body"] = json.loads(result.pop("body_json"))
307
+ return result
308
+
309
+ def list_section_presets(self, project_id: str) -> list[dict[str, Any]]:
310
+ rows = self.connection.execute("SELECT id FROM maggiedash_section_presets WHERE project_id=? ORDER BY updated_at DESC", (project_id,)).fetchall()
311
+ return [self.get_section_preset(project_id, row["id"]) for row in rows]
312
+
313
+ def insert_section_preset(self, project_id: str, preset_id: str, existing_ids: list[str], actor_id: str, reason: str) -> list[dict[str, Any]]:
314
+ preset = self.get_section_preset(project_id, preset_id)
315
+ if not preset:
316
+ raise ValueError("section preset not found")
317
+ result = ensure_section_ids(preset["body"], existing_ids)
318
+ self._audit(project_id, "section-preset.inserted", "section_preset", preset_id, actor_id, reason)
319
+ self.connection.commit()
320
+ return result
321
+
271
322
  def add_redirect(self, project_id: str, from_path: str, to_path: str, actor_id: str, reason: str, status_code: int = 301) -> dict[str, Any]:
272
323
  if not from_path.startswith("/") or not to_path.startswith("/") or self._normalise_path(from_path) == self._normalise_path(to_path):
273
324
  raise ValueError("redirect paths must be distinct absolute paths")
@@ -0,0 +1,143 @@
1
+ """Provider-neutral section registry, copy limits, and stable section keys."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import hashlib
7
+ import json
8
+ import re
9
+ import uuid
10
+ from typing import Any, Iterable
11
+
12
+
13
+ SCHEMA = "maggiedash-section-registry.v1"
14
+ IDENTITY_SCHEMA = "maggiedash-section-identity.v1"
15
+
16
+
17
+ def validate_registry(value: object) -> dict[str, Any]:
18
+ errors: list[str] = []
19
+ if not isinstance(value, dict):
20
+ return {"schemaVersion": SCHEMA, "passed": False, "errors": ["registry must be an object"]}
21
+ if value.get("schemaVersion") != SCHEMA:
22
+ errors.append(f"schemaVersion must be {SCHEMA}")
23
+ sections = value.get("sections")
24
+ if not isinstance(sections, list) or not sections:
25
+ errors.append("sections must be a non-empty list")
26
+ sections = []
27
+ seen: set[str] = set()
28
+ for index, section in enumerate(sections):
29
+ at = f"sections[{index}]"
30
+ if not isinstance(section, dict):
31
+ errors.append(f"{at} must be an object")
32
+ continue
33
+ section_type = str(section.get("type") or "")
34
+ if not re.fullmatch(r"[a-z][a-z0-9-]*", section_type):
35
+ errors.append(f"{at}.type must be a kebab-case identifier")
36
+ if section_type in seen:
37
+ errors.append(f"duplicate section type: {section_type}")
38
+ seen.add(section_type)
39
+ for field in ("purpose", "usage", "position", "repeatable"):
40
+ if field not in section:
41
+ errors.append(f"{at}.{field} is required")
42
+ if section.get("position") not in {"opening", "body", "closing"}:
43
+ errors.append(f"{at}.position must be opening, body, or closing")
44
+ if not isinstance(section.get("repeatable"), bool):
45
+ errors.append(f"{at}.repeatable must be boolean")
46
+ fields = section.get("fields")
47
+ if not isinstance(fields, list) or not fields:
48
+ errors.append(f"{at}.fields must be a non-empty list")
49
+ continue
50
+ field_names: set[str] = set()
51
+ for field_index, field in enumerate(fields):
52
+ field_at = f"{at}.fields[{field_index}]"
53
+ if not isinstance(field, dict) or not field.get("name"):
54
+ errors.append(f"{field_at} needs a name")
55
+ continue
56
+ name = str(field["name"])
57
+ if name in field_names:
58
+ errors.append(f"duplicate field: {section_type}.{name}")
59
+ field_names.add(name)
60
+ if not isinstance(field.get("limit"), int) or field["limit"] < 0:
61
+ errors.append(f"{field_at}.limit must be a non-negative integer")
62
+ if not str(field.get("usage") or "").strip():
63
+ errors.append(f"{field_at}.usage is required")
64
+ repeats = field.get("repeats")
65
+ if repeats is not None and (not isinstance(repeats, dict) or not isinstance(repeats.get("min"), int) or not isinstance(repeats.get("max"), int) or repeats["min"] < 0 or repeats["min"] > repeats["max"]):
66
+ errors.append(f"{field_at}.repeats must declare valid min/max")
67
+ return {"schemaVersion": SCHEMA, "passed": not errors, "errors": errors, "sectionTypes": sorted(seen)}
68
+
69
+
70
+ def catalogue(registry: dict[str, Any]) -> str:
71
+ """Render a compact prompt catalogue without duplicating field guidance."""
72
+ lines: list[str] = []
73
+ for section in registry.get("sections", []):
74
+ lines.append(f"{section['type']} — {section['purpose']} | when: {section['usage']} | place: {section['position']} | {'repeatable' if section['repeatable'] else 'once'}")
75
+ for field in section.get("fields", []):
76
+ repeat = field.get("repeats")
77
+ suffix = f" [{repeat['min']}-{repeat['max']} entries]" if isinstance(repeat, dict) else ""
78
+ lines.append(f" - {field['name']} ≤{field['limit']} chars{suffix}: {field['usage']}")
79
+ return "\n".join(lines)
80
+
81
+
82
+ def copy_notes(registry: dict[str, Any], sections: object) -> dict[str, Any]:
83
+ """Return advisory copy/shape notes; over-limit copy is not rejected."""
84
+ errors: list[str] = []
85
+ notes: list[dict[str, Any]] = []
86
+ if not isinstance(sections, list):
87
+ return {"passed": False, "errors": ["sections must be a list"], "notes": []}
88
+ specs = {str(item.get("type")): item for item in registry.get("sections", []) if isinstance(item, dict)}
89
+ for index, section in enumerate(sections):
90
+ if not isinstance(section, dict):
91
+ errors.append(f"sections[{index}] must be an object")
92
+ continue
93
+ section_type = str(section.get("type") or "")
94
+ spec = specs.get(section_type)
95
+ if not spec:
96
+ errors.append(f"unknown section type: {section_type}")
97
+ continue
98
+ for field in spec.get("fields", []):
99
+ name = str(field.get("name"))
100
+ value = section.get(name)
101
+ limit = int(field.get("limit", 0))
102
+ if isinstance(value, str) and limit and len(value) > limit:
103
+ notes.append({"at": f"sections[{index}].{name}", "message": f"{len(value)} characters; layout limit is about {limit}"})
104
+ repeats = field.get("repeats")
105
+ if isinstance(repeats, dict) and isinstance(value, list) and not repeats["min"] <= len(value) <= repeats["max"]:
106
+ notes.append({"at": f"sections[{index}].{name}", "message": f"{len(value)} entries; layout expects {repeats['min']}-{repeats['max']}"})
107
+ return {"passed": not errors, "errors": errors, "notes": notes}
108
+
109
+
110
+ def new_section_id(existing: Iterable[str] = ()) -> str:
111
+ taken = set(existing)
112
+ while True:
113
+ candidate = uuid.uuid4().hex[:8]
114
+ if candidate not in taken:
115
+ return candidate
116
+
117
+
118
+ def ensure_section_ids(sections: list[dict[str, Any]], existing: Iterable[str] = ()) -> list[dict[str, Any]]:
119
+ result = copy.deepcopy(sections)
120
+ existing_ids = set(existing)
121
+ taken = set(existing_ids)
122
+ for section in result:
123
+ current = str(section.get("id") or "")
124
+ if not current or current in existing_ids or current in taken:
125
+ section["id"] = new_section_id(taken)
126
+ taken.add(str(section["id"]))
127
+ return result
128
+
129
+
130
+ def section_key(section: object, index: int) -> str:
131
+ if isinstance(section, dict) and isinstance(section.get("id"), str) and section["id"]:
132
+ return section["id"]
133
+ return str(index)
134
+
135
+
136
+ def translation_key(page_id: str, section: object, index: int, field: str) -> str:
137
+ return f"page.{page_id}.sections.{section_key(section, index)}.{field}"
138
+
139
+
140
+ def section_id_migration(page_id: str, sections: list[dict[str, Any]]) -> dict[str, Any]:
141
+ identified = ensure_section_ids(sections)
142
+ mappings = [{"from": f"page.{page_id}.sections.{index}.", "to": f"page.{page_id}.sections.{section['id']}."} for index, section in enumerate(identified)]
143
+ return {"schemaVersion": IDENTITY_SCHEMA, "pageId": page_id, "sections": identified, "renameOrder": sorted(mappings, key=lambda item: len(item["from"]), reverse=True), "sourceChecksum": hashlib.sha256(json.dumps(sections, sort_keys=True, ensure_ascii=False, separators=(",", ":")).encode()).hexdigest()}
@@ -0,0 +1,22 @@
1
+ """Evidence gate for writes performed outside the running application."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ def validate(value: object) -> dict[str, Any]:
9
+ errors: list[str] = []
10
+ if not isinstance(value, dict):
11
+ return {"schemaVersion": "maggie-restart-gate.v1", "passed": False, "errors": ["restart gate must be an object"]}
12
+ if value.get("schemaVersion") != "maggie-restart-gate.v1":
13
+ errors.append("unsupported restart gate schema")
14
+ if value.get("outOfBandWrite") is not True:
15
+ errors.append("outOfBandWrite must be true for this gate")
16
+ if value.get("restartRequired") is not True:
17
+ errors.append("restartRequired must be true after an out-of-band write")
18
+ if not str(value.get("restartCommand") or "").strip():
19
+ errors.append("restartCommand is required")
20
+ if value.get("verificationAfterRestart") is not True:
21
+ errors.append("verificationAfterRestart must be true")
22
+ return {"schemaVersion": "maggie-restart-gate.v1", "passed": not errors, "errors": errors, "order": ["write", "restart", "verify"]}
@@ -0,0 +1,48 @@
1
+ """Coverage checks that follow the surface and locale changed by a release."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ SCHEMA = "maggie-surface-coverage.v1"
9
+
10
+
11
+ def validate_contract(contract: object) -> dict[str, Any]:
12
+ errors: list[str] = []
13
+ if not isinstance(contract, dict):
14
+ return {"schemaVersion": SCHEMA, "passed": False, "errors": ["coverage contract must be an object"]}
15
+ if contract.get("schemaVersion") != SCHEMA:
16
+ errors.append(f"schemaVersion must be {SCHEMA}")
17
+ surfaces = contract.get("surfaces")
18
+ if not isinstance(surfaces, list) or not surfaces:
19
+ errors.append("surfaces must be a non-empty list")
20
+ surfaces = []
21
+ seen: set[tuple[str, str]] = set()
22
+ for index, item in enumerate(surfaces):
23
+ if not isinstance(item, dict) or not item.get("name") or not isinstance(item.get("routes"), list) or not item["routes"] or not isinstance(item.get("locales"), list) or not item["locales"]:
24
+ errors.append(f"surfaces[{index}] needs name, routes, and locales")
25
+ continue
26
+ for locale in item["locales"]:
27
+ key = (str(item["name"]), str(locale))
28
+ if key in seen:
29
+ errors.append(f"duplicate surface/locale: {key[0]}/{key[1]}")
30
+ seen.add(key)
31
+ return {"schemaVersion": SCHEMA, "passed": not errors, "errors": errors, "expectedPairs": sorted(f"{surface}/{locale}" for surface, locale in seen)}
32
+
33
+
34
+ def compare(contract: dict[str, Any], observations: object) -> dict[str, Any]:
35
+ checked = validate_contract(contract)
36
+ if not checked["passed"]:
37
+ return {**checked, "observedPairs": [], "missing": [], "failed": []}
38
+ expected = {(str(item["name"]), str(locale)) for item in contract["surfaces"] for locale in item["locales"]}
39
+ observed: dict[tuple[str, str], dict[str, Any]] = {}
40
+ for item in observations if isinstance(observations, list) else []:
41
+ if not isinstance(item, dict):
42
+ continue
43
+ key = (str(item.get("surface") or ""), str(item.get("locale") or ""))
44
+ if key in expected:
45
+ observed[key] = item
46
+ missing = sorted(f"{surface}/{locale}" for surface, locale in expected - observed.keys())
47
+ failed = sorted(f"{surface}/{locale}" for (surface, locale), item in observed.items() if item.get("passed") is not True or int(item.get("status", 200)) >= 400)
48
+ return {"schemaVersion": SCHEMA, "passed": not missing and not failed, "expectedPairs": sorted(f"{surface}/{locale}" for surface, locale in expected), "observedPairs": sorted(f"{surface}/{locale}" for surface, locale in observed), "missing": missing, "failed": failed}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topy-ai/maggie",
3
- "version": "0.7.4",
3
+ "version": "0.7.5",
4
4
  "description": "Install and manage Maggie Skills for AI coding agents",
5
5
  "license": "MIT",
6
6
  "type": "module",