@assuredloop/orca-bot-kit 0.1.0 → 0.2.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 (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +545 -3
  3. package/SETUP.md +270 -0
  4. package/package.json +32 -13
  5. package/rules/evidence.md +15 -0
  6. package/rules/finishing.md +20 -0
  7. package/rules/limits.md +18 -0
  8. package/rules/mail.md +17 -0
  9. package/rules/profiles.md +9 -0
  10. package/rules/review.md +14 -0
  11. package/rules/scope.md +20 -0
  12. package/rules/simple.md +14 -0
  13. package/rules/talk.md +13 -0
  14. package/rules/tests-first.md +15 -0
  15. package/rules/the-ask.md +19 -0
  16. package/skills/obk-arch/LICENSES.md +45 -0
  17. package/skills/obk-arch/NOTICE.md +134 -0
  18. package/skills/obk-arch/SKILL.md +387 -0
  19. package/skills/obk-bot-building/LICENSES.md +21 -0
  20. package/skills/obk-bot-building/NOTICE.md +113 -0
  21. package/skills/obk-bot-building/SKILL.md +235 -0
  22. package/skills/obk-debugging/LICENSES.md +67 -0
  23. package/skills/obk-debugging/NOTICE.md +115 -0
  24. package/skills/obk-debugging/SKILL.md +419 -0
  25. package/skills/obk-decision-memo/LICENSES.md +49 -0
  26. package/skills/obk-decision-memo/NOTICE.md +181 -0
  27. package/skills/obk-decision-memo/SKILL.md +241 -0
  28. package/skills/obk-finops/LICENSES.md +19 -0
  29. package/skills/obk-finops/NOTICE.md +77 -0
  30. package/skills/obk-finops/SKILL.md +146 -0
  31. package/skills/obk-fleet-review/LICENSES.md +52 -0
  32. package/skills/obk-fleet-review/NOTICE.md +104 -0
  33. package/skills/obk-fleet-review/SKILL.md +219 -0
  34. package/skills/obk-grilling/LICENSES.md +42 -0
  35. package/skills/obk-grilling/NOTICE.md +103 -0
  36. package/skills/obk-grilling/SKILL.md +217 -0
  37. package/skills/obk-grooming/LICENSES.md +52 -0
  38. package/skills/obk-grooming/NOTICE.md +111 -0
  39. package/skills/obk-grooming/SKILL.md +219 -0
  40. package/skills/obk-handoff/LICENSES.md +45 -0
  41. package/skills/obk-handoff/NOTICE.md +51 -0
  42. package/skills/obk-handoff/SKILL.md +119 -0
  43. package/skills/obk-personal-facilitation/LICENSES.md +53 -0
  44. package/skills/obk-personal-facilitation/NOTICE.md +62 -0
  45. package/skills/obk-personal-facilitation/SKILL.md +86 -0
  46. package/skills/obk-recall/LICENSES.md +42 -0
  47. package/skills/obk-recall/NOTICE.md +53 -0
  48. package/skills/obk-recall/SKILL.md +129 -0
  49. package/skills/obk-researching/LICENSES.md +67 -0
  50. package/skills/obk-researching/NOTICE.md +170 -0
  51. package/skills/obk-researching/SKILL.md +259 -0
  52. package/skills/obk-reviewing/LICENSES.md +66 -0
  53. package/skills/obk-reviewing/NOTICE.md +58 -0
  54. package/skills/obk-reviewing/SKILL.md +408 -0
  55. package/skills/obk-tdd/LICENSES.md +68 -0
  56. package/skills/obk-tdd/NOTICE.md +54 -0
  57. package/skills/obk-tdd/SKILL.md +516 -0
  58. package/skills/obk-tdd/mutation-tools.md +80 -0
  59. package/skills/obk-teach/LICENSES.md +42 -0
  60. package/skills/obk-teach/NOTICE.md +65 -0
  61. package/skills/obk-teach/SKILL.md +106 -0
  62. package/skills/obk-why/LICENSES.md +42 -0
  63. package/skills/obk-why/NOTICE.md +74 -0
  64. package/skills/obk-why/SKILL.md +169 -0
  65. package/skills/obk-writing/LICENSES.md +272 -0
  66. package/skills/obk-writing/NOTICE.md +161 -0
  67. package/skills/obk-writing/SKILL.md +254 -0
  68. package/src/book.js +268 -0
  69. package/src/bot.js +451 -0
  70. package/src/cli.js +1130 -0
  71. package/src/conversations.js +150 -0
  72. package/src/groom.js +157 -0
  73. package/src/health.js +309 -0
  74. package/src/hooks.js +202 -0
  75. package/src/init.js +314 -0
  76. package/src/launch.js +276 -0
  77. package/src/message.js +380 -0
  78. package/src/orca.js +291 -0
  79. package/src/pause.js +58 -0
  80. package/src/record.js +209 -0
  81. package/src/restart.js +192 -0
  82. package/src/retire.js +92 -0
  83. package/src/roster.js +71 -0
  84. package/src/rules.js +408 -0
  85. package/src/skills.js +629 -0
  86. package/src/sources.js +370 -0
  87. package/src/up.js +475 -0
  88. package/src/usage.js +401 -0
  89. package/bin/okb.js +0 -4
  90. package/src/index.js +0 -11
package/src/up.js ADDED
@@ -0,0 +1,475 @@
1
+ // `obk up`: make the bots real in Orca, and put back whatever is missing.
2
+ //
3
+ // It only ever adds. It never closes a tab: closing one throws away the user's
4
+ // conversation and Orca's resume record with it. Run it twice and the second
5
+ // run does nothing.
6
+
7
+ import { existsSync, mkdirSync, realpathSync, writeFileSync } from 'node:fs';
8
+ import path from 'node:path';
9
+
10
+ import { forgetClaimed, readBook, sessionIdsIn, tabIdsIn, updateBook, withUnclaimed } from './book.js';
11
+ import { botDir, botNames, displayName, readBot } from './bot.js';
12
+ import { conversationsIn } from './conversations.js';
13
+ import { installHook } from './hooks.js';
14
+ import { addressOf, harnessOf, isShortPrompt, launchCommand, reachesMail, sessionTrouble, startPrompt, workDirOf } from './launch.js';
15
+ import { asFolderProject, findProject, makeMailbox, makeProject, openTab, retitleTab, tabs, tuiInTab, typeIntoTab } from './orca.js';
16
+ import { buildAgents } from './rules.js';
17
+ import { linkSkills } from './skills.js';
18
+
19
+ /** The one bot with a tab beside its sessions: the ops tab (PRD 6.2). */
20
+ export const BOT_FATHER = 'bot-father';
21
+
22
+ /** How long a harness is given to draw its first screen before the kit gives up on it. */
23
+ const STARTUP_MS = 10000;
24
+
25
+ /**
26
+ * And how long the second look takes. A harness can draw its first screen and
27
+ * then die on something it was handed — a context window it cannot read, an
28
+ * extra argument it does not know — and the first look would have called it
29
+ * started. A tab whose TUI is still there answers this at once; only one that
30
+ * has fallen back to a shell waits it out.
31
+ */
32
+ const SECOND_LOOK_MS = 2000;
33
+
34
+ /**
35
+ * Bring bots up in Orca. Returns `{ tabs, rules }`: one tab entry per tab it
36
+ * looked at — the sessions the book knows, and, for Bot Father, whatever else
37
+ * is open in its project — and one rules entry and one skills entry per bot.
38
+ *
39
+ * With no name it is every bot in the folder, in name order. `bot` brings up
40
+ * one bot and `session` one of its sessions, for a caller that wants one thing
41
+ * back rather than the fleet.
42
+ */
43
+ export async function bringUp(bots, { bot: onlyBot, session: onlySession } = {}) {
44
+ const named = botsNamed(bots, onlyBot);
45
+ if (onlySession !== undefined && onlyBot === undefined) {
46
+ throw new Error('--session needs --bot: say which bot the session belongs to.');
47
+ }
48
+
49
+ // A paused bot is left closed, rules, skills, tabs and all, and said to be:
50
+ // it was paused on purpose, and `obk unpause` is what brings it back.
51
+ const paused = [];
52
+ const names = named.filter((name) => {
53
+ if (readBot(botDir(bots, name), name).paused !== true) return true;
54
+ paused.push({ bot: name });
55
+ return false;
56
+ });
57
+
58
+ const { running, rules, skills } = prepareBots(bots, names, onlySession);
59
+
60
+ const report = [];
61
+ for (const { bot, home } of running) {
62
+ for (const session of sessionsOf(bot, onlySession)) {
63
+ if (session.paused === true) paused.push({ bot: bot.name, session: session.name });
64
+ }
65
+ report.push(...await bringUpBot(bots, home, bot, onlySession));
66
+ }
67
+
68
+ // A bot whose rules would not build is reported here rather than in the
69
+ // preparation, because "its sessions were not started" is this command's
70
+ // answer and not a fact about the bot: a restart prepares the same way and
71
+ // has its own thing to say about it.
72
+ const started = new Set(running.map(({ bot }) => bot.name));
73
+ return {
74
+ tabs: report,
75
+ rules: rules.map((entry) => (started.has(entry.bot) ? entry : {
76
+ ...entry,
77
+ trouble: `${entry.trouble} Its sessions were not started: a bot comes up with its rules or not at all.`,
78
+ })),
79
+ skills,
80
+ paused,
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Everything the bots need in place before a session starts, and which of them
86
+ * can be started at all: `{ running, rules, skills }`.
87
+ *
88
+ * Separate from the tabs because the order matters twice over. A session reads
89
+ * its rules, its skills and its hooks as it comes up, so all three have to be
90
+ * there before the tab is (PRD 6.6, ADR 0010) — and a restart closes a tab in
91
+ * between, so everything that can refuse must have refused before that. It
92
+ * refuses by throwing, exactly as `up` always has; a bot it leaves out of
93
+ * `running` is one whose sessions must not be started.
94
+ */
95
+ export function prepareBots(bots, names, onlySession) {
96
+ // Every bot that is coming up is read and judged before Orca is asked for
97
+ // anything at all: a fleet with one session the kit cannot start is a fleet
98
+ // the user fixes in one edit, not one they find half opened.
99
+ const chosen = names.map((name) => {
100
+ const home = realpathSync(botDir(bots, name));
101
+ return { home, bot: readBot(home, name) };
102
+ });
103
+ for (const { bot, home } of chosen) refuseWhatCannotStart(bot, home, onlySession);
104
+
105
+ // Every bot's AGENTS.md is built before Orca is asked for anything, because a
106
+ // session reads that file as it starts: a build landing after the tab was
107
+ // opened would reach nobody until the next restart (PRD 6.6). It is built at
108
+ // the path the user spelled rather than the resolved one, which is Orca's,
109
+ // so a folder reached through a link is not reported as a road out of the
110
+ // bots folder and back in.
111
+ //
112
+ // A bot the build left with no instructions file at all does not come up:
113
+ // its charter is the boundary it acts inside, and a session started without
114
+ // one has no boundary, which is worse than a session that is not running. A
115
+ // file the user edited themselves is another matter — that bot has its
116
+ // instructions, they are simply theirs, and it comes up like any other.
117
+ //
118
+ // Its skills are linked first, and for the same reason: both harnesses read a
119
+ // project's skills out of the folder the session starts in, so a session that
120
+ // came up before the links were made would be a bot missing the techniques it
121
+ // was given. A skills list the kit cannot follow is reported and does not hold
122
+ // the bot down — skills are what a bot is good at, not the boundary it works
123
+ // inside.
124
+ const skills = chosen.map(({ bot }) => linkSkills(bots, botDir(bots, bot.name), bot));
125
+
126
+ const rules = [];
127
+ const running = [];
128
+ for (const chose of chosen) {
129
+ const built = buildAgents(bots, botDir(bots, chose.bot.name), chose.bot);
130
+ rules.push(built);
131
+ if (existsSync(built.file)) running.push(chose);
132
+ }
133
+
134
+ // And the kit's hook goes into every bot folder before Orca is asked for
135
+ // anything, for the same reason: a harness reads its hooks when it comes up,
136
+ // so one written later would miss the session it was written for (ADR 0010),
137
+ // and a bot folder the kit cannot write it into stops the run with nothing
138
+ // opened anywhere. Only the harnesses a bot actually runs on; a bot with no
139
+ // sessions gets none.
140
+ for (const { bot, home } of running) {
141
+ for (const harness of new Set(awake(sessionsOf(bot, onlySession)).map((session) => harnessOf(session, bot.harness)))) {
142
+ installHook(home, harness, { bots, bot: bot.name });
143
+ }
144
+ }
145
+
146
+ return { running, rules, skills };
147
+ }
148
+
149
+ /**
150
+ * The bots a command is about: every one in the folder, or the one it named.
151
+ *
152
+ * Here because every command that acts on Orca asks the same two questions
153
+ * first, and a user who mistypes a bot's name is owed the same sentence
154
+ * whichever of them they ran.
155
+ */
156
+ export function botsNamed(bots, onlyBot) {
157
+ const names = botNames(bots);
158
+ if (names.length === 0) {
159
+ throw new Error(`there are no bots in ${bots} yet: make one with obk bot create --bots <path> --name <name> --harness claude|codex.`);
160
+ }
161
+ if (onlyBot !== undefined && !names.includes(onlyBot)) {
162
+ throw new Error(`there is no bot called ${onlyBot} in ${bots}. The bots there are: ${names.join(', ')}.`);
163
+ }
164
+ return onlyBot === undefined ? names : [onlyBot];
165
+ }
166
+
167
+ function refuseWhatCannotStart(bot, home, onlySession) {
168
+ for (const session of awake(sessionsOf(bot, onlySession))) {
169
+ const trouble = sessionTrouble(session, harnessOf(session, bot.harness), home);
170
+ if (trouble !== undefined) throw new Error(`${bot.name}: ${trouble}`);
171
+ }
172
+ }
173
+
174
+ /**
175
+ * The sessions that are not paused. A paused session is left out of a run
176
+ * altogether, so what it is set to asks nothing of the ones that will start;
177
+ * it is judged again when it is unpaused.
178
+ */
179
+ const awake = (sessions) => sessions.filter((session) => session.paused !== true);
180
+
181
+ /** The sessions a run is bringing up: all of the bot's, or the one it named. */
182
+ export function sessionsOf(bot, onlySession) {
183
+ if (onlySession === undefined) return bot.sessions;
184
+
185
+ const named = bot.sessions.filter((session) => session.name === onlySession);
186
+ if (named.length === 0) {
187
+ throw new Error(`${bot.name} has no session called ${onlySession}. Add it with obk session add, or name one it has.`);
188
+ }
189
+ return named;
190
+ }
191
+
192
+ async function bringUpBot(bots, home, bot, onlySession) {
193
+ const name = bot.name;
194
+ const title = displayName(name);
195
+ const sessions = awake(sessionsOf(bot, onlySession));
196
+
197
+ // Orca is asked first and the book is written after: nothing that takes time
198
+ // happens while the book is held, because a session's own hook may be writing
199
+ // its id into that same file at any moment.
200
+ const orca = orcaProject(home, title);
201
+ await updateBook(home, (book) => { book.orca = orca; });
202
+
203
+ const live = new Map(tabs(home).map((tab) => [tab.tabId, tab]));
204
+ const report = [];
205
+ for (const session of sessions) report.push(await bringUpSession(bots, home, live, session, bot, title));
206
+
207
+ // The ops tab, and the whole of what the kit knows about it: Bot Father's
208
+ // project needs one tab that is not a session, for work across the fleet. Any
209
+ // tab outside the book is that tab — the kit does not ask who made it, does
210
+ // not write it down and does not touch it again. No other bot has one, and a
211
+ // run asked for one session is not the run to go looking.
212
+ if (name === BOT_FATHER && onlySession === undefined) {
213
+ const sessionTabs = tabIdsIn(readBook(home));
214
+ const spare = [...live.values()].filter((tab) => !sessionTabs.has(tab.tabId));
215
+ report.push(...(spare.length === 0
216
+ ? [entry(openTab(home, `${title} ops`), { bot: name, name: null, created: true })]
217
+ : spare.map((tab) => entry(tab, { bot: name, name: null, created: false }))));
218
+ }
219
+
220
+ return report;
221
+ }
222
+
223
+ async function bringUpSession(bots, home, live, session, bot, title) {
224
+ const harness = harnessOf(session, bot.harness);
225
+ const book = readBook(home);
226
+ const was = book.sessions[session.name];
227
+ const known = live.get(was?.tab);
228
+ const tabTitle = `${title} ${session.name}`;
229
+
230
+ if (known) {
231
+ // A mailbox it can be written to, and no name. The name is the one thing
232
+ // the kit cannot give a session that is already running: `-n` goes on the
233
+ // launch line, this session was launched without one, and nothing renames a
234
+ // live harness. Writing the name down here would advertise an address that
235
+ // answers to nobody (review of PR #132, finding 1). It gets one the next
236
+ // time it starts, which is the next time the kit types its launch line.
237
+ await ensureMailbox(home, bot, session, harness);
238
+
239
+ // Whatever runs in the tab may have rewritten its title. The kit writes its
240
+ // own back, and reports that one rather than the name Orca last saw: the id
241
+ // is the session, and a title is only ever set. Nothing is typed into a tab
242
+ // that is already there — the session is in the middle of its work.
243
+ retitleTab(known.handle, tabTitle);
244
+ return entry({ ...known, title: tabTitle }, { bot: bot.name, name: session.name, created: false });
245
+ }
246
+
247
+ // Everything that can be refused is settled before Orca is asked for
248
+ // anything, so a session the kit cannot start leaves no tab behind.
249
+ const workDir = workDirOf(session, home);
250
+
251
+ // Which conversation this session is. A session the kit can name is picked up
252
+ // where it left off, with the conversation it had: the tab is gone, but the
253
+ // harness still has the session. Its duty was given to it once and is not
254
+ // given again (PRD 6.4) — after a clear it is, and that is the hook's work.
255
+ // Asked before the book is held, because it reads a folder of the harness's
256
+ // own files: nothing slow happens under the lock.
257
+ const which = whichConversation(book, home, bot, session, was, harness);
258
+ const resume = which.resume;
259
+ const prompt = resume === undefined ? startPrompt(session, { home, workDir }) : undefined;
260
+ // Anything longer than a line goes to the harness out of a file, rather than
261
+ // through the tab's shell a character at a time.
262
+ const promptFile = prompt === undefined || isShortPrompt(prompt) ? undefined : promptPath(bots, bot.name, session.name);
263
+ const command = launchCommand(session, {
264
+ harness,
265
+ home,
266
+ workDir,
267
+ prompt,
268
+ promptFile,
269
+ resume,
270
+ address: harness === 'claude' ? addressOf(bot.name, session.name) : undefined,
271
+ });
272
+
273
+ // A work dir is a plain folder, made for the session before it is told about
274
+ // it (PRD 6.4). Nothing here is a git worktree.
275
+ if (workDir !== undefined) mkdirSync(workDir, { recursive: true });
276
+
277
+ // The prompt is written where the launch line can read it from, before that
278
+ // line is typed. It is the kit's own file, not the user's: theirs stays where
279
+ // they put it, in the bot home.
280
+ if (promptFile !== undefined) {
281
+ mkdirSync(path.dirname(promptFile), { recursive: true });
282
+ writeFileSync(promptFile, prompt);
283
+ }
284
+
285
+ const made = openTab(home, tabTitle);
286
+
287
+ // Written down the moment it exists, before anything that can fail. A tab
288
+ // whose id never reached the book is a tab nobody owns: the next run would
289
+ // start a second harness beside it and take this one for the spare. What the
290
+ // book already knew about the session stays — including an id written by a
291
+ // hook while this run was busy with Orca; only the tab, the time this run
292
+ // started a harness in it, and a conversation taken over from the harness's
293
+ // own record are new.
294
+ const launched = new Date().toISOString();
295
+ await updateBook(home, (current) => {
296
+ // What the harness has in this folder that nobody claims goes on the record,
297
+ // for a person or Bot Father to settle — added to whatever was already noted,
298
+ // because this run's scan cannot see what an earlier one found. The kit never
299
+ // settles it itself.
300
+ const entry = { ...current.sessions[session.name], tab: made.tabId, launched };
301
+ current.sessions[session.name] = withUnclaimed(entry, which.unclaimed ?? []);
302
+ forgetClaimed(current);
303
+ });
304
+
305
+ // Typing it in is the way: for a project the kit has just made, giving Orca
306
+ // the harness as the tab's own command times out and leaves a dead tab.
307
+ typeIntoTab(made.handle, command);
308
+
309
+ // Now the session has an address, and not before: the mailbox it can be
310
+ // written to, and — on Claude Code — the name that line just gave it, which
311
+ // is the name another Claude session writes to. Both are written after the
312
+ // tab is on the books, so a mailbox Orca will not make leaves a tab the next
313
+ // run finds and finishes rather than a tab nobody owns (review of PR #132,
314
+ // finding 3).
315
+ await ensureMailbox(home, bot, session, harness, { named: harness === 'claude' });
316
+
317
+ // And then asking whether a TUI came up, rather than assuming one did. The
318
+ // text goes into the tab's own shell, which may have been busy with a
319
+ // question of its own and swallowed the first characters of it.
320
+ //
321
+ // Twice, because coming up and staying up are different things: a harness
322
+ // that refuses what it was handed draws a screen, prints its complaint and
323
+ // leaves a shell behind, and a run that looked once would report it as
324
+ // started. A tab that still has a TUI answers the second look at once,
325
+ // whatever the agent in it is busy with.
326
+ const tui = tuiInTab(made.handle, STARTUP_MS).running ? tuiInTab(made.handle, SECOND_LOOK_MS) : { running: false };
327
+
328
+ // The start prompt went in with that line, as the harness's own prompt
329
+ // argument, so it is the harness that holds it until it is ready — through
330
+ // its folder-trust question and its update offer. What is still worth saying
331
+ // is whether the line took at all: a shell that swallowed it swallowed the
332
+ // duty with it, and nobody has been told anything.
333
+ const promptSent = prompt === undefined ? undefined : tui.running === true;
334
+
335
+ return entry(made, {
336
+ bot: bot.name,
337
+ name: session.name,
338
+ created: true,
339
+ ...tui,
340
+ promptSent,
341
+ promptFile,
342
+ resumed: resume !== undefined,
343
+ unclaimed: which.unclaimed,
344
+ });
345
+ }
346
+
347
+ /**
348
+ * Make sure the book holds this session's mailbox, and — only where this run
349
+ * has just launched the session under it — the name it answers to.
350
+ *
351
+ * The name is written by whoever typed the launch line that gave it, and by
352
+ * nobody else. A session that was already running when the kit reached it was
353
+ * started without `-n`, nothing renames a live harness, and a name in the book
354
+ * that no harness answers to is worse than no name: the kit would send another
355
+ * session to an address with nobody at it.
356
+ *
357
+ * The mailbox is an Orca Run, made once and kept for ever. It has to be a Run
358
+ * rather than the session's tab: Orca calls a terminal a live terminal-only
359
+ * mailbox, says plainly that delivery does not outlive the tab, and refuses a
360
+ * send once the pane is gone, while a Run survives a closed tab, a relaunch and
361
+ * a restart (tech notes, section 1). Orca offers no way to delete one, so this
362
+ * makes exactly one per session and never a second.
363
+ *
364
+ * A Codex session whose user turned the sandbox switch off gets none: it could
365
+ * not read a mailbox if it had one, and an address nobody can read is worse
366
+ * than none at all. `obk message` says so in those words.
367
+ */
368
+ async function ensureMailbox(home, bot, session, harness, { named = false } = {}) {
369
+ const held = readBook(home).sessions[session.name] ?? {};
370
+
371
+ const address = named ? addressOf(bot.name, session.name) : undefined;
372
+ const mailbox = mailboxFor(readBook(home), bot, session, harness);
373
+
374
+ if (mailbox === undefined && (address === undefined || held.address === address)) return;
375
+
376
+ await updateBook(home, (current) => {
377
+ const entry = { ...current.sessions[session.name] };
378
+ // Under the lock, and only if the book still has none: two runs at once
379
+ // would each have made one, and a session with two mailboxes is a session
380
+ // half its mail never reaches. The loser's Run is left unused.
381
+ if (mailbox !== undefined && typeof entry.mailbox !== 'string') entry.mailbox = mailbox;
382
+ if (address !== undefined) entry.address = address;
383
+ current.sessions[session.name] = entry;
384
+ });
385
+ }
386
+
387
+ /**
388
+ * A new mailbox for this session, or undefined when it should not have one: it
389
+ * has one already, or it is a Codex session whose user turned the sandbox
390
+ * switch off, which could not read a mailbox if it had one.
391
+ *
392
+ * The Orca call is made outside the book's lock, which is held for one read and
393
+ * one write (book.js).
394
+ */
395
+ function mailboxFor(book, bot, session, harness) {
396
+ const held = book.sessions[session.name] ?? {};
397
+ if (typeof held.mailbox === 'string' || !reachesMail(session, harness)) return undefined;
398
+ return makeMailbox(`${bot.name}/${session.name}`);
399
+ }
400
+
401
+ /**
402
+ * Which conversation this session is about to be, as far as anything can say:
403
+ * the one the book holds, one the harness itself still has on record, or none.
404
+ *
405
+ * One tab holds one session, and the book is the authority for which
406
+ * conversation that is (ADR 0002). But the book can be incomplete — on Codex a
407
+ * hooks file must be trusted before any hook runs, and trusting it does not
408
+ * replay the event it missed — and "the book does not say" must never be read as
409
+ * "there was no conversation". So where the book is silent about a tab the kit
410
+ * has already started a harness in, the harness's own record is asked.
411
+ */
412
+ function whichConversation(book, home, bot, session, was, harness) {
413
+ if (typeof was?.session === 'string') return { resume: was.session };
414
+ // No tab: nothing has ever run for this session, so there is nothing to find.
415
+ if (typeof was?.tab !== 'string') return {};
416
+
417
+ // A harness ran in this tab before and the kit never learned which
418
+ // conversation it became — on Codex, a hooks file trusted too late is enough
419
+ // to do that. What the harness still has in this folder is worth writing down,
420
+ // but it is never this session's by the kit's say-so: the folder holds every
421
+ // session of the bot and everything they started inside themselves, and the
422
+ // review proved both ways that go wrong. So the session starts a new
423
+ // conversation with its duty, and what nobody claims is left on the record for
424
+ // a person or Bot Father to settle.
425
+ const claimed = sessionIdsIn(book);
426
+ const known = typeof was.launched === 'string'
427
+ ? conversationsIn(harness, home, was.launched).filter((one) => !claimed.has(one.id))
428
+ : [];
429
+ return { unclaimed: known.map((one) => one.id) };
430
+ }
431
+
432
+ /**
433
+ * A bot's Orca project, made if it is not there yet.
434
+ *
435
+ * It has to be a folder workspace. Registering a folder that sits inside the
436
+ * bots repo gets it recorded as a git one, which Orca gives no workspace at
437
+ * all, so no tab can be opened in it.
438
+ */
439
+ function orcaProject(home, title) {
440
+ const found = findProject(home);
441
+ const setup = found === undefined
442
+ ? makeProject(home, title)
443
+ : (found.kind === 'folder' ? found : asFolderProject(found.id, title));
444
+ return { project: setup.projectId, setup: setup.id };
445
+ }
446
+
447
+ /**
448
+ * Where the kit leaves a prompt for the launch line to pick up: a folder of its
449
+ * own beside the bots repo, the way skill-source clones sit beside it (PRD 6.3)
450
+ * — kit-made, never inside the user's repo, so it stays out of their git status.
451
+ *
452
+ * Beside *this* bots folder, and not in a shared temp directory: two bots
453
+ * folders may each hold an api-bot with a daily session, and one file for both
454
+ * of them is one bot's duty handed to another's session.
455
+ */
456
+ export const promptPath = (bots, bot, session) =>
457
+ path.join(`${bots}.prompts`, `${encodeURIComponent(bot)}.${encodeURIComponent(session)}.txt`);
458
+
459
+ function entry(tab, { bot, name, created, running = false, blockedReason, promptSent, promptFile, resumed, unclaimed }) {
460
+ const made = { bot, name, title: tab.title, tabId: tab.tabId, terminal: tab.handle, created, harnessStarted: running };
461
+ // Whether this run picked the session up where it was or started a new one.
462
+ // Only for a tab this run opened: a tab that was already there was left alone.
463
+ if (resumed !== undefined) made.resumed = resumed;
464
+ // Orca's own words for what is on screen waiting to be answered, when it
465
+ // gave any: the caller acts on it, the kit only passes it on.
466
+ if (blockedReason !== undefined) made.blockedReason = blockedReason;
467
+ // Conversations the harness still has in this bot's folder that no session
468
+ // claims: the caller is told, because the kit will not pick one.
469
+ if (unclaimed !== undefined && unclaimed.length > 0) made.unclaimed = unclaimed;
470
+ // Only for a session this run started that had something to be told.
471
+ if (promptSent !== undefined) made.promptSent = promptSent;
472
+ // And the file it was told it out of, when it was too long for the line.
473
+ if (promptFile !== undefined) made.promptFile = promptFile;
474
+ return made;
475
+ }