From 2be04aceda546427d61111159039791e0f8d3ef6 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Fri, 30 Oct 2026 03:06:26 -0400 Subject: [PATCH] Add opt-in T3 showcase support and Pick Your AI identity Scope co-owner sign-off to one CLI invocation and keep the default refusal. Add Pick Your AI palette and mark, and use the selected family's badge, ownership, and license rendering. Co-Authored-By: Claude Sonnet 5.5 --- assets/orgs/Pick-Your-AI/mark.png | Bin 0 -> 7895 bytes references/brand.lock.json | 59 ++++++++++++++++++++- references/brand.schema.json | 6 +++ references/brand.yaml | 35 ++++++++++++- scripts/ctshowcase/config.py | 17 ++++++- scripts/ctshowcase/context.py | 6 +-- scripts/ctshowcase/doctor.py | 3 +- scripts/ctshowcase/org.py | 25 +++++---- scripts/ctshowcase/project.py | 7 +-- scripts/ctshowcase/readme.py | 14 +++-- scripts/ctshowcase/readmelint.py | 3 +- scripts/ctshowcase/resolver.py | 3 +- scripts/ctshowcase/topics.py | 3 +- scripts/showcase.py | 16 +++++- tests/test_allow_t3_global.py | 82 ++++++++++++++++++++++++++++++ tests/test_config.py | 2 +- 16 files changed, 252 insertions(+), 29 deletions(-) create mode 100644 assets/orgs/Pick-Your-AI/mark.png create mode 100644 tests/test_allow_t3_global.py diff --git a/assets/orgs/Pick-Your-AI/mark.png b/assets/orgs/Pick-Your-AI/mark.png new file mode 100644 index 0000000000000000000000000000000000000000..b2408d2c0a8b0af6d5e9052cf88716ecaec5d2f2 GIT binary patch literal 7895 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 U>paE{%uwiP=s);&&msDM0NRDen*aa+ literal 0 HcmV?d00001 diff --git a/references/brand.lock.json b/references/brand.lock.json index 5ec2101..2e016c5 100644 --- a/references/brand.lock.json +++ b/references/brand.lock.json @@ -309,6 +309,61 @@ "title": "#F1F5F9" } }, + "pickyourai": { + "badge_accent": "115E59", + "badge_brand": "0F766E", + "canvas": { + "color": "#042F2E", + "grid": "#14B8A6", + "grid_opacity": 0.07, + "kind": "flat-grid" + }, + "company_label": "Pick Your AI", + "eyebrow_case": "upper", + "measured": false, + "tokens": { + "accent": "#14B8A6", + "accent_deep": "#115E59", + "accent_mid": "#14B8A6", + "bar": { + "from": "#115E59", + "height": 6, + "to": "#14B8A6" + }, + "body": "#CCFBF1", + "card_edge": "#CCFBF1", + "card_fill": "#F0FDFA", + "card_ink": "#042F2E", + "card_shadow": { + "blur": 12, + "color": "#000000", + "dx": 6, + "dy": 8, + "opacity": 0.35 + }, + "chip_attr": { + "bg": "#115E59", + "fg": "#F0FDFA" + }, + "chip_key": { + "bg": "#FBBF24", + "fg": "#042F2E" + }, + "edge": { + "color": "#14B8A6", + "opacity": 0.28 + }, + "eyebrow": "#5EEAD4", + "gold_trace": "#FBBF24", + "icon_ink": "#0F766E", + "icon_spark": "#B45309", + "ink": "#042F2E", + "muted": "#99F6E4", + "plate_low": "#042F2E", + "subtitle": "#CCFBF1", + "title": "#F0FDFA" + } + }, "romgenie": { "canvas": { "color": "#002C31", @@ -783,10 +838,12 @@ "description": null, "display_name": "Pick Your AI", "eyebrow": null, - "family": "completetech", + "family": "pickyourai", "family_topics": [], "footer": false, "former_slugs": [], + "hub_url": "https://pickyour.ai", + "mark": "assets/orgs/Pick-Your-AI/mark.png", "nav_name": "Pick Your AI", "tier": "T3" }, diff --git a/references/brand.schema.json b/references/brand.schema.json index 4b86cbc..9e40fc9 100644 --- a/references/brand.schema.json +++ b/references/brand.schema.json @@ -197,6 +197,12 @@ "company_label": { "type": "string" }, + "badge_brand": { + "type": "string" + }, + "badge_accent": { + "type": "string" + }, "eyebrow_case": { "enum": [ "upper", diff --git a/references/brand.yaml b/references/brand.yaml index a8954ec..fc0fc73 100644 --- a/references/brand.yaml +++ b/references/brand.yaml @@ -237,6 +237,37 @@ families: signal_core: "#FFE0E5" icon_ink: "#3A1A33" icon_spark: "#6B214F" # deep magenta: readable on pink + pickyourai: + # Pick Your AI's own palette, from Pick-Your-AI/.github assets/PROMPTS.md and build_assets.py (teal surfaces, one amber + # accent, deep teal ground). Not CompleteTech branding. Unmeasured: the layout has no Pick Your AI golden yet. + measured: false + company_label: Pick Your AI + badge_brand: "0F766E" # README badges (shields.io hex, no #): teal, not CompleteTech blue + badge_accent: "115E59" + eyebrow_case: upper + canvas: {kind: flat-grid, color: "#042F2E", grid: "#14B8A6", grid_opacity: 0.07} + tokens: + eyebrow: "#5EEAD4" + title: "#F0FDFA" + subtitle: "#CCFBF1" + body: "#CCFBF1" + muted: "#99F6E4" + ink: "#042F2E" + plate_low: "#042F2E" + card_fill: "#F0FDFA" # light icon card, as CompleteTech: the icon prompt and QA assume dark ink on a light plate + card_ink: "#042F2E" + card_edge: "#CCFBF1" + card_shadow: {dx: 6, dy: 8, blur: 12, color: "#000000", opacity: 0.35} + chip_key: {bg: "#FBBF24", fg: "#042F2E"} + chip_attr: {bg: "#115E59", fg: "#F0FDFA"} + bar: {from: "#115E59", to: "#14B8A6", height: 6} + edge: {color: "#14B8A6", opacity: 0.28} + gold_trace: "#FBBF24" + accent: "#14B8A6" + accent_mid: "#14B8A6" + accent_deep: "#115E59" + icon_ink: "#0F766E" + icon_spark: "#B45309" # deep amber: readable on the light icon card (the chip and accent stay #FBBF24) libreevolve: measured: false company_label: LibreEvolve @@ -618,7 +649,7 @@ orgs: family_topics: [] footer: true Pick-Your-AI: - family: completetech + family: pickyourai former_slugs: [] tier: T3 display_name: Pick Your AI @@ -627,6 +658,8 @@ orgs: eyebrow: null brand_topic: pick-your-ai family_topics: [] + mark: assets/orgs/Pick-Your-AI/mark.png + hub_url: https://pickyour.ai footer: false Jevelopers-neatHack-2026: family: completetech diff --git a/scripts/ctshowcase/config.py b/scripts/ctshowcase/config.py index 7f1307c..05ad8b2 100644 --- a/scripts/ctshowcase/config.py +++ b/scripts/ctshowcase/config.py @@ -40,6 +40,21 @@ def canonical(obj) -> str: return json.dumps(obj, sort_keys=True, ensure_ascii=False, separators=(",", ":")) +_T3_SIGNED_OFF = False + + +def set_t3_signed_off(value: bool) -> None: + """Record the run's `--allow-t3`: set once by the CLI, read by every owner gate (check_owner, art_status, + footer_status). Off by default, so a T3 owner is refused unless the caller passes the flag after an approved + t3-metadata or t3-signoff row.""" + global _T3_SIGNED_OFF + _T3_SIGNED_OFF = bool(value) + + +def t3_signed_off() -> bool: + return _T3_SIGNED_OFF + + class Brand: """Merged view over brand.yaml.""" @@ -96,7 +111,7 @@ def check_owner(self, owner: str, *, allow_t3: bool = False) -> str | None: raise Refused(f"{owner} is an excluded owner; every command refuses", log="exempt") if status == "hold": raise Refused(f"{owner} is on HOLD", log="hold") - if status == "t3" and not allow_t3: + if status == "t3" and not (allow_t3 or t3_signed_off()): raise Refused(f"{owner} is T3; needs co-owner sign-off", log="t3-signoff") return status diff --git a/scripts/ctshowcase/context.py b/scripts/ctshowcase/context.py index 1f0e9c4..bd7083f 100644 --- a/scripts/ctshowcase/context.py +++ b/scripts/ctshowcase/context.py @@ -3,7 +3,7 @@ from pathlib import Path -from .config import Brand, load_repo_override, override_from_manifests +from .config import Brand, load_repo_override, override_from_manifests, t3_signed_off from .detect import detect from .errors import ConfigError, NeedsInput from .facts import RepoFacts @@ -150,7 +150,7 @@ def art_status(self) -> dict: no_card = "exempt:no-card" if self.card_exempt() else None # §5.4 holds whatever the owner code says if watching: # a watching-org repository that is not a fork and not its `.github`: decided explicitly return {"banner": watching, "card": no_card or watching, "icon": watching} - if status in ("t3", "hold", "excluded"): + if status in ("t3", "hold", "excluded") and not (status == "t3" and t3_signed_off()): code = {"t3": "t3-signoff", "hold": "hold", "excluded": "exempt"}[status] return {"banner": code, "card": no_card or code, "icon": code} if no_card: @@ -182,7 +182,7 @@ def footer_status(self) -> str | None: """None when the decision 18 footer applies, else why not: `exempt:watching-fork`, `hold:watching-non-fork`, `t3-signoff`, `hold` (org row `footer: hold`) or `exempt:footer` (org row `footer: false`). The watching org's `footer: false` exempts only its forks (org.md), so its `.github` needs the footer.""" - if self.brand.owner_status(self.owner) == "t3": + if self.brand.owner_status(self.owner) == "t3" and not t3_signed_off(): return "t3-signoff" watching = self.watching_status() if watching or self.brand.owner_status(self.owner) == "watching": diff --git a/scripts/ctshowcase/doctor.py b/scripts/ctshowcase/doctor.py index f2198c8..1d01fc7 100644 --- a/scripts/ctshowcase/doctor.py +++ b/scripts/ctshowcase/doctor.py @@ -8,6 +8,7 @@ from . import describe, footer as footermod, labels as labelsmod, pnglint, readmelint, svglint, topics as topicsmod from . import readme as readmemod +from .config import t3_signed_off from .context import RepoContext from .detect import standard @@ -88,7 +89,7 @@ def row(ctx: RepoContext) -> dict: logs += rl.get("logs", []) else: cells["R7"] = "unknown" if not rd else "fail" - if ctx.brand.owner_status(ctx.owner) == "t3": + if ctx.brand.owner_status(ctx.owner) == "t3" and not t3_signed_off(): cells["R7"] = "hold" # T3: no README change before co-owner sign-off (lint readme refuses) lic = ctx.meta.get("license") cells["R8"] = "pass" if lic or not ctx.public else "warn" diff --git a/scripts/ctshowcase/org.py b/scripts/ctshowcase/org.py index d528af9..804fa37 100644 --- a/scripts/ctshowcase/org.py +++ b/scripts/ctshowcase/org.py @@ -308,8 +308,11 @@ def _star_entry(estate: Estate, org: str, reg: dict) -> dict | None: return sl if sl.get("name") else None -def _star_held(estate: Estate, org: str) -> str | None: +def _star_held(estate: Estate, org: str, allow_t3: bool = False) -> str | None: + from .config import t3_signed_off st = estate.resolver.owner_status(org) + if st == "t3" and (allow_t3 or t3_signed_off()): + st = None # co-owner sign-off lifts the T3 hold (decision 12); HOLD and excluded stay held if st in ("t3", "hold", "excluded"): return st if estate.resolver.family_hold(org): @@ -1523,7 +1526,7 @@ def star_list_plan(estate: Estate, org: str, *, lists_capture: dict | None = Non login = r.canonical(org) reg = r.registry_org(org) entry = _star_entry(estate, org, reg) - held = _star_held(estate, org) + held = _star_held(estate, org, allow_t3) base = {"org": login, "reads": [{"name": "star-lists", "argv": read_command("star_lists")}], "jobs": [], "pending": [], "logs": []} if not entry: @@ -2226,7 +2229,7 @@ def register(sub) -> None: s.add_argument("--render", action="store_true", help="profile: also render the cards, badges and org banner into --out-dir") s.add_argument("--banner", help="profile: hero path relative to profile/ (default assets/banner.jpg, or assets/intro.svg)") s.add_argument("--email-approved", action="store_true", default=None, help="the org email (q26) is approved") - s.add_argument("--allow-t3", action="store_true", help="T3 org after co-owner sign-off") + s.add_argument("--allow-t3", action="store_true", default=argparse.SUPPRESS, help="T3 org after co-owner sign-off") s.add_argument("--allow-unmeasured", action="store_true") s.add_argument("--lists", help="pins/profile: star_lists.graphql capture (SOC-01)") s.add_argument("--repo-ids", help="pins: JSON {owner/repo: node id} from repo_id.graphql reads") @@ -2290,30 +2293,30 @@ def cmd_org(args): estate = estate_from_args(args) act = args.action if act == "profile" and args.user: - res, code = user_profile(estate, args.user, out_dir=args.out_dir, render=args.render, allow_t3=args.allow_t3, + res, code = user_profile(estate, args.user, out_dir=args.out_dir, render=args.render, allow_t3=getattr(args, "allow_t3", False), allow_unmeasured=args.allow_unmeasured, meta_dir=args.meta_dir) elif act == "profile": res, code = profile(estate, org, out_dir=args.out_dir, render=args.render, banner=args.banner, - email_approved=args.email_approved, allow_t3=args.allow_t3, + email_approved=args.email_approved, allow_t3=getattr(args, "allow_t3", False), allow_unmeasured=args.allow_unmeasured, lists_capture=_json_file(args.lists), meta_dir=args.meta_dir) elif act == "about": - res, code = about(estate, org, email_approved=args.email_approved, allow_t3=args.allow_t3) + res, code = about(estate, org, email_approved=args.email_approved, allow_t3=getattr(args, "allow_t3", False)) elif act == "pins": - res, code = pins(estate, org, allow_t3=args.allow_t3, lists_capture=_json_file(args.lists), + res, code = pins(estate, org, allow_t3=getattr(args, "allow_t3", False), lists_capture=_json_file(args.lists), repo_ids=_json_file(args.repo_ids)) res["jobs"] = res["jobs"] + res["star_list"].get("jobs", []) elif act == "labels": if args.repos_from_gh: - capture_label_repos(estate, org, allow_t3=args.allow_t3) - res, code = labels_plan(estate, org, allow_t3=args.allow_t3, checkouts=args.checkouts, dot_github=args.dot_github, + capture_label_repos(estate, org, allow_t3=getattr(args, "allow_t3", False)) + res, code = labels_plan(estate, org, allow_t3=getattr(args, "allow_t3", False), checkouts=args.checkouts, dot_github=args.dot_github, registry_free=args.repos_from_gh) elif act == "health": - res, code = health(estate, org, repo_dir=args.dot_github, allow_t3=args.allow_t3) + res, code = health(estate, org, repo_dir=args.dot_github, allow_t3=getattr(args, "allow_t3", False)) else: snap = _json_file(args.snapshot) or {} row = doctor(estate, org, snap, repo_dir=args.dot_github, avatar=args.avatar, email_approved=args.email_approved, - allow_t3=args.allow_t3) + allow_t3=getattr(args, "allow_t3", False)) if args.org_doctor_format != "json": print(as_tsv(row, args.header) if args.org_doctor_format == "tsv" else as_md(row, args.header)) return None, OK diff --git a/scripts/ctshowcase/project.py b/scripts/ctshowcase/project.py index 760ab7e..fd7ad24 100644 --- a/scripts/ctshowcase/project.py +++ b/scripts/ctshowcase/project.py @@ -9,6 +9,7 @@ """ from __future__ import annotations +import argparse import hashlib import json import re @@ -1153,7 +1154,7 @@ def register(sub) -> None: s.add_argument("--approved", help="decisions/approved.tsv") s.add_argument("--approved-yaml", help="decisions/APPROVED_ACTIONS.yaml (approved org renames and merges, CQ-03)") s.add_argument("--actions", help="decisions/estate-actions.json (the old and new names of approved org renames)") - s.add_argument("--allow-t3", action="store_true") + s.add_argument("--allow-t3", action="store_true", default=argparse.SUPPRESS) s.add_argument("--no-status-update", action="store_true") s.add_argument("--files-dir", help="copy-plan: write the REST view bodies under DIR (paths as in `files`)") s.add_argument("--out", help="normalize: write the template spec JSON here; readme: write the README here") @@ -1239,7 +1240,7 @@ def cmd_project(args): copied = _load(args.copied) if act == "estate-plan": res = estate_plan(r, source=args.source, copied=copied, user_id=args.user_id, approval_id=args.approval_id, - allow_t3=args.allow_t3, reviewed=args.reviewed) + allow_t3=getattr(args, "allow_t3", False), reviewed=args.reviewed) else: if not args.target: raise ShowcaseError("copy-plan needs --target ORG (or @me)", log="usage") @@ -1247,7 +1248,7 @@ def cmd_project(args): res = copy_plan(args.target, role=args.role, source=args.source, title=args.title, display_name=args.display_name, copied=copied, workstreams=ws, repositories=[x for x in (args.repos or "").split(",") if x] or None, approval_id=args.approval_id, resolver=r, user_id=args.user_id, out_of_scope=args.out_of_scope, - reviewed=args.reviewed, status_update=not args.no_status_update, allow_t3=args.allow_t3) + reviewed=args.reviewed, status_update=not args.no_status_update, allow_t3=getattr(args, "allow_t3", False)) if args.jobs and res.get("files") and not args.files_dir: # each REST view job reads its body with --input; the writer's guard refuses a body it cannot read raise ShowcaseError("copy-plan writes REST view bodies: pass --files-dir DIR with --jobs so each view job's --input " diff --git a/scripts/ctshowcase/readme.py b/scripts/ctshowcase/readme.py index 509d7bc..9e1353f 100644 --- a/scripts/ctshowcase/readme.py +++ b/scripts/ctshowcase/readme.py @@ -156,7 +156,10 @@ def banner_html(ctx: RepoContext) -> str: def badges(ctx: RepoContext) -> list[tuple[str, str, str | None]]: """[(alt, image url, link)] per §7.2: language, type, license, CI. 2-4.""" - b = ctx.brand.raw["badges"] + b = dict(ctx.brand.raw["badges"]) + for k in ("brand", "accent"): # a family outside CompleteTech carries its own badge colours (brand.yaml badge_brand, badge_accent) + if ctx.style.get(f"badge_{k}"): + b[k] = ctx.style[f"badge_{k}"] jev = ctx.style["name"] == "jevplay-games" extra = "?labelColor=1E1E1E" if jev else "" out = [] @@ -296,11 +299,14 @@ def license_section(ctx: RepoContext) -> tuple[str, str] | None: if not year: ctx.log("needs-input:license-year") return None - return "License", rv["private"].replace("", year) + private = rv["private"].replace("", year) + if ctx.org.get("family") not in (None, "completetech"): + private = private.replace("CompleteTech LLC", ctx.org.get("display_name") or ctx.owner) + return "License", private if lic == "MIT": if f.exists("BRAND_ASSETS.md"): return "License", rv["skills_mit"] - if ctx.tier.startswith("T2"): + if ctx.tier.startswith("T2") or ctx.org.get("family") not in (None, "completetech"): return "License", rv["t2_mit"].replace("", ctx.org.get("display_name") or ctx.owner) return "License", rv["t1_mit"] if lic and lic not in ("NOASSERTION",): @@ -343,6 +349,8 @@ def section_facts(ctx: RepoContext, h2: str) -> str | None: if hp and ctx.meta.get("homepage_status") == 200: return f"**[{re.sub(r'^https?://', '', hp).rstrip('/')}]({hp})**" if h2 == "Ownership": + if ctx.org.get("family") not in (None, "completetech"): + return f"Organization: {ctx.owner}" return f"Owner: @CompleteDotTech · Organization: {ctx.owner}" if h2 == "Files" and ctx.det["variant"] == "J": rows = [("profile/README.md", f"Organization profile shown at github.com/{ctx.owner}")] diff --git a/scripts/ctshowcase/readmelint.py b/scripts/ctshowcase/readmelint.py index 5eeeb8b..09dfd66 100644 --- a/scripts/ctshowcase/readmelint.py +++ b/scripts/ctshowcase/readmelint.py @@ -106,7 +106,8 @@ def lint(ctx: RepoContext, path: Path, variant: str | None = None) -> dict: warns.append(f"badges-short: {nbadges} badge (no licence or language to show yet)") for s in shields: col = re.split(r"[?)]", s.rsplit("-", 1)[-1])[0].upper() - if re.fullmatch(r"[0-9A-F]{6}", col) and col not in PALETTE: + family_cols = {str(ctx.style.get(k) or "").upper() for k in ("badge_brand", "badge_accent")} + if re.fullmatch(r"[0-9A-F]{6}", col) and col not in PALETTE and col not in family_cols: fails.append(f"R7 off-palette badge colour {col}") # tagline == description desc = (ctx.meta.get("description") or "").strip() diff --git a/scripts/ctshowcase/resolver.py b/scripts/ctshowcase/resolver.py index 458d7ee..44a540d 100644 --- a/scripts/ctshowcase/resolver.py +++ b/scripts/ctshowcase/resolver.py @@ -286,13 +286,14 @@ def owner_status(self, login: str) -> str | None: def check_owner(self, login: str, *, allow_t3: bool = False, allow_hold: bool = False) -> str | None: """Refuse excluded, hold and (unless signed off) T3 orgs under any of their logins; return the status.""" + from .config import t3_signed_off from .errors import Refused st = self.owner_status(login) if st == "excluded": raise Refused(f"{login} is an excluded owner; every command refuses", log="exempt") if st == "hold" and not allow_hold: raise Refused(f"{login} is on HOLD", log="hold") - if st == "t3" and not allow_t3: + if st == "t3" and not (allow_t3 or t3_signed_off()): raise Refused(f"{login} is T3; needs co-owner sign-off", log="t3-signoff") return st diff --git a/scripts/ctshowcase/topics.py b/scripts/ctshowcase/topics.py index 6f358bd..c561331 100644 --- a/scripts/ctshowcase/topics.py +++ b/scripts/ctshowcase/topics.py @@ -3,6 +3,7 @@ import re +from .config import t3_signed_off from .context import RepoContext from .detect import standard @@ -64,7 +65,7 @@ def membership_topics(ctx: RepoContext, also_in: list[str]) -> tuple[list[str], if status in ("excluded", "hold"): logs.append(f"exempt:also-in:{org}") continue - if status == "t3": + if status == "t3" and not t3_signed_off(): logs.append(f"t3-signoff:also-in:{org}") continue known, topic = org_topic(ctx.brand, org) diff --git a/scripts/showcase.py b/scripts/showcase.py index 3e17f58..244bfd0 100644 --- a/scripts/showcase.py +++ b/scripts/showcase.py @@ -948,6 +948,8 @@ def parser() -> argparse.ArgumentParser: p.add_argument("--type", dest="type_override", metavar="TYPE", help="§5.4 type to use instead of detection (when the file tree is not available); logged") p.add_argument("--format", choices=("json", "text"), default=None, help="output style") p.add_argument("--quiet", action="store_true") + p.add_argument("--allow-t3", action="store_true", + help="treat a T3 (co-owned) owner as signed off for this run: every repository command and generator, not just `meta`; pass only when a t3-metadata or t3-signoff row is approved (default: T3 owners are refused, exit 2)") p.add_argument("--version", action="version", version=f"%(prog)s {__version__} ({STANDARD})") sub = p.add_subparsers(dest="command", required=True, metavar="") @@ -1066,7 +1068,7 @@ def parser() -> argparse.ArgumentParser: s = sub.add_parser("meta", help="normalized metadata (read-only gh api GETs with --from-gh)") s.add_argument("--from-gh", metavar="OWNER/REPO") s.add_argument("--out") - s.add_argument("--allow-t3", action="store_true", + s.add_argument("--allow-t3", action="store_true", default=argparse.SUPPRESS, help="read a T3 (co-owned) repository; pass only when a t3-metadata or t3-signoff row is approved") s.set_defaults(func=cmd_meta) @@ -1189,6 +1191,16 @@ def parser() -> argparse.ArgumentParser: def main(argv=None) -> int: + """Run one invocation. `--allow-t3` holds only for this call: the previous value is restored on every exit.""" + from ctshowcase.config import set_t3_signed_off, t3_signed_off + previous = t3_signed_off() + try: + return _main(argv) + finally: + set_t3_signed_off(previous) + + +def _main(argv=None) -> int: # Redirected Windows streams can start as cp1252 even when their consumers # expect UTF-8. Configure before argparse, which also writes help and errors. # StringIO and embedding hosts may replace either stream without reconfigure. @@ -1208,6 +1220,8 @@ def main(argv=None) -> int: args.files.extend(remaining) elif remaining: cli.error("unrecognized arguments: " + " ".join(remaining)) + from ctshowcase.config import set_t3_signed_off + set_t3_signed_off(getattr(args, "allow_t3", False)) if args.format is None: args.format = "text" if args.command in GENERATORS else "json" try: diff --git a/tests/test_allow_t3_global.py b/tests/test_allow_t3_global.py new file mode 100644 index 0000000..c28d2a4 --- /dev/null +++ b/tests/test_allow_t3_global.py @@ -0,0 +1,82 @@ +"""A global `--allow-t3` lifts the T3 refusal for every repository command; the default still refuses (exit 2).""" +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SHOWCASE = [sys.executable, str(ROOT / "scripts" / "showcase.py")] +META = json.loads((ROOT / "examples" / "meta" / "northwind-support-triage-agent.json").read_text(encoding="utf-8")) + + +def _run(tmp_path, *argv, owner="Pick-Your-AI"): + meta = tmp_path / "meta.json" + meta.write_text(json.dumps(dict(META, owner=owner, repo="northwind-synthetic")), encoding="utf-8") + return subprocess.run([*SHOWCASE, "--meta", str(meta), "--no-repo-dir", *argv], cwd=ROOT, + capture_output=True, text=True, encoding="utf-8") + + +def test_t3_owner_is_refused_without_the_flag(tmp_path): + for cmd in (["describe"], ["topics"], ["readme"], + ["banner", "--out", str(tmp_path / "b.png")]): + r = _run(tmp_path, *cmd) + assert r.returncode == 2, (cmd, r.stdout, r.stderr) + assert "co-owner sign-off" in r.stdout + r.stderr or "t3-signoff" in r.stdout + r.stderr + + +def test_global_flag_lets_repository_commands_run(tmp_path): + for cmd in (["describe"], ["topics"]): + r = _run(tmp_path, "--allow-t3", *cmd) + assert r.returncode != 2, (cmd, r.stdout, r.stderr) + assert "t3-signoff" not in r.stdout + r.stderr + + +def test_global_flag_renders_art_for_a_t3_owner(tmp_path): + out = tmp_path / "banner.png" + r = _run(tmp_path, "--allow-t3", "banner", "--out", str(out), "--allow-unmeasured", "--allow-few-chips") + assert r.returncode == 0, (r.stdout, r.stderr) + assert out.is_file() and out.stat().st_size > 10_000 + assert "t3-signoff" not in r.stdout + r.stderr + + +def test_flag_does_not_lift_excluded_owners(tmp_path): + r = _run(tmp_path, "--allow-t3", "describe", owner="BunsDev") + assert r.returncode == 2, (r.stdout, r.stderr) + + +def test_doctor_reports_t3_cells_as_held_only_without_the_flag(tmp_path): + held = _run(tmp_path, "doctor", "--format", "json") + assert json.loads(held.stdout)["cells"]["R7"] == "hold", held.stdout + signed = _run(tmp_path, "--allow-t3", "doctor", "--format", "json") + assert json.loads(signed.stdout)["cells"]["R7"] != "hold", signed.stdout + + +def test_signoff_does_not_outlive_the_invocation(tmp_path, capsys): + """`--allow-t3` holds for one main() call only: a later library call in the same process is refused again.""" + import pytest + sys.path.insert(0, str(ROOT / "scripts")) + import showcase + from ctshowcase.config import load_brand, t3_signed_off + from ctshowcase.errors import Refused + assert showcase.main(["--allow-t3", "config"]) in (0, 1) + capsys.readouterr() + assert t3_signed_off() is False + with pytest.raises(Refused): + load_brand().check_owner("Pick-Your-AI") + + +def test_signed_off_t3_org_joins_also_in_and_star_lists(brand): + from ctshowcase.config import set_t3_signed_off + from ctshowcase.org import _star_held + from ctshowcase.resolver import Resolver + est = type("E", (), {"resolver": Resolver(brand, {"orgs": {}}, None)})() + assert _star_held(est, "Pick-Your-AI") == "t3" + assert _star_held(est, "Pick-Your-AI", allow_t3=True) is None + set_t3_signed_off(True) + try: + assert _star_held(est, "Pick-Your-AI") is None + assert _star_held(est, "BunsDev") == "excluded" # sign-off never lifts an excluded owner + finally: + set_t3_signed_off(False) diff --git a/tests/test_config.py b/tests/test_config.py index 1a3db2f..95699fd 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -49,7 +49,7 @@ def test_owner_lists(brand): @pytest.mark.parametrize("family", ["completetech", "ai-ascension", "jev-engineering", "jevplay-games", "libreevolve", - "touchedbyanagent", "romgenie"]) + "touchedbyanagent", "romgenie", "pickyourai"]) def test_contrast(brand, family): f = brand.family(family) t, c = f["tokens"], f["canvas"]