diff --git a/.coderabbit.yaml b/.coderabbit.yaml index d327e5befc6ec..c6d5e1cc43a10 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -2,7 +2,7 @@ # # Auto-review is DISABLED repo-wide: the app was enabled at the org level on # 2026-08-14 and immediately began reviewing every opened PR. This repo's -# merge gate is CI ("All required checks pass") plus maintainer review — +# merge gate is CI ("All required checks pass (v2)") plus maintainer review — # CodeRabbit reviews carry no merge weight (protect-main ruleset requires # 0 approving reviews), so auto-firing on the full PR firehose adds comment # noise without gating value. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 83d67aceec488..9fe8871213560 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -10,6 +10,9 @@ # same review for the same reason. /.github/ @NousResearch/hermes-agent-core /scripts/ci/ @NousResearch/hermes-agent-core +# The code-health gate decides whether a PR passes; weakening it is a CI change. +/scripts/check @NousResearch/hermes-agent-core +/scripts/code_health/ @NousResearch/hermes-agent-core # eslint configs can define custom ``fix`` functions that run arbitrary code. eslint.config.* @NousResearch/hermes-agent-core diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 82ca9ca80eed5..a2ef1740fbf88 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -47,6 +47,7 @@ Fixes # - [ ] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [ ] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [ ] I've run `pytest tests/ -q` and all tests pass +- [ ] `python scripts/check` passes (the blocking lint and code-health checks CI runs) - [ ] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [ ] I've tested on my platform: diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 54cd3c052b3be..848ae08fae3bd 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -23,6 +23,11 @@ on: required: false type: boolean default: false + strict_acceptance: + description: "Acceptance runs only: known_failure owners whose gaps FAIL this run instead of xfailing ('upd-txn', a comma list, or '1' for every gap). Sets HERMES_E2E_STRICT_ACCEPTANCE for the E2E suites (tests/e2e/core/_pending_fixes.py)." + required: false + type: string + default: '' workflow_call: inputs: release: @@ -162,7 +167,9 @@ jobs: tests: name: Python tests needs: detect - if: needs.detect.outputs.python == 'true' + # Its E2E jobs are lanes of their own: a lane the classifier sets must reach + # its job whatever `python` says (tests/ci/test_update_ci_routing.py). + if: needs.detect.outputs.python == 'true' || needs.detect.outputs.e2e == 'true' || needs.detect.outputs.e2e_upgrade == 'true' uses: ./.github/workflows/tests.yml with: # NousResearch runs the full unit suite on one 32-core runner. Forks @@ -173,17 +180,19 @@ jobs: e2e_mode: full e2e: ${{ github.repository != 'NousResearch/hermes-agent' || needs.detect.outputs.e2e == 'true' }} e2e_upgrade: ${{ github.repository != 'NousResearch/hermes-agent' || needs.detect.outputs.e2e_upgrade == 'true' }} + strict_acceptance: ${{ inputs.strict_acceptance || '' }} # macOS + Windows lanes. The main `tests` lane above is Linux-only, and # the OS-marked tests it collects are skipped there by design (see the # `_OS_MARKS` comment in tests/conftest.py) — this is where they run. - # Same `python` lane gate: if no Python changed, neither runs. - # Only this fork's non-release Windows-native results are advisory. - # Shared tests/runners and the upstream/release matrix stay unchanged. + # Gated on `python` and on each lane it consumes: desktop_updater is set by + # TypeScript-only changes too (the Electron marker / result readers), and with + # `python` alone those never reached the Windows hand-off tests below. + # This fork's Windows-native advisory callers retain visible results. tests-os: name: OS-specific tests needs: detect - if: needs.detect.outputs.python == 'true' + if: needs.detect.outputs.python == 'true' || needs.detect.outputs.desktop_updater == 'true' || needs.detect.outputs.e2e == 'true' || needs.detect.outputs.e2e_upgrade == 'true' uses: ./.github/workflows/tests-os.yml with: platform_scope: ${{ needs.detect.outputs.os_tests_scope }} @@ -193,25 +202,29 @@ jobs: desktop_updater: ${{ needs.detect.outputs.desktop_updater == 'true' }} e2e: ${{ github.repository != 'NousResearch/hermes-agent' || needs.detect.outputs.e2e == 'true' }} e2e_upgrade: ${{ github.repository != 'NousResearch/hermes-agent' || needs.detect.outputs.e2e_upgrade == 'true' }} + strict_acceptance: ${{ inputs.strict_acceptance || '' }} tests-windows-advisory: name: Windows-only tests (advisory) needs: detect - if: needs.detect.outputs.python == 'true' && needs.detect.outputs.advisory_windows == 'true' + if: (needs.detect.outputs.python == 'true' || needs.detect.outputs.desktop_updater == 'true' || needs.detect.outputs.e2e == 'true' || needs.detect.outputs.e2e_upgrade == 'true') && needs.detect.outputs.advisory_windows == 'true' uses: ./.github/workflows/tests-os.yml with: platform_scope: windows desktop_updater: ${{ needs.detect.outputs.desktop_updater == 'true' }} e2e: ${{ github.repository != 'NousResearch/hermes-agent' || needs.detect.outputs.e2e == 'true' }} e2e_upgrade: ${{ github.repository != 'NousResearch/hermes-agent' || needs.detect.outputs.e2e_upgrade == 'true' }} + strict_acceptance: ${{ inputs.strict_acceptance || '' }} lint: name: Python lints needs: detect - if: needs.detect.outputs.python == 'true' + # frontend too: the code-health ratchet in lint.yml also covers TypeScript. + if: needs.detect.outputs.python == 'true' || needs.detect.outputs.frontend == 'true' uses: ./.github/workflows/lint.yml with: event_name: ${{ needs.detect.outputs.event_name }} + python: ${{ needs.detect.outputs.python == 'true' }} js-tests: name: JS & TS checks @@ -278,8 +291,11 @@ jobs: needs: detect # apps/desktop/e2e/update: a real install.sh install + packaged Desktop, # first run, "Update now" hand-off/relaunch, concurrent-update refusal. - # Advisory until it has proven stable on CI: its shards run with continue-on-error, so a - # red shard shows on the PR but does not fail all-checks-pass. + # Required when it runs: a PR that touches the Desktop update path (the + # classifier's e2e_desktop_update lane) cannot merge on a red shard. Like + # the Linux e2e-upgrade shards and the Windows install + update journey + # (tests / tests-os, gated on e2e_upgrade), it is skipped — and the skip + # counts as success — when the PR does not touch that path. if: ${{ needs.detect.outputs.python_prod == 'true' || needs.detect.outputs.frontend == 'true' || needs.detect.outputs.e2e_desktop_update == 'true' }} uses: ./.github/workflows/e2e-desktop-update.yml @@ -396,7 +412,8 @@ jobs: # Gate: runs after everything. ``if: always()`` ensures it reports a # status even when some deps were skipped. # - # Non-release (PR/push): failure fails, skipped counts as success. + # Non-release (PR/push): failure fails. Update/E2E consumers selected + # by detect.outputs must succeed; only unselected consumers may skip. # Release (inputs.release): strict — every required job must be # `success`. A skipped required lane fails the gate; only the PR-only # jobs (history-check, lockfile-diff, supply-chain, review-labels) and @@ -407,11 +424,19 @@ jobs: # named Windows-native advisory callers keep their real results visible # without joining these required dependencies. Shared checks stay required. # + # The check name carries a version. The ruleset requires a check NAME, and a + # green check run recorded on a PR head stays valid forever, so a PR whose + # last CI predates a new required job still merges on that old green (32 + # PRs merged without the code-health ratchet that way). When the required + # set gains a job every open PR must pass, bump the version here and in the + # protect-main ruleset in the same rollout: older greens then no longer + # count, and each PR needs one fresh CI run before it can merge. + # # Outputs ``needs-json`` — a compact ``{job_name: result}`` dict — so # the live comment poller can list failed jobs in the PR comment. # ───────────────────────────────────────────────────────────────────── all-checks-pass: - name: All required checks pass + name: ${{ github.repository == 'NousResearch/hermes-agent' && 'All required checks pass (v2)' || 'All required checks pass' }} needs: - detect - tests diff --git a/.github/workflows/docs-site-checks.yml b/.github/workflows/docs-site-checks.yml index d502e2e328179..344b0678ea7fd 100644 --- a/.github/workflows/docs-site-checks.yml +++ b/.github/workflows/docs-site-checks.yml @@ -25,6 +25,10 @@ jobs: command: npm ci working-directory: website + - name: Check Docusaurus Tinypool compatibility + run: node scripts/check-tinypool-compat.mjs + working-directory: website + - name: Install ascii-guard uses: ./.github/actions/retry with: @@ -59,3 +63,8 @@ jobs: # Same pattern Docusaurus uses internally (build:fast --locale en). run: npm run build:fast working-directory: website + + - name: Audit website dependencies + # Fails on any high/critical advisory except the allow-listed ones (braces has no + # patched release); see scripts/ci/npm_audit_gate.py. + run: python3 scripts/ci/npm_audit_gate.py website diff --git a/.github/workflows/e2e-desktop-update.yml b/.github/workflows/e2e-desktop-update.yml index 2984d611280da..6754a160732aa 100644 --- a/.github/workflows/e2e-desktop-update.yml +++ b/.github/workflows/e2e-desktop-update.yml @@ -28,8 +28,8 @@ jobs: name: Desktop install/update E2E (Linux, ${{ matrix.cell }}) runs-on: ${{ github.repository == 'NousResearch/hermes-agent' && 'ubuntu-latest-32-core' || 'ubuntu-latest' }} timeout-minutes: 30 - # Advisory until stable on CI (see ci.yaml): a red shard does not fail the caller. - continue-on-error: true + # Required whenever the e2e_desktop_update lane fires (see ci.yaml): it was + # advisory until it proved stable, and every main push shard since has passed. strategy: fail-fast: false # One install per shard (every spec restores it; paths are baked in, so diff --git a/.github/workflows/install-e2e.yml b/.github/workflows/install-e2e.yml index 31bd7a7e5946c..4e953f2f5770c 100644 --- a/.github/workflows/install-e2e.yml +++ b/.github/workflows/install-e2e.yml @@ -130,11 +130,12 @@ on: - 'setup-hermes.*' - 'hermes_bootstrap.py' - 'pm/**' + - '!pm/**/*.md' - 'hermes_cli/update*' - 'hermes_cli/*update*' - 'hermes_cli/pm*' - 'hermes_cli/*install*' - - 'hermes_cli/_early_recovery.py' + - 'hermes_cli/_early_recovery*.py' - 'hermes_cli/_launchers.py' - 'hermes_cli/managed_uv.py' - 'hermes_cli/venv_sync.py' diff --git a/.github/workflows/js-tests.yml b/.github/workflows/js-tests.yml index 2b7e6217be87c..c95597731a63b 100644 --- a/.github/workflows/js-tests.yml +++ b/.github/workflows/js-tests.yml @@ -102,6 +102,8 @@ jobs: uses: ./.github/actions/setup-pm with: toolchain: node + - name: Check bounded npm configuration resolution + run: node --test scripts/build/node-options.test.mjs - name: Install locked native test tooling without lifecycle scripts if: matrix.runner != 'windows-2025' run: npm ci --workspace apps/desktop --workspace tests-js --include-workspace-root --include=dev --ignore-scripts --no-audit --no-fund diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index ba03f7d89e119..540683dd53e6d 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,12 +1,14 @@ name: Lint (ruff + ty) -# Two things here: +# Three things here: # 1. Advisory diff — ruff + ty diagnostics as a diff vs the target branch. # Writes a Markdown summary to the run page. Exit zero always. # 2. Blocking ``ruff check .`` — enforces the explicit rules in # ``[tool.ruff.lint.select]`` (currently PLW1514). Failure blocks merge. # Separate job so the advisory diff still runs even when enforcement # fails. +# 3. Blocking code-health ratchet (``scripts/code_health``) for Python and TypeScript. +# ``python scripts/check`` runs every blocking check here locally with the same pinned tools. # # The fork's CI-sensitive review remains in review-labels.yml; CODEOWNERS # additionally routes these files where a code-owner ruleset is configured. @@ -18,6 +20,10 @@ on: description: The event name from the calling orchestrator (pull_request or push). type: string required: true + python: + description: Python changed. A frontend-only PR runs just the code-health ratchet (TS). + type: boolean + default: true permissions: contents: read @@ -29,7 +35,7 @@ concurrency: jobs: lint-diff: name: ruff + ty diff - if: inputs.event_name == 'pull_request' + if: inputs.event_name == 'pull_request' && inputs.python runs-on: ubuntu-latest # Checkout alone has been observed taking >10 min on this fork right after # a large upstream-sync merge (materializing/transferring the huge @@ -53,7 +59,7 @@ jobs: - name: Install ruff + ty uses: ./.github/actions/retry with: - command: python -m scripts.ci.python_packages ruff==0.15.10 ty==0.0.82 + command: python -m scripts.ci.python_packages "$(python -m scripts.code_health --print-pins)" ty==0.0.82 - name: Determine base ref id: base @@ -138,6 +144,7 @@ jobs: # ``lint-diff`` job above runs independently so reviewers still get # the diff comment even when enforcement fails. name: ruff enforcement (blocking) + if: inputs.python runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -152,7 +159,7 @@ jobs: - name: Install ruff uses: ./.github/actions/retry with: - command: python -m scripts.ci.python_packages ruff==0.15.10 + command: python -m scripts.ci.python_packages "$(python -m scripts.code_health --print-pins)" - name: ruff check . # No --exit-zero, no || true. Exit code propagates to the job, @@ -160,12 +167,41 @@ jobs: run: | ruff check . + code-health: + # Per-unit ratchet: every function and file keeps its own cap (its value on main when it is + # already over target, else the target) and pattern rules compare fingerprints, so a PR + # cannot trade one violation for another. The merge commit is compared with its first + # parent (current main), so an improvement that lands on main is locked in at once. + name: code health ratchet (blocking) + if: inputs.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout code + uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7.0.1 + with: + fetch-depth: 2 # the merge commit and its first parent (the base branch tip) + + - uses: ./.github/actions/setup-pm + with: + cache-python: true + prune-python-cache: true + + - name: Install pinned ruff + uses: ./.github/actions/retry + with: + command: python -m scripts.ci.python_packages "$(python -m scripts.code_health --print-pins)" + + - name: Ratchet the PR against the base branch tip + run: python scripts/check --only health --base HEAD^1 + windows-footguns: # Static guardrails on Windows-unsafe Python primitives — os.kill(pid, 0), # os.killpg, os.setsid, signal.SIGKILL without getattr fallback, # shebang scripts via subprocess, bare open() without encoding=, etc. # See scripts/check-windows-footguns.py for the full rule list. name: Windows footguns (blocking) + if: inputs.python runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -198,14 +234,19 @@ jobs: # The OS lanes import only files carrying the matching marker, so a test that fakes # macOS (is_macos -> True, sys.platform -> "darwin") without `platforms("macos")` is green on - # Linux over a faked branch and never runs on macOS (#111866, AGENTS.md § Don't fake the host OS). + # Linux over a faked branch and never runs on macOS (#111866, tests/AGENTS.md § Don't fake the host OS). - name: Forbid unmarked macOS fakes in tests run: python scripts/ci/check_os_marker_fakes.py + # Every AGENTS.md from the git root down to the working directory loads as one + # project-context block (30,720 chars on a 128k window); chains are capped at 30k. + - name: Keep every AGENTS.md under its truncation cap + run: python scripts/ci/check_agents_md_size.py + # Advisory: profile-scope hazard shapes on the lines this PR adds (child env from os.environ, # raw os.getenv of a platform credential, HOME-only RPC binding, bare-PID liveness). One # process serves many profiles; every pattern leaked the launch profile at least once. Printed - # into the log for the reviewer; never fails the job (root AGENTS.md § Code Shape Rules). + # into the log for the reviewer; never fails the job (root AGENTS.md § Rules that apply everywhere). - name: Profile-scope patterns on added lines (advisory) if: github.event_name == 'pull_request' continue-on-error: true diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 5aef533a75fc3..8f7c2b1d0a522 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -160,13 +160,16 @@ jobs: env: RELEASE_VERSION: ${{ inputs.version }} run: | - package="$(nix build "path:$RUNNER_TEMP/release-source#hermes-agent" --no-link --print-out-paths)" + source="path:$RUNNER_TEMP/release-source" + package="$(nix build "$source#default" --no-link --print-out-paths)" actual="$("$package/bin/hermes" --version)" ACTUAL_VERSION="$actual" python3 - <<'PY' import os expected = os.environ["RELEASE_VERSION"] - actual = os.environ["ACTUAL_VERSION"].split()[-1].removeprefix("v") + # Line one is "Hermes Agent v (...)"; later lines (Python, OpenAI SDK) carry other versions. + first = os.environ["ACTUAL_VERSION"].splitlines()[0].split() + actual = first[2].removeprefix("v") if first[:2] == ["Hermes", "Agent"] and len(first) > 2 else "" if actual != expected: raise SystemExit(f"Nix package version mismatch: expected {expected}, got {actual}") PY diff --git a/.github/workflows/plugin-catalog-ci.yml b/.github/workflows/plugin-catalog-ci.yml index 707fc25b87d29..59a6b2c421213 100644 --- a/.github/workflows/plugin-catalog-ci.yml +++ b/.github/workflows/plugin-catalog-ci.yml @@ -53,9 +53,12 @@ jobs: steps: - uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7.0.1 with: - # The pull_request checkout is GitHub's merge commit; depth 2 brings its - # first parent (the base tip), which is all the diff below needs. - fetch-depth: 2 + # Full commit graph + tags, blobs on demand: the diff below needs the + # merge commit's first parent, and Hermes resolves its own version + # from the release tags merged into HEAD. A shallow checkout reports + # "unknown", which plugins checking the host version reject (omh). + fetch-depth: 0 + filter: blob:none # `hermes plugins validate` below runs pinned-repo code; keep the # job's token out of .git/config where that code could read it. persist-credentials: false diff --git a/.github/workflows/pm-bundle.yml b/.github/workflows/pm-bundle.yml index e4efb5df0b986..fc399f81780eb 100644 --- a/.github/workflows/pm-bundle.yml +++ b/.github/workflows/pm-bundle.yml @@ -34,6 +34,7 @@ on: pull_request: paths: - 'pm/**' + - '!pm/**/*.md' - 'scripts/bundles/**' - 'scripts/ci/desktop_build_cache.py' - '.github/actions/desktop-build-cache/**' diff --git a/.github/workflows/termux-verify.yml b/.github/workflows/termux-verify.yml index cf5429067c1f5..db33deda724cd 100644 --- a/.github/workflows/termux-verify.yml +++ b/.github/workflows/termux-verify.yml @@ -6,12 +6,14 @@ on: paths: - 'scripts/termux/**' - 'pm/**' + - '!pm/**/*.md' - 'tests/scripts/test_termux*' - '.github/workflows/termux-verify.yml' pull_request: paths: - 'scripts/termux/**' - 'pm/**' + - '!pm/**/*.md' - 'tests/scripts/test_termux*' - '.github/workflows/termux-verify.yml' workflow_dispatch: diff --git a/.github/workflows/tests-os.yml b/.github/workflows/tests-os.yml index fce68efbcf67c..0599890eb6252 100644 --- a/.github/workflows/tests-os.yml +++ b/.github/workflows/tests-os.yml @@ -60,6 +60,12 @@ on: type: boolean required: false default: true + strict_acceptance: + description: >- + Acceptance runs only: known_failure owners whose gaps FAIL this run instead of xfailing ('upd-txn', a comma list, or '1' for every gap). Sets HERMES_E2E_STRICT_ACCEPTANCE for the E2E suites (tests/e2e/core/_pending_fixes.py). + type: string + required: false + default: '' permissions: contents: read @@ -220,6 +226,8 @@ jobs: needs: plan if: needs.plan.outputs.run_windows == 'true' && inputs.e2e_upgrade uses: ./.github/workflows/windows-install-update-e2e.yml + with: + strict_acceptance: ${{ inputs.strict_acceptance }} e2e-windows: # Real Hermes processes on a real Windows host (tests/e2e/core/windows): hermes.exe, @@ -265,6 +273,7 @@ jobs: scripts/run_tests.sh tests/e2e/core/windows/ -- \ -m "platforms and integration" -v --tb=short -rA env: + HERMES_E2E_STRICT_ACCEPTANCE: ${{ inputs.strict_acceptance }} HERMES_TEST_WORKERS: '6' HERMES_TEST_FILE_TIMEOUT: '900' HERMES_TEST_FILE_RETRIES: '0' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index af42930dd2914..273a44a265575 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -28,6 +28,12 @@ on: type: boolean required: false default: true + strict_acceptance: + description: >- + Acceptance runs only: known_failure owners whose gaps FAIL this run instead of xfailing ('upd-txn', a comma list, or '1' for every gap). Sets HERMES_E2E_STRICT_ACCEPTANCE for the E2E suites (tests/e2e/core/_pending_fixes.py). + type: string + required: false + default: '' permissions: contents: read @@ -219,6 +225,7 @@ jobs: mapfile -t files < <(find tests/e2e -name 'test_*.py' -not -path 'tests/e2e/core/upgrade/*' | sort) scripts/run_tests.sh --include-integration "${files[@]}" env: + HERMES_E2E_STRICT_ACCEPTANCE: ${{ inputs.strict_acceptance }} # An e2e file is a process tree, not one CPU-bound pytest worker. With # 32 files at once the hosted run starved live children before their # ready frames (empty stderr, still alive) and hit 60–120s deadlines @@ -467,6 +474,7 @@ jobs: scripts/run_tests.sh --include-integration "${files[@]}" env: SHARD: ${{ matrix.shard }} + HERMES_E2E_STRICT_ACCEPTANCE: ${{ inputs.strict_acceptance }} # Each file drives real installs/updaters (process trees plus uv and # git I/O); cpu_count of them at once starves their deadlines. A # shard holds at most ~7 files, so this runs each shard in one wave. diff --git a/.github/workflows/windows-install-update-e2e.yml b/.github/workflows/windows-install-update-e2e.yml index f36e254852e2a..49db1e5ef4417 100644 --- a/.github/workflows/windows-install-update-e2e.yml +++ b/.github/workflows/windows-install-update-e2e.yml @@ -14,6 +14,23 @@ name: Windows install + update E2E on: workflow_call: + inputs: + strict_acceptance: + description: >- + Acceptance runs only: known_failure owners whose gaps FAIL this run instead of xfailing ('upd-txn', a comma list, or '1' for every gap). Sets HERMES_E2E_STRICT_ACCEPTANCE for the E2E suites (tests/e2e/core/_pending_fixes.py). + type: string + required: false + default: '' + # An acceptance run of an integrated batch on its own: e.g. + # `gh workflow run windows-install-update-e2e.yml --ref -f strict_acceptance=upd-txn`. + workflow_dispatch: + inputs: + strict_acceptance: + description: >- + Acceptance runs only: known_failure owners whose gaps FAIL this run instead of xfailing ('upd-txn', a comma list, or '1' for every gap). Sets HERMES_E2E_STRICT_ACCEPTANCE for the E2E suites (tests/e2e/core/_pending_fixes.py). + type: string + required: false + default: '' push: branches: ['wine2e-install/**'] @@ -38,9 +55,14 @@ jobs: - test_git_and_holders.py - test_interpreter.py - test_paths_and_acls.py + - test_crash_cells.py + - test_pause_custody.py + - test_pause_durable.py + - test_update_lock_tree.py + - test_update_spares_other_installs.py # Each file provisions a cold install that takes about 17-22 minutes. Isolate files on - # separate hosted runners so the fork does not serialize those installs into a 50-minute job. - timeout-minutes: 40 + # separate hosted runners so the fork does not serialize those installs into a single serial job. + timeout-minutes: 75 steps: - name: Checkout code uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7.0.1 @@ -66,6 +88,7 @@ jobs: -m "platforms and integration" -v --tb=short -rA env: HERMES_E2E_WINDOWS_INSTALL: '1' + HERMES_E2E_STRICT_ACCEPTANCE: ${{ inputs.strict_acceptance }} # Machine work dirs (serve.git, transcripts) stay short under runner.temp. The fake # user profiles live in C:\Users itself, where a real one does: the checkout carries # 159-character paths, so a deeper profile hits MAX_PATH where no user would (the @@ -74,12 +97,28 @@ jobs: HERMES_E2E_PROFILES_ROOT: C:\Users HERMES_E2E_ARTIFACTS: ${{ runner.temp }}\win-update-e2e HERMES_TEST_WORKERS: '1' - HERMES_TEST_FILE_TIMEOUT: '2400' + # The crash-cell journey needs up to an hour on a standard hosted runner. + HERMES_TEST_FILE_TIMEOUT: '3600' HERMES_TEST_FILE_RETRIES: '0' OPENROUTER_API_KEY: '' OPENAI_API_KEY: '' NOUS_API_KEY: '' + - name: Every crash cell ran + if: matrix.test_file == 'test_crash_cells.py' + # run_tests.sh passes a file whose tests were all deselected (pytest exit 5) or skipped, + # so a lost marker or opt-in would run zero crash cells green. The crash journey writes + # this manifest only when it ran (tests/e2e/core/windows_update/test_crash_cells.py). + shell: pwsh + env: + HERMES_E2E_ARTIFACTS: ${{ runner.temp }}\win-update-e2e + run: | + $manifest = Join-Path $env:HERMES_E2E_ARTIFACTS 'crash-cells-ran.txt' + if (-not (Test-Path -LiteralPath $manifest)) { + throw "test_crash_cells.py ran no crash cells: $manifest is missing" + } + Get-Content -LiteralPath $manifest + - name: Upload transcripts, logs and receipts if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.github/workflows/windows-venv-e2e.yml b/.github/workflows/windows-venv-e2e.yml index 33bf7ef72d643..2afe56812995a 100644 --- a/.github/workflows/windows-venv-e2e.yml +++ b/.github/workflows/windows-venv-e2e.yml @@ -67,7 +67,7 @@ jobs: tests/hermes_cli/test_venv_holder_windows_live.py \ tests/hermes_cli/test_taskkill_identity_windows_live.py \ tests/hermes_cli/test_git_trampoline_windows_live.py \ - tests/hermes_cli/test_runtime_repair.py::TestWindowsRuntimeSelfLock + tests/pm/test_held_entry_windows_live.py - name: Run Telegram CLOSE-WAIT reconnect live E2E (#87057) shell: bash @@ -80,3 +80,26 @@ jobs: run: | set -uo pipefail bash scripts/run_tests.sh tests/tools/test_process_registry_windows_live.py + + - name: Turn Windows long paths off for later steps (#130242) + # The runner image turns LongPathsEnabled on; 0 is the Windows default + # most users have. A process reads the value when it starts, so only + # later steps see it. Keep this step after every lane that expects the + # image's setting. It does not depend on those lanes passing. + id: long_paths_off + if: ${{ !cancelled() }} + shell: pwsh + run: | + reg add 'HKLM\SYSTEM\CurrentControlSet\Control\FileSystem' /v LongPathsEnabled /t REG_DWORD /d 0 /f + if ($LASTEXITCODE) { exit $LASTEXITCODE } + (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem').LongPathsEnabled + + - name: Run PM long-path live E2E with long paths off (#130242) + # The test first asserts RtlAreLongPathsEnabled() is false in its own + # process, so this step cannot pass if the step above did not take. + # ``-m`` replaces pyproject's addopts, which deselect ``integration``. + if: ${{ !cancelled() && steps.long_paths_off.outcome == 'success' }} + shell: bash + run: | + set -uo pipefail + bash scripts/run_tests.sh tests/pm/test_long_paths_windows_live.py -- -m "platforms and integration" -v diff --git a/.gitignore b/.gitignore index 020f1281657f8..ce7e5e5fc835d 100644 --- a/.gitignore +++ b/.gitignore @@ -264,6 +264,9 @@ docs/superpowers/* # — ignore so `git status` stays clean and update's autostash skips them. .update-incomplete .update-incomplete.lock +# Kernel lock `hermes update` holds on the checkout (hermes_cli/update_lock.py). +.hermes-update.lock +.hermes-update-zip-swap.lock # Checkout fingerprint the __pycache__ tree was last validated against # (launch-time stale-bytecode sweep). Runtime state, never a code change. diff --git a/AGENTS.md b/AGENTS.md index 8c53959a71be8..26d4928e8544f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,232 +1,70 @@ # Hermes Agent - Development Guide -## Local project environment - -This checkout is the separate Hermes-agent project and targets Python 3.14+. Use -`./.venv/bin/python` for the primary Hermes-agent checkout. The canonical environment -is managed outside the checkout and is shared only by Hermes-agent worktrees. New -Hermes-agent Git worktrees created by the task, conversation, -subagent, web-Git, CLI, or PR-maintenance paths receive a `.venv` link to the verified -repository runtime before an agent is released; inside those worktrees use -`./.venv/bin/python` (which must resolve to Python >=3.14 and <3.15, as supported by the project lock). Do not install Hermes dependencies into the LunaBot environment at -`/Users/mikedemott/LunaBot-default/.venv`; that environment belongs to LunaBot/TradingBotV18. - - -## VS Code Studio Access - -- Shared launcher: `/Users/mikedemott/.local/bin/vscode-studio`. -- Open this canonical Hermes workspace with `vscode-studio hermes`. -- Open only curated workspaces or specific files; do not open `/Users/mikedemott/Codex` or network drives from Hermes agents. -- VS Code is for inspection/editing ergonomics only; tests and runtime evidence still come from explicit commands. - -Instructions for AI coding assistants and developers working on the hermes-agent codebase. -This root file holds only what applies everywhere. Each area has its own `AGENTS.md` (aim for -~8k chars; `agent/subdirectory_hints.py` delivers up to 32k and truncates head/tail with a warning -past that); see the **routing table** at the end and read the area file before editing in that area. +Read this guide and the area's `AGENTS.md` before editing. `python scripts/check` caps this hub +at 12k chars and root-to-area chains at 30k; long form belongs in the developer guide. **Never give up on the right solution.** ## What Hermes Is -Hermes is a personal AI agent that runs the same agent core across a CLI, a messaging -gateway (Telegram, Discord, Slack, ~20 platforms), a TUI, and an Electron desktop app. It -learns across sessions (memory + skills), delegates to subagents, runs scheduled jobs, and -drives a real terminal and browser. It is extended primarily through **plugins and skills**, -not by growing the core. +Hermes shares one agent core across CLI, messaging gateways, TUI and Electron desktop. It learns +through memory and skills, delegates, schedules jobs, and drives terminals and browsers. Extend +it through **plugins and skills**, keeping the core narrow. Two invariants shape almost every design decision and are the lens for reviewing any change: -- **Per-conversation prompt caching is sacred.** A long-lived conversation reuses a cached - prefix every turn. Anything that mutates past context, swaps toolsets, reloads memories, or - rebuilds the system prompt mid-conversation invalidates that cache and multiplies the user's - cost. We do not do it; the ONE exception is context compression. Slash commands that mutate - system-prompt state (skills, tools, memory) must be **cache-aware**: default to deferred - invalidation (takes effect next session) with an opt-in `--now` flag (`/skills install --now` - is the canonical pattern). +- **Per-conversation prompt caching is sacred.** Mutating past context, swapping toolsets, + reloading memories or rebuilding the system prompt mid-conversation breaks the cached prefix and + multiplies the user's cost; the ONE exception is context compression. Slash commands that change + system-prompt state defer to the next session, with an opt-in `--now` (`/skills install --now`). - **The core is a narrow waist; capability lives at the edges.** Every model tool is sent on every API call, so the bar for a new *core* tool is high. New capability should arrive as a CLI command + skill, a service-gated tool, or a plugin — not as core surface. -## Contribution Rubric — What We Want / What We Don't - -The project's intent layer. It serves humans aiming a contribution AND the automated triage -sweeper, which may only close on `implemented_on_main`, `cannot_reproduce`, or `incoherent`. -Taste-based "out of scope" closes are a human maintainer's call; the sweeper's job is to -recognize design intent and *avoid wrongly closing a legitimate contribution*. - -Read the balance right: Hermes ships a **lot**. Most merges are bug fixes to reported -behavior, and the product surface (platforms, providers, models, desktop/TUI features) -expands aggressively on purpose. The restraint below targets the **core agent + model tool -schema**, the one place where every addition is paid for on every API call. "Smallest -footprint" governs *how a capability is wired into the core*, not whether the product may -grow: expansive at the edges, conservative at the waist. - -### What we want - -- **Fix real bugs, well.** Reproduce the symptom on current `main`, point to the exact line - where it manifests, and fix the whole bug class — sibling call paths included. -- **Expand reach at the edges.** New adapters, channels, providers, models, desktop/TUI/ - dashboard features land routinely, including large ones — as long as they integrate with - the existing setup/config UX (`hermes tools`, `hermes setup`, auto-install) rather than - bolting on a raw env var. -- **Refactor god-files into clean modules.** Huge mechanical `+N/-N` extraction PRs are - wanted work. "Every line traces to the request" applies to *feature* PRs; a declared - refactor's request IS the extraction. -- **Keep the core narrow.** Prefer, in order: extend existing code → CLI command + skill → - service-gated tool (`check_fn`) → plugin → MCP server in the catalog → new core tool (last - resort). See the Footprint Ladder. -- **Extend, don't duplicate.** Check whether existing infrastructure covers the use case - before adding a module/manager/hook. When 3+ open PRs integrate the same *category* - (memory backends, providers, notifiers), design an ABC + orchestrator, wrap the existing - built-in as the first provider, and turn the competing PRs into plugins against it. -- **Behavior contracts over snapshots.** Tests assert how two pieces of data relate, never - freeze a current value (see Testing). -- **E2E validation, not just green unit mocks.** Anything touching resolution chains, config - propagation, security boundaries, remote backends, or file/network I/O must exercise the - real path with real imports against a temp `HERMES_HOME` — two of them (A→B→A) when the - change touches profile scope. Mocks hide integration bugs. -- **Cache-, alternation-, and invariant-safe.** Preserve prompt caching, strict role - alternation (never two same-role messages in a row; never a synthetic user message injected - mid-loop), and a system prompt byte-stable for the life of a conversation. -- **Contributor credit preserved.** Salvage external work by cherry-picking (rebase-merge) so - authorship survives; build on top rather than reimplementing. - -### What we don't want (rejected even when well-built) - -- **Speculative infrastructure.** Hooks/callbacks/extension points with no concrete consumer. - Adding a hook is easy; removing one after plugins depend on it is hard. A hook with a real, - stated use case is NOT speculative even if the consumer ships separately. -- **New `HERMES_*` env vars for non-secret config.** `.env` is for secrets only. Behavioral - settings (timeouts, thresholds, flags, display prefs) go in `config.yaml`; bridge to an - internal env var in code if the mechanism needs one. Reject "set X in your .env" docs - unless X is a credential. -- **A new core tool when terminal + file (or a skill) already do the job.** If the only - barrier is file visibility on a remote backend, fix the mount, not the toolset. -- **Lazy-reading escape hatches on instructional tools.** No `offset`/`limit` pagination on - tools that load content the agent must read fully (skills, prompts, playbooks) — models - read page 1 and skip the rest. -- **"Fixes" that destroy the feature they secure.** Read the original intent - (`git log -p -S`) before restricting behavior; find a fix that preserves the feature. -- **Outbound telemetry / usage attribution without opt-in gating.** No analytics, - third-party identifier tagging, or attribution tags until a generic user-facing opt-in - (config gate + setup prompt + `hermes tools` toggle) exists. Park behind a label. -- **Change-detector tests, cache-breaking mid-conversation, dead code wired in without E2E - proof, plugins that touch core files.** Plugins work within the ABCs/hooks we provide; if - one needs more, widen the generic plugin surface, never special-case it in core. -- **Third-party products integrated into the core tree.** Observability backends, vendor - SaaS connectors, analytics dashboards, and other "someone else's product" plugins do NOT - land under `plugins/` — every one becomes our burden against a fast-moving core for a - backend we don't own. Ship as a **standalone plugin repo** (`~/.hermes/plugins/` or pip - entry point), promoted in the Nous Research Discord `#plugins-skills-and-skins`. This is a - coupling decision, not a quality bar; such PRs are closed with a pointer to publish. - -### Before you call it a bug — verify the premise (and when NOT to close) - -The most common reason a well-written PR is closed is a **wrong premise** or treating an -**intentional design as a gap**. These patterns tell a reviewer what to scrutinize and tell -the sweeper when a PR is NOT safe to close (when in doubt, leave it open for a human): - -- **"Intentional design, not a gap."** Ask whether the isolation IS the design. Profiles are - independent islands on purpose: a PR adding live config inheritance from the default - profile was closed because coupling profiles is exactly what the design prevents (`--clone` - already covers "start from my default"). Read `git log -p -S ""` before assuming - something is unfinished. -- **"The premise doesn't hold against how X actually works."** Trace the real runtime before - accepting a rationale. Real closes: a rate-limit "re-probe during cooldown" PR (the breaker - trips only on a *confirmed-empty* bucket, so re-probing hammers a bucket proven empty); a - usage fix whose new branch **never executes** because an earlier guard already popped the - state. If you can't point to the exact line where the bug manifests AND show the fix changes - that line's behavior, the premise is unverified. -- **"The absence was deliberate."** Restoring "missing" `__init__.py` files made a test tree - importable as a dotted package that shadowed the real plugin and deleted its `register()` - at import time. The omission was load-bearing. -- **"Overreached / resurrected an approach we moved past."** Scope creep beyond the agreed - base, or reviving a direction maintainers closed, is rejected even when it works. Offer the - rest as a focused follow-up. - -Throughline: **verify the claim AND the intent against the codebase before writing or merging -a fix.** A reproduction on current `main` plus a line-level account beats a plausible -rationale. When unsure about intent, asking is cheaper than shipping a fix that fights the -design. +## Contribution rubric -### Security reports and fixes — follow `SECURITY.md` +The project's intent layer, for contributors and for the triage sweeper (which may only close on +`implemented_on_main`, `cannot_reproduce` or `incoherent`; taste-based closes are a maintainer's +call, and when in doubt a PR stays open). Long form with examples: +`website/docs/developer-guide/contributing.md` § Contribution rubric. -Read `SECURITY.md` before filing an issue or opening a PR with a security angle. It is the -scope authority; these are the rules that matter at submission time: +**Wanted:** real bug fixes (repro on `main`, the exact line, the whole class incl. sibling paths); +reach at the edges (adapters, providers, models, UI features) wired into the existing setup UX; +god-file → module refactors; extending before duplicating (3+ PRs in one category → an ABC + +orchestrator); behaviour-contract tests; E2E with real imports against a temp `HERMES_HOME` for +resolution, config, security and I/O changes; salvage by cherry-pick so authorship survives. -- **Undisclosed vulnerabilities go private, never public.** If a finding is in scope under - `SECURITY.md` §3.1 (isolation escape, unauthorized external-surface access, credential - exfiltration, trust-model documentation violation), report it via - [GitHub Security Advisories](https://github.com/NousResearch/hermes-agent/security/advisories/new) - or security@nousresearch.com. Do not open a public issue or PR for it, and keep - reproduction or exploit details out of commit messages, PR bodies, and comments. -- **Out-of-scope hardening is ordinary public work.** §3.2 items — approval-gate, redaction, - or Skills Guard bypasses; prompt injection without a chained §3.1 outcome; consequences of - the chosen isolation posture; break-glass settings; third-party skills/plugins — are not - vulnerabilities. File them as regular issues/PRs; don't route them through the private - channel or describe them as vulnerabilities. -- **Name the boundary.** A security issue or PR states which §2 trust boundary is crossed, - with `path/to/file.py:line` references and a reproduction on current `main`. -- **Sweeper:** a public issue or PR that discloses an apparent §3.1 vulnerability is never a - sweeper close or merge candidate; leave it for a maintainer. +**Rejected even when well-built:** hooks with no concrete consumer; new `HERMES_*` env vars for +non-secret config (`.env` is secrets only, behaviour goes in `config.yaml`); a new core tool when +terminal + file or a skill already does the job; `offset`/`limit` pagination on instructional tools; +"fixes" that destroy the feature they secure; outbound telemetry without an opt-in gate; +change-detector tests; plugins that touch core files; third-party products in the core tree (ship a +standalone plugin repo). -### The Footprint Ladder (new capability decision) +**Before you call it a bug,** verify the claim AND the intent (`git log -p -S ""`): the +isolation is often the design (profiles are islands on purpose), an absence can be load-bearing, +and a fix that cannot point to the line where the bug manifests has an unverified premise. -Choose the highest (least-footprint) rung that correctly solves the problem: +**Security:** `SECURITY.md` is the scope authority. A §3.1 finding goes private (GitHub Security +Advisories or security@nousresearch.com), never into a public issue, PR, commit or comment; §3.2 +hardening is ordinary public work. Name the §2 boundary crossed, with a repro on `main`. -1. **Extend existing code** — a variation of something that exists. Zero new surface. -2. **CLI command + skill** — config/state/infra expressible as shell commands; the agent runs - `hermes ` guided by a skill. Default for subscriptions, scheduled tasks, - service setup (`hermes webhook`, `hermes cron`, `hermes tools`). -3. **Service-gated tool (`check_fn`)** — needs structured params/returns AND only appears when - a prerequisite is configured (Home Assistant tools, memory-provider tools). This rung gates - reachability/opt-in process-wide; a capability that varies per SESSION (who is watching) is - a named toolset folded in by the toolset resolver, not a `check_fn` — see "Surface capability - is a property of the SESSION" below. -4. **Plugin** — third-party/niche/user-specific; lives in `~/.hermes/plugins/` or a pip - package, discovered at runtime. -5. **MCP server (in the catalog)** — genuinely a tool but not core-fundamental. Zero permanent - core-schema footprint, reusable by any MCP host, reached via the built-in MCP client. -6. **New core tool** — only when fundamental, broadly useful to nearly every user, and - unreachable via terminal + file or an MCP server (terminal, read_file, web_search, - browser_navigate). - -### Surface capability is a property of the SESSION, never of the process env - -A tool that works only because of *who is on the other end* (desktop panes, in-app browser, -message reactions, Projects) must resolve availability from the **session's own source**, not -from an env var on the backend. Client and backend are separate machines: the desktop app may -drive a locally spawned backend, one over SSH, one behind URL + token, or Hermes Cloud, and -only the first two carry `HERMES_DESKTOP=1`. An env-keyed gate is a silent no-op on the other -topologies — the tool is stripped from the schema while the platform hint tells the model it -is "inside the Hermes desktop app". The pattern: - -- **The toolset is the surface gate.** Keep such tools off `_HERMES_CORE_TOOLS` and in a named - toolset (`desktop_ui`, `project`); the GUI gateway's `_load_enabled_toolsets(platform)` - folds it in when the session's platform says GUI. One resolver, every topology. -- **`check_fn` answers reachability or opt-in, not surface.** "Is the bridge wired?" — fine. - "Was I spawned by Electron?" — not. `check_fn` results are TTL-cached process-wide - (`tools/registry.py`); a per-session answer does not belong there. -- **Ask which identity you mean.** `HERMES_DESKTOP=1` legitimately means "this backend was - spawned by the app" (cron ticker, web-dist handling). It does NOT mean "a GUI is watching"; - the embedded terminal pane (`hermes --tui` against that backend) is the counterexample. - -Test: if the capability still makes sense with the client on another machine, it is -session-scoped. Assert the GUI session gets the tool **with the env var absent**. +**Footprint ladder** (take the highest rung that solves it): extend existing code → CLI command + +skill → service-gated tool (`check_fn` answers reachability/opt-in, never per-session surface: +`tools/AGENTS.md`) → plugin → MCP server in the catalog → new core tool (fundamental, broadly useful, +unreachable otherwise). ## Development Environment -```bash -source ./activate # provisions/syncs PM tools + dependencies, then activates -``` -Select an isolated development `HERMES_HOME` and `HERMES_RUNTIME_DIR` first; -see `website/docs/reference/package-management.md#developer-workflow`. -fish: `source ./activate.fish`; PowerShell: `. .\activate.ps1`. `deactivate` restores the prior -environment. Non-interactive callers run under `scripts/run-in-hermes-env CMD...` instead of sourcing. -For tests, use the independent test environment in `CONTRIBUTING.md` (or Nix); -PM activation's `PYTHONPATH` does not survive the test runner's environment scrub. -`scripts/run_tests.sh` probes `.venv`, then `venv`, then `$HOME/.hermes/hermes-agent/venv` -(worktrees sharing the main checkout's venv). +For this fork, also read `website/docs/developer-guide/fork-local-environment.md` before local work. + +`source ./activate` (fish: `activate.fish`, PowerShell: `activate.ps1`) provisions and activates the +PM environment; pick an isolated `HERMES_HOME`/`HERMES_RUNTIME_DIR` first +(`website/docs/reference/package-management.md#developer-workflow`). Tests need the separate test +environment in `CONTRIBUTING.md`. **`python scripts/check`** runs every blocking lint check CI +runs, with CI's pinned tools; `--install-hook pre-push` runs it on every push (re-run after +pulling to refresh the hook). `# noqa` does not waive a ratchet finding: +`# health: allow -- ` does. ## Project Structure @@ -240,327 +78,90 @@ hermes-agent/ ├── cli.py # HermesCLI (REPL, slash dispatch) + hermes_cli/cli_*_mixin.py ├── hermes_state.py # SessionDB facade; hermes_state_*.py siblings ├── hermes_constants.py # get_hermes_home(), display_hermes_home() — profile-aware paths -├── hermes_logging.py # agent.log / errors.log / gateway.log (profile-aware) -├── batch_runner.py # Parallel batch processing -├── agent/ # turn_*.py loop phases, providers, memory, compression, prompt builder -├── hermes_cli/ # CLI subcommands, setup, config, plugins loader, skins, updater -│ └── web_routers/ # Dashboard FastAPI routers (one per surface); web_server.py mounts them -├── tools/ # Tool implementations, auto-discovered via tools/registry.py -│ └── environments/ # Terminal backends (local, docker, ssh, modal, daytona, singularity) +├── agent/ # turn loop phases, providers, memory, compression, prompt builder +├── hermes_cli/ # CLI subcommands, setup, config, plugins loader, updater, web_routers/ +├── tools/ # Tool implementations (tools/registry.py) + environments/ backends ├── gateway/ # run.py facade + run_*.py phases + session*.py + platforms/ -│ ├── platforms/ # One adapter per platform; see platforms/ADDING_A_PLATFORM.md -│ └── builtin_hooks/ # Always-registered gateway hooks (extension point; none shipped) ├── plugins/ # memory/, context_engine/, model-providers/, kanban/, image_gen/, ... ├── skills/ # Built-in skills (by category) optional-skills/: shipped, not active -├── ui-tui/ # Ink (React) terminal UI — `hermes --tui` -├── tui_gateway/ # Python JSON-RPC backend for TUI + Desktop — server.py + methods_*.py -├── apps/desktop/ # Electron desktop app (+ apps/shared JSON-RPC client) web/: dashboard SPA -├── acp_adapter/ # ACP server (VS Code / Zed / JetBrains) +├── ui-tui/, tui_gateway/ # Ink terminal UI + its Python JSON-RPC backend (also serves Desktop) +├── apps/desktop/ # Electron desktop app (+ apps/shared) web/: dashboard SPA ├── cron/ # jobs.py + scheduler.py (+ scheduler_*.py) -├── evals/ # Offline benchmarks (codebase_navigability/, compaction/, ...) -├── scripts/ # run_tests.sh, release.py, ci/ +├── pm/, hermes_platform/ # dependency/environment manager; machine facts + executable lookup +├── scripts/ # check, run_tests.sh, code_health/, ci/ ├── website/ # Docusaurus docs (developer-guide/ holds the long-form area docs) -└── tests/ # Pytest suite (~39k tests / ~3.7k files, Sep 2026) +└── tests/ # Pytest suite, mirrors the source tree ``` -**User state:** `~/.hermes/config.yaml` (settings), `~/.hermes/.env` (secrets only), -`~/.hermes/logs/` (`agent.log` INFO+, `errors.log` WARNING+, `gateway.log`); all -profile-aware via `get_hermes_home()`. Browse logs with `hermes logs [--follow] [--level] [--session]`. - -**Dependency chain:** `tools/registry.py` (no deps) ← `tools/*.py` (register at import) ← -`model_tools.py` (discovery) ← `run_agent.py`, `cli.py`, `batch_runner.py`, `environments/`. - -### Facade + siblings layout (Sep 2026 decomposition) - -Every former god file is a **facade** (public entry points + the names other packages import) -plus **siblings** `_.py` in the same directory, each owning one topic. Largest -families: `hermes_state.py` (21), `gateway/run.py` (15), `tools/mcp_tool.py` (15), -`hermes_cli/kanban.py` (14), `hermes_cli/web_server.py` (13 + 24 routers), `hermes_cli/auth.py` -(12), `tools/browser_tool.py` (11), `cli.py` (12 `hermes_cli/cli_*_mixin.py`), `run_agent.py` -(`agent/turn_*.py`, `agent_init.py`, `conversation_loop.py`). - -- **Find code by topic, not by facade:** `grep -rn "def name" /_*.py`. Reading the - facade first is the expensive way (`evals/codebase_navigability/`). -- **Siblings may import each other and late-import the facade** inside functions. A facade - never imports a sibling at module level *and* gets imported by that sibling at module level. -- **Patch where production reads.** Siblings often do `from import name` inside the - function so `monkeypatch.setattr(facade, "name", ...)` is the seam; a patch on the defining - module passes silently. Check the call site's binding before writing a patch target - (blind repointing to defining modules broke 130+ tests). -- **Don't recreate god files.** A file passing ~2,000 lines or a function passing ~300 lines / - cyclomatic complexity 30 is the signal to split along `_` FIRST, in its own - commit. New behaviour goes in a new or topical sibling — never appended to a facade. -- **No `if/elif` ladders ≥ 4 branches keyed on a name/kind** — use a dict/table → handler - (`_SLASH_DISPATCH` in `cli.py`, `_command_handler_table` in the gateway are the shape). -- **No re-export shims for internal moves** ("keep the old name importable"). Internal paths - are not API: plugins build on `ctx` and the documented ABCs. The one-time Sep 2026 - decomposition compat layer for external plugins has been removed; never reintroduce one. -- **Moving a symbol means fixing its docs in the same PR:** grep `website/docs`, - `skills/`, and every `AGENTS.md` for the old `path.py` + symbol (23 doc files went stale - after the refactor). `evals/codebase_navigability/static_metrics.py