scripts-orchestrator 3.0.0 → 3.5.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/README.md CHANGED
@@ -41,6 +41,7 @@ npm install --save-dev scripts-orchestrator
41
41
  - **Post-run hook**: Run a shell command after results are written via `post_run` config (v2.14+)
42
42
  - **Run-state file**: Library-owned in-progress indicator for live dashboard integration (v2.14+)
43
43
  - **Phase recommendations**: Memory-aware `--recommend` mode that proposes an optimal phase layout from a run's time/memory metrics (advisory, v2.15+)
44
+ - **npm workspace aggregation**: First-class workspace roll-up that discovers the npm workspaces in a repo and rolls each workspace's results JSON — plus the root run's global checks — into a single report. Drive it declaratively with the `aggregate` config key (in-process; v3.2+) or via the standalone `--aggregate` CLI mode (v3.1+)
44
45
 
45
46
  ## Configuration
46
47
 
@@ -528,11 +529,76 @@ The hook receives two environment variables:
528
529
  The hook runs synchronously and its exit code is logged but does not change the orchestrator's
529
530
  own exit code.
530
531
 
531
- **Typical use case:** trigger a monorepo rollup report after each workspace finishes:
532
+ **Typical use case:** roll up a monorepo report after each workspace finishes (see
533
+ **npm workspace aggregation** below):
532
534
  ```javascript
533
- post_run: 'node ../../scripts/merge-orchestrator-report.js'
535
+ post_run: 'npx scripts-orchestrator --aggregate ../../scripts-orchestrator-aggregate.config.js'
534
536
  ```
535
537
 
538
+ ## npm workspace aggregation (v3.1+)
539
+
540
+ In a monorepo, each npm workspace can run its own orchestrator gate (writing its own
541
+ `json_results`), and a root run can run repo-wide "global" checks. The aggregator rolls all of
542
+ these into one report — no per-repo merge script required. Drive it the easy way with the
543
+ declarative [`aggregate` config key](#declarative-aggregate-config-key-recommended-v32) below, or
544
+ invoke the [`--aggregate` CLI mode](#--aggregate-cli-mode) directly.
545
+
546
+ It reads only artifacts the library itself writes — each scope's `json_results` and the
547
+ **run-state file** (`.scripts-orchestrator-run.json`) — so it needs no log scraping. The
548
+ run-state file tells it whether the run is still in flight (live report with auto-refresh) or
549
+ finished (static report). Each workspace section is classified as **OK / FAIL / RUNNING /
550
+ PENDING / STALE / INTERRUPTED / N/A** from its own results JSON and the run window.
551
+
552
+ ### Declarative `aggregate` config key (recommended, v3.2+)
553
+
554
+ Rather than wiring a `periodic_hook` / `post_run` that shells out to `--aggregate`, set the
555
+ **`aggregate`** key in your orchestrator config and the library drives the roll-up **in-process** —
556
+ no subprocess spawned every interval, no dependency on `npx`/PATH resolution:
557
+
558
+ ```javascript
559
+ // root run config — roll up periodically while running + once, static, at the end
560
+ aggregate: './scripts-orchestrator-aggregate.config.js', // or `true` for the built-in defaults
561
+ periodic_interval_ms: 45000, // cadence for the in-process roll-up
562
+
563
+ // each workspace gate config — refresh the roll-up as that workspace finishes
564
+ aggregate: '../../scripts-orchestrator-aggregate.config.js',
565
+ ```
566
+
567
+ The value may be `true` (use defaults), a path to a config module (its `default` export is used as
568
+ the options below), or an options object inline. The library auto-detects whether the current run is
569
+ the **repo-root run** (it owns the periodic cadence and writes the final static report) or a
570
+ **fanned-out workspace run** (it refreshes the roll-up once, as that workspace finishes, leaving the
571
+ report in-progress because the root run is still live). On interrupt, the orchestrator writes one
572
+ final static roll-up itself.
573
+
574
+ ### `--aggregate` CLI mode
575
+
576
+ The same roll-up is available as a standalone CLI mode (used internally by the declarative key, and
577
+ handy for manual/one-off rendering or legacy hook wiring). It is safe to fire repeatedly:
578
+
579
+ ```bash
580
+ scripts-orchestrator --aggregate # use the built-in defaults
581
+ scripts-orchestrator --aggregate ./scripts-orchestrator-aggregate.config.js # override paths/title
582
+ ```
583
+
584
+ The optional config module's `default` export may override any of these (all paths are
585
+ resolved against the auto-detected repo root unless absolute):
586
+
587
+ | Key | Default | Meaning |
588
+ | --- | --- | --- |
589
+ | `title` | `Workspaces Quality Report` | Report heading |
590
+ | `outJson` / `outHtml` | `logs/monorepo-quality-report.{json,html}` | Where the roll-up is written |
591
+ | `runStateFile` | `logs/.scripts-orchestrator-run.json` | Run-state file used to detect in-progress + run start |
592
+ | `rootResults` | `logs/scripts-orchestrator-logs/scripts-orchestrator-results.json` | Root run's results (source of global-check rows) |
593
+ | `globalResults` | `logs/scripts-orchestrator-logs/scripts-orchestrator-global-results.json` | Fallback source of global-check rows |
594
+ | `workspaceResults` | `logs/scripts-orchestrator-logs/scripts-orchestrator-results.json` | Per-workspace results path (relative to each workspace) |
595
+ | `globalPhase` / `workspacePhase` | `global quality checks` / `workspace quality gates` | Phase names used to split global rows from the fan-out row |
596
+ | `refreshSecs` | `5` | Auto-refresh cadence injected while the run is in progress |
597
+ | `exclude` | `[]` | Workspace directories (repo-root-relative) to omit |
598
+
599
+ The library also exports the building blocks for programmatic use:
600
+ `findRepoRoot`, `discoverWorkspaceDirs`, `aggregateWorkspacesReport`, `writeAggregateReport`.
601
+
536
602
  ## Git-Based Caching
537
603
 
538
604
  The orchestrator automatically tracks the git commit hash and repository state to optimize execution:
package/index.js CHANGED
@@ -60,6 +60,11 @@ const argv = yargs(hideBin(process.argv))
60
60
  description:
61
61
  'Analyse an existing results JSON and print a memory-aware phase recommendation (no run).',
62
62
  })
