From 70739c4e0b2df7a19067fd2101546575b4c0495d Mon Sep 17 00:00:00 2001 From: Offending Commit Date: Sat, 24 Oct 2026 16:36:20 -0500 Subject: [PATCH 1/5] fix(contract): read SCHEMA_VERSION from hermes_state_common Hermes v0.22.5 removed the PLUGIN-COMPAT pointer layer (upstream #126164), so hermes_state no longer re-exports SCHEMA_VERSION and the contract module failed to import against bf0203a7. Import it from hermes_state_common first and fall back to hermes_state for older hosts. SessionDB still comes from hermes_state. Claude-Session: https://claude.ai/code/session_01VDtQ1pDTJksqAcSdrGePVb --- tests/test_hermes_contract.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_hermes_contract.py b/tests/test_hermes_contract.py index 638c25a..bd29a2e 100644 --- a/tests/test_hermes_contract.py +++ b/tests/test_hermes_contract.py @@ -68,7 +68,11 @@ def _try(): run_tool_execution_middleware, ) from tools.registry import registry # type: ignore - from hermes_state import SCHEMA_VERSION, SessionDB # type: ignore + from hermes_state import SessionDB # type: ignore + try: # v0.22.5 dropped the hermes_state re-export (upstream #126164) + from hermes_state_common import SCHEMA_VERSION # type: ignore + except ImportError: + from hermes_state import SCHEMA_VERSION # type: ignore from agent.context_engine import ContextEngine # type: ignore # A stale checkout that predates plugin-owned skills is not the From b961ec81dbbac35cbb26f10e01e642e9a947022c Mon Sep 17 00:00:00 2001 From: Offending Commit Date: Sat, 24 Oct 2026 16:37:45 -0500 Subject: [PATCH 2/5] fix(contract): install the kit with its declared deps in the lanes The lanes installed the kit into the host env with --no-deps, on the theory that the env should stay exactly the host's lock. The runtime image does not install the kit that way: its Dockerfile and plugin-refresh install it with its dependencies. The pinned host (939e45c9) happened to lock pyyaml, so the gap was invisible there. v0.22.5 dropped PyYAML for ruamel, so plugin_skill raised "No module named 'yaml'" and the upstream lane reported one error. Install the kit with its deps. Packages the host lock already satisfies are left alone; on v0.22.5 the only addition is pyyaml 6.0.3. Claude-Session: https://claude.ai/code/session_01VDtQ1pDTJksqAcSdrGePVb --- justfile | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/justfile b/justfile index 407d34d..8d183cd 100644 --- a/justfile +++ b/justfile @@ -84,8 +84,10 @@ provision-hermes-host dir venv: || { echo "no interpreter on PATH satisfies the host's requires-python $spec; add one to mise.toml" >&2; exit 1; } echo "hermes host python: $("$py" -V 2>&1) at $py (host requires-python $spec)" (cd "$host" && UV_PROJECT_ENVIRONMENT="$venv" {{ uv }} sync --frozen --quiet --python "$py") - # --no-deps: the environment stays exactly the host's lock. - {{ uv }} pip install --quiet --python "$venv/bin/python" --no-deps --reinstall-package hermes-plugin-kit . + # The kit installs with its declared deps, as the runtime image installs it. + # Packages the host lock already satisfies are left alone; v0.22.5 dropped + # PyYAML from its lock, so --no-deps left plugin_skill without `yaml`. + {{ uv }} pip install --quiet --python "$venv/bin/python" --reinstall-package hermes-plugin-kit . # Run the full contract suite against the deployed pin. # From 81dfbea2c76b249d9abdedbd79f55b1ebc225182 Mon Sep 17 00:00:00 2001 From: Offending Commit Date: Sat, 24 Oct 2026 17:07:46 -0500 Subject: [PATCH 3/5] refactor(contract): read the host spec through uv and drop narrating comments provision-hermes-host read requires-python with whatever python3 was first on PATH; it now goes through the same uv indirection as every other recipe. Comments that narrated the change, restated code, or carried a plan label from another repository are gone; the ones recording an external constraint stay. Claude-Session: https://claude.ai/code/session_01VDtQ1pDTJksqAcSdrGePVb --- hermes_plugin_kit/testing.py | 2 +- justfile | 18 +++--------------- mise.toml | 4 ---- scripts/classify_drift.py | 5 ----- 4 files changed, 4 insertions(+), 25 deletions(-) diff --git a/hermes_plugin_kit/testing.py b/hermes_plugin_kit/testing.py index 52e0b84..816ded2 100644 --- a/hermes_plugin_kit/testing.py +++ b/hermes_plugin_kit/testing.py @@ -78,7 +78,7 @@ #: #: The CI workflow keeps its own literal -- ``actions/checkout`` consumes the #: ``ref:`` before a Python toolchain exists, so the workflow cannot read this -#: value -- and a harness test asserts the two agree. +#: value. DEPLOYED_HERMES_REVISION = "939e45c91d751fadd94dcd1b873ac3cb44846213" #: Parameter names the kit's registrars pass positionally, in order. diff --git a/justfile b/justfile index 8d183cd..64025c9 100644 --- a/justfile +++ b/justfile @@ -61,16 +61,11 @@ prepare-hermes-ref ref dir: git -C "{{ dir }}" checkout -q FETCH_HEAD echo "hermes-agent at $(git -C "{{ dir }}" rev-parse HEAD)" -# Build the host's own environment from the host's own lockfile (KTD4). +# Build the host's own environment from the host's own lockfile. # # The kit's venv lacks the host's dependencies (ruamel, dotenv, ...), so the # contract modules died at import there. The host's `pyproject.toml` alone # does not resolve either, so its `uv.lock` is the only faithful source. -# -# The interpreter is the first one on PATH that satisfies the host's own -# `requires-python`. mise.toml installs one per host generation: 3.14.9 for -# v0.22.5 and later, 3.13 for the pre-upgrade deployed pin (<3.14). The venv -# lives in this repository, so the host checkout is read, never rewritten. [private] provision-hermes-host dir venv: #!/usr/bin/env bash @@ -79,14 +74,11 @@ provision-hermes-host dir venv: mkdir -p "{{ venv }}" venv="$(cd "{{ venv }}" && pwd)" test -f "$host/uv.lock" || { echo "no uv.lock in $host; cannot provision the host faithfully" >&2; exit 1; } - spec="$(python3 -c 'import sys, tomllib; print(tomllib.load(open(sys.argv[1], "rb"))["project"]["requires-python"])' "$host/pyproject.toml")" + spec="$({{ uv }} run python -c 'import sys, tomllib; print(tomllib.load(open(sys.argv[1], "rb"))["project"]["requires-python"])' "$host/pyproject.toml")" py="$({{ uv }} python find --system "$spec")" \ || { echo "no interpreter on PATH satisfies the host's requires-python $spec; add one to mise.toml" >&2; exit 1; } echo "hermes host python: $("$py" -V 2>&1) at $py (host requires-python $spec)" (cd "$host" && UV_PROJECT_ENVIRONMENT="$venv" {{ uv }} sync --frozen --quiet --python "$py") - # The kit installs with its declared deps, as the runtime image installs it. - # Packages the host lock already satisfies are left alone; v0.22.5 dropped - # PyYAML from its lock, so --no-deps left plugin_skill without `yaml`. {{ uv }} pip install --quiet --python "$venv/bin/python" --reinstall-package hermes-plugin-kit . # Run the full contract suite against the deployed pin. @@ -125,9 +117,7 @@ test-contract: test-contract-upstream # Run both contract modules and prove the run actually executed checks. # # The exit status alone cannot say whether anything ran: unittest exits 0 when -# every test skips, and on Python before 3.12 also when zero tests are -# collected. So the executed count is the evidence. The suite runs on the -# host's own environment (see provision-hermes-host). +# every test skips. So the executed count is the evidence. [private] _run-contract-suite label venv: #!/usr/bin/env bash @@ -139,8 +129,6 @@ _run-contract-suite label venv: > "$log" 2>&1 echo "EXIT=$?" >> "$log" set -e - # The whole log, not a tail: the drift classifier reads this output, and a - # tail cut the ERROR headers of an import failure and read as clean. cat "$log" grep -qE '^EXIT=0' "$log" || { echo "contract suite failed ({{ label }})" >&2; exit 1; } ran="$(grep -oE '^Ran [0-9]+' "$log" | grep -oE '[0-9]+' | head -n1)" diff --git a/mise.toml b/mise.toml index a229793..bcf48e0 100644 --- a/mise.toml +++ b/mise.toml @@ -1,7 +1,3 @@ -# The one home for this repository's toolchain. CI installs these tools with -# jdx/mise-action from mise.lock, so laptops and CI run the same Python, uv and -# Just. Run `mise trust && mise install --locked` once per checkout; the -# justfile recipes stay the entry points. min_version = "2026.10.15" [tools] diff --git a/scripts/classify_drift.py b/scripts/classify_drift.py index 6eabc7f..65654a5 100644 --- a/scripts/classify_drift.py +++ b/scripts/classify_drift.py @@ -96,11 +96,6 @@ def fingerprint(body: str) -> str: def _did_not_execute(log: str, parsed_failures: int) -> list[str]: - """Reasons the log is not evidence that the suite ran its checks. - - These need no failure block: the blocks are exactly what goes missing when - a run dies early or only part of its log arrives. - """ reasons = [ f"{module} — failed to import, so none of its checks ran" for module in dict.fromkeys(_IMPORT_FAILURE.findall(log)) From babfcf525f87d2aebf077c748950e8e4f06078b0 Mon Sep 17 00:00:00 2001 From: Offending Commit Date: Sat, 24 Oct 2026 17:28:30 -0500 Subject: [PATCH 4/5] fix(drift): count skipped contract tests as runs that checked nothing A lane whose unittest summary reads OK (skipped=N) exits 0 inside the suite; the lane recipe then fails on its own skip guard, but the drift workflow never appends the outer EXIT line, so the classifier saw a clean run. Cross-model code review (adversarial lens) found the path; it is not reachable today because the contract modules skip only at class level under HERMES_CONTRACT_REQUIRED. Claude-Session: https://claude.ai/code/session_01VDtQ1pDTJksqAcSdrGePVb --- scripts/classify_drift.py | 4 ++++ tests/test_drift_classification.py | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/scripts/classify_drift.py b/scripts/classify_drift.py index 65654a5..2e36a38 100644 --- a/scripts/classify_drift.py +++ b/scripts/classify_drift.py @@ -93,6 +93,7 @@ def fingerprint(body: str) -> str: _IMPORT_FAILURE = re.compile(r"^ImportError: Failed to import test module: (\S+)", re.M) _RAN = re.compile(r"^Ran (\d+) tests?\b", re.M) _EXIT = re.compile(r"^EXIT=(\d+)\s*$", re.M) +_SKIPPED = re.compile(r"^(?:OK|FAILED) \(.*\bskipped=(\d+)", re.M) def _did_not_execute(log: str, parsed_failures: int) -> list[str]: @@ -105,6 +106,9 @@ def _did_not_execute(log: str, parsed_failures: int) -> list[str]: reasons.append("no `Ran N tests` line — the suite never completed") elif int(ran[-1]) == 0: reasons.append("Ran 0 tests — nothing was checked") + skipped = sum(int(n) for n in _SKIPPED.findall(log)) + if skipped: + reasons.append(f"{skipped} contract tests skipped — they checked nothing") exits = [int(code) for code in _EXIT.findall(log)] if not exits: reasons.append("no `EXIT=` line — the run's outcome is unknown") diff --git a/tests/test_drift_classification.py b/tests/test_drift_classification.py index ddbe311..7a2e7ea 100644 --- a/tests/test_drift_classification.py +++ b/tests/test_drift_classification.py @@ -214,6 +214,14 @@ def test_a_non_zero_exit_with_no_parsed_failure_is_new_drift(self) -> None: self.assertTrue(new) + def test_a_run_that_skipped_contract_tests_is_new_drift(self) -> None: + """A skipped contract test checked nothing, whatever the exit said.""" + log = "\n" + "-" * 70 + "\nRan 17 tests in 0.601s\n\nOK (skipped=3)\nEXIT=0\n" + + _, new = classify_drift.classify(log, {}) + + self.assertTrue(any("skipped" in entry for entry in new), new) + def test_a_clean_run_reports_no_drift(self) -> None: log = ( "test_x (tests.test_hermes_contract.HermesContractTests.test_x) ... ok\n" From a3ce33c13ff03c403996a853b34bf62216caacfc Mon Sep 17 00:00:00 2001 From: Offending Commit Date: Sat, 24 Oct 2026 17:28:30 -0500 Subject: [PATCH 5/5] ci: pin the mise binary every mise-action step installs Without a version input the action installs the newest mise at least a day old, so two runs of one commit, release jobs holding PRIVATE_ARTIFACTS_TOKEN included, could use different installers. The action keeps verifying the download against mise's signed checksums; a sha256 input would replace that signature check with a plain hash, so only the version is pinned. min_version follows the pin. Claude-Session: https://claude.ai/code/session_01VDtQ1pDTJksqAcSdrGePVb --- .github/workflows/hermes-drift.yml | 4 ++++ .github/workflows/release.yml | 16 ++++++++++++++++ .github/workflows/test.yml | 12 ++++++++++++ mise.toml | 2 +- 4 files changed, 33 insertions(+), 1 deletion(-) diff --git a/.github/workflows/hermes-drift.yml b/.github/workflows/hermes-drift.yml index 8c1bacc..ee6a758 100644 --- a/.github/workflows/hermes-drift.yml +++ b/.github/workflows/hermes-drift.yml @@ -43,6 +43,10 @@ jobs: path: .hermes-agent-upstream - name: Install the locked toolchain (mise.toml) uses: jdx/mise-action@82a612c5ab8bcda7cc5db6b7e81a8e7584c3dc6e # v5.2.3 + with: + # Pinned so every run, release jobs included, installs the same mise; + # the action still verifies it against mise's signed checksums. + version: "2026.10.18" - name: Record the upstream revision under test id: upstream run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e1668e9..3f16b84 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,6 +55,10 @@ jobs: ref: ${{ github.sha }} - name: Install the locked toolchain (mise.toml) uses: jdx/mise-action@82a612c5ab8bcda7cc5db6b7e81a8e7584c3dc6e # v5.2.3 + with: + # Pinned so every run, release jobs included, installs the same mise; + # the action still verifies it against mise's signed checksums. + version: "2026.10.18" - name: Attach the exact trigger to local main env: EXPECTED_SHA: ${{ github.sha }} @@ -172,6 +176,10 @@ jobs: ref: ${{ github.sha }} - name: Install the locked toolchain (mise.toml) uses: jdx/mise-action@82a612c5ab8bcda7cc5db6b7e81a8e7584c3dc6e # v5.2.3 + with: + # Pinned so every run, release jobs included, installs the same mise; + # the action still verifies it against mise's signed checksums. + version: "2026.10.18" - name: Download the immutable release source uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -221,6 +229,10 @@ jobs: ref: ${{ github.sha }} - name: Install the locked toolchain (mise.toml) uses: jdx/mise-action@82a612c5ab8bcda7cc5db6b7e81a8e7584c3dc6e # v5.2.3 + with: + # Pinned so every run, release jobs included, installs the same mise; + # the action still verifies it against mise's signed checksums. + version: "2026.10.18" - name: Download the immutable release source uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -298,6 +310,10 @@ jobs: ref: ${{ github.sha }} - name: Install the locked toolchain (mise.toml) uses: jdx/mise-action@82a612c5ab8bcda7cc5db6b7e81a8e7584c3dc6e # v5.2.3 + with: + # Pinned so every run, release jobs included, installs the same mise; + # the action still verifies it against mise's signed checksums. + version: "2026.10.18" - name: Install ORAS uses: oras-project/setup-oras@1d808f7d7f6995cc68b7bf507bfe5c5446e1dc9d # v2.0.1 with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1fb9664..98c499e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,6 +16,10 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install the locked toolchain (mise.toml) uses: jdx/mise-action@82a612c5ab8bcda7cc5db6b7e81a8e7584c3dc6e # v5.2.3 + with: + # Pinned so every run, release jobs included, installs the same mise; + # the action still verifies it against mise's signed checksums. + version: "2026.10.18" - name: Run unittest suite run: just test @@ -25,6 +29,10 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install the locked toolchain (mise.toml) uses: jdx/mise-action@82a612c5ab8bcda7cc5db6b7e81a8e7584c3dc6e # v5.2.3 + with: + # Pinned so every run, release jobs included, installs the same mise; + # the action still verifies it against mise's signed checksums. + version: "2026.10.18" - name: Build and validate package metadata run: | just build @@ -54,6 +62,10 @@ jobs: path: .hermes-agent-deployed - name: Install the locked toolchain (mise.toml) uses: jdx/mise-action@82a612c5ab8bcda7cc5db6b7e81a8e7584c3dc6e # v5.2.3 + with: + # Pinned so every run, release jobs included, installs the same mise; + # the action still verifies it against mise's signed checksums. + version: "2026.10.18" # The full suite, not the context-engine module alone. It is green at # this pin, so widening adds coverage rather than risk. - name: Run the full contract suite against the deployed Hermes revision diff --git a/mise.toml b/mise.toml index bcf48e0..e89f898 100644 --- a/mise.toml +++ b/mise.toml @@ -1,4 +1,4 @@ -min_version = "2026.10.15" +min_version = "2026.10.18" [tools] # Must equal the Python in the deployed Hermes image (upstream pm/lock.json).