diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f5bd8b4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,141 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + # The coverage floor every app has to clear. vitest.shared.ts carries the same + # number so the failure happens locally first; scripts/ci/check-coverage.mjs + # re-reads it here so a package cannot configure its own gate away. + COVERAGE_THRESHOLD: "76" + +jobs: + # One matrix entry per workspace package that has tests. Runs on Linux because + # it only reads package.json files — nothing is installed or built here. + discover: + name: Discover workspace + runs-on: ubuntu-latest + outputs: + packages: ${{ steps.scan.outputs.packages }} + any: ${{ steps.scan.outputs.any }} + rust: ${{ steps.scan.outputs.rust }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - id: scan + run: node scripts/ci/workspace-packages.mjs >> "$GITHUB_OUTPUT" + + # Windows because that is the only platform the product supports. + test: + name: test · ${{ matrix.package.path }} + needs: discover + if: needs.discover.outputs.any == 'true' + runs-on: windows-latest + strategy: + # One package failing must not hide the state of the others. + fail-fast: false + matrix: + package: ${{ fromJSON(needs.discover.outputs.packages) }} + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Test with coverage + run: pnpm --filter "${{ matrix.package.name }}" run ${{ matrix.package.script }} + + - name: Enforce the coverage floor + run: node scripts/ci/check-coverage.mjs "${{ matrix.package.path }}" + + - name: Upload the coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-${{ matrix.package.slug }} + path: ${{ matrix.package.path }}/coverage + if-no-files-found: ignore + retention-days: 7 + + checks: + name: typecheck & lint + needs: discover + if: needs.discover.outputs.any == 'true' + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - run: pnpm run typecheck + + - run: pnpm run lint + + # Skipped until crates/ exists. No coverage gate here: the recorder is driven + # through its JSON-RPC boundary, and a line count over WASAPI glue would + # measure the wrong thing. + rust: + name: rust + needs: discover + if: needs.discover.outputs.rust == 'true' + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - uses: Swatinem/rust-cache@v2 + + - run: cargo fmt --all --check + + - run: cargo clippy --all-targets --all-features -- -D warnings + + - run: cargo test --locked --all-features + + # The single check to require on the branch. Skipped jobs are fine — an empty + # workspace has nothing to test — but a failed or cancelled one is not. + ci: + name: CI + if: always() + needs: [discover, test, checks, rust] + runs-on: ubuntu-latest + steps: + - name: Fail if any job failed or was cancelled + if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') + run: exit 1 + + - name: Report + run: | + echo "discover: ${{ needs.discover.result }}" + echo "test: ${{ needs.test.result }}" + echo "checks: ${{ needs.checks.result }}" + echo "rust: ${{ needs.rust.result }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f965fad --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,115 @@ +name: Release + +on: + push: + tags: ["v*"] + workflow_dispatch: + +concurrency: + # Never two runs publishing the same tag. + group: release-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: write + +jobs: + release: + name: Build and publish the installer + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + # Until task 10.1 lands there is nothing to package. Saying so beats a + # pnpm error about a filter matching no project. + - name: Check the desktop app exists + shell: pwsh + run: | + if (-not (Test-Path "apps/desktop/package.json")) { + Write-Error "apps/desktop does not exist yet — there is nothing to release (plan task 10.1)." + exit 1 + } + + - name: Check the tag matches the app version + if: startsWith(github.ref, 'refs/tags/v') + shell: pwsh + run: | + $tag = "${{ github.ref_name }}".TrimStart("v") + $version = (Get-Content "apps/desktop/package.json" -Raw | ConvertFrom-Json).version + if ($tag -ne $version) { + Write-Error "tag v$tag does not match apps/desktop/package.json version $version" + exit 1 + } + Write-Host "releasing $version" + + - name: Refuse to republish an existing release + if: startsWith(github.ref, 'refs/tags/v') + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + # A published release has been downloaded; deleting it does not undo that. + gh release view "${{ github.ref_name }}" 2>$null + if ($LASTEXITCODE -eq 0) { + Write-Error "release ${{ github.ref_name }} already exists — bump the version and tag again" + exit 1 + } + Write-Host "no release for ${{ github.ref_name }} yet" + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Build the installer + shell: pwsh + env: + # electron-builder picks these up on its own and signs when they are + # set. Absent, it builds unsigned — see adr:0009. + CSC_LINK: ${{ secrets.WINDOWS_CERTIFICATE }} + CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + # Publishing is this workflow's job, not electron-builder's. + GH_TOKEN: "" + run: pnpm --filter "@project-wiki/desktop" run package + + - name: Collect the artifacts and their checksums + id: collect + shell: pwsh + run: | + $dir = "apps/desktop/release" + $installers = @(Get-ChildItem -Path $dir -Filter *.exe -File -ErrorAction SilentlyContinue) + if ($installers.Count -eq 0) { + Write-Error "no .exe found in $dir — the package script produced no installer" + exit 1 + } + $installers | ForEach-Object { Write-Host " $($_.Name) $([math]::Round($_.Length / 1MB, 1)) MB" } + Get-FileHash -Algorithm SHA256 $installers.FullName | + ForEach-Object { "$($_.Hash.ToLower()) $(Split-Path $_.Path -Leaf)" } | + Out-File -FilePath "$dir/SHA256SUMS.txt" -Encoding utf8 + Get-Content "$dir/SHA256SUMS.txt" + + - name: Publish the release + if: startsWith(github.ref, 'refs/tags/v') + uses: softprops/action-gh-release@v2 + with: + files: | + apps/desktop/release/*.exe + apps/desktop/release/SHA256SUMS.txt + generate_release_notes: true + # A tag carrying a suffix — v0.1.0-beta.1 — is not a stable release. + prerelease: ${{ contains(github.ref_name, '-') }} + fail_on_unmatched_files: true + + # workflow_dispatch builds without a tag: useful for checking the packaging + # still works without publishing anything. + - name: Upload the installer as a build artifact + if: ${{ !startsWith(github.ref, 'refs/tags/v') }} + uses: actions/upload-artifact@v4 + with: + name: installer-${{ github.sha }} + path: apps/desktop/release/* + retention-days: 7 diff --git a/docs/adr/0001-no-backend-byok.md b/docs/adr/0001-no-backend-byok.md new file mode 100644 index 0000000..0e19945 --- /dev/null +++ b/docs/adr/0001-no-backend-byok.md @@ -0,0 +1,43 @@ +--- +status: accepted +--- + +# 0001 · No backend: BYOK, no accounts, no telemetry + +## Context + +The application handles two categories of sensitive data: meeting audio and a project's +internal documentation. It needs transcription, which is a third-party service. The +question is who talks to that service — a backend of ours, or the user's machine. + +A backend would bring real convenience: a single credential, aggregated billing, +configuration changes without a release. It would also bring the position of data +processor under the LGPD, infrastructure cost proportional to usage, and the obligation +to answer what happens to the audio of a confidential meeting that passed through our +servers. + +## Decision + +There is no backend. The user supplies their own transcription credential, the +application talks to the provider directly, and there is no account, no authentication +of our own and no telemetry of any kind — including anonymous crash telemetry. + +## Consequences + +The audio and the documents never pass through a server of ours, which leaves us in the +position of a software vendor rather than a data processor. That simplifies the LGPD +position considerably — and stops being true the instant any hosted component is added. +This is why it is an ADR and not a line in a README: the cost is not adding the +component, it is losing the position. + +The cost is onboarding: the user has to create an account somewhere else and paste a +credential before the first recording works. The application validates the credential on +the spot precisely because a wrong key discovered after an hour of recording is the worst +possible way to discover it. + +Without telemetry, we do not know what breaks on anyone's machine. Diagnosis depends on +the local log and on what the user reports. + +There is a way out for anyone who does not want even that: transcribe locally, with no +credential at all. It exists because this ADR is only convincing if the privacy argument +has a path that depends on trusting no third party whatsoever. diff --git a/docs/adr/0001-sem-backend-byok.md b/docs/adr/0001-sem-backend-byok.md deleted file mode 100644 index 91bfbe4..0000000 --- a/docs/adr/0001-sem-backend-byok.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -status: accepted ---- - -# 0001 · Nenhum backend: BYOK, sem contas, sem telemetria - -## Context - -O aplicativo processa duas categorias de dado sensível: áudio de reuniões e a -documentação interna de projetos. Ele precisa de transcrição, que é um serviço de -terceiro. A pergunta é quem fala com esse serviço — um backend nosso, ou a máquina do -usuário. - -Um backend traria conveniência real: uma credencial só, faturamento agregado, -atualização de configuração sem release. Traria também a posição de operador de dados -sob a LGPD, custo de infraestrutura proporcional ao uso, e a obrigação de responder o -que acontece com o áudio de uma reunião confidencial que passou pelos nossos servidores. - -## Decision - -Não existe backend. O usuário fornece a própria credencial de transcrição, o aplicativo -fala direto com o provedor, e não há conta, autenticação própria nem telemetria de -nenhum tipo — inclusive telemetria de erro anônima. - -## Consequences - -O áudio e os documentos nunca passam por servidor nosso, o que nos deixa na posição de -fornecedor de software e não de operador de dados. Isso simplifica bastante a posição em -relação à LGPD — e deixa de valer no instante em que qualquer componente hospedado for -adicionado. É por isso que isto é um ADR e não uma linha de README: o custo não é -adicionar o componente, é perder a posição. - -O custo é o onboarding: o usuário precisa criar conta em outro lugar e colar uma -credencial antes de a primeira gravação funcionar. O aplicativo valida a credencial na -hora justamente porque uma chave errada descoberta depois de uma hora de gravação é a -pior forma de descobrir. - -Sem telemetria, não sabemos o que quebra na máquina de ninguém. Diagnóstico depende de -log local e do que o usuário reportar. - -Há uma saída para quem não quer nem isso: transcrever localmente, sem credencial alguma. -Ela existe porque este ADR só é convincente se o argumento de privacidade tiver um -caminho que não dependa de confiar em terceiro nenhum. diff --git a/docs/adr/0002-workspace-as-a-local-markdown-folder.md b/docs/adr/0002-workspace-as-a-local-markdown-folder.md new file mode 100644 index 0000000..2ceb937 --- /dev/null +++ b/docs/adr/0002-workspace-as-a-local-markdown-folder.md @@ -0,0 +1,58 @@ +--- +status: accepted +--- + +# 0002 · The workspace is a local markdown folder, unversioned + +## Context + +The product accumulates a project's documentation and has to answer "what is the current +state of project X, and how did we get here?". Where that content lives decides almost +everything downstream: who can read it, what happens when it changes, and what can be +recovered when someone gets it wrong. + +The options were an embedded database, a service, or files. And within files there was +the sub-choice of versioning the folder with git — which would give per-line history for +free. + +## Decision + +The workspace is a folder on the user's disk, with one directory per project. Inside each +project, `raw/` holds the original sources, immutable once written, and `wiki/` holds the +pages in markdown. + +Nothing in the application creates, reads or writes a git repository. + +What replaces the history git would have given: + +- **Every write is atomic** — temporary file plus rename — so that an application killed + midway does not leave half a page behind. +- **Every write snapshots first** the pages it is about to touch, into a `.state/` folder + that is not content. +- **Every write enters an operation log** with origin and timestamp, and any operation can + be undone by its id. + +## Consequences + +The user owns their data in a format that Obsidian, VS Code, `grep` and any agent already +read. There is no tool to install and no repository concept for someone who is not a +developer, and the product stays simple to explain: it is a folder. + +What is lost, without softening it: + +- **There is no per-file history.** Nothing answers "when did this sentence appear and + from which source" except what is written in the text itself — dates, provenance links, + `log.md`. The supersession rules stop being the readable layer over the history and + become **the** history. +- **There is no synchronisation and no implicit backup.** The workspace lives where the + user put it. If they want to version or synchronise it themselves, the folder is + compatible with that — but the application knows nothing about it. +- **The snapshot is the only net.** There is no merge, no branch, and no history to fall + back to beyond the last recorded operation. + +The corollary that sets the tone for the rest of the project: **since there is nowhere to +go back to, the defence has to be at the entrance.** That is what makes write-time +validation (`adr:0003-mcp-as-the-only-bridge-to-the-llm`) structural rather than hygienic. + +The source repository of this project stays in git. Git is a tool for whoever develops the +application, not part of what the application ships. diff --git a/docs/adr/0002-workspace-como-pasta-local-de-markdown.md b/docs/adr/0002-workspace-como-pasta-local-de-markdown.md deleted file mode 100644 index 471e427..0000000 --- a/docs/adr/0002-workspace-como-pasta-local-de-markdown.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -status: accepted ---- - -# 0002 · O workspace é uma pasta local de markdown, sem versionamento - -## Context - -O produto acumula a documentação de um projeto e precisa responder "qual o estado atual -do projeto X e como chegamos aqui?". Onde esse conteúdo mora decide quase tudo a -jusante: quem consegue lê-lo, o que acontece quando ele muda, e o que é possível -recuperar quando alguém erra. - -As opções eram um banco embutido, um serviço, ou arquivos. E, dentro de arquivos, havia -a sub-escolha de versionar a pasta com git — o que daria histórico por linha de graça. - -## Decision - -O workspace é uma pasta no disco do usuário, com um diretório por projeto. Dentro de -cada projeto, `raw/` guarda as fontes originais, imutáveis depois de escritas, e `wiki/` -guarda as páginas em markdown. - -Nada no aplicativo cria, lê ou escreve um repositório git. - -O que substitui o histórico que o git daria: - -- **Toda escrita é atômica** — arquivo temporário e renomeação — para que um aplicativo - fechado no meio não deixe página pela metade. -- **Toda escrita tira snapshot antes** das páginas que vai tocar, numa pasta `.state/` - que não é conteúdo. -- **Toda escrita entra num log de operações** com origem e horário, e qualquer operação - pode ser desfeita pelo seu id. - -## Consequences - -O usuário é dono dos dados num formato que Obsidian, VS Code, `grep` e qualquer agente -já leem. Não há ferramenta a instalar nem conceito de repositório para quem não é -desenvolvedor, e o produto fica simples de explicar: é uma pasta. - -O que se perde, sem suavizar: - -- **Não há histórico por arquivo.** Nada responde "quando esta frase apareceu e em qual - fonte" exceto o que estiver escrito no próprio texto — datas, links de proveniência, - `log.md`. As regras de supersessão deixam de ser a camada legível sobre o histórico e - passam a **ser** o histórico. -- **Não há sincronização nem backup implícito.** O workspace vive onde o usuário o - colocou. Se ele quiser versionar ou sincronizar por conta própria, a pasta é - compatível com isso — mas o aplicativo não sabe a respeito. -- **O snapshot é a única rede.** Não existe merge, não existe branch, não existe - histórico ao qual recuar além da última operação registrada. - -O corolário que dá o tom do resto do projeto: **como não há para onde voltar, a defesa -tem que estar na entrada.** É isso que torna a validação na escrita -(`adr:0003-mcp-como-unica-ponte-com-o-llm`) estrutural em vez de higiênica. - -O repositório do código-fonte deste projeto continua em git. Git é ferramenta de quem -desenvolve o aplicativo, não parte do que o aplicativo entrega. diff --git a/docs/adr/0003-mcp-as-the-only-bridge-to-the-llm.md b/docs/adr/0003-mcp-as-the-only-bridge-to-the-llm.md new file mode 100644 index 0000000..a96d801 --- /dev/null +++ b/docs/adr/0003-mcp-as-the-only-bridge-to-the-llm.md @@ -0,0 +1,75 @@ +--- +status: accepted +--- + +# 0003 · MCP is the only bridge between the wiki and the LLM + +## Context + +Turning the text of a source into wiki pages is language-model work. There were two ways +to do it, and for a while the design had both at once: the application calling an LLM +internally, and an MCP server handing the finished wiki to an external agent. + +Two bridges mean two authors for the same content, two credentials, two notions of what a +good page is, and no good answer for who wins when they disagree. + +They also mean competing with the harness the user already has open. They already pay for +an agent, already configured it and already trust it. A second writing engine inside the +application is duplicated work that delivers less. + +## Decision + +**The application does not call an LLM.** It receives sources, reduces each one to +`text.md` with provenance anchors, stores the wiki and serves all of it over MCP. Reading +the text, applying the LLM-Wiki methodology and writing the pages is the user's agent. + +The MCP server exposes read, search, ingest and write. It runs over HTTP on the loopback, +is started and stopped by the application, and **serves exactly one project at a time, +chosen by the application** — no tool takes a project parameter, and the address does not +change when the project does. What base the agent can reach is decided by the application, +never by the agent. + +**What replaces the code that would have written the pages is write-time validation.** +Every write — from the editor or from MCP — is refused if the frontmatter departs from the +schema, if a wikilink does not resolve, or if a citation points at a source or an instant +that does not exist. The application does not guarantee the page is good; it guarantees it +is well formed, and returns an error the agent can read in order to try again. + +The only credential the application stores is the transcription one. + +## Consequences + +The application does one thing and stays small: gone are the LLM client, the structured +extraction, the entity resolution, the page writing and the diff approval. Gone with them +is the competition with the harness — the product becomes infrastructure it needs, instead +of a worse competitor to it. + +Three real losses: + +**There is no recompilation.** Rebuilding `wiki/` from `raw/` required exactly the LLM the +application does not have. `wiki/` stops being derivable and becomes primary content — +which promotes the snapshot and the log of +`adr:0002-workspace-as-a-local-markdown-folder` from a comfort to a foundation. + +**The convention left the code and moved into prose.** The page format used to live in a +writer tested by fixtures in CI. Now it lives in the `CLAUDE.md` generated in the project, +which is a text a model interprets. If it is vague, two agents write two different wikis in +the same folder and nothing breaks. Validation holds the form; it does not hold the +meaning. A well-formed and wrong page passes. + +**Supersession depends on the agent.** That a replaced decision ends up struck through, +dated and linked to the one replacing it used to be a rule enforced by code; now it is an +instruction. The validator reports broken links and orphan pages, but it cannot report +"this page silently overwrote a decision" — that would require understanding the content. + +Two operational consequences that become requirements: + +- **The port is local, not private.** Any process on the machine reaches the loopback, and + there is ingest and write behind it. A mandatory token on every request and confinement + to the served project are the difference between a tool and a vector. +- **Switching projects drops the connections.** Since the address is the same, a connected + harness would go on talking to what it believes is the previous base. + +If one day it makes sense to bring distillation back into the application, the path that +preserves this decision is an embedded agent speaking the same MCP tools — not a second +writer with direct disk access. diff --git a/docs/adr/0003-mcp-como-unica-ponte-com-o-llm.md b/docs/adr/0003-mcp-como-unica-ponte-com-o-llm.md deleted file mode 100644 index c198ad0..0000000 --- a/docs/adr/0003-mcp-como-unica-ponte-com-o-llm.md +++ /dev/null @@ -1,76 +0,0 @@ ---- -status: accepted ---- - -# 0003 · MCP é a única ponte entre a wiki e o LLM - -## Context - -Transformar o texto de uma fonte em páginas de wiki é trabalho de modelo de linguagem. -Havia duas formas de fazer isso, e por um tempo o desenho tinha as duas ao mesmo tempo: -o aplicativo chamando um LLM internamente, e um servidor MCP entregando a wiki pronta a -um agente externo. - -Duas pontes significam dois autores para o mesmo conteúdo, duas credenciais, duas noções -do que é uma boa página, e nenhuma resposta boa para quem ganha quando discordam. - -Significam também competir com o harness que o usuário já tem aberto. Ele já paga por um -agente, já o configurou e já confia nele. Um segundo motor de escrita dentro do -aplicativo é trabalho duplicado que entrega menos. - -## Decision - -**O aplicativo não chama LLM.** Ele recebe fontes, reduz cada uma a `text.md` com âncoras -de proveniência, guarda a wiki e serve tudo por MCP. Quem lê o texto, aplica a -metodologia LLM-Wiki e escreve as páginas é o agente do usuário. - -O servidor MCP expõe leitura, busca, ingestão e escrita. Ele roda por HTTP no loopback, -é ligado e desligado pelo aplicativo, e **serve exatamente um projeto por vez, escolhido -pelo aplicativo** — nenhuma ferramenta aceita parâmetro de projeto, e o endereço não muda -quando o projeto muda. Quem decide qual base o agente alcança é o aplicativo, nunca o -agente. - -**O que substitui o código que escreveria as páginas é validação na escrita.** Toda -gravação — do editor ou do MCP — é recusada se o frontmatter fugir do schema, se um -wikilink não resolver, ou se uma citação apontar para fonte ou instante inexistente. O -aplicativo não garante que a página seja boa; garante que seja bem formada, e devolve um -erro que o agente consiga ler para tentar de novo. - -A única credencial que o aplicativo guarda é a de transcrição. - -## Consequences - -O aplicativo faz uma coisa só e fica pequeno: some o cliente de LLM, a extração -estruturada, a resolução de entidades, a escrita de páginas e a aprovação de diff. Some -junto a competição com o harness — o produto vira infraestrutura de que ele precisa, em -vez de um concorrente pior dele. - -Três perdas reais: - -**Não há recompilação.** Reconstruir `wiki/` a partir de `raw/` exigia justamente o LLM -que o aplicativo não tem. `wiki/` deixa de ser derivável e passa a ser conteúdo -primário — o que promove o snapshot e o log de `adr:0002-workspace-como-pasta-local-de-markdown` -de conforto a fundação. - -**A convenção saiu do código e foi para a prosa.** O formato de página vivia num escritor -testado por fixtures em CI. Agora vive no `CLAUDE.md` gerado no projeto, que é um texto -que um modelo interpreta. Se ele estiver vago, dois agentes escrevem duas wikis -diferentes na mesma pasta e nada quebra. A validação segura a forma; não segura o -sentido. Uma página bem formada e errada passa. - -**A supersessão depende do agente.** Que uma decisão substituída fique riscada, datada e -ligada à que a substituiu era regra imposta por código; agora é instrução. O validador -reporta link quebrado e página órfã, mas não consegue reportar "esta página sobrescreveu -uma decisão em silêncio" — isso exigiria entender o conteúdo. - -Duas consequências operacionais que viram requisito: - -- **A porta é local, não é privada.** Qualquer processo da máquina alcança o loopback, e - há ingestão e escrita atrás dele. Token obrigatório em toda requisição e confinamento - ao projeto servido são a diferença entre uma ferramenta e um vetor. -- **Trocar o projeto derruba as conexões.** Como o endereço é o mesmo, um harness - conectado continuaria falando com o que ele acha que é a base anterior. - -Se um dia fizer sentido devolver a destilação ao aplicativo, o caminho que preserva esta -decisão é um agente embutido que fale as mesmas ferramentas MCP — não um segundo escritor -com acesso direto ao disco. diff --git a/docs/adr/0004-edicao-de-markdown-sem-blocos.md b/docs/adr/0004-edicao-de-markdown-sem-blocos.md deleted file mode 100644 index 95f254b..0000000 --- a/docs/adr/0004-edicao-de-markdown-sem-blocos.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -status: accepted ---- - -# 0004 · Edição de markdown com preview, sem blocos - -## Context - -A referência de produto é o Notion: um lugar onde a documentação do projeto se organiza. -Isso levanta que tipo de editor o aplicativo oferece, e a escolha é estrutural — um -editor de blocos não é uma tela, é uma arquitetura de documento que contamina o -armazenamento e tudo que lê os arquivos. - -`adr:0002-workspace-como-pasta-local-de-markdown` diz que o conteúdo é markdown que -Obsidian, VS Code, `grep` e qualquer agente já leem. Um editor de blocos com fidelidade -real quer um modelo próprio — blocos com id, ordenação, tipos ricos — e markdown deixa de -ser a verdade para virar formato de exportação. - -## Decision - -O aplicativo edita o markdown da página numa área de texto com preview: escrever, salvar, -criar, renomear e apagar páginas, corrigindo os wikilinks que apontavam para uma página -renomeada. - -Sem blocos arrastáveis, sem slash-commands, sem embeds, sem modelo de documento próprio. -O arquivo `.md` é a verdade, e continua editável por fora do aplicativo enquanto ele está -aberto. - -## Consequences - -O usuário ganha o caminho curto que faltava: corrigir uma frase que o agente escreveu -errado sem sair para outro programa. E a pasta continua sendo o que o argumento inteiro -do produto depende que ela seja. - -A semelhança com o Notion fica na organização e na navegação, não na experiência de -escrita. Quem espera arrastar blocos vai achar o editor pobre, e essa expectativa é -legítima — a resposta é que ela custaria o formato de arquivo, que é o ativo. - -Duas consequências operacionais: - -- **Edição concorrente existe e não é resolvida.** A mesma página pode estar aberta no - Obsidian, no aplicativo e sendo escrita por um agente via MCP. Sem versionamento não há - merge; o mínimo honesto é detectar que o arquivo mudou em disco desde que foi carregado - e recusar sobrescrever em silêncio. -- **Renomear é a operação perigosa.** Ela invalida wikilinks em páginas que o usuário não - está olhando, e por isso a correção dos links faz parte da mesma operação em vez de - virar conserto posterior no validador. - -Se um editor rico voltar à mesa, o caminho que preserva a decisão é renderizar markdown -com mais fidelidade — não trocar o formato de armazenamento por um modelo de blocos. diff --git a/docs/adr/0004-markdown-editing-without-blocks.md b/docs/adr/0004-markdown-editing-without-blocks.md new file mode 100644 index 0000000..e159c10 --- /dev/null +++ b/docs/adr/0004-markdown-editing-without-blocks.md @@ -0,0 +1,49 @@ +--- +status: accepted +--- + +# 0004 · Markdown editing with preview, without blocks + +## Context + +The product reference is Notion: a place where a project's documentation organises itself. +That raises the question of what kind of editor the application offers, and the choice is +structural — a block editor is not a screen, it is a document architecture that +contaminates the storage and everything that reads the files. + +`adr:0002-workspace-as-a-local-markdown-folder` says the content is markdown that +Obsidian, VS Code, `grep` and any agent already read. A block editor with real fidelity +wants a model of its own — blocks with ids, ordering, rich types — and markdown stops being +the truth and becomes an export format. + +## Decision + +The application edits a page's markdown in a text area with preview: write, save, create, +rename and delete pages, fixing the wikilinks that pointed at a renamed page. + +No draggable blocks, no slash commands, no embeds, no document model of its own. The `.md` +file is the truth, and stays editable from outside the application while it is open. + +## Consequences + +The user gets the short path that was missing: fixing a sentence the agent got wrong +without leaving for another program. And the folder stays what the product's entire +argument depends on it being. + +The resemblance to Notion is in the organisation and the navigation, not in the writing +experience. Anyone expecting to drag blocks will find the editor poor, and that +expectation is legitimate — the answer is that it would cost the file format, which is the +asset. + +Two operational consequences: + +- **Concurrent editing exists and is not solved.** The same page can be open in Obsidian, + in the application, and being written by an agent over MCP. Without versioning there is + no merge; the honest minimum is to detect that the file changed on disk since it was + loaded and refuse to overwrite it silently. +- **Renaming is the dangerous operation.** It invalidates wikilinks in pages the user is + not looking at, which is why fixing those links is part of the same operation instead of + becoming a later repair in the validator. + +If a rich editor comes back on the table, the path that preserves this decision is +rendering markdown with more fidelity — not swapping the storage format for a block model. diff --git a/docs/adr/0005-captura-wasapi-num-sidecar-minimo.md b/docs/adr/0005-captura-wasapi-num-sidecar-minimo.md deleted file mode 100644 index 07544ac..0000000 --- a/docs/adr/0005-captura-wasapi-num-sidecar-minimo.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -status: accepted ---- - -# 0005 · Captura por WASAPI direto, num sidecar de contrato mínimo - -## Context - -Uma das fontes é a gravação de reunião, que exige capturar simultaneamente o microfone e -o áudio que sai do sistema, em faixas separadas, por uma hora. No Windows há três -caminhos: ffmpeg, um driver de áudio virtual, ou o WASAPI direto. - -O ffmpeg no Windows não tem entrada nativa de loopback — só DirectShow. O contorno usual -é instalar um dispositivo virtual, e isso custa duas coisas caras: perde o usuário no -onboarding, e é bloqueado por antivírus corporativo, que é exatamente o ambiente onde as -reuniões acontecem. - -Capturar direto exige uma linguagem sem pausa de GC e sem runtime a instalar, o que põe o -gravador fora do processo do aplicativo — e toda fronteira de processo é uma pergunta -sobre onde a lógica mora. A resposta padrão, deixar crescer conforme a conveniência de -cada tarefa, é como um sidecar vira um segundo aplicativo. - -## Decision - -Capturar por WASAPI direto, num binário Rust standalone. O ffmpeg continua no projeto, -mas só a jusante — preparando o áudio já gravado. - -O sidecar expõe por stdio JSON-RPC exatamente: `start`, `pause`, `resume`, `stop`, -`status`, `devices`. Todo o resto — pré-processamento, transcrição, escrita, servidor -MCP — vive no lado JavaScript. - -## Consequences - -Nada a instalar além do aplicativo, e nada que um antivírus reconheça como driver. Em -troca, o projeto assume o código de captura e com ele quatro problemas que o WASAPI -entrega de brinde, nenhum dos quais aparece num teste de cinco minutos: - -- o loopback não devolve frames enquanto ninguém toca som, então o silêncio precisa ser - fabricado ou a faixa congela; -- o dispositivo padrão pode mudar no meio da reunião e matar o stream em silêncio; -- as duas faixas derivam entre si se o alinhamento não for imposto por um clock próprio; -- a pausa é de captura, não de UI — as duas faixas têm que parar e voltar no mesmo - instante, e o trecho pausado sair de ambas em bloco. - -A fronteira é pequena o bastante para ser testada por inteiro: sobe o binário, manda -JSON, verifica a resposta. - -Isto vai doer em algum momento. Vai aparecer uma necessidade — medidor de nível ao vivo, -detecção de silêncio durante a gravação — para a qual o dado já está no lado Rust e -mandá-lo pela fronteira parece desperdício. A regra é resistir: um método novo merece um -ADR que substitua este, não uma linha a mais num enum. - -O corolário é que o gravador não sabe nada sobre o workspace, sobre transcrição ou sobre -o servidor MCP. Ele grava e escreve arquivos. diff --git a/docs/adr/0005-wasapi-capture-in-a-minimal-sidecar.md b/docs/adr/0005-wasapi-capture-in-a-minimal-sidecar.md new file mode 100644 index 0000000..e97f9db --- /dev/null +++ b/docs/adr/0005-wasapi-capture-in-a-minimal-sidecar.md @@ -0,0 +1,54 @@ +--- +status: accepted +--- + +# 0005 · Direct WASAPI capture, in a sidecar with a minimal contract + +## Context + +One of the sources is the meeting recording, which requires capturing the microphone and +the system output simultaneously, on separate tracks, for an hour. On Windows there are +three paths: ffmpeg, a virtual audio driver, or WASAPI directly. + +ffmpeg on Windows has no native loopback input — only DirectShow. The usual workaround is +to install a virtual device, and that costs two expensive things: it loses the user during +onboarding, and it is blocked by corporate antivirus, which is exactly the environment +where the meetings happen. + +Capturing directly requires a language with no GC pause and no runtime to install, which +puts the recorder outside the application's process — and every process boundary is a +question about where the logic lives. The default answer, letting it grow as each task +finds convenient, is how a sidecar turns into a second application. + +## Decision + +Capture through WASAPI directly, in a standalone Rust binary. ffmpeg stays in the project, +but only downstream — preparing audio that has already been recorded. + +The sidecar exposes over stdio JSON-RPC exactly: `start`, `pause`, `resume`, `stop`, +`status`, `devices`. Everything else — preprocessing, transcription, writing, MCP server — +lives on the JavaScript side. + +## Consequences + +Nothing to install beyond the application, and nothing an antivirus recognises as a driver. +In exchange, the project takes on the capture code and with it four problems WASAPI throws +in for free, none of which shows up in a five-minute test: + +- loopback returns no frames while nobody is playing sound, so silence has to be + manufactured or the track freezes; +- the default device can change mid-meeting and kill the stream silently; +- the two tracks drift apart if the alignment is not imposed by a clock of our own; +- the pause is a capture pause, not a UI one — both tracks have to stop and resume at the + same instant, and the paused stretch has to leave both as one block. + +The boundary is small enough to be tested end to end: start the binary, send JSON, check +the response. + +This will hurt at some point. A need will come up — a live level meter, silence detection +during recording — for which the data is already on the Rust side and sending it across the +boundary looks wasteful. The rule is to resist: a new method deserves an ADR that +supersedes this one, not one more line in an enum. + +The corollary is that the recorder knows nothing about the workspace, about transcription, +or about the MCP server. It records and writes files. diff --git a/docs/adr/0006-opus-as-the-provenance-format.md b/docs/adr/0006-opus-as-the-provenance-format.md new file mode 100644 index 0000000..966caaf --- /dev/null +++ b/docs/adr/0006-opus-as-the-provenance-format.md @@ -0,0 +1,46 @@ +--- +status: accepted +--- + +# 0006 · Opus 24 kbps as the provenance format, and the WAV discarded + +## Context + +An hour of meeting in 48 kHz stereo WAV takes ~691 MB; twenty meetings fill 14 GB. And +transcription providers cap the upload at 25 MB, which no lossless encoding reaches for an +hour: + +| Format | Size | Fits in 25 MB? | +|---|---|---| +| WAV 48 kHz stereo | 691 MB | no | +| WAV 16 kHz mono | 115 MB | no | +| FLAC 16 kHz mono | ~60 MB | no | +| Opus 24 kbps mono | ~11 MB | yes | + +At the same time, every provenance link of a claim that came from audio points at an +instant of the recording. If the audio does not survive, the link lies. + +## Decision + +Opus 24 kbps mono is the permanent file format in `raw/`. The WAV is intermediate and is +discarded as soon as transcription confirms success. + +## Consequences + +The upload fits under the limit, and twenty meetings take ~220 MB instead of 14 GB. +Provenance keeps working because the Opus is what remains. + +The loss is irreversible: at 24 kbps mono there is no going back and re-transcribing with a +model that would demand more fidelity. We accept it because speech at 16 kHz mono is what +transcription models consume anyway — the discarded information is not information the +transcription would use. + +**Ordering is the most dangerous seam in this decision.** Deleting the WAV before the +success confirmation loses the whole recording, and the deletion runs at exactly the point +in the flow that can be interrupted — application closed, machine shut down, transcription +that failed on one chunk and stopped halfway. + +One consequence that pays off later: because both tracks stay separate and immutable in +`raw/`, a recording can be re-transcribed with a better provider or better speaker +attribution, and the pages rewritten from the new text. What does not exist is automatic +reconstruction of the wiki — see `adr:0003-mcp-as-the-only-bridge-to-the-llm`. diff --git a/docs/adr/0006-opus-como-formato-de-proveniencia.md b/docs/adr/0006-opus-como-formato-de-proveniencia.md deleted file mode 100644 index dc6d454..0000000 --- a/docs/adr/0006-opus-como-formato-de-proveniencia.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -status: accepted ---- - -# 0006 · Opus 24 kbps como formato de proveniência, e o WAV descartado - -## Context - -Uma hora de reunião em WAV 48 kHz estéreo ocupa ~691 MB; vinte reuniões enchem 14 GB. E -os provedores de transcrição limitam o upload a 25 MB, que nenhuma codificação sem perdas -alcança para uma hora: - -| Formato | Tamanho | Cabe em 25 MB? | -|---|---|---| -| WAV 48 kHz estéreo | 691 MB | não | -| WAV 16 kHz mono | 115 MB | não | -| FLAC 16 kHz mono | ~60 MB | não | -| Opus 24 kbps mono | ~11 MB | sim | - -Ao mesmo tempo, todo link de proveniência de uma claim vinda de áudio aponta para um -instante da gravação. Se o áudio não sobreviver, o link mente. - -## Decision - -Opus 24 kbps mono é o formato de arquivo permanente em `raw/`. O WAV é intermediário e é -descartado assim que a transcrição confirma sucesso. - -## Consequences - -O upload cabe no limite, e vinte reuniões ocupam ~220 MB em vez de 14 GB. A proveniência -continua funcionando porque o Opus é o que fica. - -A perda é irreversível: a 24 kbps mono não há como voltar atrás e retranscrever com um -modelo que exigisse mais fidelidade. Aceitamos porque fala em português a 16 kHz mono é o -que os modelos de transcrição consomem de qualquer forma — a informação descartada não é -informação que a transcrição usaria. - -**A ordem é a costura mais perigosa desta decisão.** Apagar o WAV antes da confirmação de -sucesso perde a gravação inteira, e o apagamento roda justamente no ponto do fluxo que -pode ser interrompido — aplicativo fechado, máquina desligada, transcrição que falhou em -um chunk e ficou pela metade. - -Uma consequência que se paga depois: como as duas faixas continuam separadas e imutáveis -em `raw/`, uma gravação pode ser retranscrita com um provedor melhor ou com atribuição de -locutor melhor, e as páginas reescritas a partir do texto novo. O que não existe é -reconstrução automática da wiki — ver `adr:0003-mcp-como-unica-ponte-com-o-llm`. diff --git a/docs/adr/0007-credenciais-em-texto-claro-no-config.md b/docs/adr/0007-credenciais-em-texto-claro-no-config.md deleted file mode 100644 index 52ef884..0000000 --- a/docs/adr/0007-credenciais-em-texto-claro-no-config.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -status: accepted ---- - -# 0007 · Credenciais em texto claro no config do aplicativo - -## Context - -`adr:0001-sem-backend-byok` põe a credencial de transcrição na máquina do usuário, e -`adr:0003-mcp-como-unica-ponte-com-o-llm` acrescenta um segundo segredo: o token que -protege o servidor MCP local. Os dois precisam sobreviver entre execuções, então algo os -guarda. - -No Windows há três opções: texto claro num JSON, DPAPI (`CryptProtectData`, atrelado à -conta do usuário), ou o Credential Manager. - -Isso é decidido cedo porque o formato do que já foi gravado em disco é o que torna a -mudança cara depois — migrar segredos que usuários já colaram exige código de migração e -uma janela em que os dois formatos coexistem. - -## Decision - -`config.json` no diretório de dados do aplicativo, com os segredos em texto claro. Nunca -dentro do workspace. - -```json -{ - "workspace_path": "...", - "stt": { "provider": "groq", "api_key": "", "language": "pt" }, - "mcp": { "port": 7331, "token": "" }, - "delete_wav_after_transcription": true -} -``` - -## Consequences - -Simples de escrever, de ler, de depurar e de editar à mão — o que importa num aplicativo -sem backend, onde o suporte é o usuário abrindo o próprio arquivo. - -A proteção é a do sistema de arquivos e nada além dela: qualquer processo rodando como o -usuário lê os dois segredos. Para a chave de transcrição o dano é limitado — é uma -credencial que o próprio usuário revoga. Para o token do MCP é mais sério, porque quem o -lê ganha leitura, ingestão e escrita na wiki do projeto servido. Isto é uma troca de -segurança por simplicidade, e está registrada como tal, não escondida. - -Duas consequências operacionais valem escrever: o arquivo não pode ser incluído em nenhum -pacote de diagnóstico, e nenhuma mensagem de log pode ecoar o valor de um segredo. - -Se um usuário corporativo exigir mais, o sucessor é DPAPI — envolver o valor em -`CryptProtectData` mantém o mesmo arquivo e o mesmo schema, com um campo marcando o -formato. Esse é o caminho de migração, e é o motivo de cada segredo ser um campo próprio -em vez de estar embutido numa string de conexão. diff --git a/docs/adr/0007-plaintext-credentials-in-the-config.md b/docs/adr/0007-plaintext-credentials-in-the-config.md new file mode 100644 index 0000000..0a6bcac --- /dev/null +++ b/docs/adr/0007-plaintext-credentials-in-the-config.md @@ -0,0 +1,56 @@ +--- +status: accepted +--- + +# 0007 · Plaintext credentials in the application config + +## Context + +`adr:0001-no-backend-byok` puts the transcription credential on the user's machine, and +`adr:0003-mcp-as-the-only-bridge-to-the-llm` adds a second secret: the token that protects +the local MCP server. Both have to survive across runs, so something stores them. + +On Windows there are three options: plaintext in a JSON file, DPAPI (`CryptProtectData`, +tied to the user account), or the Credential Manager. + +This is decided early because the format of what has already been written to disk is what +makes the change expensive later — migrating secrets users have already pasted requires +migration code and a window in which both formats coexist. + +## Decision + +`config.json` in the application data directory, with the secrets in plaintext. Never +inside the workspace. + +```json +{ + "workspace_path": "...", + "language": "en", + "stt": { "provider": "groq", "api_key": "" }, + "mcp": { "port": 7331, "token": "" }, + "delete_wav_after_transcription": true +} +``` + +`language` is the content language of +`adr:0008-content-language-is-a-setting-english-by-default`. It is not a secret; it lives +here because this is already the file that survives between runs. + +## Consequences + +Simple to write, to read, to debug and to edit by hand — which matters in an application +with no backend, where support is the user opening their own file. + +The protection is the filesystem's and nothing beyond it: any process running as the user +reads both secrets. For the transcription key the damage is limited — it is a credential +the user revokes themselves. For the MCP token it is more serious, because whoever reads it +gains read, ingest and write on the served project's wiki. This is a trade of security for +simplicity, and it is recorded as such, not hidden. + +Two operational consequences worth writing down: the file must not be included in any +diagnostic bundle, and no log message may echo the value of a secret. + +If a corporate user demands more, the successor is DPAPI — wrapping the value in +`CryptProtectData` keeps the same file and the same schema, with a field marking the +format. That is the migration path, and it is the reason each secret is a field of its own +rather than being embedded in a connection string. diff --git a/docs/adr/0008-content-language-is-a-setting-english-by-default.md b/docs/adr/0008-content-language-is-a-setting-english-by-default.md new file mode 100644 index 0000000..e2f694a --- /dev/null +++ b/docs/adr/0008-content-language-is-a-setting-english-by-default.md @@ -0,0 +1,68 @@ +--- +status: accepted +--- + +# 0008 · The content language is a setting, English by default + +## Context + +The product's content is written in a human language: the wiki pages, and the +transcription the pages are built from. The plan used to fix that language as Brazilian +Portuguese, which was a decision made about the first user rather than about the product. + +`adr:0003-mcp-as-the-only-bridge-to-the-llm` changes what that decision can even reach. +The application writes no content, so it has exactly two places where a language appears: +the hint sent with a transcription request, and the `CLAUDE.md` generated in the project, +which is what tells the agent the language to write pages in. Everything else the +application produces — frontmatter keys, `type` values, directory names, MCP tool names — +is identifiers, not prose. + +So the question is narrow: hard-code one language in those two places, or make it a +setting. + +## Decision + +The content language is a setting, chosen during onboarding and changeable afterwards. +**English is the default**, with Brazilian Portuguese and Spanish offered alongside it. + +It lives in `config.json` as a workspace-wide value — see +`adr:0007-plaintext-credentials-in-the-config` — and reaches exactly the two places above: +the transcription hint, and the generated project `CLAUDE.md`. + +**The schema is English regardless of the setting.** Frontmatter keys, the `decision` / +`fact` / `action_item` / `open_question` values, the `raw/` and `wiki/` directory names, +the MCP tool names and the canonical terms in `docs/glossary.md` do not translate. They +are names a program compares, and translating them would mean a wiki written in Spanish +is not readable by a tool that reads a wiki written in English. + +**The setting is workspace-wide, not per project.** A workspace holding projects in +different languages is a second axis nobody has asked for; someone who needs it has a +second workspace, which costs a folder. + +## Consequences + +An unconfigured install produces English, which is the right default for an open source +project whose repository, schema and glossary are already English. Nobody has to configure +anything to get a coherent result, and the person who needs another language changes one +setting before the first source lands. + +Three consequences worth stating plainly: + +**The setting is an instruction, not an enforcement.** Group 5 validates form; nothing +checks that a page is written in the configured language. An agent prompted in Portuguese +inside a workspace set to English will write Portuguese pages and every validation will +pass. This is the same weakness as the convention living in prose, and it is the same +answer: the generated `CLAUDE.md` has to be specific, because it is the only place the +instruction exists. + +**A source in another language is not a failure.** A Spanish recording in an English +workspace produces a Spanish `text.md`; what the agent then writes is the agent's call. +The application does not translate and does not refuse — refusing would mean detecting the +language of every source, which is a classifier the application has no business owning. + +**Three languages, because each one costs a check.** The transcription model is +multilingual and takes no work per language, but the vocabulary seeding of task 4.10 and +someone able to read the output do. Adding a fourth is one value in the setting and one +line in the generated `CLAUDE.md` — cheap. The reverse is not: once workspaces exist in +several languages, going back to one hard-coded language breaks every one of them that is +not in it. diff --git a/docs/adr/0009-distribution-through-github-releases.md b/docs/adr/0009-distribution-through-github-releases.md new file mode 100644 index 0000000..41c3a28 --- /dev/null +++ b/docs/adr/0009-distribution-through-github-releases.md @@ -0,0 +1,63 @@ +--- +status: accepted +--- + +# 0009 · Distribution through GitHub Releases, as an unsigned NSIS installer + +## Context + +The application has to reach a Windows machine. `adr:0001-no-backend-byok` leaves us with +no server of our own, so there is no update endpoint and no download host to run — whatever +distributes the binary is somebody else's infrastructure. + +The repository is already on GitHub, the tag is already the thing that says "this is a +version", and the winget and Scoop manifests of task 10.2 both work by pointing at a stable +download URL with a known hash. GitHub Releases is that URL, produced by the tag we are +already pushing. + +What remains is what the artifact is. Electron on Windows has three usual shapes: an NSIS +installer, an MSI, or a portable executable. And each of them can be signed or not, which +is a separate question with a price attached. + +## Decision + +**Releases live in GitHub Releases, built by CI from a `v*` tag.** Nothing is built on a +developer's machine and uploaded by hand — the tag is the trigger, and the workflow refuses +to run if the tag does not match the version in the application's `package.json`. + +**The artifact is a single NSIS installer**, `.exe`, produced by electron-builder with +ffmpeg and `recorder.exe` embedded, written to `apps/desktop/release/`. A `SHA256SUMS.txt` +is published beside it, because that is what a winget or Scoop manifest has to quote. + +**It is unsigned in the MVP.** The workflow reads `CSC_LINK` and `CSC_KEY_PASSWORD` from +the repository secrets and signs when they are present, so the day a certificate is bought +is a settings change and not a workflow rewrite. + +## Consequences + +Distribution costs nothing to run and nothing to operate. The download URL is stable and +predictable, which is the only property task 10.2 needs from it, and every release carries +the hash that manifest has to state. + +**An unsigned installer means Microsoft SmartScreen warns on it**, with a dialog whose +default button is "Don't run". This lands worst in exactly the environment this product is +built for: `adr:0005-wasapi-capture-in-a-minimal-sidecar` chose direct WASAPI capture +specifically to avoid a driver a corporate antivirus would block, and then the installer +delivering it is the thing that gets flagged. A certificate is the fix, it costs money +yearly, and reputation with SmartScreen accrues per certificate — so buying one late means +starting that clock late. This is recorded as a known cost, not as an oversight. + +**The choice of NSIS is harder to leave than to make.** Once installs exist in the field, +the installer type is what an upgrade path is written against, and winget and Scoop +manifests state it. Moving to MSI later is not a config change; it is a migration for +everyone who already installed. + +**There is no auto-update.** Nothing in the application checks for a new version, and this +ADR does not add one — a user learns about a release from the repository, from winget or +from Scoop. Should that change, the path that preserves this decision is electron-updater +reading the same GitHub Release, not a service of ours, which would cost the position +`adr:0001-no-backend-byok` protects. + +**A release is public and permanent.** Deleting a published release does not un-download +it, so the workflow publishes a tag exactly once and fails rather than overwriting one that +already exists. diff --git a/docs/glossary.md b/docs/glossary.md index fba24bd..e2ca248 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -1,24 +1,23 @@ -# Glossário +# Glossary -Um termo canônico por conceito, e os sinônimos a evitar. Estes termos aparecem em -código, em nomes de arquivo, em schemas JSON, nas ferramentas do servidor MCP e nas -páginas que o agente escreve — por isso são mantidos em inglês, na forma exata em que -aparecem nos schemas. +One canonical term per concept, and the synonyms to avoid. These terms appear in code, in +file names, in JSON schemas, in the MCP server's tools and in the pages the agent writes — +which is why each one is listed in the exact form it takes in the schemas. -- **workspace** — a pasta raiz escolhida pelo usuário, com um diretório por project. Avoid: cofre, library -- **project** — um projeto dentro do workspace, com `raw/`, `wiki/`, `.state/` e `CLAUDE.md` próprios. O servidor MCP serve exatamente um por vez. Avoid: namespace -- **source** — qualquer entrada em `raw/`: um arquivo subido ou uma recording. Imutável depois de escrita. Avoid: attachment -- **recording** — uma sessão de captura de áudio, identificada por `recording_id` em UTC ISO-8601. Avoid: session -- **track** — uma das duas streams capturadas, `mic` ou `system`. Avoid: feed -- **timeline** — a fusão ordenada das duas tracks em tempo real, em `timeline.json`. Avoid: transcript -- **time map** — a tabela que converte instante do áudio comprimido em instante real, em `timemap.json`. Avoid: offset table -- **chunk** — um pedaço de ~10 minutos cortado em ponto de silêncio, unidade de transcrição e de retry. Avoid: slice -- **ingest** — o caminho de uma source até estar disponível como `text.md` no project. Termina aí: escrever páginas é do agente. Avoid: sync -- **entity** — pessoa, projeto ou tópico com página própria, identificada por `id` no formato `type:slug`. Avoid: subject -- **claim** — uma afirmação registrada numa página, de tipo `decision`, `fact`, `action_item` ou `open_question`, sempre com citação. Avoid: insight -- **supersession** — marcar uma decisão anterior como substituída, preservando-a riscada com data e link para a que a substituiu. Avoid: override -- **provenance link** — o link que abre a source no ponto de origem de uma claim: instante para áudio, página para PDF. Avoid: backlink +- **workspace** — the root folder chosen by the user, with one directory per project. Avoid: vault, library +- **project** — a project inside the workspace, with its own `raw/`, `wiki/`, `.state/` and `CLAUDE.md`. The MCP server serves exactly one at a time. Avoid: namespace +- **source** — any entry in `raw/`: an uploaded file or a recording. Immutable once written. Avoid: attachment +- **recording** — one audio capture session, identified by `recording_id` in UTC ISO-8601. Avoid: session +- **track** — one of the two captured streams, `mic` or `system`. Avoid: feed +- **timeline** — the two tracks merged and ordered by real time, in `timeline.json`. Avoid: transcript +- **time map** — the table converting an instant of the compressed audio into a real instant, in `timemap.json`. Avoid: offset table +- **chunk** — a ~10-minute piece cut at a silence point; the unit of transcription and of retry. Avoid: slice +- **ingest** — the path from a source to being available as `text.md` in the project. It ends there: writing pages is the agent's job. Avoid: sync +- **entity** — a person, project or topic with a page of its own, identified by `id` in the form `type:slug`. Avoid: subject +- **claim** — a statement recorded on a page, of type `decision`, `fact`, `action_item` or `open_question`, always with a citation. Avoid: insight +- **supersession** — marking an earlier decision as replaced, preserving it struck through with a date and a link to the one replacing it. Avoid: override +- **provenance link** — the link that opens the source where a claim came from: an instant for audio, a page for a PDF. Avoid: backlink -> **`workspace` tem outro sentido em `docs/stack.md`**, onde "pnpm workspaces" nomeia a -> divisão do monorepo do código-fonte. São coisas diferentes: uma é a pasta do usuário, -> a outra é ferramenta de quem desenvolve o aplicativo. +> **`workspace` has another sense in `docs/stack.md`**, where "pnpm workspaces" names the +> way the source monorepo is divided. They are different things: one is the user's folder, +> the other is a tool for whoever develops the application. diff --git a/docs/stack.md b/docs/stack.md index ab67592..ab1f8e6 100644 --- a/docs/stack.md +++ b/docs/stack.md @@ -1,51 +1,58 @@ # Stack -Toda tecnologia adotada, com uma linha sobre por que ela ganhou o lugar. O que não -está aqui é decisão em aberto, nunca algo adotado em silêncio. +Every adopted technology, with one line on why it earned its place. What is not here is an +open decision, never something adopted silently. -Nada nesta lista está instalado ainda — o monorepo é a tarefa 1.1 de -`plans/project-wiki.md`. A lista existe antes das dependências porque é ela que -torna a adição de cada uma um ato deliberado. +Nothing on this list is installed yet — the monorepo is task 1.1 of +`plans/project-wiki.md`. The list exists before the dependencies because it is what makes +adding each one a deliberate act. -## Captura +## Capture -- **Rust** — o gravador precisa falar COM com o WASAPI e sustentar um clock próprio por uma hora sem pausa de GC. Binário standalone, sem runtime a instalar. -- **crate `wasapi`** — acesso direto ao WASAPI, incluindo loopback do dispositivo de renderização, que é exatamente o que o ffmpeg no Windows não tem. Ver `adr:0005-captura-wasapi-num-sidecar-minimo`. +- **Rust** — the recorder has to speak COM to WASAPI and hold a clock of its own for an hour with no GC pause. Standalone binary, no runtime to install. +- **`wasapi` crate** — direct access to WASAPI, including loopback of the render device, which is exactly what ffmpeg on Windows does not have. See `adr:0005-wasapi-capture-in-a-minimal-sidecar`. ## Pipeline -- **TypeScript** — o pipeline vive no processo principal do Electron; uma linguagem só entre UI e orquestração evita uma fronteira de processo que não paga por si. -- **Node.js** — runtime do Electron, já presente; nenhum processo extra. -- **ffmpeg (vendored)** — downmix, VAD e encode Opus numa ferramenta só. Empacotado com verificação de hash, nunca baixado em tempo de execução. -- **Opus 24 kbps** — a única codificação que põe uma hora de reunião abaixo do limite de 25 MB de upload. Requisito, não otimização. Ver `adr:0006-opus-como-formato-de-proveniencia`. -- **Groq `whisper-large-v3-turbo`** — provedor padrão de STT: ~US$ 0,04 por hora e ~228x tempo real, com português aceitável. É a **única** credencial do aplicativo — ver `adr:0003-mcp-como-unica-ponte-com-o-llm`. -- **whisper.cpp** — provedor local opcional, para quem exige que o áudio não saia da máquina. É o que sustenta o argumento de privacidade sem reescrever o pipeline. +- **TypeScript** — the pipeline lives in Electron's main process; one language across UI and orchestration avoids a process boundary that does not pay for itself. +- **Node.js** — Electron's runtime, already present; no extra process. +- **ffmpeg (vendored)** — downmix, VAD and Opus encode in a single tool. Bundled with hash verification, never downloaded at run time. +- **Opus 24 kbps** — the only encoding that puts an hour of meeting under the 25 MB upload limit. A requirement, not an optimisation. See `adr:0006-opus-as-the-provenance-format`. +- **Groq `whisper-large-v3-turbo`** — the default STT provider: ~US$ 0.04 per hour and ~228x real time, multilingual, which is what lets the content language be a setting rather than a fixed choice — see `adr:0008-content-language-is-a-setting-english-by-default`. It is the **only** credential the application holds — see `adr:0003-mcp-as-the-only-bridge-to-the-llm`. +- **whisper.cpp** — optional local provider, for anyone who requires that the audio never leave the machine. It is what holds up the privacy argument without rewriting the pipeline. -## Aplicativo +## Application -- **Electron** — UI desktop com o mesmo TypeScript do pipeline, e acesso a filesystem e processos filhos sem ponte nativa. -- **React** — a UI tem estado de verdade (gravação em curso, fontes atravessando o fluxo, páginas mudando enquanto o agente escreve); o ecossistema em torno do Electron é maior que o de qualquer alternativa, e isso importa mais que preferência. -- **Vite** — build e recarga do renderer, rápido o bastante para não haver tentação de pular a UI ao iterar. -- **markdown-it** — renderiza as páginas da wiki no navegador embutido; o modelo de plugins é o que permite ensinar `[[wikilink]]` e `rec://` sem reescrever o parser. -- **pnpm workspaces** — monorepo de vários pacotes TS com dependências não achatadas, que é o que impede um pacote de importar o que não declarou. +- **Electron** — desktop UI in the same TypeScript as the pipeline, with filesystem and child-process access and no native bridge. +- **React** — the UI has real state (a recording in progress, sources crossing the flow, pages changing while the agent writes); the ecosystem around Electron is larger than any alternative's, and that matters more than preference. +- **Vite** — renderer build and reload, fast enough that there is no temptation to skip the UI while iterating. +- **markdown-it** — renders the wiki pages in the embedded browser; its plugin model is what allows teaching it `[[wikilink]]` and `rec://` without rewriting the parser. +- **pnpm workspaces** — a monorepo of several TS packages with unhoisted dependencies, which is what stops a package from importing what it never declared. -O workspace não usa git: `adr:0002-workspace-como-pasta-local-de-markdown`. O código-fonte -deste projeto usa, e isso não é uma tecnologia adotada pelo produto. +The workspace does not use git: `adr:0002-workspace-as-a-local-markdown-folder`. This +project's source does, and that is not a technology adopted by the product. -## Extração de texto das fontes +## Text extraction from sources -Cada adaptador de fonte tem uma responsabilidade só — virar `text.md` com âncoras de -proveniência, e o caminho para de escrever ali — ver -`adr:0003-mcp-como-unica-ponte-com-o-llm`. +Each source adapter has a single responsibility — becoming `text.md` with provenance +anchors, and the path stops writing there — see +`adr:0003-mcp-as-the-only-bridge-to-the-llm`. -- **pdf-parse** — texto e limites de página de um PDF; é o número da página que torna a citação possível, e sem ele a fonte não serve. -- **mammoth** — DOCX para markdown preservando a hierarquia de títulos, que é a âncora equivalente à página do PDF. +- **pdf-parse** — text and page boundaries of a PDF; it is the page number that makes the citation possible, and without it the source is of no use. +- **mammoth** — DOCX to markdown preserving the heading hierarchy, which is the anchor equivalent to a PDF's page. -## Servidor MCP +## MCP server -- **MCP TypeScript SDK** — a interface do produto, não um acessório: é por onde o agente lê, ingere e escreve. Não acopla o produto a um fornecedor, e não construímos motor de busca. Ver `adr:0003-mcp-como-unica-ponte-com-o-llm`. +- **MCP TypeScript SDK** — the product's interface, not an accessory: it is how the agent reads, ingests and writes. It does not couple the product to a vendor, and we are not building a search engine. See `adr:0003-mcp-as-the-only-bridge-to-the-llm`. -## Testes e verificação +## Testing and verification -- **Vitest** — runner dos pacotes TS: roda um arquivo isolado rápido o bastante para o loop por tarefa, que é o que a verificação escopada exige. -- **`cargo test`** — o que já vem com Rust; adicionar um segundo runner não compra nada. +- **Vitest** — runner for the TS packages: it runs a single file fast enough for the per-task loop, which is what scoped verification demands. +- **`@vitest/coverage-v8`** — Vitest's V8 coverage provider, and the source of the `coverage-summary.json` that CI reads to enforce the 76% floor per package. V8 rather than Istanbul because it needs no instrumentation step. +- **`cargo test`** — what already ships with Rust; adding a second runner buys nothing. +- **GitHub Actions** — CI on `windows-latest`, which is the only platform the product supports. One job per workspace package, so a package below the coverage floor fails on its own instead of hiding behind a well-tested neighbour. It also builds and publishes the release — see `adr:0009-distribution-through-github-releases`. + +## Distribution + +- **electron-builder** — packs the Electron application, ffmpeg and `recorder.exe` into one NSIS installer, and is what reads `CSC_LINK` to sign it the day there is a certificate. See `adr:0009-distribution-through-github-releases`. +- **GitHub Releases** — where the installer is downloaded from. No host of ours to run, and a stable URL with a published hash is exactly what a winget or Scoop manifest needs. diff --git a/package.json b/package.json new file mode 100644 index 0000000..57efa7d --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "project-wiki", + "private": true, + "type": "module", + "packageManager": "pnpm@10.15.0", + "engines": { + "node": ">=22" + }, + "scripts": { + "test": "pnpm -r --if-present run test", + "test:coverage": "pnpm -r --if-present run test:coverage", + "typecheck": "tsc --noEmit -p tsconfig.json && pnpm -r --if-present run typecheck", + "lint": "pnpm -r --if-present run lint" + }, + "devDependencies": { + "@types/node": "^22.15.3", + "@vitest/coverage-v8": "^3.2.4", + "typescript": "^5.9.2", + "vitest": "^3.2.4" + } +} diff --git a/plans/project-wiki.md b/plans/project-wiki.md index 314d8f9..1a0b67b 100644 --- a/plans/project-wiki.md +++ b/plans/project-wiki.md @@ -5,238 +5,245 @@ ci: no-wait # Project Wiki — desktop -Aplicativo desktop open source (Windows 10/11, Apache-2.0) que **centraliza as fontes de -documentação de um projeto numa pasta local e serve essa pasta por MCP como uma wiki -que o agente de IA lê e escreve.** - -O usuário hoje tem a documentação de um projeto espalhada: um PDF de arquitetura, um -`.docx` de requisitos, decisões que só existem numa reunião gravada. Nada disso responde -"qual o estado atual do projeto X e como chegamos aqui?", e nada disso um agente lê sem -que alguém cole tudo à mão num prompt. - -O app faz três coisas e recusa o resto: **recebe fontes** (arquivo ou gravação) e as -reduz a texto com âncoras de proveniência; **guarda a wiki** como markdown validado; e -**serve um projeto por MCP** para o Claude Code, o Cursor ou qualquer harness. - -**O app não chama LLM nenhum.** Quem lê o texto da fonte, aplica a metodologia LLM-Wiki -e escreve as páginas é o agente, pelo MCP — a única ponte entre a wiki e um modelo. O -app não escreve conteúdo; ele valida o que entra e registra tudo que muda. Ver -`adr:0003-mcp-como-unica-ponte-com-o-llm`. - -## Fora de escopo - -- Extração, resumo ou redação de páginas pelo aplicativo. Isso é do agente. -- Chat dentro do aplicativo. A conversa acontece no harness do usuário. -- Serviço hospedado, conta, autenticação própria, multi-tenancy ou telemetria — `adr:0001-sem-backend-byok`. -- Editor de blocos ao estilo Notion — `adr:0004-edicao-de-markdown-sem-blocos`. -- Colaboração em tempo real, comentários, permissões. -- Índice invertido, embeddings ou vector store. Busca por texto sobre os arquivos, sim. -- Versionamento do workspace — `adr:0002-workspace-como-pasta-local-de-markdown`. -- macOS e Linux. -- Transcrição em tempo real, diarização por ML, bot que entra na reunião. -- Colar texto avulso como fonte. No MVP as fontes são duas: arquivo e gravação. - -## Pronto quando - -O usuário abre o app numa pasta vazia, cria o projeto, sobe um PDF e grava uma reunião -de uma hora — pausando no meio — e clica em transcrever. A tela de fontes mostra as duas -com o texto pronto em `raw/`. Ele liga o servidor MCP para esse projeto, cola a -configuração no Claude Code, e pede que a wiki seja construída a partir das fontes. As -páginas aparecem no app enquanto o agente escreve; uma escrita fora do schema é recusada -com o motivo; e uma pergunta seguinte sobre o estado do projeto é respondida citando as -páginas, com link que abre a fonte no instante certo. - -## Decidido e não em discussão - -Apache-2.0 · Windows apenas no MVP · nenhum backend · o app não chama LLM · o workspace -é uma pasta local, sem git e sem remoto · fontes ficam imutáveis em `raw/` · MCP por -HTTP local, com leitura, ingestão e escrita, servindo um projeto por vez escolhido pelo -app · a única credencial do app é a de transcrição · português brasileiro · captura de -áudio por WASAPI direto, com pausa · Opus 24 kbps como formato de proveniência. - -**Git é do código, não do produto.** Este repositório é versionado; o workspace do -usuário não. - -## O workspace +An open source desktop application (Windows 10/11, Apache-2.0) that **centralises a +project's documentation sources in a local folder and serves that folder over MCP as a +wiki the AI agent reads and writes.** + +Today the user has a project's documentation scattered: an architecture PDF, a +requirements `.docx`, decisions that exist only in a recorded meeting. None of it answers +"what is the current state of project X and how did we get here?", and none of it is read +by an agent without someone pasting it all into a prompt by hand. + +The application does three things and refuses the rest: it **takes in sources** (a file or +a recording) and reduces them to text with provenance anchors; it **stores the wiki** as +validated markdown; and it **serves one project over MCP** to Claude Code, Cursor or any +harness. + +**The application calls no LLM.** Reading the source text, applying the LLM-Wiki +methodology and writing the pages is the agent's job, over MCP — the only bridge between +the wiki and a model. The application does not write content; it validates what comes in +and records everything that changes. See `adr:0003-mcp-as-the-only-bridge-to-the-llm`. + +## Out of scope + +- Extraction, summarisation or page writing by the application. That is the agent's. +- Chat inside the application. The conversation happens in the user's harness. +- A hosted service, accounts, authentication of our own, multi-tenancy or telemetry — `adr:0001-no-backend-byok`. +- A Notion-style block editor — `adr:0004-markdown-editing-without-blocks`. +- Real-time collaboration, comments, permissions. +- An inverted index, embeddings or a vector store. Full-text search over the files, yes. +- Versioning of the workspace — `adr:0002-workspace-as-a-local-markdown-folder`. +- macOS and Linux. +- Real-time transcription, ML diarisation, a bot that joins the meeting. +- Pasting loose text as a source. In the MVP there are two kinds of source: a file and a recording. + +## Done when + +The user opens the application on an empty folder, creates the project, uploads a PDF and +records an hour-long meeting — pausing halfway — and clicks transcribe. The sources screen +shows both, with the text ready in `raw/`. They start the MCP server for that project, +paste the configuration into Claude Code, and ask for the wiki to be built from the +sources. The pages appear in the application while the agent writes; a write that departs +from the schema is refused with a reason; and a follow-up question about the state of the +project is answered by citing the pages, with a link that opens the source at the right +instant. + +## Decided and not up for discussion + +Apache-2.0 · Windows only in the MVP · no backend · the application calls no LLM · the +workspace is a local folder, no git and no remote · sources stay immutable in `raw/` · +MCP over local HTTP, with read, ingest and write, serving one project at a time chosen by +the application · the application's only credential is the transcription one · the content +language is a setting, English by default, with Brazilian Portuguese and Spanish available +(`adr:0008-content-language-is-a-setting-english-by-default`) · audio capture through +WASAPI directly, with pause · Opus 24 kbps as the provenance format. + +**Git belongs to the code, not to the product.** This repository is versioned; the user's +workspace is not. + +## The workspace ``` / - fenix/ um projeto - raw/ fontes, imutáveis depois de escritas - 2026-07-31T14-02-11Z/ uma gravação + fenix/ one project + raw/ sources, immutable once written + 2026-07-31T14-02-11Z/ a recording manifest.json · mic.opus · system.opus · timeline.json · text.md - arquitetura-fenix.pdf/ um arquivo subido + arquitetura-fenix.pdf/ an uploaded file source.pdf · text.md - wiki/ conteúdo primário, escrito pelo agente e pelo usuário + wiki/ primary content, written by the agent and by the user index.md · changelog.md · log.md projects/*.md · people/*.md · topics/*.md - .state/ snapshots e log de operações; não é conteúdo - CLAUDE.md schema e metodologia, para o agente que opera a pasta + .state/ snapshots and operation log; not content + CLAUDE.md schema and methodology, for the agent operating the folder atlas/ ... ``` --- -## 1 — Fundação - -- [ ] 1.1 (Unit) Montar o monorepo: workspace pnpm para `apps/desktop` e `packages/*`, workspace cargo para `crates/recorder`, TypeScript strict compartilhado -- [ ] 1.2 (Unit) Preencher `.claude/rules/project.md` com os comandos reais de build, teste, teste escopado, lint e formatação -- [ ] 1.3 (Unit) CI no GitHub Actions em `windows-latest`: build Rust e TS, testes, lint -- [ ] 1.4 (Unit) Remover `.claude/` e `CLAUDE.md` do `.gitignore` — a metodologia é versionada com o código, e hoje ela existe só nesta máquina -- [ ] 1.5 (Unit) Empacotar `vendor/ffmpeg` por script de download com verificação de hash - -## 2 — Workspace, projetos e escrita segura - -- [ ] 2.1 (Unit) Abrir ou criar um workspace: escolher a pasta e recusar uma já ocupada por outra coisa -- [ ] 2.2 (Unit) Criar, listar e renomear projetos, cada um com `raw/`, `wiki/`, `.state/` e `CLAUDE.md` próprios -- [ ] 2.3 (TDD) Gravar página atomicamente — temporário mais renomeação — tirando snapshot em `.state/` das páginas tocadas antes de qualquer escrita -- [ ] 2.4 (TDD) Registrar toda operação de escrita num log em `.state/`, com origem (editor, MCP), páginas afetadas e horário -- [ ] 2.5 (TDD) Desfazer uma operação pelo seu id, restaurando o snapshot e removendo o que ela criou -- [ ] 2.6 (TDD) Recusar escrita que resolva para fora do projeto servido, inclusive por caminho relativo ou link simbólico - -## 3 — Fonte: arquivos - -- [ ] 3.1 (Unit) Registrar uma fonte em `raw//` com o arquivo original preservado, marcando-a imutável depois de escrita -- [ ] 3.2 (Unit) Subir Markdown e texto puro: copiar para `raw/` e normalizar para `text.md` -- [ ] 3.3 (Unit) Subir PDF: extrair texto para `text.md` preservando o número da página como âncora de proveniência -- [ ] 3.4 (Unit) Subir DOCX: extrair texto e hierarquia de títulos para `text.md` -- [ ] 3.5 (Unit) Arrastar arquivos para a janela, escolher o projeto, e ver o que foi reconhecido e o que não foi - -## 4 — Fonte: gravação de áudio - -- [ ] 4.1 (TDD) `recorder.exe`: capturar microfone e loopback WASAPI em duas faixas WAV alinhadas por clock QPC, fabricando silêncio quando a API não entrega frames -- [ ] 4.2 (TDD) Sobreviver à troca de dispositivo padrão no meio da gravação, reabrindo o stream e anotando o evento em `device_changes` -- [ ] 4.3 (TDD) Pausar e retomar: as duas faixas param e voltam no mesmo instante, o trecho pausado sai de ambas em bloco, e o mapa de tempo continua levando qualquer instante gravado ao instante real do relógio -- [ ] 4.4 (Unit) Emitir `manifest.json` com timestamp absoluto do primeiro frame de cada faixa e os intervalos de pausa -- [ ] 4.5 (Unit) Expor o sidecar por stdio JSON-RPC com `start`, `pause`, `resume`, `stop`, `status`, `devices` -- [ ] 4.6 (Unit) ffmpeg: downmix para 16 kHz mono, VAD cortando silêncio a partir de 800 ms, encode em Opus 24 kbps -- [ ] 4.7 (TDD) Emitir o mapa de tempo que converte instante comprimido em instante real, e as fronteiras de chunk em pontos de silêncio -- [ ] 4.8 (Unit) Interface `SttProvider` com os adaptadores `groq` e `whispercpp`, trocáveis por configuração -- [ ] 4.9 (Unit) Transcrever chunks em paralelo, isolando falha e refazendo só o chunk que falhou -- [ ] 4.10 (Unit) Preencher o vocabulário da transcrição com os nomes já presentes nas páginas do projeto — é o que impede o nome do projeto de sair errado -- [ ] 4.11 (TDD) Reconstruir os timestamps absolutos a partir do offset do chunk e do mapa de tempo -- [ ] 4.12 (Unit) Fundir as duas faixas em `timeline.json` ordenada por tempo real, rotulando `me` e `remote` pela faixa de origem -- [ ] 4.13 (Unit) Renderizar o `text.md` da gravação a partir da timeline, com o instante de cada trecho como âncora de proveniência -- [ ] 4.14 (Unit) Descartar o WAV assim que a transcrição confirma sucesso, mantendo o Opus como arquivo de proveniência - -## 5 — A wiki como armazém validado - -O que substitui o código que escrevia as páginas: o app não garante que o conteúdo seja -bom, garante que ele seja **bem formado**. Toda escrita — do editor ou do MCP — passa por -aqui. - -- [ ] 5.1 (TDD) Validar o frontmatter da página contra o schema (`id`, `type`, `title`, `status`, `aliases`, `updated`, `sources`) e recusar a escrita com o motivo, em vez de gravar torto -- [ ] 5.2 (TDD) Recusar escrita cujo wikilink não resolva para página existente, dizendo qual link quebrou -- [ ] 5.3 (TDD) Recusar escrita cuja citação de proveniência não aponte para fonte existente e, no caso de áudio, para instante dentro da gravação -- [ ] 5.4 (Unit) Preencher `updated` e acrescentar a fonte em `sources` automaticamente, para que isso não dependa de o agente lembrar -- [ ] 5.5 (Unit) Acrescentar uma linha em `log.md` e a entrada em `changelog.md` a cada operação de escrita, com a origem -- [ ] 5.6 (Unit) Manter o índice: registrar página nova em `index.md` e apontar página que ficou inalcançável - -## 6 — Fluxo das fontes - -- [ ] 6.1 (Unit) Modelar o estado de cada fonte — recebida, texto pronto, referenciada em página — persistido e retomável -- [ ] 6.2 (Unit) Tela de fontes: uma linha por fonte com seu estado atual, o que falta, e o erro quando parou -- [ ] 6.3 (Unit) Botão de transcrever numa gravação parada, com progresso por chunk e a possibilidade de refazer só o que falhou -- [ ] 6.4 (Unit) Mostrar, para uma fonte, em quais páginas ela foi citada, e navegar dali para a página -- [ ] 6.5 (Unit) Mostrar, para uma página, de quais fontes ela veio — o caminho inverso do anterior -- [ ] 6.6 (Unit) Destacar fonte parada em `raw/` que nenhuma página cita, que é o caso que some de vista sozinho - -## 7 — Integridade - -Com o agente escrevendo, isto deixa de ser higiene e vira a defesa contra deriva. - -- [ ] 7.1 (Unit) Reportar wikilink quebrado e página órfã -- [ ] 7.2 (Unit) Reportar changelog dessincronizado e fonte nunca citada -- [ ] 7.3 (Unit) Reportar link de proveniência que não resolve para fonte ou instante existente -- [ ] 7.4 (Unit) Reportar sinônimo usado onde o projeto tem termo canônico -- [ ] 7.5 (Unit) Expor as verificações na UI, com o caminho de correção descrito por finding -- [ ] 7.6 (Unit) Expor as mesmas verificações como ferramenta MCP, para o agente conferir o próprio trabalho antes de encerrar - -## 8 — Aplicativo - -- [ ] 8.1 (Unit) Design system: tokens do tema escuro denso, escala tipográfica compacta, estados de foco e de erro, indicador de gravação -- [ ] 8.2 (Unit) Shell do Electron: navegação entre wiki, fontes e MCP, seletor de projeto, gravar, pausar e parar, indicador persistente enquanto grava -- [ ] 8.3 (Unit) Credencial de transcrição: chave da Groq digitada e validada na hora, ou whisper.cpp local sem credencial alguma — guardada conforme `adr:0007-credenciais-em-texto-claro-no-config` -- [ ] 8.4 (Unit) Onboarding: escolher a pasta do workspace, criar o primeiro projeto, e ligar o servidor MCP com a configuração pronta para colar -- [ ] 8.5 (Unit) Navegar a wiki renderizada: seguir wikilinks, ver a página com seu frontmatter, voltar -- [ ] 8.6 (Unit) Abrir a fonte no instante certo ao clicar num link de proveniência — áudio no timestamp, documento na página -- [ ] 8.7 (Unit) Editar o markdown de uma página com preview e salvar, passando pelas validações do grupo 5 -- [ ] 8.8 (Unit) Recusar sobrescrever página alterada em disco desde que foi carregada, em vez de perder a alteração em silêncio -- [ ] 8.9 (Unit) Criar, renomear e apagar página pela UI, corrigindo os wikilinks que apontavam para ela -- [ ] 8.10 (Unit) Refletir na tela, ao vivo, as páginas que o agente escreve por MCP -- [ ] 8.11 (Unit) Histórico de operações com desfazer, alimentado por 2.4 — o único caminho de volta que existe - -## 9 — Servidor MCP - -- [ ] 9.1 (Unit) Biblioteca de acesso ao projeto — listar, ler, buscar, ingerir, escrever — uma implementação, usada pela UI e pelo servidor -- [ ] 9.2 (Unit) Servidor MCP por HTTP, ligado só ao loopback, ligado e desligado pelo app, servindo exatamente um projeto escolhido pelo app, sempre no mesmo endereço -- [ ] 9.3 (TDD) Exigir token em toda requisição, gerado por workspace, e recusar requisição sem ele — qualquer processo local alcança essa porta -- [ ] 9.4 (TDD) Nenhuma ferramenta aceita parâmetro de projeto, e nenhuma alcança caminho fora do projeto servido -- [ ] 9.5 (Unit) Trocar o projeto servido derrubando as conexões abertas, para que o harness nunca continue falando com o projeto anterior -- [ ] 9.6 (Unit) Anunciar o projeto ativo no nome e na descrição do servidor, para que o agente diga em qual base está trabalhando -- [ ] 9.7 (Unit) Ferramentas de leitura: listar páginas, ler página, buscar por texto devolvendo trechos -- [ ] 9.8 (Unit) Ferramentas de fonte: listar fontes com seu estado e ler o `text.md` de uma delas -- [ ] 9.9 (Unit) Ferramenta de ingestão: aceitar um documento, gravá-lo em `raw/` e reduzi-lo a texto pelo mesmo caminho do grupo 3 -- [ ] 9.10 (TDD) Ferramentas de escrita — criar, atualizar, renomear e apagar página — passando pelas validações do grupo 5, pelo caminho atômico de 2.3 e pelo log de 2.4 -- [ ] 9.11 (Unit) Devolver erro de validação legível o bastante para o agente corrigir sozinho e tentar de novo -- [ ] 9.12 (Unit) Mostrar na UI, de forma inequívoca, qual projeto está sendo servido, as conexões ativas e as últimas operações que entraram por MCP -- [ ] 9.13 (Unit) Gerar a configuração pronta para colar no harness, com endereço e token -- [ ] 9.14 (TDD) Gerar `CLAUDE.md` no projeto com o schema das páginas e a metodologia LLM-Wiki — é o único lugar onde a convenção existe, já que ela deixou de existir em código -- [ ] 9.15 (Unit) Verificar de ponta a ponta que o Claude Code, apontado para o servidor e partindo de uma fonte só, constrói páginas válidas e depois responde citando-as - -## 10 — Distribuição - -- [ ] 10.1 (Unit) Instalador único com ffmpeg e `recorder.exe` embarcados, sem dependência externa a instalar -- [ ] 10.2 (Unit) Publicação em winget e Scoop -- [ ] 10.3 (Unit) README com o aviso de gravação e a responsabilidade de informar os participantes +## 1 — Foundation + +- [ ] 1.1 (Unit) Set up the monorepo: a pnpm workspace for `apps/desktop` and `packages/*`, a cargo workspace for `crates/recorder`, shared strict TypeScript +- [ ] 1.2 (Unit) Fill in `.claude/rules/project.md` with the real build, test, scoped test, lint and format commands +- [ ] 1.3 (Unit) CI on GitHub Actions on `windows-latest`: Rust and TS build, tests with a coverage floor of 76% per package, lint +- [ ] 1.4 (Unit) Remove `.claude/` and `CLAUDE.md` from `.gitignore` — the methodology is versioned with the code, and today it exists only on this machine +- [ ] 1.5 (Unit) Bundle `vendor/ffmpeg` through a download script with hash verification + +## 2 — Workspace, projects and safe writing + +- [ ] 2.1 (Unit) Open or create a workspace: choose the folder and refuse one already occupied by something else +- [ ] 2.2 (Unit) Create, list and rename projects, each with its own `raw/`, `wiki/`, `.state/` and `CLAUDE.md` +- [ ] 2.3 (TDD) Write a page atomically — temporary file plus rename — snapshotting the touched pages into `.state/` before any write +- [ ] 2.4 (TDD) Record every write operation in a log in `.state/`, with its origin (editor, MCP), the affected pages and the time +- [ ] 2.5 (TDD) Undo an operation by its id, restoring the snapshot and removing what it created +- [ ] 2.6 (TDD) Refuse a write that resolves outside the served project, including through a relative path or a symbolic link + +## 3 — Sources: files + +- [ ] 3.1 (Unit) Register a source in `raw//` with the original file preserved, marking it immutable once written +- [ ] 3.2 (Unit) Upload Markdown and plain text: copy into `raw/` and normalise to `text.md` +- [ ] 3.3 (Unit) Upload a PDF: extract the text to `text.md`, keeping the page number as a provenance anchor +- [ ] 3.4 (Unit) Upload a DOCX: extract the text and the heading hierarchy to `text.md` +- [ ] 3.5 (Unit) Drag files onto the window, choose the project, and see what was recognised and what was not + +## 4 — Sources: audio recording + +- [ ] 4.1 (TDD) `recorder.exe`: capture the microphone and the WASAPI loopback into two WAV tracks aligned by the QPC clock, manufacturing silence when the API delivers no frames +- [ ] 4.2 (TDD) Survive a default-device change mid-recording, reopening the stream and noting the event in `device_changes` +- [ ] 4.3 (TDD) Pause and resume: both tracks stop and return at the same instant, the paused stretch leaves both as one block, and the time map still maps any recorded instant to the real clock instant +- [ ] 4.4 (Unit) Emit `manifest.json` with the absolute timestamp of each track's first frame and the pause intervals +- [ ] 4.5 (Unit) Expose the sidecar over stdio JSON-RPC with `start`, `pause`, `resume`, `stop`, `status`, `devices` +- [ ] 4.6 (Unit) ffmpeg: downmix to 16 kHz mono, VAD cutting silence from 800 ms, encode to Opus 24 kbps +- [ ] 4.7 (TDD) Emit the time map converting a compressed instant into a real instant, and the chunk boundaries at silence points +- [ ] 4.8 (Unit) A `SttProvider` interface with `groq` and `whispercpp` adapters, swappable by configuration +- [ ] 4.9 (Unit) Transcribe chunks in parallel, isolating failure and redoing only the chunk that failed +- [ ] 4.10 (Unit) Seed the transcription vocabulary with the names already present in the project's pages — it is what stops the project's own name from coming out wrong +- [ ] 4.11 (TDD) Reconstruct the absolute timestamps from the chunk offset and the time map +- [ ] 4.12 (Unit) Merge the two tracks into a `timeline.json` ordered by real time, labelling `me` and `remote` by the track they came from +- [ ] 4.13 (Unit) Render the recording's `text.md` from the timeline, with each passage's instant as a provenance anchor +- [ ] 4.14 (Unit) Discard the WAV as soon as transcription confirms success, keeping the Opus as the provenance file +- [ ] 4.15 (Unit) Send the configured content language as the transcription hint rather than relying on the provider detecting it — `adr:0008-content-language-is-a-setting-english-by-default` + +## 5 — The wiki as a validated store + +What replaces the code that used to write the pages: the application does not guarantee +the content is good, it guarantees it is **well formed**. Every write — from the editor or +from MCP — goes through here. + +- [ ] 5.1 (TDD) Validate the page frontmatter against the schema (`id`, `type`, `title`, `status`, `aliases`, `updated`, `sources`) and refuse the write with a reason, instead of storing something malformed +- [ ] 5.2 (TDD) Refuse a write whose wikilink does not resolve to an existing page, saying which link broke +- [ ] 5.3 (TDD) Refuse a write whose provenance citation does not point at an existing source and, for audio, at an instant inside the recording +- [ ] 5.4 (Unit) Fill in `updated` and append the source to `sources` automatically, so that it does not depend on the agent remembering +- [ ] 5.5 (Unit) Append a line to `log.md` and an entry to `changelog.md` on every write operation, with its origin +- [ ] 5.6 (Unit) Maintain the index: register a new page in `index.md` and flag a page that became unreachable + +## 6 — Source flow + +- [ ] 6.1 (Unit) Model each source's state — received, text ready, cited on a page — persisted and resumable +- [ ] 6.2 (Unit) Sources screen: one row per source with its current state, what is missing, and the error when it stopped +- [ ] 6.3 (Unit) A transcribe button on a stopped recording, with per-chunk progress and the option to redo only what failed +- [ ] 6.4 (Unit) Show, for a source, which pages cite it, and navigate from there to the page +- [ ] 6.5 (Unit) Show, for a page, which sources it came from — the inverse path of the previous one +- [ ] 6.6 (Unit) Highlight a source sitting in `raw/` that no page cites, which is the case that disappears from view on its own + +## 7 — Integrity + +With the agent writing, this stops being hygiene and becomes the defence against drift. + +- [ ] 7.1 (Unit) Report broken wikilinks and orphan pages +- [ ] 7.2 (Unit) Report a desynchronised changelog and a source never cited +- [ ] 7.3 (Unit) Report a provenance link that does not resolve to an existing source or instant +- [ ] 7.4 (Unit) Report a synonym used where the project has a canonical term +- [ ] 7.5 (Unit) Expose the checks in the UI, with the correction path described per finding +- [ ] 7.6 (Unit) Expose the same checks as an MCP tool, so the agent can check its own work before finishing + +## 8 — Application + +- [ ] 8.1 (Unit) Design system: dense dark-theme tokens, a compact type scale, focus and error states, a recording indicator +- [ ] 8.2 (Unit) Electron shell: navigation across wiki, sources and MCP, a project selector, record, pause and stop, a persistent indicator while recording +- [ ] 8.3 (Unit) Transcription credential: a Groq key typed and validated on the spot, or local whisper.cpp with no credential at all — stored as per `adr:0007-plaintext-credentials-in-the-config` +- [ ] 8.4 (Unit) Onboarding: choose the workspace folder, create the first project, and start the MCP server with the configuration ready to paste +- [ ] 8.5 (Unit) Browse the rendered wiki: follow wikilinks, see the page with its frontmatter, go back +- [ ] 8.6 (Unit) Open the source at the right instant when a provenance link is clicked — audio at the timestamp, a document at the page +- [ ] 8.7 (Unit) Edit a page's markdown with preview and save, going through the group 5 validations +- [ ] 8.8 (Unit) Refuse to overwrite a page changed on disk since it was loaded, instead of losing the change silently +- [ ] 8.9 (Unit) Create, rename and delete a page from the UI, fixing the wikilinks that pointed at it +- [ ] 8.10 (Unit) Reflect on screen, live, the pages the agent writes over MCP +- [ ] 8.11 (Unit) An operation history with undo, fed by 2.4 — the only way back there is +- [ ] 8.12 (Unit) Choose the content language at onboarding and change it afterwards — English by default, Brazilian Portuguese and Spanish alongside it — reaching the transcription hint and the generated `CLAUDE.md`, and nothing else + +## 9 — MCP server + +- [ ] 9.1 (Unit) A project access module — list, read, search, ingest, write — one implementation, used by the UI and by the server +- [ ] 9.2 (Unit) An MCP server over HTTP, bound to the loopback only, started and stopped by the application, serving exactly one project chosen by the application, always at the same address +- [ ] 9.3 (TDD) Require a token on every request, generated per workspace, and refuse a request without it — any local process reaches that port +- [ ] 9.4 (TDD) No tool takes a project parameter, and none reaches a path outside the served project +- [ ] 9.5 (Unit) Switch the served project by dropping the open connections, so that the harness never goes on talking to the previous project +- [ ] 9.6 (Unit) Announce the active project in the server's name and description, so the agent says which base it is working on +- [ ] 9.7 (Unit) Read tools: list pages, read a page, search full text returning passages +- [ ] 9.8 (Unit) Source tools: list sources with their state and read the `text.md` of one of them +- [ ] 9.9 (Unit) An ingest tool: accept a document, write it into `raw/` and reduce it to text through the same path as group 3 +- [ ] 9.10 (TDD) Write tools — create, update, rename and delete a page — going through the group 5 validations, the atomic path of 2.3 and the log of 2.4 +- [ ] 9.11 (Unit) Return a validation error readable enough for the agent to fix it on its own and try again +- [ ] 9.12 (Unit) Show in the UI, unambiguously, which project is being served, the active connections and the latest operations that came in over MCP +- [ ] 9.13 (Unit) Generate the configuration ready to paste into the harness, with the address and the token +- [ ] 9.14 (TDD) Generate the project's `CLAUDE.md` with the page schema, the LLM-Wiki methodology and the configured content language — it is the only place either convention exists, now that neither exists in code +- [ ] 9.15 (Unit) Verify end to end that Claude Code, pointed at the server and starting from a single source, builds valid pages and then answers by citing them + +## 10 — Distribution + +- [ ] 10.1 (Unit) A single NSIS installer with ffmpeg and `recorder.exe` embedded, written to `apps/desktop/release/`, with no external dependency to install — `adr:0009-distribution-through-github-releases` +- [ ] 10.2 (Unit) Release from a `v*` tag: CI builds the installer, refuses a tag that disagrees with the app version or that already has a release, and publishes it to GitHub Releases with its `SHA256SUMS.txt` +- [ ] 10.3 (Unit) Publish to winget and Scoop, with the manifests pointing at the release URL and quoting its hash +- [ ] 10.4 (Unit) A README with the recording notice, the responsibility to inform participants, and what the SmartScreen warning on an unsigned installer means --- ## Notes -**Ordem.** O produto mínimo é 2 + 3 + 5 + 9: um projeto, um markdown subido, um armazém -que valida, e um servidor que o Claude Code opera. Com isso o ciclo inteiro já roda, sem -áudio, sem PDF e sem UI bonita. Faça esse caminho fechar primeiro — ele responde a única -pergunta que importa, que é se um agente consegue construir e manter a wiki pelas -ferramentas que você expôs. +**Order.** The minimum product is 2 + 3 + 5 + 9: one project, one uploaded markdown file, a +store that validates, and a server Claude Code drives. With that the whole cycle already +runs, with no audio, no PDF and no pretty UI. Close that path first — it answers the only +question that matters, which is whether an agent can build and maintain the wiki through +the tools you exposed. -O grupo 8 vem depois: uma wiki que o Claude Code já opera tem valor com uma UI feia, e o -contrário não é verdade. +Group 8 comes after: a wiki Claude Code already operates has value with an ugly UI, and the +reverse is not true. -**O grupo 4 é o mais caro e o menos central.** Reunião é a fonte que não deixa rastro -nenhum sozinha, mas o produto tem valor sem ela, e ela desemboca no mesmo `text.md` que -um PDF — some depois sem mudança a jusante. Se algo tiver que esperar, é este grupo. +**Group 4 is the most expensive and the least central.** A meeting is the source that +leaves no trace on its own, but the product has value without it, and it flows into the +same `text.md` a PDF does — it can arrive later with no downstream change. If something has +to wait, it is this group. -**Costuras onde isto tende a falhar.** +**Seams where this tends to fail.** -*Não há mais recompilação.* Com a destilação fora do app, `wiki/` deixou de ser derivável -de `raw/` e virou conteúdo primário. Nenhuma tarefa reconstrói a wiki, e nenhuma pode. O -par 2.3–2.5 é a única rede que existe, e é por isso que as três são `(TDD)` e vêm antes -de qualquer coisa que escreva. +*There is no recompilation any more.* With distillation out of the application, `wiki/` +stopped being derivable from `raw/` and became primary content. No task rebuilds the wiki, +and none can. The pair 2.3–2.5 is the only net there is, which is why all three are `(TDD)` +and come before anything that writes. -*A convenção mora num arquivo de prosa.* 9.14 gera o `CLAUDE.md` que carrega o schema e a -metodologia; se ele estiver vago, agentes diferentes escrevem diferente e a wiki deriva -sem que nada quebre. O grupo 5 é o que impede a deriva de virar corrupção — mas ele -verifica forma, não sentido. Uma página bem formada e errada passa. +*The convention lives in a prose file.* 9.14 generates the `CLAUDE.md` carrying the schema +and the methodology; if it is vague, different agents write differently and the wiki drifts +without anything breaking. Group 5 is what stops the drift from becoming corruption — but +it checks form, not meaning. A well-formed and wrong page passes. -*A porta é local, não é privada.* Qualquer processo na máquina alcança o loopback. Com -ingestão e escrita expostas, 9.3 e 9.4 são a diferença entre uma ferramenta e um vetor. +*The port is local, not private.* Any process on the machine reaches the loopback. With +ingest and write exposed, 9.3 and 9.4 are the difference between a tool and a vector. -*O mapa de tempo mente com confiança.* Atravessa 4.7, 4.11, 4.13, 5.3 e 7.3. Se estiver -errado, a proveniência aponta para o instante errado — pior que não existir. Três -conferências manuais numa gravação de uma hora são critério de aceite do grupo 4. +*The time map lies with confidence.* It runs through 4.7, 4.11, 4.13, 5.3 and 7.3. If it is +wrong, provenance points at the wrong instant — worse than not existing. Three manual checks +on an hour-long recording are an acceptance criterion for group 4. -*A retenção de disco tem uma ordem certa.* WAV de uma hora ocupa ~690 MB e o apagamento -(4.14) fica no ponto que pode ser interrompido. Apagar antes da confirmação perde a -gravação; nunca apagar enche o disco em vinte reuniões. +*Disk retention has a correct order.* An hour of WAV takes ~690 MB, and the deletion (4.14) +sits at exactly the point that can be interrupted. Deleting before the confirmation loses +the recording; never deleting fills the disk in twenty meetings. -*O erro de validação é uma interface.* 9.11 parece cosmético e não é: com o agente -escrevendo, uma recusa que ele não entende vira uma tentativa que ele repete igual. A -mensagem é o que fecha o laço. +*The validation error is an interface.* 9.11 looks cosmetic and is not: with the agent +writing, a refusal it cannot understand becomes an attempt it repeats verbatim. The message +is what closes the loop. -**Métodos.** As `(TDD)` são as tarefas em que estar errado não dá sintoma: alinhamento -das faixas, pausa, mapa de tempo, escrita atômica, log de operações, desfazer, -confinamento ao projeto, as três validações de escrita, o token do servidor, as -ferramentas de escrita e o `CLAUDE.md` gerado. São também as que devem ser apresentadas -antes de landar, mesmo em execução automática. +**Methods.** The `(TDD)` ones are the tasks where being wrong produces no symptom: track +alignment, pause, the time map, atomic writing, the operation log, undo, confinement to the +project, the three write validations, the server token, the write tools and the generated +`CLAUDE.md`. They are also the ones to surface before landing, even in an automatic run. diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..3ff5faa --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "apps/*" + - "packages/*" diff --git a/scripts/ci/check-coverage.mjs b/scripts/ci/check-coverage.mjs new file mode 100644 index 0000000..205453f --- /dev/null +++ b/scripts/ci/check-coverage.mjs @@ -0,0 +1,64 @@ +/** + * Fails when a package's coverage is below the floor. + * + * node scripts/ci/check-coverage.mjs apps/desktop + * + * The floor also lives in vitest.shared.ts, and vitest enforces it locally. This + * check exists because that one is opt-in: a package that overrides or drops + * `coverage.thresholds` would go green with no coverage at all. Reading the + * summary here is the check that cannot be configured away. + */ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); + +const packagePath = process.argv[2]; +if (!packagePath) { + console.error("usage: node scripts/ci/check-coverage.mjs "); + process.exit(2); +} + +const threshold = Number(process.env["COVERAGE_THRESHOLD"] ?? 76); +if (!Number.isFinite(threshold)) { + console.error(`COVERAGE_THRESHOLD is not a number: ${process.env["COVERAGE_THRESHOLD"]}`); + process.exit(2); +} + +const summaryPath = join(repoRoot, packagePath, "coverage", "coverage-summary.json"); +if (!existsSync(summaryPath)) { + console.error( + `${packagePath}: no coverage/coverage-summary.json.\n` + + "Its test script has to run vitest with --coverage and the json-summary reporter " + + "(vitest.shared.ts configures both). A test run that reports no coverage cannot " + + "clear a coverage floor.", + ); + process.exit(1); +} + +const total = JSON.parse(readFileSync(summaryPath, "utf8"))["total"]; +if (!total) { + console.error(`${packagePath}: coverage summary has no "total" section`); + process.exit(1); +} + +const metrics = ["lines", "statements", "functions", "branches"]; +const failed = []; + +console.log(`${packagePath} — floor ${threshold}%`); +for (const metric of metrics) { + const pct = total[metric]?.pct; + if (typeof pct !== "number") { + failed.push(`${metric}: missing from the summary`); + continue; + } + const ok = pct >= threshold; + console.log(` ${ok ? "ok " : "FAIL"} ${metric.padEnd(11)} ${pct.toFixed(2)}%`); + if (!ok) failed.push(`${metric}: ${pct.toFixed(2)}% < ${threshold}%`); +} + +if (failed.length > 0) { + console.error(`\n${packagePath} is below the coverage floor:\n ${failed.join("\n ")}`); + process.exit(1); +} diff --git a/scripts/ci/workspace-packages.mjs b/scripts/ci/workspace-packages.mjs new file mode 100644 index 0000000..07b124b --- /dev/null +++ b/scripts/ci/workspace-packages.mjs @@ -0,0 +1,93 @@ +/** + * Lists the pnpm workspace packages that have tests, as a GitHub Actions matrix. + * + * Emits `key=value` lines on stdout, meant to be appended to $GITHUB_OUTPUT, and + * a human-readable summary on stderr: + * + * packages=[{"name":"@project-wiki/desktop","path":"apps/desktop", ...}] + * any=true + * rust=false + * + * It reads the globs out of pnpm-workspace.yaml rather than hard-coding them, so + * adding a workspace root never means remembering to edit CI too. + */ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); + +/** The `packages:` list of pnpm-workspace.yaml — a flat sequence of glob strings. */ +function workspaceGlobs() { + const file = join(repoRoot, "pnpm-workspace.yaml"); + if (!existsSync(file)) return []; + const globs = []; + let inPackages = false; + for (const line of readFileSync(file, "utf8").split(/\r?\n/)) { + if (/^packages:\s*$/.test(line)) { + inPackages = true; + continue; + } + if (inPackages) { + const item = /^\s+-\s*["']?(.+?)["']?\s*$/.exec(line); + if (item) globs.push(item[1]); + else if (line.trim() !== "") break; + } + } + return globs; +} + +/** Only `prefix/*` is supported, which is every shape this workspace uses. */ +function expand(glob) { + const star = glob.indexOf("*"); + if (star === -1) return existsSync(join(repoRoot, glob)) ? [glob] : []; + if (!glob.endsWith("/*") || glob.slice(0, -2).includes("*")) { + process.stderr.write(`unsupported workspace glob, ignored: ${glob}\n`); + return []; + } + const prefix = glob.slice(0, -2); + const dir = join(repoRoot, prefix); + if (!existsSync(dir)) return []; + return readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => `${prefix}/${entry.name}`); +} + +const packages = []; +for (const glob of workspaceGlobs()) { + for (const path of expand(glob)) { + const manifest = join(repoRoot, path, "package.json"); + if (!existsSync(manifest)) continue; + + const pkg = JSON.parse(readFileSync(manifest, "utf8")); + const scripts = pkg.scripts ?? {}; + // A package that reports coverage is preferred; a bare `test` still runs, + // and the coverage gate then fails it for producing no summary — which is + // the honest outcome, not a silent pass. + const script = scripts["test:coverage"] ? "test:coverage" : scripts["test"] ? "test" : null; + if (!script) continue; + + packages.push({ + name: pkg.name ?? path, + path, + script, + slug: (pkg.name ?? path).replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-|-$/g, ""), + }); + } +} + +packages.sort((a, b) => a.path.localeCompare(b.path)); + +const rust = existsSync(join(repoRoot, "crates")) || existsSync(join(repoRoot, "Cargo.toml")); + +process.stdout.write(`packages=${JSON.stringify(packages)}\n`); +process.stdout.write(`any=${packages.length > 0}\n`); +process.stdout.write(`rust=${rust}\n`); + +process.stderr.write( + packages.length === 0 + ? "no workspace package declares a test script yet — the test matrix is empty\n" + : `${packages.length} package(s) to test:\n${packages + .map((p) => ` ${p.path} → pnpm --filter ${p.name} run ${p.script}\n`) + .join("")}`, +); diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..12b2012 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["node", "vitest/globals"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "skipLibCheck": true, + "noEmit": true + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a3ab56f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.base.json", + "files": [], + "include": ["vitest.shared.ts"] +} diff --git a/vitest.shared.ts b/vitest.shared.ts new file mode 100644 index 0000000..112dfab --- /dev/null +++ b/vitest.shared.ts @@ -0,0 +1,40 @@ +import { defineConfig } from "vitest/config"; + +/** + * The coverage floor every package in this repo has to clear. CI reads the same + * number out of `coverage/coverage-summary.json`, so a package that drops these + * thresholds locally still fails there — the gate is not on the honour system. + */ +export const COVERAGE_THRESHOLD = 76; + +/** + * Base config each package extends: + * + * import { defineConfig, mergeConfig } from "vitest/config"; + * import shared from "../../vitest.shared.js"; + * + * export default mergeConfig(shared, defineConfig({ test: { name: "desktop" } })); + */ +export default defineConfig({ + test: { + globals: true, + include: ["src/**/*.spec.ts", "tests/**/*.spec.ts"], + coverage: { + provider: "v8", + // json-summary is what CI parses; text is for the person reading the log. + reporter: ["text", "json-summary", "lcov"], + reportsDirectory: "coverage", + include: ["src/**/*.ts"], + exclude: ["**/*.spec.ts", "**/*.d.ts", "**/index.ts"], + // Reporting only on files a test happened to import hides the untested + // module entirely, which is the exact thing a floor is meant to catch. + all: true, + thresholds: { + lines: COVERAGE_THRESHOLD, + statements: COVERAGE_THRESHOLD, + functions: COVERAGE_THRESHOLD, + branches: COVERAGE_THRESHOLD, + }, + }, + }, +});