@topy-ai/maggie 0.7.10 → 0.7.11

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
@@ -218,8 +218,8 @@ artifact schemas.
218
218
  Recommended upgrade sequence for the current release:
219
219
 
220
220
  ```bash
221
- npx @topy-ai/maggie@0.7.10 update --project . --force
222
- npx @topy-ai/maggie@0.7.10 cleanup --project .
221
+ npx @topy-ai/maggie@0.7.11 update --project . --force
222
+ npx @topy-ai/maggie@0.7.11 cleanup --project .
223
223
  ```
224
224
 
225
225
  Maintainers should pass npm credentials through the repository helper, never
@@ -229,7 +229,7 @@ as a command-line argument:
229
229
  node scripts/publish-npm.mjs --maggie-env-file ../.env
230
230
  ```
231
231
 
232
- The 0.7.10 workflow adds served-content equivalence checks, query-route
232
+ The 0.7.11 workflow adds served-content equivalence checks, query-route
233
233
  baseline exclusions, changed-surface render evidence gates, generated skill
234
234
  catalogs, component-binding audits, and icon-family noise filtering. It also
235
235
  includes the 0.7.9 nested section-field contracts, renderer-backed examples,
@@ -251,6 +251,11 @@ observations; every route must include status, canonical, indexability, and
251
251
  required metadata checks. Agent content writes require an explicit approved
252
252
  origin, same-origin redirects, and recursive credential validation. Feedback
253
253
  submissions persist and print only an allowlisted acknowledgement. The release
254
+ also adds image-aware content equivalence with explicit legacy-baseline
255
+ limitations, locale checks for stored image alt text, a validated label/value
256
+ `pairs` section, registry fan-out checks, idempotent reconciliation, per-step
257
+ and section-scoped design evidence, catch-all source confidence, shell-safe VPS
258
+ SSH guidance, and compatible feedback CLI examples. The release
254
259
  retains the existing service matching,
255
260
  localization, seed-manifest, lockfile/analytics, sitemap, deployment and
256
261
  rollback workflows.
@@ -492,13 +497,32 @@ maggie design validate-ui --project . \
492
497
  This reuses the host project's homepage shell and `DESIGN.md`; it does not
493
498
  copy reference branding, source code, private data, or provider facts.
494
499
 
495
- The remaining installable skills are `maggie-blog-bootstrap`, `maggie-dash`,
496
- `maggie-clone`, `maggie-clone-to-template`, `maggie-marketplace`,
497
- `maggie-template`, `maggie-design`, `maggie-ops`, `maggie-deployment`,
498
- `maggie-project-context`, `maggie-social-share`, and `maggie-memory`.
500
+ For existing routes, in-place design jobs are resumable and can be narrowed to
501
+ one section. Catch-all routes remain `unproven` until their content source is
502
+ declared, and each completed step records evidence:
503
+
504
+ ```bash
505
+ maggie design in-place --project . --route /pricing \
506
+ --content-source src/lib/pages/db.ts
507
+ maggie design step in-place-<id> --project . \
508
+ --step inspect-route --evidence .maggie/evidence/route.json
509
+ maggie design section --project . --route /pricing --section-id hours
510
+ maggie design section-validate --before before.json --after after.json \
511
+ --section-id hours
512
+ ```
513
+
514
+ MaggieDash section contracts include a compact `pairs` band for factual
515
+ label/value rows. Validate required values, registry fan-out, locale sidecars,
516
+ and idempotent repairs with `maggie dash sections validate`,
517
+ `fanout-validate`, `locale-validate`, and `reconcile`.
499
518
 
500
- The package also includes `maggie-auth-reference` and `maggie-blog`. Use the
501
- stable commands below after installation:
519
+ The package includes all 18 installable skills: `maggie-blog-bootstrap`,
520
+ `maggie-dash`, `maggie-clone`, `maggie-clone-to-template`, `maggie-marketplace`,
521
+ `maggie-template`, `maggie-design`, `maggie-ops`, `maggie-deployment`,
522
+ `maggie-project-context`, `maggie-seo-geo`, `maggie-social-share`,
523
+ `maggie-service-booking`, `maggie-memory`, `maggie-content-localization`,
524
+ `maggie-feedback`, `maggie-auth-reference`, and `maggie-blog`. Use the stable
525
+ commands below after installation:
502
526
 
503
527
  ```bash
504
528
  maggie auth reference --project . --confirm
package/bin/maggie.js CHANGED
@@ -58,7 +58,7 @@ Usage:
58
58
  maggie doctor [--project PATH]
59
59
  maggie bootstrap interview [project]
60
60
  maggie dash init|install|status|migrate|transition|variant|cms --project PATH [options]
61
- maggie dash sections <validate|prompt|keys|remap-translations> [options]
61
+ maggie dash sections <validate|prompt|keys|remap-translations|fanout-validate|locale-validate|reconcile> [options]
62
62
  maggie dash components-audit --bindings-file FILE --sections-file FILE --pages-file FILE
63
63
  maggie dash status --project PATH
64
64
  maggie dash migrate --project PATH --confirm
@@ -85,6 +85,10 @@ Usage:
85
85
  maggie design validate-ui --project PATH --plan PATH --rendered-dir PATH --confirm
86
86
  maggie design icon-inventory --project PATH --source-dir src --runtime assets/icons.css --output docs/icon-inventory.json
87
87
  maggie design author --project PATH --route /about --purpose TEXT --audience TEXT --confirm
88
+ maggie design in-place --project PATH --route /pricing [--content-source PATH]
89
+ maggie design section --project PATH --route /pricing --section-id ID
90
+ maggie design section-validate --before FILE --after FILE --section-id ID
91
+ maggie design step <job-id> --step NAME --evidence FILE
88
92
  maggie auth reference --project PATH --confirm
