@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/cli.js ADDED
@@ -0,0 +1,1130 @@
1
+ #!/usr/bin/env node
2
+
3
+ // `obk` — the Orca Bot Kit command line. Bot Father's skills call it; a person
4
+ // can too. It writes files and reports what it did; it never commits.
5
+ //
6
+ // The caller is normally an LLM running a setup step, so every command says
7
+ // plainly what it made and what still wants looking at, and `--json` gives it
8
+ // the same facts to act on.
9
+
10
+ import { readFileSync, realpathSync } from 'node:fs';
11
+ import path from 'node:path';
12
+ import { parseArgs } from 'node:util';
13
+
14
+ import { addSession, changeBot, changeSession, createBot, readBot, SESSION_FIELDS } from './bot.js';
15
+ import { grooming } from './groom.js';
16
+ import { checkHealth, orcaSettingFindings } from './health.js';
17
+ import { initBots } from './init.js';
18
+ import { APPROVALS, HARNESSES, shellWord } from './launch.js';
19
+ import { checkMail, lookUp, sendMessage } from './message.js';
20
+ import { orcaCli, orcaTrouble } from './orca.js';
21
+ import { pauseSessions, unpauseSessions } from './pause.js';
22
+ import { recordSession, SHELL_ENV, TAB_ENV } from './record.js';
23
+ import { restartSessions } from './restart.js';
24
+ import { retireBot, retireSession } from './retire.js';
25
+ import { readRoster } from './roster.js';
26
+ import { buildAgents, buildRules, CODEX_CAP } from './rules.js';
27
+ import { addSkill, buildSkills, linkSkills, removeSkill } from './skills.js';
28
+ import { addSource, fetchSources } from './sources.js';
29
+ import { readUsage } from './usage.js';
30
+ import { BOT_FATHER, bringUp } from './up.js';
31
+
32
+ const USAGE = `obk — Orca Bot Kit.
33
+
34
+ Usage:
35
+ obk init --bots <path> --harness claude|codex
36
+ Create your bots folder: a git repo holding your
37
+ bots' configuration, with Bot Father in it, and
38
+ open Bot Father in Orca.
39
+ obk bot create --bots <path> --name <bot> --harness claude|codex
40
+ [--charter <text>]
41
+ Write a new bot in your bots folder. Nothing is
42
+ opened in Orca until you run obk up.
43
+ obk session add --bots <path> --bot <bot> --name <session>
44
+ [--harness claude|codex] [--model <m>] [--effort <e>]
45
+ [--context <c>] [--approval ${APPROVALS.join('|')}]
46
+ [--prompt <text> | --prompt-file <path>] [--work-dir <path>]
47
+ [--extra-arg=<arg>]
48
+ Add a session to a bot. Anything left out is the
49
+ harness's own default; approval is auto. A long
50
+ start prompt lives in a file in the bot home, and
51
+ --prompt-file names it.
52
+ A value of your own that starts with a dash is
53
+ given glued to its flag, so its dashes are not read
54
+ as ours: --prompt='- a bullet', and
55
+ --extra-arg=--search, once per extra argument.
56
+ obk bot change --bots <path> --bot <bot> --charter <text>
57
+ Give a bot a new charter and rebuild its AGENTS.md.
58
+ A running session reads it when it next starts.
59
+ obk session change --bots <path> --bot <bot> --session <session>
60
+ [--model <m>] [--effort <e>] [--context <c>]
61
+ [--approval ${APPROVALS.join('|')}]
62
+ [--prompt <text> | --prompt-file <path>] [--work-dir <path>]
63
+ [--extra-arg=<arg>]
64
+ Change a session's settings. What you leave out
65
+ stays as it is; a setting given empty, --model=,
66
+ goes back to the harness's own default. A running
67
+ session takes the change when it next starts. A
68
+ session keeps its harness: to move it, retire it
69
+ and add another.
70
+ obk pause --bots <path> --bot <bot> [--session <name>]
71
+ Stop a bot, or one of its sessions, for now: close
72
+ its tabs and have obk up leave it closed. The book
73
+ keeps its conversations. Like restart, it closes
74
+ only the tabs your book names, and none whose
75
+ conversation the book cannot name.
76
+ obk unpause --bots <path> --bot <bot> [--session <name>]
77
+ Take the pause off and bring it up again, each
78
+ session with the conversation it was having.
79
+ obk retire --bots <path> --bot <bot> [--session <name>]
80
+ End a session: close its tab and take it off the
81
+ bot, keeping its conversations in the book. Or end
82
+ a bot: close its tabs, remove its Orca project and
83
+ move its folder to retired/. It will not retire a
84
+ bot whose Orca project holds a tab your book does
85
+ not name.
86
+ obk rules build --bots <path> [--bot <bot>]
87
+ Build every bot's AGENTS.md from its charter and
88
+ the rule units it carries, or just the one you
89
+ name. Your own text outside the marked block is
90
+ kept; a block you edited by hand is reported and
91
+ never written over. It does not touch Orca.
92
+ obk skills add --bots <path> --bot <bot> --skill <ref>
93
+ Put one skill on a bot's list. It writes the list
94
+ and nothing else; obk skills build is what links
95
+ it. A skill already listed is left as it is.
96
+ obk skills remove --bots <path> --bot <bot> --skill <ref>
97
+ Take one skill off a bot's list. obk skills build
98
+ is what takes its link away.
99
+ obk skills build --bots <path> [--bot <bot>]
100
+ Link every bot's skills into both harnesses, or
101
+ just the one you name, from the kit, your own
102
+ skills folder, or any path, and take away a link
103
+ it made that no list names any more. What you put
104
+ in a bot's skills directory yourself is left alone
105
+ and shown as yours. It does not touch Orca.
106
+ obk skills fetch --bots <path> [--source <name>]
107
+ Clone the online sources skills.yaml lists, beside
108
+ your bots folder and never inside it, each at the
109
+ version you pinned, and write down the sha it got.
110
+ A source already there is left exactly as it is.
111
+ obk skills update --bots <path> [--source <name>]
112
+ Move a source on to what its ref names now, and
113
+ write down the new sha. This is the only thing that
114
+ moves one.
115
+ obk source add --bots <path> --name <name> --repo <url> --ref <ref>
116
+ [--path <subfolder>]
117
+ Write down where a shelf of skills online comes
118
+ from, and which version of it you want. It only
119
+ writes it down; obk skills fetch is what clones it.
120
+ obk message to --bots <path> --to <bot>[/<session>] [--from <bot>/<session>]
121
+ Say which road reaches that session and what its
122
+ address is. Claude to Claude in one approval class
123
+ is the harness's own messaging; everything else is
124
+ the Orca mailbox. Nothing is sent.
125
+ obk message send --bots <path> --to <bot>[/<session>] [--from <bot>/<session>]
126
+ --subject <text> [--text <text> | --text-file <path>]
127
+ [--thread <id>]
128
+ Put a message in that session's Orca mailbox and
129
+ tell its tab to look. A message too long to travel
130
+ as itself is written to a file beside your bots
131
+ folder and named in the message. A pair the
132
+ harness's own messaging reaches is not carried:
133
+ the address to write to is answered instead.
134
+ obk message check --bots <path> [--bot <bot>] [--session <name>] [--peek]
135
+ Read what is waiting for a session and mark it
136
+ read. --peek leaves it unread. Run in a session's
137
+ own tab, it is that session's mail.
138
+ obk up --bots <path> [--bot <bot>] [--session <name>]
139
+ Open whatever is missing in Orca, for every bot or
140
+ for the one you name. It only ever adds; it never
141
+ closes a tab. A session the book knows the harness
142
+ session of comes back with its conversation.
143
+ obk restart --bots <path> --bot <bot> [--session <name>]
144
+ Close a bot's session tabs and open them again,
145
+ each with the conversation it was having. It is the
146
+ one command that closes a tab, it closes only the
147
+ tabs your book names, and it will not close one
148
+ whose conversation the book cannot name.
149
+ obk health --bots <path> [--bot <bot>]
150
+ Say what is wrong with your setup: configuration
151
+ that will not work, a skill that is not where its
152
+ list says, a session Orca has lost, and what is
153
+ lying about that no bot owns. It reports and
154
+ changes nothing; what to do about each line is
155
+ yours to decide.
156
+ obk roster --bots <path> [--bot <bot>]
157
+ Say what your fleet is: every bot, its charter, the
158
+ rules and skills its lists name, and each session
159
+ with its settings, its tab and the conversation it
160
+ is in. It reads your files and reports them as they
161
+ stand; what to make of them is yours.
162
+ obk groom --bots <path> [--at <HH:MM>] [--on | --off]
163
+ Say whether the daily grooming exists, when it runs
164
+ and whether it is on, and set it up or change it
165
+ when you ask. --at makes one, in Bot Father's Orca
166
+ project. It is made off, because it spends tokens
167
+ every day: run it by hand once, read what it gives
168
+ you, then --on.
169
+ obk usage --bots <path> [--bot <bot>] [--session <name>] [--since <time>] [--until <time>]
170
+ Say what your sessions have used: the conversations
171
+ each one had, their calls and tokens, the models and
172
+ efforts they ran at, and how often they were
173
+ compacted. --since counts the calls made from that
174
+ moment on, and --until the calls made before that
175
+ one: a daily run asks from the last run's end up to
176
+ its own, and the next starts where it stopped, so no
177
+ call is counted twice. It counts tokens and never
178
+ money: what a token costs is looked up live by
179
+ whoever is asking.
180
+ obk session record --bots <path> --bot <bot>
181
+ For the kit's own hook, not for typing: it reads
182
+ what the harness says about a session starting on
183
+ standard input and writes it into the book.
184
+ obk --version Print the kit's version.
185
+ obk --help Print this text.
186
+
187
+ Every command is safe to run again. Most only add what is missing; the ones
188
+ that close a tab or take something away say so above.
189
+ Add --json to any of them for the same answer as JSON.
190
+ `;
191
+
192
+ /** The commands, and the flags each one cannot do without. */
193
+ const COMMANDS = {
194
+ init: ['bots', 'harness'],
195
+ up: ['bots'],
196
+ restart: ['bots', 'bot'],
197
+ pause: ['bots', 'bot'],
198
+ unpause: ['bots', 'bot'],
199
+ retire: ['bots', 'bot'],
200
+ health: ['bots'],
201
+ groom: ['bots'],
202
+ roster: ['bots'],
203
+ usage: ['bots'],
204
+ 'bot create': ['bots', 'name', 'harness'],
205
+ 'bot change': ['bots', 'bot'],
206
+ 'rules build': ['bots'],
207
+ 'skills add': ['bots', 'bot', 'skill'],
208
+ 'skills remove': ['bots', 'bot', 'skill'],
209
+ 'skills build': ['bots'],
210
+ 'skills fetch': ['bots'],
211
+ 'skills update': ['bots'],
212
+ 'source add': ['bots', 'name', 'repo', 'ref'],
213
+ 'session add': ['bots', 'bot', 'name'],
214
+ 'session change': ['bots', 'bot', 'session'],
215
+ 'message to': ['bots', 'to'],
216
+ 'message send': ['bots', 'to', 'subject'],
217
+ 'message check': ['bots'],
218
+ 'session record': ['bots', 'bot'],
219
+ };
220
+
221
+ /** What each flag is for, in the sentence a caller reads when it is missing. */
222
+ const NEEDED = {
223
+ bots: '--bots <path>: where your bots folder is',
224
+ name: '--name <name>: what to call it',
225
+ bot: '--bot <bot>: which bot',
226
+ session: '--session <name>: which session',
227
+ source: '--source <name>: which source',
228
+ since: '--since <time>: the moment to count from',
229
+ until: '--until <time>: the moment to count up to, not including it',
230
+ at: '--at <HH:MM>: what time of day it runs',
231
+ skill: "--skill <ref>: which skill, as a bot's list names one",
232
+ repo: '--repo <url>: the repository to clone it from',
233
+ ref: '--ref <ref>: the branch, tag or commit to pin it at',
234
+ path: '--path <subfolder>: where the skills sit inside that repository',
235
+ harness: `--harness ${HARNESSES.join('|')}: which harness it runs on`,
236
+ to: '--to <bot>/<session>: which session to write to',
237
+ from: '--from <bot>/<session>: which session is writing',
238
+ subject: '--subject <text>: what the message is about',
239
+ };
240
+
241
+ /** The flags that name something. A name that is empty names nothing. */
242
+ const IDENTIFIERS = Object.keys(NEEDED);
243
+
244
+ /**
245
+ * The session settings a flag can carry, as `[flag, field]`: every field a
246
+ * session has, spelled with dashes, except the name it is added under and the
247
+ * extra arguments, which come one flag at a time.
248
+ */
249
+ const SETTINGS = SESSION_FIELDS
250
+ .filter((field) => field !== 'name' && field !== 'extra_args')
251
+ .map((field) => [field.replaceAll('_', '-'), field]);
252
+
253
+ function version() {
254
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
255
+ return pkg.version;
256
+ }
257
+
258
+ async function run(argv) {
259
+ const { values, positionals } = parseArgs({
260
+ args: argv,
261
+ allowPositionals: true,
262
+ options: {
263
+ bots: { type: 'string' },
264
+ harness: { type: 'string' },
265
+ name: { type: 'string' },
266
+ bot: { type: 'string' },
267
+ session: { type: 'string' },
268
+ source: { type: 'string' },
269
+ since: { type: 'string' },
270
+ until: { type: 'string' },
271
+ at: { type: 'string' },
272
+ on: { type: 'boolean' },
273
+ off: { type: 'boolean' },
274
+ skill: { type: 'string' },
275
+ repo: { type: 'string' },
276
+ ref: { type: 'string' },
277
+ path: { type: 'string' },
278
+ to: { type: 'string' },
279
+ from: { type: 'string' },
280
+ subject: { type: 'string' },
281
+ text: { type: 'string' },
282
+ 'text-file': { type: 'string' },
283
+ thread: { type: 'string' },
284
+ peek: { type: 'boolean' },
285
+ charter: { type: 'string' },
286
+ ...Object.fromEntries(SETTINGS.map(([flag]) => [flag, { type: 'string' }])),
287
+ 'extra-arg': { type: 'string', multiple: true },
288
+ json: { type: 'boolean' },
289
+ help: { type: 'boolean', short: 'h' },
290
+ version: { type: 'boolean' },
291
+ },
292
+ });
293
+
294
+ if (values.help) {
295
+ process.stdout.write(USAGE);
296
+ return 0;
297
+ }
298
+ if (values.version) {
299
+ process.stdout.write(`${version()}\n`);
300
+ return 0;
301
+ }
302
+
303
+ if (positionals.length === 0) {
304
+ process.stderr.write(USAGE);
305
+ return 1;
306
+ }
307
+
308
+ // `bot`, `rules`, `skills`, `session`, `source` and `message` are commands of
309
+ // two words; the rest are one.
310
+ const words = ['bot', 'rules', 'skills', 'session', 'source', 'message'].includes(positionals[0]) ? 2 : 1;
311
+ const command = positionals.slice(0, words).join(' ');
312
+ const extra = positionals.slice(words);
313
+
314
+ if (!(command in COMMANDS)) {
315
+ throw new Error(`there is no "${command}" command. Run obk --help to see what there is.`);
316
+ }
317
+ if (extra.length > 0) {
318
+ throw new Error(`${command} takes no other arguments, and got: ${extra.join(' ')}`);
319
+ }
320
+ for (const flag of COMMANDS[command]) {
321
+ if (values[flag] === undefined) throw new Error(`${command} needs ${NEEDED[flag]}.`);
322
+ }
323
+ // A name with nothing in it is a name that was not given, whether the command
324
+ // needs it or not. The settings are not held to this: an empty model or
325
+ // effort is the harness's own, which is what leaving it out means too.
326
+ for (const flag of IDENTIFIERS) {
327
+ if (values[flag] !== undefined && values[flag].trim() === '') {
328
+ throw new Error(`${command} needs ${NEEDED[flag]}.`);
329
+ }
330
+ }
331
+ if (values.harness !== undefined && !HARNESSES.includes(values.harness)) {
332
+ throw new Error(`--harness is ${HARNESSES.join(' or ')}, and got: ${values.harness}`);
333
+ }
334
+
335
+ // One fleet, one identity, whatever spelling of its path was given (#164).
336
+ const bots = command === 'init' ? path.resolve(values.bots) : sameFleet(path.resolve(values.bots));
337
+ if (command === RECORD) return record(bots, values.bot);
338
+
339
+ const { answer, lines, code = 0 } = await commands[command](bots, values);
340
+
341
+ process.stdout.write(values.json ? `${JSON.stringify(answer, null, 2)}\n` : `${lines.join('\n')}\n`);
342
+ return code;
343
+ }
344
+
345
+ /**
346
+ * The bots folder as the file system knows it. A folder reached through a
347
+ * symlink is the same fleet: the harnesses file their transcripts under the real
348
+ * path, Orca records an automation's workspace by it, and what the kit keeps
349
+ * beside the folder (the skill sources, the start prompts, the messages) is
350
+ * named after it. Asked by one spelling and then another, a fleet would
351
+ * otherwise have two of each, and a pin set through one is undone through the
352
+ * other.
353
+ *
354
+ * Every command but `init` from the start. It is given a path that may not exist
355
+ * yet, and when it refuses one it names what the user gave it rather than
356
+ * whatever the link pointed at, which is a file they never mentioned; once the
357
+ * folder is there, it too works on the real path.
358
+ */
359
+ function sameFleet(bots) {
360
+ try {
361
+ return realpathSync(bots);
362
+ } catch {
363
+ return bots;
364
+ }
365
+ }
366
+
367
+ /**
368
+ * The line that sends someone to look at a tab, naming the Orca the kit itself
369
+ * talks to rather than the word `orca`.
370
+ *
371
+ * A bare `orca` is not a command everywhere: on this machine it is a root-only
372
+ * symlink and answers that it cannot find the app (tech notes, section 1), and
373
+ * the notes already record a session following this very advice and failing. So
374
+ * what is printed is what the kit would run, which is the only path known to
375
+ * work here.
376
+ *
377
+ * Through the kit's own `shellWord`, because `OBK_ORCA` can point at a path with
378
+ * a space in it, and unquoted the shell reads that as two words and the line
379
+ * fails for a reason that looks nothing like its cause.
380
+ */
381
+ const lookAt = (terminal) => `${shellWord(orcaCli())} terminal read --terminal ${terminal} --screen`;
382
+
383
+ /** The one command a harness runs rather than a person: the kit's hook. */
384
+ const RECORD = 'session record';
385
+
386
+ /**
387
+ * What the kit's hook does with what the harness told it, and what it answers.
388
+ *
389
+ * A hook runs inside the user's own session, so this one stays out of the way:
390
+ * it writes on standard output only what the harness is to read as JSON, and
391
+ * whatever goes wrong, it goes wrong quietly. A book left stale is a thing the
392
+ * health check finds later; a session disturbed is the user's work (ADR 0010).
393
+ */
394
+ async function record(bots, bot) {
395
+ try {
396
+ const answer = await recordSession(bots, bot, JSON.parse(readFileSync(0, 'utf8')), process.env[TAB_ENV], process.env[SHELL_ENV]);
397
+ if (answer !== undefined) process.stdout.write(`${JSON.stringify(answer)}\n`);
398
+ } catch {
399
+ // Nothing: see above.
400
+ }
401
+ return 0;
402
+ }
403
+
404
+ const commands = {
405
+ async init(bots, values) {
406
+ // Asked before anything is written, so an Orca that is down leaves the disk
407
+ // exactly as it was and the caller can simply run the command again.
408
+ refuseWhenOrcaIsDown();
409
+ const seeded = initBots(bots, values.harness);
410
+ // The typed path was for making the folder and naming it in a refusal; from
411
+ // here on it is the fleet, and the fleet is its real path (#164).
412
+ const { tabs, rules, skills, paused } = await bringUp(sameFleet(seeded.bots), { bot: BOT_FATHER });
413
+ // Setup is the other place the PRD asks for Orca's own launch arguments to
414
+ // be looked at (6.5), and the one where the user is still standing in front
415
+ // of the fleet they are making. Only that one check: a folder init has just
416
+ // made has nothing else to say about itself.
417
+ const answer = { bots: seeded.bots, created: seeded.created, completed: seeded.completed, rules, skills, tabs, paused, found: orcaSettingFindings() };
418
+ return { answer, lines: tabLines(answer, `Bot Father is up in Orca. Your bots folder: ${seeded.bots}`) };
419
+ },
420
+
421
+ async up(bots, values) {
422
+ refuseWhenOrcaIsDown();
423
+ const { tabs, rules, skills, paused } = await bringUp(bots, { bot: values.bot, session: values.session });
424
+ const answer = { bots, created: [], completed: [], rules, skills, tabs, paused };
425
+ const up = [...new Set(tabs.map((tab) => tab.bot))];
426
+ const summary = up.length === 0
427
+ ? `Nothing was brought up in Orca. Your bots folder: ${bots}`
428
+ : `Up in Orca: ${up.join(', ')}. Your bots folder: ${bots}`;
429
+ return { answer, lines: tabLines(answer, summary) };
430
+ },
431
+
432
+ health(bots, values) {
433
+ refuseWhenOrcaIsDown();
434
+ const found = checkHealth(bots, { bot: values.bot });
435
+ return {
436
+ answer: { bots, found },
437
+ lines: [
438
+ ...foundLines(found),
439
+ found.length === 0
440
+ ? `Nothing to report: everything the kit keeps is where it should be. Your bots folder: ${bots}`
441
+ : `${found.length} thing${found.length === 1 ? '' : 's'} to look at above. What to do about each is yours to decide. Your bots folder: ${bots}`,
442
+ ],
443
+ // Something to look at is not a command that failed, and the answer is
444
+ // printed either way; the code is there for whoever runs it in a script.
445
+ code: found.length === 0 ? 0 : 1,
446
+ };
447
+ },
448
+
449
+ async restart(bots, values) {
450
+ refuseWhenOrcaIsDown();
451
+ const { closed, tabs, rules, skills, paused } = await restartSessions(bots, { bot: values.bot, session: values.session });
452
+ const answer = { bots, created: [], completed: [], rules, skills, tabs, paused, closed };
453
+ const what = values.session === undefined ? values.bot : `${values.bot} ${values.session}`;
454
+ return {
455
+ answer,
456
+ lines: [
457
+ ...closed.map((tab) => `closed ${tab.bot} ${tab.name} tab ${tab.tabId} terminal ${tab.terminal}`),
458
+ ...tabLines(answer, `Restarted in Orca: ${what}. Your bots folder: ${bots}`),
459
+ ],
460
+ };
461
+ },
462
+
463
+ async pause(bots, values) {
464
+ refuseWhenOrcaIsDown();
465
+ const paused = await pauseSessions(bots, { bot: values.bot, session: values.session });
466
+ const what = values.session === undefined ? values.bot : `${values.bot} ${values.session}`;
467
+ const back = values.session === undefined ? '' : ` --session ${values.session}`;
468
+ return {
469
+ answer: { bots, ...paused },
470
+ lines: [
471
+ ...paused.closed.map((tab) => `closed ${tab.bot} ${tab.name} tab ${tab.tabId} terminal ${tab.terminal}`),
472
+ `${paused.changed ? 'paused' : 'there'.padEnd(6)} ${what}${paused.changed ? '' : ' was paused already'}`,
473
+ `obk up leaves it closed, and the book keeps its conversations. Bring it back: obk unpause --bots ${bots} --bot ${values.bot}${back}`,
474
+ ],
475
+ };
476
+ },
477
+
478
+ async unpause(bots, values) {
479
+ refuseWhenOrcaIsDown();
480
+ const { bot, session, changed, ...up } = await unpauseSessions(bots, { bot: values.bot, session: values.session });
481
+ const what = session === undefined ? bot : `${bot} ${session}`;
482
+ const answer = { bots, bot, session, changed, created: [], completed: [], ...up };
483
+ return {
484
+ answer,
485
+ lines: tabLines(answer, `${changed ? 'Unpaused' : 'Not paused, so brought up as it is'}: ${what}. Your bots folder: ${bots}`),
486
+ };
487
+ },
488
+
489
+ async retire(bots, values) {
490
+ refuseWhenOrcaIsDown();
491
+ const closedLines = (closed) => closed.map((tab) => `closed ${tab.bot} ${tab.name} tab ${tab.tabId} terminal ${tab.terminal}`);
492
+
493
+ if (values.session !== undefined) {
494
+ const retired = await retireSession(bots, { bot: values.bot, session: values.session });
495
+ return {
496
+ answer: { bots, ...retired },
497
+ lines: [
498
+ ...closedLines(retired.closed),
499
+ `retired ${retired.bot} ${retired.session}: off ${path.join('bots', retired.bot, 'bot.yaml')}, and its conversations kept in the book under retired`,
500
+ ],
501
+ };
502
+ }
503
+
504
+ const retired = await retireBot(bots, { bot: values.bot });
505
+ return {
506
+ answer: { bots, ...retired },
507
+ lines: [
508
+ ...closedLines(retired.closed),
509
+ ...(retired.project === undefined ? [] : [`removed Orca project ${retired.project}`]),
510
+ `retired ${retired.bot}: moved to ${path.relative(bots, retired.moved)}, with its book, charter and memory`,
511
+ ],
512
+ };
513
+ },
514
+
515
+ 'bot create'(bots, values) {
516
+ const made = createBot(bots, { name: values.name, harness: values.harness, charter: values.charter });
517
+ // The bot's AGENTS.md is the rules build's, here as everywhere else, so
518
+ // that a new bot's file and a rebuilt one are written by the same code.
519
+ const rules = [buildAgents(bots, made.home, readBot(made.home))];
520
+ // A new bot is given what the lists already name, so it is whole before
521
+ // anybody opens a tab on it.
522
+ const skills = [linkSkills(bots, made.home, readBot(made.home))];
523
+ const answer = { bots, bot: made.bot, home: made.home, created: made.created, rules, skills };
524
+ // A bot whose rules would not build is made but not finished: it has no
525
+ // instructions, so `up` will not start it, and saying "give it a session"
526
+ // would send the caller past the thing that needs settling first.
527
+ const trouble = rules[0].trouble !== undefined;
528
+ return {
529
+ answer,
530
+ lines: [
531
+ ...made.created.map((entry) => `created ${entry}`),
532
+ ...rulesLines(rules, bots),
533
+ ...skillsLines(skills),
534
+ trouble
535
+ ? `${made.bot} is written, and its rules are not. Settle what the line above says, then: obk rules build --bots ${bots} --bot ${made.bot}`
536
+ : `${made.bot} is written. Give it a session: obk session add --bots ${bots} --bot ${made.bot} --name <name>`,
537
+ ],
538
+ code: trouble ? 1 : 0,
539
+ };
540
+ },
541
+
542
+ 'bot change'(bots, values) {
543
+ if (values.harness !== undefined) {
544
+ throw new Error(`bot change does not change a bot's harness: its sessions' conversations belong to the harness they ran on. To move to ${values.harness}, give it a session on ${values.harness} with obk session add, or retire the bot with obk retire and create a new one.`);
545
+ }
546
+ const changed = changeBot(bots, values.bot, { charter: values.charter });
547
+ // Rebuilt at once, as bot create builds it, so the charter a session reads
548
+ // is the one the bot now has.
549
+ const rules = [buildAgents(bots, changed.home, readBot(changed.home))];
550
+ const trouble = rules[0].trouble !== undefined;
551
+ return {
552
+ answer: { bots, bot: changed.bot, home: changed.home, charter: changed.charter, rules },
553
+ lines: [
554
+ `changed the charter in ${path.join('bots', changed.bot, 'bot.yaml')}`,
555
+ ...rulesLines(rules, bots),
556
+ trouble
557
+ ? `${changed.bot}'s charter is written, and its rules are not. Settle what the line above says, then: obk rules build --bots ${bots} --bot ${changed.bot}`
558
+ : `${changed.bot}'s charter is changed. A session that is running read the old one when it started; it reads this one when it next starts.`,
559
+ ],
560
+ code: trouble ? 1 : 0,
561
+ };
562
+ },
563
+
564
+ 'skills build'(bots, values) {
565
+ const skills = buildSkills(bots, { bot: values.bot });
566
+ const trouble = skills.filter((entry) => entry.trouble !== undefined);
567
+ return {
568
+ answer: { bots, skills },
569
+ lines: [
570
+ ...skillsLines(skills),
571
+ trouble.length === 0
572
+ ? `Every bot has the skills its lists name. Your bots folder: ${bots}`
573
+ : `${trouble.map((entry) => entry.bot).join(', ')}: skills not linked. Settle what the lines above say, then link again.`,
574
+ ],
575
+ code: trouble.length === 0 ? 0 : 1,
576
+ };
577
+ },
578
+
579
+ 'skills add'(bots, values) {
580
+ const added = addSkill(bots, values.bot, values.skill);
581
+ return {
582
+ answer: { bots, bot: added.bot, home: added.home, skill: added.skill, state: added.state },
583
+ lines: [
584
+ added.state === 'added'
585
+ ? `added ${added.skill} to ${path.join('bots', added.bot, 'bot.yaml')}`
586
+ : `there ${added.skill} is on ${added.bot}'s list already, and nothing was written`,
587
+ `Link it: obk skills build --bots ${bots} --bot ${added.bot}`,
588
+ ],
589
+ };
590
+ },
591
+
592
+ 'skills remove'(bots, values) {
593
+ const removed = removeSkill(bots, values.bot, values.skill);
594
+ return {
595
+ answer: { bots, bot: removed.bot, home: removed.home, skill: removed.skill, state: removed.state },
596
+ lines: [
597
+ removed.state === 'removed'
598
+ ? `removed ${removed.skill} from ${path.join('bots', removed.bot, 'bot.yaml')}`
599
+ : `absent ${removed.skill} is not on ${removed.bot}'s list, and nothing was written`,
600
+ `Unlink it: obk skills build --bots ${bots} --bot ${removed.bot}`,
601
+ ],
602
+ };
603
+ },
604
+
605
+ 'source add'(bots, values) {
606
+ const source = addSource(bots, { name: values.name, repo: values.repo, ref: values.ref, path: values.path });
607
+ return {
608
+ answer: { bots, source },
609
+ lines: [
610
+ `added source ${source.name} to skills.yaml`,
611
+ ...Object.entries(source)
612
+ .filter(([key]) => key !== 'name')
613
+ .map(([key, value]) => ` ${key.padEnd(5)} ${value}`),
614
+ `Fetch it: obk skills fetch --bots ${bots} --source ${source.name}`,
615
+ ],
616
+ };
617
+ },
618
+
619
+ groom(bots, values) {
620
+ refuseWhenOrcaIsDown();
621
+ if (values.on === true && values.off === true) {
622
+ throw new Error('groom takes --on or --off, and got both. Say which one you want.');
623
+ }
624
+ const on = values.on === true ? true : (values.off === true ? false : undefined);
625
+ const groom = grooming(bots, { at: values.at, on });
626
+ return { answer: { bots, groom }, lines: groomLines(groom, bots) };
627
+ },
628
+
629
+ usage(bots, values) {
630
+ const usage = readUsage(bots, { bot: values.bot, session: values.session, since: values.since, until: values.until });
631
+ return { answer: { bots, usage }, lines: usageLines(usage, bots) };
632
+ },
633
+
634
+ roster(bots, values) {
635
+ const roster = readRoster(bots, { bot: values.bot });
636
+ return { answer: { bots, roster }, lines: rosterLines(roster, bots) };
637
+ },
638
+
639
+ 'skills fetch'(bots, values) {
640
+ return fetched(bots, fetchSources(bots, { source: values.source }), 'Nothing to fetch');
641
+ },
642
+
643
+ 'skills update'(bots, values) {
644
+ return fetched(bots, fetchSources(bots, { source: values.source, moving: true }), 'Nothing to update');
645
+ },
646
+
647
+ 'rules build'(bots, values) {
648
+ const rules = buildRules(bots, { bot: values.bot });
649
+ const trouble = rules.filter((entry) => entry.trouble !== undefined);
650
+ return {
651
+ answer: { bots, rules },
652
+ lines: [
653
+ ...rulesLines(rules, bots),
654
+ trouble.length === 0
655
+ ? `Rules are built. Your bots folder: ${bots}`
656
+ // Not "not built": the file itself may be fine and the trouble be
657
+ // the CLAUDE.md beside it, which is a bot whose rules still do not
658
+ // reach both harnesses.
659
+ : `${trouble.map((entry) => entry.bot).join(', ')}: the rules are not in place. Settle what the lines above say, then build again.`,
660
+ ],
661
+ // The build is the whole of this command, so a build it could not make is
662
+ // what the command ends in. `up` answers for its tabs and is not held to
663
+ // this: see bringUp.
664
+ code: trouble.length === 0 ? 0 : 1,
665
+ };
666
+ },
667
+
668
+ 'message to'(bots, values) {
669
+ // A lookup reads the book and nothing else, so it answers with Orca down:
670
+ // knowing how to reach somebody is worth having when the app is not up.
671
+ const answer = lookUp(bots, { to: values.to, from: values.from, tab: process.env[TAB_ENV] });
672
+ const where = `${answer.to.bot}/${answer.to.session}`;
673
+ return {
674
+ answer,
675
+ lines: [
676
+ `${answer.transport.padEnd(9)} ${where.padEnd(24)} ${answer.address ?? '-'}`,
677
+ ...toLines(answer, bots, where),
678
+ ],
679
+ code: answer.trouble === undefined ? 0 : 1,
680
+ };
681
+ },
682
+
683
+ 'message send'(bots, values) {
684
+ refuseWhenOrcaIsDown();
685
+ const answer = sendMessage(bots, {
686
+ to: values.to,
687
+ from: values.from,
688
+ tab: process.env[TAB_ENV],
689
+ subject: values.subject,
690
+ text: values.text,
691
+ textFile: values['text-file'],
692
+ thread: values.thread,
693
+ });
694
+ const where = `${answer.to.bot}/${answer.to.session}`;
695
+
696
+ if (!answer.sent) {
697
+ return {
698
+ answer,
699
+ lines: [`${'not sent'.padEnd(9)} ${where.padEnd(24)} ${answer.subject}`, ` ${answer.trouble}`],
700
+ code: 1,
701
+ };
702
+ }
703
+
704
+ return {
705
+ answer,
706
+ lines: [
707
+ `${'sent'.padEnd(9)} ${where.padEnd(24)} ${answer.subject}`,
708
+ ...(answer.file === undefined
709
+ ? []
710
+ : [` it was too long to travel as itself, so it went as a file: ${answer.file}`]),
711
+ ...[nudgeLine(answer, where)],
712
+ ],
713
+ };
714
+ },
715
+
716
+ 'message check'(bots, values) {
717
+ refuseWhenOrcaIsDown();
718
+ const answer = checkMail(bots, {
719
+ bot: values.bot,
720
+ session: values.session,
721
+ tab: process.env[TAB_ENV],
722
+ peek: values.peek === true,
723
+ });
724
+ const where = `${answer.bot}/${answer.session}`;
725
+
726
+ if (answer.trouble !== undefined) {
727
+ return { answer, lines: [`${'trouble'.padEnd(9)} ${where}`, ` ${answer.trouble}`], code: 1 };
728
+ }
729
+
730
+ return {
731
+ answer,
732
+ lines: [
733
+ ...answer.messages.flatMap((message) => [
734
+ `${'message'.padEnd(9)} ${message.from} ${message.at}`,
735
+ ` ${message.subject}`,
736
+ ...String(message.body ?? '').split('\n').map((line) => ` ${line}`),
737
+ ]),
738
+ answer.messages.length === 0
739
+ ? `Nothing is waiting for ${where}.`
740
+ // Said plainly, because a peek leaves the same mail there to be found
741
+ // again and a read does not.
742
+ : `${answer.messages.length} for ${where}${answer.read ? ', now read' : ', still unread: this was a peek'}.`,
743
+ ],
744
+ };
745
+ },
746
+
747
+ 'session add'(bots, values) {
748
+ const added = addSession(bots, values.bot, settingsOf(values));
749
+ const answer = { bots, bot: added.bot, home: added.home, session: added.session };
750
+ return {
751
+ answer,
752
+ lines: [
753
+ `added session ${added.session.name} to ${path.join('bots', added.bot, 'bot.yaml')}`,
754
+ ...Object.entries(added.session)
755
+ .filter(([key]) => key !== 'name')
756
+ .map(([key, value]) => ` ${key} ${oneLine(value)}`),
757
+ `Bring it up: obk up --bots ${bots} --bot ${added.bot}`,
758
+ ],
759
+ };
760
+ },
761
+
762
+ 'session change'(bots, values) {
763
+ if (values.harness !== undefined) {
764
+ throw new Error(`session change does not change a session's harness: its conversations belong to the harness they ran on. To move it, retire it with obk retire and add one on ${values.harness} with obk session add.`);
765
+ }
766
+ const { name, ...settings } = settingsOf(values);
767
+ const changed = changeSession(bots, values.bot, values.session, settings);
768
+ const restart = `obk restart --bots ${bots} --bot ${changed.bot} --session ${values.session}`;
769
+ return {
770
+ answer: { bots, bot: changed.bot, home: changed.home, session: changed.session, restart },
771
+ lines: [
772
+ `changed session ${values.session} in ${path.join('bots', changed.bot, 'bot.yaml')}`,
773
+ ...Object.entries(changed.session)
774
+ .filter(([key]) => key !== 'name')
775
+ .map(([key, value]) => ` ${key} ${oneLine(value)}`),
776
+ `A running session takes this when it next starts: ${restart}`,
777
+ ],
778
+ };
779
+ },
780
+ };
781
+
782
+ /**
783
+ * What `obk message to` says under the road and the address: how to use it, or
784
+ * what is in the way. A Claude pair the native road cannot carry says so, since
785
+ * a caller told "use the mailbox" about two Claude sessions would otherwise
786
+ * think the kit had forgotten its own rule.
787
+ */
788
+ function toLines(answer, bots, where) {
789
+ if (answer.trouble !== undefined) return [` ${answer.trouble}`];
790
+ if (answer.transport === 'native') {
791
+ return [` Write to ${answer.address} with your own harness's messaging. The kit does not carry that road.`];
792
+ }
793
+ return [
794
+ ...(answer.unnamed === true
795
+ ? [` ${where} is a Claude session running under no name the kit gave it: it was started before the kit named sessions, and nothing renames a live harness. It gets one the next time it starts. Until then the mailbox is the road that reaches it.`]
796
+ : []),
797
+ ` Send it: obk message send --bots ${bots} --to ${where} --subject <text> --text <text>`,
798
+ ];
799
+ }
800
+
801
+ /**
802
+ * What became of the line that tells the receiver to look. The message is in
803
+ * its mailbox whatever this says, so each of these is about the tab and not
804
+ * about the message.
805
+ */
806
+ function nudgeLine(answer, where) {
807
+ if (answer.nudged) return ' its tab was told to look; it will read it when it is done with what it is doing.';
808
+ if (answer.blocked !== undefined) {
809
+ return ` its tab has something waiting to be answered (${answer.blocked}), so nothing was typed into it. Settle that, and the mail is there.`;
810
+ }
811
+ if (answer.nudgeTrouble !== undefined) {
812
+ return ` it is queued, and its tab could not be told to look: ${answer.nudgeTrouble}`;
813
+ }
814
+ return ` ${where} is not up, so nothing was typed anywhere: the message waits in its mailbox.`;
815
+ }
816
+
817
+ /** The settings a `session add` was given, as they go into bot.yaml. */
818
+ function settingsOf(values) {
819
+ const settings = { name: values.name };
820
+ for (const [flag, key] of SETTINGS) {
821
+ if (values[flag] !== undefined) settings[key] = key === 'context' ? asNumberOrText(values[flag]) : values[flag];
822
+ }
823
+ if (values['extra-arg'] !== undefined) settings.extra_args = values['extra-arg'];
824
+ return settings;
825
+ }
826
+
827
+ /** A context window written as a plain number stays one in the file. */
828
+ const asNumberOrText = (value) => (/^\d+$/.test(value) ? Number(value) : value);
829
+
830
+ const oneLine = (value) => (Array.isArray(value) ? value.join(' ') : String(value)).replace(/\s+/g, ' ').trim();
831
+
832
+ function refuseWhenOrcaIsDown() {
833
+ const trouble = orcaTrouble();
834
+ if (trouble !== undefined) throw new Error(trouble);
835
+ }
836
+
837
+ /** The same facts as `--json`, as lines, for a person reading along. */
838
+ /** The one line that says which conversation this tab was given, and from where. */
839
+ const howLine = (tab) => (tab.resumed === true
840
+ ? ' it was told to resume the session the book holds, with its conversation.'
841
+ : ' it was told to start a new session: the book holds none for this one yet.');
842
+
843
+ /**
844
+ * What the harness still has in this bot's folder that no session claims. The
845
+ * kit will not pick one — a bot's sessions and everything they start share that
846
+ * folder — so it says what is there and how to settle it.
847
+ */
848
+ const unclaimedLines = (tab, bots) => {
849
+ const one = tab.unclaimed.length === 1;
850
+ return [
851
+ ' the kit cannot say which conversation this session had.',
852
+ ` ${one ? 'This one ran' : 'These ran'} in this bot's folder and no session claims ${one ? 'it' : 'them'}:`,
853
+ ...tab.unclaimed.map((id) => ` ${id}`),
854
+ " A bot's sessions share that folder, and so does anything they start",
855
+ ' inside themselves, so the kit does not guess. To give one back, write it',
856
+ ` into ${path.join(bots, 'bots', tab.bot, 'sessions.yaml')}`,
857
+ ` under ${tab.name} as session: <id> and run obk up again.`,
858
+ ];
859
+ };
860
+
861
+ /**
862
+ * What `skills fetch` and `skills update` answer: a line per source saying what
863
+ * became of it, at which ref, and which commit that turned out to be — the
864
+ * version the user asked for beside the one they got.
865
+ *
866
+ * A source carrying scripts or hooks gets one line of its own, whatever is in
867
+ * it. The kit does not scan it and does not stand in the way; the risk is the
868
+ * user's to take and theirs to know about (PRD 6.7).
869
+ */
870
+ function fetched(bots, sources, nothing) {
871
+ const trouble = sources.filter((source) => source.trouble !== undefined);
872
+ const lines = sources.flatMap((source) => (source.trouble !== undefined
873
+ ? [`${'trouble'.padEnd(9)} ${source.name}`, ` ${source.trouble}`]
874
+ : [
875
+ `${source.state.padEnd(9)} ${source.name.padEnd(24)} ${source.ref} ${source.sha.slice(0, 7)}`,
876
+ ...(source.runs ? [` ${source.name} carries scripts or hooks. The kit neither looks at them nor stops them; they are the source's, and the risk is yours.`] : []),
877
+ ]));
878
+
879
+ return {
880
+ answer: { bots, sources },
881
+ lines: [
882
+ ...lines,
883
+ // eslint-disable-next-line no-nested-ternary
884
+ sources.length === 0 ? `${nothing}: skills.yaml lists no sources. Your bots folder: ${bots}`
885
+ : trouble.length === 0 ? `Your sources are where they should be. Your bots folder: ${bots}`
886
+ : `${trouble.map((source) => source.name).join(', ')}: not fetched. Settle what the lines above say, then try again.`,
887
+ ],
888
+ code: trouble.length === 0 ? 0 : 1,
889
+ };
890
+ }
891
+
892
+ /**
893
+ * What each bot's sessions have used, as lines: a block per session, a line per
894
+ * conversation, and the bot's unclaimed ones under it. Tokens and no money, the
895
+ * same as the answer, because the price is looked up by whoever is reading.
896
+ */
897
+ function usageLines(usage, bots) {
898
+ const lines = [];
899
+
900
+ for (const entry of usage) {
901
+ lines.push(`${'bot'.padEnd(9)} ${entry.bot}`);
902
+ for (const session of entry.sessions) {
903
+ lines.push(`${'session'.padEnd(9)} ${session.name}`);
904
+ lines.push(...session.conversations.map(conversationLine));
905
+ if (session.conversations.length === 0) lines.push(' nothing on record');
906
+ }
907
+ if (entry.unclaimed.length > 0) {
908
+ lines.push(`${'unclaimed'.padEnd(9)} ${entry.bot}: no session of this bot claims these`);
909
+ lines.push(...entry.unclaimed.map(conversationLine));
910
+ }
911
+ }
912
+
913
+ lines.push(usage.length === 0
914
+ ? `No bots yet. Your bots folder: ${bots}`
915
+ : `${usage.length} bot${usage.length === 1 ? '' : 's'}. What a token costs is yours to look up. Your bots folder: ${bots}`);
916
+ return lines;
917
+ }
918
+
919
+ /** One conversation: what it is, what it ran as, and what it used. */
920
+ function conversationLine(one) {
921
+ const ran = [...one.models, ...one.efforts].join(' ');
922
+ const used = Object.entries(one.tokens)
923
+ .filter(([, count]) => count > 0)
924
+ .map(([kind, count]) => `${kind} ${count}`)
925
+ .join(' ');
926
+ return ` ${one.id} ${one.calls} call${one.calls === 1 ? '' : 's'}`
927
+ + `${ran === '' ? '' : ` ${ran}`}`
928
+ + `${used === '' ? '' : ` ${used}`}`
929
+ + `${one.compactions > 0 ? ` compacted ${one.compactions}` : ''}`;
930
+ }
931
+
932
+ /**
933
+ * What there is to say about the daily grooming: whether it exists, when it
934
+ * runs, and whether it is on. A grooming that is off is not a fault, so this
935
+ * says what is there and what the next step would be rather than warning.
936
+ */
937
+ function groomLines(groom, bots) {
938
+ if (!groom.exists) {
939
+ return [
940
+ `${'groom'.padEnd(9)} there is no daily grooming yet`,
941
+ `Make one: obk groom --bots ${bots} --at 04:00`,
942
+ ];
943
+ }
944
+
945
+ return [
946
+ `${'groom'.padEnd(9)} daily at ${groom.at} ${groom.enabled ? 'on' : 'off'}`,
947
+ groom.enabled
948
+ ? `It runs every day at ${groom.at} and spends tokens each time. Turn it off with: obk groom --bots ${bots} --off`
949
+ : `It is not running yet. Try it by hand, read what it gives you, then: obk groom --bots ${bots} --on`,
950
+ ];
951
+ }
952
+
953
+ /** The settings a session carries, in the order a session is written down. */
954
+ const SHOWN = SESSION_FIELDS.filter((field) => field !== 'name' && field !== 'harness');
955
+
956
+ /**
957
+ * The fleet as lines: a block per bot, then a block per session under it. The
958
+ * facts and nothing else, in the order the answer carries them, so that what a
959
+ * person reads here and what a skill reads from `--json` are the same thing.
960
+ *
961
+ * The charter is printed whole. It is the one part of a bot that says what it
962
+ * is for, and a report that shortened it would be making the judgement this
963
+ * command does not make.
964
+ */
965
+ function rosterLines(roster, bots) {
966
+ const lines = [];
967
+
968
+ for (const entry of roster) {
969
+ lines.push(`${'bot'.padEnd(9)} ${entry.bot}${entry.harness === undefined ? '' : ` ${entry.harness}`}${entry.paused === true ? ' paused' : ''}`);
970
+ if (entry.charter !== undefined) {
971
+ lines.push(...String(entry.charter).trimEnd().split('\n').map((line) => ` ${line}`.trimEnd()));
972
+ }
973
+ for (const [what, list] of [['rules', entry.rules], ['skills', entry.skills]]) {
974
+ if (list.length > 0) lines.push(` ${what.padEnd(6)} ${list.join(' ')}`);
975
+ }
976
+ if (entry.orca.project !== undefined) {
977
+ lines.push(` Orca project ${entry.orca.project}${entry.orca.setup === undefined ? '' : ` setup ${entry.orca.setup}`}`);
978
+ }
979
+ for (const session of entry.sessions) lines.push(...sessionLines(session));
980
+ }
981
+
982
+ lines.push(roster.length === 0
983
+ ? `No bots yet. Make one: obk bot create --bots ${bots} --name <name> --harness claude|codex`
984
+ : `${roster.length} bot${roster.length === 1 ? '' : 's'}. Your bots folder: ${bots}`);
985
+ return lines;
986
+ }
987
+
988
+ /**
989
+ * One session: what it is set to, then what the kit knows about it as a running
990
+ * thing. A setting it does not carry is left out rather than filled in, because
991
+ * left out is what it means — the harness's own default, whatever that is today.
992
+ */
993
+ function sessionLines(session) {
994
+ const set = SHOWN
995
+ .filter((field) => session[field] !== undefined)
996
+ .map((field) => `${field} ${oneLine(session[field])}`);
997
+ const book = session.book;
998
+
999
+ return [
1000
+ `${'session'.padEnd(9)} ${session.name}${session.harness === undefined ? '' : ` ${session.harness}`}${set.length === 0 ? '' : ` ${set.join(' ')}`}${session.paused === true ? ' paused' : ''}`,
1001
+ ...(book.tab === undefined
1002
+ ? []
1003
+ : [` tab ${book.tab}${book.launched === undefined ? '' : ` launched ${book.launched}`}`]),
1004
+ ...(book.session === undefined ? [] : [` conversation ${book.session}`]),
1005
+ ...(book.history ?? [])
1006
+ .filter((was) => was?.session !== undefined)
1007
+ .map((was) => ` was ${was.session}${was.ended === undefined ? '' : ` ${was.ended}`}`),
1008
+ ...(book.unclaimed ?? []).map((id) => ` unclaimed ${id}`),
1009
+ ];
1010
+ }
1011
+
1012
+ /**
1013
+ * What became of each bot's `AGENTS.md`, in the same column as the rest of the
1014
+ * report: what the build did, the file it did it to, and what it carries. A
1015
+ * build that would not go through says what is in the way instead, in the
1016
+ * words of whoever has to settle it.
1017
+ */
1018
+ function rulesLines(rules, bots) {
1019
+ return rules.flatMap((entry) => {
1020
+ const what = entry.units === undefined
1021
+ ? ''
1022
+ : ` ${entry.units.length} unit${entry.units.length === 1 ? '' : 's'}`;
1023
+ return [
1024
+ `${entry.state.padEnd(9)} ${path.relative(bots, entry.file)}${what}`,
1025
+ // Named on the run that made it: it is a file the command wrote.
1026
+ ...(entry.linked === undefined
1027
+ ? []
1028
+ : [`${'linked'.padEnd(9)} ${path.relative(bots, entry.linked)} -> AGENTS.md`]),
1029
+ ...(entry.trouble === undefined ? [] : [` ${entry.trouble}`]),
1030
+ // Codex reads no more than this of an instructions file, and says nothing
1031
+ // when it stops reading (tech notes, section 3).
1032
+ ...(entry.bytes > CODEX_CAP
1033
+ ? [` it is over the ${CODEX_CAP / 1024} KiB Codex reads, so a Codex session will not see all of it.`]
1034
+ : []),
1035
+ ];
1036
+ });
1037
+ }
1038
+
1039
+ /**
1040
+ * What each bot has in its skills directories: one line naming the bot, then
1041
+ * one per skill — what became of it, its name, and which shelf it came from.
1042
+ * A skill the user put there themselves is named too, and said to be theirs.
1043
+ */
1044
+ function skillsLines(skills) {
1045
+ return skills.flatMap((entry) => [
1046
+ entry.bot,
1047
+ ...(entry.trouble === undefined ? [] : [` ${'trouble'.padEnd(9)} ${entry.trouble}`]),
1048
+ ...entry.skills.map((skill) => ` ${(skill.managed ? 'linked' : 'yours').padEnd(9)} ${skill.name.padEnd(24)} ${skill.from ?? 'no list names it; the kit leaves it alone'}`),
1049
+ ...(entry.removed ?? []).map((name) => ` ${'removed'.padEnd(9)} ${name.padEnd(24)} no list names it now`),
1050
+ ]);
1051
+ }
1052
+
1053
+ /**
1054
+ * What a check found, two lines each: what kind of trouble it is and the one
1055
+ * thing to go and look at, then the sentence about it. The same shape wherever
1056
+ * a command reports one, so a reader who has seen one has seen them all.
1057
+ */
1058
+ const foundLines = (found) =>
1059
+ found.flatMap((one) => [`${one.kind.padEnd(9)} ${one.where}`, ` ${one.says}`]);
1060
+
1061
+ function tabLines({ bots, created, completed, rules, skills, tabs, paused = [], found = [] }, summary) {
1062
+ const lines = [
1063
+ ...created.map((entry) => `created ${entry}`),
1064
+ ...completed.map((entry) => `completed ${entry}`),
1065
+ ...rulesLines(rules, bots),
1066
+ ...skillsLines(skills),
1067
+ ];
1068
+
1069
+ for (const tab of tabs) {
1070
+ lines.push(`${tab.created ? 'opened' : 'found '} ${tab.title} tab ${tab.tabId} terminal ${tab.terminal}`);
1071
+ lines.push(...harnessLines(tab, bots));
1072
+ }
1073
+ for (const one of paused) {
1074
+ const what = one.session === undefined ? one.bot : `${one.bot} ${one.session}`;
1075
+ const back = one.session === undefined ? '' : ` --session ${one.session}`;
1076
+ lines.push(`${'paused'.padEnd(9)} ${what} left closed. Bring it back: obk unpause --bots ${bots} --bot ${one.bot}${back}`);
1077
+ }
1078
+
1079
+ // Last before the summary, because what a check found is about the setup the
1080
+ // run has just left behind rather than about any one thing it did.
1081
+ lines.push(...foundLines(found), summary);
1082
+ return lines;
1083
+ }
1084
+
1085
+ /**
1086
+ * What became of the harness in a tab this run opened. Nothing is typed into a
1087
+ * tab that was already there, and nothing into the plain tab beside the
1088
+ * sessions, so those have nothing to say.
1089
+ *
1090
+ * The caller decides what to do next from these lines, so they say both what
1091
+ * was typed and what was seen afterwards, and never one in place of the other.
1092
+ */
1093
+ function harnessLines(tab, bots) {
1094
+ if (!tab.created || tab.name === null) return [];
1095
+
1096
+ // What the line that was typed in asked for, and where the kit got it: the
1097
+ // session the book holds, one the harness itself still had on record, or a new
1098
+ // one — and if a new one, whether anything was known about an older one.
1099
+ const how = [howLine(tab)];
1100
+
1101
+ if (!tab.harnessStarted) {
1102
+ return [
1103
+ ...how,
1104
+ ' the harness was typed in, and no session came up in the tab.',
1105
+ ` Look at it: ${lookAt(tab.terminal)}`,
1106
+ ];
1107
+ }
1108
+
1109
+ const lines = tab.blockedReason === undefined
1110
+ ? [...how, ' the harness was typed in and came up.']
1111
+ : [
1112
+ ...how,
1113
+ ` the harness was typed in and came up, waiting on: ${tab.blockedReason}`,
1114
+ ` Look at it: ${lookAt(tab.terminal)}`,
1115
+ ];
1116
+
1117
+ if (tab.promptSent === true) lines.push(' the start prompt was typed in.');
1118
+ if (tab.promptSent === false) {
1119
+ lines.push(' the start prompt was not typed in: the tab was not ready for it.');
1120
+ }
1121
+ if (tab.unclaimed !== undefined) lines.push(...unclaimedLines(tab, bots));
1122
+ return lines;
1123
+ }
1124
+
1125
+ try {
1126
+ process.exitCode = await run(process.argv.slice(2));
1127
+ } catch (error) {
1128
+ process.stderr.write(`obk: ${error.message}\n`);
1129
+ process.exitCode = 1;
1130
+ }