@topy-ai/maggie 0.7.4 → 0.7.6

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 (29) hide show
  1. package/README.md +34 -6
  2. package/README.zh-TW.md +5 -1
  3. package/bin/maggie.js +18 -2
  4. package/bundled-contracts/maggie-deployment/release-runner-v1.json +14 -0
  5. package/bundled-contracts/maggiedash/README.md +6 -0
  6. package/bundled-contracts/maggiedash/agent-content-v1.json +17 -0
  7. package/bundled-contracts/maggiedash/section-identity-v1.json +14 -0
  8. package/bundled-contracts/maggiedash/translation-cache-policy-v1.json +15 -0
  9. package/bundled-references/localization-extraction-render-prd.md +55 -0
  10. package/bundled-skills/maggie-content-localization/SKILL.md +1 -1
  11. package/bundled-skills/maggie-dash/SKILL.md +53 -1
  12. package/bundled-skills/maggie-deployment/SKILL.md +20 -2
  13. package/bundled-skills/maggie-feedback/SKILL.md +4 -1
  14. package/bundled-skills/maggie-ops/SKILL.md +17 -0
  15. package/bundled-skills/maggie-seo-geo/SKILL.md +20 -0
  16. package/bundled-templates/maggiedash/section-registry.json +12 -0
  17. package/bundled-tools/clis/maggie_agent_content.py +135 -0
  18. package/bundled-tools/clis/maggie_dash.py +44 -0
  19. package/bundled-tools/clis/maggie_deployment.py +51 -0
  20. package/bundled-tools/clis/maggie_feedback.py +35 -7
  21. package/bundled-tools/clis/maggie_ops.py +23 -0
  22. package/bundled-tools/clis/maggie_verification.py +33 -0
  23. package/bundled-tools/runtime/agent_runtime.py +38 -0
  24. package/bundled-tools/runtime/maggie_dash_store.py +51 -0
  25. package/bundled-tools/runtime/maggie_sections.py +143 -0
  26. package/bundled-tools/runtime/restart_gate.py +22 -0
  27. package/bundled-tools/runtime/surface_coverage.py +122 -0
  28. package/package.json +1 -1
  29. package/references/localization-extraction-render-prd.md +55 -0
@@ -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:
@@ -20,6 +20,9 @@ from urllib.request import Request, urlopen
20
20
 
21
21
  DEFAULT_ENDPOINT = "https://feedback.noblox.app/api/feedback"
22
22
  SECRET_RE = re.compile(r"(?i)(bearer\s+|(?:api[_-]?key|token|secret|password|npm_config[^=]*|authorization)\s*[=:]\s*)[^\s,;]+")
23
+ VERSION_RE = re.compile(r"\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?")
24
+ ACKNOWLEDGEMENT_KEYS = ("status", "feedbackId", "requestId")
25
+ REPO_ROOT = Path(__file__).resolve().parents[2]
23
26
 
24
27
 
25
28
  def now() -> str:
@@ -35,17 +38,29 @@ def project_fingerprint(project: Path) -> str:
35
38
 
36
39
 
37
40
  def installed_version(project: Path) -> str:
38
- """Resolve the version from the runtime wrapper or project install state."""
41
+ """Resolve the exact runtime version and fail closed on source drift."""
39
42
  if os.environ.get("MAGGIE_VERSION"):
40
- return safe_text(os.environ["MAGGIE_VERSION"])
41
- for path in (project / ".maggie" / "install.json", project / "VERSION"):
43
+ value = safe_text(os.environ["MAGGIE_VERSION"])
44
+ return value if VERSION_RE.fullmatch(value) else "unknown"
45
+ install_path = project / ".maggie" / "install.json"
46
+ try:
47
+ value = json.loads(install_path.read_text(encoding="utf-8")).get("version", "")
48
+ if VERSION_RE.fullmatch(str(value)):
49
+ return str(value)
50
+ except (OSError, ValueError, json.JSONDecodeError, AttributeError):
51
+ pass
52
+
53
+ source_versions = []
54
+ for path in (REPO_ROOT / "VERSION", REPO_ROOT / "packages" / "maggie-cli" / "package.json"):
42
55
  try:
43
56
  value = json.loads(path.read_text(encoding="utf-8")) if path.suffix == ".json" else path.read_text(encoding="utf-8").strip()
44
57
  if isinstance(value, dict): value = value.get("version", "")
45
- if re.fullmatch(r"\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?", str(value)):
46
- return str(value)
58
+ if VERSION_RE.fullmatch(str(value)):
59
+ source_versions.append(str(value))
47
60
  except (OSError, ValueError, json.JSONDecodeError):
48
61
  continue
62
+ if source_versions and len(set(source_versions)) == 1:
63
+ return source_versions[0]
49
64
  return "unknown"
50
65
 
51
66
 
@@ -124,6 +139,18 @@ def to_markdown(data: dict) -> str:
124
139
  return "\n".join(lines)
125
140
 
126
141
 
142
+ def acknowledgement(value: object) -> dict:
143
+ """Keep only safe, scalar acknowledgement fields from a provider response."""
144
+ if not isinstance(value, dict):
145
+ return {}
146
+ result = {}
147
+ for key in ACKNOWLEDGEMENT_KEYS:
148
+ item = value.get(key)
149
+ if isinstance(item, (str, int, float, bool)):
150
+ result[key] = safe_text(item)
151
+ return result
152
+
153
+
127
154
  def read_feedback(path: Path) -> dict:
128
155
  return load_json(path)
129
156
 
@@ -152,9 +179,10 @@ def submit(args: argparse.Namespace) -> int:
152
179
  result = json.loads(raw) if raw else {}
153
180
  except (HTTPError, URLError, TimeoutError) as error:
154
181
  raise RuntimeError(f"feedback submission failed: {error}") from error
155
- data["submission"] = {"status": "submitted", "endpoint": endpoint, "submittedAt": now(), "response": result if isinstance(result, dict) else {"value": result}}
182
+ ack = acknowledgement(result)
183
+ data["submission"] = {"status": "submitted", "endpoint": endpoint, "submittedAt": now(), "acknowledgement": ack}
156
184
  path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
157
- print(json.dumps({"feedbackId": data["feedbackId"], "status": "submitted", "response": result}, indent=2, ensure_ascii=False))
185
+ print(json.dumps({"feedbackId": data["feedbackId"], "status": "submitted", "acknowledgement": ack}, indent=2, ensure_ascii=False))
158
186
  return 0
159
187
 
160
188
 
@@ -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"]}