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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sk72AxLwQnYttF6VFSxQLt
This commit is contained in:
Ebbe Baß
2026-10-15 13:33:02 +02:00
co-authored by Claude Sonnet 5.5
parent 97b2a95062
commit 24de5878ed
60 changed files with 13647 additions and 6 deletions
+9 -6
View File
@@ -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/
@@ -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/<sam>.jpg, read processed/<sam>.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).
@@ -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 `<sAMAccountName>.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/<name>.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.
@@ -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 <username>.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.
@@ -0,0 +1,25 @@
# Addendum: Technical Context for Downstream Documents
## Existing picture pipeline (context-resources/)
- `AD-ProfilePicture-Sync.ps1` (DC, scheduled task): reads `<sam>.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 `<username>.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.
@@ -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 `<username>.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 `<username>.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.
@@ -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.
@@ -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.
@@ -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.
@@ -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"
@@ -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.
@@ -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" }]
@@ -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 <username>.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/<sam>.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/<sam>.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.
@@ -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 `<sam>.jpg` from the source path, else a default image. It writes `<sam>_96.jpg` and `<sam>_448.jpg` to the processed path, writes `<sam>.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: `<source path>/<username>.jpg`, square JPEG, at least 448 px (assumed 512×512).
- Portal reads: `<processed path>/<username>.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 `<username>.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.
@@ -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 `<username>.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 `<username>.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 `<username>.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 `<username>.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 `<username>.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.
@@ -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 — `<sam>.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.
@@ -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 <sam>.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 <sam>.jpg to source and reads <sam>.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.
@@ -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 `<sAMAccountName>.jpg` to the source folder; the page shows Pending until `processed/<name>.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 `<sAMAccountName>.jpg` to the source folder and reads `<sAMAccountName>.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)?
@@ -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
@@ -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
@@ -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.
@@ -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.
@@ -0,0 +1,99 @@
<!doctype html><html lang="de"><meta charset="utf-8"><title>Darstellung (Admin) - contrast warning state</title><style>
/* Governing spec sections: EXPERIENCE.md Information Architecture, Component Patterns, State Patterns;
DESIGN.md Colors, Components. Mock only - the spines win on conflict. Offline: no JS, no network.
Brand blue is the Fluent 2 default (#0F6CBD) [ASSUMPTION, verify at scaffold]. German formal (Sie) is the default voice. */
:root{--brand:#0F6CBD;--on-brand:#fff}
*{box-sizing:border-box}
body{margin:0;padding:24px;font-family:"Segoe UI",system-ui,sans-serif;background:#e8e8e8;color:#242424}
h1.cap{font-size:14px;font-weight:600;margin:0 0 4px}
p.cap{font-size:12px;margin:0 0 16px;color:#555;max-width:900px}
.stage{display:flex;gap:32px;align-items:flex-start;flex-wrap:wrap}
.frame{border-radius:10px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,.25);border:1px solid #bbb}
.desk{width:980px}.phone{width:360px;border-radius:28px;border:8px solid #222}
.light{--bg:#fafafa;--card:#fff;--text:#242424;--sub:#616161;--line:#e0e0e0;--nav:#f5f5f5;--warn-bg:#fff9f5;--warn-line:#f7630c;--chip-bg:#fff4ce;--chip-text:#5c4400;--field:#fff;--fieldline:#8a8a8a}
.dark{--bg:#1f1f1f;--card:#292929;--text:#ffffff;--sub:#c8c8c8;--line:#3d3d3d;--nav:#242424;--warn-bg:#3a2a1f;--warn-line:#f7630c;--chip-bg:#4a3b00;--chip-text:#ffe9a8;--field:#333;--fieldline:#9a9a9a;--brand:#479ef5;--on-brand:#0a0a0a}
.app{background:var(--bg);color:var(--text);display:flex;flex-direction:column;min-height:560px}
.top{height:48px;background:var(--brand);color:var(--on-brand);display:flex;align-items:center;justify-content:space-between;padding:0 16px;font-weight:600}
.dark .top{background:#0f2a43;color:#fff}
.av{width:32px;height:32px;border-radius:50%;background:#fff;color:var(--brand);display:grid;place-items:center;font-size:12px;font-weight:700}
.dark .av{background:#479ef5;color:#0a0a0a}
.body{display:flex;flex:1}
.nav{width:220px;background:var(--nav);border-right:1px solid var(--line);padding:12px 8px}
.nav a{display:flex;align-items:center;gap:10px;padding:9px 12px;border-radius:4px;color:var(--text);font-size:14px;margin-bottom:2px}
.nav a.on{background:var(--card);font-weight:600;box-shadow:inset 3px 0 0 var(--brand)}
.nav i{width:14px;height:14px;border-radius:3px;background:var(--sub);opacity:.6;display:inline-block}
.main{flex:1;min-width:0;padding:24px 28px}
.phone .main{padding:16px}
.phone .row{flex-direction:column;gap:2px}
.phone .row span:first-child{width:auto}
.row span:last-child{min-width:0;overflow-wrap:anywhere}
.phone .card{padding:14px}
h2{font-size:24px;font-weight:600;margin:0 0 4px}.phone h2{font-size:20px}
.lead{font-size:14px;color:var(--sub);margin:0 0 18px;max-width:640px}
.card{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:16px 18px;margin-bottom:14px}
.card h3{font-size:16px;margin:0 0 8px}
.banner{border:1px solid var(--warn-line);background:var(--warn-bg);border-left-width:5px;border-radius:6px;padding:14px 16px;margin-bottom:14px;font-size:14px}
.banner b{display:block;margin-bottom:4px}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.phone .grid{grid-template-columns:1fr}
.row{display:flex;gap:10px;font-size:14px;padding:5px 0;border-bottom:1px solid var(--line)}
.row span:first-child{width:130px;color:var(--sub);flex:none}
.pic{width:72px;height:72px;border-radius:50%;background:var(--brand);color:var(--on-brand);display:grid;place-items:center;font-size:26px;font-weight:600;flex:none}
.btn{display:inline-block;background:var(--brand);color:var(--on-brand);border-radius:4px;padding:8px 16px;font-size:14px;font-weight:600;margin:0 8px 6px 0}
.btn.sec{background:transparent;color:var(--text);border:1px solid var(--fieldline)}
.field{margin-bottom:14px}.field label{display:block;font-size:14px;font-weight:600;margin-bottom:4px}
.field .in{height:34px;border:1px solid var(--fieldline);border-bottom-width:2px;border-radius:4px;background:var(--field);padding:6px 10px;font-size:14px;color:var(--sub)}
.hint{font-size:12px;color:var(--sub);margin-top:4px}
.chip{display:inline-block;background:var(--chip-bg);color:var(--chip-text);border-radius:10px;padding:2px 10px;font-size:12px;font-weight:600}
ul.rules{margin:6px 0 0;padding-left:20px;font-size:13px;color:var(--sub)}
.small{font-size:13px;color:var(--sub)}
.menu{position:absolute;right:16px;top:44px;background:var(--card);color:var(--text);border:1px solid var(--line);border-radius:8px;padding:12px 14px;font-size:13px;width:240px;box-shadow:0 6px 20px rgba(0,0,0,.25)}
.menu div{padding:4px 0}
.rel{position:relative}
.burger{display:inline-block;width:18px;height:2px;background:currentColor;box-shadow:0 6px 0 currentColor,0 -6px 0 currentColor}
table{width:100%;border-collapse:collapse;font-size:13px}
th{text-align:left;color:var(--sub);font-weight:600;padding:6px 8px;border-bottom:1px solid var(--line)}
td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top}
.ok{color:#107c10}.dark .ok{color:#6ccb5f}
.bad{color:#b10e1c;font-weight:600}.dark .bad{color:#ff99a4}
.ta{border:1px solid var(--fieldline);border-bottom-width:2px;border-radius:4px;background:var(--field);padding:8px 10px;font-size:13px;min-height:110px;white-space:pre-wrap;font-family:Consolas,monospace;color:var(--text)}
.two{display:grid;grid-template-columns:1fr 1fr;gap:14px}.phone .two{grid-template-columns:1fr}
.radio{display:inline-block;margin-right:14px;font-size:14px}
.radio:before{content:"";display:inline-block;width:12px;height:12px;border-radius:50%;border:2px solid var(--fieldline);margin-right:6px;vertical-align:-2px}
.radio.sel:before{border-color:var(--brand);background:radial-gradient(var(--brand) 40%,transparent 45%)}
.swatch{display:inline-block;width:40px;height:28px;border-radius:4px;border:1px solid var(--fieldline);vertical-align:middle;margin-right:8px}
.warnbox{border:1px solid var(--warn-line);background:var(--warn-bg);border-left-width:5px;border-radius:6px;padding:10px 14px;font-size:13px;margin-top:10px}
.filters span{display:inline-block;border:1px solid var(--fieldline);border-radius:14px;padding:2px 10px;font-size:12px;margin:0 6px 6px 0}
.filters span.sel{background:var(--brand);color:var(--on-brand);border-color:var(--brand)}
.navh{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--sub);padding:12px 12px 4px}
.tb{display:flex;gap:2px;border:1px solid var(--fieldline);border-bottom:0;border-radius:4px 4px 0 0;background:var(--nav);padding:4px}
.tb span{padding:3px 10px;font-size:13px;border-radius:3px}
.wys{border:1px solid var(--fieldline);border-bottom-width:2px;border-radius:0 0 4px 4px;background:var(--field);padding:10px 12px;font-size:14px;min-height:120px}
.preview{border:1px dashed var(--fieldline);border-radius:6px;padding:10px 12px;font-size:13px}
</style><body><h1 class="cap">Darstellung (Admin) - contrast warning state</h1><p class="cap">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.</p><div class="stage"><div class="frame desk light"><div class="app rel"><div class="top"><span>ping-mee Portal</span><span class="av">EB</span></div><div class="body"><div class="nav"><a class=""><i></i>Startseite</a><a class=""><i></i>Profil</a><a class=""><i></i>Profilbild</a><a class=""><i></i>Passwort</a><a class=""><i></i>Ankündigungen</a><a class=""><i></i>Benachrichtigungen</a><div class="navh">Veröffentlichen</div><a class=""><i></i>Ankündigungen verwalten</a><div class="navh">Administration</div><a class="on"><i></i>Darstellung</a><a class=""><i></i>Einstellungen</a><a class=""><i></i>Protokoll</a><a class=""><i></i>Status</a></div><div class="main">
<h2>Darstellung</h2>
<p class="lead">Farbe und Hintergrund gelten für alle Benutzer des Portals.</p>
<div class="card" style="max-width:640px"><h3>Akzentfarbe</h3>
<div class="field"><label>Farbe</label><span class="swatch" style="background:#FFD60A"></span><span class="in" style="display:inline-block;width:120px">#FFD60A</span> <span class="small">&nbsp;Standard: #0F6CBD</span></div>
<table><tr><th>Anzeige</th><th>Text auf der Farbe</th><th>Kontrast</th><th>Ergebnis</th></tr>
<tr><td>Hell</td><td>Weiß</td><td>1.4 : 1</td><td class="bad">zu niedrig (Minimum 4,5 : 1)</td></tr>
<tr><td>Dunkel</td><td>Fast schwarz</td><td>14.0 : 1</td><td class="ok">ausreichend</td></tr></table>
<div class="warnbox"><b>Warnung:</b> 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.</div>
<p style="margin:14px 0 0"><span class="btn">Trotzdem speichern</span><span class="btn sec">Andere Farbe wählen</span></p></div>
<div class="card" style="max-width:640px"><h3>Hintergrundbild</h3>
<p class="small" style="margin:0 0 8px">Das Bild erscheint nur für angemeldete Benutzer. Es wird beim Hochladen neu kodiert; bis 10 MB.</p>
<span class="btn sec">Bild hochladen</span><span class="btn sec">Kein Bild</span></div>
</div></div></div></div><div class="frame phone dark"><div class="app rel"><div class="top"><span class="burger"></span><span>ping-mee Portal</span><span class="av">EB</span></div><div class="body"><div class="main">
<h2>Darstellung</h2>
<p class="lead">Farbe und Hintergrund gelten für alle Benutzer des Portals.</p>
<div class="card" style="max-width:640px"><h3>Akzentfarbe</h3>
<div class="field"><label>Farbe</label><span class="swatch" style="background:#FFD60A"></span><span class="in" style="display:inline-block;width:120px">#FFD60A</span> <span class="small">&nbsp;Standard: #0F6CBD</span></div>
<table><tr><th>Anzeige</th><th>Text auf der Farbe</th><th>Kontrast</th><th>Ergebnis</th></tr>
<tr><td>Hell</td><td>Weiß</td><td>1.4 : 1</td><td class="bad">zu niedrig (Minimum 4,5 : 1)</td></tr>
<tr><td>Dunkel</td><td>Fast schwarz</td><td>14.0 : 1</td><td class="ok">ausreichend</td></tr></table>
<div class="warnbox"><b>Warnung:</b> 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.</div>
<p style="margin:14px 0 0"><span class="btn">Trotzdem speichern</span><span class="btn sec">Andere Farbe wählen</span></p></div>
<div class="card" style="max-width:640px"><h3>Hintergrundbild</h3>
<p class="small" style="margin:0 0 8px">Das Bild erscheint nur für angemeldete Benutzer. Es wird beim Hochladen neu kodiert; bis 10 MB.</p>
<span class="btn sec">Bild hochladen</span><span class="btn sec">Kein Bild</span></div>
</div></div></div></div></div></body></html>
@@ -0,0 +1,91 @@
<!doctype html><html lang="de"><meta charset="utf-8"><title>Protokoll (Admin)</title><style>
/* Governing spec sections: EXPERIENCE.md Information Architecture, Component Patterns, State Patterns;
DESIGN.md Colors, Components. Mock only - the spines win on conflict. Offline: no JS, no network.
Brand blue is the Fluent 2 default (#0F6CBD) [ASSUMPTION, verify at scaffold]. German formal (Sie) is the default voice. */
:root{--brand:#0F6CBD;--on-brand:#fff}
*{box-sizing:border-box}
body{margin:0;padding:24px;font-family:"Segoe UI",system-ui,sans-serif;background:#e8e8e8;color:#242424}
h1.cap{font-size:14px;font-weight:600;margin:0 0 4px}
p.cap{font-size:12px;margin:0 0 16px;color:#555;max-width:900px}
.stage{display:flex;gap:32px;align-items:flex-start;flex-wrap:wrap}
.frame{border-radius:10px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,.25);border:1px solid #bbb}
.desk{width:980px}.phone{width:360px;border-radius:28px;border:8px solid #222}
.light{--bg:#fafafa;--card:#fff;--text:#242424;--sub:#616161;--line:#e0e0e0;--nav:#f5f5f5;--warn-bg:#fff9f5;--warn-line:#f7630c;--chip-bg:#fff4ce;--chip-text:#5c4400;--field:#fff;--fieldline:#8a8a8a}
.dark{--bg:#1f1f1f;--card:#292929;--text:#ffffff;--sub:#c8c8c8;--line:#3d3d3d;--nav:#242424;--warn-bg:#3a2a1f;--warn-line:#f7630c;--chip-bg:#4a3b00;--chip-text:#ffe9a8;--field:#333;--fieldline:#9a9a9a;--brand:#479ef5;--on-brand:#0a0a0a}
.app{background:var(--bg);color:var(--text);display:flex;flex-direction:column;min-height:560px}
.top{height:48px;background:var(--brand);color:var(--on-brand);display:flex;align-items:center;justify-content:space-between;padding:0 16px;font-weight:600}
.dark .top{background:#0f2a43;color:#fff}
.av{width:32px;height:32px;border-radius:50%;background:#fff;color:var(--brand);display:grid;place-items:center;font-size:12px;font-weight:700}
.dark .av{background:#479ef5;color:#0a0a0a}
.body{display:flex;flex:1}
.nav{width:220px;background:var(--nav);border-right:1px solid var(--line);padding:12px 8px}
.nav a{display:flex;align-items:center;gap:10px;padding:9px 12px;border-radius:4px;color:var(--text);font-size:14px;margin-bottom:2px}
.nav a.on{background:var(--card);font-weight:600;box-shadow:inset 3px 0 0 var(--brand)}
.nav i{width:14px;height:14px;border-radius:3px;background:var(--sub);opacity:.6;display:inline-block}
.main{flex:1;min-width:0;padding:24px 28px}
.phone .main{padding:16px}
.phone .row{flex-direction:column;gap:2px}
.phone .row span:first-child{width:auto}
.row span:last-child{min-width:0;overflow-wrap:anywhere}
.phone .card{padding:14px}
h2{font-size:24px;font-weight:600;margin:0 0 4px}.phone h2{font-size:20px}
.lead{font-size:14px;color:var(--sub);margin:0 0 18px;max-width:640px}
.card{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:16px 18px;margin-bottom:14px}
.card h3{font-size:16px;margin:0 0 8px}
.banner{border:1px solid var(--warn-line);background:var(--warn-bg);border-left-width:5px;border-radius:6px;padding:14px 16px;margin-bottom:14px;font-size:14px}
.banner b{display:block;margin-bottom:4px}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.phone .grid{grid-template-columns:1fr}
.row{display:flex;gap:10px;font-size:14px;padding:5px 0;border-bottom:1px solid var(--line)}
.row span:first-child{width:130px;color:var(--sub);flex:none}
.pic{width:72px;height:72px;border-radius:50%;background:var(--brand);color:var(--on-brand);display:grid;place-items:center;font-size:26px;font-weight:600;flex:none}
.btn{display:inline-block;background:var(--brand);color:var(--on-brand);border-radius:4px;padding:8px 16px;font-size:14px;font-weight:600;margin:0 8px 6px 0}
.btn.sec{background:transparent;color:var(--text);border:1px solid var(--fieldline)}
.field{margin-bottom:14px}.field label{display:block;font-size:14px;font-weight:600;margin-bottom:4px}
.field .in{height:34px;border:1px solid var(--fieldline);border-bottom-width:2px;border-radius:4px;background:var(--field);padding:6px 10px;font-size:14px;color:var(--sub)}
.hint{font-size:12px;color:var(--sub);margin-top:4px}
.chip{display:inline-block;background:var(--chip-bg);color:var(--chip-text);border-radius:10px;padding:2px 10px;font-size:12px;font-weight:600}
ul.rules{margin:6px 0 0;padding-left:20px;font-size:13px;color:var(--sub)}
.small{font-size:13px;color:var(--sub)}
.menu{position:absolute;right:16px;top:44px;background:var(--card);color:var(--text);border:1px solid var(--line);border-radius:8px;padding:12px 14px;font-size:13px;width:240px;box-shadow:0 6px 20px rgba(0,0,0,.25)}
.menu div{padding:4px 0}
.rel{position:relative}
.burger{display:inline-block;width:18px;height:2px;background:currentColor;box-shadow:0 6px 0 currentColor,0 -6px 0 currentColor}
table{width:100%;border-collapse:collapse;font-size:13px}
th{text-align:left;color:var(--sub);font-weight:600;padding:6px 8px;border-bottom:1px solid var(--line)}
td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top}
.ok{color:#107c10}.dark .ok{color:#6ccb5f}
.bad{color:#b10e1c;font-weight:600}.dark .bad{color:#ff99a4}
.ta{border:1px solid var(--fieldline);border-bottom-width:2px;border-radius:4px;background:var(--field);padding:8px 10px;font-size:13px;min-height:110px;white-space:pre-wrap;font-family:Consolas,monospace;color:var(--text)}
.two{display:grid;grid-template-columns:1fr 1fr;gap:14px}.phone .two{grid-template-columns:1fr}
.radio{display:inline-block;margin-right:14px;font-size:14px}
.radio:before{content:"";display:inline-block;width:12px;height:12px;border-radius:50%;border:2px solid var(--fieldline);margin-right:6px;vertical-align:-2px}
.radio.sel:before{border-color:var(--brand);background:radial-gradient(var(--brand) 40%,transparent 45%)}
.swatch{display:inline-block;width:40px;height:28px;border-radius:4px;border:1px solid var(--fieldline);vertical-align:middle;margin-right:8px}
.warnbox{border:1px solid var(--warn-line);background:var(--warn-bg);border-left-width:5px;border-radius:6px;padding:10px 14px;font-size:13px;margin-top:10px}
.filters span{display:inline-block;border:1px solid var(--fieldline);border-radius:14px;padding:2px 10px;font-size:12px;margin:0 6px 6px 0}
.filters span.sel{background:var(--brand);color:var(--on-brand);border-color:var(--brand)}
.navh{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--sub);padding:12px 12px 4px}
.tb{display:flex;gap:2px;border:1px solid var(--fieldline);border-bottom:0;border-radius:4px 4px 0 0;background:var(--nav);padding:4px}
.tb span{padding:3px 10px;font-size:13px;border-radius:3px}
.wys{border:1px solid var(--fieldline);border-bottom-width:2px;border-radius:0 0 4px 4px;background:var(--field);padding:10px 12px;font-size:14px;min-height:120px}
.preview{border:1px dashed var(--fieldline);border-radius:6px;padding:10px 12px;font-size:13px}
</style><body><h1 class="cap">Protokoll (Admin)</h1><p class="cap">Audit log with filter chips, 12-month note, IP column. Illustrates: FR-24 and FR-28. Sample data.</p><div class="stage"><div class="frame desk light"><div class="app rel"><div class="top"><span>ping-mee Portal</span><span class="av">EB</span></div><div class="body"><div class="nav"><a class=""><i></i>Startseite</a><a class=""><i></i>Profil</a><a class=""><i></i>Profilbild</a><a class=""><i></i>Passwort</a><a class=""><i></i>Ankündigungen</a><a class=""><i></i>Benachrichtigungen</a><div class="navh">Veröffentlichen</div><a class=""><i></i>Ankündigungen verwalten</a><div class="navh">Administration</div><a class=""><i></i>Darstellung</a><a class=""><i></i>Einstellungen</a><a class="on"><i></i>Protokoll</a><a class=""><i></i>Status</a></div><div class="main">
<h2>Protokoll</h2><p class="lead">Wer hat wann was geändert? Einträge werden 12 Monate aufbewahrt. Passwörter und Bildinhalte werden nie gespeichert.</p>
<div class="filters"><span class="sel">Alle</span><span>Passwort</span><span>Profil</span><span>E-Mail-Änderung</span><span>Profilbild</span><span>Ankündigungen</span><span>Einstellungen</span><span>Gedrosselt</span></div>
<div class="card"><table><tr><th>Zeit</th><th>Benutzer</th><th>Aktion</th><th>IP-Adresse</th></tr>
<tr><td>15.10. 09:41</td><td>lmeier</td><td>Passwort geändert</td><td>203.0.113.24</td></tr>
<tr><td>15.10. 09:40</td><td>lmeier</td><td>Falsches aktuelles Passwort (1 von 5)</td><td>203.0.113.24</td></tr>
<tr><td>15.10. 08:12</td><td>tkoch</td><td>Profilbild hochgeladen</td><td>198.51.100.7</td></tr>
<tr><td>14.10. 20:03</td><td>msommer</td><td>E-Mail-Änderung rückgängig gemacht</td><td>198.51.100.33</td></tr>
<tr><td>14.10. 17:30</td><td>ebass</td><td>Ankündigung veröffentlicht</td><td>192.0.2.10</td></tr></table></div>
</div></div></div></div><div class="frame phone dark"><div class="app rel"><div class="top"><span class="burger"></span><span>ping-mee Portal</span><span class="av">EB</span></div><div class="body"><div class="main">
<h2>Protokoll</h2><p class="lead">Wer hat wann was geändert? Einträge werden 12 Monate aufbewahrt. Passwörter und Bildinhalte werden nie gespeichert.</p>
<div class="filters"><span class="sel">Alle</span><span>Passwort</span><span>Profil</span><span>E-Mail-Änderung</span><span>Profilbild</span><span>Ankündigungen</span><span>Einstellungen</span><span>Gedrosselt</span></div>
<div class="card"><table><tr><th>Zeit</th><th>Benutzer</th><th>Aktion</th><th>IP-Adresse</th></tr>
<tr><td>15.10. 09:41</td><td>lmeier</td><td>Passwort geändert</td><td>203.0.113.24</td></tr>
<tr><td>15.10. 09:40</td><td>lmeier</td><td>Falsches aktuelles Passwort (1 von 5)</td><td>203.0.113.24</td></tr>
<tr><td>15.10. 08:12</td><td>tkoch</td><td>Profilbild hochgeladen</td><td>198.51.100.7</td></tr>
<tr><td>14.10. 20:03</td><td>msommer</td><td>E-Mail-Änderung rückgängig gemacht</td><td>198.51.100.33</td></tr>
<tr><td>14.10. 17:30</td><td>ebass</td><td>Ankündigung veröffentlicht</td><td>192.0.2.10</td></tr></table></div>
</div></div></div></div></div></body></html>
@@ -0,0 +1,103 @@
<!doctype html><html lang="de"><meta charset="utf-8"><title>Status (Admin)</title><style>
/* Governing spec sections: EXPERIENCE.md Information Architecture, Component Patterns, State Patterns;
DESIGN.md Colors, Components. Mock only - the spines win on conflict. Offline: no JS, no network.
Brand blue is the Fluent 2 default (#0F6CBD) [ASSUMPTION, verify at scaffold]. German formal (Sie) is the default voice. */
:root{--brand:#0F6CBD;--on-brand:#fff}
*{box-sizing:border-box}
body{margin:0;padding:24px;font-family:"Segoe UI",system-ui,sans-serif;background:#e8e8e8;color:#242424}
h1.cap{font-size:14px;font-weight:600;margin:0 0 4px}
p.cap{font-size:12px;margin:0 0 16px;color:#555;max-width:900px}
.stage{display:flex;gap:32px;align-items:flex-start;flex-wrap:wrap}
.frame{border-radius:10px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,.25);border:1px solid #bbb}
.desk{width:980px}.phone{width:360px;border-radius:28px;border:8px solid #222}
.light{--bg:#fafafa;--card:#fff;--text:#242424;--sub:#616161;--line:#e0e0e0;--nav:#f5f5f5;--warn-bg:#fff9f5;--warn-line:#f7630c;--chip-bg:#fff4ce;--chip-text:#5c4400;--field:#fff;--fieldline:#8a8a8a}
.dark{--bg:#1f1f1f;--card:#292929;--text:#ffffff;--sub:#c8c8c8;--line:#3d3d3d;--nav:#242424;--warn-bg:#3a2a1f;--warn-line:#f7630c;--chip-bg:#4a3b00;--chip-text:#ffe9a8;--field:#333;--fieldline:#9a9a9a;--brand:#479ef5;--on-brand:#0a0a0a}
.app{background:var(--bg);color:var(--text);display:flex;flex-direction:column;min-height:560px}
.top{height:48px;background:var(--brand);color:var(--on-brand);display:flex;align-items:center;justify-content:space-between;padding:0 16px;font-weight:600}
.dark .top{background:#0f2a43;color:#fff}
.av{width:32px;height:32px;border-radius:50%;background:#fff;color:var(--brand);display:grid;place-items:center;font-size:12px;font-weight:700}
.dark .av{background:#479ef5;color:#0a0a0a}
.body{display:flex;flex:1}
.nav{width:220px;background:var(--nav);border-right:1px solid var(--line);padding:12px 8px}
.nav a{display:flex;align-items:center;gap:10px;padding:9px 12px;border-radius:4px;color:var(--text);font-size:14px;margin-bottom:2px}
.nav a.on{background:var(--card);font-weight:600;box-shadow:inset 3px 0 0 var(--brand)}
.nav i{width:14px;height:14px;border-radius:3px;background:var(--sub);opacity:.6;display:inline-block}
.main{flex:1;min-width:0;padding:24px 28px}
.phone .main{padding:16px}
.phone .row{flex-direction:column;gap:2px}
.phone .row span:first-child{width:auto}
.row span:last-child{min-width:0;overflow-wrap:anywhere}
.phone .card{padding:14px}
h2{font-size:24px;font-weight:600;margin:0 0 4px}.phone h2{font-size:20px}
.lead{font-size:14px;color:var(--sub);margin:0 0 18px;max-width:640px}
.card{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:16px 18px;margin-bottom:14px}
.card h3{font-size:16px;margin:0 0 8px}
.banner{border:1px solid var(--warn-line);background:var(--warn-bg);border-left-width:5px;border-radius:6px;padding:14px 16px;margin-bottom:14px;font-size:14px}
.banner b{display:block;margin-bottom:4px}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.phone .grid{grid-template-columns:1fr}
.row{display:flex;gap:10px;font-size:14px;padding:5px 0;border-bottom:1px solid var(--line)}
.row span:first-child{width:130px;color:var(--sub);flex:none}
.pic{width:72px;height:72px;border-radius:50%;background:var(--brand);color:var(--on-brand);display:grid;place-items:center;font-size:26px;font-weight:600;flex:none}
.btn{display:inline-block;background:var(--brand);color:var(--on-brand);border-radius:4px;padding:8px 16px;font-size:14px;font-weight:600;margin:0 8px 6px 0}
.btn.sec{background:transparent;color:var(--text);border:1px solid var(--fieldline)}
.field{margin-bottom:14px}.field label{display:block;font-size:14px;font-weight:600;margin-bottom:4px}
.field .in{height:34px;border:1px solid var(--fieldline);border-bottom-width:2px;border-radius:4px;background:var(--field);padding:6px 10px;font-size:14px;color:var(--sub)}
.hint{font-size:12px;color:var(--sub);margin-top:4px}
.chip{display:inline-block;background:var(--chip-bg);color:var(--chip-text);border-radius:10px;padding:2px 10px;font-size:12px;font-weight:600}
ul.rules{margin:6px 0 0;padding-left:20px;font-size:13px;color:var(--sub)}
.small{font-size:13px;color:var(--sub)}
.menu{position:absolute;right:16px;top:44px;background:var(--card);color:var(--text);border:1px solid var(--line);border-radius:8px;padding:12px 14px;font-size:13px;width:240px;box-shadow:0 6px 20px rgba(0,0,0,.25)}
.menu div{padding:4px 0}
.rel{position:relative}
.burger{display:inline-block;width:18px;height:2px;background:currentColor;box-shadow:0 6px 0 currentColor,0 -6px 0 currentColor}
table{width:100%;border-collapse:collapse;font-size:13px}
th{text-align:left;color:var(--sub);font-weight:600;padding:6px 8px;border-bottom:1px solid var(--line)}
td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top}
.ok{color:#107c10}.dark .ok{color:#6ccb5f}
.bad{color:#b10e1c;font-weight:600}.dark .bad{color:#ff99a4}
.ta{border:1px solid var(--fieldline);border-bottom-width:2px;border-radius:4px;background:var(--field);padding:8px 10px;font-size:13px;min-height:110px;white-space:pre-wrap;font-family:Consolas,monospace;color:var(--text)}
.two{display:grid;grid-template-columns:1fr 1fr;gap:14px}.phone .two{grid-template-columns:1fr}
.radio{display:inline-block;margin-right:14px;font-size:14px}
.radio:before{content:"";display:inline-block;width:12px;height:12px;border-radius:50%;border:2px solid var(--fieldline);margin-right:6px;vertical-align:-2px}
.radio.sel:before{border-color:var(--brand);background:radial-gradient(var(--brand) 40%,transparent 45%)}
.swatch{display:inline-block;width:40px;height:28px;border-radius:4px;border:1px solid var(--fieldline);vertical-align:middle;margin-right:8px}
.warnbox{border:1px solid var(--warn-line);background:var(--warn-bg);border-left-width:5px;border-radius:6px;padding:10px 14px;font-size:13px;margin-top:10px}
.filters span{display:inline-block;border:1px solid var(--fieldline);border-radius:14px;padding:2px 10px;font-size:12px;margin:0 6px 6px 0}
.filters span.sel{background:var(--brand);color:var(--on-brand);border-color:var(--brand)}
.navh{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--sub);padding:12px 12px 4px}
.tb{display:flex;gap:2px;border:1px solid var(--fieldline);border-bottom:0;border-radius:4px 4px 0 0;background:var(--nav);padding:4px}
.tb span{padding:3px 10px;font-size:13px;border-radius:3px}
.wys{border:1px solid var(--fieldline);border-bottom-width:2px;border-radius:0 0 4px 4px;background:var(--field);padding:10px 12px;font-size:14px;min-height:120px}
.preview{border:1px dashed var(--fieldline);border-radius:6px;padding:10px 12px;font-size:13px}
</style><body><h1 class="cap">Status (Admin)</h1><p class="cap">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.</p><div class="stage"><div class="frame desk light"><div class="app rel"><div class="top"><span>ping-mee Portal</span><span class="av">EB</span></div><div class="body"><div class="nav"><a class=""><i></i>Startseite</a><a class=""><i></i>Profil</a><a class=""><i></i>Profilbild</a><a class=""><i></i>Passwort</a><a class=""><i></i>Ankündigungen</a><a class=""><i></i>Benachrichtigungen</a><div class="navh">Veröffentlichen</div><a class=""><i></i>Ankündigungen verwalten</a><div class="navh">Administration</div><a class=""><i></i>Darstellung</a><a class=""><i></i>Einstellungen</a><a class=""><i></i>Protokoll</a><a class="on"><i></i>Status</a></div><div class="main">
<h2>Status</h2><p class="lead">Hier sehen Sie, ob die Hintergrundaufgaben laufen. Probleme bei Datensicherung und E-Mails erscheinen nur hier.</p>
<div class="two">
<div class="card"><h3>Datensicherung</h3><p style="margin:0"><b class="ok">Letzte erfolgreiche Sicherung: vor 3 Stunden</b></p><p class="small">Ihre Veeam-Kopie sichert diesen Ordner. Ist die Sicherung älter als 26 Stunden, wird sie hier rot.</p></div>
<div class="card"><h3>E-Mail-Versand</h3><p style="margin:0"><b class="bad">2 E-Mails fehlgeschlagen</b></p><p class="small">Der Versand wird automatisch wiederholt. Nach 5 Versuchen bleibt die E-Mail hier stehen.</p></div></div>
<div class="card"><h3>Fehlgeschlagene E-Mails</h3>
<table><tr><th>Empfänger</th><th>Betreff</th><th>Versuche</th><th>Grund</th></tr>
<tr><td>tom.k@example.com</td><td>Geplante Wartung am Samstag</td><td>3</td><td>Postfach voll</td></tr>
<tr><td>marie.s@example.com</td><td>Geplante Wartung am Samstag</td><td>2</td><td>Server nicht erreichbar</td></tr></table></div>
<div class="card"><h3>Hintergrundaufgaben</h3>
<table><tr><th>Aufgabe</th><th>Letzter Lauf</th><th>Ergebnis</th></tr>
<tr><td>Geplante Ankündigungen</td><td>vor 20 Sekunden</td><td class="ok">in Ordnung</td></tr>
<tr><td>E-Mail-Versand</td><td>vor 20 Sekunden</td><td class="ok">in Ordnung</td></tr>
<tr><td>Abgleich mit dem Konto-Verzeichnis</td><td>heute 03:00 Uhr</td><td class="ok">in Ordnung</td></tr>
<tr><td>Protokoll aufräumen</td><td>heute 03:05 Uhr</td><td class="ok">in Ordnung</td></tr></table></div>
</div></div></div></div><div class="frame phone dark"><div class="app rel"><div class="top"><span class="burger"></span><span>ping-mee Portal</span><span class="av">EB</span></div><div class="body"><div class="main">
<h2>Status</h2><p class="lead">Hier sehen Sie, ob die Hintergrundaufgaben laufen. Probleme bei Datensicherung und E-Mails erscheinen nur hier.</p>
<div class="two">
<div class="card"><h3>Datensicherung</h3><p style="margin:0"><b class="ok">Letzte erfolgreiche Sicherung: vor 3 Stunden</b></p><p class="small">Ihre Veeam-Kopie sichert diesen Ordner. Ist die Sicherung älter als 26 Stunden, wird sie hier rot.</p></div>
<div class="card"><h3>E-Mail-Versand</h3><p style="margin:0"><b class="bad">2 E-Mails fehlgeschlagen</b></p><p class="small">Der Versand wird automatisch wiederholt. Nach 5 Versuchen bleibt die E-Mail hier stehen.</p></div></div>
<div class="card"><h3>Fehlgeschlagene E-Mails</h3>
<table><tr><th>Empfänger</th><th>Betreff</th><th>Versuche</th><th>Grund</th></tr>
<tr><td>tom.k@example.com</td><td>Geplante Wartung am Samstag</td><td>3</td><td>Postfach voll</td></tr>
<tr><td>marie.s@example.com</td><td>Geplante Wartung am Samstag</td><td>2</td><td>Server nicht erreichbar</td></tr></table></div>
<div class="card"><h3>Hintergrundaufgaben</h3>
<table><tr><th>Aufgabe</th><th>Letzter Lauf</th><th>Ergebnis</th></tr>
<tr><td>Geplante Ankündigungen</td><td>vor 20 Sekunden</td><td class="ok">in Ordnung</td></tr>
<tr><td>E-Mail-Versand</td><td>vor 20 Sekunden</td><td class="ok">in Ordnung</td></tr>
<tr><td>Abgleich mit dem Konto-Verzeichnis</td><td>heute 03:00 Uhr</td><td class="ok">in Ordnung</td></tr>
<tr><td>Protokoll aufräumen</td><td>heute 03:05 Uhr</td><td class="ok">in Ordnung</td></tr></table></div>
</div></div></div></div></div></body></html>
@@ -0,0 +1,109 @@
<!doctype html><html lang="de"><meta charset="utf-8"><title>Ankündigung verfassen (Publisher)</title><style>
/* Governing spec sections: EXPERIENCE.md Information Architecture, Component Patterns, State Patterns;
DESIGN.md Colors, Components. Mock only - the spines win on conflict. Offline: no JS, no network.
Brand blue is the Fluent 2 default (#0F6CBD) [ASSUMPTION, verify at scaffold]. German formal (Sie) is the default voice. */
:root{--brand:#0F6CBD;--on-brand:#fff}
*{box-sizing:border-box}
body{margin:0;padding:24px;font-family:"Segoe UI",system-ui,sans-serif;background:#e8e8e8;color:#242424}
h1.cap{font-size:14px;font-weight:600;margin:0 0 4px}
p.cap{font-size:12px;margin:0 0 16px;color:#555;max-width:900px}
.stage{display:flex;gap:32px;align-items:flex-start;flex-wrap:wrap}
.frame{border-radius:10px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,.25);border:1px solid #bbb}
.desk{width:980px}.phone{width:360px;border-radius:28px;border:8px solid #222}
.light{--bg:#fafafa;--card:#fff;--text:#242424;--sub:#616161;--line:#e0e0e0;--nav:#f5f5f5;--warn-bg:#fff9f5;--warn-line:#f7630c;--chip-bg:#fff4ce;--chip-text:#5c4400;--field:#fff;--fieldline:#8a8a8a}
.dark{--bg:#1f1f1f;--card:#292929;--text:#ffffff;--sub:#c8c8c8;--line:#3d3d3d;--nav:#242424;--warn-bg:#3a2a1f;--warn-line:#f7630c;--chip-bg:#4a3b00;--chip-text:#ffe9a8;--field:#333;--fieldline:#9a9a9a;--brand:#479ef5;--on-brand:#0a0a0a}
.app{background:var(--bg);color:var(--text);display:flex;flex-direction:column;min-height:560px}
.top{height:48px;background:var(--brand);color:var(--on-brand);display:flex;align-items:center;justify-content:space-between;padding:0 16px;font-weight:600}
.dark .top{background:#0f2a43;color:#fff}
.av{width:32px;height:32px;border-radius:50%;background:#fff;color:var(--brand);display:grid;place-items:center;font-size:12px;font-weight:700}
.dark .av{background:#479ef5;color:#0a0a0a}
.body{display:flex;flex:1}
.nav{width:220px;background:var(--nav);border-right:1px solid var(--line);padding:12px 8px}
.nav a{display:flex;align-items:center;gap:10px;padding:9px 12px;border-radius:4px;color:var(--text);font-size:14px;margin-bottom:2px}
.nav a.on{background:var(--card);font-weight:600;box-shadow:inset 3px 0 0 var(--brand)}
.nav i{width:14px;height:14px;border-radius:3px;background:var(--sub);opacity:.6;display:inline-block}
.main{flex:1;min-width:0;padding:24px 28px}
.phone .main{padding:16px}
.phone .row{flex-direction:column;gap:2px}
.phone .row span:first-child{width:auto}
.row span:last-child{min-width:0;overflow-wrap:anywhere}
.phone .card{padding:14px}
h2{font-size:24px;font-weight:600;margin:0 0 4px}.phone h2{font-size:20px}
.lead{font-size:14px;color:var(--sub);margin:0 0 18px;max-width:640px}
.card{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:16px 18px;margin-bottom:14px}
.card h3{font-size:16px;margin:0 0 8px}
.banner{border:1px solid var(--warn-line);background:var(--warn-bg);border-left-width:5px;border-radius:6px;padding:14px 16px;margin-bottom:14px;font-size:14px}
.banner b{display:block;margin-bottom:4px}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.phone .grid{grid-template-columns:1fr}
.row{display:flex;gap:10px;font-size:14px;padding:5px 0;border-bottom:1px solid var(--line)}
.row span:first-child{width:130px;color:var(--sub);flex:none}
.pic{width:72px;height:72px;border-radius:50%;background:var(--brand);color:var(--on-brand);display:grid;place-items:center;font-size:26px;font-weight:600;flex:none}
.btn{display:inline-block;background:var(--brand);color:var(--on-brand);border-radius:4px;padding:8px 16px;font-size:14px;font-weight:600;margin:0 8px 6px 0}
.btn.sec{background:transparent;color:var(--text);border:1px solid var(--fieldline)}
.field{margin-bottom:14px}.field label{display:block;font-size:14px;font-weight:600;margin-bottom:4px}
.field .in{height:34px;border:1px solid var(--fieldline);border-bottom-width:2px;border-radius:4px;background:var(--field);padding:6px 10px;font-size:14px;color:var(--sub)}
.hint{font-size:12px;color:var(--sub);margin-top:4px}
.chip{display:inline-block;background:var(--chip-bg);color:var(--chip-text);border-radius:10px;padding:2px 10px;font-size:12px;font-weight:600}
ul.rules{margin:6px 0 0;padding-left:20px;font-size:13px;color:var(--sub)}
.small{font-size:13px;color:var(--sub)}
.menu{position:absolute;right:16px;top:44px;background:var(--card);color:var(--text);border:1px solid var(--line);border-radius:8px;padding:12px 14px;font-size:13px;width:240px;box-shadow:0 6px 20px rgba(0,0,0,.25)}
.menu div{padding:4px 0}
.rel{position:relative}
.burger{display:inline-block;width:18px;height:2px;background:currentColor;box-shadow:0 6px 0 currentColor,0 -6px 0 currentColor}
table{width:100%;border-collapse:collapse;font-size:13px}
th{text-align:left;color:var(--sub);font-weight:600;padding:6px 8px;border-bottom:1px solid var(--line)}
td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top}
.ok{color:#107c10}.dark .ok{color:#6ccb5f}
.bad{color:#b10e1c;font-weight:600}.dark .bad{color:#ff99a4}
.ta{border:1px solid var(--fieldline);border-bottom-width:2px;border-radius:4px;background:var(--field);padding:8px 10px;font-size:13px;min-height:110px;white-space:pre-wrap;font-family:Consolas,monospace;color:var(--text)}
.two{display:grid;grid-template-columns:1fr 1fr;gap:14px}.phone .two{grid-template-columns:1fr}
.radio{display:inline-block;margin-right:14px;font-size:14px}
.radio:before{content:"";display:inline-block;width:12px;height:12px;border-radius:50%;border:2px solid var(--fieldline);margin-right:6px;vertical-align:-2px}
.radio.sel:before{border-color:var(--brand);background:radial-gradient(var(--brand) 40%,transparent 45%)}
.swatch{display:inline-block;width:40px;height:28px;border-radius:4px;border:1px solid var(--fieldline);vertical-align:middle;margin-right:8px}
.warnbox{border:1px solid var(--warn-line);background:var(--warn-bg);border-left-width:5px;border-radius:6px;padding:10px 14px;font-size:13px;margin-top:10px}
.filters span{display:inline-block;border:1px solid var(--fieldline);border-radius:14px;padding:2px 10px;font-size:12px;margin:0 6px 6px 0}
.filters span.sel{background:var(--brand);color:var(--on-brand);border-color:var(--brand)}
.navh{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--sub);padding:12px 12px 4px}
.tb{display:flex;gap:2px;border:1px solid var(--fieldline);border-bottom:0;border-radius:4px 4px 0 0;background:var(--nav);padding:4px}
.tb span{padding:3px 10px;font-size:13px;border-radius:3px}
.wys{border:1px solid var(--fieldline);border-bottom-width:2px;border-radius:0 0 4px 4px;background:var(--field);padding:10px 12px;font-size:14px;min-height:120px}
.preview{border:1px dashed var(--fieldline);border-radius:6px;padding:10px 12px;font-size:13px}
</style><body><h1 class="cap">Ankündigung verfassen (Publisher)</h1><p class="cap">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.</p><div class="stage"><div class="frame desk light"><div class="app rel"><div class="top"><span>ping-mee Portal</span><span class="av">EB</span></div><div class="body"><div class="nav"><a class=""><i></i>Startseite</a><a class=""><i></i>Profil</a><a class=""><i></i>Profilbild</a><a class=""><i></i>Passwort</a><a class=""><i></i>Ankündigungen</a><a class=""><i></i>Benachrichtigungen</a><div class="navh">Veröffentlichen</div><a class="on"><i></i>Ankündigungen verwalten</a><div class="navh">Administration</div><a class=""><i></i>Darstellung</a><a class=""><i></i>Einstellungen</a><a class=""><i></i>Protokoll</a><a class=""><i></i>Status</a></div><div class="main">
<h2>Ankündigung verfassen</h2>
<p class="lead">Ihre Ankündigung erscheint auf der Startseite und kann per E-Mail an alle angemeldeten Empfänger gehen.</p>
<div class="card"><div class="field"><label>Titel</label><div class="in" style="color:var(--text)">Geplante Wartung am Samstag</div></div>
<div class="field"><label>Wichtigkeit</label><span class="radio">Information</span><span class="radio sel">Warnung</span><span class="radio">Ausfall</span>
<div class="hint">Bestimmt die Farbe der Meldung auf der Startseite. Ohne Auswahl gilt „Information“.</div></div>
<div class="field"><label>Text</label>
<div class="tb"><span><b>F</b></span><span><i>K</i></span><span>Überschrift</span><span>Liste</span><span>Link</span></div>
<div class="wys">Jellyfin und die <b>Dateifreigabe</b> sind am Samstag von 08:00 bis 12:00 Uhr nicht erreichbar.<ul style="margin:8px 0 0;padding-left:20px"><li>Grund: Umzug auf neue Hardware</li><li>Alles andere läuft normal</li></ul></div>
<div class="hint">So sehen es Benutzer auf der Startseite und in der E-Mail.</div></div></div>
<div class="card"><h3>Weitere Angaben (optional)</h3>
<div class="two"><div class="field"><label>Wartungsfenster: Beginn</label><div class="in">18.10.2026 08:00</div></div><div class="field"><label>Wartungsfenster: Ende</label><div class="in">18.10.2026 12:00</div></div></div>
<div class="hint" style="margin:-6px 0 12px">Zeitzone: Europe/Berlin. Die Zeitzone steht auch in der E-Mail.</div>
<div class="two"><div class="field"><label>Ablaufdatum</label><div class="in">18.10.2026 13:00</div><div class="hint">Danach verschwindet die Ankündigung von der Startseite.</div></div>
<div class="field"><label>Veröffentlichen</label><span class="radio">Sofort</span><span class="radio sel">Geplant</span><div class="in" style="margin-top:6px">17.10.2026 08:00</div></div></div></div>
<div class="card"><h3>E-Mail</h3><div class="radio sel">Benutzer per E-Mail benachrichtigen</div>
<p class="small" style="margin:6px 0 0">Empfänger: <b>9 von 12 Benutzern</b>. 2 haben E-Mails abgeschaltet, 1 hat keine E-Mail-Adresse hinterlegt.</p></div>
<span class="btn">Veröffentlichen</span><span class="btn sec">Als Entwurf speichern</span>
</div></div></div></div><div class="frame phone dark"><div class="app rel"><div class="top"><span class="burger"></span><span>ping-mee Portal</span><span class="av">EB</span></div><div class="body"><div class="main">
<h2>Ankündigung verfassen</h2>
<p class="lead">Ihre Ankündigung erscheint auf der Startseite und kann per E-Mail an alle angemeldeten Empfänger gehen.</p>
<div class="card"><div class="field"><label>Titel</label><div class="in" style="color:var(--text)">Geplante Wartung am Samstag</div></div>
<div class="field"><label>Wichtigkeit</label><span class="radio">Information</span><span class="radio sel">Warnung</span><span class="radio">Ausfall</span>
<div class="hint">Bestimmt die Farbe der Meldung auf der Startseite. Ohne Auswahl gilt „Information“.</div></div>
<div class="field"><label>Text</label>
<div class="tb"><span><b>F</b></span><span><i>K</i></span><span>Überschrift</span><span>Liste</span><span>Link</span></div>
<div class="wys">Jellyfin und die <b>Dateifreigabe</b> sind am Samstag von 08:00 bis 12:00 Uhr nicht erreichbar.<ul style="margin:8px 0 0;padding-left:20px"><li>Grund: Umzug auf neue Hardware</li><li>Alles andere läuft normal</li></ul></div>
<div class="hint">So sehen es Benutzer auf der Startseite und in der E-Mail.</div></div></div>
<div class="card"><h3>Weitere Angaben (optional)</h3>
<div class="two"><div class="field"><label>Wartungsfenster: Beginn</label><div class="in">18.10.2026 08:00</div></div><div class="field"><label>Wartungsfenster: Ende</label><div class="in">18.10.2026 12:00</div></div></div>
<div class="hint" style="margin:-6px 0 12px">Zeitzone: Europe/Berlin. Die Zeitzone steht auch in der E-Mail.</div>
<div class="two"><div class="field"><label>Ablaufdatum</label><div class="in">18.10.2026 13:00</div><div class="hint">Danach verschwindet die Ankündigung von der Startseite.</div></div>
<div class="field"><label>Veröffentlichen</label><span class="radio">Sofort</span><span class="radio sel">Geplant</span><div class="in" style="margin-top:6px">17.10.2026 08:00</div></div></div></div>
<div class="card"><h3>E-Mail</h3><div class="radio sel">Benutzer per E-Mail benachrichtigen</div>
<p class="small" style="margin:6px 0 0">Empfänger: <b>9 von 12 Benutzern</b>. 2 haben E-Mails abgeschaltet, 1 hat keine E-Mail-Adresse hinterlegt.</p></div>
<span class="btn">Veröffentlichen</span><span class="btn sec">Als Entwurf speichern</span>
</div></div></div></div></div></body></html>
@@ -0,0 +1,85 @@
<!doctype html><html lang="de"><meta charset="utf-8"><title>Dashboard (Startseite)</title><style>
/* Governing spec sections: EXPERIENCE.md Information Architecture, Component Patterns, State Patterns;
DESIGN.md Colors, Components. Mock only - the spines win on conflict. Offline: no JS, no network.
Brand blue is the Fluent 2 default (#0F6CBD) [ASSUMPTION, verify at scaffold]. German formal (Sie) is the default voice. */
:root{--brand:#0F6CBD;--on-brand:#fff}
*{box-sizing:border-box}
body{margin:0;padding:24px;font-family:"Segoe UI",system-ui,sans-serif;background:#e8e8e8;color:#242424}
h1.cap{font-size:14px;font-weight:600;margin:0 0 4px}
p.cap{font-size:12px;margin:0 0 16px;color:#555;max-width:900px}
.stage{display:flex;gap:32px;align-items:flex-start;flex-wrap:wrap}
.frame{border-radius:10px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,.25);border:1px solid #bbb}
.desk{width:980px}.phone{width:360px;border-radius:28px;border:8px solid #222}
.light{--bg:#fafafa;--card:#fff;--text:#242424;--sub:#616161;--line:#e0e0e0;--nav:#f5f5f5;--warn-bg:#fff9f5;--warn-line:#f7630c;--chip-bg:#fff4ce;--chip-text:#5c4400;--field:#fff;--fieldline:#8a8a8a}
.dark{--bg:#1f1f1f;--card:#292929;--text:#ffffff;--sub:#c8c8c8;--line:#3d3d3d;--nav:#242424;--warn-bg:#3a2a1f;--warn-line:#f7630c;--chip-bg:#4a3b00;--chip-text:#ffe9a8;--field:#333;--fieldline:#9a9a9a;--brand:#479ef5;--on-brand:#0a0a0a}
.app{background:var(--bg);color:var(--text);display:flex;flex-direction:column;min-height:560px}
.top{height:48px;background:var(--brand);color:var(--on-brand);display:flex;align-items:center;justify-content:space-between;padding:0 16px;font-weight:600}
.dark .top{background:#0f2a43;color:#fff}
.av{width:32px;height:32px;border-radius:50%;background:#fff;color:var(--brand);display:grid;place-items:center;font-size:12px;font-weight:700}
.dark .av{background:#479ef5;color:#0a0a0a}
.body{display:flex;flex:1}
.nav{width:220px;background:var(--nav);border-right:1px solid var(--line);padding:12px 8px}
.nav a{display:flex;align-items:center;gap:10px;padding:9px 12px;border-radius:4px;color:var(--text);font-size:14px;margin-bottom:2px}
.nav a.on{background:var(--card);font-weight:600;box-shadow:inset 3px 0 0 var(--brand)}
.nav i{width:14px;height:14px;border-radius:3px;background:var(--sub);opacity:.6;display:inline-block}
.main{flex:1;min-width:0;padding:24px 28px}
.phone .main{padding:16px}
.phone .row{flex-direction:column;gap:2px}
.phone .row span:first-child{width:auto}
.row span:last-child{min-width:0;overflow-wrap:anywhere}
.phone .card{padding:14px}
h2{font-size:24px;font-weight:600;margin:0 0 4px}.phone h2{font-size:20px}
.lead{font-size:14px;color:var(--sub);margin:0 0 18px;max-width:640px}
.card{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:16px 18px;margin-bottom:14px}
.card h3{font-size:16px;margin:0 0 8px}
.banner{border:1px solid var(--warn-line);background:var(--warn-bg);border-left-width:5px;border-radius:6px;padding:14px 16px;margin-bottom:14px;font-size:14px}
.banner b{display:block;margin-bottom:4px}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.phone .grid{grid-template-columns:1fr}
.row{display:flex;gap:10px;font-size:14px;padding:5px 0;border-bottom:1px solid var(--line)}
.row span:first-child{width:130px;color:var(--sub);flex:none}
.pic{width:72px;height:72px;border-radius:50%;background:var(--brand);color:var(--on-brand);display:grid;place-items:center;font-size:26px;font-weight:600;flex:none}
.btn{display:inline-block;background:var(--brand);color:var(--on-brand);border-radius:4px;padding:8px 16px;font-size:14px;font-weight:600;margin:0 8px 6px 0}
.btn.sec{background:transparent;color:var(--text);border:1px solid var(--fieldline)}
.field{margin-bottom:14px}.field label{display:block;font-size:14px;font-weight:600;margin-bottom:4px}
.field .in{height:34px;border:1px solid var(--fieldline);border-bottom-width:2px;border-radius:4px;background:var(--field);padding:6px 10px;font-size:14px;color:var(--sub)}
.hint{font-size:12px;color:var(--sub);margin-top:4px}
.chip{display:inline-block;background:var(--chip-bg);color:var(--chip-text);border-radius:10px;padding:2px 10px;font-size:12px;font-weight:600}
ul.rules{margin:6px 0 0;padding-left:20px;font-size:13px;color:var(--sub)}
.small{font-size:13px;color:var(--sub)}
.menu{position:absolute;right:16px;top:44px;background:var(--card);color:var(--text);border:1px solid var(--line);border-radius:8px;padding:12px 14px;font-size:13px;width:240px;box-shadow:0 6px 20px rgba(0,0,0,.25)}
.menu div{padding:4px 0}
.rel{position:relative}
.burger{display:inline-block;width:18px;height:2px;background:currentColor;box-shadow:0 6px 0 currentColor,0 -6px 0 currentColor}
</style><body><h1 class="cap">Dashboard (Startseite)</h1><p class="cap">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.</p><div class="stage"><div class="frame desk light"><div class="app rel"><div class="top"><span>ping-mee Portal</span><span class="av">LM</span></div><div class="body"><div class="nav"><a class="on"><i></i>Startseite</a><a class=""><i></i>Profil</a><a class=""><i></i>Profilbild</a><a class=""><i></i>Passwort</a><a class=""><i></i>Ankündigungen</a><a class=""><i></i>Benachrichtigungen</a></div><div class="main">
<h2>Guten Tag, Lena</h2><p class="lead">Hier sehen Sie aktuelle Hinweise und Ihre Kontodaten auf einen Blick.</p>
<div class="banner"><b>Geplante Wartung: Samstag, 18. Oktober, 08:00–12:00 Uhr (Europe/Berlin)</b>
Jellyfin und die Dateifreigabe sind in dieser Zeit nicht erreichbar. Alles andere läuft normal.</div>
<div class="grid">
<div class="card"><h3>Ihr Konto</h3>
<div style="display:flex;gap:14px;align-items:center;margin-bottom:10px"><div class="pic">LM</div><div><b>Lena Meier</b><div class="small">Benutzername: lmeier</div></div></div>
<div class="row"><span>E-Mail</span><span>lena.meier@example.com</span></div>
<div class="row"><span>Telefon</span><span>+49 170 1234567</span></div>
<div class="row"><span>Passwort zuletzt geändert</span><span>3. September 2026</span></div>
<p style="margin:12px 0 0"><span class="btn sec">Profil bearbeiten</span></p></div>
<div class="card"><h3>Letzte Ankündigungen</h3>
<div class="row"><span>12. Okt.</span><span>Neue Serie in Jellyfin verfügbar</span></div>
<div class="row"><span>29. Sep.</span><span>Server-Umzug abgeschlossen</span></div>
<div class="row"><span>14. Sep.</span><span>Kurze Unterbrechung heute Abend</span></div>
<p style="margin:12px 0 0"><span class="btn sec">Alle Ankündigungen</span></p></div></div>
</div></div><div class="menu"><b>Lena Meier</b><div class="small">lmeier</div><hr style="border:0;border-top:1px solid var(--line)"><div>Design: Hell · Dunkel · <b>System</b></div><div>Sprache: <b>Deutsch</b> · English</div><div>Anrede: <b>Sie</b> · Du</div><hr style="border:0;border-top:1px solid var(--line)"><div>Abmelden</div></div></div></div><div class="frame phone dark"><div class="app rel"><div class="top"><span class="burger"></span><span>ping-mee Portal</span><span class="av">LM</span></div><div class="body"><div class="main">
<h2>Guten Tag, Lena</h2><p class="lead">Hier sehen Sie aktuelle Hinweise und Ihre Kontodaten auf einen Blick.</p>
<div class="banner"><b>Geplante Wartung: Samstag, 18. Oktober, 08:00–12:00 Uhr (Europe/Berlin)</b>
Jellyfin und die Dateifreigabe sind in dieser Zeit nicht erreichbar. Alles andere läuft normal.</div>
<div class="grid">
<div class="card"><h3>Ihr Konto</h3>
<div style="display:flex;gap:14px;align-items:center;margin-bottom:10px"><div class="pic">LM</div><div><b>Lena Meier</b><div class="small">Benutzername: lmeier</div></div></div>
<div class="row"><span>E-Mail</span><span>lena.meier@example.com</span></div>
<div class="row"><span>Telefon</span><span>+49 170 1234567</span></div>
<div class="row"><span>Passwort zuletzt geändert</span><span>3. September 2026</span></div>
<p style="margin:12px 0 0"><span class="btn sec">Profil bearbeiten</span></p></div>
<div class="card"><h3>Letzte Ankündigungen</h3>
<div class="row"><span>12. Okt.</span><span>Neue Serie in Jellyfin verfügbar</span></div>
<div class="row"><span>29. Sep.</span><span>Server-Umzug abgeschlossen</span></div>
<div class="row"><span>14. Sep.</span><span>Kurze Unterbrechung heute Abend</span></div>
<p style="margin:12px 0 0"><span class="btn sec">Alle Ankündigungen</span></p></div></div>
</div></div></div></div></div></body></html>
@@ -0,0 +1,73 @@
<!doctype html><html lang="de"><meta charset="utf-8"><title>Passwort ändern</title><style>
/* Governing spec sections: EXPERIENCE.md Information Architecture, Component Patterns, State Patterns;
DESIGN.md Colors, Components. Mock only - the spines win on conflict. Offline: no JS, no network.
Brand blue is the Fluent 2 default (#0F6CBD) [ASSUMPTION, verify at scaffold]. German formal (Sie) is the default voice. */
:root{--brand:#0F6CBD;--on-brand:#fff}
*{box-sizing:border-box}
body{margin:0;padding:24px;font-family:"Segoe UI",system-ui,sans-serif;background:#e8e8e8;color:#242424}
h1.cap{font-size:14px;font-weight:600;margin:0 0 4px}
p.cap{font-size:12px;margin:0 0 16px;color:#555;max-width:900px}
.stage{display:flex;gap:32px;align-items:flex-start;flex-wrap:wrap}
.frame{border-radius:10px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,.25);border:1px solid #bbb}
.desk{width:980px}.phone{width:360px;border-radius:28px;border:8px solid #222}
.light{--bg:#fafafa;--card:#fff;--text:#242424;--sub:#616161;--line:#e0e0e0;--nav:#f5f5f5;--warn-bg:#fff9f5;--warn-line:#f7630c;--chip-bg:#fff4ce;--chip-text:#5c4400;--field:#fff;--fieldline:#8a8a8a}
.dark{--bg:#1f1f1f;--card:#292929;--text:#ffffff;--sub:#c8c8c8;--line:#3d3d3d;--nav:#242424;--warn-bg:#3a2a1f;--warn-line:#f7630c;--chip-bg:#4a3b00;--chip-text:#ffe9a8;--field:#333;--fieldline:#9a9a9a;--brand:#479ef5;--on-brand:#0a0a0a}
.app{background:var(--bg);color:var(--text);display:flex;flex-direction:column;min-height:560px}
.top{height:48px;background:var(--brand);color:var(--on-brand);display:flex;align-items:center;justify-content:space-between;padding:0 16px;font-weight:600}
.dark .top{background:#0f2a43;color:#fff}
.av{width:32px;height:32px;border-radius:50%;background:#fff;color:var(--brand);display:grid;place-items:center;font-size:12px;font-weight:700}
.dark .av{background:#479ef5;color:#0a0a0a}
.body{display:flex;flex:1}
.nav{width:220px;background:var(--nav);border-right:1px solid var(--line);padding:12px 8px}
.nav a{display:flex;align-items:center;gap:10px;padding:9px 12px;border-radius:4px;color:var(--text);font-size:14px;margin-bottom:2px}
.nav a.on{background:var(--card);font-weight:600;box-shadow:inset 3px 0 0 var(--brand)}
.nav i{width:14px;height:14px;border-radius:3px;background:var(--sub);opacity:.6;display:inline-block}
.main{flex:1;min-width:0;padding:24px 28px}
.phone .main{padding:16px}
.phone .row{flex-direction:column;gap:2px}
.phone .row span:first-child{width:auto}
.row span:last-child{min-width:0;overflow-wrap:anywhere}
.phone .card{padding:14px}
h2{font-size:24px;font-weight:600;margin:0 0 4px}.phone h2{font-size:20px}
.lead{font-size:14px;color:var(--sub);margin:0 0 18px;max-width:640px}
.card{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:16px 18px;margin-bottom:14px}
.card h3{font-size:16px;margin:0 0 8px}
.banner{border:1px solid var(--warn-line);background:var(--warn-bg);border-left-width:5px;border-radius:6px;padding:14px 16px;margin-bottom:14px;font-size:14px}
.banner b{display:block;margin-bottom:4px}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.phone .grid{grid-template-columns:1fr}
.row{display:flex;gap:10px;font-size:14px;padding:5px 0;border-bottom:1px solid var(--line)}
.row span:first-child{width:130px;color:var(--sub);flex:none}
.pic{width:72px;height:72px;border-radius:50%;background:var(--brand);color:var(--on-brand);display:grid;place-items:center;font-size:26px;font-weight:600;flex:none}
.btn{display:inline-block;background:var(--brand);color:var(--on-brand);border-radius:4px;padding:8px 16px;font-size:14px;font-weight:600;margin:0 8px 6px 0}
.btn.sec{background:transparent;color:var(--text);border:1px solid var(--fieldline)}
.field{margin-bottom:14px}.field label{display:block;font-size:14px;font-weight:600;margin-bottom:4px}
.field .in{height:34px;border:1px solid var(--fieldline);border-bottom-width:2px;border-radius:4px;background:var(--field);padding:6px 10px;font-size:14px;color:var(--sub)}
.hint{font-size:12px;color:var(--sub);margin-top:4px}
.chip{display:inline-block;background:var(--chip-bg);color:var(--chip-text);border-radius:10px;padding:2px 10px;font-size:12px;font-weight:600}
ul.rules{margin:6px 0 0;padding-left:20px;font-size:13px;color:var(--sub)}
.small{font-size:13px;color:var(--sub)}
.menu{position:absolute;right:16px;top:44px;background:var(--card);color:var(--text);border:1px solid var(--line);border-radius:8px;padding:12px 14px;font-size:13px;width:240px;box-shadow:0 6px 20px rgba(0,0,0,.25)}
.menu div{padding:4px 0}
.rel{position:relative}
.burger{display:inline-block;width:18px;height:2px;background:currentColor;box-shadow:0 6px 0 currentColor,0 -6px 0 currentColor}
</style><body><h1 class="cap">Passwort ändern</h1><p class="cap">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.</p><div class="stage"><div class="frame desk light"><div class="app rel"><div class="top"><span>ping-mee Portal</span><span class="av">LM</span></div><div class="body"><div class="nav"><a class=""><i></i>Startseite</a><a class=""><i></i>Profil</a><a class=""><i></i>Profilbild</a><a class="on"><i></i>Passwort</a><a class=""><i></i>Ankündigungen</a><a class=""><i></i>Benachrichtigungen</a></div><div class="main">
<h2>Passwort ändern</h2>
<p class="lead">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.</p>
<div class="card" style="max-width:520px">
<div class="field"><label>Aktuelles Passwort</label><div class="in">••••••••••</div><div class="hint">Wir prüfen es, bevor etwas geändert wird.</div></div>
<div class="field"><label>Neues Passwort</label><div class="in">&nbsp;</div>
<ul class="rules"><li>Mindestens 12 Zeichen</li><li>Groß- und Kleinbuchstaben, eine Zahl</li><li>Nicht eines Ihrer letzten Passwörter</li></ul></div>
<div class="field"><label>Neues Passwort wiederholen</label><div class="in">&nbsp;</div></div>
<span class="btn">Passwort ändern</span><span class="btn sec">Abbrechen</span></div>
<p class="small">Passwort vergessen oder abgelaufen? Wenden Sie sich bitte an den Administrator.</p>
</div></div></div></div><div class="frame phone dark"><div class="app rel"><div class="top"><span class="burger"></span><span>ping-mee Portal</span><span class="av">LM</span></div><div class="body"><div class="main">
<h2>Passwort ändern</h2>
<p class="lead">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.</p>
<div class="card" style="max-width:520px">
<div class="field"><label>Aktuelles Passwort</label><div class="in">••••••••••</div><div class="hint">Wir prüfen es, bevor etwas geändert wird.</div></div>
<div class="field"><label>Neues Passwort</label><div class="in">&nbsp;</div>
<ul class="rules"><li>Mindestens 12 Zeichen</li><li>Groß- und Kleinbuchstaben, eine Zahl</li><li>Nicht eines Ihrer letzten Passwörter</li></ul></div>
<div class="field"><label>Neues Passwort wiederholen</label><div class="in">&nbsp;</div></div>
<span class="btn">Passwort ändern</span><span class="btn sec">Abbrechen</span></div>
<p class="small">Passwort vergessen oder abgelaufen? Wenden Sie sich bitte an den Administrator.</p>
</div></div></div></div></div></body></html>
@@ -0,0 +1,69 @@
<!doctype html><html lang="de"><meta charset="utf-8"><title>Profilbild - Pending state</title><style>
/* Governing spec sections: EXPERIENCE.md Information Architecture, Component Patterns, State Patterns;
DESIGN.md Colors, Components. Mock only - the spines win on conflict. Offline: no JS, no network.
Brand blue is the Fluent 2 default (#0F6CBD) [ASSUMPTION, verify at scaffold]. German formal (Sie) is the default voice. */
:root{--brand:#0F6CBD;--on-brand:#fff}
*{box-sizing:border-box}
body{margin:0;padding:24px;font-family:"Segoe UI",system-ui,sans-serif;background:#e8e8e8;color:#242424}
h1.cap{font-size:14px;font-weight:600;margin:0 0 4px}
p.cap{font-size:12px;margin:0 0 16px;color:#555;max-width:900px}
.stage{display:flex;gap:32px;align-items:flex-start;flex-wrap:wrap}
.frame{border-radius:10px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,.25);border:1px solid #bbb}
.desk{width:980px}.phone{width:360px;border-radius:28px;border:8px solid #222}
.light{--bg:#fafafa;--card:#fff;--text:#242424;--sub:#616161;--line:#e0e0e0;--nav:#f5f5f5;--warn-bg:#fff9f5;--warn-line:#f7630c;--chip-bg:#fff4ce;--chip-text:#5c4400;--field:#fff;--fieldline:#8a8a8a}
.dark{--bg:#1f1f1f;--card:#292929;--text:#ffffff;--sub:#c8c8c8;--line:#3d3d3d;--nav:#242424;--warn-bg:#3a2a1f;--warn-line:#f7630c;--chip-bg:#4a3b00;--chip-text:#ffe9a8;--field:#333;--fieldline:#9a9a9a;--brand:#479ef5;--on-brand:#0a0a0a}
.app{background:var(--bg);color:var(--text);display:flex;flex-direction:column;min-height:560px}
.top{height:48px;background:var(--brand);color:var(--on-brand);display:flex;align-items:center;justify-content:space-between;padding:0 16px;font-weight:600}
.dark .top{background:#0f2a43;color:#fff}
.av{width:32px;height:32px;border-radius:50%;background:#fff;color:var(--brand);display:grid;place-items:center;font-size:12px;font-weight:700}
.dark .av{background:#479ef5;color:#0a0a0a}
.body{display:flex;flex:1}
.nav{width:220px;background:var(--nav);border-right:1px solid var(--line);padding:12px 8px}
.nav a{display:flex;align-items:center;gap:10px;padding:9px 12px;border-radius:4px;color:var(--text);font-size:14px;margin-bottom:2px}
.nav a.on{background:var(--card);font-weight:600;box-shadow:inset 3px 0 0 var(--brand)}
.nav i{width:14px;height:14px;border-radius:3px;background:var(--sub);opacity:.6;display:inline-block}
.main{flex:1;min-width:0;padding:24px 28px}
.phone .main{padding:16px}
.phone .row{flex-direction:column;gap:2px}
.phone .row span:first-child{width:auto}
.row span:last-child{min-width:0;overflow-wrap:anywhere}
.phone .card{padding:14px}
h2{font-size:24px;font-weight:600;margin:0 0 4px}.phone h2{font-size:20px}
.lead{font-size:14px;color:var(--sub);margin:0 0 18px;max-width:640px}
.card{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:16px 18px;margin-bottom:14px}
.card h3{font-size:16px;margin:0 0 8px}
.banner{border:1px solid var(--warn-line);background:var(--warn-bg);border-left-width:5px;border-radius:6px;padding:14px 16px;margin-bottom:14px;font-size:14px}
.banner b{display:block;margin-bottom:4px}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.phone .grid{grid-template-columns:1fr}
.row{display:flex;gap:10px;font-size:14px;padding:5px 0;border-bottom:1px solid var(--line)}
.row span:first-child{width:130px;color:var(--sub);flex:none}
.pic{width:72px;height:72px;border-radius:50%;background:var(--brand);color:var(--on-brand);display:grid;place-items:center;font-size:26px;font-weight:600;flex:none}
.btn{display:inline-block;background:var(--brand);color:var(--on-brand);border-radius:4px;padding:8px 16px;font-size:14px;font-weight:600;margin:0 8px 6px 0}
.btn.sec{background:transparent;color:var(--text);border:1px solid var(--fieldline)}
.field{margin-bottom:14px}.field label{display:block;font-size:14px;font-weight:600;margin-bottom:4px}
.field .in{height:34px;border:1px solid var(--fieldline);border-bottom-width:2px;border-radius:4px;background:var(--field);padding:6px 10px;font-size:14px;color:var(--sub)}
.hint{font-size:12px;color:var(--sub);margin-top:4px}
.chip{display:inline-block;background:var(--chip-bg);color:var(--chip-text);border-radius:10px;padding:2px 10px;font-size:12px;font-weight:600}
ul.rules{margin:6px 0 0;padding-left:20px;font-size:13px;color:var(--sub)}
.small{font-size:13px;color:var(--sub)}
.menu{position:absolute;right:16px;top:44px;background:var(--card);color:var(--text);border:1px solid var(--line);border-radius:8px;padding:12px 14px;font-size:13px;width:240px;box-shadow:0 6px 20px rgba(0,0,0,.25)}
.menu div{padding:4px 0}
.rel{position:relative}
.burger{display:inline-block;width:18px;height:2px;background:currentColor;box-shadow:0 6px 0 currentColor,0 -6px 0 currentColor}
</style><body><h1 class="cap">Profilbild - Pending state</h1><p class="cap">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.</p><div class="stage"><div class="frame desk light"><div class="app rel"><div class="top"><span>ping-mee Portal</span><span class="av">LM</span></div><div class="body"><div class="nav"><a class=""><i></i>Startseite</a><a class=""><i></i>Profil</a><a class="on"><i></i>Profilbild</a><a class=""><i></i>Passwort</a><a class=""><i></i>Ankündigungen</a><a class=""><i></i>Benachrichtigungen</a></div><div class="main">
<h2>Profilbild</h2>
<p class="lead">Ihr Bild erscheint in den ping-mee-Diensten, zum Beispiel in Jellyfin.</p>
<div class="card" style="max-width:560px">
<div style="display:flex;gap:16px;align-items:center;margin-bottom:12px"><div class="pic" style="width:96px;height:96px;font-size:34px">LM</div>
<div><span class="chip">Wird aktualisiert</span><p class="small" style="margin:8px 0 0">Ihr neues Bild wurde gespeichert. Es erscheint innerhalb von etwa 2 Stunden. Bis dahin sehen Sie hier noch das bisherige Bild.</p></div></div>
<span class="btn">Neues Bild auswählen</span><span class="btn sec">Bild entfernen</span>
<div class="hint" style="margin-top:10px">Erlaubt sind JPEG, PNG, HEIC und WebP bis 10 MB. Sie können das Bild vor dem Speichern zuschneiden.</div></div>
</div></div></div></div><div class="frame phone dark"><div class="app rel"><div class="top"><span class="burger"></span><span>ping-mee Portal</span><span class="av">LM</span></div><div class="body"><div class="main">
<h2>Profilbild</h2>
<p class="lead">Ihr Bild erscheint in den ping-mee-Diensten, zum Beispiel in Jellyfin.</p>
<div class="card" style="max-width:560px">
<div style="display:flex;gap:16px;align-items:center;margin-bottom:12px"><div class="pic" style="width:96px;height:96px;font-size:34px">LM</div>
<div><span class="chip">Wird aktualisiert</span><p class="small" style="margin:8px 0 0">Ihr neues Bild wurde gespeichert. Es erscheint innerhalb von etwa 2 Stunden. Bis dahin sehen Sie hier noch das bisherige Bild.</p></div></div>
<span class="btn">Neues Bild auswählen</span><span class="btn sec">Bild entfernen</span>
<div class="hint" style="margin-top:10px">Erlaubt sind JPEG, PNG, HEIC und WebP bis 10 MB. Sie können das Bild vor dem Speichern zuschneiden.</div></div>
</div></div></div></div></div></body></html>
+3
View File
@@ -0,0 +1,3 @@
[core]
project_name = "ping-mee-self-service-platform"
output_folder = "{project-root}/_bmad-output"
View File
+1
View File
@@ -0,0 +1 @@
*.user.toml
+2
View File
@@ -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."
File diff suppressed because it is too large Load Diff
+116
View File
@@ -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 {},
)
)
+462
View File
@@ -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-<n>.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. `<n>` 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-<n>.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-<n>.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())
+290
View File
@@ -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())
+319
View File
@@ -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-<datetime>/`, which the
method migration reuses, then removes them.
Usage:
uv run migrate_v6.py --project-root P --skill <the bmad 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-<datetime>/`, 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
+689
View File
@@ -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 `/<core.active_initiative>` 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())
+84
View File
@@ -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())
+172
View File
@@ -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())
+225
View File
@@ -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.<code>]` 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 <any installed 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.<code>] 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())
File diff suppressed because it is too large Load Diff
+142
View File
@@ -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")
+143
View File
@@ -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 <path>]
uv run --python 3.11 skills/bmad/scripts/stamp_release.py --check [--project-root <path>]
"""
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())
+90
View File
@@ -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()
@@ -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-<n>.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()
+365
View File
@@ -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))
+976
View File
@@ -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<!-- effective change -->\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()
+109
View File
@@ -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()
@@ -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()
+226
View File
@@ -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()
+237
View File
@@ -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()
+357
View File
@@ -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()
@@ -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()
+473
View File
@@ -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 <path>]
"""
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<head>[ \t]*version[ \t]*=[ \t]*)"[^"\n]*"(?P<tail>[ \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())
View File
@@ -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
@@ -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
@@ -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\<SID>\ 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