diff --git a/.github/workflows/ci-gate.yml b/.github/workflows/ci-gate.yml
index 8b771c1b..391c21cc 100644
--- a/.github/workflows/ci-gate.yml
+++ b/.github/workflows/ci-gate.yml
@@ -45,14 +45,16 @@ jobs:
set -euo pipefail
# The authoritative gate workflows. A PR that doesn't touch a given
- # workflow's paths simply won't have a run for it (→ N/A). Lint is
- # intentionally excluded (warning-only); CI Gate excludes itself.
+ # workflow's paths simply won't have a run for it (→ N/A). CI Gate
+ # excludes itself. "Lint (warning-only)" — SwiftLint/ktlint — is
+ # intentionally excluded and is NOT the same thing as "Python Lint",
+ # which is blocking and listed below.
# "Repo Hygiene" is deliberately first: unlike the others it has NO
# path filter, so it always runs and can never be N/A. That matters,
# because the guard it carries (internal documents in this PUBLIC
# repo) is most needed on exactly the docs-only PRs that trigger none
# of the path-filtered workflows below.
- GATES=("Repo Hygiene" "Swift CI" "Android CI" "Helper CI" "Supabase CI")
+ GATES=("Repo Hygiene" "Swift CI" "Android CI" "Helper CI" "Supabase CI" "Python Lint")
# Give GitHub time to create the path-triggered runs for this SHA so
# we don't race and call a still-uncreated run "N/A".
diff --git a/.github/workflows/helper-ci.yml b/.github/workflows/helper-ci.yml
index abf749f9..28544156 100644
--- a/.github/workflows/helper-ci.yml
+++ b/.github/workflows/helper-ci.yml
@@ -61,21 +61,21 @@ jobs:
python-version: '3.12'
- name: Install dev tooling
- # ruff is PINNED. Unpinned, `--upgrade` installs whatever shipped since
- # the last run, and a release that enables new rules turns a PR red for
- # code it never touched. That is not hypothetical: main was green on
- # 2026-07-23, and the next PR to trigger this workflow (2026-07-30, and
- # only because helper-ci watches Protocol.swift) drew 380 errors across
- # files nobody had edited — UP045 and friends. Ten minutes went into
- # proving the feature branch was innocent.
- #
- # Raising this pin is a deliberate task: bump it, run `ruff check .`,
- # and fix or `# noqa` what the new rules find, in a PR that does only
- # that. Never bump it as a side effect of unrelated work.
- run: python -m pip install --upgrade pip && python -m pip install pytest 'ruff==0.15.11' cryptography
+ run: python -m pip install --upgrade pip && python -m pip install pytest cryptography
- - name: Ruff lint
- run: ruff check .
+ # Ruff used to run here, and that was the bug. This job has
+ # `working-directory: helper`, so `ruff check .` only ever linted
+ # `helper/` — and this workflow's `paths:` filter meant a change to
+ # `scripts/*.py` did not start it at all. Python outside `helper/` was
+ # unlinted for the life of the repo (91 errors when first measured,
+ # 2026-08-17).
+ #
+ # Linting now lives in `.github/workflows/python-lint.yml`, which runs
+ # `ruff check .` from the REPO ROOT on every `**/*.py` change — a strict
+ # superset of what this step covered, so removing it leaves no gap in
+ # helper/ coverage. Do not re-add a ruff step here: two pinned ruff
+ # versions in two workflows is the copied-constant drift this repo has
+ # been bitten by before. The pin and its rationale live in python-lint.yml.
- name: Pytest
run: pytest -q
diff --git a/.github/workflows/python-lint.yml b/.github/workflows/python-lint.yml
new file mode 100644
index 00000000..0c51c9b4
--- /dev/null
+++ b/.github/workflows/python-lint.yml
@@ -0,0 +1,86 @@
+name: Python Lint
+
+# Repo-wide ruff. This is a BLOCKING gate — unlike "Lint (warning-only)", which
+# is SwiftLint/ktlint and deliberately advisory. `ci-gate.yml` lists this
+# workflow in GATES; a failure here fails the required check.
+#
+# Why this workflow exists at all, given helper-ci.yml already ran ruff:
+# it did not lint what it appeared to lint. Two independent defects, either one
+# of which was sufficient to hide every non-helper Python file in the repo:
+#
+# 1. helper-ci's `paths:` filter is helper/**, three named scripts, and one
+# Swift file. A change to `scripts/foo.py` matched none of them, so the
+# workflow never started.
+# 2. Even when it DID start, its ruff step ran under
+# `working-directory: helper`, so `ruff check .` resolved to `helper/`.
+# Widening the path filter alone would have fixed nothing.
+#
+# Cost of the gap, measured 2026-08-17: 91 ruff errors across `scripts/`,
+# `CLI Pulse Bar/scripts/` and `archive/` — none of which any CI run had ever
+# reported. The proximate trigger was `scripts/acquisition_reconcile.py`, added
+# in PR #433 with two F541 errors that went straight to main.
+#
+# The path filter below is therefore `**/*.py` and nothing narrower. If you add
+# a Python file anywhere in this repo, this workflow lints it. Note `**/*.py`
+# also covers paths containing spaces (`CLI Pulse Bar/scripts/*.py`) — verified
+# on a scratch branch, not assumed, because that directory holds ~24 of the
+# repo's Python files and a glob that silently skipped it would recreate this
+# exact bug.
+
+on:
+ push:
+ paths:
+ - '**/*.py'
+ - 'ruff.toml'
+ - '.github/workflows/python-lint.yml'
+ pull_request:
+ paths:
+ - '**/*.py'
+ - 'ruff.toml'
+ - '.github/workflows/python-lint.yml'
+ # Mirrors the other workflows: manual retrigger for an Actions outage
+ # without needing an empty commit.
+ workflow_dispatch: {}
+
+concurrency:
+ group: python-lint-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ ruff:
+ name: ruff (repo-wide)
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
+
+ - name: Set up Python
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
+ with:
+ python-version: '3.12'
+
+ - name: Install ruff
+ # ruff is PINNED. Unpinned, `--upgrade` installs whatever shipped since
+ # the last run, and a release that enables new rules turns a PR red for
+ # code it never touched. That is not hypothetical: main was green on
+ # 2026-07-23, and the next PR to trigger the old helper-ci workflow
+ # (2026-07-30, and only because helper-ci watches Protocol.swift) drew
+ # 380 errors across files nobody had edited — UP045 and friends. Ten
+ # minutes went into proving the feature branch was innocent.
+ #
+ # Raising this pin is a deliberate task: bump it, run `ruff check .`,
+ # and fix or `# noqa` what the new rules find, in a PR that does only
+ # that. Never bump it as a side effect of unrelated work.
+ #
+ # `ruff.toml` states `select` explicitly, so a bump changes rule
+ # BEHAVIOUR but not the rule SET — that makes the diff reviewable.
+ # Keep this pin and helper-ci.yml's Python version in step.
+ run: python -m pip install --upgrade pip && python -m pip install 'ruff==0.15.11'
+
+ - name: Ruff lint (repo root)
+ # NO working-directory. This runs at the repo root on purpose — see the
+ # header comment. `ruff.toml` at the root supplies the config and the
+ # single `archive/` exclusion.
+ run: |
+ ruff --version
+ ruff check . --output-format=github
diff --git a/CLI Pulse Bar/scripts/add_for_review_ios_v192.py b/CLI Pulse Bar/scripts/add_for_review_ios_v192.py
index cc7fc48a..376c91f3 100644
--- a/CLI Pulse Bar/scripts/add_for_review_ios_v192.py
+++ b/CLI Pulse Bar/scripts/add_for_review_ios_v192.py
@@ -9,10 +9,11 @@
- Build 30 is bound, What's New / all screenshots are in place.
"""
from __future__ import annotations
-import sys, os
+import sys
+import os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
-from submit_ios_v192 import get, post, patch, APP_ID, IOS_VERSION_ID, requests, H, BASE_URL
+from submit_ios_v192 import get, post, APP_ID, IOS_VERSION_ID, requests, H, BASE_URL
def create_review_submission() -> str:
diff --git a/CLI Pulse Bar/scripts/appstore_metadata.py b/CLI Pulse Bar/scripts/appstore_metadata.py
index a0b5a824..17526b86 100644
--- a/CLI Pulse Bar/scripts/appstore_metadata.py
+++ b/CLI Pulse Bar/scripts/appstore_metadata.py
@@ -7,10 +7,8 @@
import jwt
import time
import requests
-import json
import hashlib
import os
-import sys
# --- Config ---
API_KEY_ID = "DMMFP6XTXX"
@@ -203,7 +201,7 @@ def set_localization(version_id, locale="en-US"):
}
}, raise_on_error=False)
if result is None:
- print(f" (localization update skipped due to version state)")
+ print(" (localization update skipped due to version state)")
# whatsNew only for updates, not first version - try separately
api_patch(f"/appStoreVersionLocalizations/{loc_id}", {
"data": {
@@ -213,7 +211,7 @@ def set_localization(version_id, locale="en-US"):
}
}, raise_on_error=False)
else:
- print(f" Creating new localization...")
+ print(" Creating new localization...")
loc_data["locale"] = locale
r = api_post("/appStoreVersionLocalizations", {
"data": {
@@ -272,7 +270,7 @@ def upload_screenshots(loc_id, screenshot_files, display_type):
}
}, raise_on_error=False)
if r is None:
- print(f" Cannot create screenshot set (version state). Skipping.")
+ print(" Cannot create screenshot set (version state). Skipping.")
return
set_id = r["data"]["id"]
print(f" Created screenshot set: {set_id}")
@@ -283,7 +281,7 @@ def upload_screenshots(loc_id, screenshot_files, display_type):
if existing:
result = api_delete(f"/appScreenshots/{existing[0]['id']}")
if result >= 400:
- print(f" Cannot modify screenshots (version state). Skipping.")
+ print(" Cannot modify screenshots (version state). Skipping.")
return
for ss in existing[1:]:
api_delete(f"/appScreenshots/{ss['id']}")
@@ -317,14 +315,14 @@ def upload_screenshots(loc_id, screenshot_files, display_type):
}, raise_on_error=False)
if r is None:
- print(f" Failed to reserve. Skipping remaining screenshots.")
+ print(" Failed to reserve. Skipping remaining screenshots.")
return
screenshot_id = r["data"]["id"]
upload_ops = r["data"]["attributes"].get("uploadOperations", [])
if not upload_ops:
- print(f" No upload operations returned, skipping...")
+ print(" No upload operations returned, skipping...")
continue
# Upload parts
@@ -359,7 +357,7 @@ def upload_screenshots(loc_id, screenshot_files, display_type):
# 4. Set App Info (category, etc.)
# ============================================================
def set_app_info():
- print(f"\n Setting app info (category)...")
+ print("\n Setting app info (category)...")
r = api_get(f"/apps/{APP_ID}/appInfos")
infos = r.get("data", [])
if not infos:
@@ -381,7 +379,7 @@ def set_app_info():
},
}
})
- print(f" Category set to Developer Tools")
+ print(" Category set to Developer Tools")
except Exception as e:
print(f" Category update note: {e}")
@@ -400,7 +398,7 @@ def set_app_info():
}
}
})
- print(f" App info localization updated")
+ print(" App info localization updated")
break
diff --git a/CLI Pulse Bar/scripts/compose_appstore_screenshots.py b/CLI Pulse Bar/scripts/compose_appstore_screenshots.py
index de0308cb..b506f293 100755
--- a/CLI Pulse Bar/scripts/compose_appstore_screenshots.py
+++ b/CLI Pulse Bar/scripts/compose_appstore_screenshots.py
@@ -11,9 +11,8 @@
"""
from __future__ import annotations
-import os
from pathlib import Path
-from PIL import Image, ImageDraw, ImageFont, ImageChops
+from PIL import Image, ImageDraw, ImageFont
CANVAS_W, CANVAS_H = 2880, 1800
diff --git a/CLI Pulse Bar/scripts/resubmit.py b/CLI Pulse Bar/scripts/resubmit.py
index 45f83005..d192a85d 100644
--- a/CLI Pulse Bar/scripts/resubmit.py
+++ b/CLI Pulse Bar/scripts/resubmit.py
@@ -2,7 +2,11 @@
"""
CLI Pulse - Cancel review, update screenshots, select new build, resubmit.
"""
-import jwt, time, requests, os, hashlib, sys
+import jwt
+import time
+import requests
+import os
+import hashlib
API_KEY_ID = "DMMFP6XTXX"
API_ISSUER = "c5671c11-49ec-47d9-bd38-5e3c1a249416"
@@ -37,7 +41,7 @@ def post(path, data):
try:
for e in r.json().get("errors", []):
print(f" {e.get('detail', e.get('title'))}")
- except:
+ except Exception:
print(f" {r.text[:300]}")
return None
return r.json()
@@ -49,7 +53,7 @@ def patch(path, data):
try:
for e in r.json().get("errors", []):
print(f" {e.get('detail', e.get('title'))}")
- except:
+ except Exception:
print(f" {r.text[:300]}")
return None
return r.json()
@@ -75,9 +79,10 @@ def cancel_reviews():
if result:
print(f" Canceled: {sub_id}")
else:
- # Try legacy API
- print(f" Trying legacy appStoreVersionSubmissions...")
- r2 = get(f"/appStoreVersionSubmissions")
+ # Try legacy API. The response is deliberately not inspected —
+ # the call is a probe and we proceed either way.
+ print(" Trying legacy appStoreVersionSubmissions...")
+ get("/appStoreVersionSubmissions")
# Just proceed - may already be canceled
@@ -113,7 +118,7 @@ def select_build(version_id, platform_label):
print(f" Waiting for build processing... ({attempt+1}/12)")
time.sleep(30)
- print(f" WARNING: Could not find valid build 3. Proceeding with existing build.")
+ print(" WARNING: Could not find valid build 3. Proceeding with existing build.")
return False
@@ -148,7 +153,7 @@ def upload_screenshots(loc_id, files, display_type):
}
})
if not r:
- print(f" Failed to create screenshot set")
+ print(" Failed to create screenshot set")
return
set_id = r["data"]["id"]
@@ -174,7 +179,7 @@ def upload_screenshots(loc_id, files, display_type):
}
})
if not r:
- print(f" Failed to reserve upload")
+ print(" Failed to reserve upload")
continue
ss_id = r["data"]["id"]
@@ -218,7 +223,7 @@ def submit_for_review():
vr = get(f"/apps/{APP_ID}/appStoreVersions?filter[platform]={platform}")
for v in vr.get("data", []):
if v["attributes"]["versionString"] == "1.0.0":
- post(f"/reviewSubmissionItems", {
+ post("/reviewSubmissionItems", {
"data": {
"type": "reviewSubmissionItems",
"relationships": {
@@ -241,9 +246,9 @@ def submit_for_review():
}
})
if result:
- print(f" Submitted for review!")
+ print(" Submitted for review!")
else:
- print(f" Submit failed - may need manual submission")
+ print(" Submit failed - may need manual submission")
else:
print(" Could not create review submission")
@@ -341,7 +346,7 @@ def main():
}
}
})
- print(f" Updated privacy policy URL")
+ print(" Updated privacy policy URL")
# Try to select latest build
if mac_vid:
diff --git a/CLI Pulse Bar/scripts/submit_v1_10_8.py b/CLI Pulse Bar/scripts/submit_v1_10_8.py
index be7378a6..cbeddf22 100644
--- a/CLI Pulse Bar/scripts/submit_v1_10_8.py
+++ b/CLI Pulse Bar/scripts/submit_v1_10_8.py
@@ -154,8 +154,6 @@ def bind_build(version_id, platform_label):
for b in r.get("data", []):
bv = b["attributes"].get("version")
proc = b["attributes"].get("processingState")
- ver_rel = (b.get("relationships", {}).get("preReleaseVersion", {})
- .get("data") or {})
states.append(f"{bv}/{proc}")
if bv == TARGET_BUILD and proc == "VALID":
found = b["id"]
diff --git a/CLI Pulse Bar/scripts/submit_v1_11_0.py b/CLI Pulse Bar/scripts/submit_v1_11_0.py
index 564a406e..956b4a1e 100644
--- a/CLI Pulse Bar/scripts/submit_v1_11_0.py
+++ b/CLI Pulse Bar/scripts/submit_v1_11_0.py
@@ -179,8 +179,6 @@ def bind_build(version_id, platform_label):
for b in r.get("data", []):
bv = b["attributes"].get("version")
proc = b["attributes"].get("processingState")
- ver_rel = (b.get("relationships", {}).get("preReleaseVersion", {})
- .get("data") or {})
states.append(f"{bv}/{proc}")
if bv == TARGET_BUILD and proc == "VALID":
found = b["id"]
diff --git a/CLI Pulse Bar/scripts/submit_v1_11_1.py b/CLI Pulse Bar/scripts/submit_v1_11_1.py
index 6fd2a009..bcf44257 100644
--- a/CLI Pulse Bar/scripts/submit_v1_11_1.py
+++ b/CLI Pulse Bar/scripts/submit_v1_11_1.py
@@ -194,8 +194,6 @@ def bind_build(version_id, platform_label):
for b in r.get("data", []):
bv = b["attributes"].get("version")
proc = b["attributes"].get("processingState")
- ver_rel = (b.get("relationships", {}).get("preReleaseVersion", {})
- .get("data") or {})
states.append(f"{bv}/{proc}")
if bv == TARGET_BUILD and proc == "VALID":
found = b["id"]
diff --git a/CLI Pulse Bar/scripts/submit_v1_12_0.py b/CLI Pulse Bar/scripts/submit_v1_12_0.py
index 985e99fc..1f06140b 100644
--- a/CLI Pulse Bar/scripts/submit_v1_12_0.py
+++ b/CLI Pulse Bar/scripts/submit_v1_12_0.py
@@ -152,8 +152,6 @@ def bind_build(version_id, platform_label):
for b in r.get("data", []):
bv = b["attributes"].get("version")
proc = b["attributes"].get("processingState")
- ver_rel = (b.get("relationships", {}).get("preReleaseVersion", {})
- .get("data") or {})
states.append(f"{bv}/{proc}")
if bv == TARGET_BUILD and proc == "VALID":
found = b["id"]
diff --git a/CLI Pulse Bar/scripts/submit_v1_21_0.py b/CLI Pulse Bar/scripts/submit_v1_21_0.py
index 6b25ae7d..21664d7b 100644
--- a/CLI Pulse Bar/scripts/submit_v1_21_0.py
+++ b/CLI Pulse Bar/scripts/submit_v1_21_0.py
@@ -219,8 +219,6 @@ def bind_build(version_id, platform_label):
for b in r.get("data", []):
bv = b["attributes"].get("version")
proc = b["attributes"].get("processingState")
- ver_rel = (b.get("relationships", {}).get("preReleaseVersion", {})
- .get("data") or {})
states.append(f"{bv}/{proc}")
if bv == TARGET_BUILD and proc == "VALID":
found = b["id"]
diff --git a/CLI Pulse Bar/scripts/submit_watch_v192.py b/CLI Pulse Bar/scripts/submit_watch_v192.py
index cd372b72..cb671733 100644
--- a/CLI Pulse Bar/scripts/submit_watch_v192.py
+++ b/CLI Pulse Bar/scripts/submit_watch_v192.py
@@ -8,7 +8,9 @@
the set creation is rejected.
"""
from __future__ import annotations
-import hashlib, sys, os
+import hashlib
+import sys
+import os
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
diff --git a/ruff.toml b/ruff.toml
new file mode 100644
index 00000000..f7c93955
--- /dev/null
+++ b/ruff.toml
@@ -0,0 +1,46 @@
+# Repo-wide Ruff configuration.
+#
+# Until 2026-08-17 there was no config file at all, and the only `ruff check .`
+# in CI ran from `working-directory: helper` inside a workflow whose `paths:`
+# filter listed `helper/**`. Two independent reasons the same Python never got
+# linted: the workflow did not trigger, and even when it did it only ever saw
+# `helper/`. Python under `scripts/`, `CLI Pulse Bar/scripts/` and
+# `backend/supabase/` was unlinted for the life of the repo — 91 errors had
+# accumulated by the time anyone checked.
+#
+# `.github/workflows/python-lint.yml` now runs ruff from the repo root on every
+# `**/*.py` change, and this file is what that run reads.
+
+# CI installs Python 3.12 (see python-lint.yml / helper-ci.yml). Pin the same
+# version here so a lint that passes locally cannot fail on the runner for a
+# syntax-version reason.
+target-version = "py312"
+
+# `archive/` is documented in AGENTS.md under "Archived or historical". Nothing
+# imports it, nothing ships it, and it carries 11 of the errors that were found
+# when linting was first turned on repo-wide. Linting it would mean editing dead
+# code to satisfy a gate that protects live code.
+#
+# This is the ONLY exclusion. Everything else that ships is linted. If you are
+# about to add a second entry here, fix the code instead — an exclusion is
+# permanent in practice, and this file exists because of what "we'll clean it up
+# later" cost the first time.
+exclude = [
+ "archive",
+]
+
+[lint]
+# These four groups are exactly ruff 0.15.11's defaults, written out on purpose.
+#
+# Leaving `select` implicit means the rule set is whatever the installed ruff
+# version happens to default to, so raising the pin can turn PRs red for code
+# they never touched. That is not hypothetical — see the comment above the
+# `ruff==0.15.11` pin in python-lint.yml for the 2026-07-30 incident. Stating
+# the set explicitly makes a version bump a mechanical change and a rule-set
+# change a deliberate, reviewable one.
+#
+# E4 — import placement/formatting
+# E7 — statement-level style (multiple statements per line, bare except, ...)
+# E9 — syntax and IO errors
+# F — Pyflakes: undefined names, unused imports, unused locals
+select = ["E4", "E7", "E9", "F"]
diff --git a/scripts/ci_check_android_strings_parity.py b/scripts/ci_check_android_strings_parity.py
index aa2c7d8e..25904266 100644
--- a/scripts/ci_check_android_strings_parity.py
+++ b/scripts/ci_check_android_strings_parity.py
@@ -26,7 +26,6 @@
"""
from __future__ import annotations
-import os
import re
import sys
from pathlib import Path
diff --git a/scripts/pet_art/pet_art_gen.py b/scripts/pet_art/pet_art_gen.py
index 9d4128f0..65c2e139 100644
--- a/scripts/pet_art/pet_art_gen.py
+++ b/scripts/pet_art/pet_art_gen.py
@@ -7,7 +7,6 @@
frame) parameter deltas → frame-to-frame consistency. Emits SVG; the export
script rasterizes to PetAssets/ PNG @1x/@2x.
"""
-import math
STROKE = 14
VIEW = 512
@@ -63,16 +62,22 @@ def ears(c, cx, cy, spread=86, h=60, flop=None):
def face(c, cx, cy, eyes='dot', mouth='smile', eye_r=12, teary=False):
ex = 36
if eyes == 'closed':
- c.eye(cx-ex, cy-6, eye_r, closed=True); c.eye(cx+ex, cy-6, eye_r, closed=True)
+ c.eye(cx-ex, cy-6, eye_r, closed=True)
+ c.eye(cx+ex, cy-6, eye_r, closed=True)
elif eyes == 'half':
- c.stroke_path(f"M{cx-ex-16} {cy-6} h32"); c.stroke_path(f"M{cx+ex-16} {cy-6} h32")
+ c.stroke_path(f"M{cx-ex-16} {cy-6} h32")
+ c.stroke_path(f"M{cx+ex-16} {cy-6} h32")
elif eyes == 'wide':
- c.circles.append((cx-ex, cy-6, eye_r+8, False)); c.circles.append((cx+ex, cy-6, eye_r+8, False))
- c.circles.append((cx-ex, cy-6, 5, True)); c.circles.append((cx+ex, cy-6, 5, True))
+ c.circles.append((cx-ex, cy-6, eye_r+8, False))
+ c.circles.append((cx+ex, cy-6, eye_r+8, False))
+ c.circles.append((cx-ex, cy-6, 5, True))
+ c.circles.append((cx+ex, cy-6, 5, True))
else: # dot
- c.eye(cx-ex, cy-6, eye_r); c.eye(cx+ex, cy-6, eye_r)
+ c.eye(cx-ex, cy-6, eye_r)
+ c.eye(cx+ex, cy-6, eye_r)
if teary:
- c.stroke_path(f"M{cx-ex-6} {cy+8} q-8 20 4 24"); c.stroke_path(f"M{cx+ex+6} {cy+8} q8 20 -4 24")
+ c.stroke_path(f"M{cx-ex-6} {cy+8} q-8 20 4 24")
+ c.stroke_path(f"M{cx+ex+6} {cy+8} q8 20 -4 24")
# nose
c.fill_path(f"M{cx} {cy+18} l-8 10 h16 Z")
my = cy+28
@@ -81,13 +86,16 @@ def face(c, cx, cy, eyes='dot', mouth='smile', eye_r=12, teary=False):
elif mouth == 'flat': # unsettlingly wide flat smile (polite)
c.stroke_path(f"M{cx-58} {my} q58 30 116 0")
elif mouth == 'open': # effort (smash)
- c.circles.append((cx, my+6, 12, False)); c.stroke_path(f"M{cx-14} {my-2} h28")
+ c.circles.append((cx, my+6, 12, False))
+ c.stroke_path(f"M{cx-14} {my-2} h28")
elif mouth == 'tiny':
c.circles.append((cx, my+4, 6, False))
else: # smile
- c.stroke_path(f"M{cx} {my-4} q-16 14 -30 4"); c.stroke_path(f"M{cx} {my-4} q16 14 30 4")
+ c.stroke_path(f"M{cx} {my-4} q-16 14 -30 4")
+ c.stroke_path(f"M{cx} {my-4} q16 14 30 4")
# whiskers
- c.stroke_path(f"M{cx-106} {cy+10} h40"); c.stroke_path(f"M{cx+66} {cy+10} h40")
+ c.stroke_path(f"M{cx-106} {cy+10} h40")
+ c.stroke_path(f"M{cx+66} {cy+10} h40")
def zzz(c, x, y):
@@ -97,7 +105,8 @@ def zzz(c, x, y):
def emphasis(c, cx, cy):
- c.stroke_path(f"M{cx-150} {cy-30} l-26 -10"); c.stroke_path(f"M{cx+150} {cy-30} l26 -10")
+ c.stroke_path(f"M{cx-150} {cy-30} l-26 -10")
+ c.stroke_path(f"M{cx+150} {cy-30} l26 -10")
# ---- form drawers: (state, frame) -> Cat ----
@@ -121,16 +130,18 @@ def draw(form, state, frame):
zzz(c, 372, 250)
elif state == 'active':
c.stroke_path("M232 224 q-10 -30 8 -48")
- if frame == 1: c.stroke_path("M280 224 q10 -30 -8 -48")
+ if frame == 1:
+ c.stroke_path("M280 224 q10 -30 -8 -48")
elif form == 'polite':
base_cat(c, hx, hy, sleeping)
if sleeping:
- face(c, hx, hy, eyes='closed', mouth='flat'); zzz(c, 360, 150)
+ face(c, hx, hy, eyes='closed', mouth='flat')
+ zzz(c, 360, 150)
else:
- wider = 58 + (18 if frame == 1 else 0)
face(c, hx, hy, eyes='dot', mouth='flat')
- c.stroke_path("M226 452 q-6 -30 0 -50"); c.stroke_path("M286 452 q6 -30 0 -50") # paws together
+ c.stroke_path("M226 452 q-6 -30 0 -50")
+ c.stroke_path("M286 452 q6 -30 0 -50") # paws together
if state == 'active':
py = 300 if frame == 1 else 340
c.stroke_path(f"M330 400 q60 -20 44 -{440-py}") # raised wave paw
@@ -139,27 +150,33 @@ def draw(form, state, frame):
base_cat(c, hx, hy-6, sleeping)
if sleeping:
c.stroke_path("M176 452 h160") # slumped on keyboard
- face(c, hx, hy+30, eyes='closed', mouth='tiny'); zzz(c, 360, 250)
+ face(c, hx, hy+30, eyes='closed', mouth='tiny')
+ zzz(c, 360, 250)
else:
m = 'open' if (state == 'active' and frame == 1) else 'smile'
face(c, hx, hy-6, eyes='wide', mouth=m)
# tiny keyboard
c.stroke_path("M176 452 h160 v34 h-160 Z")
- for gx in range(196, 330, 22): c.stroke_path(f"M{gx} 460 v18")
+ for gx in range(196, 330, 22):
+ c.stroke_path(f"M{gx} 460 v18")
pl = 452 if frame == 0 else 470
- c.stroke_path(f"M214 430 v{pl-430}"); c.stroke_path(f"M298 430 v{(470 if frame==0 else 452)-430}")
+ c.stroke_path(f"M214 430 v{pl-430}")
+ c.stroke_path(f"M298 430 v{(470 if frame==0 else 452)-430}")
if state == 'active':
- c.stroke_path("M196 420 l-14 -12"); c.stroke_path("M316 420 l14 -12")
+ c.stroke_path("M196 420 l-14 -12")
+ c.stroke_path("M316 420 l14 -12")
elif form == 'pop':
base_cat(c, hx, hy, sleeping)
if sleeping:
- face(c, hx, hy, eyes='closed', mouth='tiny'); zzz(c, 360, 150)
+ face(c, hx, hy, eyes='closed', mouth='tiny')
+ zzz(c, 360, 150)
else:
m = 'O' if (frame == 1 or state == 'active') else 'smile'
eyes = 'wide' if m == 'O' else 'dot'
face(c, hx, hy, eyes=eyes, mouth=m)
- if state == 'active' and frame == 0: emphasis(c, hx, hy)
+ if state == 'active' and frame == 0:
+ emphasis(c, hx, hy)
elif form == 'long':
# comically elongated horizontal tube; normal head on the left.
@@ -167,7 +184,8 @@ def draw(form, state, frame):
# coiled into a spiral (cinnamon-roll)
c.stroke_path("M256 320 m-70 0 a70 70 0 1 1 140 0 a44 44 0 1 1 -88 0 a20 20 0 1 1 40 0")
ears(c, 200, 268, spread=44, h=40)
- face(c, 200, 300, eyes='closed', mouth='tiny'); zzz(c, 360, 250)
+ face(c, 200, 300, eyes='closed', mouth='tiny')
+ zzz(c, 360, 250)
else:
arch = 26 if (state == 'active' and frame == 1) else 0
top = 292 - arch // 2
@@ -179,20 +197,24 @@ def draw(form, state, frame):
face(c, 150, hy2 - 2, eyes='half', mouth='smile')
# four legs
legs = (214, 250, 322, 358) if state != 'active' else (206, 258, 314, 366)
- for lx in legs: c.stroke_path(f"M{lx} {top+68} v38")
+ for lx in legs:
+ c.stroke_path(f"M{lx} {top+68} v38")
c.stroke_path(f"M398 {top+30} c44 -6 58 -34 38 -60") # tail
elif form == 'huh':
if sleeping:
base_cat(c, hx, hy, True)
- hd = Cat(); hd.circles.append((256, 300, 92, False)); ears(hd, 256, 300)
+ hd = Cat()
+ hd.circles.append((256, 300, 92, False))
+ ears(hd, 256, 300)
face(hd, 256, 300, eyes='closed', mouth='tiny')
c.raw(f'{hd.inner()}')
zzz(c, 372, 250)
else:
# upright body; the whole HEAD tilts (the classic "huh?").
c.stroke_path("M176 268 C150 330 150 400 176 430 C210 452 302 452 336 430 C362 400 362 330 336 268")
- c.stroke_path("M214 452 q-6 -26 0 -44"); c.stroke_path("M298 452 q6 -26 0 -44")
+ c.stroke_path("M214 452 q-6 -26 0 -44")
+ c.stroke_path("M298 452 q6 -26 0 -44")
c.stroke_path("M336 420 c46 8 66 -26 44 -60")
tilt = 18 if frame == 0 else -18
hd = Cat()
@@ -215,7 +237,8 @@ def base_cat(c, cx, cy, sleeping, tilt=0):
ears(c, cx, cy, flop='right' if tilt else None)
c.circles.append((cx, cy, 96, False)) # head
c.stroke_path("M176 268 C150 330 150 400 176 430 C210 452 302 452 336 430 C362 400 362 330 336 268")
- c.stroke_path("M214 452 q-6 -26 0 -44"); c.stroke_path("M298 452 q6 -26 0 -44") # paws
+ c.stroke_path("M214 452 q-6 -26 0 -44")
+ c.stroke_path("M298 452 q6 -26 0 -44") # paws
c.stroke_path("M336 420 c46 8 66 -26 44 -60") # tail
@@ -235,7 +258,8 @@ def draw_egg(state):
egg_body(c)
elif state == 'idle_1':
# mid-wiggle: whole egg tilted ~8 degrees
- e = Cat(); egg_body(e)
+ e = Cat()
+ egg_body(e)
c.raw(f'{e.inner()}')
elif state == 'crack1':
egg_body(c)
@@ -243,7 +267,8 @@ def draw_egg(state):
elif state == 'crack2':
egg_body(c)
c.stroke_path("M206 210 l20 16 l-16 18 l22 14 l-14 18 l18 12") # crack spreads down
- c.circles.append((290, 250, 11, False)); c.circles.append((290, 250, 4, True)) # peeking eye
+ c.circles.append((290, 250, 11, False))
+ c.circles.append((290, 250, 4, True)) # peeking eye
elif state == 'crack3':
egg_body(c, lift=18, ear=True)
# jagged separation line across the middle
@@ -266,7 +291,8 @@ def draw_egg(state):
FRAMES = {'idle': [0, 1], 'active': [0, 1], 'sleep': [0]}
if __name__ == '__main__':
- import sys, os
+ import sys
+ import os
outdir = sys.argv[1] if len(sys.argv) > 1 else '.'
n = 0
for form in FORMS:
diff --git a/scripts/pet_golden_oracle.py b/scripts/pet_golden_oracle.py
index 21246665..4fc48332 100644
--- a/scripts/pet_golden_oracle.py
+++ b/scripts/pet_golden_oracle.py
@@ -2,7 +2,8 @@
# Independent spec oracle for the Pulse Cat M1 ruleset. Generates
# PetGoldenVectors.json with computed expectations. If the Swift PetEngine
# matches this, both match the plan §1.2 spec.
-import json, datetime
+import json
+import datetime
RULESET_VERSION = 1
WEIGHT_TABLE_VERSION = 1
@@ -35,9 +36,12 @@ def window_keys(today):
return [dkey(t - datetime.timedelta(days=i)) for i in range(WINDOW_DAYS-1, -1, -1)]
def resolve_form(dom, tempo):
- if dom == "anthropic": return "loaf" if tempo == "steady" else "polite"
- if dom == "openai": return "smash" if tempo == "steady" else "pop"
- if dom == "google": return "long"
+ if dom == "anthropic":
+ return "loaf" if tempo == "steady" else "polite"
+ if dom == "openai":
+ return "smash" if tempo == "steady" else "pop"
+ if dom == "google":
+ return "long"
return "huh" # other / None
def usable(u):
@@ -60,7 +64,8 @@ def profile(days, today):
fam_score = {}
for k in keys:
for prov, u in days.get(k,{}).items():
- if not usable(u): continue
+ if not usable(u):
+ continue
fam, w = PROV.get(prov, ("other", 500_000))
fam_score[fam] = fam_score.get(fam,0) + max(0,u.get("tokens",0))*w
total = sum(fam_score.values())
@@ -76,7 +81,8 @@ def profile(days, today):
dom = top[0]
# tempo
toks_sorted = sorted(day_tokens.values(), reverse=True)
- top3 = sum(toks_sorted[:BURST_TOP]); tt = sum(toks_sorted)
+ top3 = sum(toks_sorted[:BURST_TOP])
+ tt = sum(toks_sorted)
burst = tt > 0 and top3*100 >= tt*BURST_PCT
tempo = "burst" if burst else "steady"
form = resolve_form(dom, tempo)
@@ -85,7 +91,8 @@ def profile(days, today):
resolvedForm=form, eggStage=egg, activeDays=len(active))
def timing_allows(last, today):
- if last is None: return True
+ if last is None:
+ return True
return today >= shift(last, MIN_DAYS_BETWEEN)
def evaluate(days, today, owned, last):
@@ -107,8 +114,10 @@ def spread(prov, daykeys, tokens, msgs=0, conf=None):
out = {}
for k in daykeys:
u = {"tokens": tokens}
- if msgs: u["messages"] = msgs
- if conf: u["confidence"] = conf
+ if msgs:
+ u["messages"] = msgs
+ if conf:
+ u["confidence"] = conf
out.setdefault(k, {})[prov] = u
return out
@@ -121,7 +130,10 @@ def merge(*maps):
T = "2026-07-11"
W = window_keys(T) # 07-05 .. 07-11
-d = lambda i: W[i] # index into window, 0=oldest
+
+
+def d(i): # index into window, 0=oldest
+ return W[i]
cases = []
def add(name, days, today=T, owned=None, last=None):
@@ -158,7 +170,8 @@ def add(name, days, today=T, owned=None, last=None):
# 13 clock rollback (last hatch in the future)
add("clock_rollback_no_hatch", spread("Claude", W, 20_000), last=shift(T,9))
# 14 DST spring-forward window (ending 2026-03-09), 6 steady days
-Tdst = "2026-03-09"; Wd = window_keys(Tdst)
+Tdst = "2026-03-09"
+Wd = window_keys(Tdst)
add("dst_spring_forward_loaf", spread("Claude", Wd[1:], 25_000), today=Tdst) # 6 days
# 15 messages-only (0 weighted tokens) -> NOT qualified even at 3 active days
add("messages_only_not_qualified",
@@ -206,7 +219,8 @@ def add(name, days, today=T, owned=None, last=None):
# 25 timezone travel: window includes frozen keys on both sides of a UTC-midnight
# day boundary (same event bucketed to adjacent days in different TZs, per M0).
# Engine profiles the frozen keys as distinct active days — no merge, no gap.
-Ttrav = "2026-03-21"; Wt = window_keys(Ttrav) # ...03-15..03-21
+Ttrav = "2026-03-21"
+Wt = window_keys(Ttrav) # ...03-15..03-21
add("timezone_travel_frozen_keys",
merge(spread("Claude", [Wt[2],Wt[3],Wt[4],Wt[5],Wt[6]], 22_000)), today=Ttrav) # 5 steady days
# 26 steering: OpenAI-heavy first half, Anthropic-heavy second half — the whole
diff --git a/scripts/pet_normalize_assets.py b/scripts/pet_normalize_assets.py
index 46cb19fe..35e5c688 100644
--- a/scripts/pet_normalize_assets.py
+++ b/scripts/pet_normalize_assets.py
@@ -16,7 +16,9 @@
Requires Pillow. IP: the AI-gen prompts must obey the §1.3 red lines (no meme
names/likeness); this script only normalizes geometry, it does not create art.
"""
-import os, sys, re
+import os
+import sys
+import re
try:
from PIL import Image, ImageDraw, ImageFilter
@@ -155,7 +157,8 @@ def _selftest():
for rel, expect in cases.items():
got = parse_target(os.path.join("/in", rel), "/in")
status = "ok" if got == expect else "FAIL"
- if got != expect: ok = False
+ if got != expect:
+ ok = False
print(f" [{status}] {rel} -> {got} (expected {expect})")
print("selftest passed" if ok else "selftest FAILED")
return ok