@assuredloop/orca-bot-kit 0.2.0 → 0.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.
Files changed (50) hide show
  1. package/README.md +31 -15
  2. package/SETUP.md +10 -4
  3. package/package.json +1 -1
  4. package/rules/finishing.md +3 -0
  5. package/rules/mail.md +3 -3
  6. package/rules/scope.md +4 -2
  7. package/rules/tests-first.md +6 -4
  8. package/skills/obk-arch/LICENSES.md +7 -0
  9. package/skills/obk-arch/NOTICE.md +113 -5
  10. package/skills/obk-arch/SKILL.md +221 -10
  11. package/skills/obk-bot-building/NOTICE.md +2 -2
  12. package/skills/obk-bot-building/SKILL.md +108 -19
  13. package/skills/obk-debugging/SKILL.md +30 -15
  14. package/skills/obk-decision-memo/NOTICE.md +1 -1
  15. package/skills/obk-finops/NOTICE.md +1 -1
  16. package/skills/obk-finops/SKILL.md +6 -4
  17. package/skills/obk-fleet-review/SKILL.md +42 -5
  18. package/skills/obk-grilling/NOTICE.md +11 -3
  19. package/skills/obk-grilling/SKILL.md +11 -7
  20. package/skills/obk-grooming/SKILL.md +5 -2
  21. package/skills/obk-handoff/NOTICE.md +1 -1
  22. package/skills/obk-recall/NOTICE.md +1 -1
  23. package/skills/obk-researching/NOTICE.md +1 -1
  24. package/skills/obk-researching/SKILL.md +19 -9
  25. package/skills/obk-reviewing/SKILL.md +4 -1
  26. package/skills/obk-tdd/SKILL.md +54 -23
  27. package/skills/obk-teach/NOTICE.md +2 -2
  28. package/skills/obk-why/NOTICE.md +1 -1
  29. package/skills/obk-why/SKILL.md +21 -10
  30. package/src/book.js +2 -2
  31. package/src/bot.js +1 -1
  32. package/src/cli.js +117 -33
  33. package/src/groom.js +5 -3
  34. package/src/health.js +2 -2
  35. package/src/hooks.js +73 -18
  36. package/src/init.js +3 -3
  37. package/src/launch.js +28 -5
  38. package/src/message.js +38 -19
  39. package/src/orca-window.cjs +21 -0
  40. package/src/orca.js +191 -28
  41. package/src/pause.js +11 -6
  42. package/src/record.js +1 -1
  43. package/src/restart.js +6 -5
  44. package/src/retire.js +41 -6
  45. package/src/roster.js +1 -1
  46. package/src/rules.js +2 -2
  47. package/src/skills.js +113 -21
  48. package/src/sources.js +4 -3
  49. package/src/up.js +57 -23
  50. package/src/usage.js +2 -2
