@polymorphism-tech/morph-spec 8.42.0 → 8.44.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 (101) hide show
  1. package/CLAUDE.md +15 -5
  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 +22 -10
  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/claude-code/core/worktree-env-inject.js +1 -4
  17. package/framework/hooks/shared/agent-reap.js +89 -0
  18. package/framework/hooks/shared/e2e-check.js +3 -1
  19. package/framework/hooks/shared/graph-refresh.js +1 -9
  20. package/framework/hooks/shared/heavy-command.js +128 -0
  21. package/framework/hooks/shared/heavy-config.js +65 -0
  22. package/framework/hooks/shared/heavy-holders.js +85 -0
  23. package/framework/hooks/shared/heavy-slots.js +207 -0
  24. package/framework/hooks/shared/process-alive.js +8 -0
  25. package/framework/hooks/shared/worktree-root.js +93 -0
  26. package/framework/rules/ai-agents-standards.md +1 -0
  27. package/framework/schemas/config.schema.json +80 -0
  28. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  29. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  30. package/framework/skills/core/morph-apply/SKILL.md +8 -3
  31. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  32. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  33. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  34. package/framework/skills/core/morph-implement/SKILL.md +9 -4
  35. package/framework/skills/core/morph-machine/SKILL.md +46 -0
  36. package/framework/skills/core/morph-media/SKILL.md +1 -1
  37. package/framework/skills/core/morph-plan/SKILL.md +1 -1
  38. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  39. package/framework/skills/core/morph-review/SKILL.md +45 -16
  40. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  41. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  42. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  43. package/framework/standards/PROVENANCE.json +10 -10
  44. package/framework/standards/STANDARDS.json +45 -1
  45. package/framework/standards/ai-agents/tool-adapters.md +226 -0
  46. package/framework/standards/frontend/design-system/ai-video-generation.md +2 -0
  47. package/framework/standards/frontend/scroll-driven/frame-scrub.md +11 -1
  48. package/framework/standards/infrastructure/docker/local-compose-isolation.md +11 -1
  49. package/package.json +1 -1
  50. package/src/commands/e2e/e2e.js +27 -8
  51. package/src/commands/mcp/mcp-setup.js +10 -9
  52. package/src/commands/project/doctor-procs.js +162 -0
  53. package/src/commands/project/doctor.js +42 -4
  54. package/src/commands/project/heavy.js +61 -0
  55. package/src/commands/project/init.js +1 -1
  56. package/src/commands/project/update.js +4 -0
  57. package/src/commands/project/worktree-reap.js +34 -0
  58. package/src/commands/project/worktree.js +112 -43
  59. package/src/commands/state/approve.js +2 -1
  60. package/src/commands/state/finish.js +4 -0
  61. package/src/commands/state/gate-check.js +2 -1
  62. package/src/commands/validation/mutate.js +200 -0
  63. package/src/commands/validation/verify.js +39 -22
  64. package/src/dashboard/scanner/features-scanner.js +4 -4
  65. package/src/dashboard/scanner/project-api.js +4 -4
  66. package/src/lib/detectors/claude-config-detector.js +30 -1
  67. package/src/lib/e2e/compose-env.js +5 -2
  68. package/src/lib/git/evidence-staleness.js +39 -0
  69. package/src/lib/heavy/cleanup.js +70 -0
  70. package/src/lib/heavy/gate.js +96 -0
  71. package/src/lib/installers/mcp-installer.js +15 -2
  72. package/src/lib/mutation/adapters.js +90 -0
  73. package/src/lib/mutation/commands.js +60 -0
  74. package/src/lib/mutation/manifest.js +138 -0
  75. package/src/lib/mutation/plan.js +36 -0
  76. package/src/lib/mutation/report.js +66 -0
  77. package/src/lib/mutation/runner.js +351 -0
  78. package/src/lib/mutation/schemata.js +60 -0
  79. package/src/lib/mutation/snapshot.js +69 -0
  80. package/src/lib/mutation/subset.js +75 -0
  81. package/src/lib/mutation/trx.js +51 -0
  82. package/src/lib/procs/classify.js +91 -0
  83. package/src/lib/procs/list.js +124 -0
  84. package/src/lib/procs/mcp-config.js +103 -0
  85. package/src/lib/procs/prune.js +25 -0
  86. package/src/lib/procs/wslconfig.js +70 -0
  87. package/src/lib/projects/registry.js +6 -2
  88. package/src/lib/risk-detector.js +3 -3
  89. package/src/lib/tasks/eval-node.js +4 -34
  90. package/src/lib/tasks/linux-runner.js +30 -3
  91. package/src/lib/tasks/test-runner.js +1 -1
  92. package/src/lib/validators/contracts/contract-compliance-validator.js +4 -42
  93. package/src/lib/validators/verify-runner.js +26 -8
  94. package/src/lib/worktree/agent-reaper.js +247 -0
  95. package/src/lib/worktree/dirs.js +3 -32
  96. package/src/lib/worktree/env-allocator.js +35 -5
  97. package/src/lib/worktree/vscode-settings.js +113 -0
  98. package/src/lib/worktree/worktree-audit.js +49 -5
  99. package/src/lib/worktree/worktree-include.js +46 -0
  100. package/src/scripts/setup-infra.js +4 -0
  101. package/src/utils/hooks-installer.js +11 -4
