@topy-ai/maggie 0.7.25 → 0.7.27

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
@@ -60,7 +60,12 @@ Maggie keeps the existing project foundation and asks for decisions before
60
60
  shared routes, analytics, or publishing boundaries change. The current
61
61
  package ships 19 installable skills and a local-first MaggieDash foundation.
62
62
 
63
- The current release is `0.7.25`. It adds a host-owned mobile app surface
63
+ The current release is `0.7.27`. It adds a provider-neutral MaggieDash host
64
+ adapter contract, idempotent PostgreSQL starter schema, type-only backend
65
+ boundary, runtime endpoint conformance evidence, safe quota/error summaries,
66
+ stale marketplace readiness revalidation, explicit VPS port/origin planning,
67
+ first-release rollback evidence, and clean non-interactive bootstrap errors.
68
+ It retains the host-owned mobile app surface
64
69
  contract (`maggie design app-init`/`app-validate`), direct Astro route
65
70
  resolution, correlated feedback batches, and runtime package-version capture.
66
71
 
@@ -103,6 +108,9 @@ maggie dash api-contract --spec .maggie/openapi.json
103
108
  maggie dash api-contract --spec https://example.test/openapi.json
104
109
  maggie dash schema-audit --inventory .maggie/schema-inventory.json --fail-on-unread
105
110
  maggie dash ui runtime-validate --evidence .maggie/dashboard-runtime.json
111
+ maggie dash conformance --contract .maggie/host-adapter-v2.json \
112
+ --evidence .maggie/maggiedash-conformance.json \
113
+ --output docs/maggiedash-conformance.json
106
114
  maggie docs audit --project . --docs-dir docs --output docs/documentation-audit.json
107
115
  ```
108
116
 
@@ -397,8 +405,8 @@ artifact schemas.
397
405
  Recommended upgrade sequence for the current release:
398
406
 
399
407
  ```bash
400
- npx @topy-ai/maggie@0.7.25 update --project . --force
401
- npx @topy-ai/maggie@0.7.25 cleanup --project .
408
+ npx @topy-ai/maggie@0.7.27 update --project . --force
409
+ npx @topy-ai/maggie@0.7.27 cleanup --project .
402
410
  ```
403
411
 
404
412
  Maintainers should pass npm credentials through the repository helper, never
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.7.25 init --agent all
11
+ npx @topy-ai/maggie@0.7.26 init --agent all
12
12
  npx @topy-ai/maggie doctor --project .
13
13
  ```
14
14
 
@@ -25,7 +25,7 @@ maggie doctor --project . --require-bootstrap --strict
25
25
  deployment、memory、feedback 和 MaggieDash。內容先 draft/review,外部寫入、
26
26
  publish 與 production deployment 需要明確確認。
27
27
 
28
- 目前 release 是 `0.7.25`,加入 host-owned mobile app surface contract、signed-in
28
+ 目前 release 是 `0.7.26`,修正 top-level feedback help 並包含 host-owned mobile app surface contract、signed-in
29
29
  camera-state QA、直接 Astro route resolution、correlated feedback batch,以及
30
30
  runtime package version capture。
31
31
 
package/bin/maggie.js CHANGED
@@ -126,7 +126,7 @@ Usage:
126
126
  maggie memory <init|list|search|context|add|record-error|transition|export> --project PATH
127
127
  maggie localization <extract|plan|generate|preview|validate|review|publish|stale|glossary> [options]
128
128
  maggie seo performance|images|sitemap|indexnow|social-cards|head-tags [options] (sitemap supports strict validate and agent-files)
129
- maggie feedback <collect|preview|submit|list> [options]
129
+ maggie feedback <collect|preview|batch|submit|list> [options]
130
130
  maggie qa <start|record|summary|export|assertion-audit> [options]
131
131
  maggie site-audit URL [--crawl] [--access-log FILE] [--require-sitemap-request] [--languages en-GB,es-MX,ja-JP] [--check-hreflang]
132
132
  maggie site-audit URL --crawl --save-baseline FILE --reviewer NAME
@@ -50,6 +50,11 @@ import.
50
50
  items with stable ordering, nesting, and safe destinations;
51
51
  - [`dashboard-runtime-v1.json`](dashboard-runtime-v1.json) defines evidence
52
52
  that an admin screen mounted, ran its checks, and completed its data calls.
53
+ - MaggieDash 0.2.6 also ships a screen-level host adapter contract and
54
+ provider-neutral install starters in the first-party distribution. The
55
+ `host-adapter-v2` contract lists endpoint methods, public routes, response
56
+ content types, and required fields; `maggie dash conformance` validates
57
+ sanitized runtime observations against it.
53
58
 
54
59
  These are adapter-facing interfaces, not database schemas. A host may use
55
60
  PostgreSQL, SQLite, or another first-party store. Activity recording happens
@@ -25,6 +25,13 @@ return partial/nonzero and store only an error category. Use one worker per
25
25
  project. Host database/API schedulers must explicitly integrate this boundary;
26
26
  installing the skill does not patch an existing host ingestion implementation.
27
27
 
28
+ When a host imports JavaScript helpers from strict TypeScript, ship an adjacent
29
+ `.d.mts` declaration for every `.mjs` module that is part of the public
30
+ translation/ingest boundary. Keep the declaration's nullable/error outcome
31
+ types aligned with the runtime and include it in the typecheck fixture. This
32
+ prevents a successful runtime helper from becoming an implicit `any` or a
33
+ nullability mismatch in the scheduled path.
34
+
28
35
  ## Durable work
