Stevesolun commited on
Commit
31e282f
·
verified ·
1 Parent(s): 15c40c7

Sync ctx a4e14d3

Browse files

GitHub commit: a4e14d3ac2e5af5324d58ff178ce1a9da78cda63

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +2 -4
  2. .githooks/pre-commit +47 -44
  3. .github/workflows/clean-host-contract.yml +35 -35
  4. .github/workflows/docs.yml +88 -73
  5. .github/workflows/huggingface-sync.yml +108 -0
  6. .github/workflows/publish.yml +481 -475
  7. .github/workflows/test.yml +750 -704
  8. .github/workflows/xdist-experiment.yml +90 -90
  9. .gitignore +103 -103
  10. .hypothesis/.gitignore +0 -9
  11. .hypothesis/constants/0019c6a267d025e1 +0 -4
  12. .hypothesis/constants/004e722d2b39195a +0 -4
  13. .hypothesis/constants/009298dc5190c8bd +0 -4
  14. .hypothesis/constants/010258c38047fd64 +0 -4
  15. .hypothesis/constants/017531f0e829ce4f +0 -4
  16. .hypothesis/constants/0197b3c42bd36715 +0 -4
  17. .hypothesis/constants/0199ac9602548c81 +0 -4
  18. .hypothesis/constants/01e01103d68a3193 +0 -4
  19. .hypothesis/constants/0213343f1393a3fa +0 -4
  20. .hypothesis/constants/031f85f1c11bb00b +0 -4
  21. .hypothesis/constants/03a0bc33c3be41ac +0 -4
  22. .hypothesis/constants/03d65cf548ce3b23 +0 -4
  23. .hypothesis/constants/041b82baf95116f0 +0 -4
  24. .hypothesis/constants/044c9664c732c0e2 +0 -4
  25. .hypothesis/constants/0570a7c52cd987f9 +0 -4
  26. .hypothesis/constants/0628f2618034526e +0 -4
  27. .hypothesis/constants/068c9e16d4886008 +0 -4
  28. .hypothesis/constants/06a795a0e79c102f +0 -4
  29. .hypothesis/constants/072d43d60d40fd51 +0 -4
  30. .hypothesis/constants/0736e2d4acddd01a +0 -4
  31. .hypothesis/constants/07475514dc0bbadb +0 -4
  32. .hypothesis/constants/075aa0dd31e12c03 +0 -4
  33. .hypothesis/constants/084c48810f649441 +0 -4
  34. .hypothesis/constants/08a45b14b7a1aa82 +0 -4
  35. .hypothesis/constants/0964dd49257e9b00 +0 -4
  36. .hypothesis/constants/0967afddd3cfffcc +0 -4
  37. .hypothesis/constants/09a20f6b52e6dcc7 +0 -4
  38. .hypothesis/constants/09ae28b390a241d0 +0 -4
  39. .hypothesis/constants/09bba2fe6bc8792d +0 -4
  40. .hypothesis/constants/09e51e4d7e8d7e87 +0 -4
  41. .hypothesis/constants/0a6403dce3b6e280 +0 -4
  42. .hypothesis/constants/0ac765fb060691b5 +0 -4
  43. .hypothesis/constants/0b1d2411e5eaa76a +0 -4
  44. .hypothesis/constants/0bb70d1826311e40 +0 -4
  45. .hypothesis/constants/0bc3b2f77e74ecd9 +0 -4
  46. .hypothesis/constants/0c23327e39eaa492 +0 -4
  47. .hypothesis/constants/0c347c1a609186cf +0 -4
  48. .hypothesis/constants/0c7b341cd0f9acb2 +0 -4
  49. .hypothesis/constants/0cfa12dfbf9e9abb +0 -4
  50. .hypothesis/constants/0d65a410db824952 +0 -4
.gitattributes CHANGED
@@ -1,4 +1,2 @@
1
- graph/wiki-graph.tar.gz filter=lfs diff=lfs merge=lfs -text
2
- graph/wiki-graph-runtime.tar.gz filter=lfs diff=lfs merge=lfs -text
3
- .hypothesis/unicode_data/14.0.0/charmap.json.gz filter=lfs diff=lfs merge=lfs -text
4
- graph/skills-sh-catalog.json.gz filter=lfs diff=lfs merge=lfs -text
 
1
+ graph/wiki-graph.tar.gz filter=lfs diff=lfs merge=lfs -text
2
+ graph/wiki-graph-runtime.tar.gz filter=lfs diff=lfs merge=lfs -text
 
 
.githooks/pre-commit CHANGED
@@ -1,44 +1,47 @@
1
- #!/usr/bin/env bash
2
- # pre-commit -- refresh cheap repo stats without leaking local wiki state.
3
- #
4
- # Enable with: git config core.hooksPath .githooks
5
- # Disable with: git config --unset core.hooksPath
6
- #
7
- # This hook intentionally does not rebuild graph/wiki artifacts from
8
- # ~/.claude/skill-wiki. That directory can contain private local entities.
9
- # Release graph artifacts must be created by explicit, reproducible commands
10
- # and staged by the user or CI.
11
-
12
- set -u
13
-
14
- REPO_ROOT="$(git rev-parse --show-toplevel)"
15
- UPDATER="$REPO_ROOT/src/update_repo_stats.py"
16
-
17
- PYTHON="${PYTHON:-python3}"
18
- command -v "$PYTHON" >/dev/null 2>&1 || PYTHON="python"
19
- STATS_TIMEOUT="${CTX_REPO_STATS_TIMEOUT:-240s}"
20
-
21
- STAGED_CHANGES=$(git diff --cached --name-only)
22
- log() { echo "[pre-commit] $*" >&2; }
23
-
24
- if echo "$STAGED_CHANGES" | grep -qE '^(README\.md|docs/index\.md|graph/(wiki-graph\.tar\.gz|communities\.json|skills-sh-catalog\.json\.gz)|src/update_repo_stats\.py)$' ; then
25
- if [[ -f "$UPDATER" ]]; then
26
- STATS_CMD=("$PYTHON" "$UPDATER")
27
- if command -v timeout >/dev/null 2>&1; then
28
- STATS_CMD=(timeout "$STATS_TIMEOUT" "${STATS_CMD[@]}")
29
- fi
30
- if ! "${STATS_CMD[@]}" 2> >(sed 's/^/[pre-commit stats] /' >&2); then
31
- log "stats updater failed or timed out; continuing without README refresh"
32
- elif ! git diff --quiet -- README.md docs/index.md; then
33
- git add README.md docs/index.md
34
- log "README.md and docs/index.md refreshed and re-staged"
35
- fi
36
- fi
37
- fi
38
-
39
- if echo "$STAGED_CHANGES" | grep -qE '^(skills|agents|harnesses|imported-skills|graph/skills-sh-catalog\.json\.gz)' ; then
40
- log "entity source changed; graph/wiki tarball not rebuilt by pre-commit"
41
- log "run the explicit graph release command and stage graph/wiki-graph.tar.gz if needed"
42
- fi
43
-
44
- exit 0
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # pre-commit -- refresh cheap repo stats without leaking local wiki state.
3
+ #
4
+ # Enable with: git config core.hooksPath .githooks
5
+ # Disable with: git config --unset core.hooksPath
6
+ #
7
+ # This hook intentionally does not rebuild graph/wiki artifacts from
8
+ # ~/.claude/skill-wiki. That directory can contain private local entities.
9
+ # Release graph artifacts must be created by explicit, reproducible commands
10
+ # and staged by the user or CI.
11
+
12
+ set -u
13
+
14
+ REPO_ROOT="$(git rev-parse --show-toplevel)"
15
+ UPDATER="$REPO_ROOT/src/update_repo_stats.py"
16
+
17
+ PYTHON="${PYTHON:-python3}"
18
+ command -v "$PYTHON" >/dev/null 2>&1 || PYTHON="python"
19
+ STATS_TIMEOUT="${CTX_REPO_STATS_TIMEOUT:-240s}"
20
+
21
+ STAGED_CHANGES=$(git diff --cached --name-only)
22
+ log() { echo "[pre-commit] $*" >&2; }
23
+
24
+ if echo "$STAGED_CHANGES" | grep -qE '^(README\.md|docs/(index|knowledge-graph|catalog)\.md|graph/(wiki-graph\.tar\.gz|communities\.json|skills-sh-catalog\.json\.gz)|src/update_repo_stats\.py|src/tests/.*\.py)$' ; then
25
+ if [[ -f "$UPDATER" ]]; then
26
+ STATS_CMD=("$PYTHON" "$UPDATER")
27
+ if echo "$STAGED_CHANGES" | grep -qE '^src/tests/.*\.py$' ; then
28
+ STATS_CMD=(env CTX_UPDATE_REPO_STATS_LIVE_TESTS=1 "${STATS_CMD[@]}")
29
+ fi
30
+ if command -v timeout >/dev/null 2>&1; then
31
+ STATS_CMD=(timeout "$STATS_TIMEOUT" "${STATS_CMD[@]}")
32
+ fi
33
+ if ! "${STATS_CMD[@]}" 2> >(sed 's/^/[pre-commit stats] /' >&2); then
34
+ log "stats updater failed or timed out; continuing without README refresh"
35
+ elif ! git diff --quiet -- README.md docs/index.md docs/knowledge-graph.md docs/catalog.md; then
36
+ git add README.md docs/index.md docs/knowledge-graph.md docs/catalog.md
37
+ log "README.md, docs/index.md, docs/knowledge-graph.md, and docs/catalog.md refreshed and re-staged"
38
+ fi
39
+ fi
40
+ fi
41
+
42
+ if echo "$STAGED_CHANGES" | grep -qE '^(skills|agents|harnesses|imported-skills|graph/skills-sh-catalog\.json\.gz)' ; then
43
+ log "entity source changed; graph/wiki tarball not rebuilt by pre-commit"
44
+ log "run the explicit graph release command and stage graph/wiki-graph.tar.gz if needed"
45
+ fi
46
+
47
+ exit 0
.github/workflows/clean-host-contract.yml CHANGED
@@ -1,35 +1,35 @@
1
- name: Clean Host Contract
2
-
3
- on:
4
- workflow_dispatch:
5
- schedule:
6
- - cron: "17 3 * * 1"
7
-
8
- env:
9
- FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
10
-
11
- permissions:
12
- contents: read
13
-
14
- jobs:
15
- clean-host-contract:
16
- name: Clean wheel install and A-Z contract
17
- runs-on: ubuntu-latest
18
- timeout-minutes: 25
19
-
20
- steps:
21
- - name: Checkout
22
- uses: actions/checkout@v5
23
-
24
- - name: Set up Python 3.12
25
- uses: actions/setup-python@v6
26
- with:
27
- python-version: "3.12"
28
- cache: pip
29
- cache-dependency-path: pyproject.toml
30
-
31
- - name: Upgrade pip
32
- run: python -m pip install --upgrade pip
33
-
34
- - name: Run clean-host contract
35
- run: python scripts/clean_host_contract.py --fast
 