89
93
  maggie auth check --project PATH [--production]
90
94
  maggie blog init|inspect|ingest|validate|publish|sitemap|settings|rollback|integration-state
@@ -181,6 +181,16 @@ maggie dash sections remap-translations \
181
181
  --translations-file .maggie/translations-legacy.json \
182
182
  --mapping-file .maggie/section-translation-map.json \
183
183
  --output .maggie/translations-v2.json --confirm
184
+
185
+ # Validate locale sidecars for stored image alt text before publishing:
186
+ maggie dash sections locale-validate \
187
+ --page-id <page-id> --sections-file .maggie/sections.json \
188
+ --translations-file .maggie/translations-by-locale.json --locale zh-Hant
189
+
190
+ # Check that a host wired every registry type through all implementation surfaces:
191
+ maggie dash sections fanout-validate \
192
+ --registry templates/maggiedash/section-registry.json \
193
+ --fanout-file templates/maggiedash/section-fanout.json
184
194
  ```
185
195
 
186
196
  The catalogue declares purpose, usage, placement, repeatability and layout
@@ -198,6 +208,15 @@ added band must have a real-renderer preview or an explicitly labelled example
198
208
  fallback at the supported breakpoints. Do not describe the starter vocabulary
199
209
  as a fixed number of bands.
200
210
 
211
+ Required fields and repeated `pairs` values cannot be blank. The `pairs`
212
+ starter band is intended for compact label/value facts such as opening hours;
213
+ it is not a prose fallback. The registry fan-out manifest is a host integration
214
+ contract: when a host adds a type, update its type union/schema, blank state,
215
+ validation, readable-content extraction, renderer, and editor entry together,
216
+ then run `fanout-validate`. `locale-validate` checks stored image-alt sidecars;
217
+ the host adapter must write the section and all non-default locale rows in one
218
+ transaction. The JSON report is evidence, not a database write.
219
+
201
220
  Translation keys use stable section IDs, with an array-index fallback only
202
221
  until the ordered migration is complete. Migration preserves unique IDs that
203
222
  already belong to the source section list; it generates a new ID only for a
@@ -37,6 +37,39 @@ systemd service, environment file, Nginx/TLS configuration, release root,
37
37
  database/media migration and backup strategy, health checks, and rollback
38
38
  owner before any remote mutation.
39
39
 
40
+ ### SSH and secret-source contract
41
+
42
+ Use these canonical project variables when a VPS adapter is configured:
43
+
44
+ | Variable | Meaning | Precedence |
45
+ | --- | --- | --- |
46
+ | `NOBLOX_DEPLOY_HOST` | VPS hostname or IP | explicit CLI flag, then project `.env`, then provider secret manager |
47
+ | `NOBLOX_DEPLOY_SSH_USER` | SSH login user | explicit CLI flag, then project `.env`, then provider secret manager |
48
+ | `NOBLOX_DEPLOY_SSH_PORT` | SSH port | explicit CLI flag, then project `.env`, then provider secret manager |
49
+ | `NOBLOX_DEPLOY_SSH_KEY` | path or secret reference for the private key | explicit CLI flag, then project `.env`, then provider secret manager |
50
+ | `NOBLOX_DEPLOY_ENV_FILE` | remote runtime env-file reference | provider secret manager only for the secret values |
51
+
52
+ Legacy provider-specific names may be supported by an adapter, but must be
53
+ mapped to these names in the local plan. The SSH key is preferred. Do not put
54
+ an SSH password in a plan or command line; if password authentication is
55
+ unavoidable, resolve `NOBLOX_DEPLOY_SSH_PASSWORD` at execution time from the
56
+ approved secret manager and never echo it. Explicit CLI values override `.env`;
57
+ `.env` overrides the provider adapter's default names. Values are validated by
58
+ name and presence only, never printed.
59
+
60
+ When composing SSH options in Bash or zsh, use an array so whitespace is not
61
+ reinterpreted as extra arguments:
62
+
63
+ ```zsh
64
+ ssh_opts=(-o StrictHostKeyChecking=accept-new -o BatchMode=yes)
65
+ ssh "${ssh_opts[@]}" -p "$NOBLOX_DEPLOY_SSH_PORT" \
66
+ "$NOBLOX_DEPLOY_SSH_USER@$NOBLOX_DEPLOY_HOST" "systemctl is-active example"
67
+ ```
68
+
69
+ Do not use `ssh_opts='-o StrictHostKeyChecking=accept-new -o BatchMode=yes'`
70
+ followed by `ssh $ssh_opts ...`; zsh does not perform the word splitting that
71
+ this pattern assumes.
72
+
40
73
  Create a local, secret-free plan before remote execution:
41
74
 
42
75
  ```bash
@@ -10,13 +10,33 @@ metadata:
10
10
  ## In-place route evidence
11
11
 
12
12
  `maggie design in-place --project . --route /example` resolves local source
13
- files and falls back to a discovered `[...slug]`/`[[...slug]]` file. That
14
- fallback is currently a source candidate, not proof that the concrete URL is
15
- served. Verify the route's HTTP response, content identity and framework route
13
+ files and falls back to a discovered `[...slug]`/`[[...slug]]` file. A catch-all
14
+ is recorded as `resolution: unproven` unless `--content-source` names the
15
+ actual DB/content resolver. A source candidate is not proof that the concrete
16
+ URL is served: verify the HTTP response, content identity, and framework route
16
17
  mapping before editing a shared catch-all. Next app-router catch-alls whose
