@polymorphism-tech/morph-spec 10.5.4 → 10.6.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.
package/CLAUDE.md CHANGED
@@ -21,6 +21,7 @@ cd src/dashboard && npm install
21
21
  npm run test:changed # add -- --list to see the selection and why
22
22
 
23
23
  # Run all tests (files in parallel, cpus-1 at a time; ~6 min on a 16-core Windows box, bound by process/git contention)
24
+ # Not a step before a PR or release: the PR runs test:changed and the tag runs the whole wall before publishing
24
25
  npm test
25
26
 
26
27
  # A subtree or file (still parallel); --test-concurrency=1 to debug serially
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:** 10.5.4
6
+ **Version:** 10.6.0
7
7
  **Requires:** Node.js 22+, Claude Code
8
8
 
9
9
  ---
@@ -376,4 +376,4 @@ Code generated by morph-spec (contracts, templates, implementation output) belon
376
376
 
377
377
  ---
378
378
 
379
- *morph-spec v10.5.4 by [Polymorphism Tech](https://polymorphism.tech)*
379
+ *morph-spec v10.6.0 by [Polymorphism Tech](https://polymorphism.tech)*
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "morph-spec",
3
- "version": "10.5.4",
3
+ "version": "10.6.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",
@@ -174,4 +174,4 @@ Never edited directly — go through CLI commands or `morph-spec update` instead
174
174
 
175
175
  ---
176
176
 
177
- *morph-spec v10.5.4 by Polymorphism Tech*
177
+ *morph-spec v10.6.0 by Polymorphism Tech*
@@ -89,4 +89,4 @@ morph-spec doctor
89
89
 
90
90
  ---
91
91
 
92
- *morph-spec v10.5.4 by Polymorphism Tech*
92
+ *morph-spec v10.6.0 by Polymorphism Tech*
@@ -127,6 +127,8 @@ Feature sem `workType` (pré-v8) é tratada como `feature`.
127
127
 
128
128
  **`morph-spec finish <feature> --pr|--merge`** — o **encerramento único** da feature (worktree ou comum; a sessão escolhe por `finish.mode` do `config.json`, padrão `pr`): **arquiva** commitando `features/{f} → archive/{f}` dentro de `morph/{f}` — depois integra: `--merge` na hora; `--pr` só abre o PR, sem integrar. **O payload distingue os dois** — `--merge` sai com `integrated: true`; `--pr` sai com `integrated: false`, `awaiting: "merge"`, `pr: {number, url}` e um `nextStep` apontando para o merge. **`finished: true` sai nos dois casos e marca só o comando ter terminado, nunca a integração** — `--pr` não encerra a feature, quem encerra é o merge do PR; até lá ela aparece em `morph-spec list`/`fleet` como `aguardando-merge`, sem cópia local (ver `list` acima e `doctor` abaixo).
129
129
 
130
+ **Stamp com `pendingPr`** (`verify --ci` sob `project.prChecks`): o `--pr` espera os checks declarados no commit do archive, antes do desmonte, porque é esse o run que o merge reaproveita; provar antes do archive pagaria o CI duas vezes. Rode em `run_in_background`. `pass` → `checks: { status: "pass" }`. Check vermelho → `error: "pr-checks-fail"`: desfaz o archive com `git revert`, reabre o Gate 3, carimba `status: "fail"`, `source: "pr"`, devolve a PR a rascunho (o revert fica local, e rascunho não mergeia) e sai 1. Check que morreu em menos de 30 s (cota do Actions), cancelado, ou nenhum check no commit em 90 s → `error: "pr-checks-no-verdict"`: desfaz igual, apaga o stamp, e a saída é a completa local, `verify {f}` sem `--ci`. O próximo `finish` reaproveita a PR aberta. Sem resposta em 50 min: `finished: true`, `checks: { status: "pending" }`, sem desfazer nada. Processo morto na espera: o mesmo `finish --pr` retoma. `--merge` recusa esse stamp (`pr-proof-pending`).
131
+
130
132
  **Índice de ADRs:** no mesmo commit do archive, o `finish` lê o `2-plan/plan.md` da feature: os ADRs de `## Decisões` com `**Alcance:** projeto` entram em `.morph/specs/adrs/` (um arquivo por ADR, `{feature}--ADR-{nnn}.md` — features paralelas nunca tocam o mesmo arquivo), e a tabela `ADR | Mudança | Escopo` de `### Conflitos com o acervo` retira (total, o arquivo sai) ou estreita (parcial, `scope:` no arquivo) os ADRs revogados — forma em `morph-spec plan-check --help`; o `plan-check` avisa, enquanto o plano ainda muda, a linha que o `finish` não aplicaria. O payload traz `adrIndex: {added, revoked, narrowed}` e `revocationProblems: [{id, reason, message}]` (linha malformada ou ambígua, ADR fora do índice) — cada chave só aparece com conteúdo, e nenhum problema recusa o `finish`.
131
133
 
132
134
  `--merge` faz merge `--no-ff` **local** na default (sem push); `--pr` faz `push` + `gh pr create` (exige `origin` + `gh`, checados **antes** de arquivar — um `--pr` recusado não deixa meio-estado). Saída de teste não rastreada (`test-results/`, `TestResults/`, `playwright-report/`, `blob-report/`) não conta como árvore suja: na raiz só é tolerada, na worktree sai junto com o desmonte; qualquer outro arquivo não commitado recusa. Desmonta o worktree (PROVE-or-DECLINE) e libera o bloco de portas; derruba a stack e2e com `down --remove-orphans --rmi local -v` (`--keep-volumes` tira o `-v`) e os `e2e.hostServices` — `--rmi local` leva só as imagens que o compose construiu, nunca a puxada (`postgres`), que a próxima feature baixaria de novo; `docker builder prune` nunca roda (o cache é global da máquina). No `--pr` **preserva** a branch (o PR precisa dela) e fica em `morph/{f}`.
@@ -347,6 +349,8 @@ Código não commitado é **erro de uso (exit 2)**: o CI só vê commits. CI rod
347
349
 
348
350
  **Por quê.** Só o **artefato** vira prova, nunca o "verde" do run: workflow de lint, deploy ou de terceiro não compra o Gate 3.
349
351
 
352
+ **Prova na PR (`project.prChecks`):** projeto cujo CI de PR já roda a suíte declara `"prChecks": { "workflows": ["Backend", "Frontend"], "covers": ["dotnet", "vitest"] }`, e o `--ci` não cria `morph-verify.yml` nem paga a suíte duas vezes: roda local só o que a PR não prova (build, validadores, e2e aplicável, nós fora de `covers`) e carimba com `pendingPr`. Quem espera é o `finish --pr` (`### finish`). `workflows` é o `name:` do workflow; workflow fora da lista não prova nada. `covers` vale pelo que o workflow roda de fato: Vitest só com `--changed` na PR é cobertura menor aceita pelo projeto. Roda a completa local, com a razão, o que a rodada afetada subiria (migration, contrato, arquivo que alcança o projeto inteiro, código compartilhado) e o que não alcança o CI (`ciAvailability`). Ignorado com `project.ci: false` ou `finish.mode: "merge"`.
353
+
350
354
  ### Validador code-budget
351
355
 
352
356
  **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`).
@@ -746,6 +750,7 @@ nomeia o nó que impediu. **Apagar teste é legítimo:** a única cor vermelha q
746
750
  | Sessão que implementa | à vontade — filtro ou suíte —, pela fila `heavy` (`heavy.gateBash` ligado por padrão) | Implementar e conferir é dela; a prova do gate continua sendo o `verify` sem filtro |
747
751
  | 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 |
748
752
  | 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 |
753
+ | PR, com `project.prChecks` | os workflows do projeto, esperados pelo `finish --pr` | a suíte que a PR já paga não roda de novo na máquina nem no `morph-verify.yml` |
749
754
  | Evidência de eval (cenário `Cn` com `prova: eval`) | `verify {f} --filter {id-do-nó-evals} --skip-e2e --no-persist` | Só o nó `evals`, sem stack e sem carimbo — é a evidência de runtime do cenário, não a verificação da feature |
750
755
 
751
756
  > **Nenhum relógio do `verify` é de 600 s.** Os 600 s que existem são a espera da fila
@@ -25,6 +25,16 @@
25
25
  "type": "boolean",
26
26
  "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."
27
27
  },
28
+ "prChecks": {
29
+ "type": "object",
30
+ "description": "The project's own PR workflows as the proof of suites they already run. With it, `verify {f} --ci` leaves the `covers` nodes out of the local run, and `finish --pr` waits for the `workflows` checks of the PR commit; red or unjudged, it undoes the archive and reopens Gate 3. Ignored with project.ci: false or finish.mode: merge, and by a change the affected run would escalate to the complete one.",
31
+ "required": ["workflows", "covers"],
32
+ "additionalProperties": false,
33
+ "properties": {
34
+ "workflows": { "type": "array", "minItems": 1, "items": { "type": "string" }, "description": "Workflow `name:` values whose checks count. Any other workflow proves nothing." },
35
+ "covers": { "type": "array", "minItems": 1, "items": { "type": "string" }, "description": "project.tests[].id of the nodes these workflows prove. A node left out (e2e, evals, what the PR does not run) still runs locally." }
36
+ }
37
+ },
28
38
  "tests": {
29
39
  "type": "array",
30
40
  "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'.",
@@ -149,8 +149,18 @@
149
149
  "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." },
150
150
  "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." },
151
151
  "ts": { "type": "string", "format": "date-time", "description": "Server-side timestamp, same instant as the corresponding verifications entry's updatedAt." },
152
- "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." },
153
- "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." }
152
+ "source": { "type": "string", "enum": ["ci", "pr"], "description": "`ci`: 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. `pr`: `finish --pr` wrote it when a project.prChecks workflow came back red. Absent = local run." },
153
+ "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." },
154
+ "pendingPr": {
155
+ "type": "object",
156
+ "description": "Local run of `verify --ci` under project.prChecks: the `covers` nodes were left out and `finish --pr` waits for the `workflows` checks. Refused by `finish --merge`.",
157
+ "required": ["workflows", "covers"],
158
+ "additionalProperties": false,
159
+ "properties": {
160
+ "workflows": { "type": "array", "items": { "type": "string" } },
161
+ "covers": { "type": "array", "items": { "type": "string" } }
162
+ }
163
+ }
154
164
  },
155
165
  "additionalProperties": false
156
166
  },
@@ -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_use: Use quando for iniciar um novo projeto de LP com o template Umbraco genérico
6
6
  user-invocable: true
7
- cliVersion: "10.5.4"
7
+ cliVersion: "10.6.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_use: Use quando o usuário pedir "use /migrate-lp-to-umbraco {projeto}" ou descrever a necessidade de migrar uma LP para Umbraco
6
6
  user-invocable: true
7
- cliVersion: "10.5.4"
7
+ cliVersion: "10.6.0"
8
8
  stacks:
9
9
  - umbraco
10
10
  ---
@@ -4,7 +4,7 @@ description: Implementa o plano aprovado de uma feature morph-spec inteiro nesta
4
4
  version: core
5
5
  when_to_use: Use com o gate do plano aprovado ("/morph-apply {feature}", "implementar feature"), em "/morph-review {feature}" (entra no §5), em "/morph-hotfix {feature}" ou incidente de produção (references/hotfix.md), ou quando `morph-spec next` mandar implement, verify, fix, review ou finish
6
6
  user-invocable: true
7
- cliVersion: "10.5.4"
7
+ cliVersion: "10.6.0"
8
8
  ---
9
9
 
10
10
  # morph-apply
@@ -58,7 +58,7 @@ Verify verde: siga `references/review.md` — foto `--before-eval`, verificador
58
58
 
59
59
  ## 6. Fechar
60
60
 
61
- `morph-spec context refresh` (regera o inventário em `.morph/context/README.md`; `design-system.md` você atualiza só se mudou token) e commite o que faltar (relatórios do review): o `finish` exige árvore limpa. Modo: `finish.mode` do config do projeto, padrão `pr`, sem perguntar: `morph-spec finish {feature} --pr|--merge`. `--pr` não integra: a feature fica `aguardando-merge` até o merge. O `finish` arquiva o `feature.json`, o `plan.md` e o recap; o report e a review ficam no histórico do git. Encerrado, aponte o recap ao usuário (`.morph/archive/{feature}/recap.html`, abra no navegador; `morph-spec recap {feature} --piper` narra com o Piper local).
61
+ `morph-spec context refresh` (regera o inventário em `.morph/context/README.md`; `design-system.md` você atualiza só se mudou token) e commite o que faltar (relatórios do review): o `finish` exige árvore limpa. Modo: `finish.mode` do config do projeto, padrão `pr`, sem perguntar: `morph-spec finish {feature} --pr|--merge`. `--pr` não integra: a feature fica `aguardando-merge` até o merge. Stamp com `pendingPr`: `--pr` em `run_in_background`, porque ele espera os checks da PR; `pr-checks-fail` ou `pr-checks-no-verdict` desfaz o archive e reabre o Gate 3: siga o `hint`. O `finish` arquiva o `feature.json`, o `plan.md` e o recap; o report e a review ficam no histórico do git. Encerrado, aponte o recap ao usuário (`.morph/archive/{feature}/recap.html`, abra no navegador; `morph-spec recap {feature} --piper` narra com o Piper local).
62
62
 
63
63
  ## Arquivo de teste
64
64
 
@@ -49,7 +49,7 @@ Confirmação = **dispatch novo** só dos papéis em `gate3.redispatch`, com o `
49
49
 
50
50
  ## 6. Gate 3
51
51
 
52
- **A rodada completa:** com os relatórios sem bloqueador, `morph-spec verify {feature} --ci --json --detach` em `run_in_background` (sem CI, roda local e diz por quê; repetir o comando retoma a rodada). É a única. Sem `--filter`, sempre, e sem `--affected`: verde filtrado é verde sobre código não testado, e é ela que o gate lê. `reason: "stamp-reused"` = nada andou desde o último verde: vale. Saída `CI rodando` → espere a notificação. `fail` → corrija (CI: `gh run view <id> --log-failed`) e siga o §5, porque a correção é código que ninguém revisou. Nó `e2e` em `skip` → o `gate-check` decide se dispensa (`classifyE2eSkip`, em `risk-detector.js`: docker indisponível pausa sozinho).
52
+ **A rodada completa:** com os relatórios sem bloqueador, `morph-spec verify {feature} --ci --json --detach` em `run_in_background` (sem CI, roda local e diz por quê; repetir o comando retoma a rodada). É a única. Sem `--filter`, sempre, e sem `--affected`: verde filtrado é verde sobre código não testado, e é ela que o gate lê. `reason: "stamp-reused"` = nada andou desde o último verde: vale. Saída `CI rodando` → espere a notificação. `fail` → corrija (CI: `gh run view <id> --log-failed`) e siga o §5, porque a correção é código que ninguém revisou. Nó `e2e` em `skip` → o `gate-check` decide se dispensa (`classifyE2eSkip`, em `risk-detector.js`: docker indisponível pausa sozinho). Com `project.prChecks`, o que a PR prova fica de fora e o `finish --pr` espera os checks (`SKILL.md` §6).
53
53
 
54
54
  `morph-spec gate-check {feature} review [--json]` lê os relatórios, a cobertura dos `Cn` e os demais sinais. Forçar passagem apesar da pausa: `morph-spec gate-decision {feature} review --pause false --reason "…"`.
55
55
 
@@ -4,7 +4,7 @@ description: Leva um pedido ou o link de uma issue ao plano aprovado — roteia
4
4
  version: core
5
5
  when_to_use: Use quando uma feature, bug ou chore começar ("/morph-plan {feature}", "/morph-plan <link da issue>", "vamos planejar X"), ou ao retomar uma feature cujo gate plan ainda não foi aprovado
6
6
  user-invocable: true
7
- cliVersion: "10.5.4"
7
+ cliVersion: "10.6.0"
8
8
  ---
9
9
 
10
10
  # morph-plan
@@ -4,7 +4,7 @@ description: Conduz a fase de UI/UX de uma feature com plano aprovado — render
4
4
  version: core
5
5
  when_to_use: Use quando o ramo de design for tomado (morph-spec advance {feature} --design), antes de /morph-apply
6
6
  user-invocable: true
7
- cliVersion: "10.5.4"
7
+ cliVersion: "10.6.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": "10.5.4",
3
+ "version": "10.6.0",
4
4
  "description": "MORPH-SPEC: NLH (Natural Language Harness) for spec-driven development with Claude Code",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -24,6 +24,9 @@ import { runArchive } from './archive.js';
24
24
  import { dropLeftoverIgnore, withoutLeftoverIgnore } from '../../../framework/hooks/shared/graph-freshness.js';
25
25
  import { newBackupStamp, enforceBackupRetention, inspectFinishBackups } from '../../lib/worktree/finish-backup.js';
26
26
  import { isTestArtifact } from '../../lib/worktree/ignored-triage.js';
27
+ import { waitForPrChecks } from '../../lib/ci/pr-checks.js';
28
+ import { atomicWriteJson } from '../../core/state/state-manager.js';
29
+ import { recordGateEvent } from '../../../framework/hooks/shared/gate-event.js';
27
30
 
28
31
  const PR_TYPE_BY_WORK_TYPE = { feature: 'feat', bug: 'fix', hotfix: 'fix', chore: 'chore' };
29
32
 
@@ -253,7 +256,8 @@ export async function finishCommand(feature, options = {}) {
253
256
  });
254
257
  process.exit(1);
255
258
  }
