scripts-orchestrator 2.15.0 → 3.0.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
@@ -48,12 +48,14 @@ Create a configuration file (default: `scripts-orchestrator.config.js`) that def
48
48
 
49
49
  ```javascript
50
50
  {
51
- command: 'command_name', // The npm script to run
51
+ command: 'command_name', // The command to run (see "Command prefix" below)
52
52
  description: 'Description', // Optional description
53
53
  status: 'enabled', // 'enabled' or 'disabled'
54
54
  attempts: 1, // Number of retry attempts
55
55
  dependencies: [], // Array of dependent commands
56
56
  background: false, // Whether to run in background
57
+ shell: false, // true => run `command` verbatim as a shell command (no prefix)
58
+ prefix: 'npm run', // Optional per-command prefix override ('' to disable)
57
59
  env: { // Optional environment variables
58
60
  PORT: 3000,
59
61
  NODE_ENV: 'production'
@@ -70,6 +72,44 @@ Create a configuration file (default: `scripts-orchestrator.config.js`) that def
70
72
  }
71
73
  ```
72
74
 
75
+ ### Command prefix (`npm run` is optional)
76
+
77
+ By default every `command` is run as an npm script — the orchestrator prepends `npm run`,
78
+ so `command: 'build'` executes `npm run build`. This prefix is configurable:
79
+
80
+ - **Global default** — set `command_prefix` at the top level of the config. Use it to point at a
81
+ different runner (`'pnpm run'`, `'yarn'`) or to disable prefixing entirely so commands run as
82
+ regular shell commands:
83
+
84
+ ```javascript
85
+ export default {
86
+ command_prefix: '', // '' / false / null => run commands verbatim (plain shell)
87
+ phases: [
88
+ { name: 'checks', parallel: [
89
+ { command: 'eslint . --max-warnings 0' }, // runs as-is, supports args/pipes/&&
90
+ { command: './scripts/verify.sh' },
91
+ ]},
92
+ ],
93
+ };
94
+ ```
95
+
96
+ - **Per command** — `shell: true` forces a single command to run verbatim as a shell command
97
+ (ignoring any global prefix), and `prefix: '...'` overrides the prefix for just that command:
98
+
99
+ ```javascript
100
+ {
101
+ phases: [{ name: 'mixed', parallel: [
102
+ { command: 'build' }, // -> npm run build (global default)
103
+ { command: 'docker compose up -d', shell: true }, // raw shell command
104
+ { command: 'release', prefix: 'yarn' }, // -> yarn release
105
+ ]}],
106
+ }
107
+ ```
108
+
109
+ Precedence per command: `shell: true` (raw) → per-command `prefix` → global `command_prefix`
110
+ → the built-in `npm run` default. Existing configs are unaffected — omitting all of these keeps
111
+ the original `npm run` behaviour.
112
+
73
113
  ### Phase Configuration
74
114
 
75
115
  When using the phases format, each phase can have the following properties:
@@ -451,7 +491,7 @@ Alongside `json_results`, the library writes one NDJSON line per event to
451
491
  Dashboard tools can `tail -f` this file or watch it with `fs.watch` to get real-time updates
452
492
  without parsing human-readable log lines.
453
493
 
454
- ### Run-state file (A4)
494
+ ### Run-state file
455
495
 
456
496
  When `--logFolder` is specified, the library writes `{logFolder}/.scripts-orchestrator-run.json`
457
497
  at run start and removes it on run end:
@@ -468,7 +508,7 @@ at run start and removes it on run end:
468
508
  This file is the authoritative in-progress signal for live dashboards. Its absence means the run
469
509
  has finished (or never started).
470
510
 
471
- ### Post-run hook (A7)
511
+ ### Post-run hook
472
512
 
473
513
  Add `post_run` to your config to run a shell command **after** `json_results` is written and the
474
514
  run-state file is cleared, but **before** `process.exit()`:
@@ -526,13 +566,16 @@ This is useful when you want to:
526
566
  ## Phase Recommendations (advisory)
527
567
 
528
568
  When a run is executed with `metrics: ['time', 'memory']`, the results JSON records each command's
529
- `durationMs` and peak `memoryKb`. The `--recommend` mode reads that JSON and prints a **memory-aware
530
- phase recommendation**: it never runs anything and writes no files.
569
+ `durationMs` and peak `memoryKb`. The `--recommend` mode reads that JSON and reports a **memory-aware
570
+ phase recommendation**: it never runs anything and changes no run state.
531
571
 
532
572
  ```bash
533
- # Analyse an existing results JSON and print a suggested phase layout
573
+ # Analyse an existing results JSON and print a suggested phase layout to the console
534
574
  scripts-orchestrator --recommend ./logs/scripts-orchestrator-results.json
535
575
 
576
+ # Write the report to a plain-text log file instead of the console (only a pointer line is printed)
577
+ scripts-orchestrator --recommend ./logs/results.json --recommend-out ./logs/recommendation.log
578
+
536
579
  # Size the budget for a machine running N gates in parallel (each gets 1/N of RAM and cores)
537
580
  scripts-orchestrator --recommend ./logs/scripts-orchestrator-results.json --fanout 3
538
581
 
@@ -541,7 +584,7 @@ scripts-orchestrator --recommend ./logs/results.json --budget-mb 8192
541
584
  scripts-orchestrator --recommend ./logs/results.json --mem-safety 0.7
542
585
  ```
543
586
 
