@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
@@ -10,24 +10,39 @@ Um teste verde prova que o código roda. Só a **mutação** prova que o teste M
10
10
  propósito a linha que carrega a decisão e veja quem grita. Se ninguém grita, a suíte é decoração —
11
11
  e foi assim que 11 testes verdes cobriram um artefato que o build recusava.
12
12
 
13
- A pergunta desta rubrica é sempre a mesma: **você mutou o call site que carrega a decisão, rodou a
14
- suíte inteira, leu o MOTIVO da falha, e devolveu o arquivo byte a byte?**
13
+ A pergunta desta rubrica é sempre a mesma: **você mutou o call site que carrega a decisão, provou
14
+ cada sobrevivente contra a suíte inteira, leu o MOTIVO da falha, e devolveu o arquivo byte a byte
15
+ — com o binário provado?**
16
+
17
+ O método é `morph-spec mutate` (manifesto de mutantes → uma chamada → a seção pronta). Você escolhe
18
+ os mutantes; a ferramenta escolhe o subconjunto de testes, roda o lote, refaz todo sobrevivente com o
19
+ mutante compilado de verdade contra a suíte INTEIRA e reverte por bytes.
15
20
 
16
21
  ## Bandas 0-10
17
22
 
18
23
  | 0-3 | 4-6 | 7-8 | 9-10 |
19
24
  |---|---|---|---|
20
- | Nenhuma mutação. Ou mutação declarada sem diff, sem md5 e sem testes nomeados — afirmação, não prova. Ou revert por `git checkout --`, que descarta trabalho não commitado de terceiros | Mutou, mas no lugar errado (o `if` refatorado, não o call site que decide) ou com filtro estreito ("derrubou 3" medido sobre 40 testes de um arquivo só). Reportou md5 sem ler o motivo da falha | Mutação no call site certo, suíte inteira, diff e md5 antes/depois. Falta o MOTIVO literal da falha, ou uma cláusula de um predicado composto ficou sem rodada própria | Uma rodada por cláusula, cada uma com arquivo:linha, diff, md5 antes/depois, contagem sobre a suíte INTEIRA e a mensagem literal de falha. Revert por snapshot de bytes + `os.utime`, com `git status --porcelain src/` vazio ao fim |
25
+ | Nenhuma mutação. Ou mutação declarada sem diff, sem md5 e sem testes nomeados — afirmação, não prova. Ou revert por `git checkout --`, que descarta trabalho não commitado de terceiros | Mutou, mas no lugar errado (o `if` refatorado, não o call site que decide) ou declarou "sobreviveu" / "derruba N" a partir de um filtro escolhido por você ("derrubou 3" medido sobre 40 testes de um arquivo só). Reportou md5 sem ler o motivo da falha | Mutação no call site certo, todo sobrevivente refeito contra a suíte INTEIRA, diff e md5 antes/depois. Falta o MOTIVO literal da falha, ou uma cláusula de um predicado composto ficou sem rodada própria | Uma rodada por cláusula, cada uma com arquivo:linha, diff, md5 antes/depois, contagem com o ESCOPO dito (`≥K de N` no subconjunto que a ferramenta escolheu; `K de M` na suíte inteira) e a mensagem literal de falha. Revert por snapshot de bytes com escrita nova, rebuild forçado e DLL final igual (md5) à de antes do 1º mutante, com `git status --porcelain src/` vazio ao fim |
21
26
 
22
27
  ## Anti-padrões literais
23
28
 
24
29
  - **Mutar o `if` refatorado em vez do call site que carrega a decisão** — o extract-method moveu a
25
30
  regra; o teste continua batendo no invólucro. Ancore onde o valor é DECIDIDO. (memória: guard do
26
31
  remédio, cuja checagem real vivia duas camadas acima do `if` mutado)
27
- - **Rodar com filtro e reportar "derruba N"** — "3 de 40" e "3 de 2684" são afirmações diferentes.
28
- Só a suíte **inteira** autoriza a frase "derruba N". (memória: F30, filtro no Gate 3)
32
+ - **Reportar "sobreviveu" ou "derruba N" a partir de um filtro que você escolheu** — "3 de 40" e
33
+ "3 de 2684" são afirmações diferentes. **"Sobreviveu" só vale contra a suíte INTEIRA**, com o
34
+ mutante compilado de verdade. **"Morto" pode sair de um subconjunto, se a ferramenta o escolheu**
35
+ (testes que a feature alterou + os que citam o tipo mutado): teste que falha no subconjunto falha
36
+ na suíte, então a morte é a mesma — mas a contagem passa a ser piso (`≥K de N (subconjunto)`), e
37
+ `K de M` só existe medido na suíte inteira. (memória: F30, filtro no Gate 3)
38
+ - **Contar como morto o teste instável** — morte na suíte inteira só vale se a mesma falha se repete
39
+ na segunda rodada (dos testes que falharam, isolados, ou da suíte inteira). Medido: um teste de host, sem relação com o mutante,
40
+ "derrubou" um mutante uma vez; em sete rodadas o mutante sobreviveu seis. Falha que não se
41
+ repete é sobrevivente com razão dita, não morte. (mesma rodada: `out var` removido que só compila
42
+ no schemata é `build-error`, não morto)
29
43
  - **Filtro tão estreito que a prova fica vácua** — mutar e rodar só o teste que você já sabia que
