compat(plugins): tell users which installed plugins break on 2026-09-14, and stop loading them after

hermes_cli/plugin_compat.py is now the single source of truth for the compat window:
  COMPAT_REMOVAL_DATE = 2026-09-14; scan_plugin() statically finds `from F import n`, `import F` + `F.n`,
  alias forms and string targets against compat_manifest.json; compat_report() aggregates over the user's
  ENABLED external (non-bundled) plugins; disable_reason() decides the loader's skip.

Surfaces (all read from that one report):
  * CLI: yellow block under the banner naming plugins + date + `hermes plugins compat` (red + DISABLED after)
  * `hermes plugins compat [--json] [path]`: file:line, old -> new per hit; exit 1 while anything remains;
    `path` lets a plugin author scan their own checkout
  * `hermes doctor`: "Plugin import paths (removed Sep 14, 2026)" section next to the xAI retirement check
  * `hermes update`: post-update notice alongside the FTS/curator notices
  * Desktop: compat_report() writes HERMES_HOME/.plugin-compat-report.json (deleted when clean); Electron
    shows ONE warning dialog per distinct report after the backend is up and persists the dismissal in
    userData/plugin-compat-dismissed.json. A new affected plugin, or the date passing, is a new report.

From the date, PluginManager skips a hitting external plugin before importing it, with the reason in
LoadedPlugin.error ("uses N import path(s) removed on 2026-09-14; run `hermes plugins compat` ...") — the
same path a plugin with a broken register() takes, so nothing else is affected. Escape hatch:
plugins.allow_deprecated_imports: true (config_defaults), which only helps until the compat commit is
actually reverted.

Docs: COMPAT_MANIFEST.md (removal date, what-happens table, author instructions), plugin dev guide section.
Tests: tests/test_plugin_compat_notice.py (scanner forms, report scope, date gate + escape hatch, summary
text, report file lifecycle, loader skip via a real PluginManager), electron/plugin-compat-notice.test.ts
(show once, re-show on a different set or on the date passing, malformed file ignored).

