Metadata-Version: 2.4
Name: hobots
Version: 4.1.0
Summary: SDK Python da Hobots para RPAs e integrações — tudo é uma task: register() para execuções sob demanda (solicitações do app) e start() para robôs sempre ligados, com logs, itens, progresso, prints, erros e heartbeat. Sem init().
Project-URL: Homepage, https://hobots.app
Author: Hobots
License: MIT License
        
        Copyright (c) 2026 Hobots
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
License-File: LICENSE
Keywords: automation,hobots,monitoring,rpa,tasks,telemetry
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: System :: Monitoring
Classifier: Typing :: Typed
Requires-Python: >=3.9
Provides-Extra: dev
Requires-Dist: coverage>=7.0; extra == 'dev'
Requires-Dist: mypy>=1.10; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Description-Content-Type: text/markdown

# hobots

SDK Python da **Hobots** para robôs (RPAs) e integrações. **O agente é a própria tarefa**: o seu programa é o agente, ele faz uma coisa, e a plataforma passa a receber logs, itens processados, progresso, prints, erros e o "estou vivo" do robô.

- **`hobots.register(handler)`** — **modo sob demanda**: o robô fica online aguardando **solicitações** criadas no app (formulário do agente e agendamentos) e executa o handler a cada uma. Módulo **Trabalhos**.
- **`hobots.start(handler)`** — **modo sempre ligado**: o robô abre a própria execução ao iniciar e roda o handler (o seu loop) dentro dela; a plataforma só recebe logs, itens e progresso. Sem formulário, sem solicitante.

Uma chamada por processo: `register` **ou** `start`. Não há slug nem nome de task — o que o robô faz é o próprio agente, identificado pelo Client Secret.

Cliente HTTP puro, **sem dependências de runtime** (só a stdlib). Requer **Python 3.9+**. A API é **síncrona**: handlers são funções normais (`def`) e o SDK administra as threads de fundo — combina com Selenium, Playwright síncrono, pyautogui e código legado.

> **Todas as durações são em milissegundos**, como no SDK Node: `retry_delay=5000` são 5 segundos, `hobots.sleep(2000)` dorme 2 segundos. Os nomes não levam sufixo `_ms`.

---

## Instalação

```bash
pip install hobots
```

```python
import hobots  # o próprio módulo é o objeto global
```

---

## Em 30 segundos

```bash
export HOBOTS_CLIENT_SECRET=hb_<64 hex>   # Configurações → Agentes
export HOBOTS_INSTANCE_ID=vm-cliente-01   # opcional — default: hostname da máquina
export HOBOTS_ENVIRONMENT=staging         # opcional — default: production
```

```python
import hobots


# sob demanda: uma execução por solicitação do app (formulário do agente ou agendamento)
def processar_nfe(params, ctx):
    hobots.log.info(f"lote {params['lote']}")
    hobots.progress.total(len(params["notas"]))
    for nota in params["notas"]:
        baixar(nota)
        hobots.items.succeeded({"numero_nota": nota}, id=nota)
        hobots.progress.advance()


hobots.register(processar_nfe)
# volta na hora; o processo fica vivo (heartbeat) até hobots.close() ou Ctrl-C —
# encerramento gracioso em "Encerramento", abaixo
```

Um robô que vive rodando usa `start` no lugar de `register` — o handler é o loop:

```python
def monitor_pasta(params, ctx):  # params == {} no modo start
    while True:
        hobots.sleep(60_000)  # cancelável
        for arquivo in novos_arquivos():
            with hobots.transaction.start_child("arquivo", arquivo):
                processar(arquivo)
            hobots.items.succeeded({"arquivo": arquivo})
        hobots.log.info("ciclo ok")
        hobots.flush()  # envia as etapas, itens e logs deste ciclo agora


hobots.start(monitor_pasta)
```

