@welluable/orch 1.2.0 → 1.4.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.
@@ -1,4 +1,4 @@
1
- import { Agent } from './agent.js';
1
+ import { Agent, appendVerbose } from './agent.js';
2
2
  import { normalizeCursorToolEvent } from './tool-status.js';
3
3
 
4
4
  export class AgentCursor extends Agent {
@@ -26,8 +26,10 @@ export class AgentCursor extends Agent {
26
26
  case 'thinking': {
27
27
  switch (event.subtype) {
28
28
  case 'delta': {
29
+ const text = event.text ?? '';
30
+ appendVerbose(text);
29
31
  if (verbose) {
30
- process.stderr.write(event.text ?? '');
32
+ process.stderr.write(text);
31
33
  } else if (this.activeTools.size === 0) {
32
34
  this.setStatus('thinking…');
33
35
  }
@@ -0,0 +1,177 @@
1
+ import { Agent, modelPrintState, appendVerbose } from './agent.js';
2
+ import { normalizeOpencodeToolEvent } from './tool-status.js';
3
+
4
+ const OPENCODE_DENY_PERMISSION = '{"edit":"deny","bash":"deny"}';
5
+
6
+ export class AgentOpencode extends Agent {
7
+ #textBuffer = '';
8
+ #sawStepStart = false;
9
+ #pendingStop = false;
10
+ #settled = false;
11
+
12
+ getSpawnConfig(promptToSend) {
13
+ if (this.readOnly) {
14
+ return {
15
+ command: 'opencode',
16
+ args: ['run', '--format', 'json', '--auto', '--thinking', '--agent', 'plan', promptToSend],
17
+ options: {
18
+ cwd: this.cwd,
19
+ stdio: ['ignore', 'pipe', 'pipe'],
20
+ env: { ...process.env, OPENCODE_PERMISSION: OPENCODE_DENY_PERMISSION },
21
+ },
22
+ };
23
+ }
24
+
25
+ return {
26
+ command: 'opencode',
27
+ args: ['run', '--format', 'json', '--auto', '--thinking', promptToSend],
28
+ options: {
29
+ cwd: this.cwd,
30
+ stdio: ['ignore', 'pipe', 'pipe'],
31
+ env: process.env,
32
+ },
33
+ };
34
+ }
35
+
36
+ #printModelOnce(modelId) {
37
+ if (modelPrintState.printed || typeof modelId !== 'string' || !modelId) return;
38
+ modelPrintState.printed = true;
39
+ const wasSpinning = this.spinner?.isSpinning;
40
+ if (wasSpinning) this.spinner.stop?.();
41
+ console.log(`model: ${modelId}`);
42
+ if (wasSpinning) this.spinner.start?.();
43
+ }
44
+
45
+ #printUsageLine(part) {
46
+ const tokens = part?.tokens;
47
+ if (!tokens || typeof tokens !== 'object') return;
48
+ const input = tokens.input ?? tokens.in;
49
+ const output = tokens.output ?? tokens.out;
50
+ if (typeof input !== 'number' && typeof output !== 'number') return;
51
+
52
+ let line = ` tokens: in=${input ?? 0} out=${output ?? 0}`;
53
+ const cost = part.cost;
54
+ if (typeof cost === 'number' && cost > 0) {
55
+ line += ` · cost=$${cost}`;
56
+ }
57
+ process.stderr.write(`${line}\n`);
58
+ }
59
+
60
+ #settleOk(finish, { verbose = false, part = null } = {}) {
61
+ if (this.#settled) return;
62
+ this.#settled = true;
63
+ this.#pendingStop = false;
64
+ this.settleResult(
65
+ {
66
+ is_error: false,
67
+ result: this.#textBuffer,
68
+ duration_ms: undefined,
69
+ },
70
+ finish,
71
+ );
72
+ if (verbose && part) this.#printUsageLine(part);
73
+ }
74
+
75
+ #recordApplyPatchFiles(toolEvent) {
76
+ if (toolEvent.name !== 'apply_patch' || !this.fileTracker) return;
77
+ const entries = this.fileTracker.recordApplyPatch(toolEvent.args?.patchText);
78
+ for (const entry of entries) {
79
+ if (!entry.isNew) continue;
80
+ const wasSpinning = this.spinner?.isSpinning;
81
+ if (wasSpinning) this.spinner.stop();
82
+ console.log(` ${entry.marker} ${entry.path}`);
83
+ if (wasSpinning) this.spinner.start();
84
+ this.onFileChange?.(entry);
85
+ }
86
+ }
87
+
88
+ handleStreamEvent(event, { verbose, finish }) {
89
+ switch (event.type) {
90
+ case 'message.updated': {
91
+ this.#printModelOnce(event.properties?.info?.modelID);
92
+ break;
93
+ }
94
+ case 'step_start': {
95
+ if (!this.#sawStepStart) {
96
+ this.#sawStepStart = true;
97
+ this.setStatus('connected');
98
+ } else if (this.activeTools.size === 0) {
99
+ this.setStatus('thinking…');
100
+ }
101
+ break;
102
+ }
103
+ case 'tool_use': {
104
+ const toolEvent = normalizeOpencodeToolEvent(event);
105
+ if (!toolEvent) break;
106
+ this.onToolEvent({ ...toolEvent, phase: 'started' });
107
+ this.onToolEvent(toolEvent);
108
+ this.#recordApplyPatchFiles(toolEvent);
109
+ break;
110
+ }
111
+ case 'reasoning': {
112
+ const text = event.part?.text ?? '';
113
+ appendVerbose(text);
114
+ if (verbose) {
115
+ process.stderr.write(text);
116
+ } else if (this.activeTools.size === 0) {
117
+ this.setStatus('thinking…');
118
+ }
119
+ break;
120
+ }
121
+ case 'text': {
122
+ const text = event.part?.text ?? '';
123
+ this.#textBuffer += text;
124
+ appendVerbose(text);
125
+ if (verbose) {
126
+ process.stderr.write(text);
127
+ } else if (this.activeTools.size === 0) {
128
+ this.setStatus('composing response…');
129
+ }
130
+ if (this.#pendingStop && this.#textBuffer !== '') {
131
+ this.#settleOk(finish, { verbose });
132
+ }
133
+ break;
134
+ }
135
+ case 'step_finish': {
136
+ const reason = event.part?.reason;
137
+ if (reason === 'tool-calls') break;
138
+ if (reason === 'stop' || reason == null) {
139
+ if (this.#textBuffer !== '') {
140
+ this.#settleOk(finish, { verbose, part: event.part });
141
+ } else {
142
+ this.#pendingStop = true;
143
+ }
144
+ }
145
+ break;
146
+ }
147
+ case 'error': {
148
+ if (this.#settled) break;
149
+ this.#settled = true;
150
+ this.#pendingStop = false;
151
+ const message =
152
+ event.error?.data?.message ||
153
+ event.error?.name ||
154
+ 'OpenCode error';
155
+ this.settleResult(
156
+ {
157
+ is_error: true,
158
+ result: message,
159
+ },
160
+ finish,
161
+ );
162
+ break;
163
+ }
164
+ default:
165
+ break;
166
+ }
167
+ }
168
+
169
+ handleProcessClose(code, finish) {
170
+ if (this.#settled) return;
171
+ if (code === 0 && this.#textBuffer !== '') {
172
+ this.#settleOk(finish);
173
+ return;
174
+ }
175
+ super.handleProcessClose(code, finish);
176
+ }
177
+ }
package/lib/agent.js CHANGED
@@ -1,8 +1,13 @@
1
1
  import { spawn } from 'child_process';
2
2
  import path from 'path';
3
3
  import ora from 'ora';
4
- import { formatActiveTools } from './tool-status.js';
5
- import { patchJob } from './jobs.js';
4
+ import { formatActiveTools, toolPath } from './tool-status.js';
5
+ import { patchJob, buildLastOutcome, jobPaths, readJob } from './jobs.js';
6
+ import {
7
+ appendFailureLog,
8
+ failureLogPointer,
9
+ formatFailureSection,
10
+ } from './failure-log.js';
6
11
 
7
12
  /**
8
13
  * Every child process spawned by an agent, tracked so we can reap them all
@@ -43,6 +48,116 @@ export function setJobSlug(slug) {
43
48
  activeJobSlug = slug;
44
49
  }
45
50
 
51
+ /** Active job slug: env (detached child) wins over in-process foreground slug. */
52
+ export function getActiveJobSlug() {
53
+ return process.env.ORCH_JOB_SLUG ?? activeJobSlug;
54
+ }
55
+
56
+ /** Current stage capture + prior-stage ring for failure.log (independent of -v). */
57
+ let currentCapture = null;
58
+ /** @type {{ phase: *, stage: *, round: *, verbose: string }[]} */
59
+ let priorCaptures = [];
60
+ const PRIOR_RING_MAX = 8;
61
+
62
+ /** Clear failure-log buffers (also invoked from `resetShutdownState`). */
63
+ export function resetFailureLogState() {
64
+ currentCapture = null;
65
+ priorCaptures = [];
66
+ }
67
+
68
+ /**
69
+ * Start capturing verbose for a stage. Rotates a non-empty current buffer into
70
+ * the prior-stage ring so recover can see more than the final frame.
71
+ */
72
+ export function beginStageCapture({ phase = null, stage = null, round = null } = {}) {
73
+ if (currentCapture && currentCapture.verbose) {
74
+ priorCaptures.push({
75
+ phase: currentCapture.phase,
76
+ stage: currentCapture.stage,
77
+ round: currentCapture.round,
78
+ verbose: currentCapture.verbose,
79
+ });
80
+ if (priorCaptures.length > PRIOR_RING_MAX) {
81
+ priorCaptures.splice(0, priorCaptures.length - PRIOR_RING_MAX);
82
+ }
83
+ }
84
+ currentCapture = { phase, stage, round, verbose: '' };
85
+ }
86
+
87
+ /**
88
+ * Append a verbose chunk when a job slug is active. Never gated on `-v`
89
+ * (stderr streaming stays verbose-gated in the backends).
90
+ */
91
+ export function appendVerbose(chunk) {
92
+ if (!getActiveJobSlug()) return;
93
+ if (chunk == null || chunk === '') return;
94
+ if (!currentCapture) {
95
+ currentCapture = { phase: null, stage: null, round: null, verbose: '' };
96
+ }
97
+ currentCapture.verbose += String(chunk);
98
+ }
99
+
100
+ /**
101
+ * Append one `=== orch failure ===` section to `.orch/<slug>/failure.log`,
102
+ * patch `run.json.failureLogPath`, and return a pointer string. No-op when
103
+ * `slug` is falsy.
104
+ */
105
+ export function flushFailureLog({
106
+ cwd,
107
+ slug,
108
+ state,
109
+ exitCode = null,
110
+ finishedAt,
111
+ task = null,
112
+ error = null,
113
+ } = {}) {
114
+ if (!slug) return null;
115
+
116
+ const resolvedCwd = cwd ?? process.cwd();
117
+ let phase = currentCapture?.phase ?? null;
118
+ let stage = currentCapture?.stage ?? null;
119
+ let round = currentCapture?.round ?? null;
120
+ let resolvedTask = task;
121
+
122
+ if (phase == null || stage == null || round == null || resolvedTask == null) {
123
+ try {
124
+ const record = readJob(resolvedCwd, slug);
125
+ if (record) {
126
+ if (phase == null) phase = record.phase ?? null;
127
+ if (stage == null) stage = record.stage ?? null;
128
+ if (round == null) round = record.round ?? null;
129
+ if (resolvedTask == null) resolvedTask = record.task ?? null;
130
+ }
131
+ } catch {
132
+ // Best-effort cursor fill from run.json.
133
+ }
134
+ }
135
+
136
+ const { failureLogPath } = jobPaths(resolvedCwd, slug);
137
+ const section = formatFailureSection({
138
+ slug,
139
+ state,
140
+ phase,
141
+ stage,
142
+ round,
143
+ exitCode,
144
+ finishedAt,
145
+ task: resolvedTask,
146
+ error,
147
+ stageVerbose: currentCapture?.verbose ?? '',
148
+ priorStages: priorCaptures,
149
+ });
150
+ appendFailureLog(failureLogPath, section);
151
+
152
+ try {
153
+ patchJob(resolvedCwd, slug, { failureLogPath });
154
+ } catch {
155
+ // Best-effort: file is already on disk even if the patch fails.
156
+ }
157
+
158
+ return failureLogPointer(slug);
159
+ }
160
+
46
161
  /** Conventional shell status: 128 + signal number. */
47
162
  export function exitCodeForSignal(signal) {
48
163
  if (signal === 'SIGINT') return 130;
@@ -67,11 +182,32 @@ export function shutdown(signal, { exit = (code) => process.exit(code), jobCwd =
67
182
  const jobSlug = process.env.ORCH_JOB_SLUG ?? activeJobSlug;
68
183
  if (jobSlug) {
69
184
  try {
70
- patchJob(jobCwd, jobSlug, {
185
+ const exitCode = exitCodeForSignal(signal);
186
+ const finishedAt = new Date().toISOString();
187
+ const pointer = flushFailureLog({
188
+ cwd: jobCwd,
189
+ slug: jobSlug,
71
190
  state: 'stopped',
72
- exitCode: exitCodeForSignal(signal),
73
- finishedAt: new Date().toISOString(),
191
+ exitCode,
192
+ finishedAt,
193
+ error: signal,
74
194
  });
195
+ patchJob(jobCwd, jobSlug, (current) => ({
196
+ state: 'stopped',
197
+ exitCode,
198
+ finishedAt,
199
+ lastOutcome: buildLastOutcome({
200
+ state: 'stopped',
201
+ phase: current.phase,
202
+ stage: current.stage,
203
+ round: current.round,
204
+ exitCode,
205
+ finishedAt,
206
+ task: current.task,
207
+ summary: '',
208
+ error: pointer,
209
+ }),
210
+ }));
75
211
  } catch {
76
212
  // Best-effort: don't let a job-state write failure block shutdown.
77
213
  }
@@ -112,6 +248,7 @@ export function resetShutdownState() {
112
248
  shuttingDown = false;
113
249
  liveChildren.clear();
114
250
  activeJobSlug = null;
251
+ resetFailureLogState();
115
252
  }
116
253
 
117
254
  /**
@@ -196,10 +333,14 @@ export class Agent {
196
333
  this.refreshSpinnerText();
197
334
  }
198
335
 
199
- /** @param {{ name: string, args: Record<string, unknown>, phase: 'started'|'completed', callId: string }} toolEvent */
200
- onToolEvent({ name, args, phase, callId }) {
336
+ /** @param {{ name: string, args: Record<string, unknown>, phase: 'started'|'completed', callId: string, title?: string }} toolEvent */
337
+ onToolEvent({ name, args, phase, callId, title }) {
201
338
  if (phase === 'started') {
202
- this.activeTools.set(callId, { name, args });
339
+ this.activeTools.set(callId, {
340
+ name,
341
+ args,
342
+ ...(typeof title === 'string' && title ? { title } : {}),
343
+ });
203
344
  this.fileTracker?.record({ name, args, phase, callId });
204
345
  } else if (phase === 'completed') {
205
346
  // Recall before delete — Claude/agn completions often have empty args.
@@ -207,7 +348,7 @@ export class Agent {
207
348
  this.activeTools.delete(callId);
208
349
 
209
350
  if (this.fileTracker) {
210
- const hasPath = Boolean(args?.path || args?.file_path);
351
+ const hasPath = Boolean(toolPath(args));
211
352
  const entry = this.fileTracker.record({
212
353
  name: name || prior?.name || '',
213
354
  args: hasPath ? args : (prior?.args ?? args ?? {}),
@@ -266,6 +407,20 @@ export class Agent {
266
407
  throw new Error('handleStreamEvent must be implemented by subclass');
267
408
  }
268
409
 
410
+ /**
411
+ * Called when the child exits before a settling stream event.
412
+ * Subclasses may override (e.g. defensive success on exit 0 + buffered text).
413
+ * @param {number|null} code
414
+ * @param {(err: Error|null, value?: object) => void} finish
415
+ */
416
+ handleProcessClose(code, finish) {
417
+ this.stopElapsedTimer();
418
+ if (this.spinner?.isSpinning) {
419
+ this.spinner.fail(`[${this.name}] exited ${code}`);
420
+ }
421
+ finish(new Error(`[${this.name}] exited ${code} before result`));
422
+ }
423
+
269
424
  settleResult(event, finish) {
270
425
  this.stopElapsedTimer();
271
426
  this.activeTools.clear();
@@ -363,11 +518,7 @@ export class Agent {
363
518
  liveChildren.delete(child);
364
519
  this.process = null;
365
520
  if (!settled) {
366
- this.stopElapsedTimer();
367
- if (this.spinner?.isSpinning) {
368
- this.spinner.fail(`[${this.name}] exited ${code}`);
369
- }
370
- finish(new Error(`[${this.name}] exited ${code} before result`));
521
+ this.handleProcessClose(code, finish);
371
522
  }
372
523
  });
373
524
  });
package/lib/config.js ADDED
@@ -0,0 +1,190 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ const VALID_AGENTS = new Set(['cursor', 'claude', 'agn', 'opencode']);
6
+
7
+ /** Absolute path to the global orch config file. */
8
+ export function globalConfigPath({ homedir = os.homedir() } = {}) {
9
+ return path.join(homedir, '.orch', 'config');
10
+ }
11
+
12
+ /** Absolute path to the project-local orch config file under `cwd`. */
13
+ export function localConfigPath(cwd) {
14
+ return path.join(cwd, '.orch', 'config');
15
+ }
16
+
17
+ /**
18
+ * Read a config file. Missing file → `{}`. Bad JSON, unreadable file, or
19
+ * invalid `agent` / `notify` → throws with a message suitable for `Error: …`
20
+ * on stderr. Unknown keys are ignored; `agent` is case-sensitive.
21
+ */
22
+ export function loadConfig(configPath, displayPath = configPath) {
23
+ if (!fs.existsSync(configPath)) return {};
24
+
25
+ let raw;
26
+ try {
27
+ raw = fs.readFileSync(configPath, 'utf8');
28
+ } catch (err) {
29
+ throw new Error(`could not parse ${displayPath}: ${err.message}`);
30
+ }
31
+
32
+ let data;
33
+ try {
34
+ data = JSON.parse(raw);
35
+ } catch (err) {
36
+ throw new Error(`could not parse ${displayPath}: ${err.message}`);
37
+ }
38
+
39
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) {
40
+ throw new Error(`could not parse ${displayPath}: expected a JSON object`);
41
+ }
42
+
43
+ const out = {};
44
+
45
+ if (Object.prototype.hasOwnProperty.call(data, 'agent') && data.agent !== undefined) {
46
+ if (!VALID_AGENTS.has(data.agent)) {
47
+ throw new Error(
48
+ `invalid agent in ${displayPath}: ${JSON.stringify(data.agent)} (expected "cursor", "claude", "agn", or "opencode")`,
49
+ );
50
+ }
51
+ out.agent = data.agent;
52
+ }
53
+
54
+ if (Object.prototype.hasOwnProperty.call(data, 'notify') && data.notify !== undefined) {
55
+ if (typeof data.notify !== 'boolean') {
56
+ throw new Error(
57
+ `invalid notify in ${displayPath}: ${JSON.stringify(data.notify)} (expected true or false)`,
58
+ );
59
+ }
60
+ out.notify = data.notify;
61
+ }
62
+
63
+ return out;
64
+ }
65
+
66
+ /**
67
+ * Effective agent for a run: `--agent` > local > global > `cursor`.
68
+ * Reads local then global when `cliAgent` is omitted; throws on bad files.
69
+ */
70
+ export function resolveAgent({ cliAgent, cwd, homedir = os.homedir() } = {}) {
71
+ if (cliAgent) return cliAgent;
72
+
73
+ const local = loadConfig(localConfigPath(cwd), '.orch/config');
74
+ if (local.agent) return local.agent;
75
+
76
+ const global = loadConfig(globalConfigPath({ homedir }), '~/.orch/config');
77
+ if (global.agent) return global.agent;
78
+
79
+ return 'cursor';
80
+ }
81
+
82
+ /**
83
+ * Effective notify for a run: CLI > local > global > `true`.
84
+ * `cliNotify` is `true` | `false` | `undefined` (flag omitted).
85
+ */
86
+ export function resolveNotify({ cliNotify, cwd, homedir = os.homedir() } = {}) {
87
+ if (cliNotify === true || cliNotify === false) return cliNotify;
88
+
89
+ const local = loadConfig(localConfigPath(cwd), '.orch/config');
90
+ if (typeof local.notify === 'boolean') return local.notify;
91
+
92
+ const global = loadConfig(globalConfigPath({ homedir }), '~/.orch/config');
93
+ if (typeof global.notify === 'boolean') return global.notify;
94
+
95
+ return true;
96
+ }
97
+
98
+ /**
99
+ * Create parent dirs if needed, merge `agent` / `notify` into the existing
100
+ * config object (preserve the other key), overwrite with pretty-printed JSON,
101
+ * return the path written.
102
+ */
103
+ export function writeConfig(configPath, { agent, notify } = {}) {
104
+ if (agent !== undefined && !VALID_AGENTS.has(agent)) {
105
+ throw new Error(
106
+ `invalid agent: ${JSON.stringify(agent)} (expected "cursor", "claude", "agn", or "opencode")`,
107
+ );
108
+ }
109
+ if (notify !== undefined && typeof notify !== 'boolean') {
110
+ throw new Error(
111
+ `invalid notify: ${JSON.stringify(notify)} (expected true or false)`,
112
+ );
113
+ }
114
+
115
+ let existing = {};
116
+ if (fs.existsSync(configPath)) {
117
+ try {
118
+ const raw = JSON.parse(fs.readFileSync(configPath, 'utf8'));
119
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
120
+ existing = raw;
121
+ }
122
+ } catch {
123
+ // Corrupt file: start fresh; caller already validated inputs.
124
+ existing = {};
125
+ }
126
+ }
127
+
128
+ const next = { ...existing };
129
+ if (agent !== undefined) next.agent = agent;
130
+ if (notify !== undefined) next.notify = notify;
131
+
132
+ // Persist only known keys (drop unknown junk from a prior merge? Spec says
133
+ // unknown keys are ignored on load but does not require stripping on write.
134
+ // Keep agent/notify; preserve other keys already present.)
135
+ const out = {};
136
+ if (next.agent !== undefined) out.agent = next.agent;
137
+ if (next.notify !== undefined) out.notify = next.notify;
138
+ for (const [k, v] of Object.entries(next)) {
139
+ if (k === 'agent' || k === 'notify') continue;
140
+ out[k] = v;
141
+ }
142
+
143
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
144
+ fs.writeFileSync(configPath, `${JSON.stringify(out, null, 2)}\n`);
145
+ return configPath;
146
+ }
147
+
148
+ /**
149
+ * Print effective agent/notify and which file(s) contributed (stdout). Throws
150
+ * on invalid existing files — same fail-fast contract as a run.
151
+ */
152
+ export function printConfig({
153
+ cwd,
154
+ homedir = os.homedir(),
155
+ log = console.log,
156
+ } = {}) {
157
+ const localPath = localConfigPath(cwd);
158
+ const globalPath = globalConfigPath({ homedir });
159
+ const local = loadConfig(localPath, '.orch/config');
160
+ const global = loadConfig(globalPath, '~/.orch/config');
161
+
162
+ let agent = 'cursor';
163
+ let source = 'default (builtin)';
164
+ if (local.agent) {
165
+ agent = local.agent;
166
+ source = `local (${localPath})`;
167
+ } else if (global.agent) {
168
+ agent = global.agent;
169
+ source = `global (${globalPath})`;
170
+ }
171
+
172
+ let notify = true;
173
+ let notifySource = 'default (builtin)';
174
+ if (typeof local.notify === 'boolean') {
175
+ notify = local.notify;
176
+ notifySource = `local (${localPath})`;
177
+ } else if (typeof global.notify === 'boolean') {
178
+ notify = global.notify;
179
+ notifySource = `global (${globalPath})`;
180
+ }
181
+
182
+ log(`agent=${agent}`);
183
+ log(`source=${source}`);
184
+ log(`global=${global.agent ?? 'unset'} (${globalPath})`);
185
+ log(`local=${local.agent ?? 'unset'} (${localPath})`);
186
+ log(`notify=${notify}`);
187
+ log(`notifySource=${notifySource}`);
188
+ log(`notifyGlobal=${typeof global.notify === 'boolean' ? global.notify : 'unset'} (${globalPath})`);
189
+ log(`notifyLocal=${typeof local.notify === 'boolean' ? local.notify : 'unset'} (${localPath})`);
190
+ }