@polymorphism-tech/morph-spec 8.24.2 → 8.25.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/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.24.2
6
+ **Version:** 8.25.0
7
7
  **Requires:** Node.js 22+, Claude Code
8
8
 
9
9
  ---
@@ -368,4 +368,4 @@ Code generated by morph-spec (contracts, templates, implementation output) belon
368
368
 
369
369
  ---
370
370
 
371
- *morph-spec v8.24.2 by [Polymorphism Tech](https://polymorphism.tech)*
371
+ *morph-spec v8.25.0 by [Polymorphism Tech](https://polymorphism.tech)*
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "morph-spec",
3
- "version": "8.24.2",
3
+ "version": "8.25.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.24.2 by Polymorphism Tech*
188
+ *morph-spec v8.25.0 by Polymorphism Tech*
@@ -249,4 +249,4 @@ morph-spec doctor
249
249
 
250
250
  ---
251
251
 
252
- *morph-spec v8.24.2 by Polymorphism Tech*
252
+ *morph-spec v8.25.0 by Polymorphism Tech*
@@ -21,7 +21,7 @@
21
21
 
22
22
  ## Worktree / Fleet
23
23
 
24
- - **`morph-spec finish <feature> --pr|--merge`** — o **encerramento único** da feature (worktree ou comum): **arquiva** commitando `features/{f} → archive/{f}` dentro de `morph/{f}` — promovendo junto, no mesmo commit, os use cases da feature para o acervo permanente `.morph/specs/usecases/` com `status: implemented` (fail-open: sem `2-plan/usecases/`, no-op silencioso) — depois integra. Um `UC-{nn}` cuja promoção colide com outra feature já publicada sob o mesmo número (`ucCollisions` no output; ver `usecase reserve` abaixo) **não é promovido**, mas isso nunca recusa o `finish` — só aquele use case fica de fora, com a mensagem apontando onde o arquivo ficou e como recuperar. `--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). Desmonta o worktree (PROVE-or-DECLINE) e libera o bloco de portas; no `--pr` **preserva** a branch (o PR precisa dela) e fica em `morph/{f}`. Flags: `--keep` mantém o worktree, `--force` dispensa o Gate 3, `--discard-ignored` consente descartar gitignored no teardown. Substitui o antigo `worktree finish` + `archive` separados.
24
+ - **`morph-spec finish <feature> --pr|--merge`** — o **encerramento único** da feature (worktree ou comum): **arquiva** commitando `features/{f} → archive/{f}` dentro de `morph/{f}` — promovendo junto, no mesmo commit, os use cases da feature para o acervo permanente `.morph/specs/usecases/` com `status: implemented` (fail-open: sem `2-plan/usecases/`, no-op silencioso) — depois integra. Um `UC-{nn}` cuja promoção colide com outra feature já publicada sob o mesmo número (`ucCollisions` no output; ver `usecase reserve` abaixo) **não é promovido**, mas isso nunca recusa o `finish` — só aquele use case fica de fora, com a mensagem apontando onde o arquivo ficou e como recuperar. `--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). Desmonta o worktree (PROVE-or-DECLINE) e libera o bloco de portas; no `--pr` **preserva** a branch (o PR precisa dela) e fica em `morph/{f}`. Flags: `--keep` mantém o worktree, `--force` dispensa o Gate 3, `--discard-ignored` consente descartar gitignored no teardown. Substitui o antigo `worktree finish` + `archive` separados. **Retomável:** conflito de merge, checkout ou push falham *depois* do archive (é o commit do archive que viaja com a branch) — re-executar o `finish` retoma na integração e sai com `resumed: true`, em vez de recusar por feature ausente. Se a integração já tiver acontecido, recusa com `already-finished` — e o que conta como "já integrada" depende do modo: no `--merge`, branch contida na default; no `--pr`, PR aberto para `morph/{f}` (a branch de um `--pr` concluído fica não-mergeada de propósito, então só o teste de ancestralidade deixaria passar um segundo `gh pr create`). Sem conseguir provar nem uma coisa nem outra, recusa com `resume-undecidable` em vez de retomar às cegas.
25
25
  - **`morph-spec usecase reserve [--count <n>] [--feature <name>] [--json]`** — reserva atomicamente os próximos N números `UC-{nn}`, compartilhado por **todo worktree** deste repo (`<git-common-dir>/morph-spec/uc-registry.json`, mesmo mecanismo do `claim`/env-registry do fleet). Substitui o scan manual "leia o acervo + as features da árvore e some 1": um scan só enxerga a ÁRVORE CORRENTE, então dois worktrees planejando ao mesmo tempo mintavam o MESMO número — colisão que a promoção do `finish` resolvia sobrescrevendo por ID, apagando o use case do primeiro a chegar (já custou perda de dados em produção). O contador nunca regride (semeado do maior número já visto no acervo + nas features da árvore + no próprio registro). `--feature` é mais que auditoria: é o sinal que o guarda de colisão do `finish`/`archive` usa para distinguir uma correção legítima (`bug`, nunca reserva — reaproveita um número publicado) de uma colisão real (a feature reservou o número e o acervo já tem outra dona nele); sem `--feature`, infere a única feature ativa se for inequívoco. Fora de um repositório git: cai para o scan local (avisa no output, `shared: false`) — ainda evita autocolisão dentro da mesma árvore, mas sem a garantia entre worktrees.
26
26
  - **`morph-spec archive <feature>`** — arquiva-**sem-integrar** (atômico, git-aware, commita o move escopado por pathspec): reservado a features legadas (sem branch `morph/{f}`) e faxina. O fechamento normal (arquiva **e** integra num passo) é `morph-spec finish`. É o que `/morph-archive` chama.
27
27
  - **`morph-spec worktree setup|link|remove|list`** — gerencia o ciclo do worktree por feature (`setup` cria `worktrees/{f}/` na branch `morph/{f}`, aloca o bloco de portas e escreve `.morph/worktree.env`; `link` religa infra compartilhada; `list` mostra fase/gate de cada worktree ativo). **Fechar a feature** continua sendo `morph-spec finish` (acima).
@@ -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.24.2"
7
+ cliVersion: "8.25.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.24.2"
7
+ cliVersion: "8.25.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.24.2"
7
+ cliVersion: "8.25.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.24.2"
7
+ cliVersion: "8.25.0"
8
8
  ---
9
9
 
10
10
  # morph-brainstorming
@@ -4,7 +4,7 @@ description: Avalia a qualidade de uma tarefa ou fase com rubricas graduadas 0-1
4
4
  version: core
5
5
  when-to-invoke: ao completar uma tarefa ou ao finalizar uma fase de implementação
6
6
  user-invokable: false
7
- cliVersion: "8.24.2"
7
+ cliVersion: "8.25.0"
8
8
  ---
9
9
 
10
10
  # morph-eval
@@ -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.24.2"
7
+ cliVersion: "8.25.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.24.2"
7
+ cliVersion: "8.25.0"
8
8
  ---
9
9
 
10
10
  # morph-implement
@@ -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.24.2"
7
+ cliVersion: "8.25.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.24.2"
7
+ cliVersion: "8.25.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.24.2"
7
+ cliVersion: "8.25.0"
8
8
  ---
9
9
 
10
10
  # morph-review
@@ -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.24.2"
7
+ cliVersion: "8.25.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.24.2"
7
+ cliVersion: "8.25.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.24.2"
7
+ cliVersion: "8.25.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.24.2",
3
+ "version": "8.25.0",
4
4
  "description": "MORPH-SPEC: NLH (Natural Language Harness) for spec-driven development with Claude Code",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -968,11 +968,23 @@ export function shedRootFeatureScope(root, feature) {
968
968
  * from blocking every other feature's finish). `ok:false` means the status call
969
969
  * itself failed; an empty `dirt` means clean enough to merge into.
970
970
  *
971
+ * `exemptOwnScope` additionally forgives everything under
972
+ * `.morph/features/{feature}/` — the EXACT pathspec `shedRootFeatureScope`
973
+ * backs up and discards. It exists so `finish` can ask "is the root clean
974
+ * enough to merge into?" BEFORE archiving anything, where the shed has not run
975
+ * yet and the feature's own scaffold is still on disk. Without it that early
976
+ * question answers "dirty" for a root that the shed would have cleaned, and a
977
+ * finish that works today would start being refused.
978
+ *
979
+ * Callers must keep the post-shed check: this one is an early refusal so the
980
+ * archive never happens, not the authoritative gate.
981
+ *
971
982
  * @param {string} root
972
983
  * @param {string} feature - the feature THIS finish acts on (its own scope is the shed's job)
984
+ * @param {{ exemptOwnScope?: boolean }} [opts]
973
985
  * @returns {{ ok: boolean, dirt: string[] }}
974
986
  */
975
- export function primaryRootUnexplainedDirt(root, feature) {
987
+ export function primaryRootUnexplainedDirt(root, feature, opts = {}) {
976
988
  let rootStatusRaw;
977
989
  try {
978
990
  // `--untracked-files=all`, not the default: the default collapses an
@@ -982,9 +994,11 @@ export function primaryRootUnexplainedDirt(root, feature) {
982
994
  } catch {
983
995
  return { ok: false, dirt: [] };
984
996
  }
997
+ const ownScope = `.morph/features/${feature}/`;
985
998
  const dirt = rootStatusRaw.split('\0').filter((record) => {
986
999
  if (!record || record.length < 4) return false;
987
1000
  const p = record.slice(3).replace(/\\/g, '/');
1001
+ if (opts.exemptOwnScope && p.startsWith(ownScope)) return false;
988
1002
  return !isLiveSiblingWorktreeRegistration(p, root, feature);
989
1003
  });
990
1004
  return { ok: true, dirt };
@@ -78,6 +78,14 @@ export function buildPrTitle(feature, fj) {
78
78
  * `finished` is the integration (the actual value); `worktreeRemoved` is the
79
79
  * best-effort teardown — two independent signals, as before.
80
80
  *
81
+ * RESUMABLE. `merge-conflict`, `checkout-default-failed`, `push-failed` and
82
+ * `pr-create-failed` all fail AFTER the archive, and legitimately so: the
83
+ * archive commit is what travels with the branch. Re-running then found no
84
+ * `features/{f}` and refused from scratch, so every mid-way failure demanded a
85
+ * hand repair. Now a second run that finds the feature ARCHIVED on its own
86
+ * branch, not yet merged into the default, picks up at the integration and says
87
+ * `resumed: true` in its output — see Validation 0c.
88
+ *
81
89
  * @param {string} feature
82
90
  * @param {{ pr?: boolean, merge?: boolean, keep?: boolean, force?: boolean,
83
91
  * discardIgnored?: boolean, cwd?: string }} [options]
@@ -98,7 +106,15 @@ export async function finishCommand(feature, options = {}) {
98
106
  // containers and their ports when the feature closes. Best-effort by
99
107
  // contract — a stack that refuses to come down must never fail a finish that
100
108
  // already merged.
109
+ // Set by Validation 0c when this run picks up an already-archived feature.
110
+ // Stamped onto EVERY payload emitted from that point on, success or refusal:
111
+ // a resume merges a branch the user did not archive in this run, and that has
112
+ // to be visible. Someone who mistypes a name and lands on an archived feature
113
+ // would otherwise get a silent merge they never asked for.
114
+ let resumed = false;
115
+
101
116
  const emit = (obj) => {
117
+ if (resumed) obj = { ...obj, resumed: true };
102
118
  if (obj?.finished === true) {
103
119
  try {
104
120
  const common = execFileSync('git', ['rev-parse', '--git-common-dir'], { cwd: root, stdio: 'pipe' }).toString().trim();
@@ -180,6 +196,14 @@ export async function finishCommand(feature, options = {}) {
180
196
  // merge). A feature with NO morph/{f} branch was born before this flow —
181
197
  // route it explicitly to archive-without-integrate, never a generic refusal.
182
198
  if (!branchExists(expectedBranch, root)) {
199
+ // No branch AND no feature on disk is not a legacy feature — it is a
200
+ // feature that is not here at all (wrong name, wrong tree, or already
201
+ // archived). Routing that to `archive legacy` would tell the user to
202
+ // archive something that does not exist.
203
+ if (!existsSync(join(root, '.morph', 'features', feature))) {
204
+ emit({ finished: false, error: 'feature-not-found', feature, ...featureElsewhereHint(root, root, feature) });
205
+ process.exit(1);
206
+ }
183
207
  emit({
184
208
  finished: false,
185
209
  error: 'no-feature-branch',
@@ -203,9 +227,128 @@ export async function finishCommand(feature, options = {}) {
203
227
  featureCwd = root;
204
228
  }
205
229
 
206
- // Validation 1: Gate 3 (review) approved on the feature's own feature.json.
207
- const fj = readFeatureJson(featureCwd, feature);
208
- if (!options.force && !(fj?.approvalGates?.review?.approved === true || fj?.approvals?.review?.approved === true)) {
230
+ // Validation 0c: the feature must still be in `features/` — or, if it is not,
231
+ // this run has to decide which of three states it is in before doing anything:
232
+ // gone entirely, already ARCHIVED and awaiting integration (resume), or
233
+ // already integrated (nothing to do).
234
+ //
235
+ // Originally all three arrived as `review-not-approved`, because a missing
236
+ // feature.json parses to null and the optional chaining below fell straight
237
+ // through — being told "Gate 3 not approved" about an approved gate is what
238
+ // sent the user looking in the wrong place after an interrupted finish.
239
+ //
240
+ // Keyed on the DIRECTORY, not on the parse: readFeatureJson also returns null
241
+ // for malformed JSON, and a legacy feature with a folder but no (or broken)
242
+ // feature.json is a real thing that `--force` is expected to push through.
243
+ if (!existsSync(join(featureCwd, '.morph', 'features', feature))) {
244
+ const archivePath = join(featureCwd, '.morph', 'archive', feature);
245
+ if (!existsSync(archivePath)) {
246
+ emit({ finished: false, error: 'feature-not-found', feature, ...featureElsewhereHint(featureCwd, root, feature) });
247
+ process.exit(1);
248
+ }
249
+
250
+ // ── RESUME ──────────────────────────────────────────────────────────────
251
+ // Archived HERE, on the branch this finish would merge — every path that
252
+ // reaches this line has already proved we are standing on `morph/{f}`
253
+ // (worktree flow: found.branch === expectedBranch; common flow: branchExists
254
+ // + currentBranch === expectedBranch). So the archive step is done and only
255
+ // the integration is missing. Which is exactly what a failed merge/push
256
+ // leaves behind, and what used to require repairing by hand.
257
+ //
258
+ // The one thing still to establish is whether the integration ALSO
259
+ // happened. A branch already contained in the default is a finish that
260
+ // COMPLETED (`--keep`, or a `--pr` whose PR was merged) — re-merging it is
261
+ // the hazard this check exists to stop, and it is why resume is keyed on
262
+ // the branch's relationship to the default rather than on the archive alone.
263
+ const target = detectDefaultBranch(root);
264
+ if (!target) {
265
+ emit({ finished: false, error: 'no-default-branch', hint: 'não foi possível resolver a branch default (origin/HEAD, main ou master)', feature });
266
+ process.exit(1);
267
+ }
268
+ const merged = branchMergedInto(expectedBranch, target, root);
269
+ if (merged === null) {
270
+ // PROVE-or-DECLINE, same discipline as the worktree teardown: we could not
271
+ // establish the branch's state, so we do not act on a guess. Refusing is
272
+ // recoverable; a duplicate merge is not.
273
+ emit({
274
+ finished: false,
275
+ error: 'resume-undecidable',
276
+ feature,
277
+ branch: expectedBranch,
278
+ base: target,
279
+ archived: archivePath.replace(/\\/g, '/'),
280
+ hint: `'${feature}' está arquivada, mas não foi possível comparar ${expectedBranch} com ${target} (ref ausente?) — sem saber se o merge já aconteceu, retomar arriscaria mergear duas vezes. Verifique o estado do git antes de integrar à mão`,
281
+ });
282
+ process.exit(1);
283
+ }
284
+ if (merged) {
285
+ emit({
286
+ finished: false,
287
+ error: 'already-finished',
288
+ feature,
289
+ branch: expectedBranch,
290
+ base: target,
291
+ archived: archivePath.replace(/\\/g, '/'),
292
+ hint: `'${feature}' já foi arquivada E ${expectedBranch} já está contida em ${target} — o finish anterior concluiu. Nada a integrar.`,
293
+ });
294
+ process.exit(1);
295
+ }
296
+
297
+ // "Already integrated" is MODE-SPECIFIC, and the ancestor test above only
298
+ // answers it for `--merge`. A `--pr` that completed leaves its branch
299
+ // deliberately UNMERGED (that is what the open PR is for), so the ancestor
300
+ // test says "not merged" and would wave the resume through — straight into
301
+ // a second `gh pr create` on a head that already has one. The common flow
302
+ // makes that trivially reachable: integratePr never leaves `morph/{f}`, so
303
+ // simply re-running the command lands here.
304
+ //
305
+ // `gh pr create` refuses a duplicate, so the damage was never two PRs — it
306
+ // was `pr-create-failed` standing in for "this already finished", which is
307
+ // the same collapsed diagnosis the `feature-not-found` split removed.
308
+ if (mode === 'pr') {
309
+ const openPr = openPrFor(expectedBranch, root);
310
+ if (openPr === null) {
311
+ emit({
312
+ finished: false,
313
+ error: 'resume-undecidable',
314
+ feature,
315
+ branch: expectedBranch,
316
+ archived: archivePath.replace(/\\/g, '/'),
317
+ hint: `'${feature}' está arquivada e ${expectedBranch} não foi mergeada, mas não foi possível consultar PRs abertos com \`gh\` — retomar às cegas abriria um PR duplicado. Verifique com \`gh pr list --head ${expectedBranch}\``,
318
+ });
319
+ process.exit(1);
320
+ }
321
+ if (openPr) {
322
+ emit({
323
+ finished: false,
324
+ error: 'already-finished',
325
+ feature,
326
+ branch: expectedBranch,
327
+ base: target,
328
+ archived: archivePath.replace(/\\/g, '/'),
329
+ ...(typeof openPr === 'string' ? { pr: openPr } : {}),
330
+ hint: `'${feature}' já foi arquivada e ${expectedBranch} já tem PR aberto — o finish anterior concluiu. Falta apenas mergear o PR.`,
331
+ });
332
+ process.exit(1);
333
+ }
334
+ }
335
+
336
+ resumed = true;
337
+ }
338
+
339
+ // Validation 1: Gate 3 (review) approved on the feature's own feature.json —
340
+ // which on a resume lives in `archive/{f}`, where runArchive moved it.
341
+ //
342
+ // NOT re-checked on a resume, deliberately. The archive IS the gate's receipt:
343
+ // runArchive refuses `review-not-approved` itself, so a feature that reached
344
+ // `archive/` either passed Gate 3 or was explicitly forced past it. Re-reading
345
+ // it here would only add a way to fail — an archive whose feature.json is
346
+ // absent or unparseable (legacy shapes, a forced close) parses to null and
347
+ // falls straight through to `review-not-approved`, which is the exact
348
+ // collapsed diagnosis the `feature-not-found` fix removed. Resume integrates
349
+ // what was already archived; it does not re-litigate why.
350
+ const fj = readFeatureJson(featureCwd, feature, resumed ? 'archive' : 'features');
351
+ if (!resumed && !options.force && !(fj?.approvalGates?.review?.approved === true || fj?.approvals?.review?.approved === true)) {
209
352
  emit({ finished: false, error: 'review-not-approved', feature });
210
353
  process.exit(1);
211
354
  }
@@ -239,6 +382,37 @@ export async function finishCommand(feature, options = {}) {
239
382
  process.exit(1);
240
383
  }
241
384
 
385
+ // ── MERGE precondition, BEFORE the archive ────────────────────────────────
386
+ // `--merge` lands its merge commit in the PRIMARY ROOT, so the root has to be
387
+ // clean. That check used to live inside integrateMerge — i.e. AFTER
388
+ // runArchive had already moved the feature to .morph/archive/, promoted its
389
+ // use cases and COMMITTED. A root dirtied by unrelated work therefore got a
390
+ // refusal on a half-finished feature, and the retry then failed for a
391
+ // different reason entirely (the feature was no longer in features/).
392
+ //
393
+ // `exemptOwnScope` because shedRootFeatureScope has not run yet: this
394
+ // feature's own root-side scaffold is still on disk and is not a reason to
395
+ // refuse — the shed backs it up and discards it moments later.
396
+ //
397
+ // `--pr` is exempt by design: integratePr pushes from the root but never
398
+ // checks out or merges there, so root dirt cannot corrupt it.
399
+ if (mode === 'merge' && isWorktreeFlow) {
400
+ const rootPre = primaryRootUnexplainedDirt(root, feature, { exemptOwnScope: true });
401
+ if (!rootPre.ok) {
402
+ emit({ finished: false, error: 'primary-root-status-failed', feature });
403
+ process.exit(1);
404
+ }
405
+ if (rootPre.dirt.length) {
406
+ emit({
407
+ finished: false,
408
+ error: 'primary-root-dirty',
409
+ hint: 'a raiz primária tem trabalho não commitado — o merge acontece nela; commite ou faça stash antes do finish',
410
+ feature,
411
+ });
412
+ process.exit(1);
413
+ }
414
+ }
415
+
242
416
  // ── PR preconditions run BEFORE the archive, so a refused --pr has NO side
243
417
  // effect (nothing archived, nothing committed). ─────────────────────────
244
418
  if (mode === 'pr') {
@@ -284,6 +458,11 @@ async function integrateMerge({ emit, root, feature, expectedBranch, defaultBran
284
458
  const { shedBackup } = shedRootFeatureScope(root, feature);
285
459
  const backupNote = shedBackup ? { shedBackup } : {};
286
460
 
461
+ // Final gate before the merge commit lands. The pre-archive check above
462
+ // already refused an unrelated-dirt root WITHOUT side effects, so reaching
463
+ // a refusal HERE means the shed itself dirtied the root — a bug signal, not
464
+ // user error. Kept because this is the last moment before we mutate the
465
+ // default branch, and it is nearly free.
287
466
  const rootClean = primaryRootUnexplainedDirt(root, feature);
288
467
  if (!rootClean.ok) {
289
468
  emit({ finished: false, error: 'primary-root-status-failed', feature, ...backupNote });
@@ -521,9 +700,113 @@ function gitErr(err) {
521
700
  return (((err.stdout && err.stdout.toString()) || '') + ((err.stderr && err.stderr.toString()) || '')).slice(0, 2000) || (err.message || '').slice(0, 500);
522
701
  }
523
702
 
524
- function readFeatureJson(cwd, feature) {
703
+ /**
704
+ * Where a feature that is NOT in `features/` actually went, as extra fields on
705
+ * the `feature-not-found` payload.
706
+ *
707
+ * Exists because "not found" is the least useful thing we can say about a
708
+ * feature the user just worked on. The two real cases:
709
+ *
710
+ * - ARCHIVED: an earlier finish archived it and then failed to integrate.
711
+ * What is missing is the merge, not the whole finish — say so, because the
712
+ * user's instinct is to re-run `finish` and get refused again.
713
+ * - ON ITS BRANCH: the feature was created on `morph/{f}` and the command is
714
+ * running from the default branch, where `.morph/features/{f}` does not
715
+ * exist yet. Naming the branch turns a dead end into one checkout.
716
+ *
717
+ * Fail-open in every direction: a hint that cannot be computed is simply absent.
718
+ *
719
+ * @param {string} featureCwd - the tree we looked in
720
+ * @param {string} root - the primary root
721
+ * @param {string} feature
722
+ * @returns {{ archived?: string, hint?: string, onBranch?: string }}
723
+ */
724
+ function featureElsewhereHint(featureCwd, root, feature) {
725
+ try {
726
+ const archived = join(featureCwd, '.morph', 'archive', feature);
727
+ if (existsSync(archived)) {
728
+ return {
729
+ archived: archived.replace(/\\/g, '/'),
730
+ hint: `'${feature}' já foi arquivada por um finish anterior — o que faltou foi a integração. Verifique se o merge/PR já aconteceu antes de refazer o trabalho à mão.`,
731
+ };
732
+ }
733
+ } catch { /* fail-open */ }
734
+
735
+ try {
736
+ const branch = `morph/${feature}`;
737
+ const listed = execFileSync('git', ['branch', '--list', branch], { cwd: root, stdio: 'pipe' }).toString().trim();
738
+ if (listed) {
739
+ return {
740
+ onBranch: branch,
741
+ hint: `'${feature}' não está nesta árvore, mas a branch ${branch} existe — rode o finish de lá (\`git checkout ${branch}\`) ou do worktree da feature.`,
742
+ };
743
+ }
744
+ } catch { /* fail-open — sem git, sem hint */ }
745
+
746
+ return { hint: `'${feature}' não existe em .morph/features/ nesta árvore. Confira o nome com \`morph-spec list --all\`.` };
747
+ }
748
+
749
+ /**
750
+ * @param {string} cwd
751
+ * @param {string} feature
752
+ * @param {'features'|'archive'} [bucket] - `archive` on a resume, where the
753
+ * feature.json has already been moved (and stamped `completed`).
754
+ */
755
+ function readFeatureJson(cwd, feature, bucket = 'features') {
756
+ try {
757
+ return JSON.parse(readFileSync(join(cwd, '.morph', bucket, feature, 'feature.json'), 'utf8'));
758
+ } catch {
759
+ return null;
760
+ }
761
+ }
762
+
763
+ /**
764
+ * Is `branch` already contained in `target` — i.e. did an earlier finish get its
765
+ * merge in?
766
+ *
767
+ * Three-valued ON PURPOSE. `git merge-base --is-ancestor` answers by exit code,
768
+ * and the two failure modes mean opposite things: **1** is a real "no, not
769
+ * merged", while **128** is "one of these refs does not resolve" and proves
770
+ * nothing at all. Collapsing them to a boolean would read an unresolvable ref as
771
+ * "not merged" and let a resume re-merge a completed feature.
772
+ *
773
+ * @param {string} branch
774
+ * @param {string} target
775
+ * @param {string} cwd
776
+ * @returns {boolean|null} null when the comparison could not be made
777
+ */
778
+ export function branchMergedInto(branch, target, cwd) {
779
+ try {
780
+ execFileSync('git', ['merge-base', '--is-ancestor', branch, target], { cwd, stdio: 'pipe' });
781
+ return true;
782
+ } catch (err) {
783
+ return err?.status === 1 ? false : null;
784
+ }
785
+ }
786
+
787
+ /**
788
+ * Does `branch` already have an OPEN pull request? The `--pr` half of
789
+ * "already integrated" — a completed `--pr` leaves the branch unmerged on
790
+ * purpose, so `branchMergedInto` cannot see it.
791
+ *
792
+ * Three-valued for the same reason: an unanswerable query (no `gh`, no auth, no
793
+ * network) is not evidence of "no PR", and treating it as such would resume
794
+ * into a duplicate `gh pr create`.
795
+ *
796
+ * @param {string} branch
797
+ * @param {string} cwd
798
+ * @returns {string|boolean|null} the PR url, `true` (open PR, url unavailable),
799
+ * `false` (none open), or `null` when the question could not be asked
800
+ */
801
+ function openPrFor(branch, cwd) {
802
+ const { bin, prefix } = resolveGh();
525
803
  try {
526
- return JSON.parse(readFileSync(join(cwd, '.morph', 'features', feature, 'feature.json'), 'utf8'));
804
+ const out = execFileSync(bin, [...prefix, 'pr', 'list', '--head', branch, '--state', 'open', '--json', 'url'], { cwd, stdio: 'pipe' })
805
+ .toString()
806
+ .trim();
807
+ const arr = JSON.parse(out || '[]');
808
+ if (!Array.isArray(arr) || !arr.length) return false;
809
+ return typeof arr[0]?.url === 'string' ? arr[0].url : true;
527
810
  } catch {
528
811
  return null;
529
812
  }