@polymorphism-tech/morph-spec 8.31.0 → 8.33.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 (39) hide show
  1. package/CLAUDE.md +6 -0
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +20 -2
  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 +2 -1
  8. package/framework/MORPH.md +2 -2
  9. package/framework/docs/CLI.md +10 -5
  10. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  11. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  12. package/framework/skills/core/morph-apply/SKILL.md +3 -2
  13. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  14. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  15. package/framework/skills/core/morph-hotfix/SKILL.md +2 -2
  16. package/framework/skills/core/morph-implement/SKILL.md +1 -1
  17. package/framework/skills/core/morph-plan/SKILL.md +1 -1
  18. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  19. package/framework/skills/core/morph-review/SKILL.md +2 -2
  20. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  21. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  22. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  23. package/package.json +1 -1
  24. package/src/commands/project/branch.js +170 -0
  25. package/src/commands/project/doctor.js +137 -9
  26. package/src/commands/project/fleet.js +10 -3
  27. package/src/commands/project/worktree.js +73 -50
  28. package/src/commands/state/finish.js +41 -26
  29. package/src/commands/state/list.js +79 -6
  30. package/src/core/state/lifecycle-audit.js +127 -0
  31. package/src/lib/git/branch-classifier.js +177 -0
  32. package/src/lib/git/gh.js +30 -0
  33. package/src/lib/git/pending-prs.js +89 -0
  34. package/src/lib/git/pr-index.js +70 -0
  35. package/src/lib/tasks/test-runner.js +200 -38
  36. package/src/lib/validators/verify-runner.js +30 -4
  37. package/src/lib/worktree/fleet-scanner.js +51 -21
  38. package/src/lib/worktree/ignored-triage.js +197 -0
  39. package/src/lib/worktree/worktree-audit.js +200 -35
@@ -1,11 +1,14 @@
1
1
  import { join, relative } from 'path';
2
2
  import { execSync } from 'child_process';
3
+ import { existsSync } from 'fs';
3
4
  import { platform, homedir } from 'os';
4
5
  import fs from 'fs-extra';
5
6
  import chalk from 'chalk';
6
7
  import { logger } from '../../utils/logger.js';
7
8
  import { pathExists, readJson } from '../../utils/file-copier.js';
8
9
  import { checkClaudeMdImport } from '../../utils/claude-md-injector.js';
