diff --git a/.cursorrules b/.cursorrules deleted file mode 100644 index 4f2f35a..0000000 --- a/.cursorrules +++ /dev/null @@ -1,131 +0,0 @@ -# Gerado por letra flow move. Nao edite manualmente. - -### ⚠ ATENÇÃO: 2 problema(s) grave(s) detectado(s) pelo diagnóstico automático -Execute `letra health` para detalhes e `letra health ack ` para reconhecer. -# Letra Session — letra - -PASSO OBRIGATÓRIO #1: letra pulse — verificar estado do workspace -PASSO OBRIGATÓRIO #2: Leia .letra/context.md — contexto completo do projeto -PASSO OBRIGATÓRIO #3: Leia .letra/focus.md — foco e outcome da sessão -PASSO OBRIGATÓRIO #4: Leia .letra/specs/write-sync/spec.md — ACs do item - -## Foco Atual - -Item: ITEM-1 · Branding: header Letra no flow serve + 3 opções de logo -Spec: write-sync -Estágio: Done - -## Alertas - -Alerta · severidade baixa - ID: hr-4652f8f0 - O que: AC "flow diff" marcado [ ] mas implementado - Onde: ac-stale - Desde: 17/06/2026, 14:13:59 - Ação: `letra health ack hr-4652f8f0` - -Alerta · severidade baixa - ID: hr-4103b765 - O que: AC "flow visualize" marcado [ ] mas implementado - Onde: ac-stale - Desde: 17/06/2026, 14:13:59 - Ação: `letra health ack hr-4103b765` - -Alerta · severidade baixa - ID: hr-dd77601 - O que: AC "flow export" marcado [ ] mas implementado - Onde: ac-stale - Desde: 17/06/2026, 14:13:59 - Ação: `letra health ack hr-dd77601` - -Alerta · severidade baixa - ID: hr-6380dc9e - O que: AC "GET /" marcado [ ] mas implementado - Onde: ac-stale - Desde: 17/06/2026, 14:13:59 - Ação: `letra health ack hr-6380dc9e` - -Alerta · severidade baixa - ID: hr-352219f2 - O que: AC "letra sitrep" marcado [ ] mas implementado - Onde: ac-stale - Desde: 17/06/2026, 14:13:59 - Ação: `letra health ack hr-352219f2` - - e mais 3 alertas - -## Regras (Violação = Erro Grave) - -**Violação = Erro Grave** - -- Não edite workflow.json manualmente — use `letra flow` e `letra focus` -- Não crie specs fora de .letra/specs/ — use `letra spec new` -- Não pule os passos obrigatórios de início acima -- Execute `letra validate` antes de mover item entre estágios -- Siga a constitution.md rigorosamente - -## Fluxo de Execução - -**Loop por AC**: - 1. Implemente o AC no código - 2. `letra ac done ` — marca como concluído no spec.md - 3. `letra validate` — verifica se ACs estão consistentes - 4. Repita até todos os ACs do item estarem concluídos - -**Ao concluir todos ACs**: - → `letra pulse` — confirma estado - → `letra sitrep` — atualiza context.md - → `letra flow move --auto` — avança para próximo estágio - -## Comandos - -**Leitura (seguro — não muda nada):** - `letra pulse` — Overview do workspace - `letra health` — Alertas ativos - `letra flow board` — Todas as colunas do fluxo - `letra flow backlog` — Itens no backlog - `letra validate` — Validar specs e ACs - -**Escrita (muda estado):** - `letra health ack ` — Reconhecer alerta - `letra health dismiss ` — Descartar alerta - `letra health scan` — Re-executar verificações - `letra sitrep` — Atualizar context.md - `letra flow move --to ` — Mover item entre estágios - `letra focus ` — Definir foco - `letra focus --clear` — Limpar foco - -## Continuidade - -Última atividade: 15/06/2026, 17:55:27 -Ações: - • item_move: Item ITEM-49 movido: Backlog → Done - • item_move: Item ITEM-44 movido: Backlog → Code - • item_move: Item ITEM-44 movido: Code → Review - • item_move: Item ITEM-44 movido: Review → Done - • validate: Validação executada — 26 passed, 0 failed, 248 war - -## Checklist de Encerramento - -1. `letra pulse --json` — veja itens, ACs, alertas, backlog - -2. Decida o estado: - - **CONTINUE** (backlog tem itens OU item atual tem ACs pendentes): - → Relate o progresso: quais ACs fez, o que falta, onde parou - → Se sessão >30 min, pare e relate. Caso contrário, continue. - - **BLOCKED** (backlog vazio, item sem ACs pendentes, aguardando humano): - → Relate "Trabalho concluído, aguardando revisão" - → Liste o que foi feito e decisões necessárias - - **ALL_DONE** (todos os itens em Done, backlog vazio): - → Relate missão completa: itens concluídos, o que foi construído, próximos passos - -## Arquivos de Contexto - -@.letra/context.md -@.letra/constitution.md -@.letra/glossary.md -@.letra/constraints.md -@.letra/focus.md diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b99b3d4 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Force LF line endings for YAML files in workflows +.github/workflows/**/*.yml text eol=lf +.github/workflows/**/*.yaml text eol=lf diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b8ccbc9..8ff9557 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,131 +1,22 @@ -# Gerado por letra flow move. Nao edite manualmente. - -### ⚠ ATENÇÃO: 2 problema(s) grave(s) detectado(s) pelo diagnóstico automático -Execute `letra health` para detalhes e `letra health ack ` para reconhecer. -# Letra Session — letra - -PASSO OBRIGATÓRIO #1: letra pulse — verificar estado do workspace -PASSO OBRIGATÓRIO #2: Leia .letra/context.md — contexto completo do projeto -PASSO OBRIGATÓRIO #3: Leia .letra/focus.md — foco e outcome da sessão -PASSO OBRIGATÓRIO #4: Leia .letra/specs/write-sync/spec.md — ACs do item - -## Foco Atual - -Item: ITEM-1 · Branding: header Letra no flow serve + 3 opções de logo -Spec: write-sync -Estágio: Done - -## Alertas - -Alerta · severidade baixa - ID: hr-4652f8f0 - O que: AC "flow diff" marcado [ ] mas implementado - Onde: ac-stale - Desde: 17/06/2026, 14:13:59 - Ação: `letra health ack hr-4652f8f0` - -Alerta · severidade baixa - ID: hr-4103b765 - O que: AC "flow visualize" marcado [ ] mas implementado - Onde: ac-stale - Desde: 17/06/2026, 14:13:59 - Ação: `letra health ack hr-4103b765` - -Alerta · severidade baixa - ID: hr-dd77601 - O que: AC "flow export" marcado [ ] mas implementado - Onde: ac-stale - Desde: 17/06/2026, 14:13:59 - Ação: `letra health ack hr-dd77601` - -Alerta · severidade baixa - ID: hr-6380dc9e - O que: AC "GET /" marcado [ ] mas implementado - Onde: ac-stale - Desde: 17/06/2026, 14:13:59 - Ação: `letra health ack hr-6380dc9e` - -Alerta · severidade baixa - ID: hr-352219f2 - O que: AC "letra sitrep" marcado [ ] mas implementado - Onde: ac-stale - Desde: 17/06/2026, 14:13:59 - Ação: `letra health ack hr-352219f2` - - e mais 3 alertas - -## Regras (Violação = Erro Grave) - -**Violação = Erro Grave** - -- Não edite workflow.json manualmente — use `letra flow` e `letra focus` -- Não crie specs fora de .letra/specs/ — use `letra spec new` -- Não pule os passos obrigatórios de início acima -- Execute `letra validate` antes de mover item entre estágios -- Siga a constitution.md rigorosamente - -## Fluxo de Execução - -**Loop por AC**: - 1. Implemente o AC no código - 2. `letra ac done ` — marca como concluído no spec.md - 3. `letra validate` — verifica se ACs estão consistentes - 4. Repita até todos os ACs do item estarem concluídos - -**Ao concluir todos ACs**: - → `letra pulse` — confirma estado - → `letra sitrep` — atualiza context.md - → `letra flow move --auto` — avança para próximo estágio - -## Comandos - -**Leitura (seguro — não muda nada):** - `letra pulse` — Overview do workspace - `letra health` — Alertas ativos - `letra flow board` — Todas as colunas do fluxo - `letra flow backlog` — Itens no backlog - `letra validate` — Validar specs e ACs - -**Escrita (muda estado):** - `letra health ack ` — Reconhecer alerta - `letra health dismiss ` — Descartar alerta - `letra health scan` — Re-executar verificações - `letra sitrep` — Atualizar context.md - `letra flow move --to ` — Mover item entre estágios - `letra focus ` — Definir foco - `letra focus --clear` — Limpar foco - -## Continuidade - -Última atividade: 15/06/2026, 17:55:27 -Ações: - • item_move: Item ITEM-49 movido: Backlog → Done - • item_move: Item ITEM-44 movido: Backlog → Code - • item_move: Item ITEM-44 movido: Code → Review - • item_move: Item ITEM-44 movido: Review → Done - • validate: Validação executada — 26 passed, 0 failed, 248 war - -## Checklist de Encerramento - -1. `letra pulse --json` — veja itens, ACs, alertas, backlog - -2. Decida o estado: - - **CONTINUE** (backlog tem itens OU item atual tem ACs pendentes): - → Relate o progresso: quais ACs fez, o que falta, onde parou - → Se sessão >30 min, pare e relate. Caso contrário, continue. - - **BLOCKED** (backlog vazio, item sem ACs pendentes, aguardando humano): - → Relate "Trabalho concluído, aguardando revisão" - → Liste o que foi feito e decisões necessárias - - **ALL_DONE** (todos os itens em Done, backlog vazio): - → Relate missão completa: itens concluídos, o que foi construído, próximos passos - -## Arquivos de Contexto - +# Gerado por letra ac. Nao edite manualmente. +# Letra Context — VSCode Copilot Adapter +Read the following files before starting any task: - .letra/context.md - .letra/constitution.md - .letra/glossary.md - .letra/constraints.md - .letra/focus.md + +# Rules +- Always read specs in .letra/specs/ before writing code +- Run `letra validate` to check acceptance criteria +- Execute `letra ac done ` after implementing each AC +- Follow the constitution.md rules strictly +- Use formal tone in all generated content + + +## Mermaid Diagrams + +When the user asks to create, edit, or visualize a diagram, follow the +instructions in `.github/instructions/mermaid.instructions.md`. + diff --git a/.github/instructions/mermaid.instructions.md b/.github/instructions/mermaid.instructions.md new file mode 100644 index 0000000..90fed47 --- /dev/null +++ b/.github/instructions/mermaid.instructions.md @@ -0,0 +1,85 @@ +--- +applyTo: "**" +--- +# Mermaid AI Skills + +When the user asks to create, edit, or visualize any diagram, use the Mermaid +VS Code extension tools and commands described below. + +## Workflow + +1. Determine the diagram type and generate Mermaid syntax. +2. Write the diagram to a `.mmd` file in the project. +3. Validate syntax: correct first-line keyword, arrow types, balanced brackets. +4. Preview via the Mermaid extension — open the `.mmd` file (auto-preview) or run + **MermaidChart: Preview Diagram** (`mermaidChart.preview`). + +## LM Tools — call these for every diagram interaction + +- `mermaid-diagram-validator` — validate Mermaid syntax before presenting any diagram +- `mermaid-diagram-preview` — render a live preview inside VS Code after generating +- `get-syntax-docs-mermaid` — fetch correct syntax docs for any diagram type + +## VS Code Commands + +Invoke via Command Palette or the VS Code command API (GitHub Copilot in VS Code only). +Do not invent command IDs. Prefer writing/editing `.mmd` files when a command is not needed. + +### Diagram editing & preview +- **Preview** (`mermaidChart.preview`) — preview the active Mermaid editor (`.mmd` / `.mermaid` must be open). +- **Create Diagram** (`mermaidChart.createMermaidFile`) — creates a demo flowchart and opens preview side by side. +- **Repair Diagram** (`mermaidChart.repairDiagram`) — Mermaid AI repair for the active diagram; uses Mermaid AI credits — tell the user before running. +- **Improve Diagram** (`mermaidChart.improveDiagram`) — uses Copilot / LM API; suggests layout + styling variants for the active diagram. + +### Generate diagrams (GitHub Copilot required) +- **Generate Diagram from Code** (`mermaidChart.generateDiagramFromCode`) +- **Generate Cloud Diagram** (`mermaidChart.generateCloudDiagram`) +- **Generate ER Diagram** (`mermaidChart.generateERDiagram`) +- **Generate Docker Diagram** (`mermaidChart.generateDockerDiagram`) +- **Open AI Chat** (`mermaidChart.openCopilotChat`) + +### Mermaid Chart cloud +- **Login** (`mermaidChart.login`) / **Logout** (`mermaidChart.logout`) +- **Connect Diagram** (`mermaidChart.connectDiagramToMermaidChart`) — link a local diagram to Mermaid Chart. +- **Sync Diagram** (`mermaidChart.syncDiagramWithMermaid`) — only for diagrams already connected (frontmatter has `id:`). Example: + ```yaml + --- + id: cbd9e9ba-a2cb-47c5-a98e-8c28a753428d + --- + ``` + +### Review Mermaid Sync +For diagrams updated by the Mermaid Chart GitHub Sync app (or pre-commit regenerate): +- **Review Mermaid Sync** (`mermaidChart.reviewAppCommits`) — start / open the review flow. +- **Regenerate with Mermaid AI** (`mermaidChart.regenerateDiagramWithMermaidAI`) — regenerate from source references. +Do not manually rewrite diagrams managed by this workflow. Accept/reject/diff UI actions stay in the extension UI. + +### Install / update this pack +- **MermaidChart: Install AI Skills…** (`mermaidChart.installAiSkills`) + +## @mermaid-chart slash commands + +| Command | Purpose | +|---|---| +| `/generate_diagram_from_code` | General diagram from any source file | +| `/generate_execution_sequence` | Sequence diagram from code flow | +| `/generate_er_diagram` | ER diagram from schema / models | +| `/generate_cloud_architecture_diagram` | Cloud / CI-CD architecture | +| `/generate_docker_diagram` | Architecture from Dockerfiles | +| `/generate_c4_topdown_architecture` | C4 top-down architecture | +| `/analyze_code_ownership` | Code ownership diagram | +| `/generate_dependency_diagram` | Dependency / security visualisation | + +## Rules + +1. Always call `mermaid-diagram-validator` before showing any diagram. +2. Always call `mermaid-diagram-preview` after generating a diagram. +3. Use `get-syntax-docs-mermaid` before generating an unfamiliar diagram type. +4. Prefer `@mermaid-chart` slash commands for complex generation. +5. Write diagrams to `.mmd` files; never return unvalidated Mermaid syntax. +6. Warn the user before Repair (Mermaid AI credits). +7. Cooperate with the Sync workflow — do not manually regenerate managed diagrams. + +## Docs + +More commands and features: https://marketplace.visualstudio.com/items?itemName=MermaidChart.vscode-mermaid-chart diff --git a/.github/workflows/ds-catalog.yml b/.github/workflows/ds-catalog.yml new file mode 100644 index 0000000..836310e --- /dev/null +++ b/.github/workflows/ds-catalog.yml @@ -0,0 +1,33 @@ +name: DS Catalog + +on: + pull_request: + branches: ["main", "development"] + push: + branches: ["main", "development"] + +jobs: + storybook-catalog: + name: Storybook Catalog + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22.x + cache: "npm" + - run: npm ci + - run: npx playwright install --with-deps chromium + - run: npm -w packages/ui run typecheck + - run: npm -w packages/ui run storybook:build + - run: npm -w packages/ui run storybook:a11y + - run: npm -w packages/ui run storybook:visual + - uses: actions/upload-artifact@v4 + if: always() + with: + name: ds-catalog-storybook + path: | + packages/ui/storybook-static + packages/ui/storybook-visual-snapshots + packages/ui/catalog/ds-catalog.json + retention-days: 14 diff --git a/.gitignore b/.gitignore index a7f737a..2092445 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,45 @@ dist/ *.tsbuildinfo .DS_Store .env +.letra-link .letra/specs/*/status.md .letra/backups/ .letra/workflow.v*.json +.letra/snapshots/ .opencode/ + +# Build / generated outputs (regenerable) +storybook-static/ +**/storybook-static/ +**/.storybook-static/ +test-results/ +.tmp/ + +# Root-level scratch / debugging throwaway scripts +/debug-* +/test-harness* +/test-parse* +/val_out.txt +/flow-serve*.log +*.err.log +bump-*.diff +*.bak.* +context-economy-log.jsonl + +# Compiled output emitted in-place by tsc/tsup/vitest/playwright (regenerable, never hand-written) +*.js.map +*.d.ts.map +tsup.config.js +tsup.config.d.ts +tsup.config.js.map +tsup.config.d.ts.map +vitest.config.js +vitest.config.d.ts +vitest.config.js.map +vitest.config.d.ts.map +playwright.config.js +playwright.config.d.ts +playwright.config.js.map +playwright.config.d.ts.map +packages/*/build/ +packages/ui/storybook-visual-snapshots/ diff --git a/.hermes/instructions.md b/.hermes/instructions.md deleted file mode 100644 index b8ccbc9..0000000 --- a/.hermes/instructions.md +++ /dev/null @@ -1,131 +0,0 @@ -# Gerado por letra flow move. Nao edite manualmente. - -### ⚠ ATENÇÃO: 2 problema(s) grave(s) detectado(s) pelo diagnóstico automático -Execute `letra health` para detalhes e `letra health ack ` para reconhecer. -# Letra Session — letra - -PASSO OBRIGATÓRIO #1: letra pulse — verificar estado do workspace -PASSO OBRIGATÓRIO #2: Leia .letra/context.md — contexto completo do projeto -PASSO OBRIGATÓRIO #3: Leia .letra/focus.md — foco e outcome da sessão -PASSO OBRIGATÓRIO #4: Leia .letra/specs/write-sync/spec.md — ACs do item - -## Foco Atual - -Item: ITEM-1 · Branding: header Letra no flow serve + 3 opções de logo -Spec: write-sync -Estágio: Done - -## Alertas - -Alerta · severidade baixa - ID: hr-4652f8f0 - O que: AC "flow diff" marcado [ ] mas implementado - Onde: ac-stale - Desde: 17/06/2026, 14:13:59 - Ação: `letra health ack hr-4652f8f0` - -Alerta · severidade baixa - ID: hr-4103b765 - O que: AC "flow visualize" marcado [ ] mas implementado - Onde: ac-stale - Desde: 17/06/2026, 14:13:59 - Ação: `letra health ack hr-4103b765` - -Alerta · severidade baixa - ID: hr-dd77601 - O que: AC "flow export" marcado [ ] mas implementado - Onde: ac-stale - Desde: 17/06/2026, 14:13:59 - Ação: `letra health ack hr-dd77601` - -Alerta · severidade baixa - ID: hr-6380dc9e - O que: AC "GET /" marcado [ ] mas implementado - Onde: ac-stale - Desde: 17/06/2026, 14:13:59 - Ação: `letra health ack hr-6380dc9e` - -Alerta · severidade baixa - ID: hr-352219f2 - O que: AC "letra sitrep" marcado [ ] mas implementado - Onde: ac-stale - Desde: 17/06/2026, 14:13:59 - Ação: `letra health ack hr-352219f2` - - e mais 3 alertas - -## Regras (Violação = Erro Grave) - -**Violação = Erro Grave** - -- Não edite workflow.json manualmente — use `letra flow` e `letra focus` -- Não crie specs fora de .letra/specs/ — use `letra spec new` -- Não pule os passos obrigatórios de início acima -- Execute `letra validate` antes de mover item entre estágios -- Siga a constitution.md rigorosamente - -## Fluxo de Execução - -**Loop por AC**: - 1. Implemente o AC no código - 2. `letra ac done ` — marca como concluído no spec.md - 3. `letra validate` — verifica se ACs estão consistentes - 4. Repita até todos os ACs do item estarem concluídos - -**Ao concluir todos ACs**: - → `letra pulse` — confirma estado - → `letra sitrep` — atualiza context.md - → `letra flow move --auto` — avança para próximo estágio - -## Comandos - -**Leitura (seguro — não muda nada):** - `letra pulse` — Overview do workspace - `letra health` — Alertas ativos - `letra flow board` — Todas as colunas do fluxo - `letra flow backlog` — Itens no backlog - `letra validate` — Validar specs e ACs - -**Escrita (muda estado):** - `letra health ack ` — Reconhecer alerta - `letra health dismiss ` — Descartar alerta - `letra health scan` — Re-executar verificações - `letra sitrep` — Atualizar context.md - `letra flow move --to ` — Mover item entre estágios - `letra focus ` — Definir foco - `letra focus --clear` — Limpar foco - -## Continuidade - -Última atividade: 15/06/2026, 17:55:27 -Ações: - • item_move: Item ITEM-49 movido: Backlog → Done - • item_move: Item ITEM-44 movido: Backlog → Code - • item_move: Item ITEM-44 movido: Code → Review - • item_move: Item ITEM-44 movido: Review → Done - • validate: Validação executada — 26 passed, 0 failed, 248 war - -## Checklist de Encerramento - -1. `letra pulse --json` — veja itens, ACs, alertas, backlog - -2. Decida o estado: - - **CONTINUE** (backlog tem itens OU item atual tem ACs pendentes): - → Relate o progresso: quais ACs fez, o que falta, onde parou - → Se sessão >30 min, pare e relate. Caso contrário, continue. - - **BLOCKED** (backlog vazio, item sem ACs pendentes, aguardando humano): - → Relate "Trabalho concluído, aguardando revisão" - → Liste o que foi feito e decisões necessárias - - **ALL_DONE** (todos os itens em Done, backlog vazio): - → Relate missão completa: itens concluídos, o que foi construído, próximos passos - -## Arquivos de Contexto - -- .letra/context.md -- .letra/constitution.md -- .letra/glossary.md -- .letra/constraints.md -- .letra/focus.md diff --git a/.letra/adapters/opencode.json b/.letra/adapters/opencode.json deleted file mode 100644 index cfb147f..0000000 --- a/.letra/adapters/opencode.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "adapter": "opencode", - "version": "1", - "description": "Adapter para OpenCode — injeta contexto .letra/ no system prompt", - "injection": { - "method": "system-prompt", - "files": [ - ".letra/context.md", - ".letra/constitution.md", - ".letra/glossary.md" - ], - "template": "## Projeto: Letra\n\nVocê está trabalhando no projeto Letra. Abaixo está o contexto, constituição e glossário do projeto. Use essas informações para guiar suas decisões.\n\n### Context\n{{context.md}}\n\n### Constitution\n{{constitution.md}}\n\n### Glossary\n{{glossary.md}}\n\n### Specs Ativas\n{{specs}}\n\nSempre consulte `.letra/specs/` antes de implementar qualquer funcionalidade. Se uma spec não existe para o que está sendo pedido, sugira criá-la primeiro.", - "specs_pattern": ".letra/specs/*/spec.md" - }, - "validation": { - "command": "letra validate", - "lint_command": "letra lint .letra/" - }, - "notes": "OpenCode lê configuração via AGENTS.md ou similar. Este adapter define como o conteúdo .letra/ deve ser injetado no system prompt." -} diff --git a/.letra/brand/logo-01-terminal.svg b/.letra/brand/logo-01-terminal.svg deleted file mode 100644 index 2831997..0000000 --- a/.letra/brand/logo-01-terminal.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - >_ - letra - .dev - diff --git a/.letra/brand/logo-02-bookmark.svg b/.letra/brand/logo-02-bookmark.svg deleted file mode 100644 index a3f2eb3..0000000 --- a/.letra/brand/logo-02-bookmark.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - letra - .dev - diff --git a/.letra/brand/logo-03-diamond.svg b/.letra/brand/logo-03-diamond.svg deleted file mode 100644 index 5dedec5..0000000 --- a/.letra/brand/logo-03-diamond.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - letra - .dev - diff --git a/.letra/bump-0.5.3.diff b/.letra/bump-0.5.3.diff deleted file mode 100644 index c3204e4..0000000 --- a/.letra/bump-0.5.3.diff +++ /dev/null @@ -1,10 +0,0 @@ -*** Begin Patch -*** Update File: package.json -@@ -- "version": "0.5.1", -+ "version": "0.5.3", -*** Update File: packages/cli/package.json -@@ -- "version": "0.4.1", -+ "version": "0.5.3", -*** End Patch diff --git a/.letra/constitution.md b/.letra/constitution.md deleted file mode 100644 index 2f150e1..0000000 --- a/.letra/constitution.md +++ /dev/null @@ -1,52 +0,0 @@ -# Constitution - -> Regras não-negociáveis do projeto Letra -> Updated: 2026-06-15 - -## Arquitetura - -- Adapter layer desde o dia 1 — nunca travar em uma IDE -- Formato `.letra/` é a fonte da verdade, não o código -- CLI deve ser extensível via plugins -- **Separação de domínios**: `validation/` (formato e conteúdo de specs) e `diagnostics/` (drift entre specs, código e workflow) são módulos distintos e não devem se importar mutuamente -- **Shared modules**: código usado por mais de um detector ou comando DEVE ser extraído para módulo compartilhado em `diagnostics/shared/` ou `validation/`. PROIBIDO duplicar `searchInSource`, `walkDir`, `loadSpecs` ou funções equivalentes - -## Código - -- TypeScript estrito (`strict: true` no tsconfig) -- Biome para linting e formatação -- Testes para toda lógica de parsing e validação -- Zero dependencies desnecessárias — cada dependency precisa de justificativa -- **Thin wrappers**: Commands CLI (arquivos em `commands/`) DEVEM ser thin wrappers com no máximo 100 linhas que orquestram chamadas a módulos shared. Lógica de domínio NUNCA deve estar em comandos -- **Funções puras**: Shared modules DEVEM exportar funções puras — sem estado global, sem efeito colateral, testáveis isoladamente -- **Responsabilidade única**: Cada detector, comando e módulo faz UMA coisa. Se um arquivo tem mais de uma responsabilidade clara, DEVE ser dividido -- **Template-driven**: Adaptadores (formatters.ts) geram strings a partir de dados — sem lógica de negócio. Dados são preparados por builders separados - -## Specs - -- Thin specs: máximo 1 página por feature -- Markdown checklist para acceptance criteria -- Sem pseudo-código nas specs -- Toda spec deve ter: Outcome, Constraints, Exclusions, Acceptance Criteria, Context - -## Workflow - -- Spec atualizada como parte do Definition of Done -- PR sem spec atualizada = reject -- Dogfood: Letra é construído com Letra - -## Manutenibilidade - -- **Complexidade ciclomática**: Nenhuma função pode exceder cyclo 10. Acima disso DEVE ser refatorada em funções menores -- **Split threshold**: Arquivos com cyclo total > 30 DEVEM ser candidatos a splitting -- **Tamanho máximo**: Detectores novos no máximo 80 linhas; commands CLI no máximo 100 linhas -- **Anti-padrões proibidos**: - - Routing monolítico (cadeias if/else if com mais de 5 endpoints) — usar Router Map pattern - - Validação inline duplicada em endpoints HTTP — usar módulo `validation/` - - Lógica de domínio nos comandos CLI — extrair para módulo shared - - Duplicação de walkDir/searchInSource — usar `diagnostics/shared/file-search.ts` - -## Segurança - -- Nunca incluir secrets, tokens ou chaves no repositório -- Binário standalone para distribuição a não-devs diff --git a/.letra/constraints.md b/.letra/constraints.md deleted file mode 100644 index c8fb42d..0000000 --- a/.letra/constraints.md +++ /dev/null @@ -1,31 +0,0 @@ -# Architecture Constraints - -Leia este arquivo antes de iniciar qualquer atividade de desenvolvimento. -Ele define as regras arquiteturais que o agente deve seguir. - -## Domain-Agnostic Principle - -Letra é um framework SDD **agnóstico de domínio**. -Nenhuma suposição técnica deve ser hardcoded. -Toda suposição deve ser abstraída ou configurável. - -## Design Governance - -- Itens em **Design** só saem de lá após aprovação humana explícita. -- Mudanças estruturais (workspace, schema, arquitetura) exigem spec em Design primeiro. -- Não mover itens de Design para Code sem confirmação do usuário. - -## Before Coding - -1. Leia `.letra/context.md` para contexto completo do workspace. -2. Leia `.letra/focus.md` para saber qual item está ativo. -3. Identifique o estágio do item ativo no workflow. -4. Itens em **Design** são讨论 apenas — nunca implementar sem aprovação. -5. Consulte `.letra/specs/architecture-agnostic/spec.md` antes de qualquer refatoração estrutural. - -## Regras Gerais - -- Specs antes de código. Toda feature começa com uma spec. -- ACs no spec.md são a definição de done. -- Use `letra flow` commands para gerenciar o workflow, nunca edite workflow.json manualmente. -- Ao concluir, mova o item com `letra flow move --to `. diff --git a/.letra/context.md b/.letra/context.md deleted file mode 100644 index 09b3cc3..0000000 --- a/.letra/context.md +++ /dev/null @@ -1,89 +0,0 @@ -# Context - -> Updated: 2026-06-17T17:13:53.421Z -> Owner: letra-dev - -## Intent - -Letra é um framework de Specification-Driven Development (SDD) agnóstico a ferramentas. -Captura direção, intenção e contexto, enriquecendo prompts de agentes de código. - -## Domínio - -- **Produto**: CLI + adapters + formato de memória `.letra/` + SPA web UI -- **Público**: 1. Não-devs → 2. Devs → 3. Empresas (tarefas diversas) -- **Stack**: TypeScript, Node.js 22+, React 19 + Vite (web UI), distribuído via npm (npx) - - -**Estágio**: Code -**Item atual**: ITEM-43 — Implementar adapter Hermes Agent (spec: adapter-hermes) -**ACs**: 5/5 pendentes | 0 feito(s) -**Alertas**: 1 novo(s) · 4 em acompanhamento · 39 resolvido(s) -**Últimas decisões**: "adapter hermes architecture" (17/06/2026), "write sync single source of truth" (16/06/2026), "harness composition model" (15/06/2026), "ux redesign ai memory hub" (14/06/2026) - - - -**Estágio**: Code -**Item atual**: ITEM-43 — Implementar adapter Hermes Agent (spec: adapter-hermes) -**ACs**: 5/5 pendentes | 0 feito(s) -**Alertas**: 8 novo(s) · 4 em acompanhamento · 32 resolvido(s) -**Últimas decisões**: "adapter hermes architecture" (17/06/2026), "write sync single source of truth" (16/06/2026), "harness composition model" (15/06/2026), "ux redesign ai memory hub" (14/06/2026) - - - -**Estágio**: Code -**Item atual**: ITEM-43 — Implementar adapter Hermes Agent (spec: adapter-hermes) -**ACs**: 5/5 pendentes | 0 feito(s) -**Alertas**: 1 novo(s) · 4 em acompanhamento · 39 resolvido(s) -**Últimas decisões**: "adapter hermes architecture" (17/06/2026), "write sync single source of truth" (16/06/2026), "harness composition model" (15/06/2026), "ux redesign ai memory hub" (14/06/2026) - - - -**Estágio**: Code -**Item atual**: ITEM-43 — Implementar adapter Hermes Agent (spec: adapter-hermes) -**ACs**: 5/5 pendentes | 0 feito(s) -**Alertas**: 1 novo(s) · 4 em acompanhamento · 39 resolvido(s) -**Últimas decisões**: "adapter hermes architecture" (17/06/2026), "write sync single source of truth" (16/06/2026), "harness composition model" (15/06/2026), "ux redesign ai memory hub" (14/06/2026) - - - -**Estágio**: Code -**Item atual**: ITEM-43 — Implementar adapter Hermes Agent (spec: adapter-hermes) -**ACs**: 5/5 pendentes | 0 feito(s) -**Alertas**: 1 novo(s) · 4 em acompanhamento · 39 resolvido(s) -**Últimas decisões**: "adapter hermes architecture" (17/06/2026), "write sync single source of truth" (16/06/2026), "harness composition model" (15/06/2026), "ux redesign ai memory hub" (14/06/2026) - - - -**Estágio**: Review -**Item atual**: ITEM-44 — Melhorias no harness e loop de execução — sincronia, testes, disciplina, animação real (spec: harness-loop-realtime) -**ACs**: 5/5 pendentes | 0 feito(s) -**Alertas**: 5 em acompanhamento · 40 resolvido(s) -**Últimas decisões**: "adapter hermes architecture" (17/06/2026), "write sync single source of truth" (16/06/2026), "harness composition model" (15/06/2026), "ux redesign ai memory hub" (14/06/2026) - - -## Stack - -- **Monorepo**: npm workspaces (`packages/cli`, `packages/client`, `packages/types`) -- **CLI**: Commander, tsup (build), Vitest (testes) -- **Web UI**: React 19, Vite 6, Tailwind v4 + `@tailwindcss/vite`, shadcn/ui-inspired componentes -- **Linting**: Biome -- **Runtime**: Node 22+, ESM (`"type": "module"`) -- **Zero dependências runtime externas** — `fetch()` nativo Node 22+ - -## Restrições Reais - -- Specs devem ser thin (máx 1 página por feature) -- Sem lock-in de IDE — o formato `.letra/` é Markdown puro -- Drift detection deve funcionar para qualquer domínio (não só código) -- Pipeline CI/CD deve falhar se spec não for cumprida -- Web UI pré-compilado no build do pacote — usuário final só precisa de Node - -## Porquês - -- Escolhemos TypeScript porque 82% dos novos pacotes npm são TS em 2026 -- Escolhemos Markdown checklist porque não-devs precisam ler e escrever specs -- Escolhemos adapter OpenCode primeiro para dogfooding imediato -- Escolhemos organização GitHub dedicada para identidade de produto -- Escolhemos OKLCH sobre HSL para percepção consistente entre matizes -- Escolhemos SPA React + Vite para web UI acessível a não-devs diff --git a/.letra/decisions/adapter-hermes-architecture.md b/.letra/decisions/adapter-hermes-architecture.md deleted file mode 100644 index 20d8c72..0000000 --- a/.letra/decisions/adapter-hermes-architecture.md +++ /dev/null @@ -1,116 +0,0 @@ -# Adapter Hermes Agent — Architecture Decision - -**Date**: 2026-06-17 -**Status**: accepted - -## Context - -O **Hermes Agent** (desktop GUI app da Nous Research) precisa consumir o contexto do Letra (workflow, item ativo, ACs, alertas, comandos) para enriquecer o system prompt do LLM. Hoje o Letra suporta 6 adapters: `cursor`, `claude-code`, `windsurf`, `vscode` (Copilot), `opencode` (AGENTS.md), todos regenerados automaticamente a cada `flow move`, `focus` ou `init`. - -**Problema**: Usuários do Hermes não têm adapter dedicado. O workaround seria usar `AGENTS.md` (formato genérico), mas: -1. Colisão se o projeto já usa `AGENTS.md` para outro agente (ex: OpenCode) -2. O Hermes pode evoluir para formato próprio (`.hermes/instructions.md`) -3. Viola o princípio *domain-agnostic* — cada tool tem seu caminho dedicado - -**Restrições do projeto (constitution.md)**: -- Adapter layer desde o dia 1 — nunca travar em uma IDE -- Formato `.letra/` é a fonte da verdade -- CLI extensível via plugins -- **Separação de domínios**: `validation/` e `diagnostics/` não se importam mutuamente -- **Thin wrappers**: Commands CLI ≤100 linhas, orquestram shared modules -- **Funções puras**: Shared modules exportam funções puras, sem estado global -- **Template-driven**: Formatters geram strings; builders preparam dados - -## Decision - -**Adotar a arquitetura de "uma linha + arquivo dedicado"**: - -1. **Adicionar `hermes` em `TOOL_TARGETS`** (`packages/cli/src/adapters/generate.ts:14-24`): - ```typescript - hermes: { path: ".hermes/instructions.md", format: "text", displayName: "Hermes Agent" } - ``` - -2. **Formato `text`** (igual a `AGENTS.md`, `CLAUDE.md`) — o Hermes já entende esse formato - -3. **Caminho dedicado**: `.hermes/instructions.md` — evita colisão com outros agentes - -4. **Zero mudanças em `builder.ts`, `formatters.ts`, `generate.ts`** — reutilização 100%: - - `buildHarnessSnapshot()` já constrói snapshot completo (workflow, items, alerts, focus) - - `formatAdapterContent()` já formata L1, L5, Kickoff, Commands, Completion, Rules - - `generateAdapters()` já itera `TOOL_TARGETS` e escreve arquivos - -5. **Hook automático**: `flow-move.ts:84` chama `writeWorkflow` com `workflow.tools` — se `"hermes"` estiver no array, gera automaticamente - -6. **Workflow tools array**: `flow init --quick` pergunta "Quais agentes?" e salva no `workflow.json` - -7. **Test Strategy** (regra do projeto): - - **Unit tests**: `hermes.test.ts` — Vitest, cobertura exemplar - - **Property-based tests**: `hermes.property.test.ts` — `fast-check` para invariantes - - **Mutation testing**: Stryker com **threshold ≥80% mutation score** (CI gate) - - **Integration test**: `flow-move.integration.test.ts` — temp dir, `flow move` → arquivo gerado válido - -## Consequences - -### Positivo -- **Zero breaking changes** — nenhum módulo existente modificado além de 1 linha em `TOOL_TARGETS` -- **Reutilização máxima** — 100% do builder/formatters/generate reaproveitados -- **Consistência** — mesmo formato, mesmas seções, mesmos comandos dos outros 6 adapters -- **Isolamento** — `.hermes/instructions.md` não colide com `AGENTS.md` ou `CLAUDE.md` -- **Qualidade forçada** — mutation testing + property-based testing como gate (regra adotada no projeto) -- **Domain-agnostic** — funciona em projetos C#, Python, Go, Rust, sem Node (só escreve arquivo) - -### Negativo -- **Mais um target** para manter — mas surface area mínima (1 entrada em objeto) -- **Hermes pode mudar formato** — mitigado: adapter isolado, só toca `hermes.ts` + tests -- **Mutation testing adiciona tempo no CI** — aceitável: qualidade > velocidade para adapters críticos - -### Riscos Mitigados -| Risco | Mitigação | -|-------|-----------| -| Hermes muda formato | Adapter isolado; só `hermes.ts` + tests mudam | -| Colisão com `AGENTS.md` | Caminho dedicado `.hermes/instructions.md` | -| Mutation score baixo | Property tests cobrem invariantes; threshold 80% força qualidade | -| Projeto sem Node | Adapter só escreve arquivo — zero runtime dependency | -| `flow-claim` hardcoda `opencode` | Spec `npm-agnostic` já nota; adapter Hermes não usa `claimedBy` | - -## Alternativas Consideradas - -### 1. Reusar `AGENTS.md` (formato genérico) -**Rejeitado**: Colisão real se projeto usa OpenCode + Hermes simultaneamente. `AGENTS.md` é "owned" pelo OpenCode no ecossistema Letra. - -### 2. Formato `@` style (como `.cursorrules`) -**Rejeitado**: Hermes consome `AGENTS.md`/`CLAUDE.md` (text format). Formato `@` é para Cursor/Windsurf. - -### 3. Adapter separado (novo módulo `adapters/hermes/` com builder/formatters próprios) -**Rejeitado**: Viola DRY e `constitution.md` (shared modules, template-driven). Duplicaria 300+ linhas de lógica idêntica. - -### 4. Plugin externo (fora do monorepo) -**Rejeitado**: Adapters são core do Letra; usuários esperam `letra flow move` regenerar *todos* adapters configurados sem setup extra. - -## Implementation Notes - -**Arquivos a criar/modificar**: -| Arquivo | Tipo | Linhas | -|---------|------|--------| -| `packages/cli/src/adapters/hermes.ts` | Novo (entry point, reexporta + target) | ~15 | -| `packages/cli/src/adapters/hermes.test.ts` | Novo (unit) | ~80 | -| `packages/cli/src/adapters/hermes.property.test.ts` | Novo (property) | ~60 | -| `packages/cli/src/commands/flow-move.integration.test.ts` | Novo (integration) | ~50 | -| `packages/cli/src/adapters/generate.ts` | Modificar (+1 linha em TOOL_TARGETS) | +1 | -| `packages/cli/src/commands/flow-init.ts` | Modificar (+1 opção no prompt) | +1 | -| `AGENTS.md` (raiz) | Modificar (documentar hermes) | +1 | - -**Mutation Testing Setup** (já no projeto via `npm-agnostic` spec): -- `@stryker-mutator/core`, `@stryker-mutator/vitest-runner` -- `npm run test:mutants` roda Stryker -- CI falha se mutation score < 80% - -**Property-Based Testing**: -- `fast-check` já em deps (usado em outros detectors) -- Invariantes: snapshot consistency, header por source, alertas filtering, primaryItemId ∈ items - -## Links - -- Spec: `.letra/specs/adapter-hermes/spec.md` -- Workflow item: `ITEM-43` -- Adapters existentes: `packages/cli/src/adapters/generate.ts` (TOOL_TARGETS) \ No newline at end of file diff --git a/.letra/decisions/design-system-shadcn-dark-light.md b/.letra/decisions/design-system-shadcn-dark-light.md deleted file mode 100644 index 42f828b..0000000 --- a/.letra/decisions/design-system-shadcn-dark-light.md +++ /dev/null @@ -1,83 +0,0 @@ -# ADR: Design System — shadcn/ui + Dark/Light mode - -**Data:** 2026-06-08 -**Contexto:** ITEM-14 — SPA React, ITEM-13 — Flow Designer -**Status:** Aceita - -## Decisão - -Adotar **shadcn/ui** como base do design system do Flow UI, com suporte nativo a **dark e light mode**. - -### Detalhes - -- **shadcn/ui** não é uma dependência — é uma coleção de componentes copiáveis via CLI (`npx shadcn@latest add`). Isso significa: - - Zero runtime dependencies além do React - - Código 100% nosso pra customizar - - Tema via CSS variables (dark/light nativo) - - Acessibilidade (Radix UI under the hood) -- **Modo escuro**: toggle no header (🌙/☀️) + detecta `prefers-color-scheme` no load -- **Persistência**: salva escolha no `localStorage` -- **Paleta**: `slate` como cor base (padrão shadcn), adaptada pra Letra (blue accent) - -### Árvore de componentes importados do shadcn - -``` -button → botões primário/secundário/ghost -card → cards do dashboard/kanban -badge → badges de stage -select → dropdown de stage/move -dialog → modal de spec/tasks/config -sheet → side panel -dropdown-menu→ menu de ações -input → formulários -label → labels -separator → separadores -toggle → toggle dashboard/kanban -``` - -### Estrutura de tema - -```css -:root { - --background: 0 0% 100%; /* light */ - --foreground: 222.2 84% 4.9%; - --primary: 221.2 83.2% 53.3%; /* blue accent */ - /* ... demais tokens shadcn padrão */ -} - -.dark { - --background: 222.2 84% 4.9%; /* dark */ - --foreground: 210 40% 98%; - --primary: 217.2 91.2% 59.8%; -} -``` - -## Alternativas consideradas - -| Alternativa | Motivo da rejeição | -|---|---| -| Material UI (MUI) | Bundle pesado, runtime theme provider, difícil customizar | -| Ant Design | Estilo corporativo forte, difícil desviar do visual chinês | -| Chakra UI | Dependência runtime, ecossistema menor que shadcn | -| Tailwind UI | Pago ($299), sem acessibilidade embutida | -| CSS puro + Tailwind | Zero atalho — tudo na mão, sem patterns consistentes | - -## Consequências - -**Positivas:** -- Tema dark/light grátis -- Componentes acessíveis (Radix UI) -- Código fonte próprio (customizável) -- Bundle enxuto (só o que importamos) -- Tailwind + CSS variables = tema consistente - -**Negativas:** -- shadcn/ui muda com frequência (breaking changes menores) -- Precisa `npx shadcn` pra adicionar cada componente novo -- Time precisa conhecer Tailwind + Radix patterns - -## Próximos passos - -- ITEM-14: Setup do monorepo + Vite + Tailwind + shadcn init -- ITEM-21: Implementar theme toggle (dark/light) + persistência -- ITEM-22: Migrar UI atual para componentes shadcn diff --git a/.letra/decisions/flow-mvp-escopo-enxuto-3-comandos-valor-imediato.md b/.letra/decisions/flow-mvp-escopo-enxuto-3-comandos-valor-imediato.md deleted file mode 100644 index 184a34e..0000000 --- a/.letra/decisions/flow-mvp-escopo-enxuto-3-comandos-valor-imediato.md +++ /dev/null @@ -1,35 +0,0 @@ -# Flow MVP — escopo enxuto, 3 comandos, valor imediato - -**Date**: 2026-06-06 -**Status**: accepted - -## Context - -O Letra v0.1.14 documenta intenção via specs mas não ajuda o usuário a definir e executar seu processo de trabalho. Discussão de discovery em 2026-06-06 identificou a necessidade de um sistema de workflow. A proposta inicial era ambiciosa: automações, skills engine, web UI, regras complexas. Análise crítica revelou que isso é overengineering para v0.2. - -## Decision - -Adotar escopo mínimo para Flow MVP com 3 comandos que geram valor imediato: - -1. **`letra flow init --quick`** — wizard com 3 perguntas, gera workflow.json versionado -2. **`letra flow board`** — tabela no terminal com itens por estágio -3. **`letra flow move --to `** — move item, regenera adapters automaticamente - -Excluído do MVP (adiado para v0.3+): -- Automações complexas (webhooks, triggers condicionais) — n8n/Make resolvem -- Skills engine como conceito de primeira classe — descrição do agente basta -- Web UI (`letra flow ui`) — terminal + Mermaid cobre casos de uso iniciais -- Import de issues externas — manual via `backlog add` por enquanto - -## Consequences - -**Positivo:** -- Primeira entrega em dias, não semanas -- Cada comando vale por si só (valor fracionado) -- Menos risco de construir o que ninguém quer -- Fácil de pivotar baseado em feedback real - -**Negativo:** -- Usuários avançados podem sentir falta de automações -- Modelo de dados inicial pode precisar de migration no futuro -- Sem web UI, adoção por não-devs é mais limitada diff --git a/.letra/decisions/flow-setup-jornada-templates.md b/.letra/decisions/flow-setup-jornada-templates.md deleted file mode 100644 index 1a27f77..0000000 --- a/.letra/decisions/flow-setup-jornada-templates.md +++ /dev/null @@ -1,88 +0,0 @@ -# ADR: Jornada de setup do Flow com templates - -**Data:** 2026-06-08 -**Contexto:** ITEM-13 — Flow Designer, ITEM-14 — SPA React -**Status:** Aceita - -## Problema - -O setup inicial do Flow (`letra flow init --quick`) gera um workflow.json genérico sem orientação ao usuário. Não-devs (persona crítica) não sabem o que é um stage, uma zona, ou como configurar o fluxo ideal pro seu time. Precisamos de uma jornada guiada que eduque e configure em segundos. - -## Decisão - -Adotar **jornada de templates** com 3 opções pré-definidas + 1 opção de personalização completa. - -### Fluxo - -``` -letra init → workflow.json padrão → SPA abre na tela de Boas-Vindas - ↓ - ┌─────────────────────────────┐ - │ Escolha um template: │ - │ │ - │ [Padrão] [Kanban] [Ágil] │ - │ [Personalizar do zero] │ - └─────────────────────────────┘ - ↓ - ┌────────────────────────┼────────────────────┐ - ↓ ↓ ↓ - Template escolhido Personalizar Personalizar - ↓ ↓ ↓ - Salva workflow.json Step 1: Stages Step 2: Zonas - ↓ ↓ ↓ - Dashboard pronto Step 3: Review Step 3: Review - ↓ ↓ - Salva workflow.json Salva workflow.json - ↓ ↓ - Dashboard pronto Dashboard pronto -``` - -### Templates - -| Template | Stages | Quando usar | -|---|---|---| -| **Padrão** | backlog → design → code → review → tests → done | Time dev tradicional | -| **Kanban** | todo → doing → done | Time simples, não-dev | -| **Ágil** | backlog → sprint → review → done | Time com sprints | -| **Personalizar** | (passo a passo) | Nenhum template encaixa | - -### Tela de Personalizar (3 steps) - -1. **Stages** — editar nome, reordenar, adicionar/remover -2. **Zonas** — cada stage mapeado pra A fazer / Em andamento / Feito (dropdown) -3. **Review** — resumo visual + botão Finalizar - -### Regras de UX - -- Se escolher template → já cai no dashboard com dados -- Se personalizar → pode voltar steps, preview visível sempre -- Após finalizar setup → dashboard abre com call-to-action "Criar primeiro item" -- Configuração fica acessível depois via ⚙ no header (não é one-shot) - -## Alternativas consideradas - -| Alternativa | Motivo da rejeição | -|---|---| -| Questionário CLI (perguntas no terminal) | UX pobre para não-devs, sem preview visual | -| Config via JSON manual (editar workflow.json) | Persona não-dev não edita JSON | -| Setup one-shot sem templates | Muito atrito pro usuário comum | -| Apenas templates sem personalizar | Usuário com fluxo específico fica sem opção | - -## Consequências - -**Positivas:** -- Não-devs configuram sem ajuda técnica -- Templates educam sobre o conceito de stages/zones -- Personalização cobre casos complexos -- Preview visual reduz erros de configuração - -**Negativas:** -- 4 fluxos pra implementar e testar -- Manter templates sync com features novas - -## Próximos passos - -- ITEM-14: SPA React — setup do monorepo + servir assets -- ITEM-18: Implementar tela de Boas-Vindas + templates -- ITEM-19: Implementar wizard de personalização (3 steps) -- ITEM-20: Integrar setup com `letra init` (redirecionar pra web) diff --git a/.letra/decisions/harness-composition-model.md b/.letra/decisions/harness-composition-model.md deleted file mode 100644 index 9561569..0000000 --- a/.letra/decisions/harness-composition-model.md +++ /dev/null @@ -1,104 +0,0 @@ -# Harness Composition Model — Compor, nunca substituir - -**Date**: 2026-06-14 -**Status**: accepted - -## Context - -Auditoria de 2026-06-14 (`.letra/docs/harness-layer-audit.md`) confirmou regressão crítica: `letra flow move` substitui o conteúdo gerado por `letra init`, removendo referências a `context.md`, `constitution.md`, `glossary.md` e `focus.md`. O adapter passa de útil a inútil conforme o time usa o workflow — exatamente o oposto da proposta do produto. - -As specs `adapter-*` e `session-focus` assumem referências persistentes. O `flow-mvp` ADR declarou "regenera adapters automaticamente" no `flow move`, mas não especificou *como* compor o conteúdo. A implementação atual trata regeneração como substituição total. - -Agentes de código (Cursor, Claude Code, Codex, OpenCode) dependem de arquivos na raiz do projeto como ponte para `.letra/`. Sem essa ponte, o formato `.letra/` existe mas não chega ao prompt. - -## Decision - -Adotar o **Harness Composition Model**: o adapter é uma **view compilada** em camadas, regenerada atomicamente por um único módulo (`packages/cli/src/adapters/`). - -### Camadas do adapter (ordem fixa) - -``` -┌──────────────────────────────────────────────────────┐ -│ ADAPTER — view compilada, máx ~60 linhas │ -├──────────────────────────────────────────────────────┤ -│ L1 — Referências always-on │ -│ context, constitution, glossary, focus │ -│ (sintaxe por ferramenta: @ ou path list) │ -├──────────────────────────────────────────────────────┤ -│ L2 — Workflow snapshot │ -│ estágio ativo, itens no estágio, spec link │ -├──────────────────────────────────────────────────────┤ -│ L3 — Work signals (computados, inline) │ -│ ACs pendentes/total, tasks abertas, item primário│ -├──────────────────────────────────────────────────────┤ -│ L4 — Regras do agente (estáticas, constitution) │ -│ ler spec, validate, flow move ao concluir │ -└──────────────────────────────────────────────────────┘ -``` - -### Regras invioláveis - -| Regra | Motivo | -|-------|--------| -| Nunca colar conteúdo de spec no adapter | Thin spec; `.letra/` é fonte da verdade | -| Nunca remover L1 em regeneração | Composição, não substituição | -| L3 só com dados computados de `.letra/` | Sem duplicação manual | -| Um builder, N formatters | Agnóstico: mesmo modelo, sintaxe por tool | -| Regenerar em: `init`, `flow move`, `focus` | Todo evento que muda contexto de trabalho | - -### Sync de `focus.md` - -- `flow move` atualiza `focus.md` automaticamente para o item movido (se tiver `spec` vinculada). -- `letra focus ` continua como override manual explícito. -- `letra focus --clear` remove foco; adapter L1 referencia condicionalmente. - -### `context.md` § Estado Atual - -- **Deprecar** atualização manual de "Estado Atual" em `context.md`. -- Estado dinâmico vive em `workflow.json` + adapter L2. -- `context.md` retém apenas intent, domínio, stack, restrições e porquês (estáveis). - -### Eventos de regeneração - -| Comando | Regenera adapters | Atualiza focus.md | -|---------|-------------------|-------------------| -| `letra init` | ✅ | ❌ | -| `letra flow move` | ✅ | ✅ (item movido) | -| `letra focus ` | ✅ | ✅ (manual) | -| `letra focus --clear` | ✅ | ✅ (remove) | - -## Consequences - -**Positivo:** - -- Harness melhora com uso do workflow, não piora. -- Agentes recebem ponte estável para `.letra/` + sinais de trabalho. -- Código de adapter deixa de estar duplicado em `init.ts` e `flow-move.ts`. -- Modelo extensível para novos adapters sem reescrever lógica. - -**Negativo:** - -- Adapter fica ~2× maior (~40-60 linhas vs ~17), ainda dentro do limite thin. -- `flow move` ganha side-effect em `focus.md` — pode surpreender quem usava focus manual para outra spec. -- Specs `adapter-*` precisam atualizar exclusion "sem sync automático". - -**Mitigação do side-effect focus:** - -- `letra focus ` após `flow move` sobrescreve foco automático (override explícito vence). -- Documentar em `session-focus` spec. - -## Alternativas rejeitadas - -| Alternativa | Por que rejeitada | -|-------------|-------------------| -| Inlinar spec completa no adapter | Viola thin spec; explode tokens | -| Não regenerar no flow move | Mantém regressão; workflow e harness dessincronizam | -| Só atualizar focus, não adapter | Agentes que não leem focus continuam sem contexto | -| Webhook/SSE para sync em tempo real | Complexidade prematura; regeneração síncrona basta | - -## Referências - -- `.letra/docs/harness-layer-audit.md` -- `.letra/specs/harness-layer/spec.md` -- `.letra/specs/session-focus/spec.md` -- `.letra/specs/adapter-cursor/spec.md` diff --git a/.letra/decisions/product-reflection-letra-product-market-fit.md b/.letra/decisions/product-reflection-letra-product-market-fit.md deleted file mode 100644 index 0f5c329..0000000 --- a/.letra/decisions/product-reflection-letra-product-market-fit.md +++ /dev/null @@ -1,81 +0,0 @@ -# Product reflection: Letra product-market fit - -**Date**: 2026-06-08 -**Status**: draft - -## Context - -Letra começou como um framework de Specification-Driven Development (SDD) — um formato `.letra/` para capturar direção, intenção e contexto. Evoluiu para incluir CLI de fluxo de trabalho (backlog, stages, kanban, tasks) e agora estamos considerando um Flow Designer visual. - -Antes de continuar evoluindo, precisamos responder perguntas fundamentais: - -1. **Quem é o usuário?** Não-devs? Devs? Times? Empresas? -2. **Qual a dor real?** Falta de rastreabilidade entre spec e código? Dificuldade de onboarding de agentes? Ausência de ferramentas leves de workflow? -3. **Letra resolve um problema real ou cria um?** Estamos adicionando complexidade que o usuário não pediu? -4. **Qual o mínimo que valida a tese?** Qual o menor produto que alguém pagaria (tempo/dinheiro) para usar? -5. **Concorrência indireta:** ADRs, Notion, Linear, Jira, GitHub Projects — cada um resolve um pedaço. Onde Letra é único? -6. **Risco de escopo infinito:** Workflow, kanban, designer visual, skills engine, marketplace — onde paramos? - -## Signal from the field (Jun 2026) - -Observação direta de um time multinacional usando Letra em cenário real: - -**O problema observado:** -- Times têm **múltiplos harnesses de IA** (Cursor, Kiro, Codex, OpenCode) — cada dev usa o seu -- Cada dev tem **seu próprio fluxo de desenvolvimento** — A codifica e testa, B especifica e codifica, sem padrão de time -- **Vibe coding sem processo** é comum, especialmente entre não-devs usando ferramentas de IA -- Quem tenta seguir processo **não consegue acompanhar o estado real das atividades** -- **Retrabalho massivo e gasto desnecessário de tokens LLM** — harness mal configurado ou sem insumo suficiente de contexto - -**O gargalo real:** -- Mesmo com chat, as pessoas **não sabem fazer as perguntas certas** para acompanhar o estado do trabalho -- Também **não têm visão geral** do progresso -- O maior problema de entrega é **juntar trabalhos individuais** (cada um com seu processo/ferramenta) em **uma entrega coesa de time** -- A UX visual não é "enfeite" — é o que **mostra o que queremos ver mas não sabemos perguntar**, ou não queremos seguir um fluxo padronizado e organizado - -**Tese validada:** -Letra resolve um problema real **se** atacar: -- **Padronização sem rigidez** — cada dev mantém sua ferramenta, mas o processo/contexto é compartilhado via `.letra/` -- **Visão de estado visual** — kanban/flow como camada de entendimento compartilhado, não só de organização -- **Redução de retrabalho e tokens** — contexto estruturado reduz LLM waste -- **Unificação de times multi-ferramenta** — o `.letra/` como fonte única de verdade independente do harness - -## Hypothesis - -Letra é valioso **se**: -- Times de produto/engenharia gastam tempo mantendo docs que ninguém lê -- Agentes de código precisam de contexto estruturado que não está no repositório -- O custo de setup de workflow (Jira/Linear) é proibitivo para projetos pequenos/médios -- **(validado)** Times multi-ferramenta (Cursor, Kiro, Codex, OpenCode) não têm padrão de processo e sofrem com retrabalho e tokens desperdiçados - -## Anti-hypothesis (por que pode não valer a pena) - -- "Só mais um formato" — times já têm muitos formatos (markdown, ADRs, README) -- "Ferramenta para um problema que não existe" — devs não sentem falta de spec SDD -- "Over-engineered" — o que começa simples vira complexo rápido (como estamos vendo) -- "Ninguém quer mais uma CLI" — o mercado está saturado -- **(risco real)** UX visual é crítica e fazer bem é caro — meia-entrega não resolve - -## Insights for direction - -1. **Letra não compete com Jira/Linear** — compete com "não ter processo nenhum" -2. **O `.letra/` é o barramento** — o harness enxerga o contexto independente de quem/what gerou -3. **Visual-first para consumo, CLI-first para automação** — devs usam CLI, não-devs/noobs usam UI, ambos compartilham o mesmo estado -4. **O maior valor imediato pode ser "mostrar o estado do time"** mais do que "definir o processo ideal" -5. **Métrica principal: redução de retrabalho/tokens desperdiçados** — não quantidade de specs criadas - -## Questions to resolve - -- [ ] Validar com 3-5 potenciais usuários reais (fora de nós mesmos) -- [ ] Definir qual o *job-to-be-done* primário -- [ ] Decidir: CLI pura, CLI + visual, ou visual-first? -- [ ] Qual o estágio ideal do produto hoje (v0.3.0) — feature complete ou focused? -- [ ] Devemos considerar pivot ou kill antes de evoluir? -- [ ] **(novo)** UX visual do flow é feature ou é o produto? Se for o produto, precisamos de designer -- [ ] **(novo)** Devs aceitariam adotar `.letra/` se o benefício imediato for "menos retrabalho e tokens"? - -## Next steps - -1. Escrever mini product brief com JTBD primário -2. Identificar 3-5 pessoas para entrevista de discovery -3. Decidir entre Keep / Pivot / Kill antes do v0.4.0 diff --git a/.letra/decisions/spa-react-implementation-details.md b/.letra/decisions/spa-react-implementation-details.md deleted file mode 100644 index bf21bdd..0000000 --- a/.letra/decisions/spa-react-implementation-details.md +++ /dev/null @@ -1,57 +0,0 @@ -# ADR: SPA React — Implementation Details - -**Data:** 2026-06-08 -**Contexto:** ITEM-14 — SPA React setup -**Status:** Aceita - -## Decisões - -### 1. API REST — convenções - -| Método | Rota | Ação | Status | -|---|---|---|---| -| `GET` | `/api/workflow` | Obter workflow completo | 200 | -| `PUT` | `/api/workflow` | Substituir workflow (config) | 200 | -| `GET` | `/api/items` | Listar itens | 200 | -| `GET` | `/api/items/:id` | Obter um item | 200 | -| `POST` | `/api/items` | Criar item | 201 | -| `PATCH` | `/api/items/:id` | Atualizar parcial (stage, desc, tasks) | 200 | -| `DELETE` | `/api/items/:id` | Remover item | 204 | -| `GET` | `/api/specs` | Listar specs resolvidas | 200 | -| `GET` | `/api/specs/:id` | Obter spec por ID | 200 | -| `GET` | `/api/events` | SSE (workflow atualizado) | — | - -- Nomes **plurais** sempre (`/items`, não `/item`) -- Sucesso: `{ data: ... }` | Erro: `{ error: string }` -- SSE mantido para reload automático multi-cliente - -### 2. Tipos compartilhados - -Pacote `packages/types/` com interfaces TS puras. CLI e Client importam via `@letra/types`. Sem runtime — só `export interface`. - -### 3. Build em 2 estágios - -``` -client: vite build → dist/client/ (HTML + JS + CSS) -cli: tsup → dist/index.js + copia dist/client/ para dist/client/ -``` - -No dev, CLI faz proxy pro Vite dev server (`localhost:5173`) para HMR. - -### 4. Testes - -- **Vitest** para hooks, API clients, lógica — roda em `npm test` (rápido) -- **Playwright** para testes de regressão visual e fluxos — roda no CI apenas - -## Alternativas consideradas - -- API com nomes singulares (`/item`) → rejeitado: convenção REST é plural -- Tipos duplicados entre CLI e Client → rejeitado: shared package evita drift -- Testes só Vitest → rejeitado: fluxos críticos merecem Playwright -- Build manual sem cópia automatizada → rejeitado: `tsup` plugin resolve - -## Consequências - -- Deploy continua sendo `npm publish @letra/cli` — tudo num pacote -- CI precisa buildar client antes de CLI -- Playwright no CI aumenta tempo de pipeline diff --git a/.letra/decisions/spa-react-vite-flow-ui.md b/.letra/decisions/spa-react-vite-flow-ui.md deleted file mode 100644 index afa9fe9..0000000 --- a/.letra/decisions/spa-react-vite-flow-ui.md +++ /dev/null @@ -1,54 +0,0 @@ -# ADR: SPA React + Vite para Flow UI - -**Data:** 2026-06-08 -**Contexto:** ITEM-13 — Flow Designer -**Status:** Aceita - -## Problema - -O frontend do Flow UI (`letra flow serve`) é gerado por uma template literal de 500+ linhas em `flow-serve.ts`. Cada nova funcionalidade (config de dashboard, kanban, CRUD de itens) torna a manutenção exponencialmente mais difícil. Precisamos de uma arquitetura frontend que escale com o produto. - -## Decisão - -Adotar **SPA React** com **Vite** como toolchain de build. - -### Detalhes - -- O SPA é **pré-compilado** no build do pacote (Vite → `dist/client/`) -- O usuário final **não instala React** — só Node.js -- O CLI (`letra flow serve`) serve assets estáticos + API REST no mesmo server -- Estrutura de monorepo leve: `packages/cli/` + `packages/client/` -- API continua REST (não GraphQL) — simplicidade sobre over-engineering -- Framework: React puro + `zustand` se estado global for necessário - -### Alternativas consideradas - -| Alternativa | Motivo da rejeição | -|---|---| -| HTMX + partials server-rendered | Não escala para UI rica (drag, kanban interativo, modais complexos) | -| HTML template literal | Já no limite — qualquer feature nova é luta | -| PWA | Complexidade extra sem necessidade imediata — SPA resolve agora | -| GraphQL | Overkill para ~5 entidades e 1 cliente | - -## Consequências - -**Positivas:** -- Componentização real (Dashboard, Kanban, SidePanel, StageConfig, etc.) -- Manutenção independente entre CLI e UI -- Ferramentas maduras para testes de UI -- Build otimizado (code splitting, lazy loading) - -**Negativas:** -- Aumento de devDependencies no monorepo (React, Vite, etc.) -- Pipeline de build em dois estágios (CLI + Client) -- Curva de aprendizado para contribuidores não-familiarizados com React - -**Neutras:** -- Separação em pacotes exige coordenação de versões entre CLI e Client - -## Próximos passos - -- ITEM-14: SPA React — setup do monorepo (packages/cli + packages/client) -- ITEM-15: Migrar endpoints REST existentes para o novo SPA -- ITEM-16: Configuração de dashboard via web (zones) -- ITEM-17: CRUD de itens no SPA diff --git a/.letra/decisions/ux-redesign-ai-memory-hub.md b/.letra/decisions/ux-redesign-ai-memory-hub.md deleted file mode 100644 index 41fa3af..0000000 --- a/.letra/decisions/ux-redesign-ai-memory-hub.md +++ /dev/null @@ -1,311 +0,0 @@ -# ADR: UX Redesign — Letra como AI Memory & Spec Hub - -**Data:** 2026-06-13 -**Contexto:** ITEM-13 — Flow Designer -**Status:** Proposta - -## Problema - -A web UI do Letra cresceu com 4 views concorrentes (Setup Wizard, Dashboard 3 zonas, Kanban, SidePanel) que disputam espaço sem hierarquia clara. O produto perdeu o foco: virou um kanban genérico em vez de reforçar seu diferencial — ser o hub de specs e contexto para agentes de IA. - -## Decisão - -Redesenhar o Letra Flow UI como **"AI Memory & Spec Hub"** — um painel que coloca specs e contexto do projeto em primeiro plano, com o fluxo de trabalho como view secundária. - -### Princípios de design - -| Princípio | Descrição | -|---|---| -| **Spec-first** | A home mostra saúde das specs, drift detection e foco atual — não colunas de kanban | -| **Flow como timeline** | Kanban vira toggle opcional; default é pipe horizontal simplificado | -| **3 abas máximas** | Home | Specs | Flow — cada aba com propósito único | -| **Detalhe inline** | Ao clicar em item, expande no lugar — sem side panel sobreposto | -| **Contexto acessível** | Aba dedicada pra ler context.md, constitution.md, decisões | -| **Setup pontual** | Wizard inline de 3 passos, não tela cheia | - -### Arquitetura de telas - -``` -┌──────────────────────────────────────────────────────────────┐ -│ Header: [logo] [projeto] [busca] [tema] │ -├──────────────────────────────────────────────────────────────┤ -│ Nav: [ Home ] [ Specs ] [ Flow ] [ Context ] │ -├──────────────────────────────────────────────────────────────┤ -│ │ -│ ┌────────────────────────────────────────────────────────┐ │ -│ │ Área de conteúdo (varia por aba) │ │ -│ │ │ │ -│ │ - Home: health check, specs recentes, flow resumo, │ │ -│ │ decisões recentes, métricas │ │ -│ │ - Specs: lista + CRUD de specs (criar, editar, │ │ -│ │ marcar ACs, validar) │ │ -│ │ - Flow: pipe visual (default) ou kanban (toggle), │ │ -│ │ detalhe inline ao clicar em item │ │ -│ │ - Context: visualizador markdown de context.md, │ │ -│ │ constitution.md, glossary.md, decisões │ │ -│ │ │ │ -│ └────────────────────────────────────────────────────────┘ │ -│ │ -└──────────────────────────────────────────────────────────────┘ -``` - -### Views detalhadas - -#### 1. Home — Painel de saúde do projeto - -``` -┌──────────────────────────────────────────────────────────────┐ -│ 🏠 HOME │ -│ │ -│ ┌──────────────┐ ┌──────────────┐ ┌────────────────────┐ │ -│ │ ✅ SPECS │ │ ⚠ DRIFT │ │ 🎯 FOCO ATUAL │ │ -│ │ 23 válidas │ │ 2 specs │ │ "Implementar auth" │ │ -│ │ 0 erros │ │ sem revisão │ │ ───────────────── │ │ -│ │ │ │ há 8 dias │ │ ITEM-3 · code │ │ -│ │ [ver todas] │ │ [revisar] │ │ │ │ -│ └──────────────┘ └──────────────┘ └────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ 📋 SPECS RECENTES │ │ -│ │ │ │ -│ │ auth-flow .............. ✅ 100% · atualizado ontem │ │ -│ │ api-endpoints .......... ✅ 100% · atualizado ontem │ │ -│ │ dark-mode ............. ⚠ 50% · desatualizado 8d │ │ -│ │ notifications ......... ❌ 0% · rascunho │ │ -│ │ │ │ -│ │ [+ nova spec] [ver todas →] │ │ -│ └─────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────┐ ┌─────────────┐ ┌──────────────────────┐ │ -│ │ 📌 FLUXO │ │ 🗂 DECISÕES │ │ 📊 MÉTRICAS │ │ -│ │ backlog → │ │ 13/jun: │ │ Tempo médio/estágio │ │ -│ │ design → │ │ Usar React │ │ backlog: 2.3d │ │ -│ │ code → │ │ 12/jun: │ │ design: 4.1d │ │ -│ │ review → │ │ Mobile 1st │ │ code: --- │ │ -│ │ done │ │ │ │ bottleneck: design │ │ -│ │ [ver fluxo] │ │ [ver mais] │ │ │ │ -│ └─────────────┘ └─────────────┘ └──────────────────────┘ │ -└──────────────────────────────────────────────────────────────┘ -``` - -#### 2. Specs — Gerenciamento de especificações - -``` -┌──────────────────────────────────────────────────────────────┐ -│ 📋 SPECS [+nova] │ -│ │ -│ [Buscar specs...________________] [Todas ✓] [⚠️] [❌] │ -│ │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ auth-flow ✅ 100% · 5 ACs · atualizado 1d │ │ -│ │ ├─ Implementar login OAuth │ │ -│ │ ├─ Fluxo de refresh token │ │ -│ │ └─ Proteção de rotas privadas │ │ -│ ├─────────────────────────────────────────────────────────┤ │ -│ │ api-endpoints ✅ 100% · 8 ACs · atualizado 2d │ │ -│ │ dark-mode ⚠ 50% · 4 ACs · desatualizado 8d │ │ -│ │ notifications ❌ 0% · 3 ACs · rascunho │ │ -│ └─────────────────────────────────────────────────────────┘ │ -│ │ -│ Ao clicar, expande detalhe inline: │ -│ │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ 📄 auth-flow [editar]│ -│ │ │ │ -│ │ Outcome: │ │ -│ │ Usuário consegue fazer login com Google/GitHub e │ │ -│ │ manter sessão ativa por 7 dias. │ │ -│ │ │ │ -│ │ Acceptance Criteria: │ │ -│ │ ☑ Login com Google retorna token JWT válido │ │ -│ │ ☑ Refresh token renova sem pedir senha │ │ -│ │ ☑ Rotas privadas redirecionam para /login │ │ -│ │ ☑ Logout limpa cookies e redireciona │ │ -│ │ ☑ Sessão expira após 7 dias de inatividade │ │ -│ │ │ │ -│ │ Tags: auth, security, frontend │ │ -│ └─────────────────────────────────────────────────────────┘ │ -└──────────────────────────────────────────────────────────────┘ -``` - -#### 3. Flow — Pipe visual (default) - -``` -┌──────────────────────────────────────────────────────────────┐ -│ 📌 FLOW [pipe] [kanban] │ -│ │ -│ backlog design code done │ -│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │ -│ │ ITEM │ │ │ │ ITEM │ │ ITEM │ │ -│ │ ● 3 │ │ │ │ ● 1 │ │ ● 2 │ │ -│ │ ● 7 │───────▶│ ❁ 4 │───────▶│ ● 5 │ │ ● 6 │ │ -│ │ ● 9 │ │ │ │ │ │ │ │ -│ └──────┘ └──────┘ └──────┘ └──────┘ │ -│ 3 items 1 item 2 items 2 items │ -│ │ -│ ● = normal ❁ = com foco (item selecionado) │ -│ │ -│ Ao clicar em item, expande detalhe inline: │ -│ │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ ❁ ITEM-4 · Design · "Criar protótipo UX" │ │ -│ │ │ │ -│ │ Tasks: [████░░░░] 2/5 │ │ -│ │ ● Pesquisar concorrentes (done) │ │ -│ │ ● Definir user personas (done) │ │ -│ │ ○ Esboçar wireframes ← você está aqui │ │ -│ │ ○ Validar com usuários │ │ -│ │ ○ Refinar com base no feedback │ │ -│ │ │ │ -│ │ Spec vinculada: auth-flow │ │ -│ │ Criado: 10/jun · 3d em design │ │ -│ │ │ │ -│ │ [mover → code] [← mover backlog] │ │ -│ └─────────────────────────────────────────────────────────┘ │ -└──────────────────────────────────────────────────────────────┘ -``` - -#### 4. Flow — Modo kanban (toggle) - -``` -┌──────────────────────────────────────────────────────────────┐ -│ 📌 FLOW [pipe] [kanban] │ -├──────────┬──────────┬──────────┬──────────┬─────────────────┤ -│ BACKLOG │ DESIGN │ CODE │ REVIEW │ DONE │ -│ │ │ │ │ │ -│ ITEM-3 │ ITEM-4 ◀ │ ITEM-1 │ │ ITEM-2 │ -│ ITEM-7 │ │ ITEM-5 │ │ ITEM-6 │ -│ ITEM-9 │ │ │ │ ITEM-8 │ -│ │ │ │ │ │ -├──────────┴──────────┴──────────┴──────────┴─────────────────┤ -│ │ -│ Ao clicar em um item, expande inline abaixo do kanban: │ -│ │ -│ ┌──────────────────────────────────────────────────────┐ │ -│ │ ❁ ITEM-4 · Design · "Criar protótipo UX" │ │ -│ │ ────────────────────────────────────────────────── │ │ -│ │ Tasks: [████░░░░] 2/5 · 3d em design │ │ -│ │ [mover → code] [← mover backlog] │ │ -│ └──────────────────────────────────────────────────────┘ │ -└──────────────────────────────────────────────────────────────┘ -``` - -#### 5. Context — Memória do projeto - -``` -┌──────────────────────────────────────────────────────────────┐ -│ 🗂 CONTEXT │ -│ │ -│ [context.md] [constitution.md] [glossary.md] [decisões] │ -│ │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ # Context │ │ -│ │ │ │ -│ │ > Updated: 2026-06-13 │ │ -│ │ > Owner: letra-dev │ │ -│ │ │ │ -│ │ ## Intent │ │ -│ │ │ │ -│ │ Letra é um framework de Specification-Driven │ │ -│ │ Development (SDD)... │ │ -│ │ │ │ -│ │ [expandir] │ │ -│ └─────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ 📜 DECISÕES RECENTES │ │ -│ │ │ │ -│ │ 13/jun — Adotar React em vez de Vue │ │ -│ │ Motivo: ecossistema maior, mais devs │ │ -│ │ │ │ -│ │ 12/jun — Priorizar mobile primeiro │ │ -│ │ Motivo: 70% dos usuários são mobile │ │ -│ │ │ │ -│ │ [ver todas as 12 decisões →] │ │ -│ └─────────────────────────────────────────────────────────┘ │ -└──────────────────────────────────────────────────────────────┘ -``` - -### Empty state (primeira execução) - -``` -┌──────────────────────────────────────────────────────────────┐ -│ │ -│ ◉ letra │ -│ │ -│ Bem-vindo ao Letra │ -│ Seu hub de specs e contexto para IA │ -│ │ -│ ┌──────────────────┐ ┌──────────────────┐ │ -│ │ 🚀 Começar │ │ 📋 Templates │ │ -│ │ Configuração │ │ Ver exemplos │ │ -│ │ guiada (3 passos)│ │ de projetos │ │ -│ └──────────────────┘ └──────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ O que você pode fazer: │ │ -│ │ │ │ -│ │ 📝 Escrever specs para suas features │ │ -│ │ 📌 Organizar o fluxo de trabalho │ │ -│ │ 🤖 Alimentar agentes de IA com contexto │ │ -│ │ 📊 Acompanhar métricas e drift │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ │ -└──────────────────────────────────────────────────────────────┘ -``` - -### Setup wizard inline (3 passos) - -``` -┌──────────────────────────────────────────────────────────────┐ -│ Configuração guiada Passo 1/3 │ -│ │ -│ ┌──────────────────────────────────────────────────────┐ │ -│ │ Qual o nome do seu projeto? │ │ -│ │ │ │ -│ │ ┌────────────────────────────────────────────────┐ │ │ -│ │ │ meu-projeto │ │ │ -│ │ └────────────────────────────────────────────────┘ │ │ -│ │ │ │ -│ │ [continuar →] │ │ -│ └──────────────────────────────────────────────────────┘ │ -│ │ -│ Passo 2/3 · Quais os estágios do seu fluxo? │ -│ Passo 3/3 · Quais ferramentas de IA você usa? │ -└──────────────────────────────────────────────────────────────┘ -``` - -## Alternativas consideradas - -| Alternativa | Motivo da rejeição | -|---|---| -| Kanban-first (atual) | Produto vira clone de Trello, perde identidade | -| Apenas CLI, sem UI | Exclui não-devs do público-alvo | -| Dashboard de métricas puro | Remove capacidade de interagir com specs/flow | - -## Consequências - -**Positivas:** -- Produto com identidade clara (specs + IA context, não kanban) -- Menos views = menos código = mais fácil manter -- Hierarquia clara do que é importante - -**Negativas:** -- Requer reescrita significativa do App.tsx e navegação -- Perde a view "Dashboard 3 zonas" atual -- Usuários acostumados com kanban tradicional podem estranhar - -## Próximos passos - -1. Implementar shell de navegação (abas Home | Specs | Flow | Context) -2. Migrar Home para health check -3. Migrar Specs como view principal -4. Simplificar Flow com toggle pipe/kanban -5. Adicionar aba Context -6. Remover Setup Wizard tela cheia (substituir por inline) - -## Referências - -- Decisão anterior: `design-system-shadcn-dark-light.md` -- Decisão anterior: `spa-react-vite-flow-ui.md` -- Design system: `.letra/specs/design-system/spec.md` diff --git a/.letra/decisions/write-sync-single-source-of-truth.md b/.letra/decisions/write-sync-single-source-of-truth.md deleted file mode 100644 index 46b24f8..0000000 --- a/.letra/decisions/write-sync-single-source-of-truth.md +++ /dev/null @@ -1,398 +0,0 @@ -# Write-Sync: Motor de Sincronização e Fonte Única de Verdade - -**Date**: 2026-06-16 -**Status**: proposed - -## Context - -O Letra sofre de **inconsistência crônica entre fontes de estado**. Auditando o workspace em 16/06/2026, cada arquivo contava uma história diferente: - -| Fonte | Afirmava | Real | -|---|---|---| -| `AGENTS.md` | ITEM-16 é o atual, estágio Backlog | ITEM-32 em Review, ITEM-16 não existe mais | -| `focus.md` | command-menu (ITEM-47) | ITEM-32 é o atual | -| `context.md` | "ITEM-33 (ruler header — revisão)" | ITEM-32 em Review | -| `pulse` (workflow.json) | ITEM-32 em Review, ITEM-22 no backlog | ✅ fonte da verdade | - -6 comandos escrevem `workflow.json` de forma descentralizada. Cada um decide se regenera adapters, se atualiza context.md, se loga. Nenhum reconcilia os outros. O `stage-drift` detector tem um `autoFix` que faz `writeFileSync` direto no `workflow.json` — bypassando completamente `saveWorkflow()`, `generateAdapters()`, e `sitrep`. - -O resultado: o agente (OpenCode, Cursor, etc.) recebe contexto inconsistente a cada sessão. A confiança no harness cai. O valor do produto — "contexto enriquecido para agentes" — degrada com o uso. - -Este ADR propõe um **motor de sincronização (`writeWorkflow`)** que unifica toda mutação de workflow em um único gateway com side-effects garantidos. - -## Architecture — C4 Diagrams - -### Nível 1: Contexto do Sistema (HOJE) - -```mermaid -C4Context -title System Context — Letra (Current) - -Person(dev, "Desenvolvedor", "Usa CLI e edita specs") -Person(agent, "Agente IA", "OpenCode, Cursor, Claude Code") - -System_Boundary(letra, "Letra SDD Framework") { - System(cli, "CLI", "Commander · Node 22+") - System(webui, "Web UI", "React 19 · Vite · Tailwind v4") -} - -System_Ext(fs, "File System", ".letra/ diretório + raiz do projeto") - -Rel(dev, cli, "letra flow move, letra focus, letra health...") -Rel(dev, fs, "Edita specs, context.md") -Rel(agent, fs, "Lê AGENTS.md, .cursorrules, .letra/") -Rel(cli, fs, "Lê e escreve workflow.json, adapters, context.md, health-record.json, snapshots, focus.md") -Rel(webui, fs, "Lê via CLI HTTP API (flow serve)") -UpdateLayoutConfig($c4ShapeInRow="3", $c4BoundaryInRow="1") -``` - -### Nível 2: Containers — HOJE - -```mermaid -C4Container -title Container Diagram — Current Architecture (problematic) - -Person(dev, "Desenvolvedor") - -System_Boundary(cli, "CLI (packages/cli)") { - Container(commands, "Commands", "TypeScript", "Commander-based. Orquestram lógica de negócio") - Container(adapters, "Adapters", "TypeScript", "Builder + Formatters + Generate") - Container(diagnostics, "Diagnostics", "TypeScript", "Engine + Detectors + SnapshotStore") - Container(health, "Health Record", "TypeScript", "Persistent alert store") - Container(log, "Session Log", "TypeScript", "session-log.ts") -} - -System_Ext(workflow_json, "workflow.json", "Fonte da verdade — items, stages, tools") -System_Ext(focus_md, "focus.md", "Foco da sessão manual") -System_Ext(context_md, "context.md", "Contexto (parcialmente auto-gerado via sitrep)") -System_Ext(adapters_fs, "Adapter Files", "AGENTS.md, .cursorrules, CLAUDE.md...") -System_Ext(health_json, "health-record.json", "Alertas persistentes") -System_Ext(snapshots, "Snapshots", ".letra/snapshots/ — undo/redo") - -Rel(dev, commands, "Invoca comandos") -Rel(commands, workflow_json, "6 comandos ESCREVEM: flow-backlog, flow-move, flow-edit, flow-import, flow-init, stage-drift(autoFix)") -Rel(commands, adapters, "3 comandos CHAMAM: flow-move, focus, health (outros 3 NÃO chamam)") -Rel(commands, context_md, "1 comando ATUALIZA: sitrep. Nenhum workflow mutation chama sitrep") -Rel(commands, health_json, "health scan, diagnose") -Rel(adapters, workflow_json, "LEITURA via builder.ts (read-only)") -Rel(adapters, focus_md, "LEITURA via builder.ts") -Rel(adapters, health_json, "LEITURA via builder.ts") -Rel(diagnostics, workflow_json, "stage-drift ESCREVE direto (backdoor)") -Rel(adapters, adapters_fs, "ESCREVE adapter files") -Rel(health, health_json, "LÊ e ESCREVE health-record.json") - -Rel(workflow_json, context_md, "DERIVA via sitrep (manual)") -Rel(workflow_json, adapters_fs, "DERIVA via generateAdapters (parcial)") - -UpdateLayoutConfig($c4ShapeInRow="2", $c4BoundaryInRow="1") -``` - -### Nível 3: Componentes Internos do CLI — HOJE - -```mermaid -C4Component -title Component Diagram — CLI Commands + Data Flow (Current) - -System_Boundary(commands, "Commands Layer") { - Component(flow_backlog, "flow-backlog.ts", "TypeScript", "Add item, saveWorkflow. NÃO gera adapters") - Component(flow_move, "flow-move.ts", "TypeScript", "Move item, saveWorkflow + generateAdapters") - Component(flow_edit, "flow-edit-diff.ts", "TypeScript", "Edit metadata, saveWorkflow. NÃO gera adapters") - Component(flow_import, "flow-import-issues.ts", "TypeScript", "Import issues, saveWorkflow. NÃO gera adapters") - Component(flow_init, "flow-init.ts", "TypeScript", "Init workflow, saveWorkflow. NÃO gera adapters") - Component(focus, "focus.ts", "TypeScript", "Define focus. generateAdapters. NÃO salva workflow") - Component(health_cmd, "health.ts", "TypeScript", "scan/ack/dismiss. generateAdapters. NÃO salva workflow") - Component(sitrep, "sitrep.ts", "TypeScript", "Update context.md. NÃO salva workflow, NÃO gera adapters") - Component(diagnose, "diagnose.ts", "TypeScript", "Run diagnostics. NÃO toca workflow nem adapters") -} - -System_Boundary(persistence, "Persistence Layer") { - Component(save_wf, "saveWorkflow()", "flow-init.ts:133", "Escreve workflow.json com backup") - Component(load_wf, "loadWorkflow()", "flow-init.ts:123", "Lê workflow.json") - Component(gen_adapters, "generateAdapters()", "generate.ts:31", "Gera AGENTS.md, .cursorrules...") - Component(sitrep_update, "sitrep update", "sitrep.ts:248", "Atualiza context.md com estado atual") -} - -System_Ext(workflow_file, "workflow.json") -System_Ext(adapter_files, "AGENTS.md, .cursorrules...") -System_Ext(context_file, "context.md") - -Rel(flow_backlog, save_wf, "saveWorkflow() → workflow.json", "verde") -Rel(flow_move, save_wf, "saveWorkflow()", "verde") -Rel(flow_move, gen_adapters, "generateAdapters()", "verde") -Rel(flow_edit, save_wf, "saveWorkflow()", "verde") -Rel(flow_import, save_wf, "saveWorkflow()", "verde") -Rel(flow_init, save_wf, "saveWorkflow()", "verde") -Rel(focus, gen_adapters, "generateAdapters()", "azul") -Rel(health_cmd, gen_adapters, "generateAdapters()", "azul") -Rel(save_wf, workflow_file, "write") -Rel(load_wf, workflow_file, "read") -Rel(gen_adapters, adapter_files, "write") -Rel(sitrep_update, context_file, "write") - -UpdateLayoutConfig($c4ShapeInRow="3", $c4BoundaryInRow="2") -``` - ---- - -### Nível 1: Contexto do Sistema (PROPOSTO) - -```mermaid -C4Context -title System Context — Letra (Proposed) - -Person(dev, "Desenvolvedor", "Usa CLI e edita specs") -Person(agent, "Agente IA", "OpenCode, Cursor, Claude Code") - -System_Boundary(letra, "Letra SDD Framework") { - System(cli, "CLI", "Commander · Node 22+") - System(webui, "Web UI", "React 19 · Vite · Tailwind v4") -} - -System_Ext(fs, "File System", ".letra/ diretório + raiz do projeto") - -Rel(dev, cli, "letra flow move, letra focus, letra health...") -Rel(agent, fs, "Lê AGENTS.md, .cursorrules, .letra/") -Rel(cli, fs, "writeWorkflow() é o ÚNICO gateway de escrita de workflow.json") -Rel(webui, fs, "Lê via CLI HTTP API (flow serve)") -UpdateLayoutConfig($c4ShapeInRow="3", $c4BoundaryInRow="1") -``` - -### Nível 2: Containers — PROPOSTO - -```mermaid -C4Container -title Container Diagram — Proposed Architecture (writeWorkflow gateway) - -Person(dev, "Desenvolvedor") - -System_Boundary(cli, "CLI (packages/cli)") { - Container(wf_gateway, "writeWorkflow()", "TypeScript", "GATEWAY ÚNICO de mutação. Garante side-effects") - Container(commands, "Commands", "TypeScript", "Commander-based. Chamam writeWorkflow()") - Container(adapters, "Adapters", "TypeScript", "Builder + Formatters + Generate") - Container(diagnostics, "Diagnostics", "TypeScript", "Engine + Detectors + SnapshotStore") - Container(health, "Health Record", "TypeScript", "Persistent alert store") - Container(log, "Session Log", "TypeScript", "session-log.ts") - Container(sync_cmd, "letra sync", "TypeScript", "Reconciliação manual: regenera tudo a partir do workflow.json") -} - -System_Ext(workflow_json, "workflow.json", "SINGLE SOURCE OF TRUTH — items, stages, tools") -System_Ext(focus_md, "focus.md", "Foco validado contra workflow (warning se stale)") -System_Ext(context_md, "context.md", "Contexto auto-sincronizado via sitrep dentro do gateway") -System_Ext(adapters_fs, "Adapter Files", "AGENTS.md, .cursorrules, CLAUDE.md — SEM lista de itens") -System_Ext(health_json, "health-record.json", "Alertas persistentes (independente)") -System_Ext(snapshots, "Snapshots", ".letra/snapshots/ — undo/redo") - -Rel(dev, commands, "Invoca comandos") -Rel(commands, wf_gateway, "5 comandos chamam writeWorkflow(): backlog, move, edit, import, init", "verde") -Rel(focus, adapters, "focus chama generateAdapters() diretamente (não escreve workflow)", "azul") -Rel(health_cmd, adapters, "health chama generateAdapters() diretamente (não escreve workflow)", "azul") -Rel(wf_gateway, workflow_json, "1. saveWorkflow() interno", "verde") -Rel(wf_gateway, adapters, "2. generateAdapters() auto", "verde") -Rel(wf_gateway, context_md, "3. sitrep update auto (opcional/async)", "verde") -Rel(wf_gateway, log, "4. logEntry() auto", "verde") -Rel(adapters, adapters_fs, "ESCREVE adapter files (sem L2/L3)") -Rel(adapters, workflow_json, "LEITURA via builder.ts (read-only)") -Rel(adapters, focus_md, "LEITURA via builder.ts (validado)") -Rel(adapters, health_json, "LEITURA via builder.ts") -Rel(diagnostics, workflow_json, "stage-drift autoFix chama writeWorkflow() — NÃO writeFileSync direto") -Rel(sync_cmd, wf_gateway, "Reconciliação: regenera tudo do workflow.json") -Rel(health, health_json, "LÊ e ESCREVE health-record.json") - -UpdateLayoutConfig($c4ShapeInRow="2", $c4BoundaryInRow="1") -``` - -### Nível 3: Componentes Internos do CLI — PROPOSTO - -```mermaid -C4Component -title Component Diagram — writeWorkflow() Gateway (Proposed) - -System_Boundary(commands, "Commands Layer") { - Component(flow_backlog, "flow-backlog.ts", "TypeScript", "Add item → writeWorkflow()") - Component(flow_move, "flow-move.ts", "TypeScript", "Move item → writeWorkflow() (simplificado)") - Component(flow_edit, "flow-edit-diff.ts", "TypeScript", "Edit metadata → writeWorkflow()") - Component(flow_import, "flow-import-issues.ts", "TypeScript", "Import issues → writeWorkflow()") - Component(flow_init, "flow-init.ts", "TypeScript", "Init workflow → writeWorkflow()") - Component(focus, "focus.ts", "TypeScript", "Define/clear focus → generateAdapters() direto") - Component(health_cmd, "health.ts", "TypeScript", "scan/ack/dismiss → generateAdapters() direto") - Component(sitrep, "sitrep.ts", "TypeScript", "Update context.md (manual override)") - Component(sync, "sync.ts", "TypeScript", "letra sync — reconciliação full") -} - -System_Boundary(gateway, "Write Gateway — writeWorkflow()") { - Component(write_wf, "writeWorkflow()", "flow-init.ts (novo)", "GATEWAY: valida, persiste, regenera, loga") - Component(save_wf, "saveWorkflow()", "flow-init.ts (privado)", "Baixo nível: escreve workflow.json + backup") - Component(gen_adapters, "generateAdapters()", "generate.ts", "Chamado automaticamente pelo gateway") - Component(sitrep_update, "sitrep()", "sitrep.ts", "Chamado automaticamente (opcional)") -} - -System_Ext(workflow_file, "workflow.json") -System_Ext(adapter_files, "AGENTS.md, .cursorrules...") -System_Ext(context_file, "context.md") - -Rel(flow_backlog, write_wf, "writeWorkflow()", "verde") -Rel(flow_move, write_wf, "writeWorkflow()", "verde") -Rel(flow_edit, write_wf, "writeWorkflow()", "verde") -Rel(flow_import, write_wf, "writeWorkflow()", "verde") -Rel(flow_init, write_wf, "writeWorkflow()", "verde") -Rel(sync, write_wf, "writeWorkflow({ force: true })", "verde") -Rel(focus, gen_adapters, "generateAdapters() direto (exceção)", "azul") -Rel(health_cmd, gen_adapters, "generateAdapters() direto (exceção)", "azul") -Rel(write_wf, save_wf, "1. saveWorkflow()") -Rel(write_wf, gen_adapters, "2. generateAdapters()") -Rel(write_wf, sitrep_update, "3. sitrep() [opcional/async]") -Rel(save_wf, workflow_file, "write") -Rel(gen_adapters, adapter_files, "write (sem L2/L3)") -Rel(sitrep_update, context_file, "write") - -UpdateLayoutConfig($c4ShapeInRow="3", $c4BoundaryInRow="2") -``` - -### Fluxo: writeWorkflow() — Sequência Interna - -```mermaid -sequenceDiagram - participant C as Command (flow-move, etc.) - participant G as writeWorkflow() - participant WF as workflow.json - participant AD as generateAdapters() - participant CT as sitrep() - participant LG as logEntry() - - C->>G: writeWorkflow(root, workflow, options?) - G->>G: validate(workflow) — schema check - G->>WF: saveWorkflow() — write + backup - G->>AD: generateAdapters() — update AGENTS.md etc. - alt sitrep enabled (default: true) - G->>CT: sitrep update — refresh context.md - end - G->>LG: logEntry() — audit trail - G-->>C: return { ok, filesUpdated[] } -``` - ---- - -## Decision - -Adotar o **Write-Sync Model**: toda mutação de `workflow.json` passa por um único gateway `writeWorkflow()` que garante side-effects consistentes. - -### Contrato do Gateway - -```typescript -interface WriteWorkflowOptions { - workflow: Workflow; - source: "flow-move" | "flow-backlog" | "flow-edit" | "flow-import" | "flow-init" | "stage-drift"; - skipAdapters?: boolean; // default: false - skipSitrep?: boolean; // default: false (sitrep é caro — opcional) - skipLog?: boolean; // default: false -} - -interface WriteWorkflowResult { - ok: boolean; - filesUpdated: string[]; // paths of generated/regenerated files - error?: string; -} - -function writeWorkflow(root: string, options: WriteWorkflowOptions): WriteWorkflowResult -``` - -### Regras de Side-Effect - -| Side-effect | Quando | Skipável? | -|---|---|---| -| `saveWorkflow()` (write + backup) | Sempre | ❌ | -| `generateAdapters()` | Sempre | `skipAdapters: true` | -| `sitrep()` update context.md | Padrão: sim | `skipSitrep: true` | -| `logEntry()` | Sempre | `skipLog: true` | - -### Adaptadores sem L2/L3 - -Remove-se do adapter (AGENTS.md, .cursorrules, etc.) as seções que duplicam estado mutável: - -- **Remove L2** (`formatL2()` — lista de itens no estágio): o agente usa `letra pulse` para isso. -- **Remove L3** (`formatL3()` — sinais de trabalho): `letra pulse` também mostra. -- **Mantém L1** (referências a context.md, constitution.md, glossary.md, focus.md). -- **Mantém L5** (alertas do health record) — informação crítica que não tem outro canal. -- **Mantém** kickoff checklist, command menu, completion checklist, regras. - -### Comandos afetados (antes → depois) - -| Comando | Antes | Depois | -|---|---|---| -| `flow backlog add` | `saveWorkflow()` só | `writeWorkflow()` → adapters + sitrep | -| `flow move` | `saveWorkflow()` + `generateAdapters()` (manual) | `writeWorkflow()` → tudo automático | -| `flow edit` | `saveWorkflow()` só | `writeWorkflow()` → adapters + sitrep | -| `flow import` | `saveWorkflow()` só | `writeWorkflow()` → adapters + sitrep | -| `flow init` | `saveWorkflow()` só | `writeWorkflow()` → adapters + sitrep | -| `stage-drift autoFix` | `writeFileSync()` direto (backdoor) | `writeWorkflow()` via função importada | -| `focus` / `health` | `generateAdapters()` direto | Mantém `generateAdapters()` direto (não escrevem workflow) | - -### Código morto após implementação - -| Arquivo | Destino | Motivo | -|---|---|---| -| `formatters.ts:formatL2()` (~35 linhas) | Remover | Lista de itens removida do adapter | -| `formatters.ts:formatL3()` (~40 linhas) | Remover | Sinais de trabalho removidos do adapter | -| `builder.ts:51-85` (~35 linhas) | Simplificar/podar | Lógica de mapeamento de itens fica mais simples | -| `flow-move.ts:5` (import generateAdapters) | Remover | Substituído por `writeWorkflow()` | -| `stage-drift.ts:54-69` (autoFix writeFileSync) | Reescrever | Usar `writeWorkflow()` em vez de `writeFileSync` direto | -| `flow-backlog.ts:53`, `flow-edit.ts:85`, `flow-import.ts:129,239` | 1 linha cada | Trocar `saveWorkflow()` por `writeWorkflow()` | - -**Total estimado de linhas removidas/simplificadas:** ~120 linhas. -**Total de novas linhas:** `writeWorkflow()` ~50 linhas + `sync.ts` ~80 linhas = ~130 linhas. -**Saldo líquido:** aproximadamente neutro. - -### O que NÃO muda - -- `health-record.ts` — independente, não passa pelo gateway -- `diagnostics/engine.ts` — não toca workflow.json -- `diagnostics/detectors/*` — só `stage-drift` muda -- `diagnostics/snapshot.ts` — independente -- `validation/` — domínio separado -- `packages/client/` — lê via HTTP API, sem mudança -- `session-log.ts` — já é chamado individualmente - -## Consequences - -**Positivo:** - -- **Consistência garantida**: toda mutação de workflow regenera adapters e (opcionalmente) context.md. Fim do drift entre AGENTS.md, context.md e workflow.json. -- **Audit trail completo**: `logEntry()` automático em toda mutação. -- **Código mais simples**: 5 comandos deixam de gerenciar side-effects manualmente. -- **Backdoor fechado**: `stage-drift` não consegue mais escrever direto no workflow.json. -- **Adapter mais leve**: sem L2/L3, fica ~30% menor e nunca stale. -- **`letra sync`** como "botão de pânico" para reconciliação manual. - -**Negativo:** - -- **Behavior change**: `flow backlog add`, `flow edit`, `flow import` vão começar a regenerar adapters e atualizar context.md. Pode ser mais lento (especialmente sitrep que roda testes). Mitigação: `skipSitrep: true` como fallback, sitrep async. -- **`stage-drift` autoFix**: precisa de acesso a `writeWorkflow()`. Hoje o detector é puro (só recebe `rootDir`). Isso quebra a pureza — o detector vai precisar importar uma função de escrita. Alternativa: o autoFix retorna a ação como dados, e o engine executa. -- **Focus e Health** continuam chamando `generateAdapters()` diretamente — exceção no modelo. Se no futuro eles também precisarem escrever workflow, precisam migrar. -- **`letra sync`** adiciona outro comando ao cardápio. Risco baixo. - -### Mitigações - -| Risco | Mitigação | -|---|---| -| Sitrep lento (roda testes) | `skipSitrep: true` por padrão. Sincronização via `letra sync` manual ou async future | -| Detector perde pureza | `autoFix` retorna ação descritiva → engine executa. Mantém detector puro | -| Surpresa por comportamento novo | Documentar no --help de cada comando afetado | - -## Alternativas rejeitadas - -| Alternativa | Por que rejeitada | -|---|---| -| **Event bus / pub-sub interno** | Overengineering. 5 comandos é um número pequeno; gateway síncrono basta | -| **git hook pós-commit** | Fora do controle do .letra/. Usuário pode não usar git | -| **File watcher contínuo** | Complexidade alta. watch + debounce + race conditions. Gateway é mais simples | -| **Manter como está + docs** | Não resolve o problema. Drift vai continuar | -| **Fundir tudo em um comando "flow" monolítico** | Viola single-responsibility. Gateway não é orquestrador | - -## Referências - -- `.letra/decisions/harness-composition-model.md` — ADR anterior que definiu camadas do adapter -- `.letra/specs/context-sync/spec.md` — spec de sincronização de contexto -- `.letra/specs/write-sync/spec.md` — spec deste motor de sincronização -- `packages/cli/src/commands/flow-*.ts` — comandos afetados (6 arquivos) -- `packages/cli/src/diagnostics/detectors/stage-drift.ts:54-69` — backdoor do autoFix -- `packages/cli/src/adapters/formatters.ts:L2-L3` — seções a remover diff --git a/.letra/design/harness-improvement-report.md b/.letra/design/harness-improvement-report.md deleted file mode 100644 index 43d01f8..0000000 --- a/.letra/design/harness-improvement-report.md +++ /dev/null @@ -1,415 +0,0 @@ -# Relatório de Melhoria do Harness — Análise Arquitetural - -> Data: 2026-06-15 -> Versão: v1.0 -> Contexto: ITEM-34 a ITEM-39 concluídos; análise do gap entre diagnóstico e ação. - ---- - -## Sumário Executivo - -O harness de diagnóstico do Letra evoluiu de **6 detectores (self-diagnosis-core)** para **10 detectores**, com 168 testes (antes 130) e ciclo fechado de meta-validação. No entanto, a arquitetura revela **3 problemas estruturais** que impedem o salto de nota 5.5 → 8.5: - -1. **Diagnóstico sem estado persistente** — sugestões são efêmeras, sem ciclo de vida (new → seen → acknowledged → dismissed) -2. **Duplicação de lógica de validação** em 3 sistemas paralelos (`lint.ts`, `validate.ts`, `detectores`) que não compartilham módulos -3. **Adaptadores sem consciência do diagnóstico** — AGENTS.md e correlatos não refletem pendências ativas - -Este relatório detalha a arquitetura atual, as duplicações, a complexidade ciclomática, e propõe um plano de refatoração em **3 camadas** com impacto mínimo no código existente. - ---- - -## 1. Arquitetura Atual — Mapeamento Completo - -### 1.1 Três Sistemas de Validação Paralelos - -``` - ┌──────────────────┐ - │ CLI Commands │ - │ (index.ts) │ - └────┬──┬──┬──┬───┘ - │ │ │ │ - ┌──────────┘ │ │ └──────────────┐ - ▼ ▼ ▼ ▼ - ┌──────────┐ ┌──────────┐ ┌─────────────────┐ - │ lint.ts │ │validate │ │ diagnose.ts │ - │ (90 ln) │ │ (956 ln) │ │ (59 ln) │ - │ │ │ │ │ │ - │seções │ │heurísti- │ │ DiagnosticEngine│ - │tam. │ │cas de │ │ → 10 detectores │ - │checklist│ │conteúdo │ │ → auto-fix │ - └──────────┘ └──────────┘ └────────┬────────┘ - │ - ┌───────────────────────┼───────────┐ - ▼ ▼ ▼ - ┌──────────────┐ ┌──────────────┐ ┌────────┐ - │ flow-serve │ │ detectores │ │snap- │ - │ (API) │ │ (10 arqu.) │ │shots │ - │ │ │ │ │ │ - │validação de │ │ac-stale │ │guarda │ - │spec duplicada│ │ac-false-pos │ │antes/ │ - │(igual lint) │ │spec-code-drift│ │depois │ - └──────────────┘ │... │ └────────┘ - └──────────────┘ -``` - -### 1.2 Duplicações Identificadas - -| Código duplicado | Ocorre em | Linhas | Custo de manutenção | -|---|---|---|---| -| `searchInSource()` + `walkDir()` | `ac-stale.ts`, `ac-false-pos.ts`, `spec-code-drift.ts` | ~30 linhas × 3 = 90 linhas | ALTO — qualquer mudança no algoritmo de busca precisa replicar em 3 lugares | -| Leitura de specs (readdir + readFile) | `lint.ts`, `validate.ts`, `ac-stale`, `ac-false-pos`, `spec-code-drift`, `cross-spec-dep`, `stage-drift`, `harness-meta-test` | ~5 linhas cada × 8 = 40+ linhas | MÉDIO — padrão repetitivo, mas simples | -| Checagem de seções obrigatórias | `lint.ts:45-48` e `flow-serve.ts:318-377` | ~30 linhas | MÉDIO — lógica idêntica, duas implementações | -| Detecção de conflitos entre specs | `validate.ts:checkConflicts()` e `cross-spec-dep.ts` | ~50 linhas vs 108 linhas | BAIXO — abordagens diferentes (heuristic vs detector) | -| `escapeRegex()` | `ac-stale.ts:119-121` | 3 linhas | BAIXO — função trivial, mas deveria ser shared utility | - -### 1.3 Complexidade Ciclomática (McCabe) - -| Arquivo | Complexidade | Risco | Observação | -|---|---|---|---| -| `flow-serve.ts:handleRequest` | **~52** | Crítico | Um único método com ~30 branchs (if/else if por endpoint). Cada endpoint é simples, mas o routing linear é frágil. | -| `validate.ts:checkSpecContent` | **18** | Alto | Heurísticas aninhadas: min chars, terminologia, tom, drift temporal, cada uma com sub-checks. | -| `validate.ts:checkConflicts` | **12** | Moderado | Loop sobre specs × specs (O(n²)) com matching por label/conteúdo. | -| `engine.ts:runAll` | **14** | Moderado | Loop de detectores + branch de certainty/autoFix + try/catch triplo. | -| `snapshot-bloat.ts:run` | **6** | Baixo | Sequencial: load → serialize → check → warn/fix. | -| `harness-meta-test.ts:run` | **10** | Moderado | 3 verificações independentes (engine, snapshot, detectores), mas cada uma com sub-branchs. | -| `ac-stale.ts:run` | **4** | Baixo | Loop simples sobre specs + regex + searchInSource. | -| `formatters.ts:formatAdapterContent` | **7** | Moderado | 4 layers condicionais com formatos at/text diferentes. | -| `snapshot.ts:save` | **6** | Baixo | Sequencial com duplicate check. | - -**Total estimado do sistema de diagnóstico: ~65 pontos de complexidade ciclomática.** - -> Benchark: McCabe recomenda ≤ 10 por função. `handleRequest` (52) é o maior candidato a refatoração. - ---- - -## 2. Análise Crítica: Problemas Estruturais - -### 2.1 Diagnóstico sem Estado (P1 — Crítico) - -**Problema:** Cada `runAll()` começa do zero. Não há memória entre sessões sobre: -- Quais sugestões o agente já viu -- Quais foram ignoradas/dismissadas -- Qual foi a decisão tomada - -**Impacto:** As mesmas 15 sugestões aparecem a cada scan. O agente não sabe o que é novo vs. já analisado. Leva à fadiga e ignorância do diagnóstico. - -**Evidência no código:** -```typescript -// engine.ts:118 — appliedFixes é resetado a cada runAll() -this.appliedFixes = newApplied; // substitui, não acumula -``` - -### 2.2 Duplicação de Lógica de Busca (P2 — Moderado) - -**Problema:** Três detectores (ac-stale, ac-false-pos, spec-code-drift) implementam `searchInSource()` + `walkDir()` idênticos. Qualquer bug ou melhoria (ex: incluir `dist/`, adicionar `.js` extension) precisa ser replicada. - -**Evidência no código:** -```typescript -// ac-stale.ts:81-97 -function searchInSource(rootDir: string, terms: string[]): boolean { ... } -function walkDir(dir: string): string[] { ... } - -// ac-false-pos.ts:61-77 — idêntico -function searchInSource(rootDir: string, terms: string[]): boolean { ... } -function walkDir(dir: string): string[] { ... } - -// spec-code-drift.ts:82-116 — idêntico -function searchInSource(rootDir: string, terms: string[]): boolean { ... } -function walkDir(dir: string): string[] { ... } -``` - -### 2.3 Adaptadores sem Diagnóstico (P3 — Moderado) - -**Problema:** O AGENTS.md e demais adaptadores contêm contexto estático (workflow, ACs, foco) mas **nunca** incluem o estado atual do diagnóstico. O agente precisa saber que há pendências. - -**Evidência no código:** -```typescript -// formatters.ts:106-126 — gera 4 layers, nenhuma de diagnóstico -function formatAdapterContent(snapshot, format, meta): string { - return title + L1 + L2 + L3 + rules; - // L1: context references - // L2: workflow/items - // L3: signals (AC count, tasks) - // rules: standard instructions -} -``` - -### 2.4 flow-serve.ts com Routing Monolítico (P4 — Moderado) - -**Problema:** `handleRequest()` é um único método com ~30 endpoints em cadeia if/else if. Viola o Princípio da Responsabilidade Única (SRP). - -**Evidência no código:** -```typescript -// flow-serve.ts:141 — início do handleRequest -async handleRequest(req, res): Promise { - const { pathname } = new URL(req.url!, `http://${req.headers.host}`); - if (pathname === "/events") { ... } - else if (pathname === "/api/workflow" && req.method === "GET") { ... } - else if (pathname === "/api/specs" && req.method === "GET") { ... } - // ... ~27 mais else ifs ... -} -``` - ---- - -## 3. Proposta Arquitetural: Refatoração em 3 Camadas - -### 3.1 Camada 1: Shared Utilities (Imediato) - -Extrair código duplicado para módulos compartilhados: - -``` -packages/cli/src/ -├── diagnostics/ -│ ├── detectors/ ← mantém detectores, mas usando shared utils -│ └── shared/ -│ ├── file-search.ts ← searchInSource + walkDir (extraído de 3 detectores) -│ ├── spec-reader.ts ← loadSpecs(), parseACs(), countACs() -│ └── state.ts ← DiagnosticState persistence (novo) -├── adapters/ -│ └── formatters.ts ← + L5: diagnostics section -├── validation/ ← NOVO módulo -│ ├── structure.ts ← required sections check (extraído de lint.ts) -│ ├── content.ts ← heuristic checks (extraído de validate.ts) -│ └── index.ts -└── commands/ - ├── lint.ts ← thin wrapper sobre validation/structure.ts - ├── validate.ts ← thin wrapper sobre validation/content.ts - ├── flow-serve.ts ← usa validation/structure.ts ao invés de duplicar - └── diagnostics.ts ← NOVO: família de subcomandos (scan, state, summary) -``` - -**Impacto:** Reduz duplicação, zero mudança de comportamento. - -### 3.2 Camada 2: Diagnostic State (Curto Prazo) - -Adicionar `DiagnosticState` persistente: - -```typescript -// diagnostics/shared/state.ts - -interface DiagnosticStateEntry { - id: string; // diagnostic result ID - firstSeenAt: string; // ISO timestamp - lastSeenAt: string; - status: "new" | "seen" | "acknowledged" | "dismissed" | "resolved"; - dismissedReason?: string; - autoFixApplied?: boolean; - snapshotId?: string; -} - -interface DiagnosticState { - schemaVersion: 1; - lastScanAt: string; - entries: DiagnosticStateEntry[]; -} -``` - -**Novas APIs REST:** -``` -POST /api/diagnostics/state/ack/:id → { status: "acknowledged" } -POST /api/diagnostics/state/dismiss/:id → { status: "dismissed", reason } -GET /api/diagnostics/state → DiagnosticState (novos vs. históricos) -``` - -**Integração com adaptadores:** - -```typescript -// Adapter L5 — adicionado em formatters.ts -function formatDiagnostics(diagState: DiagnosticState): string { - const newItems = diagState.entries.filter(e => e.status === "new"); - if (newItems.length === 0) return ""; - return [ - "## Pendências Detectadas", - ...newItems.map(e => `- ${e.id}: pendente — use \`letra diagnostics ack ${e.id}\` ao revisar`), - ].join("\n"); -} -``` - -### 3.3 Camada 3: Routing Refactor (Médio Prazo) - -Refatorar `flow-serve.ts:handleRequest` usando Router pattern: - -```typescript -// flow-serve.ts -type RouteHandler = (req: IncomingMessage, res: ServerResponse, params: Record) => Promise; - -const routes = new Map(); -routes.set("GET /events", handleSSE); -routes.set("GET /api/workflow", handleGetWorkflow); -routes.set("GET /api/diagnostics", handleGetDiagnostics); -routes.set("POST /api/diagnostics/scan", handleDiagnosticsScan); -routes.set("POST /api/diagnostics/undo/:id", handleDiagnosticsUndo); -// ... - -async handleRequest(req, res): Promise { - const { pathname } = new URL(req.url!, `http://${req.headers.host}`); - const key = `${req.method} ${pathname}`; - const handler = routes.get(key) || matchPattern(routes, key); - if (handler) return handler(req, res, {}); - return this.serveClient(req, res); -} -``` - -**Impacto:** Complexidade ciclomática de 52 → 4 no handleRequest. - ---- - -## 4. Consolidação: diagnose vs. doctor vs. diagnostics - -**Decisão arquitetural:** Não criar um comando `doctor` separado. Unificar sob `letra diagnostics`. - -| Subcomando | Função | Origem | Prioridade | -|---|---|---|---| -| `letra diagnostics scan` | Executa `runAll()` e imprime resultados | `diagnose.ts` existente | Imediato | -| `letra diagnostics state` | Mostra estado persistente (novo/acknowledged/dismissed) | Novo | Curto prazo | -| `letra diagnostics ack ` | Marca sugestão como reconhecida | Novo | Curto prazo | -| `letra diagnostics dismiss ` | Marca como ignorada com motivo | Novo | Curto prazo | -| `letra diagnostics summary` | Resumo formatado para injetar em adapters | Novo | Curto prazo | - -**`letra diagnose`** continua como alias de `letra diagnostics scan` para backward compatibility. - -**Por que não "doctor"?** Porque o conceito de "doctor" (diagnosticar + tratar + acompanhar) já é exatamente o que a família `diagnostics` faz, mas falta o "acompanhar" (state). Adicionar state completa o ciclo sem criar overlapping semântico. - ---- - -## 5. Impacto da Melhoria - -| Métrica | Atual | Após Camada 1 | Após Camada 2 | Após Camada 3 | -|---|---|---|---|---| -| Linhas duplicadas | ~160 | ~30 | ~30 | ~30 | -| Complexidade handleRequest | 52 | 52 | 52 | 4 | -| Detector code sharing | 0% | 100% | 100% | 100% | -| Sugestões persistentes | ❌ | ❌ | ✅ | ✅ | -| Adaptators com diagnóstico | ❌ | ❌ | ✅ | ✅ | -| Auto-clean focus.md | ❌ | ❌ | ✅ (via state) | ✅ | -| Manutenibilidade (MI) | ~65 | ~75 | ~80 | ~90 | - -> MI = Maintainability Index estimado (escala 0-100). Cálculo baseado na redução de duplicação e complexidade ciclomática. - ---- - -## 6. Especificações Propostas - -### 6.1 Spec: `diagnostics-state` — Estado Persistente do Diagnóstico - -**Outcome:** Sugestões de diagnóstico têm ciclo de vida (new → seen → acknowledged → dismissed). O agente e o usuário sabem o que já foi analisado e o que é novo. - -**Acceptance Criteria:** -``` -- [ ] DiagnosticState persiste em .letra/diagnostics-state.json -- [ ] engine.runAll() mergeia resultados com estado existente (novos vs. repetidos) -- [ ] POST /api/diagnostics/state/ack/:id marca sugestão como acknowledged -- [ ] POST /api/diagnostics/state/dismiss/:id marca como dismissed com reason -- [ ] GET /api/diagnostics/state retorna estado completo com metadados -- [ ] Sugestões "new" aparecem primeiro no output; "acknowledged" são ocultas por padrão -- [ ] Auto-fixes bem-sucedidos marcam entrada como "resolved" -``` - -**Certeza:** 1.0 — operações determinísticas de arquivo. - -### 6.2 Spec: `diagnostics-adapter` — Diagnóstico nos Adaptadores - -**Outcome:** AGENTS.md, .cursorrules etc. incluem seção de pendências ativas do diagnóstico. - -**Acceptance Criteria:** -``` -- [ ] Adapter L5 exibe {N} sugestões novas se houver pendências -- [ ] L5 incluído apenas quando há entradas "new" no DiagnosticState -- [ ] Formato compatível com at/text (mesmo padrão de L1-L4) -- [ ] Gerado via `generateAdapters()` sem nova dependência circular -- [ ] foco.md também recebe seção de diagnóstico se houver pendências críticas -``` - -**Certeza:** 1.0 — template string determinística. - -### 6.3 Spec: `context-sync` — Sincronização Automática do Contexto - -**Outcome:** `context.md` reflete o estado real do projeto sem edição manual. - -**Acceptance Criteria:** -``` -- [ ] letra context sync computa: contagem de testes (vitest run --reporter=json) -- [ ] letra context sync atualiza: estágio atual, data, itens correntes -- [ ] letra context sync NÃO sobrescreve seções manuais (Intent, Domínio, Porquês) -- [ ] Comando falha silenciosamente se vitest não estiver disponível -- [ ] Meta-test alerta se context.md desatualizado > 7 dias se não houver sync recente -``` - -**Certeza:** 0.9 — contagem de testes pode falhar em ambientes sem build. - -### 6.4 Spec: `validation-consolidation` — Unificação da Validação - -**Outcome:** `lint.ts`, `validate.ts`, e os detectores de diagnóstico compartilham módulos de validação comuns. - -**Acceptance Criteria:** -``` -- [ ] validation/structure.ts exporta checkRequiredSections(), checkSpecLength(), checkChecklist() -- [ ] lint.ts é thin wrapper sobre validation/structure.ts (mesmo output, menos código) -- [ ] flow-serve.ts:validateSpec usa validation/structure.ts ao invés de lógica inline -- [ ] validation/content.ts exporta checkSpecContent(), checkConflicts() -- [ ] validate.ts é thin wrapper sobre validation/content.ts -- [ ] diagnostics/shared/file-search.ts exporta searchInSource(), walkDir() -- [ ] ac-stale.ts, ac-false-pos.ts, spec-code-drift.ts importam de file-search.ts -- [ ] Zero mudança de comportamento em lint/validate CLI output -- [ ] Testes existentes continuam passando sem modificação -``` - -**Certeza:** 1.0 — refatoração puramente mecânica (extrair e importar). - ---- - -## 7. Plano de Implementação - -| Fase | Spec | Depende de | Esforço estimado | Risco | -|---|---|---|---|---| -| 1 | validation-consolidation | Nenhuma | 4h | Baixo — extração mecânica | -| 2 | diagnostics-state | Nenhuma | 3h | Baixo — novo arquivo, sem modificar engine | -| 3 | diagnostics-adapter | diagnostics-state | 2h | Médio — integration com adapter pipeline | -| 4 | context-sync | Nenhuma | 2h | Baixo — comando autônomo | -| 5 | Routing refactor (flow-serve) | Nenhuma | 3h | Alto — mexe em handleRequest, core do servidor | - -**Ordem recomendada:** 1 → 2 → 3 → 4 → 5 - ---- - -## 8. Manutenção do Código Limpo - -### 8.1 Padrões a Seguir - -- **Módulos shared** em `diagnostics/shared/` e `validation/` — exportam funções puras, sem estado -- **Detectores** mantêm responsabilidade única: cada detector faz UMA coisa -- **Commands** são thin wrappers (< 100 linhas) que orquestram, não implementam lógica -- **Adapters** continuam template-driven (formatters.ts gera strings, sem lógica de negócio) - -### 8.2 Anti-padrões a Eliminar - -| Anti-padrão | Onde | Solução | -|---|---|---| -| Duplicação de searchInSource/walkDir | 3 detectores | Extrair para shared/file-search.ts | -| Routing monolítico | flow-serve.ts:handleRequest | Router Map pattern | -| Validação inline duplicada | flow-serve.ts (spec validation) | Usar validation/structure.ts | -| Lógica de AC counting espalhada | ac-counter.ts + ac-stale + ac-false-pos + stage-drift | Centralizar em shared/spec-reader.ts | - -### 8.3 Guia de Complexidade - -- Funções com cyclo > 10: **devem ser refatoradas** -- Arquivos com cyclo total > 30: **candidatos a splitting** -- Novos detectores: máximo 80 linhas, cyclo < 6 -- Commands CLI: máximo 100 linhas (thin wrapper pattern) -- Shared modules: funções puras, sem efeito colateral, testáveis isoladamente - ---- - -## 9. Conclusão - -O harness atual tem **boa cobertura de detecção** mas **falta pipeline de ação**. A refatoração proposta não muda o modelo existente — ela adiciona camadas sobre ele: - -1. **Shared utilities** reduzem duplicação e melhoram manutenibilidade -2. **Diagnostic state** fecha o ciclo do diagnóstico: detectar → persistir → agir → acompanhar -3. **Adapter integration** empurra o diagnóstico para onde o agente já olha (AGENTS.md) -4. **Routing refactor** reduz complexidade do servidor - -O impacto no código existente é **mínimo**: nenhum detector precisa ser reescrito, nenhum comando muda de comportamento. As mudanças são aditivas (novos módulos, novos endpoints) e extrativas (mover código duplicado para shared modules). - -**Nota do harness após refatoração: 5.5 → 8.5** diff --git a/.letra/docs/conceitos-e-arquitetura.md b/.letra/docs/conceitos-e-arquitetura.md deleted file mode 100644 index afa1d03..0000000 --- a/.letra/docs/conceitos-e-arquitetura.md +++ /dev/null @@ -1,331 +0,0 @@ -# Conceitos, Arquitetura e Modelo de Dados - -> Documento de refino — registra a discussão sobre especificação, item de backlog, tarefa e o modelo resiliente de dados. - -## 1. Conceitos - -### Spec (Especificação) - -**Definição:** A regra de negócio, o contrato do que precisa ser entregue. É o **"por quê"** e **"o que"**. - -| Característica | Descrição | -|---|---| -| Mudança | Rara, negociada, versionada | -| Quem escreve | Tech lead, PM, cliente | -| Conteúdo | Outcome, Constraints, Acceptance Criteria | -| Vida | Independente do workflow — pode existir sem itens | -| Anda no kanban? | **Não.** A spec é um documento estável que serve de referência | - -**Exemplo:** -``` -.letra/specs/checkout-pix/spec.md -├── Outcome: Usuário paga via PIX em até 5s -├── Constraints: QR Code dinâmico, confirmação webhook -├── Exclusions: Parcelamento -└── Acceptance Criteria: [ ] Gerar QR Code, [ ] Confirmar em tempo real -``` - ---- - -### Item de Backlog - -**Definição:** Unidade de entrega com valor de negócio. O **"quando"** — uma fatia concreta e implementável da spec. - -| Característica | Descrição | -|---|---| -| Mudança | Frequente — refinada, priorizada, movida | -| Quem escreve | Time, baseado na spec | -| Conteúdo | Descrição executável, tasks atômicas | -| Vida | Anda no workflow: Backlog → Design → Code → Tests → Review → Done | -| Anda no kanban? | **Sim.** É a unidade que se move entre os estágios | - -**Exemplo:** -``` -ITEM-12 Criar tela de QR Code dinâmico - ├── Estágio atual: Code - ├── Spec vinculada: checkout-pix - └── Tasks: - ├── TASK-1 Criar rota POST /api/pix/qrcode ✅ done - ├── TASK-2 Implementar componente QR Code 🔄 - └── TASK-3 Testar geração de QR com valor inválido -``` - ---- - -### Tarefa (Task) - -**Definição:** Unidade de execução. O **"como"** — passo mais atômico que um agente ou pessoa executa. - -| Característica | Descrição | -|---|---| -| Mudança | Alta — descoberta durante o trabalho | -| Quem escreve | Quem executa (dev, LLM) | -| Conteúdo | Comando técnico, arquivo, função | -| Vida | Dentro do item — não anda no workflow global | -| Anda no kanban? | **Não.** Anda dentro do item — como checklist ou sub-kanban | - ---- - -## 2. Modelo de Dados - -### Estrutura do `workflow.json` - -O `workflow.json` é o **registro central** do projeto. Tudo que importa — specs, itens, tarefas, vínculos — está aqui, versionado, diffável, mergeável. - -```json -{ - "version": "1.0", - "name": "Letra", - "description": "SDD-agnostic memory framework for AI coding agents", - - "specLinks": { - "checkout-pix": { - "path": ".letra/specs/checkout-pix/spec.md", - "aliases": ["pix", "pagamento-pix"] - }, - "login-sso": { - "path": ".letra/specs/login-sso/spec.md" - } - }, - - "stages": [ - { "id": "backlog", "name": "Backlog", "order": 0, "zone": "todo" }, - { "id": "design", "name": "Design", "order": 1, "zone": "doing", "allow": ["backlog"] }, - { "id": "code", "name": "Code", "order": 2, "zone": "doing", "allow": ["design"] }, - { "id": "review", "name": "Review", "order": 3, "zone": "doing", "allow": ["code"] }, - { "id": "done", "name": "Done", "order": 4, "zone": "done", "allow": ["review"] } - ], - - "items": [ - { - "id": "ITEM-12", - "description": "Criar tela de QR Code dinâmico", - "stage": "code", - "spec": "checkout-pix", - "createdAt": "2026-06-07T21:45:59.038Z", - "source": "github", - "sourceUrl": "https://github.com/owner/repo/issues/42", - "tasks": [ - { "id": "TASK-1", "description": "Criar rota POST /api/pix/qrcode", "done": true }, - { "id": "TASK-2", "description": "Implementar componente QR Code", "done": false }, - { "id": "TASK-3", "description": "Testar geração de QR com valor inválido","done": false } - ] - } - ], - - "tools": ["opencode", "vscode"], - "webhooks": [ - { "id": "wh-1", "url": "https://hooks.slack.com/...", "events": ["item.moved"], "label": "Slack" } - ], - "createdAt": "2026-06-07T21:45:54.843Z", - "updatedAt": "2026-06-13T20:06:25.825Z" -} -``` - -### Diagrama ER - -```mermaid -erDiagram - WORKFLOW { - string version - string name - string description - json specLinks "REGISTRO CENTRAL: id estavel { path, aliases }" - json webhooks "opcional: [{ url, events }]" - datetime createdAt - datetime updatedAt - string[] tools - } - STAGE { - string id PK - string name - int order - string zone "todo | doing | done" - string[] allow "transicoes permitidas" - string color "opcional: cor personalizada" - } - SPEC_LINK { - string specId PK "ESTAVEL — ex: checkout-pix" - string path "MUTAVEL — ex: .letra/specs/checkout-pix/spec.md" - string[] aliases "opcional" - } - SPEC_FILE { - string path PK "fisico no filesystem" - string content "markdown" - } - ITEM { - string id PK "ITEM-12" - string description - string stage FK - string spec FK "opcional — aponta SPEC_LINK.specId" - string source "opcional — github | linear" - string sourceUrl "opcional" - datetime createdAt - json tasks "[{ id, description, done }]" - } - - WORKFLOW ||--o{ STAGE : "tem" - WORKFLOW ||--o{ ITEM : "contem" - WORKFLOW ||--|| SPEC_LINK : "registra (workflow.specLinks{})" - ITEM }o--|| SPEC_LINK : "referencia (spec FK -> SPEC_LINK.specId)" - SPEC_LINK ||--o| SPEC_FILE : "aponta (path -> arquivo)" -``` - -### Cardinalidades - -``` -SPEC_LINK 1 ──── N ITEMS (uma spec pode ter varios itens) -ITEM 1 ──── N TASKS (um item se decompoe em varias tarefas) -STAGE 1 ──── N ITEMS (um estagio contem varios itens) -``` - ---- - -## 3. Resiliência contra Mudanças no Filesystem - -O problema central resolvido por este modelo: **o vinculo entre item e spec nao depende do nome da pasta no filesystem.** - -| Acao do usuario | Efeito | Como resolver | -|---|---|---| -| Renomear pasta `checkout-pix` -> `pix` | So atualizar `path` no `specLinks{}` | `letra spec update checkout-pix --path .letra/specs/pix/spec.md` | -| Mover spec para outro local | Atualizar `path` | Idem | -| Apagar pasta da spec acidentalmente | `letra validate` detecta path invalido | Aviso: "Spec 'checkout-pix': path nao encontrado" | -| Duas pastas com mesmo conteudo | IDs unicos no `specLinks{}` impedem conflito | O JSON nao permite chave duplicada | -| Merge conflict no `workflow.json` | Resolvido pelo Git (ja acontece hoje) | Sempre foi gerenciável | - -**Principio:** o `workflow.json` e a fonte da verdade. O filesystem (`.letra/specs/`) e apenas **cache de conteudo** — o spec.md pode ser recriado a partir do contrato, mas o vinculo esta no JSON versionado. - -### Backup e Versionamento - -- **Backup automatico:** `saveWorkflow()` salva copia timestampada em `.letra/backups/workflow-{timestamp}.json` antes de qualquer overwrite -- **Versionamento semantico:** `flow edit` incrementa minor version e salva `.letra/workflow.v{version}.json` -- **Merge em re-setup:** `createWorkflowFromTemplate` preserva items/specLinks/tools existentes - ---- - -## 4. Fluxo de Resolucao de Spec - -Quando o kanban renderiza um item: - -``` -ITEM: { spec: "checkout-pix" } - | - v - workflow.specLinks["checkout-pix"]? - | - +-----+-----+ - v v - EXISTE NAO EXISTE - | | - v v - Le o path Fallback: busca por aliases - -> spec.md Fallback 2: matching por descricao - -> renderiza -> "Spec nao encontrada" -``` - ---- - -## 5. Como o Kanban Exibe os Conceitos - -### Card do Item (fechado) - -``` -+--------------------------------------+ -| ITEM-12 [code] | -| | -| Criar tela de QR Code dinamico | -| progress: [#####...] 1/3 tasks | -| spec: checkout-pix | -+--------------------------------------+ -``` - -### Card do Item (expandido — revela tasks) - -``` -+--------------------------------------+ -| ITEM-12 [code] | -| | -| Criar tela de QR Code dinamico | -| | -| Tasks: | -| + TASK-1 Criar rota POST /api/pix | -| ~ TASK-2 Componente QR Code | -| o TASK-3 Testar valor invalido | -| | -| progress: 1/3 tasks | -| spec: checkout-pix [abrir spec] | -+--------------------------------------+ -``` - -### Agrupamento por Spec no Board - -``` -Backlog: - +- Spec: checkout-pix --------------+ - | ITEM-13 Criar webhook confirmacao | - | ITEM-15 Validacao de chave PIX | - +------------------------------------+ - -Code: - +- Spec: checkout-pix --------------+ - | ITEM-12 Tela de QR Code (1/3) | - +------------------------------------+ -``` - ---- - -## 6. Comandos (Implementados vs Futuros) - -### Implementados - -| Comando | Descricao | -|---|---| -| `letra flow init --quick` | Inicializa workflow com 3 perguntas | -| `letra flow backlog add "descricao"` | Adiciona item ao primeiro estagio (ID auto-incremental) | -| `letra flow backlog import github ` | Importa issues do GitHub | -| `letra flow backlog import linear ` | Importa issues do Linear | -| `letra flow move ITEM-1 --to Code` | Move item entre estagios + regenera adapters | -| `letra flow board` | Board visual com estagios e contagem | -| `letra flow visualize --output diagram.html` | Diagrama Mermaid do fluxo | -| `letra flow edit --name "Novo" --desc "..."` | Edita metadados do workflow | -| `letra flow diff [v1] [v2]` | Diff entre versoes | -| `letra flow export --minified` | Exporta workflow como JSON | -| `letra flow import ` | Importa workflow de arquivo | -| `letra flow serve --port 3000` | Servidor HTTP + SPA web UI | -| `letra decision new ` | ADR com template | -| `letra focus ` | Define foco da sessao | -| Via API `PATCH /api/items/:id` | Adicionar/atualizar tasks de um item | - -### Futuros - -| Comando | Descricao | -|---|---| -| `letra spec link ITEM-12 checkout-pix` | Vincula item a uma spec | -| `letra spec unlink ITEM-12` | Remove vinculo | -| `letra spec update checkout-pix --path ` | Atualiza path apos renomear/mover pasta | -| `letra validate --specs` | Verifica integridade dos vinculos spec<->item | -| `letra flow promote --validate` | Promover com validacao automatica | - ---- - -## 7. Decisoes de Design - -| Decisao | Motivo | -|---|---| -| `workflow.json` como registro central | Unico arquivo versionado, diffavel, mergeavel | -| specLinks desacoplado do path | Resiste a renomeacao/movimentacao de pastas | -| Tasks dentro do item | Nao precisam de workflow proprio, andam com o item | -| Spec nao anda no kanban | Spec e contrato estavel, item e entrega executavel | -| Fallback por descricao | Mantem compatibilidade com dados existentes | -| Adapters regenerados em `flow move` | Mantem agentes sincronizados com contexto atual | -| Backup automatico antes de overwrite | Evita perda de dados em re-setup | -| SSE live updates | UI reflete mudancas em tempo real | - ---- - -## 8. Acesso - -Este documento esta disponivel em: - -- **No repositorio:** `.letra/docs/conceitos-e-arquitetura.md` -- **GitHub Wiki:** (em breve — acesse https://github.com/jspengine/letra/wiki) diff --git a/.letra/docs/design-system.md b/.letra/docs/design-system.md deleted file mode 100644 index 9c002d9..0000000 --- a/.letra/docs/design-system.md +++ /dev/null @@ -1,295 +0,0 @@ -# Design System Reference - -> Updated: 2026-06-13 - -## Stack - -- **React 19** + TypeScript (functional components, hooks) -- **Tailwind v4** with `@tailwindcss/vite` (NO PostCSS, NO tailwind.config.js) -- **CSS variables** in `packages/client/src/index.css` -- **OKLCH** color space (not HSL) -- **Zero runtime dependencies** — all components hand-rolled - -## Color Tokens - -Defined in `packages/ui/src/index.css`. Dark mode via `.dark` class on ``. - -**Source of truth:** `packages/ui/src/index.css` — the UI package owns all tokens. -**Client** imports via `@import "../../ui/src/index.css"`. - -### Surfaces - -| Token | Light | Dark | Uso | -|---|---|---|---| -| `--surface-1` | oklch(1 0 0) | oklch(0.145 0 0) | Fundo geral | -| `--surface-2` | oklch(0.965 0 0) | oklch(0.205 0 0) | Cards, elevado | -| `--surface-3` | oklch(0.922 0 0) | oklch(0.269 0 0) | Hover, secundário | -| `--surface-input` | oklch(0.985 0 0) | oklch(0.145 0 0) | Input fields | - -### Text - -| Token | Light | Dark | Uso | -|---|---|---|---| -| `--text-primary` | oklch(0.145 0 0) | oklch(0.985 0 0) | Body, headings | -| `--text-secondary` | oklch(0.556 0 0) | oklch(0.708 0 0) | Labels, hints | -| `--text-disabled` | oklch(0.556 0 0 / 0.5) | oklch(0.708 0 0 / 0.5) | Desabilitado | -| `--text-link` | oklch(0.546 0.245 262.881) | oklch(0.685 0.246 262.881) | Links | -| `--text-inverse` | oklch(1 0 0) | oklch(0.145 0 0) | Sobre primary | - -### Borders - -| Token | Light | Dark | Uso | -|---|---|---|---| -| `--border-default` | oklch(0.922 0 0) | oklch(0.269 0 0) | Borda padrão | -| `--border-hover` | oklch(0.87 0 0) | oklch(0.33 0 0) | Hover | -| `--border-focus` | oklch(0.546 0.245 262.881) | oklch(0.685 0.246 262.881) | Foco/ring | -| `--border-disabled` | oklch(0.922 0 0 / 0.5) | oklch(0.269 0 0 / 0.5) | Desabilitado | - -### Brand - -`--primary`, `--accent`, `--primary-foreground`, `--accent-foreground` - -### Semantic - -`--success`, `--warning`, `--error`, `--info`, `--live` (+ *-foreground) - -### Overlay - -`--overlay: oklch(0 0 0 / 0.4)` — usado em dialogs/modais - -**Regra:** Sempre use `var(--*)`. Nunca `#hex` ou `rgb()` ou `oklch()` inline em componentes. -Use `npm run ds:check` para verificar violações. - -## Typography - -- **Font stack:** `system-ui, -apple-system, sans-serif` -- **Mono stack:** `ui-monospace, SFMono-Regular, 'Cascadia Code', monospace` -- **Scale:** Tailwind text scale (`text-xs`, `text-sm`, `text-base`, `text-lg`, `text-xl`, `text-2xl`, `text-3xl`) - -## Spacing & Radius - -| Token | Value | -|---|---| -| Base unit | 4px (Tailwind `p-1` = 4px) | -| `rounded-sm` | 6px | -| `rounded-lg` | 12px | -| `rounded-xl` | 16px | -| Transitions | 150ms default, 200ms hover, 300ms slow | - -## Layout - -- **Shell:** Header(56px) + NavTabs(40px) + Content(flex-1 overflow-y-auto) -- **Content padding:** `p-6` -- **Card max-width detail:** `max-w-3xl mx-auto` -- **Sidebar:** `w-80` (Specs), `w-72` (Context) - -## Icons - -### Component: `` - -```tsx -import { Icon } from "../ui/icon"; -import type { IconName } from "../ui/icon"; - - - -``` - -### API - -| Prop | Type | Default | Description | -|---|---|---|---| -| `name` | `IconName` | required | Icon identifier from the set below | -| `size` | `14 \| 16 \| 20 \| 24` | `16` | Pixel dimensions (square) | -| `className` | `string` | — | Tailwind classes (`shrink-0` always applied) | -| `style` | `CSSProperties` | — | Inline styles (use for `color: var(--*)`) | - -### Rules (do not violate) - -1. **Always use ``** — never inline raw `` in component code -2. **No icon library dependencies** — all SVGs are hand-defined in `icon.tsx` -3. **24×24 viewBox** — every icon uses this standard grid -4. **2px stroke** — `strokeWidth="2"`, `strokeLinecap="round"`, `strokeLinejoin="round"` -5. **`currentColor`** — color inherits from text; use `className="text-*"` to change -6. **`aria-hidden="true"`** — all icons are decorative, no need for screen reader announcement - -### Adding a New Icon - -1. Open `packages/client/src/components/ui/icon.tsx` -2. Add the icon path(s) to the `ICONS` record as an array of path `d` strings -3. The key becomes the `IconName` — no other file needs updating for the type to work -4. If the icon is standard (e.g., Lucide-flavored), copy the `d` attributes directly - -### Available Icons (20) - -| Name | Preview (path count) | Used In | -|---|---|---| -| `home` | House (1) | NavTabs | -| `specs` | File with lines (5) | NavTabs | -| `flow` | Bar chart / layout (1) | NavTabs | -| `context` | Clipboard with clipboard (2) | NavTabs | -| `sun` | Sun with rays (2) | Header theme toggle | -| `moon` | Crescent moon (1) | Header theme toggle | -| `grid` | Expand / crosshairs (8) | SetupWizard welcome | -| `star` | 5-pointed star (1) | Template "Ágil" | -| `list-three` | Three horizontal lines (3) | Template "Kanban" | -| `cross` | Cross with arrows (6) | Template "Padrão" | -| `settings` | Gear with inner circle (2) | Template "Personalizar" | -| `plus` | Plus sign (2) | Specs "Nova" button | -| `trash` | Trash can with lid (5) | Specs delete button | -| `check` | Checkmark (1) | Validation success | -| `edit` | Pencil (2) | Specs edit button | -| `search` | Magnifying glass (2) | Specs search input | -| `info` | Circled-i (3) | HomeView tooltips | -| `chevron-left` | Left chevron (1) | Navigation | -| `chevron-right` | Right chevron (1) | Navigation | -| `arrow-up` | Up arrow (2) | Misc | -| `x` | Close / X mark (2) | SpecsView filter (errors) | -| `alert-triangle` | Warning triangle (3) | SpecsView filter (warnings) | - -## Package - -Components now live in **`@letra/ui`** (`packages/ui/`) — a shared workspace package importable by any app. - -``` -import { Button, Badge, Card, Checkbox, Icon } from "@letra/ui"; -// CSS tokens (if not using Tailwind): -import "@letra/ui/styles"; -``` - -## Components - -### Button - -```tsx - -``` - -**Variants:** `default`, `secondary`, `outline`, `ghost` -**Sizes:** `sm` (xs), `default` (sm), `lg` (base) - -### Badge - -```tsx -healthy -3 stale -``` - -**Variants:** `default`, `secondary`, `outline`, `success`, `warning` - -### Card - -```tsx - - ... - -``` - -### Input / Textarea / Checkbox - -```tsx - - - -``` - -### Icon - -```tsx - -``` - -29 icons: home, specs, flow, context, sun, moon, grid, plus, trash, check, edit, search, info, chevron-left, chevron-right, arrow-up, star, list-three, settings, cross, x, alert-triangle, user, x-circle, check-circle, alert-circle, help, bar-chart, code - -### Dialog / ConfirmDialog / PromptDialog - -```tsx - - -``` - -Use **instead of** `window.confirm` / `window.prompt`. - -### Tabs - -Reusable tab navigation — renders `role="tablist"` with panels via render prop. - -```tsx - - {(id) =>
Panel {id}
} - -``` - -### Progress - -```tsx - -``` - -### EmptyState - -```tsx -New} /> -``` - -### Alert - -```tsx -Something went wrong -``` - -Variants: `info`, `success`, `warning`, `error` - -### Tooltip - -```tsx - -``` - -### Avatar - -```tsx - - -``` - -### Skeleton / Toast - -```tsx - - -const { toast } = useToast(); -toast("Spec salva", "success"); -``` - -## Micro-interactions - -| Element | Effect | -|---|---| -| Cards (metric, pipeline, spec) | `hover:shadow-md hover:-translate-y-0.5 hover:border-primary/30` | -| NavTabs | `hover:bg-muted/50`, active: `bg-primary/10 text-primary` | -| Buttons | `hover:opacity-90`, `focus:ring-2 ring-primary/30` | -| Spec list items | `hover:bg-muted/50`, `focus-visible:ring-2 ring-primary/30` | -| Context tab buttons | `hover:bg-muted/50`, `focus-visible:ring-2 ring-primary/30` | -| Tab content | `animate-fade-in` on `
` wrapper | -| Badge "live" | `animate-pulse-live` (pulsing green glow, 2s) | -| Toast | `animate-slide-in-right` (slides in from right, 300ms) | - -## Accessibility (WCAG 2.2 AA) - -- **Focus visible:** All interactive elements have `focus-visible:ring-2 ring-primary/30` -- **ARIA labels:** Inputs without visible `
- {/* Mobile sticky action bar (< 640px) */} -
- - - {item.spec && onTabChange && ( - - )} +
+ +
-
- - - {showTimeline && ( - setShowTimeline(false)} + + setShowDeleteConfirm(false)} + onConfirm={handleDelete} + title="Excluir item" + message={`Excluir ${item.id} remove este item do fluxo. Esta ação deve ser usada apenas quando o item não representa mais trabalho real.`} + confirmLabel="Excluir" + cancelLabel="Cancelar" + variant="danger" /> - )} ); } -interface MobileMetaProps { - item: Item; - slug: string; - typeTag: string; - typeColor: string; - curStageName: string; - progressVal: number; - progressMax: number; - daysInStage: number; - availableStages: Workflow["stages"]; - moveTarget: string; - onMoveTargetChange: (v: string) => void; - onMove: () => void; - onDelete: () => void; - onTabChange?: (tab: "specs") => void; - itemSpec?: string; -} - -function MobileMeta({ +function TaskList({ item, - slug, - typeTag, - typeColor, - curStageName, - progressVal, - progressMax, - daysInStage, - availableStages, - moveTarget, - onMoveTargetChange, - onMove, - onDelete, - onTabChange, - itemSpec, -}: MobileMetaProps) { - const [open, setOpen] = useState(false); - + onToggle, +}: { item: Item; onToggle: (taskId: string, done: boolean) => void }) { + if (!item.tasks || item.tasks.length === 0) return null; return ( -
- - {open && ( -
-
- - {typeTag} - - {item.id} -
-
-
- Estágio - {curStageName} -
-
- Progresso - {progressVal}/{progressMax} -
-
- Idade - {daysInStage}d -
- {item.claimedBy && ( -
- Responsável - 🤖 {item.claimedBy} -
- )} -
- {progressMax > 0 && } -
- - -
+ + onToggle(task.id, (event.target as HTMLInputElement).checked) + } + /> + + {task.description} + + + ))}
- )} -
+ + ); } -interface TimelineModalProps { - itemId: string; - slug: string; - onClose: () => void; -} - -function TimelineModal({ itemId, slug, onClose }: TimelineModalProps) { - const [entries, setEntries] = useState< - { id: string; timestamp: string; action: string; description: string }[] - >([]); - const [loaded, setLoaded] = useState(false); - - useEffect(() => { - fetch(`/api/log?item=${itemId}&limit=200`) - .then((r) => r.json()) - .then((data) => { - if (data.entries) setEntries(data.entries); - }) - .catch(() => {}) - .finally(() => setLoaded(true)); - }, [itemId]); - - const modalRef = useRef(null); - - useEffect(() => { - modalRef.current?.focus(); - }, []); - - useEffect(() => { - function handleKeyDown(e: KeyboardEvent) { - if (e.key === "Escape") onClose(); - } - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [onClose]); - +function EventLog({ + activities, + loaded, + open, + onOpenChange, +}: { + activities: ActivityEntry[]; + loaded: boolean; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { return ( -
{ - if (e.target === e.currentTarget) onClose(); - }} - > -
-
- -

Timeline: {slug}

- - {loaded ? `${entries.length} eventos` : "carregando…"} - -
-

- Dados: .letra/session-log.json -

- -
- -
- {!loaded ? ( -

- Carregando… -

- ) : entries.length === 0 ? ( -

- Nenhum evento registrado para este item. -

- ) : ( -
-
-
    - {entries.map((entry) => ( -
  • -
    + + + + + + Log de eventos + {loaded && ( + ({activities.length}) + )} + + +
    + {!loaded ? ( +

    Carregando...

    + ) : activities.length === 0 ? ( +

    + Nenhum evento registrado para este item. +

    + ) : ( + activities.map((entry) => ( +
    - + {entry.action} - - {new Date(entry.timestamp).toLocaleString([], { - year: "numeric", - month: "2-digit", - day: "2-digit", + + {new Date(entry.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", - second: "2-digit", })}
    -

    {entry.description}

    -
  • - ))} -
+

+ {entry.description} +

+
+ )) + )}
- )} -
-
-
+ + + + ); } diff --git a/packages/client/src/components/Flow/KanbanBoard.tsx b/packages/client/src/components/Flow/KanbanBoard.tsx new file mode 100644 index 0000000..dcbe1a1 --- /dev/null +++ b/packages/client/src/components/Flow/KanbanBoard.tsx @@ -0,0 +1,557 @@ +import { useState, useCallback, useRef, useEffect } from "react"; +import type { ResolvedSpec, Workflow, Item } from "@letra/types"; +import { Badge, Icon, Button, Progress, Card, CardContent, Tag } from "@letra/ui"; +import { cn } from "../../lib/utils"; +import { computeSlug } from "../../lib/item-utils"; +import { + doneStageIds, + humanGateStageIds, + itemOperationalState, + orderedStages, + stageActionLabel, + stagePresentation, + type ActiveFlowDefinition, + type OperationalState, +} from "../../lib/active-flow"; +import GateDecisionActions from "./GateDecisionActions"; + +interface Props { + workflow: Workflow; + activeFlow: ActiveFlowDefinition | null; + onSelectItem: (id: string) => void; + onDropItem: (itemId: string, targetStageId: string) => void; + onApproveGate?: (gateId: string) => void; + onItemDecided?: () => void; + allowDrop?: (item: Workflow["items"][0], targetStageId: string) => boolean; + specRefreshKey?: number; + onAddItem?: () => void; + filter?: string; + className?: string; +} + +function computeItemState(state: OperationalState): { + key: OperationalState; + label: string; + variant: "amber" | "success" | "info" | "error" | "agent"; + tagVariant: "default" | "agent" | "success" | "info" | "warning" | "danger"; + action: string; + icon: "check-circle" | "clock" | "chevron-right" | "shield" | "circle"; + animate: string; +} { + if (state === "done") + return { + key: state, + label: "Concluído", + variant: "success", + tagVariant: "success", + action: "Sem ação pendente", + icon: "check-circle", + animate: "", + }; + if (state === "waiting") + return { + key: state, + label: "Precisa de atenção", + variant: "amber", + tagVariant: "warning", + action: "Revisar decisão humana", + icon: "clock", + animate: "animate-timeline-dot", + }; + if (state === "blocked") + return { + key: state, + label: "Bloqueado", + variant: "error", + tagVariant: "danger", + action: "Examinar bloqueio", + icon: "shield", + animate: "", + }; + if (state === "running") + return { + key: state, + label: "Em andamento", + variant: "agent", + tagVariant: "agent", + action: "Acompanhar trabalho ativo", + icon: "chevron-right", + animate: "animate-agent-running", + }; + return { + key: state, + label: "Na fila", + variant: "info", + tagVariant: "default", + action: "Aguardando responsável", + icon: "circle", + animate: "", + }; +} + +function emptyStateForFilter(filter: string): { title: string; description: string } { + if (filter === "attention") { + return { + title: "Nenhum trabalho precisa de atenção agora.", + description: "Gates humanos e bloqueios aparecerão aqui quando exigirem revisão.", + }; + } + if (filter === "running") { + return { + title: "Nenhum trabalho está em andamento.", + description: "Quando um item estiver associado a um ator, ele aparecerá neste recorte.", + }; + } + if (filter === "queued") { + return { + title: "Nenhum trabalho está na fila.", + description: + "Itens sem responsável declarado aparecerão aqui antes de entrarem em andamento.", + }; + } + if (filter === "done") { + return { + title: "Nenhum trabalho concluído ainda.", + description: "Itens em estágios finais aparecerão neste recorte.", + }; + } + return { + title: "Nenhum trabalho neste fluxo.", + description: "Crie um item quando houver algo para supervisionar.", + }; +} + +function ItemCard({ + item, + workflow, + activeFlow, + specs, + onClick, + onDragStart, + onDragEnd, +}: { + item: Workflow["items"][0]; + workflow: Workflow; + activeFlow: ActiveFlowDefinition | null; + specs: ResolvedSpec[]; + onClick: () => void; + onDragStart: (e: React.DragEvent) => void; + onDragEnd: (e: React.DragEvent) => void; +}) { + const slug = computeSlug(item, specs, workflow); + const daysInStage = Math.floor((Date.now() - new Date(item.createdAt).getTime()) / 86400000); + const isHumanGate = humanGateStageIds(workflow, activeFlow).has(item.stage); + const state = computeItemState(itemOperationalState(item, workflow, activeFlow)); + + const linkedSpec = item.spec ? specs.find((s) => s.id === item.spec) : null; + const progress = linkedSpec + ? (() => { + const acDone = (linkedSpec.content.match(/-\s+\[x\]/g) || []).length; + const acTotal = (linkedSpec.content.match(/-\s+\[(\s|x)\]/g) || []).length; + return { + done: acDone, + total: acTotal, + label: acTotal > 0 ? `${acDone}/${acTotal} critérios` : "Sem critérios", + source: "Critérios", + }; + })() + : item.tasks && item.tasks.length > 0 + ? { + done: item.tasks.filter((task) => task.done).length, + total: item.tasks.length, + label: `${item.tasks.filter((task) => task.done).length}/${item.tasks.length} tarefas`, + source: "Tarefas", + } + : { + done: 0, + total: 0, + label: "Sem checklist", + source: "Evidência", + }; + + const resolvedStage = orderedStages(workflow, activeFlow).find( + (stage) => stage.id === item.stage, + ); + const agentName = item.claimedBy ?? resolvedStage?.roles[0]?.label ?? "Não atribuído"; + const agentAction = resolvedStage ? stageActionLabel(resolvedStage) : "Processando"; + const isRunning = state.key === "running"; + const hasProgress = progress.total > 0; + const progressValue = progress.total > 0 ? progress.done : 0; + const progressMax = progress.total > 0 ? progress.total : 1; + const progressState = + state.key === "blocked" + ? "error" + : state.key === "waiting" + ? "warning" + : state.key === "done" + ? "complete" + : state.key === "running" + ? "agent" + : "default"; + const title = item.description?.trim() || linkedSpec?.id || slug; + const ageLabel = daysInStage === 0 ? "Hoje no fluxo" : `${daysInStage}d no fluxo`; + const cardBorder = + state.key === "blocked" + ? "var(--color-danger)" + : state.key === "waiting" + ? "var(--color-primary)" + : isRunning + ? "var(--color-agent)" + : isHumanGate + ? "var(--color-success)" + : "var(--color-border)"; + const cardBackground = + state.key === "blocked" + ? "color-mix(in oklch, var(--color-danger) 5%, var(--color-bg-surface))" + : state.key === "waiting" + ? "color-mix(in oklch, var(--color-primary) 6%, var(--color-bg-surface))" + : isRunning + ? "color-mix(in oklch, var(--color-agent) 5%, var(--color-bg-surface))" + : "var(--color-bg-surface)"; + + return ( + { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onClick(); + } + }} + onDragStart={onDragStart} + onDragEnd={onDragEnd} + > + +
+
+ + {item.id} + + + {state.label} + +
+

+ {title} +

+
+ +
+
+ {resolvedStage?.name ?? item.stage} + {ageLabel} +
+

+ {linkedSpec ? `Especificação ${linkedSpec.id}` : `Evidência ${slug}`} +

+
+ +
+ + + {agentName} + + {agentAction} +
+ + {hasProgress ? ( +
+
+ + {progress.source} + + + {progress.label} + +
+ +
+ ) : null} + +
+
+ + + {state.action} + +
+
+
+
+ ); +} + +export default function KanbanBoard({ + workflow, + activeFlow, + onSelectItem, + onDropItem, + onApproveGate, + onItemDecided, + allowDrop, + specRefreshKey, + onAddItem, + filter = "all", + className, +}: Props) { + const [dragOver, setDragOver] = useState(null); + const [draggingId, setDraggingId] = useState(null); + const [specs, setSpecs] = useState([]); + const dragItem = useRef(null); + + const loadSpecs = useCallback(async () => { + try { + const res = await fetch("/api/specs"); + if (!res.ok) return; + const list: ResolvedSpec[] = await res.json(); + setSpecs(list); + } catch { + /* ignore */ + } + }, []); + + useEffect(() => { + loadSpecs(); + }, [loadSpecs]); + + useEffect(() => { + if (specRefreshKey) loadSpecs(); + }, [specRefreshKey, loadSpecs]); + + const handleDragStart = useCallback( + (e: React.DragEvent, itemId: string) => { + const item = workflow.items.find((it) => it.id === itemId); + if (!item) return; + dragItem.current = item; + setDraggingId(itemId); + e.dataTransfer.setData("text/plain", itemId); + e.dataTransfer.dropEffect = "move"; + }, + [workflow.items], + ); + + const handleDragEnd = useCallback(() => { + setDragOver(null); + setDraggingId(null); + dragItem.current = null; + }, []); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + }, []); + + const handleDragEnter = useCallback((stageId: string) => { + setDragOver(stageId); + }, []); + + const handleDragLeave = useCallback(() => { + setDragOver(null); + }, []); + + const handleDrop = useCallback( + (e: React.DragEvent, targetStageId: string) => { + e.preventDefault(); + setDragOver(null); + const itemId = e.dataTransfer.getData("text/plain"); + const item = workflow.items.find((it) => it.id === itemId); + if (!item) return; + if (item.stage === targetStageId) return; + if (allowDrop && !allowDrop(item, targetStageId)) return; + onDropItem(itemId, targetStageId); + }, + [workflow.items, allowDrop, onDropItem], + ); + + const gateStages = humanGateStageIds(workflow, activeFlow); + const doneStages = doneStageIds(workflow, activeFlow); + const stageCols = orderedStages(workflow, activeFlow).map((stage) => ({ + id: stage.id, + label: stage.name, + color: stagePresentation(stage).color, + gate: gateStages.has(stage.id), + })); + + const filterMap: Record boolean> = { + all: () => true, + attention: (it) => { + const state = itemOperationalState(it, workflow, activeFlow); + return state === "waiting" || state === "blocked"; + }, + running: (it) => itemOperationalState(it, workflow, activeFlow) === "running", + queued: (it) => itemOperationalState(it, workflow, activeFlow) === "idle", + done: (it) => doneStages.has(it.stage), + }; + const activeFilter = filterMap[filter] || filterMap.all; + const visibleItems = workflow.items.filter(activeFilter); + const emptyState = emptyStateForFilter(filter); + + function renderColumn(col: (typeof stageCols)[0]) { + const items = workflow.items.filter((it) => it.stage === col.id).filter(activeFilter); + const isOver = dragOver === col.id; + const isHumanGate = col.gate; + const hasAnyItems = workflow.items.some((it) => it.stage === col.id); + + return ( +
+
+
+ {isHumanGate && hasAnyItems ? ( +
+ ) : ( +
+ )} + + {col.label} + + + {workflow.items.filter((it) => it.stage === col.id).length} + +
+
+ +
handleDragEnter(col.id)} + onDragLeave={handleDragLeave} + onDrop={(e) => handleDrop(e, col.id)} + > + {items.length === 0 && !isOver && ( +
+ Vazio +
+ )} + + {items.map((item) => ( + onSelectItem(item.id)} + onDragStart={(e) => handleDragStart(e, item.id)} + onDragEnd={handleDragEnd} + /> + ))} + + {isHumanGate && hasAnyItems && ( +
+
+ + + Aprovação necessária + +
+ {items.map((item) => ( +
+ +
+ ))} +
+ )} +
+
+ ); + } + + return ( +
+ {visibleItems.length === 0 ? ( +
+
+ +
+
+

+ {emptyState.title} +

+

+ {emptyState.description} +

+
+
+ ) : ( +
+ {stageCols.map(renderColumn)} +
+ )} + {onAddItem && ( +
+ +
+ )} +
+ ); +} diff --git a/packages/client/src/components/Harness/HarnessViewer.tsx b/packages/client/src/components/Harness/HarnessViewer.tsx new file mode 100644 index 0000000..8f20ce5 --- /dev/null +++ b/packages/client/src/components/Harness/HarnessViewer.tsx @@ -0,0 +1,439 @@ +import { useEffect, useState, useCallback } from "react"; +import { + Collapsible, + CollapsibleTrigger, + CollapsibleContent, + Button, + Icon, + Markdown, +} from "@letra/ui"; + +interface LayerFile { + path: string; + content: string; +} + +interface FocusData { + specName?: string; + content?: string; +} + +interface L2Data { + focus: FocusData | null; + spec: LayerFile | null; +} + +interface AlertData { + id: string; + severity: string; + message: string; +} + +interface L3Data { + alerts: AlertData[]; + alertCount: number; + sessionEventCount: number; +} + +interface L4Data { + constraintsContent: string; + glossaryContent: string; +} + +interface HarnessData { + layers: { + l1: LayerFile[]; + l2: L2Data; + l3: L3Data; + l4: L4Data; + }; +} + +const LAYER_INFO: Record = { + l1: { title: "Contexto Principal", icon: "book", desc: "Arquivos fundamentais do projeto" }, + l2: { title: "Foco e Especificação", icon: "target", desc: "Foco atual e especificação ativa" }, + l3: { title: "Sinais e Estado", icon: "activity", desc: "Alertas, eventos e estado da sessão" }, + l4: { title: "Restrições e Regras", icon: "shield", desc: "Regras compiladas do sistema" }, +}; + +function useCopyToClipboard() { + const [copied, setCopied] = useState(false); + + const copy = useCallback(async (text: string) => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // fallback + } + }, []); + + return { copy, copied }; +} + +function LayerCard({ + layerKey, + children, + defaultOpen = false, +}: { + layerKey: string; + children: React.ReactNode; + defaultOpen?: boolean; +}) { + const [open, setOpen] = useState(defaultOpen); + const info = LAYER_INFO[layerKey]; + + return ( + + + +
+
{info.title}
+
+ {info.desc} +
+
+ +
+ +
{children}
+
+
+ ); +} + +function L1Content({ files }: { files: LayerFile[] }) { + return ( +
+ {files.map((f) => ( +
+
+ + + {f.path} + +
+ {f.content ? ( +
+ +
+ ) : ( +

+ (vazio) +

+ )} +
+ ))} +
+ ); +} + +function L2Content({ l2 }: { l2: L2Data }) { + return ( +
+ {l2.focus && ( +
+
+ + + .letra/focus.md + + {l2.focus.specName && ( + + {l2.focus.specName} + + )} +
+ {l2.focus.content ? ( +
+ +
+ ) : ( +

+ (sem conteúdo de foco) +

+ )} +
+ )} + {l2.spec && ( +
+
+ + + {l2.spec.path} + +
+
+ +
+
+ )} + {!l2.focus && !l2.spec && ( +

+ Nenhum foco ou spec ativo no momento. +

+ )} +
+ ); +} + +function L3Content({ l3 }: { l3: L3Data }) { + return ( +
+
+
+ + {l3.alertCount} alerta(s) ativo(s) +
+
+ + {l3.sessionEventCount} evento(s) na sessão +
+
+ + {l3.alerts.length > 0 && ( +
+

+ Alertas recentes +

+
+ {l3.alerts.map((a) => ( +
+ +
+
{a.id}
+
+ {a.message} +
+
+
+ ))} +
+
+ )} +
+ ); +} + +function L4Content({ l4 }: { l4: L4Data }) { + return ( +
+ {l4.constraintsContent && ( +
+
+ + + .letra/constraints.md + +
+
+ +
+
+ )} + {l4.glossaryContent && ( +
+
+ + + .letra/glossary.md + +
+
+ +
+
+ )} + {!l4.constraintsContent && !l4.glossaryContent && ( +

+ Nenhuma regra ou restrição adicional. +

+ )} +
+ ); +} + +export default function HarnessViewer() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const { copy, copied } = useCopyToClipboard(); + + useEffect(() => { + fetch("/api/harness-viewer") + .then((r) => (r.ok ? r.json() : Promise.reject())) + .then((d: HarnessData) => { + setData(d); + setLoading(false); + }) + .catch(() => { + setLoading(false); + }); + }, []); + + if (loading) { + return ( +
+

+ Carregando... +

+
+ ); + } + + if (!data) { + return ( +
+

+ Não foi possível carregar os dados da configuração. +

+
+ ); + } + + return ( +
+
+
+

Composição da Configuração

+

+ Camadas do prompt compilado para o agente +

+
+ +
+ +
+ + + + + + + + + + + + +
+
+ ); +} diff --git a/packages/client/src/components/Header/Header.test.tsx b/packages/client/src/components/Header/Header.test.tsx new file mode 100644 index 0000000..0e4bb1d --- /dev/null +++ b/packages/client/src/components/Header/Header.test.tsx @@ -0,0 +1,157 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeAll, describe, expect, it, vi } from "vitest"; +import { SidebarProvider } from "@letra/ui"; +import Header from "./Header"; +import type { WorkspaceData } from "../Workspaces/WorkspacesView"; + +const workspace: WorkspaceData = { + id: "workspace-1", + name: "Letra", + slug: "letra", + createdAt: "2026-07-04T00:00:00.000Z", + directories: ["C:/Workspace/letra/packages/client", "C:/Workspace/letra/packages/cli"], +}; + +const anotherWorkspace: WorkspaceData = { + id: "workspace-2", + name: "Sandbox", + slug: "sandbox", + createdAt: "2026-07-05T00:00:00.000Z", + directories: ["C:/Workspace/sandbox/packages/app"], +}; + +beforeAll(() => { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation(() => ({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + }); +}); + +function renderHeader(overrides: Partial> = {}) { + const props: React.ComponentProps = { + theme: "dark", + onThemeChange: vi.fn(), + workspaces: [workspace], + activeWorkspace: workspace, + activeDirectory: workspace.directories?.[0], + onWorkspaceChange: vi.fn(), + onDirectoryChange: vi.fn(), + onOpenHistory: vi.fn(), + health: { activeAlerts: 8, criticalAlerts: 2 }, + gateCount: 2, + ...overrides, + }; + + return { + props, + ...render( + +
+ , + ), + }; +} + +describe("Header", () => { + it("exposes global navigation, context and actionable signals", () => { + renderHeader(); + + expect(screen.getByRole("button", { name: "Recolher menu global" })).toBeTruthy(); + expect( + screen.getByRole("button", { name: "Contexto atual: workspace Letra, escopo client" }), + ).toBeTruthy(); + expect( + screen.getByRole("button", { + name: "Abrir supervisao: 2 decisoes pendentes, 8 sinais ativos, 2 bloqueiam conclusao", + }), + ).toBeTruthy(); + expect(screen.queryByRole("status", { name: "2 decisoes pendentes" })).toBeNull(); + }); + + it("changes the active scope without changing the workspace", async () => { + const user = userEvent.setup(); + const { props } = renderHeader(); + + await user.click( + screen.getByRole("button", { name: "Contexto atual: workspace Letra, escopo client" }), + ); + await user.click(screen.getByRole("option", { name: "Todo o workspace" })); + + expect(props.onDirectoryChange).toHaveBeenCalledWith(null); + expect(props.onWorkspaceChange).not.toHaveBeenCalled(); + }); + + it("changes the active workspace from the same context menu", async () => { + const user = userEvent.setup(); + const { props } = renderHeader({ + workspaces: [workspace, anotherWorkspace], + }); + + await user.click( + screen.getByRole("button", { name: "Contexto atual: workspace Letra, escopo client" }), + ); + await user.click(screen.getByRole("option", { name: "Sandbox" })); + + expect(props.onWorkspaceChange).toHaveBeenCalledWith(anotherWorkspace); + expect(props.onDirectoryChange).not.toHaveBeenCalled(); + }); + + it("keeps zero and unavailable states out of the global header", () => { + renderHeader({ health: null, gateCount: 0 }); + + expect(screen.queryByRole("button", { name: /saude/i })).toBeNull(); + expect(screen.queryByRole("status", { name: "Nenhuma decisao pendente" })).toBeNull(); + }); + + it("opens supervision from the health signal in the header", async () => { + const user = userEvent.setup(); + const onOpenHealthCenter = vi.fn(); + renderHeader({ onOpenHealthCenter }); + + await user.click( + screen.getByRole("button", { + name: "Abrir supervisao: 2 decisoes pendentes, 8 sinais ativos, 2 bloqueiam conclusao", + }), + ); + + expect(onOpenHealthCenter).toHaveBeenCalledOnce(); + }); + + it("opens workspace settings from the global header", async () => { + const user = userEvent.setup(); + const onOpenWorkspaceSettings = vi.fn(); + renderHeader({ onOpenWorkspaceSettings }); + + await user.click(screen.getByRole("button", { name: "Abrir configurações do workspace" })); + + expect(onOpenWorkspaceSettings).toHaveBeenCalledOnce(); + }); + + it("keeps history and theme as global utilities without pending-state copy", () => { + renderHeader(); + + expect(screen.queryByRole("button", { name: "Historico de correcoes" })).toBeNull(); + expect(screen.getByRole("button", { name: "Alternar para tema claro" })).toBeTruthy(); + }); + + it("does not expose diagnostic corrections as a global header metric", () => { + renderHeader({ + activeWorkspace: null, + activeDirectory: null, + health: null, + gateCount: 0, + }); + + expect( + screen.getByRole("button", { + name: "Contexto atual: workspace Escolha um workspace, escopo Todo o workspace", + }), + ).toBeTruthy(); + expect(screen.queryByRole("button", { name: /correc/ })).toBeNull(); + }); +}); diff --git a/packages/client/src/components/Header/Header.tsx b/packages/client/src/components/Header/Header.tsx index d93d361..407f647 100644 --- a/packages/client/src/components/Header/Header.tsx +++ b/packages/client/src/components/Header/Header.tsx @@ -1,111 +1,68 @@ -import { Badge, Button, Icon } from "@letra/ui"; -import DiagnosticsIndicator from "../Diagnostics/DiagnosticsIndicator"; - -interface Suggestion { - id: string; - title: string; - description: string; - type: string; - detector: string; -} +import { GlobalHeader, useSidebar } from "@letra/ui"; +import type { WorkspaceData } from "../Workspaces/WorkspacesView"; interface Props { - name: string; - language?: string; + description?: string; theme: "light" | "dark"; onThemeChange: (t: "light" | "dark") => void; - suggestions?: Suggestion[]; - onApplySuggestion?: (s: Suggestion) => void; onOpenHistory?: () => void; - claimedCount?: number; + gateCount?: number; + activeDirectory?: string | null; + workspaces: WorkspaceData[]; + activeWorkspace?: WorkspaceData | null; + onWorkspaceChange?: (ws: WorkspaceData) => void; + onDirectoryChange?: (dir: string | null) => void; + health?: { activeAlerts: number; criticalAlerts: number } | null; + onOpenHealthCenter?: () => void; + onOpenWorkspaceSettings?: () => void; +} + +function directoryLabel(path?: string | null) { + if (!path) return "Todo o workspace"; + return path.split(/[/\\]/).pop() || path; } export default function Header({ - name, - language, theme, onThemeChange, - suggestions = [], - onApplySuggestion, - onOpenHistory, - claimedCount = 0, + gateCount = 0, + activeDirectory, + workspaces, + activeWorkspace, + onWorkspaceChange, + onDirectoryChange, + health, + onOpenHealthCenter, + onOpenWorkspaceSettings, }: Props) { + const { open: sidebarOpen, setOpen: setSidebarOpen } = useSidebar(); + const directories = activeWorkspace?.directories ?? []; + return ( -
setSidebarOpen(!sidebarOpen)} + workspaces={workspaces.map((workspace) => ({ + id: workspace.slug, + name: workspace.name, + }))} + activeWorkspaceId={activeWorkspace?.slug ?? null} + scopes={directories.map((directory) => ({ + id: directory, + label: directoryLabel(directory), + }))} + activeScopeId={activeDirectory ?? null} + onWorkspaceChange={(slug) => { + const workspace = workspaces.find((entry) => entry.slug === slug); + if (workspace) onWorkspaceChange?.(workspace); }} - > -
-
- - L - -
-

- Letra. -

- - Direção e processo de pensamento para Modelos de Linguagem. - - {language && ( - - {language} - - )} -
- -
- {suggestions.length > 0 && onApplySuggestion && onOpenHistory && ( - - )} - - - - - {claimedCount > 0 ? ( - - 🤖 em andamento - - ) : ( - idle - )} -
-
+ onScopeChange={onDirectoryChange} + pendingDecisions={gateCount} + health={health} + onOpenHealthCenter={onOpenHealthCenter} + onOpenSettings={activeWorkspace ? onOpenWorkspaceSettings : undefined} + theme={theme} + onThemeChange={onThemeChange} + /> ); } diff --git a/packages/client/src/components/Header/LogoDiamond.tsx b/packages/client/src/components/Header/LogoDiamond.tsx new file mode 100644 index 0000000..39542f7 --- /dev/null +++ b/packages/client/src/components/Header/LogoDiamond.tsx @@ -0,0 +1,33 @@ +interface Props { + size?: number; +} + +export default function LogoLGraph({ size = 64 }: Props) { + return ( + + {/* Traço vertical mais alto com nó intermediário */} + + + + + + + {/* Ponto final maior, adaptável ao tema */} + + + ); +} diff --git a/packages/client/src/components/Home/HomeView.test.tsx b/packages/client/src/components/Home/HomeView.test.tsx new file mode 100644 index 0000000..7993817 --- /dev/null +++ b/packages/client/src/components/Home/HomeView.test.tsx @@ -0,0 +1,141 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Workflow } from "@letra/types"; +import HomeView from "./HomeView"; + +const workflow: Workflow = { + version: "1", + name: "Letra", + createdAt: "2026-07-01T00:00:00.000Z", + updatedAt: "2026-07-25T00:00:00.000Z", + tools: [], + stages: [{ id: "review", name: "Review", order: 1, zone: "doing" }], + items: [ + { + id: "ITEM-29", + description: "Central de Diagnosticos e Alertas de Saude do Letra", + stage: "review", + createdAt: "2026-07-01T00:00:00.000Z", + spec: "diagnostics-hub", + }, + ], + primaryItemId: "ITEM-29", +}; + +function mockFetch() { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url === "/api/focus") { + return Response.json({ active: true, spec: "diagnostics-hub" }); + } + if (url === "/api/health") { + return Response.json({ + summary: { novo: 1, ciente: 0, resolvido: 0, descartado: 0 }, + active: [ + { + id: "snapshot-bloat", + title: "Snapshot payload excede 50KB", + source: "snapshot-bloat", + severity: "alta", + status: "novo", + detectedAt: "2026-07-25T10:00:00.000Z", + }, + ], + entries: [], + }); + } + if (url.startsWith("/api/diagnostics/snapshots")) { + return Response.json({ + snapshots: [ + { + id: "snap-1", + timestamp: "2026-07-25T10:02:00.000Z", + diagnosticId: "snapshot-bloat", + diagnosticTitle: "Snapshot payload excede 50KB", + files: [ + { + path: ".letra/snapshots/snap-1.json", + before: "before", + after: "after", + }, + ], + }, + ], + }); + } + if (url.startsWith("/api/log")) { + return Response.json({ entries: [] }); + } + return Response.json({}); + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("HomeView diagnostics center", () => { + it("opens an accessible signal detail sheet with bounded diagnostic evidence", async () => { + const user = userEvent.setup(); + mockFetch(); + + render(); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "Saude do workspace" })).toBeTruthy(); + }); + + await user.click(screen.getByRole("button", { name: "Detalhes" })); + + const dialog = screen.getByRole("dialog", { name: "Snapshot payload excede 50KB" }); + expect(dialog.getAttribute("aria-describedby")).toBe("signal-sheet-description"); + expect(screen.getByRole("heading", { name: "Comparacao de drift" })).toBeTruthy(); + expect(screen.getByText(".letra/snapshots/snap-1.json")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Fechar detalhes do sinal" })).toBeTruthy(); + }); + + it("uses supervised health actions without leaving the diagnostics center", async () => { + const user = userEvent.setup(); + const fetchMock = mockFetch(); + + render(); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "Saude do workspace" })).toBeTruthy(); + }); + + await user.click(screen.getByRole("button", { name: "Verificar agora" })); + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + "/api/health/scan", + expect.objectContaining({ method: "POST" }), + ); + }); + + await user.click(screen.getByRole("button", { name: "Detalhes" })); + await user.click(screen.getByRole("button", { name: "Acompanhar" })); + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + "/api/health/ack", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ id: "snapshot-bloat" }), + }), + ); + }); + + await user.click(screen.getByRole("button", { name: "Descartar" })); + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + "/api/health/dismiss", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ id: "snapshot-bloat" }), + }), + ); + }); + }); +}); diff --git a/packages/client/src/components/Home/HomeView.tsx b/packages/client/src/components/Home/HomeView.tsx index 4199f07..45a4c42 100644 --- a/packages/client/src/components/Home/HomeView.tsx +++ b/packages/client/src/components/Home/HomeView.tsx @@ -1,675 +1,1002 @@ -import { useEffect, useState } from "react"; -import type { Workflow, ResolvedSpec } from "@letra/types"; -import { Card, CardContent, Badge, Icon } from "@letra/ui"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { Item, Workflow } from "@letra/types"; +import { + ActionPanel, + Badge, + Button, + Card, + CardContent, + CardHeader, + ErrorBanner, + Icon, + List, + ListItem, + MetadataRow, + Collapsible, + CollapsibleContent, + CollapsibleTrigger, + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, + SkeletonCard, + Tag, +} from "@letra/ui"; +import type { ActiveFlowDefinition } from "../../lib/active-flow"; +import { humanGateStageIds, orderedStages, stageAgentLabel } from "../../lib/active-flow"; +import SupervisionInbox, { + type ActivityEvent, + type AttentionSignal, + type FocusedWork, + type PendingDecision, +} from "./SupervisionInbox"; interface Props { workflow: Workflow; - onSelectItem: (id: string) => void; - onTabChange?: (tab: "specs" | "flow") => void; -} - -interface Decision { - name: string; - content: string; + activeFlow: ActiveFlowDefinition | null; + onTabChange?: (tab: "work" | "activity") => void; } interface FocusData { active: boolean; spec?: string; - content?: string; + itemId?: string; } -function daysSince(dateStr: string): number { - return Math.floor((Date.now() - new Date(dateStr).getTime()) / (1000 * 60 * 60 * 24)); +interface HealthEntry { + id?: string; + type?: string; + title?: string; + what?: string; + source?: string; + where?: string; + severity?: string; + status?: "novo" | "ciente" | "descartado" | "resolvido"; + detectedAt?: string; + resolvedAt?: string | null; + dismissedAt?: string | null; + dismissReason?: string | null; + acknowledgedAt?: string | null; } -function daysSinceFile(name: string): number { - const m = name.match(/^(\d{4}-\d{2}-\d{2})/); - if (m) return daysSince(`${m[1]}T00:00:00`); - return 0; +interface HealthResponse { + summary?: { + novo?: number; + ciente?: number; + resolvido?: number; + descartado?: number; + }; + entries?: HealthEntry[]; + active?: HealthEntry[]; } -function resolveTitle(content: string): string { - const m = content.match(/^#\s+(.+)/m); - return m ? m[1] : ""; +interface DiagnosticSnapshot { + id: string; + timestamp: string; + diagnosticId: string; + diagnosticTitle: string; + files: { path: string; before: string; after: string }[]; } -function formatDate(name: string): string { - const m = name.match(/^(\d{4}-\d{2}-\d{2})/); - if (m) { - const [y, mo, d] = m[1].split("-"); - return `${d}/${mo}/${y}`; - } - return name.replace(/\.md$/, "").replace(/-/g, " "); +interface DiagnosticSnapshotsResponse { + snapshots?: DiagnosticSnapshot[]; } -function InfoIcon({ tip }: { tip: string }) { +interface HealthSummaryView { + novo: number; + ciente: number; + resolvido: number; + descartado: number; +} + +interface LogEntry { + id: string; + timestamp: string; + action: string; + description: string; + itemId?: string | null; +} + +interface LogResponse { + entries?: LogEntry[]; +} + +function DashboardSkeleton() { return ( - - -
- {tip} +
+ +
+ +
- + +
); } -function cn(...classes: (string | false | null | undefined)[]): string { - return classes.filter(Boolean).join(" "); +function daysSince(dateStr: string): number { + return Math.floor((Date.now() - new Date(dateStr).getTime()) / (1000 * 60 * 60 * 24)); } -export default function HomeView({ workflow, onSelectItem, onTabChange }: Props) { - const [specs, setSpecs] = useState([]); - const [focus, setFocus] = useState(null); - const [decisions, setDecisions] = useState([]); - const [localStages, setLocalStages] = useState(workflow.stages); - const [dragStageIdx, setDragStageIdx] = useState(null); - const [dragItemId, setDragItemId] = useState(null); - const [dragOverStage, setDragOverStage] = useState(null); +function formatAgeLabel(createdAt: string): string { + const days = daysSince(createdAt); + if (days === 0) return "Hoje"; + if (days === 1) return "1 dia"; + return `${days} dias`; +} - useEffect(() => { - setLocalStages(workflow.stages); - }, [workflow.stages]); +function formatSince(timestamp: string): string { + const diff = Date.now() - new Date(timestamp).getTime(); + const hours = Math.floor(diff / (1000 * 60 * 60)); + if (hours < 1) { + const minutes = Math.max(1, Math.floor(diff / (1000 * 60))); + return `há ${minutes}min`; + } + if (hours < 24) return `há ${hours}h`; + const days = Math.floor(hours / 24); + return `há ${days}d`; +} - useEffect(() => { - fetch("/api/specs") - .then((r) => r.json()) - .then((data) => { - if (Array.isArray(data)) setSpecs(data); - }) - .catch(() => {}); - fetch("/api/focus") - .then((r) => r.json()) - .then((data) => setFocus(data)) - .catch(() => {}); - fetch("/api/context?file=decisions") - .then((r) => r.json()) - .then((data) => { - if (Array.isArray(data)) setDecisions(data); - }) - .catch(() => {}); - }, []); - - const totalItems = workflow.items.length; - const doingItems = workflow.items.filter((it) => { - const st = workflow.stages.find((s) => s.id === it.stage); - return ( - st?.zone === "doing" || - (!st?.zone && - workflow.stages.indexOf(st!) > 0 && - workflow.stages.indexOf(st!) < workflow.stages.length - 1) - ); - }).length; - const doneItems = workflow.items.filter((it) => { - const st = workflow.stages.find((s) => s.id === it.stage); - return ( - st?.zone === "done" || - (!st?.zone && workflow.stages.indexOf(st!) === workflow.stages.length - 1) - ); - }).length; - const staleItems = workflow.items.filter((it) => daysSince(it.createdAt) > 7).length; - - const specValid = specs.filter( - (s) => - /## Outcome/.test(s.content) && - /## Constraints/.test(s.content) && - /## Acceptance Criteria/.test(s.content), - ).length; - const specNoDate = specs.filter( - (s) => !/> Updated:\s*\d{4}-\d{2}-\d{2}/.test(s.content), - ).length; - const specDrift = specs.filter((s) => { - const m = s.content.match(/> Updated:\s*(\d{4}-\d{2}-\d{2})/); - return m ? daysSince(m[1]) > 7 : false; - }).length; - - const recentDecisions = decisions.slice(0, 4); - - function handleStageDragStart(idx: number) { - setDragStageIdx(idx); +function severityLabel(value?: string): "baixa" | "media" | "alta" { + if (!value) return "media"; + const normalized = value.toLowerCase(); + if (normalized.includes("crit") || normalized.includes("alta") || normalized.includes("high")) { + return "alta"; + } + if (normalized.includes("low") || normalized.includes("baixa")) { + return "baixa"; } + return "media"; +} + +function signalImpact(severity: "baixa" | "media" | "alta") { + if (severity === "alta") return "bloqueia conclusao"; + if (severity === "media") return "pede investigacao"; + return "pode aguardar"; +} + +function signalNextAction(status?: AttentionSignal["status"]) { + if (status === "ciente") return "acompanhar"; + if (status === "resolvido") return "ver evidencia"; + if (status === "descartado") return "ver justificativa"; + return "investigar"; +} - function handleStageDragOver(e: React.DragEvent, idx: number) { - e.preventDefault(); - if (dragStageIdx === null || dragStageIdx === idx) return; - setLocalStages((prev) => { - const next = [...prev]; - const [moved] = next.splice(dragStageIdx, 1); - next.splice(idx, 0, moved); - return next.map((s, i) => ({ ...s, order: i })); +function formatEvidenceDate(timestamp?: string | null) { + if (!timestamp) return "nao registrado"; + try { + return new Date(timestamp).toLocaleString("pt-BR", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", }); - setDragStageIdx(idx); + } catch { + return timestamp; } +} - function handleStageDragEnd() { - if (dragStageIdx !== null) { - const reordered = localStages.map((s, i) => ({ ...s, order: i })); - fetch("/api/workflow", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ stages: reordered }), - }).catch(() => {}); - } - setDragStageIdx(null); - } +function summarizeHealth(health: HealthResponse): HealthSummaryView { + const entries = health.entries ?? []; + return { + novo: health.summary?.novo ?? entries.filter((entry) => entry.status === "novo").length, + ciente: + health.summary?.ciente ?? entries.filter((entry) => entry.status === "ciente").length, + resolvido: + health.summary?.resolvido ?? + entries.filter((entry) => entry.status === "resolvido").length, + descartado: + health.summary?.descartado ?? + entries.filter((entry) => entry.status === "descartado").length, + }; +} - function handleItemDragStart(e: React.DragEvent, itemId: string) { - e.dataTransfer.setData("text/plain", itemId); - e.dataTransfer.effectAllowed = "move"; - setDragItemId(itemId); - } +function findRelatedSnapshot(entry: HealthEntry, snapshots: DiagnosticSnapshot[]) { + const candidates = [entry.id, entry.type, entry.source, entry.where, entry.title, entry.what] + .filter(Boolean) + .map((value) => String(value).toLowerCase()); + + return snapshots.find((snapshot) => { + const diagnosticId = snapshot.diagnosticId.toLowerCase(); + const diagnosticTitle = snapshot.diagnosticTitle.toLowerCase(); + return candidates.some((candidate) => { + if (!candidate) return false; + return ( + diagnosticId === candidate || + diagnosticId.includes(candidate) || + diagnosticTitle.includes(candidate) + ); + }); + }); +} + +function normalizeSnapshots(data: DiagnosticSnapshotsResponse): DiagnosticSnapshot[] { + return (data.snapshots ?? []).filter((snapshot) => Array.isArray(snapshot.files)); +} - function handleItemDragEnd() { - setDragItemId(null); - setDragOverStage(null); +function buildDiffPreview(before: string, after: string, maxLines = 16): string[] { + const beforeLines = before.split(/\r?\n/); + const afterLines = after.split(/\r?\n/); + let start = 0; + while ( + start < beforeLines.length && + start < afterLines.length && + beforeLines[start] === afterLines[start] + ) { + start += 1; } - function handleStageDrop(e: React.DragEvent, targetStageId: string) { - e.preventDefault(); - setDragOverStage(null); - const itemId = e.dataTransfer.getData("text/plain"); - if (!itemId) return; - const item = workflow.items.find((it) => it.id === itemId); - if (!item || item.stage === targetStageId) return; - fetch(`/api/items/${itemId}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ stage: targetStageId }), - }).then(() => { - onSelectItem(itemId); - }); + let beforeEnd = beforeLines.length - 1; + let afterEnd = afterLines.length - 1; + while ( + beforeEnd >= start && + afterEnd >= start && + beforeLines[beforeEnd] === afterLines[afterEnd] + ) { + beforeEnd -= 1; + afterEnd -= 1; } + const removed = beforeLines.slice(start, beforeEnd + 1).slice(0, Math.floor(maxLines / 2)); + const added = afterLines.slice(start, afterEnd + 1).slice(0, maxLines - removed.length); + const preview = [ + ...removed.map((line) => `- ${line || "(linha vazia)"}`), + ...added.map((line) => `+ ${line || "(linha vazia)"}`), + ]; + return preview.length > 0 ? preview : ["Sem diferenca textual entre before/after."]; +} + +function mapHealthSignals( + health: HealthResponse, + snapshots: DiagnosticSnapshot[] = [], +): AttentionSignal[] { + return (health.active ?? []).slice(0, 4).map((entry, index) => { + const severity = severityLabel(entry.severity); + return { + id: entry.id ?? `health-${index}`, + title: entry.title ?? entry.what ?? "Alerta ativo", + source: entry.source ?? entry.where ?? "health", + severity, + status: entry.status ?? "novo", + detectedAt: entry.detectedAt, + impact: signalImpact(severity), + nextAction: signalNextAction(entry.status), + technicalType: entry.type, + relatedSnapshot: findRelatedSnapshot(entry, snapshots), + }; + }); +} + +function GovernanceSummary({ + spec, + primaryItem, + primaryStageName, +}: { + spec: string; + primaryItem?: Item; + primaryStageName?: string; +}) { return ( -
-
-
-
-

{workflow.name}

-

- {workflow.description || "AI Memory & Spec Hub"} -

-
+ } + title="Governança do workspace" + description="Contexto canônico usado para interpretar decisões, evidências e trabalho em foco nesta sessão." + meta={ + <> + + {spec} + + contrato ativo + + } + > + , + }, + ...(primaryItem + ? [ + { + label: "Item vinculado", + value: ( + + {primaryItem.id} + + ), + icon: , + }, + ] + : []), + ...(primaryStageName + ? [ + { + label: "Estágio", + value: {primaryStageName}, + icon: , + }, + ] + : []), + ]} + /> + + ); +} -
- - - Specs - - - {specs.length} - - {specValid} válidas · {specs.length - specValid} incompletas - - - - - Drift - - - 0 - ? "var(--warning)" - : specNoDate > 0 - ? "var(--muted-foreground)" - : "var(--success)", - }} - > - {specDrift > 0 - ? specDrift - : specNoDate > 0 - ? `${specNoDate}?` - : "0"} - - - {specDrift > 0 - ? `${specDrift} desatualizadas há 7d+` - : specNoDate > 0 - ? `${specNoDate} sem data` - : "todas atualizadas"} - - - - - Foco - - - {focus?.active ? ( - - {focus.spec} - - ) : ( - - — - - )} - - {focus?.active - ? focus.content - ?.split("\n") - .find((l) => l.includes("**Outcome**")) - ?.replace(/\*\*/g, "") - .replace("Outcome:", "") - .trim() || "Em foco" - : "nenhum foco definido"} - - - - - Health - - - 0 ? "warning" : "success"}> - {staleItems > 0 ? `${staleItems} stale` : "healthy"} - - - {doneItems} done (7d) · {doingItems} in progress - - -
+function ItemSheet({ + item, + stageName, + open, + onOpenChange, +}: { + item: Item | null; + stageName: string | null; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + if (!item) return null; -
-

- Pipeline - - (arraste stages para reordenar, itens entre colunas para mover) - - -

-
- {localStages.map((stage, i) => { - const stageItems = workflow.items.filter( - (it) => it.stage === stage.id, - ); - const isDoing = - stage.zone === "doing" || - (i > 0 && i < workflow.stages.length - 1); - const isOver = dragOverStage === stage.id; - const miniItems = stageItems.slice(0, 3); - return ( - handleStageDragStart(i)} - onDragOver={(e) => { - e.preventDefault(); - if (dragItemId) { - setDragOverStage(stage.id); - } else { - handleStageDragOver(e, i); - } - }} - onDragEnd={handleStageDragEnd} - onDrop={(e) => { - if (dragItemId) { - handleStageDrop(e, stage.id); - } - }} - className={cn( - "p-3 transition-all duration-200 border-muted/60", - "hover:shadow-sm hover:-translate-y-0.5", - isDoing && "ring-1 ring-primary/10", - dragStageIdx === i && "opacity-40", - isOver && dragItemId && "ring-2 ring-primary/40", - )} - style={ - stage.color - ? { borderTop: `2px solid ${stage.color}80` } - : undefined - } - > - -
- - {stage.color && ( - - )} - - {stage.name} - - - {stageItems.length} - -
-
- {miniItems.length === 0 && ( -
- - — - -
- )} - {miniItems.map((it) => { - const days = daysSince(it.createdAt); - return ( -
- handleItemDragStart(e, it.id) + const itemMetadata = [ + { + label: "Estágio", + value: {stageName ?? item.stage}, + icon: , + }, + { + label: "Idade", + value: formatAgeLabel(item.createdAt), + icon: , + }, + ...(item.spec + ? [ + { + label: "Spec", + value: item.spec, + icon: , + }, + ] + : []), + ...(item.claimedBy + ? [ + { + label: "Responsável", + value: item.claimedBy, + icon: , + }, + ] + : []), + ]; + + return ( + + + + {item.id} + +
+ + +
+
+
+ + {item.id} + + {item.claimedBy ? {item.claimedBy} : null} +
+

+ {item.description || "Sem descrição"} +

+
+ + {item.tasks && item.tasks.length > 0 ? ( +
+
+ +

Tasks

+ + {item.tasks.length} + +
+ + {item.tasks.map((task) => ( + onTabChange?.("flow")} - className={cn( - "flex items-center gap-1 px-1.5 py-1 rounded cursor-grab active:cursor-grabbing text-xs transition-all border", - "hover:shadow-sm hover:-translate-y-0.5", - dragItemId === it.id && - "opacity-40", - stage.color - ? "border-transparent" - : "border-border/50", - )} - style={{ - background: stage.color - ? `${stage.color}12` - : "var(--muted)", - borderLeft: stage.color - ? `2px solid ${stage.color}60` - : undefined, - }} + size={18} + /> + } + title={task.description} + meta={ + - - - {it.id} - - - {days}d - -
- ); - })} - {stageItems.length > 3 && ( - - )} -
-
-
- ); - })} + {task.done ? "feito" : "pendente"} + + } + /> + ))} + +
+ ) : null} +
+ + +
+ + + + + + ); +} + +function SignalSheet({ + signal, + open, + onOpenChange, + onAcknowledge, + onDismiss, + onScan, + actionBusy, + actionMessage, +}: { + signal: AttentionSignal | null; + open: boolean; + onOpenChange: (open: boolean) => void; + onAcknowledge: (signal: AttentionSignal) => void; + onDismiss: (signal: AttentionSignal) => void; + onScan: () => void; + actionBusy?: boolean; + actionMessage?: string; +}) { + if (!signal) return null; + const diffFile = signal.relatedSnapshot?.files[0]; + const diffPreview = diffFile ? buildDiffPreview(diffFile.before, diffFile.after) : []; + + return ( + + + +
+
+ + {signal.impact ?? "pede investigacao"} + + + {signal.status === "ciente" + ? "em acompanhamento" + : (signal.status ?? "novo")} +
+ + {signal.title} + + + Evidencia do workspace para entender o impacto antes de agir. +
+ onOpenChange(false)} + > + + +
+
+ + +
+ +

Leitura de supervisao

+
+
+ + , + }, + { + label: "Acao segura", + value: signal.nextAction ?? "investigar", + icon: , + }, + { + label: "Detectado em", + value: formatEvidenceDate(signal.detectedAt), + icon: , + }, + ]} + /> + +
-
-
-
-
-