30
- quebraria não testa nada além da sua própria expectativa.
44
+ quebraria não testa nada além da sua própria expectativa. O subconjunto é da ferramenta, nunca
45
+ seu: escolhido à mão, ele volta a ser a sua expectativa.
31
46
  - **Aceitar um seed sem perguntar "que sequência real produz este seed?"** — fixture que só existe
32
47
  no teste prova o teste, não o sistema.
33
48
  - **Imprimir `diff` + md5 e parar por aí** — md5 prova que ALGO mudou, nunca O QUÊ. Sem a mensagem
@@ -36,13 +51,23 @@ suíte inteira, leu o MOTIVO da falha, e devolveu o arquivo byte a byte?**
36
51
  - **Reverter por replace reverso, ou por `git checkout --`** — o replace reverso erra em arquivo com
37
52
  ocorrências repetidas; o `git checkout --` **apaga alterações não commitadas de outra pessoa** e
38
53
  aconteceu de verdade num Gate 3 desta epic. Reverta por **snapshot/restore de bytes** (cópia feita
39
- ANTES) + `os.utime` para devolver o mtime, e confirme por `md5sum`.
54
+ ANTES), por escrita nova, e confirme por `md5sum`.
55
+ - **Devolver o mtime original com `os.utime` depois de restaurar** — os bytes voltam, mas com data
56
+ ANTIGA o arquivo fica mais velho que a DLL do último mutante: o MSBuild acha o binário em dia, não
57
+ recompila, e a rodada-base roda sobre o mutante (falso vermelho, ou pior: falso verde). Medido num
58
+ projeto .NET 10 de 697 arquivos: restore com data antiga → build de 1,9 s, nenhum `CoreCompile`,
59
+ DLL do mutante intacta; escrita nova → DLL de volta ao md5 da baseline. Restaure por **escrita
60
+ nova**, force o rebuild e **prove que o binário é o do disco** (md5 da DLL == a construída da
61
+ árvore limpa antes do 1º mutante).
40
62
  - **Escrever a mutação por heredoc do shell quando ela contém barra invertida** — o transporte come
41
63
  o escape: `\b` vira o byte `0x08`, `\n` vira quebra de linha real, e o regex mutado **não é o que
42
64
  você escreveu**. Sintoma: a mutação parece aplicada e o resultado não muda, ou o arquivo quebra
43
65
  num ponto que você não tocou. Diagnóstico em um comando: `cat -A` mostra `^H` onde devia estar
44
- `\b`. Saída: patch por **índice de linha** com o conteúdo montado sem barra invertida no fonte
45
- (`String.fromCharCode(92)`), ou ferramenta de edição estrutural — nunca heredoc.
66
+ `\b`. Medido: o transporte do Bash reduz `\\` a `\` e decodifica escapes unicode, até dentro de
67
+ heredoc de aspas simples. Saída: o manifesto de `morph-spec mutate` com a barra invertida do código
68
+ escrita como o token `{{BS}}` (a ferramenta a expande), ou patch por **índice de linha** com o
69
+ conteúdo montado sem barra invertida no fonte (`String.fromCharCode(92)`) — nunca a barra invertida
70
+ crua num heredoc.
46
71
  (memória: **três agentes** desta epic tropeçaram nisto; dois descartaram o resultado, um só
47
72
  descobriu ao conferir os bytes)
48
73
  - **Mutar antes de commitar o fix** — se a sessão morrer no meio, o que sobrevive é a mutação, não a
@@ -69,9 +94,14 @@ Na seção `## Mutações` do `evaluator-report.md`, uma linha por mutação, co
69
94
  ```markdown
70
95
  | # | Arquivo:linha | Mutação (diff) | md5 antes | md5 depois | Testes derrubados | Motivo da falha |
71
96
  |---|---|---|---|---|---|---|
72
- | M1 | src/lib/x.js:42 | `- if (a && b)` / `+ if (a)` | abc… | def… | 3 de 2684 | `expected 3 got 0` em x.test.js:88 |
97
+ | M1 | src/lib/x.js:42 | `- if (a && b)` / `+ if (a)` | abc… | def… | ≥3 de 212 (subconjunto) | `expected 3 got 0` em x.test.js:88 |
98
+ | M2 | src/lib/y.js:7 | `- return ok` / `+ return true` | 123… | 456… | 0 de 2684 (SOBREVIVEU, suíte inteira) | nenhum teste falhou na suíte inteira |
73
99
  ```
74
100
 
