scripts-orchestrator 2.15.1 → 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
 
@@ -48,12 +49,14 @@ Create a configuration file (default: `scripts-orchestrator.config.js`) that def
48
49
 
49
50
  ```javascript
50
51
  {
51
- command: 'command_name', // The npm script to run
52
+ command: 'command_name', // The command to run (see "Command prefix" below)
52
53
  description: 'Description', // Optional description
53
54
  status: 'enabled', // 'enabled' or 'disabled'
54
55
  attempts: 1, // Number of retry attempts
55
56
  dependencies: [], // Array of dependent commands
56
57
  background: false, // Whether to run in background
58
+ shell: false, // true => run `command` verbatim as a shell command (no prefix)
59
+ prefix: 'npm run', // Optional per-command prefix override ('' to disable)
57
60
  env: { // Optional environment variables
58
61
  PORT: 3000,
59
62
  NODE_ENV: 'production'
@@ -70,6 +73,44 @@ Create a configuration file (default: `scripts-orchestrator.config.js`) that def
70
73
  }
71
74
  ```
72
75
 
76
+ ### Command prefix (`npm run` is optional)
77
+
78
+ By default every `command` is run as an npm script — the orchestrator prepends `npm run`,
79
+ so `command: 'build'` executes `npm run build`. This prefix is configurable:
80
+
81
+ - **Global default** — set `command_prefix` at the top level of the config. Use it to point at a
82
+ different runner (`'pnpm run'`, `'yarn'`) or to disable prefixing entirely so commands run as
83
+ regular shell commands:
84
+
85
+ ```javascript
86
+ export default {
87
+ command_prefix: '', // '' / false / null => run commands verbatim (plain shell)
88
+ phases: [
89
+ { name: 'checks', parallel: [
90
+ { command: 'eslint . --max-warnings 0' }, // runs as-is, supports args/pipes/&&
91
+ { command: './scripts/verify.sh' },
92
+ ]},
93
+ ],
94
+ };
95
+ ```
96
+
97
+ - **Per command** — `shell: true` forces a single command to run verbatim as a shell command
98
+ (ignoring any global prefix), and `prefix: '...'` overrides the prefix for just that command:
99
+
100
+ ```javascript
101
+ {
102
+ phases: [{ name: 'mixed', parallel: [
103
+ { command: 'build' }, // -> npm run build (global default)
104
+ { command: 'docker compose up -d', shell: true }, // raw shell command
105
+ { command: 'release', prefix: 'yarn' }, // -> yarn release
106
+ ]}],
107
+ }
108
+ ```
109
+
110
+ Precedence per command: `shell: true` (raw) → per-command `prefix` → global `command_prefix`
111
+ → the built-in `npm run` default. Existing configs are unaffected — omitting all of these keeps
112
+ the original `npm run` behaviour.
113
+
73
114
  ### Phase Configuration
74
115
 
75
116
  When using the phases format, each phase can have the following properties:
@@ -488,11 +529,76 @@ The hook receives two environment variables:
488
529
  The hook runs synchronously and its exit code is logged but does not change the orchestrator's
489
530
  own exit code.
490
531
 
491
- **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):
492
534
  ```javascript
493
- post_run: 'node ../../scripts/merge-orchestrator-report.js'
535
+ post_run: 'npx scripts-orchestrator --aggregate ../../scripts-orchestrator-aggregate.config.js'
494
536
  ```
495
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
+
496
602
  ## Git-Based Caching
497
603
 
