@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/usage.js ADDED
@@ -0,0 +1,401 @@
1
+ // What the fleet's work came to in tokens (PRD 6.8).
2
+ //
3
+ // The countable half of finops, and only that half. This reports how many calls
4
+ // a conversation made, how many tokens of which kind they used, at which models
5
+ // and efforts, between which two moments, and how many times it was compacted.
6
+ // What any of that costs is a skill's to work out: a price comes from a live
7
+ // lookup and a price that cannot be found has to be said to be unknown, which is
8
+ // judgement and not arithmetic (ADR 0016).
9
+ //
10
+ // Which conversation belongs to which session is the book's to say and is never
11
+ // guessed, as everywhere else in the kit (ADR 0012). A session's conversations
12
+ // are the one the book names for it now and the ones in its history. One in the
13
+ // bot's folder that no session claims is reported under the bot instead, with
14
+ // its figures, so that what it spent is visible rather than quietly dropped.
15
+ //
16
+ // The harnesses are read, never written, and Orca is not involved.
17
+ //
18
+ // Three things here are not the obvious reading of the files, and all three are
19
+ // measured in the tech notes (sections 2 and 3):
20
+ //
21
+ // 1. Codex's `total_token_usage` is the running total for the conversation.
22
+ // Adding it up across the events double counts, and summing the per-call
23
+ // `last_token_usage` counts a call written down twice twice. So a call is
24
+ // the difference in the running total from the event before: none is a
25
+ // repeat, and a fall is a new window, whose own `last_token_usage` counts.
26
+ // 2. The two harnesses do not mean the same thing by `input_tokens`. Claude
27
+ // Code leaves the cache reads out of it; Codex counts them inside it. So
28
+ // the Codex figure has its cached tokens taken back out, and `input` means
29
+ // uncached input on both. Taken as written, one is many times the other for
30
+ // the same work, and the cached tokens get priced at full rate.
31
+ // 3. Claude Code writes the same call down more than once, so a call counts
32
+ // once per `requestId` and `message.id`.
33
+
34
+ import { readFileSync, realpathSync } from 'node:fs';
35
+
36
+ import { botDir, botNames, readBot } from './bot.js';
37
+ import { readBook, sessionIdsIn } from './book.js';
38
+ import { transcriptsIn } from './conversations.js';
39
+ import { harnessOf } from './launch.js';
40
+
41
+ /** The kinds a call's tokens are reported in, the same on either harness. */
42
+ const KINDS = ['input', 'output', 'cache_read', 'cache_write', 'reasoning'];
43
+
44
+ /**
45
+ * What each bot's sessions have used. `since` counts the calls made at or after
46
+ * that moment rather than the conversations begun after it: a conversation that
47
+ * started this morning and is still going has spent everything it spent today,
48
+ * and a filter on when it began would drop the lot. `until` counts the calls made
49
+ * before its moment, not at it, so one run's `until` is the next run's `since`
50
+ * and a call on the boundary is counted once.
51
+ */
52
+ export function readUsage(bots, { bot: only, session: onlySession, since, until } = {}) {
53
+ const names = botNames(bots);
54
+ if (only !== undefined && !names.includes(only)) {
55
+ throw new Error(`there is no bot called ${only} in ${bots}. The bots there are: ${names.join(', ') || 'none'}.`);
56
+ }
57
+
58
+ const from = since === undefined ? 0 : Date.parse(since);
59
+ if (Number.isNaN(from)) {
60
+ throw new Error(`--since is a moment, such as 2026-09-20T08:00:00Z, and got: ${since}`);
61
+ }
62
+ const to = until === undefined ? Infinity : Date.parse(until);
63
+ if (Number.isNaN(to)) {
64
+ throw new Error(`--until is a moment, such as 2026-09-21T08:00:00Z, and got: ${until}`);
65
+ }
66
+ if (to < from) {
67
+ throw new Error(`--until is before --since, so there is no window between them: ${until} comes before ${since}.`);
68
+ }
69
+
70
+ return (only === undefined ? names : [only]).map((name) => forBot(bots, name, onlySession, { from, to }));
71
+ }
72
+
73
+ /** One bot: what each of its sessions used, and what nobody claims. */
74
+ function forBot(bots, name, onlySession, window) {
75
+ // The folder as the file system knows it, not as the caller spelled it: a
76
+ // bots folder reached through a symlink is the same fleet, and the harnesses
77
+ // file their transcripts under the real path (as `restart` and `message` do).
78
+ const home = realHome(botDir(bots, name));
79
+ const bot = readBot(home, name);
80
+ const book = readBook(home);
81
+
82
+ // A bot's sessions may sit on different harnesses, so every harness any of
83
+ // them runs on is asked. The bot's own is the answer when it has no sessions.
84
+ const harnesses = new Set(bot.sessions.map((session) => harnessOf(session, bot.harness)));
85
+ if (harnesses.size === 0) harnesses.add(bot.harness);
86
+
87
+ const onRecord = new Map();
88
+ for (const harness of harnesses) {
89
+ if (harness === undefined) continue;
90
+ // Read without a `since`: that filter is about when a conversation began,
91
+ // and what is wanted here is every conversation that has spent anything in
92
+ // the window, however long ago it started.
93
+ for (const one of transcriptsIn(harness, home)) {
94
+ if (!onRecord.has(one.id)) onRecord.set(one.id, { ...one, harness });
95
+ }
96
+ }
97
+
98
+ const claimed = sessionIdsIn(book);
99
+
100
+ const sessions = bot.sessions
101
+ .filter((session) => onlySession === undefined || session.name === onlySession)
102
+ .map((session) => ({
103
+ name: session.name,
104
+ conversations: idsIn(book.sessions[session.name] ?? {})
105
+ .filter((id) => onRecord.has(id))
106
+ .map((id) => counted(onRecord.get(id), window))
107
+ .filter((one) => one !== undefined),
108
+ }));
109
+
110
+ const unclaimed = [...onRecord.values()]
111
+ .filter((one) => !claimed.has(one.id))
112
+ .map((one) => counted(one, window))
113
+ .filter((one) => one !== undefined);
114
+
115
+ return { bot: name, home, sessions, unclaimed };
116
+ }
117
+
118
+ /** The conversations a book entry names: the one it is in, then the ones before. */
119
+ const idsIn = (entry) => [
120
+ ...(typeof entry.session === 'string' ? [entry.session] : []),
121
+ ...(entry.history ?? []).map((was) => was?.session).filter((id) => typeof id === 'string'),
122
+ ];
123
+
124
+ /**
125
+ * One conversation, counted. A conversation that spent nothing in the window is
126
+ * left out rather than reported as a row of zeroes: it is not part of what has
127
+ * happened since, and a page of zeroes is harder to read than a shorter page.
128
+ */
129
+ function counted(one, window) {
130
+ const read = one.harness === 'claude' ? fromClaude : fromCodex;
131
+ const tally = {
132
+ calls: 0,
133
+ compactions: 0,
134
+ tokens: Object.fromEntries(KINDS.map((kind) => [kind, 0])),
135
+ models: new Set(),
136
+ efforts: new Set(),
137
+ // Per model, because two models are two prices: a conversation that ran on
138
+ // both cannot be costed from one total however well the prices are known.
139
+ byModel: new Map(),
140
+ first: undefined,
141
+ last: undefined,
142
+ };
143
+
144
+ read(lines(one.file), window, tally);
145
+ if (tally.calls === 0 && KINDS.every((kind) => tally.tokens[kind] === 0)) return undefined;
146
+
147
+ return {
148
+ id: one.id,
149
+ calls: tally.calls,
150
+ tokens: tally.tokens,
151
+ by_model: [...tally.byModel].map(([model, its]) => ({ model, calls: its.calls, tokens: its.tokens })),
152
+ models: [...tally.models],
153
+ efforts: [...tally.efforts],
154
+ compactions: tally.compactions,
155
+ first: new Date(tally.first).toISOString(),
156
+ last: new Date(tally.last).toISOString(),
157
+ };
158
+ }
159
+
160
+ /**
161
+ * What Claude Code wrote down: usage on each `assistant` line, the cache reads
162
+ * already outside `input_tokens`, and a compaction as a line of its own with a
163
+ * `type` and a `subtype` rather than a word anywhere in the text.
164
+ *
165
+ * The same call can be written down more than once, and the later record is the
166
+ * finished one: a pair has been seen with `output_tokens` going from 16 to 301
167
+ * between two lines a second apart (tech notes, section 2). So the records are
168
+ * gathered first and the last of each pair is what counts. Keeping the first
169
+ * looks right for a very long time, because almost every repeat is identical.
170
+ *
171
+ * But the call was made when it was first written down, and grew after. So it
172
+ * is counted as a call once, in the window its first record falls in, and its
173
+ * tokens are what it grew by in the window: its figures in its last record
174
+ * before the window's end, less those in its last record before the window's
175
+ * start. A transcript only grows, so one run's end is the next run's start and
176
+ * the runs add up to the whole, with a call still being written at a boundary
177
+ * charged once, part to each side (#169).
178
+ */
179
+ function fromClaude(entries, window, tally) {
180
+ const byCall = new Map();
181
+
182
+ for (const entry of entries) {
183
+ const when = Date.parse(entry.timestamp ?? '');
184
+ if (entry.type === 'system' && entry.subtype === 'compact_boundary') {
185
+ if (inside(when, window)) tally.compactions += 1;
186
+ continue;
187
+ }
188
+ if (entry.type !== 'assistant') continue;
189
+
190
+ const usage = entry.message?.usage;
191
+ if (usage === undefined || usage === null) continue;
192
+ const call = `${entry.requestId}\u0000${entry.message?.id}`;
193
+ if (!byCall.has(call)) byCall.set(call, []);
194
+ byCall.get(call).push({ entry, when });
195
+ }
196
+
197
+ for (const records of byCall.values()) {
198
+ const made = records[0].when;
199
+ // A record with no time of its own says nothing about being outside, as in
200
+ // `inside`: it counts as written before the end and not before the start.
201
+ const atEnd = records.findLast(({ when }) => Number.isNaN(when) || when < window.to);
202
+ if (atEnd === undefined) continue;
203
+ const atStart = records.findLast(({ when }) => !Number.isNaN(when) && when < window.from);
204
+
205
+ const now = figures(atEnd.entry);
206
+ const was = atStart === undefined ? undefined : figures(atStart.entry);
207
+ const grew = Object.fromEntries(KINDS.map((kind) => [kind, now[kind] - (was?.[kind] ?? 0)]));
208
+ const isNew = inside(made, window);
209
+ if (!isNew && KINDS.every((kind) => grew[kind] === 0)) continue;
210
+
211
+ count(tally, grew, atEnd.entry.message?.model, atEnd.entry.effort, isNew ? made : atEnd.when, isNew ? 1 : 0);
212
+ }
213
+ }
214
+
215
+ /** What one Claude Code record says its call used so far. */
216
+ function figures(entry) {
217
+ const usage = entry.message.usage;
218
+ return {
219
+ input: number(usage.input_tokens),
220
+ output: number(usage.output_tokens),
221
+ cache_read: number(usage.cache_read_input_tokens),
222
+ cache_write: number(usage.cache_creation_input_tokens),
223
+ // Claude Code does not report the thinking apart from the rest.
224
+ reasoning: 0,
225
+ };
226
+ }
227
+
228
+ /** The fields Codex writes a usage figure in, both per call and as a running total. */
229
+ const CODEX_FIELDS = [
230
+ 'input_tokens',
231
+ 'cached_input_tokens',
232
+ 'cache_write_input_tokens',
233
+ 'output_tokens',
234
+ 'reasoning_output_tokens',
235
+ ];
236
+
237
+ /**
238
+ * What Codex wrote down, which needs more care than it looks.
239
+ *
240
+ * Each event carries what the last call used and what the conversation has used
241
+ * altogether, and neither can simply be added up (tech notes, section 3). An
242
+ * event can repeat the one before it, with the same per-call figure and the
243
+ * running total unmoved, and adding the per-call figures counts that twice. The
244
+ * running total can also reset part way through, a new window, and taking the
245
+ * final one then reports only what came after the reset.
246
+ *
247
+ * So a call is counted by what it added to the running total: a rise is that
248
+ * call's usage, no change is the same call written twice, and a fall is a new
249
+ * window where the event's own per-call figure is what it used. Within a window
250
+ * the rise equals the per-call figure exactly, which is why the wrong rules look
251
+ * right on any conversation short enough to read by hand.
252
+ *
253
+ * The running total is followed through events outside the window as well, since
254
+ * what a call added can only be measured against the event before it.
255
+ */
256
+ function fromCodex(entries, window, tally) {
257
+ let model;
258
+ let effort;
259
+ let running;
260
+
261
+ for (const entry of entries) {
262
+ const when = Date.parse(entry.timestamp ?? '');
263
+
264
+ // Read whatever it was set to even before the window opens: the turn that
265
+ // governs a call can have been set hours before the call was made.
266
+ if (entry.type === 'turn_context') {
267
+ model = entry.payload?.model ?? model;
268
+ effort = entry.payload?.effort ?? effort;
269
+ continue;
270
+ }
271
+ if (entry.type === 'compacted') {
272
+ if (inside(when, window)) tally.compactions += 1;
273
+ continue;
274
+ }
275
+ if (entry.type !== 'event_msg' || entry.payload?.type !== 'token_count') continue;
276
+
277
+ const info = entry.payload?.info;
278
+ if (info === undefined || info === null) continue;
279
+
280
+ const used = spent(running, info);
281
+ if (info.total_token_usage !== undefined && info.total_token_usage !== null) {
282
+ running = info.total_token_usage;
283
+ }
284
+ // A repeat is not a call, whether or not it is inside the window.
285
+ if (used === undefined) continue;
286
+ if (!inside(when, window)) continue;
287
+
288
+ count(tally, used, model, effort, when);
289
+ }
290
+ }
291
+
292
+ /**
293
+ * What one Codex event says its call used, measured against the event before it.
294
+ * `undefined` where the event is the one before written down again.
295
+ */
296
+ function spent(running, info) {
297
+ const total = info.total_token_usage;
298
+ if (total === undefined || total === null) {
299
+ // Nothing to measure against: the per-call figure is all there is.
300
+ return info.last_token_usage === undefined ? undefined : kindsOf(info.last_token_usage);
301
+ }
302
+ if (running === undefined) return kindsOf(total);
303
+
304
+ const moved = number(total.total_tokens) - number(running.total_tokens);
305
+ if (moved === 0) return undefined;
306
+ // A fall is a new window, and the running total is counting again from there.
307
+ if (moved < 0) return kindsOf(info.last_token_usage ?? total);
308
+
309
+ return kindsOf(Object.fromEntries(
310
+ CODEX_FIELDS.map((field) => [field, number(total[field]) - number(running[field])]),
311
+ ));
312
+ }
313
+
314
+ /**
315
+ * One Codex figure in the kinds this command reports. The cached tokens sit
316
+ * inside `input_tokens` here and outside it on Claude Code, so they come out of
317
+ * the input and are reported beside it, which makes `input` mean uncached input
318
+ * on both (tech notes, sections 2 and 3).
319
+ */
320
+ function kindsOf(raw) {
321
+ const cached = number(raw?.cached_input_tokens);
322
+ return {
323
+ input: Math.max(number(raw?.input_tokens) - cached, 0),
324
+ output: number(raw?.output_tokens),
325
+ cache_read: cached,
326
+ cache_write: number(raw?.cache_write_input_tokens),
327
+ reasoning: number(raw?.reasoning_output_tokens),
328
+ };
329
+ }
330
+
331
+ /**
332
+ * Add one call to what the conversation used, and to what the model that ran it
333
+ * used. A call whose model nothing names is still counted in the total; it is
334
+ * the total that has to be complete, and a row headed by nothing would be worse
335
+ * than no row.
336
+ */
337
+ function count(tally, used, model, effort, when, calls = 1) {
338
+ tally.calls += calls;
339
+ for (const kind of KINDS) tally.tokens[kind] += used[kind];
340
+ add(tally.models, model);
341
+ add(tally.efforts, effort);
342
+ mark(tally, when);
343
+
344
+ if (typeof model !== 'string' || model === '') return;
345
+ if (!tally.byModel.has(model)) {
346
+ tally.byModel.set(model, { calls: 0, tokens: Object.fromEntries(KINDS.map((kind) => [kind, 0])) });
347
+ }
348
+ const its = tally.byModel.get(model);
349
+ its.calls += calls;
350
+ for (const kind of KINDS) its.tokens[kind] += used[kind];
351
+ }
352
+
353
+ /** The lines of a transcript that are readable JSON; the rest say nothing. */
354
+ function lines(file) {
355
+ let text;
356
+ try {
357
+ text = readFileSync(file, 'utf8');
358
+ } catch {
359
+ // A transcript that cannot be read is a conversation the kit knows nothing
360
+ // about, which is the honest answer rather than a run that stops.
361
+ return [];
362
+ }
363
+ return text.split('\n').flatMap((line) => {
364
+ if (line.trim() === '') return [];
365
+ try {
366
+ const entry = JSON.parse(line);
367
+ return entry !== null && typeof entry === 'object' ? [entry] : [];
368
+ } catch {
369
+ return [];
370
+ }
371
+ });
372
+ }
373
+
374
+ /** A bot home as the file system knows it, or as it was given when it is not there. */
375
+ function realHome(home) {
376
+ try {
377
+ return realpathSync(home);
378
+ } catch {
379
+ return home;
380
+ }
381
+ }
382
+
383
+ const number = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : 0);
384
+
385
+ const add = (set, value) => {
386
+ if (typeof value === 'string' && value !== '') set.add(value);
387
+ };
388
+
389
+ /**
390
+ * Whether a moment is in the window: at or after its start, before its end. A
391
+ * record with no time of its own is counted, as it always has been: nothing
392
+ * says it is outside.
393
+ */
394
+ const inside = (when, { from, to }) => Number.isNaN(when) || (when >= from && when < to);
395
+
396
+ /** When the first and the last counted call of this conversation were. */
397
+ function mark(tally, when) {
398
+ if (Number.isNaN(when)) return;
399
+ if (tally.first === undefined || when < tally.first) tally.first = when;
400
+ if (tally.last === undefined || when > tally.last) tally.last = when;
401
+ }
package/bin/okb.js DELETED
@@ -1,4 +0,0 @@
1
- #!/usr/bin/env node
2
- import { run } from '../src/index.js';
3
-
4
- run(process.argv.slice(2));
package/src/index.js DELETED
@@ -1,11 +0,0 @@
1
- import { createRequire } from 'node:module';
2
-
3
- const { version } = createRequire(import.meta.url)('../package.json');
4
-
5
- export function run(args) {
6
- if (args.includes('--version') || args.includes('-v')) {
7
- console.log(version);
8
- return;
9
- }
10
- console.log(`okb ${version}\n\nUsage: okb [--version]`);
11
- }