29
36
 
30
37
  After source persistence, enqueue translation work using an idempotency key
@@ -5,6 +5,14 @@ approvals, jobs, audit, API, and MCP boundaries. External systems are adapters;
5
5
  they never become the local source of truth without an explicit migration and
6
6
  rollback decision.
7
7
 
8
+ The first-party distribution installs the React admin under `./_maggie/admin`
9
+ and portable host starters under `maggiedash/`: an idempotent PostgreSQL
10
+ schema, type-only adapter boundary, and the screen-level
11
+ `maggiedash-host-adapter.v2` contract. The schema is a starting migration for
12
+ the host's normal migration system; it must not drop legacy tables or receive
13
+ unbound project IDs. The type file is intentionally not a fake database
14
+ implementation.
15
+
8
16
  ## Foundation
9
17
 
10
18
  Initialize and migrate the local project contract before content or provider
@@ -35,6 +43,17 @@ in environment variables and are never copied into reports.
35
43
  explicitly declares that mirror required.
36
44
  - API Pull, booking, analytics, email, and deployment providers expose health,
37
45
  normalized errors, and explicit configuration schemas.
46
+ - Dashboard hosts must expose the exact method, route, response content type,
47
+ and required fields declared by `host-adapter-v2`. Create sanitized runtime
48
+ observations and validate them with `maggie dash conformance`; a static
49
+ component scan is not evidence that a screen works. Astro hosts that exclude
50
+ underscore filesystem segments must rewrite `/_maggie/section-preview` once
51
+ to the internal preview route without disabling origin checks.
52
+
53
+ For API Pull, retain only bounded provider messages and effective quota values
54
+ in lifecycle state. A quota-exhausted response means daily delivery remaining
55
+ is zero even if an older allowance field says otherwise; raw provider bodies
56
+ never enter `.maggie` reports.
38
57
 
39
58
  ## Ordered workflow
40
59
 
@@ -22,6 +22,11 @@ project-specific design, service, SEO, operations, and deployment adapters.
22
22
  3. For a new MaggieDash project, run:
23
23
  `python3 tools/clis/maggie_dash.py init --project PROJECT --confirm` and
24
24
  `python3 tools/clis/maggie_dash.py migrate --project PROJECT --confirm`.
25
+ If the first-party dashboard is installed, also run
26
+ `maggie dash install --project PROJECT --confirm`; this copies the
27
+ provider-neutral backend type boundary and idempotent starter schema beside
28
+ the admin UI. Apply the schema through the host migration system, never by
29
+ dropping legacy tables.
25
30
  4. Advance the ordered foundation gate:
26
31
 
27
32
  ```bash
@@ -46,6 +51,19 @@ project-specific design, service, SEO, operations, and deployment adapters.
46
51
  9. Run build, route, SEO, accessibility, content, and deployment checks; hand
47
52
  private content operations to `maggie-ops`.
48
53
 
54
+ For an installed dashboard, create sanitized runtime observations for every
55
+ endpoint in `maggiedash/contracts/host-adapter-v2.json` and run:
56
+
57
+ ```bash
58
+ maggie dash conformance --project PROJECT \
59
+ --contract maggiedash/contracts/host-adapter-v2.json \
60
+ --evidence .maggie/maggiedash-conformance.json \
61
+ --output docs/maggiedash-conformance.json
62
+ ```
63
+
64
+ The report checks method, public route, 2xx status, content type, and required
65
+ response fields. It does not accept a static source scan as runtime evidence.
66
+
49
67
  ## Decisions and safety
50
68
 
51
69
  - Preserve detected framework, language, router, UI system, icon set, fonts,
@@ -58,6 +76,9 @@ project-specific design, service, SEO, operations, and deployment adapters.
58
76
  stop for manual conflict review; malformed content is never published.
59
77
  - Do not build a second CMS or editor beside MaggieDash. Use the shared
60
78
  ContentDocument contract and project-scoped roles.
79
+ - If stdin is not interactive, bootstrap must receive
80
+ `--non-interactive --answers-file FILE --confirm`; it exits with a concise
81
+ remediation message instead of emitting an EOF traceback.
61
82
 
62
83
  ## Completion gate
63
84
 
@@ -46,6 +46,28 @@ 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
+ The install also places a provider-neutral backend type boundary, an
50
+ idempotent PostgreSQL starter schema, and the screen-level host adapter
51
+ contract under `maggiedash/`. These are reviewable starting points, not a
52
+ portable database implementation. Apply the schema through the host's normal
53
+ migration system, keep project IDs bound as query parameters, and implement
54
+ the declared endpoint fields before enabling a dashboard screen. The
55
+ underscore-prefixed public preview route must be rewritten once by the host
56
+ framework when that framework excludes underscore filesystem segments.
57
+
58
+ Validate the live adapter with sanitized browser/runtime observations:
59
+
60
+ ```bash
61
+ maggie dash conformance --project . \
62
+ --contract maggiedash/contracts/host-adapter-v2.json \
63
+ --evidence .maggie/maggiedash-conformance.json \
64
+ --output docs/maggiedash-conformance.json
65
+ ```
66
+
67
+ This is a runtime contract gate, not a source-only check; it fails when a
68
+ declared endpoint is missing, uses the wrong route/method, returns a non-2xx
69
+ status or omits required fields.
70
+
49
71
  MaggieDash 0.2.4 adds two reusable operator workspaces. Activity is a read-only
50
72
  view backed by `/api/maggie/activity.json`; it supports actor/action filters,
51
73
  refresh, and a safe unavailable state. Navigation is an ordered,
@@ -123,10 +123,18 @@ Generate a reviewable VPS plan and configuration artifacts locally:
123
123
  python3 tools/clis/maggie_deployment.py /path/to/project \
124
124
  --vps-plan --domain example.co.uk --service example \
125
125
  --release-root /var/www/example \
126
+ --node-port 4321 \
126
127
  --plan-dir .maggie/deployment/vps-staging \
127
128
  --runner-output .maggie/deployment/vps-staging/release-runner.sh
128
129
  ```
