TransmuteLabs/Catalyst/tree/main/plugins/catalyst-refusal-watch
關於這個 mod
catalyst-refusal-watch 追蹤 TransmuteLabs/Catalyst 的 plugins/catalyst-refusal-watch。它 hook turn.step、turn.complete、tool.call(TaskStop)、prompt.context、session.start 與 session.end,偵測 refusal stopReason,發出 toast 與 transcript 列,為 TaskStop 通知維護 30 分鐘視窗,並可在 CLAUDE_REFUSAL_WATCH 開關後注入 refusalHandling 規則。它不改變引擎行為,也不注入拒絕回應;透過已驗證的 .claude-plugin/plugin.json 與 hooks register 模組發布。
安裝
請先查看作者 README,確認 marketplace 與外掛名稱;指令可能隨儲存庫結構而變動。
claude plugin marketplace add TransmuteLabs/Catalyst claude plugin install catalyst-refusal-watch
原文 / README
catalyst-refusal-watch
Делает видимым каждый обрыв ответа фильтром сервиса в момент обрыва и добавляет модели правило: сообщить об остановке и не бросать линию работы молча.
Плагин не меняет поведение движка при обрыве и не трогает текст, который движок
вставляет модели. Отказов и deny нет — только видимость.
Что делает
turn.stepотдаёт чанки и результат без изменений и в том же порядке. ЕслиstopReasonчанкаstopили результата шага —refusal, тост и строка транскрипта (модели она не уходит) показываются в момент распознавания — до чтения часов и не дожидаясь конца шага; счётчик растёт в момент распознавания, статус сессии — после ответа часов, а при отмене диспатча раньше ответа — по местному времени процесса; время, не узнанное ни от часов, ни местным, в статусе —--:--, дедупу хода оно не мешает; отказ местного времени или значение, не являющееся конечным числом — строка канала clock в debug, тост от него не зависит; обрыв, чья сессия закончилась (session.end) после входа хука, статус не ставит.turn.completeприreason: "refusal"(ход оборван и повтора не было) сообщает отдельно. Если последний шаг того же хода (agentId и turnId) уже сообщил об обрыве, тост всё равно есть, а счётчик, окно и «последний» второй раз не меняются; вернувшийся шаг хода без обрыва или конец хода, у которого полеreasonпрочиталось (в том числе отсутствующее), это снимает; шаг, оборванный исключением, и конец хода с нечитаемой причиной запись хода не снимают. НечитаемыеagentIdилиturnIdзапись хода не ставят и не снимают; такой обрыв считается отдельно.tool.callинструментаTaskStopв окне 30 минут после обрыва показывает тост с id задачи только при успехе ядра:result— запись вывода TaskStop со строковымиmessage,task_id,task_type,command— отсутствует или строка, нетdeny,isErrorотсутствует илиfalse. Вызов не блокируется и вне окна ничем не отмечается. Остановка, пришедшая, пока окно открывается (обрыв объявлен, часы ещё не ответили), считается внутри окна. Окно, метка открытия и время берутся на момент входа вызова. Время входа — местное время процесса, без ожидания: если оно внутри окна, для решения часы не читаются (запись журнала после тоста читает их как обычно). Без окна местное время решения не даёт. Окно с неизвестным концом (время обрыва не узнано ни от часов, ни местным временем) считается открытым: остановка внутри, для решения часы не читаются (запись журнала после тоста читает их как обычно). Иначе чтение часов выдаётся до ответа ядра, и исход решает его ответ. Остановка, начатая до обрыва, и остановка, чья сессия закончилась до её ответа, тоста не дают. Без окна и метки на входе часы не читаются. Неизвестное время входа (местное время не число или бросило, а часы отказали) считается внутри окна: лишний тост дешевле пропущенного. Метка открытия активна, пока местное время процесса не ушло вперёд от момента обрыва на 30 минут; перевод местных часов назад её не снимает; метка, чей момент неизвестен (местное время не число или бросило), по времени не снимается — только своим обрывом или концом сессии (session.end); нечисловое местное время входа метку не снимает. Порядок ответов часов не важен: окно кончается через 30 минут после наибольшего известного времени обрыва, «последний» — обрыв с наибольшим известным временем; обрыв с неизвестным временем держит конец окна неизвестным, пока после него не начат обрыв с известным временем. Безnext.signal(вне контракта хоста) зависшие часы держат ответ остановки.prompt.contextдобавляет запасной блокrefusalHandlingтолько при включённомCLAUDE_REFUSAL_WATCH, отсутствии подтверждённого паспортом системного носителя и отсутствии полного RULE_TEXT в любом блоке. Дубликаты собственного имени удаляются: первый блок с полным каноном (либо первый именованный, если канона среди них нет) сохраняется на позиции первого именованного блока. При вставке stale-текст заменяется; порядок прочих блоков не меняется.session.startодин раз на окружение модуля читает паспорт и выполняет awaited-пробу$.settings.read(); отдельныйsettings.readнаблюдает собственный вложенный вызов. Успешная проба без этого наблюдения даёт одну D4-строку при включённом правиле; отказ пробы сообщает «доставка не подтверждена наблюдением».session.endобнуляет счётчик «за сессию»:session.startна/clearне приходит (контракт), аsession.endприходит на каждый конец сессии, включая/clearи resume. Паспорт, вердикт пробы и latch диагностик сохраняются; fresh load/reload с новым register повторяет инициализацию.
Выключатель CLAUDE_REFUSAL_WATCH
Контракт объявлен этим модом; системный сплайс шага 26 использует тот же выключатель.
Читатель — $.env.get("CLAUDE_REFUSAL_WATCH"). Значение обрезается по краям и
переводится в нижний регистр: 1, true, yes, on включают правило; отсутствие,
пустая строка, 0, false, no, off выключают. Неизвестное значение не вставляет
текст и даёт именованную диагностику. Без явного включения мод не добавляет правило;
этот выключатель не отключает уведомления об обрывах.
Паспорт выбирается из раскрытого HOME-каталога
$HOME/.local/share/catalyst-cc/passports/: ровно одно имя
<session.version().version>-<platform>.json, где platform — [a-z0-9_]+.
Платформа модом не вычисляется. Нет каталога/совпадений — fallback; несколько
совпадений — fallback и passport-ambiguous. Поля version/platform сверяются с
именем. Skip требует step26.applied === true, совпадающий rule_sha256 и
совпадающие size/mtime канонического $HOME/.local/share/claude/versions/<version>.
Нет канонического файла либо stat расходится — fallback и одна passport-stale.
Иные отказы чтения/разбора/проверки не доказывают доставку и именованно диагностируются.
Паспорт не доказывает final model payload; иной файл запуска к нему не привязан.
Журнал — ключи log:<время>:<номер>:<метка> в $.store, по одному на запись, не больше 100 последних (порядок = порядок вставки). Старый ключ log (массив модуля до 0.1.3) не читается и не изменяется; в журнал и в предел 100 он не входит. Обработчик ждёт запись не больше 5 с; дольше — строка в debug, запись дописывается в фоне. Отказ clock.now — строка канала clock в debug; время берётся из местного времени процесса, ожидание записи идёт как обычно. Отказ clock.sleep, пока диспатч не отменён, — строка канала clock и «ожидание записи снято отказом часов; запись не подтверждена», обработчик дальше не ждёт. Когда ожидание записи снято отменой диспатча, строка — «ожидание записи снято отменой диспатча; запись не подтверждена»; если sleep при этом отказал не причиной отмены, добавляется строка канала clock. Если признак отмены сигнала нечитаем, а отмена диспатча не зафиксирована раньше, строка — «ожидание записи снято отменой или отказом часов (сигнал нечитаем); запись не подтверждена», а отказ sleep называется строкой канала clock. Каждое ожидание обработчика — часов и записи — кончается с отменой диспатча (next.signal): время берётся из местного времени процесса; строка «ожидание записи снято отменой диспатча; запись не подтверждена» пишется только там, где запись начата; отказ sleep молчит только причиной самой отмены. Обрыв, чья сессия закончилась (session.end) после входа хука, в счётчик, окно, метку открытия и статус новой сессии не входит; запись в журнал остаётся.
Каждый канал доставки изолирован: его отказ не прерывает шаг, ход или вызов.
Зубы
Прогоны #569 — только Linux, с отдельными HOME, CLAUDE_CONFIG_DIR и TMPDIR
(последний вне HOME), внутри scope с MemoryMax=4G и MemorySwapMax=0.
Не использовать живой HOME: хост выполняет уборку versions при старте.
PLUGIN_DIR — копия этого каталога; файл версии не копируется и не меняется.
CLAUDE_BIN=/Users/maratkarimov/.local/share/claude/versions/2.1.285
"$CLAUDE_BIN" --version # требуется 2.1.285
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 "$CLAUDE_BIN" plugin test "$PLUGIN_DIR"
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 "$CLAUDE_BIN" plugin validate "$PLUGIN_DIR"
tests/sec-default-569.test.ts — 49 зубов выключателя, паспорта, локального канона,
D4 и состояния окружения, включая paired host-chain с явным prepend и загрузчик
285. tests/parity.test.ts — один зуб SHA-256 полных UTF-8 байт и единственного
пина. Вместе с исходными 160 это 210 тестов. Прямые callbacks не доказывают
доставку по тирам; явный prepend не доказывает автоматическую посадку или final payload.
Кросс-репозиторный сторож запускается на Linux:
CATALYST_PATCH_KIT=/absolute/path/to/tweakcc-patch.js bash tests/scripts/check-splice-parity.sh
Нет кита, параметра либо REFUSAL_RULE — НЕ ИЗМЕРЕНО, RC=2; совпадение обоих
литералов и пина — PASS, RC=0; расхождение — FAIL, RC=1.
tests/behavior.test.ts — 160 тестов официального стенда: текст и счётчик
обрыва шага, тишина на end_turn, прозрачность чанков, категория хода без
повтора, окно TaskStop, единственность блока правила, отказ тоста, дедуп
счётчика по ходу, обрезка журнала, строка журнала без пустых полей, закрытие
нижнего потока.
- T14: чанк
stopсrefusalпри результатеend_turnдаёт ровно один тост. - T15:
stopReason: "refusal"только у результата, без чанкаstop, даёт ровно один тост. - T16: тост об остановке задачи есть на 1 799 999 мс окна и нет ровно на 1 800 000.
- T17: счётчик растёт на другом turnId и после шага без обрыва; шаг часов назад не пересчитывает.
- T18: текст правила содержит обязанные фразы, блок
refusalHandlingдлиннее 300. - T19: уведомление об остановке задачи печатается только при успешной остановке и называет агента.
- T20: два одновременных обрыва оставляют в журнале две записи.
- T21: отказ часов не глушит тост, в статусе время
HH:MM. - T22: нечисловые часы открывают окно по
Date.now(), в статусе нетNaN. - T23: ошибка нижнего потока после обрыва доходит наверх тем же объектом, тост один.
- T24: отмена сверху после обрыва даёт тост и закрывает нижний поток.
- T25:
throwсверху доходит до нижнего генератора, восстановленный чанк возвращается. - T26: бросок
next()у итератора не вызываетreturn(). - T27: запись дедупа хода кончается его complete.
- T28: статус содержит модель и локальное
HH:MM; отказ тоста пишется с{ to: "debug" }. - T29:
session.endобнуляет счётчик сессии и возвращает результатnext; окноTaskStopтоже закрывается (T39). - T30: результат без поля
doneвсё равно читается как чанк. - T31: наблюдатель читает
doneиvalueрезультата ровно по одному разу. - T32: наблюдатель держит функцию
next, взятую при входе, до конца делегирования. - T33:
stopReason: "refusal"из нижнегоreturn()тоже даёт уведомление. - T34: зависшая запись не держит вызывающего дольше 5 с и не затирает другую; в debug строка «запись дольше 5000 мс».
- T35: запись, висящая через
session.end, не теряется: в журнале остаются обе. - T36: шаг часов назад не выметает чужую запись дедупа.
- T37: аргумент
next(v)сверху доходит до нижнегоnext. - T38: отказ
store.setне мешает следующей записи; в debug строка канала store. - T39: после
session.endтоста об остановке задачи нет. - T40: предела по числу ключей нет.
- T41: отказанный
TaskStopбез окна часов не читает. - T42: нечисловое значение часов не становится временем записи.
- T43: успех
TaskStop— только запись вывода TaskStop (message/task_id/task_typeстроками), безdeny/isError; массив, пустой и неполный объект тоста не дают. - T44: поздний
setне затирает запись, которая прошла, пока он ждал. - T45: отказ
sleepотпускает вызывающего сразу, запись дописывается в фоне; в журнале оба хода. - T46: предел 5 с считается от начала каждого вызывающего, не общий на модуль.
- T47: 105 записей оставляют в журнале 100 последних, первые пять ключей удалены.
- T48: старый массив
logне трогается, новые записи идут по своим ключам. - T49: нет
throw— закрытие черезreturn, значение закрытия не читается, наверхTypeError. - T50:
stopReason: "refusal"в результате закрытия тоста не даёт. - T51: 1001 ход и complete первого дают счёт 1001; снимается только его ключ.
- T52: геттер
valueчитается ровно один раз на результат. - T53:
TaskStopсisError: 1тоста не даёт. - T54:
NaNиInfinityот часов не становятся временем записи. - T55:
TaskStopсresult: undefinedтоста не даёт. - T56: нет
throwи нетreturn— оба геттераundefined, сверху выходитTypeError. - T57: старый
logне входит в предел 100 записей. - T58: закрытие без
throwзовёт нижнийreturnбез аргументов. - T59: невызываемый нижний
return(не функция) отдаётся хосту как есть. - T60:
TaskStopсresult: nullили строкой тоста не даёт. - T61: взведённый
isErrorпри полной записи вывода тоста не даёт;isError: false— даёт. - T62: закрытие зовёт нижний
return, даже если у функции своё свойствоapply. - T63:
nextиthrowдоходят до нижних методов, даже если у функций своё свойствоcall. - T64: бросивший геттер
returnне оставляет флаг закрытия. - T65: тост об обрыве выходит в момент чанка
stop, не дожидаясь конца нижнего потока, и не повторяется. - T66:
command— отсутствует или строка; число илиnullтоста об остановке задачи не дают. - T67: каждое строковое поле записи проверяется отдельно: без
message, безtask_idили массив — тоста нет. - T68:
isError0,nullили""при полной записи тоста не дают;isError: false— даёт. - T69:
nextиthrowдоходят до нижних методов, даже если у функций своё свойствоapply. - T70: закрытие зовёт нижний
return, даже если у функции своё свойствоcall. - T71: два чанка
stopсrefusalв одном потоке дают ровно один тост. - T72: без следующего
nextтост и строка транскрипта есть сразу при чанкеstop, статус ждёт часы. - T73: зависшие часы держат шаг только до отмены диспатча; окно открывается по
Date.now(). - T74: остановка задачи, пока окно открывается, даёт тост.
- T75: метка открытия окна снимается, когда окно поставлено: после окна тоста нет.
- T76: обрыв, чей учёт пережил
session.end, не держит окно и не считается в новой сессии; запись в журнале есть. - T77:
turn.completeждёт часы только пока жив диспатч. - T78: отмена при зависшей записи отпускает вызывающего и называет причину, а не отказ часов;
sleepполучает тот же сигнал; отказsleepмолчит только причиной отмены. - T79: отмена отпускает зависшую запись, даже если
sleepсигнал не слушает. - T80: остановка, начатая до обрыва, после ответа не сравнивается с окном этого обрыва; начатая после — даёт тост.
- T81: остановка, вошедшая без окна, часов не читает ни на входе, ни после ответа ядра; окно, открытое во время её ожидания, исход не решает.
- T82: остановка, пережившая
session.end, по окну новой сессии тоста не даёт. - T83:
turn.complete, чейnextвернулся послеsession.end, даёт тост, но ни статуса, ни окна. - T84: шаг, начатый до
session.end, не ставит метку открытия и статус в новой сессии. - T85: снятие метки одного обрыва не снимает метку другого, ещё ждущего часы.
- T86: метка открытия живёт до 1 799 999 мс местного времени и снята на 1 800 000.
- T87: при уже отменённом сигнале на ожидаемый промис всё равно ставится подписка.
- T88: бросок
onAbortотклоняет ожидание — и при уже отменённом сигнале, и из слушателя. - T89: ожидание, решённое своим промисом, снимает слушатель отмены.
- T90: отмена после ответа промиса ничего не вызывает; слушатель ставится однократным.
- T91: при отменённом сигнале посторонний отказ
sleep— отказ часов. - T92: строка отмены не утверждает, что запись продолжается в фоне.
- T93: отказ
sleepбез отмены называет неподтверждённую запись. - T94: остановка, чей
nextвернулся послеsession.end, после этого часов не читает и тоста не даёт. - T95: остановка, вошедшая при открытом окне, тоста не даёт, если
session.endпришёл, пока она читала часы. - T96: та же остановка без
session.endдаёт ровно один тост. - T97: остановка, вошедшая внутри окна, даёт тост, даже если ядро ответило после края окна.
- T98: зависшее и отменённое чтение часов на входе остановки решается местным временем входа, а не более поздним.
- T99: отказ местного времени на входе остановки не отнимает у вызова его результат.
- T100: перевод местных часов назад меньше чем на окно метку открытия не снимает.
- T101: при уже отменённом сигнале у ожидаемого промиса есть обработчик отказа.
- T102: отказ промиса с бросающим
onAbortснимает слушатель отмены. - T103: при отменённом сигнале отказ
sleepновымAbortErrorне считается отказом часов. - T104: отменённый сигнал без подписки называет в строке журнала отмену диспатча, не часы.
- T106: нечисловое местное время на входе остановки не снимает меток и считается внутри.
- T107: бросок местного времени на входе остановки не отменяет решения по метке открытия.
- T108: остановка, чьё местное время входа внутри окна, даёт тост, даже если часы ответили бы за краем.
- T109: без местного времени и без часов на входе остановка считается внутри окна.
- T110: остановка с местным временем входа внутри окна не читает часов для решения.
- T111: без окна отрицательное местное время тоста не даёт и часов не читает.
- T112: обрыв, чьё время не узнано, открывает окно с неизвестным концом; остановка в нём даёт тост и не читает часов для решения.
- T113: окно, чей конец равен нулю, остаётся окном.
- T114: метка, поставленная в неизвестное время, известным временем входа не выметается.
- T115: перевод местных часов назад больше чем на окно метку открытия не снимает.
- T116: бросок местного времени при постановке метки не отнимает тост обрыва.
- T117: обрыв с неизвестным временем не пишет NaN в статус.
- T118: обрыв с неизвестным временем сводит complete своего хода.
- T119: обрыв, чьи часы ответили позже более нового, не отодвигает окно и «последний» в статусе назад.
- T120: более старый шаг хода, чьи часы ответили позже, не ломает дедуп хода.
- T121: нестроковое пояснение отказа всё равно объявляет ход.
- T122: нестроковые поля события всё равно дают текст тоста и статуса.
- T123: сигнал, чей слушатель не читается, не теряет учёт обрыва.
- T124: бросок сразу после постановки метки открытия всё равно снимает метку.
- T125: нечисловое местное время названо строкой канала clock в debug.
- T126: более новый обрыв с меньшим временем не закрывает окно более старого.
- T127: обрыв с неизвестным временем держит окно открытым, пока после него не начат обрыв с известным временем.
- T128: complete, сведённый со шагом своего хода, сохраняет модель шага в статусе.
- T129: шаг хода без обрыва снова вооружает счётчик complete этого хода.
- T130: complete с любой причиной снимает запись дедупа своего хода.
- T131: тот же turnId у другого агента — другой ход.
- T132: шаги хода, отвечающие часам не по порядку, всё равно сводят его complete.
- T133: имена инструментов не-строки всё равно дают текст тоста.
- T134: поля журнала хранятся текстом.
- T135: бросающий геттер поля отказа не теряет тост и результат хода.
- T136: бросающий геттер сигнала не теряет учёт шага.
- T137: результат TaskStop с бросающим полем возвращается как есть.
- T138: чанк с бросающим полем проходит без изменений.
- T139: результат контекста с бросающим полем blocks возвращается как есть.
- T140: сигнал, читаемый дважды, не ломает ожидание.
- T141: бесконечное местное время названо строкой канала clock.
- T142: turnId, чей текст бросает, всё равно даёт строку транскрипта.
- T143: reset снимает шов fault.
- T144: шов fault не срабатывает без метки открытия.
- T145: сломанное местное время и отказ часов называют местное время один раз.
- T146: сведённый complete, пришедший раньше ответа часов шага, не показывает счёт без этого обрыва.
- T147: время обрыва 0 и отрицательное — известное время: окно кончается через 30 минут после него.
- T148: шаг, оборванный исключением, запись дедупа хода не снимает.
- T149: при отказе часов и местного времени запись журнала хранит время null.
- T150: шаг без turnId запись дедупа не ставит — complete без turnId считается.
- T151: неконечный номер шага хранится текстом.
- T152: конец хода снимает агента из карты дедупа, когда его ходов не осталось.
- T153: нечитаемый reason не снимает запись дедупа хода.
- T154: нечитаемый признак отмены назван в строке ожидания записи.
- T155: нечитаемый agentId на конце хода не снимает запись дедупа главного хода.
- T156: вернувшийся шаг с нечитаемым agentId не снимает запись дедупа главного хода.
- T157: шаг отказа с нечитаемым agentId не ставит запись дедупа — его complete считается отдельно.
- T158: конец отказа с нечитаемым agentId считается отдельно и запись дедупа не снимает.
- T159: нечитаемый reason отменённого сигнала не выпускает AbortError отмены в канал clock.
- T160: нечитаемый reason и отказ
undefined— отказ часов, строка канала clock есть. - T161: бросающий геттер
nameу отказа, не равного причине, не прячет отказ часов.
Validate из команд выше перечисляет две отдельные регистрации session.start /
settings.read и вызов $.settings.read; это свидетель загрузчика, не tier-delivery.