@polymorphism-tech/morph-spec 8.45.0 → 8.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CLAUDE.md +1 -0
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +1 -0
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +1 -1
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/CLAUDE.md +1 -1
  8. package/framework/MORPH.md +1 -1
  9. package/framework/docs/CLI.md +5 -4
  10. package/framework/schemas/config.schema.json +4 -0
  11. package/framework/schemas/feature.schema.json +3 -1
  12. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  13. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  14. package/framework/skills/core/morph-apply/SKILL.md +1 -1
  15. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  16. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  17. package/framework/skills/core/morph-implement/SKILL.md +1 -1
  18. package/framework/skills/core/morph-machine/SKILL.md +1 -1
  19. package/framework/skills/core/morph-media/SKILL.md +1 -1
  20. package/framework/skills/core/morph-plan/SKILL.md +1 -1
  21. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  22. package/framework/skills/core/morph-review/SKILL.md +28 -15
  23. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  24. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  25. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  26. package/package.json +1 -1
  27. package/src/commands/validation/verify.js +97 -46
  28. package/src/lib/ci/ci-verify.js +118 -0
  29. package/src/lib/ci/github-ci.js +192 -0
  30. package/src/lib/ci/workflow-scaffold.js +122 -0
  31. package/src/lib/telemetry/verify-event.js +3 -2
  32. package/framework/hooks/claude-code/__pycache__/statusline.cpython-313.pyc +0 -0
package/CLAUDE.md CHANGED
@@ -156,6 +156,7 @@ When modifying framework components, verify the full scope of related files.
156
156
  | `src/lib/mutation/**` (`morph-spec mutate`, a mutação dirigida do Gate 3) | `src/commands/validation/mutate.js` (fina), `bin/morph-spec.js`, o bullet `mutate` de `framework/docs/CLI.md`, a seção "Prova por mutação" de `framework/evals/review-checklist.md` e o prompt do avaliador em `morph-review` §4, com os canários `test/lib/mutation/*` e `test/commands/mutate.test.js`. O mundo falso `test/helpers/mutation-world.js` reproduz o build INCREMENTAL do MSBuild (sem essa propriedade o teste da armadilha do mtime não morde). Invariantes: **no máximo 3 mutantes, cada um com `expect`** (`manifest.js` recusa com exit 2 — mutante cujo efeito o avaliador não prevê tende a ser equivalente); **com subconjunto utilizável, ele decide** (sobrevivente fica `survived` com escopo `subset`, sem escalar à suíte inteira — o subconjunto é escolhido pela ferramenta, nunca pelo avaliador); sem subconjunto, a suíte inteira roda e a morte tem de se repetir; restaurar é **escrita nova**, nunca `os.utime`; `killed` exige falha NOVA contra a rodada-base do MESMO escopo, e `inconclusive`/`build-error` nunca contam como morte; `proveBinary` compara a DLL final com a construída da árvore limpa. `dotnetBuildCommandFor` é a única grafia de "dotnet build" e `csharpClasses` a única leitura de classe de teste; o filtro do subconjunto é composto por `withFilter` (`narrowNode` recusa comando que já declara `--filter`). Dois parsers de TRX, ou um segundo "dotnet build" digitado, divergem |
