@polymorphism-tech/morph-spec 8.41.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 (102) hide show
  1. package/CLAUDE.md +14 -4
  2. package/README.md +3 -3
  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 +6 -5
  8. package/framework/MORPH.md +10 -9
  9. package/framework/agents.json +7 -7
  10. package/framework/docs/CLI.md +10 -3
  11. package/framework/evals/proof-rubrics/mutation-proof.md +43 -13
  12. package/framework/hooks/README.md +1 -0
  13. package/framework/hooks/claude-code/core/chain.js +2 -0
  14. package/framework/hooks/claude-code/core/comment-guard.js +107 -0
  15. package/framework/hooks/claude-code/core/heavy-gate.js +72 -0
  16. package/framework/hooks/claude-code/core/heavy-release.js +34 -0
  17. package/framework/hooks/claude-code/core/worktree-context.js +21 -20
  18. package/framework/hooks/claude-code/core/worktree-create.js +4 -4
  19. package/framework/hooks/shared/agent-reap.js +89 -0
  20. package/framework/hooks/shared/comment-rules.js +171 -0
  21. package/{src/lib/validators/code-budget → framework/hooks/shared}/comment-scanner.js +2 -2
  22. package/{src/lib/ai → framework/hooks/shared}/comment-stripper.js +2 -1
  23. package/framework/hooks/shared/e2e-check.js +3 -1
  24. package/framework/hooks/shared/graph-refresh.js +1 -9
  25. package/framework/hooks/shared/heavy-command.js +128 -0
  26. package/framework/hooks/shared/heavy-config.js +65 -0
  27. package/framework/hooks/shared/heavy-holders.js +85 -0
  28. package/framework/hooks/shared/heavy-slots.js +207 -0
  29. package/framework/hooks/shared/process-alive.js +8 -0
  30. package/framework/hooks/shared/source-files.js +34 -0
  31. package/framework/hooks/shared/worktree-root.js +93 -0
  32. package/framework/schemas/config.schema.json +80 -0
  33. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  34. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  35. package/framework/skills/core/morph-apply/SKILL.md +11 -4
  36. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  37. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  38. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  39. package/framework/skills/core/morph-implement/SKILL.md +12 -5
  40. package/framework/skills/core/morph-machine/SKILL.md +46 -0
  41. package/framework/skills/core/morph-media/SKILL.md +1 -1
  42. package/framework/skills/core/morph-plan/SKILL.md +1 -1
  43. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  44. package/framework/skills/core/morph-review/SKILL.md +45 -16
  45. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  46. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  47. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  48. package/framework/standards/PROVENANCE.json +10 -10
  49. package/framework/standards/STANDARDS.json +1 -1
  50. package/framework/standards/architecture/code-comments.md +9 -2
  51. package/framework/standards/frontend/design-system/ai-video-generation.md +2 -0
  52. package/framework/standards/frontend/scroll-driven/frame-scrub.md +11 -1
  53. package/package.json +1 -1
  54. package/src/commands/e2e/e2e.js +27 -8
  55. package/src/commands/mcp/mcp-setup.js +10 -9
  56. package/src/commands/project/doctor-procs.js +162 -0
  57. package/src/commands/project/doctor.js +42 -4
  58. package/src/commands/project/heavy.js +61 -0
  59. package/src/commands/project/init.js +1 -1
  60. package/src/commands/project/update.js +4 -0
  61. package/src/commands/project/worktree-reap.js +34 -0
  62. package/src/commands/project/worktree.js +78 -27
  63. package/src/commands/validation/mutate.js +200 -0
  64. package/src/commands/validation/verify.js +26 -1
  65. package/src/dashboard/scanner/features-scanner.js +4 -4
  66. package/src/dashboard/scanner/project-api.js +4 -4
  67. package/src/lib/ai/code-audit.js +1 -1
  68. package/src/lib/ai/registry-audit.js +1 -1
  69. package/src/lib/detectors/claude-config-detector.js +30 -1
  70. package/src/lib/heavy/cleanup.js +70 -0
  71. package/src/lib/heavy/gate.js +96 -0
  72. package/src/lib/installers/mcp-installer.js +15 -2
  73. package/src/lib/mutation/adapters.js +90 -0
  74. package/src/lib/mutation/commands.js +60 -0
  75. package/src/lib/mutation/manifest.js +138 -0
  76. package/src/lib/mutation/plan.js +36 -0
  77. package/src/lib/mutation/report.js +66 -0
  78. package/src/lib/mutation/runner.js +351 -0
  79. package/src/lib/mutation/schemata.js +60 -0
  80. package/src/lib/mutation/snapshot.js +69 -0
  81. package/src/lib/mutation/subset.js +75 -0
  82. package/src/lib/mutation/trx.js +51 -0
  83. package/src/lib/procs/classify.js +91 -0
  84. package/src/lib/procs/list.js +124 -0
  85. package/src/lib/procs/mcp-config.js +103 -0
  86. package/src/lib/procs/prune.js +25 -0
  87. package/src/lib/procs/wslconfig.js +70 -0
  88. package/src/lib/projects/registry.js +6 -2
  89. package/src/lib/tasks/dotnet-test-introspection.js +1 -1
  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/code-budget/code-budget-validator.js +4 -6
  93. package/src/lib/validators/code-budget/index.js +1 -1
  94. package/src/lib/validators/reuse/symbol-scanner.js +1 -28
  95. package/src/lib/validators/verify-runner.js +26 -8
  96. package/src/lib/worktree/agent-reaper.js +247 -0
  97. package/src/lib/worktree/dirs.js +3 -32
  98. package/src/lib/worktree/vscode-settings.js +113 -0
  99. package/src/lib/worktree/worktree-audit.js +49 -5
  100. package/src/lib/worktree/worktree-include.js +46 -0
  101. package/src/scripts/setup-infra.js +4 -0
  102. package/src/utils/hooks-installer.js +14 -6
