From 24de5878ed7f8d297e10aa3cf1e4f772fa5b0fa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ebbe=20Ba=C3=9F?= Date: Thu, 15 Oct 2026 13:33:02 +0200 Subject: [PATCH] Add BMAD planning artifacts: brief, PRD, architecture, UX, spec, tickets Includes the BMAD install config, the initiative ticket tree, the portal context-resources scripts, and the .gitignore change that tracks BMAD files (generated _bmad/render/ is ignored). Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Sk72AxLwQnYttF6VFSxQLt --- .gitignore | 15 +- .../.memlog.md | 36 + .../ARCHITECTURE-SPINE.md | 226 ++ .../.memlog.md | 40 + .../addendum.md | 25 + .../brief.md | 83 + .../epic-account-self-service.md | 46 + .../epic-announcements-and-administration.md | 47 + .../epic-platform-and-sign-in.md | 59 + .../epic-platform-and-sign-in/tickets.toml | 101 + .../initiative-ping-mee-portal.md | 63 + .../initiative-ping-mee-portal/tickets.toml | 21 + .../.memlog.md | 38 + .../addendum.md | 45 + .../prd.md | 446 +++ .../review-rubric.md | 57 + .../.memlog.md | 34 + .../SPEC.md | 100 + .../stories.yaml | 95 + .../.memlog.md | 30 + .../DESIGN.md | 183 ++ .../EXPERIENCE.md | 134 + .../mockups/key-admin-appearance.html | 99 + .../mockups/key-admin-audit.html | 91 + .../mockups/key-admin-status.html | 103 + .../mockups/key-announcement-editor.html | 109 + .../mockups/key-dashboard.html | 85 + .../mockups/key-password.html | 73 + .../mockups/key-picture-pending.html | 69 + _bmad/config.toml | 3 + _bmad/core-tools/scripts/.gitkeep | 0 _bmad/custom/.gitignore | 1 + _bmad/custom/bmad-ticket.toml | 2 + _bmad/method/scripts/tickets.py | 1473 ++++++++++ _bmad/scripts/config_utils.py | 116 + _bmad/scripts/knowledge.py | 462 +++ _bmad/scripts/memlog.py | 290 ++ _bmad/scripts/migrate_v6.py | 319 +++ _bmad/scripts/render_skill.py | 689 +++++ _bmad/scripts/resolve_config.py | 84 + _bmad/scripts/resolve_customization.py | 172 ++ _bmad/scripts/roster.py | 225 ++ _bmad/scripts/setup.py | 2490 +++++++++++++++++ _bmad/scripts/setup_check.py | 142 + _bmad/scripts/stamp_release.py | 143 + _bmad/scripts/tests/test_config_utils.py | 90 + .../tests/test_knowledge_migrations.py | 151 + _bmad/scripts/tests/test_memlog.py | 365 +++ _bmad/scripts/tests/test_render_skill.py | 976 +++++++ _bmad/scripts/tests/test_resolve_config.py | 109 + .../tests/test_resolve_customization.py | 175 ++ _bmad/scripts/tests/test_roster.py | 226 ++ _bmad/scripts/tests/test_setup_check.py | 237 ++ _bmad/scripts/tests/test_stamp_release.py | 357 +++ .../scripts/tests/test_validate_manifests.py | 553 ++++ _bmad/scripts/validate_manifests.py | 473 ++++ _bmad/toolsmith/scripts/.gitkeep | 0 context-resources/AD-ProfilePicture-Sync.ps1 | 289 ++ .../Set-AccountPictureRegistry-System.ps1 | 196 ++ context-resources/Set-LocalAccountPicture.ps1 | 292 ++ 60 files changed, 13647 insertions(+), 6 deletions(-) create mode 100644 _bmad-output/architecture/architecture-ping-mee-self-service-platform-2026-10-15/.memlog.md create mode 100644 _bmad-output/architecture/architecture-ping-mee-self-service-platform-2026-10-15/ARCHITECTURE-SPINE.md create mode 100644 _bmad-output/briefs/brief-ping-mee-self-service-platform-2026-10-15/.memlog.md create mode 100644 _bmad-output/briefs/brief-ping-mee-self-service-platform-2026-10-15/addendum.md create mode 100644 _bmad-output/briefs/brief-ping-mee-self-service-platform-2026-10-15/brief.md create mode 100644 _bmad-output/initiative-ping-mee-portal/epic-account-self-service/epic-account-self-service.md create mode 100644 _bmad-output/initiative-ping-mee-portal/epic-announcements-and-administration/epic-announcements-and-administration.md create mode 100644 _bmad-output/initiative-ping-mee-portal/epic-platform-and-sign-in/epic-platform-and-sign-in.md create mode 100644 _bmad-output/initiative-ping-mee-portal/epic-platform-and-sign-in/tickets.toml create mode 100644 _bmad-output/initiative-ping-mee-portal/initiative-ping-mee-portal.md create mode 100644 _bmad-output/initiative-ping-mee-portal/tickets.toml create mode 100644 _bmad-output/prds/prd-ping-mee-self-service-platform-2026-10-15/.memlog.md create mode 100644 _bmad-output/prds/prd-ping-mee-self-service-platform-2026-10-15/addendum.md create mode 100644 _bmad-output/prds/prd-ping-mee-self-service-platform-2026-10-15/prd.md create mode 100644 _bmad-output/prds/prd-ping-mee-self-service-platform-2026-10-15/review-rubric.md create mode 100644 _bmad-output/specs/spec-ping-mee-self-service-platform/.memlog.md create mode 100644 _bmad-output/specs/spec-ping-mee-self-service-platform/SPEC.md create mode 100644 _bmad-output/specs/spec-ping-mee-self-service-platform/stories.yaml create mode 100644 _bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/.memlog.md create mode 100644 _bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/DESIGN.md create mode 100644 _bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/EXPERIENCE.md create mode 100644 _bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/mockups/key-admin-appearance.html create mode 100644 _bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/mockups/key-admin-audit.html create mode 100644 _bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/mockups/key-admin-status.html create mode 100644 _bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/mockups/key-announcement-editor.html create mode 100644 _bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/mockups/key-dashboard.html create mode 100644 _bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/mockups/key-password.html create mode 100644 _bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/mockups/key-picture-pending.html create mode 100644 _bmad/config.toml create mode 100644 _bmad/core-tools/scripts/.gitkeep create mode 100644 _bmad/custom/.gitignore create mode 100644 _bmad/custom/bmad-ticket.toml create mode 100644 _bmad/method/scripts/tickets.py create mode 100644 _bmad/scripts/config_utils.py create mode 100644 _bmad/scripts/knowledge.py create mode 100644 _bmad/scripts/memlog.py create mode 100644 _bmad/scripts/migrate_v6.py create mode 100644 _bmad/scripts/render_skill.py create mode 100644 _bmad/scripts/resolve_config.py create mode 100644 _bmad/scripts/resolve_customization.py create mode 100644 _bmad/scripts/roster.py create mode 100644 _bmad/scripts/setup.py create mode 100644 _bmad/scripts/setup_check.py create mode 100644 _bmad/scripts/stamp_release.py create mode 100644 _bmad/scripts/tests/test_config_utils.py create mode 100644 _bmad/scripts/tests/test_knowledge_migrations.py create mode 100644 _bmad/scripts/tests/test_memlog.py create mode 100644 _bmad/scripts/tests/test_render_skill.py create mode 100644 _bmad/scripts/tests/test_resolve_config.py create mode 100644 _bmad/scripts/tests/test_resolve_customization.py create mode 100644 _bmad/scripts/tests/test_roster.py create mode 100644 _bmad/scripts/tests/test_setup_check.py create mode 100644 _bmad/scripts/tests/test_stamp_release.py create mode 100644 _bmad/scripts/tests/test_validate_manifests.py create mode 100644 _bmad/scripts/validate_manifests.py create mode 100644 _bmad/toolsmith/scripts/.gitkeep create mode 100644 context-resources/AD-ProfilePicture-Sync.ps1 create mode 100644 context-resources/Set-AccountPictureRegistry-System.ps1 create mode 100644 context-resources/Set-LocalAccountPicture.ps1 diff --git a/.gitignore b/.gitignore index eab8527..a6b1e16 100644 --- a/.gitignore +++ b/.gitignore @@ -13,9 +13,12 @@ *.vsix # BMAD method -.bmad-core/** -_bmad/** -_bmad-output/** -web-bundles/** -.memlog.md -_bmad/custom/*.user.toml +# .bmad-core/** +# _bmad/** +# _bmad-output/** +# web-bundles/** +# .memlog.md +# _bmad/custom/*.user.toml + +# BMAD generated render cache +_bmad/render/ diff --git a/_bmad-output/architecture/architecture-ping-mee-self-service-platform-2026-10-15/.memlog.md b/_bmad-output/architecture/architecture-ping-mee-self-service-platform-2026-10-15/.memlog.md new file mode 100644 index 0000000..1a55f69 --- /dev/null +++ b/_bmad-output/architecture/architecture-ping-mee-self-service-platform-2026-10-15/.memlog.md @@ -0,0 +1,36 @@ +--- +scope: ping-mee self-service portal, whole system (greenfield) +purpose: build substrate for epics/stories; architecture spine +altitude: initiative +updated: 2026-10-15T11:22 +--- + + +- (constraint) Input: PRD _bmad-output/prds/prd-ping-mee-self-service-platform-2026-10-15 (prd.md FR-1..FR-29, addendum.md) is binding; its non-goals and decisions are inherited silently. +- (constraint) Deployment: ONE Docker container on Linux behind existing reverse proxy (TLS terminated there); all config + secrets via env vars; nothing ping-mee-specific baked in; internet-exposed. +- (constraint) Integrations: OIDC (authentik, generic OIDC); LDAPS to AD with a least-privilege service account (OU + attrs givenName,sn,displayName,mail,telephoneNumber,mobile,password); SMTP (IONOS, configurable); SMB share (write source/.jpg, read processed/.md5); existing PowerShell sync scripts unchanged. +- (constraint) Scale: ~10 users; hard security bar (no injection, no session takeover, no cross-user access, throttling in front of every AD bind). +- (constraint) Open from PRD for architecture: Q-1 share unreachable, Q-2 mail retry/IONOS limits, Q-3 languages, Q-4 absolute session max, Q-5 data store/backup, Q-6 removed-AD-user cleanup, Q-7 removed-picture pending, Q-8 time zone, Q-9 md5 timing. +- (decision) Mode: COACHING. Audience: only Ebbe + AI agents (spine only; no extra human-facing artifact requested yet). User has NO stack preference - delegates choice to architect. +- (version) Django: 5.2 LTS (security support to Apr 2028), 6.0.x current as of Oct 2026 per web search (source: endoflife.date / djangoproject.com); next LTS 6.2 Apr 2027. Verify exact pin when scaffolding. +- (version) ldap3 (pure Python): last PyPI release 2.9.1 ~4 years ago, single maintainer -> stagnant. python-ldap: healthy, releases within last 3 months, needs libldap/libsasl build deps. Both can set AD unicodePwd over ldaps. +- (version) pillow-heif: 1.3.0 (Feb 2026) on PyPI per search, newer 1.6.0 in Arch testing Aug 2026; HEIC/AVIF decode 8/10/12-bit. Verify pin. +- (version) Fluent UI Blazor (Microsoft.FluentUI.AspNetCore.Components) 4.13.x documents .NET 8/9; .NET 10 support not confirmed -> weakens the .NET option. +- (decision) STACK [user-chosen, option A]: Python + Django (5.2 LTS or current; pin at scaffold), server-rendered pages, python-ldap (not ldap3: stagnant), Pillow + pillow-heif. Rejected: ASP.NET/Blazor (HEIC, .NET10 Fluent unconfirmed, Blazor circuits vs session kill), Node/React (SPA attack surface, HEIC), Go (cgo HEIC, hand-built). Binds: all units use Django ORM/sessions/CSRF/templates; Prevents: parallel hand-rolled web/auth layers. +- (direction) Office 365 look via Microsoft Fluent web components as static assets in server-rendered templates (UNVERIFIED - check at UX/scaffold). +- (decision) PARADIGM [ADOPTED by user]: modular monolith, Django project, one app per feature (accounts, profile, picture, announcements, adminui, audit) + external-world adapters with ONE OWNER each: directory (only LDAP/service account), mailer (only SMTP), share (only picture folders), idp (OIDC). No other module imports ldap/smtplib or touches share paths. Prevents: scattered AD/file access, injection surface. +- (decision) INVARIANT [agreed]: self-service actions always operate on the session user; no username taken from URL/form. Every state change writes its Audit Entry in the SAME transaction in the service layer (not view). Roles from AD group membership cached briefly (default 60s, setting). +- (decision) DATA STORE (Q-5) [user-confirmed constraints]: SQLite on a mounted volume, ONE gunicorn worker with threads + one in-process scheduler thread; scale-out not supported without moving to PostgreSQL. Backups: Veeam copy-job copies directories -> portal must write consistent SQLite backups (online backup API) into a backups/ dir on the volume on a schedule; Veeam must not rely on copying the live DB file. +- (decision) Q-4: absolute session max 4h (plus 1h idle). Q-3: German AND English, all text translatable from day one. Q-8: store UTC, display Europe/Berlin default (configurable), zone named in e-mails. Share: host mounts CIFS, container gets bind mounts source (rw) + processed (ro). +- (decision) CLIENT IP: reverse proxy is Zoraxy (per issue tobychui/zoraxy#93): appends real client IP to X-Forwarded-For and forwards an existing X-Real-IP UNCHANGED (spoofable). Rule: if TCP peer is in TRUSTED_PROXIES, client IP = rightmost X-Forwarded-For entry (trusted-proxy count configurable); NEVER use X-Real-IP; else use peer address. Verify with a test request at deploy. +- (decision) ENVIRONMENTS: no staging and no test AD. Only prod AD/authentik. Test OU can be created: a non-prod instance of the SAME image points at a test OU with a test service account scoped to it (OU, groups, share paths all config). Adapters have fakes; automated tests need no real AD/IdP/SMTP; manual smoke checklist against the test OU. +- (decision) DEPLOY: docker compose by hand on Linux host; image location not stated (assume built on host or private registry - deferred). Veeam copy-job notifies on copy failure only; portal MUST show last-successful-backup age + failed jobs (backup, mail outbox) in Admin UI because a portal-side backup failure would be invisible to Veeam. No alert e-mail; Admin UI status is enough (user). +- (decision) SECURITY [proposed, no objection]: Authlib for OIDC (code flow + PKCE, verify state/nonce/signature); throttle counters in DB keyed endpoint+user and endpoint+IP, checked BEFORE any directory call; sensitive actions re-verify password via directory adapter after throttle; passwords memory-only, never logged; secure HttpOnly cookies, CSRF everywhere, strict CSP (no inline script), security headers; DB sessions with username column; one-query session kill. +- (decision) OPS [proposed, no objection]: multi-stage image, non-root, only data/backups/share writable; entrypoint migrates then starts, fail-fast on missing env; /healthz checks DB only (not AD); JSON logs to stdout with request id, no secrets; 30s idempotent state-driven scheduler tick (no scheduler lib) running publish, outbox send, token expiry, audit retention, DB backup, nightly AD reconciliation; upgrade = backup, migrate; rollback = restore + previous tag. +- (decision) MAIL [proposed]: outbox table, per-recipient rows, retries/backoff + rate limit for IONOS, no direct sending. Q-6: nightly reconciliation deletes opt-outs/pending changes/picture state of users gone from OU; audit kept to retention. Q-1: share adapter atomic write (tmp+rename) with timeouts, error to user, no state change. Q-7/Q-9: Pending clears when processed md5 == stored md5; for removal when processed md5 != previous md5; sync-writes-md5-before-AD limitation accepted + timeout message. +- (version) nh3 (Rust ammonia binding) is the maintained HTML sanitizer; bleach deprecated Jan 2023. nh3 0.2.x on PyPI. Markdown parser: markdown-it-py (not verified; pin at scaffold). +- (version) @fluentui/web-components v3.0.0 stable 29 Jun 2026, v3.0.1 7 Jul 2026; framework-agnostic. DECISION: vendor built assets into the image at build time (no runtime CDN) - strict CSP and supply-chain; Node only in build stage. +- (version) Authlib 1.7.x current (June 2026 release) - verified by search; mozilla-django-oidc 4.0.1 (2024) older. +- (event) Gate: lint clean; adversarial pass done inline (no subagents). Fixes: LDAPS CA bundle via env (internal CA); SessionUser(key,sam) canonical identity; AD-call-inside-transaction semantics; mailer owns outbox; email plain-text + sanitized HTML; AD-19 one owner per table; single logout deferred. Independent subagent reviewers NOT run. +- (event) spine finalized +- (change) Update pass from UX run: AD-2 read allow-list incl. Password Policy + pwdLastSet; AD-13 accent colour + contrast warning; AD-15 per-device preferences cookies + de formal/informal; AD-16 rich-text editor -> Markdown, assets bundled; capability map FR-30..32; stack row for editor library (chosen at scaffold). diff --git a/_bmad-output/architecture/architecture-ping-mee-self-service-platform-2026-10-15/ARCHITECTURE-SPINE.md b/_bmad-output/architecture/architecture-ping-mee-self-service-platform-2026-10-15/ARCHITECTURE-SPINE.md new file mode 100644 index 0000000..619c339 --- /dev/null +++ b/_bmad-output/architecture/architecture-ping-mee-self-service-platform-2026-10-15/ARCHITECTURE-SPINE.md @@ -0,0 +1,226 @@ +--- +name: 'ping-mee Self-Service Portal' +type: architecture-spine +purpose: build-substrate +altitude: initiative +paradigm: 'Modular monolith with adapters (ports and adapters) for every external system' +scope: 'The whole Portal: one Django container, its adapters to AD, OIDC, SMTP and the picture share, its data, and its operation' +status: final +created: '2026-10-15' +updated: '2026-10-15' +binds: ['PRD FR-1..FR-32', 'PRD §8 cross-cutting requirements', 'PRD Q-1..Q-9', 'UX DESIGN.md and EXPERIENCE.md'] +sources: ['_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/DESIGN.md', '_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/EXPERIENCE.md', '_bmad-output/prds/prd-ping-mee-self-service-platform-2026-10-15/prd.md', '_bmad-output/prds/prd-ping-mee-self-service-platform-2026-10-15/addendum.md', '_bmad-output/briefs/brief-ping-mee-self-service-platform-2026-10-15/brief.md', 'context-resources/*.ps1'] +companions: ['_bmad-output/prds/prd-ping-mee-self-service-platform-2026-10-15/prd.md'] +--- + +# Architecture Spine — ping-mee Self-Service Portal + +## Design Paradigm + +Modular monolith with adapters. One Django project, one Django app per feature, and one adapter per external system. Features call adapters only through services; adapters know nothing about features. + +```mermaid +flowchart TD + V[views / templates] --> S[feature services] + S --> AUD[audit service] + S --> ACC[accounts: session user, roles] + S --> AD[adapters] + SCH[scheduler tick] --> S + AD --> DIR[directory: LDAPS] + AD --> MAIL[mailer: SMTP] + AD --> SHR[share: picture folders] + AD --> IDP[idp: OIDC] + S --> DB[(SQLite)] +``` + +Arrows are the only allowed dependency direction. Views never call adapters. Features never import each other's models; they call each other's services. + +## Invariants & Rules + +### AD-1 — Adapters have exactly one owner each +- **Binds:** all FRs touching AD, mail, the share or the IdP +- **Prevents:** scattered LDAP, SMTP or file access, each with its own escaping and error handling +- **Rule:** only `adapters/directory` imports `ldap`; only `adapters/mailer` imports `smtplib`; only `adapters/share` opens paths under the source or processed folders; only `adapters/idp` talks OIDC. Each adapter exposes a small interface with an in-memory fake. + +### AD-2 — Directory access is allow-listed and injection-proof +- **Binds:** FR-1, FR-5, FR-8, FR-9, FR-10, FR-14, FR-20; NFR §8.1 +- **Prevents:** LDAP injection, writes outside the OU, writes of unlisted attributes, the service account used anywhere else +- **Rule:** the Service Account is used only inside `directory`. Filters are built only with LDAP filter escaping; DNs are never built from input — the DN is found by searching `sAMAccountName` inside the configured OU. Writable attributes are a fixed allow-list: `givenName`, `sn`, `displayName`, `mail`, `telephoneNumber`, `mobile`, `unicodePwd`. Readable attributes are also a fixed allow-list: the writable ones, `sAMAccountName`, `memberOf` (or a group-membership query), `thumbnailPhoto`, `pwdLastSet`, the account's enabled and locked state, and the Password Policy (the domain's `minPwdLength`, `pwdProperties`, `pwdHistoryLength`, `minPwdAge`, `maxPwdAge`, and the user's `msDS-ResultantPSO` when a fine-grained policy applies). The Service Account's read rights are limited to the same list. Everything runs over LDAPS with certificate verification against a CA bundle supplied by environment variable (the homelab uses an internal CA); verification is never disabled. + +### AD-3 — Self-service acts on the session user only +- **Binds:** FR-5, FR-8, FR-10..FR-15, FR-21 +- **Prevents:** editing another user through a crafted URL or form +- **Rule:** no self-service view, service or form accepts a username, user id or DN. The acting user is read from the session as one object, `SessionUser(key, sam)`: `key` is the lower-case value used for every database key, `sam` is the exact `sAMAccountName` from AD used for AD calls and file names. No module derives either value itself. + +### AD-4 — Audit entry in the same transaction as the change +- **Binds:** FR-24, FR-28 +- **Prevents:** changes without a record; records without a change +- **Rule:** every state-changing service function writes its Audit Entry (who, what, when, IP) in the same database transaction, in the service layer, never in the view. Entries never contain passwords, tokens or picture content. A service that changes AD makes the AD call inside the transaction, before commit. A failed AD call rolls back and records a failure entry in its own transaction; a commit failure after a successful AD call is logged at error level with the change details. + +### AD-5 — Identity, roles and account state come from AD, cached briefly +- **Binds:** FR-1, FR-2, FR-25 +- **Prevents:** stale admin rights; a disabled account staying signed in; a directory outage granting access +- **Rule:** `accounts` resolves username, Publisher and Admin membership, and enabled/locked state from `directory` and caches the result per session for a configured time (default 60 s). A directory failure denies elevated roles and new sessions. The OIDC `preferred_username` must equal `sAMAccountName`. + +### AD-6 — Sessions are server-side with a kill-by-user primitive +- **Binds:** FR-3, FR-7, FR-11 +- **Prevents:** sessions that cannot be ended; mismatched idle and absolute limits +- **Rule:** sessions live in the database with the username in its own column. Idle limit 1 h, absolute limit 4 h, both settings. `end_sessions(username, except_current=bool)` is the only way to end sessions and is used by password change, undo, sign-out and disabled-account detection. Cookies are secure, HttpOnly and SameSite=Lax. + +### AD-7 — Throttle before any outbound call; client IP from a trusted proxy only +- **Binds:** FR-4, FR-6, FR-10, FR-11, FR-12 +- **Prevents:** AD lockout by an attacker; spoofed IPs defeating throttling +- **Rule:** counters live in the database, keyed by endpoint plus user and endpoint plus IP, and are checked before `directory`, `mailer` or upload processing. Client IP: if the TCP peer is in `TRUSTED_PROXIES`, take the last `X-Forwarded-For` entry (the one the proxy appended); otherwise use the peer address. `X-Real-IP` is never used. + +### AD-8 — Sensitive actions re-verify the password; passwords live only in memory +- **Binds:** FR-5, FR-6, FR-10 +- **Prevents:** session hijack changing the password or e-mail; password leakage +- **Rule:** password change and e-mail change call `directory.verify_password(user, password)` after the throttle check. Passwords are never stored, logged, audited or placed in error reports; sensitive POST parameters are excluded from logging and error pages. + +### AD-9 — Link actions: GET shows, POST applies; tokens are hashed and single-use +- **Binds:** FR-10, FR-11 +- **Prevents:** mail scanners confirming or undoing changes; token theft from the database +- **Rule:** confirmation (1 h) and undo (24 h) tokens are random, stored only as hashes, single-use and expiring. A GET renders a page naming the change; only a POST with CSRF applies it. + +### AD-10 — The picture pipeline is fixed +- **Binds:** FR-12..FR-15; Q-1, Q-7, Q-9 +- **Prevents:** unsafe image input; a different file name than the sync script reads; wrong Pending state +- **Rule:** accepted input is JPEG, PNG, HEIC or WebP up to 10 MB; SVG and everything else is refused. Decode with pixel and size limits, apply orientation, strip all metadata, crop square, re-encode as a 512×512 JPEG. The `share` adapter writes `.jpg` to the source folder atomically (temp file, then rename) with timeouts and records the MD5 in the database. The original upload is never stored. Pending clears when `processed/.md5` (case-insensitive, trimmed) equals the stored MD5; for a removal it clears when that file differs from the previous MD5. After a configured time the Portal shows "taking longer than expected". If the share is unreachable the user sees an error and nothing changes. + +### AD-11 — All mail goes through an outbox +- **Binds:** FR-10, FR-11, FR-7, FR-20; Q-2 +- **Prevents:** lost or duplicated mail on restart; blocking requests on SMTP; hitting provider limits +- **Rule:** the `mailer` module owns the outbox table; every other module calls `mailer.enqueue(template, recipient, context)` and nothing else. The scheduler calls `mailer.flush()`, which sends rows with retries, backoff and a configured rate limit. Recipients of an Announcement are resolved at publish time: all Users in the OU with a valid `mail` value, minus stored opt-outs; users without `mail` are recorded as skipped. Only opt-outs are stored. Every e-mail has a plain-text part; an HTML part, if any, passes the same sanitizer as the page (AD-16). Failures are visible in the Admin UI. + +### AD-12 — One scheduler thread running idempotent, state-driven jobs +- **Binds:** FR-17, FR-20, FR-24; Q-5, Q-6 +- **Prevents:** duplicate runs; jobs that depend on process memory +- **Rule:** the app runs one gunicorn worker with threads and one scheduler thread ticking every 30 s. Every job reads the database for what is due and is safe to repeat: publish due Announcements, send the outbox, expire tokens and sessions, apply audit retention, write the database backup, reconcile with AD nightly (delete opt-outs, pending changes and picture state of users no longer in the OU; audit entries stay until retention). Each run records its status for the Admin UI. + +### AD-13 — Configuration is partitioned and typed +- **Binds:** FR-23, FR-26 +- **Prevents:** secrets in the database or UI; a runtime setting that opens a security hole +- **Rule:** environment variables hold everything that identifies a system or contains a secret (AD, LDAPS, Service Account, OIDC, SMTP, share paths, group names, trusted proxies, time zone default). Runtime settings (accent colour, backgrounds, portal name, default form of address, session limits, throttle limits, audit retention) live in the database and are declared in one registry with type, default and minimum value. Saving the accent colour computes its contrast against the on-brand text colour for the light and the dark theme and returns the result and a warning when it is below 4.5:1; the save is still allowed. A missing or invalid environment variable stops startup with a clear message. Nothing in the repository names ping-mee. + +### AD-14 — SQLite with WAL on a volume, plus consistent backup files +- **Binds:** FR-27, FR-28; Q-5 +- **Prevents:** inconsistent copies of a live database; invisible backup failures +- **Rule:** the database is SQLite in WAL mode on the `/data` volume. The scheduler writes consistent backup files using the SQLite online backup API into `/backups` with a configured retention. Host-side copy jobs (Veeam) copy `/backups`, not the live file. The Admin UI shows the age of the last successful backup. + +### AD-15 — Time and language +- **Binds:** FR-17, FR-29; Q-3, Q-8 +- **Prevents:** mismatched time zones; untranslatable strings +- **Rule:** all timestamps are stored in UTC. Display uses the configured zone (default Europe/Berlin) and e-mails name the zone. Every user-visible string goes through Django translation from the first line of code; German and English ship, and German has a formal and an informal register. Per-device Preferences (theme: light, dark or system; language; German register) are stored in cookies and never on the server. The server reads them to render the page (a theme attribute on the root element, not inline script); `system` is resolved by CSS only. Language defaults from `Accept-Language`. Server-sent e-mails use the portal-wide default register. + +### AD-16 — User content is rendered safely +- **Binds:** FR-16, FR-19, FR-22; NFR §8.1 +- **Prevents:** script injection; remote code from CDNs; unsafe uploads by Admins +- **Rule:** templates auto-escape. Announcement bodies are edited in a browser rich-text editor (toolbar: bold, italic, heading, list, link) that converts to Markdown. The server treats the submitted Markdown as untrusted: it is stored as Markdown, rendered by a Markdown parser, and sanitized with an allow-list sanitizer on every render; raw HTML is not allowed. A strict Content-Security-Policy forbids inline scripts and external script sources; all front-end assets, including the rich-text editor and the Fluent web components, are built into the image, with no runtime CDN. Admin background images follow the same re-encode and size limits as pictures and are served only to signed-in Users. + +### AD-17 — Errors are typed and mapped in one place +- **Binds:** FR-5, FR-9, FR-10, FR-12, FR-14, §8.3 +- **Prevents:** raw exceptions or directory messages reaching users; inconsistent messages +- **Rule:** services raise typed errors (`ValidationFailed`, `Throttled`, `Forbidden`, `DirectoryUnavailable`, `ShareUnavailable`, `MailUnavailable`). One mapping layer turns them into translated user messages and HTTP codes. AD password-policy rejections are mapped to specific messages. + +### AD-18 — Logs and health +- **Binds:** FR-27, FR-28; §8.3 +- **Prevents:** secrets in logs; a healthy-looking container that is broken, or one restarted because AD is down +- **Rule:** logs are JSON on stdout with a request ID, never containing secrets or personal content. `/healthz` checks the database only, never AD, SMTP or the share. + +### AD-19 — Every table has exactly one owning app +- **Binds:** all features; NFR §8.1 +- **Prevents:** two modules writing the same entity in incompatible ways +- **Rule:** `accounts` owns sessions and throttle counters; `profile` owns pending e-mail change tokens; `picture` owns picture state; `announcements` owns announcements and opt-outs (the profile page calls its service); `mailer` owns the outbox; `adminui` owns settings; `scheduler` owns job runs; `audit` owns audit entries and is called by all. Other apps read an entity only through its owner's service. + +## Consistency Conventions + +| Concern | Convention | +| --- | --- | +| Naming | Django apps: `accounts`, `profile`, `picture`, `announcements`, `adminui`, `audit`. Adapters under `adapters/`: `directory`, `mailer`, `share`, `idp`. Services in `services.py` per app. Glossary terms from the PRD are used for class and table names. | +| Data and formats | Primary keys are integers. Usernames stored lower-case; the exact `sAMAccountName` from AD is used for file names. Timestamps UTC. Money: none. Tokens: random, hashed at rest. | +| State and cross-cutting | Mutations only in services, inside a transaction, with the audit entry (AD-4). Entity ownership per AD-19. Errors per AD-17. Settings via the registry (AD-13). No business logic in views or templates. | +| Testing | Every adapter has an in-memory fake; automated tests use fakes. A manual smoke checklist runs a non-production instance of the same image against a test OU. | + +## Stack + +| Name | Version | +| --- | --- | +| Python | current supported release at scaffold; pin in image | +| Django | 5.2 LTS (security fixes to April 2028) | +| python-ldap | current at scaffold (maintained) | +| Authlib | 1.7.x | +| Pillow + pillow-heif | current at scaffold (pillow-heif 1.3+) | +| nh3 + markdown-it-py | current at scaffold | +| gunicorn | one worker, threads | +| SQLite | WAL mode | +| @fluentui/web-components | 3.x, built into the image | +| Rich-text editor (browser, outputs Markdown) | chosen and pinned at scaffold after a web check; must be open source and bundled into the image | +| Docker, Docker Compose | on a Linux host; share mounted by the host (CIFS) | + +## Structural Seed + +```mermaid +flowchart LR + U[User browser] -->|HTTPS| RP[Zoraxy reverse proxy] + RP -->|HTTP| P[Portal container] + P -->|OIDC| AK[authentik] + P -->|LDAPS| DC[Active Directory] + P -->|SMTP| IO[IONOS mail] + P -->|bind mount source rw, processed ro| SH[(Share via host CIFS mount)] + P --> V[(volume /data SQLite)] + P --> B[(volume /backups)] + B -.copy job.-> VE[Veeam] + SH -.read source.-> SY[PowerShell sync task on DC] + SY -->|thumbnailPhoto| DC +``` + +```text +portal/ + config/ # settings, env parsing, setting registry + adapters/ # directory, mailer, share, idp (+ fakes) + accounts/ profile/ picture/ announcements/ adminui/ audit/ + scheduler/ # tick loop and jobs + templates/ static/ # Fluent web components built into the image + tests/ +Dockerfile # multi-stage, non-root +compose.yaml +``` + +```mermaid +erDiagram + SESSION { string username } + AUDIT_ENTRY { string actor } + ANNOUNCEMENT ||--o{ OUTBOX_MAIL : queues + OPT_OUT { string username } + PENDING_TOKEN { string username } + PICTURE_STATE { string username } + SETTING { string key } + JOB_RUN { string job } +``` + +Users themselves are not stored: AD is the source of truth. The Portal stores only data keyed by `username`. + +## Capability → Architecture Map + +| Capability / Area | Lives in | Governed by | +| --- | --- | --- | +| Sign-in, roles, sessions (FR-1..FR-3) | `accounts`, `adapters/idp`, `adapters/directory` | AD-5, AD-6 | +| Dashboard (FR-30) | `profile` (view only; calls `announcements` and `picture` services) | AD-1, AD-3 | +| Preferences (FR-31) | `accounts` (cookie middleware) | AD-15 | +| Throttling (FR-4, FR-6) | `accounts` | AD-7 | +| Password change and Password Policy (FR-5..FR-7) | `profile` | AD-2, AD-3, AD-8 | +| Profile data, e-mail change (FR-8..FR-11) | `profile` | AD-2, AD-3, AD-8, AD-9 | +| Picture (FR-12..FR-15) | `picture`, `adapters/share` | AD-10 | +| Announcements and mail (FR-16..FR-21) | `announcements`, `adapters/mailer` | AD-11, AD-12, AD-16 | +| Admin UI and Status page (FR-22..FR-25, FR-32) | `adminui`, `audit`, `scheduler` job records | AD-4, AD-12, AD-13, AD-14, AD-16 | +| Platform and operations (FR-26..FR-29) | `config`, `scheduler`, image | AD-12..AD-15, AD-18 | + +## Deferred + +- Container image location (registry or build on the host): decide at first deployment. +- Default values for throttle limits, backup retention and the Pending timeout: set at implementation, within the setting registry. +- The rich-text editor library: chosen at scaffold. The look and component set are in the UX documents. +- Moving to PostgreSQL or more than one worker: only if the user count grows. +- Alert e-mails or monitoring beyond the Admin UI: not needed now. +- A staging environment: not available; the test OU takes its place. +- Whether sign-out also ends the authentik session (single logout): decided at first deployment. diff --git a/_bmad-output/briefs/brief-ping-mee-self-service-platform-2026-10-15/.memlog.md b/_bmad-output/briefs/brief-ping-mee-self-service-platform-2026-10-15/.memlog.md new file mode 100644 index 0000000..6245468 --- /dev/null +++ b/_bmad-output/briefs/brief-ping-mee-self-service-platform-2026-10-15/.memlog.md @@ -0,0 +1,40 @@ +--- +topic: ping-mee self-service portal +updated: 2026-10-15T09:24 +--- + + +- (event) Create intent; coaching/fast mode not yet chosen. Stakes not yet read. +- (decision) Context: ping-mee.de is the user's large homelab (Jellyfin etc.); users authenticate against homelab Active Directory (domain ping-mee.local). +- (decision) Scope v1 (user words): users self-edit email, displayname, name, surname, profile picture, phone number. +- (decision) Scope v1: announcement feature so admin can announce planned downtimes and changes. +- (decision) Design: resembles classic Microsoft web UI. Login via LDAPS. +- (event) User supplied context-resources/*.ps1: existing profile-picture pipeline (fileshare source -> AD thumbnailPhoto/jpegPhoto/photo at 96px <=100KB -> Windows logon script builds account picture sizes, 448px HQ copy from share). +- (decision) Users: family and friends, ~10 people. Yet wants polished and protected 'for big deployments' (tension with right-sizing; to probe). +- (decision) Pictures: portal drops files on the fileshare (existing sync task does the AD write); portal renames to .jpg if not already named so. Portal does NOT write thumbnailPhoto itself. +- (decision) AD writes (other attributes): portal uses a dedicated service account with delegated privileges. +- (decision) Announcements: delivered by e-mail AND shown on an announcement page in the portal. +- (decision) Design: Office 365 (Fluent) look, not OWA 2010/Aero. +- (decision) Stakes: homelab use first, keep option to deploy elsewhere someday (=> no hardcoded domain/OU/share paths). +- (decision) Everything configurable, nothing baked in (domain, OU, paths, groups, SMTP, theme). Portal reachable from the INTERNET. No multi-domain. Admin UI wanted: upload backgrounds, change themes, settings. +- (decision) Pictures: portal converts to JPEG, scales/sizes to need; user message 'updated within ~2 hours' (sync-task latency). +- (decision) Writable scope: OU=Clients,OU=Organisation,DC=ping-mee,DC=local (configurable). Attributes: sn, displayName, givenName, mail, name, cn. Mail change requires confirmation e-mail. +- (assumption) GAP: phone number was in the original request but is not in the attribute list (telephoneNumber/mobile?). Also name/cn are the RDN: changing needs ModifyDN, not attribute modify. +- (decision) Announcements: publishers = members of a configurable AD group; SMTP server configurable (IONOS); opt-out/opt-in for e-mails wanted; one-way text primary, scheduled downtime window / severity / expiry as OPTIONAL features. +- (decision) Deployment: Docker container on a Linux box. +- (decision) Phone: both telephoneNumber and mobile. name and cn REMOVED from concept (no RDN renames). +- (decision) Behind reverse proxy (TLS there). Login throttling wanted. Asked: authentik IdP between AD and portal as option (user has an authentik server). +- (decision) Scope: ship everything (user: not a rational project, homelab is oversized by design). No phasing imposed; sequencing left to downstream. +- (decision) E-mail announcements: default opt-IN; on opt-out user is told they won't get downtime notifications and can opt back in via portal. +- (decision) Secrets via Docker environment variables. +- (decision) NEW SCOPE: password change by users at any time (admin currently resets manually) - core success criterion. Success = no manual password resets AND one clean all-in-one package solving these homelab problems. +- (decision) Password: users can CHANGE only (current+new); reset stays a manual admin process (out of scope). E-mail change requires current password + confirmation e-mail. Users with expired/must-change passwords can still log in (mechanism unresolved with OIDC). +- (decision) Login via OIDC (authentik) for initial authentication only; all writes stay LDAPS with service account. Prefers OIDC over LDAPS login. +- (decision) Problems solved: password change, profile info, profile pictures, announcement channel. 'Clean' = no good solution exists for these today; one all-in-one package. PowerShell scripts (sync, logon, registry) stay. +- (decision) Mode: FAST PATH chosen. Draft brief with [ASSUMPTION] tags. +- (assumption) Success criterion tension: forgotten passwords still need manual reset by admin, so 'no manual password work' only holds for known-password changes. +- (assumption) Expired-password login: authentik/AD OIDC login likely fails for expired passwords; mechanism needs design. +- (decision) Expired-password behavior with authentik unknown; to be tested. Password change confirmed: user enters current+new in portal, applied over LDAPS. +- (decision) Security success criterion: no injections, no session takeover, no cross-user read/write, throttled logins. +- (change) Draft v1 written: brief.md + addendum.md; security criterion made concrete, open question on expired passwords reworded. +- (event) Polish pass (bmad-review structure+prose) applied; brief status set to final. diff --git a/_bmad-output/briefs/brief-ping-mee-self-service-platform-2026-10-15/addendum.md b/_bmad-output/briefs/brief-ping-mee-self-service-platform-2026-10-15/addendum.md new file mode 100644 index 0000000..3f37a59 --- /dev/null +++ b/_bmad-output/briefs/brief-ping-mee-self-service-platform-2026-10-15/addendum.md @@ -0,0 +1,25 @@ +# Addendum: Technical Context for Downstream Documents + +## Existing picture pipeline (context-resources/) +- `AD-ProfilePicture-Sync.ps1` (DC, scheduled task): reads `.jpg` from the fileshare source folder, falls back to a default image, writes a 96px copy and a 448px copy to a processed folder, and sets `thumbnailPhoto`, `jpegPhoto` and `photo` in AD from the 96px copy (limit 100 KB). It writes MD5 files for change detection but does not yet compare them, so every run reprocesses every user. Target OU in the script: `OU=Organisation,DC=ping-mee,DC=local`. +- `Set-LocalAccountPicture.ps1` (user logon script): reads `thumbnailPhoto` and builds Windows account-picture sizes (32, 40, 48, 96, 192, 200, 240, 448). Large sizes use the 448px copy from the share. It uses `$roamingDir` before defining it, which is a bug. +- `Set-AccountPictureRegistry-System.ps1` (startup): applies registry entries from manifests left by the logon script. +- The portal's job is only to put a correct `.jpg` in the share's source folder. Format, size and naming constraints come from these scripts. + +## AD write scope +- Writable OU: `OU=Clients,OU=Organisation,DC=ping-mee,DC=local` (configurable). +- Attributes: `givenName`, `sn`, `displayName`, `mail`, `telephoneNumber`, `mobile`. Password via the AD password mechanism over LDAPS. +- Rejected: editing `cn` and `name`. They are the object's RDN and would need ModifyDN renames, which change the DN and risk breaking apps. + +## Security notes raised during discovery +- Internet exposure plus LDAP binds can lock AD accounts if an attacker hammers logins. Throttling must sit in front of any bind. OIDC via authentik moves the first factor there. +- E-mail change feeds announcements and any future reset path. Chain risk: hijacked session, then e-mail change. Mitigated by requiring the current password and a confirmation link. +- Secrets (service account, SMTP) come from Docker environment variables, not the admin UI. + +## Integrations +- IdP: authentik via OIDC. Keep it generic OIDC so other providers work. Not verified: whether authentik can write passwords back to AD. The portal does not rely on it. +- Mail: configurable SMTP, IONOS in the homelab. +- Reverse proxy in front terminates TLS. + +## Design +- Office 365 (Fluent) look, not OWA 2010 or Windows 7 Aero. Admin can change theme and upload backgrounds. diff --git a/_bmad-output/briefs/brief-ping-mee-self-service-platform-2026-10-15/brief.md b/_bmad-output/briefs/brief-ping-mee-self-service-platform-2026-10-15/brief.md new file mode 100644 index 0000000..1c837ce --- /dev/null +++ b/_bmad-output/briefs/brief-ping-mee-self-service-platform-2026-10-15/brief.md @@ -0,0 +1,83 @@ +--- +title: "Product Brief: ping-mee Self-Service Portal" +status: final +created: 2026-10-15 +updated: 2026-10-15 +--- + +# Product Brief: ping-mee Self-Service Portal + +## Executive Summary + +ping-mee.de is a large homelab that family and friends use, around ten people, for services such as Jellyfin. All accounts live in one Active Directory. Today, almost anything a user wants changed (a password, a name, a phone number, a profile picture) means asking the admin. There is also no good place to tell everyone about planned downtime. + +The ping-mee Self-Service Portal is a single web application that closes those gaps. Signed-in users change their own password and profile details and upload a profile picture. The admin publishes announcements that reach users by e-mail and on a page in the portal. It is built for the homelab first, reachable from the internet behind the existing reverse proxy, and configurable enough to run elsewhere later. The homelab is deliberately oversized for ten users, and so is this project. That is part of its purpose, not an accident. + +## The Problem + +- **Password changes are manual.** A user who wants a new password has to ask the admin. This is the main pain. +- **Profile data is admin-maintained.** Name, display name, e-mail and phone are edited by hand in AD. +- **Profile pictures are awkward.** The pipeline works (a file on a share, a scheduled sync to AD, a logon script on Windows clients), but a user has no way to supply the file themselves. +- **No announcement channel.** There is no single place to announce downtime or changes. +- **No existing tool fits.** Nothing handles these four things together, cleanly, in an AD-based homelab. + +## The Solution + +One portal, one login. After signing in through the existing authentik server (OIDC), a user can: + +- **Change their password** by entering the current one and a new one. +- **Edit profile data**: first name, last name, display name, e-mail, telephone number and mobile number. +- **Upload a picture.** The portal converts it to a JPEG sized for the pipeline and writes `.jpg` to the fileshare. The existing PowerShell sync then updates AD within roughly two hours, and the portal tells the user so. +- **Read announcements** on a page in the portal, and receive them by e-mail. + +Changing an e-mail address needs the current password and a confirmation link sent to the new address. Members of a configurable AD group can publish announcements. The admin has a UI to manage themes, background images and settings. The look follows Office 365. + +All writes to AD go through LDAPS with a dedicated service account that is limited to a configurable OU and a short list of attributes. + +## What Makes This Different + +There is no moat. The value is fit and cleanliness: a portal built around this exact setup (AD, authentik, the existing picture pipeline) that does four things well in one container, instead of four partial solutions. It is also configurable throughout, so it is not tied to one domain. + +## Who This Serves + +- **Users** are about ten family members and friends of the homelab, with mixed technical skill. They want to change their own details without asking anyone. +- **The admin** (the homelab owner) wants to stop doing manual password and profile changes and wants an announcement channel. This person is also the main builder and operator. + +## Success Criteria + +- Users change their own passwords without admin involvement. [ASSUMPTION: "manual" means known-password changes only; forgotten passwords stay manual, see Open Questions.] +- Users update their own profile fields and pictures without admin involvement. +- Every planned downtime or change can be announced through the portal, and opted-in users receive it by e-mail. +- Nothing is hard-coded to ping-mee: domain, OU, group, share path, SMTP and theme all come from configuration. +- The portal runs safely on the internet. Concretely: injection attacks (LDAP, SQL, script) do not work, a stolen or hijacked session does not give lasting access, and a signed-in user can never read or write another user's data. Logins are throttled. + +## Scope + +**In for the first version (everything below is wanted, sequencing is for later):** +- Login via OIDC (authentik). +- Password change with the current password. +- Profile edit for `givenName`, `sn`, `displayName`, `mail`, `telephoneNumber` and `mobile`. +- E-mail change with password and confirmation link. +- Picture upload, conversion and rename to `.jpg` on the share. +- Announcements: text, e-mail and portal page, with user opt-in (default) and opt-out. Opting out shows a notice that downtime notifications will stop and that the user can opt back in. Severity, downtime windows and expiry are optional features. +- Admin UI for themes, backgrounds and non-secret settings. +- Configuration and secrets via Docker environment variables. +- Delivery as a Docker container on a Linux host. +- Login throttling. + +**Out:** +- Password reset for forgotten passwords. The admin does this manually. +- Editing `cn` or `name`, which means no object renames. +- Multi-domain support. +- Replacing the PowerShell scripts, which stay as they are. + +## Open Questions and Risks + +- **Expired passwords.** The admin wants users with expired or "must change" passwords to still be able to log in and change them. Login goes through authentik, and it may refuse those users. How authentik behaves here is unknown and has to be tested before the design is fixed. +- **Forgotten passwords.** They still need the admin, so "no manual password work" is only partly true. Confirm that this is acceptable. +- **Internet exposure.** The portal holds an AD service account and is reachable from outside. This is the largest risk and gets its own design attention. +- **Scope.** Shipping everything in one go delays the first benefit to users. + +## Vision + +A single, polished home for everything a user needs from the homelab's identity system, and the first place the admin looks when something changes. If it works well in this homelab, it could be released for other AD-based homelabs, because nothing in it assumes ping-mee. diff --git a/_bmad-output/initiative-ping-mee-portal/epic-account-self-service/epic-account-self-service.md b/_bmad-output/initiative-ping-mee-portal/epic-account-self-service/epic-account-self-service.md new file mode 100644 index 0000000..27d91e0 --- /dev/null +++ b/_bmad-output/initiative-ping-mee-portal/epic-account-self-service/epic-account-self-service.md @@ -0,0 +1,46 @@ +--- +tracker_id: "" +key: "" +type: epic +title: "A user manages their own account" +parent: initiative-ping-mee-portal +covers: [CAP-2, CAP-3, CAP-4] +after: [] +assignee: "" +risk: high +--- + +# A user manages their own account + +## Description + +Users change their own password, profile details, e-mail address and profile picture in the live portal. This is the main reason the portal exists: it removes the admin from known-password changes and routine profile edits. + +## Outcome + +Family and friends change their own password, details and picture without the admin; the first part of the spec's success signal. + +## Done when + +1. A user changes their password with the AD password policy shown first, other sessions end, and a confirmation e-mail arrives. +2. A user edits first name, last name, display name, telephone and mobile, and the values appear in AD. +3. An e-mail change applies only after the current password and a confirmation from the new address, and the old address receives a 24-hour undo link whose use ends sessions and notifies the Admin. +4. A user uploads a JPEG, PNG, HEIC or WebP picture up to 10 MB, crops it, sees Pending until the sync task has processed it, and can remove it. +5. Injection strings and attempts on another user's data change nothing, and every action leaves an audit entry. + +## Boundaries + +The password, profile, e-mail and picture features and the share adapter. Not announcements or Admin pages, and no change to the PowerShell scripts. Touch point owned here: the picture file share and sync task. + +## References + +- parent — _bmad-output/initiative-ping-mee-portal/initiative-ping-mee-portal.md, section Requirements +- spec — _bmad-output/specs/spec-ping-mee-self-service-platform/SPEC.md, CAP-2, CAP-3, CAP-4 +- architecture — ARCHITECTURE-SPINE.md, AD-2, AD-3, AD-4, AD-8, AD-9, AD-10 +- design — DESIGN.md and EXPERIENCE.md; mock-ups key-password.html and key-picture-pending.html +- constraint — SPEC.md, section Constraints + +## Notes + +- Waits on epic-platform-and-sign-in because: it needs sign-in, sessions, the directory adapter, throttling, the audit service and the mail outbox. +- Not yet sliced: this epic is broken into stories when it is selected. diff --git a/_bmad-output/initiative-ping-mee-portal/epic-announcements-and-administration/epic-announcements-and-administration.md b/_bmad-output/initiative-ping-mee-portal/epic-announcements-and-administration/epic-announcements-and-administration.md new file mode 100644 index 0000000..f3951e8 --- /dev/null +++ b/_bmad-output/initiative-ping-mee-portal/epic-announcements-and-administration/epic-announcements-and-administration.md @@ -0,0 +1,47 @@ +--- +tracker_id: "" +key: "" +type: epic +title: "The admin announces changes and runs the portal" +parent: initiative-ping-mee-portal +covers: [CAP-5, CAP-6, CAP-7, CAP-8, CAP-10] +after: [] +assignee: "" +risk: medium +--- + +# The admin announces changes and runs the portal + +## Description + +Publishers announce planned downtime and changes, users receive them by e-mail and see them on the dashboard, and the Admin manages the look, reads the audit log and watches the status of backups, mail and background jobs. It finishes the portal and proves it ready for the internet. + +## Outcome + +Every planned downtime reaches opted-in users by e-mail and banner, and the admin can see that the portal is healthy; the second part of the spec's success signal. + +## Done when + +1. A Publisher writes an announcement with an always-set severity and optional window, expiry and schedule, and it appears as a banner and in the list at the right time. +2. Every user in the OU with a valid e-mail address and no opt-out receives it, and the Publisher sees counts and reasons beforehand. +3. The dashboard shows active announcements and the account card including the password date. +4. The Admin changes the accent colour with a contrast warning, uploads a background, edits settings, reads the audit log and sees backup age, failed e-mails and job runs. +5. Consistent backup files appear on schedule, the nightly AD reconciliation runs, and audit retention applies. +6. Security tests (injection, cross-user, throttling, sessions) and the accessibility pass succeed, and a smoke checklist exists for the test OU. + +## Boundaries + +Announcements, announcement e-mail, the dashboard, the Admin pages, the maintenance jobs and the final hardening. Not account features (epic-account-self-service). Touch point owned here: the Veeam copy job that copies the backup folder. + +## References + +- parent — _bmad-output/initiative-ping-mee-portal/initiative-ping-mee-portal.md, section Requirements +- spec — _bmad-output/specs/spec-ping-mee-self-service-platform/SPEC.md, CAP-5, CAP-6, CAP-7, CAP-8, CAP-10 +- architecture — ARCHITECTURE-SPINE.md, AD-4, AD-11 to AD-16 +- design — DESIGN.md and EXPERIENCE.md; mock-ups key-announcement-editor.html, key-admin-appearance.html, key-admin-status.html, key-admin-audit.html, key-dashboard.html +- constraint — SPEC.md, section Constraints + +## Notes + +- Waits on epic-account-self-service because: the dashboard account card shows picture status and uses the profile data it creates. +- Not yet sliced: this epic is broken into stories when it is selected. diff --git a/_bmad-output/initiative-ping-mee-portal/epic-platform-and-sign-in/epic-platform-and-sign-in.md b/_bmad-output/initiative-ping-mee-portal/epic-platform-and-sign-in/epic-platform-and-sign-in.md new file mode 100644 index 0000000..11d927d --- /dev/null +++ b/_bmad-output/initiative-ping-mee-portal/epic-platform-and-sign-in/epic-platform-and-sign-in.md @@ -0,0 +1,59 @@ +--- +tracker_id: "" +key: "" +type: epic +title: "A user can sign in to the live portal" +parent: initiative-ping-mee-portal +covers: [CAP-1, CAP-9] +after: [] +assignee: "" +risk: high +--- + +# A user can sign in to the live portal + +## Description + +The platform baseline: the container, configuration, the Active Directory adapter, sign-in through authentik, sessions with roles, the page shell with preferences, and the scheduler with the mail outbox. When it is done, a person signs in to the portal running on the Linux host and sees their own name from Active Directory in the new layout; every later epic builds on it. + +## Outcome + +The admin can sign in to the live portal through authentik and see their own name from AD in the page shell, which proves every layer connects. + +## Requirements + +Owned from the spec: CAP-1 (sign-in, roles, sessions, throttling base) and the shell and preference parts of CAP-9. Groundwork for CAP-10 (container, configuration, health) and CAP-8 (audit foundation) is laid here; their completion is in the third epic. + +- CAP-1 — spec SPEC.md, Capabilities (PRD FR-1 to FR-4) +- CAP-9, shell part — PRD FR-29, FR-31; EXPERIENCE.md Foundation and Information Architecture +- CAP-10, groundwork — PRD FR-26, FR-27; architecture AD-13, AD-18 + +## Done when + +1. A user in the test OU signs in through the portal's own OIDC application in authentik and lands in the page shell showing their name from AD. +2. A user outside the Publisher and Admin groups cannot open Publisher or Admin pages by direct address; a disabled or locked AD account is refused. +3. Sessions end after 1 hour idle and 4 hours total; repeated sensitive requests are refused before any AD call. +4. The container starts only with valid environment variables, answers its health check without AD, and runs on the Linux host behind Zoraxy. +5. Theme, language and German form of address can be chosen and are kept per device; the layout works on a phone. +6. The scheduler ticks and the mail outbox sends a test e-mail through the configured SMTP server. + +## Boundaries + +The portal's platform and sign-in only. Not profile, password or picture features (epic-account-self-service), not announcements or the Admin pages (epic-announcements-and-administration). Touch points owned here: Active Directory, authentik, Zoraxy, IONOS mail. + +## References + +- parent — _bmad-output/initiative-ping-mee-portal/initiative-ping-mee-portal.md, section Requirements +- spec — _bmad-output/specs/spec-ping-mee-self-service-platform/SPEC.md, CAP-1, CAP-9, CAP-10 +- architecture — _bmad-output/architecture/architecture-ping-mee-self-service-platform-2026-10-15/ARCHITECTURE-SPINE.md, AD-1 to AD-7, AD-11 to AD-13, AD-15, AD-18, AD-19 +- design — _bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/DESIGN.md and EXPERIENCE.md, mock-ups key-dashboard.html +- constraint — SPEC.md, section Constraints + +## Notes + +- Decision: tracer bullet is the scaffold followed by sign-in; the first visible result is the user's name in the shell at entry 6 (2026-10-15). +- Decision: a closing Refactor sweep is proposed by default; no end-to-end suite for this epic unless the user asks (2026-10-15). +- Decision: the checkpoints agreed in the spec's stories carry over: plan review on entries 1, 4 and 5, and a pause after entry 5 (user, 2026-10-15). +- Assumption: Zoraxy appends the real client IP as the last `X-Forwarded-For` entry; to be checked with a test request when the container is first deployed behind it. +- Assumption: the exact AD attribute names for the password policy are verified in the test OU during entry 4. +- Source conflict: none found. diff --git a/_bmad-output/initiative-ping-mee-portal/epic-platform-and-sign-in/tickets.toml b/_bmad-output/initiative-ping-mee-portal/epic-platform-and-sign-in/tickets.toml new file mode 100644 index 0000000..a08508b --- /dev/null +++ b/_bmad-output/initiative-ping-mee-portal/epic-platform-and-sign-in/tickets.toml @@ -0,0 +1,101 @@ +# Breakdown of epic-platform-and-sign-in. The order of the tables is the build order. + +[[entry]] +id = 1 +type = "story" +title = "Project scaffold, configuration and container" +description = "Creates the Django project, environment-variable configuration that refuses to start when invalid, the multi-stage Docker image and compose file, a health check that does not call AD, JSON logging, security headers with a strict content-security policy, and the test setup with adapter fakes." +verify = "The container starts with valid environment variables, refuses to start with an invalid one, and answers its health check on the Linux host." +covers = ["CAP-10"] +after = [] +hitl = false +risk = "medium" +plan_checkpoint = true +references = ["_bmad-output/architecture/architecture-ping-mee-self-service-platform-2026-10-15/ARCHITECTURE-SPINE.md#ad-13", "_bmad-output/architecture/architecture-ping-mee-self-service-platform-2026-10-15/ARCHITECTURE-SPINE.md#ad-18"] + +[[entry]] +id = 2 +type = "story" +title = "Prepare the test environment" +description = "A person creates a test OU in the production AD with a service account scoped to it and the delegated rights from the spine, supplies the internal CA bundle, creates a dedicated OIDC application in the production authentik restricted to test users, notes the Zoraxy route and trusted-proxy value, and prepares a test mailbox." +verify = "The service account can read and write a test user in the test OU over LDAPS, the authentik application lets a test user sign in, and the values are written down for the build." +covers = ["CAP-1"] +after = [] +hitl = true +risk = "medium" +notes = ["Authentik is production only; never change an existing authentik application.", "I can set up a test OU, but not a separate authentik."] + +[[entry]] +id = 3 +type = "story" +title = "Audit, throttle and session foundations" +description = "Builds the audit service, database-backed throttle counters, and server-side sessions with the username in its own column and a kill-by-user function; the other epics write audit entries and end sessions through these." +verify = "Tests show an audit entry is written in the same transaction as a change, a throttle refuses after its limit, and ending a user's sessions removes every session of that user." +covers = ["CAP-1", "CAP-8"] +after = [1] +hitl = false +risk = "medium" +references = ["_bmad-output/architecture/architecture-ping-mee-self-service-platform-2026-10-15/ARCHITECTURE-SPINE.md#ad-4", "_bmad-output/architecture/architecture-ping-mee-self-service-platform-2026-10-15/ARCHITECTURE-SPINE.md#ad-6", "_bmad-output/architecture/architecture-ping-mee-self-service-platform-2026-10-15/ARCHITECTURE-SPINE.md#ad-7"] + +[[entry]] +id = 4 +type = "story" +title = "Directory adapter" +description = "Builds the only code that talks to AD: lookup by sAMAccountName in the configured OU, group membership, account state, password verification, password policy read and the allow-listed writes, with an in-memory fake; sign-in and the account features call its interface." +verify = "Against the test OU the adapter reads and writes allowed attributes over LDAPS with certificate verification, refuses others, and returns the password policy." +covers = ["CAP-1", "CAP-2", "CAP-3"] +after = [1, 2] +hitl = false +risk = "high" +plan_checkpoint = true +references = ["_bmad-output/architecture/architecture-ping-mee-self-service-platform-2026-10-15/ARCHITECTURE-SPINE.md#ad-1", "_bmad-output/architecture/architecture-ping-mee-self-service-platform-2026-10-15/ARCHITECTURE-SPINE.md#ad-2"] +notes = ["Test only against the test OU, never the production OU."] + +[[entry]] +id = 5 +type = "story" +title = "Sign-in with OIDC and roles" +description = "Implements sign-in through the identity provider with the authorization-code flow, session creation, roles from AD group membership with a short cache, refusal of disabled or locked accounts, and the trusted-proxy client IP rule, using the directory adapter's interface." +verify = "A test user signs in through authentik and reaches a page showing their own name; a user outside the Publisher and Admin groups cannot open those pages; a disabled account is refused." +covers = ["CAP-1"] +after = [2, 3, 4] +hitl = false +risk = "high" +plan_checkpoint = true +done_checkpoint = true +references = ["_bmad-output/architecture/architecture-ping-mee-self-service-platform-2026-10-15/ARCHITECTURE-SPINE.md#ad-5", "_bmad-output/architecture/architecture-ping-mee-self-service-platform-2026-10-15/ARCHITECTURE-SPINE.md#ad-7"] + +[[entry]] +id = 6 +type = "story" +title = "Page shell, preferences and translations" +description = "Builds the base layout with the Fluent components bundled into the image: top bar, left menu with role-based groups, avatar menu for theme, language and German form of address, German and English text, light and dark themes, and the phone layout." +verify = "After sign-in the shell shows the user's name, the menu groups match their role, the three preferences persist per device, and the layout works at phone width." +covers = ["CAP-9"] +after = [5] +hitl = false +risk = "medium" +references = ["_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/mockups/key-dashboard.html", "_bmad-output/architecture/architecture-ping-mee-self-service-platform-2026-10-15/ARCHITECTURE-SPINE.md#ad-15", "_bmad-output/architecture/architecture-ping-mee-self-service-platform-2026-10-15/ARCHITECTURE-SPINE.md#ad-16"] + +[[entry]] +id = 7 +type = "story" +title = "Scheduler and mail outbox" +description = "Builds the single 30-second scheduler tick with idempotent jobs and job-run records, the mailer adapter with the outbox, retries, backoff and rate limit, and a fake mailer; other epics only call its enqueue function." +verify = "A queued test e-mail is sent through the configured SMTP server, a failed send is retried and recorded, and a restart sends nothing twice." +covers = ["CAP-6", "CAP-10"] +after = [3] +hitl = false +risk = "medium" +references = ["_bmad-output/architecture/architecture-ping-mee-self-service-platform-2026-10-15/ARCHITECTURE-SPINE.md#ad-11", "_bmad-output/architecture/architecture-ping-mee-self-service-platform-2026-10-15/ARCHITECTURE-SPINE.md#ad-12"] + +[[entry]] +id = 8 +type = "story" +title = "Refactor sweep" +description = "Cleans up the epic's code using the plans and the review findings deferred during the epic; scope pushed out of another story is a new story, not part of this one." +verify = "The test suite still passes and the deferred review findings from entries 1 to 7 are resolved or recorded." +covers = [] +after = [1, 2, 3, 4, 5, 6, 7] +hitl = false +risk = "low" diff --git a/_bmad-output/initiative-ping-mee-portal/initiative-ping-mee-portal.md b/_bmad-output/initiative-ping-mee-portal/initiative-ping-mee-portal.md new file mode 100644 index 0000000..6b9086d --- /dev/null +++ b/_bmad-output/initiative-ping-mee-portal/initiative-ping-mee-portal.md @@ -0,0 +1,63 @@ +--- +tracker_id: "" +key: "" +type: initiative +title: "Users run their own account and the admin has one announcement channel" +parent: none +covers: [CAP-1, CAP-2, CAP-3, CAP-4, CAP-5, CAP-6, CAP-7, CAP-8, CAP-9, CAP-10] +after: [] +assignee: "" +risk: high +--- + +# Users run their own account and the admin has one announcement channel + +## Description + +The ping-mee Self-Service Portal lets about ten homelab users change their own password, details and profile picture, and lets the admin announce planned downtime by e-mail and on a page. The spec owns the capabilities, constraints and non-goals; this initiative delivers them to production, internet-facing, in a form that can later be deployed elsewhere. + +## Outcome + +Family and friends change their own password, details and picture without the admin, and every planned downtime reaches opted-in users by e-mail and banner; the spec's success signal is the measure. + +## Requirements + +The numbered spec is the requirement source: CAP-1 to CAP-10 in `_bmad-output/specs/spec-ping-mee-self-service-platform/SPEC.md`. Requirement-level detail (FR-1 to FR-32) is in its PRD companion. + +## Done when + +1. A family member changes their own password, name, phone number and picture in the live portal with no admin help. +2. A scheduled downtime announcement reaches every opted-in user by e-mail and shows as a banner on the dashboard. +3. Injection, cross-user access and brute-force attempts against the live portal fail and appear in the audit log. +4. The portal runs from one Docker image configured only by environment variables on the Linux host behind the reverse proxy. +5. Consistent database backup files appear in the backup folder and the status page shows their age. +6. Every capability CAP-1 to CAP-10 is live for all users, not behind a flag. + +## Boundaries + +The portal and its adapters. Not an Active Directory admin console, not a password-reset flow, not a replacement for the PowerShell picture scripts or the identity provider; see the spec's non-goals. Tracer path: sign in through authentik, look up the user in the test OU, and see their name in the new page shell. + +- Touch point: Active Directory (LDAPS) — the portal reads and writes through a service account; owner: epic-platform-and-sign-in +- Touch point: authentik (production) — a dedicated OIDC application for the portal; owner: epic-platform-and-sign-in +- Touch point: Zoraxy reverse proxy — trusted-proxy and client-IP settings; owner: epic-platform-and-sign-in +- Touch point: IONOS mail — SMTP credentials for the outbox; owner: epic-platform-and-sign-in +- Touch point: picture file share and the PowerShell sync task — write source files, read processed hashes, no script changes; owner: epic-account-self-service +- Touch point: Veeam copy job — copies the backup folder; owner: epic-announcements-and-administration + +## References + +- spec — _bmad-output/specs/spec-ping-mee-self-service-platform/SPEC.md, section Capabilities +- spec companion — _bmad-output/prds/prd-ping-mee-self-service-platform-2026-10-15/prd.md, sections Features and Cross-Cutting Requirements +- architecture — _bmad-output/architecture/architecture-ping-mee-self-service-platform-2026-10-15/ARCHITECTURE-SPINE.md, section Invariants & Rules (the decisions every epic adopts: AD-1 to AD-19) +- design — _bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/DESIGN.md and EXPERIENCE.md, with the mock-ups in mockups/ +- constraint — SPEC.md, section Constraints + +## Notes + +- Decision: three epics cut by "usable early": platform and sign-in, then account self-service, then announcements and administration (user, 2026-10-15). +- Decision: the spec stays in `_bmad-output/specs/spec-ping-mee-self-service-platform/` and is cited by path, because its companions use relative links. +- Decision: the epic rule "split only for a distinct outcome, owner or a part usable early" is saved for later slicing (user, 2026-10-15). +- Decision: no spike; the user is not worried about any one area (2026-10-15). +- Assumption: the test environment is a test OU in the production AD with a scoped service account, and a dedicated OIDC application in the production authentik restricted to test users; there is no staging environment. +- Parked: nothing from the spec is deferred. +- Waits on epic-platform-and-sign-in because: every other epic uses its sign-in, directory adapter, sessions and mail outbox. diff --git a/_bmad-output/initiative-ping-mee-portal/tickets.toml b/_bmad-output/initiative-ping-mee-portal/tickets.toml new file mode 100644 index 0000000..917f4e6 --- /dev/null +++ b/_bmad-output/initiative-ping-mee-portal/tickets.toml @@ -0,0 +1,21 @@ +# Epics of the initiative, in build order. + +[[epic]] +id = 1 +slug = "epic-platform-and-sign-in" +title = "A user can sign in to the live portal" +covers = ["CAP-1", "CAP-9"] + +[[epic]] +id = 2 +slug = "epic-account-self-service" +title = "A user manages their own account" +covers = ["CAP-2", "CAP-3", "CAP-4"] +after = [{ epic = 1, needs = "sign-in and sessions, the directory adapter, throttling, the audit service and the mail outbox" }] + +[[epic]] +id = 3 +slug = "epic-announcements-and-administration" +title = "The admin announces changes and runs the portal" +covers = ["CAP-5", "CAP-6", "CAP-7", "CAP-8", "CAP-10"] +after = [{ epic = 2, needs = "the profile and picture data the dashboard account card shows" }] diff --git a/_bmad-output/prds/prd-ping-mee-self-service-platform-2026-10-15/.memlog.md b/_bmad-output/prds/prd-ping-mee-self-service-platform-2026-10-15/.memlog.md new file mode 100644 index 0000000..aa3ec40 --- /dev/null +++ b/_bmad-output/prds/prd-ping-mee-self-service-platform-2026-10-15/.memlog.md @@ -0,0 +1,38 @@ +--- +topic: ping-mee self-service portal PRD +updated: 2026-10-15T10:22 +--- + + +- (event) Create intent. Input: brief at _bmad-output/briefs/brief-ping-mee-self-service-platform-2026-10-15/ (brief.md + addendum.md). context-resources/ PowerShell scripts were read earlier but the folder is missing from disk now (untracked; likely removed by elevated cleanup). +- (decision) Stakes: internal tool built to a higher standard (homelab first, option to deploy elsewhere). Mode: COACHING path, entry point Vision + Features; short journeys for user, publisher, admin. +- (decision) User approved web research on authentik behavior with expired/must-change AD passwords and password write-back. +- (event) Research: authentik LDAP source supports 'User password writeback' to AD over ldaps (needs delegated rights); AD returns bind error 49/data 532 (expired) / 773 (must reset) which blocks the bind; authentik AD docs say nothing on expired-password handling. +- (decision) Expired/must-change passwords: OUT of portal. User contacts admin, admin resets in AD, then user logs in and changes it. No separate expired-password page. (Resolves brief open question.) +- (decision) Roles: three separate concepts - user, announcement publishers (configurable AD group), admin (a SEPARATE configurable AD group). +- (assumption) Vision draft and 7-feature list (sign-in, password change, profile edit, picture, announcements, admin UI, platform/ops) presented; user answered only the two push-backs - treated as accepted unless corrected. +- (decision) Sign-in/password: OIDC identity maps to AD via preferred_username == sAMAccountName (confirmed identical). Roles via AD group membership checked by portal. Session lifetime: 1 hour. +- (decision) Password change: per-user failed-attempt cap before touching AD (e.g. 5 then pause; make configurable - agreed); after change, end user's other sessions AND send 'password changed' e-mail. +- (decision) Profile: displayName is free text. E-mail change: notify OLD address and include an UNDO link valid 24h; confirmation link to NEW address (1h assumed, single use). +- (decision) Pictures: accept jpeg, png, svg, heic, webp, max 10MB; in-browser crop/edit tool wanted; output square JPEG ~512px (assumed; sync makes 96/448 itself from source). Show current picture read from AD attribute with PENDING status until new picture is live. User can remove picture (delete .jpg -> sync falls back to default). +- (decision) Audit log of profile/password/e-mail/picture/announcement actions wanted. +- (assumption) SVG upload is a security risk (XSS/XXE/external refs); must be rasterized server-side in a sandbox, original never served - to confirm with user. +- (assumption) Pending detection idea: sync script writes processed/.md5 = MD5 of the source file; portal can compare with MD5 of file it wrote (needs read access to processed path) - to confirm. +- (decision) Pending status via processed/.md5 comparison accepted; dependency: PowerShell sync script stays unchanged; portal needs read on processed path + write on source path (both configurable). +- (decision) SVG REMOVED from accepted picture formats (security). Supported: jpeg, png, heic, webp, 10MB. +- (decision) E-mail undo link: ends all user sessions, tells user to change password, notifies admin. +- (decision) Audit log: 12 months retention (configurable), readable by admins in admin UI only; records who/what/when/IP, never passwords or picture contents. +- (decision) Announcements: publisher writes title+body (plain text + Markdown, rendered safely/sanitized); draft or publish; optional severity (info/warning/outage), downtime window, expiry; edit/withdraw; scheduled publish time wanted; edit after send => explicit 'notify users again' option (no auto re-send). +- (decision) Announcements readable only when signed in (no public status page; e-mail covers portal-down case). Active ones banner on home page. Opt-in default; opt-out = receive nothing, no outage exception. +- (decision) Admin UI: runtime = cosmetic/behaviour settings only (theme, backgrounds, portal name, session length, attempt cap, audit retention). Environment-only = anything identifying a system or holding a secret incl. AD/LDAPS, service account, SMTP, OIDC, paths, and the publisher/admin group names. Admin background uploads re-encoded + size-limited and served publicly (login page). Admins cannot edit other users' profile/picture/password; admin manages only the portal. +- (assumption) Feature 7 unanswered by user, set as assumptions: embedded DB (SQLite) in mounted volume; interface languages TBD (German/English); WCAG 2.1 AA target; env-var config; structured logs; secure cookies/CSRF/headers. +- (change) Draft PRD v1 written (prd.md + addendum.md): 29 FRs across 7 features, UJ-1..4, SM-1..5 + counter-metrics, Q-1..Q-7. +- (event) Reviewer gate: rubric walker run inline, review-rubric.md written. Grade Good; 3 high (FR-4 undefined, FR-10/11 GET links, recipient list), ~9 medium, 3 low. +- (change) Review fixes applied: FR-4 rewritten (endpoints, own limits, OIDC throttles sign-in); POST-confirm for e-mail confirm/undo links; backgrounds signed-in only (resolves §2.2/FR-22 conflict); disabled/locked account consequence in FR-1; glossary + Audit Log, Draft, Withdrawn, Severity, Downtime Window; Vision casing. Deferred Q-8 (time zone), Q-10 (md5 timing). Q-9 recipient list pending user decision. +- (decision) Q-9 resolved (option A): announcement recipients = all Users in the OU with a valid mail value, signed in or not, minus explicit opt-outs; users without mail are skipped and recorded; portal stores only opt-outs. Q-9 removed from open questions (numbering gap kept: Q-1..Q-8, Q-10). +- (override) Supersedes earlier admin-UI entry: backgrounds are served to signed-in Users only (not publicly/login page). Supersedes picture entry: SVG removed. +- (event) Memlog audit done: all decisions captured in prd.md or addendum.md; superseded entries noted. context-resources/ restored by user and verified against addendum (md5 at line 114 before AD write at 117; uppercase hex, trailing newline). +- (event) Input reconciliation (brief, brief addendum, scripts) done: no dropped requirements; only Office 365 look is thin (one assumption in FR-22/§8.4) - handed to bmad-ux. +- (decision) Deferred open questions (owner Ebbe, revisit at bmad-architecture): Q-1 share unreachable, Q-2 mail retry/IONOS limits, Q-3 languages, Q-4 absolute session max, Q-5 data store/backup, Q-6 removed-AD-user cleanup, Q-7 removed-picture pending, Q-8 time zone, Q-9 md5 timing. None block UX. +- (event) Polish done (section refs, assumptions index); PRD finalized +- (change) Update pass from UX run: FR-5 reads Password Policy from AD; FR-16 rich-text editor stored as Markdown; FR-17 severity required (default information); FR-20 recipient summary; FR-22 accent colour + contrast warning; FR-29 languages resolved (de+en, formal/informal), Q-3 resolved; NEW FR-30 dashboard, FR-31 per-device preferences, FR-32 status page (grouped in 4.8); glossary Password Policy, Preference; addendum read rights. diff --git a/_bmad-output/prds/prd-ping-mee-self-service-platform-2026-10-15/addendum.md b/_bmad-output/prds/prd-ping-mee-self-service-platform-2026-10-15/addendum.md new file mode 100644 index 0000000..7573438 --- /dev/null +++ b/_bmad-output/prds/prd-ping-mee-self-service-platform-2026-10-15/addendum.md @@ -0,0 +1,45 @@ +# Addendum: Technical Context for Downstream Documents + +Carries the detail that does not belong in `prd.md`. The brief's addendum (`_bmad-output/briefs/.../addendum.md`) holds the original context; this file adds what the PRD discovery produced. + +## Existing picture pipeline (the three PowerShell scripts stay unchanged) +- `AD-ProfilePicture-Sync.ps1` (DC, scheduled task): for every user in the target OU reads `.jpg` from the source path, else a default image. It writes `_96.jpg` and `_448.jpg` to the processed path, writes `.md5` (MD5 of the source file) there, and sets `thumbnailPhoto`, `jpegPhoto` and `photo` from the 96px copy (limit 100 KB). It never compares the MD5, so every run reprocesses every user. +- `Set-LocalAccountPicture.ps1` (logon): builds Windows account-picture sizes from `thumbnailPhoto`; large sizes use the 448px copy from the share. +- `Set-AccountPictureRegistry-System.ps1` (startup): applies registry entries from manifests. +- The original scripts are in `context-resources/` (untracked in git). Verified against them: the MD5 file is written at line 114 and the AD attributes at line 117, so a failed AD write still leaves a matching MD5 file. + +## Portal and the pipeline: contract +- Portal writes: `/.jpg`, square JPEG, at least 448 px (assumed 512×512). +- Portal reads: `/.md5` to detect that the Sync Task has processed the file (the hash is the MD5 of the Portal's file). This ties the Portal to the unchanged script. +- The hash in `.md5` is an uppercase hex MD5 written with a trailing newline (`Get-FileHash` plus `Set-Content`). Compare case-insensitively and trim whitespace. +- The sync script covers `OU=Organisation`, which includes the Portal's writable OU. File names use `sAMAccountName`. +- Portal reads: `thumbnailPhoto` from Active Directory to show the current picture. +- Both paths are configuration. + +## Active Directory +- Writable OU: configurable; example in the homelab: `OU=Clients,OU=Organisation,DC=ping-mee,DC=local`. +- Attributes written: `givenName`, `sn`, `displayName`, `mail`, `telephoneNumber`, `mobile`, and the password via LDAPS. +- The Service Account needs delegated rights for exactly those attributes and password change on that OU. Password writes need LDAPS. +- Read rights also needed: `pwdLastSet`, account enabled and locked state, group membership, `thumbnailPhoto`, and the Password Policy shown on the password page (domain `minPwdLength`, `pwdProperties`, `pwdHistoryLength`, and the user's `msDS-ResultantPSO` for a fine-grained policy). [Attribute names to verify in the test OU.] +- Expired and "must change" passwords: AD refuses the bind (LDAP error 49, data 532 or 773). The Portal does not handle these. The Admin resets the password in AD. +- authentik's LDAP source offers its own password writeback to AD (needs `ldaps://` and delegated rights). Not used; the Portal changes passwords itself. Source: authentik Active Directory and LDAP source docs. + +## Identity and roles +- OIDC via authentik, but kept generic so another provider works. +- `preferred_username` equals `sAMAccountName` (confirmed identical in the homelab). +- Roles: the Portal checks Publisher and Admin group membership in Active Directory. + +## Security notes +- Throttling happens in front of every bind, to avoid lockouts by attackers. +- E-mail change chain risk (hijacked session, e-mail change, reset via e-mail) mitigated by: current password required, confirmation to the new address, notice and 24-hour Undo Link to the old address, undo ends sessions and notifies the Admin. +- Picture handling: HEIC, WebP, PNG and JPEG only; SVG rejected because it can carry scripts and external references. Decode with size and pixel limits; strip metadata; never serve originals. +- Markdown in Announcements is rendered with sanitization. +- Secrets only in Docker environment variables. + +## Candidate technical choices (not decided) +- Embedded database (for example SQLite) in a mounted volume is enough for ten users; a server database could be a later option. +- A server-side image library with HEIC support is required. +- SMB access from a Linux container: a mounted share is the simplest. + +## Design +- Office 365 (Fluent) look, not OWA 2010 or Aero. Themes and backgrounds are Admin-managed. diff --git a/_bmad-output/prds/prd-ping-mee-self-service-platform-2026-10-15/prd.md b/_bmad-output/prds/prd-ping-mee-self-service-platform-2026-10-15/prd.md new file mode 100644 index 0000000..ef9008f --- /dev/null +++ b/_bmad-output/prds/prd-ping-mee-self-service-platform-2026-10-15/prd.md @@ -0,0 +1,446 @@ +--- +title: "PRD: ping-mee Self-Service Portal" +status: final +created: 2026-10-15 +updated: 2026-10-15 +--- + +# PRD: ping-mee Self-Service Portal +*Working title — confirm.* + +## 0. Document Purpose + +This PRD defines the ping-mee Self-Service Portal for the homelab owner, who is also the builder, and for the downstream workflows (UX, architecture, epics and stories). It builds on the product brief in `_bmad-output/briefs/brief-ping-mee-self-service-platform-2026-10-15/`. Vocabulary is anchored in the Glossary (§3). Features are grouped in §4 with globally numbered requirements nested under them. Inferred points are tagged `[ASSUMPTION]` inline and indexed in §10. Look and behavior are specified in `_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/` (`DESIGN.md`, `EXPERIENCE.md`), which this PRD does not duplicate. Technical choices live in `addendum.md`, not here. + +## 1. Vision + +Every User of the ping-mee homelab can manage their own account in one clean place: change their password, correct their name and contact details, and set a profile picture. The Admin has one channel to tell everyone about planned downtime and changes. + +Today every such change, however small, goes through the admin. No existing tool covers these four things together in an Active Directory homelab. The portal is built around the real setup (Active Directory, authentik, the existing picture pipeline), runs as one Docker container behind the existing reverse proxy, and is reachable from the internet. Nothing in it is tied to ping-mee: everything is configurable, so it can be deployed elsewhere later. The homelab is oversized for its ten users, and the portal is built to a standard that matches. + +## 2. Target User + +### 2.1 Jobs To Be Done +- As a **User**, I want to change my own password, details and picture without asking anyone, so that I stay in control of my account. +- As a **User**, I want to hear about planned downtime before it happens, so that I am not surprised when a service is gone. +- As the **Admin**, I want users to handle their own changes, so that I stop doing manual password and profile work. +- As the **Admin** and **Publisher**, I want one place to announce changes, so that nobody has to be told twice. +- As the **Admin**, I want the portal to be safe on the internet, so that exposing it does not put the Active Directory at risk. + +### 2.2 Non-Users (v1) +- Anyone without an Active Directory account in the configured OU. +- Users with an expired or "must change" password. They contact the Admin, who resets the password in Active Directory; they then sign in and change it themselves. +- Anonymous visitors. The portal shows them nothing except the sign-in redirect. + +### 2.3 Key User Journeys +*Drafted from the brief and the discovery conversation. Personas are illustrative. [ASSUMPTION: confirm or replace.]* + +- **UJ-1. Lena changes her password before it leaks.** + - **Persona + context:** Lena, a friend who uses Jellyfin, reuses a password and wants a fresh one. + - **Entry state:** not signed in; opens the portal address from a bookmark. + - **Path:** signs in through authentik; opens "Password"; enters her current password and the new one twice; submits. + - **Climax:** the portal confirms the change and signs her out of other sessions. + - **Resolution:** a "password changed" e-mail arrives; she uses the new password everywhere. + - **Edge case:** the new password breaks the domain's complexity rule; the portal shows the rule in plain language and nothing changes. + +- **UJ-2. Tom sets a profile picture from his iPhone.** + - **Persona + context:** Tom, family, has a HEIC photo and wants it as his avatar. + - **Entry state:** signed in, on "Profile". + - **Path:** chooses the photo; crops it square in the browser; saves. + - **Climax:** the page shows his current picture with a "pending" mark and the note that it updates within about two hours. + - **Resolution:** once the sync has run, the mark disappears and the new picture shows. + - **Edge case:** the file is 14 MB; the portal rejects it before upload and says why. + +- **UJ-3. Ebbe announces a maintenance window.** + - **Persona + context:** Ebbe, the homelab owner, in the Publisher group, plans a server move. + - **Entry state:** signed in, on "Announcements". + - **Path:** writes a title and Markdown text; sets severity to warning, the downtime window and an expiry; schedules the announcement for the next morning. + - **Climax:** at the scheduled time the announcement appears on the page and opted-in users receive the e-mail. + - **Resolution:** after the move, Ebbe withdraws the announcement. + - **Edge case:** he corrects the end time later; he edits the text and chooses "notify users again". + +- **UJ-4. Marie sees an e-mail change she did not make.** + - **Persona + context:** Marie finds an e-mail at her old address saying her address was changed. + - **Entry state:** she is not signed in; she reads the message on her phone. + - **Path:** opens the undo link within 24 hours. + - **Climax:** the old address is restored and all her sessions end. + - **Resolution:** she is told to change her password; the Admin gets a notification. + +## 3. Glossary +*Used verbatim throughout this PRD and by downstream workflows.* + +- **User** — a person with an Active Directory account in the configured OU who signs in to the portal. +- **Publisher** — a User who is a member of the configured Publisher group and may create Announcements. +- **Admin** — a User who is a member of the configured Admin group. The Admin group is separate from the Publisher group. +- **Portal** — the web application this PRD specifies. +- **Service Account** — the Active Directory account the Portal uses for LDAPS writes. Its rights are limited to the configured OU and a fixed list of attributes. +- **Profile Field** — one of: first name (`givenName`), last name (`sn`), display name (`displayName`), e-mail (`mail`), telephone number (`telephoneNumber`), mobile number (`mobile`). +- **Session** — a User's signed-in period in the Portal. It lasts up to 1 hour of inactivity. +- **Picture Source File** — the file `.jpg` that the Portal writes to the configured source path of the Share. +- **Share** — the file share where the Picture Source File is written; the Sync Task reads from it. +- **Sync Task** — the existing scheduled PowerShell task that turns Picture Source Files into the Active Directory picture attributes. It is not part of the Portal. +- **Pending** — a Picture Source File has been written but the Sync Task has not processed it yet. +- **Announcement** — a message from a Publisher with a title, a formatted body (edited in a rich-text editor, stored as Markdown), a Severity, and an optional Downtime Window, expiry and publish time. +- **Opt-in / Opt-out** — a User's choice to receive or not receive Announcement e-mails. New Users are opted in. +- **Audit Entry** — a record of who did what, when, and from which IP address. +- **Audit Log** — the collection of all Audit Entries, readable by Admins. +- **Draft** — an Announcement that is saved but not published; only Publishers see it. +- **Withdrawn** — an Announcement that was published and then taken down; Users no longer see it. +- **Severity** — the level of an Announcement: information, warning or outage. Every Announcement has one; the default is information. +- **Downtime Window** — the optional start and end time of a planned downtime stated in an Announcement. +- **Password Policy** — the password rules Active Directory enforces for a User (minimum length, complexity, history), read by the Portal and shown on the password page. +- **Preference** — a choice stored on the User's device, not on the server: theme (light, dark, system), language, and the German form of address (formal or informal). +- **Undo Link** — a single-use link, valid for 24 hours, sent to the old address after an e-mail change, that reverts the change. + +## 4. Features + +### 4.1 Sign-in and Sessions +**Description:** A User signs in through authentik using OIDC. The Portal maps the OIDC identity to the Active Directory account and decides the User's roles from group membership. Realizes UJ-1. + +**Functional Requirements:** + +#### FR-1: Sign in with OIDC +A User can sign in by being redirected to the configured OIDC provider and returning to the Portal. Realizes UJ-1. + +**Consequences (testable):** +- The Portal matches the OIDC `preferred_username` claim to `sAMAccountName` in the configured OU. +- A sign-in for a name not found in the OU is refused with a generic message. +- A User whose Active Directory account is disabled or locked cannot start a Session, and an existing Session of such a User ends at its next request. +- An unauthenticated request to any Portal page redirects to sign-in. + +**Out of Scope:** +- Signing in with a local password or directly against LDAPS. + +#### FR-2: Resolve roles +The Portal determines whether a User is a Publisher or an Admin by checking membership of the configured Publisher group and Admin group in Active Directory. + +**Consequences (testable):** +- A User in neither group sees no Announcement editor and no admin pages. +- Direct requests to Publisher or Admin pages by a User without the role return an authorization error. +- Group names come from configuration only. + +#### FR-3: Session lifetime and sign-out +A Session ends after 1 hour of inactivity or when the User signs out. The inactivity time is an Admin-changeable setting. [ASSUMPTION: 1 hour default; whether it also has an absolute maximum is open, see Q-4.] + +**Consequences (testable):** +- Requests after expiry redirect to sign-in. +- Sign-out ends the Session on the server, not only in the browser. + +#### FR-4: Throttle sensitive requests +The Portal limits repeated requests to password change (FR-5), e-mail change (FR-10), the confirmation and Undo Link pages (FR-10, FR-11), and picture upload (FR-12), per User and per IP address, before any call to Active Directory. Sign-in itself is throttled by the OIDC provider, not by the Portal. + +**Consequences (testable):** +- Each covered endpoint has its own configured limit and pause. [ASSUMPTION: defaults are chosen at design time.] +- Once a limit is reached, further requests to that endpoint are refused with a "try again later" message, without contacting Active Directory or the SMTP server. +- Throttled requests are recorded as Audit Entries (FR-28). +- Limits are configurable. +- Failed current-password checks follow the stricter per-User rule in FR-6. + +### 4.2 Password Change +**Description:** A User changes their own password by entering the current and the new password. The Portal verifies the current password and applies the new one over LDAPS. Active Directory's own complexity, history and age rules apply. Users with expired passwords are not served; they contact the Admin. Realizes UJ-1. + +**Functional Requirements:** + +#### FR-5: Change password +A User can change their password by entering the current password and the new password twice. Realizes UJ-1. + +**Consequences (testable):** +- Before the User types, the page lists the User's Password Policy as read from Active Directory (including a fine-grained policy that applies to the User). +- The current password is verified over LDAPS before the change is applied. +- When Active Directory rejects the new password, the Portal shows the reason in plain language. +- No password is written to logs, the audit log or error output. + +#### FR-6: Limit failed password attempts +The Portal counts failed current-password checks per User and refuses further attempts after a configured limit, with a configured pause. [ASSUMPTION: default 5 failures, then a 15-minute pause.] + +**Consequences (testable):** +- The limit applies before the bind to Active Directory, so it protects the account from lockout by the Portal. +- The limit and pause are configurable. + +#### FR-7: After a password change +After a successful change the Portal ends the User's other Sessions and sends a "password changed" e-mail to the User's address. + +**Consequences (testable):** +- All other Sessions of that User are invalid immediately. +- The e-mail names the time of the change and does not contain the password. + +### 4.3 Profile Data +**Description:** A User edits their Profile Fields. Every value is validated and escaped before it reaches Active Directory. An e-mail change is a sensitive action: it needs the current password, a confirmation from the new address, and a notice with an Undo Link to the old address. Realizes UJ-4. + +**Functional Requirements:** + +#### FR-8: View and edit Profile Fields +A User can view and edit first name, last name, display name, telephone number and mobile number. The username is shown and cannot be edited. + +**Consequences (testable):** +- Saved values appear in Active Directory under the matching attributes. +- A User can only read and write their own Profile Fields. +- The display name is free text. [ASSUMPTION: no further rules.] + +#### FR-9: Validate and escape input +The Portal validates length, characters and format of each Profile Field and escapes values for LDAP. + +**Consequences (testable):** +- LDAP filter and DN injection strings do not alter queries or write to other objects. +- Invalid input is rejected with a message naming the field. +- Phone number format is checked. [ASSUMPTION: international format with digits, spaces, `+`, `-` and brackets.] + +#### FR-10: Change e-mail with confirmation +A User can change their e-mail by entering the current password and the new address. The change applies only after the User opens a confirmation link sent to the new address. Realizes UJ-4. + +**Consequences (testable):** +- The current password is verified before the confirmation e-mail is sent, and counts against the failed-attempt limit in FR-6. +- The confirmation link is single-use and expires after 1 hour. [ASSUMPTION] +- Opening the link shows a page that names the pending change and applies it only after the User clicks a confirm button (POST). A plain GET of the link changes nothing, so mail scanners cannot confirm it. +- Until confirmed, `mail` in Active Directory is unchanged. + +#### FR-11: Notify the old address and offer undo +When an e-mail change is applied, the Portal sends a notice to the old address that includes an Undo Link valid for 24 hours. Realizes UJ-4. + +**Consequences (testable):** +- Opening the Undo Link shows a page that names the change and restores the old address in Active Directory only after the User clicks a confirm button (POST). A plain GET of the link changes nothing, so mail scanners cannot trigger an undo. +- Undo also ends all Sessions of that User, tells the User to change their password, and notifies the Admin. +- The Undo Link works once and expires after 24 hours. + +### 4.4 Profile Picture +**Description:** A User uploads a picture, crops it in the browser, and the Portal writes a square JPEG named `.jpg` to the Share. The Sync Task, which stays unchanged, makes the 96px and 448px versions and writes the Active Directory attributes within about two hours. The Portal shows the current picture from Active Directory with a Pending mark until the Sync Task has processed the new file. Realizes UJ-2. + +**Functional Requirements:** + +#### FR-12: Upload and convert a picture +A User can upload a JPEG, PNG, HEIC or WebP file of up to 10 MB. The Portal re-encodes it as a square JPEG of at least 448 px [ASSUMPTION: 512×512] and writes it as the Picture Source File. Realizes UJ-2. + +**Consequences (testable):** +- The file is named `.jpg` regardless of the uploaded name, replacing any earlier file. +- Other formats, including SVG, and files over 10 MB are rejected before upload with a clear message. +- Metadata is removed from the output. +- The original upload is not stored or served. + +#### FR-13: Crop and edit in the browser +A User can crop the picture to a square and adjust it before saving. [ASSUMPTION: crop, zoom and rotate; no filters.] + +**Consequences (testable):** +- The Portal re-validates and re-encodes the result on the server, whatever the browser sends. + +#### FR-14: Show current picture and Pending status +The Portal shows the User's current picture read from the Active Directory picture attribute and marks it Pending after an upload. Realizes UJ-2. + +**Consequences (testable):** +- The Pending mark clears when the MD5 of the written Picture Source File equals the content of `.md5` in the configured processed path. +- The User sees the note that updates take about two hours. +- If Pending lasts longer than a configured time, the Portal says it is taking longer than expected. [ASSUMPTION] + +#### FR-15: Remove the picture +A User can remove their picture, which deletes the Picture Source File so the Sync Task falls back to the default image. + +**Consequences (testable):** +- The Portal shows Pending until the Sync Task has restored the default. + +### 4.5 Announcements +**Description:** A Publisher writes Announcements in a rich-text editor that shows the finished result (bold, italic, heading, list, link); the text is stored as Markdown. Users read them on a page after signing in, and opted-in Users receive them by e-mail. Realizes UJ-3. + +**Functional Requirements:** + +#### FR-16: Create, save as draft and publish +A Publisher can write an Announcement with a title and body, save it as a draft, and publish it. Realizes UJ-3. + +**Consequences (testable):** +- Only Publishers can create, edit, withdraw or publish. +- The body is edited in a rich-text editor with a toolbar for bold, italic, heading, list and link; the Publisher never sees Markdown syntax. +- The body is stored as Markdown and sanitized every time it is shown: HTML and script content never executes. + +#### FR-17: Severity and optional fields +Every Announcement has a Severity (information, warning or outage); the default is information and it cannot be left empty. A Publisher can add a Downtime Window, an expiry and a publish time. + +**Consequences (testable):** +- An expired Announcement no longer appears in the active list. +- An Announcement with a future publish time appears and is e-mailed at that time only. +- Severity decides the colour, icon and wording of the banner (FR-19). + +#### FR-18: Edit, withdraw and notify again +A Publisher can edit or withdraw an Announcement. Editing never sends e-mail on its own; the Publisher can choose "notify users again". + +**Consequences (testable):** +- A withdrawn Announcement disappears from the Users' page. +- "Notify again" sends one new e-mail to opted-in Users. + +#### FR-19: Announcement page and banner +A signed-in User can read current and past Announcements on a page, newest first. Active Announcements show a banner on the home page. + +**Consequences (testable):** +- Signed-out visitors cannot read any Announcement. +- The banner shows severity. + +#### FR-20: Send Announcement e-mail +When an Announcement is published, every opted-in User receives it by e-mail through the configured SMTP server. + +**Consequences (testable):** +- Recipients are all Users in the configured OU with a valid `mail` value in Active Directory, whether or not they have ever signed in, except those who are opted out. +- A User with no valid `mail` value is skipped, and the skip is recorded so the Admin can see who was not reached. +- Before publishing, the Publisher sees how many Users will receive the e-mail and how many are left out because they opted out or have no address. +- Users who are opted out receive nothing, regardless of severity. +- Sending failures are recorded and visible to the Admin. [ASSUMPTION] + +#### FR-21: Opt in and out +A User can opt in or out of Announcement e-mails at any time. Every User is opted in by default, including Users who have never signed in; the Portal stores only explicit opt-outs. Opting out shows a notice that they will receive no downtime notifications and can opt back in on the Portal. + +### 4.6 Admin UI +**Description:** An Admin manages how the Portal looks and behaves, and reads the audit log. Admins cannot edit other Users' data; the Admin UI manages only the Portal. + +**Functional Requirements:** + +#### FR-22: Accent colour and backgrounds +An Admin can choose the portal-wide accent colour and upload background images. The default accent is the Fluent blue and the default look follows the Microsoft 365 admin center. [ASSUMPTION: verify the default blue against the Fluent web components at build.] + +**Consequences (testable):** +- When the Admin enters a colour, the Portal shows its contrast for the light and the dark theme and a warning if it is below WCAG AA (4.5:1); the Admin can still save it. +- Uploaded images are re-encoded and limited in size. +- Backgrounds are shown only to signed-in Users. Anonymous visitors see nothing but the sign-in redirect (§2.2). + +#### FR-23: Runtime settings +An Admin can change cosmetic and behaviour settings: portal name, session inactivity time, failed-attempt limit and pause, audit retention. + +**Consequences (testable):** +- Settings that identify a system or contain a secret (Active Directory, LDAPS, Service Account, SMTP, OIDC, paths, group names) are not changeable here. + +#### FR-24: Audit log viewer +An Admin can read the audit log. + +**Consequences (testable):** +- The log is not visible to non-Admins. +- Entries older than the configured retention (default 12 months) are removed automatically. + +#### FR-25: Admin access control +Only members of the Admin group can open Admin UI pages. + +### 4.7 Platform and Operations +**Description:** The Portal runs as one Docker container on a Linux host behind a reverse proxy that terminates TLS. All connection settings and secrets come from environment variables. + +**Functional Requirements:** + +#### FR-26: Configuration through environment variables +All system-specific values come from documented environment variables with safe defaults. + +**Consequences (testable):** +- Nothing in the image names ping-mee, a domain, an OU, a group, an IP address or a path. +- Secrets never appear in logs or in the Admin UI. + +#### FR-27: Docker delivery +The Portal is delivered as a Docker image that runs with the Share mounted and persists its data in a mounted volume. It exposes a health check. + +#### FR-28: Record audit entries +The Portal records an Audit Entry for password changes, Profile Field changes, e-mail change requests, confirmations and undos, picture uploads and removals, Announcement actions, settings changes, and failed and throttled sign-in and password attempts. Each entry holds who, what, when and the IP address. + +**Consequences (testable):** +- No password, token or picture content appears in an entry. + +#### FR-29: Interface language +The interface is available in German and English. The language is chosen from the browser's language and the User can switch it (see FR-31). German has a formal and an informal form of address; the Admin sets the portal-wide default (formal). + +### 4.8 Dashboard, Preferences and Status +**Description:** The landing page shows what matters now, each User sets how the portal looks and speaks on their own device, and the Admin sees on one page whether the background work is healthy. See the UX documents for layout and wording. Realizes UJ-1, UJ-3. + +**Functional Requirements:** + +#### FR-30: Dashboard +After signing in, a User lands on a dashboard that shows all active Announcements as banners and an account card with picture (with its Pending status), name, username, e-mail, telephone number and the date the password was last changed. + +**Consequences (testable):** +- Active Announcements appear newest first with their Severity. +- The password date comes from Active Directory. +- The card links to the profile page. + +#### FR-31: Per-device Preferences +A User can set theme (light, dark or system), language and, in German, the form of address (formal or informal) from a menu opened from their picture in the upper right. The choices are stored on the device. + +**Consequences (testable):** +- The default theme is the system theme and the default language comes from the browser. +- The form-of-address choice is shown only when the language is German. +- The choices take effect immediately and are not stored on the server. +- E-mails use the portal-wide default form of address, because the device choice cannot reach them. [ASSUMPTION] + +#### FR-32: Status page +An Admin can see on one page the age of the last successful database backup, the e-mails that failed to send with their reason, and the last run and result of each background job. + +**Consequences (testable):** +- A backup older than a configured age (default 26 hours) is marked as a problem. +- An e-mail that failed after the configured number of attempts stays listed until it is resolved. +- No other alert (e-mail or otherwise) is sent. [Decision: the Admin watches this page.] + +## 5. Non-Goals (Explicit) +- Not an Active Directory admin console: Admins cannot edit other Users' data or reset passwords here. +- No forgotten-password reset and no expired-password flow. The Admin handles both by hand. +- No editing of `cn` or `name`, and so no object renames. +- No multi-domain support. +- No replacement of the PowerShell scripts or the Sync Task. +- No public announcement page or status page. +- No SVG uploads. +- Not a general identity provider: sign-in stays with authentik. + +## 6. MVP Scope + +### 6.1 In Scope +- Everything in §4, shipped together. [Decision: the Admin chose not to phase the first release; sequencing is left to epics and stories.] + +### 6.2 Out of Scope for MVP +- Everything in §5. + +## 7. Success Metrics + +**Primary** +- **SM-1**: Number of known-password changes done by the Admin by hand per month — target 0 after rollout. Validates FR-5. +- **SM-2**: Share of profile and picture changes made by Users themselves — target: all of them. Validates FR-8, FR-12. +- **SM-3**: Share of planned downtimes announced through the Portal — target 100%. Validates FR-16, FR-20. + +**Secondary** +- **SM-4**: Time from Publish to e-mail delivery for opted-in Users. Validates FR-20. +- **SM-5**: Share of Pending pictures cleared within the expected time. Validates FR-14. + +**Counter-metrics (do not optimize)** +- **SM-C1**: Number of Announcements or e-mails sent. More is not better; this counterbalances SM-3. +- **SM-C2**: Session length. Do not lengthen Sessions for convenience at the cost of safety. Counterbalances SM-2. +- **SM-C3**: Failed-attempt limits. Do not relax them to reduce support messages. Counterbalances SM-1. + +## 8. Cross-Cutting Requirements + +### 8.1 Security *(system-wide)* +- Injection (LDAP, SQL, script) does not work against any input. +- A stolen or hijacked Session does not give lasting access: sensitive actions require the current password, and sensitive changes notify the old contact. +- A User can never read or write another User's data. +- The Service Account has the minimum rights: only the configured OU and the listed attributes. +- Uploaded files are re-encoded; the original is never served; decoding runs with limits against decompression bombs. +- Cookies are secure, CSRF protection and security headers are enabled. + +### 8.2 Privacy +- Personal data (names, e-mail, phone, picture, audit entries) is processed only for the functions above. Audit retention is 12 months by default and configurable. +- Data of Users removed from Active Directory is removed from the Portal. [ASSUMPTION: see Q-6.] + +### 8.3 Reliability and Operations +- Failures of the Share, SMTP or Active Directory are reported to the User in plain language and recorded in the audit log or logs. +- The Portal depends on authentik being available for sign-in. [ASSUMPTION: accepted.] + +### 8.4 Accessibility and Look +- The interface follows the Microsoft 365 admin center look (see the UX documents) and meets WCAG 2.1 AA. [ASSUMPTION] + +## 9. Open Questions +- **Q-1.** *Resolved in architecture (AD-10):* if the Share is unreachable during an upload, the User sees an error and nothing changes. +- **Q-2.** *Partly resolved (AD-11):* failed e-mails are retried with backoff and a rate limit and stay listed for the Admin. Still open: the exact retry count and the IONOS sending limits. +- **Q-4.** *Resolved in architecture (AD-6):* the absolute session limit is 4 hours, on top of 1 hour of inactivity; both are settings. +- **Q-5.** *Resolved in architecture (AD-14):* SQLite in WAL mode on a volume, with consistent backup files written to a backup folder that the Admin's host copy job copies. Still open: how a restore is tested. +- **Q-6.** *Resolved in architecture (AD-12):* a nightly reconciliation removes data of Users no longer in the OU; audit entries stay until retention ends. +- **Q-7.** *Resolved in architecture (AD-10):* for a removed picture, Pending clears when the processed MD5 differs from the previous one. +- **Q-8.** *Resolved in architecture (AD-15):* stored in UTC, shown in the configured zone (default Europe/Berlin), zone named in e-mails. +- **Q-9.** *Accepted limitation (AD-10):* the Sync Task writes `.md5` before it sets the Active Directory attributes, so a failed attribute write can clear Pending early. The "taking longer than expected" message and the Admin's checks are the safety net. + +## 10. Assumptions Index +- §4.1 FR-3 — 1 hour default with an Admin-changeable value. +- §4.1 FR-4 — default limits per endpoint are chosen at design time. +- §4.2 FR-6 — default 5 failures, 15-minute pause. +- §4.3 FR-8, FR-9, FR-10 — display name has no rules; phone format; 1-hour single-use confirmation link. +- §4.4 FR-12, FR-13, FR-14 — 512×512 output; crop, zoom and rotate only; "taking longer than expected" threshold. +- §4.5 FR-20 — sending failures visible to the Admin. +- §4.6 FR-22 — default accent is the Fluent blue; verify at build. +- §4.8 FR-31 — e-mails use the portal-wide form of address. +- §4.7 FR-29 — resolved: German and English. +- §8.2–8.4 — user data removal, authentik availability, WCAG 2.1 AA. +- §2.3 — journeys and personas are illustrative. diff --git a/_bmad-output/prds/prd-ping-mee-self-service-platform-2026-10-15/review-rubric.md b/_bmad-output/prds/prd-ping-mee-self-service-platform-2026-10-15/review-rubric.md new file mode 100644 index 0000000..4a38ea2 --- /dev/null +++ b/_bmad-output/prds/prd-ping-mee-self-service-platform-2026-10-15/review-rubric.md @@ -0,0 +1,57 @@ +# PRD Quality Review — ping-mee Self-Service Portal + +## Overall verdict +Grade: Good. The PRD has a clear thesis (an admin-free self-service loop for an AD homelab), honest scope boundaries, and unusually good security reasoning for its size. The risk is in a handful of places where a requirement reads as testable but is not yet defined: the throttling requirement, the recipient list for announcements, and link-based actions that a mail scanner can trigger by accident. None block UX work; three should be fixed before architecture. + +## Decision-readiness — strong +Decisions are stated as decisions and carry their reasons: separate Admin and Publisher groups (§3), no expired-password flow (§2.2), no SVG (FR-12, §5), opt-out means no mail at all (FR-20), pending detection via the MD5 file (FR-14). The cost of "ship everything" is named (§6.1 note, brief) rather than hidden. Open Questions are genuinely open. + +### Findings +- **low** Admin-changeable security settings (FR-23) — An Admin session can weaken the failed-attempt limit or session time. Settings changes are audited (FR-28), but there are no floor values. *Fix:* add a consequence that limits have minimum values. + +## Substance over theater — adequate +No persona theater (four journeys, each drives requirements) and no innovation theater (the brief admits there is no moat). §8.3 and §8.4 are thinner: "failures are reported in plain language" and "meets WCAG 2.1 AA" carry no thresholds, and there is no statement about availability expectations. + +### Findings +- **medium** Reliability has no targets (§8.3) — Says what is reported, not what is expected. *Fix:* state explicitly that there is no availability target for the homelab release, or set one (for example "restart-safe, no data loss on restart"). +- **low** Upload processing time (FR-12) — No bound. *Fix:* add a testable limit (for example "a 10 MB HEIC is processed within 10 seconds or fails with a message"). + +## Strategic coherence — adequate +The thesis is coherent and the features serve it. Success metrics validate the thesis and counter-metrics are present. The weakness is that "everything ships together" leaves downstream workflows with no dependency order. + +### Findings +- **medium** No build-order guidance (§6.1) — Epics and stories will have to infer that sign-in and roles come first, the audit log is needed by everything, and the Admin UI depends on runtime settings. *Fix:* add a short dependency note (not phasing) under §6.1. +- **medium** Metrics are hard to measure (SM-1, SM-2) — "0 manual changes" and "all of them" cannot be observed from the Portal, because manual work happens outside it. *Fix:* state the measurement method (for example the Admin's own count, plus audit-log counts of self-service actions). + +## Done-ness clarity — adequate +Most FRs have testable consequences. Weak spots are where the behavior is implied rather than defined. + +### Findings +- **high** FR-4 "Throttle requests" — "failed attempts" is undefined: sign-in happens at authentik, so the Portal never sees failed sign-ins. The only failed attempts the Portal sees are in the password-change and e-mail-change forms (FR-6, FR-10). Which endpoints does FR-4 cover, and are the limits per User, per IP, or both? *Fix:* list the covered endpoints (password change, e-mail change, confirmation and undo links, uploads) and state limits for each; keep FR-6 as the account-lockout protection. +- **high** FR-10 / FR-11 links act on GET — Mail clients and security scanners open links automatically. A scanner could confirm an e-mail change or trigger an undo. *Fix:* the link opens a page that shows the pending action and requires a click (POST); add this as a consequence to both. +- **medium** FR-12 minimum input size — Rejects over 10 MB but says nothing about small images (for example 200×200). The sync script resizes to 448 px, so a tiny source gets upscaled. *Fix:* define a minimum size (reject, or accept with a warning). +- **medium** FR-1 disabled and locked accounts — Not covered. *Fix:* a consequence that a User whose AD account is disabled or locked cannot start or keep a Session. +- **medium** FR-14 relies on timing in the script — `.md5` is written before the AD attributes are set. If the AD write fails afterwards, the Portal sees a match and clears Pending while the picture is not live. *Fix:* note this in the addendum and add a consequence that the Portal also compares the `thumbnailPhoto` change or accepts the limitation explicitly. + +## Scope honesty — strong +Non-Goals do real work (no admin console, no reset flow, no SVG, no cn/name). Assumptions are tagged inline (16) and indexed. Open Questions Q-1 to Q-7 are specific. The "everything ships together" decision is attributed to the Admin, not smoothed over. + +### Findings +- **high** Recipient list is undefined (FR-20, FR-21) — "New Users are opted in", but the Portal only learns about a User at first sign-in. Do Users who have never signed in get downtime e-mails? Users without a `mail` value? This decides who is told about downtime. *Fix:* add an Open Question and a requirement for the recipient source (all Users in the OU with a valid `mail`, versus only Users who have signed in). +- **medium** Time zone is unspecified (FR-17) — Downtime windows and scheduled publish times have no time zone rule. *Fix:* add Q-8: store UTC, display in a configured time zone, and name the zone in e-mails. +- **medium** Contradiction on anonymous visitors (§2.2 vs FR-22) — §2.2 says anonymous visitors see nothing except the redirect, but FR-22 says backgrounds are served to anonymous visitors. *Fix:* decide where backgrounds show (after sign-in only, or on a pre-sign-in landing page) and align both. + +## Downstream usability — adequate +IDs are contiguous and cross-references resolve. Journeys have named protagonists. Glossary is present, with some gaps. + +### Findings +- **medium** Glossary gaps — "audit log", "draft", "withdrawn", "severity" and "downtime window" are used as terms but not defined. *Fix:* add them, and use "Audit Entry" or "audit log" consistently. +- **low** Casing drift in the Vision (§1) — "user" and "admin" in lower case where §3 defines User and Admin. *Fix:* capitalize, or accept the Vision as informal prose. + +## Shape fit — strong +A capability-first PRD with short journeys suits a single-operator internal tool with several user roles. The weight on security requirements matches the internet exposure. + +## Mechanical notes +- IDs: FR-1 to FR-29, UJ-1 to UJ-4, SM-1 to SM-5 and C1 to C3, Q-1 to Q-7 are contiguous with no unresolved references. +- Assumptions Index: roundtrip is incomplete. The inline tags in FR-3, FR-12 and FR-14 are indexed; the "notify again" default and the audit-failure behavior are not tagged. +- Required sections for an internal tool are present; no compliance section is needed, but GDPR basics sit under §8.2 as an assumption. diff --git a/_bmad-output/specs/spec-ping-mee-self-service-platform/.memlog.md b/_bmad-output/specs/spec-ping-mee-self-service-platform/.memlog.md new file mode 100644 index 0000000..80816b3 --- /dev/null +++ b/_bmad-output/specs/spec-ping-mee-self-service-platform/.memlog.md @@ -0,0 +1,34 @@ +--- +topic: ping-mee Self-Service Portal +updated: 2026-10-15T12:37 +--- + + +- (event) Create. Inputs (rich, structured, no elicitation): PRD+addendum, brief+addendum, ARCHITECTURE-SPINE, UX DESIGN.md+EXPERIENCE.md, context-resources scripts. Slug inherited from PRD: ping-mee-self-service-platform. Interactive, express. +- (decision) Companions: ADOPTED prd.md, prd addendum.md, ARCHITECTURE-SPINE.md, DESIGN.md, EXPERIENCE.md (FR-level consequences and AD rules are load-bearing and would bloat the kernel). Sources absorbed: brief.md, brief addendum.md. No spec-authored companions. +- (capability) CAP-1 sign-in via OIDC, roles from AD groups, server-side sessions (1h idle/4h absolute), disabled accounts cut off, sensitive-request throttling [PRD FR-1..FR-4] +- (capability) CAP-2 user changes own password (current+new), policy shown from AD, applied over LDAPS, other sessions ended, e-mail sent [FR-5..FR-7] +- (capability) CAP-3 user edits own profile fields; e-mail change needs password + confirmation to new address + notice and 24h undo link to old [FR-8..FR-11] +- (capability) CAP-4 profile picture upload (JPEG/PNG/HEIC/WebP <=10MB), crop, write .jpg to share, Pending until sync processed, remove [FR-12..FR-15] +- (capability) CAP-5 publishers write announcements (rich text stored as Markdown, severity always set, optional window/expiry/schedule), edit/withdraw/notify again; page + banners [FR-16..FR-19] +- (capability) CAP-6 announcement e-mail to all OU users with valid mail minus opt-outs via outbox; opt-in default; failures visible to Admin [FR-20, FR-21] +- (capability) CAP-7 admin manages the portal: accent colour with contrast warning, backgrounds, runtime settings, audit log viewer, status page [FR-22..FR-25, FR-32] +- (capability) CAP-8 every sensitive action leaves an audit entry, kept 12 months, never holding passwords/tokens/picture content [FR-28] +- (capability) CAP-9 dashboard, per-device preferences (theme/language/German form of address), German+English, responsive desktop+phone, WCAG 2.1 AA [FR-29..FR-31] +- (capability) CAP-10 deployable anywhere: one Docker container, config+secrets via env vars, nothing ping-mee-specific, consistent DB backups, health check [FR-26, FR-27] +- (constraint) Architecture: Django modular monolith with adapters, one owner per external system, 19 rules in ARCHITECTURE-SPINE (AD-1..AD-19) are binding. +- (constraint) Single container, one worker, SQLite WAL on a volume; portal writes its own consistent backup files because Veeam copies directories. +- (constraint) AD only via LDAPS with a service account limited to a configurable OU and fixed attribute allow-lists; no cn/name edits. +- (constraint) Existing PowerShell sync/logon scripts stay unchanged: portal only writes .jpg to source and reads .md5 from processed. +- (constraint) Internet-facing: no injection, no cross-user access, throttling before any AD call, passwords never stored or logged, strict CSP with no inline script or external assets. +- (constraint) Expired, forgotten or must-change passwords are never handled by the portal; the Admin resets them in AD. +- (decision) Companion precedence: behaviour = PRD; architecture = ARCHITECTURE-SPINE; look = DESIGN.md; UX behaviour = EXPERIENCE.md. Where PRD and UX docs differ, the 2026-10-15 update pass aligned them. +- (question) Open: exact retry count + IONOS limits; restore test; authentik single logout; rich-text editor library + asset build; image location; confirm accepted early-clear of Pending (PRD Q-9). +- (event) Self-validate pass 1 (coherence): rules 1-6,8 hold - each CAP has intent+success, 9 non-goals, constraints bend design, ids CAP-1..CAP-10 unique. Fixed: open questions copied stale PRD list -> PRD Q-1,2,4..9 marked resolved/accepted per spine AD-6/10/11/12/14/15, SPEC lists only truly open items. +- (event) Self-validate pass 2 (preservation): FR-1..FR-32 each covered by a CAP range; PRD addendum, spine, DESIGN, EXPERIENCE adopted as companions so FR consequences, AD rules and screen behaviour are preserved by reference; brief absorbed (all claims present in PRD). Wrapper-only dropped: PRD document-purpose, journeys narrative personas (kept in PRD/EXPERIENCE), success-metric SM-1..5 targets (kept in PRD). +- (event) spec finalized +- (decision) Story Breakdown agreed (17 stories, execution order): 1 scaffold, 2 audit/throttle/session foundations, 3 directory adapter, 4 OIDC sign-in, 5 page shell+prefs, 6 scheduler+mail outbox, 7 password change, 8 profile edit, 9 e-mail change, 10 picture backend, 11 picture screens, 12 announcements core, 13 announcement editor, 14 announcement e-mail+opt-out, 15 dashboard, 16 admin pages+maintenance jobs, 17 hardening+acceptance. +- (decision) Checkpoints: spec_checkpoint on stories 1,3,4,7,10; done_checkpoint (pause after) on 4,7,16,17. User is sole reviewer. +- (constraint) Testing environment: a test OU can be created, but NO separate authentik - authentik is production only. Portal gets its own dedicated OIDC application/provider in prod authentik, restricted to test users while testing; never modify existing authentik applications. +- (event) stories.yaml written (17 stories) and checked against schema rules: required fields, unique string ids, prefix-free, no status field. +- (event) Clarification: stories.yaml check above was done with a regex structure check (17 ids/titles/descriptions, unique, quoted, prefix-free, no status, no stray unindented lines); a full YAML parse was not possible because PyYAML could not be installed. diff --git a/_bmad-output/specs/spec-ping-mee-self-service-platform/SPEC.md b/_bmad-output/specs/spec-ping-mee-self-service-platform/SPEC.md new file mode 100644 index 0000000..cb748fa --- /dev/null +++ b/_bmad-output/specs/spec-ping-mee-self-service-platform/SPEC.md @@ -0,0 +1,100 @@ +--- +id: SPEC-ping-mee-self-service-platform +companions: + - ../../prds/prd-ping-mee-self-service-platform-2026-10-15/prd.md + - ../../prds/prd-ping-mee-self-service-platform-2026-10-15/addendum.md + - ../../architecture/architecture-ping-mee-self-service-platform-2026-10-15/ARCHITECTURE-SPINE.md + - ../../ux-designs/ux-ping-mee-self-service-platform-2026-10-15/DESIGN.md + - ../../ux-designs/ux-ping-mee-self-service-platform-2026-10-15/EXPERIENCE.md +sources: + - ../../briefs/brief-ping-mee-self-service-platform-2026-10-15/brief.md + - ../../briefs/brief-ping-mee-self-service-platform-2026-10-15/addendum.md +--- + +> **Canonical contract.** This SPEC and the files in `companions:` are the complete, preservation-validated contract for what to build, test, and validate. Source documents listed in frontmatter are for traceability — consult them only if you need narrative rationale or prose color this contract intentionally omits. + +# ping-mee Self-Service Portal + +## Why + +The ping-mee homelab, used by about ten family members and friends, keeps all accounts in one Active Directory. Today every change — a password, a name, a phone number, a picture — goes through the admin by hand, and there is no single place to announce downtime. This is a pain to solve for the admin and the users, and it should be built to a standard that lets it run safely on the internet and later be deployed in other Active Directory homelabs. + +## Capabilities + +*Each capability's requirement-level detail (FR-N with testable consequences) is in the PRD companion; the number in brackets is the PRD range.* + +- **CAP-1** [FR-1..FR-4] + - **intent:** A person with an AD account signs in through the identity provider and gets only the access their AD group membership allows. + - **success:** A user in the configured OU signs in via OIDC; a user outside the Publisher and Admin groups cannot reach those pages by direct address; sessions end after 1 hour idle or 4 hours total; a disabled or locked AD account loses access at its next request; repeated sensitive requests are refused before any AD call. +- **CAP-2** [FR-5..FR-7] + - **intent:** A signed-in user changes their own password. + - **success:** With the correct current password and a policy-conforming new one, the AD password changes over LDAPS, the user's other sessions end and a confirmation e-mail is sent; a policy rejection is shown in plain words with no change; five wrong current-password attempts block further tries for the configured pause. +- **CAP-3** [FR-8..FR-11] + - **intent:** A signed-in user edits their own first name, last name, display name, e-mail and telephone numbers. + - **success:** Saved values appear in the matching AD attributes; injection strings change nothing else; an e-mail change applies only after the current password, a click on a confirmation page from the new address, and sends the old address a notice with a 24-hour undo that ends the user's sessions and notifies the Admin. +- **CAP-4** [FR-12..FR-15] + - **intent:** A signed-in user sets or removes their profile picture. + - **success:** A JPEG, PNG, HEIC or WebP file up to 10 MB is cropped, re-encoded and written as `.jpg` to the source folder; the page shows Pending until `processed/.md5` matches the written file; other formats and larger files are refused with a reason. +- **CAP-5** [FR-16..FR-19] + - **intent:** A Publisher announces planned changes and downtime. + - **success:** A Publisher writes a titled announcement in a rich-text editor with an always-set severity and an optional downtime window, expiry and publish time; it appears as a banner and in a list for signed-in users, can be edited, withdrawn, or re-sent on request; script content in the body never runs. +- **CAP-6** [FR-20, FR-21] + - **intent:** Users hear about announcements by e-mail unless they opted out. + - **success:** On publish every user in the OU with a valid e-mail address, minus explicit opt-outs, receives the e-mail through the configured SMTP server; users who opted out receive nothing; the Publisher sees counts and reasons beforehand; failures are listed for the Admin. +- **CAP-7** [FR-22..FR-25, FR-32] + - **intent:** An Admin manages how the portal looks and behaves and can see whether it is healthy. + - **success:** Only Admin-group members reach the Admin pages; the accent colour is checked for contrast in both themes with a warning but allowed; runtime settings change without restart; the status page shows backup age, failed e-mails and job runs; the audit log is readable. +- **CAP-8** [FR-28] + - **intent:** The Admin can reconstruct who changed what. + - **success:** Every password change, profile change, e-mail change step, picture action, announcement action, settings change and throttled or failed attempt produces an entry with who, what, when and IP, kept 12 months, holding no password, token or picture content. +- **CAP-9** [FR-29..FR-31] + - **intent:** Each user sees a clear landing page and sets how the portal looks and speaks on their own device. + - **success:** The dashboard shows active announcements and an account card with the password date; theme, language and German form of address are chosen from the avatar menu and stored on the device; German and English work, with formal and informal German; the portal is usable on desktop and phone and meets WCAG 2.1 AA. +- **CAP-10** [FR-26, FR-27] + - **intent:** The portal runs unchanged in any environment from configuration alone. + - **success:** One Docker image starts only when all required environment variables are valid; no image content names ping-mee, a domain, OU, group, IP or path; secrets appear in no log or UI; a health check works without AD; consistent database backup files appear in the backup folder on schedule. + +## Constraints + +- Architecture is binding as written in `ARCHITECTURE-SPINE.md` (rules AD-1..AD-19): Django modular monolith with one owner per external system. +- One container, one worker, SQLite on a volume; the portal writes its own consistent backups, because the host copy job copies directories. +- AD is reached only over LDAPS with a service account limited to a configurable OU and fixed read and write attribute lists; `cn` and `name` are never edited. +- The existing PowerShell sync, logon and registry scripts stay unchanged; the portal only writes `.jpg` to the source folder and reads `.md5` from the processed folder. +- The portal is internet-facing: no injection, no cross-user access, throttling before any AD call, passwords never stored or logged, strict content-security policy with no inline script and no external fonts, scripts or CDNs. +- Expired, forgotten or must-change passwords are never handled by the portal; the Admin resets them in AD. +- Precedence when companions differ: behavior follows the PRD, architecture follows the spine, look follows `DESIGN.md`, screen behavior follows `EXPERIENCE.md`. + +## Non-goals + +- No forgotten-password or expired-password flow. +- No admin console for other users' data: Admins cannot edit another user's profile, picture or password. +- No renaming of AD objects (`cn`, `name`). +- No multi-domain support. +- No replacement of the PowerShell scripts or the sync task. +- No public status or announcement page; signed-out visitors see nothing. +- No SVG uploads. +- No alerting beyond the Admin status page; no staging environment (a test OU is used instead). +- No identity-provider features of its own: sign-in and multi-factor stay with authentik. + +## Success signal + +A family member with no help from the admin changes their password, name, phone number and picture in the portal, while the admin publishes a scheduled downtime announcement that every opted-in user receives by e-mail and sees as a banner, and an attacker's injection, cross-user and brute-force attempts against the internet-facing portal all fail and are recorded. + +## Assumptions + +- Default values are placeholders to confirm at build: failed-attempt limit and pause (5, 15 minutes), confirmation link lifetime (1 hour), picture output (512×512 JPEG), Pending timeout, throttle limits per endpoint, backup retention, and the "backup too old" age (26 hours). +- E-mails use the portal-wide default form of address, because a per-device choice cannot reach server-sent mail. +- The default accent colour is the Fluent 2 default blue (`#0F6CBD`) and needs verifying against the component package. +- Active Directory attribute names for the password policy (`minPwdLength`, `pwdProperties`, `pwdHistoryLength`, `msDS-ResultantPSO`) need verifying in the test OU. +- Users depend on authentik being available for sign-in; no fallback. +- Data of users removed from AD is deleted by a nightly reconciliation; audit entries stay until retention ends. +- Sample personas and journeys in the PRD and UX documents are illustrative. + +## Open Questions + +- What are the exact retry count and the IONOS sending limits for announcement e-mail? +- How is a restore from the portal's backup files tested before it is needed? +- Does sign-out also end the authentik session? +- Which rich-text editor library is used, and how are the front-end assets built into the image? +- Where does the Docker image live: a registry, or built on the host? +- Is the early-clearing of Pending after a failed AD write by the sync script an acceptable limitation (PRD Q-9; accepted in the spine, confirm at first use)? diff --git a/_bmad-output/specs/spec-ping-mee-self-service-platform/stories.yaml b/_bmad-output/specs/spec-ping-mee-self-service-platform/stories.yaml new file mode 100644 index 0000000..851d17e --- /dev/null +++ b/_bmad-output/specs/spec-ping-mee-self-service-platform/stories.yaml @@ -0,0 +1,95 @@ +- id: "1" + title: Project scaffold, configuration and container + description: >- + Create the Django project, environment-variable configuration that refuses to start when invalid, the multi-stage Docker image and compose file, the health check, JSON logging, security headers with a strict content-security policy, and the test setup with adapter fakes. Covers CAP-10 and the foundations of AD-1, AD-13 and AD-18 in the architecture spine. + spec_checkpoint: true + invoke_dev_with: >- + Verify and pin current versions of Python, Django (5.2 LTS) and the other stack entries on the web before binding them. Nothing in the repository may name ping-mee, a domain, an OU, a group, an IP or a path. +- id: "2" + title: Audit, throttle and session foundations + description: >- + Build the audit service, the database-backed throttle counters, and server-side sessions with the username in its own column and a kill-by-user function. Covers CAP-8 and the base of CAP-1; rules AD-4, AD-6, AD-7 and AD-19. +- id: "3" + title: Directory adapter + description: >- + Build the only code that talks to Active Directory: lookup by sAMAccountName inside the configured OU, group membership, account state, password verification, password policy read, and the allow-listed writes, plus an in-memory fake. Covers CAP-1 to CAP-3; rules AD-1, AD-2, AD-5 and AD-8. + spec_checkpoint: true + invoke_dev_with: >- + Use python-ldap, not ldap3. LDAPS must verify against a CA bundle supplied by environment variable. Test only against a test OU with a test service account scoped to it, never against the production OU. Verify the password-policy attribute names in the test OU. +- id: "4" + title: Sign-in with OIDC and roles + description: >- + Implement sign-in through the identity provider using the authorization-code flow, session creation, roles from AD group membership with a short cache, disabled accounts cut off, and the trusted-proxy client IP rule. Covers CAP-1; rules AD-5, AD-6 and AD-7. + spec_checkpoint: true + done_checkpoint: true + invoke_dev_with: >- + Use Authlib. Authentik is the production instance only: the portal gets its own dedicated OIDC application and provider, restricted to test users while testing. Never change any existing authentik application. The proxy is Zoraxy: take the last X-Forwarded-For entry from a trusted proxy and never X-Real-IP. +- id: "5" + title: Page shell, preferences and translations + description: >- + Build the base layout with the Fluent web components bundled into the image: top bar, left menu with role-based groups, avatar menu for theme, language and German form of address, German and English text, light and dark themes, and the phone layout. Covers CAP-9 and the layout parts of DESIGN.md and EXPERIENCE.md. + invoke_dev_with: >- + No inline scripts and no external assets. Preferences live in cookies; the theme is set by the server and CSS, not by script. Follow the mock-ups in the UX folder. +- id: "6" + title: Scheduler and mail outbox + description: >- + Build the single 30-second scheduler tick with idempotent jobs, job-run records, the mailer adapter and the outbox with retries, backoff and a rate limit, and a fake mailer. Covers CAP-6 and CAP-10; rules AD-11 and AD-12. +- id: "7" + title: Password change + description: >- + Build the password page with the Active Directory password policy listed up front, the current-password check with the attempt cap, the LDAPS change, ending the user's other sessions, and the confirmation e-mail. Covers CAP-2 (FR-5 to FR-7); rules AD-2, AD-3 and AD-8. + spec_checkpoint: true + done_checkpoint: true + invoke_dev_with: >- + Test only against the test OU. Never log, store or audit a password. Plain-language messages for policy rejections. +- id: "8" + title: Profile edit + description: >- + Build the profile page for first name, last name, display name, telephone and mobile with validation and LDAP escaping. Covers part of CAP-3 (FR-8, FR-9); rules AD-2 and AD-3. +- id: "9" + title: E-mail change with confirmation and undo + description: >- + Build the e-mail change flow: current password, confirmation page from a link to the new address, notice with a 24-hour undo link to the old address, and the undo effects (restore, end sessions, notify the Admin). Covers CAP-3 (FR-10, FR-11); rules AD-8 and AD-9. + invoke_dev_with: >- + A link opened with GET must show a page and change nothing; only a POST with CSRF applies the change. Tokens are hashed at rest and single use. +- id: "10" + title: Picture backend + description: >- + Build upload validation, decoding of JPEG, PNG, HEIC and WebP with size and pixel limits, square re-encoding, the share adapter with atomic writes and timeouts, and the stored MD5 state. Covers CAP-4 (FR-12, FR-14, FR-15 backend); rule AD-10. + spec_checkpoint: true + invoke_dev_with: >- + Pin the HEIC library after checking current versions on the web. Refuse SVG and everything else. Never store or serve the original upload. The share is mounted by the host; write only to the source folder and read the processed folder. +- id: "11" + title: Picture screens + description: >- + Build the picture page with in-browser cropping, the Pending status read from the share and Active Directory, and removal. Covers CAP-4 (FR-13 to FR-15 UI); see the picture mock-up and EXPERIENCE.md. +- id: "12" + title: Announcements core + description: >- + Build the announcement model with an always-set severity, draft, publish and withdraw, the list page and the banners, with safe Markdown rendering. Covers CAP-5 (FR-16 to FR-19 backend); rules AD-16 and AD-19. +- id: "13" + title: Announcement editor + description: >- + Build the publisher pages with the rich-text editor, downtime window, expiry, scheduled publishing, edit, and the "notify again" option. Covers CAP-5 (FR-16, FR-17, FR-18 UI); see the editor mock-up. + invoke_dev_with: >- + Choose a maintained open-source rich-text editor that outputs Markdown and can be bundled into the image, after checking current options on the web. The server must treat the submitted Markdown as untrusted. +- id: "14" + title: Announcement e-mail and opt-out + description: >- + Build recipient resolution (all users in the OU with a valid address minus opt-outs), the opt-in and opt-out page with its notice, the recipient summary for the Publisher, and the failure listing. Covers CAP-6 (FR-20, FR-21); rule AD-11. +- id: "15" + title: Dashboard + description: >- + Build the landing page with active announcement banners and the account card, including the password last changed date. Covers CAP-9 (FR-30); see the dashboard mock-up. +- id: "16" + title: Admin pages and maintenance jobs + description: >- + Build the Admin pages (accent colour with contrast warning, backgrounds, runtime settings, audit log viewer, status page) and the maintenance jobs (database backup with retention, nightly AD reconciliation, audit retention). Covers CAP-7, CAP-8 and CAP-10; rules AD-12 to AD-14. + done_checkpoint: true + invoke_dev_with: >- + Backups use the SQLite online backup API into the backup folder; the status page shows the age of the last good backup. +- id: "17" + title: Hardening and acceptance + description: >- + Run security tests (injection, cross-user access, throttling, session handling), the accessibility pass, and write deployment notes and a smoke checklist for the test OU. Covers all capabilities and the success signal in SPEC.md. + done_checkpoint: true diff --git a/_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/.memlog.md b/_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/.memlog.md new file mode 100644 index 0000000..9ee1bcb --- /dev/null +++ b/_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/.memlog.md @@ -0,0 +1,30 @@ +--- +topic: ping-mee Self-Service Portal UX +updated: 2026-10-15T11:57 +--- + + +- (event) Create intent. Source candidates (paths only): brief, brief addendum, prd, prd addendum, ARCHITECTURE-SPINE (AD-16 strict CSP, vendored Fluent web components 3.x, server-rendered Django, de+en i18n). +- (decision) Feel (user words): a utility, simple and straight to the point; users are NOT tech savvy; imagined like the M365 admin center: clean, simple, 'just a big page for settings'; not too much for the user; settings intuitive and informative. +- (decision) Themes: light AND dark, default follows system theme (prefers-color-scheme), user can override. Form factors: desktop AND mobile both must work (responsive web). +- (assumption) Working mode not stated by user - proceeding in COACHING path (suggested); stakes = internal tool. +- (decision) IA: option B (short left menu, one topic per page) PLUS a big dashboard as the landing page showing announcements and some account information. +- (decision) 'Informative' = transparency about what each setting does (explain effect of each field/action, e.g. password rules before typing, picture 'updates within ~2h'/pending). +- (decision) Voice: German formal (Sie) by default with a setting to switch to informal (du). Language auto-detected from browser language, switchable. +- (decision) Colour: Microsoft blue by default; colour must be changeable in settings. Theme (light/dark/system), formal/informal, and language switch live in ONE menu opened from the profile picture in the upper right corner; stored PER DEVICE. +- (assumption) To confirm: is the colour change an Admin (global, PRD FR-22) setting or per-user? Is formal/informal German-only? Dashboard 'account info' contents. +- (decision) Accent colour is PORTAL-WIDE (Admin sets it; matches PRD FR-22); user menu keeps theme/language/formality only. Formal/informal is a GERMAN-ONLY setting (hidden when language is English). +- (decision) Dashboard account card also shows 'Password last changed on ...' (from AD pwdLastSet). No further pages wanted beyond the IA table. +- (assumption) UNANSWERED: should informal setting apply to e-mails? Assumed: e-mails use the portal-wide default register (per-device choice cannot reach server-sent mail). Also assumed default brand blue = Fluent 2 web components default; verify at scaffold. +- (event) Rendered 3 key-screen mocks in .working/: key-dashboard.html (desktop light + menu open, phone dark), key-password.html, key-picture-pending.html. German formal copy. Password rules in the mock are SAMPLE text; real rules need domain policy (new requirement: portal reads AD password policy). +- (assumption) Mock layout assumptions: top bar in brand blue (dark: deep navy), left nav 6 items (collapses to burger on phone), banner for active announcement (left accent bar by severity), card grid on dashboard (stacked on phone). +- (decision) Mock review feedback: look approved ('really good', M365-admin-centre feel right); wording 'perfect'; dashboard 'clean'. Defect: phone dashboard cut off on right -> fixed (main min-width:0, rows stack on phone, text wraps). +- (decision) States, wording rules and accessibility floor drafted by facilitator accepted by user ('no' changes): saved/error/too-many-attempts/pending/loading/empty/unavailable states; plain-language, no jargon (no LDAP/Verzeichnis/Token); strings in one translation file x3 (de-Sie, de-du, en); WCAG 2.1 AA, keyboard, skip link, icon+text not colour alone, 44px touch targets, errors linked to fields and announced. +- (decision) Accent colour check: portal computes contrast for the chosen colour (light and dark theme) and shows a CHECK result plus a WARNING when below AA; Admin may still save (warn, not block). +- (decision) Password rules: portal READS the policy from AD and lists it before typing (NEW requirement - affects PRD FR-5 and ARCHITECTURE-SPINE AD-2 read allow-list; consider Fine-Grained Password Policy msDS-ResultantPSO). +- (decision) User wants MOCKS of Publisher and Admin screens (announcement editor, admin appearance, status, audit log) to give opinion. +- (event) Rendered 4 admin/publisher mocks in .working/: key-announcement-editor, key-admin-appearance (contrast check+warning, computed 1.41:1 white vs #FFD60A, 14.02:1 dark text), key-admin-status (backup age, failed mail, jobs), key-admin-audit. Nav gains groups 'Veröffentlichen' (Publisher) and 'Administration' (Darstellung, Einstellungen, Protokoll, Status). Awaiting user opinion. +- (decision) Mock feedback: admin/publisher mocks approved otherwise ('cannot add anything'). CHANGE 1: announcement body uses a WYSIWYG editor (toolbar, formatted as you type) instead of visible Markdown; stored format stays Markdown (editor converts), server still sanitizes on render; e-mail keeps plain-text part. Affects PRD FR-16 wording + ARCHITECTURE-SPINE AD-16; editor library undecided (needs web verification; must be vendored, CSP-safe). +- (decision) CHANGE 2: Importance (severity) is ALWAYS set, not optional: default Information, shown in the main card. Optional card keeps only downtime window, expiry, schedule. Affects PRD FR-17 (severity was listed optional). +- (event) Mockups promoted from .working/ to mockups/ (7 files). All 7 IA surfaces with a layout driver are mocked; spine-only: Profil, Benachrichtigungen, Ankündigungen (read list), confirm/undo pages, Einstellungen. +- (event) spines finalized diff --git a/_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/DESIGN.md b/_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/DESIGN.md new file mode 100644 index 0000000..e492acd --- /dev/null +++ b/_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/DESIGN.md @@ -0,0 +1,183 @@ +--- +name: ping-mee Portal +description: Calm, utility-style self-service portal for a homelab, in the spirit of the Microsoft 365 admin center. Fluent 2 web components; this DESIGN.md specifies the brand-layer delta and the portal's own components. +status: final +updated: 2026-10-15 +sources: + - _bmad-output/prds/prd-ping-mee-self-service-platform-2026-10-15/prd.md + - _bmad-output/architecture/architecture-ping-mee-self-service-platform-2026-10-15/ARCHITECTURE-SPINE.md +colors: + # Fluent 2 neutrals are inherited. Only the brand and the portal's semantic colours are listed. + # Brand is portal-wide and Admin-changeable; this is the default (Fluent 2 default brand blue). [ASSUMPTION: verify against @fluentui/web-components 3.x at scaffold] + brand: '#0F6CBD' + on-brand: '#FFFFFF' + brand-dark: '#479EF5' + on-brand-dark: '#0A0A0A' + top-bar-dark: '#0F2A43' + background: '#FAFAFA' + surface: '#FFFFFF' + nav-surface: '#F5F5F5' + text: '#242424' + text-secondary: '#616161' + line: '#E0E0E0' + background-dark: '#1F1F1F' + surface-dark: '#292929' + nav-surface-dark: '#242424' + text-dark: '#FFFFFF' + text-secondary-dark: '#C8C8C8' + line-dark: '#3D3D3D' + severity-info: '#0F6CBD' + severity-warning: '#F7630C' + severity-outage: '#B10E1C' + severity-outage-dark: '#FF99A4' + success: '#107C10' + success-dark: '#6CCB5F' + pending-surface: '#FFF4CE' + pending-text: '#5C4400' + pending-surface-dark: '#4A3B00' + pending-text-dark: '#FFE9A8' +typography: + # System font stack only: the security policy forbids external fonts. + family: + fontFamily: '"Segoe UI Variable", "Segoe UI", system-ui, sans-serif' + page-title: + fontSize: 24px + fontWeight: '600' + lineHeight: '1.25' + page-title-phone: + fontSize: 20px + fontWeight: '600' + lineHeight: '1.25' + card-title: + fontSize: 16px + fontWeight: '600' + body: + fontSize: 14px + fontWeight: '400' + lineHeight: '1.45' + label: + fontSize: 14px + fontWeight: '600' + small: + fontSize: 13px + hint: + fontSize: 12px + mono-editor: + fontFamily: 'Consolas, monospace' + fontSize: 13px +rounded: + sm: 4px + md: 6px + lg: 8px + pill: 10px + full: 9999px +spacing: + card-padding: 16px 18px + card-gap: 14px + page-padding: 24px 28px + page-padding-phone: 16px + nav-width: 220px + top-bar-height: 48px + touch-target-min: 44px +components: + top-bar: + background: '{colors.brand}' + foreground: '{colors.on-brand}' + height: '{spacing.top-bar-height}' + top-bar-dark: + background: '{colors.top-bar-dark}' + foreground: '{colors.text-dark}' + nav-item: + foreground: '{colors.text}' + radius: '{rounded.sm}' + nav-item-active: + background: '{colors.surface}' + border: 'inset 3px left {colors.brand}' + fontWeight: '600' + card: + background: '{colors.surface}' + border: '1px solid {colors.line}' + radius: '{rounded.lg}' + padding: '{spacing.card-padding}' + banner: + radius: '{rounded.md}' + border: '1px solid, left 5px, colour by severity' + button-primary: + background: '{colors.brand}' + foreground: '{colors.on-brand}' + radius: '{rounded.sm}' + button-secondary: + background: 'transparent' + foreground: '{colors.text}' + border: '1px solid' + radius: '{rounded.sm}' + chip-pending: + background: '{colors.pending-surface}' + foreground: '{colors.pending-text}' + radius: '{rounded.pill}' + input: + border: '1px solid, bottom 2px' + radius: '{rounded.sm}' + avatar: + radius: '{rounded.full}' +--- + +## Brand & Style + +A calm utility. The portal should feel like the Microsoft 365 admin center's settings pages: clean, plain and confident, one clear task per page, with nothing decorative. The people who use it are not technical, so the design earns trust through restraint and legibility, not through personality. It follows Fluent 2 so the controls behave the way these users already know from Microsoft products. + +Light and dark are equal citizens. The portal follows the system theme by default and the user can override it per device. The accent colour is portal-wide and set by the Admin. + +Mock-ups: `mockups/key-dashboard.html`, `mockups/key-password.html`, `mockups/key-picture-pending.html`, `mockups/key-announcement-editor.html`, `mockups/key-admin-appearance.html`, `mockups/key-admin-status.html`, `mockups/key-admin-audit.html`. The spine wins on conflict. + +## Colors + +- **`{colors.brand}`** is the one accent: top bar, primary buttons, active-nav marker and focus emphasis. It is Admin-changeable (FR-22). In dark mode the lighter `{colors.brand-dark}` is used with `{colors.on-brand-dark}` text. +- **Neutrals** (`background`, `surface`, `nav-surface`, `text`, `text-secondary`, `line`) carry almost everything; the dark variants have the `-dark` suffix. Cards sit on `{colors.surface}` above `{colors.background}`. +- **Severity colours** colour only the left edge and icon of an announcement banner: `{colors.severity-info}`, `{colors.severity-warning}`, `{colors.severity-outage}`. Colour is never the only signal; each severity also has an icon and a word. +- **Pending** uses `{colors.pending-surface}` and `{colors.pending-text}` for the "being updated" chip and nothing else. +- **Success** (`{colors.success}`) marks healthy job status and saved confirmations. +- **Contrast.** All text and control pairs meet WCAG 2.1 AA in both themes. The Admin colour check (EXPERIENCE.md, Admin Appearance) computes contrast for the chosen brand colour against `{colors.on-brand}` (light) and `{colors.on-brand-dark}` (dark) and warns when below 4.5:1. + +## Typography + +One family, the system stack `{typography.family}`, because the security policy allows no external fonts. A small ramp: `{typography.page-title}` for the page heading (`{typography.page-title-phone}` on phones), `{typography.card-title}` for cards, `{typography.body}` for text, `{typography.label}` for field labels, `{typography.small}` and `{typography.hint}` for explanations. The announcement editor's source view, if shown, uses `{typography.mono-editor}`. Sentence case everywhere; no all-caps except the small group headings in the menu. + +## Layout & Spacing + +Left menu (`{spacing.nav-width}`) plus a content area on desktop; the menu collapses behind a menu button on phones. Content is left-aligned in cards on a single column; two columns of cards only on the dashboard and the status page, stacked on phones. Page padding is `{spacing.page-padding}` (`{spacing.page-padding-phone}` on phones); cards are separated by `{spacing.card-gap}`. Lines of explanatory text stay below about 640 px wide. Touch targets are at least `{spacing.touch-target-min}` on phones. On phones, label/value rows stack and long values wrap, so nothing is cut off at the right edge. + +## Elevation & Depth + +Flat. Depth comes from a 1 px `{colors.line}` border and the surface/background step, not from shadows. The only shadows are on the profile menu (a popover) and on dialogs. + +## Shapes + +Small, even radii: `{rounded.sm}` for buttons and inputs, `{rounded.md}` for banners, `{rounded.lg}` for cards, `{rounded.pill}` for chips, `{rounded.full}` for avatars. + +## Components + +| Component | Visual spec | +|---|---| +| Top bar | `{components.top-bar}`: portal name at left, profile picture at right (opens the profile menu). On phones a menu button replaces the left menu. Dark mode uses `{components.top-bar-dark}`. | +| Left menu | Items `{components.nav-item}`; active item `{components.nav-item-active}`. Group headings "Veröffentlichen" and "Administration" appear only for Publishers and Admins, in small upper-case text. | +| Card | `{components.card}`; title `{typography.card-title}`. | +| Announcement banner | `{components.banner}`: left edge in the severity colour, icon, bold title, one or two lines of text. At most the active announcements, newest first. | +| Pending chip | `{components.chip-pending}` with the text "Wird aktualisiert". | +| Buttons | Primary `{components.button-primary}`; secondary `{components.button-secondary}`. One primary per card. | +| Fields | `{components.input}`; label above, hint below in `{typography.hint}`; error text below in the outage colour with an icon. | +| Profile menu | Popover from the avatar: name and username, theme (Hell, Dunkel, System), language (Deutsch, English), address form (Sie, Du; German only), sign-out. | +| Rich text editor | Toolbar above a text area that shows the result: bold, italic, heading, list, link. Same border as inputs. | +| Tables | Compact rows with `{colors.line}` separators, used for the audit log, job status and failed mail. | +| Filter chips | Pill buttons; the selected chip uses `{colors.brand}`. | +| Colour field (Admin) | Swatch, hex field, contrast table (light and dark), warning box. | + +## Do's and Don'ts + +- **Do** keep one primary action per card and say what it will do and what happens next. +- **Do** show state in words as well as colour. +- **Do** keep pages short; split a long page before adding scrolling sections. +- **Don't** add decoration, illustrations or animation beyond Fluent's own. +- **Don't** use more than the one brand accent. +- **Don't** hide a state in colour alone, or in hover. +- **Don't** load fonts, icons or scripts from the network. diff --git a/_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/EXPERIENCE.md b/_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/EXPERIENCE.md new file mode 100644 index 0000000..e0be426 --- /dev/null +++ b/_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/EXPERIENCE.md @@ -0,0 +1,134 @@ +--- +name: ping-mee Portal +status: final +updated: 2026-10-15 +sources: + - _bmad-output/prds/prd-ping-mee-self-service-platform-2026-10-15/prd.md + - _bmad-output/architecture/architecture-ping-mee-self-service-platform-2026-10-15/ARCHITECTURE-SPINE.md +--- + +# ping-mee Portal — Experience Spine + +## Foundation + +Responsive web, server-rendered pages. Desktop and phone both work. UI system: Fluent 2 web components (version 3), built into the image. `DESIGN.md` is the visual identity reference and names the brand layer; this spine is the behavior. Users are family and friends who are not technical, so every page does one job and explains it. Languages: German and English. German has two address forms, formal "Sie" (default) and informal "du". + +## Information Architecture + +| Surface | Reached from | Purpose | Who | +|---|---|---|---| +| Startseite (dashboard) | Landing page, menu | Active announcements, account card, last announcements | everyone | +| Profil | Menu, account card | Edit first name, last name, display name, e-mail, telephone, mobile | everyone | +| Profilbild | Menu | Upload, crop, remove; shows Pending | everyone | +| Passwort | Menu | Change password | everyone | +| Ankündigungen | Menu, dashboard | Read current and past announcements | everyone | +| Benachrichtigungen | Menu | Opt in or out of announcement e-mails | everyone | +| E-Mail bestätigen / rückgängig | Link in an e-mail | Confirm or undo an e-mail change | link holder | +| Ankündigungen verwalten | Menu group "Veröffentlichen" | List, write, schedule, edit, withdraw | Publishers | +| Darstellung | Menu group "Administration" | Accent colour with contrast check, background image | Admins | +| Einstellungen | same | Portal name, session limits, throttle limits, audit retention | Admins | +| Protokoll | same | Read the audit log | Admins | +| Status | same | Backup age, failed e-mails, background jobs | Admins | +| Profile menu | Avatar, upper right | Theme, language, address form (German only), sign-out | everyone | + +On phones the left menu opens from a menu button. Dialogs never stack more than one level deep. Every stated need has a surface, and every surface is reached by a flow below. + +→ Composition reference: `mockups/key-dashboard.html`, `mockups/key-password.html`, `mockups/key-picture-pending.html`, `mockups/key-announcement-editor.html`, `mockups/key-admin-appearance.html`, `mockups/key-admin-status.html`, `mockups/key-admin-audit.html`. Spine wins on conflict. + +## Voice and Tone + +Microcopy. Brand posture lives in `DESIGN.md`. All text is translatable and kept in one translation file with three registers: German formal, German informal, English. The e-mails use the portal-wide default register. [ASSUMPTION: a per-device choice cannot reach server-sent mail.] + +| Do | Don't | +|---|---| +| "Ihre Änderungen wurden gespeichert." | "Operation erfolgreich." | +| "Ihr neues Bild erscheint innerhalb von etwa 2 Stunden." | "Der Sync-Task läuft periodisch." | +| "Zu viele Versuche. Bitte warten Sie einige Minuten." | "HTTP 429" | +| "Das ist gerade nicht möglich. Bitte versuchen Sie es später erneut." | Directory, LDAP, token or other system words | +| Say what the action does and what happens next. | Leave the user guessing after a click. | +| Short sentences, one idea each. | Long explanations before the form. | + +The informal setting changes only the form of address ("Sie" becomes "du", with matching verbs and possessives) and applies only to German. + +## Component Patterns + +Behavioral. Visual specs live in `DESIGN.md.Components`. + +| Component | Use | Behavioral rules | +|---|---|---| +| Announcement banner | Dashboard | Shows every active announcement, newest first, severity in colour, icon and word. Cannot be dismissed while active. | +| Account card | Dashboard | Shows picture (with Pending chip if waiting), name, username, e-mail, telephone, "Passwort zuletzt geändert" from AD. Link to Profil. | +| Form | Profil, Passwort, Profilbild | Explains the effect of the action above the first field. Validates on submit, shows errors next to the field and in a summary at the top. Primary button shows a spinner while working and is disabled. | +| Password rules | Passwort | The rules read from the domain's password policy are listed before the user types. [New requirement: the portal reads the policy from AD.] | +| Sensitive action | Passwort, E-Mail change | Asks for the current password in the same form. Never remembers it. | +| Picture editor | Profilbild | Choose a file, crop to a square and adjust in the browser, then save. Accepts JPEG, PNG, HEIC, WebP up to 10 MB; says why when it refuses a file. | +| Rich text editor | Ankündigungen verwalten | Toolbar: bold, italic, heading, list, link. The text shows as the finished result. Stored as Markdown, sanitized when shown. | +| Importance | Announcement editor | Always set; default "Information". Cannot be left empty. | +| Recipient summary | Announcement editor | States how many users get the e-mail and why others do not (opted out, no address). | +| Contrast check | Darstellung | Shows contrast for light and dark. Below 4.5:1 shows a warning; saving stays possible. | +| Profile menu | Every page | Theme, language, address form (shown only in German), sign-out. Choices are stored per device and apply immediately. | +| Filter chips | Protokoll | One selected at a time; "Alle" by default. | + +## State Patterns + +| State | Surface | Treatment | +|---|---|---| +| Saved | Any form | A success message at the top: what was saved and what happens next. | +| Validation error | Any form | Message under the field, linked to it, plus a summary; focus moves to the summary. | +| Password rejected by policy | Passwort | The reason in plain words, for example "Das Passwort ist zu kurz." Nothing is changed. | +| Too many attempts | Passwort, E-Mail change, links, upload | "Zu viele Versuche. Bitte warten Sie einige Minuten." No technical detail; the pause is not shown in numbers. | +| Pending | Profilbild, dashboard | Yellow chip "Wird aktualisiert" and the two-hour note. If it lasts longer than the configured time: "Das dauert länger als erwartet." | +| Loading | Buttons | Spinner on the button; the page stays usable. Cold page loads are plain server-rendered pages with no skeleton. | +| Empty | Ankündigungen, lists | A friendly line, for example "Derzeit gibt es keine Ankündigungen." | +| Unavailable | Any action needing AD, the share or mail | "Das ist gerade nicht möglich. Bitte versuchen Sie es später erneut." Nothing changes. | +| Session ended | Any page | The next action leads to sign-in with "Aus Sicherheitsgründen wurden Sie abgemeldet." [ASSUMPTION] | +| Not allowed | Publisher or Admin page for a normal user | The page does not exist for them: no menu entry; a direct address shows "Dazu fehlt Ihnen die Berechtigung." | +| Link used or expired | Confirm and undo pages | "Dieser Link ist abgelaufen oder wurde schon verwendet." with a way back to the portal. | +| Contrast warning | Darstellung | Warning box with the reason; "Trotzdem speichern" and "Andere Farbe wählen". | +| Backup older than 26 hours / failed mail | Status | The value turns red with a sentence saying what it means. | +| No account match | Sign-in | A generic refusal; no hint whether the name exists. | + +## Interaction Primitives + +- **Keyboard:** everything works with the keyboard; the focus outline is always visible; a "Zum Inhalt springen" link is the first tab stop. Escape closes the profile menu and dialogs. +- **Touch:** targets are at least 44 px; no hover-only controls. +- **Preferences per device:** theme (Hell, Dunkel, System; default System), language (default from the browser; switchable), and the German address form (default formal). They live in the profile menu from the avatar in the upper right and take effect without leaving the page. They are not stored on the server. +- **Confirmation links:** a link opens a page that names the change; only a button press applies it. +- **Banned:** infinite scroll, hover-only actions, dialogs stacked more than one deep, countdown timers visible to ordinary users, any action that runs on opening a link. + +## Accessibility Floor + +Behavioral. Visual contrast lives in `DESIGN.md`. Target: WCAG 2.1 AA. + +- Full keyboard use and a skip link. +- Colour is never the only signal; banners carry an icon and a word. +- Form errors are linked to their fields and announced to screen readers. +- Headings are in order; each page has one main heading. +- Labels are visible, never only placeholders. +- Text can be enlarged to 200 percent without loss of content. +- The Admin accent colour is checked for contrast before it can be applied, with a warning. + +## Responsive & Platform + +Two layouts. Wide: left menu plus content. Narrow (phones): content only, with the menu behind a menu button; label/value rows stack; the two-column card grids become one column. The break is around 720 px. [ASSUMPTION: exact breakpoint decided at build.] Dark and light follow the system by default. + +## Inspiration & Anti-patterns + +Inspiration: the Microsoft 365 admin center's settings pages (calm, one topic per page, explanations beside controls). Anti-patterns: dense admin tables for ordinary users; long walls of help text; jargon from directory services; many accent colours. + +## Key Flows + +Journeys mirror the PRD (UJ-1 to UJ-4). + +- **UJ-1. Lena changes her password before it leaks.** Lena signs in through authentik and lands on the dashboard. She opens Passwort. The page tells her the effect (other devices are signed out, she gets an e-mail) and lists the rules. She enters her current password and the new one twice and presses "Passwort ändern". *Climax:* the page says her password was changed. *Resolution:* she receives a confirmation e-mail. *Edge:* a rule is broken; the reason shows in plain words and nothing changes. +- **UJ-2. Tom sets a profile picture from his iPhone.** On Profilbild Tom picks a HEIC photo, crops it square and saves. *Climax:* the page shows his current picture with the chip "Wird aktualisiert" and the two-hour note. *Resolution:* when the sync has run, the chip disappears. *Edge:* a 14 MB file is refused with a reason before upload. +- **UJ-3. Ebbe announces a maintenance window.** In Ankündigungen verwalten Ebbe writes a title, sets importance to Warnung, writes the text in the editor, sets the maintenance window and an expiry, and schedules it for the next morning. The recipient summary shows who will be e-mailed. *Climax:* at the scheduled time the banner appears on every dashboard and the e-mail goes out. *Resolution:* after the move he withdraws it. *Edge:* he corrects the end time and chooses "Benutzer erneut benachrichtigen". +- **UJ-4. Marie sees an e-mail change she did not make.** Marie opens the undo link from the notice at her old address. The page names the change and shows one button. *Climax:* she presses "Änderung rückgängig machen" and her old address is back. *Resolution:* she is told to change her password, her sessions end, and the Admin is notified. + +## Open items + +- The password rules text depends on reading the domain policy from AD; this is a new requirement for the PRD (FR-5) and the architecture spine (AD-2 read allow-list). +- The WYSIWYG editor changes PRD FR-16 and AD-16; the editor library is chosen at build and must be built into the image. +- Severity is required with the default "Information" (PRD FR-17 listed it as optional). +- The Einstellungen page was not mocked; it is a labelled list of fields with an explanation beside each. +- The session-ended message and the exact phone breakpoint are assumptions. diff --git a/_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/mockups/key-admin-appearance.html b/_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/mockups/key-admin-appearance.html new file mode 100644 index 0000000..6d9f8bd --- /dev/null +++ b/_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/mockups/key-admin-appearance.html @@ -0,0 +1,99 @@ +Darstellung (Admin) - contrast warning state

Darstellung (Admin) - contrast warning state

Accent colour with the contrast check for both themes and the warning; saving stays possible. Background upload. Illustrates: FR-22 and the check-and-warn decision. Colour values are computed.

ping-mee PortalEB
+

Darstellung

+

Farbe und Hintergrund gelten für alle Benutzer des Portals.

+

Akzentfarbe

+
#FFD60A  Standard: #0F6CBD
+ + +
AnzeigeText auf der FarbeKontrastErgebnis
HellWeiß1.4 : 1zu niedrig (Minimum 4,5 : 1)
DunkelFast schwarz14.0 : 1ausreichend
+
Warnung: Im hellen Design ist weißer Text auf dieser Farbe schwer lesbar. Sie können sie trotzdem speichern, aber manche Benutzer erkennen Schaltflächen dann schlechter.
+

Trotzdem speichernAndere Farbe wählen

+

Hintergrundbild

+

Das Bild erscheint nur für angemeldete Benutzer. Es wird beim Hochladen neu kodiert; bis 10 MB.

+Bild hochladenKein Bild
+
ping-mee PortalEB
+

Darstellung

+

Farbe und Hintergrund gelten für alle Benutzer des Portals.

+

Akzentfarbe

+
#FFD60A  Standard: #0F6CBD
+ + +
AnzeigeText auf der FarbeKontrastErgebnis
HellWeiß1.4 : 1zu niedrig (Minimum 4,5 : 1)
DunkelFast schwarz14.0 : 1ausreichend
+
Warnung: Im hellen Design ist weißer Text auf dieser Farbe schwer lesbar. Sie können sie trotzdem speichern, aber manche Benutzer erkennen Schaltflächen dann schlechter.
+

Trotzdem speichernAndere Farbe wählen

+

Hintergrundbild

+

Das Bild erscheint nur für angemeldete Benutzer. Es wird beim Hochladen neu kodiert; bis 10 MB.

+Bild hochladenKein Bild
+
\ No newline at end of file diff --git a/_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/mockups/key-admin-audit.html b/_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/mockups/key-admin-audit.html new file mode 100644 index 0000000..676298a --- /dev/null +++ b/_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/mockups/key-admin-audit.html @@ -0,0 +1,91 @@ +Protokoll (Admin)

Protokoll (Admin)

Audit log with filter chips, 12-month note, IP column. Illustrates: FR-24 and FR-28. Sample data.

ping-mee PortalEB
+

Protokoll

Wer hat wann was geändert? Einträge werden 12 Monate aufbewahrt. Passwörter und Bildinhalte werden nie gespeichert.

+
AllePasswortProfilE-Mail-ÄnderungProfilbildAnkündigungenEinstellungenGedrosselt
+
+ + + + +
ZeitBenutzerAktionIP-Adresse
15.10. 09:41lmeierPasswort geändert203.0.113.24
15.10. 09:40lmeierFalsches aktuelles Passwort (1 von 5)203.0.113.24
15.10. 08:12tkochProfilbild hochgeladen198.51.100.7
14.10. 20:03msommerE-Mail-Änderung rückgängig gemacht198.51.100.33
14.10. 17:30ebassAnkündigung veröffentlicht192.0.2.10
+
ping-mee PortalEB
+

Protokoll

Wer hat wann was geändert? Einträge werden 12 Monate aufbewahrt. Passwörter und Bildinhalte werden nie gespeichert.

+
AllePasswortProfilE-Mail-ÄnderungProfilbildAnkündigungenEinstellungenGedrosselt
+
+ + + + +
ZeitBenutzerAktionIP-Adresse
15.10. 09:41lmeierPasswort geändert203.0.113.24
15.10. 09:40lmeierFalsches aktuelles Passwort (1 von 5)203.0.113.24
15.10. 08:12tkochProfilbild hochgeladen198.51.100.7
14.10. 20:03msommerE-Mail-Änderung rückgängig gemacht198.51.100.33
14.10. 17:30ebassAnkündigung veröffentlicht192.0.2.10
+
\ No newline at end of file diff --git a/_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/mockups/key-admin-status.html b/_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/mockups/key-admin-status.html new file mode 100644 index 0000000..8466048 --- /dev/null +++ b/_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/mockups/key-admin-status.html @@ -0,0 +1,103 @@ +Status (Admin)

Status (Admin)

Backup age, failed e-mails and job runs, all in one place because nothing else alerts the Admin. Illustrates: AD-12, AD-14 status surfaces.

ping-mee PortalEB
+

Status

Hier sehen Sie, ob die Hintergrundaufgaben laufen. Probleme bei Datensicherung und E-Mails erscheinen nur hier.

+
+

Datensicherung

Letzte erfolgreiche Sicherung: vor 3 Stunden

Ihre Veeam-Kopie sichert diesen Ordner. Ist die Sicherung älter als 26 Stunden, wird sie hier rot.

+

E-Mail-Versand

2 E-Mails fehlgeschlagen

Der Versand wird automatisch wiederholt. Nach 5 Versuchen bleibt die E-Mail hier stehen.

+

Fehlgeschlagene E-Mails

+ + +
EmpfängerBetreffVersucheGrund
tom.k@example.comGeplante Wartung am Samstag3Postfach voll
marie.s@example.comGeplante Wartung am Samstag2Server nicht erreichbar
+

Hintergrundaufgaben

+ + + + +
AufgabeLetzter LaufErgebnis
Geplante Ankündigungenvor 20 Sekundenin Ordnung
E-Mail-Versandvor 20 Sekundenin Ordnung
Abgleich mit dem Konto-Verzeichnisheute 03:00 Uhrin Ordnung
Protokoll aufräumenheute 03:05 Uhrin Ordnung
+
ping-mee PortalEB
+

Status

Hier sehen Sie, ob die Hintergrundaufgaben laufen. Probleme bei Datensicherung und E-Mails erscheinen nur hier.

+
+

Datensicherung

Letzte erfolgreiche Sicherung: vor 3 Stunden

Ihre Veeam-Kopie sichert diesen Ordner. Ist die Sicherung älter als 26 Stunden, wird sie hier rot.

+

E-Mail-Versand

2 E-Mails fehlgeschlagen

Der Versand wird automatisch wiederholt. Nach 5 Versuchen bleibt die E-Mail hier stehen.

+

Fehlgeschlagene E-Mails

+ + +
EmpfängerBetreffVersucheGrund
tom.k@example.comGeplante Wartung am Samstag3Postfach voll
marie.s@example.comGeplante Wartung am Samstag2Server nicht erreichbar
+

Hintergrundaufgaben

+ + + + +
AufgabeLetzter LaufErgebnis
Geplante Ankündigungenvor 20 Sekundenin Ordnung
E-Mail-Versandvor 20 Sekundenin Ordnung
Abgleich mit dem Konto-Verzeichnisheute 03:00 Uhrin Ordnung
Protokoll aufräumenheute 03:05 Uhrin Ordnung
+
\ No newline at end of file diff --git a/_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/mockups/key-announcement-editor.html b/_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/mockups/key-announcement-editor.html new file mode 100644 index 0000000..34ea82d --- /dev/null +++ b/_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/mockups/key-announcement-editor.html @@ -0,0 +1,109 @@ +Ankündigung verfassen (Publisher)

Ankündigung verfassen (Publisher)

Desktop light, phone dark. Title, importance (always set, default Information) and a WYSIWYG text editor; optional downtime window, expiry and schedule; recipient count with the reason for each gap. Illustrates: UJ-3, publisher flow. The left menu shows the extra entries for Publishers and Admins.

ping-mee PortalEB
+

Ankündigung verfassen

+

Ihre Ankündigung erscheint auf der Startseite und kann per E-Mail an alle angemeldeten Empfänger gehen.

+
Geplante Wartung am Samstag
+
InformationWarnungAusfall +
Bestimmt die Farbe der Meldung auf der Startseite. Ohne Auswahl gilt „Information“.
+
+
FKÜberschriftListeLink
+
Jellyfin und die Dateifreigabe sind am Samstag von 08:00 bis 12:00 Uhr nicht erreichbar.
  • Grund: Umzug auf neue Hardware
  • Alles andere läuft normal
+
So sehen es Benutzer auf der Startseite und in der E-Mail.
+

Weitere Angaben (optional)

+
18.10.2026 08:00
18.10.2026 12:00
+
Zeitzone: Europe/Berlin. Die Zeitzone steht auch in der E-Mail.
+
18.10.2026 13:00
Danach verschwindet die Ankündigung von der Startseite.
+
SofortGeplant
17.10.2026 08:00
+

E-Mail

Benutzer per E-Mail benachrichtigen
+

Empfänger: 9 von 12 Benutzern. 2 haben E-Mails abgeschaltet, 1 hat keine E-Mail-Adresse hinterlegt.

+VeröffentlichenAls Entwurf speichern +
ping-mee PortalEB
+

Ankündigung verfassen

+

Ihre Ankündigung erscheint auf der Startseite und kann per E-Mail an alle angemeldeten Empfänger gehen.

+
Geplante Wartung am Samstag
+
InformationWarnungAusfall +
Bestimmt die Farbe der Meldung auf der Startseite. Ohne Auswahl gilt „Information“.
+
+
FKÜberschriftListeLink
+
Jellyfin und die Dateifreigabe sind am Samstag von 08:00 bis 12:00 Uhr nicht erreichbar.
  • Grund: Umzug auf neue Hardware
  • Alles andere läuft normal
+
So sehen es Benutzer auf der Startseite und in der E-Mail.
+

Weitere Angaben (optional)

+
18.10.2026 08:00
18.10.2026 12:00
+
Zeitzone: Europe/Berlin. Die Zeitzone steht auch in der E-Mail.
+
18.10.2026 13:00
Danach verschwindet die Ankündigung von der Startseite.
+
SofortGeplant
17.10.2026 08:00
+

E-Mail

Benutzer per E-Mail benachrichtigen
+

Empfänger: 9 von 12 Benutzern. 2 haben E-Mails abgeschaltet, 1 hat keine E-Mail-Adresse hinterlegt.

+VeröffentlichenAls Entwurf speichern +
\ No newline at end of file diff --git a/_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/mockups/key-dashboard.html b/_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/mockups/key-dashboard.html new file mode 100644 index 0000000..170d5fd --- /dev/null +++ b/_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/mockups/key-dashboard.html @@ -0,0 +1,85 @@ +Dashboard (Startseite)

Dashboard (Startseite)

Desktop light with the profile menu open (theme, language, formality). Phone dark: menu collapsed, cards stacked. Illustrates: IA Dashboard, announcement banner (warning), account card including password age.

ping-mee PortalLM
+

Guten Tag, Lena

Hier sehen Sie aktuelle Hinweise und Ihre Kontodaten auf einen Blick.

+ +
+

Ihr Konto

+
LM
Lena Meier
Benutzername: lmeier
+
E-Maillena.meier@example.com
+
Telefon+49 170 1234567
+
Passwort zuletzt geändert3. September 2026
+

Profil bearbeiten

+

Letzte Ankündigungen

+
12. Okt.Neue Serie in Jellyfin verfügbar
+
29. Sep.Server-Umzug abgeschlossen
+
14. Sep.Kurze Unterbrechung heute Abend
+

Alle Ankündigungen

+
ping-mee PortalLM
+

Guten Tag, Lena

Hier sehen Sie aktuelle Hinweise und Ihre Kontodaten auf einen Blick.

+ +
+

Ihr Konto

+
LM
Lena Meier
Benutzername: lmeier
+
E-Maillena.meier@example.com
+
Telefon+49 170 1234567
+
Passwort zuletzt geändert3. September 2026
+

Profil bearbeiten

+

Letzte Ankündigungen

+
12. Okt.Neue Serie in Jellyfin verfügbar
+
29. Sep.Server-Umzug abgeschlossen
+
14. Sep.Kurze Unterbrechung heute Abend
+

Alle Ankündigungen

+
\ No newline at end of file diff --git a/_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/mockups/key-password.html b/_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/mockups/key-password.html new file mode 100644 index 0000000..549818d --- /dev/null +++ b/_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/mockups/key-password.html @@ -0,0 +1,73 @@ +Passwort ändern

Passwort ändern

Desktop light, phone dark. Rules shown before typing; effect of the change stated up front; forgotten or expired passwords point to the Admin. Illustrates: password flow (UJ-1). The listed rules are sample text; the real ones must come from the domain policy.

ping-mee PortalLM
+

Passwort ändern

+

Dieses Passwort gilt für alle ping-mee-Dienste. Nach der Änderung werden Sie auf anderen Geräten abgemeldet, und Sie erhalten eine Bestätigung per E-Mail.

+
+
••••••••••
Wir prüfen es, bevor etwas geändert wird.
+
 
+
  • Mindestens 12 Zeichen
  • Groß- und Kleinbuchstaben, eine Zahl
  • Nicht eines Ihrer letzten Passwörter
+
 
+Passwort ändernAbbrechen
+

Passwort vergessen oder abgelaufen? Wenden Sie sich bitte an den Administrator.

+
ping-mee PortalLM
+

Passwort ändern

+

Dieses Passwort gilt für alle ping-mee-Dienste. Nach der Änderung werden Sie auf anderen Geräten abgemeldet, und Sie erhalten eine Bestätigung per E-Mail.

+
+
••••••••••
Wir prüfen es, bevor etwas geändert wird.
+
 
+
  • Mindestens 12 Zeichen
  • Groß- und Kleinbuchstaben, eine Zahl
  • Nicht eines Ihrer letzten Passwörter
+
 
+Passwort ändernAbbrechen
+

Passwort vergessen oder abgelaufen? Wenden Sie sich bitte an den Administrator.

+
\ No newline at end of file diff --git a/_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/mockups/key-picture-pending.html b/_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/mockups/key-picture-pending.html new file mode 100644 index 0000000..35202ad --- /dev/null +++ b/_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/mockups/key-picture-pending.html @@ -0,0 +1,69 @@ +Profilbild - Pending state

Profilbild - Pending state

Desktop light, phone dark. Pending chip plus the two-hour note; the current picture stays until the sync has run. Illustrates: picture flow (UJ-2), pending state.

ping-mee PortalLM
+

Profilbild

+

Ihr Bild erscheint in den ping-mee-Diensten, zum Beispiel in Jellyfin.

+
+
LM
+
Wird aktualisiert

Ihr neues Bild wurde gespeichert. Es erscheint innerhalb von etwa 2 Stunden. Bis dahin sehen Sie hier noch das bisherige Bild.

+Neues Bild auswählenBild entfernen +
Erlaubt sind JPEG, PNG, HEIC und WebP bis 10 MB. Sie können das Bild vor dem Speichern zuschneiden.
+
ping-mee PortalLM
+

Profilbild

+

Ihr Bild erscheint in den ping-mee-Diensten, zum Beispiel in Jellyfin.

+
+
LM
+
Wird aktualisiert

Ihr neues Bild wurde gespeichert. Es erscheint innerhalb von etwa 2 Stunden. Bis dahin sehen Sie hier noch das bisherige Bild.

+Neues Bild auswählenBild entfernen +
Erlaubt sind JPEG, PNG, HEIC und WebP bis 10 MB. Sie können das Bild vor dem Speichern zuschneiden.
+
\ No newline at end of file diff --git a/_bmad/config.toml b/_bmad/config.toml new file mode 100644 index 0000000..e8d9807 --- /dev/null +++ b/_bmad/config.toml @@ -0,0 +1,3 @@ +[core] +project_name = "ping-mee-self-service-platform" +output_folder = "{project-root}/_bmad-output" diff --git a/_bmad/core-tools/scripts/.gitkeep b/_bmad/core-tools/scripts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/_bmad/custom/.gitignore b/_bmad/custom/.gitignore new file mode 100644 index 0000000..42d7bc6 --- /dev/null +++ b/_bmad/custom/.gitignore @@ -0,0 +1 @@ +*.user.toml diff --git a/_bmad/custom/bmad-ticket.toml b/_bmad/custom/bmad-ticket.toml new file mode 100644 index 0000000..5fa2769 --- /dev/null +++ b/_bmad/custom/bmad-ticket.toml @@ -0,0 +1,2 @@ +[workflow] +slice_to_epics = "An epic is a part of the product the user wants usable early, delivered to production by one owner. Split only for a distinct outcome, a distinct owner, or a part usable early, never for a ticket count." diff --git a/_bmad/method/scripts/tickets.py b/_bmad/method/scripts/tickets.py new file mode 100644 index 0000000..612fee7 --- /dev/null +++ b/_bmad/method/scripts/tickets.py @@ -0,0 +1,1473 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""tickets — read a ticket tree and answer what is next. + +A container folder holds its ticket file, `tickets.toml`, flat leaf files named `-.md`, +and the builds' plan files. An epic's `tickets.toml` lists its planned leaves as `[[entry]]` tables +(`id`, `type`, `title`, `after`, and whatever else the plan records); an initiative's lists its +epics as `[[epic]]` tables (`id`, `slug`, `after = [{epic, needs}]`). Tables are in build order. +`id` names an entry for good and is never reused: a number, or letters and digits such as `6a`, and +`6` and `"6"` are one id. A leaf file carries it in frontmatter, which is how the file joins its +entry. An entry needs no leaf file to start. A ticket needs refining before it starts when it is a +bug, its entry says `refine = true`, or it has no entry. A leaf file's frontmatter adds +`tracker_status` and `refined`; its `after` and `hitl` replace the entry's, absent reading as empty, +and a difference from the entry is `drift` on the row, which gives the file's and the entry's +value of each field that differs. An entry's `unknown` is a question to settle before the ticket starts; +once the entry is pulled it is the `Unknown:` lines of the file's Notes. A row carries it, and next holds +the ticket under `blocked` until it is gone. A row also carries `plan_checkpoint` and `done_checkpoint` +when the entry sets them: the orchestrator of an unattended run reads them, and no build does. `risk` +(low, medium, high) is the ticket's proposed risk, the leaf file's once it is pulled; the build settles it +in its plan. + +A plan is any other `.md` whose frontmatter has `ticket` and whose `type` is not a leaf type. A +`ticket` that is an id joins the entry with that id in the plan's folder; any other string joins the +leaf file with that stem (a backlog leaf). A plan is never a row of its own. It holds the ticket's +`status`, `assignee`, `blocked_at`, and `blocked_reason`; a leaf file's own fields are read only when +the ticket has no plan, and a plan with no `assignee` key leaves the leaf file's. A plan whose `ticket` +names nothing is skipped, and one whose `status` is unknown blocks its ticket; next and status list both +under `problems`. + +`status` is draft, ready-for-dev, in-progress, in-review, or built from the builds (built is their +last: the build finished and nobody has called it done), blocked from build-auto, done from the +user or an orchestrator through mark, or dropped; absent means no build has started. +On a tracker store `tracker_status` mirrors the tracker's word (backlog, in-progress, review, done, +dropped). A ticket's `state` is `planned` with no file and no plan, else `tracker_status`, else +derived from `status`: absent, draft, ready-for-dev -> backlog; in-progress, blocked -> in-progress; +in-review, built -> review; done; dropped. + +`after` lists real prerequisites: a sibling's id (a number bare, since a quoted number is a tracker +id; an id with a letter quoted), or a quoted string that is `.` for an entry in +another epic of the same initiative, `epic-` for that whole epic, a sibling's file name, or a +tracker id. An epic file's own `after` names epics and holds every ticket under it; rows show it as `gated_by`. A dropped prerequisite still blocks. + +On an initiative, or an epic it lists, next and status report `unpinned_after` (a declared epic +`after` no entry of the waiting epic pins), `undeclared_after` (an entry's `after` into an epic its +own epic does not declare), and `order_conflict` (an epic that waits on one later in build order). +status's `epics` rows carry the declared `after` with its `needs`, and the epic file's own gate as +`gated_by`. + + next [] tickets whose prerequisites are done or in review, grouped by state, in + build order; an epic's own `after` waits for that epic to be done. A + `blocked` row's `waiting_on` lists its unmet prerequisites + status [] every ticket in build order, what it blocks, counts by state, longest chain, + `next_id` (the id a new ticket in the folder takes: one past the highest + number used, or `next_id` at the top of its `tickets.toml` when that is + higher) + find [] the one ticket a reference names, with its entry's text fields (empty once + the entry is pulled: the file holds them) and the absolute paths + `epic_file` (its container's file), `story_file` (null until pulled), and + `plan` (where its plan is or goes) + pull write entry id's leaf file: `after` and `hitl` always, other fields only + when the entry sets them; no status + mark [] [--assignee ] [--blocked ] + set a ticket's status in its plan, creating a frontmatter-only plan when + there is none; --blocked sets blocked_at and blocked_reason, else both are + cleared (repo store only) + mirror [] write what a tracker returned into leaf files, pulling a file when the + entry has none. Stdin is a JSON array, one object per ticket: `ref` (or + `tracker_id` alone, for a ticket that already carries it) and any of + `tracker_id`, `remote`, `tracker_status`, `assignee`, `after`. Only the + keys given are written, and never `status` (tracker stores only) + +`` is an epic folder, a backlog folder, or an initiative folder (all its epics). `` is +`.`, an entry id inside an epic folder (one with a letter, from any folder), a +tracker id, a file name, or an unbroken phrase from the title that matches one ticket. Each row of +next, status, and find carries `ref`, a reference find resolves in the folder the command ran on. +`--project-root` names the project holding `_bmad/` when the tickets live outside it. A relative +`` that is not a folder under the working directory is looked up under `{output_folder}`, then +the active initiative, then the project root, so an epic's folder name alone finds it. + +With no ``, next, status, find, mark, and mirror run on the active initiative, +`{output_folder}/{active_initiative}`. next and status then also report `backlog`, the same view of +`{output_folder}/backlog` when that folder exists, each row's `ref` its file name; find, mark, and mirror +look there for a ticket the initiative does not hold. +The project root is `--project-root`, else the first folder at or above the working directory that +holds `_bmad/`. `active_initiative` and `output_folder` (`[core]`) come from the +BMad config, merged by the project's `_bmad/scripts/config_utils.py`. `{project-root}` is +substituted, and a relative path is taken from the project root. + +Output is one JSON object on stdout. Exit 0 on success, 1 on a malformed tree, 2 when +the store forbids the operation. +""" + +import argparse +import codecs +import importlib.util +import json +import os +import re +import sys +import tomllib +import unicodedata +from datetime import date +from pathlib import Path + +sys.dont_write_bytecode = True + +STATUSES = ("draft", "ready-for-dev", "in-progress", "in-review", "built", "done", "blocked", "dropped") +STATES = ("backlog", "in-progress", "review", "done", "dropped") +CONTAINER_STATUSES = ("in-progress", "done", "dropped") +STATE_OF = { + "": "backlog", + "draft": "backlog", + "ready-for-dev": "backlog", + "in-progress": "in-progress", + "blocked": "in-progress", + "in-review": "review", + "built": "review", + "done": "done", + "dropped": "dropped", +} +LEAF_TYPES = ("story", "spike", "bug") +CONTAINER_TYPES = ("initiative", "epic") +NAME_RE = re.compile(r"^(story|spike|bug)-(.+)\.md$") +ID_RE = re.compile(r"^[0-9A-Za-z]+$") +CROSS_RE = re.compile(r"^([0-9A-Za-z]+)\.([0-9A-Za-z]+)$") +EPIC_RE = re.compile(r"^epic-[^/]+$") +BREAKDOWN = "tickets.toml" +QUOTED_COMMENT_RE = re.compile(r"""^("(?:[^"\\]|\\.)*"|'(?:[^']|'')*')\s+#.*$""") +FRONTMATTER_RE = re.compile(r"\A---\n(.*?)\n---(?:\n|\Z)", re.S) +PLAN_FIELDS = ("status", "assignee", "blocked_at", "blocked_reason") +COMMENT_RE = re.compile(r"", re.S) # a template's example sits in one +UNKNOWN_RE = re.compile(r"^[ \t]*(?:[-*][ \t]+)?Unknown:[ \t]*(\S.*)$", re.M) + + +class TicketError(Exception): + """`data` is added to the error object the command prints.""" + + def __init__(self, message: str, **data): + super().__init__(message) + self.data = data + + +class NoMatch(TicketError): + pass + + +class StoreRefusal(Exception): + pass + + +# ---------------------------------------------------------------- frontmatter + + +def parse_frontmatter(text: str, lenient: bool = False) -> dict: + """Minimal YAML subset: `key: value`, lists as `[a, b]`, quoted or bare scalars. Lenient + skips block lists instead of refusing them, for plans written from the build's template.""" + m = FRONTMATTER_RE.match(text) + if not m: + return {} + data = {} + for line in m.group(1).splitlines(): + if lenient and (line[:1].isspace() or line.startswith("- ")): + continue + if line.lstrip().startswith("- "): + raise TicketError("frontmatter lists must be inline: `key: [a, b]`") + if not line.strip() or line.lstrip().startswith("#") or ":" not in line: + continue + key, _, value = line.partition(":") + value = value.split(" #")[0].strip() + quoted = QUOTED_COMMENT_RE.match(value) + data[key.strip()] = _scalar(quoted.group(1) if quoted else value) + return data + + +def _scalar(value: str): + if value.startswith("[") and value.endswith("]"): + inner = value[1:-1].strip() + return [] if not inner else [_scalar(v.strip()) for v in inner.split(",")] + if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": + if value[0] == '"': + try: + return str(json.loads(value)) + except ValueError: + pass + return value[1:-1] if value[0] == '"' else value[1:-1].replace("''", "'") + if value in ("true", "false"): + return value == "true" + if re.fullmatch(r"-?\d+", value): + return int(value) + return value + + +def set_frontmatter_value(text: str, key: str, value: str) -> str: + m = FRONTMATTER_RE.match(text) + if not m: + raise TicketError("ticket has no frontmatter") + block = m.group(1) + pattern = re.compile(rf"^{re.escape(key)}:.*$\n?", re.M) + if value == "": + block = pattern.sub("", block).rstrip("\n") + elif pattern.search(block): + block = pattern.sub(lambda _: f"{key}: {value}\n", block, count=1).rstrip("\n") + else: + block = f"{block}\n{key}: {value}" + return text[: m.start(1)] + block + text[m.end(1) :] + + +def _list(value, where: str) -> list: + if value in (None, ""): + return [] + if not isinstance(value, list): + raise TicketError(f"{where}: after must be a list") + return value + + +def _flag(value) -> bool: + return str(value).lower() == "true" + + +def _one_of(value, allowed: tuple, where: str, field: str): + """`value` when it is absent (`""`) or one of `allowed`; else the error naming them.""" + if value not in ("", *allowed): + raise TicketError(f"{where}: {field} {value!r} is not one of {', '.join(allowed)}") + return value + + +# ---------------------------------------------------------------- loading + + +def read_text(path: Path) -> str: + # utf-8-sig: Windows editors can save a byte-order mark, which would hide the frontmatter. + return path.read_text(encoding="utf-8-sig") + + +def load_breakdown(folder: Path) -> dict: + path = folder / BREAKDOWN + if not path.is_file(): + return {} + where = f"{folder.name}/{BREAKDOWN}" + try: + data = tomllib.loads(read_text(path)) + except tomllib.TOMLDecodeError as e: + raise TicketError(f"{where}: {e}") from e + floor = data.get("next_id", 0) + if isinstance(floor, bool) or not isinstance(floor, int): + raise TicketError(f"{where}: `next_id` must be a whole number") + for table in ("entry", "epic"): + rows = data.get(table, []) + if not isinstance(rows, list) or not all(isinstance(r, dict) for r in rows): + raise TicketError(f"{where}: write `[[{table}]]` tables, one per {table}") + for r in rows: + for key in ("covers", "after", "references", "notes"): + if not isinstance(r.get(key, []), list): + raise TicketError(f"{where}: `{key}` must be a list") + r["id"] = _id(r.get("id")) + if r["id"] is None: + raise TicketError(f"{where}: every {table} needs an `id`: a number, or letters and digits") + if table == "epic": + if not isinstance(r.get("slug"), str) or not r["slug"]: + raise TicketError(f"{where}: epic {r['id']} needs a `slug`") + for a in r.get("after", []): + if not isinstance(a, dict) or (_id(a.get("epic")) is None and not isinstance(a.get("epic"), str)): + raise TicketError( + f'{where}: an epic\'s `after` takes tables: [{{ epic = , needs = "..." }}]' + ) + return data + + +def _id(value): + """An id as the tree compares it, else None: a number, or letters and digits; digits alone are the number.""" + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + # A frontmatter value of true or false is read as a boolean, so neither can be an id. + if isinstance(value, str) and ID_RE.match(value) and value not in ("true", "false"): + return int(value) if value.isdigit() else value + return None + + +def load_container(folder: Path) -> dict: + path = folder / f"{folder.name}.md" + if not path.is_file(): + raise TicketError(f"{folder.name}: no {path.name}") + fm = parse_frontmatter(read_text(path)) + if fm.get("type") not in CONTAINER_TYPES: + raise TicketError( + f"{folder.name}/{path.name}: type {fm.get('type')!r} is not one of {', '.join(CONTAINER_TYPES)}" + ) + status = _one_of(fm.get("status", ""), CONTAINER_STATUSES, f"{folder.name}/{path.name}", "status") + return { + "slug": folder.name, + "tracker_id": str(fm.get("tracker_id", "") or ""), + "status": status, + "raw_after": _list(fm.get("after"), f"{folder.name}.md"), + } + + +def file_unknown(text: str) -> str: + """The `Unknown:` lines of a leaf file's body, as `pull` writes an entry's `unknown` into Notes.""" + body = COMMENT_RE.sub("", FRONTMATTER_RE.sub("", text, count=1)) + return "; ".join(m.group(1).strip() for m in UNKNOWN_RE.finditer(body)) + + +def load_folder(folder: Path, problems: list[str]) -> list[dict]: + """One row per ticket in a folder, in build order: every breakdown entry, joined to its + leaf file when one exists, then leaf files the breakdown does not list. Plans then set + the status fields of the rows they join.""" + where = folder.name + rows = {} + for e in load_breakdown(folder).get("entry", []): + n, kind = e["id"], e.get("type") + if kind not in LEAF_TYPES: + raise TicketError(f"{where}/{BREAKDOWN}: entry {n} type {kind!r} is not one of {', '.join(LEAF_TYPES)}") + if n in rows: + raise TicketError(f"{where}/{BREAKDOWN}: two entries with id {n}") + rows[n] = { + "epic": where, + "id": n, + "file": None, + "type": kind, + "tracker_id": "", + "title": str(e.get("title", "")), + "status": "", + "tracker_status": "", + "state": "planned", + "assignee": "", + "refined": False, + "refine": kind == "bug" or _flag(e.get("refine", False)), + "description": str(e.get("description", "")), + "verify": str(e.get("verify", "")), + "unknown": str(e.get("unknown", "")), + "references": [str(v) for v in e.get("references", [])], + "notes": [str(v) for v in e.get("notes", [])], + "risk": str(e.get("risk", "")), + "hitl": _flag(e.get("hitl", False)), + "plan_checkpoint": _flag(e.get("plan_checkpoint", False)), + "done_checkpoint": _flag(e.get("done_checkpoint", False)), + "covers": [str(c) for c in e.get("covers", [])], + "estimate": e.get("estimate", ""), + "blocked_at": "", + "blocked_reason": "", + "raw_after": _list(e.get("after"), f"{where}/{BREAKDOWN} entry {n}"), + "entry_after": None, + } + unlisted, stray, plans = {}, [], [] + seen = {} + for path in sorted(folder.glob("*.md")): + text = read_text(path) + fm = parse_frontmatter(text, lenient=True) + if not fm and text.startswith("---") and NAME_RE.match(path.name): + raise TicketError(f"{where}/{path.name}: frontmatter does not close") + if fm.get("type") not in LEAF_TYPES: + if "ticket" in fm: + plans.append((path.name, fm)) + continue + try: + fm = parse_frontmatter(text) + except TicketError as e: + raise TicketError(f"{where}/{path.name}: {e}") from e + status = _one_of(fm.get("status", ""), STATUSES, f"{where}/{path.name}", "status") + tracker_status = _one_of(fm.get("tracker_status", ""), STATES, f"{where}/{path.name}", "tracker_status") + n = _id(fm.get("id")) + if n is not None: + if n in seen: + raise TicketError(f"{seen[n]} and {path.name} share the id {n}") + seen[n] = path.name + row = rows.get(n) if n is not None else None + if row is None: + row = {"epic": where, "id": n, "raw_after": [], "entry_after": None, "covers": [], "title": ""} + row["refine"] = True + if n is None: + stray.append(row) + else: + unlisted[n] = row + else: + row["entry_after"] = row["raw_after"] + row["entry_hitl"] = row["hitl"] + row.update( + { + "file": path.name, + "type": fm.get("type"), + "tracker_id": str(fm.get("tracker_id", "") or ""), + "title": str(fm.get("title", "") or row["title"]), + "status": status, + "tracker_status": tracker_status, + "state": tracker_status or STATE_OF[status], + "assignee": str(fm.get("assignee", "") or ""), + "refined": _flag(fm.get("refined", False)), + "refine": row["refine"] or fm.get("type") == "bug", + "hitl": _flag(fm.get("hitl", False)), + "covers": [str(c) for c in fm["covers"]] if isinstance(fm.get("covers"), list) else row["covers"], + "estimate": fm.get("estimate", row.get("estimate", "")), + "risk": fm["risk"] if isinstance(fm.get("risk"), str) else row.get("risk", ""), + "blocked_at": fm.get("blocked_at", ""), + "blocked_reason": str(fm.get("blocked_reason", "") or ""), + "unknown": file_unknown(text), + } + ) + row["raw_after"] = _list(fm.get("after", []), f"{where}/{path.name}") + out = list(rows.values()) + [unlisted[n] for n in sorted(unlisted, key=lambda n: (isinstance(n, str), n))] + stray + join_plans(out, plans, where, problems) + return out + + +def join_plans(rows: list[dict], plans: list[tuple[str, dict]], where: str, problems: list[str]) -> None: + """Set each plan's status fields on the one row its `ticket` names; a bad plan is a problem, not an error.""" + for name, fm in plans: + ticket = fm["ticket"] + row = None + if _id(ticket) is not None: + ticket = _id(ticket) + row = next((r for r in rows if r["id"] == ticket), None) + if row is None and isinstance(ticket, str) and ticket: + row = next((r for r in rows if r["file"] == f"{ticket}.md"), None) + if row is None: + problems.append(f"{where}/{name}: ticket {ticket!r} names no entry or leaf file in {where}; skipped") + continue + if "plan" in row: + raise TicketError(f"{where}/{row['plan']} and {name} are both plans for ticket {ticket!r}") + fields = {k: str(fm.get(k, "") or "") for k in PLAN_FIELDS} + if "assignee" not in fm: + # A tracker's assignee is mirrored into the leaf file; the builds' plans carry no assignee. + fields["assignee"] = row.get("assignee", "") + try: + _one_of(fields["status"], STATUSES, f"{where}/{name}", "status") + except TicketError as e: + problems.append(f"{e}; the ticket reads as blocked until the plan is fixed") + fields.update(status="blocked", blocked_reason=f"{name} has an unknown status {fields['status']!r}") + row.update({"plan": name, "state": row["tracker_status"] or STATE_OF[fields["status"]], **fields}) + + +def epic_folders(initiative: Path) -> list[Path]: + return sorted( + d + for d in initiative.glob("epic-*") + if d.is_dir() and ((d / f"{d.name}.md").is_file() or (d / BREAKDOWN).is_file()) + ) + + +def load_tree(folder: Path) -> dict: + """The folder asked about plus every epic its tickets can name.""" + epics = epic_folders(folder) + if epics or "epic" in load_breakdown(folder): + scope, initiative, folders = None, folder, None + elif folder in epic_folders(folder.parent): + scope, initiative, folders = folder.name, folder.parent, epic_folders(folder.parent) + epics = folders + else: + scope, initiative, folders = folder.name, None, [folder] + listed = load_breakdown(initiative).get("epic", []) if initiative else [] + order = [e.get("slug") for e in listed] + epics.sort(key=lambda d: (order.index(d.name) if d.name in order else len(order), d.name)) + if folders is None: + folders = [*epics, folder] + epic_ids = {} + for e in listed: + if e["id"] in epic_ids.values(): + raise TicketError(f"{initiative.name}/{BREAKDOWN}: two epics with id {e['id']}") + if e["slug"] in epic_ids: + raise TicketError(f"{initiative.name}/{BREAKDOWN}: two epics with slug {e['slug']}") + epic_ids[e["slug"]] = e["id"] + problems = [] + tickets = [t for f in folders for t in load_folder(f, problems)] + for t in tickets: + t["key"] = f"{t['epic']}/{t['id']}" if t["id"] is not None else f"{t['epic']}/{t['file']}" + tree = { + "scope": scope, + "initiative": initiative, + "folders": {f.name: f for f in folders}, + "epic_ids": epic_ids, + "containers": {f.name: load_container(f) for f in epics}, + "tickets": tickets, + "problems": problems, + } + _resolve(tree) + _check_cycles(tickets, tree["containers"]) + return tree + + +def _resolve(tree: dict) -> None: + tickets, containers = tree["tickets"], tree["containers"] + by_key = {t["key"]: t for t in tickets} + slugs = {i: slug for slug, i in tree["epic_ids"].items()} + ids = {c["tracker_id"]: slug for slug, c in containers.items() if c["tracker_id"]} + ids.update({t["tracker_id"]: t["key"] for t in tickets if t["tracker_id"]}) + owners: dict[str, list[str]] = {} + carriers = [(s, c["tracker_id"]) for s, c in containers.items()] + [(t["key"], t["tracker_id"]) for t in tickets] + for key, tid in carriers: + if tid: + owners.setdefault(tid.lower(), []).append(key) + for tid, keys in owners.items(): + if len(keys) > 1: + raise TicketError(f"tracker_id {tid!r} is on more than one ticket: {', '.join(keys)}") + + def sibling(t, ref, where): + """A bare number is always a sibling's id and a quoted one never is; an id with a letter is one when a + sibling has it.""" + mates = [o for o in tickets if o["epic"] == t["epic"]] + if isinstance(ref, int) and not isinstance(ref, bool): + hit = next((o for o in mates if o["id"] == ref), None) + if hit is None: + raise TicketError(f"{where}: after {ref!r} names no entry in {t['epic']}") + return hit["key"] + for o in mates: + if isinstance(o["id"], str) and o["id"] == ref: + return o["key"] + for o in mates: + if o["file"] and ref in (o["file"], o["file"][:-3]): + return o["key"] + return None + + def resolve(t, refs, where): + keys = [] + for ref in refs: + text = str(ref) + key = sibling(t, ref, where) if "id" in t else None + m = CROSS_RE.match(text) + slug = slugs.get(_id(m.group(1))) if m else None + if key is None and slug: + key = f"{slug}/{_id(m.group(2))}" + if key not in by_key: + raise TicketError(f"{where}: after {ref!r} names no entry in {slug}") + if key is None and EPIC_RE.match(text): + if text not in containers: + raise TicketError(f"{where}: after {ref!r} names no epic in this initiative") + key = text + if key is None: + key = ids.get(text) + if key is None and m: + raise TicketError(f"{where}: after {ref!r} names no epic id in this initiative's {BREAKDOWN}") + if key is None and NAME_RE.match(text if text.endswith(".md") else f"{text}.md"): + raise TicketError( + f"{where}: after {ref!r} matches no ticket in {t['epic']}; a file name names a pulled ticket in the " + "same folder only: use the entry's id, or move a backlog ticket into the epic as an entry" + ) + if key is None: + raise TicketError(f"{where}: after {ref!r} matches no ticket") + if key not in keys: + keys.append(key) + return keys + + for t in tickets: + where = f"{t['epic']}/{t['file']}" if t["file"] else f"{t['epic']}/{BREAKDOWN} entry {t['id']}" + t["after"] = resolve(t, t.pop("raw_after"), where) + planned = t.pop("entry_after") + t["gated_by"] = [] + entry_hitl = t.pop("entry_hitl", None) + t["drift"] = {} + if planned is not None: + entry_after = resolve(t, planned, where) + if sorted(entry_after) != sorted(t["after"]): + t["drift"]["after"] = {"file": t["after"], "entry": entry_after} + if entry_hitl != t["hitl"]: + t["drift"]["hitl"] = {"file": t["hitl"], "entry": entry_hitl} + for slug, c in containers.items(): + gates = resolve({"epic": slug}, c.pop("raw_after"), f"{slug}.md") + c["after"] = gates + for t in tickets: + if t["epic"] == slug: + t["gated_by"] = gates + + +def _check_cycles(tickets: list[dict], containers: dict) -> None: + sys.setrecursionlimit(max(1000, 3 * len(tickets) + 100)) + graph = {t["key"]: t["after"] + t["gated_by"] for t in tickets} + members = {} + for t in tickets: + members.setdefault(t["epic"], []).append(t["key"]) + for slug, c in containers.items(): + members.setdefault(slug, []).extend(c["after"]) + state = {} + + def visit(node, path): + if state.get(node) == "done": + return + if state.get(node) == "active": + raise TicketError("cycle through " + " -> ".join(path + [node])) + state[node] = "active" + for b in graph.get(node, members.get(node, [])): + visit(b, path + [node]) + state[node] = "done" + + for node in graph: + visit(node, []) + + +# ---------------------------------------------------------------- views + + +def done_keys(tree: dict) -> set: + done = {t["key"] for t in tree["tickets"] if t["state"] == "done"} + return done | {slug for slug, c in tree["containers"].items() if c["status"] == "done"} + + +def in_scope(tree: dict) -> list[dict]: + return [t for t in tree["tickets"] if tree["scope"] in (None, t["epic"])] + + +def classify(tree: dict) -> dict: + done = done_keys(tree) + # A ticket in review meets an `after`; an epic gate still waits for the epic to be done. + met = done | {t["key"] for t in tree["tickets"] if t["state"] == "review"} + groups = {"ready_to_refine": [], "ready_to_start": [], "in_progress": [], "blocked": []} + for t in in_scope(tree): + s = t["state"] + if s in ("done", "dropped"): + continue + unmet = [b for b in t["after"] if b not in met] + [b for b in t["gated_by"] if b not in done] + if t["status"] == "blocked" or t["blocked_at"]: + t["waiting_on"] = unmet + groups["blocked"].append(t) + elif s in ("in-progress", "review"): + groups["in_progress"].append(t) + elif unmet: + t["waiting_on"] = unmet + groups["blocked"].append(t) + elif t["refine"] and not t["refined"]: + groups["ready_to_refine"].append(t) # refining is where its unknown gets settled + elif t.get("unknown"): + groups["blocked"].append(t) + else: + groups["ready_to_start"].append(t) + return groups + + +def longest_remaining_chain(tree: dict) -> list[str]: + remaining = {t["key"]: t for t in tree["tickets"] if t["state"] not in ("done", "dropped")} + memo = {} + + def chain(k): + if k in memo: + return memo[k] + best = [] + for b in remaining[k]["after"] + remaining[k]["gated_by"]: + if b in remaining: + c = chain(b) + if len(c) > len(best): + best = c + memo[k] = best + [k] + return memo[k] + + longest = [] + for t in in_scope(tree): + if t["key"] in remaining: + c = chain(t["key"]) + if len(c) > len(longest): + longest = c + return [ref(k, None, tree) for k in longest] + + +def ref(key: str, epic: str | None, tree: dict) -> str | int: + """A key as the plan writes it: a sibling's id, `.` elsewhere, an epic's slug.""" + slug, _, n = key.partition("/") + if not n: + return slug + if _id(n) is None: + return key + if slug == epic: + return _id(n) + if slug in tree["epic_ids"]: + return f"{tree['epic_ids'][slug]}.{n}" + return key + + +def declared_after(tree: dict) -> dict: + """Each epic the initiative's `tickets.toml` lists, in build order, with its `after` as `[{epic, needs}]`.""" + if not tree["initiative"]: + return {} + listed = load_breakdown(tree["initiative"]).get("epic", []) + slugs = [e["slug"] for e in listed] + by_id = {i: slug for slug, i in tree["epic_ids"].items()} + out = {} + for e in listed: + out[e["slug"]] = [] + for a in e.get("after", []): + needed = by_id.get(_id(a.get("epic")), a.get("epic")) + if needed not in slugs: + raise TicketError( + f"{tree['initiative'].name}/{BREAKDOWN}: {e['slug']} is after {a.get('epic')!r}, which is no epic listed" + ) + out[e["slug"]].append({"epic": needed, "needs": a.get("needs", "")}) + return out + + +def unpinned_after(tree: dict, declared: dict) -> list[dict]: + """Declared `after` lines whose waiting epic has tickets but none waiting on the named epic.""" + out = [] + for slug, edges in declared.items(): + mine = [t for t in tree["tickets"] if t["epic"] == slug] + for a in edges: + needed = a["epic"] + pinned = any(b == needed or b.startswith(f"{needed}/") for t in mine for b in t["after"] + t["gated_by"]) + if mine and not pinned and tree["scope"] in (None, slug): + out.append({"epic": slug, "after": needed, "needs": a["needs"]}) + return out + + +def cross_epic_after(tree: dict, declared: dict) -> dict: + """`undeclared_after`: an entry's `after` into an epic its epic does not declare. `order_conflict`: an epic + that waits, declared or through its tickets, on an epic later in the initiative's build order.""" + order = list(declared) + undeclared, conflicts = [], [] + + def conflict(epic, needed): + pair = {"epic": epic, "after": needed} + if order.index(needed) > order.index(epic) and pair not in conflicts and tree["scope"] in (None, epic): + conflicts.append(pair) + + for slug, edges in declared.items(): + for a in edges: + conflict(slug, a["epic"]) + for slug, c in tree["containers"].items(): + for b in c["after"]: + if slug in declared and b.partition("/")[0] in declared: + conflict(slug, b.partition("/")[0]) + for t in tree["tickets"]: + if t["epic"] not in declared: + continue + allowed = {a["epic"] for a in declared[t["epic"]]} + for b in t["after"]: + needed = b.partition("/")[0] + if needed == t["epic"] or needed not in declared: + continue + conflict(t["epic"], needed) + if needed not in allowed and tree["scope"] in (None, t["epic"]): + undeclared.append( + {"epic": t["epic"], "after": needed, "ref": row_ref(t, tree), "names": ref(b, t["epic"], tree)} + ) + return {"undeclared_after": undeclared, "order_conflict": conflicts} + + +def row_ref(t: dict, tree: dict) -> str | None: + """What `find` resolves to this ticket in the folder the command ran on; never the title, + which can repeat across epics.""" + if t["id"] is not None and t["epic"] in tree["epic_ids"]: + return f"{tree['epic_ids'][t['epic']]}.{t['id']}" + if t["id"] is not None and t["epic"] == tree["scope"]: + return str(t["id"]) + return t["file"] + + +def public(t: dict, tree: dict, blocks: dict | None = None) -> dict: + row = { + k: t[k] + for k in ( + "epic", + "id", + "file", + "type", + "tracker_id", + "title", + "status", + "tracker_status", + "state", + "assignee", + "hitl", + "risk", + "covers", + "estimate", + "refine", + "refined", + "blocked_at", + "blocked_reason", + ) + } + row["ref"] = row_ref(t, tree) + row["after"] = [ref(b, t["epic"], tree) for b in t["after"]] + if t["gated_by"]: + row["gated_by"] = t["gated_by"] + if blocks is not None: + row["blocks"] = [ref(b, t["epic"], tree) for b in blocks.get(t["key"], [])] + if t["drift"]: + row["drift"] = dict(t["drift"]) + if "after" in row["drift"]: + row["drift"]["after"] = {k: [ref(b, t["epic"], tree) for b in v] for k, v in t["drift"]["after"].items()} + if t.get("waiting_on"): + row["waiting_on"] = [ref(b, t["epic"], tree) for b in t["waiting_on"]] + for key in ("unknown", "plan_checkpoint", "done_checkpoint"): + if t.get(key): + row[key] = t[key] + return row + + +# ---------------------------------------------------------------- store + + +def find_project_root(start: Path) -> Path | None: + for p in [start, *start.parents]: + if (p / "_bmad").is_dir(): + return p + return None + + +def project_root_for(args, start: Path) -> Path | None: + return Path(args.project_root).resolve() if args.project_root else find_project_root(start) + + +def store_config(project_root: Path | None) -> dict: + """The `[tickets]` table of the project's store config, empty when there is none.""" + if not project_root: + return {} + cfg = project_root / "_bmad" / "custom" / "ticketing-store-config.toml" + if not cfg.is_file(): + return {} + tickets = tomllib.loads(read_text(cfg)).get("tickets", {}) + return tickets if isinstance(tickets, dict) else {} + + +def store_name(project_root: Path | None) -> str: + store = store_config(project_root).get("store", "repo") + return store if isinstance(store, str) and store else "repo" + + +def central_config(project_root: Path) -> dict: + """The BMad config with its layers merged by the project's own `config_utils.py`.""" + path = project_root / "_bmad" / "scripts" / "config_utils.py" + if not path.is_file(): + raise TicketError(f"cannot read the BMad config: {path} is missing") + spec = importlib.util.spec_from_file_location("bmad_config_utils", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + try: + return module.load_central_config(project_root) + except module.ConfigError as e: + raise TicketError(str(e)) from e + + +def tickets_root(project_root: Path, config: dict | None = None) -> Path: + """`{output_folder}` for the project: the ticket tree lives beside the documents.""" + config = central_config(project_root) if config is None else config + core = config.get("core", {}) + output = str(core.get("output_folder", "") if isinstance(core, dict) else "") + output = output.replace("{project-root}", str(project_root)) + return project_root / output + + +def active_initiative(project_root: Path) -> Path: + """`{output_folder}/{active_initiative}` for the project.""" + config = central_config(project_root) + core = config.get("core", {}) + name = core.get("active_initiative") if isinstance(core, dict) else None + if not isinstance(name, str) or not name.strip(): + raise TicketError( + "no active initiative: set core.active_initiative in _bmad/custom/config.user.toml, or pass a folder" + ) + folder = (tickets_root(project_root, config) / name.strip()).resolve() + if not folder.is_dir(): + raise TicketError(f"active initiative folder not found: {folder}") + return folder + + +# ---------------------------------------------------------------- commands + + +def _folder(args) -> Path: + if args.dir is None: + root = project_root_for(args, Path.cwd()) + if root is None: + raise TicketError("no project root found: no _bmad/ at or above the working directory; pass --project-root") + # The store is then read from this project even when output_folder lies outside it. + args.project_root = str(root) + folder = active_initiative(root) + backlog = (tickets_root(root) / "backlog").resolve() + args.backlog = backlog if backlog.is_dir() and backlog != folder else None + return folder + folder = Path(args.dir).resolve() + root = None if folder.is_dir() or Path(args.dir).is_absolute() else project_root_for(args, Path.cwd()) + if root is not None: + try: + bases = [tickets_root(root), root] + except TicketError: # no BMad config to name the store: the project root alone + bases = [root] + else: + try: + bases.insert(1, active_initiative(root)) + except TicketError: # none set: the store and the project root + pass + for base in bases: + if (base / args.dir).is_dir(): + return (base / args.dir).resolve() + if not folder.is_dir(): + raise TicketError(f"not a folder: {folder}") + return folder + + +def with_backlog(args, out: dict, view) -> dict: + """Add the backlog's view to a command that ran on the active initiative.""" + backlog = getattr(args, "backlog", None) + if backlog is not None: + try: + out["backlog"] = view(backlog) + except (TicketError, OSError, ValueError) as e: + out["backlog"] = {"folder": backlog.name, "error": str(e)} + for group in ("ready_to_refine", "ready_to_start", "in_progress", "blocked", "tickets"): + for row in out["backlog"].get(group, []): + # An id here could also name a ticket in the initiative; the file name cannot. + row["ref"] = row["file"] or row["ref"] + return out + + +def locate(args, folder: Path, text: str) -> tuple[dict, dict]: + """The ticket a reference names and the tree holding it: the folder's, else, with no ``, the backlog's.""" + tree = load_tree(folder) + try: + return resolve_ticket(tree, text), tree + except NoMatch as miss: + backlog = getattr(args, "backlog", None) + if backlog is None: + raise + try: + other = load_tree(backlog) + other["fallback"] = True + return resolve_ticket(other, text), other + except (NoMatch, OSError, ValueError): # not there, or a backlog that cannot be read + raise miss from None + + +def next_view(folder: Path) -> dict: + tree = load_tree(folder) + declared = declared_after(tree) + return { + "folder": folder.name, + **{k: [public(t, tree) for t in v] for k, v in classify(tree).items()}, + "unpinned_after": unpinned_after(tree, declared), + **cross_epic_after(tree, declared), + **({"problems": tree["problems"]} if tree["problems"] else {}), + } + + +def cmd_next(args) -> dict: + folder = _folder(args) + store = store_name(project_root_for(args, folder)) + if store != "repo" and not args.synced: + raise StoreRefusal(f"store is {store}: sync ticket status from the tracker first, then rerun with --synced") + return with_backlog(args, {"store": store, **next_view(folder)}, next_view) + + +def next_id(tree: dict, folder: str) -> int: + """The id a new ticket in the folder takes: one past the highest number its entries and leaf files use, or + `next_id` at the top of its `tickets.toml` when that is higher.""" + # `6a` uses up 6: a lettered id is a split of that number. + used = [re.match(r"\d+", str(t["id"])) for t in tree["tickets"] if t["epic"] == folder] + # An entry moved to another folder took its id along; the counter it left keeps that id from coming back. + floor = load_breakdown(tree["folders"][folder]).get("next_id", 0) + return max(max((int(m.group()) for m in used if m), default=0) + 1, floor) + + +def status_view(folder: Path) -> dict: + tree = load_tree(folder) + declared = declared_after(tree) + tickets = in_scope(tree) + counts = {} + for t in tickets: + counts[t["state"]] = counts.get(t["state"], 0) + 1 + blocks = {} + for t in tree["tickets"]: + for b in t["after"]: + blocks.setdefault(b, []).append(t["key"]) + out = { + "folder": folder.name, + "tickets": [public(t, tree, blocks) for t in tickets], + "counts": {"total": len(tickets), **counts}, + "longest_remaining_chain": longest_remaining_chain(tree), + "unpinned_after": unpinned_after(tree, declared), + **cross_epic_after(tree, declared), + **({"problems": tree["problems"]} if tree["problems"] else {}), + } + if tree["scope"] is None: + out["epics"] = [ + { + "slug": slug, + "id": tree["epic_ids"].get(slug), + "status": c["status"], + "after": declared.get(slug, []), + "gated_by": c["after"], + "blocks": [ref(b, None, tree) for b in blocks.get(slug, [])], + "next_id": next_id(tree, slug), + } + for slug, c in tree["containers"].items() + ] + else: + out["next_id"] = next_id(tree, tree["scope"]) + return out + + +def cmd_status(args) -> dict: + folder = _folder(args) + store = store_name(project_root_for(args, folder)) + return with_backlog(args, {"store": store, **status_view(folder)}, status_view) + + +PULLED = """--- +{frontmatter} +--- + +# {heading} + +## Description + +{description} + +## Acceptance Criteria + +Verify: {verify} + +## References + +- parent — {parent} +{references}{notes}""" + + +def title_slug(title: str) -> str: + title = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode("ascii") + return re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")[:60].rstrip("-") or "untitled" + + +def resolve_ticket(tree: dict, text: str) -> dict: + """The one row a reference names; see `` above.""" + tickets = tree["tickets"] + ref = text.strip() + if not ref: + raise TicketError("the ticket reference is empty") + low = ref.lower() + hits = [] + m = CROSS_RE.match(ref) + if m: + slug = {i: s for s, i in tree["epic_ids"].items()}.get(_id(m.group(1))) + hits = [t for t in tickets if slug and t["epic"] == slug and t["id"] == _id(m.group(2))] + elif _id(ref) is not None and tree["scope"]: + hits = [t for t in tickets if t["epic"] == tree["scope"] and t["id"] == _id(ref)] + if not hits and isinstance(_id(ref), str): + # An id with a letter names its ticket in any epic before the words of a title can match it. + hits = [t for t in tickets if t["id"] == _id(ref)] + for pool in (in_scope(tree), tickets): + if not hits: + hits = [t for t in pool if t["file"] and low in (t["file"].lower(), t["file"][:-3].lower())] + if not hits: + hits = [t for t in tickets if t["tracker_id"] and low == t["tracker_id"].lower()] + if not hits and not ref.isdigit(): + hits = [t for t in in_scope(tree) if low in t["title"].lower()] + if not hits: + raise NoMatch(f"no ticket matches {ref!r}") + if len(hits) > 1: + names = ", ".join(ref_name(t, tree) for t in hits) + raise TicketError(f"{ref!r} matches more than one ticket: {names}") + return hits[0] + + +def leaf_stem(t: dict, tree: dict) -> str: + """The leaf file's stem, or the one `pull` gives it: `-`, with `-` added when a + file or an earlier entry in the folder already has that name.""" + if t["file"]: + return t["file"][:-3] + stem = f"{t['type']}-{title_slug(t['title'])}" + taken = set() + for o in tree["tickets"]: + if o is t: + break + if o["epic"] == t["epic"] and not o["file"]: + taken.add(f"{o['type']}-{title_slug(o['title'])}") + taken |= {o["file"][:-3] for o in tree["tickets"] if o["epic"] == t["epic"] and o["file"]} + return f"{stem}-{t['id']}" if stem in taken else stem + + +def plan_path(t: dict, tree: dict) -> Path: + """The joined plan, else `-plan.md`.""" + folder = tree["folders"][t["epic"]] + if t.get("plan"): + return folder / t["plan"] + return folder / f"{leaf_stem(t, tree)}-plan.md" + + +def cmd_find(args) -> dict: + folder = _folder(args) + t, tree = locate(args, folder, args.ref) + home = tree["folders"][t["epic"]] + row = public(t, tree) + if tree.get("fallback") and t["file"]: + row["ref"] = t["file"] # as the backlog view names it: an id could name a ticket in the initiative + # Once a leaf file exists it holds the text and the entry's copy is no longer kept up. Unlisted and stray + # leaves have no entry. + entry = {} if t["file"] else t + container = home / f"{home.name}.md" # an epic's file, or the initiative's for a leaf directly under one + return { + **row, + "folder": home.name, + "description": entry.get("description", ""), + "verify": entry.get("verify", ""), + "references": entry.get("references", []), + "notes": entry.get("notes", []), + "unknown": t.get("unknown", ""), + "epic_file": str(container) if container.is_file() else None, + "story_file": str(home / t["file"]) if t["file"] else None, + "plan": str(plan_path(t, tree)), + } + + +def ref_name(t: dict, tree: dict) -> str: + return f"{t['file'] or t['id']} in {t['epic']}" + + +def cmd_pull(args) -> dict: + folder = _folder(args) + tree = load_tree(folder) + n = _id(args.id) + t = next((t for t in in_scope(tree) if t["epic"] == folder.name and t["id"] == n), None) + if n is None or t is None: + raise TicketError(f"{folder.name}/{BREAKDOWN} has no entry {args.id}") + if t["file"]: + raise TicketError(f"entry {args.id} is already pulled: {t['file']}") + path = write_leaf(t, tree, project_root_for(args, folder)) + return {"file": path.name, "refine": t["refine"]} + + +def write_leaf(t: dict, tree: dict, root: Path | None) -> Path: + """Write an entry's leaf file from the entry and name it on the row.""" + folder = tree["folders"][t["epic"]] + path = folder / f"{leaf_stem(t, tree)}.md" + if path.exists(): + raise TicketError(f"{path.name} exists already; change entry {t['id']}'s title") + after = [str(ref(b, t["epic"], tree)) for b in t["after"]] + epic_file = folder / f"{folder.name}.md" + try: + parent = Path(os.path.relpath(epic_file, root)).as_posix() if root else epic_file.as_posix() + except ValueError: # another drive on Windows + parent = epic_file.as_posix() + notes = ([f"Unknown: {t['unknown']}"] if t["unknown"] else []) + t["notes"] + # Other empty fields are left out; `after` and `hitl` stay because `status` compares them with the entry. + # No status: the build writes it when it starts. + fields = [ + ("id", str(t["id"])), + ("type", t["type"]), + ("title", json.dumps(t["title"], ensure_ascii=False)), + ("parent", t["epic"]), + ("covers", f"[{', '.join(t['covers'])}]" if t["covers"] else ""), + ("after", f"[{', '.join(after)}]"), + ("refined", "false" if t["refine"] else ""), + ("hitl", "true" if t["hitl"] else "false"), + ("risk", t["risk"]), + ("estimate", json.dumps(str(t["estimate"])) if t["estimate"] != "" else ""), + ] + path.write_text( + PULLED.format( + frontmatter="\n".join(f"{k}: {v}" for k, v in fields if v != ""), + heading=t["title"], + parent=parent, + description=t["description"], + verify=t["verify"], + references="".join(f"- {r}\n" for r in t["references"]), + notes="\n## Notes\n\n" + "".join(f"- {n}\n" for n in notes) if notes else "", + ), + encoding="utf-8", + ) + t["file"] = path.name + return path + + +def quoted(value: str) -> str: + """A double-quoted scalar that `parse_frontmatter` reads back exactly.""" + # parse_frontmatter cuts a value at " #" and splits lines on these characters, so they go in as escapes. + text = json.dumps(value, ensure_ascii=False).replace(" #", " \\u0023") + return text.translate({c: f"\\u{c:04x}" for c in (0x85, 0x2028, 0x2029)}) + + +def edit_frontmatter(path: Path, values: dict[str, str]) -> str: + """Set frontmatter values in a file, keeping its line endings and byte-order mark; an empty value removes + the line. Returns the new text.""" + raw = path.read_bytes() + text = raw.decode("utf-8-sig").replace("\r\n", "\n") + for key, value in values.items(): + text = set_frontmatter_value(text, key, value) + data = text.encode("utf-8") # before the file is opened, so a failure leaves the file as it was + if b"\r\n" in raw: + data = data.replace(b"\n", b"\r\n") + if raw.startswith(codecs.BOM_UTF8): + data = codecs.BOM_UTF8 + data + path.write_bytes(data) + return text + + +def cmd_mark(args) -> dict: + """Write status, assignee, and the blocked fields to the ticket's plan; never to its leaf file.""" + folder = _folder(args) + store = store_name(project_root_for(args, folder)) + if store != "repo": + raise StoreRefusal(f"store is {store}: change status through the store's write verb, not this script") + t, tree = locate(args, folder, args.ref) + path = plan_path(t, tree) + blocked = {"blocked_at": "", "blocked_reason": ""} + if args.blocked is not None: + blocked = {"blocked_at": quoted(date.today().isoformat()), "blocked_reason": quoted(args.blocked)} + created = not t.get("plan") + if created: + assignee = args.assignee if args.assignee is not None else t["assignee"] + fields = [ + ("title", quoted(t["title"])), + ("ticket", str(t["id"]) if t["id"] is not None else quoted(t["file"][:-3])), + ("status", args.status), + ("assignee", quoted(assignee) if assignee else ""), + *blocked.items(), + ] + text = "---\n" + "".join(f"{k}: {v}\n" for k, v in fields if v != "") + "---\n" + data = text.encode("utf-8") # before the file is opened, so a failure leaves no empty plan + try: + with path.open("xb") as f: + f.write(data) + except FileExistsError: + raise TicketError(f"{path.name} exists already and is not the plan for {ref_name(t, tree)}") from None + else: + values = {"status": args.status, **blocked} + if args.assignee is not None: + values["assignee"] = quoted(args.assignee) + text = edit_frontmatter(path, values) + fm = parse_frontmatter(text, lenient=True) + return { + "plan": str(path), + "created": created, + **{k: fm.get(k, "") for k in PLAN_FIELDS}, + } + + +MIRROR_KEYS = ("ref", "tracker_id", "remote", "tracker_status", "assignee", "after") + + +def _mirror_values(item: dict, where: str) -> dict[str, str]: + """The frontmatter lines one mirrored ticket sets, each validated.""" + values = {} + for key in ("tracker_id", "remote", "assignee"): + if key in item: + value = item[key] + if key == "tracker_id" and isinstance(value, int) and not isinstance(value, bool): + value = str(value) # a tracker that numbers its items returns a number + if not isinstance(value, str): + raise TicketError(f"{where}: {key} must be a string") + values[key] = quoted(value) if value else "" + if "tracker_status" in item: + values["tracker_status"] = _one_of(item["tracker_status"], STATES, where, "tracker_status") + if "after" in item: + if not isinstance(item["after"], list): + raise TicketError(f"{where}: after must be a list") + parts = [] + for b in item["after"]: + if isinstance(b, bool) or not isinstance(b, (int, str)) or (isinstance(b, str) and ("," in b or not b)): + raise TicketError(f"{where}: after takes ids and names without commas, not {b!r}") + # A number stays bare, a sibling's id; a string is quoted, so a tracker id of digits stays one. + parts.append(str(b) if isinstance(b, int) else quoted(b)) + values["after"] = f"[{', '.join(parts)}]" + return values + + +def by_tracker_id(tree: dict, tracker_id: str) -> dict: + """The one ticket that already carries a tracker id; nothing else about it is matched.""" + hits = [t for t in tree["tickets"] if t["tracker_id"] and t["tracker_id"].lower() == tracker_id.lower()] + if not hits: + raise NoMatch(f"no ticket carries tracker_id {tracker_id!r}") + if len(hits) > 1: + raise TicketError(f"tracker_id {tracker_id!r} is on more than one ticket") + return hits[0] + + +def cmd_mirror(args) -> dict: + """Write what the tracker returned into each ticket's leaf file; never `status`, which is the build's.""" + folder = _folder(args) + root = project_root_for(args, folder) + store = store_name(root) + if store == "repo": + raise StoreRefusal("store is repo: there is no tracker to mirror") + items = json.load(sys.stdin) + if not isinstance(items, list) or not all(isinstance(i, dict) for i in items): + raise TicketError("mirror reads a JSON array of objects on stdin") + folders, trees = [folder], [load_tree(folder)] + if getattr(args, "backlog", None): + try: + trees.append(load_tree(args.backlog)) + folders.append(args.backlog) + except (TicketError, OSError, ValueError): # a backlog that cannot be read holds no match + pass + work, unmatched, seen = [], [], set() + for n, item in enumerate(items, 1): + name = item.get("ref", item.get("tracker_id")) + where = f"item {n} ({name!r})" + unknown = [k for k in item if k not in MIRROR_KEYS] + if unknown: + hint = "; status is the build's and is never mirrored" if "status" in unknown else "" + raise TicketError(f"{where}: unknown key {unknown[0]!r}{hint}") + if isinstance(name, bool) or not isinstance(name, (int, str)) or name == "": + raise TicketError(f"item {n}: give a ref, or the tracker_id of a ticket that already carries it") + values = _mirror_values(item, where) + hit = miss = None + for tree in trees: + try: + # A tracker id alone never falls back to an entry id or a title that happens to match it. + t = resolve_ticket(tree, str(name)) if "ref" in item else by_tracker_id(tree, str(name)) + except NoMatch as e: + miss = miss or e + continue + hit = (t, tree) + break + if hit is None: + unmatched.append({"ref": name, "error": str(miss)}) + continue + if id(hit[0]) in seen: + raise TicketError(f"{where}: {ref_name(*hit)} is named twice") + seen.add(id(hit[0])) + work.append((*hit, values)) + undo, mirrored = [], [] + try: + for t, tree, values in work: + pulled = not t["file"] + if pulled: + path = write_leaf(t, tree, root) + undo.append((path, None)) + else: + path = tree["folders"][t["epic"]] / t["file"] + undo.append((path, path.read_bytes())) + edit_frontmatter(path, values) + ref_out = row_ref(t, tree) if tree is trees[0] else path.name + mirrored.append({"ref": ref_out, "file": path.name, "pulled": pulled, "set": sorted(values)}) + for f in folders: + load_tree(f) + except (TicketError, OSError, ValueError) as e: + for path, raw in undo: + try: + if raw is None: + path.unlink(missing_ok=True) + else: + path.write_bytes(raw) + except OSError: # keep restoring the rest; the first failure is the one reported + pass + # The discovery procedure needs `unmatched` even when a known ticket's `after` named one of them. + raise TicketError(f"nothing was mirrored: {e}", **({"unmatched": unmatched} if unmatched else {})) from e + return {"folder": folder.name, "store": store, "mirrored": mirrored, "unmatched": unmatched} + + +REF_HELP = """ is `.`, an entry id inside an epic folder (one with a letter, from any +folder), a tracker id, a file name, or an unbroken phrase from the title that matches one ticket.""" + +DIR_HELP = """ is an epic folder, a backlog folder, or an initiative folder (all its epics): a path, or a +folder's name under the store or the active initiative. Left out, it is the active initiative.""" + +BACKLOG_HELP = "With no , a ticket the initiative does not hold is looked for in the backlog folder." + +HELP = { + "next": f"""Tickets grouped by what can happen next, in build order. + +{DIR_HELP} With no , `backlog` holds the same view of the backlog folder, each row's `ref` +its file name. + +Output: `ready_to_refine` (needs full criteria first), `ready_to_start`, `in_progress`, and `blocked`; a +`blocked` row has `waiting_on`, its unmet prerequisites, `blocked_reason`, or `unknown`, a question to +settle before it starts. A prerequisite is met when it is done or in review; an epic's own gate +(`gated_by`) waits for that epic to be done. `unpinned_after`, `undeclared_after`, `order_conflict`, and +`problems` report a tree that needs fixing. Every row carries `ref`, which find resolves, `state`, and +`risk`; a row has `drift` when the leaf file's `after` or `hitl` differs from the entry's, and `plan_checkpoint` or +`done_checkpoint` when the entry sets it.""", + "status": f"""Every ticket in build order. + +{DIR_HELP} With no , `backlog` holds the same view of the backlog folder, each row's `ref` +its file name. + +Output: `tickets` (each with `status`, `tracker_status`, `state`, `blocks`, and `drift` when the leaf file's +`after` or `hitl` differs from the entry's, with both values), `counts` by state, `next_id` (the id a new +ticket in the folder takes: one past the highest number used, or `next_id` at the top of the folder's +`tickets.toml` when that is higher; on an initiative, in each `epics` row), `longest_remaining_chain`, and +on an initiative `epics` with each epic's declared `after`.""", + "find": f"""The one ticket a reference names. + +{DIR_HELP} +{BACKLOG_HELP} +{REF_HELP} + +Output: the ticket's row; its entry's `description`, `verify`, `references`, and `notes`, all empty once +the entry is pulled, when `story_file` holds them; `unknown`; its `folder`; and the absolute paths +`epic_file` (the file of the container it is under, null in a backlog folder), `story_file` (null until +the entry is pulled), and `plan` (where its plan is or goes; it may not exist yet).""", + "pull": """Write an entry's leaf file from its entry. + + is the epic folder and the entry's id. The file is `-.md`: `after` and +`hitl` always, other fields only when the entry sets them, no status.""", + "mark": f"""Set a ticket's status in its plan; never in its leaf file. Repo store only. + +{DIR_HELP} +{BACKLOG_HELP} +{REF_HELP} + +A ticket with no plan gets one holding only frontmatter. `--blocked` sets `blocked_at` (today) and +`blocked_reason`; without it both are cleared.""", + "mirror": f"""Write what a tracker returned into leaf files. Tracker stores only. + +{DIR_HELP} +{BACKLOG_HELP} + +Stdin is a JSON array, one object per ticket: `ref` (or `tracker_id` alone, which matches only a ticket +that already carries that id) and any of `tracker_id`, `remote`, `tracker_status` ({", ".join(STATES)}), +`assignee`, and `after` (a list: a number is a sibling's id, a string any other prerequisite form, a +tracker id included). Only the keys given are written, an empty string removes the line, and `status` is +never written. An entry with no leaf file is pulled first. A ticket the tree does not hold is listed under +`unmatched` and the rest are written; values that would leave the tree unreadable write nothing, and the +error then still lists `unmatched`.""", +} + + +def main() -> int: + raw = argparse.RawDescriptionHelpFormatter + parser = argparse.ArgumentParser(description=__doc__, formatter_class=raw) + parser.add_argument( + "--project-root", + help="project holding _bmad/; default: walk up from the ticket folder, or the working directory with no folder", + ) + sub = parser.add_subparsers(dest="command", required=True) + p = sub.add_parser( + "next", + help="tickets whose prerequisites are done or in review, by state", + description=HELP["next"], + formatter_class=raw, + ) + p.add_argument("dir", nargs="?", help="default: the active initiative") + p.add_argument("--synced", action="store_true", help="tracker status was mirrored just now") + p.set_defaults(func=cmd_next) + p = sub.add_parser("status", help="every ticket resolved", description=HELP["status"], formatter_class=raw) + p.add_argument("dir", nargs="?", help="default: the active initiative") + p.set_defaults(func=cmd_status) + p = sub.add_parser("find", help="the one ticket a reference names", description=HELP["find"], formatter_class=raw) + p.add_argument("dir", nargs="?", help="default: the active initiative") + p.add_argument("ref") + p.set_defaults(func=cmd_find) + p = sub.add_parser("pull", help="write an entry's leaf file", description=HELP["pull"], formatter_class=raw) + p.add_argument("dir") + p.add_argument("id") + p.set_defaults(func=cmd_pull) + p = sub.add_parser( + "mark", + help="set a ticket's status in its plan (repo store only)", + description=HELP["mark"], + formatter_class=raw, + ) + p.add_argument("dir", nargs="?", help="default: the active initiative") + p.add_argument("ref") + p.add_argument("status", choices=STATUSES) + p.add_argument("--assignee") + p.add_argument( + "--blocked", metavar="REASON", help="set blocked_at to today and blocked_reason; else both are cleared" + ) + p.set_defaults(func=cmd_mark) + p = sub.add_parser( + "mirror", + help="write what a tracker returned into leaf files (tracker stores only)", + description=HELP["mirror"], + formatter_class=raw, + ) + p.add_argument("dir", nargs="?", help="default: the active initiative") + p.set_defaults(func=cmd_mirror) + args = parser.parse_args() + try: + print(json.dumps(args.func(args), ensure_ascii=False, default=str)) + return 0 + except StoreRefusal as e: + print(json.dumps({"error": str(e)}), file=sys.stderr) + return 2 + except (TicketError, OSError, ValueError) as e: + print(json.dumps({"error": str(e), **getattr(e, "data", {})}, ensure_ascii=False), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + if sys.platform == "win32": + # Piped output on Windows defaults to a legacy code page, not UTF-8. + sys.stdout.reconfigure(encoding="utf-8") + sys.stderr.reconfigure(encoding="utf-8") + raise SystemExit(main()) diff --git a/_bmad/scripts/config_utils.py b/_bmad/scripts/config_utils.py new file mode 100644 index 0000000..55cefc2 --- /dev/null +++ b/_bmad/scripts/config_utils.py @@ -0,0 +1,116 @@ +"""Shared strict TOML loading and structural merge support.""" + +from __future__ import annotations + +import tomllib +from collections.abc import Iterable +from pathlib import Path +from typing import Any + + +class ConfigError(ValueError): + """Raised when a present configuration layer cannot be used safely.""" + + +_KEYED_MERGE_FIELDS = ("code", "id") + + +def load_toml(path: Path, *, required: bool = False) -> dict[str, Any]: + """Load a TOML table, allowing absence only for optional layers.""" + if not path.exists(): + if required: + raise ConfigError(f"required TOML file not found: {path}") + return {} + if not path.is_file(): + raise ConfigError(f"TOML layer is not a file: {path}") + try: + with path.open("rb") as stream: + parsed = tomllib.load(stream) + except tomllib.TOMLDecodeError as error: + raise ConfigError(f"failed to parse {path}: {error}") from error + except OSError as error: + raise ConfigError(f"failed to read {path}: {error}") from error + if not isinstance(parsed, dict): + raise ConfigError(f"TOML layer did not parse to a table: {path}") + return parsed + + +def _detect_keyed_merge_field(items: list[Any]) -> str | None: + if not items or not all(isinstance(item, dict) for item in items): + return None + for candidate in _KEYED_MERGE_FIELDS: + if all(candidate in item for item in items): + for item in items: + value = item[candidate] + if not isinstance(value, str): + raise ConfigError( + f"keyed array identifier `{candidate}` must be a string, got {type(value).__name__}" + ) + if not value: + raise ConfigError(f"keyed array identifier `{candidate}` must not be empty") + return candidate + return None + + +def _merge_arrays(base: list[Any], override: list[Any]) -> list[Any]: + keyed_field = _detect_keyed_merge_field(base + override) + if keyed_field is None: + return list(base) + list(override) + + result: list[Any] = [] + index_by_key: dict[str, int] = {} + for item in base: + copied = dict(item) + index_by_key[copied[keyed_field]] = len(result) + result.append(copied) + for item in override: + copied = dict(item) + key = copied[keyed_field] + if key in index_by_key: + result[index_by_key[key]] = copied + else: + index_by_key[key] = len(result) + result.append(copied) + return result + + +def structural_merge(base: Any, override: Any) -> Any: + """Merge tables recursively, keyed table arrays by identity, and append other arrays.""" + if isinstance(base, dict) and isinstance(override, dict): + result = dict(base) + for key, value in override.items(): + result[key] = structural_merge(result[key], value) if key in result else value + return result + if isinstance(base, list) and isinstance(override, list): + return _merge_arrays(base, override) + return override + + +def merge_layers(layers: Iterable[dict[str, Any]]) -> dict[str, Any]: + merged: dict[str, Any] = {} + for layer in layers: + merged = structural_merge(merged, layer) + return merged + + +def load_central_config(project_root: Path) -> dict[str, Any]: + bmad_dir = project_root / "_bmad" + return merge_layers( + ( + load_toml(bmad_dir / "config.toml", required=True), + load_toml(bmad_dir / "custom" / "config.toml"), + load_toml(bmad_dir / "custom" / "config.user.toml"), + ) + ) + + +def load_customization(project_root: Path | None, skill_dir: Path) -> dict[str, Any]: + skill_name = skill_dir.name + custom_dir = project_root / "_bmad" / "custom" if project_root else None + return merge_layers( + ( + load_toml(skill_dir / "customize.toml", required=True), + load_toml(custom_dir / f"{skill_name}.toml") if custom_dir else {}, + load_toml(custom_dir / f"{skill_name}.user.toml") if custom_dir else {}, + ) + ) diff --git a/_bmad/scripts/knowledge.py b/_bmad/scripts/knowledge.py new file mode 100644 index 0000000..b23d3a9 --- /dev/null +++ b/_bmad/scripts/knowledge.py @@ -0,0 +1,462 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Report the knowledge documents the installed modules offer. + +A folder whose `bmod.toml` has a `[bmod]` table is a module record, whatever +the folder is called. The record names the module's skills and holds each +knowledge document once. `help/help.md` in the record's folder covers every +skill of the module and needs no entry. A `[[bmod.knowledge]]` entry adds a further document +and says which of the module's skills it covers: `"*"` or no `skills` key means +all of them, a list means the named ones. + +Every other `help/*.md` is a topic: detail that `help/help.md` points to and a +reader opens only when a question needs it. Topics are listed with their +file path and never with their text. + +A `migration-.toml` file in the record's folder is a migration the +module ships: the rules for moving a project from one major version of the +module to the next. `` is a whole number that sets the order in which a +module's migrations are listed, checked and run. One is listed only when its +`[migration]` table names the record's `module` and has `from`, `to`, +`title`, `summary`, `detect`, `guide`, and a `checklist`; the listing carries +`from`, `to`, `title` and the file path, never the text. A `[migration]` +table in a file with any other name, and two files of one module with the +same number, are problems. `bmad migrate` reads the file. + +A file this script cannot use becomes an entry in `problems`, never an +exception. + +Usage: + uv run knowledge.py --root .claude/skills [--root ...] [--content] +""" + +from __future__ import annotations + +import argparse +import json +import re +import stat +import sys +import tomllib +from pathlib import Path, PurePosixPath +from typing import NamedTuple + +sys.dont_write_bytecode = True + +MANIFEST_NAME = "bmod.toml" +TOPICS_DIR = "help" +HELP_NAME = f"{TOPICS_DIR}/help.md" +ROSTER_NAME = "roster.toml" +RETIRED_NAME = "retired.toml" +MIGRATION_TABLE = "migration" +MIGRATION_FIELDS = ("module", "from", "to", "title", "summary", "detect", "guide") +MIGRATION_NAME = re.compile(r"migration-([0-9]+)\.toml") +READ_LIMIT = 1024 * 1024 + + +class Module(NamedTuple): + code: str + folder: Path + table: dict[str, object] + skills: list[str] + + +class Scan(NamedTuple): + folders: dict[str, Path] + modules: list[Module] + skills: list[dict[str, object]] + problems: list[dict[str, object]] + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Report the knowledge documents the installed modules offer.") + parser.add_argument("--root", type=Path, action="append", required=True, help="a skills root to scan") + parser.add_argument("--content", action="store_true", help="include each document's text") + args = parser.parse_args(argv) + print(json.dumps(collect(args.root, include_content=args.content), ensure_ascii=False)) + return 0 + + +def collect(roots: list[Path], *, include_content: bool = False) -> dict[str, object]: + found = scan(roots) + problems = found.problems + documents: dict[tuple[str, str], dict[str, object]] = {} + + for module in found.modules: + entries = module.table.get("knowledge", []) + if not isinstance(entries, list): + problems.append( + {"kind": "knowledge", "skill": module.folder.name, "problem": "[bmod] 'knowledge' is not a list"} + ) + continue + if (module.folder / HELP_NAME).exists(): + entries = [{"path": HELP_NAME}, *entries] + for entry in entries: + record_document(documents, problems, found.folders, module, entry, include_content=include_content) + + topics = [ + topic + for module in found.modules + for topic in module_topics(module, problems) + if (topic["module"], topic["path"]) not in documents + ] + + migrations = [migration for module in found.modules for migration in module_migrations(module, problems)] + + return { + "roots": [str(root) for root in roots], + "skills": sorted(found.skills, key=lambda item: str(item["skill"])), + "documents": sorted(documents.values(), key=lambda item: (str(item["module"]), str(item["path"]))), + "topics": sorted(topics, key=lambda item: (str(item["module"]), str(item["path"]))), + "migrations": sorted(migrations, key=lambda item: (str(item["module"]), migration_number(str(item["path"])))), + "problems": problems, + } + + +def scan(roots: list[Path]) -> Scan: + """Find every module record and every module skill in the roots.""" + folders: dict[str, Path] = {} + modules: list[Module] = [] + problems: list[dict[str, object]] = [] + record_codes: dict[str, str] = {} + pending: list[tuple[Path, Path, dict[str, object], bool]] = [] + + for root in roots: + try: + found = sorted(path for path in root.iterdir() if path.is_dir()) + except OSError as error: + problems.append({"kind": "root", "root": str(root), "problem": f"cannot read root {root}: {error}"}) + continue + for folder in found: + # The first root wins a folder name outright: a project copy shadows a + # user copy even when the project copy carries no bmod.toml. + if folder.name in folders: + continue + folders[folder.name] = folder + manifest = folder / MANIFEST_NAME + try: + if not manifest.is_file(): + continue + data = tomllib.loads(manifest.read_text(encoding="utf-8")) + except (OSError, UnicodeError, tomllib.TOMLDecodeError) as error: + problems.append(manifest_problem(folder, f"cannot use {manifest}: {error}")) + continue + if "bmod" not in data and "skill" not in data: + problems.append(manifest_problem(folder, f"{manifest} has neither a [bmod] nor a [skill] table")) + continue + + skill = data.get("skill") + if "skill" in data and not isinstance(skill, dict): + problems.append(manifest_problem(folder, f"{manifest}: 'skill' is not a table")) + skill = None + if "bmod" in data: + module = read_record(folder, data["bmod"], skill is not None, problems) + if module is not None: + record_codes[folder.name] = module.code + first = next((other for other in modules if other.code.casefold() == module.code.casefold()), None) + if first is None: + modules.append(module) + else: + problems.append( + { + "kind": "module", + "skill": folder.name, + "problem": f"{folder.name}: module {module.code!r} is already recorded by " + f"{first.folder.name}; {first.folder.name} is used", + } + ) + if skill is not None: + pending.append((root, folder, skill, "bmod" in data)) + + skills = [ + resolve_skill(root, folder, table, own_record, record_codes) for root, folder, table, own_record in pending + ] + problems.extend(absent_records(skills, folders)) + for entry in skills: + entry.pop("source") + return Scan(folders, modules, skills, problems) + + +def manifest_problem(folder: Path, problem: str) -> dict[str, object]: + return {"kind": "manifest", "skill": folder.name, "manifest": str(folder / MANIFEST_NAME), "problem": problem} + + +def read_record(folder: Path, table: object, has_skill: bool, problems: list[dict[str, object]]) -> Module | None: + manifest = folder / MANIFEST_NAME + if not isinstance(table, dict): + problems.append(manifest_problem(folder, f"{manifest}: 'bmod' is not a table")) + return None + code = table.get("code") + if not isinstance(code, str) or not code: + problems.append(manifest_problem(folder, f"{manifest}: [bmod] has no usable 'code'")) + return None + listed = table.get("skills") + if listed is None: + # A record that is also a skill, with no list, is its own one member. + members = [folder.name] if has_skill else [] + elif isinstance(listed, list) and all(isinstance(name, str) and name for name in listed): + members = list(dict.fromkeys(listed)) + else: + problems.append(manifest_problem(folder, f"{manifest}: [bmod] 'skills' is not a list of skill names")) + members = [] + return Module(code, folder, table, members) + + +def resolve_skill( + root: Path, folder: Path, table: dict[str, object], own_record: bool, record_codes: dict[str, str] +) -> dict[str, object]: + bmod = folder.name if own_record else table.get("bmod") + if not isinstance(bmod, str) or not bmod: + bmod = None + return { + "skill": folder.name, + "module": record_codes.get(bmod) if bmod else None, + "bmod": bmod, + "root": str(root), + "source": table.get("source"), + } + + +def absent_records(skills: list[dict[str, object]], folders: dict[str, Path]) -> list[dict[str, object]]: + """One problem per module record that skills name and no root holds.""" + problems: list[dict[str, object]] = [] + by_bmod: dict[str, list[dict[str, object]]] = {} + for entry in skills: + if entry["module"] is not None: + continue + if entry["bmod"] is None: + problems.append( + { + "kind": "manifest", + "skill": entry["skill"], + "problem": f"{entry['skill']}: [skill] does not name its module record under 'bmod'", + } + ) + continue + by_bmod.setdefault(str(entry["bmod"]), []).append(entry) + for bmod, entries in sorted(by_bmod.items()): + names = sorted(str(entry["skill"]) for entry in entries) + state = "has no usable module record" if bmod in folders else "is not installed" + problem: dict[str, object] = { + "kind": "module", + "bmod": bmod, + "skills": names, + "problem": f"module record {bmod} {state}; it is named by {', '.join(names)}", + } + command = None if bmod in folders else install_command(entries[0]["source"], bmod) + if command: + problem["install"] = command + problem["problem"] = f"{problem['problem']}; install it with `{command}`" + problems.append(problem) + return problems + + +def install_command(source: object, skill: str) -> str | None: + if not isinstance(source, str) or not source.startswith("github:"): + return None + parts = source.removeprefix("github:").split("/") + if len(parts) < 2 or not all(parts[:2]): + return None + return f"npx skills add {parts[0]}/{parts[1]} --skill {skill}" + + +def record_document( + documents: dict[tuple[str, str], dict[str, object]], + problems: list[dict[str, object]], + folders: dict[str, Path], + module: Module, + entry: object, + *, + include_content: bool, +) -> None: + folder = module.folder + name = entry.get("path") if isinstance(entry, dict) else None + if not isinstance(name, str): + problems.append( + {"kind": "knowledge", "skill": folder.name, "problem": f"knowledge entry {entry!r} has no path"} + ) + return + relative = safe_skill_relative(name) + if relative is None: + problems.append({"kind": "knowledge", "skill": folder.name, "problem": f"knowledge names unsafe path {name!r}"}) + return + covered = entry.get("skills", "*") + if covered == "*": + skills = list(module.skills) + elif isinstance(covered, list) and all(isinstance(skill, str) and skill for skill in covered): + skills = list(dict.fromkeys(covered)) + else: + problems.append( + { + "kind": "knowledge", + "skill": folder.name, + "problem": f"knowledge entry {name!r}: 'skills' is neither \"*\" nor a list of skill names", + } + ) + return + key = (module.code, relative.as_posix()) + if key in documents: + problems.append({"kind": "knowledge", "skill": folder.name, "problem": f"knowledge names {name!r} twice"}) + return + + path = folder.joinpath(*relative.parts) + try: + raw = read_document(path, folder) + except (OSError, ValueError) as error: + problems.append( + {"kind": "document", "skill": folder.name, "document": str(path), "problem": f"{path}: {error}"} + ) + return + try: + text = raw.decode("utf-8") + except UnicodeError as error: + problems.append( + { + "kind": "document", + "skill": folder.name, + "document": str(path), + "problem": f"{path}: not valid UTF-8, so it is not a knowledge document: {error}", + } + ) + return + + document: dict[str, object] = { + "module": module.code, + "path": relative.as_posix(), + "skills": skills, + "installed_skills": [skill for skill in skills if skill in folders], + "reported_from": folder.name, + } + if include_content: + document["content"] = text + documents[key] = document + + +def module_topics(module: Module, problems: list[dict[str, object]]) -> list[dict[str, object]]: + folder = module.folder + try: + found = sorted(path for path in (folder / TOPICS_DIR).glob("*.md") if path != folder / HELP_NAME) + except OSError: + return [] + topics: list[dict[str, object]] = [] + for path in found: + try: + read_document(path, folder).decode("utf-8") + except (OSError, ValueError) as error: + problems.append( + {"kind": "document", "skill": folder.name, "document": str(path), "problem": f"{path}: {error}"} + ) + continue + topics.append( + {"module": module.code, "topic": path.stem, "path": f"{TOPICS_DIR}/{path.name}", "file": str(path)} + ) + return topics + + +def module_migrations(module: Module, problems: list[dict[str, object]]) -> list[dict[str, object]]: + """The migrations a record ships: its `migration-.toml` files, in number order.""" + folder = module.folder + try: + found = sorted( + path for path in folder.glob("*.toml") if path.name not in (MANIFEST_NAME, ROSTER_NAME, RETIRED_NAME) + ) + except OSError: + return [] + migrations: list[dict[str, object]] = [] + for path in found: + try: + data = tomllib.loads(read_document(path, folder).decode("utf-8")) + except (OSError, ValueError) as error: + problems.append(migration_problem(folder, path, str(error))) + continue + if MIGRATION_TABLE not in data: + continue + table = data[MIGRATION_TABLE] + if not isinstance(table, dict): + problems.append(migration_problem(folder, path, "'migration' is not a table")) + continue + fields = {name: table.get(name) for name in MIGRATION_FIELDS} + missing = [name for name, value in fields.items() if not isinstance(value, str) or not value.strip()] + checklist = table.get("checklist") + if ( + not isinstance(checklist, list) + or not checklist + or not all(isinstance(item, str) and item.strip() for item in checklist) + ): + missing.append("checklist") + if missing: + problems.append(migration_problem(folder, path, f"[migration] needs non-empty {', '.join(missing)}")) + continue + if fields["module"] != module.code: + problems.append( + migration_problem( + folder, path, f"[migration] module {fields['module']!r} is not this record's {module.code!r}" + ) + ) + continue + if migration_number(path.name) is None: + problems.append(migration_problem(folder, path, "a migration file must be named migration-.toml")) + continue + listed = {name: fields[name] for name in ("from", "to", "title")} + migrations.append({"module": module.code, "path": path.name, "file": str(path), **listed}) + numbers = [migration_number(str(item["path"])) for item in migrations] + duplicated = {number for number in numbers if numbers.count(number) > 1} + for item in migrations: + if migration_number(str(item["path"])) in duplicated: + problems.append( + migration_problem( + folder, Path(str(item["file"])), "another migration of this module has the same number" + ) + ) + kept = [item for item in migrations if migration_number(str(item["path"])) not in duplicated] + return sorted(kept, key=lambda item: migration_number(str(item["path"]))) + + +def migration_number(name: str) -> int | None: + match = MIGRATION_NAME.fullmatch(name) + return int(match.group(1)) if match else None + + +def migration_problem(folder: Path, path: Path, problem: str) -> dict[str, object]: + return {"kind": "migration", "skill": folder.name, "document": str(path), "problem": f"{path}: {problem}"} + + +def read_document(path: Path, folder: Path) -> bytes: + """Read a knowledge document, refusing anything that is not a plain file inside the folder.""" + resolved = path.resolve() + if not resolved.is_relative_to(folder.resolve()): + raise ValueError("resolves outside the skill folder") + status = resolved.stat() + if not stat.S_ISREG(status.st_mode): + raise ValueError("is not a regular file") + with resolved.open("rb") as handle: + raw = handle.read(READ_LIMIT + 1) + if len(raw) > READ_LIMIT: + raise ValueError(f"is larger than {READ_LIMIT} bytes") + return raw + + +def safe_skill_relative(entry: str) -> PurePosixPath | None: + """A bmod.toml path that cannot escape the skill folder, or None if it can. + + Mirrors safe_skill_relative in setup.py. A URL parses as an ordinary + relative path and a Windows drive prefix makes a later join discard the + skill folder, so both are refused by name. pathlib drops "." components + itself, so only ".." and an empty final component need checking. + """ + if not entry or "://" in entry or "\\" in entry or ":" in entry: + return None + relative = PurePosixPath(entry) + if relative.is_absolute() or ".." in relative.parts or not relative.name: + return None + return relative + + +if __name__ == "__main__": + if sys.platform == "win32": + # Piped output on Windows defaults to a legacy code page, not UTF-8. + sys.stdout.reconfigure(encoding="utf-8") + sys.stderr.reconfigure(encoding="utf-8") + sys.exit(main()) diff --git a/_bmad/scripts/memlog.py b/_bmad/scripts/memlog.py new file mode 100644 index 0000000..57a7cb3 --- /dev/null +++ b/_bmad/scripts/memlog.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""memlog — an append-only memory log: LLM-optimal working memory for a skill. + +A memlog is the dense, chronological record of everything that mattered in a piece of +work — every item the user generated or accepted — kept minimal like human memory: only +what's important, never bloated. It persists ACROSS sessions, so a fresh session can +load it and continue. It is NOT a deliverable; downstream artifacts (a brief, a PRD, a +deck, a report) are *derived* from it on demand. The host skill supplies the vocabulary +by how it calls `append` — the tool stays neutral. + +It is a FLAT log: there are no sections or grouping. Every entry is one line, recorded +at the END in the order it happened. The chronology itself is the structure — an event +like "started technique X" is just another entry, same as an idea or an insight. + +Three invariants make it trustworthy: + + 1. Append-only, chronological. Entries land at the end, in the order they happen. + Nothing is ever inserted backward, reordered, edited, or removed. There is no + edit or delete subcommand by design; history is never rewritten. + 2. Write-only / blind. Every command is a context-free write and echoes the new state + as one line of JSON, so the caller never re-reads the file mid-session. `append` adds + its line with one OS append write, so concurrent appenders never drop each other. + The one time the file is read is on resume — and the caller reads it itself, not + via this script. + 3. No lifecycle status. A memory log has no "complete" flag. Whether the work is done, + blocked, or paused is itself a fact that happened, so it is recorded as an entry + (e.g. `append --type event --text "session complete"`), never as frontmatter the + log would have to mutate. The chronology stays the single source of truth, and a + resume learns the state by reading the last entries — the same way it learns + everything else. + +Atomicity: `init` and `set` write a temp file, flush and fsync it, then atomically rename +it over the target. `append` never rewrites the file: it adds the entry at the end of file +in a single append write (O_APPEND on POSIX, a FILE_APPEND_DATA handle on Windows), which the +OS places after whatever other processes appended, so parallel appends all land. + +The file shape (.memlog.md): + + --- + topic: Onboarding flow for a budgeting app + goal: lift week-1 retention + updated: 2026-06-07T14:22 + --- + + - (note) user picked techniques: SCAMPER, then Six Thinking Hats + - (technique) started SCAMPER + - (idea) skip the signup wall: let people try with sample data first + - (idea) auto-import one bank account so the first screen shows real numbers + - (question) is open-banking consent too heavy for step one? + - (insight) the "scary numbers" risk and the "real numbers" idea are one lever: show real data, pre-categorized + - (direction) optimize for the anxious first-timer, not the power user + - (decision) lead with one pre-categorized account; defer multi-account import + - (event) session complete + +Each entry may carry an optional `--type` — what KIND it is (idea, insight, question, +decision, direction, assumption, gap, note, event, …) — and an optional `--by` naming +who it came from (e.g. `user`, `coach`), for sessions where authorship matters. Both +render into one short inline tag: `(idea)`, `(idea by user)`, `(by coach)`. Omit them +for a plain note. The host skill names the vocabulary; the script does not enforce one. + +Commands: + init (--workspace DIR | --path FILE) [--field k=v ...] create the memlog (errors if it exists) + append (--workspace DIR | --path FILE) --text STR [--type T] [--by W] append one entry at the end + set (--workspace DIR | --path FILE) --key K --value V set/replace a descriptive frontmatter field + +Addressing: `--workspace` is the run folder, and the memlog is always {workspace}/.memlog.md. +`--path` points straight at the memlog file instead, for callers that already hold the path. +""" + +from __future__ import annotations # keep type-hint syntax lazy so the script runs on 3.8+ + +import argparse +import json +import os +import sys +from datetime import datetime +from pathlib import Path + +MEMLOG = ".memlog.md" + + +def now() -> str: + return datetime.now().strftime("%Y-%m-%dT%H:%M") + + +def resolve(args) -> Path: + """The memlog file, from either addressing mode: {workspace}/.memlog.md or an explicit --path.""" + return Path(args.path) if args.path else Path(args.workspace) / MEMLOG + + +def split(text: str) -> tuple[dict, str]: + """Return (frontmatter dict in source order, body str). Frontmatter is plain key: value. + + The closing fence is the first line that is *exactly* `---`, so a `---` inside a + field value (topic/goal are free user text) never truncates the frontmatter. + """ + lines = text.splitlines() + if not lines or lines[0] != "---": + raise ValueError(".memlog.md has no frontmatter") + end = next((i for i in range(1, len(lines)) if lines[i] == "---"), None) + if end is None: + raise ValueError(".memlog.md frontmatter is not terminated") + meta: dict[str, str] = {} + for line in lines[1:end]: + if ":" in line: + k, v = line.split(":", 1) + meta[k.strip()] = v.strip() + return meta, "\n".join(lines[end + 1 :]).lstrip("\n") + + +def render(meta: dict, body: str) -> str: + # Neutralize newlines in values so a multi-line field can't break the fence on re-read. + fm = "\n".join(f"{k}: {' '.join(str(v).splitlines())}" for k, v in meta.items()) + return "---\n" + fm + "\n---\n\n" + body.rstrip("\n") + "\n" + + +def touch(meta: dict) -> None: + """Stamp `updated` and keep it last so the field order stays predictable.""" + meta.pop("updated", None) + meta["updated"] = now() + + +def write_atomic(path: Path, text: str) -> None: + """Temp + flush + fsync + atomic rename, so a crash never half-writes an entry.""" + tmp = path.with_suffix(path.suffix + ".tmp") + with open(tmp, "w", encoding="utf-8") as f: + f.write(text) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, path) + + +def append_line(path: Path, text: str) -> None: + """Add text at end of file in one OS append write, then sync it to disk. Never creates the file.""" + data = text.replace("\n", os.linesep).encode("utf-8") # match text-mode line endings + if sys.platform != "win32": + fd = os.open(path, os.O_WRONLY | os.O_APPEND) + try: + if os.write(fd, data) != len(data): + raise OSError(f"short write appending to {path}") + os.fsync(fd) + finally: + os.close(fd) + return + # The CRT's append mode seeks then writes in two steps; a handle with only + # FILE_APPEND_DATA access makes Windows itself place every write at end of file. + import ctypes + from ctypes import wintypes + + k32 = ctypes.WinDLL("kernel32", use_last_error=True) + k32.CreateFileW.argtypes = [ + wintypes.LPCWSTR, + wintypes.DWORD, + wintypes.DWORD, + ctypes.c_void_p, + wintypes.DWORD, + wintypes.DWORD, + wintypes.HANDLE, + ] + k32.CreateFileW.restype = wintypes.HANDLE + k32.WriteFile.argtypes = [ + wintypes.HANDLE, + ctypes.c_char_p, + wintypes.DWORD, + ctypes.POINTER(wintypes.DWORD), + ctypes.c_void_p, + ] + k32.WriteFile.restype = wintypes.BOOL + k32.FlushFileBuffers.argtypes = [wintypes.HANDLE] + k32.FlushFileBuffers.restype = wintypes.BOOL + k32.CloseHandle.argtypes = [wintypes.HANDLE] + file_append_data, share_all, open_existing, normal = 0x4, 0x7, 3, 0x80 + handle = k32.CreateFileW(str(path), file_append_data, share_all, None, open_existing, normal, None) + if handle is None or handle == wintypes.HANDLE(-1).value: + raise ctypes.WinError(ctypes.get_last_error()) + try: + written = wintypes.DWORD() + if not k32.WriteFile(handle, data, len(data), ctypes.byref(written), None): + raise ctypes.WinError(ctypes.get_last_error()) + if written.value != len(data): + raise OSError(f"short write appending to {path}") + if not k32.FlushFileBuffers(handle): + raise ctypes.WinError(ctypes.get_last_error()) + finally: + k32.CloseHandle(handle) + + +def entry_count(body: str) -> int: + return sum(1 for ln in body.splitlines() if ln.startswith("- ")) + + +def ack(path: Path, body: str) -> None: + """Echo new state so the caller never re-reads the file to know where it stands.""" + print( + json.dumps( + { + "ok": True, + "memlog": str(path), + "entries": entry_count(body), + } + ) + ) + + +def cmd_init(args) -> int: + path = resolve(args) + if path.exists(): + print(f"error: {path} already exists; use append/set to update it", file=sys.stderr) + return 2 + path.parent.mkdir(parents=True, exist_ok=True) + meta: dict[str, str] = {} + for pair in args.field or []: + if "=" not in pair: + print(f"error: --field expects key=value, got {pair!r}", file=sys.stderr) + return 2 + k, v = pair.split("=", 1) + meta[k.strip()] = v.strip() + touch(meta) + write_atomic(path, render(meta, "")) + ack(path, "") + return 0 + + +def cmd_append(args) -> int: + path = resolve(args) + raw = path.read_text(encoding="utf-8") + split(raw) # a missing or malformed log fails here, before anything is written + text = " ".join(args.text.split()) # collapse newlines/runs → one-line entry, no prose bloat + label = args.type or "" + if args.by: + label = f"{label} by {args.by}".strip() # attribution: "(idea by user)" / "(by coach)" + tag = f"({label}) " if label else "" + entry = f"- {tag}{text}" + append_line(path, ("" if raw.endswith("\n") else "\n") + entry + "\n") + ack(path, split(path.read_text(encoding="utf-8"))[1]) + return 0 + + +def cmd_set(args) -> int: + path = resolve(args) + meta, body = split(path.read_text(encoding="utf-8")) + meta[args.key] = args.value + touch(meta) + write_atomic(path, render(meta, body)) + ack(path, body) + return 0 + + +def add_target(sp) -> None: + """Every command addresses the memlog the same way: a run folder or an explicit path.""" + g = sp.add_mutually_exclusive_group(required=True) + g.add_argument("--workspace", help="run folder; the memlog is {workspace}/.memlog.md") + g.add_argument("--path", help="explicit memlog file path (alternative to --workspace)") + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = p.add_subparsers(dest="cmd", required=True) + + pi = sub.add_parser("init", help="create the memlog") + add_target(pi) + pi.add_argument("--field", action="append", metavar="KEY=VALUE", help="frontmatter field (repeatable)") + pi.set_defaults(func=cmd_init) + + pa = sub.add_parser("append", help="append one entry at the end") + add_target(pa) + pa.add_argument("--text", required=True) + pa.add_argument("--type", help="entry kind, rendered as an inline tag") + pa.add_argument("--by", help="who the entry came from (e.g. user, coach); rendered into the tag") + pa.set_defaults(func=cmd_append) + + pset = sub.add_parser("set", help="set a descriptive frontmatter field") + add_target(pset) + pset.add_argument("--key", required=True) + pset.add_argument("--value", required=True) + pset.set_defaults(func=cmd_set) + + args = p.parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + if sys.platform == "win32": + # Piped output on Windows defaults to a legacy code page, not UTF-8. + sys.stdout.reconfigure(encoding="utf-8") + sys.stderr.reconfigure(encoding="utf-8") + sys.exit(main()) diff --git a/_bmad/scripts/migrate_v6.py b/_bmad/scripts/migrate_v6.py new file mode 100644 index 0000000..e5b53f7 --- /dev/null +++ b/_bmad/scripts/migrate_v6.py @@ -0,0 +1,319 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Report or remove the v6 traces in `_bmad/` that v7 does not read. + +With no flag it lists them: the classic installer's files, the v6 install answers +v7 has no key for, and customizations of skills that are not installed. With +`--clean` it copies `_bmad/` to `.v6-v7-migration-backup-/`, which the +method migration reuses, then removes them. + +Usage: + uv run migrate_v6.py --project-root P --skill [--root R ...] [--clean] +""" + +from __future__ import annotations + +import argparse +import datetime +import importlib.util +import re +import shutil +import sys +import tomllib +from pathlib import Path, PurePosixPath + +sys.dont_write_bytecode = True + + +def _load_setup(): + spec = importlib.util.spec_from_file_location("bmad_setup", Path(__file__).with_name("setup.py")) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +_setup = _load_setup() +Installation = _setup.Installation +Retirement = _setup.Retirement +TABLE_HEADER = _setup.TABLE_HEADER +TEAM_CONFIG = _setup.TEAM_CONFIG +USER_CONFIG = _setup.USER_CONFIG +_MISSING = _setup._MISSING +discover_installation = _setup.discover_installation +header_path = _setup.header_path +lookup = _setup.lookup +present = _setup.present +reject_unusable_bmad = _setup.reject_unusable_bmad +render_toml = _setup.render_toml +retired_skills = _setup.retired_skills +retirement_report = _setup.retirement_report +write_text = _setup.write_text + +# Traces the classic installer leaves under _bmad. +LEGACY_LEFTOVERS = ( + "_config/manifest.yaml", + "_config/files-manifest.csv", + "_config/skill-manifest.csv", + "_config/bmad-help.csv", + "config.user.toml", + "core/config.yaml", + "core/module-help.csv", + "core/v6-shims", + "bmm/config.yaml", + "bmm/module-help.csv", + "bmm/v6-shims", +) +# The v6.12 install answers v7 has no key for. Only these are removed, so a key added later is never touched. +V6_KEYS = ( + ("core", "user_name"), + ("core", "communication_language"), + ("core", "document_output_language"), + ("modules", "bmm", "user_skill_level"), + ("modules", "bmm", "planning_artifacts"), + ("modules", "bmm", "implementation_artifacts"), + ("modules", "bmm", "project_knowledge"), +) +CENTRAL_CONFIGS = (TEAM_CONFIG, "_bmad/custom/config.toml", USER_CONFIG) +BACKUP_PREFIX = ".v6-v7-migration-backup-" + + +def legacy_leftovers(project_root: Path) -> list[str]: + bmad = project_root / "_bmad" + return [ + relative + for relative in LEGACY_LEFTOVERS + if (path := bmad.joinpath(*PurePosixPath(relative).parts)).exists() and inside_bmad(path, project_root) + ] + + +def inside_bmad(path: Path, project_root: Path) -> bool: + """A link along the way could point a cleanup outside the project; such a path is not cleaned.""" + return path.resolve().is_relative_to((project_root / "_bmad").resolve()) + + +def stale_config_keys(project_root: Path) -> list[tuple[str, tuple[str, ...]]]: + """The V6_KEYS set in the central config files.""" + stale: list[tuple[str, tuple[str, ...]]] = [] + for relative in CENTRAL_CONFIGS: + path = project_root / relative + if path.is_symlink() or not path.is_file() or not inside_bmad(path, project_root): + continue + try: + data = tomllib.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, tomllib.TOMLDecodeError): + continue + stale += [(relative, key) for key in V6_KEYS if lookup(data, key) is not _MISSING] + return stale + + +def unused_customizations(project_root: Path, installation: Installation, retirement: Retirement) -> list[str]: + """`_bmad/custom/` files of skills that are not installed. Files setup is about to rename are not unused.""" + custom = project_root / "_bmad" / "custom" + if custom.is_symlink() or not custom.is_dir(): + return [] + moving = {old for old, _new in (*retirement.renames, *retirement.unmoved)} + unused: list[str] = [] + for path in sorted(custom.iterdir(), key=lambda entry: entry.name): + name = path.name + stem = name.removesuffix(".toml").removesuffix(".user") + if not name.startswith("bmad-") or not name.endswith(".toml") or name in moving: + continue + if stem not in installation.folders: + unused.append(name) + return unused + + +def traces_json(project_root: Path, installation: Installation, retirement: Retirement) -> dict[str, object]: + return { + "legacy_leftovers": legacy_leftovers(project_root), + "stale_config_keys": [ + {"file": relative, "key": ".".join(path)} for relative, path in stale_config_keys(project_root) + ], + "unused_customizations": [ + f"_bmad/custom/{name}" for name in unused_customizations(project_root, installation, retirement) + ], + } + + +def clean(project_root: Path, skill_root: Path, *, roots: tuple[Path, ...] = ()) -> dict[str, object]: + """Back up `_bmad/`, then remove the v6 traces v7 does not read: the classic installer's files, v6 + config keys, and customizations of skills that are not installed.""" + reject_unusable_bmad(project_root) + bmad = project_root / "_bmad" + installation = discover_installation(skill_root, roots) + if installation.missing_records: + # Without every module record, a renamed skill's customizations would look unused. + raise Exception("a module record is missing; run setup to repair the installation before cleaning up") + retirement = retirement_report( + project_root, skill_root, installation, retired_skills(installation.modules, installation.modules) + ) + leftovers = legacy_leftovers(project_root) + stale = stale_config_keys(project_root) + unused = unused_customizations(project_root, installation, retirement) + edits = { + relative: text_without_keys( + (project_root / relative).read_text(encoding="utf-8"), [key for file, key in stale if file == relative] + ) + for relative in dict.fromkeys(file for file, _path in stale) + } + report: dict[str, object] = { + "mode": "clean", + "backup": None, + "removed_files": [], + "removed_keys": [], + "removed_customizations": [], + } + if not (leftovers or stale or unused): + return report + backup = backup_bmad(project_root) + report["backup"] = backup.relative_to(project_root).as_posix() + for relative in leftovers: + path = bmad.joinpath(*PurePosixPath(relative).parts) + if path.is_dir() and not path.is_symlink(): + shutil.rmtree(path) + else: + path.unlink() + prune_empty_parents(path.parent, bmad) + for relative, text in edits.items(): + write_text(project_root / relative, text) + for name in unused: + (bmad / "custom" / name).unlink() + report["removed_files"] = [f"_bmad/{relative}" for relative in leftovers] + report["removed_keys"] = [{"file": relative, "key": ".".join(path)} for relative, path in stale] + report["removed_customizations"] = [f"_bmad/custom/{name}" for name in unused] + return report + + +def backup_bmad(project_root: Path) -> Path: + """Copy `_bmad/` into a new `.v6-v7-migration-backup-/`, which the method migration reuses. + Its `.gitignore` keeps the copy, personal settings included, out of every commit.""" + now = datetime.datetime.now() + backup = project_root / f"{BACKUP_PREFIX}{now:%Y%m%d-%H%M}" + if present(backup): + backup = project_root / f"{BACKUP_PREFIX}{now:%Y%m%d-%H%M%S}" + shutil.copytree(project_root / "_bmad", backup / "_bmad", symlinks=True) + write_text(backup / ".gitignore", "*") + return backup + + +def prune_empty_parents(folder: Path, stop: Path) -> None: + while folder != stop and folder.is_dir() and not folder.is_symlink() and not any(folder.iterdir()): + folder.rmdir() + folder = folder.parent + + +def text_without_keys(text: str, paths: list[tuple[str, ...]]) -> str: + """The file's text with these keys removed, so comments and layout survive. + + A key written on one line in its table's section is cut by text, and a table left + empty loses its header. When the result does not parse to the expected values, + the whole file is rendered instead. + """ + expected = tomllib.loads(text) + for path in paths: + drop_path(expected, path) + lines = text.split("\n") + for path in paths: + lines = lines_without_key(lines, path[:-1], path[-1]) + headers = [index for index, line in enumerate(lines) if TABLE_HEADER.match(line)] + emptied = { + index + for position, index in enumerate(headers) + if (name := header_path(lines[index])) is not None + and lookup(expected, name) is _MISSING + and not any( + line.strip() and not line.lstrip().startswith("#") + for line in lines[index + 1 : headers[position + 1] if position + 1 < len(headers) else len(lines)] + ) + } + candidate = "\n".join(line for index, line in enumerate(lines) if index not in emptied) + try: + if tomllib.loads(candidate) == expected: + return candidate + except tomllib.TOMLDecodeError: + pass + return render_toml(expected) + + +def drop_path(data: dict, path: tuple[str, ...]) -> None: + """Delete the key at path, then any table the deletion left empty.""" + parents = [data] + for part in path[:-1]: + child = parents[-1].get(part) + if not isinstance(child, dict): + return + parents.append(child) + parents[-1].pop(path[-1], None) + for depth in range(len(path) - 1, 0, -1): + if parents[depth]: + break + parents[depth - 1].pop(path[depth - 1], None) + + +def lines_without_key(lines: list[str], table: tuple[str, ...], leaf: str) -> list[str]: + start, end = 0, len(lines) + if table: + headers = [(index, header_path(line)) for index, line in enumerate(lines) if TABLE_HEADER.match(line)] + for position, (index, name) in enumerate(headers): + if name == table: + start = index + 1 + end = headers[position + 1][0] if position + 1 < len(headers) else len(lines) + break + else: + return lines + else: + end = next((index for index, line in enumerate(lines) if TABLE_HEADER.match(line)), len(lines)) + key = re.compile(rf"\s*(?:{re.escape(leaf)}|\"{re.escape(leaf)}\"|'{re.escape(leaf)}')\s*=") + for index in range(start, end): + if not key.match(lines[index]): + continue + # A multi-line value ends at the first line after which the file parses again. + for stop in range(index + 1, end + 1): + remaining = [*lines[:index], *lines[stop:]] + try: + tomllib.loads("\n".join(remaining)) + except tomllib.TOMLDecodeError: + continue + return remaining + return lines + return lines + + +def report(project_root: Path, skill_root: Path, *, roots: tuple[Path, ...] = ()) -> dict[str, object]: + """The traces `--clean` would remove. Reads only.""" + reject_unusable_bmad(project_root) + installation = discover_installation(skill_root, roots) + retirement = retirement_report( + project_root, skill_root, installation, retired_skills(installation.modules, installation.modules) + ) + return {"mode": "report", **traces_json(project_root, installation, retirement)} + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Report or remove the v6 traces in _bmad that v7 does not read.") + parser.add_argument("--project-root", type=Path, required=True) + parser.add_argument("--skill", type=Path, required=True) + parser.add_argument("--root", type=Path, action="append", default=[], help="an active skills folder, repeated") + parser.add_argument("--clean", action="store_true", help="back up _bmad, then remove the traces") + args = parser.parse_args(argv) + project_root = args.project_root.resolve() + skill_root = args.skill.resolve() + roots = tuple(root.resolve() for root in args.root) + run = clean if args.clean else report + _setup.print_json(run(project_root, skill_root, roots=roots)) + return 0 + + +if __name__ == "__main__": + if sys.platform == "win32": + # Piped output on Windows defaults to a legacy code page, not UTF-8. + sys.stdout.reconfigure(encoding="utf-8") + sys.stderr.reconfigure(encoding="utf-8") + try: + raise SystemExit(main()) + except Exception as error: + sys.stderr.write(f"error: {error}\n") + raise SystemExit(1) from None diff --git a/_bmad/scripts/render_skill.py b/_bmad/scripts/render_skill.py new file mode 100644 index 0000000..1f7882c --- /dev/null +++ b/_bmad/scripts/render_skill.py @@ -0,0 +1,689 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# dependencies = ["jinja2>=3.1"] +# /// +"""Render a skill's Markdown sources into an immutable project snapshot.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import shutil +import sys +import tomllib +from collections.abc import Callable +from datetime import date, time +from pathlib import Path +from typing import Any + +import jinja2 + +# Installed scripts are consumer files, not a location for interpreter caches. +sys.dont_write_bytecode = True + +from config_utils import ( # noqa: E402 + ConfigError, + load_central_config, + load_customization, + load_toml, + structural_merge, +) + + +class RenderError(ValueError): + """Raised when rendering cannot safely publish a snapshot.""" + + +class _ArgumentParser(argparse.ArgumentParser): + def error(self, message: str) -> None: + raise RenderError(message) + + +_PARAMETER = r"[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*" + + +def _hash_bytes(content: bytes) -> str: + return hashlib.sha256(content).hexdigest() + + +def _canonical_json(value: Any) -> bytes: + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=_json_scalar).encode( + "utf-8" + ) + + +def _json_scalar(value: Any) -> str: + if isinstance(value, (date, time)): + return value.isoformat() + raise TypeError(f"unsupported JSON value: {type(value).__name__}") + + +def _toml_literal(text: str, label: str) -> Any: + try: + parsed = tomllib.loads(f"value = {text}") + except tomllib.TOMLDecodeError as error: + raise RenderError(f"invalid TOML value for {label}: {error}") from error + if set(parsed) != {"value"}: + raise RenderError(f"{label} must contain a single TOML value") + return parsed["value"] + + +def _invocation_customization( + defaults: dict[str, Any], overrides: Path | None, assignments: list[str] +) -> tuple[dict[str, Any], dict[str, Any]]: + file_layer = load_toml(overrides, required=True) if overrides is not None else {} + command_layer: dict[str, Any] = {} + assigned: list[str] = [] + for assignment in assignments: + path, separator, raw = assignment.partition("=") + if not separator or re.fullmatch(_PARAMETER, path) is None: + raise RenderError(f"invalid --set assignment {assignment!r}; expected bare dotted key=value") + # A repeated or overlapping path is a caller mistake, not a precedence rule. + for earlier in assigned: + if path == earlier or path.startswith(f"{earlier}.") or earlier.startswith(f"{path}."): + raise RenderError(f"--set `{path}` conflicts with earlier --set `{earlier}`") + assigned.append(path) + default = _lookup(defaults, path, "customization parameter") + value = ( + raw if isinstance(default, str) and not raw.lstrip().startswith(('"', "'")) else _toml_literal(raw, path) + ) + target = command_layer + parts = path.split(".") + for part in parts[:-1]: + target = target.setdefault(part, {}) + target[parts[-1]] = value + return file_layer, command_layer + + +def _leaf_paths(table: dict[str, Any], prefix: str = "") -> set[str]: + leaves: set[str] = set() + for key, value in table.items(): + path = f"{prefix}{key}" + if isinstance(value, dict): + leaves |= _leaf_paths(value, f"{path}.") + else: + leaves.add(path) + return leaves + + +def _declares(defaults: dict[str, Any], path: str) -> bool: + node: Any = defaults + for part in path.split("."): + if not isinstance(node, dict) or part not in node: + return False + node = node[part] + return True + + +def _check_persistent_layers(project_root: Path, skill_dir: Path, defaults: dict[str, Any] | None) -> None: + """A persistent override may only set keys the skill declares; a stale or misspelled key halts.""" + custom_dir = project_root / "_bmad" / "custom" + for layer in (custom_dir / f"{skill_dir.name}.toml", custom_dir / f"{skill_dir.name}.user.toml"): + undeclared = sorted(path for path in _leaf_paths(load_toml(layer)) if not _declares(defaults or {}, path)) + if undeclared: + raise RenderError(f"{layer} sets keys {skill_dir.name} does not declare: {', '.join(undeclared)}") + + +def _lookup(data: dict[str, Any], dotted_path: str, label: str) -> Any: + current: Any = data + for part in dotted_path.split("."): + if not isinstance(current, dict) or part not in current: + raise RenderError(f"missing {label} `{dotted_path}`") + current = current[part] + return current + + +def _require_string(value: Any, label: str, *, allow_empty: bool = False) -> str: + if not isinstance(value, str): + raise RenderError(f"{label} must be a string, got {type(value).__name__}") + if not allow_empty and not value.strip(): + raise RenderError(f"{label} must not be empty") + return value + + +def _require_string_list(value: Any, label: str) -> list[str]: + if not isinstance(value, list): + raise RenderError(f"{label} must be a list, got {type(value).__name__}") + result = [] + for index, item in enumerate(value): + result.append(_require_string(item, f"{label}[{index}]")) + return result + + +def _require_review_layers(value: Any, label: str) -> list[dict[str, str]]: + if not isinstance(value, list): + raise RenderError(f"{label} must be a list of tables") + result: list[dict[str, str]] = [] + seen: set[str] = set() + for index, item in enumerate(value): + item_label = f"{label}[{index}]" + if not isinstance(item, dict): + raise RenderError(f"{item_label} must be a table") + identifier = _require_string(item.get("id"), f"{item_label}.id") + if identifier in seen: + raise RenderError(f"duplicate review layer id `{identifier}`") + seen.add(identifier) + layer = { + "id": identifier, + "name": _require_string(item.get("name", identifier), f"{item_label}.name"), + "instruction": _require_string(item.get("instruction"), f"{item_label}.instruction", allow_empty=True), + } + if "when" in item: + layer["when"] = _require_string(item["when"], f"{item_label}.when") + result.append(layer) + return result + + +def _load_sources(skill_dir: Path) -> dict[str, str]: + sources: dict[str, str] = {} + for candidate in sorted(skill_dir.rglob("*.md")): + if candidate.name == "SKILL.md": + continue + name = candidate.relative_to(skill_dir).as_posix() + path = candidate.resolve(strict=True) + if not path.is_relative_to(skill_dir): + raise RenderError(f"render source escapes skill directory: {name}") + if not path.is_file(): + raise RenderError(f"render source is missing or not a file: {path}") + try: + sources[name] = path.read_text(encoding="utf-8") + except (OSError, UnicodeError) as error: + raise RenderError(f"failed to read render source {path}: {error}") from error + if "workflow.md" not in sources: + raise RenderError(f"render entry is missing: {skill_dir / 'workflow.md'}") + return sources + + +def _resolve_config_value(value: Any, label: str, project_root: Path) -> str: + text = _require_string(value, label) + if "{project-root}" not in text: + return text + resolved = text.replace("{project-root}", project_root.as_posix()) + if not Path(resolved).is_absolute(): + raise RenderError(f"{label} must resolve to an absolute path: {resolved}") + return resolved + + +def _find_config_values(data: Any, key: str, prefix: str = "") -> list[tuple[str, Any]]: + matches: list[tuple[str, Any]] = [] + if not isinstance(data, dict): + return matches + for name, value in data.items(): + path = f"{prefix}.{name}" if prefix else name + if name == key and not isinstance(value, (dict, list)): + matches.append((path, value)) + matches.extend(_find_config_values(value, key, path)) + return matches + + +def _resolve_short_config(central: dict[str, Any], key: str, project_root: Path) -> tuple[str, str]: + matches = _find_config_values(central, key) + if not matches: + raise RenderError(f"missing config value `{key}`") + if len(matches) > 1: + paths = ", ".join(path for path, _ in matches) + raise RenderError(f"ambiguous config value `{key}` found at: {paths}") + path, value = matches[0] + return path, _resolve_config_value(value, f"config.{path}", project_root) + + +def _format_markdown_list(items: list[str]) -> str: + if not items: + return "_None._" + rendered = [] + for item in items: + lines = item.splitlines() or [""] + rendered.append("- " + lines[0]) + rendered.extend(" " + line for line in lines[1:]) + return "\n".join(rendered) + + +def _format_review_layers(layers: list[dict[str, str]]) -> str: + active = [layer for layer in layers if layer["instruction"].strip()] + if not active: + return "No active review layers. HALT with blocking condition `no active review layers`." + sections = [] + for layer in active: + section = [f"#### {layer['name']} (`{layer['id']}`)"] + if layer.get("when"): + section.extend(["", f"Run only when: {layer['when']}"]) + section.extend(["", layer["instruction"].strip()]) + sections.append("\n".join(section)) + return "\n\n".join(sections) + + +def _resolve_customization_value(value: Any, default: Any, label: str) -> Any: + """Validate an effective customization leaf against the shape of its shipped default.""" + if isinstance(default, str): + allow_empty = not default.strip() + return _require_string(value, label, allow_empty=allow_empty) + if isinstance(default, list): + if default and all(isinstance(item, dict) for item in default): + return _require_review_layers(value, label) + return _require_string_list(value, label) + if isinstance(default, (bool, int, float, date, time)): + if type(value) is not type(default): + raise RenderError(f"{label} must be {type(default).__name__}, got {type(value).__name__}") + return value + raise RenderError(f"{label} has unsupported default type {type(default).__name__}") + + +class _Text(str): + """A customization string. Looping over one is a template mistake, not a walk over its characters.""" + + def __new__(cls, value: str, label: str) -> _Text: + text = super().__new__(cls, value) + text.label = label + return text + + def __iter__(self): + raise RenderError(f"`{self.label}` is a string, not a list") + + +class _Derived: + """A name the renderer computes from the central config, when a template reaches it.""" + + def __init__(self, compute: Callable[[], Any]) -> None: + self.compute = compute + + +class _MarkdownList(list): + """A string-list customization; inserted directly it renders as the Markdown list it always did.""" + + def __str__(self) -> str: + return _format_markdown_list(list(self)) + + +class _LayerList(list): + """A review-layer customization; inserted directly it renders as lens sections.""" + + def __str__(self) -> str: + return _format_review_layers(list(self)) + + +def _bind_customization(value: Any, label: str, destination: Path) -> Any: + """Bind `{skill-root}` in customization prose to the generation and wrap lists for insertion.""" + root = destination.as_posix() + if isinstance(value, str): + return _Text(value.replace("{skill-root}", root), label) + if isinstance(value, list): + if value and all(isinstance(item, dict) for item in value): + return _LayerList( + [{key: text.replace("{skill-root}", root) for key, text in layer.items()} for layer in value] + ) + return _MarkdownList([item.replace("{skill-root}", root) for item in value]) + return value + + +class _Table: + """A dotted namespace over a TOML table. Names never hit Python attributes, so `workflow.items` is a lookup.""" + + def __init__(self, path: str) -> None: + self._path = path + + def _child(self, name: str) -> str: + return f"{self._path}.{name}" + + def _resolve(self, name: str) -> Any: + raise NotImplementedError + + def __getattr__(self, name: str) -> Any: + if name.startswith("_"): + raise AttributeError(name) + return self._resolve(name) + + def __getitem__(self, name: Any) -> Any: + if not isinstance(name, str): + raise RenderError(f"`{self._path}` is indexed by name, not {name!r}") + return self._resolve(name) + + def __str__(self) -> str: + raise RenderError(f"`{self._path}` is a table, not a value") + + +class _ConfigTable(_Table): + """`config.key` is the short lookup of one scalar anywhere in the central config; `config.a.b.c` is a path.""" + + def __init__(self, central: dict[str, Any], table: dict[str, Any], path: str, ctx: _RenderContext) -> None: + super().__init__(path) + self._central = central + self._table = table + self._ctx = ctx + + def _resolve(self, name: str) -> Any: + if self._path == "config" and name not in self._table: + path, resolved = _resolve_short_config(self._central, name, self._ctx.project_root) + self._ctx.inputs[f"config.{path}"] = resolved + return _Text(resolved, f"config.{path}") + label = self._child(name) + if name not in self._table: + raise RenderError(f"missing config value `{label.removeprefix('config.')}`") + value = self._table[name] + if isinstance(value, dict): + return _ConfigTable(self._central, value, label, self._ctx) + resolved = _resolve_config_value(value, label, self._ctx.project_root) + self._ctx.inputs[label] = resolved + return _Text(resolved, label) + + +class _CustomizationTable(_Table): + """The effective customization, each leaf validated against its `customize.toml` default.""" + + def __init__(self, defaults: dict[str, Any] | None, values: dict[str, Any], path: str, ctx: _RenderContext) -> None: + super().__init__(path) + self._defaults = defaults + self._values = values + self._ctx = ctx + + def _resolve(self, name: str) -> Any: + path = self._child(name) + if self._defaults is None: + raise RenderError(f"`{path}` requires customize.toml") + if name not in self._defaults: + raise RenderError(f"missing customization parameter `{path}`") + if name not in self._values: + raise RenderError(f"missing customization value `{path}`") + default, value = self._defaults[name], self._values[name] + label = f"customization.{path}" + if isinstance(default, dict): + if not isinstance(value, dict): + raise RenderError(f"{label} must be a table, got {type(value).__name__}") + return _CustomizationTable(default, value, path, self._ctx) + resolved = _resolve_customization_value(value, default, label) + self._ctx.inputs[label] = resolved + return _bind_customization(resolved, label, self._ctx.destination) + + +class _RenderContext: + """One rendering pass: the values it serves and the rendered() links each source makes.""" + + def __init__( + self, + *, + central: dict[str, Any], + defaults: dict[str, Any] | None, + customization: dict[str, Any], + source_names: set[str], + project_root: Path, + destination: Path, + ) -> None: + self.project_root = project_root + self.destination = destination + self.inputs: dict[str, Any] = {} + self.links: dict[str, set[str]] = {} + self._source_names = source_names + self.variables = { + "config": _ConfigTable(central, central, "config", self), + "workflow": _CustomizationTable( + None if defaults is None else defaults.get("workflow", {}), + customization.get("workflow", {}), + "workflow", + self, + ), + "rendered": self._rendered, + "halt": self._halt, + "initiative_folder": _Derived(self._initiative_folder), + } + self._central = central + + def _initiative_folder(self) -> _Text: + """`core.output_folder`, extended by `/` when an initiative is set.""" + folder = str(self.variables["config"].core.output_folder) + core = self._central.get("core") + initiative = core.get("active_initiative") if isinstance(core, dict) else None + if initiative is not None: + folder = f"{folder}/{_require_string(initiative, 'config.core.active_initiative')}" + self.inputs["initiative_folder"] = folder + return _Text(folder, "initiative_folder") + + @staticmethod + def _halt(message: Any) -> str: + """Let a template reject its inputs; the caller prefixes the source and line.""" + raise RenderError(str(message)) + + @jinja2.pass_context + def _rendered(self, context: jinja2.runtime.Context, target: Any) -> str: + if not isinstance(target, str) or target not in self._source_names: + raise RenderError(f"rendered() targets undeclared source: {target}") + self.links.setdefault(context.name or "", set()).add(target) + return (self.destination / target).as_posix() + + +class _Context(jinja2.runtime.Context): + """Reaching a derived name computes it, so only the values a template reads key its generation.""" + + def resolve_or_missing(self, key: str) -> Any: + value = super().resolve_or_missing(key) + return value.compute() if isinstance(value, _Derived) else value + + +class _SourceLoader(jinja2.BaseLoader): + """Serve sources by name, and name them so template frames carry `source:line`.""" + + def __init__(self, sources: dict[str, str]) -> None: + self._sources = sources + + def get_source(self, environment: jinja2.Environment, template: str) -> tuple[str, str, Any]: + if template not in self._sources: + raise jinja2.TemplateNotFound(template) + return self._sources[template], template, lambda: True + + +def _template_location(error: BaseException, source_names: set[str]) -> str | None: + if isinstance(error, jinja2.TemplateSyntaxError): + return f"{error.name}:{error.lineno}" if error.name else None + location = None + traceback = error.__traceback__ + while traceback is not None: + filename = traceback.tb_frame.f_code.co_filename + if filename in source_names: + location = f"{filename}:{traceback.tb_lineno}" + traceback = traceback.tb_next + return location + + +def _render_sources(sources: dict[str, str], skill_dir: Path, context: _RenderContext) -> dict[str, str]: + """Render every source as a Jinja2 template against the context; return the non-empty outputs.""" + # Skill sources name their bundled non-Markdown files (scripts, assets) + # through {skill-root}; those stay in the installed skill directory. + bound = {name: content.replace("{skill-root}", skill_dir.as_posix()) for name, content in sources.items()} + environment = jinja2.Environment( + loader=_SourceLoader(bound), + undefined=jinja2.StrictUndefined, + autoescape=False, + keep_trailing_newline=True, + trim_blocks=True, + lstrip_blocks=True, + ) + environment.context_class = _Context + rendered: dict[str, str] = {} + for name in sources: + try: + rendered[name] = environment.get_template(name).render(context.variables) + except Exception as error: + location = _template_location(error, set(sources)) + message = str(error) if isinstance(error, (RenderError, ConfigError, jinja2.TemplateError)) else repr(error) + raise RenderError(f"{location or name}: {message}") from error + # A source whose body renders to nothing is left out; links into it from survivors are broken. + omitted = {name for name, text in rendered.items() if not text.strip()} + if "workflow.md" in omitted: + raise RenderError("workflow.md: rendered empty") + for name in sorted(set(rendered) - omitted): + for target in sorted(context.links.get(name, set()) & omitted): + raise RenderError(f"{name}: rendered() targets omitted source: {target}") + return {name: text for name, text in rendered.items() if name not in omitted} + + +def _verify_existing(destination: Path, manifest: dict[str, Any]) -> None: + manifest_path = destination / "manifest.json" + try: + existing = json.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as error: + raise RenderError(f"corrupt existing generation {destination}: {error}") from error + if existing != manifest: + raise RenderError(f"generation collision or corruption at {destination}") + # Only the manifest's own files are verified. Anything else in the folder (Thumbs.db, + # editor swap files, sync conflict copies) is read by nobody and is left alone. + missing = sorted(name for name in manifest["outputs"] if not (destination / name).is_file()) + if missing: + raise RenderError( + f"generation is missing rendered files: {', '.join(missing)} in {destination}; " + "deleting that folder is safe because the next run renders it again" + ) + for name, expected_hash in manifest["outputs"].items(): + try: + actual_hash = _hash_bytes((destination / name).read_bytes()) + except OSError as error: + raise RenderError(f"failed to verify {destination / name}: {error}") from error + if actual_hash != expected_hash: + raise RenderError(f"generation output hash mismatch: {destination / name}") + + +def _publish(destination: Path, outputs: dict[str, bytes], manifest: dict[str, Any]) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) + if destination.exists(): + _verify_existing(destination, manifest) + return + # One attempt, not mkdtemp: on Windows, older Pythons' mkdtemp takes "access denied" + # for a name collision and tries the next name, some two billion times. The render + # would hang in a folder it cannot write to instead of halting. + staging = destination.parent / f".staging-{os.urandom(8).hex()}" + staging.mkdir() + try: + for name, content in outputs.items(): + path = staging / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + (staging / "manifest.json").write_bytes( + json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True, default=_json_scalar).encode("utf-8") + + b"\n" + ) + try: + os.rename(staging, destination) + except OSError: + if destination.exists(): + _verify_existing(destination, manifest) + else: + raise + finally: + if staging.exists(): + shutil.rmtree(staging, ignore_errors=True) + + +def render( + project_root: Path, skill_dir: Path, *, overrides: Path | None = None, assignments: list[str] | None = None +) -> Path: + project_root = project_root.resolve(strict=True) + skill_dir = skill_dir.resolve(strict=True) + if not (project_root / "_bmad").is_dir(): + raise RenderError(f"project root does not contain _bmad/: {project_root}") + + sources = _load_sources(skill_dir) + central = load_central_config(project_root) + has_customization = bool(overrides is not None or assignments) or (skill_dir / "customize.toml").is_file() + defaults = load_toml(skill_dir / "customize.toml", required=True) if has_customization else None + _check_persistent_layers(project_root, skill_dir, defaults) + customization = load_customization(project_root, skill_dir) if has_customization else {} + supplied: set[str] = set() + if defaults is not None: + file_layer, command_layer = _invocation_customization(defaults, overrides, assignments or []) + customization = structural_merge(structural_merge(customization, file_layer), command_layer) + supplied = _leaf_paths(file_layer) | _leaf_paths(command_layer) + + source_hashes = {name: _hash_bytes(content.encode("utf-8")) for name, content in sources.items()} + root_hash = _hash_bytes(str(project_root).encode("utf-8"))[:12] + slug = re.sub(r"[^a-z0-9]+", "-", project_root.name.lower()).strip("-") or "project" + slug = slug[:80].rstrip("-") or "project" + namespace = project_root / "_bmad" / "render" / skill_dir.name / f"{slug}-{root_hash}" + + def render_pass(destination: Path) -> tuple[_RenderContext, dict[str, str]]: + context = _RenderContext( + central=central, + defaults=defaults, + customization=customization, + source_names=set(sources), + project_root=project_root, + destination=destination, + ) + return context, _render_sources(sources, skill_dir, context) + + # The generation path is keyed by the values the templates reach, and the + # templates insert that path, so a first pass against a placeholder + # destination collects the inputs and the real pass renders the output. + probe, _ = render_pass(namespace / "pending") + # An override may only name a key some template actually read; values are validated where consumed. + unused = sorted(path for path in supplied if f"customization.{path}" not in probe.inputs) + if unused: + raise RenderError(f"invocation override not used by this render: {', '.join(unused)}") + # Store TOML date/time inputs in the same JSON representation used on disk. + input_values = json.loads(_canonical_json(probe.inputs)) + renderer_hash = _hash_bytes(Path(__file__).read_bytes()) + identity = { + "project_root": str(project_root), + "skill_root": str(skill_dir), + "renderer_sha256": renderer_hash, + "jinja2_version": jinja2.__version__, + "resolved_values": input_values, + "source_sha256": source_hashes, + } + generation_hash = _hash_bytes(_canonical_json(identity))[:20] + destination = namespace / generation_hash + _, rendered = render_pass(destination) + outputs = {name: content.encode("utf-8") for name, content in rendered.items()} + output_hashes = {name: _hash_bytes(content) for name, content in outputs.items()} + manifest = { + "schema_version": 1, + "skill": skill_dir.name, + "project_root": str(project_root), + "project_slug": slug, + "root_hash": root_hash, + "generation_hash": generation_hash, + "inputs": identity, + "outputs": output_hashes, + } + _publish(destination, outputs, manifest) + return destination / "workflow.md" + + +def report_owed_setup(skill_dir: Path, project_root: Path) -> None: + # Runs before rendering so the note lands ahead of the instruction to follow, + # and still shows when rendering halts. It must never fail the render. + try: + import setup_check + except Exception: + return + setup_check.report(skill_dir, project_root) + + +def main() -> int: + parser = _ArgumentParser(description=__doc__) + parser.add_argument("--project-root", required=True) + parser.add_argument("--skill", required=True) + parser.add_argument("--overrides", type=Path, help="invocation-only customization TOML file") + parser.add_argument("--set", dest="assignments", action="append", default=[], metavar="KEY=VALUE") + reconfigure = getattr(sys.stdout, "reconfigure", None) + if reconfigure is not None: + reconfigure(encoding="utf-8") + try: + args = parser.parse_args() + report_owed_setup(Path(args.skill).resolve(), Path(args.project_root).resolve()) + entry = render( + Path(args.project_root), Path(args.skill), overrides=args.overrides, assignments=args.assignments + ) + except (ConfigError, RenderError, OSError, UnicodeError, ValueError) as error: + sys.stdout.write(f"HALT: {' '.join(str(error).splitlines())}\n") + return 1 + sys.stdout.write(f"read and follow {entry}\n") + return 0 + + +if __name__ == "__main__": + if sys.platform == "win32": + # Piped output on Windows defaults to a legacy code page, not UTF-8. + sys.stdout.reconfigure(encoding="utf-8") + sys.stderr.reconfigure(encoding="utf-8") + raise SystemExit(main()) diff --git a/_bmad/scripts/resolve_config.py b/_bmad/scripts/resolve_config.py new file mode 100644 index 0000000..2af0e64 --- /dev/null +++ b/_bmad/scripts/resolve_config.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Resolve BMad's four central TOML layers to JSON.""" + +import argparse +import json +import sys +from pathlib import Path + +# Installed scripts are consumer files, not a location for interpreter caches. +sys.dont_write_bytecode = True + +try: + from config_utils import ConfigError, load_central_config +except ModuleNotFoundError as error: + if error.name != "tomllib": + raise + sys.stderr.write("error: Python 3.11+ is required (stdlib `tomllib` not found).\n") + raise SystemExit(3) from None + + +_MISSING = object() + + +def extract_key(data, dotted_key: str): + current = data + for part in dotted_key.split("."): + if isinstance(current, dict) and part in current: + current = current[part] + else: + return _MISSING + return current + + +def write_json_stdout(output) -> None: + """Pin stdout to UTF-8 — a Windows cp1252 default cannot encode emoji icons.""" + reconfigure = getattr(sys.stdout, "reconfigure", None) + if reconfigure is not None: + reconfigure(encoding="utf-8") + sys.stdout.write(json.dumps(output, indent=2, ensure_ascii=False) + "\n") + + +def main() -> int: + parser = argparse.ArgumentParser(description="Resolve BMad central config using four-layer TOML merge.") + parser.add_argument( + "--project-root", + "-p", + required=True, + help="Absolute project root containing _bmad/", + ) + parser.add_argument( + "--key", + "-k", + action="append", + default=[], + help="Dotted field path to resolve (repeatable). Omit for full dump.", + ) + args = parser.parse_args() + + try: + merged = load_central_config(Path(args.project_root).resolve()) + except ConfigError as error: + sys.stderr.write(f"error: {error}\n") + return 1 + + output = merged + if args.key: + output = {} + for key in args.key: + value = extract_key(merged, key) + if value is not _MISSING: + output[key] = value + write_json_stdout(output) + return 0 + + +if __name__ == "__main__": + if sys.platform == "win32": + # Piped output on Windows defaults to a legacy code page, not UTF-8. + sys.stdout.reconfigure(encoding="utf-8") + sys.stderr.reconfigure(encoding="utf-8") + raise SystemExit(main()) diff --git a/_bmad/scripts/resolve_customization.py b/_bmad/scripts/resolve_customization.py new file mode 100644 index 0000000..d99e7cf --- /dev/null +++ b/_bmad/scripts/resolve_customization.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Resolve a skill's default, team, and user TOML customization layers.""" + +import argparse +import json +import sys +from pathlib import Path + +# Installed scripts are consumer files, not a location for interpreter caches. +sys.dont_write_bytecode = True + +try: + from config_utils import ConfigError, load_customization +except ModuleNotFoundError as error: + if error.name != "tomllib": + raise + sys.stderr.write("error: Python 3.11+ is required (stdlib `tomllib` not found).\n") + raise SystemExit(3) from None + + +_MISSING = object() + + +def find_project_root(start: Path) -> Path | None: + """Nearest ancestor holding `_bmad/`, falling back to the nearest holding `.git`. + + `_bmad/` outranks `.git` at every depth: a submodule or nested repo carries + `.git` without being the BMad project, so treating the two as equal stops the + walk short of the root that owns `_bmad/custom/`. + """ + git_root: Path | None = None + current = start.resolve() + while True: + if (current / "_bmad").is_dir(): + return current + if git_root is None and (current / ".git").exists(): + git_root = current + if current.parent == current: + return git_root + current = current.parent + + +def script_project_root() -> Path | None: + """Project root implied by this script's own install path. + + Skills invoke `{project-root}/_bmad/scripts/resolve_customization.py`, so when + this file sits at that path its grandparent is a project root the caller already + resolved. + """ + parents = Path(__file__).resolve().parents + if len(parents) >= 3 and parents[0].name == "scripts" and parents[1].name == "_bmad": + return parents[2] + return None + + +def candidate_project_roots(skill_dir: Path) -> list[Path]: + """Plausible project roots, most trustworthy first. + + The working directory leads because the project is where the user is working, + not where the skill happens to be installed — a home-installed skill walks up to + `~`, and any `~/_bmad` there would otherwise mask the real project's overrides. + """ + ordered: list[Path] = [] + for root in ( + find_project_root(Path.cwd()), + script_project_root(), + find_project_root(skill_dir), + ): + if root is not None and root not in ordered: + ordered.append(root) + return ordered + + +def has_override(root: Path, skill_name: str) -> bool: + custom_dir = root / "_bmad" / "custom" + return any((custom_dir / name).is_file() for name in (f"{skill_name}.toml", f"{skill_name}.user.toml")) + + +def warn_on_masked_override(chosen: Path, rejected: list[Path], skill_name: str) -> None: + """Break the silence when a real override exists under a root we did not pick.""" + if has_override(chosen, skill_name): + return + for root in rejected: + if has_override(root, skill_name): + sys.stderr.write( + f"note: resolved project root {chosen} has no customization for " + f"`{skill_name}`, but {root} does. Using {chosen}; pass " + f"--project-root to select the other explicitly.\n" + ) + return + + +def extract_key(data, dotted_key: str): + current = data + for part in dotted_key.split("."): + if isinstance(current, dict) and part in current: + current = current[part] + else: + return _MISSING + return current + + +def write_json_stdout(output) -> None: + reconfigure = getattr(sys.stdout, "reconfigure", None) + if reconfigure is not None: + reconfigure(encoding="utf-8") + sys.stdout.write(json.dumps(output, indent=2, ensure_ascii=False) + "\n") + + +def main() -> int: + parser = argparse.ArgumentParser(description="Resolve skill customization using three-layer TOML merge.") + parser.add_argument("--skill", "-s", required=True, help="Absolute path to the skill directory") + parser.add_argument( + "--project-root", + "-p", + help="Explicit project root containing _bmad/ (recommended)", + ) + parser.add_argument( + "--key", + "-k", + action="append", + default=[], + help="Dotted field path to resolve (repeatable). Omit for full dump.", + ) + args = parser.parse_args() + + skill_dir = Path(args.skill).resolve() + if args.project_root: + project_root = Path(args.project_root).resolve() + else: + candidates = candidate_project_roots(skill_dir) + project_root = candidates[0] if candidates else None + if project_root is not None: + warn_on_masked_override(project_root, candidates[1:], skill_dir.name) + + try: + merged = load_customization(project_root, skill_dir) + except ConfigError as error: + sys.stderr.write(f"error: {error}\n") + return 1 + + output = merged + if args.key: + output = {} + for key in args.key: + value = extract_key(merged, key) + if value is not _MISSING: + output[key] = value + write_json_stdout(output) + report_owed_setup(skill_dir, project_root) + return 0 + + +def report_owed_setup(skill_dir: Path, project_root: Path | None) -> None: + # The resolver is the first thing every skill runs, so it is where a skill + # learns that setup owes it something. It must never fail the resolve. + try: + import setup_check + except Exception: + return + setup_check.report(skill_dir, project_root) + + +if __name__ == "__main__": + if sys.platform == "win32": + # Piped output on Windows defaults to a legacy code page, not UTF-8. + sys.stdout.reconfigure(encoding="utf-8") + sys.stderr.reconfigure(encoding="utf-8") + raise SystemExit(main()) diff --git a/_bmad/scripts/roster.py b/_bmad/scripts/roster.py new file mode 100644 index 0000000..4675a2c --- /dev/null +++ b/_bmad/scripts/roster.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Report the people and groups the installed modules offer. + +A module's roster is `roster.toml` beside its `bmod.toml`, in the module +record's folder. It lists members and the groups they form. Nothing is +recorded under `_bmad`: the roster is whatever the installed modules offer +right now, so adding or removing a skill changes it with no setup step. + +A member with `skill` is an agent, present only while that skill is installed; +its name, title and icon follow the skill's customization. A member without +`skill` is a guest, available to groups and never part of the default room. +`[agents.]` tables in the central config still apply on top, so a user's +own agents and overrides keep working. + +Usage: + uv run roster.py --skill [--project-root P] [--root R ...] +""" + +from __future__ import annotations + +import argparse +import json +import sys +import tomllib +from pathlib import Path + +sys.dont_write_bytecode = True + +from config_utils import ConfigError, load_central_config, load_customization # noqa: E402 +from knowledge import ROSTER_NAME, Module, install_command, read_document, scan # noqa: E402 + +MEMBER_FIELDS = ("name", "icon", "title", "persona", "capabilities", "model") +AGENT_FIELDS = ("name", "icon", "title") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Report the members and groups the installed skills offer.") + parser.add_argument("--skill", type=Path, help="an installed skill; the skills beside it are scanned") + parser.add_argument("--root", type=Path, action="append", default=[], help="a further skills root to scan") + parser.add_argument("--project-root", type=Path, help="project root holding _bmad/, for customization") + args = parser.parse_args(argv) + roots = ([args.skill.resolve().parent] if args.skill else []) + args.root + if not roots: + parser.error("give --skill or --root") + report = collect(roots, args.project_root.resolve() if args.project_root else None) + reconfigure = getattr(sys.stdout, "reconfigure", None) + if reconfigure is not None: + reconfigure(encoding="utf-8") + sys.stdout.write(json.dumps(report, indent=2, ensure_ascii=False) + "\n") + return 0 + + +def collect(roots: list[Path], project_root: Path | None = None) -> dict[str, object]: + found = scan(roots) + problems = found.problems + skills = found.folders + files: dict[tuple[str, str], dict[str, object]] = {} + + for module in found.modules: + if (module.folder / ROSTER_NAME).exists(): + record_file(files, problems, module) + + members: dict[str, dict[str, object]] = {} + groups: dict[str, dict[str, object]] = {} + for (code, path), file in sorted(files.items()): + source = file["module"].table.get("update_source") + for member in listed(file["data"], "members", problems, code, path): + add_member(members, problems, member, code, path, source, skills, project_root) + for group in listed(file["data"], "groups", problems, code, path): + add_group(groups, problems, group, code, path) + + agents = {code: member for code, member in members.items() if member.get("installed")} + apply_central_agents(agents, members, problems, project_root) + + return { + "agents": agents, + "members": members, + "groups": list(groups.values()), + "rosters": [ + {"module": code, "path": path, "skills": [name for name in file["module"].skills if name in skills]} + for (code, path), file in sorted(files.items()) + ], + "problems": problems, + } + + +def listed(data: dict[str, object], key: str, problems: list[dict[str, object]], module: str, path: str) -> list: + found = data.get(key, []) + if isinstance(found, list): + return found + problems.append({"kind": "roster", "problem": f"{module} {path}: '{key}' is not a list"}) + return [] + + +def record_file( + files: dict[tuple[str, str], dict[str, object]], problems: list[dict[str, object]], module: Module +) -> None: + folder = module.folder + path = folder / ROSTER_NAME + try: + data = tomllib.loads(read_document(path, folder).decode("utf-8")) + except (OSError, ValueError, UnicodeError, tomllib.TOMLDecodeError) as error: + problems.append({"kind": "roster", "skill": folder.name, "problem": f"{path}: {error}"}) + return + files.setdefault((module.code, ROSTER_NAME), {"module": module, "data": data}) + + +def add_member( + members: dict[str, dict[str, object]], + problems: list[dict[str, object]], + member: object, + module: str, + path: str, + source: object, + skills: dict[str, Path], + project_root: Path | None, +) -> None: + code = member.get("code") if isinstance(member, dict) else None + if not isinstance(code, str) or not code: + problems.append({"kind": "member", "problem": f"{module} {path}: a member has no code"}) + return + if code in members: + problems.append( + { + "kind": "member", + "problem": f"{module} {path}: member {code!r} is already defined by {members[code]['module']}", + } + ) + return + entry: dict[str, object] = {"code": code, "module": module, "source": "roster"} + for field in MEMBER_FIELDS: + if isinstance(member.get(field), str): + entry[field] = member[field] + skill = member.get("skill") + if isinstance(skill, str) and skill: + entry["skill"] = skill + entry["installed"] = skill in skills + if entry["installed"]: + entry.update(agent_identity(skills[skill], project_root)) + else: + command = install_command(source, skill) + if command: + entry["install"] = command + entry.setdefault("name", code) + members[code] = entry + + +def agent_identity(skill_dir: Path, project_root: Path | None) -> dict[str, str]: + """The name, title and icon the agent actually answers to, after any customization.""" + try: + agent = load_customization(project_root, skill_dir).get("agent", {}) + except (ConfigError, OSError): + return {} + if not isinstance(agent, dict): + return {} + return {field: agent[field] for field in AGENT_FIELDS if isinstance(agent.get(field), str) and agent[field]} + + +def add_group( + groups: dict[str, dict[str, object]], problems: list[dict[str, object]], group: object, module: str, path: str +) -> None: + group_id = group.get("id") if isinstance(group, dict) else None + if not isinstance(group_id, str) or not group_id: + problems.append({"kind": "group", "problem": f"{module} {path}: a group has no id"}) + return + if group_id in groups: + problems.append( + { + "kind": "group", + "problem": f"{module} {path}: group {group_id!r} is already defined by {groups[group_id]['module']}", + } + ) + return + groups[group_id] = {**group, "module": module} + + +def apply_central_agents( + agents: dict[str, dict[str, object]], + members: dict[str, dict[str, object]], + problems: list[dict[str, object]], + project_root: Path | None, +) -> None: + """Lay the central config's [agents.] tables over the scan. + + This is how a user adds an agent of their own or describes one further, and + how an install made before rosters existed keeps the agents it recorded. + An entry for a roster agent whose skill is gone is skipped: the old + installer recorded it and nothing removed it when the skill went. For a + roster agent the roster and the skill's customization decide name, title, + icon and module, so a recorded default never undoes a customized name. + """ + if project_root is None or not (project_root / "_bmad").is_dir(): + return + try: + configured = load_central_config(project_root).get("agents", {}) + except (ConfigError, OSError) as error: + problems.append({"kind": "config", "problem": str(error)}) + return + if not isinstance(configured, dict): + return + for code, info in configured.items(): + if not isinstance(info, dict) or members.get(code, {}).get("installed") is False: + continue + entry = agents.setdefault(code, {"code": code, "source": "config"}) + settled = ( + {"module", *(field for field in AGENT_FIELDS if field in entry)} if entry["source"] == "roster" else set() + ) + for field, value in info.items(): + if field in settled: + continue + # Older installs recorded the persona paragraph as `description`. + target = "persona" if field == "description" and "persona" not in info else field + entry[target] = value + entry.setdefault("name", code) + + +if __name__ == "__main__": + if sys.platform == "win32": + # Piped output on Windows defaults to a legacy code page, not UTF-8. + sys.stdout.reconfigure(encoding="utf-8") + sys.stderr.reconfigure(encoding="utf-8") + sys.exit(main()) diff --git a/_bmad/scripts/setup.py b/_bmad/scripts/setup.py new file mode 100644 index 0000000..1e0a83b --- /dev/null +++ b/_bmad/scripts/setup.py @@ -0,0 +1,2490 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Set up or report on the project BMad runtime from the installed bmod.toml files.""" + +from __future__ import annotations + +import argparse +import copy +import datetime +import json +import os +import re +import shutil +import sys +import tomllib +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path, PurePosixPath +from typing import NamedTuple + +sys.dont_write_bytecode = True + +MANIFEST_NAME = "bmod.toml" +RETIRED_NAME = "retired.toml" +QUESTION_KEYS = frozenset({"key", "prompt", "default"}) +OPTIONAL_QUESTION_KEYS = frozenset({"scope"}) +QUESTION_SCOPES = ("team", "user") +UPDATE_SOURCE_PREFIXES = ("github:", "https://", "file:", "plugin:") +MODULE_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]*\Z") +SKILL_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]*\Z") +RESERVED_MODULE_DIRS = frozenset({"_config", "custom", "modules", "scripts"}) +TEAM_CONFIG = "_bmad/config.toml" +USER_CONFIG = "_bmad/custom/config.user.toml" +CUSTOM_GITIGNORE = "*.user.toml\n" +GITIGNORE_COVERS_USER_CONFIG = frozenset({"*.user.toml", "config.user.toml", "*.toml", "*"}) + +SEMVER = re.compile( + r"(?P0|[1-9][0-9]*)\." + r"(?P0|[1-9][0-9]*)\." + r"(?P0|[1-9][0-9]*)" + r"(?:-(?P" + r"(?:0|[1-9][0-9]*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)" + r"(?:\.(?:0|[1-9][0-9]*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*" + r"))?" + r"(?:\+(?P[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?\Z" +) +SOURCE_READ_LIMIT = 1024 * 1024 +_MISSING = object() + + +class Requirement(NamedTuple): + skill: str + version: str | None + source: str | None + + +class KnowledgeEntry(NamedTuple): + path: PurePosixPath + skills: tuple[str, ...] | None + + +class Rename(NamedTuple): + old: str + new: str + + +class ConfigQuestion(NamedTuple): + module: str + key: str + prompt: str + default: str + scope: str = "team" + + +class ParsedBmod(NamedTuple): + code: str + version: str + update_source: str + skills: tuple[str, ...] | None + knowledge: tuple[KnowledgeEntry, ...] + questions: tuple[ConfigQuestion, ...] + required_skills: tuple[Requirement, ...] + recommended_skills: tuple[Requirement, ...] + pre_install_message: str = "" + post_install_message: str = "" + + +class ParsedRetired(NamedTuple): + renamed: tuple[Rename, ...] = () + removed: tuple[str, ...] = () + + +class ParsedSkill(NamedTuple): + bmod: str | None + source: str | None + scripts: tuple[PurePosixPath, ...] + required_skills: tuple[Requirement, ...] + recommended_skills: tuple[Requirement, ...] + + +class ParsedFile(NamedTuple): + bmod: ParsedBmod | None + skill: ParsedSkill | None + + +class InstalledFile(NamedTuple): + folder: str + source: Path + file: Path + parsed: ParsedFile + + +class InstalledModule(NamedTuple): + module: str + folder: str + source: Path + file: Path + parsed: ParsedBmod + skills: tuple[str, ...] + absent_skills: tuple[str, ...] + members: tuple[InstalledFile, ...] + questions: tuple[ConfigQuestion, ...] + retired: ParsedRetired = ParsedRetired() + + +class Retired(NamedTuple): + name: str + module: str + renamed_to: str | None + update_source: str + record_folder: Path + + +class Retirement(NamedTuple): + in_use: tuple[dict[str, object], ...] + renames: tuple[tuple[str, str], ...] + unmoved: tuple[tuple[str, str], ...] + unused: tuple[tuple[str, str], ...] + install_offers: tuple[dict[str, object], ...] + + +class Installation(NamedTuple): + files: tuple[InstalledFile, ...] + modules: tuple[InstalledModule, ...] + missing_records: tuple[dict[str, object], ...] + problems: tuple[dict[str, object], ...] + roots: tuple[Path, ...] = () + folders: dict[str, Path] = {} + duplicates: tuple[dict[str, object], ...] = () + + +class PlainTree(NamedTuple): + directories: tuple[PurePosixPath, ...] + files: tuple[tuple[PurePosixPath, bytes], ...] + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Set up and repair {project-root}/_bmad, or report on the installed BMad modules." + ) + parser.add_argument("--project-root", type=Path, required=True) + parser.add_argument("--skill", type=Path, required=True) + parser.add_argument("--module", help="limit the run to one module, by code or by bmod-") + parser.add_argument( + "--root", + type=Path, + action="append", + default=[], + help="an active skills folder, repeated; the first holding a skill wins", + ) + parser.add_argument("--module-answers", type=Path) + parser.add_argument( + "--list-config-questions", + action="store_true", + help="print unanswered installed-module questions as JSON", + ) + parser.add_argument( + "--status", + action="store_true", + help="report on the installation without changing files", + ) + parser.add_argument( + "--remove-retired", + nargs="+", + metavar="SKILL", + help="delete these renamed or removed skills from the project's skills folders", + ) + parser.add_argument( + "--remove-copies", + nargs="+", + metavar="PATH", + help="delete these copies of duplicated skills, as duplicate_skills lists them", + ) + parser.add_argument( + "--source-record", + nargs=2, + metavar=("SOURCE", "FOLDER"), + help="print the version and pre-install message of the module record FOLDER at SOURCE", + ) + args = parser.parse_args(argv) + project_root = args.project_root.resolve() + skill_root = args.skill.resolve() + roots = tuple(root.resolve() for root in args.root) + if args.source_record is not None: + if ( + args.status + or args.list_config_questions + or args.module_answers is not None + or args.remove_retired is not None + or args.remove_copies is not None + ): + parser.error("--source-record cannot be combined with other modes") + # setup.md adds --module to every call when the user names a module; it does not apply here. + print_json(source_record_report(project_root, *args.source_record)) + return 0 + if args.remove_retired is not None or args.remove_copies is not None: + if ( + args.status + or args.list_config_questions + or args.module_answers is not None + or (args.remove_retired is not None and args.remove_copies is not None) + ): + parser.error("--remove-retired and --remove-copies cannot be combined with other modes") + if args.remove_copies is not None: + print_json(remove_copies(project_root, skill_root, args.remove_copies, roots=roots)) + else: + print_json(remove_retired(project_root, skill_root, args.remove_retired, module=args.module, roots=roots)) + return 0 + if args.status: + if args.list_config_questions or args.module_answers is not None: + parser.error("--status cannot be combined with questions or answers") + print_json(status_report(project_root, skill_root, module=args.module, roots=roots)) + return 0 + if args.list_config_questions: + if args.module_answers is not None: + parser.error("--list-config-questions cannot be combined with answer files") + listing = list_config_questions(project_root, skill_root, module=args.module, roots=roots) + print_json(listing) + return 0 + report = setup( + project_root, + skill_root, + module=args.module, + roots=roots, + module_answers=(load_module_answers(args.module_answers) if args.module_answers is not None else None), + module_answers_source=args.module_answers, + ) + print_json(report) + return 0 + + +def print_json(value: object) -> None: + print(json.dumps(value, ensure_ascii=False, default=str)) + + +def setup( + project_root: Path, + skill_root: Path, + *, + module: str | None = None, + module_answers: dict[tuple[str, str], str] | None = None, + module_answers_source: Path | None = None, + roots: tuple[Path, ...] = (), +) -> dict[str, object]: + """Create what is missing, repair what is stale, add new answers, and report what was done.""" + bmad = project_root / "_bmad" + reject_unusable_bmad(project_root) + scripts_src, config_src = payload(skill_root) + installation = discover_installation(skill_root, roots) + selected, unknown = select_module(installation, module, mode="setup") + if unknown is not None: + return unknown + scoped = installation.modules if selected is None else (selected,) + + existing_text, merged, base_text = team_config_plan(project_root, config_src) + user_existing_text, user_existing = existing_user_config(project_root) + user_merged = copy.deepcopy(user_existing) + + pending = find_pending_questions(scoped, merged, user_existing, project_root) + answers = validate_module_answers(module_answers, pending, source=module_answers_source) + team_added: list[tuple[tuple[str, ...], str]] = [] + user_added: list[tuple[tuple[str, ...], str]] = [] + for question in pending: + path = ("modules", question.module, *question.key.split(".")) + value = answers[(question.module, question.key)] + if question.scope == "user": + set_missing_value(user_merged, path, value, user_config_path(project_root)) + user_added.append((path, value)) + else: + set_missing_value(merged, path, value, project_root / "_bmad" / "config.toml") + team_added.append((path, value)) + # base_text is the file's own text only when the template adds nothing to it. + team_text = base_text if base_text == existing_text else None + config_text = text_with_answers(team_text, team_added, merged) if team_added else base_text + user_text = text_with_answers(user_existing_text, user_added, user_merged) if user_added else None + if user_added: + reject_unwritable_user_config(project_root) + + retirement = retirement_report(project_root, skill_root, installation, retired_skills(scoped, installation.modules)) + + module_trees: dict[str, PlainTree] = {} + for installed in scoped: + reject_unusable_module_root(bmad / installed.module) + module_trees[installed.module] = declared_scripts_tree(read_module_scripts(installed)) + + created = not bmad.exists() + done = {"missing": "created", "stale": "repaired", "current": "current"} + shared_state = done[tree_state(bmad / "scripts", read_plain_tree(scripts_src))] + module_states = {code: done[tree_state(bmad / code / "scripts", tree)] for code, tree in module_trees.items()} + config_state = team_config_state(project_root, existing_text, config_text) + gitignore_state = custom_gitignore_state(project_root) + custom = bmad / "custom" + changed = ( + created + or shared_state != "current" + or config_state != "current" + or bool(user_added) + or gitignore_state == "missing" + or not (custom.exists() or custom.is_symlink()) + or any(state != "current" for state in module_states.values()) + or bool(retirement.renames) + ) + if changed: + materialize_bmad( + project_root, + scripts_src, + config_text, + module_trees, + user_config_text=user_text, + custom_renames=retirement.renames, + ) + output = project_root / output_folder(config_text) + if not output.exists() and not output.is_symlink(): + changed = True + ensure_dir(output) + + unmet = unmet_requirements(installation, skill_root, module=None if selected is None else selected.module) + recommended = unmet_recommendations(installation, skill_root, module=None if selected is None else selected.module) + remaining = find_pending_questions(installation.modules, merged, user_merged, project_root) + next_command = next_step(installation.missing_records, unmet, setup_owed=bool(remaining)) + problems = [*installation.problems, *custom_gitignore_problems(gitignore_state)] + return { + "mode": "setup", + "status": "created" if created else "repaired" if changed else "current", + "changed": changed, + "module": None if selected is None else selected.module, + "bmad": bmad_report(installation, skill_root), + "shared_scripts": shared_state, + "config": config_state, + "custom_gitignore": "created" if gitignore_state == "missing" else gitignore_state, + "modules": [ + { + **module_summary(installed, project_root), + "scripts": module_states[installed.module], + **message_json("post_install_message", installed.parsed.post_install_message), + } + for installed in scoped + ], + "answers_added": [ + { + "module": question.module, + "key": question.key, + "scope": question.scope, + "file": scope_file(question.scope), + } + for question in pending + ], + "answers": current_answers(scoped, merged, user_merged), + "pending_questions": [question_json(question) for question in remaining], + "unmet_requirements": unmet, + "unmet_recommendations": recommended, + "missing_module_records": list(installation.missing_records), + "duplicate_skills": duplicates_json(installation.duplicates, project_root), + "problems": problems, + **retirement_json(retirement), + "current": (next_command is None and not unmet and not problems and not installation.missing_records), + "next": next_command, + } + + +def payload(skill_root: Path) -> tuple[Path, Path]: + scripts_src = skill_root / "scripts" + assets_src = skill_root / "assets" + config_src = assets_src / "config.template.toml" + resolve_config = scripts_src / "resolve_config.py" + for directory in (scripts_src, assets_src): + if not directory.is_dir(): + raise Exception(f"missing directory: {directory}") + for file in (resolve_config, config_src): + if not file.is_file(): + raise Exception(f"missing file: {file}") + return (scripts_src, config_src) + + +def team_config_plan(project_root: Path, config_src: Path) -> tuple[str | None, dict, str]: + """The team file's text, its values with the template's new keys filled in, and the text setup would write.""" + template_text = fill_team_config(config_src.read_text(encoding="utf-8"), project_root) + template = parse_toml(template_text, config_src) + existing_text, existing = existing_team_config(project_root) + merged = fill_keep(template, existing) + if not isinstance(merged, dict): + raise Exception(f"invalid team config: {project_root / '_bmad' / 'config.toml'}") + base_text = existing_text if existing_text is not None and merged == existing else render_toml(merged) + return existing_text, merged, base_text + + +def team_config_state(project_root: Path, existing_text: str | None, config_text: str) -> str: + if existing_text is None: + return "created" + path = project_root / "_bmad" / "config.toml" + if path.is_symlink() or fill_toml(existing_text, config_text) != existing_text: + return "updated" + return "current" + + +def reject_unusable_module_root(module_root: Path) -> None: + if module_root.is_symlink() or (module_root.exists() and not module_root.is_dir()): + raise Exception(f"module runtime is not a plain directory: {module_root}") + + +def pending_config_questions( + project_root: Path, + skill_root: Path, + modules: tuple[InstalledModule, ...], +) -> tuple[ConfigQuestion, ...]: + _scripts, config_src = payload(skill_root) + _existing_text, merged, _base_text = team_config_plan(project_root, config_src) + _user_text, user_config = existing_user_config(project_root) + return find_pending_questions(modules, merged, user_config, project_root) + + +def list_config_questions( + project_root: Path, skill_root: Path, *, module: str | None = None, roots: tuple[Path, ...] = () +) -> object: + """The pending questions as a JSON list, or the unknown-module report.""" + reject_unusable_bmad(project_root) + installation = discover_installation(skill_root, roots) + selected, unknown = select_module(installation, module, mode="list-config-questions") + if unknown is not None: + return unknown + scoped = installation.modules if selected is None else (selected,) + return [question_json(question) for question in pending_config_questions(project_root, skill_root, scoped)] + + +def question_json(question: ConfigQuestion) -> dict[str, str]: + return { + "module": question.module, + "key": question.key, + "prompt": question.prompt, + "default": question.default, + "scope": question.scope, + } + + +def scope_file(scope: str) -> str: + return USER_CONFIG if scope == "user" else TEAM_CONFIG + + +def current_answers( + modules: tuple[InstalledModule, ...], + team: dict, + user: dict, +) -> dict[str, list[dict[str, object]]]: + answers: dict[str, list[dict[str, object]]] = {} + for installed in modules: + entries: list[dict[str, object]] = [] + for question in installed.questions: + config = user if question.scope == "user" else team + value = lookup(config, ("modules", question.module, *question.key.split("."))) + if value is _MISSING: + continue + entries.append( + { + "key": question.key, + "scope": question.scope, + "file": scope_file(question.scope), + "value": value, + } + ) + if entries: + answers[installed.module] = entries + return answers + + +def lookup(data: object, keys: tuple[str, ...]) -> object: + current = data + for key in keys: + if not isinstance(current, dict) or key not in current: + return _MISSING + current = current[key] + return current + + +def reject_unusable_bmad(project_root: Path) -> None: + bmad = project_root / "_bmad" + if bmad.is_symlink(): + target = bmad.resolve() + raise Exception( + f"{bmad} is a symlink to {target}; setup replaces " + f"_bmad in place, so run it with --project-root " + f"{target.parent} to fix the real installation" + ) + if bmad.exists() and not bmad.is_dir(): + raise Exception(f"existing BMad runtime is not a directory: {bmad}") + + +def user_config_path(project_root: Path) -> Path: + return project_root / "_bmad" / "custom" / "config.user.toml" + + +def existing_user_config(project_root: Path) -> tuple[str | None, dict]: + path = user_config_path(project_root) + if not path.exists() and not path.is_symlink(): + return None, {} + if not path.is_file(): + raise Exception(f"user config is not a file: {path}") + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeError) as error: + raise Exception(f"cannot read user config {path}: {error}") from error + return text, parse_toml(text, path) + + +def reject_unwritable_user_config(project_root: Path) -> None: + """Setup writes a user answer only into a plain file in a plain folder.""" + path = user_config_path(project_root) + for candidate in (path.parent, path): + if candidate.is_symlink(): + raise Exception(f"cannot add a user answer: {candidate} is a symlink") + if path.parent.exists() and not path.parent.is_dir(): + raise Exception(f"cannot add a user answer: {path.parent} is not a directory") + + +def custom_gitignore_state(project_root: Path) -> str: + custom = project_root / "_bmad" / "custom" + if custom.is_symlink() or (custom.exists() and not custom.is_dir()): + return "skipped" + gitignore = custom / ".gitignore" + if not gitignore.exists() and not gitignore.is_symlink(): + return "missing" + try: + lines = {line.strip() for line in gitignore.read_text(encoding="utf-8").splitlines()} + except (OSError, UnicodeError): + return "unprotected" + return "current" if lines & GITIGNORE_COVERS_USER_CONFIG else "unprotected" + + +def custom_gitignore_problems(state: str) -> list[dict[str, object]]: + if state != "unprotected": + return [] + return [ + { + "kind": "custom-gitignore", + "message": ( + f"_bmad/custom/.gitignore has no line that ignores {USER_CONFIG}, so user answers may be " + "committed; add the line *.user.toml" + ), + } + ] + + +def retired_skills( + scoped: tuple[InstalledModule, ...], + installed: tuple[InstalledModule, ...], +) -> tuple[Retired, ...]: + """Old names the modules renamed or removed. A name an installed module still lists is not retired.""" + current = {name for module in installed for name in (*module.skills, *module.absent_skills)} + retired: list[Retired] = [] + for module in scoped: + entries = [(rename.old, rename.new) for rename in module.retired.renamed] + entries += [(name, None) for name in module.retired.removed] + retired.extend( + Retired(name, module.module, new, module.parsed.update_source, module.source) + for name, new in entries + if name not in current + ) + return tuple(retired) + + +def skill_folders(project_root: Path, installation: Installation) -> tuple[Path, ...]: + """Where retired skills are looked for: the active roots, which may be global, and every `./skills` + in the project, where a classic installer may have left copies for tools this host does not load. + """ + candidates: list[Path] = list(installation.roots) + try: + entries = sorted(project_root.iterdir(), key=lambda path: path.name) + except OSError: + entries = [] + inside = project_root.resolve() + active = {root.resolve() for root in installation.roots} + # A tool folder linked outside the project is not a project copy; deleting through it would hit the target. + candidates += [ + folder + for entry in entries + if entry.name.startswith(".") and (folder := entry / "skills").is_dir() + if folder.resolve().is_relative_to(inside) or folder.resolve() in active + ] + folders: list[Path] = [] + seen: set[Path] = set() + for folder in candidates: + resolved = folder.resolve() + if resolved not in seen: + seen.add(resolved) + folders.append(folder) + return tuple(folders) + + +def is_global(path: Path, project_root: Path) -> bool: + """A skill or skills folder outside the project belongs to the skills CLI's global scope.""" + return not path.is_relative_to(project_root) + + +def shown_path(path: Path, project_root: Path) -> str: + """Relative inside the project, `~/`-based under the home folder, absolute otherwise.""" + if path.is_relative_to(project_root): + return path.relative_to(project_root).as_posix() + home = Path.home() + if path.is_relative_to(home): + return "~/" + path.relative_to(home).as_posix() + return path.as_posix() + + +def lock_path(project_root: Path, path: Path) -> Path: + """The skills CLI lock of the scope a skill folder is in: the project's, or the global one.""" + if not is_global(path, project_root): + return project_root / "skills-lock.json" + state = os.environ.get("XDG_STATE_HOME") + if state: + return Path(state) / "skills" / ".skill-lock.json" + return Path.home() / ".agents" / ".skill-lock.json" + + +def present(path: Path) -> bool: + return path.exists() or path.is_symlink() + + +def retirement_report( + project_root: Path, skill_root: Path, installation: Installation, retired: tuple[Retired, ...] +) -> Retirement: + """Where the retired skills are still installed or customized, and what setup does about it.""" + folders = skill_folders(project_root, installation) + custom = project_root / "_bmad" / "custom" + plain_custom = custom.is_dir() and not custom.is_symlink() + in_use: list[dict[str, object]] = [] + renames: list[tuple[str, str]] = [] + unmoved: list[tuple[str, str]] = [] + unused: list[tuple[str, str]] = [] + used: set[str] = set() + for skill in retired: + paths = [folder / skill.name for folder in folders if present(folder / skill.name)] + if paths: + used.add(skill.name) + in_use.append( + { + "skill": skill.name, + "module": skill.module, + "renamed_to": skill.renamed_to, + "paths": [shown_path(path, project_root) for path in paths], + "global": any(is_global(path, project_root) for path in paths), + } + ) + if not plain_custom: + continue + for suffix in (".toml", ".user.toml"): + old = f"{skill.name}{suffix}" + if not present(custom / old): + continue + used.add(skill.name) + if skill.renamed_to is None: + unused.append((skill.name, old)) + continue + new = f"{skill.renamed_to}{suffix}" + taken = present(custom / new) or any(target == new for _old, target in renames) + (unmoved if taken else renames).append((old, new)) + offers: dict[str, dict[str, object]] = {} + for skill in retired: + new = skill.renamed_to + if new is None or skill.name not in used or new in offers or new in installation.folders: + continue + offers[new] = { + "skill": new, + "replaces": skill.name, + "module": skill.module, + "install": install_command( + skill.update_source, new, global_install=is_global(skill.record_folder, project_root) + ), + } + return Retirement(tuple(in_use), tuple(renames), tuple(unmoved), tuple(unused), tuple(offers.values())) + + +def retirement_json(retirement: Retirement) -> dict[str, object]: + def custom(name: str) -> str: + return f"_bmad/custom/{name}" + + return { + "retired_skills": list(retirement.in_use), + "custom_renames": [{"from": custom(old), "to": custom(new)} for old, new in retirement.renames], + "custom_not_renamed": [{"from": custom(old), "to": custom(new)} for old, new in retirement.unmoved], + "custom_unused": [{"skill": skill, "file": custom(name)} for skill, name in retirement.unused], + "install_offers": list(retirement.install_offers), + } + + +def remove_retired( + project_root: Path, + skill_root: Path, + names: list[str], + *, + module: str | None = None, + roots: tuple[Path, ...] = (), +) -> dict[str, object]: + """Delete retired skills from the skills folders and drop them from the skills CLI locks.""" + installation = discover_installation(skill_root, roots) + selected, unknown = select_module(installation, module, mode="remove-retired") + if unknown is not None: + return unknown + scoped = installation.modules if selected is None else (selected,) + retired = {skill.name for skill in retired_skills(scoped, installation.modules)} + names = list(dict.fromkeys(names)) + for name in names: + if name not in retired: + raise Exception(f"{name!r} is not a renamed or removed skill of an installed module") + folders = skill_folders(project_root, installation) + targets = [(name, folder / name) for folder in folders for name in names if present(folder / name)] + return {"mode": "remove-retired", **remove_skill_paths(project_root, folders, targets)} + + +def remove_copies( + project_root: Path, + skill_root: Path, + paths: list[str], + *, + roots: tuple[Path, ...] = (), +) -> dict[str, object]: + """Delete copies of duplicated skills, keeping at least one copy of each.""" + installation = discover_installation(skill_root, roots) + copies: dict[str, tuple[str, Path]] = {} + listed: list[tuple[str, list[str]]] = [] + for duplicate in installation.duplicates: + skill = str(duplicate["skill"]) + shown = [] + for folder in duplicate["folders"]: # type: ignore[attr-defined] + copies[shown_path(folder, project_root)] = (skill, folder) + shown.append(shown_path(folder, project_root)) + listed.append((skill, shown)) + paths = list(dict.fromkeys(paths)) + for path in paths: + if path not in copies: + raise Exception(f"{path!r} is not a copy listed in duplicate_skills") + for skill, shown in listed: + if all(path in paths for path in shown): + raise Exception(f"removing every copy of {skill!r} is not a duplicate cleanup") + targets = [copies[path] for path in paths] + return { + "mode": "remove-copies", + **remove_skill_paths(project_root, skill_folders(project_root, installation), targets), + } + + +def remove_skill_paths( + project_root: Path, + folders: tuple[Path, ...], + targets: list[tuple[str, Path]], +) -> dict[str, object]: + """Delete skill folders, then drop each name from the lock of its scope once no copy is left there. + + Every affected lock is read before anything is deleted, so a bad lock stops the run first. + """ + locks: dict[Path, dict | None] = {} + for _name, path in targets: + lock_file = lock_path(project_root, path) + if lock_file not in locks: + locks[lock_file] = read_skills_lock(lock_file) + removed: list[str] = [] + for _name, path in targets: + if path.is_symlink() or path.is_file(): + path.unlink() + elif path.is_dir(): + shutil.rmtree(path) + else: + continue + removed.append(shown_path(path, project_root)) + changed: list[dict[str, object]] = [] + for lock_file, lock in locks.items(): + if lock is None: + continue + scope_folders = [folder for folder in folders if lock_path(project_root, folder) == lock_file] + dropped = [ + name + for name in dict.fromkeys(name for name, path in targets if lock_path(project_root, path) == lock_file) + if name in lock["skills"] and not any(present(folder / name) for folder in scope_folders) + ] + if not dropped: + continue + for name in dropped: + del lock["skills"][name] + ending = "\n" if lock_file.read_text(encoding="utf-8").endswith("\n") else "" + lock_file.write_text(json.dumps(lock, indent=2, ensure_ascii=False) + ending, encoding="utf-8") + changed.append({"file": shown_path(lock_file, project_root), "entries_removed": dropped}) + return {"removed": removed, "locks": changed} + + +def read_skills_lock(path: Path) -> dict | None: + """The skills CLI's lock, read before anything is deleted so a bad file stops the run.""" + if not path.is_file(): + return None + try: + lock = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as error: + raise Exception(f"cannot read {path}: {error}") from error + if not isinstance(lock, dict) or not isinstance(lock.get("skills"), dict): + raise Exception(f"{path} has no skills table") + return lock + + +def select_module( + installation: Installation, + name: str | None, + *, + mode: str, +) -> tuple[InstalledModule | None, dict[str, object] | None]: + """The module a name means, or the report that lists what is installed.""" + if name is None: + return None, None + for names in ( + lambda installed: installed.module, + lambda installed: f"bmod-{installed.module}", + lambda installed: installed.folder, + ): + for installed in installation.modules: + if name == names(installed): + return installed, None + wanted = {name, f"bmod-{name}"} + return None, { + "mode": mode, + "status": "unknown-module", + "changed": False, + "module": name, + "installed_modules": [installed.module for installed in installation.modules], + "missing_module_records": [record for record in installation.missing_records if record["bmod"] in wanted], + } + + +def module_summary(installed: InstalledModule, project_root: Path) -> dict[str, object]: + global_install = is_global(installed.source, project_root) + return { + "module": installed.module, + "folder": installed.folder, + "version": installed.parsed.version, + "update_source": installed.parsed.update_source, + "skills": list(installed.skills), + "scope": "global" if global_install else "project", + "absent_skills": list(installed.absent_skills), + "absent_install": install_command( + installed.parsed.update_source, *installed.absent_skills, global_install=global_install + ), + } + + +def bmad_report(installation: Installation, skill_root: Path) -> dict[str, object]: + """The bmad skill in use. Its version is its module's, and unknown when that record is absent.""" + report: dict[str, object] = {"skill": skill_root.name, "version": None, "module": None} + resolved = skill_root.resolve() + by_folder = {installed.folder: installed for installed in installation.files} + for installed in installation.files: + if installed.source.resolve() != resolved: + continue + record = installed.parsed.bmod + if record is None and installed.parsed.skill is not None and installed.parsed.skill.bmod is not None: + other = by_folder.get(installed.parsed.skill.bmod) + record = other.parsed.bmod if other is not None else None + if record is not None: + report.update({"version": record.version, "module": record.code}) + break + return report + + +def unmet_requirements( + installation: Installation, + skill_root: Path, + *, + module: str | None = None, +) -> list[dict[str, object]]: + """Required skills the current install does not satisfy.""" + return unmet_entries(installation, skill_root, "required_skills", module) + + +def unmet_recommendations( + installation: Installation, + skill_root: Path, + *, + module: str | None = None, +) -> list[dict[str, object]]: + """Recommended skills that are absent or too old. Worth offering, never a fault.""" + return unmet_entries(installation, skill_root, "recommended_skills", module) + + +def unmet_entries( + installation: Installation, + skill_root: Path, + field: str, + module: str | None, +) -> list[dict[str, object]]: + """A module's list is checked once for the module, a skill's own list once for the skill.""" + by_folder = {installed.folder: installed for installed in installation.files} + declared: list[tuple[str, str | None, str, Requirement]] = [] + for installed in installation.modules: + for requirement in getattr(installed.parsed, field): + declared.append((installed.folder, installed.module, installed.parsed.update_source, requirement)) + for installed in installation.files: + skill = installed.parsed.skill + if skill is None: + continue + record = installed.parsed.bmod + if record is None and skill.bmod is not None and skill.bmod in by_folder: + record = by_folder[skill.bmod].parsed.bmod + default_source = skill.source if skill.source is not None else record.update_source if record else None + for requirement in getattr(skill, field): + declared.append((installed.folder, record.code if record else None, default_source or "", requirement)) + + unmet: list[dict[str, object]] = [] + for folder, code, default_source, requirement in declared: + if requirement.skill == folder or (module is not None and code != module): + continue + state, present = requirement_check(installation.folders, requirement) + if state is None: + continue + source = requirement.source if requirement.source is not None else default_source + unmet.append( + { + "skill": folder, + "module": code, + "requires": requirement.skill, + "minimum": requirement.version, + "installed": present, + "state": state, + "source": source, + "channel": requirement_channel(source), + "install": fix_command(state, source, requirement.skill), + } + ) + return unmet + + +def skill_path(skills: Path | dict[str, Path], name: str) -> Path | None: + """An installed skill's folder: from one skills folder, or from the folders found across the roots.""" + if isinstance(skills, dict): + return skills.get(name) + path = skills / name + return path if path.is_dir() else None + + +def requirement_check(skills: Path | dict[str, Path], requirement: Requirement) -> tuple[str | None, str | None]: + """Why a requirement is unmet, with the version found. The state is None when it is met.""" + if skill_path(skills, requirement.skill) is None: + return "missing", None + if requirement.version is None: + return None, None + installed = skill_module_version(skills, requirement.skill) + if installed is None: + if names_a_module_record(skills, requirement.skill): + # Its record is absent, which is reported as a missing module record. + return None, None + # A copy from before module records has no version to read, and that + # copy is what a minimum version exists to catch. + return "unknown-version", None + return requirement_state(installed, requirement.version), installed + + +def names_a_module_record(skills: Path | dict[str, Path], skill: str) -> bool: + folder = skill_path(skills, skill) + try: + parsed = read_bmod_file(folder / MANIFEST_NAME) if folder is not None else None + except Exception: + return False + return parsed is not None and parsed.skill is not None and parsed.skill.bmod is not None + + +def skill_module_version(skills: Path | dict[str, Path], skill: str) -> str | None: + """The version of the module an installed skill belongs to. + + Skills carry no version. None means the skill has no bmod.toml or its + module record is absent, so there is nothing to compare. + """ + try: + folder = skill_path(skills, skill) + parsed = read_bmod_file(folder / MANIFEST_NAME) if folder is not None else None + if parsed is None: + return None + if parsed.bmod is not None: + return parsed.bmod.version + if parsed.skill is None or parsed.skill.bmod is None: + return None + record_folder = skill_path(skills, parsed.skill.bmod) + if record_folder is None: + return None + record = read_bmod_file(record_folder / MANIFEST_NAME) + except Exception: + return None + if record is None or record.bmod is None: + return None + return record.bmod.version + + +def read_bmod_file(path: Path) -> ParsedFile | None: + if not path.is_file(): + return None + try: + raw = path.read_bytes() + except OSError as error: + raise Exception(f"cannot read bmod file {path}: {error}") from error + return parse_bmod_file(path, raw) + + +def requirement_state(installed: str | None, minimum: str) -> str | None: + """Why an installed version fails a minimum, or None when it meets it. + + The development branch carries `X-next` until `X` is released, and that + build already holds everything `X` will. SemVer orders it below `X`, which + would report every skill on a development install as outdated. + """ + if installed is None: + return "missing" + comparison = compare_semver(installed, minimum) + if comparison is None: + return "unorderable" + if comparison >= 0: + return None + have = parse_orderable_semver(installed) + want = parse_orderable_semver(minimum) + if have is not None and want is not None and have[0] == want[0] and want[1] is None and have[1] == ("next",): + return None + return "outdated" + + +def requirement_channel(source: str) -> str: + """How a missing or stale requirement is installed, so help offers the right command.""" + if source.startswith("plugin:"): + return "plugin" + if source.startswith("file:"): + return "local" + return "skills-cli" + + +def install_command(source: str, *skills: str, global_install: bool = False) -> str | None: + """The `npx skills` command that installs these skills, or None when the source has no such command.""" + if not skills or not source.startswith("github:"): + return None + owner, repository, *_tree = source.removeprefix("github:").split("/") + return f"npx skills add {owner}/{repository} --skill {' '.join(skills)}" + (" -g" if global_install else "") + + +UPDATE_FIXES = ("outdated", "unknown-version") + + +def fix_command(state: str, source: str, skill: str) -> str | None: + """Adding a skill does not raise its module's version, so an outdated one is updated instead.""" + if state == "missing": + return install_command(source, skill) + if state in UPDATE_FIXES and requirement_channel(source) == "skills-cli": + return "npx skills update" + return None + + +def next_step( + missing_records: tuple[dict[str, object], ...], + unmet: list[dict[str, object]], + *, + update_available: bool = False, + setup_owed: bool = False, + module: str | None = None, +) -> str | None: + """The one command to run next: install what is absent, then update, then setup.""" + for record in missing_records: + if record["install"] is not None: + return str(record["install"]) + for entry in unmet: + if entry["state"] == "missing" and entry["install"] is not None: + return str(entry["install"]) + outdated = any(entry["state"] in UPDATE_FIXES and entry["channel"] == "skills-cli" for entry in unmet) + if outdated or update_available: + return "npx skills update" + if setup_owed: + return "bmad setup" if module is None else f"bmad setup {module}" + return None + + +def existing_team_config(project_root: Path) -> tuple[str | None, dict]: + path = project_root / "_bmad" / "config.toml" + if not path.exists() and not path.is_symlink(): + return None, {} + if not path.is_file(): + raise Exception(f"team config is not a file: {path}") + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeError) as error: + raise Exception(f"cannot read team config {path}: {error}") from error + return text, parse_toml(text, path) + + +def parse_toml(text: str, source: Path | str) -> dict: + try: + return tomllib.loads(text) + except tomllib.TOMLDecodeError as error: + raise Exception(f"cannot parse TOML {source}: {error}") from error + + +def discover_installation(skill_root: Path, roots: tuple[Path, ...] = ()) -> Installation: + """Every bmod.toml in the active roots, sorted into module records, their skills, and problems. + + The folder the bmad skill runs from is always a root. The first root holding a skill wins it, so a + project copy shadows a global one; the other copies are reported as duplicates. + """ + problems: list[dict[str, object]] = [] + roots = unique_folders((*roots, skill_root.parent)) + folders, duplicates = locate_skills(roots, skill_root.parent) + files = discover_installed_files(folders, problems) + by_folder = {installed.folder: installed for installed in files} + winners = select_module_records(files, problems) + + modules: list[InstalledModule] = [] + for code in sorted(winners): + record_file = winners[code] + record = record_file.parsed.bmod + assert record is not None + listed = member_names(record_file) + present = tuple(name for name in listed if name in folders) + members: list[InstalledFile] = [] + for name in present: + member = by_folder.get(name) + if member is None or member.parsed.skill is None: + continue + if member is record_file or (member.parsed.bmod is None and member.parsed.skill.bmod == record_file.folder): + members.append(member) + continue + detail = ( + f"names {member.parsed.skill.bmod!r} as its bmod" + if member.parsed.bmod is None + else "is a module record of its own" + ) + problems.append( + { + "kind": "membership", + "skill": name, + "bmod": record_file.folder, + "message": f"{record_file.file} lists the skill {name!r}, but {member.file} {detail}", + } + ) + try: + retired = read_retired_file(record_file.source) + except Exception as error: + problems.append({"kind": "retired-file", "folder": record_file.folder, "message": str(error)}) + retired = ParsedRetired() + modules.append( + InstalledModule( + code, + record_file.folder, + record_file.source, + record_file.file, + record, + present, + tuple(name for name in listed if name not in present), + tuple(members), + record.questions, + retired, + ) + ) + + missing_records: list[dict[str, object]] = [] + for installed in files: + skill = installed.parsed.skill + if skill is not None and installed.parsed.bmod is not None and installed.folder not in member_names(installed): + problems.append( + { + "kind": "membership", + "skill": installed.folder, + "bmod": installed.folder, + "message": f"{installed.file} holds [bmod] and [skill], but its skills list leaves out {installed.folder!r}", + } + ) + if skill is None or installed.parsed.bmod is not None or skill.bmod is None: + continue + record_file = by_folder.get(skill.bmod) + if record_file is None or record_file.parsed.bmod is None: + source = skill.source or "" + missing_records.append( + { + "skill": installed.folder, + "bmod": skill.bmod, + "source": source, + "channel": requirement_channel(source), + "install": install_command(source, skill.bmod), + } + ) + elif installed.folder not in member_names(record_file): + problems.append( + { + "kind": "membership", + "skill": installed.folder, + "bmod": skill.bmod, + "message": ( + f"{installed.file} names {skill.bmod!r} as its bmod, but " + f"{record_file.file} does not list the skill {installed.folder!r}" + ), + } + ) + return Installation(files, tuple(modules), tuple(missing_records), tuple(problems), roots, folders, duplicates) + + +def unique_folders(folders: tuple[Path, ...]) -> tuple[Path, ...]: + """The folders in order, each once, however it is reached.""" + unique: list[Path] = [] + seen: set[Path] = set() + for folder in folders: + resolved = folder.resolve() + if resolved not in seen: + seen.add(resolved) + unique.append(folder) + return tuple(unique) + + +def locate_skills(roots: tuple[Path, ...], own_folder: Path) -> tuple[dict[str, Path], tuple[dict[str, object], ...]]: + """Each skill's folder in the first root that holds it, and the BMad skills installed in more than one. + + The bmad skill's own folder must be readable; an unreadable extra root is skipped. + """ + copies: dict[str, list[Path]] = {} + for root in roots: + try: + entries = sorted(root.iterdir(), key=lambda path: path.name) + except OSError as error: + if root.resolve() == own_folder.resolve(): + raise Exception(f"cannot inspect installed skills {root}: {error}") from error + continue + for entry in entries: + if entry.is_dir(): + copies.setdefault(entry.name, []).append(entry) + folders = {name: paths[0] for name, paths in copies.items()} + duplicates: list[dict[str, object]] = [] + for name, paths in sorted(copies.items()): + distinct = unique_folders(tuple(paths)) + if len(distinct) > 1 and any((path / MANIFEST_NAME).is_file() for path in distinct): + duplicates.append({"skill": name, "folders": distinct}) + return folders, tuple(duplicates) + + +def copy_version(folder: Path) -> str | None: + """The module version of one installed copy, read through the record beside it.""" + try: + parsed = read_bmod_file(folder / MANIFEST_NAME) + if parsed is None: + return None + if parsed.bmod is not None: + return parsed.bmod.version + if parsed.skill is None or parsed.skill.bmod is None: + return None + record = read_bmod_file(folder.parent / parsed.skill.bmod / MANIFEST_NAME) + except Exception: + return None + return record.bmod.version if record is not None and record.bmod is not None else None + + +def duplicates_json(duplicates: tuple[dict[str, object], ...], project_root: Path) -> list[dict[str, object]]: + """Each duplicate with the copy in use first, and whether a copy not in use is newer.""" + report: list[dict[str, object]] = [] + for duplicate in duplicates: + folders: tuple[Path, ...] = duplicate["folders"] # type: ignore[assignment] + copies = [ + { + "path": shown_path(folder, project_root), + "global": is_global(folder, project_root), + "version": copy_version(folder), + } + for folder in folders + ] + used = copies[0]["version"] + newer_elsewhere = any( + (compare_semver(str(copy["version"]), str(used)) or 0) > 0 + for copy in copies[1:] + if copy["version"] is not None and used is not None + ) + report.append( + { + "skill": duplicate["skill"], + "used": copies[0]["path"], + "newer_copy_unused": newer_elsewhere, + "copies": copies, + } + ) + return report + + +def discover_installed_files(folders: dict[str, Path], problems: list[dict[str, object]]) -> tuple[InstalledFile, ...]: + """One unusable file must not stop the install: it becomes a problem and its folder is skipped.""" + files: list[InstalledFile] = [] + for name in sorted(folders): + sibling = folders[name] + path = sibling / MANIFEST_NAME + try: + parsed = read_bmod_file(path) + except Exception as error: + problems.append({"kind": "bmod-file", "folder": sibling.name, "message": str(error)}) + continue + if parsed is not None: + files.append(InstalledFile(sibling.name, sibling, path, parsed)) + return tuple(files) + + +def select_module_records( + files: tuple[InstalledFile, ...], + problems: list[dict[str, object]], +) -> dict[str, InstalledFile]: + """One record per module code. Files arrive sorted by folder name, so the first one wins.""" + casefolded: dict[str, InstalledFile] = {} + winners: dict[str, InstalledFile] = {} + for installed in files: + record = installed.parsed.bmod + if record is None: + continue + previous = casefolded.get(record.code.casefold()) + if previous is None: + casefolded[record.code.casefold()] = installed + winners[record.code] = installed + continue + assert previous.parsed.bmod is not None + if previous.parsed.bmod.code != record.code: + raise Exception( + "installed module codes differ only by case: " + f"{previous.parsed.bmod.code!r} from {previous.file} and " + f"{record.code!r} from {installed.file}" + ) + problems.append( + { + "kind": "duplicate-module", + "module": record.code, + "folder": installed.folder, + "kept": previous.folder, + "message": ( + f"module code {record.code!r} is declared by {previous.file} and by " + f"{installed.file}; the first is used" + ), + } + ) + return winners + + +def member_names(record_file: InstalledFile) -> tuple[str, ...]: + record = record_file.parsed.bmod + assert record is not None + if record.skills is not None: + return record.skills + return (record_file.folder,) if record_file.parsed.skill is not None else () + + +def read_module_scripts(installed: InstalledModule) -> tuple[tuple[PurePosixPath, bytes], ...]: + """The scripts a module's installed skills place in `_bmad//scripts/`.""" + placed: dict[PurePosixPath, tuple[bytes, Path]] = {} + scripts: list[tuple[PurePosixPath, bytes]] = [] + for member in installed.members: + assert member.parsed.skill is not None + for relative in member.parsed.skill.scripts: + content = read_declared_script(member.source, relative, member.file) + destination = PurePosixPath(*relative.parts[1:]) + previous = placed.get(destination) + if previous is None: + placed[destination] = (content, member.file) + scripts.append((relative, content)) + elif previous[0] != content: + raise Exception( + f"module {installed.module!r} has two different scripts for " + f"{destination.as_posix()!r}: {previous[1]} and {member.file}" + ) + return tuple(scripts) + + +def parse_bmod_file(path: Path, raw: bytes) -> ParsedFile: + """Read the fields BMad uses and ignore every other key and table. + + An author may add keys of their own, and a newer file may carry keys this + version predates. Neither may stop a skill from installing. + """ + try: + source = raw.decode("utf-8") + except UnicodeError as error: + raise Exception(f"invalid bmod file {path}: {error}") from error + data = parse_toml(source, path) + bmod_table = data.get("bmod") + skill_table = data.get("skill") + if bmod_table is None and skill_table is None: + raise Exception(f"bmod file {path} must hold a [bmod] table, a [skill] table, or both") + for name, table in (("bmod", bmod_table), ("skill", skill_table)): + if table is not None and not isinstance(table, dict): + raise Exception(f"bmod file {path} field {name!r} must be a table") + bmod = parse_bmod_table(bmod_table, path) if bmod_table is not None else None + skill = parse_skill_table(skill_table, path, standalone=bmod is None) if skill_table is not None else None + return ParsedFile(bmod, skill) + + +def parse_bmod_table(table: dict, path: Path) -> ParsedBmod: + code = required_string(table, "bmod", "code", path) + if MODULE_NAME.fullmatch(code) is None or code.casefold() in RESERVED_MODULE_DIRS: + raise Exception(f"bmod file {path} field 'bmod.code' has unsafe value {code!r}") + version = required_string(table, "bmod", "version", path) + update_source = required_string(table, "bmod", "update_source", path) + validate_source(update_source, "bmod.update_source", path) + skills = table.get("skills") + return ParsedBmod( + code, + version, + update_source, + parse_skill_names(skills, "bmod.skills", path) if skills is not None else None, + parse_knowledge(table.get("knowledge"), path), + parse_questions(table.get("config_questions"), code, path), + parse_requirements(table.get("required_skills"), "bmod.required_skills", path), + parse_requirements(table.get("recommended_skills"), "bmod.recommended_skills", path), + optional_string(table, "bmod", "pre_install_message", path), + optional_string(table, "bmod", "post_install_message", path), + ) + + +def read_retired_file(folder: Path) -> ParsedRetired: + """The skills a module renamed or removed, from the `retired.toml` beside its record.""" + path = folder / RETIRED_NAME + if not path.is_file(): + return ParsedRetired() + try: + data = parse_toml(path.read_bytes().decode("utf-8"), path) + except (OSError, UnicodeError) as error: + raise Exception(f"cannot read {path}: {error}") from error + renamed = parse_renamed(data.get("renamed"), path) + removed = parse_skill_names(data.get("removed", []), "removed", path) + retired = [rename.old for rename in renamed] + list(removed) + repeated = next((name for name in retired if retired.count(name) > 1), None) + if repeated is not None: + raise Exception(f"bmod file {path} retires {repeated!r} more than once in renamed and removed") + return ParsedRetired(renamed, removed) + + +def parse_renamed(value: object, path: Path) -> tuple[Rename, ...]: + """Skills the module renamed, as `{ from, to }` tables.""" + if value is None: + return () + if not isinstance(value, list): + raise Exception(f"bmod file {path} field 'renamed' must be a list of tables") + renamed: list[Rename] = [] + for index, entry in enumerate(value): + field = f"renamed[{index}]" + if not isinstance(entry, dict): + raise Exception(f"bmod file {path} field {field} must be a table") + names: list[str] = [] + for key in ("from", "to"): + name = entry.get(key) + if not isinstance(name, str) or SKILL_NAME.fullmatch(name) is None: + raise Exception(f"bmod file {path} field '{field}.{key}' must be a skill name; found {name!r}") + names.append(name) + if names[0] == names[1]: + raise Exception(f"bmod file {path} field {field} renames {names[0]!r} to itself") + renamed.append(Rename(*names)) + return tuple(renamed) + + +def parse_skill_table(table: dict, path: Path, *, standalone: bool) -> ParsedSkill: + bmod: str | None = None + source: str | None = None + if standalone: + bmod = required_string(table, "skill", "bmod", path) + if SKILL_NAME.fullmatch(bmod) is None: + raise Exception(f"bmod file {path} field 'skill.bmod' has unsafe value {bmod!r}") + source = required_string(table, "skill", "source", path) + validate_source(source, "skill.source", path) + return ParsedSkill( + bmod, + source, + parse_scripts(table.get("scripts"), path), + parse_requirements(table.get("required_skills"), "skill.required_skills", path), + parse_requirements(table.get("recommended_skills"), "skill.recommended_skills", path), + ) + + +def validate_source(value: str, field: str, path: Path) -> None: + prefix = next( + (candidate for candidate in UPDATE_SOURCE_PREFIXES if value.startswith(candidate)), + None, + ) + if prefix is None or not value.removeprefix(prefix): + raise Exception(f"bmod file {path} field {field!r} must name a source") + if prefix == "github:": + github_parts = value.removeprefix(prefix).split("/") + if len(github_parts) < 2 or any(not part for part in github_parts): + raise Exception(f"bmod file {path} field {field!r} github source must name owner/repo") + if prefix == "https://" and any(character.isspace() for character in value): + raise Exception(f"bmod file {path} field {field!r} must be a valid HTTPS URL") + + +def parse_skill_names(value: object, field: str, path: Path) -> tuple[str, ...]: + if not isinstance(value, list): + raise Exception(f"bmod file {path} field {field!r} must be a list of skill names") + names: list[str] = [] + for entry in value: + if not isinstance(entry, str) or SKILL_NAME.fullmatch(entry) is None: + raise Exception(f"bmod file {path} field {field!r} has unsafe skill name {entry!r}") + if entry in names: + raise Exception(f"bmod file {path} field {field!r} repeats {entry!r}") + names.append(entry) + return tuple(names) + + +def parse_path(entry: object, field: str, path: Path, seen: list[PurePosixPath]) -> PurePosixPath: + if not isinstance(entry, str) or not entry: + raise Exception(f"bmod file {path} field {field!r} has invalid value {entry!r}") + relative = safe_skill_relative(entry) + if relative is None: + raise Exception(f"bmod file {path} field {field!r} has unsafe value {entry!r}") + if relative in seen: + raise Exception(f"bmod file {path} field {field!r} repeats {entry!r}") + return relative + + +def parse_knowledge(value: object, path: Path) -> tuple[KnowledgeEntry, ...]: + """The module's help documents, as paths inside the bmod folder, each with the skills it covers.""" + if value is None: + return () + if not isinstance(value, list): + raise Exception(f"bmod file {path} field 'bmod.knowledge' must be a list of tables") + knowledge: list[KnowledgeEntry] = [] + for index, entry in enumerate(value): + field = f"bmod.knowledge[{index}]" + if not isinstance(entry, dict): + raise Exception(f"bmod file {path} field {field} must be a table") + relative = parse_path(entry.get("path"), f"{field}.path", path, [item.path for item in knowledge]) + skills = entry.get("skills", "*") + if skills == "*": + knowledge.append(KnowledgeEntry(relative, None)) + continue + if isinstance(skills, str): + raise Exception(f"bmod file {path} field '{field}.skills' must be \"*\" or a list of skill names") + knowledge.append(KnowledgeEntry(relative, parse_skill_names(skills, f"{field}.skills", path))) + return tuple(knowledge) + + +def safe_skill_relative(entry: str) -> PurePosixPath | None: + """A bmod.toml path that cannot escape the skill folder, or None if it can. + + Shared with validate_manifests.py and knowledge.py so one rule decides + this everywhere. A URL parses as an ordinary relative path and a Windows + drive prefix makes a later join discard the skill folder, so both are + refused by name. pathlib drops "." components itself, so only ".." and an + empty final component need checking. + """ + if not entry or "://" in entry or "\\" in entry or ":" in entry: + return None + relative = PurePosixPath(entry) + if relative.is_absolute() or ".." in relative.parts or not relative.name: + return None + return relative + + +def parse_requirements(value: object, field: str, path: Path) -> tuple[Requirement, ...]: + """A flat list of skills. A plain name comes from the declaring file's own source.""" + if value is None: + return () + if not isinstance(value, list): + raise Exception(f"bmod file {path} field {field!r} must be a list of skills") + requirements: list[Requirement] = [] + for index, entry in enumerate(value): + item = f"{field}[{index}]" + if isinstance(entry, str): + requirement = Requirement(entry, None, None) + elif isinstance(entry, dict): + skill = entry.get("skill") + if not isinstance(skill, str): + raise Exception(f"bmod file {path} field '{item}.skill' must be a string and is required") + source = entry.get("source") + if not isinstance(source, str): + raise Exception(f"bmod file {path} field '{item}.source' must be a string and is required") + validate_source(source, f"{item}.source", path) + minimum = entry.get("version") + if minimum is not None: + if not isinstance(minimum, str): + raise Exception(f"bmod file {path} field '{item}.version' must be a string") + if parse_orderable_semver(minimum) is None: + raise Exception( + f"bmod file {path} field '{item}.version' must be an orderable version; found {minimum!r}" + ) + requirement = Requirement(skill, minimum, source) + else: + raise Exception(f"bmod file {path} field {item!r} must be a skill name or a table") + if SKILL_NAME.fullmatch(requirement.skill) is None: + raise Exception(f"bmod file {path} field {item!r} has unsafe skill name {requirement.skill!r}") + if any(requirement.skill == other.skill for other in requirements): + raise Exception(f"bmod file {path} field {field!r} repeats {requirement.skill!r}") + requirements.append(requirement) + return tuple(requirements) + + +def required_string(table: dict, name: str, field: str, path: Path) -> str: + value = table.get(field) + if not isinstance(value, str) or not value.strip(): + raise Exception(f"bmod file {path} field '{name}.{field}' must be a non-empty string") + return value + + +def optional_string(table: dict, name: str, field: str, path: Path | str) -> str: + value = table.get(field, "") + if not isinstance(value, str): + raise Exception(f"bmod file {path} field '{name}.{field}' must be a string") + return value + + +def parse_questions(value: object, module: str, path: Path) -> tuple[ConfigQuestion, ...]: + if value is None: + return () + if not isinstance(value, list): + raise Exception(f"bmod file {path} field 'bmod.config_questions' must be a list") + questions: list[ConfigQuestion] = [] + seen: list[str] = [] + for index, question in enumerate(value): + field = f"bmod.config_questions[{index}]" + if not isinstance(question, dict): + raise Exception(f"bmod file {path} field {field} must be a mapping") + keys = set(question) + if not QUESTION_KEYS <= keys or not keys <= QUESTION_KEYS | OPTIONAL_QUESTION_KEYS: + missing = sorted(QUESTION_KEYS - keys) + unknown = sorted(keys - QUESTION_KEYS - OPTIONAL_QUESTION_KEYS, key=str) + detail = f"missing key {missing[0]!r}" if missing else f"unknown key {unknown[0]!r}" + raise Exception(f"bmod file {path} field {field} has {detail}") + for key in QUESTION_KEYS: + if not isinstance(question[key], str): + raise Exception(f"bmod file {path} field {field}.{key} must be a string") + scope = question.get("scope", "team") + if scope not in QUESTION_SCOPES: + raise Exception(f'bmod file {path} field {field}.scope must be "team" or "user"; found {scope!r}') + prompt = question["prompt"] + key = question["key"] + if not prompt.strip(): + raise Exception(f"bmod file {path} field {field}.prompt must be non-empty") + if not key or any(not part or part != part.strip() for part in key.split(".")): + raise Exception(f"bmod file {path} field {field}.key must be a non-empty dotted key") + if key == module or key.startswith(f"{module}."): + raise Exception(f"bmod file {path} field {field}.key {key!r} must not start with module {module!r}") + conflict = conflicting_question_key(seen, key) + if conflict is not None: + raise Exception(f"bmod file {path} config question key {key!r} conflicts with {conflict!r}") + seen.append(key) + questions.append(ConfigQuestion(module, key, prompt, question["default"], scope)) + return tuple(questions) + + +def conflicting_question_key(keys: list[str], candidate: str) -> str | None: + for key in keys: + if key == candidate or key.startswith(f"{candidate}.") or candidate.startswith(f"{key}."): + return key + return None + + +def parse_scripts(value: object, path: Path) -> tuple[PurePosixPath, ...]: + if value is None: + return () + if not isinstance(value, list): + raise Exception(f"bmod file {path} field 'skill.scripts' must be a list") + scripts: list[PurePosixPath] = [] + for entry in value: + if not isinstance(entry, str) or not entry: + raise Exception(f"bmod file {path} field 'skill.scripts' has invalid value {entry!r}") + relative = PurePosixPath(entry) + if ( + relative.is_absolute() + or "\\" in entry + or len(relative.parts) < 2 + or relative.parts[0] != "scripts" + or ".." in relative.parts + or "." in relative.parts + ): + raise Exception(f"bmod file {path} field 'skill.scripts' has unsafe value {entry!r}") + scripts.append(relative) + return tuple(scripts) + + +def read_declared_script(skill_root: Path, relative: PurePosixPath, file: Path) -> bytes: + root = skill_root.resolve() + candidate = root.joinpath(*relative.parts) + try: + resolved = candidate.resolve(strict=True) + resolved.relative_to(root) + except (OSError, RuntimeError, ValueError) as error: + raise Exception(f"bmod file {file} declares unsafe or missing script {relative.as_posix()!r}") from error + if not resolved.is_file(): + raise Exception(f"bmod file {file} declared script {relative.as_posix()!r} is not a file") + try: + return resolved.read_bytes() + except OSError as error: + raise Exception(f"cannot read script {resolved} declared by {file}: {error}") from error + + +def status_report( + project_root: Path, skill_root: Path, *, module: str | None = None, roots: tuple[Path, ...] = () +) -> dict[str, object]: + """Everything `bmad status` says. Reads only; nothing under the project is written.""" + reject_unusable_bmad(project_root) + bmad = project_root / "_bmad" + installation = discover_installation(skill_root, roots) + selected, unknown = select_module(installation, module, mode="status") + if unknown is not None: + return unknown + scoped = installation.modules if selected is None else (selected,) + code = None if selected is None else selected.module + problems = list(installation.problems) + + modules: list[dict[str, object]] = [] + update_states: list[str] = [] + for installed in scoped: + update = module_update_report(project_root, installed) + update_states.append(str(update["state"])) + try: + reject_unusable_module_root(bmad / installed.module) + scripts = tree_state( + bmad / installed.module / "scripts", declared_scripts_tree(read_module_scripts(installed)) + ) + except Exception as error: + scripts = "could-not-check" + problems.append({"kind": "scripts", "module": installed.module, "message": str(error)}) + modules.append( + { + **module_summary(installed, project_root), + "scripts": scripts, + "update": update, + } + ) + + scripts_src, config_src = payload(skill_root) + shared_state = tree_state(bmad / "scripts", read_plain_tree(scripts_src)) + existing_text, _merged, base_text = team_config_plan(project_root, config_src) + output = project_root / output_folder(base_text) + custom = bmad / "custom" + pending = pending_config_questions(project_root, skill_root, scoped) + unmet = unmet_requirements(installation, skill_root, module=code) + gitignore_state = custom_gitignore_state(project_root) + problems.extend(custom_gitignore_problems(gitignore_state)) + retirement = retirement_report(project_root, skill_root, installation, retired_skills(scoped, installation.modules)) + setup_owed = ( + not bmad.is_dir() + or shared_state != "current" + or team_config_state(project_root, existing_text, base_text) != "current" + or not (custom.exists() or custom.is_symlink()) + or not (output.exists() or output.is_symlink()) + or bool(pending) + or gitignore_state == "missing" + or any(item["scripts"] in ("missing", "stale") for item in modules) + or bool(retirement.renames) + ) + next_command = next_step( + installation.missing_records, + unmet, + update_available="newer-available" in update_states, + setup_owed=setup_owed, + module=code, + ) + settled = ("current", "ahead", "plugin-managed", "could-not-check") + return { + "mode": "status", + "module": code, + "bmad_exists": bmad.is_dir(), + "bmad": bmad_report(installation, skill_root), + "shared_scripts": shared_state, + "custom_gitignore": gitignore_state, + "modules": modules, + "missing_module_records": list(installation.missing_records), + "duplicate_skills": duplicates_json(installation.duplicates, project_root), + "pending_questions": [question_json(question) for question in pending], + "unmet_requirements": unmet, + "unmet_recommendations": unmet_recommendations(installation, skill_root, module=code), + "problems": problems, + **retirement_json(retirement), + "current": ( + next_command is None + and not unmet + and not problems + and not installation.missing_records + and all(state in settled for state in update_states) + ), + "next": next_command, + } + + +def module_update_report(project_root: Path, installed: InstalledModule) -> dict[str, object]: + """How the installed module compares with the `[bmod] version` at its source.""" + update_source = installed.parsed.update_source + if update_source.startswith("plugin:"): + plugin = update_source.removeprefix("plugin:") + return { + "state": "plugin-managed", + "plugin": plugin, + "instruction": ( + f"this module ships inside the {plugin} plugin — update the " + "plugin through its marketplace, not these files" + ), + } + source = update_source + try: + source = source_file_location(project_root, update_source, installed.folder) + source_version, pre_message = parse_source_record(source, read_source_file(source, update_source)) + except Exception as error: + return {"state": "could-not-check", "source": source, "reason": str(error)} + state = version_state(installed.parsed.version, source_version) + return { + "state": state, + "source": source, + "source_version": source_version, + **(message_json("pre_install_message", pre_message) if state == "newer-available" else {}), + } + + +def source_record_report(project_root: Path, update_source: str, folder: str) -> dict[str, object]: + """The version and pre-install message of a module record at its source, before the module is added.""" + report: dict[str, object] = {"mode": "source-record", "folder": folder, "source": update_source} + try: + if SKILL_NAME.fullmatch(folder) is None: + raise Exception(f"unsafe module record folder {folder!r}") + validate_source(update_source, "source", Path("--source-record")) + if update_source.startswith("plugin:"): + raise Exception("a plugin source has no module record to read") + source = source_file_location(project_root, update_source, folder) + report["source"] = source + version, pre_message = parse_source_record(source, read_source_file(source, update_source)) + except Exception as error: + return {**report, "state": "could-not-check", "reason": str(error)} + return {**report, "state": "read", "version": version, **message_json("pre_install_message", pre_message)} + + +def message_json(key: str, message: str) -> dict[str, str]: + """An install message for a report; an empty one is left out, so it is never shown.""" + return {key: message} if message.strip() else {} + + +def source_file_location(project_root: Path, update_source: str, folder: str) -> str: + quoted_folder = urllib.parse.quote(folder, safe="") + quoted_name = urllib.parse.quote(MANIFEST_NAME, safe="") + if update_source.startswith("file:"): + root_text = update_source.removeprefix("file:") + root = Path(root_text) + if not root.is_absolute(): + root = project_root / root + return str((root / folder / MANIFEST_NAME).resolve()) + if update_source.startswith("https://"): + try: + parsed = urllib.parse.urlsplit(update_source) + except ValueError as error: + raise Exception(f"invalid update_source {update_source!r}: {error}") from error + return urllib.parse.urlunsplit( + parsed._replace(path=(parsed.path.rstrip("/") + f"/{quoted_folder}/{quoted_name}")) + ) + github = update_source.removeprefix("github:") + owner, repository, *tree = github.split("/") + # With no path the repo root is the skill, so its bmod.toml sits at the root. + parts = (*tree, folder, MANIFEST_NAME) if tree else (MANIFEST_NAME,) + path = "/".join(urllib.parse.quote(part, safe="") for part in parts) + return ( + "https://raw.githubusercontent.com/" + f"{urllib.parse.quote(owner, safe='')}/" + f"{urllib.parse.quote(repository, safe='')}/main/{path}" + ) + + +def read_source_file(source: str, update_source: str) -> bytes: + if update_source.startswith("file:"): + path = Path(source) + try: + raw = path.read_bytes() + except OSError as error: + raise Exception(f"cannot read source bmod file {path}: {error}") from error + else: + request = urllib.request.Request( + source, + headers={"Accept": "text/plain", "User-Agent": "bmad-status"}, + ) + try: + with urllib.request.urlopen(request, timeout=10) as response: + raw = response.read(SOURCE_READ_LIMIT + 1) + except (OSError, urllib.error.URLError) as error: + raise Exception(f"cannot read source bmod file {source}: {error}") from error + if len(raw) > SOURCE_READ_LIMIT: + raise Exception(f"source bmod file {source} exceeds {SOURCE_READ_LIMIT} bytes") + return raw + + +def parse_source_record(source: str, raw: bytes) -> tuple[str, str]: + """The `[bmod]` version and pre-install message of a source record, from the one fetch status makes.""" + try: + text = raw.decode("utf-8") + except UnicodeError as error: + raise Exception(f"invalid source bmod file {source}: {error}") from error + table = parse_toml(text, source).get("bmod") + version = table.get("version") if isinstance(table, dict) else None + if not isinstance(version, str) or not version.strip(): + raise Exception(f"source bmod file {source} field 'bmod.version' must be a non-empty string") + # Lenient: a bad message at the source must not hide a newer version. + message = table.get("pre_install_message", "") + return version, message if isinstance(message, str) else "" + + +def version_state(installed: str, source: str) -> str: + comparison = compare_semver(installed, source) + if comparison is None: + return "differing-unordered" + if comparison == 0: + return "current" + if comparison < 0: + return "newer-available" + return "ahead" + + +def compare_semver(left: str, right: str) -> int | None: + left_parsed = parse_orderable_semver(left) + right_parsed = parse_orderable_semver(right) + if left_parsed is None or right_parsed is None: + return None + left_core, left_pre = left_parsed + right_core, right_pre = right_parsed + if left_core != right_core: + return -1 if left_core < right_core else 1 + return compare_prerelease(left_pre, right_pre) + + +def parse_orderable_semver( + value: str, +) -> tuple[tuple[int, int, int], tuple[str, ...] | None] | None: + match = SEMVER.fullmatch(value) + if match is None or "-dev" in value.casefold(): + return None + prerelease = match.group("prerelease") + return ( + ( + int(match.group("major")), + int(match.group("minor")), + int(match.group("patch")), + ), + tuple(prerelease.split(".")) if prerelease is not None else None, + ) + + +def compare_prerelease(left: tuple[str, ...] | None, right: tuple[str, ...] | None) -> int: + if left is None or right is None: + if left is right: + return 0 + return 1 if left is None else -1 + for left_item, right_item in zip(left, right, strict=False): + if left_item == right_item: + continue + left_numeric = left_item.isdigit() + right_numeric = right_item.isdigit() + if left_numeric and right_numeric: + return -1 if int(left_item) < int(right_item) else 1 + if left_numeric != right_numeric: + return -1 if left_numeric else 1 + return -1 if left_item < right_item else 1 + if len(left) == len(right): + return 0 + return -1 if len(left) < len(right) else 1 + + +def declared_scripts_tree(scripts: tuple[tuple[PurePosixPath, bytes], ...]) -> PlainTree: + by_path = {PurePosixPath(*relative.parts[1:]): content for relative, content in scripts} + if not by_path: + # Git drops an empty directory, and a clone without it would report the module's scripts as missing. + by_path = {PurePosixPath(".gitkeep"): b""} + files = tuple(sorted(by_path.items(), key=lambda item: item[0].as_posix())) + directories = { + PurePosixPath(*relative.parts[:index]) + for relative, _content in files + for index in range(1, len(relative.parts)) + } + return PlainTree(tuple(sorted(directories, key=str)), files) + + +def read_plain_tree(root: Path) -> PlainTree: + if not root.is_dir() or root.is_symlink(): + raise Exception(f"payload scripts are not a plain directory: {root}") + directories: list[PurePosixPath] = [] + files: list[tuple[PurePosixPath, bytes]] = [] + try: + entries = sorted(root.rglob("*"), key=lambda path: path.as_posix()) + except OSError as error: + raise Exception(f"cannot inspect payload scripts {root}: {error}") from error + for entry in entries: + relative = PurePosixPath(entry.relative_to(root).as_posix()) + if entry.is_symlink(): + raise Exception(f"payload scripts contain a symlink: {entry}") + if entry.is_dir(): + directories.append(relative) + continue + if not entry.is_file(): + raise Exception(f"payload scripts contain a non-file entry: {entry}") + try: + content = entry.read_bytes() + except OSError as error: + raise Exception(f"cannot read payload script {entry}: {error}") from error + files.append((relative, content)) + return PlainTree(tuple(directories), tuple(files)) + + +def tree_matches(root: Path, expected: PlainTree) -> bool: + if not root.is_dir() or root.is_symlink(): + return False + try: + actual = read_plain_tree(root) + except Exception: + return False + return actual == expected + + +def tree_state(root: Path, expected: PlainTree) -> str: + if not root.exists() and not root.is_symlink(): + return "missing" + return "current" if tree_matches(root, expected) else "stale" + + +def find_pending_questions( + modules: tuple[InstalledModule, ...], + config: dict, + user_config: dict, + project_root: Path, +) -> tuple[ConfigQuestion, ...]: + """A team question is pending once per project, a user question once per person.""" + pending: list[ConfigQuestion] = [] + for installed in modules: + for question in installed.questions: + path = ("modules", question.module, *question.key.split(".")) + if question.scope == "user": + answered = has_path(user_config, path, user_config_path(project_root)) + else: + answered = has_path(config, path, project_root / "_bmad" / "config.toml") + if not answered: + pending.append( + question._replace(default=question.default.replace("{directory_name}", project_root.name)) + ) + return tuple(pending) + + +def has_path(data: object, path: tuple[str, ...], source: Path) -> bool: + current = data + for part in path: + if not isinstance(current, dict): + raise Exception(f"cannot inspect {'.'.join(path)}: parent value in {source} is not a table") + if part not in current: + return False + current = current[part] + return True + + +def load_module_answers(path: Path) -> dict[tuple[str, str], str]: + if not path.is_file(): + raise Exception(f"missing file: {path}") + try: + data = tomllib.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, tomllib.TOMLDecodeError) as error: + raise Exception(f"cannot parse module answers {path}: {error}") from error + if not data: + return {} + if set(data) != {"modules"} or not isinstance(data["modules"], dict): + raise Exception(f"--module-answers {path} must contain only module answer tables") + answers: dict[tuple[str, str], str] = {} + for module, values in data["modules"].items(): + if not isinstance(module, str) or not isinstance(values, dict): + raise Exception(f"--module-answers {path} has an invalid module table") + flatten_module_answers(path, module, values, (), answers) + return answers + + +def flatten_module_answers( + source: Path, + module: str, + values: dict, + prefix: tuple[str, ...], + answers: dict[tuple[str, str], str], +) -> None: + for key, value in values.items(): + parts = (*prefix, str(key)) + if isinstance(value, dict): + flatten_module_answers(source, module, value, parts, answers) + continue + dotted = ".".join(parts) + if not isinstance(value, str): + raise Exception(f"--module-answers {source} value modules.{module}.{dotted} must be a string") + identifier = (module, dotted) + if identifier in answers: + raise Exception(f"--module-answers {source} defines modules.{module}.{dotted} more than once") + answers[identifier] = value + + +def validate_module_answers( + supplied: dict[tuple[str, str], str] | None, + pending: tuple[ConfigQuestion, ...], + *, + source: Path | None = None, +) -> dict[tuple[str, str], str]: + answers = supplied or {} + if source is not None: + source_label = f"--module-answers {source}" + elif supplied is None: + source_label = "no --module-answers file" + else: + source_label = "in-process module answers" + for identifier, value in answers.items(): + if ( + not isinstance(identifier, tuple) + or len(identifier) != 2 + or not all(isinstance(part, str) for part in identifier) + or not isinstance(value, str) + ): + raise Exception(f"{source_label} must map (module, key) pairs to strings") + expected = {(question.module, question.key) for question in pending} + extra = sorted(set(answers) - expected) + if extra: + module, key = extra[0] + raise Exception(f"{source_label} contains modules.{module}.{key}, which is not a pending question") + missing = [question for question in pending if (question.module, question.key) not in answers] + if missing and supplied is None: + question = missing[0] + raise Exception( + f"pending question modules.{question.module}.{question.key} has no answer; " + "pass --module-answers (run --list-config-questions first)" + ) + if missing: + question = missing[0] + raise Exception( + f"{source_label} is missing an answer for pending question " + f"modules.{question.module}.{question.key}; run " + "--list-config-questions first" + ) + return answers + + +TABLE_HEADER = re.compile(r"\s*\[(?P\[)?(?P[^\[\]]+)\]\]?\s*(?:#.*)?\Z") + + +def text_with_answers(text: str | None, added: list[tuple[tuple[str, ...], str]], expected: dict) -> str: + """The file's own text with the new answers added, so comments and layout survive. + + The result must parse to exactly the expected values. When it does not, or + a table cannot be placed by text, the whole file is rendered instead. + """ + if text is not None: + try: + for path, value in added: + text = insert_answer(text, path, value) + if tomllib.loads(text) == expected: + return text + except (ValueError, tomllib.TOMLDecodeError): + pass + return render_toml(expected) + + +def insert_answer(text: str, path: tuple[str, ...], value: str) -> str: + table, leaf = path[:-1], path[-1] + entry = f"{toml_key(leaf)} = {toml_string(value)}" + lines = text.split("\n") + headers = [(index, header_path(line)) for index, line in enumerate(lines) if TABLE_HEADER.match(line)] + for position, (index, name) in enumerate(headers): + if name != table: + continue + end = headers[position + 1][0] if position + 1 < len(headers) else len(lines) + last = max( + (row for row in range(index, end) if lines[row].strip() and not lines[row].lstrip().startswith("#")), + default=index, + ) + return "\n".join([*lines[: last + 1], entry, *lines[last + 1 :]]) + if lookup(tomllib.loads(text), table) is not _MISSING: + raise ValueError(f"{'.'.join(table)} is not written as a table header") + header = ".".join(toml_key(part) for part in table) + separator = "" if not text or text.endswith("\n\n") else "\n" if text.endswith("\n") else "\n\n" + return f"{text}{separator}[{header}]\n{entry}\n" + + +def header_path(line: str) -> tuple[str, ...] | None: + match = TABLE_HEADER.match(line) + if match is None or match.group("array"): + return None + try: + data = tomllib.loads(f"{match.group('name')} = 1") + except tomllib.TOMLDecodeError: + return None + parts: list[str] = [] + while isinstance(data, dict): + ((key, data),) = data.items() + parts.append(key) + return tuple(parts) + + +def set_missing_value( + data: dict, + path: tuple[str, ...], + value: str, + source: Path, +) -> None: + current = data + for part in path[:-1]: + child = current.get(part) + if child is None: + child = {} + current[part] = child + elif not isinstance(child, dict): + dotted = ".".join(path) + raise Exception(f"cannot add {dotted}: parent value in {source} is not a table") + current = child + leaf = path[-1] + if leaf in current: + raise Exception(f"refusing to overwrite existing {'.'.join(path)} in {source}") + current[leaf] = value + + +def fill_team_config(text: str, project_root: Path) -> str: + return text.replace("{directory_name}", project_root.name) + + +def output_folder(config_text: str) -> str: + folder = tomllib.loads(config_text).get("core", {}).get("output_folder", "_bmad-output") + prefix = "{project-root}/" + if folder.startswith(prefix): + folder = folder[len(prefix) :] + return folder or "_bmad-output" + + +def materialize_bmad( + project_root: Path, + scripts_src: Path, + config_text: str, + module_trees: dict[str, PlainTree], + *, + user_config_text: str | None = None, + custom_renames: tuple[tuple[str, str], ...] = (), +) -> None: + bmad = project_root / "_bmad" + project_root.mkdir(parents=True, exist_ok=True) + # One attempt, not mkdtemp: on Windows, older Pythons' mkdtemp takes "access denied" + # for a name collision and tries the next name, some two billion times. Setup + # would hang in a folder it cannot write to instead of reporting the failure. + staging = project_root / f"_bmad.setup-{os.urandom(8).hex()}" + staging.mkdir() + try: + # Seed staging so custom/, extra *.user.toml, and leftovers + # survive replace_dir. + if bmad.exists(): + scripts = bmad / "scripts" + + def ignore_scripts_link(directory: str, _names: list[str]) -> set[str]: + if scripts.is_symlink() and Path(directory) == bmad: + return {"scripts"} + return set() + + shutil.copytree( + bmad, + staging, + dirs_exist_ok=True, + symlinks=True, + ignore=ignore_scripts_link, + ) + stage_bmad( + staging, + scripts_src=scripts_src, + config_text=config_text, + module_trees=module_trees, + user_config_text=user_config_text, + custom_renames=custom_renames, + ) + replace_dir(staging, bmad) + except Exception: + shutil.rmtree(staging, ignore_errors=True) + raise + + +def replace_dir(src: Path, dest: Path) -> None: + if not dest.exists(): + src.rename(dest) + return + # Not mkdtemp: Windows refuses a rename onto an existing directory. + backup = dest.with_name(f"_bmad.old-{datetime.datetime.now():%Y%m%d-%H%M%S}") + dest.rename(backup) + try: + src.rename(dest) + except Exception: + backup.rename(dest) + raise + shutil.rmtree(backup) + + +def stage_bmad( + staging: Path, + *, + scripts_src: Path, + config_text: str, + module_trees: dict[str, PlainTree], + user_config_text: str | None, + custom_renames: tuple[tuple[str, str], ...] = (), +) -> None: + ensure_scripts(staging / "scripts", scripts_src) + ensure_file(staging / "config.toml", config_text) + # A module's scripts directory exists even when it declares no scripts, + # so a second run reports current, not repaired. + for code, tree in module_trees.items(): + ensure_plain_tree(staging / code / "scripts", tree) + custom = staging / "custom" + ensure_dir(custom) + if custom.is_symlink() or not custom.is_dir(): + return + gitignore = custom / ".gitignore" + if not gitignore.exists() and not gitignore.is_symlink(): + write_text(gitignore, CUSTOM_GITIGNORE) + if user_config_text is not None: + write_text(custom / "config.user.toml", user_config_text) + for old, new in custom_renames: + (custom / old).rename(custom / new) + + +def ensure_scripts(dest: Path, src: Path) -> None: + source = read_plain_tree(src) + if tree_matches(dest, source): + return + if dest.is_symlink() or dest.is_file(): + dest.unlink() + elif dest.exists(): + shutil.rmtree(dest) + dest.mkdir(parents=True) + for relative in source.directories: + dest.joinpath(*relative.parts).mkdir(parents=True, exist_ok=True) + for relative, _content in source.files: + target = dest.joinpath(*relative.parts) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src.joinpath(*relative.parts), target) + + +def ensure_plain_tree(dest: Path, source: PlainTree) -> None: + if tree_matches(dest, source): + return + if dest.is_symlink() or dest.is_file(): + dest.unlink() + elif dest.exists(): + shutil.rmtree(dest) + dest.mkdir(parents=True) + for relative in source.directories: + dest.joinpath(*relative.parts).mkdir(parents=True, exist_ok=True) + for relative, content in source.files: + target = dest.joinpath(*relative.parts) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(content) + + +def write_text(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + content if content.endswith("\n") else content + "\n", + encoding="utf-8", + ) + + +def ensure_file(path: Path, content: str) -> None: + if path.is_symlink(): + path.unlink() + elif path.is_file(): + existing = path.read_text(encoding="utf-8") + filled = fill_toml(existing, content) + if filled == existing: + return + content = filled + elif path.exists(): + shutil.rmtree(path) + write_text(path, content) + + +def ensure_dir(path: Path) -> None: + if not path.is_symlink() and not path.exists(): + path.mkdir(parents=True) + + +def toml_string(value: str) -> str: + replacements = { + "\\": "\\\\", + '"': '\\"', + "\b": "\\b", + "\t": "\\t", + "\n": "\\n", + "\f": "\\f", + "\r": "\\r", + } + escaped = "".join(replacements.get(character, toml_control(character)) for character in value) + return f'"{escaped}"' + + +def toml_control(value: str) -> str: + codepoint = ord(value) + if codepoint < 0x20 or codepoint == 0x7F: + return f"\\u{codepoint:04X}" + return value + + +def toml_key(key: str) -> str: + if key and key.isascii() and key[0].isalpha() and all(c.isalnum() or c in "-_" for c in key): + return key + return toml_string(key) + + +def toml_value(value: object) -> str: + if isinstance(value, str): + return toml_string(value) + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, int): + return str(value) + if isinstance(value, float): + return str(value) + if isinstance(value, (datetime.datetime, datetime.date, datetime.time)): + return value.isoformat() + if value is None: + return '""' + if isinstance(value, list): + return "[ " + ", ".join(toml_value(item) for item in value) + " ]" + if isinstance(value, dict): + rendered = ", ".join(f"{toml_key(str(key))} = {toml_value(item)}" for key, item in value.items()) + return "{ " + rendered + " }" + return toml_string(str(value)) + + +def fill_keep(template: object, existing: object) -> object: + if isinstance(template, dict) and isinstance(existing, dict): + result = dict(template) + for key, value in existing.items(): + result[key] = fill_keep(result[key], value) if key in result else value + return result + return existing + + +def render_toml(data: dict) -> str: + lines: list[str] = [] + + def emit_scalars(table: dict) -> None: + for key, value in table.items(): + if not isinstance(value, dict): + lines.append(f"{toml_key(str(key))} = {toml_value(value)}") + + def emit_tables(table: dict, prefix: tuple[str, ...]) -> None: + for key, value in table.items(): + if not isinstance(value, dict): + continue + header = (*prefix, str(key)) + scalars = any(not isinstance(item, dict) for item in value.values()) + nested = any(isinstance(item, dict) for item in value.values()) + if scalars or not nested: + if lines: + lines.append("") + lines.append(f"[{'.'.join(toml_key(part) for part in header)}]") + emit_scalars(value) + emit_tables(value, header) + + emit_scalars(data) + emit_tables(data, ()) + return "\n".join(lines) + "\n" + + +def fill_toml(existing_text: str, template_text: str) -> str: + try: + existing = tomllib.loads(existing_text) + template = tomllib.loads(template_text) + except tomllib.TOMLDecodeError as error: + raise Exception(f"cannot merge malformed TOML: {error}") from error + if not isinstance(existing, dict) or not isinstance(template, dict): + return template_text + merged = fill_keep(template, existing) + if merged == existing: + return existing_text + if merged == template: + return template_text + return render_toml(merged) + + +def cli() -> int: + try: + return main() + except Exception as error: + sys.stderr.write(f"error: {error}\n") + return 1 + + +if __name__ == "__main__": + if sys.platform == "win32": + # Piped output on Windows defaults to a legacy code page, not UTF-8. + sys.stdout.reconfigure(encoding="utf-8") + sys.stderr.reconfigure(encoding="utf-8") + raise SystemExit(cli()) diff --git a/_bmad/scripts/setup_check.py b/_bmad/scripts/setup_check.py new file mode 100644 index 0000000..b225e74 --- /dev/null +++ b/_bmad/scripts/setup_check.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Tell a starting skill what setup still owes it. + +A skill can be installed after the last `bmad setup`, without its module +record, or need a newer hub than the one present. Nothing is recorded to detect +that: everything here is read from the skill's own `bmod.toml`, its module's +`bmod.toml` beside it, the skills installed beside it, and `_bmad/`. + +Only what stops a skill from working well is reported, because this runs every +time a skill starts. Recommended skills belong to setup, status, and help. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.dont_write_bytecode = True + +MANIFEST_NAME = "bmod.toml" +_MISSING = object() + + +def install_command(source: str, skill: str) -> str: + import setup as hub + + command = hub.install_command(source, skill) + return f"`{command}`" if command is not None else f"`{skill}` from {source}" + + +def owed(skill_dir: Path, project_root: Path | None) -> list[str]: + """Plain sentences for the agent to relay; empty when nothing is owed.""" + import setup as hub + from config_utils import ConfigError, load_central_config + + parsed = hub.read_bmod_file(skill_dir / MANIFEST_NAME) + if parsed is None or parsed.skill is None: + return [] + skill = parsed.skill + notes: list[str] = [] + + record = parsed.bmod + if record is None: + other = hub.read_bmod_file(skill_dir.parent / skill.bmod / MANIFEST_NAME) + record = other.bmod if other is not None else None + if record is None: + notes.append( + f"belongs to a module whose record `{skill.bmod}` is not installed beside it. " + f"Offer to install it with {install_command(skill.source, skill.bmod)}." + ) + + own_source = skill.source if skill.source is not None else record.update_source if record else "" + declared = [(requirement, record.update_source) for requirement in (record.required_skills if record else ())] + declared += [(requirement, own_source) for requirement in skill.required_skills] + # A skill named by both lists is checked once, against the higher minimum. + wanted: dict[str, tuple] = {} + for requirement, default_source in declared: + if requirement.skill == skill_dir.name: + continue + kept = wanted.get(requirement.skill) + if kept is None or ( + requirement.version is not None + and (kept[0].version is None or (hub.compare_semver(requirement.version, kept[0].version) or 0) > 0) + ): + wanted[requirement.skill] = (requirement, default_source) + for requirement, default_source in wanted.values(): + state, installed = hub.requirement_check(skill_dir.parent, requirement) + if state == "missing": + source = requirement.source or default_source + notes.append( + f"needs the `{requirement.skill}` skill, which is not installed beside it. " + f"If you have no `{requirement.skill}` skill, offer to install it with " + f"{install_command(source, requirement.skill)}." + ) + elif state == "outdated": + notes.append( + f"needs `{requirement.skill}` {requirement.version} or later, and {installed} is installed. " + "Offer to run `npx skills update`." + ) + elif state == "unknown-version": + notes.append( + f"needs `{requirement.skill}` {requirement.version} or later, and the installed copy's version " + "cannot be read. Offer to run `npx skills update`." + ) + + if record is None or project_root is None or not (project_root / "_bmad").is_dir(): + return notes + + if record.questions: + try: + config = load_central_config(project_root) + except ConfigError: + config = None + if config is not None: + unanswered = [ + question.key + for question in record.questions + if lookup(config, ("modules", question.module, *question.key.split("."))) is _MISSING + ] + if unanswered: + notes.append( + f"belongs to module `{record.code}`, whose setup questions were never answered " + f"({', '.join(unanswered)}). Offer to run `bmad setup`." + ) + + installed_scripts = project_root / "_bmad" / record.code / "scripts" + for relative in skill.scripts: + packaged = skill_dir.joinpath(*relative.parts) + placed = installed_scripts.joinpath(*relative.parts[1:]) + if not placed.is_file() or (packaged.is_file() and placed.read_bytes() != packaged.read_bytes()): + notes.append( + f"belongs to module `{record.code}`, whose scripts in `_bmad/{record.code}/scripts/` are " + "missing or out of date. Offer to run `bmad setup`." + ) + break + + return notes + + +def lookup(data: object, keys: tuple[str, ...]) -> object: + current = data + for key in keys: + if not isinstance(current, dict) or key not in current: + return _MISSING + current = current[key] + return current + + +def report(skill_dir: Path, project_root: Path | None) -> None: + """Write what is owed to stderr, worded as an instruction so no skill has to explain it. + + A failure here must never stop the skill from resolving. + """ + try: + notes = owed(skill_dir, project_root) + except Exception: + return + for note in notes: + sys.stderr.write(f"setup: before continuing, tell the user that `{skill_dir.name}` {note}\n") diff --git a/_bmad/scripts/stamp_release.py b/_bmad/scripts/stamp_release.py new file mode 100644 index 0000000..c413b2a --- /dev/null +++ b/_bmad/scripts/stamp_release.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Release version stamper for module repositories. + +Writes a human-supplied SemVer version into every module record: the +`version` line inside the `[bmod]` table of each skills/*/bmod.toml that has +one. Member skills carry no version and are not written. Each module +repository's release runbook uses it to stamp releases and the next +placeholder on `dev`. + +Before writing anything it runs the repository checks in +validate_manifests.py beside it, the same ones pre-commit and CI run. +`--check` runs only those checks and writes nothing. + +A file may carry keys and tables this script does not know. The runtime +ignores them, so a release must not refuse them; they are left exactly as +written. The version line is rewritten textually, so nothing else in a file +moves, and each new file is parsed and compared before anything is written. + +After writing, the script re-reads every file and fails naming the offending +path if anything is off. + +Usage: + uv run --python 3.11 skills/bmad/scripts/stamp_release.py 1.2.0 [--project-root ] + uv run --python 3.11 skills/bmad/scripts/stamp_release.py --check [--project-root ] +""" + +from __future__ import annotations + +import argparse +import importlib.util +import sys +import tomllib +from pathlib import Path + +sys.dont_write_bytecode = True + + +class StampError(Exception): + pass + + +def load_validator(): + """The repository checks, so a release and a commit can never be held to different rules.""" + path = Path(__file__).resolve().parent / "validate_manifests.py" + spec = importlib.util.spec_from_file_location("bmad_validate_for_stamp", path) + if spec is None or spec.loader is None: + raise StampError(f"cannot load the repository checks at {path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +validator = load_validator() +setup = validator.setup + + +def validate_version(version: str) -> None: + problem = validator.version_problem(version) + if problem is not None: + raise StampError(problem) + + +def collect_records(project_root: Path) -> list[Path]: + report = validator.check_repo(project_root) + if report.problems: + raise StampError("\n ".join(report.problems)) + return list(report.records) + + +def read_toml(path: Path, rel: str) -> tuple[str, dict[str, object]]: + try: + text = path.read_bytes().decode("utf-8") + return text, tomllib.loads(text) + except (OSError, UnicodeError, tomllib.TOMLDecodeError) as error: + raise StampError(f"{rel}: cannot read bmod file: {error}") from error + + +def verify_stamp(root: Path, records: list[Path], expected: dict[str, dict[str, object]]) -> None: + for record in records: + rel = record.relative_to(root).as_posix() + _, data = read_toml(record, rel) + if data != expected[rel]: + raise StampError(f"{rel}: stamping changed something other than the version") + collect_records(root) + + +def run(project_root: Path, version: str) -> int: + try: + validate_version(version) + records = collect_records(project_root) + + # Nothing is written if any file fails. + planned: list[tuple[Path, str]] = [] + expected: dict[str, dict[str, object]] = {} + for record in records: + rel = record.relative_to(project_root).as_posix() + original, data = read_toml(record, rel) + try: + planned.append((record, validator.stamp_text(original, version))) + except ValueError as error: + raise StampError(f"{rel}: {error}") from error + expected[rel] = validator.with_version(data, version) + + for path, content in planned: + try: + path.write_bytes(content.encode("utf-8")) + except OSError as error: + raise StampError(f"{path.relative_to(project_root).as_posix()}: cannot write: {error}") from error + verify_stamp(project_root, records, expected) + except StampError as error: + print(f"Error: {error}", file=sys.stderr) + return 1 + + print(f"Stamped version {version} into {len(planned)} files:") + for path, _ in planned: + print(f" {path.relative_to(project_root).as_posix()}") + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Stamp a version into every module record.") + parser.add_argument("version", nargs="?", help='SemVer release version, e.g. "6.12.0"') + parser.add_argument("--check", action="store_true", help="run the repository checks and write nothing") + parser.add_argument( + "--project-root", type=Path, default=Path.cwd(), help="repository to stamp (default: the current directory)" + ) + args = parser.parse_args(argv) + if args.check == (args.version is not None): + parser.error("give a version to stamp, or --check, but not both") + if args.check: + return validator.main(["--project-root", str(args.project_root)]) + return run(args.project_root.resolve(), args.version) + + +if __name__ == "__main__": + if sys.platform == "win32": + # Piped output on Windows defaults to a legacy code page, not UTF-8. + sys.stdout.reconfigure(encoding="utf-8") + sys.stderr.reconfigure(encoding="utf-8") + sys.exit(main()) diff --git a/_bmad/scripts/tests/test_config_utils.py b/_bmad/scripts/tests/test_config_utils.py new file mode 100644 index 0000000..bacce07 --- /dev/null +++ b/_bmad/scripts/tests/test_config_utils.py @@ -0,0 +1,90 @@ +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from config_utils import ( # noqa: E402 + ConfigError, + load_central_config, + load_customization, + load_toml, + structural_merge, +) + + +class ConfigUtilsTests(unittest.TestCase): + def test_structural_merge_recurses_appends_and_replaces_keyed_tables(self): + base = { + "nested": {"keep": True, "replace": "old"}, + "plain": ["base"], + "items": [{"id": "one", "value": "old"}], + } + override = { + "nested": {"replace": "new"}, + "plain": ["override"], + "items": [ + {"id": "one", "value": "new"}, + {"id": "two", "value": "added"}, + ], + } + + merged = structural_merge(base, override) + + self.assertEqual(merged["nested"], {"keep": True, "replace": "new"}) + self.assertEqual(merged["plain"], ["base", "override"]) + self.assertEqual( + merged["items"], + [ + {"id": "one", "value": "new"}, + {"id": "two", "value": "added"}, + ], + ) + + def test_non_string_keyed_identifier_is_rejected(self): + with self.assertRaisesRegex(ConfigError, "identifier `id` must be a string"): + structural_merge([{"id": "valid"}], [{"id": 42}]) + + def test_present_malformed_optional_layer_is_rejected(self): + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "optional.toml" + path.write_text("[broken\n", encoding="utf-8") + + with self.assertRaisesRegex(ConfigError, "failed to parse"): + load_toml(path) + + def test_missing_optional_layer_is_empty(self): + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "optional.toml" + + self.assertEqual(load_toml(path), {}) + + def test_filesystem_layer_precedence(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + bmad = root / "_bmad" + custom = bmad / "custom" + skill = bmad / "bmm" / "sample-skill" + custom.mkdir(parents=True) + skill.mkdir(parents=True) + (bmad / "config.toml").write_text('[value]\norder = "base-team"\n', encoding="utf-8") + (bmad / "config.user.toml").write_text( + '[value]\norder = "base-user"\nstray = "ignored"\n', encoding="utf-8" + ) + (custom / "config.toml").write_text('[value]\norder = "custom-team"\n', encoding="utf-8") + (custom / "config.user.toml").write_text('[value]\norder = "custom-user"\n', encoding="utf-8") + (skill / "customize.toml").write_text('[value]\norder = "default"\n', encoding="utf-8") + (custom / "sample-skill.toml").write_text('[value]\norder = "team"\n', encoding="utf-8") + (custom / "sample-skill.user.toml").write_text('[value]\norder = "user"\n', encoding="utf-8") + + merged = load_central_config(root) + self.assertEqual(merged["value"]["order"], "custom-user") + # _bmad/config.user.toml is old-installer debris (setup.py's + # LEGACY_LEFTOVERS), not a layer. Nothing writes it; nothing reads it. + self.assertNotIn("stray", merged["value"]) + self.assertEqual(load_customization(root, skill)["value"]["order"], "user") + + +if __name__ == "__main__": + unittest.main() diff --git a/_bmad/scripts/tests/test_knowledge_migrations.py b/_bmad/scripts/tests/test_knowledge_migrations.py new file mode 100644 index 0000000..99cbc09 --- /dev/null +++ b/_bmad/scripts/tests/test_knowledge_migrations.py @@ -0,0 +1,151 @@ +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import knowledge # noqa: E402 + +SOURCE = "github:acme/tools/skills" +MIGRATION = """ +[migration] +module = "demo" +from = "1" +to = "2" +title = "Move demo artifacts to the v2 layout" +summary = "v1 to v2" +detect = "a v1 folder" +guide = "move it" +checklist = ["it moved"] +""" + + +def write_module(root: Path, skills: tuple[str, ...] = (), *, code: str = "demo") -> Path: + record = root / f"bmod-{code}" + record.mkdir(parents=True) + names = ", ".join(f'"{name}"' for name in skills) + lines = ["[bmod]", f'code = "{code}"', 'version = "1.0.0"', f'update_source = "{SOURCE}"', f"skills = [{names}]"] + (record / "bmod.toml").write_text("\n".join(lines) + "\n", encoding="utf-8") + for name in skills: + folder = root / name + folder.mkdir(parents=True) + (folder / "bmod.toml").write_text(f'[skill]\nbmod = "{record.name}"\nsource = "{SOURCE}"\n', encoding="utf-8") + return record + + +class MigrationTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.skills = Path(self.temp.name) / ".claude" / "skills" + + def test_a_migration_file_is_listed_by_its_table_without_its_text(self): + record = write_module(self.skills, ("demo-one",)) + (record / "migration-1.toml").write_text(MIGRATION, encoding="utf-8") + report = knowledge.collect([self.skills]) + self.assertEqual( + report["migrations"], + [ + { + "module": "demo", + "path": "migration-1.toml", + "file": str(record / "migration-1.toml"), + "from": "1", + "to": "2", + "title": "Move demo artifacts to the v2 layout", + } + ], + ) + self.assertEqual(report["problems"], []) + + def test_other_toml_files_beside_the_record_are_not_migrations_and_a_bad_key_is_named(self): + record = write_module(self.skills) + (record / "roster.toml").write_text("[[members]\nnot even toml\n", encoding="utf-8") + (record / "notes.toml").write_text('migration = "not a table"\n', encoding="utf-8") + report = knowledge.collect([self.skills]) + self.assertEqual(report["migrations"], []) + self.assertEqual([problem["kind"] for problem in report["problems"]], ["migration"]) + self.assertIn("'migration' is not a table", report["problems"][0]["problem"]) + + def test_a_migration_without_its_fields_is_a_problem_not_a_listing(self): + record = write_module(self.skills) + (record / "migration-2.toml").write_text('[migration]\nmodule = "demo"\nfrom = 1\nto = "2"\n', encoding="utf-8") + report = knowledge.collect([self.skills]) + self.assertEqual(report["migrations"], []) + self.assertEqual(len(report["problems"]), 1) + self.assertEqual(report["problems"][0]["kind"], "migration") + self.assertIn( + "needs non-empty from, title, summary, detect, guide, checklist", report["problems"][0]["problem"] + ) + + def test_whitespace_only_fields_and_blank_checklist_items_are_missing(self): + record = write_module(self.skills) + text = MIGRATION.replace('guide = "move it"', 'guide = " "').replace( + 'checklist = ["it moved"]', 'checklist = [" "]' + ) + (record / "migration-3.toml").write_text(text, encoding="utf-8") + report = knowledge.collect([self.skills]) + self.assertEqual(report["migrations"], []) + self.assertIn("needs non-empty guide, checklist", report["problems"][0]["problem"]) + + def test_a_migration_for_another_module_is_a_problem(self): + record = write_module(self.skills) + text = MIGRATION.replace('module = "demo"', 'module = "other"') + (record / "migration-4.toml").write_text(text, encoding="utf-8") + report = knowledge.collect([self.skills]) + self.assertEqual(report["migrations"], []) + self.assertIn("is not this record's 'demo'", report["problems"][0]["problem"]) + + def test_migrations_are_listed_by_number_not_by_text(self): + record = write_module(self.skills) + for number in (10, 2, 1): + text = MIGRATION.replace('from = "1"', f'from = "{number}"').replace('to = "2"', f'to = "{number + 1}"') + (record / f"migration-{number}.toml").write_text(text, encoding="utf-8") + report = knowledge.collect([self.skills]) + self.assertEqual( + [m["path"] for m in report["migrations"]], ["migration-1.toml", "migration-2.toml", "migration-10.toml"] + ) + self.assertEqual(report["problems"], []) + + def test_a_migration_outside_the_naming_rule_is_a_problem(self): + record = write_module(self.skills) + for name in ("v1-v2-migration.toml", "migration-one.toml", "migration-1-2.toml"): + (record / name).write_text(MIGRATION, encoding="utf-8") + report = knowledge.collect([self.skills]) + self.assertEqual(report["migrations"], []) + self.assertEqual(len(report["problems"]), 3) + for problem in report["problems"]: + self.assertIn("must be named migration-.toml", problem["problem"]) + + def test_two_migrations_of_one_module_with_the_same_number_are_problems(self): + record = write_module(self.skills) + other = write_module(self.skills, code="other") + (record / "migration-1.toml").write_text(MIGRATION, encoding="utf-8") + (record / "migration-01.toml").write_text(MIGRATION, encoding="utf-8") + (record / "migration-2.toml").write_text(MIGRATION, encoding="utf-8") + (other / "migration-1.toml").write_text( + MIGRATION.replace('module = "demo"', 'module = "other"'), encoding="utf-8" + ) + report = knowledge.collect([self.skills]) + self.assertEqual( + [(m["module"], m["path"]) for m in report["migrations"]], + [("demo", "migration-2.toml"), ("other", "migration-1.toml")], + ) + self.assertEqual( + sorted(Path(problem["document"]).name for problem in report["problems"]), + ["migration-01.toml", "migration-1.toml"], + ) + for problem in report["problems"]: + self.assertIn("same number", problem["problem"]) + + def test_a_migration_that_is_not_toml_is_a_problem(self): + record = write_module(self.skills) + (record / "migration-5.toml").write_text('[migration\nfrom = "1"\n', encoding="utf-8") + report = knowledge.collect([self.skills]) + self.assertEqual(report["migrations"], []) + self.assertEqual([problem["kind"] for problem in report["problems"]], ["migration"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/_bmad/scripts/tests/test_memlog.py b/_bmad/scripts/tests/test_memlog.py new file mode 100644 index 0000000..81cf2d4 --- /dev/null +++ b/_bmad/scripts/tests/test_memlog.py @@ -0,0 +1,365 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = ["pytest>=8.0"] +# /// +"""Tests for memlog.py. Run: uv run --with pytest pytest scripts/tests/test_memlog.py + +The spine under test is the flat, append-only, chronological invariant: every entry is +one line recorded at the end in the order it happened — no sections, no grouping, and no +lifecycle status the log would have to mutate. +""" + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parent.parent / "memlog.py" +sys.path.insert(0, str(SCRIPT.parent)) +import memlog # noqa: E402 + +MEMLOG = ".memlog.md" + + +@pytest.fixture +def ws(tmp_path): + return str(tmp_path) + + +def read(ws): + return (Path(ws) / MEMLOG).read_text(encoding="utf-8") + + +def body_of(ws): + return memlog.split(read(ws))[1] + + +def entries(ws): + return [ln for ln in body_of(ws).splitlines() if ln.startswith("- ")] + + +def init(ws, **fields): + fields = fields or {"topic": "Reinvent the lunchbox", "goal": "ideas for a pitch"} + argv = ["init", "--workspace", ws] + for k, v in fields.items(): + argv += ["--field", f"{k}={v}"] + assert memlog.main(argv) == 0 + + +def append(ws, text, entry_type=None, by=None): + argv = ["append", "--workspace", ws, "--text", text] + if entry_type: + argv += ["--type", entry_type] + if by: + argv += ["--by", by] + assert memlog.main(argv) == 0 + + +# --- init --------------------------------------------------------------- + + +def test_init_writes_frontmatter_fields(ws): + init(ws) + meta, body = memlog.split(read(ws)) + assert meta["topic"] == "Reinvent the lunchbox" + assert meta["goal"] == "ideas for a pitch" + assert "updated" in meta + assert body.strip() == "" + + +def test_init_has_no_lifecycle_status(ws): + # A memory log carries no "status" flag; completion is an appended entry, not frontmatter. + init(ws) + meta, _ = memlog.split(read(ws)) + assert "status" not in meta + + +def test_init_arbitrary_fields(ws): + init(ws, topic="T", audience="board") + meta, _ = memlog.split(read(ws)) + assert meta["audience"] == "board" + + +def test_init_refuses_overwrite(ws): + init(ws) + assert memlog.main(["init", "--workspace", ws, "--field", "topic=other"]) == 2 + + +def test_init_creates_missing_workspace(tmp_path): + nested = str(tmp_path / "a" / "b") + assert memlog.main(["init", "--workspace", nested, "--field", "topic=T"]) == 0 + assert (Path(nested) / MEMLOG).is_file() + + +def test_init_rejects_malformed_field(ws): + assert memlog.main(["init", "--workspace", ws, "--field", "noequals"]) == 2 + + +# --- addressing: --workspace and --path are interchangeable -------------- + + +def test_path_addressing_targets_the_file_directly(tmp_path): + target = tmp_path / "run" / ".memlog.md" + assert memlog.main(["init", "--path", str(target), "--field", "topic=T"]) == 0 + assert target.is_file() + assert memlog.main(["append", "--path", str(target), "--text", "an idea", "--type", "idea"]) == 0 + body = memlog.split(target.read_text(encoding="utf-8"))[1] + assert "- (idea) an idea" in body + + +def test_workspace_and_path_resolve_to_same_file(ws): + init(ws) + via_path = str(Path(ws) / MEMLOG) + assert memlog.main(["append", "--path", via_path, "--text", "from path"]) == 0 + assert memlog.main(["append", "--workspace", ws, "--text", "from workspace"]) == 0 + assert entries(ws) == ["- from path", "- from workspace"] + + +def test_target_is_required(ws): + with pytest.raises(SystemExit): + memlog.main(["append", "--text", "orphan"]) # neither --workspace nor --path + + +# --- append: flat chronological order is the whole point ----------------- + + +def test_append_lands_at_end_in_order(ws): + init(ws) + append(ws, "first") + append(ws, "second") + append(ws, "third") + assert entries(ws) == ["- first", "- second", "- third"] + + +def test_no_sections_or_headings_ever(ws): + init(ws) + append(ws, "started foo", entry_type="technique") + append(ws, "an idea", entry_type="idea") + append(ws, "started bar", entry_type="technique") + assert "## " not in body_of(ws) # the flat log never grows headings + + +def test_type_renders_as_inline_tag(ws): + init(ws) + append(ws, "the earth revolves around the sun", entry_type="idea") + append(ws, "how do we handle stampede?", entry_type="question") + body = body_of(ws) + assert "- (idea) the earth revolves around the sun" in body + assert "- (question) how do we handle stampede?" in body + + +def test_append_without_type_is_plain_note(ws): + init(ws) + append(ws, "bare entry") + assert entries(ws) == ["- bare entry"] + + +def test_completion_is_an_entry_not_a_status(ws): + # The documented way to mark a session done: append it. Frontmatter never gains a status. + init(ws) + append(ws, "session complete", entry_type="event") + meta, _ = memlog.split(read(ws)) + assert "status" not in meta + assert entries(ws)[-1] == "- (event) session complete" + + +def test_append_collapses_newlines_into_one_line(ws): + init(ws) + append(ws, "line one\nline two\n spaced out") + assert entries(ws) == ["- line one line two spaced out"] + + +def test_revisited_technique_is_just_a_later_entry(ws): + # the user's model: switching techniques is an entry, not a section to return to + init(ws) + append(ws, "started SCAMPER", entry_type="technique") + append(ws, "magnetic latch", entry_type="idea") + append(ws, "started Six Hats", entry_type="technique") + append(ws, "stale data risk", entry_type="idea") + append(ws, "started SCAMPER", entry_type="technique") # back to SCAMPER — just appended again + append(ws, "stackable tiers", entry_type="idea") + assert entries(ws) == [ + "- (technique) started SCAMPER", + "- (idea) magnetic latch", + "- (technique) started Six Hats", + "- (idea) stale data risk", + "- (technique) started SCAMPER", + "- (idea) stackable tiers", + ] + + +def test_by_renders_attribution_in_tag(ws): + # Creative Partner mode must record whose idea each one was + init(ws) + append(ws, "magnetic latch lid", entry_type="idea", by="user") + append(ws, "lid doubles as a plate", entry_type="idea", by="coach") + body = body_of(ws) + assert "- (idea by user) magnetic latch lid" in body + assert "- (idea by coach) lid doubles as a plate" in body + + +def test_by_without_type_renders_alone(ws): + init(ws) + append(ws, "off-the-cuff thought", by="coach") + assert entries(ws) == ["- (by coach) off-the-cuff thought"] + + +def test_heterogeneous_entry_types_coexist(ws): + init(ws) + append(ws, "an idea", entry_type="idea") + append(ws, "an open question", entry_type="question") + append(ws, "a decision we made", entry_type="decision") + append(ws, "user wants mobile-first", entry_type="direction") + body = body_of(ws) + for tag in ("(idea)", "(question)", "(decision)", "(direction)"): + assert tag in body + + +def test_free_vocabulary_is_not_enforced(ws): + # The tool is neutral: any --type the host skill names renders verbatim. + init(ws) + append(ws, "a custom kind", entry_type="crack") + append(ws, "another", entry_type="lock") + body = body_of(ws) + assert "- (crack) a custom kind" in body + assert "- (lock) another" in body + + +# --- set: generic descriptive frontmatter, no lifecycle semantics -------- + + +def test_set_adds_field(ws): + init(ws) + memlog.main(["set", "--workspace", ws, "--key", "mode", "--value", "partner"]) + assert memlog.split(read(ws))[0]["mode"] == "partner" + + +def test_set_replaces_field(ws): + init(ws, topic="T", mode="facilitator") + memlog.main(["set", "--workspace", ws, "--key", "mode", "--value", "partner"]) + assert memlog.split(read(ws))[0]["mode"] == "partner" + + +def test_set_preserves_body(ws): + init(ws) + append(ws, "keep me", entry_type="idea") + memlog.main(["set", "--workspace", ws, "--key", "mode", "--value", "partner"]) + meta, body = memlog.split(read(ws)) + assert meta["mode"] == "partner" + assert "- (idea) keep me" in body + + +def test_updated_stays_last(ws): + init(ws) + memlog.main(["set", "--workspace", ws, "--key", "owner", "--value", "BMad"]) + meta = memlog.split(read(ws))[0] + assert list(meta)[-1] == "updated" + + +# --- robustness --------------------------------------------------------- + + +def test_roundtrip_render_is_stable(ws): + init(ws) + append(ws, "one", entry_type="idea") + meta, body = memlog.split(read(ws)) + assert memlog.split(memlog.render(meta, body)) == (meta, body) + + +def test_commas_in_field_survive(ws): + init(ws, topic="cars, trains, and planes") + append(ws, "z", entry_type="idea") + meta, _ = memlog.split(read(ws)) + assert meta["topic"] == "cars, trains, and planes" + + +def test_triple_dash_in_field_does_not_corrupt_frontmatter(ws): + # A `---` inside a value must NOT be read as the closing fence: topic stays intact + # and the body never leaks frontmatter text. + init(ws, topic="Pricing --- tiers --- and add-ons") + append(ws, "an idea", entry_type="idea") + meta, body = memlog.split(read(ws)) + assert meta["topic"] == "Pricing --- tiers --- and add-ons" + assert entries(ws) == ["- (idea) an idea"] + assert "topic:" not in body # frontmatter never bled into the body + + +def test_newline_in_field_is_neutralized(ws): + # A value carrying a newline can't break the fence on the next round-trip. + memlog.main(["init", "--workspace", ws, "--field", "topic=line one\nline two"]) + append(ws, "x", entry_type="idea") + meta, _ = memlog.split(read(ws)) + assert "\n" not in meta["topic"] + + +def test_append_emits_json_ack(ws, capsys): + init(ws) + append(ws, "x", entry_type="idea") + out = json.loads(capsys.readouterr().out.strip().splitlines()[-1]) + assert out["ok"] is True + assert out["entries"] == 1 + assert out["memlog"].endswith(MEMLOG) + assert "status" not in out # no lifecycle status + assert "section" not in out # sections are gone + + +def test_ack_entry_count_climbs(ws, capsys): + init(ws) + append(ws, "a") + append(ws, "b") + out = json.loads(capsys.readouterr().out.strip().splitlines()[-1]) + assert out["entries"] == 2 + + +# --- append writes only its own line ------------------------------------ + + +def test_first_entry_after_init(ws): + init(ws) + append(ws, "first", entry_type="idea") + meta, _ = memlog.split(read(ws)) + assert meta["topic"] == "Reinvent the lunchbox" + assert entries(ws) == ["- (idea) first"] + + +def test_append_does_not_restamp_updated(ws): + init(ws) + path = Path(ws) / MEMLOG + path.write_text(read(ws).replace(memlog.split(read(ws))[0]["updated"], "2000-01-01T00:00"), encoding="utf-8") + append(ws, "a") + append(ws, "b") + assert memlog.split(read(ws))[0]["updated"] == "2000-01-01T00:00" + assert entries(ws) == ["- a", "- b"] + + +def test_append_to_missing_log_fails_and_creates_nothing(ws): + with pytest.raises(FileNotFoundError): + memlog.main(["append", "--workspace", ws, "--text", "orphan"]) + assert not (Path(ws) / MEMLOG).exists() + + +def test_append_after_missing_trailing_newline_starts_new_line(ws): + init(ws) + append(ws, "first") + path = Path(ws) / MEMLOG + path.write_text(read(ws).rstrip("\n"), encoding="utf-8") + append(ws, "second") + assert entries(ws) == ["- first", "- second"] + + +def test_parallel_appends_all_land(ws): + init(ws) + n = 40 + procs = [ + subprocess.Popen( + [sys.executable, str(SCRIPT), "append", "--workspace", ws, "--text", f"entry {i}"], + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + for i in range(n) + ] + failures = [p.stderr.read().decode() for p in procs if p.wait() != 0] + assert failures == [] + assert sorted(entries(ws)) == sorted(f"- entry {i}" for i in range(n)) diff --git a/_bmad/scripts/tests/test_render_skill.py b/_bmad/scripts/tests/test_render_skill.py new file mode 100644 index 0000000..cc817ea --- /dev/null +++ b/_bmad/scripts/tests/test_render_skill.py @@ -0,0 +1,976 @@ +"""Snapshot renderer tests against the current install layout and shipped skills. + +Host skills live outside `_bmad/`. `_bmad/` is the project runtime setup +materializes: shared scripts, team config, custom overlays, and published +snapshots. Call `render()` for the success path. Use the installed CLI for +the agent-facing dispatch/HALT contract and for anything that needs a +separate process. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import tomllib +import unittest +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +from helpers import chmod + +SCRIPTS_SRC = Path(__file__).resolve().parents[1] +REPO = SCRIPTS_SRC.parents[2] +SKILLS_SRC = REPO / "skills" +CONFIG_TEMPLATE = (SCRIPTS_SRC.parent / "assets" / "config.template.toml").read_text(encoding="utf-8") +SHARED_SCRIPTS = ( + "config_utils.py", + "memlog.py", + "render_skill.py", + "resolve_config.py", + "resolve_customization.py", +) +SHIPPED_SKILLS = ("bmad-build-auto", "bmad-build", "bmad-code-review") +RENDERED_SKILLS = (*SHIPPED_SKILLS, "bmad-walkthrough", "bmad-retrospective", "bmad-qa-generate-e2e-tests") +COMPILE_TOKEN = re.compile(r"\{\{\s*(?:config|workflow)\.|\{\{\s*(?:rendered\(|initiative_folder)|\{%") +DISPATCH_PREFIX = "read and follow " + +sys.path.insert(0, str(SCRIPTS_SRC)) +import render_skill as rs # noqa: E402 + + +def _team_config(project: Path) -> str: + return CONFIG_TEMPLATE.replace("{directory_name}", project.name) + + +def _copy_skill(dest: Path, name: str) -> Path: + shutil.copytree( + SKILLS_SRC / name, + dest, + ignore=shutil.ignore_patterns("__pycache__", "*.pyc"), + ) + return dest + + +def _files(directory: Path) -> dict[str, bytes]: + files = { + path.relative_to(directory).as_posix(): path.read_bytes() for path in directory.rglob("*") if path.is_file() + } + return dict(sorted(files.items())) + + +def _markdown(directory: Path) -> str: + return "\n".join(content.decode("utf-8") for name, content in _files(directory).items() if name.endswith(".md")) + + +def _namespace_dir(project: Path, skill_name: str) -> Path: + root = str(project.resolve()) + slug = re.sub(r"[^a-z0-9]+", "-", project.name.lower()).strip("-") or "project" + slug = slug[:80].rstrip("-") or "project" + root_hash = hashlib.sha256(root.encode("utf-8")).hexdigest()[:12] + return project / "_bmad" / "render" / skill_name / f"{slug}-{root_hash}" + + +class PublishInternalsTests(unittest.TestCase): + """Corruption and reuse branches of `_publish` without rendering a whole skill.""" + + def test_identical_publish_reuses_and_rejects_each_corruption_mode(self): + with tempfile.TemporaryDirectory() as temp_dir: + dest = Path(temp_dir) / "generation" + outputs = {"workflow.md": b"hello\n"} + manifest = { + "schema_version": 1, + "outputs": {"workflow.md": rs._hash_bytes(b"hello\n")}, + } + rs._publish(dest, outputs, manifest) + rs._publish(dest, outputs, manifest) + self.assertEqual((dest / "workflow.md").read_bytes(), b"hello\n") + + with self.assertRaisesRegex(rs.RenderError, "collision or corruption"): + rs._publish(dest, outputs, {**manifest, "extra": True}) + + (dest / "Thumbs.db").write_bytes(b"stray") + rs._publish(dest, outputs, manifest) + self.assertEqual((dest / "Thumbs.db").read_bytes(), b"stray") + + (dest / "workflow.md").unlink() + with self.assertRaisesRegex( + rs.RenderError, rf"missing rendered files: workflow\.md in {re.escape(str(dest))}" + ): + rs._publish(dest, outputs, manifest) + + (dest / "workflow.md").write_bytes(b"hello\ncorrupt") + with self.assertRaisesRegex(rs.RenderError, "hash mismatch"): + rs._publish(dest, outputs, manifest) + (dest / "workflow.md").write_bytes(b"hello\n") + + (dest / "manifest.json").write_text("{", encoding="utf-8") + with self.assertRaisesRegex(rs.RenderError, "corrupt existing"): + rs._publish(dest, outputs, manifest) + + +class RenderSkillTests(unittest.TestCase): + def _workspace( + self, + *, + name: str = "project", + shared_bmad: Path | None = None, + config: str | None = None, + ) -> SimpleNamespace: + outer = Path(tempfile.mkdtemp(prefix="bmad-render-")) + self.addCleanup(shutil.rmtree, outer, True) + project = outer / name + project.mkdir(parents=True) + (project / "nested" / "cwd").mkdir(parents=True) + if shared_bmad is None: + bmad = project / "_bmad" + scripts = bmad / "scripts" + scripts.mkdir(parents=True) + for script in SHARED_SCRIPTS: + shutil.copy2(SCRIPTS_SRC / script, scripts / script) + (bmad / "custom").mkdir() + (bmad / "config.toml").write_text( + config if config is not None else _team_config(project), + encoding="utf-8", + ) + else: + (project / "_bmad").symlink_to(shared_bmad) + bmad = shared_bmad + return SimpleNamespace(outer=outer, project=project, bmad=bmad) + + def _skill(self, ws: SimpleNamespace, name: str) -> Path: + return _copy_skill(ws.outer / "skills" / name, name) + + def _cli( + self, + project: Path, + skill: Path, + *, + cwd: Path | None = None, + args: tuple[str, ...] = (), + timeout: float | None = None, + ) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + sys.executable, + str(project / "_bmad" / "scripts" / "render_skill.py"), + "--project-root", + str(project), + "--skill", + str(skill), + *args, + ], + cwd=cwd or project, + text=True, + capture_output=True, + check=False, + timeout=timeout, + ) + + def _entry(self, result: subprocess.CompletedProcess[str]) -> Path: + self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr) + lines = result.stdout.strip().split("\n") + self.assertEqual(len(lines), 1, msg=result.stdout) + self.assertTrue(lines[0].startswith(DISPATCH_PREFIX), msg=result.stdout) + output = Path(lines[0][len(DISPATCH_PREFIX) :]) + self.assertTrue(output.is_absolute()) + return output + + def _assert_rendered(self, workflow: Path, project: Path, skill_name: str) -> Path: + snap = workflow.parent + self.assertEqual(workflow.name, "workflow.md") + self.assertIn(f"{os.sep}render{os.sep}{skill_name}{os.sep}", str(workflow)) + self.assertFalse((snap / "SKILL.md").exists()) + manifest = json.loads((snap / "manifest.json").read_text(encoding="utf-8")) + self.assertEqual(manifest["project_root"], str(project.resolve())) + self.assertEqual(manifest["skill"], skill_name) + actual = _files(snap) + expected = [*manifest["outputs"], "manifest.json"] + self.assertEqual(sorted(actual), sorted(expected)) + for name, digest in manifest["outputs"].items(): + self.assertEqual(rs._hash_bytes(actual[name]), digest, name) + markdown = _markdown(snap) + self.assertIsNone(COMPILE_TOKEN.search(markdown), markdown) + self.assertNotIn("{skill-root}", markdown) + if skill_name not in ("bmad-retrospective", "bmad-qa-generate-e2e-tests"): + artifacts = (project.resolve() / "_bmad-output").as_posix() + self.assertIn(artifacts, markdown) + return snap + + def _fixture_skill(self, ws: SimpleNamespace, defaults: str, workflow: str, **sources: str) -> Path: + skill = ws.outer / "skills" / "fixture" + skill.mkdir(parents=True) + (skill / "customize.toml").write_text(defaults, encoding="utf-8") + for name, content in {"workflow.md": workflow, **sources}.items(): + (skill / name).write_text(content, encoding="utf-8") + return skill + + def _assert_halt(self, result: subprocess.CompletedProcess[str], ws: SimpleNamespace) -> None: + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + self.assertTrue(result.stdout.startswith("HALT:"), result.stdout) + self.assertEqual(len(result.stdout.splitlines()), 1, result.stdout) + self.assertNotIn("Traceback", result.stdout + result.stderr) + self.assertFalse((ws.bmad / "render").exists()) + + def test_invocation_precedence_flag_order_and_persistent_isolation(self): + ws = self._workspace() + skill = self._fixture_skill(ws, '[workflow]\nmessage = "shipped"\n', "{{ workflow.message }}\n") + project_file = ws.bmad / "custom" / "fixture.toml" + user_file = ws.bmad / "custom" / "fixture.user.toml" + override_file = ws.project / "nested" / "cwd" / "invocation.toml" + project_file.write_text('[workflow]\nmessage = "project"\n', encoding="utf-8") + user_file.write_text('[workflow]\nmessage = "user"\n', encoding="utf-8") + override_file.write_text('[workflow]\nmessage = "file"\n', encoding="utf-8") + original = { + path: path.read_bytes() for path in (project_file, user_file, override_file, skill / "customize.toml") + } + baseline = rs.render(ws.project, skill) + self.assertEqual(baseline.read_text(), "user\n") + from_file = rs.render(ws.project, skill, overrides=override_file) + self.assertEqual(from_file.read_text(), "file\n") + expected = None + for args in ( + ( + "--set", + "workflow.message=command = wins", + "--overrides", + "invocation.toml", + ), + ( + "--overrides", + "invocation.toml", + "--set", + "workflow.message=command = wins", + ), + ): + entry = self._entry(self._cli(ws.project, skill, cwd=override_file.parent, args=args)) + self.assertEqual(entry.read_text(), "command = wins\n") + if expected is not None: + self.assertEqual(entry, expected) + expected = entry + self.assertEqual(rs.render(ws.project, skill), baseline) + for path, content in original.items(): + self.assertEqual(path.read_bytes(), content) + user_file.unlink() + self.assertEqual(rs.render(ws.project, skill).read_text(), "project\n") + project_file.unlink() + self.assertEqual(rs.render(ws.project, skill).read_text(), "shipped\n") + + def test_equivalent_invocation_forms_merge_structures_and_reuse_rendered(self): + ws = self._workspace() + skill = self._fixture_skill( + ws, + '[workflow]\nfacts = ["base"]\n[workflow.details]\nlabel = "base"\nkept = "kept"\n' + '[[workflow.layers]]\nid = "a"\nname = "A"\ninstruction = "original"\n', + "{{ workflow.facts }}\n{{ workflow.details.label }} {{ workflow.details.kept }}\n{{ workflow.layers }}\n", + ) + override = ws.project / "overrides.toml" + override.write_text( + '[workflow]\nfacts = ["added"]\ndetails = { label = "changed" }\n' + 'layers = [{ id = "a", name = "Replaced", instruction = "replacement" }, ' + '{ id = "b", instruction = "new" }]\n', + encoding="utf-8", + ) + file_entry = rs.render(ws.project, skill, overrides=override) + command_entry = rs.render( + ws.project, + skill, + assignments=[ + 'workflow.facts=["added"]', + 'workflow.details={ label = "changed" }', + 'workflow.layers=[{ id = "a", name = "Replaced", instruction = "replacement" }, ' + '{ id = "b", instruction = "new" }]', + ], + ) + self.assertEqual(file_entry, command_entry) + self.assertEqual(_files(file_entry.parent), _files(command_entry.parent)) + content = command_entry.read_text() + self.assertIn("- base\n- added", content) + self.assertIn("changed kept", content) + self.assertIn("Replaced (`a`)", content) + self.assertIn("b (`b`)", content) + self.assertNotIn("original", content) + combined = rs.render(ws.project, skill, overrides=override, assignments=['workflow.facts=["command"]']) + self.assertIn("- base\n- added\n- command", combined.read_text()) + + def test_string_assignment_syntax_and_conflicting_paths_halt(self): + ws = self._workspace() + skill = self._fixture_skill(ws, '[workflow]\nmessage = "base"\n', "{{ workflow.message }}") + for assignment, expected in ( + ("workflow.message=words = more words", "words = more words"), + ('workflow.message="line\\nnext"', "line\nnext"), + ("workflow.message='literal = text'", "literal = text"), + ("workflow.message=", ""), + ): + with self.subTest(assignment=assignment): + # The nonempty shipped default deliberately rejects empty prose. + if not expected: + with self.assertRaisesRegex(rs.RenderError, "must not be empty"): + rs.render(ws.project, skill, assignments=[assignment]) + else: + self.assertEqual(rs.render(ws.project, skill, assignments=[assignment]).read_text(), expected) + for assignments in ( + ["workflow.message=first", "workflow.message=second"], + ['workflow={ message = "table" }', "workflow.message=child"], + ["workflow.message=child", 'workflow={ message = "table" }'], + ): + with self.subTest(assignments=assignments): + with self.assertRaisesRegex(rs.RenderError, "conflicts with earlier --set"): + rs.render(ws.project, skill, assignments=assignments) + + def test_undeclared_persistent_key_halts_and_unread_declared_key_renders(self): + ws = self._workspace() + skill = self._fixture_skill(ws, '[workflow]\nmessage = "base"\nunread = "base"\n', "{{ workflow.message }}") + team = ws.bmad / "custom" / f"{skill.name}.toml" + user = ws.bmad / "custom" / f"{skill.name}.user.toml" + # A declared key this render never reads is still a valid persistent override. + user.write_text('[workflow]\nunread = "changed"\n', encoding="utf-8") + self.assertEqual(rs.render(ws.project, skill).read_text(), "base") + generations = set((ws.bmad / "render" / skill.name).rglob("manifest.json")) + for layer, content, expected in ( + ( + user, + '[workflow]\nmessage = "ok"\nmesage = "typo"\n', + r"user\.toml sets keys fixture does not declare: workflow\.mesage$", + ), + ( + team, + '[[workflow.review_layers]]\nid = "stale"\nname = "Stale"\ninstruction = "x"\n', + r"workflow\.review_layers$", + ), + (team, "[workflow.message]\nnested = true\n", r"workflow\.message\.nested$"), + (team, 'message = "top level"\n', r"does not declare: message$"), + ): + with self.subTest(layer=layer.name, content=content): + layer.write_text(content, encoding="utf-8") + with self.assertRaisesRegex(rs.RenderError, expected): + rs.render(ws.project, skill) + self.assertEqual(set((ws.bmad / "render" / skill.name).rglob("manifest.json")), generations) + layer.unlink() + + @unittest.skipIf(hasattr(os, "geteuid") and os.geteuid() == 0, "root bypasses file permission bits") + def test_render_folder_that_refuses_new_files_halts(self): + ws = self._workspace() + skill = self._fixture_skill(ws, '[workflow]\nmessage = "shipped"\n', "{{ workflow.message }}\n") + namespace = _namespace_dir(ws.project, "fixture") + namespace.mkdir(parents=True) + chmod(namespace, 0o555, deny="WD,AD") + self.addCleanup(chmod, namespace, 0o755) + + # The timeout: on Windows, older Pythons' mkdtemp retried here some two + # billion times, and a hang must fail this test, not the job. + result = self._cli(ws.project, skill, timeout=60) + + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + self.assertTrue(result.stdout.startswith("HALT:"), result.stdout) + self.assertIn("denied", result.stdout.lower()) + self.assertNotIn("Traceback", result.stdout + result.stderr) + self.assertEqual(list(namespace.iterdir()), []) + + def test_invalid_invocation_halts_before_publication(self): + invalid = ( + ("--set", "workflow.message"), + ("--set", ".workflow.message=x"), + ("--set", "workflow.unknown=x"), + ("--set", 'workflow.message="unterminated'), + ("--set", "workflow.count=words"), + # Declared but never reaches a token or condition in this render. + ("--set", "workflow.count=7"), + # Consumed, so the consumer rejects the type. + ("--set", "workflow.items=7"), + ("--set", "workflow.items=[true]"), + ("--set", "workflow.count=2\nextra=3"), + ("--overrides", "missing.toml"), + ("--overrides",), + ("--set",), + ) + for args in invalid: + with self.subTest(args=args): + ws = self._workspace() + skill = self._fixture_skill( + ws, + '[workflow]\nmessage = "base"\ncount = 1\nitems = ["base"]\n', + "{{ workflow.message }} {{ workflow.items }}\n", + ) + self._assert_halt(self._cli(ws.project, skill, args=args), ws) + for content in ("[workflow", '[workflow]\nunknown="x"', "[workflow]\ncount=7", "[workflow]\nitems=7"): + with self.subTest(content=content): + ws = self._workspace() + skill = self._fixture_skill(ws, '[workflow]\ncount = 1\nitems = ["base"]\n', "{{ workflow.items }}\n") + override = ws.project / "bad.toml" + override.write_text(content, encoding="utf-8") + self._assert_halt(self._cli(ws.project, skill, args=("--overrides", str(override))), ws) + + def test_nested_conditions_omit_files_and_do_not_resolve_excluded_expressions(self): + ws = self._workspace() + skill = self._fixture_skill( + ws, + '[workflow]\nchoice = "left"\nenabled = true\n', + '{% if workflow.choice == "left" %}\nLeft {{ rendered("left.md") }}\n' + "{% if workflow.enabled %}\nEnabled\n{% else %}\n" + '{{ config.missing }} {{ workflow.missing }} {{ rendered("missing.md") }}\n{% endif %}\n' + '{% else %}\nRight {{ rendered("right.md") }}\n{% endif %}\n', + **{ + "left.md": '{% if workflow.choice == "left" %}\nLeft detail\n{% endif %}\n', + "right.md": '\n{% if workflow.choice != "left" %}\nRight detail\n{% endif %}\n', + }, + ) + left = rs.render(ws.project, skill) + before = _files(left.parent) + # Standalone tag lines leave no blank lines behind. + self.assertEqual(left.read_text(), f"Left {(left.parent / 'left.md').as_posix()}\nEnabled\n") + self.assertEqual((left.parent / "left.md").read_text(), "Left detail\n") + self.assertFalse((left.parent / "right.md").exists()) + right = rs.render(ws.project, skill, assignments=["workflow.choice=right"]) + self.assertNotEqual(left, right) + self.assertEqual(right.read_text(), f"Right {(right.parent / 'right.md').as_posix()}\n") + self.assertFalse((right.parent / "left.md").exists()) + self.assertTrue((right.parent / "right.md").exists()) + self.assertEqual(before, _files(left.parent)) + self.assertEqual(left, rs.render(ws.project, skill)) + manifest = json.loads((left.parent / "manifest.json").read_text()) + self.assertEqual( + manifest["inputs"]["resolved_values"], + { + "customization.workflow.choice": "left", + "customization.workflow.enabled": True, + }, + ) + self.assertIn("right.md", manifest["inputs"]["source_sha256"]) + self.assertNotIn("right.md", manifest["outputs"]) + (skill / "right.md").write_text((skill / "right.md").read_text().replace("Right detail", "Changed detail")) + self.assertNotEqual(left, rs.render(ws.project, skill)) + + def test_typed_scalar_condition_inputs_identify_generations_even_for_identical_output(self): + for default, condition, override in ( + ("true", "workflow.value == true", "false"), + ("1", "workflow.value == 1", "2"), + ("1.5", "workflow.value == 1.5", "2.5"), + ('"one"', 'workflow.value == "one"', '"two"'), + ("2026-09-07", 'workflow.value|string == "2026-09-07"', "2026-09-08"), + ): + with self.subTest(default=default): + ws = self._workspace() + skill = self._fixture_skill( + ws, + f"[workflow]\nvalue = {default}\n", + f"{{% if {condition} %}}\nSame\n{{% else %}}\nSame\n{{% endif %}}\n", + ) + before = rs.render(ws.project, skill) + after = rs.render(ws.project, skill, assignments=[f"workflow.value={override}"]) + self.assertEqual(before.read_bytes(), after.read_bytes()) + self.assertNotEqual(before, after) + self.assertEqual(after, rs.render(ws.project, skill, assignments=[f"workflow.value={override}"])) + + def test_loops_iterate_list_values_and_direct_insertion_keeps_markdown_forms(self): + ws = self._workspace() + skill = self._fixture_skill( + ws, + '[workflow]\nfacts = ["one", "two"]\n[[workflow.layers]]\nid = "a"\nname = "A"\n' + 'instruction = "Read {skill-root}/a.md"\n[[workflow.layers]]\nid = "b"\ninstruction = ""\n', + "{% for fact in workflow.facts %}\n* {{ fact }}\n{% endfor %}\n{{ workflow.facts }}\n" + "{% for layer in workflow.layers if layer.instruction %}\n{{ layer.id }}: {{ layer.instruction }}\n" + "{% endfor %}\n{{ workflow.layers }}\n", + ) + entry = rs.render(ws.project, skill) + snap = entry.parent + self.assertEqual( + entry.read_text(), + f"* one\n* two\n- one\n- two\na: Read {snap.as_posix()}/a.md\n#### A (`a`)\n\nRead {snap.as_posix()}/a.md\n", + ) + manifest = json.loads((snap / "manifest.json").read_text()) + self.assertEqual( + manifest["inputs"]["resolved_values"], + { + "customization.workflow.facts": ["one", "two"], + "customization.workflow.layers": [ + {"id": "a", "name": "A", "instruction": "Read {skill-root}/a.md"}, + {"id": "b", "name": "b", "instruction": ""}, + ], + }, + ) + + def test_template_errors_report_source_location_and_halt(self): + templates = ( + '{% if workflow.value == "one" %}\ntext\n', + "{% else %}\n", + "{% endif %}\n", + "{% if %}\n{% endif %}\n", + "{{ workflow.value\n", + "{{ workflow.unknown }}\n", + "{{ nothing }}\n", + "{{ workflow }}\n", + "{{ config }}\n", + "{{ config.nothing }}\n", + "{{ config.core.nothing }}\n", + "{{ workflow.items.missing }}\n", + "{% for item in workflow.value %}{{ item }}{% endfor %}\n", + "{% for item in workflow.count %}{{ item }}{% endfor %}\n", + '{{ rendered("missing.md") }}\n', + "{{ rendered(workflow.items) }}\n", + '{{ halt("rejected") }}\n', + ) + for template in templates: + with self.subTest(template=template): + ws = self._workspace() + skill = self._fixture_skill(ws, '[workflow]\nvalue = "one"\ncount = 1\nitems = []\n', f"ok\n{template}") + result = self._cli(ws.project, skill) + self._assert_halt(result, ws) + self.assertRegex(result.stdout, r"^HALT: workflow\.md:\d+: ") + ws = self._workspace() + skill = self._fixture_skill( + ws, '[workflow]\nvalue = "one"\n', "entry\n", **{"detail.md": "\n\n{{ nothing }}\n"} + ) + with self.assertRaisesRegex(rs.RenderError, r"^detail\.md:3: 'nothing' is undefined$"): + rs.render(ws.project, skill) + (skill / "detail.md").write_text("{{ workflow.missing }}\n", encoding="utf-8") + with self.assertRaisesRegex( + rs.RenderError, r"^detail\.md:1: missing customization parameter `workflow\.missing`$" + ): + rs.render(ws.project, skill) + guard = '{% if workflow.value not in ("two", "three") %}{{ halt("value must be two or three, not " ~ workflow.value) }}{% endif %}\n' + (skill / "detail.md").write_text(guard + "kept\n", encoding="utf-8") + with self.assertRaisesRegex(rs.RenderError, r"^detail\.md:1: value must be two or three, not one$"): + rs.render(ws.project, skill) + self.assertEqual( + _files(rs.render(ws.project, skill, assignments=["workflow.value=two"]).parent)["detail.md"], b"kept\n" + ) + + def test_excluded_entry_and_surviving_reference_to_excluded_file_halt(self): + for workflow in ( + "{% if workflow.enabled == false %}\nExcluded\n{% endif %}\n", + 'Read {{ rendered("detail.md") }}\n', + ): + with self.subTest(workflow=workflow): + ws = self._workspace() + skill = self._fixture_skill( + ws, + "[workflow]\nenabled = true\n", + workflow, + **{"detail.md": "{% if workflow.enabled == false %}\nExcluded\n{% endif %}\n"}, + ) + self._assert_halt(self._cli(ws.project, skill), ws) + + def test_raw_blocks_keep_agent_placeholders_verbatim(self): + ws = self._workspace() + skill = self._fixture_skill( + ws, + "[workflow]\n", + "epic: {% raw %}{{epic_number}}{% endraw +%}\nnext {% raw %}{{prev}}{% endraw %} {{ '{%' }} done\n", + ) + self.assertEqual(rs.render(ws.project, skill).read_text(), "epic: {{epic_number}}\nnext {{prev}} {% done\n") + + def test_invocation_prose_keeps_template_syntax_opaque(self): + ws = self._workspace() + skill = self._fixture_skill(ws, '[workflow]\nmessage = ""\n', "{{ workflow.message }}\n") + literal = "{% if workflow.missing %}\n{{ workflow.missing }} {{ config.missing }} {# note #}\n{% endif %}" + literal += '\n{{ rendered("missing.md") }} {skill-root}/detail.md' + entry = rs.render(ws.project, skill, assignments=[f"workflow.message={literal}"]) + self.assertEqual(entry.read_text(), literal.replace("{skill-root}", entry.parent.as_posix()) + "\n") + + def test_jinja2_version_is_part_of_the_generation_identity(self): + ws = self._workspace() + skill = self._fixture_skill(ws, "[workflow]\n", "stable\n") + original = rs.render(ws.project, skill) + manifest = json.loads((original.parent / "manifest.json").read_text()) + self.assertEqual(manifest["inputs"]["jinja2_version"], rs.jinja2.__version__) + with mock.patch.object(rs.jinja2, "__version__", "0.0.0"): + changed = rs.render(ws.project, skill) + self.assertNotEqual(changed, original) + self.assertEqual(changed.read_bytes(), original.read_bytes()) + self.assertTrue(original.exists()) + + def test_shipped_skills_publish_root_bound_snapshots(self): + for name in SHIPPED_SKILLS: + with self.subTest(name): + ws = self._workspace() + skill = self._skill(ws, name) + workflow = rs.render(ws.project, skill, assignments=["workflow.review=thorough"]) + snap = self._assert_rendered(workflow, ws.project, name) + self.assertIn("{plan_file}", _markdown(snap)) + hunter = snap / "review-prompts" / "edge-case-hunter.md" + self.assertTrue(hunter.is_file()) + self.assertIn(hunter.as_posix(), _markdown(snap)) + + def test_rendered_skills_publish_snapshots_without_skill_root(self): + for name in RENDERED_SKILLS: + with self.subTest(name): + ws = self._workspace() + skill = self._skill(ws, name) + workflow = rs.render(ws.project, skill) + self._assert_rendered(workflow, ws.project, name) + + def test_build_skills_render_the_active_initiative_branch(self): + for name in ("bmad-build", "bmad-build-auto"): + with self.subTest(name): + ws = self._workspace() + (ws.bmad / "custom" / "config.user.toml").write_text( + '[core]\nactive_initiative = "initiative-checkout"\n', encoding="utf-8" + ) + skill = self._skill(ws, name) + snap = self._assert_rendered(rs.render(ws.project, skill), ws.project, name) + markdown = _markdown(snap) + folder = (ws.project.resolve() / "_bmad-output" / "initiative-checkout").as_posix() + self.assertIn(f"{folder}/plan-{{slug}}.md", markdown) + self.assertNotIn("No initiative is active", markdown) + + def test_build_skills_render_each_pinned_route(self): + for name in ("bmad-build", "bmad-build-auto"): + for route in ("oneshot", "full"): + with self.subTest(name=name, route=route): + ws = self._workspace() + skill = self._skill(ws, name) + workflow = rs.render(ws.project, skill, assignments=[f"workflow.route={route}"]) + self._assert_rendered(workflow, ws.project, name) + + def test_build_skills_default_to_quick_review(self): + for name in ("bmad-build", "bmad-build-auto"): + with self.subTest(name): + ws = self._workspace() + skill = self._skill(ws, name) + snap = rs.render(ws.project, skill).parent + markdown = _markdown(snap) + self.assertIn("Write `review: 'quick'` and `review_source: 'pinned'`", markdown) + self.assertIn("Quick (`quick`)", markdown) + self.assertNotIn((snap / "review-prompts" / "edge-case-hunter.md").as_posix(), markdown) + + def test_skill_root_binds_bundled_scripts_to_the_installed_skill(self): + ws = self._workspace() + skill = self._skill(ws, "bmad-retrospective") + snap = self._assert_rendered(rs.render(ws.project, skill), ws.project, "bmad-retrospective") + markdown = _markdown(snap) + self.assertIn((skill / "scripts" / "git_evidence.py").as_posix(), markdown) + manifest = json.loads((snap / "manifest.json").read_text(encoding="utf-8")) + self.assertEqual(manifest["inputs"]["skill_root"], str(skill.resolve())) + elsewhere = _copy_skill(ws.outer / "elsewhere" / "bmad-retrospective", "bmad-retrospective") + other = rs.render(ws.project, elsewhere) + self.assertNotEqual(other.parent, snap) + self.assertIn((elsewhere / "scripts" / "git_evidence.py").as_posix(), _markdown(other.parent)) + + def test_cli_from_nested_cwd_dispatches_one_absolute_workflow(self): + ws = self._workspace() + skill = self._skill(ws, "bmad-build") + workflow = self._entry(self._cli(ws.project, skill, cwd=ws.project / "nested" / "cwd")) + self._assert_rendered(workflow, ws.project, "bmad-build") + self.assertFalse((ws.bmad / "scripts" / "__pycache__").exists()) + self.assertFalse((skill / "__pycache__").exists()) + + def test_identical_input_and_unreferenced_config_reuse_bytes(self): + ws = self._workspace() + skill = self._skill(ws, "bmad-build") + first = rs.render(ws.project, skill) + first_files = _files(first.parent) + self.assertEqual(rs.render(ws.project, skill), first) + with (ws.bmad / "config.toml").open("a", encoding="utf-8") as handle: + handle.write('\nunreferenced_value = "ignored"\n') + self.assertEqual(rs.render(ws.project, skill), first) + current = _files(first.parent) + for name, content in first_files.items(): + self.assertEqual(current[name], content, name) + + def test_referenced_config_and_source_changes_publish_new_generations(self): + ws = self._workspace() + skill = self._skill(ws, "bmad-build-auto") + before = rs.render(ws.project, skill) + before_files = _files(before.parent) + (ws.bmad / "custom" / "config.toml").write_text( + '[core]\noutput_folder = "{project-root}/impl-v2"\n', + encoding="utf-8", + ) + after_config = rs.render(ws.project, skill) + self.assertNotEqual(after_config, before) + self.assertIn("/impl-v2/", after_config.read_text(encoding="utf-8")) + self.assertTrue(before.exists()) + + (skill / "plan-template.md").write_text( + (skill / "plan-template.md").read_text(encoding="utf-8") + "\n\n", + encoding="utf-8", + ) + after_source = rs.render(ws.project, skill) + self.assertNotEqual(after_source, after_config) + current = _files(before.parent) + for name, content in before_files.items(): + self.assertEqual(current[name], content, name) + + def test_initiative_folder_follows_the_active_initiative_where_a_template_reaches_it(self): + ws = self._workspace() + skill = self._fixture_skill(ws, "[workflow]\n", "{{ initiative_folder }}\n") + user_config = ws.bmad / "custom" / "config.user.toml" + output_folder = (ws.project.resolve() / "_bmad-output").as_posix() + unset = rs.render(ws.project, skill) + self.assertEqual(unset.read_text(encoding="utf-8"), f"{output_folder}\n") + user_config.write_text('[core]\nactive_initiative = "initiative-checkout"\n', encoding="utf-8") + active = rs.render(ws.project, skill) + self.assertNotEqual(active, unset) + self.assertEqual(active.read_text(encoding="utf-8"), f"{output_folder}/initiative-checkout\n") + for value, message in (("42", "must be a string"), ('""', "must not be empty")): + with self.subTest(value=value): + user_config.write_text(f"[core]\nactive_initiative = {value}\n", encoding="utf-8") + with self.assertRaisesRegex(rs.RenderError, f"config.core.active_initiative {message}"): + rs.render(ws.project, skill) + + (skill / "workflow.md").write_text("plain\n", encoding="utf-8") + user_config.unlink() + unreferenced = rs.render(ws.project, skill) + user_config.write_text('[core]\nactive_initiative = "initiative-checkout"\n', encoding="utf-8") + self.assertEqual(rs.render(ws.project, skill), unreferenced) + + def test_shared_runtime_keeps_distinct_root_bound_snapshots(self): + first = self._workspace() + skill = self._skill(first, "bmad-build") + second = self._workspace(name="other", shared_bmad=first.bmad) + one = rs.render(first.project, skill) + two = rs.render(second.project, skill) + self.assertNotEqual(one, two) + self.assertIn(first.project.resolve().as_posix(), one.read_text(encoding="utf-8")) + self.assertIn(second.project.resolve().as_posix(), two.read_text(encoding="utf-8")) + + def test_concurrent_cli_renderers_reuse_one_complete_generation(self): + ws = self._workspace() + skill = self._skill(ws, "bmad-build") + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(lambda _: self._cli(ws.project, skill), range(2))) + entries = [self._entry(result) for result in results] + self.assertEqual(entries[0], entries[1]) + self.assertTrue((entries[0].parent / "manifest.json").is_file()) + + def test_malformed_config_and_customization_halt_without_traceback(self): + ws = self._workspace() + skill = self._skill(ws, "bmad-build") + (ws.bmad / "custom" / "config.toml").write_text("[core\nbad", encoding="utf-8") + result = self._cli(ws.project, skill) + self.assertNotEqual(result.returncode, 0) + self.assertTrue(result.stdout.startswith("HALT:"), result.stdout) + self.assertNotIn(DISPATCH_PREFIX, result.stdout) + self.assertNotIn("Traceback", result.stdout + result.stderr) + + (ws.bmad / "custom" / "config.toml").unlink() + (ws.bmad / "custom" / f"{skill.name}.toml").write_text("[workflow\nbad", encoding="utf-8") + result = self._cli(ws.project, skill) + self.assertNotEqual(result.returncode, 0) + self.assertIn("failed to parse", result.stdout) + + def test_missing_wrong_type_and_non_string_layer_id_halt(self): + template = _team_config(Path("project")) + missing = template.replace( + 'output_folder = "{project-root}/_bmad-output"\n', + "", + ) + ws = self._workspace(config=missing) + skill = self._skill(ws, "bmad-build") + result = self._cli(ws.project, skill) + self.assertIn("missing config value", result.stdout) + + wrong = template.replace( + 'output_folder = "{project-root}/_bmad-output"', + "output_folder = 42", + ) + ws = self._workspace(config=wrong) + skill = self._skill(ws, "bmad-build") + result = self._cli(ws.project, skill) + self.assertIn("must be a string", result.stdout) + + ws = self._workspace() + skill = self._skill(ws, "bmad-build") + (ws.bmad / "custom" / f"{skill.name}.toml").write_text( + '[[workflow.thorough_lenses]]\nid = 42\nname = "bad"\ninstruction = "bad"\n', + encoding="utf-8", + ) + result = self._cli(ws.project, skill) + self.assertIn("identifier `id` must be a string", result.stdout) + + def test_customization_prose_is_not_rescanned_as_source_tokens(self): + ws = self._workspace() + skill = self._skill(ws, "bmad-build") + literal = '{{ rendered("step-04-review.md") }}' + compile_literal = "{{ workflow.implementation_handoff }}" + (ws.bmad / "custom" / f"{skill.name}.user.toml").write_text( + f"[workflow]\non_complete = 'Preserve {literal} and {compile_literal} as prose'\n", + encoding="utf-8", + ) + markdown = _markdown(rs.render(ws.project, skill).parent) + self.assertIn(literal, markdown) + self.assertIn(compile_literal, markdown) + + def test_review_layer_override_guard_and_empty_layer_halt(self): + ws = self._workspace() + skill = self._skill(ws, "bmad-build") + (ws.bmad / "custom" / f"{skill.name}.toml").write_text( + "\n".join( + [ + "[workflow]", + 'review = "thorough"', + "[[workflow.thorough_lenses]]", + 'id = "blind-hunter"', + 'name = "Replacement"', + 'instruction = "Run replacement review."', + 'when = "the replacement condition holds"', + "", + ] + ), + encoding="utf-8", + ) + review = (rs.render(ws.project, skill).parent / "step-04-review.md").read_text(encoding="utf-8") + self.assertIn("Replacement (`blind-hunter`)", review) + self.assertIn("Run only when: the replacement condition holds", review) + self.assertIn("Run replacement review.", review) + + defaults = tomllib.loads((skill / "customize.toml").read_text(encoding="utf-8")) + disabled = '[workflow]\nreview = "thorough"\n' + "\n".join( + f'[[workflow.thorough_lenses]]\nid = "{layer["id"]}"\nname = "disabled"\ninstruction = ""\n' + for layer in defaults["workflow"]["thorough_lenses"] + ) + (ws.bmad / "custom" / f"{skill.name}.toml").write_text(disabled, encoding="utf-8") + review = (rs.render(ws.project, skill).parent / "step-04-review.md").read_text(encoding="utf-8") + self.assertIn("No active review layers. HALT", review) + + def test_non_empty_open_plan_override_reaches_both_terminal_routes(self): + ws = self._workspace() + skill = self._skill(ws, "bmad-build") + (ws.bmad / "custom" / f"{skill.name}.user.toml").write_text( + '[workflow]\nopen_plan = "OPEN-PLAN-SENTINEL {project-root} {plan_file}"\n', + encoding="utf-8", + ) + snap = rs.render(ws.project, skill).parent + for name in ("step-05-present.md", "step-oneshot.md"): + rendered = (snap / name).read_text(encoding="utf-8") + self.assertIn("OPEN-PLAN-SENTINEL {project-root} {plan_file}", rendered) + + def test_installed_renderer_identity_change_publishes_a_new_generation(self): + ws = self._workspace() + skill = self._skill(ws, "bmad-build") + original = self._entry(self._cli(ws.project, skill)) + with (ws.bmad / "scripts" / "render_skill.py").open("a", encoding="utf-8") as handle: + handle.write("\n# renderer identity change\n") + changed = self._entry(self._cli(ws.project, skill)) + self.assertNotEqual(changed, original) + self.assertTrue(original.exists()) + + def test_convention_only_skill_renders_without_customization(self): + ws = self._workspace() + skill = ws.outer / "skills" / "plain-workflow" + skill.mkdir(parents=True) + (skill / "workflow.md").write_text('Read `{{ rendered("step.md") }}`.\n', encoding="utf-8") + (skill / "step.md").write_text("No rendered values required.\n", encoding="utf-8") + workflow = rs.render(ws.project, skill) + self.assertIn(f"{os.sep}render{os.sep}plain-workflow{os.sep}", str(workflow)) + self.assertTrue((workflow.parent / "step.md").is_file()) + self.assertIn((workflow.parent / "step.md").as_posix(), workflow.read_text(encoding="utf-8")) + + def test_ambiguous_shorthand_and_source_symlink_escape_halt(self): + config = _team_config(Path("project")) + '\n[modules.bmm]\noutput_folder = "{project-root}/dup"\n' + ws = self._workspace(config=config) + skill = self._skill(ws, "bmad-build") + result = self._cli(ws.project, skill) + self.assertIn("ambiguous config value", result.stdout) + + ws = self._workspace() + skill = self._skill(ws, "bmad-build") + outside = ws.outer / "outside.md" + outside.write_text("outside\n", encoding="utf-8") + (skill / "workflow.md").unlink() + (skill / "workflow.md").symlink_to(outside) + result = self._cli(ws.project, skill) + self.assertIn("escapes skill directory", result.stdout) + + def test_long_project_basename_is_bounded_in_the_snapshot_namespace(self): + ws = self._workspace(name="project-" + ("x" * 220)) + skill = self._skill(ws, "bmad-build") + workflow = rs.render(ws.project, skill) + self.assertLessEqual(len(workflow.parent.parent.name), 93) + + def test_snapshot_paths_stay_opaque_when_the_project_name_looks_like_tokens(self): + ws = self._workspace(name="{{ workflow.on_complete }}-{{ config.output_folder }}") + skill = self._skill(ws, "bmad-build") + workflow = rs.render(ws.project, skill) + text = workflow.read_text(encoding="utf-8") + match = re.search(r"`([^`]*step-01-clarify-and-route\.md)`", text) + self.assertIsNotNone(match, text) + self.assertTrue(match.group(1).startswith(ws.project.resolve().as_posix())) + self.assertTrue(Path(match.group(1)).is_file()) + + def test_publication_failure_does_not_dispatch_or_alter_another_root(self): + stable = self._workspace() + skill = self._skill(stable, "bmad-build") + original = rs.render(stable.project, skill) + original_files = _files(original.parent) + broken = self._workspace(name="broken", shared_bmad=stable.bmad) + namespace = _namespace_dir(broken.project, skill.name) + namespace.parent.mkdir(parents=True, exist_ok=True) + namespace.write_text("not a directory\n", encoding="utf-8") + result = self._cli(broken.project, skill) + self.assertNotEqual(result.returncode, 0) + self.assertTrue(result.stdout.startswith("HALT:"), result.stdout) + self.assertNotIn(DISPATCH_PREFIX, result.stdout) + current = _files(original.parent) + for name, content in original_files.items(): + self.assertEqual(current[name], content, name) + + def test_corrupt_existing_destination_is_never_overwritten(self): + ws = self._workspace() + skill = self._skill(ws, "bmad-build") + workflow = rs.render(ws.project, skill) + workflow.write_text(workflow.read_text(encoding="utf-8") + "corrupt", encoding="utf-8") + result = self._cli(ws.project, skill) + self.assertNotEqual(result.returncode, 0) + self.assertIn("hash mismatch", result.stdout) + self.assertTrue(workflow.read_text(encoding="utf-8").endswith("corrupt")) + + def test_stray_file_in_generation_is_ignored_and_missing_output_is_named(self): + ws = self._workspace() + skill = self._skill(ws, "bmad-build") + workflow = rs.render(ws.project, skill) + stray = workflow.parent / "Thumbs.db" + stray.write_bytes(b"explorer") + result = self._cli(ws.project, skill) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(result.stdout, f"{DISPATCH_PREFIX}{workflow}\n") + self.assertEqual(stray.read_bytes(), b"explorer") + + workflow.unlink() + result = self._cli(ws.project, skill) + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + self.assertEqual(len(result.stdout.splitlines()), 1, result.stdout) + self.assertNotIn("Traceback", result.stdout + result.stderr) + self.assertIn(f"missing rendered files: workflow.md in {workflow.parent}", result.stdout) + self.assertIn("deleting that folder is safe", result.stdout) + self.assertFalse(workflow.exists()) + + def test_skill_md_command_dispatches_for_every_rendered_skill(self): + for name in RENDERED_SKILLS: + with self.subTest(name): + ws = self._workspace() + skill = self._skill(ws, name) + text = (skill / "SKILL.md").read_text(encoding="utf-8") + fenced = re.search(r"```bash\n(.*?)```", text, re.S) + self.assertIsNotNone(fenced, f"{name}: SKILL.md ships no bash command") + command = ( + fenced.group(1) + .strip() + .replace("{project-root}", str(ws.project)) + .replace("{skill-root}", str(skill)) + ) + self.assertNotIn("{", command) + dispatched = self._entry( + subprocess.run( + command, + cwd=ws.project / "nested" / "cwd", + shell=True, + text=True, + capture_output=True, + check=False, + ) + ) + self.assertEqual(dispatched.name, "workflow.md") + self.assertTrue(dispatched.is_file()) + + +if __name__ == "__main__": + unittest.main() diff --git a/_bmad/scripts/tests/test_resolve_config.py b/_bmad/scripts/tests/test_resolve_config.py new file mode 100644 index 0000000..38b96bb --- /dev/null +++ b/_bmad/scripts/tests/test_resolve_config.py @@ -0,0 +1,109 @@ +import json +import os +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +SCRIPT = Path(__file__).resolve().parents[1] / "resolve_config.py" + + +class ResolveConfigCliTests(unittest.TestCase): + def test_missing_tomllib_exits_with_actionable_version_error(self): + with tempfile.TemporaryDirectory() as temp_dir: + scripts = Path(temp_dir) + shutil.copy2(SCRIPT, scripts / SCRIPT.name) + shutil.copy2(SCRIPT.parent / "config_utils.py", scripts / "config_utils.py") + (scripts / "tomllib.py").write_text( + 'raise ModuleNotFoundError("No module named tomllib", name="tomllib")\n', + encoding="utf-8", + ) + + result = subprocess.run( + [sys.executable, str(scripts / SCRIPT.name), "--help"], + text=True, + capture_output=True, + check=False, + ) + + self.assertEqual(result.returncode, 3) + self.assertEqual( + result.stderr, + "error: Python 3.11+ is required (stdlib `tomllib` not found).\n", + ) + self.assertNotIn("Traceback", result.stderr) + + def test_full_and_repeated_key_output_follow_layer_precedence(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + custom = root / "_bmad" / "custom" + custom.mkdir(parents=True) + (root / "_bmad" / "config.toml").write_text('[core]\nname = "base"\nkeep = "yes"\n', encoding="utf-8") + (root / "_bmad" / "config.user.toml").write_text( + '[core]\nname = "base-user"\nstray = "ignored"\n', encoding="utf-8" + ) + (custom / "config.toml").write_text('[core]\nname = "team"\n', encoding="utf-8") + (custom / "config.user.toml").write_text('[core]\nname = "user"\n', encoding="utf-8") + + full = self._run(root) + self.assertEqual(full.returncode, 0, msg=full.stderr) + # _bmad/config.user.toml is old-installer debris, not a layer: + # its `stray` key never reaches the merge. + self.assertEqual(json.loads(full.stdout)["core"], {"name": "user", "keep": "yes"}) + + keyed = self._run(root, "--key", "core.name", "--key", "missing") + self.assertEqual(keyed.returncode, 0, msg=keyed.stderr) + self.assertEqual(json.loads(keyed.stdout), {"core.name": "user"}) + + def test_malformed_present_layer_fails(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + custom = root / "_bmad" / "custom" + custom.mkdir(parents=True) + (root / "_bmad" / "config.toml").write_text("[core]\nvalid = true\n", encoding="utf-8") + (custom / "config.toml").write_text("[broken\n", encoding="utf-8") + + result = self._run(root) + self.assertNotEqual(result.returncode, 0) + self.assertIn("failed to parse", result.stderr) + + def test_writes_emoji_json_when_stdout_encoding_is_cp1252(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + (root / "_bmad").mkdir(parents=True) + (root / "_bmad" / "config.toml").write_text( + '[agents]\nname = "Analyst"\nicon = "📊"\n', + encoding="utf-8", + ) + + env = os.environ.copy() + env["PYTHONIOENCODING"] = "cp1252" + result = subprocess.run( + [sys.executable, str(SCRIPT), "--project-root", str(root)], + capture_output=True, + env=env, + check=False, + ) + + stderr = result.stderr.decode("utf-8", errors="replace") + self.assertEqual(result.returncode, 0, msg=stderr) + + output = result.stdout.decode("utf-8") + self.assertIn("📊", output) + resolved = json.loads(output) + self.assertEqual(resolved["agents"]["icon"], "📊") + + @staticmethod + def _run(root: Path, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(SCRIPT), "--project-root", str(root), *args], + text=True, + capture_output=True, + check=False, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/_bmad/scripts/tests/test_resolve_customization.py b/_bmad/scripts/tests/test_resolve_customization.py new file mode 100644 index 0000000..55f44e2 --- /dev/null +++ b/_bmad/scripts/tests/test_resolve_customization.py @@ -0,0 +1,175 @@ +import json +import os +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +SCRIPT = Path(__file__).resolve().parents[1] / "resolve_customization.py" + + +class ResolveCustomizationStdoutTests(unittest.TestCase): + def test_missing_tomllib_exits_with_actionable_version_error(self): + with tempfile.TemporaryDirectory() as temp_dir: + scripts = Path(temp_dir) + shutil.copy2(SCRIPT, scripts / SCRIPT.name) + shutil.copy2(SCRIPT.parent / "config_utils.py", scripts / "config_utils.py") + (scripts / "tomllib.py").write_text( + 'raise ModuleNotFoundError("No module named tomllib", name="tomllib")\n', + encoding="utf-8", + ) + + result = subprocess.run( + [sys.executable, str(scripts / SCRIPT.name), "--help"], + text=True, + capture_output=True, + check=False, + ) + + self.assertEqual(result.returncode, 3) + self.assertEqual( + result.stderr, + "error: Python 3.11+ is required (stdlib `tomllib` not found).\n", + ) + self.assertNotIn("Traceback", result.stderr) + + def test_writes_emoji_json_when_stdout_encoding_is_cp1252(self): + with tempfile.TemporaryDirectory() as temp_dir: + skill_dir = Path(temp_dir) / "emoji-agent" + skill_dir.mkdir() + (skill_dir / "customize.toml").write_text( + '[agent]\nname = "Emoji Agent"\nicon = "🧭"\n', + encoding="utf-8", + ) + + env = os.environ.copy() + env["PYTHONIOENCODING"] = "cp1252" + result = subprocess.run( + [ + sys.executable, + str(SCRIPT), + "--skill", + str(skill_dir), + "--key", + "agent", + ], + capture_output=True, + cwd=temp_dir, + env=env, + check=False, + ) + + stderr = result.stderr.decode("utf-8", errors="replace") + self.assertEqual(result.returncode, 0, msg=stderr) + + output = result.stdout.decode("utf-8") + self.assertIn("🧭", output) + resolved = json.loads(output) + self.assertEqual(resolved["agent"]["icon"], "🧭") + + +def write(path: Path, body: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + + +def facts(*entries: str) -> str: + listed = ", ".join(f'"{entry}"' for entry in entries) + return f"[workflow]\npersistent_facts = [{listed}]\n" + + +def resolve(skill_dir: Path, cwd: Path, *extra: str): + return subprocess.run( + [sys.executable, str(SCRIPT), "--skill", str(skill_dir), "--key", "workflow", *extra], + text=True, + capture_output=True, + cwd=str(cwd), + check=False, + ) + + +class ProjectRootResolutionTests(unittest.TestCase): + """Regression cover for #2796 — the project is where the user works, not + where the skill is installed.""" + + def test_home_installed_skill_reads_project_override_not_home_bmad(self): + with tempfile.TemporaryDirectory() as temp_dir: + home = Path(temp_dir) / "home" + project = Path(temp_dir) / "project" + skill = home / ".claude" / "skills" / "demo-skill" + write(skill / "customize.toml", facts("shipped default")) + (home / "_bmad" / "custom").mkdir(parents=True) + write(project / "_bmad" / "custom" / "demo-skill.toml", facts("team override")) + + result = resolve(skill, project) + + self.assertEqual(result.returncode, 0, msg=result.stderr) + resolved = json.loads(result.stdout)["workflow"]["persistent_facts"] + self.assertEqual(resolved, ["shipped default", "team override"]) + + def test_project_installed_skill_still_resolves(self): + with tempfile.TemporaryDirectory() as temp_dir: + project = Path(temp_dir) / "project" + skill = project / ".claude" / "skills" / "demo-skill" + write(skill / "customize.toml", facts("shipped default")) + write(project / "_bmad" / "custom" / "demo-skill.toml", facts("team override")) + + result = resolve(skill, project) + + self.assertEqual(result.returncode, 0, msg=result.stderr) + resolved = json.loads(result.stdout)["workflow"]["persistent_facts"] + self.assertEqual(resolved, ["shipped default", "team override"]) + + def test_walk_prefers_bmad_over_a_nearer_git_directory(self): + with tempfile.TemporaryDirectory() as temp_dir: + project = Path(temp_dir) / "project" + submodule = project / "vendor" / "sub" + skill = Path(temp_dir) / "skills" / "demo-skill" + write(skill / "customize.toml", facts("shipped default")) + write(project / "_bmad" / "custom" / "demo-skill.toml", facts("team override")) + (submodule / ".git").mkdir(parents=True) + + result = resolve(skill, submodule) + + self.assertEqual(result.returncode, 0, msg=result.stderr) + resolved = json.loads(result.stdout)["workflow"]["persistent_facts"] + self.assertEqual(resolved, ["shipped default", "team override"]) + + def test_notes_when_a_rejected_root_holds_the_only_override(self): + with tempfile.TemporaryDirectory() as temp_dir: + home = Path(temp_dir) / "home" + project = Path(temp_dir) / "project" + skill = home / ".claude" / "skills" / "demo-skill" + write(skill / "customize.toml", facts("shipped default")) + write(home / "_bmad" / "custom" / "demo-skill.toml", facts("home override")) + (project / "_bmad" / "custom").mkdir(parents=True) + + result = resolve(skill, project) + + self.assertEqual(result.returncode, 0, msg=result.stderr) + resolved = json.loads(result.stdout)["workflow"]["persistent_facts"] + self.assertEqual(resolved, ["shipped default"]) + self.assertIn("demo-skill", result.stderr) + self.assertIn("--project-root", result.stderr) + + def test_explicit_project_root_wins_and_stays_quiet(self): + with tempfile.TemporaryDirectory() as temp_dir: + home = Path(temp_dir) / "home" + project = Path(temp_dir) / "project" + skill = home / ".claude" / "skills" / "demo-skill" + write(skill / "customize.toml", facts("shipped default")) + write(home / "_bmad" / "custom" / "demo-skill.toml", facts("home override")) + (project / "_bmad" / "custom").mkdir(parents=True) + + result = resolve(skill, project, "--project-root", str(home)) + + self.assertEqual(result.returncode, 0, msg=result.stderr) + resolved = json.loads(result.stdout)["workflow"]["persistent_facts"] + self.assertEqual(resolved, ["shipped default", "home override"]) + self.assertEqual(result.stderr, "") + + +if __name__ == "__main__": + unittest.main() diff --git a/_bmad/scripts/tests/test_roster.py b/_bmad/scripts/tests/test_roster.py new file mode 100644 index 0000000..7788468 --- /dev/null +++ b/_bmad/scripts/tests/test_roster.py @@ -0,0 +1,226 @@ +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import roster # noqa: E402 + +SOURCE = "github:acme/tools/skills" +ROSTER = """ +[[members]] +code = "demo-agent-ann" +skill = "demo-agent-ann" +name = "Ann" +icon = "A" +title = "Analyst" +persona = "Asks for evidence." + +[[members]] +code = "demo-agent-bob" +skill = "demo-agent-bob" +name = "Bob" +persona = "Builds it." + +[[members]] +code = "guest" +name = "Guest" +persona = "Only in groups." + +[[groups]] +id = "demo-room" +name = "Demo Room" +members = ["demo-agent-ann", "demo-agent-bob", "guest"] +""" + + +def write_skill(root: Path, name: str, *, bmod: str = "bmod-demo") -> Path: + folder = root / name + folder.mkdir(parents=True) + (folder / "bmod.toml").write_text(f'[skill]\nbmod = "{bmod}"\nsource = "{SOURCE}"\n', encoding="utf-8") + return folder + + +def write_module( + root: Path, + skills: tuple[str, ...] = (), + *, + code: str = "demo", + folder: str | None = None, + listed: tuple[str, ...] | None = None, + roster_text: str | None = ROSTER, +) -> Path: + """Write a module record and the member skill folders in `skills`; `listed` is what the record names.""" + record = root / (folder or f"bmod-{code}") + record.mkdir(parents=True) + names = ", ".join(f'"{name}"' for name in (skills if listed is None else listed)) + lines = ["[bmod]", f'code = "{code}"', 'version = "1.0.0"', f'update_source = "{SOURCE}"', f"skills = [{names}]"] + if roster_text is not None: + (record / "roster.toml").write_text(roster_text, encoding="utf-8") + (record / "bmod.toml").write_text("\n".join(lines) + "\n", encoding="utf-8") + for name in skills: + write_skill(root, name, bmod=record.name) + return record + + +class RosterTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.project = Path(self.temp.name) + self.skills = self.project / ".claude" / "skills" + + def test_only_members_whose_skill_is_installed_are_agents(self): + write_module(self.skills, ("demo-agent-ann",), listed=("demo-agent-ann", "demo-agent-bob")) + (self.skills / "demo-agent-ann" / "customize.toml").write_text('[agent]\nname = "Ann"\n', encoding="utf-8") + report = roster.collect([self.skills]) + self.assertEqual(list(report["agents"]), ["demo-agent-ann"]) + self.assertEqual(set(report["members"]), {"demo-agent-ann", "demo-agent-bob", "guest"}) + self.assertNotIn("installed", report["members"]["guest"]) + + def test_an_absent_agent_keeps_its_persona_and_names_its_install_command(self): + write_module(self.skills, ("demo-workflow",)) + bob = roster.collect([self.skills])["members"]["demo-agent-bob"] + self.assertFalse(bob["installed"]) + self.assertEqual(bob["persona"], "Builds it.") + self.assertEqual(bob["install"], "npx skills add acme/tools --skill demo-agent-bob") + + def test_an_installed_agent_answers_to_its_customized_name(self): + write_module(self.skills, ("demo-agent-ann",)) + (self.skills / "demo-agent-ann" / "customize.toml").write_text( + '[agent]\nname = "Ann"\ntitle = "Analyst"\n', encoding="utf-8" + ) + custom = self.project / "_bmad" / "custom" + custom.mkdir(parents=True) + (custom / "demo-agent-ann.toml").write_text('[agent]\nname = "Annika"\n', encoding="utf-8") + agent = roster.collect([self.skills], self.project)["agents"]["demo-agent-ann"] + self.assertEqual((agent["name"], agent["title"], agent["persona"]), ("Annika", "Analyst", "Asks for evidence.")) + + def test_a_roster_is_read_from_the_record_and_lists_the_installed_member_skills(self): + write_module(self.skills, ("demo-one", "demo-two"), listed=("demo-one", "demo-two", "demo-absent")) + report = roster.collect([self.skills]) + self.assertEqual( + report["rosters"], [{"module": "demo", "path": "roster.toml", "skills": ["demo-one", "demo-two"]}] + ) + self.assertEqual([group["id"] for group in report["groups"]], ["demo-room"]) + self.assertEqual(report["problems"], []) + + def test_a_module_with_no_skills_still_offers_its_roster(self): + write_module(self.skills) + report = roster.collect([self.skills]) + self.assertEqual(report["rosters"], [{"module": "demo", "path": "roster.toml", "skills": []}]) + self.assertEqual(set(report["members"]), {"demo-agent-ann", "demo-agent-bob", "guest"}) + self.assertEqual((report["agents"], report["problems"]), ({}, [])) + + def test_a_single_skill_module_is_its_own_member(self): + folder = self.skills / "demo-agent-ann" + folder.mkdir(parents=True) + (folder / "bmod.toml").write_text( + f'[bmod]\ncode = "solo"\nversion = "1.0.0"\nupdate_source = "{SOURCE}"\n\n[skill]\n', + encoding="utf-8", + ) + (folder / "roster.toml").write_text(ROSTER, encoding="utf-8") + report = roster.collect([self.skills]) + self.assertEqual(report["rosters"], [{"module": "solo", "path": "roster.toml", "skills": ["demo-agent-ann"]}]) + self.assertEqual(list(report["agents"]), ["demo-agent-ann"]) + self.assertEqual(report["problems"], []) + + def test_a_record_is_found_whatever_its_folder_is_called(self): + write_module(self.skills, ("demo-agent-ann",), folder="demo-record") + report = roster.collect([self.skills]) + self.assertEqual(list(report["agents"]), ["demo-agent-ann"]) + self.assertEqual(report["problems"], []) + + def test_a_second_record_for_a_code_is_a_problem_and_the_first_wins(self): + write_module(self.skills, ("demo-one",), folder="a-record") + write_module(self.skills, ("demo-two",), folder="b-record", roster_text='[[members]]\ncode = "late"\n') + report = roster.collect([self.skills]) + self.assertNotIn("late", report["members"]) + self.assertEqual([found["skills"] for found in report["rosters"]], [["demo-one"]]) + self.assertEqual( + [(problem["kind"], problem["skill"]) for problem in report["problems"]], [("module", "b-record")] + ) + + def test_a_member_skill_whose_record_is_absent_is_a_problem(self): + write_skill(self.skills, "demo-agent-ann") + report = roster.collect([self.skills]) + self.assertEqual((report["agents"], report["members"], report["rosters"]), ({}, {}, [])) + (problem,) = report["problems"] + self.assertEqual( + (problem["kind"], problem["bmod"], problem["skills"]), ("module", "bmod-demo", ["demo-agent-ann"]) + ) + self.assertEqual(problem["install"], "npx skills add acme/tools --skill bmod-demo") + + def test_a_second_module_cannot_redefine_a_member_or_group(self): + write_module(self.skills, ("demo-one",)) + write_module(self.skills, ("other-one",), code="other") + report = roster.collect([self.skills]) + self.assertEqual(report["members"]["guest"]["module"], "demo") + self.assertEqual( + sorted(problem["kind"] for problem in report["problems"]), ["group", "member", "member", "member"] + ) + + def test_central_config_agents_are_laid_over_the_scan(self): + write_module(self.skills, ("demo-workflow",)) + (self.project / "_bmad").mkdir() + (self.project / "_bmad" / "config.toml").write_text( + '[agents.my-agent]\nname = "Mine"\ndescription = "From before rosters."\n', encoding="utf-8" + ) + agent = roster.collect([self.skills], self.project)["agents"]["my-agent"] + self.assertEqual((agent["name"], agent["persona"], agent["source"]), ("Mine", "From before rosters.", "config")) + + def test_a_recorded_agent_whose_skill_was_removed_stays_out_of_the_default_room(self): + write_module(self.skills, ("demo-agent-ann",)) + (self.project / "_bmad").mkdir() + (self.project / "_bmad" / "config.toml").write_text( + '[agents.demo-agent-ann]\nteam = "crew"\n\n[agents.demo-agent-bob]\nname = "Bob"\n', encoding="utf-8" + ) + agents = roster.collect([self.skills], self.project)["agents"] + self.assertEqual(sorted(agents), ["demo-agent-ann"]) + self.assertEqual(agents["demo-agent-ann"]["team"], "crew") + + def test_config_does_not_replace_a_roster_agents_name_or_module(self): + write_module(self.skills, ("demo-agent-ann",)) + (self.project / "_bmad").mkdir() + (self.project / "_bmad" / "config.toml").write_text( + '[agents.demo-agent-ann]\nmodule = "old"\nname = "Recorded"\nteam = "crew"\n', encoding="utf-8" + ) + agent = roster.collect([self.skills], self.project)["agents"]["demo-agent-ann"] + self.assertEqual((agent["module"], agent["team"]), ("demo", "crew")) + self.assertNotEqual(agent["name"], "Recorded") + + def test_a_roster_key_in_the_record_is_ignored(self): + record = write_module(self.skills, ("demo-one",), roster_text=None) + text = (record / "bmod.toml").read_text(encoding="utf-8") + (record / "bmod.toml").write_text(text + 'roster = ["other.toml"]\n', encoding="utf-8") + (record / "other.toml").write_text(ROSTER, encoding="utf-8") + report = roster.collect([self.skills]) + self.assertEqual((report["members"], report["rosters"], report["problems"]), ({}, [], [])) + + def test_a_roster_that_links_outside_the_record_folder_is_refused(self): + record = write_module(self.skills, ("demo-one",)) + (self.skills / "demo-one" / "elsewhere.toml").write_text(ROSTER, encoding="utf-8") + (record / "roster.toml").unlink() + try: + (record / "roster.toml").symlink_to(self.skills / "demo-one" / "elsewhere.toml") + except OSError: + self.skipTest("symlinks are not available") + report = roster.collect([self.skills]) + self.assertEqual(report["members"], {}) + self.assertEqual([problem["kind"] for problem in report["problems"]], ["roster"]) + + def test_members_or_groups_that_are_not_lists_are_problems(self): + write_module(self.skills, ("demo-one",), roster_text='members = "ann"\n\n[groups]\nid = "room"\n') + report = roster.collect([self.skills]) + self.assertEqual((report["members"], report["groups"]), ({}, [])) + self.assertEqual([problem["kind"] for problem in report["problems"]], ["roster", "roster"]) + + def test_a_module_without_a_roster_offers_nothing(self): + write_module(self.skills, ("demo-one",), roster_text=None) + report = roster.collect([self.skills]) + self.assertEqual((report["agents"], report["groups"], report["problems"]), ({}, [], [])) + + +if __name__ == "__main__": + unittest.main() diff --git a/_bmad/scripts/tests/test_setup_check.py b/_bmad/scripts/tests/test_setup_check.py new file mode 100644 index 0000000..a8fc4e3 --- /dev/null +++ b/_bmad/scripts/tests/test_setup_check.py @@ -0,0 +1,237 @@ +import io +import sys +import tempfile +import unittest +from contextlib import redirect_stderr +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import setup_check # noqa: E402 + +SOURCE = "github:bmad-code-org/BMAD-METHOD/skills" +REQUIRES_BMAD = f'required_skills = [{{ skill = "bmad", version = "6.13.0", source = "{SOURCE}" }}]\n' + + +def write_bmod(folder: Path, *, code: str, version: str, skills: list[str], extra: str = "") -> None: + folder.mkdir(parents=True, exist_ok=True) + listed = ", ".join(f'"{name}"' for name in skills) + (folder / "bmod.toml").write_text( + f'[bmod]\ncode = "{code}"\nversion = "{version}"\nupdate_source = "{SOURCE}"\nskills = [{listed}]\n{extra}', + encoding="utf-8", + ) + + +def write_skill(folder: Path, *, bmod: str, source: str = SOURCE, extra: str = "") -> None: + folder.mkdir(parents=True, exist_ok=True) + (folder / "bmod.toml").write_text(f'[skill]\nbmod = "{bmod}"\nsource = "{source}"\n{extra}', encoding="utf-8") + + +class OwedSetupTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.project = Path(self.temp.name) / "project" + self.skills = self.project / ".claude" / "skills" + (self.project / "_bmad").mkdir(parents=True) + (self.project / "_bmad" / "config.toml").write_text("[core]\n", encoding="utf-8") + + def hub(self, version: str) -> None: + write_bmod(self.skills / "bmod-core-tools", code="core-tools", version=version, skills=["bmad"]) + write_skill(self.skills / "bmad", bmod="bmod-core-tools") + + def skill(self, *, module_extra: str = "", skill_extra: str = "") -> Path: + write_bmod(self.skills / "bmod-demo", code="demo", version="1.0.0", skills=["demo-skill"], extra=module_extra) + folder = self.skills / "demo-skill" + write_skill(folder, bmod="bmod-demo", extra=skill_extra) + return folder + + def test_nothing_is_owed_when_requirements_are_met(self): + self.hub("6.13.0") + folder = self.skill(module_extra=REQUIRES_BMAD) + self.assertEqual(setup_check.owed(folder, self.project), []) + + def test_a_skill_without_a_bmod_file_owes_nothing(self): + folder = self.skills / "plain-skill" + folder.mkdir(parents=True) + self.assertEqual(setup_check.owed(folder, self.project), []) + + def test_a_module_record_owes_nothing(self): + self.skill(module_extra=REQUIRES_BMAD) + self.assertEqual(setup_check.owed(self.skills / "bmod-demo", self.project), []) + + def test_a_missing_module_record_is_named_with_its_install_command(self): + folder = self.skills / "demo-skill" + write_skill(folder, bmod="bmod-demo", source="github:acme/demo/skills") + (note,) = setup_check.owed(folder, self.project) + self.assertIn("`bmod-demo`", note) + self.assertIn("`npx skills add acme/demo --skill bmod-demo`", note) + + def test_a_skill_without_its_record_still_reports_its_own_requirements(self): + folder = self.skills / "demo-skill" + write_skill(folder, bmod="bmod-demo", extra='required_skills = ["other-skill"]\n') + record, requirement = setup_check.owed(folder, self.project) + self.assertIn("`bmod-demo`", record) + self.assertIn("`npx skills add bmad-code-org/BMAD-METHOD --skill other-skill`", requirement) + + def test_an_older_required_skill_is_reported_with_both_versions(self): + self.hub("6.12.0") + folder = self.skill(module_extra=REQUIRES_BMAD) + (note,) = setup_check.owed(folder, self.project) + self.assertIn("`bmad` 6.13.0 or later", note) + self.assertIn("6.12.0 is installed", note) + + def test_the_next_build_of_the_required_version_meets_it(self): + self.hub("6.13.0-next") + folder = self.skill(module_extra=REQUIRES_BMAD) + self.assertEqual(setup_check.owed(folder, self.project), []) + + def test_the_next_build_of_an_earlier_version_does_not(self): + self.hub("6.12.0-next") + folder = self.skill(module_extra=REQUIRES_BMAD) + self.assertEqual(len(setup_check.owed(folder, self.project)), 1) + + def test_a_required_skill_without_its_module_record_is_not_called_outdated(self): + write_skill(self.skills / "bmad", bmod="bmod-core-tools") + folder = self.skill(module_extra=REQUIRES_BMAD) + self.assertEqual(setup_check.owed(folder, self.project), []) + + def test_a_required_skill_with_no_bmod_file_is_reported_with_an_unreadable_version(self): + (self.skills / "bmad").mkdir(parents=True) + folder = self.skill(module_extra=REQUIRES_BMAD) + (note,) = setup_check.owed(folder, self.project) + self.assertIn("version cannot be read", note) + self.assertIn("npx skills update", note) + + def test_a_requirement_may_carry_no_version(self): + (self.skills / "other-skill").mkdir(parents=True) + folder = self.skill(skill_extra='required_skills = ["other-skill"]\n') + self.assertEqual(setup_check.owed(folder, self.project), []) + + def test_the_modules_requirements_and_the_skills_own_are_both_checked(self): + folder = self.skill( + module_extra='required_skills = ["module-need"]\n', + skill_extra='required_skills = ["skill-need", "module-need"]\n', + ) + notes = setup_check.owed(folder, self.project) + self.assertEqual(len(notes), 2) + self.assertIn("`module-need`", notes[0]) + self.assertIn("`skill-need`", notes[1]) + + def test_a_skill_required_by_both_lists_is_held_to_the_higher_minimum(self): + self.hub("6.13.0") + higher = f'required_skills = [{{ skill = "bmad", version = "6.14.0", source = "{SOURCE}" }}]\n' + for module_extra, skill_extra in ( + (REQUIRES_BMAD, higher), + (higher, REQUIRES_BMAD), + ('required_skills = ["bmad"]\n', higher), + ): + with self.subTest(module=module_extra): + folder = self.skill(module_extra=module_extra, skill_extra=skill_extra) + (note,) = setup_check.owed(folder, self.project) + self.assertIn("`bmad` 6.14.0 or later", note) + + def test_a_requirement_that_names_the_skill_itself_is_skipped(self): + folder = self.skill(module_extra='required_skills = ["demo-skill"]\n') + self.assertEqual(setup_check.owed(folder, self.project), []) + + def test_a_missing_required_skill_names_its_install_command(self): + folder = self.skill( + skill_extra='required_skills = [{ skill = "other-skill", version = "2.0.0", source = "github:acme/tools/skills" }]\n' + ) + (note,) = setup_check.owed(folder, self.project) + self.assertIn("`npx skills add acme/tools --skill other-skill`", note) + + def test_a_missing_required_skill_defaults_to_the_declaring_files_source(self): + folder = self.skill(skill_extra='required_skills = ["bmad"]\n') + (note,) = setup_check.owed(folder, self.project) + self.assertIn("`npx skills add bmad-code-org/BMAD-METHOD --skill bmad`", note) + + def test_recommended_skills_are_never_reported(self): + folder = self.skill( + module_extra='recommended_skills = ["module-extra"]\n', + skill_extra='recommended_skills = ["other-skill"]\n', + ) + self.assertEqual(setup_check.owed(folder, self.project), []) + + def test_an_unanswered_question_is_reported_and_an_empty_answer_is_not(self): + question = ( + '\n[[bmod.config_questions]]\nkey = "active_initiative"\nprompt = "Active initiative?"\ndefault = ""\n' + ) + folder = self.skill(module_extra=question) + (note,) = setup_check.owed(folder, self.project) + self.assertIn("active_initiative", note) + self.assertIn("`bmad setup`", note) + + (self.project / "_bmad" / "config.toml").write_text( + '[core]\n\n[modules.demo]\nactive_initiative = ""\n', encoding="utf-8" + ) + self.assertEqual(setup_check.owed(folder, self.project), []) + + def test_a_user_question_is_answered_by_the_users_own_file(self): + question = ( + '\n[[bmod.config_questions]]\nkey = "note_style"\nscope = "user"\nprompt = "Style?"\ndefault = "bullets"\n' + ) + folder = self.skill(module_extra=question) + self.assertEqual(len(setup_check.owed(folder, self.project)), 1) + + custom = self.project / "_bmad" / "custom" + custom.mkdir() + (custom / "config.user.toml").write_text('[modules.demo]\nnote_style = "prose"\n', encoding="utf-8") + self.assertEqual(setup_check.owed(folder, self.project), []) + + def test_a_missing_or_stale_placed_script_is_reported(self): + folder = self.skill(skill_extra='scripts = ["scripts/tool.py"]\n') + (folder / "scripts").mkdir() + (folder / "scripts" / "tool.py").write_bytes(b"new\n") + (note,) = setup_check.owed(folder, self.project) + self.assertIn("`_bmad/demo/scripts/`", note) + self.assertIn("`bmad setup`", note) + + placed = self.project / "_bmad" / "demo" / "scripts" / "tool.py" + placed.parent.mkdir(parents=True) + placed.write_bytes(b"old\n") + self.assertEqual(len(setup_check.owed(folder, self.project)), 1) + placed.write_bytes(b"new\n") + self.assertEqual(setup_check.owed(folder, self.project), []) + + def test_a_single_skill_module_reads_both_tables_from_its_own_file(self): + folder = self.skills / "release-notes" + folder.mkdir(parents=True) + (folder / "bmod.toml").write_text( + '[bmod]\ncode = "notes"\nversion = "1.0.0"\nupdate_source = "github:acme/release-notes"\n' + 'required_skills = ["other-skill"]\n\n' + '[[bmod.config_questions]]\nkey = "path"\nprompt = "Path?"\ndefault = "x"\n\n[skill]\n', + encoding="utf-8", + ) + requirement, question = setup_check.owed(folder, self.project) + self.assertIn("`npx skills add acme/release-notes --skill other-skill`", requirement) + self.assertIn("module `notes`", question) + + def test_report_writes_each_note_as_an_instruction(self): + folder = self.skills / "demo-skill" + write_skill(folder, bmod="bmod-demo") + stream = io.StringIO() + with redirect_stderr(stream): + setup_check.report(folder, self.project) + self.assertTrue(stream.getvalue().startswith("setup: before continuing, tell the user that `demo-skill` ")) + + def test_report_stays_silent_when_a_bmod_file_cannot_be_parsed(self): + folder = self.skills / "broken-skill" + folder.mkdir(parents=True) + (folder / "bmod.toml").write_text("not toml [", encoding="utf-8") + stream = io.StringIO() + with redirect_stderr(stream): + setup_check.report(folder, self.project) + self.assertEqual(stream.getvalue(), "") + + write_skill(folder, bmod="bmod-demo") + (self.skills / "bmod-demo").mkdir() + (self.skills / "bmod-demo" / "bmod.toml").write_text("not toml [", encoding="utf-8") + with redirect_stderr(stream): + setup_check.report(folder, self.project) + self.assertEqual(stream.getvalue(), "") + + +if __name__ == "__main__": + unittest.main() diff --git a/_bmad/scripts/tests/test_stamp_release.py b/_bmad/scripts/tests/test_stamp_release.py new file mode 100644 index 0000000..fb1c0f7 --- /dev/null +++ b/_bmad/scripts/tests/test_stamp_release.py @@ -0,0 +1,357 @@ +import contextlib +import importlib.util +import io +import subprocess +import sys +import tempfile +import tomllib +import unittest +from pathlib import Path + +SCRIPTS = Path(__file__).resolve().parents[1] +STAMPER = SCRIPTS / "stamp_release.py" +SETUP_PY = SCRIPTS / "setup.py" + +SOURCE = "github:bmad-code-org/BMAD-METHOD/skills" + +RECORD = ( + "[bmod]\n" + 'code = "{code}"\n' + 'version = "{version}"\n' + f'update_source = "{SOURCE}"\n' + "skills = [{skills}]\n" + 'pre_install_message = ""\n' + 'post_install_message = ""\n' + "\n" + "[[bmod.knowledge]]\n" + 'path = "extra.md"\n' +) + +SKILL = f'[skill]\nbmod = "{{bmod}}"\nsource = "{SOURCE}"\n' + +MEMBERS = {"method": ("bmad-build", "bmad-spec"), "core-tools": ("bmad", "bmad-flow")} + + +def load_module(name: str, path: Path): + sys.dont_write_bytecode = True + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +sr = load_module("stamp_release", STAMPER) +setup = load_module("bmad_setup_stamp_contract", SETUP_PY) + + +def write(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def record_text(code: str, version: str) -> str: + skills = ", ".join(f'"{skill}"' for skill in MEMBERS[code]) + return RECORD.format(code=code, version=version, skills=skills) + + +def make_tree(root: Path, version: str = "6.11.0-next") -> None: + for code, members in MEMBERS.items(): + folder = root / "skills" / f"bmod-{code}" + write(folder / "bmod.toml", record_text(code, version)) + write(folder / "help" / "help.md", "# help\n") + write(folder / "extra.md", "# extra\n") + write(folder / "SKILL.md", "# record\n") + for skill in members: + write(root / "skills" / skill / "bmod.toml", SKILL.format(bmod=f"bmod-{code}")) + + +def snapshot(root: Path) -> dict[str, bytes]: + return {path.relative_to(root).as_posix(): path.read_bytes() for path in sorted(root.rglob("*")) if path.is_file()} + + +def run_stamper(root: Path, version: str) -> tuple[int, str, str]: + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = sr.run(root, version) + return code, out.getvalue(), err.getvalue() + + +class StampReleaseTests(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.root = Path(self._tmp.name).resolve() + self.method = self.root / "skills" / "bmod-method" / "bmod.toml" + + def assert_refused(self, version: str, *expected: str) -> None: + before = snapshot(self.root) + code, _, err = run_stamper(self.root, version) + self.assertEqual(code, 1) + for text in expected: + self.assertIn(text, err) + self.assertEqual(snapshot(self.root), before) + + def test_happy_path_stamps_every_record_and_no_member_skill(self): + make_tree(self.root) + before = snapshot(self.root) + code, out, err = run_stamper(self.root, "1.2.0") + self.assertEqual(code, 0, err) + after = snapshot(self.root) + changed = sorted(name for name in after if after[name] != before[name]) + self.assertEqual(changed, ["skills/bmod-core-tools/bmod.toml", "skills/bmod-method/bmod.toml"]) + self.assertEqual(self.method.read_text(encoding="utf-8"), record_text("method", "1.2.0")) + self.assertIn("Stamped version 1.2.0 into 2 files", out) + self.assertIn("skills/bmod-method/bmod.toml", out) + self.assertIn("skills/bmod-core-tools/bmod.toml", out) + self.assertNotIn("skills/bmad-build/bmod.toml", out) + + def test_single_skill_module_is_stamped(self): + make_tree(self.root) + notes = self.root / "skills" / "release-notes" / "bmod.toml" + write( + notes, + f'[bmod]\ncode = "notes"\nversion = "6.11.0-next"\nupdate_source = "{SOURCE}"\n' + 'pre_install_message = ""\npost_install_message = ""\n\n' + f'[skill]\nrequired_skills = [{{ skill = "x", version = "1.0.0", source = "{SOURCE}" }}]\n', + ) + write(notes.parent / "SKILL.md", "# notes\n") + code, out, err = run_stamper(self.root, "1.2.0") + self.assertEqual(code, 0, err) + data = tomllib.loads(notes.read_text(encoding="utf-8")) + self.assertEqual(data["bmod"]["version"], "1.2.0") + self.assertEqual(data["skill"]["required_skills"][0]["version"], "1.0.0") + self.assertIn("into 3 files", out) + + def test_non_semver_version_touches_nothing(self): + make_tree(self.root) + self.assert_refused("banana", "SemVer") + + def test_dev_prerelease_rejected_with_explanation(self): + make_tree(self.root) + self.assert_refused("1.2.0-dev", "-dev", "current") + + def test_build_metadata_rejected_with_explanation(self): + make_tree(self.root) + self.assert_refused("1.2.0+hotfix", "build metadata", "'1.2.0'") + + def test_build_metadata_on_prerelease_rejected(self): + make_tree(self.root) + self.assert_refused("1.2.0-rc.1+build.5", "build metadata") + + def test_orderable_prerelease_is_accepted(self): + make_tree(self.root) + code, _, err = run_stamper(self.root, "6.12.0-next.1") + self.assertEqual(code, 0, err) + self.assertEqual(tomllib.loads(self.method.read_text(encoding="utf-8"))["bmod"]["version"], "6.12.0-next.1") + + def test_repository_check_failure_names_the_file_and_touches_nothing(self): + make_tree(self.root) + write(self.root / "skills" / "bmad-orphan" / "SKILL.md", "# orphan\n") + self.assert_refused("1.2.0", "skills/bmad-orphan: missing bmod.toml") + + def test_record_missing_version_key_names_file_and_touches_nothing(self): + make_tree(self.root) + write(self.method, record_text("method", "x").replace('version = "x"\n', "")) + self.assert_refused("1.2.0", "skills/bmod-method/bmod.toml", "'bmod.version'") + + def test_every_problem_is_reported_in_one_run(self): + make_tree(self.root) + write(self.root / "skills" / "bmad-orphan" / "SKILL.md", "# orphan\n") + (self.root / "skills" / "bmod-method" / "help" / "help.md").unlink() + self.assert_refused("1.2.0", "skills/bmad-orphan", "skills/bmod-method/help/help.md") + + def test_empty_skills_tree_reports_error(self): + code, _, err = run_stamper(self.root, "1.2.0") + self.assertEqual(code, 1) + self.assertIn("bmod.toml", err) + + def test_tree_with_no_module_record_reports_error(self): + write(self.root / "skills" / "plain" / "bmod.toml", '[skill]\nbmod = "bmod-x"\nsource = "github:o/r"\n') + self.assert_refused("1.2.0", "[skill] bmod names 'bmod-x', which is not a module record") + + def test_keys_and_tables_the_stamper_does_not_know_are_left_alone(self): + make_tree(self.root) + text = record_text("method", "6.11.0-next").replace( + 'code = "method"\n', 'code = "method"\nfuture_field = ["anything"]\n' + ) + extra = '\n[bmod.builder]\nversion = "9.9.9"\n\n[other]\nversion = "8.8.8"\nnote = "anything"\n' + write(self.method, text + extra) + code, _, err = run_stamper(self.root, "1.2.0") + self.assertEqual(code, 0, err) + self.assertEqual( + self.method.read_text(encoding="utf-8"), + text.replace('version = "6.11.0-next"', 'version = "1.2.0"') + extra, + ) + + def test_version_in_a_table_before_bmod_is_left_alone(self): + make_tree(self.root) + text = '[other]\nversion = "8.8.8"\n\n' + record_text("method", "6.11.0-next") + write(self.method, text) + code, _, err = run_stamper(self.root, "1.2.0") + self.assertEqual(code, 0, err) + data = tomllib.loads(self.method.read_text(encoding="utf-8")) + self.assertEqual((data["other"]["version"], data["bmod"]["version"]), ("8.8.8", "1.2.0")) + + def test_requirement_versions_are_left_alone(self): + make_tree(self.root) + line = f'required_skills = [{{ skill = "bmad", version = "6.13.0", source = "{SOURCE}" }}]\n' + text = record_text("method", "6.11.0-next").replace("\n[[bmod.knowledge]]", line + "\n[[bmod.knowledge]]") + write(self.method, text) + code, _, err = run_stamper(self.root, "1.2.0") + self.assertEqual(code, 0, err) + stamped = self.method.read_text(encoding="utf-8") + self.assertIn(line, stamped) + self.assertIn('version = "1.2.0"\n', stamped) + + def test_indentation_comment_and_line_endings_are_preserved(self): + make_tree(self.root) + text = record_text("method", "6.11.0-next").replace( + 'version = "6.11.0-next"\n', ' version = "6.11.0-next" # stamped\n' + ) + self.method.write_bytes(text.replace("\n", "\r\n").encode("utf-8")) + code, _, err = run_stamper(self.root, "1.2.0") + self.assertEqual(code, 0, err) + expected = text.replace('"6.11.0-next"', '"1.2.0"').replace("\n", "\r\n") + self.assertEqual(self.method.read_bytes(), expected.encode("utf-8")) + + def test_version_the_stamper_cannot_find_as_a_line_touches_nothing(self): + make_tree(self.root) + write(self.method, record_text("method", "x").replace('version = "x"', "version = '6.11.0-next'")) + self.assert_refused("1.2.0", "skills/bmod-method/bmod.toml", "expected exactly one 'version = \"...\"' line") + + def test_version_lookalike_inside_a_multi_line_string_touches_nothing(self): + make_tree(self.root) + note = 'note = """\nversion = "x"\n"""\n' + text = record_text("method", "6.11.0-next").replace('code = "method"\n', 'code = "method"\n' + note) + write(self.method, text) + self.assert_refused("1.2.0", "skills/bmod-method/bmod.toml", "line inside [bmod], found 2") + + def test_only_version_line_being_inside_a_string_touches_nothing(self): + make_tree(self.root) + note = 'note = """\nversion = "x"\n"""\n' + text = record_text("method", "x").replace('version = "x"\n', "version = '6.11.0-next'\n" + note) + write(self.method, text) + self.assert_refused("1.2.0", "skills/bmod-method/bmod.toml", "would change something other than") + + def test_bmod_written_without_a_table_header_touches_nothing(self): + make_tree(self.root) + write( + self.method, + f'bmod = {{ code = "method", version = "6.11.0-next", update_source = "{SOURCE}" }}\n', + ) + for skill in MEMBERS["method"]: + (self.root / "skills" / skill / "bmod.toml").unlink() + (self.root / "skills" / skill).rmdir() + self.assert_refused("1.2.0", "skills/bmod-method/bmod.toml", "expected exactly one '[bmod]' table header") + + +class CheckModeTests(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.root = Path(self._tmp.name).resolve() + make_tree(self.root) + + def check(self, *argv: str) -> tuple[int, str, str]: + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = sr.main([*argv, "--project-root", str(self.root)]) + return code, out.getvalue(), err.getvalue() + + def test_check_validates_and_writes_nothing(self): + before = snapshot(self.root) + code, out, err = self.check("--check") + self.assertEqual(code, 0, err) + self.assertIn("bmod files valid:", out) + self.assertEqual(snapshot(self.root), before) + + def test_check_fails_on_a_broken_tree(self): + (self.root / "skills" / "bmod-method" / "SKILL.md").unlink() + before = snapshot(self.root) + code, _, err = self.check("--check") + self.assertEqual(code, 1) + self.assertIn("skills/bmod-method: a module record folder must ship SKILL.md", err) + self.assertEqual(snapshot(self.root), before) + + def test_version_and_check_together_is_a_usage_error(self): + with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as raised: + sr.main(["1.2.0", "--check"]) + self.assertEqual(raised.exception.code, 2) + + def test_neither_version_nor_check_is_a_usage_error(self): + with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as raised: + sr.main([]) + self.assertEqual(raised.exception.code, 2) + + def test_run_from_the_repository_root_checks_the_current_directory(self): + result = subprocess.run( + [sys.executable, "-B", str(STAMPER), "--check"], cwd=self.root, capture_output=True, text=True, check=False + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("bmod files valid:", result.stdout) + + +class StampedContentTests(unittest.TestCase): + def test_only_the_bmod_table_version_changes(self): + original = '[a]\nversion = "1"\n[bmod]\ncode = "x"\nversion = "2"\n[[bmod.knowledge]]\nversion = "3"\n' + stamped = sr.validator.stamp_text(original, "9.9.9") + self.assertEqual(stamped, original.replace('version = "2"', 'version = "9.9.9"')) + + def test_two_version_lines_inside_bmod_are_refused(self): + with self.assertRaises(ValueError): + sr.validator.stamp_text('[bmod]\nversion = "1"\nversion = "2"\n', "9.9.9") + + +class InstallerContractTests(unittest.TestCase): + """Pin the version rules the stamper adds on top of skills/bmad/scripts/setup.py. + + If setup.py's rules drift, these fail instead of shipping a release an + installed module cannot order. + """ + + def test_stamper_uses_the_runtime_semver(self): + self.assertEqual(sr.setup.SEMVER.pattern, setup.SEMVER.pattern) + + def test_validate_version_accepts_exactly_what_setup_can_distinguish(self): + """Accept a version only if setup.py can both order it and tell it apart. + + Build metadata is orderable but not distinguishing: setup.py drops it, + so `1.2.0+hotfix` compares equal to `1.2.0` and a release stamped that + way is invisible to an installed module. The stamper is stricter than + orderability by exactly that much. + """ + candidates = ( + "1.2.0", + "0.0.1", + "6.12.0-next.1", + "1.2.0-rc.1+build.5", + "1.2.0+hotfix", + "banana", + "1.2", + "01.2.0", + "1.2.0-dev", + "1.2.0-DEV", + "1.2.0-dev.1", + "", + ) + for version in candidates: + parsed = setup.parse_orderable_semver(version) + distinguishable = parsed is not None and "+" not in version + try: + sr.validate_version(version) + accepted = True + except sr.StampError: + accepted = False + self.assertEqual(accepted, distinguishable, version) + + def test_setup_orders_build_metadata_as_equal(self): + """The premise of the rejection above, pinned against setup.py itself.""" + self.assertEqual( + setup.parse_orderable_semver("1.2.0+hotfix"), + setup.parse_orderable_semver("1.2.0"), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/_bmad/scripts/tests/test_validate_manifests.py b/_bmad/scripts/tests/test_validate_manifests.py new file mode 100644 index 0000000..637617f --- /dev/null +++ b/_bmad/scripts/tests/test_validate_manifests.py @@ -0,0 +1,553 @@ +import contextlib +import importlib.util +import io +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +VALIDATOR = Path(__file__).resolve().parents[1] / "validate_manifests.py" + +SOURCE = "github:bmad-code-org/BMAD-METHOD/skills" +MESSAGES = 'pre_install_message = ""\npost_install_message = ""\n' + +METHOD_RECORD = ( + "[bmod]\n" + 'code = "method"\n' + 'version = "{version}"\n' + f'update_source = "{SOURCE}"\n' + 'skills = ["bmad-build", "bmad-spec"]\n' + 'required_skills = ["bmad"]\n' + f"{MESSAGES}" + "\n" + "[[bmod.knowledge]]\n" + 'path = "delivery-help.md"\n' + 'skills = ["bmad-build"]\n' +) + +CORE_TOOLS_RECORD = ( + "[bmod]\n" + 'code = "core-tools"\n' + 'version = "{version}"\n' + f'update_source = "{SOURCE}"\n' + 'skills = ["bmad", "bmad-flow"]\n' + f"{MESSAGES}" +) + +SKILL = f'[skill]\nbmod = "{{bmod}}"\nsource = "{SOURCE}"\n' + +ROSTER = ( + "[[members]]\n" + 'code = "bmad-build"\n' + 'skill = "bmad-build"\n' + 'name = "Amelia"\n' + "\n" + "[[members]]\n" + 'code = "guest"\n' + 'name = "Guest"\n' + "\n" + "[[groups]]\n" + 'id = "team"\n' + 'members = ["bmad-build", "guest"]\n' +) + +MEMBERS = {"bmod-method": ("bmad-build", "bmad-spec"), "bmod-core-tools": ("bmad", "bmad-flow")} + + +def load_module(name: str, path: Path): + sys.dont_write_bytecode = True + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +vm = load_module("validate_manifests", VALIDATOR) + + +def write(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def make_tree(root: Path, version: str = "6.11.0-next") -> None: + skills = root / "skills" + write(skills / "bmod-method" / "bmod.toml", METHOD_RECORD.format(version=version)) + write(skills / "bmod-method" / "help" / "help.md", "# help\n") + write(skills / "bmod-method" / "delivery-help.md", "# delivery\n") + write(skills / "bmod-method" / "roster.toml", ROSTER) + write(skills / "bmod-core-tools" / "bmod.toml", CORE_TOOLS_RECORD.format(version=version)) + write(skills / "bmod-core-tools" / "help" / "help.md", "# help\n") + for bmod, members in MEMBERS.items(): + write(skills / bmod / "SKILL.md", "# record\n") + for skill in members: + write(skills / skill / "bmod.toml", SKILL.format(bmod=bmod)) + write(skills / skill / "SKILL.md", "# skill\n") + + +class ValidatorCase(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.root = Path(self._tmp.name).resolve() + self.skills = self.root / "skills" + make_tree(self.root) + + def problems(self) -> str: + return "\n".join(vm.check_repo(self.root).problems) + + def method_record(self, old: str, new: str) -> None: + path = self.skills / "bmod-method" / "bmod.toml" + text = path.read_text(encoding="utf-8") + self.assertIn(old, text) + write(path, text.replace(old, new)) + + +class CleanTreeTests(ValidatorCase): + def test_clean_tree_has_no_problems_and_reports_its_records(self): + report = vm.check_repo(self.root) + self.assertEqual(report.problems, ()) + self.assertEqual( + [path.relative_to(self.root).as_posix() for path in report.records], + ["skills/bmod-core-tools/bmod.toml", "skills/bmod-method/bmod.toml"], + ) + self.assertEqual((report.skills, report.documents), (4, 3)) + + def test_unknown_keys_and_tables_are_left_alone(self): + self.method_record('code = "method"\n', 'code = "method"\nfuture_field = ["anything"]\n') + path = self.skills / "bmod-method" / "bmod.toml" + write(path, path.read_text(encoding="utf-8") + '\n[builder]\nversion = "9.9.9"\n') + write( + self.skills / "bmad-spec" / "bmod.toml", + SKILL.format(bmod="bmod-method") + "later = true\n\n[extra]\nx = 1\n", + ) + self.assertEqual(self.problems(), "") + + def test_single_skill_module_is_valid_under_any_folder_name(self): + write( + self.skills / "release-notes" / "bmod.toml", + '[bmod]\ncode = "notes"\nversion = "6.11.0-next"\nupdate_source = "github:acme/notes"\n' + f"{MESSAGES}\n[skill]\n", + ) + write(self.skills / "release-notes" / "help" / "help.md", "# help\n") + write(self.skills / "release-notes" / "SKILL.md", "# notes\n") + self.assertEqual(self.problems(), "") + + def test_module_with_no_skills_is_valid(self): + write( + self.skills / "bmod-rooms" / "bmod.toml", + f'[bmod]\ncode = "rooms"\nversion = "6.11.0-next"\nupdate_source = "{SOURCE}"\n{MESSAGES}', + ) + write(self.skills / "bmod-rooms" / "help" / "help.md", "# help\n") + write(self.skills / "bmod-rooms" / "SKILL.md", "# rooms\n") + self.assertEqual(self.problems(), "") + + def test_empty_skills_tree_is_a_problem(self): + empty = self.root / "empty" + empty.mkdir() + self.assertIn("no skills/*/bmod.toml found", "\n".join(vm.check_repo(empty).problems)) + + def test_main_exit_codes(self): + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = vm.main(["--project-root", str(self.root)]) + self.assertEqual(code, 0, err.getvalue()) + self.assertIn("4 skills, 2 module records, 3 knowledge documents", out.getvalue()) + (self.skills / "bmad-flow" / "bmod.toml").unlink() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = vm.main(["--project-root", str(self.root)]) + self.assertEqual(code, 1) + self.assertIn("skills/bmad-flow: missing bmod.toml", err.getvalue()) + + def test_run_from_outside_the_bmad_scripts_checks_the_current_directory(self): + result = subprocess.run( + [sys.executable, "-B", str(VALIDATOR)], cwd=self.root, capture_output=True, text=True, check=False + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("bmod files valid: 4 skills, 2 module records", result.stdout) + (self.skills / "bmad-flow" / "bmod.toml").unlink() + result = subprocess.run( + [sys.executable, "-B", str(VALIDATOR)], cwd=self.root, capture_output=True, text=True, check=False + ) + self.assertEqual(result.returncode, 1) + self.assertIn("skills/bmad-flow: missing bmod.toml", result.stderr) + + +class FileRuleTests(ValidatorCase): + def test_skill_folder_without_a_bmod_file(self): + write(self.skills / "bmad-orphan" / "SKILL.md", "# orphan\n") + self.assertIn("skills/bmad-orphan: missing bmod.toml", self.problems()) + + def test_file_the_runtime_parser_rejects(self): + write(self.skills / "bmad-spec" / "bmod.toml", 'note = "neither table"\n') + problems = self.problems() + self.assertIn("skills/bmad-spec/bmod.toml: the runtime parser rejects this file", problems) + self.assertIn("[bmod] table, a [skill] table, or both", problems) + + def test_record_missing_a_required_key(self): + self.method_record('version = "6.11.0-next"\n', "") + problems = self.problems() + self.assertIn("skills/bmod-method/bmod.toml", problems) + self.assertIn("'bmod.version'", problems) + + def test_record_missing_an_install_message_key(self): + for key in ("pre_install_message", "post_install_message"): + with self.subTest(key=key): + make_tree(self.root) + self.method_record(f'{key} = ""\n', "") + self.assertIn(f"skills/bmod-method/bmod.toml: [bmod] is missing {key!r}", self.problems()) + + def test_install_message_that_is_not_a_string(self): + self.method_record('pre_install_message = ""\n', "pre_install_message = 3\n") + problems = self.problems() + self.assertIn("skills/bmod-method/bmod.toml: the runtime parser rejects this file", problems) + self.assertIn("'bmod.pre_install_message' must be a string", problems) + + def test_skill_missing_a_required_key(self): + write(self.skills / "bmad-spec" / "bmod.toml", '[skill]\nbmod = "bmod-method"\n') + problems = self.problems() + self.assertIn("skills/bmad-spec/bmod.toml", problems) + self.assertIn("'skill.source'", problems) + + def test_github_source_with_two_parts_is_accepted(self): + self.method_record(f'update_source = "{SOURCE}"', 'update_source = "github:o/r"') + for skill in MEMBERS["bmod-method"]: + write(self.skills / skill / "bmod.toml", '[skill]\nbmod = "bmod-method"\nsource = "github:o/r"\n') + self.assertEqual(self.problems(), "") + + def test_github_source_with_one_part_is_rejected(self): + write(self.skills / "bmad-spec" / "bmod.toml", '[skill]\nbmod = "bmod-method"\nsource = "github:o"\n') + self.assertIn("github source must name owner/repo", self.problems()) + + +class RecordRuleTests(ValidatorCase): + def test_record_folder_not_named_after_its_code(self): + (self.skills / "bmod-method").rename(self.skills / "bmod-bmad-method") + for skill in MEMBERS["bmod-method"]: + write(self.skills / skill / "bmod.toml", SKILL.format(bmod="bmod-bmad-method")) + self.assertIn( + "skills/bmod-bmad-method/bmod.toml: a module record folder is named 'bmod-method'", self.problems() + ) + + def test_second_record_for_one_code(self): + write( + self.skills / "bmod-zeta" / "bmod.toml", + f'[bmod]\ncode = "method"\nversion = "6.11.0-next"\nupdate_source = "{SOURCE}"\n\n[skill]\n', + ) + self.assertIn( + "skills/bmod-zeta/bmod.toml: module code 'method' is already declared by skills/bmod-method/bmod.toml", + self.problems(), + ) + + def test_codes_differing_only_by_case(self): + write( + self.skills / "upper" / "bmod.toml", + f'[bmod]\ncode = "Method"\nversion = "6.11.0-next"\nupdate_source = "{SOURCE}"\n\n[skill]\n', + ) + self.assertIn("module code 'Method' is already declared by", self.problems()) + + def test_records_with_different_versions(self): + self.method_record('version = "6.11.0-next"', 'version = "6.12.0"') + problems = self.problems() + self.assertIn("every module record carries one version", problems) + self.assertIn("bmod-method has '6.12.0'", problems) + + def test_bmod_folder_without_a_bmod_table(self): + write(self.skills / "bmod-x" / "bmod.toml", '[skill]\nbmod = "bmod-method"\nsource = "github:o/r"\n') + self.assertIn("skills/bmod-x/bmod.toml: a bmod-* folder holds a module record", self.problems()) + + def test_record_version_that_is_not_semver(self): + self.method_record('version = "6.11.0-next"', 'version = "1.0"') + self.assertIn("skills/bmod-method/bmod.toml: [bmod] invalid version '1.0': must be SemVer", self.problems()) + + def test_record_version_the_runtime_cannot_order(self): + for version in ("6.11.0-dev", "6.11.0+build.1"): + with self.subTest(version=version): + self.method_record('version = "6.11.0-next"', f'version = "{version}"') + self.assertIn(f"skills/bmod-method/bmod.toml: [bmod] invalid version {version!r}", self.problems()) + self.method_record(f'version = "{version}"', 'version = "6.11.0-next"') + + def test_record_without_skill_md(self): + (self.skills / "bmod-method" / "SKILL.md").unlink() + self.assertIn("skills/bmod-method: a module record folder must ship SKILL.md", self.problems()) + + +class RetiredRuleTests(ValidatorCase): + def retire(self, record: str, lines: str) -> None: + write(self.skills / record / "retired.toml", lines) + + def test_retired_names_that_no_longer_ship_are_valid(self): + self.retire( + "bmod-method", 'renamed = [{ from = "bmad-old-build", to = "bmad-build" }]\nremoved = ["bmad-gone"]\n' + ) + self.assertEqual(self.problems(), "") + + def test_a_retired_name_that_still_ships_is_a_problem(self): + self.retire("bmod-method", 'removed = ["bmad-spec"]\n') + self.assertIn("retires 'bmad-spec', but skills/bmad-spec still ships", self.problems()) + + def test_a_rename_to_a_skill_the_repository_does_not_ship_is_a_problem(self): + self.retire("bmod-method", 'renamed = [{ from = "bmad-old", to = "bmad-missing" }]\n') + self.assertIn("renames 'bmad-old' to 'bmad-missing', which this repository does not ship", self.problems()) + + def test_a_name_retired_by_two_records_is_a_problem(self): + self.retire("bmod-method", 'removed = ["bmad-gone"]\n') + self.retire("bmod-core-tools", 'removed = ["bmad-gone"]\n') + self.assertIn("retires 'bmad-gone', which skills/bmod-", self.problems()) + + def test_two_renames_to_one_skill_are_a_problem(self): + self.retire( + "bmod-method", + 'renamed = [{ from = "bmad-a", to = "bmad-build" }, { from = "bmad-b", to = "bmad-build" }]\n', + ) + self.assertIn("renames more than one skill to 'bmad-build'", self.problems()) + + def test_a_retired_file_the_runtime_rejects_is_a_problem(self): + self.retire("bmod-method", 'removed = ["bmad-gone", "bmad-gone"]\n') + self.assertIn("skills/bmod-method/retired.toml: the runtime parser rejects this file", self.problems()) + + +class StampRuleTests(ValidatorCase): + def test_record_the_stamper_cannot_stamp(self): + self.method_record('version = "6.11.0-next"', "version = '6.11.0-next'") + self.assertIn( + "skills/bmod-method/bmod.toml: stamp_release.py cannot stamp this file: expected exactly one " + "'version = \"...\"' line inside [bmod], found 0", + self.problems(), + ) + + def test_version_lookalike_inside_a_multi_line_string(self): + self.method_record('code = "method"\n', 'code = "method"\nnote = """\nversion = "x"\n"""\n') + self.assertIn("cannot stamp this file", self.problems()) + + def test_the_check_writes_nothing(self): + path = self.skills / "bmod-method" / "bmod.toml" + before = path.read_bytes() + self.assertEqual(self.problems(), "") + self.assertEqual(path.read_bytes(), before) + + +class MembershipRuleTests(ValidatorCase): + def test_skill_naming_a_record_the_repo_lacks(self): + write(self.skills / "bmad-spec" / "bmod.toml", SKILL.format(bmod="bmod-absent")) + self.assertIn( + "skills/bmad-spec/bmod.toml: [skill] bmod names 'bmod-absent', which is not a module record", + self.problems(), + ) + + def test_skill_naming_a_folder_that_is_not_a_record(self): + write(self.skills / "bmad-spec" / "bmod.toml", SKILL.format(bmod="bmad-build")) + self.assertIn("[skill] bmod names 'bmad-build', which is not a module record", self.problems()) + + def test_skill_its_record_does_not_list(self): + write(self.skills / "bmad-extra" / "bmod.toml", SKILL.format(bmod="bmod-method")) + self.assertIn( + "skills/bmad-extra/bmod.toml: [skill] bmod names 'bmod-method', but skills/bmod-method/bmod.toml " + "does not list 'bmad-extra'", + self.problems(), + ) + + def test_listed_skill_the_repo_does_not_ship(self): + self.method_record('["bmad-build", "bmad-spec"]', '["bmad-build", "bmad-spec", "bmad-typo"]') + self.assertIn( + "skills/bmod-method/bmod.toml: lists the skill 'bmad-typo', which this repository does not ship", + self.problems(), + ) + + def test_listed_skill_that_names_another_record(self): + write(self.skills / "bmad-spec" / "bmod.toml", SKILL.format(bmod="bmod-core-tools")) + self.assertIn( + "skills/bmod-method/bmod.toml: lists the skill 'bmad-spec', but skills/bmad-spec/bmod.toml names " + "'bmod-core-tools' as its bmod", + self.problems(), + ) + + def test_listed_folder_that_is_a_record(self): + self.method_record('["bmad-build", "bmad-spec"]', '["bmad-build", "bmad-spec", "bmod-core-tools"]') + self.assertIn("lists 'bmod-core-tools', which is a module record", self.problems()) + + def test_member_source_differs_from_its_record(self): + write(self.skills / "bmad-spec" / "bmod.toml", '[skill]\nbmod = "bmod-method"\nsource = "github:o/r"\n') + self.assertIn( + f"skills/bmad-spec/bmod.toml: [skill] source 'github:o/r' differs from skills/bmod-method/bmod.toml " + f"update_source {SOURCE!r}", + self.problems(), + ) + + def test_record_with_skill_table_left_out_of_its_own_list(self): + write( + self.skills / "notes" / "bmod.toml", + f'[bmod]\ncode = "notes"\nversion = "6.11.0-next"\nupdate_source = "{SOURCE}"\nskills = []\n\n[skill]\n', + ) + self.assertIn("skills/notes/bmod.toml: holds [skill], but its own [bmod] skills list leaves", self.problems()) + + +class RequirementRuleTests(ValidatorCase): + def add(self, folder: str, line: str) -> None: + path = self.skills / folder / "bmod.toml" + write(path, path.read_text(encoding="utf-8") + line) + + def test_plain_names_in_this_repo_are_accepted_in_both_tables(self): + self.add("bmad-build", 'required_skills = ["bmad"]\nrecommended_skills = ["bmad-spec"]\n') + self.assertEqual(self.problems(), "") + + def test_required_plain_name_the_repo_lacks(self): + self.add("bmad-build", 'required_skills = ["bmad-typo"]\n') + self.assertIn( + "skills/bmad-build/bmod.toml: skill.required_skills entry 'bmad-typo' names no skill in this repository", + self.problems(), + ) + + def test_recommended_plain_name_the_repo_lacks(self): + self.add("bmad-build", 'recommended_skills = ["bmad-typo"]\n') + self.assertIn("skill.recommended_skills entry 'bmad-typo' names no skill", self.problems()) + + def test_record_plain_name_the_repo_lacks(self): + self.method_record('required_skills = ["bmad"]', 'required_skills = ["bmad-typo"]') + self.assertIn( + "skills/bmod-method/bmod.toml: bmod.required_skills entry 'bmad-typo' names no skill", self.problems() + ) + + def test_table_entry_from_another_repo_is_accepted(self): + self.add( + "bmad-build", 'required_skills = [{ skill = "elsewhere", version = "1.0.0", source = "github:o/r" }]\n' + ) + self.assertEqual(self.problems(), "") + + def test_table_entry_without_a_source(self): + self.add("bmad-build", 'required_skills = [{ skill = "bmad", version = "6.13.0" }]\n') + self.assertIn("'skill.required_skills[0].source' must be a string", self.problems()) + + def test_version_with_build_metadata(self): + self.add("bmad-build", f'required_skills = [{{ skill = "bmad", version = "6.13.0+x", source = "{SOURCE}" }}]\n') + problems = self.problems() + self.assertIn("entry 'bmad' version '6.13.0+x' carries build metadata", problems) + self.assertIn("'6.13.0'", problems) + + def test_unorderable_version(self): + self.add("bmad-build", f'required_skills = [{{ skill = "bmad", version = "6.13", source = "{SOURCE}" }}]\n') + self.assertIn("'skill.required_skills[0].version' must be an orderable version", self.problems()) + + +class PathRuleTests(ValidatorCase): + def test_help_file_a_bmod_folder_does_not_ship(self): + (self.skills / "bmod-method" / "help" / "help.md").unlink() + self.assertIn( + "skills/bmod-method/help/help.md, which every bmod-* folder holds, the module record does not ship", + self.problems(), + ) + + def test_help_file_is_optional_outside_a_bmod_folder(self): + write( + self.skills / "solo" / "bmod.toml", + f'[bmod]\ncode = "solo"\nversion = "6.11.0-next"\nupdate_source = "{SOURCE}"\n{MESSAGES}\n[skill]\n', + ) + write(self.skills / "solo" / "SKILL.md", "# skill\n") + self.assertEqual(self.problems(), "") + + def test_help_symlink(self): + target = self.skills / "bmod-method" / "help" / "help.md" + target.unlink() + target.symlink_to(self.skills / "bmod-method" / "delivery-help.md") + self.assertIn("skills/bmod-method/help/help.md, which every bmod-* folder holds, is a symlink", self.problems()) + + def test_topic_file_named_in_help_is_valid(self): + write(self.skills / "bmod-method" / "help" / "help.md", "# help\n\nSee `help/deep-dive.md`.\n") + write(self.skills / "bmod-method" / "help" / "deep-dive.md", "# deep dive\n") + self.assertEqual(self.problems(), "") + + def test_topic_file_help_never_names(self): + write(self.skills / "bmod-method" / "help" / "orphan.md", "# orphan\n") + self.assertIn("skills/bmod-method/help/orphan.md is never named in help/help.md", self.problems()) + + def test_help_naming_a_topic_file_that_does_not_exist(self): + write(self.skills / "bmod-method" / "help" / "help.md", "# help\n\nSee `help/gone.md`.\n") + self.assertIn( + "skills/bmod-method/help/help.md names skills/bmod-method/help/gone.md, which does not exist", + self.problems(), + ) + + def test_topic_file_naming_a_topic_that_does_not_exist(self): + write(self.skills / "bmod-method" / "help" / "help.md", "# help\n\nSee `help/deep-dive.md`.\n") + write(self.skills / "bmod-method" / "help" / "deep-dive.md", "See `help/gone.md`.\n") + self.assertIn( + "skills/bmod-method/help/deep-dive.md names skills/bmod-method/help/gone.md, which does not exist", + self.problems(), + ) + + def test_knowledge_naming_the_help_file(self): + self.method_record('path = "delivery-help.md"', 'path = "help/help.md"') + self.assertIn("knowledge names 'help/help.md', which is always read", self.problems()) + + def test_knowledge_file_the_record_does_not_ship(self): + (self.skills / "bmod-method" / "delivery-help.md").unlink() + self.assertIn( + "skills/bmod-method/bmod.toml: knowledge names 'delivery-help.md', which the module record does not ship", + self.problems(), + ) + + def test_knowledge_url(self): + self.method_record('path = "delivery-help.md"', 'path = "https://docs.example.com/help.md"') + self.assertIn("unsafe value", self.problems()) + + def test_knowledge_path_leaving_the_folder(self): + self.method_record('path = "delivery-help.md"', 'path = "../bmad-build/SKILL.md"') + self.assertIn("unsafe value", self.problems()) + + def test_knowledge_directory(self): + (self.skills / "bmod-method" / "delivery-help.md").unlink() + (self.skills / "bmod-method" / "delivery-help.md").mkdir() + self.assertIn("knowledge names 'delivery-help.md', which is not a regular file", self.problems()) + + def test_knowledge_symlink(self): + target = self.skills / "bmod-method" / "delivery-help.md" + target.unlink() + target.symlink_to(self.skills / "bmod-method" / "help" / "help.md") + self.assertIn("knowledge names 'delivery-help.md', which is a symlink", self.problems()) + + def test_knowledge_naming_a_skill_outside_the_module(self): + self.method_record('skills = ["bmad-build"]\n', 'skills = ["bmad-build", "bmad-flow"]\n') + self.assertIn( + "knowledge 'delivery-help.md' names 'bmad-flow', which is not a skill of module 'method'", + self.problems(), + ) + + def test_a_module_without_a_roster_file_is_valid(self): + (self.skills / "bmod-method" / "roster.toml").unlink() + self.assertEqual(self.problems(), "") + + def test_roster_symlink(self): + target = self.skills / "bmod-method" / "roster.toml" + target.unlink() + target.symlink_to(self.skills / "bmod-method" / "help" / "help.md") + self.assertIn("skills/bmod-method/roster.toml is a symlink", self.problems()) + + def test_roster_that_is_not_toml(self): + write(self.skills / "bmod-method" / "roster.toml", "[[members\n") + self.assertIn("skills/bmod-method/roster.toml: cannot read roster", self.problems()) + + +class RosterContentTests(ValidatorCase): + def roster(self, old: str, new: str) -> None: + self.assertIn(old, ROSTER) + write(self.skills / "bmod-method" / "roster.toml", ROSTER.replace(old, new)) + + def test_member_code_defined_twice(self): + self.roster('code = "guest"', 'code = "bmad-build"') + self.assertIn("skills/bmod-method/roster.toml: member code 'bmad-build' is defined twice", self.problems()) + + def test_group_naming_an_undefined_member(self): + self.roster('members = ["bmad-build", "guest"]', 'members = ["bmad-build", "nobody"]') + self.assertIn("group 'team' lists 'nobody', which no member defines", self.problems()) + + def test_member_skill_the_repo_does_not_ship(self): + self.roster('skill = "bmad-build"', 'skill = "bmad-typo"') + self.assertIn( + "member 'bmad-build' names skill 'bmad-typo', which this repository does not ship", self.problems() + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/_bmad/scripts/validate_manifests.py b/_bmad/scripts/validate_manifests.py new file mode 100644 index 0000000..4f09a35 --- /dev/null +++ b/_bmad/scripts/validate_manifests.py @@ -0,0 +1,473 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Check every skills/*/bmod.toml against the runtime that has to read it. + +This file owns the repository checks: pre-commit and CI run it, and stamp_release.py beside it imports +`check_repo` and `stamp_text` from it. Keys and tables the runtime does not know are left alone. +It ships with the `bmad` skill, so any module repository can run it against its own tree. + +Usage: + uv run skills/bmad/scripts/validate_manifests.py [--project-root ] +""" + +from __future__ import annotations + +import argparse +import copy +import importlib.util +import re +import sys +import tomllib +from pathlib import Path, PurePosixPath +from typing import NamedTuple + +sys.dont_write_bytecode = True + +SCRIPTS = Path(__file__).resolve().parent +RECORD_PREFIX = "bmod-" +STAMP_PROBE = "0.0.0-stamp-check" +MESSAGE_KEYS = ("pre_install_message", "post_install_message") + +TABLE_HEADER = re.compile(r"[ \t]*\[\[?[^\[\]\n]+\]\]?[ \t]*(?:#[^\n]*)?\r?\n?") +BMOD_HEADER = re.compile(r"[ \t]*\[[ \t]*bmod[ \t]*\][ \t]*(?:#[^\n]*)?\r?\n?") +VERSION_LINE = re.compile(r'(?P[ \t]*version[ \t]*=[ \t]*)"[^"\n]*"(?P[ \t]*(?:#[^\n]*)?\r?\n?)') + + +def load(name: str, path: Path): + spec = importlib.util.spec_from_file_location(name, path) + if spec is None or spec.loader is None: + raise SystemExit(f"cannot load {path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +setup = load("bmad_setup_validate", SCRIPTS / "setup.py") +knowledge = load("bmad_knowledge_validate", SCRIPTS / "knowledge.py") + + +class RepoReport(NamedTuple): + records: tuple[Path, ...] + skills: int + documents: int + problems: tuple[str, ...] + + +def check_repo(project_root: Path) -> RepoReport: + skills_dir = project_root / "skills" + folders = sorted(path for path in skills_dir.glob("*") if path.is_dir()) + if not folders: + problem = f"no skills/*/{setup.MANIFEST_NAME} found under {project_root}: pass the repository root with --project-root" + return RepoReport((), 0, 0, (problem,)) + + problems: list[str] = [] + files: dict[str, setup.ParsedFile] = {} + for folder in folders: + manifest = folder / setup.MANIFEST_NAME + if not manifest.is_file(): + problems.append(f"skills/{folder.name}: missing {setup.MANIFEST_NAME}") + continue + try: + files[folder.name] = setup.parse_bmod_file(manifest, manifest.read_bytes()) + except Exception as error: + problems.append(f"{rel(folder.name)}: the runtime parser rejects this file: {error}") + + shipped = {folder.name for folder in folders} + records = {name: parsed.bmod for name, parsed in files.items() if parsed.bmod is not None} + members = {name: member_names(name, files[name]) for name in records} + + problems += record_problems(files, records, skills_dir) + problems += membership_problems(files, records, members, shipped) + retired: dict[str, setup.ParsedRetired] = {} + for name in records: + try: + retired[name] = setup.read_retired_file(skills_dir / name) + except Exception as error: + problems.append(f"skills/{name}/{setup.RETIRED_NAME}: the runtime parser rejects this file: {error}") + problems += retired_problems(retired, shipped) + for name, parsed in files.items(): + for table, source in (("bmod", parsed.bmod), ("skill", parsed.skill)): + if source is not None: + problems += requirement_problems(name, table, source, shipped) + documents = 0 + for name, record in records.items(): + folder = skills_dir / name + documents += len(record.knowledge) + (folder / knowledge.HELP_NAME).is_file() + problems += knowledge_problems(name, record, folder, members[name]) + problems += topic_problems(name, folder) + problems += roster_file_problems(name, record, folder, skills_dir) + problems += stamp_problems(name, folder / setup.MANIFEST_NAME) + problems += message_problems(name, folder / setup.MANIFEST_NAME) + + if not problems: + problems += runtime_problems(skills_dir) + + record_files = tuple(skills_dir / name / setup.MANIFEST_NAME for name in sorted(records)) + skill_count = sum(1 for parsed in files.values() if parsed.skill is not None) + return RepoReport(record_files, skill_count, documents, tuple(problems)) + + +def version_problem(version: str) -> str | None: + """Why an installed module could not use this record version, or None. The stamper applies the same rule.""" + match = setup.SEMVER.fullmatch(version) + if match is None: + return f"invalid version {version!r}: must be SemVer (MAJOR.MINOR.PATCH, optional prerelease), e.g. 6.12.0" + # setup.py refuses to order any version containing "-dev". + if "-dev" in version.casefold(): + return ( + f'invalid version {version!r}: setup.py cannot order "-dev" ' + "versions, so an installed module would never compare as current — " + "pick a different prerelease label" + ) + # setup.py drops build metadata when ordering, so "1.2.0+x" compares equal to "1.2.0". + if match.group("build") is not None: + base = version.split("+", 1)[0] + return ( + f"invalid version {version!r}: setup.py ignores build metadata when " + f"ordering, so this compares equal to {base!r} and an installed module " + "would never see the release — change the major, minor, patch, or " + "prerelease part" + ) + return None + + +def stamp_text(original: str, version: str) -> str: + """The file with only the `version` line inside [bmod] rewritten. Raises ValueError when that cannot be done.""" + lines = original.splitlines(keepends=True) + headers = [index for index, line in enumerate(lines) if BMOD_HEADER.fullmatch(line)] + if len(headers) != 1: + raise ValueError(f"expected exactly one '[bmod]' table header line, found {len(headers)}") + start = headers[0] + 1 + # Only the [bmod] table itself: a table further down may have a `version` of its own. + end = next((index for index in range(start, len(lines)) if TABLE_HEADER.fullmatch(lines[index])), len(lines)) + matches = [index for index in range(start, end) if VERSION_LINE.fullmatch(lines[index])] + if len(matches) != 1: + raise ValueError(f"expected exactly one 'version = \"...\"' line inside [bmod], found {len(matches)}") + match = VERSION_LINE.fullmatch(lines[matches[0]]) + assert match is not None + lines[matches[0]] = f'{match.group("head")}"{version}"{match.group("tail")}' + stamped = "".join(lines) + if tomllib.loads(stamped) != with_version(tomllib.loads(original), version): + raise ValueError("rewriting the version line would change something other than [bmod] version") + return stamped + + +def with_version(data: dict, version: str) -> dict: + expected = copy.deepcopy(data) + expected["bmod"]["version"] = version + return expected + + +def message_problems(name: str, manifest: Path) -> list[str]: + """Records in this repo carry both install messages, empty or not, so authors see the fields exist.""" + table = tomllib.loads(manifest.read_text(encoding="utf-8"))["bmod"] + return [ + f"{rel(name)}: [bmod] is missing {key!r}; add it, empty if the module has no message" + for key in MESSAGE_KEYS + if key not in table + ] + + +def stamp_problems(name: str, manifest: Path) -> list[str]: + """A record the stamper could not stamp fails here, at commit time.""" + try: + stamp_text(manifest.read_bytes().decode("utf-8"), STAMP_PROBE) + except (OSError, ValueError) as error: + return [f"{rel(name)}: stamp_release.py cannot stamp this file: {error}"] + return [] + + +def rel(folder: str) -> str: + return f"skills/{folder}/{setup.MANIFEST_NAME}" + + +def member_names(folder: str, parsed: setup.ParsedFile) -> tuple[str, ...]: + if parsed.bmod.skills is not None: + return parsed.bmod.skills + return (folder,) if parsed.skill is not None else () + + +def record_problems( + files: dict[str, setup.ParsedFile], records: dict[str, setup.ParsedBmod], skills_dir: Path +) -> list[str]: + problems: list[str] = [] + for name, parsed in files.items(): + if name.startswith(RECORD_PREFIX) and parsed.bmod is None: + problems.append( + f"{rel(name)}: a {RECORD_PREFIX}* folder holds a module record, but this file has no [bmod]" + ) + first_by_code: dict[str, str] = {} + for name, record in records.items(): + problem = version_problem(record.version) + if problem is not None: + problems.append(f"{rel(name)}: [bmod] {problem}") + skill_md = skills_dir / name / "SKILL.md" + if skill_md.is_symlink() or not skill_md.is_file(): + problems.append(f"skills/{name}: a module record folder must ship SKILL.md as a plain file") + if files[name].skill is None and name != RECORD_PREFIX + record.code: + problems.append( + f"{rel(name)}: a module record folder is named {RECORD_PREFIX + record.code!r} " + f"after its code; this one is {name!r}" + ) + first = first_by_code.setdefault(record.code.casefold(), name) + if first != name: + problems.append( + f"{rel(name)}: module code {record.code!r} is already declared by {rel(first)}; one record per code" + ) + versions = {name: record.version for name, record in records.items()} + if len(set(versions.values())) > 1: + listed = ", ".join(f"{name} has {version!r}" for name, version in versions.items()) + problems.append(f"skills/: every module record carries one version, stamped together; {listed}") + return problems + + +def membership_problems( + files: dict[str, setup.ParsedFile], + records: dict[str, setup.ParsedBmod], + members: dict[str, tuple[str, ...]], + shipped: set[str], +) -> list[str]: + problems: list[str] = [] + for name, parsed in files.items(): + if parsed.skill is None: + continue + if parsed.bmod is not None: + if name not in members[name]: + problems.append(f"{rel(name)}: holds [skill], but its own [bmod] skills list leaves {name!r} out") + continue + bmod = parsed.skill.bmod + if bmod in records and parsed.skill.source != records[bmod].update_source: + problems.append( + f"{rel(name)}: [skill] source {parsed.skill.source!r} differs from {rel(bmod)} " + f"update_source {records[bmod].update_source!r}" + ) + if bmod not in records: + problems.append( + f"{rel(name)}: [skill] bmod names {bmod!r}, which is not a module record in this repository" + ) + elif name not in members[bmod]: + problems.append(f"{rel(name)}: [skill] bmod names {bmod!r}, but {rel(bmod)} does not list {name!r}") + for name in records: + for member in members[name]: + parsed = files.get(member) + if member not in shipped: + problems.append(f"{rel(name)}: lists the skill {member!r}, which this repository does not ship") + elif parsed is None: + continue + elif parsed.skill is None or (parsed.bmod is not None and member != name): + problems.append( + f"{rel(name)}: lists {member!r}, which is a module record and not a skill of this module" + ) + elif member != name and parsed.skill.bmod != name: + problems.append( + f"{rel(name)}: lists the skill {member!r}, but {rel(member)} names {parsed.skill.bmod!r} as its bmod" + ) + return problems + + +def retired_problems(records: dict[str, setup.ParsedRetired], shipped: set[str]) -> list[str]: + """A retired name is never shipped again, and a rename points at a skill this repository ships.""" + problems: list[str] = [] + retired_by: dict[str, str] = {} + for name, record in records.items(): + retired = [*(rename.old for rename in record.renamed), *record.removed] + for old in retired: + if old in shipped: + problems.append( + f"{retired_rel(name)}: retires {old!r}, but skills/{old} still ships; a retired name is never reused" + ) + first = retired_by.setdefault(old, name) + if first != name: + problems.append(f"{retired_rel(name)}: retires {old!r}, which {retired_rel(first)} already retires") + targets = [rename.new for rename in record.renamed] + for new in dict.fromkeys(target for target in targets if targets.count(target) > 1): + problems.append( + f"{retired_rel(name)}: renames more than one skill to {new!r}; " + "their customizations would collide, so list the extras under removed" + ) + for rename in record.renamed: + if rename.new not in shipped: + problems.append( + f"{retired_rel(name)}: renames {rename.old!r} to {rename.new!r}, which this repository does not ship" + ) + return problems + + +def retired_rel(folder: str) -> str: + return f"skills/{folder}/{setup.RETIRED_NAME}" + + +def requirement_problems( + folder: str, table: str, source: setup.ParsedBmod | setup.ParsedSkill, shipped: set[str] +) -> list[str]: + """Shape is the runtime parser's job. These are the rules only the repository can decide.""" + problems: list[str] = [] + for field in ("required_skills", "recommended_skills"): + for requirement in getattr(source, field): + where = f"{rel(folder)}: {table}.{field} entry {requirement.skill!r}" + # setup.py drops build metadata when ordering, so such a minimum could never be told apart. + if requirement.version is not None and "+" in requirement.version: + problems.append( + f"{where} version {requirement.version!r} carries build metadata, which setup.py ignores " + f"when ordering; it would compare equal to {requirement.version.split('+', 1)[0]!r}" + ) + if requirement.source is None and requirement.skill not in shipped: + problems.append(f"{where} names no skill in this repository and gives no source to fetch it from") + return problems + + +def knowledge_problems(name: str, record: setup.ParsedBmod, folder: Path, members: tuple[str, ...]) -> list[str]: + problems: list[str] = [] + help_path = folder / knowledge.HELP_NAME + if name.startswith("bmod-") or help_path.exists() or help_path.is_symlink(): + problem = plain_file_problem(folder, PurePosixPath(knowledge.HELP_NAME)) + if problem is not None: + problems.append(f"skills/{name}/{knowledge.HELP_NAME}, which every bmod-* folder holds, {problem}") + for entry in record.knowledge: + if entry.path.as_posix() == knowledge.HELP_NAME: + problems.append(f"{rel(name)}: knowledge names {knowledge.HELP_NAME!r}, which is always read") + continue + problem = plain_file_problem(folder, entry.path) + if problem is not None: + problems.append(f"{rel(name)}: knowledge names {entry.path.as_posix()!r}, which {problem}") + for skill in entry.skills or (): + if skill not in members: + problems.append( + f"{rel(name)}: knowledge {entry.path.as_posix()!r} names {skill!r}, which is not a skill of " + f"module {record.code!r}" + ) + return problems + + +TOPIC_REFERENCE = re.compile(r"`help/([^`/<>]+\.md)`") + + +def topic_problems(name: str, folder: Path) -> list[str]: + """A topic `help.md` never points to is never read, and a pointer to no file misleads the reader.""" + try: + text = (folder / knowledge.HELP_NAME).read_text(encoding="utf-8") + except (OSError, UnicodeError): + text = "" + help_file = PurePosixPath(knowledge.HELP_NAME).name + named = set(TOPIC_REFERENCE.findall(text)) - {help_file} + shipped = {path.name for path in (folder / knowledge.TOPICS_DIR).glob("*.md")} - {help_file} + where = f"skills/{name}/{knowledge.TOPICS_DIR}" + problems = [f"{where}/{topic} is never named in {knowledge.HELP_NAME}" for topic in sorted(shipped - named)] + problems += [ + f"skills/{name}/{knowledge.HELP_NAME} names {where}/{topic}, which does not exist" + for topic in sorted(named - shipped) + ] + for topic in sorted(shipped): + try: + body = (folder / knowledge.TOPICS_DIR / topic).read_text(encoding="utf-8") + except (OSError, UnicodeError): + continue + problems += [ + f"{where}/{topic} names {where}/{other}, which does not exist" + for other in sorted(set(TOPIC_REFERENCE.findall(body)) - shipped - {help_file}) + ] + for topic in sorted(shipped & named): + problem = plain_file_problem(folder, PurePosixPath(knowledge.TOPICS_DIR, topic)) + if problem is not None: + problems.append(f"{where}/{topic} {problem}") + return problems + + +def plain_file_problem(folder: Path, relative: PurePosixPath) -> str | None: + path = folder.joinpath(*relative.parts) + if path.is_symlink(): + return "is a symlink, not a plain file" + try: + knowledge.read_document(path, folder) + except FileNotFoundError: + return "the module record does not ship" + except (OSError, ValueError) as error: + return str(error) + return None + + +def roster_file_problems(name: str, record: setup.ParsedBmod, folder: Path, skills_dir: Path) -> list[str]: + path = folder / knowledge.ROSTER_NAME + if not path.exists() and not path.is_symlink(): + return [] + where = f"skills/{name}/{knowledge.ROSTER_NAME}" + problem = plain_file_problem(folder, PurePosixPath(knowledge.ROSTER_NAME)) + if problem is not None: + return [f"{where} {problem}"] + try: + party = tomllib.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, tomllib.TOMLDecodeError) as error: + return [f"{where}: cannot read roster: {error}"] + return [f"{where}: {problem}" for problem in roster_problems(party, skills_dir)] + + +def roster_problems(party: dict, skills_dir: Path) -> list[str]: + """A group naming a member nobody defines, or a member naming a skill this repo lacks, is a typo.""" + problems: list[str] = [] + members = [member for member in as_list(party.get("members")) if isinstance(member, dict)] + codes = [member.get("code") for member in members] + repeated = sorted({code for code in codes if isinstance(code, str) and codes.count(code) > 1}) + problems += [f"member code {code!r} is defined twice" for code in repeated] + for member in members: + skill = member.get("skill") + if skill is not None and not (isinstance(skill, str) and (skills_dir / skill / "SKILL.md").is_file()): + problems.append(f"member {member.get('code')!r} names skill {skill!r}, which this repository does not ship") + for group in as_list(party.get("groups")): + if not isinstance(group, dict): + continue + for code in as_list(group.get("members")): + if code not in codes: + problems.append(f"group {group.get('id')!r} lists {code!r}, which no member defines") + return problems + + +def as_list(value: object) -> list: + return value if isinstance(value, list) else [] + + +def runtime_problems(skills_dir: Path) -> list[str]: + """The tree as `bmad` itself would discover it. Runs only on a tree the checks above accept.""" + problems: list[str] = [] + try: + installation = setup.discover_installation(skills_dir / "bmad") + except Exception as error: + return [f"skills/: setup.py cannot discover the modules: {error}"] + problems += [f"skills/: setup.py reports: {problem['message']}" for problem in installation.problems] + problems += [ + f"skills/: setup.py finds no module record {missing['bmod']!r} for {missing['skill']!r}" + for missing in installation.missing_records + ] + report = knowledge.collect([skills_dir]) + problems += [f"skills/: knowledge.py reports: {problem['problem']}" for problem in report["problems"]] + return problems + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Check every skills/*/bmod.toml in a repository.") + parser.add_argument( + "--project-root", type=Path, default=Path.cwd(), help="repository to check (default: the current directory)" + ) + args = parser.parse_args(argv) + + report = check_repo(args.project_root.resolve()) + if report.problems: + print(f"bmod file validation failed ({len(report.problems)}):", file=sys.stderr) + for problem in report.problems: + print(f" {problem}", file=sys.stderr) + return 1 + + print( + f"bmod files valid: {report.skills} skills, {len(report.records)} module records, " + f"{report.documents} knowledge documents." + ) + return 0 + + +if __name__ == "__main__": + if sys.platform == "win32": + # Piped output on Windows defaults to a legacy code page, not UTF-8. + sys.stdout.reconfigure(encoding="utf-8") + sys.stderr.reconfigure(encoding="utf-8") + sys.exit(main()) diff --git a/_bmad/toolsmith/scripts/.gitkeep b/_bmad/toolsmith/scripts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/context-resources/AD-ProfilePicture-Sync.ps1 b/context-resources/AD-ProfilePicture-Sync.ps1 new file mode 100644 index 0000000..7bfdd6b --- /dev/null +++ b/context-resources/AD-ProfilePicture-Sync.ps1 @@ -0,0 +1,289 @@ +# AD-ProfilePicture-Sync.ps1 +# Runs on the Domain Controller via Task Scheduler as a service account or SYSTEM +# Syncs profile pictures from the fileshare to AD attributes for all users in the target OU + +Import-Module ActiveDirectory + +$Config = @{ + SourcePath = "\\192.168.190.40\DC_data\ProfilePictures\source" + DefaultImage = "\\192.168.190.40\DC_data\ProfilePictures\default\default.jpg" + ProcessedPath = "\\192.168.190.40\DC_data\ProfilePictures\processed" + LogPath = "\\192.168.190.40\DC_data\ProfilePictures\logs\sync_log.txt" + TargetOU = "OU=Organisation,DC=ping-mee,DC=local" + MaxPhotoBytes = 100KB +} + +function Write-Log($msg) { + $line = "[$(Get-Date -f 'yyyy-MM-dd HH:mm:ss')] $msg" + Add-Content -Path $Config.LogPath -Value $line + Write-Host $line +} + +function Resize-Image { + param( + [string]$InputPath, + [string]$OutputPath, + [int]$Size, + [int]$Quality = 90 + ) + Add-Type -AssemblyName System.Drawing + + $src = [System.Drawing.Image]::FromFile($InputPath) + + $ratioX = $Size / $src.Width + $ratioY = $Size / $src.Height + $ratio = if ($ratioX -le $ratioY) { $ratioX } else { $ratioY } + + $newWidth = [int]($src.Width * $ratio) + $newHeight = [int]($src.Height * $ratio) + + $bmp = New-Object System.Drawing.Bitmap($newWidth, $newHeight) + $g = [System.Drawing.Graphics]::FromImage($bmp) + $g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic + $g.Clear([System.Drawing.Color]::White) + $g.DrawImage($src, 0, 0, $newWidth, $newHeight) + + $encoder = [System.Drawing.Imaging.Encoder]::Quality + $encoderParams = New-Object System.Drawing.Imaging.EncoderParameters(1) + $encoderParams.Param[0] = New-Object System.Drawing.Imaging.EncoderParameter($encoder, $Quality) + $codec = [System.Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() | Where-Object { $_.MimeType -eq 'image/jpeg' } + + $bmp.Save($OutputPath, $codec, $encoderParams) + $g.Dispose(); $bmp.Dispose(); $src.Dispose() +} + +function Set-ADProfilePhoto { + param($User, [byte[]]$PhotoBytes) + + # thumbnailPhoto — primary Microsoft attribute, used by Outlook, most LDAP apps + Set-ADUser -Identity $User -Replace @{ thumbnailPhoto = $PhotoBytes } + + # jpegPhoto — RFC 2798 / inetOrgPerson, used by Nextcloud, GitLab, etc. + Set-ADUser -Identity $User -Replace @{ jpegPhoto = $PhotoBytes } + + # photo — inetOrgPerson, broad LDAP compatibility + try { + Set-ADUser -Identity $User -Replace @{ photo = $PhotoBytes } + } catch { + Write-Log " [WARN] 'photo' attribute not writable for $($User.SamAccountName): $_" + } +} + +# --- Main Loop --- +$users = Get-ADUser -SearchBase $Config.TargetOU -Filter * -Properties thumbnailPhoto, jpegPhoto, SID + +foreach ($user in $users) { + $sam = $user.SamAccountName + Write-Log "Processing: $sam" + + # Resolve source image + $imgPath = Join-Path $Config.SourcePath "$sam.jpg" + if (-not (Test-Path $imgPath)) { + Write-Log " No specific photo found, using default." + $imgPath = $Config.DefaultImage + } + + # GUARD: Abort if resolved image still doesn't exist + if (-not (Test-Path $imgPath)) { + Write-Log " [ERROR] Image not found at '$imgPath'. Skipping $sam." + continue + } + + # Generate processed versions — both from the original source + $processed96 = Join-Path $Config.ProcessedPath "${sam}_96.jpg" + $processed448 = Join-Path $Config.ProcessedPath "${sam}_448.jpg" + + try { + Resize-Image -InputPath $imgPath -OutputPath $processed96 -Size 96 + Resize-Image -InputPath $imgPath -OutputPath $processed448 -Size 448 + } catch { + Write-Log " [ERROR] Failed to resize image for $sam : $_" + continue + } + + # AD only receives the 96px version to stay within the 100KB attribute limit + $photoBytes = [System.IO.File]::ReadAllBytes($processed96) + + if ($photoBytes.Length -gt $Config.MaxPhotoBytes) { + Write-Log " [ERROR] Photo for $sam exceeds 100KB after resize. Skipping AD write." + continue + } + + # Write hash file for change detection on next run + $hash = (Get-FileHash $imgPath -Algorithm MD5).Hash + $hashFile = Join-Path $Config.ProcessedPath "${sam}.md5" + $hash | Set-Content $hashFile + + Set-ADProfilePhoto -User $user -PhotoBytes $photoBytes + Write-Log " AD attributes updated (thumbnailPhoto, jpegPhoto, photo)." +} + +Write-Log "Sync complete." +# SIG # Begin signature block +# MIIe7gYJKoZIhvcNAQcCoIIe3zCCHtsCAQExCzAJBgUrDgMCGgUAMGkGCisGAQQB +# gjcCAQSgWzBZMDQGCisGAQQBgjcCAR4wJgIDAQAABBAfzDtgWUsITrck0sYpfvNR +# AgEAAgEAAgEAAgEAAgEAMCEwCQYFKw4DAhoFAAQUVspl2f++k4gMH4DhClIJlSS6 +# HvGgghkyMIIGFDCCA/ygAwIBAgIQeiOu2lNplg+RyD5c9MfjPzANBgkqhkiG9w0B +# AQwFADBXMQswCQYDVQQGEwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMS4w +# LAYDVQQDEyVTZWN0aWdvIFB1YmxpYyBUaW1lIFN0YW1waW5nIFJvb3QgUjQ2MB4X +# DTIxMDMyMjAwMDAwMFoXDTM2MDMyMTIzNTk1OVowVTELMAkGA1UEBhMCR0IxGDAW +# BgNVBAoTD1NlY3RpZ28gTGltaXRlZDEsMCoGA1UEAxMjU2VjdGlnbyBQdWJsaWMg +# VGltZSBTdGFtcGluZyBDQSBSMzYwggGiMA0GCSqGSIb3DQEBAQUAA4IBjwAwggGK +# AoIBgQDNmNhDQatugivs9jN+JjTkiYzT7yISgFQ+7yavjA6Bg+OiIjPm/N/t3nC7 +# wYUrUlY3mFyI32t2o6Ft3EtxJXCc5MmZQZ8AxCbh5c6WzeJDB9qkQVa46xiYEpc8 +# 1KnBkAWgsaXnLURoYZzksHIzzCNxtIXnb9njZholGw9djnjkTdAA83abEOHQ4ujO +# GIaBhPXG2NdV8TNgFWZ9BojlAvflxNMCOwkCnzlH4oCw5+4v1nssWeN1y4+RlaOy +# wwRMUi54fr2vFsU5QPrgb6tSjvEUh1EC4M29YGy/SIYM8ZpHadmVjbi3Pl8hJiTW +# w9jiCKv31pcAaeijS9fc6R7DgyyLIGflmdQMwrNRxCulVq8ZpysiSYNi79tw5RHW +# ZUEhnRfs/hsp/fwkXsynu1jcsUX+HuG8FLa2BNheUPtOcgw+vHJcJ8HnJCrcUWhd +# Fczf8O+pDiyGhVYX+bDDP3GhGS7TmKmGnbZ9N+MpEhWmbiAVPbgkqykSkzyYVr15 +# OApZYK8CAwEAAaOCAVwwggFYMB8GA1UdIwQYMBaAFPZ3at0//QET/xahbIICL9AK +# PRQlMB0GA1UdDgQWBBRfWO1MMXqiYUKNUoC6s2GXGaIymzAOBgNVHQ8BAf8EBAMC +# AYYwEgYDVR0TAQH/BAgwBgEB/wIBADATBgNVHSUEDDAKBggrBgEFBQcDCDARBgNV +# HSAECjAIMAYGBFUdIAAwTAYDVR0fBEUwQzBBoD+gPYY7aHR0cDovL2NybC5zZWN0 +# aWdvLmNvbS9TZWN0aWdvUHVibGljVGltZVN0YW1waW5nUm9vdFI0Ni5jcmwwfAYI +# KwYBBQUHAQEEcDBuMEcGCCsGAQUFBzAChjtodHRwOi8vY3J0LnNlY3RpZ28uY29t +# L1NlY3RpZ29QdWJsaWNUaW1lU3RhbXBpbmdSb290UjQ2LnA3YzAjBggrBgEFBQcw +# AYYXaHR0cDovL29jc3Auc2VjdGlnby5jb20wDQYJKoZIhvcNAQEMBQADggIBABLX +# eyCtDjVYDJ6BHSVY/UwtZ3Svx2ImIfZVVGnGoUaGdltoX4hDskBMZx5NY5L6SCcw +# DMZhHOmbyMhyOVJDwm1yrKYqGDHWzpwVkFJ+996jKKAXyIIaUf5JVKjccev3w16m +# NIUlNTkpJEor7edVJZiRJVCAmWAaHcw9zP0hY3gj+fWp8MbOocI9Zn78xvm9XKGB +# p6rEs9sEiq/pwzvg2/KjXE2yWUQIkms6+yslCRqNXPjEnBnxuUB1fm6bPAV+Tsr/ +# Qrd+mOCJemo06ldon4pJFbQd0TQVIMLv5koklInHvyaf6vATJP4DfPtKzSBPkKlO +# tyaFTAjD2Nu+di5hErEVVaMqSVbfPzd6kNXOhYm23EWm6N2s2ZHCHVhlUgHaC4AC +# MRCgXjYfQEDtYEK54dUwPJXV7icz0rgCzs9VI29DwsjVZFpO4ZIVR33LwXyPDbYF +# kLqYmgHjR3tKVkhh9qKV2WCmBuC27pIOx6TYvyqiYbntinmpOqh/QPAnhDgexKG9 +# GX/n1PggkGi9HCapZp8fRwg8RftwS21Ln61euBG0yONM6noD2XQPrFwpm3GcuqJM +# f0o8LLrFkSLRQNwxPDDkWXhW+gZswbaiie5fd/W2ygcto78XCSPfFWveUOSZ5SqK +# 95tBO8aTHmEa4lpJVD7HrTEn9jb1EGvxOb1cnn0CMIIGKjCCBRKgAwIBAgITdQAA +# AA+pTsONn8YiVAAAAAAADzANBgkqhkiG9w0BAQsFADBHMRUwEwYKCZImiZPyLGQB +# GRYFbG9jYWwxGDAWBgoJkiaJk/IsZAEZFghwaW5nLW1lZTEUMBIGA1UEAxMLcGlu +# Zy1tZWUtQ0EwHhcNMjYwNDA5MDczOTM2WhcNMjcwNDA5MDczOTM2WjB8MRUwEwYK +# CZImiZPyLGQBGRYFbG9jYWwxGDAWBgoJkiaJk/IsZAEZFghwaW5nLW1lZTEVMBMG +# A1UECxMMT3JnYW5pc2F0aW9uMRcwFQYDVQQLEw5BZG1pbmlzdHJhdGlvbjEZMBcG +# A1UEAxMQSVQgQWRtaW5pc3RyYXRvcjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC +# AQoCggEBALCk9syN/9zSWis5l6RKd+Twg6GnkBhwkUn5LDwdALKc5bImd7rLCF9j +# +m6zWNEq4Lrl8382PSFLnCnXRd0MeecmMBRXEryLktcnl2OgFPiu87fodptBYRBp +# LpUyHB0XeJlT+plHgURfFIJ8vZwvNP+i0Ai1xmcKdlmUH3PQRVTg5QaH0kzHYPgf +# BNhsWXRujCjg23sR0sZ2seAUOX3KVfiGFyZswHF09MlxJPq05QR8TBTfK1InuTaT +# 1is38tCKEkANQjVt4yZ8KYV0YuM897oEZvtnwJEMDNP75H/3uZoIJ0v0dfUyjMHN +# DfKdt0aDxZpPTT9iICZl3EMveAN+l70CAwEAAaOCAtgwggLUMD0GCSsGAQQBgjcV +# BwQwMC4GJisGAQQBgjcVCIPqtkCCy4lzgsWRF4Xw1Q+BwbsoQISyshCFqrN4AgFk +# AgEGMBMGA1UdJQQMMAoGCCsGAQUFBwMDMA4GA1UdDwEB/wQEAwIHgDAbBgkrBgEE +# AYI3FQoEDjAMMAoGCCsGAQUFBwMDMB0GA1UdDgQWBBTroFoeAJDNgwWOwbsoP6+h +# n09ftjAfBgNVHSMEGDAWgBTgQNNlwwWCz4c1/FnAOF+J5D5/0DCByAYDVR0fBIHA +# MIG9MIG6oIG3oIG0hoGxbGRhcDovLy9DTj1waW5nLW1lZS1DQSxDTj1EQzEsQ049 +# Q0RQLENOPVB1YmxpYyUyMEtleSUyMFNlcnZpY2VzLENOPVNlcnZpY2VzLENOPUNv +# bmZpZ3VyYXRpb24sREM9cGluZy1tZWUsREM9bG9jYWw/Y2VydGlmaWNhdGVSZXZv +# Y2F0aW9uTGlzdD9iYXNlP29iamVjdENsYXNzPWNSTERpc3RyaWJ1dGlvblBvaW50 +# MIHABggrBgEFBQcBAQSBszCBsDCBrQYIKwYBBQUHMAKGgaBsZGFwOi8vL0NOPXBp +# bmctbWVlLUNBLENOPUFJQSxDTj1QdWJsaWMlMjBLZXklMjBTZXJ2aWNlcyxDTj1T +# ZXJ2aWNlcyxDTj1Db25maWd1cmF0aW9uLERDPXBpbmctbWVlLERDPWxvY2FsP2NB +# Q2VydGlmaWNhdGU/YmFzZT9vYmplY3RDbGFzcz1jZXJ0aWZpY2F0aW9uQXV0aG9y +# aXR5MDIGA1UdEQQrMCmgJwYKKwYBBAGCNxQCA6AZDBdpdC1hZG1pbkBwaW5nLW1l +# ZS5sb2NhbDBPBgkrBgEEAYI3GQIEQjBAoD4GCisGAQQBgjcZAgGgMAQuUy0xLTUt +# MjEtMzIxMTQ3Njk4My0xNjY0MTg4ODMzLTM5MDgzNjg2MDMtMTEwNDANBgkqhkiG +# 9w0BAQsFAAOCAQEAw+wwNb8CIqJaqpTzGgJ8TfS3Wt1y9qlNEWCfdyKjl3LbH7HU +# uJmXFrUiygxyWlDtHbD5bah4JlLprv/wSkAN8c3DqxGxTB06orLZNRXH/plR6R69 +# wRD23GrxTGcP66iIRUnpDCiX+tXlRyQnxtb/7OPFSy1B/NYC6re74M7fV3cZ6/lU +# gxy0aLzwCWjgfzJvJMTDSsWJKKIHpdIktR2EhCkCdWJay+8j2SAmT+IG8i/MAXrc +# 6wmc4p/O17eAx0sx22hw6JPiA9TNFLfSGrPSzUjX3SD3OuoxPnJNpM4GY4kte5Hs +# lIQ8+KnsCf2oBsk7AciBlLTEuOshD7rtTom9hzCCBmIwggTKoAMCAQICEQCkKTtu +# Ht3XpzQIh616TrckMA0GCSqGSIb3DQEBDAUAMFUxCzAJBgNVBAYTAkdCMRgwFgYD +# VQQKEw9TZWN0aWdvIExpbWl0ZWQxLDAqBgNVBAMTI1NlY3RpZ28gUHVibGljIFRp +# bWUgU3RhbXBpbmcgQ0EgUjM2MB4XDTI1MDMyNzAwMDAwMFoXDTM2MDMyMTIzNTk1 +# OVowcjELMAkGA1UEBhMCR0IxFzAVBgNVBAgTDldlc3QgWW9ya3NoaXJlMRgwFgYD +# VQQKEw9TZWN0aWdvIExpbWl0ZWQxMDAuBgNVBAMTJ1NlY3RpZ28gUHVibGljIFRp +# bWUgU3RhbXBpbmcgU2lnbmVyIFIzNjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCC +# AgoCggIBANOElfRupFN48j0QS3gSBzzclIFTZ2Gsn7BjsmBF659/kpA2Ey7NXK3M +# P6JdrMBNU8wdmkf+SSIyjX++UAYWtg3Y/uDRDyg8RxHeHRJ+0U1jHEyH5uPdk1tt +# iPC3x/gOxIc9P7Gn3OgW7DQc4x07exZ4DX4XyaGDq5LoEmk/BdCM1IelVMKB3WA6 +# YpZ/XYdJ9JueOXeQObSQ/dohQCGyh0FhmwkDWKZaqQBWrBwZ++zqlt+z/QYTgEnZ +# o6dyIo2IhXXANFkCHutL8765NBxvolXMFWY8/reTnFxk3MajgM5NX6wzWdWsPJxY +# RhLxtJLSUJJ5yWRNw+NBqH1ezvFs4GgJ2ZqFJ+Dwqbx9+rw+F2gBdgo4j7CVomP4 +# 9sS7CbqsdybbiOGpB9DJhs5QVMpYV73TVV3IwLiBHBECrTgUfZVOMF0KSEq2zk/L +# sfvehswavE3W4aBXJmGjgWSpcDz+6TqeTM8f1DIcgQPdz0IYgnT3yFTgiDbFGOFN +# t6eCidxdR6j9x+kpcN5RwApy4pRhE10YOV/xafBvKpRuWPjOPWRBlKdm53kS2aMh +# 08spx7xSEqXn4QQldCnUWRz3Lki+TgBlpwYwJUbR77DAayNwAANE7taBrz2v+Mnn +# ogMrvvct0iwvfIA1W8kp155Lo44SIfqGmrbJP6Mn+Udr3MR2oWozAgMBAAGjggGO +# MIIBijAfBgNVHSMEGDAWgBRfWO1MMXqiYUKNUoC6s2GXGaIymzAdBgNVHQ4EFgQU +# iGGMoSo3ZIEoYKGbMdCM/SwCzk8wDgYDVR0PAQH/BAQDAgbAMAwGA1UdEwEB/wQC +# MAAwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwgwSgYDVR0gBEMwQTA1BgwrBgEEAbIx +# AQIBAwgwJTAjBggrBgEFBQcCARYXaHR0cHM6Ly9zZWN0aWdvLmNvbS9DUFMwCAYG +# Z4EMAQQCMEoGA1UdHwRDMEEwP6A9oDuGOWh0dHA6Ly9jcmwuc2VjdGlnby5jb20v +# U2VjdGlnb1B1YmxpY1RpbWVTdGFtcGluZ0NBUjM2LmNybDB6BggrBgEFBQcBAQRu +# MGwwRQYIKwYBBQUHMAKGOWh0dHA6Ly9jcnQuc2VjdGlnby5jb20vU2VjdGlnb1B1 +# YmxpY1RpbWVTdGFtcGluZ0NBUjM2LmNydDAjBggrBgEFBQcwAYYXaHR0cDovL29j +# c3Auc2VjdGlnby5jb20wDQYJKoZIhvcNAQEMBQADggGBAAKBPqSGclEh+WWpLj1S +# iuHlm8xLE0SThI2yLuq+75s11y6SceBchpnKpxWaGtXc8dya1Aq3RuW//y3wMThs +# vT4fSba2AoSWlR67rA4fTYGMIhgzocsids0ct/pHaocLVJSwnTYxY2pE0hPoZAvR +# ebctbsTqENmZHyOVjOFlwN2R3DRweFeNs4uyZN5LRJ5EnVYlcTOq3bl1tI5poru9 +# WaQRWQ4eynXp7Pj0Fz4DKr86HYECRJMWiDjeV0QqAcQMFsIjJtrYTw7mU81qf4FB +# c4u4swphLeKRNyn9DDrd3HIMJ+CpdhSHEGleeZ5I79YDg3B3A/fmVY2GaMik1Vm+ +# FajEMv4/EN2mmHf4zkOuhYZNzVm4NrWJeY4UAriLBOeVYODdA1GxFr1ycbcUEGlU +# ecc4RCPgYySs4d00NNuicR4a9n7idJlevAJbha/arIYMEuUqTeRRbWkhJwMKmb9y +# EvppRudKyu1t6l21sIuIZqcpVH8oLWCxHS0LpDRF9Y4jijCCBoIwggRqoAMCAQIC +# EDbCsL18Gzrno7PdNsvJdWgwDQYJKoZIhvcNAQEMBQAwgYgxCzAJBgNVBAYTAlVT +# MRMwEQYDVQQIEwpOZXcgSmVyc2V5MRQwEgYDVQQHEwtKZXJzZXkgQ2l0eTEeMBwG +# A1UEChMVVGhlIFVTRVJUUlVTVCBOZXR3b3JrMS4wLAYDVQQDEyVVU0VSVHJ1c3Qg +# UlNBIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTIxMDMyMjAwMDAwMFoXDTM4 +# MDExODIzNTk1OVowVzELMAkGA1UEBhMCR0IxGDAWBgNVBAoTD1NlY3RpZ28gTGlt +# aXRlZDEuMCwGA1UEAxMlU2VjdGlnbyBQdWJsaWMgVGltZSBTdGFtcGluZyBSb290 +# IFI0NjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAIid2LlFZ50d3ei5 +# JoGaVFTAfEkFm8xaFQ/ZlBBEtEFAgXcUmanU5HYsyAhTXiDQkiUvpVdYqZ1uYoZE +# MgtHES1l1Cc6HaqZzEbOOp6YiTx63ywTon434aXVydmhx7Dx4IBrAou7hNGsKioI +# BPy5GMN7KmgYmuu4f92sKKjbxqohUSfjk1mJlAjthgF7Hjx4vvyVDQGsd5KarLW5 +# d73E3ThobSkob2SL48LpUR/O627pDchxll+bTSv1gASn/hp6IuHJorEu6EopoB1C +# NFp/+HpTXeNARXUmdRMKbnXWflq+/g36NJXB35ZvxQw6zid61qmrlD/IbKJA6COw +# /8lFSPQwBP1ityZdwuCysCKZ9ZjczMqbUcLFyq6KdOpuzVDR3ZUwxDKL1wCAxgL2 +# Mpz7eZbrb/JWXiOcNzDpQsmwGQ6Stw8tTCqPumhLRPb7YkzM8/6NnWH3T9ClmcGS +# F22LEyJYNWCHrQqYubNeKolzqUbCqhSqmr/UdUeb49zYHr7ALL8bAJyPDmubNqMt +# uaobKASBqP84uhqcRY/pjnYd+V5/dcu9ieERjiRKKsxCG1t6tG9oj7liwPddXEcY +# GOUiWLm742st50jGwTzxbMpepmOP1mLnJskvZaN5e45NuzAHteORlsSuDt5t4BBR +# CJL+5EZnnw0ezntk9R8QJyAkL6/bAgMBAAGjggEWMIIBEjAfBgNVHSMEGDAWgBRT +# eb9aqitKz1SA4dibwJ3ysgNmyzAdBgNVHQ4EFgQU9ndq3T/9ARP/FqFsggIv0Ao9 +# FCUwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wEwYDVR0lBAwwCgYI +# KwYBBQUHAwgwEQYDVR0gBAowCDAGBgRVHSAAMFAGA1UdHwRJMEcwRaBDoEGGP2h0 +# dHA6Ly9jcmwudXNlcnRydXN0LmNvbS9VU0VSVHJ1c3RSU0FDZXJ0aWZpY2F0aW9u +# QXV0aG9yaXR5LmNybDA1BggrBgEFBQcBAQQpMCcwJQYIKwYBBQUHMAGGGWh0dHA6 +# Ly9vY3NwLnVzZXJ0cnVzdC5jb20wDQYJKoZIhvcNAQEMBQADggIBAA6+ZUHtaES4 +# 5aHF1BGH5Lc7JYzrftrIF5Ht2PFDxKKFOct/awAEWgHQMVHol9ZLSyd/pYMbaC0I +# Z+XBW9xhdkkmUV/KbUOiL7g98M/yzRyqUOZ1/IY7Ay0YbMniIibJrPcgFp73WDnR +# DKtVutShPSZQZAdtFwXnuiWl8eFARK3PmLqEm9UsVX+55DbVIz33Mbhba0HUTEYv +# 3yJ1fwKGxPBsP/MgTECimh7eXomvMm0/GPxX2uhwCcs/YLxDnBdVVlxvDjHjO1cu +# wbOpkiJGHmLXXVNbsdXUC2xBrq9fLrfe8IBsA4hopwsCj8hTuwKXJlSTrZcPRVSc +# cP5i9U28gZ7OMzoJGlxZ5384OKm0r568Mo9TYrqzKeKZgFo0fj2/0iHbj55hc20j +# fxvK3mQi+H7xpbzxZOFGm/yVQkpo+ffv5gdhp+hv1GDsvJOtJinJmgGbBFZIThbq +# I+MHvAmMmkfb3fTxmSkop2mSJL1Y2x/955S29Gu0gSJIkc3z30vU/iXrMpWx2tS7 +# UVfVP+5tKuzGtgkP7d/doqDrLF1u6Ci3TpjAZdeLLlRQZm867eVeXED58LXd1Dk6 +# UvaAhvmWYXoiLz4JA5gPBcz7J311uahxCweNxE+xxxR3kT0WKzASo5G/PyDez6NH +# dIUKBeE3jDPs2ACc6CkJ1Sji4PKWVT0/MYIFJjCCBSICAQEwXjBHMRUwEwYKCZIm +# iZPyLGQBGRYFbG9jYWwxGDAWBgoJkiaJk/IsZAEZFghwaW5nLW1lZTEUMBIGA1UE +# AxMLcGluZy1tZWUtQ0ECE3UAAAAPqU7DjZ/GIlQAAAAAAA8wCQYFKw4DAhoFAKB4 +# MBgGCisGAQQBgjcCAQwxCjAIoAKAAKECgAAwGQYJKoZIhvcNAQkDMQwGCisGAQQB +# gjcCAQQwHAYKKwYBBAGCNwIBCzEOMAwGCisGAQQBgjcCARUwIwYJKoZIhvcNAQkE +# MRYEFCSv+GJlt5QapIL4BtjV31KgrTQfMA0GCSqGSIb3DQEBAQUABIIBAF1PE5ri +# iMlYNkjQsr3+Z1SJ/EItpyYlEZrT13t42WraDTIR+FxggkCTOaM6/FSRUXO8kMCu +# CiFx0IVl50P/YBbZ/l9kY0DD9dHcfoeCOaF5KyfwnZONQbSdMq/nydDqKtXHMu6/ +# VuMGtoP+SwfR9dWWzoooCKcnx5InVcl2XJR5yvet11DuoDFVmbxlKfqaIX2ieZ4e +# TJ431lLN4A5W5Q+1a2GEw3MPO6IkT68EsqaNuygscKGOLMrkZxpgpvXTM4C3LPeG +# EFlvTa+TD+ScTIl3djz38LsuppSN0XdPbpP9h8FkeWfa8wo/v061toA7RON9t4cA +# T+g9bj+/UPaEA3OhggMjMIIDHwYJKoZIhvcNAQkGMYIDEDCCAwwCAQEwajBVMQsw +# CQYDVQQGEwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMSwwKgYDVQQDEyNT +# ZWN0aWdvIFB1YmxpYyBUaW1lIFN0YW1waW5nIENBIFIzNgIRAKQpO24e3denNAiH +# rXpOtyQwDQYJYIZIAWUDBAICBQCgeTAYBgkqhkiG9w0BCQMxCwYJKoZIhvcNAQcB +# MBwGCSqGSIb3DQEJBTEPFw0yNjA0MTkxMTU5MDVaMD8GCSqGSIb3DQEJBDEyBDDb +# /5Xcb4yXXq8UPocqdx39gipy8qf2UiiIqbBSW4YWvxWqqsuezH7ONKwPpwu38Asw +# DQYJKoZIhvcNAQEBBQAEggIACh+I1I6l7UW7P6vre696SpxrUTRFOBAk4HwQOkCE +# F5owI0x2Gwas9mNZEuUEvZEfOuJ/0Y6LDq3T+dJADV/lHvqkVlOZipDJ0cQaNgeE +# AzdnXm/xirXfuzocjDVQXR+ucvUm2SFTtOrWxF9m1ivdpjZUtN+Z94IuLVLfZ7GE +# nivwLBhMAU63lWZl2iSOnIhC8LtHxPtDlPDffCf88ng8p0jHjuWl3RbqcjxH0He2 +# GoLZZ8UNxXDrH3QhASurk7ou6HyJbKXdE617CaPGBSBB07aIPtJmh789MXX2tCKZ +# lEV7c/Hd6f9K1j/tYBmt0e2tXWm1NeL/uta0owsLWAcIk1KQ1ZsXjOjjqHQdHphQ +# 21ab0nKZTjZROQMC3Rc1nvwZ/xG/mtLLQXS/kZMahaDj0e8fWhBU9rag27RLLECB +# eyat+DSCUA27BZo7mQt9kqT4EurjqFWVtGiFNDNtW+TzUeBr3Fl7md5ARWMf/5jJ +# KvmDAnwEZKPBRoOuaGpJfuy89vXcRYr4czBNoFGsBAwLWaIZKI2IuXNHM7ZUc/LJ +# 4UNNrBHl8Z4niaSC9i4uv3xi7zMtQpJKJzYiMpAjC3/zPcj8a0T+pGSGY/2DIRJj +# SALVk4wBQJyJxBZAnw4OifhAEgG6edHDQmPEPep2RaFk1TOJBnkG3WyCxF/YurD/ +# gB0= +# SIG # End signature block diff --git a/context-resources/Set-AccountPictureRegistry-System.ps1 b/context-resources/Set-AccountPictureRegistry-System.ps1 new file mode 100644 index 0000000..68b7f1d --- /dev/null +++ b/context-resources/Set-AccountPictureRegistry-System.ps1 @@ -0,0 +1,196 @@ +# Set-AccountPictureRegistry-System.ps1 +# Computer Configuration → Windows Settings → Scripts → Startup + +$image_base = "C:\ProgramData\AccountPictures" + +# Find all manifest files left by the user logon script +$manifests = Get-ChildItem $image_base -Recurse -Filter "reg_manifest.txt" -ErrorAction SilentlyContinue + +ForEach ($manifest in $manifests) { + $lines = Get-Content $manifest.FullName + $sid = $lines[0] + $reg_key = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AccountPicture\Users\$sid" + + If ((Test-Path $reg_key) -eq $false) { New-Item -Path $reg_key -Force | Out-Null } + New-ItemProperty -Path $reg_key -Name "SourceId" -Value 0 -Force | Out-Null + + ForEach ($line in ($lines | Select-Object -Skip 1)) { + $parts = $line -split "=", 2 + $size = $parts[0] + $path = $parts[1] + New-ItemProperty -Path $reg_key -Name "Image$size" -Value $path -Force | Out-Null + Write-Host " Set Image$size → $path" + } + + # Clean up manifest after processing + Remove-Item $manifest.FullName -Force + Write-Host "Registry updated for SID: $sid" +} +# SIG # Begin signature block +# MIIe7gYJKoZIhvcNAQcCoIIe3zCCHtsCAQExCzAJBgUrDgMCGgUAMGkGCisGAQQB +# gjcCAQSgWzBZMDQGCisGAQQBgjcCAR4wJgIDAQAABBAfzDtgWUsITrck0sYpfvNR +# AgEAAgEAAgEAAgEAAgEAMCEwCQYFKw4DAhoFAAQUveyvkQyCLXOAdx4kvXAXpYA7 +# ftKgghkyMIIGFDCCA/ygAwIBAgIQeiOu2lNplg+RyD5c9MfjPzANBgkqhkiG9w0B +# AQwFADBXMQswCQYDVQQGEwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMS4w +# LAYDVQQDEyVTZWN0aWdvIFB1YmxpYyBUaW1lIFN0YW1waW5nIFJvb3QgUjQ2MB4X +# DTIxMDMyMjAwMDAwMFoXDTM2MDMyMTIzNTk1OVowVTELMAkGA1UEBhMCR0IxGDAW +# BgNVBAoTD1NlY3RpZ28gTGltaXRlZDEsMCoGA1UEAxMjU2VjdGlnbyBQdWJsaWMg +# VGltZSBTdGFtcGluZyBDQSBSMzYwggGiMA0GCSqGSIb3DQEBAQUAA4IBjwAwggGK +# AoIBgQDNmNhDQatugivs9jN+JjTkiYzT7yISgFQ+7yavjA6Bg+OiIjPm/N/t3nC7 +# wYUrUlY3mFyI32t2o6Ft3EtxJXCc5MmZQZ8AxCbh5c6WzeJDB9qkQVa46xiYEpc8 +# 1KnBkAWgsaXnLURoYZzksHIzzCNxtIXnb9njZholGw9djnjkTdAA83abEOHQ4ujO +# GIaBhPXG2NdV8TNgFWZ9BojlAvflxNMCOwkCnzlH4oCw5+4v1nssWeN1y4+RlaOy +# wwRMUi54fr2vFsU5QPrgb6tSjvEUh1EC4M29YGy/SIYM8ZpHadmVjbi3Pl8hJiTW +# w9jiCKv31pcAaeijS9fc6R7DgyyLIGflmdQMwrNRxCulVq8ZpysiSYNi79tw5RHW +# ZUEhnRfs/hsp/fwkXsynu1jcsUX+HuG8FLa2BNheUPtOcgw+vHJcJ8HnJCrcUWhd +# Fczf8O+pDiyGhVYX+bDDP3GhGS7TmKmGnbZ9N+MpEhWmbiAVPbgkqykSkzyYVr15 +# OApZYK8CAwEAAaOCAVwwggFYMB8GA1UdIwQYMBaAFPZ3at0//QET/xahbIICL9AK +# PRQlMB0GA1UdDgQWBBRfWO1MMXqiYUKNUoC6s2GXGaIymzAOBgNVHQ8BAf8EBAMC +# AYYwEgYDVR0TAQH/BAgwBgEB/wIBADATBgNVHSUEDDAKBggrBgEFBQcDCDARBgNV +# HSAECjAIMAYGBFUdIAAwTAYDVR0fBEUwQzBBoD+gPYY7aHR0cDovL2NybC5zZWN0 +# aWdvLmNvbS9TZWN0aWdvUHVibGljVGltZVN0YW1waW5nUm9vdFI0Ni5jcmwwfAYI +# KwYBBQUHAQEEcDBuMEcGCCsGAQUFBzAChjtodHRwOi8vY3J0LnNlY3RpZ28uY29t +# L1NlY3RpZ29QdWJsaWNUaW1lU3RhbXBpbmdSb290UjQ2LnA3YzAjBggrBgEFBQcw +# AYYXaHR0cDovL29jc3Auc2VjdGlnby5jb20wDQYJKoZIhvcNAQEMBQADggIBABLX +# eyCtDjVYDJ6BHSVY/UwtZ3Svx2ImIfZVVGnGoUaGdltoX4hDskBMZx5NY5L6SCcw +# DMZhHOmbyMhyOVJDwm1yrKYqGDHWzpwVkFJ+996jKKAXyIIaUf5JVKjccev3w16m +# NIUlNTkpJEor7edVJZiRJVCAmWAaHcw9zP0hY3gj+fWp8MbOocI9Zn78xvm9XKGB +# p6rEs9sEiq/pwzvg2/KjXE2yWUQIkms6+yslCRqNXPjEnBnxuUB1fm6bPAV+Tsr/ +# Qrd+mOCJemo06ldon4pJFbQd0TQVIMLv5koklInHvyaf6vATJP4DfPtKzSBPkKlO +# tyaFTAjD2Nu+di5hErEVVaMqSVbfPzd6kNXOhYm23EWm6N2s2ZHCHVhlUgHaC4AC +# MRCgXjYfQEDtYEK54dUwPJXV7icz0rgCzs9VI29DwsjVZFpO4ZIVR33LwXyPDbYF +# kLqYmgHjR3tKVkhh9qKV2WCmBuC27pIOx6TYvyqiYbntinmpOqh/QPAnhDgexKG9 +# GX/n1PggkGi9HCapZp8fRwg8RftwS21Ln61euBG0yONM6noD2XQPrFwpm3GcuqJM +# f0o8LLrFkSLRQNwxPDDkWXhW+gZswbaiie5fd/W2ygcto78XCSPfFWveUOSZ5SqK +# 95tBO8aTHmEa4lpJVD7HrTEn9jb1EGvxOb1cnn0CMIIGKjCCBRKgAwIBAgITdQAA +# AA+pTsONn8YiVAAAAAAADzANBgkqhkiG9w0BAQsFADBHMRUwEwYKCZImiZPyLGQB +# GRYFbG9jYWwxGDAWBgoJkiaJk/IsZAEZFghwaW5nLW1lZTEUMBIGA1UEAxMLcGlu +# Zy1tZWUtQ0EwHhcNMjYwNDA5MDczOTM2WhcNMjcwNDA5MDczOTM2WjB8MRUwEwYK +# CZImiZPyLGQBGRYFbG9jYWwxGDAWBgoJkiaJk/IsZAEZFghwaW5nLW1lZTEVMBMG +# A1UECxMMT3JnYW5pc2F0aW9uMRcwFQYDVQQLEw5BZG1pbmlzdHJhdGlvbjEZMBcG +# A1UEAxMQSVQgQWRtaW5pc3RyYXRvcjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC +# AQoCggEBALCk9syN/9zSWis5l6RKd+Twg6GnkBhwkUn5LDwdALKc5bImd7rLCF9j +# +m6zWNEq4Lrl8382PSFLnCnXRd0MeecmMBRXEryLktcnl2OgFPiu87fodptBYRBp +# LpUyHB0XeJlT+plHgURfFIJ8vZwvNP+i0Ai1xmcKdlmUH3PQRVTg5QaH0kzHYPgf +# BNhsWXRujCjg23sR0sZ2seAUOX3KVfiGFyZswHF09MlxJPq05QR8TBTfK1InuTaT +# 1is38tCKEkANQjVt4yZ8KYV0YuM897oEZvtnwJEMDNP75H/3uZoIJ0v0dfUyjMHN +# DfKdt0aDxZpPTT9iICZl3EMveAN+l70CAwEAAaOCAtgwggLUMD0GCSsGAQQBgjcV +# BwQwMC4GJisGAQQBgjcVCIPqtkCCy4lzgsWRF4Xw1Q+BwbsoQISyshCFqrN4AgFk +# AgEGMBMGA1UdJQQMMAoGCCsGAQUFBwMDMA4GA1UdDwEB/wQEAwIHgDAbBgkrBgEE +# AYI3FQoEDjAMMAoGCCsGAQUFBwMDMB0GA1UdDgQWBBTroFoeAJDNgwWOwbsoP6+h +# n09ftjAfBgNVHSMEGDAWgBTgQNNlwwWCz4c1/FnAOF+J5D5/0DCByAYDVR0fBIHA +# MIG9MIG6oIG3oIG0hoGxbGRhcDovLy9DTj1waW5nLW1lZS1DQSxDTj1EQzEsQ049 +# Q0RQLENOPVB1YmxpYyUyMEtleSUyMFNlcnZpY2VzLENOPVNlcnZpY2VzLENOPUNv +# bmZpZ3VyYXRpb24sREM9cGluZy1tZWUsREM9bG9jYWw/Y2VydGlmaWNhdGVSZXZv +# Y2F0aW9uTGlzdD9iYXNlP29iamVjdENsYXNzPWNSTERpc3RyaWJ1dGlvblBvaW50 +# MIHABggrBgEFBQcBAQSBszCBsDCBrQYIKwYBBQUHMAKGgaBsZGFwOi8vL0NOPXBp +# bmctbWVlLUNBLENOPUFJQSxDTj1QdWJsaWMlMjBLZXklMjBTZXJ2aWNlcyxDTj1T +# ZXJ2aWNlcyxDTj1Db25maWd1cmF0aW9uLERDPXBpbmctbWVlLERDPWxvY2FsP2NB +# Q2VydGlmaWNhdGU/YmFzZT9vYmplY3RDbGFzcz1jZXJ0aWZpY2F0aW9uQXV0aG9y +# aXR5MDIGA1UdEQQrMCmgJwYKKwYBBAGCNxQCA6AZDBdpdC1hZG1pbkBwaW5nLW1l +# ZS5sb2NhbDBPBgkrBgEEAYI3GQIEQjBAoD4GCisGAQQBgjcZAgGgMAQuUy0xLTUt +# MjEtMzIxMTQ3Njk4My0xNjY0MTg4ODMzLTM5MDgzNjg2MDMtMTEwNDANBgkqhkiG +# 9w0BAQsFAAOCAQEAw+wwNb8CIqJaqpTzGgJ8TfS3Wt1y9qlNEWCfdyKjl3LbH7HU +# uJmXFrUiygxyWlDtHbD5bah4JlLprv/wSkAN8c3DqxGxTB06orLZNRXH/plR6R69 +# wRD23GrxTGcP66iIRUnpDCiX+tXlRyQnxtb/7OPFSy1B/NYC6re74M7fV3cZ6/lU +# gxy0aLzwCWjgfzJvJMTDSsWJKKIHpdIktR2EhCkCdWJay+8j2SAmT+IG8i/MAXrc +# 6wmc4p/O17eAx0sx22hw6JPiA9TNFLfSGrPSzUjX3SD3OuoxPnJNpM4GY4kte5Hs +# lIQ8+KnsCf2oBsk7AciBlLTEuOshD7rtTom9hzCCBmIwggTKoAMCAQICEQCkKTtu +# Ht3XpzQIh616TrckMA0GCSqGSIb3DQEBDAUAMFUxCzAJBgNVBAYTAkdCMRgwFgYD +# VQQKEw9TZWN0aWdvIExpbWl0ZWQxLDAqBgNVBAMTI1NlY3RpZ28gUHVibGljIFRp +# bWUgU3RhbXBpbmcgQ0EgUjM2MB4XDTI1MDMyNzAwMDAwMFoXDTM2MDMyMTIzNTk1 +# OVowcjELMAkGA1UEBhMCR0IxFzAVBgNVBAgTDldlc3QgWW9ya3NoaXJlMRgwFgYD +# VQQKEw9TZWN0aWdvIExpbWl0ZWQxMDAuBgNVBAMTJ1NlY3RpZ28gUHVibGljIFRp +# bWUgU3RhbXBpbmcgU2lnbmVyIFIzNjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCC +# AgoCggIBANOElfRupFN48j0QS3gSBzzclIFTZ2Gsn7BjsmBF659/kpA2Ey7NXK3M +# P6JdrMBNU8wdmkf+SSIyjX++UAYWtg3Y/uDRDyg8RxHeHRJ+0U1jHEyH5uPdk1tt +# iPC3x/gOxIc9P7Gn3OgW7DQc4x07exZ4DX4XyaGDq5LoEmk/BdCM1IelVMKB3WA6 +# YpZ/XYdJ9JueOXeQObSQ/dohQCGyh0FhmwkDWKZaqQBWrBwZ++zqlt+z/QYTgEnZ +# o6dyIo2IhXXANFkCHutL8765NBxvolXMFWY8/reTnFxk3MajgM5NX6wzWdWsPJxY +# RhLxtJLSUJJ5yWRNw+NBqH1ezvFs4GgJ2ZqFJ+Dwqbx9+rw+F2gBdgo4j7CVomP4 +# 9sS7CbqsdybbiOGpB9DJhs5QVMpYV73TVV3IwLiBHBECrTgUfZVOMF0KSEq2zk/L +# sfvehswavE3W4aBXJmGjgWSpcDz+6TqeTM8f1DIcgQPdz0IYgnT3yFTgiDbFGOFN +# t6eCidxdR6j9x+kpcN5RwApy4pRhE10YOV/xafBvKpRuWPjOPWRBlKdm53kS2aMh +# 08spx7xSEqXn4QQldCnUWRz3Lki+TgBlpwYwJUbR77DAayNwAANE7taBrz2v+Mnn +# ogMrvvct0iwvfIA1W8kp155Lo44SIfqGmrbJP6Mn+Udr3MR2oWozAgMBAAGjggGO +# MIIBijAfBgNVHSMEGDAWgBRfWO1MMXqiYUKNUoC6s2GXGaIymzAdBgNVHQ4EFgQU +# iGGMoSo3ZIEoYKGbMdCM/SwCzk8wDgYDVR0PAQH/BAQDAgbAMAwGA1UdEwEB/wQC +# MAAwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwgwSgYDVR0gBEMwQTA1BgwrBgEEAbIx +# AQIBAwgwJTAjBggrBgEFBQcCARYXaHR0cHM6Ly9zZWN0aWdvLmNvbS9DUFMwCAYG +# Z4EMAQQCMEoGA1UdHwRDMEEwP6A9oDuGOWh0dHA6Ly9jcmwuc2VjdGlnby5jb20v +# U2VjdGlnb1B1YmxpY1RpbWVTdGFtcGluZ0NBUjM2LmNybDB6BggrBgEFBQcBAQRu +# MGwwRQYIKwYBBQUHMAKGOWh0dHA6Ly9jcnQuc2VjdGlnby5jb20vU2VjdGlnb1B1 +# YmxpY1RpbWVTdGFtcGluZ0NBUjM2LmNydDAjBggrBgEFBQcwAYYXaHR0cDovL29j +# c3Auc2VjdGlnby5jb20wDQYJKoZIhvcNAQEMBQADggGBAAKBPqSGclEh+WWpLj1S +# iuHlm8xLE0SThI2yLuq+75s11y6SceBchpnKpxWaGtXc8dya1Aq3RuW//y3wMThs +# vT4fSba2AoSWlR67rA4fTYGMIhgzocsids0ct/pHaocLVJSwnTYxY2pE0hPoZAvR +# ebctbsTqENmZHyOVjOFlwN2R3DRweFeNs4uyZN5LRJ5EnVYlcTOq3bl1tI5poru9 +# WaQRWQ4eynXp7Pj0Fz4DKr86HYECRJMWiDjeV0QqAcQMFsIjJtrYTw7mU81qf4FB +# c4u4swphLeKRNyn9DDrd3HIMJ+CpdhSHEGleeZ5I79YDg3B3A/fmVY2GaMik1Vm+ +# FajEMv4/EN2mmHf4zkOuhYZNzVm4NrWJeY4UAriLBOeVYODdA1GxFr1ycbcUEGlU +# ecc4RCPgYySs4d00NNuicR4a9n7idJlevAJbha/arIYMEuUqTeRRbWkhJwMKmb9y +# EvppRudKyu1t6l21sIuIZqcpVH8oLWCxHS0LpDRF9Y4jijCCBoIwggRqoAMCAQIC +# EDbCsL18Gzrno7PdNsvJdWgwDQYJKoZIhvcNAQEMBQAwgYgxCzAJBgNVBAYTAlVT +# MRMwEQYDVQQIEwpOZXcgSmVyc2V5MRQwEgYDVQQHEwtKZXJzZXkgQ2l0eTEeMBwG +# A1UEChMVVGhlIFVTRVJUUlVTVCBOZXR3b3JrMS4wLAYDVQQDEyVVU0VSVHJ1c3Qg +# UlNBIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTIxMDMyMjAwMDAwMFoXDTM4 +# MDExODIzNTk1OVowVzELMAkGA1UEBhMCR0IxGDAWBgNVBAoTD1NlY3RpZ28gTGlt +# aXRlZDEuMCwGA1UEAxMlU2VjdGlnbyBQdWJsaWMgVGltZSBTdGFtcGluZyBSb290 +# IFI0NjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAIid2LlFZ50d3ei5 +# JoGaVFTAfEkFm8xaFQ/ZlBBEtEFAgXcUmanU5HYsyAhTXiDQkiUvpVdYqZ1uYoZE +# MgtHES1l1Cc6HaqZzEbOOp6YiTx63ywTon434aXVydmhx7Dx4IBrAou7hNGsKioI +# BPy5GMN7KmgYmuu4f92sKKjbxqohUSfjk1mJlAjthgF7Hjx4vvyVDQGsd5KarLW5 +# d73E3ThobSkob2SL48LpUR/O627pDchxll+bTSv1gASn/hp6IuHJorEu6EopoB1C +# NFp/+HpTXeNARXUmdRMKbnXWflq+/g36NJXB35ZvxQw6zid61qmrlD/IbKJA6COw +# /8lFSPQwBP1ityZdwuCysCKZ9ZjczMqbUcLFyq6KdOpuzVDR3ZUwxDKL1wCAxgL2 +# Mpz7eZbrb/JWXiOcNzDpQsmwGQ6Stw8tTCqPumhLRPb7YkzM8/6NnWH3T9ClmcGS +# F22LEyJYNWCHrQqYubNeKolzqUbCqhSqmr/UdUeb49zYHr7ALL8bAJyPDmubNqMt +# uaobKASBqP84uhqcRY/pjnYd+V5/dcu9ieERjiRKKsxCG1t6tG9oj7liwPddXEcY +# GOUiWLm742st50jGwTzxbMpepmOP1mLnJskvZaN5e45NuzAHteORlsSuDt5t4BBR +# CJL+5EZnnw0ezntk9R8QJyAkL6/bAgMBAAGjggEWMIIBEjAfBgNVHSMEGDAWgBRT +# eb9aqitKz1SA4dibwJ3ysgNmyzAdBgNVHQ4EFgQU9ndq3T/9ARP/FqFsggIv0Ao9 +# FCUwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wEwYDVR0lBAwwCgYI +# KwYBBQUHAwgwEQYDVR0gBAowCDAGBgRVHSAAMFAGA1UdHwRJMEcwRaBDoEGGP2h0 +# dHA6Ly9jcmwudXNlcnRydXN0LmNvbS9VU0VSVHJ1c3RSU0FDZXJ0aWZpY2F0aW9u +# QXV0aG9yaXR5LmNybDA1BggrBgEFBQcBAQQpMCcwJQYIKwYBBQUHMAGGGWh0dHA6 +# Ly9vY3NwLnVzZXJ0cnVzdC5jb20wDQYJKoZIhvcNAQEMBQADggIBAA6+ZUHtaES4 +# 5aHF1BGH5Lc7JYzrftrIF5Ht2PFDxKKFOct/awAEWgHQMVHol9ZLSyd/pYMbaC0I +# Z+XBW9xhdkkmUV/KbUOiL7g98M/yzRyqUOZ1/IY7Ay0YbMniIibJrPcgFp73WDnR +# DKtVutShPSZQZAdtFwXnuiWl8eFARK3PmLqEm9UsVX+55DbVIz33Mbhba0HUTEYv +# 3yJ1fwKGxPBsP/MgTECimh7eXomvMm0/GPxX2uhwCcs/YLxDnBdVVlxvDjHjO1cu +# wbOpkiJGHmLXXVNbsdXUC2xBrq9fLrfe8IBsA4hopwsCj8hTuwKXJlSTrZcPRVSc +# cP5i9U28gZ7OMzoJGlxZ5384OKm0r568Mo9TYrqzKeKZgFo0fj2/0iHbj55hc20j +# fxvK3mQi+H7xpbzxZOFGm/yVQkpo+ffv5gdhp+hv1GDsvJOtJinJmgGbBFZIThbq +# I+MHvAmMmkfb3fTxmSkop2mSJL1Y2x/955S29Gu0gSJIkc3z30vU/iXrMpWx2tS7 +# UVfVP+5tKuzGtgkP7d/doqDrLF1u6Ci3TpjAZdeLLlRQZm867eVeXED58LXd1Dk6 +# UvaAhvmWYXoiLz4JA5gPBcz7J311uahxCweNxE+xxxR3kT0WKzASo5G/PyDez6NH +# dIUKBeE3jDPs2ACc6CkJ1Sji4PKWVT0/MYIFJjCCBSICAQEwXjBHMRUwEwYKCZIm +# iZPyLGQBGRYFbG9jYWwxGDAWBgoJkiaJk/IsZAEZFghwaW5nLW1lZTEUMBIGA1UE +# AxMLcGluZy1tZWUtQ0ECE3UAAAAPqU7DjZ/GIlQAAAAAAA8wCQYFKw4DAhoFAKB4 +# MBgGCisGAQQBgjcCAQwxCjAIoAKAAKECgAAwGQYJKoZIhvcNAQkDMQwGCisGAQQB +# gjcCAQQwHAYKKwYBBAGCNwIBCzEOMAwGCisGAQQBgjcCARUwIwYJKoZIhvcNAQkE +# MRYEFMiSp7573WaFIlLppiExgWj8x4WSMA0GCSqGSIb3DQEBAQUABIIBAGtEDXqe +# 4PZfuxp9RLZIWvEWmsjLBIUFZ//pUHMnW8XVKV+cqyQt/7XPFmO08HUS7dlcd44V +# 9uHmhWA0yHchBmk979UxQKkVR2W5LXOJPly22LXlZA6ZCieFc/sY6GC6fhbTN51S +# 4TSTsswvJxh/OR7+O35hOAFx+j0nPYZqKnqDsRAvrZXj1XQBeN9Lm7Tm6KeWQUN2 +# U7jXABN0445FRjIC2DNc8py4YLB5tcVOkgScqKRTr5SdJptmFIkIZPeGapFlOnFr +# agUCIcq73U5xj6veljAwyVVrnoHW6gFhRCGj25Jy++UZGF754UnhOJaIjy0QG9JW +# Rtr1F/NcAyb1oWGhggMjMIIDHwYJKoZIhvcNAQkGMYIDEDCCAwwCAQEwajBVMQsw +# CQYDVQQGEwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMSwwKgYDVQQDEyNT +# ZWN0aWdvIFB1YmxpYyBUaW1lIFN0YW1waW5nIENBIFIzNgIRAKQpO24e3denNAiH +# rXpOtyQwDQYJYIZIAWUDBAICBQCgeTAYBgkqhkiG9w0BCQMxCwYJKoZIhvcNAQcB +# MBwGCSqGSIb3DQEJBTEPFw0yNjA0MTkxMTIwMThaMD8GCSqGSIb3DQEJBDEyBDDq +# EQ8qF4aB63P3xcZxa44dERy5giSG/ril/XbA3xRw6r2zvdbkmvFWDGC5Tw2AsAUw +# DQYJKoZIhvcNAQEBBQAEggIAgs9ni7gbLQ3BMhKVelDE2XXWNB6L1oM/i0odVBDu +# 4SWbbZqSOhg7XF+u6xQaaCStN0Yil8BL/dow+/kW6hrqQAWdOKPQxokwUpR3FJHJ +# WEtDmkyVLuPJFSSrEXSxHaSPecJcVKKYj/9sAobSx94oEt7W/NAON/vvO6W9sdmx +# kpAlhrbwaA04wsOdMyPIwr1S6BaGI30ZAMh3N6Kg3+6YkaHplbEX35dd+4l0Piah +# nXC9QS+e035o7WsOptXQu4I28Oexjd9lqw1lDSM2cLDDjT9DMPfEqhDaNODjhS5v +# XiOOfa9NvreoLwaY0b7WsZskR1SLacweWiqZHcu9Ecx81iFdLCMDneqAxe9hQ/zI +# s73/oX2xlTOldA6nUEgs1fBd1VrWjo74V4DFbecmHf9qnf5YZRs2e7j7j8QP/k1H +# GyRb9AVdlorgMCVjnCAT0XFLfVDZ0ZmggSqN323ITg8Rf10Z2Ix0oSIvVvEytvDH +# rNEQeHZFiBFL9W8Ua9Xx+zva80wn5O3SiwZmsIxb94x004u9gl9b8BPYqiyWxLuI +# IG7cbhMsiuZzE9e7RaQ/Tkm6a408Lj+y25PsGiLhYK/fLY9/g46yPaPA+Zc2RWQ5 +# iTYSF+vhLh1TVdtknxfXbwlDaXDbfi6EIR45GKl84VQYmbl8e6X1IHT3T2DpgMMq +# egs= +# SIG # End signature block diff --git a/context-resources/Set-LocalAccountPicture.ps1 b/context-resources/Set-LocalAccountPicture.ps1 new file mode 100644 index 0000000..12f9102 --- /dev/null +++ b/context-resources/Set-LocalAccountPicture.ps1 @@ -0,0 +1,292 @@ +# Set-LocalAccountPicture.ps1 +# User Configuration → Windows Settings → Scripts → Logon +# Reads photo from AD for small sizes, high quality version from fileshare for large sizes +# Writes all image sizes to C:\ProgramData\AccountPictures\\ and updates registry + +[CmdletBinding(SupportsShouldProcess=$true)] +Param() + +function Test-Null($InputObject) { return !([bool]$InputObject) } + +function ResizeImage { + param( + [String]$ImagePath, + [Int]$Quality = 90, + [Int]$targetSize, + [String]$OutputLocation + ) + Add-Type -AssemblyName "System.Drawing" + + $img = [System.Drawing.Image]::FromFile($ImagePath) + + $ImageEncoder = [System.Drawing.Imaging.Encoder]::Quality + $encoderParams = New-Object System.Drawing.Imaging.EncoderParameters(1) + $encoderParams.Param[0] = New-Object System.Drawing.Imaging.EncoderParameter($ImageEncoder, $Quality) + $Codec = [System.Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() | Where-Object { $_.MimeType -eq 'image/jpeg' } + + $ratioX = $targetSize / $img.Width + $ratioY = $targetSize / $img.Height + $ratio = if ($ratioX -le $ratioY) { $ratioX } else { $ratioY } + + $newWidth = [int]($img.Width * $ratio) + $newHeight = [int]($img.Height * $ratio) + + $bmpResized = New-Object System.Drawing.Bitmap($newWidth, $newHeight) + $graph = [System.Drawing.Graphics]::FromImage($bmpResized) + $graph.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic + $graph.Clear([System.Drawing.Color]::White) + $graph.DrawImage($img, 0, 0, $newWidth, $newHeight) + + $bmpResized.Save($OutputLocation, $Codec, $encoderParams) + $bmpResized.Dispose() + $img.Dispose() +} + +# Resolve current user and SID +$user = ([ADSISearcher]"(&(objectCategory=User)(SAMAccountName=$env:USERNAME))").FindOne().Properties +$user_photo = $user.thumbnailphoto +$user_sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value + +Write-Host "Updating account picture for $($user.displayname) ($env:USERNAME)..." + +# Fallback to default image from fileshare if AD has no photo +If ((Test-Null $user_photo) -eq $true) { + Write-Host "No AD photo found, loading default from fileshare." + $defaultPath = "\\192.168.190.40\DC_data\ProfilePictures\default\default.jpg" + if (Test-Path $defaultPath) { + $user_photo = [System.IO.File]::ReadAllBytes($defaultPath) + } else { + Write-Error "No AD photo and no default image found. Aborting." + exit 1 + } +} + +# Paths +$image_sizes = @(32, 40, 48, 96, 192, 200, 240, 448) +$image_base = "C:\ProgramData\AccountPictures" +$dir = "$image_base\$user_sid" +$hqPath = "\\192.168.190.40\DC_data\ProfilePictures\processed\${env:USERNAME}_448.jpg" +$useHQ = Test-Path $hqPath + +# Registry +$reg_key = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AccountPicture\Users\$user_sid" +If ((Test-Path $reg_key) -eq $false) { New-Item -Path $reg_key -Force | Out-Null } +New-ItemProperty -Path $reg_key -Name "SourceId" -Value 0 -Force | Out-Null + +$hkcu_key = "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\AccountPicture" +If ((Test-Path $hkcu_key) -eq $false) { New-Item -Path $hkcu_key -Force | Out-Null } + +ForEach ($size in $image_sizes) { + $path = "$roamingDir\Image${size}.jpg" + New-ItemProperty -Path $hkcu_key -Name "Image$size" -Value $path -Force | Out-Null +} + +# Clear stale cache +If (Test-Path $dir) { + Get-ChildItem $dir -ErrorAction SilentlyContinue | Remove-Item -Force +} + +# Save images and write registry entries +ForEach ($size in $image_sizes) { + If ((Test-Path -Path $dir) -eq $false) { $(mkdir $dir).Attributes = "Hidden" } + + $file_name = "Image${size}.jpg" + $pathtmp = "$dir\_$file_name" + $path = "$dir\$file_name" + + # Large sizes use the high quality 448px fileshare version to avoid upscaling artefacts + # Small sizes use the AD thumbnail which is sufficient at those dimensions + if ($size -ge 192 -and $useHQ) { + Copy-Item $hqPath $path -Force + Write-Host " Saving: $file_name (HQ from fileshare)" + } else { + $user_photo | Set-Content -Path $pathtmp -Encoding Byte -Force + ResizeImage -ImagePath $pathtmp -targetSize $size -OutputLocation $path + Remove-Item $pathtmp + Write-Host " Saving: $file_name (from AD)" + } + + New-ItemProperty -Path $reg_key -Name "Image$size" -Value $path -Force | Out-Null +} + +$roamingDir = "C:\Users\$env:USERNAME\AppData\Roaming\Microsoft\Windows\AccountPictures" +New-Item -ItemType Directory -Force -Path $roamingDir | Out-Null + +ForEach ($size in $image_sizes) { + $src = "$dir\Image${size}.jpg" + $dest = "$roamingDir\Image${size}.jpg" + if (Test-Path $src) { + Copy-Item $src $dest -Force + Write-Host " Copied to roaming: Image${size}.jpg" + } +} + +Write-Host "Done." +# SIG # Begin signature block +# MIIe7gYJKoZIhvcNAQcCoIIe3zCCHtsCAQExCzAJBgUrDgMCGgUAMGkGCisGAQQB +# gjcCAQSgWzBZMDQGCisGAQQBgjcCAR4wJgIDAQAABBAfzDtgWUsITrck0sYpfvNR +# AgEAAgEAAgEAAgEAAgEAMCEwCQYFKw4DAhoFAAQUvJUdLTHvXRlrD/lB0q3PTF/J +# lHugghkyMIIGFDCCA/ygAwIBAgIQeiOu2lNplg+RyD5c9MfjPzANBgkqhkiG9w0B +# AQwFADBXMQswCQYDVQQGEwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMS4w +# LAYDVQQDEyVTZWN0aWdvIFB1YmxpYyBUaW1lIFN0YW1waW5nIFJvb3QgUjQ2MB4X +# DTIxMDMyMjAwMDAwMFoXDTM2MDMyMTIzNTk1OVowVTELMAkGA1UEBhMCR0IxGDAW +# BgNVBAoTD1NlY3RpZ28gTGltaXRlZDEsMCoGA1UEAxMjU2VjdGlnbyBQdWJsaWMg +# VGltZSBTdGFtcGluZyBDQSBSMzYwggGiMA0GCSqGSIb3DQEBAQUAA4IBjwAwggGK +# AoIBgQDNmNhDQatugivs9jN+JjTkiYzT7yISgFQ+7yavjA6Bg+OiIjPm/N/t3nC7 +# wYUrUlY3mFyI32t2o6Ft3EtxJXCc5MmZQZ8AxCbh5c6WzeJDB9qkQVa46xiYEpc8 +# 1KnBkAWgsaXnLURoYZzksHIzzCNxtIXnb9njZholGw9djnjkTdAA83abEOHQ4ujO +# GIaBhPXG2NdV8TNgFWZ9BojlAvflxNMCOwkCnzlH4oCw5+4v1nssWeN1y4+RlaOy +# wwRMUi54fr2vFsU5QPrgb6tSjvEUh1EC4M29YGy/SIYM8ZpHadmVjbi3Pl8hJiTW +# w9jiCKv31pcAaeijS9fc6R7DgyyLIGflmdQMwrNRxCulVq8ZpysiSYNi79tw5RHW +# ZUEhnRfs/hsp/fwkXsynu1jcsUX+HuG8FLa2BNheUPtOcgw+vHJcJ8HnJCrcUWhd +# Fczf8O+pDiyGhVYX+bDDP3GhGS7TmKmGnbZ9N+MpEhWmbiAVPbgkqykSkzyYVr15 +# OApZYK8CAwEAAaOCAVwwggFYMB8GA1UdIwQYMBaAFPZ3at0//QET/xahbIICL9AK +# PRQlMB0GA1UdDgQWBBRfWO1MMXqiYUKNUoC6s2GXGaIymzAOBgNVHQ8BAf8EBAMC +# AYYwEgYDVR0TAQH/BAgwBgEB/wIBADATBgNVHSUEDDAKBggrBgEFBQcDCDARBgNV +# HSAECjAIMAYGBFUdIAAwTAYDVR0fBEUwQzBBoD+gPYY7aHR0cDovL2NybC5zZWN0 +# aWdvLmNvbS9TZWN0aWdvUHVibGljVGltZVN0YW1waW5nUm9vdFI0Ni5jcmwwfAYI +# KwYBBQUHAQEEcDBuMEcGCCsGAQUFBzAChjtodHRwOi8vY3J0LnNlY3RpZ28uY29t +# L1NlY3RpZ29QdWJsaWNUaW1lU3RhbXBpbmdSb290UjQ2LnA3YzAjBggrBgEFBQcw +# AYYXaHR0cDovL29jc3Auc2VjdGlnby5jb20wDQYJKoZIhvcNAQEMBQADggIBABLX +# eyCtDjVYDJ6BHSVY/UwtZ3Svx2ImIfZVVGnGoUaGdltoX4hDskBMZx5NY5L6SCcw +# DMZhHOmbyMhyOVJDwm1yrKYqGDHWzpwVkFJ+996jKKAXyIIaUf5JVKjccev3w16m +# NIUlNTkpJEor7edVJZiRJVCAmWAaHcw9zP0hY3gj+fWp8MbOocI9Zn78xvm9XKGB +# p6rEs9sEiq/pwzvg2/KjXE2yWUQIkms6+yslCRqNXPjEnBnxuUB1fm6bPAV+Tsr/ +# Qrd+mOCJemo06ldon4pJFbQd0TQVIMLv5koklInHvyaf6vATJP4DfPtKzSBPkKlO +# tyaFTAjD2Nu+di5hErEVVaMqSVbfPzd6kNXOhYm23EWm6N2s2ZHCHVhlUgHaC4AC +# MRCgXjYfQEDtYEK54dUwPJXV7icz0rgCzs9VI29DwsjVZFpO4ZIVR33LwXyPDbYF +# kLqYmgHjR3tKVkhh9qKV2WCmBuC27pIOx6TYvyqiYbntinmpOqh/QPAnhDgexKG9 +# GX/n1PggkGi9HCapZp8fRwg8RftwS21Ln61euBG0yONM6noD2XQPrFwpm3GcuqJM +# f0o8LLrFkSLRQNwxPDDkWXhW+gZswbaiie5fd/W2ygcto78XCSPfFWveUOSZ5SqK +# 95tBO8aTHmEa4lpJVD7HrTEn9jb1EGvxOb1cnn0CMIIGKjCCBRKgAwIBAgITdQAA +# AA+pTsONn8YiVAAAAAAADzANBgkqhkiG9w0BAQsFADBHMRUwEwYKCZImiZPyLGQB +# GRYFbG9jYWwxGDAWBgoJkiaJk/IsZAEZFghwaW5nLW1lZTEUMBIGA1UEAxMLcGlu +# Zy1tZWUtQ0EwHhcNMjYwNDA5MDczOTM2WhcNMjcwNDA5MDczOTM2WjB8MRUwEwYK +# CZImiZPyLGQBGRYFbG9jYWwxGDAWBgoJkiaJk/IsZAEZFghwaW5nLW1lZTEVMBMG +# A1UECxMMT3JnYW5pc2F0aW9uMRcwFQYDVQQLEw5BZG1pbmlzdHJhdGlvbjEZMBcG +# A1UEAxMQSVQgQWRtaW5pc3RyYXRvcjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC +# AQoCggEBALCk9syN/9zSWis5l6RKd+Twg6GnkBhwkUn5LDwdALKc5bImd7rLCF9j +# +m6zWNEq4Lrl8382PSFLnCnXRd0MeecmMBRXEryLktcnl2OgFPiu87fodptBYRBp +# LpUyHB0XeJlT+plHgURfFIJ8vZwvNP+i0Ai1xmcKdlmUH3PQRVTg5QaH0kzHYPgf +# BNhsWXRujCjg23sR0sZ2seAUOX3KVfiGFyZswHF09MlxJPq05QR8TBTfK1InuTaT +# 1is38tCKEkANQjVt4yZ8KYV0YuM897oEZvtnwJEMDNP75H/3uZoIJ0v0dfUyjMHN +# DfKdt0aDxZpPTT9iICZl3EMveAN+l70CAwEAAaOCAtgwggLUMD0GCSsGAQQBgjcV +# BwQwMC4GJisGAQQBgjcVCIPqtkCCy4lzgsWRF4Xw1Q+BwbsoQISyshCFqrN4AgFk +# AgEGMBMGA1UdJQQMMAoGCCsGAQUFBwMDMA4GA1UdDwEB/wQEAwIHgDAbBgkrBgEE +# AYI3FQoEDjAMMAoGCCsGAQUFBwMDMB0GA1UdDgQWBBTroFoeAJDNgwWOwbsoP6+h +# n09ftjAfBgNVHSMEGDAWgBTgQNNlwwWCz4c1/FnAOF+J5D5/0DCByAYDVR0fBIHA +# MIG9MIG6oIG3oIG0hoGxbGRhcDovLy9DTj1waW5nLW1lZS1DQSxDTj1EQzEsQ049 +# Q0RQLENOPVB1YmxpYyUyMEtleSUyMFNlcnZpY2VzLENOPVNlcnZpY2VzLENOPUNv +# bmZpZ3VyYXRpb24sREM9cGluZy1tZWUsREM9bG9jYWw/Y2VydGlmaWNhdGVSZXZv +# Y2F0aW9uTGlzdD9iYXNlP29iamVjdENsYXNzPWNSTERpc3RyaWJ1dGlvblBvaW50 +# MIHABggrBgEFBQcBAQSBszCBsDCBrQYIKwYBBQUHMAKGgaBsZGFwOi8vL0NOPXBp +# bmctbWVlLUNBLENOPUFJQSxDTj1QdWJsaWMlMjBLZXklMjBTZXJ2aWNlcyxDTj1T +# ZXJ2aWNlcyxDTj1Db25maWd1cmF0aW9uLERDPXBpbmctbWVlLERDPWxvY2FsP2NB +# Q2VydGlmaWNhdGU/YmFzZT9vYmplY3RDbGFzcz1jZXJ0aWZpY2F0aW9uQXV0aG9y +# aXR5MDIGA1UdEQQrMCmgJwYKKwYBBAGCNxQCA6AZDBdpdC1hZG1pbkBwaW5nLW1l +# ZS5sb2NhbDBPBgkrBgEEAYI3GQIEQjBAoD4GCisGAQQBgjcZAgGgMAQuUy0xLTUt +# MjEtMzIxMTQ3Njk4My0xNjY0MTg4ODMzLTM5MDgzNjg2MDMtMTEwNDANBgkqhkiG +# 9w0BAQsFAAOCAQEAw+wwNb8CIqJaqpTzGgJ8TfS3Wt1y9qlNEWCfdyKjl3LbH7HU +# uJmXFrUiygxyWlDtHbD5bah4JlLprv/wSkAN8c3DqxGxTB06orLZNRXH/plR6R69 +# wRD23GrxTGcP66iIRUnpDCiX+tXlRyQnxtb/7OPFSy1B/NYC6re74M7fV3cZ6/lU +# gxy0aLzwCWjgfzJvJMTDSsWJKKIHpdIktR2EhCkCdWJay+8j2SAmT+IG8i/MAXrc +# 6wmc4p/O17eAx0sx22hw6JPiA9TNFLfSGrPSzUjX3SD3OuoxPnJNpM4GY4kte5Hs +# lIQ8+KnsCf2oBsk7AciBlLTEuOshD7rtTom9hzCCBmIwggTKoAMCAQICEQCkKTtu +# Ht3XpzQIh616TrckMA0GCSqGSIb3DQEBDAUAMFUxCzAJBgNVBAYTAkdCMRgwFgYD +# VQQKEw9TZWN0aWdvIExpbWl0ZWQxLDAqBgNVBAMTI1NlY3RpZ28gUHVibGljIFRp +# bWUgU3RhbXBpbmcgQ0EgUjM2MB4XDTI1MDMyNzAwMDAwMFoXDTM2MDMyMTIzNTk1 +# OVowcjELMAkGA1UEBhMCR0IxFzAVBgNVBAgTDldlc3QgWW9ya3NoaXJlMRgwFgYD +# VQQKEw9TZWN0aWdvIExpbWl0ZWQxMDAuBgNVBAMTJ1NlY3RpZ28gUHVibGljIFRp +# bWUgU3RhbXBpbmcgU2lnbmVyIFIzNjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCC +# AgoCggIBANOElfRupFN48j0QS3gSBzzclIFTZ2Gsn7BjsmBF659/kpA2Ey7NXK3M +# P6JdrMBNU8wdmkf+SSIyjX++UAYWtg3Y/uDRDyg8RxHeHRJ+0U1jHEyH5uPdk1tt +# iPC3x/gOxIc9P7Gn3OgW7DQc4x07exZ4DX4XyaGDq5LoEmk/BdCM1IelVMKB3WA6 +# YpZ/XYdJ9JueOXeQObSQ/dohQCGyh0FhmwkDWKZaqQBWrBwZ++zqlt+z/QYTgEnZ +# o6dyIo2IhXXANFkCHutL8765NBxvolXMFWY8/reTnFxk3MajgM5NX6wzWdWsPJxY +# RhLxtJLSUJJ5yWRNw+NBqH1ezvFs4GgJ2ZqFJ+Dwqbx9+rw+F2gBdgo4j7CVomP4 +# 9sS7CbqsdybbiOGpB9DJhs5QVMpYV73TVV3IwLiBHBECrTgUfZVOMF0KSEq2zk/L +# sfvehswavE3W4aBXJmGjgWSpcDz+6TqeTM8f1DIcgQPdz0IYgnT3yFTgiDbFGOFN +# t6eCidxdR6j9x+kpcN5RwApy4pRhE10YOV/xafBvKpRuWPjOPWRBlKdm53kS2aMh +# 08spx7xSEqXn4QQldCnUWRz3Lki+TgBlpwYwJUbR77DAayNwAANE7taBrz2v+Mnn +# ogMrvvct0iwvfIA1W8kp155Lo44SIfqGmrbJP6Mn+Udr3MR2oWozAgMBAAGjggGO +# MIIBijAfBgNVHSMEGDAWgBRfWO1MMXqiYUKNUoC6s2GXGaIymzAdBgNVHQ4EFgQU +# iGGMoSo3ZIEoYKGbMdCM/SwCzk8wDgYDVR0PAQH/BAQDAgbAMAwGA1UdEwEB/wQC +# MAAwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwgwSgYDVR0gBEMwQTA1BgwrBgEEAbIx +# AQIBAwgwJTAjBggrBgEFBQcCARYXaHR0cHM6Ly9zZWN0aWdvLmNvbS9DUFMwCAYG +# Z4EMAQQCMEoGA1UdHwRDMEEwP6A9oDuGOWh0dHA6Ly9jcmwuc2VjdGlnby5jb20v +# U2VjdGlnb1B1YmxpY1RpbWVTdGFtcGluZ0NBUjM2LmNybDB6BggrBgEFBQcBAQRu +# MGwwRQYIKwYBBQUHMAKGOWh0dHA6Ly9jcnQuc2VjdGlnby5jb20vU2VjdGlnb1B1 +# YmxpY1RpbWVTdGFtcGluZ0NBUjM2LmNydDAjBggrBgEFBQcwAYYXaHR0cDovL29j +# c3Auc2VjdGlnby5jb20wDQYJKoZIhvcNAQEMBQADggGBAAKBPqSGclEh+WWpLj1S +# iuHlm8xLE0SThI2yLuq+75s11y6SceBchpnKpxWaGtXc8dya1Aq3RuW//y3wMThs +# vT4fSba2AoSWlR67rA4fTYGMIhgzocsids0ct/pHaocLVJSwnTYxY2pE0hPoZAvR +# ebctbsTqENmZHyOVjOFlwN2R3DRweFeNs4uyZN5LRJ5EnVYlcTOq3bl1tI5poru9 +# WaQRWQ4eynXp7Pj0Fz4DKr86HYECRJMWiDjeV0QqAcQMFsIjJtrYTw7mU81qf4FB +# c4u4swphLeKRNyn9DDrd3HIMJ+CpdhSHEGleeZ5I79YDg3B3A/fmVY2GaMik1Vm+ +# FajEMv4/EN2mmHf4zkOuhYZNzVm4NrWJeY4UAriLBOeVYODdA1GxFr1ycbcUEGlU +# ecc4RCPgYySs4d00NNuicR4a9n7idJlevAJbha/arIYMEuUqTeRRbWkhJwMKmb9y +# EvppRudKyu1t6l21sIuIZqcpVH8oLWCxHS0LpDRF9Y4jijCCBoIwggRqoAMCAQIC +# EDbCsL18Gzrno7PdNsvJdWgwDQYJKoZIhvcNAQEMBQAwgYgxCzAJBgNVBAYTAlVT +# MRMwEQYDVQQIEwpOZXcgSmVyc2V5MRQwEgYDVQQHEwtKZXJzZXkgQ2l0eTEeMBwG +# A1UEChMVVGhlIFVTRVJUUlVTVCBOZXR3b3JrMS4wLAYDVQQDEyVVU0VSVHJ1c3Qg +# UlNBIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTIxMDMyMjAwMDAwMFoXDTM4 +# MDExODIzNTk1OVowVzELMAkGA1UEBhMCR0IxGDAWBgNVBAoTD1NlY3RpZ28gTGlt +# aXRlZDEuMCwGA1UEAxMlU2VjdGlnbyBQdWJsaWMgVGltZSBTdGFtcGluZyBSb290 +# IFI0NjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAIid2LlFZ50d3ei5 +# JoGaVFTAfEkFm8xaFQ/ZlBBEtEFAgXcUmanU5HYsyAhTXiDQkiUvpVdYqZ1uYoZE +# MgtHES1l1Cc6HaqZzEbOOp6YiTx63ywTon434aXVydmhx7Dx4IBrAou7hNGsKioI +# BPy5GMN7KmgYmuu4f92sKKjbxqohUSfjk1mJlAjthgF7Hjx4vvyVDQGsd5KarLW5 +# d73E3ThobSkob2SL48LpUR/O627pDchxll+bTSv1gASn/hp6IuHJorEu6EopoB1C +# NFp/+HpTXeNARXUmdRMKbnXWflq+/g36NJXB35ZvxQw6zid61qmrlD/IbKJA6COw +# /8lFSPQwBP1ityZdwuCysCKZ9ZjczMqbUcLFyq6KdOpuzVDR3ZUwxDKL1wCAxgL2 +# Mpz7eZbrb/JWXiOcNzDpQsmwGQ6Stw8tTCqPumhLRPb7YkzM8/6NnWH3T9ClmcGS +# F22LEyJYNWCHrQqYubNeKolzqUbCqhSqmr/UdUeb49zYHr7ALL8bAJyPDmubNqMt +# uaobKASBqP84uhqcRY/pjnYd+V5/dcu9ieERjiRKKsxCG1t6tG9oj7liwPddXEcY +# GOUiWLm742st50jGwTzxbMpepmOP1mLnJskvZaN5e45NuzAHteORlsSuDt5t4BBR +# CJL+5EZnnw0ezntk9R8QJyAkL6/bAgMBAAGjggEWMIIBEjAfBgNVHSMEGDAWgBRT +# eb9aqitKz1SA4dibwJ3ysgNmyzAdBgNVHQ4EFgQU9ndq3T/9ARP/FqFsggIv0Ao9 +# FCUwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wEwYDVR0lBAwwCgYI +# KwYBBQUHAwgwEQYDVR0gBAowCDAGBgRVHSAAMFAGA1UdHwRJMEcwRaBDoEGGP2h0 +# dHA6Ly9jcmwudXNlcnRydXN0LmNvbS9VU0VSVHJ1c3RSU0FDZXJ0aWZpY2F0aW9u +# QXV0aG9yaXR5LmNybDA1BggrBgEFBQcBAQQpMCcwJQYIKwYBBQUHMAGGGWh0dHA6 +# Ly9vY3NwLnVzZXJ0cnVzdC5jb20wDQYJKoZIhvcNAQEMBQADggIBAA6+ZUHtaES4 +# 5aHF1BGH5Lc7JYzrftrIF5Ht2PFDxKKFOct/awAEWgHQMVHol9ZLSyd/pYMbaC0I +# Z+XBW9xhdkkmUV/KbUOiL7g98M/yzRyqUOZ1/IY7Ay0YbMniIibJrPcgFp73WDnR +# DKtVutShPSZQZAdtFwXnuiWl8eFARK3PmLqEm9UsVX+55DbVIz33Mbhba0HUTEYv +# 3yJ1fwKGxPBsP/MgTECimh7eXomvMm0/GPxX2uhwCcs/YLxDnBdVVlxvDjHjO1cu +# wbOpkiJGHmLXXVNbsdXUC2xBrq9fLrfe8IBsA4hopwsCj8hTuwKXJlSTrZcPRVSc +# cP5i9U28gZ7OMzoJGlxZ5384OKm0r568Mo9TYrqzKeKZgFo0fj2/0iHbj55hc20j +# fxvK3mQi+H7xpbzxZOFGm/yVQkpo+ffv5gdhp+hv1GDsvJOtJinJmgGbBFZIThbq +# I+MHvAmMmkfb3fTxmSkop2mSJL1Y2x/955S29Gu0gSJIkc3z30vU/iXrMpWx2tS7 +# UVfVP+5tKuzGtgkP7d/doqDrLF1u6Ci3TpjAZdeLLlRQZm867eVeXED58LXd1Dk6 +# UvaAhvmWYXoiLz4JA5gPBcz7J311uahxCweNxE+xxxR3kT0WKzASo5G/PyDez6NH +# dIUKBeE3jDPs2ACc6CkJ1Sji4PKWVT0/MYIFJjCCBSICAQEwXjBHMRUwEwYKCZIm +# iZPyLGQBGRYFbG9jYWwxGDAWBgoJkiaJk/IsZAEZFghwaW5nLW1lZTEUMBIGA1UE +# AxMLcGluZy1tZWUtQ0ECE3UAAAAPqU7DjZ/GIlQAAAAAAA8wCQYFKw4DAhoFAKB4 +# MBgGCisGAQQBgjcCAQwxCjAIoAKAAKECgAAwGQYJKoZIhvcNAQkDMQwGCisGAQQB +# gjcCAQQwHAYKKwYBBAGCNwIBCzEOMAwGCisGAQQBgjcCARUwIwYJKoZIhvcNAQkE +# MRYEFK/5vTVDCyD7/3i8WUMcey1C4jGtMA0GCSqGSIb3DQEBAQUABIIBABDd/jJ+ +# PjUrJWZP6Kh3IXnCSFB7auT80HPa/UawEIb1d6WCkjOG70IrYl65w/COpuJJJpj7 +# G5k5TS1ftRK9K1+l6uOIEzwvpmJOgZa1yntB95nktO4PUD7obuKkQjOGILOUm8fr +# b3qPf9ZmG9yAoXNLFPbpHOB0eeZOl/jQ5mLypq7KfrBcxRfK4QHhqcal2Eliy5Gp +# A6ZrU8OLp5FYDuR4Qcip4a5t6IqhsDLhstVepy9fQS1q9n7yulrhcri63pK04prg +# wAsRQJloP07rx0uJiKixKryyUbywjwbVwadeXMg5f+jiVpTTF75u8UX6kLq0mK1j +# 1q/f7zTuGjLB1AmhggMjMIIDHwYJKoZIhvcNAQkGMYIDEDCCAwwCAQEwajBVMQsw +# CQYDVQQGEwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMSwwKgYDVQQDEyNT +# ZWN0aWdvIFB1YmxpYyBUaW1lIFN0YW1waW5nIENBIFIzNgIRAKQpO24e3denNAiH +# rXpOtyQwDQYJYIZIAWUDBAICBQCgeTAYBgkqhkiG9w0BCQMxCwYJKoZIhvcNAQcB +# MBwGCSqGSIb3DQEJBTEPFw0yNjA0MTkxMjE1MzZaMD8GCSqGSIb3DQEJBDEyBDB4 +# WgFHFhwQabbJNUfnozIcOjN/t1j2dfO/tTYpYPFgNfJEhcpdRHQRQT63B/ykny0w +# DQYJKoZIhvcNAQEBBQAEggIAVvj8SGhYF7rYzs6Bfr2en5yN52cPOhW3JYP/JJ6w +# 26GVz9BBwOYNtZZFHp5QQVJqAarMpXKYXz1oHjmPpjwi7e7uxhoJJpT+LlQmHxaS +# SErwE2r2A23lvvqybN9tV/e96nUUojtScYuz0mgHL8DakU7trtDDMnBoZ+0U3d89 +# y83yOpEY652YTA75YXILRRx16Yfq4AWoBXhY7T9y/7lDcYqGEhFzFyLlrI99QOwl +# 57+q7f6BDQjCZTMsQkT8F6btGTu3kIcc7GoRNiN2hKvcCE0G3CIddSPPJhg64B9t +# MOUBqhA9DfeFq9iMWRbYYKUizxmHpJZQ+dlRtRHehmV73HdRa3/QmUVLIiaY2TGP +# ePB0YqmpBp+oVnuCAS/pzEtvMSygl6sgz4lr5x2zyQM8uPHaDIzum7N49YA2+ly0 +# RVgzZOIE/ztoHa5qbHcVPqug5Z7pZhU6gba21wj7l9EIhyV16iPQorML/4YWkvCe +# hYaTbin/KfA2pKwRNiUGYVomYwEMLpd4Ce8iOgsAMrwKfhalqzXLoJiEbnEyv5vl +# 0Rb3K6Lz3FmP6MZLPLvnxAxwlsI8zsgtTKiSq2wYMrjfehwfzSOol+fHjh2JbRwy +# tZW/tY9+sYvF/pT1VDZSKOxsKZT6wc3etEq8cKerfbn9qFEJcMEeRSbF1kFOUjix +# 0tA= +# SIG # End signature block