@cleocode/core 2026.10.2 → 2026.10.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.
Files changed (228) hide show
  1. package/dist/build-provenance.json +2 -2
  2. package/dist/cloud/http.d.ts +2 -0
  3. package/dist/cloud/http.d.ts.map +1 -1
  4. package/dist/cloud/http.js +3 -0
  5. package/dist/cloud/http.js.map +1 -1
  6. package/dist/cloud/manifest-check.d.ts.map +1 -1
  7. package/dist/cloud/manifest-check.js +13 -0
  8. package/dist/cloud/manifest-check.js.map +1 -1
  9. package/dist/cloud/nexus-enrol.d.ts +9 -0
  10. package/dist/cloud/nexus-enrol.d.ts.map +1 -1
  11. package/dist/cloud/nexus-enrol.js +45 -2
  12. package/dist/cloud/nexus-enrol.js.map +1 -1
  13. package/dist/cloud/nexus-first-run.d.ts +142 -0
  14. package/dist/cloud/nexus-first-run.d.ts.map +1 -0
  15. package/dist/cloud/nexus-first-run.js +352 -0
  16. package/dist/cloud/nexus-first-run.js.map +1 -0
  17. package/dist/cloud/nexus-project-names.d.ts +173 -0
  18. package/dist/cloud/nexus-project-names.d.ts.map +1 -0
  19. package/dist/cloud/nexus-project-names.js +314 -0
  20. package/dist/cloud/nexus-project-names.js.map +1 -0
  21. package/dist/cloud/nexus-vault-keys.d.ts +35 -3
  22. package/dist/cloud/nexus-vault-keys.d.ts.map +1 -1
  23. package/dist/cloud/nexus-vault-keys.js +212 -22
  24. package/dist/cloud/nexus-vault-keys.js.map +1 -1
  25. package/dist/cloud/nexus-vault.d.ts.map +1 -1
  26. package/dist/cloud/nexus-vault.js +14 -2
  27. package/dist/cloud/nexus-vault.js.map +1 -1
  28. package/dist/dispatch/contracts/derive-output-contract.d.ts +1 -1
  29. package/dist/dispatch/contracts/derive-output-contract.d.ts.map +1 -1
  30. package/dist/dispatch/contracts/derive-output-contract.js +2 -1
  31. package/dist/dispatch/contracts/derive-output-contract.js.map +1 -1
  32. package/dist/dispatch/contracts/output-contracts.d.ts +2 -1
  33. package/dist/dispatch/contracts/output-contracts.d.ts.map +1 -1
  34. package/dist/dispatch/contracts/output-contracts.js +1 -1
  35. package/dist/dispatch/contracts/output-contracts.js.map +1 -1
  36. package/dist/dispatch/mvi-projection.d.ts +1 -1
  37. package/dist/dispatch/mvi-projection.d.ts.map +1 -1
  38. package/dist/dispatch/mvi-projection.js +1 -1
  39. package/dist/dispatch/mvi-projection.js.map +1 -1
  40. package/dist/docs/export-document.js +24462 -24572
  41. package/dist/docs/export-document.js.map +3 -3
  42. package/dist/doctor/db-substrate.d.ts +6 -9
  43. package/dist/doctor/db-substrate.d.ts.map +1 -1
  44. package/dist/doctor/db-substrate.js +10 -14
  45. package/dist/doctor/db-substrate.js.map +1 -1
  46. package/dist/doctor/migrations.d.ts +18 -0
  47. package/dist/doctor/migrations.d.ts.map +1 -1
  48. package/dist/doctor/migrations.js +54 -2
  49. package/dist/doctor/migrations.js.map +1 -1
  50. package/dist/error-catalog.js +1 -1
  51. package/dist/error-catalog.js.map +1 -1
  52. package/dist/errors.d.ts +1 -1
  53. package/dist/errors.d.ts.map +1 -1
  54. package/dist/errors.js +1 -1
  55. package/dist/errors.js.map +1 -1
  56. package/dist/init.d.ts.map +1 -1
  57. package/dist/init.js +21 -5
  58. package/dist/init.js.map +1 -1
  59. package/dist/llm/conversation.d.ts.map +1 -1
  60. package/dist/llm/conversation.js +15 -4
  61. package/dist/llm/conversation.js.map +1 -1
  62. package/dist/llm/plugin-facade.js +277 -376
  63. package/dist/llm/plugin-facade.js.map +3 -3
  64. package/dist/llm/transports/bedrock.d.ts +1 -0
  65. package/dist/llm/transports/bedrock.d.ts.map +1 -1
  66. package/dist/llm/transports/bedrock.js +30 -12
  67. package/dist/llm/transports/bedrock.js.map +1 -1
  68. package/dist/memory/decision-contradiction.d.ts.map +1 -1
  69. package/dist/memory/decision-contradiction.js +10 -5
  70. package/dist/memory/decision-contradiction.js.map +1 -1
  71. package/dist/memory/dialectic-evaluator.d.ts.map +1 -1
  72. package/dist/memory/dialectic-evaluator.js +4 -1
  73. package/dist/memory/dialectic-evaluator.js.map +1 -1
  74. package/dist/memory/transcript-extractor.d.ts.map +1 -1
  75. package/dist/memory/transcript-extractor.js +3 -2
  76. package/dist/memory/transcript-extractor.js.map +1 -1
  77. package/dist/metrics/token-service.d.ts +16 -0
  78. package/dist/metrics/token-service.d.ts.map +1 -1
  79. package/dist/metrics/token-service.js +18 -0
  80. package/dist/metrics/token-service.js.map +1 -1
  81. package/dist/nexus/api-contracts.d.ts.map +1 -1
  82. package/dist/nexus/api-contracts.js +27 -11
  83. package/dist/nexus/api-contracts.js.map +1 -1
  84. package/dist/nexus/api-extractors/http-extractor.d.ts +2 -2
  85. package/dist/nexus/api-extractors/http-extractor.d.ts.map +1 -1
  86. package/dist/nexus/api-extractors/http-extractor.js +3 -3
  87. package/dist/nexus/api-extractors/http-extractor.js.map +1 -1
  88. package/dist/nexus/clusters.js +1 -1
  89. package/dist/nexus/clusters.js.map +1 -1
  90. package/dist/nexus/diff.d.ts +1 -1
  91. package/dist/nexus/diff.d.ts.map +1 -1
  92. package/dist/nexus/diff.js +3 -2
  93. package/dist/nexus/diff.js.map +1 -1
  94. package/dist/nexus/flows.js +1 -1
  95. package/dist/nexus/flows.js.map +1 -1
  96. package/dist/nexus/identity.d.ts +8 -0
  97. package/dist/nexus/identity.d.ts.map +1 -1
  98. package/dist/nexus/identity.js +17 -1
  99. package/dist/nexus/identity.js.map +1 -1
  100. package/dist/nexus/index.d.ts +1 -1
  101. package/dist/nexus/index.d.ts.map +1 -1
  102. package/dist/nexus/index.js +2 -2
  103. package/dist/nexus/index.js.map +1 -1
  104. package/dist/nexus/nexus-bridge.d.ts.map +1 -1
  105. package/dist/nexus/nexus-bridge.js +5 -5
  106. package/dist/nexus/nexus-bridge.js.map +1 -1
  107. package/dist/nexus/registry.d.ts +22 -0
  108. package/dist/nexus/registry.d.ts.map +1 -1
  109. package/dist/nexus/registry.js +76 -1
  110. package/dist/nexus/registry.js.map +1 -1
  111. package/dist/paths.js +1 -1
  112. package/dist/paths.js.map +1 -1
  113. package/dist/project-scope.js +1 -1
  114. package/dist/project-scope.js.map +1 -1
  115. package/dist/project-tombstone.js +1 -1
  116. package/dist/project-tombstone.js.map +1 -1
  117. package/dist/release/open.d.ts +3 -6
  118. package/dist/release/open.d.ts.map +1 -1
  119. package/dist/release/open.js +59 -23
  120. package/dist/release/open.js.map +1 -1
  121. package/dist/release/preflight-skip.d.ts +69 -10
  122. package/dist/release/preflight-skip.d.ts.map +1 -1
  123. package/dist/release/preflight-skip.js +195 -47
  124. package/dist/release/preflight-skip.js.map +1 -1
  125. package/dist/render/colors.js +1 -19
  126. package/dist/render/colors.js.map +1 -1
  127. package/dist/render/tasks/colors.js +1 -1
  128. package/dist/render/tasks/colors.js.map +1 -1
  129. package/dist/resources/governor.d.ts +12 -0
  130. package/dist/resources/governor.d.ts.map +1 -1
  131. package/dist/resources/governor.js +5 -1
  132. package/dist/resources/governor.js.map +1 -1
  133. package/dist/resources/heavy-command-hook-delivery.d.ts +144 -0
  134. package/dist/resources/heavy-command-hook-delivery.d.ts.map +1 -0
  135. package/dist/resources/heavy-command-hook-delivery.js +204 -0
  136. package/dist/resources/heavy-command-hook-delivery.js.map +1 -0
  137. package/dist/resources/heavy-command.d.ts +69 -0
  138. package/dist/resources/heavy-command.d.ts.map +1 -1
  139. package/dist/resources/heavy-command.js +479 -7
  140. package/dist/resources/heavy-command.js.map +1 -1
  141. package/dist/resources/run-class.d.ts.map +1 -1
  142. package/dist/resources/run-class.js +1 -0
  143. package/dist/resources/run-class.js.map +1 -1
  144. package/dist/routing/build-command-groups.d.ts +1 -1
  145. package/dist/routing/build-command-groups.d.ts.map +1 -1
  146. package/dist/routing/build-command-groups.js +4 -1
  147. package/dist/routing/build-command-groups.js.map +1 -1
  148. package/dist/scaffold/ensure-skills.d.ts.map +1 -1
  149. package/dist/scaffold/ensure-skills.js +58 -2
  150. package/dist/scaffold/ensure-skills.js.map +1 -1
  151. package/dist/selfimprove/fix-gen.js +277 -376
  152. package/dist/selfimprove/fix-gen.js.map +3 -3
  153. package/dist/sessions/briefing.d.ts.map +1 -1
  154. package/dist/sessions/briefing.js +11 -0
  155. package/dist/sessions/briefing.js.map +1 -1
  156. package/dist/store/dual-scope-db.d.ts.map +1 -1
  157. package/dist/store/dual-scope-db.js +6 -1
  158. package/dist/store/dual-scope-db.js.map +1 -1
  159. package/dist/store/portable-bundle-scan.d.ts +22 -0
  160. package/dist/store/portable-bundle-scan.d.ts.map +1 -1
  161. package/dist/store/portable-bundle-scan.js +97 -25
  162. package/dist/store/portable-bundle-scan.js.map +1 -1
  163. package/dist/store/portable-bundle.d.ts +7 -0
  164. package/dist/store/portable-bundle.d.ts.map +1 -1
  165. package/dist/store/portable-bundle.js +10 -1
  166. package/dist/store/portable-bundle.js.map +1 -1
  167. package/dist/store/table-classification.d.ts +27 -0
  168. package/dist/store/table-classification.d.ts.map +1 -1
  169. package/dist/store/table-classification.js +32 -0
  170. package/dist/store/table-classification.js.map +1 -1
  171. package/dist/store/vault-manifest.d.ts +5 -1
  172. package/dist/store/vault-manifest.d.ts.map +1 -1
  173. package/dist/store/vault-manifest.js +9 -2
  174. package/dist/store/vault-manifest.js.map +1 -1
  175. package/dist/system/health.d.ts +13 -0
  176. package/dist/system/health.d.ts.map +1 -1
  177. package/dist/system/health.js +96 -1
  178. package/dist/system/health.js.map +1 -1
  179. package/dist/tasks/affected-packages.d.ts +20 -7
  180. package/dist/tasks/affected-packages.d.ts.map +1 -1
  181. package/dist/tasks/affected-packages.js +39 -13
  182. package/dist/tasks/affected-packages.js.map +1 -1
  183. package/dist/tasks/affected-template.d.ts +93 -0
  184. package/dist/tasks/affected-template.d.ts.map +1 -0
  185. package/dist/tasks/affected-template.js +198 -0
  186. package/dist/tasks/affected-template.js.map +1 -0
  187. package/dist/tasks/command-line.d.ts +4 -1
  188. package/dist/tasks/command-line.d.ts.map +1 -1
  189. package/dist/tasks/command-line.js +7 -2
  190. package/dist/tasks/command-line.js.map +1 -1
  191. package/dist/tasks/done-plan.d.ts.map +1 -1
  192. package/dist/tasks/done-plan.js +12 -2
  193. package/dist/tasks/done-plan.js.map +1 -1
  194. package/dist/tasks/duplicate-detector.d.ts.map +1 -1
  195. package/dist/tasks/duplicate-detector.js +11 -5
  196. package/dist/tasks/duplicate-detector.js.map +1 -1
  197. package/dist/tasks/evidence.d.ts.map +1 -1
  198. package/dist/tasks/evidence.js +185 -11
  199. package/dist/tasks/evidence.js.map +1 -1
  200. package/dist/tasks/heavy-tool-env.d.ts +385 -18
  201. package/dist/tasks/heavy-tool-env.d.ts.map +1 -1
  202. package/dist/tasks/heavy-tool-env.js +719 -40
  203. package/dist/tasks/heavy-tool-env.js.map +1 -1
  204. package/dist/tasks/index.d.ts +2 -2
  205. package/dist/tasks/index.d.ts.map +1 -1
  206. package/dist/tasks/index.js +1 -1
  207. package/dist/tasks/index.js.map +1 -1
  208. package/dist/tasks/tool-cache-env.d.ts +20 -13
  209. package/dist/tasks/tool-cache-env.d.ts.map +1 -1
  210. package/dist/tasks/tool-cache-env.js +27 -48
  211. package/dist/tasks/tool-cache-env.js.map +1 -1
  212. package/dist/tasks/tool-cache.d.ts +8 -0
  213. package/dist/tasks/tool-cache.d.ts.map +1 -1
  214. package/dist/tasks/tool-cache.js +19 -6
  215. package/dist/tasks/tool-cache.js.map +1 -1
  216. package/dist/tasks/tool-semaphore.d.ts +66 -23
  217. package/dist/tasks/tool-semaphore.d.ts.map +1 -1
  218. package/dist/tasks/tool-semaphore.js +96 -34
  219. package/dist/tasks/tool-semaphore.js.map +1 -1
  220. package/dist/upgrade.d.ts.map +1 -1
  221. package/dist/upgrade.js +37 -4
  222. package/dist/upgrade.js.map +1 -1
  223. package/dist/validation/doctor/checks.d.ts +17 -0
  224. package/dist/validation/doctor/checks.d.ts.map +1 -1
  225. package/dist/validation/doctor/checks.js +83 -0
  226. package/dist/validation/doctor/checks.js.map +1 -1
  227. package/package.json +13 -13
  228. package/schemas/project-context.schema.json +5 -1