**Envie as etapas quando o bot processar itens.** No modo start a execução dura dias, e a transaction dela só fecharia quando o robô parasse — até lá as steps não chegariam à Central. `hobots.flush()` envia **agora** as steps terminadas (como transactions parciais, op `hobots.run.flush`, em lotes de até 200), drena o buffer de itens e os logs/progresso pendentes, esperando a entrega por até `timeout` ms (default 15 000). Chame ao fim de cada ciclo ou lote de itens. Steps ainda abertas ficam para o próximo `flush()`; o fim da execução não repete o que já foi enviado.

O **módulo `hobots`** é o objeto global: `hobots.log`, `hobots.items`, `hobots.progress`, `hobots.transaction`… resolvem a execução **atual** de qualquer módulo do programa — sem passar contexto de mão em mão, mesmo com várias execuções em paralelo.

---

## Credenciais e identidade

| Env | Opção | O que é |
| --- | --- | --- |
| `HOBOTS_CLIENT_SECRET` | `client_secret` | O **Client Secret do agente** (`hb_<64 hex>`), copiado em **Configurações → Agentes**. Viaja só no header `Authorization: Bearer`, nunca na URL. Ausente ou inválido → `register()`/`start()` levanta `ValueError` na hora, citando a env (sem ecoar o valor). |
| `HOBOTS_INSTANCE_ID` | `instance_id` | Identidade **estável** desta instância do robô (`vm-cliente-01`). Sem ela, o hostname. Heartbeat e execuções convergem nela: **um agente só** no painel. |
| `HOBOTS_ENVIRONMENT` | `environment` | Ambiente lógico do robô (`development` \| `testing` \| `staging` \| `production`). Sem ela, `production`. |
| `HOBOTS_API_URL` | — | Só em desenvolvimento: aponta o SDK para outro host (`http://localhost:3001`). Em produção é sempre `https://api2.hobots.app`. |

A opção passada em `register()`/`start()` tem precedência sobre a env — útil para copiar e colar o snippet do app. O secret nunca aparece em `repr()` do SDK.

---

## Os dois modos

| | `register(handler, **opções)` | `start(handler, **opções)` |
| --- | --- | --- |
| Quem dispara | Uma **solicitação** no app (formulário do agente, ou agendamento) | O próprio robô, ao iniciar |
| `params` do handler | O dict validado do formulário | `{}` |
| Quantas execuções | Uma por solicitação; `concurrency` em paralelo (1..5) | **Uma só** — o handler é o seu loop; quando ele retorna, a execução fecha |
| O processo | Fica vivo fazendo polling | Fica vivo pelo heartbeat, mesmo depois do handler retornar |
| Reexecutar pelo app | Sim | Não — só reiniciando o robô |
| No painel | Instância "sob demanda" | Instância "sempre ligado" |

As duas chamadas **voltam na hora**: o polling e o handler do modo start rodam em threads de fundo. **Um processo = um agente = uma chamada.** A segunda chamada a `register()`/`start()` levanta `RuntimeError` (`o agente já foi iniciado`). Um robô que precisa monitorar uma pasta *e* atender solicitações são dois agentes — dois processos, cada um com o seu Client Secret.

O handler é sempre `handler(params, ctx)` — dois argumentos posicionais, também no modo start (onde `params` é `{}`). O retorno é ignorado.

> **Modo start e o limite de logs:** o servidor guarda no máximo **1000 linhas de log por execução**. Num loop que roda por dias, logue o que importa (ciclos com novidade, avisos, erros) e registre o trabalho em `hobots.items` — o SDK não limita itens por execução (quem limita é a cota de armazenamento do projeto no servidor). Chame `hobots.flush()` a cada ciclo para as etapas, os itens e os logs chegarem à Central sem esperar o robô parar.

---

## O módulo `hobots`

