runspector 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. runspector-0.5.0/.githooks/pre-push +33 -0
  2. runspector-0.5.0/.github/dependabot.yml +28 -0
  3. runspector-0.5.0/.github/workflows/ci.yml +142 -0
  4. runspector-0.5.0/.github/workflows/evals.yml +143 -0
  5. runspector-0.5.0/.github/workflows/mutation.yml +126 -0
  6. runspector-0.5.0/.github/workflows/release.yml +188 -0
  7. runspector-0.5.0/.github/workflows/sweep.yml +43 -0
  8. runspector-0.5.0/.gitignore +35 -0
  9. runspector-0.5.0/.python-version +1 -0
  10. runspector-0.5.0/CLAUDE.md +51 -0
  11. runspector-0.5.0/LICENSE +21 -0
  12. runspector-0.5.0/PKG-INFO +83 -0
  13. runspector-0.5.0/README.md +351 -0
  14. runspector-0.5.0/biome.jsonc +44 -0
  15. runspector-0.5.0/docs/architecture.md +134 -0
  16. runspector-0.5.0/docs/pypi.md +56 -0
  17. runspector-0.5.0/docs/releases/v0.3.1.md +49 -0
  18. runspector-0.5.0/docs/releases/v0.4.0.md +59 -0
  19. runspector-0.5.0/docs/releases/v0.4.1.md +29 -0
  20. runspector-0.5.0/docs/releases/v0.5.0.md +34 -0
  21. runspector-0.5.0/docs/roadmap.md +117 -0
  22. runspector-0.5.0/docs/screenshots/coach-light.png +0 -0
  23. runspector-0.5.0/docs/screenshots/coach.png +0 -0
  24. runspector-0.5.0/docs/screenshots/demo-card.json +21 -0
  25. runspector-0.5.0/docs/screenshots/readme-coach.png +0 -0
  26. runspector-0.5.0/docs/screenshots/readme-runs.png +0 -0
  27. runspector-0.5.0/docs/screenshots/readme-today.png +0 -0
  28. runspector-0.5.0/docs/screenshots/readme-trend.png +0 -0
  29. runspector-0.5.0/docs/screenshots/readme-you.png +0 -0
  30. runspector-0.5.0/docs/screenshots/runs-light.png +0 -0
  31. runspector-0.5.0/docs/screenshots/runs.png +0 -0
  32. runspector-0.5.0/docs/screenshots/today-light.png +0 -0
  33. runspector-0.5.0/docs/screenshots/today.png +0 -0
  34. runspector-0.5.0/docs/screenshots/trend-light.png +0 -0
  35. runspector-0.5.0/docs/screenshots/trend.png +0 -0
  36. runspector-0.5.0/docs/screenshots/you-light.png +0 -0
  37. runspector-0.5.0/docs/screenshots/you.png +0 -0
  38. runspector-0.5.0/docs/video/README.md +18 -0
  39. runspector-0.5.0/docs/video/assets/audio/answer.mp3 +0 -0
  40. runspector-0.5.0/docs/video/assets/audio/chat.mp3 +0 -0
  41. runspector-0.5.0/docs/video/assets/audio/close.mp3 +0 -0
  42. runspector-0.5.0/docs/video/assets/audio/hook.mp3 +0 -0
  43. runspector-0.5.0/docs/video/assets/audio/runs.mp3 +0 -0
  44. runspector-0.5.0/docs/video/assets/audio/strategy.mp3 +0 -0
  45. runspector-0.5.0/docs/video/assets/audio/today.mp3 +0 -0
  46. runspector-0.5.0/docs/video/assets/audio/try.mp3 +0 -0
  47. runspector-0.5.0/docs/video/assets/audio/watch.mp3 +0 -0
  48. runspector-0.5.0/docs/video/assets/captured.json +9 -0
  49. runspector-0.5.0/docs/video/assets/fonts/OFL-inter.txt +93 -0
  50. runspector-0.5.0/docs/video/assets/fonts/OFL-jetbrains-mono.txt +93 -0
  51. runspector-0.5.0/docs/video/assets/fonts/inter-400.woff2 +0 -0
  52. runspector-0.5.0/docs/video/assets/fonts/inter-600.woff2 +0 -0
  53. runspector-0.5.0/docs/video/assets/fonts/inter-700.woff2 +0 -0
  54. runspector-0.5.0/docs/video/assets/fonts/inter-800.woff2 +0 -0
  55. runspector-0.5.0/docs/video/assets/fonts/jbmono-400.woff2 +0 -0
  56. runspector-0.5.0/docs/video/assets/fonts/jbmono-600.woff2 +0 -0
  57. runspector-0.5.0/docs/video/build.py +373 -0
  58. runspector-0.5.0/docs/video/hyperframes.json +12 -0
  59. runspector-0.5.0/docs/video/narration.json +47 -0
  60. runspector-0.5.0/docs/video/poster.png +0 -0
  61. runspector-0.5.0/docs/video/runspector-demo.mp4 +0 -0
  62. runspector-0.5.0/docs/video/scenes.css +202 -0
  63. runspector-0.5.0/docs/video/tts.py +50 -0
  64. runspector-0.5.0/evals/README.md +157 -0
  65. runspector-0.5.0/evals/RESULTS.md +49 -0
  66. runspector-0.5.0/evals/cases.yaml +1612 -0
  67. runspector-0.5.0/evals/fixture_gen.py +273 -0
  68. runspector-0.5.0/evals/run_evals.py +309 -0
  69. runspector-0.5.0/evals/sim.py +681 -0
  70. runspector-0.5.0/pyproject.toml +130 -0
  71. runspector-0.5.0/scripts/install_check.py +216 -0
  72. runspector-0.5.0/scripts/pii_gate.py +166 -0
  73. runspector-0.5.0/scripts/screenshots.py +212 -0
  74. runspector-0.5.0/scripts/video.py +128 -0
  75. runspector-0.5.0/server.json +28 -0
  76. runspector-0.5.0/src/runspector/__init__.py +3 -0
  77. runspector-0.5.0/src/runspector/auth.py +291 -0
  78. runspector-0.5.0/src/runspector/cli.py +387 -0
  79. runspector-0.5.0/src/runspector/demo.py +154 -0
  80. runspector-0.5.0/src/runspector/facts.py +278 -0
  81. runspector-0.5.0/src/runspector/garmin.py +924 -0
  82. runspector-0.5.0/src/runspector/habits.py +311 -0
  83. runspector-0.5.0/src/runspector/logic.py +591 -0
  84. runspector-0.5.0/src/runspector/mcp_server.py +351 -0
  85. runspector-0.5.0/src/runspector/migrations/0001_init.sql +99 -0
  86. runspector-0.5.0/src/runspector/migrations/0002_workouts.sql +13 -0
  87. runspector-0.5.0/src/runspector/migrations/0003_proposal_items.sql +31 -0
  88. runspector-0.5.0/src/runspector/migrations/0004_load_balance.sql +19 -0
  89. runspector-0.5.0/src/runspector/models.py +170 -0
  90. runspector-0.5.0/src/runspector/onboarding.py +207 -0
  91. runspector-0.5.0/src/runspector/paths.py +359 -0
  92. runspector-0.5.0/src/runspector/plan.py +1172 -0
  93. runspector-0.5.0/src/runspector/planning.py +953 -0
  94. runspector-0.5.0/src/runspector/profile.py +117 -0
  95. runspector-0.5.0/src/runspector/skills/coach.md +128 -0
  96. runspector-0.5.0/src/runspector/skills/zones.md +122 -0
  97. runspector-0.5.0/src/runspector/snapshot.py +675 -0
  98. runspector-0.5.0/src/runspector/store.py +967 -0
  99. runspector-0.5.0/src/runspector/sync.py +286 -0
  100. runspector-0.5.0/src/runspector/templates.json +51 -0
  101. runspector-0.5.0/src/runspector/tools.py +756 -0
  102. runspector-0.5.0/src/runspector/web/__init__.py +0 -0
  103. runspector-0.5.0/src/runspector/web/agent.py +471 -0
  104. runspector-0.5.0/src/runspector/web/jobs.py +259 -0
  105. runspector-0.5.0/src/runspector/web/server.py +1146 -0
  106. runspector-0.5.0/src/runspector/web/static/app.js +1696 -0
  107. runspector-0.5.0/src/runspector/web/static/cards.css +126 -0
  108. runspector-0.5.0/src/runspector/web/static/cards.js +825 -0
  109. runspector-0.5.0/src/runspector/web/static/chassis.js +308 -0
  110. runspector-0.5.0/src/runspector/web/static/icon.svg +1 -0
  111. runspector-0.5.0/src/runspector/web/static/index.html +238 -0
  112. runspector-0.5.0/src/runspector/web/static/input-guard.js +36 -0
  113. runspector-0.5.0/src/runspector/web/static/logic.js +476 -0
  114. runspector-0.5.0/src/runspector/web/static/signin.js +49 -0
  115. runspector-0.5.0/src/runspector/web/static/theme-boot.js +14 -0
  116. runspector-0.5.0/src/runspector/web/static/theme.js +85 -0
  117. runspector-0.5.0/src/runspector/web/static/tokens.css +504 -0
  118. runspector-0.5.0/src/runspector/web/static/ui.js +478 -0
  119. runspector-0.5.0/src/runspector/web/static/you.js +297 -0
  120. runspector-0.5.0/tests/conftest.py +243 -0
  121. runspector-0.5.0/tests/golden/build_week.txt +3150 -0
  122. runspector-0.5.0/tests/test_api_contract.py +129 -0
  123. runspector-0.5.0/tests/test_build_week_golden.py +68 -0
  124. runspector-0.5.0/tests/test_demo.py +76 -0
  125. runspector-0.5.0/tests/test_eval_fixture.py +142 -0
  126. runspector-0.5.0/tests/test_facts.py +97 -0
  127. runspector-0.5.0/tests/test_garmin.py +669 -0
  128. runspector-0.5.0/tests/test_habits.py +123 -0
  129. runspector-0.5.0/tests/test_invariants.py +2232 -0
  130. runspector-0.5.0/tests/test_js_python_contract.py +358 -0
  131. runspector-0.5.0/tests/test_legacy_move.py +276 -0
  132. runspector-0.5.0/tests/test_logic.py +591 -0
  133. runspector-0.5.0/tests/test_migrations.py +151 -0
  134. runspector-0.5.0/tests/test_onboarding.py +208 -0
  135. runspector-0.5.0/tests/test_packaging.py +54 -0
  136. runspector-0.5.0/tests/test_palette.py +179 -0
  137. runspector-0.5.0/tests/test_pii_gate.py +216 -0
  138. runspector-0.5.0/tests/test_planning.py +1015 -0
  139. runspector-0.5.0/tests/test_profile.py +78 -0
  140. runspector-0.5.0/tests/test_signin.py +391 -0
  141. runspector-0.5.0/tests/test_simulation.py +49 -0
  142. runspector-0.5.0/tests/test_snapshot.py +724 -0
  143. runspector-0.5.0/tests/test_store.py +922 -0
  144. runspector-0.5.0/tests/test_sync.py +395 -0
  145. runspector-0.5.0/tests/test_tools.py +2541 -0
  146. runspector-0.5.0/tests/test_ui_smoke.py +270 -0
  147. runspector-0.5.0/tests/test_vendor_api.py +127 -0
  148. runspector-0.5.0/tests/test_web.py +887 -0
  149. runspector-0.5.0/tests/test_what_comes_next.py +666 -0
  150. runspector-0.5.0/uv.lock +1409 -0
  151. runspector-0.5.0/web-tests/cards.test.mjs +238 -0
  152. runspector-0.5.0/web-tests/input-guard.test.mjs +74 -0
  153. runspector-0.5.0/web-tests/logic.test.mjs +602 -0
  154. runspector-0.5.0/web-tests/progress-strip.test.mjs +94 -0
  155. runspector-0.5.0/web-tests/signin.test.mjs +41 -0
  156. runspector-0.5.0/web-tests/stored-before-the-rename.test.mjs +25 -0
  157. runspector-0.5.0/web-tests/theme.test.mjs +33 -0
