SLOPSHOPPER

ork-clima

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…

newcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ork-clima
› fix the failing auth test and add an audit log call ● ork-clima: Clima de Cota: a ponte ork-ponte não respondeu; confira se ela está instalada e carregada. O /clima mostra o que ficou guardado. ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /clima ⎿ ork-clima: O Clima de Cota depende da ponte ork-ponte, que não respondeu nesta sessão; confira se ela está instalada e ca ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Orkastery

English · Português

npm MIT license

A software factory of AI agents that proves its work: GOAL, PLAN, GO, CHECK, SHIP and MASTER.

In one sentence: ork conducts 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.

  • Status: in daily use · on npm as @orkastery/cli, with changes per version · reviewed on 2026-09-27
  • Proof: GitHub CI on every PR: 2,733 core tests, 24 canaries and 20 skills with 199 assertions, zero failures (CI of the main at PR #74, run 37102089623, 2026-10-03)
  • Product and roadmap: docs/produto/ and docs/roadmap/, checked against the code by ork docs verificar
  • Language: this page is the canonical entry point, mirrored in Portuguese. The pages under docs/ 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 prints
npm 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

Your first thread

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
  • If 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.
  • A claim is what the agent states plus the command that judges it: ork claims add <thread> <file> --claim "<statement>" --verificar "<command>".

Why it exists

  • Coding agents got good. Conducting agents did not.
  • Three agents in the same repository edit the same files, skip the same steps and hand over a report saying it worked. The report is the part that is worth nothing.
  • Someone is missing between you and them: someone who holds the roadmap, isolates the loops, serializes the merge, re-runs the claim and only interrupts you when it matters.
  • ork takes that seat. It is a deterministic TypeScript CLI, with no server and no embedded LLM.
  • Decisions sized for a full day. Whoever conducts agents is also running dozens of other things. Every question 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.

What changes for builders

Without orkWith ork
The agent says the tests passork 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 dayAn hourly summary says what is pending, urgent, blocking or critical
Every obvious decision becomes a questionThe obvious one comes decided and reported; you change it if you want
The runtime account runs out and the roadmap stopsThe same prompt continues on the next profile or on the fallback runtime
Two sessions step on the same fileOne worktree per thread, and leases with a queue
The docs lie about the codeork docs verificar fails the PR when a page drifts

How it works

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

The thread: six phases no agent skips

flowchart LR
    G(["GOAL"]) --> P(["PLAN"]) --> GO(["GO"]) --> C(["CHECK"]) --> S(["SHIP"]) --> M(["MASTER"])
    C -.->|"failed: GO-FIX with a typed spec"| GO
PhaseWhat it deliversWhat ork requires to pass
GOALObjective, done criteria, claims with a commanda claim without a command is refused (claims.unverifiable)
PLANTasks, decisions, an executable verify per taska plan without an executable verify does not become GO
GOOne atomic commit per task, in the thread's worktreewriting outside the worktree is blocked by a lease
CHECKVerification against the baseline, plus independent CIa failed claim becomes claims.failed or verify.regression
SHIPSerialized merge with a proven pushonly counts when git ls-remote matches the local SHA
MASTERTyped postmortem and a conduction indexthe index comes from the ledger; nobody types it

Four modes, chosen by #TAG

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.

#TAGPausesCycleWhat it is for
#Classic3GOAL* / PLAN* / GO-CHECK* / SHIP-MASTERThe ork init default: delicate premises
#Maestro1GOAL-PLAN* / GO-CHECK-SHIP / MASTERA clear, quick solution
#Auto0GOAL-PLAN-GO-CHECK-SHIP-MASTERDocs, studies, configuration, audits
#Fast0GOA 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.

Truth, not reports

  • A claim is a contract: the statement, the file and the command that judges it, in claims.jsonl. There are even negative claims, checked by the command that would falsify them.
  • The baseline separates regression from debt: ork verify --baseline records the world before GO.
  • Independent CHECK: ork ci prepare exports the claims, and the GitHub ork-verify job re-runs them on the exact SHA.
  • Proven push: by git ls-remote, never by the exit code of git push.
  • Prompt sha256 and the effective trio in the ledger: the runtime, model and effort of each phase are facts, not statements.
  • 22 typed gate reasons, each with its own retry action: ork retry policy. cost.violation never gets an automatic retry.

Layered human attention

  • Hourly summary: how many items are pending, how many are urgent, how many block a thread, how many are critical, and "can I send them now?".
  • Batch: with your yes, up to 5 objective questions at a time, each with alternatives a–d and one recommended; then up to 2 open ones, one at a time.
  • The obvious decision comes made: ork decisao registrar keeps the why, how to change it and the cost of changing it now or later; you are informed.
  • An expired deadline waits or escalates, never approves. An answer only counts with the HMAC proof of the authenticated channel; the agent never answers for you.
  • Times in your time zone: owner.timezone, stated once per message.

Runtimes and accounts

  • Two runtimes: 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.
  • Accounts per profile: ork accounts add <id> --runtime R --dir D runs the CLI's own login; ork never reads, copies or migrates a credential.
  • Rotation: an exhausted quota or a lost login sends the same prompt to the next profile or to the block's fallback; a short rate limit waits in the queue.
  • Subscription only: a paid key in the factory's environment is a cost.violation. Details and responsibilities in SECURITY.md.

Context and memory

  • Token gate: decides between continuing in the session or opening another; without a measurement it does not rotate, and it says so.
  • Triaged handoff: CRITICAL goes inline, IMPORTANT becomes a pointer with the moment to fetch it, SUMMARIZABLE goes with provenance.
  • Optional OrkMind: with memory: orkmind, memory goes to the tenant's database; without it, it degrades to files with a typed reason.

Documentation as code

  • Two versioned standards: product documentation and product roadmap, v1.1.
  • Three readers per page: a person with ADHD (answer first, one idea per line), the parity checker, and AI agents (YAML frontmatter with stable IDs).
  • 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.
  • In CI: the documentacao job runs markdownlint and ork docs verificar on every PR.

Install on your host

ork adapter list                     # the hosts and where each one installs
ork adapter install claude-code --dry-run
HostWhat gets installed
claude-codeA plugin with the catalog skills, phase subagents, the /orkastery:ork entry point and hooks
codexThe $ork entry point and the conduction catalog as project-local skills
hermesA routing skill, the HITL ingress plugin and thread-opening scripts
openclawAn extension with the ork_* tools, each one a CLI call
  • Zero business rules in the host: the #TAG becomes a mode because the host calls ork modos --do-pedido; the core validates it.
  • Installation with a receipt: sha256 per file; a divergence on both sides asks for a decision, file by file.

What exists today, and what does not

CapabilityStatus
Threads, six phases, four modes, ledger, worktrees, leases, boardworks
Claims, baseline, verify on the real HEAD, 22 typed reasons, GO-FIXworks
CI as an independent CHECK on the exact SHAworks; native protection of main depends on the GitHub plan (RM-012)
ork ship with a proven pushworks
Account rotation across profiles and runtimesworks; account state is still per project (RM-040)
Layered HITL, a–d batch, decisions made and reportedworks since 2026-09-24 (RM-041); automatic hourly summary since 2026-09-25 (RM-045)
Documentation as code with parity in CIworks (RM-044)
#Fast mode: one phase, minimal proof, no push on its ownworks since 2026-09-27 (RM-042)
Roadmap item reservations across machinesworks since 2026-09-27 (RM-047); thread state is still per machine
Stable local verify on a machine whose CPU the hypervisor stealsdoes not exist: on such a machine, the reliable proof is CI (RM-037)
A runtime_reported measurement of the context windowdoes not exist: claude-bg does not expose context usage, and the gate says unavailable
Deterministic code graphworks 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.

Verified in this tree

MeasureValueHow to check
Core tests2,733, zero failing (CI on 2026-10-03)npm --prefix core run test:ci
Behavior canaries24, all greenork eval --so-canarios
Skills corpus20 skills, 101 cases, 199 assertionsork eval --so-skills
Product docs and roadmapzero parity errorsork docs verificar
Runtime dependencies9, pinnedcore/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.

Documentation

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.

SectionStart with
Getting startedQuickstart in English, and the full quickstart in Portuguese: from zero to the first full cycle
GuidesModes, verification, human attention, memory, audits
ReferenceCLI (the source is ork --help) and contracts
ConceptsOverview and architecture
Product and roadmapWhat exists and what comes next, checked against the code

Reading the CLI in English

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 printsIt means
PRONTO / BLOQUEADOready / blocked: the ork doctor verdict
correcaothe fix for the line above it
Proximo passonext step
VERDADE SUSTENTADA / REPROVADOthe claims held / failed: the ork verify verdict
AGUARDANDO AUTORIZACAO HUMANAwaiting for your authorization
Precisa de humano agoraneeds a human now: the first line of ork pulse
Recomendação, opçãorecommendation, option
aceitas por omissaoaccepted by default, because nobody scored them
pendente, respondidapending, answered
Horários em <zone>times are shown in that time zone
  • Dates are day/month in Portuguese output: 03/10 06:30 is October 3. The commands that follow an English locale print 2026-10-03 06:30.
  • Commands and flags in Portuguese: 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.
TermMeaning
HITLhuman in the loop: a question only you can answer, with alternatives and one recommendation
ledgerthe append-only event log of a thread, in .orkastery/threads/<thread>/ledger.jsonl; ork phase list <thread> prints it
claima statement, the file it is about and the command that judges it
worktreethe separate git checkout of each thread, under .claude/worktrees/
leasea lock on a shared resource, such as the main tree during a merge, with a queue
pulsethe hourly summary of what waits for you: ork pulse
maestrothe conversational overview in a host (say orkastery maestro), and the onboarding stage about you
GOAL … MASTERthe six phases, in the thread table
#Classic, #Maestro, #Auto, #Fastthe conduction modes, in the modes table

Built with itself

  • Orkastery is built with Orkastery: every change starts in a thread, with claims that CI re-runs on the exact SHA before the merge.
  • Each thread's ledger stays on the machine that conducts it; what reaches this repository is the PR with the proof and the roadmap synced from git by ork docs sincronizar.
  • More than one machine works in parallel: before starting, each one reserves the roadmap item (ork roadmap pegar), and two never take the same one.

Ecosystem

ProjectWhat it is
orkasteryThis repository: the ork core, the adapters, the skills and the auditors
OrkMindThe memory layer: Postgres with pgvector, an ontology and tag search; optional
orkastery.comThe website and user docs
orkmind.comThe OrkMind site

Maestro in the conversation (code delivered, live activation pending)

  • Say 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.
  • The query does not open work and does not grant a gate.
  • Top priority: HITL usability. Questions in short bullets, with a recommendation and clear options; the UUID stays internal.
  • Telegram is optional. There is no web control panel: Orkastery conducts in the conversation, and OrkMind keeps the knowledge.
  • Status: code package delivered on 2026-09-19 (PR #13). Proof of activation in a new session per host is still pending (RM-032).

Credits and license

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.

Source 4 files
hooks/register.ts 206 lines
1// 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}
206
hooks/contas.ts 284 lines
1// 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}
284
hooks/previsao.ts 394 lines
1// 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}
394
types/index.d.ts 81 lines
1// 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