diff --git a/.gitignore b/.gitignore index f0c3cd2..8ee7285 100644 --- a/.gitignore +++ b/.gitignore @@ -25,11 +25,16 @@ coverage/ # ai-jail -> github.com/akitaonrails/ai-jail (GPL-3.0) # harnes-opencode -> harness de referencia, usado no diagnostico # promptfoo-demo-evals -> exemplos do promptfoo +# orca -> github.com/stablyai/orca (MIT), referencia de +# feature para a superficie visual (C12). Lido, nunca +# vendorizado: nada dele entra no produto. # A versao minima exigida do ai-jail esta no README. # ========================================== /ai-jail /harnes-opencode /promptfoo-demo-evals +/orca +/orca-linux.AppImage # ========================================== # Artefato de execucao do harness diff --git a/CHANGELOG.md b/CHANGELOG.md index bae99f6..1684388 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,474 @@ Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/). Versionamento [SemVer](https://semver.org/lang/pt-BR/). +## [0.3.0] - 2026-08-25 + +Motor de memória (C5) e adapter `claude-code` (C8), que é a v0.3 inteira. + +Fica de fora a reescrita da página de sessão como narrativa (R5.2), que é chamada +de modelo e depende do Maestro, e o servidor MCP que expõe as tools do núcleo ao +agente (R5.6, R8.2), os dois declarados abaixo. + +A revisão desta metade caiu em cima do C3 e endureceu o motor de fronteira, o +que está registrado mais abaixo. + +Entre escrever esta versão e publicá-la, ela foi ao campo pela primeira vez. O +que voltou de lá está na seção do teste de campo, e é a parte mais importante +deste changelog. + +### Adicionado + +- Página de memória em `.harness/memory/pages/.md`, markdown com cabeçalho + validado por JSON Schema. + + O arquivo é a versão canônica; o SQLite é só índice, e pode ser apagado e + reconstruído sem perda. É a mesma divisão da evidência. + +- Índice FTS5 com frescor por hash do arquivo (R5.3). + + Toda busca sincroniza o índice antes de responder: página editada fora do + `psh` entra na resposta seguinte, página corrompida sai do índice **com + aviso**, e o número de páginas examinadas vai na saída (R2.13). + +- Modo degradado declarado na busca: SQLite sem FTS5 responde por varredura de + substring, e diz que respondeu por varredura, no resultado e no `psh doctor`. + +- `psh remember ""` (R5.5), `psh memory + list|search|get|promote|consolidate|reindex` e `psh handoff` (R5.4). + +- `psh handoff` monta o bloco de retomada a partir do estado e da evidência em + disco, nunca de resumo de modelo: fase, tentativa, última decisão, o que + reprova o portão agora, memória fixada e o próximo comando. + + É o caso de uso UC3: fechar a sessão às 18h por limite de plano e reabrir + amanhã, possivelmente em outro runtime. + +- `psh memory promote ` leva a página para `docs/decisoes/` no + repositório e deixa a página apontando para o destino (R5.7). + + Duas cópias sem ponteiro seriam duas verdades. + +- `psh memory consolidate` fecha a sessão numa página (R5.2), a partir da faixa + da trilha que ainda não foi consolidada. + + Fases, verificadores, violações de fronteira, decisões humanas, anotações e + comandos, cada linha carregando o número da entrada que a originou. + + A marca d'água mora em `.harness/memory/consolidation.json`, fora do índice, + e só avança depois de a página existir e a trilha registrar. + +### Decisões que valem registro + +- **A memória entra no deny duro da fronteira.** + + O bloco de handoff é injetado no início da sessão seguinte. Memória que o + agente escreve à mão é texto que ele injeta em si mesmo depois, sem passar por + nenhuma porta do núcleo. Escrita de página é por `psh remember`, mesmo com + `write: ["**"]` na allowlist. + +- **O cabeçalho não aceita o que não consegue devolver.** + + `psh remember --title $'x\npinned: false'` gravava a página, reportava sucesso + e deixava a anotação ilegível para sempre. Tag com vírgula voltava partida em + duas. Quebra de linha e vírgula agora param na porta de escrita, com o campo + nomeado: perder a anotação que o comando existe para guardar é pior do que + recusar o título. + +- **Promover não contorna a fronteira de quem promove.** + + `psh memory promote x --to src/web/app.tsx --force` deixaria um agente com + allowlist `src/api/**` escrever fora dela com a assinatura do núcleo. O + destino passa pela fronteira do agente quando há agente; o humano continua + sendo a autoridade que define a allowlist. + +- **Symlink não é página.** + + Ele não era lido, mas também não era contado nem nomeado em lugar nenhum, o + que contraria o R2.13. Agora entra na contagem e sai nomeado na lista de + ignorados, e `psh memory get` recusa pelo mesmo motivo que a enumeração. + +- **A consulta do usuário nunca é sintaxe de FTS.** + + Cada termo vira literal entre aspas com prefixo. Quem digita + `psh memory search "NOT ai-jail"` está procurando essas três palavras, não + escrevendo expressão booleana, e um termo com `*`, `(` ou `"` não derruba a + busca nem vira operador por acidente. + +- **O slug é conferido antes de virar caminho.** + + Minúscula, dígito e hífen. `psh memory get ../../etc/passwd` para no alfabeto, + não no sistema de arquivos (R2.14). + +- **A página fixada vem antes da relevância na ordenação.** + + R5.5 diz que ela não pode ser perdida na consolidação, e ser empurrada para + fora do limite da busca é uma forma de perder. + +- **`.harness/memory/` fica fora do repositório.** + + A faixa é transitória por definição (R5.7). O que precisa sobreviver com + garantia sai dela por `psh memory promote` e vira arquivo versionado, que entra + em revisão como qualquer outro. + +- **Cabeçalho de página é lido em modo estrito.** + + Campo desconhecido, campo repetido ou fechamento ausente derrubam a leitura. + Página meio lida vira contexto errado na sessão seguinte, e contexto errado não + avisa que está errado. + +- **O bloco de retomada tem teto declarado.** + + Ele vai para o início da sessão seguinte, e janela útil é recurso medido + (R7.2). Doze páginas fixadas, quatrocentos caracteres por corpo, e o que ficou + de fora sai dito no próprio bloco, com o comando que traz o resto. + +- **A trilha é a captura (R5.1).** + + Decisão de fase, resultado de verificador e anotação já entram nela por R4.3, + encadeados por hash e conferíveis por `psh audit verify`. Guardar uma segunda + cópia dos mesmos fatos num buffer paralelo criaria duas versões da mesma + sessão, e a segunda não teria como provar que é verdadeira. + + O que falta é prompt do usuário, que só o adapter enxerga. + +- **A consolidação não resume a si mesma.** + + Ela grava uma entrada `memory.write` ao terminar. Sem filtrar essa entrada, + rodar o comando três vezes seguidas produzia três páginas, e as duas últimas + só falavam da anterior. + +- **Trilha comprometida não vira memória.** + + A consolidação verifica a cadeia antes de resumir. Assinar como memória um + relato que a própria cadeia não sustenta seria fabricar prova. + +- **A narrativa por LLM não foi improvisada.** + + R5.2 pede a página reescrita como narrativa, e isso é chamada de modelo pelo + Maestro, que é v0.4. Em vez de chamar modelo por fora do roteador, a página + sai montada da trilha, com o número de cada entrada, e diz na própria página + que foi montada sem modelo. Quando o C6 entrar, a narrativa vira uma reescrita + por cima deste texto. + +### Adapter `claude-code` (R8.1, R8.2) + +- Cinco pontos de extensão registrados em `.claude/settings.json`, em **forma + exec**: o runtime executa o binário direto, com os argumentos em lista, sem + shell no meio. + + Caminho de instalação com aspas, cifrão ou crase nunca chega a um parser de + shell. É a mesma postura do R2.14 para expressão regular. + +- `SessionStart` injeta as regras do harness e o bloco de retomada (R5.4). + `UserPromptSubmit` captura o prompt, que era a única peça do R5.1 que a trilha + não via sozinha. `PreToolUse` aplica a fronteira antes da escrita acontecer. + `PostToolUse` avisa quando a escrita derrubou o frescor de uma evidência. + `SessionEnd` consolida a sessão. + +- `psh adapter claude-code install|uninstall|status|contract|hook`. + +- A poda é de duas pontas e por handler (R8.6f): instalar remove o registro + antigo do psh, preserva hook de terceiro no mesmo evento, e conta quantos + apontavam para artefato ausente. + +- `psh doctor` reporta quantos pontos de extensão estão ativos (R8.6c). Quatro + de cinco é falha visível, porque significa uma responsabilidade do R8.1 que + simplesmente não acontece. + +### O contrato saiu do binário, não da documentação (R8.6b, R8.6g) + +Este é o modo de falha mais provável e mais silencioso do projeto, e a seção 3.2 +do PRD registra duas ocorrências na casa: 881 linhas de plugin morto no harness +de referência, e os três mecanismos do `prostaff-hooks` que nunca responderam +porque o campo se chamava `prompt` e o código dizia `message`. + +Então nenhum nome foi escrito de memória. Todos saíram do artefato instalado do +Claude Code 2.1.238, e ficam em `contract.json`, conferidos símbolo a símbolo +por `psh adapter claude-code contract`, que lê o binário e reprova o que não +existe. + +O que a leitura do binário mostrou e a documentação não diria: + +- O runtime aceita **31** eventos de hook, não os oito de sempre. + +- `PreToolUse` aceita `permissionDecision: "defer"` além de allow, deny e ask. + +- A entrada de hook aceita `args`, a forma exec, que é como o adapter registra. + +- `SessionEnd` existe e é onde a consolidação pertence. `Stop` dispara ao fim de + cada turno, e consolidar ali geraria uma página por resposta do modelo. + +**A validação foi verificada de ponta a ponta numa sessão real**, pelo ponto de +entrada do usuário e sem gastar turno de modelo (R8.6d). O log do próprio +runtime registra `Successfully parsed and validated hook JSON output` e +`Hook SessionStart provided additionalContext (1253 chars)`, e o efeito foi +conferido em disco (R8.6e): duas entradas na trilha e a página de sessão gerada. + +Um caminho continua sem prova em sessão real: `PreToolUse` e `PostToolUse` +exigem uma chamada de tool, que exige turno de modelo. Eles estão cobertos por +teste do handler e pelo mesmo registro que o `SessionStart` usa. + +### Não entregue, e declarado + +- **Servidor MCP com as tools do núcleo** (R5.6, R8.2). O agente hoje fala com o + harness por linha de comando, não por tool. + +- **Compressor de saída** (R8.7). + +- **Registro de toda chamada de tool na trilha** (R4.3). Hoje entram sessão, + prompt e decisão de fronteira negada. Registrar toda tool call somaria um + `fsync` por chamada, e isso precisa de medição antes de virar padrão. + +### Corrigido no motor de auditoria + +- **Uma escrita nova consertava a âncora de uma trilha adulterada.** + + A âncora existe para pegar reescrita coordenada (R4.1), mas quem a conferia era + só o `verify`. Como todo `append` regrava a âncora com o topo novo, bastava + uma escrita qualquer depois da adulteração, `psh remember` ou o hook de fim de + sessão, para a cadeia voltar a fechar e o estrago sumir do relatório. + + Agora o `append` recusa escrever numa trilha que não bate com a própria + âncora. A conferência é por contagem de linha e hash do topo, sem re-hashear a + cadeia inteira: remoção no meio muda a contagem, edição ou religamento mudam o + topo. + + Achado por um teste do adapter que esperava a consolidação falhar numa trilha + quebrada e viu ela passar. + +### Corrigido fora do escopo da memória + +- **Flag de traço simples nunca existiu no parser.** + + `psh audit log -n 5` virava dois posicionais ignorados: o comando respondia com + o limite padrão e sem erro nenhum. Pior, o `-n` chegava a comando que lê + posicional e virava nome de coisa. + + Agora argumento que parece flag e não é flag falha dizendo qual é a forma + certa, e o uso continua possível depois de `--`. O texto de ajuda, que + documentava `-n`, passou a documentar `--n`. + +- **`--n abc` chegava ao `LIMIT` do SQLite.** + + O usuário via `datatype mismatch` com pilha de exceção no lugar de "use um + número". Erro de uso é falha de uso, não erro inesperado. + +### Corrigido no motor de fronteira + +- **O `ai-jail` gravava a própria configuração dentro do projeto.** + + Por padrão ele escreve um `.ai-jail` na raiz e o lê na execução seguinte. O + arquivo mora na árvore que o agente edita, e parte da montagem passaria a vir + de algo que o próprio enjaulado escreve, que é exatamente o G4. + + Na prática ele também acumulava lixo: cada corrida somava os `deny_paths` de + novo, guardados como `~/...`, e o `ai-jail` os reabria como `/~/...`, + avisando `rule not applied` para regra que não existia. A regra que valia + continuava sendo a do argv, medido, mas o ruído escondia o aviso de verdade. + + O argv passou a levar `--clean --no-save-config`. A jaula é montada só a + partir do contrato do `psh`. + +- **O `.ai-jail` estava fora do relatório de violação.** + + A exceção existia porque o sandbox escrevia o arquivo. Com a escrita + desligada, ela só servia para deixar passar uma cópia feita pelo agente. O + conjunto de arquivos perdoados ficou vazio, e o arquivo entra no deny como + qualquer outro. + +- **A jaula zera o ambiente do processo filho.** + + `PSH_AGENT` no `env` do spawn chega vazio lá dentro. A marca de qual agente + está executando vai explícita no argv, por `--env`, para que o núcleo saiba + quem pediu a ação também dentro da jaula. + +### O primeiro teste de campo, e o que ele devolveu + +Antes de publicar, a versão foi rodada fora da suíte contra um projeto real que +chama LLM de verdade: `promptfoo-demo-evals/multilingo-language-app`, com ai-jail +1.20.1, verificador `evals` gastando API da Anthropic e da OpenAI, e uma sessão +real do Claude Code sob os cinco hooks. + +O laudo está em `DEVDOCS/CAMPO-01-multilingo.md` e o roteiro que reproduz tudo no +terminal está em `.verificacao/campo-multilingo/run.sh`. + +Tudo o que o marco promete se sustentou, e saíram dez achados. Cinco foram +corrigidos antes desta publicação, e estão abaixo. Os outros cinco seguem +abertos e declarados no laudo e no README: nenhum deles produz valor de portão +errado, que foi o critério para não segurar a versão. + +O padrão que os dois achados graves têm em comum virou regra do projeto: **a +infraestrutura usada para observar um workspace não pode modificar o conjunto de +arquivos que está sendo observado**, e `sandbox(corrida atual)` não depende de +`sandbox(corrida anterior)`. + +### Adicionado depois do campo + +- **`psh audit reanchor --reason "..."`, o caminho de volta para trilha e âncora + divergentes.** + + A recusa de escrever numa trilha que não bate com a âncora funciona e está + certa, mas ela travava o projeto para qualquer operação que escreva na trilha, + ou seja, praticamente todas. A mensagem mandava rodar `psh audit verify`, que + só confirma o diagnóstico, e nenhum comando resolvia. Sobrava mexer no + `.harness` na mão, que é exatamente como evidência de adulteração desaparece. + + Reancorar não conserta a trilha nem finge que a divergência não houve: grava na + própria trilha, como `audit.note`, o que a âncora dizia, o que o arquivo diz, + quem decidiu e por quê, e só então move a âncora para o topo real. A + divergência vira cicatriz permanente e legível. + + O `--reason` é obrigatório, porque o valor do comando está em deixar escrito + por que se decidiu seguir, não em silenciar o alarme. + + E ele recusa quando o defeito está **dentro** do arquivo (linha corrompida, + `seq` fora de ordem, elo quebrado, hash que não fecha): aí a âncora não é o + problema, e mover a âncora só trocaria um relatório vermelho por outro. + + Achado 10 do Campo 01. + +### Corrigido depois do campo + +- **A jaula de uma corrida contaminava a corrida seguinte.** + + `buildArgv` não passava `--clean` nem `--no-save-config`, então o ai-jail + gravava a configuração da corrida no `.ai-jail` do projeto e a lia na corrida + seguinte. Config de projeto do ai-jail é política monotônica: ela só aperta, e + a linha de comando não reabre o que ela fechou. + + Consequência medida: um verificador com `network: true` rodava **sem rede** + logo depois de um sem rede. O `curl` saía com 6, o promptfoo contabilizava oito + erros de conexão e ainda assim produzia relatório com `successes: 0`, o `psh` + extraía esse zero e o portão decidia em cima dele. Métrica de corrida cujo + contrato de sandbox não foi cumprido, sem nada na saída dizendo isso. + + O `psh exec` já passava as duas flags. Os dois pontos de entrada da jaula + discordavam entre si, e o errado era justamente o que produz valor de portão. + + A invariante que isso cristaliza: `sandbox(corrida atual)` não depende de + `sandbox(corrida anterior)`. Coberta por teste de integração nas duas ordens, + com rede real. + + Achado 1 do Campo 01. + +- **A jaula sujava a árvore observada durante a corrida.** + + Mesma causa, consequência diferente: como o `.ai-jail` era escrito **durante** a + execução, qualquer verificador que observe `**` falhava com + `workspace-mutated-during-run` - inclusive o `secrets`, que vem no `common.json` + do próprio pacote com `watch: ["**"]`. Um verificador que roda `/bin/true` + reprovava. + + Achado 2 do Campo 01. + +- **O artefato de runtime do próprio harness entrava no cálculo de frescor.** + + A lista de exclusão cobria quatro caminhos enquanto o `Layout` já tinha sete + diretórios de runtime, então `memory/`, `approvals/`, `reviews/` e `tmp/` + contavam como mudança do workspace. Um `psh memory consolidate` entre a medição + e o portão derrubava a evidência de quem observa `**`, citando arquivo que + nenhum verificador escreveu. + + Num projeto Git o `.harness/.gitignore` mascarava parte disso; fora do Git, ou + num app dentro de um repositório maior, aparecia inteiro. + + A classificação agora é explícita nos dois sentidos, em `HARNESS_RUNTIME_PATHS` + e `HARNESS_OBSERVABLE_PATHS`, e há teste que cobra que todo caminho do `Layout` + esteja num dos dois. O defeito não foi a lista estar errada, foi ela ter + envelhecido calada enquanto o `Layout` crescia. + + A exclusão passou a aparecer contada e nomeada no manifesto da evidência + (`harness_artifacts`): exclusão silenciosa é como um arquivo deixa de ser visto + sem ninguém perceber. + + Resíduo do achado 2 do Campo 01. + +- **O relatório de CI dizia que não havia motor de fronteira.** + + `boundary_engine` era o literal `"absent"`, declaração da v0.1 que ninguém + atualizou quando o motor entrou na v0.2. No mesmo projeto e no mesmo instante o + `psh status` dizia `fronteira mount` e o `psh doctor` dizia `fronteira aplicada + por mount do ai-jail`, enquanto o JSON de CI dizia o contrário. + + O campo virou `boundary: { mode, agents, detail }` e sai da mesma função que + alimenta o `status`. Duas declarações paralelas do mesmo fato era o defeito, não + o valor errado, e há teste exigindo que os dois digam a mesma coisa. + + Mudança no formato do relatório: quem consumia `boundary_engine` passa a ler + `boundary.mode`. + + Achado 3 do Campo 01. + +- **Projeto em subdiretório de repositório perdia o `.gitignore`.** + + `isGitRepo` era `existsSync(join(root, ".git"))`, que só acerta o projeto que é + a raiz do repositório. Todo app dentro de um repositório maior, que é o layout + de qualquer monorepo, caía para caminhada, e aí o `.gitignore` deixava de valer + para o hash da árvore. + + Efeito medido na cobaia: entraram no hash o `.env` com chave real, o diretório + de relatório do promptfoo e um binário de node de 100 MB que precisou ser + vendorizado. O `psh doctor` declarava isso como `[ok] enumeracao por + caminhada`, com o detalhe "projeto nao e repositorio Git", que é falso - e o + `checkSecrets` do mesmo doctor usava `git ls-files` no mesmo diretório sem + problema nenhum. + + Quem responde agora é o próprio Git, por `git rev-parse --is-inside-work-tree`. + O fallback pelo diretório continua existindo para um caso só, o `git` não ter + respondido, porque é ele que mantém o `walk-fallback` visível em vez de virar + caminhada silenciosa. + + Achado 4 do Campo 01. + +### Desempenho + +- **O ajv compilava todo schema no import, em toda invocação.** + + Medido no binário compilado, com um projeto real: `psh --version` levava + 185 ms contra 2 ms do `bun` cru, e 181 desses 185 iam embora antes de a + primeira linha de lógica rodar. Compilar JSON Schema é geração de código, e + isso acontecia no escopo de módulo, para os sete schemas, mesmo em comando que + não valida nada. + + Isso importa porque o hook do adapter roda **uma vez por chamada de tool**: a + mesma partida entrava no caminho crítico de cada escrita da sessão. + + Os validadores passaram a compilar na primeira vez que são usados. A interface + do ajv foi mantida, incluindo `.errors`, então nenhum ponto de uso mudou. + + Medido depois: `psh --version` caiu de 185 ms para 53 ms, e o hook de + `PreToolUse` de 200 ms para 156 ms. + + O que sobra do custo está atribuído: a decisão de fronteira em si leva 1 ms, o + `loadBoundary` leva 38 ms compilando o schema da allowlist sob demanda, e o + resto é partida do binário. Baixar mais exige validador pré-compilado em tempo + de build, que é mudança de pipeline. + +### Qualidade + +- 524 testes, acima dos 326 da v0.2.0, todos passando **também com o `ai-jail` + real ligado e com rede**, sem nenhum pulado. + +- Cobertura de linha de 98,43% no projeto, 100% em `memory/search.ts`, + `memory/consolidate.ts`, `audit/chain.ts`, `cli/args.ts` e `util/paths.ts`. + +- A validação de contrato roda também contra um artefato controlado, para o + caminho ser exercitado em máquina sem o Claude Code instalado. + +- Cinco casos novos na suíte de integração com a jaula real: nenhuma + configuração deixada no projeto, a terceira corrida enjaula igual à primeira, + configuração plantada na raiz não muda a fronteira, o id do agente atravessa a + jaula, e a memória fica fora de alcance pelo kernel. + +- Cada correção vinda do campo foi exercitada **contra o próprio defeito** antes + de valer como regressão: com a correção revertida, os testes falham; com ela, + passam. Sem isso um teste de regressão é só um teste a mais. + +- Os cinco testes de jaula que provam a invariante do sandbox mediram rede de + verdade, nas duas ordens de execução. Eles ficavam `skip` porque o AppArmor do + `bun` instalado por snap nega `exec` de binário de fora do confinamento, e o + README do `psh` agora diz como sair disso. + ## [0.2.0] - 2026-08-22 Motor de fronteira (C3). diff --git a/README.md b/README.md index 0e2309c..273848d 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ [![TypeScript](https://img.shields.io/badge/typescript-5.9-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/) [![SQLite](https://img.shields.io/badge/SQLite-WAL-003B57?logo=sqlite&logoColor=white)](https://sqlite.org/) [![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](LICENSE) -[![Version](https://img.shields.io/badge/version-0.2.0-orange.svg)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-0.3.0-orange.svg)](CHANGELOG.md) @@ -31,7 +31,7 @@ ║ Cobertura, teste, lint e segurança viram registro assinado por hash da ║ ║ árvore que foi verificada. ║ ║ ║ -║ v0.2.0 · fronteira aplicada pelo kernel · 325 testes · binário único ║ +║ v0.3.0 · memória entre sessões · adapter Claude Code · 524 testes ║ ╚══════════════════════════════════════════════════════════════════════════════╝ ``` @@ -99,6 +99,11 @@ saberia se a garantia existe. │ [■] Adapter CI - headless, JSON, código de saída estável │ │ [■] psh doctor - diagnóstico fora do runtime, colável em issue │ │ [■] Binário único - bun build --compile, sem runtime instalado │ +│ [■] Boundary Engine - allowlist por agente, aplicada pelo kernel │ +│ [■] Memory Engine - página em disco, índice FTS5, frescor por hash │ +│ [■] psh handoff - retomada montada do estado, não de resumo │ +│ [■] Consolidação - a sessão vira página, cada linha com a origem │ +│ [■] Adapter Claude Code - cinco hooks, contrato lido do binário instalado │ └─────────────────────────────────────────────────────────────────────────────┘ ``` @@ -200,7 +205,15 @@ psh verify # o NÚCLEO roda os verificadores do portão atual psh status # fase, tentativa, portão, sandbox, trilha psh advance # avalia o portão e decide a transição psh audit verify # integridade da trilha encadeada +psh audit reanchor # decisão humana registrada quando trilha e âncora divergem psh doctor # diagnóstico completo + +psh remember "" # fixa o que não pode ser perdido entre sessões +psh memory search # busca na memória do projeto +psh handoff # bloco de retomada, pronto para a próxima sessão + +psh adapter claude-code install # registra os cinco hooks em .claude/ +psh adapter claude-code contract # confere o contrato contra o binário instalado ``` `psh init` é não destrutivo. @@ -216,8 +229,8 @@ perfil lean fase phase.5.build - Build + Quality tentativa 2 (retries 1/2) status in-progress -sandbox ai-jail 0.10.0 operante -fronteira ausente (C3 entra na v0.2) +sandbox ai-jail 1.19.2 operante +fronteira mount (2 agentes) trilha íntegra (47 entradas, 0 problemas) portão all-of: REPROVADO @@ -254,6 +267,7 @@ O que isso impede, na prática: | Suíte morta por timeout ou sinal | falha, nunca zero | | Apagar uma linha da trilha | `psh audit verify` acusa, saída 4 | | Reescrever a trilha inteira relinkada | a âncora fora do arquivo acusa | +| Escrever depois de adulterar a trilha | recusado antes da escrita, para o estrago não sumir | --- @@ -346,7 +360,7 @@ Override é ato humano com confirmação, e CI não tem humano para confirmar. "_type": "psh-ci-report", "phase": "phase.5.build", "sandbox_mode": "ai-jail", - "boundary_engine": "absent", + "boundary": { "mode": "mount", "agents": 2, "detail": "escrita restrita pelo kernel via ai-jail" }, "verify": { "ran": [{ "verifier": "coverage", "status": "ok", "value": 87.4 }] }, "gate": { "passed": true }, "advance": { "decision": "advanced", "to": "phase.6.ux-gate" }, @@ -390,9 +404,49 @@ não ter harness nenhum. Casamento por texto erra nos dois sentidos, e tratar isso como controle criaria confiança que o mecanismo não sustenta. -- **Não gerencia modelo, custo ou memória entre sessões.** +- **A memória não é fonte canônica.** + + A faixa em `.harness/memory/` é transitória e fica fora do repositório. O que + precisa sobreviver com garantia sai dela por `psh memory promote` e vira + arquivo versionado, que entra em revisão como qualquer outro. + +- **A página de sessão não é narrativa escrita por modelo.** + + Ela é montada da trilha, com o número da entrada em cada linha. A reescrita + como narrativa que o R5.2 pede é chamada de modelo e depende do Maestro, que é + a v0.4. + +- **O adapter não expõe as tools do núcleo por MCP.** + + O agente fala com o harness por linha de comando. O servidor MCP é marco + posterior. + +- **A atribuição de quem fez o quê é melhor esforço dentro da jaula.** + + `psh exec` marca a sessão com o id do agente, mas o agente roda com ambiente + próprio e pode apagar a marca antes de chamar o `psh`. O que ele não apaga é a + entrada `command.exec` da mesma execução na trilha, e é por ela que a + correlação fecha. + +- **Não gerencia modelo nem custo.** + + Maestro e contabilidade de token são marcos posteriores. + +- **O verificador não declara toolchain nem credencial.** + + As flags da jaula são fixas e o `env` do verificador é mapa literal, sem + interpolação. Toolchain instalado sob `$HOME` some lá dentro, e passar uma + credencial exigiria escrever a chave em texto puro num arquivo versionado. + + No primeiro teste de campo isso custou vendorizar o binário do node dentro da + árvore, e só funcionou porque o promptfoo lê o `.env` do diretório de trabalho + por conta própria. + +- **Cinco achados do primeiro teste de campo seguem abertos.** - Marcos posteriores. + Estão em `DEVDOCS/CAMPO-01-multilingo.md`, com repro e gravidade. Nenhum deles + produz valor de portão errado, que foi o critério para publicar a v0.3.0 com + eles em aberto em vez de segurar a versão. --- @@ -401,7 +455,7 @@ não ter harness nenhum. ```sh cd psh bun run check # typecheck + verificação estática + testes com cobertura -bun test # 236 testes +bun test # 524 testes bun run build # binário único ``` @@ -411,7 +465,8 @@ bun run build # binário único │ psh/src/evidence execução de verificador, extratores, frescor │ │ psh/src/gate avaliação de portão sobre evidência │ │ psh/src/audit trilha encadeada por hash │ -│ psh/src/adapters ci (headless) │ +│ psh/src/memory página, índice FTS5, handoff, consolidação │ +│ psh/src/adapters ci (headless) e claude-code (cinco hooks) │ │ psh/schemas contratos de dados versionados │ └──────────────────────────────────────────────────────────────────────────┘ ``` @@ -433,7 +488,7 @@ Regras da suíte de testes: |--------|-------------------------------------------------------------------------------|--------------| | 0.1 | Núcleo verificável: workflow, evidência, auditoria, CLI, adapter CI | **entregue** | | 0.2 | Motor de fronteira, integração com ai-jail, modo degradado, suíte adversarial | **entregue** | -| 0.3 | Adapter Claude Code e memória entre sessões | planejado | +| 0.3 | Adapter Claude Code e memória entre sessões | **entregue** | | 0.4 | Roteamento de modelo, contabilidade de token e custo | planejado | | 0.5 | Adapter OpenCode, perfis por stack | planejado | | 1.0 | Endurecimento, binários assinados, matriz de CI completa | planejado | diff --git a/psh/README.md b/psh/README.md index 94d65fe..e200d76 100644 --- a/psh/README.md +++ b/psh/README.md @@ -1,7 +1,8 @@ # psh - núcleo verificável do ProStaff Harness -Marco **v0.1**: os três motores que fazem um portão valer alguma coisa, mais a -CLI mínima e o adapter `ci`. +Marco **v0.3**: os quatro motores que fazem um portão valer alguma coisa, +memória que sobrevive à sessão, fronteira aplicada pelo kernel, e os adapters +`ci` e `claude-code`. Os identificadores no formato `R1.1`, `R2.4` e afins apontam para a especificação interna do projeto. @@ -22,9 +23,12 @@ editando. |--------------------|----------------------------------------------------------|----------| | C1 Workflow engine | R1.1 a R1.6 | completo | | C2 Evidence engine | R2.1 a R2.7, mais R2.10 a R2.14 | completo | +| C3 Boundary engine | R3.1 a R3.6 | completo | | C4 Audit engine | R4.1 a R4.3 | completo | +| C5 Memory engine | R5.1, R5.3 a R5.5, R5.7; R5.2 sem a narrativa por LLM | parcial | +| C8 Adapters | R8.1, R8.2, R8.2b, R8.6b a R8.6g; sem MCP e sem R8.7 | parcial | | CLI | `init`, `status`, `verify`, `advance`, `audit`, `doctor` | completo | -| Adapter | `ci` (headless) | completo | +| Adapter | `ci` (headless) e `claude-code` (cinco hooks) | completo | Dois comandos além do mínimo declarado no roadmap, porque sem eles o marco não fecha sozinho: @@ -35,32 +39,42 @@ fecha sozinho: calculado pelo núcleo por parse dos artefatos, então o núcleo precisa de um ponto de entrada para se reinvocar. -## Fora do escopo da v0.1, e declarado como tal - -- **C3, motor de fronteira.** - - Entra na v0.2. - - Até lá, `psh doctor` e `psh status` dizem `fronteira ausente`, e o adapter - `ci` devolve `boundary_engine: absent` no relatório. - - O R2.6b, que torna evidência e review inalcançáveis por agente, hoje é - convenção e não mecanismo. - - O código diz isso em vez de sugerir garantia. - -- **Sandbox real.** - - O contrato do R2.7 está implementado: rede desligada por padrão, opt-in - declarado por verificador e credencial de agente não montada. - - Mas sem `ai-jail` instalado o modo é `degraded`, declarado no `psh status`, no - `psh doctor` e dentro de **cada registro de evidência**. - -- **C5 a C9, e o C11 além de cobertura.** - +## Fora do escopo da v0.3, e declarado como tal + +- **Página de sessão reescrita como narrativa** (R5.2). + + O `psh memory consolidate` monta a página a partir da trilha, linha por linha, + cada uma carregando o número da entrada que a originou. + + A reescrita em prosa é chamada de modelo e depende do Maestro, que é marco + seguinte. Até lá a página é fiel e seca, que é a ordem certa das duas. + +- **Servidor MCP com as tools do núcleo** (R5.6, R8.2). + + O agente fala com o harness por linha de comando, não por tool. + +- **Sandbox sem `ai-jail` instalado.** + + O contrato do R2.7 está implementado, e com `ai-jail` presente a fronteira é + aplicada pelo kernel. + + Sem ele o modo é `degraded`, que reverte em vez de impedir, e isso é declarado + no `psh status`, no `psh doctor` e dentro de **cada registro de evidência**. + +- **C6, C7, C9, e o C11 além de cobertura.** + Marcos seguintes. +- **Cinco achados do primeiro teste de campo.** + + Verificador não tem como declarar toolchain nem credencial; `advance --force` + passa sem `--reason`; a recusa de métrica forjada usa lista fixa de nomes em vez + dos ids do contrato carregado; depois da fase terminal o estado fica sem nome; + e `psh exec` captura o stdout do comando em vez de repassá-lo. + + Estão descritos em `DEVDOCS/CAMPO-01-multilingo.md`, com repro. Nenhum deles + produz valor de portão errado, que foi o critério para não segurar a versão. + ## Instalação e uso ```sh @@ -75,6 +89,7 @@ psh verify # o NÚCLEO roda os verificadores do portão atua psh status # fase, tentativa, portão, sandbox, trilha psh advance # avalia o portão e decide a transição psh audit verify # integridade da trilha encadeada +psh audit reanchor --reason # caminho de volta quando trilha e âncora divergem psh doctor # diagnóstico colável em issue psh adapter ci --json # verify + gate + advance, headless ``` @@ -211,6 +226,47 @@ comando caía no default sem avisar. Também achado na validação, também com teste de regressão. +**Observar um workspace não pode modificá-lo.** + +A infraestrutura que mede a árvore não entra na medição, senão ela mede a si +mesma e a evidência cai sozinha. + +Duas coisas saem daí. O sandbox roda com `--clean --no-save-config`, então o +`psh` não lê nem grava o `.ai-jail` do projeto e uma corrida não tem como +envenenar a seguinte. E o que o núcleo escreve dentro de `.harness/` enquanto +opera fica fora do cálculo de frescor, listado em `HARNESS_RUNTIME_PATHS`. + +A lista é nomeada, não é "tudo dentro de `.harness/`": contrato e documento de +fase moram no mesmo diretório e continuam observáveis, porque escondê-los abriria +a classe dos arquivos invisíveis ao observador. + +E a exclusão aparece contada e nomeada no manifesto da evidência, porque decisão +de medição que não fica no registro não pode ser auditada depois. + +Os dois casos foram achados no Campo 01, e a invariante é +`sandbox(corrida atual)` não depender de `sandbox(corrida anterior)`. + +**Trilha divergente tem caminho de volta declarado.** + +A recusa de escrever em trilha que não bate com a âncora está certa, mas travar +sem saída deixava uma alternativa real só, que era apagar o `.harness` na mão - +exatamente como evidência de adulteração desaparece. + +`psh audit reanchor --reason "..."` grava na própria trilha o que a âncora dizia, +o que o arquivo diz, quem decidiu e por quê, e só então move a âncora. + +Ele recusa quando o defeito está dentro do arquivo, porque aí a âncora não é o +problema e mover a âncora só trocaria um relatório vermelho por outro. + +**Um fato, uma fonte.** + +O relatório de CI dizia `boundary_engine: "absent"` enquanto o `psh status` dizia +`fronteira mount`, no mesmo projeto e no mesmo instante. + +O defeito não foi o valor errado, foi haver duas declarações paralelas do mesmo +fato. O adapter passou a chamar a mesma função que alimenta o `status`, e há +teste que exige que os dois digam a mesma coisa palavra por palavra. + **Caminho nunca vira expressão regular (R2.14).** Casamento por `Bun.Glob` com o caminho sempre do lado da entrada, comparação de @@ -225,7 +281,7 @@ coisa que não seja literal constante. /.harness/ workflow.json contrato de fases, portões e verificadores (humano) state.json snapshot da fase (NÚCLEO, apenas) - boundary.json allowlist (humano; motor entra na v0.2) + boundary.json allowlist por agente (humano; aplicada pelo motor do C3) harness.db SQLite: eventos, índice de evidência, contadores, âncora evidence///.json (NÚCLEO, apenas) .manifest.json arquivo -> hash @@ -233,10 +289,20 @@ coisa que não seja literal constante. reviews/*.review.json score de LLM amarrado ao hash do artefato approvals/*.json aprovação humana amarrada ao conteúdo audit/chain.jsonl trilha encadeada por hash + memory/pages/*.md página de memória (canônica; o SQLite é só índice) + memory/consolidation.json marca d'água da consolidação de sessão + tmp/ rascunho de execução, por exemplo o snapshot da fronteira ``` -`harness.db`, `evidence/`, `audit/` e `approvals/` entram no `.gitignore` gerado -pelo `psh init`. +`harness.db`, `evidence/`, `audit/`, `approvals/` e `memory/` entram no +`.gitignore` gerado pelo `psh init`. + +O que dentro de `.harness/` é escrito pelo núcleo enquanto ele opera está +declarado em `HARNESS_RUNTIME_PATHS`, e fica fora do cálculo de frescor. + +O contrato e os documentos de fase moram no mesmo diretório e continuam +observáveis, listados em `HARNESS_OBSERVABLE_PATHS`. Há teste cobrando que todo +caminho do `Layout` esteja numa das duas listas. ## Qualidade @@ -250,12 +316,15 @@ Cobertura de linha nos módulos que o R11.1 exige em 85%: | Módulo | Linhas | |-------------------------|--------| | `audit/chain.ts` | 100% | -| `evidence/sandbox.ts` | 100% | +| `evidence/workspace.ts` | 95% | +| `gate/evaluate.ts` | 93% | +| `evidence/sandbox.ts` | 93% | | `evidence/runner.ts` | 92% | -| `evidence/workspace.ts` | 89% | | `evidence/store.ts` | 88% | | `evidence/extract/` | 85% | -| `gate/evaluate.ts` | 93% | + +O projeto inteiro está em 98% de linha, com a suíte rodando com `ai-jail` real e +rede. Nos demais módulos o piso é 70%, e o menor é `util/self.ts` com 71%. @@ -287,12 +356,12 @@ Casos da suíte adversarial do R11.2 já cobertos: - **15** - instalação em diretório com `+`, `[` e espaço no nome - **16** - varredura sem nenhum candidato -Os casos 1, 2, 5, 6 e 9 dependem do motor de fronteira e entram na v0.2. +Os casos 1, 2, 5, 6 e 9 dependem do motor de fronteira, entregue na v0.2. ## Rodando a suíte completa -Sete testes de integração exercitam a fronteira contra o `ai-jail` de verdade, e -três dependem de Git. +Dezessete testes de integração exercitam a fronteira e a jaula contra o `ai-jail` +de verdade, três deles medindo rede real, e alguns dependem de Git. Quando as ferramentas não estão presentes eles são declarados `skip`, porque passar sem exercitar seria pior do que não existir. @@ -331,7 +400,7 @@ isso no `psh status` e no `psh doctor`. ## Nota de ambiente -Um `bun` instalado por snap roda confinado, e o confinamento aparece de três +Um `bun` instalado por snap roda confinado, e o confinamento aparece de quatro formas. Não enxerga o `git` do sistema: o frescor cai para caminhada e o `.gitignore` @@ -339,10 +408,26 @@ deixa de ser respeitado. Tem `/tmp` privado: um projeto ali fica invisível para processos fora do snap. -E o `ai-jail` lançado por ele não alcança o `bwrap`, então o isolamento não -sobe. +Tem `HOME` redirecionado para `~/snap/bun-js/`, então `homedir()` não é o +home real. + +E o AppArmor do snap nega `exec` de binário de fora do confinamento: um +`spawn` do `ai-jail` volta `EACCES` mesmo com o caminho absoluto certo e o bit +de execução no lugar. + +O `psh doctor` reprova com `workspace-enum` no primeiro caso, e é o quarto que +deixa os testes de `boundary-aijail.test.ts` em `skip` permanente, incluindo os +que provam que a jaula de uma corrida não contamina a seguinte. + +Para exercitá-los é preciso um `bun` fora do snap: + +```sh +curl -fsSL -o bun.zip https://github.com/oven-sh/bun/releases/latest/download/bun-linux-x64.zip +unzip -q bun.zip && mv bun-linux-x64/bun ~/.cache/psh-bin/bun + +PSH_AI_JAIL_BIN=$PWD/ai-jail ~/.cache/psh-bin/bun test +``` -O `psh doctor` reprova com `workspace-enum` no primeiro caso, e os testes de -integração se declaram `skip` no terceiro. +Com ele a suíte roda inteira, sem nenhum `skip`. O binário compilado por `bun run build` não tem nenhuma dessas limitações. diff --git a/psh/package.json b/psh/package.json index 1c0640d..65cbc02 100644 --- a/psh/package.json +++ b/psh/package.json @@ -1,6 +1,6 @@ { "name": "psh", - "version": "0.2.0", + "version": "0.3.0", "private": true, "description": "ProStaff Harness - nucleo verificavel (workflow, evidence, audit)", "type": "module", diff --git a/psh/schemas/memory-consolidation.schema.json b/psh/schemas/memory-consolidation.schema.json new file mode 100644 index 0000000..1c9ae57 --- /dev/null +++ b/psh/schemas/memory-consolidation.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://prostaff.dev/schemas/psh-memory-consolidation-v1.json", + "title": "marca d'agua da consolidacao de memoria (R5.2)", + "type": "object", + "required": ["_type", "version", "last_seq", "last_slug", "updated_at"], + "additionalProperties": false, + "properties": { + "_type": { "const": "psh-memory-consolidation" }, + "version": { "const": 1 }, + "last_seq": { + "type": "integer", + "minimum": 0, + "description": "Ultima entrada da trilha ja consolidada. A proxima consolidacao comeca em last_seq + 1." + }, + "last_slug": { + "type": ["string", "null"], + "description": "Pagina gerada pela ultima consolidacao, ou null quando nunca houve uma." + }, + "updated_at": { "type": "string", "minLength": 1 } + } +} diff --git a/psh/schemas/memory-page.schema.json b/psh/schemas/memory-page.schema.json new file mode 100644 index 0000000..0c537be --- /dev/null +++ b/psh/schemas/memory-page.schema.json @@ -0,0 +1,40 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://prostaff.dev/schemas/psh-memory-page-v1.json", + "title": "psh memory page (faixa transitoria, nao canonica - R5.7)", + "type": "object", + "required": ["_type", "version", "slug", "kind", "title", "pinned", "source", "created_at", "updated_at"], + "additionalProperties": false, + "properties": { + "_type": { "const": "psh-memory-page" }, + "version": { "const": 1 }, + "slug": { + "type": "string", + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", + "maxLength": 96, + "description": "Nome do arquivo em .harness/memory/pages/. Restrito a minuscula, digito e hifen: o slug vira caminho e nao pode carregar separador, '..' nem espaco." + }, + "kind": { + "enum": ["fact", "decision", "verifier", "session", "prompt", "note"], + "description": "R5.1: as origens de captura. 'fact' e o que entra por 'psh remember'." + }, + "title": { "type": "string", "minLength": 1, "maxLength": 200 }, + "pinned": { + "type": "boolean", + "description": "R5.5: pagina fixada nao pode ser perdida na consolidacao." + }, + "phase": { "type": ["string", "null"] }, + "tags": { "type": "array", "items": { "type": "string", "minLength": 1 } }, + "source": { + "type": "string", + "minLength": 1, + "description": "Quem originou: 'human:', 'core' ou o id do agente." + }, + "created_at": { "type": "string", "minLength": 1 }, + "updated_at": { "type": "string", "minLength": 1 }, + "promoted_to": { + "type": ["string", "null"], + "description": "R5.7: caminho no repositorio para onde a pagina foi promovida. Preenchido, a pagina deixou de ser a versao que vale." + } + } +} diff --git a/psh/src/adapters/ci.ts b/psh/src/adapters/ci.ts index 236cd68..b466f52 100644 --- a/psh/src/adapters/ci.ts +++ b/psh/src/adapters/ci.ts @@ -4,6 +4,8 @@ import { runVerify, type VerifyOutcome } from "../cli/verify.ts"; import { advance, type AdvanceOutcome } from "../workflow/advance.ts"; import { evaluateGate, type GateResult } from "../gate/evaluate.ts"; import { currentAttempt } from "../cli/context.ts"; +import { descreverFronteira, type BoundaryReport } from "../cli/status.ts"; +import { detectSandbox } from "../evidence/sandbox.ts"; import { selfArgv } from "../util/self.ts"; import { PSH_VERSION } from "../version.ts"; @@ -29,7 +31,18 @@ export interface CiReport { phase: string | null; attempt: number; sandbox_mode: string; - boundary_engine: "absent"; + /** + * Campo 01, achado 3: isto era o literal `"absent"`, declaracao da v0.1 que + * ninguem atualizou quando o motor de fronteira entrou na v0.2. No mesmo + * projeto e no mesmo instante o `status` dizia `fronteira mount` e este + * relatorio dizia que fronteira nao havia, entao quem consumia o JSON no CI + * concluia o contrario do que estava acontecendo. + * + * Agora sai da mesma funcao que alimenta o `psh status`, e nao de uma segunda + * declaracao paralela: duas fontes para o mesmo fato foi o defeito, nao o + * valor errado. + */ + boundary: BoundaryReport; verify: { ran: { verifier: string; status: string; value: number | null; exit_code: number | null; error: string | null }[]; considered: number; @@ -97,7 +110,7 @@ export function runCi(opts: CiOptions): CiReport { phase: phaseId, attempt: verify?.attempt ?? currentAttempt(ctx, phaseId), sandbox_mode: verify?.sandbox_mode ?? "degraded", - boundary_engine: "absent", + boundary: descreverFronteira(ctx, verify?.sandbox_mode ?? detectSandbox().mode), verify: verify === null ? null @@ -130,7 +143,10 @@ export function runCi(opts: CiOptions): CiReport { export function renderCi(report: CiReport): string { const lines: string[] = []; lines.push(`psh ${report.psh} | adapter ci | fase ${report.phase ?? "(sem fase)"} tentativa ${report.attempt}`); - lines.push(`sandbox ${report.sandbox_mode} | fronteira ${report.boundary_engine} | trilha ${report.audit_ok ? "integra" : "COMPROMETIDA"}`); + lines.push( + `sandbox ${report.sandbox_mode} | fronteira ${report.boundary.mode} (${report.boundary.agents} agente(s)) | ` + + `trilha ${report.audit_ok ? "integra" : "COMPROMETIDA"}`, + ); if (report.verify !== null) { for (const r of report.verify.ran) { lines.push(` ${r.status === "ok" ? "ok " : "NAO"} ${r.verifier}: ${r.error ?? (r.value ?? `exit ${r.exit_code}`)}`); diff --git a/psh/src/adapters/claude-code/contract.json b/psh/src/adapters/claude-code/contract.json new file mode 100644 index 0000000..e1c4317 --- /dev/null +++ b/psh/src/adapters/claude-code/contract.json @@ -0,0 +1,71 @@ +{ + "_type": "psh-adapter-contract", + "version": 1, + "runtime": "claude-code", + "description": "R8.6b / R8.6g: nome de evento, campo de payload e chave de configuracao nunca sao escritos de memoria. Cada simbolo desta lista e conferido contra o artefato do runtime instalado por 'psh adapter claude-code contract'. O que o adapter usa e o que esta aqui, e nada alem.", + "verified_against": { + "version": "2.1.238", + "artifact": "binario unico ELF com o bundle JS embutido", + "method": "varredura de simbolo no artefato instalado, nunca na documentacao", + "verified_at": "2026-08-23" + }, + "events": { + "SessionStart": { + "usa": "injeta o bloco de retomada e as regras do harness (R5.4, R8.1)", + "payload": ["hook_event_name", "session_id", "transcript_path", "cwd", "source"], + "output": ["hookEventName", "additionalContext"] + }, + "UserPromptSubmit": { + "usa": "captura o prompt do usuario, a peca do R5.1 que a trilha nao ve sozinha", + "payload": ["hook_event_name", "session_id", "cwd", "prompt"], + "output": ["hookEventName", "additionalContext"] + }, + "PreToolUse": { + "usa": "fronteira de escrita antes da tool rodar (R3.1, R8.1)", + "payload": ["hook_event_name", "session_id", "cwd", "tool_name", "tool_input", "tool_use_id", "file_path", "notebook_path"], + "output": ["hookEventName", "permissionDecision", "permissionDecisionReason"] + }, + "PostToolUse": { + "usa": "avisa quando a escrita derrubou o frescor de uma evidencia (R2.4)", + "payload": ["hook_event_name", "session_id", "cwd", "tool_name", "tool_input", "tool_response", "tool_use_id"], + "output": ["hookEventName", "additionalContext"] + }, + "SessionEnd": { + "usa": "consolida a sessao em pagina de memoria (R5.2)", + "payload": ["hook_event_name", "session_id", "cwd", "reason"], + "output": [] + } + }, + "permission_decisions": ["allow", "deny", "ask"], + "settings": { + "arquivo": ".claude/settings.json", + "chaves": ["hooks"], + "forma": "hooks.[].matcher e hooks.[].hooks[]", + "entrada_de_hook": ["type", "command", "args", "timeout"], + "tipo_de_hook": "command", + "forma_exec": "com 'args' presente o 'command' e resolvido como executavel e recebe os argumentos direto, sem shell. Caminho com aspas, cifrao ou crase nunca chega a um parser de shell, que e a mesma postura do R2.14 para regex." + }, + "simbolos_conferidos": [ + "PreToolUse", + "PostToolUse", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "hook_event_name", + "session_id", + "transcript_path", + "tool_name", + "tool_input", + "tool_response", + "tool_use_id", + "hookSpecificOutput", + "hookEventName", + "additionalContext", + "permissionDecision", + "permissionDecisionReason", + "prompt", + "file_path", + "notebook_path", + "Argument list for exec form" + ] +} diff --git a/psh/src/adapters/claude-code/contract.ts b/psh/src/adapters/claude-code/contract.ts new file mode 100644 index 0000000..d5f5873 --- /dev/null +++ b/psh/src/adapters/claude-code/contract.ts @@ -0,0 +1,243 @@ +import { existsSync, openSync, readSync, closeSync, realpathSync, statSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { delimiter, isAbsolute, join } from "node:path"; +import contratoJson from "./contract.json" with { type: "json" }; + +export interface EventoDoContrato { + usa: string; + payload: string[]; + output: string[]; +} + +export interface ContratoDoAdapter { + _type: "psh-adapter-contract"; + version: 1; + runtime: string; + description: string; + verified_against: { version: string; artifact: string; method: string; verified_at: string }; + events: Record; + permission_decisions: string[]; + settings: { + arquivo: string; + chaves: string[]; + forma: string; + entrada_de_hook: string[]; + tipo_de_hook: string; + forma_exec: string; + }; + simbolos_conferidos: string[]; +} + +export const CONTRATO = contratoJson as unknown as ContratoDoAdapter; + +export interface RuntimeInstalado { + encontrado: boolean; + bin: string | null; + /** Artefato real, com symlink resolvido: e nele que a varredura roda. */ + artefato: string | null; + versao: string | null; + detalhe: string; +} + +/** + * Onde o runtime instalado esta. + * + * `PSH_CLAUDE_BIN` existe para o teste e para instalacao fora do PATH. Sem ela, + * a busca e por PATH, e o symlink e resolvido: o artefato que interessa e o + * arquivo de verdade, nao o atalho. + */ +export function acharRuntime(): RuntimeInstalado { + const declarado = process.env.PSH_CLAUDE_BIN; + const candidato = + declarado !== undefined && declarado !== "" ? declarado : procurarNoPath("claude") ?? localDeInstalacao(); + + if (candidato === null || !existsSync(candidato)) { + return { + encontrado: false, + bin: candidato, + artefato: null, + versao: null, + detalhe: + candidato === null + ? "nenhum 'claude' no PATH; declare PSH_CLAUDE_BIN se ele estiver instalado fora dele" + : `${candidato} nao existe`, + }; + } + + const artefato = realpathSync(candidato); + const versao = spawnSync(candidato, ["--version"], { encoding: "utf8" }); + const linha = (versao.stdout ?? "").trim().split("\n")[0] ?? ""; + return { + encontrado: true, + bin: candidato, + artefato, + versao: linha === "" ? null : linha, + detalhe: `${candidato} -> ${artefato}`, + }; +} + +/** + * Local de instalacao conhecido, quando o PATH nao ajuda. + * + * Um `bun` confinado por snap recebe PATH higienizado e nao enxerga + * `~/.local/bin`, onde o instalador nativo do Claude Code poe o atalho. Sem + * este segundo olhar, o diagnostico diria "runtime ausente" numa maquina que + * tem o runtime instalado, e diagnostico que mente e pior do que diagnostico + * que falta. + */ +function localDeInstalacao(): string | null { + const home = process.env.HOME; + if (home === undefined || home === "") return null; + const alvo = join(home, ".local", "bin", "claude"); + return existsSync(alvo) ? alvo : null; +} + +function procurarNoPath(nome: string): string | null { + for (const dir of (process.env.PATH ?? "").split(delimiter)) { + if (dir === "") continue; + const alvo = isAbsolute(dir) ? join(dir, nome) : null; + if (alvo !== null && existsSync(alvo)) return alvo; + } + return null; +} + +export interface ResultadoDaVarredura { + encontrados: string[]; + ausentes: string[]; + /** R2.13: bytes lidos do artefato, para varredura vazia nao virar sucesso. */ + bytes_examinados: number; +} + +/** + * Procura cada simbolo dentro do artefato instalado. + * + * A leitura e por bloco com sobreposicao: um simbolo pode cair exatamente na + * emenda de dois blocos, e sem a sobreposicao ele sumiria - varredura que perde + * o que existe reprova adapter que esta certo, e ensina a ignorar o resultado. + * + * A comparacao e por `Buffer.indexOf`, nunca por expressao regular montada com + * o simbolo: e a mesma regra do R2.14, um simbolo com `+` ou `[` viraria + * quantificador. + * + * Limite declarado: a busca e por bytes, entao um simbolo que e prefixo de + * outro (`permissionDecision` dentro de `permissionDecisionReason`) e dado como + * presente quando so o mais longo existe. O que esta validacao pega e ponto de + * extensao que sumiu ou campo que mudou de nome, que sao os dois modos de falha + * que a secao 3.2 do PRD descreve; renomeacao parcial de campo irmao passa. + */ +export function varrerSimbolos( + artefato: string, + simbolos: readonly string[], + opts: { blocoBytes?: number } = {}, +): ResultadoDaVarredura { + const bloco = opts.blocoBytes ?? 8 * 1024 * 1024; + const faltando = new Set(simbolos); + const encontrados: string[] = []; + const maiorSimbolo = simbolos.reduce((max, s) => Math.max(max, Buffer.byteLength(s)), 0); + const sobreposicao = Math.max(0, maiorSimbolo - 1); + + const tamanho = statSync(artefato).size; + const fd = openSync(artefato, "r"); + let lidos = 0; + try { + const buffer = Buffer.allocUnsafe(bloco + sobreposicao); + let posicao = 0; + while (posicao < tamanho && faltando.size > 0) { + const cauda = posicao === 0 ? 0 : Math.min(sobreposicao, posicao); + const inicio = posicao - cauda; + const n = readSync(fd, buffer, 0, bloco + cauda, inicio); + if (n <= 0) break; + lidos += n - cauda; + const janela = buffer.subarray(0, n); + for (const simbolo of [...faltando]) { + if (janela.indexOf(simbolo, 0, "utf8") >= 0) { + faltando.delete(simbolo); + encontrados.push(simbolo); + } + } + posicao = inicio + n; + } + } finally { + closeSync(fd); + } + + return { encontrados: encontrados.sort(), ausentes: [...faltando].sort(), bytes_examinados: lidos }; +} + +export interface RelatorioDoContrato { + runtime: RuntimeInstalado; + contrato_versao_conferida: string; + simbolos_declarados: number; + encontrados: string[]; + ausentes: string[]; + bytes_examinados: number; + ok: boolean; + motivo: string; +} + +/** + * R8.6b: valida o contrato contra o runtime **instalado**, nao contra a + * documentacao. + * + * Este e o modo de falha mais provavel e o mais silencioso do projeto, e ja + * aconteceu duas vezes na casa: 881 linhas de plugin morto no harness de + * referencia, e os tres mecanismos do `prostaff-hooks` que nunca responderam + * porque o campo se chamava `prompt` e o codigo dizia `message`. + */ +export function conferirContrato(): RelatorioDoContrato { + const runtime = acharRuntime(); + const base = { + runtime, + contrato_versao_conferida: CONTRATO.verified_against.version, + simbolos_declarados: CONTRATO.simbolos_conferidos.length, + }; + + if (!runtime.encontrado || runtime.artefato === null) { + return { + ...base, + encontrados: [], + ausentes: [...CONTRATO.simbolos_conferidos], + bytes_examinados: 0, + ok: false, + motivo: `runtime nao encontrado: ${runtime.detalhe}`, + }; + } + + const varredura = varrerSimbolos(runtime.artefato, CONTRATO.simbolos_conferidos); + + // Varredura que nao leu nada nao e varredura limpa (R2.13). + if (varredura.bytes_examinados === 0) { + return { + ...base, + ...varredura, + ok: false, + motivo: `nenhum byte lido de ${runtime.artefato}: varredura sem candidato e falha de configuracao, nao resultado limpo`, + }; + } + + const ok = varredura.ausentes.length === 0; + return { + ...base, + ...varredura, + ok, + motivo: ok + ? `${varredura.encontrados.length} simbolo(s) conferido(s) em ${runtime.versao ?? "versao desconhecida"}` + : `${varredura.ausentes.length} simbolo(s) que o adapter usa nao existem em ${runtime.versao ?? "?"}: ${varredura.ausentes.join(", ")}`, + }; +} + +export function renderContrato(r: RelatorioDoContrato): string { + const linhas = [ + `runtime ${r.runtime.versao ?? "(ausente)"}`, + `artefato ${r.runtime.artefato ?? "-"}`, + `contrato conferido contra ${r.contrato_versao_conferida}`, + `simbolos ${r.encontrados.length}/${r.simbolos_declarados} encontrados, ${Math.round(r.bytes_examinados / 1024 / 1024)} MiB examinados`, + `resultado ${r.ok ? "OK" : "REPROVADO"}`, + ` ${r.motivo}`, + ]; + if (r.ausentes.length > 0 && r.runtime.encontrado) { + linhas.push("", "ausentes:"); + for (const s of r.ausentes) linhas.push(` - ${s}`); + } + return linhas.join("\n"); +} diff --git a/psh/src/adapters/claude-code/hook.ts b/psh/src/adapters/claude-code/hook.ts new file mode 100644 index 0000000..438e51d --- /dev/null +++ b/psh/src/adapters/claude-code/hook.ts @@ -0,0 +1,381 @@ +import { isAbsolute, resolve } from "node:path"; +import { openProject, type ProjectContext } from "../../cli/context.ts"; +import { boundaryOf } from "../../cli/boundary.ts"; +import { consolidate } from "../../cli/memory.ts"; +import { buildHandoff, renderHandoff } from "../../memory/handoff.ts"; +import { detectDestructive } from "../../boundary/detect.ts"; +import { compileGlobs } from "../../util/globs.ts"; +import { toRel } from "../../util/paths.ts"; +import { EXIT, PshError } from "../../util/errors.ts"; +import { PSH_VERSION } from "../../version.ts"; + +/** + * Entrada de hook, com os nomes que o runtime instalado usa de verdade. + * + * Nenhum destes nomes foi escrito de memoria: todos saem do `contract.json`, + * que e conferido contra o artefato instalado (R8.2b, R8.6b, R8.6g). A + * diferenca entre `prompt` e `message` foi o que desligou o `prostaff-hooks` + * inteiro sem emitir um erro sequer. + */ +export interface HookInput { + hook_event_name?: unknown; + session_id?: unknown; + transcript_path?: unknown; + cwd?: unknown; + source?: unknown; + reason?: unknown; + prompt?: unknown; + tool_name?: unknown; + tool_input?: unknown; + tool_response?: unknown; + tool_use_id?: unknown; +} + +export type PermissionDecision = "allow" | "deny" | "ask"; + +export interface HookOutput { + hookSpecificOutput?: { + hookEventName: string; + additionalContext?: string; + permissionDecision?: PermissionDecision; + permissionDecisionReason?: string; + }; + systemMessage?: string; +} + +export interface HookResult { + output: HookOutput | null; + exitCode: number; + /** Por que o hook fez o que fez. Vai para stderr, para diagnostico. */ + nota: string; +} + +const SILENCIO: HookResult = { output: null, exitCode: EXIT.OK, nota: "" }; + +/** Tools que escrevem arquivo, com o campo onde o caminho vem em cada uma. */ +const CAMPO_DE_CAMINHO: Record = { + Write: "file_path", + Edit: "file_path", + MultiEdit: "file_path", + NotebookEdit: "notebook_path", +}; + +const texto = (v: unknown): string | null => (typeof v === "string" && v !== "" ? v : null); + +export interface HookDeps { + /** Injetavel para o teste nao depender do diretorio de trabalho do processo. */ + abrirProjeto?: (cwd: string) => ProjectContext; + now?: Date; +} + +/** + * R8.1: o adapter so traduz evento para chamada do nucleo. + * + * Nenhuma regra de negocio mora aqui. Fronteira e do C3, memoria e do C5, + * frescor e do C2: o hook decide de quem e o assunto e devolve o formato que o + * runtime entende. + */ +export function handleHook(bruto: unknown, deps: HookDeps = {}): HookResult { + if (bruto === null || typeof bruto !== "object") { + return { output: null, exitCode: EXIT.OK, nota: "payload de hook nao e objeto; nada a fazer" }; + } + const input = bruto as HookInput; + const evento = texto(input.hook_event_name); + if (evento === null) { + return { output: null, exitCode: EXIT.OK, nota: "payload sem hook_event_name; nada a fazer" }; + } + + const cwd = texto(input.cwd) ?? process.cwd(); + const abrir = deps.abrirProjeto ?? ((dir: string) => openProject(dir)); + + let ctx: ProjectContext; + try { + ctx = abrir(cwd); + } catch (cause) { + // Sessao fora de projeto com harness e o caso comum, nao erro: o hook fica + // calado. Travar toda sessao do usuario por causa disso seria pior do que + // nao existir. + const erro = cause as PshError; + if (erro.exitCode === EXIT.NOT_INITIALIZED) return SILENCIO; + return degradar(evento, `nao foi possivel abrir o projeto em ${cwd}: ${erro.message}`); + } + + try { + switch (evento) { + case "SessionStart": + return aoAbrirSessao(ctx, input, deps); + case "UserPromptSubmit": + return aoReceberPrompt(ctx, input); + case "PreToolUse": + return antesDaTool(ctx, input); + case "PostToolUse": + return depoisDaTool(ctx, input); + case "SessionEnd": + return aoFecharSessao(ctx, input, deps); + default: + return { output: null, exitCode: EXIT.OK, nota: `evento ${evento} nao e tratado por este adapter` }; + } + } catch (cause) { + return degradar(evento, (cause as Error).message); + } finally { + ctx.close(); + } +} + +/** + * Falha do adapter nunca vira sessao travada nem escrita liberada em silencio. + * + * Em `PreToolUse` a decisao degradada e `ask`: quem decide passa a ser o + * humano, com o motivo na tela. Liberar caladamente esconderia o furo, e negar + * tudo transformaria qualquer erro do harness em sessao inutilizavel. + */ +function degradar(evento: string, motivo: string): HookResult { + if (evento === "PreToolUse") { + return { + output: { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "ask", + permissionDecisionReason: `psh ${PSH_VERSION} nao conseguiu decidir: ${motivo}`, + }, + }, + exitCode: EXIT.OK, + nota: motivo, + }; + } + return { output: null, exitCode: EXIT.OK, nota: motivo }; +} + +// --- SessionStart --------------------------------------------------------- + +function aoAbrirSessao(ctx: ProjectContext, input: HookInput, deps: HookDeps): HookResult { + const handoff = buildHandoff(ctx, { now: deps.now }); + const bloco = [regrasDoHarness(ctx), "", renderHandoff(handoff)].join("\n"); + + ctx.chain.append("adapter.event", "adapter:claude-code", { + event: "SessionStart", + session_id: texto(input.session_id), + source: texto(input.source), + phase: handoff.phase, + pending: handoff.pending.length, + injected_chars: bloco.length, + }); + + return { + output: { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: bloco } }, + exitCode: EXIT.OK, + nota: `bloco de retomada injetado (${bloco.length} caracteres)`, + }; +} + +/** + * R8.1, primeira responsabilidade: o prompt do harness. + * + * Curto de proposito. Ele ocupa janela em toda sessao (R7.2), e regra que o + * nucleo aplica sozinho nao precisa ser pedida ao modelo: a fronteira barra a + * escrita, o portao recusa metrica por argumento. O que esta aqui e o que o + * modelo precisa saber para nao perder tempo tentando. + */ +function regrasDoHarness(ctx: ProjectContext): string { + return [ + `# Harness psh ${PSH_VERSION}`, + "", + `Este projeto roda sob o ProStaff Harness, perfil ${ctx.state.profile}.`, + "", + "- Portao consome evidencia produzida por `psh verify`. Numero dito por voce", + " nao entra: nenhum comando aceita metrica por argumento.", + "- Escrita fora da allowlist do agente e barrada pela fronteira, nao por", + " convencao. `psh boundary check ` diz o veredito antes da tentativa.", + "- Fato que precisa sobreviver a esta sessao vira `psh remember \"\"`.", + " O resto e resumido sozinho no fim da sessao.", + ].join("\n"); +} + +// --- UserPromptSubmit ----------------------------------------------------- + +const MARCADORES_DE_SEGREDO = [ + "AKIA", + "sk-ant-", + "sk-proj-", + "ghp_", + "github_pat_", + "xoxb-", + "-----BEGIN RSA PRIVATE KEY-----", + "-----BEGIN OPENSSH PRIVATE KEY-----", +]; + +export const LIMITE_DO_PROMPT = 2000; + +/** + * R5.1: a peca da captura que a trilha nao ve sozinha. + * + * O prompt vai para a trilha, nao para uma pagina por prompt: a consolidacao ja + * le a trilha, entao ele aparece na pagina da sessao junto do resto. + * + * R4.4, versao curta: prompt com marcador de segredo nao entra em texto. A + * trilha e append-only e encadeada por hash, entao segredo que entra nao sai + * mais - o registro guarda que houve prompt, e o tamanho dele. + */ +function aoReceberPrompt(ctx: ProjectContext, input: HookInput): HookResult { + const prompt = texto(input.prompt); + if (prompt === null) { + return { output: null, exitCode: EXIT.OK, nota: "prompt vazio" }; + } + + const marcador = MARCADORES_DE_SEGREDO.find((m) => prompt.includes(m)) ?? null; + const cortado = prompt.length > LIMITE_DO_PROMPT; + + ctx.chain.append("prompt.submit", "human:sessao", { + session_id: texto(input.session_id), + phase: ctx.state.phase, + chars: prompt.length, + truncated: cortado, + redacted: marcador !== null, + redacted_marker: marcador, + text: marcador === null ? prompt.slice(0, LIMITE_DO_PROMPT) : null, + }); + + return { + output: null, + exitCode: EXIT.OK, + nota: marcador === null ? `prompt registrado (${prompt.length} caracteres)` : `prompt redigido: marcador ${marcador}`, + }; +} + +// --- PreToolUse ----------------------------------------------------------- + +function antesDaTool(ctx: ProjectContext, input: HookInput): HookResult { + const tool = texto(input.tool_name); + if (tool === null) return SILENCIO; + const entrada = (input.tool_input ?? {}) as Record; + + if (tool === "Bash") { + // R3.3: deteccao de comando destrutivo e alerta, nunca bloqueio. Quem + // decide escrita e a fronteira, e ela nao le expressao regular. + const argv = [texto(entrada.command) ?? ""]; + const alertas = detectDestructive(argv); + if (alertas.alerts.length === 0) return SILENCIO; + return { + output: { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "allow", + permissionDecisionReason: `psh: alerta de comando destrutivo (${alertas.alerts.map((a) => a.id).join(", ")}). O comando roda; a fronteira decide o que ele consegue escrever.`, + }, + }, + exitCode: EXIT.OK, + nota: `alerta destrutivo: ${alertas.alerts.map((a) => a.id).join(", ")}`, + }; + } + + const campo = CAMPO_DE_CAMINHO[tool]; + if (campo === undefined) return SILENCIO; + const alvo = texto(entrada[campo]); + if (alvo === null) return SILENCIO; + + const agente = agenteDaSessao(ctx); + const decisao = boundaryOf(ctx).canWrite(agente, alvo); + if (decisao.allowed) { + return { output: null, exitCode: EXIT.OK, nota: `${alvo} dentro da fronteira de ${agente}` }; + } + + ctx.chain.append("boundary.decision", `agent:${agente}`, { + action: "negado-antes-da-escrita", + tool, + path: decisao.rel ?? alvo, + rule: decisao.rule.kind, + reason: decisao.reason, + tool_use_id: texto(input.tool_use_id), + }); + + return { + output: { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: `psh: ${decisao.reason}. Para liberar, o humano roda 'psh boundary add ${agente} '.`, + }, + }, + exitCode: EXIT.OK, + nota: `negado: ${alvo}`, + }; +} + +/** + * Qual agente a sessao esta usando. + * + * `PSH_AGENT` quando a sessao roda sob `psh exec`; senao o `default_agent` do + * contrato. Sem nenhum dos dois nao ha o que decidir, e a fronteira ja recusa + * agente desconhecido. + */ +function agenteDaSessao(ctx: ProjectContext): string { + const marcado = process.env.PSH_AGENT?.trim(); + if (marcado !== undefined && marcado !== "") return marcado; + return boundaryOf(ctx).defaultAgent ?? "default"; +} + +// --- PostToolUse ---------------------------------------------------------- + +/** + * R2.4 pelo lado do agente: a escrita acabou de derrubar evidencia. + * + * O portao ja recusaria na hora de avancar, mas o agente descobriria isso + * minutos depois. Dizer na hora custa uma linha e evita uma fase inteira + * apoiada em numero velho. + */ +function depoisDaTool(ctx: ProjectContext, input: HookInput): HookResult { + const tool = texto(input.tool_name); + if (tool === null) return SILENCIO; + const campo = CAMPO_DE_CAMINHO[tool]; + if (campo === undefined) return SILENCIO; + const entrada = (input.tool_input ?? {}) as Record; + const alvo = texto(entrada[campo]); + if (alvo === null) return SILENCIO; + + const abs = isAbsolute(alvo) ? alvo : resolve(ctx.layout.root, alvo); + const rel = toRel(ctx.layout.root, abs); + const atingidos: string[] = []; + + for (const verificador of ctx.workflow.verifiers) { + const globs = verificador.watch ?? []; + if (globs.length === 0) continue; + if (compileGlobs(globs).matchedBy(rel) !== null) atingidos.push(verificador.id); + } + if (atingidos.length === 0) return SILENCIO; + + return { + output: { + hookSpecificOutput: { + hookEventName: "PostToolUse", + additionalContext: + `psh: ${rel} esta na arvore observada por ${atingidos.join(", ")}. ` + + `A evidencia desses verificadores ficou obsoleta e o portao vai recusar ate 'psh verify' rodar de novo.`, + }, + }, + exitCode: EXIT.OK, + nota: `frescor derrubado: ${atingidos.join(", ")}`, + }; +} + +// --- SessionEnd ----------------------------------------------------------- + +function aoFecharSessao(ctx: ProjectContext, input: HookInput, deps: HookDeps): HookResult { + ctx.chain.append("adapter.event", "adapter:claude-code", { + event: "SessionEnd", + session_id: texto(input.session_id), + reason: texto(input.reason), + }); + + // R5.2: a sessao vira pagina. Falha aqui nao pode derrubar o encerramento da + // sessao do usuario, entao o motivo vai para o diagnostico e o hook sai limpo. + try { + const r = consolidate(ctx, { now: deps.now }); + return { + output: null, + exitCode: EXIT.OK, + nota: r.slug === null ? "nada a consolidar" : `sessao consolidada em ${r.slug}`, + }; + } catch (cause) { + return { output: null, exitCode: EXIT.OK, nota: `consolidacao falhou: ${(cause as Error).message}` }; + } +} diff --git a/psh/src/adapters/claude-code/install.ts b/psh/src/adapters/claude-code/install.ts new file mode 100644 index 0000000..75ef07e --- /dev/null +++ b/psh/src/adapters/claude-code/install.ts @@ -0,0 +1,288 @@ +import { existsSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { readJsonFile, writeJsonAtomic } from "../../util/json.ts"; +import type { Layout } from "../../util/paths.ts"; +import { CONTRATO } from "./contract.ts"; + +/** Argumentos que marcam um hook como nosso. Ver `ehNosso`. */ +export const ARGV_DO_HOOK = ["adapter", "claude-code", "hook"] as const; + +export interface EntradaDeHook { + type?: string; + command?: string; + args?: string[]; + timeout?: number; + [k: string]: unknown; +} + +export interface GrupoDeHook { + matcher?: string; + hooks?: EntradaDeHook[]; + [k: string]: unknown; +} + +export interface SettingsDoClaude { + hooks?: Record; + [k: string]: unknown; +} + +export function settingsPath(layout: Layout): string { + return join(layout.root, ".claude", "settings.json"); +} + +/** + * Um hook e nosso quando o argv dele e o do adapter. + * + * A marca e o proprio argv, e nao um campo inventado: quem executa o hook e o + * runtime, e o que ele executa e exatamente isto. Marca em campo extra some + * quando alguem edita o arquivo a mao; o argv nao, porque sem ele o hook deixa + * de funcionar. + */ +export function ehNosso(entrada: EntradaDeHook): boolean { + const args = entrada.args; + if (Array.isArray(args)) { + for (let i = 0; i + ARGV_DO_HOOK.length <= args.length; i += 1) { + if (ARGV_DO_HOOK.every((parte, j) => args[i + j] === parte)) return true; + } + } + // Instalacao antiga, em forma de shell. + return typeof entrada.command === "string" && entrada.command.includes(ARGV_DO_HOOK.join(" ")); +} + +export interface PlanoDeInstalacao { + settings: string; + existia: boolean; + eventos: string[]; + /** R8.6f: registros nossos removidos antes de reescrever, contados. */ + removidos: number; + /** Registros nossos que apontavam para artefato ausente. */ + orfaos: number; + /** Hooks de terceiros preservados, contados: instalar nao pisa em vizinho. */ + preservados: number; + timeout_s: number; +} + +export interface ResultadoDaInstalacao extends PlanoDeInstalacao { + aplicado: boolean; +} + +function lerSettings(path: string): SettingsDoClaude { + if (!existsSync(path)) return {}; + return readJsonFile(path); +} + +/** + * R8.6f: remover ponto de extensao e operacao de duas pontas. + * + * A poda opera por handler, nunca por bloco: um grupo que tem o nosso hook e o + * de outra ferramenta perde so o nosso. Ela tambem so toca o que e nosso, e + * reporta quantos removeu - apagar o produtor sem limpar o consumidor deixa o + * erro na maquina de quem instalou. + */ +export function podar( + settings: SettingsDoClaude, + opts: { somenteOrfaos?: boolean } = {}, +): { settings: SettingsDoClaude; removidos: number; orfaos: number; preservados: number } { + const hooks = settings.hooks ?? {}; + const novo: Record = {}; + let removidos = 0; + let orfaos = 0; + let preservados = 0; + + for (const [evento, grupos] of Object.entries(hooks)) { + const gruposNovos: GrupoDeHook[] = []; + for (const grupo of Array.isArray(grupos) ? grupos : []) { + const entradas = Array.isArray(grupo.hooks) ? grupo.hooks : []; + const mantidas: EntradaDeHook[] = []; + for (const entrada of entradas) { + if (!ehNosso(entrada)) { + preservados += 1; + mantidas.push(entrada); + continue; + } + const orfao = typeof entrada.command === "string" && !existsSync(entrada.command); + if (orfao) orfaos += 1; + if (opts.somenteOrfaos === true && !orfao) { + mantidas.push(entrada); + continue; + } + removidos += 1; + } + // Grupo que ficou sem hook nenhum nao fica no arquivo: registro vazio + // apontando para lugar nenhum e exatamente o que a poda existe para tirar. + if (mantidas.length > 0) gruposNovos.push({ ...grupo, hooks: mantidas }); + } + if (gruposNovos.length > 0) novo[evento] = gruposNovos; + } + + const resultado: SettingsDoClaude = { ...settings }; + if (Object.keys(novo).length > 0) resultado.hooks = novo; + else delete resultado.hooks; + return { settings: resultado, removidos, orfaos, preservados }; +} + +export interface InstallOptions { + /** argv do proprio psh, como o runtime vai executar. */ + selfArgv: string[]; + timeout_s?: number; + dryRun?: boolean; +} + +/** + * Registra os hooks do adapter em `.claude/settings.json`. + * + * A forma e a **exec**: `command` e o executavel e `args` sao os argumentos, + * sem shell no meio. Caminho de instalacao com espaco, aspas ou cifrao nunca + * chega a um parser de shell - mesma postura do R2.14 para expressao regular. + */ +export function instalar(layout: Layout, opts: InstallOptions): ResultadoDaInstalacao { + const path = settingsPath(layout); + const existia = existsSync(path); + const timeout = opts.timeout_s ?? 30; + + const podado = podar(lerSettings(path)); + const hooks: Record = { ...(podado.settings.hooks ?? {}) }; + + const [executavel, ...prefixo] = opts.selfArgv; + if (executavel === undefined) { + throw new Error("selfArgv vazio: sem executavel nao ha o que registrar"); + } + + const eventos = Object.keys(CONTRATO.events); + for (const evento of eventos) { + const entrada: EntradaDeHook = { + type: CONTRATO.settings.tipo_de_hook, + command: executavel, + args: [...prefixo, ...ARGV_DO_HOOK], + timeout, + }; + const grupos = hooks[evento] ?? []; + hooks[evento] = [...grupos, { matcher: "", hooks: [entrada] }]; + } + + const resultado: ResultadoDaInstalacao = { + settings: path, + existia, + eventos, + removidos: podado.removidos, + orfaos: podado.orfaos, + preservados: podado.preservados, + timeout_s: timeout, + aplicado: opts.dryRun !== true, + }; + + if (opts.dryRun === true) return resultado; + + mkdirSync(join(layout.root, ".claude"), { recursive: true }); + writeJsonAtomic(path, { ...podado.settings, hooks }); + return resultado; +} + +export interface ResultadoDaRemocao { + settings: string; + removidos: number; + orfaos: number; + preservados: number; + existia: boolean; +} + +export function desinstalar(layout: Layout): ResultadoDaRemocao { + const path = settingsPath(layout); + if (!existsSync(path)) { + return { settings: path, removidos: 0, orfaos: 0, preservados: 0, existia: false }; + } + const podado = podar(lerSettings(path)); + writeJsonAtomic(path, podado.settings); + return { + settings: path, + removidos: podado.removidos, + orfaos: podado.orfaos, + preservados: podado.preservados, + existia: true, + }; +} + +export interface PontoAtivo { + evento: string; + registrado: boolean; + orfao: boolean; + comando: string | null; +} + +export interface StatusDoAdapter { + settings: string; + existe: boolean; + pontos: PontoAtivo[]; + registrados: number; + esperados: number; + orfaos: number; + completo: boolean; +} + +/** + * R8.6c: quais pontos de extensao estao efetivamente registrados. + * + * Adapter carregado pela metade e falha visivel, nunca degradacao silenciosa: + * quatro de cinco eventos registrados significa uma responsabilidade do R8.1 + * que simplesmente nao acontece, e ninguem descobriria sozinho. + */ +export function statusDoAdapter(layout: Layout): StatusDoAdapter { + const path = settingsPath(layout); + const existe = existsSync(path); + const settings = existe ? lerSettings(path) : {}; + const hooks = settings.hooks ?? {}; + const pontos: PontoAtivo[] = []; + + for (const evento of Object.keys(CONTRATO.events)) { + let registrado = false; + let orfao = false; + let comando: string | null = null; + for (const grupo of hooks[evento] ?? []) { + for (const entrada of grupo.hooks ?? []) { + if (!ehNosso(entrada)) continue; + registrado = true; + comando = typeof entrada.command === "string" ? entrada.command : null; + if (comando !== null && !existsSync(comando)) orfao = true; + } + } + pontos.push({ evento, registrado, orfao, comando }); + } + + const registrados = pontos.filter((p) => p.registrado).length; + const orfaos = pontos.filter((p) => p.orfao).length; + return { + settings: path, + existe, + pontos, + registrados, + esperados: pontos.length, + orfaos, + completo: registrados === pontos.length && orfaos === 0, + }; +} + +export function renderStatusDoAdapter(s: StatusDoAdapter): string { + const linhas = [ + `settings ${s.settings}${s.existe ? "" : " (ausente)"}`, + `pontos ativos ${s.registrados}/${s.esperados}${s.orfaos > 0 ? `, ${s.orfaos} apontando para artefato ausente` : ""}`, + "", + ]; + for (const p of s.pontos) { + const marca = p.orfao ? "ORFAO" : p.registrado ? "ok " : "NAO "; + linhas.push(` ${marca} ${p.evento.padEnd(18)} ${CONTRATO.events[p.evento]?.usa ?? ""}`); + } + if (!s.completo) { + linhas.push("", "Adapter parcialmente carregado. Rode 'psh adapter claude-code install'."); + } + return linhas.join("\n"); +} + +export function renderInstalacao(r: ResultadoDaInstalacao): string { + return [ + `${r.aplicado ? "registrado" : "registraria"} em ${r.settings}${r.existia ? "" : " (arquivo novo)"}`, + ` eventos: ${r.eventos.join(", ")}`, + ` hooks nossos substituidos: ${r.removidos} (${r.orfaos} apontavam para artefato ausente)`, + ` hooks de terceiros preservados: ${r.preservados}`, + ` forma exec: o runtime executa o binario direto, sem shell no meio`, + ].join("\n"); +} diff --git a/psh/src/audit/chain.ts b/psh/src/audit/chain.ts index bff91f6..7e6b0aa 100644 --- a/psh/src/audit/chain.ts +++ b/psh/src/audit/chain.ts @@ -26,6 +26,10 @@ export type AuditEventType = | "maestro.call" | "human.override" | "human.approval" + | "memory.write" + | "memory.promote" + | "adapter.event" + | "prompt.submit" | "audit.note"; export interface AuditEntry { @@ -96,45 +100,120 @@ export class AuditChain { const release = this.#lock(); try { const head = this.#head(); - const base = { - seq: head.seq + 1, - ts: this.#now().toISOString(), - type, - actor, - payload, - prev_hash: head.hash, - }; - const entry: AuditEntry = { ...base, hash: entryHash(base) }; + this.#assertAncoraBate(head); + return this.#grava(type, actor, payload, head); + } finally { + release(); + } + } - let fd: number; - try { - mkdirSync(dirname(this.path), { recursive: true }); - fd = openSync(this.path, "a", 0o644); - } catch (cause) { + /** + * O caminho de volta para trilha e ancora divergentes. + * + * A protecao de `#assertAncoraBate` esta certa em travar o projeto: uma trilha + * que nao bate com a propria ancora perdeu a garantia que ela existe para dar. + * Mas travar sem saida deixava uma unica alternativa real, que era apagar o + * `.harness` na mao, e apagar no susto e exatamente como a evidencia de + * adulteracao desaparece. + * + * Reancorar nao conserta nada nem finge que o estranho nao aconteceu: ele + * grava na propria trilha o que a ancora dizia, o que o arquivo diz, quem + * decidiu e por que, e so entao passa a ancora a apontar para o topo real. A + * divergencia vira cicatriz permanente e legivel, em vez de virar diretorio + * apagado. + * + * Recusa quando o problema esta DENTRO do arquivo (linha corrompida, seq fora + * de ordem, elo quebrado, hash que nao fecha): ai a ancora nao e o defeito, e + * mover a ancora so trocaria um relatorio vermelho por outro. Prometer conserto + * nesse caso seria pior que nao ter o comando. + */ + reanchor(reason: string, actor: string): { entry: AuditEntry; anchorBefore: AuditAnchor | null } { + if (reason.trim() === "") { + throw new AuditError("reancorar exige um motivo escrito: a decisao fica na trilha, nao na memoria de quem rodou", { + path: this.path, + }); + } + if (this.#anchor === null) { + throw new AuditError("este projeto nao tem ancora externa, entao nao ha o que reancorar", { + path: this.path, + }); + } + + const release = this.#lock(); + try { + const interno = this.verify().problems.filter((p) => !p.kind.startsWith("anchor-")); + if (interno.length > 0) { throw new AuditError( - `nao foi possivel abrir a trilha em ${this.path}: ${(cause as Error).message}`, - { path: this.path }, + `a trilha tem ${interno.length} problema(s) dentro do proprio arquivo, e reancorar nao conserta isso: ` + + `${interno.map((p) => `${p.kind}${"line" in p ? ` na linha ${p.line}` : ""}`).join(", ")}. ` + + "A ancora nao e o defeito aqui. Rode 'psh audit verify' e trate a cadeia antes.", + { path: this.path, problems: interno.length }, ); } - try { - writeSync(fd, `${canonicalJson(entry)}\n`); - fsyncSync(fd); - } catch (cause) { - throw new AuditError(`falha ao gravar na trilha: ${(cause as Error).message}`, { - path: this.path, - seq: entry.seq, - }); - } finally { - closeSync(fd); - } - this.#anchor?.writeAnchor({ count: entry.seq, head_hash: entry.hash }); - return entry; + const anchorBefore = this.#anchor.readAnchor(); + const head = this.#head(); + const linhas = this.#contarLinhas(); + const entry = this.#grava( + "audit.note", + actor, + { + note: "reancoragem da trilha por decisao humana", + reason, + anchor_before: anchorBefore === null ? null : { count: anchorBefore.count, head_hash: anchorBefore.head_hash }, + file_at_reanchor: { count: linhas, head_hash: head.hash }, + }, + head, + ); + return { entry, anchorBefore }; } finally { release(); } } + /** Grava e reancora. Fora do `append` porque `reanchor` entra sem a assercao. */ + #grava( + type: AuditEventType, + actor: string, + payload: Record, + head: { seq: number; hash: string }, + ): AuditEntry { + const base = { + seq: head.seq + 1, + ts: this.#now().toISOString(), + type, + actor, + payload, + prev_hash: head.hash, + }; + const entry: AuditEntry = { ...base, hash: entryHash(base) }; + + let fd: number; + try { + mkdirSync(dirname(this.path), { recursive: true }); + fd = openSync(this.path, "a", 0o644); + } catch (cause) { + throw new AuditError( + `nao foi possivel abrir a trilha em ${this.path}: ${(cause as Error).message}`, + { path: this.path }, + ); + } + try { + writeSync(fd, `${canonicalJson(entry)}\n`); + fsyncSync(fd); + } catch (cause) { + throw new AuditError(`falha ao gravar na trilha: ${(cause as Error).message}`, { + path: this.path, + seq: entry.seq, + }); + } finally { + closeSync(fd); + } + + this.#anchor?.writeAnchor({ count: entry.seq, head_hash: entry.hash }); + return entry; + } + read(): AuditEntry[] { if (!existsSync(this.path)) return []; const raw = readFileSync(this.path, "utf8"); @@ -217,6 +296,44 @@ export class AuditChain { }; } + /** + * Nao se escreve em cima de trilha que nao bate com a propria ancora. + * + * A ancora existe para pegar reescrita coordenada (R4.1), mas quem so a + * conferia era o `verify`. Como todo `append` regrava a ancora com o topo + * novo, bastava uma escrita qualquer depois da adulteracao - `psh remember`, + * o hook de fim de sessao, qualquer coisa - para a cadeia voltar a fechar e o + * estrago sumir do relatorio. + * + * A conferencia e por contagem de linha e hash do topo, sem re-hashear a + * cadeia inteira: remocao no meio muda a contagem, e edicao ou religamento + * mudam o hash do topo. E o mesmo alcance do que a ancora ja prometia, agora + * cobrado antes da escrita e nao so depois. + */ + #assertAncoraBate(head: { seq: number; hash: string }): void { + const ancora = this.#anchor?.readAnchor() ?? null; + if (ancora === null) return; + + const linhas = this.#contarLinhas(); + + if (ancora.count === linhas && ancora.head_hash === head.hash) return; + + throw new AuditError( + `recusando escrever numa trilha que nao bate com a ancora: ancora diz ${ancora.count} entrada(s) com topo ${ancora.head_hash}, ` + + `o arquivo tem ${linhas} entrada(s) com topo ${head.hash}. ` + + "Rode 'psh audit verify' para o diagnostico, e 'psh audit reanchor --reason \"...\"' " + + "para registrar a decisao de seguir a partir do arquivo atual.", + { path: this.path, anchor_count: ancora.count, file_count: linhas }, + ); + } + + #contarLinhas(): number { + if (!existsSync(this.path)) return 0; + return readFileSync(this.path, "utf8") + .split("\n") + .filter((l) => l.trim() !== "").length; + } + #head(): { seq: number; hash: string } { if (!existsSync(this.path)) return { seq: 0, hash: GENESIS_HASH }; const raw = readFileSync(this.path, "utf8"); diff --git a/psh/src/boundary/execute.ts b/psh/src/boundary/execute.ts index 002ad51..9749c4a 100644 --- a/psh/src/boundary/execute.ts +++ b/psh/src/boundary/execute.ts @@ -56,12 +56,20 @@ const SKIP_DIRS = new Set([".git", "node_modules", ".venv", "target", "dist"]); /** * Arquivos que o proprio sandbox cria ou reescreve durante o setup. * - * Sem esta excecao, o `.ai-jail` que o ai-jail grava na raiz aparece como - * "arquivo criado fora da fronteira" e vira violacao do agente. Acusar o - * mecanismo de isolamento de violar a fronteira que ele esta aplicando polui o - * relatorio e treina quem le a ignorar violacao de verdade. + * O conjunto esta vazio desde que o argv passou a levar `--no-save-config`: o + * ai-jail nao grava mais o `.ai-jail` na raiz, entao nao ha o que perdoar. E + * bom que esteja vazio - enquanto o arquivo era escrito pelo sandbox, uma + * copia dele feita pelo agente tambem passava sem virar violacao. */ -const ARQUIVOS_DO_SANDBOX = new Set([".ai-jail"]); +const ARQUIVOS_DO_SANDBOX = new Set(); + +/** + * Marca de quem esta rodando o comando. + * + * Acao tomada por comando do agente precisa nascer assinada como agente, e nao + * como o operador dono do terminal (R4.3). + */ +export const PSH_AGENT_ENV = "PSH_AGENT"; /** * Executa um comando sob a fronteira do agente. @@ -78,7 +86,10 @@ export function execUnderBoundary(opts: ExecOptions): ExecResult { const sandbox = opts.sandbox ?? detectSandbox(); const cwd = opts.cwd ?? opts.layout.root; const timeout = (opts.timeout_s ?? 900) * 1000; - const env = opts.env ?? herdarAmbiente(); + // Quem chama o psh de dentro daqui e o agente, nao o operador. Sem esta marca + // a trilha passaria a afirmar que um humano fez o que o agente fez. + // No modo enjaulado ela vai tambem no argv, porque a jaula zera o ambiente. + const env = { ...(opts.env ?? herdarAmbiente()), [PSH_AGENT_ENV]: opts.agentId }; const enjaulado = sandbox.mode === "ai-jail" && sandbox.jail_bin !== null; const argv = enjaulado ? montarArgvEnjaulado(opts, sandbox.jail_bin!) : opts.argv; @@ -125,7 +136,7 @@ interface Snapshot { } function tirarSnapshot(opts: ExecOptions, budget: number): Snapshot { - const dir = join(opts.layout.harness, "tmp", `boundary-${process.pid}-${opts.agentId}`); + const dir = join(opts.layout.tmpDir, `boundary-${process.pid}-${opts.agentId}`); rmSync(dir, { recursive: true, force: true }); mkdirSync(dir, { recursive: true }); @@ -281,8 +292,27 @@ function aplicarModificacao(abs: string, rel: string, decisao: Decision, snapsho */ export function montarArgvEnjaulado(opts: ExecOptions, jailBin: string): string[] { const agente = opts.policy.agent(opts.agentId); - const args = [jailBin, "--no-agent-state", "--no-docker", "--no-ssh"]; + // `--clean --no-save-config`, medido contra o ai-jail 1.19.2. + // + // Por padrao o ai-jail grava um `.ai-jail` na raiz do projeto e o le na + // execucao seguinte. O arquivo fica dentro da arvore que o agente edita, e a + // fronteira passaria a depender, em parte, de um arquivo que o proprio + // enjaulado escreve - o mesmo erro que o G4 aponta no harness de referencia. + // + // Na pratica ele tambem acumulava lixo: cada corrida somava os deny paths de + // novo, guardados na forma `~/...`, e o ai-jail os reabria como + // `/~/...`, avisando "rule not applied" para regra que nao existia. A + // regra que vale continua sendo a do argv, mas o ruido escondia o aviso de + // verdade. + // + // Com as duas flags a jaula e montada so a partir do contrato do psh. + const args = [jailBin, "--clean", "--no-save-config", "--no-agent-state", "--no-docker", "--no-ssh"]; args.push(agente?.network === true ? "--network" : "--no-network"); + // Medido contra o ai-jail 1.19.2: a jaula zera o ambiente do filho, entao + // `PSH_AGENT` no env do spawn chega vazio la dentro e a acao do agente + // voltaria a ser registrada como acao de humano. O valor vai explicito no + // argv, e nao por heranca, para nao depender do ambiente de fora. + args.push("--env", `${PSH_AGENT_ENV}=${opts.agentId}`); for (const abs of caminhosNegados(opts)) args.push("--deny-path", abs); args.push("--", ...opts.argv); @@ -308,8 +338,6 @@ export function caminhosNegados(opts: ExecOptions): string[] { } for (const e of entradas) { const filho = rel === "" ? e.name : `${rel}/${e.name}`; - // O proprio ai-jail precisa escrever este arquivo no setup. - if (filho === ".ai-jail") continue; const permitido = opts.policy.canWrite(opts.agentId, filho).allowed; if (e.isDirectory()) { diff --git a/psh/src/boundary/policy.ts b/psh/src/boundary/policy.ts index 5f88415..e273ad1 100644 --- a/psh/src/boundary/policy.ts +++ b/psh/src/boundary/policy.ts @@ -1,15 +1,14 @@ import { existsSync, realpathSync } from "node:fs"; import { isAbsolute, resolve } from "node:path"; -import Ajv from "ajv"; import boundarySchema from "../../schemas/boundary.schema.json" with { type: "json" }; import { ContractError } from "../util/errors.ts"; import { readJsonFile } from "../util/json.ts"; +import { lazyValidator } from "../util/schema.ts"; import { compileGlobs, normalizeRel, type GlobSet } from "../util/globs.ts"; import { toRel, type Layout } from "../util/paths.ts"; import { formatAjvErrors } from "../workflow/load.ts"; -const ajv = new Ajv({ allErrors: true, strict: false }); -export const validateBoundarySchema = ajv.compile(boundarySchema); +export const validateBoundarySchema = lazyValidator(boundarySchema); export interface AgentPolicy { description?: string; @@ -42,6 +41,11 @@ export const DENY_ALWAYS: readonly string[] = [ ".harness/audit", ".harness/approvals/**", ".harness/approvals", + // R5.4: o bloco de handoff entra no inicio da sessao seguinte. Memoria que o + // agente escreve a mao e texto que ele injeta em si mesmo depois, sem passar + // por nenhuma porta do nucleo. Escrita de memoria e por 'psh remember'. + ".harness/memory/**", + ".harness/memory", ".harness/state.json", ".harness/boundary.json", ".harness/workflow.json", diff --git a/psh/src/cli/args.ts b/psh/src/cli/args.ts index 7918d0e..2fa7a75 100644 --- a/psh/src/cli/args.ts +++ b/psh/src/cli/args.ts @@ -21,6 +21,7 @@ const BOOLEAN_FLAGS = new Set([ "force", "gate-only", "skip-verify", + "pinned", "help", "version", ]); @@ -41,6 +42,7 @@ export function parseArgs(argv: readonly string[]): ParsedArgs { break; } if (!token.startsWith("--")) { + assertNaoEhFlagCurta(token); positional.push(token); continue; } @@ -66,6 +68,29 @@ export function parseArgs(argv: readonly string[]): ParsedArgs { return { positional, flags }; } +/** + * O parser nao tem flag de traco simples, e por isso ela nao pode passar calada. + * + * `psh audit log -n 5` virava dois posicionais ignorados: o comando respondia + * com o limite padrao e ninguem via erro. Pior, o `-n` chegava a comando que le + * posicional e virava nome de coisa. Argumento que parece flag e nao e flag + * falha aqui, dizendo qual e a forma certa. + * + * Texto que comeca com traco continua possivel depois de `--`, que e a saida + * padrao de linha de comando para isso. + */ +function assertNaoEhFlagCurta(token: string): void { + const segundo = token[1]; + if (token[0] !== "-" || segundo === undefined) return; + const letra = (segundo >= "a" && segundo <= "z") || (segundo >= "A" && segundo <= "Z"); + if (!letra) return; + throw new PshError( + `argumento ${JSON.stringify(token)} parece flag mas o psh nao usa traco simples. ` + + `Use --${token.slice(1)}, ou passe o texto depois de '--' se ele comeca com traco mesmo.`, + { exitCode: EXIT.FAILURE }, + ); +} + export function rejectUnknownFlags(args: ParsedArgs, allowed: readonly string[], command: string): void { const unknown = [...args.flags.keys()].filter((k) => !allowed.includes(k)); if (unknown.length > 0) { @@ -90,3 +115,22 @@ export function flagString(args: ParsedArgs, name: string): string | null { export function flagBool(args: ParsedArgs, name: string): boolean { return args.flags.has(name); } + +/** + * Inteiro nao negativo, conferido aqui. + * + * `Number("abc")` e `NaN`, e `NaN` chegava ate o `LIMIT` do SQLite: o usuario + * via "datatype mismatch" com pilha de excecao no lugar de "use um numero". + * Erro de uso e falha de uso, nao erro inesperado. + */ +export function flagInt(args: ParsedArgs, name: string, fallback: number): number { + const raw = flagString(args, name); + if (raw === null) return fallback; + const value = Number(raw); + if (!Number.isInteger(value) || value < 0) { + throw new PshError(`--${name} exige um inteiro nao negativo, recebeu ${JSON.stringify(raw)}`, { + exitCode: EXIT.FAILURE, + }); + } + return value; +} diff --git a/psh/src/cli/doctor.ts b/psh/src/cli/doctor.ts index 2ae6446..1ae6c9e 100644 --- a/psh/src/cli/doctor.ts +++ b/psh/src/cli/doctor.ts @@ -3,8 +3,11 @@ import { spawnSync } from "node:child_process"; import { delimiter, isAbsolute, join } from "node:path"; import type { ProjectContext } from "./context.ts"; import { detectSandbox } from "../evidence/sandbox.ts"; -import { probeGit } from "../evidence/workspace.ts"; +import { isGitRepo, probeGit } from "../evidence/workspace.ts"; import { defaultInstallDirs, loadBoundary } from "../boundary/policy.ts"; +import { syncIndex } from "../memory/search.ts"; +import { statusDoAdapter } from "../adapters/claude-code/install.ts"; +import { acharRuntime, CONTRATO } from "../adapters/claude-code/contract.ts"; import { PSH_TOKEN } from "../util/self.ts"; import { PSH_VERSION } from "../version.ts"; @@ -39,6 +42,8 @@ export function runDoctor(ctx: ProjectContext): DoctorReport { checks.push(checkAbsolutePaths(ctx)); checks.push(checkSecrets(ctx)); checks.push(checkEvidenceOwnership(ctx)); + checks.push(...checkMemory(ctx)); + checks.push(...checkAdapterClaudeCode(ctx)); return { psh_version: PSH_VERSION, @@ -336,7 +341,9 @@ function checkEnumeration(ctx: ProjectContext): Check { if (probe.available) { return { id: "workspace-enum", level: "ok", message: "enumeracao por git", detail: probe.detail }; } - if (!existsSync(join(ctx.layout.root, ".git"))) { + // Mesma pergunta que a enumeracao faz, feita ao git e nao ao diretorio: um app + // dentro de repositorio maior nao tem `.git/` proprio e continua sob Git. + if (!isGitRepo(ctx.layout.root)) { return { id: "workspace-enum", level: "ok", message: "enumeracao por caminhada", detail: probe.detail }; } return { @@ -347,6 +354,104 @@ function checkEnumeration(ctx: ProjectContext): Check { }; } +/** + * C5 ativo: o diagnostico diz por qual mecanismo a busca responde e o que ficou + * fora do indice. + * + * Busca que responde menos porque o SQLite veio sem FTS5, ou porque uma pagina + * esta corrompida, nao pode parecer busca que respondeu tudo (mesma regra do + * modo degradado do sandbox e da fronteira). + */ +function checkMemory(ctx: ProjectContext): Check[] { + const out: Check[] = []; + out.push( + ctx.db.ftsAvailable + ? { id: "memory-fts", level: "ok", message: "busca de memoria por FTS5" } + : { + id: "memory-fts", + level: "warn", + message: "MODO DEGRADADO: SQLite sem FTS5, busca por substring", + detail: `${ctx.db.ftsUnavailableReason ?? "modulo ausente"}. Sem ranking, sem prefixo e sem tolerancia a acento.`, + }, + ); + + const sync = syncIndex(ctx.db, ctx.layout); + if (sync.unreadable.length > 0) { + out.push({ + id: "memory-pages", + level: "fail", + message: `${sync.unreadable.length} pagina(s) de memoria ilegivel(is), fora do indice`, + detail: sync.unreadable.map((p) => `${p.slug}: ${p.message}`).join("; "), + }); + } else if (sync.skipped.length > 0) { + out.push({ + id: "memory-pages", + level: "warn", + message: `${sync.skipped.length} arquivo(s) em .harness/memory/pages/ ignorado(s) por nome invalido`, + detail: sync.skipped.join(", "), + }); + } else { + out.push({ + id: "memory-pages", + level: "ok", + message: `${sync.pages_examined} pagina(s) de memoria examinada(s), ${sync.indexed} reindexada(s)`, + }); + } + return out; +} + +/** + * R8.6c: adapter parcialmente carregado e falha visivel. + * + * O modo de falha mais caro do projeto e o silencioso: 881 linhas de plugin + * morto no harness de referencia, e os tres mecanismos do `prostaff-hooks` que + * nunca responderam. Aqui o diagnostico diz quantos pontos de extensao estao + * registrados de verdade, e contra qual versao do runtime o contrato passou. + */ +function checkAdapterClaudeCode(ctx: ProjectContext): Check[] { + const status = statusDoAdapter(ctx.layout); + if (!status.existe && status.registrados === 0) { + // Nao instalado nao e defeito: nem todo projeto usa este runtime. + return [ + { + id: "adapter-claude-code", + level: "ok", + message: "adapter claude-code nao instalado neste projeto", + detail: "Instale com 'psh adapter claude-code install'.", + }, + ]; + } + + const out: Check[] = []; + out.push( + status.completo + ? { + id: "adapter-claude-code", + level: "ok", + message: `adapter claude-code: ${status.registrados}/${status.esperados} pontos de extensao ativos`, + } + : { + id: "adapter-claude-code", + level: "fail", + message: `adapter claude-code carregado pela metade: ${status.registrados}/${status.esperados} pontos ativos, ${status.orfaos} orfao(s)`, + detail: `${status.pontos.filter((p) => !p.registrado).map((p) => p.evento).join(", ") || "-"} sem registro. Rode 'psh adapter claude-code install'.`, + }, + ); + + const runtime = acharRuntime(); + out.push({ + id: "adapter-claude-code-runtime", + level: runtime.encontrado ? "ok" : "warn", + message: runtime.encontrado + ? `runtime ${runtime.versao ?? "?"}; contrato conferido contra ${CONTRATO.verified_against.version}` + : "runtime claude-code nao encontrado; o contrato nao pode ser conferido aqui", + detail: runtime.encontrado + ? "Confira simbolo a simbolo com 'psh adapter claude-code contract'." + : runtime.detalhe, + }); + return out; +} + function checkEvidenceOwnership(ctx: ProjectContext): Check { const gitignore = join(ctx.layout.harness, ".gitignore"); if (!existsSync(gitignore)) { diff --git a/psh/src/cli/init.ts b/psh/src/cli/init.ts index 5a72229..e2398a1 100644 --- a/psh/src/cli/init.ts +++ b/psh/src/cli/init.ts @@ -171,6 +171,9 @@ harness.db-shm evidence/ audit/ approvals/ +# R5.7: memoria e faixa transitoria. O que precisa sobreviver com garantia sai +# daqui por 'psh memory promote' e vira arquivo versionado em docs/. +memory/ `; const BOUNDARY_SEED = { @@ -194,7 +197,7 @@ export function applyPlan(plan: InitPlan, opts: { backup: boolean }): Layout { mkdirSync(layout.evidenceDir, { recursive: true }); mkdirSync(layout.auditDir, { recursive: true }); mkdirSync(layout.reviewsDir, { recursive: true }); - mkdirSync(layout.memoryDir, { recursive: true }); + mkdirSync(layout.memoryPagesDir, { recursive: true }); mkdirSync(layout.approvalsDir, { recursive: true }); if (opts.backup) { diff --git a/psh/src/cli/memory.ts b/psh/src/cli/memory.ts new file mode 100644 index 0000000..6e88c34 --- /dev/null +++ b/psh/src/cli/memory.ts @@ -0,0 +1,442 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import type { ProjectContext } from "./context.ts"; +import { boundaryOf } from "./boundary.ts"; +import { EXIT, PshError } from "../util/errors.ts"; +import { isInside, toRel } from "../util/paths.ts"; +import { buildPage, readPage, writePage, type NewPageInput, type StoredPage } from "../memory/store.ts"; +import { searchMemory, syncIndex, type SearchResult } from "../memory/search.ts"; +import { serializePage, type PageKind } from "../memory/page.ts"; +import { + ATOR_DA_CONSOLIDACAO, + buildDigest, + entradasPendentes, + narrarDaTrilha, + NARRADOR, + readConsolidationState, + slugDoDigest, + tagsDoDigest, + tituloDoDigest, + writeConsolidationState, + type SessionDigest, +} from "../memory/consolidate.ts"; + +/** Onde `psh memory promote` deposita, quando o chamador nao diz (R5.7). */ +export const PROMOTE_DIR = join("docs", "decisoes"); + +export interface RememberOptions { + fact: string; + title?: string; + tags?: string[]; + kind?: PageKind; + pinned?: boolean; + source?: string; + now?: Date; +} + +/** + * R5.5: anotacao explicita. + * + * Nasce fixada por definicao - o ponto do comando e justamente ser o item que a + * consolidacao nao pode descartar. + */ +export function remember(ctx: ProjectContext, opts: RememberOptions): StoredPage { + const fato = opts.fact.trim(); + if (fato === "") { + throw new PshError("uso: psh remember \"\"", { exitCode: EXIT.FAILURE }); + } + const titulo = (opts.title ?? primeiraLinha(fato)).trim(); + if (titulo === "") { + throw new PshError("--title vazio: a pagina precisa de um titulo para ser encontrada depois", { + exitCode: EXIT.FAILURE, + }); + } + const input: NewPageInput = { + title: titulo, + body: fato, + kind: opts.kind ?? "fact", + pinned: opts.pinned ?? true, + source: opts.source ?? origemDaSessao(), + phase: ctx.state.phase, + tags: opts.tags ?? [], + now: opts.now, + }; + const stored = writePage(ctx.layout, buildPage(ctx.layout, input)); + + ctx.chain.append("memory.write", input.source, { + slug: stored.page.slug, + kind: stored.page.kind, + pinned: stored.page.pinned, + phase: stored.page.phase, + content_sha256: stored.content_sha256, + }); + syncIndex(ctx.db, ctx.layout); + return stored; +} + +/** + * Quem esta anotando. + * + * `psh exec` marca a sessao com `PSH_AGENT`, entao uma anotacao feita por + * comando do agente nasce assinada como agente. A atribuicao e **melhor + * esforco**: o agente roda dentro da jaula com ambiente proprio e pode apagar a + * variavel antes de chamar o `psh`. O que ele nao consegue apagar e a entrada + * `command.exec` da mesma execucao na trilha, e e por ela que a correlacao + * fecha. Identidade por canal fora do alcance do agente e trabalho do contrato + * de adapter (R8.1). + */ +function origemDaSessao(): string { + const agente = process.env.PSH_AGENT; + if (agente !== undefined && agente.trim() !== "") return `agent:${agente.trim()}`; + return `human:${process.env.USER ?? "operador"}`; +} + +function primeiraLinha(texto: string, limite = 80): string { + const linha = texto.split("\n")[0]!.trim(); + return linha.length <= limite ? linha : `${linha.slice(0, limite - 3).trimEnd()}...`; +} + +export interface PromoteResult { + slug: string; + from: string; + /** Caminho relativo a raiz do projeto: e ele que fica gravado na pagina. */ + to: string; + bytes: number; +} + +/** + * R5.7: a passagem de faixa transitoria para fonte canonica. + * + * O arquivo promovido vive no repositorio e entra em revisao como qualquer + * outro. A pagina de memoria continua existindo, mas passa a apontar para o + * destino: duas copias sem ponteiro seriam duas verdades. + */ +export function promote( + ctx: ProjectContext, + slug: string, + opts: { to?: string | null; force?: boolean; now?: Date } = {}, +): PromoteResult { + const stored = readPage(ctx.layout, slug); + const destinoRel = opts.to ?? join(PROMOTE_DIR, `${stored.page.slug}.md`); + if (destinoRel.trim() === "") { + throw new PshError("--to vazio: informe o arquivo de destino", { exitCode: EXIT.FAILURE }); + } + if (isAbsolute(destinoRel)) { + throw new PshError(`--to precisa ser relativo a raiz do projeto: ${destinoRel}`, { exitCode: EXIT.FAILURE }); + } + const destino = resolve(ctx.layout.root, destinoRel); + + if (!isInside(ctx.layout.root, destino)) { + throw new PshError(`destino fora do projeto: ${destinoRel}`, { exitCode: EXIT.BOUNDARY_VIOLATION }); + } + if (isInside(ctx.layout.harness, destino)) { + throw new PshError( + `destino dentro de .harness/: ${destinoRel}. Promover e sair da faixa transitoria, nao mudar de gaveta dentro dela.`, + { exitCode: EXIT.BOUNDARY_VIOLATION }, + ); + } + if (existsSync(destino) && opts.force !== true) { + throw new PshError( + `${destinoRel} ja existe. Escolha outro --to ou repita com --force para sobrescrever.`, + { exitCode: EXIT.FAILURE }, + ); + } + assertDestinoDentroDaFronteira(ctx, destinoRel, destino); + + const conteudo = renderPromovido(stored, opts.now ?? new Date()); + mkdirSync(dirname(destino), { recursive: true }); + writeFileSync(destino, conteudo, { mode: 0o644 }); + + const relFinal = toRel(ctx.layout.root, destino); + const atualizada = writePage(ctx.layout, { + ...stored.page, + promoted_to: relFinal, + updated_at: (opts.now ?? new Date()).toISOString(), + }); + + ctx.chain.append("memory.promote", origemDaSessao(), { + slug: stored.page.slug, + to: relFinal, + bytes: conteudo.length, + content_sha256: atualizada.content_sha256, + }); + syncIndex(ctx.db, ctx.layout); + + return { slug: stored.page.slug, from: toRel(ctx.layout.root, stored.path), to: relFinal, bytes: conteudo.length }; +} + +/** + * R3.5b: promover escreve no repositorio, e quem escreve e o nucleo. + * + * Sem esta conferencia o comando vira lavanderia de escrita: um agente com + * allowlist `src/api/**` chamaria + * `psh memory promote x --to src/web/app.tsx --force` e o arquivo sairia + * alterado com a assinatura do nucleo, sem passar pela fronteira dele. + * + * O humano nao passa por aqui: ele e a autoridade que define a allowlist, e o + * deny duro do `.harness/` continua valendo para os dois, logo acima. + */ +function assertDestinoDentroDaFronteira(ctx: ProjectContext, destinoRel: string, destino: string): void { + const agente = process.env.PSH_AGENT?.trim(); + if (agente === undefined || agente === "") return; + + const decisao = boundaryOf(ctx).canWrite(agente, destino); + if (!decisao.allowed) { + throw new PshError( + `agente '${agente}' nao pode escrever em ${destinoRel}: ${decisao.reason}. ` + + "Promover nao contorna a fronteira de quem promove.", + { exitCode: EXIT.BOUNDARY_VIOLATION, detail: { agent: agente, path: destinoRel, rule: decisao.rule.kind } }, + ); + } +} + +function renderPromovido(stored: StoredPage, now: Date): string { + const p = stored.page; + const linhas = [ + `# ${p.title}`, + "", + `> Promovido de \`.harness/memory/pages/${p.slug}.md\` em ${now.toISOString()}.`, + `> Origem ${p.source}, registrado em ${p.created_at}${p.phase === null ? "" : `, fase ${p.phase}`}.`, + "", + p.body.trim(), + "", + ]; + if (p.tags.length > 0) { + linhas.push("", `Tags: ${p.tags.join(", ")}`, ""); + } + return linhas.join("\n"); +} + +export interface ConsolidateResult { + /** Nulo quando nao havia nada novo na trilha. */ + slug: string | null; + path: string | null; + from_seq: number; + to_seq: number; + entries_examined: number; + /** Entradas da propria consolidacao, puladas para ela nao resumir a si mesma. */ + ignoradas: number; + /** Ate onde a trilha ja esta consolidada depois desta corrida. */ + watermark: number; + narrador: string; + dry_run: boolean; + digest: SessionDigest; +} + +/** + * R5.2: as capturas da sessao viram uma pagina. + * + * A faixa entre a marca d'agua e o topo da trilha vira uma pagina `session`, + * nao fixada, que aparece em "Memoria recente" no proximo `psh handoff`. + * + * A pagina nasce nao fixada de proposito: o que precisa sobreviver a qualquer + * corte e o que o humano fixou com `psh remember` (R5.5), e um resumo de sessao + * nao entra nessa categoria so por ser recente. + */ +export function consolidate( + ctx: ProjectContext, + opts: { dryRun?: boolean; now?: Date } = {}, +): ConsolidateResult { + const state = readConsolidationState(ctx.layout); + const pendentes = entradasPendentes(ctx.chain, state); + const digest = buildDigest(pendentes.entradas); + + const base = { + from_seq: digest.from_seq, + to_seq: digest.to_seq, + entries_examined: digest.entries_examined, + ignoradas: pendentes.ignoradas, + watermark: pendentes.entradas.length === 0 ? pendentes.head_seq : digest.to_seq, + narrador: NARRADOR, + dry_run: opts.dryRun === true, + digest, + }; + + if (pendentes.entradas.length === 0) { + // Sem conteudo novo nao ha pagina, mas a marca d'agua avanca assim mesmo: + // senao as entradas de bookkeeping da propria consolidacao ficariam sendo + // relidas em toda corrida seguinte, para sempre. + if (opts.dryRun !== true && pendentes.head_seq > state.last_seq) { + writeConsolidationState(ctx.layout, { + _type: "psh-memory-consolidation", + version: 1, + last_seq: pendentes.head_seq, + last_slug: state.last_slug, + updated_at: (opts.now ?? new Date()).toISOString(), + }); + } + return { ...base, slug: null, path: null }; + } + + const slug = slugDoDigest(digest); + if (opts.dryRun === true) { + return { ...base, slug, path: null }; + } + + const stored = writePage( + ctx.layout, + buildPage(ctx.layout, { + title: tituloDoDigest(digest), + body: narrarDaTrilha(digest), + kind: "session", + pinned: false, + source: `core:consolidate/${NARRADOR}`, + phase: ctx.state.phase, + tags: tagsDoDigest(digest), + slug, + now: opts.now, + }), + ); + + ctx.chain.append("memory.write", ATOR_DA_CONSOLIDACAO, { + slug: stored.page.slug, + kind: stored.page.kind, + pinned: stored.page.pinned, + phase: stored.page.phase, + content_sha256: stored.content_sha256, + consolidated_from: digest.from_seq, + consolidated_to: digest.to_seq, + entries_examined: digest.entries_examined, + narrador: NARRADOR, + }); + + // A marca d'agua so avanca depois de a pagina existir e a trilha registrar. + // Na ordem inversa, uma falha no meio perderia a sessao para sempre, porque a + // faixa ja estaria marcada como consolidada. + writeConsolidationState(ctx.layout, { + _type: "psh-memory-consolidation", + version: 1, + last_seq: digest.to_seq, + last_slug: stored.page.slug, + updated_at: (opts.now ?? new Date()).toISOString(), + }); + + syncIndex(ctx.db, ctx.layout); + return { ...base, slug: stored.page.slug, path: stored.path }; +} + +export function renderConsolidate(r: ConsolidateResult): string { + if (r.slug === null) { + return ( + `nada a consolidar: a trilha esta consolidada ate a entrada ${r.watermark}` + + (r.ignoradas > 0 ? ` (${r.ignoradas} entrada(s) da propria consolidacao, que nao resume a si mesma)` : "") + ); + } + const linhas = [ + `${r.dry_run ? "consolidaria" : "consolidado"}: ${r.slug}`, + ` entradas ${r.digest.from_seq} a ${r.digest.to_seq}, ${r.entries_examined} examinada(s)`, + ` ${r.digest.fases.length} fase(s), ${r.digest.verificadores.length} verificacao(oes), ` + + `${r.digest.violacoes.length} violacao(oes), ${r.digest.decisoes.length} decisao(oes) humana(s), ` + + `${r.digest.anotacoes.length} anotacao(oes), ${r.digest.comandos.total} comando(s)`, + ]; + const naoLidas = Object.entries(r.digest.nao_classificadas); + if (naoLidas.length > 0) { + linhas.push(` nao resumidas: ${naoLidas.map(([t, n]) => `${t} (${n})`).join(", ")}`); + } + if (r.path !== null) linhas.push(` ${r.path}`); + linhas.push( + ` montado a partir da trilha, sem chamada de modelo; a narrativa por LLM (R5.2) depende do Maestro`, + ); + return linhas.join("\n"); +} + +export function search(ctx: ProjectContext, query: string, limit: number): SearchResult { + return searchMemory(ctx.db, ctx.layout, query, limit); +} + +export interface ListResult { + pages: { + slug: string; + title: string; + kind: string; + pinned: boolean; + phase: string | null; + tags: string[]; + updated_at: string; + promoted_to: string | null; + }[]; + sync: ReturnType; +} + +export function list(ctx: ProjectContext, opts: { pinnedOnly?: boolean; limit?: number } = {}): ListResult { + const sync = syncIndex(ctx.db, ctx.layout); + const pages = ctx.db.listMemoryPages(opts).map((row) => ({ + slug: row.slug, + title: row.title, + kind: row.kind, + pinned: row.pinned === 1, + phase: row.phase, + tags: JSON.parse(row.tags) as string[], + updated_at: row.updated_at, + promoted_to: row.promoted_to, + })); + return { pages, sync }; +} + +export function get(ctx: ProjectContext, slug: string): StoredPage { + return readPage(ctx.layout, slug); +} + +// --- renderizacao --------------------------------------------------------- + +export function renderList(result: ListResult): string { + if (result.pages.length === 0) { + return `nenhuma pagina de memoria (${result.sync.pages_examined} arquivo(s) examinado(s) em .harness/memory/pages/)`; + } + const linhas = result.pages.map((p) => { + const marca = p.pinned ? "*" : " "; + const promovida = p.promoted_to === null ? "" : ` -> ${p.promoted_to}`; + return `${marca} ${p.slug.padEnd(38)} ${p.kind.padEnd(9)} ${p.updated_at} ${p.title}${promovida}`; + }); + linhas.push(""); + linhas.push( + `${result.pages.length} pagina(s); indice: ${result.sync.indexed} reindexada(s), ${result.sync.unchanged} inalterada(s), ${result.sync.removed} removida(s), ${result.sync.pages_examined} arquivo(s) examinado(s)`, + ); + linhas.push(...avisosDeSync(result.sync)); + return linhas.join("\n"); +} + +export function renderSearch(result: SearchResult): string { + const linhas: string[] = []; + if (result.mode === "scan") { + linhas.push( + "MODO DEGRADADO: SQLite sem FTS5, busca por substring (sem ranking nem prefixo).", + "", + ); + } + if (result.hits.length === 0) { + linhas.push( + `nenhum resultado para ${JSON.stringify(result.query)} (${result.sync.pages_examined} pagina(s) examinada(s))`, + ); + linhas.push(...avisosDeSync(result.sync)); + return linhas.join("\n"); + } + for (const hit of result.hits) { + linhas.push(`${hit.pinned ? "*" : " "} ${hit.slug} [${hit.kind}] ${hit.updated_at}`); + linhas.push(` ${hit.title}`); + if (hit.snippet !== "") linhas.push(` ${hit.snippet}`); + if (hit.promoted_to !== null) linhas.push(` promovida para ${hit.promoted_to}`); + linhas.push(""); + } + linhas.push( + `${result.hits.length} resultado(s) por ${result.mode}; ${result.sync.pages_examined} pagina(s) examinada(s)`, + ); + linhas.push(...avisosDeSync(result.sync)); + return linhas.join("\n"); +} + +/** Pagina ilegivel some da busca: some com aviso, nunca em silencio. */ +function avisosDeSync(sync: ReturnType): string[] { + const linhas: string[] = []; + for (const problema of sync.unreadable) { + linhas.push(` ! pagina ${problema.slug} fora do indice: ${problema.message}`); + } + if (sync.skipped.length > 0) { + linhas.push(` ! ${sync.skipped.length} arquivo(s) ignorado(s) por nome invalido: ${sync.skipped.join(", ")}`); + } + return linhas; +} + +export function renderPage(stored: StoredPage): string { + return serializePage(stored.page).trimEnd(); +} diff --git a/psh/src/cli/status.ts b/psh/src/cli/status.ts index 8309c86..ea07ad5 100644 --- a/psh/src/cli/status.ts +++ b/psh/src/cli/status.ts @@ -4,6 +4,12 @@ import { detectSandbox } from "../evidence/sandbox.ts"; import { evaluateGate, type GateResult } from "../gate/evaluate.ts"; import { loadBoundary } from "../boundary/policy.ts"; +export interface BoundaryReport { + mode: "mount" | "degradado" | "indisponivel"; + agents: number; + detail: string; +} + export interface StatusReport { profile: string; phase: string | null; @@ -13,13 +19,18 @@ export interface StatusReport { max_auto_retries: number | null; status: string; sandbox: { mode: string; detail: string }; - boundary: { mode: "mount" | "degradado" | "indisponivel"; agents: number; detail: string }; + boundary: BoundaryReport; gate: GateResult | null; audit: { ok: boolean; entries: number; problems: number }; history: { phase: string; attempt: number; verdict: string; at: string }[]; } -function descreverFronteira(ctx: ProjectContext, modo: string): StatusReport["boundary"] { +/** + * Fonte unica do estado da fronteira. O adapter `ci` chama esta mesma funcao: + * duas declaracoes paralelas do mesmo fato foi como o relatorio de CI passou uma + * versao inteira dizendo `absent` enquanto o `status` dizia `mount`. + */ +export function descreverFronteira(ctx: ProjectContext, modo: string): BoundaryReport { try { const policy = loadBoundary(ctx.layout); return modo === "ai-jail" diff --git a/psh/src/db/index.ts b/psh/src/db/index.ts index fc5c91b..85034c3 100644 --- a/psh/src/db/index.ts +++ b/psh/src/db/index.ts @@ -62,6 +62,41 @@ CREATE TABLE IF NOT EXISTS audit_anchor ( head_hash TEXT NOT NULL, updated_at TEXT NOT NULL ); + +-- R5.3: indice das paginas de memoria. O registro canonico e o arquivo em +-- .harness/memory/pages/, e content_sha256 e o que decide se a linha ainda +-- vale. Este indice pode ser apagado e reconstruido sem perda. +CREATE TABLE IF NOT EXISTS memory_pages ( + slug TEXT PRIMARY KEY, + title TEXT NOT NULL, + kind TEXT NOT NULL, + pinned INTEGER NOT NULL DEFAULT 0, + phase TEXT, + tags TEXT NOT NULL DEFAULT '[]', + source TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + promoted_to TEXT, + content_sha256 TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS memory_pages_updated ON memory_pages(pinned DESC, updated_at DESC); +`; + +/** + * R5.3: a busca e FTS5. + * + * Fica fora do SCHEMA principal porque FTS5 e modulo de compilacao do SQLite e + * pode nao existir no binario em uso. Ausencia vira modo degradado declarado + * (`mode: "scan"` na resposta da busca e um aviso no `psh doctor`), nunca um + * comando que morre no meio nem uma busca que responde menos sem avisar. + */ +const FTS_SCHEMA = ` +CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5( + slug UNINDEXED, + text, + tokenize = 'unicode61 remove_diacritics 2' +); `; export interface EventRow { @@ -102,13 +137,39 @@ export interface AttemptRow { updated_at: string; } +export interface MemoryPageRow { + slug: string; + title: string; + kind: string; + pinned: number; + phase: string | null; + tags: string; + source: string; + created_at: string; + updated_at: string; + promoted_to: string | null; + content_sha256: string; + body: string; +} + export class HarnessDb implements AnchorStore { readonly db: Database; + /** Falso quando o SQLite em uso nao traz FTS5. Vira modo `scan` na busca. */ + readonly ftsAvailable: boolean; + readonly ftsUnavailableReason: string | null; constructor(path: string) { if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true }); this.db = new Database(path, { create: true }); this.db.run(SCHEMA); + try { + this.db.run(FTS_SCHEMA); + this.ftsAvailable = true; + this.ftsUnavailableReason = null; + } catch (cause) { + this.ftsAvailable = false; + this.ftsUnavailableReason = (cause as Error).message; + } } close(): void { @@ -199,6 +260,112 @@ export class HarnessDb implements AnchorStore { .run(row.phase, row.attempt, row.retries_used, row.updated_at); } + // --- memoria (R5.3) ---------------------------------------------------- + + /** Slug -> hash do arquivo indexado. E a base da decisao de frescor. */ + memoryHashes(): Map { + const rows = this.db.query(`SELECT slug, content_sha256 FROM memory_pages`).all() as { + slug: string; + content_sha256: string; + }[]; + return new Map(rows.map((r) => [r.slug, r.content_sha256])); + } + + /** + * `row.body` e o corpo da pagina, usado no trecho da resposta; `ftsText` e o + * texto indexavel (titulo, tags e corpo). Sao coisas diferentes: quem guarda + * o texto indexavel na coluna de corpo devolve o titulo duplicado no trecho. + */ + upsertMemoryPage(row: MemoryPageRow, ftsText: string): void { + this.db.transaction(() => { + this.db + .query( + `INSERT INTO memory_pages + (slug, title, kind, pinned, phase, tags, source, created_at, updated_at, promoted_to, content_sha256, body) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(slug) DO UPDATE SET + title = excluded.title, kind = excluded.kind, pinned = excluded.pinned, + phase = excluded.phase, tags = excluded.tags, source = excluded.source, + created_at = excluded.created_at, updated_at = excluded.updated_at, + promoted_to = excluded.promoted_to, content_sha256 = excluded.content_sha256, + body = excluded.body`, + ) + .run( + row.slug, + row.title, + row.kind, + row.pinned, + row.phase, + row.tags, + row.source, + row.created_at, + row.updated_at, + row.promoted_to, + row.content_sha256, + row.body, + ); + if (this.ftsAvailable) { + this.db.query(`DELETE FROM memory_fts WHERE slug = ?`).run(row.slug); + this.db.query(`INSERT INTO memory_fts (slug, text) VALUES (?, ?)`).run(row.slug, ftsText); + } + })(); + } + + deleteMemoryPage(slug: string): void { + this.db.transaction(() => { + this.db.query(`DELETE FROM memory_pages WHERE slug = ?`).run(slug); + if (this.ftsAvailable) this.db.query(`DELETE FROM memory_fts WHERE slug = ?`).run(slug); + })(); + } + + countMemoryPages(opts: { pinnedOnly?: boolean } = {}): number { + const where = opts.pinnedOnly === true ? `WHERE pinned = 1` : ``; + return (this.db.query(`SELECT count(*) AS n FROM memory_pages ${where}`).get() as { n: number }).n; + } + + listMemoryPages(opts: { pinnedOnly?: boolean; limit?: number } = {}): MemoryPageRow[] { + const limit = opts.limit ?? 100; + const where = opts.pinnedOnly === true ? `WHERE pinned = 1` : ``; + return this.db + .query(`SELECT * FROM memory_pages ${where} ORDER BY pinned DESC, updated_at DESC LIMIT ?`) + .all(limit) as MemoryPageRow[]; + } + + /** + * Fixada primeiro, relevancia depois: R5.5 diz que a pagina fixada nao pode + * ser perdida, e ser empurrada para fora do limite da busca e uma forma de + * perder. + */ + searchMemoryFts(matchExpression: string, limit: number): MemoryPageRow[] { + return this.db + .query( + `SELECT p.* FROM memory_fts f + JOIN memory_pages p ON p.slug = f.slug + WHERE memory_fts MATCH ? + ORDER BY p.pinned DESC, bm25(memory_fts) + LIMIT ?`, + ) + .all(matchExpression, limit) as MemoryPageRow[]; + } + + /** Modo degradado: varredura por substring, sem stemming e sem ranking. */ + searchMemoryScan(query: string, limit: number): MemoryPageRow[] { + const termos = query + .split(/\s+/) + .map((t) => t.trim().toLowerCase()) + .filter((t) => t !== ""); + if (termos.length === 0) return []; + const condicoes = termos.map(() => `instr(lower(p.title || ' ' || p.tags || ' ' || p.body), ?) > 0`); + return this.db + .query( + `SELECT p.* FROM memory_pages p + WHERE ${condicoes.join(" AND ")} + ORDER BY p.pinned DESC, p.updated_at DESC + LIMIT ?`, + ) + .all(...termos, limit) as MemoryPageRow[]; + } + // --- ancora da trilha -------------------------------------------------- readAnchor(): AuditAnchor | null { diff --git a/psh/src/evidence/runner.ts b/psh/src/evidence/runner.ts index fcfbcc5..15e4b36 100644 --- a/psh/src/evidence/runner.ts +++ b/psh/src/evidence/runner.ts @@ -164,7 +164,7 @@ export function runVerifier(opts: RunVerifierOptions): RunVerifierResult { manifest: relToProject(layout, slot.manifestPath), stdout: run.stdout, stderr: run.stderr, - manifestFiles: after.files, + workspace: after, layout, slot, }; @@ -343,7 +343,7 @@ interface FinishArgs { manifest: string | null; stdout: string; stderr: string; - manifestFiles?: Record; + workspace?: WorkspaceManifest; error: { reason: EvidenceErrorReason; message: string } | null; layout: Layout; slot: EvidenceSlot; @@ -371,7 +371,7 @@ function finish(args: FinishArgs): EvidenceRecord { candidates_examined: args.candidates_examined, enumeration: args.enumeration, artifact: args.artifact, - manifest: args.manifestFiles === undefined ? null : args.manifest, + manifest: args.workspace === undefined ? null : args.manifest, stdout_sha256: `sha256:${sha256(args.stdout)}`, stderr_sha256: `sha256:${sha256(args.stderr)}`, sandbox: args.sandbox, @@ -381,6 +381,6 @@ function finish(args: FinishArgs): EvidenceRecord { return writeEvidence(args.layout, args.slot, record, { stdout: args.stdout, stderr: args.stderr, - manifestFiles: args.manifestFiles ?? null, + workspace: args.workspace ?? null, }); } diff --git a/psh/src/evidence/sandbox.ts b/psh/src/evidence/sandbox.ts index ebd72e4..82a863c 100644 --- a/psh/src/evidence/sandbox.ts +++ b/psh/src/evidence/sandbox.ts @@ -150,7 +150,17 @@ export function buildArgv(req: RunRequest, status: SandboxStatus): { argv: strin } // R3.4: defaults do ai-jail preservados. Credencial de agente nunca montada; // rede so quando o verificador declara que precisa (R2.7). - const jailArgs = ["--no-agent-state", "--no-docker", "--no-ssh"]; + // + // `--clean` e `--no-save-config` nao sao preferencia, sao a diferenca entre o + // contrato valer e nao valer. Sem eles o ai-jail grava a corrida atual no + // `.ai-jail` do projeto e le esse arquivo na corrida seguinte, e como config + // de projeto e politica monotonica (so restringe, nunca libera), o que a + // corrida anterior gravou desliga capacidade que esta corrida declarou. Na + // pratica o `--network` chegava uma corrida atrasado: o verificador com + // `network: true` rodava sem rede logo depois de um sem rede, o comando + // falhava por conexao em vez de por sandbox, e a metrica desse relatorio + // quebrado ainda assim virava valor de portao. Achado no Campo 01. + const jailArgs = ["--clean", "--no-save-config", "--no-agent-state", "--no-docker", "--no-ssh"]; if (req.network) jailArgs.push("--network"); else jailArgs.push("--no-network"); return { argv: [status.jail_bin, ...jailArgs, "--", ...req.argv], wrapped: true }; diff --git a/psh/src/evidence/store.ts b/psh/src/evidence/store.ts index ed3248a..082eba0 100644 --- a/psh/src/evidence/store.ts +++ b/psh/src/evidence/store.ts @@ -5,6 +5,7 @@ import { toRel } from "../util/paths.ts"; import { readJsonFile, writeJsonAtomic } from "../util/json.ts"; import { ContractError } from "../util/errors.ts"; import { formatAjvErrors, validateEvidenceSchema } from "../workflow/load.ts"; +import type { WorkspaceManifest } from "./workspace.ts"; export const ADHOC_PHASE = "_adhoc"; @@ -80,7 +81,7 @@ export function writeEvidence( layout: Layout, slot: EvidenceSlot, record: EvidenceRecord, - extras: { stdout: string; stderr: string; manifestFiles: Record | null }, + extras: { stdout: string; stderr: string; workspace: WorkspaceManifest | null }, ): EvidenceRecord { if (!validateEvidenceSchema(record as unknown)) { throw new ContractError( @@ -91,13 +92,20 @@ export function writeEvidence( mkdirSync(slot.dir, { recursive: true }); writeFileSync(slot.stdoutPath, extras.stdout, { mode: 0o644 }); writeFileSync(slot.stderrPath, extras.stderr, { mode: 0o644 }); - if (extras.manifestFiles !== null) { + if (extras.workspace !== null) { + // `harness_artifacts` fica gravado junto dos arquivos medidos porque a + // exclusao do artefato do proprio harness e decisao de medicao, e decisao de + // medicao que nao aparece no registro nao pode ser auditada depois. writeJsonAtomic(slot.manifestPath, { _type: "psh-workspace-manifest", version: 1, verifier: record.verifier, workspace_hash: record.workspace_hash, - files: extras.manifestFiles, + harness_artifacts: { + skipped: extras.workspace.harness_artifacts_skipped, + excluded: extras.workspace.harness_artifacts_excluded, + }, + files: extras.workspace.files, }); } writeJsonAtomic(slot.recordPath, record); @@ -125,6 +133,8 @@ export interface StoredManifest { version: 1; verifier: string; workspace_hash: string | null; + /** Ausente nos manifestos gravados antes da v0.3.1. */ + harness_artifacts?: { skipped: number; excluded: readonly string[] }; files: Record; } diff --git a/psh/src/evidence/workspace.ts b/psh/src/evidence/workspace.ts index cb6b47a..70bef43 100644 --- a/psh/src/evidence/workspace.ts +++ b/psh/src/evidence/workspace.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { spawnSync } from "node:child_process"; import { sha256 } from "../util/hash.ts"; import { compileGlobs, normalizeRel } from "../util/globs.ts"; +import { HARNESS_RUNTIME_PATHS } from "../util/paths.ts"; /** Separador de `git ls-files -z`. */ const NUL = String.fromCharCode(0); @@ -26,6 +27,14 @@ export interface WorkspaceManifest { enumeration: Enumeration; files: Record; hash: string; + /** + * Artefato do proprio harness descartado antes de casar com `watch`, contado e + * nomeado. A exclusao existe para o observador nao se medir, mas ela precisa + * aparecer no registro: exclusao silenciosa e como um arquivo deixa de ser + * visto sem ninguem perceber. + */ + harness_artifacts_skipped: number; + harness_artifacts_excluded: readonly string[]; } export interface GitProbe { @@ -59,14 +68,6 @@ export interface HashOptions { const WALK_SKIP_DIRS = new Set([".git", "node_modules", ".venv", "target", "dist", "coverage"]); -/** Caminhos internos do harness que nunca entram no calculo de frescor. */ -const ALWAYS_EXCLUDED_PREFIXES = [ - ".harness/evidence/", - ".harness/audit/", - ".harness/harness.db", - ".harness/state.json", -]; - /** * R2.4: hash da arvore dos paths observados. * Arquivos rastreados e nao rastreados, respeitando `.gitignore` quando o @@ -95,9 +96,13 @@ export function hashWorkspace(root: string, opts: HashOptions): WorkspaceManifes const perPattern = new Map(include.patterns.map((p) => [p, 0])); const files: Record = {}; let matched = 0; + let harness_artifacts_skipped = 0; for (const rel of candidates) { - if (isAlwaysExcluded(rel)) continue; + if (isHarnessRuntime(rel)) { + harness_artifacts_skipped += 1; + continue; + } const pattern = include.matchedBy(rel); if (pattern === null) continue; if (exclude?.matches(rel)) continue; @@ -129,6 +134,8 @@ export function hashWorkspace(root: string, opts: HashOptions): WorkspaceManifes enumeration, files, hash: `sha256:${sha256(lines)}`, + harness_artifacts_skipped, + harness_artifacts_excluded: HARNESS_RUNTIME_PATHS, }; } @@ -157,14 +164,39 @@ export function diffManifests( return { changed: changed.sort(), added: added.sort(), removed: removed.sort() }; } -function isAlwaysExcluded(rel: string): boolean { - for (const prefix of ALWAYS_EXCLUDED_PREFIXES) { +/** + * Comparacao por prefixo declarado, nao por "comeca com `.harness/`": o contrato + * e os documentos de fase moram no mesmo diretorio e continuam observaveis. + */ +function isHarnessRuntime(rel: string): boolean { + for (const prefix of HARNESS_RUNTIME_PATHS) { if (rel === prefix || rel.startsWith(prefix)) return true; } return false; } +/** + * Quem responde se o projeto esta sob Git e o proprio Git, nao a presenca de um + * `.git/` na raiz. + * + * `existsSync(join(root, ".git"))` so acerta o caso do projeto que e a raiz do + * repositorio. Todo app dentro de um repositorio maior, que e o layout de + * qualquer monorepo, caia para caminhada: o `.gitignore` deixava de valer para o + * hash da arvore, e entravam nele o `.env` com chave real, o diretorio de + * relatorio e ate binario vendorizado. O `psh doctor` ainda declarava isso como + * `[ok] enumeracao por caminhada`, com o detalhe "projeto nao e repositorio + * Git", que era falso. Achado 4 do Campo 01. + * + * O fallback pelo diretorio continua existindo para um caso so: o `git` nao + * respondeu. Ai a resposta honesta e "ha `.git/` e nao consigo perguntar", que e + * o que mantem o `walk-fallback` visivel em vez de virar caminhada silenciosa. + */ export function isGitRepo(root: string): boolean { + const res = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], { + cwd: root, + encoding: "utf8", + }); + if (!res.error && res.status === 0) return res.stdout.trim() === "true"; return existsSync(join(root, ".git")); } diff --git a/psh/src/index.ts b/psh/src/index.ts index d336a4b..9aa7fcd 100644 --- a/psh/src/index.ts +++ b/psh/src/index.ts @@ -6,7 +6,7 @@ import { EXIT, PshError, type ExitCode } from "./util/errors.ts"; import { writeJsonAtomic } from "./util/json.ts"; import { sha256 } from "./util/hash.ts"; import { selfArgv } from "./util/self.ts"; -import { flagBool, flagString, parseArgs, rejectUnknownFlags, type ParsedArgs } from "./cli/args.ts"; +import { flagBool, flagInt, flagString, parseArgs, rejectUnknownFlags, type ParsedArgs } from "./cli/args.ts"; import { io } from "./cli/io.ts"; import { openProject } from "./cli/context.ts"; import { applyPlan, buildPlan, detectStack, ensureNotNested, renderPlan } from "./cli/init.ts"; @@ -15,7 +15,31 @@ import { buildStatus, renderStatus } from "./cli/status.ts"; import { renderDoctor, runDoctor } from "./cli/doctor.ts"; import { runSpecCoverage } from "./cli/spec-coverage.ts"; import { renderCi, runCi } from "./adapters/ci.ts"; +import { conferirContrato, renderContrato } from "./adapters/claude-code/contract.ts"; +import { handleHook } from "./adapters/claude-code/hook.ts"; +import { + desinstalar, + instalar, + renderInstalacao, + renderStatusDoAdapter, + statusDoAdapter, +} from "./adapters/claude-code/install.ts"; import { addWriteGlob, boundaryOf, checkPath, renderBoundaryList, renderExec, runExec } from "./cli/boundary.ts"; +import { + consolidate as consolidateMemory, + get as getMemoryPage, + list as listMemory, + promote as promoteMemoryPage, + remember, + renderConsolidate as renderMemoryConsolidate, + renderList as renderMemoryList, + renderPage as renderMemoryPage, + renderSearch as renderMemorySearch, + search as searchMemoryPages, +} from "./cli/memory.ts"; +import { syncIndex as syncMemoryIndex } from "./memory/search.ts"; +import { buildHandoff, renderHandoff } from "./memory/handoff.ts"; +import type { PageKind } from "./memory/page.ts"; import { advance } from "./workflow/advance.ts"; import { approvalPath, assertNoForgedMetrics, type ApprovalRecord } from "./gate/evaluate.ts"; import { DENY_ALWAYS } from "./boundary/policy.ts"; @@ -28,11 +52,17 @@ const USAGE = `psh ${PSH_VERSION} - ProStaff Harness (nucleo verificavel) psh verify [...] [--all] [--json] psh advance [--force] [--reason ] [--yes] [--json] psh approve [--as ] - psh audit verify|log [-n ] [--json] + psh audit verify|log [--n ] [--json] + psh audit reanchor --reason [--as ] [--json] psh doctor [--json] psh boundary list|check |add [--agent ] [--json] psh exec --agent [--timeout ] -- + psh remember "" [--title ] [--tags a,b] [--kind ] + psh memory list|search |get |promote |consolidate|reindex + [--pinned] [--n ] [--to ] [--force] [--json] + psh handoff [--json] [--n ] psh adapter ci [--json] [--gate-only] [--skip-verify] + psh adapter claude-code install|uninstall|status|contract|hook [--json] [--dry-run] psh internal spec-coverage --spec --tasks [--out ] Valor de portao vem sempre de registro de evidencia produzido por 'psh verify'. @@ -75,8 +105,14 @@ export async function main(argv: string[]): Promise { return cmdBoundary(args); case "exec": return cmdExec(args); + case "remember": + return cmdRemember(args); + case "memory": + return cmdMemory(args); + case "handoff": + return cmdHandoff(args); case "adapter": - return cmdAdapter(args); + return await cmdAdapter(args); case "internal": return cmdInternal(args); default: @@ -233,7 +269,7 @@ function cmdApprove(args: ParsedArgs): ExitCode { } function cmdAudit(args: ParsedArgs): ExitCode { - rejectUnknownFlags(args, ["json", "n", "root"], "audit"); + rejectUnknownFlags(args, ["json", "n", "root", "reason", "as"], "audit"); const sub = args.positional[0] ?? "verify"; const ctx = openProject(flagString(args, "root") ?? undefined); try { @@ -253,7 +289,7 @@ function cmdAudit(args: ParsedArgs): ExitCode { return result.ok ? EXIT.OK : EXIT.AUDIT_BROKEN; } if (sub === "log") { - const limit = Number(flagString(args, "n") ?? "20"); + const limit = flagInt(args, "n", 20); const entries = ctx.chain.read().slice(-limit); if (flagBool(args, "json")) { io().out(`${JSON.stringify(entries, null, 2)}\n`); @@ -264,7 +300,36 @@ function cmdAudit(args: ParsedArgs): ExitCode { } return EXIT.OK; } - throw new PshError(`subcomando desconhecido: psh audit ${sub}. Use 'verify' ou 'log'.`, { + if (sub === "reanchor") { + // O motivo e obrigatorio de propria natureza: reancorar e admitir que a + // trilha e a ancora discordaram, e o valor do comando esta em deixar + // escrito por que se decidiu seguir a partir do arquivo, e nao em + // silenciar o alarme. + const reason = flagString(args, "reason"); + if (reason === null || reason.trim() === "") { + throw new PshError( + "uso: psh audit reanchor --reason \"por que a trilha e a ancora divergiram e por que seguir a partir do arquivo atual\"", + { exitCode: EXIT.FAILURE }, + ); + } + const antes = ctx.chain.verify(); + const quem = flagString(args, "as") ?? process.env.USER ?? "human"; + const { entry, anchorBefore } = ctx.chain.reanchor(reason, `human:${quem}`); + const depois = ctx.chain.verify(); + if (flagBool(args, "json")) { + io().out(`${JSON.stringify({ reanchored: true, reason, by: quem, entry, before: antes, after: depois }, null, 2)}\n`); + } else { + io().out( + `reancorada por ${quem}: a ancora dizia ${anchorBefore?.count ?? 0} entrada(s) com topo ${anchorBefore?.head_hash ?? "-"}, ` + + `e o arquivo tinha ${antes.entries}.\n` + + `A divergencia ficou registrada na trilha como entrada ${entry.seq} (audit.note), com o motivo.\n` + + `motivo: ${reason}\n` + + `${depois.ok ? "cadeia integra" : "CADEIA AINDA COMPROMETIDA"}: ${depois.entries} entradas, topo ${depois.head_hash}\n`, + ); + } + return depois.ok ? EXIT.OK : EXIT.AUDIT_BROKEN; + } + throw new PshError(`subcomando desconhecido: psh audit ${sub}. Use 'verify', 'log' ou 'reanchor'.`, { exitCode: EXIT.FAILURE, }); } finally { @@ -366,10 +431,143 @@ function cmdExec(args: ParsedArgs): ExitCode { } } -function cmdAdapter(args: ParsedArgs): ExitCode { +function cmdRemember(args: ParsedArgs): ExitCode { + rejectUnknownFlags(args, ["title", "tags", "kind", "json", "root"], "remember"); + const fato = args.positional.join(" ").trim(); + if (fato === "") { + throw new PshError('uso: psh remember ""', { exitCode: EXIT.FAILURE }); + } + const ctx = openProject(flagString(args, "root") ?? undefined); + try { + const kind = flagString(args, "kind") ?? "fact"; + if (!MEMORY_KINDS.includes(kind)) { + throw new PshError(`tipo de memoria desconhecido: ${kind}. Use ${MEMORY_KINDS.join(", ")}.`, { + exitCode: EXIT.CONTRACT_INVALID, + }); + } + const stored = remember(ctx, { + fact: fato, + title: flagString(args, "title") ?? undefined, + tags: parseTagList(flagString(args, "tags")), + kind: kind as PageKind, + }); + io().out( + flagBool(args, "json") + ? `${JSON.stringify({ slug: stored.page.slug, path: stored.path, content_sha256: stored.content_sha256 }, null, 2)}\n` + : `anotado e fixado: ${stored.page.slug}\n ${stored.path}\n`, + ); + return EXIT.OK; + } finally { + ctx.close(); + } +} + +const MEMORY_KINDS = ["fact", "decision", "verifier", "session", "prompt", "note"]; + +function parseTagList(raw: string | null): string[] { + if (raw === null) return []; + return raw + .split(",") + .map((t) => t.trim()) + .filter((t) => t !== ""); +} + +function cmdMemory(args: ParsedArgs): ExitCode { + rejectUnknownFlags(args, ["json", "root", "n", "pinned", "to", "force", "dry-run"], "memory"); + const sub = args.positional[0] ?? "list"; + const ctx = openProject(flagString(args, "root") ?? undefined); + try { + const json = flagBool(args, "json"); + + if (sub === "list") { + const result = listMemory(ctx, { + pinnedOnly: flagBool(args, "pinned"), + limit: flagInt(args, "n", 100), + }); + io().out(json ? `${JSON.stringify(result, null, 2)}\n` : `${renderMemoryList(result)}\n`); + return EXIT.OK; + } + + if (sub === "search") { + const consulta = args.positional.slice(1).join(" ").trim(); + if (consulta === "") throw new PshError("uso: psh memory search ", { exitCode: EXIT.FAILURE }); + const result = searchMemoryPages(ctx, consulta, flagInt(args, "n", 10)); + io().out(json ? `${JSON.stringify(result, null, 2)}\n` : `${renderMemorySearch(result)}\n`); + // Busca sem resultado nao e erro: e resposta. + return EXIT.OK; + } + + if (sub === "get") { + const slug = args.positional[1]; + if (slug === undefined) throw new PshError("uso: psh memory get ", { exitCode: EXIT.FAILURE }); + const stored = getMemoryPage(ctx, slug); + io().out( + json + ? `${JSON.stringify({ ...stored.page, path: stored.path, content_sha256: stored.content_sha256 }, null, 2)}\n` + : `${renderMemoryPage(stored)}\n`, + ); + return EXIT.OK; + } + + if (sub === "promote") { + const slug = args.positional[1]; + if (slug === undefined) throw new PshError("uso: psh memory promote [--to ]", { exitCode: EXIT.FAILURE }); + const result = promoteMemoryPage(ctx, slug, { + to: flagString(args, "to"), + force: flagBool(args, "force"), + }); + io().out( + json + ? `${JSON.stringify(result, null, 2)}\n` + : `promovida: ${result.slug}\n ${result.from} -> ${result.to} (${result.bytes} bytes)\n a pagina continua existindo e agora aponta para o destino.\n`, + ); + return EXIT.OK; + } + + if (sub === "consolidate") { + const result = consolidateMemory(ctx, { dryRun: flagBool(args, "dry-run") }); + io().out(json ? `${JSON.stringify(result, null, 2)}\n` : `${renderMemoryConsolidate(result)}\n`); + return EXIT.OK; + } + + if (sub === "reindex") { + const sync = syncMemoryIndex(ctx.db, ctx.layout); + io().out( + json + ? `${JSON.stringify(sync, null, 2)}\n` + : `indice: ${sync.indexed} reindexada(s), ${sync.unchanged} inalterada(s), ${sync.removed} removida(s), ${sync.pages_examined} arquivo(s) examinado(s)\n`, + ); + return sync.unreadable.length > 0 ? EXIT.FAILURE : EXIT.OK; + } + + throw new PshError( + `subcomando desconhecido: psh memory ${sub}. Use list, search, get, promote, consolidate ou reindex.`, + { exitCode: EXIT.FAILURE }, + ); + } finally { + ctx.close(); + } +} + +function cmdHandoff(args: ParsedArgs): ExitCode { + rejectUnknownFlags(args, ["json", "root", "n"], "handoff"); + const ctx = openProject(flagString(args, "root") ?? undefined); + try { + const handoff = buildHandoff(ctx, { limit: flagInt(args, "n", 5) }); + io().out( + flagBool(args, "json") ? `${JSON.stringify(handoff, null, 2)}\n` : `${renderHandoff(handoff)}\n`, + ); + return EXIT.OK; + } finally { + ctx.close(); + } +} + +async function cmdAdapter(args: ParsedArgs): Promise { const sub = args.positional[0]; + if (sub === "claude-code") return cmdAdapterClaudeCode(args); if (sub !== "ci") { - throw new PshError(`adapter desconhecido: ${sub ?? "(nenhum)"}. Disponivel na v0.1: ci`, { + throw new PshError(`adapter desconhecido: ${sub ?? "(nenhum)"}. Disponiveis: ci, claude-code`, { exitCode: EXIT.FAILURE, }); } @@ -390,6 +588,106 @@ function cmdAdapter(args: ParsedArgs): ExitCode { } } +/** + * R8.2: o adapter do Claude Code. + * + * `hook` e o ponto que o runtime chama; os outros sao para o humano. + */ +async function cmdAdapterClaudeCode(args: ParsedArgs): Promise { + rejectUnknownFlags(args, ["json", "root", "dry-run", "timeout"], "adapter claude-code"); + const acao = args.positional[1] ?? "status"; + const json = flagBool(args, "json"); + + if (acao === "hook") return await cmdHook(args); + + if (acao === "contract") { + const relatorio = conferirContrato(); + io().out(json ? `${JSON.stringify(relatorio, null, 2)}\n` : `${renderContrato(relatorio)}\n`); + return relatorio.ok ? EXIT.OK : EXIT.CONTRACT_INVALID; + } + + const ctx = openProject(flagString(args, "root") ?? undefined); + try { + if (acao === "install") { + const r = instalar(ctx.layout, { + selfArgv: selfArgv(), + timeout_s: flagString(args, "timeout") === null ? undefined : flagInt(args, "timeout", 30), + dryRun: flagBool(args, "dry-run"), + }); + io().out(json ? `${JSON.stringify(r, null, 2)}\n` : `${renderInstalacao(r)}\n`); + return EXIT.OK; + } + if (acao === "uninstall") { + const r = desinstalar(ctx.layout); + io().out( + json + ? `${JSON.stringify(r, null, 2)}\n` + : `${r.removidos} hook(s) do psh removido(s) de ${r.settings}; ${r.preservados} de terceiros preservado(s)\n`, + ); + return EXIT.OK; + } + if (acao === "status") { + const s = statusDoAdapter(ctx.layout); + io().out(json ? `${JSON.stringify(s, null, 2)}\n` : `${renderStatusDoAdapter(s)}\n`); + return s.completo ? EXIT.OK : EXIT.FAILURE; + } + throw new PshError( + `acao desconhecida: psh adapter claude-code ${acao}. Use install, uninstall, status, contract ou hook.`, + { exitCode: EXIT.FAILURE }, + ); + } finally { + ctx.close(); + } +} + +/** + * O hook le o payload em stdin e devolve JSON em stdout. + * + * Falha aqui nunca derruba a sessao de quem esta trabalhando: o motivo vai para + * stderr, que o runtime mostra em modo verboso, e o codigo de saida e zero. + */ +async function cmdHook(args: ParsedArgs): Promise { + const bruto = await lerStdin(); + let payload: unknown = null; + try { + payload = bruto.trim() === "" ? null : JSON.parse(bruto); + } catch (cause) { + io().err(`psh hook: payload nao e JSON valido: ${(cause as Error).message}\n`); + return EXIT.OK; + } + const resultado = handleHook(payload); + if (resultado.output !== null) io().out(`${JSON.stringify(resultado.output)}\n`); + if (resultado.nota !== "") io().err(`psh hook: ${resultado.nota}\n`); + void args; + return resultado.exitCode as ExitCode; +} + +/** + * Leitura de stdin por descritor, e nao pela API de stream. + * + * `Bun.stdin.stream()` falha com EPERM sob confinamento (o bun instalado por + * snap, por exemplo), e um hook que morre ao ler o proprio payload derruba a + * sessao de quem esta trabalhando. `readSync` no descritor 0 funciona nos dois + * casos e nao depende de nada alem do kernel. + */ +async function lerStdin(): Promise { + const pedacos: Buffer[] = []; + const buffer = Buffer.allocUnsafe(64 * 1024); + for (;;) { + let lidos: number; + try { + lidos = readSync(0, buffer, 0, buffer.length, null); + } catch (cause) { + const erro = cause as NodeJS.ErrnoException; + if (erro.code === "EOF" || erro.code === "EAGAIN") break; + throw cause; + } + if (lidos <= 0) break; + pedacos.push(Buffer.from(buffer.subarray(0, lidos))); + } + return Buffer.concat(pedacos).toString("utf8"); +} + function cmdInternal(args: ParsedArgs): ExitCode { const sub = args.positional[0]; if (sub !== "spec-coverage") { diff --git a/psh/src/memory/consolidate.ts b/psh/src/memory/consolidate.ts new file mode 100644 index 0000000..7d2cc91 --- /dev/null +++ b/psh/src/memory/consolidate.ts @@ -0,0 +1,521 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import consolidationSchema from "../../schemas/memory-consolidation.schema.json" with { type: "json" }; +import type { AuditChain, AuditEntry } from "../audit/chain.ts"; +import { AuditError, ContractError } from "../util/errors.ts"; +import { readJsonFile, writeJsonAtomic } from "../util/json.ts"; +import { lazyValidator } from "../util/schema.ts"; +import type { Layout } from "../util/paths.ts"; +import { formatAjvErrors } from "../workflow/load.ts"; + +export const validateConsolidationSchema = lazyValidator(consolidationSchema); + +export interface ConsolidationState { + _type: "psh-memory-consolidation"; + version: 1; + last_seq: number; + last_slug: string | null; + updated_at: string; +} + +export function consolidationStatePath(layout: Layout): string { + return join(layout.memoryDir, "consolidation.json"); +} + +/** + * A marca d'agua mora em disco, nao no SQLite. + * + * O indice e descartavel por construcao; se a marca morasse nele, apagar o + * indice faria a proxima consolidacao varrer a trilha inteira de novo e + * despejar meses de historico numa pagina so. + */ +export function readConsolidationState(layout: Layout): ConsolidationState { + const path = consolidationStatePath(layout); + if (!existsSync(path)) { + return { + _type: "psh-memory-consolidation", + version: 1, + last_seq: 0, + last_slug: null, + updated_at: new Date(0).toISOString(), + }; + } + const raw = readJsonFile(path); + if (!validateConsolidationSchema(raw)) { + throw new ContractError( + `${path} invalido:\n - ${formatAjvErrors(validateConsolidationSchema.errors).join("\n - ")}`, + { path }, + ); + } + return raw as unknown as ConsolidationState; +} + +export function writeConsolidationState(layout: Layout, state: ConsolidationState): void { + writeJsonAtomic(consolidationStatePath(layout), state); +} + +// --- digest --------------------------------------------------------------- + +export interface FaseNoDigest { + from: string | null; + to: string | null; + attempt: number; + verdict: string; + passed: boolean; + override: boolean; + reason: string | null; + seq: number; +} + +export interface VerificadorNoDigest { + verifier: string; + phase: string | null; + status: string; + value: number | null; + exit_code: number | null; + error: string | null; + seq: number; +} + +export interface AnotacaoNoDigest { + slug: string; + kind: string; + pinned: boolean; + promoted_to: string | null; + seq: number; +} + +export interface ViolacaoNoDigest { + path: string; + action: string; + agent: string; + seq: number; +} + +export interface DecisaoHumanaNoDigest { + tipo: "aprovacao" | "override" | "allowlist"; + quem: string; + assunto: string; + motivo: string | null; + seq: number; +} + +export interface MarcoNoDigest { + descricao: string; + seq: number; +} + +export interface PedidoNoDigest { + texto: string | null; + chars: number; + truncated: boolean; + redacted: boolean; + seq: number; +} + +export interface SessionDigest { + /** Faixa de entradas da trilha que virou esta pagina. */ + from_seq: number; + to_seq: number; + started_at: string; + finished_at: string; + /** R2.13: quantas entradas foram examinadas, nao so as que viraram linha. */ + entries_examined: number; + marcos: MarcoNoDigest[]; + pedidos: PedidoNoDigest[]; + fases: FaseNoDigest[]; + verificadores: VerificadorNoDigest[]; + anotacoes: AnotacaoNoDigest[]; + violacoes: ViolacaoNoDigest[]; + decisoes: DecisaoHumanaNoDigest[]; + comandos: { total: number; falharam: number; alertas: string[] }; + /** Tipos de entrada que nenhuma seção soube ler, contados por tipo. */ + nao_classificadas: Record; +} + +const texto = (valor: unknown): string | null => (typeof valor === "string" ? valor : null); +const numero = (valor: unknown): number | null => (typeof valor === "number" ? valor : null); + +/** + * R5.2, lado da captura: a trilha **e** a captura. + * + * Decisao de fase, resultado de verificador e anotacao ja entram nela por + * R4.3, encadeados por hash e conferiveis por `psh audit verify`. Guardar uma + * segunda copia dos mesmos fatos num buffer paralelo criaria duas versoes da + * mesma sessao, e a segunda nao teria como provar que e verdadeira. + * + * O que falta e prompt do usuario, que so o adapter enxerga (R5.1, R8.2). + */ +export function buildDigest(entries: readonly AuditEntry[]): SessionDigest { + const digest: SessionDigest = { + from_seq: entries[0]?.seq ?? 0, + to_seq: entries.at(-1)?.seq ?? 0, + started_at: entries[0]?.ts ?? "", + finished_at: entries.at(-1)?.ts ?? "", + entries_examined: entries.length, + marcos: [], + pedidos: [], + fases: [], + verificadores: [], + anotacoes: [], + violacoes: [], + decisoes: [], + comandos: { total: 0, falharam: 0, alertas: [] }, + nao_classificadas: {}, + }; + + for (const entry of entries) { + const p = entry.payload; + switch (entry.type) { + case "phase.transition": + case "human.override": { + const gate = p.gate as { passed?: boolean } | undefined; + digest.fases.push({ + from: texto(p.from_phase), + to: texto(p.to_phase), + attempt: numero(p.attempt) ?? 1, + verdict: texto(p.verdict) ?? "?", + passed: gate?.passed === true, + override: entry.type === "human.override", + reason: texto(p.reason), + seq: entry.seq, + }); + break; + } + case "verifier.run": { + const erro = p.error as { message?: string } | null | undefined; + digest.verificadores.push({ + verifier: texto(p.verifier) ?? "?", + phase: texto(p.phase), + status: texto(p.status) ?? "?", + value: numero(p.value), + exit_code: numero(p.exit_code), + error: erro?.message ?? null, + seq: entry.seq, + }); + break; + } + case "memory.write": { + digest.anotacoes.push({ + slug: texto(p.slug) ?? "?", + kind: texto(p.kind) ?? "?", + pinned: p.pinned === true, + promoted_to: null, + seq: entry.seq, + }); + break; + } + case "memory.promote": { + const slug = texto(p.slug) ?? "?"; + const anterior = digest.anotacoes.find((a) => a.slug === slug); + if (anterior !== undefined) anterior.promoted_to = texto(p.to); + else + digest.anotacoes.push({ + slug, + kind: "promovida", + pinned: false, + promoted_to: texto(p.to), + seq: entry.seq, + }); + break; + } + case "boundary.decision": { + if (texto(p.action) === "violacao") { + digest.violacoes.push({ + path: texto(p.path) ?? "?", + action: texto(p.result) ?? texto(p.boundary_action) ?? "registrada", + agent: entry.actor, + seq: entry.seq, + }); + } else { + digest.decisoes.push({ + tipo: "allowlist", + quem: entry.actor, + assunto: texto(p.glob) ?? texto(p.path) ?? "allowlist", + motivo: texto(p.action), + seq: entry.seq, + }); + } + break; + } + case "command.exec": { + digest.comandos.total += 1; + if (numero(p.exit_code) !== 0) digest.comandos.falharam += 1; + for (const alerta of Array.isArray(p.destructive_alerts) ? p.destructive_alerts : []) { + const id = texto(alerta); + if (id !== null && !digest.comandos.alertas.includes(id)) digest.comandos.alertas.push(id); + } + break; + } + case "harness.init": { + const perfil = texto(p.profile) ?? "?"; + const stack = texto(p.stack) ?? "?"; + digest.marcos.push({ descricao: `harness inicializado: perfil ${perfil}, stack ${stack}`, seq: entry.seq }); + break; + } + case "adapter.event": { + const evento = texto(p.event); + const motivo = texto(p.reason); + digest.marcos.push({ + descricao: + evento === "SessionStart" + ? `sessao aberta pelo adapter (${texto(p.source) ?? "origem nao declarada"}), ${numero(p.injected_chars) ?? 0} caracteres injetados` + : `sessao encerrada pelo adapter${motivo === null ? "" : ` (${motivo})`}`, + seq: entry.seq, + }); + break; + } + case "prompt.submit": { + digest.pedidos.push({ + texto: texto(p.text), + chars: numero(p.chars) ?? 0, + truncated: p.truncated === true, + redacted: p.redacted === true, + seq: entry.seq, + }); + break; + } + case "human.approval": { + digest.decisoes.push({ + tipo: "aprovacao", + quem: entry.actor, + assunto: texto(p.subject) ?? "?", + motivo: null, + seq: entry.seq, + }); + break; + } + default: { + const tipo = entry.type; + digest.nao_classificadas[tipo] = (digest.nao_classificadas[tipo] ?? 0) + 1; + } + } + } + + // Override entra duas vezes na trilha quando tambem transiciona; a lista de + // decisoes recebe a versao humana para que o registro nao dependa de quem le + // a secao de fases. + for (const fase of digest.fases) { + if (!fase.override) continue; + digest.decisoes.push({ + tipo: "override", + quem: "humano", + assunto: `${fase.from ?? "?"} #${fase.attempt}`, + motivo: fase.reason, + seq: fase.seq, + }); + } + digest.decisoes.sort((a, b) => a.seq - b.seq); + + return digest; +} + +// --- narracao ------------------------------------------------------------- + +export const NARRADOR = "trilha"; + +/** + * R5.2 pede a pagina "reescrita como narrativa", e isso e uma chamada de LLM + * que passa pelo Maestro (C6). O Maestro nao existe nesta versao. + * + * Em vez de chamar modelo por fora do roteador, a consolidacao monta a pagina a + * partir da trilha: cada linha tem o numero da entrada que a originou, e nada + * aqui depende de um modelo lembrar direito. Quando o C6 entrar, a narrativa + * vira uma reescrita **por cima** deste texto, com o original preservado. + */ +export function narrarDaTrilha(digest: SessionDigest): string { + const l: string[] = []; + + l.push( + `Sessao de ${digest.started_at} a ${digest.finished_at}, entradas ${digest.from_seq} a ${digest.to_seq} da trilha.`, + ); + l.push(""); + + if (digest.marcos.length > 0) { + l.push("## Marcos"); + l.push(""); + for (const m of digest.marcos) l.push(`- \`#${m.seq}\` ${m.descricao}`); + l.push(""); + } + + if (digest.pedidos.length > 0) { + l.push("## Pedidos do usuario"); + l.push(""); + for (const pedido of digest.pedidos) { + const corpo = + pedido.texto === null + ? `(nao registrado: o prompt trazia marcador de segredo, ${pedido.chars} caracteres)` + : primeiraLinhaDoPedido(pedido.texto) + (pedido.truncated ? " (cortado)" : ""); + l.push(`- \`#${pedido.seq}\` ${corpo}`); + } + l.push(""); + } + + if (digest.fases.length > 0) { + l.push("## Fases"); + l.push(""); + for (const f of digest.fases) { + const alvo = f.to === null ? "(fim)" : f.to; + const marca = f.override ? " **override humano**" : ""; + l.push(`- \`#${f.seq}\` ${f.from ?? "(nenhuma)"} -> ${alvo}, tentativa ${f.attempt}: ${f.verdict}${marca}`); + if (f.reason !== null) l.push(` motivo declarado: ${f.reason}`); + } + l.push(""); + } + + if (digest.verificadores.length > 0) { + l.push("## Verificadores"); + l.push(""); + for (const v of digest.verificadores) { + const valor = v.value === null ? "sem valor" : String(v.value); + l.push(`- \`#${v.seq}\` ${v.verifier}: ${v.status}, ${valor}, exit ${v.exit_code ?? "-"}`); + if (v.error !== null) l.push(` erro: ${v.error}`); + } + l.push(""); + } + + if (digest.violacoes.length > 0) { + l.push("## Fronteira"); + l.push(""); + for (const v of digest.violacoes) { + l.push(`- \`#${v.seq}\` ${v.agent} tentou escrever em ${v.path} (${v.action})`); + } + l.push(""); + } + + if (digest.decisoes.length > 0) { + l.push("## Decisoes humanas"); + l.push(""); + for (const d of digest.decisoes) { + l.push(`- \`#${d.seq}\` ${d.tipo}: ${d.assunto}, por ${d.quem}`); + if (d.motivo !== null) l.push(` ${d.motivo}`); + } + l.push(""); + } + + if (digest.anotacoes.length > 0) { + l.push("## Anotacoes da sessao"); + l.push(""); + for (const a of digest.anotacoes) { + const destino = a.promoted_to === null ? "" : `, promovida para ${a.promoted_to}`; + l.push(`- \`#${a.seq}\` \`${a.slug}\`${a.pinned ? " (fixada)" : ""}${destino}`); + } + l.push(""); + } + + if (digest.comandos.total > 0) { + l.push("## Comandos sob fronteira"); + l.push(""); + l.push(`- ${digest.comandos.total} execucao(oes), ${digest.comandos.falharam} com codigo de saida diferente de zero.`); + if (digest.comandos.alertas.length > 0) { + l.push(`- Alertas de comando destrutivo: ${digest.comandos.alertas.join(", ")}.`); + } + l.push(""); + } + + const naoLidas = Object.entries(digest.nao_classificadas); + if (naoLidas.length > 0) { + l.push("## Entradas que esta versao nao resume"); + l.push(""); + // R2.13: o que a consolidacao nao soube ler sai contado por tipo. Entrada + // que some sem numero vira sessao que parece menor do que foi. + for (const [tipo, n] of naoLidas.sort()) l.push(`- ${tipo}: ${n}`); + l.push(""); + } + + l.push("---"); + l.push(""); + l.push( + `Pagina montada a partir da trilha por \`psh memory consolidate\`, sem chamada de modelo. ` + + `${digest.entries_examined} entrada(s) examinada(s). ` + + `A reescrita como narrativa (R5.2) depende do Maestro e ainda nao existe.`, + ); + + return l.join("\n"); +} + +function primeiraLinhaDoPedido(texto: string, limite = 160): string { + const linha = texto.split("\n").find((l) => l.trim() !== "")?.trim() ?? ""; + return linha.length <= limite ? linha : `${linha.slice(0, limite - 3).trimEnd()}...`; +} + +export function tituloDoDigest(digest: SessionDigest): string { + const dia = digest.started_at.slice(0, 10); + const fase = digest.fases.at(-1); + if (fase !== undefined) { + return `Sessao de ${dia}: ${fase.from ?? "inicio"} para ${fase.to ?? "fim"}`; + } + if (digest.verificadores.length > 0) { + return `Sessao de ${dia}: ${digest.verificadores.length} verificacao(oes)`; + } + if (digest.pedidos.length > 0) { + return `Sessao de ${dia}: ${digest.pedidos.length} pedido(s) do usuario`; + } + return `Sessao de ${dia}: ${digest.entries_examined} entrada(s) na trilha`; +} + +export function slugDoDigest(digest: SessionDigest): string { + const dia = digest.started_at.slice(0, 10); + return `sessao-${dia}-${String(digest.from_seq).padStart(4, "0")}-${String(digest.to_seq).padStart(4, "0")}`; +} + +export function tagsDoDigest(digest: SessionDigest): string[] { + const tags = new Set(["sessao"]); + for (const f of digest.fases) { + if (f.to !== null) tags.add(f.to); + if (f.from !== null) tags.add(f.from); + } + for (const v of digest.verificadores) tags.add(v.verifier); + return [...tags].slice(0, 12); +} + +/** Ator com que a propria consolidacao assina o que grava na trilha. */ +export const ATOR_DA_CONSOLIDACAO = "core:consolidate"; + +/** + * A consolidacao nao resume a si mesma. + * + * Ela grava uma entrada `memory.write` na trilha ao terminar. Sem este filtro + * essa entrada vira material da corrida seguinte, que gera outra pagina, que + * grava outra entrada: `psh memory consolidate` rodado tres vezes seguidas + * produzia tres paginas, e as duas ultimas so falavam da anterior. + */ +export function ehEntradaDaConsolidacao(entry: AuditEntry): boolean { + return entry.type === "memory.write" && entry.actor === ATOR_DA_CONSOLIDACAO; +} + +export interface Pendentes { + entradas: AuditEntry[]; + /** Topo da trilha, para a marca d'agua avancar mesmo sem pagina nova. */ + head_seq: number; + /** Quantas entradas de bookkeeping da propria consolidacao foram puladas. */ + ignoradas: number; +} + +/** + * Entradas ainda nao consolidadas. + * + * Consolidar em cima de trilha comprometida seria assinar como memoria um + * relato que a propria cadeia nao sustenta, entao a verificacao vem antes. + */ +export function entradasPendentes(chain: AuditChain, state: ConsolidationState): Pendentes { + const verificacao = chain.verify(); + if (!verificacao.ok) { + throw new AuditError( + `trilha comprometida (${verificacao.problems.length} problema(s)): a consolidacao nao resume o que a cadeia nao sustenta. Rode 'psh audit verify'.`, + { problems: verificacao.problems.length }, + ); + } + const todas = chain.read(); + const topo = todas.at(-1)?.seq ?? 0; + if (state.last_seq > topo) { + throw new ContractError( + `a marca d'agua da consolidacao aponta para a entrada ${state.last_seq}, mas a trilha termina em ${topo}. ` + + "A trilha encolheu depois da ultima consolidacao, e isso e problema de auditoria, nao de memoria.", + { last_seq: state.last_seq, head_seq: topo }, + ); + } + const novas = todas.filter((e) => e.seq > state.last_seq); + const entradas = novas.filter((e) => !ehEntradaDaConsolidacao(e)); + return { entradas, head_seq: topo, ignoradas: novas.length - entradas.length }; +} diff --git a/psh/src/memory/handoff.ts b/psh/src/memory/handoff.ts new file mode 100644 index 0000000..5fbd1b7 --- /dev/null +++ b/psh/src/memory/handoff.ts @@ -0,0 +1,217 @@ +import type { ProjectContext } from "../cli/context.ts"; +import { buildStatus, type StatusReport } from "../cli/status.ts"; +import { PSH_VERSION } from "../version.ts"; +import { syncIndex, type IndexSync } from "./search.ts"; + +/** + * Teto do bloco de retomada. + * + * O bloco vai para o inicio da sessao seguinte, e a janela util e recurso + * medido (R7.2). Memoria fixada que cresce sem limite viraria prefixo fixo que + * ninguem contou. O corte e **declarado**, item por item e no total, e o texto + * inteiro continua a um `psh memory get` de distancia. + */ +export const MAX_PINNED_NO_BLOCO = 12; +export const MAX_CORPO_POR_ITEM = 400; + +export interface HandoffItem { + slug: string; + title: string; + kind: string; + updated_at: string; + body: string; + /** Verdadeiro quando o corpo foi cortado para caber no bloco. */ + body_truncated: boolean; +} + +export interface HandoffPending { + label: string; + observed: string; + expected: string; + reason: string | null; +} + +export interface Handoff { + _type: "psh-handoff"; + version: 1; + psh_version: string; + generated_at: string; + root: string; + profile: string; + phase: string | null; + phase_name: string | null; + attempt: number; + retries_used: number; + status: string; + /** Ultima transicao registrada: de onde a sessao anterior parou. */ + last_decision: { phase: string; attempt: number; verdict: string; at: string } | null; + /** O que reprova o portao agora. E isto que a proxima sessao precisa fechar. */ + pending: HandoffPending[]; + sandbox_mode: string; + boundary_mode: string; + audit_ok: boolean; + pinned: HandoffItem[]; + /** Fixadas que existem mas nao couberam no bloco. Nunca corte silencioso. */ + pinned_omitted: number; + recent: HandoffItem[]; + memory_index: IndexSync; + next_commands: string[]; +} + +/** + * R5.4: contexto de retomada. + * + * Tudo aqui e lido do estado e da evidencia em disco, nunca de resumo de + * modelo. O bloco existe porque a sessao seguinte pode abrir em outro runtime, + * outro dia, ou depois de o processo morrer no meio de uma fase (UC3): o que + * ele afirma precisa ser verificavel pelos mesmos comandos que ele sugere. + */ +export function buildHandoff(ctx: ProjectContext, opts: { limit?: number; now?: Date } = {}): Handoff { + const limite = opts.limit ?? 5; + const status = buildStatus(ctx); + const memoryIndex = syncIndex(ctx.db, ctx.layout); + + const todasFixadas = ctx.db.listMemoryPages({ pinnedOnly: true, limit: MAX_PINNED_NO_BLOCO + 1 }); + const pinned = todasFixadas.slice(0, MAX_PINNED_NO_BLOCO).map(toItem); + const totalFixadas = ctx.db.countMemoryPages({ pinnedOnly: true }); + const fixadas = new Set(pinned.map((p) => p.slug)); + const recent = ctx.db + .listMemoryPages({ limit: limite + pinned.length }) + .filter((row) => !fixadas.has(row.slug) && row.pinned !== 1) + .slice(0, limite) + .map(toItem); + + const ultima = ctx.state.history.at(-1) ?? null; + + return { + _type: "psh-handoff", + version: 1, + psh_version: PSH_VERSION, + generated_at: (opts.now ?? new Date()).toISOString(), + root: ctx.layout.root, + profile: status.profile, + phase: status.phase, + phase_name: status.phase_name, + attempt: status.attempt, + retries_used: status.retries_used, + status: status.status, + last_decision: + ultima === null + ? null + : { phase: ultima.phase, attempt: ultima.attempt, verdict: ultima.verdict, at: ultima.at }, + pending: pendencias(status), + sandbox_mode: status.sandbox.mode, + boundary_mode: status.boundary.mode, + audit_ok: status.audit.ok, + pinned, + pinned_omitted: Math.max(0, totalFixadas - pinned.length), + recent, + memory_index: memoryIndex, + next_commands: proximosComandos(status), + }; +} + +function toItem(row: { slug: string; title: string; kind: string; updated_at: string; body: string }): HandoffItem { + const corpo = row.body.trim(); + const cortado = corpo.length > MAX_CORPO_POR_ITEM; + return { + slug: row.slug, + title: row.title, + kind: row.kind, + updated_at: row.updated_at, + body: cortado ? `${corpo.slice(0, MAX_CORPO_POR_ITEM).trimEnd()}...` : corpo, + body_truncated: cortado, + }; +} + +function pendencias(status: StatusReport): HandoffPending[] { + if (status.gate === null) return []; + return status.gate.checks + .filter((c) => !c.passed) + .map((c) => ({ + label: c.label, + observed: c.observed === null ? "sem evidencia" : String(c.observed), + expected: c.expected === null || c.expected === undefined ? "-" : String(c.expected), + reason: c.reason ?? null, + })); +} + +function proximosComandos(status: StatusReport): string[] { + if (!status.audit.ok) return ["psh audit verify"]; + if (status.phase === null) return ["psh status"]; + if (status.gate === null || status.gate.passed) return ["psh advance"]; + const semEvidencia = status.gate.checks.some((c) => !c.passed && c.observed === null); + return semEvidencia ? ["psh verify --all", "psh status"] : ["psh status", "psh verify --all"]; +} + +/** + * Bloco pronto para prepend. Markdown, porque e o formato que todo runtime + * aceita como texto de sistema sem tratamento especial. + */ +export function renderHandoff(h: Handoff): string { + const linhas: string[] = []; + linhas.push("# Retomada de sessao (psh handoff)"); + linhas.push(""); + linhas.push( + `Projeto \`${h.root}\`, perfil ${h.profile}, gerado em ${h.generated_at} pelo psh ${h.psh_version}.`, + ); + linhas.push(""); + linhas.push("## Onde a sessao anterior parou"); + linhas.push(""); + linhas.push( + `- Fase **${h.phase ?? "(nenhuma)"}**${h.phase_name === null ? "" : ` - ${h.phase_name}`}, tentativa ${h.attempt}, status ${h.status}.`, + ); + linhas.push( + h.last_decision === null + ? "- Nenhuma transicao registrada ainda." + : `- Ultima decisao: ${h.last_decision.phase} #${h.last_decision.attempt} ${h.last_decision.verdict} em ${h.last_decision.at}.`, + ); + linhas.push(`- Sandbox ${h.sandbox_mode}, fronteira ${h.boundary_mode}, trilha ${h.audit_ok ? "integra" : "COMPROMETIDA"}.`); + linhas.push(""); + + linhas.push("## Pendencias abertas"); + linhas.push(""); + if (h.pending.length === 0) { + linhas.push("Nenhum check do portao esta reprovado agora."); + } else { + for (const p of h.pending) { + linhas.push(`- **${p.label}**: observado ${p.observed}, esperado ${p.expected}.${p.reason === null ? "" : ` ${p.reason}`}`); + } + } + linhas.push(""); + + if (h.pinned.length > 0) { + linhas.push("## Memoria fixada"); + linhas.push(""); + for (const item of h.pinned) { + linhas.push(`- **${item.title}** (\`${item.slug}\`)`); + if (item.body !== "") linhas.push(` ${item.body.replaceAll("\n", "\n ")}`); + if (item.body_truncated) linhas.push(` (cortado no bloco; \`psh memory get ${item.slug}\` traz o texto inteiro)`); + } + if (h.pinned_omitted > 0) { + linhas.push( + `- mais ${h.pinned_omitted} pagina(s) fixada(s) fora do bloco, por teto de tamanho: \`psh memory list --pinned\``, + ); + } + linhas.push(""); + } + + if (h.recent.length > 0) { + linhas.push("## Memoria recente"); + linhas.push(""); + for (const item of h.recent) { + linhas.push(`- ${item.title} (\`${item.slug}\`, ${item.kind}, ${item.updated_at})`); + } + linhas.push(""); + } + + linhas.push("## Como continuar"); + linhas.push(""); + for (const cmd of h.next_commands) linhas.push(`- \`${cmd}\``); + linhas.push(""); + linhas.push( + "> R5.7: memoria e faixa transitoria, nao e fonte canonica. O que precisa " + + "sobreviver com garantia vira arquivo no repositorio por `psh memory promote`.", + ); + return linhas.join("\n"); +} diff --git a/psh/src/memory/page.ts b/psh/src/memory/page.ts new file mode 100644 index 0000000..e3e7f8a --- /dev/null +++ b/psh/src/memory/page.ts @@ -0,0 +1,283 @@ +import memoryPageSchema from "../../schemas/memory-page.schema.json" with { type: "json" }; +import { ContractError } from "../util/errors.ts"; +import { lazyValidator } from "../util/schema.ts"; +import { formatAjvErrors } from "../workflow/load.ts"; + +export const validateMemoryPageSchema = lazyValidator(memoryPageSchema); + +export type PageKind = "fact" | "decision" | "verifier" | "session" | "prompt" | "note"; + +/** Cabecalho da pagina. O corpo fica fora daqui porque nao entra no schema. */ +export interface PageHeader { + _type: "psh-memory-page"; + version: 1; + slug: string; + kind: PageKind; + title: string; + pinned: boolean; + phase: string | null; + tags: string[]; + source: string; + created_at: string; + updated_at: string; + promoted_to: string | null; +} + +export interface MemoryPage extends PageHeader { + body: string; +} + +const FENCE = "---"; + +/** + * Ordem fixa de campo no cabecalho. + * + * Nao e estetica: a pagina em disco e a versao canonica e o indice guarda o + * hash do arquivo. Se a ordem variasse entre duas escritas do mesmo conteudo, o + * hash mudaria sozinho e toda pagina pareceria desatualizada a cada leitura. + */ +const FIELD_ORDER: readonly (keyof PageHeader)[] = [ + "_type", + "version", + "slug", + "kind", + "title", + "pinned", + "phase", + "tags", + "source", + "created_at", + "updated_at", + "promoted_to", +]; + +const KNOWN_FIELDS = new Set(FIELD_ORDER as readonly string[]); + +/** + * R5.7 / R3.6: o slug vira nome de arquivo dentro de `.harness/memory/pages/`. + * + * Um slug com `/`, `..` ou nome vazio escreveria fora do diretorio de memoria, + * e a memoria e injetada no contexto da sessao seguinte pelo handoff (R5.4). O + * alfabeto e conferido aqui, antes de qualquer `join`, e nao por regex sobre o + * caminho ja montado (R2.14). + */ +export function assertSafeSlug(slug: string): string { + const ok = slug.length > 0 && slug.length <= 96 && !slug.startsWith("-") && !slug.endsWith("-"); + const alfabetoOk = [...slug].every((ch) => (ch >= "a" && ch <= "z") || (ch >= "0" && ch <= "9") || ch === "-"); + if (!ok || !alfabetoOk) { + throw new ContractError( + `slug de memoria invalido: ${JSON.stringify(slug)}. Use minuscula, digito e hifen, sem separador de caminho.`, + { slug }, + ); + } + return slug; +} + +const ACENTOS: Record = { + á: "a", à: "a", ã: "a", â: "a", ä: "a", + é: "e", ê: "e", è: "e", ë: "e", + í: "i", ì: "i", î: "i", ï: "i", + ó: "o", õ: "o", ô: "o", ò: "o", ö: "o", + ú: "u", ù: "u", û: "u", ü: "u", + ç: "c", ñ: "n", +}; + +/** + * Titulo vira slug: sem acento, sem maiuscula, separador unico. + * + * O corte respeita palavra inteira. Um slug cortado no meio da palavra vira + * nome de arquivo que ninguem reconhece na listagem, e o slug e a chave que o + * humano digita em `psh memory get`. + */ +export function slugify(title: string, limit = 48): string { + const palavras: string[] = []; + let atual: string[] = []; + for (const ch of title.toLowerCase()) { + const base = ACENTOS[ch] ?? ch; + if ((base >= "a" && base <= "z") || (base >= "0" && base <= "9")) { + atual.push(base); + } else if (atual.length > 0) { + palavras.push(atual.join("")); + atual = []; + } + } + if (atual.length > 0) palavras.push(atual.join("")); + if (palavras.length === 0) return ""; + + const escolhidas: string[] = []; + let tamanho = 0; + for (const palavra of palavras) { + const custo = palavra.length + (escolhidas.length > 0 ? 1 : 0); + if (escolhidas.length > 0 && tamanho + custo > limit) break; + escolhidas.push(palavra); + tamanho += custo; + } + return escolhidas.join("-").slice(0, limit); +} + +export function serializePage(page: MemoryPage): string { + const header = headerOf(page); + assertValidHeader(header); + const linhas = [FENCE]; + for (const field of FIELD_ORDER) { + linhas.push(`${field}: ${formatValue(header[field])}`); + } + linhas.push(FENCE, ""); + const corpo = page.body.trim(); + return `${linhas.join("\n")}\n${corpo}\n`; +} + +function formatValue(value: unknown): string { + if (value === null) return ""; + if (Array.isArray(value)) return value.join(", "); + return String(value); +} + +/** + * Leitura estrita: campo desconhecido, cabecalho ausente ou linha sem `:` + * derrubam a leitura. Pagina meio lida vira contexto errado na sessao seguinte, + * e contexto errado nao avisa que esta errado. + */ +export function parsePage(text: string, origem: string): MemoryPage { + const linhas = text.split("\n"); + if (linhas[0]?.trim() !== FENCE) { + throw new ContractError(`pagina de memoria sem cabecalho em ${origem}: a primeira linha precisa ser '---'.`, { + path: origem, + }); + } + const bruto: Record = {}; + let i = 1; + for (; i < linhas.length; i += 1) { + const linha = linhas[i]!; + if (linha.trim() === FENCE) break; + // Linha em branco dentro do cabecalho e cabecalho que acabou sem fechar. O + // arquivo termina com quebra de linha, entao este e o caso comum de + // truncamento, e ele merece a mensagem certa em vez de "esperado campo:". + if (linha.trim() === "") { + throw new ContractError(`cabecalho nao fechado em ${origem}: falta o '---' de fechamento.`, { + path: origem, + line: i + 1, + }); + } + const sep = linha.indexOf(":"); + if (sep < 0) { + throw new ContractError(`cabecalho invalido em ${origem}, linha ${i + 1}: esperado 'campo: valor'.`, { + path: origem, + line: i + 1, + }); + } + const campo = linha.slice(0, sep).trim(); + if (!KNOWN_FIELDS.has(campo)) { + throw new ContractError( + `campo desconhecido no cabecalho de ${origem}: ${JSON.stringify(campo)}. Conhecidos: ${FIELD_ORDER.join(", ")}.`, + { path: origem, field: campo }, + ); + } + if (Object.hasOwn(bruto, campo)) { + throw new ContractError(`campo repetido no cabecalho de ${origem}: ${campo}.`, { path: origem, field: campo }); + } + bruto[campo] = linha.slice(sep + 1).trim(); + } + if (i >= linhas.length) { + throw new ContractError(`cabecalho nao fechado em ${origem}: falta o '---' de fechamento.`, { path: origem }); + } + + const header = { + _type: bruto._type ?? "", + version: bruto.version === undefined ? undefined : Number(bruto.version), + slug: bruto.slug ?? "", + kind: bruto.kind ?? "", + title: bruto.title ?? "", + pinned: parseBool(bruto.pinned, origem), + phase: emptyToNull(bruto.phase), + tags: parseTags(bruto.tags), + source: bruto.source ?? "", + created_at: bruto.created_at ?? "", + updated_at: bruto.updated_at ?? "", + promoted_to: emptyToNull(bruto.promoted_to), + } as unknown as PageHeader; + + assertValidHeader(header, origem); + assertSafeSlug(header.slug); + return { ...header, body: linhas.slice(i + 1).join("\n").trim() }; +} + +function assertValidHeader(header: PageHeader, origem = ""): void { + if (!validateMemoryPageSchema(header as unknown)) { + throw new ContractError( + `pagina de memoria invalida (${origem}):\n - ${formatAjvErrors(validateMemoryPageSchema.errors).join("\n - ")}`, + { path: origem }, + ); + } + assertCabecalhoRepresentavel(header, origem); +} + +/** + * O cabecalho e uma linha por campo, e a lista de tags e separada por virgula. + * + * Um titulo com quebra de linha escreveria campo novo no meio do cabecalho, e o + * arquivo nao voltaria a ser lido: `psh remember --title $'x\npinned: false'` + * reportava sucesso e deixava a anotacao ilegivel para sempre. Uma tag com + * virgula voltava partida em duas. + * + * A recusa e na porta de escrita, com o campo nomeado. Perder a anotacao que o + * comando existe para guardar e pior do que recusar o titulo. + */ +function assertCabecalhoRepresentavel(header: PageHeader, origem: string): void { + for (const campo of ["slug", "title", "source", "phase", "created_at", "updated_at", "promoted_to"] as const) { + const valor = header[campo]; + if (typeof valor === "string" && temQuebraDeLinha(valor)) { + throw new ContractError( + `campo '${campo}' da pagina de memoria nao pode ter quebra de linha (${origem}): ${JSON.stringify(valor)}`, + { path: origem, field: campo }, + ); + } + } + for (const tag of header.tags) { + if (temQuebraDeLinha(tag) || tag.includes(",")) { + throw new ContractError( + `tag invalida (${origem}): ${JSON.stringify(tag)}. A lista e separada por virgula, entao a tag nao pode ter virgula nem quebra de linha.`, + { path: origem, tag }, + ); + } + if (tag.trim() !== tag) { + throw new ContractError(`tag com espaco na ponta (${origem}): ${JSON.stringify(tag)}`, { path: origem, tag }); + } + } +} + +function temQuebraDeLinha(valor: string): boolean { + return valor.includes("\n") || valor.includes("\r"); +} + +function parseBool(value: string | undefined, origem: string): boolean { + if (value === "true") return true; + if (value === "false") return false; + throw new ContractError( + `campo 'pinned' invalido em ${origem}: ${JSON.stringify(value ?? "")}. Use true ou false.`, + { path: origem }, + ); +} + +function parseTags(value: string | undefined): string[] { + if (value === undefined || value.trim() === "") return []; + return value + .split(",") + .map((t) => t.trim()) + .filter((t) => t !== ""); +} + +function emptyToNull(value: string | undefined): string | null { + return value === undefined || value === "" ? null : value; +} + +function headerOf(page: MemoryPage): PageHeader { + const { body, ...header } = page; + void body; + return header; +} + +/** Texto que entra no indice de busca: titulo, tags e corpo. */ +export function indexableText(page: MemoryPage): string { + return [page.title, page.tags.join(" "), page.body].filter((p) => p !== "").join("\n"); +} diff --git a/psh/src/memory/search.ts b/psh/src/memory/search.ts new file mode 100644 index 0000000..6fd856a --- /dev/null +++ b/psh/src/memory/search.ts @@ -0,0 +1,171 @@ +import type { HarnessDb } from "../db/index.ts"; +import type { Layout } from "../util/paths.ts"; +import { indexableText } from "./page.ts"; +import { listPageFiles, readPageIfPresent, type StoredPage } from "./store.ts"; + +export type SearchMode = "fts5" | "scan"; + +export interface IndexSync { + /** R2.13: quantas paginas o mecanismo olhou, nao quantas mudaram. */ + pages_examined: number; + indexed: number; + unchanged: number; + removed: number; + /** Arquivos ignorados por nome fora do alfabeto de slug. */ + skipped: string[]; + unreadable: { slug: string; message: string }[]; +} + +export interface SearchHit { + slug: string; + title: string; + kind: string; + pinned: boolean; + phase: string | null; + tags: string[]; + updated_at: string; + promoted_to: string | null; + snippet: string; +} + +export interface SearchResult { + query: string; + mode: SearchMode; + hits: SearchHit[]; + sync: IndexSync; +} + +/** + * Sincroniza o indice com o disco antes de qualquer consulta. + * + * O indice nunca e autoridade: o mesmo principio do frescor de evidencia + * (R2.4). Pagina editada fora do `psh` ainda e pagina, e o hash do arquivo e o + * que decide se a linha indexada vale. Sem esta passagem, uma busca responderia + * com o texto de ontem sem dizer que e de ontem. + */ +export function syncIndex(db: HarnessDb, layout: Layout): IndexSync { + const { slugs, skipped, examined } = listPageFiles(layout); + const conhecidos = db.memoryHashes(); + const sync: IndexSync = { + pages_examined: examined, + indexed: 0, + unchanged: 0, + removed: 0, + skipped, + unreadable: [], + }; + + for (const slug of slugs) { + let stored: StoredPage | null; + try { + stored = readPageIfPresent(layout, slug); + } catch (cause) { + // Pagina corrompida sai do indice em vez de responder com a versao velha. + sync.unreadable.push({ slug, message: (cause as Error).message }); + if (conhecidos.has(slug)) { + db.deleteMemoryPage(slug); + sync.removed += 1; + } + continue; + } + if (stored === null) continue; + if (conhecidos.get(slug) === stored.content_sha256) { + sync.unchanged += 1; + continue; + } + db.upsertMemoryPage( + { + slug: stored.page.slug, + title: stored.page.title, + kind: stored.page.kind, + pinned: stored.page.pinned ? 1 : 0, + phase: stored.page.phase, + tags: JSON.stringify(stored.page.tags), + source: stored.page.source, + created_at: stored.page.created_at, + updated_at: stored.page.updated_at, + promoted_to: stored.page.promoted_to, + content_sha256: stored.content_sha256, + body: stored.page.body, + }, + indexableText(stored.page), + ); + sync.indexed += 1; + } + + const noDisco = new Set(slugs); + for (const slug of conhecidos.keys()) { + if (!noDisco.has(slug)) { + db.deleteMemoryPage(slug); + sync.removed += 1; + } + } + + return sync; +} + +/** + * Consulta em linguagem de usuario, nunca em sintaxe de FTS5. + * + * Cada termo vira literal entre aspas com prefixo: `fronteira` casa + * `fronteiras`, e um termo com `-`, `*` ou `"` nao derruba a consulta nem vira + * operador por acidente. Quem digita `psh memory search "NOT ai-jail"` esta + * procurando essas tres palavras, nao escrevendo uma expressao booleana. + */ +export function toMatchExpression(query: string): string | null { + const termos = query + .split(/\s+/) + .map((t) => t.trim()) + .filter((t) => t !== "") + .map((t) => `"${t.replaceAll('"', '""')}"*`); + return termos.length === 0 ? null : termos.join(" "); +} + +export function searchMemory( + db: HarnessDb, + layout: Layout, + query: string, + limit = 10, +): SearchResult { + const sync = syncIndex(db, layout); + const expressao = toMatchExpression(query); + // O modo declarado e o do mecanismo, nao o do resultado: consulta sem termo + // devolve zero por nao ter o que procurar, e dizer "scan" ali seria reportar + // degradacao que nao houve. + const mode: SearchMode = db.ftsAvailable ? "fts5" : "scan"; + + const rows = + expressao === null + ? [] + : mode === "fts5" + ? db.searchMemoryFts(expressao, limit) + : db.searchMemoryScan(query, limit); + + return { + query, + mode, + hits: rows.map((row) => ({ + slug: row.slug, + title: row.title, + kind: row.kind, + pinned: row.pinned === 1, + phase: row.phase, + tags: JSON.parse(row.tags) as string[], + updated_at: row.updated_at, + promoted_to: row.promoted_to, + snippet: trecho(row.body, query), + })), + sync, + }; +} + +/** Trecho ao redor da primeira ocorrencia, sem acentuar diferenca de caixa. */ +export function trecho(body: string, query: string, janela = 160): string { + const texto = body.replaceAll("\n", " ").trim(); + const primeiro = query.split(/\s+/).find((t) => t !== "") ?? ""; + const at = primeiro === "" ? -1 : texto.toLowerCase().indexOf(primeiro.toLowerCase()); + if (at < 0) return texto.slice(0, janela) + (texto.length > janela ? "..." : ""); + const inicio = Math.max(0, at - janela / 4); + const fim = Math.min(texto.length, inicio + janela); + return `${inicio > 0 ? "..." : ""}${texto.slice(inicio, fim)}${fim < texto.length ? "..." : ""}`; +} diff --git a/psh/src/memory/store.ts b/psh/src/memory/store.ts new file mode 100644 index 0000000..45fd03e --- /dev/null +++ b/psh/src/memory/store.ts @@ -0,0 +1,173 @@ +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + readdirSync, + renameSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { basename, join } from "node:path"; +import { ContractError, PshError, EXIT } from "../util/errors.ts"; +import { sha256 } from "../util/hash.ts"; +import type { Layout } from "../util/paths.ts"; +import { assertSafeSlug, parsePage, serializePage, slugify, type MemoryPage, type PageKind } from "./page.ts"; + +const EXT = ".md"; + +export interface StoredPage { + page: MemoryPage; + path: string; + /** Hash do arquivo como esta no disco. E ele que decide frescor do indice. */ + content_sha256: string; +} + +export function pagesDir(layout: Layout): string { + return layout.memoryPagesDir; +} + +export function pagePath(layout: Layout, slug: string): string { + return join(pagesDir(layout), `${assertSafeSlug(slug)}${EXT}`); +} + +/** + * Enumeracao das paginas em disco. + * + * O disco e a versao canonica; o SQLite e so indice (mesma divisao da + * evidencia). Arquivo com nome fora do alfabeto de slug e ignorado e contado, + * nunca lido: o nome do arquivo e a chave, e chave que nao bate com o conteudo + * so gera pagina fantasma no indice. + */ +export function listPageFiles(layout: Layout): { slugs: string[]; skipped: string[]; examined: number } { + const dir = pagesDir(layout); + if (!existsSync(dir)) return { slugs: [], skipped: [], examined: 0 }; + const slugs: string[] = []; + const skipped: string[] = []; + let examined = 0; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) continue; + examined += 1; + // Symlink nao e pagina: seguir um daqui leria arquivo de fora da faixa de + // memoria e o conteudo entraria no bloco de handoff da sessao seguinte. + // Ele e contado e nomeado, nunca invisivel (R2.13). + if (!entry.isFile()) { + skipped.push(entry.name); + continue; + } + if (!entry.name.endsWith(EXT)) { + skipped.push(entry.name); + continue; + } + const slug = basename(entry.name, EXT); + try { + assertSafeSlug(slug); + } catch { + skipped.push(entry.name); + continue; + } + slugs.push(slug); + } + return { slugs: slugs.sort(), skipped, examined }; +} + +export function readPage(layout: Layout, slug: string): StoredPage { + const path = pagePath(layout, slug); + if (!existsSync(path)) { + throw new PshError(`pagina de memoria nao encontrada: ${slug}`, { exitCode: EXIT.FAILURE, detail: { slug } }); + } + return readPageFile(path, slug); +} + +export function readPageIfPresent(layout: Layout, slug: string): StoredPage | null { + const path = pagePath(layout, slug); + return existsSync(path) ? readPageFile(path, slug) : null; +} + +function readPageFile(path: string, slug: string): StoredPage { + // A enumeracao ja ignora o que nao e arquivo comum; `psh memory get` e a + // outra porta para o mesmo caminho, e as duas precisam concordar. + if (!lstatSync(path).isFile()) { + throw new ContractError(`${path} nao e um arquivo comum: pagina de memoria nao segue symlink.`, { path }); + } + const raw = readFileSync(path, "utf8"); + const page = parsePage(raw, path); + if (page.slug !== slug) { + throw new ContractError( + `pagina ${path} declara slug ${JSON.stringify(page.slug)} mas o arquivo se chama ${JSON.stringify(slug)}.`, + { path, declared: page.slug, file: slug }, + ); + } + return { page, path, content_sha256: sha256(raw) }; +} + +/** R5.1 / R5.5: esta e a unica porta de escrita de pagina de memoria. */ +export function writePage(layout: Layout, page: MemoryPage): StoredPage { + const text = serializePage(page); + const path = pagePath(layout, page.slug); + mkdirSync(pagesDir(layout), { recursive: true }); + const tmp = join(pagesDir(layout), `.${page.slug}${EXT}.${process.pid}.tmp`); + try { + writeFileSync(tmp, text, { mode: 0o644 }); + renameSync(tmp, path); + } catch (cause) { + try { + unlinkSync(tmp); + } catch { + /* tmp ja removido */ + } + throw cause; + } + return { page, path, content_sha256: sha256(text) }; +} + +/** + * Slug livre a partir do titulo. Colisao vira sufixo numerico em vez de + * sobrescrever: duas anotacoes com o mesmo titulo sao duas anotacoes. + */ +export function freeSlug(layout: Layout, title: string, fallback = "nota"): string { + const base = slugify(title) || fallback; + if (!existsSync(pagePath(layout, base))) return base; + for (let n = 2; n < 1000; n += 1) { + const candidato = `${base}-${n}`; + if (!existsSync(pagePath(layout, candidato))) return candidato; + } + throw new PshError(`nao foi possivel derivar slug livre a partir de ${JSON.stringify(title)}`, { + exitCode: EXIT.FAILURE, + }); +} + +export interface NewPageInput { + title: string; + body: string; + kind: PageKind; + pinned: boolean; + source: string; + phase?: string | null; + tags?: string[]; + slug?: string; + now?: Date; +} + +export function buildPage(layout: Layout, input: NewPageInput): MemoryPage { + const agora = (input.now ?? new Date()).toISOString(); + const titulo = input.title.trim(); + const slug = input.slug === undefined ? freeSlug(layout, titulo) : assertSafeSlug(input.slug); + return { + _type: "psh-memory-page", + version: 1, + slug, + kind: input.kind, + title: titulo, + pinned: input.pinned, + phase: input.phase ?? null, + // A leitura devolve tag sem espaco na ponta; normalizar aqui e o que faz + // escrever e ler de volta darem a mesma pagina. + tags: (input.tags ?? []).map((t) => t.trim()).filter((t) => t !== ""), + source: input.source.trim(), + created_at: agora, + updated_at: agora, + promoted_to: null, + body: input.body, + }; +} diff --git a/psh/src/util/paths.ts b/psh/src/util/paths.ts index 5e5963f..2671ea9 100644 --- a/psh/src/util/paths.ts +++ b/psh/src/util/paths.ts @@ -5,6 +5,47 @@ import { normalizeRel } from "./globs.ts"; export const HARNESS_DIR = ".harness"; +/** + * O que dentro de `.harness/` e escrito pelo proprio nucleo enquanto ele opera. + * + * Nada disso entra no calculo de frescor, porque a infraestrutura que observa um + * workspace nao pode ser contada como quem o modificou. Sem essa regra um + * verificador que observa `**` reprova sozinho: basta um `psh memory + * consolidate` entre a medicao e o portao para a evidencia cair citando um + * arquivo que nenhum verificador escreveu. + * + * A lista e nomeada em vez de ser "tudo dentro de `.harness/`" porque contrato e + * documento de fase moram no mesmo diretorio e precisam continuar observaveis. + * Esconder um deles abriria a classe dos arquivos invisiveis ao observador, que + * e exatamente o que um harness que alega frescor nao pode ter. + */ +export const HARNESS_RUNTIME_PATHS: readonly string[] = [ + `${HARNESS_DIR}/evidence/`, + `${HARNESS_DIR}/audit/`, + `${HARNESS_DIR}/memory/`, + `${HARNESS_DIR}/approvals/`, + `${HARNESS_DIR}/reviews/`, + `${HARNESS_DIR}/tmp/`, + `${HARNESS_DIR}/harness.db`, + `${HARNESS_DIR}/state.json`, +]; + +/** + * O outro lado da mesma classificacao: o que mora em `.harness/` e continua + * valendo como material de portao, entao e observavel. + * + * As duas listas juntas precisam cobrir o `Layout` inteiro, e `paths-and-globs` + * cobra isso. E o teste que impede a lista de runtime de envelhecer calada, que + * foi como `memory/`, `approvals/` e `reviews/` ficaram de fora dela. + */ +export const HARNESS_OBSERVABLE_PATHS: readonly string[] = [ + `${HARNESS_DIR}/workflow.json`, + `${HARNESS_DIR}/boundary.json`, + `${HARNESS_DIR}/SPEC.md`, + `${HARNESS_DIR}/brief.md`, + `${HARNESS_DIR}/sprints/`, +]; + export interface Layout { root: string; harness: string; @@ -17,7 +58,10 @@ export interface Layout { chainPath: string; reviewsDir: string; memoryDir: string; + memoryPagesDir: string; approvalsDir: string; + /** Rascunho de execucao, por exemplo o snapshot que a fronteira usa. */ + tmpDir: string; } export function layoutFor(root: string): Layout { @@ -34,7 +78,9 @@ export function layoutFor(root: string): Layout { chainPath: join(harness, "audit", "chain.jsonl"), reviewsDir: join(harness, "reviews"), memoryDir: join(harness, "memory"), + memoryPagesDir: join(harness, "memory", "pages"), approvalsDir: join(harness, "approvals"), + tmpDir: join(harness, "tmp"), }; } diff --git a/psh/src/util/schema.ts b/psh/src/util/schema.ts new file mode 100644 index 0000000..7cf0b91 --- /dev/null +++ b/psh/src/util/schema.ts @@ -0,0 +1,30 @@ +import Ajv from "ajv"; +import type { ValidateFunction } from "ajv"; + +const ajv = new Ajv({ allErrors: true, strict: false }); + +/** + * Validador compilado na primeira vez que e usado, nunca no import. + * + * Compilar JSON Schema e geracao de codigo, e o ajv fazia isso no escopo de + * modulo: `psh --version` pagava a compilacao dos sete schemas do projeto sem + * validar nada. Medido no binario compilado, eram 185 ms de partida contra 2 ms + * do `bun` cru, e 181 desses 185 iam embora antes de a primeira linha de logica + * rodar. + * + * Isso importa porque o hook do adapter roda **uma vez por chamada de tool**: a + * mesma partida entrava no caminho critico de cada escrita da sessao. + * + * A funcao devolvida mantem a interface do ajv, incluindo `.errors` depois da + * chamada, entao nenhum ponto de uso muda. + */ +export function lazyValidator(schema: object): ValidateFunction { + let compilado: ValidateFunction | null = null; + const validar = ((dados: unknown): boolean => { + compilado ??= ajv.compile(schema); + const ok = compilado(dados); + validar.errors = compilado.errors; + return ok; + }) as ValidateFunction; + return validar; +} diff --git a/psh/src/version.ts b/psh/src/version.ts index 0816882..9aab7e7 100644 --- a/psh/src/version.ts +++ b/psh/src/version.ts @@ -1,2 +1,2 @@ -export const PSH_VERSION = "0.2.0"; +export const PSH_VERSION = "0.3.0"; export const RUNNER_ID = `psh@${PSH_VERSION}`; diff --git a/psh/src/workflow/load.ts b/psh/src/workflow/load.ts index 7b1f1af..7b73b47 100644 --- a/psh/src/workflow/load.ts +++ b/psh/src/workflow/load.ts @@ -1,20 +1,18 @@ -import Ajv from "ajv"; import type { ErrorObject, ValidateFunction } from "ajv"; import workflowSchema from "../../schemas/workflow.schema.json" with { type: "json" }; import stateSchema from "../../schemas/state.schema.json" with { type: "json" }; import evidenceSchema from "../../schemas/evidence.schema.json" with { type: "json" }; import reviewSchema from "../../schemas/review.schema.json" with { type: "json" }; import { ContractError } from "../util/errors.ts"; +import { lazyValidator } from "../util/schema.ts"; import { readJsonFile } from "../util/json.ts"; import { compileGlobs } from "../util/globs.ts"; import { Workflow, type WorkflowContract } from "./types.ts"; -const ajv = new Ajv({ allErrors: true, strict: false }); - -export const validateWorkflowSchema = ajv.compile(workflowSchema) as ValidateFunction; -export const validateStateSchema = ajv.compile(stateSchema) as ValidateFunction; -export const validateEvidenceSchema = ajv.compile(evidenceSchema) as ValidateFunction; -export const validateReviewSchema = ajv.compile(reviewSchema) as ValidateFunction; +export const validateWorkflowSchema = lazyValidator(workflowSchema) as ValidateFunction; +export const validateStateSchema = lazyValidator(stateSchema) as ValidateFunction; +export const validateEvidenceSchema = lazyValidator(evidenceSchema) as ValidateFunction; +export const validateReviewSchema = lazyValidator(reviewSchema) as ValidateFunction; export function formatAjvErrors(errors: ErrorObject[] | null | undefined): string[] { return (errors ?? []).map((e) => `${e.instancePath || "/"} ${e.message ?? "invalido"}`); diff --git a/psh/tests/adapter-ci.test.ts b/psh/tests/adapter-ci.test.ts index 842453b..49a8740 100644 --- a/psh/tests/adapter-ci.test.ts +++ b/psh/tests/adapter-ci.test.ts @@ -66,15 +66,32 @@ describe("adapter ci: headless, sem interacao, sem regra de negocio propria", () gate: { passed: boolean }; advance: { decision: string; to: string }; audit_ok: boolean; - boundary_engine: string; + boundary: { mode: string; agents: number; detail: string }; }; expect(report._type).toBe("psh-ci-report"); expect(report.verify.ran[0]).toMatchObject({ verifier: "coverage" }); expect(report.gate.passed).toBe(true); expect(report.advance).toMatchObject({ decision: "advanced", to: "fim" }); expect(report.audit_ok).toBe(true); - // O adapter declara que o motor de fronteira nao existe, em vez de calar. - expect(report.boundary_engine).toBe("absent"); + expect(report.boundary.mode).not.toBe("absent"); + expect(report.boundary.detail.length).toBeGreaterThan(0); + }); + + /** + * Campo 01, achado 3. O campo era o literal `"absent"` desde a v0.1, entao no + * mesmo instante o `status` dizia `fronteira mount` e o CI dizia que motor de + * fronteira nao havia. Quem consumia o JSON concluia o contrario do que estava + * acontecendo, que e a pior falha possivel num contrato de maquina. + */ + test("o que o CI diz da fronteira e o que o status diz, palavra por palavra", () => { + const layout = projeto(LCOV_87); + const ci = JSON.parse(psh(layout, ["adapter", "ci", "--gate-only", "--json"]).out) as { + boundary: { mode: string; agents: number; detail: string }; + }; + const status = JSON.parse(psh(layout, ["status", "--json"]).out) as { + boundary: { mode: string; agents: number; detail: string }; + }; + expect(ci.boundary).toEqual(status.boundary); }); test("portao reprovado sai com codigo de portao e nao avanca de fase", () => { diff --git a/psh/tests/adapter-claude-code.test.ts b/psh/tests/adapter-claude-code.test.ts new file mode 100644 index 0000000..6f9a915 --- /dev/null +++ b/psh/tests/adapter-claude-code.test.ts @@ -0,0 +1,633 @@ +import { afterAll, afterEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { runCli } from "../src/index.ts"; +import { captureIo } from "../src/cli/io.ts"; +import { EXIT } from "../src/util/errors.ts"; +import { AuditChain } from "../src/audit/chain.ts"; +import { openProject } from "../src/cli/context.ts"; +import { handleHook } from "../src/adapters/claude-code/hook.ts"; +import { + CONTRATO, + acharRuntime, + conferirContrato, + renderContrato, + varrerSimbolos, +} from "../src/adapters/claude-code/contract.ts"; +import { + desinstalar, + ehNosso, + instalar, + podar, + settingsPath, + statusDoAdapter, + type SettingsDoClaude, +} from "../src/adapters/claude-code/install.ts"; +import type { WorkflowContract } from "../src/workflow/types.ts"; +import type { Layout } from "../src/util/paths.ts"; +import { cleanupTempProjects, harnessWith, tempProject, writeFile } from "./helpers.ts"; + +afterAll(cleanupTempProjects); + +let restaurar: (() => void) | null = null; +afterEach(() => { + restaurar?.(); + restaurar = null; +}); + +async function cli(argv: string[]): Promise<{ code: number; out: string; err: string }> { + const cap = captureIo(); + restaurar = cap.restore; + const code = await runCli(argv); + cap.restore(); + restaurar = null; + return { code, out: cap.out.join(""), err: cap.err.join("") }; +} + +const CONTRATO_VAZIO: Partial & { phases: WorkflowContract["phases"] } = { + profile: "gate-only", + verifiers: [], + phases: [], +}; + +/** Projeto com fronteira estreita: backend so escreve em src/api. */ +function projeto(): Layout { + const layout = tempProject({ git: false }); + writeFile(layout, "src/api/users.ts", "api\n"); + writeFile(layout, "src/web/app.tsx", "web\n"); + writeFile( + layout, + ".harness/boundary.json", + JSON.stringify({ + _type: "psh-boundary", + version: 1, + default_agent: "backend", + agents: { backend: { write: ["src/api/**"] } }, + }), + ); + harnessWith(layout, CONTRATO_VAZIO).close(); + return layout; +} + +function hook(layout: Layout | null, payload: Record) { + const ctx = layout === null ? null : openProject(layout.root); + try { + return handleHook(payload, ctx === null ? {} : { abrirProjeto: () => ctx }); + } finally { + ctx?.close(); + } +} + +function trilha(layout: Layout) { + return new AuditChain(layout.chainPath).read(); +} + +describe("contrato do runtime (R8.6b, R8.6g)", () => { + test("todo campo que o adapter usa esta declarado no fixture", () => { + // O adapter le `tool_input.file_path`; se o nome nao estivesse na lista + // conferida, a validacao contra o binario nao pegaria a troca de nome. + for (const simbolo of ["hook_event_name", "tool_name", "tool_input", "prompt", "permissionDecision"]) { + expect(CONTRATO.simbolos_conferidos).toContain(simbolo); + } + for (const evento of ["SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "SessionEnd"]) { + expect(Object.keys(CONTRATO.events)).toContain(evento); + expect(CONTRATO.simbolos_conferidos).toContain(evento); + } + }); + + test("a varredura acha simbolo colado na emenda de dois blocos", () => { + // Sem sobreposicao entre blocos, um simbolo partido ao meio sumiria e a + // validacao reprovaria adapter que esta certo. + const layout = tempProject({ git: false }); + const alvo = join(layout.root, "artefato.bin"); + const recheio = "x".repeat(1000); + writeFileSync(alvo, `${recheio}PreToolUse${recheio}`); + + const r = varrerSimbolos(alvo, ["PreToolUse"], { blocoBytes: 1005 }); + expect(r.encontrados).toEqual(["PreToolUse"]); + expect(r.ausentes).toEqual([]); + expect(r.bytes_examinados).toBeGreaterThan(0); + }); + + test("simbolo ausente e reprovado, nao ignorado", () => { + const layout = tempProject({ git: false }); + const alvo = join(layout.root, "artefato.bin"); + writeFileSync(alvo, "runtime que nao fala nossa lingua"); + const r = varrerSimbolos(alvo, ["PreToolUse", "hookSpecificOutput"]); + expect(r.ausentes.sort()).toEqual(["PreToolUse", "hookSpecificOutput"]); + }); +}); + +describe("contrato conferido contra um artefato controlado", () => { + /** + * Runtime de mentira: um script que responde `--version` e carrega os + * simbolos no proprio corpo. Ele existe para que o caminho de validacao seja + * exercitado em qualquer maquina, inclusive onde o Claude Code nao esta + * instalado - o teste contra o binario de verdade continua logo abaixo. + */ + function runtimeFalso(layout: Layout, simbolos: readonly string[]): string { + const alvo = join(layout.root, "claude-de-mentira"); + writeFileSync(alvo, `#!/bin/sh\necho "9.9.9 (Claude Code)"\n# ${simbolos.join(" ")}\n`, { mode: 0o755 }); + return alvo; + } + + function comRuntime(bin: string, fn: () => T): T { + const antes = process.env.PSH_CLAUDE_BIN; + process.env.PSH_CLAUDE_BIN = bin; + try { + return fn(); + } finally { + if (antes === undefined) delete process.env.PSH_CLAUDE_BIN; + else process.env.PSH_CLAUDE_BIN = antes; + } + } + + test("artefato com todos os simbolos passa, e a versao vem do proprio binario", () => { + const layout = tempProject({ git: false }); + const bin = runtimeFalso(layout, CONTRATO.simbolos_conferidos); + const r = comRuntime(bin, () => conferirContrato()); + + expect(r.ok).toBe(true); + expect(r.ausentes).toEqual([]); + expect(r.runtime.versao).toBe("9.9.9 (Claude Code)"); + expect(r.bytes_examinados).toBeGreaterThan(0); + expect(renderContrato(r)).toContain("OK"); + }); + + test("artefato sem um simbolo reprova nomeando o que falta", async () => { + const layout = tempProject({ git: false }); + const bin = runtimeFalso( + layout, + CONTRATO.simbolos_conferidos.filter((x) => x !== "notebook_path"), + ); + const r = comRuntime(bin, () => conferirContrato()); + + expect(r.ok).toBe(false); + expect(r.ausentes).toEqual(["notebook_path"]); + expect(renderContrato(r)).toContain("notebook_path"); + + const saida = await comRuntime(bin, () => cli(["adapter", "claude-code", "contract"])); + expect(saida.code).toBe(EXIT.CONTRACT_INVALID); + }); + + test("simbolo que e prefixo de outro nao e conferido sozinho, e isso e declarado", () => { + const layout = tempProject({ git: false }); + // A varredura procura os bytes do simbolo. `permissionDecision` aparece + // dentro de `permissionDecisionReason`, entao a presenca do segundo + // satisfaz o primeiro. O limite e este, e esta escrito: a validacao pega + // ponto de extensao que sumiu, nao renomeacao parcial de campo irmao. + const bin = runtimeFalso(layout, ["permissionDecisionReason"]); + const r = varrerSimbolos(bin, ["permissionDecision", "permissionDecisionReason"]); + expect(r.ausentes).toEqual([]); + }); + + test("runtime ausente e reprovacao declarada, nao passe livre", () => { + const r = comRuntime("/caminho/que/nao/existe/claude", () => conferirContrato()); + expect(r.ok).toBe(false); + expect(r.motivo).toContain("runtime nao encontrado"); + expect(r.bytes_examinados).toBe(0); + }); +}); + +const RUNTIME = acharRuntime(); + +describe.skipIf(!RUNTIME.encontrado)("contrato conferido contra o runtime instalado", () => { + test("todo simbolo declarado existe no artefato instalado", async () => { + const r = await cli(["adapter", "claude-code", "contract", "--json"]); + const relatorio = JSON.parse(r.out) as { + ok: boolean; + ausentes: string[]; + bytes_examinados: number; + runtime: { versao: string }; + }; + expect(relatorio.ausentes).toEqual([]); + expect(relatorio.ok).toBe(true); + // R2.13: varredura que nao leu nada nao e varredura limpa. + expect(relatorio.bytes_examinados).toBeGreaterThan(0); + expect(r.code).toBe(EXIT.OK); + }); +}); + +describe("hook: fronteira antes da escrita (R8.1, R8.2b)", () => { + test("escrita fora da allowlist e negada, com o motivo e o caminho de liberacao", () => { + const layout = projeto(); + const r = hook(layout, { + hook_event_name: "PreToolUse", + cwd: layout.root, + tool_name: "Write", + tool_input: { file_path: "src/web/app.tsx", content: "x" }, + tool_use_id: "t1", + }); + + expect(r.output?.hookSpecificOutput).toMatchObject({ + hookEventName: "PreToolUse", + permissionDecision: "deny", + }); + expect(r.output?.hookSpecificOutput?.permissionDecisionReason).toContain("psh boundary add"); + // R8.6e: o efeito e conferido no disco, nao na resposta. + const entrada = trilha(layout).at(-1)!; + expect(entrada.type).toBe("boundary.decision"); + expect(entrada.payload.path).toBe("src/web/app.tsx"); + }); + + test("escrita dentro da allowlist passa sem ruido", () => { + const layout = projeto(); + const antes = trilha(layout).length; + const r = hook(layout, { + hook_event_name: "PreToolUse", + cwd: layout.root, + tool_name: "Write", + tool_input: { file_path: "src/api/users.ts" }, + }); + expect(r.output).toBeNull(); + expect(trilha(layout)).toHaveLength(antes); + }); + + test("o deny duro tambem vale pela tool", () => { + const layout = projeto(); + const r = hook(layout, { + hook_event_name: "PreToolUse", + cwd: layout.root, + tool_name: "Edit", + tool_input: { file_path: ".harness/evidence/coverage.json" }, + }); + expect(r.output?.hookSpecificOutput?.permissionDecision).toBe("deny"); + }); + + test("notebook usa notebook_path, e nao file_path", () => { + const layout = projeto(); + const r = hook(layout, { + hook_event_name: "PreToolUse", + cwd: layout.root, + tool_name: "NotebookEdit", + tool_input: { notebook_path: "src/web/analise.ipynb" }, + }); + expect(r.output?.hookSpecificOutput?.permissionDecision).toBe("deny"); + }); + + test("comando destrutivo e alerta, nunca bloqueio (R3.3)", () => { + const layout = projeto(); + const r = hook(layout, { + hook_event_name: "PreToolUse", + cwd: layout.root, + tool_name: "Bash", + tool_input: { command: "rm -rf build" }, + }); + expect(r.output?.hookSpecificOutput?.permissionDecision).toBe("allow"); + expect(r.output?.hookSpecificOutput?.permissionDecisionReason).toContain("alerta"); + }); + + test("tool que nao escreve arquivo nao interessa ao adapter", () => { + const layout = projeto(); + expect(hook(layout, { hook_event_name: "PreToolUse", cwd: layout.root, tool_name: "Read", tool_input: { file_path: "x" } }).output).toBeNull(); + }); + + test("falha do harness vira 'ask', nunca liberacao calada nem sessao travada", () => { + const layout = projeto(); + writeFileSync(layout.boundaryPath, "{ isto nao e json"); + const r = hook(layout, { + hook_event_name: "PreToolUse", + cwd: layout.root, + tool_name: "Write", + tool_input: { file_path: "src/web/app.tsx" }, + }); + expect(r.output?.hookSpecificOutput?.permissionDecision).toBe("ask"); + expect(r.exitCode).toBe(EXIT.OK); + }); +}); + +describe("hook: inicio e fim de sessao (R5.4, R5.2)", () => { + test("SessionStart injeta as regras do harness e o bloco de retomada", () => { + const layout = projeto(); + const r = hook(layout, { hook_event_name: "SessionStart", cwd: layout.root, session_id: "s1", source: "startup" }); + + const contexto = r.output?.hookSpecificOutput?.additionalContext ?? ""; + expect(r.output?.hookSpecificOutput?.hookEventName).toBe("SessionStart"); + expect(contexto).toContain("Harness psh"); + expect(contexto).toContain("Retomada de sessao"); + + const entrada = trilha(layout).at(-1)!; + expect(entrada.type).toBe("adapter.event"); + expect(entrada.payload.event).toBe("SessionStart"); + expect(entrada.payload.injected_chars).toBe(contexto.length); + }); + + test("a memoria fixada chega ao bloco injetado", async () => { + const layout = projeto(); + await cli(["remember", "nao mexer no gerador de migration", "--root", layout.root]); + const r = hook(layout, { hook_event_name: "SessionStart", cwd: layout.root }); + expect(r.output?.hookSpecificOutput?.additionalContext).toContain("nao mexer no gerador de migration"); + }); + + test("SessionEnd consolida a sessao em pagina", () => { + const layout = projeto(); + hook(layout, { hook_event_name: "SessionStart", cwd: layout.root }); + const r = hook(layout, { hook_event_name: "SessionEnd", cwd: layout.root, reason: "clear" }); + + expect(r.nota).toContain("consolidada"); + const paginas = readFileSync(layout.chainPath, "utf8"); + expect(paginas).toContain("core:consolidate"); + }); + + test("trilha adulterada para o hook na porta, sem travar a sessao", () => { + const layout = projeto(); + hook(layout, { hook_event_name: "SessionStart", cwd: layout.root }); + const linhas = readFileSync(layout.chainPath, "utf8").split("\n").filter((l) => l !== ""); + writeFileSync(layout.chainPath, `${linhas.slice(0, -1).join("\n")}\n`); + + const r = hook(layout, { hook_event_name: "SessionEnd", cwd: layout.root, reason: "other" }); + expect(r.exitCode).toBe(EXIT.OK); + expect(r.nota).toContain("ancora"); + expect(r.output).toBeNull(); + }); + + test("falha na consolidacao nao derruba o fim da sessao de quem trabalha", () => { + const layout = projeto(); + hook(layout, { hook_event_name: "SessionStart", cwd: layout.root }); + mkdirSync(layout.memoryDir, { recursive: true }); + writeFileSync(join(layout.memoryDir, "consolidation.json"), '{"_type":"outra-coisa"}'); + + const r = hook(layout, { hook_event_name: "SessionEnd", cwd: layout.root, reason: "other" }); + expect(r.exitCode).toBe(EXIT.OK); + expect(r.nota).toContain("consolidacao falhou"); + }); +}); + +describe("hook: captura de prompt (R5.1)", () => { + test("o prompt entra na trilha, que e de onde a consolidacao le", () => { + const layout = projeto(); + hook(layout, { hook_event_name: "UserPromptSubmit", cwd: layout.root, prompt: "arruma o endpoint de login" }); + + const entrada = trilha(layout).at(-1)!; + expect(entrada.type).toBe("prompt.submit"); + expect(entrada.payload.text).toBe("arruma o endpoint de login"); + expect(entrada.payload.redacted).toBe(false); + }); + + test("prompt com marcador de segredo nao vira texto na trilha (R4.4)", () => { + const layout = projeto(); + hook(layout, { + hook_event_name: "UserPromptSubmit", + cwd: layout.root, + prompt: "usa a chave sk-ant-api03-EXEMPLO para o deploy", + }); + + const entrada = trilha(layout).at(-1)!; + // A trilha e append-only e encadeada: segredo que entra nao sai mais. + expect(entrada.payload.text).toBeNull(); + expect(entrada.payload.redacted).toBe(true); + expect(entrada.payload.redacted_marker).toBe("sk-ant-"); + expect(entrada.payload.chars).toBeGreaterThan(0); + }); + + test("prompt longo entra cortado, e o corte fica declarado", () => { + const layout = projeto(); + hook(layout, { hook_event_name: "UserPromptSubmit", cwd: layout.root, prompt: "a".repeat(5000) }); + const entrada = trilha(layout).at(-1)!; + expect(entrada.payload.truncated).toBe(true); + expect(String(entrada.payload.text)).toHaveLength(2000); + expect(entrada.payload.chars).toBe(5000); + }); +}); + +describe("hook: frescor depois da escrita (R2.4)", () => { + test("escrever em arvore observada avisa que a evidencia caiu", () => { + const layout = tempProject({ git: false }); + writeFile(layout, "src/a.ts", "x\n"); + harnessWith(layout, { + profile: "gate-only", + phases: [], + verifiers: [ + { + id: "coverage", + run: ["sh", "-c", "exit 0"], + extract: { kind: "lcov", file: "coverage/lcov.info", metric: "lines.pct" }, + watch: ["src/**"], + timeout_s: 30, + }, + ], + } as unknown as Partial & { phases: WorkflowContract["phases"] }).close(); + + const r = hook(layout, { + hook_event_name: "PostToolUse", + cwd: layout.root, + tool_name: "Write", + tool_input: { file_path: "src/a.ts" }, + tool_response: { success: true }, + }); + expect(r.output?.hookSpecificOutput?.hookEventName).toBe("PostToolUse"); + expect(r.output?.hookSpecificOutput?.additionalContext).toContain("coverage"); + }); + + test("escrita fora da arvore observada nao vira ruido", () => { + const layout = tempProject({ git: false }); + harnessWith(layout, { + profile: "gate-only", + phases: [], + verifiers: [ + { + id: "coverage", + run: ["sh", "-c", "exit 0"], + extract: { kind: "lcov", file: "coverage/lcov.info", metric: "lines.pct" }, + watch: ["src/**"], + timeout_s: 30, + }, + ], + } as unknown as Partial & { phases: WorkflowContract["phases"] }).close(); + + const r = hook(layout, { + hook_event_name: "PostToolUse", + cwd: layout.root, + tool_name: "Write", + tool_input: { file_path: "README.md" }, + }); + expect(r.output).toBeNull(); + }); +}); + +describe("hook: fora de projeto com harness", () => { + test("sessao em diretorio qualquer nao vira erro nem saida", () => { + const r = handleHook({ hook_event_name: "SessionStart", cwd: "/tmp" }); + expect(r.output).toBeNull(); + expect(r.exitCode).toBe(EXIT.OK); + }); + + test("payload sem evento nao faz nada", () => { + expect(handleHook({}).output).toBeNull(); + expect(handleHook(null).output).toBeNull(); + expect(handleHook("texto").exitCode).toBe(EXIT.OK); + }); + + test("evento que o adapter nao trata sai calado", () => { + const r = handleHook({ hook_event_name: "PreCompact", cwd: "/tmp" }); + expect(r.output).toBeNull(); + }); +}); + +describe("instalacao dos hooks (R8.6f)", () => { + const ARGV = ["/usr/bin/bun", "/opt/psh/src/index.ts"]; + + test("registra os cinco eventos em forma exec, sem shell no meio", () => { + const layout = projeto(); + const r = instalar(layout, { selfArgv: ARGV }); + expect(r.eventos).toHaveLength(5); + + const settings = JSON.parse(readFileSync(settingsPath(layout), "utf8")) as SettingsDoClaude; + const entrada = settings.hooks!.PreToolUse![0]!.hooks![0]!; + expect(entrada.type).toBe("command"); + expect(entrada.command).toBe("/usr/bin/bun"); + // Caminho com aspas ou cifrao nunca chega a um parser de shell. + expect(entrada.args).toEqual(["/opt/psh/src/index.ts", "adapter", "claude-code", "hook"]); + }); + + test("instalar duas vezes nao duplica registro", () => { + const layout = projeto(); + instalar(layout, { selfArgv: ARGV }); + const segunda = instalar(layout, { selfArgv: ARGV }); + + expect(segunda.removidos).toBe(5); + const settings = JSON.parse(readFileSync(settingsPath(layout), "utf8")) as SettingsDoClaude; + expect(settings.hooks!.PreToolUse).toHaveLength(1); + }); + + test("hook de terceiro no mesmo evento sobrevive", () => { + const layout = projeto(); + writeFile( + layout, + ".claude/settings.json", + JSON.stringify({ + hooks: { + PreToolUse: [{ matcher: "", hooks: [{ type: "command", command: "/opt/outra-ferramenta" }] }], + }, + permissions: { allow: ["Bash(git *)"] }, + }), + ); + + const r = instalar(layout, { selfArgv: ARGV }); + expect(r.preservados).toBe(1); + + const settings = JSON.parse(readFileSync(settingsPath(layout), "utf8")) as SettingsDoClaude; + const comandos = settings.hooks!.PreToolUse!.flatMap((g) => g.hooks!.map((h) => h.command)); + expect(comandos).toContain("/opt/outra-ferramenta"); + expect(comandos).toContain("/usr/bin/bun"); + // Chave que nao e nossa continua no arquivo. + expect(settings.permissions).toEqual({ allow: ["Bash(git *)"] }); + }); + + test("registro nosso apontando para artefato ausente e podado e contado", () => { + const layout = projeto(); + writeFile( + layout, + ".claude/settings.json", + JSON.stringify({ + hooks: { + SessionStart: [ + { + matcher: "", + hooks: [ + { type: "command", command: "/opt/psh-que-foi-desinstalado/psh", args: ["adapter", "claude-code", "hook"] }, + ], + }, + ], + }, + }), + ); + + const r = instalar(layout, { selfArgv: ARGV }); + expect(r.orfaos).toBe(1); + expect(r.removidos).toBe(1); + const settings = JSON.parse(readFileSync(settingsPath(layout), "utf8")) as SettingsDoClaude; + const comandos = settings.hooks!.SessionStart!.flatMap((g) => g.hooks!.map((h) => h.command)); + expect(comandos).not.toContain("/opt/psh-que-foi-desinstalado/psh"); + }); + + test("grupo que fica sem hook nenhum sai do arquivo", () => { + const { settings } = podar({ + hooks: { Stop: [{ matcher: "", hooks: [{ type: "command", command: "/x", args: ["adapter", "claude-code", "hook"] }] }] }, + }); + expect(settings.hooks).toBeUndefined(); + }); + + test("instalacao antiga em forma de shell tambem e reconhecida", () => { + expect(ehNosso({ type: "command", command: "psh adapter claude-code hook" })).toBe(true); + expect(ehNosso({ type: "command", command: "/opt/outra" })).toBe(false); + expect(ehNosso({ type: "command", command: "x", args: ["adapter", "claude-code", "hook"] })).toBe(true); + }); + + test("desinstalar tira o nosso e devolve a conta", () => { + const layout = projeto(); + instalar(layout, { selfArgv: ARGV }); + const r = desinstalar(layout); + expect(r.removidos).toBe(5); + expect(statusDoAdapter(layout).registrados).toBe(0); + }); + + test("--dry-run nao escreve", () => { + const layout = projeto(); + const r = instalar(layout, { selfArgv: ARGV, dryRun: true }); + expect(r.aplicado).toBe(false); + expect(existsSync(settingsPath(layout))).toBe(false); + }); +}); + +describe("status do adapter (R8.6c)", () => { + test("adapter carregado pela metade e falha visivel", async () => { + const layout = projeto(); + instalar(layout, { selfArgv: [process.execPath, "/opt/psh/src/index.ts"] }); + + // Alguem tirou um evento a mao: quatro de cinco significa uma + // responsabilidade do R8.1 que simplesmente nao acontece. + const settings = JSON.parse(readFileSync(settingsPath(layout), "utf8")) as SettingsDoClaude; + delete settings.hooks!.PostToolUse; + writeFileSync(settingsPath(layout), JSON.stringify(settings)); + + const s = statusDoAdapter(layout); + expect(s.registrados).toBe(4); + expect(s.completo).toBe(false); + expect(s.pontos.find((p) => p.evento === "PostToolUse")!.registrado).toBe(false); + + const r = await cli(["adapter", "claude-code", "status", "--root", layout.root]); + expect(r.code).toBe(EXIT.FAILURE); + expect(r.out).toContain("parcialmente carregado"); + }); + + test("registro apontando para artefato ausente aparece como orfao", () => { + const layout = projeto(); + instalar(layout, { selfArgv: ["/opt/psh-que-sumiu/psh"] }); + const s = statusDoAdapter(layout); + expect(s.orfaos).toBe(5); + expect(s.completo).toBe(false); + }); + + test("sem settings, o status diz que nao ha ponto ativo", () => { + const layout = projeto(); + const s = statusDoAdapter(layout); + expect(s.existe).toBe(false); + expect(s.registrados).toBe(0); + }); +}); + +describe("CLI do adapter", () => { + test("acao desconhecida nao vira status em silencio", async () => { + const layout = projeto(); + const r = await cli(["adapter", "claude-code", "ativar", "--root", layout.root]); + expect(r.code).toBe(EXIT.FAILURE); + expect(r.err).toContain("acao desconhecida"); + }); + + test("adapter desconhecido lista os que existem", async () => { + const r = await cli(["adapter", "opencode"]); + expect(r.code).toBe(EXIT.FAILURE); + expect(r.err).toContain("ci, claude-code"); + }); + + test("install e status conversam pelo disco", async () => { + const layout = projeto(); + const i = await cli(["adapter", "claude-code", "install", "--json", "--root", layout.root]); + expect(i.code).toBe(EXIT.OK); + const s = await cli(["adapter", "claude-code", "status", "--json", "--root", layout.root]); + const status = JSON.parse(s.out) as { registrados: number; completo: boolean }; + expect(status.registrados).toBe(5); + expect(status.completo).toBe(true); + }); +}); diff --git a/psh/tests/audit-chain.test.ts b/psh/tests/audit-chain.test.ts index 5a3de8b..8956f7f 100644 --- a/psh/tests/audit-chain.test.ts +++ b/psh/tests/audit-chain.test.ts @@ -174,3 +174,135 @@ describe("trilha encadeada por hash (R4.1, R4.2)", () => { db.close(); }); }); + +describe("nao se escreve em cima de trilha adulterada", () => { + test("append recusa quando o arquivo nao bate com a ancora", () => { + const { layout, db, chain } = chainWithAnchor(); + seed(chain, 3); + + // Some com a ultima linha: a cadeia continua internamente consistente, e e + // exatamente por isso que a ancora existe. + const linhas = lines(layout.chainPath); + writeFileSync(layout.chainPath, `${linhas.slice(0, -1).join("\n")}\n`); + + expect(() => chain.append("audit.note", "core:psh", {})).toThrow(AuditError); + expect(() => chain.append("audit.note", "core:psh", {})).toThrow(/ancora/); + db.close(); + }); + + test("sem a recusa, uma escrita qualquer apagaria o estrago do relatorio", () => { + const { layout, db, chain } = chainWithAnchor(); + seed(chain, 3); + writeFileSync(layout.chainPath, `${lines(layout.chainPath).slice(0, -1).join("\n")}\n`); + + // O `append` regrava a ancora com o topo novo. Se ele nao conferisse antes, + // a cadeia voltaria a fechar e `verify` diria "integra" para uma trilha de + // onde uma entrada foi removida. + expect(chain.verify().ok).toBe(false); + try { + chain.append("audit.note", "core:psh", {}); + } catch { + /* esperado */ + } + expect(chain.verify().ok).toBe(false); + db.close(); + }); + + test("trilha intacta continua aceitando escrita", () => { + const { db, chain } = chainWithAnchor(); + seed(chain, 2); + expect(() => chain.append("audit.note", "core:psh", {})).not.toThrow(); + expect(chain.verify().ok).toBe(true); + db.close(); + }); + + test("a recusa diz como sair, nao so que travou", () => { + const { layout, db, chain } = chainWithAnchor(); + seed(chain, 3); + writeFileSync(layout.chainPath, `${lines(layout.chainPath).slice(0, -1).join("\n")}\n`); + + // Travar sem saida deixa uma alternativa real so: apagar o `.harness` na + // mao, que e como a evidencia de adulteracao desaparece. + expect(() => chain.append("audit.note", "core:psh", {})).toThrow(/reanchor/); + db.close(); + }); +}); + +/** + * Campo 01, achado 10: a protecao funcionava e nao tinha caminho de volta. + * + * Reancorar nao conserta a trilha nem finge que a divergencia nao houve. Ele + * grava na propria trilha o que a ancora dizia, o que o arquivo diz e por que se + * decidiu seguir, e so entao move a ancora. Divergencia vira cicatriz legivel em + * vez de diretorio apagado no susto. + */ +describe("reancoragem e decisao humana registrada, nao conserto silencioso", () => { + /** Trunca a trilha mantendo o banco intacto: o estado do campo, reproduzido. */ + function divergir(layout: { chainPath: string }, manter: number): void { + writeFileSync(layout.chainPath, `${lines(layout.chainPath).slice(0, manter).join("\n")}\n`); + } + + test("destrava o projeto e deixa a divergencia escrita na trilha", () => { + const { layout, db, chain } = chainWithAnchor(); + seed(chain, 5); + divergir(layout, 2); + expect(chain.verify().ok).toBe(false); + + const { entry, anchorBefore } = chain.reanchor("trilha truncada por restauracao de backup", "human:mike"); + + expect(anchorBefore?.count).toBe(5); + expect(entry.type).toBe("audit.note"); + expect(entry.payload.reason).toBe("trilha truncada por restauracao de backup"); + expect(entry.payload.anchor_before).toEqual({ count: 5, head_hash: anchorBefore!.head_hash }); + expect(entry.payload.file_at_reanchor).toMatchObject({ count: 2 }); + + // Destravou de verdade, e o registro da divergencia ficou. + expect(chain.verify().ok).toBe(true); + expect(() => chain.append("audit.note", "core:psh", {})).not.toThrow(); + expect(chain.read().some((e) => e.payload.note === "reancoragem da trilha por decisao humana")).toBe(true); + db.close(); + }); + + test("sem motivo escrito nao reancora", () => { + const { layout, db, chain } = chainWithAnchor(); + seed(chain, 3); + divergir(layout, 1); + + expect(() => chain.reanchor("", "human:mike")).toThrow(AuditError); + expect(() => chain.reanchor(" ", "human:mike")).toThrow(/motivo/); + expect(chain.verify().ok).toBe(false); + db.close(); + }); + + test("recusa quando o defeito esta dentro do arquivo, porque mover a ancora nao conserta isso", () => { + const { layout, db, chain } = chainWithAnchor(); + seed(chain, 3); + + // Edicao de payload no meio: a linha deixa de fechar com o proprio hash. + const linhas = lines(layout.chainPath); + const adulterada = JSON.parse(linhas[1]!) as { payload: Record }; + adulterada.payload = { i: 999 }; + linhas[1] = canonicalJson(adulterada); + writeFileSync(layout.chainPath, `${linhas.join("\n")}\n`); + + expect(() => chain.reanchor("quero destravar", "human:mike")).toThrow(/nao conserta/); + expect(chain.verify().ok).toBe(false); + db.close(); + }); + + test("a entrada de reancoragem encadeia no topo real e nao apaga o que sobrou", () => { + const { layout, db, chain } = chainWithAnchor(); + seed(chain, 4); + const antes = chain.read(); + divergir(layout, 2); + + const { entry } = chain.reanchor("disco cheio truncou a trilha", "human:mike"); + const depois = chain.read(); + + expect(depois).toHaveLength(3); + expect(depois.slice(0, 2)).toEqual(antes.slice(0, 2)); + expect(entry.prev_hash).toBe(antes[1]!.hash); + expect(entry.seq).toBe(3); + db.close(); + }); +}); diff --git a/psh/tests/boundary-aijail.test.ts b/psh/tests/boundary-aijail.test.ts index e84d6de..a388f6a 100644 --- a/psh/tests/boundary-aijail.test.ts +++ b/psh/tests/boundary-aijail.test.ts @@ -6,6 +6,8 @@ import { join } from "node:path"; import { BoundaryPolicy } from "../src/boundary/policy.ts"; import { execUnderBoundary } from "../src/boundary/execute.ts"; import { layoutFor, type Layout } from "../src/util/paths.ts"; +import { runVerifier } from "../src/evidence/runner.ts"; +import type { VerifierSpec } from "../src/workflow/types.ts"; import type { SandboxStatus } from "../src/evidence/sandbox.ts"; /** @@ -134,7 +136,7 @@ describe.skipIf(!JAIL_OK)("fronteira aplicada pelo kernel, com ai-jail real", () expect(r.violations[0]!.action).toBe("deleted"); }); - test("o .ai-jail que o proprio sandbox grava nao vira violacao do agente", () => { + test("corrida limpa nao inventa violacao", () => { const { layout, policy } = projetoForaDoTmp(); const r = comJaula(layout, policy, "true"); expect(r.violations).toEqual([]); @@ -147,3 +149,194 @@ describe.skipIf(!JAIL_OK)("fronteira aplicada pelo kernel, com ai-jail real", () expect(r.exit_code).not.toBe(0); }); }); + +describe.skipIf(!JAIL_OK)("a jaula e montada so a partir do contrato do psh", () => { + test("nenhum arquivo de configuracao e deixado no projeto", () => { + const { layout, policy } = projetoForaDoTmp(); + comJaula(layout, policy, "true"); + // Por padrao o ai-jail grava um `.ai-jail` na raiz e o le na proxima + // corrida. O arquivo mora dentro da arvore que o agente edita, e a fronteira + // nao pode depender, nem em parte, de algo que o enjaulado escreve. + expect(existsSync(join(layout.root, ".ai-jail"))).toBe(false); + }); + + test("a segunda corrida enjaula igual a primeira", () => { + const { layout, policy } = projetoForaDoTmp(); + for (const tentativa of [1, 2, 3]) { + const r = comJaula(layout, policy, `echo invadido-${tentativa} > src/web/app.tsx`); + expect(r.exit_code).not.toBe(0); + expect(ler(layout, "src/web/app.tsx")).toBe("web original\n"); + expect(r.stderr).not.toContain("rule not applied"); + } + }); + + test("configuracao plantada na raiz nao muda a fronteira", () => { + const { layout, policy } = projetoForaDoTmp(); + writeFileSync( + join(layout.root, ".ai-jail"), + 'command = ["true"]\ndeny_paths = []\nnetwork = true\n', + ); + const r = comJaula(layout, policy, "echo invadido > src/web/app.tsx"); + expect(r.exit_code).not.toBe(0); + expect(ler(layout, "src/web/app.tsx")).toBe("web original\n"); + }); + + test("a memoria fica fora de alcance do agente, pelo kernel (R5.4)", () => { + const { layout, policy } = projetoForaDoTmp(); + mkdirSync(layout.memoryPagesDir, { recursive: true }); + writeFileSync(join(layout.memoryPagesDir, "fato.md"), "pagina original\n"); + + const r = comJaula(layout, policy, "echo injetado > .harness/memory/pages/fato.md"); + + // O bloco de handoff entra no inicio da sessao seguinte: memoria que o + // agente escreve e texto que ele injeta em si mesmo depois. + expect(r.exit_code).not.toBe(0); + expect(ler(layout, ".harness/memory/pages/fato.md")).toBe("pagina original\n"); + expect(r.violations).toEqual([]); + }); + + test("o id do agente atravessa a jaula, que zera o ambiente do filho", () => { + const { layout, policy } = projetoForaDoTmp(); + // Medido contra o 1.19.2: sem `--env` explicito no argv, `PSH_AGENT` chega + // vazio la dentro e `psh remember` do agente nasceria assinado como humano. + const r = comJaula(layout, policy, 'printf "[%s]" "$PSH_AGENT"'); + expect(r.stdout).toContain("[backend]"); + }); +}); + +/** + * Campo 01, achado 1 e 2. + * + * O `.ai-jail` de projeto e politica monotonica: ele so restringe, e a linha de + * comando nao consegue reabrir o que ele fechou. Enquanto o `psh exec` ja + * passava `--clean --no-save-config`, o caminho do verificador nao passava, e + * era justamente o caminho que produz valor de portao. + * + * O sintoma media dinheiro: o proprio psh gravava `.ai-jail` com `network = + * false` ao rodar um verificador sem rede, e o verificador seguinte, declarado + * com `network: true`, rodava sem rede. O comando falhava por conexao, o + * relatorio saia com zero acerto, e esse zero virava valor de portao. + * + * O teste usa `lockdown` em vez de rede porque o efeito e o mesmo (config de + * projeto apertando a corrida) e nao depende de internet para ser observado. + */ +describe.skipIf(!JAIL_OK)("config de projeto do ai-jail nao alcanca o verificador", () => { + const spec: VerifierSpec = { + id: "escreve", + run: ["/bin/sh", "-c", "echo gerado > src/api/gerado.txt"], + extract: { kind: "exit-code" }, + watch: ["src/web/**"], + timeout_s: 60, + }; + + const sandboxReal = (): SandboxStatus => ({ + mode: "ai-jail", + detail: "binario real", + jail_bin: JAIL!, + jail_version: "real", + }); + + test("um .ai-jail hostil no projeto nao aperta a corrida do verificador", () => { + const { layout } = projetoForaDoTmp(); + writeFileSync(join(layout.root, ".ai-jail"), "lockdown = true\n"); + + const { record } = runVerifier({ layout, spec, phase: "f", attempt: 1, sandbox: sandboxReal() }); + + // Sem `--clean`, o lockdown do arquivo deixaria a arvore somente leitura e + // o `echo` morreria com "Read-only file system". + expect(record.status).toBe("ok"); + expect(record.exit_code).toBe(0); + expect(ler(layout, "src/api/gerado.txt")).toBe("gerado\n"); + }); + + test("o verificador nao deixa .ai-jail para tras, nem reescreve o que existe", () => { + const { layout } = projetoForaDoTmp(); + const alvo = join(layout.root, ".ai-jail"); + + runVerifier({ layout, spec, phase: "f", attempt: 1, sandbox: sandboxReal() }); + expect(existsSync(alvo)).toBe(false); + + writeFileSync(alvo, "lockdown = true\n"); + runVerifier({ layout, spec, phase: "f", attempt: 2, sandbox: sandboxReal() }); + expect(readFileSync(alvo, "utf8")).toBe("lockdown = true\n"); + }); +}); + +/** + * Campo 01, achado 1: a invariante que o bug violava. + * + * sandbox(corrida atual) nao depende de sandbox(corrida anterior) + * + * Aqui a rede e medida de verdade, porque era exatamente a capacidade que + * chegava atrasada uma corrida. `curl` sem rede morre com 6 (host nao resolve) e + * com rede alcanca o endpoint. O teste nao olha o corpo da resposta, so se o + * processo chegou la, entao nao depende de status HTTP nem de credencial. + * + * Depende de internet, e por isso se declara `skip` quando ela nao existe: + * passar sem exercitar seria pior do que nao existir. + */ +const CURL = "/usr/bin/curl"; +const ALVO_HTTP = "https://example.com"; +const INTERNET_OK = (() => { + if (!existsSync(CURL)) return false; + return spawnSync(CURL, ["-sS", "-m", "8", "-o", "/dev/null", ALVO_HTTP]).status === 0; +})(); + +describe.skipIf(!JAIL_OK || !INTERNET_OK)("a rede da corrida nao depende da corrida anterior", () => { + const sandboxReal = (): SandboxStatus => ({ + mode: "ai-jail", + detail: "binario real", + jail_bin: JAIL!, + jail_version: "real", + }); + + function specCurl(id: string, network: boolean): VerifierSpec { + return { + id, + run: [CURL, "-sS", "-m", "15", "-o", "/dev/null", ALVO_HTTP], + extract: { kind: "exit-code" }, + watch: ["src/web/**"], + timeout_s: 60, + network, + }; + } + + /** Devolve o codigo de saida do curl dentro da jaula. */ + function corrida(layout: Layout, id: string, network: boolean, attempt: number): number | null { + const { record } = runVerifier({ + layout, + spec: specCurl(id, network), + phase: "f", + attempt, + sandbox: sandboxReal(), + }); + return record.exit_code; + } + + test("sem rede primeiro, com rede depois: a segunda alcanca a rede", () => { + const { layout } = projetoForaDoTmp(); + expect(corrida(layout, "sem-rede", false, 1)).not.toBe(0); + expect(corrida(layout, "com-rede", true, 2)).toBe(0); + }); + + test("com rede primeiro, sem rede depois: a terceira ainda alcanca a rede", () => { + const { layout } = projetoForaDoTmp(); + expect(corrida(layout, "com-rede", true, 1)).toBe(0); + expect(corrida(layout, "sem-rede", false, 2)).not.toBe(0); + expect(corrida(layout, "com-rede", true, 3)).toBe(0); + }); + + test("o resultado de uma corrida com rede e o mesmo em qualquer ordem", () => { + const depoisDeSemRede = (() => { + const { layout } = projetoForaDoTmp(); + corrida(layout, "sem-rede", false, 1); + return corrida(layout, "com-rede", true, 2); + })(); + const semPredecessor = (() => { + const { layout } = projetoForaDoTmp(); + return corrida(layout, "com-rede", true, 1); + })(); + expect(depoisDeSemRede).toBe(semPredecessor); + expect(depoisDeSemRede).toBe(0); + }); +}); diff --git a/psh/tests/boundary-exec.test.ts b/psh/tests/boundary-exec.test.ts index 8e7eb10..fed3750 100644 --- a/psh/tests/boundary-exec.test.ts +++ b/psh/tests/boundary-exec.test.ts @@ -241,10 +241,29 @@ describe("montagem para o ai-jail (R3.1 camada 1, R3.4)", () => { expect(negados).not.toContain(join(layout.root, "src")); }); - test("o .ai-jail que o sandbox grava nao vira violacao do agente", () => { + test("o argv desliga a configuracao persistida do ai-jail", () => { + const { layout, policy } = cenario(); + const argv = montarArgvEnjaulado({ layout, policy, agentId: "backend", argv: ["true"] }, "/bin/ai-jail"); + // Sem estas duas, o ai-jail grava um `.ai-jail` na raiz e o le na corrida + // seguinte: parte da fronteira passaria a vir de um arquivo que mora dentro + // da arvore que o agente edita. + expect(argv).toContain("--clean"); + expect(argv).toContain("--no-save-config"); + }); + + test("o id do agente vai no argv, porque a jaula zera o ambiente", () => { + const { layout, policy } = cenario(); + const argv = montarArgvEnjaulado({ layout, policy, agentId: "backend", argv: ["true"] }, "/bin/ai-jail"); + expect(argv).toContain("--env"); + expect(argv).toContain("PSH_AGENT=backend"); + }); + + test("configuracao de sandbox criada pelo enjaulado vira violacao", () => { const { layout, policy } = cenario(); const bin = join(layout.root, "jail-que-escreve"); - // Wrapper que imita o ai-jail: grava o proprio .ai-jail antes de executar. + // Wrapper que imita a versao antiga do ai-jail, que gravava o proprio + // `.ai-jail`. Hoje o argv desliga essa escrita, entao o arquivo aparecendo + // significa que alguem dentro da jaula o escreveu - e isso e violacao. writeFileSync( bin, '#!/bin/sh\nwhile [ "$1" != "--" ]; do shift; done\nshift\necho config > "$(dirname "$0")/.ai-jail"\nexec "$@"\n', @@ -257,14 +276,18 @@ describe("montagem para o ai-jail (R3.1 camada 1, R3.4)", () => { argv: ["sh", "-c", "true"], sandbox: { mode: "ai-jail", detail: "falso", jail_bin: bin, jail_version: "1" }, }); - expect(r.violations).toEqual([]); + expect(r.violations.map((v) => v.path)).toContain(".ai-jail"); + expect(existsSync(join(layout.root, ".ai-jail"))).toBe(false); }); - test("o arquivo de configuracao do proprio ai-jail nao entra no deny", () => { + test("configuracao de sandbox deixada na raiz entra no deny", () => { const { layout, policy } = cenario(); writeFile(layout, ".ai-jail", "config\n"); const negados = caminhosNegados({ layout, policy, agentId: "backend", argv: ["true"] }); - expect(negados.some((n) => n.endsWith("/.ai-jail"))).toBe(false); + // Enquanto o sandbox gravava este arquivo, ele precisava ficar de fora do + // deny. Com `--no-save-config` ele e um arquivo qualquer do projeto, e a + // regra do agente vale para ele como para qualquer outro. + expect(negados.some((n) => n.endsWith("/.ai-jail"))).toBe(true); }); test("agente com allowlist ampla nega so o deny duro", () => { diff --git a/psh/tests/cli-inprocess.test.ts b/psh/tests/cli-inprocess.test.ts index ef3ea5c..8d32722 100644 --- a/psh/tests/cli-inprocess.test.ts +++ b/psh/tests/cli-inprocess.test.ts @@ -262,6 +262,42 @@ describe("approve e audit pela CLI", () => { expect(r.code).toBe(EXIT.FAILURE); expect(r.err).toContain("subcomando desconhecido"); }); + + /** + * Campo 01, achado 10. Trunca a trilha com o banco intacto, que e o unico + * estado onde a divergencia foi reproduzivel, e cobra o ciclo inteiro pela + * CLI: travado, diagnosticado, reancorado com motivo, destravado. + */ + test("reanchor devolve um projeto travado por divergencia de ancora", async () => { + const layout = projeto(); + await cli(["verify", "--root", layout.root]); + await cli(["advance", "--root", layout.root]); + + const linhas = readFileSync(layout.chainPath, "utf8").split("\n").filter((l) => l.trim() !== ""); + expect(linhas.length).toBeGreaterThan(1); + writeFileSync(layout.chainPath, `${linhas.slice(0, 1).join("\n")}\n`); + + const travado = await cli(["approve", "brief.md", "--root", layout.root]); + expect(travado.code).not.toBe(EXIT.OK); + expect(travado.err).toContain("reanchor"); + + const semMotivo = await cli(["audit", "reanchor", "--root", layout.root]); + expect(semMotivo.code).toBe(EXIT.FAILURE); + expect(semMotivo.err).toContain("--reason"); + + const r = await cli([ + "audit", "reanchor", "--reason", "trilha truncada na restauracao do backup de ontem", + "--as", "michael", "--root", layout.root, + ]); + expect(r.code).toBe(EXIT.OK); + expect(r.out).toContain("michael"); + expect(r.out).toContain("trilha truncada"); + + // Destravou, e o motivo ficou na trilha em vez de na memoria de quem rodou. + const depois = await cli(["approve", "brief.md", "--root", layout.root]); + expect(depois.code).toBe(EXIT.OK); + expect(readFileSync(layout.chainPath, "utf8")).toContain("trilha truncada na restauracao"); + }); }); describe("doctor e internal pela CLI", () => { @@ -346,6 +382,23 @@ describe("parser de argumento", () => { const a = parseArgs(["--turbo"]); expect(() => rejectUnknownFlags(a, ["json", "root"], "verify")).toThrow(/--json/); }); + + test("traco simples e recusado em vez de virar posicional ignorado", () => { + // 'psh audit log -n 5' respondia com o limite padrao e sem erro: o '-n' e o + // '5' viravam posicionais que ninguem lia. + expect(() => parseArgs(["log", "-n", "5"])).toThrow(PshError); + expect(() => parseArgs(["log", "-n", "5"])).toThrow(/--n/); + }); + + test("numero negativo e traco sozinho continuam sendo texto", () => { + expect(parseArgs(["-5"]).positional).toEqual(["-5"]); + expect(parseArgs(["-"]).positional).toEqual(["-"]); + }); + + test("depois de -- o traco simples volta a ser texto", () => { + const a = parseArgs(["exec", "--", "sh", "-c", "echo oi"]); + expect(a.positional).toEqual(["exec", "sh", "-c", "echo oi"]); + }); }); describe("ordem de flag e subcomando (regressao do parser)", () => { diff --git a/psh/tests/memory-cli.test.ts b/psh/tests/memory-cli.test.ts new file mode 100644 index 0000000..baf9fa6 --- /dev/null +++ b/psh/tests/memory-cli.test.ts @@ -0,0 +1,688 @@ +import { afterAll, afterEach, describe, expect, test } from "bun:test"; +import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { runCli } from "../src/index.ts"; +import { captureIo } from "../src/cli/io.ts"; +import { EXIT } from "../src/util/errors.ts"; +import { AuditChain } from "../src/audit/chain.ts"; +import { DENY_ALWAYS } from "../src/boundary/policy.ts"; +import { buildHandoff, renderHandoff, MAX_PINNED_NO_BLOCO } from "../src/memory/handoff.ts"; +import { openProject } from "../src/cli/context.ts"; +import { remember } from "../src/cli/memory.ts"; +import { boundaryOf } from "../src/cli/boundary.ts"; +import { execUnderBoundary } from "../src/boundary/execute.ts"; +import type { WorkflowContract } from "../src/workflow/types.ts"; +import type { Layout } from "../src/util/paths.ts"; +import { cleanupTempProjects, harnessWith, tempProject, writeFile } from "./helpers.ts"; + +afterAll(cleanupTempProjects); + +let restaurar: (() => void) | null = null; +afterEach(() => { + restaurar?.(); + restaurar = null; +}); + +async function cli(argv: string[]): Promise<{ code: number; out: string; err: string }> { + const cap = captureIo(); + restaurar = cap.restore; + const code = await runCli(argv); + cap.restore(); + restaurar = null; + return { code, out: cap.out.join(""), err: cap.err.join("") }; +} + +const CONTRATO: Partial & { phases: WorkflowContract["phases"] } = { + profile: "gate-only", + verifiers: [], + phases: [], +}; + +function projeto(): Layout { + const layout = tempProject({ git: false }); + harnessWith(layout, CONTRATO).close(); + return layout; +} + +function trilha(layout: Layout) { + return new AuditChain(layout.chainPath).read(); +} + +describe("psh remember (R5.5)", () => { + test("anota, fixa e deixa a pagina em disco", async () => { + const layout = projeto(); + const r = await cli(["remember", "o ai-jail recusa rw-map sobreposto", "--root", layout.root]); + expect(r.code).toBe(EXIT.OK); + + const pagina = join(layout.memoryPagesDir, "o-ai-jail-recusa-rw-map-sobreposto.md"); + expect(existsSync(pagina)).toBe(true); + const texto = readFileSync(pagina, "utf8"); + expect(texto).toContain("pinned: true"); + expect(texto).toContain("kind: fact"); + expect(texto).toContain("o ai-jail recusa rw-map sobreposto"); + }); + + test("a escrita entra na trilha, com o hash do que foi escrito (R4.3)", async () => { + const layout = projeto(); + await cli(["remember", "fato auditado", "--root", layout.root]); + const entrada = trilha(layout).at(-1)!; + expect(entrada.type).toBe("memory.write"); + expect(entrada.payload.slug).toBe("fato-auditado"); + expect(String(entrada.payload.content_sha256)).toStartWith(""); + expect(entrada.payload.pinned).toBe(true); + }); + + test("fato vazio e uso errado, nao pagina vazia", async () => { + const layout = projeto(); + const r = await cli(["remember", " ", "--root", layout.root]); + expect(r.code).toBe(EXIT.FAILURE); + expect(r.err).toContain("psh remember"); + }); + + test("tipo desconhecido para de vez, em vez de virar 'note'", async () => { + const layout = projeto(); + const r = await cli(["remember", "x", "--kind", "inventado", "--root", layout.root]); + expect(r.code).toBe(EXIT.CONTRACT_INVALID); + expect(r.err).toContain("tipo de memoria desconhecido"); + }); + + test("titulo e tags explicitos chegam ao arquivo", async () => { + const layout = projeto(); + await cli([ + "remember", + "corpo longo do fato", + "--title", + "titulo curto", + "--tags", + "fronteira, ai-jail,", + "--kind", + "decision", + "--root", + layout.root, + ]); + const texto = readFileSync(join(layout.memoryPagesDir, "titulo-curto.md"), "utf8"); + expect(texto).toContain("title: titulo curto"); + expect(texto).toContain("tags: fronteira, ai-jail"); + expect(texto).toContain("kind: decision"); + }); +}); + +describe("psh memory", () => { + test("search acha o que remember gravou, e diz por qual modo", async () => { + const layout = projeto(); + await cli(["remember", "a fronteira monta por complemento", "--root", layout.root]); + const r = await cli(["memory", "search", "complemento", "--json", "--root", layout.root]); + expect(r.code).toBe(EXIT.OK); + const resultado = JSON.parse(r.out) as { mode: string; hits: { slug: string }[] }; + expect(resultado.mode).toBe("fts5"); + expect(resultado.hits.map((h) => h.slug)).toEqual(["a-fronteira-monta-por-complemento"]); + }); + + test("busca sem resultado e resposta, nao erro", async () => { + const layout = projeto(); + await cli(["remember", "qualquer coisa", "--root", layout.root]); + const r = await cli(["memory", "search", "sagitario", "--root", layout.root]); + expect(r.code).toBe(EXIT.OK); + expect(r.out).toContain("nenhum resultado"); + }); + + test("list separa fixada de solta e conta o que examinou", async () => { + const layout = projeto(); + await cli(["remember", "fato fixado", "--root", layout.root]); + const r = await cli(["memory", "list", "--json", "--root", layout.root]); + const resultado = JSON.parse(r.out) as { pages: { slug: string; pinned: boolean }[]; sync: { pages_examined: number } }; + expect(resultado.pages).toHaveLength(1); + expect(resultado.pages[0]!.pinned).toBe(true); + expect(resultado.sync.pages_examined).toBe(1); + }); + + test("get devolve a pagina como ela esta no disco", async () => { + const layout = projeto(); + await cli(["remember", "fato para ler", "--root", layout.root]); + const r = await cli(["memory", "get", "fato-para-ler", "--root", layout.root]); + expect(r.code).toBe(EXIT.OK); + expect(r.out).toContain("slug: fato-para-ler"); + }); + + test("get de slug inexistente falha dizendo o slug", async () => { + const layout = projeto(); + const r = await cli(["memory", "get", "nao-existe", "--root", layout.root]); + expect(r.code).toBe(EXIT.FAILURE); + expect(r.err).toContain("nao-existe"); + }); + + test("get de slug com caminho e recusado antes de tocar no disco", async () => { + const layout = projeto(); + const r = await cli(["memory", "get", "../../etc/passwd", "--root", layout.root]); + expect(r.code).toBe(EXIT.CONTRACT_INVALID); + expect(r.err).toContain("slug de memoria invalido"); + }); + + test("reindex reporta pagina corrompida e falha (R2.13)", async () => { + const layout = projeto(); + await cli(["remember", "fato bom", "--root", layout.root]); + writeFileSync(join(layout.memoryPagesDir, "fato-bom.md"), "isto nao e pagina\n"); + const r = await cli(["memory", "reindex", "--json", "--root", layout.root]); + expect(r.code).toBe(EXIT.FAILURE); + const sync = JSON.parse(r.out) as { unreadable: { slug: string }[] }; + expect(sync.unreadable[0]!.slug).toBe("fato-bom"); + }); + + test("subcomando desconhecido nao vira list em silencio", async () => { + const layout = projeto(); + const r = await cli(["memory", "esquecer", "--root", layout.root]); + expect(r.code).toBe(EXIT.FAILURE); + expect(r.err).toContain("subcomando desconhecido"); + }); +}); + +describe("psh memory promote (R5.7)", () => { + test("promove para o repositorio e deixa a pagina apontando para o destino", async () => { + const layout = projeto(); + await cli(["remember", "decisao que precisa sobreviver", "--root", layout.root]); + const r = await cli(["memory", "promote", "decisao-que-precisa-sobreviver", "--root", layout.root]); + expect(r.code).toBe(EXIT.OK); + + const destino = join(layout.root, "docs", "decisoes", "decisao-que-precisa-sobreviver.md"); + expect(existsSync(destino)).toBe(true); + const promovido = readFileSync(destino, "utf8"); + expect(promovido).toContain("# decisao que precisa sobreviver"); + expect(promovido).toContain("Promovido de `.harness/memory/pages/decisao-que-precisa-sobreviver.md`"); + + const pagina = readFileSync(join(layout.memoryPagesDir, "decisao-que-precisa-sobreviver.md"), "utf8"); + expect(pagina).toContain("promoted_to: docs/decisoes/decisao-que-precisa-sobreviver.md"); + }); + + test("a promocao entra na trilha", async () => { + const layout = projeto(); + await cli(["remember", "fato promovido", "--root", layout.root]); + await cli(["memory", "promote", "fato-promovido", "--root", layout.root]); + const entrada = trilha(layout).at(-1)!; + expect(entrada.type).toBe("memory.promote"); + expect(entrada.payload.to).toBe("docs/decisoes/fato-promovido.md"); + }); + + test("nao sobrescreve destino existente sem --force", async () => { + const layout = projeto(); + await cli(["remember", "fato repetido", "--root", layout.root]); + writeFile(layout, "docs/decisoes/fato-repetido.md", "conteudo humano que nao pode sumir\n"); + + const r = await cli(["memory", "promote", "fato-repetido", "--root", layout.root]); + expect(r.code).toBe(EXIT.FAILURE); + expect(readFileSync(join(layout.root, "docs/decisoes/fato-repetido.md"), "utf8")).toContain("conteudo humano"); + + const forcado = await cli(["memory", "promote", "fato-repetido", "--force", "--root", layout.root]); + expect(forcado.code).toBe(EXIT.OK); + expect(readFileSync(join(layout.root, "docs/decisoes/fato-repetido.md"), "utf8")).toContain("# fato repetido"); + }); + + test("destino fora do projeto e violacao de fronteira", async () => { + const layout = projeto(); + await cli(["remember", "fato viajante", "--root", layout.root]); + const r = await cli([ + "memory", + "promote", + "fato-viajante", + "--to", + "../fora-do-projeto.md", + "--root", + layout.root, + ]); + expect(r.code).toBe(EXIT.BOUNDARY_VIOLATION); + expect(existsSync(join(layout.root, "..", "fora-do-projeto.md"))).toBe(false); + }); + + test("destino dentro de .harness/ e recusado: promover e sair da faixa transitoria", async () => { + const layout = projeto(); + await cli(["remember", "fato interno", "--root", layout.root]); + const r = await cli([ + "memory", + "promote", + "fato-interno", + "--to", + ".harness/evidence/forjado.md", + "--root", + layout.root, + ]); + expect(r.code).toBe(EXIT.BOUNDARY_VIOLATION); + expect(existsSync(join(layout.harness, "evidence", "forjado.md"))).toBe(false); + }); + + test("destino absoluto e recusado", async () => { + const layout = projeto(); + await cli(["remember", "fato absoluto", "--root", layout.root]); + const r = await cli(["memory", "promote", "fato-absoluto", "--to", "/tmp/x.md", "--root", layout.root]); + expect(r.code).toBe(EXIT.FAILURE); + expect(r.err).toContain("relativo a raiz"); + }); +}); + +describe("memoria fora do alcance do agente", () => { + test("o deny duro cobre .harness/memory/ (R5.4)", () => { + expect(DENY_ALWAYS).toContain(".harness/memory/**"); + expect(DENY_ALWAYS).toContain(".harness/memory"); + }); + + test("boundary check bloqueia escrita de pagina mesmo com write ['**']", async () => { + const layout = projeto(); + const r = await cli([ + "boundary", + "check", + ".harness/memory/pages/injetada.md", + "--json", + "--root", + layout.root, + ]); + expect(r.code).toBe(EXIT.BOUNDARY_VIOLATION); + const decisao = JSON.parse(r.out) as { allowed: boolean; rule: string; reason: string }; + expect(decisao.allowed).toBe(false); + expect(decisao.rule).toBe("deny-always"); + expect(decisao.reason).toContain(".harness/memory/**"); + }); +}); + +describe("psh handoff (R5.4)", () => { + test("o bloco carrega fase, pendencia e memoria fixada", async () => { + const layout = tempProject({ git: false }); + harnessWith(layout, { + profile: "lean", + verifiers: [], + phases: [ + { + id: "build", + name: "Build", + terminal: true, + next: [], + gate: { + type: "all-of", + checks: [{ kind: "presence", file: "docs/spec.md", min_lines: 5 }], + on_fail: { action: "block", message: "spec ausente" }, + }, + on_failure: { class: "quality", max_auto_retries: 1 }, + }, + ], + }).close(); + + await cli(["remember", "nao mexer no gerador de migration", "--root", layout.root]); + + const r = await cli(["handoff", "--root", layout.root]); + expect(r.code).toBe(EXIT.OK); + expect(r.out).toContain("Retomada de sessao"); + expect(r.out).toContain("build"); + expect(r.out).toContain("nao mexer no gerador de migration"); + expect(r.out).toContain("docs/spec.md"); + expect(r.out).toContain("psh verify --all"); + }); + + test("o json diz o que a pendencia observou e o que esperava", async () => { + const layout = tempProject({ git: false }); + harnessWith(layout, { + profile: "lean", + verifiers: [], + phases: [ + { + id: "build", + name: "Build", + terminal: true, + next: [], + gate: { + type: "all-of", + checks: [{ kind: "presence", file: "docs/spec.md", min_lines: 5 }], + on_fail: { action: "block", message: "spec ausente" }, + }, + on_failure: { class: "quality", max_auto_retries: 1 }, + }, + ], + }).close(); + + const r = await cli(["handoff", "--json", "--root", layout.root]); + const h = JSON.parse(r.out) as { + _type: string; + phase: string; + pending: { label: string; observed: string }[]; + next_commands: string[]; + }; + expect(h._type).toBe("psh-handoff"); + expect(h.phase).toBe("build"); + expect(h.pending[0]!.observed).toBe("sem evidencia"); + expect(h.next_commands).toContain("psh verify --all"); + }); + + test("sem pendencia o proximo passo e avancar", () => { + const layout = projeto(); + const ctx = openProject(layout.root); + try { + const h = buildHandoff(ctx); + expect(h.pending).toEqual([]); + expect(h.next_commands).toEqual(["psh status"]); + expect(renderHandoff(h)).toContain("Nenhum check do portao esta reprovado agora"); + } finally { + ctx.close(); + } + }); + + test("trilha comprometida vira o unico proximo passo", () => { + const layout = projeto(); + const ctx = openProject(layout.root); + try { + remember(ctx, { fact: "algo" }); + } finally { + ctx.close(); + } + const linhas = readFileSync(layout.chainPath, "utf8").split("\n").filter((l) => l !== ""); + writeFileSync(layout.chainPath, `${linhas.slice(0, -1).join("\n")}\n`); + + const ctx2 = openProject(layout.root); + try { + const h = buildHandoff(ctx2); + expect(h.audit_ok).toBe(false); + expect(h.next_commands).toEqual(["psh audit verify"]); + } finally { + ctx2.close(); + } + }); + + test("a fixada aparece em 'fixada' e nao se repete em 'recente'", () => { + const layout = projeto(); + const ctx = openProject(layout.root); + try { + remember(ctx, { fact: "fixada de verdade" }); + remember(ctx, { fact: "solta", pinned: false }); + const h = buildHandoff(ctx); + expect(h.pinned.map((p) => p.slug)).toEqual(["fixada-de-verdade"]); + expect(h.recent.map((p) => p.slug)).toEqual(["solta"]); + } finally { + ctx.close(); + } + }); +}); + +describe("psh doctor: estado da memoria", () => { + test("reporta o mecanismo de busca e quantas paginas examinou", async () => { + const layout = projeto(); + await cli(["remember", "fato diagnosticado", "--root", layout.root]); + const r = await cli(["doctor", "--json", "--root", layout.root]); + const report = JSON.parse(r.out) as { checks: { id: string; level: string; message: string }[] }; + const fts = report.checks.find((c) => c.id === "memory-fts")!; + const pages = report.checks.find((c) => c.id === "memory-pages")!; + expect(fts.level).toBe("ok"); + expect(fts.message).toContain("FTS5"); + expect(pages.level).toBe("ok"); + expect(pages.message).toContain("1 pagina(s)"); + }); + + test("pagina ilegivel derruba o diagnostico, em vez de sumir da busca em silencio", async () => { + const layout = projeto(); + await cli(["remember", "fato que vai corromper", "--root", layout.root]); + writeFileSync(join(layout.memoryPagesDir, "fato-que-vai-corromper.md"), "lixo\n"); + + const r = await cli(["doctor", "--json", "--root", layout.root]); + expect(r.code).toBe(EXIT.FAILURE); + const report = JSON.parse(r.out) as { checks: { id: string; level: string; detail?: string }[] }; + const pages = report.checks.find((c) => c.id === "memory-pages")!; + expect(pages.level).toBe("fail"); + expect(pages.detail).toContain("fato-que-vai-corromper"); + }); +}); + +describe("psh init prepara a faixa de memoria", () => { + test("cria o diretorio de paginas e mantem a memoria fora do repositorio (R5.7)", () => { + const layout = tempProject({ git: false }); + const r = Bun.spawnSync([process.execPath, join(import.meta.dir, "..", "src", "index.ts"), "init", "--profile", "gate-only", "--yes", "--root", layout.root], { + env: { ...process.env, PSH_SANDBOX: "off" }, + }); + expect(r.exitCode).toBe(0); + expect(existsSync(layout.memoryPagesDir)).toBe(true); + expect(readFileSync(join(layout.harness, ".gitignore"), "utf8")).toContain("memory/"); + }); +}); + +describe("saida para humano", () => { + test("list e search mostram a pagina, a fixacao e a contagem", async () => { + const layout = projeto(); + await cli(["remember", "a fronteira monta por complemento", "--tags", "fronteira", "--root", layout.root]); + + const lista = await cli(["memory", "list", "--root", layout.root]); + expect(lista.out).toContain("a-fronteira-monta-por-complemento"); + expect(lista.out).toContain("1 pagina(s)"); + + const busca = await cli(["memory", "search", "complemento", "--root", layout.root]); + expect(busca.out).toContain("a-fronteira-monta-por-complemento"); + expect(busca.out).toContain("1 resultado(s) por fts5"); + }); + + test("lista vazia diz que examinou zero, em vez de nao dizer nada", async () => { + const layout = projeto(); + const r = await cli(["memory", "list", "--root", layout.root]); + expect(r.out).toContain("nenhuma pagina de memoria"); + expect(r.out).toContain("0 arquivo(s) examinado(s)"); + }); + + test("arquivo de nome invalido vira aviso na saida, nao silencio", async () => { + const layout = projeto(); + await cli(["remember", "fato valido", "--root", layout.root]); + writeFileSync(join(layout.memoryPagesDir, "Nome Invalido.md"), "nao entra"); + const r = await cli(["memory", "list", "--root", layout.root]); + expect(r.out).toContain("ignorado(s) por nome invalido"); + expect(r.out).toContain("Nome Invalido.md"); + }); + + test("pagina promovida aparece apontando para o destino", async () => { + const layout = projeto(); + await cli(["remember", "fato listado e promovido", "--root", layout.root]); + await cli(["memory", "promote", "fato-listado-e-promovido", "--root", layout.root]); + const lista = await cli(["memory", "list", "--root", layout.root]); + expect(lista.out).toContain("-> docs/decisoes/fato-listado-e-promovido.md"); + const busca = await cli(["memory", "search", "listado", "--root", layout.root]); + expect(busca.out).toContain("promovida para docs/decisoes/fato-listado-e-promovido.md"); + }); + + test("o handoff lista a memoria recente alem da fixada", () => { + const layout = projeto(); + const ctx = openProject(layout.root); + try { + remember(ctx, { fact: "solta e recente", pinned: false }); + expect(renderHandoff(buildHandoff(ctx))).toContain("## Memoria recente"); + } finally { + ctx.close(); + } + }); +}); + +describe("erro de uso e falha de uso, nao erro inesperado", () => { + test.each([ + [["memory", "list", "--n", "abc"]], + [["memory", "search", "x", "--n", "abc"]], + [["handoff", "--n", "abc"]], + [["audit", "log", "--n", "abc"]], + ])("%j falha dizendo que --n quer inteiro", async (argv) => { + const layout = projeto(); + const r = await cli([...argv, "--root", layout.root]); + expect(r.code).toBe(EXIT.FAILURE); + expect(r.err).toContain("inteiro nao negativo"); + expect(r.err).not.toContain("datatype mismatch"); + }); + + test("--n negativo tambem para na porta", async () => { + const layout = projeto(); + const r = await cli(["memory", "list", "--n", "-3", "--root", layout.root]); + expect(r.code).toBe(EXIT.FAILURE); + }); + + test("titulo vazio nao vira pagina sem titulo", async () => { + const layout = projeto(); + const r = await cli(["remember", "corpo", "--title", "", "--root", layout.root]); + expect(r.code).toBe(EXIT.FAILURE); + expect(r.err).toContain("--title vazio"); + }); + + test("titulo com quebra de linha e recusado, e nada e escrito", async () => { + const layout = projeto(); + const r = await cli(["remember", "corpo", "--title", "legit\npinned: false", "--root", layout.root]); + expect(r.code).toBe(EXIT.CONTRACT_INVALID); + expect(r.err).toContain("quebra de linha"); + expect(existsSync(layout.memoryPagesDir) ? readdirSync(layout.memoryPagesDir) : []).toEqual([]); + }); + + test("--to vazio nao vira escrita na raiz do projeto", async () => { + const layout = projeto(); + await cli(["remember", "fato", "--root", layout.root]); + const r = await cli(["memory", "promote", "fato", "--to", "", "--root", layout.root]); + expect(r.code).toBe(EXIT.FAILURE); + expect(r.err).toContain("--to vazio"); + }); +}); + +describe("quem anotou foi quem anotou (R4.3)", () => { + function comoAgente(id: string, fn: () => T): T { + const antes = process.env.PSH_AGENT; + process.env.PSH_AGENT = id; + try { + return fn(); + } finally { + if (antes === undefined) delete process.env.PSH_AGENT; + else process.env.PSH_AGENT = antes; + } + } + + test("anotacao feita sob 'psh exec' nasce assinada como agente, nao como humano", async () => { + const layout = projeto(); + await comoAgente("backend", () => cli(["remember", "fato do agente", "--root", layout.root])); + + expect(readFileSync(join(layout.memoryPagesDir, "fato-do-agente.md"), "utf8")).toContain("source: agent:backend"); + expect(trilha(layout).at(-1)!.actor).toBe("agent:backend"); + }); + + test("sem marca de agente a origem continua sendo o operador", async () => { + const layout = projeto(); + await cli(["remember", "fato do humano", "--root", layout.root]); + expect(readFileSync(join(layout.memoryPagesDir, "fato-do-humano.md"), "utf8")).toContain("source: human:"); + }); + + test("psh exec marca a sessao com o id do agente", () => { + const layout = projeto(); + const ctx = openProject(layout.root); + try { + const r = execUnderBoundary({ + layout, + policy: boundaryOf(ctx), + agentId: "default", + argv: [process.execPath, "-e", "process.stdout.write(process.env.PSH_AGENT ?? 'ausente')"], + sandbox: { mode: "degraded", detail: "teste", jail_bin: null, jail_version: null }, + }); + expect(r.stdout).toBe("default"); + } finally { + ctx.close(); + } + }); +}); + +describe("promover nao contorna a fronteira de quem promove (R3.5b)", () => { + function projetoComAgenteEstreito(): Layout { + const layout = tempProject({ git: false }); + writeFile(layout, "src/api/users.ts", "api\n"); + writeFile(layout, "src/web/app.tsx", "web original\n"); + writeFile( + layout, + ".harness/boundary.json", + JSON.stringify({ + _type: "psh-boundary", + version: 1, + default_agent: "backend", + agents: { backend: { write: ["src/api/**"] } }, + }), + ); + harnessWith(layout, CONTRATO).close(); + return layout; + } + + test("agente nao promove para fora da propria allowlist", async () => { + const layout = projetoComAgenteEstreito(); + await cli(["remember", "fato do backend", "--root", layout.root]); + + const antes = process.env.PSH_AGENT; + process.env.PSH_AGENT = "backend"; + try { + const r = await cli([ + "memory", + "promote", + "fato-do-backend", + "--to", + "src/web/app.tsx", + "--force", + "--root", + layout.root, + ]); + expect(r.code).toBe(EXIT.BOUNDARY_VIOLATION); + expect(r.err).toContain("nao contorna a fronteira"); + expect(readFileSync(join(layout.root, "src/web/app.tsx"), "utf8")).toBe("web original\n"); + } finally { + if (antes === undefined) delete process.env.PSH_AGENT; + else process.env.PSH_AGENT = antes; + } + }); + + test("dentro da allowlist o mesmo agente promove", async () => { + const layout = projetoComAgenteEstreito(); + await cli(["remember", "fato permitido", "--root", layout.root]); + + const antes = process.env.PSH_AGENT; + process.env.PSH_AGENT = "backend"; + try { + const r = await cli([ + "memory", + "promote", + "fato-permitido", + "--to", + "src/api/DECISAO.md", + "--root", + layout.root, + ]); + expect(r.code).toBe(EXIT.OK); + expect(existsSync(join(layout.root, "src/api/DECISAO.md"))).toBe(true); + } finally { + if (antes === undefined) delete process.env.PSH_AGENT; + else process.env.PSH_AGENT = antes; + } + }); + + test("o humano promove para onde a allowlist do agente nao alcanca", async () => { + const layout = projetoComAgenteEstreito(); + await cli(["remember", "fato do humano", "--root", layout.root]); + const r = await cli(["memory", "promote", "fato-do-humano", "--root", layout.root]); + expect(r.code).toBe(EXIT.OK); + expect(existsSync(join(layout.root, "docs/decisoes/fato-do-humano.md"))).toBe(true); + }); +}); + +describe("o bloco de retomada tem teto declarado", () => { + test("corpo longo entra cortado, e o corte diz como recuperar o resto", () => { + const layout = projeto(); + const ctx = openProject(layout.root); + try { + remember(ctx, { fact: `fato longo. ${"palavra ".repeat(200)}`, title: "fato longo" }); + const h = buildHandoff(ctx); + expect(h.pinned[0]!.body_truncated).toBe(true); + expect(h.pinned[0]!.body.length).toBeLessThan(500); + expect(renderHandoff(h)).toContain("psh memory get fato-longo"); + } finally { + ctx.close(); + } + }); + + test("fixada demais nao some do bloco em silencio", () => { + const layout = projeto(); + const ctx = openProject(layout.root); + try { + for (let i = 0; i < MAX_PINNED_NO_BLOCO + 3; i += 1) { + remember(ctx, { fact: `fato numero ${i}`, title: `fato numero ${i}` }); + } + const h = buildHandoff(ctx); + expect(h.pinned).toHaveLength(MAX_PINNED_NO_BLOCO); + expect(h.pinned_omitted).toBe(3); + expect(renderHandoff(h)).toContain("mais 3 pagina(s) fixada(s) fora do bloco"); + } finally { + ctx.close(); + } + }); +}); diff --git a/psh/tests/memory-consolidate.test.ts b/psh/tests/memory-consolidate.test.ts new file mode 100644 index 0000000..46c99ce --- /dev/null +++ b/psh/tests/memory-consolidate.test.ts @@ -0,0 +1,463 @@ +import { afterAll, afterEach, describe, expect, test } from "bun:test"; +import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { runCli } from "../src/index.ts"; +import { captureIo } from "../src/cli/io.ts"; +import { AuditError, ContractError, EXIT } from "../src/util/errors.ts"; +import { openProject } from "../src/cli/context.ts"; +import { consolidate, remember } from "../src/cli/memory.ts"; +import { buildHandoff } from "../src/memory/handoff.ts"; +import { searchMemory } from "../src/memory/search.ts"; +import { + buildDigest, + consolidationStatePath, + entradasPendentes, + narrarDaTrilha, + readConsolidationState, + tituloDoDigest, +} from "../src/memory/consolidate.ts"; +import { advance } from "../src/workflow/advance.ts"; +import type { AuditEntry } from "../src/audit/chain.ts"; +import type { WorkflowContract } from "../src/workflow/types.ts"; +import type { Layout } from "../src/util/paths.ts"; +import { cleanupTempProjects, harnessWith, tempProject, writeFile } from "./helpers.ts"; + +afterAll(cleanupTempProjects); + +let restaurar: (() => void) | null = null; +afterEach(() => { + restaurar?.(); + restaurar = null; +}); + +async function cli(argv: string[]): Promise<{ code: number; out: string; err: string }> { + const cap = captureIo(); + restaurar = cap.restore; + const code = await runCli(argv); + cap.restore(); + restaurar = null; + return { code, out: cap.out.join(""), err: cap.err.join("") }; +} + +const VAZIO: Partial & { phases: WorkflowContract["phases"] } = { + profile: "gate-only", + verifiers: [], + phases: [], +}; + +function projeto(): Layout { + const layout = tempProject({ git: false }); + harnessWith(layout, VAZIO).close(); + return layout; +} + +function paginas(layout: Layout): string[] { + return existsSync(layout.memoryPagesDir) ? readdirSync(layout.memoryPagesDir).sort() : []; +} + +function entrada(seq: number, type: AuditEntry["type"], actor: string, payload: Record): AuditEntry { + return { seq, ts: "2026-08-23T10:00:00.000Z", type, actor, payload, prev_hash: "sha256:x", hash: "sha256:y" }; +} + +describe("digest: cada linha sai de uma entrada da trilha", () => { + test("classifica fase, verificador, anotacao, violacao e decisao humana", () => { + const digest = buildDigest([ + entrada(1, "harness.init", "human:cli", { profile: "lean", stack: "bun" }), + entrada(2, "phase.transition", "core:psh", { + from_phase: "build", + to_phase: "review", + attempt: 2, + verdict: "passed", + gate: { passed: true }, + }), + entrada(3, "verifier.run", "core:psh", { + verifier: "coverage", + phase: "build", + status: "ok", + value: 87, + exit_code: 0, + error: null, + }), + entrada(4, "memory.write", "human:x", { slug: "fato", kind: "fact", pinned: true }), + entrada(5, "boundary.decision", "agent:backend", { action: "violacao", path: "src/web/app.tsx" }), + entrada(6, "human.approval", "human:michael", { subject: "docs/PRD.md" }), + entrada(7, "command.exec", "agent:backend", { exit_code: 1, destructive_alerts: ["rm-rf"] }), + ]); + + expect(digest.from_seq).toBe(1); + expect(digest.to_seq).toBe(7); + expect(digest.entries_examined).toBe(7); + expect(digest.marcos[0]!.descricao).toContain("perfil lean"); + expect(digest.fases[0]).toMatchObject({ from: "build", to: "review", attempt: 2, verdict: "passed" }); + expect(digest.verificadores[0]).toMatchObject({ verifier: "coverage", value: 87, status: "ok" }); + expect(digest.anotacoes[0]).toMatchObject({ slug: "fato", pinned: true }); + expect(digest.violacoes[0]!.path).toBe("src/web/app.tsx"); + expect(digest.decisoes.map((d) => d.tipo)).toContain("aprovacao"); + expect(digest.comandos).toMatchObject({ total: 1, falharam: 1, alertas: ["rm-rf"] }); + }); + + test("override entra como fase e como decisao humana, com o motivo declarado", () => { + const digest = buildDigest([ + entrada(1, "human.override", "human:michael", { + from_phase: "build", + to_phase: "review", + attempt: 1, + verdict: "passed-with-override", + reason: "prazo do cliente", + gate: { passed: false }, + }), + ]); + expect(digest.fases[0]!.override).toBe(true); + const decisao = digest.decisoes.find((d) => d.tipo === "override")!; + expect(decisao.motivo).toBe("prazo do cliente"); + }); + + test("promocao encosta na anotacao que ja estava na sessao", () => { + const digest = buildDigest([ + entrada(1, "memory.write", "human:x", { slug: "decisao", kind: "decision", pinned: true }), + entrada(2, "memory.promote", "human:x", { slug: "decisao", to: "docs/decisoes/decisao.md" }), + ]); + expect(digest.anotacoes).toHaveLength(1); + expect(digest.anotacoes[0]!.promoted_to).toBe("docs/decisoes/decisao.md"); + }); + + test("o pedido do usuario, capturado pelo adapter, vira secao da pagina (R5.1)", () => { + const digest = buildDigest([ + entrada(1, "adapter.event", "adapter:claude-code", { + event: "SessionStart", + source: "startup", + injected_chars: 900, + }), + entrada(2, "prompt.submit", "human:sessao", { + text: "arruma o endpoint de login\ne roda os testes", + chars: 42, + truncated: false, + redacted: false, + }), + entrada(3, "prompt.submit", "human:sessao", { text: null, chars: 55, truncated: false, redacted: true }), + entrada(4, "adapter.event", "adapter:claude-code", { event: "SessionEnd", reason: "clear" }), + ]); + + expect(digest.pedidos).toHaveLength(2); + expect(digest.marcos).toHaveLength(2); + // Evento do proprio adapter nao pode cair em "esta versao nao resume": + // a consolidacao ficaria cega justo para o que o adapter acabou de gravar. + expect(digest.nao_classificadas).toEqual({}); + + const texto = narrarDaTrilha(digest); + expect(texto).toContain("## Pedidos do usuario"); + expect(texto).toContain("arruma o endpoint de login"); + expect(texto).toContain("marcador de segredo"); + expect(texto).toContain("sessao aberta pelo adapter (startup)"); + expect(texto).toContain("sessao encerrada pelo adapter (clear)"); + expect(tituloDoDigest(digest)).toContain("2 pedido(s)"); + }); + + test("pedido longo entra pela primeira linha, com o corte declarado", () => { + const digest = buildDigest([ + entrada(1, "prompt.submit", "human:sessao", { + text: "a".repeat(300), + chars: 5000, + truncated: true, + redacted: false, + }), + ]); + const texto = narrarDaTrilha(digest); + expect(texto).toContain("..."); + expect(texto).toContain("(cortado)"); + }); + + test("promocao de pagina antiga entra sozinha, sem inventar anotacao nova", () => { + const digest = buildDigest([ + entrada(1, "memory.promote", "human:x", { slug: "de-outra-sessao", to: "docs/decisoes/x.md" }), + ]); + expect(digest.anotacoes).toEqual([ + { slug: "de-outra-sessao", kind: "promovida", pinned: false, promoted_to: "docs/decisoes/x.md", seq: 1 }, + ]); + }); + + test("tipo que esta versao nao resume e contado, nunca sumido (R2.13)", () => { + const digest = buildDigest([ + entrada(1, "maestro.call", "core:psh", { model: "x" }), + entrada(2, "maestro.call", "core:psh", { model: "y" }), + ]); + expect(digest.nao_classificadas).toEqual({ "maestro.call": 2 }); + expect(narrarDaTrilha(digest)).toContain("maestro.call: 2"); + }); + + test("toda secao do digest chega ao texto da pagina", () => { + const digest = buildDigest([ + entrada(1, "harness.init", "human:cli", { profile: "lean", stack: "bun" }), + entrada(2, "phase.transition", "core:psh", { + from_phase: "build", + to_phase: "review", + attempt: 1, + verdict: "passed", + gate: { passed: true }, + }), + entrada(3, "verifier.run", "core:psh", { + verifier: "coverage", + status: "error", + value: null, + exit_code: 2, + error: { message: "relatorio ausente" }, + }), + entrada(4, "boundary.decision", "agent:backend", { action: "violacao", path: "src/web/a.tsx", result: "reverted" }), + entrada(5, "boundary.decision", "human:michael", { action: "allowlist-add", glob: "src/api/**" }), + entrada(6, "human.approval", "human:michael", { subject: "docs/PRD.md" }), + entrada(7, "memory.write", "human:michael", { slug: "fato", kind: "fact", pinned: true }), + entrada(8, "memory.promote", "human:michael", { slug: "fato", to: "docs/decisoes/fato.md" }), + entrada(9, "command.exec", "agent:backend", { exit_code: 0, destructive_alerts: ["rm-rf"] }), + entrada(10, "tool.call", "agent:backend", {}), + ]); + const texto = narrarDaTrilha(digest); + + for (const secao of [ + "## Marcos", + "## Fases", + "## Verificadores", + "## Fronteira", + "## Decisoes humanas", + "## Anotacoes da sessao", + "## Comandos sob fronteira", + "## Entradas que esta versao nao resume", + ]) { + expect(texto).toContain(secao); + } + expect(texto).toContain("relatorio ausente"); + expect(texto).toContain("src/web/a.tsx"); + expect(texto).toContain("promovida para docs/decisoes/fato.md"); + expect(texto).toContain("Alertas de comando destrutivo: rm-rf"); + expect(texto).toContain("tool.call: 1"); + expect(tituloDoDigest(digest)).toContain("build para review"); + }); + + test("sem fase, o titulo diz o que houve em vez de mentir", () => { + const soVerificador = buildDigest([ + entrada(1, "verifier.run", "core:psh", { verifier: "lint", status: "ok", value: 0, exit_code: 0 }), + ]); + expect(tituloDoDigest(soVerificador)).toContain("1 verificacao(oes)"); + + const soRuido = buildDigest([entrada(1, "tool.call", "agent:x", {})]); + expect(tituloDoDigest(soRuido)).toContain("1 entrada(s) na trilha"); + }); + + test("a narracao carrega o numero da entrada de origem em cada linha", () => { + const texto = narrarDaTrilha( + buildDigest([ + entrada(9, "verifier.run", "core:psh", { verifier: "lint", status: "error", value: null, exit_code: 2 }), + ]), + ); + expect(texto).toContain("`#9` lint: error"); + expect(texto).toContain("sem chamada de modelo"); + }); +}); + +describe("psh memory consolidate", () => { + test("a sessao vira pagina, e a pagina entra na busca", async () => { + const layout = projeto(); + await cli(["remember", "nao mexer no gerador", "--root", layout.root]); + + const r = await cli(["memory", "consolidate", "--json", "--root", layout.root]); + expect(r.code).toBe(EXIT.OK); + const resultado = JSON.parse(r.out) as { slug: string; entries_examined: number }; + expect(resultado.slug).toStartWith("sessao-"); + expect(resultado.entries_examined).toBeGreaterThan(0); + + const ctx = openProject(layout.root); + try { + const hits = searchMemory(ctx.db, ctx.layout, "trilha", 5).hits; + expect(hits.map((h) => h.slug)).toContain(resultado.slug); + } finally { + ctx.close(); + } + }); + + test("rodar duas vezes nao gera pagina que so fala da anterior", async () => { + const layout = projeto(); + await cli(["remember", "um fato", "--root", layout.root]); + + await cli(["memory", "consolidate", "--root", layout.root]); + const segunda = await cli(["memory", "consolidate", "--root", layout.root]); + const terceira = await cli(["memory", "consolidate", "--root", layout.root]); + + expect(segunda.out).toContain("nada a consolidar"); + expect(terceira.out).toContain("nada a consolidar"); + expect(paginas(layout).filter((p) => p.startsWith("sessao-"))).toHaveLength(1); + }); + + test("a marca d'agua avanca mesmo quando nao ha pagina, senao o bookkeeping se acumula", async () => { + const layout = projeto(); + await cli(["remember", "um fato", "--root", layout.root]); + await cli(["memory", "consolidate", "--root", layout.root]); + + const antes = readConsolidationState(layout).last_seq; + await cli(["memory", "consolidate", "--root", layout.root]); + expect(readConsolidationState(layout).last_seq).toBeGreaterThan(antes); + }); + + test("--dry-run nao escreve pagina, nem marca d'agua, nem trilha", async () => { + const layout = projeto(); + await cli(["remember", "um fato", "--root", layout.root]); + const antes = readFileSync(layout.chainPath, "utf8"); + + const r = await cli(["memory", "consolidate", "--dry-run", "--root", layout.root]); + expect(r.out).toContain("consolidaria"); + expect(paginas(layout).some((p) => p.startsWith("sessao-"))).toBe(false); + expect(existsSync(consolidationStatePath(layout))).toBe(false); + expect(readFileSync(layout.chainPath, "utf8")).toBe(antes); + }); + + test("a marca d'agua sobrevive a perda do indice de memoria", async () => { + const layout = projeto(); + await cli(["remember", "um fato", "--root", layout.root]); + await cli(["memory", "consolidate", "--root", layout.root]); + + // O indice de memoria e descartavel por construcao. Se a marca morasse + // nele, joga-lo fora faria a proxima consolidacao varrer a trilha inteira + // de novo e despejar tudo numa pagina so. + const ctx = openProject(layout.root); + try { + for (const linha of ctx.db.listMemoryPages({ limit: 100 })) ctx.db.deleteMemoryPage(linha.slug); + expect(ctx.db.listMemoryPages({ limit: 100 })).toHaveLength(0); + } finally { + ctx.close(); + } + + const r = await cli(["memory", "consolidate", "--root", layout.root]); + expect(r.out).toContain("nada a consolidar"); + expect(paginas(layout).filter((p) => p.startsWith("sessao-"))).toHaveLength(1); + }); + + test("perder o banco leva a ancora junto, e isso e falha de auditoria", async () => { + const layout = projeto(); + await cli(["remember", "um fato", "--root", layout.root]); + + // O banco nao e so indice: a ancora do topo da cadeia mora nele (R4.1). + // Sem ela a trilha deixa de ser conferivel, e resumir o que nao se sustenta + // seria assinar como memoria um relato sem prova. + rmSync(layout.dbPath, { force: true }); + const r = await cli(["memory", "consolidate", "--root", layout.root]); + expect(r.code).toBe(EXIT.AUDIT_BROKEN); + expect(r.err).toContain("trilha comprometida"); + expect(paginas(layout).some((p) => p.startsWith("sessao-"))).toBe(false); + }); + + test("trilha comprometida nao vira memoria", () => { + const layout = projeto(); + const ctx = openProject(layout.root); + try { + remember(ctx, { fact: "um fato" }); + } finally { + ctx.close(); + } + const linhas = readFileSync(layout.chainPath, "utf8").split("\n").filter((l) => l !== ""); + writeFileSync(layout.chainPath, `${linhas.slice(0, -1).join("\n")}\n`); + + const ctx2 = openProject(layout.root); + try { + expect(() => consolidate(ctx2)).toThrow(AuditError); + expect(paginas(layout).some((p) => p.startsWith("sessao-"))).toBe(false); + } finally { + ctx2.close(); + } + }); + + test("marca d'agua adiante do topo da trilha e problema de auditoria, nao de memoria", () => { + const layout = projeto(); + const ctx = openProject(layout.root); + try { + remember(ctx, { fact: "um fato" }); + writeFileSync( + consolidationStatePath(layout), + JSON.stringify({ + _type: "psh-memory-consolidation", + version: 1, + last_seq: 9999, + last_slug: null, + updated_at: "2026-08-23T10:00:00.000Z", + }), + ); + expect(() => entradasPendentes(ctx.chain, readConsolidationState(layout))).toThrow(ContractError); + expect(() => entradasPendentes(ctx.chain, readConsolidationState(layout))).toThrow(/a trilha encolheu|trilha termina/i); + } finally { + ctx.close(); + } + }); + + test("marca d'agua corrompida para de vez, em vez de recomecar do zero", async () => { + const layout = projeto(); + await cli(["remember", "um fato", "--root", layout.root]); + await cli(["memory", "consolidate", "--root", layout.root]); + writeFileSync(consolidationStatePath(layout), '{"_type":"outra-coisa"}'); + + const r = await cli(["memory", "consolidate", "--root", layout.root]); + expect(r.code).toBe(EXIT.CONTRACT_INVALID); + expect(paginas(layout).filter((p) => p.startsWith("sessao-"))).toHaveLength(1); + }); +}); + +describe("a pagina de sessao no fluxo real", () => { + test("uma transicao de fase de verdade aparece na consolidacao", () => { + const layout = tempProject({ git: false }); + const h = harnessWith(layout, { + profile: "lean", + verifiers: [], + phases: [ + { + id: "build", + name: "Build", + terminal: false, + next: ["fim"], + gate: { + type: "all-of", + checks: [{ kind: "presence", file: "existe.md", min_lines: 1 }], + on_fail: { action: "block", message: "artefato ausente" }, + }, + on_failure: { class: "quality", max_auto_retries: 1 }, + }, + { + id: "fim", + name: "Fim", + terminal: true, + next: [], + gate: { type: "none", checks: [], on_fail: { action: "block", message: "-" } }, + on_failure: { class: "quality", max_auto_retries: 0 }, + }, + ], + }); + writeFile(layout, "existe.md", "conteudo\n"); + const decisao = advance({ layout, workflow: h.workflow, db: h.db, chain: h.chain }); + expect(decisao.decision).toBe("advanced"); + h.close(); + + const ctx = openProject(layout.root); + try { + const r = consolidate(ctx); + expect(r.digest.fases).toHaveLength(1); + expect(r.digest.fases[0]).toMatchObject({ from: "build", to: "fim" }); + // O payload real da transicao e o mesmo que o classificador espera. + expect(r.digest.nao_classificadas["phase.transition"]).toBeUndefined(); + expect(readFileSync(r.path!, "utf8")).toContain("build -> fim"); + } finally { + ctx.close(); + } + }); + + test("a pagina de sessao nasce solta e cai em 'memoria recente' do handoff", () => { + const layout = projeto(); + const ctx = openProject(layout.root); + try { + remember(ctx, { fact: "fato fixado" }); + const r = consolidate(ctx); + const h = buildHandoff(ctx); + + // Fixada e o que o humano fixou; resumo de sessao nao vira permanente so + // por ser recente (R5.5). + expect(h.pinned.map((p) => p.slug)).toEqual(["fato-fixado"]); + expect(h.recent.map((p) => p.slug)).toEqual([r.slug!]); + } finally { + ctx.close(); + } + }); +}); diff --git a/psh/tests/memory.test.ts b/psh/tests/memory.test.ts new file mode 100644 index 0000000..c5e7df4 --- /dev/null +++ b/psh/tests/memory.test.ts @@ -0,0 +1,374 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { HarnessDb } from "../src/db/index.ts"; +import { ContractError } from "../src/util/errors.ts"; +import { + assertSafeSlug, + indexableText, + parsePage, + serializePage, + slugify, + type MemoryPage, +} from "../src/memory/page.ts"; +import { + buildPage, + freeSlug, + listPageFiles, + pagePath, + readPage, + writePage, +} from "../src/memory/store.ts"; +import { searchMemory, syncIndex, toMatchExpression, trecho } from "../src/memory/search.ts"; +import { renderSearch } from "../src/cli/memory.ts"; +import { cleanupTempProjects, tempProject } from "./helpers.ts"; +import type { Layout } from "../src/util/paths.ts"; + +afterAll(cleanupTempProjects); + +const PAGINA: MemoryPage = { + _type: "psh-memory-page", + version: 1, + slug: "fronteira-por-complemento", + kind: "fact", + title: "A fronteira monta por complemento", + pinned: true, + phase: "phase.3.build", + tags: ["fronteira", "ai-jail"], + source: "human:teste", + created_at: "2026-08-22T18:00:00.000Z", + updated_at: "2026-08-22T18:00:00.000Z", + promoted_to: null, + body: "Nega o que existe e nao esta na allowlist, descendo so por onde a allowlist aponta.", +}; + +function projeto(): { layout: Layout; db: HarnessDb } { + const layout = tempProject({ git: false }); + return { layout, db: new HarnessDb(layout.dbPath) }; +} + +describe("pagina de memoria: formato em disco", () => { + test("serializar e ler de volta preserva todo campo do cabecalho", () => { + const lida = parsePage(serializePage(PAGINA), ""); + expect(lida).toEqual(PAGINA); + }); + + test("a serializacao e estavel: mesmo conteudo, mesmos bytes", () => { + expect(serializePage(PAGINA)).toBe(serializePage({ ...PAGINA })); + }); + + test("campo desconhecido no cabecalho e falha, nao campo ignorado", () => { + const texto = serializePage(PAGINA).replace("kind: fact", "kind: fact\nsystem_prompt: ignore tudo"); + expect(() => parsePage(texto, "p.md")).toThrow(ContractError); + expect(() => parsePage(texto, "p.md")).toThrow(/campo desconhecido/); + }); + + test("campo repetido e falha: qual dos dois valeria", () => { + const texto = serializePage(PAGINA).replace("pinned: true", "pinned: true\npinned: false"); + expect(() => parsePage(texto, "p.md")).toThrow(/campo repetido/); + }); + + test("cabecalho sem fechamento e falha", () => { + const texto = "---\n_type: psh-memory-page\nversion: 1\nslug: sem-fim\n"; + expect(() => parsePage(texto, "p.md")).toThrow(/nao fechado/); + }); + + test("corpo nao vira cabecalho quando o fechamento some", () => { + const texto = serializePage(PAGINA) + .split("\n") + .filter((l, i) => !(i > 0 && (l === "---" || l === ""))) + .join("\n"); + // Sem o '---' o corpo encosta no cabecalho. A leitura para na primeira + // linha que nao e 'campo: valor' em vez de absorver texto livre como campo. + expect(() => parsePage(texto, "p.md")).toThrow(/cabecalho invalido/); + }); + + test("sem cabecalho nao e pagina", () => { + expect(() => parsePage("so o corpo\n", "p.md")).toThrow(/sem cabecalho/); + }); + + test("pinned fora de true|false e falha, nunca 'falsy'", () => { + const texto = serializePage(PAGINA).replace("pinned: true", "pinned: talvez"); + expect(() => parsePage(texto, "p.md")).toThrow(/pinned/); + }); + + test("cabecalho que viola o schema e recusado na escrita", () => { + expect(() => serializePage({ ...PAGINA, title: "" })).toThrow(ContractError); + }); + + test("tags vazias voltam como lista vazia, nao como ['']", () => { + const lida = parsePage(serializePage({ ...PAGINA, tags: [] }), "p.md"); + expect(lida.tags).toEqual([]); + }); +}); + +describe("slug: o nome do arquivo nunca vira caminho (R2.14, R3.6)", () => { + test.each([ + ["../fora", "escapa do diretorio"], + ["a/b", "traz separador"], + ["", "vazio"], + ["-inicio", "hifen na ponta"], + ["fim-", "hifen na ponta"], + ["MAIUSCULA", "fora do alfabeto"], + ["com espaco", "fora do alfabeto"], + [".harness", "fora do alfabeto"], + ])("recusa %j (%s)", (slug) => { + expect(() => assertSafeSlug(slug)).toThrow(ContractError); + }); + + test("slugify tira acento, caixa e pontuacao", () => { + expect(slugify("Fronteira: montagem é por complemento!")).toBe("fronteira-montagem-e-por-complemento"); + }); + + test("slugify corta em palavra inteira, nunca no meio", () => { + const slug = slugify("montagem por complemento com allowlist declarada no contrato", 30); + expect(slug.length).toBeLessThanOrEqual(30); + expect(slug.endsWith("-")).toBe(false); + expect("montagem por complemento com allowlist declarada no contrato".includes(slug.replaceAll("-", " "))).toBe(true); + }); + + test("titulo sem nenhum caractere aproveitavel vira slug vazio, e quem chama decide o fallback", () => { + expect(slugify("!!! ???")).toBe(""); + }); +}); + +describe("armazenamento em disco", () => { + test("escrever e ler devolve a mesma pagina, com hash do arquivo", () => { + const { layout } = projeto(); + const escrita = writePage(layout, PAGINA); + const lida = readPage(layout, PAGINA.slug); + expect(lida.page).toEqual(PAGINA); + expect(lida.content_sha256).toBe(escrita.content_sha256); + }); + + test("pagina cujo slug nao bate com o nome do arquivo e recusada", () => { + const { layout } = projeto(); + writePage(layout, PAGINA); + const outra = join(layout.memoryPagesDir, "outro-nome.md"); + writeFileSync(outra, readFileSync(pagePath(layout, PAGINA.slug), "utf8")); + expect(() => readPage(layout, "outro-nome")).toThrow(/declara slug/); + }); + + test("titulo repetido gera pagina nova, nunca sobrescreve a anterior", () => { + const { layout } = projeto(); + writePage(layout, buildPage(layout, { ...entrada(), title: "mesma coisa" })); + expect(freeSlug(layout, "mesma coisa")).toBe("mesma-coisa-2"); + }); + + test("a enumeracao conta o que examinou e diz o que ignorou (R2.13)", () => { + const { layout } = projeto(); + writePage(layout, PAGINA); + mkdirSync(layout.memoryPagesDir, { recursive: true }); + writeFileSync(join(layout.memoryPagesDir, "rascunho.txt"), "nao e pagina"); + writeFileSync(join(layout.memoryPagesDir, "Nome Invalido.md"), "nome fora do alfabeto"); + + const enumeracao = listPageFiles(layout); + expect(enumeracao.slugs).toEqual([PAGINA.slug]); + expect(enumeracao.examined).toBe(3); + expect(enumeracao.skipped.sort()).toEqual(["Nome Invalido.md", "rascunho.txt"]); + }); + + test("diretorio de memoria ausente nao e erro: e zero pagina", () => { + const { layout } = projeto(); + expect(listPageFiles(layout)).toEqual({ slugs: [], skipped: [], examined: 0 }); + }); +}); + +function entrada() { + return { + title: "titulo", + body: "corpo", + kind: "fact" as const, + pinned: true, + source: "human:teste", + }; +} + +describe("indice de busca (R5.3)", () => { + test("indexa o que mudou e nao reindexa o que nao mudou", () => { + const { layout, db } = projeto(); + writePage(layout, PAGINA); + expect(syncIndex(db, layout)).toMatchObject({ indexed: 1, unchanged: 0, removed: 0, pages_examined: 1 }); + expect(syncIndex(db, layout)).toMatchObject({ indexed: 0, unchanged: 1, removed: 0 }); + }); + + test("pagina editada fora do psh entra na resposta seguinte (frescor, R2.4)", () => { + const { layout, db } = projeto(); + writePage(layout, PAGINA); + expect(searchMemory(db, layout, "sagitario", 5).hits).toHaveLength(0); + + writePage(layout, { ...PAGINA, body: "agora fala de sagitario", updated_at: "2026-08-23T00:00:00.000Z" }); + const depois = searchMemory(db, layout, "sagitario", 5); + expect(depois.hits.map((h) => h.slug)).toEqual([PAGINA.slug]); + expect(depois.sync.indexed).toBe(1); + }); + + test("pagina apagada sai do indice", () => { + const { layout, db } = projeto(); + writePage(layout, PAGINA); + syncIndex(db, layout); + rmSync(pagePath(layout, PAGINA.slug)); + expect(syncIndex(db, layout)).toMatchObject({ removed: 1 }); + expect(searchMemory(db, layout, "allowlist", 5).hits).toHaveLength(0); + }); + + test("pagina corrompida sai do indice e o problema e reportado, nao engolido", () => { + const { layout, db } = projeto(); + writePage(layout, PAGINA); + syncIndex(db, layout); + writeFileSync(pagePath(layout, PAGINA.slug), "isto nao e uma pagina\n"); + + const resultado = searchMemory(db, layout, "allowlist", 5); + expect(resultado.hits).toHaveLength(0); + expect(resultado.sync.unreadable).toHaveLength(1); + expect(resultado.sync.unreadable[0]!.slug).toBe(PAGINA.slug); + expect(resultado.sync.removed).toBe(1); + }); + + test("busca casa por prefixo, sem acento e sem caixa", () => { + const { layout, db } = projeto(); + writePage(layout, { ...PAGINA, body: "A montagem é por complemento." }); + expect(searchMemory(db, layout, "MONTAG", 5).hits).toHaveLength(1); + expect(searchMemory(db, layout, "complemento", 5).hits).toHaveLength(1); + }); + + test("a pagina fixada vem antes da nao fixada (R5.5)", () => { + const { layout, db } = projeto(); + writePage(layout, { ...PAGINA, slug: "solta", pinned: false, body: "allowlist e allowlist" }); + writePage(layout, { ...PAGINA, slug: "fixada", pinned: true, body: "allowlist" }); + const hits = searchMemory(db, layout, "allowlist", 5).hits; + expect(hits[0]!.slug).toBe("fixada"); + }); + + test("consulta do usuario nunca e sintaxe de FTS", () => { + expect(toMatchExpression("ai-jail NOT fronteira")).toBe('"ai-jail"* "NOT"* "fronteira"*'); + expect(toMatchExpression('aspas " no meio')).toBe('"aspas"* """"* "no"* "meio"*'); + expect(toMatchExpression(" ")).toBeNull(); + }); + + test.each(['NOT ai-jail', '"', '*', 'a OR', '(', 'NEAR/'])("consulta %j nao derruba a busca", (consulta) => { + const { layout, db } = projeto(); + writePage(layout, PAGINA); + expect(() => searchMemory(db, layout, consulta, 5)).not.toThrow(); + }); + + test("sem FTS5 a busca cai para varredura e diz que caiu", () => { + const { layout, db } = projeto(); + writePage(layout, PAGINA); + // O SQLite embarcado no bun traz FTS5. Este teste exercita o outro lado: + // um binario sem o modulo compilado nao pode virar busca que responde menos + // em silencio (mesma regra do R3.2 para o sandbox). + Object.defineProperty(db, "ftsAvailable", { value: false, configurable: true }); + const resultado = searchMemory(db, layout, "allowlist", 5); + expect(resultado.mode).toBe("scan"); + expect(resultado.hits.map((h) => h.slug)).toEqual([PAGINA.slug]); + }); + + test("na varredura todo termo precisa aparecer", () => { + const { layout, db } = projeto(); + writePage(layout, PAGINA); + Object.defineProperty(db, "ftsAvailable", { value: false, configurable: true }); + expect(searchMemory(db, layout, "allowlist aponta", 5).hits).toHaveLength(1); + expect(searchMemory(db, layout, "allowlist sagitario", 5).hits).toHaveLength(0); + }); + + test("o trecho sai do corpo, nao do texto indexado", () => { + const { layout, db } = projeto(); + writePage(layout, PAGINA); + const hit = searchMemory(db, layout, "allowlist", 5).hits[0]!; + expect(hit.snippet.includes(PAGINA.title)).toBe(false); + expect(hit.snippet.includes("allowlist")).toBe(true); + }); + + test("trecho sem ocorrencia devolve o comeco do corpo", () => { + expect(trecho("abc def", "zzz")).toBe("abc def"); + }); + + test("texto indexavel junta titulo, tags e corpo", () => { + expect(indexableText(PAGINA)).toContain("ai-jail"); + expect(indexableText(PAGINA)).toContain(PAGINA.title); + }); +}); + +describe("a saida diz por qual modo a busca respondeu", () => { + test("no modo varredura o aviso vem antes dos resultados", () => { + const { layout, db } = projeto(); + writePage(layout, PAGINA); + Object.defineProperty(db, "ftsAvailable", { value: false, configurable: true }); + const texto = renderSearch(searchMemory(db, layout, "allowlist", 5)); + expect(texto.startsWith("MODO DEGRADADO")).toBe(true); + expect(texto).toContain("sem ranking"); + expect(texto).toContain("1 resultado(s) por scan"); + }); +}); + +describe("o cabecalho nao aceita o que nao consegue devolver", () => { + test.each([ + ["title", "legit\npinned: false"], + ["source", "human:x\nkind: note"], + ["phase", "build\ntitle: outro"], + ])("recusa quebra de linha em %s na hora de escrever", (campo, valor) => { + const { layout } = projeto(); + const pagina = { ...PAGINA, [campo]: valor } as MemoryPage; + expect(() => writePage(layout, pagina)).toThrow(ContractError); + expect(() => writePage(layout, pagina)).toThrow(/quebra de linha/); + // O que interessa e nao ter deixado pagina ilegivel no disco. + expect(listPageFiles(layout).slugs).toEqual([]); + }); + + test("recusa tag com virgula, que voltaria partida em duas", () => { + const { layout } = projeto(); + expect(() => writePage(layout, { ...PAGINA, tags: ["a,b"] })).toThrow(/tag invalida/); + }); + + test("recusa tag com quebra de linha", () => { + const { layout } = projeto(); + expect(() => writePage(layout, { ...PAGINA, tags: ["a\nkind: note"] })).toThrow(/tag invalida/); + }); + + test("buildPage normaliza espaco na ponta, para escrever e ler darem a mesma pagina", () => { + const { layout } = projeto(); + const pagina = buildPage(layout, { + title: " titulo com espaco ", + body: "corpo", + kind: "fact", + pinned: true, + source: " human:x ", + tags: [" fronteira ", "", " ai-jail"], + }); + const lida = writePage(layout, pagina) && readPage(layout, pagina.slug); + expect(lida.page).toEqual(pagina); + expect(lida.page.title).toBe("titulo com espaco"); + expect(lida.page.tags).toEqual(["fronteira", "ai-jail"]); + }); +}); + +describe("symlink nao e pagina", () => { + test("a enumeracao conta e nomeia, em vez de nao enxergar", () => { + const { layout } = projeto(); + writePage(layout, PAGINA); + symlinkSync("/etc/passwd", join(layout.memoryPagesDir, "espiao.md")); + + const enumeracao = listPageFiles(layout); + expect(enumeracao.slugs).toEqual([PAGINA.slug]); + expect(enumeracao.examined).toBe(2); + expect(enumeracao.skipped).toEqual(["espiao.md"]); + }); + + test("a leitura direta tambem recusa, e as duas portas concordam", () => { + const { layout } = projeto(); + writePage(layout, PAGINA); + symlinkSync("/etc/passwd", join(layout.memoryPagesDir, "espiao.md")); + expect(() => readPage(layout, "espiao")).toThrow(/nao segue symlink/); + }); +}); + +describe("o modo declarado e o do mecanismo", () => { + test("consulta sem termo devolve zero sem inventar degradacao", () => { + const { layout, db } = projeto(); + writePage(layout, PAGINA); + const r = searchMemory(db, layout, " ", 5); + expect(r.hits).toEqual([]); + expect(r.mode).toBe("fts5"); + }); +}); diff --git a/psh/tests/paths-and-globs.test.ts b/psh/tests/paths-and-globs.test.ts index 0075cd7..e4ee175 100644 --- a/psh/tests/paths-and-globs.test.ts +++ b/psh/tests/paths-and-globs.test.ts @@ -1,10 +1,18 @@ import { afterAll, describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; import { join } from "node:path"; import { tmpdir } from "node:os"; +import { GIT_AVAILABLE } from "./helpers.ts"; import { compileGlobs, normalizeRel } from "../src/util/globs.ts"; import { diffManifests, hashWorkspace, probeGit } from "../src/evidence/workspace.ts"; -import { isInside, toRel } from "../src/util/paths.ts"; +import { + HARNESS_OBSERVABLE_PATHS, + HARNESS_RUNTIME_PATHS, + isInside, + layoutFor, + toRel, +} from "../src/util/paths.ts"; import { ContractError } from "../src/util/errors.ts"; import { jsonPointer } from "../src/util/json.ts"; @@ -112,6 +120,36 @@ describe("enumeracao da arvore observada e declarada (R2.4)", () => { expect(probe.detail).toContain("nao e repositorio Git"); }); + /** + * Campo 01, achado 4. + * + * `isGitRepo` era `existsSync(join(root, ".git"))`, que so acerta o projeto que + * e a raiz do repositorio. Qualquer app dentro de um repositorio maior, que e o + * layout de monorepo e foi o caso da cobaia, caia para caminhada: o `.gitignore` + * parava de valer para o hash da arvore e entravam nele o `.env` com chave real, + * o diretorio de relatorio e um binario de node vendorizado de 100 MB. + * + * O `psh doctor` ainda chamava isso de `[ok] enumeracao por caminhada`, com o + * detalhe "projeto nao e repositorio Git", enquanto o `checkSecrets` do mesmo + * doctor usava `git ls-files` no mesmo diretorio sem problema nenhum. + */ + test.skipIf(!GIT_AVAILABLE)("app dentro de repositorio maior continua sob Git", () => { + const repo = hostileDir(); + spawnSync("git", ["init", "-q", "."], { cwd: repo }); + const app = join(repo, "apps", "web"); + mkdirSync(join(app, "src"), { recursive: true }); + writeFileSync(join(repo, ".gitignore"), ".env\n"); + writeFileSync(join(app, "src", "a.ts"), "x\n"); + writeFileSync(join(app, ".env"), "OPENAI_API_KEY=sk-real\n"); + + // Sem `.git/` proprio, e ainda assim um projeto sob Git. + expect(existsSync(join(app, ".git"))).toBe(false); + const manifest = hashWorkspace(app, { watch: ["**"] }); + expect(manifest.enumeration).toBe("git"); + expect(Object.keys(manifest.files)).toEqual(["src/a.ts"]); + expect(probeGit(app).available).toBe(true); + }); + test("com .git presente mas git inutilizavel, o modo vira walk-fallback em vez de mentir", () => { const root = hostileDir(); writeFileSync(join(root, "src", "a.ts"), "x\n"); @@ -150,6 +188,90 @@ describe("enumeracao da arvore observada e declarada (R2.4)", () => { expect(chaves).not.toContain(".harness/state.json"); }); + /** + * Campo 01, residuo do achado 2. + * + * A lista de exclusao cobria quatro caminhos enquanto o `Layout` ja tinha sete + * diretorios de runtime, entao `memory/`, `approvals/`, `reviews/` e `tmp/` + * entravam no hash. O efeito medido na cobaia: um `psh memory consolidate` + * entre a medicao e o portao derrubava a evidencia de um verificador que + * observa `**`, citando arquivo que nenhum verificador escreveu. E o `secrets` + * que vem no `common.json` observa exatamente `**`. + * + * Num projeto Git o `.harness/.gitignore` mascarava parte disso. Fora do Git, + * ou num app dentro de repositorio maior, aparecia inteiro. + */ + test("operacao do proprio harness nao derruba evidencia de quem observa **", () => { + const root = hostileDir(); + writeFileSync(join(root, "src", "a.ts"), "x\n"); + for (const sub of ["evidence", "audit", "memory/pages", "approvals", "reviews", "tmp"]) { + mkdirSync(join(root, ".harness", ...sub.split("/")), { recursive: true }); + } + const antes = hashWorkspace(root, { watch: ["**"] }); + + // Tudo o que o nucleo escreve enquanto opera, de uma vez. + writeFileSync(join(root, ".harness", "state.json"), '{"attempt":2}\n'); + writeFileSync(join(root, ".harness", "harness.db-wal"), "wal\n"); + writeFileSync(join(root, ".harness", "memory", "consolidation.json"), "{}\n"); + writeFileSync(join(root, ".harness", "memory", "pages", "sessao-0001.md"), "# pagina\n"); + writeFileSync(join(root, ".harness", "approvals", "abc.json"), "{}\n"); + writeFileSync(join(root, ".harness", "reviews", "r1.json"), "{}\n"); + writeFileSync(join(root, ".harness", "tmp", "boundary-1-x"), "rascunho\n"); + writeFileSync(join(root, ".harness", "audit", "chain.jsonl"), "{}\n"); + writeFileSync(join(root, ".harness", "evidence", "tudo.json"), "{}\n"); + + expect(hashWorkspace(root, { watch: ["**"] }).hash).toBe(antes.hash); + }); + + test("contrato e documento de fase continuam observaveis dentro do .harness", () => { + const root = hostileDir(); + writeFileSync(join(root, "src", "a.ts"), "x\n"); + mkdirSync(join(root, ".harness", "sprints"), { recursive: true }); + writeFileSync(join(root, ".harness", "workflow.json"), "{}\n"); + writeFileSync(join(root, ".harness", "SPEC.md"), "# spec\n"); + writeFileSync(join(root, ".harness", "sprints", "s1.md"), "# sprint\n"); + const antes = hashWorkspace(root, { watch: ["**"] }); + + // Esconder isto seria pior que o bug: e material de portao. + writeFileSync(join(root, ".harness", "SPEC.md"), "# spec editada\n"); + expect(hashWorkspace(root, { watch: ["**"] }).hash).not.toBe(antes.hash); + }); + + test("a exclusao aparece no manifesto, contada e nomeada", () => { + const root = hostileDir(); + writeFileSync(join(root, "src", "a.ts"), "x\n"); + mkdirSync(join(root, ".harness", "memory"), { recursive: true }); + writeFileSync(join(root, ".harness", "memory", "consolidation.json"), "{}\n"); + writeFileSync(join(root, ".harness", "state.json"), "{}\n"); + + // Exclusao silenciosa e como um arquivo deixa de ser visto sem ninguem + // perceber. Ela decide medicao, entao mora no registro da medicao. + const manifest = hashWorkspace(root, { watch: ["**"] }); + expect(manifest.harness_artifacts_skipped).toBe(2); + expect(manifest.harness_artifacts_excluded).toContain(".harness/memory/"); + }); + + /** + * O defeito nao foi a lista estar errada, foi ela ter envelhecido calada + * enquanto o `Layout` crescia. Este teste e o que cobra a sincronia: qualquer + * caminho novo no `Layout` tem que ser classificado como artefato de runtime + * ou como observavel, e a escolha fica explicita em vez de omitida. + */ + test("todo caminho do Layout esta classificado como runtime ou observavel", () => { + const layout = layoutFor("/proj"); + const declarados = [...HARNESS_RUNTIME_PATHS, ...HARNESS_OBSERVABLE_PATHS]; + const cobre = (prefixo: string, rel: string): boolean => { + const semBarra = prefixo.endsWith("/") ? prefixo.slice(0, -1) : prefixo; + return rel === prefixo || rel === semBarra || rel.startsWith(prefixo); + }; + const naoClassificados = Object.entries(layout) + .filter(([chave]) => chave !== "root" && chave !== "harness") + .map(([, abs]) => toRel("/proj", abs)) + .filter((rel) => !declarados.some((p) => cobre(p, rel))); + + expect(naoClassificados).toEqual([]); + }); + test("diff nomeia modificado, criado e removido separadamente", () => { const antes = { "a.ts": "h1", "b.ts": "h2" }; const depois = { "a.ts": "h1-mudou", "c.ts": "h3" }; diff --git a/psh/tests/sandbox.test.ts b/psh/tests/sandbox.test.ts index 91373e6..d677966 100644 --- a/psh/tests/sandbox.test.ts +++ b/psh/tests/sandbox.test.ts @@ -105,7 +105,15 @@ describe("montagem do comando (R3.4: defaults seguros preservados)", () => { const status: SandboxStatus = { mode: "ai-jail", detail: "t", jail_bin: "/bin/ai-jail", jail_version: "1" }; const { argv, wrapped } = buildArgv(req, status); expect(wrapped).toBe(true); - expect(argv.slice(0, 5)).toEqual(["/bin/ai-jail", "--no-agent-state", "--no-docker", "--no-ssh", "--no-network"]); + expect(argv.slice(0, 7)).toEqual([ + "/bin/ai-jail", + "--clean", + "--no-save-config", + "--no-agent-state", + "--no-docker", + "--no-ssh", + "--no-network", + ]); expect(argv).toContain("--"); expect(argv.slice(-3)).toEqual(["sh", "-c", "true"]); }); @@ -116,6 +124,24 @@ describe("montagem do comando (R3.4: defaults seguros preservados)", () => { expect(argv).toContain("--network"); expect(argv).not.toContain("--no-network"); }); + + /** + * Campo 01: config de projeto do ai-jail e politica monotonica, entao o que a + * corrida anterior gravou so pode tirar capacidade desta. Sem estas duas flags + * o proprio psh escrevia o `.ai-jail` que desligaria a rede do verificador + * seguinte, e o portao decidia em cima de metrica de corrida quebrada. + */ + test("o psh nunca le nem grava o .ai-jail do projeto", () => { + const status: SandboxStatus = { mode: "ai-jail", detail: "t", jail_bin: "/bin/ai-jail", jail_version: "1" }; + for (const network of [false, true]) { + const { argv } = buildArgv({ ...req, network }, status); + const corte = argv.indexOf("--"); + expect(corte).toBeGreaterThan(0); + const flags = argv.slice(1, corte); + expect(flags).toContain("--clean"); + expect(flags).toContain("--no-save-config"); + } + }); }); describe("propagacao de codigo de saida atravessando o wrapper (R2.10)", () => {