diff --git a/.agents/skills/capture-review-debts/SKILL.md b/.agents/skills/capture-review-debts/SKILL.md new file mode 100644 index 0000000..9a0c5a5 --- /dev/null +++ b/.agents/skills/capture-review-debts/SKILL.md @@ -0,0 +1,183 @@ +--- +name: capture-review-debts +description: Use when the user asks to register leftover /simplify findings as trackable Forgejo Issues, harvest session review debts, triage post-simplify follow-ups, or says "registra os débitos", "o que ficou do simplify", "harvest debts". +--- + +# Capturar débitos de /simplify em Issues + +Após um ciclo de entrega, `/simplify` (skill `code-simplification`) deixa achados +**maiores que o cleanup da sessão**. Esta skill **triageia** esses achados a +partir do contexto da sessão, decide o que vira Issue rastreável (e o que não), +**mescla** o que for do mesmo lote, e só então registra via +**`bun run agent:register`** (lotes `kind:chore|defect`, com plano curto em +`docs/plans/` quando score ≥3) ou **`bun run agent:file-miss`** +(`kind:agent-miss`, défice comportamental do fluxo de agentes). Não implementa +código. + +**Regra inviolável — nunca editar Issue `in-progress`.** Nem para absorver um +débito do mesmo pai: registra como **Issue nova** com `depends: []` +no frontmatter — assim ela destrava sozinha na fila quando o pai flipar `done`. + +**Qualidade de decisão:** [decision-quality.md](../work-issue/decision-quality.md) +— caro vs barato, defer+gatilho, depth/YAGNI. Score sozinho não basta: classifique +o **tipo de decisão** na triage. + +## Checklist + +``` +- [ ] 1. Colher candidatos da sessão (simplify) +- [ ] 2. Deduplicar contra código, Issues (`bun run issue all`) e planos +- [ ] 3. Pontuar importância e classificar destino +- [ ] 4. Mesclar relacionados (mesmo lote / mesma superfície) +- [ ] 5. Apresentar tabela de triage e obter confirmação +- [ ] 6. Registrar só o aprovado (agent:register / agent:file-miss; plano curto quando score ≥3) +``` + +## Passo 1 — Colher candidatos + +Fontes (nesta ordem; não invente achados que não apareceram): + +1. **Mensagens da sessão atual** — resumos finais de `/simplify` ("skipped", "recommend", "larger than cleanup"). +2. **Diff local** — só para verificar se um achado já foi aplicado; não para minerar débitos novos. +3. **Transcripts** — só se a sessão atual for curta demais e o usuário apontar um chat anterior. + +Para cada candidato, registre uma linha bruta: + +| Campo | Conteúdo | +| ----------- | --------------------------------------------------- | +| `id` | S1, S2… | +| `origem` | simplify quality\|perf\|reuse | +| `resumo` | uma frase | +| `evidência` | quote curto / path | +| `já_feito?` | sim se o diff/código já resolve | + +**Ignore na colheita:** elogios, sugestões hipotéticas sem achado, itens que o próprio simplify marcou como "fixed". + +## Passo 2 — Deduplicar + +Contra o repositório **antes** de pontuar: + +| Check | Ação se verdadeiro | +| ------------------------------------------------------ | --------------------------------------------------------------- | +| Já no diff / código da sessão | → balde **já_resolvido** (não reabrir) | +| Já em `docs/plans/*.md` ou numa Issue (`bun run issue all`) | → **absorver** no plano existente ou **descartar** como coberto | +| Pré-existente em `main`, fora do escopo da entrega | → **descartar** deste lote (bug separado só se o usuário pedir) | +| Intencional em `AGENTS.md` / decisão travada | → **descartar** | + +## Passo 3 — Pontuar e decidir destino + +Para cada candidato restante, atribua **importância** (1–5): + +| Score | Quando | +| ----- | ------------------------------------------------------------------------------------------- | +| 5 | Bloqueia feature dependente, hot path em produção, risco de segurança (token/WS/PII) | +| 4 | Perf/DRY com ≥3 call sites ou custo herdado por lib core (ws/settings/messages) | +| 3 | UX acionável, DRY claro 2 call sites, a11y outline | +| 2 | Higiene/naming, polish cosmético | +| 1 | Preferência de estilo, rename de pureza, micro-otimização sem evidência | + +**Bump caro de reverter:** protocolo WS / schema de storage / token handling → piso de score **4–5**. + +**Tipo de decisão** (obrigatório — uma coluna na triage): + +| Tipo | Exemplos | Destino típico | +| ------------------ | ----------------------------------------- | --------------------------- | +| **expensive_lock** | protocolo WS, formato de storage, token | registrar / absorver (nunca descartar por score baixo artificial) | +| **cheap_polish** | copy, motion, rename, P3 | descartar ou já_resolvido | +| **defer_trigger** | DRY <3 call sites, abstração prematura | **não registrar** — anotar gatilho no plano-pai | + +**Destino** (escolha exatamente um): + +| Destino | Critério | +| ---------------- | ------------------------------------------------------------------------------------- | +| **registrar** | Score ≥3 **e** não coberto **e** tipo ≠ defer_trigger puro; cabe num plano próprio ou lote mesclado | +| **absorver** | Score ≥3 mas é fase natural de um plano existente | +| **descartar** | Score ≤2 **ou** rename de pureza **ou** "nice to have" sem dono **ou** skip explícito do simplify | +| **defer** | Tipo defer_trigger: não cria ID novo; registra gatilho no plano-pai | +| **já_resolvido** | Feito no cleanup da sessão | + +**Separação de tipos (não misturar num único plano):** + +- **Engenharia / escala / DRY** → família `escala-dry-pos-.md`. +- **UX / produto pós-critique** → plano tipo `*-ux-pos-critique.md`. +- Nunca fundir "N+1 request do WS" com "copy do botão" no mesmo item. +- Nunca fundir **expensive_lock** com **cheap_polish** no mesmo lote sem fases ordenadas (lock primeiro). + +## Passo 4 — Mesclar + +Dentro do **mesmo tipo** (engenharia **ou** UX): + +- Mesclar se compartilham **superfície** (mesmo componente/lib) **ou** **pai** (mesma Issue que acabou de ser entregue). +- Um plano, várias **fases** ordenadas por ROI (expensive_lock / perf antes de DRY cosmético; produto antes de P3 motion). +- Declarar **appetite** do lote (ex. `~1 dia eng fill-in`) para a Issue não virar epic sem teto. +- Não mesclar across pais não relacionados. +- Se sobrar um único achado score ≥3, ainda assim um plano curto é melhor que Issue órfã sem plano. + +Alvo de merge: **1 plano engenharia + no máximo 1 plano UX** por sessão de entrega, salvo pais distintos. + +## Passo 5 — Tabela de triage + confirmação + +Mostre ao usuário **antes** de editar docs: + +```markdown +| ID | Resumo | Origem | Score | Tipo decisão | Destino | Lote mesclado | +| --- | ------------- | ---------------- | ----- | --------------- | -------------- | ---------------- | +| S1 | … | simplify/perf | 4 | expensive_lock | registrar | Escala pós-X F1 | +| S2 | shared helper | simplify/reuse | 3 | defer_trigger | defer | gatilho: 3º path | +| S3 | rename pureza | simplify/quality | 2 | cheap_polish | descartar | — | +``` + +Inclua baldes **já_resolvido**, **descartar** e **defer** com uma linha de racional/gatilho cada (transparência > silêncio). + +**Pare e confirme** — exceto em **modo autônomo** (`work-issue`): aí aplique a +seção Modo autônomo abaixo e pule o AskQuestion. Só avance ao Passo 6 com +aprovação explícita (humano) ou com as regras do modo autônomo. Sem isso = não +cria Issue nem toca `docs/plans/`. + +## Passo 6 — Registrar via `agent:register` / `agent:file-miss` + +Para cada lote com destino **registrar** ou **absorver**: + +1. Lotes de engenharia/UX: `bun run agent:register -- --id --title "" --prio