544
- It reports two things:
587
+ It reports three things:
545
588
 
546
589
  1. **Observed timeline** — each phase's wall-clock (the longest step in it) and the concurrent peak
547
590
  memory (Σ of member peaks), flagging any phase whose concurrent peak exceeds the host budget.
@@ -551,6 +594,9 @@ It reports two things:
551
594
  `coreShare = (cores − 2) ÷ fanout`. Long steps seed phases; short steps fill the gaps beneath them,
552
595
  so the estimated makespan stays near the theoretical floor (the single longest step) without
553
596
  oversubscribing RAM.
597
+ 3. **Verdict** — a single yes/no line: whether re-grouping is worth it (it must trim ≥5% and ≥5s off
598
+ the makespan), or — when one step is ≥95% of the makespan — that the only remaining lever is to
599
+ split that step into smaller commands the orchestrator can schedule separately.
554
600
 
555
601
  The same logic is exported for programmatic use:
556
602
 
@@ -560,9 +606,12 @@ import { recommendPhases, formatRecommendationReport } from 'scripts-orchestrato
560
606
  const payload = JSON.parse(fs.readFileSync('./logs/results.json', 'utf8'));
561
607
  const rec = recommendPhases(payload, { fanout: 3 });
562
608
  console.log(formatRecommendationReport(rec));
