@polymorphism-tech/morph-spec 10.5.2 → 10.5.4

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 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.2
6
+ **Version:** 10.5.4
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.2 by [Polymorphism Tech](https://polymorphism.tech)*
379
+ *morph-spec v10.5.4 by [Polymorphism Tech](https://polymorphism.tech)*
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "morph-spec",
3
- "version": "10.5.2",
3
+ "version": "10.5.4",
4
4
  "displayName": "MORPH-SPEC Framework",
5
5
  "description": "Spec-driven development with 38 agents and 8-phase workflow for .NET/Blazor/Next.js/Azure",
6
6
  "publisher": "polymorphism-tech",
@@ -174,4 +174,4 @@ Never edited directly — go through CLI commands or `morph-spec update` instead
174
174
 
175
175
  ---
176
176
 
177
- *morph-spec v10.5.2 by Polymorphism Tech*
177
+ *morph-spec v10.5.4 by Polymorphism Tech*
@@ -89,4 +89,4 @@ morph-spec doctor
89
89
 
90
90
  ---
91
91
 
92
- *morph-spec v10.5.2 by Polymorphism Tech*
92
+ *morph-spec v10.5.4 by Polymorphism Tech*
@@ -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 só a sua parte dos núcleos (`núcleos / heavy.slots`, mínimo 2) por `DOTNET_PROCESSOR_COUNT` (build e testes) e `--maxWorkers` no Vitest. 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. O preço é rodada um pouco mais lenta. `DOTNET_PROCESSOR_COUNT` já definido no ambiente vence. O hook `heavy-gate` toma vaga, mas não muda o comando do Bash: lá isso não se aplica.
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: each run keeps to its share of the cores (cores / slots) through DOTNET_PROCESSOR_COUNT and vitest --maxWorkers. Priority is left alone on purpose: below normal, a process starves on a busy machine and fails by its own timeouts."
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.2"
7
+ cliVersion: "10.5.4"
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.2"
7
+ cliVersion: "10.5.4"
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.2"
7
+ cliVersion: "10.5.4"
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.2"
7
+ cliVersion: "10.5.4"
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.2"
7
+ cliVersion: "10.5.4"
8
8
  ---
9
9
 
10
10
  # morph-uiux
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polymorphism-tech/morph-spec",
3
- "version": "10.5.2",
3
+ "version": "10.5.4",
4
4
  "description": "MORPH-SPEC: NLH (Natural Language Harness) for spec-driven development with Claude Code",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -1,7 +1,8 @@
1
- import { availableParallelism, freemem } from 'node:os';
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 politeRun ? cpuShareOf(config.slots) : null; },
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 = { [NODE_REUSE_ENV]: env[NODE_REUSE_ENV], [LEASE_ENV]: env[LEASE_ENV], [CPU_ENV]: env[CPU_ENV] };
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
- if (politeRun) env[CPU_ENV] ??= String(cpuShareOf(config.slots));
157
+ applyCpuShare();
149
158
  if (lease) env[LEASE_ENV] = String(process.pid);
150
159
  entered = true;
151
160
  if (lease) {
@@ -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 child = spawnFn(process.execPath, [script, ...argv], {
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))];
@@ -303,13 +311,14 @@ export async function installGlobalStatusline(hooksSourceDir, globalClaudeDirOve
303
311
  const statuslineCommand = `node "${join(globalDir, 'statusline.js').replace(/\\/g, '/')}"`;
304
312
 
305
313
  // Without refreshInterval the status line only re-renders on events, freezing
306
- // the rate-limit countdown through a long turn. Shallow merge: only `statusLine` is ours.
314
+ // the rate-limit countdown through a long turn. It shows minutes, and each tick of each open
315
+ // session costs a bash, a node and git: 60 s is the floor that buys anything. Shallow merge: only `statusLine` is ours.
307
316
  fx.mergeJson(settingsPath, {
308
317
  statusLine: {
309
318
  type: 'command',
310
319
  command: statuslineCommand,
311
320
  padding: 2,
312
- refreshInterval: 10
321
+ refreshInterval: 60
313
322
  }
314
323
  });
315
324
  return { installed: true, globalDir };