129
130
 
131
+ `--node-port` is intentionally explicit and must be an unused host port; the
132
+ planner never guesses a default because a guessed port can collide with an
133
+ existing service. The generated plan also records the TLS-proxy boundary:
134
+ keep Astro `security.checkOrigin` enabled, configure the canonical HTTPS
135
+ `allowedDomains`, and forward the public host/protocol only from a trusted
136
+ reverse proxy.
137
+
130
138
  The output contains only the immutable release/current layout, systemd unit,
131
139
  Nginx reverse-proxy config, health commands, rollback command, and an ordered
132
140
  reviewable release runner. The runner keeps migrations before the symlink flip,
@@ -237,9 +245,12 @@ Review the generated runner before execution. Its order is install → typecheck
237
245
  retention keeps the current release and one rollback candidate.
238
246
 
239
247
  Production additionally requires `docs/deployment-rollback-smoke.json` with
240
- `passed: true`, `environment: production`, a non-empty `previousRelease`, and
241
- `testedAt`. A rollback command in a plan is not proof that the rollback was
242
- tested. Staging does not require this production-only evidence.
248
+ `passed: true`, `environment: production`, and `testedAt`. For an upgrade it
249
+ must include a non-empty `previousRelease`. For the first production release,
250
+ set `releaseKind: "first"` and record a non-empty `notApplicableReason`; the
251
+ runner also requires `RELEASE_KIND=first`. A rollback command in a plan is not
252
+ proof that the rollback was tested. Staging does not require this
253
+ production-only evidence.
243
254
 
244
255
  ## Required workflow
245
256
 
@@ -76,9 +76,16 @@ Create a local, secret-free plan before remote execution:
76
76
  python3 tools/clis/maggie_deployment.py . --vps-plan \
77
77
  --domain example.co.uk --service example \
78
78
  --release-root /var/www/example \
79
+ --node-port 4321 \
79
80
  --plan-dir .maggie/deployment/vps-staging