- Specs Recentes -

- -
-
- {[...specs] - .sort((a, b) => { - const da = a.content.match( - /> Updated:\s*(\d{4}-\d{2}-\d{2})/, - ); - const db = b.content.match( - /> Updated:\s*(\d{4}-\d{2}-\d{2})/, - ); - const ta = da ? new Date(da[1]).getTime() : 0; - const tb = db ? new Date(db[1]).getTime() : 0; - return tb - ta; - }) - .slice(0, 4) - .map((spec) => { - const hasOutcome = /## Outcome/.test(spec.content); - const hasAC = /## Acceptance Criteria/.test( - spec.content, - ); - const acDone = (spec.content.match(/-\s+\[x\]/g) || []) - .length; - const acTotal = ( - spec.content.match(/-\s+\[(\s|x)\]/g) || [] - ).length; - const pct = - acTotal > 0 - ? Math.round((acDone / acTotal) * 100) - : 0; - return ( - - -
- - {spec.id} - - - {pct}% - -
-
- - - {acTotal} ACs - - - ·{" "} - {hasOutcome - ? "completa" - : "rascunho"} - -
-
-
- ); - })} - {specs.length === 0 && ( -

- Nenhuma spec ainda. -

- )} -
+ + +
+ +

Evidencia

-
+ + + + } + title={signal.title} + description="Sinal ativo registrado no prontuario de saude do workspace." + meta={ + <> + {signal.source} + {signal.id} + + } + tone={ + signal.severity === "alta" + ? "danger" + : signal.severity === "baixa" + ? "info" + : "warning" + } + /> + + + -
-
-