10
+ import { buildPrIndex } from '../../lib/git/pr-index.js';
11
+ import { pendingPrFeatures, MORPH_BRANCH_RE } from '../../lib/git/pending-prs.js';
9
12
  import {
10
13
  checkCLIOutdated,
11
14
  checkProjectOutdated,
@@ -556,10 +559,12 @@ async function doctorMcpCommand(targetPath) {
556
559
  * Orphan worktrees used to accumulate invisibly: `fleet` only reports worktrees
557
560
  * on a `morph/*` branch, so anything else never showed up anywhere.
558
561
  *
559
- * `--prune` runs the one safe repair (`git worktree prune`: admin records for
560
- * directories that are already gone). Deleting a directory that still exists is
561
- * reported with the exact command, never performed — same PROVE-or-DECLINE
562
- * discipline as the finish teardown.
562
+ * `--prune` runs the safe repairs: `git worktree prune` (admin records for
563
+ * directories that are already gone) and the removal of EMPTY unregistered
564
+ * residue directories, which by definition hold nothing that can be lost.
565
+ * Deleting a directory that still HOLDS something is reported with the exact
566
+ * command, never performed — same PROVE-or-DECLINE discipline as the finish
567
+ * teardown.
563
568
  */
564
569
  export async function doctorWorktreesCommand(targetPath, options = {}) {
565
570
  const { auditWorktrees, pruneWorktrees } = await import('../../lib/worktree/worktree-audit.js');
@@ -568,19 +573,28 @@ export async function doctorWorktreesCommand(targetPath, options = {}) {
568
573
  const beforeIssues = auditWorktrees(targetPath);
569
574
  const beforeMissing = beforeIssues.filter((i) => i.type === 'registered-missing').length;
570
575
  const beforeDangling = beforeIssues.filter((i) => i.type === 'dangling-registration').length;
576
+ const beforeEmptyDirs = beforeIssues.filter((i) => i.type === 'unregistered-dir' && i.empty).length;
571
577
  const result = await pruneWorktrees(targetPath);
572
578
  if (result.pruned) logger.success(`git worktree prune: ${beforeMissing} registro(s) órfão(s) removido(s).`);
573
579
  else logger.warn(`prune falhou: ${result.error}`);
580
+ if (result.removedEmptyDirs.length) {
581
+ logger.success(`${result.removedEmptyDirs.length}/${beforeEmptyDirs} diretório(s) vazio(s) removido(s): ${result.removedEmptyDirs.join(', ')}`);
582
+ }
574
583
  if (result.clearedRegistrations.length) {
575
584
  logger.success(`registro de worktree limpo em ${result.clearedRegistrations.length}/${beforeDangling} feature(s): ${result.clearedRegistrations.map((r) => r.feature).join(', ')}`);
576
585
  }
577
586
  }
578
587
 
579
588
  const issues = auditWorktrees(targetPath);
589
+ // `severity: 'info'` entries (a worktree carrying unintegrated work) are
590
+ // REPORTED but are not failures — a `doctor --worktrees || ...` in CI must not
591
+ // go red because somebody has an open PR. The human path below splits the same
592
+ // way; both surfaces must agree on what counts as broken.
593
+ const isProblem = (i) => i.severity !== 'info';
580
594
 
581
595
  if (options.json) {
582
596
  console.log(JSON.stringify({ issues }, null, 2));
583
- return issues.length === 0;
597
+ return !issues.some(isProblem);
584
598
  }
585
599
 
586
600
  logger.header('MORPH-SPEC Worktrees');
@@ -589,18 +603,90 @@ export async function doctorWorktreesCommand(targetPath, options = {}) {
589
603
  return true;
590
604
  }
591
605
 
592
- logger.warn(`${issues.length} inconsistência(s):`);
593
- for (const i of issues) {
606
+ // `unintegrated-work` is NOT an inconsistency — it is a worktree doing its
607
+ // job, reported so the reader knows why no cleanup is offered for it. Counting
608
+ // it as a problem is how a diagnostic starts nagging people into destroying
609
+ // things to make the warning go away.
610
+ const informational = issues.filter((i) => !isProblem(i));
611
+ const problems = issues.filter(isProblem);
612
+
613
+ if (problems.length) logger.warn(`${problems.length} inconsistência(s):`);
614
+ for (const i of problems) {
594
615
  console.log(` ⚠ ${i.type}`);
595
616
  console.log(` ${i.detail}`);
596
617
  console.log(` → ${i.fix}`);
597
618
  }
598
- if (issues.some((i) => i.type === 'registered-missing' || i.type === 'dangling-registration') && !options.prune) {
599
- logger.dim('\n Rode `morph-spec doctor --worktrees --prune` para limpar os registros órfãos.');
619
+ for (const i of informational) {
620
+ console.log(` ℹ ${i.type}`);
621
+ console.log(` ${i.detail}`);
622
+ console.log(` → ${i.fix}`);
623
+ }
624
+ // An EMPTY unregistered directory is prunable too — it holds nothing that can
625
+ // be lost — so the hint must mention it, or the one issue `--prune` now fixes
626
+ // automatically would still read as a manual chore.
627
+ const prunable = (i) => i.type === 'registered-missing'
628
+ || i.type === 'dangling-registration'
629
+ || (i.type === 'unregistered-dir' && i.empty);
630
+ if (problems.some(prunable) && !options.prune) {
631
+ logger.dim('\n Rode `morph-spec doctor --worktrees --prune` para limpar os registros órfãos e os diretórios vazios.');
632
+ }
633
+ if (!problems.length) {
634
+ logger.success('Nenhuma inconsistência — os itens acima são informativos.');
635
+ return true;
600
636
  }
601
637
  return false;
602
638
  }
603
639
 
640
+ /** Dias a partir dos quais um PR aberto vira aviso. */
641
+ const PR_STALE_DAYS = 7;
642
+
643
+ /**
644
+ * As inconsistências de PR desta árvore.
645
+ *
646
+ * pr-awaiting-merge — PR aberto há muito, sem cópia local: trabalho em
647
+ * revisão que nenhuma superfície local reivindica.
648
+ * pr-merged-not-landed — o PR mergeou mas `archive/{f}` não está aqui. O
649
+ * archive viaja no diff do PR, então ou falta um
650
+ * `git pull`, ou ele não estava no diff.
651
+ *
652
+ * Offline-safe: índice `null` devolve `null`, NUNCA lista vazia — lista vazia
653
+ * afirmaria "perguntei e não achei nada", que é diferente de "não pude
654
+ * perguntar". Achado #3: um `[]` disfarçado de "sem problema" é o que fazia
655
+ * `doctor` imprimir `✓ pull requests` mesmo sem conseguir consultar o `gh`.
656
+ *
657
+ * @returns {Array<{feature:string,type:string,detail:string}>|null}
658
+ * null = DESCONHECIDO (índice não pôde ser construído); nunca "sem PRs".
659
+ */
660
+ function auditPullRequests(root) {
661
+ const index = buildPrIndex(root);
662
+ if (!index) return null;
663
+ const issues = [];
664
+
665
+ for (const p of pendingPrFeatures(root, { index }) || []) {
666
+ if (p.ageDays != null && p.ageDays >= PR_STALE_DAYS) {
667
+ issues.push({
668
+ feature: p.feature,
669
+ type: 'pr-awaiting-merge',
670
+ detail: `PR #${p.number} aberto há ${p.ageDays}d e sem cópia local — mergeie ou feche`,
671
+ });
672
+ }
673
+ }
674
+
675
+ for (const [branch, pr] of index) {
676
+ const m = branch.match(MORPH_BRANCH_RE);
677
+ if (!m || pr.state !== 'MERGED') continue;
678
+ const feature = m[1];
679
+ if (existsSync(join(root, '.morph', 'archive', feature))) continue;
680
+ issues.push({
681
+ feature,
682
+ type: 'pr-merged-not-landed',
683
+ detail: `PR #${pr.number} foi mergeado mas .morph/archive/${feature} não está aqui — falta \`git pull\`?`,
684
+ });
685
+ }
686
+
687
+ return issues;
688
+ }
689
+
604
690
  export async function doctorCommand(options = {}) {
605
691
  // `--json` and `--prune` only mean something under `--worktrees`. Falling
606
692
  // through to the human health check would hand a script a banner and a table
@@ -1075,6 +1161,32 @@ export async function doctorCommand(options = {}) {
1075
1161
  hasWarnings = true;
1076
1162
  }
1077
1163
 
1164
+ // Pull requests — o pendurado (aberto há dias sem cópia local) e o mergeado
1165
+ // que não pousou (archive não chegou nesta árvore). Offline-safe: sem `gh`,
1166
+ // sem origin no GitHub, ou qualquer falha, `auditPullRequests` nunca lança
1167
+ // (achado #7 — mesma guarda fail-open que `fleet-scanner.js` já usa para a
1168
+ // síntese idêntica) e devolve `null` — achado #3: um índice desconhecido
1169
+ // renderiza como "não verificado", nunca como `✓ ok`, porque `[]` e
1170
+ // "não pude perguntar" não são a mesma coisa.
1171
+ let prIssues;
1172
+ try {
1173
+ prIssues = auditPullRequests(targetPath);
1174
+ } catch {
1175
+ prIssues = null; // fail-open — um doctor não pode ficar mais ruidoso porque o gh sumiu
1176
+ }
1177
+ if (prIssues === null) {
1178
+ checks.push({ name: 'pull requests', status: 'skip', msg: 'gh indisponível — não verificado' });
1179
+ } else if (prIssues.length === 0) {
1180
+ checks.push({ name: 'pull requests', status: 'ok' });
1181
+ } else {
1182
+ checks.push({
1183
+ name: 'pull requests',
1184
+ status: 'warn',
1185
+ msg: `${prIssues.length} pendência${prIssues.length === 1 ? '' : 's'} (veja abaixo)`,
1186
+ });
1187
+ hasWarnings = true;
1188
+ }
1189
+
1078
1190
  // Codebase graph — opcional (ADR-002). Nunca 'missing': um projeto que não
1079
1191
  // usa o grafo tem de continuar com o doctor verde.
1080
1192
  const graphAudit = auditGraph(targetPath);
@@ -1130,6 +1242,11 @@ export async function doctorCommand(options = {}) {
1130
1242
  console.log(chalk.red(` ✗ ${check.name}`) + chalk.gray(' (missing)'));
1131
1243
  } else if (check.status === 'error') {
1132
1244
  console.log(chalk.red(` ✗ ${check.name}`) + chalk.gray(` (${check.msg})`));
1245
+ } else if (check.status === 'skip') {
1246
+ // "Não verificado" — nem ok nem falha. Nunca soma em hasWarnings/hasErrors:
1247
+ // um `gh` indisponível não pode deixar o doctor mais barulhento nem mudar
1248
+ // o exit code (achado #3).
1249
+ console.log(chalk.gray(` ○ ${check.name}`) + chalk.gray(check.msg ? ` (${check.msg})` : ''));
1133
1250
  }
1134
1251
  }
1135
1252
 
@@ -1144,6 +1261,17 @@ export async function doctorCommand(options = {}) {
1144
1261
  console.log(chalk.gray(" Fix: 'morph-spec approve' (order), '--description' on create, or 'morph-spec delete' for dead scaffolds."));
1145
1262
  }
1146
1263
 
1264
+ // Per-feature detail for the PR inconsistencies (item: pendurado ou merge
1265
+ // que não pousou).
1266
+ if (prIssues?.length) {
1267
+ logger.blank();
1268
+ console.log(chalk.yellow(' Pull requests pendentes:'));
1269
+ for (const issue of prIssues) {
1270
+ console.log(chalk.gray(` • ${issue.feature} [${issue.type}] — ${issue.detail}`));
1271
+ }
1272
+ console.log(chalk.gray(' `finish --pr` abre o PR; quem encerra a feature é o merge.'));
1273
+ }
1274
+
1147
1275
  logger.blank();
1148
1276
 
1149
1277
  if (hasErrors) {
@@ -55,14 +55,21 @@ export function fleetCommand(options = {}) {
55
55
  };
56
56
 
57
57
  console.log(chalk.white.bold(
58
- col('FEATURE', 20) + col('TYPE', 9) + col('PHASE', 11) + col('TASKS', 8)
58
+ // PHASE at 18: `aguardando-merge` (the pr-pending synthesized phase, see
59
+ // fleet-scanner.js) is 16 chars — 1 wider than the col width `list.js`
60
+ // already uses (17) for the identical string. A narrower column here
61
+ // would misalign every column after it just for that one row.
62
+ col('FEATURE', 20) + col('TYPE', 9) + col('PHASE', 18) + col('TASKS', 8)
59
63
  + col('GATES PEND.', 20) + col('WHERE', 24) + col('REMOTO', 16) + col('CLAIM', 16)
60
64
  + col('PORTS', 12) + col('HANDOFF', 9) + 'ATIVIDADE',
61
65
  ));
62
66
  for (const e of fleet.entries) {
63
67
  const tasks = e.tasks ? `${e.tasks.done}/${e.tasks.total}` : '-';
64
68
  const gates = e.pendingGates.length ? e.pendingGates.join(',') : '-';
65
- const where = e.location === 'worktree' ? e.branch : 'raiz';
69
+ // pr-pending has no worktree and isn't root-resident either — its branch
70
+ // is the only place it exists, so show that instead of the misleading
71
+ // 'raiz' a location-blind check would print.
72
+ const where = (e.location === 'worktree' || e.location === 'pr-pending') ? e.branch : 'raiz';
66
73
  const ports = e.env ? `${e.env.range[0]}-${e.env.range[1]}` : '-';
67
74
  const handoff = e.handoff?.exists ? 'sim' : '-';
68
75
  const activity = [
@@ -73,7 +80,7 @@ export function fleetCommand(options = {}) {
73
80
  // length and append the padding by hand.
74
81
  const claimPlain = e.claim ? `${e.claim.live ? 'sessão' : 'morta'} (${claimAge(e.claim)})` : '-';
75
82
  console.log(
76
- col(e.feature, 20) + col(e.workType || '-', 9) + col(e.phase, 11) + col(tasks, 8)
83
+ col(e.feature, 20) + col(e.workType || '-', 9) + col(e.phase, 18) + col(tasks, 8)
77
84
  + col(gates, 20) + col(where, 24) + col(remoteCell(e.remote), 16)
78
85
  + claimCell(e.claim) + ' '.repeat(Math.max(1, 16 - claimPlain.length))
79
86
  + col(ports, 12) + col(handoff, 9) + activity,
@@ -35,6 +35,7 @@ import { existsSync, mkdirSync, symlinkSync, readFileSync, writeFileSync, append
35
35
  import { join, resolve, dirname } from 'path';
36
36
  import chalk from 'chalk';
37
37
  import { detectDefaultBranch } from '../../lib/git/default-branch.js';
38
+ import { triageIgnoredPaths } from '../../lib/worktree/ignored-triage.js';
38
39
  // The reparse scan lives in hooks/shared because the PreToolUse guardrail needs
39
40
  // the SAME answer this file does, and hooks can never import from `src/`.
40
41
  import { findReparsePoints } from '../../../framework/hooks/shared/reparse-scan.js';
@@ -767,23 +768,6 @@ export function unlinkWorktreeInfraLinks(worktreePath, mainCwd) {
767
768
  */
768
769
  const IGNORED_SHIELDABLE_PREFIXES = ['.morph/'];
769
770
 
770
- /**
771
- * Well-known, regenerable build artifacts — `bin/`, `obj/` (dotnet), a real
772
- * `node_modules` left behind outside the setup-created junction, and
773
- * `*.tsbuildinfo` (tsc incremental cache). These are the everyday leftovers of
774
- * running a build/test cycle inside a worktree, not user data: they are fully
775
- * reproducible from source, and requiring `--discard-ignored` for every finish
776
- * that ever ran a build turns the flag from "explicit consent for a real risk"
777
- * into a rubber stamp nobody reads anymore. Auto-discarding them (like any
778
- * `discarded` entry, WITHOUT a backup — see the note below) is safe precisely
779
- * because "regenerable" is a stronger guarantee than "backed up".
780
- *
781
- * Deliberately narrow — do NOT add speculative patterns (`dist/`, `.next/`)
782
- * beyond what's proven to be pure noise; anything else stays in `blocking` by
783
- * default, requiring the explicit `--discard-ignored` consent.
784
- *
785
- * @param {string} p - forward-slash relative path from `git status --ignored`
786
- */
787
771
  /**
788
772
  * Whether a dirty root-side path is EXACTLY a sibling feature's live worktree
789
773
  * registration — see the call site (primaryRootUnexplainedDirt, the root-clean
@@ -811,16 +795,9 @@ function isLiveSiblingWorktreeRegistration(relPath, root, feature) {
811
795
  }
812
796
  }
813
797
 
814
- function isDisposableBuildArtifact(p) {
815
- if (/\.tsbuildinfo$/.test(p)) return true;
816
- const segments = p.split('/').filter(Boolean);
817
- return segments.some((s) => s === 'bin' || s === 'obj' || s === 'node_modules');
818
- }
819
-
820
798
  /**
821
- * Split the worktree's GITIGNORED leftovers into what this command may remove
822
- * (morph's own, after a backup, or a known disposable build artifact, without
823
- * one) and what it must refuse to destroy.
799
+ * Split the worktree's GITIGNORED leftovers into what this command may delete
800
+ * outright, what it must COPY OUT first, and what it cannot even enumerate.
824
801
  *
825
802
  * Why this exists: `git worktree remove` deletes gitignored files outright, and
826
803
  * the clean-check that gates it uses `git status --porcelain`, which omits them
@@ -829,14 +806,33 @@ function isDisposableBuildArtifact(p) {
829
806
  * disposable; morph must not, because "not in git" is exactly the property that
830
807
  * makes a file unrecoverable.
831
808
  *
832
- * Fail-CLOSED: if the status call fails we cannot enumerate what is at stake, so
833
- * everything is reported as blocking.
809
+ * The buckets, and why refusing is no longer one of them: the earlier version
810
+ * blocked on ANY unrecognised gitignored path, which in practice meant blocking
811
+ * on `.env` files that were byte-identical to the primary root's — nothing was
812
+ * at stake, and the only escape offered (`--discard-ignored`) also destroyed the
813
+ * genuinely unique `keys/` next to them. `triageIgnoredPaths` answers the
814
+ * question that actually decides whether bytes are lost (see ignored-triage.js),
815
+ * so the command can PROCEED in every case, backing up only what exists nowhere
816
+ * else. A refusal survives for exactly one reason now: the backup FAILED.
817
+ *
818
+ * shieldable — morph's own bookkeeping + anything EXCLUSIVE to this worktree.
819
+ * Copied to `finish-backup` before the removal; a failed copy is
820
+ * what declines the removal.
821
+ * discarded — build artifacts (regenerable) and REPLICABLE files (the root
822
+ * holds the same path with the same SHA-256). Deleted without a
823
+ * backup because neither loses bytes.
824
+ * blocking — the fail-CLOSED sentinel only: `git status --ignored` itself
825
+ * failed, so we cannot enumerate what is at stake.
826
+ *
827
+ * `--discard-ignored` no longer means "destroy the unique ones too, blind"; it
828
+ * means SKIP THE BACKUP — the same removal, without the copy.
834
829
  *
835
830
  * @param {string} worktreePath
831
+ * @param {string} rootPath - the primary root, for the replica comparison
836
832
  * @param {{ discardIgnored?: boolean }} options
837
- * @returns {{ shieldable: string[], blocking: string[], discarded: string[] }}
833
+ * @returns {{ shieldable: string[], blocking: string[], discarded: string[], replicable: string[] }}
838
834
  */
839
- function classifyIgnoredLeftovers(worktreePath, options = {}) {
835
+ function classifyIgnoredLeftovers(worktreePath, rootPath, options = {}) {
840
836
  let out;
841
837
  try {
842
838
  // `-z` + execFile: NUL-delimited, no shell, no C-style quoting to undo —
@@ -850,30 +846,30 @@ function classifyIgnoredLeftovers(worktreePath, options = {}) {
850
846
  // empirically, not assumed.
851
847
  out = execFileSync('git', ['status', '--porcelain', '--ignored=matching', '-z'], { cwd: worktreePath, stdio: 'pipe' }).toString();
852
848
  } catch {
853
- return { shieldable: [], blocking: ['<git status --ignored falhou — não foi possível enumerar o que seria destruído>'], discarded: [] };
849
+ return { shieldable: [], blocking: ['<git status --ignored falhou — não foi possível enumerar o que seria destruído>'], discarded: [], replicable: [] };
854
850
  }
855
851
 
856
- const shieldable = [];
857
- const blockingRest = [];
858
- const disposable = [];
852
+ const morphOwned = [];
853
+ const rest = [];
859
854
  for (const record of out.split('\0')) {
860
855
  if (!record || record.length < 4) continue;
861
856
  if (record.slice(0, 2) !== '!!') continue; // only ignored entries; the rest is the clean-check's job
862
857
  const p = record.slice(3).replace(/\\/g, '/');
863
- if (IGNORED_SHIELDABLE_PREFIXES.some((prefix) => p.startsWith(prefix))) shieldable.push(p);
864
- else if (isDisposableBuildArtifact(p)) disposable.push(p);
865
- else blockingRest.push(p);
858
+ if (IGNORED_SHIELDABLE_PREFIXES.some((prefix) => p.startsWith(prefix))) morphOwned.push(p);
859
+ else rest.push(p);
860
+ }
861
+
862
+ const { replicable, disposable, exclusive } = triageIgnoredPaths({ worktreePath, rootPath, paths: rest });
863
+ const shieldable = [...morphOwned, ...exclusive];
864
+
865
+ // Explicit consent = SKIP THE BACKUP, not "widen what dies". The same paths
866
+ // are removed either way; the flag only decides whether a copy is made first.
867
+ // The list is never thrown away — these are the bytes that exist nowhere
868
+ // else, and reporting them is the least this can do.
869
+ if (options.discardIgnored) {
870
+ return { shieldable: [], blocking: [], discarded: [...disposable, ...replicable, ...shieldable], replicable };
866
871
  }
867
- // Explicit consent clears the rest of the block too — but the list is NOT
868
- // thrown away. These are the files that exist nowhere else, and they are the
869
- // ones about to die; reporting them is the least this can do. (Backing them
870
- // up instead is not an option: this bucket is where arbitrarily large
871
- // leftovers land, so a copy is unbounded. The user declared them
872
- // disposable — they still get told.)
873
- if (options.discardIgnored) return { shieldable, blocking: [], discarded: [...disposable, ...blockingRest] };
874
- // Known build artifacts are discarded by default, no flag required — they
875
- // are reproducible from source, unlike anything else in `blockingRest`.
876
- return { shieldable, blocking: blockingRest, discarded: disposable };
872
+ return { shieldable, blocking: [], discarded: [...disposable, ...replicable], replicable };
877
873
  }
878
874
 
879
875
  /**
@@ -1177,7 +1173,7 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
1177
1173
  const unlinked = unlinkWorktreeInfraLinks(worktreePath, root);
1178
1174
 
1179
1175
  const scan = findReparsePoints(worktreePath);
1180
- const ignored = classifyIgnoredLeftovers(worktreePath, options);
1176
+ const ignored = classifyIgnoredLeftovers(worktreePath, root, options);
1181
1177
  const blockers = [...scan.points, ...scan.unprovable];
1182
1178
 
1183
1179
  // Back up ONLY once the removal is otherwise cleared: nothing is destroyed on
@@ -1257,14 +1253,22 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
1257
1253
  const removeCmd = (addressByTarget || !isValidFeatureName(feature))
1258
1254
  ? `morph-spec worktree remove --target "${worktreePath}"`
1259
1255
  : `morph-spec worktree remove ${feature}`;
1256
+ const backupDir = join(root, '.git', 'morph-spec', 'finish-backup');
1257
+ const sample = (list) => `${list.slice(0, 5).join(', ')}${list.length > 5 ? `, +${list.length - 5}` : ''}`;
1260
1258
  const declineReason = worktreeRemoved
1261
1259
  ? null
1262
1260
  : ignored.blocking.length
1263
- ? `worktree NÃO removido: contém arquivo(s) gitignored que existem só ali e a remoção apagaria (${ignored.blocking.slice(0, 5).join(', ')}${ignored.blocking.length > 5 ? `, +${ignored.blocking.length - 5}` : ''}). Salve o que importa e rode \`${removeCmd}\`, ou \`${removeCmd} --discard-ignored\` para descartá-los deliberadamente`
1261
+ ? `worktree NÃO removido: não foi possível enumerar os arquivos gitignored (${sample(ignored.blocking)}), então não dá para provar que a remoção não destrói nada. Rode \`git status --ignored\` dentro do worktree para ver o que está travando e tente de novo com \`${removeCmd}\``
1264
1262
  : blockers.length
1265
1263
  ? `worktree NÃO removido: ainda contém link(s)/caminho(s) não verificáveis (${blockers.join(', ')}) que a remoção seguiria para fora, apagando o alvo na raiz. Remova-os e rode \`${removeCmd}\``
1266
1264
  : ignored.shieldable.length && ignoredBackup === null
1267
- ? `worktree NÃO removido: o backup do gitignored do worktree falhou, e sem ele a remoção seria destrutiva. Libere espaço/permissão em \`.git/morph-spec/\` e rode \`${removeCmd}\`, ou \`${removeCmd} --discard-ignored\` se não precisar desse conteúdo`
1265
+ // The ONLY gitignored refusal left. The old hint was circular — it
1266
+ // said "salve o que importa e rode <the command that just failed>".
1267
+ // This one names WHAT is exclusive, what was replicable (so the
1268
+ // reader stops worrying about those), and WHERE the copy was going.
1269
+ ? `worktree NÃO removido: o backup falhou. Estes arquivos existem só neste worktree e a remoção os apagaria: ${sample(ignored.shieldable)}. `
1270
+ + (ignored.replicable.length ? `(${sample(ignored.replicable)} são idênticos aos da raiz — esses não são o problema.) ` : '')
1271
+ + `O backup iria para \`${backupDir}\`; libere espaço/permissão ali e rode \`${removeCmd}\`, ou copie os arquivos você mesmo e rode \`${removeCmd} --discard-ignored\` para pular o backup`
1268
1272
  : removalFailure?.kind === 'in-use'
1269
1273
  ? `worktree NÃO removido: o diretório está EM USO por outro processo (instância do Claude Code, VS Code, dev server ou terminal com cwd em "${worktreePath}"). A infra compartilhada ficou DESMONTADA de propósito — feche o que está usando o diretório e rode \`${removeCmd}\`. Se preferir continuar trabalhando nele, restaure a infra com \`morph-spec worktree link --all\``
1270
1274
  : `worktree NÃO removido: a remoção falhou${removalFailure?.detail ? ` (${removalFailure.detail})` : ''}. A infra compartilhada ficou DESMONTADA de propósito — rode \`${removeCmd}\` para tentar de novo, ou \`morph-spec worktree link --all\` para voltar a trabalhar nele`;
@@ -1273,6 +1277,14 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
1273
1277
  worktreeRemoved,
1274
1278
  ignoredBackup,
1275
1279
  discarded: ignored.discarded,
1280
+ replicable: ignored.replicable,
1281
+ // The infra reparse points this teardown dropped BEFORE letting git near
1282
+ // the directory. Reported because it is the load-bearing safety step of the
1283
+ // whole operation and was previously invisible: the user had no way to see
1284
+ // that `.claude` was a junction into the primary root, nor that it was
1285
+ // unlinked rather than followed. On a removal it is the proof the root's
1286
+ // real `.claude/` was never at risk.
1287
+ unlinked,
1276
1288
  blockers,
1277
1289
  ignoredBlocking: ignored.blocking,
1278
1290
  shieldable: ignored.shieldable,
@@ -1405,13 +1417,24 @@ export async function worktreeRemoveCommand(featureArg, options = {}) {
1405
1417
  ...(branch ? { branch } : {}),
1406
1418
  ...(feature ? { feature } : {}),
1407
1419
  };
1420
+ // Reported on BOTH paths: on a removal it is the proof the primary root's
1421
+ // real `.claude/`/`.morph/framework/` were unlinked rather than followed; on
1422
+ // a refusal it is what `relinked` did or did not put back.
1423
+ if (teardown.unlinked.length) out.unlinked = teardown.unlinked;
1408
1424
  if (teardown.worktreeRemoved) {
1409
1425
  if (teardown.discarded.length) out.discardedIgnored = teardown.discarded;
1426
+ // Broken out of `discardedIgnored` on purpose: "deleted" and "deleted
1427
+ // because the identical bytes are still in the root" are not the same
1428
+ // report, and the second is the one that needs no follow-up from the user.
1429
+ if (teardown.replicable.length) out.replicatedInRoot = teardown.replicable;
1410
1430
  if (teardown.ignoredBackup) out.ignoredBackup = teardown.ignoredBackup;
1431
+ if (teardown.shieldable.length) out.backedUp = teardown.shieldable;
1411
1432
  } else {
1412
1433
  out.refused = teardown.refused;
1413
1434
  if (teardown.blockers.length) out.reparsePoints = teardown.blockers;
1414
1435
  if (teardown.ignoredBlocking.length) out.ignoredFiles = teardown.ignoredBlocking;
1436
+ if (teardown.shieldable.length) out.exclusiveIgnored = teardown.shieldable;
1437
+ if (teardown.replicable.length) out.replicatedInRoot = teardown.replicable;
1415
1438
  if (teardown.infraLeftUnlinked.length) out.infraLeftUnlinked = teardown.infraLeftUnlinked;
1416
1439
  out.hint = teardown.declineReason;
1417
1440
  }
@@ -13,6 +13,7 @@ import {
13
13
  cwdIsInsideWorktree,
14
14
  } from '../project/worktree.js';
15
15
  import { detectDefaultBranch, hasOriginRemote } from '../../lib/git/default-branch.js';
16
+ import { resolveGh } from '../../lib/git/gh.js';
16
17
  import { releaseClaim } from '../../../framework/hooks/shared/claim-registry.js';
17
18
  import {
18
19
  composeProjectName,
@@ -326,7 +327,7 @@ export async function finishCommand(feature, options = {}) {
326
327
  branch: expectedBranch,
327
328
  base: target,
328
329
  archived: archivePath.replace(/\\/g, '/'),
329
- ...(typeof openPr === 'string' ? { pr: openPr } : {}),
330
+ ...(typeof openPr === 'string' ? { pr: { number: prNumberFromUrl(openPr), url: openPr } } : {}),
330
331
  hint: `'${feature}' já foi arquivada e ${expectedBranch} já tem PR aberto — o finish anterior concluiu. Falta apenas mergear o PR.`,
331
332
  });
332
333
  process.exit(1);
@@ -487,7 +488,7 @@ async function integrateMerge({ emit, root, feature, expectedBranch, defaultBran
487
488
  // VALID (it still names a live directory), so nothing to clean up here —
488
489
  // only report where it lives and how to close it later.
489
490
  emit({
490
- finished: true, mode: 'merge', feature, merged: expectedBranch, kept: true, worktreeRemoved: false,
491
+ finished: true, integrated: true, mode: 'merge', feature, merged: expectedBranch, kept: true, worktreeRemoved: false,
491
492
  worktree: { path: worktreePath, branch: expectedBranch },
492
493
  hint: `worktree preservado em ${worktreePath} — feche depois com \`morph-spec worktree remove ${feature}\`.`,
493
494
  ...backupNote, ...ucNote,
@@ -526,10 +527,26 @@ async function integrateMerge({ emit, root, feature, expectedBranch, defaultBran
526
527
  }
527
528
  tryGit(['branch', '-d', expectedBranch], root);
528
529
  // The root index (gitignored) was already updated by the archive (same cwd).
529
- emit({ finished: true, mode: 'merge', feature, merged: expectedBranch, into: defaultBranch, worktreeRemoved: false, nextStep: MERGE_NEXT_STEP, ...ucNote });
530
+ emit({ finished: true, integrated: true, mode: 'merge', feature, merged: expectedBranch, into: defaultBranch, worktreeRemoved: false, nextStep: MERGE_NEXT_STEP, ...ucNote });
530
531
  process.exit(0);
531
532
  }
532
533
 
534
+ /**
535
+ * O número do PR a partir da url que o `gh pr create` imprime.
536
+ *
537
+ * Existe porque o número é o que um humano usa para falar do PR ("mergeia o
538
+ * #51"), e a url é a única coisa que o `gh` devolve. Um formato inesperado
539
+ * devolve `null` em vez de um palpite: o payload prefere admitir que não sabe
540
+ * o número a afirmar um errado.
541
+ *
542
+ * @param {string|null} url
543
+ * @returns {number|null}
544
+ */
545
+ export function prNumberFromUrl(url) {
546
+ const m = typeof url === 'string' ? url.match(/\/pull\/(\d+)(?:[/?#]|$)/) : null;
547
+ return m ? Number(m[1]) : null;
548
+ }
549
+
533
550
  // ── PR integration (push + gh, no local merge) ───────────────────────────────
534
551
 
535
552
  async function integratePr({ emit, root, feature, expectedBranch, defaultBranch, isWorktreeFlow, worktreePath, options, prTitle, prBody, arch }) {
@@ -571,13 +588,23 @@ async function integratePr({ emit, root, feature, expectedBranch, defaultBranch,
571
588
  dropFromRootIndex(root, feature);
572
589
  }
573
590
 
591
+ const prNumber = prNumberFromUrl(prUrl);
592
+ const prLabel = prNumber ? `#${prNumber}` : 'o PR';
593
+
574
594
  const out = {
575
595
  finished: true,
596
+ // O comando terminou; a INTEGRAÇÃO não aconteceu. Eram a mesma coisa no
597
+ // payload antigo, e é dessa colisão que sai o "shipped" para uma feature
598
+ // cujo PR seguia aberto: quem lê `finished: true` sem mais nada conclui
599
+ // que acabou. A default só recebe archive/{f} quando o PR mergear.
600
+ integrated: false,
601
+ awaiting: 'merge',
576
602
  mode: 'pr',
577
603
  feature,
578
604
  pushed: expectedBranch,
579
605
  base: defaultBranch,
580
- pr: prUrl,
606
+ pr: { number: prNumber, url: prUrl },
607
+ nextStep: `mergeie ${prLabel} — a feature só estará encerrada quando ${defaultBranch} receber o archive pelo merge`,
581
608
  ...promotedNote(arch),
582
609
  ...(shedBackup ? { shedBackup } : {}),
583
610
  ...(teardown
@@ -626,6 +653,7 @@ const MERGE_NEXT_STEP = 'integração LOCAL — nada foi publicado. Rode `git pu
626
653
  function mergeOutput({ feature, branch, worktreePath, teardown, shedBackup }) {
627
654
  return {
628
655
  finished: true,
656
+ integrated: true,
629
657
  mode: 'merge',
630
658
  feature,
631
659
  merged: branch,
@@ -641,6 +669,15 @@ function teardownExtras(teardown, worktreePath, branch, deleteBranch) {
641
669
  const out = {};
642
670
  if (teardown.ignoredBackup) out.worktreeIgnoredBackup = teardown.ignoredBackup;
643
671
  if (teardown.worktreeRemoved && teardown.discarded.length) out.discardedIgnored = teardown.discarded;
672
+ // WHAT was copied out, and what needed no copy because the primary root holds
673
+ // the identical bytes. Reported on both paths: after a removal these two lists
674
+ // are the whole answer to "did I just lose something?", and the user should
675
+ // not have to open the backup directory to find out.
676
+ if (teardown.shieldable?.length) out.worktreeIgnoredBackedUp = teardown.shieldable;
677
+ if (teardown.replicable?.length) out.replicatedInRoot = teardown.replicable;
678
+ // The infra reparse points dropped BEFORE git touched the directory — the
679
+ // proof the primary root's real `.claude/` was unlinked, never followed.
680
+ if (teardown.unlinked?.length) out.unlinked = teardown.unlinked;
644
681
  if (!teardown.worktreeRemoved) {
645
682
  out.path = worktreePath;
646
683
  out.branch = branch;
@@ -688,28 +725,6 @@ function gitStatusPorcelain(cwd) {
688
725
  }
689
726
  }
690
727
 
691
- /**
692
- * Resolve the `gh` invocation. Defaults to the `gh` binary on PATH. Overridable
693
- * via MORPH_GH_BIN: a plain string names a different binary (a corporate wrapper,
694
- * `hub`, …); a JSON array is a full argv prefix — e.g.
695
- * `["/path/to/node","/path/to/gh-shim.js"]` — which is how the tests inject a
696
- * cross-platform stub that execFileSync can actually spawn (Node ≥20 refuses to
697
- * run `.cmd`/`.bat` shims without `shell:true`, and shell:true would reopen the
698
- * arg-injection hole the whole file avoids).
699
- *
700
- * @returns {{ bin: string, prefix: string[] }}
701
- */
702
- function resolveGh() {
703
- const raw = process.env.MORPH_GH_BIN;
704
- if (raw && raw.trim().startsWith('[')) {
705
- try {
706
- const arr = JSON.parse(raw);
707
- if (Array.isArray(arr) && arr.length) return { bin: String(arr[0]), prefix: arr.slice(1).map(String) };
708
- } catch { /* not JSON — treat as a plain binary name below */ }
709
- }
710
- return { bin: raw && raw.trim() ? raw.trim() : 'gh', prefix: [] };
711
- }
712
-
713
728
  function ghAuthOk() {
714
729
  const { bin, prefix } = resolveGh();
715
730
  try {