Live A/B on this box with a demo plugin on old paths: before the date it loads and the banner/doctor/report
name it; with today=2026-09-14 it is skipped with the reason and the banner turns red; with the escape
hatch it loads again.
This commit is contained in:
Teknium
2026-09-04 01:28:31 -07:00
parent 1392e4b857
commit 0a5164cebe
16 changed files with 754 additions and 14 deletions
+16 -2
View File
@@ -5,11 +5,25 @@ files. **Internal import paths are not a stable API**, and after that PR the nam
longer defined where they used to be. To give external plugin authors time to update, every name
is still importable from its OLD module through a `PLUGIN-COMPAT` block appended to that module.
**This layer is temporary.** It was added as a single commit and will be removed, on the announced
date, by reverting that commit. Update your plugin to import from the `new location` column now.
**This layer is temporary and removed on 2026-09-14.** It was added as a single commit and is removed
by reverting that commit. Update your plugin to import from the `new location` column now.
Nothing inside this repository is allowed to use these pointers (`scripts/check_compat_pointers.py`
fails CI if it does).
**What happens to an affected plugin.**
| when | CLI banner / `hermes doctor` / `hermes update` | Desktop | the plugin |
|---|---|---|---|
| before 2026-09-14 | yellow notice naming the plugin, the date, and `hermes plugins compat` | one-time modal (per set of affected plugins) | loads; each old-path resolution emits `HermesPluginCompatWarning` once |
| from 2026-09-14 | red notice: plugin **DISABLED** | one-time modal | **not loaded**; `hermes plugins list` shows the reason |
| after the revert lands | same | same | not loaded (the old paths no longer exist) |
Escape hatch for users who cannot wait on an author: `plugins.allow_deprecated_imports: true` in
`config.yaml` keeps affected plugins loading after the date, until the revert actually removes the paths.
**For plugin authors:** run `hermes plugins compat <path-to-your-plugin>` — it prints every `file:line`,
old path → new path, and exits 1 while anything remains. Import from the `new location` column.
**You will see a warning.** The first time a process resolves a name through one of these blocks, Hermes emits a
`HermesPluginCompatWarning` (a `FutureWarning`) naming the old path, the new path, and the removal target — once per
name per process. Fix the import and it goes away. To silence during migration:
+46
View File
@@ -204,6 +204,10 @@ import { probeGatewayWebSocket } from './gateway-ws-probe'
import { registerGitIpc } from './git-ipc'
import { clearStaleGitLocks } from './gitlock'
import { readAndConsumeHandoffResult } from './handoff-result'
import {
pendingNotice as pendingPluginCompatNotice,
recordDismissed as recordPluginCompatDismissed
} from './plugin-compat-notice'
import {
ATTACHMENT_UPLOAD_DEFAULT_MAX_BYTES,
clampDataUrlReadMaxMb,
@@ -6715,6 +6719,44 @@ function getAppIconPath() {
}
}
// One-time modal for plugins importing pre-decomposition module paths (see
// electron/plugin-compat-notice.ts). The backend writes the report during plugin
// discovery; we show each distinct report exactly once and remember the dismissal
// in userData so the user is never nagged twice about the same set of plugins.
let pluginCompatNoticeShown = false
async function showPluginCompatNoticeOnce() {
if (pluginCompatNoticeShown) return
if (!mainWindow || mainWindow.isDestroyed()) return
let notice
try {
notice = pendingPluginCompatNotice(HERMES_HOME, app.getPath('userData'))
} catch (err) {
rememberLog(`[plugins] compat notice check failed: ${err.message}`)
return
}
if (!notice) return
pluginCompatNoticeShown = true
rememberLog(`[plugins] compat notice shown (${notice.key})`)
try {
await dialog.showMessageBox(mainWindow, {
type: 'warning',
title: notice.title,
message: notice.message,
detail: notice.detail,
buttons: ['OK'],
defaultId: 0,
noLink: true
})
} finally {
try {
recordPluginCompatDismissed(app.getPath('userData'), notice.key)
} catch (err) {
rememberLog(`[plugins] could not persist compat notice dismissal: ${err.message}`)
}
}
}
function sendOpenUpdatesRequested() {
if (!mainWindow || mainWindow.isDestroyed()) {
return
@@ -13049,6 +13091,10 @@ async function startHermes() {
// accumulated count of the resolved episode.
bootstrapRepairAttempt = 0
// The backend's plugin discovery just ran and refreshed HERMES_HOME/.plugin-compat-report.json.
// Surface it once (per distinct set of affected plugins) after the window is up; never block boot.
setTimeout(() => void showPluginCompatNoticeOnce(), 1500)
return {
baseUrl,
mode: 'local',
@@ -0,0 +1,74 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { test } from 'vitest'
import { DISMISSED_FILE, REPORT_FILE, pendingNotice, recordDismissed, reportKey } from './plugin-compat-notice'
function tmp() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-compat-'))
}
const REPORT = {
removal_date: '2026-09-14',
in_effect: false,
plugins: {
alpha: [{ file: '__init__.py', line: 3, old: 'tools.web_tools.prefers_gateway', new: 'tools.tool_backend_helpers.prefers_gateway' }],
beta: [
{ file: 'a.py', line: 1, old: 'hermes_cli.kanban_db.connect', new: 'hermes_cli.kanban_db_connect.connect' },
{ file: 'a.py', line: 9, old: 'hermes_cli.kanban_db.connect_closing', new: 'hermes_cli.kanban_db_connect.connect_closing' }
]
},
lines: ['2 plugins use import paths that stop working on 2026-09-14 (10 days): alpha (1), beta (2)', 'Details: hermes plugins compat']
}
test('no report file → no notice', () => {
assert.equal(pendingNotice(tmp(), tmp()), null)
})
test('report → one notice naming plugins, date and the CLI command', () => {
const home = tmp()
fs.writeFileSync(path.join(home, REPORT_FILE), JSON.stringify(REPORT))
const n = pendingNotice(home, tmp())
assert.ok(n)
assert.equal(n.title, 'Plugins need an update')
assert.match(n.message, /2 plugins import module paths that stop working on 2026-09-14/)
assert.match(n.detail, /• alpha — 1 import \(e\.g\. tools\.web_tools\.prefers_gateway → tools\.tool_backend_helpers\.prefers_gateway\)/)
assert.match(n.detail, /• beta — 2 imports/)
assert.match(n.detail, /hermes plugins compat/)
})
test('dismissal is remembered for the same report and forgotten for a different one', () => {
const home = tmp()
const userData = tmp()
fs.writeFileSync(path.join(home, REPORT_FILE), JSON.stringify(REPORT))
const first = pendingNotice(home, userData)
assert.ok(first)
recordDismissed(userData, first.key)
assert.ok(fs.existsSync(path.join(userData, DISMISSED_FILE)))
assert.equal(pendingNotice(home, userData), null, 'same report must not show twice')
// a third affected plugin is new information
const grown = { ...REPORT, plugins: { ...REPORT.plugins, gamma: [{ file: 'g.py', line: 1, old: 'x.y', new: 'z.y' }] } }
fs.writeFileSync(path.join(home, REPORT_FILE), JSON.stringify(grown))
const second = pendingNotice(home, userData)
assert.ok(second)
assert.notEqual(second.key, first.key)
// the date passing (plugins now disabled) is new information too, with different wording
const disabled = { ...REPORT, in_effect: true }
fs.writeFileSync(path.join(home, REPORT_FILE), JSON.stringify(disabled))
const third = pendingNotice(home, userData)
assert.ok(third)
assert.equal(third.title, 'Some plugins were not loaded')
assert.match(third.detail, /allow_deprecated_imports/)
assert.notEqual(reportKey(disabled as any), reportKey(REPORT as any))
})
test('empty or malformed report is ignored', () => {
const home = tmp()
fs.writeFileSync(path.join(home, REPORT_FILE), JSON.stringify({ ...REPORT, plugins: {} }))
assert.equal(pendingNotice(home, tmp()), null)
fs.writeFileSync(path.join(home, REPORT_FILE), '{not json')
assert.equal(pendingNotice(home, tmp()), null)
})
@@ -0,0 +1,107 @@
/**
* One-time Desktop notice for plugins that import pre-decomposition module paths (PR #102117).
*
* The Python side (hermes_cli/plugin_compat.py) statically scans the user's enabled external plugins on
* every CLI/gateway/TUI start and writes HERMES_HOME/.plugin-compat-report.json when any plugin imports a
* path scheduled for removal on 2026-09-14 (deleting the file when none do). Desktop reads that file at
* boot and shows ONE modal, then records the dismissal in userData so the same set of affected plugins is
* never shown again. A *different* set (a new affected plugin, or the removal date passing so the plugins
* are now disabled) is a new message and shows once more.
*
* Pure module: no Electron imports, so it is unit-testable; main.ts owns the dialog.
*/
import fs from 'fs'
import path from 'path'
export const REPORT_FILE = '.plugin-compat-report.json'
export const DISMISSED_FILE = 'plugin-compat-dismissed.json'
export interface PluginCompatHit {
file: string
line: number
old: string
new: string
}
export interface PluginCompatReport {
removal_date: string
in_effect: boolean
written_at?: string
plugins: Record<string, PluginCompatHit[]>
lines: [string, string]
}
/** Stable identity of a report: which plugins, how many hits each, and whether removal is in effect. */
export function reportKey(report: PluginCompatReport): string {
const parts = Object.keys(report.plugins)
.sort()
.map(name => `${name}:${report.plugins[name].length}`)
return `${report.in_effect ? 'disabled' : 'pending'}|${parts.join(',')}`
}
export function readReport(hermesHome: string): PluginCompatReport | null {
try {
const raw = fs.readFileSync(path.join(hermesHome, REPORT_FILE), 'utf8')
const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== 'object' || !parsed.plugins || !Array.isArray(parsed.lines)) return null
if (Object.keys(parsed.plugins).length === 0) return null
return parsed as PluginCompatReport
} catch {
return null
}
}
export function wasDismissed(userData: string, key: string): boolean {
try {
const raw = JSON.parse(fs.readFileSync(path.join(userData, DISMISSED_FILE), 'utf8'))
return Array.isArray(raw?.keys) && raw.keys.includes(key)
} catch {
return false
}
}
export function recordDismissed(userData: string, key: string): void {
const file = path.join(userData, DISMISSED_FILE)
let keys: string[] = []
try {
const raw = JSON.parse(fs.readFileSync(file, 'utf8'))
if (Array.isArray(raw?.keys)) keys = raw.keys.filter((k: unknown) => typeof k === 'string')
} catch {
/* first dismissal */
}
if (!keys.includes(key)) keys.push(key)
fs.mkdirSync(userData, { recursive: true })
fs.writeFileSync(file, JSON.stringify({ keys: keys.slice(-20) }, null, 2))
}
export interface PendingNotice {
key: string
title: string
message: string
detail: string
}
/** The modal to show this boot, or null (no report, or this exact report already dismissed). */
export function pendingNotice(hermesHome: string, userData: string): PendingNotice | null {
const report = readReport(hermesHome)
if (!report) return null
const key = reportKey(report)
if (wasDismissed(userData, key)) return null
const names = Object.keys(report.plugins).sort()
const list = names
.map(n => {
const hits = report.plugins[n]
const first = hits[0]
return `• ${n} — ${hits.length} import${hits.length === 1 ? '' : 's'} (e.g. ${first.old} → ${first.new})`
})
.join('\n')
const title = report.in_effect ? 'Some plugins were not loaded' : 'Plugins need an update'
const message = report.in_effect
? `${names.length} plugin${names.length === 1 ? '' : 's'} import${names.length === 1 ? 's' : ''} module paths that were removed on ${report.removal_date} and ${names.length === 1 ? 'was' : 'were'} not loaded.`
: `${names.length} plugin${names.length === 1 ? '' : 's'} import${names.length === 1 ? 's' : ''} module paths that stop working on ${report.removal_date}.`
const detail = report.in_effect
? `${list}\n\nUpdate the plugin(s), or force-load them with plugins.allow_deprecated_imports: true in config.yaml (they will still break once the compatibility layer is removed).\n\nFull list: hermes plugins compat`
: `${list}\n\nCheck for plugin updates or notify the author before ${report.removal_date}. After that date these plugins are not loaded.\n\nFull list: hermes plugins compat`
return { key, title, message, detail }
}
+16
View File
@@ -74,6 +74,21 @@ class CLIInfoMixin:
"""Informational views and reload flows for the interactive CLI: banner, help, tools, usage,
insights, MCP/skills reload, bang shell."""
def _show_plugin_compat_notice(self) -> None:
"""One yellow block under the banner when an enabled external plugin imports paths scheduled for
removal (red once the date has passed and the plugin was skipped). Never raises."""
try:
from hermes_cli.plugin_compat import compat_report, removal_in_effect, summary_lines
lines = summary_lines(compat_report())
except Exception:
return
if not lines:
return
colour = "bold red" if removal_in_effect() else "bold yellow"
self._console_print()
self._console_print(f"[{colour}]⚠ {lines[0]}[/]")
self._console_print(f"[dim] {lines[1]}[/]")
def show_banner(self):
"""Display the welcome banner in Claude Code style."""
from cli import _build_compact_banner, get_tool_definitions, logger
@@ -154,6 +169,7 @@ class CLIInfoMixin:
# Low context warning — tied to the runtime guard so guidance cannot drift.
from agent.model_metadata import MINIMUM_CONTEXT_LENGTH
self._show_plugin_compat_notice()
if ctx_len and ctx_len < MINIMUM_CONTEXT_LENGTH:
self._console_print()
self._console_print(
+4
View File
@@ -1547,6 +1547,10 @@ DEFAULT_CONFIG = {
# Wall-clock cap (seconds) for one in-process Python plugin hook callback; shell hooks keep
# their own per-entry `timeout`. 0 = no cap (sync call on agent thread). Max 600.
"hook_callback_timeout": 30,
# Keep loading external plugins that still import pre-decomposition module paths after the
# 2026-09-14 removal date (see COMPAT_MANIFEST.md, `hermes plugins compat`). Stopgap only: the
# old paths raise ImportError once the compat layer is actually removed.
"allow_deprecated_imports": False,
},
# Shell-script hooks: event name (pre_tool_call, post_tool_call, pre_llm_call, subagent_stop,
# ...) -> list of {matcher, command, timeout}. First run of a new command prompts for consent;
+3 -1
View File
@@ -30,6 +30,7 @@ from hermes_cli.doctor_config import (
_check_env_file,
_check_mcp_security,
_check_xai_retirement,
_check_plugin_compat,
)
from hermes_cli.doctor_platform import (
_check_certificates,
@@ -110,7 +111,8 @@ DOCTOR_CHECKS = (
('Python Environment', _check_python_environment), ('SSL / CA Certificates', _check_certificates),
('Required Packages', _check_required_packages), ('Configuration Files', _check_env_file),
(None, _check_config_file), (None, _check_config_drift),
('xAI Model Retirement (May 15, 2026)', _check_xai_retirement), ('Auth Providers', _check_auth_providers),
('xAI Model Retirement (May 15, 2026)', _check_xai_retirement),
('Plugin import paths (removed Sep 14, 2026)', _check_plugin_compat), ('Auth Providers', _check_auth_providers),
('Directory Structure', _check_directory_structure), (None, _check_state_db),
(None, _check_gateway_supervision), (None, _check_command_installation),
('External Tools', _check_git_and_rg), (None, _check_terminal_backend), (None, _check_node_and_browser),
+17
View File
@@ -418,3 +418,20 @@ def _check_xai_retirement(should_fix: bool, f: Finding) -> None:
check_warn(format_issue(ref))
check_info(f"Migration guide: {MIGRATION_GUIDE_URL}")
f.manual_issues.append(f"Update {len(retired_refs)} retired xAI model reference(s) in config.yaml — see {MIGRATION_GUIDE_URL}")
@doctor_check("Plugin compat check skipped", "({e})")
def _check_plugin_compat(should_fix: bool, f: Finding) -> None:
from hermes_cli.plugin_compat import ALLOW_KEY, COMPAT_REMOVAL, compat_report, removal_in_effect
report = compat_report()
if not report:
check_ok(f"No enabled plugin imports paths removed on {COMPAT_REMOVAL}")
return
for name, hits in sorted(report.items()):
(check_fail if removal_in_effect() else check_warn)(
f"{name}: {len(hits)} import(s) of paths removed on {COMPAT_REMOVAL}", f"{hits[0].old} -> {hits[0].new}")
check_info("Details: hermes plugins compat")
f.manual_issues.append(
f"Update {len(report)} plugin(s) still importing pre-decomposition paths (hermes plugins compat) — "
+ ("they are NOT being loaded" if removal_in_effect() else f"they stop loading on {COMPAT_REMOVAL}")
+ f"; escape hatch: plugins.{ALLOW_KEY}: true")
+270 -10
View File
@@ -1,27 +1,287 @@
"""Once-per-process notice for external plugins still importing from pre-decomposition module paths.
"""Plugin compatibility with the Sep 2026 decomposition: detect, warn, and (after the date) disable.
The Sep 2026 decomposition (PR #102117) moved most of Hermes's internals into ``<stem>_<topic>`` sibling
modules. Old paths keep resolving through ``PLUGIN-COMPAT`` blocks so plugins have time to update; each
resolution through such a block calls :func:`warn_once` so the plugin author sees, exactly once per process
per name, where the code went and when the old path disappears. Internal Hermes code never reaches this
(``scripts/check_compat_pointers.py`` fails CI if it does).
The decomposition (PR #102117) moved most of Hermes's internals into ``<stem>_<topic>`` sibling modules.
Old import paths keep resolving through ``PLUGIN-COMPAT`` blocks until :data:`COMPAT_REMOVAL_DATE`, when
the commit that added them is reverted. This module is the single source of truth for everything that
tells plugin authors and users about that:
* :func:`scan_plugin` — static AST scan of one plugin directory for imports of manifest names.
* :func:`compat_report` — ``{plugin_name: [Hit, ...]}`` across the user's ENABLED external plugins, cached.
* :func:`removal_in_effect` — True once today >= the removal date (or the layer is already gone).
* :func:`warn_once` — the per-name runtime warning emitted by the PLUGIN-COMPAT ``__getattr__`` blocks.
Surfaces that read from here: the CLI banner, ``hermes plugins compat``, ``hermes doctor``, the post-update
notices, the TUI/Desktop ``plugins.compat_report`` RPC, and ``PluginManager`` (which skips a hitting plugin
after the date unless ``plugins.allow_deprecated_imports: true``).
This module is part of the compat layer and is removed with it.
"""
from __future__ import annotations
import ast
import datetime as _dt
import json
import os
import threading
import warnings
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Tuple
# Set by the maintainers when the removal is scheduled; surfaces in every warning and in COMPAT_MANIFEST.md.
COMPAT_REMOVAL = os.environ.get("HERMES_PLUGIN_COMPAT_REMOVAL", "the next minor release after the announced date (see COMPAT_MANIFEST.md)")
COMPAT_REMOVAL_DATE = _dt.date(2026, 9, 14)
COMPAT_REMOVAL = COMPAT_REMOVAL_DATE.isoformat()
ALLOW_KEY = "allow_deprecated_imports" # under plugins: in config.yaml
_MANIFEST_NAME = "compat_manifest.json"
_SKIP_DIRS = {"__pycache__", "node_modules", ".git", "tests", "test", ".venv", "venv"}
class HermesPluginCompatWarning(FutureWarning):
"""A plugin imported a name from its pre-decomposition module path."""
_seen: set[tuple[str, str]] = set()
@dataclass(frozen=True)
class Hit:
file: str # path relative to the plugin dir
line: int
old: str # "facade.name"
new: str # "target_module.name" (or the target module when the name is unchanged)
# ---------------------------------------------------------------------------------------------- manifest
_manifest_lock = threading.Lock()
_manifest_cache: Optional[Dict[str, Dict[str, str]]] = None # facade -> {name: new_path}
def manifest_path() -> Path:
return Path(__file__).resolve().parent.parent / _MANIFEST_NAME
def load_manifest() -> Dict[str, Dict[str, str]]:
"""``{facade_module: {name: new_dotted_path}}``; ``{}`` when the compat layer is gone."""
global _manifest_cache
with _manifest_lock:
if _manifest_cache is not None:
return _manifest_cache
out: Dict[str, Dict[str, str]] = {}
p = manifest_path()
if p.exists():
try:
for e in json.loads(p.read_text(encoding="utf-8"))["entries"]:
target = e.get("target") or ""
if target.startswith("("): # restored-def etc.: no new home, just "gone later"
new = f"{e['facade']}.{e['name']} (removed; no replacement — vendor a copy)"
elif target.endswith("." + e["name"]):
new = target
else:
new = f"{target}.{e['name']}"
out.setdefault(e["facade"], {})[e["name"]] = new
except Exception:
out = {}
_manifest_cache = out
return out
def removal_in_effect(today: Optional[_dt.date] = None) -> bool:
"""True when hitting plugins must be disabled: the date has passed or the layer is already reverted."""
if not manifest_path().exists():
return True
return (today or _dt.date.today()) >= COMPAT_REMOVAL_DATE
def days_until_removal(today: Optional[_dt.date] = None) -> int:
return (COMPAT_REMOVAL_DATE - (today or _dt.date.today())).days
# ---------------------------------------------------------------------------------------------- scanner
def _iter_py(root: Path) -> Iterable[Path]:
for dp, dns, fns in os.walk(root):
dns[:] = [d for d in dns if d not in _SKIP_DIRS and not d.startswith(".")]
for f in fns:
if f.endswith(".py"):
yield Path(dp) / f
def scan_source(src: str, rel: str, manifest: Dict[str, Dict[str, str]]) -> List[Hit]:
"""Hits in one file: ``from F import n``, ``import F`` + ``F.n``, ``import F as a`` + ``a.n``,
and string targets ``"F.n"`` (``patch``/``import_module``)."""
try:
tree = ast.parse(src)
except SyntaxError:
return []
hits: List[Hit] = []
aliases: Dict[str, str] = {} # local alias -> facade module
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.module in manifest and node.level == 0:
for a in node.names:
if a.name in manifest[node.module]:
hits.append(Hit(rel, node.lineno, f"{node.module}.{a.name}", manifest[node.module][a.name]))
elif isinstance(node, ast.Import):
for a in node.names:
if a.name in manifest:
aliases[a.asname or a.name] = a.name
for node in ast.walk(tree):
if isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name) and node.value.id in aliases:
fac = aliases[node.value.id]
if node.attr in manifest[fac]:
hits.append(Hit(rel, node.lineno, f"{fac}.{node.attr}", manifest[fac][node.attr]))
elif isinstance(node, ast.Attribute):
# dotted: pkg.sub.name -> resolve the full module chain
parts: List[str] = []
cur: ast.AST = node
while isinstance(cur, ast.Attribute):
parts.append(cur.attr)
cur = cur.value
if isinstance(cur, ast.Name):
parts.append(cur.id)
parts.reverse()
for i in range(1, len(parts)):
mod, name = ".".join(parts[:i]), parts[i]
if mod in manifest and name in manifest[mod]:
hits.append(Hit(rel, node.lineno, f"{mod}.{name}", manifest[mod][name]))
elif isinstance(node, ast.Constant) and isinstance(node.value, str) and "." in node.value:
mod, _, name = node.value.rpartition(".")
if mod in manifest and name in manifest[mod]:
hits.append(Hit(rel, node.lineno, node.value, manifest[mod][name]))
# dedupe (the two walks can see the same Attribute)
return sorted(set(hits), key=lambda h: (h.file, h.line, h.old))
def scan_plugin(plugin_dir: Path, manifest: Optional[Dict[str, Dict[str, str]]] = None) -> List[Hit]:
manifest = load_manifest() if manifest is None else manifest
if not manifest or not plugin_dir or not Path(plugin_dir).is_dir():
return []
hits: List[Hit] = []
for p in _iter_py(Path(plugin_dir)):
try:
src = p.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
hits += scan_source(src, str(p.relative_to(plugin_dir)), manifest)
return hits
# ---------------------------------------------------------------------------------------------- report
_report_lock = threading.Lock()
_report_cache: Dict[Tuple[str, ...], Dict[str, List[Hit]]] = {}
def compat_report(manifests=None, *, force: bool = False) -> Dict[str, List[Hit]]:
"""``{plugin_name: hits}`` for every ENABLED external (non-bundled) plugin with at least one hit.
``manifests`` defaults to the current PluginManager's discovered manifests. Cached per manifest set.
"""
if manifests is None:
try:
from hermes_cli.plugins import get_plugin_manager
mgr = get_plugin_manager()
mgr.discover_and_load()
manifests = [lp.manifest for lp in mgr._plugins.values()]
except Exception:
return {}
external = [m for m in manifests if getattr(m, "source", "") != "bundled" and getattr(m, "path", None)]
key = tuple(sorted(f"{m.name}@{m.path}" for m in external))
with _report_lock:
if not force and key in _report_cache:
return _report_cache[key]
manifest = load_manifest()
out: Dict[str, List[Hit]] = {}
for m in external:
d = Path(str(m.path).partition(":")[0])
d = d if d.is_dir() else d.parent
hits = scan_plugin(d, manifest)
if hits:
out[m.name] = hits
with _report_lock:
_report_cache[key] = out
_write_report_file(out)
return out
REPORT_FILE = ".plugin-compat-report.json"
def report_file_path() -> Path:
from hermes_constants import get_hermes_home
return get_hermes_home() / REPORT_FILE
def _write_report_file(report: Dict[str, List[Hit]]) -> None:
"""Persist the latest report for surfaces without a Python runtime handy (the Desktop boot modal).
Written on every scan so a fixed plugin clears the notice on the next start; removed outright when
there is nothing to report so a stale file can never resurface a resolved warning.
"""
try:
p = report_file_path()
if not report:
if p.exists():
p.unlink()
return
payload = {"removal_date": COMPAT_REMOVAL, "in_effect": removal_in_effect(),
"written_at": _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds"),
"plugins": {k: [h.__dict__ for h in v] for k, v in report.items()},
"lines": summary_lines(report)}
p.parent.mkdir(parents=True, exist_ok=True)
tmp = p.with_suffix(".tmp")
tmp.write_text(json.dumps(payload, indent=1), encoding="utf-8")
os.replace(tmp, p)
except Exception:
pass
def plugin_hits(manifest) -> List[Hit]:
"""Hits for ONE manifest (used by the loader before importing it)."""
if getattr(manifest, "source", "") == "bundled" or not getattr(manifest, "path", None):
return []
d = Path(str(manifest.path).partition(":")[0])
return scan_plugin(d if d.is_dir() else d.parent)
def allow_deprecated_imports(config: Optional[dict] = None) -> bool:
"""``plugins.allow_deprecated_imports: true`` keeps hitting plugins loading after the date."""
try:
if config is None:
from hermes_cli.config import load_config_readonly
config = load_config_readonly()
return bool(((config or {}).get("plugins") or {}).get(ALLOW_KEY, False))
except Exception:
return False
def disable_reason(manifest, *, today: Optional[_dt.date] = None) -> Optional[str]:
"""Why the loader must skip this plugin now, or None. Only ever non-None after the removal date."""
if not removal_in_effect(today) or allow_deprecated_imports():
return None
hits = plugin_hits(manifest)
if not hits:
return None
return (f"uses {len(hits)} import path(s) removed on {COMPAT_REMOVAL}; run `hermes plugins compat` "
f"for the list, update the plugin, or set plugins.{ALLOW_KEY}: true to force-load")
def summary_lines(report: Dict[str, List[Hit]], *, today: Optional[_dt.date] = None) -> List[str]:
"""Plain-text lines for banners/notices; empty when there is nothing to say."""
if not report:
return []
n = len(report)
names = ", ".join(f"{k} ({len(v)})" for k, v in sorted(report.items()))
if removal_in_effect(today):
head = (f"{n} plugin{'s' if n != 1 else ''} DISABLED: they import paths removed on {COMPAT_REMOVAL}: {names}")
tail = f"Update the plugin(s) or set plugins.{ALLOW_KEY}: true to force-load. Details: hermes plugins compat"
else:
d = days_until_removal(today)
head = (f"{n} plugin{'s' if n != 1 else ''} use{'s' if n == 1 else ''} import paths that stop working on "
f"{COMPAT_REMOVAL} ({d} day{'s' if d != 1 else ''}): {names}")
tail = "Check for plugin updates or notify the author before then. Details: hermes plugins compat"
return [head, tail]
# ---------------------------------------------------------------------------------------------- runtime warn
_seen: set = set()
def warn_once(facade: str, name: str, target_module: str, target_name: str) -> None:
@@ -32,7 +292,7 @@ def warn_once(facade: str, name: str, target_module: str, target_name: str) -> N
new = f"{target_module}.{target_name}" if target_name != name else f"{target_module}.{name}"
warnings.warn(
f"hermes plugin compat: `{facade}.{name}` moved to `{new}`. The old path is kept only for external "
f"plugins and is removed in {COMPAT_REMOVAL}; update your import.",
f"plugins and is removed on {COMPAT_REMOVAL}; update your import.",
HermesPluginCompatWarning,
stacklevel=3,
)
+37
View File
@@ -1997,6 +1997,42 @@ def _action_pack(args):
pack_command(args)
def cmd_compat(args: Any | None = None) -> None:
"""``hermes plugins compat`` — which installed plugins import paths scheduled for removal, and where."""
import sys
from pathlib import Path
from hermes_cli.plugin_compat import (
ALLOW_KEY, COMPAT_REMOVAL, compat_report, removal_in_effect, scan_plugin, summary_lines)
console = _console()
path = getattr(args, "path", None)
if path:
hits = scan_plugin(Path(path).expanduser().resolve())
report = {Path(path).name: hits} if hits else {}
else:
report = compat_report(force=True)
if getattr(args, "json", False):
print(json.dumps({"removal_date": COMPAT_REMOVAL, "in_effect": removal_in_effect(),
"plugins": {k: [h.__dict__ for h in v] for k, v in report.items()}}, indent=2))
sys.exit(1 if report else 0)
if not report:
console.print(f"[green]✓ No enabled plugin imports paths scheduled for removal on {COMPAT_REMOVAL}.[/green]")
return
head, tail = summary_lines(report)
console.print(f"[bold {'red' if removal_in_effect() else 'yellow'}]{head}[/]")
console.print(f"[dim]{tail}[/dim]")
for name, hits in sorted(report.items()):
table = _table(((f"{name} ({len(hits)} import{'s' if len(hits) != 1 else ''})", "bold"), ("old path", "yellow"), ("new path", "green")),
title=None, show_lines=False)
for h in hits:
table.add_row(f"{h.file}:{h.line}", h.old, h.new)
console.print()
console.print(table)
console.print()
console.print(f"[dim]After {COMPAT_REMOVAL} these plugins are not loaded. Update them, or force-load with "
f"plugins.{ALLOW_KEY}: true in config.yaml (the old paths still break once the compat layer is reverted).[/dim]")
sys.exit(1)
# Tri-state flags: neither --x nor --no-x given == None == interactive prompt.
_PLUGIN_ACTIONS = {
"install": lambda args: cmd_install(
@@ -2021,6 +2057,7 @@ _PLUGIN_ACTIONS = {
"list": lambda args: cmd_list(args),
"ls": lambda args: cmd_list(args),
"doctor": lambda args: cmd_plugin_doctor(args.target, ci=getattr(args, "ci", False)),
"compat": lambda args: cmd_compat(args),
"pack": _action_pack,
"show": lambda args: cmd_show(args.name),
"info": lambda args: cmd_show(args.name),
+9
View File
@@ -278,6 +278,15 @@ class PluginLoaderMixin:
if manifest.portable:
self._load_portable_plugin(manifest, loaded)
return
# After the compat-removal date an external plugin that still imports pre-decomposition paths is
# skipped with a clear reason instead of dying on ImportError mid-register (hermes_cli.plugin_compat).
from hermes_cli.plugin_compat import disable_reason
reason = disable_reason(manifest)
if reason:
loaded.error = reason
logger.warning("Plugin '%s' not loaded: %s", manifest.name, reason)
self._plugins[plugin_key] = loaded
return
registration_start = len(self._registration_order)
module_name = self._policy_module_name(manifest)
self._track_tool_override_policy(manifest, module_name)
+11
View File
@@ -101,6 +101,17 @@ def build_plugins_parser(subparsers, *, cmd_plugins: Callable) -> None:
plugins_doctor.add_argument(
"--ci", action="store_true", help="Exit non-zero when validation reports an error")
plugins_compat = plugins_subparsers.add_parser(
"compat",
help="Show installed plugins that import paths removed by the Sep 2026 decomposition",
description="Statically scans every enabled external plugin for imports of pre-decomposition "
"module paths (see COMPAT_MANIFEST.md) and prints file:line, old path -> new path. "
"Exits 1 when any plugin is affected. Plugins still affected on the removal date are "
"not loaded (override: plugins.allow_deprecated_imports: true).")
plugins_compat.add_argument("--json", action="store_true", help="Machine-readable output")
plugins_compat.add_argument(
"path", nargs="?", help="Scan one plugin directory instead of the installed set (for plugin authors)")
plugins_pack = plugins_subparsers.add_parser(
"pack", help="Declarative, shareable plugin sets (hermes-pack.yaml)",
description="Install, export, or inspect plugin packs — a single YAML file "
+11
View File
@@ -936,6 +936,16 @@ def _refresh_cua_driver_after_update() -> None:
install_cua_driver(upgrade=True, require_confirmed_update=True, show_installer_progress=False)
def _print_plugin_compat_notice() -> None:
"""Installed plugins importing paths that the Sep 2026 decomposition scheduled for removal."""
from hermes_cli.plugin_compat import compat_report, removal_in_effect, summary_lines
lines = summary_lines(compat_report(force=True))
if not lines:
return
colour = "\033[1;31m" if removal_in_effect() else "\033[1;33m"
print(f"\n{colour}⚠ {lines[0]}\033[0m\n {lines[1]}")
def _print_post_update_notices_and_self_heals() -> None:
"""Best-effort notices (FTS optimize, curator) and self-heals (FHS PATH, ACP launcher,
Windows bin launchers, cua-driver refresh) that run after the summary."""
@@ -956,6 +966,7 @@ def _print_post_update_notices_and_self_heals() -> None:
('hermes-acp launcher self-heal failed: %s', _ensure_acp_launcher),
('Windows bin launcher migration failed: %s', _migrate_windows_bin_path),
('cua-driver refresh failed: %s', _refresh_cua_driver_after_update),
('Plugin compat notice failed: %s', _print_plugin_compat_notice),
):
with _best_effort(message):
step()
+117
View File
@@ -0,0 +1,117 @@
"""hermes_cli.plugin_compat: detect plugins on old import paths, tell the user, disable after the date.
Kept with the compat layer (tests/test_compat_manifest_targets.py); deleted with it.
"""
from __future__ import annotations
import datetime as dt
import json
import textwrap
from pathlib import Path
from types import SimpleNamespace
import pytest
from hermes_cli import plugin_compat as pc
ROOT = Path(__file__).resolve().parent.parent
pytestmark = pytest.mark.skipif(not (ROOT / "compat_manifest.json").exists(), reason="compat layer removed")
MANIFEST = {"tools.web_tools": {"prefers_gateway": "tools.tool_backend_helpers.prefers_gateway"},
"hermes_cli.kanban_db": {"connect": "hermes_cli.kanban_db_connect.connect"}}
@pytest.mark.parametrize("src, expect", [
("from tools.web_tools import prefers_gateway\n", ["tools.web_tools.prefers_gateway"]),
("import tools.web_tools\nx = tools.web_tools.prefers_gateway()\n", ["tools.web_tools.prefers_gateway"]),
("import tools.web_tools as wt\nwt.prefers_gateway()\n", ["tools.web_tools.prefers_gateway"]),
("from unittest.mock import patch\npatch('hermes_cli.kanban_db.connect')\n", ["hermes_cli.kanban_db.connect"]),
("from tools.web_tools import web_search\n", []), # live name: not a hit
("from tools.tool_backend_helpers import prefers_gateway\n", []), # already migrated
])
def test_scan_source_finds_every_import_form(src, expect):
hits = pc.scan_source(src, "p.py", MANIFEST)
assert [h.old for h in hits] == expect
for h in hits:
assert h.new == MANIFEST[h.old.rsplit(".", 1)[0]][h.old.rsplit(".", 1)[1]]
def test_scan_plugin_walks_dir_and_skips_tests(tmp_path):
(tmp_path / "__init__.py").write_text("from tools.web_tools import prefers_gateway\n")
(tmp_path / "sub").mkdir(); (tmp_path / "sub" / "m.py").write_text("import hermes_cli.kanban_db as k\nk.connect()\n")
(tmp_path / "tests").mkdir(); (tmp_path / "tests" / "t.py").write_text("from tools.web_tools import prefers_gateway\n")
hits = pc.scan_plugin(tmp_path, MANIFEST)
assert sorted(h.file for h in hits) == ["__init__.py", "sub/m.py"]
def _manifest(name, path, source="user"):
return SimpleNamespace(name=name, path=str(path), source=source)
def test_compat_report_only_external_plugins_with_hits(tmp_path, monkeypatch):
monkeypatch.setattr(pc, "load_manifest", lambda: MANIFEST)
monkeypatch.setattr(pc, "_write_report_file", lambda r: None)
good = tmp_path / "good"; good.mkdir(); (good / "__init__.py").write_text("x = 1\n")
bad = tmp_path / "bad"; bad.mkdir(); (bad / "__init__.py").write_text("from tools.web_tools import prefers_gateway\n")
bundled = tmp_path / "bundled"; bundled.mkdir(); (bundled / "__init__.py").write_text("from tools.web_tools import prefers_gateway\n")
report = pc.compat_report([_manifest("good", good), _manifest("bad", bad), _manifest("ours", bundled, "bundled")], force=True)
assert list(report) == ["bad"] and report["bad"][0].old == "tools.web_tools.prefers_gateway"
def test_disable_only_after_the_date_and_not_when_allowed(tmp_path, monkeypatch):
monkeypatch.setattr(pc, "load_manifest", lambda: MANIFEST)
bad = tmp_path / "bad"; bad.mkdir(); (bad / "__init__.py").write_text("from tools.web_tools import prefers_gateway\n")
m = _manifest("bad", bad)
before, after = pc.COMPAT_REMOVAL_DATE - dt.timedelta(days=1), pc.COMPAT_REMOVAL_DATE
monkeypatch.setattr(pc, "allow_deprecated_imports", lambda config=None: False)
assert pc.disable_reason(m, today=before) is None
reason = pc.disable_reason(m, today=after)
assert reason and pc.COMPAT_REMOVAL in reason and "hermes plugins compat" in reason
monkeypatch.setattr(pc, "allow_deprecated_imports", lambda config=None: True)
assert pc.disable_reason(m, today=after) is None
good = tmp_path / "good"; good.mkdir(); (good / "__init__.py").write_text("x=1\n")
monkeypatch.setattr(pc, "allow_deprecated_imports", lambda config=None: False)
assert pc.disable_reason(_manifest("good", good), today=after) is None
def test_summary_lines_name_plugins_and_the_date():
report = {"alpha": [pc.Hit("a.py", 1, "x.y", "z.y")], "beta": [pc.Hit("b.py", 2, "x.y", "z.y"), pc.Hit("b.py", 3, "x.q", "z.q")]}
before = pc.COMPAT_REMOVAL_DATE - dt.timedelta(days=3)
head, tail = pc.summary_lines(report, today=before)
assert "2 plugins" in head and "alpha (1)" in head and "beta (2)" in head and pc.COMPAT_REMOVAL in head and "3 days" in head
assert "hermes plugins compat" in tail
head_after, _ = pc.summary_lines(report, today=pc.COMPAT_REMOVAL_DATE)
assert "DISABLED" in head_after
assert pc.summary_lines({}) == []
def test_report_file_written_and_removed(tmp_path, monkeypatch):
monkeypatch.setattr(pc, "report_file_path", lambda: tmp_path / "r.json")
pc._write_report_file({"p": [pc.Hit("a.py", 1, "x.y", "z.y")]})
data = json.loads((tmp_path / "r.json").read_text())
assert data["plugins"]["p"][0]["old"] == "x.y" and data["removal_date"] == pc.COMPAT_REMOVAL and len(data["lines"]) == 2
pc._write_report_file({})
assert not (tmp_path / "r.json").exists()
def test_loader_skips_hitting_plugin_after_date(tmp_path, monkeypatch):
"""PluginManager records the reason and never imports the plugin."""
from hermes_cli.plugins import PluginManager
monkeypatch.setattr(pc, "load_manifest", lambda: MANIFEST)
monkeypatch.setattr(pc, "removal_in_effect", lambda today=None: True)
monkeypatch.setattr(pc, "allow_deprecated_imports", lambda config=None: False)
plugin = tmp_path / "plugins" / "oldpaths"; plugin.mkdir(parents=True)
(plugin / "plugin.yaml").write_text("name: oldpaths\nversion: 0.1\ndescription: t\n")
(plugin / "__init__.py").write_text(textwrap.dedent("""
from tools.web_tools import prefers_gateway
LOADED = True
def register(ctx):
raise AssertionError("must not be imported/registered")
"""))
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
mgr = PluginManager(scope_key=str(tmp_path))
from hermes_cli.plugins_manifest import PluginManifest
real = PluginManifest(name="oldpaths", version="0.1", description="t", source="user", path=str(plugin))
mgr._load_plugin(real)
loaded = next(lp for lp in mgr._plugins.values() if lp.manifest.name == "oldpaths")
assert not loaded.enabled and loaded.error and pc.COMPAT_REMOVAL in loaded.error
+1 -1
View File
@@ -32,7 +32,7 @@ def test_compat_resolution_warns_once_per_name_with_the_new_location():
ours = [w for w in rec if issubclass(w.category, HermesPluginCompatWarning)]
assert len(ours) == 1, [str(w.message) for w in rec]
msg = str(ours[0].message)
assert f"`{facade}.{name}` moved to `" in msg and "removed in" in msg
assert f"`{facade}.{name}` moved to `" in msg and "removed on 2026-09-14" in msg
def test_importing_the_facade_itself_does_not_warn():
@@ -162,6 +162,21 @@ from an isolated `HERMES_HOME`. Those tests load and invoke the plugin through
`PluginManager`; they assert real registration and callback outcomes rather
than internal symbol lists or source-code shape.
### Sep 2026 module decomposition: old import paths end 2026-09-14
Hermes's internals were split into `<stem>_<topic>` sibling modules in Sep 2026 (PR #102117). **Internal
import paths were never part of the plugin contract** above, but many plugins used them. Every moved name
still resolves from its old module until **2026-09-14**, then the compatibility layer is removed.
- **Check your plugin:** `hermes plugins compat /path/to/your/plugin` lists every `file:line` with the
old path and the new one, and exits 1 while any remain. `COMPAT_MANIFEST.md` in the repo is the full map.
- **What users see:** a notice under the CLI banner, in `hermes doctor` and after `hermes update`, and a
one-time Desktop dialog naming the plugin. Each resolution through an old path also emits a
`HermesPluginCompatWarning` once per process.
- **From 2026-09-14:** plugins that still import old paths are **not loaded** (the reason shows in
`hermes plugins list`). Users can force-load with `plugins.allow_deprecated_imports: true` until the
layer is actually removed, at which point the old paths raise `ImportError`.
## What you're building
A **calculator** plugin with two tools: