omnilane 0.8.3 → 0.9.1

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/bin/omnilane-mcp CHANGED
@@ -9,6 +9,7 @@ const { spawn } = require('child_process');
9
9
  const ROOT = path.resolve(__dirname, '..');
10
10
  const DISPATCH_SCRIPT = path.join(ROOT, 'scripts', 'dispatch.sh');
11
11
  const JOBS_SCRIPT = path.join(ROOT, 'scripts', 'jobs.sh');
12
+ const DOCTOR_SCRIPT = path.join(ROOT, 'scripts', 'doctor.sh');
12
13
  const LATEST_PROTOCOL_VERSION = '2025-11-25';
13
14
  const SUPPORTED_PROTOCOL_VERSIONS = new Set([
14
15
  '2025-11-25',
@@ -146,6 +147,196 @@ const tools = [
146
147
  openWorldHint: false,
147
148
  },
148
149
  },
150
+ {
151
+ name: 'explain',
152
+ description: 'Explain one lane offline: every fallback candidate and the selected vendor, with no provider call and no job state.',
153
+ inputSchema: {
154
+ type: 'object',
155
+ properties: {
156
+ lane: {
157
+ type: 'string',
158
+ pattern: '^[a-z][a-z0-9-]*$',
159
+ description: 'Configured omnilane lane name.',
160
+ },
161
+ json: {
162
+ type: 'boolean',
163
+ default: false,
164
+ description: 'Return the versioned JSON envelope instead of the human table.',
165
+ },
166
+ },
167
+ required: ['lane'],
168
+ additionalProperties: false,
169
+ },
170
+ annotations: {
171
+ readOnlyHint: true,
172
+ destructiveHint: false,
173
+ idempotentHint: true,
174
+ openWorldHint: false,
175
+ },
176
+ },
177
+ {
178
+ name: 'validate',
179
+ description: 'Lint the effective routing table offline and report PASS/WARN/FAIL per lane, with no provider call and no job state.',
180
+ inputSchema: {
181
+ type: 'object',
182
+ properties: {
183
+ json: {
184
+ type: 'boolean',
185
+ default: false,
186
+ description: 'Return the versioned JSON envelope instead of the human report.',
187
+ },
188
+ },
189
+ additionalProperties: false,
190
+ },
191
+ annotations: {
192
+ readOnlyHint: true,
193
+ destructiveHint: false,
194
+ idempotentHint: true,
195
+ openWorldHint: false,
196
+ },
197
+ },
198
+ {
199
+ name: 'dry_run',
200
+ description: 'Resolve a dispatch plan (vendor, model, mode, timeouts, side-effect decision) without calling any provider or creating job state. Work mode requires an explicit workdir.',
201
+ inputSchema: {
202
+ type: 'object',
203
+ properties: {
204
+ lane: {
205
+ type: 'string',
206
+ pattern: '^[a-z][a-z0-9-]*$',
207
+ description: 'Configured omnilane lane name.',
208
+ },
209
+ task: { type: 'string', description: 'Task text used only to resolve the plan; it is never sent to a provider.' },
210
+ mode: {
211
+ type: 'string',
212
+ enum: ['advise', 'work'],
213
+ default: 'advise',
214
+ description: 'Read-only advice or write-enabled work.',
215
+ },
216
+ workdir: {
217
+ type: 'string',
218
+ minLength: 1,
219
+ description: 'Working directory. Required when mode is work.',
220
+ },
221
+ vendor: { type: 'string', description: 'Optional configured vendor override.' },
222
+ model: { type: 'string', description: 'Optional routed model override.' },
223
+ effort: { type: 'string', description: 'Optional routed effort override.' },
224
+ timeout: {
225
+ type: 'integer',
226
+ minimum: 1,
227
+ description: 'Per-call timeout in seconds forwarded to dispatch.sh.',
228
+ },
229
+ },
230
+ required: ['lane', 'task'],
231
+ additionalProperties: false,
232
+ allOf: [
233
+ {
234
+ if: { properties: { mode: { const: 'work' } }, required: ['mode'] },
235
+ then: { required: ['workdir'] },
236
+ },
237
+ ],
238
+ },
239
+ annotations: {
240
+ readOnlyHint: true,
241
+ destructiveHint: false,
242
+ idempotentHint: true,
243
+ openWorldHint: false,
244
+ },
245
+ },
246
+ {
247
+ name: 'jobs_list',
248
+ description: 'List omnilane background jobs. Metadata only; task and result bodies stay private.',
249
+ inputSchema: {
250
+ type: 'object',
251
+ properties: {
252
+ json: {
253
+ type: 'boolean',
254
+ default: false,
255
+ description: 'Return the versioned JSON envelope instead of the human list.',
256
+ },
257
+ },
258
+ additionalProperties: false,
259
+ },
260
+ annotations: {
261
+ readOnlyHint: true,
262
+ destructiveHint: false,
263
+ idempotentHint: true,
264
+ openWorldHint: false,
265
+ },
266
+ },
267
+ {
268
+ name: 'doctor',
269
+ description: 'Read-only local health report for routing, state, watchdog, and optional UI support.',
270
+ inputSchema: {
271
+ type: 'object',
272
+ properties: {
273
+ json: {
274
+ type: 'boolean',
275
+ default: false,
276
+ description: 'Return the versioned JSON envelope instead of the human report.',
277
+ },
278
+ },
279
+ additionalProperties: false,
280
+ },
281
+ annotations: {
282
+ readOnlyHint: true,
283
+ destructiveHint: false,
284
+ idempotentHint: true,
285
+ openWorldHint: false,
286
+ },
287
+ },
288
+ {
289
+ name: 'jobs_stats',
290
+ description: 'Aggregate local background-job outcomes and lane/vendor distribution from bounded public metadata. Never reads task or result bodies.',
291
+ inputSchema: {
292
+ type: 'object',
293
+ properties: {
294
+ last: {
295
+ type: 'integer',
296
+ minimum: 1,
297
+ description: 'Limit the aggregate to the most recent N jobs.',
298
+ },
299
+ json: {
300
+ type: 'boolean',
301
+ default: false,
302
+ description: 'Return the versioned JSON envelope instead of the human report.',
303
+ },
304
+ },
305
+ additionalProperties: false,
306
+ },
307
+ annotations: {
308
+ readOnlyHint: true,
309
+ destructiveHint: false,
310
+ idempotentHint: true,
311
+ openWorldHint: false,
312
+ },
313
+ },
314
+ {
315
+ name: 'jobs_audit',
316
+ description: 'Bounded read-only integrity and privacy scan of the local job store. Reports findings without printing task or result content.',
317
+ inputSchema: {
318
+ type: 'object',
319
+ properties: {
320
+ last: {
321
+ type: 'integer',
322
+ minimum: 1,
323
+ description: 'Limit the scan to the most recent N jobs.',
324
+ },
325
+ json: {
326
+ type: 'boolean',
327
+ default: false,
328
+ description: 'Return the versioned JSON envelope instead of the human report.',
329
+ },
330
+ },
331
+ additionalProperties: false,
332
+ },
333
+ annotations: {
334
+ readOnlyHint: true,
335
+ destructiveHint: false,
336
+ idempotentHint: true,
337
+ openWorldHint: false,
338
+ },
339
+ },
149
340
  ];
150
341
 
151
342
  class RpcError extends Error {
@@ -237,6 +428,60 @@ function validateJobArguments(args) {
237
428
  return null;
238
429
  }
239
430
 
431
+ function validateJsonOnlyArguments(args) {
432
+ const error = validationError(args, ['json']);
433
+ if (error) return error;
434
+ if (OWN(args, 'json') && typeof args.json !== 'boolean') return 'json must be a boolean';
435
+ return null;
436
+ }
437
+
438
+ function validateJobsQueryArguments(args) {
439
+ const error = validationError(args, ['last', 'json']);
440
+ if (error) return error;
441
+ if (OWN(args, 'last') && (!Number.isInteger(args.last) || args.last < 1)) {
442
+ return 'last must be a positive integer';
443
+ }
444
+ if (OWN(args, 'json') && typeof args.json !== 'boolean') return 'json must be a boolean';
445
+ return null;
446
+ }
447
+
448
+ function validateExplainArguments(args) {
449
+ const error = validationError(args, ['lane', 'json']);
450
+ if (error) return error;
451
+ if (typeof args.lane !== 'string' || !LANE_PATTERN.test(args.lane)) {
452
+ return 'lane must match ^[a-z][a-z0-9-]*$';
453
+ }
454
+ if (OWN(args, 'json') && typeof args.json !== 'boolean') return 'json must be a boolean';
455
+ return null;
456
+ }
457
+
458
+ function validateDryRunArguments(args) {
459
+ const error = validationError(args, [
460
+ 'lane', 'task', 'mode', 'workdir', 'vendor', 'model', 'effort', 'timeout',
461
+ ]);
462
+ if (error) return error;
463
+ if (typeof args.lane !== 'string' || !LANE_PATTERN.test(args.lane)) {
464
+ return 'lane must match ^[a-z][a-z0-9-]*$';
465
+ }
466
+ if (typeof args.task !== 'string') return 'task must be a string';
467
+ if (OWN(args, 'mode') && args.mode !== 'advise' && args.mode !== 'work') {
468
+ return 'mode must be advise or work';
469
+ }
470
+ if (OWN(args, 'workdir') && (typeof args.workdir !== 'string' || args.workdir.length === 0)) {
471
+ return 'workdir must be a non-empty string';
472
+ }
473
+ for (const key of ['vendor', 'model', 'effort']) {
474
+ if (OWN(args, key) && typeof args[key] !== 'string') return `${key} must be a string`;
475
+ }
476
+ if (OWN(args, 'timeout') && (!Number.isInteger(args.timeout) || args.timeout < 1)) {
477
+ return 'timeout must be a positive integer';
478
+ }
479
+ if (args.mode === 'work' && !OWN(args, 'workdir')) {
480
+ return 'work mode requires an explicit workdir';
481
+ }
482
+ return null;
483
+ }
484
+
240
485
  function createCapture() {
241
486
  const chunks = { stdout: [], stderr: [] };
242
487
  let collected = 0;
@@ -363,6 +608,52 @@ async function callTool(name, args) {
363
608
  const command = name === 'jobs_status' ? 'status' : 'result';
364
609
  return runToolScript(name, JOBS_SCRIPT, [command, args.id]);
365
610
  }
611
+ if (name === 'jobs_list') {
612
+ const error = validateJsonOnlyArguments(args);
613
+ if (error) return toolError(error);
614
+ const argv = args.json === true ? ['--json', 'list'] : ['list'];
615
+ return runToolScript('jobs_list', JOBS_SCRIPT, argv);
616
+ }
617
+ if (name === 'explain') {
618
+ const error = validateExplainArguments(args);
619
+ if (error) return toolError(error);
620
+ const argv = ['--explain', args.lane];
621
+ if (args.json === true) argv.push('--json');
622
+ return runToolScript('explain', DISPATCH_SCRIPT, argv);
623
+ }
624
+ if (name === 'validate') {
625
+ const error = validateJsonOnlyArguments(args);
626
+ if (error) return toolError(error);
627
+ const argv = ['--validate'];
628
+ if (args.json === true) argv.push('--json');
629
+ return runToolScript('validate', DISPATCH_SCRIPT, argv);
630
+ }
631
+ if (name === 'dry_run') {
632
+ const error = validateDryRunArguments(args);
633
+ if (error) return toolError(error);
634
+ const mode = OWN(args, 'mode') ? args.mode : 'advise';
635
+ const argv = ['--dry-run', '--mode', mode];
636
+ for (const key of ['workdir', 'vendor', 'model', 'effort', 'timeout']) {
637
+ if (OWN(args, key)) argv.push(`--${key}`, String(args[key]));
638
+ }
639
+ argv.push(args.lane, args.task);
640
+ return runToolScript('dry_run', DISPATCH_SCRIPT, argv);
641
+ }
642
+ if (name === 'doctor') {
643
+ const error = validateJsonOnlyArguments(args);
644
+ if (error) return toolError(error);
645
+ const argv = args.json === true ? ['--json'] : [];
646
+ return runToolScript('doctor', DOCTOR_SCRIPT, argv);
647
+ }
648
+ if (name === 'jobs_stats' || name === 'jobs_audit') {
649
+ const error = validateJobsQueryArguments(args);
650
+ if (error) return toolError(error);
651
+ const argv = [];
652
+ if (args.json === true) argv.push('--json');
653
+ argv.push(name === 'jobs_stats' ? 'stats' : 'audit');
654
+ if (OWN(args, 'last')) argv.push('--last', String(args.last));
655
+ return runToolScript(name, JOBS_SCRIPT, argv);
656
+ }
366
657
  throw new RpcError(-32602, `Unknown tool: ${name}`);
367
658
  }
368
659
 
@@ -0,0 +1,50 @@
1
+ # Fish completion for omnilane. Static command/option completion only; it never
2
+ # invokes dispatch, provider CLIs, or the executable machine-local overlay.
3
+ # Dynamic lane-name and job-id completion remain Bash/Zsh-only for now.
4
+
5
+ # Top-level subcommands (offered only before a subcommand is chosen).
6
+ complete -c omnilane -f -n __fish_use_subcommand -a version -d 'installed release version'
7
+ complete -c omnilane -f -n __fish_use_subcommand -a list -d 'effective routing table'
8
+ complete -c omnilane -f -n __fish_use_subcommand -a route -d 'dispatch or consult a model'
9
+ complete -c omnilane -f -n __fish_use_subcommand -a dispatch -d 'dispatch or consult a model'
10
+ complete -c omnilane -f -n __fish_use_subcommand -a jobs -d 'inspect background jobs'
11
+ complete -c omnilane -f -n __fish_use_subcommand -a mcp -d 'MCP stdio server'
12
+ complete -c omnilane -f -n __fish_use_subcommand -a doctor -d 'read-only health report'
13
+ complete -c omnilane -f -n __fish_use_subcommand -a release-audit -d 'offline release gate'
14
+ complete -c omnilane -f -n __fish_use_subcommand -a ui -d 'Live Board server'
15
+ complete -c omnilane -f -n __fish_use_subcommand -a configure -d 'lane routing (menu or set/get/unset/list/diff)'
16
+ complete -c omnilane -f -n __fish_use_subcommand -a completion -d 'print a shell completion script'
17
+ complete -c omnilane -f -n __fish_use_subcommand -a help -d 'usage'
18
+
19
+ # route / dispatch options.
20
+ complete -c omnilane -n '__fish_seen_subcommand_from route dispatch' -l mode -x -a 'advise work' -d 'read-only advise or write-enabled work'
21
+ complete -c omnilane -n '__fish_seen_subcommand_from route dispatch' -l vendor -x -a 'codex claude grok gemini kimi qwen opencode openrouter deepseek zai mistral groq cerebras exec vote' -d 'pin one vendor, no fallback'
22
+ complete -c omnilane -n '__fish_seen_subcommand_from route dispatch' -l effort -x -a 'low medium high xhigh max' -d 'reasoning effort'
23
+ complete -c omnilane -n '__fish_seen_subcommand_from route dispatch' -l workdir -r -d 'working directory'
24
+ complete -c omnilane -n '__fish_seen_subcommand_from route dispatch' -l model -x -d 'routed model override'
25
+ complete -c omnilane -n '__fish_seen_subcommand_from route dispatch' -l timeout -x -d 'per-call timeout in seconds'
26
+ complete -c omnilane -n '__fish_seen_subcommand_from route dispatch' -l job-timeout -x -d 'whole-job timeout in seconds'
27
+ complete -c omnilane -n '__fish_seen_subcommand_from route dispatch' -l background -d 'run as a background job'
28
+ complete -c omnilane -n '__fish_seen_subcommand_from route dispatch' -l dry-run -d 'resolve the plan and stop'
29
+
30
+ # jobs subcommands (only before a jobs subcommand is chosen).
31
+ complete -c omnilane -f \
32
+ -n '__fish_seen_subcommand_from jobs; and not __fish_seen_subcommand_from list status result tail retry stats wait audit prune cancel rm help' \
33
+ -a 'list status result tail retry stats wait audit prune cancel rm help' -d 'jobs subcommand'
34
+ complete -c omnilane -n '__fish_seen_subcommand_from jobs' -l json -d 'JSON output'
35
+ # jobs list / stats filters.
36
+ complete -c omnilane -n '__fish_seen_subcommand_from jobs; and __fish_seen_subcommand_from list stats' -l lane -x -d 'filter by lane'
37
+ complete -c omnilane -n '__fish_seen_subcommand_from jobs; and __fish_seen_subcommand_from list stats' -l vendor -x -a 'codex claude grok gemini kimi qwen opencode openrouter deepseek zai mistral groq cerebras exec' -d 'filter by vendor'
38
+ complete -c omnilane -n '__fish_seen_subcommand_from jobs; and __fish_seen_subcommand_from list' -l status -x -a 'running done' -d 'filter by status'
39
+
40
+ # configure subcommands (non-interactive).
41
+ complete -c omnilane -f -n '__fish_seen_subcommand_from configure; and not __fish_seen_subcommand_from set get unset list diff' -a 'set get unset list diff' -d 'configure action'
42
+
43
+ # doctor / release-audit / ui / completion.
44
+ complete -c omnilane -n '__fish_seen_subcommand_from doctor' -l json -d 'JSON output'
45
+ complete -c omnilane -n '__fish_seen_subcommand_from release-audit' -l target -x -d 'target version'
46
+ complete -c omnilane -n '__fish_seen_subcommand_from release-audit' -l allow-dirty -d 'permit a dirty tree'
47
+ complete -c omnilane -n '__fish_seen_subcommand_from release-audit' -l require-tag -d 'require an annotated tag'
48
+ complete -c omnilane -n '__fish_seen_subcommand_from release-audit' -l json -d 'JSON output'
49
+ complete -c omnilane -f -n '__fish_seen_subcommand_from ui' -a 'start status url stop' -d 'Live Board action'
50
+ complete -c omnilane -f -n '__fish_seen_subcommand_from completion' -a 'bash zsh fish' -d 'target shell'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omnilane",
3
- "version": "0.8.3",
3
+ "version": "0.9.1",
4
4
  "description": "One routing table, every harness — classify subtasks into lanes and dispatch each lane to the best vendor's agentic CLI (Codex, Claude, Gemini, Grok) using your existing subscription logins.",
5
5
  "bin": {
6
6
  "omnilane": "bin/omnilane"
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env bash
2
+ set -uo pipefail
3
+ # omnilane self-check: run the CONTRIBUTING required checks in one command.
4
+ # Usage: check.sh [--quick] [REPO_DIR]
5
+ # --quick skip the two slow suite runs (python unittest, tests/run.sh)
6
+ # A SKIP (tool unavailable or target absent) is not a failure; the script exits
7
+ # non-zero only when a check actually FAILs. No -e: checks may fail and are
8
+ # tallied rather than aborting the run.
9
+
10
+ QUICK=0
11
+ REPO=""
12
+ while [[ $# -gt 0 ]]; do
13
+ case "$1" in
14
+ --quick) QUICK=1; shift ;;
15
+ -h|--help) echo "usage: check.sh [--quick] [REPO_DIR]"; exit 0 ;;
16
+ --) shift; break ;;
17
+ -*) echo "check.sh: unknown option: $1" >&2; exit 2 ;;
18
+ *) if [[ -n "$REPO" ]]; then echo "check.sh: too many arguments" >&2; exit 2; fi
19
+ REPO="$1"; shift ;;
20
+ esac
21
+ done
22
+ if [[ $# -gt 0 ]]; then
23
+ if [[ -n "$REPO" ]]; then echo "check.sh: too many arguments" >&2; exit 2; fi
24
+ REPO="$1"
25
+ fi
26
+ [[ -n "$REPO" ]] || REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
27
+
28
+ PASS_N=0; FAIL_N=0; SKIP_N=0
29
+ _pass() { PASS_N=$((PASS_N + 1)); printf 'PASS %s\n' "$1"; }
30
+ _skip() { SKIP_N=$((SKIP_N + 1)); printf 'SKIP %s (%s)\n' "$1" "$2"; }
31
+ _fail() { FAIL_N=$((FAIL_N + 1)); printf 'FAIL %s\n' "$1"; [[ -z "${2:-}" ]] || printf ' %s\n' "$2"; }
32
+
33
+ # Shell files present in the repo (CONTRIBUTING's bash -n / shellcheck set).
34
+ shell_files=()
35
+ for pattern in 'bin/omnilane' 'scripts/*.sh' 'scripts/lib/*.sh' 'scripts/runners/*.sh' 'install.sh'; do
36
+ # shellcheck disable=SC2086 # $pattern is an intentional glob against $REPO
37
+ for f in $REPO/$pattern; do
38
+ [[ -f "$f" ]] && shell_files+=("$f")
39
+ done
40
+ done
41
+
42
+ # 1) bash -n on every tracked shell file
43
+ if [[ ${#shell_files[@]} -eq 0 ]]; then
44
+ _skip bash-syntax "no shell files found"
45
+ else
46
+ bad=""
47
+ for f in "${shell_files[@]}"; do
48
+ bash -n "$f" 2>/dev/null || bad="$bad ${f##*/}"
49
+ done
50
+ if [[ -z "$bad" ]]; then _pass bash-syntax; else _fail bash-syntax "invalid:$bad"; fi
51
+ fi
52
+
53
+ # 2) shellcheck -S warning (skip if unavailable)
54
+ if ! command -v shellcheck >/dev/null 2>&1; then
55
+ _skip shellcheck "not installed"
56
+ elif [[ ${#shell_files[@]} -eq 0 ]]; then
57
+ _skip shellcheck "no shell files"
58
+ elif shellcheck -S warning "${shell_files[@]}" >/dev/null 2>&1; then
59
+ _pass shellcheck
60
+ else
61
+ _fail shellcheck "shellcheck -S warning reported findings"
62
+ fi
63
+
64
+ # 3) perl -c on the job-timeout supervisor
65
+ perl_file="$REPO/scripts/lib/job-timeout.pl"
66
+ if ! command -v perl >/dev/null 2>&1; then
67
+ _skip perl-syntax "perl not installed"
68
+ elif [[ ! -f "$perl_file" ]]; then
69
+ _skip perl-syntax "job-timeout.pl absent"
70
+ elif perl -c "$perl_file" >/dev/null 2>&1; then
71
+ _pass perl-syntax
72
+ else
73
+ _fail perl-syntax "perl -c failed"
74
+ fi
75
+
76
+ # 4) python compile of the UI and test modules
77
+ py_files=()
78
+ for rel in scripts/ui.py tests/test_ui.py tests/test_ci_policy.py tests/ui_browser_harness.py; do
79
+ [[ -f "$REPO/$rel" ]] && py_files+=("$REPO/$rel")
80
+ done
81
+ if ! command -v python3 >/dev/null 2>&1; then
82
+ _skip py-compile "python3 not installed"
83
+ elif [[ ${#py_files[@]} -eq 0 ]]; then
84
+ _skip py-compile "no python files"
85
+ elif python3 -m py_compile "${py_files[@]}" >/dev/null 2>&1; then
86
+ _pass py-compile
87
+ else
88
+ _fail py-compile "py_compile failed"
89
+ fi
90
+
91
+ # 5) python unit tests (slow; --quick skips)
92
+ if [[ "$QUICK" -eq 1 ]]; then
93
+ _skip unittest "--quick"
94
+ elif ! command -v python3 >/dev/null 2>&1; then
95
+ _skip unittest "python3 not installed"
96
+ elif [[ ! -d "$REPO/tests" ]]; then
97
+ _skip unittest "no tests dir"
98
+ elif ( cd "$REPO" && python3 -m unittest discover -s tests -p 'test_*.py' >/dev/null 2>&1 ); then
99
+ _pass unittest
100
+ else
101
+ _fail unittest "python unittest suite failed"
102
+ fi
103
+
104
+ # 6) shell test suite (slow; --quick skips)
105
+ if [[ "$QUICK" -eq 1 ]]; then
106
+ _skip shell-suite "--quick"
107
+ elif [[ ! -f "$REPO/tests/run.sh" ]]; then
108
+ _skip shell-suite "tests/run.sh absent"
109
+ elif bash "$REPO/tests/run.sh" >/dev/null 2>&1; then
110
+ _pass shell-suite
111
+ else
112
+ _fail shell-suite "bash tests/run.sh failed"
113
+ fi
114
+
115
+ # 7) effective routing table resolves
116
+ if [[ ! -f "$REPO/scripts/dispatch.sh" ]]; then
117
+ _skip dispatch-list "dispatch.sh absent"
118
+ elif bash "$REPO/scripts/dispatch.sh" --list >/dev/null 2>&1; then
119
+ _pass dispatch-list
120
+ else
121
+ _fail dispatch-list "dispatch.sh --list failed"
122
+ fi
123
+
124
+ printf '\nsummary: %d passed, %d skipped, %d failed\n' "$PASS_N" "$SKIP_N" "$FAIL_N"
125
+ [[ "$FAIL_N" -eq 0 ]]