| Membro | O que faz |
| --- | --- |
| `hobots.log.debug/info/warn/error/success(msg, *rest, image=None)` | Log da execução, enviado incrementalmente ao app (e ecoado no terminal como `[hobots] INFO …`). Aceita vários argumentos como `print()` — `hobots.log.info("nota", numero, "ok")` — cada um vira texto, unidos por espaço. `image=` pendura um print na linha. **Nunca levanta** — pode ir em `finally`. |
| `hobots.items.succeeded(payload=None, *, id=None, image=None)` · `.failed(msg, payload=None, *, id=None, image=None)` · `.occurrence(msg, …)` · `.add(status, …)` | **A entrega do robô**: cada item processado, com status e atributos (viram colunas no app — declare-as com a opção `items={"columns": [...]}` do `register`/`start`). `image=` pendura um print no item. Bufferizado, em lotes. |
| `hobots.items.each(lista, handler, *, id=…, payload=…)` | **Loop com o contrato garantido**: cada elemento vira step + item; exceção no handler = item `failed` com a mensagem e o loop **segue** até o fim; progresso automático. Só cancelamento/timeout interrompem. |
| `hobots.progress.total(n)` · `.advance(k=1)` · `.set(atual, total=None)` · `.current` · `.snapshot` | Barra, percentual e tempo restante na execução. Sem `total()` nada é enviado. |
| `hobots.transaction.start_child(op, descrição)` | Uma **step** no waterfall de performance da execução (também como `with`). A transaction da execução (`run`) já existe e é finalizada pelo SDK. `step.set_status(status, msg=None, image=…)` / `set_error(err, msg=None, image=…)` penduram um print na step. |
| `hobots.image(bytes \| caminho, *, caption=None, content_type=None)` | Embrulha um **print** (PNG/JPEG/WEBP, até 5 MB, 20 por execução) para ir em `image=` de um log, item ou step. Puro: o upload roda em segundo plano quando o evento é registrado; nunca levanta nem bloqueia. |
| `hobots.capture_exception(erro, hint=None)` | Abre uma **issue** (módulo Issues). `hint["attachment"]` anexa um print à linha de log correspondente. |
| `hobots.retry(fn, *, retries=3, delay=1000, should_retry=None)` | Retry ciente do cancelamento da execução. `fn` recebe o número da tentativa; `delay` aceita `callable(tentativa) -> ms`. |
| `hobots.sleep(ms)` | Espera **cancelável**: levanta `RunCanceled` se a execução for cancelada. Use no lugar de `time.sleep()`. |
| `hobots.flush(timeout=15_000)` | Envia **agora** o que a execução acumulou: steps terminadas (transactions parciais), itens e logs/progresso pendentes; espera a entrega até `timeout` ms. Para o modo start, a cada ciclo. Nunca levanta; fora de uma execução não faz nada. |
| `hobots.signal` · `hobots.raise_if_canceled()` | Cancelamento cooperativo manual (`signal` é um `threading.Event`), para controle fino. |
| `hobots.run_id` · `hobots.params` | Identidade da execução atual. |
| `hobots.register(...)` · `hobots.start(...)` · `hobots.close(timeout=30_000)` | Ciclo de vida do processo. |

O handler também recebe o mesmo contexto como 2º argumento (`ctx.log`, `ctx.items`, `ctx.progress`, `ctx.transaction`, `ctx.signal`, `ctx.params`, `ctx.sleep`, `ctx.retry`, `ctx.raise_if_canceled`, `ctx.flush`), para quem prefere explícito. Fora de qualquer execução, os membros de contexto caem em fallbacks seguros e avisam uma vez — `hobots.log` continua imprimindo no terminal.

> **Evite `from hobots import log`** (ou `items`, `progress`…) no topo de um módulo: o valor capturado no import é o de fora de execução. Importe o módulo e chame `hobots.log.info(...)` — cada acesso resolve a execução atual.

---

## Cancelamento automático

Uma execução cancelada no app **não mata** o handler — o SDK dispara `hobots.signal` e o próximo ponto de parada levanta **`RunCanceled`**:

- `hobots.items.*`, `hobots.progress.total/advance/set`, `hobots.transaction.start_child()` (e `step.start_child()`), `hobots.sleep()` e cada tentativa de `hobots.retry()`.
- **Nunca `hobots.log.*`** — um log dentro de um `finally` de limpeza não pode explodir.