256
- if (openPr) {
259
+ // A run killed while waiting left the PR open and the proof unread: this run picks the wait up.
260
+ if (openPr && !readFeatureJson(featureCwd, feature, 'archive')?.verification?.pendingPr) {
257
261
  emit({
258
262
  finished: false,
259
263
  error: 'already-finished',
@@ -278,6 +282,16 @@ export async function finishCommand(feature, options = {}) {
278
282
  emit({ finished: false, error: 'review-not-approved', feature });
279
283
  process.exit(1);
280
284
  }
285
+ const pendingPr = fj?.verification?.pendingPr ?? null;
286
+ if (mode === 'merge' && pendingPr && !options.force) {
287
+ emit({
288
+ finished: false,
289
+ error: 'pr-proof-pending',
290
+ feature,
291
+ hint: `${pendingPr.covers.join(', ')} só seriam provadas pelo CI da PR, e --merge não abre PR. Rode \`morph-spec verify ${feature}\` (sem --ci), \`gate-check ${feature} review\` e o finish de novo`,
292
+ });
293
+ process.exit(1);
294
+ }
281
295
  const prTitle = buildPrTitle(feature, fj);
282
296
  const prBody = (fj?.description && String(fj.description).trim())
283
297
  ? String(fj.description).trim()
@@ -364,7 +378,7 @@ export async function finishCommand(feature, options = {}) {
364
378
  if (mode === 'merge') {
365
379
  return integrateMerge({ emit, root, feature, expectedBranch, defaultBranch, isWorktreeFlow, worktreePath, options, arch, backupStamp });
366
380
  }
367
- return integratePr({ emit, root, feature, expectedBranch, defaultBranch, isWorktreeFlow, worktreePath, options, prTitle, prBody, arch, backupStamp });
381
+ return integratePr({ emit, root, feature, expectedBranch, defaultBranch, isWorktreeFlow, worktreePath, options, prTitle, prBody, arch, backupStamp, pendingPr, featureTree: cleanCwd });
368
382
  }
369
383
 
370
384
  // A junction/symlink in the worktree points at content in the ROOT, and `rm -rf` or
@@ -600,7 +614,43 @@ export function prNumberFromUrl(url) {
600
614
  return m ? Number(m[1]) : null;
601
615
  }
602
616
 
603
- async function integratePr({ emit, root, feature, expectedBranch, defaultBranch, isWorktreeFlow, worktreePath, options, prTitle, prBody, arch, backupStamp }) {
617
+ // The checks of the archive commit are the proof: a second push of the same code would pay the CI twice,
618
+ // so a red or unjudged run undoes the archive here and the feature goes back to the session.
619
+ function reopenFeature({ tree, feature, check, ciRun, sha }) {
620
+ const archiveCommit = execFileSync('git', ['log', '-1', '--format=%H', '--fixed-strings', `--grep=chore(morph): archive ${feature}`], { cwd: tree, stdio: 'pipe' }).toString().trim();
621
+ execFileSync('git', ['revert', '--no-edit', archiveCommit], { cwd: tree, stdio: 'pipe' });
622
+
623
+ const path = join(tree, '.morph', 'features', feature, 'feature.json');
624
+ const fj = JSON.parse(readFileSync(path, 'utf8'));
625
+ const now = new Date().toISOString();
626
+ const reason = check.state === 'fail'
627
+ ? `CI da PR vermelho: ${check.failed.map((c) => c.workflowName).join(', ')}`
628
+ : `CI da PR sem veredito: ${check.reason}`;
629
+ fj.approvalGates = { ...fj.approvalGates, review: { approved: false, timestamp: now, approvedBy: null, revokedBy: 'finish', revokedAt: now, reason } };
630
+ if (check.state === 'fail') {
631
+ const { pendingPr: _, ...stamp } = fj.verification;
632
+ fj.verification = { ...stamp, status: 'fail', nodes: { ...stamp.nodes, tests: 'fail' }, sha, ts: now, source: 'pr', ciRun };
633
+ const entry = fj.verifications?.feature;
634
+ if (entry) fj.verifications.feature = { ...entry, status: 'fail', failStreak: (entry.failStreak ?? 0) + 1, updatedAt: now };
635
+ } else {
636
+ delete fj.verification;
637
+ }
638
+ atomicWriteJson(path, fj);
639
+ recordGateEvent(tree, { feature, gate: 'review', step: 'unapprove', approvedBy: null });
640
+ commitFeatureState(tree, feature);
641
+ return reason;
642
+ }
643
+
644
+ // The revert stays local (a push would pay another CI run), so the reopened PR still holds the archive:
645
+ // as a draft it cannot be merged by accident, on a plan without branch protection.
646
+ function setPrDraft(root, prUrl, draft) {
647
+ try {
648
+ const { bin, prefix } = resolveGh();
649
+ execFileSync(bin, [...prefix, 'pr', 'ready', prUrl, ...(draft ? ['--undo'] : [])], { cwd: root, stdio: 'pipe', timeout: 15000 });
650
+ } catch { /* gh refuses a PR already in that state */ }
651
+ }
652
+
653
+ async function integratePr({ emit, root, feature, expectedBranch, defaultBranch, isWorktreeFlow, worktreePath, options, prTitle, prBody, arch, backupStamp, pendingPr, featureTree }) {
604
654
  try {
605
655
  execFileSync('git', ['push', '-u', 'origin', expectedBranch], { cwd: root, stdio: 'pipe' });
606
656
  } catch (err) {
@@ -608,13 +658,48 @@ async function integratePr({ emit, root, feature, expectedBranch, defaultBranch,
608
658
  process.exit(1);
609
659
  }
610
660
 
611
- let prUrl = null;
612
- try {
613
- const { bin, prefix } = resolveGh();
614
- prUrl = execFileSync(bin, [...prefix, 'pr', 'create', '--base', defaultBranch, '--head', expectedBranch, '--title', prTitle, '--body', prBody], { cwd: root, stdio: 'pipe' }).toString().trim();
615
- } catch (err) {
616
- emit({ finished: false, error: 'pr-create-failed', output: gitErr(err), hint: 'a branch foi enviada (push OK); abra o PR manualmente com `gh pr create`', feature, pushed: expectedBranch });
617
- process.exit(1);
661
+ // A feature reopened by a red PR comes back here with that PR still open.
662
+ const openPr = openPrFor(expectedBranch, root);
663
+ let prUrl = typeof openPr === 'string' ? openPr : null;
664
+ if (prUrl) setPrDraft(root, prUrl, false);
665
+ if (!prUrl) {
666
+ try {
667
+ const { bin, prefix } = resolveGh();
668
+ prUrl = execFileSync(bin, [...prefix, 'pr', 'create', '--base', defaultBranch, '--head', expectedBranch, '--title', prTitle, '--body', prBody], { cwd: root, stdio: 'pipe' }).toString().trim();
669
+ } catch (err) {
670
+ emit({ finished: false, error: 'pr-create-failed', output: gitErr(err), hint: 'a branch foi enviada (push OK); abra o PR manualmente com `gh pr create`', feature, pushed: expectedBranch });
671
+ process.exit(1);
672
+ }
673
+ }
674
+
675
+ let checks = null;
676
+ if (pendingPr) {
677
+ const sha = execFileSync('git', ['rev-parse', expectedBranch], { cwd: root, stdio: 'pipe' }).toString().trim();
678
+ const check = await waitForPrChecks(root, { branch: expectedBranch, sha, workflows: pendingPr.workflows });
679
+ if (check.state === 'fail' || check.state === 'no-verdict') {
680
+ let reason;
681
+ try {
682
+ reason = reopenFeature({ tree: featureTree, feature, check, ciRun: check.failed?.[0]?.detailsUrl ?? prUrl, sha });
683
+ } catch (err) {
684
+ emit({ finished: false, error: 'reopen-failed', output: gitErr(err), hint: `CI da PR sem verde (${check.state}) e o archive não foi desfeito: não mergeie ${prUrl}; rode \`git revert\` do commit de archive em ${featureTree}`, feature });
685
+ process.exit(1);
686
+ }
687
+ const red = check.state === 'fail';
688
+ setPrDraft(root, prUrl, true);
689
+ emit({
690
+ finished: false,
691
+ error: red ? 'pr-checks-fail' : 'pr-checks-no-verdict',
692
+ feature,
693
+ pr: { number: prNumberFromUrl(prUrl), url: prUrl },
694
+ reason,
695
+ ...(red ? { failed: check.failed.map((c) => ({ workflow: c.workflowName, url: c.detailsUrl })) } : {}),
696
+ hint: red
697
+ ? 'archive desfeito e Gate 3 reaberto: corrija (`gh run view <id> --log-failed`), commite e siga review.md §5; o finish reaproveita esta PR'
698
+ : `archive desfeito e Gate 3 reaberto: rode \`morph-spec verify ${feature} --json --detach\` (sem --ci, a completa local), \`gate-check ${feature} review\`, approve e o finish de novo; o finish reaproveita esta PR`,
699
+ });
700
+ process.exit(1);
701
+ }
702
+ checks = check.state === 'pass' ? { status: 'pass' } : { status: 'pending', reason: check.reason };
618
703
  }
619
704
 
620
705
  // The worktree flow keeps the branch (the open PR needs it); the common flow stays on morph/{f},
@@ -654,7 +739,10 @@ async function integratePr({ emit, root, feature, expectedBranch, defaultBranch,
654
739
  pushed: expectedBranch,
655
740
  base: defaultBranch,
656
741
  pr: { number: prNumber, url: prUrl },
657
- nextStep: `mergeie ${prLabel} — a feature só estará encerrada quando ${defaultBranch} receber o archive pelo merge`,
742
+ ...(checks ? { checks } : {}),
743
+ nextStep: checks?.status === 'pending'
744
+ ? `confira \`gh pr checks ${prNumber ?? expectedBranch}\` antes de mergear ${prLabel}: a suíte ainda não respondeu`
745
+ : `mergeie ${prLabel} — a feature só estará encerrada quando ${defaultBranch} receber o archive pelo merge`,
658
746
  ...adrIndexNote(arch),
659
747
  ...(shedBackup ? { shedBackup } : {}),
660
748
  ...(e2e ? { e2e } : {}),
@@ -13,7 +13,7 @@ import { HeavyBusyError } from '../../lib/heavy/gate.js';
13
13
  import { afterHeavyRun } from '../../lib/heavy/cleanup.js';
14
14
  import { memoryBusyHint } from '../../lib/heavy/live-stacks.js';
15
15
  import { readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js';
16
- import { runCiVerify, CiUsageError, reusableLocalStamp } from '../../lib/ci/ci-verify.js';
16
+ import { runCiVerify, CiUsageError, reusableLocalStamp, resolvePrProof } from '../../lib/ci/ci-verify.js';
17
17
  import { syncStackRules } from '../../lib/installers/stack-sync.js';
18
18
  import {
19
19
  DETACH_RUN_ENV, DETACH_STAGE_ENV, DETACH_WORKER_ENV, launchWorker, markWorkerExit, runFilesOf, superviseVerify,
@@ -137,6 +137,7 @@ async function runLocalVerify(featureName, options, fingerprint) {
137
137
  fingerprint,
138
138
  fresh: options.fresh,
139
139
  affected: options.affected,
140
+ excludeNodes: options.excludeNodes,
140
141
  timeout: options.timeout,
141
142
  skipTests: options.skipTests,
142
143
  skipBuild: options.skipBuild,
@@ -231,14 +232,19 @@ export async function verifyCommand(featureName, options = {}) {
231
232
  }
232
233
 
233
234
  let ci = null;
235
+ let prProof = null;
234
236
  if (options.ci) {
235
237
  const fresh = reusableLocalStamp(process.cwd(), feature.verification);
236
238
  if (fresh) exitWithReusedStamp(featureName, fresh, 'stamp verde e fresco — o CI não roda de novo', options);
239
+ prProof = resolvePrProof(process.cwd(), featureName, readMorphConfig(process.cwd()));
240
+ if (prProof?.local) console.error(chalk.yellow(`\n↪ completa local: ${prProof.local}\n`));
237
241
  try {
238
- const outcome = await runCiVerify(process.cwd(), featureName, readMorphConfig(process.cwd()), {
242
+ const outcome = prProof ? null : await runCiVerify(process.cwd(), featureName, readMorphConfig(process.cwd()), {
239
243
  log: (line) => console.error(chalk.gray(` ${line}`)),
240
244
  });
241
- if (outcome.mode === 'ci') {
245
+ if (!outcome) {
246
+ // The PR proof has no run to wait for here: `finish --pr` waits for it.
247
+ } else if (outcome.mode === 'ci') {
242
248
  ci = outcome;
243
249
  } else if (outcome.mode === 'pending') {
244
250
  if (options.json) console.log(JSON.stringify({ status: 'pending', persisted: false, reason: outcome.reason }, null, 2));
@@ -259,7 +265,8 @@ export async function verifyCommand(featureName, options = {}) {
259
265
  // Taken before the run: it is the tree the run is about to read.
260
266
  const fingerprint = treeFingerprint(process.cwd());
261
267
 
262
- const result = ci ? ci.result : await runLocalVerify(featureName, options, fingerprint);
268
+ const pendingPr = prProof?.covers ? prProof : null;
269
+ const result = ci ? ci.result : await runLocalVerify(featureName, { ...options, excludeNodes: pendingPr?.covers }, fingerprint);
263
270
 
264
271
  const cleanup = ci ? { status: 'skipped', reason: 'ci' } : afterHeavyRun({
265
272
  projectPath: process.cwd(),
@@ -305,6 +312,7 @@ export async function verifyCommand(featureName, options = {}) {
305
312
  ts: now,
306
313
  ...(result.flaky?.length ? { flaky: result.flaky } : {}),
307
314
  ...(ci ? { source: 'ci', ciRun: ci.run.url } : {}),
315
+ ...(pendingPr && result.status === 'pass' ? { pendingPr } : {}),
308
316
  };
309
317
  if (!scopedRun) await updateFeature(featureName, 'verification', stamp);
310
318
  }
@@ -334,6 +342,7 @@ export async function verifyCommand(featureName, options = {}) {
334
342
  Array.isArray(node.runs) ? { ...withoutOutput(node), runs: node.runs.map(withoutOutput) } : withoutOutput(node)]));
335
343
  const output = {
336
344
  ...result, nodes, persisted: persist, ...(persist ? {} : { reason }), repeatOf, cleanup,
345
+ ...(pendingPr ? { pendingPr } : {}),
337
346
  ...(ci ? { source: 'ci', ciRun: { id: ci.run.databaseId, url: ci.run.url, sha: ci.run.headSha }, workflowCreated: ci.created } : {}),
338
347
  };
339
348
  console.log(JSON.stringify(output, null, 2));
@@ -8,7 +8,10 @@ import {
8
8
  CI_WORKFLOW_DIR, CI_WORKFLOW_FILE, ciAvailability, downloadResult, findCiWorkflow, pushBranch,
9
9
  uncommittedCode, waitForRun, workflowPushProblem,
10
10
  } from './github-ci.js';
11
+ import { prProofConfig } from './pr-checks.js';
11
12
  import { renderCiWorkflow } from './workflow-scaffold.js';
13
+ import { resolveTestNodes } from '../tasks/test-nodes.js';
14
+ import { selectAffected } from '../verify/affected.js';
12
15
 
13
16
  export const CI_WAIT_MS = 50 * 60 * 1000;
14
17
  const APPEAR_MS = Number(process.env.MORPH_CI_APPEAR_MS ?? 90 * 1000);
@@ -58,6 +61,19 @@ export function reusableLocalStamp(root, stamp) {
58
61
  return treeMovedSince(root, stamp.sha, head) ? null : stamp;
59
62
  }
60
63
 
64
+ /**
65
+ * `covers` = suites the PR's own checks prove, left out of the local run. `local` = run everything here,
66
+ * with the reason. Null = the project declared no PR proof. What the affected run would escalate stays local.
67
+ */
68
+ export function resolvePrProof(root, featureName, config, { select = selectAffected } = {}) {
69
+ const proof = prProofConfig(config);
70
+ if (!proof) return null;
71
+ const avail = ciAvailability(root, featureName, config);
72
+ if (!avail.ok) return { local: avail.reason };
73
+ const selection = select({ projectPath: root, nodes: resolveTestNodes(root).nodes });
74
+ return selection.scope === 'full' ? { local: selection.reason } : proof;
75
+ }
76
+
61
77
  // Every way CI cannot answer is `mode: 'local'` with the reason: no CI is never a pause. Only the
62
78
  // uploaded `verify --json` becomes a result, judged by the same stamp and rules as a local run.
63
79
  export async function runCiVerify(root, featureName, config, { log = () => {}, waitMs = CI_WAIT_MS, wait = waitForRun } = {}) {
@@ -0,0 +1,76 @@
1
+ import { execFileSync } from 'child_process';
2
+ import { resolveGh } from '../git/gh.js';
3
+
4
+ const FAILED = new Set(['FAILURE', 'TIMED_OUT']);
5
+ const PASSED = new Set(['SUCCESS', 'SKIPPED', 'NEUTRAL']);
6
+
7
+ // A job killed by exhausted Actions quota fails within seconds without running a step.
8
+ const MIN_JUDGED_MS = 30 * 1000;
9
+
10
+ export const PR_WAIT_MS = 50 * 60 * 1000;
11
+
12
+ /** The PR proof a project declared; null when it closes by merge, opted out of CI or declared nothing. */
13
+ export function prProofConfig(config) {
14
+ if (config?.project?.ci === false || config?.finish?.mode === 'merge') return null;
15
+ const { workflows, covers } = config?.project?.prChecks ?? {};
16
+ const filled = (list) => Array.isArray(list) && list.length > 0;
17
+ return filled(workflows) && filled(covers) ? { workflows, covers } : null;
18
+ }
19
+
20
+ function neverRan(check) {
21
+ const elapsed = Date.parse(check.completedAt) - Date.parse(check.startedAt);
22
+ return !Number.isFinite(elapsed) || elapsed < MIN_JUDGED_MS;
23
+ }
24
+
25
+ /** Only a declared workflow counts: a green lint or deploy run says nothing about the suite. */
26
+ export function judgeChecks(rollup, workflows) {
27
+ const checks = (rollup ?? []).filter((check) => workflows.includes(check.workflowName));
28
+ if (checks.length === 0) return { state: 'absent' };
29
+ if (checks.some((check) => check.status !== 'COMPLETED')) return { state: 'running' };
30
+
31
+ const failed = checks.filter((check) => FAILED.has(check.conclusion));
32
+ const real = failed.filter((check) => !neverRan(check));
33
+ if (real.length > 0) return { state: 'fail', failed: real };
34
+ if (failed.length > 0) return { state: 'no-verdict', reason: `${failed[0].workflowName} morreu sem rodar (cota do Actions?)` };
35
+
36
+ const undecided = checks.find((check) => !PASSED.has(check.conclusion));
37
+ if (undecided) return { state: 'no-verdict', reason: `${undecided.workflowName}: ${undecided.conclusion}` };
38
+ if (!checks.some((check) => check.conclusion === 'SUCCESS')) return { state: 'no-verdict', reason: 'nenhum check julgou o código' };
39
+ return { state: 'pass' };
40
+ }
41
+
42
+ function readPr(root, branch) {
43
+ try {
44
+ const { bin, prefix } = resolveGh();
45
+ return JSON.parse(execFileSync(bin, [...prefix, 'pr', 'view', branch, '--json', 'headRefOid,statusCheckRollup'], { cwd: root, stdio: 'pipe', timeout: 15000 }).toString());
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Waits `appearMs` even when the first workflow already settled: the others may not have been created yet.
53
+ * @returns {Promise<{ state: 'pass'|'fail'|'no-verdict'|'pending', failed?: object[], reason?: string }>}
54
+ */
55
+ export async function waitForPrChecks(root, { branch, sha, workflows, timeoutMs = PR_WAIT_MS,
56
+ appearMs = Number(process.env.MORPH_CI_APPEAR_MS ?? 90 * 1000), pollMs = 15000,
57
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now, read = readPr }) {
58
+ const start = now();
59
+ let misses = 0;
60
+ for (;;) {
61
+ const elapsed = now() - start;
62
+ const pr = read(root, branch);
63
+ if (pr === null) {
64
+ if (++misses >= 3) return { state: 'no-verdict', reason: 'gh pr view não respondeu' };
65
+ } else {
66
+ misses = 0;
67
+ const verdict = pr.headRefOid === sha ? judgeChecks(pr.statusCheckRollup, workflows) : { state: 'absent' };
68
+ if (elapsed >= appearMs) {
69
+ if (verdict.state === 'absent') return { state: 'no-verdict', reason: `nenhum check de ${workflows.join(', ')} neste commit` };
70
+ if (verdict.state !== 'running') return verdict;
71
+ }
72
+ }
73
+ if (elapsed >= timeoutMs) return { state: 'pending', reason: `checks não terminaram em ${Math.round(timeoutMs / 60000)} min` };
74
+ await sleep(pollMs);
75
+ }
76
+ }
@@ -105,7 +105,9 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
105
105
  // Usage errors are judged before the build: paying for a build to discover a typo
106
106
  // is the worst ordering, and a refusal after the cache was touched leaves a half-rewritten tree.
107
107
  const resolvedPlan = resolveTestNodes(projectPath);
108
- const testPlan = options.selection ? { ...resolvedPlan, nodes: options.selection.nodes } : resolvedPlan;
108
+ const excluded = new Set(options.excludeNodes ?? []);
109
+ const scopedNodes = options.selection ? options.selection.nodes : resolvedPlan.nodes;
110
+ const testPlan = { ...resolvedPlan, nodes: scopedNodes.filter((node) => !excluded.has(node.id)) };
109
111
  const filter = parseTestFilter(options.filter);
110
112
  if (filter && !testPlan.nodes.some((node) => nodeMatchesFilter(node, filter))) {
111
113
  const declared = testPlan.nodes.map((node) => node.id).join(', ') || '(none)';
@@ -150,10 +152,11 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
150
152
  const kept = options.selection.nodes.map((n) => n.id).join(', ') || 'nenhuma';
151
153
  feedback.push(`[verify] rodada afetada, suítes: ${kept}; a completa roda uma vez antes da aprovação`);
152
154
  }
155
+ if (excluded.size > 0) feedback.push(`[verify] provadas pelo CI da PR no finish: ${[...excluded].join(', ')}`);
153
156
 
154
157
  // Only a complete run feeds or reads the cache: a partial one proves nothing about the nodes it left out.
155
158
  const cacheDir = fingerprint && !filter && !skipTests && !skipBuild && !skipE2e && !recordBaseline
156
- && !refreshCache && !linux && !freshE2e
159
+ && !refreshCache && !linux && !freshE2e && excluded.size === 0
157
160
  ? resolveGitCommonDir(projectPath)
158
161
  : null;
159
162
  const reuse = planReuse(cacheDir && !fresh ? readVerifyCache(cacheDir, featureName) : null, fingerprint);
@@ -172,7 +175,7 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
172
175
  projectPath,
173
176
  testPlan,
174
177
  filter,
175
- skipTests,
178
+ skipTests: skipTests || (excluded.size > 0 && testPlan.nodes.length === 0),
176
179
  buildNode: nodes.build,
177
180
  timeout: explicitTimeout,
178
181
  refreshCache,