@@ -47,6 +47,7 @@ import { runValidation } from './validation-runner.js';
47
47
  import { loadRubric, aggregateScores } from './score-aggregator.js';
48
48
  import { checkOutputsTraceability } from './traceability-check.js';
49
49
  import { getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
50
+ import { createHeavyGate } from '../heavy/gate.js';
50
51
 
51
52
  /** Default build timeout — dotnet cold build is slow; 30s (the Stop-hook default) is too short. */
52
53
  const DEFAULT_BUILD_TIMEOUT = 120_000;
@@ -105,9 +106,20 @@ export class VerifyUsageError extends Error {
105
106
  * @param {Function} [options._runValidation] - Injectable override for runValidation (testing)
106
107
  * @param {Function} [options._runE2eCheck] - Injectable override for runE2eCheck (testing)
107
108
  * @param {Function} [options._checkOutputsTraceability] - Injectable override for checkOutputsTraceability (testing)
109
+ * @param {Object} [options._heavyGate] - Injectable heavy-slot gate (testing); default is one per run
108
110
  * @returns {Promise<Object>} Aggregated verify result — see module docs for shape
111
+ * @throws {HeavyBusyError} when no heavy slot (or RAM) freed within `heavy.waitTimeoutSec`
109
112
  */
110
113
  export async function runVerify(projectPath, featureName, taskId, options = {}) {
114
+ const gate = options._heavyGate ?? createHeavyGate({ projectPath, feature: featureName });
115
+ try {
116
+ return await runVerifyGated(projectPath, featureName, taskId, options, gate);
117
+ } finally {
118
+ gate.release();
119
+ }
120
+ }
121
+
122
+ async function runVerifyGated(projectPath, featureName, taskId, options, gate) {
111
123
  const {
112
124
  rubric: rubricOverride,
113
125
  timeout = DEFAULT_BUILD_TIMEOUT,
@@ -209,7 +221,7 @@ export async function runVerify(projectPath, featureName, taskId, options = {})
209
221
  const feedback = [];
210
222
 
211
223
  // 1. build
212
- nodes.build = await runBuildNode({ projectPath, stack, paths, timeout, skipBuild, _runBuildCheck });
224
+ nodes.build = await runBuildNode({ projectPath, stack, paths, timeout, skipBuild, gate, _runBuildCheck });
213
225
  appendBuildFeedback(feedback, nodes.build);
214
226
 
215
227
  // 2. tests — skipped entirely (no invocation) when build already failed.
@@ -229,6 +241,7 @@ export async function runVerify(projectPath, featureName, taskId, options = {})
229
241
  refreshCache,
230
242
  linux,
231
243
  linuxImage,
244
+ gate,
232
245
  _runTestSuite,
233
246
  _runLinuxSuite,
234
247
  _exec,
@@ -255,6 +268,7 @@ export async function runVerify(projectPath, featureName, taskId, options = {})
255
268
  skipE2e,
256
269
  freshE2e,
257
270
  buildNode: nodes.build,
271
+ gate,
258
272
  _runE2eCheck,
259
273
  });
260
274
  appendE2eFeedback(feedback, nodes.e2e);
@@ -297,7 +311,7 @@ export async function runVerify(projectPath, featureName, taskId, options = {})
297
311
  // Build node
298
312
  // ============================================================================
299
313
 
300
- async function runBuildNode({ projectPath, stack, paths = {}, timeout, skipBuild, _runBuildCheck }) {
314
+ async function runBuildNode({ projectPath, stack, paths = {}, timeout, skipBuild, gate, _runBuildCheck }) {
301
315
  if (skipBuild) {
302
316
  return { status: 'skip', command: null, durationMs: 0, reason: 'requested', output: '' };
303
317
  }
@@ -309,6 +323,7 @@ async function runBuildNode({ projectPath, stack, paths = {}, timeout, skipBuild
309
323
  }
310
324
  const command = steps.map((s) => s.command).join(' && ');
311
325
 
326
+ gate.enter('build');
312
327
  const start = Date.now();
313
328
  const result = await _runBuildCheck(projectPath, stack, timeout, paths);
314
329
  const durationMs = Date.now() - start;
@@ -352,7 +367,7 @@ function appendBuildFeedback(feedback, buildNode) {
352
367
  */
353
368
  async function runTestsNode({
354
369
  projectPath, testPlan, filter, skipTests, buildNode, timeout, taskId, refreshCache = false,
355
- linux = false, linuxImage = null, _runTestSuite, _runLinuxSuite = runLinuxSuite, _exec,
370
+ linux = false, linuxImage = null, gate, _runTestSuite, _runLinuxSuite = runLinuxSuite, _exec,
356
371
  }) {
357
372
  const { nodes, source } = testPlan;
358
373
  // Kept readable for whoever only prints the string — the same join the
@@ -372,7 +387,7 @@ async function runTestsNode({
372
387
 
373
388
  if (linux) {
374
389
  const { runs, environment } = await runNodesInLinux({
375
- projectPath, nodes, filter, taskId, timeout, linuxImage, _runLinuxSuite, _exec,
390
+ projectPath, nodes, filter, taskId, timeout, linuxImage, gate, _runLinuxSuite, _exec,
376
391
  });
377
392
  return rollUpTestRuns(runs, { command, source, environment });
378
393
  }
@@ -380,7 +395,7 @@ async function runTestsNode({
380
395
  const runs = [];
381
396
  for (const node of nodes) {
382
397
  runs.push(await runOneTestNode({
383
- projectPath, node, filter, taskId, timeout, refreshCache, _runTestSuite,
398
+ projectPath, node, filter, taskId, timeout, refreshCache, gate, _runTestSuite,
384
399
  }));
385
400
  }
386
401
 
@@ -403,7 +418,7 @@ async function runTestsNode({
403
418
  * bind mount: see linux-runner.js. That is the whole reason this path exists.
404
419
  */
405
420
  async function runNodesInLinux({
406
- projectPath, nodes, filter, taskId, timeout, linuxImage, _runLinuxSuite, _exec,
421
+ projectPath, nodes, filter, taskId, timeout, linuxImage, gate, _runLinuxSuite, _exec,
407
422
  }) {
408
423
  const decided = new Map();
409
424
  const toRun = [];
@@ -419,6 +434,7 @@ async function runNodesInLinux({
419
434
  (sum, node) => sum + resolveTestTimeout(projectPath, node, timeout), 0
420
435
  ) || undefined;
421
436
 
437
+ if (toRun.length > 0) gate.enter('tests');
422
438
  const suite = await _runLinuxSuite({
423
439
  projectPath,
424
440
  nodes: toRun,
@@ -458,7 +474,7 @@ async function runNodesInLinux({
458
474
  * from the report is the defect this whole feature exists to close.
459
475
  */
460
476
  async function runOneTestNode({
461
- projectPath, node, filter, taskId, timeout, refreshCache = false, _runTestSuite,
477
+ projectPath, node, filter, taskId, timeout, refreshCache = false, gate, _runTestSuite,
462
478
  }) {
463
479
  const decided = preDecideRun({ node, filter, taskId, projectPath, refreshCache });
464
480
  if (decided) return decided;
@@ -476,6 +492,7 @@ async function runOneTestNode({
476
492
  // wrote nothing the old scenario count — and hiding an empty run behind it.
477
493
  if (evalChannel) discardFile(evalChannel.summaryPath);
478
494
 
495
+ gate.enter('tests');
479
496
  const start = Date.now();
480
497
  const result = await _runTestSuite(projectPath, {
481
498
  timeout, node, ...(evalChannel ? { env: evalChannel.env } : {}),
@@ -1027,7 +1044,7 @@ function appendValidatorsFeedback(feedback, validatorsNode) {
1027
1044
  // E2E node
1028
1045
  // ============================================================================
1029
1046
 
1030
- async function runE2eNode({ projectPath, featureName, taskId, task = null, skipE2e, freshE2e, buildNode, _runE2eCheck }) {
1047
+ async function runE2eNode({ projectPath, featureName, taskId, task = null, skipE2e, freshE2e, buildNode, gate, _runE2eCheck }) {
1031
1048
  if (buildNode.status === 'fail') {
1032
1049
  return {
1033
1050
  status: 'skip',
@@ -1056,6 +1073,7 @@ async function runE2eNode({ projectPath, featureName, taskId, task = null, skipE
1056
1073
  env: buildComposeEnv({ cwd: projectPath, config, featureName, gitCommonDir }),
1057
1074
  skipRequested: skipE2e,
1058
1075
  forceFresh: freshE2e,
1076
+ beforeHeavy: () => gate.enter('e2e'),
1059
1077
  // The stack stays up between verifies on purpose: cold-starting a .NET
1060
1078
  // container per task is the difference between a usable loop and one
1061
1079
  // nobody runs. `morph-spec e2e down` (and `finish`) tear it down.
@@ -0,0 +1,247 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import {
3
+ closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, statSync, unlinkSync, writeFileSync,
4
+ } from 'node:fs';
5
+ import { basename, join, resolve } from 'node:path';
6
+ import {
7
+ cwdIsInsideWorktree, isGitRepo, parseWorktreeList, resolvePrimaryRoot, runWorktreeRemove,
8
+ sessionAnchoredInWorktree,
9
+ } from '../../commands/project/worktree.js';
10
+ import { containerDirs, isUnder } from '../../../framework/hooks/shared/worktree-root.js';
11
+ import { withoutLeftoverIgnore } from '../../../framework/hooks/shared/graph-freshness.js';
12
+ import { resolveGitCommonDir } from '../../../framework/hooks/shared/git-paths.js';
13
+ import { pidAlive } from '../../../framework/hooks/shared/process-alive.js';
14
+ import { readProjectConfig } from '../../../framework/hooks/shared/heavy-config.js';
15
+
16
+ export const DEFAULT_GRACE_MINUTES = 30;
17
+
18
+ const AGENT_DIR = /^agent-([0-9a-f]+)$/;
19
+ const AGENT_BRANCH = /^morph-task\/agent-([0-9a-f]+)$/;
20
+ const AGENT_REF = /^refs\/heads\/morph-task\/agent-[0-9a-f]+$/;
21
+ const TAIL_BYTES = 64 * 1024;
22
+ const LOCK_MAX_AGE_MS = 15 * 60_000;
23
+ const SAMPLE = 5;
24
+
25
+ const norm = (p) => resolve(p).replace(/\\/g, '/');
26
+ const gitOut = (cwd, args, input) => execFileSync('git', args, {
27
+ cwd, stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf8', input,
28
+ });
29
+ const sample = (list) => `${list.slice(0, SAMPLE).join(', ')}${list.length > SAMPLE ? `, +${list.length - SAMPLE}` : ''}`;
30
+
31
+ export function resolveGraceMinutes(root, override) {
32
+ const valid = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0;
33
+ if (valid(override)) return override;
34
+ const configured = readProjectConfig(root)?.worktree?.reap?.graceMinutes;
35
+ return valid(configured) ? configured : DEFAULT_GRACE_MINUTES;
36
+ }
37
+
38
+ /**
39
+ * Commits reachable from `tip` and from no other branch, remote or tag; agent branches are left out
40
+ * of the comparison, and `--all` is not used (the agent's own HEAD would protect it). `null` = unmeasured.
41
+ */
42
+ export function exclusiveCommitCount(root, tip) {
43
+ if (!tip) return null;
44
+ try {
45
+ const refs = gitOut(root, ['for-each-ref', '--format=%(refname)', 'refs/heads', 'refs/remotes', 'refs/tags'])
46
+ .split(/\r?\n/).filter((r) => r && !AGENT_REF.test(r));
47
+ const input = `${[tip, ...refs.map((r) => `^${r}`)].join('\n')}\n`;
48
+ const count = Number.parseInt(gitOut(root, ['rev-list', '--count', '--stdin'], input).trim(), 10);
49
+ return Number.isInteger(count) ? count : null;
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ function tailOfEvents(file) {
56
+ const fd = openSync(file, 'r');
57
+ try {
58
+ const { size, mtimeMs } = fstatSync(fd);
59
+ const len = Math.min(size, TAIL_BYTES);
60
+ const buf = Buffer.alloc(len);
61
+ readSync(fd, buf, 0, len, size - len);
62
+ const lines = buf.toString('utf8').split(/\r?\n/).filter((l) => l.trim());
63
+ if (size > len) lines.shift();
64
+ return { last: lines.at(-1) ?? null, mtimeMs };
65
+ } finally {
66
+ closeSync(fd);
67
+ }
68
+ }
69
+
70
+ /** `stopped` only when the tree's last event is this agent's own `AgentUsage`. */
71
+ export function readAgentStop(worktreePath, hex) {
72
+ const file = join(worktreePath, '.morph', 'logs', 'events.jsonl');
73
+ if (!existsSync(file)) return { status: 'unknown', detail: 'sem .morph/logs/events.jsonl na árvore' };
74
+ let tail;
75
+ try { tail = tailOfEvents(file); } catch { return { status: 'unknown', detail: 'events.jsonl ilegível' }; }
76
+ let event = null;
77
+ try { event = tail.last ? JSON.parse(tail.last) : null; } catch { event = null; }
78
+ if (!event || typeof event !== 'object') return { status: 'unknown', detail: 'último evento ilegível' };
79
+ if (event.event !== 'AgentUsage') return { status: 'active', detail: `último evento: ${event.event ?? '?'}` };
80
+ if (String(event.agentId ?? '').toLowerCase() !== hex) return { status: 'unknown', detail: 'AgentUsage de outro agente' };
81
+ return { status: 'stopped', mtimeMs: tail.mtimeMs };
82
+ }
83
+
84
+ function dirtOf(worktreePath) {
85
+ try {
86
+ const lines = gitOut(worktreePath, ['status', '--porcelain', '--untracked-files=all']).split(/\r?\n/).filter((l) => l.trim());
87
+ return withoutLeftoverIgnore(worktreePath, lines).map((l) => (l.startsWith('??') ? `untracked ${l.slice(3)}` : l.slice(3)));
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ function agentBranches(root) {
94
+ try {
95
+ return gitOut(root, ['for-each-ref', '--format=%(refname:short) %(objectname)', 'refs/heads/morph-task/'])
96
+ .split(/\r?\n/).filter(Boolean)
97
+ .map((line) => { const [name, sha] = line.split(' '); return { name, sha, hex: AGENT_BRANCH.exec(name)?.[1] }; })
98
+ .filter((b) => b.hex);
99
+ } catch {
100
+ return [];
101
+ }
102
+ }
103
+
104
+ function branchTouchedMs(root, branch) {
105
+ const common = resolveGitCommonDir(root);
106
+ if (!common) return null;
107
+ try { return statSync(join(common, 'logs', 'refs', 'heads', ...branch.split('/'))).mtimeMs; } catch { return null; }
108
+ }
109
+
110
+ function agentWorktrees(root) {
111
+ const rootNorm = norm(root);
112
+ const containers = containerDirs(root);
113
+ return parseWorktreeList(root).flatMap((e) => {
114
+ const path = norm(e.path);
115
+ const hex = AGENT_DIR.exec(basename(path))?.[1];
116
+ if (!hex || path === rootNorm) return [];
117
+ if (!isUnder(containers, path)) return [];
118
+ const branch = e.branch ? e.branch.replace(/^refs\/heads\//, '') : null;
119
+ if (branch && branch !== `morph-task/agent-${hex}`) return [];
120
+ return [{ path, hex, branch, tip: e.head, locked: e.locked }];
121
+ });
122
+ }
123
+
124
+ function judgeWorktree(item, ctx) {
125
+ const keep = (state, reason, detail) => ({ ...item, state, reason, detail });
126
+ if (!existsSync(item.path)) return keep('preserved', 'directory-missing', 'o diretório sumiu; `morph-spec doctor --worktrees --prune` limpa o registro');
127
+ if (cwdIsInsideWorktree(ctx.cwd, item.path) || sessionAnchoredInWorktree(item.path, ctx.env)) return keep('active', 'anchored');
128
+ if (item.locked) return keep('preserved', 'locked', '`git worktree unlock` libera, se o lock já não vale');
129
+
130
+ const stop = readAgentStop(item.path, item.hex);
131
+ if (stop.status === 'active') return keep('active', 'agent-active', stop.detail);
132
+ if (stop.status !== 'stopped') return keep('preserved', 'no-usage-event', stop.detail);
133
+ const idleMin = (ctx.now - stop.mtimeMs) / 60_000;
134
+ if (idleMin < ctx.graceMinutes) return keep('pending', 'within-grace', `parou há ${Math.floor(idleMin)} min (graça de ${ctx.graceMinutes} min)`);
135
+
136
+ const dirt = dirtOf(item.path);
137
+ if (dirt === null) return keep('preserved', 'unknown', 'git status falhou');
138
+ if (dirt.length) return keep('preserved', 'dirty', sample(dirt));
139
+
140
+ const exclusive = exclusiveCommitCount(ctx.root, item.tip);
141
+ if (exclusive === null) return keep('preserved', 'unknown', 'não foi possível contar os commits exclusivos');
142
+ if (exclusive > 0) return keep('preserved', 'exclusive-commits', `${exclusive} commit(s) só nesta branch`);
143
+ return keep('reapable', 'stopped-clean');
144
+ }
145
+
146
+ function judgeOrphanBranch(branch, ctx) {
147
+ const item = { path: null, hex: branch.hex, branch: branch.name, tip: branch.sha };
148
+ const keep = (state, reason, detail) => ({ ...item, state, reason, detail });
149
+ const exclusive = exclusiveCommitCount(ctx.root, branch.sha);
150
+ if (exclusive === null) return keep('preserved', 'unknown', 'não foi possível contar os commits exclusivos');
151
+ if (exclusive > 0) return keep('preserved', 'exclusive-commits', `${exclusive} commit(s) só nesta branch`);
152
+ const touched = branchTouchedMs(ctx.root, branch.name);
153
+ if (touched === null) return keep('preserved', 'unknown', 'sem reflog para medir a idade da branch');
154
+ const idleMin = (ctx.now - touched) / 60_000;
155
+ if (idleMin < ctx.graceMinutes) return keep('pending', 'within-grace', `branch tocada há ${Math.floor(idleMin)} min (graça de ${ctx.graceMinutes} min)`);
156
+ return keep('reapable', 'orphan-branch');
157
+ }
158
+
159
+ /** Read-only: every agent tree and orphan agent branch with its verdict. */
160
+ export function planAgentReap(root, { cwd = root, now = Date.now(), graceMinutes, env = process.env } = {}) {
161
+ const ctx = { root, cwd, now, env, graceMinutes: resolveGraceMinutes(root, graceMinutes) };
162
+ const trees = agentWorktrees(root);
163
+ const withTree = new Set(trees.map((t) => t.branch).filter(Boolean));
164
+ const orphans = agentBranches(root).filter((b) => !withTree.has(b.name));
165
+ const items = [...trees.map((t) => judgeWorktree(t, ctx)), ...orphans.map((b) => judgeOrphanBranch(b, ctx))];
166
+ return { graceMinutes: ctx.graceMinutes, items };
167
+ }
168
+
169
+ function acquireReapLock(root, now) {
170
+ const common = resolveGitCommonDir(root);
171
+ if (!common) return null;
172
+ const dir = join(common, 'morph-spec');
173
+ const file = join(dir, 'agent-reap.lock');
174
+ try { mkdirSync(dir, { recursive: true }); } catch { return null; }
175
+ for (let attempt = 0; attempt < 2; attempt += 1) {
176
+ try {
177
+ writeFileSync(file, JSON.stringify({ pid: process.pid, since: now }), { flag: 'wx' });
178
+ return () => { try { unlinkSync(file); } catch { /* already gone */ } };
179
+ } catch (err) {
180
+ if (err.code !== 'EEXIST') return null;
181
+ }
182
+ let holder = null;
183
+ try { holder = JSON.parse(readFileSync(file, 'utf8')); } catch { holder = null; }
184
+ const stale = !holder || !pidAlive(holder.pid) || now - Number(holder.since) > LOCK_MAX_AGE_MS;
185
+ if (!stale) return null;
186
+ try { unlinkSync(file); } catch { return null; }
187
+ }
188
+ return null;
189
+ }
190
+
191
+ function deleteAgentBranch(root, branch, sha) {
192
+ if (exclusiveCommitCount(root, sha) !== 0) return false;
193
+ try { gitOut(root, ['branch', '-D', branch]); return true; } catch { return false; }
194
+ }
195
+
196
+ const brief = ({ path, branch, hex }) => ({ path, branch, hex });
197
+ const why = ({ path, branch, hex, reason, detail }) => ({ path, branch, hex, reason, ...(detail ? { detail } : {}) });
198
+
199
+ /**
200
+ * Removes the worktrees and branches of stopped, clean agents that hold no commit only they have;
201
+ * everything else is listed with the reason and left alone.
202
+ */
203
+ export async function reapAgentWorktrees(cwd = process.cwd(), {
204
+ dryRun = false, graceMinutes, now = Date.now, env = process.env, remove = runWorktreeRemove,
205
+ } = {}) {
206
+ if (!isGitRepo(cwd)) return { ok: false, error: 'not-a-git-repo', dryRun };
207
+ const root = resolvePrimaryRoot(cwd);
208
+ const summarize = (plan, extra = {}) => {
209
+ const of = (state) => plan.items.filter((i) => i.state === state);
210
+ return {
211
+ ok: true, dryRun, graceMinutes: plan.graceMinutes,
212
+ reaped: [], branchesDeleted: [],
213
+ ...(dryRun ? { wouldReap: of('reapable').map(brief) } : {}),
214
+ preserved: [...of('preserved'), ...of('pending')].map(why),
215
+ active: of('active').length,
216
+ ...extra,
217
+ };
218
+ };
219
+
220
+ if (dryRun) return summarize(planAgentReap(root, { cwd, now: now(), graceMinutes, env }));
221
+
222
+ const release = acquireReapLock(root, now());
223
+ if (!release) return { ...summarize({ graceMinutes: resolveGraceMinutes(root, graceMinutes), items: [] }), busy: true };
224
+ try {
225
+ const plan = planAgentReap(root, { cwd, now: now(), graceMinutes, env });
226
+ const out = summarize(plan);
227
+ for (const item of plan.items.filter((i) => i.state === 'reapable')) {
228
+ if (item.path) {
229
+ const { out: removal } = await remove(null, { target: item.path, ifClean: true, relinkOnFailure: true, cwd: root });
230
+ if (!removal.removed) {
231
+ out.preserved.push(why({ ...item, reason: 'remove-failed', detail: removal.hint || removal.error }));
232
+ continue;
233
+ }
234
+ out.reaped.push({ ...brief(item), ...(removal.ignoredBackup ? { backup: removal.ignoredBackup } : {}) });
235
+ if (item.branch && deleteAgentBranch(root, item.branch, item.tip)) out.branchesDeleted.push(item.branch);
236
+ } else if (deleteAgentBranch(root, item.branch, item.tip)) {
237
+ out.branchesDeleted.push(item.branch);
238
+ out.reaped.push(brief(item));
239
+ } else {
240
+ out.preserved.push(why({ ...item, reason: 'unknown', detail: 'a branch mudou antes de ser apagada' }));
241
+ }
242
+ }
243
+ return out;
244
+ } finally {
245
+ release();
246
+ }
247
+ }
@@ -1,32 +1,3 @@
1
- /**
2
- * dirs.js — the ONE place the worktree container directory is named.
3
- *
4
- * Every reader that resolves worktrees BY CONVENTION (test discovery, graph
5
- * ignore list, the installed .gitignore, the architecture scanner, the
6
- * dashboard scanner) imports from here. Duplicating the string is how the
7
- * C1 bug comes back: a reader keeps the old name and silently goes blind.
8
- *
9
- * Readers that go through `git worktree list` (fleet, finish, worktree list)
10
- * never depended on this — git records absolute paths.
11
- */
12
-
13
- /**
14
- * The directory new worktrees are created under, relative to the primary root.
15
- *
16
- * NO leading dot, deliberately (C1). Jest escapes the `.` when it interpolates
17
- * an absolute `rootDir` into the `testMatch` glob, so `<root>/.worktrees/...`
18
- * stops matching and an entire frontend suite reports **0 tests without an
19
- * error** — the suite "passes" having tested nothing. The dot bought nothing:
20
- * the directory is gitignored either way.
21
- */
22
- export const WORKTREES_DIR = 'worktrees';
23
-
24
- /**
25
- * The pre-C1 directory name. New worktrees never land here, but every reader
26
- * that resolves worktrees BY CONVENTION must still accept it, so a machine
27
- * that already had `.worktrees/{f}` keeps working with no manual migration.
28
- */
29
- export const LEGACY_WORKTREES_DIR = '.worktrees';
30
-
31
- /** Both names, new first — the read order for convention-based lookups. */
32
- export const WORKTREE_DIR_NAMES = [WORKTREES_DIR, LEGACY_WORKTREES_DIR];
1
+ // The names live in framework/hooks/shared/worktree-root.js (a hook cannot import `src/`, and the
2
+ // `worktree.root` resolver needs them); every convention-based reader still imports them from here.
3
+ export { WORKTREES_DIR, LEGACY_WORKTREES_DIR, WORKTREE_DIR_NAMES } from '../../../framework/hooks/shared/worktree-root.js';
@@ -168,10 +168,10 @@ export const PORT_OFFSETS = Object.freeze({ web: 0, api: 1, postgres: 2, redis:
168
168
  * not in the root's. Every value is a string (env-safe); key order is stable.
169
169
  *
170
170
  * @param {{ base: number, range: [number, number] }} block
171
- * @param {{ stack?: string, feature?: string }} [opts]
171
+ * @param {{ stack?: string, feature?: string, aliases?: Record<string, string> }} [opts] - aliases: resolvePortAliases()
172
172
  * @returns {Record<string,string>}
173
173
  */
174
- export function portLayout(block, { stack = '', feature = '' } = {}) {
174
+ export function portLayout(block, { stack = '', feature = '', aliases = {} } = {}) {
175
175
  const env = {};
176
176
  if (feature) env.COMPOSE_PROJECT_NAME = composeProjectName(feature);
177
177
  env.MORPH_PORT_BASE = String(block.base);
@@ -183,9 +183,39 @@ export function portLayout(block, { stack = '', feature = '' } = {}) {
183
183
  if (/\bdotnet\b/i.test(String(stack))) {
184
184
  env.ASPNETCORE_URLS = `http://localhost:${block.base + PORT_OFFSETS.api}`;
185
185
  }
186
+ for (const [alias, port] of Object.entries(aliases)) env[alias] = String(block.base + PORT_OFFSETS[port]);
186
187
  return env;
187
188
  }
188
189
 
190
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
191
+ const RESERVED_ENV_NAMES = new Set([
192
+ ...Object.keys(portLayout({ base: 0, range: [0, 0] }, { stack: 'dotnet', feature: 'x' })),
193
+ 'PLAYWRIGHT_BASE_URL',
194
+ 'MORPH_FEATURE',
195
+ ]);
196
+
197
+ // e2e.portEnvNames: extra env names for the same four ports, so a compose file reading
198
+ // ${PG_PORT} is fed by the block, not by its own default. An entry that cannot be
199
+ // honoured is dropped and named in `problems`.
200
+ export function resolvePortAliases(config) {
201
+ const declared = config?.e2e?.portEnvNames;
202
+ const aliases = {};
203
+ const problems = [];
204
+ if (declared === undefined || declared === null) return { aliases, problems };
205
+ if (typeof declared !== 'object' || Array.isArray(declared)) {
206
+ return { aliases, problems: [`e2e.portEnvNames deve ser um objeto { ${Object.keys(PORT_OFFSETS).join('|')}: "NOME" }`] };
207
+ }
208
+ for (const [port, name] of Object.entries(declared)) {
209
+ const at = `e2e.portEnvNames.${port}`;
210
+ if (!Object.hasOwn(PORT_OFFSETS, port)) problems.push(`${at}: porta desconhecida (use ${Object.keys(PORT_OFFSETS).join('|')})`);
211
+ else if (typeof name !== 'string' || !ENV_NAME.test(name)) problems.push(`${at}: ${JSON.stringify(name)} não é um nome de variável de ambiente válido`);
212
+ else if (RESERVED_ENV_NAMES.has(name)) problems.push(`${at}: "${name}" já é uma variável do layout`);
213
+ else if (Object.hasOwn(aliases, name)) problems.push(`${at}: "${name}" já é o alias de ${aliases[name]}`);
214
+ else aliases[name] = port;
215
+ }
216
+ return { aliases, problems };
217
+ }
218
+
189
219
  /**
190
220
  * Render the `.morph/worktree.env` contents for a block: a header, the
191
221
  * feature, and exactly the `portLayout()` pairs — nothing else, so the file
@@ -199,15 +229,15 @@ export function portLayout(block, { stack = '', feature = '' } = {}) {
199
229
  * @param {string} feature - the env-registry key: `<feature>` or `task/<name>`
200
230
  * @param {{ base: number, range: [number, number] }} block
201
231
  * @param {string} [stack] - config.json `project.stack` (e.g. "nextjs+dotnet")
202
- * @param {{ morphFeature?: string|null }} [opts]
232
+ * @param {{ morphFeature?: string|null, aliases?: Record<string, string> }} [opts]
203
233
  * @returns {string}
204
234
  */
205
- export function buildEnvFileContent(feature, block, stack = '', { morphFeature = null } = {}) {
235
+ export function buildEnvFileContent(feature, block, stack = '', { morphFeature = null, aliases = {} } = {}) {
206
236
  const lines = [
207
237
  `# Managed by morph-spec — ambiente dedicado desta worktree (feature: ${feature}).`,
208
238
  '# Derivado, git-invisível, regenerado por `worktree setup|link`; nunca editar nem commitar.',
209
239
  `MORPH_FEATURE=${morphFeature || feature}`,
210
- ...Object.entries(portLayout(block, { stack, feature })).map(([k, v]) => `${k}=${v}`),
240
+ ...Object.entries(portLayout(block, { stack, feature, aliases })).map(([k, v]) => `${k}=${v}`),
211
241
  ];
212
242
  return `${lines.join('\n')}\n`;
213
243
  }
@@ -0,0 +1,113 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { jsoncToJson, stripComments } from '../../../framework/hooks/shared/comment-stripper.js';
5
+ import { ensureCommonExclude } from '../../../framework/hooks/shared/git-exclude.js';
6
+ import { readProjectConfig } from '../../../framework/hooks/shared/heavy-config.js';
7
+ import { getProjectTags } from '../stack-filter.js';
8
+ import { realEffects } from '../installers/effects.js';
9
+ import { WORKTREE_DIR_NAMES } from './dirs.js';
10
+
11
+ const SETTINGS_REL = join('.vscode', 'settings.json');
12
+ const EXCLUDE_MARKER = '# morph-spec: editor settings';
13
+ const DEFAULT_INDENT = ' ';
14
+ const DOTNET_ROOT_FILE = /\.(sln|slnx|csproj)$/i;
15
+
16
+ const isDotnet = (root, config) => getProjectTags(config).includes('dotnet')
17
+ || (() => { try { return readdirSync(root).some((n) => DOTNET_ROOT_FILE.test(n)); } catch { return false; } })();
18
+
19
+ /** Globs per settings key. `bin/obj` leave `files.exclude` outside .NET: a JS `bin/` holds source. */
20
+ export function desiredEditorSettings({ dotnet }) {
21
+ const dirs = WORKTREE_DIR_NAMES.map((n) => `**/${n}/**`);
22
+ const watched = Object.fromEntries([...dirs, '**/node_modules/**', '**/bin/**', '**/obj/**'].map((g) => [g, true]));
23
+ const listed = Object.fromEntries([...dirs, '**/node_modules/**', ...(dotnet ? ['**/bin/**', '**/obj/**'] : [])].map((g) => [g, true]));
24
+ return { 'files.watcherExclude': watched, 'search.exclude': { ...watched }, 'files.exclude': listed };
25
+ }
26
+
27
+ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
28
+
29
+ function isTracked(root, rel) {
30
+ try {
31
+ execFileSync('git', ['ls-files', '--error-unmatch', '--', rel], { cwd: root, stdio: 'pipe' });
32
+ return true;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ function mergeMissing(current, wanted) {
39
+ const added = [];
40
+ const skipped = [];
41
+ for (const [key, globs] of Object.entries(wanted)) {
42
+ if (current[key] === undefined) {
43
+ current[key] = { ...globs };
44
+ added.push(...Object.keys(globs).map((g) => `${key}:${g}`));
45
+ } else if (isPlainObject(current[key])) {
46
+ for (const glob of Object.keys(globs)) {
47
+ if (!(glob in current[key])) { current[key][glob] = true; added.push(`${key}:${glob}`); }
48
+ }
49
+ } else {
50
+ skipped.push(key);
51
+ }
52
+ }
53
+ return { added, skipped };
54
+ }
55
+
56
+ function snippetFor(wanted, present) {
57
+ const missing = {};
58
+ for (const [key, globs] of Object.entries(wanted)) {
59
+ const gaps = Object.fromEntries(Object.entries(globs).filter(([g]) => !(isPlainObject(present?.[key]) && g in present[key])));
60
+ if (Object.keys(gaps).length) missing[key] = gaps;
61
+ }
62
+ return JSON.stringify(missing, null, 2);
63
+ }
64
+
65
+ /**
66
+ * Read-only: what `.vscode/settings.json` would need. `write` carries the full new text;
67
+ * `suggest` carries the snippet to paste, because rewriting would drop comments, trailing
68
+ * commas or a tracked file's cleanliness.
69
+ */
70
+ export function planEditorSettings(root, { config = readProjectConfig(root), env = process.env } = {}) {
71
+ const file = join(root, SETTINGS_REL);
72
+ if (config?.worktree?.editorSettings === false || env.MORPH_EDITOR_SETTINGS === '0') return { status: 'off', file };
73
+
74
+ const wanted = desiredEditorSettings({ dotnet: isDotnet(root, config) });
75
+ if (!existsSync(file)) {
76
+ return { status: 'write', file, created: true, added: Object.entries(wanted).flatMap(([k, g]) => Object.keys(g).map((x) => `${k}:${x}`)), content: `${JSON.stringify(wanted, null, DEFAULT_INDENT)}\n` };
77
+ }
78
+
79
+ const suggest = (reason, present) => ({ status: 'suggest', file, reason, snippet: snippetFor(wanted, present) });
80
+ let text;
81
+ try { text = readFileSync(file, 'utf8'); } catch { return suggest('unreadable'); }
82
+ const body = text.replace(/^\uFEFF/, '');
83
+ let parsed;
84
+ try { parsed = JSON.parse(jsoncToJson(body)); } catch { return suggest('unreadable'); }
85
+ if (!isPlainObject(parsed)) return suggest('not-object');
86
+ if (stripComments(body) !== body || jsoncToJson(body) !== stripComments(body)) return suggest('comments', parsed);
87
+
88
+ const { added, skipped } = mergeMissing(parsed, wanted);
89
+ if (!added.length) return { status: 'unchanged', file, ...(skipped.length ? { skipped } : {}) };
90
+ if (isTracked(root, SETTINGS_REL)) return suggest('tracked', JSON.parse(jsoncToJson(body)));
91
+
92
+ const indent = /^([ \t]+)"/m.exec(body)?.[1] ?? DEFAULT_INDENT;
93
+ const eol = body.includes('\r\n') ? '\r\n' : '\n';
94
+ const out = JSON.stringify(parsed, null, indent).replace(/\n/g, eol) + (/\n$/.test(body) ? eol : '');
95
+ return { status: 'write', file, created: false, added, ...(skipped.length ? { skipped } : {}), content: `${text.startsWith('\uFEFF') ? '\uFEFF' : ''}${out}` };
96
+ }
97
+
98
+ /**
99
+ * Applies the plan through `fx` (so `update --dry-run` predicts it). A created file joins the
100
+ * common `info/exclude`: untracked, it would count as root dirt and `finish --merge` refuses that.
101
+ */
102
+ export function applyEditorSettings(root, { fx = realEffects(), config, env } = {}) {
103
+ const plan = planEditorSettings(root, { config, env });
104
+ const { content, ...report } = plan;
105
+ if (plan.status !== 'write') return report;
106
+ try {
107
+ fx.writeFile(plan.file, content);
108
+ if (plan.created && !fx.dryRun) ensureCommonExclude(root, ['/.vscode/settings.json'], EXCLUDE_MARKER);
109
+ return { ...report, written: !fx.dryRun };
110
+ } catch (err) {
111
+ return { status: 'failed', file: plan.file, reason: String(err?.message ?? err).slice(0, 200) };
112
+ }
113
+ }