Num loop de itens, isso costuma bastar: registrar o item processado já é o ponto de parada. Para controle fino, `hobots.raise_if_canceled()` e `hobots.signal.is_set()` continuam disponíveis. Quando o cancelamento veio do app, o `RunCanceled` é **suprimido** — a execução já está `cancelada` no servidor e nada é reportado por cima. Limpeza (fechar navegador, desfazer estado) vai em `try/finally`.

> **Atenção:** não use `if hobots.signal.is_set(): return`. Retornar normalmente reporta a execução como **`succeeded`**. É preciso **levantar** — e é o que os pontos de parada fazem. Se capturar o `RunCanceled` para limpar, **re-levante**.

---

## Retry

Quando o handler levanta, o SDK **tenta de novo na mesma execução**: aviso no log (`tentativa 1/2 falhou: … — nova tentativa em 5000ms`), espera cancelável, re-invocação com os mesmos `params`. Default: **1 tentativa extra** com 5 s. Itens e progresso já enviados permanecem — escreva handlers **idempotentes** (use o `id` de negócio nos itens: repetido, o servidor atualiza em vez de duplicar).

```python
hobots.register(handler, retries=3, retry_delay=lambda tentativa: tentativa * 10_000)
```

Nunca há nova tentativa em **cancelamento**, **timeout** (`TaskTimeout` é terminal) ou execução já cancelada no app. Para uma chamada instável dentro do handler, use `hobots.retry()`.

---

## Opções

```python
hobots.register(handler, *, client_secret=None, ..., concurrency=None, poll_interval=None)
hobots.start(handler, *, client_secret=None, ...)
```

Todas as opções são **keyword-only** e são **do agente** — só há uma chamada por processo, então não existe "opção de processo" separada da "opção da task".

| Opção | Default | Descrição |
| --- | --- | --- |
| `client_secret` | env `HOBOTS_CLIENT_SECRET` | Client Secret do agente |
| `instance_id` | env `HOBOTS_INSTANCE_ID` → hostname | Identidade da instância |
| `environment` | env `HOBOTS_ENVIRONMENT` → `"production"` | `development` \| `testing` \| `staging` \| `production` — decide quem é alertado |
| `release` | — | Versão do programa, ex. `robo-nfe@1.4.2` |
| `tags` | — | Tags de todos os eventos |
| `debug` | `False` | Loga a atividade do SDK |
| `machine_metrics` | `True` | CPU/memória/disco no heartbeat e em cada evento |
| `capture_unhandled` | `True` | `sys.excepthook`/`threading.excepthook` viram issues |
| `items` | — | `{"columns": [...]}` — colunas da aba de itens no app (uma declaração, do agente) |
| `retries` | `1` | Tentativas extras do handler (`0` desliga) |
| `retry_delay` | `5000` | ms fixo ou `callable(tentativa) -> ms` |
| `timeout` | — (infinito) | Timeout por tentativa, em ms; terminal (`TaskTimeout`). O handler não é morto: o signal dispara e o slot é liberado |
| `concurrency` (register) | `1` | Execuções simultâneas do agente (1..5) |
| `poll_interval` (register) | `2000` | Intervalo entre polls sem trabalho (mín. `1000`) |
| avançadas: `install_exit_handlers`, `log_flush_interval`, `cancel_check_interval`, `cancel_grace` | `True`, `2000`, `5000`, `10000` | Ver [Referência de API](./docs/api-reference.md) |

---

## Encerramento

O heartbeat mantém o processo vivo por design — é a única thread **não daemon** do SDK, o que faz o script continuar rodando depois da última linha. `hobots.close(timeout=30_000)` para o polling, aguarda as execuções em voo, para o heartbeat (libera o processo) e faz o flush final; devolve `False` se algo não terminou a tempo.

Para um encerramento **gracioso** no Ctrl-C/SIGTERM (esperar a execução em andamento terminar), instale o seu handler de sinal e segure a main thread até ele disparar:

