@polymorphism-tech/morph-spec 8.42.0 → 8.43.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 (87) hide show
  1. package/CLAUDE.md +9 -1
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +48 -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 +5 -4
  8. package/framework/MORPH.md +10 -9
  9. package/framework/agents.json +1 -1
  10. package/framework/docs/CLI.md +10 -3
  11. package/framework/evals/proof-rubrics/mutation-proof.md +43 -13
  12. package/framework/hooks/claude-code/core/heavy-gate.js +72 -0
  13. package/framework/hooks/claude-code/core/heavy-release.js +34 -0
  14. package/framework/hooks/claude-code/core/worktree-context.js +21 -20
  15. package/framework/hooks/claude-code/core/worktree-create.js +4 -4
  16. package/framework/hooks/shared/agent-reap.js +89 -0
  17. package/framework/hooks/shared/e2e-check.js +3 -1
  18. package/framework/hooks/shared/graph-refresh.js +1 -9
  19. package/framework/hooks/shared/heavy-command.js +128 -0
  20. package/framework/hooks/shared/heavy-config.js +65 -0
  21. package/framework/hooks/shared/heavy-holders.js +85 -0
  22. package/framework/hooks/shared/heavy-slots.js +207 -0
  23. package/framework/hooks/shared/process-alive.js +8 -0
  24. package/framework/hooks/shared/worktree-root.js +93 -0
  25. package/framework/schemas/config.schema.json +80 -0
  26. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  27. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  28. package/framework/skills/core/morph-apply/SKILL.md +8 -3
  29. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  30. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  31. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  32. package/framework/skills/core/morph-implement/SKILL.md +9 -4
  33. package/framework/skills/core/morph-machine/SKILL.md +46 -0
  34. package/framework/skills/core/morph-media/SKILL.md +1 -1
  35. package/framework/skills/core/morph-plan/SKILL.md +1 -1
  36. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  37. package/framework/skills/core/morph-review/SKILL.md +45 -16
  38. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  39. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  40. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  41. package/framework/standards/PROVENANCE.json +10 -10
  42. package/framework/standards/frontend/design-system/ai-video-generation.md +2 -0
  43. package/framework/standards/frontend/scroll-driven/frame-scrub.md +11 -1
  44. package/package.json +1 -1
  45. package/src/commands/e2e/e2e.js +27 -8
  46. package/src/commands/mcp/mcp-setup.js +10 -9
  47. package/src/commands/project/doctor-procs.js +162 -0
  48. package/src/commands/project/doctor.js +42 -4
  49. package/src/commands/project/heavy.js +61 -0
  50. package/src/commands/project/init.js +1 -1
  51. package/src/commands/project/update.js +4 -0
  52. package/src/commands/project/worktree-reap.js +34 -0
  53. package/src/commands/project/worktree.js +78 -27
  54. package/src/commands/validation/mutate.js +200 -0
  55. package/src/commands/validation/verify.js +26 -1
  56. package/src/dashboard/scanner/features-scanner.js +4 -4
  57. package/src/dashboard/scanner/project-api.js +4 -4
  58. package/src/lib/detectors/claude-config-detector.js +30 -1
  59. package/src/lib/heavy/cleanup.js +70 -0
  60. package/src/lib/heavy/gate.js +96 -0
  61. package/src/lib/installers/mcp-installer.js +15 -2
  62. package/src/lib/mutation/adapters.js +90 -0
  63. package/src/lib/mutation/commands.js +60 -0
  64. package/src/lib/mutation/manifest.js +138 -0
  65. package/src/lib/mutation/plan.js +36 -0
  66. package/src/lib/mutation/report.js +66 -0
  67. package/src/lib/mutation/runner.js +351 -0
  68. package/src/lib/mutation/schemata.js +60 -0
  69. package/src/lib/mutation/snapshot.js +69 -0
  70. package/src/lib/mutation/subset.js +75 -0
  71. package/src/lib/mutation/trx.js +51 -0
  72. package/src/lib/procs/classify.js +91 -0
  73. package/src/lib/procs/list.js +124 -0
  74. package/src/lib/procs/mcp-config.js +103 -0
  75. package/src/lib/procs/prune.js +25 -0
  76. package/src/lib/procs/wslconfig.js +70 -0
  77. package/src/lib/projects/registry.js +6 -2
  78. package/src/lib/tasks/linux-runner.js +30 -3
  79. package/src/lib/tasks/test-runner.js +1 -1
  80. package/src/lib/validators/verify-runner.js +26 -8
  81. package/src/lib/worktree/agent-reaper.js +247 -0
  82. package/src/lib/worktree/dirs.js +3 -32
  83. package/src/lib/worktree/vscode-settings.js +113 -0
  84. package/src/lib/worktree/worktree-audit.js +49 -5
  85. package/src/lib/worktree/worktree-include.js +46 -0
  86. package/src/scripts/setup-infra.js +4 -0
  87. package/src/utils/hooks-installer.js +11 -4
@@ -725,7 +725,24 @@ export async function doctorWorktreesCommand(targetPath, options = {}) {
725
725
  }
