Broadcast Alerts (cross-session notices with expiry + priority)
notices specs/notices/broadcast-alerts.kmd
O sistema único de **alertas de broadcast** da Koder Stack: arquivos em `meta/context/notices/active/*.md` que o hook `notices-check.sh` (`UserPromptSubmit`) injeta no contexto de **toda sessão**, ANTES de qualquer ação, ordenados por prioridade, cada um com **expiração explícita** (auto-some) e/ou **disparo agendado** (`fires_on`, aparece só a partir de uma data futura — substitui `/schedule` para obrigações datadas). Unifica os antigos `notices` (locks/cortesias técnicas cross-session) e `reminders.md` (lembretes do owner com data) num só formato + um só hook. Substitui o TTL implícito por severity+idade (onde `high` = nunca expirava) por um `expires:` explícito.
When this spec applies
All triggers
- Criar um alerta lido por todas as sessões (lock, cortesia, lembrete, obrigação datada)
- Definir expiração/prioridade/disparo-futuro de um aviso entre sessões
- Editar notices-check.sh ou o schema de notices/active/
- Aposentar um /schedule em favor de um alerta datado lido por todas as sessões
Specification body
Broadcast Alerts
Por quê
Toda sessão do Claude Code precisa ler, antes de qualquer coisa, os avisos
importantes que outras sessões/o owner deixaram: locks de componente, cortesias,
lembretes datados, obrigações futuras (ex.: "remover compat-shims em 2026-06-20").
O hook notices-check.sh já injeta notices/active/ no UserPromptSubmit de toda
sessão — esta spec formaliza o schema (com expiração + prioridade + disparo
agendado) e o comportamento do hook, e unifica o antigo reminders.md aqui.
R1 — Local + formato
- Um alerta = um arquivo
meta/context/notices/active/<slug>.mdcom frontmatter YAML- corpo Markdown. Arquivado = movido para
meta/context/notices/archive/.
- corpo Markdown. Arquivado = movido para
- O hook lê só
active/.archive/é histórico (nunca injetado).
R2 — Schema (frontmatter)
| Campo | Obrig. | Default | Significado |
|---|---|---|---|
title | sim | — | manchete curta (1 linha) |
created | sim | — | data de autoria YYYY-MM-DD (alias retro: date) |
priority | não | 5 | 0–9; maior = aparece primeiro |
expires | não | — | YYYY-MM-DD; não exibe a partir desta data + auto-arquiva (TTL) |
fires_on | não | — | YYYY-MM-DD; não exibe ANTES desta data (disparo agendado) |
status | não | active | active | done (done não exibe) |
kind | não | alert | lock | courtesy | reminder | alert | bug-dep |
audience | não | all | all (técnico, toda sessão) | owner (pessoal) |
recurring | não | none | none | daily (re-arma manual: ao disparar, bumpar fires_on +1 até done) |
severity | não | normal | retrocompat: se NÃO houver expires, o TTL cai no esquema antigo idade+severidade (high=∞, normal=7d, low=3d a partir de created) |
component | não | — | componente alvo (locks) |
session_id | sim (locks novos) | — | (locks) UUID da sessão Claude Code que colocou o lock ($CLAUDE_CODE_SESSION_ID); permite a uma sessão bloqueada identificar quem detém o lock (mapeável a .session-state/<uuid>.json). koder-lock grava automaticamente. Locks à mão / fallback DEVEM emiti-lo também — nunca um PID (session: 724929 é anti-padrão: morre e não mapeia). Legados sem ele são grandfathered (warn, não erro). Enforçado por koder-spec-audit locks (R3.11). |
session_name | não | — | (locks) dica humana legível do dono — nome da worktree/k.claude <nome> quando houver, senão a conta ($CLAUDE_ACCOUNT_NAME). Opcional (não há fonte por-sessão universal); o reason/title do lock já dá o "o quê". O session_id é a identidade canônica. |
applies_when | não | — | (R3.10) expressão Kanon (domínio notices) sobre o foco da sessão; ausente ⇒ universal |
kind: bug-dep(dependência de bug,component-bug-discovery.kmd): emitido por uma sessão que, usando uma instância instalada de um componente Koder, achou um bug nele e depende da correção para prosseguir. O corpo declara o ticket do bug (<COMPONENT>-NNN) e o componente. Sinal-back: quem corrige o bug flipa este alerta parastatus: doneao fechar o ticket — é o sinal que re-aciona a sessão descobridora (que então atualiza a instância e retoma). Pareia compriorityalta (7–8) +component:+ oblocked-by:no ticket da descobridora.
expiresvsfires_on:expires= "vale ATÉ" (some depois).fires_on= "aparece A PARTIR DE" (some antes). Os dois podem coexistir: uma obrigação que só importa numa janela. Um lock típico usaexpires; uma obrigação datada (ex.: remover shims) usafires_on(+ opcionalexpiresalguns dias depois).
R3 — Comportamento do hook (notices-check.sh, UserPromptSubmit)
Para cada arquivo em active/, nesta ordem:
R3.1
status: done→ não exibe (candidato a arquivar).R3.2
fires_ondefinido ehoje < fires_on→ não exibe (ainda não disparou).R3.3
expiresdefinido ehoje >= expires→ não exibe + auto-arquiva (mvidempotente p/archive/, falha silenciosa em corrida entre sessões).R3.4 sem
expires→ retrocompat: idade =hoje - created; TTL porseverity(high=999d,normal=7d,low=3d); se idade > TTL → não exibe + auto-arquiva.R3.5 senão → exibe, com countdown:
expires→⏳ vence em Nd/⚠️ VENCE HOJE; obrigaçãofires_onrecém-disparada/atrasada ourecurringvencido →🔔 [VENCIDO há Nd]/🔔 HOJE/🔔 em Nd.R3.6 Ordenar os exibidos por
prioritydesc, desempate por urgência (menor dias-até-expirar / mais atrasado primeiro).R3.7 Emitir um bloco único
[alerts]noadditionalContext(JSON válido). Saída sempre JSON bem-formado; qualquer erro de parse de um arquivo → pular esse arquivo, nunca abortar o hook (não pode quebrar nenhuma sessão).R3.8 — disciplina de renderização (corpo sob demanda). O bloco é injetado em todo turno (custo de token por-turno); por isso o corpo (primeiras 6 linhas) só é incluído para itens que exigem ação a cada turno:
kind: lock,kind: bug-dep, e obrigaçõesfires_onjá disparadas. Todo o resto (courtesy,alertinformativo,reminder) é exibido como uma linha (título + countdown); o corpo vive no arquivo, lido sob demanda (Read) quando o título for relevante à tarefa. Reduziu o bloco de ~3.964→~1.051 tok/turno (~73%) sem perda de acionabilidade (locks/bug-deps/obrigações mantêm corpo). A relevância por-sessão (mostrar um notice só a sessões que casam um predicado) é trabalho separado (applies_when:avaliado pelo motor Kanon — verrfcs/stack-RFC-015), não esta regra de apresentação.R3.9 —
audience: ownerfora do bloco técnico. Noticesaudience: owner(pessoal/jurídico/admin do owner) não entram no bloco lido por sessões de código — são ruído para trabalho técnico. Continuam acessíveis via/k-reminders(que lê os arquivos direto, não a saída do hook). Defaultaudience: all→ só pula quem é explicitamenteowner.R3.10 — relevância por-sessão via Kanon
applies_when(motor, não bash). Um notice pode declararapplies_when:(expressão Kanon do domínio notices — verspecs/kanon/expressions.kmd §4, fatoscomponent/repodo foco da sessão). O hook resolve a relevância chamandokoder-spec-audit notices --component <foco>(o motor — nunca reavalia Kanon em bash, RFC-015 R4): exibe os universais (semapplies_when) + os que casam o foco. Opt-in (ausência = universal) e fail-open por contrato: motor ausente/erro/timeout, foco desconhecido, ou regra inválida ⇒ mostra (nunca esconde um lock). Notices não precisam declararkanon:(default = versão corrente do motor); umkanon:não suportado fail-opens. É o análogo por-turno doapplicable(seleção computada de specs).R3.11 — todo lock novo carrega
session_id(dono identificável). Um lock existe pra uma sessão bloqueada responder "quem detém isto, e ainda está vivo?". Isso só funciona se o lock traz umsession_idestável (UUID$CLAUDE_CODE_SESSION_ID, mapeável a.session-state/<uuid>.json). Okoder-lock placegrava automaticamente; toda outra trilha de escrita (fallback prosa dotasks/concurrent-lock.md, skills, à mão) DEVE emiti-lo — nunca um PID no campo ad-hocsession:(session: 724929morre e não mapeia; anti-padrão real, id#261 2026-07-02). Enforçado porkoder-spec-audit locks(advisory por default;--strict= exit 1 pra gate CI): classifica cada lock ativo emok(session_id UUID) /no-session-id(camposession:ad-hoc) /ad-hoc-session(session_id não-UUID) /anonymous(nada). Legados semsession_idsão grandfathered (warn, não erro) até serem re-colocados viakoder-lock.R3.12 —
kind: courtesyaflora 1× por sessão. Cortesia é informacional (não-acionável); mostrá-la a cada turno é o maior custo de token por-turno remanescente depois que R3.8 cortou o corpo (sobra a linha). O hook mostra a cortesia na primeira vez que ela aparece na sessão e suprime nas seguintes, via um marcador por-sessão ($KODER_ALERTS_STATE/<session_id>, default~/.claude/state/alerts-courtesy-seen/, criado só quando ao menos uma cortesia foi de fato mostrada). Sókind: courtesy(match exato) é suprimido —lock,bug-dep, obrigaçõesfires_on,reminderealertnunca. FAIL-OPEN por contrato: semsession_id→ nunca suprime (mostra tudo, comportamento pré-R3.12); um bug de supressão jamais pode esconder um lock. Uma sessão nova re-mostra a cortesia (marcador é por-session_id). Motivo:/k-token-audit2026-07-07 (meta/context#788 Parte B).
R4 — Precedência de leitura (CLAUDE.md)
Toda sessão DEVE tratar o bloco [alerts] como leitura prioritária antes de
agir: respeitar locks, honrar obrigações fires_on disparadas, considerar cortesias.
R5 — Migração
- Os itens de
meta/context/reminders.mdviram arquivosnotices/active/*.mdcomkind: reminder,audience: owner,fires_on: <data>,recurring: daily(onde aplicável).reminders.md+reminders-check.shsão aposentados. /k-reminderslista os alertaskind: reminderdo novo sistema.
Testes
- T1 — arquivo com
expiresno passado → não injetado + arquivado. - T2 — arquivo com
fires_onno futuro → não injetado; no/após a data → injetado. - T3 — dois arquivos com
prioritydistinta → o de maior priority aparece primeiro. - T4 —
status: done→ nunca injetado. - T5 — retrocompat: arquivo sem
expires,severity: low,createdhá 4 dias → não injetado. - T6 — arquivo com frontmatter malformado → pulado; hook ainda emite JSON válido p/ os demais.
- T7 —
recurring: dailyvencido → injetado com tag[VENCIDO há Nd]. - T8 —
kind: bug-depativo → injetado normalmente; ao virarstatus: done(sinal-back do fixer) → não mais injetado (candidato a arquivar). - T9 —
kind: courtesy→ injetado como uma linha (sem corpo);kind: lockekind: bug-dep→ injetados com corpo (6 linhas); obrigaçãofires_ondisparada → com corpo (R3.8). - T10 —
audience: owner→ não injetado no bloco;audience: all(ou ausente) → injetado normalmente (R3.9). - T11 —
applies_when: component == "koda": foco=koda → injetado; foco=kdb → não injetado; sem foco / motor ausente / regra inválida → injetado (fail-open). Avaliado porkoder-spec-audit notices(testeTestNoticeRelevance), não por bash (R3.10). - T12 —
kind: courtesy+session_idfixo: turno 1 → injetado; turno 2 da mesma sessão → não injetado; umkind: lockno mesmo dir → injetado nos dois turnos (invariante de segurança); sessão nova → cortesia re-injetada; semsession_id→ cortesia injetada todo turno (fail-open). Verificado pormeta/tests/regression/020-notices-courtesy-once-per-session.test.sh(R3.12).