75
- E, ao fim da seção: **Reversão:** snapshot/restore de bytes + `os.utime`; `git status --porcelain
76
- src/` vazio. Feature sem código de produção declara a seção com a linha
77
- `não aplicável — feature sem código de produção`.
101
+ A coluna "Testes derrubados" sempre diz o escopo: `≥K de N (subconjunto)` é piso; `K de M (suíte
102
+ inteira)` é contagem; sobrevivente é sempre `0 de M (suíte inteira)`. `morph-spec mutate` imprime a
103
+ seção inteira nesse formato — cole-a, não a redigite.
104
+
105
+ E, ao fim da seção: **Reversão:** snapshot/restore de bytes por escrita nova; DLL final igual (md5)
106
+ à de antes do 1º mutante; `git status --porcelain src/` vazio. Feature sem código de produção
107
+ declara a seção com a linha `não aplicável — feature sem código de produção`.
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * heavy-gate.js — PreToolUse hook (Bash matcher), link of the Bash chain before worktree-env-inject.
4
+ *
5
+ * A sub-agent that runs `dotnet build` or `npm test` straight through Bash bypasses the heavy-node
6
+ * semaphore that `morph-spec verify` respects. With `heavy.gateBash: true` this hook takes a slot
7
+ * before such a command runs and `heavy-release` gives it back on Post/PostFailure, matched by
8
+ * `tool_use_id`. It never rewrites the command: a rewrite would be judged against the user's
9
+ * permission rules in its rewritten form, and would auto-approve what the prompt would have asked.
10
+ *
11
+ * A slot taken here has no live process to watch — the hook exits at once — so it lapses by TTL
12
+ * (the call's own timeout plus slack). A call refused by a permission rule or interrupted by the
13
+ * user fires no Post hook and keeps its slot until then.
14
+ *
15
+ * Waits below the platform's 600 s hook timeout, because a timed-out PreToolUse hook lets the call
16
+ * run: the refusal has to come from here. Fail-open otherwise.
17
+ */
18
+
19
+ import { stateExists } from '../../shared/state-reader.js';
20
+ import { blockResult, runStandalone } from '../../shared/hook-response.js';
21
+ import { heavyKind, leaseTtlMs } from '../../shared/heavy-command.js';
22
+
23
+ const HOOK_BUDGET_SEC = 540;
24
+
25
+ /**
26
+ * @param {object} payload - parsed PreToolUse stdin payload (Bash matcher)
27
+ * @param {{ env?: object, acquire?: Function }} [deps] - test seams; production passes none
28
+ * @returns {Promise<object|null>} a block result when no slot came free, else null (pass)
29
+ */
30
+ export async function run(payload, deps = {}) {
31
+ try {
32
+ if (payload?.tool_name && payload.tool_name !== 'Bash') return null;
33
+ const input = payload?.tool_input ?? {};
34
+ if (input.run_in_background) return null;
35
+ const kind = heavyKind(input.command);
36
+ if (!kind || !payload?.tool_use_id) return null;
37
+
38
+ // Loaded only here: this link runs on every Bash call and almost none is heavy.
39
+ const { readHeavyConfig } = await import('../../shared/heavy-config.js');
40
+ const { acquireHeavy, describeBusy, heavyDir } = await import('../../shared/heavy-slots.js');
41
+ const env = deps.env ?? process.env;
42
+ const dir = env.CLAUDE_PROJECT_DIR || payload?.cwd || process.cwd();
43
+ if (!stateExists(dir)) return null;
44
+ const config = readHeavyConfig(dir, env);
45
+ if (!config.enabled || !config.gateBash) return null;
46
+
47
+ const result = (deps.acquire ?? acquireHeavy)({
48
+ dir: heavyDir(env),
49
+ slots: config.slots,
50
+ label: kind,
51
+ feature: null,
52
+ node: null,
53
+ cwd: dir,
54
+ minFreeMemGb: config.minFreeMemGb,
55
+ waitTimeoutMs: Math.min(config.waitTimeoutSec, HOOK_BUDGET_SEC) * 1000,
56
+ maxHoldMs: config.maxHoldMinutes * 60_000,
57
+ lease: { id: payload.tool_use_id, ttlMs: leaseTtlMs(input, env) },
58
+ env,
59
+ log: () => {},
60
+ });
61
+ if (result.status !== 'busy') return null;
62
+
63
+ return blockResult(
64
+ `⏳ \`${kind}\` não rodou: ${describeBusy(result, config)}. Não é falha do código — aguarde e repita o MESMO comando. `
65
+ + '`morph-spec heavy status` mostra quem segura os slots; `heavy.gateBash: false` desliga esta espera.',
66
+ );
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ await runStandalone(run);
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * heavy-release.js — PostToolUse / PostToolUseFailure link: gives back the slot `heavy-gate` took
4
+ * for this Bash call, matched by `tool_use_id`.
5
+ *
6
+ * Silent and fail-open. A call that fires neither event (refused by a rule, interrupted) leaves its
7
+ * slot to the TTL. Releasing is not deciding, so this link is safe in the async failure binding.
8
+ */
9
+
10
+ import { existsSync } from 'node:fs';
11
+ import { stateExists } from '../../shared/state-reader.js';
12
+ import { runStandalone } from '../../shared/hook-response.js';
13
+
14
+ /**
15
+ * @param {object} payload - PostToolUse or PostToolUseFailure stdin payload
16
+ * @param {{ env?: object }} [deps] - test seam
17
+ * @returns {Promise<null>}
18
+ */
19
+ export async function run(payload, deps = {}) {
20
+ try {
21
+ if (payload?.tool_name !== 'Bash' || !payload.tool_use_id) return null;
22
+ const env = deps.env ?? process.env;
23
+ if (!stateExists(env.CLAUDE_PROJECT_DIR || payload?.cwd || process.cwd())) return null;
24
+ // Runs after every tool call: pay for the slot module only when a semaphore directory exists.
25
+ const { heavyDir } = await import('../../shared/heavy-holders.js');
26
+ const dir = heavyDir(env);
27
+ if (!existsSync(dir)) return null;
28
+ const { releaseLeaseById } = await import('../../shared/heavy-slots.js');
29
+ releaseLeaseById(dir, payload.tool_use_id);
30
+ } catch { /* fail-open */ }
31
+ return null;
32
+ }
33
+
34
+ await runStandalone(run);
@@ -51,34 +51,35 @@ import { acquireClaim, foreignClaim, claimAge } from '../../shared/claim-registr
51
51
  import { rotateActivityLogIfLarge } from '../../shared/activity-logger.js';
52
52
  import { rotateEventsLogIfLarge } from '../../shared/telemetry-logger.js';
53
53
  import { kickGraphRefresh, seedFromPrimary } from '../../shared/graph-refresh.js';
54
+ import { kickAgentReap } from '../../shared/agent-reap.js';
55
+ import { isInContainer } from '../../shared/worktree-root.js';
56
+ import { isSecondaryWorktree, resolveGitCommonDir } from '../../shared/git-paths.js';
54
57
 
55
58
  function projectDir() {
56
59
  return process.env.CLAUDE_PROJECT_DIR || process.cwd();
57
60
  }
58
61
 
59
- function gitRevParse(arg, dir) {
60
- return execSync(`git rev-parse ${arg}`, { cwd: dir, stdio: ['pipe', 'pipe', 'pipe'] })
61
- .toString()
62
- .trim();
63
- }
62
+ const samePath = (a, b) => (process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b);
64
63
 
65
- /**
66
- * A secondary git worktree has a `--git-dir` (worktree-private, under
67
- * `.../.git/worktrees/{name}`) distinct from its `--git-common-dir` (the
68
- * primary repo's shared `.git`). In the primary root both resolve to the
69
- * same path. Any git error (not a repo at all) is treated as "not a
70
- * worktree" — fail-open.
71
- */
72
- function isSecondaryWorktree(dir) {
64
+ // Inside a worktree Claude Code keeps CLAUDE_PROJECT_DIR at the project root and moves only the payload `cwd`.
65
+ function sessionDir(payload) {
66
+ const base = projectDir();
67
+ const cwd = typeof payload?.cwd === 'string' ? payload.cwd : '';
68
+ if (!cwd || samePath(resolve(cwd), resolve(base))) return base;
73
69
  try {
74
- const gitDir = gitRevParse('--git-dir', dir);
75
- const commonDir = gitRevParse('--git-common-dir', dir);
76
- return gitDir !== commonDir;
70
+ const common = resolveGitCommonDir(cwd);
71
+ return common && samePath(resolve(common), resolve(resolveGitCommonDir(base) ?? '')) && isSecondaryWorktree(cwd) ? cwd : base;
77
72
  } catch {
78
- return false;
73
+ return base;
79
74
  }
80
75
  }
81
76
 
77
+ function gitRevParse(arg, dir) {
78
+ return execSync(`git rev-parse ${arg}`, { cwd: dir, stdio: ['pipe', 'pipe', 'pipe'] })
79
+ .toString()
80
+ .trim();
81
+ }
82
+
82
83
  /** Feature name from the current branch, if it matches `morph/{feature}`. */
83
84
  function featureFromBranch(dir) {
84
85
  try {
@@ -118,8 +119,7 @@ function isOutsideContainer(dir) {
118
119
  try {
119
120
  const common = resolve(dir, gitRevParse('--git-common-dir', dir)).replace(/\\/g, '/');
120
121
  const root = common.replace(/\/\.git$/, '');
121
- const norm = resolve(dir).replace(/\\/g, '/');
122
- return !['worktrees', '.worktrees'].some((d) => norm.startsWith(`${root}/${d}/`));
122
+ return !isInContainer(root, dir);
123
123
  } catch {
124
124
  return false;
125
125
  }
@@ -280,7 +280,7 @@ function claimBlock(dir, feature, sessionId) {
280
280
  */
281
281
  export async function run(payload) {
282
282
  try {
283
- const dir = projectDir();
283
+ const dir = sessionDir(payload);
284
284
  // The SessionStart payload carries session_id — the identity the claim
285
285
  // registry uses to tell "me" from "another session".
286
286
  const sessionId = payload?.session_id || `pid-${process.pid}`;
@@ -303,6 +303,7 @@ export async function run(payload) {
303
303
  // Returns immediately — nothing here waits on graphify.
304
304
  try { seedFromPrimary(dir); } catch { /* non-blocking — a no-op on the root */ }
305
305
  try { kickGraphRefresh(dir); } catch { /* non-blocking */ }
306
+ try { kickAgentReap(dir); } catch { /* non-blocking */ }
306
307
 
307
308
  if (isSecondaryWorktree(dir)) {
308
309
  const task = taskFromBranch(dir);
@@ -43,8 +43,7 @@ import { homedir } from 'os';
43
43
  import { stateExists } from '../../shared/state-reader.js';
44
44
  import { resolveGitCommonDir } from '../../shared/git-paths.js';
45
45
  import { runMorphSpec } from '../../shared/morph-cli.js';
46
-
47
- const WORKTREES_DIR = 'worktrees';
46
+ import { newWorktreesDir } from '../../shared/worktree-root.js';
48
47
 
49
48
  const fwd = (p) => String(p).replace(/\\/g, '/');
50
49
 
@@ -152,14 +151,15 @@ function defaultBranch(root) {
152
151
  */
153
152
  function gitFallback(root, name, base) {
154
153
  const branch = `morph-task/${name}`;
155
- const path = `${root}/${WORKTREES_DIR}/${name}`;
154
+ const container = newWorktreesDir(root);
155
+ const path = `${container}/${name}`;
156
156
  if (existsSync(path)) return { path };
157
157
  try { git(['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], root); }
158
158
  catch {
159
159
  const from = base || defaultBranch(root);
160
160
  git(from ? ['branch', branch, from] : ['branch', branch], root);
161
161
  }
162
- mkdirSync(join(root, WORKTREES_DIR), { recursive: true });
162
+ mkdirSync(container, { recursive: true });
163
163
  git(['worktree', 'add', path, branch], root);
164
164
  return { path };
165
165
  }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * agent-reap.js — dispara `morph-spec worktree reap` em background no SessionStart.
3
+ *
4
+ * O hook de SessionStart tem 5 s de timeout e o reaper mede git por árvore: por isso o
5
+ * disparo só olha o disco (nenhum subprocesso) e solta este arquivo como worker DESTACADO
6
+ * (`--run <raiz>`), que chama o CLI. Só a raiz: o worker nasce com `cwd` no git common dir
7
+ * e nunca segura o diretório de uma worktree.
8
+ *
9
+ * Fail-open em tudo. `MORPH_WORKTREE_REAP=0` desliga.
10
+ *
11
+ * @module agent-reap
12
+ */
13
+
14
+ import { spawn } from 'child_process';
15
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs';
16
+ import { join, resolve } from 'path';
17
+ import { fileURLToPath } from 'url';
18
+ import { isSecondaryWorktree, resolveGitCommonDir } from './git-paths.js';
19
+ import { primaryTree } from './graph-freshness.js';
20
+ import { runMorphSpec } from './morph-cli.js';
21
+
22
+ const WORKER = fileURLToPath(import.meta.url);
23
+ const THROTTLE_MS = 10 * 60_000;
24
+ const WORKER_TIMEOUT_MS = 15 * 60_000;
25
+ const ADMIN_DIR = /^agent-[0-9a-f]+\d*$/;
26
+ const AGENT_BRANCH_FILE = /^agent-[0-9a-f]+$/;
27
+
28
+ function anyEntry(dir, pattern) {
29
+ try { return readdirSync(dir).some((name) => pattern.test(name)); } catch { return false; }
30
+ }
31
+
32
+ /** Uma worktree de agent registrada, ou uma branch `morph-task/agent-*` (solta ou empacotada). */
33
+ export function hasAgentLeftovers(common) {
34
+ if (anyEntry(join(common, 'worktrees'), ADMIN_DIR)) return true;
35
+ if (anyEntry(join(common, 'refs', 'heads', 'morph-task'), AGENT_BRANCH_FILE)) return true;
36
+ try {
37
+ return /refs\/heads\/morph-task\/agent-[0-9a-f]+/.test(readFileSync(join(common, 'packed-refs'), 'utf8'));
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ function recentlyKicked(marker, now) {
44
+ try {
45
+ const age = now - Number(readFileSync(marker, 'utf8'));
46
+ return age >= 0 && age < THROTTLE_MS;
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * @param {string} dir - diretório do projeto da sessão
54
+ * @returns {{ kicked: boolean, reason?: string, pid?: number }}
55
+ */
56
+ export function kickAgentReap(dir, { spawnFn = spawn, env = process.env, now = Date.now } = {}) {
57
+ if (env.MORPH_WORKTREE_REAP === '0') return { kicked: false, reason: 'disabled' };
58
+ const common = resolveGitCommonDir(dir);
59
+ const root = common && primaryTree(dir);
60
+ if (!root) return { kicked: false, reason: 'not-a-repo' };
61
+ if (isSecondaryWorktree(dir)) return { kicked: false, reason: 'secondary-worktree' };
62
+ if (!hasAgentLeftovers(common)) return { kicked: false, reason: 'nothing-to-reap' };
63
+
64
+ const marker = join(common, 'morph-spec', 'agent-reap.kick');
65
+ if (recentlyKicked(marker, now())) return { kicked: false, reason: 'throttled' };
66
+ try {
67
+ mkdirSync(join(common, 'morph-spec'), { recursive: true });
68
+ writeFileSync(marker, String(now()));
69
+ const child = spawnFn(process.execPath, [WORKER, '--run', root], {
70
+ cwd: common, env, detached: true, stdio: 'ignore', windowsHide: true,
71
+ });
72
+ child.on?.('error', () => {});
73
+ child.unref?.();
74
+ return { kicked: true, pid: child.pid };
75
+ } catch {
76
+ return { kicked: false, reason: 'spawn-failed' };
77
+ }
78
+ }
79
+
80
+ const isMain = (() => {
81
+ const argv1 = process.argv[1];
82
+ if (!argv1) return false;
83
+ const norm = (p) => (process.platform === 'win32' ? resolve(p).toLowerCase() : resolve(p));
84
+ return norm(argv1) === norm(WORKER);
85
+ })();
86
+
87
+ if (isMain && process.argv[2] === '--run' && process.argv[3] && existsSync(process.argv[3])) {
88
+ try { runMorphSpec(['worktree', 'reap', '--json'], { cwd: process.argv[3], timeoutMs: WORKER_TIMEOUT_MS }); } catch { /* fail-open */ }
89
+ }
@@ -675,7 +675,7 @@ function sleep(ms) {
675
675
 
676
676
  /** Tear the stack down. Best-effort: teardown failure never fails a check. */
677
677
  export function tearDownStack(cwd, { composeFile, projectName, env, volumes = false } = {}, exec = defaultExec) {
678
- const args = [...composeArgs(composeFile, projectName), 'down'];
678
+ const args = [...composeArgs(composeFile, projectName), 'down', '--remove-orphans'];
679
679
  if (volumes) args.push('-v');
680
680
  const result = exec('docker', args, { cwd, env });
681
681
  return { status: result.code === 0 ? 'pass' : 'fail', output: result.output };
@@ -863,6 +863,7 @@ export function runE2eCheck(cwd, options = {}) {
863
863
  insideWorktree = isSecondaryWorktree(cwd),
864
864
  healthTimeout = DEFAULT_HEALTH_TIMEOUT,
865
865
  specTimeout = DEFAULT_SPEC_TIMEOUT,
866
+ beforeHeavy = null,
866
867
  _exec: exec = defaultExec,
867
868
  } = options;
868
869
 
@@ -910,6 +911,7 @@ export function runE2eCheck(cwd, options = {}) {
910
911
  : {};
911
912
  const composeEnv = { COMPOSE_PROJECT_NAME: projectName, ...authEnv, ...(env || {}) };
912
913
 
914
+ if (typeof beforeHeavy === 'function') beforeHeavy();
913
915
  const up = bringUpStack(
914
916
  cwd,
915
917
  { composeFile, projectName, gitCommonDir, env: composeEnv, forceFresh, healthTimeout },
@@ -50,6 +50,7 @@ import { fileURLToPath } from 'url';
50
50
  import { readPrimaryHead, resolveGitCommonDir, workTreeTop } from './git-paths.js';
51
51
  import { findFirstOnPath, quoteForCmd } from './morph-cli.js';
52
52
  import { ensureCommonExclude } from './git-exclude.js';
53
+ import { pidAlive } from './process-alive.js';
53
54
  import {
54
55
  GRAPH_DIR, graphFile, graphFreshness, ignoreRulesHash, primaryTree, readRefreshRecord,
55
56
  recordRefreshRun, refreshPending, treeKey, writeRefreshRecord,
@@ -110,15 +111,6 @@ function mtimeOf(path) {
110
111
  }
111
112
  }
112
113
 
113
- function pidAlive(pid) {
114
- try {
115
- process.kill(pid, 0);
116
- return true;
117
- } catch (err) {
118
- return err?.code === 'EPERM';
119
- }
120
- }
121
-
122
114
  /**
123
115
  * Estado do lock do repositório — o que `graph status` mostra como "refresh em
124
116
  * andamento". A idade conta da última renovação: um lock de processo morto,
@@ -0,0 +1,128 @@
1
+ import { tokenize } from './command-tokens.js';
2
+
3
+ const DOTNET_HEAVY = new Set(['build', 'test', 'publish', 'pack', 'msbuild', 'vstest']);
4
+ const PACKAGE_MANAGERS = new Set(['npm', 'pnpm', 'yarn', 'bun']);
5
+ const HEAVY_SCRIPT = /^(test|build)(:.+)?$/;
6
+ const WRAPPERS = new Set(['time', 'nohup', 'nice', 'command', 'exec', 'env']);
7
+ const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
8
+ const INFO_FLAGS = new Set(['--help', '-h', '--version', '-?']);
9
+
10
+ function splitSegments(command) {
11
+ const segments = [];
12
+ let current = '';
13
+ let quote = null;
14
+ const s = String(command || '');
15
+ for (let i = 0; i < s.length; i++) {
16
+ const ch = s[i];
17
+ if (quote) {
18
+ current += ch;
19
+ if (ch === quote) quote = null;
20
+ else if (ch === '\\' && quote === '"') current += s[++i] ?? '';
21
+ continue;
22
+ }
23
+ if (ch === '"' || ch === "'") { quote = ch; current += ch; continue; }
24
+ if (ch === '\\') { current += ch + (s[++i] ?? ''); continue; }
25
+ if (';|&\n'.includes(ch)) { segments.push(current); current = ''; continue; }
26
+ current += ch;
27
+ }
28
+ segments.push(current);
29
+ return segments;
30
+ }
31
+
32
+ function executable(token) {
33
+ return token.replace(/^.*[\\/]/, '').replace(/\.(exe|cmd|bat|ps1)$/i, '').toLowerCase();
34
+ }
35
+
36
+ function skipWrappers(tokens) {
37
+ let i = 0;
38
+ for (;;) {
39
+ const t = tokens[i];
40
+ if (t === undefined) return [];
41
+ if (ASSIGNMENT.test(t)) { i++; continue; }
42
+ if (t === 'timeout') { i += 2; continue; }
43
+ if (WRAPPERS.has(executable(t))) { i++; continue; }
44
+ return tokens.slice(i);
45
+ }
46
+ }
47
+
48
+ const DOCKER_VALUE_FLAGS = new Set(['-f', '--file', '-p', '--project-name', '--profile', '--env-file', '--project-directory', '-H', '--host', '--context']);
49
+
50
+ function firstArg(args, valueFlags = new Set()) {
51
+ for (let i = 0; i < args.length; i++) {
52
+ if (!args[i].startsWith('-')) return args[i];
53
+ if (valueFlags.has(args[i])) i++;
54
+ }
55
+ return undefined;
56
+ }
57
+
58
+ function kindOfTokens(tokens) {
59
+ const [head, ...args] = tokens;
60
+ if (!head || args.some((a) => INFO_FLAGS.has(a))) return null;
61
+ const exe = executable(head);
62
+ const sub = firstArg(args, exe === 'docker' || exe === 'docker-compose' ? DOCKER_VALUE_FLAGS : undefined);
63
+
64
+ if (exe === 'dotnet') return DOTNET_HEAVY.has(sub) ? `dotnet ${sub}` : null;
65
+ if (exe === 'msbuild') return 'msbuild';
66
+ if (exe === 'vitest' || exe === 'jest') return exe;
67
+ if (exe === 'playwright') return sub === 'test' ? 'playwright test' : null;
68
+ if (exe === 'next' || exe === 'vite') return sub === 'build' ? `${exe} build` : null;
69
+
70
+ if (PACKAGE_MANAGERS.has(exe)) {
71
+ if (sub === 'test' || sub === 'build') return `${exe} ${sub}`;
72
+ if (sub === 'run' || sub === 'run-script') {
73
+ const script = args.slice(args.indexOf(sub) + 1).find((t) => !t.startsWith('-'));
74
+ return script && HEAVY_SCRIPT.test(script) ? `${exe} run ${script}` : null;
75
+ }
76
+ return null;
77
+ }
78
+
79
+ if (exe === 'npx' || exe === 'bunx') {
80
+ if (sub === 'vitest' || sub === 'jest') return `npx ${sub}`;
81
+ const rest = args.slice(args.indexOf(sub) + 1).find((t) => !t.startsWith('-'));
82
+ if (sub === 'playwright') return rest === 'test' ? 'npx playwright test' : null;
83
+ if (sub === 'next') return rest === 'build' ? 'npx next build' : null;
84
+ return null;
85
+ }
86
+
87
+ if (exe === 'docker-compose') return sub === 'up' || sub === 'build' ? `docker-compose ${sub}` : null;
88
+ if (exe === 'docker') {
89
+ if (sub === 'build' || sub === 'buildx') return `docker ${sub}`;
90
+ if (sub === 'compose') {
91
+ const action = firstArg(args.slice(args.indexOf(sub) + 1), DOCKER_VALUE_FLAGS);
92
+ return action === 'up' || action === 'build' ? `docker compose ${action}` : null;
93
+ }
94
+ }
95
+ return null;
96
+ }
97
+
98
+ /**
99
+ * The first heavy command of a Bash command line, as a label, or null. Looks at the executable of
100
+ * each `;`/`&&`/`||`/`|`/newline segment, so `morph-spec heavy run -- dotnet test` and `echo "dotnet build"`
101
+ * are not heavy: the first runs under its own slot, the second runs nothing.
102
+ * @param {string} command
103
+ * @returns {string|null}
104
+ */
105
+ export function heavyKind(command) {
106
+ for (const segment of splitSegments(command)) {
107
+ const tokens = skipWrappers(tokenize(segment.replace(/^[\s({]+/, '').replace(/[\s)}]+$/, '')));
108
+ const kind = kindOfTokens(tokens);
109
+ if (kind) return kind;
110
+ }
111
+ return null;
112
+ }
113
+
114
+ /**
115
+ * How long a foreground Bash call can run: its own `timeout` (capped at the platform maximum) plus slack.
116
+ * The lease has no live process to watch, so this is the only thing that frees it after a denied or
117
+ * interrupted call.
118
+ */
119
+ const BASH_DEFAULT_TIMEOUT_MS = 2 * 60_000;
120
+ const BASH_MAX_TIMEOUT_MS = 10 * 60_000;
121
+ const LEASE_SLACK_MS = 30_000;
122
+
123
+ export function leaseTtlMs(toolInput, env = process.env) {
124
+ const fallback = Number(env.BASH_DEFAULT_TIMEOUT_MS) || BASH_DEFAULT_TIMEOUT_MS;
125
+ const max = Math.max(Number(env.BASH_MAX_TIMEOUT_MS) || BASH_MAX_TIMEOUT_MS, fallback);
126
+ const asked = Number(toolInput?.timeout);
127
+ return Math.min(Number.isFinite(asked) && asked > 0 ? asked : fallback, max) + LEASE_SLACK_MS;
128
+ }
@@ -0,0 +1,65 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ const CONFIG_FILE = join('.morph', 'config', 'config.json');
5
+
6
+ export const HEAVY_DEFAULTS = Object.freeze({
7
+ enabled: true,
8
+ slots: 2,
9
+ minFreeMemGb: 4,
10
+ waitTimeoutSec: 600,
11
+ maxHoldMinutes: 90,
12
+ shutdownBuildServer: true,
13
+ disableNodeReuse: false,
14
+ gateBash: false,
15
+ });
16
+
17
+ const isBool = (v) => typeof v === 'boolean';
18
+ const VALID = {
19
+ enabled: isBool,
20
+ slots: (v) => Number.isInteger(v) && v >= 1,
21
+ minFreeMemGb: (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0,
22
+ waitTimeoutSec: (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0,
23
+ maxHoldMinutes: (v) => typeof v === 'number' && Number.isFinite(v) && v > 0,
24
+ shutdownBuildServer: isBool,
25
+ disableNodeReuse: isBool,
26
+ gateBash: isBool,
27
+ };
28
+
29
+ export function readProjectConfig(projectPath) {
30
+ const file = join(projectPath, CONFIG_FILE);
31
+ if (!existsSync(file)) return null;
32
+ try {
33
+ return JSON.parse(readFileSync(file, 'utf8').replace(/^/, ''));
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ export function resolveHeavyConfig(config, env = process.env) {
40
+ const out = { ...HEAVY_DEFAULTS };
41
+ const warnings = [];
42
+ const raw = config?.heavy;
43
+
44
+ if (raw !== undefined) {
45
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
46
+ warnings.push('`heavy` no config.json não é um objeto; usando os padrões');
47
+ } else {
48
+ for (const [key, value] of Object.entries(raw)) {
49
+ if (!(key in VALID)) warnings.push(`heavy.${key} não existe; ignorada`);
50
+ else if (VALID[key](value)) out[key] = value;
51
+ else warnings.push(`heavy.${key} inválida (${JSON.stringify(value)}); usando ${JSON.stringify(HEAVY_DEFAULTS[key])}`);
52
+ }
53
+ }
54
+ }
55
+
56
+ if (/^(off|0|false)$/i.test(env.MORPH_HEAVY ?? '')) out.enabled = false;
57
+ const envSlots = Number(env.MORPH_HEAVY_SLOTS);
58
+ if (env.MORPH_HEAVY_SLOTS !== undefined && VALID.slots(envSlots)) out.slots = envSlots;
59
+
60
+ return { ...out, warnings };
61
+ }
62
+
63
+ export function readHeavyConfig(projectPath, env = process.env) {
64
+ return resolveHeavyConfig(readProjectConfig(projectPath), env);
65
+ }