@@ -0,0 +1,33 @@
1
+ #!/bin/sh
2
+ # Before main moves: what CI would fail on, here, in about a minute. Commits go
3
+ # straight to main and CI only reports afterwards - and `uv tool install
4
+ # git+...runspector` installs whatever main is at that moment.
5
+ #
6
+ # git config core.hooksPath .githooks # once per clone, opt-in
7
+ # git push --no-verify # skip, when you know why
8
+ set -e
9
+ cd "$(git rev-parse --show-toplevel)"
10
+
11
+ zero=0000000000000000000000000000000000000000
12
+ while read -r local_ref local_sha remote_ref remote_sha; do
13
+ case "$local_ref" in refs/tags/*) continue ;; esac
14
+ [ "$local_sha" = "$zero" ] && continue
15
+ [ "$remote_sha" = "$zero" ] && continue
16
+ changed=$(git diff --name-only "$remote_sha" "$local_sha")
17
+ # The coach's prompt is code with its own tests: a change to it ships with
18
+ # an eval run, which is what rewrites evals/RESULTS.md.
19
+ if echo "$changed" | grep -qE '^src/runspector/skills/|^evals/cases\.yaml$' &&
20
+ ! echo "$changed" | grep -qx 'evals/RESULTS.md'; then
21
+ echo "pre-push: the coach prompt or the eval cases changed, evals/RESULTS.md did not."
22
+ echo " uv run python evals/run_evals.py (or --no-verify, and say why)"
23
+ exit 1
24
+ fi
25
+ done
26
+
27
+ uv run ruff check
28
+ uv run pytest -q -x -p no:cacheprovider
29
+ uv run python scripts/pii_gate.py
30
+ # The frontend lint, when node is here at all (same version as ci.yml).
31
+ if command -v npx >/dev/null 2>&1; then
32
+ npx --yes @biomejs/biome@2.5.15 lint --error-on-warnings
33
+ fi
@@ -0,0 +1,28 @@
1
+ # The workflow pins its actions to commit SHAs, which is only as good as the
2
+ # habit of moving them: the first three pins aged into a Node 20 deprecation
3
+ # notice within a week, and nothing said so. Dependabot files the PR; CI on the
4
+ # PR is the check that the new SHA still does what the old one did.
5
+ version: 2
6
+ updates:
7
+ - package-ecosystem: github-actions
8
+ directory: /
9
+ schedule:
10
+ interval: weekly
11
+ # Keeps the `# vX.Y.Z` comment next to the SHA up to date, so the pin stays
12
+ # readable by a human.
13
+ commit-message:
14
+ prefix: "ci"
15
+ - package-ecosystem: uv
16
+ directory: /
17
+ schedule:
18
+ interval: weekly
19
+ # `garminconnect` tracks an unofficial API; a minor bump there is exactly
20
+ # the change that needs a human to run `runspector sync` once before merging.
21
+ commit-message:
22
+ prefix: "deps"
23
+ ignore:
24
+ # `pyproject.toml` pins `mcp<2` on purpose: 2.x renamed FastMCP and
25
+ # changed the server API. Dependabot's very first PR proposed 2.2.0
26
+ # anyway and CI rejected it; a major bump there is a port, not a bump.
27
+ - dependency-name: mcp
28
+ update-types: ["version-update:semver-major"]
@@ -0,0 +1,142 @@
1
+ name: ci
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ # One matrix per branch. Pushing twice in a minute otherwise leaves the first
9
+ # six legs running against a commit nobody is waiting for any more. A push to
10
+ # `main` is not cancelled: its result is the badge.
11
+ concurrency:
12
+ group: ci-${{ github.ref }}
13
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
14
+
15
+ # Least privilege: nothing in this workflow writes to the repo, packages or
16
+ # releases. Job-level grants stay opt-in from here.
17
+ permissions:
18
+ contents: read
19
+
20
+ jobs:
21
+ test:
22
+ # The slowest leg (Windows) measures three to four minutes, so fifteen is
23
+ # around four times the headroom and still catches a hang early. Without it
24
+ # the default is six HOURS per leg — and this suite has already had one:
25
+ # headless Chrome on the macOS runners never returns, it only ever hit a
26
+ # timeout inside the test.
27
+ timeout-minutes: 15
28
+ strategy:
29
+ fail-fast: false
30
+ matrix:
31
+ os: [ubuntu-latest, windows-latest, macos-latest]
32
+ # `requires-python = ">=3.12"` is a promise about every version above it;
33
+ # one unpinned interpreter per OS tested none of them in particular.
34
+ python: ["3.12", "3.13"]
35
+ # Coverage is measured on ONE leg: the number is the same code on every
36
+ # OS, but a floor checked six times fails on whichever leg skips the most
37
+ # platform branches. 93 % at the time the floor was set (v0.4.1); the
38
+ # floor only stops it from eroding unnoticed.
39
+ include:
40
+ - os: ubuntu-latest
41
+ python: "3.12"
42
+ coverage: coverage # the value lands in the check's name: keep it short
43
+ runs-on: ${{ matrix.os }}
44
+ steps:
45
+ # Actions are pinned to commit SHAs, not tags: a tag is mutable, and these
46
+ # three run with access to the workflow token before any test does.
47
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
48
+ with:
49
+ persist-credentials: false # nothing here pushes; no token left in .git/config
50
+ - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
51
+ with:
52
+ python-version: ${{ matrix.python }}
53
+ # Keyed on uv.lock, so the cache is only reused for the exact
54
+ # dependency set it was built from.
55
+ enable-cache: true
56
+ cache-dependency-glob: uv.lock
57
+ - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
58
+ with:
59
+ node-version: 22
60
+ # The matrix is only real if the interpreter actually changes with it.
61
+ # `.python-version` pins the local dev interpreter and could quietly win
62
+ # over the matrix, which would leave one leg testing the other's version
63
+ # and the README claiming two. So every `uv run` names the version, and
64
+ # this step fails the job if what arrives is not what was asked for.
65
+ - name: The interpreter is the one this leg is for
66
+ run: |
67
+ uv run --python ${{ matrix.python }} python -c "import sys; got = '%d.%d' % sys.version_info[:2]; want = '${{ matrix.python }}'; print('running', got); raise SystemExit(0 if got == want else f'expected {want}, got {got}')"
68
+
69
+ - name: Lint
70
+ run: uv run --python ${{ matrix.python }} ruff check
71
+ - name: Python tests (no database server, no network, no Garmin account)
72
+ shell: bash # one syntax for the empty $COVERAGE on every leg, Windows included
73
+ env:
74
+ COVERAGE: ${{ matrix.coverage && '--cov=runspector --cov-branch --cov-report=term:skip-covered --cov-fail-under=92' || '' }}
75
+ # Outside the tree: the data file holds absolute paths, and pii_gate
76
+ # (two steps down) reads every file in the checkout.
77
+ COVERAGE_FILE: ${{ runner.temp }}/.coverage
78
+ run: uv run --python ${{ matrix.python }} pytest -q $COVERAGE
79
+ - name: Frontend logic tests
80
+ run: node --test "web-tests/*.test.mjs"
81
+ - name: Eval suite is well-formed (dry run, no model calls)
82
+ run: uv run --python ${{ matrix.python }} python evals/run_evals.py --dry-run
83
+ - name: Nothing personal in the tree
84
+ run: uv run --python ${{ matrix.python }} python scripts/pii_gate.py
85
+
86
+ # The shipped frontend has no build step, so nothing reads it before a
87
+ # browser does. Biome lints it (rules and the reasons for the ones switched
88
+ # off: biome.jsonc). Once, not per leg: the result cannot depend on the OS.
89
+ frontend:
90
+ timeout-minutes: 5
91
+ runs-on: ubuntu-latest
92
+ steps:
93
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
94
+ with:
95
+ persist-credentials: false # nothing here pushes; no token left in .git/config
96
+ - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
97
+ with:
98
+ node-version: 22
99
+ package-manager-cache: false # no package.json, nothing to cache
100
+ - name: Biome
101
+ run: npx --yes @biomejs/biome@2.5.15 lint --error-on-warnings
102
+
103
+ # The suite above proves the code from inside the checkout. This job proves
104
+ # the PACKAGE from outside it: build the wheel, `uv tool install` it into a
105
+ # throw-away tool dir the way a user would, and drive the installed
106
+ # executable through the README's quick start - demo over HTTP, doctor,
107
+ # sync, login without a terminal, the MCP handshake. It is the "other
108
+ # computer" the author does not have: a machine that has never seen the
109
+ # source tree. Standard-library Python drives it, so it runs on whatever
110
+ # interpreter the runner has, and the installed tool brings its own.
111
+ install:
112
+ timeout-minutes: 15
113
+ strategy:
114
+ fail-fast: false
115
+ matrix:
116
+ os: [ubuntu-latest, windows-latest, macos-latest]
117
+ runs-on: ${{ matrix.os }}
118
+ steps:
119
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
120
+ with:
121
+ persist-credentials: false # nothing here pushes; no token left in .git/config
122
+ - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
123
+ with:
124
+ python-version: "3.12"
125
+ enable-cache: true
126
+ cache-dependency-glob: uv.lock
127
+ - name: Install the wheel as a user and use it as one
128
+ run: uv run --no-project python scripts/install_check.py
129
+
130
+ # The workflows themselves, audited: unpinned actions, template injection,
131
+ # credentials left in the checkout, caches feeding a release. Every workflow
132
+ # here runs with a token, and a Dependabot PR edits them weekly.
133
+ workflows:
134
+ timeout-minutes: 5
135
+ runs-on: ubuntu-latest
136
+ steps:
137
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
138
+ with:
139
+ persist-credentials: false # nothing here pushes; no token left in .git/config
140
+ - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
141
+ - name: zizmor
142
+ run: uvx zizmor@1.31.1 --min-severity medium .github/workflows
@@ -0,0 +1,143 @@
1
+ name: evals
2
+
3
+ # The coach-prompt evals (evals/run_evals.py) with a real model, once a week -
4
+ # but only in a week in which main moved: an unchanged prompt on an unchanged
5
+ # model measures the weather and spends the subscription for it. By hand
6
+ # (workflow_dispatch) it always runs.
7
+ #
8
+ # The model calls go through the maintainer's Claude subscription: the secret
9
+ # CLAUDE_CODE_OAUTH_TOKEN (from `claude setup-token`, valid one year) lives in
10
+ # the environment `evals`, which only `main` may use. Forks and pull requests
11
+ # never see it, and the job that decides whether to run does not touch it, so
12
+ # a quiet week spends nothing. The result is the job summary and an artifact;
13
+ # evals/RESULTS.md and the README badge stay a local, committed record.
14
+ #
15
+ # Renew the token:
16
+ # claude setup-token
17
+ # gh secret set CLAUDE_CODE_OAUTH_TOKEN --env evals -R edxleon/runspector
18
+ # gh variable set CLAUDE_TOKEN_CREATED --body YYYY-MM-DD -R edxleon/runspector
19
+
20
+ on:
21
+ schedule:
22
+ - cron: "41 5 * * 1" # Mondays, 05:41 UTC - after the sweep
23
+ workflow_dispatch:
24
+
25
+ concurrency:
26
+ group: evals
27
+ cancel-in-progress: false
28
+
29
+ permissions:
30
+ contents: read
31
+
32
+ jobs:
33
+ changed:
34
+ runs-on: ubuntu-latest
35
+ timeout-minutes: 5
36
+ outputs:
37
+ run: ${{ steps.check.outputs.run }}
38
+ steps:
39
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
40
+ with:
41
+ persist-credentials: false # nothing here pushes; no token left in .git/config
42
+ fetch-depth: 0 # the last week of history, not one commit
43
+ - name: Did main move in the last 7 days?
44
+ id: check
45
+ env:
46
+ EVENT: ${{ github.event_name }}
47
+ run: |
48
+ commits=$(git log --since="7 days ago" --format='%h %s' HEAD)
49
+ if [ "$EVENT" = "workflow_dispatch" ]; then
50
+ echo "run=true" >> "$GITHUB_OUTPUT"
51
+ echo "started by hand - the evals run"
52
+ elif [ -n "$commits" ]; then
53
+ echo "run=true" >> "$GITHUB_OUTPUT"
54
+ echo "commits on main in the last 7 days:"; echo "$commits"
55
+ else
56
+ echo "run=false" >> "$GITHUB_OUTPUT"
57
+ echo "no commit on main in the last 7 days - no evals, nothing spent" | tee -a "$GITHUB_STEP_SUMMARY"
58
+ fi
59
+
60
+ evals:
61
+ needs: changed
62
+ if: needs.changed.outputs.run == 'true'
63
+ runs-on: ubuntu-latest
64
+ environment: evals
65
+ # 29 cases at ~20 s for the answer and ~10 s for the judge: about 15 min.
66
+ timeout-minutes: 45
67
+ steps:
68
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
69
+ with:
70
+ persist-credentials: false # nothing here pushes; no token left in .git/config
71
+ - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
72
+ with:
73
+ python-version: "3.12"
74
+ # No cache in the one job that holds the token: a cache any workflow
75
+ # run can write must not feed a step that sees it.
76
+ enable-cache: false
77
+ - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
78
+ with:
79
+ node-version: 22
80
+ package-manager-cache: false # same reason as above
81
+ # Pinned: the version the committed evals/RESULTS.md was produced with.
82
+ # A new CLI is a deliberate bump here, not a surprise in a red run.
83
+ - name: Claude Code CLI
84
+ run: npm install -g @anthropic-ai/claude-code@2.1.312
85
+
86
+ - name: How long the token has left
87
+ env:
88
+ CREATED: ${{ vars.CLAUDE_TOKEN_CREATED }}
89
+ run: |
90
+ python3 - <<'EOF'
91
+ import datetime as dt, os
92
+ created = os.environ.get("CREATED", "").strip()
93
+ if not created:
94
+ print("::notice::set CLAUDE_TOKEN_CREATED (gh variable set ...) to be warned before the token expires")
95
+ raise SystemExit(0)
96
+ expires = dt.date.fromisoformat(created) + dt.timedelta(days=365)
97
+ left = (expires - dt.date.today()).days
98
+ print(f"Claude token created {created}, expires {expires} ({left} days)")
99
+ if left <= 30:
100
+ print(f"::warning::the Claude token expires on {expires} ({left} days) - renew it, see the head of evals.yml")
101
+ EOF
102
+
103
+ # One call before the suite: an expired or revoked token is one clear line, not
104
+ # every case red with the same auth error.
105
+ - name: The token works
106
+ env:
107
+ CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
108
+ run: |
109
+ if [ -z "$CLAUDE_CODE_OAUTH_TOKEN" ]; then
110
+ echo "::error::no CLAUDE_CODE_OAUTH_TOKEN in the environment 'evals'"; exit 1
111
+ fi
112
+ if ! out=$(echo "Reply with the single word OK." |
113
+ claude --print --safe-mode --tools "" --model sonnet 2>&1); then
114
+ echo "::error::Claude refused the token (expired, revoked or out of quota) - renew: claude setup-token, then gh secret set CLAUDE_CODE_OAUTH_TOKEN --env evals -R edxleon/runspector"
115
+ echo "$out" | tail -n 3
116
+ exit 1
117
+ fi
118
+ echo "token ok"
119
+
120
+ - name: Evals
121
+ env:
122
+ CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
123
+ # pyyaml comes from the dev group in uv.lock, hash-checked (--locked):
124
+ # `--with pyyaml` would pull whatever PyPI serves today into the one
125
+ # job that holds the token.
126
+ run: uv run --python 3.12 --locked python evals/run_evals.py
127
+
128
+ - name: Result as the job summary
129
+ if: always()
130
+ run: |
131
+ if [ -f evals/RESULTS.md ]; then cat evals/RESULTS.md >> "$GITHUB_STEP_SUMMARY"; fi
132
+
133
+ # The answers and the judge's reasons of every case: what a red case is
134
+ # read from. Synthetic athletes only - nothing personal is in them.
135
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
136
+ if: always()
137
+ with:
138
+ name: evals-${{ github.run_number }}
139
+ path: |
140
+ evals/RESULTS.md
141
+ evals/last-run.json
142
+ retention-days: 30
143
+ if-no-files-found: ignore
@@ -0,0 +1,126 @@
1
+ name: mutation
2
+
3
+ # Mutation testing of the rule modules (`[tool.mutmut]` in pyproject.toml), once
4
+ # a week. 93 % branch coverage says the tests RUN the rules; this says whether
5
+ # they would NOTICE a rule changing: mutmut flips a comparison, a constant, an
6
+ # `and` into an `or`, runs the tests that cover that function, and a mutant no
7
+ # test kills is a change to the coach's decisions that would ship green.
8
+ #
9
+ # Code written with an AI and tested by the same AI tends to test what the code
10
+ # does rather than what it should do; a survivor is where that shows. It is
11
+ # the bug hunt, like sweep.yml, not a gate: survivors are expected, and the
12
+ # useful ones become a scenario test in tests/test_what_comes_next.py, red
13
+ # against the mutant first. Linux only: mutmut needs a clean fork().
14
+ #
15
+ # One job per module, side by side: all ~5,600 mutants on one 4-core runner
16
+ # do not fit in three hours. Measured per module, 4 workers in a Linux
17
+ # container (v0.4.1): habits 601 mutants in 4 min, planning 1,559 in 23, plan
18
+ # 2,387 in 26; the summary 1-2 min after that.
19
+
20
+ on:
21
+ schedule:
22
+ - cron: "23 2 * * 1" # Mondays, 02:23 UTC
23
+ workflow_dispatch:
24
+
25
+ concurrency:
26
+ group: mutation
27
+ cancel-in-progress: false # a run by hand waits; it does not cancel the Monday run
28
+
29
+ permissions:
30
+ contents: read
31
+
32
+ jobs:
33
+ mutation:
34
+ strategy:
35
+ fail-fast: false
36
+ matrix:
37
+ module: [logic, planning, plan, habits]
38
+ runs-on: ubuntu-latest
39
+ timeout-minutes: 150
40
+ steps:
41
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
42
+ with:
43
+ persist-credentials: false # nothing here pushes; no token left in .git/config
44
+ - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
45
+ with:
46
+ python-version: "3.12"
47
+ enable-cache: true
48
+ cache-dependency-glob: uv.lock
49
+ # The suite runs the shipped logic.js through node (test_js_python_contract).
50
+ - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
51
+ with:
52
+ node-version: 22
53
+ package-manager-cache: false
54
+
55
+ # mutmut and its dependencies come from the `mutation` group in uv.lock,
56
+ # hash-checked: `--with` would resolve them fresh from PyPI every week,
57
+ # into a job whose uv cache ci.yml and sweep.yml restore.
58
+ # Red only when mutmut itself cannot run (the clean suite fails in its
59
+ # copy of the tree) - surviving mutants are the result, not an error.
60
+ - name: Mutate the rule modules
61
+ # Its own limit, under the job's: the summary and the artifact still
62
+ # run after a cancel, and mutmut has saved every result so far.
63
+ timeout-minutes: 120
64
+ env:
65
+ MODULE: ${{ matrix.module }}
66
+ run: uv run --python 3.12 --locked --group mutation mutmut run --max-children 4 "runspector.$MODULE.*"
67
+
68
+ - name: Survivors as the job summary
69
+ if: always()
70
+ env:
71
+ MODULE: ${{ matrix.module }}
72
+ run: |
73
+ # `mutmut results` lists every mutant not killed, of every module (the
74
+ # other jobs' as "not checked"). Only this module's `survived` is a
75
+ # test that is missing; `timeout` and `no tests` are counted apart.
76
+ uv run --python 3.12 --locked --group mutation mutmut results \
77
+ | grep -E "^ *runspector\.$MODULE\." > results.txt || true
78
+ grep -E ': survived$' results.txt > survivors.txt || true
79
+ {
80
+ echo "## $MODULE.py - surviving mutants: $(wc -l < survivors.txt)"
81
+ echo
82
+ echo "Not survived, not killed: $(grep -cvE ': survived$' results.txt || true)"
83
+ echo "($(cut -d: -f2 results.txt | grep -vx ' survived' | sort | uniq -c | tr '\n' ' '))"
84
+ echo
85
+ echo "Per function (the full diffs are in the artifact):"
86
+ echo '```'
87
+ sed -E 's/__mutmut_[0-9]+.*//' survivors.txt | sort | uniq -c | sort -nr
88
+ echo '```'
89
+ } >> "$GITHUB_STEP_SUMMARY"
90
+ # The diffs in one pass: `mutmut show` re-parses the whole mutated
91
+ # module per call (12 s for plan.py), which for a few hundred
92
+ # survivors outlasts the job. The mutated file holds every mutant as
93
+ # `<name>__mutmut_N` next to `<name>__mutmut_orig`.
94
+ python3 - survivors.txt > survivors.diff <<'PY'
95
+ import ast, difflib, os, sys
96
+
97
+ module = os.environ["MODULE"]
98
+ path = f"mutants/src/runspector/{module}.py"
99
+ source = open(path, encoding="utf-8").read()
100
+ funcs = {n.name: n for n in ast.walk(ast.parse(source))
101
+ if isinstance(n, ast.FunctionDef | ast.AsyncFunctionDef)}
102
+ for line in open(sys.argv[1], encoding="utf-8"):
103
+ key = line.split(":")[0].strip()
104
+ name = key.removeprefix(f"runspector.{module}.")
105
+ base = name.rpartition("__mutmut_")[0]
106
+ orig, mutant = funcs.get(base + "__mutmut_orig"), funcs.get(name)
107
+ if orig is None or mutant is None:
108
+ print(f"# {key}: not in {path}\n")
109
+ continue
110
+ a = ast.get_source_segment(source, orig).replace(base + "__mutmut_orig", base)
111
+ b = ast.get_source_segment(source, mutant).replace(name, base)
112
+ print(f"# {key}")
113
+ print("\n".join(difflib.unified_diff(a.splitlines(), b.splitlines(), "original",
114
+ "mutant", lineterm="", n=2)) + "\n")
115
+ PY
116
+
117
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
118
+ if: always()
119
+ with:
120
+ name: mutation-${{ github.run_number }}-${{ matrix.module }}
121
+ path: |
122
+ results.txt
123
+ survivors.txt
124
+ survivors.diff
125
+ retention-days: 30
126
+ if-no-files-found: ignore
@@ -0,0 +1,188 @@
1
+ name: release
2
+
3
+ # A pushed tag `vX.Y.Z` becomes the GitHub release, with the wheel and the
4
+ # sdist built HERE and signed with a build-provenance attestation, the same
5
+ # files on PyPI, and the MCP registry entry pointing at them. `build` and
6
+ # `test` run without rights; `release`, `pypi` and `mcp-registry` hold only the
7
+ # rights their own step needs and run no project code. Until now a
8
+ # release was four hand steps on one laptop (bump, build, create, upload), and
9
+ # nothing checked that the tag, `pyproject.toml` and `__version__` agree - a
10
+ # wheel named 0.3.0 on a v0.3.1 release would have passed.
11
+ #
12
+ # git tag v0.3.2 && git push origin v0.3.2
13
+ #
14
+ # The notes come from `docs/releases/<tag>.md` when it exists, else GitHub
15
+ # generates them from the commits. A release that already exists (created by
16
+ # hand) keeps its notes and gets the files attached.
17
+
18
+ on:
19
+ push:
20
+ tags: ["v*.*.*"]
21
+
22
+ concurrency:
23
+ group: release-${{ github.ref }}
24
+ cancel-in-progress: false
25
+
26
+ permissions:
27
+ contents: read
28
+
29
+ jobs:
30
+ # The suite, with no rights at all. Everything the tests import - this code
31
+ # and every dependency - runs here, so it must not run where the job can
32
+ # mint an OIDC token or write a release.
33
+ test:
34
+ runs-on: ubuntu-latest
35
+ timeout-minutes: 15
36
+ steps:
37
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
38
+ with:
39
+ persist-credentials: false # nothing here pushes; no token left in .git/config
40
+ - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
41
+ with:
42
+ python-version: "3.12"
43
+ enable-cache: false # no cache anywhere on the way to a release
44
+ # The Python suite runs the shipped logic.js through node
45
+ # (tests/test_js_python_contract.py): the same node as ci.yml.
46
+ - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
47
+ with:
48
+ node-version: 22
49
+ package-manager-cache: false
50
+ - name: The tag, pyproject.toml, __version__ and server.json name the same version
51
+ env:
52
+ TAG: ${{ github.ref_name }}
53
+ run: |
54
+ uv run --python 3.12 --locked python - <<'EOF'
55
+ import json, os, re, tomllib
56
+ tag = os.environ["TAG"].removeprefix("v")
57
+ project = tomllib.load(open("pyproject.toml", "rb"))["project"]["version"]
58
+ init = re.search(r'__version__ = "([^"]+)"', open("src/runspector/__init__.py").read())[1]
59
+ # The registry entry pins the PyPI version a client installs: a stale
60
+ # one would hand every new user the previous release.
61
+ server = json.load(open("server.json"))
62
+ listed = [server["version"], *(p["version"] for p in server["packages"])]
63
+ print(f"tag {tag}, pyproject {project}, __version__ {init}, server.json {listed}")
64
+ raise SystemExit(0 if {tag, project, init, *listed} == {tag}
65
+ else "versions disagree - bump all of them, re-tag")
66
+ EOF
67
+ # The matrix in ci.yml ran on this commit when it reached main; this is
68
+ # the short version again, so a tag on a commit that never went through
69
+ # CI (or a red one) cannot become a release.
70
+ - name: Lint and tests
71
+ run: |
72
+ uv run --python 3.12 --locked ruff check
73
+ uv run --python 3.12 --locked pytest -q
74
+ uv run --python 3.12 --locked python scripts/pii_gate.py
75
+
76
+ # The package, built with no rights: `uv build` runs the build backend
77
+ # (hatchling, pinned exactly in pyproject.toml) and nothing of this project.
78
+ # Built before anything else touches the tree, and handed on as an artifact.
79
+ build:
80
+ runs-on: ubuntu-latest
81
+ timeout-minutes: 10
82
+ steps:
83
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
84
+ with:
85
+ persist-credentials: false # nothing here pushes; no token left in .git/config
86
+ - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
87
+ with:
88
+ python-version: "3.12"
89
+ enable-cache: false # no cache anywhere on the way to a release
90
+ - name: Build
91
+ run: uv build
92
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
93
+ with:
94
+ name: dist
95
+ path: dist/
96
+ retention-days: 1
97
+ if-no-files-found: error
98
+
99
+ # Attest and publish - no Python, no project code, no dependency runs here,
100
+ # only the checkout (for the notes file), GitHub's attest action and `gh`.
101
+ release:
102
+ needs: [build, test]
103
+ runs-on: ubuntu-latest
104
+ timeout-minutes: 10
105
+ permissions:
106
+ contents: write # create the release, upload its files
107
+ id-token: write # the attestation's OIDC identity
108
+ attestations: write # store the attestation
109
+ steps:
110
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
111
+ with:
112
+ persist-credentials: false # nothing here pushes; no token left in .git/config
113
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
114
+ with:
115
+ name: dist
116
+ path: dist/
117
+
118
+ - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
119
+ with:
120
+ subject-path: dist/*
121
+
122
+ - name: Release
123
+ env:
124
+ GH_TOKEN: ${{ github.token }}
125
+ TAG: ${{ github.ref_name }}
126
+ run: |
127
+ if gh release view "$TAG" >/dev/null 2>&1; then
128
+ gh release upload "$TAG" dist/* --clobber
129
+ elif [ -f "docs/releases/$TAG.md" ]; then
130
+ gh release create "$TAG" dist/* --verify-tag --title "$TAG" --notes-file "docs/releases/$TAG.md"
131
+ else
132
+ gh release create "$TAG" dist/* --verify-tag --title "$TAG" --generate-notes
133
+ fi
134
+
135
+ # PyPI through a trusted publisher: no token exists anywhere, PyPI trusts the
136
+ # OIDC identity of this workflow in the `pypi` environment. Like `release`, it
137
+ # runs no project code and uploads the files `build` made. After `release`, not
138
+ # beside it: a version on PyPI can never be replaced, so it goes out only once
139
+ # the attestation and the GitHub release for exactly these files exist.
140
+ pypi:
141
+ needs: [release]
142
+ runs-on: ubuntu-latest
143
+ timeout-minutes: 10
144
+ environment: pypi
145
+ permissions:
146
+ id-token: write # the trusted-publisher identity, nothing else
147
+ steps:
148
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
149
+ with:
150
+ name: dist
151
+ path: dist/
152
+ - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
153
+
154
+ # The MCP registry entry points at the PyPI package and checks the `mcp-name`
155
+ # line in its README, so it runs after `pypi`. mcp-publisher is a downloaded
156
+ # binary in a job that can mint an OIDC token: pinned to a version and checked
157
+ # against the hash taken when it was pinned, so nothing floats in here.
158
+ mcp-registry:
159
+ needs: [pypi]
160
+ runs-on: ubuntu-latest
161
+ timeout-minutes: 15
162
+ permissions:
163
+ contents: read
164
+ id-token: write # the registry's GitHub OIDC login for io.github.edxleon/*
165
+ env:
166
+ MCP_PUBLISHER_VERSION: v1.8.1
167
+ MCP_PUBLISHER_SHA256: a06c9096dcb9727c13555b6be26c7effa707b01f06a4c561ba7a3635443cf2cc
168
+ steps:
169
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
170
+ with:
171
+ persist-credentials: false # nothing here pushes; no token left in .git/config
172
+ - name: mcp-publisher, verified
173
+ run: |
174
+ curl -fsSL -o mcp-publisher.tar.gz \
175
+ "https://github.com/modelcontextprotocol/registry/releases/download/${MCP_PUBLISHER_VERSION}/mcp-publisher_linux_amd64.tar.gz"
176
+ echo "${MCP_PUBLISHER_SHA256} mcp-publisher.tar.gz" | sha256sum -c -
177
+ tar -xzf mcp-publisher.tar.gz mcp-publisher
178
+ # PyPI serves a fresh upload's README a little after the upload returns,
179
+ # and the registry reads it from there - so a few tries, not one.
180
+ - name: Publish server.json
181
+ run: |
182
+ ./mcp-publisher login github-oidc
183
+ for attempt in 1 2 3 4 5 6; do
184
+ ./mcp-publisher publish && exit 0
185
+ echo "attempt $attempt failed - PyPI may not show the new release yet"
186
+ sleep 30
187
+ done
188
+ exit 1