1
+ name: Clean Host Contract
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ schedule:
6
+ - cron: "17 3 * * 1"
7
+
8
+ env:
9
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
10
+
11
+ permissions:
12
+ contents: read
13
+
14
+ jobs:
15
+ clean-host-contract:
16
+ name: Clean wheel install and A-Z contract
17
+ runs-on: ubuntu-latest
18
+ timeout-minutes: 25
19
+
20
+ steps:
21
+ - name: Checkout
22
+ uses: actions/checkout@v5
23
+
24
+ - name: Set up Python 3.12
25
+ uses: actions/setup-python@v6
26
+ with:
27
+ python-version: "3.12"
28
+ cache: pip
29
+ cache-dependency-path: pyproject.toml
30
+
31
+ - name: Upgrade pip
32
+ run: python -m pip install --upgrade pip
33
+
34
+ - name: Run clean-host contract
35
+ run: python scripts/clean_host_contract.py --fast
.github/workflows/docs.yml CHANGED
@@ -1,73 +1,88 @@
1
- name: Deploy docs to GitHub Pages
2
-
3
- # Build the MkDocs Material site and publish to GitHub Pages on every push
4
- # to main. Also deployable on demand via the workflow_dispatch trigger.
5
-
6
- on:
7
- push:
8
- branches:
9
- - main
10
- paths:
11
- - "docs/**"
12
- - "mkdocs.yml"
13
- - "requirements-docs.txt"
14
- - ".github/workflows/docs.yml"
15
- workflow_dispatch:
16
-
17
- env:
18
- FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
19
-
20
- permissions:
21
- contents: read
22
-
23
- # Allow only one concurrent deploy; cancel in-progress runs on a new push.
24
- concurrency:
25
- group: "pages"
26
- cancel-in-progress: true
27
-
28
- jobs:
29
- build:
30
- name: Build site
31
- runs-on: ubuntu-latest
32
- steps:
33
- - name: Checkout
34
- uses: actions/checkout@v5
35
- with:
36
- fetch-depth: 0 # Required by mkdocs git-revision plugins if added.
37
-
38
- - name: Set up Python
39
- uses: actions/setup-python@v6
40
- with:
41
- python-version: "3.11"
42
- cache: "pip"
43
- cache-dependency-path: requirements-docs.txt
44
-
45
- - name: Install docs dependencies
46
- run: |
47
- python -m pip install --upgrade pip
48
- python -m pip install -r requirements-docs.txt
49
-
50
- - name: Build site (strict)
51
- run: |
52
- python -m mkdocs build --strict
53
-
54
- - name: Upload Pages artifact
55
- uses: actions/upload-pages-artifact@v5
56
- with:
57
- path: site
58
-
59
- deploy:
60
- name: Deploy to GitHub Pages
61
- needs: build
62
- runs-on: ubuntu-latest
63
- permissions:
64
- contents: read
65
- pages: write
66
- id-token: write
67
- environment:
68
- name: github-pages
69
- url: ${{ steps.deployment.outputs.page_url }}
70
- steps:
71
- - name: Deploy
72
- id: deployment
73
- uses: actions/deploy-pages@v5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Deploy docs to GitHub Pages
2
+
3
+ # Build the MkDocs Material site and publish to GitHub Pages on every push
4
+ # to main. Also deployable on demand via the workflow_dispatch trigger.
5
+
6
+ on:
7
+ push:
8
+ branches:
9
+ - main
10
+ paths:
11
+ - "docs/**"
12
+ - "mkdocs.yml"
13
+ - "requirements-docs.txt"
14
+ - ".github/workflows/docs.yml"
15
+ workflow_dispatch:
16
+
17
+ env:
18
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
19
+
20
+ permissions:
21
+ contents: read
22
+
23
+ # Allow only one concurrent deploy; cancel in-progress runs on a new push.
24
+ concurrency:
25
+ group: "pages"
26
+ cancel-in-progress: true
27
+
28
+ jobs:
29
+ build:
30
+ name: Build site
31
+ runs-on: ubuntu-latest
32
+ steps:
33
+ - name: Checkout
34
+ uses: actions/checkout@v5
35
+ with:
36
+ fetch-depth: 0 # Required by mkdocs git-revision plugins if added.
37
+
38
+ - name: Set up Python
39
+ uses: actions/setup-python@v6
40
+ with:
41
+ python-version: "3.11"
42
+ cache: "pip"
43
+ cache-dependency-path: requirements-docs.txt
44
+
45
+ - name: Install docs dependencies
46
+ run: |
47
+ python -m pip install --upgrade pip
48
+ python -m pip install -r requirements-docs.txt
49
+
50
+ - name: Build site (strict)
51
+ run: |
52
+ python -m mkdocs build --strict
53
+
54
+ - name: Archive Pages artifact
55
+ run: |
56
+ tar \
57
+ --dereference --hard-dereference \
58
+ --directory site \
59
+ -cvf "$RUNNER_TEMP/artifact.tar" \
60
+ --exclude=.git \
61
+ --exclude=.github \
62
+ --exclude=.[^/]* \
63
+ .
64
+
65
+ - name: Upload Pages artifact
66
+ uses: actions/upload-artifact@v4
67
+ with:
68
+ name: github-pages
69
+ path: ${{ runner.temp }}/artifact.tar
70
+ retention-days: 1
71
+ if-no-files-found: error
72
+ overwrite: true
73
+
74
+ deploy:
75
+ name: Deploy to GitHub Pages
76
+ needs: build
77
+ runs-on: ubuntu-latest
78
+ permissions:
79
+ contents: read
80
+ pages: write
81
+ id-token: write
82
+ environment:
83
+ name: github-pages
84
+ url: ${{ steps.deployment.outputs.page_url }}
85
+ steps:
86
+ - name: Deploy
87
+ id: deployment
88
+ uses: actions/deploy-pages@v5
.github/workflows/huggingface-sync.yml ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Sync Hugging Face
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ workflow_dispatch:
8
+
9
+ env:
10
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
11
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
12
+
13
+ permissions:
14
+ contents: read
15
+
16
+ concurrency:
17
+ group: "huggingface-sync-main"
18
+ cancel-in-progress: true
19
+
20
+ jobs:
21
+ sync:
22
+ name: Sync model repo
23
+ runs-on: ubuntu-latest
24
+ timeout-minutes: 60
25
+ steps:
26
+ - name: Checkout
27
+ uses: actions/checkout@v5
28
+ with:
29
+ fetch-depth: 0
30
+ lfs: false
31
+
32
+ - name: Skip when HF_TOKEN is not configured
33
+ if: ${{ env.HF_TOKEN == '' }}
34
+ run: |
35
+ echo "::notice title=Hugging Face sync skipped::Set the HF_TOKEN repository secret to publish Stevesolun/ctx automatically."
36
+
37
+ - name: Classify sync scope
38
+ id: scope
39
+ if: ${{ env.HF_TOKEN != '' }}
40
+ shell: bash
41
+ env:
42
+ BEFORE_SHA: ${{ github.event.before }}
43
+ EVENT_NAME: ${{ github.event_name }}
44
+ HEAD_SHA: ${{ github.sha }}
45
+ run: |
46
+ set -euo pipefail
47
+ if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
48
+ echo "sync_mode=full" >> "$GITHUB_OUTPUT"
49
+ exit 0
50
+ fi
51
+ BASE="$BEFORE_SHA"
52
+ if [[ -z "$BASE" || "$BASE" =~ ^0+$ ]] || ! git cat-file -e "$BASE^{commit}" 2>/dev/null; then
53
+ BASE="$(git rev-parse "$HEAD_SHA^")"
54
+ fi
55
+ git diff --name-only "$BASE" "$HEAD_SHA" > changed-files.txt
56
+ python - <<'PY' >> "$GITHUB_OUTPUT"
57
+ from pathlib import Path
58
+
59
+ files = [
60
+ line.strip().replace("\\", "/")
61
+ for line in Path("changed-files.txt").read_text(encoding="utf-8").splitlines()
62
+ if line.strip()
63
+ ]
64
+ card_only_prefixes = (".github/", "docs/", "src/tests/")
65
+ card_only_files = {"README.md", "CHANGELOG.md"}
66
+ card_only = bool(files) and all(
67
+ path in card_only_files or path.startswith(card_only_prefixes)
68
+ for path in files
69
+ )
70
+ print(f"sync_mode={'card' if card_only else 'full'}")
71
+ PY
72
+
73
+ - name: Hydrate required graph artifacts from release assets
74
+ if: ${{ env.HF_TOKEN != '' && steps.scope.outputs.sync_mode == 'full' }}
75
+ env:
76
+ GH_TOKEN: ${{ github.token }}
77
+ run: |
78
+ latest_tag="$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName --jq .tagName)"
79
+ echo "Hydrating graph artifacts from release ${latest_tag}"
80
+ rm -f graph/wiki-graph.tar.gz graph/wiki-graph-runtime.tar.gz graph/skills-sh-catalog.json.gz
81
+ gh release download "$latest_tag" --repo "$GITHUB_REPOSITORY" --dir graph --pattern wiki-graph.tar.gz
82
+ gh release download "$latest_tag" --repo "$GITHUB_REPOSITORY" --dir graph --pattern wiki-graph-runtime.tar.gz
83
+ gh release download "$latest_tag" --repo "$GITHUB_REPOSITORY" --dir graph --pattern skills-sh-catalog.json.gz
84
+
85
+ - name: Set up Python
86
+ if: ${{ env.HF_TOKEN != '' }}
87
+ uses: actions/setup-python@v6
88
+ with:
89
+ python-version: "3.11"
90
+ cache: "pip"
91
+ cache-dependency-path: pyproject.toml
92
+
93
+ - name: Install sync dependencies
94
+ if: ${{ env.HF_TOKEN != '' }}
95
+ run: |
96
+ python -m pip install --upgrade pip
97
+ python -m pip install -e ".[dev]" huggingface_hub
98
+
99
+ - name: Sync Hugging Face model repo
100
+ if: ${{ env.HF_TOKEN != '' }}
101
+ env:
102
+ SYNC_MODE: ${{ steps.scope.outputs.sync_mode }}
103
+ run: |
104
+ if [[ "$SYNC_MODE" == "card" ]]; then
105
+ python scripts/sync_huggingface.py --repo . --repo-id Stevesolun/ctx --repo-type model --card-only
106
+ else
107
+ python scripts/sync_huggingface.py --repo . --repo-id Stevesolun/ctx --repo-type model
108
+ fi
.github/workflows/publish.yml CHANGED
@@ -1,475 +1,481 @@
1
- name: Publish to PyPI
2
-
3
- # Publish claude-ctx to PyPI on every tag that looks like a version
4
- # (v0.5.0, v0.5.0-rc1, v1.0.0, …). Uses PyPI Trusted Publishing —
5
- # no API token needed once the pending publisher is configured at
6
- # https://pypi.org/manage/account/publishing/.
7
- #
8
- # Also deployable on demand to TestPyPI via workflow_dispatch. Production
9
- # PyPI publishes must use a version tag push so graph release assets are
10
- # uploaded before the wheel is published.
11
-
12
- on:
13
- push:
14
- tags:
15
- - "v*"
16
- workflow_dispatch:
17
- inputs:
18
- repository:
19
- description: "PyPI repository (pypi or testpypi)"
20
- required: true
21
- default: "pypi"
22
- type: choice
23
- options:
24
- - pypi
25
- - testpypi
26
-
27
- env:
28
- FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
29
-
30
- permissions:
31
- contents: read
32
-
33
- jobs:
34
- build:
35
- name: Build sdist + wheel
36
- runs-on: ubuntu-latest
37
- permissions:
38
- contents: read
39
- outputs:
40
- graph_assets_available: ${{ steps.resolve_graph_lfs.outcome == 'success' }}
41
- steps:
42
- - name: Checkout
43
- uses: actions/checkout@v5
44
- with:
45
- lfs: false
46
-
47
- - name: Set up Python
48
- uses: actions/setup-python@v6
49
- with:
50
- python-version: "3.12"
51
-
52
- - name: Install release tooling
53
- run: |
54
- python -m pip install --upgrade pip
55
- python -m pip install ".[dev]" build packaging twine
56
-
57
- - name: Validate release target
58
- run: |
59
- python - <<'PY'
60
- import os
61
- import tomllib
62
- from packaging.version import Version
63
-
64
- event = os.environ["GITHUB_EVENT_NAME"]
65
- target_repository = os.environ.get("INPUT_REPOSITORY", "pypi")
66
- ref_type = os.environ.get("GITHUB_REF_TYPE", "")
67
- ref_name = os.environ["GITHUB_REF_NAME"]
68
-
69
- if event == "workflow_dispatch" and target_repository == "pypi":
70
- raise SystemExit(
71
- "Manual PyPI publish is disabled; push a version tag so "
72
- "graph release assets are uploaded before publishing"
73
- )
74
- if event == "workflow_dispatch" and target_repository == "testpypi":
75
- print(f"manual TestPyPI publish allowed from {ref_type}:{ref_name}")
76
- raise SystemExit(0)
77
-
78
- if ref_type != "tag":
79
- raise SystemExit(
80
- "PyPI release must run from a version tag; "
81
- f"got {ref_type}:{ref_name}"
82
- )
83
- tag = ref_name
84
- if not tag.startswith("v"):
85
- raise SystemExit(f"release tag must start with v: {tag}")
86
-
87
- with open("pyproject.toml", "rb") as fh:
88
- package_version = tomllib.load(fh)["project"]["version"]
89
-
90
- tag_version = str(Version(tag[1:]))
91
- normalized_package_version = str(Version(package_version))
92
- if tag_version != normalized_package_version:
93
- raise SystemExit(
94
- f"tag {tag!r} does not match pyproject version {package_version!r}"
95
- )
96
- print(f"release version {package_version} matches tag {tag}")
97
- PY
98
- env:
99
- INPUT_REPOSITORY: ${{ github.event.inputs.repository || 'pypi' }}
100
-
101
- - name: Reject already published PyPI version
102
- run: |
103
- python - <<'PY'
104
- import os
105
- import tomllib
106
- import urllib.error
107
- import urllib.request
108
-
109
- target_repository = os.environ.get("INPUT_REPOSITORY", "pypi")
110
- if target_repository != "pypi":
111
- print(f"skipping PyPI reuse check for {target_repository}")
112
- raise SystemExit(0)
113
-
114
- with open("pyproject.toml", "rb") as fh:
115
- project = tomllib.load(fh)["project"]
116
-
117
- name = project["name"]
118
- package_version = project["version"]
119
- url = f"https://pypi.org/pypi/{name}/{package_version}/json"
120
- try:
121
- with urllib.request.urlopen(url, timeout=15):
122
- raise SystemExit(
123
- f"{name} {package_version} already exists on PyPI; "
124
- "bump the version before publishing"
125
- )
126
- except urllib.error.HTTPError as exc:
127
- if exc.code == 404:
128
- print(f"{name} {package_version} is not present on PyPI")
129
- raise SystemExit(0)
130
- raise
131
- PY
132
- env:
133
- INPUT_REPOSITORY: ${{ github.event.inputs.repository || 'pypi' }}
134
-
135
- - name: Resolve release graph LFS artifacts
136
- id: resolve_graph_lfs
137
- env:
138
- GH_TOKEN: ${{ github.token }}
139
- TAG_NAME: ${{ github.ref_name }}
140
- run: |
141
- set -euo pipefail
142
- git lfs install --local
143
- if git lfs pull --include="graph/wiki-graph.tar.gz,graph/wiki-graph-runtime.tar.gz" --exclude=""; then
144
- exit 0
145
- fi
146
- echo "Git LFS download failed; trying matching prior release asset."
147
- python - <<'PY'
148
- import hashlib
149
- import json
150
- import os
151
- from pathlib import Path
152
- import subprocess
153
- import time
154
- import urllib.request
155
-
156
- repo = os.environ["GITHUB_REPOSITORY"]
157
- current_tag = os.environ.get("TAG_NAME", "")
158
- release_asset_wait_seconds = 300
159
- release_asset_poll_seconds = 10
160
-
161
- def load_releases() -> list[dict]:
162
- return json.loads(subprocess.check_output(
163
- ["gh", "api", f"repos/{repo}/releases?per_page=50"],
164
- text=True,
165
- ))
166
-
167
- def hydrate_from_release(path_name: str, hydrated_min_size: int) -> None:
168
- graph_tar = Path(path_name)
169
- pointer = graph_tar.read_text(encoding="utf-8", errors="replace")
170
- expected_oid = ""
171
- expected_size = 0
172
- for line in pointer.splitlines():
173
- if line.startswith("oid sha256:"):
174
- expected_oid = line.split(":", 1)[1].strip()
175
- elif line.startswith("size "):
176
- expected_size = int(line.split(" ", 1)[1].strip())
177
- if not expected_oid:
178
- if graph_tar.stat().st_size > hydrated_min_size:
179
- print(f"{graph_tar} is already hydrated")
180
- return
181
- raise SystemExit(f"{path_name} is neither hydrated nor an LFS pointer")
182
-
183
- deadline = time.monotonic() + release_asset_wait_seconds
184
- while True:
185
- candidates = []
186
- for release in load_releases():
187
- tag_name = str(release.get("tag_name") or "")
188
- is_graph_cache = tag_name.startswith("graph-artifacts-")
189
- if release.get("draft") or (
190
- release.get("prerelease") and not is_graph_cache
191
- ):
192
- continue
193
- if tag_name == current_tag:
194
- continue
195
- for asset in release.get("assets", []):
196
- if asset.get("name") != graph_tar.name:
197
- continue
198
- digest = str(asset.get("digest") or "")
199
- size = int(asset.get("size") or 0)
200
- if size != expected_size:
201
- continue
202
- if digest and digest != f"sha256:{expected_oid}":
203
- continue
204
- candidates.append((tag_name, asset))
205
-
206
- if candidates:
207
- break
208
- if time.monotonic() >= deadline:
209
- raise SystemExit(
210
- f"No previous release asset matches {path_name} "
211
- f"sha256:{expected_oid} size:{expected_size}"
212
- )
213
- print(
214
- f"Waiting for matching release asset {graph_tar.name} "
215
- f"sha256:{expected_oid} size:{expected_size}"
216
- )
217
- time.sleep(release_asset_poll_seconds)
218
-
219
- source_tag, asset = candidates[0]
220
- tmp = graph_tar.with_name(f"{graph_tar.name}.download")
221
- sha = hashlib.sha256()
222
- total = 0
223
- with urllib.request.urlopen(asset["browser_download_url"], timeout=300) as resp: # noqa: S310
224
- with tmp.open("wb") as fh:
225
- while True:
226
- chunk = resp.read(1024 * 1024)
227
- if not chunk:
228
- break
229
- sha.update(chunk)
230
- total += len(chunk)
231
- fh.write(chunk)
232
- actual_oid = sha.hexdigest()
233
- if actual_oid != expected_oid or total != expected_size:
234
- tmp.unlink(missing_ok=True)
235
- raise SystemExit(
236
- f"Downloaded {path_name} does not match LFS pointer: "
237
- f"sha256:{actual_oid} size:{total}"
238
- )
239
- tmp.replace(graph_tar)
240
- print(
241
- f"Hydrated {path_name} from {source_tag} release asset "
242
- f"sha256:{actual_oid} size:{total}"
243
- )
244
-
245
- hydrate_from_release("graph/wiki-graph.tar.gz", 100_000_000)
246
- hydrate_from_release("graph/wiki-graph-runtime.tar.gz", 10_000_000)
247
- PY
248
-
249
- - name: Validate release graph artifacts
250
- if: steps.resolve_graph_lfs.outcome == 'success'
251
- run: |
252
- python src/validate_graph_artifacts.py \
253
- --graph-dir graph \
254
- --deep \
255
- --min-nodes 100000 \
256
- --min-edges 2000000 \
257
- --min-skills-sh-nodes 89000 \
258
- --min-semantic-edges 1000000 \
259
- --expected-nodes 102928 \
260
- --expected-edges 2913960 \
261
- --expected-semantic-edges 1683193 \
262
- --expected-harness-nodes 207 \
263
- --expected-skills-sh-nodes 89471 \
264
- --expected-skills-sh-catalog-entries 89465 \
265
- --expected-skills-sh-converted 89465 \
266
- --expected-skill-pages 91464 \
267
- --expected-agent-pages 467 \
268
- --expected-mcp-pages 10790 \
269
- --expected-harness-pages 207 \
270
- --line-threshold 180 \
271
- --max-stage-lines 40
272
-
273
- - name: Validate README and docs stats
274
- if: steps.resolve_graph_lfs.outcome == 'success'
275
- run: python src/update_repo_stats.py --check
276
-
277
- - name: Static gates
278
- run: |
279
- python -m ruff check src hooks scripts
280
- python -m mypy src
281
-
282
- - name: Clean-host contract
283
- run: python scripts/clean_host_contract.py --fast
284
-
285
- - name: Release canary tests
286
- run: |
287
- python -m pytest -q --no-cov \
288
- src/tests/test_ci_classifier.py \
289
- src/tests/test_package_scaffold.py \
290
- src/tests/test_clean_host_contract.py \
291
- src/tests/test_alive_loop_e2e.py
292
-
293
- - name: Build distributions
294
- run: python -m build
295
-
296
- - name: Check distributions
297
- run: python -m twine check dist/*
298
-
299
- - name: Check distribution contents
300
- run: |
301
- python - <<'PY'
302
- import tarfile
303
- import zipfile
304
- from pathlib import Path
305
-
306
- wheel = next(Path("dist").glob("*.whl"))
307
- sdist = next(Path("dist").glob("*.tar.gz"))
308
-
309
- with zipfile.ZipFile(wheel) as zf:
310
- wheel_names = set(zf.namelist())
311
- required = {"ctx/config.json", "ctx/skill-registry.json"}
312
- missing = sorted(required - wheel_names)
313
- if missing:
314
- raise SystemExit(f"wheel missing packaged defaults: {missing}")
315
-
316
- with tarfile.open(sdist, "r:gz") as tf:
317
- names = set(tf.getnames())
318
- forbidden = ("/src/tests/", "/.claude/", "/.a5c/")
319
- leaked = sorted(name for name in names if any(part in name for part in forbidden))
320
- if leaked:
321
- raise SystemExit("sdist contains local/test artifacts:\n" + "\n".join(leaked[:20]))
322
- print(f"checked distribution contents: {wheel.name}, {sdist.name}")
323
- PY
324
-
325
- - name: Smoke install wheel
326
- run: |
327
- python -m venv .venv-smoke
328
- . .venv-smoke/bin/activate
329
- python -m pip install --upgrade pip
330
- wheel="$(python - <<'PY'
331
- from pathlib import Path
332
- print(next(Path("dist").glob("*.whl")))
333
- PY
334
- )"
335
- python -m pip install "$wheel"
336
- python -m pip check
337
- python - <<'PY'
338
- import tomllib
339
- from importlib.metadata import entry_points, version
340
-
341
- import ctx
342
- import ctx_config
343
-
344
- with open("pyproject.toml", "rb") as fh:
345
- expected_scripts = set(tomllib.load(fh)["project"]["scripts"])
346
-
347
- dist_version = version("claude-ctx")
348
- if ctx.__version__ != dist_version:
349
- raise SystemExit(
350
- f"ctx.__version__={ctx.__version__!r} != metadata {dist_version!r}"
351
- )
352
- if ctx_config.cfg.recommendation_top_k != 5:
353
- raise SystemExit("packaged default config was not loaded")
354
- scripts = {
355
- ep.name: ep for ep in entry_points(group="console_scripts")
356
- if ep.name == "ctx" or ep.name.startswith("ctx-")
357
- }
358
- missing = sorted(expected_scripts - set(scripts))
359
- extra = sorted(set(scripts) - expected_scripts)
360
- if missing or extra:
361
- raise SystemExit(
362
- "wheel console-script surface mismatch\n"
363
- f"missing: {missing}\n"
364
- f"extra: {extra}"
365
- )
366
- failures = []
367
- for ep in scripts.values():
368
- try:
369
- ep.load()
370
- except Exception as exc:
371
- failures.append(f"{ep.name}: {exc!r}")
372
- if failures:
373
- raise SystemExit("console script load failures:\n" + "\n".join(failures))
374
- unsafe_help = {"ctx-mcp-server"}
375
- safe_help = sorted(expected_scripts - unsafe_help)
376
- with open("ctx-console-help.txt", "w", encoding="utf-8", newline="\n") as fh:
377
- fh.write("\n".join(safe_help) + "\n")
378
- print(
379
- f"loaded {len(scripts)} ctx console scripts from wheel "
380
- f"{dist_version}; help-smoke={len(safe_help)}"
381
- )
382
- PY
383
- while IFS= read -r cmd; do
384
- cmd="${cmd%$'\r'}"
385
- [[ -z "$cmd" ]] && continue
386
- echo "help smoke: $cmd"
387
- "$cmd" --help >/dev/null
388
- done < ctx-console-help.txt
389
- python -m pip install "${wheel}[harness]"
390
- python - <<'PY'
391
- import litellm
392
-
393
- print(f"harness extra import ok: litellm {getattr(litellm, '__version__', 'unknown')}")
394
- PY
395
-
396
- - name: Upload dist artifact
397
- uses: actions/upload-artifact@v7
398
- with:
399
- name: dist
400
- path: dist/
401
-
402
- - name: Upload graph artifact bundle
403
- if: steps.resolve_graph_lfs.outcome == 'success'
404
- uses: actions/upload-artifact@v7
405
- with:
406
- name: graph-release-assets
407
- path: |
408
- graph/wiki-graph.tar.gz
409
- graph/wiki-graph-runtime.tar.gz
410
- graph/skills-sh-catalog.json.gz
411
- graph/communities.json
412
- graph/entity-overlays.jsonl
413
-
414
- release-assets:
415
- name: Upload graph release assets
416
- needs: build
417
- if: github.event_name == 'push' && needs.build.outputs.graph_assets_available == 'true'
418
- runs-on: ubuntu-latest
419
- permissions:
420
- contents: write
421
- steps:
422
- - name: Download graph artifact bundle
423
- uses: actions/download-artifact@v7
424
- with:
425
- name: graph-release-assets
426
- path: graph-release-assets
427
-
428
- - name: Upload graph assets to GitHub release
429
- env:
430
- GH_TOKEN: ${{ github.token }}
431
- TAG_NAME: ${{ github.ref_name }}
432
- run: |
433
- gh release view "$TAG_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1 \
434
- || gh release create "$TAG_NAME" \
435
- --repo "$GITHUB_REPOSITORY" \
436
- --title "$TAG_NAME" \
437
- --notes "ctx $TAG_NAME"
438
- gh release upload "$TAG_NAME" \
439
- --repo "$GITHUB_REPOSITORY" \
440
- graph-release-assets/wiki-graph.tar.gz \
441
- graph-release-assets/wiki-graph-runtime.tar.gz \
442
- graph-release-assets/skills-sh-catalog.json.gz \
443
- graph-release-assets/communities.json \
444
- graph-release-assets/entity-overlays.jsonl \
445
- --clobber
446
-
447
- publish:
448
- name: Publish to PyPI
449
- needs:
450
- - build
451
- - release-assets
452
- if: ${{ always() && needs.build.result == 'success' && (needs.release-assets.result == 'success' || (github.event_name == 'workflow_dispatch' && github.event.inputs.repository == 'testpypi')) }}
453
- runs-on: ubuntu-latest
454
- permissions:
455
- contents: read
456
- id-token: write # required for Trusted Publishing
457
- environment:
458
- name: pypi
459
- url: https://pypi.org/project/claude-ctx/
460
- steps:
461
- - name: Download dist artifact
462
- uses: actions/download-artifact@v7
463
- with:
464
- name: dist
465
- path: dist/
466
-
467
- - name: Publish to PyPI
468
- if: github.event_name == 'push'
469
- uses: pypa/gh-action-pypi-publish@release/v1
470
-
471
- - name: Publish to TestPyPI
472
- if: github.event_name == 'workflow_dispatch' && github.event.inputs.repository == 'testpypi'
473
- uses: pypa/gh-action-pypi-publish@release/v1
474
- with:
475
- repository-url: https://test.pypi.org/legacy/
 
 
 
 
 
 
 
1
+ name: Publish to PyPI
2
+
3
+ # Publish claude-ctx to PyPI on every tag that looks like a version
4
+ # (v0.5.0, v0.5.0-rc1, v1.0.0, …). Uses PyPI Trusted Publishing —
5
+ # no API token needed once the pending publisher is configured at
6
+ # https://pypi.org/manage/account/publishing/.
7
+ #
8
+ # Also deployable on demand to TestPyPI via workflow_dispatch. Production
9
+ # PyPI publishes must use a version tag push so graph release assets are
10
+ # uploaded before the wheel is published.
11
+
12
+ on:
13
+ push:
14
+ tags:
15
+ - "v*"
16
+ workflow_dispatch:
17
+ inputs:
18
+ repository:
19
+ description: "PyPI repository (pypi or testpypi)"
20
+ required: true
21
+ default: "pypi"
22
+ type: choice
23
+ options:
24
+ - pypi
25
+ - testpypi
26
+
27
+ env:
28
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
29
+
30
+ permissions:
31
+ contents: read
32
+
33
+ jobs:
34
+ build:
35
+ name: Build sdist + wheel
36
+ runs-on: ubuntu-latest
37
+ permissions:
38
+ contents: read
39
+ outputs:
40
+ graph_assets_available: ${{ steps.resolve_graph_assets.outcome == 'success' }}
41
+ steps:
42
+ - name: Checkout
43
+ uses: actions/checkout@v5
44
+ with:
45
+ lfs: false
46
+
47
+ - name: Set up Python
48
+ uses: actions/setup-python@v6
49
+ with:
50
+ python-version: "3.12"
51
+
52
+ - name: Install release tooling
53
+ run: |
54
+ python -m pip install --upgrade pip
55
+ python -m pip install ".[dev]" build packaging twine
56
+
57
+ - name: Validate release target
58
+ run: |
59
+ python - <<'PY'
60
+ import os
61
+ import tomllib
62
+ from packaging.version import Version
63
+
64
+ event = os.environ["GITHUB_EVENT_NAME"]
65
+ target_repository = os.environ.get("INPUT_REPOSITORY", "pypi")
66
+ ref_type = os.environ.get("GITHUB_REF_TYPE", "")
67
+ ref_name = os.environ["GITHUB_REF_NAME"]
68
+
69
+ if event == "workflow_dispatch" and target_repository == "pypi":
70
+ raise SystemExit(
71
+ "Manual PyPI publish is disabled; push a version tag so "
72
+ "graph release assets are uploaded before publishing"
73
+ )
74
+ if event == "workflow_dispatch" and target_repository == "testpypi":
75
+ print(f"manual TestPyPI publish allowed from {ref_type}:{ref_name}")
76
+ raise SystemExit(0)
77
+
78
+ if ref_type != "tag":
79
+ raise SystemExit(
80
+ "PyPI release must run from a version tag; "
81
+ f"got {ref_type}:{ref_name}"
82
+ )
83
+ tag = ref_name
84
+ if not tag.startswith("v"):
85
+ raise SystemExit(f"release tag must start with v: {tag}")
86
+
87
+ with open("pyproject.toml", "rb") as fh:
88
+ package_version = tomllib.load(fh)["project"]["version"]
89
+
90
+ tag_version = str(Version(tag[1:]))
91
+ normalized_package_version = str(Version(package_version))
92
+ if tag_version != normalized_package_version:
93
+ raise SystemExit(
94
+ f"tag {tag!r} does not match pyproject version {package_version!r}"
95
+ )
96
+ print(f"release version {package_version} matches tag {tag}")
97
+ PY
98
+ env:
99
+ INPUT_REPOSITORY: ${{ github.event.inputs.repository || 'pypi' }}
100
+
101
+ - name: Reject already published PyPI version
102
+ run: |
103
+ python - <<'PY'
104
+ import os
105
+ import tomllib
106
+ import urllib.error
107
+ import urllib.request
108
+
109
+ target_repository = os.environ.get("INPUT_REPOSITORY", "pypi")
110
+ if target_repository != "pypi":
111
+ print(f"skipping PyPI reuse check for {target_repository}")
112
+ raise SystemExit(0)
113
+
114
+ with open("pyproject.toml", "rb") as fh:
115
+ project = tomllib.load(fh)["project"]
116
+
117
+ name = project["name"]
118
+ package_version = project["version"]
119
+ url = f"https://pypi.org/pypi/{name}/{package_version}/json"
120
+ try:
121
+ with urllib.request.urlopen(url, timeout=15):
122
+ raise SystemExit(
123
+ f"{name} {package_version} already exists on PyPI; "
124
+ "bump the version before publishing"
125
+ )
126
+ except urllib.error.HTTPError as exc:
127
+ if exc.code == 404:
128
+ print(f"{name} {package_version} is not present on PyPI")
129
+ raise SystemExit(0)
130
+ raise
131
+ PY
132
+ env:
133
+ INPUT_REPOSITORY: ${{ github.event.inputs.repository || 'pypi' }}
134
+
135
+ - name: Resolve release graph artifacts from release assets
136
+ id: resolve_graph_assets
137
+ env:
138
+ GH_TOKEN: ${{ github.token }}
139
+ TAG_NAME: ${{ github.ref_name }}
140
+ run: |
141
+ set -euo pipefail
142
+ echo "Resolving graph artifacts from matching release assets to avoid Git LFS bandwidth."
143
+ python - <<'PY'
144
+ import hashlib
145
+ import json
146
+ import os
147
+ from pathlib import Path
148
+ import subprocess
149
+ import time
150
+ import urllib.request
151
+
152
+ repo = os.environ["GITHUB_REPOSITORY"]
153
+ current_tag = os.environ.get("TAG_NAME", "")
154
+ release_asset_wait_seconds = 300
155
+ release_asset_poll_seconds = 10
156
+ expected_graph_assets = {
157
+ "graph/wiki-graph.tar.gz": {
158
+ "sha256": "91b30795e7d200cf31a62a8749969d12658f5f74636d2de06d6b2b24b393c12f",
159
+ "size": 457545675,
160
+ },
161
+ "graph/wiki-graph-runtime.tar.gz": {
162
+ "sha256": "334fb19bace3fd6e4b92087850f17297fb248032957d123f3f1432dfde2e36c0",
163
+ "size": 175773376,
164
+ },
165
+ }
166
+
167
+ def load_releases() -> list[dict]:
168
+ return json.loads(subprocess.check_output(
169
+ ["gh", "api", f"repos/{repo}/releases?per_page=50"],
170
+ text=True,
171
+ ))
172
+
173
+ def hydrate_from_release(path_name: str, hydrated_min_size: int) -> None:
174
+ graph_tar = Path(path_name)
175
+ fallback = expected_graph_assets[path_name]
176
+ expected_oid = fallback["sha256"]
177
+ expected_size = int(fallback["size"])
178
+ if graph_tar.exists():
179
+ pointer = graph_tar.read_text(encoding="utf-8", errors="replace")
180
+ for line in pointer.splitlines():
181
+ if line.startswith("oid sha256:"):
182
+ expected_oid = line.split(":", 1)[1].strip()
183
+ elif line.startswith("size "):
184
+ expected_size = int(line.split(" ", 1)[1].strip())
185
+ if not pointer.startswith("version https://git-lfs.github.com/spec/v1") and graph_tar.stat().st_size > hydrated_min_size:
186
+ print(f"{graph_tar} is already hydrated")
187
+ return
188
+
189
+ deadline = time.monotonic() + release_asset_wait_seconds
190
+ while True:
191
+ candidates = []
192
+ for release in load_releases():
193
+ tag_name = str(release.get("tag_name") or "")
194
+ is_graph_cache = tag_name.startswith("graph-artifacts-")
195
+ if release.get("draft") or (
196
+ release.get("prerelease") and not is_graph_cache
197
+ ):
198
+ continue
199
+ if tag_name == current_tag:
200
+ continue
201
+ for asset in release.get("assets", []):
202
+ if asset.get("name") != graph_tar.name:
203
+ continue
204
+ digest = str(asset.get("digest") or "")
205
+ size = int(asset.get("size") or 0)
206
+ if size != expected_size:
207
+ continue
208
+ if digest and digest != f"sha256:{expected_oid}":
209
+ continue
210
+ candidates.append((tag_name, asset))
211
+
212
+ if candidates:
213
+ break
214
+ if time.monotonic() >= deadline:
215
+ raise SystemExit(
216
+ f"No previous release asset matches {path_name} "
217
+ f"sha256:{expected_oid} size:{expected_size}"
218
+ )
219
+ print(
220
+ f"Waiting for matching release asset {graph_tar.name} "
221
+ f"sha256:{expected_oid} size:{expected_size}"
222
+ )
223
+ time.sleep(release_asset_poll_seconds)
224
+
225
+ source_tag, asset = candidates[0]
226
+ tmp = graph_tar.with_name(f"{graph_tar.name}.download")
227
+ sha = hashlib.sha256()
228
+ total = 0
229
+ with urllib.request.urlopen(asset["browser_download_url"], timeout=300) as resp: # noqa: S310
230
+ with tmp.open("wb") as fh:
231
+ while True:
232
+ chunk = resp.read(1024 * 1024)
233
+ if not chunk:
234
+ break
235
+ sha.update(chunk)
236
+ total += len(chunk)
237
+ fh.write(chunk)
238
+ actual_oid = sha.hexdigest()
239
+ if actual_oid != expected_oid or total != expected_size:
240
+ tmp.unlink(missing_ok=True)
241
+ raise SystemExit(
242
+ f"Downloaded {path_name} does not match LFS pointer: "
243
+ f"sha256:{actual_oid} size:{total}"
244
+ )
245
+ tmp.replace(graph_tar)
246
+ print(
247
+ f"Hydrated {path_name} from {source_tag} release asset "
248
+ f"sha256:{actual_oid} size:{total}"
249
+ )
250
+
251
+ hydrate_from_release("graph/wiki-graph.tar.gz", 100_000_000)
252
+ hydrate_from_release("graph/wiki-graph-runtime.tar.gz", 10_000_000)
253
+ PY
254
+
255
+ - name: Validate release graph artifacts
256
+ if: steps.resolve_graph_assets.outcome == 'success'
257
+ run: |
258
+ python src/validate_graph_artifacts.py \
259
+ --graph-dir graph \
260
+ --deep \
261
+ --min-nodes 100000 \
262
+ --min-edges 2000000 \
263
+ --min-skills-sh-nodes 89000 \
264
+ --min-semantic-edges 1000000 \
265
+ --expected-nodes 102928 \
266
+ --expected-edges 2913960 \
267
+ --expected-semantic-edges 1683193 \
268
+ --expected-harness-nodes 207 \
269
+ --expected-skills-sh-nodes 89471 \
270
+ --expected-skills-sh-catalog-entries 89465 \
271
+ --expected-skills-sh-converted 89465 \
272
+ --expected-skill-pages 91464 \
273
+ --expected-agent-pages 467 \
274
+ --expected-mcp-pages 10790 \
275
+ --expected-harness-pages 207 \
276
+ --line-threshold 180 \
277
+ --max-stage-lines 40
278
+
279
+ - name: Validate README and docs stats
280
+ if: steps.resolve_graph_assets.outcome == 'success'
281
+ run: python src/update_repo_stats.py --check
282
+
283
+ - name: Static gates
284
+ run: |
285
+ python -m ruff check src hooks scripts
286
+ python -m mypy src
287
+
288
+ - name: Clean-host contract
289
+ run: python scripts/clean_host_contract.py --fast
290
+
291
+ - name: Release canary tests
292
+ run: |
293
+ python -m pytest -q --no-cov \
294
+ src/tests/test_ci_classifier.py \
295
+ src/tests/test_package_scaffold.py \
296
+ src/tests/test_clean_host_contract.py \
297
+ src/tests/test_alive_loop_e2e.py
298
+
299
+ - name: Build distributions
300
+ run: python -m build
301
+
302
+ - name: Check distributions
303
+ run: python -m twine check dist/*
304
+
305
+ - name: Check distribution contents
306
+ run: |
307
+ python - <<'PY'
308
+ import tarfile
309
+ import zipfile
310
+ from pathlib import Path
311
+
312
+ wheel = next(Path("dist").glob("*.whl"))
313
+ sdist = next(Path("dist").glob("*.tar.gz"))
314
+
315
+ with zipfile.ZipFile(wheel) as zf:
316
+ wheel_names = set(zf.namelist())
317
+ required = {"ctx/config.json", "ctx/skill-registry.json"}
318
+ missing = sorted(required - wheel_names)
319
+ if missing:
320
+ raise SystemExit(f"wheel missing packaged defaults: {missing}")
321
+
322
+ with tarfile.open(sdist, "r:gz") as tf:
323
+ names = set(tf.getnames())
324
+ forbidden = ("/src/tests/", "/.claude/", "/.a5c/")
325
+ leaked = sorted(name for name in names if any(part in name for part in forbidden))
326
+ if leaked:
327
+ raise SystemExit("sdist contains local/test artifacts:\n" + "\n".join(leaked[:20]))
328
+ print(f"checked distribution contents: {wheel.name}, {sdist.name}")
329
+ PY
330
+
331
+ - name: Smoke install wheel
332
+ run: |
333
+ python -m venv .venv-smoke
334
+ . .venv-smoke/bin/activate
335
+ python -m pip install --upgrade pip
336
+ wheel="$(python - <<'PY'
337
+ from pathlib import Path
338
+ print(next(Path("dist").glob("*.whl")))
339
+ PY
340
+ )"
341
+ python -m pip install "$wheel"
342
+ python -m pip check
343
+ python - <<'PY'
344
+ import tomllib
345
+ from importlib.metadata import entry_points, version
346
+
347
+ import ctx
348
+ import ctx_config
349
+
350
+ with open("pyproject.toml", "rb") as fh:
351
+ expected_scripts = set(tomllib.load(fh)["project"]["scripts"])
352
+
353
+ dist_version = version("claude-ctx")
354
+ if ctx.__version__ != dist_version:
355
+ raise SystemExit(
356
+ f"ctx.__version__={ctx.__version__!r} != metadata {dist_version!r}"
357
+ )
358
+ if ctx_config.cfg.recommendation_top_k != 5:
359
+ raise SystemExit("packaged default config was not loaded")
360
+ scripts = {
361
+ ep.name: ep for ep in entry_points(group="console_scripts")
362
+ if ep.name == "ctx" or ep.name.startswith("ctx-")
363
+ }
364
+ missing = sorted(expected_scripts - set(scripts))
365
+ extra = sorted(set(scripts) - expected_scripts)
366
+ if missing or extra:
367
+ raise SystemExit(
368
+ "wheel console-script surface mismatch\n"
369
+ f"missing: {missing}\n"
370
+ f"extra: {extra}"
371
+ )
372
+ failures = []
373
+ for ep in scripts.values():
374
+ try:
375
+ ep.load()
376
+ except Exception as exc:
377
+ failures.append(f"{ep.name}: {exc!r}")
378
+ if failures:
379
+ raise SystemExit("console script load failures:\n" + "\n".join(failures))
380
+ unsafe_help = {"ctx-mcp-server"}
381
+ safe_help = sorted(expected_scripts - unsafe_help)
382
+ with open("ctx-console-help.txt", "w", encoding="utf-8", newline="\n") as fh:
383
+ fh.write("\n".join(safe_help) + "\n")
384
+ print(
385
+ f"loaded {len(scripts)} ctx console scripts from wheel "
386
+ f"{dist_version}; help-smoke={len(safe_help)}"
387
+ )
388
+ PY
389
+ while IFS= read -r cmd; do
390
+ cmd="${cmd%$'\r'}"
391
+ [[ -z "$cmd" ]] && continue
392
+ echo "help smoke: $cmd"
393
+ "$cmd" --help >/dev/null
394
+ done < ctx-console-help.txt
395
+ python -m pip install "${wheel}[harness]"
396
+ python - <<'PY'
397
+ import litellm
398
+
399
+ print(f"harness extra import ok: litellm {getattr(litellm, '__version__', 'unknown')}")
400
+ PY
401
+
402
+ - name: Upload dist artifact
403
+ uses: actions/upload-artifact@v7
404
+ with:
405
+ name: dist
406
+ path: dist/
407
+
408
+ - name: Upload graph artifact bundle
409
+ if: steps.resolve_graph_assets.outcome == 'success'
410
+ uses: actions/upload-artifact@v7
411
+ with:
412
+ name: graph-release-assets
413
+ path: |
414
+ graph/wiki-graph.tar.gz
415
+ graph/wiki-graph-runtime.tar.gz
416
+ graph/skills-sh-catalog.json.gz
417
+ graph/communities.json
418
+ graph/entity-overlays.jsonl
419
+
420
+ release-assets:
421
+ name: Upload graph release assets
422
+ needs: build
423
+ if: github.event_name == 'push' && needs.build.outputs.graph_assets_available == 'true'
424
+ runs-on: ubuntu-latest
425
+ permissions:
426
+ contents: write
427
+ steps:
428
+ - name: Download graph artifact bundle
429
+ uses: actions/download-artifact@v7
430
+ with:
431
+ name: graph-release-assets
432
+ path: graph-release-assets
433
+
434
+ - name: Upload graph assets to GitHub release
435
+ env:
436
+ GH_TOKEN: ${{ github.token }}
437
+ TAG_NAME: ${{ github.ref_name }}
438
+ run: |
439
+ gh release view "$TAG_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1 \
440
+ || gh release create "$TAG_NAME" \
441
+ --repo "$GITHUB_REPOSITORY" \
442
+ --title "$TAG_NAME" \
443
+ --notes "ctx $TAG_NAME"
444
+ gh release upload "$TAG_NAME" \
445
+ --repo "$GITHUB_REPOSITORY" \
446
+ graph-release-assets/wiki-graph.tar.gz \
447
+ graph-release-assets/wiki-graph-runtime.tar.gz \
448
+ graph-release-assets/skills-sh-catalog.json.gz \
449
+ graph-release-assets/communities.json \
450
+ graph-release-assets/entity-overlays.jsonl \
451
+ --clobber
452
+
453
+ publish:
454
+ name: Publish to PyPI
455
+ needs:
456
+ - build
457
+ - release-assets
458
+ if: ${{ always() && needs.build.result == 'success' && (needs.release-assets.result == 'success' || (github.event_name == 'workflow_dispatch' && github.event.inputs.repository == 'testpypi')) }}
459
+ runs-on: ubuntu-latest
460
+ permissions:
461
+ contents: read
462
+ id-token: write # required for Trusted Publishing
463
+ environment:
464
+ name: pypi
465
+ url: https://pypi.org/project/claude-ctx/
466
+ steps:
467
+ - name: Download dist artifact
468
+ uses: actions/download-artifact@v7
469
+ with:
470
+ name: dist
471
+ path: dist/
472
+
473
+ - name: Publish to PyPI
474
+ if: github.event_name == 'push'
475
+ uses: pypa/gh-action-pypi-publish@release/v1
476
+
477
+ - name: Publish to TestPyPI
478
+ if: github.event_name == 'workflow_dispatch' && github.event.inputs.repository == 'testpypi'
479
+ uses: pypa/gh-action-pypi-publish@release/v1
480
+ with:
481
+ repository-url: https://test.pypi.org/legacy/
.github/workflows/test.yml CHANGED
@@ -1,704 +1,750 @@
1
- name: Tests
2
-
3
- on:
4
- push:
5
- branches: [main]
6
- pull_request:
7
- branches: [main]
8
-
9
- env:
10
- FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
11
-
12
- permissions:
13
- contents: read
14
-
15
- concurrency:
16
- group: tests-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
17
- cancel-in-progress: true
18
-
19
- jobs:
20
- classify:
21
- name: "Change classifier"
22
- runs-on: ubuntu-latest
23
- outputs:
24
- browser_changed: ${{ steps.classify.outputs.browser_changed }}
25
- ci_changed: ${{ steps.classify.outputs.ci_changed }}
26
- docs_changed: ${{ steps.classify.outputs.docs_changed }}
27
- docs_only: ${{ steps.classify.outputs.docs_only }}
28
- graph_artifact_changed: ${{ steps.classify.outputs.graph_artifact_changed }}
29
- graph_changed: ${{ steps.classify.outputs.graph_changed }}
30
- graph_only: ${{ steps.classify.outputs.graph_only }}
31
- package_changed: ${{ steps.classify.outputs.package_changed }}
32
- similarity_changed: ${{ steps.classify.outputs.similarity_changed }}
33
- source_changed: ${{ steps.classify.outputs.source_changed }}
34
- steps:
35
- - name: Checkout with full history
36
- uses: actions/checkout@v5
37
- with:
38
- fetch-depth: 0
39
- lfs: false
40
-
41
- - name: Classify changed paths
42
- id: classify
43
- shell: bash
44
- env:
45
- BEFORE_SHA: ${{ github.event.before }}
46
- EVENT_NAME: ${{ github.event_name }}
47
- HEAD_SHA: ${{ github.sha }}
48
- PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
49
- run: |
50
- set -euo pipefail
51
- BASE="$BEFORE_SHA"
52
- if [[ "$EVENT_NAME" == "pull_request" && -n "$PR_BASE_SHA" ]]; then
53
- BASE="$PR_BASE_SHA"
54
- fi
55
- if [[ -z "$BASE" || "$BASE" =~ ^0+$ ]]; then
56
- BASE="$(git rev-list --max-parents=0 "$HEAD_SHA")"
57
- fi
58
- if git cat-file -e "$BASE^{commit}" 2>/dev/null; then
59
- git diff --name-only "$BASE" "$HEAD_SHA" > changed-files.txt
60
- elif git cat-file -e "$HEAD_SHA^" 2>/dev/null; then
61
- git diff --name-only "$HEAD_SHA^" "$HEAD_SHA" > changed-files.txt
62
- else
63
- git diff-tree --no-commit-id --name-only -r "$HEAD_SHA" > changed-files.txt
64
- fi
65
-
66
- python scripts/ci_classifier.py changed-files.txt
67
-
68
- static:
69
- name: "Static quality gates"
70
- needs: classify
71
- if: ${{ github.event_name != 'pull_request' || (needs.classify.outputs.docs_only != 'true' && needs.classify.outputs.graph_only != 'true') }}
72
- runs-on: ubuntu-latest
73
- steps:
74
- - name: Checkout
75
- uses: actions/checkout@v5
76
- with:
77
- lfs: false
78
-
79
- - name: Set up Python 3.11
80
- uses: actions/setup-python@v6
81
- with:
82
- python-version: "3.11"
83
- cache: pip
84
- cache-dependency-path: pyproject.toml
85
-
86
- - name: Install dependencies
87
- run: |
88
- python -m pip install --upgrade pip
89
- python -m pip install ".[dev]"
90
-
91
- - name: Run static quality gates
92
- run: python -m ruff check src hooks scripts && python -m mypy src && python -m pip check
93
-
94
- unit-linux:
95
- name: "unit-linux"
96
- needs: classify
97
- if: ${{ github.event_name != 'pull_request' || (needs.classify.outputs.docs_only != 'true' && needs.classify.outputs.graph_only != 'true') }}
98
- runs-on: ubuntu-latest
99
- steps:
100
- - name: Checkout
101
- uses: actions/checkout@v5
102
- with:
103
- lfs: false
104
-
105
- - name: Set up Python 3.11
106
- uses: actions/setup-python@v6
107
- with:
108
- python-version: "3.11"
109
- cache: pip
110
- cache-dependency-path: pyproject.toml
111
-
112
- - name: Install dependencies
113
- run: |
114
- python -m pip install --upgrade pip
115
- python -m pip install ".[dev]"
116
-
117
- - name: Run tests with coverage gate
118
- # Bump this number as the coverage sprint ships - never lower it.
119
- run: pytest -q -m "not browser and not integration" --cov=src --cov-report=term-missing --cov-fail-under=40
120
-
121
- - name: Upload coverage artifact
122
- uses: actions/upload-artifact@v7
123
- with:
124
- name: coverage
125
- path: .coverage
126
- include-hidden-files: true
127
- retention-days: 7
128
-
129
- similarity-integration:
130
- name: "Similarity precision/recall"
131
- needs: classify
132
- if: ${{ github.event_name != 'pull_request' || (needs.classify.outputs.docs_only != 'true' && needs.classify.outputs.similarity_changed == 'true') }}
133
- runs-on: ubuntu-latest
134
- steps:
135
- - name: Checkout
136
- uses: actions/checkout@v5
137
- with:
138
- lfs: false
139
-
140
- - name: Set up Python 3.11
141
- uses: actions/setup-python@v6
142
- with:
143
- python-version: "3.11"
144
- cache: pip
145
- cache-dependency-path: pyproject.toml
146
-
147
- - name: Install embedding dependencies
148
- run: |
149
- python -m pip install --upgrade pip
150
- python -m pip install ".[dev,embeddings]"
151
-
152
- - name: Run similarity precision/recall gate
153
- env:
154
- CTX_REQUIRE_SIMILARITY_EVAL: "1"
155
- run: pytest -q --no-cov -m integration src/tests/test_similarity_precision_recall.py
156
-
157
- test:
158
- name: pytest (${{ matrix.os }} / py${{ matrix.python-version }})
159
- if: ${{ github.event_name != 'pull_request' }}
160
- runs-on: ${{ matrix.os }}
161
- strategy:
162
- fail-fast: false
163
- matrix:
164
- include:
165
- - os: ubuntu-latest
166
- python-version: "3.12"
167
- - os: windows-latest
168
- python-version: "3.11"
169
- - os: windows-latest
170
- python-version: "3.12"
171
- - os: macos-latest
172
- python-version: "3.11"
173
- - os: macos-latest
174
- python-version: "3.12"
175
-
176
- steps:
177
- - name: Checkout
178
- uses: actions/checkout@v5
179
- with:
180
- lfs: false
181
-
182
- - name: Set up Python ${{ matrix.python-version }}
183
- uses: actions/setup-python@v6
184
- with:
185
- python-version: ${{ matrix.python-version }}
186
- cache: pip
187
- cache-dependency-path: pyproject.toml
188
-
189
- - name: Install dependencies
190
- run: |
191
- python -m pip install --upgrade pip
192
- python -m pip install ".[dev]"
193
-
194
- - name: Run tests without coverage
195
- run: pytest -q -m "not browser and not integration" --no-cov
196
-
197
- contract-compat:
198
- name: "Contract compatibility (${{ matrix.os }})"
199
- needs: classify
200
- if: ${{ github.event_name != 'pull_request' || (needs.classify.outputs.docs_only != 'true' && needs.classify.outputs.graph_only != 'true') }}
201
- runs-on: ${{ matrix.os }}
202
- strategy:
203
- fail-fast: false
204
- matrix:
205
- os: [windows-latest, macos-latest]
206
-
207
- steps:
208
- - name: Checkout
209
- uses: actions/checkout@v5
210
- with:
211
- lfs: false
212
-
213
- - name: Set up Python 3.12
214
- uses: actions/setup-python@v6
215
- with:
216
- python-version: "3.12"
217
-
218
- - name: Install dependencies
219
- run: |
220
- python -m pip install --upgrade pip
221
- python -m pip install ".[dev]"
222
-
223
- - name: Run cross-OS contract tests
224
- run: python -m pytest -q --no-cov src/tests/test_clean_host_contract.py src/tests/test_package_scaffold.py
225
-
226
- e2e-canary:
227
- name: "A-Z alive-loop E2E canary"
228
- needs: classify
229
- if: ${{ github.event_name != 'pull_request' || (needs.classify.outputs.docs_only != 'true' && needs.classify.outputs.graph_only != 'true') }}
230
- runs-on: ubuntu-latest
231
- steps:
232
- - name: Checkout
233
- uses: actions/checkout@v5
234
- with:
235
- lfs: false
236
-
237
- - name: Set up Python 3.11
238
- uses: actions/setup-python@v6
239
- with:
240
- python-version: "3.11"
241
- cache: pip
242
- cache-dependency-path: pyproject.toml
243
-
244
- - name: Install dependencies
245
- run: |
246
- python -m pip install --upgrade pip
247
- python -m pip install ".[dev]"
248
-
249
- - name: Run E2E + fuzz canary suite
250
- # These two files pin the critical-path invariants:
251
- # test_alive_loop_e2e.py: A-Z user journey (signals bundle →
252
- # install → unload → purge) — any handoff regression trips it.
253
- # test_fuzz_yaml_rendering.py: property-based YAML injection
254
- # coverage on install/enrich render_scalar.
255
- # They live in the main matrix job already, but pulling them
256
- # forward as a separate fail-fast canary surfaces the regression
257
- # in seconds instead of waiting for the full test run.
258
- run: |
259
- pytest -q --no-cov \
260
- src/tests/test_alive_loop_e2e.py \
261
- src/tests/test_fuzz_yaml_rendering.py
262
-
263
- docs-check:
264
- name: "Docs strict build"
265
- needs: classify
266
- if: ${{ github.event_name == 'pull_request' && needs.classify.outputs.docs_changed == 'true' }}
267
- runs-on: ubuntu-latest
268
- steps:
269
- - name: Checkout
270
- uses: actions/checkout@v5
271
- with:
272
- lfs: false
273
-
274
- - name: Set up Python 3.11
275
- uses: actions/setup-python@v6
276
- with:
277
- python-version: "3.11"
278
- cache: pip
279
- cache-dependency-path: requirements-docs.txt
280
-
281
- - name: Build docs strictly
282
- run: |
283
- python -m pip install --upgrade pip
284
- python -m pip install -r requirements-docs.txt
285
- python -m mkdocs build --strict
286
-
287
- graph-check:
288
- name: "Graph artifact check"
289
- needs: classify
290
- if: ${{ github.event_name == 'pull_request' && needs.classify.outputs.graph_artifact_changed == 'true' }}
291
- runs-on: ubuntu-latest
292
- steps:
293
- - name: Checkout graph artifacts
294
- uses: actions/checkout@v5
295
- with:
296
- lfs: false
297
-
298
- - name: Resolve graph LFS artifacts
299
- shell: bash
300
- env:
301
- GH_TOKEN: ${{ github.token }}
302
- run: |
303
- set -euo pipefail
304
- git lfs install --local
305
- if git lfs pull --include="graph/wiki-graph.tar.gz,graph/wiki-graph-runtime.tar.gz" --exclude=""; then
306
- exit 0
307
- fi
308
- echo "Git LFS download failed; trying matching prior release asset."
309
- python - <<'PY'
310
- import hashlib
311
- import json
312
- import os
313
- from pathlib import Path
314
- import subprocess
315
- import time
316
- import urllib.request
317
-
318
- repo = os.environ["GITHUB_REPOSITORY"]
319
- release_asset_wait_seconds = 300
320
- release_asset_poll_seconds = 10
321
-
322
- def load_releases() -> list[dict]:
323
- return json.loads(subprocess.check_output(
324
- ["gh", "api", f"repos/{repo}/releases?per_page=50"],
325
- text=True,
326
- ))
327
-
328
- def hydrate_from_release(path_name: str, hydrated_min_size: int) -> None:
329
- graph_tar = Path(path_name)
330
- pointer = graph_tar.read_text(encoding="utf-8", errors="replace")
331
- expected_oid = ""
332
- expected_size = 0
333
- for line in pointer.splitlines():
334
- if line.startswith("oid sha256:"):
335
- expected_oid = line.split(":", 1)[1].strip()
336
- elif line.startswith("size "):
337
- expected_size = int(line.split(" ", 1)[1].strip())
338
- if not expected_oid:
339
- if graph_tar.stat().st_size > hydrated_min_size:
340
- print(f"{graph_tar} is already hydrated")
341
- return
342
- raise SystemExit(f"{path_name} is neither hydrated nor an LFS pointer")
343
-
344
- deadline = time.monotonic() + release_asset_wait_seconds
345
- while True:
346
- candidates = []
347
- for release in load_releases():
348
- tag_name = str(release.get("tag_name") or "")
349
- is_graph_cache = tag_name.startswith("graph-artifacts-")
350
- if release.get("draft") or (
351
- release.get("prerelease") and not is_graph_cache
352
- ):
353
- continue
354
- for asset in release.get("assets", []):
355
- if asset.get("name") != graph_tar.name:
356
- continue
357
- digest = str(asset.get("digest") or "")
358
- size = int(asset.get("size") or 0)
359
- if size != expected_size:
360
- continue
361
- if digest and digest != f"sha256:{expected_oid}":
362
- continue
363
- candidates.append((tag_name, asset))
364
-
365
- if candidates:
366
- break
367
- if time.monotonic() >= deadline:
368
- raise SystemExit(
369
- f"No previous release asset matches {path_name} "
370
- f"sha256:{expected_oid} size:{expected_size}"
371
- )
372
- print(
373
- f"Waiting for matching release asset {graph_tar.name} "
374
- f"sha256:{expected_oid} size:{expected_size}"
375
- )
376
- time.sleep(release_asset_poll_seconds)
377
-
378
- source_tag, asset = candidates[0]
379
- tmp = graph_tar.with_name(f"{graph_tar.name}.download")
380
- sha = hashlib.sha256()
381
- total = 0
382
- with urllib.request.urlopen(asset["browser_download_url"], timeout=300) as resp: # noqa: S310
383
- with tmp.open("wb") as fh:
384
- while True:
385
- chunk = resp.read(1024 * 1024)
386
- if not chunk:
387
- break
388
- sha.update(chunk)
389
- total += len(chunk)
390
- fh.write(chunk)
391
- actual_oid = sha.hexdigest()
392
- if actual_oid != expected_oid or total != expected_size:
393
- tmp.unlink(missing_ok=True)
394
- raise SystemExit(
395
- f"Downloaded {path_name} does not match LFS pointer: "
396
- f"sha256:{actual_oid} size:{total}"
397
- )
398
- tmp.replace(graph_tar)
399
- print(
400
- f"Hydrated {path_name} from {source_tag} release asset "
401
- f"sha256:{actual_oid} size:{total}"
402
- )
403
-
404
- hydrate_from_release("graph/wiki-graph.tar.gz", 100_000_000)
405
- hydrate_from_release("graph/wiki-graph-runtime.tar.gz", 10_000_000)
406
- PY
407
-
408
- - name: Validate shipped graph artifacts
409
- run: |
410
- python src/validate_graph_artifacts.py \
411
- --graph-dir graph \
412
- --deep \
413
- --min-nodes 100000 \
414
- --min-edges 2000000 \
415
- --min-skills-sh-nodes 89000 \
416
- --min-semantic-edges 1000000 \
417
- --expected-nodes 102928 \
418
- --expected-edges 2913960 \
419
- --expected-semantic-edges 1683193 \
420
- --expected-harness-nodes 207 \
421
- --expected-skills-sh-nodes 89471 \
422
- --expected-skills-sh-catalog-entries 89465 \
423
- --expected-skills-sh-converted 89465 \
424
- --expected-skill-pages 91464 \
425
- --expected-agent-pages 467 \
426
- --expected-mcp-pages 10790 \
427
- --expected-harness-pages 207 \
428
- --line-threshold 180 \
429
- --max-stage-lines 40
430
-
431
- browser-security:
432
- name: "Browser monitor security"
433
- needs: classify
434
- if: ${{ github.event_name == 'push' || needs.classify.outputs.browser_changed == 'true' }}
435
- runs-on: ubuntu-latest
436
- steps:
437
- - name: Checkout
438
- uses: actions/checkout@v5
439
- with:
440
- lfs: false
441
-
442
- - name: Set up Python 3.12
443
- uses: actions/setup-python@v6
444
- with:
445
- python-version: "3.12"
446
- cache: pip
447
- cache-dependency-path: pyproject.toml
448
-
449
- - name: Install browser Python dependencies
450
- run: |
451
- python -m pip install --upgrade pip
452
- python -m pip install ".[dev,browser]"
453
-
454
- - name: Detect Playwright version
455
- id: playwright
456
- run: |
457
- python - <<'PY' >> "$GITHUB_OUTPUT"
458
- from importlib.metadata import version
459
-
460
- print(f"version={version('playwright')}")
461
- PY
462
-
463
- - name: Cache Playwright browsers
464
- uses: actions/cache@v4
465
- with:
466
- path: ~/.cache/ms-playwright
467
- key: playwright-${{ runner.os }}-${{ steps.playwright.outputs.version }}
468
-
469
- - name: Install browser runtime
470
- run: |
471
- python -m playwright install-deps chromium
472
- python -m playwright install chromium
473
-
474
- - name: Run browser security tests
475
- run: pytest -q --no-cov -m browser src/tests/test_ctx_monitor_browser.py
476
-
477
- package-build:
478
- name: "Build wheel package"
479
- needs: classify
480
- if: ${{ github.event_name != 'pull_request' || (needs.classify.outputs.docs_only != 'true' && needs.classify.outputs.graph_only != 'true') }}
481
- runs-on: ubuntu-latest
482
- steps:
483
- - name: Checkout
484
- uses: actions/checkout@v5
485
- with:
486
- lfs: false
487
-
488
- - name: Set up Python 3.12
489
- uses: actions/setup-python@v6
490
- with:
491
- python-version: "3.12"
492
- cache: pip
493
- cache-dependency-path: pyproject.toml
494
-
495
- - name: Build and inspect wheel
496
- run: |
497
- python -m pip install --upgrade pip
498
- python -m pip install build twine
499
- python -m build
500
- python -m twine check dist/*
501
- python - <<'PY'
502
- from pathlib import Path
503
-
504
- wheels = sorted(Path("dist").glob("*.whl"))
505
- if len(wheels) != 1:
506
- raise SystemExit(f"expected exactly one wheel, found {len(wheels)}")
507
- wheel = wheels[0]
508
- if not wheel.name.endswith("-py3-none-any.whl"):
509
- message = f"expected a universal pure-Python wheel, got {wheel.name}"
510
- raise SystemExit(message)
511
- print(f"built package-smoke wheel: {wheel.name}")
512
- PY
513
-
514
- - name: Upload wheel artifact
515
- uses: actions/upload-artifact@v7
516
- with:
517
- name: package-smoke-wheel
518
- path: dist/*.whl
519
- retention-days: 7
520
-
521
- package-smoke:
522
- name: "Wheel package smoke (${{ matrix.os }})"
523
- needs:
524
- - classify
525
- - package-build
526
- if: ${{ github.event_name != 'pull_request' || (needs.classify.outputs.docs_only != 'true' && needs.classify.outputs.graph_only != 'true') }}
527
- runs-on: ${{ matrix.os }}
528
- strategy:
529
- fail-fast: false
530
- matrix:
531
- os: [ubuntu-latest, windows-latest, macos-latest]
532
- defaults:
533
- run:
534
- shell: bash
535
- steps:
536
- - name: Checkout
537
- uses: actions/checkout@v5
538
- with:
539
- lfs: false
540
-
541
- - name: Set up Python 3.12
542
- uses: actions/setup-python@v6
543
- with:
544
- python-version: "3.12"
545
- cache: pip
546
- cache-dependency-path: pyproject.toml
547
-
548
- - name: Download wheel artifact
549
- uses: actions/download-artifact@v7
550
- with:
551
- name: package-smoke-wheel
552
- path: dist
553
-
554
- - name: Install wheel in clean venv
555
- run: |
556
- python -m venv .venv-smoke
557
- if [[ "$RUNNER_OS" == "Windows" ]]; then
558
- . .venv-smoke/Scripts/activate
559
- else
560
- . .venv-smoke/bin/activate
561
- fi
562
- python -m pip install --upgrade pip
563
- python -m pip install dist/*.whl
564
- python -m pip check
565
- python - <<'PY'
566
- import tomllib
567
- from importlib.metadata import entry_points, version
568
-
569
- import ctx
570
-
571
- with open("pyproject.toml", "rb") as fh:
572
- expected_scripts = set(tomllib.load(fh)["project"]["scripts"])
573
-
574
- dist_version = version("claude-ctx")
575
- if ctx.__version__ != dist_version:
576
- raise SystemExit(
577
- f"ctx.__version__={ctx.__version__!r} != metadata {dist_version!r}"
578
- )
579
-
580
- script_eps = {
581
- ep.name: ep
582
- for ep in entry_points(group="console_scripts")
583
- if ep.name == "ctx" or ep.name.startswith("ctx-")
584
- }
585
- missing = sorted(expected_scripts - set(script_eps))
586
- extra = sorted(set(script_eps) - expected_scripts)
587
- if missing or extra:
588
- raise SystemExit(
589
- "wheel console-script surface mismatch\n"
590
- f"missing: {missing}\n"
591
- f"extra: {extra}"
592
- )
593
- failures = []
594
- for ep in script_eps.values():
595
- try:
596
- ep.load()
597
- except Exception as exc:
598
- failures.append(f"{ep.name}: {exc!r}")
599
- if failures:
600
- raise SystemExit("console script load failures:\n" + "\n".join(failures))
601
-
602
- unsafe_help = {"ctx-mcp-server"}
603
- safe_help = sorted(expected_scripts - unsafe_help)
604
- with open(
605
- "ctx-console-help.txt", "w", encoding="utf-8", newline="\n"
606
- ) as fh:
607
- fh.write("\n".join(safe_help) + "\n")
608
- print(
609
- f"loaded {len(script_eps)} ctx console scripts from wheel "
610
- f"{dist_version}; help-smoke={len(safe_help)}"
611
- )
612
- PY
613
- while IFS= read -r cmd; do
614
- cmd="${cmd%$'\r'}"
615
- [[ -z "$cmd" ]] && continue
616
- echo "help smoke: $cmd"
617
- "$cmd" --help >/dev/null
618
- done < ctx-console-help.txt
619
-
620
- clean-host-contract:
621
- name: "Clean host contract"
622
- needs: classify
623
- if: ${{ github.event_name != 'pull_request' || (needs.classify.outputs.docs_only != 'true' && needs.classify.outputs.graph_only != 'true') }}
624
- runs-on: ubuntu-latest
625
- timeout-minutes: 25
626
- steps:
627
- - name: Checkout
628
- uses: actions/checkout@v5
629
- with:
630
- lfs: false
631
-
632
- - name: Set up Python 3.12
633
- uses: actions/setup-python@v6
634
- with:
635
- python-version: "3.12"
636
- cache: pip
637
- cache-dependency-path: pyproject.toml
638
-
639
- - name: Upgrade pip
640
- run: python -m pip install --upgrade pip
641
-
642
- - name: Run clean-host contract
643
- run: python scripts/clean_host_contract.py --fast
644
-
645
- no-test-no-merge:
646
- name: "Contract changes must touch tests"
647
- needs: classify
648
- runs-on: ubuntu-latest
649
- if: ${{ github.event_name == 'pull_request' && needs.classify.outputs.docs_only != 'true' && needs.classify.outputs.graph_only != 'true' }}
650
- steps:
651
- - name: Checkout with full history
652
- uses: actions/checkout@v5
653
- with:
654
- fetch-depth: 0
655
- lfs: false
656
-
657
- - name: Enforce test-coverage-per-PR policy
658
- # Policy: any product or CI/package contract change
659
- # must be accompanied by at least one test file change in the
660
- # same PR. Exemptions:
661
- # - Pure docs / comment changes (matched by a trivial heuristic)
662
- # - Config JSON (src/config.json) — schema changes get tests
663
- # for the accessor, not the JSON itself.
664
- # - Changes ONLY inside __pycache__, .pyc files, etc.
665
- run: |
666
- set -euo pipefail
667
- BASE="${{ github.event.pull_request.base.sha }}"
668
- HEAD="${{ github.event.pull_request.head.sha }}"
669
- LABELS='${{ toJson(github.event.pull_request.labels.*.name) }}'
670
- python scripts/ci_no_test_policy.py \
671
- --base "$BASE" \
672
- --head "$HEAD" \
673
- --labels-json "$LABELS"
674
-
675
- ci-required:
676
- name: "CI required"
677
- runs-on: ubuntu-latest
678
- needs:
679
- - classify
680
- - static
681
- - unit-linux
682
- - similarity-integration
683
- - test
684
- - contract-compat
685
- - e2e-canary
686
- - docs-check
687
- - graph-check
688
- - browser-security
689
- - package-smoke
690
- - package-build
691
- - clean-host-contract
692
- - no-test-no-merge
693
- if: ${{ always() }}
694
- steps:
695
- - name: Checkout
696
- uses: actions/checkout@v5
697
- with:
698
- lfs: false
699
-
700
- - name: Check required job results
701
- env:
702
- EVENT_NAME: ${{ github.event_name }}
703
- NEEDS_JSON: ${{ toJson(needs) }}
704
- run: python scripts/ci_required.py
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Tests
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ env:
10
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
11
+
12
+ permissions:
13
+ contents: read
14
+
15
+ concurrency:
16
+ group: tests-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
17
+ cancel-in-progress: true
18
+
19
+ jobs:
20
+ classify:
21
+ name: "Change classifier"
22
+ runs-on: ubuntu-latest
23
+ outputs:
24
+ browser_changed: ${{ steps.classify.outputs.browser_changed }}
25
+ ci_changed: ${{ steps.classify.outputs.ci_changed }}
26
+ docs_changed: ${{ steps.classify.outputs.docs_changed }}
27
+ docs_only: ${{ steps.classify.outputs.docs_only }}
28
+ graph_artifact_changed: ${{ steps.classify.outputs.graph_artifact_changed }}
29
+ graph_changed: ${{ steps.classify.outputs.graph_changed }}
30
+ graph_only: ${{ steps.classify.outputs.graph_only }}
31
+ package_changed: ${{ steps.classify.outputs.package_changed }}
32
+ similarity_changed: ${{ steps.classify.outputs.similarity_changed }}
33
+ source_changed: ${{ steps.classify.outputs.source_changed }}
34
+ steps:
35
+ - name: Checkout with full history
36
+ uses: actions/checkout@v5
37
+ with:
38
+ fetch-depth: 0
39
+ lfs: false
40
+
41
+ - name: Classify changed paths
42
+ id: classify
43
+ shell: bash
44
+ env:
45
+ BEFORE_SHA: ${{ github.event.before }}
46
+ EVENT_NAME: ${{ github.event_name }}
47
+ HEAD_SHA: ${{ github.sha }}
48
+ PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
49
+ run: |
50
+ set -euo pipefail
51
+ BASE="$BEFORE_SHA"
52
+ if [[ "$EVENT_NAME" == "pull_request" && -n "$PR_BASE_SHA" ]]; then
53
+ BASE="$PR_BASE_SHA"
54
+ fi
55
+ if [[ -z "$BASE" || "$BASE" =~ ^0+$ ]]; then
56
+ BASE="$(git rev-list --max-parents=0 "$HEAD_SHA")"
57
+ fi
58
+ if git cat-file -e "$BASE^{commit}" 2>/dev/null; then
59
+ git diff --name-only "$BASE" "$HEAD_SHA" > changed-files.txt
60
+ elif git cat-file -e "$HEAD_SHA^" 2>/dev/null; then
61
+ git diff --name-only "$HEAD_SHA^" "$HEAD_SHA" > changed-files.txt
62
+ else
63
+ git diff-tree --no-commit-id --name-only -r "$HEAD_SHA" > changed-files.txt
64
+ fi
65
+
66
+ python scripts/ci_classifier.py changed-files.txt
67
+
68
+ static:
69
+ name: "Static quality gates"
70
+ needs: classify
71
+ if: ${{ github.event_name != 'pull_request' || (needs.classify.outputs.docs_only != 'true' && needs.classify.outputs.graph_only != 'true') }}
72
+ runs-on: ubuntu-latest
73
+ steps:
74
+ - name: Checkout
75
+ uses: actions/checkout@v5
76
+ with:
77
+ lfs: false
78
+
79
+ - name: Set up Python 3.11
80
+ uses: actions/setup-python@v6
81
+ with:
82
+ python-version: "3.11"
83
+ cache: pip
84
+ cache-dependency-path: pyproject.toml
85
+
86
+ - name: Install dependencies
87
+ run: |
88
+ python -m pip install --upgrade pip
89
+ python -m pip install ".[dev]"
90
+
91
+ - name: Run static quality gates
92
+ run: python -m ruff check src hooks scripts && python -m mypy src && python -m pip check
93
+
94
+ unit-linux:
95
+ name: "unit-linux"
96
+ needs: classify
97
+ if: ${{ github.event_name != 'pull_request' || (needs.classify.outputs.docs_only != 'true' && needs.classify.outputs.graph_only != 'true') }}
98
+ runs-on: ubuntu-latest
99
+ steps:
100
+ - name: Checkout
101
+ uses: actions/checkout@v5
102
+ with:
103
+ lfs: false
104
+
105
+ - name: Set up Python 3.11
106
+ uses: actions/setup-python@v6
107
+ with:
108
+ python-version: "3.11"
109
+ cache: pip
110
+ cache-dependency-path: pyproject.toml
111
+
112
+ - name: Install dependencies
113
+ run: |
114
+ python -m pip install --upgrade pip
115
+ python -m pip install ".[dev]"
116
+
117
+ - name: Run tests with coverage gate
118
+ # Bump this number as the coverage sprint ships - never lower it.
119
+ run: pytest -q -m "not browser and not integration" --cov=src --cov-report=term-missing --cov-fail-under=40
120
+
121
+ - name: Upload coverage artifact
122
+ uses: actions/upload-artifact@v7
123
+ with:
124
+ name: coverage
125
+ path: .coverage
126
+ include-hidden-files: true
127
+ retention-days: 7
128
+
129
+ similarity-integration:
130
+ name: "Similarity precision/recall"
131
+ needs: classify
132
+ if: ${{ github.event_name != 'pull_request' || (needs.classify.outputs.docs_only != 'true' && needs.classify.outputs.similarity_changed == 'true') }}
133
+ runs-on: ubuntu-latest
134
+ steps:
135
+ - name: Checkout
136
+ uses: actions/checkout@v5
137
+ with:
138
+ lfs: false
139
+
140
+ - name: Set up Python 3.11
141
+ uses: actions/setup-python@v6
142
+ with:
143
+ python-version: "3.11"
144
+ cache: pip
145
+ cache-dependency-path: pyproject.toml
146
+
147
+ - name: Install embedding dependencies
148
+ run: |
149
+ python -m pip install --upgrade pip
150
+ python -m pip install ".[dev,embeddings]"
151
+
152
+ - name: Cache MiniLM model
153
+ uses: actions/cache@v4
154
+ with:
155
+ path: ~/.cache/huggingface
156
+ key: hf-sentence-transformers-all-MiniLM-L6-v2-v1
157
+
158
+ - name: Pre-download MiniLM model
159
+ env:
160
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
161
+ run: |
162
+ python - <<'PY'
163
+ import time
164
+ from sentence_transformers import SentenceTransformer
165
+
166
+ model = "sentence-transformers/all-MiniLM-L6-v2"
167
+ for attempt in range(1, 5):
168
+ try:
169
+ SentenceTransformer(model)
170
+ print(f"ready: {model}")
171
+ break
172
+ except Exception as exc:
173
+ print(f"attempt {attempt} failed: {exc}", flush=True)
174
+ if attempt == 4:
175
+ raise
176
+ time.sleep(15 * attempt)
177
+ PY
178
+
179
+ - name: Run similarity precision/recall gate
180
+ env:
181
+ CTX_REQUIRE_SIMILARITY_EVAL: "1"
182
+ run: pytest -q --no-cov -m integration src/tests/test_similarity_precision_recall.py
183
+
184
+ test:
185
+ name: pytest (${{ matrix.os }} / py${{ matrix.python-version }})
186
+ if: ${{ github.event_name != 'pull_request' }}
187
+ runs-on: ${{ matrix.os }}
188
+ strategy:
189
+ fail-fast: false
190
+ matrix:
191
+ include:
192
+ - os: ubuntu-latest
193
+ python-version: "3.12"
194
+ - os: windows-latest
195
+ python-version: "3.11"
196
+ - os: windows-latest
197
+ python-version: "3.12"
198
+ - os: macos-latest
199
+ python-version: "3.11"
200
+ - os: macos-latest
201
+ python-version: "3.12"
202
+
203
+ steps:
204
+ - name: Checkout
205
+ uses: actions/checkout@v5
206
+ with:
207
+ lfs: false
208
+
209
+ - name: Set up Python ${{ matrix.python-version }}
210
+ uses: actions/setup-python@v6
211
+ with:
212
+ python-version: ${{ matrix.python-version }}
213
+ cache: pip
214
+ cache-dependency-path: pyproject.toml
215
+
216
+ - name: Install dependencies
217
+ run: |
218
+ python -m pip install --upgrade pip
219
+ python -m pip install ".[dev]"
220
+
221
+ - name: Run tests without coverage
222
+ run: pytest -q -m "not browser and not integration" --no-cov
223
+
224
+ contract-compat:
225
+ name: "Contract compatibility (${{ matrix.os }})"
226
+ needs: classify
227
+ if: ${{ github.event_name != 'pull_request' || (needs.classify.outputs.docs_only != 'true' && needs.classify.outputs.graph_only != 'true') }}
228
+ runs-on: ${{ matrix.os }}
229
+ strategy:
230
+ fail-fast: false
231
+ matrix:
232
+ os: [windows-latest, macos-latest]
233
+
234
+ steps:
235
+ - name: Checkout
236
+ uses: actions/checkout@v5
237
+ with:
238
+ lfs: false
239
+
240
+ - name: Set up Python 3.12
241
+ uses: actions/setup-python@v6
242
+ with:
243
+ python-version: "3.12"
244
+
245
+ - name: Install dependencies
246
+ run: |
247
+ python -m pip install --upgrade pip
248
+ python -m pip install ".[dev]"
249
+
250
+ - name: Run cross-OS contract tests
251
+ run: python -m pytest -q --no-cov src/tests/test_clean_host_contract.py src/tests/test_package_scaffold.py
252
+
253
+ e2e-canary:
254
+ name: "A-Z alive-loop E2E canary"
255
+ needs: classify
256
+ if: ${{ github.event_name != 'pull_request' || (needs.classify.outputs.docs_only != 'true' && needs.classify.outputs.graph_only != 'true') }}
257
+ runs-on: ubuntu-latest
258
+ steps:
259
+ - name: Checkout
260
+ uses: actions/checkout@v5
261
+ with:
262
+ lfs: false
263
+
264
+ - name: Set up Python 3.11
265
+ uses: actions/setup-python@v6
266
+ with:
267
+ python-version: "3.11"
268
+ cache: pip
269
+ cache-dependency-path: pyproject.toml
270
+
271
+ - name: Install dependencies
272
+ run: |
273
+ python -m pip install --upgrade pip
274
+ python -m pip install ".[dev]"
275
+
276
+ - name: Run E2E + fuzz canary suite
277
+ # These two files pin the critical-path invariants:
278
+ # test_alive_loop_e2e.py: A-Z user journey (signals → bundle →
279
+ # install → unload → purge) — any handoff regression trips it.
280
+ # test_fuzz_yaml_rendering.py: property-based YAML injection
281
+ # coverage on install/enrich render_scalar.
282
+ # They live in the main matrix job already, but pulling them
283
+ # forward as a separate fail-fast canary surfaces the regression
284
+ # in seconds instead of waiting for the full test run.
285
+ run: |
286
+ pytest -q --no-cov \
287
+ src/tests/test_alive_loop_e2e.py \
288
+ src/tests/test_fuzz_yaml_rendering.py
289
+
290
+ docs-check:
291
+ name: "Docs strict build"
292
+ needs: classify
293
+ if: ${{ github.event_name == 'pull_request' && needs.classify.outputs.docs_changed == 'true' }}
294
+ runs-on: ubuntu-latest
295
+ steps:
296
+ - name: Checkout
297
+ uses: actions/checkout@v5
298
+ with:
299
+ lfs: false
300
+
301
+ - name: Set up Python 3.11
302
+ uses: actions/setup-python@v6
303
+ with:
304
+ python-version: "3.11"
305
+ cache: pip
306
+ cache-dependency-path: requirements-docs.txt
307
+
308
+ - name: Build docs strictly
309
+ run: |
310
+ python -m pip install --upgrade pip
311
+ python -m pip install -r requirements-docs.txt
312
+ python -m mkdocs build --strict
313
+
314
+ graph-check:
315
+ name: "Graph artifact check"
316
+ needs: classify
317
+ if: ${{ github.event_name == 'pull_request' && needs.classify.outputs.graph_artifact_changed == 'true' }}
318
+ runs-on: ubuntu-latest
319
+ steps:
320
+ - name: Checkout graph artifacts
321
+ uses: actions/checkout@v5
322
+ with:
323
+ lfs: false
324
+
325
+ - name: Set up Python
326
+ uses: actions/setup-python@v6
327
+ with:
328
+ python-version: "3.12"
329
+
330
+ - name: Install graph check dependencies
331
+ run: |
332
+ python -m pip install --upgrade pip
333
+ python -m pip install ".[dev]"
334
+
335
+ - name: Resolve graph artifacts from release assets
336
+ shell: bash
337
+ env:
338
+ GH_TOKEN: ${{ github.token }}
339
+ run: |
340
+ set -euo pipefail
341
+ echo "Resolving graph artifacts from matching release assets to avoid Git LFS bandwidth."
342
+ python - <<'PY'
343
+ import hashlib
344
+ import json
345
+ import os
346
+ from pathlib import Path
347
+ import subprocess
348
+ import time
349
+ import urllib.request
350
+
351
+ repo = os.environ["GITHUB_REPOSITORY"]
352
+ release_asset_wait_seconds = 300
353
+ release_asset_poll_seconds = 10
354
+ expected_graph_assets = {
355
+ "graph/wiki-graph.tar.gz": {
356
+ "sha256": "91b30795e7d200cf31a62a8749969d12658f5f74636d2de06d6b2b24b393c12f",
357
+ "size": 457545675,
358
+ },
359
+ "graph/wiki-graph-runtime.tar.gz": {
360
+ "sha256": "334fb19bace3fd6e4b92087850f17297fb248032957d123f3f1432dfde2e36c0",
361
+ "size": 175773376,
362
+ },
363
+ }
364
+
365
+ def load_releases() -> list[dict]:
366
+ return json.loads(subprocess.check_output(
367
+ ["gh", "api", f"repos/{repo}/releases?per_page=50"],
368
+ text=True,
369
+ ))
370
+
371
+ def hydrate_from_release(path_name: str, hydrated_min_size: int) -> None:
372
+ graph_tar = Path(path_name)
373
+ fallback = expected_graph_assets[path_name]
374
+ expected_oid = fallback["sha256"]
375
+ expected_size = int(fallback["size"])
376
+ if graph_tar.exists():
377
+ pointer = graph_tar.read_text(encoding="utf-8", errors="replace")
378
+ for line in pointer.splitlines():
379
+ if line.startswith("oid sha256:"):
380
+ expected_oid = line.split(":", 1)[1].strip()
381
+ elif line.startswith("size "):
382
+ expected_size = int(line.split(" ", 1)[1].strip())
383
+ if not pointer.startswith("version https://git-lfs.github.com/spec/v1") and graph_tar.stat().st_size > hydrated_min_size:
384
+ print(f"{graph_tar} is already hydrated")
385
+ return
386
+
387
+ deadline = time.monotonic() + release_asset_wait_seconds
388
+ while True:
389
+ candidates = []
390
+ for release in load_releases():
391
+ tag_name = str(release.get("tag_name") or "")
392
+ is_graph_cache = tag_name.startswith("graph-artifacts-")
393
+ if release.get("draft") or (
394
+ release.get("prerelease") and not is_graph_cache
395
+ ):
396
+ continue
397
+ for asset in release.get("assets", []):
398
+ if asset.get("name") != graph_tar.name:
399
+ continue
400
+ digest = str(asset.get("digest") or "")
401
+ size = int(asset.get("size") or 0)
402
+ if size != expected_size:
403
+ continue
404
+ if digest and digest != f"sha256:{expected_oid}":
405
+ continue
406
+ candidates.append((tag_name, asset))
407
+
408
+ if candidates:
409
+ break
410
+ if time.monotonic() >= deadline:
411
+ raise SystemExit(
412
+ f"No previous release asset matches {path_name} "
413
+ f"sha256:{expected_oid} size:{expected_size}"
414
+ )
415
+ print(
416
+ f"Waiting for matching release asset {graph_tar.name} "
417
+ f"sha256:{expected_oid} size:{expected_size}"
418
+ )
419
+ time.sleep(release_asset_poll_seconds)
420
+
421
+ source_tag, asset = candidates[0]
422
+ tmp = graph_tar.with_name(f"{graph_tar.name}.download")
423
+ sha = hashlib.sha256()
424
+ total = 0
425
+ with urllib.request.urlopen(asset["browser_download_url"], timeout=300) as resp: # noqa: S310
426
+ with tmp.open("wb") as fh:
427
+ while True:
428
+ chunk = resp.read(1024 * 1024)
429
+ if not chunk:
430
+ break
431
+ sha.update(chunk)
432
+ total += len(chunk)
433
+ fh.write(chunk)
434
+ actual_oid = sha.hexdigest()
435
+ if actual_oid != expected_oid or total != expected_size:
436
+ tmp.unlink(missing_ok=True)
437
+ raise SystemExit(
438
+ f"Downloaded {path_name} does not match LFS pointer: "
439
+ f"sha256:{actual_oid} size:{total}"
440
+ )
441
+ tmp.replace(graph_tar)
442
+ print(
443
+ f"Hydrated {path_name} from {source_tag} release asset "
444
+ f"sha256:{actual_oid} size:{total}"
445
+ )
446
+
447
+ hydrate_from_release("graph/wiki-graph.tar.gz", 100_000_000)
448
+ hydrate_from_release("graph/wiki-graph-runtime.tar.gz", 10_000_000)
449
+ PY
450
+
451
+ - name: Validate shipped graph artifacts
452
+ run: |
453
+ python src/validate_graph_artifacts.py \
454
+ --graph-dir graph \
455
+ --deep \
456
+ --min-nodes 100000 \
457
+ --min-edges 2000000 \
458
+ --min-skills-sh-nodes 89000 \
459
+ --min-semantic-edges 1000000 \
460
+ --expected-nodes 102928 \
461
+ --expected-edges 2913960 \
462
+ --expected-semantic-edges 1683193 \
463
+ --expected-harness-nodes 207 \
464
+ --expected-skills-sh-nodes 89471 \
465
+ --expected-skills-sh-catalog-entries 89465 \
466
+ --expected-skills-sh-converted 89465 \
467
+ --expected-skill-pages 91464 \
468
+ --expected-agent-pages 467 \
469
+ --expected-mcp-pages 10790 \
470
+ --expected-harness-pages 207 \
471
+ --line-threshold 180 \
472
+ --max-stage-lines 40
473
+
474
+ - name: Validate README and docs stats
475
+ run: python src/update_repo_stats.py --check
476
+
477
+ browser-security:
478
+ name: "Browser monitor security"
479
+ needs: classify
480
+ if: ${{ github.event_name == 'push' || needs.classify.outputs.browser_changed == 'true' }}
481
+ runs-on: ubuntu-latest
482
+ steps:
483
+ - name: Checkout
484
+ uses: actions/checkout@v5
485
+ with:
486
+ lfs: false
487
+
488
+ - name: Set up Python 3.12
489
+ uses: actions/setup-python@v6
490
+ with:
491
+ python-version: "3.12"
492
+ cache: pip
493
+ cache-dependency-path: pyproject.toml
494
+
495
+ - name: Install browser Python dependencies
496
+ run: |
497
+ python -m pip install --upgrade pip
498
+ python -m pip install ".[dev,browser]"
499
+
500
+ - name: Detect Playwright version
501
+ id: playwright
502
+ run: |
503
+ python - <<'PY' >> "$GITHUB_OUTPUT"
504
+ from importlib.metadata import version
505
+
506
+ print(f"version={version('playwright')}")
507
+ PY
508
+
509
+ - name: Cache Playwright browsers
510
+ uses: actions/cache@v4
511
+ with:
512
+ path: ~/.cache/ms-playwright
513
+ key: playwright-${{ runner.os }}-${{ steps.playwright.outputs.version }}
514
+
515
+ - name: Install browser runtime
516
+ run: |
517
+ python -m playwright install-deps chromium
518
+ python -m playwright install chromium
519
+
520
+ - name: Run browser security tests
521
+ run: pytest -q --no-cov -m browser src/tests/test_ctx_monitor_browser.py
522
+
523
+ package-build:
524
+ name: "Build wheel package"
525
+ needs: classify
526
+ if: ${{ github.event_name != 'pull_request' || (needs.classify.outputs.docs_only != 'true' && needs.classify.outputs.graph_only != 'true') }}
527
+ runs-on: ubuntu-latest
528
+ steps:
529
+ - name: Checkout
530
+ uses: actions/checkout@v5
531
+ with:
532
+ lfs: false
533
+
534
+ - name: Set up Python 3.12
535
+ uses: actions/setup-python@v6
536
+ with:
537
+ python-version: "3.12"
538
+ cache: pip
539
+ cache-dependency-path: pyproject.toml
540
+
541
+ - name: Build and inspect wheel
542
+ run: |
543
+ python -m pip install --upgrade pip
544
+ python -m pip install build twine
545
+ python -m build
546
+ python -m twine check dist/*
547
+ python - <<'PY'
548
+ from pathlib import Path
549
+
550
+ wheels = sorted(Path("dist").glob("*.whl"))
551
+ if len(wheels) != 1:
552
+ raise SystemExit(f"expected exactly one wheel, found {len(wheels)}")
553
+ wheel = wheels[0]
554
+ if not wheel.name.endswith("-py3-none-any.whl"):
555
+ message = f"expected a universal pure-Python wheel, got {wheel.name}"
556
+ raise SystemExit(message)
557
+ print(f"built package-smoke wheel: {wheel.name}")
558
+ PY
559
+
560
+ - name: Upload wheel artifact
561
+ uses: actions/upload-artifact@v7
562
+ with:
563
+ name: package-smoke-wheel
564
+ path: dist/*.whl
565
+ retention-days: 7
566
+
567
+ package-smoke:
568
+ name: "Wheel package smoke (${{ matrix.os }})"
569
+ needs:
570
+ - classify
571
+ - package-build
572
+ if: ${{ github.event_name != 'pull_request' || (needs.classify.outputs.docs_only != 'true' && needs.classify.outputs.graph_only != 'true') }}
573
+ runs-on: ${{ matrix.os }}
574
+ strategy:
575
+ fail-fast: false
576
+ matrix:
577
+ os: [ubuntu-latest, windows-latest, macos-latest]
578
+ defaults:
579
+ run:
580
+ shell: bash
581
+ steps:
582
+ - name: Checkout
583
+ uses: actions/checkout@v5
584
+ with:
585
+ lfs: false
586
+
587
+ - name: Set up Python 3.12
588
+ uses: actions/setup-python@v6
589
+ with:
590
+ python-version: "3.12"
591
+ cache: pip
592
+ cache-dependency-path: pyproject.toml
593
+
594
+ - name: Download wheel artifact
595
+ uses: actions/download-artifact@v7
596
+ with:
597
+ name: package-smoke-wheel
598
+ path: dist
599
+
600
+ - name: Install wheel in clean venv
601
+ run: |
602
+ python -m venv .venv-smoke
603
+ if [[ "$RUNNER_OS" == "Windows" ]]; then
604
+ . .venv-smoke/Scripts/activate
605
+ else
606
+ . .venv-smoke/bin/activate
607
+ fi
608
+ python -m pip install --upgrade pip
609
+ python -m pip install dist/*.whl
610
+ python -m pip check
611
+ python - <<'PY'
612
+ import tomllib
613
+ from importlib.metadata import entry_points, version
614
+
615
+ import ctx
616
+
617
+ with open("pyproject.toml", "rb") as fh:
618
+ expected_scripts = set(tomllib.load(fh)["project"]["scripts"])
619
+
620
+ dist_version = version("claude-ctx")
621
+ if ctx.__version__ != dist_version:
622
+ raise SystemExit(
623
+ f"ctx.__version__={ctx.__version__!r} != metadata {dist_version!r}"
624
+ )
625
+
626
+ script_eps = {
627
+ ep.name: ep
628
+ for ep in entry_points(group="console_scripts")
629
+ if ep.name == "ctx" or ep.name.startswith("ctx-")
630
+ }
631
+ missing = sorted(expected_scripts - set(script_eps))
632
+ extra = sorted(set(script_eps) - expected_scripts)
633
+ if missing or extra:
634
+ raise SystemExit(
635
+ "wheel console-script surface mismatch\n"
636
+ f"missing: {missing}\n"
637
+ f"extra: {extra}"
638
+ )
639
+ failures = []
640
+ for ep in script_eps.values():
641
+ try:
642
+ ep.load()
643
+ except Exception as exc:
644
+ failures.append(f"{ep.name}: {exc!r}")
645
+ if failures:
646
+ raise SystemExit("console script load failures:\n" + "\n".join(failures))
647
+
648
+ unsafe_help = {"ctx-mcp-server"}
649
+ safe_help = sorted(expected_scripts - unsafe_help)
650
+ with open(
651
+ "ctx-console-help.txt", "w", encoding="utf-8", newline="\n"
652
+ ) as fh:
653
+ fh.write("\n".join(safe_help) + "\n")
654
+ print(
655
+ f"loaded {len(script_eps)} ctx console scripts from wheel "
656
+ f"{dist_version}; help-smoke={len(safe_help)}"
657
+ )
658
+ PY
659
+ while IFS= read -r cmd; do
660
+ cmd="${cmd%$'\r'}"
661
+ [[ -z "$cmd" ]] && continue
662
+ echo "help smoke: $cmd"
663
+ "$cmd" --help >/dev/null
664
+ done < ctx-console-help.txt
665
+
666
+ clean-host-contract:
667
+ name: "Clean host contract"
668
+ needs: classify
669
+ if: ${{ github.event_name != 'pull_request' || (needs.classify.outputs.docs_only != 'true' && needs.classify.outputs.graph_only != 'true') }}
670
+ runs-on: ubuntu-latest
671
+ timeout-minutes: 25
672
+ steps:
673
+ - name: Checkout
674
+ uses: actions/checkout@v5
675
+ with:
676
+ lfs: false
677
+
678
+ - name: Set up Python 3.12
679
+ uses: actions/setup-python@v6
680
+ with:
681
+ python-version: "3.12"
682
+ cache: pip
683
+ cache-dependency-path: pyproject.toml
684
+
685
+ - name: Upgrade pip
686
+ run: python -m pip install --upgrade pip
687
+
688
+ - name: Run clean-host contract
689
+ run: python scripts/clean_host_contract.py --fast
690
+
691
+ no-test-no-merge:
692
+ name: "Contract changes must touch tests"
693
+ needs: classify
694
+ runs-on: ubuntu-latest
695
+ if: ${{ github.event_name == 'pull_request' && needs.classify.outputs.docs_only != 'true' && needs.classify.outputs.graph_only != 'true' }}
696
+ steps:
697
+ - name: Checkout with full history
698
+ uses: actions/checkout@v5
699
+ with:
700
+ fetch-depth: 0
701
+ lfs: false
702
+
703
+ - name: Enforce test-coverage-per-PR policy
704
+ # Policy: any product or CI/package contract change
705
+ # must be accompanied by at least one test file change in the
706
+ # same PR. Exemptions:
707
+ # - Pure docs / comment changes (matched by a trivial heuristic)
708
+ # - Config JSON (src/config.json) — schema changes get tests
709
+ # for the accessor, not the JSON itself.
710
+ # - Changes ONLY inside __pycache__, .pyc files, etc.
711
+ run: |
712
+ set -euo pipefail
713
+ BASE="${{ github.event.pull_request.base.sha }}"
714
+ HEAD="${{ github.event.pull_request.head.sha }}"
715
+ LABELS='${{ toJson(github.event.pull_request.labels.*.name) }}'
716
+ python scripts/ci_no_test_policy.py \
717
+ --base "$BASE" \
718
+ --head "$HEAD" \
719
+ --labels-json "$LABELS"
720
+
721
+ ci-required:
722
+ name: "CI required"
723
+ runs-on: ubuntu-latest
724
+ needs:
725
+ - classify
726
+ - static
727
+ - unit-linux
728
+ - similarity-integration
729
+ - test
730
+ - contract-compat
731
+ - e2e-canary
732
+ - docs-check
733
+ - graph-check
734
+ - browser-security
735
+ - package-smoke
736
+ - package-build
737
+ - clean-host-contract
738
+ - no-test-no-merge
739
+ if: ${{ always() }}
740
+ steps:
741
+ - name: Checkout
742
+ uses: actions/checkout@v5
743
+ with:
744
+ lfs: false
745
+
746
+ - name: Check required job results
747
+ env:
748
+ EVENT_NAME: ${{ github.event_name }}
749
+ NEEDS_JSON: ${{ toJson(needs) }}
750
+ run: python scripts/ci_required.py
.github/workflows/xdist-experiment.yml CHANGED
@@ -1,90 +1,90 @@
1
- name: Xdist experiment
2
-
3
- on:
4
- workflow_dispatch:
5
- pull_request:
6
- branches: [main]
7
- paths:
8
- - ".github/workflows/xdist-experiment.yml"
9
-
10
- env:
11
- FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
12
-
13
- concurrency:
14
- group: xdist-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
15
- cancel-in-progress: true
16
-
17
- permissions:
18
- contents: read
19
-
20
- jobs:
21
- xdist:
22
- name: xdist (${{ matrix.os }} / py${{ matrix.python-version }})
23
- runs-on: ${{ matrix.os }}
24
- strategy:
25
- fail-fast: false
26
- matrix:
27
- os: [ubuntu-latest, windows-latest, macos-latest]
28
- python-version: ["3.12"]
29
- defaults:
30
- run:
31
- shell: bash
32
- steps:
33
- - name: Checkout
34
- uses: actions/checkout@v5
35
- with:
36
- lfs: false
37
-
38
- - name: Set up Python ${{ matrix.python-version }}
39
- uses: actions/setup-python@v6
40
- with:
41
- python-version: ${{ matrix.python-version }}
42
- cache: pip
43
- cache-dependency-path: pyproject.toml
44
-
45
- - name: Install experiment dependencies
46
- run: |
47
- python -m pip install --upgrade pip
48
- python -m pip install ".[dev]" pytest-xdist
49
-
50
- - name: Run xdist experiment
51
- run: |
52
- set +e
53
- start="$(python - <<'PY'
54
- import time
55
- print(time.monotonic())
56
- PY
57
- )"
58
- python -m pytest -q -m "not browser" --no-cov \
59
- -n 2 \
60
- --dist=loadfile \
61
- --max-worker-restart=0 \
62
- --durations=25
63
- status="$?"
64
- end="$(python - <<'PY'
65
- import time
66
- print(time.monotonic())
67
- PY
68
- )"
69
- elapsed="$(python - "$start" "$end" <<'PY'
70
- import sys
71
- start = float(sys.argv[1])
72
- end = float(sys.argv[2])
73
- print(f"{end - start:.1f}")
74
- PY
75
- )"
76
-
77
- {
78
- echo "## pytest-xdist experiment"
79
- echo ""
80
- echo "- OS: $RUNNER_OS"
81
- echo "- Python: ${{ matrix.python-version }}"
82
- echo "- Command: \`python -m pytest -q -m \"not browser\" --no-cov -n 2 --dist=loadfile --max-worker-restart=0 --durations=25\`"
83
- echo "- Exit code: $status"
84
- echo "- Elapsed seconds: $elapsed"
85
- } >> "$GITHUB_STEP_SUMMARY"
86
-
87
- if [ "$status" -ne 0 ]; then
88
- echo "::warning::xdist experiment failed on $RUNNER_OS with exit code $status"
89
- fi
90
- exit "$status"
 
1
+ name: Xdist experiment
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ pull_request:
6
+ branches: [main]
7
+ paths:
8
+ - ".github/workflows/xdist-experiment.yml"
9
+
10
+ env:
11
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
12
+
13
+ concurrency:
14
+ group: xdist-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
15
+ cancel-in-progress: true
16
+
17
+ permissions:
18
+ contents: read
19
+
20
+ jobs:
21
+ xdist:
22
+ name: xdist (${{ matrix.os }} / py${{ matrix.python-version }})
23
+ runs-on: ${{ matrix.os }}
24
+ strategy:
25
+ fail-fast: false
26
+ matrix:
27
+ os: [ubuntu-latest, windows-latest, macos-latest]
28
+ python-version: ["3.12"]
29
+ defaults:
30
+ run:
31
+ shell: bash
32
+ steps:
33
+ - name: Checkout
34
+ uses: actions/checkout@v5
35
+ with:
36
+ lfs: false
37
+
38
+ - name: Set up Python ${{ matrix.python-version }}
39
+ uses: actions/setup-python@v6
40
+ with:
41
+ python-version: ${{ matrix.python-version }}
42
+ cache: pip
43
+ cache-dependency-path: pyproject.toml
44
+
45
+ - name: Install experiment dependencies
46
+ run: |
47
+ python -m pip install --upgrade pip
48
+ python -m pip install ".[dev]" pytest-xdist
49
+
50
+ - name: Run xdist experiment
51
+ run: |
52
+ set +e
53
+ start="$(python - <<'PY'
54
+ import time
55
+ print(time.monotonic())
56
+ PY
57
+ )"
58
+ python -m pytest -q -m "not browser" --no-cov \
59
+ -n 2 \
60
+ --dist=loadfile \
61
+ --max-worker-restart=0 \
62
+ --durations=25
63
+ status="$?"
64
+ end="$(python - <<'PY'
65
+ import time
66
+ print(time.monotonic())
67
+ PY
68
+ )"
69
+ elapsed="$(python - "$start" "$end" <<'PY'
70
+ import sys
71
+ start = float(sys.argv[1])
72
+ end = float(sys.argv[2])
73
+ print(f"{end - start:.1f}")
74
+ PY
75
+ )"
76
+
77
+ {
78
+ echo "## pytest-xdist experiment"
79
+ echo ""
80
+ echo "- OS: $RUNNER_OS"
81
+ echo "- Python: ${{ matrix.python-version }}"
82
+ echo "- Command: \`python -m pytest -q -m \"not browser\" --no-cov -n 2 --dist=loadfile --max-worker-restart=0 --durations=25\`"
83
+ echo "- Exit code: $status"
84
+ echo "- Elapsed seconds: $elapsed"
85
+ } >> "$GITHUB_STEP_SUMMARY"
86
+
87
+ if [ "$status" -ne 0 ]; then
88
+ echo "::warning::xdist experiment failed on $RUNNER_OS with exit code $status"
89
+ fi
90
+ exit "$status"
.gitignore CHANGED
@@ -1,103 +1,103 @@
1
- # Babysitter orchestration — runs, state, and local SDK install
2
- .a5c/
3
-
4
- # Python
5
- __pycache__/
6
- *.py[cod]
7
- *.pyo
8
- *.pyd
9
- .Python
10
- *.egg-info/
11
- dist/
12
- build/
13
- *.egg
14
- .eggs/
15
- .venv/
16
- .venv-release/
17
- .venv-smoke/
18
- venv/
19
- env/
20
- .env
21
-
22
- # Node / npm
23
- node_modules/
24
- npm-debug.log*
25
- yarn-debug.log*
26
- yarn-error.log*
27
- package-lock.json
28
-
29
- # Temp / runtime outputs
30
- /tmp/
31
- *.tmp
32
- *.bak
33
- *.swp
34
- *.swo
35
- .DS_Store
36
- Thumbs.db
37
-
38
- # Skill system runtime files (generated, not source)
39
- /skills/skill-router/check-gates.md
40
- /skills/skill-router/failure-log.md
41
-
42
- # Agent directives (cloned from global ~/.claude/CLAUDE.md — personal, not for sharing)
43
- CLAUDE.md
44
-
45
- # Per-workspace Claude Code settings (local permissions, user-specific)
46
- .claude/
47
-
48
- # Wiki (runtime knowledge store, not source)
49
- # Uncomment to exclude the wiki from the repo:
50
- # ~/.claude/skill-wiki/
51
-
52
- # Intent log and pending signals (session runtime)
53
- # These live in ~/.claude/ so already outside the repo
54
-
55
- # Internal review artifacts and working notes (not public docs)
56
- /docs/reports/
57
- /docs/plans/20[0-9][0-9]-*/
58
- /docs/plans/20[0-9][0-9]-*.md
59
- /internal/reviews/
60
-
61
- # Generated graph review artifacts (regenerate locally when needed)
62
- /graph/*-report.md
63
- /graph/*-report.json
64
- /graph/*-report.json.gz
65
- /graph/*.staged
66
- /graph/*.partial
67
- /graph/*.lock
68
- *.promotion.json
69
- /graph/tag-backfill.md
70
- /graph/tag-backfill.json
71
-
72
- # Secrets / config overrides
73
- skill-system-config.json
74
- .env.local
75
- .env.development
76
- .env.production
77
- .envrc
78
-
79
- # Strix security scanner (local venv, instructions, output)
80
- .strix-venv/
81
- .strix-instructions.txt
82
- .strix-output/
83
- .strix-out/
84
- .strix-run.log
85
- .strix-preview-wiki/
86
- .imported-deployed/
87
- strix_runs/
88
-
89
- # Test coverage
90
- .coverage
91
- .ci-preflight-dist/
92
- htmlcov/
93
- .pytest_cache/
94
- .mypy_cache/
95
- .mypy_cache*/
96
- .ruff_cache/
97
- .hypothesis/
98
-
99
- # Local code-review graph experiments
100
- .code-review-graph/
101
-
102
- # MkDocs build output
103
- /site/
 