726
726
  }
727
727
 
728
+ const { reapAgentWorktrees } = await import('../../lib/worktree/agent-reaper.js');
729
+ const reap = await reapAgentWorktrees(targetPath);
730
+ if (reap.ok) {
731
+ const gone = reap.reaped.map((r) => r.path ?? r.branch);
732
+ prune.agentReap = { reaped: gone, branchesDeleted: reap.branchesDeleted, preserved: reap.preserved, ...(reap.busy ? { busy: true } : {}) };
733
+ if (!options.json && gone.length) logger.success(`sobras de sub-agent removidas: ${gone.join(', ')}`);
734
+ if (!options.json && reap.busy) logger.warn('reaper de sub-agent já em execução em outro processo; nada foi feito nesta rodada');
735
+ }
736
+
728
737
  const { resolvePrimaryRoot } = await import('./worktree.js');
738
+ const { applyEditorSettings } = await import('../../lib/worktree/vscode-settings.js');
739
+ const editor = applyEditorSettings(resolvePrimaryRoot(targetPath));
740
+ if (editor.status !== 'off') {
741
+ prune.editorSettings = editor;
742
+ if (!options.json && editor.status === 'write') logger.success(`.vscode/settings.json: ${editor.added.length} exclusão(ões) de worktree adicionada(s)`);
743
+ if (!options.json && editor.status === 'suggest') logger.warn(`.vscode/settings.json não foi alterado (${editor.reason}); cole isto nele:\n${editor.snippet}`);
744
+ }
745
+
729
746
  const { applyBackupRetention, summarizeRetention } = await import('../../lib/worktree/finish-backup.js');
730
747
  // The explicit prune is the one that reaches the backups written before the
731
748
  // retention existed (no manifest): the automatic one never touches them.