157
157
  | `--record-baseline` (`recordTestBaselines` em `src/lib/tasks/test-nodes.js`) | `verify-runner.js` (`baselineTargetProblem` recusa ANTES do build; `recordBaselineFrom` só grava com todos os `runs[]` verdes), `src/commands/validation/verify.js` (`baselineStampBlock`: só a rodada que GRAVOU a baseline persiste o stamp — sem gravação, o `reason` nomeia o nó), `framework/schemas/config.schema.json` (`expectedCount`) e `atomicWriteJson` de `state-manager.js` — o número é MEDIDO por comando, nunca digitado (D8). Com `{task}` é erro de uso ANTES do build (`baselineTargetProblem`, #87), e `recordTestBaselines` recusa de novo qualquer medição com `selection` — a segunda barreira mora no gravador |
158
158
  | `src/lib/tasks/eval-node.js` | os leitores do MESMO nó `runner: "evals"`: `test-nodes.js` (o enum de `TEST_RUNNERS` e a validação fail-closed do bloco `evals`), `verify-runner.js` (`preDecideRun` faz o pré-voo — os três estados de cache reprovam ANTES de `dotnet test`; `judgeEvalRun` lê o sumário depois), `verify.js` (`evalsStatusOf` deriva `nodes.evals` do stamp) e `framework/schemas/config.schema.json`. O pin se resolve em UM lugar, `resolveAiPin` (`src/lib/ai/pin-drift.js`, o mesmo do `doctor --ai`): `.morph/framework/ai-pin.json` do projeto e, sem ele, o do pacote da CLI. O `update` nunca copia o pin, então o fallback é o caso comum — um leitor próprio do eval deixou `MORPH_EVAL_PIN` sem ser injetado em todo projeto instalado, e com ele a régua do carimbo desligada sem aviso. Subir o pin do pacote reprova (`eval-cache-stale-pin`) toda cache carimbada no anterior: é o efeito, não um defeito. **`null` é DESCONHECIDO, nunca 0**: sumário ilegível não pode fabricar queda contra `expectedCount`, e miss de cache é `fail` nomeado, JAMAIS `skip`. A unidade do nó é o CENÁRIO: só `judgeEvalRun` compara com `expectedCount` — `judgeRun` passa baseline `null` para `runner: "evals"` (#110 parte 2: os `[Fact]` do extrator contra a baseline de cenários reprovavam um 24/24), e sem sumário legível a contagem é `null`, nunca a do extrator — nem compara nem vira baseline. O sumário (`morph-eval-{id}-{pid}.json` no temp) é APAGADO antes e depois da rodada: um arquivo herdado sob o mesmo pid era lido como desta. `--linux` com nó de eval é erro de uso: o canal `MORPH_EVAL_*` carrega caminhos absolutos do host e o container roda uma cópia em `/work` |
159
+ | `src/lib/ci/**` (`verify {f} --ci`, a prova do Gate 3 no CI) | O CI é **outra fonte do MESMO stamp**, nunca uma regra de gate: `verify.js` grava o `verify --json` que o run publicou (artefato `morph-verify`, `CI_RESULT_FILE`) pelo mesmo caminho do local, com `source: 'ci'`, `ciRun` e `sha` = `headSha` do run — `gate-check`/`risk-detector`/`approve` não mudam. Só o ARTEFATO vira prova, nunca a `conclusion` do run (workflow de lint verde não compra o Gate 3), e `findCiWorkflow` só aceita workflow que chama `morph-spec verify` E publica `morph-verify.json` — o `renderCiWorkflow` (`workflow-scaffold.js`) tem de continuar passando nele (canário em `test/lib/ci/workflow-scaffold.test.js`). O run vale por ÁRVORE, não por SHA (`treeMovedSince`): commit só de `.morph/` não invalida, e o gatilho do workflow exclui `.morph/**` menos o `config.json` pela mesma razão (senão `cancel-in-progress` cancela o run do código). `cancelled`/`skipped`/`startup_failure` não são prova. Todo "não dá" é `mode: 'local'` com razão (fallback, nunca pausa); código fora de commit é exit 2; run pendente no prazo é exit 3 sem stamp. A URL do origin é lida CRUA (`remote.origin.url`): `git remote get-url` aplica `insteadOf`, que é como os testes apontam o push para um bare local. Canários: `test/commands/verify-ci.test.js` (stub `test/helpers/gh-ci-stub.js`), `test/lib/ci/*` |
159
160
  | `nodeStatuses()` em `src/commands/validation/verify.js` | os TRÊS blocos `nodes` de `framework/schemas/feature.schema.json`, todos `additionalProperties: false` — `verification.nodes`, `verifications.feature.nodes` e `taskVerifications.*.nodes`. A função é escritora ÚNICA dos três: um campo novo aqui sem os três no schema faz o histórico violar o próprio contrato. Canário: "os DOIS escritores do mapa de nós concordam" em `test/commands/verify.test.js` |
160
161
  | `src/lib/graph/**` | `test/lib/graph/*.test.js`, the committed fixture `test/fixtures/graph/namespace-hierarchy.graph.json` (ADR-003 regression — never hand-edit it), `doctor.js` (`codebase graph` check), `architecture-scanner.js` (centrality section), `setup-infra.js` step 18b. `isTestPath` (`sanitize.js`) é o predicado ÚNICO de teste: `god-nodes.js`, `recoverDispatch`, `graph status\|explain` e `symbol-scanner.js` (`isTestFile`, que o `code-budget` também lê) o importam, e uma segunda régua faz o grafo e o validador discordarem sobre o mesmo arquivo. A fixture `test-doubles.graph.json` saiu do graphify real (passo a passo em `docs/specs/graphify-integration/fixture-test-doubles/`) — regerar, nunca editar à mão |
161
162
  | `framework/hooks/shared/graph-freshness.js` (a regra única de "grafo em dia") | os três lados que perguntam: `graph-refresh.js` (decide se roda e se o disparo solta processo), `graph-loader.js` `getFreshness` (`graph status\|explain\|affected` e o `doctor`) e `graph build\|refresh` (gravam a rodada em `<git-common-dir>/morph-spec/graph-refresh-state.json`). O registro da rodada existe porque o `graphify update` 0.9.50 não recarimba `built_at_commit` quando a topologia não muda: sem ele, o grafo parecia velho para sempre e cada disparo rodava o graphify de novo (~80 s no MORPH_GHLBrain). As regras do `.graphifyignore` entram por CONTEÚDO (hash sem comentário, BOM e CRLF), nunca pelo relógio, e `ignoreTextOf` é a ÚNICA leitura de "as regras desta árvore": numa worktree sem arquivo próprio, as da raiz — as mesmas que o `graph refresh` põe ali durante a rodada (`prepareIgnore` em `build.js`). O arquivo temporário leva `TEMP_IGNORE_MARKER` na 1ª linha; nas duas triagens de sujeira (`finish` e `worktree remove`) `withoutLeftoverIgnore` o tira da conta ANTES das recusas e `dropLeftoverIgnore` o apaga só DEPOIS delas — apagar antes é mutação atrás de uma precondição. Mudar a grafia do marcador num lado só devolve o `worktree-dirty` de uma rodada morta no meio. Mora em `hooks/shared` porque hook não importa `src/` |
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  > Spec-driven development framework for multi-stack projects. Turns feature requests into implementation-ready code through structured, AI-orchestrated phases.
4
4
 
5
5
  **Package:** `@polymorphism-tech/morph-spec`
6
- **Version:** 8.45.0
6
+ **Version:** 8.46.0
7
7
  **Requires:** Node.js 22+, Claude Code
8
8
 
9
9
  ---
@@ -379,4 +379,4 @@ Code generated by morph-spec (contracts, templates, implementation output) belon
379
379
 
380
380
  ---
381
381
 
382
- *morph-spec v8.45.0 by [Polymorphism Tech](https://polymorphism.tech)*
382
+ *morph-spec v8.46.0 by [Polymorphism Tech](https://polymorphism.tech)*
package/bin/morph-spec.js CHANGED
@@ -406,6 +406,7 @@ program
406
406
  .option('--skip-e2e', 'Skip the e2e node (Docker bring-up + Playwright)')
407
407
  .option('--fresh-e2e', 'Recreate the e2e stack from scratch: down -v (named volumes included), drop the freshness stamp, rebuild, re-seed auth')
408
408
  .option('--no-persist', 'Do not persist the result to feature.json')
409
+ .option('--ci', 'Feature scope only: push morph/<feature> and take the proof from the project CI (creates .github/workflows/morph-verify.yml when no workflow runs verify). Same stamp, source "ci". No GitHub/gh/workflow = local verify with the reason; uncommitted code = exit 2; CI still running after 50 min = exit 3, no stamp')
409
410
  .action((feature, task, options) =>
410
411
  verifyCommand(feature, task, { ...options, noPersist: options.persist === false })
411
412
  );
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "morph-spec",
3
- "version": "8.45.0",
3
+ "version": "8.46.0",
4
4
  "displayName": "MORPH-SPEC Framework",
5
5
  "description": "Spec-driven development with 38 agents and 8-phase workflow for .NET/Blazor/Next.js/Azure",
6
6
  "publisher": "polymorphism-tech",
@@ -185,4 +185,4 @@ Never edited directly — go through CLI commands or `morph-spec update` instead
185
185
 
186
186
  ---
187
187
 
188
- *morph-spec v8.45.0 by Polymorphism Tech*
188
+ *morph-spec v8.46.0 by Polymorphism Tech*
@@ -249,4 +249,4 @@ morph-spec doctor
249
249
 
250
250
  ---
251
251
 
252
- *morph-spec v8.45.0 by Polymorphism Tech*
252
+ *morph-spec v8.46.0 by Polymorphism Tech*
@@ -68,7 +68,7 @@ Se não houver feature ativa, pergunte ao usuário qual feature trabalhar.
68
68
  | `dag <f>` | DAG de `tasks.json`: sequencial/paralelo, recomenda isolamento |
69
69
  | `graph status\|build\|refresh` | Grafo de codebase. `status` reporta frescor e se há refresh em background |
70
70
  | `graph affected\|explain <sym>` | Blast radius e vizinhança saneados. Sem grafo, caia para grep — nunca é erro |
71
- | `verify <f> [task] [--json]` | Build+testes+validadores+e2e — a prova determinística da task e do Gate 3 |
71
+ | `verify <f> [task] [--ci]` | Build+testes+validadores+e2e — prova da task; a do Gate 3 com `--ci` (no CI) |
72
72
  | `e2e up\|status\|down <f>` | Stack de verificação (docker compose) |
73
73
  | `doctor --worktrees [--prune]` | Reconcilia git × disco × features; só `--prune` remove algo |
74
74
  | `report-check <f> [task...] [--json]` | Audita relatório de sub-agent vs. trabalho entregue |
@@ -26,7 +26,7 @@ Entre gates, você opera com autonomia total. O dev valida. Você executa.
26
26
 
27
27
  **Regra de ouro:** dúvida que não bloqueia → resolver e registrar; dúvida que bloqueia → uma pergunta, máximo. Vale **entre gates** (plan/implement/review) — **não** vale na abertura da feature: a fase proposal pode rodar em **modo entrevista** (escolhido na pergunta de abertura, `morph-brainstorming` Parte 2), onde perguntar até fechar o entendimento é o comportamento desejado.
28
28
 
29
- **A prova é o código:** `morph-spec verify` (build+testes+validadores) prova o que é determinístico; o `doneCriteria` prova o comportamento. Não há nota por task — estilo e estrutura são nota do avaliador do Gate 3, nunca bloqueio.
29
+ **A prova é o código:** `morph-spec verify` (build+testes+validadores) prova o que é determinístico; o `doneCriteria` prova o comportamento. A suíte do Gate 3 roda no CI do projeto (`verify {f} --ci`: mesmo stamp, a máquina fica livre; sem CI, local). Não há nota por task — estilo e estrutura são nota do avaliador do Gate 3, nunca bloqueio.
30
30
 
31
31
  ---
32
32
 
@@ -44,7 +44,8 @@
44
44
  ## Validação
45
45
 
46
46
  - **`morph-spec dag <feature>`** — análise do DAG de tasks.json com decisão sequencial vs paralelo **ponderada por effort** (raízes S vão inline, nunca ganham agente próprio). Também cruza `tasks.json` com os use cases: `usecase` apontando para um `UC-{nn}` inexistente é **erro** (exit 1, não despacha); pós-condição órfã e `doneCriteria` que parafraseou em vez de copiar são avisos. UC com nome fora do padrão `UC-{nn}-{slug}.md` (slug com maiúscula ou `_`) continua encontrado, com aviso: o `finish` não o promove ao acervo. `doneCriteria` com vocabulário de artefato (`grep`, "contém a string", "nenhum arquivo", glob `**/`…) sem `Por que artefato:` também é aviso, sob `⚠ Critérios de artefato` — o exit não muda, e o `--json` traz a chave irmã `artifactCriteria`. Os achados de ciclo de vida do `usecase check` (task sobre UC `superseded`, revogação que o `finish` não aplicaria) saem sob `⚠ Ciclo de vida do acervo`, também só aviso, na chave `lifecycle`. **Tamanho de task:** task com mais de 8 `outputs` (`MAX_TASK_OUTPUTS`) é **erro** (exit 1) enquanto o Gate 2 não foi aprovado — fatie por pós-condição — e só aviso depois dele (chore/hotfix nascem com o plano aprovado por política); o `--json` traz `taskSize: {max, oversized, blocking}`. `--mermaid` nunca é bloqueado.
47
- - **`morph-spec verify <feature> [task] [--json] [--timeout <ms>] [--filter <ids>] [--full] [--record-baseline] [--refresh-cache] [--linux] [--linux-image <img>] [--skip-tests] [--skip-build] [--skip-e2e] [--fresh-e2e] [--no-persist]`** — cadeia determinística build+testes+validadores+**e2e** (código antes do juiz LLM); escopo de task quando informado, senão a feature inteira. O nó `e2e` é o único que **executa** a aplicação: sobe o `docker compose` do projeto, espera todo serviço ficar saudável e roda as specs Playwright quando existem (sem specs, ainda vale como smoke — "a app sobe" já pega o serviço não registrado que derruba o startup e passa batido no build). Aplicabilidade: escopo de feature (Gate 3) **sempre** roda; escopo de task roda para `group` `frontend`/`backend`/`infra` e dispensa `tests`/`docs`. **Dispensa legítima × setup faltando:** projeto sem compose e sem a integração `docker` é `skip` com razão; projeto que **declara** `docker` e não tem compose é `fail` — é verificação que nunca foi montada, não projeto que não containeriza. **Descoberta de testes e relógio:** a varredura por `*Tests.csproj` **não enxerga worktrees** — nem os convencionais (`worktrees/`, `.worktrees/`) nem os registrados em `git worktree list` fora da convenção. Um worktree é o checkout de OUTRA feature do mesmo repo: incluí-lo dobrava o relógio e reportava sobre código que não está sob verificação (em campo isso deu `tests: fail` no timeout exato, com o sumário da 1ª execução dizendo "Com falha: 0"). O timeout do nó `tests` deixou de ser fixo: `--timeout` (que agora cobre build **e** tests) > `project.testTimeoutMs` no `config.json` > **120 s por projeto de teste encadeado** — um encadeamento nunca nasce condenado pelo tamanho. Quando o relógio estoura, o nó diz isso com nome (`timeout após N ms na execução X de Y — nenhum teste falhou`) em vez de devolver log truncado. **`--fresh-e2e` é destrutivo por definição:** derruba a stack anterior **com os volumes nomeados** (`down -v`), apaga o carimbo de frescor, sobe com `--build` e força o seed de auth a rodar de novo. Sem o `down -v`, "fresco" reconstruiria a imagem e preservaria o banco velho — e um `storageState` capturado contra aquele banco continuaria autenticando um usuário que não existe mais (403 que parece app quebrada). **No escopo de task o recorte é automático:** `verify {f} {task}` deixa o `group` da task escolher os nós e os arquivos de teste de `outputs` estreitarem o comando dentro de cada nó — o relógio de uma task não paga a suíte inteira (regras, veredito e limites em *O recorte por task*, na seção **Suítes de teste**). **`--filter <ids>` força a escolha de nós** (lista separada por vírgula, casando `id` exato ou substring; desliga a escolha pelo `group`, e o estreitamento dentro dos nós continua) e **`--full` desliga o recorte** (o `group` não escolhe nós e nenhum comando é estreitado; os nós com `e2e: true` e os de `runner: "evals"` continuam só no escopo de feature; no escopo de feature `--full` é no-op, porque ali nada é recortado). O Gate 3 continua sendo `verify {f} --json` **sem filtro**, porque a prova que o avaliador lê tem de cobrir todos os nós (o CI idem) — e um `verify {f} --filter …` de escopo de **feature** não persiste o stamp (`persisted: false`, `reason: 'filtered'`), nem um de escopo de feature com `--skip-tests` (`reason: 'skip-tests'`) ou `--skip-build` (`reason: 'skip-build'`): um nó pulado não reprova o rollup, e rodada parcial nunca fica no lugar da prova do Gate 3. No escopo de task as três gravam normalmente — o stamp já diz `task:{id}`, e o Gate 3 pausa nele. O nó não casado **aparece** em `runs[]` como `skip` nomeando o filtro, em vez de sumir — suíte que some do relatório é verde sobre código não testado. E `--filter` que não casa nenhum nó é **erro de uso (exit 2)**, checado antes do build: filtrar tudo e reportar verde seria a forma mais barata de recriar o defeito que a lista de nós existe para matar. **`--record-baseline` é a ÚNICA via de escrita da baseline:** mede cada nó numa execução real e grava `project.tests[].expectedCount` de volta no `config.json` (escrita atômica `tmp.{pid}`+rename, preservando todo o resto do arquivo). A regra do `morph-plan` — *baseline é um COMANDO, nunca um número* — sobrevive porque ninguém digita o número: um valor escrito à mão descreve o que alguém acreditou, e a divergência entre a crença e a suíte é o defeito que a regra existe para matar. Recusa alto e claro em vez de improvisar: execução **vermelha** não grava (congelaria a regressão), execução **parcial** não grava (com `--filter` alguns nós ficam sem medida, e baseline de meia frota é afirmação sobre a outra metade que ninguém fez), contagem **desconhecida** não grava (`null` nunca vira `0`), e **sem `config.json` é erro de uso (exit 2)** — o arquivo nunca é criado implicitamente. **Com `{task}` também é erro de uso (exit 2)**, recusado antes do build: no escopo de task o verify recorta nós e testes, e baseline de recorte não é baseline — o gravador ainda recusa, uma segunda vez, qualquer medição que carregue `selection`. A execução que gravou a baseline persiste o stamp; a que não gravou nada, não. **`--linux` roda as suítes dentro de um container Linux** (`mcr.microsoft.com/dotnet/sdk:10.0` por default; sobrescrevível por `--linux-image` ou por `project.tests[].linuxImage`), e a árvore sob teste é **copiada** para o filesystem do container, não montada: o bind mount do Docker Desktop no Windows atravessa 9p/gRPC-FUSE, que é **case-insensitive** — rodar em cima do mount devolveria o mesmo verde do Windows e não provaria nada sobre defeitos de caixa (`Link=`, `Uri.TryCreate`), que são exatamente os que só aparecem no Linux. A raiz é montada **read-only** em `/src` só como origem do `tar` (com exclusão de `node_modules`, `bin`, `obj`, `.git` e os diretórios-contêiner de worktree), a cópia vive em `/work`, e cada nó roda de `/work/{cwd}`. Sem Docker, **erro de uso (exit 2)**, jamais um `skip`: "não deu para checar no Linux" lido como verde é o próprio defeito. `--linux` com `--skip-tests` se anulam (erro de uso), e um nó `e2e: true` é pulado no container com motivo (docker-em-docker fora de escopo). O resultado marca `nodes.tests.environment = { kind: 'linux-container', image }` — sem isso, um verde de container e um verde de host viram indistinguíveis no `feature.json` e a próxima pessoa não sabe o que foi provado. **`--no-persist` não grava nada no `feature.json`** — nem o histórico `verifications.*` nem o stamp `verification` que o gate lê: a execução vira sonda, nunca prova para gate algum, qualquer que seja a regra de carimbo do escopo. E o `--filter` só estreita o nó de testes (build, validadores e e2e rodam igual). Por isso a execução de evidência de eval do `morph-implement` §5 é `verify {f} --filter {id-do-nó-evals} --skip-e2e --no-persist`: só o eval, sem stack, sem escrever no `feature.json`. **Onde grava:** só na árvore DONA da feature (`resolveFeatureTree`, a regra do guard dos mutadores, perguntada da raiz) — o worktree com `morph/{f}` checada, mesmo sem ponteiro `worktree.path` na raiz, ou a raiz quando nenhum worktree vivo a possui. Fora dela, `persisted: false` com `reason: 'unregistered-worktree'` (outro worktree, como o `morph-task/{n}` de um sub-agent) ou `'owner-tree-elsewhere'` (a raiz, com o dono vivo em outro lugar). **Rodada repetida:** no escopo de feature, se nada mudou fora de `.morph/` desde o verify de feature anterior (HEAD, diff, arquivos não rastreados e o `config.json`), o `--json` traz `repeatOf: { ts, status, sha }` e a saída humana avisa — é aviso, a rodada executa e carimba igual, porque só o código sabe que a árvore não mudou, não que o ambiente (Docker, banco) também não. Forma de `project.tests[]`, prefixo `[tests/{id}]` do `feedback[]`, regra de contagem/baseline e a divisão loop × suíte: seção **Suítes de teste** abaixo. **Validador que não pôde checar:** o `reuse` sem grafo não acha nada, e um `validators: pass` dos outros validadores o esconderia — então o `feedback[]` diz `[validators/reuse] não rodou: …`, e o nó leva `skipped: [{ validator, reason, unavailable }]` (quando o nó inteiro pula, a própria linha dele já dá o motivo). **Nós pesados (build, testes, e2e) esperam por slot** (`morph-spec heavy`): estouro da espera sai **exit 3** com `status: "busy"`, `persisted: false`, sem stamp e sem evento — não é vermelho, e o orquestrador repete a chamada.
47
+ - **`morph-spec verify <feature> [task] [--json] [--timeout <ms>] [--filter <ids>] [--full] [--record-baseline] [--refresh-cache] [--linux] [--linux-image <img>] [--skip-tests] [--skip-build] [--skip-e2e] [--fresh-e2e] [--no-persist] [--ci]`** — cadeia determinística build+testes+validadores+**e2e** (código antes do juiz LLM); escopo de task quando informado, senão a feature inteira. O nó `e2e` é o único que **executa** a aplicação: sobe o `docker compose` do projeto, espera todo serviço ficar saudável e roda as specs Playwright quando existem (sem specs, ainda vale como smoke — "a app sobe" já pega o serviço não registrado que derruba o startup e passa batido no build). Aplicabilidade: escopo de feature (Gate 3) **sempre** roda; escopo de task roda para `group` `frontend`/`backend`/`infra` e dispensa `tests`/`docs`. **Dispensa legítima × setup faltando:** projeto sem compose e sem a integração `docker` é `skip` com razão; projeto que **declara** `docker` e não tem compose é `fail` — é verificação que nunca foi montada, não projeto que não containeriza. **Descoberta de testes e relógio:** a varredura por `*Tests.csproj` **não enxerga worktrees** — nem os convencionais (`worktrees/`, `.worktrees/`) nem os registrados em `git worktree list` fora da convenção. Um worktree é o checkout de OUTRA feature do mesmo repo: incluí-lo dobrava o relógio e reportava sobre código que não está sob verificação (em campo isso deu `tests: fail` no timeout exato, com o sumário da 1ª execução dizendo "Com falha: 0"). O timeout do nó `tests` deixou de ser fixo: `--timeout` (que agora cobre build **e** tests) > `project.testTimeoutMs` no `config.json` > **120 s por projeto de teste encadeado** — um encadeamento nunca nasce condenado pelo tamanho. Quando o relógio estoura, o nó diz isso com nome (`timeout após N ms na execução X de Y — nenhum teste falhou`) em vez de devolver log truncado. **`--fresh-e2e` é destrutivo por definição:** derruba a stack anterior **com os volumes nomeados** (`down -v`), apaga o carimbo de frescor, sobe com `--build` e força o seed de auth a rodar de novo. Sem o `down -v`, "fresco" reconstruiria a imagem e preservaria o banco velho — e um `storageState` capturado contra aquele banco continuaria autenticando um usuário que não existe mais (403 que parece app quebrada). **No escopo de task o recorte é automático:** `verify {f} {task}` deixa o `group` da task escolher os nós e os arquivos de teste de `outputs` estreitarem o comando dentro de cada nó — o relógio de uma task não paga a suíte inteira (regras, veredito e limites em *O recorte por task*, na seção **Suítes de teste**). **`--filter <ids>` força a escolha de nós** (lista separada por vírgula, casando `id` exato ou substring; desliga a escolha pelo `group`, e o estreitamento dentro dos nós continua) e **`--full` desliga o recorte** (o `group` não escolhe nós e nenhum comando é estreitado; os nós com `e2e: true` e os de `runner: "evals"` continuam só no escopo de feature; no escopo de feature `--full` é no-op, porque ali nada é recortado). O Gate 3 continua sendo `verify {f} --json` **sem filtro**, porque a prova que o avaliador lê tem de cobrir todos os nós (o CI idem) — e um `verify {f} --filter …` de escopo de **feature** não persiste o stamp (`persisted: false`, `reason: 'filtered'`), nem um de escopo de feature com `--skip-tests` (`reason: 'skip-tests'`) ou `--skip-build` (`reason: 'skip-build'`): um nó pulado não reprova o rollup, e rodada parcial nunca fica no lugar da prova do Gate 3. No escopo de task as três gravam normalmente — o stamp já diz `task:{id}`, e o Gate 3 pausa nele. O nó não casado **aparece** em `runs[]` como `skip` nomeando o filtro, em vez de sumir — suíte que some do relatório é verde sobre código não testado. E `--filter` que não casa nenhum nó é **erro de uso (exit 2)**, checado antes do build: filtrar tudo e reportar verde seria a forma mais barata de recriar o defeito que a lista de nós existe para matar. **`--record-baseline` é a ÚNICA via de escrita da baseline:** mede cada nó numa execução real e grava `project.tests[].expectedCount` de volta no `config.json` (escrita atômica `tmp.{pid}`+rename, preservando todo o resto do arquivo). A regra do `morph-plan` — *baseline é um COMANDO, nunca um número* — sobrevive porque ninguém digita o número: um valor escrito à mão descreve o que alguém acreditou, e a divergência entre a crença e a suíte é o defeito que a regra existe para matar. Recusa alto e claro em vez de improvisar: execução **vermelha** não grava (congelaria a regressão), execução **parcial** não grava (com `--filter` alguns nós ficam sem medida, e baseline de meia frota é afirmação sobre a outra metade que ninguém fez), contagem **desconhecida** não grava (`null` nunca vira `0`), e **sem `config.json` é erro de uso (exit 2)** — o arquivo nunca é criado implicitamente. **Com `{task}` também é erro de uso (exit 2)**, recusado antes do build: no escopo de task o verify recorta nós e testes, e baseline de recorte não é baseline — o gravador ainda recusa, uma segunda vez, qualquer medição que carregue `selection`. A execução que gravou a baseline persiste o stamp; a que não gravou nada, não. **`--linux` roda as suítes dentro de um container Linux** (`mcr.microsoft.com/dotnet/sdk:10.0` por default; sobrescrevível por `--linux-image` ou por `project.tests[].linuxImage`), e a árvore sob teste é **copiada** para o filesystem do container, não montada: o bind mount do Docker Desktop no Windows atravessa 9p/gRPC-FUSE, que é **case-insensitive** — rodar em cima do mount devolveria o mesmo verde do Windows e não provaria nada sobre defeitos de caixa (`Link=`, `Uri.TryCreate`), que são exatamente os que só aparecem no Linux. A raiz é montada **read-only** em `/src` só como origem do `tar` (com exclusão de `node_modules`, `bin`, `obj`, `.git` e os diretórios-contêiner de worktree), a cópia vive em `/work`, e cada nó roda de `/work/{cwd}`. Sem Docker, **erro de uso (exit 2)**, jamais um `skip`: "não deu para checar no Linux" lido como verde é o próprio defeito. `--linux` com `--skip-tests` se anulam (erro de uso), e um nó `e2e: true` é pulado no container com motivo (docker-em-docker fora de escopo). O resultado marca `nodes.tests.environment = { kind: 'linux-container', image }` — sem isso, um verde de container e um verde de host viram indistinguíveis no `feature.json` e a próxima pessoa não sabe o que foi provado. **`--no-persist` não grava nada no `feature.json`** — nem o histórico `verifications.*` nem o stamp `verification` que o gate lê: a execução vira sonda, nunca prova para gate algum, qualquer que seja a regra de carimbo do escopo. E o `--filter` só estreita o nó de testes (build, validadores e e2e rodam igual). Por isso a execução de evidência de eval do `morph-implement` §5 é `verify {f} --filter {id-do-nó-evals} --skip-e2e --no-persist`: só o eval, sem stack, sem escrever no `feature.json`. **Onde grava:** só na árvore DONA da feature (`resolveFeatureTree`, a regra do guard dos mutadores, perguntada da raiz) — o worktree com `morph/{f}` checada, mesmo sem ponteiro `worktree.path` na raiz, ou a raiz quando nenhum worktree vivo a possui. Fora dela, `persisted: false` com `reason: 'unregistered-worktree'` (outro worktree, como o `morph-task/{n}` de um sub-agent) ou `'owner-tree-elsewhere'` (a raiz, com o dono vivo em outro lugar). **Rodada repetida:** no escopo de feature, se nada mudou fora de `.morph/` desde o verify de feature anterior (HEAD, diff, arquivos não rastreados e o `config.json`), o `--json` traz `repeatOf: { ts, status, sha }` e a saída humana avisa — é aviso, a rodada executa e carimba igual, porque só o código sabe que a árvore não mudou, não que o ambiente (Docker, banco) também não. Forma de `project.tests[]`, prefixo `[tests/{id}]` do `feedback[]`, regra de contagem/baseline e a divisão loop × suíte: seção **Suítes de teste** abaixo. **Validador que não pôde checar:** o `reuse` sem grafo não acha nada, e um `validators: pass` dos outros validadores o esconderia — então o `feedback[]` diz `[validators/reuse] não rodou: …`, e o nó leva `skipped: [{ validator, reason, unavailable }]` (quando o nó inteiro pula, a própria linha dele já dá o motivo). **Nós pesados (build, testes, e2e) esperam por slot** (`morph-spec heavy`): estouro da espera sai **exit 3** com `status: "busy"`, `persisted: false`, sem stamp e sem evento — não é vermelho, e o orquestrador repete a chamada.
48
+ - **`morph-spec verify <feature> --ci [--json]`** — a prova de escopo de feature tirada do **CI do projeto** (GitHub Actions), não da máquina: faz `git push -u origin morph/<feature>`, espera o run do commit e transforma o `verify --json` que o run publicou (artefato `morph-verify`) no **mesmo stamp** de uma rodada local, com `source: "ci"` e `ciRun` (URL do run); `sha` é o `headSha` do run, então o `verificationStale` compara o código que o CI viu com o HEAD. Nenhuma regra de gate muda: e2e pulado, escopo, evals e stale valem igual. Só o **artefato** vira prova, nunca o "verde" do run: workflow de lint, deploy ou de terceiro não compra o Gate 3. **Sem workflow** que rode `morph-spec verify` publicando `morph-verify.json`, cria e commita `.github/workflows/morph-verify.yml` na branch da feature (gatilho `push` em `morph/**`, `.morph/` fora do gatilho exceto o `config.json`, `cancel-in-progress`, setup de .NET por `global.json`/stack, install por lockfile, browsers do Playwright onde há config, e o `morph-spec` na versão de `.morph/.morphversion`). Um run sobre um commit anterior vale quando só `.morph/` andou desde ele; run `cancelled`/`skipped`/`startup_failure` não é prova. **Sem CI cai para o verify local, com a razão** (`project.ci: false`, origin fora do GitHub, `gh` ausente ou sem login, branch que não é `morph/<feature>`, token HTTPS sem o escopo `workflow` para criar o arquivo, push recusado, workflow que não disparou nem com `gh workflow run`, run sem o artefato). Código não commitado é **erro de uso (exit 2)**: o CI só vê commits. CI rodando depois de 50 min é **exit 3** (`status: "pending"`), sem stamp — não é vermelho. Com task, `--filter`, `--skip-*`, `--fresh-e2e`, `--linux`, `--record-baseline`, `--refresh-cache` ou `--no-persist`, erro de uso. Vermelho imprime `gh run view <id> --log-failed`. **O runner só tem o que está commitado:** o que testes e e2e leem de arquivo gitignored (`.env`, `appsettings.*.json` local, connection string do Neon, `storageState`) não existe lá, e o run sai vermelho sem defeito de código. Declare o valor como secret do repositório e exponha-o no `env:` do job em `morph-verify.yml` (o arquivo é do projeto: editar à mão é o esperado, e o `findCiWorkflow` continua aceitando enquanto ele chamar `morph-spec verify` publicando `morph-verify.json`), ou desligue com `project.ci: false`.
48
49
  - **Validador `code-budget` (nó `validators` do `verify`)** — mede o que a árvore **adicionou** contra a branch default: `git diff -U0` a partir do merge-base com `origin/{default}` (ou `{default}` local), arquivo novo não rastreado conta inteiro e arquivo movido sem edição não conta. Lê `.cs`/`.ts`/`.tsx`/`.js`/`.jsx`/`.mjs`/`.cjs`, fora de `.d.ts`, de `node_modules`/`bin`/`obj`/`dist`/`.morph` e de código gerado (`*.Designer.cs`, `*.g.cs`, `*ModelSnapshot.cs`). Três medidas, cada uma **um aviso, nunca erro**, mapeado em `code_quality` da rubrica: (a) **linhas de comentário adicionadas que citam processo** — `UC-nn`, `ADR-nn`, `FR-nn`, `T{n}`, `Gate N`, `spec.md`, mockup — avisa a partir de uma, com a contagem sobre o total de linhas de comentário adicionadas e até 4 `arquivo:linha` (standard `architecture-code-comments`); comentário é o que o varredor de `src/lib/ai/comment-stripper.js` separa de string, com template e regex literal de TS/JS; (b) **linhas não vazias de teste ÷ de produção** — avisa acima de **1,5×**, e só quando o teste adicionado passa de **40 linhas** (a regressão de um bug de duas linhas cabe abaixo disso); sem linha de produção, o aviso dá as duas contagens sem razão. (c) **densidade de comentário** — linhas de comentário adicionadas ÷ linhas de código adicionadas (as não vazias menos as de comentário), doc-comment (`///`, JSDoc) incluído — avisa acima de **1 a cada 10**, e só a partir de **40 linhas** de código, com até 4 arquivos mais comentados e suas contagens (o padrão do standard `architecture-code-comments` é nenhum comentário). Teste é o que o validador `reuse` já trata como teste: arquivo `.test.` ou `.spec.` com extensão `js`/`jsx`/`ts`/`tsx`, e tudo sob `__tests__/`, `test/`, `tests/` ou pasta terminada em `Test`/`Tests` (`Foo.Tests/`, `FooTests/`). O resto conta como produção, inclusive `a.test.mjs`, `a.test.cjs` e `*.e2e.ts` fora dessas pastas. Sem base git, ou sem linha de código adicionada, pula com razão; qualquer falha de medição também vira skip, nunca erro. Fica fora do modo `light`. Excesso justificado não silencia o aviso: a justificativa mora na task (orçamento do `morph-plan` passo 5), e é lá que o avaliador a lê.
49
50
  - **`morph-spec mutate [feature] [--plan <arquivo>] [--out <arquivo>] [--no-subset] [--allow-dirty] [--jobs <n>] [--timeout <ms>] [--restore] [--json]`** — mutação **dirigida** para o avaliador do Gate 3: no máximo **3** mutantes, só quando o diff tem uma guarda que um UC exige e o avaliador não achou o teste que a morde. **Manifesto** (`--plan`, default `4-review/mutants.json`): `{"mutants":[{"id":"M1","file":"src/x.cs","line":42,"find":"a && b","replace":"a","expect":"OrdersTests.RecusaSemItens deve falhar"}]}` — `expect` é obrigatório (qual teste deve falhar e por quê, dito antes de rodar), `find` começa na `line` e é único nela; barra invertida do código vai como `{{BS}}` (o transporte do Bash a reduz). Mais de 3 mutantes ou `expect` ausente: exit 2. **Método:** recusa arquivo não commitado (`--allow-dirty` assume o risco); snapshot dos bytes fora da árvore (`<git-common-dir>/morph-spec/mutation/<run>/`) com journal — rodada que morreu se desfaz por `mutate --restore`; em .NET, uma chave `MORPH_MUTANT` por mutante de expressão e um build só (o que a chave não compila cai para rebuild real, e falha de build é `build-error`, nunca morte); depois **o subconjunto decide**: os testes que a feature alterou mais os que citam os tipos mutados (vitest/jest usam `related`) — a ferramenta escolhe, não o avaliador. `killed` = falha **nova** contra a rodada-base do mesmo escopo, com o motivo literal do TRX; `survived` no subconjunto é o veredito, com o `expect` ao lado; sem subconjunto utilizável, a suíte inteira roda e a morte tem de se repetir. **Reversão:** bytes do snapshot com md5 conferido, por escrita nova (nunca `os.utime`), rebuild forçado e DLL igual à de antes do 1º mutante. **Exit:** `0` rodou (sobrevivente é achado), `1` a árvore já não compilava, `2` uso, `3` semáforo ocupado, `4` **inseguro** (arquivo não restaurado ou binário que não bate). Segura **um** slot `heavy`.
50
51
  - **`morph-spec e2e up|status|down <feature>`** — ciclo de vida do stack de verificação, compartilhando toda a lógica com o nó `e2e` do verify. `up [--fresh]` reusa um stack quente **só** quando ele provadamente serve o código atual: o carimbo compara `HEAD` + `git status --porcelain` + `git diff HEAD`, então qualquer edição, commitada ou não, invalida o reuso (a exceção é o serviço com bind mount do código, que serve o disco por construção). Na dúvida, rebuild — o layer cache do Docker faz disso barato. O motivo impresso diz **o que** decidiu o reuso, e as duas procedências nunca se confundem: `source unchanged since build` é o carimbo, `source is bind-mounted` é o disco. E a segunda só sai quando **todo** serviço com seção `build:` monta a raiz do projeto — um sidecar Playwright montando `./e2e` ao lado de uma app compilada em imagem não fala pela stack inteira; nesse caso quem decide é o carimbo. `--fresh` derruba com volumes (`down -v`), apaga o carimbo e re-roda o seed. `status [--json]` responde a pergunta que decide se um verde vale alguma coisa (no ar? saudável? construído do código atual?) e sai 0 só com os três; o `baseUrl` do payload é o endereço real da app — é dele que a `morph-review` tira a URL que vai no prompt do avaliador, em vez de deixá-lo adivinhar `localhost:3000`. `down [--volumes]` é best-effort e nunca bloqueia. Isolamento entre worktrees: `COMPOSE_PROJECT_NAME=morph-{feature}` separa os containers e o bloco de portas do fleet (`env-allocator`) separa as portas — um compose com porta fixa não pode ser isolado, e isso é avisado. O bloco vale nos **dois** caminhos (este comando e o nó do `verify`): `PORT`, `MORPH_PORT_BASE`, `ASPNETCORE_URLS` em dotnet, mais `PLAYWRIGHT_BASE_URL` apontando para a porta alocada. Por isso o `playwright.config.ts` precisa ler a base URL do ambiente em vez de cravá-la — com a URL cravada, a suíte bate numa porta vazia e o `ECONNREFUSED` se disfarça de app quebrada.
@@ -275,8 +276,8 @@ passa com aviso de alta, e o comando grava.
275
276
  | --- | --- | --- |
276
277
  | Sub-agent implementador | só o filtro dos próprios testes, **uma vez**, antes do relatório (nunca `morph-spec verify`, nunca red→green) | A prova é do orquestrador; cada rodada extra dentro do sub-agent custa turnos que o teto corta |
277
278
  | Loop por task | `verify {f} {task}` (recorte automático: o `group` escolhe os nós, os `outputs` estreitam os testes — ver *O recorte por task*; `--filter {stack-da-task}` só para forçar os nós, `--full` para desligar) | O relógio de uma task não paga a suíte inteira; a task tocou **um** stack |
278
- | Gate 3 | `verify {f} --json` (sem filtro) | A prova que o avaliador lê tem de cobrir **todos** os nós |
279
- | CI | a suíte inteira, todos os nós | idem |
279
+ | Gate 3 | `verify {f} --ci --json` (sem filtro; sem CI disponível, roda local) | A prova tem de cobrir **todos** os nós; no CI ela não ocupa a máquina e roda em paralelo com o avaliador |
280
+ | CI | a suíte inteira, todos os nós (`morph-verify.yml` roda o mesmo `verify {f}`) | idem — e o artefato dele é o stamp do Gate 3 |
280
281
  | Evidência de eval (task com `evidencias/` em `outputs`) | `verify {f} --filter {id-do-nó-evals} --skip-e2e --no-persist` | Só o nó `evals` (pulado no escopo de task), sem stack e sem carimbo — não é a verificação da task, é a prova que o `morph-implement` §5 grava |
281
282
 
282
283
  > **Não existe constante de 600 s neste CLI** (`grep -rn "600000" src/ framework/` → zero). O teto que
@@ -539,6 +540,6 @@ O servidor MCP (`graphify-mcp`, 10 tools, todas aceitando `project_path`) está
539
540
  | `PostToolUse` / `PostToolUseFailure` | `tool`, `toolUseId`, `durationMs`; Bash/PowerShell: `command` (redigido, ≤2000 chars) + `commandChars`, `exitCode`, `outcome` (`ok`/`error`/`timeout`/`interrupted`/`background`), `exitMasked` quando o exit gravado não é o do trabalho (pipe sem `pipefail`, `|| …`, `&` em background, ou `;` seguido de `echo`/`true`/`:` no fim); Read: `target`, `kind`, `bytes` (tamanho do arquivo), `offset`, `limit`, `bytesRead` — um Read que falhou não grava bytes, porque não trouxe nada; Agent: `persona`, `desc`, `prompt` + `promptChars`; SendMessage: `target`, `messageChars` |
540
541
  | `ToolBatch` | `size`, `tools` (contagem por ferramenta) — um por turno do modelo |
541
542
  | `SessionUsage` / `AgentUsage` | totais acumulados do transcript + `delta` desde a amostra anterior |
542
- | `Verify` | `scope`, `task`, `status`, `sha`, `fingerprint` (a árvore fora de `.morph/`), `tree` (`root`/`worktree`), `persisted`, `reason`, `nodes.{build,tests,validators,e2e}.{status,durationMs}`, `nodes.validators.{ran,skipped}` |
543
+ | `Verify` | `scope`, `task`, `status`, `sha`, `fingerprint` (a árvore fora de `.morph/`), `tree` (`root`/`worktree`), `persisted`, `reason`, `source` (`local`/`ci`: a rodada do `--ci` registra os nós e durações do run), `nodes.{build,tests,validators,e2e}.{status,durationMs}`, `nodes.validators.{ran,skipped}` |
543
544
 
544
545
  Nada da saída de um comando é gravado — nem o corpo de erro do `PostToolUseFailure`, onde um valor de `.env` pode aparecer sem nome ao lado. `command`, `prompt`, `desc` e `target` passam por redação de segredos óbvios antes de gravar: valor ao lado de um nome de segredo (`API_KEY=…`, `*_KEY`/`*_PASS=…`, `--password …`, `user-secrets set <k> <v>`, `AccountKey=…`), `Authorization: …`, `user:pass@` (senha com `/` inclusive) e `?sig=`/`?access_token=` em URL, e formatos de token (GitHub, `sk-`, `sk_live_`, `npm_`, Slack, AWS, Google, JWT). É por padrão, não por garantia: um valor sem nome ao lado passa.
@@ -9,6 +9,10 @@
9
9
  "type": "object",
10
10
  "additionalProperties": true,
11
11
  "properties": {
12
+ "ci": {
13
+ "type": "boolean",
14
+ "description": "false turns `verify {f} --ci` into the local verify (with the reason printed). Absent or true: the Gate 3 proof comes from the GitHub Actions workflow that runs `morph-spec verify` (created on first use), whenever origin is GitHub and gh is authenticated."
15
+ },
12
16
  "tests": {
13
17
  "type": "array",
14
18
  "description": "One entry per test suite. Absent (not empty) means the four-step fallback of resolveTestPlan() decides — every project created before this key existed keeps working. An empty array is an explicit declaration of 'no test suites'.",
@@ -152,7 +152,9 @@
152
152
  "e2eReason": { "type": ["string", "null"], "description": "The LITERAL reason the e2e node skipped (e2e-check.js's own wording), or null when it did not skip. risk-detector.js's classifyE2eSkip() reads it: 'project does not containerize' / 'does not touch runtime' are legitimate dispensations and pass; 'docker is not available' / 'requested' (--skip-e2e) / anything unclassifiable — null included — are an absence of proof and pause Gate 3. A skip recorded WITHOUT a reason is refused on purpose: a nameless dispensation is a silent no-op." },
153
153
  "sha": { "type": ["string", "null"], "description": "HEAD commit SHA at verify time (git rev-parse HEAD); null when not in a git repo or no commits yet." },
154
154
  "ts": { "type": "string", "format": "date-time", "description": "Server-side timestamp, same instant as the corresponding verifications/taskVerifications entry's updatedAt." },
155
- "scope": { "type": "string", "description": "'feature', or 'task:<id>' for a task-scoped verify. READ BY THE GATE, not just audit: this stamp is singular and any verify overwrites it, so a 'task:<id>' value at ship time means the last verify was NOT the feature's. risk-detector.js therefore accepts an e2e dispensation ('project'/'scope' in e2eReason) only from scope === 'feature' — a `morph-spec verify {f} {task}` of a task whose group does not touch runtime stamps exactly the legitimate-sounding reason that would otherwise buy Gate 3 for the whole feature (the last task of a feature is typically group: docs). Absent/unknown falls on the safe side, like an unclassifiable reason." }
155
+ "scope": { "type": "string", "description": "'feature', or 'task:<id>' for a task-scoped verify. READ BY THE GATE, not just audit: this stamp is singular and any verify overwrites it, so a 'task:<id>' value at ship time means the last verify was NOT the feature's. risk-detector.js therefore accepts an e2e dispensation ('project'/'scope' in e2eReason) only from scope === 'feature' — a `morph-spec verify {f} {task}` of a task whose group does not touch runtime stamps exactly the legitimate-sounding reason that would otherwise buy Gate 3 for the whole feature (the last task of a feature is typically group: docs). Absent/unknown falls on the safe side, like an unclassifiable reason." },
156
+ "source": { "type": "string", "enum": ["ci"], "description": "Present only when the stamp came from `verify --ci`: the verify --json the project CI uploaded for the pushed commit, judged by the same rules as a local run. Absent = local run." },
157
+ "ciRun": { "type": "string", "description": "URL of the CI run whose artifact wrote this stamp (with source: ci). `sha` is that run's headSha, so verificationStale compares the code CI saw with HEAD." }
156
158
  },
157
159
  "additionalProperties": false
158
160
  },
@@ -4,7 +4,7 @@ description: Guia a criação de uma landing page nova ponta-a-ponta a partir do
4
4
  version: core
5
5
  when-to-invoke: ao iniciar um novo projeto de LP com o template Umbraco genérico
6
6
  user-invokable: true
7
- cliVersion: "8.45.0"
7
+ cliVersion: "8.46.0"
8
8
  stacks:
9
9
  - umbraco
10
10
  ---
@@ -4,7 +4,7 @@ description: Migra uma landing page existente (React/Vite, Next.js estático ou
4
4
  version: core
5
5
  when-to-invoke: ao receber instrução "use /migrate-lp-to-umbraco {projeto}" do usuário, ou quando o usuário descrever necessidade de migrar uma LP para Umbraco
6
6
  user-invokable: true
7
- cliVersion: "8.45.0"
7
+ cliVersion: "8.46.0"
8
8
  stacks:
9
9
  - umbraco
10
10
  ---
@@ -4,7 +4,7 @@ description: Orquestra a execução autônoma de tasks de uma feature morph-spec
4
4
  version: core
5
5
  when-to-invoke: após Gate 2 aprovado, ao receber instrução "use /morph-apply" ou "implementar feature"
6
6
  user-invokable: true
7
- cliVersion: "8.45.0"
7
+ cliVersion: "8.46.0"
8
8
  ---
9
9
 
10
10
  # morph-apply
@@ -4,7 +4,7 @@ description: Entende o negócio, os usuários e a arquitetura antes de qualquer
4
4
  version: core
5
5
  when-to-invoke: ao receber um novo request de feature, antes de morph-plan
6
6
  user-invokable: true
7
- cliVersion: "8.45.0"
7
+ cliVersion: "8.46.0"
8
8
  ---
9
9
 
10
10
  # morph-brainstorming
@@ -4,7 +4,7 @@ description: ADW cirúrgico de hotfix — resolve uma queda/bug crítico de prod
4
4
  version: core
5
5
  when-to-invoke: ao receber "/morph-hotfix {feature}", quando a morph-proposal classifica o pedido como workType hotfix (Passo 0), ou o usuário diz "produção caiu / hotfix / urgente"
6
6
  user-invokable: true
7
- cliVersion: "8.45.0"
7
+ cliVersion: "8.46.0"
8
8
  ---
9
9
 
10
10
  # morph-hotfix
@@ -4,7 +4,7 @@ description: Guia a execução de cada tarefa de implementação com standard e
4
4
  version: core
5
5
  when-to-invoke: ao iniciar cada task de implementação listada em tasks.json
6
6
  user-invokable: false
7
- cliVersion: "8.45.0"
7
+ cliVersion: "8.46.0"
8
8
  ---
9
9
 
10
10
  # morph-implement
@@ -4,7 +4,7 @@ description: Cuida da RAM e da CPU da máquina enquanto o morph-spec trabalha
4
4
  version: core
5
5
  when-to-invoke: quando a máquina está lenta ou sem RAM, um comando pesado esperou ou foi recusado pela fila, sobrou worktree de sub-agent, ou vários sub-agents pesados vão rodar em paralelo
6
6
  user-invokable: false
7
- cliVersion: "8.45.0"
7
+ cliVersion: "8.46.0"
8
8
  ---
9
9
 
10
10
  # morph-machine
@@ -4,7 +4,7 @@ description: Conduz a AUTORIA de mídia gerada por IA — escolher o modelo pelo
4
4
  version: core
5
5
  when-to-invoke: quando a tarefa é PRODUZIR imagem ou vídeo por IA (catálogo, marketing, asset de LP) e alguém precisa decidir modelo, prompt e aprovação
6
6
  user-invokable: true
7
- cliVersion: "8.45.0"
7
+ cliVersion: "8.46.0"
8
8
  stacks:
9
9
  - dotnet
10
10
  - nextjs
@@ -4,7 +4,7 @@ description: Guia a produção dos artefatos de planejamento de uma feature (use
4
4
  version: core
5
5
  when-to-invoke: ao iniciar a fase plan de uma feature, após o Gate 1 (proposal aprovado)
6
6
  user-invokable: true
7
- cliVersion: "8.45.0"
7
+ cliVersion: "8.46.0"
8
8
  ---
9
9
 
10
10
  # morph-plan
@@ -4,7 +4,7 @@ description: Orquestra a fase de proposta+plano de uma feature morph-spec. Encad
4
4
  version: core
5
5
  when-to-invoke: ao receber instrução "use /morph-proposal" do usuário, ou ao iniciar uma nova feature
6
6
  user-invokable: true
7
- cliVersion: "8.45.0"
7
+ cliVersion: "8.46.0"
8
8
  ---
9
9
 
10
10
  # morph-proposal
@@ -4,7 +4,7 @@ description: Conduz a fase de revisão e produz o review-report.md para o Gate 3
4
4
  version: core
5
5
  when-to-invoke: ao iniciar a fase review, após todas as tasks de tasks.json estarem com status `done`
6
6
  user-invokable: true
7
- cliVersion: "8.45.0"
7
+ cliVersion: "8.46.0"
8
8
  ---
9
9
 
10
10
  # morph-review
@@ -37,16 +37,28 @@ Fase review, com todas as tasks `done` e o `recap.md` em dia. Normalmente chamad
37
37
  - `evaluator.partial: true` (`PARCIAL` na 1ª linha): ver "PARCIAL" no passo 4.
38
38
  - Ausente ou sem veredito: siga para o passo 3.
39
39
 
40
- 3. **Prova da suíte — uma rodada, sua.** Rode `morph-spec verify {feature} --json` **depois da
41
- última task** (o `gate-check` pausa o ship se o último stamp é de escopo `task:{id}`).
42
- **Sem `--filter`, sempre**: `verify {feature}` **nunca recorta**, e um verify filtrado não persiste o
40
+ 3. **Prova da suíte — uma rodada, no CI.** Depois da última task, commite o trabalho da feature na
41
+ branch (`git add <paths>` + `git commit`: o CI só vê commits) e rode
42
+ `morph-spec verify {feature} --ci --json` com `run_in_background`. Ele empurra `morph/{feature}`,
43
+ cria o workflow `morph-verify.yml` se o projeto não tem um, espera o run e grava o stamp do JSON que
44
+ o run publicou. Sem CI (sem GitHub, sem `gh`, `project.ci: false`) ele mesmo roda o verify local e
45
+ diz por quê. **Quando a saída mostrar `CI rodando`, despache o avaliador (passo 4) sem esperar o
46
+ fim**: o workflow e o push já estão commitados, então a foto `--before-eval` não os acusa como
47
+ mutação, e a suíte corre no CI em paralelo. Saiu `verify local`: espere o fim antes do passo 4 —
48
+ suíte local e `mutate` disputam a máquina. Exit 2 = trabalho fora de commit; exit 3 (`pending`/`busy`)
49
+ não é vermelho (ver Ritmo). Rode-a só depois da última task: o `gate-check` pausa o ship se o
50
+ último stamp é de escopo `task:{id}`. **Sem `--filter`, sempre**: `verify {feature}` **nunca recorta**, e um verify filtrado não persiste o
43
51
  stamp. Se o JSON vier com `repeatOf`, nada mudou desde a rodada anterior — use aquela. Esta é a
44
52
  única execução de testes do Gate 3: o avaliador não roda a suíte de novo.
45
- - Nó `e2e`: `pass` → passe a URL ao avaliador (`morph-spec e2e status {feature} --json` →
46
- `baseUrl`). `fail` → volte à correção. `skip` → quem classifica é o `gate-check`
47
- (`classifyE2eSkip`, em `risk-detector.js`): dispensa de projeto/escopo passa; ambiente,
48
- `--skip-e2e` ou razão desconhecida pausam sozinhos. Diga ao avaliador que a app não foi exercida.
49
- - `traceability.warnings`: repasse ao avaliador como sinal, não bloqueio.
53
+ - Nó `e2e`: `pass` local → passe a URL ao avaliador (`morph-spec e2e status {feature} --json` →
54
+ `baseUrl`); `pass` no CI → a app foi exercida lá, não há stack local para navegar. `fail` →
55
+ correção (vermelho no CI: `gh run view <id> --log-failed`, impresso pelo verify). Vermelho por
56
+ config que só existe na máquina (`.env`, appsettings, connection string, secret) não é defeito do
57
+ código: pergunte ao usuário entre declarar o secret + `env:` no `morph-verify.yml` ou `project.ci: false`. `skip` → quem
58
+ classifica é o `gate-check` (`classifyE2eSkip`, em `risk-detector.js`): dispensa de projeto/escopo
59
+ passa; ambiente, `--skip-e2e` ou razão desconhecida pausam sozinhos.
60
+ - Vermelho com o avaliador ainda rodando: não mexa na árvore até ele voltar (passo 4); corrija junto
61
+ com os bloqueadores dele. `traceability.warnings` entram no review-report como sinal, não bloqueio.
50
62
 
51
63
  4. **Despache o avaliador independente** (`morph-evaluator`, contexto fresco, sem Write/Edit).
52
64
  - Tire a foto da árvore e escolha o modelo: `morph-spec report-check {feature} --before-eval --json`.
@@ -57,14 +69,14 @@ Fase review, com todas as tasks `done` e o `recap.md` em dia. Normalmente chamad
57
69
  review-report. Numa worktree com grafo stale, `morph-spec graph refresh` antes.
58
70
  ```
59
71
  dispatch Agent (subagent_type: "morph-evaluator", model: {evaluatorModel.model}) with prompt:
60
- contexto: use cases + spec.md + diff da feature + JSON do verify (nós, e2e, URL)
72
+ contexto: use cases + spec.md + diff da feature + JSON do verify se já terminou (nós, e2e, URL)
61
73
  + blast radius + 1-design/mockups.md (se existir) + 3-implement/evidencias/*.md (se existir)
62
74
  instrução: |
63
75
  Siga .morph/framework/evals/review-checklist.md e grave
64
76
  .morph/features/{feature}/4-review/evaluator-report.md ANTES de encerrar — o arquivo é o
65
77
  retorno; não chame SendMessage. Se o arquivo já existir quando você começar, é resíduo de
66
78
  outra tentativa: não o leia antes de escrever o seu (ancoragem no veredito alheio).
67
- A suíte já rodou (JSON acima): você NÃO roda verify, build nem testes. Bash é para ler
79
+ A suíte roda fora de você (JSON acima, ou o CI em curso): você NÃO roda verify, build nem testes. Bash é para ler
68
80
  (git diff/log/show, grep, morph-spec graph) e para no máximo UMA rodada de
69
81
  `morph-spec mutate {feature}` (até 3 mutantes com `expect`), só quando o checklist pede.
70
82
  Nada em segundo plano, nada de laço de espera. NUNCA `git checkout --`: descarta trabalho
@@ -80,8 +92,9 @@ Fase review, com todas as tasks `done` e o `recap.md` em dia. Normalmente chamad
80
92
  - `tree.sinceBeforeEval.changed` vazio e `tree.markers` vazio = árvore como antes do dispatch.
81
93
  Arquivo listado = mutação que ficou no disco: `morph-spec mutate --restore` devolve os bytes do
82
94
  snapshot conferindo md5; fora de uma rodada de mutação, desfaça linha a linha pelo diff.
83
- **NUNCA `git checkout -- src/` aqui**: na árvore de
84
- uma feature em review não existe nenhum commit da feature, e o checkout apaga o trabalho junto.
95
+ **NUNCA `git checkout -- src/` aqui**: o checkout apaga junto tudo o que não foi commitado depois
96
+ da prova (correção de bloqueador, trabalho de outra sessão), e no verify local a feature pode nem
97
+ ter commit.
85
98
  - `tree.sinceBeforeEval` `null` = sem foto; `tree` `null` = git não respondeu, nada foi conferido.
86
99
  - Não rode build/teste enquanto o agente está `running`.
87
100
 
@@ -91,8 +104,8 @@ Fase review, com todas as tasks `done` e o `recap.md` em dia. Normalmente chamad
91
104
  MESMO avaliador por `SendMessage`, colando `tree.dirty` e `tree.commits` no recado. Só se o id não
92
105
  retomar, despache um novo.
93
106
 
94
- 5. **Bloqueadores.** `**Verdict:** FIX` ou bloqueador aberto: corrija (loop da `morph-apply`), rode o
95
- verify de novo e **confirme só os bloqueadores** retomando o MESMO avaliador por `SendMessage`
107
+ 5. **Bloqueadores.** `**Verdict:** FIX` ou bloqueador aberto: corrija (loop da `morph-apply`), commite, rode o
108
+ `verify {feature} --ci` de novo e **confirme só os bloqueadores** retomando o MESMO avaliador por `SendMessage`
96
109
  (com `tree.dirty`/`tree.commits` e uma foto `--before-eval` nova). Ele não reavalia a feature
97
110
  inteira, e **nota nunca motiva nova rodada**. Avaliador encerrado: despache um novo dizendo que
98
111
  é uma confirmação dos bloqueadores Bn — o relatório é append-only e vale o último `**Verdict:**`.
@@ -4,7 +4,7 @@ description: Varre o codebase existente, detecta stack atual, compara com o stac
4
4
  version: core
5
5
  when-to-invoke: ao iniciar uma feature em projeto brownfield (codebase já existente)
6
6
  user-invokable: true
7
- cliVersion: "8.45.0"
7
+ cliVersion: "8.46.0"
8
8
  ---
9
9
 
10
10
  # morph-stack-scan
@@ -4,7 +4,7 @@ description: Localiza um standard específico via busca server-side (morph-spec
4
4
  version: core
5
5
  when-to-invoke: quando precisar consultar um standard específico durante a implementação
6
6
  user-invokable: true
7
- cliVersion: "8.45.0"
7
+ cliVersion: "8.46.0"
8
8
  ---
9
9
 
10
10
  # morph-standards
@@ -4,7 +4,7 @@ description: Conduz a fase opcional de UI/UX — design system, mockups, compone
4
4
  version: core
5
5
  when-to-invoke: ao tomar o ramo uiux (morph-spec advance --design), depois do Gate 1 e antes do morph-plan
6
6
  user-invokable: true
7
- cliVersion: "8.45.0"
7
+ cliVersion: "8.46.0"
8
8
  ---
9
9
 
10
10
  # morph-uiux
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polymorphism-tech/morph-spec",
3
- "version": "8.45.0",
3
+ "version": "8.46.0",
4
4
  "description": "MORPH-SPEC: NLH (Natural Language Harness) for spec-driven development with Claude Code",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -4,7 +4,7 @@ import { getFeature, updateFeature } from '../../core/state/state-manager.js';
4
4
  import { runVerify, readTask, VerifyUsageError } from '../../lib/validators/verify-runner.js';
5
5
  import { resolvePrimaryRoot } from '../project/worktree.js';
6
6
  import { resolveFeatureTree } from '../../lib/worktree/feature-truth.js';
7
- import { canonicalPath } from '../../lib/tasks/test-runner.js';
7
+ import { canonicalPath, readMorphConfig } from '../../lib/tasks/test-runner.js';
8
8
  import { getHeadSha } from '../../lib/git/default-branch.js';
9
9
  import { EVAL_RUNNER } from '../../lib/tasks/eval-node.js';
10
10
  import { buildVerifyEvent, recordVerifyEvent, findRepeatedVerify } from '../../lib/telemetry/verify-event.js';
@@ -12,6 +12,7 @@ import { treeFingerprint } from '../../lib/git/tree-fingerprint.js';
12
12
  import { HeavyBusyError } from '../../lib/heavy/gate.js';
13
13
  import { afterHeavyRun } from '../../lib/heavy/cleanup.js';
14
14
  import { readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js';
15
+ import { runCiVerify, CiUsageError } from '../../lib/ci/ci-verify.js';
15
16
 
16
17
  /** ✓ pass · ✗ fail · – skip */
17
18
  function statusIcon(status) {
@@ -190,6 +191,54 @@ function evalsStatusOf(result) {
190
191
  */
191
192
  const PARTIAL_RUN_FLAGS = [['filter', 'filtered'], ['skipTests', 'skip-tests'], ['skipBuild', 'skip-build']];
192
193
 
194
+ const CI_EXCLUSIVE_FLAGS = [
195
+ ['filter', '--filter'], ['skipTests', '--skip-tests'], ['skipBuild', '--skip-build'], ['skipE2e', '--skip-e2e'],
196
+ ['freshE2e', '--fresh-e2e'], ['linux', '--linux'], ['recordBaseline', '--record-baseline'], ['refreshCache', '--refresh-cache'],
197
+ ['noPersist', '--no-persist'],
198
+ ];
199
+
200
+ async function runLocalVerify(featureName, taskId, options) {
201
+ try {
202
+ return await runVerify(process.cwd(), featureName, taskId || null, {
203
+ rubric: options.rubric,
204
+ timeout: options.timeout,
205
+ skipTests: options.skipTests,
206
+ skipBuild: options.skipBuild,
207
+ skipE2e: options.skipE2e,
208
+ freshE2e: options.freshE2e,
209
+ filter: options.filter,
210
+ recordBaseline: options.recordBaseline,
211
+ refreshCache: options.refreshCache,
212
+ linux: options.linux,
213
+ linuxImage: options.linuxImage,
214
+ // Task scope: switch the recorte off (#87). At feature scope nothing is
215
+ // ever narrowed, so the flag is a documented no-op there, not an error.
216
+ full: options.full,
217
+ });
218
+ } catch (err) {
219
+ // A wrong invocation is exit 2, like an unknown feature or task — never a
220
+ // verdict. `--filter` that selects nothing must not print green (or red):
221
+ // there is no verification to report, only a command to fix.
222
+ if (err instanceof VerifyUsageError) {
223
+ console.error(chalk.red(`\n❌ ${err.message}\n`));
224
+ process.exit(2);
225
+ }
226
+ // Ocupado não é veredito: nada foi verificado, então não há stamp nem
227
+ // evento, e o exit 3 nunca é "vermelho".
228
+ if (err instanceof HeavyBusyError) {
229
+ if (options.json) {
230
+ const holders = err.holders.map(({ pid, label, feature, node, since }) => ({ pid, label, feature, node, since }));
231
+ console.log(JSON.stringify({
232
+ status: 'busy', persisted: false, busy: { reason: err.reason, holders, freeGb: err.freeGb, waitedMs: err.waitedMs },
233
+ }, null, 2));
234
+ }
235
+ console.error(chalk.yellow(`\n⏳ verify não rodou: ${err.message}. Não é vermelho — a fila já esperou o prazo dela: não repita em laço — siga sem esta rodada e avise o usuário (\`morph-spec heavy status\` mostra quem segura os slots).\n`));
236
+ process.exit(3);
237
+ }
238
+ throw err;
239
+ }
240
+ }
241
+
193
242
  /**
194
243
  * `morph-spec verify <feature> [task]` — the deterministic code-before-judge
195
244
  * gate: build + tests + validators, BEFORE the LLM eval. See verify-runner.js
@@ -223,6 +272,14 @@ export async function verifyCommand(featureName, taskId, options = {}) {
223
272
  process.exit(2);
224
273
  }
225
274
 
275
+ if (options.ci) {
276
+ const conflict = taskId ? `a task ${taskId} (o loop por task é local)` : CI_EXCLUSIVE_FLAGS.find(([flag]) => options[flag])?.[1];
277
+ if (conflict) {
278
+ console.error(chalk.red(`\n❌ --ci roda a suíte inteira da feature no CI; não combina com ${conflict}\n`));
279
+ process.exit(2);
280
+ }
281
+ }
282
+
226
283
  // The runner's own reader (one reader of tasks.json for verify, #87): null
227
284
  // means tasks.json or the task is missing, and both are this usage error.
228
285
  if (taskId) {
@@ -232,51 +289,36 @@ export async function verifyCommand(featureName, taskId, options = {}) {
232
289
  }
233
290
  }
234
291
 
235
- // Taken before the run: it is the tree the run is about to read.
236
- const fingerprint = treeFingerprint(process.cwd());
237
-
238
- let result;
239
- try {
240
- result = await runVerify(process.cwd(), featureName, taskId || null, {
241
- rubric: options.rubric,
242
- timeout: options.timeout,
243
- skipTests: options.skipTests,
244
- skipBuild: options.skipBuild,
245
- skipE2e: options.skipE2e,
246
- freshE2e: options.freshE2e,
247
- filter: options.filter,
248
- recordBaseline: options.recordBaseline,
249
- refreshCache: options.refreshCache,
250
- linux: options.linux,
251
- linuxImage: options.linuxImage,
252
- // Task scope: switch the recorte off (#87). At feature scope nothing is
253
- // ever narrowed, so the flag is a documented no-op there, not an error.
254
- full: options.full,
255
- });
256
- } catch (err) {
257
- // A wrong invocation is exit 2, like an unknown feature or task — never a
258
- // verdict. `--filter` that selects nothing must not print green (or red):
259
- // there is no verification to report, only a command to fix.
260
- if (err instanceof VerifyUsageError) {
261
- console.error(chalk.red(`\n❌ ${err.message}\n`));
262
- process.exit(2);
263
- }
264
- // Ocupado não é veredito: nada foi verificado, então não há stamp nem
265
- // evento, e o exit 3 nunca é "vermelho".
266
- if (err instanceof HeavyBusyError) {
267
- if (options.json) {
268
- const holders = err.holders.map(({ pid, label, feature, node, since }) => ({ pid, label, feature, node, since }));
269
- console.log(JSON.stringify({
270
- status: 'busy', persisted: false, busy: { reason: err.reason, holders, freeGb: err.freeGb, waitedMs: err.waitedMs },
271
- }, null, 2));
292
+ let ci = null;
293
+ if (options.ci) {
294
+ try {
295
+ const outcome = await runCiVerify(process.cwd(), featureName, readMorphConfig(process.cwd()), {
296
+ log: (line) => console.error(chalk.gray(` ${line}`)),
297
+ });
298
+ if (outcome.mode === 'ci') {
299
+ ci = outcome;
300
+ } else if (outcome.mode === 'pending') {
301
+ if (options.json) console.log(JSON.stringify({ status: 'pending', persisted: false, reason: outcome.reason }, null, 2));
302
+ console.error(chalk.yellow(`\n⏳ ${outcome.reason}. Não é vermelho e não há stamp: não repita em laço — siga e deixe o gate-check decidir.\n`));
303
+ process.exit(3);
304
+ } else {
305
+ console.error(chalk.yellow(`\n↪ CI indisponível (${outcome.reason}) — verify local.\n`));
272
306
  }
273
- console.error(chalk.yellow(`\n⏳ verify não rodou: ${err.message}. Não é vermelho — a fila já esperou o prazo dela: não repita em laço — siga sem esta rodada e avise o usuário (\`morph-spec heavy status\` mostra quem segura os slots).\n`));
274
- process.exit(3);
307
+ } catch (err) {
308
+ if (err instanceof CiUsageError) {
309
+ console.error(chalk.red(`\n❌ ${err.message}\n`));
310
+ process.exit(2);
311
+ }
312
+ throw err;
275
313
  }
276
- throw err;
277
314
  }
278
315
 
279
- const cleanup = afterHeavyRun({
316
+ // Taken before the run: it is the tree the run is about to read.
317
+ const fingerprint = treeFingerprint(process.cwd());
318
+
319
+ const result = ci ? ci.result : await runLocalVerify(featureName, taskId, options);
320
+
321
+ const cleanup = ci ? { status: 'skipped', reason: 'ci' } : afterHeavyRun({
280
322
  projectPath: process.cwd(),
281
323
  config: readHeavyConfig(process.cwd()),
282
324
  scope: taskId ? 'task' : 'feature',
@@ -329,9 +371,10 @@ export async function verifyCommand(featureName, taskId, options = {}) {
329
371
  status: result.status,
330
372
  nodes: nodeStatuses(result),
331
373
  e2eReason: result.nodes.e2e.status === 'skip' ? (result.nodes.e2e.reason ?? null) : null,
332
- sha: getHeadSha(process.cwd()),
374
+ sha: ci ? ci.run.headSha : getHeadSha(process.cwd()),
333
375
  ts: now,
334
376
  scope: taskId ? `task:${taskId}` : 'feature',
377
+ ...(ci ? { source: 'ci', ciRun: ci.run.url } : {}),
335
378
  };
336
379
  await updateFeature(featureName, 'verification', stamp);
337
380
  }
@@ -341,9 +384,9 @@ export async function verifyCommand(featureName, taskId, options = {}) {
341
384
  let repeatOf = null;
342
385
  try {
343
386
  const cwd = process.cwd();
344
- if (!taskId && persist) repeatOf = findRepeatedVerify(cwd, { feature: featureName, task: null, scope: result.scope, fingerprint });
387
+ if (!taskId && persist && !ci) repeatOf = findRepeatedVerify(cwd, { feature: featureName, task: null, scope: result.scope, fingerprint });
345
388
  const tree = resolve(cwd).replace(/\\/g, '/') === resolvePrimaryRoot(cwd) ? 'root' : 'worktree';
346
- recordVerifyEvent(cwd, buildVerifyEvent(result, { sha: getHeadSha(cwd), tree, persisted: persist, reason, fingerprint }));
389
+ recordVerifyEvent(cwd, buildVerifyEvent(result, { sha: ci ? ci.run.headSha : getHeadSha(cwd), tree, persisted: persist, reason, fingerprint, source: ci ? 'ci' : 'local' }));
347
390
  } catch { /* telemetry never changes the verdict */ }
348
391
 
349
392
  // The refusal always goes to stderr, so it survives --json (whose stdout has
@@ -355,10 +398,18 @@ export async function verifyCommand(featureName, taskId, options = {}) {
355
398
  }
356
399
 
357
400
  if (options.json) {
358
- const output = { ...result, persisted: persist, ...(persist ? {} : { reason }), repeatOf, cleanup };
401
+ const output = {
402
+ ...result, persisted: persist, ...(persist ? {} : { reason }), repeatOf, cleanup,
403
+ ...(ci ? { source: 'ci', ciRun: { id: ci.run.databaseId, url: ci.run.url, sha: ci.run.headSha }, workflowCreated: ci.created } : {}),
404
+ };
359
405
  console.log(JSON.stringify(output, null, 2));
360
406
  } else {
361
407
  console.log(formatVerifyResult(result));
408
+ if (ci) {
409
+ console.log(chalk.cyan(`fonte: CI ${ci.run.url}`));
410
+ if (result.status === 'fail') console.log(chalk.gray(` log das falhas: gh run view ${ci.run.databaseId} --log-failed`));
411
+ console.log('');
412
+ }
362
413
  if (cleanup.status !== 'skipped') {
363
414
  const mark = cleanup.status === 'ran' ? chalk.green('✓') : chalk.yellow('⚠');
364
415
  console.log(chalk.gray(`${mark} dotnet build-server shutdown: ${cleanup.status}${cleanup.reason ? ` (${cleanup.reason})` : ''}\n`));
@@ -0,0 +1,118 @@
1
+ import { execFileSync } from 'child_process';
2
+ import { existsSync, mkdirSync, writeFileSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { getHeadSha } from '../git/default-branch.js';
5
+ import { treeMovedSince } from '../git/evidence-staleness.js';
6
+ import { resolveGh } from '../git/gh.js';
7
+ import {
8
+ CI_WORKFLOW_DIR, CI_WORKFLOW_FILE, ciAvailability, downloadResult, findCiWorkflow, pushBranch,
9
+ uncommittedCode, waitForRun, workflowPushProblem,
10
+ } from './github-ci.js';
11
+ import { renderCiWorkflow } from './workflow-scaffold.js';
12
+
13
+ export const CI_WAIT_MS = 50 * 60 * 1000;
14
+ const APPEAR_MS = Number(process.env.MORPH_CI_APPEAR_MS ?? 90 * 1000);
15
+
16
+ export class CiUsageError extends Error {}
17
+
18
+ const NODE_KEYS = ['build', 'tests', 'validators', 'e2e'];
19
+
20
+ function isVerifyResult(result) {
21
+ return Boolean(result) && ['pass', 'fail'].includes(result.status) && result.scope === 'feature'
22
+ && NODE_KEYS.every((k) => typeof result.nodes?.[k]?.status === 'string');
23
+ }
24
+
25
+ function scaffoldWorkflow(root, config) {
26
+ const rel = `${CI_WORKFLOW_DIR}/${CI_WORKFLOW_FILE}`;
27
+ const abs = join(root, rel);
28
+ if (existsSync(abs)) return { error: `${rel} já existe e não chama morph-spec verify publicando o JSON` };
29
+ try {
30
+ mkdirSync(join(root, CI_WORKFLOW_DIR), { recursive: true });
31
+ writeFileSync(abs, renderCiWorkflow(root, config));
32
+ execFileSync('git', ['add', '--', rel], { cwd: root, stdio: 'pipe' });
33
+ execFileSync('git', ['commit', '-m', 'ci: workflow morph-verify (prova do Gate 3 no CI)', '--', rel], { cwd: root, stdio: 'pipe' });
34
+ return { path: rel };
35
+ } catch (err) {
36
+ return { error: `não consegui criar ${rel}: ${(err.stderr?.toString() || err.message).trim()}` };
37
+ }
38
+ }
39
+
40
+ function dispatchWorkflow(root, workflow, branch) {
41
+ try {
42
+ const { bin, prefix } = resolveGh();
43
+ execFileSync(bin, [...prefix, 'workflow', 'run', workflow, '--ref', branch], { cwd: root, stdio: 'pipe', timeout: 15000 });
44
+ return true;
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * `verify {f} --ci`: the feature-scope proof, run by the project's CI over the pushed commit.
52
+ * Every way CI cannot answer is `mode: 'local'` with the reason, and the caller runs the local
53
+ * verify: no CI is never a pause. Only the uploaded `verify --json` becomes a result, so the
54
+ * gate judges a CI run by the same stamp and the same rules as a local one.
55
+ *
56
+ * @returns {Promise<
57
+ * { mode: 'local', reason: string } |
58
+ * { mode: 'pending', reason: string, run: object|null } |
59
+ * { mode: 'ci', result: object, run: object, created: string|null }>}
60
+ * @throws {CiUsageError} code CI would not see (uncommitted work)
61
+ */
62
+ export async function runCiVerify(root, featureName, config, { log = () => {}, waitMs = CI_WAIT_MS, wait = waitForRun } = {}) {
63
+ const avail = ciAvailability(root, featureName, config);
64
+ if (!avail.ok) return { mode: 'local', reason: avail.reason };
65
+
66
+ const dirty = uncommittedCode(root);
67
+ if (dirty === null) return { mode: 'local', reason: 'git status não respondeu' };
68
+ if (dirty.length > 0) {
69
+ const shown = dirty.slice(0, 5).join(', ') + (dirty.length > 5 ? ` (+${dirty.length - 5})` : '');
70
+ throw new CiUsageError(`o CI só vê commits e há trabalho fora deles: ${shown}. Commite na branch da feature (git add <paths> && git commit) e rode de novo, ou rode sem --ci`);
71
+ }
72
+
73
+ let workflow = findCiWorkflow(root);
74
+ let created = null;
75
+ if (!workflow) {
76
+ const problem = workflowPushProblem(avail);
77
+ if (problem) return { mode: 'local', reason: problem };
78
+ const scaffold = scaffoldWorkflow(root, config);
79
+ if (scaffold.error) return { mode: 'local', reason: scaffold.error };
80
+ created = scaffold.path;
81
+ workflow = CI_WORKFLOW_FILE;
82
+ log(`workflow criado e commitado: ${created}`);
83
+ }
84
+
85
+ const pushError = pushBranch(root, avail.branch);
86
+ if (pushError) return { mode: 'local', reason: `push de ${avail.branch} falhou: ${pushError}` };
87
+
88
+ const head = getHeadSha(root);
89
+ const waitOpts = {
90
+ branch: avail.branch,
91
+ workflow,
92
+ isCurrent: (sha) => !treeMovedSince(root, sha, head),
93
+ timeoutMs: waitMs,
94
+ appearMs: APPEAR_MS,
95
+ onPending: (run) => log(`CI rodando: ${run.url}`),
96
+ };
97
+ let outcome = await wait(root, waitOpts);
98
+ if (outcome.state === 'no-run' && dispatchWorkflow(root, workflow, avail.branch)) {
99
+ log(`nenhum run para este commit; disparei ${workflow} manualmente`);
100
+ outcome = await wait(root, waitOpts);
101
+ }
102
+ if (outcome.state === 'no-run') return { mode: 'local', reason: `${workflow} não rodou para este commit` };
103
+ if (outcome.state === 'unknown') return { mode: 'local', reason: 'gh run list não respondeu' };
104
+ if (outcome.state === 'timeout') {
105
+ return { mode: 'pending', reason: `o CI não terminou em ${Math.round(waitMs / 60000)} min`, run: outcome.run ?? null };
106
+ }
107
+
108
+ const { run } = outcome;
109
+ const result = downloadResult(root, run.databaseId);
110
+ if (!isVerifyResult(result)) {
111
+ return {
112
+ mode: 'local',
113
+ reason: `run ${run.conclusion} sem o JSON do verify (${run.url}; gh run view ${run.databaseId} --log-failed)`,
114
+ };
115
+ }
116
+ const { persisted, reason, repeatOf, cleanup, ...verdict } = result;
117
+ return { mode: 'ci', result: verdict, run, created };
118
+ }
@@ -0,0 +1,192 @@
1
+ import { execFileSync, spawnSync } from 'child_process';
2
+ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'fs';
3
+ import { tmpdir } from 'os';
4
+ import { join } from 'path';
5
+ import { resolveGh } from '../git/gh.js';
6
+
7
+ export const CI_WORKFLOW_DIR = '.github/workflows';
8
+ export const CI_WORKFLOW_FILE = 'morph-verify.yml';
9
+ export const CI_ARTIFACT = 'morph-verify';
10
+ export const CI_RESULT_FILE = 'morph-verify.json';
11
+
12
+ // A run that never judged the code (superseded, filtered out, quota) is no proof either way.
13
+ const NO_VERDICT = new Set(['cancelled', 'skipped', 'startup_failure', 'stale', 'neutral']);
14
+
15
+ const VERIFY_CALL = /morph-spec\S*\s+verify\b/;
16
+
17
+ const git = (cwd, args) => execFileSync('git', args, { cwd, stdio: 'pipe' }).toString();
18
+
19
+ function gh(cwd, args, timeout = 15000) {
20
+ const { bin, prefix } = resolveGh();
21
+ return execFileSync(bin, [...prefix, ...args], { cwd, stdio: 'pipe', timeout }).toString();
22
+ }
23
+
24
+ /**
25
+ * The workflow that can stand in for `verify {feature}`: it must call `morph-spec verify` AND
26
+ * publish its JSON, because only that artifact becomes a stamp. A green run of any other
27
+ * workflow (lint, deploy) proves nothing about the suite.
28
+ *
29
+ * @param {string} root
30
+ * @returns {string|null} workflow file name, as `gh run list --workflow` takes it
31
+ */
32
+ export function findCiWorkflow(root) {
33
+ const dir = join(root, CI_WORKFLOW_DIR);
34
+ if (!existsSync(dir)) return null;
35
+ const files = readdirSync(dir).filter((f) => /\.ya?ml$/i.test(f)).sort((a, b) =>
36
+ (a === CI_WORKFLOW_FILE ? -1 : b === CI_WORKFLOW_FILE ? 1 : a.localeCompare(b)));
37
+ for (const file of files) {
38
+ let text;
39
+ try {
40
+ text = readFileSync(join(dir, file), 'utf8');
41
+ } catch {
42
+ continue;
43
+ }
44
+ if (VERIFY_CALL.test(text) && text.includes(CI_RESULT_FILE)) return file;
45
+ }
46
+ return null;
47
+ }
48
+
49
+ export function currentBranch(root) {
50
+ try {
51
+ return git(root, ['rev-parse', '--abbrev-ref', 'HEAD']).trim();
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ function originUrl(root) {
58
+ try {
59
+ return git(root, ['config', '--get', 'remote.origin.url']).trim();
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ function ghAuthText(root) {
66
+ const { bin, prefix } = resolveGh();
67
+ const res = spawnSync(bin, [...prefix, 'auth', 'status'], { cwd: root, stdio: 'pipe', timeout: 10000 });
68
+ if (res.error || res.status !== 0) return null;
69
+ return `${res.stdout ?? ''}${res.stderr ?? ''}`;
70
+ }
71
+
72
+ /**
73
+ * Whether CI can answer for this feature, and why not. Never throws: every "no" is a reason the
74
+ * caller prints before falling back to the local verify.
75
+ *
76
+ * @param {string} root - the feature's tree
77
+ * @param {string} featureName
78
+ * @param {object|null} config - `.morph/config/config.json`
79
+ * @returns {{ ok: true, branch: string, https: boolean, scopes: string|null } | { ok: false, reason: string }}
80
+ */
81
+ export function ciAvailability(root, featureName, config) {
82
+ if (config?.project?.ci === false) return { ok: false, reason: 'project.ci = false no config.json' };
83
+ const url = originUrl(root);
84
+ if (!url) return { ok: false, reason: 'sem remote origin' };
85
+ if (!/github\.com/i.test(url)) return { ok: false, reason: 'origin não é GitHub' };
86
+ const branch = currentBranch(root);
87
+ if (branch !== `morph/${featureName}`) {
88
+ return { ok: false, reason: `branch atual "${branch}" não é morph/${featureName}` };
89
+ }
90
+ const auth = ghAuthText(root);
91
+ if (auth === null) return { ok: false, reason: 'gh ausente ou não autenticado' };
92
+ const scopes = auth.match(/Token scopes:\s*(.*)/i)?.[1] ?? null;
93
+ return { ok: true, branch, https: /^https?:/i.test(url), scopes };
94
+ }
95
+
96
+ /**
97
+ * Creating a workflow file over HTTPS needs the token's `workflow` scope; without it GitHub
98
+ * rejects the whole push. Unknown scopes (fine-grained token, SSH remote) are not a refusal.
99
+ */
100
+ export function workflowPushProblem({ https, scopes }) {
101
+ if (!https || scopes === null) return null;
102
+ return /\bworkflow\b/.test(scopes) ? null : 'o token do gh não tem o escopo "workflow" (gh auth refresh -s workflow)';
103
+ }
104
+
105
+ /**
106
+ * Paths CI would not see: uncommitted or untracked files outside `.morph/`, plus the config,
107
+ * which names the test commands.
108
+ *
109
+ * @returns {string[]|null} null when git did not answer
110
+ */
111
+ export function uncommittedCode(root) {
112
+ let out;
113
+ try {
114
+ out = git(root, ['status', '--porcelain', '-z', '--untracked-files=all', '--no-renames']);
115
+ } catch {
116
+ return null;
117
+ }
118
+ return out.split('\0').filter(Boolean).map((line) => line.slice(3))
119
+ .filter((path) => !path.startsWith('.morph/') || path === '.morph/config/config.json');
120
+ }
121
+
122
+ export function pushBranch(root, branch) {
123
+ try {
124
+ git(root, ['push', '-u', 'origin', branch]);
125
+ return null;
126
+ } catch (err) {
127
+ return (err.stderr?.toString() || err.message).trim().split('\n').slice(-3).join(' ');
128
+ }
129
+ }
130
+
131
+ /** @returns {Array<object>|null} newest first; null = gh did not answer */
132
+ export function listRuns(root, branch, workflow) {
133
+ try {
134
+ return JSON.parse(gh(root, ['run', 'list', '--branch', branch, '--workflow', workflow, '--limit', '20',
135
+ '--json', 'databaseId,status,conclusion,headSha,url,createdAt']));
136
+ } catch {
137
+ return null;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * The newest run over the same code as HEAD. `isCurrent` is a tree comparison, not SHA equality:
143
+ * the evaluator and the archive commit only `.morph/`, and the workflow does not re-run for them.
144
+ */
145
+ export function pickRun(runs, isCurrent) {
146
+ return (runs || []).find((run) => !(run.status === 'completed' && NO_VERDICT.has(run.conclusion))
147
+ && run.headSha && isCurrent(run.headSha)) ?? null;
148
+ }
149
+
150
+ /** @returns {object|null} the parsed `verify --json` the run uploaded; null when absent or unreadable */
151
+ export function downloadResult(root, runId) {
152
+ const dir = mkdtempSync(join(tmpdir(), 'morph-ci-'));
153
+ try {
154
+ gh(root, ['run', 'download', String(runId), '--name', CI_ARTIFACT, '--dir', dir], 120000);
155
+ return JSON.parse(readFileSync(join(dir, CI_RESULT_FILE), 'utf8'));
156
+ } catch {
157
+ return null;
158
+ } finally {
159
+ rmSync(dir, { recursive: true, force: true });
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Polls until the run over the current tree completes. Never decides: the caller turns
165
+ * `timeout`/`no-run`/`unknown` into "no CI proof", never into red.
166
+ *
167
+ * @returns {Promise<{ state: 'completed'|'timeout'|'no-run'|'unknown', run?: object }>}
168
+ */
169
+ export async function waitForRun(root, { branch, workflow, isCurrent, timeoutMs, appearMs, pollMs = 15000,
170
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now, onPending = () => {}, list = listRuns }) {
171
+ const start = now();
172
+ let seen = false;
173
+ let misses = 0;
174
+ for (;;) {
175
+ const runs = list(root, branch, workflow);
176
+ if (runs === null) {
177
+ if (++misses >= 3) return { state: 'unknown' };
178
+ } else {
179
+ misses = 0;
180
+ const run = pickRun(runs, isCurrent);
181
+ if (run?.status === 'completed') return { state: 'completed', run };
182
+ if (run) {
183
+ if (!seen) onPending(run);
184
+ seen = true;
185
+ }
186
+ }
187
+ const elapsed = now() - start;
188
+ if (!seen && elapsed >= appearMs) return { state: 'no-run' };
189
+ if (elapsed >= timeoutMs) return { state: 'timeout' };
190
+ await sleep(pollMs);
191
+ }
192
+ }
@@ -0,0 +1,122 @@
1
+ import { existsSync, readdirSync } from 'fs';
2
+ import { join, posix, relative, resolve } from 'path';
3
+ import { PACKAGE_LOCATIONS } from '../detectors/index.js';
4
+ import { CI_ARTIFACT, CI_RESULT_FILE } from './github-ci.js';
5
+
6
+ const LOCKFILES = [
7
+ ['package-lock.json', 'npm ci'],
8
+ ['pnpm-lock.yaml', 'corepack enable && pnpm install --frozen-lockfile'],
9
+ ['yarn.lock', 'corepack enable && yarn install --frozen-lockfile'],
10
+ ];
11
+ const PLAYWRIGHT_CONFIGS = ['playwright.config.ts', 'playwright.config.js', 'playwright.config.mjs'];
12
+
13
+ function packageDirs(root, config) {
14
+ const dirs = [...PACKAGE_LOCATIONS];
15
+ if (typeof config?.project?.frontendPath === 'string') dirs.push(config.project.frontendPath);
16
+ for (const node of Array.isArray(config?.project?.tests) ? config.project.tests : []) {
17
+ if (typeof node?.cwd === 'string') dirs.push(node.cwd);
18
+ }
19
+ const seen = new Set();
20
+ const out = [];
21
+ for (const dir of dirs) {
22
+ const abs = resolve(root, dir.trim() || '.');
23
+ const rel = relative(root, abs).split('\\').join('/') || '.';
24
+ if (rel.startsWith('..') || seen.has(rel)) continue;
25
+ seen.add(rel);
26
+ const lock = LOCKFILES.find(([file]) => existsSync(join(abs, file)));
27
+ if (lock) out.push({ dir: rel, install: lock[1], playwright: PLAYWRIGHT_CONFIGS.some((f) => existsSync(join(abs, f))) });
28
+ }
29
+ return out;
30
+ }
31
+
32
+ function usesDotnet(root, config) {
33
+ if (existsSync(join(root, 'global.json'))) return true;
34
+ if (/dotnet/i.test(String(config?.project?.stack ?? ''))) return true;
35
+ if ((config?.project?.tests ?? []).some?.((node) => node?.runner === 'dotnet' || /\bdotnet\b/.test(node?.command ?? ''))) return true;
36
+ try {
37
+ return readdirSync(root).some((f) => /\.(sln|slnx|csproj)$/i.test(f));
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ function dotnetSetup(root, config) {
44
+ if (!usesDotnet(root, config)) return [];
45
+ const hasGlobalJson = existsSync(join(root, 'global.json'));
46
+ return [
47
+ ' - uses: actions/setup-dotnet@v4',
48
+ ' with:',
49
+ hasGlobalJson ? ' global-json-file: global.json' : " dotnet-version: '10.0.x'",
50
+ ];
51
+ }
52
+
53
+ const step = (name, dir, run) => [
54
+ ` - name: ${name}`,
55
+ ...(dir === '.' ? [] : [` working-directory: ${posix.normalize(dir)}`]),
56
+ ` run: ${run}`,
57
+ ];
58
+
59
+ /**
60
+ * The workflow `verify {f} --ci` relies on. Same judge as the local run: it calls the project's
61
+ * pinned morph-spec, so the suite is whatever `project.tests` says, not a second definition here.
62
+ * `.morph/` is out of the trigger (bookkeeping commits would cancel the run over the code) except
63
+ * the config, which names the commands.
64
+ *
65
+ * @param {string} root
66
+ * @param {object|null} config
67
+ * @returns {string}
68
+ */
69
+ export function renderCiWorkflow(root, config) {
70
+ const packages = packageDirs(root, config);
71
+ const lines = [
72
+ '# Gerado por morph-spec: a prova do Gate 3 roda aqui (verify {feature} --ci).',
73
+ 'name: morph-verify',
74
+ '',
75
+ 'on:',
76
+ ' push:',
77
+ " branches: ['morph/**']",
78
+ ' paths:',
79
+ " - '**'",
80
+ " - '!.morph/**'",
81
+ " - '.morph/config/config.json'",
82
+ ' workflow_dispatch:',
83
+ '',
84
+ 'concurrency:',
85
+ ' group: morph-verify-${{ github.ref }}',
86
+ ' cancel-in-progress: true',
87
+ '',
88
+ 'jobs:',
89
+ ' verify:',
90
+ ' runs-on: ubuntu-latest',
91
+ ' timeout-minutes: 45',
92
+ ' env:',
93
+ " MORPH_HEAVY: 'off'",
94
+ ' steps:',
95
+ ' - uses: actions/checkout@v4',
96
+ ' - uses: actions/setup-node@v4',
97
+ ' with:',
98
+ " node-version: '22'",
99
+ ...dotnetSetup(root, config),
100
+ ];
101
+ for (const pkg of packages) {
102
+ lines.push(...step(`install (${pkg.dir})`, pkg.dir, pkg.install));
103
+ if (pkg.playwright) lines.push(...step(`playwright browsers (${pkg.dir})`, pkg.dir, 'npx playwright install --with-deps chromium'));
104
+ }
105
+ lines.push(
106
+ ' - name: morph-spec verify',
107
+ ' shell: bash',
108
+ ' run: |',
109
+ ' feature="${GITHUB_REF_NAME#morph/}"',
110
+ " version=$(node -p \"try { require('./.morph/.morphversion').morphVersion } catch { 'latest' }\")",
111
+ ` npx -y "@polymorphism-tech/morph-spec@\${version}" verify "$feature" --json --no-persist > ${CI_RESULT_FILE}`,
112
+ ' - uses: actions/upload-artifact@v4',
113
+ ' if: always()',
114
+ ' with:',
115
+ ` name: ${CI_ARTIFACT}`,
116
+ ` path: ${CI_RESULT_FILE}`,
117
+ ' if-no-files-found: ignore',
118
+ ' retention-days: 7',
119
+ '',
120
+ );
121
+ return lines.join('\n');
122
+ }
@@ -14,10 +14,10 @@ const NODE_NAMES = ['build', 'tests', 'validators', 'e2e'];
14
14
  /**
15
15
  * @param {Object} result - runVerify() output
16
16
  * @param {{ sha: string|null, tree: 'root'|'worktree', persisted: boolean, reason: string|null,
17
- * fingerprint?: string|null }} context
17
+ * fingerprint?: string|null, source?: 'local'|'ci' }} context
18
18
  * @returns {Object}
19
19
  */
20
- export function buildVerifyEvent(result, { sha, tree, persisted, reason, fingerprint = null }) {
20
+ export function buildVerifyEvent(result, { sha, tree, persisted, reason, fingerprint = null, source = 'local' }) {
21
21
  const nodes = {};
22
22
  for (const name of NODE_NAMES) {
23
23
  const node = result.nodes?.[name];
@@ -40,6 +40,7 @@ export function buildVerifyEvent(result, { sha, tree, persisted, reason, fingerp
40
40
  tree,
41
41
  persisted,
42
42
  reason: persisted ? null : (reason ?? null),
43
+ source,
43
44
  nodes,
44
45
  };
45
46
  }