Skip to content

kanban: 'superseded' terminal outcome for an already-satisfied premise (t_90178503) - #895

Closed
Kyzcreig wants to merge 10000 commits into
mainfrom
kanban/superseded-outcome
Closed

Kyzcreig wants to merge 10000 commits into
mainfrom
kanban/superseded-outcome

Conversation

@Kyzcreig

@Kyzcreig Kyzcreig commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

Closes the platform gap measured on 2026-09-22: 9 wasted runs across t_e484ec2d + t_e587758d.

The gap

A worker that verifies its card's work already landed on main has no honest verb. complete_task demands evidence of work it did not do; block_task demands a blocker that does not exist. So it exits rc=0, the dispatcher books:

worker exited cleanly (rc=0) without kanban_complete... - protocol violation

...retries 3x (the retry reproduces it identically), then gave_up with a notification that reads like a crash. Three identical clean exits were never going to become a fourth different one.

What this adds

  1. kanban_complete(superseded_by=<card|PR|sha>) - closes the card done with run outcome superseded and the pointer as its evidence. No summary/result/survivor required. An empty pointer is refused (EmptyCompletionError): a superseded card with no pointer is a silent delete of work. Also on the CLI as --superseded-by.
  2. SUCCESS_RUN_OUTCOMES - every reader that asks "did this task succeed" now counts superseded alongside completed: parent-handoff context, role history, the respawn guard, the crash-streak breaker, the edit backfill. Without this a superseded card reads as never-run and gets respawned.
  3. Dispatcher stops burning the budget. Two byte-identical clean exits are a REPRODUCED no-op, not a flake - trip at 2 instead of 3, with stopped_early=reproduced_clean_exit + identical_violations on the event. Absent worker output never compares equal, so the early trip stays off when there is no evidence.
  4. Notifier distinguishes the shapes. "premise superseded by X" and "needs input: finished with NOTHING TO DO 2x identically (no crash)" instead of "gave up (retries exhausted)".
  5. Workers are told to use it - KANBAN_GUIDANCE step 4b, the turn-end stop-nudge, and the retry corrective text.

Constraints from the card honoured: no new HERMES_* env var; the survivor escape is not widened (superseded simply has no artifact to preserve); superseded cannot skip the evidence pointer.

Verification

RED-first. tests/hermes_cli/test_kanban_superseded_outcome.py (new, 10 tests) failed on current main before the change:

tests/hermes_cli/test_kanban_superseded_outcome.py ..........  10 passed
tests/tools/test_kanban_tools.py                              47 passed  (4 new)

The acceptance test closes a card whose premise is already satisfied and asserts it ends after ONE run, done/superseded, superseded_by populated, consecutive_failures == 0, with a distinguishable notification. The dispatcher test asserts the reproduced-no-op path holds after two runs and never spawns a third.

CLI end-to-end against a sandbox board:

--- superseded, empty ---
cannot complete t_00699dfa: completion blocked: ... was completed as superseded with an
empty superseded_by; name the card, PR or sha that satisfied the premise
--- normal, no evidence ---
cannot complete t_9502837e: completion blocked: ... has no result or summary evidence
--- superseded, real ---
Completed t_00699dfa     ->  status=done outcome=superseded meta={'superseded_by': '#889'}

Regression, two-arm control (the 21/2 failures below are INHERITED - the identical lists fail in a clean-room worktree at origin/main):

suite this branch origin/main
tests/hermes_cli -k kanban 21 failed, 457 passed 21 failed, 447 passed
tests/gateway+tools+agent -k "kanban or prompt_builder or system_prompt" 2 failed, 394 passed 2 failed, 390 passed

Delta is +10 / +4 passing = the new tests. ruff clean.

One regression WAS caught and fixed mid-run: lengthening _PROTOCOL_VIOLATION_ERROR pushed the worker's own last output past the 500-char cap _record_task_failure stores, reddening two existing reap tests. The constant is now shorter than main (349 -> 298 chars) - that budget is shared with the worker's explanation, which is the half the board actually needs.

Fixture cards