1
+ # Babysitter orchestration — runs, state, and local SDK install
2
+ .a5c/
3
+
4
+ # Python
5
+ __pycache__/
6
+ *.py[cod]
7
+ *.pyo
8
+ *.pyd
9
+ .Python
10
+ *.egg-info/
11
+ dist/
12
+ build/
13
+ *.egg
14
+ .eggs/
15
+ .venv/
16
+ .venv-release/
17
+ .venv-smoke/
18
+ venv/
19
+ env/
20
+ .env
21
+
22
+ # Node / npm
23
+ node_modules/
24
+ npm-debug.log*
25
+ yarn-debug.log*
26
+ yarn-error.log*
27
+ package-lock.json
28
+
29
+ # Temp / runtime outputs
30
+ /tmp/
31
+ *.tmp
32
+ *.bak
33
+ *.swp
34
+ *.swo
35
+ .DS_Store
36
+ Thumbs.db
37
+
38
+ # Skill system runtime files (generated, not source)
39
+ /skills/skill-router/check-gates.md
40
+ /skills/skill-router/failure-log.md
41
+
42
+ # Agent directives (cloned from global ~/.claude/CLAUDE.md — personal, not for sharing)
43
+ CLAUDE.md
44
+
45
+ # Per-workspace Claude Code settings (local permissions, user-specific)
46
+ .claude/
47
+
48
+ # Wiki (runtime knowledge store, not source)
49
+ # Uncomment to exclude the wiki from the repo:
50
+ # ~/.claude/skill-wiki/
51
+
52
+ # Intent log and pending signals (session runtime)
53
+ # These live in ~/.claude/ so already outside the repo
54
+
55
+ # Internal review artifacts and working notes (not public docs)
56
+ /docs/reports/
57
+ /docs/plans/20[0-9][0-9]-*/
58
+ /docs/plans/20[0-9][0-9]-*.md
59
+ /internal/reviews/
60
+
61
+ # Generated graph review artifacts (regenerate locally when needed)
62
+ /graph/*-report.md
63
+ /graph/*-report.json
64
+ /graph/*-report.json.gz
65
+ /graph/*.staged
66
+ /graph/*.partial
67
+ /graph/*.lock
68
+ *.promotion.json
69
+ /graph/tag-backfill.md
70
+ /graph/tag-backfill.json
71
+
72
+ # Secrets / config overrides
73
+ skill-system-config.json
74
+ .env.local
75
+ .env.development
76
+ .env.production
77
+ .envrc
78
+
79
+ # Strix security scanner (local venv, instructions, output)
80
+ .strix-venv/
81
+ .strix-instructions.txt
82
+ .strix-output/
83
+ .strix-out/
84
+ .strix-run.log
85
+ .strix-preview-wiki/
86
+ .imported-deployed/
87
+ strix_runs/
88
+
89
+ # Test coverage
90
+ .coverage
91
+ .ci-preflight-dist/
92
+ htmlcov/
93
+ .pytest_cache/
94
+ .mypy_cache/
95
+ .mypy_cache*/
96
+ .ruff_cache/
97
+ .hypothesis/
98
+
99
+ # Local code-review graph experiments
100
+ .code-review-graph/
101
+
102
+ # MkDocs build output
103
+ /site/
.hypothesis/.gitignore DELETED
@@ -1,9 +0,0 @@
1
- # This .gitignore file was automatically created by Hypothesis. Hypothesis gitignores
2
- # .hypothesis by default, because we generally recommend that .hypothesis not be checked
3
- # into version control.
4
- #
5
- # If you *would* like to check .hypothesis into version control, you should delete this
6
- # file. Hypothesis will not re-create this .gitignore unless .hypothesis is deleted (and
7
- # if it does, that's a bug - please report it!)
8
-
9
- *
 
 
 
 
 
 
 
 
 
 
.hypothesis/constants/0019c6a267d025e1 DELETED
@@ -1,4 +0,0 @@
1
- # file: C:\Users\solun\AppData\Roaming\Python\Python311\site-packages\networkx\utils\__init__.py
2
- # hypothesis_version: 6.152.1
3
-
4
- []
 
 
 
 
 