@@ -28,18 +28,49 @@
28
28
  * |---|---|---|
29
29
  * | `NODE_OPTIONS=--max-old-space-size=N` | Heap ceiling for EVERY node process in the tree, inherited | node docs; appended, never clobbered |
30
30
  * | `VITEST_MAX_WORKERS=N` | `if (process.env.VITEST_MAX_WORKERS) resolved.maxWorkers = parseInt(...)` — overrides the resolved config, so it binds projects that set their own | read out of vitest 4.1.4 `dist/chunks/coverage.*.js` |
31
- * | `npm_config_workspace_concurrency=N` | Bounds `pnpm -r` fan-out across workspace packages | `npm_config_workspace_concurrency=1 pnpm config get workspace-concurrency` → `1` |
31
+ * | `npm_config_workspace_concurrency=N` / `pnpm_config_workspace_concurrency=N` | Bounds `pnpm -r` fan-out across workspace packages (pnpm 10 reads the first, pnpm 11+ the second) | `npm_config_workspace_concurrency=1 pnpm config get workspace-concurrency` → `1` on pnpm 10.30; `pnpm_config_…` → `1` on pnpm 12.6 (T13122) |
32
32
  *
33
33
  * Together these bound the product the semaphore could not see:
34
34
  * `packages in flight × workers per run × heap per worker`.