```python
import signal
import threading

import hobots

parar = threading.Event()
signal.signal(signal.SIGINT, lambda signum, frame: parar.set())
signal.signal(signal.SIGTERM, lambda signum, frame: parar.set())

hobots.register(processar_nfe)

while not parar.wait(1):  # o timeout mantém o Ctrl-C responsivo também no Windows
    pass
hobots.close(15_000)  # espera a execução em voo, para o heartbeat e faz o flush
```

Com handler próprio de sinal, o SDK não interfere. **Sem** ele, um `SIGINT`/`SIGTERM` com execuções ativas reporta cada uma como `failed`/`AgentTerminated`, faz o flush da telemetria e o processo sai com 130/143 — o app mostra a falha na hora. Uma exceção não tratada na main thread também encerra o processo (exit 1), mesmo com o heartbeat vivo.

> Não chame `sys.exit()` de dentro do handler de sinal quando o script principal já terminou: o `SystemExit` cai dentro do `threading._shutdown` do interpretador ("Exception ignored in…"). O padrão acima — sinal só seta um `Event`, a main thread chama `close()` — evita isso.

> **Dados sensíveis:** logs, itens, mensagens de erro e prints são enviados ao servidor e ficam visíveis no app. O SDK redige padrões óbvios de segredo (`hb_…`, Bearer, `sk-…`, credenciais em URL), mas não borra prints nem filtra dados pessoais. Capture só o necessário.

---

## Migrando da 3.x

A 4.0 tira o nome da task: **o agente é a própria tarefa**. Um processo é um agente, um agente faz uma coisa.

| 3.x | 4.0 |
| --- | --- |
| `hobots.register("processar-nfe", handler, **opções)` | `hobots.register(handler, **opções)` |
| `hobots.start("monitor", handler, **opções)` | `hobots.start(handler, **opções)` |
| `start("monitor", …)` + `register("x", …)` no mesmo processo, ou vários `register()` | **Um por processo** — separe em agentes (um processo e um Client Secret cada); a 2ª chamada levanta `RuntimeError` |
| `hobots.task` · `ctx.task` | Removidos — o agente não tem nome no código |
| Formulário com `task_slug` | **Um** formulário por agente (Configurações → Agentes); os `params` chegam iguais |
| `items={"columns": …}` por task; `concurrency` por task | Os mesmos, mas **do agente** (uma declaração) |
| Opções de processo "da primeira chamada"; `poll_interval` "do primeiro `register()`" | Não existe mais — todas as opções são as da única chamada |
| `TaskHandler` | `RunHandler` (`InstanceMode` e `ItemsDeclaration` entram no `__all__`) |
| `[hobots:<slug>] INFO …` no terminal | `[hobots] INFO …` |
| `/v1/tasks/poll`, `/v1/tasks/runs…` (só quem fala o wire direto) | `/v1/runs/poll`, `/v1/runs…`; `instance.tasks`/`task_items` → `instance.mode`/`instance.items` |

Antes (3.x):

```python
hobots.register("processar-nfe", processar, concurrency=2, items={"columns": ["numero_nota"]})
```

Depois (4.0):

```python
hobots.register(processar, concurrency=2, items={"columns": ["numero_nota"]})
```

---

## Diferenças em relação ao SDK Node

Mesmo protocolo, mesmos defaults, nomes em `snake_case`. O que muda por causa da linguagem:

- **Boot síncrono:** o `register()`/`start()` configura o processo na hora (no Node, num microtask).
- **Sem `globalThis`:** `import hobots` já é o objeto global — os membros de contexto são resolvidos por `__getattr__` do módulo, com `contextvars` no lugar do `AsyncLocalStorage`.
- **`hobots.signal` é um `threading.Event`** em vez de `AbortSignal`.
- **Threads próprias do handler** não herdam o contexto da execução. Com uma execução só ativa, `hobots.*` ainda a encontra; com `concurrency > 1`, passe o `ctx` ou rode a thread com `contextvars.copy_context().run(...)`.
- **Requisições em voo não são interrompíveis** (`urllib` não tem `AbortSignal`): um `close()` durante um poll espera a resposta e devolve à plataforma, como `AgentTerminated`, qualquer run que ele ainda entregue.

