@polymorphism-tech/morph-spec 10.5.2 → 10.5.3
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/README.md +2 -2
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/docs/CLI.md +1 -1
- package/framework/hooks/shared/background-spawn.js +20 -0
- package/framework/hooks/shared/graph-refresh.js +2 -1
- package/framework/hooks/shared/heavy-config.js +9 -0
- package/framework/schemas/config.schema.json +1 -1
- 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 +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +1 -1
- package/package.json +1 -1
- package/src/lib/heavy/gate.js +18 -9
- package/src/lib/heavy/reclaim.js +1 -1
- package/src/lib/verify/detached.js +3 -1
- package/src/utils/hooks-installer.js +8 -0
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
> Spec-driven development framework for multi-stack projects. Turns feature requests into implementation-ready code through structured, AI-orchestrated phases.
|
|
4
4
|
|
|
5
5
|
**Package:** `@polymorphism-tech/morph-spec`
|
|
6
|
-
**Version:** 10.5.
|
|
6
|
+
**Version:** 10.5.3
|
|
7
7
|
**Requires:** Node.js 22+, Claude Code
|
|
8
8
|
|
|
9
9
|
---
|
|
@@ -376,4 +376,4 @@ Code generated by morph-spec (contracts, templates, implementation output) belon
|
|
|
376
376
|
|
|
377
377
|
---
|
|
378
378
|
|
|
379
|
-
*morph-spec v10.5.
|
|
379
|
+
*morph-spec v10.5.3 by [Polymorphism Tech](https://polymorphism.tech)*
|
package/claude-plugin.json
CHANGED
package/docs/CHEATSHEET.md
CHANGED
package/docs/QUICKSTART.md
CHANGED
package/framework/docs/CLI.md
CHANGED
|
@@ -418,7 +418,7 @@ Isolamento entre worktrees: `COMPOSE_PROJECT_NAME=morph-{feature}` separa os con
|
|
|
418
418
|
|
|
419
419
|
**Vigia durante o `verify --detach`** (`heavy.criticalFreeMemGb`, 1, `0` desliga): o piso julga o começo de um nó; o vigia, um processo próprio, lê a RAM a cada 10 s durante a rodada. Abaixo do valor, tenta o reclaim na 1ª leitura; seis leituras seguidas abaixo param **só aquela rodada**, com a árvore de processos dela: exit 3, sem stamp, nunca vermelho.
|
|
420
420
|
|
|
421
|
-
**Máquina responsiva** (`heavy.yieldToUser`, ligado): cada rodada usa
|
|
421
|
+
**Máquina responsiva** (`heavy.yieldToUser`, ligado): um quarto dos núcleos fica de fora (mínimo 2); a cada nó, a rodada sozinha usa o resto, e com outra na fila usa `resto / heavy.slots`, por `DOTNET_PROCESSOR_COUNT` (build e testes), `VITEST_MAX_FORKS`, `VITEST_MAX_THREADS` e `--maxWorkers`. Numa máquina de 16 núcleos: 12 sozinha, 6 acompanhada. Medido em duas suítes reais do Vitest: sem custo de tempo acompanhada, 4% sozinha, e metade da latência para abrir um processo. O comando solto, fora da fila, também respeita: `init` e `update` gravam a parte acompanhada em `env` de `.claude/settings.local.json`, sem sobrescrever valor que já esteja lá. Prioridade baixa fica de fora de propósito: numa máquina ocupada, o processo perde a disputa e falha pelo próprio limite de tempo.
|
|
422
422
|
|
|
423
423
|
**Fila que não recusa** (`heavy.detachedWaitMinutes`, 60): fora de um shell não há timeout de chamada, então o `verify --detach` espera a vez em vez de sair com exit 3 em `waitTimeoutSec`. `MORPH_HEAVY_WAIT_SEC` sobrepõe a espera.
|
|
424
424
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
|
|
4
|
+
const HOST = fileURLToPath(import.meta.url);
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Arguments for a detached node process. On Windows `detached` means no console at all and
|
|
8
|
+
* `windowsHide` is ignored, so every console child opens a visible window; a child that is not
|
|
9
|
+
* detached has a hidden console but dies with its node parent (libuv job). The host holds both
|
|
10
|
+
* ends: detached itself, it stays alive for a child whose hidden console all descendants inherit.
|
|
11
|
+
*/
|
|
12
|
+
export function backgroundArgs(args, platform = process.platform) {
|
|
13
|
+
return platform === 'win32' ? [HOST, ...args] : args;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (process.argv[1] === HOST) {
|
|
17
|
+
const child = spawn(process.execPath, process.argv.slice(2), { stdio: 'inherit', windowsHide: true });
|
|
18
|
+
child.on('error', () => process.exit(1));
|
|
19
|
+
child.on('exit', (code) => process.exit(code ?? 1));
|
|
20
|
+
}
|
|
@@ -12,6 +12,7 @@ import { homedir } from 'os';
|
|
|
12
12
|
import { fileURLToPath } from 'url';
|
|
13
13
|
import { readPrimaryHead, resolveGitCommonDir, workTreeTop } from './git-paths.js';
|
|
14
14
|
import { findFirstOnPath, quoteForCmd } from './morph-cli.js';
|
|
15
|
+
import { backgroundArgs } from './background-spawn.js';
|
|
15
16
|
import { ensureCommonExclude } from './git-exclude.js';
|
|
16
17
|
import { pidAlive } from './process-alive.js';
|
|
17
18
|
import {
|
|
@@ -274,7 +275,7 @@ export function kickGraphRefresh(dir, { spawnFn = spawn, env = process.env } = {
|
|
|
274
275
|
return { kicked: false, reason: 'graphify-unavailable' };
|
|
275
276
|
}
|
|
276
277
|
try {
|
|
277
|
-
const child = spawnFn(process.execPath, [WORKER, '--run', root], {
|
|
278
|
+
const child = spawnFn(process.execPath, backgroundArgs([WORKER, '--run', root]), {
|
|
278
279
|
cwd: resolveGitCommonDir(root), env, detached: true, stdio: 'ignore', windowsHide: true,
|
|
279
280
|
});
|
|
280
281
|
child.on?.('error', () => {});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { availableParallelism } from 'node:os';
|
|
2
3
|
import { join } from 'node:path';
|
|
3
4
|
|
|
4
5
|
const CONFIG_FILE = join('.morph', 'config', 'config.json');
|
|
@@ -22,6 +23,14 @@ export const HEAVY_DEFAULTS = Object.freeze({
|
|
|
22
23
|
|
|
23
24
|
export const HEAVY_KINDS = Object.freeze(['unit', 'build', 'e2e']);
|
|
24
25
|
|
|
26
|
+
export const CPU_SHARE_ENVS = Object.freeze(['DOTNET_PROCESSOR_COUNT', 'VITEST_MAX_FORKS', 'VITEST_MAX_THREADS']);
|
|
27
|
+
|
|
28
|
+
/** A quarter of the machine stays with the person; what is left goes to one run, or is split by the slots. */
|
|
29
|
+
export function cpuShareOf(slots, { cores = availableParallelism(), alone = false } = {}) {
|
|
30
|
+
const usable = cores - Math.max(2, Math.floor(cores / 4));
|
|
31
|
+
return Math.max(2, alone ? usable : Math.floor(usable / slots));
|
|
32
|
+
}
|
|
33
|
+
|
|
25
34
|
const isBool = (v) => typeof v === 'boolean';
|
|
26
35
|
const isGb = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0;
|
|
27
36
|
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
@@ -299,7 +299,7 @@
|
|
|
299
299
|
"yieldToUser": {
|
|
300
300
|
"type": "boolean",
|
|
301
301
|
"default": true,
|
|
302
|
-
"description": "Heavy runs leave room for what the person is using:
|
|
302
|
+
"description": "Heavy runs leave room for what the person is using: a quarter of the cores stays out, a run alone takes the rest and a run with company takes rest / slots, through DOTNET_PROCESSOR_COUNT, VITEST_MAX_FORKS, VITEST_MAX_THREADS and --maxWorkers. `init` and `update` write the shared value to `env` in .claude/settings.local.json for what runs outside the queue. Priority is left alone on purpose: below normal, a process starves on a busy machine and fails by its own timeouts."
|
|
303
303
|
},
|
|
304
304
|
"criticalFreeMemGb": {
|
|
305
305
|
"type": "number",
|
|
@@ -4,7 +4,7 @@ description: Guia a criação de uma landing page nova ponta-a-ponta a partir do
|
|
|
4
4
|
version: core
|
|
5
5
|
when_to_use: Use quando for iniciar um novo projeto de LP com o template Umbraco genérico
|
|
6
6
|
user-invocable: true
|
|
7
|
-
cliVersion: "10.5.
|
|
7
|
+
cliVersion: "10.5.3"
|
|
8
8
|
stacks:
|
|
9
9
|
- umbraco
|
|
10
10
|
---
|
|
@@ -4,7 +4,7 @@ description: Migra uma landing page existente (React/Vite, Next.js estático ou
|
|
|
4
4
|
version: core
|
|
5
5
|
when_to_use: Use quando o usuário pedir "use /migrate-lp-to-umbraco {projeto}" ou descrever a necessidade de migrar uma LP para Umbraco
|
|
6
6
|
user-invocable: true
|
|
7
|
-
cliVersion: "10.5.
|
|
7
|
+
cliVersion: "10.5.3"
|
|
8
8
|
stacks:
|
|
9
9
|
- umbraco
|
|
10
10
|
---
|
|
@@ -4,7 +4,7 @@ description: Implementa o plano aprovado de uma feature morph-spec inteiro nesta
|
|
|
4
4
|
version: core
|
|
5
5
|
when_to_use: Use com o gate do plano aprovado ("/morph-apply {feature}", "implementar feature"), em "/morph-review {feature}" (entra no §5), em "/morph-hotfix {feature}" ou incidente de produção (references/hotfix.md), ou quando `morph-spec next` mandar implement, verify, fix, review ou finish
|
|
6
6
|
user-invocable: true
|
|
7
|
-
cliVersion: "10.5.
|
|
7
|
+
cliVersion: "10.5.3"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-apply
|
|
@@ -4,7 +4,7 @@ description: Leva um pedido ou o link de uma issue ao plano aprovado — roteia
|
|
|
4
4
|
version: core
|
|
5
5
|
when_to_use: Use quando uma feature, bug ou chore começar ("/morph-plan {feature}", "/morph-plan <link da issue>", "vamos planejar X"), ou ao retomar uma feature cujo gate plan ainda não foi aprovado
|
|
6
6
|
user-invocable: true
|
|
7
|
-
cliVersion: "10.5.
|
|
7
|
+
cliVersion: "10.5.3"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-plan
|
|
@@ -4,7 +4,7 @@ description: Conduz a fase de UI/UX de uma feature com plano aprovado — render
|
|
|
4
4
|
version: core
|
|
5
5
|
when_to_use: Use quando o ramo de design for tomado (morph-spec advance {feature} --design), antes de /morph-apply
|
|
6
6
|
user-invocable: true
|
|
7
|
-
cliVersion: "10.5.
|
|
7
|
+
cliVersion: "10.5.3"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-uiux
|
package/package.json
CHANGED
package/src/lib/heavy/gate.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { minFreeMemFor, readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js';
|
|
1
|
+
import { freemem } from 'node:os';
|
|
2
|
+
import { CPU_SHARE_ENVS, cpuShareOf, minFreeMemFor, readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js';
|
|
3
3
|
import { heavyClass } from '../../../framework/hooks/shared/heavy-command.js';
|
|
4
|
-
import { GB } from '../../../framework/hooks/shared/heavy-holders.js';
|
|
4
|
+
import { GB, readHolders } from '../../../framework/hooks/shared/heavy-holders.js';
|
|
5
|
+
import { pidAlive } from '../../../framework/hooks/shared/process-alive.js';
|
|
5
6
|
import {
|
|
6
7
|
acquireHeavy, awaitFreeMemory, describeBusy, enterVerifySuite, heavyDir, isVerifySuite, releaseHeavy, updateLease,
|
|
7
8
|
} from '../../../framework/hooks/shared/heavy-slots.js';
|
|
@@ -27,9 +28,6 @@ export class HeavyBusyError extends Error {
|
|
|
27
28
|
|
|
28
29
|
const NODE_REUSE_ENV = 'MSBUILDDISABLENODEREUSE';
|
|
29
30
|
const LEASE_ENV = 'MORPH_HEAVY_LEASE';
|
|
30
|
-
const CPU_ENV = 'DOTNET_PROCESSOR_COUNT';
|
|
31
|
-
|
|
32
|
-
export const cpuShareOf = (slots, cores = availableParallelism()) => Math.max(2, Math.floor(cores / slots));
|
|
33
31
|
|
|
34
32
|
export function createHeavyGate({
|
|
35
33
|
projectPath, label = 'verify', feature = null, config = readHeavyConfig(projectPath),
|
|
@@ -43,8 +41,18 @@ export function createHeavyGate({
|
|
|
43
41
|
let exitHandler = null;
|
|
44
42
|
let saved = null;
|
|
45
43
|
let reclaimed = false;
|
|
44
|
+
let cpuShare = null;
|
|
46
45
|
const politeRun = config.enabled && config.yieldToUser;
|
|
47
46
|
|
|
47
|
+
// Judged at each node: a run alone takes nearly the machine, and steps back once another one is in.
|
|
48
|
+
const applyCpuShare = () => {
|
|
49
|
+
if (!politeRun) return;
|
|
50
|
+
const { holders = readHolders } = deps;
|
|
51
|
+
const live = holders(heavyDir(env), { now: Date.now, isAlive: pidAlive, maxHoldMs: config.maxHoldMinutes * 60_000 });
|
|
52
|
+
cpuShare = cpuShareOf(config.slots, { alone: !live.some((h) => h.pid !== process.pid) });
|
|
53
|
+
for (const name of CPU_SHARE_ENVS) env[name] = String(cpuShare);
|
|
54
|
+
};
|
|
55
|
+
|
|
48
56
|
const reclaimBelow = (floor) => {
|
|
49
57
|
const { freeMem = freemem, reclaim = reclaimUnlessDisabled } = deps;
|
|
50
58
|
if (reclaimed || !config.reclaimIdleMinutes || freeMem() / GB >= floor) return;
|
|
@@ -105,7 +113,7 @@ export function createHeavyGate({
|
|
|
105
113
|
return {
|
|
106
114
|
config,
|
|
107
115
|
get waitedMs() { return waitedMs; },
|
|
108
|
-
get cpuShare() { return
|
|
116
|
+
get cpuShare() { return cpuShare; },
|
|
109
117
|
|
|
110
118
|
enter(node, kind = heavyClass(node)) {
|
|
111
119
|
const suite = isVerifySuite({ label, node });
|
|
@@ -113,6 +121,7 @@ export function createHeavyGate({
|
|
|
113
121
|
if (suite && lease && !suiteEntered) awaitSuite();
|
|
114
122
|
raiseFloor(kind);
|
|
115
123
|
updateLease(lease, { node });
|
|
124
|
+
applyCpuShare();
|
|
116
125
|
return;
|
|
117
126
|
}
|
|
118
127
|
for (const warning of config.warnings ?? []) log(`heavy: ${warning}`);
|
|
@@ -143,9 +152,9 @@ export function createHeavyGate({
|
|
|
143
152
|
waitedMs = result.waitedMs;
|
|
144
153
|
}
|
|
145
154
|
|
|
146
|
-
saved =
|
|
155
|
+
saved = Object.fromEntries([NODE_REUSE_ENV, LEASE_ENV, ...CPU_SHARE_ENVS].map((name) => [name, env[name]]));
|
|
147
156
|
if (config.disableNodeReuse) env[NODE_REUSE_ENV] = '1';
|
|
148
|
-
|
|
157
|
+
applyCpuShare();
|
|
149
158
|
if (lease) env[LEASE_ENV] = String(process.pid);
|
|
150
159
|
entered = true;
|
|
151
160
|
if (lease) {
|
package/src/lib/heavy/reclaim.js
CHANGED
|
@@ -90,7 +90,7 @@ export function reclaimMemory({ projectPath, feature = null, config, env = proce
|
|
|
90
90
|
if (!config.reclaimIdleMinutes) return { freed: [], containers: [] };
|
|
91
91
|
|
|
92
92
|
const cachedExec = onceByArgs(exec);
|
|
93
|
-
const live = holders(config, env, now
|
|
93
|
+
const live = holders(config, env, now);
|
|
94
94
|
const held = new Set(live.map((h) => h.feature).filter(Boolean));
|
|
95
95
|
const idleMs = config.reclaimIdleMinutes * 60_000;
|
|
96
96
|
|
|
@@ -3,6 +3,7 @@ import { createHash } from 'node:crypto';
|
|
|
3
3
|
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
6
|
+
import { backgroundArgs } from '../../../framework/hooks/shared/background-spawn.js';
|
|
6
7
|
import { heavyDir } from '../../../framework/hooks/shared/heavy-holders.js';
|
|
7
8
|
import { pidAlive } from '../../../framework/hooks/shared/process-alive.js';
|
|
8
9
|
import { treeFingerprint } from '../git/tree-fingerprint.js';
|
|
@@ -71,7 +72,8 @@ function spawnStage(stage, { argv, cwd, env, paths, script, spawnFn = spawn }) {
|
|
|
71
72
|
const out = openSync(paths.out, 'a');
|
|
72
73
|
const err = openSync(paths.err, 'a');
|
|
73
74
|
try {
|
|
74
|
-
const
|
|
75
|
+
const args = [script, ...argv];
|
|
76
|
+
const child = spawnFn(process.execPath, stage === 'launch' ? args : backgroundArgs(args), {
|
|
75
77
|
cwd,
|
|
76
78
|
detached: true,
|
|
77
79
|
windowsHide: true,
|
|
@@ -3,6 +3,7 @@ import { readFile } from 'fs/promises';
|
|
|
3
3
|
import { existsSync, readdirSync } from 'fs';
|
|
4
4
|
import { homedir } from 'os';
|
|
5
5
|
import { realEffects } from '../lib/installers/effects.js';
|
|
6
|
+
import { CPU_SHARE_ENVS, cpuShareOf, readHeavyConfig } from '../../framework/hooks/shared/heavy-config.js';
|
|
6
7
|
|
|
7
8
|
const HOOKS_VERSION = '3.17.0';
|
|
8
9
|
|
|
@@ -275,6 +276,13 @@ export async function installClaudeHooks(targetPath, { fx = realEffects() } = {}
|
|
|
275
276
|
settings.plansDirectory = '.morph/plans';
|
|
276
277
|
}
|
|
277
278
|
|
|
279
|
+
// What runs outside the queue (a loose `dotnet test`) keeps to a run's share too; a value already here stays.
|
|
280
|
+
const heavy = readHeavyConfig(targetPath);
|
|
281
|
+
if (heavy.yieldToUser) {
|
|
282
|
+
const share = String(cpuShareOf(heavy.slots));
|
|
283
|
+
settings.env = { ...Object.fromEntries(CPU_SHARE_ENVS.map((name) => [name, share])), ...settings.env };
|
|
284
|
+
}
|
|
285
|
+
|
|
278
286
|
settings._morph_managed = settings._morph_managed || {};
|
|
279
287
|
settings._morph_managed.hooks_version = HOOKS_VERSION;
|
|
280
288
|
settings._morph_managed.hooks_installed = [...new Set(MORPH_HOOKS.map(h => h.event))];
|