.hypothesis/constants/004e722d2b39195a DELETED
@@ -1,4 +0,0 @@
1
- # file: C:\Users\solun\AppData\Roaming\Python\Python311\site-packages\_pytest\freeze_support.py
2
- # hypothesis_version: 6.152.1
3
-
4
- ['.']
 
 
 
 
 
.hypothesis/constants/009298dc5190c8bd DELETED
@@ -1,4 +0,0 @@
1
- # file: C:\Users\solun\AppData\Roaming\Python\Python311\site-packages\networkx\algorithms\polynomials.py
2
- # hypothesis_version: 6.152.1
3
-
4
- ['chromatic_polynomial', 'contraction_idx', 'directed', 'tutte_polynomial', 'x', 'y']
 
 
 
 
 
.hypothesis/constants/010258c38047fd64 DELETED
@@ -1,4 +0,0 @@
1
- # file: C:\Users\solun\AppData\Roaming\Python\Python311\site-packages\coverage\lcovreport.py
2
- # hypothesis_version: 6.152.1
3
-
4
- [',', '-', '0', '1', '=', 'LCOV report', 'ascii', 'end_of_record\n', 'function', 'utf-8']
 
 
 
 
 
.hypothesis/constants/017531f0e829ce4f DELETED
@@ -1,4 +0,0 @@
1
- # file: C:\Users\solun\AppData\Roaming\Python\Python311\site-packages\networkx\algorithms\summarization.py
2
- # hypothesis_version: 6.152.1
3
-
4
- ['Supernode-', '[edge_attributes]', '[node_attributes]', 'dedensify', 'group', 'not copy', 'snap_aggregation', 'types']
 
 
 
 
 