17
- file is named `page.tsx` are not covered by this filename fallback. Capture
18
- responsive and interaction evidence separately; a plan in phase `ready` is
19
- not a successful browser test.
18
+ file is named `page.tsx` are not covered by this filename fallback.
19
+
20
+ In-place work is resumable. Record each completed step and its evidence:
21
+
22
+ ```bash
23
+ maggie design step in-place-<id> --project . \
24
+ --step inspect-route --evidence .maggie/evidence/route.json
25
+ maggie design status in-place-<id> --project .
26
+ ```
27
+
28
+ The job becomes `done` only after every required step has evidence; `ready`
29
+ means the contract exists, not that browser QA passed. For a one-band change,
30
+ use section-scoped mode and validate before/after evidence:
31
+
32
+ ```bash
33
+ maggie design section --project . --route /pricing --section-id hours
34
+ maggie design section-validate --before before.json --after after.json \
35
+ --section-id hours
36
+ ```
37
+
38
+ The validator requires the target section to change and proves that every
39
+ other section remains unchanged.
20
40
 
21
41
  ## Source-to-runtime icon inventory
22
42
 
@@ -16,7 +16,7 @@ general lesson or regression test.
16
16
  ## Collect locally
17
17
 
18
18
  ```bash
19
- maggie feedback collect --project . \
19
+ maggie feedback --project . collect \
20
20
  --skill maggie-clone-to-template --run-id clone-001 \
21
21
  --type bug --phase visual-qa \
22
22
  --summary "Mobile hero overflows the viewport" \
@@ -26,6 +26,7 @@ maggie feedback collect --project . \
26
26
  --reproduce "Run the clone workflow" \
27
27
  --reproduce "Open the mobile screenshot" \
28
28
  --screenshot ./screenshots/mobile.png
29
+ # The equivalent `maggie feedback collect --project . ...` spelling is also supported.
29
30
  maggie feedback preview .maggie/feedback/<feedback-id>.json --format markdown
30
31
  ```
31
32
 
@@ -0,0 +1,11 @@
1
+ {
2
+ "schemaVersion": "maggiedash-section-fanout.v1",
3
+ "description": "Starter host contract: every registry type must be wired through each implementation surface. Hosts extending the registry must regenerate this file.",
4
+ "typeUnion": ["cards", "cta", "faq", "hero", "links", "pairs", "prose"],
5
+ "sectionSchema": ["cards", "cta", "faq", "hero", "links", "pairs", "prose"],
6
+ "blank": ["cards", "cta", "faq", "hero", "links", "pairs", "prose"],
7
+ "validation": ["cards", "cta", "faq", "hero", "links", "pairs", "prose"],
8
+ "textExtraction": ["cards", "cta", "faq", "hero", "links", "pairs", "prose"],
9
+ "renderer": ["cards", "cta", "faq", "hero", "links", "pairs", "prose"],
10
+ "editor": ["cards", "cta", "faq", "hero", "links", "pairs", "prose"]
11
+ }
@@ -33,6 +33,19 @@
33
33
  ],
34
34
  "example": {"type": "prose", "heading": "Make the important part easier to understand", "paragraphs": ["Start with the decision your reader is trying to make.", "Then give them the context, evidence and next step in that order."]}
35
35
  },