35
35
  *
36
- * Applied ONLY to `test` / `build`. Capping `lint` or `typecheck` would
37
- * serialise cheap single-process work for no benefit.
36
+ * The worker levers apply ONLY to `test` / `build`, the tools that fork.
37
+ * `typecheck` and `lint` are single processes, but not cheap ones: one
38
+ * TypeScript program on a large monorepo holds 2–5 GB (a live `tsc --noEmit`
39
+ * held 4.7 GB on 2026-10-03), so since T13123 they get the heap ceiling and the
40
+ * workspace-concurrency bound too ({@link isMemoryBoundTool}), and no worker
41
+ * variables, which nothing they run reads.
38
42
  *
39
- * A project that genuinely wants more can set any of these itself — an existing
40
- * value is always respected, because a deliberate setting beats our default.
43
+ * ## An inherited value can tighten the plan, never loosen it (T13122)
44
+ *
45
+ * The overlay is merged over the CALLER's environment: whatever the shell
46
+ * profile, the agent harness or a parent process exported. Until T13122 an
47
+ * inherited value always won, on the theory that "a deliberate setting beats our
48
+ * default". A shell profile is not a per-project decision: on 2026-10-03 a
49
+ * `~/.zprofile` export of `NODE_OPTIONS=--max-old-space-size=8192` reached a
50
+ * `cleo verify --evidence tool:test`, and the run got 6 workers × 8 GiB = all of
51
+ * a 48 GiB Mac, because the worker count was sized for a 4 GiB heap it was not
52
+ * getting.
53
+ *
54
+ * So the run has a heap budget — the default worker count times the default heap
55
+ * ({@link heavyRunBudgetMb}) — and the overlay is planned against it
56
+ * ({@link planHeavyToolEnv}):
57
+ *
58
+ * - an inherited `NODE_OPTIONS` heap that fits the budget is kept, and the
59
+ * worker count shrinks so `workers × heap` still fits; one above the budget
60
+ * is clamped to it;
61
+ * - an inherited worker count or workspace concurrency at or below the plan
62
+ * is kept; one above it is clamped;
63
+ * - only an explicit CLEO override asks for more: `CLEO_HEAVY_HEAP_MB`,
64
+ * `CLEO_HEAVY_WORKERS`, `CLEO_HEAVY_WORKSPACE_CONCURRENCY`.
65
+ *
66
+ * A project that wants more for its own suite still sets it in its own script
67
+ * (`"test": "NODE_OPTIONS=… vitest run"`) or config: that runs below the
68
+ * overlay and is untouched by it. The plan, and every value it clamped, is
69
+ * reported by `cleo verify` and `cleo run`.
41
70
  *
42
71
  * @task T12096
72
+ * @task T13122
73
+ * @task T13123
43
74
  */
44
75
  import { totalmem } from 'node:os';
45
76
  /**
@@ -64,6 +95,87 @@ export const MIN_HEAVY_WORKERS = 1;
64
95
  * memory-bound box does not finish at all.
65
96
  */
66
97
  export const WORKSPACE_CONCURRENCY = 1;