498
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 {
@@ -27,6 +28,12 @@ export class Orchestrator {
27
28
  this.sequential = sequential;
28
29
  this.force = force;
29
30
  this.metrics = Array.isArray(metrics) ? metrics : [];
31
+ // Global command prefix. Defaults to 'npm run' so existing configs keep working.
32
+ // Set `command_prefix` to '' / false / null in the config to run commands verbatim
33
+ // as regular shell commands. Per-command `shell: true` or `prefix` overrides this.
34
+ this.commandPrefix = Object.prototype.hasOwnProperty.call(config ?? {}, 'command_prefix')
35
+ ? this._normalizePrefix(config.command_prefix)
36
+ : 'npm run';
30
37
  this.jsonResultsPath = jsonResultsPath ?? null;
31
38
  this.htmlResultsPath = htmlResultsPath ?? null;
32
39
  this.processManager = processManager;
@@ -49,9 +56,21 @@ export class Orchestrator {
49
56
  : null;
50
57
  // post-run hook command (shell string)
51
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;
52
65
  // Periodic hook: shell command fired on an interval while the run is in flight (set in index.js).
53
66
  // The library owns only the cadence; the command itself is project-specific (e.g. roll-up render).
54
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;
55
74
  this.periodicIntervalMs = 45000;
56
75
  this._periodicTimer = null;
57
76
  this._periodicRunning = false;
@@ -180,6 +199,8 @@ export class Orchestrator {
180
199
  ...(this.startTime ? { overallDurationMs: Date.now() - this.startTime } : {}), // elapsed so far
181
200
  commands,
182
201
  ...(this.config.phases && this.phaseResults.length > 0 ? { phases: this.phaseResults } : {}),
202
+ ...(this.memoryHeat ? { memoryHeat: this.memoryHeat } : {}),
203
+ ...(this.durationHeat ? { durationHeat: this.durationHeat } : {}),
183
204
  };
184
205
 
185
206
  const tmpPath = outPath + '.tmp';
@@ -263,6 +284,29 @@ export class Orchestrator {
263
284
  }
264
285
  }
265
286
 
287
+ // Normalize a prefix value into a clean string. false/null/'' all mean "no prefix"
288
+ // (run the command verbatim); any string is trimmed.
289
+ _normalizePrefix(value) {
290
+ if (value === false || value === null || value === undefined) return '';
291
+ return String(value).trim();
292
+ }
293
+
294
+ // Resolve the effective prefix for a single command.
295
+ // Precedence: per-command `shell: true` (raw) > per-command `prefix` > global commandPrefix.
296
+ _resolvePrefix(commandConfig = {}) {
297
+ if (commandConfig.shell === true) return '';
298
+ if (Object.prototype.hasOwnProperty.call(commandConfig, 'prefix')) {
299
+ return this._normalizePrefix(commandConfig.prefix);
300
+ }
301
+ return this.commandPrefix;
302
+ }
303
+
304
+ // Format a command for display, honoring its resolved prefix.
305
+ _displayCommand(command, commandConfig = {}) {
306
+ const prefix = this._resolvePrefix(commandConfig);
307
+ return prefix ? `${prefix} ${command}` : command;
308
+ }
309
+
266
310
  async executeCommand(commandConfig, visited = new Set(), phaseName = null) {
267
311
  const {
268
312
  command,
@@ -281,6 +325,8 @@ export class Orchestrator {
281
325
  } = commandConfig;
282
326
 
283
327
  const startTime = Date.now();
328
+ // Effective invocation prefix for this command ('' => run verbatim as a shell command).
329
+ const prefix = this._resolvePrefix(commandConfig);
284
330
 
285
331
  // Record the destination log file for this command (honors per-command override).
286
332
  // Done early so even disabled/skipped commands report where output would land.
@@ -303,7 +349,7 @@ export class Orchestrator {
303
349
 
304
350
  // Skip execution if the command is disabled
305
351
  if (status === 'disabled') {
306
- this.logger.warn(`Skipping: npm run ${command} (status: disabled)`);
352
+ this.logger.warn(`Skipping: ${this._displayCommand(command, commandConfig)} (status: disabled)`);
307
353
  this.skippedCommands.push(command);
308
354
  this.skipReasons.set(command, 'disabled');
309
355
  setTiming(Date.now() - startTime);
@@ -336,6 +382,7 @@ export class Orchestrator {
336
382
  startedByScript: false,
337
383
  process_tracking,
338
384
  kill_command,
385
+ prefix,
339
386
  });
340
387
  setTiming(Date.now() - startTime);
341
388
  visited.delete(command);
@@ -411,6 +458,7 @@ export class Orchestrator {
411
458
  env,
412
459
  reportTime: this.metrics.includes('time'),
413
460
  reportMemory: this.metrics.includes('memory'),
461
+ prefix,
414
462
  });
415
463
  lastRunResult = runResult;
416
464
  const { success, output } = runResult;
@@ -564,6 +612,8 @@ export class Orchestrator {
564
612
  ...(this.config.phases && this.phaseResults.length > 0
565
613
  ? { phases: this.phaseResults }
566
614
  : {}),
615
+ ...(this.memoryHeat ? { memoryHeat: this.memoryHeat } : {}),
616
+ ...(this.durationHeat ? { durationHeat: this.durationHeat } : {}),
567
617
  };
568
618
 
569
619
  const json = JSON.stringify(payload, null, 2);
@@ -860,11 +910,43 @@ export class Orchestrator {
860
910
  }
861
911
  }
862
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
+
863
939
  // Periodic hook: start the interval timer (fires once promptly, then every interval).
864
940
  _startPeriodicHook() {
865
- 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;
866
946
  this.logger.info(
867
- `⏱️ 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)`,
868
950
  );
869
951
  this._firePeriodicTick(); // prompt first roll-up so an initial aggregate exists
870
952
  this._periodicTimer = setInterval(() => this._firePeriodicTick(), this.periodicIntervalMs);
@@ -873,6 +955,11 @@ export class Orchestrator {
873
955
 
874
956
  // Fire the periodic hook asynchronously, with an overlap guard so slow hooks don't pile up.
875
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
+ }
876
963
  if (!this.periodicHook || this._periodicRunning) {
877
964
  if (this._periodicRunning) {
878
965
  this.logger.verbose('[periodic_hook] previous invocation still running; skipping tick');
@@ -914,6 +1001,14 @@ export class Orchestrator {
914
1001
 
915
1002
  // Final synchronous fire so the aggregate reflects the finished run before the process exits.
916
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
+ }
917
1012
  if (!this.periodicHook) return;
918
1013
  this._stopPeriodicHook();
919
1014
  try {
@@ -0,0 +1,60 @@
1
+ import { Orchestrator } from './orchestrator.js';
2
+
3
+ const baseConfig = (extra = {}) => ({
4
+ phases: [{ name: 'p', parallel: [{ command: 'build' }] }],
5
+ ...extra,
6
+ });
7
+
8
+ describe('Orchestrator command prefix resolution', () => {
9
+ test('defaults to "npm run" when no command_prefix is configured', () => {
10
+ const orch = new Orchestrator(baseConfig());
11
+ expect(orch.commandPrefix).toBe('npm run');
12
+ expect(orch._resolvePrefix({ command: 'build' })).toBe('npm run');
13
+ expect(orch._displayCommand('build', { command: 'build' })).toBe('npm run build');
14
+ });
15
+
16
+ test('global command_prefix can be disabled with empty string', () => {
17
+ const orch = new Orchestrator(baseConfig({ command_prefix: '' }));
18
+ expect(orch.commandPrefix).toBe('');
19
+ expect(orch._resolvePrefix({ command: 'ls -la' })).toBe('');
20
+ expect(orch._displayCommand('ls -la', { command: 'ls -la' })).toBe('ls -la');
21
+ });
22
+
23
+ test('global command_prefix can be disabled with false or null', () => {
24
+ expect(new Orchestrator(baseConfig({ command_prefix: false })).commandPrefix).toBe('');
25
+ expect(new Orchestrator(baseConfig({ command_prefix: null })).commandPrefix).toBe('');
26
+ });
27
+
28
+ test('global command_prefix can be set to a custom runner', () => {
29
+ const orch = new Orchestrator(baseConfig({ command_prefix: 'pnpm run' }));
30
+ expect(orch._resolvePrefix({ command: 'build' })).toBe('pnpm run');
31
+ expect(orch._displayCommand('build', { command: 'build' })).toBe('pnpm run build');
32
+ });
33
+
34
+ test('per-command shell:true runs verbatim as a bash command (overrides global)', () => {
35
+ const orch = new Orchestrator(baseConfig({ command_prefix: 'npm run' }));
36
+ const cmd = { command: 'echo hello && ls', shell: true };
37
+ expect(orch._resolvePrefix(cmd)).toBe('');
38
+ expect(orch._displayCommand('echo hello && ls', cmd)).toBe('echo hello && ls');
39
+ });
40
+
41
+ test('per-command prefix string overrides the global prefix', () => {
42
+ const orch = new Orchestrator(baseConfig({ command_prefix: 'npm run' }));
43
+ const cmd = { command: 'build', prefix: 'yarn' };
44
+ expect(orch._resolvePrefix(cmd)).toBe('yarn');
45
+ expect(orch._displayCommand('build', cmd)).toBe('yarn build');
46
+ });
47
+
48
+ test('per-command empty prefix runs verbatim even when global prefix is set', () => {
49
+ const orch = new Orchestrator(baseConfig({ command_prefix: 'npm run' }));
50
+ const cmd = { command: 'make build', prefix: '' };
51
+ expect(orch._resolvePrefix(cmd)).toBe('');
52
+ expect(orch._displayCommand('make build', cmd)).toBe('make build');
53
+ });
54
+
55
+ test('shell:true takes precedence over an explicit prefix', () => {
56
+ const orch = new Orchestrator(baseConfig());
57
+ const cmd = { command: 'echo hi', shell: true, prefix: 'yarn' };
58
+ expect(orch._resolvePrefix(cmd)).toBe('');
59
+ });
60
+ });