@@ -788,6 +805,9 @@ export async function doctorWorktreesCommand(targetPath, options = {}) {
788
805
  if (problems.some(prunable) && !options.prune) {
789
806
  logger.dim('\n Rode `morph-spec doctor --worktrees --prune` para limpar os registros órfãos e os diretórios vazios.');
790
807
  }
808
+ if (issues.some((i) => i.type === 'agent-worktree-leftover' && i.reapable) && !options.prune) {
809
+ logger.dim('\n Rode `morph-spec worktree reap` (ou `doctor --worktrees --prune`) para remover as sobras de sub-agent que já pararam.');
810
+ }
791
811
  if (!problems.length) {
792
812
  logger.success('Nenhuma inconsistência — os itens acima são informativos.');
793
813
  return true;
@@ -932,25 +952,38 @@ export async function doctorAiCommand(targetPath, options = {}) {
932
952
  }
933
953
 
934
954
  export async function doctorCommand(options = {}) {
935
- // `--json` and `--prune` only mean something under `--worktrees`. Falling
955
+ // `--json` and `--prune` only mean something under `--worktrees` or `--procs`. Falling
936
956
  // through to the human health check would hand a script a banner and a table
937
957
  // where it asked for JSON — a caller has no way to notice a flag that was
938
958
  // dropped in silence, so refuse instead of pretending.
939
959
  const scoped = ['json', 'prune'].filter((f) => options[f]);
940
- if (scoped.length && !options.worktrees) {
960
+ if (scoped.length && !options.worktrees && !options.procs) {
941
961
  const flags = scoped.map((f) => `--${f}`).join(' e ');
942
- logger.error(`${flags} só se aplica${scoped.length > 1 ? 'm' : ''} a \`morph-spec doctor --worktrees\`.`);
962
+ logger.error(`${flags} só se aplica${scoped.length > 1 ? 'm' : ''} a \`morph-spec doctor --worktrees\` ou \`morph-spec doctor --procs\`.`);
943
963
  logger.dim(` Rode: morph-spec doctor --worktrees ${scoped.map((f) => `--${f}`).join(' ')}`);
944
964
  process.exitCode = 1;
945
965
  return false;
946
966
  }
947
967
 
968
+ // `--prune` é uma flag só e poda coisas diferentes em cada modo; juntar os dois
969
+ // modos faria as duas podas destrutivas rodarem sob um único pedido.
970
+ if (options.procs) {
971
+ const conflicts = ['full', 'mcp', 'worktrees', 'reset'].filter((f) => options[f]);
972
+ if (conflicts.length) {
973
+ const flags = conflicts.map((f) => `--${f}`).join(' e ');
974
+ logger.error(`--procs não se combina com ${flags} — cada superfície tem o seu relatório, o seu código de saída e a sua poda.`);
975
+ logger.dim(' Rode: morph-spec doctor --procs');
976
+ process.exitCode = 1;
977
+ return false;
978
+ }
979
+ }
980
+
948
981
  // `--ai` é superfície própria (D1). Combiná-la com outra superfície pediria
949
982
  // dois relatórios com dois códigos de saída no mesmo processo — o segundo
950
983
  // apagaria o primeiro em silêncio. Mesmo molde da guarda acima: recusa em
951
984
  // vez de fingir.
952
985
  if (options.ai) {
953
- const conflicts = ['full', 'mcp', 'worktrees'].filter((f) => options[f]);
986
+ const conflicts = ['full', 'mcp', 'worktrees', 'procs'].filter((f) => options[f]);
954
987
  if (conflicts.length) {
955
988
  const flags = conflicts.map((f) => `--${f}`).join(' e ');
956
989
  logger.error(`--ai não se combina com ${flags} — cada superfície tem o seu relatório e o seu código de saída.`);
@@ -1011,6 +1044,11 @@ export async function doctorCommand(options = {}) {
1011
1044
  return clean;
1012
1045
  }
1013
1046
 
1047
+ if (options.procs) {
1048
+ const { doctorProcsCommand } = await import('./doctor-procs.js');
1049
+ return doctorProcsCommand(process.cwd(), options);
1050
+ }
1051
+
1014
1052
  const targetPath = process.cwd();
1015
1053
 
1016
1054
  logger.header('MORPH-SPEC Health Check');
@@ -0,0 +1,61 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import chalk from 'chalk';
3
+ import { logger } from '../../utils/logger.js';
4
+ import { readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js';
5
+ import { createHeavyGate, HeavyBusyError } from '../../lib/heavy/gate.js';
6
+ import { describeHolder, heavyDir, heavyStatus } from '../../../framework/hooks/shared/heavy-slots.js';
7
+
8
+ const EXIT_BUSY = 3;
9
+ const NEEDS_CMD_SHIM = /^(npm|npx|pnpm|yarn)$/;
10
+
11
+ export function heavyStatusCommand(options = {}) {
12
+ const config = readHeavyConfig(process.cwd());
13
+ const status = heavyStatus({ dir: heavyDir(), maxHoldMs: config.maxHoldMinutes * 60_000 });
14
+ const report = {
15
+ enabled: config.enabled,
16
+ slots: config.slots,
17
+ minFreeMemGb: config.minFreeMemGb,
18
+ dir: status.dir,
19
+ freeGb: Number(status.freeGb.toFixed(2)),
20
+ holders: status.holders.map(({ pid, label, feature, node, cwd, since, leaseId }) => ({ pid, label, feature, node, cwd, since, ...(leaseId ? { leaseId } : {}) })),
21
+ exclusive: status.exclusive ? { pid: status.exclusive.pid, label: status.exclusive.label, since: status.exclusive.since } : null,
22
+ };
23
+
24
+ if (options.json) {
25
+ console.log(JSON.stringify(report, null, 2));
26
+ return report;
27
+ }
28
+
29
+ logger.header('Slots de nós pesados (build, testes, e2e)');
30
+ if (!config.enabled) console.log(chalk.yellow(' ⚠ semáforo desligado (heavy.enabled: false ou MORPH_HEAVY=off)'));
31
+ console.log(chalk.gray(` diretório: ${report.dir}`));
32
+ const memMark = report.freeGb < config.minFreeMemGb ? chalk.yellow('⚠') : chalk.green('✓');
33
+ console.log(` ${memMark} RAM livre: ${report.freeGb.toFixed(1)} GB (mínimo ${config.minFreeMemGb} GB)`);
34
+ console.log(` slots em uso: ${status.holders.length}/${config.slots}`);
35
+ for (const h of status.holders) console.log(chalk.gray(` • ${describeHolder(h)}${h.cwd ? ` — ${h.cwd}` : ''}`));
36
+ if (status.exclusive) console.log(chalk.yellow(` ⚠ limpeza do servidor de build em curso (${describeHolder(status.exclusive)})`));
37
+ logger.blank();
38
+ return report;
39
+ }
40
+
41
+ export function heavyRunCommand(command, options = {}) {
42
+ const [file, ...args] = command;
43
+ const gate = createHeavyGate({ projectPath: process.cwd(), label: options.label ?? 'run' });
44
+ try {
45
+ gate.enter(file);
46
+ } catch (err) {
47
+ if (!(err instanceof HeavyBusyError)) throw err;
48
+ console.error(chalk.yellow(`\n⏳ ${file} não rodou: ${err.message}. Tente de novo quando liberar (\`morph-spec heavy status\`).\n`));
49
+ process.exit(EXIT_BUSY);
50
+ }
51
+
52
+ const exe = process.platform === 'win32' && NEEDS_CMD_SHIM.test(file) ? `${file}.cmd` : file;
53
+ const res = spawnSync(exe, args, { stdio: 'inherit', shell: exe.endsWith('.cmd') });
54
+ gate.release();
55
+
56
+ if (res.error) {
57
+ console.error(chalk.red(`\n❌ não consegui executar ${file}: ${res.error.message}\n`));
58
+ process.exit(127);
59
+ }
60
+ process.exit(res.status ?? 1);
61
+ }
@@ -288,7 +288,7 @@ export async function initCommand(options) {
288
288
  spinner.stop();
289
289
  const existingMcps = claudeConfigDetected?.mcpServers || [];
290
290
  const orchestration = orchestrateMcpSetup(targetPath, detectedStack, existingMcps,
291
- { integrations: config.project?.integrations ?? [] });
291
+ { integrations: config.project?.integrations ?? [], enabledPlugins: claudeConfigDetected?.enabledPlugins ?? [] });
292
292
 
293
293
  const autoNames = Object.keys(orchestration.autoInstallable);
294
294
  const manualNames = Object.keys(orchestration.needsManualSetup);
@@ -33,6 +33,7 @@ import { getProjectTags } from '../../lib/stack-filter.js';
33
33
  import { updateClaudeMdAgentsBlock, installRulesForStack } from '../../scripts/setup-infra.js';
34
34
  import { reconcileLegacyTemplatesCopy } from '../../lib/installers/templates-reconcile.js';
35
35
  import { createEffects } from '../../lib/installers/effects.js';
36
+ import { applyEditorSettings } from '../../lib/worktree/vscode-settings.js';
36
37
  import { registerProject } from '../../lib/projects/registry.js';
37
38
  import {
38
39
  writeGraphifyignore, describeOutdatedIgnore, graphifyignoreTracked,
@@ -729,6 +730,9 @@ export async function updateCommand(options = {}) {
729
730
  await updateGitignore(targetPath, fx);
730
731
  await ensureGitattributes(targetPath, fx);
731
732
 
733
+ updateSpinner.text = 'Editor excludes for worktrees (.vscode/settings.json)...';
734
+ try { applyEditorSettings(targetPath, { fx }); } catch { /* fail-open */ }
735
+
732
736
  // Migration: a .graphifyignore the previous version generated and nobody
733
737
  // edited is swapped for the current one when its RULES change (test doubles
734
738
  // and fixtures back in the graph — a header-only difference is left alone);
@@ -0,0 +1,34 @@
1
+ import chalk from 'chalk';
2
+ import { reapAgentWorktrees } from '../../lib/worktree/agent-reaper.js';
3
+
4
+ const label = (item) => item.path ?? item.branch ?? item.hex;
5
+
6
+ function printReport(out) {
7
+ const mode = out.dryRun ? ' (simulação)' : '';
8
+ console.log(chalk.bold(`\nWorktrees de sub-agent${mode} — graça de ${out.graceMinutes} min\n`));
9
+ if (out.busy) console.log(chalk.yellow(' ⏳ outro reaper está em execução; nada foi feito\n'));
10
+
11
+ for (const item of out.wouldReap ?? []) console.log(chalk.cyan(` → removeria: ${label(item)}`));
12
+ for (const item of out.reaped) console.log(chalk.green(` ✓ removido: ${label(item)}${item.backup ? chalk.gray(` (backup: ${item.backup})`) : ''}`));
13
+ for (const item of out.preserved) {
14
+ console.log(chalk.yellow(` ⚠ preservado: ${label(item)} — ${item.reason}${item.detail ? `: ${item.detail}` : ''}`));
15
+ }
16
+ if (out.active) console.log(chalk.gray(` ${out.active} agente(s) ainda ativo(s), intocado(s)`));
17
+ const nothing = !out.reaped.length && !out.preserved.length && !out.active && !(out.wouldReap ?? []).length;
18
+ if (nothing && !out.busy) console.log(chalk.green(' ✓ nenhuma worktree de sub-agent sobrando'));
19
+ console.log();
20
+ }
21
+
22
+ export async function worktreeReapCommand(options = {}) {
23
+ const grace = options.grace === undefined ? undefined : Number(options.grace);
24
+ if (grace !== undefined && !(Number.isFinite(grace) && grace >= 0)) {
25
+ console.error(chalk.red(`--grace precisa ser um número de minutos >= 0 (recebi "${options.grace}")`));
26
+ process.exit(2);
27
+ }
28
+
29
+ const out = await reapAgentWorktrees(options.cwd || process.cwd(), { dryRun: options.dryRun === true, graceMinutes: grace });
30
+ if (options.json) console.log(JSON.stringify(out));
31
+ else if (out.ok) printReport(out);
32
+ else console.log(chalk.gray(` fora de um repositório git — nada a fazer (${out.error})`));
33
+ return out;
34
+ }
@@ -58,6 +58,21 @@ import { dropLeftoverIgnore, withoutLeftoverIgnore } from '../../../framework/ho
58
58
  // rationale documented there); re-exported here so existing importers and the
59
59
  // dependency-matrix row keep pointing at this file.
60
60
  import { WORKTREES_DIR, LEGACY_WORKTREES_DIR, WORKTREE_DIR_NAMES } from '../../lib/worktree/dirs.js';
61
+ import { applyEditorSettings } from '../../lib/worktree/vscode-settings.js';
62
+ import { copyWorktreeIncludes } from '../../lib/worktree/worktree-include.js';
63
+ import { containerDirs, externalContainer, newWorktreesDir } from '../../../framework/hooks/shared/worktree-root.js';
64
+
65
+ /** Fail-open: the editor file is a convenience, never a reason a worktree is not created. */
66
+ function rootAndEditorReport(root) {
67
+ const { warning } = externalContainer(root);
68
+ const worktreeRoot = warning ? { worktreeRootWarning: warning } : {};
69
+ try {
70
+ const report = applyEditorSettings(root);
71
+ return report.status === 'off' ? worktreeRoot : { ...worktreeRoot, editorSettings: report };
72
+ } catch {
73
+ return worktreeRoot;
74
+ }
75
+ }
61
76
  export { WORKTREES_DIR, LEGACY_WORKTREES_DIR, WORKTREE_DIR_NAMES };
62
77
 
63
78
  // ── Pure helpers (exported for tests) ──────────────────────────────────────
@@ -146,7 +161,8 @@ export function worktreeExists(worktreePath, cwd = process.cwd()) {
146
161
  export function buildWorktreePath(feature, cwd = process.cwd(), options = {}) {
147
162
  const suffix = options.fresh ? `-${getDateSuffix()}` : '';
148
163
  const normalizedCwd = cwd.replace(/\\/g, '/');
149
- return `${normalizedCwd}/${WORKTREES_DIR}/${feature}${suffix}`;
164
+ const container = externalContainer(normalizedCwd).dir ?? `${normalizedCwd}/${WORKTREES_DIR}`;
165
+ return `${container}/${feature}${suffix}`;
150
166
  }
151
167
 
152
168
  /**
@@ -514,7 +530,7 @@ export async function runWorktreeSetup(feature, options = {}) {
514
530
  }
515
531
 
516
532
  // Create worktree directory parent if needed
517
- const parentDir = join(cwd, WORKTREES_DIR);
533
+ const parentDir = newWorktreesDir(cwd);
518
534
  if (!existsSync(parentDir)) {
519
535
  mkdirSync(parentDir, { recursive: true });
520
536
  }
@@ -524,6 +540,7 @@ export async function runWorktreeSetup(feature, options = {}) {
524
540
 
525
541
  // Set up infrastructure junctions (node_modules, .claude, .morph/framework).
526
542
  setupInfraLinks(worktreePath, cwd);
543
+ const included = copyWorktreeIncludes(worktreePath, cwd);
527
544
 
528
545
  // Fleet env: allocate this feature's deterministic port block and write the
529
546
  // worktree-local manifest (.morph/worktree.env). Fail-open — a worktree
@@ -573,9 +590,11 @@ export async function runWorktreeSetup(feature, options = {}) {
573
590
  branch,
574
591
  branchedFrom,
575
592
  feature,
593
+ ...(included.length ? { worktreeInclude: included } : {}),
576
594
  materialized: materialized.materialized,
577
595
  ...(envBlock ? { env: { base: envBlock.base, range: envBlock.range } } : {}),
578
596
  ...(graph.seeded ? { graph: 'seeded' } : {}),
597
+ ...rootAndEditorReport(resolvePrimaryRoot(cwd)),
579
598
  nextStep: `Abra o Claude Code em ${worktreePath} para trabalhar nesta feature`,
580
599
  };
581
600
  } catch (err) {
@@ -699,13 +718,14 @@ export async function runWorktreeProvision(name, options = {}) {
699
718
  }
700
719
  }
701
720
 
702
- const parentDir = join(root, WORKTREES_DIR);
721
+ const parentDir = newWorktreesDir(root);
703
722
  if (!existsSync(parentDir)) mkdirSync(parentDir, { recursive: true });
704
723
 
705
724
  execFileSync('git', ['worktree', 'add', worktreePath, branch], { cwd: root, stdio: 'pipe' });
706
725
 
707
726
  setupInfraLinks(worktreePath, root);
708
727
  const envFiles = copyRootEnvFiles(worktreePath, root);
728
+ const included = copyWorktreeIncludes(worktreePath, root);
709
729
  // A tree born from `morph/<f>` works FOR that feature: MORPH_FEATURE says
710
730
  // so, while the port block stays the task's own (`task/<name>`).
711
731
  const forFeature = String(options.base || '').match(/^morph\/(.+)$/)?.[1] ?? null;
@@ -724,9 +744,11 @@ export async function runWorktreeProvision(name, options = {}) {
724
744
  name,
725
745
  ...(forFeature ? { feature: forFeature } : {}),
726
746
  ...(envFiles.length ? { envFiles } : {}),
747
+ ...(included.length ? { worktreeInclude: included } : {}),
727
748
  materialized: materialized.materialized,
728
749
  ...(envBlock ? { env: { base: envBlock.base, range: envBlock.range } } : {}),
729
750
  ...(graph.seeded ? { graph: 'seeded' } : {}),
751
+ ...rootAndEditorReport(root),
730
752
  };
731
753
  } catch (err) {
732
754
  return { ok: true, exitCode: 0, created: false, error: (err.message || 'unknown').slice(0, 200), name };
@@ -886,7 +908,7 @@ export async function allocateWorktreeEnv(worktreePath, root, envKey, { morphFea
886
908
  * Parse `git worktree list --porcelain` into structured entries.
887
909
  * Fail-open: returns `[]` on any git error (e.g. not a git repo).
888
910
  * @param {string} cwd
889
- * @returns {Array<{ path: string, branch: string|null, head: string|null }>}
911
+ * @returns {Array<{ path: string, branch: string|null, head: string|null, locked: boolean }>}
890
912
  */
891
913
  export function parseWorktreeList(cwd) {
892
914
  let output;
@@ -901,7 +923,9 @@ export function parseWorktreeList(cwd) {
901
923
  for (const line of output.split('\n')) {
902
924
  if (line.startsWith('worktree ')) {
903
925
  if (current) entries.push(current);
904
- current = { path: line.slice('worktree '.length).trim().replace(/\\/g, '/'), branch: null, head: null };
926
+ current = { path: line.slice('worktree '.length).trim().replace(/\\/g, '/'), branch: null, head: null, locked: false };
927
+ } else if (/^locked(\s|$)/.test(line) && current) {
928
+ current.locked = true;
905
929
  } else if (line.startsWith('branch ') && current) {
906
930
  current.branch = line.slice('branch '.length).trim();
907
931
  } else if (line.startsWith('HEAD ') && current) {
@@ -937,8 +961,8 @@ export function findWorktreeForFeature(feature, root) {
937
961
 
938
962
  let match = entries.find((e) => e.branch === refName);
939
963
  if (!match) {
940
- const guess = buildWorktreePath(feature, root).replace(/\\/g, '/');
941
- match = entries.find((e) => e.path === guess);
964
+ const guesses = containerDirs(root).map((dir) => `${dir}/${feature}`);
965
+ match = entries.find((e) => guesses.includes(e.path));
942
966
  }
943
967
  if (!match) return null;
944
968
 
@@ -1388,6 +1412,12 @@ export function pickRemovalFailure(stderrs) {
1388
1412
  * `stamp` names the backup; `finish` passes the one it gave the shed, so both
1389
1413
  * folders of that finish count as one backup under the retention.
1390
1414
  *
1415
+ * `relinkOnFailure` (the agent reaper) also restores the junctions when git
1416
+ * FAILED, not only when we refused: the reaper removes trees on a schedule, and
1417
+ * a lock that leaves a possibly-live agent without `.claude` kills its hooks.
1418
+ * Off by default — a person retrying a failed removal by hand is what the
1419
+ * unlinked state protects. A tree git already de-registered has nothing to link.
1420
+ *
1391
1421
  * @param {string} worktreePath
1392
1422
  * @param {{ root: string, feature: string, branch: string, options?: object, deleteBranch?: boolean, stamp?: string }} ctx
1393
1423
  * @returns {Promise<{ worktreeRemoved: boolean, ignoredBackup: string|null, discarded: string[],
@@ -1395,7 +1425,7 @@ export function pickRemovalFailure(stderrs) {
1395
1425
  * relinked: boolean, refused: boolean, infraLeftUnlinked: string[],
1396
1426
  * removalFailure: {kind: string, detail: string}|null }>}
1397
1427
  */
1398
- export async function teardownWorktree(worktreePath, { root, feature, branch, options = {}, deleteBranch = true, addressByTarget = false, stamp }) {
1428
+ export async function teardownWorktree(worktreePath, { root, feature, branch, options = {}, deleteBranch = true, addressByTarget = false, relinkOnFailure = false, stamp }) {
1399
1429
  // Drop the infra junctions FIRST, without following them — this is what makes
1400
1430
  // a removal survivable (see unlinkWorktreeInfraLinks). Runs unconditionally.
1401
1431
  //
@@ -1463,7 +1493,8 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
1463
1493
  // and the caller falls back to telling the user to run `worktree link --all`.
1464
1494
  const refused = !canRemove;
1465
1495
  let relinked = false;
1466
- if (refused && unlinked.length && existsSync(worktreePath)) {
1496
+ const relinkAfterFailure = relinkOnFailure && !refused && !worktreeRemoved && !removalFailure?.alreadyDeregistered;
1497
+ if ((refused || relinkAfterFailure) && unlinked.length && existsSync(worktreePath)) {
1467
1498
  try {
1468
1499
  setupInfraLinks(worktreePath, root);
1469
1500
  relinked = true;
@@ -1471,7 +1502,7 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
1471
1502
  }
1472
1503
  // What the caller must tell the user about on the FAILURE path: the worktree
1473
1504
  // survived without its shared infra, on purpose.
1474
- const infraLeftUnlinked = (!worktreeRemoved && !refused) ? unlinked : [];
1505
+ const infraLeftUnlinked = (!worktreeRemoved && !refused && !relinked) ? unlinked : [];
1475
1506
 
1476
1507
  // Every branch names `morph-spec worktree remove` and NEVER `git worktree
1477
1508
  // remove`. The raw command follows the junctions this function manages, and a
@@ -1508,8 +1539,14 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
1508
1539
  + (removalFailure.kind === 'in-use' ? ' — EM USO por outro processo (instância do Claude Code, VS Code, dev server ou terminal com cwd nele)' : '')
1509
1540
  + `${removalFailure.detail ? ` (${removalFailure.detail})` : ''}. \`${removeCmd}\` não o alcança mais — o git não o conhece. Feche o que o usa, confira que está vazio e sem junction, e apague só o diretório (sem recursão)`
1510
1541
  : removalFailure?.kind === 'in-use'
1511
- ? `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\``
1512
- : `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`;
1542
+ ? `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}"). `
1543
+ + (relinked
1544
+ ? `A infra compartilhada foi RESTAURADA — o worktree segue utilizável. Feche o que está usando o diretório e rode \`${removeCmd}\``
1545
+ : `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\``)
1546
+ : `worktree NÃO removido: a remoção falhou${removalFailure?.detail ? ` (${removalFailure.detail})` : ''}. `
1547
+ + (relinked
1548
+ ? `A infra compartilhada foi RESTAURADA — o worktree segue utilizável. Rode \`${removeCmd}\` para tentar de novo`
1549
+ : `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`);
1513
1550
 
1514
1551
  return {
1515
1552
  worktreeRemoved,
@@ -1580,8 +1617,9 @@ export function sessionAnchoredInWorktree(worktreePath, env = process.env) {
1580
1617
  // ── remove ───────────────────────────────────────────────────────────────
1581
1618
 
1582
1619
  /**
1583
- * Command handler for `morph-spec worktree remove <feature>` /
1584
- * `morph-spec worktree remove --target <path>`.
1620
+ * Non-exiting core of `morph-spec worktree remove <feature>` /
1621
+ * `morph-spec worktree remove --target <path>` — returns `{ out, code }` instead
1622
+ * of printing and exiting, so the agent reaper can call it in a loop.
1585
1623
  *
1586
1624
  * WHY THIS EXISTS: `teardownWorktree` — the only removal that drops the infra
1587
1625
  * junctions before letting git near the directory — used to be reachable ONLY
@@ -1600,13 +1638,14 @@ export function sessionAnchoredInWorktree(worktreePath, env = process.env) {
1600
1638
  * closing a feature is still `morph-spec finish`.
1601
1639
  *
1602
1640
  * @param {string|undefined} featureArg
1603
- * @param {{ target?: string, discardIgnored?: boolean, deleteBranch?: boolean, ifClean?: boolean, cwd?: string }} [options]
1641
+ * @param {{ target?: string, discardIgnored?: boolean, deleteBranch?: boolean, ifClean?: boolean, relinkOnFailure?: boolean, cwd?: string }} [options]
1642
+ * @returns {Promise<{ out: object, code: 0|1 }>}
1604
1643
  */
1605
- export async function worktreeRemoveCommand(featureArg, options = {}) {
1644
+ export async function runWorktreeRemove(featureArg, options = {}) {
1606
1645
  const cwdArg = options.cwd || process.cwd();
1607
- const emit = (obj, code) => { console.log(JSON.stringify(obj)); process.exit(code); };
1646
+ const outcome = (out, code) => ({ out, code });
1608
1647
 
1609
- if (!isGitRepo(cwdArg)) emit({ removed: false, error: 'not-a-git-repo' }, 1);
1648
+ if (!isGitRepo(cwdArg)) return outcome({ removed: false, error: 'not-a-git-repo' }, 1);
1610
1649
  const root = resolvePrimaryRoot(cwdArg);
1611
1650
 
1612
1651
  let worktreePath = null;
@@ -1623,11 +1662,11 @@ export async function worktreeRemoveCommand(featureArg, options = {}) {
1623
1662
  // is the same path-traversal class already fixed in delete/archive.
1624
1663
  const entry = parseWorktreeList(root).find((e) => e.path === worktreePath);
1625
1664
  if (!entry) {
1626
- emit({ removed: false, error: 'not-a-registered-worktree', target: worktreePath, root,
1665
+ return outcome({ removed: false, error: 'not-a-registered-worktree', target: worktreePath, root,
1627
1666
  hint: 'o path precisa ser um worktree registrado neste repo — confira `morph-spec worktree list`' }, 1);
1628
1667
  }
1629
1668
  if (worktreePath === root) {
1630
- emit({ removed: false, error: 'refuses-primary-root', path: worktreePath,
1669
+ return outcome({ removed: false, error: 'refuses-primary-root', path: worktreePath,
1631
1670
  hint: 'a raiz primária nunca é removível por este comando' }, 1);
1632
1671
  }
1633
1672
  branch = entry.branch ? entry.branch.replace(/^refs\/heads\//, '') : null;
@@ -1640,12 +1679,12 @@ export async function worktreeRemoveCommand(featureArg, options = {}) {
1640
1679
  }
1641
1680
  } else {
1642
1681
  if (!isValidFeatureName(feature)) {
1643
- emit({ removed: false, error: 'invalid-feature-name', feature: feature ?? null,
1682
+ return outcome({ removed: false, error: 'invalid-feature-name', feature: feature ?? null,
1644
1683
  hint: 'informe <feature> (kebab-case) ou use --target <path>' }, 1);
1645
1684
  }
1646
1685
  const found = findWorktreeForFeature(feature, root);
1647
1686
  if (!found || found.path.replace(/\\/g, '/') === root) {
1648
- emit({ removed: false, error: 'no-worktree-for-feature', feature, root,
1687
+ return outcome({ removed: false, error: 'no-worktree-for-feature', feature, root,
1649
1688
  hint: `nenhum worktree separado para "${feature}" — confira \`morph-spec worktree list\`` }, 1);
1650
1689
  }
1651
1690
  worktreePath = found.path;
@@ -1656,7 +1695,7 @@ export async function worktreeRemoveCommand(featureArg, options = {}) {
1656
1695
  // is MORE likely to hit it: the natural moment to run it is right after
1657
1696
  // working inside the worktree.
1658
1697
  if (cwdIsInsideWorktree(cwdArg, worktreePath)) {
1659
- emit({ removed: false, error: 'cwd-inside-worktree', path: worktreePath, root,
1698
+ return outcome({ removed: false, error: 'cwd-inside-worktree', path: worktreePath, root,
1660
1699
  hint: `saia do worktree antes de removê-lo — o processo trava o próprio diretório de trabalho. Rode de novo a partir de "${root}"` }, 1);
1661
1700
  }
1662
1701
 
@@ -1666,7 +1705,7 @@ export async function worktreeRemoveCommand(featureArg, options = {}) {
1666
1705
  // refusal IS the deferral.
1667
1706
  if (sessionAnchoredInWorktree(worktreePath)) {
1668
1707
  const again = featureAddressable && feature ? `morph-spec worktree remove ${feature}` : `morph-spec worktree remove --target "${worktreePath}"`;
1669
- emit({ removed: false, error: 'session-anchored-in-worktree', path: worktreePath, root,
1708
+ return outcome({ removed: false, error: 'session-anchored-in-worktree', path: worktreePath, root,
1670
1709
  hint: `a sessão do Claude Code que rodou este comando está ancorada neste worktree — os hooks dela resolvem \`.morph/framework\` por ele, e desmontá-lo quebraria todo tool call seguinte (e o diretório seguiria travado pela sessão). Feche esta sessão e rode \`${again}\` de uma sessão aberta em "${root}"` }, 1);
1671
1710
  }
1672
1711
 
@@ -1681,12 +1720,12 @@ export async function worktreeRemoveCommand(featureArg, options = {}) {
1681
1720
  try {
1682
1721
  status = execFileSync('git', ['status', '--porcelain', '--untracked-files=all'], { cwd: worktreePath, stdio: 'pipe' }).toString();
1683
1722
  } catch {
1684
- emit({ removed: false, error: 'status-failed', path: worktreePath, root,
1723
+ return outcome({ removed: false, error: 'status-failed', path: worktreePath, root,
1685
1724
  hint: 'não foi possível provar que o worktree está limpo — nada foi removido' }, 1);
1686
1725
  }
1687
1726
  const dirt = withoutLeftoverIgnore(worktreePath, status.split(/\r?\n/).filter((l) => l.trim()));
1688
1727
  if (dirt.length) {
1689
- emit({ removed: false, error: 'worktree-dirty', path: worktreePath, root, dirt: dirt.slice(0, 20),
1728
+ return outcome({ removed: false, error: 'worktree-dirty', path: worktreePath, root, dirt: dirt.slice(0, 20),
1690
1729
  hint: `o worktree tem trabalho não commitado — commit ou descarte antes, ou rode \`morph-spec worktree remove --target "${worktreePath}"\` sem --if-clean para remover com backup` }, 1);
1691
1730
  }
1692
1731
  }
@@ -1725,6 +1764,7 @@ export async function worktreeRemoveCommand(featureArg, options = {}) {
1725
1764
  // Removing a worktree is not closing a feature: the branch is the work, and
1726
1765
  // deleting it here would discard unintegrated commits. Opt-in only.
1727
1766
  deleteBranch: options.deleteBranch === true,
1767
+ relinkOnFailure: options.relinkOnFailure === true,
1728
1768
  // Re-run advice must address the worktree the way it was addressable here:
1729
1769
  // a `--target` worktree has no feature name that would resolve.
1730
1770
  addressByTarget: !featureAddressable,
@@ -1763,7 +1803,18 @@ export async function worktreeRemoveCommand(featureArg, options = {}) {
1763
1803
  const retention = enforceBackupRetention(root, { protectStamps: [stamp] });
1764
1804
  if (retention) out.backupRetention = retention;
1765
1805
  }
1766
- emit(out, teardown.worktreeRemoved ? 0 : 1);
1806
+ return outcome(out, teardown.worktreeRemoved ? 0 : 1);
1807
+ }
1808
+
1809
+ /**
1810
+ * `morph-spec worktree remove` — prints the JSON verdict and exits with its code.
1811
+ * @param {string|undefined} featureArg
1812
+ * @param {object} [options] - see `runWorktreeRemove`
1813
+ */
1814
+ export async function worktreeRemoveCommand(featureArg, options = {}) {
1815
+ const { out, code } = await runWorktreeRemove(featureArg, options);
1816
+ console.log(JSON.stringify(out));
1817
+ process.exit(code);
1767
1818
  }
1768
1819
 
1769
1820
  // ── list ─────────────────────────────────────────────────────────────────