.hypothesis/constants/0197b3c42bd36715 DELETED
@@ -1,4 +0,0 @@
1
- # file: C:\Steves_Files\Work\Research_and_Papers\ctx\src\ctx\core\graph\resolve_graph.py
2
- # hypothesis_version: 6.152.1
3
-
4
- [0.0, 1.0, ',', ', ', '--hops', '--json', '--matched', '--tags', '--top', '1', ':', 'Output as JSON', '__main__', 'agent:', 'ann:v1:', 'ann_attach', 'artifacts', 'attach_key', 'communities', 'ctx_graph_path', 'delta', 'edges', 'export_id', 'graph', 'graph-walk', 'graph.json', 'graphify-out', 'harness:', 'id', 'kind', 'label', 'links', 'matching_tags', 'mcp-server:', 'mode', 'name', 'never_load', 'node_id', 'nodes', 'normalized_score', 'on', 'replace_scope', 'report', 'results', 'score', 'semantic_build_floor', 'semantic_sim', 'shared_tags', 'skill', 'skill:', 'source', 'store_true', 'tag-search', 'tag_sim', 'tags', 'target', 'token_sim', 'true', 'type', 'utf-8', 'via', 'weight', 'y', 'yes', '~/.claude/skill-wiki']
 
 
 
 
 