### Changelog resumido

- **4.1.0** — `hobots.flush()` (e `ctx.flush()`): envia as steps terminadas como transactions parciais (op `hobots.run.flush`), os itens e os logs pendentes sem esperar o fim da execução — para o modo start; sem teto de itens por execução no SDK (só a cota do servidor); o `finish()` da transaction quebra mais de 200 steps em várias transactions em vez de truncar.
- **4.0.0** — **o agente é a própria tarefa** (breaking): `register(handler, **opções)`/`start(handler, **opções)` **sem slug**; **uma chamada por processo** (a segunda levanta `RuntimeError`); `hobots.task`/`ctx.task` removidos; `TaskHandler`→`RunHandler`, `InstanceMode` e `ItemsDeclaration` exportados; wire: `InstanceInfo.mode` (`on_demand` \| `always_on`) e `InstanceInfo.items` no lugar de `tasks`/`task_items`, endpoints em `/v1/runs/*`; eco no terminal `[hobots] INFO …`. Guia de migração acima e no [CHANGELOG](./CHANGELOG.md).
- **3.3.0** — `hobots.attach()` removido; o print vai junto do evento pelo kwarg `image=` em `log.*`, itens e steps.
- **3.1.0** — `hobots.log.*` aceita vários argumentos (como `print`); eco de transaction/steps no terminal só com `debug=True`; `None` dentro de `extra`/`payload`/`params`/`metadata` viaja como `null`; heartbeat com jitter de ±10% e `interval_ms` clampado à faixa do servidor; fila da telemetria nunca descarta heartbeat; prazos do reporter (flush, progresso, heartbeat de cancelamento) valem mesmo com o handler num loop apertado; logs emitidos depois do fim da execução seguem como log tardio; `close()` nunca deixa uma run reivindicada sem dono; PATCH terminal nunca passa de 100 logs; `progress.set(inf)`/`advance` concorrente corrigidos; tipos `Environment`/`EnvironmentInput` e todos os tipos do protocolo exportados.

---

## Documentação

- [Getting started](./docs/getting-started.md) — instalação, envs, o primeiro agente nos dois modos, encerramento.
- [Execuções](./docs/runs.md) — handler, `hobots.*`, itens, progresso, anexos, cancelamento, retry, concorrência, idempotência.
- [Conceitos](./docs/concepts.md) — o vocabulário: agente, execução, evento, transaction, step, heartbeat, instância, claim…
- [Referência de API](./docs/api-reference.md) — assinaturas, tabelas de opções e tempos.
- [Protocolo & transporte](./docs/protocol.md) — endpoints, envelope, retries, claim.
- [Exemplos](./examples) — scripts executáveis, um por funcionalidade.

## Desenvolvimento

```bash
pip install -e ".[dev]"
pytest            # sem rede (servidor HTTP local nas fixtures)
ruff check . && mypy src
coverage run -m pytest && coverage report   # reprova abaixo de 97% (linhas + branches)
```

### Publicação

0. **Rode a suíte** — não publique com ela vermelha:
   ```bash
   pytest && ruff check . && mypy src && coverage run -m pytest && coverage report
   ```
1. **Ajuste a versão** em `src/hobots/__about__.py` (semver).
2. **Construa o pacote**:
   ```bash
   python3 -m pip install build twine
   python3 -m build
   ```
3. **Publique no PyPI** (requer credenciais em `~/.pypirc`):
   ```bash
   python3 -m twine upload dist/*
   ```
4. **Confirme a publicação** em [pypi.org/project/hobots](https://pypi.org/project/hobots).
5. **Crie um commit** com a nova versão.

Os artifacts em `dist/` podem ser descartados após a publicação.

## Licença

[MIT](./LICENSE) © Hobots