- Decisões Recentes -

- {recentDecisions.length === 0 ? ( -

- Nenhuma decisão registrada. -

- ) : ( -
- {recentDecisions.map((d) => ( - - -
- {formatDate(d.name)} -
-
- {resolveTitle(d.content) || d.name} -
-
-
- ))} -
- )} - {decisions.length > 4 && ( - - )} + + +
+ +

Comparacao de drift

-
+ + + {signal.relatedSnapshot ? ( +
+ , + }, + { + label: "Registrado em", + value: formatEvidenceDate( + signal.relatedSnapshot.timestamp, + ), + icon: , + }, + { + label: "Arquivos", + value: signal.relatedSnapshot.files.length, + icon: , + }, + ]} + /> + + {signal.relatedSnapshot.files.map((file) => ( + } + title={file.path} + description="Arquivo capturado no snapshot do diagnostico." + meta={before/after} + /> + ))} + + {diffFile ? ( +
+											{diffPreview.join("\n")}
+										
+ ) : null} +
+ ) : ( + + } + title="Nenhum snapshot relacionado encontrado." + description="Este sinal nao possui comparacao before/after disponivel; a evidencia atual vem do health-record." + meta={fallback honesto} + tone="info" + /> + + )} +
+ -
-

- Métricas -

- - - {workflow.stages.map((stage) => { - const items = workflow.items.filter( - (it) => it.stage === stage.id, - ); - const avg = - items.length > 0 - ? Math.round( - (items.reduce( - (acc, it) => - acc + daysSince(it.createdAt), - 0, - ) / - items.length) * - 10, - ) / 10 - : 0; - const max = - items.length > 0 - ? Math.max( - ...items.map((it) => - daysSince(it.createdAt), - ), - ) - : 0; - return ( -
- {stage.name} - - {items.length} items · avg {avg}d · max {max}d - - {avg > 0 && avg > 5 && ( - bottleneck - )} -
- ); - })} + + + +
+ +