--kind chore --plan docs/plans/.md` — **um lote mesclado, não um item por achado micro**. Inclua appetite e rabbit holes do lote no plano curto. Com `--plan`, a Issue nasce `blocked` até o plano estar em `main` e `bun run agent:ready -- --issue `. +2. Défice comportamental do fluxo de agentes: `bun run agent:file-miss` (`kind:agent-miss`). +3. Se **absorver** num plano existente: edite o plano (nova fase/seção). **Se a Issue dona do plano estiver `in-progress`, NÃO a edite** — registra como Issue nova com `--depends ` (destrava sozinha quando o pai flipar `done`). +4. Se **defer**: anote o gatilho no plano-pai (Adiado com gatilho / Explicitamente fora) — não crie Issue só por isso. +5. No plano, seções obrigatórias além do template: + - **Já resolvido no simplify (não reabrir)** + - **Explicitamente fora** (skips dos revisores + descartes + defers com gatilho deste triage) + +Não implemente as fases aqui. Execução é via `work-issue`, só se o usuário seguir. + +**Próximo no fluxo de entrega:** após a triage (confirmada ou modo autônomo), o +fechamento segue o Passo 4 de `work-issue` (`bun run pr -- --head +--body "Closes #N" --automerge` → merge → CI). + +## Modo autônomo (`work-issue`) + +Quando não há humano no gate (Passo 5): + +1. Faça colheita, dedup, score e tipo como de costume. +2. Decida o destino pela triage completa (Passos 3–4) — registre o que a triage + manda (score ≥3 registrar, expensive_lock com piso 4–5), absorva em plano + existente, defira com gatilho ou descarte o resto — **sem abrir AskQuestion**. +3. Resuma no comentário/fechamento o que registrou vs deferiu vs descartou. + +## Anti-padrões (baseline) + +| Desculpa | Realidade | +| ---------------------------------------- | --------------------------------------------------------------------- | +| "Virou um item por achado" | Mesclar no lote; backlog poluído é pior que débito omitido de score 2 | +| "Registro ad hoc, é só um bullet" | Sempre `agent:register` com frontmatter completo (id/depends/priority/model) | +| "Rename PascalCase / pureza merece ID" | Score ≤2 → descartar, a menos que desbloqueie reuso real já pedido | +| "Junto DRY e UX num plano" | Tipos separados (engenharia vs UX) | +| "DRY com 1 call site vira escala-dry" | defer_trigger + gatilho; não registrar epic YAGNI | +| "Não li o plano existente" | Grep primeiro; absorver > duplicar | +| "Registro sem perguntar" | Passo 5 é gate (humano-gated); em modo autônomo o agente decide e registra | +| "Corto token/WS do lote por tempo" | expensive_lock nunca é cortável por appetite | + +## Resumo ao usuário + +1. Contagem: colhidos / já_resolvidos / descartados / deferidos / absorvidos / a registrar +2. Tabela de triage (final pós-confirmação), com coluna tipo de decisão +3. IDs/slugs criados ou planos estendidos (links) + appetite dos lotes +4. O que ficou de fora de propósito (e por quê / gatilho) diff --git a/.agents/skills/code-simplification/SKILL.md b/.agents/skills/code-simplification/SKILL.md new file mode 100644 index 0000000..239b284 --- /dev/null +++ b/.agents/skills/code-simplification/SKILL.md @@ -0,0 +1,331 @@ +--- +name: code-simplification +description: Simplifies code for clarity. Use when refactoring code for clarity without changing behavior. Use when code works but is harder to read, maintain, or extend than it should be. Use when reviewing code that has accumulated unnecessary complexity. +--- + +# Code Simplification + +> Inspired by the [Claude Code Simplifier plugin](https://github.com/anthropics/claude-plugins-official/blob/main/plugins/code-simplifier/agents/code-simplifier.md). Adapted here as a model-agnostic, process-driven skill for any AI coding agent. + +## Overview + +Simplify code by reducing complexity while preserving exact behavior. The goal is not fewer lines — it's code that is easier to read, understand, modify, and debug. Every simplification must pass a simple test: "Would a new team member understand this faster than the original?" + +## When to Use + +- After a feature is working and tests pass, but the implementation feels heavier than it needs to be +- During code review when readability or complexity issues are flagged +- When you encounter deeply nested logic, long functions, or unclear names +- When refactoring code written under time pressure +- When consolidating related logic scattered across files +- After merging changes that introduced duplication or inconsistency + +**When NOT to use:** + +- Code is already clean and readable — don't simplify for the sake of it +- You don't understand what the code does yet — comprehend before you simplify +- The code is performance-critical and the "simpler" version would be measurably slower +- You're about to rewrite the module entirely — simplifying throwaway code wastes effort + +## The Five Principles + +### 1. Preserve Behavior Exactly + +Don't change what the code does — only how it expresses it. All inputs, outputs, side effects, error behavior, and edge cases must remain identical. If you're not sure a simplification preserves behavior, don't make it. + +``` +ASK BEFORE EVERY CHANGE: +→ Does this produce the same output for every input? +→ Does this maintain the same error behavior? +→ Does this preserve the same side effects and ordering? +→ Do all existing tests still pass without modification? +``` + +### 2. Follow Project Conventions + +Simplification means making code more consistent with the codebase, not imposing external preferences. Before simplifying: + +``` +1. Read CLAUDE.md / project conventions +2. Study how neighboring code handles similar patterns +3. Match the project's style for: + - Import ordering and module system + - Function declaration style + - Naming conventions + - Error handling patterns + - Type annotation depth +``` + +Simplification that breaks project consistency is not simplification — it's churn. + +### 3. Prefer Clarity Over Cleverness + +Explicit code is better than compact code when the compact version requires a mental pause to parse. + +```typescript +// UNCLEAR: Dense ternary chain +const label = isNew ? 'New' : isUpdated ? 'Updated' : isArchived ? 'Archived' : 'Active'; + +// CLEAR: Readable mapping +function getStatusLabel(item: Item): string { + if (item.isNew) return 'New'; + if (item.isUpdated) return 'Updated'; + if (item.isArchived) return 'Archived'; + return 'Active'; +} +``` + +```typescript +// UNCLEAR: Chained reduces with inline logic +const result = items.reduce((acc, item) => ({ + ...acc, + [item.id]: { ...acc[item.id], count: (acc[item.id]?.count ?? 0) + 1 } +}), {}); + +// CLEAR: Named intermediate step +const countById = new Map(); +for (const item of items) { + countById.set(item.id, (countById.get(item.id) ?? 0) + 1); +} +``` + +### 4. Maintain Balance + +Simplification has a failure mode: over-simplification. Watch for these traps: + +- **Inlining too aggressively** — removing a helper that gave a concept a name makes the call site harder to read +- **Combining unrelated logic** — two simple functions merged into one complex function is not simpler +- **Removing "unnecessary" abstraction** — some abstractions exist for extensibility or testability, not complexity +- **Optimizing for line count** — fewer lines is not the goal; easier comprehension is + +### 5. Scope to What Changed + +Default to simplifying recently modified code. Avoid drive-by refactors of unrelated code unless explicitly asked to broaden scope. Unscoped simplification creates noise in diffs and risks unintended regressions. + +## The Simplification Process + +### Step 1: Understand Before Touching (Chesterton's Fence) + +Before changing or removing anything, understand why it exists. This is Chesterton's Fence: if you see a fence across a road and don't understand why it's there, don't tear it down. First understand the reason, then decide if the reason still applies. + +``` +BEFORE SIMPLIFYING, ANSWER: +- What is this code's responsibility? +- What calls it? What does it call? +- What are the edge cases and error paths? +- Are there tests that define the expected behavior? +- Why might it have been written this way? (Performance? Platform constraint? Historical reason?) +- Check git blame: what was the original context for this code? +``` + +If you can't answer these, you're not ready to simplify. Read more context first. + +### Step 2: Identify Simplification Opportunities + +Scan for these patterns — each one is a concrete signal, not a vague smell: + +**Structural complexity:** + +| Pattern | Signal | Simplification | +|---------|--------|----------------| +| Deep nesting (3+ levels) | Hard to follow control flow | Extract conditions into guard clauses or helper functions | +| Long functions (50+ lines) | Multiple responsibilities | Split into focused functions with descriptive names | +| Nested ternaries | Requires mental stack to parse | Replace with if/else chains, switch, or lookup objects | +| Boolean parameter flags | `doThing(true, false, true)` | Replace with options objects or separate functions | +| Repeated conditionals | Same `if` check in multiple places | Extract to a well-named predicate function | + +**Naming and readability:** + +| Pattern | Signal | Simplification | +|---------|--------|----------------| +| Generic names | `data`, `result`, `temp`, `val`, `item` | Rename to describe the content: `userProfile`, `validationErrors` | +| Abbreviated names | `usr`, `cfg`, `btn`, `evt` | Use full words unless the abbreviation is universal (`id`, `url`, `api`) | +| Misleading names | Function named `get` that also mutates state | Rename to reflect actual behavior | +| Comments explaining "what" | `// increment counter` above `count++` | Delete the comment — the code is clear enough | +| Comments explaining "why" | `// Retry because the API is flaky under load` | Keep these — they carry intent the code can't express | + +**Redundancy:** + +| Pattern | Signal | Simplification | +|---------|--------|----------------| +| Duplicated logic | Same 5+ lines in multiple places | Extract to a shared function | +| Dead code | Unreachable branches, unused variables, commented-out blocks | Remove (after confirming it's truly dead) | +| Unnecessary abstractions | Wrapper that adds no value | Inline the wrapper, call the underlying function directly | +| Over-engineered patterns | Factory-for-a-factory, strategy-with-one-strategy | Replace with the simple direct approach | +| Redundant type assertions | Casting to a type that's already inferred | Remove the assertion | + +### Step 3: Apply Changes Incrementally + +Make one simplification at a time. Run tests after each change. **Submit refactoring changes separately from feature or bug fix changes.** A PR that refactors and adds a feature is two PRs — split them. + +``` +FOR EACH SIMPLIFICATION: +1. Make the change +2. Run the test suite +3. If tests pass → commit (or continue to next simplification) +4. If tests fail → revert and reconsider +``` + +Avoid batching multiple simplifications into a single untested change. If something breaks, you need to know which simplification caused it. + +**The Rule of 500:** If a refactoring would touch more than 500 lines, invest in automation (codemods, sed scripts, AST transforms) rather than making the changes by hand. Manual edits at that scale are error-prone and exhausting to review. + +### Step 4: Verify the Result + +After all simplifications, step back and evaluate the whole: + +``` +COMPARE BEFORE AND AFTER: +- Is the simplified version genuinely easier to understand? +- Did you introduce any new patterns inconsistent with the codebase? +- Is the diff clean and reviewable? +- Would a teammate approve this change? +``` + +If the "simplified" version is harder to understand or review, revert. Not every simplification attempt succeeds. + +## Language-Specific Guidance + +### TypeScript / JavaScript + +```typescript +// SIMPLIFY: Unnecessary async wrapper +// Before +async function getUser(id: string): Promise { + return await userService.findById(id); +} +// After +function getUser(id: string): Promise { + return userService.findById(id); +} + +// SIMPLIFY: Verbose conditional assignment +// Before +let displayName: string; +if (user.nickname) { + displayName = user.nickname; +} else { + displayName = user.fullName; +} +// After +const displayName = user.nickname || user.fullName; + +// SIMPLIFY: Manual array building +// Before +const activeUsers: User[] = []; +for (const user of users) { + if (user.isActive) { + activeUsers.push(user); + } +} +// After +const activeUsers = users.filter((user) => user.isActive); + +// SIMPLIFY: Redundant boolean return +// Before +function isValid(input: string): boolean { + if (input.length > 0 && input.length < 100) { + return true; + } + return false; +} +// After +function isValid(input: string): boolean { + return input.length > 0 && input.length < 100; +} +``` + +### Python + +```python +# SIMPLIFY: Verbose dictionary building +# Before +result = {} +for item in items: + result[item.id] = item.name +# After +result = {item.id: item.name for item in items} + +# SIMPLIFY: Nested conditionals with early return +# Before +def process(data): + if data is not None: + if data.is_valid(): + if data.has_permission(): + return do_work(data) + else: + raise PermissionError("No permission") + else: + raise ValueError("Invalid data") + else: + raise TypeError("Data is None") +# After +def process(data): + if data is None: + raise TypeError("Data is None") + if not data.is_valid(): + raise ValueError("Invalid data") + if not data.has_permission(): + raise PermissionError("No permission") + return do_work(data) +``` + +### React / JSX + +```tsx +// SIMPLIFY: Verbose conditional rendering +// Before +function UserBadge({ user }: Props) { + if (user.isAdmin) { + return Admin; + } else { + return User; + } +} +// After +function UserBadge({ user }: Props) { + const variant = user.isAdmin ? 'admin' : 'default'; + const label = user.isAdmin ? 'Admin' : 'User'; + return {label}; +} + +// SIMPLIFY: Prop drilling through intermediate components +// Before — consider whether context or composition solves this better. +// This is a judgment call — flag it, don't auto-refactor. +``` + +## Common Rationalizations + +| Rationalization | Reality | +|---|---| +| "It's working, no need to touch it" | Working code that's hard to read will be hard to fix when it breaks. Simplifying now saves time on every future change. | +| "Fewer lines is always simpler" | A 1-line nested ternary is not simpler than a 5-line if/else. Simplicity is about comprehension speed, not line count. | +| "I'll just quickly simplify this unrelated code too" | Unscoped simplification creates noisy diffs and risks regressions in code you didn't intend to change. Stay focused. | +| "The types make it self-documenting" | Types document structure, not intent. A well-named function explains *why* better than a type signature explains *what*. | +| "This abstraction might be useful later" | Don't preserve speculative abstractions. If it's not used now, it's complexity without value. Remove it and re-add when needed. | +| "The original author must have had a reason" | Maybe. Check git blame — apply Chesterton's Fence. But accumulated complexity often has no reason; it's just the residue of iteration under pressure. | +| "I'll refactor while adding this feature" | Separate refactoring from feature work. Mixed changes are harder to review, revert, and understand in history. | + +## Red Flags + +- Simplification that requires modifying tests to pass (you likely changed behavior) +- "Simplified" code that is longer and harder to follow than the original +- Renaming things to match your preferences rather than project conventions +- Removing error handling because "it makes the code cleaner" +- Simplifying code you don't fully understand +- Batching many simplifications into one large, hard-to-review commit +- Refactoring code outside the scope of the current task without being asked + +## Verification + +After completing a simplification pass: + +- [ ] All existing tests pass without modification +- [ ] Build succeeds with no new warnings +- [ ] Linter/formatter passes (no style regressions) +- [ ] Each simplification is a reviewable, incremental change +- [ ] The diff is clean — no unrelated changes mixed in +- [ ] Simplified code follows project conventions (checked against CLAUDE.md or equivalent) +- [ ] No error handling was removed or weakened +- [ ] No dead code was left behind (unused imports, unreachable branches) +- [ ] A teammate or review agent would approve the change as a net improvement diff --git a/.agents/skills/plan-issue/SKILL.md b/.agents/skills/plan-issue/SKILL.md new file mode 100644 index 0000000..65b6de3 --- /dev/null +++ b/.agents/skills/plan-issue/SKILL.md @@ -0,0 +1,154 @@ +--- +name: plan-issue +description: >- + Transforma ideias humanas em Issues Forgejo + planos de intenção em + docs/plans/ (persona, fluxo, objetivo, direção suave no código — sem + decisões duras de engenharia). Divide o pedido nas menores tarefas que + ainda fazem sentido. Use quando o usuário pedir /plan-issue, planejar + features, fatiar um pedido em Issues, ou registrar trabalho novo na fila. +disable-model-invocation: true +--- + +# Planejar Issues (intenção, não engenharia) + +Esta skill transforma ideias soltas em: (1) um **plano de intenção** em `docs/plans/.md` por item, e (2) uma **Issue Forgejo rastreável** (`bun run agent:register`, frontmatter `id/depends/priority/model`). Issues Forgejo são a fonte canônica de spec/status/deps/prio/modelo. + +## Ciclo de vida (obrigatório) + +```text +rascunho local (Issue: —) + → GATE (Passo 5) + confirmação explícita do lote + → register com --plan → Issue NÃO claimável (blocked; script enforça) + → commit + PR dos planos (Related #N, nunca Closes) + → merge em main + → promote blocked→ready (`bun run agent:ready`) + → fila / `worktree next` +``` + +**Regras duras:** + +1. **Nada no Forgejo antes do gate.** Antes da confirmação explícita do Passo 5: proibido `bun run agent:register`, `bun run pr` / push de PR de planos. Planos locais (`Issue: —`) ok. +2. **Confirmação = OK ao overview do lote** (ex. "confirma", "pode registrar"). "Ok" ambíguo no meio da edição **não** dispara o Passo 6. +3. **Register com `--plan` não nasce `ready`.** O script aplica `blocked` automaticamente quando `--plan` está presente. Sem `--plan` (chore body-only / `file-miss`), pode nascer `ready`. +4. **Promote só depois do plano em `main`.** Nunca flipar para `ready` com o PR ainda aberto — isso recria a race de claim. Caminhos: (A) `bun run agent:ready -- --issue N` no fim do Passo 6 após merge; (B) workflow de merge que lê `Related #N` (o CI já tem `agent:promote-related`). Ambos idempotentes. +5. **Planos de Issues `in-progress` / `done` / `in-prod` são imutáveis.** Não editar `docs/plans/.md` nem o body de intenção dessas Issues. Refino → **plano + Issue novos** (sucessor; `depends` no pai se fizer sentido). Enquanto a Issue ainda é só `blocked`/`ready` (sem claim), editar o mesmo plano ainda é barato. + +## Divisão com as skills de execução + +| Skill | Papel | +| ----- | ----- | +| **`plan-issue` (esta)** | Intenção humana: o quê / para quem / por quê / outcome. Direção suave no codebase. **Proibido** travar schema, signatures, abstrações ou "Abordagem" de engenharia. | +| **`work-issue`** | Humano supervisiona: Issue já claimada (claim via `worktree next`) → plano de **implementação** (Plan mode) → **pausa** para confirmação → executa. | + +Aqui **não** se implementa código de produto, **não** se escreve plano de implementação. + +**Shaping (não tour):** aplique [shaping.md](shaping.md) em silêncio — appetite, fatia mínima útil, rabbit holes de produto, self-score ≥4 antes de gravar. + +## Checklist + +``` +- [ ] 1. Parse do lote + dedup (intra-lote, Issues existentes, docs/plans) +- [ ] 2. Reserva de IDs de uma vez por trilha (issuesById()) +- [ ] 3. Por item (ordem topológica): classificar → fatiar → explorar só o suficiente → intenção completa +- [ ] 4. Sugestão de modelo × effort (uma linha por item) +- [ ] 5. GATE: overview do lote → confirmar/iterar (PARAR aqui até o humano confirmar; sem Issue/PR) +- [ ] 6. Registro: `agent:register` (`--plan` → `blocked`) → PR `Related #N` → merge → `bun run agent:ready` +``` + +## Passo 1 — Parse e dedup + +1. **Separe os itens.** Entrada pode ser 1 ideia ou N. Se ambíguo, assuma a leitura mais provável e liste — a confirmação vai no gate. +2. **Fatia mínima útil.** Prefira várias Issues pequenas a um epic. Cada item deve caber num appetite curto e entregar um outcome verificável sozinho. Mesclar só quando separar criaria trabalho inútil (mesmo fluxo, mesma persona, mesma superfície sem valor incremental). +3. **Dedup intra-lote:** mesclar | absorver (fase de plano existente, sem ID novo) | manter separados com `depends`. +4. **Dedup contra o existente:** `bun run issue all` + `issuesById()` + grep em `docs/plans/*.md`. + - Já coberto / entregue → apontar e não criar. + - Issue **`in-progress` / `done` / `in-prod`:** **não** editar o plano dela — se a intenção mudou, item **sucessor** (plano + Issue novos). + - Issue só `blocked` / `ready` (ainda não claimada): pode editar o plano existente (fase de plano) sem ID novo. + - Novo → seguir. + +## Passo 2 — Reserva de IDs + +Último ID via `issuesById()`; distribua **antes** de escrever planos. Trilhas da Iara: `I` para a maioria (chat/UI/local-first), `OPS+` para operações/ferramentas, `FD+` para débitos futuros sem trilha. Fora de trilha: prefixos `O0+`, `FD+`, `RS+`. + +## Passo 3 — Por item: intenção completa + +Ordem topológica (dependente cita ID do dependido). Por item: + +1. **Tipo** + +| Tipo | Destino | +| ---- | ------- | +| Feature com escopo próprio | `kind:feature` + plano de intenção | +| Chore / débito pequeno | `kind:chore` + plano curto (ou body se trivial) | +| Défice do fluxo de agentes | `bun run agent:file-miss` (`kind:agent-miss`) — não aqui | +| Bloqueio externo (jurídico/LGPD) | `blocked` + `needs:consent`/`needs:migration`; texto no lote | +| Decisão de NÃO fazer | Comentário/doc, não Issue | + +2. **Explorar o código o mínimo** — só para apontar **direção** (pastas/domínios prováveis: `src/components/`, `src/lib/`) e evitar duplicar algo já entregue. Não inventar signatures nem diagramas de componentes. +3. **Plano de intenção** em `docs/plans/.md` via [intention-template.md](intention-template.md). Se muda UI, um **rascunho visual** é bem-vindo (HTML/PNG anexado ao plano ou à Issue) mas opcional — o gate é textual. Self-score ≥4/5 ([shaping.md](shaping.md)). + +### O que é proibido no plano de intenção + +- Decisões de schema / nomes de arquivos novos obrigatórios / mermaid de arquitetura de solução +- "Abordagem proposta" com componentes nomeados +- Fases de implementação verificáveis com quota de engenharia +- Forçar o executor a uma única forma técnica + +### O que é obrigatório + +- Intenção do humano (problema/oportunidade) +- Persona(s) e fluxo desejado (job / outcome) +- Critérios de aceite em linguagem de produto +- Appetite e fora de escopo (produto) +- **Direção provável no codebase** (pastas/domínios — hipotética, revisável) +- Questões em aberto com **Opções + Recomendação de produto** (não de engenharia) + +## Passo 4 — Modelo × effort + +Registre no cabeçalho (`Model:`). Default da Iara: `deepseek/deepseek-v4-flash`. Não crie par `{id}-plan` / `{id}-exec` por default — o plano de implementação nasce em `work-issue`. + +## Passo 5 — GATE + +Antes de criar Issues **ou** abrir PR de planos: + +- Overview: ID, título, prio, depends, appetite, modelo, link do plano local +- Esboço textual de fluxo só se ajudar +- Perguntas acumuladas numa rodada, recomendação de produto primeiro + +**Pare e espere.** Itere até confirmação explícita do lote (não basta um "ok" solto durante a edição). Só então Passo 6. + +## Passo 6 — Registro (não claimável até plano em `main`) + +Ordem obrigatória: + +1. **Register** (com `--model`; com `--plan` o script nasce `blocked`): + +```bash +bun run agent:register -- --id --title "" --prio \ + --depends --kind --plan docs/plans/.md \ + --model +``` + +Sem `--plan`: nasce `ready` (use `--blocked` só se quiser não-claimável sem plano). + +2. Atualize `Issue: #N` (e status) no plano local. +3. Commit + push (branch de trabalho) + **PR** `--base main` com **`Related #N`** (nunca `Closes #N` em PR só de `docs/plans/`): + +```bash +bun run pr -- --head --title "Plano(s): " --body "Related #" --automerge +``` + +4. Espere o merge em `main`. +5. **Promote** (idempotente se já `ready`): + +```bash +bun run agent:ready -- --issue +``` + +O workflow de merge (`agent:promote-related`) é safety net se este passo falhar — não pule o promote do agente no caminho feliz. + +**NÃO faz:** implementar código; claim; escrever `*-impl.md`; editar plano de Issue `in-progress`/`done`/`in-prod`; marcar `ready` antes do plano em `main`; registrar/abrir PR antes do gate. + +## Resumo final + +Tabela do lote + mesclados/absorvidos/descartados + decisões de produto assumidas _(validar)_ + o que o gate decidiu + Issues `#N` + PRs de plano. diff --git a/.agents/skills/plan-issue/intention-template.md b/.agents/skills/plan-issue/intention-template.md new file mode 100644 index 0000000..f218802 --- /dev/null +++ b/.agents/skills/plan-issue/intention-template.md @@ -0,0 +1,101 @@ +# Template de plano de intenção (`docs/plans/.md`) + +Plano de **produto/intenção** — o que o humano quer, para quem, e como saber que deu certo. **Não** é plano de engenharia: quem executa (`work-issue` / `agent-work-issue`) cria o `docs/plans/-impl.md` depois, com liberdade de reavaliar a abordagem técnica. + +Shaping: [shaping.md](shaping.md). Dados (só decisão de produto): [data-presentation.md](data-presentation.md). + +````markdown +# + +Status: rascunho +Atualizado em: +Issue: # (após `bun run agent:register`; senão "—") +Priority: +Model: +Impeccable: — +Rascunho UI: -ui-draft.html + PNG embutido abaixo> +Appetite: +Responsável: — + +## Intenção + + + +## Persona e fluxo + +- **Persona / contexto:** +- **Job principal:** +- **Fluxo desejado:** +- **Anti-goals de produto:** + +### Esboço de fluxo (B/C/D) + + + +\```text +[início] → … → [outcome] +\``` + +### Rascunho UI (B/C/D) + + + +![Rascunho UI — ](-ui-draft-.png) + +## Objetivo e aceite + +- +- + +## Dados (intenção) + + + +- **Vou apresentar dados?** +- **Decisões desbloqueadas:** +- **Forma:** *adiada ao plano de implementação* — aqui só restrições de produto (ex.: "sem % estadual absoluto", "leitura relativa/local"). + +## Direção no codebase (hipótese) + + + +- **Áreas prováveis:** /`, `src/utilities//`, `src/lib/…`> +- **Precedente a olhar:** +- **Risco de acoplamento:** + +## Dependências + +- + +## Fora de escopo + +- + +## Rabbit holes de produto + +- **.** Se alguém “só completar”: . **Corte neste item:** <…>. + +## Questões em aberto (produto) + + + +- **?** **Opções:** A | B | C. **Recomendação:** <…>. _(assumido — validar com produto)_ quando couber. + +## Referências + +- GitHub Issue # +- Rascunho UI (gate): +- +- `AGENTS.md` / `docs/ARCHITECTURE.md` — só se o item toca convenção já travada no repo +```` + +Notas: + +- Alvo: ~60–100 linhas. Mais que isso costuma significar engenharia vazando para a intenção. +- Classe **A:** `Impeccable: A — N/A`; `Rascunho UI: N/A`; omita esboço de fluxo. +- Classe **B/C/D:** rascunho HTML+Tailwind obrigatório no gate ([ui-draft-html.md](ui-draft-html.md)); renderize com `bun run ui-draft:render` e embuta o PNG no plano. +- Não rode `/impeccable` aqui. +- Self-score shaping ≥4/5 antes de gravar. diff --git a/.agents/skills/plan-issue/shaping.md b/.agents/skills/plan-issue/shaping.md new file mode 100644 index 0000000..8dc35dc --- /dev/null +++ b/.agents/skills/plan-issue/shaping.md @@ -0,0 +1,58 @@ +# Shaping de intenção (plan-issue) + +Filtros para fatiar e descrever o que o humano quer — **sem** travar engenharia. Inspirado em Shape Up / 37signals (appetite, build less) e no caro-vs-barato de produto. Decisão silenciosa de produto é defeito; decisão silenciosa de schema aqui é **erro** (isso pertence ao plano de implementação). + +## Fatia mínima útil + +Cada Issue deve: + +1. Caber num **appetite** declarado (tempo fixo, escopo flexível). +2. Entregar **um** outcome verificável sozinho. +3. Ser compreensível sem ler o epic-pai. + +Se o pedido humano misturar jobs (ex.: “mapa + CSV + invite”), **separe** — a menos que um sem o outro não tenha valor. + +| Preferir separar | Preferir manter junto | +| ---------------- | --------------------- | +| Outcomes distintos para personas distintas | Mesmo fluxo, mesma tela, mesmo aceite | +| Um pedaço desbloqueia o outro depois | Separar criaria handoff inútil | +| Um pedaço é jurídico/LGPD e o outro não | — | + +## Appetite + +Declare quanto o slice vale, não estimativa aberta: + +- Exemplos: `~0,5 dia, só copy/fluxo` · `~1 dia, um encaixe em lista existente` · `~1–2 dias, fluxo novo numa rota` +- Se a intenção não cabe → cortar rabbit holes / Fora de escopo, **não** inflar o item nem detalhar engenharia para “caber”. + +## Caro de reverter (produto) vs barato + +No plano de intenção, “travar” só o que é caro **de produto** e já é política do repo ou decisão humana explícita: + +| Pode afirmar na intenção | Deixar para o plano de implementação | +| ------------------------ | ------------------------------------ | +| Leader lockdown / assimetria de votos | Collection nova, unicidade, shape do schema | +| Fail-closed de consentimento (conceito) | Chave `Consent` concreta, textos versionados | +| “Sem % estadual absoluto” | Chart vs tabela vs mapa | +| Appetite e fora de escopo | Nomes de utilities, signatures, migration | + +## Rabbit holes de produto + +Armadilhas se alguém “só completar” o desejo humano: + +- Segundo cadastro de pessoa paralelo a `Contact` +- Spreadsheet mode / edição em massa sem pedido +- Dashboard de vaidade sem decisão nomeável +- Escopo que puxa redesign de paleta ou shell inteiro + +## Self-score (0–5, gate ≥4) + +1. Fatia = um outcome verificável? +2. Appetite declarado e a intenção cabe nele? +3. Persona + job + aceite claros (sem jargão de stack)? +4. Direção no codebase é hipótese (não contrato técnico)? +5. Zero decisões duras de engenharia no plano? + +Se o item muda UI: rascunho HTML+Tailwind no gate ([ui-draft-html.md](ui-draft-html.md)) antes de registrar — não conta como “engenharia no plano”; é validação visual da intenção. + +<4 → corrigir antes de gravar / registrar Issue. diff --git a/.agents/skills/work-issue/SKILL.md b/.agents/skills/work-issue/SKILL.md new file mode 100644 index 0000000..7fbafef --- /dev/null +++ b/.agents/skills/work-issue/SKILL.md @@ -0,0 +1,102 @@ +--- +name: work-issue +description: >- + Executa uma Issue já claimada de ponta a ponta com supervisão humana no + plano: o contexto da sessão identifica a Issue (sem claim), Plan mode com + plano de implementação, pausa para confirmação humana, depois execução → + /simplify → capture-review-debts autônomo (o agente decide o que registrar + e o que descartar) → PR Ready em main com auto-merge. Use quando o usuário + pedir /work-issue, quiser supervisionar a abordagem de engenharia, ou + trabalhar uma Issue com gate humano antes do código. +disable-model-invocation: true +--- + +# Work-issue (humano supervisiona) + +Fluxo próprio do humano na sua máquina, com a sessão aberta no worktree da +Issue. A sessão **já nasce no contrato**: Issue claimada, worktree correto, +branch correta — o claim é feito **fora da skill** (o `worktree next` claima). +A skill assume isso e vai direto ao trabalho — **nenhum passo de +claim/ambiente** (só a validação de contexto do Passo 1). + +Os materiais compartilhados vivem aqui como referência: `engineering-brief.md`, +`implementation-template.md`, `decision-quality.md`, `execution-pipeline.md` +(mecânica de execução → fechamento). + +**Proibido:** merge sem CI green; editar outras Issues `in-progress`; pular a +pausa do impl plan; Draft / sem auto-merge. + +## Checklist + +``` +- [ ] 0. Prep: `bun install` se preciso +- [ ] 1. Contexto: Issue do prompt/`$ARGUMENTS` da sessão (ausente → UMA pergunta com validação; nunca claim) +- [ ] 2. Abrir o plano de intenção do body da Issue (`Plano: docs/plans/...`; sem link → body é spec) +- [ ] 3. Plan mode → escrever `docs/plans/-impl.md` → **PARAR e confirmar com o humano** +- [ ] 4. Após "ok": Passo 4 (execução → /simplify → débitos autônomos → PR, via `execution-pipeline.md`) +``` + +## Passo 1 — Contexto da sessão + +A Issue chega no prompt/`$ARGUMENTS` do `/work-issue` (`--issue ` ou o número). +Sempre consulte o Forgejo para o resto (fonte única é a Issue, nunca um brief +duplicado). + +- **Presente** → valide o básico (consulte a Issue: existe, `OPEN`, label + `in-progress` — confirma que o claim foi feito fora da skill) e use. +- **Ausente** → **uma** pergunta ao humano ("Qual Issue?"), valide o número + informado com a mesma checagem e siga. +- **Checagem falhou** (não existe / não `OPEN` / sem `in-progress`) → a Issue + não está claimada: **pare e peça ao humano** para reabrir a sessão + (`bun run worktree next --issue `; o `worktree next` claima). Nunca siga + com Issue não claimada, nunca claim na sessão. +- **Nunca rode `bun run agent:claim`** — claim é contrato do ambiente + (`worktree next`), não da skill. + +## Passo 2 — Intenção + +O path do plano de intenção vem do **body da Issue** (`Plano: [docs/plans/.md]`). +Sem plano linkado → o body é a spec. Abra e leia. + +## Passo 3 — Plano de implementação + GATE humano + +Plan mode: `SwitchMode` → `plan` (deliberar engenharia a partir da intenção). + +1. Leia `engineering-brief.md` e aplique skills/princípios sob demanda. +2. Explore o código o bastante para **reavaliar** a "Direção no codebase" da + intenção — você **deve** escolher a melhor abordagem maintainable, mesmo que + difira da hipótese do plano de intenção, desde que o **aceite de produto** se + mantenha. +3. Escreva `docs/plans/-impl.md` via `implementation-template.md`. + Qualidade: `decision-quality.md` ≥4/5. +4. Apresente no chat: abordagem recomendada, opções rejeitadas, fases, riscos, + o que diverge da hipótese de direção da intenção. +5. **Pare.** Não escreva código de feature até confirmação explícita ("ok", + "pode executar", "aprovado", …). +6. Se o humano pedir mudança de abordagem → revise o `*-impl.md` e reapresente. +7. Confirmação → marque Status `aprovado` no impl plan → `SwitchMode` `agent` → + continue. + +Divergência material de produto (aceite/persona da intenção não cabem): +pare e **pergunte ao humano** — ele decide entre item sucessor ou `blocked`; +nunca invente produto novo. + +## Passo 4 — Executar + +`SwitchMode` → `agent` se ainda estiver em plan. Siga a mecânica de +[`execution-pipeline.md`](execution-pipeline.md) (executar → simplify → +fechar em main), com os deltas do ator humano: + +- **Branch:** `-` do worktree — nunca crie branch nova na sessão. +- **UI:** shape → craft → critique → polish. +- **`capture-review-debts`:** **autônomo** — colha, dedupe, pontue e **decida + você mesmo o destino** (registrar/absorver/deferir/descartar) pela triage + da própria skill, **sem pausa para o humano**; registre via + `bun run agent:register` / `bun run agent:file-miss` (Issues novas com + `depends` no pai se necessário) e resuma o que registrou vs descartou no + fechamento. + +## Resumo final + +Issue (contexto da sessão) · impl plan (abordagem + rejeitadas + divergências da +hipótese) · simplify + débitos registrados/deferidos · PR + estado do merge. diff --git a/.agents/skills/work-issue/decision-quality.md b/.agents/skills/work-issue/decision-quality.md new file mode 100644 index 0000000..de48697 --- /dev/null +++ b/.agents/skills/work-issue/decision-quality.md @@ -0,0 +1,51 @@ +# Qualidade de decisão (plano de implementação) + +Aplique ao escrever `docs/plans/-impl.md` em `work-issue` / `agent-work-issue`. O plano de **intenção** já fixou o outcome — aqui se escolhe a engenharia. Decisão silenciosa é defeito. + +## Caro vs barato + +Delibere e registre só o **caro de reverter**. O barato vira Não escopo, fill-in, ou “mais simples que funciona” com **gatilho de revisitação**. + +| Caro (decidir agora + rejeitadas) | Barato (adiar / mais simples) | +| --------------------------------- | ----------------------------- | +| Collection nova, unicidade, access | Polish cosmético, 2º estilo de card | +| Nova chave `Consent` / PII | Copy, motion, a11y P3 | +| Semântica multi-collection / locks | Prefetch, ordem de colunas | +| URL pública / slug imutável | Rename de pureza | +| Fronteira de módulo em volatilidade real | Adapter com 1 call site | + +## Forma obrigatória + +Toda decisão não trivial de engenharia: + +```text +Opções: A | B | C +Recomendação: B — porque … +Alternativas rejeitadas: A porque …; C porque … +``` + +No `*-impl.md`: **Decisões de engenharia** = decisão + por quê + rejeitadas. + +## Appetite e tracer bullet + +Respeite o appetite do plano de intenção. Se a abordagem estoura → cortar rabbit holes, não inflar. Preferir **tracer bullet** cedo (schema mínimo → uma action → uma superfície) antes de polish paralelo. + +## Depth check + +Antes de propor arquivo/utility/componente novo: + +1. Já existe módulo profundo? (`campaignAccess`, shells de lista, `withPayloadTransaction`, …) → **reusar** +2. Pass-through raso? → **não criar** +3. Conhecimento vazaria em N módulos? → **encapsular uma vez** + +## Rabbit holes Teqo + +Layers/cerimônia sem volatilidade; cadastro paralelo a `Contact`; Consent por ID hardcoded; DRY <3 call sites; redesign de paleta fora de `campaign`. + +## Self-score (0–5, gate ≥4) + +1. Decisões caras têm rejeitadas? +2. Abordagem cabe no appetite da intenção? +3. Rabbit holes nomeados? +4. Depth check: reusa shells/helpers existentes? +5. Intenção (aceite de produto) permanece satisfeita — engenharia não reescreveu o outcome? diff --git a/.agents/skills/work-issue/engineering-brief.md b/.agents/skills/work-issue/engineering-brief.md new file mode 100644 index 0000000..7903769 --- /dev/null +++ b/.agents/skills/work-issue/engineering-brief.md @@ -0,0 +1,32 @@ +# Brief de engenharia (carregar antes do plano de implementação) + +Leia o que for relevante **antes** de travar a abordagem no `*-impl.md`. + +## Pinos do repo (sempre) + +- `AGENTS.md` — convenções, stack, workflow, CI +- `src/lib/` — regras de domínio: `ws.ts` (protocolo `zeroclaw.v1`), `messages.ts`, `settings.ts` (localStorage), `voice.ts` +- `vite.config.ts` — base `/_app/`, PWA (manifest + service worker), background sync do WS + +## Skills sob demanda (não tour) + +| Situação | Skill / princípio | +| -------- | ----------------- | +| Onde colocar lógica / dependência | `clean-architecture` — Dependency Rule; core testável | +| Complexidade de módulo / API | `software-design-philosophy` — deep modules, anti-classitis | +| Escopo v1 / cortar especulação | `37signals-way` / `pragmatic-programmer` — build less, tracer bullet | +| KPI/mapa/série na UI | [data-presentation.md](../plan-issue/data-presentation.md) pergunta 3 no impl | +| Debug de fluxo assíncrono | `debugging-and-error-recovery` | + +## Invariantes que o impl plan não pode violar + +- **Local-first:** configuração, sessões e histórico guardados apenas no dispositivo (localStorage) — nada de servidor próprio para estado do cliente. +- **Token nunca logado/commitado** — vai apenas ao gateway configurado pelo usuário. +- **Protocolo `zeroclaw.v1`** — tipos de `src/lib/ws.ts` são contrato; mudança de protocolo é breaking (major) e precisa de plano próprio. +- **Aprovações**: ações que exigem confirmação do assistente passam pelo fluxo `approval_request` → `approval_response` (ApprovalCard) — nunca auto-aprovar. +- **Copy pt-BR / identificadores em inglês.** +- **PWA**: qualquer asset novo que precise de offline deve entrar em `globPatterns`/manifest do `vite.config.ts`. + +## Sistema de UI + +`src/components/` é enxuto: `Chat.tsx` (thread + composer), `Settings.tsx` (URL/key/agente/STT/TTS), `ApprovalCard.tsx`. Reuse os primitives do assistant-ui — não reinvente thread/composer. Antes de criar componente novo, confira se não cabe estender um dos três. diff --git a/.agents/skills/work-issue/execution-pipeline.md b/.agents/skills/work-issue/execution-pipeline.md new file mode 100644 index 0000000..5e37ec5 --- /dev/null +++ b/.agents/skills/work-issue/execution-pipeline.md @@ -0,0 +1,47 @@ +# Pipeline de execução compartilhada (work-issue) + +Mecânica comum de execução → fechamento do fluxo (humano supervisiona). O corpo +mora aqui; `work-issue/SKILL.md` referencia este material. + +## Executar + +Ordem: + +1. **Camadas base** — utilitários, libs (`src/lib/`), tipos de domínio + (`src/lib/ws.ts`, `src/lib/messages.ts`, `src/lib/settings.ts`). +2. **UI** — `src/components/` (Chat, Settings, ApprovalCard): shape → craft → + critique → polish. +3. **Gates** — `bun run gate` na iteração (lint + build/typecheck). Entrega via + push da branch (o PR roda o CI completo). + +Tracer bullet cedo se o item for grande. Inclua o `*-impl.md` no commit da +entrega. + +## /simplify + débitos + +1. Rode a skill `code-simplification` completa (**3 reviewers paralelos via + Task, read-only**) no diff da sessão. +2. Aplique fixes pontuais que preservem comportamento. +3. Rode `capture-review-debts` autônomo (o agente decide o destino). Nunca + edite a Issue `in-progress` atual para absorver débitos. + +## Fechar em main + +1. Branch do ator (ver deltas) — nunca crie branch nova fora dela. +2. Commit (inclua o `*-impl.md`). +3. Push da branch para o origin. +4. PR **Ready** (nunca draft) com `Closes #`: + +```bash +bun run pr -- --head --title "" --body "Closes #" --automerge +``` + +`--automerge` aguarda os checks (branch protection `checks`) e mergea por +rebase. O workflow de CI (`issue:transition`) flipa `done`/`in-prod` no merge. +Comente na Issue o desfecho em uma linha. + +## Deltas por ator + +| Ator | Branch | UI | `capture-review-debts` | +| ---- | ------ | --- | ---------------------- | +| **Humano** (`work-issue`) | `-` (worktree; nunca crie branch nova na sessão) | shape → craft → critique → polish | **autônomo** — decide o destino dos achados (registrar/absorver/deferir/descartar) pela triage da skill; sem pausa para o humano | diff --git a/.agents/skills/work-issue/implementation-template.md b/.agents/skills/work-issue/implementation-template.md new file mode 100644 index 0000000..8608c89 --- /dev/null +++ b/.agents/skills/work-issue/implementation-template.md @@ -0,0 +1,65 @@ +# Template de plano de implementação (`docs/plans/-impl.md`) + +Criado em Plan mode por `work-issue` / `agent-work-issue` a partir do plano de **intenção**. Aqui a engenharia é deliberada — Opções + Recomendação + rejeitadas ([decision-quality.md](decision-quality.md)). + +O outcome de produto do plano de intenção é **inviolável**. A forma técnica é **livre** desde que mantenha aceite, appetite e invariantes do repo ([engineering-brief.md](engineering-brief.md)). + +````markdown +# Impl: + +Status: rascunho | aprovado | em execução +Atualizado em: +Issue: # +Intenção: docs/plans/.md +Appetite restante: + +## Leitura da intenção + +- **Outcome:** <1–2 frases, nas palavras do aceite de produto> +- **O que NÃO negociar:** +- **O que reavaliar:** + +## Abordagem recomendada + +\```mermaid +flowchart LR + … +\``` + +**Opções consideradas:** A | B | C +**Recomendação:** <…> — porque … +**Rejeitadas:** … + +### Componentes / mudanças + +- **``** (`src/…`): +- **Migration:** +- **Access / Consent:** +- **UI:** + +### Dados → forma (se aplicável) + +- Forma escolhida + por quê + rejeitadas (pergunta 3 de data-presentation) + +## Fases verificáveis + +1. **Tracer / schema+server** — +2. **UI** — <…> +3. **Gates** — `bun run gate`; push via `bun run push` + +## Rabbit holes / Não escopo (engenharia) + +- … + +## Riscos e mitigação + +- … + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto +- [ ] Invariantes AGENTS/engineering-standards +- [ ] Testes de domínio previstos (unit/int) onde access/write paths mudam +```` + +Self-score decision-quality ≥4 antes de marcar `aprovado` ou executar. diff --git a/.agents/skills/worktree-next-issue/SKILL.md b/.agents/skills/worktree-next-issue/SKILL.md new file mode 100644 index 0000000..95eadec --- /dev/null +++ b/.agents/skills/worktree-next-issue/SKILL.md @@ -0,0 +1,55 @@ +--- +name: worktree-next-issue +description: >- + Claima deterministicamente a próxima Issue da fila (ou a direcionada com + --issue N), cria o worktree git dela a partir de main com branch legível no + formato - (slug pt-BR do título sem acentos) e provisiona o + ambiente (bun install). Usar quando o usuário pedir "worktree da próxima + issue", "cria o worktree para o próximo da fila", "prepara o ambiente do + próximo item". +--- + +# Worktree da próxima Issue claimável + +Prepara o ambiente git para a próxima Issue da fila de claim — **o claim é parte do `next`**: mesma fila/ordem e lock otimista do `bun run agent:claim`, rodado **antes** de criar o worktree; claim falhou → motivo e saída sem worktree. Reabrir uma Issue já claimada (`--issue N` com `in-progress`) não re-claima. + +## Fonte canônica: `bun run worktree` (script) + +A lógica determinística (fila → código+slug → branch → worktree) vive em **`scripts/worktree.mjs`** — fonte única. Não a reescreva à mão aqui. + +```bash +bun run worktree next [--issue N] [--stay] # CLAIMA a Issue + cria worktree de origin/main + bun install +bun run worktree plan [bag] [--stay] # worktree de PLANEJAMENTO (/plan-issue): um DIFERENTE por invocação +bun run worktree new [bag] [--stay] # worktree NEUTRO (sem função pré-definida): um DIFERENTE por invocação +bun run worktree kill [--force] # destrói o worktree atual + volta ao main +``` + +No opencode, isso é o comando **`/worktree next`** / **`/worktree kill`** (`.opencode/commands/worktree.md`), que só repassa `$ARGUMENTS` para o script. + +`plan` é o primo do `next`: worktree de **planejamento** para rodar a skill `/plan-issue` sem ocupar o main. **Cada invocação cria um DIFERENTE** (sessões paralelas): com `bag` opcional → branch `plans/plan-issue-` (sufixo `-2`/`-3` se o nome já estiver vivo); sem `bag` → próximo sequencial `plans/plan-issue-` livre. `new` é o irmão **neutro** (sem função pré-definida): branch `work/` ou `work/` sequencial, prefixo que não colide com `next` nem `plan`. O fluxo desta skill (fila → próxima Issue → implementação) é só `next`. + +**Default-go:** `next`, `plan` e `new` imprimem `cd

