@assuredloop/orca-bot-kit 0.1.0 → 0.3.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/LICENSE +21 -0
- package/README.md +548 -3
- package/SETUP.md +275 -0
- package/package.json +32 -13
- package/rules/evidence.md +15 -0
- package/rules/finishing.md +23 -0
- package/rules/limits.md +18 -0
- package/rules/mail.md +17 -0
- package/rules/profiles.md +9 -0
- package/rules/review.md +14 -0
- package/rules/scope.md +22 -0
- package/rules/simple.md +14 -0
- package/rules/talk.md +13 -0
- package/rules/tests-first.md +17 -0
- package/rules/the-ask.md +19 -0
- package/skills/obk-arch/LICENSES.md +52 -0
- package/skills/obk-arch/NOTICE.md +242 -0
- package/skills/obk-arch/SKILL.md +598 -0
- package/skills/obk-bot-building/LICENSES.md +21 -0
- package/skills/obk-bot-building/NOTICE.md +113 -0
- package/skills/obk-bot-building/SKILL.md +312 -0
- package/skills/obk-debugging/LICENSES.md +67 -0
- package/skills/obk-debugging/NOTICE.md +115 -0
- package/skills/obk-debugging/SKILL.md +434 -0
- package/skills/obk-decision-memo/LICENSES.md +49 -0
- package/skills/obk-decision-memo/NOTICE.md +181 -0
- package/skills/obk-decision-memo/SKILL.md +241 -0
- package/skills/obk-finops/LICENSES.md +19 -0
- package/skills/obk-finops/NOTICE.md +77 -0
- package/skills/obk-finops/SKILL.md +148 -0
- package/skills/obk-fleet-review/LICENSES.md +52 -0
- package/skills/obk-fleet-review/NOTICE.md +104 -0
- package/skills/obk-fleet-review/SKILL.md +256 -0
- package/skills/obk-grilling/LICENSES.md +42 -0
- package/skills/obk-grilling/NOTICE.md +111 -0
- package/skills/obk-grilling/SKILL.md +221 -0
- package/skills/obk-grooming/LICENSES.md +52 -0
- package/skills/obk-grooming/NOTICE.md +111 -0
- package/skills/obk-grooming/SKILL.md +222 -0
- package/skills/obk-handoff/LICENSES.md +45 -0
- package/skills/obk-handoff/NOTICE.md +51 -0
- package/skills/obk-handoff/SKILL.md +119 -0
- package/skills/obk-personal-facilitation/LICENSES.md +53 -0
- package/skills/obk-personal-facilitation/NOTICE.md +62 -0
- package/skills/obk-personal-facilitation/SKILL.md +86 -0
- package/skills/obk-recall/LICENSES.md +42 -0
- package/skills/obk-recall/NOTICE.md +53 -0
- package/skills/obk-recall/SKILL.md +129 -0
- package/skills/obk-researching/LICENSES.md +67 -0
- package/skills/obk-researching/NOTICE.md +170 -0
- package/skills/obk-researching/SKILL.md +269 -0
- package/skills/obk-reviewing/LICENSES.md +66 -0
- package/skills/obk-reviewing/NOTICE.md +58 -0
- package/skills/obk-reviewing/SKILL.md +411 -0
- package/skills/obk-tdd/LICENSES.md +68 -0
- package/skills/obk-tdd/NOTICE.md +54 -0
- package/skills/obk-tdd/SKILL.md +547 -0
- package/skills/obk-tdd/mutation-tools.md +80 -0
- package/skills/obk-teach/LICENSES.md +42 -0
- package/skills/obk-teach/NOTICE.md +65 -0
- package/skills/obk-teach/SKILL.md +106 -0
- package/skills/obk-why/LICENSES.md +42 -0
- package/skills/obk-why/NOTICE.md +74 -0
- package/skills/obk-why/SKILL.md +180 -0
- package/skills/obk-writing/LICENSES.md +272 -0
- package/skills/obk-writing/NOTICE.md +161 -0
- package/skills/obk-writing/SKILL.md +254 -0
- package/src/book.js +268 -0
- package/src/bot.js +451 -0
- package/src/cli.js +1169 -0
- package/src/conversations.js +150 -0
- package/src/groom.js +159 -0
- package/src/health.js +309 -0
- package/src/hooks.js +257 -0
- package/src/init.js +314 -0
- package/src/launch.js +299 -0
- package/src/message.js +402 -0
- package/src/orca.js +362 -0
- package/src/pause.js +63 -0
- package/src/record.js +209 -0
- package/src/restart.js +193 -0
- package/src/retire.js +92 -0
- package/src/roster.js +71 -0
- package/src/rules.js +408 -0
- package/src/skills.js +630 -0
- package/src/sources.js +371 -0
- package/src/up.js +500 -0
- package/src/usage.js +401 -0
- package/bin/okb.js +0 -4
- package/src/index.js +0 -11
package/src/launch.js
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// What a session is started with: the one line typed into its tab, which brings
|
|
2
|
+
// the harness up and carries the session's start prompt with it.
|
|
3
|
+
//
|
|
4
|
+
// The flag mapping is PRD 6.4 and ADR 0015, re-checked against the installed
|
|
5
|
+
// CLIs (Claude Code 2.1.278, Codex 0.155.1). Every session is given its
|
|
6
|
+
// approval level explicitly, so a user's own global harness defaults cannot
|
|
7
|
+
// leak into a bot. The kit names no model: a session that sets none gets the
|
|
8
|
+
// harness's own.
|
|
9
|
+
//
|
|
10
|
+
// The text goes into the tab's ordinary shell, so every word is quoted the way
|
|
11
|
+
// a shell needs it. `claude --model sonnet[1m]` would be a glob to zsh.
|
|
12
|
+
|
|
13
|
+
import { readFileSync } from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
/** The approval levels, and what each one is called on each harness. */
|
|
18
|
+
const APPROVAL = {
|
|
19
|
+
claude: {
|
|
20
|
+
auto: ['--permission-mode', 'auto'],
|
|
21
|
+
ask: ['--permission-mode', 'manual'],
|
|
22
|
+
'dangerously-skip': ['--dangerously-skip-permissions'],
|
|
23
|
+
},
|
|
24
|
+
codex: {
|
|
25
|
+
auto: ['--approve-for-me'],
|
|
26
|
+
ask: ['-a', 'on-request'],
|
|
27
|
+
'dangerously-skip': ['--dangerously-bypass-approvals-and-sandbox'],
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const APPROVALS = Object.keys(APPROVAL.claude);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* How a permission bypass is spelled on a harness: the flags the
|
|
35
|
+
* `dangerously-skip` level puts on the launch line, and the same ones Orca's
|
|
36
|
+
* own default launch arguments carry when they carry a bypass (PRD 6.5).
|
|
37
|
+
*/
|
|
38
|
+
export const bypassFlags = (harness) => APPROVAL[harness]['dangerously-skip'];
|
|
39
|
+
|
|
40
|
+
export const HARNESSES = Object.keys(APPROVAL);
|
|
41
|
+
|
|
42
|
+
/** The level a session that names none runs at (ADR 0015). */
|
|
43
|
+
export const DEFAULT_APPROVAL = 'auto';
|
|
44
|
+
|
|
45
|
+
/** A setting the user left out. An empty string is one too: it means the harness's own. */
|
|
46
|
+
const set = (value) => value !== undefined && value !== null && String(value).trim() !== '';
|
|
47
|
+
|
|
48
|
+
/** Which harness a session runs on: its own, or the bot's. */
|
|
49
|
+
export const harnessOf = (session, botHarness) => (set(session.harness) ? session.harness : botHarness);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A session's name on Claude Code, which is also the address another Claude
|
|
53
|
+
* session writes to (ADR 0018). It goes on the launch line as `-n`, and it is
|
|
54
|
+
* re-applied every time the session is started: a resume keeps the name by
|
|
55
|
+
* itself (tech notes, section 2), and the kit does not depend on that.
|
|
56
|
+
*/
|
|
57
|
+
export const addressOf = (bot, session) => `${bot}.${session}`;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The Codex setting without which a bot cannot reach Orca at all.
|
|
61
|
+
*
|
|
62
|
+
* Inside Codex's workspace-write sandbox the Orca CLI runs but cannot connect
|
|
63
|
+
* to the running app, so `orca orchestration` is refused and a Codex bot can
|
|
64
|
+
* neither read nor send fleet mail. With this switch on, the same session at
|
|
65
|
+
* the same approval level reaches it (tech notes, section 3, proved live).
|
|
66
|
+
* The widening is real and said plainly where the user sees the launch line:
|
|
67
|
+
* that sandbox gains network access, there being no localhost-only setting.
|
|
68
|
+
*/
|
|
69
|
+
const NETWORK_ACCESS = 'sandbox_workspace_write.network_access';
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Whether the user has settled that switch themselves in this session's extra
|
|
73
|
+
* arguments. Theirs wins, whichever way they set it: the kit adds nothing
|
|
74
|
+
* beside it, and a session that turned it off is reported as out of reach of
|
|
75
|
+
* fleet mail rather than quietly failing to answer.
|
|
76
|
+
*/
|
|
77
|
+
export const setsNetworkAccess = (session) =>
|
|
78
|
+
extraWords(session.extra_args).some((word) => word.includes(NETWORK_ACCESS));
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Whether fleet mail can reach this session at all: everything but a Codex
|
|
82
|
+
* session whose user turned the sandbox switch off.
|
|
83
|
+
*/
|
|
84
|
+
export const reachesMail = (session, harness) =>
|
|
85
|
+
harness !== 'codex' || !turnedOff(session);
|
|
86
|
+
|
|
87
|
+
const turnedOff = (session) =>
|
|
88
|
+
extraWords(session.extra_args).some((word) => word.includes(`${NETWORK_ACCESS}=false`));
|
|
89
|
+
|
|
90
|
+
/** Where a session's work dir is: under the bot home, or wherever it says. */
|
|
91
|
+
export function workDirOf(session, home) {
|
|
92
|
+
if (!set(session.work_dir)) return undefined;
|
|
93
|
+
return path.resolve(home, session.work_dir);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* What a session asks for that cannot be done, in the user's own terms.
|
|
98
|
+
*
|
|
99
|
+
* Both commands that write a session check this, and so does the run that
|
|
100
|
+
* starts one: a session can also arrive by hand, and a bot.yaml the user edited
|
|
101
|
+
* is worth the same plain sentence rather than a stray flag typed into a tab.
|
|
102
|
+
*/
|
|
103
|
+
export function sessionTrouble(session, harness, home) {
|
|
104
|
+
if (set(session.prompt) && set(session.prompt_file)) {
|
|
105
|
+
return `${where(session)} has both a prompt and a prompt file, and a session is told its duty once. Keep the one you meant.`;
|
|
106
|
+
}
|
|
107
|
+
// The file is the prompt, so a session that cannot be read cannot be started,
|
|
108
|
+
// and saying so now beats a tab that comes up with nothing to do.
|
|
109
|
+
if (set(session.prompt_file)) {
|
|
110
|
+
try {
|
|
111
|
+
readFileSync(promptFile(session, home), 'utf8');
|
|
112
|
+
} catch (error) {
|
|
113
|
+
return `${where(session)} has its prompt in ${session.prompt_file}, and that file cannot be read (${error.code}): ${promptFile(session, home)}. Write it, or point the session at the file you meant.`;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (!HARNESSES.includes(harness)) {
|
|
117
|
+
return `${where(session)} runs on ${harness}, and the harnesses are ${HARNESSES.join(' and ')}.`;
|
|
118
|
+
}
|
|
119
|
+
if (set(session.approval) && !APPROVALS.includes(session.approval)) {
|
|
120
|
+
return `${where(session)} is set to approval ${session.approval}, and the levels are ${APPROVALS.join(', ')}.`;
|
|
121
|
+
}
|
|
122
|
+
// Codex is given the context window as a number of tokens. `1m` is what a
|
|
123
|
+
// Claude session would say, and copying it here does not fail at the kit but
|
|
124
|
+
// in Codex, which exits with a type error the moment it starts.
|
|
125
|
+
if (harness === 'codex' && set(session.context) && !/^\d+$/.test(String(session.context).trim())) {
|
|
126
|
+
return `${where(session)} sets a context of ${session.context} on Codex, and Codex counts that in tokens, as a whole number: 200000, for example. A context like 1m belongs on Claude Code, where it is part of the model name.`;
|
|
127
|
+
}
|
|
128
|
+
// On Claude Code the context window is a suffix on the model name, so there
|
|
129
|
+
// is nothing to hang it on when no model was named.
|
|
130
|
+
if (harness === 'claude' && set(session.context) && !set(session.model)) {
|
|
131
|
+
return `${where(session)} sets a context on Claude Code but no model. On Claude Code the context is part of the model name (--model 'sonnet[1m]'), so give the session a model as well, or leave the context out.`;
|
|
132
|
+
}
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const where = (session) => (set(session.name) ? `session ${session.name}` : 'this session');
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The line that starts the harness in a session's tab, with the session's start
|
|
140
|
+
* prompt on the end of it as the harness's own prompt argument.
|
|
141
|
+
*
|
|
142
|
+
* Nothing is added for a setting the session left out, and the order is fixed:
|
|
143
|
+
* approval, model, effort, context, work dir, whatever the user added, and the
|
|
144
|
+
* prompt last, where a positional argument goes.
|
|
145
|
+
*
|
|
146
|
+
* The prompt rides on this line rather than being typed in afterwards, which is
|
|
147
|
+
* what a first run in a fresh bots repo taught us: the harness may be showing
|
|
148
|
+
* its folder-trust question, Claude Code's reads as idle to Orca, and a second
|
|
149
|
+
* send would land on that list and answer it. A prompt given as an argument is
|
|
150
|
+
* held by the harness until it is ready for it (tech notes, section 1).
|
|
151
|
+
*/
|
|
152
|
+
export function launchCommand(session, { harness, home, workDir, prompt, promptFile: fromFile, resume, address }) {
|
|
153
|
+
const trouble = sessionTrouble(session, harness, home);
|
|
154
|
+
if (trouble !== undefined) throw new Error(trouble);
|
|
155
|
+
|
|
156
|
+
const words = [harness];
|
|
157
|
+
// Codex resumes through a subcommand, which has to come first; Claude Code
|
|
158
|
+
// resumes through a flag, which goes with the session id at the end. Both
|
|
159
|
+
// take every other flag exactly as a fresh session does.
|
|
160
|
+
if (resume !== undefined && harness === 'codex') words.push('resume');
|
|
161
|
+
words.push(...APPROVAL[harness][set(session.approval) ? session.approval : DEFAULT_APPROVAL]);
|
|
162
|
+
|
|
163
|
+
if (harness === 'claude') {
|
|
164
|
+
// The name is the address other Claude sessions write to, so it goes on
|
|
165
|
+
// every launch line, a resume included (ADR 0018).
|
|
166
|
+
if (address !== undefined) words.push('-n', address);
|
|
167
|
+
// The context window rides on the model name: `sonnet[1m]`.
|
|
168
|
+
if (set(session.model)) words.push('--model', set(session.context) ? `${session.model}[${session.context}]` : session.model);
|
|
169
|
+
if (set(session.effort)) words.push('--effort', String(session.effort));
|
|
170
|
+
} else {
|
|
171
|
+
// Without this the Orca CLI cannot reach Orca from inside the sandbox, so
|
|
172
|
+
// the session is in no fleet at all. The user's own setting of it wins.
|
|
173
|
+
if (!setsNetworkAccess(session)) words.push('-c', `${NETWORK_ACCESS}=true`);
|
|
174
|
+
if (set(session.model)) words.push('-m', session.model);
|
|
175
|
+
if (set(session.effort)) words.push('-c', `model_reasoning_effort=${session.effort}`);
|
|
176
|
+
if (set(session.context)) words.push('-c', `model_context_window=${session.context}`);
|
|
177
|
+
// In Codex's auto mode the sandbox lets it write in the folder it was
|
|
178
|
+
// started in, and nowhere else (ADR 0015), so a work dir outside the bot
|
|
179
|
+
// home has to be named.
|
|
180
|
+
if (workDir !== undefined && !inside(home, workDir)) words.push('--add-dir', workDir);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return [
|
|
184
|
+
// The shell's own pid, so the session's hook can tell the harness this line
|
|
185
|
+
// starts — the shell's child — from anything the session starts later inside
|
|
186
|
+
// the tab. `$$` is the shell's, and it is not quoted for that reason.
|
|
187
|
+
`${SHELL_ENV}=$$`,
|
|
188
|
+
`${CLI_ENV}=${quoted(ownCli())}`,
|
|
189
|
+
...words.map(quoted),
|
|
190
|
+
...extraWords(session.extra_args),
|
|
191
|
+
...resumeWords(harness, resume),
|
|
192
|
+
// `--` first: a prompt of the user's own may start with a dash — a
|
|
193
|
+
// Markdown bullet does — and both harnesses would read it as an option of
|
|
194
|
+
// theirs and refuse to start.
|
|
195
|
+
...promptWords(prompt, fromFile),
|
|
196
|
+
].join(' ');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* What the session is told when it comes up, or undefined when it has nothing
|
|
201
|
+
* to say. The work dir is named here and nowhere else: it is an instruction to
|
|
202
|
+
* the session, not something the harness is told (PRD 6.4).
|
|
203
|
+
*
|
|
204
|
+
* A long prompt lives in a file in the bot home and the session points at it;
|
|
205
|
+
* a short one is written in the session itself. Either way it is word for word
|
|
206
|
+
* as the user wrote it: it travels as one argument, quoted for the shell it is
|
|
207
|
+
* typed into, so nothing has to be flattened to keep it whole — the two
|
|
208
|
+
* spaces, the newlines and the indentation of a prompt carrying a piece of
|
|
209
|
+
* text or code are the user's, and none of the kit's business.
|
|
210
|
+
*/
|
|
211
|
+
export function startPrompt(session, { home, workDir }) {
|
|
212
|
+
const note = workDir === undefined
|
|
213
|
+
? undefined
|
|
214
|
+
: `Your work dir is ${workDir}. It is a plain folder the kit made for you, not a git worktree.`;
|
|
215
|
+
|
|
216
|
+
// Only the ends are taken off — a `prompt: |` in YAML, and a file, both carry
|
|
217
|
+
// a newline the user never typed. What is inside is theirs.
|
|
218
|
+
const said = [set(session.prompt_file) ? readFileSync(promptFile(session, home), 'utf8') : session.prompt, note]
|
|
219
|
+
.filter(set)
|
|
220
|
+
.map((part) => String(part).trim());
|
|
221
|
+
return said.length === 0 ? undefined : said.join('\n\n');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Where a session's prompt file is: in the bot home, where the user put it. */
|
|
225
|
+
export const promptFile = (session, home) => path.resolve(home, session.prompt_file);
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* A prompt short and simple enough to type into the launch line as it is: one
|
|
229
|
+
* line, and not a long one. Everything else goes to the harness out of a file,
|
|
230
|
+
* which keeps a page of text out of a tab's shell and its history.
|
|
231
|
+
*/
|
|
232
|
+
export const isShortPrompt = (prompt) => !prompt.includes('\n') && prompt.length <= SHORT_PROMPT;
|
|
233
|
+
|
|
234
|
+
const SHORT_PROMPT = 200;
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The prompt on the launch line: the text itself when it is short, and
|
|
238
|
+
* otherwise the file the kit wrote it to, read back by the shell that types the
|
|
239
|
+
* line. `"$(cat …)"` hands the harness one argument whatever is in the file.
|
|
240
|
+
*/
|
|
241
|
+
function promptWords(prompt, fromFile) {
|
|
242
|
+
if (fromFile !== undefined) return ['--', `"$(cat ${quoted(fromFile)})"`];
|
|
243
|
+
return prompt === undefined ? [] : ['--', quoted(prompt)];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The session the harness is told to pick up again, when there is one: a flag
|
|
248
|
+
* on Claude Code, the argument of the `resume` subcommand on Codex.
|
|
249
|
+
*/
|
|
250
|
+
const resumeWords = (harness, resume) => {
|
|
251
|
+
if (resume === undefined) return [];
|
|
252
|
+
return harness === 'claude' ? ['--resume', quoted(resume)] : [quoted(resume)];
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
/** Whether `target` is the folder at `home` or something inside it. */
|
|
256
|
+
const inside = (home, target) => target === home || target.startsWith(home + path.sep);
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The user's own `extra_args`. A list is a list of arguments, each quoted as
|
|
260
|
+
* one. A plain string is the user's own shell text, typed as they wrote it.
|
|
261
|
+
*/
|
|
262
|
+
function extraWords(extra) {
|
|
263
|
+
if (Array.isArray(extra)) return extra.filter(set).map((arg) => quoted(String(arg)));
|
|
264
|
+
return set(extra) ? [String(extra).trim()] : [];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Where the launch line tells the session's hook which shell typed the harness
|
|
269
|
+
* in. One tab holds one session: the harness this shell starts. Anything that
|
|
270
|
+
* session starts inside the tab is not the session and can never be it.
|
|
271
|
+
*/
|
|
272
|
+
export const SHELL_ENV = 'OBK_TAB_SHELL';
|
|
273
|
+
|
|
274
|
+
/** A word as a shell needs it. Anything a shell would read as more than a word is quoted. */
|
|
275
|
+
const quoted = (word) => (/^[A-Za-z0-9,._+:@%/=-]+$/.test(word) ? word : `'${word.replaceAll("'", `'\\''`)}'`);
|
|
276
|
+
|
|
277
|
+
/** The same, for anything else of the kit's that has to build a shell line. */
|
|
278
|
+
export const shellWord = quoted;
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The CLI that is running, by the path it was started by: the kit calls itself
|
|
282
|
+
* back with this, never with the bare name `obk`, because the bare name is
|
|
283
|
+
* whatever PATH says, and PATH is not the kit's to decide (#220). A user's
|
|
284
|
+
* install is started through npm's bin link, and the link is kept rather than
|
|
285
|
+
* resolved, so the path survives an upgrade of the package; a system test that
|
|
286
|
+
* runs a checkout's `src/cli.js` gets that checkout. What an environment
|
|
287
|
+
* variable claims is not asked: the kit says who it is from how it was started.
|
|
288
|
+
*/
|
|
289
|
+
export const ownCli = () => (process.argv[1] === undefined
|
|
290
|
+
// Nothing started as a script, as with `node -e`: the kit's own entry file.
|
|
291
|
+
? fileURLToPath(new URL('./cli.js', import.meta.url))
|
|
292
|
+
: path.resolve(process.argv[1]));
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Where the launch line hands the session the CLI that started it, so a bot's
|
|
296
|
+
* own commands can call the same one. A plain variable, because a harness's
|
|
297
|
+
* shell keeps those where it rearranges PATH (#220).
|
|
298
|
+
*/
|
|
299
|
+
export const CLI_ENV = 'OBK_CLI';
|