98
+ /**
99
+ * The default heap never drops below this, in MiB, however small the machine —
100
+ * one worker must be able to run a real suite.
101
+ *
102
+ * @task T13122
103
+ */
104
+ export const MIN_HEAVY_HEAP_MB = 1024;
105
+ /**
106
+ * Fraction of total RAM the DEFAULT heap may reach. {@link HEAVY_TOOL_HEAP_MB}
107
+ * on an 8 GiB laptop is half the machine; on a 4 GiB box it would be all of it,
108
+ * which is no ceiling at all. Machines of 8 GiB and up are unaffected.
109
+ *
110
+ * @task T13122
111
+ */
112
+ export const HEAVY_HEAP_RAM_FRACTION = 0.5;
113
+ /**
114
+ * Explicit heap ceiling for heavy tools, in MiB. The only way to ask for a heap
115
+ * above the run's budget: unlike an inherited `NODE_OPTIONS`, it is never
116
+ * clamped.
117
+ *
118
+ * @task T13122
119
+ */
120
+ export const HEAVY_HEAP_ENV = 'CLEO_HEAVY_HEAP_MB';
121
+ /**
122
+ * Explicit worker count for heavy tools. Replaces the planned count for every
123
+ * runner variable, even when that puts the run over its budget.
124
+ *
125
+ * @task T13122
126
+ */
127
+ export const HEAVY_WORKERS_ENV = 'CLEO_HEAVY_WORKERS';
128
+ /**
129
+ * Explicit workspace concurrency for heavy tools (`pnpm -r` packages in flight).
130
+ *
131
+ * @task T13122
132
+ */
133
+ export const HEAVY_WORKSPACE_CONCURRENCY_ENV = 'CLEO_HEAVY_WORKSPACE_CONCURRENCY';
134
+ /**
135
+ * The two spellings of pnpm's `workspace-concurrency` in the environment. pnpm
136
+ * 10 reads only `npm_config_workspace_concurrency`; pnpm 11+ reads only
137
+ * `pnpm_config_workspace_concurrency` (measured: pnpm 10.30.0 and 12.6.0 each
138
+ * report `undefined` for the other spelling). Setting one bounded `pnpm -r`
139
+ * fan-out on one pnpm major and silently nothing on the other, so the overlay
140
+ * sets both — for every launcher, because an `npm test` script that runs
141
+ * `pnpm -r` still reads them. Both are read case-insensitively, and an
142
+ * uppercase spelling outranks the lowercase one, so an inherited
143
+ * `NPM_CONFIG_WORKSPACE_CONCURRENCY` is planned and overlaid as well
144
+ * ({@link workspaceConcurrencyNames}).
145
+ *
146
+ * @task T13122
147
+ */
148
+ export const WORKSPACE_CONCURRENCY_VARS = [
149
+ 'npm_config_workspace_concurrency',
150
+ 'pnpm_config_workspace_concurrency',
151
+ ];
152
+ /**
153
+ * Every environment name that sets pnpm's workspace concurrency in `env`: the
154
+ * two canonical spellings, plus any case or dash variant of them present (pnpm
155
+ * reads these case-insensitively, uppercase first, and `workspace-concurrency`
156
+ * with a dash as readily).
157
+ *
158
+ * @param env - The caller's environment.
159
+ * @returns The names to plan and overlay, canonical first.
160
+ * @task T13122
161
+ */
162
+ export function workspaceConcurrencyNames(env) {
163
+ const canonical = [...WORKSPACE_CONCURRENCY_VARS];
164
+ const wanted = new Set(canonical);
165
+ const variants = Object.keys(env).filter((name) => !wanted.has(name) && wanted.has(name.toLowerCase().replace(/-/g, '_')));
166
+ return [...canonical, ...variants.sort()];
167
+ }
168
+ /**
169
+ * V8 young-generation (semi-space) ceiling a plan allows, in MiB: Node 24's own
170
+ * default on a 64-bit host. `heap_size_limit` is old space plus three
171
+ * semi-spaces (measured on Node 24: `--max-old-space-size=4096` with
172
+ * `--max-semi-space-size=4096` reports 16384 MiB), so an inherited large
173
+ * semi-space would multiply the planned heap without touching the old-space
174
+ * flag; above this it is clamped.
175
+ *
176
+ * @task T13122
177
+ */
178
+ export const MAX_SEMI_SPACE_MB = 64;
67
179
  /**
68
180
  * Canonical tools treated as HEAVY — the ones that fork.
69
181
  *
@@ -97,6 +209,35 @@ const HEAVY_TOOLS = new Set(['test', 'build']);
97
209
  export function isHeavyTool(canonical) {
98
210
  return HEAVY_TOOLS.has(canonical);
99
211
  }
212
+ /**
213
+ * Canonical tools whose memory must be bounded: the heavy ones, plus the
214
+ * single-process tools that build a whole TypeScript program (T13123).
215
+ */
216
+ const MEMORY_BOUND_TOOLS = new Set(['test', 'build', 'typecheck', 'lint']);
217
+ /**
218
+ * Whether a canonical tool's memory is bounded: a heap ceiling and workspace
219
+ * concurrency in its spawn env, and a RAM-derived machine-wide slot count that
220
+ * shrinks under pressure.
221
+ *
222
+ * A superset of {@link isHeavyTool}. `typecheck` and `lint` were treated as
223
+ * cheap (`max(2, cpus/2)` slots, no ceiling) until T13123: nine concurrent
224
+ * `tsc` runs of 2–5 GB each on an 18-core box is most of 48 GB. `audit` and
225
+ * `security-scan` stay unbounded — they are network-bound and small.
226
+ *
227
+ * @param canonical - the tool in question.
228
+ * @returns `true` for `test`, `build`, `typecheck` and `lint`.
229
+ *
230
+ * @example
231
+ * ```ts
232
+ * isMemoryBoundTool('typecheck'); // true
233
+ * isMemoryBoundTool('audit'); // false
234
+ * ```
235
+ *
236
+ * @task T13123
237
+ */
238
+ export function isMemoryBoundTool(canonical) {
239
+ return MEMORY_BOUND_TOOLS.has(canonical);
240
+ }
100
241
  /**
101
242
  * Per-runner worker-count variables, keyed by the env var each runner reads.
102
243
  *
@@ -126,7 +267,11 @@ const WORKER_COUNT_VARS = [
126
267
  'PYTEST_XDIST_AUTO_NUM_WORKERS',
127
268
  ];
128
269
  /**
129
- * Worker count this machine can hold, given {@link GIB_PER_WORKER}.
270
+ * Default worker count this machine can hold, given {@link GIB_PER_WORKER}.
271
+ *
272
+ * The planned count ({@link planHeavyToolEnv}) never exceeds this: a small
273
+ * inherited heap does not buy extra workers, because each worker also costs
274
+ * memory outside its heap.
130
275
  *
131
276
  * @param totalRamGib - total RAM in GiB; defaults to a live reading.
132
277
  * @returns a value in `[MIN_HEAVY_WORKERS, MAX_HEAVY_WORKERS]`.
@@ -136,12 +281,340 @@ export function heavyToolWorkers(totalRamGib = totalmem() / 1024 ** 3) {
136
281
  return Math.min(MAX_HEAVY_WORKERS, Math.max(MIN_HEAVY_WORKERS, byRam));
137
282
  }
138
283
  /**
139
- * Append `--max-old-space-size` to an existing `NODE_OPTIONS`, or create it.
284
+ * Default heap ceiling per Node process, in MiB: {@link HEAVY_TOOL_HEAP_MB},
285
+ * lowered to {@link HEAVY_HEAP_RAM_FRACTION} of RAM on a machine under 8 GiB
286
+ * (never below {@link MIN_HEAVY_HEAP_MB}).
287
+ *
288
+ * @param totalRamGib - total RAM in GiB; defaults to a live reading.
289
+ * @returns the default heap in MiB.
290
+ *
291
+ * @example
292
+ * ```ts
293
+ * defaultHeavyHeapMb(64); // → 4096
294
+ * defaultHeavyHeapMb(4); // → 2048
295
+ * ```
296
+ *
297
+ * @task T13122
298
+ */
299
+ export function defaultHeavyHeapMb(totalRamGib = totalmem() / 1024 ** 3) {
300
+ const byRam = Math.floor(totalRamGib * 1024 * HEAVY_HEAP_RAM_FRACTION);
301
+ return Math.min(HEAVY_TOOL_HEAP_MB, Math.max(MIN_HEAVY_HEAP_MB, byRam));
302
+ }
303
+ /**
304
+ * Default heap ceiling for a single-process tool (`typecheck`, `lint`), in MiB:
305
+ * {@link defaultHeavyHeapMb}, but never above a quarter of RAM — V8's own
306
+ * default old space on a machine under 16 GiB (measured 4288 MiB with no flags
307
+ * on a 64 GiB host, the capped default) — so the plan never RAISES a `tsc`'s
308
+ * ceiling on a small machine (review of #1810). Never below
309
+ * {@link MIN_HEAVY_HEAP_MB}.
310
+ *
311
+ * @param totalRamGib - total RAM in GiB; defaults to a live reading.
312
+ * @returns the default heap in MiB.
313
+ *
314
+ * @example
315
+ * ```ts
316
+ * defaultSingleProcessHeapMb(64); // → 4096
317
+ * defaultSingleProcessHeapMb(8); // → 2048
318
+ * ```
319
+ *
320
+ * @task T13123
321
+ */
322
+ export function defaultSingleProcessHeapMb(totalRamGib = totalmem() / 1024 ** 3) {
323
+ const quarter = Math.max(MIN_HEAVY_HEAP_MB, Math.floor((totalRamGib * 1024) / 4));
324
+ return Math.min(defaultHeavyHeapMb(totalRamGib), quarter);
325
+ }
326
+ /**
327
+ * Heap budget for one heavy run, in MiB: the default worker count times the
328
+ * default heap. `workspace concurrency × workers × heap` must fit in it.
329
+ *
330
+ * Unchanged by T13122 for the default case (6 × 4096 = 24 GiB on 36 GiB and
331
+ * up, 1 × 4096 on 8 GiB); what changed is that an inherited heap is now planned
332
+ * against it instead of multiplying it.
333
+ *
334
+ * @param totalRamGib - total RAM in GiB; defaults to a live reading.
335
+ * @returns the budget in MiB.
336
+ *
337
+ * @example
338
+ * ```ts
339
+ * heavyRunBudgetMb(64); // → 24576 (6 workers × 4096)
340
+ * heavyRunBudgetMb(16); // → 8192 (2 workers × 4096)
341
+ * heavyRunBudgetMb(8); // → 4096 (1 worker × 4096)
342
+ * ```
343
+ *
344
+ * @task T13122
345
+ */
346
+ export function heavyRunBudgetMb(totalRamGib = totalmem() / 1024 ** 3) {
347
+ return heavyToolWorkers(totalRamGib) * defaultHeavyHeapMb(totalRamGib);
348
+ }
349
+ /**
350
+ * `NODE_OPTIONS` flags that set a V8 heap limit. `max-old-space-size` and its
351
+ * percentage form decide the old-space ceiling; `max-semi-space-size` sizes the
352
+ * young generation, three of which count toward the limit, and is capped at
353
+ * {@link MAX_SEMI_SPACE_MB}.
354
+ *
355
+ * @task T12989
356
+ * @task T13122
357
+ */
358
+ export const HEAP_FLAG_NAMES = new Set([
359
+ 'max-old-space-size',
360
+ 'max-old-space-size-percentage',
361
+ 'max-semi-space-size',
362
+ ]);
363
+ /** The two flags that set the old-space ceiling, which a plan replaces. */
364
+ const OLD_SPACE_FLAGS = new Set([
365
+ 'max-old-space-size',
366
+ 'max-old-space-size-percentage',
367
+ ]);
368
+ /** The young-generation flag, capped at {@link MAX_SEMI_SPACE_MB}. */
369
+ const SEMI_SPACE_FLAG = new Set(['max-semi-space-size']);
370
+ function spannedWords(text) {
371
+ return [...text.matchAll(/\S+/g)].map((m) => ({
372
+ text: m[0],
373
+ start: m.index,
374
+ end: m.index + m[0].length,
375
+ }));
376
+ }
377
+ /**
378
+ * Remove the heap flags named in `names` from a `NODE_OPTIONS` value, cutting
379
+ * their spans (and the whitespace before each) out of the original string, so
380
+ * every other byte — quoted paths with runs of spaces included — is kept.
381
+ */
382
+ function cutHeapFlags(text, names) {
383
+ const words = spannedWords(text);
384
+ const tokens = words.map((w) => w.text);
385
+ let out = '';
386
+ let cursor = 0;
387
+ for (let i = 0; i < words.length; i++) {
388
+ const flag = heapTokenAt(tokens, i);
389
+ if (flag === null || !names.has(flag.name))
390
+ continue;
391
+ const first = words[i];
392
+ const last = words[i + flag.width - 1];
393
+ if (first === undefined || last === undefined)
394
+ continue;
395
+ // Drop the whitespace before the flag along with it.
396
+ let from = first.start;
397
+ while (from > cursor && /\s/.test(text[from - 1] ?? ''))
398
+ from--;
399
+ out += text.slice(cursor, from);
400
+ cursor = last.end;
401
+ i += flag.width - 1;
402
+ }
403
+ return (out + text.slice(cursor)).trim();
404
+ }
405
+ function heapTokenAt(tokens, i) {
406
+ const token = tokens[i] ?? '';
407
+ if (!token.startsWith('--'))
408
+ return null;
409
+ const eq = token.indexOf('=');
410
+ const name = (eq === -1 ? token.slice(2) : token.slice(2, eq)).replace(/_/g, '-');
411
+ if (!HEAP_FLAG_NAMES.has(name))
412
+ return null;
413
+ const next = tokens[i + 1];
414
+ if (eq === -1 && next !== undefined && /^\d+(\.\d+)?$/.test(next)) {
415
+ return { name, value: next, width: 2 };
416
+ }
417
+ return { name, value: eq === -1 ? '' : token.slice(eq + 1), width: 1 };
418
+ }
419
+ /**
420
+ * The V8 heap flags a `NODE_OPTIONS` value sets, by name, with the value in
421
+ * effect.
422
+ *
423
+ * V8 reads flags left to right and a later value replaces an earlier one, so
424
+ * the LAST occurrence of each flag is the effective one. Underscores in a flag
425
+ * name read as dashes, as they do to V8, and the space-separated spelling
426
+ * (`--max-old-space-size 4096`) is read too.
427
+ *
428
+ * @param nodeOptions - A `NODE_OPTIONS` value, if any.
429
+ * @returns flag name → effective value, in first-seen order.
430
+ *
431
+ * @task T12989
432
+ * @task T13122
433
+ */
434
+ export function parseHeapFlags(nodeOptions) {
435
+ const tokens = (nodeOptions ?? '').trim().split(/\s+/).filter(Boolean);
436
+ const values = new Map();
437
+ for (let i = 0; i < tokens.length; i++) {
438
+ const flag = heapTokenAt(tokens, i);
439
+ if (flag === null)
440
+ continue;
441
+ values.set(flag.name, flag.value);
442
+ i += flag.width - 1;
443
+ }
444
+ return values;
445
+ }
446
+ /**
447
+ * The old-space heap ceiling a `NODE_OPTIONS` value asks for, in MiB, or
448
+ * `null` when it sets none (or sets one Node could not use).
449
+ *
450
+ * `--max-old-space-size-percentage` wins over `--max-old-space-size` whatever
451
+ * their order (measured on Node 24.21: `=10` beside `=2048` gave a 6745 MiB
452
+ * limit on a 64 GiB machine either way round), and resolves against total RAM.
453
+ *
454
+ * @param nodeOptions - A `NODE_OPTIONS` value, if any.
455
+ * @param totalRamMb - Total RAM in MiB, for the percentage form.
456
+ * @returns the requested heap in MiB, or `null`.
457
+ *
458
+ * @example
459
+ * ```ts
460
+ * inheritedHeapMb('--max-old-space-size=8192', 65536); // → 8192
461
+ * inheritedHeapMb('--max-old-space-size-percentage=25', 65536); // → 16384
462
+ * inheritedHeapMb('--enable-source-maps', 65536); // → null
463
+ * ```
464
+ *
465
+ * @task T13122
466
+ */
467
+ export function inheritedHeapMb(nodeOptions, totalRamMb) {
468
+ const flags = parseHeapFlags(nodeOptions);
469
+ const pct = Number(flags.get('max-old-space-size-percentage') ?? Number.NaN);
470
+ if (Number.isFinite(pct) && pct > 0 && pct <= 100) {
471
+ return Math.floor((totalRamMb * pct) / 100);
472
+ }
473
+ const mb = Number(flags.get('max-old-space-size') ?? Number.NaN);
474
+ return Number.isFinite(mb) && mb >= 1 ? Math.floor(mb) : null;
475
+ }
476
+ /**
477
+ * Set the old-space heap ceiling in a `NODE_OPTIONS` value: every existing
478
+ * `--max-old-space-size` and `--max-old-space-size-percentage` is cut out and
479
+ * one `--max-old-space-size=<heapMb>` appended. Every other byte survives — a
480
+ * project may rely on `--experimental-*` or a quoted `--require` path there.
140
481
  *
141
- * Never clobbers: a project may legitimately set `--experimental-*` flags there,
142
- * and dropping them would break the very command we are trying to run. An
143
- * existing `--max-old-space-size` is left alone — an explicit choice outranks
144
- * our default.
482
+ * Removing the percentage form is required, not tidiness: it outranks the size
483
+ * form whatever the order, so leaving it would silently undo the ceiling.
484
+ *
485
+ * @param existing - current `NODE_OPTIONS`, if any.
486
+ * @param heapMb - ceiling to apply.
487
+ * @returns the rewritten value.
488
+ *
489
+ * @example
490
+ * ```ts
491
+ * withHeapCeiling('--enable-source-maps --max-old-space-size=8192', 4096);
492
+ * // → '--enable-source-maps --max-old-space-size=4096'
493
+ * ```
494
+ *
495
+ * @task T13122
496
+ */
497
+ export function withHeapCeiling(existing, heapMb) {
498
+ const rest = cutHeapFlags(existing ?? '', OLD_SPACE_FLAGS);
499
+ const flag = `--max-old-space-size=${heapMb}`;
500
+ return rest.length > 0 ? `${rest} ${flag}` : flag;
501
+ }
502
+ /**
503
+ * Cap the semi-space (young generation) flag of a `NODE_OPTIONS` value at
504
+ * {@link MAX_SEMI_SPACE_MB}: a larger `--max-semi-space-size` is cut out and the
505
+ * cap appended. A value at or under the cap is left alone.
506
+ *
507
+ * @param existing - `NODE_OPTIONS`, if any.
508
+ * @returns The value to use, and the inherited semi-space it clamped (or `null`).
509
+ *
510
+ * @example
511
+ * ```ts
512
+ * withSemiSpaceCap('--max-semi-space-size=4096'); // → { value: '--max-semi-space-size=64', clampedFrom: 4096 }
513
+ * ```
514
+ *
515
+ * @task T13122
516
+ */
517
+ export function withSemiSpaceCap(existing) {
518
+ const raw = parseHeapFlags(existing).get('max-semi-space-size');
519
+ const semi = Number(raw ?? Number.NaN);
520
+ if (raw === undefined || (Number.isFinite(semi) && semi <= MAX_SEMI_SPACE_MB)) {
521
+ return { value: existing, clampedFrom: null };
522
+ }
523
+ const rest = cutHeapFlags(existing ?? '', SEMI_SPACE_FLAG);
524
+ const flag = `--max-semi-space-size=${MAX_SEMI_SPACE_MB}`;
525
+ return {
526
+ value: rest.length > 0 ? `${rest} ${flag}` : flag,
527
+ clampedFrom: Number.isFinite(semi) ? semi : null,
528
+ };
529
+ }
530
+ /**
531
+ * Bound an inherited `MAKEFLAGS` to `workers` jobs.
532
+ *
533
+ * Absent: `-j<workers>` (GNU make sizes a bare `-j` off nproc). Carrying a
534
+ * jobserver (`--jobserver-auth=` / `--jobserver-fds=`): kept, since inside a
535
+ * `make` recipe the parent's jobserver already bounds the jobs. Otherwise any
536
+ * `-j`, `-jN`, `--jobs[=N]`, a short cluster ending in j (`-sj18`) or make's
537
+ * dash-less first word (`j18`) above `workers` (a bare one is unlimited) is
538
+ * replaced by `-j<workers>`, keeping every other flag — a profile-wide
539
+ * `export MAKEFLAGS=-j18` no longer outruns the plan.
540
+ *
541
+ * @param raw - The inherited `MAKEFLAGS`.
542
+ * @param workers - The planned worker count.
543
+ * @returns The value to set, or `null` to keep the inherited one.
544
+ * @task T13122
545
+ */
546
+ export function boundMakeflags(raw, workers) {
547
+ if (raw === undefined || raw.trim() === '')
548
+ return `-j${workers}`;
549
+ const words = raw.trim().split(/\s+/);
550
+ if (words.some((w) => /^--jobserver-(auth|fds)=/.test(w)))
551
+ return null;
552
+ const kept = [];
553
+ let over = false;
554
+ for (let i = 0; i < words.length; i++) {
555
+ const word = words[i] ?? '';
556
+ const long = /^--jobs(?:=(\d*))?$/.exec(word);
557
+ // A short-flag cluster ending in j (`-j18`, `-sj18`, `-kj`), or make's own
558
+ // dash-less letter form as the first word (`j18`, `kj`). Only make's
559
+ // argument-less short flags may precede the j: in `-Ij18` the `j18` is
560
+ // -I's directory, and splitting it would hand -I the `-j` (review of #1824).
561
+ const short = long === null ? /^(-?)([BbdehikLnpqrRsStvw]*)j(\d*)$/.exec(word) : null;
562
+ const isShort = short !== null && (short[1] === '-' || i === 0);
563
+ if (long === null && !isShort) {
564
+ kept.push(word);
565
+ continue;
566
+ }
567
+ let count = long !== null ? (long[1] ?? '') : (short?.[3] ?? '');
568
+ const takesNext = long !== null ? long[1] === undefined : count === '';
569
+ const next = words[i + 1];
570
+ if (count === '' && takesNext && next !== undefined && /^\d+$/.test(next)) {
571
+ count = next;
572
+ i++;
573
+ }
574
+ // The cluster's other letters (`s`, `k`) are flags of their own: keep them.
575
+ if (isShort && short?.[2])
576
+ kept.push(`-${short[2]}`);
577
+ if (count === '' || Number(count) > workers)
578
+ over = true;
579
+ else
580
+ kept.push(`-j${count}`);
581
+ }
582
+ return over ? [...kept.filter((w) => !/^-j\d+$/.test(w)), `-j${workers}`].join(' ') : null;
583
+ }
584
+ /**
585
+ * Remove npm's `Unknown env config` warnings from captured output.
586
+ *
587
+ * npm warns once per run for every `npm_config_*` variable it does not know,
588
+ * including `npm_config_workspace_concurrency`, which the overlay sets for the
589
+ * `pnpm -r` an `npm test` script may run. The warning would otherwise sit in
590
+ * the stderr tail CLEO quotes when a tool fails and push the real error out of
591
+ * it; the variable itself stays, because dropping it for npm unbounded those
592
+ * child `pnpm -r` runs (review of #1808).
593
+ *
594
+ * @param text - Captured stderr.
595
+ * @returns The text without those warning lines.
596
+ * @task T13122
597
+ */
598
+ export function withoutNpmEnvConfigWarnings(text) {
599
+ if (!text.includes('Unknown env config'))
600
+ return text;
601
+ return text
602
+ .split('\n')
603
+ .filter(
604
+ // A coloured npm (FORCE_COLOR, color=always) prefixes ANSI codes.
605
+ (line) => !/^npm (warn|WARN) Unknown env config\b/.test(line.replace(ANSI_SGR, '')))
606
+ .join('\n');
607
+ }
608
+ /** ANSI select-graphic-rendition escapes (colours), stripped before matching npm's warning. */
609
+ const ANSI_SGR = /\u001b\[[0-9;]*m/g;
610
+ /**
611
+ * Append `--max-old-space-size` to an existing `NODE_OPTIONS`, or create it,
612
+ * leaving an existing `--max-old-space-size` alone.
613
+ *
614
+ * @deprecated Since T13122 the heavy-tool overlay plans the heap against the
615
+ * run's budget instead of letting any inherited value win; use
616
+ * {@link planHeavyToolEnv} (or {@link withHeapCeiling} to set a ceiling).
617
+ * Kept for SDK consumers.
145
618
  *
146
619
  * @param existing - current `NODE_OPTIONS`, if any.
147
620
  * @param heapMb - ceiling to apply.
@@ -154,50 +627,256 @@ export function mergeNodeOptions(existing, heapMb) {
154
627
  const flag = `--max-old-space-size=${heapMb}`;
155
628
  return current.length > 0 ? `${current} ${flag}` : flag;
156
629
  }
630
+ /** A strictly positive base-10 integer, or `null` (`'50%'`, `'0'`, `'-1'`, `'4x'` are all `null`). */
631
+ function positiveInt(raw) {
632
+ const text = (raw ?? '').trim();
633
+ if (!/^\d+$/.test(text))
634
+ return null;
635
+ const n = Number.parseInt(text, 10);
636
+ return n > 0 ? n : null;
637
+ }
638
+ /** An explicit `CLEO_HEAVY_*` override, or `null`; an unusable value is noted and ignored. */
639
+ function readOverride(env, name, ignored) {
640
+ const raw = env[name];
641
+ if (raw === undefined || raw.trim() === '')
642
+ return null;
643
+ const n = positiveInt(raw);
644
+ if (n === null)
645
+ ignored.push(`${name}=${JSON.stringify(raw)} (not a positive integer)`);
646
+ return n;
647
+ }
648
+ /** The heap flags of a `NODE_OPTIONS` value as one readable string, e.g. `--max-old-space-size=8192`. */
649
+ function heapFlagsText(nodeOptions) {
650
+ return [...parseHeapFlags(nodeOptions)]
651
+ .filter(([name]) => OLD_SPACE_FLAGS.has(name))
652
+ .map(([name, value]) => `--${name}=${value}`)
653
+ .join(' ');
654
+ }
655
+ function chooseHeap(env, override, maxHeapMb, totalRamMb, defaultHeapMb) {
656
+ const inherited = inheritedHeapMb(env.NODE_OPTIONS, totalRamMb);
657
+ if (override !== null)
658
+ return { heapMb: override, source: 'override', inherited };
659
+ // The default heap never shrinks to fit an explicit workspace concurrency: a
660
+ // run asked for that way is reported over budget instead (`overBudget`).
661
+ if (inherited === null)
662
+ return { heapMb: defaultHeapMb, source: 'default', inherited };
663
+ if (inherited <= maxHeapMb)
664
+ return { heapMb: inherited, source: 'inherited', inherited };
665
+ return { heapMb: maxHeapMb, source: 'clamped', inherited };
666
+ }
667
+ /** One worker-count variable: keep an inherited value within the plan, else overlay the plan. */
668
+ function planCount(name, raw, planned, forced, overlay, clamped, kept) {
669
+ const inherited = positiveInt(raw);
670
+ if (!forced && inherited !== null && inherited <= planned) {
671
+ kept.push(`${name}=${inherited}`);
672
+ return;
673
+ }
674
+ overlay[name] = String(planned);
675
+ if (!forced && raw !== undefined && raw !== '') {
676
+ clamped.push({ name, from: raw, to: String(planned) });
677
+ }
678
+ }
679
+ /** A tool that runs as one process: nothing to size. */
680
+ const SINGLE_PROCESS = {
681
+ workers: 1,
682
+ source: 'plan',
683
+ reason: 'one process, no worker pool',
684
+ };
157
685
  /**
158
- * Build the environment overlay for a heavy tool spawn.
686
+ * The worker count of a forking tool — `CLEO_HEAVY_WORKERS`, else as many as
687
+ * fit (`fit`, the budget over packages × heap), never above the default — written
688
+ * to every runner variable and to `MAKEFLAGS`.
689
+ */
690
+ function planWorkers(env, fit, defaultWorkers, ledger) {
691
+ const override = readOverride(env, HEAVY_WORKERS_ENV, ledger.ignored);
692
+ const workers = override ?? Math.max(MIN_HEAVY_WORKERS, Math.min(defaultWorkers, Math.floor(fit)));
693
+ for (const key of WORKER_COUNT_VARS) {
694
+ planCount(key, env[key], workers, override !== null, ledger.overlay, ledger.clamped, ledger.kept);
695
+ }
696
+ // GNU make sizes `-j` off nproc when told `-j` with no argument; an explicit
697
+ // job count bounds a Makefile-driven test/build target too.
698
+ const makeflags = boundMakeflags(env.MAKEFLAGS, workers);
699
+ if (makeflags !== null) {
700
+ ledger.overlay.MAKEFLAGS = makeflags;
701
+ if (env.MAKEFLAGS && env.MAKEFLAGS.trim() !== '' && override === null) {
702
+ ledger.clamped.push({ name: 'MAKEFLAGS', from: env.MAKEFLAGS, to: makeflags });
703
+ }
704
+ }
705
+ if (override !== null)
706
+ return { workers, source: 'override', reason: HEAVY_WORKERS_ENV };
707
+ return {
708
+ workers,
709
+ source: 'plan',
710
+ reason: workers < defaultWorkers
711
+ ? `fewer than the default ${defaultWorkers} so the run fits its budget`
712
+ : 'default for this RAM',
713
+ };
714
+ }
715
+ function heapReason(choice) {
716
+ switch (choice.source) {
717
+ case 'override':
718
+ return HEAVY_HEAP_ENV;
719
+ case 'inherited':
720
+ return 'inherited NODE_OPTIONS';
721
+ case 'clamped':
722
+ return `inherited NODE_OPTIONS asked for ${choice.inherited} MiB, more than the budget allows`;
723
+ default:
724
+ return 'CLEO default';
725
+ }
726
+ }
727
+ /**
728
+ * Plan a heavy or memory-bound tool spawn: the environment overlay, and the
729
+ * resource plan behind it (T13122, T13123).
159
730
  *
160
- * Returns an empty object for non-heavy tools, so the caller can merge
161
- * unconditionally.
731
+ * The heap is chosen first — `CLEO_HEAVY_HEAP_MB`, else the inherited
732
+ * `NODE_OPTIONS` heap when it fits the budget (clamped to it otherwise), else
733
+ * {@link defaultHeavyHeapMb}. The worker count then derives from the heap
734
+ * actually in effect, `⌊budget / (packages × heap)⌋`, never above
735
+ * {@link heavyToolWorkers}, so `packages × workers × heap` stays within
736
+ * {@link heavyRunBudgetMb}. Inherited worker counts and workspace concurrency
737
+ * are kept at or below the plan and clamped above it; `CLEO_HEAVY_WORKERS` and
738
+ * `CLEO_HEAVY_WORKSPACE_CONCURRENCY` override.
739
+ *
740
+ * A single-process memory-bound tool (`typecheck`, `lint`) gets the same heap
741
+ * ceiling and workspace concurrency, and no worker variables: its plan is one
742
+ * process (T13123).
162
743
  *
163
744
  * @param canonical - the canonical tool about to be spawned.
164
745
  * @param env - the environment the child would otherwise inherit.
165
746
  * @param totalRamGib - total RAM in GiB; injectable for deterministic tests.
166
- * @returns variables to overlay; existing deliberate values are preserved.
747
+ * @returns the overlay and plan; an empty overlay and `null` plan for a tool
748
+ * that is not memory-bound (`audit`, `security-scan`).
167
749
  *
168
750
  * @example
169
751
  * ```ts
170
- * const overlay = heavyToolEnv('test', process.env, 62);
171
- * // { NODE_OPTIONS: '--max-old-space-size=4096',
172
- * // VITEST_MAX_WORKERS: '6',
173
- * // npm_config_workspace_concurrency: '1' }
174
- * heavyToolEnv('lint', process.env, 62); // → {}
752
+ * // A shell profile exported an 8 GiB heap on a 64 GiB machine:
753
+ * const { overlay, resources } = planHeavyToolEnv(
754
+ * 'test', { NODE_OPTIONS: '--max-old-space-size=8192' }, 64);
755
+ * overlay.VITEST_MAX_WORKERS; // → '3' (3 × 8192 = the 24576 MiB budget)
756
+ * resources?.heapSource; // → 'inherited'
175
757
  * ```
176
758
  *
177
759
  * @task T12096
760
+ * @task T13122
761
+ * @task T13123
178
762
  */
179
- export function heavyToolEnv(canonical, env = process.env, totalRamGib = totalmem() / 1024 ** 3) {
180
- if (!isHeavyTool(canonical))
181
- return {};
182
- const overlay = {
183
- NODE_OPTIONS: mergeNodeOptions(env.NODE_OPTIONS, HEAVY_TOOL_HEAP_MB),
184
- };
185
- // Respect a deliberate setting; supply one otherwise. An existing value
186
- // always wins — a project that asked for more has outranked our default
187
- // since T12096, and that contract is unchanged.
188
- const workers = String(heavyToolWorkers(totalRamGib));
189
- for (const key of WORKER_COUNT_VARS) {
190
- if (!env[key])
191
- overlay[key] = workers;
763
+ export function planHeavyToolEnv(canonical, env = process.env, totalRamGib = totalmem() / 1024 ** 3) {
764
+ if (!isMemoryBoundTool(canonical))
765
+ return { overlay: {}, resources: null };
766
+ const totalRamMb = Math.floor(totalRamGib * 1024);
767
+ const defaultWorkers = heavyToolWorkers(totalRamGib);
768
+ // A single process starts from Node's own default ceiling on its machine; a
769
+ // forking tool from the heavy default. Both share the heavy run's budget.
770
+ const defaultHeapMb = isHeavyTool(canonical)
771
+ ? defaultHeavyHeapMb(totalRamGib)
772
+ : defaultSingleProcessHeapMb(totalRamGib);
773
+ const budgetMb = defaultWorkers * defaultHeavyHeapMb(totalRamGib);
774
+ const overlay = {};
775
+ const clamped = [];
776
+ const kept = [];
777
+ const ignored = [];
778
+ // Packages in flight first: the heap ceiling an inherited value may keep is
779
+ // the budget shared between them.
780
+ const packagesOverride = readOverride(env, HEAVY_WORKSPACE_CONCURRENCY_ENV, ignored);
781
+ const packages = packagesOverride ?? WORKSPACE_CONCURRENCY;
782
+ const packageNames = workspaceConcurrencyNames(env);
783
+ for (const name of packageNames) {
784
+ planCount(name, env[name], packages, packagesOverride !== null, overlay, clamped, kept);
192
785
  }
193
- if (!env.npm_config_workspace_concurrency) {
194
- overlay.npm_config_workspace_concurrency = String(WORKSPACE_CONCURRENCY);
786
+ // What the child gets: the overlay's value, or the inherited one it kept —
787
+ // the largest across every spelling, since which one pnpm reads depends on
788
+ // its version and the case.
789
+ const packagesInFlight = Math.max(...packageNames.map((name) => positiveInt(overlay[name] ?? env[name]) ?? packages));
790
+ const heap = chooseHeap(env, readOverride(env, HEAVY_HEAP_ENV, ignored), Math.max(1, Math.floor(budgetMb / packagesInFlight)), totalRamMb, defaultHeapMb);
791
+ // Young generation first: three semi-spaces count toward the heap limit.
792
+ const semi = withSemiSpaceCap(env.NODE_OPTIONS);
793
+ if (semi.clampedFrom !== null) {
794
+ clamped.push({
795
+ name: 'NODE_OPTIONS',
796
+ from: `--max-semi-space-size=${semi.clampedFrom}`,
797
+ to: `--max-semi-space-size=${MAX_SEMI_SPACE_MB}`,
798
+ });
195
799
  }
196
- // GNU make sizes `-j` off nproc when told `-j` with no argument; an explicit
197
- // job count here bounds a Makefile-driven test/build target too.
198
- if (!env.MAKEFLAGS) {
199
- overlay.MAKEFLAGS = `-j${workers}`;
800
+ overlay.NODE_OPTIONS = withHeapCeiling(semi.value, heap.heapMb);
801
+ if (heap.source === 'clamped') {
802
+ clamped.push({
803
+ name: 'NODE_OPTIONS',
804
+ from: heapFlagsText(env.NODE_OPTIONS),
805
+ to: `--max-old-space-size=${heap.heapMb}`,
806
+ });
807
+ }
808
+ // A single-process tool (typecheck, lint) has no worker pool to size.
809
+ const pool = isHeavyTool(canonical)
810
+ ? planWorkers(env, budgetMb / (packagesInFlight * heap.heapMb), defaultWorkers, {
811
+ overlay,
812
+ clamped,
813
+ kept,
814
+ ignored,
815
+ })
816
+ : SINGLE_PROCESS;
817
+ const { workers } = pool;
818
+ const product = packagesInFlight * workers * heap.heapMb;
819
+ const overBudget = product > budgetMb;
820
+ const parts = [
821
+ `heap ${heap.heapMb} MiB (${heapReason(heap)}) × ${workers} worker(s) (${pool.reason}) × ` +
822
+ `${packagesInFlight} workspace package(s) at once = ${product} MiB of a ${budgetMb} MiB budget ` +
823
+ `(${Math.round(totalRamMb / 1024)} GiB RAM)`,
824
+ ];
825
+ if (clamped.length > 0) {
826
+ parts.push(`clamped ${clamped.map((c) => `${c.name} ${c.from} → ${c.to}`).join(', ')}; ` +
827
+ `set ${HEAVY_HEAP_ENV}, ${HEAVY_WORKERS_ENV} or ${HEAVY_WORKSPACE_CONCURRENCY_ENV} to ask for more`);
200
828
  }
201
- return overlay;
829
+ if (overBudget)
830
+ parts.push('OVER budget by an explicit CLEO_HEAVY_* override');
831
+ if (ignored.length > 0)
832
+ parts.push(`ignored ${ignored.join(', ')}`);
833
+ return {
834
+ overlay,
835
+ resources: {
836
+ heapMb: heap.heapMb,
837
+ heapSource: heap.source,
838
+ inheritedHeapMb: heap.inherited,
839
+ workers,
840
+ workersSource: pool.source,
841
+ workspaceConcurrency: packagesInFlight,
842
+ budgetMb,
843
+ totalRamMb,
844
+ clamped,
845
+ kept,
846
+ overBudget,
847
+ summary: parts.join('; '),
848
+ },
849
+ };
850
+ }
851
+ /**
852
+ * Build the environment overlay for a heavy or memory-bound tool spawn: the
853
+ * `overlay` of {@link planHeavyToolEnv}.
854
+ *
855
+ * Returns an empty object for a tool that is not memory-bound, so the caller
856
+ * can merge unconditionally.
857
+ *
858
+ * @param canonical - the canonical tool about to be spawned.
859
+ * @param env - the environment the child would otherwise inherit.
860
+ * @param totalRamGib - total RAM in GiB; injectable for deterministic tests.
861
+ * @returns variables to overlay; inherited values within the plan are kept.
862
+ *
863
+ * @example
864
+ * ```ts
865
+ * const overlay = heavyToolEnv('test', {}, 62);
866
+ * // { NODE_OPTIONS: '--max-old-space-size=4096',
867
+ * // VITEST_MAX_WORKERS: '6',
868
+ * // npm_config_workspace_concurrency: '1', … }
869
+ * heavyToolEnv('typecheck', {}, 62);
870
+ * // { NODE_OPTIONS: '--max-old-space-size=4096',
871
+ * // npm_config_workspace_concurrency: '1', pnpm_config_workspace_concurrency: '1' }
872
+ * heavyToolEnv('audit', process.env, 62); // → {}
873
+ * ```
874
+ *
875
+ * @task T12096
876
+ * @task T13122
877
+ * @task T13123
878
+ */
879
+ export function heavyToolEnv(canonical, env = process.env, totalRamGib = totalmem() / 1024 ** 3) {
880
+ return planHeavyToolEnv(canonical, env, totalRamGib).overlay;
202
881
  }
203
882
  //# sourceMappingURL=heavy-tool-env.js.map