@assuredloop/orca-bot-kit 0.1.0 → 0.3.0

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