36
+ {
37
+ "type": "pairs",
38
+ "purpose": "Shows named facts as a compact label-and-value band.",
39
+ "usage": "Use for opening hours, specifications, eligibility, or other factual pairs; never for long prose.",
40
+ "position": "body",
41
+ "repeatable": true,
42
+ "fields": [
43
+ {"name": "heading", "usage": "What the facts describe.", "limit": 90, "required": true},
44
+ {"name": "rows", "usage": "Non-empty factual label/value pairs.", "limit": 0, "repeats": {"min": 1, "max": 12, "of": [{"name": "label", "usage": "The fact name.", "limit": 70, "required": true}, {"name": "value", "usage": "The concise fact value; never blank.", "limit": 180, "required": true}]}},
45
+ {"name": "note", "usage": "Optional clarification below the facts.", "limit": 220}
46
+ ],
47
+ "example": {"type": "pairs", "heading": "At a glance", "rows": [{"label": "Typical response", "value": "Within one working day"}, {"label": "Available", "value": "Monday to Friday"}], "note": "Times may change on public holidays."}
48
+ },
36
49
  {
37
50
  "type": "cards",
38
51
  "purpose": "Presents parallel points side by side.",
@@ -23,7 +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, remap_translations, section_id_migration, validate_registry, copy_notes # noqa: E402
26
+ from maggie_sections import catalogue, remap_translations, section_id_migration, validate_registry, copy_notes, validate_values, validate_fanout, reconcile_fields, validate_locale_coverage # noqa: E402
27
27
  from route_imports import classify_bindings # noqa: E402
28
28
 
29
29
 
@@ -334,6 +334,33 @@ def command_sections(args: argparse.Namespace) -> int:
334
334
  result["output"] = str(output)
335
335
  emit(result)
336
336
  return 0 if result["passed"] else 1
337
+ if args.sections_command == "fanout-validate":
338
+ registry = json.loads(Path(args.registry).resolve().read_text(encoding="utf-8"))
339
+ fanout = json.loads(Path(args.fanout_file).resolve().read_text(encoding="utf-8"))
340
+ checked = validate_registry(registry)
341
+ result = {**validate_fanout(registry, fanout), "registry": checked}
342
+ emit(result)
343
+ return 0 if result["passed"] and checked["passed"] else 1
344
+ if args.sections_command == "reconcile":
345
+ current = json.loads(Path(args.current_file).resolve().read_text(encoding="utf-8"))
346
+ desired = json.loads(Path(args.desired_file).resolve().read_text(encoding="utf-8"))
347
+ result = reconcile_fields(current, desired)
348
+ if result["passed"]:
349
+ require_confirm(args)
350
+ output = Path(args.output).resolve()
351
+ if output.exists() and not args.force:
352
+ raise ValueError(f"output exists; use --force to replace: {output}")
353
+ output.parent.mkdir(parents=True, exist_ok=True)
354
+ output.write_text(json.dumps(result["result"], indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
355
+ result["output"] = str(output)
356
+ emit(result)
357
+ return 0 if result["passed"] else 1
358
+ if args.sections_command == "locale-validate":
359
+ sections = json.loads(Path(args.sections_file).resolve().read_text(encoding="utf-8"))
360
+ translations = json.loads(Path(args.translations_file).resolve().read_text(encoding="utf-8"))
361
+ result = validate_locale_coverage(args.page_id, sections, translations, args.locale)
362
+ emit(result)
363
+ return 0 if result["passed"] else 1
337
364
  registry = json.loads(Path(args.registry).resolve().read_text(encoding="utf-8"))
338
365
  checked = validate_registry(registry)
339
366
  if not checked["passed"]:
@@ -344,9 +371,9 @@ def command_sections(args: argparse.Namespace) -> int:
344
371
  return 0
345
372
  if args.sections_command == "validate":
346
373
  sections = json.loads(Path(args.sections_file).resolve().read_text(encoding="utf-8"))
347
- result = {**checked, "copy": copy_notes(registry, sections)}
374
+ result = {**checked, "copy": copy_notes(registry, sections), "values": validate_values(registry, sections)}
348
375
  emit(result)
349
- return 0 if result["copy"]["passed"] else 1
376
+ return 0 if result["copy"]["passed"] and result["values"]["passed"] else 1
350
377
  sections = json.loads(Path(args.sections_file).resolve().read_text(encoding="utf-8"))
351
378
  emit(section_id_migration(args.page_id, sections))
352
379
  return 0
@@ -484,6 +511,15 @@ def parser() -> argparse.ArgumentParser:
484
511
  sections_remap = sections_sub.add_parser("remap-translations", help="move an existing translation keyspace into stable section keys")
485
512
  sections_remap.add_argument("--translations-file", required=True); sections_remap.add_argument("--mapping-file", required=True); sections_remap.add_argument("--output", required=True); sections_remap.add_argument("--force", action="store_true"); sections_remap.add_argument("--confirm", action="store_true")
486
513
  sections_remap.set_defaults(func=command_sections)
514
+ sections_fanout = sections_sub.add_parser("fanout-validate", help="check registry type fan-out across host implementation surfaces")
515
+ sections_fanout.add_argument("--registry", required=True); sections_fanout.add_argument("--fanout-file", required=True)
516
+ sections_fanout.set_defaults(func=command_sections)
517
+ sections_reconcile = sections_sub.add_parser("reconcile", help="apply an idempotent compare-and-set JSON repair")
518
+ sections_reconcile.add_argument("--current-file", required=True); sections_reconcile.add_argument("--desired-file", required=True); sections_reconcile.add_argument("--output", required=True); sections_reconcile.add_argument("--force", action="store_true"); sections_reconcile.add_argument("--confirm", action="store_true")
519
+ sections_reconcile.set_defaults(func=command_sections)
520
+ sections_locale = sections_sub.add_parser("locale-validate", help="check locale sidecar coverage for stored image alt fields")
521
+ sections_locale.add_argument("--page-id", required=True); sections_locale.add_argument("--sections-file", required=True); sections_locale.add_argument("--translations-file", required=True); sections_locale.add_argument("--locale", action="append", required=True)
522
+ sections_locale.set_defaults(func=command_sections)
487
523
  sections_keys.set_defaults(func=command_sections)
488
524
  components = sub.add_parser("components-audit", help="classify route component bindings against live section/page inventories")
489
525
  components.add_argument("--bindings-file", required=True)
@@ -328,7 +328,7 @@ PHASES = [
328
328
  ],
329
329
  },
330
330
  ]
331
- DESIGN_JOB_PHASES = ("created", "preflight", "planned", "validated", "ready", "failed")
331
+ DESIGN_JOB_PHASES = ("created", "preflight", "planned", "validated", "ready", "done", "failed")
332
332
 
333
333
 
334
334
  def existing_evidence(project: Path, target: dict[str, str]) -> list[str]:
@@ -506,7 +506,32 @@ def design_resume(project: Path, job_id: str) -> int:
506
506
  return run_design_job(job["urls"], project, job["clone_run"], force=True)[0]
507
507
 
508
508
 
509
- def in_place_job(project: Path, routes: list[str], force: bool = False) -> int:
509
+ def _step_records(names: list[str]) -> list[dict[str, object]]:
510
+ return [{"name": name, "status": "pending", "evidence": None} for name in names]
511
+
512
+
513
+ def design_step(project: Path, job_id: str, step: str, evidence: str) -> int:
514
+ """Record one piece of implementation evidence and make progress durable."""
515
+ path = design_job_path(project.resolve(), job_id)
516
+ if not path.exists():
517
+ raise ValueError(f"design job not found: {path}")
518
+ job = json.loads(path.read_text(encoding="utf-8"))
519
+ steps = job.get("steps", [])
520
+ target = next((item for item in steps if item.get("name") == step), None)
521
+ if target is None:
522
+ raise ValueError(f"unknown design step for {job_id}: {step}")
523
+ evidence_path = Path(evidence).expanduser().resolve()
524
+ if not evidence_path.exists():
525
+ raise ValueError(f"step evidence does not exist: {evidence_path}")
526
+ target.update({"status": "done", "evidence": str(evidence_path), "completedAt": datetime.now(timezone.utc).isoformat()})
527
+ job.setdefault("history", []).append({"event": "step-completed", "step": step, "evidence": str(evidence_path), "at": datetime.now(timezone.utc).isoformat()})
528
+ job["phase"] = "done" if all(item.get("status") == "done" for item in steps) else "ready"
529
+ save_design_job(project, job)
530
+ print(json.dumps({"jobId": job_id, "step": step, "status": target["status"], "phase": job["phase"], "evidence": str(evidence_path)}, indent=2))
531
+ return 0
532
+
533
+
534
+ def in_place_job(project: Path, routes: list[str], force: bool = False, content_source: str = "") -> int:
510
535
  """Create a resumable redesign contract for existing first-party routes."""
511
536
  project = project.resolve()
512
537
  if not routes:
@@ -540,7 +565,13 @@ def in_place_job(project: Path, routes: list[str], force: bool = False) -> int:
540
565
  break
541
566
  if not match:
542
567
  raise ValueError(f"existing route required for in-place redesign: {route}")
543
- resolved_routes.append({"route": value, "source": str(match)})
568
+ catch_all = match.stem in {"[...slug]", "[[...slug]]"}
569
+ resolved_routes.append({
570
+ "route": value,
571
+ "source": str(match),
572
+ "resolution": "declared" if content_source else ("unproven" if catch_all else "direct-file"),
573
+ "contentSource": content_source or ("unproven catch-all route; declare the DB/content resolver before implementation" if catch_all else str(match)),
574
+ })
544
575
  digest = hashlib.sha256((str(project) + "\n" + "\n".join(routes)).encode()).hexdigest()[:12]
545
576
  job_id = f"in-place-{digest}"
546
577
  path = design_job_path(project, job_id)
@@ -558,7 +589,34 @@ def in_place_job(project: Path, routes: list[str], force: bool = False) -> int:
558
589
  "clone_run_required": False,
559
590
  "shared_tokens_required": True,
560
591
  "palette_change_allowed": False,
561
- "required_steps": ["inspect-route", "apply-design-contract", "visual-review", "route-validation"],
592
+ "required_steps": ["inspect-route", "declare-content-source", "apply-design-contract", "visual-review", "route-validation"],
593
+ "steps": _step_records(["inspect-route", "declare-content-source", "apply-design-contract", "visual-review", "route-validation"]),
594
+ "history": [{"phase": "ready", "at": datetime.now(timezone.utc).isoformat()}],
595
+ }
596
+ save_design_job(project, job)
597
+ print(json.dumps(job, indent=2, ensure_ascii=False))
598
+ return 0
599
+
600
+
601
+ def section_job(project: Path, route: str, section_id: str, force: bool = False) -> int:
602
+ """Create a focused design job with an unchanged-rest assertion."""
603
+ project = project.resolve()
604
+ if not route.startswith("/") or not section_id.strip():
605
+ raise ValueError("section mode requires a route starting with / and a section id")
606
+ contract = require_design_contract(project)
607
+ digest = hashlib.sha256((str(project) + "\n" + route + "\n" + section_id).encode()).hexdigest()[:12]
608
+ job_id = f"section-{digest}"
609
+ path = design_job_path(project, job_id)
610
+ if path.exists() and not force:
611
+ print(path.read_text(encoding="utf-8"), end="")
612
+ return 0
613
+ required = ["inspect-section", "capture-before", "implement-section", "capture-after", "assert-unchanged-rest", "route-validation"]
614
+ job = {
615
+ "id": job_id, "workflow": "maggie-design", "mode": "section-scoped", "phase": "ready",
616
+ "project": str(project), "route": route, "sectionId": section_id,
617
+ "design_contract": contract, "clone_run_required": False,
618
+ "scope": {"targetSection": section_id, "unchangedRestAssertion": "all non-target section content and structure hashes remain unchanged"},
619
+ "required_steps": required, "steps": _step_records(required),
562
620
  "history": [{"phase": "ready", "at": datetime.now(timezone.utc).isoformat()}],
563
621
  }
