Independent review: substring matching lost the selected objective (after a
message offering API or UI work, '/goal ship the API' and '/goal ship the UI'
kicked identically), and the gateway and TUI /goal paths still re-sent the
full goal. The rule now lives once in hermes_cli.goals.goal_kick_prompt:
the pointer replaces the goal only when the goal is >= 400 chars, contained
in the last user message, and >= 80% of it. The CLI reads the last user
message from its history; the gateway and TUI read it from the SessionDB
(fail-safe: goal sent verbatim).
Tests: a fragment that selects one option is kicked verbatim; a long goal
that is a minority of a longer message is kicked verbatim; all three
surfaces call the shared function.
`/goal <text>` queues the goal text as the next user turn to start the loop.
When that text is what the user just pasted (a handoff note, a plan the agent
already has), the kickoff re-sends it verbatim: in the 1,393-agent run
`/goal <2,000-char handoff>` came 16 minutes after the same note was pasted
as a message, and the agent spent 11 API calls / 6 min deciding it was a
replay, with the note duplicated in context from then on.
_goal_kick_prompt() compares the normalized goal against the LAST user
message (string or block content); when contained, the kick is a one-line
'[Goal set] Continue with the goal you were just given; there is no need to
re-read it.' Otherwise the goal text is sent as before. Both /goal <text>
and /goal draft use it.
Tests (2): a just-pasted goal (plain and block content) kicks with the
pointer; a new goal, an unrelated last message, and a goal pasted in an
OLDER turn kick verbatim.
A gateway with no profile mapping (or one whose relaunch could not be armed) is
SIGTERMed and listed under "Restart manually" — by design it has no successor and
publishes no fleet-matrix row. It still counted in ``killed_pids`` and the
pre-restart snapshot, so ``_fleet_probe_expected_runtimes`` demanded rows that
could not exist and a fully successful update exited 1 with "Fleet version check
returned no rows even though gateway runtimes were expected", leaving the
fleet_restart_pending marker behind and every later CLI start warning about it.
Track the unmapped stops on the restart outcome and subtract them from the
row-predicting signals (``fleet_probe_signals``); relaunched/systemd gateways
still predict rows exactly as before.
A dashboard-only runtime plan (gateway never started) made
_fleet_probe_expected_runtimes() return True from the unfiltered
'plan.runtimes is non-empty' check. collect_fleet_versions() reports
gateway identities only, so the probe waited for rows that cannot exist,
printed the incomplete-verification warning, and exited 1 after a
successful update (#97332).
Key the plan-derived expectation on kind == 'gateway' records — the same
row-capability rule already applied to the Windows resume token (#93406)
— and update the two tests that pinned the old object() placeholder so
they pin the runtime-kind distinction. Restart-phase, killed-PID, and
pre-restart-PID signals still fail closed unchanged.
Widen the salvaged receipt protection to every updater-owned module: the
pre-update plan's ``RuntimeRecord`` dataclass (``update_inventory``) has the same
failure — the plan is built before the purge and the post-restart reconciliation
filters with ``isinstance``, so an evicted module means a fresh class, zero matches,
and an empty runtime-outcome report. One prefix rule instead of a name list that
has to be re-fixed per symptom.
The runtime-only assertion passed on unfixed main (it exercised
model_tools, not _get_platform_tools). Replace it with the contract the
fix exists for: a toolset is listed iff at least one of its tools survives
_select_tool_names.
hermes tools --summary and CLI /tools used name-level disabled_toolsets subtraction, so disabling debugging still showed terminal/web/file as enabled while runtime stripped those tools. Prune platform toolsets after tool-level subtraction and pass disabled_toolsets into CLI get_tool_definitions calls.
Fixes#97015
Co-authored-by: Cursor <cursoragent@cursor.com>
Drop the literal-key assertion (change-detector) and the seeded 500-roll
loop (leaked global RNG state; vacuous if neither member rolls). Exercise
the fallback against a throwaway distribution so the contract no longer
depends on which browser_tasks entry has the highest probability.
Independent 97% rolls for browser and search co-occur only ~94% of the
time, versus 97% when web_search was bundled inside the browser toolset
(hermes-sweeper review). Grouped "+"-compound entries roll once and
select every member together; browser_tasks now uses "browser+search".
Deterministic tests cover the hit/miss/fallback/invalid-member contracts
and a 500-sample co-occurrence invariant.
web_search belongs to the web/search toolsets, but the browser toolset
also listed it as a member. Because disabled_toolsets is a strict
end-of-pipeline subtraction (#17309), disabling browser — a natural
choice for headless/Docker deployments with no Chromium — stripped
web_search from every session even when web was enabled.
Remove web_search from the browser composite so its membership no longer
couples web search to browser availability. The browser_tasks RL
distribution kept web_search via browser, so add the search toolset there
to preserve its effective tool availability.
Fixes#64503
Cherry-picked from PR #101542 by @sohomsahaun.
Two-step drill-down: provider static_select → model static_select,
with Back/Cancel buttons. Resolves through the same gateway callback
as Discord/Telegram, so persistence and failed-switch rollback
semantics come for free.
Changes:
- send_model_picker() posts Block Kit provider/model picker via chat_postMessage
- _handle_model_picker_action() dispatches provider/model/back/cancel actions
- _model_picker_state bounded dict (cap=100, workspace-scoped keys)
- Expired-state UX: missing state rewrites message to expiry notice
- Failure header: both exception and gateway error-prefix get 'Failed' header
- Index-based option values avoid Slack's 75-char value cap
- 21 tests covering send, action dispatch, gateway integration
Co-authored-by: Sohom Sahaun <ssahaun19@gmail.com>
The chat_completions finalizer now reads collector-observed chunks, not the
consumer loop, so the test feeds the captured on_chunk before calling it —
the same ordering Relay guarantees.
Relay invokes its finalizer as soon as the provider stream ends, concurrently with
Hermes' consumer thread, so any finalizer that reads the consumer loop's closures
races the last chunk. #103104 moved `usage` onto the collector; the same race still
truncated the final tool-call `arguments` delta, dropped the last content delta, and
downgraded `finish_reason` to "stop" in Relay's annotated response (reproduced ~50%
of runs). Every other Relay integration (Anthropic, Bedrock, Codex) already
rebuilds from collector-observed chunks; this makes chat_completions match via
`_RelayChatAccumulator` and removes the single-field nonlocal stopgap.
Test: the ordering harness is shared, and a second invariant test pins the
tool-call/finish_reason case (fails on the stopgap, passes here). Hermes' own
returned response was never affected.
* fix(desktop): treat a macOS ps: parent-marker mismatch as inconclusive, not death
`ps -o lstart=` is a TZ/locale-rendered wall-clock string. Electron caches it
once per app lifetime; the backend re-renders it per spawn. A timezone change
(travel, or the DST boundary) while the app stays open makes the SAME instant
differ byte-for-byte, and _is_serve_orphaned() treated that as proof the parent
died -- os._exit(0) ~5ms after HERMES_BACKEND_READY, silently, on every respawn
until a full quit.
Route mismatches through _parent_start_marker_mismatch_is_conclusive(): linux:/
win:/winms: machine markers stay conclusive (PID-reuse defence intact); any ps:
side degrades to the PID-liveness check the legacy Desktop path already uses.
Log one warning before os._exit(0) so the exit is no longer traceless.
Fixes#95693Fixes#93958
Co-authored-by: Ahmett101 <Ahmett101@users.noreply.github.com>
* fix(desktop): reject truncated ps: parent markers; blank marker env means absent
A ps: marker that got whitespace-split in env plumbing (`ps:Sat`) passed
_valid_parent_start_marker and armed a watchdog that could never match, so
the backend exited 0 right after HERMES_BACKEND_READY. Require a full
lstart value (>=4 tokens, a 4-digit year, a time). Treat empty
HERMES_PARENT_START_MARKER / HERMES_PARENT_NONCE as absent so a blank
inherited value degrades to PID-only tracking instead of disarming or
misfiring.
* chore: map contributor email for drewTuzson
* fix(desktop): log when the parent-death watchdog disarms on an unusable marker
Disarming is the fail-safe branch (the backend keeps serving) but it also
means this backend will never reap itself when the Desktop dies. Leave one
warning naming the rejected marker so that downgrade is not traceless.
* test(desktop): live macOS proof that a TZ-drifted ps: marker no longer kills the backend
Runs the real start_server in a subprocess against the host ps, with the
marker rendered under Europe/Paris and the backend under America/New_York.
Asserts READY + still alive after several watchdog polls, then that the
backend still exits once the stand-in parent is killed. Fails on main with
the reported symptom (exit 0, live parent); macos_only lane.
---------
Co-authored-by: ygd58 <buraysandro9@gmail.com>
Co-authored-by: Ahmett101 <Ahmett101@users.noreply.github.com>
Co-authored-by: Drew Tuzson <drew.tuzson@uqual.com>
* fix(dashboard): prevent PTY input from blocking event loop
* fix(win-pty): don't terminate a healthy ConPTY on write cancellation; log leaked write workers
Review follow-up to the backpressure fix.
CancelledError on WinPtyBridge.write() ran the same path as a timeout and
force-terminated the ConPTY. Cancellation means the owning socket went away
mid-write, which is the keep-alive session's normal reattach case, not a
wedged child; killing the process there defeats the PTY-outlives-socket
design. Give the in-flight write the shutdown grace window and only
terminate if it never lands.
When terminate() fails to unblock pywinpty, the worker stays parked in the
default executor. That was swallowed by a bare except; log it so a slow
thread-pool starvation is diagnosable.
---------
Co-authored-by: Austin Pickett <pickett.austin@gmail.com>
* feat(pty_bridge): mark the dashboard-spawned TUI with HERMES_PTY_HOST
Ink needs to know when its emulator is the dashboard's xterm.js rather
than a native terminal, so it can drop hidden-tab recovery work that only
makes sense for emulators that coalesce output.
Co-authored-by: Raymond <supere989@users.noreply.github.com>
* fix(tui): skip the focus-in erase+repaint under the dashboard PTY
Ink answers a DECSET 1004 focus-in with a full clear+repaint to heal rows
a native emulator may have dropped while the tab was hidden. xterm.js fed
by the dashboard WebSocket never drops frames, and it reports focus on
every OS window blur/focus, so under the dashboard that repaint was a
visible "session reloaded" flash on every alt-tab. Keep the mode
re-assert and keep delivering the focus report to TerminalFocusProvider
(the composer hides its cursor on blur); only the repaint is skipped.
Co-authored-by: Raymond <supere989@users.noreply.github.com>
* fix(web): restore terminal focus after an OS app-switch
Alt-tabbing away and back lands browser focus on <body>, so Ctrl+V never
reached the composer. Pull focus back into xterm on window focus under
the same ownership rule tab activation already uses, extracted into
shouldRestoreTerminalFocus so both paths share it.
Co-authored-by: Raymond <supere989@users.noreply.github.com>
---------
Co-authored-by: Raymond <supere989@users.noreply.github.com>
Six slugs land in the nous and openrouter curated lists, above the gpt-5.6 line:
openai/gpt-6-astra{,-fast,-flex} and openai/gpt-6-astra-pro{,-fast,-flex}.
Nous Portal serves the tiers as distinct slugs (verified live: each echoes its id, service_tier
default/priority/flex, cost 1x/2x/0.5x). OpenRouter serves them as ENDPOINTS of the base model
(tags openai/fast, openai/flex) and silently routes an unknown suffix to the standard tier at
standard price, so the OpenRouter profile rewrites a tier slug to its base wire model and pins
provider.only to that tier's endpoints (OPENROUTER_ENDPOINT_PINS). The base slug is pinned to
openai/azure/azure-us so default routing never lands on a flex or fast endpoint.
Provider-agnostic metadata: one DEFAULT_CONTEXT_LENGTHS entry (gpt-6-astra: 1,050,000, live on
OpenRouter for both models; substring-matches -pro and the tier suffixes). Pricing is skipped:
both routes bill via official_models_api. Reasoning floor not added (no evidence of long thinks).
Follow-up to the salvaged fix from #102994. The quickstart leg tests
stubbed web_deps.late itself, so the resolution path that regressed
(late() -> web_server facade -> getattr) was never exercised; the PR's
proposed tests pinned the module string instead, which fails on any
future move of the symbol even when the call site is updated.
Stub only the leaf (web_server_config._apply_model_assignment_sync) so
the real late() runs, and add one invariant test: _assign_default must
call it with ("main", "llamacpp", model_id, "", "", ""). Red on base
(AttributeError), green with the fix; not tied to where the symbol lives.
Drop the docstring parenthetical that described test mechanics.
The whole-codebase refactor moved _apply_model_assignment_sync from the
web_server facade into web_server_config, but _assign_default in
web_routers/local_models.py still resolved it through the default late()
module (web_server), which no longer carries the symbol. A local-model
quickstart therefore failed its final 'making it your default' step with
AttributeError: module 'hermes_cli.web_server' has no attribute
'_apply_model_assignment_sync'.
Pass the defining module explicitly to web_deps.late() so the call
resolves against hermes_cli.web_server_config, matching how the sibling
models.py router imports the same helper. Update the two quickstart test
stubs to accept the module argument the real late(name, module=...) takes.
* feat(gateway): let NAS broker the scale-to-zero suspend where the guest has no lever
* fix(gateway): require the going_idle ack and outlast the broker before suspending
* fix(gateway): release the redial hold on every path that abandons the suspend
* fix(gateway): hold the re-dial only for the brokered lever, and require it
* fix(gateway): make the re-dial hold contract real and fence the in-guest suspend too
* refactor(gateway): share the watcher-iteration and descriptor helpers across the sleep tests
* refactor(gateway): move the sleep tests' repeated setup into their fixtures
* refactor(gateway): one docstring line per sleep test, and parametrise the abort and lever paths
* fix(gateway): fence the freeze gap, cool down aborts, and stop clobbering a shutdown drain
* fix(gateway): slice the Fly freeze fence on the wall clock and widen it for larger machines
Symptom: with Browser Use mode (the default) and no cloud provider / CDP
override, browser_exec left BU_CDP_* unset, so the browser-use harness ran
its own local discovery — hunting for the user's INSTALLED Chrome on its
default profile. That path needs the chrome://inspect remote-debugging
toggle plus an "Allow remote debugging?" popup per run, is blocked outright
on Chrome >=136, and on a headless host (or one without Chrome) fails with
"chrome-not-running: no supported Chromium-family browser is running". The
built-in browser_* tools never had this problem: they drive the Chromium
Hermes installs, launched through agent-browser.
Change: `_resolve_backend_cdp` step 4 becomes the local ENGINE —
lightpanda when configured, otherwise `_resolve_managed_chromium_cdp`,
which runs `agent-browser --session <key> get cdp-url` through the legacy
`_run_browser_command` (Chromium preflight/auto-install, per-key session
cache, inactivity reaper, atexit) and exports the returned ws:// endpoint
as BU_CDP_WS. Each cache key (task, or bu-named-<session>) gets its own
Chromium, so named sessions are private and skip the own-tab preamble.
Running `get cdp-url` on every call also refreshes agent-browser's idle
timer (it never sees the harness's direct CDP traffic) and follows a
relaunch after the reaper closed the browser.
Live: before, browser_exec on origin/main → exit 1 "chrome-not-running";
after → new_tab/page_info succeed on a cold start, warm reuse, post-reap
relaunch, and a named session; a missing Chromium surfaces the install
hint instead of the harness's Chrome hunt.
The skill review fork, the combined memory+skill review, and the curator's
consolidation pass all described references/ as the place for
"session-specific detail", and the curator's demote step said to move a
sibling's file under the umbrella. Followed literally over months that
produced one dev skill with a 100k SKILL.md dense in PR numbers and 443
one-per-session reference files, plus five sibling skills restating the
same rules and the repo's AGENTS.md.
The three prompts now share one shape contract: an entry is an imperative
rule plus one clause of why, stated once; no PR/issue numbers, dates, or
quoted chat as content; references/ is a small topical set extended in
place, never a per-session file; skills do not restate always-loaded
context. Consolidation is defined as distilling, and copying a sibling
verbatim under references/ is named as the failure. skill_manage's schema
carries the one-sentence version.
Two advisory linter rules make the shape visible in the tool result the
moment it starts to drift: incident-log-shape (PR/issue-number density in
prose) and references-sprawl (>60 reference files), the latter also run
on references/ writes. On the real before/after: the old skill trips both,
the consolidated one trips neither.
Adds plugins/web/perplexity — a keyed-only WebSearchProvider over httpx:
- search: POST https://api.perplexity.ai/search (documented Search API),
search_context_size=low so `snippet` stays description-sized;
results[].snippet -> description, max_results capped at the API's 20.
- extract: POST /sdk/content/snippets — the query-relevant page-excerpt
route behind `pplx content snippets` (the CLI's `content fetch` is
deprecated upstream). web_extract has no query, so the URLs' path words
serve as the relevance query; per-URL `error` entries survive a 200.
- Wired into the same touchpoints as the other keyed vendors: legacy
backend set + credential ladder + availability probe (web_tools),
registry preference walk, OPTIONAL_ENV_VARS, `hermes config`/status/
dump key lists, nous_subscription direct-credential detection, setup
summary, test conftests, docs.
Not a keyless-ring member (Perplexity has no anonymous tier). Related
closed PRs #9192 / #23981 / #45225 predate the plugin ABC.
The compat scanner derived a directory from manifest.path by splitting at the
first ':' and falling back to .parent when the result was not a dir. An
entry-point plugin (`vendor_plugin:register`) and a Windows path
(`C:\Users\...`) both collapsed to '.', so a stray .py in the launch directory
was attributed to the plugin and the installed package was never scanned.
After the removal date that disables the wrong plugin. `_scan_root()` now
takes directory manifests verbatim and resolves entry points via
importlib.util.find_spec to the installed package dir; anything unresolvable
scans nothing.
`plugins.allow_deprecated_imports` used bool(), so the YAML string "false"
opened the post-removal bypass. It now requires the literal boolean True, and
summary_lines() says "force-loaded" instead of "DISABLED" when the override is
what kept the plugins running.
Reported-by: ayushnangia (PR #102117 review)
The compat sweep dropped the auth-facade re-export of _mark_qwen_oauth_active
while hermes_cli/auth_commands.py still reached it through the auth_mod module
alias, so every 'hermes auth add qwen' died with AttributeError on this branch.
Import it from hermes_cli/auth_qwen where it now lives. Module-alias attribute
reads dodge import-time checks; the sweep in the PR thread found this to be the
only production hit of that class.
Reported-by: yoniebans (PR #102117 review)
The on-demand subdirectory hint loader (agent/subdirectory_hints.py, #5291) tail-chopped anything past
8,000 chars with a bare marker and no log line. apps/desktop/AGENTS.md (11k) has been arriving cut off in
every Desktop-area session since it was written, and nobody could tell. Compared with the field: Codex
caps its whole instruction chain at 32 KiB (project_doc_max_bytes) and documents it; Claude Code and
Cursor apply no cap to nested files; OpenCode has no nested discovery at all. Our on-demand + cache-safe
+ ancestor-walk design is the strongest of the four; only the constant and the silent cut were wrong.
Now: ceiling 32,000 chars (Codex's number, a guard against a stray huge CLAUDE.md in a vendored tree, not
a target), and truncation goes through prompt_builder._truncate_content — head 70% + tail 20%, a marker
naming the file to read_file for the rest, and a WARNING in the log. Area AGENTS.md files should stay
around 8k anyway: the text lands in a tool result on the first touch of the directory.
Tests: oversized hint keeps head+tail, names the path, and logs; a 12k area file (over the old cap, under
the new) arrives intact.
Live Desktop E2E (real Electron, worktree backend, demo plugin on old paths) found the modal never fired:
compat_report() was only called from the CLI banner / doctor / update / plugins-compat surfaces, none of
which run inside the Desktop's `serve` backend, so .plugin-compat-report.json was never written.
PluginManager.discover_and_load now refreshes the report from the manifests it just discovered (fail-open).
E2E after the fix, all five acceptance steps green on the real seat: report written and names the plugin;
native dialog 'Plugins need an update' with plugin, date, and `hermes plugins compat`; OK persists the
dismissal; relaunch with the same userData shows nothing and logs no 'compat notice shown'; fixing the
plugin's imports deletes the report and shows nothing.
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.
It side-loaded tools/terminal_tool.py via importlib.spec_from_file_location under the bare name
'terminal_tool', which bypassed both the package and scripts/check_compat_pointers.py, then read
cleanup_vm through the compat pointer. Found by running the suite with HermesPluginCompatWarning
promoted to an error — the only in-tree resolution through a pointer across 44,7k tests.
Every PLUGIN-COMPAT __getattr__ now calls hermes_cli.plugin_compat.warn_once(facade, name, target) before
resolving, emitting a HermesPluginCompatWarning (FutureWarning) once per process per name: old path, new
path, removal target. Importing a facade for its live API stays silent; only resolving a moved name warns.
COMPAT_MANIFEST.md documents the warning and how to silence it during migration.
Verified the runtime never routes through a pointer: every entry point (run_agent, cli, hermes_cli.main,
gateway.run, tui_gateway.server, web_server, model_tools + tool discovery, hermes_state, cron.scheduler,
browser_tool, mcp_tool, kanban, auth) imports clean and `hermes doctor` runs end to end with the warning
promoted to an error.
Also restores the check_compat_pointers CI step to .github/workflows/lint.yml, which a0be177aac dropped
when the compat layer was regenerated (the lint script itself was present; the workflow step was not).
hermes_cli/plugin_compat.py, tests/test_plugin_compat_warning.py and the two-line insert per facade are
part of the compat layer and go away with it.
AGENTS.md: Project Structure tree reflects the decomposition (run_agent 1.5k not 12k, cli 4.6k not 11k,
hermes_state facade + 21 siblings, web_routers/, evals/, test counts); new "Facade + siblings layout"
section with the sibling families table and the rules that follow (find by topic, patch where production
reads, compat pointers off limits, don't recreate god files); AIAgent/Agent Loop point at agent/turn_*.py
and conversation_loop; CLI dispatch documents _SLASH_DISPATCH + the _handle_<name>_command convention and
"Adding a Slash Command" no longer tells you to add an elif (there is no ladder to add to on either surface).
evals/codebase_navigability/: what the codebase costs an agent, not the CPU.
bench.py ~19k real "locate X" tasks from tests/ imports; tokens (tiktoken o200k) of the defining
file vs the symbol, context-window fit, read windows, siblings, symbol CC
lookup_sim.py paired grep+read simulation over 4k common symbols; tool calls + tokens returned
static_metrics.py LOC split, size distributions, elif/nesting, radon CC/MI, import graph + SCC cycles
runtime_bench.py fresh-interpreter import/CLI/hot-path/collection timings with tree-purity assertion
tests/evals/test_codebase_navigability.py pins the resolver's facade/sibling behaviour.
Review findings on #102117 (independent reviewer + itsflownium):
* hermes_cli.kanban_db.connect / connect_closing pointed at hermes_cli.projects_db (different DB, no
board= parameter). The compat generator ranked candidate homes by path proximity when a name is
defined in several modules. Now it requires shape compatibility with the BASE definition (same
literal for constants, superset of parameter names for defs) and prefers the facade's own
<stem>_* sibling. Same class fixed for tools.tts_tool.DEFAULT_XAI_BASE_URL (-> tts_tool_providers),
and 17 constants/defs that had been pointed at same-named strangers (Matrix MAX_MESSAGE_LENGTH ->
Signal's 8000, tts MAX_TEXT_LENGTH -> BlueBubbles', honcho/retaindb/supermemory *_SCHEMA -> another
plugin's schema, ...) are now restored from BASE verbatim instead.
* send_yuanbao_direct (restored-def): body called adapter._outbound.send_direct, which HEAD moved to
the sender; rewritten to adapter._outbound.sender.send_direct.
* COMPAT_MANIFEST.md states the scope explicitly: public top-level names only; private names and
test monkeypatch seams are not preserved.
* scripts/check_subprocess_stdin.py: _splat_carries_stdin looked 30 lines ahead in the file text
and was satisfied by an unrelated later stdin=; it now finds the splatted name's definition via AST
and requires stdin inside that expression/body.
Tests: tests/test_compat_manifest_targets.py (pointer identity vs the facade's sibling; kanban
connect(board=) opens a Kanban DB, not projects.db; both FAIL on the previous layer),
test_subprocess_stdin_guard gains the false-negative probe, and the MoA -Q quiet-output contract
tests are back (tests/agent/test_moa_quiet_reference_output.py) against build_moa_facade.
Tests did monkeypatch.setattr(<facade module>, name) where name is now defined in a
sibling module and the production path reads the sibling's binding. Where production
reads through BOTH bindings the setattr is duplicated onto the defining module (import
added next to the existing alias import); where only the sibling reads it the target is
repointed. Seams whose production readers go through the facade are left alone.