` na última linha **por padrão**; `--stay` suprime. No terminal interativo, use a função `worktree()` do **roteador global** (`~/.config/shell/worktree.sh` — escopado pelo projeto atual via `git rev-parse`) para o `cd` ser aplicado de verdade no shell que te chamou. `kill` também imprime `cd
` no fim. + +**Launch do opencode:** só a função shell marca `TEQO_WORKTREE_TERMINAL=1`; com o marcador, `next`/`plan`/`new` imprimem também a diretiva `launch opencode --model deepseek/deepseek-v4-flash --auto [--prompt "/work-issue --issue "]` **antes** do `cd`, e a função a executa depois do cd — `next` abre o TUI com `/work-issue --issue ` já enviado; `plan` abre com `/plan-issue` já enviado; `new` abre sem `--prompt`. **O comando `/worktree` do opencode (e qualquer automação sem o marcador) nunca lança o TUI.** + +## Fluxo quando invocado como skill + +1. Rode `bun run worktree next` e leia a saída inteira. Se a fila estiver vazia, ele para sozinho — não crie worktree sem Issue. +2. Se a saída reclamar de conflito (worktree/branch já existentes), o script já reporta o que reutilizar. +3. Reporte ao usuário: código, `#`, branch (`-`), path — e que a Issue **já foi claimada** pelo próprio `next` (não rodar `bun run agent:claim`). + +## O que o script garante (contrato) + +- **Claim determinístico primeiro:** a próxima `ready` desbloqueada (mesma fila/ordem do `bun run agent:claim` — por `prio:P*` e mais antiga primeiro) é claimada com o **mesmo lock otimista** antes de qualquer `git`; race → o script para com o motivo, **sem worktree órfão**. `--issue N` claima a Issue direcionada (`ready`) ou **reabre** uma já claimada (`in-progress` — sem re-claim, idempotente; worktree reutilizado/criado). A saída imprime o brief do claim e avisa "já claimada — NÃO rodar `bun run agent:claim`". +- **Branch `-`:** `code` = `id` do frontmatter; `slug` = título pt-BR slugificado (acentos fora, não-alfanumérico → hífen); truncamento só no slug, nunca no código; valida com `git check-ref-format --allow-onelevel`. +- **Base `origin/main`** (com `git fetch` antes); dir em `WORKTREES_ROOT` do `.forgejo/worktree.env` (default `~/.worktrees/`). +- **Provisiona o ambiente do worktree:** `bun install` (Iara é SPA sem banco — não há DB/porta/migrations). +- **`kill`:** recusa destruir o worktree principal (main); recusa worktree sujo sem `--force`; remove e apaga o branch. +- **NUNCA usa `--force` no `add`**, não inventa código de Issue, e nunca desfaz claim. + +## NÃO faz + +- Não roda `bun run agent:claim` por conta própria (o `next` claima sozinho). +- Não usa `git worktree add --force` nem destrói worktrees existentes (isso é `kill`, explícito). +- Não desfaz claim (reverter `in-progress` → `ready` é outra entrega). +- Não duplica a lógica: se o script falhar ou faltar (API fora do ar), reporte e pare. diff --git a/.forgejo/workflows/agent-pr-ready-automerge.yml b/.forgejo/workflows/agent-pr-ready-automerge.yml new file mode 100644 index 0000000..42f739f --- /dev/null +++ b/.forgejo/workflows/agent-pr-ready-automerge.yml @@ -0,0 +1,21 @@ +# Safety net: PRs de entrega (feature → main) devem ser Ready + auto-merge. +# Cobre o caso de um agente esquecer de armar o merge. Roda +# scripts/forgejo-pr-automerge.mjs (plain Node): marca draft → ready, espera o +# status check `checks` (branch protection em main) e mergea por rebase. + +name: Agent PR Ready + auto-merge + +on: + pull_request: + types: [opened, reopened, synchronize, ready_for_review, converted_to_draft] + branches: [main] + +jobs: + ready-automerge: + if: github.event.pull_request.state == 'open' + runs-on: host + timeout-minutes: 40 + steps: + - uses: actions/checkout@v5 + - name: Mark Ready and merge when checks pass + run: node scripts/forgejo-pr-automerge.mjs --pr "${{ github.event.pull_request.number }}" diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..db42d7b --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,71 @@ +name: CI + +# PR gate + post-merge verifier + deploy: +# - PRs (feature → main) rodam `checks` (lint + build + e2e) — o branch +# protection em `main` exige o status `checks` para merge. +# - Push em `main` (merge dos PRs) roda `checks` de novo e depois `deploy` +# (rsync do dist/ para o homeserver). Deploy é gated em `checks`. +# +# Runner: host (workstation). Sem Postgres/serviços — Iara é um SPA. + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + checks: + name: checks + runs-on: host + timeout-minutes: 20 + steps: + - uses: actions/checkout@v5 + + - name: Instalar bun + run: | + export PATH="$HOME/.bun/bin:$PATH" + bun --version + + - name: Instalar dependências + run: | + export PATH="$HOME/.bun/bin:$PATH" + bun install --frozen-lockfile + + - name: Lint + run: | + export PATH="$HOME/.bun/bin:$PATH" + bun run lint + + - name: Build (tsc + vite) + run: | + export PATH="$HOME/.bun/bin:$PATH" + bun run build + + - name: Instalar navegador do Playwright + run: | + export PATH="$HOME/.bun/bin:$PATH" + bunx playwright install chromium + + - name: E2E (Playwright — só aqui, nunca local) + run: | + export PATH="$HOME/.bun/bin:$PATH" + bun run test:e2e + + deploy: + runs-on: host + needs: checks + if: github.ref == 'refs/heads/main' + timeout-minutes: 20 + steps: + - name: Build e deploy + run: | + set -e + export PATH="$HOME/.bun/bin:$PATH" + rm -rf /tmp/iara-build + git clone -q --depth 1 -b main https://git.solla.dev/amana/iara-pwa.git /tmp/iara-build + cd /tmp/iara-build + bun install --frozen-lockfile + bun run build + rsync -az --delete dist/ homeserver:~/iara-pwa/dist/ + echo "deploy concluido em $(date -u +%H:%M:%SZ)" diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml deleted file mode 100644 index 4106fb9..0000000 --- a/.forgejo/workflows/deploy.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: deploy - -on: - push: - branches: [main] - -jobs: - deploy: - runs-on: host - steps: - - name: Build e deploy - run: | - set -e - rm -rf /tmp/iara-build - git clone -q --depth 1 -b main https://git.solla.dev/amana/iara-pwa.git /tmp/iara-build - cd /tmp/iara-build - export PATH="$HOME/.bun/bin:$PATH" - bun install --frozen-lockfile - bun run build - rsync -az --delete dist/ homeserver:~/iara-pwa/dist/ - echo "deploy concluido em $(date -u +%H:%M:%SZ)" diff --git a/.forgejo/workflows/issue-done-on-main-merge.yml b/.forgejo/workflows/issue-done-on-main-merge.yml new file mode 100644 index 0000000..c8a48e5 --- /dev/null +++ b/.forgejo/workflows/issue-done-on-main-merge.yml @@ -0,0 +1,17 @@ +# Main merge flips claimed issues to done + in-prod. +# Script plain-Node (stdlib) — no bun/install needed. + +name: Main merge flips claimed issues to done + in-prod + +on: + pull_request: + types: [closed] + +jobs: + issue-done: + if: github.event.pull_request.base.ref == 'main' && github.event.pull_request.merged == true + runs-on: host + steps: + - uses: actions/checkout@v5 + - name: Flip Closes/Fixes #N issues to done and in-prod + run: node scripts/forgejo-issue-transition.mjs --pr "${{ github.event.pull_request.number }}" diff --git a/.forgejo/workflows/plan-issue-ready-on-main-merge.yml b/.forgejo/workflows/plan-issue-ready-on-main-merge.yml new file mode 100644 index 0000000..00bb4a6 --- /dev/null +++ b/.forgejo/workflows/plan-issue-ready-on-main-merge.yml @@ -0,0 +1,26 @@ +# Safety net: PRs mergeados em main com `Related #N` promovem Issues que ainda +# aguardam o plano de intenção (`blocked` + docs/plans/ link) para `ready`. +# Idempotente com `bun run agent:ready` (caminho feliz do agente). + +name: Promote plan-issue Related on main merge + +on: + pull_request: + types: [closed] + branches: [main] + +jobs: + plan-issue-ready: + if: >- + github.event.pull_request.merged == true && + ( + contains(github.event.pull_request.body, 'Related #') || + contains(github.event.pull_request.body, 'related #') || + contains(github.event.pull_request.body, 'RELATED #') + ) + runs-on: host + timeout-minutes: 5 + steps: + - uses: actions/checkout@v5 + - name: Promote Related #N awaiting plan → ready + run: node scripts/agent-promote-related-on-merge.mjs --pr "${{ github.event.pull_request.number }}" diff --git a/.forgejo/worktree.env b/.forgejo/worktree.env new file mode 100644 index 0000000..e3bf0f4 --- /dev/null +++ b/.forgejo/worktree.env @@ -0,0 +1,2 @@ +FORGEJO_REPOSITORY=amana/iara-pwa +WORKTREES_ROOT=~/.worktrees/iara diff --git a/.gitignore b/.gitignore index a547bf3..c3af59d 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,10 @@ dist dist-ssr *.local +# Playwright +playwright-report +test-results + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/.opencode/commands/issue.md b/.opencode/commands/issue.md new file mode 100644 index 0000000..e750962 --- /dev/null +++ b/.opencode/commands/issue.md @@ -0,0 +1,7 @@ +--- +description: Mostra a próxima Issue claimável (next) ou overview das Issues abertas (all) +--- + +Rode o script determinístico abaixo (não improvise a lógica) e reporte o resultado de forma concisa. + +!`node scripts/issue.mjs $ARGUMENTS` diff --git a/.opencode/commands/plan-issue.md b/.opencode/commands/plan-issue.md new file mode 100644 index 0000000..d4a5af1 --- /dev/null +++ b/.opencode/commands/plan-issue.md @@ -0,0 +1,8 @@ +--- +description: Transforma ideias humanas em Issues Forgejo + planos de intenção em docs/plans/ (com gate e registro) +model: deepseek/deepseek-v4-flash +--- + +Carregue a skill `plan-issue` (ferramenta de skills, nome exato) e siga o fluxo dela de ponta a ponta. A fonte canônica é `.agents/skills/plan-issue/SKILL.md` — não a transcreva nem recrie o fluxo. + +$ARGUMENTS diff --git a/.opencode/commands/work-issue.md b/.opencode/commands/work-issue.md new file mode 100644 index 0000000..ca9724e --- /dev/null +++ b/.opencode/commands/work-issue.md @@ -0,0 +1,8 @@ +--- +description: Executa uma Issue já claimada de ponta a ponta com supervisão humana (plano de implementação → pausa → execução → simplify → débitos → PR) +model: deepseek/deepseek-v4-flash +--- + +Carregue a skill `work-issue` (ferramenta de skills, nome exato) e siga o fluxo dela de ponta a ponta. A fonte canônica é `.agents/skills/work-issue/SKILL.md` — não a transcreva nem recrie o fluxo. + +$ARGUMENTS diff --git a/.opencode/commands/worktree.md b/.opencode/commands/worktree.md new file mode 100644 index 0000000..6bfda49 --- /dev/null +++ b/.opencode/commands/worktree.md @@ -0,0 +1,14 @@ +--- +description: Claima a próxima Issue (next [--issue N] [--stay]), cria o worktree dela e provisiona o ambiente; também cria worktree de planejamento (plan [bag] [--stay]), worktree neutro (new [bag] [--stay]) ou destrói o atual (kill [--force]) +--- + +Rode o script determinístico abaixo (não improvise a lógica) e reporte o resultado de forma concisa. + +- `next [--issue N] [--stay]`: **claima** a próxima Issue claimável da fila (mesma fila/ordem e lock otimista de `bun run agent:claim`) e cria (ou reutiliza) o worktree dela. Claim falhou → reporte o motivo — não há worktree. `--issue N` claima a Issue direcionada (`ready`) ou **reabre** uma já claimada (`in-progress`, sem re-claim). A saída avisa "já claimada — NÃO rodar `bun run agent:claim`". Por padrão o script imprime `cd ` na última linha — aplique-o na sessão de shell atual (`cd `) e reporte o novo diretório. `--stay` suprime o `cd` (não troque de diretório) mas o claim ainda acontece. `--go` explícito continua aceito como no-op. +- `plan [bag] [--stay]`: cria um worktree de planejamento **diferente a cada invocação** (branch `plans/plan-issue-`, ou `plans/plan-issue-` sequencial sem bag; sufixo `-2`/`-3` em colisão) para rodar `/plan-issue` sem ocupar o main; nunca conflita com `next`. Não claima nada. Mesmo contrato de `cd` do `next`: aplique a última linha `cd ` por padrão; `--stay` suprime. +- `new [bag] [--stay]`: cria um worktree **neutro** (sem função pré-definida — explorar ideia, conversar, planejar sem registrar nada) **diferente a cada invocação** (branch `work/`, ou `work/` sequencial sem bag; sufixo `-2`/`-3` em colisão); o prefixo minúsculo `work/…` nunca conflita com `next` nem `plan`. Não claima nada. Mesmo contrato de `cd` do `plan`. +- `kill [--force]`: destrói o worktree em que você está. O script imprime `cd
` na última linha — aplique-o (`cd
`) para a sessão sair do diretório destruído. + +> **Este comando roda SEM o marcador `TEQO_WORKTREE_TERMINAL`, então nunca recebe a diretiva `launch`** — só o `cd`. O launch do opencode acontece apenas na função `worktree()` do roteador global (`~/.config/shell/worktree.sh`, terminal interativo); nunca abra um TUI aninhado a partir daqui. + +!`node scripts/worktree.mjs $ARGUMENTS` diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..155008c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,86 @@ +# Iara — convenções de trabalho para agentes + +**Iara** é um chat PWA local-first para falar com assistentes de IA (gateways +compatíveis com o protocolo ZeroClaw). Vite + React + TypeScript + assistant-ui ++ Tailwind v4 + vite-plugin-pwa. Sem backend próprio: o estado do cliente vive +no dispositivo (localStorage); o único servidor externo é o gateway WS do +usuário. + +## Stack e scripts + +- **Runtime:** bun (também há `node` para os scripts de agente — são ESM puros). +- `bun run dev` — dev server; `bun run build` — `tsc -b && vite build` (build de produção em `dist/`); `bun run preview` — servir o build. +- `bun run gate` — gate local: `lint` (oxlint) + `build` (typecheck + build). Rode antes de push. +- `bun run test:e2e` — **e2e Playwright; roda SÓ no CI, nunca local** (decisão do fluxo: não rodar e2e local para não divergir do CI). + +## Arquitetura do código + +- `src/lib/ws.ts` — protocolo WebSocket `zeroclaw.v1` (tipos de mensagens, conecta em `/ws/chat?token=&agent=&name=`). **Contrato**: mudar tipos de protocolo é breaking. +- `src/lib/messages.ts` — normalização/montagem de mensagens. +- `src/lib/settings.ts` — config local (URL/key/agente/STT/TTS) em localStorage. +- `src/lib/voice.ts` — STT/TTS opcional (Web Speech + endpoints configuráveis). +- `src/components/Chat.tsx` — thread + composer (assistant-ui primitives). +- `src/components/Settings.tsx` — tela de configuração. +- `src/components/ApprovalCard.tsx` — card de aprovação (`approval_request` → `approval_response`). +- `vite.config.ts` — base `/_app/` (servido pelo gateway do ZeroClaw), PWA manifest + service worker, background sync do WS. + +**Invariantes:** local-first (nada de servidor para estado do cliente); token +nunca logado/commitado; aprovações nunca auto-aprovadas; copy pt-BR / +identificadores em inglês; assets offline em `globPatterns` do PWA. + +## Workflow de trabalho (fluxo tipo teqo) + +Issues do Forgejo (`git.solla.dev/amana/iara-pwa`) são a fonte canônica de +spec/status/deps/prio. Estado em labels: `ready | in-progress | blocked | done` +(+ `in-prod`). Frontmatter no body da Issue: + +```yaml +--- +id: I7 +depends: [I3] +serializes: [] +priority: P1 +--- +Plano: [`docs/plans/.md`](docs/plans/.md) +``` + +Ciclo completo (detalhes nas skills `plan-issue` / `work-issue`): + +1. **`/plan-issue`** (num worktree `bun run worktree plan`) → plano de intenção em `docs/plans/.md` + Issue `blocked` (`bun run agent:register --plan`) → PR `Related #N` → merge → `bun run agent:ready -- --issue N` (ou o CI promove sozinho). +2. **`bun run worktree next`** → claima a próxima Issue `ready` (label `in-progress`), cria worktree `~/.worktrees/iara/-` de `origin/main`, roda `bun install` e abre o opencode com `/work-issue --issue N`. +3. **`/work-issue`** → plano de implementação (`docs/plans/-impl.md`), pausa para aprovação humana, execução. +4. **`/simplify`** (skill `code-simplification`) → 3 reviews locais paralelos no diff. +5. **`capture-review-debts`** → triagem + débitos (`bun run agent:register` / `agent:file-miss`). +6. **PR Ready** `Closes #N` com auto-merge (`bun run pr -- --automerge`) → CI (lint+build+e2e) → merge → `done`/`in-prod` → deploy. + +### Comandos de agente (bun) + +| Comando | O que faz | +| ------- | --------- | +| `bun run worktree next/plan/new/kill` | fila → worktree da Issue / planejamento / neutro / destruir | +| `bun run issue next\|all` | próxima claimável / overview (read-only) | +| `bun run agent:claim` | claima a próxima `ready` (não usar em sessão — `worktree next` já claima) | +| `bun run agent:register` | cria Issue rastreável (com `--plan` nasce `blocked`) | +| `bun run agent:ready` | promove `blocked` → `ready` (plano em main) | +| `bun run agent:file-miss` | registra défice do fluxo de agentes | +| `bun run agent:status` | grafo/overview read-only | +| `bun run pr -- --head --body "Closes #N" --automerge` | cria PR e mergea quando os checks passarem | + +**O comando de terminal `worktree` é o roteador global** (`~/.config/shell/worktree.sh`): descobre o projeto pelo diretório atual (`git rev-parse`) e roda o script do projeto; config por projeto em `.forgejo/worktree.env` (commitado, sem secrets). + +## CI (Forgejo Actions — `.forgejo/workflows/`) + +- `ci.yml` — em push (main) e PR: job `checks` (lint + build + **e2e** Playwright) e job `deploy` (só main, `needs: checks`, rsync para o homeserver). +- `issue-done-on-main-merge.yml` — PRs mergeadas com `Closes #N` flipam a Issue para `done`/`in-prod` (`scripts/forgejo-issue-transition.mjs`). +- `plan-issue-ready-on-main-merge.yml` — PRs com `Related #N` promovem `blocked` → `ready` (`scripts/agent-promote-related-on-merge.mjs`). +- `agent-pr-ready-automerge.yml` — marca Ready e mergea quando `checks` ficar green (safety net do auto-merge). + +**e2e só no CI** — nunca rode `bun run test:e2e` local como parte do fluxo normal. + +## Regras de entrega + +- **Gate antes de push:** `bun run gate` limpo. CI roda lint+build+e2e de novo no PR. +- **PR nunca draft** para entrega; `Closes #N` no body; auto-merge armado. +- **Nunca editar Issue `in-progress`** de outra sessão; débito vira Issue nova com `depends` no pai. +- **Planos imutáveis** para Issues `in-progress`/`done`/`in-prod` — refino = sucessor. +- **Sem `git push` nu** com o fluxo de agente — use o fluxo do PR (a branch é do worktree). diff --git a/bun.lock b/bun.lock index 1be4dd7..2c29083 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,7 @@ "vite-plugin-pwa": "^1.3.0", }, "devDependencies": { + "@playwright/test": "^1.62.1", "@types/node": "^24.13.3", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", @@ -284,6 +285,8 @@ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.78.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Sb5ocmLSuYeOuXd+CFOToGKp/gjXUEWDnvIGwhnh8aq8wY4TMmEnKnvbogSW7RdMZv77JSARduS7/gv+khYEjA=="], + "@playwright/test": ["@playwright/test@1.62.1", "", { "dependencies": { "playwright": "1.62.1" }, "bin": { "playwright": "cli.js" } }, "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ=="], + "@radix-ui/number": ["@radix-ui/number@1.1.3", "", {}, "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.7", "", {}, "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q=="], @@ -1006,6 +1009,10 @@ "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + "playwright": ["playwright@1.62.1", "", { "dependencies": { "playwright-core": "1.62.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg=="], + + "playwright-core": ["playwright-core@1.62.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="], + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="], @@ -1282,6 +1289,8 @@ "path-scurry/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "workbox-build/pretty-bytes": ["pretty-bytes@5.6.0", "", {}, "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg=="], diff --git a/docs/plans/README.md b/docs/plans/README.md new file mode 100644 index 0000000..c956149 --- /dev/null +++ b/docs/plans/README.md @@ -0,0 +1,13 @@ +# Planos de intenção / implementação + +Convenção herdada do teqo (paralelismo com fila de claim em Issues Forgejo): + +- **`docs/plans/.md`** — plano de **intenção** (produto): o quê / para + quem / por quê / outcome, direção suave no codebase. Criado pela skill + `plan-issue`; a Issue nasce `blocked` até o plano estar em `main`. +- **`docs/plans/-impl.md`** — plano de **implementação** (engenharia): + abordagem + rejeitadas + fases. Criado pela skill `work-issue` no worktree, + com gate humano antes da execução. + +Planos de Issues `in-progress`/`done`/`in-prod` são **imutáveis** — refino vira +plano + Issue sucessor (`depends` no pai). diff --git a/package.json b/package.json index f5cc3bf..8394622 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,20 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "oxlint", - "preview": "vite preview" + "preview": "vite preview", + "gate": "bun run lint && bun run build", + "test:e2e": "playwright test", + "worktree": "node scripts/worktree.mjs", + "issue": "node scripts/issue.mjs", + "pr": "node scripts/pr.mjs", + "agent:claim": "node scripts/agent-claim.mjs", + "agent:register": "node scripts/agent-register.mjs", + "agent:ready": "node scripts/agent-ready.mjs", + "agent:file-miss": "node scripts/agent-file-miss.mjs", + "agent:status": "node scripts/agent-status.mjs", + "agent:promote-related": "node scripts/agent-promote-related-on-merge.mjs", + "pr:automerge": "node scripts/forgejo-pr-automerge.mjs", + "issue:transition": "node scripts/forgejo-issue-transition.mjs" }, "dependencies": { "@assistant-ui/react": "^0.15.14", @@ -20,6 +33,7 @@ "vite-plugin-pwa": "^1.3.0" }, "devDependencies": { + "@playwright/test": "^1.62.1", "@types/node": "^24.13.3", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..3ec880d --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/e2e", + timeout: 30_000, + expect: { timeout: 10_000 }, + fullyParallel: true, + retries: process.env.CI ? 1 : 0, + workers: process.env.CI ? 2 : 1, + use: { + baseURL: "http://localhost:4173/_app/", + trace: "on-first-retry", + }, + webServer: { + command: "bun run build && bun run preview -- --port 4173 --strictPort", + port: 4173, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/scripts/agent-claim.mjs b/scripts/agent-claim.mjs new file mode 100644 index 0000000..e3936e5 --- /dev/null +++ b/scripts/agent-claim.mjs @@ -0,0 +1,64 @@ +/** + * `bun run agent:claim` — claim the next trackable issue for this agent run. + * + * Queue: open issues labeled `ready`, filtered to UNBLOCKED (every frontmatter + * `depends` id is closed or labeled `done`/`in-prod`), ordered by `prio:P0..P3` + * then oldest first. `--dry-run` prints the queue without claiming. + * + * Lock otimista: re-reads the issue right before flipping labels, refuses if + * someone else already took it (`in-progress` present / `ready` gone), then + * swaps ready→in-progress and leaves a claim comment with the run timestamp. + * + * Portado do teqo (scripts/agent-claim.mjs). + */ + +import { + buildClaimQueue, + claimBriefLines, + claimIssue, + dieAgent, + issuesById, + parseArgs, +} from './lib/agent-forgejo.mjs' +import { loadProjectEnv } from './lib/load-project-env.mjs' + +loadProjectEnv() +const forgejoModule = await import('./lib/forgejo-api.mjs'); +const { forgejoApi: api } = forgejoModule; + +const die = dieAgent('claim') +const { flags } = parseArgs(process.argv.slice(2), new Set(['issue'])) + +const openReady = await api.listIssues({ state: 'open', labels: 'ready', limit: 200 }) + +const queue = buildClaimQueue(openReady, await issuesById()) + +if (flags['dry-run'] || flags['dryrun']) { + console.log(`[agent:claim] ${queue.length} unblocked ready issue(s):`) + for (const entry of queue) { + const model = entry.meta.model ? ` model:${entry.meta.model}` : '' + console.log(` #${entry.issue.number} [${entry.priority}]${model} ${entry.issue.title}`) + } + process.exit(0) +} + +const pick = flags.issue + ? queue.find((entry) => entry.issue.number === Number(flags.issue)) + : queue[0] + +if (!pick) { + die( + flags.issue + ? `Issue #${flags.issue} is not claimable (missing, not ready, or blocked by deps).` + : 'Nothing to claim — no unblocked issues labeled `ready`. Run with --dry-run to inspect.', + ) +} + +await claimIssue(pick, die) + +console.log(`\n[agent:claim] Claimed #${pick.issue.number} — ${pick.issue.title}`) +for (const line of claimBriefLines(pick)) console.log(line) +console.log( + '\n[agent:claim] Fluxo: claim feito fora da sessão → abra a sessão de trabalho no worktree (skill `work-issue`) → gates → PR Ready --base main (Closes #' + + `${pick.issue.number}) → auto-merge.`, +) diff --git a/scripts/agent-file-miss.mjs b/scripts/agent-file-miss.mjs new file mode 100644 index 0000000..0e17332 --- /dev/null +++ b/scripts/agent-file-miss.mjs @@ -0,0 +1,45 @@ +/** + * `bun run agent:file-miss` — file an agent-miss issue (guardrail pipeline). + * + * Misses feed the progressive-guardrail harvest: each miss becomes an issue + * with `kind:agent-miss`; periodically a human/agent harvests them into a + * programmatic guardrail. + * + * bun run agent:file-miss -- --title "migration merged sem atualizar seed:minimal" \ + * [--body "contexto..."] [--kind agent-miss|defect] + * + * Portado do teqo (scripts/agent-file-miss.mjs) — URL derivada do repo. + */ + +import { dieAgent, parseArgs, issueUrl } from './lib/agent-forgejo.mjs' +import { loadProjectEnv } from './lib/load-project-env.mjs' + +loadProjectEnv() +const forgejoModule = await import('./lib/forgejo-api.mjs');; +const { forgejoApi: api } = forgejoModule + +const die = dieAgent('file-miss') +const { flags } = parseArgs(process.argv.slice(2), new Set(['title', 'body', 'kind'])) + +if (!flags.title) { + die( + 'Usage: bun run agent:file-miss -- --title [--body details] [--kind agent-miss|defect]', + ) +} + +const kind = flags.kind === 'defect' ? 'defect' : 'agent-miss' +const body = [ + flags.body ?? '', + '', + '_Registrado por `bun run agent:file-miss`. Harvest: avaliar guardrail programático (teste de convenção, lint, check de CI)._', +].join('\n') + +const created = await api.createIssue({ + title: flags.title, + body, +}) +const url = issueUrl(created.number) + +await api.setLabels(created.number, { add: [`kind:${kind}`, 'prio:P2'] }) + +console.log(`[agent:file-miss] filed ${url}`) diff --git a/scripts/agent-promote-related-on-merge.mjs b/scripts/agent-promote-related-on-merge.mjs new file mode 100644 index 0000000..633282c --- /dev/null +++ b/scripts/agent-promote-related-on-merge.mjs @@ -0,0 +1,78 @@ +/** + * OPS18 safety net — on PR merge to `main`, promote plan-issue Issues cited as + * `Related #N` from `blocked` → `ready` when they are still awaiting a plan. + * + * Soft-skips ineligible Issues (already ready, product-blocked without a plan + * link, needs:*, etc.). Flip/comment failures for one Issue are logged and do + * not abort siblings. Idempotent with `pnpm agent:ready`. + * + * node scripts/agent-promote-related-on-merge.mjs --pr 123 + * PR_BODY='Related #296' node scripts/agent-promote-related-on-merge.mjs --pr 123 + * + * Env: GITHUB_TOKEN (Forgejo Actions) / FORGEJO_API_TOKEN; optional PR_BODY skips the PR read. + */ + +import { dieAgent, parseArgs, setLabels } from './lib/agent-forgejo.mjs' +import { canPromotePlanIssue, parseRelatedIssueNumbers } from './lib/agent-plan-lifecycle.mjs' +import { forgejoApi as api } from './lib/forgejo-api.mjs' + +const die = dieAgent('promote-related') +const { flags } = parseArgs(process.argv.slice(2), new Set(['pr'])) + +if (!flags.pr || !/^\d+$/.test(String(flags.pr))) { + die('Usage: node scripts/agent-promote-related-on-merge.mjs --pr ') +} + +const prNumber = Number(flags.pr) +const bodyFromEnv = process.env.PR_BODY +const body = + typeof bodyFromEnv === 'string' ? bodyFromEnv : (await api.getPullRequest(prNumber)).body +const numbers = parseRelatedIssueNumbers(body) + +if (numbers.length === 0) { + console.log(`[agent:promote-related] PR #${prNumber}: no Related #N — nothing to do`) + process.exit(0) +} + +console.log( + `[agent:promote-related] PR #${prNumber}: Related → ${numbers.map((n) => `#${n}`).join(', ')}`, +) + +for (const number of numbers) { + let issue + try { + issue = await api.getIssue(number) + } catch (error) { + console.log( + `[agent:promote-related] #${number}: skip (issue view failed: ${ + error instanceof Error ? error.message : String(error) + })`, + ) + continue + } + + const verdict = canPromotePlanIssue(issue) + if (!verdict.ok) { + if (verdict.reason === 'already-ready') { + console.log(`[agent:promote-related] #${number}: already ready — skip`) + continue + } + console.log(`[agent:promote-related] #${number}: skip (${verdict.reason})`) + continue + } + + try { + await setLabels(number, { add: ['ready'], remove: ['blocked'] }) + await api.addComment( + number, + `Promovido a \`ready\` via Action OPS18 — plano de intenção em \`main\` (PR #${prNumber} com \`Related #${number}\`).`, + ) + console.log(`[agent:promote-related] #${number}: blocked → ready`) + } catch (error) { + console.log( + `[agent:promote-related] #${number}: promote failed (${ + error instanceof Error ? error.message : String(error) + })`, + ) + } +} diff --git a/scripts/agent-ready.mjs b/scripts/agent-ready.mjs new file mode 100644 index 0000000..3920d83 --- /dev/null +++ b/scripts/agent-ready.mjs @@ -0,0 +1,58 @@ +/** + * `bun run agent:ready` — flip plan-issue Issues from `blocked` → `ready` after + * the intention plan is on `main`. Idempotent per issue. + * + * bun run agent:ready -- --issue 12 + * bun run agent:ready -- --issue 12,13 + * + * Fail-closed via `canPromotePlanIssue`: open + `blocked` + `docs/plans/` link, + * and not `needs:consent` / `requirements-changed` / `in-progress`|`done`|`in-prod`. + * Idempotent skip when already `ready`. + * + * Portado do teqo (scripts/agent-ready.mjs). + */ + +import { dieAgent, parseArgs, setLabels } from './lib/agent-forgejo.mjs' +import { canPromotePlanIssue } from './lib/agent-plan-lifecycle.mjs' +import { loadProjectEnv } from './lib/load-project-env.mjs' + +loadProjectEnv() +const forgejoModule = await import('./lib/forgejo-api.mjs');; +const { forgejoApi: api } = forgejoModule + +const die = dieAgent('ready') +const { flags } = parseArgs(process.argv.slice(2), new Set(['issue'])) + +if (!flags.issue) { + die('Usage: bun run agent:ready -- --issue ') +} + +const numbers = String(flags.issue) + .split(',') + .map((value) => value.trim()) + .filter(Boolean) + .map((value) => { + if (!/^\d+$/.test(value)) die(`Invalid issue number "${value}".`) + return Number(value) + }) + +if (numbers.length === 0) die('Usage: bun run agent:ready -- --issue ') + +for (const number of numbers) { + const issue = await api.getIssue(number) + const verdict = canPromotePlanIssue(issue) + if (!verdict.ok) { + if (verdict.reason === 'already-ready') { + console.log(`[agent:ready] #${number} already ready — skip`) + continue + } + die(`#${number} cannot mark ready (${verdict.reason}).`) + } + + await setLabels(number, { add: ['ready'], remove: ['blocked'] }) + await api.addComment( + number, + 'Promovido a `ready`: plano de intenção em `main` (ciclo de planejamento — `bun run agent:ready`).', + ) + console.log(`[agent:ready] #${number} blocked → ready`) +} diff --git a/scripts/agent-register.mjs b/scripts/agent-register.mjs new file mode 100644 index 0000000..80f5d71 --- /dev/null +++ b/scripts/agent-register.mjs @@ -0,0 +1,90 @@ +/** + * `bun run agent:register` — create a trackable issue (spec + prio + deps). + * + * bun run agent:register -- --id I7 --title "Ações rápidas — X" \ + * --prio P1 --depends I3 --kind feature --plan docs/plans/x.md [--body "..."] + * + * Body = frontmatter (id/depends/priority) + spec (linked plan + --body text). + * Starts life labeled `ready`, or `blocked` when `--blocked` / `--plan` + * (plan-linked Issues are not claimable until the plan is on `main` and + * `bun run agent:ready` promotes them). + * + * Portado do teqo (scripts/agent-register.mjs) — URL derivada do repo. + */ + +import { + dieAgent, + issuesById, + parseArgs, + serializeFrontmatter, + issueUrl, +} from './lib/agent-forgejo.mjs' +import { resolveRegisterStateLabel } from './lib/agent-plan-lifecycle.mjs' +import { loadProjectEnv } from './lib/load-project-env.mjs' + +loadProjectEnv() +const forgejoModule = await import('./lib/forgejo-api.mjs');; +const { forgejoApi: api } = forgejoModule + +const die = dieAgent('register') +const { flags } = parseArgs( + process.argv.slice(2), + new Set(['id', 'title', 'prio', 'depends', 'kind', 'plan', 'body', 'labels', 'model']), +) + +if (!flags.id || !flags.title) + die( + 'Usage: bun run agent:register -- --id --title [--prio P2] [--depends A,B] [--kind feature] [--plan path] [--body text] [--labels extra,labels] [--model slug] [--blocked]', + ) + +const priority = /^P[0-3]$/.test(flags.prio ?? 'P2') + ? (flags.prio ?? 'P2') + : die(`Invalid --prio "${flags.prio}" (P0..P3).`) +const depends = (flags.depends ?? '') + .split(',') + .map((v) => v.trim()) + .filter(Boolean) + +const existing = (await issuesById()).get(flags.id) +if (existing) + die(`An issue with id ${flags.id} already exists: #${existing.number} — ${existing.title}`) + +const body = serializeFrontmatter( + { + id: flags.id, + depends, + serializes: [], + priority, + ...(flags.model ? { model: flags.model } : {}), + }, + [flags.plan ? `Plano: [\`${flags.plan}\`](${flags.plan})` : null, flags.body ?? ''] + .filter((chunk) => chunk !== null) + .join('\n\n'), +) + +const stateLabel = resolveRegisterStateLabel({ + hasPlan: Boolean(flags.plan), + explicitBlocked: Boolean(flags.blocked), +}) +const labels = [ + stateLabel, + `prio:${priority}`, + `kind:${flags.kind ?? 'feature'}`, + ...(flags.labels ?? '') + .split(',') + .map((v) => v.trim()) + .filter(Boolean), +] + +const created = await api.createIssue({ + title: `${flags.id} — ${flags.title}`, + body, +}) +await api.setLabels(created.number, { add: labels }) +const url = issueUrl(created.number) + +const hint = + stateLabel === 'blocked' && flags.plan && !flags.blocked + ? ' (blocked até plano em main — depois: bun run agent:ready -- --issue <N>)' + : '' +console.log(`[agent:register] created ${url}${hint}`) diff --git a/scripts/agent-status.mjs b/scripts/agent-status.mjs new file mode 100644 index 0000000..f126946 --- /dev/null +++ b/scripts/agent-status.mjs @@ -0,0 +1,223 @@ +/** + * `pnpm agent:status` — read-only overview of the trackable-issue graph. + * + * Sections: overview counters (state/prio/kind), current claim queue (same + * ordering as `agent:claim --dry-run`, with `model:`), mermaid dependency + * graph of open issues, blockers with reasons, and consolidation suggestions + * (merge / break-down candidates). Never touches issues labeled + * `in-progress` — read-only over `gh issue list --state all`. + */ + +import { + dieAgent, + issuesById, + labelNames, + parseFrontmatter, + priorityRank, +} from './lib/agent-forgejo.mjs' +import { forgejoApi as api } from './lib/forgejo-api.mjs' + +const die = dieAgent('status') + +let issues +try { + issues = await api.listIssues({ state: 'all', limit: 200 }) +} catch (error) { + die(`Forgejo issue list failed: ${error.message}`) +} + +const rows = issues + .map((issue) => { + const { meta } = parseFrontmatter(issue.body) + if (typeof meta.id !== 'string' || meta.id.length === 0) return null + const labels = labelNames(issue) + const stateLabel = labels.find((label) => + ['ready', 'in-progress', 'blocked', 'done', 'in-prod'].includes(label), + ) + return { + issue, + meta, + labels, + state: + issue.state === 'CLOSED' + ? (stateLabel ?? 'done') + : (stateLabel ?? (issue.state === 'OPEN' ? 'open' : 'done')), + priority: labels.find((label) => /^prio:P[0-3]$/.test(label))?.replace('prio:', '') ?? 'P2', + kind: labels.find((label) => label.startsWith('kind:'))?.replace('kind:', '') ?? 'feature', + needs: labels.filter((label) => label.startsWith('needs:')), + depends: Array.isArray(meta.depends) ? meta.depends : [], + model: typeof meta.model === 'string' ? meta.model : null, + } + }) + .filter(Boolean) + +const open = rows.filter((row) => row.issue.state === 'OPEN') +const doneIds = new Set( + rows + .filter( + (row) => row.issue.state === 'CLOSED' || row.state === 'done' || row.state === 'in-prod', + ) + .map((row) => row.meta.id), +) + +// --- Overview ---------------------------------------------------------------- + +console.log('## Overview\n') +const countBy = (fn) => { + const counts = new Map() + for (const row of rows) { + const key = fn(row) + counts.set(key, (counts.get(key) ?? 0) + 1) + } + return [...counts.entries()].sort((a, b) => String(a[0]).localeCompare(String(b[0]))) +} +console.log(`Total rastreáveis: ${rows.length} (${open.length} abertas)`) +console.log( + 'Por estado: ' + + countBy((row) => row.state) + .map(([state, count]) => `${state}=${count}`) + .join(' '), +) +console.log( + 'Abertas por prio: ' + + countBy((row) => (row.issue.state === 'OPEN' ? row.priority : null)) + .filter(([prio]) => prio !== null) + .map(([prio, count]) => `${prio}=${count}`) + .join(' '), +) +console.log( + 'Abertas por kind: ' + + countBy((row) => (row.issue.state === 'OPEN' ? row.kind : null)) + .filter(([kind]) => kind !== null) + .map(([kind, count]) => `${kind}=${count}`) + .join(' '), +) + +const needsAttention = open.filter((row) => row.needs.length > 0) +if (needsAttention.length > 0) { + console.log('\nneeds:* abertas:') + for (const row of needsAttention) { + console.log( + ` #${row.issue.number} ${row.meta.id} [${row.needs.join(', ')}] ${row.issue.title}`, + ) + } +} + +const recentDone = rows.filter((row) => row.state === 'done' || row.state === 'in-prod').slice(-5) +if (recentDone.length > 0) { + console.log('\nÚltimas concluídas:') + for (const row of recentDone) { + console.log(` #${row.issue.number} ${row.meta.id} [${row.state}] ${row.issue.title}`) + } +} + +// --- Fila atual (mesma ordem do agent:claim --dry-run) ------------------------ + +console.log('\n## Fila atual (ordem do agent:claim)\n') +const byId = await issuesById() +const queue = open + .filter((row) => row.state === 'ready') + .map((row) => ({ + row, + blockedBy: row.depends.filter((id) => byId.has(id) && !doneIds.has(id)), + })) + .filter((entry) => entry.blockedBy.length === 0) + .sort((a, b) => { + const rank = priorityRank(a.row.priority) - priorityRank(b.row.priority) + return rank !== 0 ? rank : a.row.issue.createdAt.localeCompare(b.row.issue.createdAt) + }) + +if (queue.length === 0) { + console.log('(vazia — nada `ready` desbloqueado)') +} else { + queue.forEach(({ row }, index) => { + const top = index === 0 ? ' ← topo' : '' + const model = row.model ? ` model:${row.model}` : ' model:—' + console.log( + ` #${row.issue.number} ${row.meta.id} [${row.priority}]${model} ${row.issue.title}${top}`, + ) + }) +} + +// --- Bloqueios --------------------------------------------------------------- + +const blocked = open.filter( + (row) => row.state === 'blocked' || row.depends.some((id) => byId.has(id) && !doneIds.has(id)), +) +console.log('\n## Bloqueios\n') +if (blocked.length === 0) { + console.log('(nenhum)') +} else { + for (const row of blocked) { + const depBlockers = row.depends.filter((id) => byId.has(id) && !doneIds.has(id)) + const reasons = [] + if (depBlockers.length > 0) reasons.push(`dep aberta: ${depBlockers.join(', ')}`) + for (const need of row.needs) reasons.push(need) + if (row.labels.includes('requirements-changed')) reasons.push('requirements-changed') + if (row.state === 'blocked' && reasons.length === 0) + reasons.push('label blocked (motivo no body)') + console.log( + ` #${row.issue.number} ${row.meta.id} [${row.priority}] ${row.issue.title} — ${reasons.join('; ')}`, + ) + } +} + +// --- Mermaid ----------------------------------------------------------------- + +console.log('\n## Grafo (abertas)\n') +console.log('```mermaid') +console.log('graph LR') +for (const row of open) { + const label = `${row.meta.id} ${row.state}/${row.priority}` + console.log(` ${row.meta.id}["${label}"]`) +} +for (const row of open) { + for (const dep of row.depends) { + if (byId.has(dep)) console.log(` ${dep} --> ${row.meta.id}`) + } +} +console.log('```') + +// --- Sugestões de consolidação (nunca toca in-progress) ----------------------- + +console.log('\n## Sugestões de consolidação (só leitura — decisão é humana)\n') +const candidates = open.filter((row) => row.state !== 'in-progress') +const mergeCandidates = [] +const byPrefix = new Map() +for (const row of candidates) { + // Domain proxy: the first two significant words of the title (after the + // "ID — " prefix). Items sharing domain + prio with no execution are the + // merge-review candidates; a bare same-track grouping is too noisy. + const words = row.issue.title + .replace(/^[A-Z0-9+]+\s*—\s*/, '') + .split(/[\s/—-]+/) + .filter(Boolean) + .slice(0, 2) + .join(' ') + .toLowerCase() + const key = `${words}/${row.priority}` + byPrefix.set(key, [...(byPrefix.get(key) ?? []), row]) +} +for (const [key, group] of byPrefix) { + if (group.length >= 2 && group.length <= 6) { + mergeCandidates.push( + `mesmo domínio/prio (${key}), ${group.length} abertas sem execução: ${group.map((row) => row.meta.id).join(', ')} — avaliar merge`, + ) + } +} +const dependentCount = new Map() +for (const row of candidates) { + for (const dep of row.depends) dependentCount.set(dep, (dependentCount.get(dep) ?? 0) + 1) +} +const breakdownCandidates = candidates.filter((row) => (dependentCount.get(row.meta.id) ?? 0) >= 3) + +if (mergeCandidates.length === 0 && breakdownCandidates.length === 0) { + console.log('(nenhuma heurística disparou)') +} else { + for (const line of mergeCandidates) console.log(` merge? ${line}`) + for (const row of breakdownCandidates) { + console.log( + ` break-down? #${row.issue.number} ${row.meta.id} bloqueia ${dependentCount.get(row.meta.id)} itens — avaliar fatiar (título guarda-chuva?)`, + ) + } +} diff --git a/scripts/forgejo-issue-transition.mjs b/scripts/forgejo-issue-transition.mjs new file mode 100644 index 0000000..d89bbea --- /dev/null +++ b/scripts/forgejo-issue-transition.mjs @@ -0,0 +1,67 @@ +/** + * Plain-Node CLI (no pnpm) — on merge to main, flip Issues cited as + * `Closes #N` / `Fixes #N` in the PR body to `done` + `in-prod` and comment. + * Replacement for the `gh issue edit/comment` loop of issue-done-on-main-merge. + * The PR body comes from the API (Forgejo's PR webhook payload is not relied + * on), so the workflow only needs the PR number. + * + * node scripts/forgejo-issue-transition.mjs --pr <N> + */ + +import { createApi } from './lib/forgejo-api.mjs' + +const parseArgs = (argv) => { + const flags = {} + for (let index = 2; index < argv.length; index += 1) { + const arg = argv[index] + if (arg.startsWith('--')) { + const name = arg.slice(2) + const next = argv[index + 1] + flags[name] = typeof next === 'string' && !next.startsWith('--') ? next : true + if (typeof next === 'string' && !next.startsWith('--')) index += 1 + } + } + return flags +} + +const flags = parseArgs(process.argv) +const prNumber = Number(flags.pr) +if (!Number.isInteger(prNumber) || prNumber <= 0) { + console.error('Usage: node scripts/forgejo-issue-transition.mjs --pr <N>') + process.exit(1) +} + +const api = createApi({}) +const pr = await api.getPullRequest(prNumber) +if (!pr) { + console.error(`[forgejo-issue-transition] PR #${prNumber} não encontrada`) + process.exit(1) +} +if (!pr.merged) { + console.log(`[forgejo-issue-transition] PR #${prNumber} não mergeada — skip`) + process.exit(0) +} + +const numbers = [ + ...new Set([...pr.body.matchAll(/(?:closes|fixes)\s+#(\d+)/gi)].map((match) => Number(match[1]))), +] + +if (numbers.length === 0) { + console.log(`[forgejo-issue-transition] PR #${prNumber}: nenhum Closes/Fixes #N — nada a fazer`) + process.exit(0) +} + +for (const number of numbers) { + try { + await api.setLabels(number, { add: ['done', 'in-prod'], remove: ['in-progress'] }) + await api.addComment( + number, + `Merged em main via PR #${prNumber} — \`done\` + \`in-prod\`. Deploy de produção segue o verificador \`ci.yml\` (gated).`, + ) + console.log(`[forgejo-issue-transition] #${number}: done + in-prod`) + } catch (error) { + console.log( + `[forgejo-issue-transition] #${number}: skip (${error instanceof Error ? error.message : error})`, + ) + } +} diff --git a/scripts/forgejo-pr-automerge.mjs b/scripts/forgejo-pr-automerge.mjs new file mode 100644 index 0000000..ea1b9d8 --- /dev/null +++ b/scripts/forgejo-pr-automerge.mjs @@ -0,0 +1,63 @@ +/** + * Plain-Node CLI (no pnpm) — agent PR safety net: wait for checks and merge by + * rebase (the Forgejo equivalent of `gh pr merge --auto --rebase`, which the + * Forgejo API does not offer as a scheduled operation). Idempotent: already + * merged → no-op success. + * + * node scripts/forgejo-pr-automerge.mjs --pr <N> + */ + +import { createApi } from './lib/forgejo-api.mjs' + +const parseArgs = (argv) => { + const flags = {} + for (let index = 2; index < argv.length; index += 1) { + const arg = argv[index] + if (arg.startsWith('--')) { + const name = arg.slice(2) + const next = argv[index + 1] + flags[name] = typeof next === 'string' && !next.startsWith('--') ? next : true + if (typeof next === 'string' && !next.startsWith('--')) index += 1 + } + } + return flags +} + +const flags = parseArgs(process.argv) +const prNumber = Number(flags.pr) +if (!Number.isInteger(prNumber) || prNumber <= 0) { + console.error('Usage: node scripts/forgejo-pr-automerge.mjs --pr <N>') + process.exit(1) +} + +const api = createApi({}) +try { + const pr = await api.getPullRequest(prNumber) + if (!pr || pr.state !== 'OPEN') { + console.log( + `[forgejo-pr-automerge] PR #${prNumber} não está aberto (${pr?.state ?? 'inexistente'}) — skip`, + ) + process.exit(0) + } + if (pr.isDraft) { + console.log(`[forgejo-pr-automerge] PR #${prNumber} é draft — marcando Ready`) + await api.markPullRequestReady(prNumber) + } else { + console.log(`[forgejo-pr-automerge] PR #${prNumber} já Ready`) + } + const merged = await api.autoMerge(prNumber, { + // The workflow job that runs this script reports its own status on the + // PR head sha — ignoring it avoids the self-wait deadlock. The required + // `checks` context is enforced by waitForChecks. + ignoreContexts: ['ready-automerge'], + log: (line) => console.log(`[forgejo-pr-automerge] ${line}`), + }) + console.log( + merged + ? `[forgejo-pr-automerge] PR #${prNumber} mergeado (rebase)` + : `[forgejo-pr-automerge] PR #${prNumber} já mergeado`, + ) +} catch (error) { + console.error(`[forgejo-pr-automerge] falhou: ${error instanceof Error ? error.message : error}`) + process.exit(1) +} diff --git a/scripts/issue.mjs b/scripts/issue.mjs new file mode 100644 index 0000000..9745f9e --- /dev/null +++ b/scripts/issue.mjs @@ -0,0 +1,114 @@ +/** + * `bun run issue` — Issues da fila de claim, determinístico e read-only (nunca + * altera o Forgejo; claim continua `bun run agent:claim`). + * + * bun run issue next imprime a próxima Issue claimável (topo da fila do claim) + * bun run issue all overview compacto das Issues abertas (contadores + lista) + * + * Portado do teqo (scripts/issue.mjs) — URL derivada de FORGEJO_REPOSITORY. + */ + +import { + dieAgent, + labelNames, + nextClaimableIssue, + parseArgs, + parseFrontmatter, + priorityRank, + issueUrl, +} from './lib/agent-forgejo.mjs' +import { loadProjectEnv } from './lib/load-project-env.mjs' + +loadProjectEnv() +const forgejoModule = await import('./lib/forgejo-api.mjs'); +const { forgejoApi: api } = forgejoModule; + +const die = dieAgent('issue') + +const cmdNext = async () => { + const pick = await nextClaimableIssue() + if (!pick) { + die('Fila vazia — nada `ready` desbloqueado. Rode `bun run issue all` para a overview.') + } + const { issue, meta, priority, satisfiedWithoutIssue } = pick + const id = typeof meta.id === 'string' ? meta.id : null + const prefix = id ? `${id} — ` : null + const subject = + prefix && issue.title.startsWith(prefix) ? issue.title.slice(prefix.length) : issue.title + + console.log(`#${issue.number} ${id ?? ''} [${priority}]`.trim()) + console.log(subject) + if (meta.model) console.log(`model: ${meta.model}`) + if (satisfiedWithoutIssue.length > 0) { + console.log(`deps satisfeitas (roadmap, sem Issue): ${satisfiedWithoutIssue.join(', ')}`) + } + console.log(`url: ${issueUrl(issue.number)}`) +} + +const count = (map, key) => map.set(key, (map.get(key) ?? 0) + 1) + +const cmdAll = async () => { + const issues = await api.listIssues({ state: 'open', limit: 200 }) + + const rows = [] + for (const issue of issues) { + const { meta } = parseFrontmatter(issue.body) + if (typeof meta.id !== 'string' || meta.id.length === 0) continue + const labels = labelNames(issue) + rows.push({ + issue, + id: meta.id, + state: labels.find((label) => ['ready', 'in-progress', 'blocked'].includes(label)) ?? 'open', + priority: labels.find((label) => /^prio:P[0-3]$/.test(label))?.replace('prio:', '') ?? 'P2', + kind: labels.find((label) => label.startsWith('kind:'))?.replace('kind:', '') ?? 'feature', + }) + } + + const byState = new Map() + const byPrio = new Map() + const byKind = new Map() + for (const row of rows) { + count(byState, row.state) + count(byPrio, row.priority) + count(byKind, row.kind) + } + const fmt = (map) => + [...map.entries()] + .sort(([a], [b]) => String(a).localeCompare(String(b))) + .map(([key, value]) => `${key}=${value}`) + .join(' ') + + console.log(`Issues abertas: ${rows.length}`) + console.log(` por estado: ${fmt(byState) || '(—)'}`) + console.log(` por prio: ${fmt(byPrio) || '(—)'}`) + console.log(` por kind: ${fmt(byKind) || '(—)'}`) + console.log('') + const sorted = [...rows].sort((a, b) => { + const rank = priorityRank(a.priority) - priorityRank(b.priority) + return rank !== 0 ? rank : a.issue.number - b.issue.number + }) + for (const row of sorted) { + console.log( + ` #${row.issue.number} ${row.id} [${row.state}/${row.priority}] ${row.issue.title}`, + ) + } +} + +const { positional } = parseArgs(process.argv.slice(2), new Set()) +const subcommand = positional[0] + +if (!subcommand) { + console.log('Uso: bun run issue next | all') + console.log(' next imprime a próxima Issue claimável (topo da fila do claim)') + console.log(' all overview compacto das Issues abertas') + process.exit(1) +} + +try { + if (subcommand === 'next') await cmdNext() + else if (subcommand === 'all') await cmdAll() + else die(`subcomando desconhecido: ${subcommand} (esperado: next | all)`) +} catch (error) { + if (error?.stderr) die(error.stderr.toString().trim()) + die(error?.message ?? String(error)) +} diff --git a/scripts/lib/agent-forgejo.mjs b/scripts/lib/agent-forgejo.mjs new file mode 100644 index 0000000..c3ebc5f --- /dev/null +++ b/scripts/lib/agent-forgejo.mjs @@ -0,0 +1,239 @@ +/** + * Shared helpers for the agent ops scripts (`bun run agent:*`). Everything + * goes through the Forgejo REST API (scripts/lib/forgejo-api.mjs). + * + * Issue contract (spec + status + deps in Forgejo Issues): each trackable + * issue carries a YAML-ish frontmatter block at the top of the body: + * + * --- + * id: I7 + * depends: [I3] + * serializes: [] + * priority: P1 + * --- + * + * State lives in labels: ready | in-progress | blocked | done (+ in-prod). + * + * Portado do teqo (scripts/lib/agent-forgejo.mjs) — só a URL da Issue deixou + * de ser hardcoded e passou a derivar de FORGEJO_REPOSITORY. + */ + +import { dieWithLabel } from './cli.mjs' +import { forgejoApi as api } from './forgejo-api.mjs' + +const PRIORITIES = ['P0', 'P1', 'P2', 'P3'] +export const priorityRank = (priority) => PRIORITIES.indexOf(priority) + +export const dieAgent = (script) => dieWithLabel(`agent:${script}`) + +/** URL canônica de uma Issue do projeto (derivada de FORGEJO_REPOSITORY). */ +export const issueUrl = (number) => { + const repo = process.env.FORGEJO_REPOSITORY ?? 'amana/iara-pwa' + return `https://git.solla.dev/${repo}/issues/${number}` +} + +export const parseFrontmatter = (body) => { + const match = /^---\n([\s\S]*?)\n---\n?/.exec(body ?? '') + if (!match) return { meta: {}, rest: body ?? '' } + const meta = {} + for (const line of match[1].split('\n')) { + const kv = /^([a-zA-Z]+):\s*(.*)$/.exec(line.trim()) + if (!kv) continue + const [, key, raw] = kv + meta[key] = raw.startsWith('[') + ? raw + .slice(1, -1) + .split(',') + .map((value) => value.trim()) + .filter(Boolean) + : raw + } + return { meta, rest: body.slice(match[0].length) } +} + +export const serializeFrontmatter = (meta, rest) => { + const lines = ['---'] + for (const [key, value] of Object.entries(meta)) { + lines.push(`${key}: ${Array.isArray(value) ? `[${value.join(', ')}]` : value}`) + } + lines.push('---', '') + return lines.join('\n') + (rest ?? '') +} + +/** All issues (open or closed) that carry a frontmatter `id`, keyed by id. */ +export const issuesById = async () => { + const issues = await api.listIssues({ state: 'all', limit: 200 }) + const byId = new Map() + for (const issue of issues) { + const { meta } = parseFrontmatter(issue.body) + if (typeof meta.id === 'string' && meta.id.length > 0) byId.set(meta.id, issue) + } + return byId +} + +export const labelNames = (issue) => issue.labels.map((label) => label.name) + +/** Issue ids that count as a satisfied dependency (closed or done/in-prod). */ +const doneIdsOf = (byId) => + new Set( + [...byId.entries()] + .filter(([, issue]) => { + const labels = labelNames(issue) + return issue.state === 'CLOSED' || labels.includes('done') || labels.includes('in-prod') + }) + .map(([id]) => id), + ) + +/** + * Claim-queue entry shape derived from ONE issue: meta, priority and the + * dependency verdicts. Shared by `buildClaimQueue` (the queue) and + * `claimQueueEntry` (single-issue contexts like reopening an in-progress + * session) — one derivation, no drift. + */ +const entryForIssue = (issue, byId, doneIds) => { + const { meta } = parseFrontmatter(issue.body) + const depends = Array.isArray(meta.depends) ? meta.depends : [] + // A dep without an issue is a delivered roadmap item — satisfied. + const satisfiedWithoutIssue = depends.filter((id) => !byId.has(id)) + const blockedBy = depends.filter((id) => byId.has(id) && !doneIds.has(id)) + return { + issue, + meta, + priority: labelNames(issue).find((label) => /^prio:P[0-3]$/.test(label)) ?? 'prio:P2', + satisfiedWithoutIssue, + blockedBy, + } +} + +/** + * Claim queue shared by `agent:claim` and the agent pool: open `ready` issues + * filtered to UNBLOCKED, ordered by `prio:P0..P3` then oldest first. + */ +export const buildClaimQueue = (openReady, byId) => { + const doneIds = doneIdsOf(byId) + + return openReady + .map((issue) => entryForIssue(issue, byId, doneIds)) + .filter((entry) => entry.blockedBy.length === 0) + .sort((a, b) => { + const rank = + priorityRank(a.priority.replace('prio:', '')) - + priorityRank(b.priority.replace('prio:', '')) + return rank !== 0 ? rank : a.issue.createdAt.localeCompare(b.issue.createdAt) + }) +} + +/** Entry shape for ONE issue regardless of its labels — the reopen path. */ +export const claimQueueEntry = (issue, byId) => entryForIssue(issue, byId, doneIdsOf(byId)) + +/** + * Pure decision for `worktree next --issue <N>`: `reopen` = already claimed + * (`in-progress` — no re-claim); `claim` = `ready`; `error` = anything else. + */ +export const claimTargetVerdict = (issue) => { + if (issue.state !== 'OPEN') { + return { kind: 'error', message: `Issue não está aberta (${issue.state}).` } + } + const names = labelNames(issue) + if (names.includes('in-progress')) return { kind: 'reopen' } + if (names.includes('ready')) return { kind: 'claim' } + return { + kind: 'error', + message: `Issue não é claimável (labels: ${names.join(', ') || 'nenhum'}).`, + } +} + +/** + * Optimistic claim shared by `agent:claim` and `worktree next`: re-reads the + * issue right before flipping labels, refuses if someone else already took it, + * then swaps ready→in-progress and leaves a claim comment. + */ +export const claimIssue = async (entry, die) => { + const fresh = await api.getIssue(entry.issue.number) + const freshLabels = labelNames(fresh) + if ( + fresh.state !== 'OPEN' || + !freshLabels.includes('ready') || + freshLabels.includes('in-progress') + ) { + die( + `Issue #${entry.issue.number} was just claimed or closed by someone else. ` + + 'Re-run `bun run agent:claim` or reopen the session with `bun run worktree next --issue ' + + `${entry.issue.number}` + + '`.', + ) + } + + await setLabels(entry.issue.number, { add: ['in-progress'], remove: ['ready'] }) + await api.addComment( + entry.issue.number, + `Claimed by agent run at ${new Date().toISOString()}. Lock otimista: outro claim deve falhar e re-rodar \`bun run agent:claim\`.`, + ) +} + +/** + * Lines of the claim brief (id, priority, model, deps, url, spec body) + * printed by both claim surfaces (`agent:claim` and `worktree next`). + */ +export const claimBriefLines = (entry) => { + const { rest } = parseFrontmatter(entry.issue.body) + const issueId = entry.meta.id ?? null + let subject = entry.issue.title + if (issueId) { + const idPrefix = `${issueId} — ` + if (subject.startsWith(idPrefix)) subject = subject.slice(idPrefix.length) + } + const sessionTitle = issueId + ? `#${entry.issue.number} ${issueId} — ${subject}` + : `#${entry.issue.number} — ${subject}` + + return [ + ` id: ${issueId ?? '(none)'} priority: ${entry.priority}`, + ` rename_chat: ${sessionTitle.slice(0, 200)}`, + entry.meta.model + ? ` model: ${entry.meta.model} (metadata consultiva — o work-issue não verifica modelo; ver skill model-selection)` + : ' model: ausente — registrar slug único na Issue (ver skill model-selection)', + ...(entry.satisfiedWithoutIssue.length > 0 + ? [ + ` deps sem issue (roadmap entregue, satisfeitas): ${entry.satisfiedWithoutIssue.join(', ')}`, + ] + : []), + ` url: ${issueUrl(entry.issue.number)}`, + '', + '--- spec ---', + '', + rest.trim() || '(empty body — see linked plan)', + ] +} + +export const setLabels = (number, { add = [], remove = [] }) => + api.setLabels(number, { add, remove }) + +export const nextClaimableIssue = async () => { + const openReady = await api.listIssues({ state: 'open', labels: 'ready', limit: 200 }) + const queue = buildClaimQueue(openReady, await issuesById()) + return queue[0] ?? null +} + +export const parseArgs = (argv, flagsWithValue) => { + const flags = {} + const positional = [] + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + if (arg.startsWith('--')) { + const name = arg.slice(2) + if (flagsWithValue.has(name)) { + const next = argv[index + 1] + // A following `--flag` is not a value: `--issue --stay` leaves the + // flag unset instead of silently consuming another flag as its value. + flags[name] = typeof next === 'string' && next.startsWith('--') ? undefined : next + index += 1 + } else { + flags[name] = true + } + } else { + positional.push(arg) + } + } + return { flags, positional } +} diff --git a/scripts/lib/agent-plan-lifecycle.mjs b/scripts/lib/agent-plan-lifecycle.mjs new file mode 100644 index 0000000..a1632d5 --- /dev/null +++ b/scripts/lib/agent-plan-lifecycle.mjs @@ -0,0 +1,73 @@ +/** + * Plan-issue lifecycle helpers: register with `--plan` starts non-claimable; + * promote to `ready` only after the intention plan is on `main`. + * + * Pure — no IO. Portado do teqo (scripts/lib/agent-plan-lifecycle.mjs). + */ + +import { labelNames } from './agent-forgejo.mjs' +import { HUMAN_GATE_LABELS, issueHasPlanLink } from './agent-pool-eligibility.mjs' + +/** + * Initial state label for a newly registered issue. + * + * @param {{ hasPlan: boolean, explicitBlocked?: boolean }} options + * @returns {'ready' | 'blocked'} + */ +export const resolveRegisterStateLabel = ({ hasPlan, explicitBlocked = false }) => { + if (explicitBlocked || hasPlan) return 'blocked' + return 'ready' +} + +const TERMINAL_OR_ACTIVE = ['in-progress', 'done', 'in-prod'] + +/** + * Whole-word `Related #N` only (case-insensitive). Does not match + * closing keywords (`Closes`/`Fixes`/`Resolves`) and rejects hyphenated + * compounds like `non-related #5`. + */ +const RELATED_ISSUE_RE = /(?<![\w-])related\s+#(\d+)\b/gi + +/** + * Issue numbers cited as `Related #N` in a PR body (plans-only contract). + * Deduped, stable first-seen order. + * + * @param {string | null | undefined} body + * @returns {number[]} + */ +export const parseRelatedIssueNumbers = (body) => { + if (!body) return [] + const seen = new Set() + const numbers = [] + for (const match of body.matchAll(RELATED_ISSUE_RE)) { + const number = Number(match[1]) + if (seen.has(number)) continue + seen.add(number) + numbers.push(number) + } + return numbers +} + +/** + * Whether an issue is waiting for its intention plan on `main` and may be + * flipped `blocked` → `ready` by `agent:ready` (or the merge Action). + * + * @param {{ state?: string, body?: string, labels?: Array<{ name: string }> }} issue + * @returns {{ ok: true } | { ok: false, reason: string }} + */ +export const canPromotePlanIssue = (issue) => { + if ((issue?.state ?? 'OPEN') !== 'OPEN') return { ok: false, reason: 'not-open' } + const labels = labelNames(issue ?? { labels: [] }) + if (labels.includes('ready') && !labels.includes('blocked')) { + return { ok: false, reason: 'already-ready' } + } + if (!labels.includes('blocked')) return { ok: false, reason: 'not-blocked' } + if (TERMINAL_OR_ACTIVE.some((label) => labels.includes(label))) { + return { ok: false, reason: 'state-label' } + } + if (HUMAN_GATE_LABELS.some((label) => labels.includes(label))) { + return { ok: false, reason: 'needs-human' } + } + if (!issueHasPlanLink(issue)) return { ok: false, reason: 'no-plan-link' } + return { ok: true } +} diff --git a/scripts/lib/agent-pool-eligibility.mjs b/scripts/lib/agent-pool-eligibility.mjs new file mode 100644 index 0000000..56d4243 --- /dev/null +++ b/scripts/lib/agent-pool-eligibility.mjs @@ -0,0 +1,57 @@ +/** + * Autonomous-claim eligibility (pool) — portado do teqo. A Iara ainda não usa + * pool de agentes; mantido para compatibilidade com `agent-plan-lifecycle`. + */ + +import { labelNames } from './agent-forgejo.mjs' + +export const POOL_CIRCUIT_BREAKER_FAILURES = 2 + +const EXCLUDED_STATE_LABELS = ['in-progress', 'blocked', 'done', 'in-prod'] +export const HUMAN_GATE_LABELS = ['requirements-changed', 'needs:consent'] + +export const isAutonomousClaimable = ( + entry, + { migrationBusy = false, poolFailureCount = 0 } = {}, +) => { + const labels = labelNames(entry.issue) + if (entry.issue.state !== 'OPEN') return { ok: false, reason: 'not-open' } + if (!labels.includes('ready')) return { ok: false, reason: 'not-ready' } + if (EXCLUDED_STATE_LABELS.some((label) => labels.includes(label))) { + return { ok: false, reason: 'state-label' } + } + if (HUMAN_GATE_LABELS.some((label) => labels.includes(label))) { + return { ok: false, reason: 'needs-human' } + } + if ((entry.blockedBy ?? []).length > 0) return { ok: false, reason: 'blocked-by-deps' } + if (poolFailureCount >= POOL_CIRCUIT_BREAKER_FAILURES) { + return { ok: false, reason: 'circuit-breaker' } + } + const serializes = Array.isArray(entry.meta?.serializes) ? entry.meta.serializes : [] + const touchesSchema = labels.includes('needs:migration') || serializes.includes('migrations') + if (touchesSchema && migrationBusy) return { ok: false, reason: 'migration-busy' } + return { ok: true } +} + +/** Plan link is a preference for claim/pool status (warn), never a claim blocker. */ +export const issueHasPlanLink = (issue) => /docs\/plans\//.test(issue?.body ?? '') + +export const buildPoolQueue = ( + claimQueue, + { migrationBusy = false, failureCountsByIssue = new Map() } = {}, +) => { + const eligible = [] + const excluded = [] + for (const entry of claimQueue) { + const verdict = isAutonomousClaimable(entry, { + migrationBusy, + poolFailureCount: failureCountsByIssue.get(entry.issue.number) ?? 0, + }) + if (verdict.ok) { + eligible.push({ entry, hasPlan: issueHasPlanLink(entry.issue) }) + } else { + excluded.push({ entry, reason: verdict.reason }) + } + } + return { eligible, excluded } +} diff --git a/scripts/lib/cli.mjs b/scripts/lib/cli.mjs new file mode 100644 index 0000000..bae23d4 --- /dev/null +++ b/scripts/lib/cli.mjs @@ -0,0 +1,10 @@ +/** + * Shared CLI skeleton for scripts/*.mjs — portado do teqo, enxuto para a Iara + * (sem helpers de banco/download). `die` é o mínimo que os agent scripts usam. + */ + +/** Labelled `die` factory: `dieWithLabel('worktree')` → the script's `die`. */ +export const dieWithLabel = (label) => (message) => { + console.error(`\n[${label}] ${message}\n`) + process.exit(1) +} diff --git a/scripts/lib/forgejo-api.mjs b/scripts/lib/forgejo-api.mjs new file mode 100644 index 0000000..403779c --- /dev/null +++ b/scripts/lib/forgejo-api.mjs @@ -0,0 +1,323 @@ +/** + * Forgejo REST client for the agent ops scripts (`bun run agent:*`, workflow + * helpers). Zero dependencies — plain Node `fetch` — so it also runs inside + * Forgejo Actions jobs without `bun install`. + * + * Auth: `FORGEJO_API_TOKEN` (PAT) or the native `GITHUB_TOKEN` that Forgejo + * Actions injects. Base URL: `FORGEJO_API_URL`, else `<GITHUB_SERVER_URL>/api/v1` + * inside Actions, else `https://git.solla.dev/api/v1`. A browser-ish User-Agent + * is sent because the instance sits behind Cloudflare. + * + * Shapes are normalized to the GitHub-flavored contract the agent scripts + * already use: `issue.state` ∈ OPEN|CLOSED, `issue.labels = [{ name, color }]`, + * `createdAt` ISO string. + * + * Portado do teqo (scripts/lib/forgejo-api.mjs) — sem alteração de lógica; + * default de repositório vem do `.forgejo/worktree.env` (load-project-env). + */ + +const DEFAULT_BASE_URL = 'https://git.solla.dev/api/v1' +const USER_AGENT = 'iara-agent-scripts/1.0 (forgejo)' + +/** + * @typedef {object} ForgejoApiOptions + * @property {string} [base] + * @property {string} [token] + * @property {string} [repository] + * @property {(input: string | URL, init?: { method?: string; headers?: Record<string, string>; body?: string }) => Promise<Response>} [fetchImpl] + */ + +/** + * @param {ForgejoApiOptions} [options] + */ +export const createApi = ({ base, token, repository, fetchImpl } = {}) => { + const baseUrl = ( + base ?? + process.env.FORGEJO_API_URL ?? + (process.env.GITHUB_SERVER_URL ? `${process.env.GITHUB_SERVER_URL}/api/v1` : null) ?? + DEFAULT_BASE_URL + ).replace(/\/+$/, '') + const repo = + repository ?? + process.env.FORGEJO_REPOSITORY ?? + process.env.GITHUB_REPOSITORY ?? + 'amana/iara-pwa' + const fetcher = fetchImpl ?? fetch + const [owner, name] = repo.split('/') + + const request = async (path, { method = 'GET', body, query } = {}) => { + const authToken = token ?? process.env.FORGEJO_API_TOKEN ?? process.env.GITHUB_TOKEN + if (!authToken) { + throw new Error( + 'Sem token para a API do Forgejo — defina FORGEJO_API_TOKEN (ou rode dentro do Forgejo Actions).', + ) + } + const qs = query + ? '?' + + Object.entries(query) + .filter(([, value]) => value !== undefined && value !== null) + .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`) + .join('&') + : '' + const response = await fetcher(`${baseUrl}${path}${qs}`, { + method, + headers: { + Authorization: `token ${authToken}`, + 'Content-Type': 'application/json', + 'User-Agent': USER_AGENT, + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }) + const text = await response.text() + if (!response.ok) { + throw new Error(`Forgejo API ${method} ${path} → ${response.status}: ${text.slice(0, 400)}`) + } + return text ? JSON.parse(text) : null + } + + const normalizeState = (state) => (String(state).toLowerCase() === 'closed' ? 'CLOSED' : 'OPEN') + + const normalizeIssue = (issue) => ({ + number: issue.number, + title: issue.title, + body: issue.body ?? '', + state: normalizeState(issue.state), + createdAt: issue.created_at, + labels: (issue.labels ?? []).map((label) => ({ + name: label.name, + color: label.color ?? '', + })), + }) + + const api = { + /** GET /api/v1/repos/{owner}/{repo}/issues — shape normalized like `gh issue list --json`. */ + listIssues: async ({ state = 'open', labels, limit = 200, page = 1 } = {}) => { + const issues = await request(`/repos/${owner}/${name}/issues`, { + query: { state, labels, limit, page, type: 'issues' }, + }) + return (Array.isArray(issues) ? issues : []).map(normalizeIssue) + }, + + getIssue: async (number) => { + const issue = await request(`/repos/${owner}/${name}/issues/${number}`) + return normalizeIssue(issue) + }, + + createIssue: async ({ title, body }) => { + const issue = await request(`/repos/${owner}/${name}/issues`, { + method: 'POST', + body: { title, body }, + }) + return normalizeIssue(issue) + }, + + /** Adds labels (never removes). */ + addLabels: (number, labels) => + request(`/repos/${owner}/${name}/issues/${number}/labels`, { + method: 'POST', + body: { labels }, + }), + + /** Removes labels by name (resolves ids, ignores unknown names). */ + removeLabels: async (number, labels) => { + const current = await request(`/repos/${owner}/${name}/issues/${number}/labels`) + for (const label of current ?? []) { + if (labels.includes(label.name)) { + await request(`/repos/${owner}/${name}/issues/${number}/labels/${label.id}`, { + method: 'DELETE', + }) + } + } + }, + + /** + * @param {number} number + * @param {{ add?: string[], remove?: string[] }} [options] + */ + setLabels: async (number, { add = [], remove = [] } = {}) => { + if (remove.length > 0) await api.removeLabels(number, remove) + if (add.length > 0) await api.addLabels(number, add) + }, + + addComment: (number, body) => + request(`/repos/${owner}/${name}/issues/${number}/comments`, { + method: 'POST', + body: { body }, + }), + + listIssueComments: async (number) => { + const comments = await request(`/repos/${owner}/${name}/issues/${number}/comments`) + return (Array.isArray(comments) ? comments : []).map((comment) => ({ + body: comment.body ?? '', + user: comment.user?.login ?? '', + })) + }, + + listPullRequestFiles: async (number) => { + const files = await request(`/repos/${owner}/${name}/pulls/${number}/files`) + return (Array.isArray(files) ? files : []).map((file) => ({ + filename: file.filename, + status: file.status, + })) + }, + + /** GET /api/v1/repos/{owner}/{repo}/pulls — raw, normalized lightly. */ + listPullRequests: async ({ state = 'open', limit = 100 } = {}) => { + const pulls = await request(`/repos/${owner}/${name}/pulls`, { + query: { state, limit }, + }) + return (Array.isArray(pulls) ? pulls : []).map((pr) => ({ + number: pr.number, + title: pr.title, + state: normalizeState(pr.state), + body: pr.body ?? '', + head: { ref: pr.head?.ref, sha: pr.head?.sha }, + base: { ref: pr.base?.ref }, + })) + }, + + getPullRequest: async (number) => { + const pr = await request(`/repos/${owner}/${name}/pulls/${number}`) + return { + number: pr.number, + title: pr.title, + state: normalizeState(pr.state), + merged: Boolean(pr.merged), + mergeable: pr.mergeable, + isDraft: Boolean(pr.is_draft), + body: pr.body ?? '', + head: { ref: pr.head?.ref, sha: pr.head?.sha }, + base: { ref: pr.base?.ref }, + } + }, + + /** Marks a draft PR as ready for review (PATCH is_draft=false). */ + markPullRequestReady: (number) => + request(`/repos/${owner}/${name}/pulls/${number}`, { + method: 'PATCH', + body: { is_draft: false }, + }), + + createPullRequest: async ({ head, base, title, body, draft = false }) => { + const pr = await request(`/repos/${owner}/${name}/pulls`, { + method: 'POST', + body: { head, base, title, body, draft }, + }) + return { number: pr.number, title: pr.title, htmlUrl: pr.html_url, isDraft: Boolean(pr.is_draft) } + }, + + /** + * Commit statuses of a sha (Forgejo Actions check runs surface here). + * Returns [{ context, state }] with state ∈ success|failure|pending|error. + */ + listCommitStatuses: async (sha) => { + const statuses = await request(`/repos/${owner}/${name}/commits/${sha}/status`) + const combined = statuses?.statuses ?? [] + return combined.map((entry) => ({ context: entry.context, state: entry.status })) + }, + + /** Merges a PR with rebase (the repo's canonical merge style). */ + mergePullRequest: (number) => + request(`/repos/${owner}/${name}/pulls/${number}/merge`, { + method: 'POST', + body: { Do: 'rebase' }, + }), + + /** Triggers a workflow via workflow_dispatch. */ + workflowDispatch: (workflowFile, { ref = 'main', inputs = {} } = {}) => + request(`/repos/${owner}/${name}/actions/workflows/${workflowFile}/dispatches`, { + method: 'POST', + body: { ref, inputs }, + }), + + /** GET /api/v1/repos/{owner}/{repo}/contents/{path}?ref=… — { content, sha } base64-decoded. */ + getFileContents: async (path, ref = 'main') => { + const file = await request(`/repos/${owner}/${name}/contents/${path}`, { + query: { ref }, + }) + return { + content: Buffer.from(file.content ?? '', 'base64').toString('utf8'), + sha: file.sha, + } + }, + + /** POST /contents — creates a file on a branch (fails if it exists). */ + createFile: (path, { message, content, branch }) => + request(`/repos/${owner}/${name}/contents/${path}`, { + method: 'POST', + body: { + message, + content: Buffer.from(content, 'utf8').toString('base64'), + branch, + }, + }), + + /** PUT /contents — updates an existing file (sha required). */ + updateFile: (path, { message, content, branch, sha }) => + request(`/repos/${owner}/${name}/contents/${path}`, { + method: 'PUT', + body: { + message, + content: Buffer.from(content, 'utf8').toString('base64'), + branch, + sha, + }, + }), + + /** POST /api/v1/repos/{owner}/{repo}/branch_protections (Forgejo rule-based protection). */ + updateBranchProtection: (branch, payload) => + request(`/repos/${owner}/${name}/branch_protections`, { + method: 'POST', + body: { ...payload, branch_name: branch }, + }), + + /** + * All required statuses on the PR head are success (or a failure exists). + * `ignoreContexts` excludes a job's OWN status — a workflow that merges a + * PR sees itself as `pending` on the head sha and would deadlock waiting + * for itself. `requiredContext` (default `checks`) must be success. + */ + waitForChecks: async ( + number, + { timeoutMs = 30 * 60 * 1000, pollMs = 15000, log, ignoreContexts = [], requiredContext = 'checks' } = {}, + ) => { + const started = Date.now() + for (;;) { + const pr = await api.getPullRequest(number) + if (!pr || pr.state !== 'OPEN' || pr.merged) return pr + const statuses = await api.listCommitStatuses(pr.head.sha) + const relevant = statuses.filter((entry) => !ignoreContexts.some((c) => entry.context.includes(c))) + const failed = relevant.filter( + (entry) => entry.state === 'failure' || entry.state === 'error', + ) + const pending = relevant.filter((entry) => entry.state === 'pending') + if (failed.length > 0) { + const names = failed.map((entry) => entry.context).join(', ') + throw new Error(`Checks falharam no PR #${number}: ${names}`) + } + const required = relevant.find((entry) => entry.context.includes(requiredContext)) + if (required && required.state === 'success' && pending.length === 0) return pr + if (Date.now() - started > timeoutMs) { + throw new Error(`Timeout esperando checks do PR #${number}.`) + } + if (log) + log(`[pr#${number}] ${statuses.length} status, ${pending.length} pendente — aguardando…`) + await new Promise((resolve) => setTimeout(resolve, pollMs)) + } + }, + + /** Wait for checks then merge by rebase (the `gh pr merge --auto --rebase` equivalent). */ + autoMerge: async (number, options) => { + const pr = await api.waitForChecks(number, options) + if (pr && !pr.merged && pr.state === 'OPEN') { + return api.mergePullRequest(number) + } + return null + }, + } + + return api +} + +/** Default instance bound to the environment. */ +export const forgejoApi = createApi({}) diff --git a/scripts/lib/load-project-env.mjs b/scripts/lib/load-project-env.mjs new file mode 100644 index 0000000..b6e3b41 --- /dev/null +++ b/scripts/lib/load-project-env.mjs @@ -0,0 +1,63 @@ +/** + * Project env loader — lê `.forgejo/worktree.env` do repo (subindo do CWD) e + * aplica em `process.env` quando as variáveis ainda não estão setadas. É o que + * permite que os scripts rodem por qualquer caminho (comando `/worktree` do + * opencode, `bun run issue`, roteador de terminal) sem depender de exportação + * global. Arquivo commitado, sem segredos. + */ + +import { existsSync, readFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' + +const parseEnvFile = (text) => { + const out = {} + for (const raw of text.split('\n')) { + const line = raw.trim() + if (line === '' || line.startsWith('#')) continue + const eq = line.indexOf('=') + if (eq <= 0) continue + const key = line.slice(0, eq).trim() + const value = line.slice(eq + 1).trim().replace(/^["']|["']$/g, '') + if (key) out[key] = value + } + return out +} + +/** Caminho do `.forgejo/worktree.env` do projeto dono do CWD (ou null). */ +export const findProjectEnvFile = (from = process.cwd()) => { + let dir = resolve(from) + for (;;) { + const candidate = join(dir, '.forgejo', 'worktree.env') + if (existsSync(candidate)) return candidate + const parent = dirname(dir) + if (parent === dir) return null + dir = parent + } +} + +/** Expande um `~` inicial (ex. `~/.worktrees/iara`) para o homedir. */ +export const expandHome = (value) => { + if (typeof value !== 'string' || !value.startsWith('~')) return value + return join(process.env.HOME ?? '', value.slice(1)) +} + +/** + * Carrega o `.forgejo/worktree.env` do projeto do CWD (se existir) em + * process.env — só preenche o que ainda não está definido. Retorna o config + * resolvido: { repo, worktreesRoot }. + */ +export const loadProjectEnv = (from = process.cwd()) => { + const file = findProjectEnvFile(from) + const parsed = file ? parseEnvFile(readFileSync(file, 'utf8')) : {} + + const repo = + process.env.FORGEJO_REPOSITORY ?? parsed.FORGEJO_REPOSITORY ?? process.env.GITHUB_REPOSITORY ?? 'amana/iara-pwa' + const worktreesRoot = expandHome( + process.env.WORKTREES_ROOT ?? parsed.WORKTREES_ROOT ?? `~/.worktrees/${repo.split('/')[1] ?? 'work'}`, + ) + + if (!process.env.FORGEJO_REPOSITORY) process.env.FORGEJO_REPOSITORY = repo + if (!process.env.WORKTREES_ROOT) process.env.WORKTREES_ROOT = worktreesRoot + + return { repo, worktreesRoot, envFile: file } +} diff --git a/scripts/lib/slug.mjs b/scripts/lib/slug.mjs new file mode 100644 index 0000000..501a8e3 --- /dev/null +++ b/scripts/lib/slug.mjs @@ -0,0 +1,9 @@ +/** Slugify pt-BR (acentos fora, não-alfanumérico → hífen) — portado de teqo/src/lib/slug.ts. */ +export const slugify = (value) => + value + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') // strip accents + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') // non-alphanumeric -> hyphen + .replace(/-+/g, '-') // collapse hyphens + .replace(/^-|-$/g, '') // trim leading/trailing hyphens diff --git a/scripts/lib/worktree.mjs b/scripts/lib/worktree.mjs new file mode 100644 index 0000000..719e237 --- /dev/null +++ b/scripts/lib/worktree.mjs @@ -0,0 +1,132 @@ +/** + * Pure helpers for `scripts/worktree.mjs` — branch naming derived from the + * claim-queue issue. Naming: `<code>-<slug>` where `code` is the frontmatter + * `id` and `slug` is the pt-BR title slugified (accents stripped, + * non-alphanumeric → `-`). Portado do teqo, com slugify em scripts/lib/slug.mjs. + */ + +import { slugify } from './slug.mjs' + +/** + * Prefix of every `/plan-issue` planning-worktree branch (`bun run worktree + * plan`). Lowercase-led `plans/…`, so it can never collide with a `next` + * branch — `<Code>-<slug>` is always uppercase-led. + */ +export const PLAN_BRANCH_PREFIX = 'plans/plan-issue' + +/** Env var the interactive-terminal `worktree()` function sets to request the opencode launch. */ +export const WORKTREE_TERMINAL_ENV = 'TEQO_WORKTREE_TERMINAL' + +/** Preset model for the opencode launch — change the preset by editing this constant. */ +export const OPENCODE_PRESET_MODEL = 'deepseek/deepseek-v4-flash' + +/** + * Skill command sent as the launch's initial message per purpose. `next` + * sends `/work-issue` (the launch appends `--issue <N>` — the claimed issue); + * `plan` sends `/plan-issue`; `new` sends nothing ("apenas conversar"). + */ +export const OPENCODE_SKILL_COMMAND_BY_PURPOSE = { + next: '/work-issue', + plan: '/plan-issue', + new: null, +} + +/** + * Prefix of every neutral-worktree branch (`bun run worktree new`). + * Lowercase-led `work/…` — disjoint from `next`'s `<Code>-<slug>` and from + * `plan`'s `plans/plan-issue-…`. + */ +export const WORK_BRANCH_PREFIX = 'work' + +/** Total branch-name budget — mirrors `branchNameForIssue` (60). */ +const NAMESPACE_BRANCH_MAX_LENGTH = 60 + +/** Strip the leading `<code> — ` (or any dash variant) off a title. */ +const stripCodePrefix = (title, code) => { + const escaped = code.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + return title.replace(new RegExp(`^${escaped}\\s*[—–:-]?\\s*`), '') +} + +/** + * `code` = frontmatter `id` (falls back to the leading `ID — ` token of the + * title); `subject` = the title with that prefix removed. + */ +export const issueCodeAndSubject = (issue) => { + const title = issue.title ?? '' + const code = typeof issue.meta?.id === 'string' && issue.meta.id.length > 0 ? issue.meta.id : null + const subject = code + ? stripCodePrefix(title, code) + : title.replace(/^[A-Za-z0-9+]+\s*[—–:-]?\s*/, '') + return { code, subject } +} + +/** + * `<code>-<slug>` truncated to `maxLength` total characters (always keeping + * the code). Throws when the issue has no id — fail loudly, never invent. + */ +export const branchNameForIssue = (issue, maxLength = 60) => { + const { code, subject } = issueCodeAndSubject(issue) + if (!code) { + throw new Error(`Issue sem frontmatter id: #${issue.number} ${issue.title}`) + } + const slug = slugify(subject) || 'issue' + const full = `${code}-${slug}` + if (full.length <= maxLength) return full + const keep = Math.max(1, maxLength - code.length - 1) + return `${code}-${slug.slice(0, keep)}` +} + +/** + * Shared branch naming for namespace worktrees NOT tied to the claim queue + * (`plan`, `new`). Every invocation must land on a DIFFERENT branch so + * parallel sessions coexist: with `bag` → `<prefix><slug>` (+ `-2`, `-3` on + * collision); without → next free sequential `<prefix>1`, `<prefix>2`, … + */ +const namespaceBranchName = ({ prefix, bag = '', taken = new Set(), fallback }) => { + const hasBag = typeof bag === 'string' && bag.trim().length > 0 + + if (!hasBag) { + for (let n = 1; ; n += 1) { + const candidate = `${prefix}${n}` + if (!taken.has(candidate)) return candidate + } + } + + const slug = slugify(bag) || fallback + const base = `${prefix}${slug.slice(0, NAMESPACE_BRANCH_MAX_LENGTH - prefix.length)}` + if (!taken.has(base)) return base + + for (let n = 2; ; n += 1) { + const suffix = `-${n}` + const keep = Math.max(1, NAMESPACE_BRANCH_MAX_LENGTH - prefix.length - suffix.length) + const candidate = `${prefix}${slug.slice(0, keep)}${suffix}` + if (!taken.has(candidate)) return candidate + } +} + +/** + * Launch directive for the opencode TUI, printed by `worktree next`/`plan` + * right before the `cd <dir>` line when called from the interactive terminal + * (`WORKTREE_TERMINAL=1`). The shell function applies the `cd` first, then + * tokenizes and executes this line (xargs — quote-aware, never eval). Returns + * `null` outside the terminal so the `/worktree` opencode command never + * launches a nested TUI. + */ +export const opencodeLaunchDirective = ({ dir, purpose, terminal = false, issueNumber = null }) => { + if (!terminal) return null + const prompt = OPENCODE_SKILL_COMMAND_BY_PURPOSE[purpose] + const args = [dir, '--model', OPENCODE_PRESET_MODEL, '--auto'] + if (prompt) { + const value = purpose === 'next' && issueNumber ? `${prompt} --issue ${issueNumber}` : prompt + args.push('--prompt', JSON.stringify(value)) + } + return `launch opencode ${args.join(' ')}` +} + +/** Branch for a `/plan-issue` planning worktree — namespace `plans/plan-issue-…`. */ +export const planBranchName = ({ bag = '', taken = new Set() }) => + namespaceBranchName({ prefix: `${PLAN_BRANCH_PREFIX}-`, bag, taken, fallback: 'plano' }) + +/** Branch for a neutral worktree (`bun run worktree new`) — namespace `work/…`. */ +export const workBranchName = ({ bag = '', taken = new Set() }) => + namespaceBranchName({ prefix: `${WORK_BRANCH_PREFIX}/`, bag, taken, fallback: 'work' }) diff --git a/scripts/pr.mjs b/scripts/pr.mjs new file mode 100644 index 0000000..9a99146 --- /dev/null +++ b/scripts/pr.mjs @@ -0,0 +1,57 @@ +/** + * `bun run pr` — cria PR para `main` (Ready ou draft) e, opcionalmente, aguarda + * os checks e mergea por rebase. Equivalente local do `gh pr create` + + * `gh pr merge --auto --rebase` do fluxo do teqo, sem depender do `gh`. + * + * bun run pr -- --head <branch> [--title "…"] [--body "…"] [--draft] [--automerge] + * + * Defaults: head = branch atual, base = main, title = primeiro commit, body vazio. + * Com `--automerge`, espera o status check `checks` ficar green e mergea + * (rebase). `--no-pr` não é suportado — criação é o objetivo. + */ + +import { execFileSync } from 'node:child_process' +import { loadProjectEnv } from './lib/load-project-env.mjs' +import { dieAgent, parseArgs } from './lib/agent-forgejo.mjs' + +loadProjectEnv() +const forgejoModule = await import('./lib/forgejo-api.mjs');; +const { createApi } = forgejoModule + +const die = dieAgent('pr') +const api = createApi({}) +const { flags, positional } = parseArgs(process.argv.slice(2), new Set(['head', 'title', 'body'])) + +const currentBranch = () => + execFileSync('git', ['branch', '--show-current'], { encoding: 'utf8' }).trim() +const firstCommitSubject = () => + execFileSync('git', ['log', '-1', '--format=%s'], { encoding: 'utf8' }).trim() + +const head = flags.head ?? positional[0] ?? currentBranch() +if (!head) die('Não consegui detectar a branch atual (detached?) — use --head <branch>.') +if (head === 'main') die('PR para main não faz sentido a partir de main.') + +const title = flags.title ?? firstCommitSubject() +const body = flags.body ?? '' + +console.log(`[pr] criando PR ${head} → main: ${title}`) +const pr = await api.createPullRequest({ + head, + base: 'main', + title, + body, + draft: Boolean(flags.draft), +}) +console.log(`[pr] PR #${pr.number} criado: ${pr.htmlUrl}${pr.isDraft ? ' (draft)' : ''}`) + +if (flags.automerge) { + console.log(`[pr] aguardando checks do PR #${pr.number} e mergeando…`) + const merged = await api.autoMerge(pr.number, { + log: (line) => console.log(`[pr] ${line}`), + }) + console.log( + merged + ? `[pr] PR #${pr.number} mergeado (rebase)` + : `[pr] PR #${pr.number} já mergeado ou fechado`, + ) +} diff --git a/scripts/worktree.mjs b/scripts/worktree.mjs new file mode 100644 index 0000000..885864d --- /dev/null +++ b/scripts/worktree.mjs @@ -0,0 +1,444 @@ +/** + * `bun run worktree` — worktree management determinístico em torno da fila de + * claim do projeto (mesma fila de `bun run agent:claim`). + * + * bun run worktree next [--issue N] [--stay] + * claima a próxima Issue claimável ANTES de criar + * o worktree a partir de origin/main (mesma fila + * e lock otimista de `bun run agent:claim`; claim + * falhou → motivo e saída SEM worktree órfão); + * branch `<code>-<slug>`. + * `--issue N` claima a Issue direcionada (`ready`) + * ou REABRE uma já claimada (`in-progress` — sem + * re-claim; worktree reutilizado/criado e launch + * na hora). + * Por padrão imprime `cd <dir>` no fim — node + * não muda o cwd do shell pai; o shell chamador + * aplica (opencode/CDP usa o opencode command; + * terminal interativo: função `worktree()` do + * roteador em ~/.config/shell/worktree.sh). + * `--stay` suprime a linha `cd` (o claim ainda + * acontece); `--go` explícito continua aceito + * como no-op. + * Chamado do terminal interativo (com + * `TEQO_WORKTREE_TERMINAL=1`), imprime também a + * diretiva `launch opencode <dir> --model + * deepseek/deepseek-v4-flash --auto --prompt + * "/work-issue --issue <N>"` ANTES do `cd` — a + * função shell executa o cd e então a linha, e o + * TUI do opencode abre no worktree com + * `/work-issue --issue <N>` já enviado. Presets + * em scripts/lib/worktree.mjs. Sem o marcador + * (comando `/worktree` do opencode), a diretiva + * não é impressa — nunca abre TUI aninhado. + * Provisiona o worktree: `bun install` (Iara é + * um SPA sem banco — não há DB/porta/migrations). + * bun run worktree plan [bag] [--stay] + * cria um worktree de PLANEJAMENTO novo para rodar + * a skill /plan-issue sem ocupar o main — cada + * invocação cria UM DIFERENTE: com `bag`, branch + * `plans/plan-issue-<bag>` (e `-2`, `-3`, … se o + * nome já estiver vivo); sem `bag`, o próximo + * sequencial `plans/plan-issue-<n>` livre. Prefixo + * minúsculo `plans/…` — nunca colide com um + * `next` posterior. + * bun run worktree new [bag] [--stay] + * cria um worktree NEUTRO novo — sem função + * pré-definida (explorar ideia, conversar, ou + * planejar sem registrar nada): branch `work/<bag>` + * ou `work/<n>` sequencial. + * bun run worktree kill [--force] + * destrói o worktree em que o shell atual está + * (recusa worktree sujo sem `--force`); por + * padrão termina imprimindo `cd <main>` para o + * shell voltar ao worktree principal. + * + * Read-only no Forgejo? NÃO — desde o início `next` CLAIMA (mesma fila/ordem e + * lock otimista do `bun run agent:claim`; `--issue N` claim direcionado ou + * reabre sessão já claimada). `plan`/`new`/`kill` não tocam Issues. + * Dir raiz: configurado em `.forgejo/worktree.env` (WORKTREES_ROOT; default + * `~/.worktrees/<repo>`). Portado do teqo (scripts/worktree.mjs), sem banco. + */ + +import { execFileSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import { homedir } from 'node:os' +import { join, resolve } from 'node:path' + +import { + claimBriefLines, + claimIssue, + claimQueueEntry, + claimTargetVerdict, + dieAgent, + issuesById, + nextClaimableIssue, + parseArgs, +} from './lib/agent-forgejo.mjs' +import { forgejoApi as api } from './lib/forgejo-api.mjs' +import { loadProjectEnv } from './lib/load-project-env.mjs' +import { + branchNameForIssue, + opencodeLaunchDirective, + planBranchName, + workBranchName, + WORKTREE_TERMINAL_ENV, +} from './lib/worktree.mjs' + +const { worktreesRoot } = loadProjectEnv() +const die = dieAgent('worktree') +const WORKTREES_ROOT = worktreesRoot + +/** + * True when the interactive terminal shell function sets `TEQO_WORKTREE_TERMINAL=1` + * and will execute the `launch` directive we print. Without the marker (the + * `/worktree` opencode command, automation) the launch line is never printed. + */ +const terminalShell = process.env[WORKTREE_TERMINAL_ENV] === '1' + +/** Print the `launch` directive (only exists from the terminal shell); the `cd` line stays last. */ +const printLaunchDirective = ({ dir, purpose, issueNumber }) => { + const line = opencodeLaunchDirective({ dir, purpose, terminal: terminalShell, issueNumber }) + if (line) console.log(line) +} + +/** `git` wrapper; `okIfFails` swallows the failure and returns null instead. */ +const git = (args, { okIfFails = false } = {}) => { + try { + return execFileSync('git', args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }).trim() + } catch (error) { + if (okIfFails) return null + const stderr = (error.stderr?.toString() ?? '').trim() + die(stderr || error.message || `git ${args.join(' ')} falhou`) + } + return null +} + +/** Parse `git worktree list --porcelain` into [{ path, branch }]. */ +const parseWorktreeList = (porcelain) => { + const entries = [] + let current = null + for (const raw of porcelain.split('\n')) { + const line = raw.trimEnd() + if (line.startsWith('worktree ')) { + current = { path: line.slice('worktree '.length), branch: null } + entries.push(current) + } else if (line.startsWith('branch refs/heads/')) { + if (current) current.branch = line.slice('branch refs/heads/'.length) + } else if (line === '') { + current = null + } + } + return entries +} + +/** `bun install` no worktree se node_modules ainda não existir. */ +const ensureWorktreeDeps = (dir) => { + if (existsSync(join(dir, 'node_modules', '.bin'))) return + console.log('[worktree] ▶ node_modules ausente — bun install …') + execFileSync('bun', ['install'], { cwd: dir, stdio: 'inherit' }) +} + +/** + * Provisiona o ambiente do worktree. Iara é um SPA sem banco — só `bun install` + * e copia secretos gitignored de `.opencode/secrets/` se existirem (opencode + * se recusa a abrir com referência `{file:…}` pendurada). + */ +const provision = async ({ dir, mainRoot }) => { + const sourceDir = join(mainRoot, '.opencode', 'secrets') + if (existsSync(sourceDir)) { + const { copyFileSync, mkdirSync, readdirSync, statSync } = await import('node:fs') + const targetDir = join(dir, '.opencode', 'secrets') + mkdirSync(targetDir, { recursive: true }) + for (const name of readdirSync(sourceDir)) { + const source = join(sourceDir, name) + const target = join(targetDir, name) + if (!statSync(source).isFile() || existsSync(target)) continue + copyFileSync(source, target) + console.log(`[worktree] .opencode/secrets/${name} copiado do worktree principal.`) + } + } + ensureWorktreeDeps(dir) +} + +/** + * Pick determinístico do `next` — READ-ONLY no Forgejo: `--issue <N>` escolhe a + * Issue direcionada (via `claimTargetVerdict`: `ready` → claim, `in-progress` → + * reopen sem re-claim), sem a flag escolhe a próxima da fila (mesmo pick/ordem + * do `bun run agent:claim`). O claim em si acontece só em `cmdNext`, DEPOIS da + * derivação/validação do branch — uma Issue sem frontmatter id (ou branch + * inválido) morre antes do flip de labels, nunca deixando claim órfão. + */ +const pickNextIssue = async ({ requestedIssueNumber, die }) => { + if (requestedIssueNumber !== null) { + const raw = String(requestedIssueNumber) + const number = Number(raw) + if (!Number.isInteger(number) || number <= 0) { + die(`--issue inválido: ${raw}`) + } + const target = await api.getIssue(number) + const verdict = claimTargetVerdict(target) + if (verdict.kind === 'error') die(verdict.message) + const entry = claimQueueEntry(target, await issuesById()) + if (verdict.kind === 'reopen') { + // Sessão já claimada — reabrir não re-claima (reopen é sobre a sessão). + return { entry, reopened: true, directed: true } + } + if (entry.blockedBy.length > 0) { + die(`Issue #${number} não é claimável (bloqueada por ${entry.blockedBy.join(', ')}).`) + } + return { entry, reopened: false, directed: true } + } + + const pick = await nextClaimableIssue() + if (!pick) { + die('Fila vazia — nada `ready` desbloqueado. Rode `bun run agent:status` para ver a fila.') + } + return { entry: pick, reopened: false, directed: false } +} + +const cmdNext = async (stay, requestedIssueNumber) => { + const { entry, reopened, directed } = await pickNextIssue({ requestedIssueNumber, die }) + + const issue = entry.issue + const branch = branchNameForIssue({ ...issue, meta: entry.meta }) + if (git(['check-ref-format', '--allow-onelevel', branch], { okIfFails: true }) === null) { + die(`Branch derivado inválido para refname: ${branch}`) + } + const dir = join(WORKTREES_ROOT, branch) + + // O claim flips labels — só depois da derivação do branch provar que a Issue + // tem tudo para virar worktree. + if (!reopened) await claimIssue(entry, die) + const claimedIssueNumber = reopened ? null : issue.number + + const headline = reopened + ? 'Sessão já claimada — reabrindo (sem re-claim)' + : directed + ? 'Claimado (direcionado — `--issue`)' + : 'Claimado da fila' + console.log(`\n${headline}: #${issue.number} ${issue.title}`) + for (const line of claimBriefLines(entry)) console.log(line) + + try { + git(['fetch', 'origin']) + + const entries = parseWorktreeList(git(['worktree', 'list', '--porcelain'])) + if (entries.some((entry) => resolve(entry.path) === resolve(dir))) { + console.log(`Worktree já existe em ${dir} — reutilizando, sem duplicar.`) + } else { + const branchExists = + git(['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], { + okIfFails: true, + }) !== null + if (branchExists) { + // Reopen com worktree removido à mão: `-B` resetaria commits de sessão. + const ahead = git(['log', '--oneline', `origin/main..${branch}`], { okIfFails: true }) + if (ahead) { + die( + `Branch ${branch} tem commits fora de origin/main e o worktree não existe — reabrir com -B descartaria esses commits. Rode \`git branch -D ${branch}\` (sessão encerrada) ou resolva antes.`, + ) + } + } + const flag = branchExists ? '-B' : '-b' + git(['worktree', 'add', flag, branch, dir, 'origin/main']) + + console.log('Worktree criado:') + console.log(` branch: ${branch}`) + console.log(` path: ${dir}`) + console.log(' origem: origin/main') + } + console.log( + reopened + ? 'Issue já claimada (sessão reaberta) — NÃO rodar `bun run agent:claim` (o claim é parte do `worktree next`).' + : 'Issue claimada por este comando — NÃO rodar `bun run agent:claim` (o claim é parte do `worktree next`).', + ) + + const mainRoot = entries[0]?.path + await provision({ dir, mainRoot }) + + console.log(`\nAmbiente provisionado: bun install em ${dir}`) + } catch (error) { + if (claimedIssueNumber !== null) { + console.error( + `\n[worktree] Issue #${claimedIssueNumber} ficou claimada (o worktree não foi concluído) — ` + + `reabra quando quiser: \`bun run worktree next --issue ${claimedIssueNumber}\`.\n`, + ) + } + throw error + } + + if (!stay) { + printLaunchDirective({ dir, purpose: 'next', issueNumber: issue.number }) + console.log(`cd ${dir}`) + } +} + +/** Branch short-names already alive — local refs plus origin (shortened). */ +const buildTakenBranchNames = () => { + const taken = new Set() + for (const scope of [ + ( + git(['for-each-ref', '--format=%(refname:short)', 'refs/heads'], { + okIfFails: true, + }) ?? '' + ).split('\n'), + ( + git(['for-each-ref', '--format=%(refname:short)', 'refs/remotes/origin'], { + okIfFails: true, + }) ?? '' + ) + .split('\n') + .map((name) => name.replace(/^origin\//, '')), + ]) { + for (const name of scope) if (name) taken.add(name) + } + return taken +} + +/** + * Shared runner for namespace worktrees NOT tied to the claim queue (`plan` + * and `new`): fetches origin, picks a FRESH branch in the namespace, provisions + * the worktree and prints the `cd <dir>` line by default (`--stay` suppresses). + */ +const cmdNamespaceBranch = async ({ stay, purpose, noun, sessionLabel, branchName }) => { + git(['fetch', 'origin']) + + const entries = parseWorktreeList(git(['worktree', 'list', '--porcelain'])) + const taken = buildTakenBranchNames() + + const branch = branchName(taken) + if (git(['check-ref-format', '--allow-onelevel', branch], { okIfFails: true }) === null) { + die(`Branch ${purpose} inválido para refname: ${branch}`) + } + const dir = join(WORKTREES_ROOT, branch) + + if (entries.some((entry) => resolve(entry.path) === resolve(dir))) { + die( + `Já existe um worktree ${noun} em ${dir} (branch ${branch} não detectado nos refs). Rode \`bun run worktree kill\` de dentro dele.`, + ) + } + + git(['worktree', 'add', '-b', branch, dir, 'origin/main']) + + console.log(`Worktree ${noun} criado:`) + console.log(` sessão: ${sessionLabel}`) + console.log(` branch: ${branch}`) + console.log(` path: ${dir}`) + console.log(' origem: origin/main') + + const mainRoot = entries[0]?.path + await provision({ dir, mainRoot }) + + if (!stay) { + printLaunchDirective({ dir, purpose }) + console.log(`cd ${dir}`) + } +} + +/** `plan` — um worktree de planejamento `/plan-issue`, diferente por invocação. */ +const cmdPlan = async (stay, bag) => + cmdNamespaceBranch({ + stay, + purpose: 'plan', + noun: 'de planejamento', + sessionLabel: bag && bag.trim() ? `lote "${bag}"` : 'sequencial', + branchName: (taken) => planBranchName({ bag, taken }), + }) + +/** `new` — um worktree NEUTRO (sem função pré-definida), diferente por invocação. */ +const cmdNew = async (stay, bag) => + cmdNamespaceBranch({ + stay, + purpose: 'new', + noun: 'neutro', + sessionLabel: bag && bag.trim() ? `bag "${bag}"` : 'sequencial', + branchName: (taken) => workBranchName({ bag, taken }), + }) + +const cmdKill = async (force) => { + const entries = parseWorktreeList(git(['worktree', 'list', '--porcelain'])) + const mainRoot = entries[0]?.path + if (!mainRoot) die('Não consegui ler os worktrees deste repo.') + const top = git(['rev-parse', '--show-toplevel']) + if (resolve(top) === resolve(mainRoot)) { + die( + 'Você está no worktree principal (main) — não dá para destruí-lo. Rode `git worktree list`.', + ) + } + + const entry = entries.find((candidate) => resolve(candidate.path) === resolve(top)) + const branch = entry?.branch ?? null + + const dirty = git(['-C', top, 'status', '--porcelain']) + if (dirty && !force) { + const sample = dirty + .split('\n') + .slice(0, 10) + .map((line) => ` ${line}`) + .join('\n') + die( + `Worktree ${top} está sujo. Commite/stacke ou confirme com \`bun run worktree kill --force\`.\n${sample}`, + ) + } + + git(['-C', mainRoot, 'worktree', 'remove', '--force', top]) + if (branch) git(['-C', mainRoot, 'branch', '-D', branch]) + + console.log(`Worktree destruído: ${top}`) + console.log(` branch removido: ${branch ?? '(detached — nada a remover)'}`) + + console.log(`cd ${mainRoot}`) +} + +const { flags, positional } = parseArgs(process.argv.slice(2), new Set(['issue'])) +const subcommand = positional[0] + +if (!subcommand) { + console.log( + 'Uso: bun run worktree next [--issue N] [--stay] | plan [bag] [--stay] | new [bag] [--stay] | kill [--force]', + ) + console.log(' next [--issue N] [--stay]') + console.log(' CLAIMA a próxima Issue claimável (mesma fila/ordem e lock otimista do') + console.log(' `bun run agent:claim`) e cria o worktree dela (branch <code>-<slug>) de') + console.log(' origin/main, com bun install. `--issue N` claima a Issue direcionada') + console.log(' (`ready`) ou REABRE uma já claimada (`in-progress`, sem re-claim). Por') + console.log(' padrão imprime `cd <dir>` no fim; no terminal (TEQO_WORKTREE_TERMINAL=1)') + console.log(' imprime também a diretiva `launch opencode … --prompt "/work-issue --issue <N>"`;') + console.log(' --stay suprime cd e launch (o claim ainda acontece); --go é no-op') + console.log('\n plan [bag] [--stay]') + console.log(' cria um worktree de planejamento DIFERENTE a cada invocação (sessões') + console.log(' /plan-issue paralelas): com bag, branch plans/plan-issue-<bag> (sufixo') + console.log(' -2/-3 se o nome já existir); sem bag, o próximo plans/plan-issue-<n>') + console.log(' sequencial livre; o prefixo minúsculo plans/… nunca colide com o branch') + console.log(' <code>-<slug> de `next`; no terminal, mesma diretiva `launch` com') + console.log(' --prompt /plan-issue enviado') + console.log('\n new [bag] [--stay]') + console.log(' cria um worktree NEUTRO (sem função pré-definida) DIFERENTE a cada') + console.log(' invocação: com bag, branch work/<bag> (sufixo -2/-3 se o nome já') + console.log(' existir); sem bag, o próximo work/<n> sequencial livre; no terminal,') + console.log(' mesma diretiva `launch` porém sem --prompt (apenas conversar)') + console.log(' kill [--force] destrói o worktree em que você está (recusa sujo sem') + console.log(' --force) e imprime `cd <main>` no fim') + process.exit(1) +} + +try { + if (subcommand === 'next') { + if ('issue' in flags && flags.issue === undefined) { + die('`--issue` requer um número (ex.: `--issue 12`).') + } + if (positional.length > 1) { + die('`next` não aceita argumento posicional — use `--issue <N>` para direcionar a Issue.') + } + await cmdNext(Boolean(flags.stay), flags.issue ?? null) + } else if (subcommand === 'plan') await cmdPlan(Boolean(flags.stay), positional[1]) + else if (subcommand === 'new') await cmdNew(Boolean(flags.stay), positional[1]) + else if (subcommand === 'kill') { + if (flags.stay) die('`--stay` não se aplica a `kill` — ele sempre volta ao main.') + await cmdKill(Boolean(flags.force)) + } else die(`subcomando desconhecido: ${subcommand} (esperado: next | plan | new | kill)`) +} catch (error) { + if (error?.stderr) die(error.stderr.toString().trim()) + die(error?.message ?? String(error)) +} diff --git a/tests/e2e/settings.spec.ts b/tests/e2e/settings.spec.ts new file mode 100644 index 0000000..895de5f --- /dev/null +++ b/tests/e2e/settings.spec.ts @@ -0,0 +1,14 @@ +import { expect, test } from "@playwright/test"; + +test("tela de configuração salva e abre o chat", async ({ page }) => { + await page.goto("/"); + + await expect(page.getByText("Converse com o seu assistente de IA")).toBeVisible(); + await page.getByPlaceholder("wss://app.exemplo.dev/ws/chat").fill("ws://127.0.0.1:1/ws/chat"); + await page.getByPlaceholder("seu token de acesso").fill("token-de-teste"); + await page.getByRole("button", { name: "Conectar" }).click(); + + await expect(page.getByPlaceholder("Escreva sua mensagem…")).toBeVisible(); + await expect(page.getByRole("button", { name: "Configurações" })).toBeVisible(); + await expect(page.getByText("Fale com a Iara — pergunte, peça para criar eventos, tarefas, notas e e-mails.")).toBeVisible(); +}); diff --git a/tests/e2e/ws-reconnect.spec.ts b/tests/e2e/ws-reconnect.spec.ts new file mode 100644 index 0000000..ee6211e --- /dev/null +++ b/tests/e2e/ws-reconnect.spec.ts @@ -0,0 +1,25 @@ +import { expect, test } from "@playwright/test"; + +test("chat monta e tenta reconectar quando o WS falha", async ({ page }) => { + // Config apontando para uma porta fechada → conexão falha e o cliente + // entra no ciclo de reconexão (status "reconectando…"). + await page.addInitScript(() => { + localStorage.setItem( + "iara.settings.v1", + JSON.stringify({ + gatewayUrl: "ws://127.0.0.1:1/ws/chat", + token: "token-de-teste", + agent: "main", + sessionName: "Iara", + sttUrl: "", + ttsUrl: "", + }), + ); + }); + + await page.goto("/"); + + await expect(page.getByPlaceholder("Escreva sua mensagem…")).toBeVisible(); + await expect(page.getByText("reconectando…")).toBeVisible(); + await expect(page.getByRole("button", { name: "Enviar" })).toBeDisabled(); +});