@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.
- package/CLAUDE.md +15 -5
- package/README.md +2 -2
- 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 +5 -4
- package/framework/MORPH.md +10 -9
- package/framework/agents.json +1 -1
- package/framework/docs/CLI.md +22 -10
- package/framework/evals/proof-rubrics/mutation-proof.md +43 -13
- 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/claude-code/core/worktree-env-inject.js +1 -4
- package/framework/hooks/shared/agent-reap.js +89 -0
- 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/worktree-root.js +93 -0
- package/framework/rules/ai-agents-standards.md +1 -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 +8 -3
- 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 +9 -4
- 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 +45 -1
- package/framework/standards/ai-agents/tool-adapters.md +226 -0
- package/framework/standards/frontend/design-system/ai-video-generation.md +2 -0
- package/framework/standards/frontend/scroll-driven/frame-scrub.md +11 -1
- package/framework/standards/infrastructure/docker/local-compose-isolation.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 +112 -43
- package/src/commands/state/approve.js +2 -1
- package/src/commands/state/finish.js +4 -0
- package/src/commands/state/gate-check.js +2 -1
- package/src/commands/validation/mutate.js +200 -0
- package/src/commands/validation/verify.js +39 -22
- package/src/dashboard/scanner/features-scanner.js +4 -4
- package/src/dashboard/scanner/project-api.js +4 -4
- package/src/lib/detectors/claude-config-detector.js +30 -1
- package/src/lib/e2e/compose-env.js +5 -2
- package/src/lib/git/evidence-staleness.js +39 -0
- 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/risk-detector.js +3 -3
- package/src/lib/tasks/eval-node.js +4 -34
- package/src/lib/tasks/linux-runner.js +30 -3
- package/src/lib/tasks/test-runner.js +1 -1
- package/src/lib/validators/contracts/contract-compliance-validator.js +4 -42
- 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/env-allocator.js +35 -5
- 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 +11 -4
|
@@ -51,34 +51,35 @@ import { acquireClaim, foreignClaim, claimAge } from '../../shared/claim-registr
|
|
|
51
51
|
import { rotateActivityLogIfLarge } from '../../shared/activity-logger.js';
|
|
52
52
|
import { rotateEventsLogIfLarge } from '../../shared/telemetry-logger.js';
|
|
53
53
|
import { kickGraphRefresh, seedFromPrimary } from '../../shared/graph-refresh.js';
|
|
54
|
+
import { kickAgentReap } from '../../shared/agent-reap.js';
|
|
55
|
+
import { isInContainer } from '../../shared/worktree-root.js';
|
|
56
|
+
import { isSecondaryWorktree, resolveGitCommonDir } from '../../shared/git-paths.js';
|
|
54
57
|
|
|
55
58
|
function projectDir() {
|
|
56
59
|
return process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
57
60
|
}
|
|
58
61
|
|
|
59
|
-
|
|
60
|
-
return execSync(`git rev-parse ${arg}`, { cwd: dir, stdio: ['pipe', 'pipe', 'pipe'] })
|
|
61
|
-
.toString()
|
|
62
|
-
.trim();
|
|
63
|
-
}
|
|
62
|
+
const samePath = (a, b) => (process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b);
|
|
64
63
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
* worktree" — fail-open.
|
|
71
|
-
*/
|
|
72
|
-
function isSecondaryWorktree(dir) {
|
|
64
|
+
// Inside a worktree Claude Code keeps CLAUDE_PROJECT_DIR at the project root and moves only the payload `cwd`.
|
|
65
|
+
function sessionDir(payload) {
|
|
66
|
+
const base = projectDir();
|
|
67
|
+
const cwd = typeof payload?.cwd === 'string' ? payload.cwd : '';
|
|
68
|
+
if (!cwd || samePath(resolve(cwd), resolve(base))) return base;
|
|
73
69
|
try {
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
return gitDir !== commonDir;
|
|
70
|
+
const common = resolveGitCommonDir(cwd);
|
|
71
|
+
return common && samePath(resolve(common), resolve(resolveGitCommonDir(base) ?? '')) && isSecondaryWorktree(cwd) ? cwd : base;
|
|
77
72
|
} catch {
|
|
78
|
-
return
|
|
73
|
+
return base;
|
|
79
74
|
}
|
|
80
75
|
}
|
|
81
76
|
|
|
77
|
+
function gitRevParse(arg, dir) {
|
|
78
|
+
return execSync(`git rev-parse ${arg}`, { cwd: dir, stdio: ['pipe', 'pipe', 'pipe'] })
|
|
79
|
+
.toString()
|
|
80
|
+
.trim();
|
|
81
|
+
}
|
|
82
|
+
|
|
82
83
|
/** Feature name from the current branch, if it matches `morph/{feature}`. */
|
|
83
84
|
function featureFromBranch(dir) {
|
|
84
85
|
try {
|
|
@@ -118,8 +119,7 @@ function isOutsideContainer(dir) {
|
|
|
118
119
|
try {
|
|
119
120
|
const common = resolve(dir, gitRevParse('--git-common-dir', dir)).replace(/\\/g, '/');
|
|
120
121
|
const root = common.replace(/\/\.git$/, '');
|
|
121
|
-
|
|
122
|
-
return !['worktrees', '.worktrees'].some((d) => norm.startsWith(`${root}/${d}/`));
|
|
122
|
+
return !isInContainer(root, dir);
|
|
123
123
|
} catch {
|
|
124
124
|
return false;
|
|
125
125
|
}
|
|
@@ -280,7 +280,7 @@ function claimBlock(dir, feature, sessionId) {
|
|
|
280
280
|
*/
|
|
281
281
|
export async function run(payload) {
|
|
282
282
|
try {
|
|
283
|
-
const dir =
|
|
283
|
+
const dir = sessionDir(payload);
|
|
284
284
|
// The SessionStart payload carries session_id — the identity the claim
|
|
285
285
|
// registry uses to tell "me" from "another session".
|
|
286
286
|
const sessionId = payload?.session_id || `pid-${process.pid}`;
|
|
@@ -303,6 +303,7 @@ export async function run(payload) {
|
|
|
303
303
|
// Returns immediately — nothing here waits on graphify.
|
|
304
304
|
try { seedFromPrimary(dir); } catch { /* non-blocking — a no-op on the root */ }
|
|
305
305
|
try { kickGraphRefresh(dir); } catch { /* non-blocking */ }
|
|
306
|
+
try { kickAgentReap(dir); } catch { /* non-blocking */ }
|
|
306
307
|
|
|
307
308
|
if (isSecondaryWorktree(dir)) {
|
|
308
309
|
const task = taskFromBranch(dir);
|
|
@@ -43,8 +43,7 @@ import { homedir } from 'os';
|
|
|
43
43
|
import { stateExists } from '../../shared/state-reader.js';
|
|
44
44
|
import { resolveGitCommonDir } from '../../shared/git-paths.js';
|
|
45
45
|
import { runMorphSpec } from '../../shared/morph-cli.js';
|
|
46
|
-
|
|
47
|
-
const WORKTREES_DIR = 'worktrees';
|
|
46
|
+
import { newWorktreesDir } from '../../shared/worktree-root.js';
|
|
48
47
|
|
|
49
48
|
const fwd = (p) => String(p).replace(/\\/g, '/');
|
|
50
49
|
|
|
@@ -152,14 +151,15 @@ function defaultBranch(root) {
|
|
|
152
151
|
*/
|
|
153
152
|
function gitFallback(root, name, base) {
|
|
154
153
|
const branch = `morph-task/${name}`;
|
|
155
|
-
const
|
|
154
|
+
const container = newWorktreesDir(root);
|
|
155
|
+
const path = `${container}/${name}`;
|
|
156
156
|
if (existsSync(path)) return { path };
|
|
157
157
|
try { git(['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], root); }
|
|
158
158
|
catch {
|
|
159
159
|
const from = base || defaultBranch(root);
|
|
160
160
|
git(from ? ['branch', branch, from] : ['branch', branch], root);
|
|
161
161
|
}
|
|
162
|
-
mkdirSync(
|
|
162
|
+
mkdirSync(container, { recursive: true });
|
|
163
163
|
git(['worktree', 'add', path, branch], root);
|
|
164
164
|
return { path };
|
|
165
165
|
}
|
|
@@ -42,9 +42,6 @@ const LAUNCHER_RE = /\b(?:(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:dev|start)\b|nex
|
|
|
42
42
|
/** The command already chose its own port/project — respect it. */
|
|
43
43
|
const SELF_CONFIGURED_RE = /(?:^|[\s;&|])(?:PORT|API_PORT|POSTGRES_PORT|COMPOSE_PROJECT_NAME|ASPNETCORE_URLS)=|--port\b|(?:^|\s)-p\s+\d|compose\b[^|;&]*\s-p\s/;
|
|
44
44
|
|
|
45
|
-
/** Keys the layout owns; anything else in the manifest is left alone. */
|
|
46
|
-
const LAYOUT_KEYS = ['COMPOSE_PROJECT_NAME', 'MORPH_PORT_BASE', 'MORPH_PORT_RANGE', 'PORT', 'API_PORT', 'POSTGRES_PORT', 'REDIS_PORT', 'ASPNETCORE_URLS', 'PLAYWRIGHT_BASE_URL'];
|
|
47
|
-
|
|
48
45
|
/** POSIX single-quote, safe for Git Bash on Windows too. */
|
|
49
46
|
function shq(v) {
|
|
50
47
|
return `'${String(v).replace(/'/g, "'\\''")}'`;
|
|
@@ -59,7 +56,7 @@ function shq(v) {
|
|
|
59
56
|
export function rewriteCommand(command, env) {
|
|
60
57
|
if (!command || !LAUNCHER_RE.test(command)) return null;
|
|
61
58
|
if (SELF_CONFIGURED_RE.test(command)) return null;
|
|
62
|
-
const pairs =
|
|
59
|
+
const pairs = Object.keys(env).filter((k) => k !== 'MORPH_FEATURE' && env[k]).map((k) => [k, env[k]]);
|
|
63
60
|
if (!pairs.some(([k]) => k === 'PORT')) return null;
|
|
64
61
|
// The web tier's URL is what the spec suite needs; derive it once here.
|
|
65
62
|
if (!env.PLAYWRIGHT_BASE_URL) pairs.push(['PLAYWRIGHT_BASE_URL', `http://localhost:${env.PORT}`]);
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-reap.js — dispara `morph-spec worktree reap` em background no SessionStart.
|
|
3
|
+
*
|
|
4
|
+
* O hook de SessionStart tem 5 s de timeout e o reaper mede git por árvore: por isso o
|
|
5
|
+
* disparo só olha o disco (nenhum subprocesso) e solta este arquivo como worker DESTACADO
|
|
6
|
+
* (`--run <raiz>`), que chama o CLI. Só a raiz: o worker nasce com `cwd` no git common dir
|
|
7
|
+
* e nunca segura o diretório de uma worktree.
|
|
8
|
+
*
|
|
9
|
+
* Fail-open em tudo. `MORPH_WORKTREE_REAP=0` desliga.
|
|
10
|
+
*
|
|
11
|
+
* @module agent-reap
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { spawn } from 'child_process';
|
|
15
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs';
|
|
16
|
+
import { join, resolve } from 'path';
|
|
17
|
+
import { fileURLToPath } from 'url';
|
|
18
|
+
import { isSecondaryWorktree, resolveGitCommonDir } from './git-paths.js';
|
|
19
|
+
import { primaryTree } from './graph-freshness.js';
|
|
20
|
+
import { runMorphSpec } from './morph-cli.js';
|
|
21
|
+
|
|
22
|
+
const WORKER = fileURLToPath(import.meta.url);
|
|
23
|
+
const THROTTLE_MS = 10 * 60_000;
|
|
24
|
+
const WORKER_TIMEOUT_MS = 15 * 60_000;
|
|
25
|
+
const ADMIN_DIR = /^agent-[0-9a-f]+\d*$/;
|
|
26
|
+
const AGENT_BRANCH_FILE = /^agent-[0-9a-f]+$/;
|
|
27
|
+
|
|
28
|
+
function anyEntry(dir, pattern) {
|
|
29
|
+
try { return readdirSync(dir).some((name) => pattern.test(name)); } catch { return false; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Uma worktree de agent registrada, ou uma branch `morph-task/agent-*` (solta ou empacotada). */
|
|
33
|
+
export function hasAgentLeftovers(common) {
|
|
34
|
+
if (anyEntry(join(common, 'worktrees'), ADMIN_DIR)) return true;
|
|
35
|
+
if (anyEntry(join(common, 'refs', 'heads', 'morph-task'), AGENT_BRANCH_FILE)) return true;
|
|
36
|
+
try {
|
|
37
|
+
return /refs\/heads\/morph-task\/agent-[0-9a-f]+/.test(readFileSync(join(common, 'packed-refs'), 'utf8'));
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function recentlyKicked(marker, now) {
|
|
44
|
+
try {
|
|
45
|
+
const age = now - Number(readFileSync(marker, 'utf8'));
|
|
46
|
+
return age >= 0 && age < THROTTLE_MS;
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {string} dir - diretório do projeto da sessão
|
|
54
|
+
* @returns {{ kicked: boolean, reason?: string, pid?: number }}
|
|
55
|
+
*/
|
|
56
|
+
export function kickAgentReap(dir, { spawnFn = spawn, env = process.env, now = Date.now } = {}) {
|
|
57
|
+
if (env.MORPH_WORKTREE_REAP === '0') return { kicked: false, reason: 'disabled' };
|
|
58
|
+
const common = resolveGitCommonDir(dir);
|
|
59
|
+
const root = common && primaryTree(dir);
|
|
60
|
+
if (!root) return { kicked: false, reason: 'not-a-repo' };
|
|
61
|
+
if (isSecondaryWorktree(dir)) return { kicked: false, reason: 'secondary-worktree' };
|
|
62
|
+
if (!hasAgentLeftovers(common)) return { kicked: false, reason: 'nothing-to-reap' };
|
|
63
|
+
|
|
64
|
+
const marker = join(common, 'morph-spec', 'agent-reap.kick');
|
|
65
|
+
if (recentlyKicked(marker, now())) return { kicked: false, reason: 'throttled' };
|
|
66
|
+
try {
|
|
67
|
+
mkdirSync(join(common, 'morph-spec'), { recursive: true });
|
|
68
|
+
writeFileSync(marker, String(now()));
|
|
69
|
+
const child = spawnFn(process.execPath, [WORKER, '--run', root], {
|
|
70
|
+
cwd: common, env, detached: true, stdio: 'ignore', windowsHide: true,
|
|
71
|
+
});
|
|
72
|
+
child.on?.('error', () => {});
|
|
73
|
+
child.unref?.();
|
|
74
|
+
return { kicked: true, pid: child.pid };
|
|
75
|
+
} catch {
|
|
76
|
+
return { kicked: false, reason: 'spawn-failed' };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const isMain = (() => {
|
|
81
|
+
const argv1 = process.argv[1];
|
|
82
|
+
if (!argv1) return false;
|
|
83
|
+
const norm = (p) => (process.platform === 'win32' ? resolve(p).toLowerCase() : resolve(p));
|
|
84
|
+
return norm(argv1) === norm(WORKER);
|
|
85
|
+
})();
|
|
86
|
+
|
|
87
|
+
if (isMain && process.argv[2] === '--run' && process.argv[3] && existsSync(process.argv[3])) {
|
|
88
|
+
try { runMorphSpec(['worktree', 'reap', '--json'], { cwd: process.argv[3], timeoutMs: WORKER_TIMEOUT_MS }); } catch { /* fail-open */ }
|
|
89
|
+
}
|
|
@@ -675,7 +675,7 @@ function sleep(ms) {
|
|
|
675
675
|
|
|
676
676
|
/** Tear the stack down. Best-effort: teardown failure never fails a check. */
|
|
677
677
|
export function tearDownStack(cwd, { composeFile, projectName, env, volumes = false } = {}, exec = defaultExec) {
|
|
678
|
-
const args = [...composeArgs(composeFile, projectName), 'down'];
|
|
678
|
+
const args = [...composeArgs(composeFile, projectName), 'down', '--remove-orphans'];
|
|
679
679
|
if (volumes) args.push('-v');
|
|
680
680
|
const result = exec('docker', args, { cwd, env });
|
|
681
681
|
return { status: result.code === 0 ? 'pass' : 'fail', output: result.output };
|
|
@@ -863,6 +863,7 @@ export function runE2eCheck(cwd, options = {}) {
|
|
|
863
863
|
insideWorktree = isSecondaryWorktree(cwd),
|
|
864
864
|
healthTimeout = DEFAULT_HEALTH_TIMEOUT,
|
|
865
865
|
specTimeout = DEFAULT_SPEC_TIMEOUT,
|
|
866
|
+
beforeHeavy = null,
|
|
866
867
|
_exec: exec = defaultExec,
|
|
867
868
|
} = options;
|
|
868
869
|
|
|
@@ -910,6 +911,7 @@ export function runE2eCheck(cwd, options = {}) {
|
|
|
910
911
|
: {};
|
|
911
912
|
const composeEnv = { COMPOSE_PROJECT_NAME: projectName, ...authEnv, ...(env || {}) };
|
|
912
913
|
|
|
914
|
+
if (typeof beforeHeavy === 'function') beforeHeavy();
|
|
913
915
|
const up = bringUpStack(
|
|
914
916
|
cwd,
|
|
915
917
|
{ composeFile, projectName, gitCommonDir, env: composeEnv, forceFresh, healthTimeout },
|
|
@@ -50,6 +50,7 @@ import { fileURLToPath } from 'url';
|
|
|
50
50
|
import { readPrimaryHead, resolveGitCommonDir, workTreeTop } from './git-paths.js';
|
|
51
51
|
import { findFirstOnPath, quoteForCmd } from './morph-cli.js';
|
|
52
52
|
import { ensureCommonExclude } from './git-exclude.js';
|
|
53
|
+
import { pidAlive } from './process-alive.js';
|
|
53
54
|
import {
|
|
54
55
|
GRAPH_DIR, graphFile, graphFreshness, ignoreRulesHash, primaryTree, readRefreshRecord,
|
|
55
56
|
recordRefreshRun, refreshPending, treeKey, writeRefreshRecord,
|
|
@@ -110,15 +111,6 @@ function mtimeOf(path) {
|
|
|
110
111
|
}
|
|
111
112
|
}
|
|
112
113
|
|
|
113
|
-
function pidAlive(pid) {
|
|
114
|
-
try {
|
|
115
|
-
process.kill(pid, 0);
|
|
116
|
-
return true;
|
|
117
|
-
} catch (err) {
|
|
118
|
-
return err?.code === 'EPERM';
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
114
|
/**
|
|
123
115
|
* Estado do lock do repositório — o que `graph status` mostra como "refresh em
|
|
124
116
|
* andamento". A idade conta da última renovação: um lock de processo morto,
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { tokenize } from './command-tokens.js';
|
|
2
|
+
|
|
3
|
+
const DOTNET_HEAVY = new Set(['build', 'test', 'publish', 'pack', 'msbuild', 'vstest']);
|
|
4
|
+
const PACKAGE_MANAGERS = new Set(['npm', 'pnpm', 'yarn', 'bun']);
|
|
5
|
+
const HEAVY_SCRIPT = /^(test|build)(:.+)?$/;
|
|
6
|
+
const WRAPPERS = new Set(['time', 'nohup', 'nice', 'command', 'exec', 'env']);
|
|
7
|
+
const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
8
|
+
const INFO_FLAGS = new Set(['--help', '-h', '--version', '-?']);
|
|
9
|
+
|
|
10
|
+
function splitSegments(command) {
|
|
11
|
+
const segments = [];
|
|
12
|
+
let current = '';
|
|
13
|
+
let quote = null;
|
|
14
|
+
const s = String(command || '');
|
|
15
|
+
for (let i = 0; i < s.length; i++) {
|
|
16
|
+
const ch = s[i];
|
|
17
|
+
if (quote) {
|
|
18
|
+
current += ch;
|
|
19
|
+
if (ch === quote) quote = null;
|
|
20
|
+
else if (ch === '\\' && quote === '"') current += s[++i] ?? '';
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (ch === '"' || ch === "'") { quote = ch; current += ch; continue; }
|
|
24
|
+
if (ch === '\\') { current += ch + (s[++i] ?? ''); continue; }
|
|
25
|
+
if (';|&\n'.includes(ch)) { segments.push(current); current = ''; continue; }
|
|
26
|
+
current += ch;
|
|
27
|
+
}
|
|
28
|
+
segments.push(current);
|
|
29
|
+
return segments;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function executable(token) {
|
|
33
|
+
return token.replace(/^.*[\\/]/, '').replace(/\.(exe|cmd|bat|ps1)$/i, '').toLowerCase();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function skipWrappers(tokens) {
|
|
37
|
+
let i = 0;
|
|
38
|
+
for (;;) {
|
|
39
|
+
const t = tokens[i];
|
|
40
|
+
if (t === undefined) return [];
|
|
41
|
+
if (ASSIGNMENT.test(t)) { i++; continue; }
|
|
42
|
+
if (t === 'timeout') { i += 2; continue; }
|
|
43
|
+
if (WRAPPERS.has(executable(t))) { i++; continue; }
|
|
44
|
+
return tokens.slice(i);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const DOCKER_VALUE_FLAGS = new Set(['-f', '--file', '-p', '--project-name', '--profile', '--env-file', '--project-directory', '-H', '--host', '--context']);
|
|
49
|
+
|
|
50
|
+
function firstArg(args, valueFlags = new Set()) {
|
|
51
|
+
for (let i = 0; i < args.length; i++) {
|
|
52
|
+
if (!args[i].startsWith('-')) return args[i];
|
|
53
|
+
if (valueFlags.has(args[i])) i++;
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function kindOfTokens(tokens) {
|
|
59
|
+
const [head, ...args] = tokens;
|
|
60
|
+
if (!head || args.some((a) => INFO_FLAGS.has(a))) return null;
|
|
61
|
+
const exe = executable(head);
|
|
62
|
+
const sub = firstArg(args, exe === 'docker' || exe === 'docker-compose' ? DOCKER_VALUE_FLAGS : undefined);
|
|
63
|
+
|
|
64
|
+
if (exe === 'dotnet') return DOTNET_HEAVY.has(sub) ? `dotnet ${sub}` : null;
|
|
65
|
+
if (exe === 'msbuild') return 'msbuild';
|
|
66
|
+
if (exe === 'vitest' || exe === 'jest') return exe;
|
|
67
|
+
if (exe === 'playwright') return sub === 'test' ? 'playwright test' : null;
|
|
68
|
+
if (exe === 'next' || exe === 'vite') return sub === 'build' ? `${exe} build` : null;
|
|
69
|
+
|
|
70
|
+
if (PACKAGE_MANAGERS.has(exe)) {
|
|
71
|
+
if (sub === 'test' || sub === 'build') return `${exe} ${sub}`;
|
|
72
|
+
if (sub === 'run' || sub === 'run-script') {
|
|
73
|
+
const script = args.slice(args.indexOf(sub) + 1).find((t) => !t.startsWith('-'));
|
|
74
|
+
return script && HEAVY_SCRIPT.test(script) ? `${exe} run ${script}` : null;
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (exe === 'npx' || exe === 'bunx') {
|
|
80
|
+
if (sub === 'vitest' || sub === 'jest') return `npx ${sub}`;
|
|
81
|
+
const rest = args.slice(args.indexOf(sub) + 1).find((t) => !t.startsWith('-'));
|
|
82
|
+
if (sub === 'playwright') return rest === 'test' ? 'npx playwright test' : null;
|
|
83
|
+
if (sub === 'next') return rest === 'build' ? 'npx next build' : null;
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (exe === 'docker-compose') return sub === 'up' || sub === 'build' ? `docker-compose ${sub}` : null;
|
|
88
|
+
if (exe === 'docker') {
|
|
89
|
+
if (sub === 'build' || sub === 'buildx') return `docker ${sub}`;
|
|
90
|
+
if (sub === 'compose') {
|
|
91
|
+
const action = firstArg(args.slice(args.indexOf(sub) + 1), DOCKER_VALUE_FLAGS);
|
|
92
|
+
return action === 'up' || action === 'build' ? `docker compose ${action}` : null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The first heavy command of a Bash command line, as a label, or null. Looks at the executable of
|
|
100
|
+
* each `;`/`&&`/`||`/`|`/newline segment, so `morph-spec heavy run -- dotnet test` and `echo "dotnet build"`
|
|
101
|
+
* are not heavy: the first runs under its own slot, the second runs nothing.
|
|
102
|
+
* @param {string} command
|
|
103
|
+
* @returns {string|null}
|
|
104
|
+
*/
|
|
105
|
+
export function heavyKind(command) {
|
|
106
|
+
for (const segment of splitSegments(command)) {
|
|
107
|
+
const tokens = skipWrappers(tokenize(segment.replace(/^[\s({]+/, '').replace(/[\s)}]+$/, '')));
|
|
108
|
+
const kind = kindOfTokens(tokens);
|
|
109
|
+
if (kind) return kind;
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* How long a foreground Bash call can run: its own `timeout` (capped at the platform maximum) plus slack.
|
|
116
|
+
* The lease has no live process to watch, so this is the only thing that frees it after a denied or
|
|
117
|
+
* interrupted call.
|
|
118
|
+
*/
|
|
119
|
+
const BASH_DEFAULT_TIMEOUT_MS = 2 * 60_000;
|
|
120
|
+
const BASH_MAX_TIMEOUT_MS = 10 * 60_000;
|
|
121
|
+
const LEASE_SLACK_MS = 30_000;
|
|
122
|
+
|
|
123
|
+
export function leaseTtlMs(toolInput, env = process.env) {
|
|
124
|
+
const fallback = Number(env.BASH_DEFAULT_TIMEOUT_MS) || BASH_DEFAULT_TIMEOUT_MS;
|
|
125
|
+
const max = Math.max(Number(env.BASH_MAX_TIMEOUT_MS) || BASH_MAX_TIMEOUT_MS, fallback);
|
|
126
|
+
const asked = Number(toolInput?.timeout);
|
|
127
|
+
return Math.min(Number.isFinite(asked) && asked > 0 ? asked : fallback, max) + LEASE_SLACK_MS;
|
|
128
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const CONFIG_FILE = join('.morph', 'config', 'config.json');
|
|
5
|
+
|
|
6
|
+
export const HEAVY_DEFAULTS = Object.freeze({
|
|
7
|
+
enabled: true,
|
|
8
|
+
slots: 2,
|
|
9
|
+
minFreeMemGb: 4,
|
|
10
|
+
waitTimeoutSec: 600,
|
|
11
|
+
maxHoldMinutes: 90,
|
|
12
|
+
shutdownBuildServer: true,
|
|
13
|
+
disableNodeReuse: false,
|
|
14
|
+
gateBash: false,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const isBool = (v) => typeof v === 'boolean';
|
|
18
|
+
const VALID = {
|
|
19
|
+
enabled: isBool,
|
|
20
|
+
slots: (v) => Number.isInteger(v) && v >= 1,
|
|
21
|
+
minFreeMemGb: (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0,
|
|
22
|
+
waitTimeoutSec: (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0,
|
|
23
|
+
maxHoldMinutes: (v) => typeof v === 'number' && Number.isFinite(v) && v > 0,
|
|
24
|
+
shutdownBuildServer: isBool,
|
|
25
|
+
disableNodeReuse: isBool,
|
|
26
|
+
gateBash: isBool,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function readProjectConfig(projectPath) {
|
|
30
|
+
const file = join(projectPath, CONFIG_FILE);
|
|
31
|
+
if (!existsSync(file)) return null;
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(readFileSync(file, 'utf8').replace(/^/, ''));
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function resolveHeavyConfig(config, env = process.env) {
|
|
40
|
+
const out = { ...HEAVY_DEFAULTS };
|
|
41
|
+
const warnings = [];
|
|
42
|
+
const raw = config?.heavy;
|
|
43
|
+
|
|
44
|
+
if (raw !== undefined) {
|
|
45
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
46
|
+
warnings.push('`heavy` no config.json não é um objeto; usando os padrões');
|
|
47
|
+
} else {
|
|
48
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
49
|
+
if (!(key in VALID)) warnings.push(`heavy.${key} não existe; ignorada`);
|
|
50
|
+
else if (VALID[key](value)) out[key] = value;
|
|
51
|
+
else warnings.push(`heavy.${key} inválida (${JSON.stringify(value)}); usando ${JSON.stringify(HEAVY_DEFAULTS[key])}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (/^(off|0|false)$/i.test(env.MORPH_HEAVY ?? '')) out.enabled = false;
|
|
57
|
+
const envSlots = Number(env.MORPH_HEAVY_SLOTS);
|
|
58
|
+
if (env.MORPH_HEAVY_SLOTS !== undefined && VALID.slots(envSlots)) out.slots = envSlots;
|
|
59
|
+
|
|
60
|
+
return { ...out, warnings };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function readHeavyConfig(projectPath, env = process.env) {
|
|
64
|
+
return resolveHeavyConfig(readProjectConfig(projectPath), env);
|
|
65
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { freemem, homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { pidAlive } from './process-alive.js';
|
|
5
|
+
|
|
6
|
+
export const GB = 1024 ** 3;
|
|
7
|
+
export const WRITE_GRACE_MS = 5000;
|
|
8
|
+
export const EXCLUSIVE_MAX_AGE_MS = 2 * 60 * 1000;
|
|
9
|
+
export const SLOT_FILE = /^slot-\d+\.lock$/;
|
|
10
|
+
|
|
11
|
+
export function heavyDir(env = process.env, { platform = process.platform, home = homedir() } = {}) {
|
|
12
|
+
if (env.MORPH_HEAVY_DIR) return env.MORPH_HEAVY_DIR;
|
|
13
|
+
if (platform === 'win32') return join(env.LOCALAPPDATA || join(home, 'AppData', 'Local'), 'morph-spec', 'heavy');
|
|
14
|
+
return join(env.XDG_STATE_HOME || join(home, '.local', 'state'), 'morph-spec', 'heavy');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const mtimeOf = (path) => {
|
|
18
|
+
try { return statSync(path).mtimeMs; } catch { return null; }
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const leaseTtlMs = (meta) => (Number.isFinite(meta.ttlMs) && meta.ttlMs > 0 ? meta.ttlMs : Infinity);
|
|
22
|
+
|
|
23
|
+
export function readLockFile(path, { now, isAlive, maxHoldMs }) {
|
|
24
|
+
const t = now();
|
|
25
|
+
let meta;
|
|
26
|
+
try {
|
|
27
|
+
meta = JSON.parse(readFileSync(path, 'utf8'));
|
|
28
|
+
} catch {
|
|
29
|
+
const mtime = mtimeOf(path);
|
|
30
|
+
if (mtime === null) return null;
|
|
31
|
+
const writing = t - mtime < WRITE_GRACE_MS;
|
|
32
|
+
return { path, pid: null, since: null, label: '(gravando)', live: writing, unreadable: true };
|
|
33
|
+
}
|
|
34
|
+
const age = t - Date.parse(meta.since);
|
|
35
|
+
const inWindow = age >= 0 && age < maxHoldMs;
|
|
36
|
+
// A per-call lease belongs to a hook process that is long gone: only its TTL says when it lapses.
|
|
37
|
+
const live = meta.leaseId ? inWindow && age < leaseTtlMs(meta) : inWindow && Boolean(isAlive(meta.pid));
|
|
38
|
+
return { ...meta, path, live };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function scanHolders(dir, ctx) {
|
|
42
|
+
let names = [];
|
|
43
|
+
try { names = readdirSync(dir).filter((n) => SLOT_FILE.test(n)); } catch { return { live: [], stale: [] }; }
|
|
44
|
+
const live = [];
|
|
45
|
+
const stale = [];
|
|
46
|
+
for (const name of names) {
|
|
47
|
+
const holder = readLockFile(join(dir, name), ctx);
|
|
48
|
+
if (!holder) continue;
|
|
49
|
+
(holder.live ? live : stale).push(holder);
|
|
50
|
+
}
|
|
51
|
+
return { live, stale };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function readHolders(dir, ctx) {
|
|
55
|
+
return scanHolders(dir, ctx).live;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function readExclusive(dir, ctx) {
|
|
59
|
+
const holder = readLockFile(join(dir, 'exclusive.lock'), { ...ctx, maxHoldMs: EXCLUSIVE_MAX_AGE_MS });
|
|
60
|
+
return holder?.live ? holder : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const ageText = (ms) => {
|
|
64
|
+
const min = Math.floor(ms / 60000);
|
|
65
|
+
return min < 1 ? `${Math.max(0, Math.floor(ms / 1000))}s` : `${min}m`;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export function describeHolder(h, now = Date.now()) {
|
|
69
|
+
const what = [h.label, [h.feature, h.node].filter(Boolean).join('/')].filter(Boolean).join(' ');
|
|
70
|
+
const age = h.since ? `, há ${ageText(now - Date.parse(h.since))}` : '';
|
|
71
|
+
const who = h.leaseId ? 'chamada Bash' : `pid ${h.pid ?? '?'}`;
|
|
72
|
+
return `${what || 'processo'}, ${who}${age}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function heavyStatus({
|
|
76
|
+
dir, maxHoldMs, now = Date.now, isAlive = pidAlive, freeMem = freemem,
|
|
77
|
+
}) {
|
|
78
|
+
const ctx = { now, isAlive, maxHoldMs };
|
|
79
|
+
return {
|
|
80
|
+
dir,
|
|
81
|
+
holders: readHolders(dir, ctx),
|
|
82
|
+
exclusive: readExclusive(dir, ctx),
|
|
83
|
+
freeGb: freeMem() / GB,
|
|
84
|
+
};
|
|
85
|
+
}
|