@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.
- package/CLAUDE.md +14 -4
- package/README.md +3 -3
- package/bin/morph-spec.js +48 -2
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +6 -5
- package/framework/MORPH.md +10 -9
- package/framework/agents.json +7 -7
- package/framework/docs/CLI.md +10 -3
- package/framework/evals/proof-rubrics/mutation-proof.md +43 -13
- package/framework/hooks/README.md +1 -0
- package/framework/hooks/claude-code/core/chain.js +2 -0
- package/framework/hooks/claude-code/core/comment-guard.js +107 -0
- package/framework/hooks/claude-code/core/heavy-gate.js +72 -0
- package/framework/hooks/claude-code/core/heavy-release.js +34 -0
- package/framework/hooks/claude-code/core/worktree-context.js +21 -20
- package/framework/hooks/claude-code/core/worktree-create.js +4 -4
- package/framework/hooks/shared/agent-reap.js +89 -0
- package/framework/hooks/shared/comment-rules.js +171 -0
- package/{src/lib/validators/code-budget → framework/hooks/shared}/comment-scanner.js +2 -2
- package/{src/lib/ai → framework/hooks/shared}/comment-stripper.js +2 -1
- package/framework/hooks/shared/e2e-check.js +3 -1
- package/framework/hooks/shared/graph-refresh.js +1 -9
- package/framework/hooks/shared/heavy-command.js +128 -0
- package/framework/hooks/shared/heavy-config.js +65 -0
- package/framework/hooks/shared/heavy-holders.js +85 -0
- package/framework/hooks/shared/heavy-slots.js +207 -0
- package/framework/hooks/shared/process-alive.js +8 -0
- package/framework/hooks/shared/source-files.js +34 -0
- package/framework/hooks/shared/worktree-root.js +93 -0
- package/framework/schemas/config.schema.json +80 -0
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +11 -4
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- package/framework/skills/core/morph-implement/SKILL.md +12 -5
- package/framework/skills/core/morph-machine/SKILL.md +46 -0
- package/framework/skills/core/morph-media/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +1 -1
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +45 -16
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +1 -1
- package/framework/standards/PROVENANCE.json +10 -10
- package/framework/standards/STANDARDS.json +1 -1
- package/framework/standards/architecture/code-comments.md +9 -2
- package/framework/standards/frontend/design-system/ai-video-generation.md +2 -0
- package/framework/standards/frontend/scroll-driven/frame-scrub.md +11 -1
- package/package.json +1 -1
- package/src/commands/e2e/e2e.js +27 -8
- package/src/commands/mcp/mcp-setup.js +10 -9
- package/src/commands/project/doctor-procs.js +162 -0
- package/src/commands/project/doctor.js +42 -4
- package/src/commands/project/heavy.js +61 -0
- package/src/commands/project/init.js +1 -1
- package/src/commands/project/update.js +4 -0
- package/src/commands/project/worktree-reap.js +34 -0
- package/src/commands/project/worktree.js +78 -27
- package/src/commands/validation/mutate.js +200 -0
- package/src/commands/validation/verify.js +26 -1
- package/src/dashboard/scanner/features-scanner.js +4 -4
- package/src/dashboard/scanner/project-api.js +4 -4
- package/src/lib/ai/code-audit.js +1 -1
- package/src/lib/ai/registry-audit.js +1 -1
- package/src/lib/detectors/claude-config-detector.js +30 -1
- package/src/lib/heavy/cleanup.js +70 -0
- package/src/lib/heavy/gate.js +96 -0
- package/src/lib/installers/mcp-installer.js +15 -2
- package/src/lib/mutation/adapters.js +90 -0
- package/src/lib/mutation/commands.js +60 -0
- package/src/lib/mutation/manifest.js +138 -0
- package/src/lib/mutation/plan.js +36 -0
- package/src/lib/mutation/report.js +66 -0
- package/src/lib/mutation/runner.js +351 -0
- package/src/lib/mutation/schemata.js +60 -0
- package/src/lib/mutation/snapshot.js +69 -0
- package/src/lib/mutation/subset.js +75 -0
- package/src/lib/mutation/trx.js +51 -0
- package/src/lib/procs/classify.js +91 -0
- package/src/lib/procs/list.js +124 -0
- package/src/lib/procs/mcp-config.js +103 -0
- package/src/lib/procs/prune.js +25 -0
- package/src/lib/procs/wslconfig.js +70 -0
- package/src/lib/projects/registry.js +6 -2
- package/src/lib/tasks/dotnet-test-introspection.js +1 -1
- package/src/lib/tasks/linux-runner.js +30 -3
- package/src/lib/tasks/test-runner.js +1 -1
- package/src/lib/validators/code-budget/code-budget-validator.js +4 -6
- package/src/lib/validators/code-budget/index.js +1 -1
- package/src/lib/validators/reuse/symbol-scanner.js +1 -28
- package/src/lib/validators/verify-runner.js +26 -8
- package/src/lib/worktree/agent-reaper.js +247 -0
- package/src/lib/worktree/dirs.js +3 -32
- package/src/lib/worktree/vscode-settings.js +113 -0
- package/src/lib/worktree/worktree-audit.js +49 -5
- package/src/lib/worktree/worktree-include.js +46 -0
- package/src/scripts/setup-infra.js +4 -0
- 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
|
+
}
|
package/src/lib/worktree/dirs.js
CHANGED
|
@@ -1,32 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
-
|
|
174
|
-
|
|
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:
|
|
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 =
|
|
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({
|