Origem tecnica

+
+ + Mostrar dados + +
+ + + , + }, + { + label: "Origem", + value: signal.source, + icon: , + }, + { + label: "Tipo", + value: signal.technicalType ?? "nao informado", + icon: , + }, + { + label: "Urgencia", + value: signal.severity, + icon: , + }, + ]} + /> -
-
-
+ + + +
+ + {actionMessage ? ( +

+ {actionMessage} +

+ ) : null} + + + +
+ + + ); +} + +export default function HomeView({ workflow, activeFlow, onTabChange }: Props) { + const [focus, setFocus] = useState(null); + const [signals, setSignals] = useState([]); + const [healthSummary, setHealthSummary] = useState(null); + const [signalsAvailable, setSignalsAvailable] = useState(true); + const [diagnosticSnapshots, setDiagnosticSnapshots] = useState([]); + const [activity, setActivity] = useState([]); + const [activityAvailable, setActivityAvailable] = useState(true); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const [selectedItemId, setSelectedItemId] = useState(null); + const [selectedSignal, setSelectedSignal] = useState(null); + const [healthBusy, setHealthBusy] = useState(false); + const [healthActionMessage, setHealthActionMessage] = useState(""); + + const applyHealth = useCallback( + (health: HealthResponse, snapshots: DiagnosticSnapshot[] = []) => { + const nextSignals = mapHealthSignals(health, snapshots); + setHealthSummary(summarizeHealth(health)); + setSignals(nextSignals); + setSignalsAvailable(true); + setSelectedSignal((current) => { + if (!current) return current; + return nextSignals.find((signal) => signal.id === current.id) ?? null; + }); + return nextSignals; + }, + [], + ); + + const refreshHealthSignals = useCallback(async () => { + const [healthResponse, snapshotsResponse] = await Promise.all([ + fetch("/api/health"), + fetch("/api/diagnostics/snapshots?limit=20"), + ]); + if (!healthResponse.ok) throw new Error("health unavailable"); + const health = (await healthResponse.json()) as HealthResponse; + const snapshots = snapshotsResponse.ok + ? normalizeSnapshots((await snapshotsResponse.json()) as DiagnosticSnapshotsResponse) + : diagnosticSnapshots; + setDiagnosticSnapshots(snapshots); + return applyHealth(health, snapshots); + }, [applyHealth]); + + useEffect(() => { + let cancelled = false; + + async function load() { + setLoading(true); + setError(false); + + const [focusResult, healthResult, snapshotsResult, logResult] = + await Promise.allSettled([ + fetch("/api/focus").then((response) => response.json()), + fetch("/api/health").then((response) => response.json()), + fetch("/api/diagnostics/snapshots?limit=20").then((response) => + response.json(), + ), + fetch("/api/log?limit=4").then((response) => response.json()), + ]); + + if (cancelled) return; + + if (focusResult.status === "fulfilled") { + setFocus(focusResult.value); + } + + const snapshots = + snapshotsResult.status === "fulfilled" + ? normalizeSnapshots(snapshotsResult.value as DiagnosticSnapshotsResponse) + : []; + setDiagnosticSnapshots(snapshots); + + if (healthResult.status === "fulfilled") { + const health = healthResult.value as HealthResponse; + applyHealth(health, snapshots); + } else { + setSignals([]); + setHealthSummary(null); + setSignalsAvailable(false); + } + + if (logResult.status === "fulfilled") { + const logs = logResult.value as LogResponse; + setActivity( + (logs.entries ?? []).slice(0, 4).map((entry) => ({ + id: entry.id, + action: entry.action, + description: entry.description, + timestamp: entry.timestamp, + itemId: entry.itemId ?? null, + })), + ); + setActivityAvailable(true); + } else { + setActivity([]); + setActivityAvailable(false); + } + + if ( + focusResult.status === "rejected" && + healthResult.status === "rejected" && + logResult.status === "rejected" + ) { + setError(true); + } + + setLoading(false); + } + + load(); + return () => { + cancelled = true; + }; + }, [applyHealth]); + + const stages = useMemo(() => orderedStages(workflow, activeFlow), [activeFlow, workflow]); + + const decisions = useMemo(() => { + const gateStages = humanGateStageIds(workflow, activeFlow); + + return workflow.items + .filter((item) => gateStages.has(item.stage)) + .map((item) => ({ + itemId: item.id, + title: item.description || item.id, + stage: stages.find((stage) => stage.id === item.stage)?.name ?? item.stage, + actor: stageAgentLabel(item.stage, workflow, activeFlow), + since: formatSince(item.createdAt), + })); + }, [activeFlow, stages, workflow]); + + const selectedItem = selectedItemId + ? (workflow.items.find((item) => item.id === selectedItemId) ?? null) + : null; + const selectedStageName = selectedItem + ? (stages.find((stage) => stage.id === selectedItem.stage)?.name ?? null) + : null; + const primaryItemId = focus?.itemId ?? workflow.primaryItemId ?? workflow.items[0]?.id; + const primaryItem = primaryItemId + ? workflow.items.find((item) => item.id === primaryItemId) + : undefined; + const primaryStageName = primaryItem + ? (stages.find((stage) => stage.id === primaryItem.stage)?.name ?? primaryItem.stage) + : undefined; + const primaryWork: FocusedWork | undefined = primaryItem + ? { + id: primaryItem.id, + title: primaryItem.description || primaryItem.id, + description: "Resumo operacional do item que está no centro da supervisão agora.", + stage: primaryStageName, + spec: primaryItem.spec ?? focus?.spec, + ageLabel: formatAgeLabel(primaryItem.createdAt), + actor: + primaryItem.claimedBy ?? + stageAgentLabel(primaryItem.stage, workflow, activeFlow), + } + : undefined; + + function openItem(itemId: string) { + setSelectedItemId(itemId); + window.dispatchEvent(new CustomEvent("letra-open-item", { detail: itemId })); + } + + async function postHealthAction( + path: "/api/health/scan" | "/api/health/ack" | "/api/health/dismiss", + body?: Record, + ) { + setHealthBusy(true); + setHealthActionMessage("Atualizando saude do workspace..."); + try { + const response = await fetch(path, { + method: "POST", + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + if (!response.ok) throw new Error("health action failed"); + const nextSignals = await refreshHealthSignals(); + setHealthActionMessage("Saude do workspace atualizada."); + return nextSignals; + } catch { + setHealthActionMessage("Nao foi possivel atualizar a saude agora."); + return null; + } finally { + setHealthBusy(false); + } + } + + async function scanHealth() { + await postHealthAction("/api/health/scan"); + } + + async function acknowledgeSignal(signal: AttentionSignal) { + const nextSignals = await postHealthAction("/api/health/ack", { id: signal.id }); + if (nextSignals) { + setSelectedSignal(nextSignals.find((entry) => entry.id === signal.id) ?? null); + } + } + + async function dismissSignal(signal: AttentionSignal) { + const nextSignals = await postHealthAction("/api/health/dismiss", { id: signal.id }); + if (nextSignals) { + setSelectedSignal(null); + } + } + + if (loading) { + return ; + } + + return ( +
+
+
+ {error ? ( + window.location.reload()}> + Não foi possível carregar a supervisão agora. + + ) : null} + + onTabChange?.("activity")} + onOpenWork={() => onTabChange?.("work")} + onOpenSignal={setSelectedSignal} + onScanHealth={scanHealth} + healthBusy={healthBusy} + /> + + {focus?.active && focus.spec ? ( + + ) : null}
+ + { + if (!open) setSelectedItemId(null); + }} + /> + { + if (!open) setSelectedSignal(null); + }} + onAcknowledge={acknowledgeSignal} + onDismiss={dismissSignal} + onScan={scanHealth} + actionBusy={healthBusy} + actionMessage={healthActionMessage} + />
); } diff --git a/packages/client/src/components/Home/SupervisionInbox.test.tsx b/packages/client/src/components/Home/SupervisionInbox.test.tsx new file mode 100644 index 0000000..d3d82d2 --- /dev/null +++ b/packages/client/src/components/Home/SupervisionInbox.test.tsx @@ -0,0 +1,157 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import SupervisionInbox from "./SupervisionInbox"; + +describe("SupervisionInbox", () => { + it("prioritizes a pending human decision and explains its safe consequence", async () => { + const user = userEvent.setup(); + const onReviewDecision = vi.fn(); + + render( + , + ); + + expect(screen.getByText("Prioridade agora")).toBeTruthy(); + expect( + screen.getByText("Um gate humano impede o fluxo de avancar sem sua decisao."), + ).toBeTruthy(); + expect(screen.getByRole("heading", { name: "Decisoes pendentes" })).toBeTruthy(); + expect(screen.getByText("Aprovar contrato publico")).toBeTruthy(); + expect(screen.getByText(/nenhuma mudanca ocorre antes da sua decisao/i)).toBeTruthy(); + + await user.click(screen.getByRole("button", { name: "Revisar decisao prioritaria" })); + expect(onReviewDecision).toHaveBeenCalledWith("ITEM-9"); + }); + + it("surfaces health signals as supervisory evidence", async () => { + const user = userEvent.setup(); + const onOpenSignal = vi.fn(); + const onScanHealth = vi.fn(); + + render( + , + ); + + expect(screen.getByRole("heading", { name: "Saude do workspace" })).toBeTruthy(); + expect(screen.getByText("Validacao falhou")).toBeTruthy(); + expect(screen.getAllByText("bloqueia conclusao").length).toBeGreaterThan(0); + expect(screen.getByText("Resolvidos")).toBeTruthy(); + expect(screen.getByRole("heading", { name: "Ultimas evidencias" })).toBeTruthy(); + + await user.click(screen.getByRole("button", { name: "Examinar evidencias" })); + expect(onOpenSignal).toHaveBeenCalledWith(expect.objectContaining({ id: "health-1" })); + + await user.click(screen.getByRole("button", { name: "Verificar agora" })); + expect(onScanHealth).toHaveBeenCalledOnce(); + }); + + it("uses honest empty states and opens focused work without mutating it", async () => { + const user = userEvent.setup(); + const onOpenItem = vi.fn(); + + render( + , + ); + + expect(screen.getByText("Nenhuma decisao aguarda voce.")).toBeTruthy(); + expect(screen.getByText("Nenhum sinal ativo de saude.")).toBeTruthy(); + expect( + screen.getByText( + "Historico resolvido ou descartado fica em Atividade; esta area mostra apenas o que pede investigacao agora.", + ), + ).toBeTruthy(); + expect(screen.getByText("Em foco por ser o item central da sessao atual.")).toBeTruthy(); + expect(screen.getByText("Nenhuma atividade registrada.")).toBeTruthy(); + expect(screen.getByText(/abre o item sem alterar seu estagio/i)).toBeTruthy(); + + await user.click(screen.getByRole("button", { name: "Abrir trabalho em foco" })); + expect(onOpenItem).toHaveBeenCalledWith("ITEM-62"); + }); + + it("communicates unavailable health and scan progress without hiding supervision", () => { + render( + , + ); + + expect(screen.getByText("Saude indisponivel agora.")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Verificando" }).hasAttribute("disabled")).toBe( + true, + ); + expect( + screen.getByText( + "A supervisao continua disponivel, mas sem a fotografia de saude do workspace.", + ), + ).toBeTruthy(); + }); +}); diff --git a/packages/client/src/components/Home/SupervisionInbox.tsx b/packages/client/src/components/Home/SupervisionInbox.tsx new file mode 100644 index 0000000..9faa4da --- /dev/null +++ b/packages/client/src/components/Home/SupervisionInbox.tsx @@ -0,0 +1,730 @@ +import { + ActionPanel, + Badge, + Button, + Card, + CardContent, + CardHeader, + EmptyState, + Icon, + List, + ListItem, + MetadataRow, + NavHeader, + Tag, +} from "@letra/ui"; +import type { IconName } from "@letra/ui"; + +export interface PendingDecision { + itemId: string; + title: string; + stage: string; + actor: string; + since: string; +} + +export interface AttentionSignal { + id: string; + title: string; + source: string; + severity: "baixa" | "media" | "alta"; + status?: "novo" | "ciente" | "descartado" | "resolvido"; + detectedAt?: string; + impact?: string; + nextAction?: string; + technicalType?: string; + relatedSnapshot?: { + id: string; + timestamp: string; + diagnosticId: string; + diagnosticTitle: string; + files: { path: string; before: string; after: string }[]; + }; +} + +export interface ActivityEvent { + id: string; + action: string; + description: string; + timestamp: string; + itemId?: string | null; +} + +export interface FocusedWork { + id: string; + title: string; + description?: string; + stage?: string; + spec?: string; + ageLabel?: string; + actor?: string; +} + +interface Props { + decisions: PendingDecision[]; + signals: AttentionSignal[]; + healthSummary?: { + novo: number; + ciente: number; + resolvido: number; + descartado: number; + }; + activity: ActivityEvent[]; + signalsAvailable?: boolean; + activityAvailable?: boolean; + primaryItemId?: string; + primaryWork?: FocusedWork; + onReviewDecision: (itemId: string) => void; + onOpenItem: (itemId: string) => void; + onOpenActivity: () => void; + onOpenWork: () => void; + onOpenSignal: (signal: AttentionSignal) => void; + onScanHealth?: () => void; + healthBusy?: boolean; +} + +function eventTime(timestamp: string) { + try { + return new Date(timestamp).toLocaleString("pt-BR", { + day: "2-digit", + month: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); + } catch { + return timestamp; + } +} + +function severityVariant(severity: AttentionSignal["severity"]) { + return severity === "alta" ? "error" : severity === "baixa" ? "info" : "amber"; +} + +function severityTone(severity: AttentionSignal["severity"]) { + return severity === "alta" ? "danger" : severity === "baixa" ? "info" : "warning"; +} + +function statusLabel(status?: AttentionSignal["status"]) { + if (status === "ciente") return "em acompanhamento"; + if (status === "descartado") return "descartado"; + if (status === "resolvido") return "resolvido"; + return "novo"; +} + +function statusVariant(status?: AttentionSignal["status"]) { + if (status === "ciente") return "warning" as const; + if (status === "resolvido") return "success" as const; + if (status === "descartado") return "default" as const; + return "info" as const; +} + +function eventPresentation(action: string): { + icon: IconName; + variant: "amber" | "success" | "info" | "error" | "agent"; + tagVariant: "default" | "agent" | "success" | "info" | "warning" | "danger"; + tone: "default" | "warning" | "danger" | "success" | "info"; + label: string; +} { + const normalized = action.toLowerCase(); + if ( + normalized.includes("fail") || + normalized.includes("erro") || + normalized.includes("error") + ) { + return { + icon: "circle-x", + variant: "error", + tagVariant: "danger", + tone: "danger", + label: "falha", + }; + } + if ( + normalized.includes("validate") || + normalized.includes("build") || + normalized.includes("test") + ) { + return { + icon: "terminal", + variant: "amber", + tagVariant: "warning", + tone: "warning", + label: "validacao", + }; + } + if ( + normalized.includes("done") || + normalized.includes("complete") || + normalized.includes("pass") + ) { + return { + icon: "circle-check", + variant: "success", + tagVariant: "success", + tone: "success", + label: "concluido", + }; + } + if (normalized.includes("agent") || normalized.includes("automation")) { + return { + icon: "bot", + variant: "agent", + tagVariant: "agent", + tone: "info", + label: "agente", + }; + } + return { icon: "activity", variant: "info", tagVariant: "info", tone: "info", label: "evento" }; +} + +export default function SupervisionInbox({ + decisions, + signals, + healthSummary, + activity, + signalsAvailable = true, + activityAvailable = true, + primaryItemId, + primaryWork, + onReviewDecision, + onOpenItem, + onOpenActivity, + onOpenWork, + onOpenSignal, + onScanHealth, + healthBusy = false, +}: Props) { + const focusedWork = + primaryWork ?? + (primaryItemId + ? { + id: primaryItemId, + title: primaryItemId, + } + : null); + + const nextAction = decisions[0] + ? { + label: "Revisar decisao prioritaria", + reason: "Um gate humano impede o fluxo de avancar sem sua decisao.", + description: `Responder a solicitacao sobre ${decisions[0].itemId}.`, + consequence: + "A revisao abre a evidencia do item; nenhuma mudanca ocorre antes da sua decisao.", + icon: "shield" as const, + tone: "warning" as const, + run: () => onReviewDecision(decisions[0].itemId), + } + : signals.length > 0 + ? { + label: "Examinar evidencias", + reason: "Ha sinal ativo de saude pedindo investigacao antes de qualquer correcao.", + description: "Compreender o impacto e a evidencia do sinal antes de agir.", + consequence: + "Abre a central de saude; nenhuma correcao e aplicada automaticamente.", + icon: "alert-triangle" as const, + tone: "info" as const, + run: () => onOpenSignal(signals[0]), + } + : focusedWork + ? { + label: "Abrir trabalho em foco", + reason: "Nao ha decisao ou sinal ativo; acompanhe o item central da sessao.", + description: `Continuar a supervisao de ${focusedWork.id}.`, + consequence: "Abre o item sem alterar seu estagio ou estado canonico.", + icon: "box" as const, + tone: "default" as const, + run: () => onOpenItem(focusedWork.id), + } + : { + label: "Ver trabalho disponivel", + reason: "Nao ha decisao ou sinal ativo; escolha um item para supervisionar.", + description: "Escolher com seguranca o proximo trabalho a supervisionar.", + consequence: "Abre Trabalho sem iniciar ou mover qualquer item.", + icon: "grid" as const, + tone: "default" as const, + run: onOpenWork, + }; + + const attentionCount = decisions.length + signals.length; + const attentionSummary = + attentionCount > 0 ? ( +
+ {decisions.length > 0 ? ( + + {decisions.length} decisoes + + ) : null} + {signals.length > 0 ? ( + + {signals.length} {signals.length === 1 ? "sinal" : "sinais"} + + ) : null} +
+ ) : ( + + nada aguardando voce + + ); + + const focusedWorkMetadata = focusedWork + ? [ + { + label: "Item", + value: ( + + {focusedWork.id} + + ), + icon: , + }, + ...(focusedWork.stage + ? [ + { + label: "Estagio", + value: {focusedWork.stage}, + icon: , + }, + ] + : []), + ...(focusedWork.ageLabel + ? [ + { + label: "Idade", + value: focusedWork.ageLabel, + icon: , + }, + ] + : []), + ...(focusedWork.actor + ? [ + { + label: "Responsavel", + value: focusedWork.actor, + icon: , + }, + ] + : []), + ] + : []; + + return ( +
+ } + right={attentionSummary} + /> + } + title="Prioridade agora" + description={nextAction.description} + action={} + > +
+
+ {nextAction.label} + nao altera estado +
+

+ {nextAction.reason} +

+

+ {nextAction.consequence} +

+
+
+ +
+
+ + +
+ +
+

Decisoes pendentes

+

+ Gates humanos que precisam da sua decisao antes do fluxo + avancar. +

+
+
+ 0 ? "alert-triangle" : "circle"} + variant={decisions.length > 0 ? "amber" : "info"} + tone="soft" + > + {decisions.length} + +
+ + {decisions.length === 0 ? ( + } + title="Nenhuma decisao aguarda voce." + description="Quando um gate humano aparecer, ele entra nesta fila com origem e consequencia." + /> + ) : ( + + {decisions.map((decision) => ( + } + title={decision.title} + description="Aguardando decisao humana antes do fluxo avancar." + meta={ + <> + + {decision.itemId} + + gate humano + + } + action={ + + } + tone="warning" + > + + {decision.stage} + + ), + icon: , + }, + { + label: "Responsavel", + value: decision.actor, + icon: , + }, + { + label: "Desde", + value: decision.since, + icon: , + }, + ]} + /> + + ))} + + )} + +
+ + + +
+ +
+

