Setup do workflow de agente (tipo teqo) + CI com e2e só no CI #1

Merged
fsolla merged 5 commits from OPS1-setup-workflow-teqo into main 2026-08-18 00:53:12 +00:00
48 changed files with 3748 additions and 22 deletions

View file

@ -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: [<id-do-pai>]`
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-<slug>.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 <ID> --title "<título>" --prio <P> --kind chore --plan docs/plans/<slug>.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 <N>`.
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 <id-do-pai>` (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 <branch>
--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)

View file

@ -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<string, number>();
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<User> {
return await userService.findById(id);
}
// After
function getUser(id: string): Promise<User> {
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 <Badge variant="admin">Admin</Badge>;
} else {
return <Badge variant="default">User</Badge>;
}
}
// After
function UserBadge({ user }: Props) {
const variant = user.isAdmin ? 'admin' : 'default';
const label = user.isAdmin ? 'Admin' : 'User';
return <Badge variant={variant}>{label}</Badge>;
}
// 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

View file

@ -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/<slug>.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/<slug>.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<dígitos>` 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/<slug>.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 <ID> --title "<título>" --prio <P0..P3> \
--depends <A,B> --kind <feature|chore|...> --plan docs/plans/<slug>.md \
--model <slug>
```
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 <branch> --title "Plano(s): <resumo>" --body "Related #<N>" --automerge
```
4. Espere o merge em `main`.
5. **Promote** (idempotente se já `ready`):
```bash
bun run agent:ready -- --issue <N[,N…]>
```
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.

View file

@ -0,0 +1,101 @@
# Template de plano de intenção (`docs/plans/<slug>.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/<slug>-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
# <Título do item em pt-BR>
Status: rascunho
Atualizado em: <YYYY-MM-DD>
Issue: #<N> (após `bun run agent:register`; senão "—")
Priority: <P0 | P1 | P2 | P3>
Model: <slug model-selection>
Impeccable: <A | B | C | D> — <N/A sem UI | encaixe em tela X | fluxo novo em Y>
Rascunho UI: <N/A — sem UI | docs/plans/<slug>-ui-draft.html + PNG embutido abaixo>
Appetite: <ex.: ~0,5–1 dia eng; um outcome verificável>
Responsável: —
## Intenção
<Por que isso existe agora. Problema ou oportunidade em prosa curta — voz do humano, não da stack.>
## Persona e fluxo
- **Persona / contexto:** <quem usa, onde (campo/mesa), estado de espírito>
- **Job principal:** <uma frase — o que precisa conseguir>
- **Fluxo desejado:** <passos em linguagem de usuário; o que vê / faz / decide>
- **Anti-goals de produto:** <o que esta entrega NÃO deve virar (ex.: spreadsheet mode, segundo cadastro de pessoa)>
### Esboço de fluxo (B/C/D)
<!-- Opcional se houver Rascunho UI. ASCII de jornada (backup textual), não layout de implementação.
Omitir se A / sem UI. O artefato obrigatório do gate para UI é o PNG do rascunho HTML+Tailwind — ver ui-draft-html.md. -->
\```text
[início] → … → [outcome]
\```
### Rascunho UI (B/C/D)
<!-- Obrigatório se houver UI: PNG(s) renderizado(s) de docs/plans/<slug>-ui-draft.html
(bun run ui-draft:render) — este é o rascunho visual a validar no gate.
Omitir se A / sem UI. O HTML é a fonte iterável; o PNG é o registro do aceite. -->
![Rascunho UI — <rótulo da cena>](<slug>-ui-draft-<rótulo>.png)
## Objetivo e aceite
- <Outcome verificável em linguagem de produto, um por bullet>
- <Guardrails de produto que valem para o item: ex. "liderança não vê estimativas", "fail-closed se faltar consentimento" — sem nomear migration/collection>
## Dados (intenção)
<!-- Sempre presente. Sem métrica: "Dados: N/A — <por quê>". -->
- **Vou apresentar dados?** <Não | Sim, só aggregate para outro consumidor | Sim, superfície neste item>
- **Decisões desbloqueadas:** <ator + escolha; uma por bullet. Sem decisão nomeável → corte vaidade.>
- **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)
<!-- Soft. Revisável. Sem signatures, sem "criar collection X", sem mermaid de solução. -->
- **Áreas prováveis:** <rotas `/campanha/…`, pastas `src/components/campaign/<domínio>/`, `src/utilities/<domínio>/`, `src/lib/…`>
- **Precedente a olhar:** <plano/Issue/arquivo análogo, se houver>
- **Risco de acoplamento:** <uma linha — o que o executor deve respeitar (ex.: leader lockdown, lista B18)>
## Dependências
- <IDs duros/suaves ou "Nenhuma">
## Fora de escopo
- <Exclusões de produto, com destino (outro ID/plano) quando houver>
## Rabbit holes de produto
- **<Risco>.** Se alguém “só completar”: <explosão de escopo>. **Corte neste item:** <…>.
## Questões em aberto (produto)
<!-- Opções + Recomendação de produto. Nunca pergunta sem posição. -->
- **<Pergunta>?** **Opções:** A | B | C. **Recomendação:** <…>. _(assumido — validar com produto)_ quando couber.
## Referências
- GitHub Issue #<N>
- Rascunho UI (gate): <link do `.html` + PNGs embutidos acima | N/A>
- <arquivos/rotas úteis para o executor abrir primeiro — como pista, não contrato>
- `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.

View file