@@ -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';
@@ -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
+ }
@@ -30,16 +30,48 @@ import {
30
30
  isGitRepo,
31
31
  resolvePrimaryRoot,
32
32
  parseWorktreeList,
33
- WORKTREE_DIR_NAMES,
34
33
  clearDanglingWorktreeRegistration,
35
34
  } from '../../commands/project/worktree.js';
35
+ import { containerDirs, isUnder } from '../../../framework/hooks/shared/worktree-root.js';
36
36
  import { detectDefaultBranch } from '../git/default-branch.js';
37
37
  import { buildPrIndex } from '../git/pr-index.js';
38
38
  import { classifyBranch, commitsAhead, resolveBaseRef } from '../git/branch-classifier.js';
39
39
  import { auditDockerStacks } from './docker-audit.js';
40
40
  import { auditFinishBackups } from './finish-backup.js';
41
+ import { planAgentReap } from './agent-reaper.js';
41
42
 
42
43
  const norm = (p) => resolve(p).replace(/\\/g, '/');
44
+ const AGENT_TASK = /^agent-[0-9a-f]+$/;
45
+
46
+ const AGENT_FIX = {
47
+ 'stopped-clean': 'morph-spec worktree reap (ou `morph-spec doctor --worktrees --prune`)',
48
+ 'orphan-branch': 'morph-spec worktree reap (ou `morph-spec doctor --worktrees --prune`)',
49
+ 'directory-missing': 'morph-spec doctor --worktrees --prune',
50
+ locked: 'a árvore está com `git worktree lock`; `git worktree unlock <path>` libera, se o lock já não vale',
51
+ unknown: 'não foi possível provar que é seguro remover — deixe como está',
52
+ };
53
+
54
+ function agentLeftoverIssue(item) {
55
+ const where = item.path ?? item.branch;
56
+ const reapable = item.state === 'reapable';
57
+ const fix = AGENT_FIX[item.reason]
58
+ ?? (item.reason === 'exclusive-commits'
59
+ ? `integre a branch (\`git merge --no-ff ${item.branch}\` no orquestrador) antes de remover — ela é o único exemplar`
60
+ : item.reason === 'dirty'
61
+ ? `confira o trabalho na árvore (commite ou descarte) e rode \`morph-spec worktree remove --target "${item.path}"\``
62
+ : `o harness não registrou o fim do agente; se ele já terminou, \`morph-spec worktree remove --target "${item.path}"\``);
63
+ return {
64
+ type: 'agent-worktree-leftover',
65
+ severity: 'info',
66
+ ...(item.path ? { path: item.path } : {}),
67
+ branch: item.branch,
68
+ reapable,
69
+ detail: reapable
70
+ ? `${where} é sobra de sub-agent que já parou, sem trabalho pendente`
71
+ : `${where} é sobra de sub-agent com trabalho a preservar (${item.reason}${item.detail ? `: ${item.detail}` : ''})`,
72
+ fix,
73
+ };
74
+ }
43
75
 