564
622
  save_design_job(project, job)
@@ -566,6 +624,25 @@ def in_place_job(project: Path, routes: list[str], force: bool = False) -> int:
566
624
  return 0
567
625
 
568
626
 
627
+ def validate_section_evidence(before_path: Path, after_path: Path, section_id: str) -> dict[str, object]:
628
+ """Compare section-scoped evidence and prove the rest stayed unchanged."""
629
+ before = json.loads(before_path.resolve().read_text(encoding="utf-8"))
630
+ after = json.loads(after_path.resolve().read_text(encoding="utf-8"))
631
+ before_sections = {str(item.get("id")): item for item in before.get("sections", []) if isinstance(item, dict) and item.get("id")}
632
+ after_sections = {str(item.get("id")): item for item in after.get("sections", []) if isinstance(item, dict) and item.get("id")}
633
+ errors = []
634
+ if section_id not in before_sections or section_id not in after_sections:
635
+ errors.append("target section is missing from before or after evidence")
636
+ unchanged = sorted(section for section in before_sections.keys() & after_sections.keys() if section != section_id and before_sections[section] != after_sections[section])
637
+ if unchanged:
638
+ errors.append("non-target sections changed: " + ", ".join(unchanged))
639
+ missing_rest = sorted((before_sections.keys() ^ after_sections.keys()) - {section_id})
640
+ if missing_rest:
641
+ errors.append("non-target section set changed: " + ", ".join(missing_rest))
642
+ target_changed = section_id in before_sections and section_id in after_sections and before_sections[section_id] != after_sections[section_id]
643
+ return {"schemaVersion": "maggie-section-evidence.v1", "passed": not errors and target_changed, "targetSection": section_id, "targetChanged": target_changed, "unchangedRest": not unchanged and not missing_rest, "changedNonTargetSections": unchanged, "errors": errors + ([] if target_changed else ["target section did not change"]), "before": str(before_path.resolve()), "after": str(after_path.resolve())}
644
+
645
+
569
646
  def author_job(project: Path, route: str, purpose: str, audience: str, brief_file: Path | None, confirm: bool) -> int:
