From 2e9a11e61ae37c767b8826d6021bf1f819d060f6 Mon Sep 17 00:00:00 2001 From: "Michael D." Date: Sun, 23 Aug 2026 03:52:12 -0300 Subject: [PATCH 01/12] =?UTF-8?q?fix(psh):=20a=20jaula=20=C3=A9=20montada?= =?UTF-8?q?=20s=C3=B3=20a=20partir=20do=20contrato,=20nunca=20de=20arquivo?= =?UTF-8?q?=20no=20projeto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Medido contra o ai-jail 1.19.2, ao investigar se a marca de qual agente está executando sobrevive dentro da jaula. Por padrão o ai-jail grava um `.ai-jail` na raiz do projeto e o lê na execução seguinte. O arquivo mora dentro da árvore que o agente edita, então parte da montagem passaria a vir de algo que o próprio enjaulado escreve, que é o G4 que o PRD aponta no harness de referência. Na prática ele também 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 não existia. A regra que valia continuou sendo a do argv, e isso está medido, mas o ruído escondia o aviso de verdade. O argv passou a levar `--clean --no-save-config`. Com a escrita desligada, a exceção que perdoava o `.ai-jail` no relatório de violação perdeu o motivo de existir e 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 terceira coisa é que a jaula zera o ambiente do processo filho: `PSH_AGENT` no env do spawn chega vazio lá dentro. A marca vai explícita no argv, por `--env`, para que o núcleo saiba quem pediu a ação também dentro da jaula. Quatro casos novos na suíte de integração com o binário real: nenhuma configuração deixada no projeto, a terceira corrida enjaula igual à primeira, configuração plantada na raiz não muda a fronteira, e o id do agente atravessa a jaula. --- CHANGELOG.md | 40 +++++++++++++++++++++++++++ README.md | 2 +- psh/src/boundary/execute.ts | 46 +++++++++++++++++++++++++------ psh/tests/boundary-aijail.test.ts | 42 +++++++++++++++++++++++++++- psh/tests/boundary-exec.test.ts | 33 ++++++++++++++++++---- 5 files changed, 147 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bae99f6..ced55ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,46 @@ Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/). Versionamento [SemVer](https://semver.org/lang/pt-BR/). +## [Não lançado] + +Endurecimento do motor de fronteira (C3), medido contra o `ai-jail` 1.19.2. + +### Corrigido + +- **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. + +### Qualidade + +- Quatro 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, e o id do agente atravessa + a jaula. + ## [0.2.0] - 2026-08-22 Motor de fronteira (C3). diff --git a/README.md b/README.md index 0e2309c..637d1b0 100644 --- a/README.md +++ b/README.md @@ -216,7 +216,7 @@ perfil lean fase phase.5.build - Build + Quality tentativa 2 (retries 1/2) status in-progress -sandbox ai-jail 0.10.0 operante +sandbox ai-jail 1.19.2 operante fronteira ausente (C3 entra na v0.2) trilha íntegra (47 entradas, 0 problemas) diff --git a/psh/src/boundary/execute.ts b/psh/src/boundary/execute.ts index 002ad51..1e12743 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; @@ -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/tests/boundary-aijail.test.ts b/psh/tests/boundary-aijail.test.ts index e84d6de..eee5d9b 100644 --- a/psh/tests/boundary-aijail.test.ts +++ b/psh/tests/boundary-aijail.test.ts @@ -134,7 +134,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 +147,43 @@ 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("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]"); + }); +}); 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", () => { From 0c99537e16f74f9d9ac0d74bd4c96db00674b3bf Mon Sep 17 00:00:00 2001 From: "Michael D." Date: Sun, 23 Aug 2026 03:54:05 -0300 Subject: [PATCH 02/12] =?UTF-8?q?feat(psh):=20motor=20de=20mem=C3=B3ria,?= =?UTF-8?q?=20com=20p=C3=A1gina=20em=20disco=20e=20handoff=20entre=20sess?= =?UTF-8?q?=C3=B5es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Primeira metade do C5, a faixa transitória de conhecimento entre sessões. A página de memória é markdown em `.harness/memory/pages/`, com cabeçalho validado por JSON Schema. O arquivo é a versão canônica e o SQLite é só índice, que pode ser apagado e reconstruído sem perda: a mesma divisão da evidência. Toda busca sincroniza o índice antes de responder, comparando o hash do arquivo. 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). Sem FTS5 no SQLite a busca cai para varredura e diz que caiu, no resultado e no `psh doctor`. O `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 UC3, fechar a sessão por limite de plano e reabrir depois, possivelmente em outro runtime. A memória entra no deny duro da fronteira, e isso está medido contra o ai-jail real. O bloco de handoff é injetado no início da sessão seguinte, então memória que o agente escreve à mão é texto que ele injeta em si mesmo depois, sem passar por nenhuma porta do núcleo. Falta para fechar a v0.3: a captura automática (R5.1) depende dos hooks do adapter claude-code, e a consolidação por LLM (R5.2) depende do Maestro. Três coisas que a revisão achou e que não são da memória: - Quebra de linha em título ou origem gravava a página, reportava sucesso e deixava a anotação ilegível para sempre. Tag com vírgula voltava partida em duas. As duas param na porta de escrita, com o campo nomeado. - `psh memory promote --to` deixaria um agente escrever fora da própria allowlist com a assinatura do núcleo. O destino passa pela fronteira de quem promove. - Flag de traço simples nunca existiu no parser: `psh audit log -n 5` virava dois posicionais ignorados e o comando respondia com o limite padrão, sem erro. Agora falha dizendo a forma certa, e `--n abc` não chega mais ao LIMIT do SQLite como NaN. --- CHANGELOG.md | 139 +++++- psh/schemas/memory-page.schema.json | 40 ++ psh/src/audit/chain.ts | 2 + psh/src/boundary/policy.ts | 5 + psh/src/cli/args.ts | 44 ++ psh/src/cli/doctor.ts | 48 ++ psh/src/cli/init.ts | 5 +- psh/src/cli/memory.ts | 296 ++++++++++++ psh/src/db/index.ts | 167 +++++++ psh/src/index.ts | 155 ++++++- psh/src/memory/handoff.ts | 217 +++++++++ psh/src/memory/page.ts | 284 ++++++++++++ psh/src/memory/search.ts | 171 +++++++ psh/src/memory/store.ts | 173 +++++++ psh/src/util/paths.ts | 2 + psh/tests/boundary-aijail.test.ts | 14 + psh/tests/cli-inprocess.test.ts | 17 + psh/tests/memory-cli.test.ts | 688 ++++++++++++++++++++++++++++ psh/tests/memory.test.ts | 374 +++++++++++++++ 19 files changed, 2832 insertions(+), 9 deletions(-) create mode 100644 psh/schemas/memory-page.schema.json create mode 100644 psh/src/cli/memory.ts create mode 100644 psh/src/memory/handoff.ts create mode 100644 psh/src/memory/page.ts create mode 100644 psh/src/memory/search.ts create mode 100644 psh/src/memory/store.ts create mode 100644 psh/tests/memory-cli.test.ts create mode 100644 psh/tests/memory.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ced55ae..cd676ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,132 @@ Versionamento [SemVer](https://semver.org/lang/pt-BR/). ## [Não lançado] -Endurecimento do motor de fronteira (C3), medido contra o `ai-jail` 1.19.2. +Motor de memória (C5), primeira metade da v0.3. -### Corrigido +Falta a outra metade para publicar: a captura automática (R5.1) e a consolidação +por LLM (R5.2) dependem, respectivamente, dos hooks do adapter `claude-code` e do +Maestro. + +Enquanto isso a faixa é alimentada à mão, por `psh remember`. + +A revisão desta metade caiu em cima do C3 e endureceu o motor de fronteira, o +que está registrado mais abaixo. + +### 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|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. + +### 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. + +### 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.** @@ -39,10 +162,16 @@ Endurecimento do motor de fronteira (C3), medido contra o `ai-jail` 1.19.2. ### Qualidade -- Quatro casos novos na suíte de integração com a jaula real: nenhuma +- 440 testes, acima dos 326 da v0.2.0, todos passando **também com o `ai-jail` + real ligado**, sem nenhum pulado. + +- Cobertura de linha de 94,29% no projeto, 100% em `memory/search.ts` e em + `cli/args.ts`. + +- 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, e o id do agente atravessa - a jaula. + 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. ## [0.2.0] - 2026-08-22 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/audit/chain.ts b/psh/src/audit/chain.ts index bff91f6..2552424 100644 --- a/psh/src/audit/chain.ts +++ b/psh/src/audit/chain.ts @@ -26,6 +26,8 @@ export type AuditEventType = | "maestro.call" | "human.override" | "human.approval" + | "memory.write" + | "memory.promote" | "audit.note"; export interface AuditEntry { diff --git a/psh/src/boundary/policy.ts b/psh/src/boundary/policy.ts index 5f88415..a7d61e6 100644 --- a/psh/src/boundary/policy.ts +++ b/psh/src/boundary/policy.ts @@ -42,6 +42,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..e968b17 100644 --- a/psh/src/cli/doctor.ts +++ b/psh/src/cli/doctor.ts @@ -5,6 +5,7 @@ import type { ProjectContext } from "./context.ts"; import { detectSandbox } from "../evidence/sandbox.ts"; import { probeGit } from "../evidence/workspace.ts"; import { defaultInstallDirs, loadBoundary } from "../boundary/policy.ts"; +import { syncIndex } from "../memory/search.ts"; import { PSH_TOKEN } from "../util/self.ts"; import { PSH_VERSION } from "../version.ts"; @@ -39,6 +40,7 @@ export function runDoctor(ctx: ProjectContext): DoctorReport { checks.push(checkAbsolutePaths(ctx)); checks.push(checkSecrets(ctx)); checks.push(checkEvidenceOwnership(ctx)); + checks.push(...checkMemory(ctx)); return { psh_version: PSH_VERSION, @@ -347,6 +349,52 @@ 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; +} + 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..cb336e1 --- /dev/null +++ b/psh/src/cli/memory.ts @@ -0,0 +1,296 @@ +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"; + +/** 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 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/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/index.ts b/psh/src/index.ts index d336a4b..09e2d48 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"; @@ -16,6 +16,19 @@ import { renderDoctor, runDoctor } from "./cli/doctor.ts"; import { runSpecCoverage } from "./cli/spec-coverage.ts"; import { renderCi, runCi } from "./adapters/ci.ts"; import { addWriteGlob, boundaryOf, checkPath, renderBoundaryList, renderExec, runExec } from "./cli/boundary.ts"; +import { + get as getMemoryPage, + list as listMemory, + promote as promoteMemoryPage, + remember, + 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,10 +41,14 @@ 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 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 |reindex + [--pinned] [--n ] [--to ] [--force] [--json] + psh handoff [--json] [--n ] psh adapter ci [--json] [--gate-only] [--skip-verify] psh internal spec-coverage --spec --tasks [--out ] @@ -75,6 +92,12 @@ 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); case "internal": @@ -253,7 +276,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`); @@ -366,6 +389,132 @@ function cmdExec(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"], "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 === "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 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(); + } +} + function cmdAdapter(args: ParsedArgs): ExitCode { const sub = args.positional[0]; if (sub !== "ci") { 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..1755ea4 --- /dev/null +++ b/psh/src/memory/page.ts @@ -0,0 +1,284 @@ +import Ajv from "ajv"; +import memoryPageSchema from "../../schemas/memory-page.schema.json" with { type: "json" }; +import { ContractError } from "../util/errors.ts"; +import { formatAjvErrors } from "../workflow/load.ts"; + +const ajv = new Ajv({ allErrors: true, strict: false }); +export const validateMemoryPageSchema = ajv.compile(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..a55b8a3 100644 --- a/psh/src/util/paths.ts +++ b/psh/src/util/paths.ts @@ -17,6 +17,7 @@ export interface Layout { chainPath: string; reviewsDir: string; memoryDir: string; + memoryPagesDir: string; approvalsDir: string; } @@ -34,6 +35,7 @@ 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"), }; } diff --git a/psh/tests/boundary-aijail.test.ts b/psh/tests/boundary-aijail.test.ts index eee5d9b..da2684c 100644 --- a/psh/tests/boundary-aijail.test.ts +++ b/psh/tests/boundary-aijail.test.ts @@ -179,6 +179,20 @@ describe.skipIf(!JAIL_OK)("a jaula e montada so a partir do contrato do psh", () 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 diff --git a/psh/tests/cli-inprocess.test.ts b/psh/tests/cli-inprocess.test.ts index ef3ea5c..c387923 100644 --- a/psh/tests/cli-inprocess.test.ts +++ b/psh/tests/cli-inprocess.test.ts @@ -346,6 +346,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.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"); + }); +}); From b716dd3198f896202077668d80975881d325d714 Mon Sep 17 00:00:00 2001 From: "Michael D." Date: Sun, 23 Aug 2026 04:08:22 -0300 Subject: [PATCH 03/12] =?UTF-8?q?feat(psh):=20consolida=C3=A7=C3=A3o=20de?= =?UTF-8?q?=20sess=C3=A3o,=20montada=20a=20partir=20da=20trilha?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R5.2, a segunda metade do C5: a sessão vira uma página de memória. A captura já existia e ninguém tinha reparado. Decisão de fase, resultado de verificador, violação de fronteira, decisão humana e anotação entram na trilha desde o R4.3, encadeadas 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. Então a consolidação lê a trilha, e cada linha da página carrega o número da entrada que a originou. O que R5.2 pede e esta versão não faz é a reescrita como narrativa, porque isso é chamada de modelo e precisa passar pelo Maestro, que é v0.4. Chamar modelo por fora do roteador seria furar a própria regra. A página diz, nela mesma, que foi montada sem modelo. Quando o C6 entrar, a narrativa vira uma reescrita por cima deste texto. A marca d'água mora em `.harness/memory/consolidation.json`, não no SQLite: o índice é descartável por construção, e se a marca morasse nele, jogar o índice fora faria a próxima consolidação varrer a trilha inteira e despejar meses de histórico numa página só. Ela também só avança depois de a página existir e a trilha registrar, porque na ordem inversa uma falha no meio perderia a sessão para sempre. Duas coisas que a primeira execução mostrou: - A consolidação se auto-alimentava. Ela grava uma entrada `memory.write` ao terminar, e essa entrada virava material da corrida seguinte: rodar o comando três vezes produzia três páginas, e as duas últimas só falavam da anterior. Agora ela pula as próprias entradas, e a marca d'água avança mesmo quando não há página, senão o bookkeeping ficaria sendo relido para sempre. - Trilha comprometida não vira memória. A cadeia é verificada antes de resumir, porque assinar como memória um relato que a própria cadeia não sustenta seria fabricar prova. A página de sessão nasce solta, não fixada: o que precisa sobreviver a qualquer corte é o que o humano fixou com `psh remember` (R5.5), e um resumo não entra nessa categoria só por ser recente. Ela aparece em "Memória recente" no `psh handoff` seguinte, que é a seção que até agora nunca enchia. Tipo de entrada que esta versão não sabe resumir sai contado por tipo na própria página (R2.13). Entrada que some sem número vira sessão que parece menor do que foi. --- CHANGELOG.md | 56 ++- psh/schemas/memory-consolidation.schema.json | 22 + psh/src/cli/memory.ts | 146 ++++++ psh/src/index.ts | 14 +- psh/src/memory/consolidate.ts | 469 +++++++++++++++++++ psh/tests/memory-consolidate.test.ts | 417 +++++++++++++++++ 6 files changed, 1112 insertions(+), 12 deletions(-) create mode 100644 psh/schemas/memory-consolidation.schema.json create mode 100644 psh/src/memory/consolidate.ts create mode 100644 psh/tests/memory-consolidate.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cd676ec..278878a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,12 @@ Versionamento [SemVer](https://semver.org/lang/pt-BR/). Motor de memória (C5), primeira metade da v0.3. -Falta a outra metade para publicar: a captura automática (R5.1) e a consolidação -por LLM (R5.2) dependem, respectivamente, dos hooks do adapter `claude-code` e do -Maestro. +Falta a outra metade para publicar: a captura de **prompt do usuário** (R5.1) +depende dos hooks do adapter `claude-code`, e a reescrita da página de sessão +como narrativa (R5.2) depende do Maestro. -Enquanto isso a faixa é alimentada à mão, por `psh remember`. +O resto da captura já existe sem adapter nenhum, porque a trilha de auditoria +sempre registrou decisão de fase, resultado de verificador e anotação. A revisão desta metade caiu em cima do C3 e endureceu o motor de fronteira, o que está registrado mais abaixo. @@ -34,8 +35,8 @@ que está registrado mais abaixo. - 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|reindex` e - `psh handoff` (R5.4). +- `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 @@ -49,6 +50,15 @@ que está registrado mais abaixo. 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.** @@ -114,6 +124,34 @@ que está registrado mais abaixo. (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. + ### Corrigido fora do escopo da memória - **Flag de traço simples nunca existiu no parser.** @@ -162,11 +200,11 @@ que está registrado mais abaixo. ### Qualidade -- 440 testes, acima dos 326 da v0.2.0, todos passando **também com o `ai-jail` +- 444 testes, acima dos 326 da v0.2.0, todos passando **também com o `ai-jail` real ligado**, sem nenhum pulado. -- Cobertura de linha de 94,29% no projeto, 100% em `memory/search.ts` e em - `cli/args.ts`. +- Cobertura de linha de 94,44% no projeto, 100% em `memory/search.ts`, + `memory/consolidate.ts` e `cli/args.ts`. - 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, 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/src/cli/memory.ts b/psh/src/cli/memory.ts index cb336e1..6e88c34 100644 --- a/psh/src/cli/memory.ts +++ b/psh/src/cli/memory.ts @@ -7,6 +7,19 @@ 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"); @@ -194,6 +207,139 @@ function renderPromovido(stored: StoredPage, now: Date): string { 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); } diff --git a/psh/src/index.ts b/psh/src/index.ts index 09e2d48..6dfc803 100644 --- a/psh/src/index.ts +++ b/psh/src/index.ts @@ -17,10 +17,12 @@ import { runSpecCoverage } from "./cli/spec-coverage.ts"; import { renderCi, runCi } from "./adapters/ci.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, @@ -46,7 +48,7 @@ const USAGE = `psh ${PSH_VERSION} - ProStaff Harness (nucleo verificavel) psh boundary list|check |add [--agent ] [--json] psh exec --agent [--timeout ] -- psh remember "" [--title ] [--tags a,b] [--kind ] - psh memory list|search |get |promote |reindex + 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] @@ -431,7 +433,7 @@ function parseTagList(raw: string | null): string[] { } function cmdMemory(args: ParsedArgs): ExitCode { - rejectUnknownFlags(args, ["json", "root", "n", "pinned", "to", "force"], "memory"); + 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 { @@ -482,6 +484,12 @@ function cmdMemory(args: ParsedArgs): ExitCode { 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( @@ -493,7 +501,7 @@ function cmdMemory(args: ParsedArgs): ExitCode { } throw new PshError( - `subcomando desconhecido: psh memory ${sub}. Use list, search, get, promote ou reindex.`, + `subcomando desconhecido: psh memory ${sub}. Use list, search, get, promote, consolidate ou reindex.`, { exitCode: EXIT.FAILURE }, ); } finally { diff --git a/psh/src/memory/consolidate.ts b/psh/src/memory/consolidate.ts new file mode 100644 index 0000000..a26368f --- /dev/null +++ b/psh/src/memory/consolidate.ts @@ -0,0 +1,469 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import Ajv from "ajv"; +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 type { Layout } from "../util/paths.ts"; +import { formatAjvErrors } from "../workflow/load.ts"; + +const ajv = new Ajv({ allErrors: true, strict: false }); +export const validateConsolidationSchema = ajv.compile(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 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[]; + 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: [], + 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 "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.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"); +} + +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)`; + } + 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/tests/memory-consolidate.test.ts b/psh/tests/memory-consolidate.test.ts new file mode 100644 index 0000000..fdafa13 --- /dev/null +++ b/psh/tests/memory-consolidate.test.ts @@ -0,0 +1,417 @@ +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("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(); + } + }); +}); From f84f4fddc2c69c479433a2e1aa1f08ad4e23c063 Mon Sep 17 00:00:00 2001 From: "Michael D." Date: Sun, 23 Aug 2026 04:40:48 -0300 Subject: [PATCH 04/12] =?UTF-8?q?fix(psh):=20n=C3=A3o=20se=20escreve=20em?= =?UTF-8?q?=20cima=20de=20trilha=20que=20n=C3=A3o=20bate=20com=20a=20?= =?UTF-8?q?=C3=A2ncora?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A âncora existe para pegar reescrita coordenada da cadeia (R4.1), mas quem a conferia era só o `psh audit verify`. Como todo `append` regrava a âncora com o topo novo, bastava uma escrita qualquer depois da adulteração para a cadeia voltar a fechar: remova a última linha da trilha, rode `psh remember`, e o `verify` volta a dizer "íntegra" para uma trilha de onde uma entrada foi arrancada. Agora o `append` confere antes de escrever e recusa com erro de auditoria. A conferência é por contagem de linha e hash do topo, sem re-hashear a cadeia inteira: remoção no meio muda a contagem, e edição ou religamento mudam o hash do topo. É o mesmo alcance que a âncora já prometia, cobrado antes da escrita e não só depois dela. Achado por um teste do adapter que esperava a consolidação recusar uma trilha quebrada e viu ela passar, porque o próprio hook de fim de sessão tinha gravado uma entrada antes e consertado a âncora no caminho. --- CHANGELOG.md | 17 ++++++++++++++ psh/src/audit/chain.ts | 34 ++++++++++++++++++++++++++++ psh/tests/audit-chain.test.ts | 42 +++++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 278878a..8533c31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -152,6 +152,23 @@ que está registrado mais abaixo. que foi montada sem modelo. Quando o C6 entrar, a narrativa vira uma reescrita por cima deste texto. +### 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.** diff --git a/psh/src/audit/chain.ts b/psh/src/audit/chain.ts index 2552424..af93df2 100644 --- a/psh/src/audit/chain.ts +++ b/psh/src/audit/chain.ts @@ -28,6 +28,8 @@ export type AuditEventType = | "human.approval" | "memory.write" | "memory.promote" + | "adapter.event" + | "prompt.submit" | "audit.note"; export interface AuditEntry { @@ -98,6 +100,7 @@ export class AuditChain { const release = this.#lock(); try { const head = this.#head(); + this.#assertAncoraBate(head); const base = { seq: head.seq + 1, ts: this.#now().toISOString(), @@ -219,6 +222,37 @@ 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 = existsSync(this.path) + ? readFileSync(this.path, "utf8").split("\n").filter((l) => l.trim() !== "").length + : 0; + + 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'.`, + { path: this.path, anchor_count: ancora.count, file_count: linhas }, + ); + } + #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/tests/audit-chain.test.ts b/psh/tests/audit-chain.test.ts index 5a3de8b..a2182aa 100644 --- a/psh/tests/audit-chain.test.ts +++ b/psh/tests/audit-chain.test.ts @@ -174,3 +174,45 @@ 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(); + }); +}); From 294beeb5f24778c3f42f41d2720cb070b27fa102 Mon Sep 17 00:00:00 2001 From: "Michael D." Date: Sun, 23 Aug 2026 04:41:42 -0300 Subject: [PATCH 05/12] =?UTF-8?q?feat(psh):=20adapter=20claude-code,=20com?= =?UTF-8?q?=20o=20contrato=20lido=20do=20bin=C3=A1rio=20instalado?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C8 para o runtime que a casa usa, e a peça que fechava o R5.1: o prompt do usuário, que é a única coisa da captura que a trilha não vê sozinha. Cinco pontos de extensão. SessionStart injeta as regras do harness e o bloco de retomada; UserPromptSubmit registra o pedido; 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. O registro é 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, que é a mesma postura do R2.14 para expressão regular. Nada disso foi escrito de memória. O R8.6b manda validar contra o runtime instalado, e a seção 3.2 do PRD registra duas vezes em que essa regra foi quebrada nesta 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 os nomes saíram do artefato do Claude Code 2.1.238, ficam em `contract.json`, e `psh adapter claude-code contract` lê o binário e reprova o que não existe. O que o binário mostrou e a documentação não diria: o runtime aceita 31 eventos de hook, `PreToolUse` tem `defer` além de allow, deny e ask, a entrada de hook aceita a forma exec, e `SessionEnd` existe. Este último mudou o desenho: `Stop` dispara ao fim de cada turno, e consolidar ali geraria uma página por resposta do modelo. A validação foi conferida 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 "provided additionalContext (1253 chars)", e o efeito foi conferido em disco (R8.6e): as entradas na trilha e a página de sessão gerada. Falha do adapter nunca vira sessão travada nem escrita liberada em silêncio: em PreToolUse a decisão degradada é `ask`, com o motivo na tela, e quem decide passa a ser o humano. 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` diz quantos pontos estão ativos, porque quatro de cinco significa uma responsabilidade do R8.1 que simplesmente não acontece. Fica declarado o que não entrou: servidor MCP com as tools do núcleo (R5.6), compressor de saída (R8.7), e o registro de toda chamada de tool na trilha, que somaria um fsync por chamada e precisa de medição antes de virar padrão. Também fica declarado o limite da varredura de símbolo: ela procura bytes, então símbolo que é prefixo de outro é dado como presente quando só o mais longo existe. --- CHANGELOG.md | 89 ++- psh/src/adapters/claude-code/contract.json | 71 +++ psh/src/adapters/claude-code/contract.ts | 243 ++++++++ psh/src/adapters/claude-code/hook.ts | 381 +++++++++++++ psh/src/adapters/claude-code/install.ts | 288 ++++++++++ psh/src/cli/doctor.ts | 55 ++ psh/src/index.ts | 117 +++- psh/src/memory/consolidate.ts | 53 ++ psh/tests/adapter-claude-code.test.ts | 633 +++++++++++++++++++++ psh/tests/memory-consolidate.test.ts | 46 ++ 10 files changed, 1963 insertions(+), 13 deletions(-) create mode 100644 psh/src/adapters/claude-code/contract.json create mode 100644 psh/src/adapters/claude-code/contract.ts create mode 100644 psh/src/adapters/claude-code/hook.ts create mode 100644 psh/src/adapters/claude-code/install.ts create mode 100644 psh/tests/adapter-claude-code.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8533c31..a437f0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,14 +6,11 @@ Versionamento [SemVer](https://semver.org/lang/pt-BR/). ## [Não lançado] -Motor de memória (C5), primeira metade da v0.3. +Motor de memória (C5) e adapter `claude-code` (C8), que é a v0.3 inteira. -Falta a outra metade para publicar: a captura de **prompt do usuário** (R5.1) -depende dos hooks do adapter `claude-code`, e a reescrita da página de sessão -como narrativa (R5.2) depende do Maestro. - -O resto da captura já existe sem adapter nenhum, porque a trilha de auditoria -sempre registrou decisão de fase, resultado de verificador e anotação. +Falta para publicar apenas 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), declarado abaixo. A revisão desta metade caiu em cima do C3 e endureceu o motor de fronteira, o que está registrado mais abaixo. @@ -152,6 +149,75 @@ que está registrado mais abaixo. 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.** @@ -217,11 +283,14 @@ que está registrado mais abaixo. ### Qualidade -- 444 testes, acima dos 326 da v0.2.0, todos passando **também com o `ai-jail` +- 490 testes, acima dos 326 da v0.2.0, todos passando **também com o `ai-jail` real ligado**, sem nenhum pulado. -- Cobertura de linha de 94,44% no projeto, 100% em `memory/search.ts`, - `memory/consolidate.ts` e `cli/args.ts`. +- Cobertura de linha de 94,29% no projeto, 100% em `memory/search.ts`, + `memory/consolidate.ts`, `audit/chain.ts` e `cli/args.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, 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/cli/doctor.ts b/psh/src/cli/doctor.ts index e968b17..700e190 100644 --- a/psh/src/cli/doctor.ts +++ b/psh/src/cli/doctor.ts @@ -6,6 +6,8 @@ import { detectSandbox } from "../evidence/sandbox.ts"; import { 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"; @@ -41,6 +43,7 @@ export function runDoctor(ctx: ProjectContext): DoctorReport { checks.push(checkSecrets(ctx)); checks.push(checkEvidenceOwnership(ctx)); checks.push(...checkMemory(ctx)); + checks.push(...checkAdapterClaudeCode(ctx)); return { psh_version: PSH_VERSION, @@ -395,6 +398,58 @@ function checkMemory(ctx: ProjectContext): Check[] { 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/index.ts b/psh/src/index.ts index 6dfc803..070e5be 100644 --- a/psh/src/index.ts +++ b/psh/src/index.ts @@ -15,6 +15,15 @@ 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, @@ -52,6 +61,7 @@ const USAGE = `psh ${PSH_VERSION} - ProStaff Harness (nucleo verificavel) [--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'. @@ -101,7 +111,7 @@ export async function main(argv: string[]): Promise { case "handoff": return cmdHandoff(args); case "adapter": - return cmdAdapter(args); + return await cmdAdapter(args); case "internal": return cmdInternal(args); default: @@ -523,10 +533,11 @@ function cmdHandoff(args: ParsedArgs): ExitCode { } } -function cmdAdapter(args: ParsedArgs): ExitCode { +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, }); } @@ -547,6 +558,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 index a26368f..a73c8c9 100644 --- a/psh/src/memory/consolidate.ts +++ b/psh/src/memory/consolidate.ts @@ -106,6 +106,14 @@ export interface MarcoNoDigest { 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; @@ -115,6 +123,7 @@ export interface SessionDigest { /** 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[]; @@ -146,6 +155,7 @@ export function buildDigest(entries: readonly AuditEntry[]): SessionDigest { finished_at: entries.at(-1)?.ts ?? "", entries_examined: entries.length, marcos: [], + pedidos: [], fases: [], verificadores: [], anotacoes: [], @@ -244,6 +254,28 @@ export function buildDigest(entries: readonly AuditEntry[]): SessionDigest { 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", @@ -307,6 +339,19 @@ export function narrarDaTrilha(digest: SessionDigest): string { 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(""); @@ -390,6 +435,11 @@ export function narrarDaTrilha(digest: SessionDigest): string { 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); @@ -399,6 +449,9 @@ export function tituloDoDigest(digest: SessionDigest): string { 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`; } 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/memory-consolidate.test.ts b/psh/tests/memory-consolidate.test.ts index fdafa13..46c99ce 100644 --- a/psh/tests/memory-consolidate.test.ts +++ b/psh/tests/memory-consolidate.test.ts @@ -121,6 +121,52 @@ describe("digest: cada linha sai de uma entrada da trilha", () => { 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" }), From 5a0013c6488c87d54e9c2a07bceac78aae6049e7 Mon Sep 17 00:00:00 2001 From: "Michael D." Date: Sun, 23 Aug 2026 04:47:15 -0300 Subject: [PATCH 06/12] chore: v0.3.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Memória entre sessões e adapter Claude Code, os dois marcos que o roadmap declarava para esta versão. Fica registrado no README o que a versão não faz: a página de sessão é montada da trilha e não escrita por modelo, a memória não é fonte canônica, o adapter não expõe tools por MCP, e a atribuição de quem fez o quê é melhor esforço dentro da jaula. --- CHANGELOG.md | 2 +- README.md | 53 +++++++++++++++++++++++++++++++++++++++------- psh/README.md | 5 ++++- psh/package.json | 2 +- psh/src/version.ts | 2 +- 5 files changed, 52 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a437f0a..d412446 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/). Versionamento [SemVer](https://semver.org/lang/pt-BR/). -## [Não lançado] +## [0.3.0] - 2026-08-23 Motor de memória (C5) e adapter `claude-code` (C8), que é a v0.3 inteira. diff --git a/README.md b/README.md index 637d1b0..4f9c13f 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 · 506 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 │ └─────────────────────────────────────────────────────────────────────────────┘ ``` @@ -201,6 +206,13 @@ 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 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. @@ -217,7 +229,7 @@ fase phase.5.build - Build + Quality tentativa 2 (retries 1/2) status in-progress sandbox ai-jail 1.19.2 operante -fronteira ausente (C3 entra na v0.2) +fronteira mount (2 agentes) trilha íntegra (47 entradas, 0 problemas) portão all-of: REPROVADO @@ -390,9 +402,33 @@ 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.** - Marcos posteriores. + Maestro e contabilidade de token são marcos posteriores. --- @@ -401,7 +437,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 # 506 testes bun run build # binário único ``` @@ -411,7 +447,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 +470,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..0ae1002 100644 --- a/psh/README.md +++ b/psh/README.md @@ -22,9 +22,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: 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/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}`; From eea47360ff22001ad7a5bfd1b1cbf2d1b4915b72 Mon Sep 17 00:00:00 2001 From: "Michael D." Date: Sun, 23 Aug 2026 04:57:02 -0300 Subject: [PATCH 07/12] =?UTF-8?q?perf(psh):=20schema=20compila=20na=20prim?= =?UTF-8?q?eira=20valida=C3=A7=C3=A3o,=20n=C3=A3o=20no=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Achado medindo o harness contra um projeto real, com o binário compilado. `psh --version` levava 185 ms; o `bun` cru leva 2 ms. Os 181 ms de diferença iam embora antes de a primeira linha de lógica rodar, porque o ajv compilava os sete schemas do projeto no escopo de módulo, mesmo em comando que não valida nada. Isso não era só cosmético: o hook do adapter roda uma vez por chamada de tool, então essa partida entrava no caminho crítico de cada escrita da sessão. Os validadores passaram a compilar sob demanda, mantendo a interface do ajv, inclusive `.errors`. Nenhum ponto de uso mudou. Depois: `psh --version` em 53 ms e o hook de PreToolUse em 156 ms. O que sobra está atribuído e registrado no CHANGELOG: a decisão de fronteira 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. --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ psh/src/boundary/policy.ts | 5 ++--- psh/src/memory/consolidate.ts | 5 ++--- psh/src/memory/page.ts | 5 ++--- psh/src/util/schema.ts | 30 ++++++++++++++++++++++++++++++ psh/src/workflow/load.ts | 12 +++++------- 6 files changed, 67 insertions(+), 16 deletions(-) create mode 100644 psh/src/util/schema.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d412446..3689f85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,32 @@ Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/). Versionamento [SemVer](https://semver.org/lang/pt-BR/). +## [Não lançado] + +### Corrigido + +- **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. + ## [0.3.0] - 2026-08-23 Motor de memória (C5) e adapter `claude-code` (C8), que é a v0.3 inteira. diff --git a/psh/src/boundary/policy.ts b/psh/src/boundary/policy.ts index a7d61e6..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; diff --git a/psh/src/memory/consolidate.ts b/psh/src/memory/consolidate.ts index a73c8c9..7d2cc91 100644 --- a/psh/src/memory/consolidate.ts +++ b/psh/src/memory/consolidate.ts @@ -1,15 +1,14 @@ import { existsSync } from "node:fs"; import { join } from "node:path"; -import Ajv from "ajv"; 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"; -const ajv = new Ajv({ allErrors: true, strict: false }); -export const validateConsolidationSchema = ajv.compile(consolidationSchema); +export const validateConsolidationSchema = lazyValidator(consolidationSchema); export interface ConsolidationState { _type: "psh-memory-consolidation"; diff --git a/psh/src/memory/page.ts b/psh/src/memory/page.ts index 1755ea4..e3e7f8a 100644 --- a/psh/src/memory/page.ts +++ b/psh/src/memory/page.ts @@ -1,10 +1,9 @@ -import Ajv from "ajv"; 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"; -const ajv = new Ajv({ allErrors: true, strict: false }); -export const validateMemoryPageSchema = ajv.compile(memoryPageSchema); +export const validateMemoryPageSchema = lazyValidator(memoryPageSchema); export type PageKind = "fact" | "decision" | "verifier" | "session" | "prompt" | "note"; 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/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"}`); From 2521a99fd6a84cd3064ccb01318425f8682654ae Mon Sep 17 00:00:00 2001 From: "Michael D." Date: Tue, 25 Aug 2026 13:51:09 -0300 Subject: [PATCH 08/12] =?UTF-8?q?fix(psh):=20a=20jaula=20de=20uma=20corrid?= =?UTF-8?q?a=20n=C3=A3o=20contamina=20a=20corrida=20seguinte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O `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. Ou seja, o que o psh gravava numa corrida só podia tirar capacidade da corrida seguinte, nunca devolver. 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, e nada na saída dizia 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 mesma escrita sujava a árvore observada durante a corrida: qualquer verificador com `watch: ["**"]` reprovava por `workspace-mutated-during-run`, incluindo o `secrets` que vem no pacote. Um verificador que roda `/bin/true` reprovava. A invariante que isso cristaliza: sandbox(corrida atual) não depende de sandbox(corrida anterior). Cinco testes de integração cobrem as duas ordens de execução com rede real, e foram exercitados contra o próprio defeito: com a correção revertida, os cinco falham. Achados 1 e 2 do Campo 01. --- psh/src/evidence/sandbox.ts | 12 ++- psh/tests/boundary-aijail.test.ts | 139 ++++++++++++++++++++++++++++++ psh/tests/sandbox.test.ts | 28 +++++- 3 files changed, 177 insertions(+), 2 deletions(-) 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/tests/boundary-aijail.test.ts b/psh/tests/boundary-aijail.test.ts index da2684c..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"; /** @@ -201,3 +203,140 @@ describe.skipIf(!JAIL_OK)("a jaula e montada so a partir do contrato do psh", () 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/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)", () => { From 7be1bf2e3a69ece21dd54b02902a72a347a745e0 Mon Sep 17 00:00:00 2001 From: "Michael D." Date: Tue, 25 Aug 2026 13:51:26 -0300 Subject: [PATCH 09/12] =?UTF-8?q?fix(psh):=20o=20frescor=20volta=20a=20med?= =?UTF-8?q?ir=20a=20=C3=A1rvore,=20n=C3=A3o=20o=20harness=20nem=20meia=20?= =?UTF-8?q?=C3=A1rvore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dois defeitos do mesmo mecanismo, achados no primeiro teste de campo. O artefato de runtime do próprio harness contava como mudança do workspace. A lista de sempre-excluídos cobria quatro caminhos enquanto o `Layout` já tinha sete diretórios de runtime, então `memory/`, `approvals/`, `reviews/` e `tmp/` entravam no hash. Um `psh memory consolidate` entre a medição e o portão derrubava a evidência de quem observa `**`, citando arquivo que verificador nenhum escreveu. Num projeto Git o `.harness/.gitignore` mascarava parte disso. A classificação agora é explícita nos dois sentidos, em `HARNESS_RUNTIME_PATHS` e `HARNESS_OBSERVABLE_PATHS`: 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. Há teste cobrando que todo caminho do `Layout` esteja numa das duas listas, porque o defeito não foi a lista estar errada, foi ela ter envelhecido calada enquanto o `Layout` crescia. Pelo mesmo motivo o `tmpDir` entrou no `Layout`, em vez de continuar como a string "tmp" repetida no `execute.ts`, que era como ele escapava da classificação. A exclusão passou a aparecer no manifesto da evidência, contada e nomeada: decisão de medição que não fica no registro não pode ser auditada depois. O segundo: `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 caía para caminhada, e o `.gitignore` deixava de valer para o hash. Na cobaia entraram no hash o `.env` com chave real, o diretório de relatório e um binário de node de 100 MB. O `psh doctor` chamava isso de `[ok] enumeracao por caminhada`, com o detalhe "projeto nao e repositorio Git", que é falso, enquanto o `checkSecrets` do mesmo doctor usava `git ls-files` no mesmo diretório sem problema. Quem responde agora é o Git, por `git rev-parse --is-inside-work-tree`. O fallback pelo diretório continua 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. A regra que os dois casos compartilham: a infraestrutura usada para observar um workspace não pode modificar o conjunto de arquivos observado. Resíduo do achado 2, e achado 4 do Campo 01. --- psh/src/boundary/execute.ts | 2 +- psh/src/cli/doctor.ts | 6 +- psh/src/evidence/runner.ts | 8 +- psh/src/evidence/store.ts | 16 +++- psh/src/evidence/workspace.ts | 54 ++++++++++--- psh/src/util/paths.ts | 44 +++++++++++ psh/tests/paths-and-globs.test.ts | 126 +++++++++++++++++++++++++++++- 7 files changed, 233 insertions(+), 23 deletions(-) diff --git a/psh/src/boundary/execute.ts b/psh/src/boundary/execute.ts index 1e12743..9749c4a 100644 --- a/psh/src/boundary/execute.ts +++ b/psh/src/boundary/execute.ts @@ -136,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 }); diff --git a/psh/src/cli/doctor.ts b/psh/src/cli/doctor.ts index 700e190..1ae6c9e 100644 --- a/psh/src/cli/doctor.ts +++ b/psh/src/cli/doctor.ts @@ -3,7 +3,7 @@ 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"; @@ -341,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 { 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/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/util/paths.ts b/psh/src/util/paths.ts index a55b8a3..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; @@ -19,6 +60,8 @@ export interface Layout { 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 { @@ -37,6 +80,7 @@ export function layoutFor(root: string): Layout { memoryDir: join(harness, "memory"), memoryPagesDir: join(harness, "memory", "pages"), approvalsDir: join(harness, "approvals"), + tmpDir: join(harness, "tmp"), }; } 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" }; From b0ba088151fe27ea14167bdd11064751c3ac00ba Mon Sep 17 00:00:00 2001 From: "Michael D." Date: Tue, 25 Aug 2026 13:51:26 -0300 Subject: [PATCH 10/12] feat(psh): psh audit reanchor, o caminho de volta para trilha divergente MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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, em vez de virar diretório apagado no susto. O `--reason` é obrigatório por natureza do comando: o valor dele está no que fica escrito, não em silenciar o alarme. E ele recusa quando o defeito está dentro do arquivo, ou seja linha corrompida, seq fora de ordem, elo quebrado ou hash que não fecha. Aí a âncora não é o defeito, e mover a âncora só trocaria um relatório vermelho por outro. Prometer conserto nesse caso seria pior que não ter o comando. A mensagem da recusa passou a citar a saída, em vez de só dizer que travou. Achado 10 do Campo 01. --- psh/src/audit/chain.ts | 147 +++++++++++++++++++++++++------- psh/src/index.ts | 34 +++++++- psh/tests/audit-chain.test.ts | 90 +++++++++++++++++++ psh/tests/cli-inprocess.test.ts | 36 ++++++++ 4 files changed, 272 insertions(+), 35 deletions(-) diff --git a/psh/src/audit/chain.ts b/psh/src/audit/chain.ts index af93df2..7e6b0aa 100644 --- a/psh/src/audit/chain.ts +++ b/psh/src/audit/chain.ts @@ -101,45 +101,119 @@ export class AuditChain { try { const head = this.#head(); this.#assertAncoraBate(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) }; + 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"); @@ -240,19 +314,26 @@ export class AuditChain { const ancora = this.#anchor?.readAnchor() ?? null; if (ancora === null) return; - const linhas = existsSync(this.path) - ? readFileSync(this.path, "utf8").split("\n").filter((l) => l.trim() !== "").length - : 0; + 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'.`, + `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/index.ts b/psh/src/index.ts index 070e5be..9aa7fcd 100644 --- a/psh/src/index.ts +++ b/psh/src/index.ts @@ -53,6 +53,7 @@ const USAGE = `psh ${PSH_VERSION} - ProStaff Harness (nucleo verificavel) psh advance [--force] [--reason ] [--yes] [--json] psh approve [--as ] 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 ] -- @@ -268,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 { @@ -299,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 { diff --git a/psh/tests/audit-chain.test.ts b/psh/tests/audit-chain.test.ts index a2182aa..8956f7f 100644 --- a/psh/tests/audit-chain.test.ts +++ b/psh/tests/audit-chain.test.ts @@ -215,4 +215,94 @@ describe("nao se escreve em cima de trilha adulterada", () => { 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/cli-inprocess.test.ts b/psh/tests/cli-inprocess.test.ts index c387923..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", () => { From 0c7fa9b2a63309684b7377a419222b938565b23d Mon Sep 17 00:00:00 2001 From: "Michael D." Date: Tue, 25 Aug 2026 13:51:45 -0300 Subject: [PATCH 11/12] =?UTF-8?q?fix(psh):=20o=20relat=C3=B3rio=20de=20CI?= =?UTF-8?q?=20diz=20o=20estado=20real=20da=20fronteira?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `boundary_engine` era o literal "absent" no tipo e no objeto, declaração da v0.1 de quando o C3 não existia, que ninguém atualizou quando o motor de fronteira 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 que motor de fronteira não havia. Quem consome esse JSON conclui o contrário do que está acontecendo, que é a pior falha possível num contrato de máquina: não é ausência de informação, é informação invertida. O campo virou `boundary: { mode, agents, detail }` e sai da `descreverFronteira`, a mesma função que alimenta o `psh status`. O defeito não foi o valor errado, foi haver duas declarações paralelas do mesmo fato, e por isso o teste não confere o valor: ele exige que `ci --json` e `status --json` digam a mesma coisa, palavra por palavra. Muda o formato do relatório: quem consumia `boundary_engine` passa a ler `boundary.mode`. Achado 3 do Campo 01. --- psh/src/adapters/ci.ts | 22 +++++++++++++++++++--- psh/src/cli/status.ts | 15 +++++++++++++-- psh/tests/adapter-ci.test.ts | 23 ++++++++++++++++++++--- 3 files changed, 52 insertions(+), 8 deletions(-) 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/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/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", () => { From 5a6d558bbee4deebcc1d54ceccdbdf011a17d35e Mon Sep 17 00:00:00 2001 From: "Michael D." Date: Tue, 25 Aug 2026 13:51:57 -0300 Subject: [PATCH 12/12] docs: v0.3.0, com o que o primeiro teste de campo devolveu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 0.3.0 foi escrita em 23/08 e nunca chegou a ser publicada. Entre escrever e publicar ela foi a campo pela primeira vez, contra um projeto real que chama LLM de verdade, e voltou com dez achados. Cinco foram corrigidos antes desta publicação e estão nos commits anteriores. Os outros cinco ficam 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 bloco [Não lançado] do changelog foi absorvido na 0.3.0, que passa a ser datada de 25/08. O README do psh ainda abria com "Marco v0.1" e tinha uma seção dizendo que o C3 entrava na v0.2 e que o adapter `ci` devolvia `boundary_engine: absent`. Era a mesma classe de mentira do achado 3, só que no arquivo que se lê primeiro. O marco virou v0.3, a seção "Fora do escopo" foi reescrita para o que de fato falta, e o layout em disco passou a mostrar `memory/` e `tmp/`, que existem desde a v0.3 e não estavam lá. O README da raiz tinha o mesmo `boundary_engine: absent` no exemplo de JSON do CI. A seção "O que o psh não faz" ganhou os achados que seguem abertos, incluindo o do toolchain e credencial, que no campo custou vendorizar o binário do node dentro da árvore. Também entram no changelog as três decisões de projeto que os achados cristalizaram: quem observa um workspace não o modifica, trilha divergente tem caminho de volta declarado, e um fato tem uma fonte só. --- .gitignore | 5 ++ CHANGELOG.md | 217 ++++++++++++++++++++++++++++++++++++++++++-------- README.md | 24 +++++- psh/README.md | 162 +++++++++++++++++++++++++++---------- 4 files changed, 331 insertions(+), 77 deletions(-) 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 3689f85..1684388 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,43 +4,21 @@ Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/). Versionamento [SemVer](https://semver.org/lang/pt-BR/). -## [Não lançado] - -### Corrigido - -- **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. - -## [0.3.0] - 2026-08-23 +## [0.3.0] - 2026-08-25 Motor de memória (C5) e adapter `claude-code` (C8), que é a v0.3 inteira. -Falta para publicar apenas 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), declarado abaixo. +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 @@ -307,13 +285,175 @@ teste do handler e pelo mesmo registro que o `SessionStart` usa. 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 -- 490 testes, acima dos 326 da v0.2.0, todos passando **também com o `ai-jail` - real ligado**, sem nenhum pulado. +- 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 94,29% no projeto, 100% em `memory/search.ts`, - `memory/consolidate.ts`, `audit/chain.ts` e `cli/args.ts`. +- 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. @@ -323,6 +463,15 @@ teste do handler e pelo mesmo registro que o `SessionStart` usa. 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 4f9c13f..273848d 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ ║ Cobertura, teste, lint e segurança viram registro assinado por hash da ║ ║ árvore que foi verificada. ║ ║ ║ -║ v0.3.0 · memória entre sessões · adapter Claude Code · 506 testes ║ +║ v0.3.0 · memória entre sessões · adapter Claude Code · 524 testes ║ ╚══════════════════════════════════════════════════════════════════════════════╝ ``` @@ -205,6 +205,7 @@ 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 @@ -266,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 | --- @@ -358,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" }, @@ -430,6 +432,22 @@ não ter harness nenhum. 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.** + + 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. + --- ## 09 · Desenvolvimento @@ -437,7 +455,7 @@ não ter harness nenhum. ```sh cd psh bun run check # typecheck + verificação estática + testes com cobertura -bun test # 506 testes +bun test # 524 testes bun run build # binário único ``` diff --git a/psh/README.md b/psh/README.md index 0ae1002..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. @@ -38,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 @@ -78,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 ``` @@ -214,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 @@ -228,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 @@ -236,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 @@ -253,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%. @@ -290,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. @@ -334,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` @@ -342,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.