44
76
  /** Feature names morph knows about in the primary root. */
45
77
  function knownFeatures(root) {
@@ -170,15 +202,16 @@ function isEmptyDir(path) {
170
202
  /** Directories physically present under either worktree root. */
171
203
  function dirsOnDisk(root) {
172
204
  const found = [];
173
- for (const dirName of WORKTREE_DIR_NAMES) {
174
- const base = join(root, dirName);
205
+ const rootNorm = norm(root);
206
+ for (const base of containerDirs(root)) {
175
207
  if (!existsSync(base)) continue;
208
+ const label = base.startsWith(`${rootNorm}/`) ? base.slice(rootNorm.length + 1) : base;
176
209
  try {
177
210
  for (const name of readdirSync(base)) {
178
211
  const full = join(base, name);
179
212
  try {
180
213
  if (statSync(full).isDirectory()) {
181
- found.push({ name, path: norm(full), container: dirName, empty: isEmptyDir(full) });
214
+ found.push({ name, path: norm(full), container: label, empty: isEmptyDir(full) });
182
215
  }
183
216
  } catch { /* unreadable entry — skip */ }
184
217
  }
@@ -214,6 +247,11 @@ function dirsOnDisk(root) {
214
247
  * directory that exists but git's own
215
248
  * registry does not know as a worktree
216
249
  *
250
+ * • agent-worktree-leftover (info) — a sub-agent's `agent-<hex>` tree or
251
+ * `morph-task/agent-<hex>` branch left behind:
252
+ * `reapable` when the agent stopped and nothing
253
+ * is lost, otherwise the reason it is kept
254
+ *
217
255
  * • docker-stack-orphan (warn) — a `morph-*` docker stack of this repository
218
256
  * still running for a feature this tree does
219
257
  * not know, and
@@ -242,6 +280,7 @@ export function auditWorktrees(cwd = process.cwd(), { exec } = {}) {
242
280
  // two differently on purpose.
243
281
  const prIndex = buildPrIndex(root);
244
282
 
283
+ const containers = containerDirs(root);
245
284
  const registered = parseWorktreeList(root);
246
285
  const registeredPaths = new Set();
247
286
  const features = knownFeatures(root);
@@ -263,7 +302,7 @@ export function auditWorktrees(cwd = process.cwd(), { exec } = {}) {
263
302
 
264
303
  const feature = entry.branch?.match(/^refs\/heads\/morph\/(.+)$/)?.[1] || null;
265
304
  if (!feature) {
266
- const inContainer = WORKTREE_DIR_NAMES.some((d) => path.startsWith(`${rootNorm}/${d}/`));
305
+ const inContainer = isUnder(containers, path);
267
306
  const task = entry.branch?.match(/^refs\/heads\/morph-task\/(.+)$/)?.[1] || null;
268
307
 
269
308
  if (!inContainer) {
@@ -287,6 +326,7 @@ export function auditWorktrees(cwd = process.cwd(), { exec } = {}) {
287
326
  }
288
327
 
289
328
  if (task) {
329
+ if (AGENT_TASK.test(task)) continue; // reported once, by the agent plan below
290
330
  // `morph-task/*`: a task-isolation tree provisioned by the WorktreeCreate
291
331
  // hook. Clean → nothing to say (the orchestrator removes it after the
292
332
  // merge). With commits the default does not have → report, never
@@ -367,6 +407,10 @@ export function auditWorktrees(cwd = process.cwd(), { exec } = {}) {
367
407
  }
368
408
  }
369
409
 
410
+ for (const item of planAgentReap(root, { cwd }).items) {
411
+ if (item.state === 'reapable' || item.state === 'preserved') issues.push(agentLeftoverIssue(item));
412
+ }
413
+
370
414
  for (const d of dirsOnDisk(root)) {
371
415
  if (registeredPaths.has(d.path)) continue;
372
416
  issues.push({