.hypothesis/constants/0199ac9602548c81 DELETED
@@ -1,4 +0,0 @@
1
- # file: C:\Users\solun\AppData\Roaming\Python\Python311\site-packages\networkx\algorithms\connectivity\edge_augmentation.py
2
- # hypothesis_version: 6.152.1
3
-
4
- ['MetaEdge', 'directed', 'generator', 'is_k_edge_connected', 'k_edge_augmentation', 'mapping', 'members', 'meta_uv', 'multigraph', 'no available edges', 'uv', 'w', 'weight']
 
 
 
 
 
.hypothesis/constants/01e01103d68a3193 DELETED
@@ -1,4 +0,0 @@
1
- # file: C:\Users\solun\AppData\Roaming\Python\Python311\site-packages\networkx\algorithms\centrality\katz.py
2
- # hypothesis_version: 6.152.1
3
-
4
- [1e-06, 0.1, 1.0, 1000, 'katz_centrality', 'multigraph', 'weight']
 
 
 
 
 
.hypothesis/constants/0213343f1393a3fa DELETED
@@ -1,4 +0,0 @@
1
- # file: C:\Users\solun\AppData\Roaming\Python\Python311\site-packages\networkx\algorithms\components\connected.py
2
- # hypothesis_version: 6.152.1
3
-
4
- ['connected_components', 'directed', 'is_connected']
 
 
 
 
 