563
- // rec.recommended.bins, rec.observed, rec.observedMakespanMs, rec.budgetBytes, …
609
+ // rec.verdict.worthwhile, rec.verdict.reason, rec.recommended.bins, rec.observed, rec.budgetBytes, …
564
610
  ```
565
611
 
612
+ A natural place to wire it is the `post_run` config hook, so each run prints a recommendation for its
613
+ own results JSON when it finishes.
614
+
566
615
  This is advisory only — the budget is conservative (per-process peaks summed as if they coincide) and
567
616
  the packing does not model inter-phase data dependencies, so validate any suggested layout against a
568
617
  real run before adopting it.
package/index.js CHANGED
@@ -58,7 +58,7 @@ const argv = yargs(hideBin(process.argv))
58
58
  .option('recommend', {
59
59
  type: 'string',
60
60
  description:
61
- 'Analyse an existing results JSON and print a memory-aware phase recommendation (R12, no run).',
61
+ 'Analyse an existing results JSON and print a memory-aware phase recommendation (no run).',
62
62
  })
63
63
  .option('fanout', {
64
64
  type: 'number',
@@ -72,6 +72,10 @@ const argv = yargs(hideBin(process.argv))
72
72
  type: 'number',
73
73
  description: 'Override the --recommend memory budget with a fixed value in MB.',
74
74
  })
75
+ .option('recommend-out', {
76
+ type: 'string',
77
+ description: 'Write the --recommend report to this file (plain text) instead of the console.',
78
+ })
75
79
  .help()
76
80
  .alias('h', 'help')
77
81
  .parse();
@@ -108,7 +112,8 @@ if (argv.render != null) {
108
112
  }
109
113
 
110
114
  // --recommend mode: analyse an existing results JSON and print a memory-aware phase
111
- // recommendation (R12). Advisory only — no orchestration run, no files written.
115
+ // recommendation. Advisory only — no orchestration run. Output goes to the console, or to a
116
+ // plain-text log file when --recommend-out is given.
112
117
  if (argv.recommend != null) {
113
118
  const { recommendPhases, formatRecommendationReport } = await import('./lib/index.js');
114
119
  const srcPath = path.resolve(process.cwd(), argv.recommend);
@@ -128,7 +133,18 @@ if (argv.recommend != null) {
128
133
  memSafety: argv.memSafety,
129
134
  budgetMb: argv.budgetMb,
130
135
  });
131
- console.log(formatRecommendationReport(rec, { sourcePath: path.relative(process.cwd(), srcPath) }));
136
+ const report = formatRecommendationReport(rec, { sourcePath: path.relative(process.cwd(), srcPath) });
137
+ if (argv.recommendOut != null && argv.recommendOut !== '-') {
138
+ // Strip ANSI colour codes so the log file stays plain text. The escape char is built at runtime
139
+ // so the regex carries no literal control character (keeps eslint's no-control-regex happy).
140
+ const ansi = new RegExp(String.fromCharCode(27) + '\\[[0-9;]*m', 'g');
141
+ const outPath = path.resolve(process.cwd(), argv.recommendOut);
142
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
143
+ fs.writeFileSync(outPath, report.replace(ansi, '') + '\n', 'utf8');
144
+ log.info(`📄 Phase recommendation written to ${path.relative(process.cwd(), outPath)}`);
145
+ } else {
146
+ console.log(report);
147
+ }
132
148
  process.exit(0);
133
149
  }
134
150
 
@@ -188,7 +204,7 @@ const htmlResultsPath =
188
204
  ? argv.htmlResults
189
205
  : (commandsConfig.html_results ?? commandsConfig.html_results_path ?? null);
190
206
 
191
- // A7: post-run hook — shell command run after json_results written
207
+ // post-run hook — shell command run after json_results written
192
208
  const postRun = commandsConfig.post_run ?? null;
193
209
 
194
210
  // Periodic hook — shell command run on an interval WHILE the run is in flight (e.g. to roll up
@@ -215,7 +231,7 @@ const orchestrator = new Orchestrator(
215
231
  jsonResultsPath,
216
232
  htmlResultsPath,
217
233
  );
218
- // A7: wire post-run hook from config
234
+ // wire post-run hook from config
219
235
  orchestrator.postRun = postRun;
220
236
  // Wire periodic hook (cadence owned by the library)
221
237
  orchestrator.periodicHook = periodicHook;
@@ -230,7 +246,7 @@ const handleSignal = async (signal) => {
230
246
  } catch (error) {
231
247
  log.error(`Cleanup failed: ${error.message}`);
232
248
  }
233
- // A4: clear run-state so dashboards know the run ended
249
+ // clear run-state so dashboards know the run ended
234
250
  orchestrator._clearRunState();
235
251
  process.exit(1);
236
252
  };
package/lib/index.js CHANGED
@@ -6,6 +6,7 @@ import { GitCache } from './git-cache.js';
6
6
  import { renderReportHtml } from './report-html.js';
7
7
  import {
8
8
  recommendPhases,
9
+ decideVerdict,
9
10
  formatRecommendationReport,
10
11
  computeBudget,
11
12
  usableSteps,
@@ -21,6 +22,7 @@ export {
21
22
  GitCache,
22
23
  renderReportHtml,
23
24
  recommendPhases,
25
+ decideVerdict,
24
26
  formatRecommendationReport,
25
27
  computeBudget,
26
28
  usableSteps,
@@ -27,6 +27,12 @@ export class Orchestrator {
27
27
  this.sequential = sequential;
28
28
  this.force = force;
29
29
  this.metrics = Array.isArray(metrics) ? metrics : [];
30
+ // Global command prefix. Defaults to 'npm run' so existing configs keep working.
31
+ // Set `command_prefix` to '' / false / null in the config to run commands verbatim
32
+ // as regular shell commands. Per-command `shell: true` or `prefix` overrides this.
33
+ this.commandPrefix = Object.prototype.hasOwnProperty.call(config ?? {}, 'command_prefix')
34
+ ? this._normalizePrefix(config.command_prefix)
35
+ : 'npm run';
30
36
  this.jsonResultsPath = jsonResultsPath ?? null;
31
37
  this.htmlResultsPath = htmlResultsPath ?? null;
32
38
  this.processManager = processManager;
@@ -39,15 +45,15 @@ export class Orchestrator {
39
45
  this.commandLogPaths = new Map(); // command -> resolved destination log file (absolute)
40
46
  this.phaseResults = []; // { name, success, durationMs } per phase run
41
47
  this.gitCache = new GitCache(logFolder);
42
- // A1: track per-command start times for incremental JSON
48
+ // track per-command start times for incremental JSON
43
49
  this.commandStartTimes = new Map(); // command -> ISO start string
44
- // A2: events file path derived from jsonResultsPath
50
+ // events file path derived from jsonResultsPath
45
51
  this.eventsPath = this._deriveEventsPath(jsonResultsPath);
46
- // A4: library-owned run-state file
52
+ // library-owned run-state file
47
53
  this.runStatePath = logFolder
48
54
  ? path.join(path.resolve(logFolder), '.scripts-orchestrator-run.json')
49
55
  : null;
50
- // A7: post-run hook command (shell string)
56
+ // post-run hook command (shell string)
51
57
  this.postRun = null; // set from config in index.js
52
58
  // Periodic hook: shell command fired on an interval while the run is in flight (set in index.js).
53
59
  // The library owns only the cadence; the command itself is project-specific (e.g. roll-up render).
@@ -84,7 +90,7 @@ export class Orchestrator {
84
90
  return jsonResultsPath.replace(/\.json$/, '') + '-events.ndjson';
85
91
  }
86
92
 
87
- // A4: write current run state atomically
93
+ // write current run state atomically
88
94
  _writeRunState(extra = {}) {
89
95
  if (!this.runStatePath) return;
90
96
  const state = {
@@ -100,13 +106,13 @@ export class Orchestrator {
100
106
  } catch { /* non-fatal */ }
101
107
  }
102
108
 
103
- // A4: remove run-state file on run end
109
+ // remove run-state file on run end
104
110
  _clearRunState() {
105
111
  if (!this.runStatePath) return;
106
112
  try { fs.unlinkSync(this.runStatePath); } catch { /* ignore */ }
107
113
  }
108
114
 
109
- // A2: append a structured NDJSON event
115
+ // append a structured NDJSON event
110
116
  _appendEvent(type, data = {}) {
111
117
  if (!this.eventsPath) return;
112
118
  const line = JSON.stringify({ type, timestamp: new Date().toISOString(), ...data });
@@ -117,7 +123,7 @@ export class Orchestrator {
117
123
  }
118
124
  }
119
125
 
120
- // A1: atomically write current run state (completed + in-flight commands) to json_results
126
+ // atomically write current run state (completed + in-flight commands) to json_results
121
127
  _writePartialResults() {
122
128
  if (this.jsonResultsPath == null || this.jsonResultsPath === '-') return;
123
129
  const outPath = this.jsonResultsPath || './scripts-orchestrator-results.json';
@@ -202,7 +208,7 @@ export class Orchestrator {
202
208
  }
203
209
  }
204
210
 
205
- // A4: keep run-state file in sync with current active commands and phase
211
+ // keep run-state file in sync with current active commands and phase
206
212
  const inFlight = commands.filter(c => c.success === null).map(c => c.command);
207
213
  const currentPhase = commands.length > 0 ? (commands[commands.length - 1].phase ?? null) : null;
208
214
  this._writeRunState({
@@ -263,6 +269,29 @@ export class Orchestrator {
263
269
  }
264
270
  }
265
271
 
272
+ // Normalize a prefix value into a clean string. false/null/'' all mean "no prefix"
273
+ // (run the command verbatim); any string is trimmed.
274
+ _normalizePrefix(value) {
275
+ if (value === false || value === null || value === undefined) return '';
276
+ return String(value).trim();
277
+ }
278
+
279
+ // Resolve the effective prefix for a single command.
280
+ // Precedence: per-command `shell: true` (raw) > per-command `prefix` > global commandPrefix.
281
+ _resolvePrefix(commandConfig = {}) {
282
+ if (commandConfig.shell === true) return '';
283
+ if (Object.prototype.hasOwnProperty.call(commandConfig, 'prefix')) {
284
+ return this._normalizePrefix(commandConfig.prefix);
285
+ }
286
+ return this.commandPrefix;
287
+ }
288
+
289
+ // Format a command for display, honoring its resolved prefix.
290
+ _displayCommand(command, commandConfig = {}) {
291
+ const prefix = this._resolvePrefix(commandConfig);
292
+ return prefix ? `${prefix} ${command}` : command;
293
+ }
294
+
266
295
  async executeCommand(commandConfig, visited = new Set(), phaseName = null) {
267
296
  const {
268
297
  command,
@@ -281,6 +310,8 @@ export class Orchestrator {
281
310
  } = commandConfig;
282
311
 
283
312
  const startTime = Date.now();
313
+ // Effective invocation prefix for this command ('' => run verbatim as a shell command).
314
+ const prefix = this._resolvePrefix(commandConfig);
284
315
 
285
316
  // Record the destination log file for this command (honors per-command override).
286
317
  // Done early so even disabled/skipped commands report where output would land.
@@ -303,7 +334,7 @@ export class Orchestrator {
303
334
 
304
335
  // Skip execution if the command is disabled
305
336
  if (status === 'disabled') {
306
- this.logger.warn(`Skipping: npm run ${command} (status: disabled)`);
337
+ this.logger.warn(`Skipping: ${this._displayCommand(command, commandConfig)} (status: disabled)`);
307
338
  this.skippedCommands.push(command);
308
339
  this.skipReasons.set(command, 'disabled');
309
340
  setTiming(Date.now() - startTime);
@@ -336,6 +367,7 @@ export class Orchestrator {
336
367
  startedByScript: false,
337
368
  process_tracking,
338
369
  kill_command,
370
+ prefix,
339
371
  });
340
372
  setTiming(Date.now() - startTime);
341
373
  visited.delete(command);
@@ -382,7 +414,7 @@ export class Orchestrator {
382
414
  }
383
415
  }
384
416
 
385
- // A1/A2: record start and emit event
417
+ // record start and emit event
386
418
  this.commandStartTimes.set(command, new Date().toISOString());
387
419
  this._appendEvent('command_start', { command, phase: phaseName, scope: 'workspace' });
388
420
  this._writePartialResults();
@@ -411,6 +443,7 @@ export class Orchestrator {
411
443
  env,
412
444
  reportTime: this.metrics.includes('time'),
413
445
  reportMemory: this.metrics.includes('memory'),
446
+ prefix,
414
447
  });
415
448
  lastRunResult = runResult;
416
449
  const { success, output } = runResult;
@@ -461,7 +494,7 @@ export class Orchestrator {
461
494
 
462
495
  const totalDurationMs = Date.now() - startTime;
463
496
  setTiming(totalDurationMs, lastRunResult?.memoryKb ?? null);
464
- // A1/A2: emit completion event and write incremental results
497
+ // emit completion event and write incremental results
465
498
  this._appendEvent('command_end', { command, phase: phaseName, success: result, durationMs: totalDurationMs });
466
499
  this._writePartialResults();
467
500
  visited.delete(command);
@@ -601,7 +634,7 @@ export class Orchestrator {
601
634
  async run() {
602
635
  this.startTime = Date.now();
603
636
  this.runStartedAt = this.startTime;
604
- // A4: write initial run-state at start
637
+ // write initial run-state at start
605
638
  this._writeRunState({ phase: null, activeCommand: null });
606
639
  try {
607
640
  // Check if we should skip execution based on git state (unless forced)
@@ -808,7 +841,7 @@ export class Orchestrator {
808
841
  );
809
842
  }
810
843
 
811
- // A2: emit run_end event; A1: final JSON written by writeJsonResults (replaces partial)
844
+ // emit run_end event; final JSON written by writeJsonResults (replaces partial)
812
845
  const runDurationMs = this.startTime ? Date.now() - this.startTime : undefined;
813
846
  this._appendEvent('run_end', { success: !hasFailures, ...(runDurationMs != null ? { durationMs: runDurationMs } : {}) });
814
847
 
@@ -817,14 +850,14 @@ export class Orchestrator {
817
850
  this.writeJsonResults(hasFailures);
818
851
  }
819
852
 
820
- // A4: clear run-state file — run is done
853
+ // clear run-state file — run is done
821
854
  this._clearRunState();
822
855
 
823
856
  // Final roll-up (synchronous) AFTER run-state is cleared, so the aggregate reflects the
824
857
  // finished run (an in-flight marker would otherwise make the final report read as running).
825
858
  this._firePeriodicHookFinal();
826
859
 
827
- // A7: run post_run hook after results are written
860
+ // run post_run hook after results are written
828
861
  this._runPostRunHook(hasFailures);
829
862
 
830
863
  // Update git cache on successful execution
@@ -846,7 +879,7 @@ export class Orchestrator {
846
879
  // Stop periodic ticks on error.
847
880
  this._stopPeriodicHook();
848
881
 
849
- // A4: clear run-state on error too
882
+ // clear run-state on error too
850
883
  this._clearRunState();
851
884
 
852
885
  // Cleanup on error
@@ -928,7 +961,7 @@ export class Orchestrator {
928
961
  }
929
962
  }
930
963
 
931
- // A7: run user-configured post_run shell command
964
+ // run user-configured post_run shell command
932
965
  _runPostRunHook(hasFailures) {
933
966
  if (!this.postRun) return;
934
967
  this.logger.info(`[post_run] ${this.postRun}`);
@@ -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
+ });
@@ -46,6 +46,7 @@ export class ProcessManager {
46
46
  startedByScript,
47
47
  process_tracking,
48
48
  kill_command,
49
+ prefix = 'npm run',
49
50
  }) {
50
51
  this.logger.verbose(`Adding background process: ${command} (${url})`);
51
52
  this.backgroundProcessesDetails.push({
@@ -54,6 +55,7 @@ export class ProcessManager {
54
55
  startedByScript,
55
56
  process_tracking,
56
57
  kill_command,
58
+ prefix,
57
59
  });
58
60
  }
59
61
 
@@ -93,7 +95,13 @@ export class ProcessManager {
93
95
  env = null,
94
96
  reportTime = false,
95
97
  reportMemory = false,
98
+ prefix = 'npm run',
96
99
  }) {
100
+ // Resolve how the command is invoked. A non-empty prefix (e.g. 'npm run') is
101
+ // prepended to the command name; an empty/false prefix runs the command verbatim
102
+ // as a regular shell command. `displayCmd` is what we surface in logs.
103
+ const commandPrefix = prefix ? String(prefix).trim() : '';
104
+ const displayCmd = commandPrefix ? `${commandPrefix} ${cmd}` : cmd;
97
105
  const baseDir = this.logFolder
98
106
  ? path.resolve(this.logFolder)
99
107
  : process.cwd();
@@ -131,12 +139,12 @@ export class ProcessManager {
131
139
  const startTime = Date.now();
132
140
  let timeOutputPath = null;
133
141
  // Build command with environment variables if provided
134
- let fullCommand = `npm run ${cmd}`;
142
+ let fullCommand = displayCmd;
135
143
  if (env && Object.keys(env).length > 0) {
136
144
  const envStr = Object.entries(env)
137
145
  .map(([key, value]) => `${key}=${value}`)
138
146
  .join(' ');
139
- fullCommand = `${envStr} npm run ${cmd}`;
147
+ fullCommand = `${envStr} ${displayCmd}`;
140
148
  }
141
149
  const useTimeWrapper =
142
150
  reportMemory && !background && (process.platform === 'linux' || process.platform === 'darwin');
@@ -286,6 +294,7 @@ export class ProcessManager {
286
294
  url: healthCheck?.url,
287
295
  startedByScript: true,
288
296
  kill_command,
297
+ prefix: commandPrefix,
289
298
  });
290
299
 
291
300
  this.logger.verbose(`Unreferencing process ${processGroupId}`);
@@ -293,7 +302,7 @@ export class ProcessManager {
293
302
 
294
303
  this.logger.stopTask(cmd);
295
304
  this.logger.verbose(
296
- `Background process started: npm run ${cmd} (PGID: ${processGroupId})`,
305
+ `Background process started: ${displayCmd} (PGID: ${processGroupId})`,
297
306
  );
298
307
  return {
299
308
  success: true,
@@ -304,7 +313,7 @@ export class ProcessManager {
304
313
  } catch (error) {
305
314
  if (attempt === maxAttempts) {
306
315
  this.logger.error(
307
- `Failed to start background process: npm run ${cmd}`,
316
+ `Failed to start background process: ${displayCmd}`,
308
317
  );
309
318
  this.logger.verbose(
310
319
  `Final verification attempt failed: ${error.message}`,
@@ -382,7 +391,7 @@ export class ProcessManager {
382
391
 
383
392
  if (code !== 0) {
384
393
  this.logger.error(
385
- `Failed: npm run ${cmd} ❌${durationStr} (exit code: ${code})`,
394
+ `Failed: ${displayCmd} ❌${durationStr} (exit code: ${code})`,
386
395
  );
387
396
  this.logger.verbose(`Process output: ${output}`);
388
397
  resolve({
@@ -392,7 +401,7 @@ export class ProcessManager {
392
401
  memoryKb,
393
402
  });
394
403
  } else {
395
- this.logger.success(`Completed: npm run ${cmd} ✅${durationStr}`);
404
+ this.logger.success(`Completed: ${displayCmd} ✅${durationStr}`);
396
405
  resolve({
397
406
  success: true,
398
407
  output,
@@ -507,13 +516,14 @@ export class ProcessManager {
507
516
  );
508
517
 
509
518
  const killPromises = commandProcesses.map(
510
- async ({ command, pgid, url, startedByScript, kill_command }) => {
519
+ async ({ command, pgid, url, startedByScript, kill_command, prefix }) => {
511
520
  await this.cleanupProcess({
512
521
  command,
513
522
  pgid,
514
523
  url,
515
524
  startedByScript,
516
525
  kill_command,
526
+ prefix,
517
527
  });
518
528
  },
519
529
  );
@@ -529,7 +539,7 @@ export class ProcessManager {
529
539
  );
530
540
  }
531
541
 
532
- async cleanupProcess({ command, pgid, url, startedByScript, kill_command }) {
542
+ async cleanupProcess({ command, pgid, url, startedByScript, kill_command, prefix = 'npm run' }) {
533
543
  if (!startedByScript) {
534
544
  this.logger.verbose(
535
545
  `- Skipping cleanup for ${command} (${url}) as it was not started by this script`,
@@ -544,13 +554,15 @@ export class ProcessManager {
544
554
  // Try custom kill command first if specified
545
555
  if (kill_command) {
546
556
  try {
557
+ const killDisplay = prefix ? `${String(prefix).trim()} ${kill_command}` : kill_command;
547
558
  this.logger.verbose(
548
- `- Using custom kill command: npm run ${kill_command}`,
559
+ `- Using custom kill command: ${killDisplay}`,
549
560
  );
550
561
  const result = await this.runCommand({
551
562
  cmd: kill_command,
552
563
  logFile: null,
553
564
  background: false,
565
+ prefix,
554
566
  });
555
567
  if (result.success) {
556
568
  this.logger.verbose(
@@ -1,4 +1,6 @@
1
1
  import path from 'path';
2
+ import fs from 'fs';
3
+ import os from 'os';
2
4
  import { ProcessManager } from './process-manager.js';
3
5
 
4
6
  describe('ProcessManager.getLogPath', () => {
@@ -20,3 +22,59 @@ describe('ProcessManager.getLogPath', () => {
20
22
  expect(result).toBe(path.resolve(override));
21
23
  });
22
24
  });
25
+
26
+ describe('ProcessManager.runCommand prefix handling', () => {
27
+ let tmpDir;
28
+ let prevCwd;
29
+
30
+ beforeEach(() => {
31
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'so-prefix-'));
32
+ prevCwd = process.cwd();
33
+ process.chdir(tmpDir);
34
+ });
35
+
36
+ afterEach(() => {
37
+ process.chdir(prevCwd);
38
+ fs.rmSync(tmpDir, { recursive: true, force: true });
39
+ });
40
+
41
+ test('runs a regular bash command verbatim when prefix is disabled', async () => {
42
+ const pm = new ProcessManager();
43
+ pm.setLogFolder(tmpDir);
44
+ const marker = 'orchestrator-raw-bash-ok';
45
+ const result = await pm.runCommand({
46
+ cmd: `echo ${marker}`,
47
+ background: false,
48
+ prefix: '',
49
+ });
50
+ expect(result.success).toBe(true);
51
+ const logPath = pm.getLogPath(`echo ${marker}`);
52
+ expect(fs.readFileSync(logPath, 'utf8')).toContain(marker);
53
+ });
54
+
55
+ test('honors a custom prefix by invoking it (failure surfaces a non-zero exit)', async () => {
56
+ const pm = new ProcessManager();
57
+ pm.setLogFolder(tmpDir);
58
+ // With prefix 'npm run' and no package.json script, the command must fail —
59
+ // proving the prefix is actually prepended rather than the command run raw.
60
+ const result = await pm.runCommand({
61
+ cmd: 'definitely-not-a-script',
62
+ background: false,
63
+ prefix: 'npm run',
64
+ });
65
+ expect(result.success).toBe(false);
66
+ });
67
+
68
+ test('supports multi-token shell commands (pipes, &&) when run raw', async () => {
69
+ const pm = new ProcessManager();
70
+ pm.setLogFolder(tmpDir);
71
+ const result = await pm.runCommand({
72
+ cmd: 'printf "a\\nb\\nc\\n" | grep b',
73
+ background: false,
74
+ prefix: '',
75
+ });
76
+ expect(result.success).toBe(true);
77
+ const logPath = pm.getLogPath('printf');
78
+ expect(fs.readFileSync(logPath, 'utf8').trim()).toBe('b');
79
+ });
80
+ });
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * @file recommend-phases.js
3
- * @description R12 — memory-aware phase recommender (advisory).
3
+ * @description Memory-aware phase recommender (advisory).
4
4
  *
5
5
  * Reads a results JSON (the same payload the orchestrator writes via `metrics: ['time','memory']`)
6
6
  * and proposes a phase layout that keeps each phase's concurrent peak memory under a per-host
7
- * budget while letting long-running steps overlap. This is the "advisory" first step of R12:
8
- * it only reports — it does not change how a run is scheduled.
7
+ * budget while letting long-running steps overlap. It only reports it does not change how a
8
+ * run is scheduled.
9
9
  *
10
10
  * Algorithm: First-Fit-Decreasing bin-packing by step duration. Steps are sorted longest-first
11
11
  * and each is placed into the earliest phase where adding it keeps Σ(concurrent peak memory) ≤ budget
@@ -26,8 +26,8 @@ const GB = 1024 * 1024 * 1024;
26
26
  * budget = totalmem × memSafety ÷ fanout (overridable wholesale via budgetMb)
27
27
  * coreShare = (cores − 2) ÷ fanout (≥ 1)
28
28
  *
29
- * `fanout` models the workspace-level parallelism (R1's `--parallel=N`): when N workspaces gate
30
- * concurrently they share the host, so each gets 1/N of RAM and cores.
29
+ * `fanout` models the workspace-level parallelism: when N workspaces gate concurrently they share
30
+ * the host, so each gets 1/N of RAM and cores.
31
31
  */
32
32
  export function computeBudget(opts = {}) {
33
33
  const totalMemBytes = opts.totalMemBytes != null ? Number(opts.totalMemBytes) : os.totalmem();
@@ -143,6 +143,18 @@ export function recommendPhases(payload, opts = {}) {
143
143
  const observedMakespanMs = observed.reduce((sum, p) => sum + p.wallclockMs, 0);
144
144
  const recommendedMakespanMs = bins.reduce((sum, b) => sum + b.wallclockMs, 0);
145
145
  const optimalMakespanMs = steps.length ? Math.max(...steps.map((s) => s.durationMs)) : 0;
146
+ const longestStep = steps.length
147
+ ? steps.reduce((a, b) => (b.durationMs > a.durationMs ? b : a))
148
+ : null;
149
+
150
+ const verdict = decideVerdict({
151
+ steps,
152
+ observedMakespanMs,
153
+ recommendedMakespanMs,
154
+ optimalMakespanMs,
155
+ longestStep,
156
+ binCount: bins.length,
157
+ });
146
158
 
147
159
  return {
148
160
  ...budget,
@@ -151,10 +163,70 @@ export function recommendPhases(payload, opts = {}) {
151
163
  observedMakespanMs,
152
164
  recommended: { bins, makespanMs: recommendedMakespanMs },
153
165
  optimalMakespanMs,
166
+ verdict,
154
167
  warnings,
155
168
  };
156
169
  }
157
170
 
171
+ /**
172
+ * Reduce the numbers to a single yes/no answer: "is re-grouping these phases worth it?".
173
+ *
174
+ * Re-grouping helps only when packing meaningfully beats the observed makespan. It cannot beat the
175
+ * single longest step (the theoretical floor), so when one step dominates the makespan the honest
176
+ * answer is "no — splitting that step is the only lever left", not "re-group".
177
+ *
178
+ * Returns `{ worthwhile, savedMs, reason }`. Thresholds are deliberately conservative so the advice
179
+ * stays quiet unless there's a real, non-trivial win.
180
+ */
181
+ export function decideVerdict({
182
+ steps,
183
+ observedMakespanMs,
184
+ recommendedMakespanMs,
185
+ optimalMakespanMs,
186
+ longestStep,
187
+ binCount,
188
+ }) {
189
+ if (!steps.length) {
190
+ return { worthwhile: false, savedMs: 0, reason: 'No timed steps to analyse.' };
191
+ }
192
+
193
+ const savedMs = observedMakespanMs - recommendedMakespanMs;
194
+ const savedFraction = observedMakespanMs > 0 ? savedMs / observedMakespanMs : 0;
195
+ const dominantFraction = observedMakespanMs > 0 ? optimalMakespanMs / observedMakespanMs : 0;
196
+
197
+ // A real win: packing trims at least 5% AND at least 5s off the observed makespan.
198
+ const significant = savedMs >= 5000 && savedFraction >= 0.05;
199
+ if (significant) {
200
+ return {
201
+ worthwhile: true,
202
+ savedMs,
203
+ reason:
204
+ `Re-grouping into ${binCount} phase(s) could trim ~${fmtDuration(savedMs)} ` +
205
+ `(${Math.round(savedFraction * 100)}%) off the makespan.`,
206
+ };
207
+ }
208
+
209
+ // One step is ≥95% of the makespan: nothing else matters until it's broken up.
210
+ if (dominantFraction >= 0.95 && longestStep) {
211
+ return {
212
+ worthwhile: false,
213
+ savedMs,
214
+ reason:
215
+ `One step ("${longestStep.command}", ${fmtDuration(optimalMakespanMs)}) is ` +
216
+ `~${Math.round(dominantFraction * 100)}% of the makespan, so re-grouping the rest cannot help. ` +
217
+ 'To go faster, split that step into smaller commands the orchestrator can schedule separately.',
218
+ };
219
+ }
220
+
221
+ return {
222
+ worthwhile: false,
223
+ savedMs,
224
+ reason:
225
+ `The current layout is already within ~${fmtDuration(Math.max(0, savedMs))} of the packed ` +
226
+ 'optimum — re-grouping is not worth it.',
227
+ };
228
+ }
229
+
158
230
  // ---- formatting helpers ---------------------------------------------------
159
231
 
160
232
  export function fmtDuration(ms) {
@@ -185,7 +257,7 @@ export function formatRecommendationReport(rec, { sourcePath = null } = {}) {
185
257
  const c = chalk;
186
258
  const L = [];
187
259
 
188
- L.push(c.bold('🧮 Scripts-Orchestrator — memory-aware phase recommendation (R12, advisory)'));
260
+ L.push(c.bold('🧮 Scripts-Orchestrator — memory-aware phase recommendation (advisory)'));
189
261
  if (sourcePath) L.push(c.dim(` Source: ${sourcePath}`));
190
262
  L.push(
191
263
  ` Budget: ${c.yellow(fmtMemKb(rec.budgetBytes / KB))} ` +
@@ -194,7 +266,11 @@ export function formatRecommendationReport(rec, { sourcePath = null } = {}) {
194
266
  );
195
267
 
196
268
  for (const w of rec.warnings) L.push(c.yellow(` ⚠ ${w}`));
197
- if (rec.steps.length === 0) return L.join('\n');
269
+ if (rec.steps.length === 0) {
270
+ L.push('');
271
+ L.push(`${c.bold('Verdict:')} ${verdictLine(rec.verdict)}`);
272
+ return L.join('\n');
273
+ }
198
274
 
199
275
  // Observed timeline
200
276
  L.push('');
@@ -239,6 +315,11 @@ export function formatRecommendationReport(rec, { sourcePath = null } = {}) {
239
315
  : c.dim('(no change)');
240
316
  L.push(` recommended (packed): ${fmtDuration(rec.recommended.makespanMs)} ${delta}`);
241
317
  L.push(` theoretical floor (∞ RAM): ${fmtDuration(rec.optimalMakespanMs)}`);
318
+
319
+ // Verdict — the one-line yes/no the reader actually wants.
320
+ L.push('');
321
+ L.push(`${c.bold('Verdict:')} ${verdictLine(rec.verdict)}`);
322
+
242
323
  L.push('');
243
324
  L.push(
244
325
  c.dim(
@@ -249,3 +330,11 @@ export function formatRecommendationReport(rec, { sourcePath = null } = {}) {
249
330
 
250
331
  return L.join('\n');
251
332
  }
333
+
334
+ /** Render the verdict as a colored ✅/❌ one-liner. */
335
+ function verdictLine(verdict) {
336
+ if (!verdict) return '';
337
+ return verdict.worthwhile
338
+ ? `${chalk.green('✅ Yes')} — ${verdict.reason}`
339
+ : `${chalk.yellow('❌ No')} — ${verdict.reason}`;
340
+ }
@@ -4,6 +4,7 @@ import {
4
4
  observedTimeline,
5
5
  packPhases,
6
6
  recommendPhases,
7
+ decideVerdict,
7
8
  formatRecommendationReport,
8
9
  } from './recommend-phases.js';
9
10
 
@@ -104,4 +105,60 @@ describe('recommendPhases', () => {
104
105
  expect(rec.warnings.join(' ')).toMatch(/nothing to recommend/i);
105
106
  expect(() => formatRecommendationReport(rec)).not.toThrow();
106
107
  });
108
+
109
+ test('attaches a verdict object to the recommendation', () => {
110
+ const rec = recommendPhases(payload, { totalMemBytes: 16 * 1024 ** 3, cores: 10, fanout: 1 });
111
+ expect(rec.verdict).toBeDefined();
112
+ expect(typeof rec.verdict.worthwhile).toBe('boolean');
113
+ expect(typeof rec.verdict.reason).toBe('string');
114
+ });
115
+ });
116
+
117
+ describe('decideVerdict', () => {
118
+ const longest = { command: 'build', durationMs: 600000 };
119
+
120
+ test('says yes when packing trims a meaningful chunk off the makespan', () => {
121
+ const v = decideVerdict({
122
+ steps: [{}, {}, {}],
123
+ observedMakespanMs: 100000,
124
+ recommendedMakespanMs: 70000, // 30s / 30% saved
125
+ optimalMakespanMs: 60000,
126
+ longestStep: longest,
127
+ binCount: 2,
128
+ });
129
+ expect(v.worthwhile).toBe(true);
130
+ expect(v.savedMs).toBe(30000);
131
+ });
132
+
133
+ test('says no — and points at the monolith — when one step dominates', () => {
134
+ const v = decideVerdict({
135
+ steps: [{}, {}],
136
+ observedMakespanMs: 610000,
137
+ recommendedMakespanMs: 610000,
138
+ optimalMakespanMs: 600000, // ~98% of makespan
139
+ longestStep: longest,
140
+ binCount: 1,
141
+ });
142
+ expect(v.worthwhile).toBe(false);
143
+ expect(v.reason).toMatch(/split that step/i);
144
+ expect(v.reason).toContain('build');
145
+ });
146
+
147
+ test('says no when the saving is below the threshold', () => {
148
+ const v = decideVerdict({
149
+ steps: [{}, {}, {}, {}],
150
+ observedMakespanMs: 100000,
151
+ recommendedMakespanMs: 98000, // only 2% / 2s
152
+ optimalMakespanMs: 40000,
153
+ longestStep: longest,
154
+ binCount: 2,
155
+ });
156
+ expect(v.worthwhile).toBe(false);
157
+ expect(v.reason).toMatch(/isn't worth it|within/i);
158
+ });
159
+
160
+ test('handles the no-steps case', () => {
161
+ const v = decideVerdict({ steps: [], observedMakespanMs: 0, recommendedMakespanMs: 0, optimalMakespanMs: 0, longestStep: null, binCount: 0 });
162
+ expect(v.worthwhile).toBe(false);
163
+ });
107
164
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scripts-orchestrator",
3
- "version": "2.15.0",
3
+ "version": "3.0.0",
4
4
  "description": "A powerful script orchestrator for running parallel commands with dependency management, background processes, and health checks",
5
5
  "main": "lib/index.js",
6
6
  "type": "module",
@@ -1,4 +1,8 @@
1
1
  export default {
2
+ // Optional: prefix prepended to every command. Defaults to 'npm run'.
3
+ // Set to '' (or false/null) to run commands verbatim as regular shell commands,
4
+ // or to another runner like 'pnpm run'. Per-command `shell: true` / `prefix` override this.
5
+ // command_prefix: 'npm run',
2
6
  // Optional: metrics to report (time, memory). CLI --metrics overrides.
3
7
  // metrics: ['time'],
4
8
  // Optional: path for JSON results, or '-' for stdout. CLI --json-results overrides.