@ -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.
&lt;4 → corrigir antes de gravar / registrar Issue.

View file

@ -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/<slug>-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 <N>` 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 <N>`; 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/<slug>.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/<slug>-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:** `<Code>-<slug>` 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.

View file

@ -0,0 +1,51 @@
# Qualidade de decisão (plano de implementação)
Aplique ao escrever `docs/plans/<slug>-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 &lt;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?

View file

@ -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.

View file

@ -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 #<N>`:
```bash
bun run pr -- --head <branch> --title "<título>" --body "Closes #<N>" --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`) | `<Code>-<slug>` (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 |

View file

@ -0,0 +1,65 @@
# Template de plano de implementação (`docs/plans/<slug>-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: <mesmo título da intenção>
Status: rascunho | aprovado | em execução
Atualizado em: <YYYY-MM-DD>
Issue: #<N>
Intenção: docs/plans/<slug>.md
Appetite restante: <herdado / ajustado com corte explícito>
## Leitura da intenção
- **Outcome:** <1–2 frases, nas palavras do aceite de produto>
- **O que NÃO negociar:** <lockdowns de produto / access / LGPD citados na intenção>
- **O que reavaliar:** <hipóteses de “Direção no codebase” que podem estar erradas>
## Abordagem recomendada
\```mermaid
flowchart LR
…
\```
**Opções consideradas:** A | B | C
**Recomendação:** <…> — porque …
**Rejeitadas:** …
### Componentes / mudanças
- **`<símbolo>`** (`src/…`): <responsabilidade; o que reusa>
- **Migration:** <nome sugerido / “sem migration”>
- **Access / Consent:** <helpers; chave se houver; fail-closed>
- **UI:** <Impeccable A–D; shells a reusar; shape→craft→critique→polish se B/C/D>
### Dados → forma (se aplicável)
- Forma escolhida + por quê + rejeitadas (pergunta 3 de data-presentation)
## Fases verificáveis
1. **Tracer / schema+server** — <quota do appetite>
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.

View file

@ -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 <código>-<slug> (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-<bag>` (sufixo `-2`/`-3` se o nome já estiver vivo); sem `bag` → próximo sequencial `plans/plan-issue-<n>` livre. `new` é o irmão **neutro** (sem função pré-definida): branch `work/<bag>` ou `work/<n>` 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 <dir>` 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 <main>` 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 <dir> --model deepseek/deepseek-v4-flash --auto [--prompt "/work-issue --issue <N>"]` **antes** do `cd`, e a função a executa depois do cd — `next` abre o TUI com `/work-issue --issue <N>` 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, `#<N>`, branch (`<code>-<slug>`), 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>-<slug>`:** `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/<repo>`).
- **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.

View file

@ -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 }}"

71
.forgejo/workflows/ci.yml Normal file
View file

@ -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)"

View file

@ -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)"

View file

@ -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 }}"

View file

@ -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 }}"

2
.forgejo/worktree.env Normal file
View file

@ -0,0 +1,2 @@
FORGEJO_REPOSITORY=amana/iara-pwa
WORKTREES_ROOT=~/.worktrees/iara

4
.gitignore vendored
View file

@ -12,6 +12,10 @@ dist
dist-ssr
*.local
# Playwright
playwright-report
test-results
# Editor directories and files
.vscode/*
!.vscode/extensions.json

View file

@ -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`

View file

@ -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

View file

@ -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

View file

@ -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 <dir>` na última linha — aplique-o na sessão de shell atual (`cd <dir>`) 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-<bag>`, ou `plans/plan-issue-<n>` 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 <dir>` 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/<bag>`, ou `work/<n>` 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 <main>` na última linha — aplique-o (`cd <main>`) 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`

86
AGENTS.md Normal file
View file

@ -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 `<gateway>/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/<slug>.md`](docs/plans/<slug>.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/<slug>.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/<code>-<slug>` de `origin/main`, roda `bun install` e abre o opencode com `/work-issue --issue N`.
3. **`/work-issue`** → plano de implementação (`docs/plans/<slug>-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 <b> --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).

View file

@ -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=="],

13
docs/plans/README.md Normal file
View file

@ -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/<slug>.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/<slug>-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).

View file

@ -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",

20
playwright.config.ts Normal file
View file

@ -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,
},
});

64
scripts/agent-claim.mjs Normal file
View file

@ -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.`,
)

View file

@ -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 <what went wrong> [--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}`)

View file

@ -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 <N>')
}
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)
})`,
)
}
}

58
scripts/agent-ready.mjs Normal file
View file

@ -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 <N[,N…]>')
}
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 <N[,N…]>')
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`)
}

View file

@ -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 <ID> --title <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}`)

223
scripts/agent-status.mjs Normal file
View file

@ -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?)`,
)
}
}

View file

@ -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})`,
)
}
}

View file

@ -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)
}

114
scripts/issue.mjs Normal file
View file

@ -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))
}

View file

@ -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 }
}

View file

@ -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 }
}

View file

@ -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 }
}

10
scripts/lib/cli.mjs Normal file
View file

@ -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)
}

323
scripts/lib/forgejo-api.mjs Normal file
View file

@ -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({})

View file

@ -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 }
}

9
scripts/lib/slug.mjs Normal file
View file

@ -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

132
scripts/lib/worktree.mjs Normal file
View file

@ -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' })

57
scripts/pr.mjs Normal file
View file

@ -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`,
)
}

444
scripts/worktree.mjs Normal file
View file

@ -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))
}

View file

@ -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();
});

View file

@ -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();
});