package/src/health.js CHANGED
@@ -266,8 +266,8 @@ function inOrca(home, bot, setups) {
266
266
  /**
267
267
  * The conversations a harness has on record in this bot's folder that the book
268
268
  * does not name anywhere. The hook is how an id reaches the book, and when it
269
- * fails it fails quietly (ADR 0010), so this is where a stale book shows: the
270
- * harness's own record set beside it (ADR 0002).
269
+ * fails it fails quietly (ADR 0020), so this is where a stale book shows: the
270
+ * harness's own record set beside it (ADR 0012).
271
271
  */
272
272
  function offTheBook(home, bot, book) {
273
273
  const named = new Set();
package/src/hooks.js CHANGED
@@ -1,4 +1,4 @@
1
- // The kit's own hook, in the bot's folder and nowhere else (ADR 0010).
1
+ // The kit's own hook, in the bot's folder and nowhere else (ADR 0020).
2
2
  //
3
3
  // Both harnesses read a hooks file out of the folder a session starts in, and
4
4
  // every session starts at its bot home, so the bot folder is where the kit's
@@ -15,7 +15,7 @@ import path from 'node:path';
15
15
  import { isDeepStrictEqual } from 'node:util';
16
16
 
17
17
  import { leadsOutside } from './bot.js';
18
- import { shellWord } from './launch.js';
18
+ import { ownCli, shellWord } from './launch.js';
19
19
 
20
20
  /** Where each harness reads a project's hooks, inside the bot home. */
21
21
  const HOOK_FILE = { claude: '.claude/settings.json', codex: '.codex/hooks.json' };
@@ -31,17 +31,16 @@ const EVENT = 'SessionStart';
31
31
  /** How long the harness gives the hook before it goes on without it. */
32
32
  const TIMEOUT = 10;
33
33
 
34
- /** The kit's own hook among whatever else the user keeps in the file. */
35
- const KIT_COMMAND = 'obk session record';
36
-
37
34
  /**
38
- * What the hook runs. `|| true` and a quiet stderr because a hook must never
39
- * disturb the session it fires in (ADR 0010): a kit that is not installed, or a
40
- * book that cannot be written, leaves the session alone and the health check
41
- * finds the stale book later.
35
+ * What the hook runs: the CLI that wrote it, by its own path, so the session
36
+ * reports to the kit that set it up and not to whatever `obk` PATH finds in the
37
+ * harness's shell (#220). `|| true` and a quiet stderr because a hook must never
38
+ * disturb the session it fires in (ADR 0020): a kit that is not there, or a book
39
+ * that cannot be written, leaves the session alone and the health check finds
40
+ * the stale book later.
42
41
  */
43
- export const hookCommand = (bots, bot) =>
44
- `${KIT_COMMAND} --bots ${shellWord(bots)} --bot ${shellWord(bot)} 2>/dev/null || true`;
42
+ export const hookCommand = (bots, bot, cli = ownCli()) =>
43
+ `${shellWord(cli)} session record --bots ${shellWord(bots)} --bot ${shellWord(bot)} 2>/dev/null || true`;
45
44
 
46
45
  /**
47
46
  * Make sure the bot at `home` has the kit's SessionStart hook for `harness`.
@@ -81,7 +80,7 @@ export function installHook(home, harness, { bots, bot }) {
81
80
  *
82
81
  * The same question `installHook` asks, without the writing. It matters more
83
82
  * than it looks: the hook is the whole of how the book learns which
84
- * conversation a session is running as (ADR 0002), and when it is not there
83
+ * conversation a session is running as (ADR 0012), and when it is not there
85
84
  * nothing else says so — the book simply stops being true.
86
85
  */
87
86
  export function hookTrouble(home, harness, { bots, bot }) {
@@ -95,7 +94,22 @@ export function hookTrouble(home, harness, { bots, bot }) {
95
94
  let settings;
96
95
  try {
97
96
  settings = readSettings(file);
98
- wanted = { ...settings, hooks: withKitHook(settings.hooks, file, { type: 'command', command: hookCommand(bots, bot), timeout: TIMEOUT }) };
97
+ // The hook in the file is judged by what it runs, not by whether this is
98
+ // the install that wrote it. A bot made before the kit named itself by path
99
+ // runs the bare `obk`, and one another install wrote runs that install: both
100
+ // still reach a kit, and the next `obk up` writes this one's. So the line is
101
+ // checked against the program already in it, and only one that names a CLI
102
+ // that is not there any more is wrong — `|| true` would keep that quiet, and
103
+ // the book would stop being true with nothing to say why.
104
+ const running = kitProgramIn(settings);
105
+ if (running !== undefined && running !== BARE && !existsSync(running)) {
106
+ return {
107
+ where: file,
108
+ says: `${file} holds the kit's session hook, but it runs ${running}, which is not there any more. ${stale} obk up puts back one that runs the kit you have.`,
109
+ };
110
+ }
111
+ const program = running ?? ownCli();
112
+ wanted = { ...settings, hooks: withKitHook(settings.hooks, file, { type: 'command', command: hookCommand(bots, bot, program), timeout: TIMEOUT }) };
99
113
  } catch (error) {
100
114
  return { where: file, says: `${error.message} ${stale}` };
101
115
  }
@@ -110,7 +124,7 @@ export function hookTrouble(home, harness, { bots, bot }) {
110
124
  /**
111
125
  * What to say about a hooks file that a link takes out of the bot folder, or
112
126
  * undefined when it stays in. The kit writes its hook in the bot folder and
113
- * nowhere else (ADR 0010); through a link like that it would be writing the
127
+ * nowhere else (ADR 0020); through a link like that it would be writing the
114
128
  * user's own settings, so it does not, and says why.
115
129
  */
116
130
  function outside(home, file) {
@@ -169,14 +183,55 @@ function withKitHook(hooks, file, mine) {
169
183
  /**
170
184
  * The kit's own hook entry, wherever it sits and whatever sits beside it: a
171
185
  * command of exactly the shape `hookCommand` writes, for any bots folder and
172
- * bot, so an entry written before the folder moved is still the kit's. A line
173
- * of the user's that only mentions the command is theirs (PRD 6.5).
186
+ * bot, run by a kit — the bare `obk` a bot made before #220 still has, this
187
+ * install, or another — so an entry written before the folder moved or the kit
188
+ * changed is still the kit's, and is rewritten in place rather than joined by a
189
+ * second. A line of the user's that only mentions the command, or runs it with
190
+ * a program of their own, is theirs (PRD 6.5).
174
191
  */
175
- const isKitHook = (hook) => typeof hook?.command === 'string' && KIT_HOOK.test(hook.command);
192
+ const isKitHook = (hook) => kitProgramOf(hook?.command) !== undefined;
176
193
 
177
194
  /** One word as `shellWord` writes it: bare, or single-quoted with `'\''` inside. */
178
195
  const WORD = String.raw`(?:[A-Za-z0-9,._+:@%/=-]+|'(?:[^']|'\\'')*')`;
179
- const KIT_HOOK = new RegExp(String.raw`^${KIT_COMMAND} --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
196
+ const KIT_HOOK = new RegExp(String.raw`^(${WORD}) session record --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
197
+
198
+ /** The program a bot made before the kit named itself by path runs. */
199
+ const BARE = 'obk';
200
+
201
+ /**
202
+ * Whether a program is a kit: the bare name, an install's `obk` wherever it
203
+ * lives, or a checkout's `src/cli.js` — the only two ways `ownCli` is reached.
204
+ */
205
+ const isKitCli = (program) => path.basename(program) === BARE || program.endsWith('/src/cli.js');
206
+
207
+ /** The program a hook command of the kit's runs, unquoted, or undefined for any other command. */
208
+ function kitProgramOf(command) {
209
+ const found = typeof command === 'string' ? KIT_HOOK.exec(command) : null;
210
+ if (found === null) return undefined;
211
+ const program = unquoted(found[1]);
212
+ return isKitCli(program) ? program : undefined;
213
+ }
214
+
215
+ /**
216
+ * What the kit's hook in these settings runs, as a path or the bare name, or
217
+ * undefined when there is none. Read back out of its shell quoting, so it can be
218
+ * looked for on disk.
219
+ */
220
+ function kitProgramIn(settings) {
221
+ for (const groups of Object.values(settings.hooks ?? {})) {
222
+ if (!Array.isArray(groups)) continue;
223
+ for (const group of groups) {
224
+ for (const hook of Array.isArray(group?.hooks) ? group.hooks : []) {
225
+ const program = kitProgramOf(hook?.command);
226
+ if (program !== undefined) return program;
227
+ }
228
+ }
229
+ }
230
+ return undefined;
231
+ }
232
+
233
+ /** A word as `shellWord` wrote it, read back to what it stands for. */
234
+ const unquoted = (word) => (word.startsWith("'") ? word.slice(1, -1).replaceAll(`'\\''`, "'") : word);
180
235
 
181
236
  /**
182
237
  * What the file says, or nothing when there is no file yet. A file the kit
package/src/init.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // Creates the user's bots folder: their own git repo, seeded with the files the
2
2
  // kit reads, and Bot Father. The kit puts none of its own code in here — kit
3
- // skills are linked from the installed package (ADR 0004).
3
+ // skills are linked from the installed package (ADR 0014).
4
4
  //
5
5
  // Seeding only ever adds what is missing. The user edits these files, by hand
6
6
  // or through Bot Father, so a re-run must never write over them.
@@ -15,7 +15,7 @@ import { RECORD, SKILL_DIRS } from './skills.js';
15
15
 
16
16
  /**
17
17
  * What the kit makes on this machine, and what the repo therefore does not
18
- * carry (ADR 0004, PRD 6.3).
18
+ * carry (ADR 0014, PRD 6.3).
19
19
  *
20
20
  * A bot's skills are symlinks into wherever the kit and the user's skill
21
21
  * sources are installed on the machine that made them, so a clone somewhere
@@ -126,7 +126,7 @@ const MANAGEMENT_SKILLS = ['obk-bot-building', 'obk-finops', 'obk-fleet-review',
126
126
  const BOT_FATHER_YAML = 'bots/bot-father/bot.yaml';
127
127
 
128
128
  /** The management session every Bot Father has: the tab you talk to it in. */
129
- const DAILY_SESSION = 'daily';
129
+ export const DAILY_SESSION = 'daily';
130
130
 
131
131
  const seeds = (harness) => [
132
132
  ['.gitignore', GITIGNORE],
package/src/launch.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // What a session is started with: the one line typed into its tab, which brings
2
2
  // the harness up and carries the session's start prompt with it.
3
3
  //
4
- // The flag mapping is PRD 6.4 and ADR 0005, re-checked against the installed
4
+ // The flag mapping is PRD 6.4 and ADR 0015, re-checked against the installed
5
5
  // CLIs (Claude Code 2.1.278, Codex 0.155.1). Every session is given its
6
6
  // approval level explicitly, so a user's own global harness defaults cannot
7
7
  // leak into a bot. The kit names no model: a session that sets none gets the
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { readFileSync } from 'node:fs';
14
14
  import path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
15
16
 
16
17
  /** The approval levels, and what each one is called on each harness. */
17
18
  const APPROVAL = {
@@ -38,7 +39,7 @@ export const bypassFlags = (harness) => APPROVAL[harness]['dangerously-skip'];
38
39
 
39
40
  export const HARNESSES = Object.keys(APPROVAL);
40
41
 
41
- /** The level a session that names none runs at (ADR 0005). */
42
+ /** The level a session that names none runs at (ADR 0015). */
42
43
  export const DEFAULT_APPROVAL = 'auto';
43
44
 
44
45
  /** A setting the user left out. An empty string is one too: it means the harness's own. */
@@ -49,7 +50,7 @@ export const harnessOf = (session, botHarness) => (set(session.harness) ? sessio
49
50
 
50
51
  /**
51
52
  * A session's name on Claude Code, which is also the address another Claude
52
- * session writes to (ADR 0008). It goes on the launch line as `-n`, and it is
53
+ * session writes to (ADR 0018). It goes on the launch line as `-n`, and it is
53
54
  * re-applied every time the session is started: a resume keeps the name by
54
55
  * itself (tech notes, section 2), and the kit does not depend on that.
55
56
  */
@@ -161,7 +162,7 @@ export function launchCommand(session, { harness, home, workDir, prompt, promptF
161
162
 
162
163
  if (harness === 'claude') {
163
164
  // The name is the address other Claude sessions write to, so it goes on
164
- // every launch line, a resume included (ADR 0008).
165
+ // every launch line, a resume included (ADR 0018).
165
166
  if (address !== undefined) words.push('-n', address);
166
167
  // The context window rides on the model name: `sonnet[1m]`.
167
168
  if (set(session.model)) words.push('--model', set(session.context) ? `${session.model}[${session.context}]` : session.model);
@@ -174,7 +175,7 @@ export function launchCommand(session, { harness, home, workDir, prompt, promptF
174
175
  if (set(session.effort)) words.push('-c', `model_reasoning_effort=${session.effort}`);
175
176
  if (set(session.context)) words.push('-c', `model_context_window=${session.context}`);
176
177
  // In Codex's auto mode the sandbox lets it write in the folder it was
177
- // started in, and nowhere else (ADR 0005), so a work dir outside the bot
178
+ // started in, and nowhere else (ADR 0015), so a work dir outside the bot
178
179
  // home has to be named.
179
180
  if (workDir !== undefined && !inside(home, workDir)) words.push('--add-dir', workDir);
180
181
  }
@@ -184,6 +185,7 @@ export function launchCommand(session, { harness, home, workDir, prompt, promptF
184
185
  // starts — the shell's child — from anything the session starts later inside
185
186
  // the tab. `$$` is the shell's, and it is not quoted for that reason.
186
187
  `${SHELL_ENV}=$$`,
188
+ `${CLI_ENV}=${quoted(ownCli())}`,
187
189
  ...words.map(quoted),
188
190
  ...extraWords(session.extra_args),
189
191
  ...resumeWords(harness, resume),
@@ -274,3 +276,24 @@ const quoted = (word) => (/^[A-Za-z0-9,._+:@%/=-]+$/.test(word) ? word : `'${wor
274
276
 
275
277
  /** The same, for anything else of the kit's that has to build a shell line. */
276
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';
package/src/message.js CHANGED
@@ -1,4 +1,4 @@
1
- // `obk message`: one session writing to another (PRD 6.9, ADR 0008).
1
+ // `obk message`: one session writing to another (PRD 6.9, ADR 0018).
2
2
  //
3
3
  // Two roads, and the bot never picks. Claude to Claude in the same approval
4
4
  // class is the harness's own messaging, which no command line can send for it —
@@ -20,8 +20,8 @@ import path from 'node:path';
20
20
 
21
21
  import { readBook } from './book.js';
22
22
  import { botDir, botNames, readBot } from './bot.js';
23
- import { harnessOf, reachesMail } from './launch.js';
24
- import { ackMailbox, postMessage, readMailbox, tabs, tuiInTab, typeIntoTab, useMailbox } from './orca.js';
23
+ import { harnessOf, ownCli, reachesMail, shellWord } from './launch.js';
24
+ import { ackMailbox, coordinatorOf, postMessage, readMailbox, tabs, tabToTypeInto, typeIntoTab, useMailbox } from './orca.js';
25
25
 
26
26
  /**
27
27
  * How much of a message travels as itself. Above this it is written to a file
@@ -147,7 +147,7 @@ export function sendMessage(bots, { to: target, from: sender, tab, subject, text
147
147
  return {
148
148
  ...answer,
149
149
  sent: false,
150
- trouble: `${from.bot}/${from.session} has no mailbox of its own yet, so a reply would have nowhere to go. Bring it up first: obk up --bots ${bots} --bot ${from.bot}`,
150
+ trouble: `${from.bot}/${from.session} has no mailbox of its own yet, so a reply would have nowhere to go. Bring it up first: ${shellWord(ownCli())} up --bots ${shellWord(bots)} --bot ${shellWord(from.bot)}`,
151
151
  };
152
152
  }
153
153
 
@@ -173,7 +173,9 @@ export function sendMessage(bots, { to: target, from: sender, tab, subject, text
173
173
  /**
174
174
  * What is waiting for a session, and the reading of it.
175
175
  *
176
- * Orca fences a Run to one reader, so this binds before it reads. A read that
176
+ * Orca fences a Run to one reader, its coordinator. A session whose tab is live
177
+ * is bound to that tab and read as it; one whose tab is down is read as its
178
+ * Run's coordinator, and nothing is bound (issue #249). A read that
177
179
  * is not a peek acknowledges the batch it read: that is what makes the next
178
180
  * check bring the next one rather than the same again.
179
181
  */
@@ -192,10 +194,27 @@ export function checkMail(bots, { bot: botName, session: sessionName, tab, peek
192
194
  };
193
195
  }
194
196
 
195
- useMailbox(who.mailbox);
196
- const found = readMailbox(who.mailbox, { peek });
197
+ // Read as the session's own tab, wherever this check was typed. Binding the
198
+ // tab that asked would hand it the session's mailbox, and Orca's notice for
199
+ // every message after (issue #228). A session with no live tab is read as
200
+ // whatever its mailbox is bound to, a closed tab included, and nothing is
201
+ // bound: the session's tab is bound again when it is back up (issue #249).
202
+ const live = who.tab === undefined ? undefined : tabs(who.home).find((tab) => tab.tabId === who.tab)?.handle;
203
+ if (live !== undefined) useMailbox(who.mailbox, live);
204
+ const handle = live ?? coordinatorOf(who.mailbox);
205
+ if (handle === undefined) {
206
+ return {
207
+ bots,
208
+ bot: who.bot,
209
+ session: who.session,
210
+ mailbox: who.mailbox,
211
+ messages: [],
212
+ trouble: `${who.bot}/${who.session} is not up, and its mailbox is bound to no tab, so there is nothing to read it as. Its mail waits until its tab is back: \`up\` brings it back, or \`unpause\` when it is paused.`,
213
+ };
214
+ }
215
+ const found = readMailbox(who.mailbox, { peek, handle });
197
216
  const messages = (found.messages ?? []).map((message) => asMessage(bots, message));
198
- if (!peek && found.deliveryId !== undefined && messages.length > 0) ackMailbox(who.mailbox, found.deliveryId);
217
+ if (!peek && found.deliveryId !== undefined && messages.length > 0) ackMailbox(who.mailbox, found.deliveryId, handle);
199
218
 
200
219
  return { bots, bot: who.bot, session: who.session, mailbox: who.mailbox, read: !peek, messages };
201
220
  }
@@ -295,9 +314,10 @@ const stamp = () => new Date().toISOString().replaceAll(':', '-').replace('.', '
295
314
  *
296
315
  * Nothing in the mailbox reaches a running harness by itself, and a typed line
297
316
  * is taken as the next turn by a busy session rather than cutting into the one
298
- * it is having. A tab with no TUI in it is not typed into at all — there is
317
+ * it is having. A tab with no harness in it is not typed into at all — there is
299
318
  * nobody there to read it, and the message waits in the mailbox until the
300
- * session is up. A tab the book does not hold is never typed into on any road.
319
+ * session is up. Nor is one the kit cannot tell about. A tab the book does not
320
+ * hold is never typed into on any road.
301
321
  *
302
322
  * Nor is a tab with something on screen waiting to be answered. A line typed
303
323
  * into one of those is not a message: it is an answer to whatever question is
@@ -315,16 +335,15 @@ function nudge(to, from, subject) {
315
335
  if (to.tab === undefined) return { nudged: false };
316
336
 
317
337
  try {
318
- const live = tabs(to.home).find((tab) => tab.tabId === to.tab);
319
- if (live === undefined) return { nudged: false };
320
-
321
- const tui = tuiInTab(live.handle, LOOK_MS);
322
- if (!tui.running) return { nudged: false };
323
- if (tui.blockedReason !== undefined) return { nudged: false, blocked: tui.blockedReason };
338
+ const found = tabToTypeInto(to.home, to.tab, LOOK_MS);
339
+ if (found.blocked !== undefined) return { nudged: false, blocked: found.blocked };
340
+ // A line that lands in a shell is run there, with the sender's subject in it.
341
+ if (found.unsure !== undefined) return { nudged: false, nudgeTrouble: found.unsure };
342
+ if (found.handle === undefined) return { nudged: false };
324
343
 
325
344
  typeIntoTab(
326
- live.handle,
327
- `Fleet mail from ${from.bot}/${from.session}: ${subject}. Read it with obk message check --bots ${to.bots} --bot ${to.bot} --session ${to.session}`,
345
+ found.handle,
346
+ `Fleet mail from ${from.bot}/${from.session}: ${subject}. Read it with ${shellWord(ownCli())} message check --bots ${shellWord(to.bots)} --bot ${shellWord(to.bot)} --session ${shellWord(to.session)}`,
328
347
  );
329
348
  return { nudged: true };
330
349
  } catch (error) {
@@ -377,4 +396,4 @@ function whyNotReachable(bot, session, harness, held) {
377
396
  }
378
397
 
379
398
  const notUpYet = (who) =>
380
- `${who.bot}/${who.session} has no mailbox yet: it has never been brought up. Start it and it gets one: obk up --bots ${who.bots} --bot ${who.bot} --session ${who.session}`;
399
+ `${who.bot}/${who.session} has no mailbox yet: it has never been brought up. Start it and it gets one: ${shellWord(ownCli())} up --bots ${shellWord(who.bots)} --bot ${shellWord(who.bot)} --session ${shellWord(who.session)}`;
@@ -0,0 +1,21 @@
1
+ // Run by Orca's own binary as plain Node, the way Orca's `bin/orca` runs its
2
+ // CLI: `ELECTRON_RUN_AS_NODE=1 <Orca> orca-window.cjs <client> <projectId> <wait>`.
3
+ //
4
+ // It asks Orca's runtime, through Orca's own client, for `project.update` with
5
+ // no changes on one project. That is the call that makes Orca's window read its
6
+ // projects again, and Orca's CLI does not offer it (#224, ADR 0021). It exits 0
7
+ // only when the runtime took the call; anything else is a non-zero exit, and
8
+ // the kit reads every non-zero exit the same way.
9
+
10
+ 'use strict';
11
+
12
+ const [client, projectId, wait] = process.argv.slice(2);
13
+
14
+ process.exitCode = 1;
15
+ const { RuntimeClient } = require(client);
16
+ new RuntimeClient(undefined, Number(wait))
17
+ .call('project.update', { projectId, updates: {} })
18
+ .then((answer) => {
19
+ if (answer?.ok === true) process.exitCode = 0;
20
+ }, () => {})
21
+ .finally(() => process.exit());