80
81
  ```
81
82
 
83
+ Always choose and pass an unused `--node-port`; no default is assumed because
84
+ the planner cannot safely know which ports are occupied on the target host.
85
+ The generated Nginx/Astro boundary keeps `checkOrigin` enabled and requires
86
+ canonical HTTPS `allowedDomains` plus trusted `X-Forwarded-Host`/
87
+ `X-Forwarded-Proto` handling when TLS terminates at the proxy.
88
+
82
89
  Review the generated systemd and Nginx files, then obtain explicit approval
83
90
  before installing them on a host.
84
91
 
@@ -106,5 +113,8 @@ canonical URLs, booking links, and protected Ops/API routes. Check both apex
106
113
  and `www` when configured. Record HTTP status, commit, and release.
107
114
 
108
115
  Rollback switches `current` to the previous immutable release and restarts the
109
- service. If migrations are not backward-compatible, stop before deployment
110
- and provide a tested restore or database rollback plan.
116
+ service. If migrations are not backward-compatible, stop before deployment and
117
+ provide a tested restore or database rollback plan. The first production
118
+ release may declare rollback as not applicable only with `releaseKind: "first"`,
119
+ a non-empty `notApplicableReason`, and `RELEASE_KIND=first`; upgrades still
120
+ need tested `previousRelease` evidence.
@@ -74,7 +74,10 @@ python3 tools/clis/maggie_marketplace.py resume <template-id>
74
74
 
75
75
  If generated assets are missing, the job stops at `WAITING_FOR_ASSETS`; cloned
76
76
  images are never accepted as generated-asset fallbacks. Only a job at `READY`
77
- is complete.
77
+ is complete. A previously `READY` job is revalidated against the current
78
+ validator before it is reported complete. If the validator has changed or the
79
+ artifact drifted, the job becomes `STALE` and must be resumed or rebuilt; do
80
+ not treat an old readiness flag as proof of current validity.
78
81
 
79
82
  Each import also creates `tasks.json`. It is the workflow ledger: it stores
80
83
  source/output inventories for logos, icons, images, headings, links, buttons,
@@ -426,8 +426,29 @@ def command_api_lifecycle(args: argparse.Namespace) -> int:
426
426
  "total_posts", "matchedPosts", "matched_posts", "eligible",
427
427
  "unmatched", "queued", "accepted", "created", "updated",
428
428
  "skipped", "removed", "nextCursor", "next_cursor",
429
+ "message", "error", "quota_exhausted", "quotaExhausted",
430
+ "quotaDailyRemaining", "quota_daily_remaining",
431
+ "quotaMonthlyRemaining", "quota_monthly_remaining",
429
432
  )
430
- summary = {key: value[key] for key in allowed if key in value and isinstance(value[key], (str, int, float, bool, type(None)))}
433
+ summary = {}
434
+ for key in allowed:
435
+ if key not in value or not isinstance(value[key], (str, int, float, bool, type(None))):
436
+ continue
437
+ item = value[key]
438
+ if key in {"message", "error"} and isinstance(item, str):
439
+ item = " ".join(item.split())[:300]
440
+ summary[key] = item
441
+ quota = value.get("quota")
442
+ if isinstance(quota, dict):
443
+ quota_summary = {}
444
+ for window_name, window in quota.items():
445
+ if not isinstance(window, dict) or not isinstance(window.get("remaining"), int):
446
+ continue
447
+ quota_summary[str(window_name)] = {"remaining": window["remaining"]}
448
+ if quota_summary:
449
+ summary["quota"] = quota_summary
450
+ if value.get("quota_exhausted") is True or value.get("quotaExhausted") is True:
451
+ summary["effective_delivery_remaining"] = 0
431
452
  items = value.get("items")
432
453
  if isinstance(items, list):
433
454
  summary["item_count"] = len(items)
@@ -639,6 +660,13 @@ def interview_value(key: str, question: str, current: str, choices: list[str]) -
639
660
  def command_bootstrap_interview(args: argparse.Namespace) -> int:
640
661
  root = Path(args.project).resolve()
641
662
  analysis = detect(root)
663
+ if not args.non_interactive and not sys.stdin.isatty():
664
+ print(
665
+ "NON_INTERACTIVE_REQUIRED: stdin is not interactive; use --non-interactive "
666
+ "--answers-file answers.json --confirm after reviewing the proposal.",
667
+ file=sys.stderr,
668
+ )
669
+ return 2
642
670
  answers: dict[str, str] = {}
643
671
  if args.answers_file:
644
672
  try:
@@ -102,8 +102,38 @@ def manifest_file_pairs(source: Path, manifest: dict[str, object], target: Path)
102
102
  return pairs
103
103
 
104
104
 
105
+ def manifest_root_file_pairs(source: Path, manifest: dict[str, object], root: Path) -> list[tuple[Path, Path, Path]]:
106
+ """Return optional host-root files without changing the admin target contract."""
107
+ pairs: list[tuple[Path, Path, Path]] = []
108
+ for item in manifest.get("rootFiles", []):
109
+ if isinstance(item, str):
110
+ source_relative = Path(item)
111
+ target_relative = source_relative
112
+ elif isinstance(item, dict):
113
+ source_relative = Path(str(item.get("source", "")))
114
+ target_relative = Path(str(item.get("target", "")))
115
+ else:
116
+ raise RuntimeError("MaggieDash rootFiles entries must be strings or objects")
117
+ if not source_relative.parts or not target_relative.parts:
118
+ raise RuntimeError("MaggieDash rootFiles entries require source and target")
119
+ if source_relative.is_absolute() or target_relative.is_absolute() or ".." in source_relative.parts or ".." in target_relative.parts:
120
+ raise RuntimeError("unsafe MaggieDash rootFiles path")
121
+ source_item = source / source_relative
122
+ if source_item.is_file():
123
+ pairs.append((source_item, root / target_relative, source_relative))
124
+ continue
125
+ if not source_item.is_dir():
126
+ raise RuntimeError(f"MaggieDash rootFiles source is missing: {source_relative}")
127
+ for path in sorted(source_item.rglob("*")):
128
+ if path.is_file():
129
+ child = path.relative_to(source_item)
130
+ pairs.append((path, root / target_relative / child, source_relative / child))
131
+ return pairs
132
+
133
+
105
134
  def command_install_diff(source: Path, manifest: dict[str, object], target: Path, compare_dir: Path | None, force: bool) -> dict[str, object]:
106
135
  pairs = manifest_file_pairs(source, manifest, target)
136
+ root_pairs = manifest_root_file_pairs(source, manifest, target.parents[1])
107
137
  files: list[dict[str, str]] = []
108
138
  source_relative = {relative for _, _, relative in pairs}
109
139
  for source_file, destination, relative in pairs:
@@ -119,6 +149,15 @@ def command_install_diff(source: Path, manifest: dict[str, object], target: Path
119
149
  else:
120
150
  action = "update" if force else "preserve"
121
151
  files.append({"path": str(relative), "action": action, "comparePath": str(compare_file)})
152
+ for source_file, destination, relative in root_pairs:
153
+ compare_file = destination
154
+ if not compare_file.exists():
155
+ action = "add"
156
+ elif compare_file.read_bytes() == source_file.read_bytes():
157
+ action = "unchanged"
158
+ else:
159
+ action = "update" if force else "preserve"
160
+ files.append({"path": str(relative), "action": action, "comparePath": str(compare_file)})
122
161
  if compare_dir and compare_dir.exists():
123
162
  for existing in sorted(compare_dir.rglob("*")):
124
163
  if not existing.is_file():
@@ -204,6 +243,13 @@ def command_install(args: argparse.Namespace) -> int:
204
243
  current_installed = [str(destination)]
205
244
  installed.extend(current_installed)
206
245
  preserved.extend(current_preserved)
246
+ for source_file, destination, _ in manifest_root_file_pairs(source, manifest, root):
247
+ if destination.exists() and not args.force:
248
+ preserved.append(str(destination))
249
+ else:
250
+ destination.parent.mkdir(parents=True, exist_ok=True)
251
+ shutil.copy2(source_file, destination)
252
+ installed.append(str(destination))
207
253
  state = {
208
254
  "schemaVersion": "maggiedash-install.v1",
209
255
  "status": "installed",
@@ -442,6 +488,62 @@ def command_api_contract(args: argparse.Namespace) -> int:
442
488
  return 0 if result["passed"] else 1
443
489
 
444
490
 
491
+ def command_conformance(args: argparse.Namespace) -> int:
492
+ """Validate sanitized runtime evidence for every declared host endpoint."""
493
+ project = Path(getattr(args, "project", ".")).resolve()
494
+ def input_path(value: str) -> Path:
495
+ path = Path(value)
496
+ return path if path.is_absolute() else project / path
497
+ contract = json.loads(input_path(args.contract).read_text(encoding="utf-8"))
498
+ evidence = json.loads(input_path(args.evidence).read_text(encoding="utf-8"))
499
+ endpoints = contract.get("x-endpoints") or contract.get("endpoints") or contract.get("properties", {}).get("endpoints", {}).get("x-endpoints", [])
500
+ observations = evidence.get("observations") if isinstance(evidence, dict) else None
501
+ errors: list[str] = []
502
+ if not isinstance(endpoints, list) or not endpoints:
503
+ errors.append("contract must declare at least one endpoint")
504
+ endpoints = []
505
+ if not isinstance(observations, list):
506
+ errors.append("evidence.observations must be an array")
507
+ observations = []
508
+ by_id = {str(item.get("id")): item for item in observations if isinstance(item, dict) and item.get("id")}
509
+ checked = []
510
+ for endpoint in endpoints:
511
+ endpoint_id = str(endpoint.get("id", "")) if isinstance(endpoint, dict) else ""
512
+ item_errors: list[str] = []
513
+ observation = by_id.get(endpoint_id)
514
+ if not observation:
515
+ item_errors.append("missing runtime observation")
516
+ else:
517
+ if observation.get("method") != endpoint.get("method"):
518
+ item_errors.append("observed method does not match contract")
519
+ if observation.get("route") != endpoint.get("route"):
520
+ item_errors.append("observed route does not match contract")
521
+ status = observation.get("httpStatus")
522
+ if not isinstance(status, int) or isinstance(status, bool) or not 200 <= status < 300:
523
+ item_errors.append("endpoint did not return a 2xx status")
524
+ expected_type = endpoint.get("response", {}).get("contentType")
525
+ if observation.get("contentType") != expected_type:
526
+ item_errors.append("observed content type does not match contract")
527
+ fields = observation.get("responseFields")
528
+ required = endpoint.get("response", {}).get("requiredFields", [])
529
+ if not isinstance(fields, list) or not set(required).issubset(set(fields)):
530
+ item_errors.append("response is missing one or more declared fields")
531
+ if observation.get("passed") is not True:
532
+ item_errors.append("runtime observation is not passed")
533
+ checked.append({"id": endpoint_id, "passed": not item_errors, "errors": item_errors})
534
+ duplicate_ids = len(by_id) != len(observations)
535
+ if duplicate_ids:
536
+ errors.append("evidence contains duplicate or invalid observation ids")
537
+ report = {"schemaVersion": "maggiedash-host-conformance.v1", "checked": len(checked), "endpoints": checked, "errors": errors, "passed": not errors and bool(checked) and all(item["passed"] for item in checked)}
538
+ if args.output:
539
+ output = input_path(args.output)
540
+ output.parent.mkdir(parents=True, exist_ok=True)
541
+ output.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
542
+ report["output"] = str(output)
543
+ emit(report)
544
+ return 0 if report["passed"] else 1
545
+
546
+
445
547
  def command_schema_audit(args: argparse.Namespace) -> int:
446
548
  value = json.loads(Path(args.inventory).resolve().read_text(encoding="utf-8"))
447
549
  result = audit_schema_inventory(value, fail_on_unread=args.fail_on_unread)
@@ -603,6 +705,12 @@ def parser() -> argparse.ArgumentParser:
603
705
  api_contract = sub.add_parser("api-contract", help="validate a local or live declared request and success-response contract")
604
706
  api_contract.add_argument("--spec", required=True, help="local JSON file or HTTPS URL (loopback HTTP is allowed for development)")
605
707
  api_contract.set_defaults(func=command_api_contract)
708
+ conformance = sub.add_parser("conformance", help="validate one sanitized runtime observation for every declared MaggieDash endpoint")
709
+ conformance.add_argument("--project", default=".", help="host project root for relative contract, evidence, and output paths")
710
+ conformance.add_argument("--contract", required=True, help="MaggieDash host adapter contract JSON")
711
+ conformance.add_argument("--evidence", required=True, help="sanitized endpoint observations JSON")
712
+ conformance.add_argument("--output")
713
+ conformance.set_defaults(func=command_conformance)
606
714
  schema_audit = sub.add_parser("schema-audit", help="find declared tables with missing reader evidence")
607
715
  schema_audit.add_argument("--inventory", required=True, help="host-produced schema inventory JSON")
608
716
  schema_audit.add_argument("--fail-on-unread", action="store_true")
@@ -55,8 +55,7 @@ def preflight(project: Path, target: str, environment: str) -> dict:
55
55
  rollback_passed = False
56
56
  if rollback_evidence.exists():
57
57
  try:
58
- evidence = json.loads(rollback_evidence.read_text(encoding="utf-8"))
59
- rollback_passed = bool(evidence.get("passed") is True and evidence.get("environment") == "production" and evidence.get("previousRelease") and evidence.get("testedAt"))
58
+ rollback_passed = validate_rollback_evidence(json.loads(rollback_evidence.read_text(encoding="utf-8"))) ["passed"]
60
59
  except (OSError, json.JSONDecodeError):
61
60
  rollback_passed = False
62
61
  migration_manifest = project / ".maggie" / "migration-manifest.json"
@@ -79,12 +78,14 @@ def preflight(project: Path, target: str, environment: str) -> dict:
79
78
  return result
80
79
 
81
80
 
82
- def vps_plan(domain: str, service: str, release_root: str, node_port: int) -> dict:
81
+ def vps_plan(domain: str, service: str, release_root: str, node_port: int | None) -> dict:
83
82
  """Create reviewable, secret-free VPS service and reverse-proxy artifacts."""
84
83
  if not re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?", domain):
85
84
  raise ValueError("domain must be a hostname without a scheme or path")
86
85
  if not re.fullmatch(r"[A-Za-z0-9_.@-]+", service):
87
86
  raise ValueError("service must contain only safe systemd name characters")
87
+ if node_port is None:
88
+ raise ValueError("node port must be supplied explicitly; choose an unused port for this host")
88
89
  if not 1024 <= node_port <= 65535:
89
90
  raise ValueError("node port must be between 1024 and 65535")
90
91
  root = release_root.rstrip("/")
@@ -97,6 +98,12 @@ def vps_plan(domain: str, service: str, release_root: str, node_port: int) -> di
97
98
  "release_root": root,
98
99
  "current_release": current,
99
100
  "node_port": node_port,
101
+ "proxy": {
102
+ "trust_forwarded_proto": True,
103
+ "forwarded_headers": ["X-Forwarded-Host", "X-Forwarded-Proto"],
104
+ "origin_check": "Astro security.checkOrigin remains enabled",
105
+ "allowed_domains_required": True,
106
+ },
100
107
  "release_layout": f"{root}/releases/<immutable-release> plus current symlink",
101
108
  "retention": {"keep": 2, "preserve": [current, f"{root}/releases/<previous-release>"], "prune": f"find {root}/releases -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\\n' | sort -nr | tail -n +3 | cut -d' ' -f2- | xargs -r rm -rf --"},
102
109
  "staging_first": True,
@@ -141,6 +148,32 @@ def validate_data_checkpoint(path: Path) -> dict:
141
148
  return {"passed": not errors, "declared": True, "errors": errors}
142
149
 
143
150
 
151
+ def validate_rollback_evidence(value: object) -> dict:
152
+ """Validate release rollback evidence, including the explicitly safe first release."""
153
+ if not isinstance(value, dict):
154
+ return {"passed": False, "errors": ["rollback evidence must be an object"]}
155
+ errors = []
156
+ if value.get("schemaVersion") not in {None, "maggie-deployment-rollback.v1"}:
157
+ errors.append("rollback evidence schemaVersion is unsupported")
158
+ if value.get("passed") is not True:
159
+ errors.append("rollback evidence is not passed")
160
+ if value.get("environment") != "production":
161
+ errors.append("rollback evidence must target production")
162
+ if not value.get("testedAt"):
163
+ errors.append("rollback evidence testedAt is required")
164
+ release_kind = value.get("releaseKind", "upgrade")
165
+ if release_kind not in {"first", "upgrade"}:
166
+ errors.append("rollback evidence releaseKind must be first or upgrade")
167
+ if release_kind == "first":
168
+ if value.get("previousRelease"):
169
+ errors.append("first release evidence must not claim a previous release")
170
+ if not value.get("notApplicableReason"):
171
+ errors.append("first release evidence needs notApplicableReason")
172
+ elif not value.get("previousRelease"):
173
+ errors.append("upgrade rollback evidence requires previousRelease")
174
+ return {"passed": not errors, "errors": errors}
175
+
176
+
144
177
  def validate_scheduler_request(method: str, origin: str | None, has_auth: bool) -> dict:
145
178
  errors = []
146
179
  if method.upper() == "POST":
@@ -210,6 +243,7 @@ WantedBy=multi-user.target
210
243
  proxy_set_header Host $host;
211
244
  proxy_set_header X-Real-IP $remote_addr;
212
245
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
246
+ proxy_set_header X-Forwarded-Host $host;
213
247
  proxy_set_header X-Forwarded-Proto $scheme;
214
248
  }}
215
249
  }}
@@ -230,10 +264,15 @@ set -euo pipefail
230
264
 
231
265
  # Migration precedes the symlink flip; later releases carry agent state.
232
266
  : "${{RELEASE_ID:?set RELEASE_ID to an immutable release name}}"
267
+ RELEASE_KIND="${{RELEASE_KIND:-upgrade}}"
233
268
  RELEASE_ROOT="{root}"
234
269
  CURRENT="{current}"
235
270
  RELEASE="$RELEASE_ROOT/releases/$RELEASE_ID"
236
271
  PREVIOUS="$(readlink -f "$CURRENT" 2>/dev/null || true)"
272
+ if [ "$RELEASE_KIND" != "first" ] && [ -z "$PREVIOUS" ]; then
273
+ echo "No previous release found; set RELEASE_KIND=first for the initial production release." >&2
274
+ exit 2
275
+ fi
237
276
  mkdir -p "$RELEASE"
238
277
  cd "$RELEASE"
239
278
 
@@ -277,7 +316,7 @@ def main() -> int:
277
316
  parser.add_argument("--domain", help="canonical hostname for --vps-plan")
278
317
  parser.add_argument("--service", default="maggie-site", help="systemd service name")
279
318
  parser.add_argument("--release-root", default="/var/www/maggie-site", help="immutable release root")
280
- parser.add_argument("--node-port", type=int, default=4321)
319
+ parser.add_argument("--node-port", type=int, required=False, default=None, help="unused host port; required with --vps-plan")
281
320
  parser.add_argument("--plan-dir", help="directory for generated VPS artifacts")
282
321
  parser.add_argument("--runner-output", help="write a reviewable ordered VPS release runner")
283
322
  parser.add_argument("--retention-plan", action="store_true", help="create a read-only release prune candidate plan")
@@ -25,7 +25,7 @@ DEFAULT_SAMPLE_ID = "medical-spa-landing-001"
25
25
  PUBLISHED_STATUSES = {"published", "ready"}
26
26
  PHOSPHOR_BRAND_ICONS = {"ph-instagram-logo", "ph-facebook-logo", "ph-x-logo", "ph-linkedin-logo", "ph-youtube-logo", "ph-tiktok-logo"}
27
27
  PHOSPHOR_CDN = "https://cdn.jsdelivr.net/npm/@phosphor-icons/web@2.1.1/src/regular/style.css"
28
- JOB_PHASES = ("CREATED", "IMPORTED", "WAITING_FOR_ASSETS", "COMPLETED", "VALIDATED", "READY", "FAILED")
28
+ JOB_PHASES = ("CREATED", "IMPORTED", "WAITING_FOR_ASSETS", "COMPLETED", "VALIDATED", "READY", "STALE", "FAILED")
29
29
 
30
30
 
31
31
  class MetadataParser(HTMLParser):
@@ -1738,8 +1738,16 @@ def run_marketplace_job(args: argparse.Namespace) -> int:
1738
1738
  if existing_path.exists() and not args.force:
1739
1739
  existing = load_job(root, args.id)
1740
1740
  if existing.get("phase") == "READY":
1741
- print(f"marketplace job already ready: {args.id}")
1742
- return 0
1741
+ destination = template_path(root, args.id)
1742
+ # READY is a claim, not a permanent exemption from newer
1743
+ # validators. Re-check it on every reuse and make a stale package
1744
+ # explicitly resumable when the contract has moved on.
1745
+ if validate_template(destination, args.sample.resolve() if args.sample else None) == 0:
1746
+ print(f"marketplace job already ready: {args.id}")
1747
+ return 0
1748
+ set_job_phase(root, existing, "STALE", staleReason="current validator rejected the recorded READY package")
1749
+ print(f"STALE: marketplace job {args.id} must be resumed or rebuilt")
1750
+ return 1
1743
1751
  args = args_from_job(existing)
1744
1752
  job = load_job(root, args.id) if existing_path.exists() else {
1745
1753
  "id": args.id,
@@ -1823,7 +1831,7 @@ def resume_marketplace_job(args: argparse.Namespace) -> int:
1823
1831
  print(f"marketplace job already ready: {args.job_id}")
1824
1832
  return 0
1825
1833
  resumed = args_from_job(job)
1826
- if job.get("phase") == "FAILED":
1834
+ if job.get("phase") in {"FAILED", "STALE"}:
1827
1835
  destination = template_path(root, args.job_id)
1828
1836
  # A post-import failure must resume from the completion checkpoint;
1829
1837
  # importing again would erase generated assets and task evidence.
@@ -23,8 +23,11 @@ feedback payloads, or public repositories.
23
23
 
24
24
  `/history` rejects invalid pagination with `422`; do not request more than 50
25
25
  records per page. Empty `/posts` or `/updates` responses are normal and mean
26
- there is currently no queued item. The public OpenAPI contract does not include
27
- `/whoami`; resolve configuration from the API key and project context instead.
26
+ there is currently no queued item. Quota responses must expose the effective
27
+ delivery allowance: when `quota_exhausted` is true, report the relevant daily
28
+ remaining value as zero and preserve a bounded provider `message`/error
29
+ summary. The public OpenAPI contract does not include `/whoami`; resolve
30
+ configuration from the API key and project context instead.
28
31
 
29
32
  Returned content may expose provider-specific fields such as `content`,
30
33
  `content_markdown`, `content_html`, `content_portable_text`, `body`, or `html`.
@@ -56,8 +59,9 @@ python3 tools/clis/maggie.py api lifecycle --project . \
56
59
  The first command is always dry-run. The executing command persists key-scoped
57
60
  context, remote IDs, versions, slugs, canonicals, and hashes under
58
61
  `.maggie/ai-cmo-lifecycle.json`. Raw provider responses are not copied into
59
- the checkpoint. Pulled content is projected as draft-only state; no lifecycle
60
- step publishes local content.
62
+ the checkpoint; only bounded messages, effective quota fields, and safe scalar
63
+ outcomes are retained. Pulled content is projected as draft-only state; no
64
+ lifecycle step publishes local content.
61
65
 
62
66
  To prepare and explicitly import the projection into MaggieDash drafts:
63
67
 
@@ -108,6 +108,26 @@ class MaggieDashStore:
108
108
 
109
109
  def init_project(self, project_id: str, name: str, adapter: str = "maggiedash-sqlite", locale: str = "en-GB") -> dict[str, Any]:
110
110
  timestamp = now()
111
+ # The default CLI identity is a provisional row. Once the operator
112
+ # supplies a real project id, converge that row instead of leaving an
113
+ # orphan project behind in the local store.
114
+ if project_id != "local-project":
115
+ has_target = self.connection.execute("SELECT 1 FROM maggiedash_projects WHERE id=?", (project_id,)).fetchone()
116
+ has_default = self.connection.execute("SELECT 1 FROM maggiedash_projects WHERE id=?", ("local-project",)).fetchone()
117
+ if not has_target and has_default:
118
+ # Create the destination first so SQLite's foreign-key checks
119
+ # remain enabled while project-scoped rows are moved.
120
+ self.connection.execute(
121
+ "INSERT INTO maggiedash_projects (id,name,adapter,locale,status,created_at,updated_at) "
122
+ "SELECT ?,name,adapter,locale,status,created_at,updated_at FROM maggiedash_projects WHERE id=?",
123
+ (project_id, "local-project"),
124
+ )
125
+ existing_tables = {row[0] for row in self.connection.execute("SELECT name FROM sqlite_master WHERE type='table'")}
126
+ for table in ("maggiedash_memberships", "maggiedash_documents", "maggiedash_redirects", "maggiedash_audit_events", "maggiedash_section_presets"):
127
+ if table not in existing_tables:
128
+ continue
129
+ self.connection.execute(f"UPDATE {table} SET project_id=? WHERE project_id=?", (project_id, "local-project"))
130
+ self.connection.execute("DELETE FROM maggiedash_projects WHERE id=?", ("local-project",))
111
131
  self.connection.execute(
112
132
  "INSERT INTO maggiedash_projects (id,name,adapter,locale,created_at,updated_at) VALUES (?,?,?,?,?,?) "
113
133
  "ON CONFLICT(id) DO UPDATE SET name=excluded.name, adapter=excluded.adapter, locale=excluded.locale, updated_at=excluded.updated_at",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topy-ai/maggie",
3
- "version": "0.7.25",
3
+ "version": "0.7.27",
4
4
  "description": "Install and manage Maggie Skills for AI coding agents",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -25,6 +25,13 @@ return partial/nonzero and store only an error category. Use one worker per
25
25
  project. Host database/API schedulers must explicitly integrate this boundary;
26
26
  installing the skill does not patch an existing host ingestion implementation.
27
27
 
28
+ When a host imports JavaScript helpers from strict TypeScript, ship an adjacent
29
+ `.d.mts` declaration for every `.mjs` module that is part of the public
30
+ translation/ingest boundary. Keep the declaration's nullable/error outcome
31
+ types aligned with the runtime and include it in the typecheck fixture. This
32
+ prevents a successful runtime helper from becoming an implicit `any` or a
33
+ nullability mismatch in the scheduled path.
34
+
28
35
  ## Durable work
29
36
 
30
37
  After source persistence, enqueue translation work using an idempotency key
@@ -5,6 +5,14 @@ approvals, jobs, audit, API, and MCP boundaries. External systems are adapters;
5
5
  they never become the local source of truth without an explicit migration and
6
6
  rollback decision.
7
7
 
8
+ The first-party distribution installs the React admin under `./_maggie/admin`
9
+ and portable host starters under `maggiedash/`: an idempotent PostgreSQL
10
+ schema, type-only adapter boundary, and the screen-level
11
+ `maggiedash-host-adapter.v2` contract. The schema is a starting migration for
12
+ the host's normal migration system; it must not drop legacy tables or receive
13
+ unbound project IDs. The type file is intentionally not a fake database
14
+ implementation.
15
+
8
16
  ## Foundation
9
17
 
10
18
  Initialize and migrate the local project contract before content or provider
@@ -35,6 +43,17 @@ in environment variables and are never copied into reports.
35
43
  explicitly declares that mirror required.
36
44
  - API Pull, booking, analytics, email, and deployment providers expose health,
37
45
  normalized errors, and explicit configuration schemas.
46
+ - Dashboard hosts must expose the exact method, route, response content type,
47
+ and required fields declared by `host-adapter-v2`. Create sanitized runtime
48
+ observations and validate them with `maggie dash conformance`; a static
49
+ component scan is not evidence that a screen works. Astro hosts that exclude
50
+ underscore filesystem segments must rewrite `/_maggie/section-preview` once
51
+ to the internal preview route without disabling origin checks.
52
+
53
+ For API Pull, retain only bounded provider messages and effective quota values
54
+ in lifecycle state. A quota-exhausted response means daily delivery remaining
55
+ is zero even if an older allowance field says otherwise; raw provider bodies
56
+ never enter `.maggie` reports.
38
57
 
39
58
  ## Ordered workflow
40
59