.hypothesis/constants/031f85f1c11bb00b DELETED
@@ -1,4 +0,0 @@
1
- # file: C:\Users\solun\AppData\Roaming\Python\Python311\site-packages\numpy\_typing\_nbit_base.py
2
- # hypothesis_version: 6.152.1
3
-
4
- ['NBitBase', '_128Bit', '_16Bit', '_32Bit', '_64Bit', '_8Bit', '_96Bit', 'numpy._typing', 'numpy.typing']
 
 
 
 
 
.hypothesis/constants/03a0bc33c3be41ac DELETED
@@ -1,4 +0,0 @@
1
- # file: C:\Users\solun\AppData\Roaming\Python\Python311\site-packages\_pytest\config\findpaths.py
2
- # hypothesis_version: 6.152.1
3
-
4
- ['-', '.cfg', '.ini', '.pytest.ini', '.pytest.toml', '.toml', '::', '=', 'file', 'ini', 'ini_options', 'override', 'pyproject.toml', 'pytest', 'pytest.ini', 'pytest.toml', 'setup.cfg', 'setup.py', 'toml', 'tool', 'tool:pytest', 'tox.ini', 'utf-8']
 
 
 
 
 
.hypothesis/constants/03d65cf548ce3b23 DELETED
@@ -1,4 +0,0 @@
1
- # file: C:\Users\solun\AppData\Roaming\Python\Python311\site-packages\numpy\_core\memmap.py
2
- # hypothesis_version: 6.152.1
3
-
4
- [b'\x00', -100.0, 'C', '_mmap', 'b', 'c', 'copyonwrite', 'flush', 'memmap', 'name', 'numpy', 'r', 'r+', 'read', 'readonly', 'readwrite', 'w+', 'write']
 
 
 
 
 