Saude do workspace

+

+ Sinais priorizados por impacto, evidencia e proxima acao + segura. +

+
+
+
+ 0 ? "alert-triangle" : "circle"} + variant={signals.length > 0 ? "amber" : "info"} + tone="soft" + > + {signals.length} + + {onScanHealth ? ( + + ) : null} +
+
+ + {healthSummary && signals.length > 0 ? ( + , + }, + { + label: "Em acompanhamento", + value: healthSummary.ciente, + icon: , + }, + { + label: "Resolvidos", + value: healthSummary.resolvido, + icon: , + }, + { + label: "Descartados", + value: healthSummary.descartado, + icon: , + }, + ]} + /> + ) : null} + {!signalsAvailable ? ( + } + title="Saude indisponivel agora." + description="A supervisao continua disponivel, mas sem a fotografia de saude do workspace." + /> + ) : signals.length === 0 ? ( + } + title="Nenhum sinal ativo de saude." + description="Historico resolvido ou descartado fica em Atividade; esta area mostra apenas o que pede investigacao agora." + /> + ) : ( + + {signals.map((signal) => ( + } + title={signal.title} + description={ + signal.impact ?? + "Pede investigacao antes de qualquer correcao." + } + meta={ + <> + + {signal.impact ?? "pede investigacao"} + + + {statusLabel(signal.status)} + + + } + action={ + + } + tone={severityTone(signal.severity)} + > + , + }, + { + label: "Urgencia", + value: ( + + {signal.severity} + + ), + icon: ( + + ), + }, + { + label: "Acao segura", + value: signal.nextAction ?? "investigar", + icon: , + }, + ]} + /> + + ))} + + )} + +
+
+ +
+ + +
+ +

