@topy-ai/maggie 0.7.52 → 0.7.54
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-zh-TW.md +28 -4
- package/README.md +93 -60
- package/bin/maggie.js +67 -12
- package/bundled-contracts/maggie-assistant-tools/booking-receptionist-example-v1.json +161 -0
- package/bundled-contracts/maggie-assistant-tools/tool-registry-v1.schema.json +268 -20
- package/bundled-contracts/maggie-deployment/bridge-host-example-v1.json +49 -7
- package/bundled-contracts/maggiedash/booking-email-templates-v1.json +181 -13
- package/bundled-contracts/maggiedash/booking-rules-v1.json +1699 -0
- package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +398 -39
- package/bundled-references/maggiedash-booking/PRD.md +22 -11
- package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +1 -1
- package/bundled-references/maggiedash-booking/RULES.md +167 -0
- package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +2 -2
- package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +42 -4
- package/bundled-references/provider-adapter-contract.md +16 -3
- package/bundled-skills/catalog.json +1 -1
- package/bundled-skills/maggie-assistant-tools/SKILL.md +54 -3
- package/bundled-skills/maggie-assistant-tools/references/approvals-and-audit.md +27 -0
- package/bundled-skills/maggie-assistant-tools/references/booking-receptionist.md +45 -0
- package/bundled-skills/maggie-assistant-tools/references/bridge-host.md +54 -0
- package/bundled-skills/maggie-auth-reference/SKILL.md +18 -0
- package/bundled-skills/maggie-blog/SKILL.md +8 -0
- package/bundled-skills/maggie-booking/SKILL.md +66 -14
- package/bundled-skills/maggie-content-localization/SKILL.md +26 -0
- package/bundled-skills/maggie-dash/SKILL.md +28 -2
- package/bundled-skills/maggie-deployment/SKILL.md +48 -0
- package/bundled-skills/maggie-deployment/references/cloudflare.md +19 -0
- package/bundled-skills/maggie-deployment/references/vps.md +92 -0
- package/bundled-skills/maggie-design/SKILL.md +98 -3
- package/bundled-skills/maggie-design/references/app-ui-patterns.md +79 -0
- package/bundled-skills/maggie-feedback/SKILL.md +27 -2
- package/bundled-skills/maggie-marketplace/SKILL.md +12 -0
- package/bundled-skills/maggie-marketplace/references/directory-search.md +51 -0
- package/bundled-skills/maggie-marketplace/references/directory.mjs +112 -0
- package/bundled-skills/maggie-mobile-app/SKILL.md +65 -6
- package/bundled-skills/maggie-mobile-app/references/payments-and-push.md +78 -0
- package/bundled-skills/maggie-qa-workflow/SKILL.md +60 -0
- package/bundled-skills/maggie-seo-geo/SKILL.md +33 -0
- package/bundled-skills/maggie-service-booking/SKILL.md +28 -3
- package/bundled-skills/maggie-social-share/SKILL.md +56 -4
- package/bundled-skills/maggie-social-share/references/queue-and-connections.md +28 -0
- package/bundled-tools/clis/maggie_assistant_tools.py +84 -3
- package/bundled-tools/clis/maggie_booking.py +24 -12
- package/bundled-tools/clis/maggie_booking_connect_qa.py +136 -0
- package/bundled-tools/clis/maggie_booking_rules.py +476 -0
- package/bundled-tools/clis/maggie_bridge_host.py +77 -1
- package/bundled-tools/clis/maggie_dash.py +173 -3
- package/bundled-tools/clis/maggie_deployment_ops.py +524 -0
- package/bundled-tools/clis/maggie_design.py +15 -2
- package/bundled-tools/clis/maggie_design_bulk.py +290 -0
- package/bundled-tools/clis/maggie_design_lint.py +205 -3
- package/bundled-tools/clis/maggie_feedback.py +138 -28
- package/bundled-tools/clis/maggie_live_urls.py +138 -0
- package/bundled-tools/clis/maggie_localization.py +3 -0
- package/bundled-tools/clis/maggie_localization_keys.py +151 -0
- package/bundled-tools/clis/maggie_media_generate.py +174 -0
- package/bundled-tools/clis/maggie_mobile.py +219 -4
- package/bundled-tools/clis/maggie_product_release.py +49 -1
- package/bundled-tools/clis/maggie_qa_app_preflight.py +122 -4
- package/bundled-tools/clis/maggie_qa_fit.py +217 -0
- package/bundled-tools/clis/maggie_qa_workflow.py +5 -0
- package/bundled-tools/clis/maggie_schema_complexity.py +84 -0
- package/bundled-tools/clis/maggie_seo_routes.py +154 -0
- package/bundled-tools/clis/maggie_service_migration.py +272 -0
- package/bundled-tools/clis/maggie_social_queue.py +2 -0
- package/bundled-tools/clis/maggie_social_share.py +174 -7
- package/bundled-tools/integrations/maggie-api-pull.md +22 -0
- package/bundled-tools/runtime/assistant_approval.py +98 -0
- package/package.json +1 -1
- package/references/provider-adapter-contract.md +16 -3
- package/bundled-contracts/maggiedash/execution-board.json +0 -5463
- package/bundled-references/maggiedash-booking/CURRENT-STATE.md +0 -210
- package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +0 -5463
- package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +0 -270
- package/bundled-references/maggiedash-booking/PROGRESS.md +0 -279
- package/bundled-references/maggiedash-booking/README.md +0 -288
- package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +0 -551
- package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +0 -107
- package/bundled-references/maggiedash-booking/TASKS.md +0 -178
|
@@ -20,6 +20,10 @@ from urllib.request import Request, urlopen
|
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
DEFAULT_ENDPOINT = "https://feedback.noblox.app/api/feedback"
|
|
23
|
+
# Both hosts accept the same anonymous contract and de-duplicate on feedbackId,
|
|
24
|
+
# so a draft is offered to the next one only when the previous host failed
|
|
25
|
+
# before storing it (network error or a 5xx such as Cloudflare's 52x).
|
|
26
|
+
DEFAULT_ENDPOINTS = (DEFAULT_ENDPOINT, "https://feedback.cmo.so/api/feedback")
|
|
23
27
|
SECRET_RE = re.compile(r"(?i)(bearer\s+|(?:api[_-]?key|token|secret|password|npm_config[^=]*|authorization)\s*[=:]\s*)[^\s,;]+")
|
|
24
28
|
LOCAL_PATH_RE = re.compile(
|
|
25
29
|
r"(?<![A-Za-z0-9])(?:~/(?:[^\s,;:)\]}]+/)*[^\s,;:)\]}]+|"
|
|
@@ -348,28 +352,52 @@ def hosted_payload(data: dict) -> dict:
|
|
|
348
352
|
return payload
|
|
349
353
|
|
|
350
354
|
|
|
355
|
+
def submit_endpoints(value: object) -> list[str]:
|
|
356
|
+
endpoints = [value] if isinstance(value, str) else list(value or DEFAULT_ENDPOINTS)
|
|
357
|
+
result = []
|
|
358
|
+
for endpoint in endpoints:
|
|
359
|
+
endpoint = str(endpoint).rstrip("/")
|
|
360
|
+
parsed = urlparse(endpoint)
|
|
361
|
+
if parsed.scheme != "https" or not parsed.netloc:
|
|
362
|
+
raise ValueError("endpoint must be an absolute HTTPS URL")
|
|
363
|
+
if endpoint not in result:
|
|
364
|
+
result.append(endpoint)
|
|
365
|
+
return result
|
|
366
|
+
|
|
367
|
+
|
|
351
368
|
def submit(args: argparse.Namespace) -> int:
|
|
352
369
|
if not args.confirm:
|
|
353
370
|
raise ValueError("submit requires --confirm; feedback is never sent implicitly")
|
|
354
371
|
path = Path(args.feedback)
|
|
355
372
|
data = read_feedback(path)
|
|
356
|
-
|
|
357
|
-
parsed = urlparse(endpoint)
|
|
358
|
-
if parsed.scheme != "https" or not parsed.netloc:
|
|
359
|
-
raise ValueError("endpoint must be an absolute HTTPS URL")
|
|
373
|
+
endpoints = submit_endpoints(args.endpoint)
|
|
360
374
|
payload = hosted_payload(data)
|
|
361
375
|
payload["projectContext"] = data.get("projectContext") if data.get("privacy", {}).get("projectContextAllowed") else None
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
376
|
+
body = json.dumps(payload, ensure_ascii=False).encode()
|
|
377
|
+
failures = []
|
|
378
|
+
for endpoint in endpoints:
|
|
379
|
+
request = Request(endpoint, data=body, headers={"Content-Type": "application/json", "Accept": "application/json", "User-Agent": "MaggieFeedback/0.1"}, method="POST")
|
|
380
|
+
try:
|
|
381
|
+
with urlopen(request, timeout=20) as response:
|
|
382
|
+
raw = response.read(1_000_000).decode("utf-8", "replace")
|
|
383
|
+
result = json.loads(raw) if raw else {}
|
|
384
|
+
break
|
|
385
|
+
except HTTPError as error:
|
|
386
|
+
failures.append({"endpoint": endpoint, "error": f"HTTP {error.code}"})
|
|
387
|
+
# A 4xx is the host rejecting this draft; another host would too.
|
|
388
|
+
if error.code < 500:
|
|
389
|
+
raise RuntimeError(f"feedback submission failed: {endpoint} answered HTTP {error.code}") from error
|
|
390
|
+
except (URLError, TimeoutError, OSError) as error:
|
|
391
|
+
failures.append({"endpoint": endpoint, "error": safe_text(getattr(error, "reason", error))})
|
|
392
|
+
else:
|
|
393
|
+
tried = "; ".join(f"{item['endpoint']} ({item['error']})" for item in failures)
|
|
394
|
+
raise RuntimeError(f"feedback submission failed on every endpoint: {tried}")
|
|
369
395
|
ack = acknowledgement(result)
|
|
370
396
|
data["submission"] = {"status": "submitted", "endpoint": endpoint, "submittedAt": now(), "acknowledgement": ack}
|
|
397
|
+
if failures:
|
|
398
|
+
data["submission"]["failedEndpoints"] = failures
|
|
371
399
|
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
372
|
-
print(json.dumps({"feedbackId": data["feedbackId"], "status": "submitted", "acknowledgement": ack}, indent=2, ensure_ascii=False))
|
|
400
|
+
print(json.dumps({"feedbackId": data["feedbackId"], "status": "submitted", "endpoint": endpoint, "failedEndpoints": failures, "acknowledgement": ack}, indent=2, ensure_ascii=False))
|
|
373
401
|
return 0
|
|
374
402
|
|
|
375
403
|
|
|
@@ -459,6 +487,12 @@ def collect_batch(args: argparse.Namespace) -> int:
|
|
|
459
487
|
return 0
|
|
460
488
|
|
|
461
489
|
|
|
490
|
+
AREA_SUBJECT_RE = re.compile(r"^([A-Za-z][A-Za-z0-9 /&._-]{0,30}):\s+(\S.*)$")
|
|
491
|
+
SCOPE_RE = re.compile(r"^[a-z]+\(([^)]{1,40})\)!?:", re.IGNORECASE)
|
|
492
|
+
SKILL_HINT_RE = re.compile(r"\b(?:maggie[\w-]*|skills?|cli|mcp|codex|claude)\b", re.IGNORECASE)
|
|
493
|
+
SUMMARY_ITEM_CAP = 49
|
|
494
|
+
|
|
495
|
+
|
|
462
496
|
def classify_subject(subject: str, include_all: bool) -> tuple[str, str] | None:
|
|
463
497
|
"""Return (type, text) for one commit subject, or ``None`` to skip it."""
|
|
464
498
|
merge = MERGE_PR_RE.match(subject)
|
|
@@ -474,22 +508,58 @@ def classify_subject(subject: str, include_all: bool) -> tuple[str, str] | None:
|
|
|
474
508
|
return "bug", subject
|
|
475
509
|
if PLAIN_FEATURE_RE.search(subject):
|
|
476
510
|
return "improvement", subject
|
|
511
|
+
# "Area: sentence" is how many product teams describe a change; it is a
|
|
512
|
+
# change in that area even when the sentence has no recognised verb.
|
|
513
|
+
if AREA_SUBJECT_RE.match(subject):
|
|
514
|
+
return "improvement", subject
|
|
477
515
|
return ("general", subject) if include_all else None
|
|
478
516
|
|
|
479
517
|
|
|
480
|
-
def
|
|
481
|
-
"""
|
|
518
|
+
def subject_scope(subject: str) -> str:
|
|
519
|
+
"""The conventional-commit scope or ``Area:`` prefix of a subject, lower-cased; ``""`` when absent."""
|
|
520
|
+
match = SCOPE_RE.match(subject)
|
|
521
|
+
if match:
|
|
522
|
+
return match.group(1).strip().lower()
|
|
523
|
+
if FIX_SUBJECT_RE.match(subject) or FEATURE_SUBJECT_RE.match(subject) or CHORE_SUBJECT_RE.match(subject):
|
|
524
|
+
return ""
|
|
525
|
+
area = AREA_SUBJECT_RE.match(subject)
|
|
526
|
+
return area.group(1).strip().lower() if area else ""
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _summary_repos(values: list[str], default_rev: str = "") -> list[tuple[str, Path, str]]:
|
|
530
|
+
"""Parse ``--repo [LABEL=]PATH[@REV]`` values; unlabeled repositories become repo1, repo2, ..."""
|
|
482
531
|
repos = []
|
|
483
532
|
for index, value in enumerate(values or ["."], start=1):
|
|
484
533
|
label, sep, path = value.partition("=")
|
|
485
534
|
if not sep or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,40}", label):
|
|
486
535
|
label, path = f"repo{index}", value
|
|
487
|
-
|
|
488
|
-
|
|
536
|
+
rev = default_rev
|
|
537
|
+
head, at, tail = path.rpartition("@")
|
|
538
|
+
if at and head and not Path(path).expanduser().exists():
|
|
539
|
+
path, rev = head, tail
|
|
540
|
+
if rev and (rev.startswith("-") or not re.fullmatch(r"[A-Za-z0-9._/@{}~^:-]{1,200}", rev)):
|
|
541
|
+
raise ValueError("revision must be a branch, tag or commit such as origin/main")
|
|
542
|
+
repos.append((label, Path(path).expanduser().resolve(), rev or "HEAD"))
|
|
543
|
+
if len({label for label, _, _ in repos}) != len(repos):
|
|
489
544
|
raise ValueError("--repo labels must be unique")
|
|
490
545
|
return repos
|
|
491
546
|
|
|
492
547
|
|
|
548
|
+
def _behind_upstream(repo: Path, rev: str) -> tuple[str, int] | None:
|
|
549
|
+
"""When reading HEAD, report (upstream, commits HEAD lacks) from local refs; never fetches."""
|
|
550
|
+
if rev != "HEAD":
|
|
551
|
+
return None
|
|
552
|
+
upstream = subprocess.run(["git", "-C", str(repo), "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], capture_output=True, text=True, check=False)
|
|
553
|
+
if upstream.returncode or not upstream.stdout.strip():
|
|
554
|
+
return None
|
|
555
|
+
count = subprocess.run(["git", "-C", str(repo), "rev-list", "--count", "HEAD..@{u}"], capture_output=True, text=True, check=False)
|
|
556
|
+
try:
|
|
557
|
+
behind = int(count.stdout.strip() or 0)
|
|
558
|
+
except ValueError:
|
|
559
|
+
return None
|
|
560
|
+
return (upstream.stdout.strip(), behind) if behind else None
|
|
561
|
+
|
|
562
|
+
|
|
493
563
|
def session_summary(args: argparse.Namespace) -> int:
|
|
494
564
|
"""Draft a reviewable batch file from commit subjects; nothing is collected or submitted."""
|
|
495
565
|
since = str(args.since or "").strip()
|
|
@@ -503,32 +573,53 @@ def session_summary(args: argparse.Namespace) -> int:
|
|
|
503
573
|
batch_id = safe_text(args.batch_id) or f"session-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}"
|
|
504
574
|
if not SAFE_BATCH_ID_RE.fullmatch(batch_id):
|
|
505
575
|
raise ValueError("batch ID is invalid")
|
|
506
|
-
repos = _summary_repos(args.repo)
|
|
576
|
+
repos = _summary_repos(args.repo, str(getattr(args, "rev", "") or "").strip())
|
|
507
577
|
include_all = bool(getattr(args, "all", False))
|
|
508
|
-
|
|
578
|
+
skill_relevant = bool(getattr(args, "skill_relevant", False))
|
|
579
|
+
skill_hint = safe_text(args.skill).lower().removeprefix("maggie-")
|
|
580
|
+
candidates = []
|
|
509
581
|
skipped = 0
|
|
582
|
+
project_commits = 0
|
|
583
|
+
seen = 0
|
|
510
584
|
per_repo = {}
|
|
511
|
-
|
|
585
|
+
warnings = []
|
|
586
|
+
for label, repo, rev in repos:
|
|
512
587
|
command = ["git", "-C", str(repo), "log", "--format=%h%x1f%s"]
|
|
513
588
|
if not getattr(args, "merges", False):
|
|
514
589
|
command.append("--no-merges")
|
|
515
|
-
command += [f"--since={since_date}",
|
|
590
|
+
command += [f"--since={since_date}", rev] if since_date else [f"{since}..{rev}"]
|
|
516
591
|
completed = subprocess.run(command, capture_output=True, text=True, check=False)
|
|
517
592
|
if completed.returncode:
|
|
518
|
-
raise ValueError(f"git log failed for {label}; check --repo and --since")
|
|
593
|
+
raise ValueError(f"git log failed for {label}; check --repo, --rev and --since")
|
|
594
|
+
lag = _behind_upstream(repo, rev)
|
|
595
|
+
if lag:
|
|
596
|
+
warnings.append(f"{label}: local HEAD is {lag[1]} commit(s) behind {lag[0]}; commits pushed from worktrees are missing. Re-run with --repo {label}=PATH@{lag[0]} (git fetch first).")
|
|
519
597
|
counted = 0
|
|
520
598
|
for line in completed.stdout.splitlines():
|
|
521
599
|
sha, _, subject = line.partition("\x1f")
|
|
522
600
|
subject = safe_text(subject)[:200]
|
|
523
601
|
if not sha or not subject:
|
|
524
602
|
continue
|
|
603
|
+
seen += 1
|
|
525
604
|
classified = classify_subject(subject, include_all)
|
|
526
605
|
if classified is None:
|
|
527
606
|
skipped += 1
|
|
528
607
|
continue
|
|
608
|
+
scope = subject_scope(subject)
|
|
609
|
+
if skill_relevant and not (SKILL_HINT_RE.search(subject) or (skill_hint and skill_hint in subject.lower())):
|
|
610
|
+
project_commits += 1
|
|
611
|
+
continue
|
|
529
612
|
kind, text = classified
|
|
530
613
|
text = text[:1].upper() + text[1:] if text else subject
|
|
531
614
|
commit = f"{label}@{sha}" if len(repos) > 1 else sha
|
|
615
|
+
candidates.append({"kind": kind, "text": text, "commit": commit, "scope": scope or label})
|
|
616
|
+
counted += 1
|
|
617
|
+
per_repo[label] = counted
|
|
618
|
+
grouped = len(candidates) > SUMMARY_ITEM_CAP
|
|
619
|
+
items = []
|
|
620
|
+
if not grouped:
|
|
621
|
+
for candidate in candidates:
|
|
622
|
+
commit, text, kind = candidate["commit"], candidate["text"], candidate["kind"]
|
|
532
623
|
item = {
|
|
533
624
|
"type": kind,
|
|
534
625
|
"summary": text,
|
|
@@ -540,10 +631,24 @@ def session_summary(args: argparse.Namespace) -> int:
|
|
|
540
631
|
if kind in {"bug", "improvement"}:
|
|
541
632
|
item.update({"fixed": True, "resolution": text})
|
|
542
633
|
items.append(item)
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
634
|
+
else:
|
|
635
|
+
# Too many commits for one batch: one item per scope, so nothing is dropped silently.
|
|
636
|
+
scopes: dict[str, list[dict]] = {}
|
|
637
|
+
for candidate in candidates:
|
|
638
|
+
scopes.setdefault(candidate["scope"], []).append(candidate)
|
|
639
|
+
ordered = sorted(scopes.items(), key=lambda pair: (-len(pair[1]), pair[0]))
|
|
640
|
+
for scope, entries in ordered[:SUMMARY_ITEM_CAP]:
|
|
641
|
+
bugs = sum(1 for entry in entries if entry["kind"] == "bug")
|
|
642
|
+
items.append({
|
|
643
|
+
"type": "bug" if bugs * 2 > len(entries) else "improvement",
|
|
644
|
+
"summary": f"{scope}: {len(entries)} commit(s) ({bugs} fix) — keep only if a Maggie skill or CLI fell short",
|
|
645
|
+
"expected": f"{REVIEW_MARKER} what the skill or CLI should have done",
|
|
646
|
+
"actual": f"{REVIEW_MARKER} summarise the {scope} commits that came from a skill gap",
|
|
647
|
+
"validation": f"{REVIEW_MARKER} the test or check that verified the {scope} commits",
|
|
648
|
+
"evidence": [f"commit:{entry['commit']}" for entry in entries[:20]],
|
|
649
|
+
})
|
|
650
|
+
if len(ordered) > SUMMARY_ITEM_CAP:
|
|
651
|
+
warnings.append(f"{len(ordered) - SUMMARY_ITEM_CAP} scope(s) did not fit in one batch; narrow --since-date or use --skill-relevant.")
|
|
547
652
|
items.append({
|
|
548
653
|
"type": "feature",
|
|
549
654
|
"summary": f"{REVIEW_MARKER} describe an open ask from this session, or delete this item",
|
|
@@ -557,6 +662,7 @@ def session_summary(args: argparse.Namespace) -> int:
|
|
|
557
662
|
"phase": safe_text(args.phase) or "session-summary",
|
|
558
663
|
"reviewRequired": True,
|
|
559
664
|
"note": "Draft only. Remove items unrelated to Maggie skills or CLIs, replace every REVIEW: and delete the open-ask placeholder if unused. `maggie feedback batch` rejects unedited REVIEW: fields.",
|
|
665
|
+
"commits": {"seen": seen, "drafted": len(candidates), "skipped": skipped, "projectOnly": project_commits, "groupedByScope": grouped},
|
|
560
666
|
"items": items,
|
|
561
667
|
}
|
|
562
668
|
project = Path(args.project).resolve()
|
|
@@ -567,7 +673,9 @@ def session_summary(args: argparse.Namespace) -> int:
|
|
|
567
673
|
raise ValueError("output already exists; choose another --output or --batch-id")
|
|
568
674
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
569
675
|
output.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
570
|
-
|
|
676
|
+
if grouped:
|
|
677
|
+
warnings.append(f"{len(candidates)} commits exceed the {SUMMARY_ITEM_CAP}-item batch limit, so they were grouped into one item per scope.")
|
|
678
|
+
print(json.dumps({"status": "draft-written", "batchId": batch_id, "items": len(items), "commitsSeen": seen, "draftedCommits": len(candidates), "skippedCommits": skipped, "projectOnlyCommits": project_commits, "groupedByScope": grouped, "repositories": per_repo, "warnings": warnings, "output": str(output), "submitted": False}, ensure_ascii=False))
|
|
571
679
|
return 0
|
|
572
680
|
|
|
573
681
|
|
|
@@ -716,7 +824,9 @@ def main() -> int:
|
|
|
716
824
|
summary_parser.add_argument("--project", default=argparse.SUPPRESS)
|
|
717
825
|
summary_parser.add_argument("--since", default="", help="git revision; commits after it are summarized")
|
|
718
826
|
summary_parser.add_argument("--since-date", default="", help="ISO date/time; use across several repositories")
|
|
719
|
-
summary_parser.add_argument("--repo", action="append", default=[], help="[LABEL=]PATH of a git repository; repeat for app, backend, edge (default: current directory)")
|
|
827
|
+
summary_parser.add_argument("--repo", action="append", default=[], help="[LABEL=]PATH[@REV] of a git repository; repeat for app, backend, edge (default: current directory at HEAD)")
|
|
828
|
+
summary_parser.add_argument("--rev", default="", help="revision to read in every repository without its own @REV, e.g. origin/main")
|
|
829
|
+
summary_parser.add_argument("--skill-relevant", action="store_true", help="keep only commits that mention Maggie, a skill, a CLI or MCP (or the --skill name); count the rest as project-only")
|
|
720
830
|
summary_parser.add_argument("--all", action="store_true", help="also draft unclassified and chore subjects as general items")
|
|
721
831
|
summary_parser.add_argument("--merges", action="store_true", help="include merge commits (PR branch names are classified)")
|
|
722
832
|
summary_parser.add_argument("--batch-id", default="")
|
|
@@ -733,7 +843,7 @@ def main() -> int:
|
|
|
733
843
|
preview_parser.add_argument("--format", choices=("json", "markdown"), default="json")
|
|
734
844
|
submit_parser = sub.add_parser("submit")
|
|
735
845
|
submit_parser.add_argument("feedback")
|
|
736
|
-
submit_parser.add_argument("--endpoint", default
|
|
846
|
+
submit_parser.add_argument("--endpoint", action="append", help="HTTPS endpoint; repeat for an ordered fallback list (default: the NoBlox host, then feedback.cmo.so)")
|
|
737
847
|
submit_parser.add_argument("--confirm", action="store_true")
|
|
738
848
|
list_parser = sub.add_parser("list")
|
|
739
849
|
list_parser.add_argument("--project", default=argparse.SUPPRESS)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Record where API Pull deliveries went live, from the site's sitemap.
|
|
3
|
+
|
|
4
|
+
A delivered post is useful to other channels (social posts, internal links)
|
|
5
|
+
only once its live URL is known. The site should report it when it publishes;
|
|
6
|
+
this matcher fills the gaps from the sitemap with two rules:
|
|
7
|
+
|
|
8
|
+
* additive only: a record that already has a live URL keeps it, and a record
|
|
9
|
+
that matches nothing in this run is left as it was (never marked unmatched),
|
|
10
|
+
so the match can run on a schedule;
|
|
11
|
+
* the original page first: when translated copies share the slug
|
|
12
|
+
(``/blog/x`` and ``/zh-hans/blog/x``), the URL without a locale segment wins.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
import sys
|
|
20
|
+
import xml.etree.ElementTree as ET
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from urllib.parse import urlsplit
|
|
24
|
+
from urllib.request import Request, urlopen
|
|
25
|
+
|
|
26
|
+
LOCALE_SEGMENT = re.compile(r"^[a-z]{2}(?:[-_][a-z0-9]{2,8})?$", re.I)
|
|
27
|
+
MAX_SITEMAPS = 50
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def now() -> str:
|
|
31
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def fetch(location: str) -> str:
|
|
35
|
+
if not location.startswith(("http://", "https://")):
|
|
36
|
+
return Path(location).read_text(encoding="utf-8")
|
|
37
|
+
with urlopen(Request(location, headers={"User-Agent": "MaggieLiveUrls/1.0"}), timeout=20) as response:
|
|
38
|
+
return response.read(20_000_000).decode("utf-8", "replace")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def sitemap_urls(location: str) -> list[str]:
|
|
42
|
+
"""Every <loc> of a sitemap, following sitemap indexes (bounded)."""
|
|
43
|
+
seen: set[str] = set()
|
|
44
|
+
urls: list[str] = []
|
|
45
|
+
queue = [location]
|
|
46
|
+
while queue and len(seen) < MAX_SITEMAPS:
|
|
47
|
+
current = queue.pop(0)
|
|
48
|
+
if current in seen:
|
|
49
|
+
continue
|
|
50
|
+
seen.add(current)
|
|
51
|
+
root = ET.fromstring(fetch(current))
|
|
52
|
+
tag = root.tag.rsplit("}", 1)[-1]
|
|
53
|
+
locs = [element.text.strip() for element in root.iter() if element.tag.rsplit("}", 1)[-1] == "loc" and element.text]
|
|
54
|
+
if tag == "sitemapindex":
|
|
55
|
+
queue.extend(locs)
|
|
56
|
+
else:
|
|
57
|
+
urls.extend(locs)
|
|
58
|
+
return list(dict.fromkeys(urls))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def has_locale_segment(url: str) -> bool:
|
|
62
|
+
parts = [part for part in urlsplit(url).path.split("/") if part]
|
|
63
|
+
return len(parts) > 1 and bool(LOCALE_SEGMENT.match(parts[0]))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def best_match(slug: str, urls: list[str]) -> str | None:
|
|
67
|
+
candidates = [url for url in urls if [part for part in urlsplit(url).path.split("/") if part][-1:] == [slug]]
|
|
68
|
+
if not candidates:
|
|
69
|
+
return None
|
|
70
|
+
# Original before translated copies, then the shortest path.
|
|
71
|
+
return sorted(candidates, key=lambda url: (has_locale_segment(url), len(urlsplit(url).path), url))[0]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def match(state: dict, urls: list[str]) -> dict:
|
|
75
|
+
records = state.get("content", {}) if isinstance(state.get("content"), dict) else {}
|
|
76
|
+
matched, kept, missing = [], [], []
|
|
77
|
+
for key, record in records.items():
|
|
78
|
+
if not isinstance(record, dict):
|
|
79
|
+
continue
|
|
80
|
+
if record.get("live_url"):
|
|
81
|
+
kept.append({"remote_content_id": record.get("remote_content_id") or key, "live_url": record["live_url"], "source": record.get("live_url_source", "")})
|
|
82
|
+
continue
|
|
83
|
+
slug = str(record.get("slug") or "").strip("/")
|
|
84
|
+
url = best_match(slug, urls) if slug else None
|
|
85
|
+
if url:
|
|
86
|
+
record["live_url"] = url
|
|
87
|
+
record["live_url_source"] = "sitemap"
|
|
88
|
+
record["live_url_matched_at"] = now()
|
|
89
|
+
matched.append({"remote_content_id": record.get("remote_content_id") or key, "slug": slug, "live_url": url})
|
|
90
|
+
else:
|
|
91
|
+
missing.append({"remote_content_id": record.get("remote_content_id") or key, "slug": slug})
|
|
92
|
+
return {"matched": matched, "kept": kept, "notFound": missing}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def main() -> int:
|
|
96
|
+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
97
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
98
|
+
run = sub.add_parser("match", help="fill missing live URLs of delivered posts from the sitemap (additive)")
|
|
99
|
+
run.add_argument("--project", default=".")
|
|
100
|
+
run.add_argument("--state", help="lifecycle state (default: .maggie/ai-cmo-lifecycle.json)")
|
|
101
|
+
run.add_argument("--sitemap", required=True, help="sitemap URL or file (indexes are followed)")
|
|
102
|
+
run.add_argument("--write", action="store_true", help="save the new live URLs into the state file")
|
|
103
|
+
run.add_argument("--report-body", help="write a report-state body for the newly matched posts (send with `maggie api report-state --body-file`)")
|
|
104
|
+
record = sub.add_parser("record", help="record a live URL the site reported when it published a delivered post")
|
|
105
|
+
record.add_argument("--project", default=".")
|
|
106
|
+
record.add_argument("--state")
|
|
107
|
+
record.add_argument("--content-id", required=True)
|
|
108
|
+
record.add_argument("--url", required=True)
|
|
109
|
+
args = parser.parse_args()
|
|
110
|
+
state_path = Path(args.state) if args.state else Path(args.project) / ".maggie" / "ai-cmo-lifecycle.json"
|
|
111
|
+
try:
|
|
112
|
+
state = json.loads(state_path.read_text(encoding="utf-8"))
|
|
113
|
+
if args.command == "record":
|
|
114
|
+
if urlsplit(args.url).scheme not in {"http", "https"}:
|
|
115
|
+
raise ValueError("--url must be an absolute http(s) URL")
|
|
116
|
+
target = state.get("content", {}).get(args.content_id)
|
|
117
|
+
if not isinstance(target, dict):
|
|
118
|
+
raise ValueError(f"no delivered post {args.content_id} in {state_path}")
|
|
119
|
+
# The site's own report is authoritative and replaces a sitemap guess.
|
|
120
|
+
target.update({"live_url": args.url, "live_url_source": "site", "live_url_matched_at": now()})
|
|
121
|
+
state_path.write_text(json.dumps(state, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
122
|
+
print(json.dumps({"status": "recorded", "remote_content_id": args.content_id, "live_url": args.url}, indent=2))
|
|
123
|
+
return 0
|
|
124
|
+
result = match(state, sitemap_urls(args.sitemap))
|
|
125
|
+
if args.write:
|
|
126
|
+
state_path.write_text(json.dumps(state, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
127
|
+
if args.report_body and result["matched"]:
|
|
128
|
+
body = {"items": [{"content_id": item["remote_content_id"], "url": item["live_url"], "status": "published"} for item in result["matched"]][:500]}
|
|
129
|
+
Path(args.report_body).write_text(json.dumps(body, indent=2) + "\n", encoding="utf-8")
|
|
130
|
+
print(json.dumps({"status": "matched" if args.write else "dry-run", **{key: value for key, value in result.items()}, "counts": {key: len(value) for key, value in result.items()}}, indent=2, ensure_ascii=False))
|
|
131
|
+
return 0
|
|
132
|
+
except (OSError, ValueError, json.JSONDecodeError, ET.ParseError) as error:
|
|
133
|
+
print(f"maggie live-urls: {error}", file=sys.stderr)
|
|
134
|
+
return 1
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
if __name__ == "__main__":
|
|
138
|
+
raise SystemExit(main())
|
|
@@ -17,6 +17,7 @@ from localization_runner import generate, process_adapter
|
|
|
17
17
|
|
|
18
18
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
19
19
|
import maggie_email_localization as email_localization
|
|
20
|
+
import maggie_localization_keys as localization_keys
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
def generate_job(args: argparse.Namespace) -> int:
|
|
@@ -399,6 +400,7 @@ def main() -> int:
|
|
|
399
400
|
for name in ("stale", "glossary"):
|
|
400
401
|
command = sub.add_parser(name); command.add_argument("--project", type=Path, default=Path("."))
|
|
401
402
|
email_localization.add_parsers(sub)
|
|
403
|
+
localization_keys.add_parsers(sub)
|
|
402
404
|
args = parser.parse_args()
|
|
403
405
|
try:
|
|
404
406
|
if args.command == "generate": return generate_job(args)
|
|
@@ -411,6 +413,7 @@ def main() -> int:
|
|
|
411
413
|
if args.command == "publish": return publish(args.job, args.confirm)
|
|
412
414
|
if args.command == "stale": return stale_jobs(args.project)
|
|
413
415
|
if args.command in ("email-matrix", "lifecycle"): return email_localization.run(args)
|
|
416
|
+
if args.command in ("key-check", "fragments"): return localization_keys.run(args)
|
|
414
417
|
return glossary(args.project)
|
|
415
418
|
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
416
419
|
print(f"ERROR: {error}", file=sys.stderr); return 1
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Translation keys for generated pages: text keys are site-wide, positions are not.
|
|
2
|
+
|
|
3
|
+
A translation stored under its source text applies to every page that shows
|
|
4
|
+
that text. That is right for a shared header, wrong for generic phrases on
|
|
5
|
+
generated pages ("your practice" is a clinic on one page and a firm on
|
|
6
|
+
another). `key-check` reads per-page translation rows and lists the texts that
|
|
7
|
+
need position keys. `fragments` reads rendered HTML and lists text split
|
|
8
|
+
around an inline span (a styled brand name) into pieces too short to translate
|
|
9
|
+
on their own ("Can", "No.", "What").
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
from html.parser import HTMLParser
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
INLINE = {"span", "strong", "em", "b", "i", "a", "mark", "small", "sup", "sub", "u"}
|
|
20
|
+
BLOCK = {"p", "h1", "h2", "h3", "h4", "h5", "h6", "li", "td", "th", "dt", "dd", "figcaption", "blockquote", "button", "label", "summary", "caption"}
|
|
21
|
+
SKIP = {"script", "style", "noscript", "template", "svg", "code", "pre"}
|
|
22
|
+
WORD = re.compile(r"[\w'’-]+", re.UNICODE)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_rows(path: Path) -> list[dict]:
|
|
26
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
27
|
+
rows = value.get("rows", []) if isinstance(value, dict) else value
|
|
28
|
+
if not isinstance(rows, list):
|
|
29
|
+
raise ValueError("rows must be a list of {page, position?, text, translation}")
|
|
30
|
+
return [row for row in rows if isinstance(row, dict)]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def key_check(rows: list[dict], locale: str | None = None) -> dict:
|
|
34
|
+
"""Source texts whose translation differs between pages cannot share a text key."""
|
|
35
|
+
by_text: dict[str, dict[str, set[str]]] = {}
|
|
36
|
+
for row in rows:
|
|
37
|
+
if locale and row.get("locale") not in (None, locale):
|
|
38
|
+
continue
|
|
39
|
+
text = " ".join(str(row.get("text") or "").split())
|
|
40
|
+
translation = " ".join(str(row.get("translation") or "").split())
|
|
41
|
+
if not text or not translation:
|
|
42
|
+
continue
|
|
43
|
+
by_text.setdefault(text, {}).setdefault(translation, set()).add(str(row.get("page") or "?"))
|
|
44
|
+
conflicts = []
|
|
45
|
+
for text, translations in sorted(by_text.items()):
|
|
46
|
+
if len(translations) > 1:
|
|
47
|
+
pages = sorted({page for group in translations.values() for page in group})
|
|
48
|
+
conflicts.append({"text": text, "translations": {translation: sorted(group)[:5] for translation, group in translations.items()}, "pages": len(pages)})
|
|
49
|
+
shared = sum(1 for translations in by_text.values() if len({page for group in translations.values() for page in group}) > 1)
|
|
50
|
+
return {
|
|
51
|
+
"valid": not conflicts,
|
|
52
|
+
"texts": len(by_text),
|
|
53
|
+
"sharedAcrossPages": shared,
|
|
54
|
+
"needPositionKeys": len(conflicts),
|
|
55
|
+
"conflicts": conflicts[:200],
|
|
56
|
+
"advice": "Store these under per-page position keys (render the source page, then key each node by page + position with a source hash); keep text keys only as a fallback for genuinely shared text." if conflicts else "",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class FragmentParser(HTMLParser):
|
|
61
|
+
"""Collect, per block element, the run of text pieces and inline spans."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, brand: re.Pattern[str]) -> None:
|
|
64
|
+
super().__init__(convert_charrefs=True)
|
|
65
|
+
self.brand = brand
|
|
66
|
+
self.stack: list[str] = []
|
|
67
|
+
self.blocks: list[list[tuple[str, str]]] = [] # (kind, text): kind is "text" or "inline"
|
|
68
|
+
self.inline_depth = 0
|
|
69
|
+
self.inline_text = ""
|
|
70
|
+
self.skip = 0
|
|
71
|
+
self.findings: list[dict] = []
|
|
72
|
+
|
|
73
|
+
def handle_starttag(self, tag: str, attrs) -> None: # noqa: ANN001
|
|
74
|
+
if tag in SKIP:
|
|
75
|
+
self.skip += 1
|
|
76
|
+
return
|
|
77
|
+
if tag in BLOCK:
|
|
78
|
+
self.blocks.append([])
|
|
79
|
+
elif tag in INLINE and self.blocks:
|
|
80
|
+
if self.inline_depth == 0:
|
|
81
|
+
self.inline_text = ""
|
|
82
|
+
self.inline_depth += 1
|
|
83
|
+
|
|
84
|
+
def handle_endtag(self, tag: str) -> None:
|
|
85
|
+
if tag in SKIP:
|
|
86
|
+
self.skip = max(0, self.skip - 1)
|
|
87
|
+
return
|
|
88
|
+
if tag in INLINE and self.inline_depth:
|
|
89
|
+
self.inline_depth -= 1
|
|
90
|
+
if self.inline_depth == 0 and self.blocks:
|
|
91
|
+
self.blocks[-1].append(("inline", self.inline_text))
|
|
92
|
+
elif tag in BLOCK and self.blocks:
|
|
93
|
+
self.finish(self.blocks.pop())
|
|
94
|
+
|
|
95
|
+
def handle_data(self, data: str) -> None:
|
|
96
|
+
if self.skip or not self.blocks:
|
|
97
|
+
return
|
|
98
|
+
if self.inline_depth:
|
|
99
|
+
self.inline_text += data
|
|
100
|
+
elif data.strip():
|
|
101
|
+
self.blocks[-1].append(("text", data))
|
|
102
|
+
|
|
103
|
+
def finish(self, parts: list[tuple[str, str]]) -> None:
|
|
104
|
+
if not any(kind == "inline" and self.brand.search(text) for kind, text in parts):
|
|
105
|
+
return
|
|
106
|
+
sentence = " ".join(" ".join(text.split()) for _, text in parts).strip()
|
|
107
|
+
for index, (kind, text) in enumerate(parts):
|
|
108
|
+
if kind != "text":
|
|
109
|
+
continue
|
|
110
|
+
neighbours = [parts[i] for i in (index - 1, index + 1) if 0 <= i < len(parts)]
|
|
111
|
+
if not any(other_kind == "inline" and self.brand.search(other_text) for other_kind, other_text in neighbours):
|
|
112
|
+
continue
|
|
113
|
+
words = WORD.findall(text)
|
|
114
|
+
if 1 <= len(words) <= 2:
|
|
115
|
+
self.findings.append({"fragment": " ".join(text.split()), "words": len(words), "sentence": sentence[:200]})
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def fragments(paths: list[Path], brand: str) -> dict:
|
|
119
|
+
pattern = re.compile(re.escape(brand), re.I)
|
|
120
|
+
findings = []
|
|
121
|
+
for path in paths:
|
|
122
|
+
parser = FragmentParser(pattern)
|
|
123
|
+
parser.feed(path.read_text(encoding="utf-8", errors="replace"))
|
|
124
|
+
findings.extend({"file": str(path), **item} for item in parser.findings)
|
|
125
|
+
return {
|
|
126
|
+
"valid": not findings,
|
|
127
|
+
"files": len(paths),
|
|
128
|
+
"fragments": findings[:500],
|
|
129
|
+
"advice": f"Translate the whole sentence around {brand!r} as one unit (keep the span as a placeholder such as {{brand}}) instead of storing these short pieces." if findings else "",
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def add_parsers(sub: argparse._SubParsersAction) -> None:
|
|
134
|
+
keys = sub.add_parser("key-check", help="list source texts translated differently on different pages (they need per-page position keys)")
|
|
135
|
+
keys.add_argument("rows", type=Path, help='JSON rows [{"page", "position"?, "text", "translation", "locale"?}]')
|
|
136
|
+
keys.add_argument("--locale")
|
|
137
|
+
split = sub.add_parser("fragments", help="find text split around an inline brand span into pieces too short to translate")
|
|
138
|
+
split.add_argument("--html", type=Path, nargs="+", required=True, help="rendered HTML files or directories (searched for *.html)")
|
|
139
|
+
split.add_argument("--brand", required=True, help="the inline span's text, e.g. the brand name")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def run(args: argparse.Namespace) -> int:
|
|
143
|
+
if args.command == "key-check":
|
|
144
|
+
result = key_check(load_rows(args.rows), args.locale)
|
|
145
|
+
else:
|
|
146
|
+
files: list[Path] = []
|
|
147
|
+
for item in args.html:
|
|
148
|
+
files.extend(sorted(item.rglob("*.html")) if item.is_dir() else [item])
|
|
149
|
+
result = fragments(files, args.brand)
|
|
150
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
151
|
+
return 0 if result["valid"] else 1
|