Both live fixtures moved while this was in flight and I did not retro-close either: t_e484ec2d reached done on its own merits (run 7271, PR #892 approved), and t_e587758d has a live run. Their value here was the measurement, which the new tests now encode as a permanent gate.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

JoaoMarcos44 and others added 30 commits September 22, 2026 13:40
Preserve record framing structurally so messages containing internal blank lines are not split into pseudo-records.

- agent/context_compressor.py:
  * Extract _serialize_records_for_summary() to retain turn boundaries as a sequence of records.
  * Add _sample_summary_records() to sample across serialized turn records directly without lossy double-newline splitting.
  * Correct separator accounting in omission markers at gap boundaries so separator counts match full serialized text.
  * Update _sample_summary_input() to support structural records while preserving flat-string fallback with trailing fragment pruning.
  * Wire _generate_summary() to use _serialize_records_for_summary() and _sample_summary_records() in lean mode.
- tests/agent/test_context_compressor.py:
  * Test multi-paragraph serialized messages maintain role boundaries and keep newest message paragraphs together.
  * Test newest message ending in blank lines does not produce empty tail anchor.
  * Test exact omission marker separator accounting.
  * Test end-to-end _generate_summary prompt assembly with multi-paragraph messages.

(cherry picked from commit f68f1bf)
The record split added .rstrip("\n") to every serialized record. That
changes the bytes _serialize_for_summary returns for legacy tail_mode
and for agent/micro_compaction.py's _micro_summarize_one, which never
sample and never needed it: a message with trailing newlines was no
longer reproduced verbatim in the summarizer prompt. Sampling receives
records structurally, so an empty pseudo-record can no longer become the
tail anchor and the strip has no remaining purpose.
_sample_summary_input accepted either a flat string or a record
sequence and, for strings, rebuilt records with content.split("\n\n").
The PR's own third commit established that "\n\n" is not a record
delimiter (message bodies contain blank lines), so that fallback would
re-introduce split records for any caller that reached it. The only
production caller already passes records to _sample_summary_records;
drop the dual-signature wrapper and move the test callers to records.
Replace the eight sampler tests with two invariants. The boundary test
now drives _generate_summary so it fails on main for the actual defect
(a retained section starting mid-record) instead of on a helper name,
and folds in the multi-paragraph case since blank lines inside a message
were what broke the string-split approach. Dropped: covers_multiple_
regions and generate_summary_lean_samples_structural_records (green on
main, no teeth), omission_markers_account_separators_exactly (asserted
only that a marker exists), newest_message_ending_in_blank_lines (moot
once records are structural), and preserves_oversized_last_record (a
record above the whole cap is unreachable through _serialize_for_summary
because _CONTENT_MAX bounds message bodies). The remaining oversized
test asserts behaviour — tail kept, oversized record bounded — not the
marker wording.
…ch#118390)

Lean sampling is the documented budget mechanism, but nothing reported
how much of a region actually reached the summarizer, which is the gap
the reporter narrowed NousResearch#118362 to. _sample_summary_records now returns
record-level coverage counters alongside the bounded transcript and
_generate_summary writes them into the active compression telemetry
(summary_input_chars / sampled_chars / omitted_chars / record_count /
sampled_record_count / elided_record_count), so the existing
compression_attempt JSON log line carries them with no transcript
content. Char counters cover record content only, not separators or
markers. Elision markers also name the one-based record range they
stand for, so a reader can tell how many turns a gap hides, not only
how many characters.

Co-authored-by: KoNit-K <konit.block@protonmail.com>
Delete the `if len(res) > limit` re-trim at the end of
`_bound_oversized_record` (agent/context_compressor.py:3488-3489).

Why it can never fire: `marker_reserve` is the marker width formatted
with `elided=len(record)`, the largest value `elided` can take, so the
real marker is <= marker_reserve; `head` is `record[:head_len].rstrip`
(<= head_len) and `tail` is `record[-tail_len:].lstrip` (<= tail_len);
head_len + tail_len == limit - marker_reserve. Hence
len(head + marker + tail) <= limit always. The branch was untested and
unreachable (gate 2c W2).
…mary_records

Delete the post-render overflow-trim loop (agent/context_compressor.py
:3591-3607) and state the invariant in its place.

Why it is unreachable: every display record is pre-bounded to `target`
via _bound_oversized_record; each slice's greedy fill stops before
`size + sep + next > target`, so a slice renders to <= target chars and
the n slices to <= n*target (a merge of overlapping/adjacent slices is
their union: overlap only removes chars, adjacency adds one 2-char
separator but drops one marker of >= 2 chars). There are at most n-1
markers, each <= marker_len because marker_len is formatted with
first=last=len(records) and elided=total_len, the maxima. Since
target = (cap - (n-1)*marker_len) // n, the rendered length is
<= n*target + (n-1)*marker_len <= cap. Gate mutation M5 (loop disabled)
left all 7 sampler tests green; 30 random shapes never entered it.
… records

The whole-record greedy fill in `_sample_summary_records`
(agent/context_compressor.py:3541,3555-3563) stops a slice as soon as the
next record would cross `target` (~20K), so each slice can be short by up
to one record. With mid-sized records that wastes a large share of the
160K cap: probe worst case (10K records) filled 90,660/160,000 (57%),
8K records 129,021 (81%); main filled 159,988 by splitting records.

Add a bounded extension pass after `selected` is built: newest slice
grows backward, older slices grow forward, one whole record at a time,
only while the exact rendered length stays <= cap and the slice does not
run into its neighbour (adjacent slices are merged so no separator is
lost). Records are never split and the cap is never exceeded.

After: worst random shape 150,581 (94%, remaining headroom < one 10K
record), 8K records 153,117 (96%), 1551-record region 159,996 with
968 whole records, 0 partial, 8 buckets within +-10%, telemetry
input_chars == sampled_chars + omitted_chars (probe: $D/fold/probe_C_fold.py).
The existing keeps_record_boundaries fixture (1.2K records) fills 99.1%
without this pass, so no fill assert was added there — it would be toothless.
`_begin_compression_telemetry` (agent/context_compressor.py:1947-1960)
did not seed the six `summary_input_*` counters that
`_record_summary_input_coverage` adds on the lean path, so the emitted
`compression_attempt` line (conversation_compression.py:1451-1470) had a
different key set for lean and legacy attempts. Seed them as None so the
schema is stable; probe: lean and legacy attempts now emit identical key
sets (gate 2c suggestion, L1947-1960).
…y_records

The coverage docstring (agent/context_compressor.py:3510-3511) said the
counters count "record content only", but `sampled_chars` sums the
*display* records (post `_bound_oversized_record` truncation) while
`input_chars` sums the raw records. Spell that out so telemetry
consumers do not compute `omitted` two different ways (gate 2c
suggestion, L3512-3513/3524-3525).
tests/agent/test_lean_single_aux_call.py:163 accepted either `<seg09>`
or a `content[-500:]` substring. The last slice is anchored to the
newest record, so the fallback can never be what passes; assert
`"<seg09>" in out` outright and drop the now-unused `content`.
Red when the tail anchor is defeated (end = len(records) - 1): 1 failed;
green at head (gate 2c suggestion).
The budget-extension pass in _sample_summary_records grew the newest
slice until it hit the cap and only then moved to older slices, so on
mid-size records all headroom went to one region: 10K x 100 records ->
records/slice [1,1,1,1,1,1,1,8] (newest 4.3x the mean). That contradicts
the design comment above (regions must not consume each other's budget)
and the docs' "evenly sampled". Wrap the per-slice loop in a
`while grew` round so each slice adds at most ONE whole record per
round (newest grows backward, others forward); the cap pre-check and
exact re-render check are unchanged.

Before -> after (records per slice, fill unchanged):
  10000x100  [1,1,1,1,1,1,1,8]      -> [1,2,2,2,2,2,2,2]      fill 0.944
   8000x100  [2,2,2,2,2,2,2,5]      -> [2,2,2,2,2,3,3,3]      fill 0.957
   4000x300  [4,4,4,4,4,4,4,11]     -> [4,5,5,5,5,5,5,5]      fill 0.987
   2000x600  [9,9,9,9,9,9,9,15]     -> [9,9,10,10,10,10,10,10] fill 0.995
    500x2000 [37,...,37,39]         -> [37,...,37,38,38]      fill 0.998
Re-gate probe (40 random shapes): 0 violations, worst fill 0.892 -> 0.930,
worst char drift 4.27 -> 1.83; under-cap output byte-identical to base.

Test: test_lean_sampling_oversized_middle_record_does_not_evict_tail now
asserts no slice holds more than mean+1 records (RED with the previous
newest-first order: [16,16,16,16,16,16,18]; GREEN: [16,16,16,16,16,17,17]).

Finding: $D/regate/C.md W1 (agent/context_compressor.py:3599-3623).
…check

The comment claimed "marker widths shrink as records leave a gap" — but
shrinkage can only make the render smaller, which the pre-check already
tolerates. The exact re-render exists for the opposite case: a gap's
first index can gain a digit or thousands separator (999 -> 1,000, +2
chars) when the added record moves a marker, and that growth can push a
render sitting at cap over it. Comment only; no behaviour change.

Finding: $D/regate/C.md suggestion 1 (agent/context_compressor.py:3619).
No fixture exercised the final `selected = _merged(selected)` in
_sample_summary_records: the re-gate mutant that dropped it survived
all 8 bounding tests and 40 random probe shapes. Add 20 x 8.5K records
(170K > 160K cap, headroom > one 8.5K gap) so the round-robin extension
closes the gaps between the newer slices. Assert consecutive shown
records are joined by exactly "\n\n" (an unmerged adjacent pair renders
as "...xxx[USER]: record-..." with no separator), remaining gaps carry a
correctly numbered marker, fewer than 7 markers survive, and
sampled_record_count equals the whole records shown.

RED with the final `_merged` call removed: AssertionError (14, 15);
GREEN at head.

Finding: $D/regate/C.md suggestion 2 (M-c, agent/context_compressor.py:3623).
…of inline

The slice-build loop in _sample_summary_records hand-rolled the same
"overlapping/touching slice -> extend previous" fold that the `_merged`
closure 25 lines below implements (agent/context_compressor.py:3563-3568
vs :3590-3597). Hoist `_merged` above the loop, append raw (start, end)
pairs, and merge once before the extension pass; the first `_render`
no longer wraps `selected` in a redundant `_merged` since it is already
merged. One interval-merge implementation instead of two.

Output is byte-identical: the merge is applied once to the same ordered
slice list, so the extension pass starts from the same `selected`.
Verified with a 47-shape capture (40 probe_C2 shapes + 7 edge shapes)
diffed before/after: identical sampled text and coverage; probe_C2
40 shapes -> 0 violations; under-cap base identity x4 True.
…ing _render

The `if cursor < len(records)` block at the end of `_render`
(agent/context_compressor.py:3580-3583) duplicated the gap-marker
arithmetic of the in-loop branch but had no path to it.

Invariant: `_render` is only reached with non-empty `records`
(empty returns early), and n >= 1, so the slice-build loop always runs
its last iteration, which anchors `end = len(records)` and
`start = end - 1 >= 0`, hence `end > start` and the final slice ends at
`len(records)`. `_merged` keeps `max(end)` for the last interval, and the
extension pass only grows the newest slice backward (`(s - 1, e)`) or
older slices forward but never past the next slice's start, so the last
slice's end stays `len(records)`. Therefore `cursor == len(records)`
after the loop for every input.

Byte-identical across the 47-shape capture (40 probe_C2 shapes + 7 edge
shapes incl. single-record and empty); probe_C2 -> 0 violations;
under-cap base identity x4 True.
`_bound_oversized_record` (agent/context_compressor.py:3485-3488) split
`remaining` with float arithmetic (`int(remaining * 0.5)`) and guarded
the tail slice with `if tail_len else ""`. `remaining = limit -
marker_reserve` is >= 1 because the line above already returned when
`limit <= marker_reserve`, so `head_len = remaining // 2 <= remaining - 1`
and `tail_len = remaining - head_len >= 1` always: the guard can never
fire. Use `remaining // 2` and drop the dead guard.

Byte-identical: `int(r * 0.5) == r // 2` holds for every non-negative
int (checked exhaustively for r in 0..10**6), and the guard was dead.
Capture of 800 bounded-record cases (4 record shapes x remaining 1..200)
plus the 47-shape sampling capture is identical before/after.
…T_SLICES

tests/agent/test_context_compressor.py:3397 asserted
`sampled.count("chars elided") < 7`, hardcoding the n-1 gap count that
the 8-slice constant implies and that the comment explained in prose.
Use `ContextCompressor._SAMPLED_INPUT_SLICES - 1` so the invariant
("the extension pass closed at least one initial gap") survives a
change to the slice count instead of silently becoming a change
detector. `_SAMPLED_INPUT_SLICES - 1 == 7` today, so the assertion
value is unchanged.
…mary_records

`_finish` (agent/context_compressor.py:3586-3588) only wrapped
`_coverage` and re-derived `shown` from the final `selected`; it was
called once, at the return. With `_merged` hoisted above the slice loop
the classmethod now has two closures (`_merged`, `_render`) plus
`_coverage` instead of four. Same expressions, same evaluation order
(`_render` first, then the coverage counters), so the output and
coverage dict are byte-identical — verified on the 47-shape capture and
probe_C2 (0 violations, base identity x4 True).
The invariant (source .caf untouched, sibling .wav not overwritten, owned
output removed) belongs beside the existing CAF tests rather than in a
new file. The conversion-error case exercised the same ExitStack
return-inside-with path as provider-error, so it is dropped to stay at
two cases. Also reverts the unrelated encoding="utf-8" drive-by on the
.env fixture in TestTranscribeCredentialReadGuard.
Pass ignore_cleanup_errors=True to the TemporaryDirectory that holds the
converted CAF->WAV file (tools/transcription_tools.py:420).

WHY: the sibling trim-dir cleanup a few lines below already swallows
errors (shutil.rmtree(..., ignore_errors=True)), but the CAF work dir did
not. On Windows an AV scanner or the search indexer briefly holding the
freshly written WAV makes rmtree raise out of TemporaryDirectory.__exit__,
turning an otherwise successful transcription into an exception. The
kwarg is available since 3.10; pyproject requires-python >= 3.11, and
plugins/teams_pipeline/meetings.py:199 already uses it.

No new test: the behaviour is stdlib.
Remove `monkeypatch.setattr(audio.shutil, "which", lambda _name: None)`
from test_caf_conversion_preserves_neighbors_and_removes_owned_output
(tests/tools/test_transcription_tools.py:1360).

Why it is dead: `_convert_caf_to_wav` (tools/transcription_audio.py:168-177)
tries candidates in order ffmpeg-then-afconvert and returns on the first
success. The test already patches `_find_ffmpeg_binary -> "ffmpeg"` and
`_run_quiet -> encode`, which never raises, so the ffmpeg candidate always
succeeds and the afconvert candidate is never executed. Whether
`shutil.which("afconvert")` returns a path or None cannot change the
outcome. The patch also mutated the process-global `shutil.which` for the
test's duration for no benefit.

Proof: `-k Caf` selection is 4 passed before and after.
…dable

The new read-failure branch in gc_blobs() returned (0, 0) silently, so
`hermes curator ledger --compact` printed "0 unreferenced blob(s) removed"
as if the sweep had run. Log a warning with the underlying error so an
operator can tell "nothing to reap" from "could not look".

The kept invariant test now also asserts the warning via caplog.
compact_ledger() caught OSError on the read but decoded the bytes outside
the guard, so a ledger with invalid UTF-8 raised UnicodeDecodeError out of
`hermes curator ledger --compact`. Same escape class the gc_blobs() fix
closes; treat it identically (no-op, ledger left untouched).
Three read_text() calls in test_entry_stores_only_changed_paths_and_rollback_still_restores
gained encoding="utf-8" alongside the gc_blobs fix. They are unrelated
Windows hygiene (the footgun checker does not flag them); keep the PR
scoped to the blob-GC read-failure bug.
gc_blobs() aborts the sweep on a json.JSONDecodeError (tools/skill_ledger.py:351-352)
but did so silently, while the sibling read-failure abort a few lines above logs
"ledger unreadable (...); blob GC skipped". `hermes curator ledger --compact` therefore
printed "0 blobs removed" as if the sweep had run and found nothing. Emit the same
warning family so the operator learns the ledger needs repair before blobs can be GC'd.

Test: `malformed-json` param on the kept invariant test — (0, 0), blobs intact, and
"blob GC skipped" in caplog. RED with the warning removed
(assert 'blob GC skipped' in ''), GREEN at head.
After json.loads() succeeds, gc_blobs() called row.get(...) unconditionally
(tools/skill_ledger.py:353). A syntactically valid but non-object line such as `[]`
or `"x"` raised AttributeError out of gc_blobs() and up through
`hermes curator ledger --compact`. list_entries() already tolerates such rows with an
isinstance(row, dict) check (~L414); mirror it here and abort the sweep with the same
"malformed ledger line; blob GC skipped" warning — a row we cannot interpret may still
hold blob references, so nothing may be deleted.

Test: `non-dict-row` param on the kept invariant test (ledger + b"[]\n"). RED with the
guard removed (AttributeError: 'list' object has no attribute 'get'), GREEN at head.
kshitijk4poor and others added 25 commits September 23, 2026 00:25
…trim comments and tests

`_cap_binds` was the last inline copy of the cap-clamped-to-window predicate that NousResearch#119406
centralised; the banner still prints the raw configured cap. Plugin engines without the
helper report "not binding", as before when no cap was set. The threshold_tokens comment
and the new test module stop narrating the incident; the parametrize takes the whole agent
config so the `{}` failure path is spelled out rather than derived from a ternary.
…NousResearch#119456)

* feat: setup profile is minted by the backend and found by role, not by name

The guided onboarding runs in a profile the desktop used to create itself
(profiles.create with a soul, "already exists" treated as success) and
recognise by the literal "hermes-setup". The upcoming setup toolset grants
catalog installs to that profile, so the marker that grants it must be
written only by the backend.

- profile.yaml carries `role: setup`; read_profile_meta / write_profile_meta /
  ProfileInfo know it; profiles.list and GET /api/profiles report it.
- hermes_cli/setup_profile.py: ensure (find by role, adopt a pre-role
  hermes-setup dir, else clone default + soul + role) and reset (soul,
  memories, skills back to the created state, in place). The soul text moves
  here from the renderer.
- tui_gateway/methods_onboarding.py: onboarding.ensure_setup_profile and
  onboarding.reset_setup_profile. Neither takes a name; profiles.create and
  profiles.configure already reject `role` (unknown key, 4000).
- Copies never inherit the role: --clone-all, profile import, and a
  distribution that ships profile.yaml drop it.
- setup.status for a named profile reports `ready` once the boot bootstrap
  settled. Since one host backend serves every profile (NousResearch#118246) the
  desktop's setup-profile probe lands on this branch, which never set
  `ready`, and the kickoff waited forever.
- Desktop: SETUP_PROFILE, ensureSetupProfile(profiles.create) and
  composeSetupSoul are gone. store/setup-profile.ts holds the name the
  backend returned (or the roster's role row after a relaunch); kickoff,
  handoff and the build card use it. The dev reset calls the reset RPC.

* fix: write the setup soul as bytes so Windows keeps \n line endings

* refactor(desktop): drop the renderer's setup-profile store; the backend is the only owner

Kickoff reads the name straight from onboarding.ensure_setup_profile and records it on
$setupSession, which every later step already carries. The handoff recovery check reads
the roster row's role. No renderer module holds a setup-profile name or a fallback lookup.
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…Luna show up (NousResearch#119412)

The ChatGPT Codex models endpoint gates entries on client_version against each
model's minimal_client_version. Hermes sent the "0.0.0" sentinel, which used to
return the whole account catalog; since the GPT-6 Sol/Luna rollout it returns a
frozen legacy list (astra + the 5.6 trio) while any version at or above the newest
minimal_client_version (0.155.0, 1.0.0, 99.0.0 alike, live 2026-09-22) returns
everything the account is entitled to. So the picker and the context-window probe
hid gpt-6-sol / gpt-6-luna that the vendor's own client showed for the same account.

Both request sites now go through fetch_codex_catalog_entries(): try the
newest-client URL first and fall back to the "0.0.0" sentinel only when that
answer is non-200 or empty (the backend used to reject out-of-sequence versions
that way). No local ~/.codex/models_cache.json is consulted: a missing or stale cache (this
host records 0.147.0, which hides gpt-6-sol at 0.155.0) must not decide what the
account can see.

Report by @Stone441 (NousResearch#119412); supersedes NousResearch#119420 by @KoNit-K, whose fix read the
compatibility version from the local `~/.codex` cache.
An empty catalog now legitimately triggers the 0.0.0 sentinel fallback (two requests per
site), so the fake answers one model and the one-request-per-site count still holds; every
request still carries the residency headers.
…othing else can grant it (NousResearch#119491)

The setup toolset (empty until NS-964 registers request_catalog_install)
is reserved for the profile whose backend-written profile.yaml carries
role: setup. Two points enforce it:

- Grant: tui_gateway/server.py::_load_enabled_toolsets folds the in-scope
  profile's role toolsets into all three return paths (configured CLI
  toolsets, coding posture, HERMES_TUI_TOOLSETS pin), next to the
  client-surface set. The pin keeps it too: an operator pin picks
  configurable toolsets and must not strip the profile's own.
- Deny: model_tools._select_tool_names strips toolsets reserved for any
  other role from every selection, including None/"all", a saved
  platform_toolsets list, profiles.configure and the env pin. That is the
  one point every surface's selection passes, so a default session cannot
  get the tool by any route.

The role is read with read_profile_meta on get_hermes_home(), which under a
session's home override is that session's profile dir (no directory scan).
setup stays out of _HERMES_CORE_TOOLS, CONFIGURABLE_TOOLSETS and the
platform-native recovery loop (it has no tools, so the loop skips it).

Linear NS-963.
… salvage NousResearch#107451) (NousResearch#119536)

* fix(profiles): toggle MCP servers via the enabled key the runtime reads

The profile editor (profiles.describe / profiles.configure) tracked an MCP
server's on/off state through a `disabled` key on `mcp_servers.<name>`, but
every runtime resolver keys off `enabled` (enabled_mcp_server_names,
coding_context, oneshot, the gateway's tool-name resolver). Disabling a
server in the editor wrote `disabled: true` and the server stayed live at
runtime; a server with `enabled: false` in config showed as enabled in the
editor.

describe now reports `enabled` (default true) and still honours a legacy
`disabled: true`. _save_mcp_toggles writes `enabled: true/false` for every
server and drops the legacy `disabled` key, so old configs migrate on the
next save.

Tests pin describe's reading of `enabled`, `enabled: false`, legacy
`disabled: true` and an unset flag; configure's written `enabled` flags and
the removal of `disabled`; and that a server disabled through the editor is
excluded by enabled_mcp_server_names.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(profiles): trim MCP toggle tests to two invariants

The describe-only and configure-only tests are subsumed by the round trip:
configure writes the key the runtime resolver reads, and describe agrees
with that resolver.

* fix(config): migrate legacy MCP `disabled: true` to `enabled: false`

Older profile editors switched an MCP server off by writing `disabled: true`,
a key no runtime reader consults, so those servers kept running while the
editor showed them off. The editor now writes `enabled`, but configs saved
before the fix still carry the old key until the user saves again.

Config v46 converts each truthy `disabled` to `enabled: false` and drops the
key; a falsy `disabled` is inert and left alone. `hermes update` runs it for
every profile, so the runtime turns off what the user already turned off.
The runtime readers stay on one key; they do not learn `disabled`.

`disabled: true` wins over an explicit `enabled: true`: `hermes mcp add`
writes `enabled: true`, and the old editor only added `disabled`, so the pair
on disk means "the user turned this off in the editor".

---------

Co-authored-by: BowmanStephen <34071312+BowmanStephen@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
)

The `enabled` key had four parsers. The MCP client (`_parse_boolish`) read
`enabled: 0` as on; the toolset resolver and editor (`_parse_enabled_flag`)
read it as off. The server list (`summarize_server`, `/api/mcp/servers`) read
any non-`False` value as on, so `enabled: "false"` showed on while the agent
skipped it. The catalog and `hermes mcp list` accepted only true/1/yes, so
`enabled: on` showed off while the server ran.

`tools/mcp_tool_common.py::mcp_server_enabled` is now the only reader, and
every surface calls it. `_parse_boolish` treats YAML numbers by truthiness
(0 off, other numbers on). Everything else keeps the client's semantics:
the off words are off, absent / null / junk stay on, with the existing
warning for junk.

The desktop MCP page mirrors the rule in `serverEnabled`
(`apps/desktop/src/lib/mcp-servers.ts`). One case table
(`mcp-enabled-cases.json`) drives the Python invariant test and the vitest
test, so the page and the runtime cannot drift apart again.
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…rmes tools picker, desktop connector card, health sweep) (NousResearch#119601)

* fix(mcp): ACP, `hermes tools` and the desktop connector card use the one enabled reader

NousResearch#119567 left four readers of `mcp_servers.<name>.enabled` on their own rules.
@webtecnica's NousResearch#119560 found the ACP one:

- `acp_adapter/session.py::_make_agent` used `is not False`, so an ACP
  session kept a server with `enabled: "false"` or `enabled: 0` in its
  toolset while the MCP client skipped it.
- `hermes tools` MCP picker (`tools_config_mcp._configure_mcp_tools_interactive`)
  read `enabled: 0` as on and crashed on a non-dict entry.
- Desktop connector card (`connectors/data/join.ts`) and the MCP health sweep
  (`store/mcp-health.ts`) used `enabled !== false`.

All four now call `mcp_server_enabled` (Python) or `serverEnabled` (renderer),
which share one case table.

Co-authored-by: webtecnica <webtecnica@gmail.com>

* chore: retrigger CI (zero-job dispatch failure, auto-heal)

---------

Co-authored-by: webtecnica <webtecnica@gmail.com>
Avoid removing Chromium's live collapsed selection container while sweeping zero-length composer text-node litter.\n\nCloses NousResearch#100302.

(cherry picked from commit 9dc30fa)
The NousResearch#100302 sweep guard keeps the caret's own empty text node alive, but
normalizeComposerEditorDom has three more paths that can delete the node a
live caret is anchored in: the phantom tail-block removal, the trailing-br
removal, and the block unwrap. Per the DOM spec a boundary re-anchors in the
removed node's parent, so the caret reads as "valid" while its position has
drifted (the NousResearch#88621 class: editor stays activeElement, printable keys stop
producing input events, clicking restores typing).

Normalization is text-preserving, so this snapshots the caret once — both as
the boundary (node, offset) and as a composerPlainText offset, the same units
the undo/redo caret save already uses — and re-establishes it via
placeCaretAtOffset only when the snapshotted anchor node no longer exists in
the editor. Hidden keep-alive composers are excluded: the snapshot is null
unless the selection is a single collapsed range inside this editor, so a
background repaint never steals the document selection the visible composer
owns. Skipped when the editor does not hold focus (document.activeElement).

The sweep guard from the previous commit stays: skipping the node is cheaper
than a remove-then-restore round-trip and keeps the caret node identity
stable through the empty-litter case.

Tests: the new invariant (caret restored when its anchor block is removed)
fails on the base commit without this change; a second test pins that a
still-valid selection passes through untouched. Composer suite 461/461,
tsc/eslint/prettier clean.

(cherry picked from commit 741265a)
…modal (NousResearch#119633)

* feat(desktop): catalog install card rows for plugin and skill targets

manage_catalog opens the same connection operation manage_connections does,
with rows of kind plugin/skill. The renderer collapsed every non-connector
kind to 'mcp', so those rows would have drawn as MCP servers with no name,
purpose or provenance.

- Contract: ConnectionTargetKind gains plugin/skill; ConnectionOperationTarget
  types the catalog fields agreed in CATALOG-ROW-CONTRACT.md (display,
  description, tier, platforms, repo, sha, subdir, scan, requirements,
  has_desktop_half, target_profile, app_state, skill).
- Store: kinds map through a lookup; catalog rows carry a typed CatalogEntry.
- CatalogRow: kind glyph, name, kind/tier/platform pills, one-line purpose,
  then Install / Advanced / Skip. Installing shows the backend's per-row
  detail; installed shows "N tools · show names"; failed shows the reason
  and Try again (approved again for that row only); skipped stays quiet.
- Advanced: a centred dialog with the Plugins-tab controls (halves, target
  profile, enable, force, pin) plus read-only source and scan. Its Install
  sends the values in the answer env; the row's Install sends env null.
- A missed tool.start restores the card on a manage_catalog row.

* fix(desktop): catalog row uses the app's sliding progress and one pill style

The installing row drew a full-width pulsing line, which reads as a finished
rule, not work in progress; it now uses the Progress primitive's sliding
indeterminate block (reduced motion honoured there). The kind pill was
all-caps monospace next to two sentence-case pills; all three now share one
shape, matching the V1 offer artboard, and the platform pill stays the one
coloured note. The Advanced modal's Source and Security tables share a fixed
label column, and requirements render as prose, not code.

* fix(desktop): a skill's Advanced shows only the controls a skill has

A skill has no agent/desktop halves, no enable step and no commit pin, so
its Advanced dialog offered three controls that do nothing. It now shows the
target profile, force reinstall, source, security and credentials, and sends
only target_profile, force and the credential values. The preparing line
takes the card's width instead of the whole chat column.
…chat, no Connect-now (NousResearch#119644)

Installing a plugin from any surface now makes its MCP servers and skills
usable in every open chat of that profile on the chat's next turn. There is
no Connect-now button, no /reload-mcp, no relaunch.

- hermes_cli/plugins_activation.py::load_and_go_live: after the forced plugin
  rescan, connect the plugin's portable MCP servers one by one
  (register_mcp_servers, the connector flow's call), then refresh that
  profile's open chats and queue a turn note listing the servers, their
  tools and the plugin's skills. activation gains live_now; deferred keeps
  only Python tools and prompt sections.
- MCP tools are deferred behind tool_search / tool_call, so appending them
  (preserve_prefix) leaves the model-facing tool array and the cached prompt
  prefix unchanged; the note rides the existing one-shot turn-note channel,
  so the system prompt stays byte-stable. /reload-mcp keeps its consent gate:
  it is a full rebuild.
- tui_gateway/methods_tools.py: one session walk (_refresh_live_sessions)
  shared by reload.mcp and plugin activation, filtered to the plugin's
  profile home.
- hermes plugins install/enable (another process) asks the running Desktop /
  dashboard backend to do the in-process half through the new
  POST /api/dashboard/agent-plugins/activate, found via the host rendezvous
  record and its session token.
- Desktop: the Connect-now toast and its strings are removed in all six
  locales; the install toast says what went live ("3 tools connected ·
  skill X ready") and warns per server that did not connect.
- Contract: PluginActivation.live_now; regenerated TS/OpenRPC.
- register_skill docstring: plugin skills are listed by skills_list.
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…emise

A worker that verifies its card's work already landed on main had no honest
verb: complete_task demanded evidence of work it did not do, block_task
demanded a blocker that did not exist. So it exited rc=0, the dispatcher
booked a protocol violation, retried 3x, and gave up with a crash-flavoured
notification. Measured 9 wasted runs across t_e484ec2d + t_e587758d.

- kanban_complete(superseded_by=<card|PR|sha>) closes the card done with
  run outcome 'superseded' and the pointer as its evidence; no summary /
  result / survivor required. An EMPTY pointer is refused
  (EmptyCompletionError) — an unnamed supersede is a silent delete of work.
- SUCCESS_RUN_OUTCOMES: every reader that asks "did this succeed" now counts
  'superseded' (parent handoff context, role history, respawn guard, crash
  streak, edit backfill), or a superseded card reads as never-run.
- Dispatcher: two byte-identical clean exits are a REPRODUCED no-op, not a
  flake. Stop at 2 instead of spending the full budget on the same run;
  event carries stopped_early=reproduced_clean_exit + identical_violations.
  Absent worker output never compares equal, so the early trip stays off.
- Notifier: "premise superseded by X" and "needs input: finished with
  NOTHING TO DO Nx identically (no crash)" instead of "gave up".
- Worker protocol (KANBAN_GUIDANCE 4b) + stop-nudge + retry corrective text
  tell workers to use it instead of exiting silently. The violation constant
  got SHORTER (349 -> 298 chars): its 500-char budget is shared with the
  worker's own explanation, which is the load-bearing half.

Verified: tests/hermes_cli/test_kanban_superseded_outcome.py 10 passed
(kernel, dispatcher early-trip, notifier wording); tests/tools 4 new passed;
CLI e2e against a sandbox board — empty pointer refused, real pointer ->
status=done outcome=superseded meta={'superseded_by': ...}.
Regression: tests/hermes_cli -k kanban = 21 failed / 457 passed; the same 21
fail at origin/main in a clean-room worktree (inherited, +10 passing).
tests/gateway+tools+agent -k "kanban or prompt" = 2 failed / 394 passed;
both also fail at origin/main. ruff clean.
@Kyzcreig

Copy link
Copy Markdown
Collaborator Author

Closing: branched from origin/main (NousResearch upstream, split kanban modules) but this repo's main is the ANG-Ventures fork with a monolithic hermes_cli/kanban_db.py — unrelated histories, so the diff cannot apply. Re-porting onto fork/main; will open a replacement PR.

@ang-fleet-workers

Copy link
Copy Markdown

CLOSED: SUPERSEDED-BY #897 — re-ported onto fork main as #897 (5f26ca8, ancestor of main): superseded_by in tools/kanban_tools.py:775 and tests/hermes_cli/test_kanban_superseded_outcome.py. card t_90178503 by daedalus-opus (t_cef7a187)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.