Trabalho em foco

+
+ {focusedWork ? ( + + {focusedWork.id} + + ) : null} +
+ + {focusedWork ? ( + <> +
+
+ supervisionavel + {focusedWork.actor ? ( + {focusedWork.actor} + ) : null} +
+

+ {focusedWork.title} +

+

+ {decisions.length > 0 + ? "Em foco porque esta relacionado a uma decisao humana." + : signals.length > 0 + ? "Em foco para cruzar sinais ativos com o trabalho atual." + : "Em foco por ser o item central da sessao atual."} +

+
+ +
+ + somente leitura +
+ + ) : ( + } + title="Nenhum trabalho em foco." + description="Abra Trabalho para escolher o proximo item a supervisionar." + action={ + + } + /> + )} +
+
+
+
+ + + +
+ +
+

Ultimas evidencias

+

+ Eventos recentes que comprovam o que aconteceu no workspace. +

+
+
+ +
+ + {!activityAvailable ? ( + } + title="Atividade indisponivel no momento." + description="A timeline completa permanece em Atividade quando o log voltar." + /> + ) : activity.length === 0 ? ( + } + title="Nenhuma atividade registrada." + description="Eventos reais do workspace aparecem aqui como evidencia." + /> + ) : ( + + {activity.map((event) => { + const presentation = eventPresentation(event.action); + return ( + } + title={event.description} + description={eventTime(event.timestamp)} + meta={ + <> + + {presentation.label} + + + {event.action} + + {event.itemId ? {event.itemId} : null} + + } + action={ + event.itemId ? ( + + ) : null + } + tone={presentation.tone} + /> + ); + })} + + )} + +
+
+ ); +} diff --git a/packages/client/src/components/Kanban/KanbanView.tsx b/packages/client/src/components/Kanban/KanbanView.tsx index 6edf3d5..f512300 100644 --- a/packages/client/src/components/Kanban/KanbanView.tsx +++ b/packages/client/src/components/Kanban/KanbanView.tsx @@ -1,19 +1,34 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { ResolvedSpec, Workflow } from "@letra/types"; import { Card, CardContent } from "@letra/ui"; -import { Icon, Progress } from "@letra/ui"; +import { Icon, Progress, Button } from "@letra/ui"; import { cn } from "../../lib/utils"; import { MarchingBorder } from "./MarchingBorder"; import type { Item } from "@letra/types"; -import { computeSlug, computeTypeTag, countACs, TYPE_COLORS, type ItemType } from "../../lib/item-utils"; +import { + computeSlug, + computeTypeTag, + countACs, + TYPE_COLORS, + type ItemType, +} from "../../lib/item-utils"; +import { + humanGateStageIds, + orderedStages, + stagePresentation, + type ActiveFlowDefinition, +} from "../../lib/active-flow"; +import { PhaseBadge } from "./PhaseBadge"; interface Props { workflow: Workflow; + activeFlow?: ActiveFlowDefinition | null; onSelectItem: (id: string) => void; onItemMoved: () => void; onDropItem?: (itemId: string, targetStageId: string) => void; allowMoveToStage?: (item: Workflow["items"][0], targetStageId: string) => boolean; specRefreshKey?: number; + onAddItem?: () => void; } function daysSince(dateStr: string): number { @@ -21,9 +36,9 @@ function daysSince(dateStr: string): number { } function daysColor(d: number): string { - if (d <= 2) return "var(--muted-foreground)"; - if (d <= 7) return "var(--warning)"; - return "var(--error)"; + if (d <= 2) return "var(--color-text-secondary)"; + if (d <= 7) return "var(--color-warning)"; + return "var(--color-danger)"; } function daysIcon(d: number): "check-circle" | "alert-circle" | "x-circle" | null { @@ -64,11 +79,13 @@ function truncate(text: string, max: number): string { export default function KanbanView({ workflow, + activeFlow = null, onSelectItem, onItemMoved, onDropItem, allowMoveToStage, specRefreshKey = 0, + onAddItem, }: Props) { const [dragOver, setDragOver] = useState(null); const [draggingId, setDraggingId] = useState(null); @@ -158,17 +175,38 @@ export default function KanbanView({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ stage: targetStageId }), }); - const releaseP = item.claimedBy && targetStageId === "review" - ? fetch(`/api/items/${itemId}/release`, { method: "POST" }) - : Promise.resolve(); + const releaseP = + item.claimedBy && humanGateStageIds(workflow, activeFlow).has(targetStageId) + ? fetch(`/api/items/${itemId}/release`, { method: "POST" }) + : Promise.resolve(); Promise.all([p, releaseP]).then(debouncedMove).catch(console.warn); } } - return ( + return workflow.items.length === 0 ? ( +
+
+

+ Nenhum item no board. +

+

+ Adicione seu primeiro item via{" "} + + letra flow backlog add <desc> + +

+ {onAddItem && ( + + )} +
+
+ ) : (
- {workflow.stages.map((stage) => { + {orderedStages(workflow, activeFlow).map((stage) => { const stageItems = workflow.items.filter((it) => it.stage === stage.id); + const stageColor = stagePresentation(stage).color; const isOver = dragOver === stage.id; const isOverDenied = isOver && draggingId && allowMoveToStage @@ -177,14 +215,14 @@ export default function KanbanView({ stage.id, ) : false; - const accentBorder = stage.color ? `2px solid ${stage.color}40` : undefined; - const accentHeader = stage.color ? stage.color : undefined; + const accentBorder = `2px solid ${stageColor}40`; + const accentHeader = stageColor; return ( handleDrop(e, stage.id)} > - -

- {accentHeader && ( + +
+

+ {accentHeader && ( + + )} + {stage.name} - )} - {stage.name} - - {stageItems.length} - -

-
+ className="text-xs font-normal" + style={{ color: "var(--color-text-secondary)" }} + > + {stageItems.length} + +

+
+
{stageItems.length === 0 && (

(empty)

@@ -226,47 +269,72 @@ export default function KanbanView({ const slug = cachedSlug(it, specs, workflow); const typeTag = cachedType(it); const typeColor = TYPE_COLORS[typeTag]; - const linkedSpec = it.spec ? specs.find((s) => s.id === it.spec) : null; - const acCount = linkedSpec ? countACs(linkedSpec.content) : null; + const linkedSpec = it.spec + ? specs.find((s) => s.id === it.spec) + : null; + const acCount = linkedSpec + ? countACs(linkedSpec.content) + : null; const hasTasks = it.tasks && it.tasks.length > 0; - const progressMax = acCount ? acCount.total : hasTasks ? it.tasks!.length : 0; - const progressVal = acCount ? acCount.done : hasTasks ? it.tasks!.filter((t) => t.done).length : 0; + const progressMax = acCount + ? acCount.total + : hasTasks + ? it.tasks?.length + : 0; + const progressVal = acCount + ? acCount.done + : hasTasks + ? it.tasks?.filter((t) => t.done).length + : 0; return (
onSelectItem(it.id)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelectItem(it.id); + } + }} onDragStart={(e) => handleDragStart(e, it.id)} onDragEnd={handleDragEnd} - className={cn( - "relative group rounded-lg border text-card-foreground transition-all duration-200 cursor-grab active:cursor-grabbing hover:shadow-sm hover:-translate-y-0.5", - draggingId === it.id && "opacity-40", - stage.color - ? "bg-card/90 hover:border-transparent" - : "bg-card hover:border-primary/20", - )} - style={{ - borderColor: isClaimed - ? "transparent" - : isFocused - ? "var(--border-focus)" - : stage.color - ? `${stage.color}30` - : "var(--border)", - borderLeft: isFocused && !isClaimed ? "3px solid var(--border-focus)" : undefined, - background: stage.color - ? `color-mix(in srgb, ${stage.color}08, var(--card))` + className={cn( + "relative group rounded-[var(--radius-sm)] border text-card-foreground transition-all duration-200 cursor-grab active:cursor-grabbing hover:shadow-sm hover:-translate-y-0.5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary", + draggingId === it.id && + "opacity-70 scale-[1.02] shadow-md", + stageColor + ? "bg-card/90 hover:border-transparent" + : "bg-card hover:border-primary/20", + )} + style={{ + borderColor: isClaimed + ? "transparent" + : isFocused + ? "var(--border-focus)" + : stageColor + ? `${stageColor}30` + : "var(--color-border)", + borderLeft: + isFocused && !isClaimed + ? "3px solid var(--border-focus)" : undefined, + background: stageColor + ? `color-mix(in srgb, ${stageColor}08, var(--color-bg-surface))` + : undefined, boxShadow: isClaimed ? undefined : isFocused - ? `0 0 8px color-mix(in srgb, var(--border-focus) 30%, transparent)` + ? "0 0 8px color-mix(in srgb, var(--border-focus) 30%, transparent)" : draggingId === it.id ? undefined - : stage.color - ? `0 1px 3px ${stage.color}15` - : `0 1px 2px oklch(0 0 0 / 0.08)`, - }} + : stageColor + ? `0 1px 3px ${stageColor}15` + : "0 1px 2px oklch(0 0 0 / 0.08)", + }} > {isClaimed && }
@@ -276,14 +344,18 @@ export default function KanbanView({ title={`${it.id} — ${it.description}`} > {isFocused && ( - )} {slug} {typeTag} + {it.currentPhase && + stage.phases?.states?.[it.currentPhase] && ( + + )}
{truncate(it.description, 40)}
- {(progressMax > 0) && ( + {progressMax > 0 && ( )} -
-
+
{itemAlerts[it.id] > 0 && ( - + ⚠{itemAlerts[it.id]} )} @@ -322,12 +411,15 @@ export default function KanbanView({
{isClaimed && ( - 🤖 + > + 🤖 + )} {icon && } @@ -337,68 +429,121 @@ export default function KanbanView({
{isFocused ? ( - + {loadingButtons.has(`focus-${it.id}`) + ? "⏳" + : "★ Focus"} + ) : ( - + {loadingButtons.has(`focus-${it.id}`) + ? "⏳" + : "☆ Focus"} + )} {isClaimed ? ( - + {loadingButtons.has(`release-${it.id}`) + ? "⏳" + : "Release"} + ) : ( - + {loadingButtons.has(`claim-${it.id}`) + ? "⏳" + : "Claim"} + )}
diff --git a/packages/client/src/components/Kanban/MarchingBorder.tsx b/packages/client/src/components/Kanban/MarchingBorder.tsx index ba28525..1335f06 100644 --- a/packages/client/src/components/Kanban/MarchingBorder.tsx +++ b/packages/client/src/components/Kanban/MarchingBorder.tsx @@ -16,20 +16,15 @@ export function MarchingBorder({ className }: MarchingBorderProps) { }} aria-hidden="true" > - + diff --git a/packages/client/src/components/Kanban/PhaseBadge.tsx b/packages/client/src/components/Kanban/PhaseBadge.tsx new file mode 100644 index 0000000..7ae58d2 --- /dev/null +++ b/packages/client/src/components/Kanban/PhaseBadge.tsx @@ -0,0 +1,13 @@ +import { Badge } from "@letra/ui"; + +interface PhaseBadgeProps { + phase: { id: string; label: string }; +} + +export function PhaseBadge({ phase }: PhaseBadgeProps) { + return ( + + {phase.label} + + ); +} diff --git a/packages/client/src/components/Logs/AuditLogView.tsx b/packages/client/src/components/Logs/AuditLogView.tsx new file mode 100644 index 0000000..71131c5 --- /dev/null +++ b/packages/client/src/components/Logs/AuditLogView.tsx @@ -0,0 +1,1160 @@ +import { useState, useEffect, useCallback, useMemo } from "react"; +import { + ActivityTimeline, + Badge, + Button, + ButtonGroup, + ButtonGroupItem, + Card, + CardContent, + CardHeader, + Collapsible, + CollapsibleContent, + CollapsibleTrigger, + DateField, + Icon, + Input, + List, + ListItem, + MetadataRow, + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + Tag, + TimelineItem, +} from "@letra/ui"; +import type { IconName } from "@letra/ui"; +import { translateSubjectType } from "../../lib/term-translations"; +import { translateAction } from "../../lib/action-labels"; + +type EventKind = "flow" | "execution" | "human" | "system"; +type EventStatus = "started" | "succeeded" | "failed" | "blocked" | "requested" | "info"; + +interface OperationalAuditEvent { + id: string; + timestamp: string; + kind: EventKind; + action: string; + status: EventStatus; + actor: { type: "human" | "agent" | "system"; id?: string; label: string }; + source: { surface: string; command?: string }; + subject?: { type: string; id: string; label?: string }; + summary: string; + reason?: string; + correlationId?: string; + details: Record; + legacy?: Record; +} + +interface AuditFacets { + kinds: Record; + statuses: Record; + sources: Record; + actions: Record; +} + +interface AuditResponse { + items: OperationalAuditEvent[]; + total: number; + page: number; + limit: number; + facets: AuditFacets; +} + +interface GroupedEvent { + date: string; + label: string; + groups: EventGroup[]; +} + +interface EventGroup { + key: string; + events: OperationalAuditEvent[]; + collapsed: boolean; + isNoise: boolean; +} + +const DATE_GROUPS = [ + { label: "Hoje", days: 0 }, + { label: "Ontem", days: 1 }, + { label: "Esta semana", days: 7 }, + { label: "Este mês", days: 30 }, +] as const; + +const KIND_FILTERS = [ + { label: "Todos", value: "" }, + { label: "Humano", value: "human" }, + { label: "Agente", value: "flow" }, + { label: "Sistema", value: "system" }, + { label: "Execução", value: "execution" }, +] as const; + +function getDateGroup(ts: string): { label: string; order: number } { + const d = new Date(ts); + const now = new Date(); + const diff = Math.floor((now.getTime() - d.getTime()) / 86400000); + if (diff === 0) return { label: "Hoje", order: 0 }; + if (diff === 1) return { label: "Ontem", order: 1 }; + if (diff <= 7) return { label: "Esta semana", order: 2 }; + if (diff <= 30) return { label: "Este mês", order: 3 }; + return { label: "Mais antigo", order: 4 }; +} + +function formatTime(iso: string) { + try { + const d = new Date(iso); + return d.toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit" }); + } catch { + return iso; + } +} + +function fullTime(iso: string) { + try { + const d = new Date(iso); + return d.toLocaleString("pt-BR", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + } catch { + return iso; + } +} + +function noiseKey(e: OperationalAuditEvent): string | null { + if (e.kind !== "system") return null; + const actionId = e.correlationId || (e.details?.actionId as string); + if (!actionId) return null; + return `${actionId}:${e.details?.outcome || "unknown"}`; +} + +function statusVariant(status: EventStatus): "success" | "amber" | "info" { + if (status === "succeeded") return "success"; + if (status === "failed" || status === "blocked") return "amber"; + return "info"; +} + +function timelineStatus( + e: OperationalAuditEvent, +): "default" | "success" | "error" | "info" | "agent" { + if (e.status === "failed" || e.status === "blocked") return "error"; + if (e.status === "succeeded") return "success"; + if (e.kind === "system") return "info"; + if (e.kind === "human") return "default"; + return "agent"; +} + +function timelineIcon(e: OperationalAuditEvent): IconName { + if (e.status === "failed" || e.status === "blocked") return "circle-x"; + if (e.status === "succeeded") return "circle-check"; + if (e.kind === "system") return "activity"; + if (e.kind === "human") return "user"; + return "bot"; +} + +function detailText(e: OperationalAuditEvent, key: string): string | null { + const value = e.details?.[key]; + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function subjectLabel(type: string): string { + return translateSubjectType(type); +} + +function evidenceRefs(e: OperationalAuditEvent): { label: string; value: string; tone: "info" }[] { + const refs: { label: string; value: string; tone: "info" }[] = []; + if (e.subject) + refs.push({ label: subjectLabel(e.subject.type), value: e.subject.id, tone: "info" }); + const decisionFile = detailText(e, "decisionFile"); + if (decisionFile) refs.push({ label: "Decisão", value: decisionFile, tone: "info" }); + const path = detailText(e, "path"); + if (path) refs.push({ label: "Arquivo", value: path, tone: "info" }); + return refs; +} + +function investigationSummary(e: OperationalAuditEvent) { + const path = detailText(e, "path") ?? detailText(e, "decisionFile"); + return { + who: e.actor.label, + what: e.summary || e.action, + where: e.subject?.id || path || detailText(e, "source") || "Workspace", + result: + detailText(e, "outcome") || + (e.details?.effect as string) || + "Registrado como evidência", + why: e.reason || detailText(e, "trigger") || null, + evidence: evidenceRefs(e), + }; +} + +export default function AuditLogView() { + const [logs, setLogs] = useState([]); + const [facets, setFacets] = useState({ + kinds: {}, + statuses: {}, + sources: {}, + actions: {}, + }); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [filterAction, setFilterAction] = useState(""); + const [filterKind, setFilterKind] = useState(""); + const [filterSince, setFilterSince] = useState(""); + const [includeTechnicalEvents, setIncludeTechnicalEvents] = useState(false); + const [selectedEvent, setSelectedEvent] = useState(null); + const [technicalDetailsOpen, setTechnicalDetailsOpen] = useState(false); + const [expandedNoise, setExpandedNoise] = useState>(new Set()); + const [expandedDates, setExpandedDates] = useState>(new Set(["Hoje", "Ontem"])); + const limit = 50; + + useEffect(() => { + const t = setTimeout(() => setDebouncedSearch(search), 300); + return () => clearTimeout(t); + }, [search]); + + const fetchLogs = useCallback( + async (p: number) => { + setLoading(true); + setError(null); + try { + const params = new URLSearchParams(); + if (debouncedSearch) params.set("q", debouncedSearch); + if (filterAction) params.set("action", filterAction); + if (filterSince) params.set("since", filterSince); + if (includeTechnicalEvents) params.set("debug", "true"); + params.set("page", String(p)); + params.set("limit", String(limit)); + + const res = await fetch(`/api/log?${params}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data: AuditResponse = await res.json(); + setLogs(data.items || []); + setFacets(data.facets || { kinds: {}, statuses: {}, sources: {}, actions: {} }); + setTotal(data.total || 0); + setPage(data.page || 1); + } catch (err) { + setError(err instanceof Error ? err.message : "Falha ao carregar log"); + setLogs([]); + setTotal(0); + } finally { + setLoading(false); + } + }, + [debouncedSearch, filterAction, filterSince, includeTechnicalEvents], + ); + + useEffect(() => { + setPage(1); + }, [debouncedSearch, filterAction, filterKind, filterSince, includeTechnicalEvents]); + + useEffect(() => { + fetchLogs(page); + }, [fetchLogs, page]); + + useEffect(() => { + setTechnicalDetailsOpen(false); + }, [selectedEvent?.id]); + + const noised = useMemo(() => { + const filtered = filterKind ? logs.filter((e) => e.kind === filterKind) : logs; + const result: OperationalAuditEvent[] = []; + let i = 0; + while (i < filtered.length) { + const key = noiseKey(filtered[i]); + if (key) { + const group: OperationalAuditEvent[] = [filtered[i]]; + let j = i + 1; + while (j < filtered.length && noiseKey(filtered[j]) === key) { + group.push(filtered[j]); + j++; + } + if (group.length > 1) { + const isExpanded = expandedNoise.has(key); + if (isExpanded) { + result.push(...group); + } else { + result.push({ + ...group[0], + _noiseCount: group.length, + _noiseKey: key, + } as OperationalAuditEvent & { _noiseCount: number; _noiseKey: string }); + } + } else { + result.push(group[0]); + } + i = j; + } else { + result.push(filtered[i]); + i++; + } + } + return result; + }, [logs, filterKind, expandedNoise]); + + const grouped = useMemo(() => { + const groups = new Map(); + for (const entry of noised) { + const { label } = getDateGroup(entry.timestamp); + const list = groups.get(label) || []; + list.push(entry); + groups.set(label, list); + } + const orderMap: Record = { + Hoje: 0, + Ontem: 1, + "Esta semana": 2, + "Este mês": 3, + }; + return Array.from(groups.entries()) + .map(([label, entries]) => ({ + label, + entries, + order: orderMap[label] ?? 4, + })) + .sort((a, b) => a.order - b.order); + }, [noised]); + + const metrics = useMemo(() => { + const totalEvents = logs.length; + const systemEvents = logs.filter((e) => e.kind === "system").length; + const humanEvents = logs.filter((e) => e.kind === "human").length; + const agentEvents = logs.filter((e) => e.kind === "execution" || e.kind === "flow").length; + const todayEvents = logs.filter((e) => getDateGroup(e.timestamp).label === "Hoje").length; + const succeededEvents = logs.filter((e) => e.status === "succeeded").length; + const failedEvents = logs.filter((e) => e.status === "failed").length; + return { + totalEvents, + systemEvents, + humanEvents, + agentEvents, + todayEvents, + succeededEvents, + failedEvents, + }; + }, [logs]); + + const totalPages = Math.ceil(total / limit); + const selectedSummary = selectedEvent ? investigationSummary(selectedEvent) : null; + + function toggleNoise(key: string) { + setExpandedNoise((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + } + + function toggleDateGroup(label: string) { + setExpandedDates((prev) => { + const next = new Set(prev); + if (next.has(label)) next.delete(label); + else next.add(label); + return next; + }); + } + + if (error) { + return ( +
+ +

{error}

+ +
+ ); + } + + return ( +
+ {/* Investigation Sheet */} + { + if (!open) setSelectedEvent(null); + }} + > + + {selectedEvent && ( + <> + +
+
+ + {translateAction(selectedEvent.action)} + + + {selectedEvent.actor.label} + + {selectedEvent.status === "failed" || + selectedEvent.status === "blocked" ? ( + + {selectedEvent.status} + + ) : null} +
+ + Evidência da atividade + + + {selectedSummary + ? `${selectedSummary.what} · ${fullTime(selectedEvent.timestamp)}` + : fullTime(selectedEvent.timestamp)} + +
+ setSelectedEvent(null)} + > + + +
+
+ {selectedSummary && ( + + +
+

+ Resumo investigativo +

+

+ {selectedSummary.who} registrou{" "} + + {selectedSummary.what} + {" "} + em{" "} + + {selectedSummary.where} + + . Resultado: {String(selectedSummary.result)}. +

+
+
+ + , + }, + { + label: "Quando", + value: fullTime(selectedEvent.timestamp), + icon: , + }, + { + label: "Onde", + value: + selectedEvent.subject?.id || + selectedSummary.where, + icon: , + }, + { + label: "Resultado", + value: selectedEvent.status, + icon: ( + + ), + }, + ]} + /> + {selectedSummary.why ? ( +
+

+ Por quê +

+

+ {String(selectedSummary.why)} +

+
+ ) : null} +
+
+ )} + + {selectedSummary && selectedSummary.evidence.length > 0 ? ( + + +
+

+ Evidências relacionadas +

+

+ Referências que sustentam este registro. +

+
+
+ + + {selectedSummary.evidence.map((ref) => ( + + } + title={ref.value} + meta={ + + {ref.label} + + } + action={ + ref.label === "Item" ? ( + + ) : ref.label === "Spec" ? ( + + ) : null + } + /> + ))} + + +
+ ) : null} + + + +
+

+ Registro original +

+

+ Mensagem capturada na trilha de atividade. +

+
+
+ +

+ {selectedEvent.summary} +

+
+ {selectedEvent.correlationId ? ( + {selectedEvent.correlationId} + ) : null} + {selectedEvent.subject ? ( + + {subjectLabel(selectedEvent.subject.type)}:{" "} + {selectedEvent.subject.id} + + ) : null} + {selectedEvent.id} +
+
+
+ + {selectedEvent.details ? ( + + + +
+ + Detalhes técnicos + + + JSON bruto para auditoria e depuração. + +
+ +
+ + +
+														{JSON.stringify(
+															selectedEvent.details,
+															null,
+															2,
+														)}
+													
+
+
+
+
+ ) : null} +
+ + )} +
+
+ + {/* Main content */} +
+
+ + +
+
+ + + trilha investigativa + +
+

Atividade

+

+ Origem, resultado e evidências do workspace em uma linha do + tempo auditável. +

+
+
+
+

{metrics.succeededEvents}

+

+ sucesso +

+
+
+

+ {metrics.failedEvents} +

+

+ falhas +

+
+
+

{metrics.todayEvents}

+

+ hoje +

+
+
+
+ +
+
+

+ Origem +

+

quem ou qual automação registrou

+
+
+

+ Evento +

+

o que mudou no trabalho

+
+
+

+ Evidência +

+

item, AC, decisão ou arquivo

+
+
+

+ Resultado +

+

efeito observável antes de agir

+
+
+
+
+ + {includeTechnicalEvents ? ( + + +
+ +
+

+ Eventos técnicos incluídos +

+

+ {metrics.systemEvents} automaçõ + {metrics.systemEvents !== 1 ? "es" : "o"} nesta consulta +

+
+
+ +
+
+ ) : null} + + {/* Metric cards */} +
+ + +

{metrics.totalEvents}

+

+ Total +

+
+
+ + +

+ {metrics.systemEvents} +

+

+ Sistema +

+
+
+ + +

+ {metrics.humanEvents} +

+

+ Humano +

+
+
+ + +

+ {metrics.agentEvents} +

+

+ Agente +

+
+
+
+ + {/* Filters */} + + +
+
+ + setSearch(e.target.value)} + aria-label="Buscar atividade" + /> +
+
+

+ Ator +

+ + {KIND_FILTERS.map((filter) => ( + setFilterKind(filter.value)} + > + {filter.label} + + ))} + +
+
+ setFilterSince(e.target.value)} + /> +
+
+ + {(search || filterKind || filterSince) && ( + + )} +
+
+
+
+ + {/* Timeline */} + {loading ? ( +
+

+ Carregando... +

+
+ ) : logs.length === 0 ? ( +
+ +

+ Nenhuma atividade encontrada +

+
+ ) : ( +
+ {grouped.map((group) => { + const isOpen = expandedDates.has(group.label); + return ( +
+ + {isOpen && ( + + {group.entries.map((entry, idx) => { + const isNoiseGroup = + ( + entry as OperationalAuditEvent & { + _noiseCount?: number; + _noiseKey?: string; + } + )._noiseCount !== undefined; + const noiseCount = ( + entry as OperationalAuditEvent & { + _noiseCount?: number; + } + )._noiseCount; + const noiseKeyVal = ( + entry as OperationalAuditEvent & { + _noiseKey?: string; + } + )._noiseKey; + const isLast = idx === group.entries.length - 1; + if (isNoiseGroup) { + return ( + + } + title={translateAction( + entry.action, + )} + description={`${noiseCount} atividades similares agrupadas para reduzir ruído operacional.`} + timestamp={formatTime( + entry.timestamp, + )} + action={ +
+ + x{noiseCount} + + +
+ } + last={isLast} + /> + ); + } + return ( + + } + title={entry.summary || entry.action} + description={`${entry.actor.label} · ${entry.subject?.id || "workspace"}`} + timestamp={formatTime(entry.timestamp)} + action={ +
+ + {translateAction( + entry.action, + )} + + + {entry.actor.label} + + {entry.subject ? ( + + {subjectLabel( + entry.subject.type, + )} + : {entry.subject.id} + + ) : null} + +
+ } + last={isLast} + /> + ); + })} +
+ )} +
+ ); + })} +
+ )} + + {/* Pagination */} + {total > 0 && ( +
+ + {total} atividade{total !== 1 ? "s" : ""} no total + +
+ + + Página {page} de {totalPages} + + +
+
+ )} +
+
+
+ ); +} diff --git a/packages/client/src/components/NavTabs/NavTabs.tsx b/packages/client/src/components/NavTabs/NavTabs.tsx deleted file mode 100644 index 2e073c3..0000000 --- a/packages/client/src/components/NavTabs/NavTabs.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { cn } from "../../lib/utils"; -import { Icon } from "@letra/ui"; -import type { IconName } from "@letra/ui"; - -type Tab = "home" | "specs" | "flow" | "context"; - -interface Props { - activeTab: Tab; - onTabChange: (tab: Tab) => void; -} - -const TABS: { id: Tab; label: string; icon: IconName }[] = [ - { id: "home", label: "Home", icon: "home" }, - { id: "specs", label: "Specs", icon: "specs" }, - { id: "flow", label: "Flow", icon: "flow" }, - { id: "context", label: "Context", icon: "context" }, -]; - -export function NavTabs({ activeTab, onTabChange }: Props) { - return ( - - ); -} diff --git a/packages/client/src/components/SetupWizard/InlineSetupWizard.tsx b/packages/client/src/components/SetupWizard/InlineSetupWizard.tsx index dc8aaaa..fae3f4a 100644 --- a/packages/client/src/components/SetupWizard/InlineSetupWizard.tsx +++ b/packages/client/src/components/SetupWizard/InlineSetupWizard.tsx @@ -1,506 +1,840 @@ -import { useCallback, useState } from "react"; -import { Button, Input, Badge, Card, CardContent, Icon } from "@letra/ui"; -import { TEMPLATES } from "./templates"; +import { useState, useRef } from "react"; +import { Button, Input, Textarea, Badge, Icon, Checkbox } from "@letra/ui"; import { cn } from "../../lib/utils"; -interface StageDef { - id: string; - name: string; - zone: "todo" | "doing" | "done"; -} - interface Props { onComplete: (workflow: unknown) => void; } -let stageCounter = 0; -function freshId(prefix = "stage"): string { - stageCounter++; - return `${prefix}-${stageCounter}`; -} - -const DEFAULT_STAGES: StageDef[] = [ - { id: "backlog", name: "Backlog", zone: "todo" }, - { id: "design", name: "Design", zone: "doing" }, - { id: "code", name: "Code", zone: "doing" }, - { id: "review", name: "Review", zone: "doing" }, - { id: "done", name: "Done", zone: "done" }, +const ADAPTERS = [ + { id: "opencode", label: "OpenCode" }, + { id: "cursor", label: "Cursor" }, + { id: "claude-code", label: "Claude Code" }, + { id: "windsurf", label: "Windsurf" }, + { id: "hermes", label: "Hermes" }, + { id: "vscode", label: "VS Code" }, + { id: "copilot", label: "Copilot" }, ]; -const TOOLS = [ - { id: "opencode", label: "OpenCode", icon: "code" as const }, - { id: "cursor", label: "Cursor", icon: "edit" as const }, - { id: "claude-code", label: "Claude Code", icon: "help" as const }, - { id: "windsurf", label: "Windsurf", icon: "chevron-right" as const }, - { id: "vscode", label: "VS Code", icon: "code" as const }, +const COMMON_ROOTS = [ + { path: "C:/Workspace", label: "C:/Workspace" }, + { path: "C:/Dev", label: "C:/Dev" }, + { path: "C:/Projects", label: "C:/Projects" }, + { path: "C:/Users", label: "C:/Users" }, + { path: "D:/", label: "D:/" }, ]; -type Step = "welcome" | "template" | "customize" | "tools" | "review"; +interface AgentPromptParams { + dirs: string[]; + name: string; + description: string; + workspacePath: string; +} -export default function InlineSetupWizard({ onComplete }: Props) { - const [step, setStep] = useState("welcome"); +const AGENT_PROMPTS: Record string> = { + opencode: ( + p, + ) => `Você é um arquiteto de software especializado em configurar o Letra (framework SDD — Specification-Driven Development) para novos workspaces. + +## Missão +Analise profundamente o workspace em **${p.workspacePath}** e seus diretórios monitorados (${p.dirs.join(", ")}) para gerar o harness completo do Letra - conjunto de arquivos de contexto que descrevem a arquitetura, decisões, glossário e fluxo de trabalho. + +## Passos + +### 1. Explorar o Workspace +- Varra todos os diretórios listados acima +- Identifique a stack principal (linguagem, framework, runtime, banco de dados) +- Detecte padrões de arquitetura (MVC, hexagonal, microsserviços, monólito, etc.) +- Mapeie a estrutura de diretórios e o propósito de cada módulo/pasta +- Identifique convenções de código (ESLint, Prettier, tsconfig, Dockerfile, CI/CD) +- Verifique se há package.json, Cargo.toml, pyproject.toml, Gemfile, go.mod, etc. +- Analise testes existentes (framework de teste, cobertura, padrões) + +### 2. Gerar Constitution (${p.workspacePath}/.letra/constitution.md) +Regras não-negociáveis do workspace: +- Stack e versões obrigatórias +- Padrões de arquitetura que devem ser seguidos +- Convenções de código (naming, organização de imports, testes) +- Regras de segurança (nunca expor secrets, validação de input, etc.) +- Práticas de CI/CD e deploy +- Como as specs devem ser escritas (thin specs, formato) + +### 3. Gerar Context (${p.workspacePath}/.letra/context.md) +Visão geral do workspace: +- Intent: propósito do workspace "${p.name}" — ${p.description} +- Domínio: problema de negócio que o workspace resolve +- Stack técnica completa (linguagem, frameworks, banco, infra) +- Restrições reais (prazos, equipe, limitações técnicas) +- Decisões arquiteturais importantes (com links para ADRs) +- Glossário de termos específicos do domínio + +### 4. Gerar Glossary (${p.workspacePath}/.letra/glossary.md) +- Termos técnicos e de domínio mapeados +- Abreviações e siglas usadas no projeto +- Nomes de módulos, pacotes e suas responsabilidades + +### 5. Configurar Fluxo SDLC +- Crie ${p.workspacePath}/.letra/workflow.json com os estágios: + - Backlog (todo) + - Spec Draft (doing) + - Spec Review (doing) + - Code (doing) + - Code Review (doing) + - Ready to PR (doing) + - Done (done) +- Para cada item existente no backlog, crie entries no workflow + +### 6. Gerar Spec Inicial +- ${p.workspacePath}/.letra/specs/_template/spec.md — template de spec +- ${p.workspacePath}/.letra/specs/README.md — guia do diretório de specs + +### 7. Adaptadores +Configure os adaptadores para: ${p.dirs.join(", ")} +- AGENTS.md (formato OpenCode) +- .cursorrules (formato Cursor) +- CLAUDE.md (formato Claude Code) +- .windsurfrules (formato Windsurf) +- .opencode/instructions.md (formato OpenCode) +- .github/copilot-instructions.md (formato GitHub Copilot) + +Cada adaptador deve conter: +- Seção de contexto com os diretórios monitorados +- Comandos disponíveis (pulse, sitrep, flow move, health) +- Seção de item ativo (se houver) +- Regras de handoff entre agentes + +## Formato de Saída +Gere todos os arquivos dentro de ${p.workspacePath}/.letra/. Retorne um resumo do que foi criado.`, + + cursor: ( + p, + ) => `You are an expert software architect configuring the Letra framework (SDD — Specification-Driven Development) for a workspace. + +## Mission +Deeply analyze the workspace at **${p.workspacePath}** and its monitored directories (${p.dirs.join(", ")}) to generate the complete Letra harness. + +## Steps + +### 1. Explore the Workspace +- Walk all listed directories +- Identify the main stack (language, framework, runtime, database) +- Detect architecture patterns (MVC, hexagonal, microservices, monolith, etc.) +- Map directory structure and each module's purpose +- Identify code conventions (linting, formatting, tsconfig, Dockerfile, CI/CD) +- Check for package.json, Cargo.toml, pyproject.toml, etc. +- Analyze existing tests (framework, coverage, patterns) + +### 2. Generate Constitution +Non-negotiable workspace rules in ${p.workspacePath}/.letra/constitution.md + +### 3. Generate Context +Workspace overview in ${p.workspacePath}/.letra/context.md including intent, domain, tech stack, constraints, architectural decisions. + +### 4. Generate Glossary +Technical and domain terms in ${p.workspacePath}/.letra/glossary.md + +### 5. Configure SDLC Flow +Workflow with stages: Backlog, Spec Draft, Spec Review, Code, Code Review, Ready to PR, Done. + +### 6. Generate Spec Template +- ${p.workspacePath}/.letra/specs/_template/spec.md +- ${p.workspacePath}/.letra/specs/README.md + +### 7. Configure .cursorrules +Generate .cursorrules with context, available commands, active item section, and handoff rules. + +Return a summary of everything created.`, - const stepOrder: Step[] = ["welcome", "template", "customize", "tools", "review"]; + "claude-code": ( + p, + ) => `You are configuring the Letra SDD framework for "${p.name}" at ${p.workspacePath}. Deeply analyze the workspace (${p.dirs.join(", ")}) — explore its stack, architecture, code conventions, and test patterns — then generate the complete Letra harness: + +1. **${p.workspacePath}/.letra/constitution.md** — architecture rules, code conventions, security policies +2. **${p.workspacePath}/.letra/context.md** — workspace intent, domain, tech stack, constraints, decisions +3. **${p.workspacePath}/.letra/glossary.md** — domain and technical terms +4. **${p.workspacePath}/.letra/workflow.json** — SDLC stages +5. **${p.workspacePath}/.letra/specs/** — template and README +6. **CLAUDE.md** — Claude Code adapter with workspace context, available commands, active items, and handoff rules + +For each file, reflect the actual project patterns you discover during exploration. The harness must match the real project profile.`, + + windsurf: ( + p, + ) => `Configure the Letra SDD framework for "${p.name}" at ${p.workspacePath}. Explore the codebase in ${p.dirs.join(", ")}, identify stack, architecture, patterns, and generate the full Letra harness: + +- .letra/constitution.md — rules and conventions +- .letra/context.md — workspace overview and decisions +- .letra/glossary.md — terms +- .letra/workflow.json — SDLC pipeline +- .letra/specs/ — spec template +- .windsurfrules — Windsurf adapter with workspace context, commands, and handoff + +Base every file on real project analysis, not generic templates.`, + + hermes: ( + p, + ) => `Configure Letra for "${p.name}" at ${p.workspacePath}. Analyze ${p.dirs.join(", ")} deeply — stack, architecture, code style, tests — then generate: + +- .letra/constitution.md, context.md, glossary.md +- .letra/workflow.json with SDLC stages +- .letra/specs/ template +- .hermes/instructions.md with workspace context, commands, and handoff rules + +Tailor every file to the actual project profile found during exploration.`, + + vscode: ( + p, + ) => `Configure Letra SDD for "${p.name}" at ${p.workspacePath}. Explore ${p.dirs.join(", ")}, detect tech stack, architecture, and conventions, then generate: + +- .letra/constitution.md, context.md, glossary.md +- .letra/workflow.json +- .letra/specs/ template +- .vscode/settings.json with recommended extensions for the detected stack +- .vscode/copilot-instructions.md with workspace context + +All files must reflect real project analysis, not generic templates.`, + + copilot: ( + p, + ) => `Analyze the workspace "${p.name}" at ${p.workspacePath}. Walk directories ${p.dirs.join(", ")}, identify stack, architecture, patterns, and configure Letra: + +- .letra/constitution.md — workspace rules +- .letra/context.md — intent, domain, stack, constraints +- .letra/glossary.md — terms +- .letra/workflow.json — SDLC stages +- .letra/specs/ — spec template +- .github/copilot-instructions.md — Copilot adapter with full workspace context + +Infer everything from actual codebase analysis.`, +}; + +type Step = "name" | "directories" | "template" | "review" | "done"; + +interface DirNode { + name: string; + path: string; + expanded: boolean; + loading: boolean; + children: DirNode[]; +} + +export default function InlineSetupWizard({ onComplete }: Props) { + const [step, setStep] = useState("name"); + const stepOrder: Step[] = ["name", "directories", "template", "review"]; const stepLabels: Record = { - welcome: "Boas-vindas", + name: "Nome", + directories: "Diretórios", template: "Template", - customize: "Estágios", - tools: "Ferramentas", review: "Revisão", + done: "Concluído", }; const currentIndex = stepOrder.indexOf(step); - const [projectName, setProjectName] = useState(""); - const [selectedTemplate, setSelectedTemplate] = useState(null); - const [stages, setStages] = useState(DEFAULT_STAGES); - const [selectedTools, setSelectedTools] = useState(["opencode"]); - function createWorkflow(data: { template?: string; stages?: StageDef[]; tools?: string[] }) { - const body: Record = { - tools: selectedTools, - name: projectName || undefined, - }; - if (data.stages) { - body.stages = data.stages.map((s) => ({ id: s.id, name: s.name, zone: s.zone })); - } else if (data.template) { - body.template = data.template; + // ── Step 1 state ── + const [workspaceName, setWorkspaceName] = useState(""); + const [description, setDescription] = useState(""); + const [workspacePath, setWorkspacePath] = useState(""); + const nameValid = workspaceName.trim().length > 0; + const descValid = description.trim().length >= 10; + const pathValid = workspacePath.trim().length > 0; + const step1Valid = nameValid && descValid && pathValid; + const wsBrowseRef = useRef(null); + + // ── Step 2 state ── + const [selectedDirs, setSelectedDirs] = useState([]); + const [customDirInput, setCustomDirInput] = useState(""); + const [customDirs, setCustomDirs] = useState([]); + + const [dirTrees, setDirTrees] = useState( + COMMON_ROOTS.map((r) => ({ + name: r.label, + path: r.path, + expanded: false, + loading: false, + children: [], + })), + ); + + const allDirs = [...selectedDirs, ...customDirs]; + const step2Valid = allDirs.length >= 1; + + function toggleDir(path: string) { + setSelectedDirs((prev) => + prev.includes(path) ? prev.filter((d) => d !== path) : [...prev, path], + ); + } + + function addCustomDir() { + const path = customDirInput.trim(); + if (path && !customDirs.includes(path) && !allDirs.includes(path)) { + setCustomDirs([...customDirs, path]); + setCustomDirInput(""); } - fetch("/api/workflow/template", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }) - .then((r) => r.json()) - .then((data) => { - if (data && !data.error) onComplete(data); - }); } + function removeCustomDir(path: string) { + setCustomDirs(customDirs.filter((d) => d !== path)); + } + + function handleWsBrowse(e: React.ChangeEvent) { + const files = e.target.files; + if (files && files.length > 0) { + const relPath = (files[0] as any).webkitRelativePath as string | undefined; + if (relPath) setWorkspacePath(relPath.split("/")[0]); + } + } + + async function toggleDirTree(node: DirNode) { + if (node.expanded) { + node.expanded = false; + setDirTrees([...dirTrees]); + return; + } + if (node.children.length === 0 && !node.loading) { + node.loading = true; + setDirTrees([...dirTrees]); + try { + const res = await fetch(`/api/fs/dirs?path=${encodeURIComponent(node.path)}`); + const data = await res.json(); + node.children = (data.dirs || []).map((d: { name: string; path: string }) => ({ + name: d.name, + path: d.path, + expanded: false, + loading: false, + children: [], + })); + } catch {} + node.loading = false; + } + node.expanded = true; + setDirTrees([...dirTrees]); + } + + // ── Step 3 state ── + const [selectedTools, setSelectedTools] = useState(["opencode"]); + const step3Valid = selectedTools.length >= 1; + function toggleTool(id: string) { setSelectedTools((prev) => prev.includes(id) ? prev.filter((t) => t !== id) : [...prev, id], ); } - function addStage() { - setStages([...stages, { id: freshId(), name: "", zone: "doing" }]); + // ── Step 4 (review) — submission ── + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(""); + const [generatedPrompt, setGeneratedPrompt] = useState(""); + const [createdWorkflow, setCreatedWorkflow] = useState(null); + + async function createWorkflow() { + setSubmitting(true); + setSubmitError(""); + const body: Record = { + tools: selectedTools, + name: workspaceName.trim(), + description: description.trim(), + workspacePath: workspacePath.trim(), + directories: allDirs, + template: "padrao", + }; + try { + const res = await fetch("/api/workflow/setup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = await res.json(); + setSubmitting(false); + if (data && !data.error) { + setCreatedWorkflow(data); + const primaryTool = selectedTools[0]; + const prompter = AGENT_PROMPTS[primaryTool] || AGENT_PROMPTS.opencode; + setGeneratedPrompt( + prompter({ + dirs: allDirs, + name: workspaceName.trim(), + description: description.trim(), + workspacePath: workspacePath.trim(), + }), + ); + setStep("done"); + } else { + setSubmitError(data?.error || "Erro desconhecido ao criar workspace"); + } + } catch (e) { + setSubmitting(false); + setSubmitError("Erro de conexão com o servidor"); + } } - function removeStage(id: string) { - setStages(stages.filter((s) => s.id !== id)); + function goNext() { + const idx = stepOrder.indexOf(step); + if (idx < stepOrder.length - 1) setStep(stepOrder[idx + 1]); } - function renameStage(id: string, name: string) { - setStages(stages.map((s) => (s.id === id ? { ...s, name } : s))); + function goBack() { + const idx = stepOrder.indexOf(step); + if (idx > 0) setStep(stepOrder[idx - 1]); } - function setZone(id: string, zone: "todo" | "doing" | "done") { - setStages(stages.map((s) => (s.id === id ? { ...s, zone } : s))); + function renderDirTree(nodes: DirNode[], depth = 0) { + return nodes.map((node) => ( +
+
+ + +
+ {node.expanded && + node.children.length > 0 && + renderDirTree(node.children, depth + 1)} +
+ )); } + const btnClass = + "transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] disabled:opacity-40 disabled:hover:scale-100"; + return (
-
- {/* ── Step Progress ── */} -
- {stepOrder.map((s, i) => ( -
- - {i < stepOrder.length - 1 && ( -
- )} +
+ {/* ── Progress ── */} + {step !== "done" && ( + <> +
+ {stepOrder.map((s, i) => ( +
+ + {i < stepOrder.length - 1 && ( +
+ )} +
+ ))}
- ))} -
-
- {stepOrder.map((s, i) => (
- - {stepLabels[s]} - - {i < stepOrder.length - 1 && } + {stepOrder.map((s, i) => ( +
+ + {stepLabels[s]} + + {i < stepOrder.length - 1 && } +
+ ))}
- ))} -
+ + )} - {/* ── Welcome ── */} - {step === "welcome" && ( -
-
+ {/* ═══ Step 1 — Name + Description + Path ═══ */} + {step === "name" && ( +
+
-

Bem-vindo ao Letra

+

Configurar Workspace

- Seu hub de specs e contexto para IA. Organize seu fluxo de trabalho - e mantenha seus agentes alinhados. + Defina o nome, descrição e local do workspace.

-
- setProjectName(e.target.value)} - /> - -
-
- {[ - { - icon: "edit" as const, - text: "Escrever specs para suas features", - }, - { icon: "flow" as const, text: "Organizar o fluxo de trabalho" }, - { - icon: "context" as const, - text: "Alimentar agentes de IA com contexto", - }, - { icon: "bar-chart" as const, text: "Acompanhar métricas e drift" }, - ].map((item, i) => ( -
+
+ + setWorkspaceName(e.target.value)} + /> +
+
+ + +
+
+ + +
+ + diff --git a/sketches/er-onboarding-v2/index.html b/sketches/er-onboarding-v2/index.html new file mode 100644 index 0000000..a1833e6 --- /dev/null +++ b/sketches/er-onboarding-v2/index.html @@ -0,0 +1,452 @@ + + + + +Letra — ER Onboarding v2 (Gates + Agentes) + + + +
+

Modelo de dados — Onboarding & Controle de Fluxo (v1)

+

Entidades, relacionamentos e schema JSON. Foco: configuração do projeto + política de agentes/humanos.

+ +
+
+ +
+
Workspace
+
+
iduuid
+
namestring
+
rootPathpath
+
createdAtiso8601
+
+
+ + +
+
Project
+
+
iduuid
+
workspaceIduuid FK
+
namestring
+
slugstring
+
templateIdstring
+
manifestPathpath
+
(sem flows[] — instância separada)
+
+
+ + +
+
Repository
+
+
iduuid
+
projectIduuid FK
+
localPathpath
+
remoteUrlstring?
+
kindenum
+
+
+ + +
+
FlowTemplate
+
+
idstring
+
versionsemver
+
stagesStageDef[]
+
(fonte: harness/flows/*.yaml)
+
+
+ + +
+
Gate
+
+
idstring
+
namestring
+
typeenum
+
blockingboolean
+
policyRefstring?
+
+
+ + +
+
AgentRole
+
+
idstring
+
labelstring
+
allowedStagesstring[]
+
ex: reviewer, security, analyst
+
+
+ + +
+
Capability
+
+
idstring
+
actionstring
+
scopeenum
+
ex: comment, approve, close
+
+
+ + +
+
AgentCapability
+
+
iduuid
+
roleIdstring FK
+
capabilityIdstring FK
+
gateIdstring FK
+
allowboolean
+
+
+
+ + +
+
Relacionamentos
+
+
+ Workspace + 1 ──── N + CONTÉM + N ──── 1 + Project +
+
+ Project + 1 ──── N + ESCANEIA + N ──── 1 + Repository +
+
+ Project + N ──── N + APLICA + N ──── 1 + Gate (via ProjectGate) +
+
+ Gate + 1 ──── N + PERMITE + N ──── 1 + AgentCapability +
+
+ AgentRole + 1 ──── N + POSSUI + N ──── 1 + AgentCapability +
+
+ Capability + 1 ──── N + É USADA EM + N ──── 1 + AgentCapability +
+
+ Capability + define ação + EM + scope + (comment/approve/close) +
+
+
+ Workspace / AgentCapability + Project + Repository + FlowTemplate + Gate + AgentRole + Capability +
+
+
+ +

Schema JSON — Instâncias de exemplo

+

Formato dos arquivos/registros persistidos no workspace.

+ +
+ +
+
workspace.yaml
+
+
{
+  "id": "ws_8f3a12",
+  "name": "default",
+  "rootPath": "/home/user/.letra/workspace"
+}
+
+
+ + +
+
projects/{slug}.json
+
+
{
+  "id": "proj_9b2c44",
+  "workspaceId": "ws_8f3a12",
+  "name": "plataforma-pagamentos",
+  "slug": "plataforma-pagamentos",
+  "templateId": "sdlc"
+}
+
+
+ + +
+
repositories/{id}.json
+
+
{
+  "id": "repo_1a7d09",
+  "projectId": "proj_9b2c44",
+  "localPath": "C:\\projetos\\plataforma",
+  "remoteUrl": "https://github.com/org/plataforma",
+  "kind": "mono"
+}
+
+
+ + +
+
harness/gates/{id}.yaml
+
+
// Gate: human-approved-code
+{
+  "id": "human-approved-code",
+  "name": "Aprovação Humana (Code)",
+  "type": "human",
+  "blocking": true,
+  "policyRef": "policies/code-review.json"
+}
+
+
+ + +
+
harness/roles/{id}.yaml
+
+
// AgentRole: reviewer
+{
+  "id": "reviewer",
+  "label": "Code Reviewer",
+  "allowedStages": ["code-review", "security"]
+}
+
+
+ + +
+
harness/capabilities/{id}.yaml
+
+
// Capability: approve-pr
+{
+  "id": "approve-pr",
+  "action": "pull_request.approve",
+  "scope": "pr"
+}
+
+// Capability: comment-review
+{
+  "id": "comment-review",
+  "action": "pull_request.review.comment",
+  "scope": "pr"
+}
+
+
+ + +
+
agent_capabilities + policy
+
+
// AgentCapability
+{
+  "id": "ac_01",
+  "roleId": "reviewer",
+  "capabilityId": "approve-pr",
+  "gateId": "human-approved-code",
+  "allow": false
+}
+
+// policy/code-review.json
+{
+  "minReviewers": 1,
+  "requireHuman": true,
+  "allowAgentToComment": true,
+  "allowAgentToApprove": false
+}
+
+
+
+
+ + diff --git a/sketches/er-onboarding/index.html b/sketches/er-onboarding/index.html new file mode 100644 index 0000000..e0b1bf4 --- /dev/null +++ b/sketches/er-onboarding/index.html @@ -0,0 +1,414 @@ + + + + +Letra — ER Onboarding v1 + + + +
+

Modelo de dados — Onboarding & Configuração (v1)

+

Entidades, relacionamentos e schema JSON correspondente. Foco: primeira execução do letra.

+ +
+
+ +
+
Workspace
+
+
iduuid
+
namestring
+
rootPathpath
+
createdAtiso8601
+
updatedAtiso8601
+
+
+ + +
+
Project
+
+
iduuid
+
workspaceIduuid FK
+
namestring
+
slugstring
+
templateIdstring FK
+
manifestPathpath
+
statusenum
+
+
+ + +
+
Repository
+
+
iduuid
+
projectIduuid FK
+
localPathpath
+
remoteUrlstring?
+
branchstring?
+
kindenum
+
+
+ + +
+
FlowTemplate
+
+
idstring
+
versionsemver
+
labelstring
+
stagesStageDef[]
+
(source: harness/flows/*.yaml)
+
+
+ + +
+
Gate
+
+
idstring
+
namestring
+
typeenum
+
blockingboolean
+
+
+ + +
+
ProjectGate
+
+
iduuid
+
projectIduuid FK
+
gateIdstring FK
+
requiredboolean
+
orderint?
+
+
+
+ + +
+
Relacionamentos
+
+
Workspace1 ──── NCONTÉMN ──── 1Project
+
Project1 ──── 1TEM1 ──── 1FlowTemplate (via templateId)
+
Project1 ──── NESCANEIAN ──── 1Repository
+
ProjectN ──── NAPLICAN ──── 1Gate (via ProjectGate)
+
FlowTemplate1 ──── NDEFINEN ──── 1StageDef
+
+
+ Workspace + Project + Repository + FlowTemplate + Gate + ProjectGate +
+
+
+ +

Schema JSON — Instâncias de exemplo

+

Formato dos arquivos/registros que serão persistidos no workspace.

+ +
+ +
+
workspace.yaml
+
+
{
+  "id": "ws_8f3a12",
+  "name": "default",
+  "rootPath": "/home/user/.letra/workspace",
+  "createdAt": "2026-06-22T10:00:00Z",
+  "updatedAt": "2026-06-22T10:00:00Z"
+}
+
+
+ + +
+
projects/{slug}.json
+
+
{
+  "id": "proj_9b2c44",
+  "workspaceId": "ws_8f3a12",
+  "name": "plataforma-pagamentos",
+  "slug": "plataforma-pagamentos",
+  "templateId": "sdlc",
+  "manifestPath": "../repos/plataforma/.letra/manifest.json",
+  "status": "active"
+}
+
+
+ + +
+
repositories/{id}.json
+
+
{
+  "id": "repo_1a7d09",
+  "projectId": "proj_9b2c44",
+  "localPath": "C:\\projetos\\plataforma",
+  "remoteUrl": "https://github.com/org/plataforma",
+  "branch": "main",
+  "kind": "mono"
+}
+
+
+ + +
+
letra.manifest.json
+
+
{
+  "schemaVersion": "1.0",
+  "projectId": "proj_9b2c44",
+  "templateId": "sdlc",
+  "harnessVersion": "0.1.0",
+  "repositories": [
+    { "id": "repo_1a7d09", "path": "../../" }
+  ],
+  "gates": [
+    "human-approved-spec",
+    "human-approved-code"
+  ]
+}
+
+
+ + +
+
harness/flows/sdlc.yaml
+
+
{
+  "id": "sdlc",
+  "version": "0.1.0",
+  "label": "SDLC",
+  "stages": [
+    { "id": "backlog", "label": "Backlog" },
+    { "id": "spec-draft", "label": "Spec Draft" },
+    { "id": "spec-review", "label": "Spec Review" },
+    { "id": "code", "label": "Code" },
+    { "id": "code-review", "label": "Code Review" },
+    { "id": "security", "label": "Security" },
+    { "id": "ready-to-pr", "label": "Ready to PR" },
+    { "id": "done", "label": "Done" }
+  ]
+}
+
+
+ + +
+
gates + project_gates
+
+
// Gate (do harness)
+{
+  "id": "human-approved-spec",
+  "name": "Aprovação Humana (Spec)",
+  "type": "human",
+  "blocking": true
+}
+
+// ProjectGate (instância)
+{
+  "id": "pg_4e5f01",
+  "projectId": "proj_9b2c44",
+  "gateId": "human-approved-spec",
+  "required": true,
+  "order": 1
+}
+
+
+
+
+ + diff --git a/sketches/onboarding-linear/index.html b/sketches/onboarding-linear/index.html new file mode 100644 index 0000000..267b27a --- /dev/null +++ b/sketches/onboarding-linear/index.html @@ -0,0 +1,356 @@ + + + + +Letra — Setup + + + + + +
+ +
+

Nome do projeto

+

Identificador único no workspace do letra.

+
+ + +

Minúsculas, números e hífens. Ex: meu-app, backend-api

+
+
+
+ +
+
+ + + + + + + + + + + + +
+ + + + + diff --git a/sketches/onboarding-web-vercel/index.html b/sketches/onboarding-web-vercel/index.html new file mode 100644 index 0000000..0f36559 --- /dev/null +++ b/sketches/onboarding-web-vercel/index.html @@ -0,0 +1,362 @@ + + + + +Letra — Setup + + + +
+ + + +
+

Qual o nome do seu projeto?

+

Será usado como identificador no workspace do letra.

+
+ + +

Letras minúsculas, números e hífens. Sem espaços.

+
+
+
+ +
+
+ + + + + + + + + + + + +
+ + + + diff --git a/sketches/references/README.md b/sketches/references/README.md new file mode 100644 index 0000000..f71f3e2 --- /dev/null +++ b/sketches/references/README.md @@ -0,0 +1,94 @@ +# Referências de UX — Dashboards de Projeto com Agentes + +**Data:** 2026-06-21 +**Imagens:** +- `sketches/references/agentwork-projeto-astro.png` +- `sketches/references/pitagor-projeto-orion.png` + +**Contexto:** Referências visuais para o Letra (workspace-centric, hexagonal, v1). + +--- + +## Padrões observados (comuns aos dois) + +| Padrão | Como aparece | Relevância para Letra | +|--------|-------------|----------------------| +| **Sidebar esquerda como âncora** | Navegação principal sempre visível, não some ao scroll | TUI do Letra (`letra workspace list`, `letra status`) pode usar sidebar ASCII fixa | +| **Seletor de contexto no topo** | Dropdown ou título do projeto/workspace atual | `letra workspace switch` → mostrar nome do workspace ativo em todo momento | +| **Status visual por avatar/cor** | Cada agente/ator tem cor e ícone fixos | Stages do SDLC (Spec, Code, Review, Security) merecem cores fixas | +| **Board/Kanban como vista canonical** | Colunas: Backlog → Pronto → Em andamento → Revisão → Concluído | Stage do SDLC é literalmente isso. Pode reaproveitar a estrutura | +| **Feed de interações à direita** | Timeline de ações/chat | Perfeito para `letra review` — mostrar comentários, aprovadores, timestamps | +| **Métricas em donut/barra** | Progresso geral, atividades por status, agentes online | Mesmas métricas do Letra: cycle time, review wait, throughput | +| **Tabs para alternância de visão** | Kanban / Timeline / Arquivos / Config | Letra: `letra flow` (board) / `letra metrics` (timeline) / `letra repo` (arquivos) | +| **Ações primárias no topo** | Botão "+ Nova Atividade" | `letra flow start`, `letra review`, `letra pr` como botões de ação no dashboard | +| **Input de resposta inline** | "Responder nessa interação..." | No `letra review`, comentário direto no diff | + +--- + +## Divergências (qual escolher para Letra) + +| Dimensão | AgentWork (Astro) | Pitagor (Orion) | Letra deve... | +|----------|-------------------|-----------------|---------------| +| **Metáfora** | Fluxo de agentes colaborando (serial) | Kanban de demandas (paralelo) | **Kanban** (mais próximo de stages lineares) | +| **Complexidade** | Alto (5 agentes, cores, setas, timeline) | Médio (colunas, cards, barra lateral) | **Médio** — evita over-design na v1 | +| **Foco** | Visualização de processo | Execução e movimentação | **Ambos** — board + timeline | +| **Tipografia** | Display font para títulos, cards com sombra | Limpo, sans-serif, whitespace generoso | **Pitagor** — mais próximo de Vercel/Linear | +| **Acessibilidade** | Cores distintas por agente | Contraste bom, ícones + texto | Seguir Pitagor | + +--- + +## Aplicação direta no Letra + +### Dashboard (`letra` / `letra status`) +``` +┌─────────────────────────────────────────────────────┐ +│ letra. Workspace: pix-credito [switch] [config] │ +├──────────┬──────────────────────────┬───────────────┤ +│ Workspaces│ Fluxo SDLC │ Interações │ +│ │ ┌─────┬─────┬─────┐ │ recentes │ +│ > pix- │ │Spec │Code │Review│ │ │ +│ credito│ │ ✓ │ ⟳ │ │ │ 10:32 PR #142 │ +│ │ └─────┴─────┴─────┘ │ 10:45 approved│ +│ auth- │ Gates: spec-review ✓ │ │ +│ redesign│ code-review ⟳ │ │ +│ │ │ │ +│ + novo │ Métricas │ │ +│ │ Cycle: 2.3d Rev: 4.2h │ │ +└──────────┴──────────────────────────┴───────────────┘ +``` + +### `letra review` TUI +- Diff à esquerda (como Pitagor) +- Checklist + comentários à direita (como AgentWork feed) +- Ações: Approve / Request Changes / Comment +- Status visuais por cor (verde = aprovado, amarelo = atenção, vermelho = bloqueado) + +--- + +## Insights para a arquitetura + +1. **Workspace é o contexto canônico** — ambos mostram o nome do projeto no topo, sempre visível. Letra deve fazer o mesmo com o workspace ativo. + +2. **Stage = coluna + cor + ícone** — mapeamento direto: + - Spec Draft → roxo + - Code → azul + - Review → amarelo + - Security → laranja + - Done → verde + +3. **Gate = indicador na coluna** — se o gate `spec-review` está pendente, a coluna Spec tem um ícone de cadeado ou `⏳`. + +4. **Interações são first-class** — tanto AgentWork quanto Pitagor tratam ações/comentários como cidadãos de primeira classe. No Letra, `review comments` e `gate approvals` são entidades do core, não só texto solto. + +5. **Progresso = função de atividades concluídas** — donut chart ou barra simples. Letra pode usar: + - `(stages completados) / (total de stages) * 100` + - Ou weighted: cada stage tem peso diferente (Spec=10%, Code=30%, Review=30%, Security=20%, Done=10%) + +--- + +## Próximo passo sugerido + +Quer que eu: +1. **Desenhe o dashboard do Letra** (mockup TUI denso) baseado nessas referências? +2. **Defina as cores e ícones dos stages SDLC** antes de ir para o schema? +3. **Volte para os mockups do wizard** e ajuste algo com base no que vimos aqui? diff --git a/sketches/references/composer_2026-06-21_18-26-40-295_9c95ad.png b/sketches/references/composer_2026-06-21_18-26-40-295_9c95ad.png new file mode 100644 index 0000000..10c74af Binary files /dev/null and b/sketches/references/composer_2026-06-21_18-26-40-295_9c95ad.png differ diff --git a/sketches/references/composer_2026-06-21_18-28-29-021_d95096.png b/sketches/references/composer_2026-06-21_18-28-29-021_d95096.png new file mode 100644 index 0000000..c5cee3d Binary files /dev/null and b/sketches/references/composer_2026-06-21_18-28-29-021_d95096.png differ diff --git a/sketches/sdlc-flow/index.html b/sketches/sdlc-flow/index.html new file mode 100644 index 0000000..7ffe782 --- /dev/null +++ b/sketches/sdlc-flow/index.html @@ -0,0 +1,359 @@ + + + + +Letra — SDLC Flow Diagram + + + +
+

SDLC Template — Fluxo, Gates e Agentes

+

Fases, pontos de verificação e permissões por role. Base do template built-in do letra.

+ +
+ +
+
ESTÁGIO 1
+
+
Backlog
+
Item registrado, aguardando priorização.
+
+
+
→
+ + +
+
ESTÁGIO 2
+
+
Spec Draft
+
Agente gera rascunho de especificação a partir do ticket.
+
analyst
+
+
gate: —
+
+
→
+ + +
+
ESTÁGIO 3
+
+
Spec Review
+
Humano aprova ou rejeita a especificação.
+
reviewerhumano
+
+
GATE · human-approved-spec
+
+
→
+ + +
+
ESTÁGIO 4
+
+
Code
+
Implementação das mudanças aprovadas.
+
builder
+
+
gate: —
+
+
→
+ + +
+
ESTÁGIO 5
+
+
Code Review
+
Revisão de diff, comentários, aprovação final.
+
reviewerhumano
+
+
GATE · human-approved-code
+
+
→
+ + +
+
ESTÁGIO 6
+
+
Security
+
Scan de vulnerabilidades, análise de dependências.
+
securityhumano
+
+
GATE · security-clear
+
+
→
+ + +
+
ESTÁGIO 7
+
+
Ready to PR
+
Gera descrição, adiciona checks, cria PR.
+
pr-bot
+
+
GATE · all-acs-passing
+
+
→
+ + +
+
ESTÁGIO 8
+
+
Done
+
PR mergeado, ciclo concluído.
+
+
+
+ +
+ Gate human (blocking) + Gate automated + Estágio sem gate + Agente + Humano +
+ +
+

Política de permissões por role (AgentCapability + policyRef)

+
+
+
analyst
+
• read_code
+
• write_spec
+
• generate_doc
+
• approve (não)
+
Stage: Spec Draft
+
+
+
builder
+
• read_code
+
• write_code
+
• run_tests
+
• approve (não)
+
Stage: Code
+
+
+
reviewer
+
• read_code
+
• comment_pr
+
• suggest_changes
+
• approve (não)
+
Gate: human-approved-code (allow=false)
+
+
+
security
+
• scan_dependencies
+
• report_findings
+
• approve (não)
+
Gate: security-clear (blocking)
+
+
+
pr-bot
+
• create_pr
+
• assign_reviewers
+
• merge (não)
+
Stage: Ready to PR
+
+
+
humano
+
• approve spec
+
• approve code
+
• approve security
+
• merge PR
+
• override gates
+
Gates: human-* (always allowed)
+
+
+
+ +
+

Schema JSON — SDLC Template (harness/flows/sdlc.yaml)

+ + + + + + + + + + +
CampoTipoDescrição
idstring"sdlc"
versionsemver"0.1.0"
stages[]StageDef[]8 estágios em ordem
gates[]GateRef[]Gates usados pelos estágios
defaultRoles[]string[]["analyst","builder","reviewer","security","pr-bot"]
policyRefstring"policies/sdlc-default.json"
+
+ +
+

Schema JSON — Gate + Policy de exemplo

+ + + + + + + + + + + +
CampoValor
gateIdhuman-approved-code
typehuman
blockingtrue
policy.minReviewers1
policy.requireHumantrue
policy.allowAgentToCommenttrue
policy.allowAgentToApprovefalse
+
+
+ + diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..04bc25e --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "skills": { + "shadcn": { + "source": "shadcn/ui", + "sourceType": "github", + "skillPath": "skills/shadcn/SKILL.md", + "computedHash": "0dc0edeeaec2ae491ae506806517866a3f9bd48d04e0458feb37e0252862891f" + } + } +} diff --git a/software-development/letra-dev-loop/SKILL.md b/software-development/letra-dev-loop/SKILL.md new file mode 100644 index 0000000..d583d6d --- /dev/null +++ b/software-development/letra-dev-loop/SKILL.md @@ -0,0 +1,47 @@ +# Letra Dev Loop + +Fluxo oficial do projeto letra. Siga na ordem: pulse -> context -> focus -> spec. + +## Pré-sessão +- Rode `letra pulse` +- Leia `AGENTS.md`, `context.md`, `focus.md`, `specs/` +- Não mova `focus.md` manualmente; use `letra focus` + +## Regras fixas +- Use `letra flow move ... --auto`; nunca use `--force` +- Registre logs com `letra log add ... --item ` +- Marque ACs com `letra ac done --spec ` +- Não transite estágios sem aprovação humana +- Só mova para Code após aprovação da spec +- Após criar a spec, faça release imediato do item e aguarde aprovação humana antes de Code +- Quando não houver mais estágios, mova para Done manualmente + +## Regras de branch e release +- Nunca fazer commit direto em `main` +- Toda linha de trabalho deve sair de `development` +- Para cada item/feature, crie uma branch `feature/{nome-feature}` +- Desenvolva, valide e commit **apenas** dentro dessa `feature/*` +- Quando estiver pronta, abra um PR `feature/* -> development` +- Após revisão/merge na `development`, o release deve ser gerado a partir dela + +## Release / Publish (regra obrigatória) +- O publish **deve** ser feito a partir de um checkout limpo de `main` +- Passos: + 1. `git checkout main` + 2. `git clean -fd` (só remove arquivos não rastreados não-ignorados; preserva `.gitignore`) + 3. `git checkout .` (descarta alterações locais em arquivos tracked) + 4. `git pull origin main` + 5. Confirme `package.json` e `packages//package.json` com a versão da tag + 6. `npm -w packages/ run typecheck` (ex: `npm -w packages/cli run typecheck`) + 7. `npm -w packages/ run test` + 8. `npm publish --workspace packages/ --access public` +- Nunca publique a partir de uma working tree suja +- Se o npm rejeitar por versão existente, confira se já foi publicado antes e não repita publish +- Tag e publish devem usar a mesma versão; atualize package.json **antes** de publicar + +## Checklist de publish +- [ ] Checkout limpo de `main` +- [ ] Typecheck verde (somente workspaces com script) +- [ ] Testes verdes +- [ ] Pacote versionado corretamente +- [ ] Publicação confirmada no npm diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..d23b75e --- /dev/null +++ b/start.bat @@ -0,0 +1,13 @@ +@echo off +echo Starting Letra... +echo. + +REM Start CLI server in background +start "Letra CLI Server" cmd /c "node packages\cli\dist\index.js flow serve" + +REM Wait for CLI server +timeout /t 3 /nobreak >nul + +REM Start Vite dev server +cd packages\client +call npm run dev