Clima de Cota do Orkastery no Claude Code: prevê quando a cota da conta acaba no ritmo atual, avisa na faixa antes de a fase morrer no meio e diz qual retomada…

English · Português

In one sentence:
orkconducts AI coding agents (Claude Code and Codex) through parallel six-phase threads, checks every claim against the repository before it counts, and only calls you when the decision is really yours.
@orkastery/cli, with changes per version · reviewed on 2026-09-27main at PR #74, run 37102089623, 2026-10-03)docs/produto/ and docs/roadmap/, checked against the code by ork docs verificardocs/ are in Brazilian Portuguese, and the CLI reference is also in English. The CLI prints Brazilian Portuguese too, except ork doctor, ork init, ork onboarding, ork experiencia show, ork thread (except thread new --from-finding), ork phase list, ork phase run, ork verify, ork ship, ork pulse, ork master, ork retry plan, ork retry run, ork roadmap (status and reservations), ork worktree, ork claims, ork decisao placar, ork accounts, ork network (except network roadmap), ork docs, ork adapter and the help, which print English when owner.language or your locale asks for en; so do the orkastery.yaml comments, the AGENTS.md and CLAUDE.md blocks, the ork docs init templates and the skills, checklists, commands and subagents that ork adapter install copies; in English, read the quickstart on orkastery.com and the glossary of what the CLI printsnpm install -g @orkastery/cli
ork demo # 30 seconds: a false claim rejected, the fixed one accepted; no account, no model
ork doctor # what holds on this machine right now; exits != 0 when blocked
ork init # writes the orkastery.yaml; run it at the root of a git repository with at least one commit
ork modos # the four conduction modes
ork docs init # product docs and roadmap in the standard, with lint
The smallest full cycle, in #Fast mode (a single GO phase), right after ork init. <thread> is the ID that ork thread new prints.
git add orkastery.yaml AGENTS.md && git commit -m "ork init"
ork thread new "add an exclamation mark to greet" --modo fast
ork phase run <thread> GO --prompt "<the request>" # the agent runtime works in the thread's worktree
ork verify <thread> # re-runs the claims and the manifest checks on the real HEAD
ork ship <thread> --para main --autorizar-push <you> # #Fast never pushes without your authorization
ork master <thread> --aceitar-omissao # closes the thread with the index derived from the ledger
phase run stops with runtime.workspace-untrusted, run claude once in the worktree, accept the trust prompt, then ork retry run <thread>. ork pulse shows the same instruction.ork claims add <thread> <file> --claim "<statement>" --verificar "<command>".ork takes that seat. It is a deterministic TypeScript CLI, with no server and no embedded LLM.ork brings you is short and organized, with alternatives and one recommendation, so you decide in seconds without rereading a wall of text. That lightness is a design goal, meant also for people with ADHD: it keeps you conducting instead of drowning.Without ork | With ork |
|---|---|
| The agent says the tests pass | ork verify re-runs the command on the real HEAD, and CI re-runs it again on a clean runner |
| You ask for status several times a day | An hourly summary says what is pending, urgent, blocking or critical |
| Every obvious decision becomes a question | The obvious one comes decided and reported; you change it if you want |
| The runtime account runs out and the roadmap stops | The same prompt continues on the next profile or on the fallback runtime |
| Two sessions step on the same file | One worktree per thread, and leases with a queue |
| The docs lie about the code | ork docs verificar fails the PR when a page drifts |
Three layers, and business rules live only in the middle one:
flowchart TB
subgraph L1["Layer 1 - where you conduct"]
A["Claude Code, Codex, Hermes, OpenClaw<br/>adapters with no business rules"]
end
subgraph L2["Layer 2 - the ork core"]
B["schedules, isolates, verifies, serializes, records<br/>deterministic TypeScript, no LLM"]
end
subgraph L3["Layer 3 - who writes the code"]
C["claude-bg and codex runtimes<br/>their own accounts, profile rotation"]
end
A -->|"thread new, phase run, gate, ship"| B
B -->|"assembled prompt, sha256 in the ledger"| C
C -.->|"commits, diffs, command output"| B
B -.->|"summary, question batch, delivery"| A
flowchart LR
G(["GOAL"]) --> P(["PLAN"]) --> GO(["GO"]) --> C(["CHECK"]) --> S(["SHIP"]) --> M(["MASTER"])
C -.->|"failed: GO-FIX with a typed spec"| GO
| Phase | What it delivers | What ork requires to pass |
|---|---|---|
| GOAL | Objective, done criteria, claims with a command | a claim without a command is refused (claims.unverifiable) |
| PLAN | Tasks, decisions, an executable verify per task | a plan without an executable verify does not become GO |
| GO | One atomic commit per task, in the thread's worktree | writing outside the worktree is blocked by a lease |
| CHECK | Verification against the baseline, plus independent CI | a failed claim becomes claims.failed or verify.regression |
| SHIP | Serialized merge with a proven push | only counts when git ls-remote matches the local SHA |
| MASTER | Typed postmortem and a conduction index | the index comes from the ledger; nobody types it |
You write the #TAG in the request itself. The mode changes where the cycle waits for you; verifying the delivery is the same in all four.
| #TAG | Pauses | Cycle | What it is for |
|---|---|---|---|
| #Classic | 3 | GOAL* / PLAN* / GO-CHECK* / SHIP-MASTER | The ork init default: delicate premises |
| #Maestro | 1 | GOAL-PLAN* / GO-CHECK-SHIP / MASTER | A clear, quick solution |
| #Auto | 0 | GOAL-PLAN-GO-CHECK-SHIP-MASTER | Docs, studies, configuration, audits |
| #Fast | 0 | GO | A small, clear request that takes minutes; the push asks for authorization |
* marks the block that pauses. Source of truth: ork modos.#Look and #Ork were retired on 2026-09-24 (RM-043); what was recorded with them stays readable forever.#Fast runs only GO, with a minimal proof: a focused test, a cheap claim or an absence declared in the ledger. It never authorizes a push on its own and never touches a public contract (RM-042).The mode loosens the PAUSE. The mode NEVER loosens the VERIFICATION.
claims.jsonl. There are even negative claims, checked by the command that would falsify them.ork verify --baseline records the world before GO.ork ci prepare exports the claims, and the GitHub ork-verify job re-runs them on the exact SHA.git ls-remote, never by the exit code of git push.ork retry policy. cost.violation never gets an automatic retry.ork decisao registrar keeps the why, how to change it and the cost of changing it now or later; you are informed.owner.timezone, stated once per message.claude-bg (default) and codex, resolved by name in core/src/runtimes.ts; ork setup picks runtime, model and effort per block of each mode.ork accounts add <id> --runtime R --dir D runs the CLI's own login; ork never reads, copies or migrates a credential.cost.violation. Details and responsibilities in SECURITY.md.memory: orkmind, memory goes to the tenant's database; without it, it degrades to files with a typed reason.ork docs verificar: fails a source, symbol, contract or command that no longer exists, a merge commit outside main, and incoherent status.ork docs sincronizar --escrever: writes only facts from the ledger and git (merge, phase) and regenerates tables and indexes.documentacao job runs markdownlint and ork docs verificar on every PR.ork adapter list # the hosts and where each one installs
ork adapter install claude-code --dry-run
| Host | What gets installed |
|---|---|
| claude-code | A plugin with the catalog skills, phase subagents, the /orkastery:ork entry point and hooks |
| codex | The $ork entry point and the conduction catalog as project-local skills |
| hermes | A routing skill, the HITL ingress plugin and thread-opening scripts |
| openclaw | An extension with the ork_* tools, each one a CLI call |
ork modos --do-pedido; the core validates it.| Capability | Status |
|---|---|
| Threads, six phases, four modes, ledger, worktrees, leases, board | works |
Claims, baseline, verify on the real HEAD, 22 typed reasons, GO-FIX | works |
| CI as an independent CHECK on the exact SHA | works; native protection of main depends on the GitHub plan (RM-012) |
ork ship with a proven push | works |
| Account rotation across profiles and runtimes | works; account state is still per project (RM-040) |
| Layered HITL, a–d batch, decisions made and reported | works since 2026-09-24 (RM-041); automatic hourly summary since 2026-09-25 (RM-045) |
| Documentation as code with parity in CI | works (RM-044) |
#Fast mode: one phase, minimal proof, no push on its own | works since 2026-09-27 (RM-042) |
| Roadmap item reservations across machines | works since 2026-09-27 (RM-047); thread state is still per machine |
| Stable local verify on a machine whose CPU the hypervisor steals | does not exist: on such a machine, the reliable proof is CI (RM-037) |
A runtime_reported measurement of the context window | does not exist: claude-bg does not expose context usage, and the gate says unavailable |
| Deterministic code graph | works in the CLI (ork grafo: index, incremental update, queries and thread context, with each edge's provenance), for npm installs since 0.5.3; the MCP tools and the hint in the phase request stay behind the grafo.mcp flag, off by default (RM-031) |
The full picture, item by item and with git evidence, is in the roadmap index.
| Measure | Value | How to check |
|---|---|---|
| Core tests | 2,733, zero failing (CI on 2026-10-03) | npm --prefix core run test:ci |
| Behavior canaries | 24, all green | ork eval --so-canarios |
| Skills corpus | 20 skills, 101 cases, 199 assertions | ork eval --so-skills |
| Product docs and roadmap | zero parity errors | ork docs verificar |
| Runtime dependencies | 9, pinned | core/package.json |
On a virtual machine whose CPU the hypervisor steals (the steal in sar), the local verify can overrun the tests' time windows. That is why the merge gate is the independent CI, and the fix is planned in RM-037.
The docs home is organized by what you want to do now. Every page under docs/ is in Brazilian Portuguese; the CLI reference and the orchestration experience guide also have English versions, and the English quickstart lives on orkastery.com.
| Section | Start with |
|---|---|
| Getting started | Quickstart in English, and the full quickstart in Portuguese: from zero to the first full cycle |
| Guides | Modes, verification, human attention, memory, audits |
| Reference | CLI (the source is ork --help) and contracts |
| Concepts | Overview and architecture |
| Product and roadmap | What exists and what comes next, checked against the code |
Many commands still print Brazilian Portuguese (ork doctor, ork init, ork onboarding, ork experiencia show, ork thread (except thread new --from-finding), ork phase list, ork phase run, ork verify, ork ship, ork pulse, ork master, ork retry plan, ork retry run, ork roadmap (status and reservations), ork worktree, ork claims, ork decisao placar, ork accounts, ork network (except network roadmap), ork docs, ork adapter and the help follow an English locale). These are the words a first run shows.
| The CLI prints | It means |
|---|---|
PRONTO / BLOQUEADO | ready / blocked: the ork doctor verdict |
correcao | the fix for the line above it |
Proximo passo | next step |
VERDADE SUSTENTADA / REPROVADO | the claims held / failed: the ork verify verdict |
AGUARDANDO AUTORIZACAO HUMANA | waiting for your authorization |
Precisa de humano agora | needs a human now: the first line of ork pulse |
Recomendação, opção | recommendation, option |
aceitas por omissao | accepted by default, because nobody scored them |
pendente, respondida | pending, answered |
Horários em <zone> | times are shown in that time zone |
03/10 06:30 is October 3. The commands that follow an English locale print 2026-10-03 06:30.modos (modes), --modo (mode; --mode also works), --para (target branch), --por (who records it), --autorizar-push (authorize the push), --conteudo (content), --verificar (the command that verifies), decisao registrar (record a decision), docs verificar (check the docs), experiencia show (conversation preferences).owner.language: en-US sets the language of the conversation in your host (Claude Code, Codex), of the CLI commands that follow the locale and of what ork init, ork docs init and ork adapter install write for people to read (the orkastery.yaml comments, the AGENTS.md and CLAUDE.md blocks, the docs templates and the skills, checklists, commands and subagents the adapter installs); set it when machines with different locales conduct the same project, or those committed blocks switch language on each run; owner.language: pt-BR keeps them in Portuguese even with LANG=en_US.| Term | Meaning |
|---|---|
| HITL | human in the loop: a question only you can answer, with alternatives and one recommendation |
| ledger | the append-only event log of a thread, in .orkastery/threads/<thread>/ledger.jsonl; ork phase list <thread> prints it |
| claim | a statement, the file it is about and the command that judges it |
| worktree | the separate git checkout of each thread, under .claude/worktrees/ |
| lease | a lock on a shared resource, such as the main tree during a merge, with a queue |
| pulse | the hourly summary of what waits for you: ork pulse |
| maestro | the conversational overview in a host (say orkastery maestro), and the onboarding stage about you |
| GOAL … MASTER | the six phases, in the thread table |
| #Classic, #Maestro, #Auto, #Fast | the conduction modes, in the modes table |
ork docs sincronizar.ork roadmap pegar), and two never take the same one.| Project | What it is |
|---|---|
| orkastery | This repository: the ork core, the adapters, the skills and the auditors |
| OrkMind | The memory layer: Postgres with pgvector, an ontology and tag search; optional |
| orkastery.com | The website and user docs |
| orkmind.com | The OrkMind site |
orkastery maestro in a host with the adapter installed: you get an overview with sources, gaps, threads, sessions, HITL and next actions. In the terminal: ork maestro --json.No metronome ever turned anyone into Bernstein. Mastery is not tempo: it is knowing, after each performance, what you did, what it cost and how well it went, and being better at the next one. That is the phase we call MASTER, and it is why the project is called Orkastery.
hooks/register.ts 206 lines1// O Clima de Cota (RM-059, onda 1, F4). Le as janelas de limite da conta desta sessao a cada medicao do
2// motor, guarda a serie no `$.store` do mod, preve quando cada janela acaba no ritmo atual e avisa na faixa
3// da ponte antes de a fase morrer no meio, com as retomadas que nao cabem (T4.2). So na sessao do dono, so
4// le e so avisa: troca de conta, runtime ou modelo fica com o nucleo (rodizio) e com o dono. O `$.ork` vem
5// da `ork-ponte`; a logica fica nos modulos puros ao lado, e aqui so as chamadas ao `$`, sempre escritas
6// pelo nome do noun e do metodo, como o validador exige.
7import type { Register } from 'claude-code'
8import type { ClimaNaoCabe, ClimaProblema } from '../types'
9import {
10 cruzar, linhasDoCruzamento, marcasDoRelatorio, naoCabemGuardados, paradasDoRetrato, resumoDoClima, retomadasDaFila,
11 textoDoNaoCabe, threadsAbertas, threadsQueNaoCabem,
12} from './contas'
13import {
14 CHAVE_DA_SERIE, emAlerta, MOTIVO_SEM_PONTE, motivoDoErro, prever, problemaDaLeitura, registrarLeituras, TEXTO_SEM_PONTE,
15 textoDaFaixa, textoDoClima, textoForaDoDono,
16} from './previsao'
17
18const MOD = 'ork-clima'
19const FAIXA = { plugin: 'ork-clima', key: 'faixa' } as const
20const NAO_CABEM = { plugin: 'ork-clima', key: 'naoCabem' } as const
21const PROBLEMA = { plugin: 'ork-clima', key: 'problema' } as const
22
23/** O fuso da sessao (D6): a ponte nao entrega o `owner.timezone`. */
24function fusoDaSessao(): string {
25 try {
26 return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
27 } catch {
28 return 'UTC'
29 }
30}
31
32export const register: Register = on => {
33 // O `/clima` fica disponivel fora da sessao de fase (D3), e tambem sem a ponte, para dizer o motivo. A faixa
34 // so sai na primeira medicao: antes de o servidor `orkastery` conectar, a ponte ainda responde `dormente`.
35 // O registro recusado nao segura o inicio da sessao, mas o motivo vai ao transcript (D15).
36 on('session.start', async ($, e, next) => {
37 let papel: string | null = null
38 try {
39 papel = await $.ork.papel()
40 } catch {
41 papel = null
42 }
43 if (papel !== 'fase') {
44 await $.command.register({ name: 'clima', description: 'Previsão da cota da conta desta sessão: quando cada janela acaba no ritmo atual' })
45 .catch(erro => $.ui.log(`Clima de Cota: o /clima não foi registrado (${motivoDoErro(erro)}).`))
46 }
47 return next(e)
48 })
49
50 // Cada medicao: grava a leitura quando as janelas mudaram (D2) e republica a faixa so quando o texto muda (D5).
51 // Com janela em alerta e leitura nova, le a fila inteira de retomada e guarda as que nao cabem (D7, D13); a
52 // medicao sem leitura nova usa o que ficou guardado. So vai ao registro do mostrado o que a faixa mostrou, e o
53 // registro que falhar fica pendente para a medicao seguinte (D15). Fora do papel `dono`, a linha sai (D13).
54 // Uma falha nunca segura a medicao: o motivo novo vai uma vez ao transcript e fica no estado para o `/clima`,
55 // ate uma medicao boa limpa-lo (D15).
56 on('session.measure', async ($, e, next) => {
57 let agora: number | null = null
58 let problema: ClimaProblema | null = null
59 let semLeituraNova = false
60 try {
61 agora = await $.clock.now()
62 let papel: string | null = null
63 try {
64 papel = await $.ork.papel()
65 } catch {
66 papel = null
67 }
68 const anterior = await $.state.get(FAIXA)
69 if (papel === null) {
70 problema = { motivo: MOTIVO_SEM_PONTE, em: agora, daLeitura: false }
71 } else if (papel !== 'dono') {
72 if ((anterior.value ?? null) !== null) {
73 await $.ork.faixa({ mod: MOD })
74 await $.state.set(FAIXA, null, { ifVersion: anterior.version })
75 }
76 } else {
77 const fuso = fusoDaSessao()
78 const leu = e.changed.includes('rateLimits')
79 semLeituraNova = !leu
80 let serie = await $.store.get(CHAVE_DA_SERIE)
81 if (leu) {
82 const motivo = problemaDaLeitura(e.rateLimits)
83 if (motivo !== null) problema = { motivo, em: agora, daLeitura: true }
84 // A medicao sem a lista de janelas nao apaga a serie guardada (D15).
85 if (Array.isArray(e.rateLimits)) {
86 serie = registrarLeituras(serie, e.rateLimits, agora)
87 await $.store.set(CHAVE_DA_SERIE, serie)
88 }
89 }
90 const previsoes = prever(serie, agora)
91 const alerta = previsoes.some(emAlerta)
92 const guardados = await $.state.get(NAO_CABEM)
93 let naoCabem: ClimaNaoCabe[] = alerta ? naoCabemGuardados(guardados.value) : []
94 if (alerta && leu) {
95 try {
96 const fila = retomadasDaFila(await $.ork.ler({ consulta: 'retry.list' }))
97 if (fila !== null) {
98 const antes = naoCabem
99 const lidaEm = agora
100 naoCabem = cruzar(previsoes, fila, lidaEm).filter(d => d.classe === 'nao-cabe').map(d =>
101 antes.find(n => n.id === d.retomada.id)
102 ?? { id: d.retomada.id, thread: d.retomada.thread, texto: textoDoNaoCabe(d, fuso, lidaEm), registrada: false })
103 }
104 } catch {
105 // Fila indisponivel: vale o que a ultima leitura deixou, e o `/clima` diz o motivo quando le a fila.
106 }
107 }
108 let versao = guardados.version
109 if (JSON.stringify(naoCabem) !== JSON.stringify(guardados.value ?? [])) {
110 versao = (await $.state.set(NAO_CABEM, naoCabem, { ifVersion: guardados.version })).version
111 }
112 const threads = naoCabem.map(n => n.thread).filter((t, i, todas) => todas.indexOf(t) === i)
113 const texto = textoDaFaixa(previsoes, fuso, agora, threads)
114 let mostrada = (anterior.value ?? null) === texto
115 if (!mostrada) {
116 const r = await $.ork.faixa(texto === null ? { mod: MOD } : { mod: MOD, texto })
117 if (texto === null || r.publicada) {
118 await $.state.set(FAIXA, texto, { ifVersion: anterior.version })
119 mostrada = true
120 }
121 }
122 if (mostrada && texto !== null && naoCabem.some(n => !n.registrada)) {
123 const registradas: ClimaNaoCabe[] = []
124 for (const n of naoCabem) {
125 if (n.registrada) {
126 registradas.push(n)
127 continue
128 }
129 try {
130 await $.ork.mostrado({ mod: MOD, canal: 'faixa', chave: `clima:nao-cabe:${n.id}`, thread: n.thread, texto: n.texto })
131 registradas.push({ ...n, registrada: true })
132 } catch (erro) {
133 registradas.push(n)
134 if (problema === null) problema = { motivo: `o registro do mostrado na ponte falhou (${motivoDoErro(erro)})`, em: agora, daLeitura: false }
135 }
136 }
137 naoCabem = registradas
138 await $.state.set(NAO_CABEM, naoCabem, { ifVersion: versao })
139 }
140 }
141 } catch (erro) {
142 problema = { motivo: `a medição falhou (${motivoDoErro(erro)})`, em: agora, daLeitura: false }
143 }
144 try {
145 const guardado = await $.state.get(PROBLEMA)
146 const antes = guardado.value ?? null
147 const depois = problema ?? (semLeituraNova && antes !== null && antes.daLeitura ? antes : null)
148 if (JSON.stringify(depois) !== JSON.stringify(antes)) {
149 await $.state.set(PROBLEMA, depois, { ifVersion: guardado.version })
150 // So o motivo novo vai ao transcript: a falha que se repete a cada medicao nao enche a tela.
151 if (depois !== null && depois.motivo !== antes?.motivo) $.ui.log(`Clima de Cota: ${depois.motivo}. O /clima mostra o que ficou guardado.`)
152 }
153 } catch (erro) {
154 $.ui.log(`Clima de Cota: o problema da medição não foi guardado (${motivoDoErro(erro)}).`, { to: 'debug' })
155 }
156 return next(e)
157 })
158
159 // O `/clima` le a serie guardada e nao grava ponto: o uso pedido na hora repete a ultima resposta, e um ponto
160 // com a hora de agora achataria o ritmo (D2). Le a fila, o retrato, as threads abertas e o registro de contas
161 // ao mesmo tempo (D3, D13); leitura que falha vira aviso no texto, o problema da ultima medicao aparece (D15), e
162 // o que foi mostrado vai ao registro da ponte (D8).
163 on('command.run', { command: 'clima' }, async $ => {
164 const agora = await $.clock.now()
165 const fuso = fusoDaSessao()
166 let papel: string
167 try {
168 papel = await $.ork.papel()
169 } catch {
170 return { text: TEXTO_SEM_PONTE }
171 }
172 if (papel !== 'dono') return { text: textoForaDoDono(papel) }
173 const previsoes = prever(await $.store.get(CHAVE_DA_SERIE), agora)
174 const problema = (await $.state.get(PROBLEMA)).value ?? null
175 const ler = (p: Promise<unknown>) => p.then(valor => ({ ok: true as const, valor }), erro => ({ ok: false as const, erro: motivoDoErro(erro) }))
176 const [fila, retrato, abertas, registro] = await Promise.all([
177 ler($.ork.ler({ consulta: 'retry.list' })),
178 ler($.ork.retrato().then(r => r.retrato)),
179 ler($.ork.ler({ consulta: 'thread.list' })),
180 ler($.ork.ler({ consulta: 'accounts.esgotamentos' })),
181 ])
182 const retomadas = fila.ok ? retomadasDaFila(fila.valor) : null
183 const despachos = retomadas === null ? null : cruzar(previsoes, retomadas, agora)
184 // Sem a lista de threads abertas, as paradas nao sao conferidas: o retrato tambem traz thread fechada (D14).
185 const listaDeAbertas = abertas.ok ? threadsAbertas(abertas.valor) : null
186 const linhas = linhasDoCruzamento({
187 despachos, erroDaFila: fila.ok ? null : fila.erro,
188 paradas: retrato.ok && listaDeAbertas !== null ? paradasDoRetrato(retrato.valor, listaDeAbertas) : null,
189 erroDoRetrato: !retrato.ok ? retrato.erro
190 : listaDeAbertas === null ? `sem a lista de threads abertas: ${abertas.ok ? 'fora do formato' : abertas.erro}` : null,
191 marcas: registro.ok ? marcasDoRelatorio(registro.valor) : null, erroDoRegistro: registro.ok ? null : registro.erro,
192 }, previsoes, fuso, agora)
193 const naoCabem = (despachos ?? []).filter(d => d.classe === 'nao-cabe')
194 try {
195 await $.ork.mostrado({ mod: MOD, canal: 'transcript', chave: 'clima:consulta', texto: resumoDoClima(previsoes, threadsQueNaoCabem(naoCabem), fuso, agora) })
196 for (const d of naoCabem) {
197 await $.ork.mostrado({ mod: MOD, canal: 'transcript', chave: `clima:nao-cabe:${d.retomada.id}`, thread: d.retomada.thread,
198 texto: textoDoNaoCabe(d, fuso, agora) })
199 }
200 } catch {
201 // O registro do mostrado e melhor esforco: o texto chega ao dono de qualquer jeito, e cada `/clima` registra de novo.
202 }
203 return { text: textoDoClima(previsoes, fuso, agora, linhas, problema) }
204 })
205}
206hooks/contas.ts 284 lines1// O cruzamento do Clima de Cota (RM-059, onda 1, F4; T4.2): a previsao das janelas da conta desta sessao
2// contra os despachos que dependem de cota (a fila de retomada inteira, de `ork retry list --json`, e as
3// threads abertas paradas por cota, no retrato do nucleo) e contra as marcas de esgotamento por conta do
4// registro compartilhado (RM-040). D7, D8 e D13 a D15 da thread. Modulo puro: le o que a ponte trouxe, classifica
5// e escreve; nenhum `$` passa por aqui.
6import type { ClimaEstado, ClimaNaoCabe, ClimaPrevisao } from '../types'
7import { duracao, emAlerta, formatarHora, rotuloDaJanela, textoDaFaixa } from './previsao'
8
9/** Uma retomada da fila do nucleo que ainda vai despachar. */
10export type ClimaRetomada = {
11 id: string
12 thread: string
13 /** Quando a fila libera, em ms desde a epoca; `null` quando a fila nao disse. */
14 liberaEm: number | null
15 tentativas: number | null
16}
17
18/** Uma thread aberta cujo ultimo gate reprovado foi por cota. */
19export type ClimaParada = { thread: string; motivo: string }
20
21/**
22 * O que o retrato trouxe dos bloqueios: as paradas, se a pagina veio cortada e quantas threads ficaram sem ledger
23 * (`lacunasCortadas` quando o retrato cortou a lista de lacunas e o numero e um piso).
24 */
25export type ClimaParadas = { itens: ClimaParada[]; paginaCortada: boolean; lacunas: number; lacunasCortadas: boolean }
26
27/** Uma marca do registro compartilhado de contas (`ork.esgotamentos/v1`), com a conta opaca. */
28export type ClimaMarca = {
29 conta: string
30 runtime: string
31 estado: string
32 motivo: string
33 esgotadoAte: number | null
34 projeto: string
35}
36
37export type ClimaClasse = 'nao-cabe' | 'cabe' | 'depois-de-renovar'
38
39export type ClimaDespacho = {
40 retomada: ClimaRetomada
41 /** Quando a retomada despacha: a hora de liberar, ou agora se ja liberou. */
42 quando: number
43 classe: ClimaClasse
44 /** A janela que decide a classe; `null` sem janela em alerta. */
45 janela: string | null
46 estadoDaJanela: ClimaEstado | null
47 acabaEm: number | null
48 renovaEm: number | null
49 /** Na classe `cabe`, a cota que sobra no ritmo atual ate o primeiro esgotamento. */
50 folgaMs: number | null
51}
52
53/** O que o `/clima` cruzou: `null` onde a leitura falhou, com o motivo ao lado. */
54export type ClimaCruzamento = {
55 despachos: ClimaDespacho[] | null
56 erroDaFila: string | null
57 paradas: ClimaParadas | null
58 erroDoRetrato: string | null
59 marcas: ClimaMarca[] | null
60 erroDoRegistro: string | null
61}
62
63/** Os motivos de gate que sao de cota: a politica do nucleo espera a janela ou roda o perfil (D7). */
64export const MOTIVOS_DE_COTA: readonly string[] = ['runtime.quota-exhausted', 'runtime.rate-limited']
65export const CONTRATO_DOS_ESGOTAMENTOS = 'ork.esgotamentos/v1'
66/** O que o nucleo ja sabe fazer, com os comandos da propria politica dele; o Clima nunca executa (D7). */
67export const SUGESTOES: readonly string[] = [
68 'esperar a janela: a retomada fica na fila até renovar, e ork retry list mostra a fila;',
69 'seguir em outro perfil: ork accounts list mostra os perfis e quem está esgotado, e ork retry run <thread> segue no próximo perfil do rodízio;',
70 'adiar a fase: segurar o despacho da fase seguinte até a janela renovar.',
71]
72
73const ID_DE_THREAD = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/
74const ID_DE_RETOMADA = /^R[1-9][0-9]{0,8}$/
75const CONTA_OPACA = /^[a-f0-9]{12}$/
76const NAO_REGISTRADO = 'nao registrado'
77const ORDEM_DA_CLASSE: Readonly<Record<ClimaClasse, number>> = { 'nao-cabe': 0, 'cabe': 1, 'depois-de-renovar': 2 }
78
79type Bruto = Record<string, unknown>
80
81function objeto(v: unknown): Bruto | null {
82 return v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Bruto) : null
83}
84
85function texto(v: unknown, teto: number): string | null {
86 return typeof v === 'string' && v.trim() !== '' ? v.replace(/\s+/g, ' ').trim().slice(0, teto) : null
87}
88
89function instante(v: unknown): number | null {
90 const t = typeof v === 'string' ? Date.parse(v) : NaN
91 return Number.isFinite(t) ? t : null
92}
93
94/**
95 * As retomadas que ainda vao despachar, da fila inteira (`ork retry list --json`, D13): a fila e um JSONL so de
96 * acrescimo, entao a ultima gravacao de um id vence, e so o estado `aguardando` conta. `null` fora do formato.
97 */
98export function retomadasDaFila(fila: unknown): ClimaRetomada[] | null {
99 if (!Array.isArray(fila)) return null
100 const porId = new Map<string, Bruto>()
101 for (const bruto of fila) {
102 const p = objeto(bruto)
103 if (p !== null && typeof p.id === 'string' && ID_DE_RETOMADA.test(p.id)) porId.set(p.id, p)
104 }
105 const itens: ClimaRetomada[] = []
106 for (const p of porId.values()) {
107 if (p.estado !== 'aguardando' || typeof p.thread !== 'string' || !ID_DE_THREAD.test(p.thread)) continue
108 const tentativas = p.tentativas
109 itens.push({
110 id: p.id as string, thread: p.thread, liberaEm: instante(p.liberaEm),
111 tentativas: typeof tentativas === 'number' && Number.isInteger(tentativas) && tentativas >= 0 ? tentativas : null,
112 })
113 }
114 return itens
115}
116
117/** As threads abertas, de `ork thread list --json` (que sem `--todas` so lista as nao fechadas); `null` fora do formato. */
118export function threadsAbertas(lista: unknown): string[] | null {
119 const threads = objeto(lista)?.threads
120 if (!Array.isArray(threads)) return null
121 return threads.map(objeto).filter((t): t is Bruto => t !== null && typeof t.id === 'string' && t.status !== 'fechada')
122 .map(t => t.id as string)
123}
124
125function threadDasRefs(refs: unknown): string | null {
126 for (const r of Array.isArray(refs) ? refs : []) {
127 const o = objeto(r)
128 if (o?.kind === 'thread' && typeof o.id === 'string' && ID_DE_THREAD.test(o.id)) return o.id
129 }
130 return null
131}
132
133/**
134 * As threads paradas por cota, da secao `blockers` do retrato (D7), so as abertas quando a lista delas veio
135 * (o retrato tambem traz thread fechada). A pagina cortada e a lacuna de ledger sao ditas a parte (D13).
136 */
137export function paradasDoRetrato(retrato: unknown, abertas: readonly string[] | null): ClimaParadas {
138 const s = objeto(objeto(objeto(retrato)?.sections)?.blockers)
139 const itens: ClimaParada[] = []
140 const lista = s?.items
141 for (const bruto of Array.isArray(lista) ? lista : []) {
142 const i = objeto(bruto)
143 const motivo = objeto(i?.facts)?.reason
144 if (i === null || typeof motivo !== 'string' || !MOTIVOS_DE_COTA.includes(motivo)) continue
145 const thread = threadDasRefs(i.refs)
146 if (thread === null || (abertas !== null && !abertas.includes(thread)) || itens.some(p => p.thread === thread)) continue
147 itens.push({ thread, motivo })
148 }
149 const proxima = objeto(s?.coverage)?.nextOffset
150 const gaps = Array.isArray(s?.gaps) ? (s?.gaps as unknown[]).filter((g): g is string => typeof g === 'string') : []
151 const lacunas = gaps.filter(g => g.startsWith('ledger.unavailable:')).length
152 // O retrato guarda so as primeiras lacunas e conta o resto em `maestro.messages.omitted:N` (D14).
153 return { itens, paginaCortada: typeof proxima === 'number', lacunas, lacunasCortadas: gaps.some(g => g.startsWith('maestro.messages.omitted:')) }
154}
155
156/** As marcas do registro de contas; `null` quando o relatorio nao e do contrato `ork.esgotamentos/v1`. */
157export function marcasDoRelatorio(relatorio: unknown): ClimaMarca[] | null {
158 const r = objeto(relatorio)
159 if (r === null || r.contrato !== CONTRATO_DOS_ESGOTAMENTOS || !Array.isArray(r.marcas)) return null
160 const marcas: ClimaMarca[] = []
161 for (const bruta of r.marcas) {
162 const m = objeto(bruta)
163 if (m === null || typeof m.conta !== 'string' || !CONTA_OPACA.test(m.conta)) continue
164 const runtime = texto(m.runtime, 40)
165 const estado = texto(m.estado, 40)
166 if (runtime === null || estado === null) continue
167 marcas.push({
168 conta: m.conta, runtime, estado, motivo: texto(m.motivo, 80) ?? 'sem motivo',
169 esgotadoAte: instante(m.esgotadoAte), projeto: texto(m.projeto, 80) ?? NAO_REGISTRADO,
170 })
171 }
172 return marcas
173}
174
175/** O trecho sem cota de uma janela em alerta: do esgotamento (ou da leitura, se ja esgotou) ate renovar. */
176function trechoSemCota(p: ClimaPrevisao): { p: ClimaPrevisao; inicio: number; fim: number } | null {
177 if (!emAlerta(p)) return null
178 const inicio = p.estado === 'esgotada' ? p.lidaEm : p.acabaEm
179 if (inicio === null) return null
180 return { p, inicio, fim: p.renovaEm ?? Number.POSITIVE_INFINITY }
181}
182
183/**
184 * Cada retomada contra os trechos sem cota das janelas em alerta (D7): `nao-cabe` quando despacha dentro de
185 * um deles (na hora exata da renovacao a janela ja renovou); `cabe`, com a folga ate o primeiro esgotamento,
186 * quando despacha antes; `depois-de-renovar` quando todos ja renovaram. Sem janela em alerta, `cabe` sem folga.
187 */
188export function cruzar(previsoes: readonly ClimaPrevisao[], retomadas: readonly ClimaRetomada[], agora: number): ClimaDespacho[] {
189 const trechos = previsoes.map(trechoSemCota).filter((t): t is NonNullable<ReturnType<typeof trechoSemCota>> => t !== null)
190 const despacho = (r: ClimaRetomada, quando: number, classe: ClimaClasse, t: (typeof trechos)[number] | undefined,
191 folgaMs: number | null): ClimaDespacho => ({
192 retomada: r, quando, classe, janela: t?.p.kind ?? null, estadoDaJanela: t?.p.estado ?? null,
193 acabaEm: t?.inicio ?? null, renovaEm: t?.p.renovaEm ?? null, folgaMs,
194 })
195 return retomadas.map(r => {
196 const quando = Math.max(agora, r.liberaEm ?? agora)
197 const dentro = trechos.filter(t => quando >= t.inicio && quando < t.fim)
198 if (dentro.length > 0) return despacho(r, quando, 'nao-cabe', dentro.reduce((a, b) => (b.fim > a.fim ? b : a)), null)
199 const antes = trechos.filter(t => quando < t.inicio)
200 if (antes.length > 0) {
201 const primeiro = antes.reduce((a, b) => (b.inicio < a.inicio ? b : a))
202 return despacho(r, quando, 'cabe', primeiro, primeiro.inicio - quando)
203 }
204 if (trechos.length > 0) return despacho(r, quando, 'depois-de-renovar', trechos.reduce((a, b) => (b.fim > a.fim ? b : a)), null)
205 return despacho(r, quando, 'cabe', undefined, null)
206 }).sort((a, b) => ORDEM_DA_CLASSE[a.classe] - ORDEM_DA_CLASSE[b.classe] || a.quando - b.quando)
207}
208
209/** As threads das retomadas que nao cabem, sem repetir, na ordem do cruzamento. */
210export function threadsQueNaoCabem(despachos: readonly ClimaDespacho[]): string[] {
211 const threads: string[] = []
212 for (const d of despachos) if (d.classe === 'nao-cabe' && !threads.includes(d.retomada.thread)) threads.push(d.retomada.thread)
213 return threads
214}
215
216/** Uma retomada no `/clima`. */
217export function textoDoDespacho(d: ClimaDespacho, fuso: string, agora: number): string {
218 const volta = d.quando <= agora ? 'volta agora' : `volta ${formatarHora(d.quando, fuso, agora)}`
219 const quem = `${d.retomada.id} da ${d.retomada.thread} ${volta}`
220 const janela = d.janela === null ? 'a janela' : `a janela de ${rotuloDaJanela(d.janela)}`
221 if (d.classe === 'nao-cabe') {
222 const acaba = d.estadoDaJanela === 'esgotada' || d.acabaEm === null ? 'já esgotou' : `acaba ~${formatarHora(d.acabaEm, fuso, agora)}`
223 const renova = d.renovaEm === null ? 'não tem hora de renovar' : `renova ${formatarHora(d.renovaEm, fuso, agora)}`
224 return `${quem} e não cabe: ${janela} ${acaba} e ${renova}.`
225 }
226 if (d.classe === 'depois-de-renovar') return `${quem}, depois de ${janela} renovar.`
227 if (d.folgaMs !== null) return `${quem}, com ~${duracao(d.folgaMs)} de cota no ritmo atual.`
228 return `${quem}.`
229}
230
231/** As retomadas que nao cabiam, como o estado guardou; a entrada fora do formato fica de fora (D15). */
232export function naoCabemGuardados(valor: unknown): ClimaNaoCabe[] {
233 if (!Array.isArray(valor)) return []
234 return valor.filter((n): n is ClimaNaoCabe => !!n && typeof n.id === 'string' && typeof n.thread === 'string'
235 && typeof n.texto === 'string' && typeof n.registrada === 'boolean')
236}
237
238/** O que vai ao registro do mostrado por retomada que nao cabe: so o que nao muda a cada leitura (D13). */
239export function textoDoNaoCabe(d: ClimaDespacho, fuso: string, agora: number): string {
240 const janela = d.janela === null ? 'na janela' : `na janela de ${rotuloDaJanela(d.janela)}`
241 const renova = d.renovaEm === null ? ', que não tem hora de renovar' : `, que renova ${formatarHora(d.renovaEm, fuso, agora)}`
242 return `${d.retomada.id} da ${d.retomada.thread} não cabe ${janela}${renova}.`
243}
244
245/** O resumo que o `/clima` registra como mostrado (D8): a linha da faixa, ou que nada pede atencao. */
246export function resumoDoClima(previsoes: readonly ClimaPrevisao[], naoCabem: readonly string[], fuso: string, agora: number): string {
247 return textoDaFaixa(previsoes, fuso, agora, naoCabem) ?? 'nenhuma janela pede atenção'
248}
249
250export function textoDaParada(p: ClimaParada): string {
251 return `${p.thread} parada por ${p.motivo}.`
252}
253
254export function textoDaMarca(m: ClimaMarca, fuso: string, agora: number): string {
255 const prazo = m.esgotadoAte === null ? 'sem prazo' : `até ${formatarHora(m.esgotadoAte, fuso, agora)}`
256 const onde = m.projeto === NAO_REGISTRADO ? 'em projeto não registrado' : `em ${m.projeto}`
257 return `conta ${m.conta} (${m.runtime}): ${m.estado} ${prazo}, visto ${onde} (${m.motivo}).`
258}
259
260/** As linhas do cruzamento no `/clima`: fila, paradas, contas do registro e, se algo aperta, as sugestoes. */
261export function linhasDoCruzamento(c: ClimaCruzamento, previsoes: readonly ClimaPrevisao[], fuso: string, agora: number): string[] {
262 const linhas = ['Despachos que dependem da cota (fila de retomada do núcleo):']
263 if (c.despachos === null) linhas.push(`- Fila de retomada indisponível (${c.erroDaFila ?? 'fora do formato'}).`)
264 else if (c.despachos.length === 0) linhas.push('- Nenhuma retomada na fila.')
265 else for (const d of c.despachos) linhas.push(`- ${textoDoDespacho(d, fuso, agora)}`)
266 linhas.push('Threads abertas paradas por cota (retrato do núcleo):')
267 if (c.paradas === null) linhas.push(`- Não deu para conferir as paradas (${c.erroDoRetrato ?? 'sem resposta'}).`)
268 else {
269 if (c.paradas.itens.length === 0) linhas.push('- Nenhuma.')
270 for (const p of c.paradas.itens) linhas.push(`- ${textoDaParada(p)}`)
271 if (c.paradas.paginaCortada) linhas.push('- Os bloqueios vieram em mais de uma página; o Clima leu a primeira.')
272 if (c.paradas.lacunas > 0) {
273 linhas.push(`- O retrato não leu o ledger de ${c.paradas.lacunasCortadas ? 'pelo menos ' : ''}${c.paradas.lacunas} thread(s).`)
274 }
275 }
276 linhas.push('Contas no registro do núcleo (ork accounts esgotamentos):')
277 if (c.marcas === null) linhas.push(`- Registro de contas indisponível (${c.erroDoRegistro ?? 'fora do contrato ork.esgotamentos/v1'}).`)
278 else if (c.marcas.length === 0) linhas.push('- Nenhuma conta marcada no registro.')
279 else for (const m of c.marcas) linhas.push(`- ${textoDaMarca(m, fuso, agora)}`)
280 const aperta = previsoes.some(emAlerta) || (c.despachos ?? []).some(d => d.classe === 'nao-cabe') || (c.paradas?.itens.length ?? 0) > 0
281 if (aperta) linhas.push('O que o núcleo já sabe fazer:', ...SUGESTOES.map(s => `- ${s}`))
282 return linhas
283}
284hooks/previsao.ts 394 lines1// O Clima de Cota (RM-059, onda 1, F4; T4.1): a serie das janelas de limite da conta desta sessao e a
2// previsao de quando cada uma acaba no ritmo atual (D4 a D6 e D12 a D15 da thread). Modulo puro: so dados
3// entram e saem, para o teste do nucleo transpila-lo sem o Claude Code. Nenhum `$` passa por aqui, e o
4// fuso chega explicito: quem chama decide qual e.
5import type { ClimaEstado, ClimaJanela, ClimaPonto, ClimaPrevisao, ClimaProblema, ClimaSerie } from '../types'
6
7export const FORMATO_DA_SERIE = 'ork.clima-serie/v1'
8/** A chave da serie no `$.store` do mod, que dura entre sessoes. */
9export const CHAVE_DA_SERIE = 'serie'
10export const TETO_DE_PONTOS = 120
11export const TETO_DE_JANELAS = 8
12/** Diferenca de `resetsAt` que ainda e a mesma janela. */
13export const TOLERANCIA_DA_RENOVACAO_MS = 60 * 1000
14/** Dentro da janela o uso so cresce: uma queda deste tamanho so acontece quando ela renova. */
15export const QUEDA_QUE_RENOVA = 1
16/** O ritmo sai deste intervalo recente, proporcional a janela de cota (D4). */
17export const INTERVALO_DO_RITMO_MS: Readonly<Record<string, number>> = {
18 five_hour: 60 * 60 * 1000,
19 seven_day: 24 * 60 * 60 * 1000,
20}
21export const INTERVALO_PADRAO_DO_RITMO_MS = 60 * 60 * 1000
22/**
23 * Serie mais nova que o intervalo so tem ritmo com esta fracao dele entre a primeira e a ultima leitura: 10 min
24 * na janela de 5 h e 4 h na de 7 d (D13).
25 */
26export const FRACAO_MINIMA_DO_INTERVALO = 6
27/**
28 * Nestas janelas, a serie mais nova que o intervalo so preve o esgotamento ate a distancia que ela ja observou:
29 * o ritmo de quatro horas de dia util nao vale pela madrugada (D14), e a serie nova no meio da semana ainda
30 * avisa quando o fim esta perto (D15).
31 */
32export const JANELAS_COM_HORIZONTE: readonly string[] = ['seven_day']
33/** A partir deste percentual a faixa avisa mesmo sem ritmo (D5). */
34export const LIMIAR_DE_ATENCAO = 90
35/** Na faixa, o percentual vai em degraus e a hora de acabar vai arredondada, para o texto nao mudar a cada ponto (D13). */
36export const DEGRAU_DO_PERCENTUAL = 10
37export const PASSO_DA_HORA_NA_FAIXA_MS = 15 * 60 * 1000
38/** O texto da faixa nunca passa disto, o mesmo teto da ponte. */
39export const TETO_DA_FAIXA = 200
40/** Quantas threads que nao cabem a faixa nomeia antes do `e mais N`. */
41export const TETO_NA_FAIXA = 3
42export const AVISO_DE_AUTORIDADE = 'O Clima só lê e avisa: troca de conta, de runtime ou de modelo fica com o núcleo e com você.'
43
44const KIND = /^[a-z0-9][a-z0-9_.-]{0,63}$/i
45const ORDEM_DOS_KINDS = ['five_hour', 'seven_day']
46const ROTULOS: Readonly<Record<string, string>> = { five_hour: '5 h', seven_day: '7 d', spend_limit: 'gasto' }
47const GRAVIDADE: Readonly<Record<ClimaEstado, number>> = { 'esgotada': 0, 'acaba-antes': 1, 'folga': 2, 'sem-ritmo': 3 }
48
49function numero(v: unknown): v is number {
50 return typeof v === 'number' && Number.isFinite(v)
51}
52
53/** `5 h`, `7 d`, `gasto` ou o proprio `kind`. */
54export function rotuloDaJanela(kind: string): string {
55 return Object.prototype.hasOwnProperty.call(ROTULOS, kind) ? (ROTULOS[kind] as string) : kind
56}
57
58function ordemDoKind(kind: string): number {
59 const i = ORDEM_DOS_KINDS.indexOf(kind)
60 return i < 0 ? ORDEM_DOS_KINDS.length : i
61}
62
63function porKind(a: { kind: string }, b: { kind: string }): number {
64 return ordemDoKind(a.kind) - ordemDoKind(b.kind) || (a.kind < b.kind ? -1 : a.kind > b.kind ? 1 : 0)
65}
66
67/** O intervalo recente do ritmo de uma janela. */
68export function intervaloDoRitmo(kind: string): number {
69 return Object.prototype.hasOwnProperty.call(INTERVALO_DO_RITMO_MS, kind) ? (INTERVALO_DO_RITMO_MS[kind] as number)
70 : INTERVALO_PADRAO_DO_RITMO_MS
71}
72
73/** Quanto de serie a janela precisa antes de ter ritmo, enquanto e mais nova que o intervalo. */
74export function minimoDaSerieJovem(kind: string): number {
75 return intervaloDoRitmo(kind) / FRACAO_MINIMA_DO_INTERVALO
76}
77
78/**
79 * Ate onde a serie jovem preve, nas janelas com horizonte: o tempo entre a primeira e a ultima leitura. `null`
80 * quando a serie ja cobre o intervalo inteiro ou a janela nao tem horizonte (D15).
81 */
82export function horizonteDaSerie(j: Pick<ClimaJanela, 'kind' | 'pontos'>): number | null {
83 const ultimo = j.pontos[j.pontos.length - 1]
84 const primeiro = j.pontos[0]
85 if (ultimo === undefined || primeiro === undefined || !JANELAS_COM_HORIZONTE.includes(j.kind)) return null
86 return primeiro.em >= ultimo.em - intervaloDoRitmo(j.kind) ? ultimo.em - primeiro.em : null
87}
88
89/** A leitura conferida, ou `null` quando ela nao serve (kind estranho, percentual que nao e numero, data ruim). */
90export function conferirLeitura(bruta: unknown): { kind: string; pct: number; renovaEm: number | null } | null {
91 const l = (bruta ?? {}) as Record<string, unknown>
92 if (typeof l.kind !== 'string' || !KIND.test(l.kind)) return null
93 if (!numero(l.percentUsed) || l.percentUsed < 0 || l.percentUsed > 1000) return null
94 let renovaEm: number | null = null
95 if (l.resetsAt !== undefined && l.resetsAt !== null) {
96 const t = typeof l.resetsAt === 'string' ? Date.parse(l.resetsAt) : NaN
97 if (!Number.isFinite(t)) return null
98 renovaEm = t
99 }
100 return { kind: l.kind, pct: Math.round(l.percentUsed * 10) / 10, renovaEm }
101}
102
103/** A serie guardada, ou uma vazia quando o que esta no store nao e deste formato. */
104export function lerSerie(guardado: unknown): ClimaSerie {
105 const s = guardado as Partial<ClimaSerie> | null | undefined
106 const janelas: ClimaJanela[] = []
107 if (s && s.formato === FORMATO_DA_SERIE && Array.isArray(s.janelas)) {
108 for (const bruta of s.janelas) {
109 const j = (bruta ?? {}) as Partial<ClimaJanela>
110 if (typeof j.kind !== 'string' || !KIND.test(j.kind) || janelas.some(x => x.kind === j.kind)) continue
111 if (!(j.renovaEm === null || numero(j.renovaEm))) continue
112 const pontos = (Array.isArray(j.pontos) ? j.pontos : [])
113 .filter((p): p is ClimaPonto => !!p && numero(p.em) && numero(p.pct))
114 .map(p => ({ em: p.em, pct: p.pct }))
115 .sort((a, b) => a.em - b.em)
116 if (pontos.length > 0) janelas.push({ kind: j.kind, renovaEm: j.renovaEm, pontos: pontos.slice(-TETO_DE_PONTOS) })
117 }
118 }
119 return { formato: FORMATO_DA_SERIE, janelas: janelas.sort(porKind).slice(0, TETO_DE_JANELAS) }
120}
121
122/** A janela ja renovou: a ultima leitura dela nao diz mais nada. */
123export function vencida(j: Pick<ClimaJanela, 'renovaEm'>, agora: number): boolean {
124 return j.renovaEm !== null && j.renovaEm + TOLERANCIA_DA_RENOVACAO_MS <= agora
125}
126
127/** Acrescenta o ponto em ordem de tempo; numa corrida plana ficam so o primeiro e o ultimo ponto. */
128function acrescentar(pontos: readonly ClimaPonto[], novo: ClimaPonto): ClimaPonto[] {
129 const ordenados = [...pontos.filter(p => p.em !== novo.em), novo].sort((a, b) => a.em - b.em)
130 const saida: ClimaPonto[] = []
131 for (const p of ordenados) {
132 const ultimo = saida[saida.length - 1]
133 const penultimo = saida[saida.length - 2]
134 if (ultimo !== undefined && penultimo !== undefined && ultimo.pct === p.pct && penultimo.pct === p.pct) saida[saida.length - 1] = p
135 else saida.push(p)
136 }
137 return saida.slice(-TETO_DE_PONTOS)
138}
139
140/**
141 * O que a medicao trouxe fora do formato, numa frase, ou `null` quando tudo serviu: a lista de janelas que nem e
142 * lista, ou as janelas que `conferirLeitura` descarta. Sem isso, um campo renomeado no motor apagaria o Clima
143 * calado (D15).
144 */
145export function problemaDaLeitura(leituras: unknown): string | null {
146 if (!Array.isArray(leituras)) return 'a medição avisou leitura nova sem a lista de janelas'
147 const descartadas = leituras.filter(l => conferirLeitura(l) === null).length
148 if (descartadas === 0) return null
149 return `a leitura trouxe ${descartadas === 1 ? '1 janela' : `${descartadas} janelas`} num formato que o Clima não conhece`
150}
151
152/**
153 * Grava as leituras de uma medicao em que as janelas mudaram: uma serie por `kind`, serie nova quando a
154 * janela renova (o `resetsAt` muda ou o percentual cai), tetos de pontos e de janelas (D4). A medicao traz
155 * todas as janelas da conta, entao a janela vencida e a que saiu da leitura deixam a serie (D13). Com janela
156 * fora do formato a leitura nao esta completa, e as outras janelas guardadas ficam (D15).
157 */
158export function registrarLeituras(guardado: unknown, leituras: readonly unknown[] | undefined, agora: number): ClimaSerie {
159 const brutas = leituras ?? []
160 const lidas = brutas.map(conferirLeitura).filter((l): l is NonNullable<ReturnType<typeof conferirLeitura>> => l !== null)
161 const anteriores = lerSerie(guardado).janelas.filter(j => !vencida(j, agora))
162 const janelas: ClimaJanela[] = lidas.length === brutas.length ? [] : anteriores.filter(a => !lidas.some(l => l.kind === a.kind))
163 for (const l of lidas) {
164 if (janelas.some(j => j.kind === l.kind)) continue
165 const anterior = anteriores.find(j => j.kind === l.kind)
166 const ultimo = anterior?.pontos[anterior.pontos.length - 1]
167 const renovou = anterior !== undefined && (
168 (anterior.renovaEm !== null && l.renovaEm !== null && Math.abs(anterior.renovaEm - l.renovaEm) > TOLERANCIA_DA_RENOVACAO_MS)
169 || (ultimo !== undefined && ultimo.pct - l.pct >= QUEDA_QUE_RENOVA))
170 const ponto = { em: agora, pct: l.pct }
171 janelas.push(anterior === undefined || renovou
172 ? { kind: l.kind, renovaEm: l.renovaEm, pontos: [ponto] }
173 : { kind: l.kind, renovaEm: l.renovaEm ?? anterior.renovaEm, pontos: acrescentar(anterior.pontos, ponto) })
174 }
175 return { formato: FORMATO_DA_SERIE, janelas: janelas.sort(porKind).slice(0, TETO_DE_JANELAS) }
176}
177
178/**
179 * O ritmo da janela, em pontos por ms, no intervalo recente (D13). Como o uso so cresce, o valor no inicio do
180 * intervalo sai dos pontos que o cercam: exato quando o ultimo antes e o primeiro dentro sao iguais, e por
181 * interpolacao quando nao sao; com leitura esparsa (D12), isso da a inclinacao desde o ultimo ponto antes do
182 * intervalo. Serie mais nova que o intervalo precisa de `minimoDaSerieJovem` entre a primeira e a ultima
183 * leitura; antes disso, nao ha ritmo.
184 */
185export function ritmoDaJanela(j: Pick<ClimaJanela, 'kind' | 'pontos'>): number | null {
186 const ultimo = j.pontos[j.pontos.length - 1]
187 const primeiro = j.pontos[0]
188 if (ultimo === undefined || primeiro === undefined) return null
189 const intervalo = intervaloDoRitmo(j.kind)
190 const inicio = ultimo.em - intervalo
191 const antes = [...j.pontos].reverse().find(p => p.em < inicio)
192 const dentro = j.pontos.find(p => p.em >= inicio)
193 if (antes !== undefined && dentro !== undefined) {
194 const noInicio = antes.pct === dentro.pct ? antes.pct
195 : antes.pct + (dentro.pct - antes.pct) * (inicio - antes.em) / (dentro.em - antes.em)
196 return (ultimo.pct - noInicio) / intervalo
197 }
198 if (ultimo.em - primeiro.em < minimoDaSerieJovem(j.kind)) return null
199 return (ultimo.pct - primeiro.pct) / (ultimo.em - primeiro.em)
200}
201
202/** Pontos por hora com uma casa, ou duas abaixo de 1, para o ritmo lento da semana nao virar zero (D14). */
203function arredondarRitmo(porHora: number): number {
204 return Math.abs(porHora) < 1 ? Math.round(porHora * 100) / 100 : Math.round(porHora * 10) / 10
205}
206
207/** A previsao de cada janela que ainda nao renovou, da mais urgente para a mais calma. */
208export function prever(guardado: unknown, agora: number): ClimaPrevisao[] {
209 const previsoes: ClimaPrevisao[] = []
210 for (const j of lerSerie(guardado).janelas) {
211 const ultimo = j.pontos[j.pontos.length - 1]
212 if (ultimo === undefined || vencida(j, agora)) continue
213 const ritmoPorMs = ritmoDaJanela(j)
214 let estado: ClimaEstado
215 let acabaEm: number | null = null
216 if (ultimo.pct >= 100) estado = 'esgotada'
217 else if (ritmoPorMs === null) estado = 'sem-ritmo'
218 else if (ritmoPorMs <= 0) estado = 'folga'
219 else {
220 acabaEm = Math.round(ultimo.em + (100 - ultimo.pct) / ritmoPorMs)
221 const horizonte = horizonteDaSerie(j)
222 if (j.renovaEm !== null && acabaEm >= j.renovaEm) estado = 'folga'
223 else if (horizonte !== null && acabaEm - ultimo.em > horizonte) {
224 // A serie jovem nao preve alem do que ja observou (D15).
225 estado = 'sem-ritmo'
226 acabaEm = null
227 } else estado = 'acaba-antes'
228 }
229 previsoes.push({
230 kind: j.kind, rotulo: rotuloDaJanela(j.kind), pct: ultimo.pct, lidaEm: ultimo.em, renovaEm: j.renovaEm,
231 ritmoPorHora: ritmoPorMs === null ? null : arredondarRitmo(ritmoPorMs * 3600 * 1000), acabaEm, estado,
232 })
233 }
234 return previsoes.sort((a, b) => GRAVIDADE[a.estado] - GRAVIDADE[b.estado] || porKind(a, b))
235}
236
237/** A janela ja se esgotou ou acaba antes de renovar no ritmo atual. */
238export function emAlerta(p: ClimaPrevisao): boolean {
239 return p.estado === 'esgotada' || p.estado === 'acaba-antes'
240}
241
242/** A janela pede atencao na faixa: em alerta, ou no limiar mesmo sem ritmo (D5). */
243export function pedeAtencao(p: ClimaPrevisao): boolean {
244 return emAlerta(p) || p.pct >= LIMIAR_DE_ATENCAO
245}
246
247type Partes = { ano: string; mes: string; dia: string; hora: string; minuto: string }
248
249function partesLocais(ms: number, fuso: string): Partes {
250 let f: Intl.DateTimeFormat
251 try {
252 f = new Intl.DateTimeFormat('en-US', { timeZone: fuso, year: 'numeric', month: '2-digit', day: '2-digit',
253 hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
254 } catch {
255 f = new Intl.DateTimeFormat('en-US', { timeZone: 'UTC', year: 'numeric', month: '2-digit', day: '2-digit',
256 hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
257 }
258 const p: Record<string, string> = {}
259 for (const parte of f.formatToParts(new Date(ms))) p[parte.type] = parte.value
260 return { ano: p.year ?? '', mes: p.month ?? '', dia: p.day ?? '', hora: p.hour ?? '', minuto: p.minute ?? '' }
261}
262
263/**
264 * A hora no fuso, com o minuto truncado como o nucleo escreve: `16:40` no mesmo dia de `agora`, `13/10 09:00`
265 * em outro dia e `13/10/2027 09:00` em outro ano.
266 */
267export function formatarHora(ms: number, fuso: string, agora: number): string {
268 const p = partesLocais(ms, fuso)
269 const a = partesLocais(agora, fuso)
270 if (p.ano === a.ano && p.mes === a.mes && p.dia === a.dia) return `${p.hora}:${p.minuto}`
271 return `${p.dia}/${p.mes}${p.ano !== a.ano ? `/${p.ano}` : ''} ${p.hora}:${p.minuto}`
272}
273
274/** `45min`, `1h30`, `2d 05h`, como o nucleo escreve; abaixo de 1 min, `menos de 1 min`. */
275export function duracao(ms: number): string {
276 const minutos = Math.floor(Math.abs(ms) / 60000)
277 if (minutos < 1) return 'menos de 1 min'
278 if (minutos < 60) return `${minutos}min`
279 const horas = Math.floor(minutos / 60)
280 if (horas < 24) return `${horas}h${String(minutos % 60).padStart(2, '0')}`
281 return `${Math.floor(horas / 24)}d ${String(horas % 24).padStart(2, '0')}h`
282}
283
284/** `em 1h30` ou `há 10min`, relativo a `agora`. */
285export function distancia(ms: number, agora: number): string {
286 return ms >= agora ? `em ${duracao(ms - agora)}` : `há ${duracao(agora - ms)}`
287}
288
289/** O numero com virgula decimal e no maximo `casas` casas. */
290export function numeroBr(n: number, casas = 1): string {
291 const fator = 10 ** casas
292 return String(Math.round(n * fator) / fator).replace('.', ',')
293}
294
295/** O percentual da faixa em degraus: `70% ou mais`, ou `menos de 10%` (D13). */
296export function percentualEmDegrau(pct: number): string {
297 const degrau = Math.floor(pct / DEGRAU_DO_PERCENTUAL) * DEGRAU_DO_PERCENTUAL
298 return degrau <= 0 ? `menos de ${DEGRAU_DO_PERCENTUAL}%` : `${degrau}% ou mais`
299}
300
301/**
302 * O trecho de uma janela na faixa: percentual em degraus e hora de acabar arredondada para baixo ao quarto de
303 * hora (D13), que assim nunca passa da renovacao (D14). A hora que nao fica depois da leitura vira `acaba em
304 * instantes` (D15).
305 */
306function trechoDaFaixa(p: ClimaPrevisao, fuso: string, agora: number): string {
307 const renova = p.renovaEm === null ? '' : `, renova ${formatarHora(p.renovaEm, fuso, agora)}`
308 if (p.estado === 'esgotada') return `${p.rotulo} esgotou${renova}`
309 if (p.estado === 'acaba-antes' && p.acabaEm !== null) {
310 const acaba = Math.floor(p.acabaEm / PASSO_DA_HORA_NA_FAIXA_MS) * PASSO_DA_HORA_NA_FAIXA_MS
311 const quando = acaba > p.lidaEm ? `acaba ~${formatarHora(acaba, fuso, agora)}` : 'acaba em instantes'
312 return `${p.rotulo} com ${percentualEmDegrau(p.pct)}, ${quando}${renova}`
313 }
314 return `${p.rotulo} com ${percentualEmDegrau(p.pct)}${renova}`
315}
316
317function cortar(texto: string, teto: number): string {
318 return texto.length <= teto ? texto : texto.slice(0, teto - 1) + '…'
319}
320
321/**
322 * A linha do Clima na faixa da ponte, ou `null` quando nenhuma janela pede atencao (a linha sai). As threads
323 * que nao cabem vem primeiro, porque a faixa corta o fim da linha (D13).
324 */
325export function textoDaFaixa(previsoes: readonly ClimaPrevisao[], fuso: string, agora: number, naoCabem: readonly string[] = []): string | null {
326 const trechos = previsoes.filter(pedeAtencao).map(p => trechoDaFaixa(p, fuso, agora))
327 if (trechos.length === 0) return null
328 const nomeadas = naoCabem.slice(0, TETO_NA_FAIXA)
329 const mais = naoCabem.length > nomeadas.length ? ` e mais ${naoCabem.length - nomeadas.length}` : ''
330 const inicio = nomeadas.length > 0 ? `não cabe: ${nomeadas.join(', ')}${mais}; ` : ''
331 return cortar(inicio + trechos.join(' · '), TETO_DA_FAIXA)
332}
333
334/** `às 14:30` hoje, `em 11/10 14:30` em outro dia. */
335function quandoLida(ms: number, fuso: string, agora: number): string {
336 const hora = formatarHora(ms, fuso, agora)
337 return hora.includes('/') ? `em ${hora}` : `às ${hora}`
338}
339
340/** Uma janela no `/clima`, com o ritmo, a previsao, a renovacao, as distancias e a hora da leitura. */
341export function linhaDaJanela(p: ClimaPrevisao, fuso: string, agora: number): string {
342 const ritmo = p.ritmoPorHora === null ? 'ritmo ainda sem leituras suficientes'
343 : `ritmo de ${numeroBr(p.ritmoPorHora, Math.abs(p.ritmoPorHora) < 1 ? 2 : 1)} pontos por hora`
344 let previsao: string
345 if (p.estado === 'esgotada') previsao = 'esgotou'
346 else if (p.estado === 'acaba-antes' && p.acabaEm !== null) {
347 previsao = `acaba ~${formatarHora(p.acabaEm, fuso, agora)} (${distancia(p.acabaEm, agora)}), antes de renovar`
348 } else if (p.estado === 'folga') previsao = 'folga no ritmo atual'
349 else previsao = 'sem previsão ainda'
350 const renova = p.renovaEm === null ? 'sem hora de renovação' : `renova ${formatarHora(p.renovaEm, fuso, agora)} (${distancia(p.renovaEm, agora)})`
351 return `- ${p.rotulo}: ${numeroBr(p.pct)}%, ${ritmo}, ${previsao}, ${renova}. Lida ${quandoLida(p.lidaEm, fuso, agora)}.`
352}
353
354/** O problema da ultima medicao numa linha: `A medicao falhou (...) as 14:30.` (D15). */
355export function textoDoProblema(p: ClimaProblema, fuso: string, agora: number): string {
356 const quando = p.em === null ? '' : ` ${quandoLida(p.em, fuso, agora)}`
357 return `${p.motivo.charAt(0).toUpperCase()}${p.motivo.slice(1)}${quando}.`
358}
359
360/**
361 * O texto do `/clima`: as janelas da conta desta sessao, o problema da ultima medicao, o que vier depois e o aviso
362 * de autoridade. Com problema guardado, a falta de leitura nao e atribuida a conta (D15).
363 */
364export function textoDoClima(previsoes: readonly ClimaPrevisao[], fuso: string, agora: number, extras: readonly string[] = [],
365 problema: ClimaProblema | null = null): string {
366 const linhas = ['Clima de cota da conta desta sessão']
367 if (previsoes.length > 0) {
368 for (const p of previsoes) linhas.push(linhaDaJanela(p, fuso, agora))
369 } else if (problema === null) {
370 linhas.push('Sem leitura de cota ainda. A leitura chega na medição depois de uma resposta da API desta sessão, e só existe em conta de assinatura.')
371 } else {
372 linhas.push('Sem leitura de cota guardada.')
373 }
374 if (problema !== null) linhas.push(textoDoProblema(problema, fuso, agora))
375 return [...linhas, ...extras, AVISO_DE_AUTORIDADE].join('\n')
376}
377
378/** O `/clima` fora da sessao do dono: o Clima so roda nela (D3). */
379export function textoForaDoDono(papel: string): string {
380 const base = `O Clima de Cota só roda na sessão do dono de um projeto do Orkastery; o papel desta sessão é ${papel}.`
381 return papel === 'dormente' ? `${base} Se o servidor orkastery ainda está conectando, tente de novo em instantes.` : base
382}
383
384/** O `/clima` sem a ponte, de quem o Clima depende para tudo; o erro interno nao vai ao dono (D13). */
385export const TEXTO_SEM_PONTE = 'O Clima de Cota depende da ponte ork-ponte, que não respondeu nesta sessão; confira se ela está instalada e carregada.'
386/** O mesmo motivo, como problema da medicao (D15). */
387export const MOTIVO_SEM_PONTE = 'a ponte ork-ponte não respondeu; confira se ela está instalada e carregada'
388
389/** O motivo de um erro em uma linha curta. */
390export function motivoDoErro(erro: unknown): string {
391 const texto = erro instanceof Error ? erro.message : String(erro)
392 return texto.replace(/\s+/g, ' ').trim().slice(0, 200)
393}
394types/index.d.ts 81 lines1// Contrato do Clima de Cota (RM-059, onda 1, F4): os tipos da serie e da previsao das janelas de limite
2// da conta, e o estado do mod. O Clima nao acrescenta noun ao `$`: usa o `$.ork` da `ork-ponte`, que ele
3// lista em `dependencies`. Sem import nem referencia, como o motor exige de um contrato.
4
5/** Um ponto da serie: quando foi lido (ms desde a epoca, relogio do `$.clock`) e o percentual. */
6export type ClimaPonto = {
7 em: number
8 pct: number
9}
10
11/** A serie de uma janela (`five_hour`, `seven_day` ou a de gasto de um gateway), desde que ela renovou. */
12export type ClimaJanela = {
13 kind: string
14 /** Quando renova, em ms desde a epoca; `null` quando o motor nao disse. */
15 renovaEm: number | null
16 pontos: ClimaPonto[]
17}
18
19/** A serie `ork.clima-serie/v1` no `$.store` do mod, que dura entre sessoes. */
20export type ClimaSerie = {
21 formato: 'ork.clima-serie/v1'
22 janelas: ClimaJanela[]
23}
24
25/**
26 * `esgotada`: 100% ou mais. `acaba-antes`: no ritmo atual, acaba antes de renovar. `folga`: o ritmo nao
27 * esgota antes de renovar. `sem-ritmo`: ainda sem leituras para medir o ritmo, ou com uma serie nova demais
28 * para prever tao longe (D15).
29 */
30export type ClimaEstado = 'esgotada' | 'acaba-antes' | 'folga' | 'sem-ritmo'
31
32/** A previsao de uma janela no ritmo atual. */
33export type ClimaPrevisao = {
34 kind: string
35 /** `5 h`, `7 d`, `gasto` ou o proprio `kind`. */
36 rotulo: string
37 /** O percentual da ultima leitura. */
38 pct: number
39 /** Quando a ultima leitura chegou, em ms desde a epoca. */
40 lidaEm: number
41 renovaEm: number | null
42 /** Pontos percentuais por hora no intervalo recente; `null` sem leituras suficientes. */
43 ritmoPorHora: number | null
44 /** Quando acaba no ritmo atual, em ms desde a epoca; `null` sem ritmo que esgote. */
45 acabaEm: number | null
46 estado: ClimaEstado
47}
48
49/** Uma retomada da fila que nao cabe, como a ultima leitura da fila deixou. */
50export type ClimaNaoCabe = {
51 id: string
52 thread: string
53 /** O que vai ao registro do mostrado da ponte (D8). */
54 texto: string
55 /** Se o registro ja aceitou; a que falhou e tentada de novo na medicao seguinte (D15). */
56 registrada: boolean
57}
58
59/** O ultimo problema de uma medicao, que o `/clima` mostra (D15). */
60export type ClimaProblema = {
61 /** O que aconteceu, numa frase que comeca minuscula. */
62 motivo: string
63 /** Quando, em ms desde a epoca; `null` quando nem o relogio respondeu. */
64 em: number | null
65 /** Se veio da leitura: so a proxima leitura boa o limpa. */
66 daLeitura: boolean
67}
68
69declare module 'claude-code' {
70 interface PluginState {
71 'ork-clima': {
72 /** A ultima linha que o Clima publicou na faixa da ponte; `null` depois de tira-la. */
73 faixa: string | null
74 /** As retomadas que nao cabiam na ultima leitura da fila, para as medicoes sem leitura nova (D13). */
75 naoCabem: ClimaNaoCabe[]
76 /** O ultimo problema de medicao, ate uma medicao boa limpa-lo (D15). */
77 problema: ClimaProblema | null
78 }
79 }
80}
81