63
+ .option('aggregate', {
64
+ type: 'string',
65
+ description:
66
+ 'Aggregate every npm workspace\'s results JSON into one roll-up report (no run). Pass an optional config path to override the default paths/title.',
67
+ })
63
68
  .option('fanout', {
64
69
  type: 'number',
65
70
  description: 'Workspace fan-out (parallel gates sharing the host) used to size the --recommend budget. Default 1.',
@@ -148,6 +153,47 @@ if (argv.recommend != null) {
148
153
  process.exit(0);
149
154
  }
150
155
 
156
+ // --aggregate mode: roll up every npm workspace's results JSON (plus the root run's global
157
+ // checks) into a single report. Advisory of nothing — it just renders state already on disk, so
158
+ // it is safe to fire repeatedly (e.g. from the periodic_hook while the run is in flight, or once
159
+ // at the end). No orchestration run happens here.
160
+ if (argv.aggregate != null) {
161
+ const { writeAggregateReport } = await import('./lib/index.js');
162
+ let options = {};
163
+ const cfgArg = argv.aggregate;
164
+ if (cfgArg && cfgArg !== '-') {
165
+ const cfgPath = path.resolve(process.cwd(), cfgArg);
166
+ if (!fs.existsSync(cfgPath)) {
167
+ log.error(`Error: --aggregate config not found at ${cfgPath}`);
168
+ process.exit(1);
169
+ }
170
+ try {
171
+ options = (await import(new URL(`file://${cfgPath}`).href)).default ?? {};
172
+ } catch (err) {
173
+ log.error(`Error: failed to load --aggregate config: ${err.message}`);
174
+ process.exit(1);
175
+ }
176
+ }
177
+ // The orchestrator fires this hook a final time AFTER clearing its run-state, tagging it with
178
+ // SCRIPTS_ORCHESTRATOR_PERIODIC=final. Honour that as an explicit "run is over" signal so the
179
+ // final report is static (no auto-refresh) even if a stray marker lingers.
180
+ if (process.env.SCRIPTS_ORCHESTRATOR_PERIODIC === 'final') {
181
+ options = { ...options, inProgress: false };
182
+ }
183
+ try {
184
+ const { jsonPath, htmlPath } = writeAggregateReport(options);
185
+ if (process.env.ORCHESTRATOR_MERGE_QUIET !== '1') {
186
+ log.info(
187
+ `📄 Aggregated workspace report → ${path.relative(process.cwd(), jsonPath)} (+ ${path.basename(htmlPath)})`,
188
+ );
189
+ }
190
+ } catch (err) {
191
+ log.error(`Error: failed to aggregate workspace report: ${err.message}`);
192
+ process.exit(1);
193
+ }
194
+ process.exit(0);
195
+ }
196
+
151
197
  // Extract arguments
152
198
  const args = argv._;
153
199
  const configPath = args[0] || './scripts-orchestrator.config.js';
@@ -207,6 +253,14 @@ const htmlResultsPath =
207
253
  // post-run hook — shell command run after json_results written
208
254
  const postRun = commandsConfig.post_run ?? null;
209
255
 
256
+ // Memory heat thresholds for the HTML report — fractions (0–1) of the run's peak memory above which
257
+ // a command is coloured amber (mid) / red (high). Config: `memory_heat: { mid: 0.33, high: 0.66 }`.
258
+ const memoryHeat = commandsConfig.memory_heat ?? null;
259
+
260
+ // Duration heat thresholds for the HTML report — fractions (0–1) of the run's slowest command above
261
+ // which a command is coloured amber (mid) / red (high). Config: `duration_heat: { mid: 0.33, high: 0.66 }`.
262
+ const durationHeat = commandsConfig.duration_heat ?? null;
263
+
210
264
  // Periodic hook — shell command run on an interval WHILE the run is in flight (e.g. to roll up
211
265
  // results into an aggregate report). Library owns only the cadence; the command is project-specific.
212
266
  const periodicHook = commandsConfig.periodic_hook ?? null;
@@ -214,6 +268,34 @@ const periodicIntervalMs = Number(commandsConfig.periodic_interval_ms) > 0
214
268
  ? Number(commandsConfig.periodic_interval_ms)
215
269
  : 45000;
216
270
 
271
+ // Declarative npm-workspace roll-up. `aggregate: true` uses library defaults; a string loads that
272
+ // config module's default export as writeAggregateReport options; an object is used verbatim. When
273
+ // set, the library drives the workspace aggregate IN-PROCESS — the repo-root run rolls up on the
274
+ // periodic cadence + once at the end (static), and a fanned-out workspace run rolls up once as it
275
+ // finishes. This is the declarative replacement for wiring periodic_hook / post_run to shell out
276
+ // to `scripts-orchestrator --aggregate`.
277
+ let aggregateOptions = null;
278
+ const aggregateCfg = commandsConfig.aggregate;
279
+ if (aggregateCfg != null && aggregateCfg !== false) {
280
+ if (aggregateCfg === true) {
281
+ aggregateOptions = {};
282
+ } else if (typeof aggregateCfg === 'string') {
283
+ const aggCfgPath = path.resolve(process.cwd(), aggregateCfg);
284
+ if (!fs.existsSync(aggCfgPath)) {
285
+ log.error(`Error: aggregate config not found at ${aggCfgPath}`);
286
+ process.exit(1);
287
+ }
288
+ try {
289
+ aggregateOptions = (await import(new URL(`file://${aggCfgPath}`).href)).default ?? {};
290
+ } catch (err) {
291
+ log.error(`Error: failed to load aggregate config: ${err.message}`);
292
+ process.exit(1);
293
+ }
294
+ } else if (typeof aggregateCfg === 'object') {
295
+ aggregateOptions = aggregateCfg;
296
+ }
297
+ }
298
+
217
299
  // Set the log folder for the main orchestrator logs if specified
218
300
  if (logFolder) {
219
301
  log.setLogFolder(logFolder);
@@ -233,9 +315,14 @@ const orchestrator = new Orchestrator(
233
315
  );
234
316
  // wire post-run hook from config
235
317
  orchestrator.postRun = postRun;
318
+ // wire memory heat thresholds from config (HTML report colouring)
319
+ orchestrator.memoryHeat = memoryHeat;
320
+ orchestrator.durationHeat = durationHeat;
236
321
  // Wire periodic hook (cadence owned by the library)
237
322
  orchestrator.periodicHook = periodicHook;
238
323
  orchestrator.periodicIntervalMs = periodicIntervalMs;
324
+ // Wire declarative in-process workspace roll-up (takes the in-process path when set)
325
+ orchestrator.aggregateOptions = aggregateOptions;
239
326
 
240
327
  // Enhanced signal handlers
241
328
  const handleSignal = async (signal) => {
@@ -248,6 +335,11 @@ const handleSignal = async (signal) => {
248
335
  }
249
336
  // clear run-state so dashboards know the run ended
250
337
  orchestrator._clearRunState();
338
+ // Write one final static workspace roll-up so an interrupted run leaves a non-refreshing report
339
+ // (only when the declarative in-process aggregate is configured; a no-op otherwise).
340
+ if (orchestrator.aggregateOptions) {
341
+ orchestrator._firePeriodicHookFinal();
342
+ }
251
343
  process.exit(1);
252
344
  };
253
345
 
package/lib/index.js CHANGED
@@ -13,6 +13,12 @@ import {
13
13
  observedTimeline,
14
14
  packPhases,
15
15
  } from './recommend-phases.js';
16
+ import {
17
+ findRepoRoot,
18
+ discoverWorkspaceDirs,
19
+ aggregateWorkspacesReport,
20
+ writeAggregateReport,
21
+ } from './workspaces.js';
16
22
 
17
23
  export {
18
24
  Orchestrator,
@@ -28,5 +34,9 @@ export {
28
34
  usableSteps,
29
35
  observedTimeline,
30
36
  packPhases,
37
+ findRepoRoot,
38
+ discoverWorkspaceDirs,
39
+ aggregateWorkspacesReport,
40
+ writeAggregateReport,
31
41
  };
32
42
  export default Orchestrator;
@@ -0,0 +1,83 @@
1
+ import { Orchestrator } from './orchestrator.js';
2
+
3
+ const baseConfig = (extra = {}) => ({
4
+ phases: [{ name: 'p', parallel: [{ command: 'build' }] }],
5
+ ...extra,
6
+ });
7
+
8
+ // Build an orchestrator with the in-process aggregate wired, and stub the seams that would
9
+ // otherwise spawn processes / touch disk so we can assert the decision logic in isolation.
10
+ function makeOrch({ aggregateOptions = {}, periodicHook = null, isRoot = true } = {}) {
11
+ const orch = new Orchestrator(baseConfig());
12
+ orch.aggregateOptions = aggregateOptions;
13
+ orch.periodicHook = periodicHook;
14
+ orch._isRepoRootRun = () => isRoot;
15
+ // Capture aggregate fires instead of writing a report.
16
+ orch._fireAggregateCalls = [];
17
+ orch._fireAggregate = (inProgress) => orch._fireAggregateCalls.push(inProgress);
18
+ return orch;
19
+ }
20
+
21
+ // Minimal call-counter so the suite stays dependency-free (no jest globals under the ESM runner).
22
+ function counter() {
23
+ const fn = (...args) => { fn.calls.push(args); };
24
+ fn.calls = [];
25
+ return fn;
26
+ }
27
+
28
+ describe('Orchestrator in-process workspace aggregate', () => {
29
+ test('no aggregate configured: periodic machinery is inert', () => {
30
+ const orch = new Orchestrator(baseConfig());
31
+ const fireSpy = counter();
32
+ orch._firePeriodicTick = fireSpy;
33
+ orch._startPeriodicHook();
34
+ expect(orch._periodicTimer).toBeNull();
35
+ expect(fireSpy.calls).toHaveLength(0);
36
+ });
37
+
38
+ test('root run with aggregate: starts cadence with a prompt first roll-up', () => {
39
+ const orch = makeOrch({ isRoot: true });
40
+ const tickSpy = counter();
41
+ orch._firePeriodicTick = tickSpy;
42
+ orch._startPeriodicHook();
43
+ expect(tickSpy.calls).toHaveLength(1); // prompt first roll-up
44
+ expect(orch._periodicTimer).not.toBeNull();
45
+ orch._stopPeriodicHook(); // avoid a dangling interval
46
+ expect(orch._periodicTimer).toBeNull();
47
+ });
48
+
49
+ test('fanned-out workspace run with aggregate: no periodic cadence', () => {
50
+ const orch = makeOrch({ isRoot: false });
51
+ const tickSpy = counter();
52
+ orch._firePeriodicTick = tickSpy;
53
+ orch._startPeriodicHook();
54
+ expect(tickSpy.calls).toHaveLength(0);
55
+ expect(orch._periodicTimer).toBeNull();
56
+ });
57
+
58
+ test('periodic tick rolls up with auto-detected inProgress (undefined)', () => {
59
+ const orch = makeOrch({ isRoot: true });
60
+ orch._firePeriodicTick();
61
+ expect(orch._fireAggregateCalls).toEqual([undefined]);
62
+ });
63
+
64
+ test('final fire on the root run forces a static (inProgress=false) report', () => {
65
+ const orch = makeOrch({ isRoot: true });
66
+ orch._firePeriodicHookFinal();
67
+ expect(orch._fireAggregateCalls).toEqual([false]);
68
+ });
69
+
70
+ test('final fire on a workspace run leaves inProgress auto-detected (root still running)', () => {
71
+ const orch = makeOrch({ isRoot: false });
72
+ orch._firePeriodicHookFinal();
73
+ expect(orch._fireAggregateCalls).toEqual([undefined]);
74
+ });
75
+
76
+ test('a legacy periodic_hook takes precedence over the in-process aggregate', () => {
77
+ const orch = makeOrch({ isRoot: true, periodicHook: 'echo hi' });
78
+ // With a shell hook configured, the tick must NOT take the in-process path.
79
+ orch._periodicRunning = true; // make the shell path a no-op without spawning
80
+ orch._firePeriodicTick();
81
+ expect(orch._fireAggregateCalls).toEqual([]);
82
+ });
83
+ });
@@ -6,6 +6,7 @@ import { healthCheck } from './health-check.js';
6
6
  import { log } from './logger.js';
7
7
  import { GitCache } from './git-cache.js';
8
8
  import { renderReportHtml } from './report-html.js';
9
+ import { findRepoRoot, writeAggregateReport } from './workspaces.js';
9
10
  import chalk from 'chalk';
10
11
 
11
12
  export class Orchestrator {
@@ -55,9 +56,21 @@ export class Orchestrator {
55
56
  : null;
56
57
  // post-run hook command (shell string)
57
58
  this.postRun = null; // set from config in index.js
59
+ // Memory heat thresholds for the HTML report (fractions of the run's peak). Set from config in
60
+ // index.js; embedded into the results payload so the renderer (and --render) honour them.
61
+ this.memoryHeat = null;
62
+ // Duration heat thresholds for the HTML report (fractions of the run's slowest command). Set from
63
+ // config in index.js; embedded into the results payload so the renderer (and --render) honour them.
64
+ this.durationHeat = null;
58
65
  // Periodic hook: shell command fired on an interval while the run is in flight (set in index.js).
59
66
  // The library owns only the cadence; the command itself is project-specific (e.g. roll-up render).
60
67
  this.periodicHook = null;
68
+ // Declarative npm-workspace roll-up (set from config in index.js). When non-null, the library
69
+ // drives the workspace aggregate IN-PROCESS — no shell-out — using these writeAggregateReport
70
+ // options: the repo-root run owns the periodic cadence + final static report, while a fanned-out
71
+ // workspace run refreshes the roll-up once when it finishes. Replaces wiring a periodic_hook /
72
+ // post_run that shells out to `scripts-orchestrator --aggregate`.
73
+ this.aggregateOptions = null;
61
74
  this.periodicIntervalMs = 45000;
62
75
  this._periodicTimer = null;
63
76
  this._periodicRunning = false;
@@ -186,6 +199,8 @@ export class Orchestrator {
186
199
  ...(this.startTime ? { overallDurationMs: Date.now() - this.startTime } : {}), // elapsed so far
187
200
  commands,
188
201
  ...(this.config.phases && this.phaseResults.length > 0 ? { phases: this.phaseResults } : {}),
202
+ ...(this.memoryHeat ? { memoryHeat: this.memoryHeat } : {}),
203
+ ...(this.durationHeat ? { durationHeat: this.durationHeat } : {}),
189
204
  };
190
205
 
191
206
  const tmpPath = outPath + '.tmp';
@@ -597,6 +612,8 @@ export class Orchestrator {
597
612
  ...(this.config.phases && this.phaseResults.length > 0
598
613
  ? { phases: this.phaseResults }
599
614
  : {}),
615
+ ...(this.memoryHeat ? { memoryHeat: this.memoryHeat } : {}),
616
+ ...(this.durationHeat ? { durationHeat: this.durationHeat } : {}),
600
617
  };
601
618
 
602
619
  const json = JSON.stringify(payload, null, 2);
@@ -893,11 +910,43 @@ export class Orchestrator {
893
910
  }
894
911
  }
895
912
 
913
+ // Is this process the repo-root orchestrator run (vs. a fanned-out workspace run)? The root run
914
+ // owns the periodic roll-up cadence and the final static report; a workspace run only refreshes
915
+ // the aggregate once as it finishes (mirrors the old root periodic_hook / workspace post_run split).
916
+ _isRepoRootRun() {
917
+ try {
918
+ const root = findRepoRoot(process.cwd());
919
+ return root != null && path.resolve(process.cwd()) === root;
920
+ } catch {
921
+ return false;
922
+ }
923
+ }
924
+
925
+ // Roll up every workspace's results into the aggregate report, in-process. inProgress is left
926
+ // undefined (auto-detected from the root run-state file) except for the root run's final fire.
927
+ _fireAggregate(inProgress) {
928
+ try {
929
+ const opts = inProgress == null
930
+ ? this.aggregateOptions
931
+ : { ...this.aggregateOptions, inProgress };
932
+ const { jsonPath } = writeAggregateReport(opts);
933
+ this.logger.verbose(`[aggregate] rolled up workspaces → ${jsonPath}`);
934
+ } catch (err) {
935
+ this.logger.warn(`[aggregate] roll-up failed: ${err.message}`);
936
+ }
937
+ }
938
+
896
939
  // Periodic hook: start the interval timer (fires once promptly, then every interval).
897
940
  _startPeriodicHook() {
898
- if (!this.periodicHook || this._periodicTimer) return;
941
+ if (this._periodicTimer) return;
942
+ if (!this.periodicHook && !this.aggregateOptions) return;
943
+ // In-process aggregate: only the repo-root run drives the periodic cadence. A fanned-out
944
+ // workspace run refreshes the roll-up just once, at the end (see _firePeriodicHookFinal).
945
+ if (this.aggregateOptions && !this.periodicHook && !this._isRepoRootRun()) return;
899
946
  this.logger.info(
900
- `⏱️ Periodic report hook every ${Math.round(this.periodicIntervalMs / 1000)}s: ${this.periodicHook}`,
947
+ this.periodicHook
948
+ ? `⏱️ Periodic report hook every ${Math.round(this.periodicIntervalMs / 1000)}s: ${this.periodicHook}`
949
+ : `⏱️ Periodic workspace roll-up every ${Math.round(this.periodicIntervalMs / 1000)}s (in-process)`,
901
950
  );
902
951
  this._firePeriodicTick(); // prompt first roll-up so an initial aggregate exists
903
952
  this._periodicTimer = setInterval(() => this._firePeriodicTick(), this.periodicIntervalMs);
@@ -906,6 +955,11 @@ export class Orchestrator {
906
955
 
907
956
  // Fire the periodic hook asynchronously, with an overlap guard so slow hooks don't pile up.
908
957
  _firePeriodicTick() {
958
+ // In-process aggregate path: synchronous, so no overlap guard needed.
959
+ if (this.aggregateOptions && !this.periodicHook) {
960
+ this._fireAggregate(undefined);
961
+ return;
962
+ }
909
963
  if (!this.periodicHook || this._periodicRunning) {
910
964
  if (this._periodicRunning) {
911
965
  this.logger.verbose('[periodic_hook] previous invocation still running; skipping tick');
@@ -947,6 +1001,14 @@ export class Orchestrator {
947
1001
 
948
1002
  // Final synchronous fire so the aggregate reflects the finished run before the process exits.
949
1003
  _firePeriodicHookFinal() {
1004
+ // In-process aggregate path: the repo-root run owns the "run complete" signal, so it forces a
1005
+ // static (non-refresh) report. A fanned-out workspace run must NOT — the root run is still in
1006
+ // flight, so leave inProgress auto-detected from the (still-present) root run-state file.
1007
+ if (this.aggregateOptions && !this.periodicHook) {
1008
+ this._stopPeriodicHook();
1009
+ this._fireAggregate(this._isRepoRootRun() ? false : undefined);
1010
+ return;
1011
+ }
950
1012
  if (!this.periodicHook) return;
951
1013
  this._stopPeriodicHook();
952
1014
  try {
@@ -27,6 +27,45 @@ export function formatMs(ms) {
27
27
  return `${s}s`;
28
28
  }
29
29
 
30
+ // Compact memory formatting (input in KB): MB up to ~1 GB, then GB.
31
+ export function formatMem(kb) {
32
+ if (kb == null) return '—';
33
+ const mb = kb / 1024;
34
+ if (mb < 1024) return `${mb.toFixed(mb < 10 ? 1 : 0)} MB`;
35
+ return `${(mb / 1024).toFixed(1)} GB`;
36
+ }
37
+
38
+ // Default heat thresholds (fraction of the heaviest command in the run).
39
+ export const DEFAULT_HEAT_THRESHOLDS = { mid: 0.33, high: 0.66 };
40
+
41
+ // Normalize caller-supplied thresholds, clamping to (0,1) and ensuring mid < high.
42
+ // Falls back to a default for any missing/invalid value.
43
+ export function normalizeHeatThresholds(t) {
44
+ const def = DEFAULT_HEAT_THRESHOLDS;
45
+ const clamp = (v, fallback) => (Number.isFinite(v) && v > 0 && v < 1 ? v : fallback);
46
+ let mid = clamp(t?.mid, def.mid);
47
+ let high = clamp(t?.high, def.high);
48
+ if (mid >= high) { mid = def.mid; high = def.high; }
49
+ return { mid, high };
50
+ }
51
+
52
+ // Classify a value relative to the maximum in the run (low / mid / high), so heavy or
53
+ // slow commands can be flagged green→amber→red. Thresholds (fractions of the run's max)
54
+ // are configurable; defaults are used otherwise. Returns null when no value was recorded.
55
+ export function heatLevel(value, max, thresholds = DEFAULT_HEAT_THRESHOLDS) {
56
+ if (value == null || !(max > 0)) return null;
57
+ const frac = value / max;
58
+ if (frac >= thresholds.high) return 'high';
59
+ if (frac >= thresholds.mid) return 'mid';
60
+ return 'low';
61
+ }
62
+
63
+ // Classify a command's memory use relative to the heaviest in the run, so the
64
+ // Gantt can flag which bars are too memory-hungry to run alongside others.
65
+ export function memoryHeat(memoryKb, maxMemory, thresholds = DEFAULT_HEAT_THRESHOLDS) {
66
+ return heatLevel(memoryKb, maxMemory, thresholds);
67
+ }
68
+
30
69
  // Keys that are folded into the synthetic Status column or the Gantt rather than shown raw.
31
70
  const HANDLED_KEYS = new Set(['success', 'startedAt', 'skipReason']);
32
71
 
@@ -83,6 +122,24 @@ function barCellWrap(text, percent, kind) {
83
122
  return `<div class="cellbar"><span>${text}</span><div class="bar ${kind}" style="width:${percent.toFixed(1)}%"></div></div>`;
84
123
  }
85
124
 
125
+ // Duration cell: green→amber→red heat scale relative to the slowest command in the run,
126
+ // applied to both the bar and the value text, so the slowest commands stand out at a glance.
127
+ function durCellWrap(durationMs, maxDuration, thresholds) {
128
+ const heat = heatLevel(durationMs, maxDuration, thresholds);
129
+ const heatCls = heat ? ` dur-${heat}` : '';
130
+ const percent = pct(durationMs, maxDuration);
131
+ return `<div class="cellbar"><span class="durval${heatCls}">${formatMs(durationMs)}</span><div class="bar dur${heatCls}" style="width:${percent.toFixed(1)}%"></div></div>`;
132
+ }
133
+
134
+ // Memory cell: same green→amber→red heat scale as the Gantt, applied to both the
135
+ // bar and the value text, so the table and the Gantt read identically.
136
+ function memCellWrap(memoryKb, maxMemory, thresholds) {
137
+ const heat = memoryHeat(memoryKb, maxMemory, thresholds);
138
+ const heatCls = heat ? ` mem-${heat}` : '';
139
+ const percent = pct(memoryKb, maxMemory);
140
+ return `<div class="cellbar"><span class="memval${heatCls}">${formatMem(memoryKb)}</span><div class="bar mem${heatCls}" style="width:${percent.toFixed(1)}%"></div></div>`;
141
+ }
142
+
86
143
  // Render a single command cell for a given column key.
87
144
  function renderCell(key, c, ctx) {
88
145
  const v = c[key];
@@ -92,9 +149,9 @@ function renderCell(key, c, ctx) {
92
149
  case 'phase':
93
150
  return v != null ? escapeHtml(v) : '—';
94
151
  case 'durationMs':
95
- return v != null ? barCellWrap(formatMs(v), pct(v, ctx.maxDuration), 'dur') : '—';
152
+ return v != null ? durCellWrap(v, ctx.maxDuration, ctx.durationHeatThresholds) : '—';
96
153
  case 'memoryKb':
97
- return v != null ? barCellWrap(`${(v / 1024).toFixed(1)} MB`, pct(v, ctx.maxMemory), 'mem') : '—';
154
+ return v != null ? memCellWrap(v, ctx.maxMemory, ctx.heatThresholds) : '—';
98
155
  case 'logFile':
99
156
  return v
100
157
  ? `<a class="logref" href="file://${escapeHtml(path.resolve(process.cwd(), v))}" title="${escapeHtml(v)}"><code>${escapeHtml(v)}</code></a>`
@@ -130,7 +187,7 @@ function renderPhasesTable(phases, ctx) {
130
187
  }
131
188
 
132
189
  // Critical-path Gantt for one command list (uses observed startedAt + durationMs).
133
- function renderGantt(commands, overallDurationMs, hasPhases) {
190
+ function renderGantt(commands, overallDurationMs, hasPhases, ctx = {}) {
134
191
  const timed = (commands || [])
135
192
  .filter((c) => c.startedAt && c.durationMs != null)
136
193
  .map((c) => ({ ...c, _start: Date.parse(c.startedAt) }))
@@ -160,15 +217,29 @@ function renderGantt(commands, overallDurationMs, hasPhases) {
160
217
  }
161
218
  const criticalTotal = timed.filter((c) => criticalKeys.has(c.command)).reduce((sum, c) => sum + c.durationMs, 0);
162
219
 
220
+ const anyMemory = timed.some((c) => c.memoryKb != null);
221
+
163
222
  const ganttRow = (c) => {
164
223
  const offsetPct = ((c._start - runStart) / spanMs) * 100;
165
224
  const widthPct = Math.max((c.durationMs / spanMs) * 100, 0.5);
166
225
  const crit = criticalKeys.has(c.command);
167
226
  const cls = c.success === false ? 'failed' : crit ? 'crit' : '';
168
- return `<div class="gantt-row"><div class="gantt-label" title="${escapeHtml(c.command)}${c.phase ? ` — ${escapeHtml(c.phase)}` : ''}">${crit ? '★ ' : ''}${escapeHtml(c.command)}</div><div class="gantt-track"><div class="gantt-bar ${cls}" style="left:${offsetPct.toFixed(2)}%;width:${widthPct.toFixed(2)}%" title="${escapeHtml(c.command)} — ${formatMs(c.durationMs)}"><span class="gantt-dur">${formatMs(c.durationMs)}</span></div></div></div>`;
227
+ const heat = memoryHeat(c.memoryKb, ctx.maxMemory, ctx.heatThresholds);
228
+ const heatCls = heat ? ` mem-${heat}` : '';
229
+ const memTitle = c.memoryKb != null ? ` · ${formatMem(c.memoryKb)}` : '';
230
+ const memChip = anyMemory
231
+ ? `<div class="gantt-mem${heat ? ` mem-${heat}` : ''}">${c.memoryKb != null ? formatMem(c.memoryKb) : '—'}</div>`
232
+ : '';
233
+ return `<div class="gantt-row"><div class="gantt-label" title="${escapeHtml(c.command)}${c.phase ? ` — ${escapeHtml(c.phase)}` : ''}">${crit ? '★ ' : ''}${escapeHtml(c.command)}</div>${memChip}<div class="gantt-track"><div class="gantt-bar ${cls}${heatCls}" style="left:${offsetPct.toFixed(2)}%;width:${widthPct.toFixed(2)}%" title="${escapeHtml(c.command)} — ${formatMs(c.durationMs)}${memTitle}"><span class="gantt-dur">${formatMs(c.durationMs)}</span></div></div></div>`;
169
234
  };
170
235
 
171
- return `<section><h3>Actual Critical Path</h3><p class="muted">★ marks each phase's bottleneck — the chain that drives wall-clock time. Critical path ≈ <strong>${formatMs(criticalTotal)}</strong>${overallDurationMs != null ? ` of ${formatMs(overallDurationMs)} total` : ''}.</p><div class="gantt">${timed.map(ganttRow).join('')}</div></section>`;
236
+ const t = ctx.heatThresholds || DEFAULT_HEAT_THRESHOLDS;
237
+ const memLegend = anyMemory
238
+ ? ' Bar outline + memory column show peak RSS (green→red, relative to the heaviest command:' +
239
+ ` amber ≥ ${Math.round(t.mid * 100)}%, red ≥ ${Math.round(t.high * 100)}%) so you can spot which` +
240
+ ' parallel commands are too memory-hungry to overlap.'
241
+ : '';
242
+ return `<section><h3>Actual Critical Path</h3><p class="muted">★ marks each phase's bottleneck — the chain that drives wall-clock time. Critical path ≈ <strong>${formatMs(criticalTotal)}</strong>${overallDurationMs != null ? ` of ${formatMs(overallDurationMs)} total` : ''}.${memLegend}</p><div class="gantt">${timed.map(ganttRow).join('')}</div></section>`;
172
243
  }
173
244
 
174
245
  function renderMeta(meta) {
@@ -188,7 +259,7 @@ function renderSection(section, columns, ctx) {
188
259
  const inner =
189
260
  renderMeta(section.meta) +
190
261
  renderPhasesTable(section.phases, ctx) +
191
- renderGantt(section.commands, section.overallDurationMs, hasPhases) +
262
+ renderGantt(section.commands, section.overallDurationMs, hasPhases, ctx) +
192
263
  renderCommandsTable(section.commands, columns, ctx) +
193
264
  (section.sections || []).map((s) => renderSection(s, columns, ctx)).join('');
194
265
  return `<details class="section" open><summary><span class="badge ${st.kind}">${escapeHtml(st.label)}</span> <span class="section-title">${escapeHtml(section.title || 'Section')}</span><span class="section-dur">${dur}</span></summary><div class="section-body">${inner}</div></details>`;
@@ -224,6 +295,20 @@ tr.running { background: rgba(59,130,246,0.08); }
224
295
  .cellbar span { font-size: 0.85em; }
225
296
  .bar { height: 6px; background: #3b82f6; border-radius: 4px; min-width: 2px; margin-top: 2px; }
226
297
  .bar.mem { background: #8b5cf6; }
298
+ /* Duration heat: same green→amber→red scale, relative to the slowest command. */
299
+ .bar.dur.dur-low { background: #22c55e; }
300
+ .bar.dur.dur-mid { background: #f59e0b; }
301
+ .bar.dur.dur-high { background: #ef4444; }
302
+ .durval.dur-low { color: #22c55e; }
303
+ .durval.dur-mid { color: #f59e0b; }
304
+ .durval.dur-high { color: #ef4444; font-weight: 600; }
305
+ /* Memory heat: same green→amber→red scale as the Gantt's bar outline. */
306
+ .bar.mem.mem-low { background: #22c55e; }
307
+ .bar.mem.mem-mid { background: #f59e0b; }
308
+ .bar.mem.mem-high { background: #ef4444; }
309
+ .memval.mem-low { color: #22c55e; }
310
+ .memval.mem-mid { color: #f59e0b; }
311
+ .memval.mem-high { color: #ef4444; font-weight: 600; }
227
312
  code { font-size: 0.9em; background: #333; padding: 0.1rem 0.3rem; border-radius: 4px; }
228
313
  a.logref { color: #60a5fa; text-decoration: none; }
229
314
  a.logref:hover { text-decoration: underline; }
@@ -235,6 +320,14 @@ a.logref:hover { text-decoration: underline; }
235
320
  .gantt-bar { position: absolute; top: 0; height: 16px; background: #3b82f6; border-radius: 4px; min-width: 2px; display: flex; align-items: center; overflow: hidden; }
236
321
  .gantt-bar.crit { background: #f59e0b; }
237
322
  .gantt-bar.failed { background: #ef4444; }
323
+ /* Memory heat: an inset ring on the bar (doesn't change layout or overlap neighbours). */
324
+ .gantt-bar.mem-low { box-shadow: inset 0 0 0 2px #22c55e; }
325
+ .gantt-bar.mem-mid { box-shadow: inset 0 0 0 2px #f59e0b; }
326
+ .gantt-bar.mem-high { box-shadow: inset 0 0 0 2px #ef4444; }
327
+ .gantt-mem { width: 64px; flex: 0 0 64px; text-align: right; font-family: ui-monospace, monospace; font-size: 0.7rem; color: #888; }
328
+ .gantt-mem.mem-low { color: #22c55e; }
329
+ .gantt-mem.mem-mid { color: #f59e0b; }
330
+ .gantt-mem.mem-high { color: #ef4444; font-weight: 600; }
238
331
  .gantt-dur { font-size: 0.65rem; color: #0f0f0f; padding: 0 4px; white-space: nowrap; }
239
332
  details.section { background: #232323; border-radius: 8px; margin-bottom: 0.6rem; padding: 0.25rem 0.75rem; }
240
333
  details.section > summary { cursor: pointer; padding: 0.5rem 0; display: flex; align-items: center; gap: 0.6rem; }
@@ -263,6 +356,9 @@ export function renderReportHtml(payload) {
263
356
  const ctx = {
264
357
  maxDuration: Math.max(0, ...allCommands.map((c) => c.durationMs || 0)),
265
358
  maxMemory: Math.max(0, ...allCommands.map((c) => c.memoryKb || 0)),
359
+ // Heat thresholds travel in the payload so re-rendering a saved JSON (--render) honours them.
360
+ heatThresholds: normalizeHeatThresholds(payload.memoryHeat),
361
+ durationHeatThresholds: normalizeHeatThresholds(payload.durationHeat),
266
362
  };
267
363
 
268
364
  const top = statusOf({ success });
@@ -270,7 +366,7 @@ export function renderReportHtml(payload) {
270
366
 
271
367
  const topBlocks =
272
368
  renderPhasesTable(payload.phases, ctx) +
273
- renderGantt(topCommands, overallDurationMs, hasTopPhases) +
369
+ renderGantt(topCommands, overallDurationMs, hasTopPhases, ctx) +
274
370
  (topCommands.length > 0
275
371
  ? `<section><h3>Commands</h3>${renderCommandsTable(topCommands, columns, ctx)}</section>`
276
372
  : '');