@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
@@ -219,6 +219,12 @@ files and the application remain separated (`maggie-deploy` versus
219
219
  Install and review that narrow sudoers policy on the host before executing a
220
220
  runner.
221
221
 
222
+ Generated release runners include their Maggie version and a template
223
+ fingerprint. VPS preflight checks runners under `.maggie/deployment/` and warns
224
+ when they are unmarked or stale; use `--check-runner PATH` for a runner stored
225
+ elsewhere. Regenerate from the reviewed VPS plan, inspect the diff, and replace
226
+ the checked-in runner only after review.
227
+
222
228
  VPS plans include a bounded retention policy: keep two immutable releases,
223
229
  preserve the `current` target and rollback target, and review prune candidates
224
230
  before any operator executes cleanup. Generate a read-only candidate report:
@@ -301,28 +307,41 @@ output/state artifact and must never be used as the provider source argument.
301
307
  When validating a project schedule, pass `--project` so the URL is compared
302
308
  exactly with `.maggie/booking/services.json` `sourceUrl`.
303
309
 
304
- For a single read-only staging release gate, run. This gate also requires
305
- explicit editorial approval for all launch category copy; rendered draft
306
- evidence is not publication approval:
310
+ For a single read-only staging release gate, first declare the project's release
311
+ surface in `.maggie/release-profile.json`. The manifest is the source of truth;
312
+ Maggie does not infer Booking, MaggieDash, migration, analytics, service
313
+ catalogue, or editorial gates from incidental files. A minimal public Astro
314
+ site can use:
315
+
316
+ ```json
317
+ {
318
+ "schemaVersion": "maggie-release-profile.v1",
319
+ "profile": "public-astro",
320
+ "projectType": "astro",
321
+ "capabilities": ["deployment"]
322
+ }
323
+ ```
324
+
325
+ Add `migration`, `maggiedash-health`, `schedule`, `analytics-contract`,
326
+ `service-facts`, `editorial-review`, or `durable-site-evidence` only when that
327
+ project actually owns the corresponding artifact. For a single read-only
328
+ staging release gate, run:
307
329
 
308
330
  ```bash
309
331
  maggie tool maggie_release.py /path/to/project \
332
+ --profile /path/to/project/.maggie/release-profile.json \
310
333
  --environment staging --target vps-with-cloudflare-dns \
311
334
  --base-url https://staging.example.com
312
335
  ```
313
336
 
314
- If `.maggie/scenario-manifest.json` or `.maggie/qa-runs/` exists, the same
315
- preflight consumes the latest matching `maggie qa` run for the requested
316
- environment (and `--base-url`, when supplied). It blocks when a scenario is
317
- not passed, has no browser-adapter evidence, or the matching run is missing.
318
- Projects without scenario QA report `not-configured`; Maggie does not run the
319
- browser itself.
320
-
321
- This aggregates deployment, migration, provider-health, schedule, analytics,
322
- service-fact, editorial approval, durable SEO/route/category evidence,
323
- MaggieDash schema compatibility, and live runtime security-header checks. It writes
324
- `.maggie/release-preflight.json`; a failed gate blocks release. It never
325
- deploys, migrates, publishes, or sends provider requests.
337
+ If `scenario-qa` is declared, the preflight consumes the latest matching
338
+ `maggie qa` run for the requested environment (and `--base-url`, when
339
+ supplied). It blocks when a scenario is not passed, has no browser-adapter
340
+ evidence, or the matching run is missing. The report records every undeclared
341
+ optional gate as `skipped`, so a public site is not blocked by Booking- or
342
+ MaggieDash-only evidence. It writes `.maggie/release-preflight.json`; a failed
343
+ gate blocks release. It never deploys, migrates, publishes, or sends provider
344
+ requests.
326
345
 
327
346
  Generate a repeatable VPS runner as part of the plan. A deploy step that exists
328
347
  only in an operator's shell history is not a release contract:
@@ -338,6 +357,27 @@ Review the generated runner before execution. Its order is install → typecheck
338
357
  → migrate → build → carry agent state → switch → restart → verify → prune;
339
358
  retention keeps the current release and one rollback candidate.
340
359
 
360
+ Before writing a VPS plan or running the release gate, validate the real
361
+ least-privilege deploy account. This check is read-only: it verifies SSH access,
362
+ write permission for both the release root and `releases/`, and the exact
363
+ non-interactive systemd allowlist. A failed check does not create the plan or
364
+ runner:
365
+
366
+ ```bash
367
+ maggie deployment --verify-deployer \
368
+ --deployer-host "$DEPLOY_SERVER_IP" \
369
+ --deployer-user "$DEPLOY_SERVER_SSH_USER" \
370
+ --release-root /var/www/example \
371
+ --service example \
372
+ --output .maggie/deployment/deployer-delegation.json
373
+ ```
374
+
375
+ For a VPS `maggie release`, this evidence is required at
376
+ `.maggie/deployment/deployer-delegation.json` (or pass
377
+ `--deployer-evidence`). The generated runner waits for bounded transient
378
+ `000/502/503/504` startup responses after restart, while returning a clear
379
+ failure immediately for permanent HTTP responses.
380
+
341
381
  Production additionally requires `docs/deployment-rollback-smoke.json` with
342
382
  `passed: true`, `environment: production`, and `testedAt`. For an upgrade it
343
383
  must include a non-empty `previousRelease`. For the first production release,
@@ -30,10 +30,29 @@ maggie feedback --project . collect \
30
30
  maggie feedback preview .maggie/feedback/<feedback-id>.json --format markdown
31
31
  ```
32
32
 
33
- The draft is written to `.maggie/feedback/`. It contains the Maggie version,
33
+ The draft is written to `.maggie/feedback/`. List local drafts with
34
+ `maggie feedback list --project .`; `--project` works before or after the
35
+ subcommand. A draft contains the Maggie version,
34
36
  skill, run ID, phase, error fingerprint, expected/actual result, reproduction
35
37
  steps, resolution, validation, and metadata-only screenshot references.
36
38
 
39
+ Attach bounded, privacy-safe evidence and relationships when a maintainer will
40
+ need to connect a report to a test, deployment, issue, or related run. Only
41
+ identifiers are stored; file contents, logs, credentials, and local paths are
42
+ never copied:
43
+
44
+ ```bash
45
+ maggie feedback collect --project . --summary "Adapter unavailable" \
46
+ --evidence-ref test:browser-capability:passed \
47
+ --evidence-ref report:browser-capability:v1 \
48
+ --relationship same-run=clone-001
49
+ ```
50
+
51
+ Evidence references are bounded and normalized into the
52
+ `maggie-feedback-evidence-bundle.v1` contract. The accepted kinds are
53
+ `test`, `report`, `screenshot`, `command`, `deployment`, `issue`, `commit`,
54
+ and `artifact`; relationships connect safe IDs such as feedback or run IDs.
55
+
37
56
  If a draft is marked fixed with `--fixed` (or the supplied run report says
38
57
  `fixed`), both `--resolution` and `--validation` are required. Feedback text
39
58
  also scrubs common POSIX/home/temp and Windows absolute paths by default while
@@ -78,6 +97,12 @@ This writes `.maggie/feedback/batches/<batch-id>-review.json` under the
78
97
  duplicate fingerprints/summaries, while retaining only redacted observations;
79
98
  review the aggregate and each draft before submitting.
80
99
 
100
+ Batch review also emits a bounded `evidenceGraph` and
101
+ `remediationSuggestions`. The graph links feedback IDs to evidence IDs and
102
+ safe related references, while suggestions flag duplicate fingerprints,
103
+ missing batch indexes, shared evidence, or a batch with no validation
104
+ references. These are review aids, not automatic issue or memory writes.
105
+
81
106
  Example:
82
107
 
83
108
  {
@@ -29,7 +29,7 @@ truth, content state, or audit log.
29
29
 
30
30
  ## Workflow
31
31
 
32
- 1. At the start of a relevant workflow, run `maggie memory context --project . --skill <skill>` and load only matching active items.
32
+ 1. At the start of a relevant workflow, run `maggie memory context --project . --skill <skill>` and load only matching active project-scope items. Use `--include-shared` only after explicit opt-in.
33
33
  2. Show material preferences, conventions, and prevention lessons before making a decision.
34
34
  3. When the user gives a durable preference or confirms a repaired pitfall, save it explicitly as `candidate` or `active` with scope and evidence.
35
35
  4. Record every meaningful failure with `record-error`; include a stable fingerprint, resolution, and validation result when fixed.
@@ -45,6 +45,15 @@ truth, content state, or audit log.
45
45
  preserving project scope. Expired active items remain inspectable through
46
46
  explicit status listing but are excluded from normal context.
47
47
 
48
+ Project-scope `maggie init` creates the repository's `.maggie/memory/` store.
49
+ Repeated initialization preserves existing memory files without rewriting
50
+ their contents or timestamps. A context response distinguishes a ready store
51
+ from an uninitialized project; the shared memory hook falls back to the
52
+ installed CLI when local Python tools are absent.
53
+
54
+ Project `context`, `search`, and `list` read only project-scope items by
55
+ default. `--include-shared` explicitly opts into user/workspace-scoped entries.
56
+
48
57
  ```bash
49
58
  maggie memory init --project .
50
59
  maggie memory context --project . --skill maggie-clone
@@ -88,6 +88,14 @@ maggie ops google-capabilities --project . \
88
88
  --report .maggie/google-capability-input.json
89
89
  ```
90
90
 
91
+ The input must use `maggie-google-capability-report.v2`: each row records the
92
+ redacted active account, the selected target (`property`, `container`,
93
+ `customer`, `project`, or `site`), and the exact scopes. Both account and target
94
+ must be explicitly verified, and `target.id` must equal `resource`. The command
95
+ prints these safe identifiers and scopes before any separate provider adapter
96
+ is allowed to perform a write. A v1 report is rejected because it cannot prove
97
+ that the browser/session is on the intended Google account and property.
98
+
91
99
  The result separates `read`, `report`, `edit`, and `publish` for each
92
100
  provider/resource. Write and publish remain `not_tested` until the operator
93
101
  explicitly confirms the exact mutation and a successful read-back is captured.
@@ -164,6 +172,20 @@ Every mutation must validate the session, role, resource ownership/project
164
172
  scope, input schema, legal state transition, and idempotency/correlation key.
165
173
  Record actor, timestamp, previous state, next state, reason, and result.
166
174
 
175
+ For a GA4/GTM or other Google provider write, validate a hash-only
176
+ write/readback bundle before treating the operation as complete:
177
+
178
+ ```bash
179
+ maggie ops external-write-gate --project . \
180
+ --evidence .maggie/external-write-readback-input.json \
181
+ --output .maggie/external-write-readback.json
182
+ ```
183
+
184
+ The bundle requires one stable idempotency key across bounded retries, explicit
185
+ confirmation, successful readback fingerprints, an empty duplicate list, and
186
+ an empty orphaned-workspace list. It never stores provider payloads; an
187
+ incomplete or mismatched readback blocks the release gate.
188
+
167
189
  ### Operate
168
190
 
169
191
  Use the dashboard or API to perform an explicitly requested operation. Show an
@@ -125,7 +125,10 @@ maggie analytics release-gate --project . --environment staging \
125
125
 
126
126
  Use `contracts/maggie-seo/gsc-readiness-v1.schema.json` as the evidence
127
127
  boundary. Credentials and mutation scopes remain host-owned; a passing local
128
- contract is not proof of property ownership or live readback.
128
+ contract is not proof of property ownership or live readback. If a GA4/GTM
129
+ write is part of the workflow, also pass hash-only
130
+ `.maggie/external-write-readback.json` evidence to the analytics release gate;
131
+ duplicate or orphaned provider objects fail closed.
129
132
 
130
133
  ## Freeze and compare a reviewed site
131
134
 
@@ -13,6 +13,7 @@ from urllib.parse import urlsplit
13
13
  import sys
14
14
  sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
15
15
  from analytics_traffic import audit_events # noqa: E402
16
+ from external_write import validate as validate_external_write # noqa: E402
16
17
 
17
18
 
18
19
  GA4_ID = re.compile(r"^G-[A-Z0-9]+$", re.I)
@@ -122,6 +123,12 @@ def release_gate(args: argparse.Namespace) -> int:
122
123
  errors.append("gsc-evidence-schema")
123
124
  elif args.require_gsc:
124
125
  errors.append("gsc-evidence-required")
126
+ if args.write_readback:
127
+ write_readback, write_errors = load_json(Path(args.write_readback).resolve(), "external write readback")
128
+ errors.extend(write_errors)
129
+ write_result = validate_external_write(write_readback or {})
130
+ check("external-write-readback", write_result["passed"], "idempotency, readback, duplicate, and cleanup evidence", checks)
131
+ errors.extend(write_result["errors"])
125
132
  if browser:
126
133
  check("browser-schema", browser.get("schemaVersion") == "maggie-analytics-browser.v1", "versioned browser evidence", checks)
127
134
  check("browser-render", browser.get("passed") is True and isinstance(browser.get("routes"), list) and bool(browser["routes"]), "routes rendered without a browser failure", checks)
@@ -191,6 +198,7 @@ def main() -> int:
191
198
  parser.add_argument("--smoke-report", help="production smoke evidence for release-gate")
192
199
  parser.add_argument("--gsc-evidence", help="versioned GSC readiness evidence for release-gate")
193
200
  parser.add_argument("--require-gsc", action="store_true", help="require GSC readiness evidence in release-gate")
201
+ parser.add_argument("--write-readback", help="hash-only external write/readback evidence for release-gate")
194
202
  parser.add_argument("--events", help="redacted JSON array of analytics events for traffic-audit")
195
203
  args = parser.parse_args()
196
204
  if args.command == "gsc-readiness":
@@ -10,6 +10,7 @@ from urllib.parse import urlparse
10
10
  RUNTIME = Path(__file__).resolve().parents[1] / "runtime"
11
11
  sys.path.insert(0, str(RUNTIME))
12
12
  from browser_behavior import validate_samples
13
+ from browser_capability import discover_browser_adapter, require_browser_adapter
13
14
  from localization_runner import checkpoint
14
15
 
15
16
 
@@ -108,10 +109,17 @@ def run_interactions(call, steps: list[dict]) -> list[dict]:
108
109
  def audit(args):
109
110
  if urlparse(args.url).scheme not in {"http", "https", "file"}:
110
111
  raise ValueError("URL must use http, https or file")
111
- if not args.required:
112
+ if not args.required and not args.check_browser:
112
113
  raise ValueError("at least one --required selector is necessary")
113
114
  output = args.output.resolve()
114
115
  output.mkdir(parents=True, exist_ok=True)
116
+ capability = discover_browser_adapter(args.browse)
117
+ capability_path = args.capability_report.resolve() if args.capability_report else output / "browser-capability.json"
118
+ checkpoint(capability_path, capability)
119
+ if args.check_browser:
120
+ print(json.dumps(capability, indent=2))
121
+ return 0 if capability["status"] == "ready" else 1
122
+ require_browser_adapter(args.browse)
115
123
  interactions = load_interactions(args.interactions)
116
124
  def call(*command):
117
125
  result = subprocess.run([str(args.browse), *command], capture_output=True, text=True, timeout=45)
@@ -120,7 +128,8 @@ def audit(args):
120
128
  return result.stdout
121
129
  report = {"schemaVersion": "maggie-browser-audit.v1", "url": args.url,
122
130
  "passed": False, "viewports": [], "evidence": "browser-captured",
123
- "interactionManifest": str(args.interactions.resolve()) if args.interactions else None}
131
+ "interactionManifest": str(args.interactions.resolve()) if args.interactions else None,
132
+ "browserCapability": str(capability_path)}
124
133
  try:
125
134
  for index, viewport in enumerate(args.viewport or ["390x844", "768x1024", "1440x900"]):
126
135
  width, height = [int(value) for value in viewport.split("x")]
@@ -158,6 +167,8 @@ def main():
158
167
  parser.add_argument("url")
159
168
  parser.add_argument("--browse", type=Path, required=True)
160
169
  parser.add_argument("--output", type=Path, required=True, help="new directory for evidence")
170
+ parser.add_argument("--capability-report", type=Path, help="write the redacted browser adapter capability report here")
171
+ parser.add_argument("--check-browser", action="store_true", help="only check adapter capability; do not navigate or write page evidence")
161
172
  parser.add_argument("--viewport", action="append")
162
173
  parser.add_argument("--required", action="append", default=[])
163
174
  parser.add_argument("--sticky", action="append", default=[])
@@ -3,8 +3,12 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import argparse
6
+ import hashlib
7
+ import inspect
6
8
  import json
9
+ import os
7
10
  import re
11
+ import shlex
8
12
  import subprocess
9
13
  import sys
10
14
  from datetime import datetime, timezone
@@ -181,7 +185,103 @@ def worker_secret_preflight_main(argv: list[str]) -> int:
181
185
  return 0 if result["passed"] else 1
182
186
 
183
187
 
184
- def preflight(project: Path, target: str, environment: str) -> dict:
188
+ def deployer_delegation_preflight(
189
+ host: str,
190
+ user: str,
191
+ release_root: str,
192
+ service: str,
193
+ ssh_command: list[str] | None = None,
194
+ ) -> dict:
195
+ """Read-only SSH check for release-directory and systemd delegation."""
196
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,253}", host):
197
+ raise ValueError("deployer host must be a hostname, address, or IPv6-safe token")
198
+ if not re.fullmatch(r"[a-z_][a-z0-9_-]{0,31}\$?", user):
199
+ raise ValueError("deployer user must be a safe local account name")
200
+ if not release_root.startswith("/") or "\n" in release_root or "\r" in release_root:
201
+ raise ValueError("release root must be an absolute path without newlines")
202
+ if not re.fullmatch(r"[A-Za-z0-9_.@-]+", service):
203
+ raise ValueError("service must contain only safe systemd name characters")
204
+
205
+ root = release_root.rstrip("/") or "/"
206
+ releases = f"{root}/releases" if root != "/" else "/releases"
207
+ remote_script = "\n".join((
208
+ "set -eu",
209
+ f"test -d {shlex.quote(root)} && test -w {shlex.quote(root)}",
210
+ f"test -d {shlex.quote(releases)} && test -w {shlex.quote(releases)}",
211
+ f"sudo -n -l /bin/systemctl restart {shlex.quote(service)} >/dev/null 2>&1",
212
+ f"sudo -n -l /bin/systemctl is-active {shlex.quote(service)} >/dev/null 2>&1",
213
+ )) + "\n"
214
+ command = list(ssh_command or ["ssh"])
215
+ command.extend(["-o", "BatchMode=yes", "-o", "ConnectTimeout=8", f"{user}@{host}", "sh", "-s"])
216
+ try:
217
+ completed = subprocess.run(command, input=remote_script, capture_output=True, text=True, check=False, timeout=20)
218
+ except (OSError, subprocess.SubprocessError) as error:
219
+ return {
220
+ "schemaVersion": "maggie-deployment-delegation.v1",
221
+ "host": host,
222
+ "user": user,
223
+ "releaseRoot": root,
224
+ "service": service,
225
+ "state": "transient",
226
+ "passed": False,
227
+ "checks": {},
228
+ "errors": [f"SSH delegation check could not start: {type(error).__name__}"],
229
+ "mutation": "not executed",
230
+ }
231
+ stderr = (completed.stderr or "").lower()
232
+ transient = completed.returncode in {124, 255} or any(marker in stderr for marker in (
233
+ "timed out", "connection refused", "connection reset", "no route", "could not resolve", "temporary failure",
234
+ ))
235
+ state = "passed" if completed.returncode == 0 else "transient" if transient else "failed"
236
+ return {
237
+ "schemaVersion": "maggie-deployment-delegation.v1",
238
+ "host": host,
239
+ "user": user,
240
+ "releaseRoot": root,
241
+ "service": service,
242
+ "state": state,
243
+ "passed": completed.returncode == 0,
244
+ "checks": {
245
+ "sshConnection": completed.returncode == 0,
246
+ "releaseRootWritable": completed.returncode == 0,
247
+ "releasesDirectoryWritable": completed.returncode == 0,
248
+ "sudoSystemctlRestart": completed.returncode == 0,
249
+ "sudoSystemctlStatus": completed.returncode == 0,
250
+ },
251
+ "errors": [] if completed.returncode == 0 else [
252
+ "deploy-user delegation check is transient; retry after SSH/host readiness" if transient
253
+ else "deploy-user cannot write the release directory or use the exact systemd allowlist"
254
+ ],
255
+ "mutation": "not executed",
256
+ }
257
+
258
+
259
+ def validate_deployer_delegation(value: object, expected_root: str | None = None, expected_service: str | None = None) -> dict:
260
+ if not isinstance(value, dict):
261
+ return {"passed": False, "state": "failed", "errors": ["delegation evidence must be an object"]}
262
+ errors: list[str] = []
263
+ if value.get("schemaVersion") != "maggie-deployment-delegation.v1":
264
+ errors.append("delegation evidence schemaVersion is unsupported")
265
+ if value.get("state") != "passed" or value.get("passed") is not True:
266
+ errors.append("deploy-user delegation evidence is not passed")
267
+ if expected_root and value.get("releaseRoot") != expected_root.rstrip("/"):
268
+ errors.append("delegation releaseRoot does not match the deployment plan")
269
+ if expected_service and value.get("service") != expected_service:
270
+ errors.append("delegation service does not match the deployment plan")
271
+ checks = value.get("checks") if isinstance(value.get("checks"), dict) else {}
272
+ for name in ("sshConnection", "releaseRootWritable", "releasesDirectoryWritable", "sudoSystemctlRestart", "sudoSystemctlStatus"):
273
+ if checks.get(name) is not True:
274
+ errors.append(f"delegation check is not passed: {name}")
275
+ return {"passed": not errors, "state": "passed" if not errors else "failed", "errors": errors}
276
+
277
+
278
+ def preflight(
279
+ project: Path,
280
+ target: str,
281
+ environment: str,
282
+ require_deployer: bool = False,
283
+ delegation_evidence: Path | None = None,
284
+ ) -> dict:
185
285
  package_path = project / "package.json"
186
286
  package = json.loads(package_path.read_text(encoding="utf-8")) if package_path.exists() else {}
187
287
  scripts = package.get("scripts") or {}
@@ -240,10 +340,27 @@ def preflight(project: Path, target: str, environment: str) -> dict:
240
340
  data_required = bool(migration.get("dataDependencies"))
241
341
  data_checkpoint = validate_data_checkpoint(data_path) if data_path.exists() else {"passed": not data_required, "declared": False, "errors": ["data-release.json is required for a data-dependent release"] if data_required else []}
242
342
  retention_ok = bool(plan.get("retention", {}).get("keep") in (2, 3, 4, 5) and plan.get("retention", {}).get("prune") and plan.get("retention", {}).get("preserve"))
243
- result["checks"].update({"node_runtime_declared": bool(package.get("engines", {}).get("node") or package.get("dependencies", {}).get("astro")), "start_command_or_runtime": "start" in scripts or "preview" in scripts, "stateless_or_migration_plan": True, "release_layout_declared": True, "rollback_command_declared": True, "release_retention_policy": retention_ok, "data_release_checkpoint": data_checkpoint["passed"], "remote_verification_pending": True, "release_plan_artifact": bool(plan_path and plan.get("release_layout") and plan.get("current_release")), "systemd_artifact": bool(systemd_text and "ExecStart=" in systemd_text and "EnvironmentFile=" in systemd_text and "NoNewPrivileges=true" in systemd_text), "nginx_artifact": bool(nginx_text and "proxy_pass" in nginx_text and "server_name" in nginx_text), "deployment_artifacts_secret_free": secret_free, "rollback_evidence": rollback_passed if environment == "production" else True})
343
+ delegation = {"passed": True, "state": "not-required", "errors": []}
344
+ delegation_path = delegation_evidence or project / ".maggie" / "deployment" / "deployer-delegation.json"
345
+ if require_deployer:
346
+ try:
347
+ delegation_value = json.loads(delegation_path.read_text(encoding="utf-8"))
348
+ except (OSError, json.JSONDecodeError) as error:
349
+ delegation_value = {}
350
+ delegation = {"passed": False, "state": "inconclusive", "errors": [f"delegation evidence is unavailable: {error}"]}
351
+ else:
352
+ delegation = validate_deployer_delegation(delegation_value, plan.get("release_root"), plan.get("service"))
353
+ delegation["path"] = str(delegation_path)
354
+ result["checks"].update({"node_runtime_declared": bool(package.get("engines", {}).get("node") or package.get("dependencies", {}).get("astro")), "start_command_or_runtime": "start" in scripts or "preview" in scripts, "stateless_or_migration_plan": True, "release_layout_declared": True, "rollback_command_declared": True, "release_retention_policy": retention_ok, "data_release_checkpoint": data_checkpoint["passed"], "remote_verification_pending": not delegation["passed"], "deployer_delegation": delegation["passed"], "release_plan_artifact": bool(plan_path and plan.get("release_layout") and plan.get("current_release")), "systemd_artifact": bool(systemd_text and "ExecStart=" in systemd_text and "EnvironmentFile=" in systemd_text and "NoNewPrivileges=true" in systemd_text), "nginx_artifact": bool(nginx_text and "proxy_pass" in nginx_text and "server_name" in nginx_text), "deployment_artifacts_secret_free": secret_free, "rollback_evidence": rollback_passed if environment == "production" else True})
355
+ result["delegation"] = delegation
244
356
  result["vps"] = {"runtime":"Node.js standalone","reverseProxy":"Nginx","processManager":"systemd","releaseLayout":"/var/www/<site>/releases/<release> + current symlink","dns":"Cloudflare DNS or registrar DNS; verify apex and www separately","remoteCommands":["npm ci","npm run build","systemctl restart <service>","systemctl is-active <service>","curl -fsS https://<domain>/robots.txt","curl -fsS https://<domain>/sitemap.xml"],"handoverFields":["host","sshUser","domain","service","release","previousRelease","nginxConfig","migrationVersion","verification","rollback"]}
245
357
  result["next_action"] = "review VPS host, SSH user, domain/DNS, systemd service, Nginx config, release path, migrations, and rollback owner before execute"
358
+ runners = sorted(deployment_dir.rglob("release-runner.sh")) if deployment_dir.exists() else []
359
+ result["release_runners"] = [release_runner_status(path) for path in runners[:25]]
360
+ result["warnings"] = [f"generated release runner is stale or unmarked: {item['path']}" for item in result["release_runners"] if item["status"] == "stale"]
246
361
  result["blocking_checks"] = ["package_manifest", "build_command", "env_example", "node_runtime_declared", "start_command_or_runtime", "stateless_or_migration_plan", "release_layout_declared", "rollback_command_declared", "release_retention_policy", "data_release_checkpoint", "release_plan_artifact", "systemd_artifact", "nginx_artifact", "deployment_artifacts_secret_free", "rollback_evidence"]
362
+ if require_deployer:
363
+ result["blocking_checks"].append("deployer_delegation")
247
364
  else:
248
365
  result["blocking_checks"] = ["package_manifest", "build_command", "env_example"]
249
366
  return result
@@ -438,15 +555,51 @@ WantedBy=multi-user.target
438
555
  (output_dir / f"{domain}.conf").write_text(nginx, encoding="utf-8")
439
556
 
440
557
 
558
+ def release_runner_generator() -> dict[str, str]:
559
+ version = os.environ.get("MAGGIE_VERSION", "").strip()
560
+ if not version:
561
+ version_file = Path(__file__).resolve().parents[2] / "VERSION"
562
+ try:
563
+ version = version_file.read_text(encoding="utf-8").strip()
564
+ except OSError:
565
+ version = "unknown"
566
+ source = inspect.getsource(release_runner).encode("utf-8")
567
+ return {"version": version, "fingerprint": hashlib.sha256(source).hexdigest()[:16]}
568
+
569
+
570
+ def release_runner_status(path: Path) -> dict:
571
+ try:
572
+ content = path.read_text(encoding="utf-8", errors="replace")
573
+ except OSError as error:
574
+ return {"path": str(path), "status": "stale", "reason": f"runner cannot be read: {error}", "refresh": "Regenerate from the reviewed VPS plan and inspect the diff."}
575
+ match = re.search(r"^# Maggie release runner: generator=([^ ]+) template=([a-f0-9]{16})$", content, re.MULTILINE)
576
+ current = release_runner_generator()
577
+ if not match:
578
+ return {"path": str(path), "status": "stale", "reason": "runner has no generator version/fingerprint", "currentGenerator": current, "refresh": "Regenerate from the reviewed VPS plan and inspect the diff before replacing this file."}
579
+ generated = {"version": match.group(1), "fingerprint": match.group(2)}
580
+ stale = generated != current
581
+ return {
582
+ "path": str(path),
583
+ "status": "stale" if stale else "current",
584
+ "generatedBy": generated,
585
+ "currentGenerator": current,
586
+ "reason": "runner generator version or template fingerprint has changed" if stale else "runner matches the current generator",
587
+ "refresh": "Regenerate from the reviewed VPS plan and inspect the diff before replacing this file." if stale else "",
588
+ }
589
+
590
+
441
591
  def release_runner(plan: dict) -> str:
442
592
  """Render the ordered, reviewable VPS runner for one release."""
443
593
  root = plan["release_root"]
444
594
  service = plan["service"]
445
595
  domain = plan["domain"]
446
596
  current = plan["current_release"]
597
+ generator = release_runner_generator()
447
598
  return f'''#!/usr/bin/env bash
448
599
  set -euo pipefail
449
600
 
601
+ # Maggie release runner: generator={generator["version"]} template={generator["fingerprint"]}
602
+
450
603
  # Migration precedes the symlink flip; later releases carry agent state.
451
604
  : "${{RELEASE_ID:?set RELEASE_ID to an immutable release name}}"
452
605
  RELEASE_KIND="${{RELEASE_KIND:-upgrade}}"
@@ -471,9 +624,38 @@ if [ -d "$CURRENT/.claude" ]; then cp -a "$CURRENT/.claude" "$RELEASE/.claude";
471
624
 
472
625
  ln -sfn "$RELEASE" "$CURRENT"
473
626
  sudo -n /bin/systemctl restart "{service}"
474
- sudo -n /bin/systemctl is-active --quiet "{service}"
475
- curl -fsS "https://{domain}/robots.txt" >/dev/null
476
- curl -fsS "https://{domain}/sitemap.xml" >/dev/null
627
+
628
+ # A restarted service can be healthy at the origin before Nginx/Cloudflare is
629
+ # ready. Retry only bounded transient states; fail immediately on permanent
630
+ # HTTP responses so an outage is not hidden as a startup delay.
631
+ wait_for_active() {{
632
+ local attempt
633
+ for attempt in $(seq 1 12); do
634
+ if sudo -n /bin/systemctl is-active --quiet "{service}"; then return 0; fi
635
+ if [ "$attempt" -lt 12 ]; then sleep 2; fi
636
+ done
637
+ echo "permanent service readiness failure: {service}" >&2
638
+ return 1
639
+ }}
640
+ wait_for_http() {{
641
+ local url="$1" attempt status
642
+ for attempt in $(seq 1 12); do
643
+ status="$(curl --silent --show-error --output /dev/null --write-out '%{{http_code}}' "$url" 2>/dev/null || printf '000')"
644
+ case "$status" in
645
+ 2??|3??) return 0 ;;
646
+ 000|502|503|504)
647
+ if [ "$attempt" -lt 12 ]; then sleep 2; continue; fi
648
+ echo "transient readiness exhausted: $url returned $status" >&2
649
+ return 1 ;;
650
+ *)
651
+ echo "permanent readiness failure: $url returned $status" >&2
652
+ return 1 ;;
653
+ esac
654
+ done
655
+ }}
656
+ wait_for_active
657
+ wait_for_http "https://{domain}/robots.txt"
658
+ wait_for_http "https://{domain}/sitemap.xml"
477
659
 
478
660
  # Keep current plus one rollback candidate, and prune only after health checks.
479
661
  mapfile -t RELEASES < <(find "$RELEASE_ROOT/releases" -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\\n' | sort -nr | cut -d' ' -f2-)
@@ -507,8 +689,13 @@ def main() -> int:
507
689
  parser.add_argument("--release-root", default="/var/www/maggie-site", help="immutable release root")
508
690
  parser.add_argument("--node-port", type=int, required=False, default=None, help="unused host port; required with --vps-plan")
509
691
  parser.add_argument("--deployer-user", default="maggie-deploy", help="dedicated least-privilege SSH/deploy account")
692
+ parser.add_argument("--deployer-host", help="SSH host for --verify-deployer")
693
+ parser.add_argument("--verify-deployer", action="store_true", help="run a read-only SSH delegation check before writing VPS artifacts")
694
+ parser.add_argument("--require-deployer", action="store_true", help="require passed delegation evidence during deployment preflight")
695
+ parser.add_argument("--deployer-evidence", help="delegation evidence JSON; defaults to .maggie/deployment/deployer-delegation.json")
510
696
  parser.add_argument("--plan-dir", help="directory for generated VPS artifacts")
511
697
  parser.add_argument("--runner-output", help="write a reviewable ordered VPS release runner")
698
+ parser.add_argument("--check-runner", help="read-only check of a generated runner's Maggie version and template fingerprint")
512
699
  parser.add_argument("--retention-plan", action="store_true", help="create a read-only release prune candidate plan")
513
700
  parser.add_argument("--current-link", help="current symlink for --retention-plan")
514
701
  parser.add_argument("--keep-releases", type=int, default=2)
@@ -521,6 +708,21 @@ def main() -> int:
521
708
  parser.add_argument("--has-auth", action="store_true")
522
709
  args = parser.parse_args()
523
710
  try:
711
+ if args.check_runner:
712
+ report = release_runner_status(Path(args.check_runner).expanduser().resolve())
713
+ print(json.dumps(report, indent=2, ensure_ascii=False))
714
+ return 0
715
+ if args.verify_deployer:
716
+ if not args.deployer_host:
717
+ parser.error("--deployer-host is required with --verify-deployer")
718
+ delegation = deployer_delegation_preflight(args.deployer_host, args.deployer_user, args.release_root, args.service)
719
+ if args.output:
720
+ output = Path(args.output).resolve()
721
+ output.parent.mkdir(parents=True, exist_ok=True)
722
+ output.write_text(json.dumps(delegation, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
723
+ print(json.dumps(delegation, indent=2, ensure_ascii=False))
724
+ if not delegation["passed"]:
725
+ return 1
524
726
  if args.vps_plan:
525
727
  if not args.domain:
526
728
  parser.error("--domain is required with --vps-plan")
@@ -549,7 +751,13 @@ def main() -> int:
549
751
  result = verify_infrastructure(Path(args.units_dir).resolve() if args.units_dir else None, args.service, args.timer)
550
752
  print(json.dumps(result, indent=2, ensure_ascii=False))
551
753
  return 0 if result["passed"] else 1
552
- result = preflight(Path(args.project).resolve(), args.target, args.environment)
754
+ result = preflight(
755
+ Path(args.project).resolve(),
756
+ args.target,
757
+ args.environment,
758
+ args.require_deployer,
759
+ Path(args.deployer_evidence).expanduser().resolve() if args.deployer_evidence else None,
760
+ )
553
761
  if args.output:
554
762
  Path(args.output).parent.mkdir(parents=True, exist_ok=True)
555
763
  Path(args.output).write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")