.hypothesis/constants/041b82baf95116f0 DELETED
@@ -1,4 +0,0 @@
1
- # file: C:\Users\solun\AppData\Roaming\Python\Python311\site-packages\_pytest\stash.py
2
- # hypothesis_version: 6.152.1
3
-
4
- ['D', 'Stash', 'StashKey', 'T', '_storage']
 
 
 
 
 
.hypothesis/constants/044c9664c732c0e2 DELETED
@@ -1,4 +0,0 @@
1
- # file: C:\Users\solun\AppData\Roaming\Python\Python311\site-packages\networkx\algorithms\community\label_propagation.py
2
- # hypothesis_version: 6.152.1
3
-
4
- ['asyn_lpa_communities', 'directed', 'seed', 'weight']
 
 
 
 
 
.hypothesis/constants/0570a7c52cd987f9 DELETED
@@ -1,4 +0,0 @@
1
- # file: C:\Users\solun\AppData\Roaming\Python\Python311\site-packages\yaml\resolver.py
2
- # hypothesis_version: 6.152.1
3
-
4
- ['!&*', '-+0123456789', '-+0123456789.', '0123456789', '<', '=', 'BaseResolver', 'N', 'Resolver', '^(?:!|&|\\*)$', '^(?:<<)$', '^(?:=)$', 'n', 'yYnNtTfFoO', 'yaml_path_resolvers', '~']
 
 
 
 
 
.hypothesis/constants/0628f2618034526e DELETED
@@ -1,4 +0,0 @@
1
- # file: C:\Users\solun\AppData\Roaming\Python\Python311\site-packages\anyio\abc\_resources.py
2
- # hypothesis_version: 6.152.1
3
-
4
- ['T']
 
 
 
 
 
.hypothesis/constants/068c9e16d4886008 DELETED
@@ -1,4 +0,0 @@
1
- # file: C:\Users\solun\AppData\Roaming\Python\Python311\site-packages\_pytest\terminalprogress.py
2
- # hypothesis_version: 6.152.1
3
-
4
- ['TERM', 'dumb', 'terminalreporter']
 
 
 
 
 
.hypothesis/constants/06a795a0e79c102f DELETED
@@ -1,4 +0,0 @@
1
- # file: C:\Steves_Files\Work\Research_and_Papers\ctx\scripts\build_dashboard_graph_index.py
2
- # hypothesis_version: 6.152.1
3
-
4
- [0.0, 1.0, 10000, ',', '--graph-json', '--output', '--top-k', ':', 'VACUUM', '__main__', 'degree', 'description', 'edge_reasons', 'edges', 'edges_count', 'export_id', 'graph', 'id', 'label', 'links', 'max_degree', 'nodes', 'nodes_count', 'quality_score', 'reasons', 'semantic', 'shared_tags', 'slug_token_sim', 'source', 'source_overlap', 'tag_sim', 'tags', 'target', 'top_k', 'type', 'usage_score', 'utf-8', 'version', 'weight']
 
 
 
 
 
.hypothesis/constants/072d43d60d40fd51 DELETED
@@ -1,4 +0,0 @@
1
- # file: C:\Steves_Files\Work\Research_and_Papers\ctx\src\backup_config.py
2
- # hypothesis_version: 6.152.1
3
-
4
- [1024, '%Y%m%dT%H%M%SZ', '..', '.claude.json', '.credentials.json', '/', ':', 'AGENTS.md', 'CLAUDE.md', '\\', '\\\\', 'agents', 'backup', 'claude.json', 'dest', 'excludes', 'full', 'hybrid', 'incremental', 'keep_daily', 'keep_latest', 'max_file_bytes', 'memory_glob', 'name_format', 'pending-skills.json', 'retention', 'scope', 'settings.json', 'skill-manifest.json', 'skill-registry.json', 'skills', 'snapshot_dir', 'src', 'stats-cache.json', 'timestamp_format', 'top_files', 'trees', 'trees[].dest', 'trees[].src', 'user-profile.json', 'utf-8', '{timestamp}', '~/.claude/backups']
 
 
 
 
 
.hypothesis/constants/0736e2d4acddd01a DELETED
@@ -1,4 +0,0 @@
1
- # file: C:\Users\solun\AppData\Roaming\Python\Python311\site-packages\yaml\error.py
2
- # hypothesis_version: 6.152.1
3
-
4
- ['\x00\r\n\x85\u2028\u2029', ' ... ', ':\n', 'Mark', 'MarkedYAMLError', 'YAMLError', '^']
 
 
 
 
 
.hypothesis/constants/07475514dc0bbadb DELETED
@@ -1,4 +0,0 @@
1
- # file: C:\Users\solun\AppData\Roaming\Python\Python311\site-packages\pytest\__main__.py
2
- # hypothesis_version: 6.152.1
3
-
4
- ['__main__']
 
 
 
 
 
.hypothesis/constants/075aa0dd31e12c03 DELETED
@@ -1,4 +0,0 @@
1
- # file: C:\Users\solun\AppData\Roaming\Python\Python311\site-packages\xdist\_version.py
2
- # hypothesis_version: 6.152.1
3
-
4
- ['3.8.0', '__version__', '__version_tuple__', 'version', 'version_tuple']
 
 
 
 
 
.hypothesis/constants/084c48810f649441 DELETED
@@ -1,4 +0,0 @@
1
- # file: C:\Users\solun\AppData\Roaming\Python\Python311\site-packages\networkx\readwrite\text.py
2
- # hypothesis_version: 6.152.1
3
-
4
- [' ...', '!', '+', '+-- ', ', ', '-', '...', ': ', '<-', 'L-- ', 'L-> ', '_', 'as_dict', 'backedge', 'collapse', 'empty', 'label', 'mapping', 'vertical_edge', 'w', 'write', 'write_network_text', '|', '| ', '|-- ', '|-> ', '─', '│', '│ ', '└── ', '└─╼ ', '├── ', '├─╼ ', '╎ ', '╙', '╙── ', '╟── ', '╽', '╾']
 
 
 
 
 
.hypothesis/constants/08a45b14b7a1aa82 DELETED
@@ -1,4 +0,0 @@
1
- # file: C:\Users\solun\AppData\Roaming\Python\Python311\site-packages\xdist\_path.py
2
- # hypothesis_version: 6.152.1
3
-
4
- []
 
 
 
 
 
.hypothesis/constants/0964dd49257e9b00 DELETED
@@ -1,4 +0,0 @@
1
- # file: C:\Users\solun\AppData\Roaming\Python\Python311\site-packages\markdown\treeprocessors.py
2
- # hypothesis_version: 6.152.1
3
-
4
- ['\n%s', '%04d', 'br', 'code', 'd', 'inline', 'pre', 'prettify', 'unescape', '{}(\\d+){}', '{}{}{}', '{}{}{}{}']
 
 
 
 
 
.hypothesis/constants/0967afddd3cfffcc DELETED
@@ -1,4 +0,0 @@
1
- # file: C:\Users\solun\AppData\Roaming\Python\Python311\site-packages\networkx\algorithms\centrality\eigenvector.py
2
- # hypothesis_version: 6.152.1
3
-
4
- [1e-06, 100, 'LR', 'multigraph', 'weight']
 
 
 
 
 
.hypothesis/constants/09a20f6b52e6dcc7 DELETED
@@ -1,4 +0,0 @@
1
- # file: C:\Users\solun\AppData\Roaming\Python\Python311\site-packages\networkx\algorithms\minors\__init__.py
2
- # hypothesis_version: 6.152.1
3
-
4
- ['contracted_edge', 'contracted_nodes', 'equivalence_classes', 'identified_nodes', 'quotient_graph']
 
 
 
 
 
.hypothesis/constants/09ae28b390a241d0 DELETED
@@ -1,4 +0,0 @@
1
- # file: C:\Users\solun\AppData\Roaming\Python\Python311\site-packages\sortedcontainers\sorteddict.py
2
- # hypothesis_version: 6.152.1
3
-
4
- [50331648, ', ', '<not-given>', 'items', 'iteritems', 'iterkeys', 'itervalues', 'keys', 'values', 'viewitems', 'viewkeys', 'viewvalues', '{0!r}, ', '{0!r}: {1!r}', '{0}({1}{{{2}}})']
 
 
 
 
 
.hypothesis/constants/09bba2fe6bc8792d DELETED
@@ -1,4 +0,0 @@
1
- # file: C:\Steves_Files\Work\Research_and_Papers\ctx\src\ctx\core\source_registry.py
2
- # hypothesis_version: 6.152.1
3
-
4
- ['-', '--json', '--registry', '-nc', 'Apache-2.0', 'CC BY-NC 4.0', 'ExternalSourceRecord', 'MIT', '_', '__main__', 'agents-md', 'agpl', 'apache-2.0', 'bsd-2-clause', 'bsd-3-clause', 'cc-by-4.0', 'cc0-1.0', 'copyleft license', 'explicit-permission', 'external-link', 'full', 'full-body', 'gpl', 'harness', 'import_mode', 'isc', 'julius-caveman', 'knowledge-protocol', 'lat-md', 'lgpl', 'license', 'mattpocock-skills', 'metadata-only', 'mit', 'mpl-2.0', 'name', 'no-explicit', 'noncommercial', 'notes', 'optillm', 'owner-permission', 'permission_reference', 'permission_status', 'revision', 'skill-suite', 'source_kind', 'sources', 'store_true', 'unknown', 'unknown license', 'unlicense', 'url', 'utf-8']
 
 
 
 
 
.hypothesis/constants/09e51e4d7e8d7e87 DELETED
@@ -1,4 +0,0 @@
1
- # file: C:\Users\solun\AppData\Roaming\Python\Python311\site-packages\networkx\algorithms\connectivity\disjoint_paths.py
2
- # hypothesis_version: 6.152.1
3
-
4
- ['G', 'auxiliary', 'auxiliary?', 'capacity', 'cutoff', 'edge_disjoint_paths', 'flow', 'flow_func', 'flow_value', 'id', 'inf', 'mapping', 'node_disjoint_paths', 'residual', 'two_phase', 'value_only']
 
 
 
 
 
.hypothesis/constants/0a6403dce3b6e280 DELETED
@@ -1,4 +0,0 @@
1
- # file: C:\Users\solun\AppData\Roaming\Python\Python311\site-packages\coverage\env.py
2
- # hypothesis_version: 6.152.1
3
-
4
- ['+', 'COVERAGE_COVERAGE', 'COVERAGE_TESTING', 'CPython', 'PYBEHAVIOR', 'PyPy', 'True', '_', '_is_gil_enabled', 'alpha', 'darwin', 'debug_info', 'linux', 'monitoring', 'win32']
 
 
 
 
 
.hypothesis/constants/0ac765fb060691b5 DELETED
@@ -1,4 +0,0 @@
1
- # file: C:\Users\solun\AppData\Roaming\Python\Python311\site-packages\networkx\algorithms\isolate.py
2
- # hypothesis_version: 6.152.1
3
-
4
- ['is_isolate', 'isolates', 'number_of_isolates']
 
 
 
 
 
.hypothesis/constants/0b1d2411e5eaa76a DELETED
@@ -1,4 +0,0 @@
1
- # file: C:\Users\solun\AppData\Roaming\Python\Python311\site-packages\networkx\algorithms\__init__.py
2
- # hypothesis_version: 6.152.1
3
-
4
- []
 
 
 
 
 
.hypothesis/constants/0bb70d1826311e40 DELETED
@@ -1,4 +0,0 @@
1
- # file: C:\Users\solun\AppData\Roaming\Python\Python311\site-packages\execnet\gateway_bootstrap.py
2
- # hypothesis_version: 6.152.1
3
-
4
- [b'1', 255, 'except NameError:', 'import socket', 'import sys', 'remoteaddress', 'sys.stdout.flush()', 'try: execmodel', 'utf-8']
 
 
 
 
 
.hypothesis/constants/0bc3b2f77e74ecd9 DELETED
@@ -1,4 +0,0 @@
1
- # file: C:\Users\solun\AppData\Roaming\Python\Python311\site-packages\pytest_mock\__init__.py
2
- # hypothesis_version: 6.152.1
3
-
4
- ['AsyncMockType', 'MockFixture', 'MockType', 'MockerFixture', 'PytestMockWarning', 'class_mocker', 'mocker', 'module_mocker', 'package_mocker', 'pytest_addoption', 'pytest_configure', 'session_mocker']
 
 
 
 
 
.hypothesis/constants/0c23327e39eaa492 DELETED
@@ -1,4 +0,0 @@
1
- # file: C:\Users\solun\AppData\Roaming\Python\Python311\site-packages\networkx\algorithms\components\__init__.py
2
- # hypothesis_version: 6.152.1
3
-
4
- []
 
 
 
 
 
.hypothesis/constants/0c347c1a609186cf DELETED
@@ -1,4 +0,0 @@
1
- # file: C:\Steves_Files\Work\Research_and_Papers\ctx\src\ctx\adapters\generic\compaction.py
2
- # hypothesis_version: 6.152.1
3
-
4
- [0.3, 600, 60000, ')', ', ', 'CompactionResult', '[Compacted', '[summary was empty]', '] ', 'assistant', 'system', 'tool', 'user']
 
 
 
 
 
.hypothesis/constants/0c7b341cd0f9acb2 DELETED
@@ -1,4 +0,0 @@
1
- # file: C:\Steves_Files\Work\Research_and_Papers\ctx\src\mcp_fetch.py
2
- # hypothesis_version: 6.152.1
3
-
4
- [',', '--limit', '--list-sources', '--refresh', '--source', '--verbose', '-v', ':', 'NAME', '__main__', 'all', 'count', 'ctx-mcp-fetch', 'reconfigure', 'replace', 'store_true', 'utf-8']
 
 
 
 
 
.hypothesis/constants/0cfa12dfbf9e9abb DELETED
@@ -1,4 +0,0 @@
1
- # file: C:\Users\solun\AppData\Roaming\Python\Python311\site-packages\colorama\initialise.py
2
- # hypothesis_version: 6.152.1
3
-
4
- ['win32']
 
 
 
 
 
.hypothesis/constants/0d65a410db824952 DELETED
@@ -1,4 +0,0 @@
1
- # file: C:\Users\solun\AppData\Roaming\Python\Python311\site-packages\yaml\representer.py
2
- # hypothesis_version: 6.152.1
3
-
4
- [0.0, 1.0, 1e+300, '%r', '%r%rj', '%r+%rj', '%rj', '%s.%s', '-.inf', '.', '.0e', '.inf', '.nan', 'BaseRepresenter', 'Representer', 'RepresenterError', 'SafeRepresenter', '__getstate__', '__newobj__', '__reduce__', '__reduce_ex__', 'args', 'ascii', 'dictitems', 'e', 'encodebytes', 'false', 'items', 'listitems', 'null', 'state', 'true', 'yaml_representers', '|']