570
647
  """Create an original page brief without requiring an external source URL."""
571
648
  project = project.resolve()
@@ -592,6 +669,20 @@ def author_job(project: Path, route: str, purpose: str, audience: str, brief_fil
592
669
 
593
670
 
594
671
  def main() -> int:
672
+ if len(sys.argv) > 1 and sys.argv[1] in {"-h", "--help"}:
673
+ print("""usage: maggie_design.py <command> [options]
674
+
675
+ commands:
676
+ run run the complete clone-backed design workflow
677
+ in-place create a resumable redesign contract for existing routes
678
+ section create a section-scoped redesign contract
679
+ step record completion evidence for one design step
680
+ status show a design job and its per-step progress
681
+ resume restart a failed design workflow
682
+ author plan an original first-party page
683
+ reference-ui, init, validate-ui, rebrand, review
684
+ """)
685
+ return 0
595
686
  if len(sys.argv) > 1 and sys.argv[1] == "reference-ui":
596
687
  command = argparse.ArgumentParser(description="Record sanitized blog/service UI evidence from a read-only reference project.")
597
688
  command.add_argument("--project", type=Path, default=Path.cwd())
@@ -653,13 +744,51 @@ def main() -> int:
653
744
  in_place = argparse.ArgumentParser(description="Create an in-place redesign contract for existing first-party routes.")
654
745
  in_place.add_argument("--project", type=Path, default=Path.cwd())
655
746
  in_place.add_argument("--route", action="append", required=True, help="existing route, for example /pricing")
747
+ in_place.add_argument("--content-source", default="", help="declared DB/content resolver or source manifest for catch-all routes")
656
748
  in_place.add_argument("--force", action="store_true")
657
749
  args = in_place.parse_args(sys.argv[2:])
658
750
  try:
659
- return in_place_job(args.project, args.route, args.force)
751
+ return in_place_job(args.project, args.route, args.force, args.content_source)
660
752
  except (OSError, ValueError, json.JSONDecodeError) as error:
661
753
  print(f"BLOCKED: maggie-design in-place: {error}", file=sys.stderr)
662
754
  return 1
755
+ if len(sys.argv) > 1 and sys.argv[1] == "section":
756
+ section = argparse.ArgumentParser(description="Create a section-scoped redesign contract with an unchanged-rest assertion.")
757
+ section.add_argument("--project", type=Path, default=Path.cwd())
758
+ section.add_argument("--route", required=True)
759
+ section.add_argument("--section-id", required=True)
760
+ section.add_argument("--force", action="store_true")
761
+ args = section.parse_args(sys.argv[2:])
762
+ try:
763
+ return section_job(args.project, args.route, args.section_id, args.force)
764
+ except (OSError, ValueError, json.JSONDecodeError) as error:
765
+ print(f"BLOCKED: maggie-design section: {error}", file=sys.stderr)
766
+ return 1
767
+ if len(sys.argv) > 1 and sys.argv[1] == "step":
768
+ step = argparse.ArgumentParser(description="Record evidence for one design workflow step.")
769
+ step.add_argument("job_id")
770
+ step.add_argument("--project", type=Path, default=Path.cwd())
771
+ step.add_argument("--step", required=True)
772
+ step.add_argument("--evidence", type=Path, required=True)
773
+ args = step.parse_args(sys.argv[2:])
774
+ try:
775
+ return design_step(args.project, args.job_id, args.step, str(args.evidence))
776
+ except (OSError, ValueError, json.JSONDecodeError) as error:
777
+ print(f"BLOCKED: maggie-design step: {error}", file=sys.stderr)
778
+ return 1
779
+ if len(sys.argv) > 1 and sys.argv[1] == "section-validate":
780
+ section_validate = argparse.ArgumentParser(description="Validate before/after evidence for one section and assert the rest is unchanged.")
781
+ section_validate.add_argument("--before", type=Path, required=True)
782
+ section_validate.add_argument("--after", type=Path, required=True)
783
+ section_validate.add_argument("--section-id", required=True)
784
+ args = section_validate.parse_args(sys.argv[2:])
785
+ try:
786
+ result = validate_section_evidence(args.before, args.after, args.section_id)
787
+ print(json.dumps(result, indent=2, ensure_ascii=False))
788
+ return 0 if result["passed"] else 1
789
+ except (OSError, ValueError, json.JSONDecodeError) as error:
790
+ print(f"BLOCKED: maggie-design section-validate: {error}", file=sys.stderr)
791
+ return 1
663
792
  if len(sys.argv) > 1 and sys.argv[1] == "author":
664
793
  author = argparse.ArgumentParser(description="Create an original first-party page brief and route plan.")
665
794
  author.add_argument("--project", type=Path, default=Path.cwd())
@@ -204,6 +204,9 @@ def main() -> int:
204
204
  parser.add_argument("--project", default=".")
205
205
  sub = parser.add_subparsers(dest="command", required=True)
206
206
  collect_parser = sub.add_parser("collect")
207
+ # Accept the project flag after the subcommand as shown in the public
208
+ # examples. argparse otherwise only accepts the global flag before it.
209
+ collect_parser.add_argument("--project", default=argparse.SUPPRESS)
207
210
  collect_parser.add_argument("--skill", default="")
208
211
  collect_parser.add_argument("--run-id", default="")
209
212
  collect_parser.add_argument("--run-report")
@@ -168,7 +168,17 @@ def audit_page(url: str, html: str, status: int, content_type: str, expected_lan
168
168
  "headings": page.headings,
169
169
  "paragraphs": page.paragraphs,
170
170
  "links": [{"href": urljoin(url, link["href"]), "text": link["text"]} for link in page.links],
171
- "contentHash": hashlib.sha256(json.dumps({"headings": page.headings, "paragraphs": page.paragraphs, "links": page.links}, sort_keys=True, ensure_ascii=False).encode()).hexdigest(),
171
+ # Images and their alt text are readable content too. Keeping them
172
+ # in this contract prevents a prose-only migration from silently
173
+ # dropping the visual content of a page.
174
+ "images": [{"src": urljoin(url, image["src"]), "alt": image.get("alt")} for image in page.images],
175
+ "imageAlts": [image.get("alt") for image in page.images],
176
+ "contentHash": hashlib.sha256(json.dumps({
177
+ "headings": page.headings,
178
+ "paragraphs": page.paragraphs,
179
+ "links": [{"href": urljoin(url, link["href"]), "text": link["text"]} for link in page.links],
180
+ "images": [{"src": urljoin(url, image["src"]), "alt": image.get("alt")} for image in page.images],
181
+ }, sort_keys=True, ensure_ascii=False).encode()).hexdigest(),
172
182
  },
173
183
  "robots": not robots or not any(token in {"noindex", "none", "nofollow"} for token in robots_tokens),
174
184
  "robots_directives": robots,
@@ -13,6 +13,9 @@ from typing import Any, Iterable
13
13
  SCHEMA = "maggiedash-section-registry.v1"
14
14
  IDENTITY_SCHEMA = "maggiedash-section-identity.v1"
15
15
  TRANSLATION_REMAP_SCHEMA = "maggiedash-translation-remap.v1"
16
+ FANOUT_SCHEMA = "maggiedash-section-fanout.v1"
17
+ RECONCILE_SCHEMA = "maggiedash-reconcile.v1"
18
+ LOCALE_SCHEMA = "maggiedash-locale-coverage.v1"
16
19
 
17
20
 
18
21
  def _validate_field(field: object, at: str, errors: list[str], *, nested: bool = False) -> None:
@@ -154,6 +157,72 @@ def copy_notes(registry: dict[str, Any], sections: object) -> dict[str, Any]:
154
157
  return {"passed": not errors, "errors": errors, "notes": notes}
155
158
 
156
159
 
160
+ def validate_values(registry: dict[str, Any], sections: object) -> dict[str, Any]:
161
+ """Validate required scalar and repeated values, including pairs rows."""
162
+ if not isinstance(sections, list):
163
+ return {"passed": False, "errors": ["sections must be a list"]}
164
+ specs = {str(item.get("type")): item for item in registry.get("sections", []) if isinstance(item, dict)}
165
+ errors: list[str] = []
166
+
167
+ def check_fields(value: object, fields: list[dict[str, Any]], path: str) -> None:
168
+ for field in fields:
169
+ name = str(field.get("name"))
170
+ child = value.get(name) if isinstance(value, dict) else None
171
+ at = f"{path}.{name}"
172
+ if field.get("required") and (child is None or (isinstance(child, str) and not child.strip())):
173
+ errors.append(f"{at} is required and cannot be blank")
174
+ repeats = field.get("repeats")
175
+ if not isinstance(repeats, dict) or not isinstance(child, list):
176
+ continue
177
+ if not repeats["min"] <= len(child) <= repeats["max"]:
178
+ errors.append(f"{at} must contain {repeats['min']}-{repeats['max']} entries")
179
+ child_fields = [item for item in repeats.get("of", []) if isinstance(item, dict)]
180
+ for index, item in enumerate(child):
181
+ if isinstance(item, dict):
182
+ check_fields(item, child_fields, f"{at}[{index}]")
183
+ elif len(child_fields) == 1 and child_fields[0].get("required") and not str(item).strip():
184
+ errors.append(f"{at}[{index}] is required and cannot be blank")
185
+
186
+ for index, section in enumerate(sections):
187
+ if not isinstance(section, dict):
188
+ errors.append(f"sections[{index}] must be an object")
189
+ continue
190
+ spec = specs.get(str(section.get("type")))
191
+ if spec:
192
+ check_fields(section, [field for field in spec.get("fields", []) if isinstance(field, dict)], f"sections[{index}]")
193
+ return {"passed": not errors, "errors": errors}
194
+
195
+
196
+ def translation_key_paths(page_id: str, sections: object, field_names: tuple[str, ...] = ("imageAlt",)) -> list[str]:
197
+ """Find stored section fields that need a locale sidecar value."""
198
+ if not isinstance(sections, list):
199
+ return []
200
+ paths: list[str] = []
201
+
202
+ def walk(value: object, path: str) -> None:
203
+ if isinstance(value, dict):
204
+ for key, child in value.items():
205
+ child_path = f"{path}.{key}"
206
+ if key in field_names and isinstance(child, str) and child.strip():
207
+ paths.append(child_path)
208
+ walk(child, child_path)
209
+ elif isinstance(value, list):
210
+ for index, child in enumerate(value):
211
+ walk(child, f"{path}.{index}")
212
+
213
+ for index, section in enumerate(sections):
214
+ walk(section, f"page.{page_id}.sections.{section_key(section, index)}")
215
+ return paths
216
+
217
+
218
+ def validate_locale_coverage(page_id: str, sections: object, translations: object, locales: Iterable[str]) -> dict[str, Any]:
219
+ """Require non-default locale rows for every declared translatable field."""
220
+ paths = translation_key_paths(page_id, sections)
221
+ values = translations if isinstance(translations, dict) else {}
222
+ missing = [{"locale": locale, "key": key} for locale in locales for key in paths if not isinstance(values.get(locale), dict) or not str(values[locale].get(key) or "").strip()]
223
+ return {"schemaVersion": LOCALE_SCHEMA, "passed": not missing, "pageId": page_id, "requiredKeys": paths, "missing": missing, "writePolicy": "section row and locale rows must be committed in one transaction"}
224
+
225
+
157
226
  def new_section_id(existing: Iterable[str] = ()) -> str:
158
227
  taken = set(existing)
159
228
  while True:
@@ -226,3 +295,39 @@ def remap_translations(translations: object, mappings: object) -> dict[str, Any]
226
295
  return {"schemaVersion": TRANSLATION_REMAP_SCHEMA, "passed": not errors, "errors": errors,
227
296
  "translations": result, "applied": applied, "unmapped": sorted(unmapped),
228
297
  "mappingCount": len(valid)}
298
+
299
+
300
+ def validate_fanout(registry: dict[str, Any], fanout: object) -> dict[str, Any]:
301
+ """Ensure every registry type is wired through each host implementation surface.
302
+
303
+ A registry is declarative, but hosts still need a type union/schema,
304
+ blank-state logic, validation, text extraction, renderer, and editor
305
+ affordance. This contract makes a missing fan-out edit fail loudly.
306
+ """
307
+ if not isinstance(fanout, dict) or fanout.get("schemaVersion") != FANOUT_SCHEMA:
308
+ return {"schemaVersion": FANOUT_SCHEMA, "passed": False, "errors": [f"fanout schemaVersion must be {FANOUT_SCHEMA}"]}
309
+ types = {str(item.get("type")) for item in registry.get("sections", []) if isinstance(item, dict)}
310
+ surfaces = ("typeUnion", "sectionSchema", "blank", "validation", "textExtraction", "renderer", "editor")
311
+ missing: list[dict[str, str]] = []
312
+ for surface in surfaces:
313
+ values = fanout.get(surface)
314
+ declared = set(values) if isinstance(values, list) else set(values.keys()) if isinstance(values, dict) else set()
315
+ for section_type in sorted(types - declared):
316
+ missing.append({"type": section_type, "surface": surface})
317
+ return {"schemaVersion": FANOUT_SCHEMA, "passed": not missing, "errors": [f"{item['type']} missing {item['surface']} fan-out" for item in missing], "sectionTypes": sorted(types), "missing": missing}
318
+
319
+
320
+ def reconcile_fields(current: object, desired: object) -> dict[str, Any]:
321
+ """Return an idempotent compare-and-set result for repair scripts."""
322
+ if not isinstance(current, dict) or not isinstance(desired, dict):
323
+ return {"schemaVersion": RECONCILE_SCHEMA, "passed": False, "errors": ["current and desired must be objects"]}
324
+ result = copy.deepcopy(current)
325
+ changed: list[str] = []
326
+ already_correct: list[str] = []
327
+ for key, value in desired.items():
328
+ if current.get(key) == value:
329
+ already_correct.append(str(key))
330
+ else:
331
+ result[key] = copy.deepcopy(value)
332
+ changed.append(str(key))
333
+ return {"schemaVersion": RECONCILE_SCHEMA, "passed": True, "changed": changed, "alreadyCorrect": already_correct, "result": result, "convergent": True}
@@ -64,9 +64,15 @@ def compare(baseline: dict, report: dict) -> dict:
64
64
  changes.append({"url": url, "fields": fields})
65
65
  expected_content = baseline.get("content", {})
66
66
  content_changes = []
67
+ content_not_compared = []
67
68
  if expected_content:
68
69
  for url in sorted(expected_content.keys() & current_content.keys()):
69
- fields = sorted(key for key in expected_content[url].keys() | current_content[url].keys() if expected_content[url].get(key) != current_content[url].get(key))
70
+ expected_contract = expected_content[url]
71
+ current_contract = current_content[url]
72
+ ignored_legacy_fields = {"images", "imageAlts"} if "images" not in expected_contract else set()
73
+ fields = sorted(key for key in expected_contract.keys() | current_contract.keys() if key not in ignored_legacy_fields and expected_contract.get(key) != current_contract.get(key))
74
+ if "images" not in expected_contract:
75
+ content_not_compared.append({"url": url, "fields": ["images", "imageAlts"], "reason": "baseline predates image content contract"})
70
76
  if fields:
71
77
  content_changes.append({"url": url, "fields": fields})
72
78
  missing_content = sorted(expected_content.keys() - current_content.keys())
@@ -75,6 +81,7 @@ def compare(baseline: dict, report: dict) -> dict:
75
81
  return {"passed": not errors and not added and not removed and not changes and not content_changes,
76
82
  "errors": errors, "added": added, "removed": removed, "changed": changes,
77
83
  "contentChanged": content_changes,
84
+ "contentNotCompared": content_not_compared,
78
85
  "excludedQueryUrls": query_urls}
79
86
 
80
87
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topy-ai/maggie",
3
- "version": "0.7.10",
3
+ "version": "0.7.11",
4
4
  "description": "Install and manage Maggie Skills for AI coding agents",
5
5
  "license": "MIT",
6
6
  "type": "module",