@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/rules.js ADDED
@@ -0,0 +1,408 @@
1
+ // A bot's `AGENTS.md`, built from its charter and the rule units it carries
2
+ // (PRD 6.6, ADR 0013).
3
+ //
4
+ // This is the one place the kit's own text lands inside the user's repo, so the
5
+ // promise around it is narrow: the build owns one marked block and nothing else
6
+ // in the file. What is written outside the markers is the user's and comes back
7
+ // exactly as they left it. The begin marker carries a checksum of what the build
8
+ // last put between the markers, so a hand edit inside the block is seen and
9
+ // stops the build rather than being written over.
10
+ //
11
+ // Two shelves of units. The kit's own are in `rules/` in the package, and every
12
+ // one of them marked `applies: all` is carried by every bot. The user's are in
13
+ // `rules/` in their bots folder. Anything beyond the defaults is named in a
14
+ // list — `defaults.yaml` for every bot, a `bot.yaml` for one — as `kit:<name>`
15
+ // for one of the kit's or a bare name for one of theirs.
16
+
17
+ import { createHash } from 'node:crypto';
18
+ import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
19
+ import path from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { parse } from 'yaml';
22
+
23
+ import { botDir, botNames, displayName, readBot } from './bot.js';
24
+
25
+ /** The kit's own units, inside the installed package. */
26
+ const KIT_RULES = fileURLToPath(new URL('../rules', import.meta.url));
27
+
28
+ /** What a name in a list carries to mean one of the kit's rather than one of the user's. */
29
+ const KIT = 'kit:';
30
+
31
+ /** The file a bot's rules are built into, and the name Claude Code is certain to read. */
32
+ const AGENTS = 'AGENTS.md';
33
+ const CLAUDE = 'CLAUDE.md';
34
+
35
+ /** All Codex reads of an instructions file (tech notes, section 3). */
36
+ export const CODEX_CAP = 32 * 1024;
37
+
38
+ /** The end of the block. Nothing of the build's own is in it, so it is matched whole. */
39
+ const END = '<!-- obk:rules end -->';
40
+
41
+ /** The start of it: the checksum, then a sentence for whoever opens the file. */
42
+ const BEGIN = /^<!-- obk:rules ([0-9a-f]{16})\b.*-->$/;
43
+ const beginLine = (sum) =>
44
+ `<!-- obk:rules ${sum} — built by obk from bot.yaml and the rule units; write your own text below the end marker. -->`;
45
+
46
+ /**
47
+ * What the block is stamped with: enough of a hash that the build can tell its
48
+ * own text from a line somebody typed in, and short enough to read. It answers
49
+ * one question — is this still the block the build left? — and nothing else.
50
+ */
51
+ const checksum = (text) => createHash('sha256').update(text).digest('hex').slice(0, 16);
52
+
53
+ /**
54
+ * Build the `AGENTS.md` of one bot. Returns what there is to report about it:
55
+ * `{ bot, file, state, units, bytes }` for a file the build owns, and
56
+ * `{ bot, file, state, trouble }` for one it will not touch.
57
+ *
58
+ * The two kinds of trouble are told apart by which half of the work failed.
59
+ * What the bot carries is `failed` — a name that resolves to nothing, a unit
60
+ * that cannot be read — and the user fixes it in a list. The file itself is
61
+ * `conflict`, which only the person who edited it can settle.
62
+ */
63
+ export function buildAgents(bots, home, bot) {
64
+ const file = path.join(home, AGENTS);
65
+
66
+ let built;
67
+ try {
68
+ built = blockFor(bots, bot);
69
+ } catch (error) {
70
+ return { bot: bot.name, file, state: 'failed', trouble: error.message };
71
+ }
72
+
73
+ let state;
74
+ try {
75
+ state = writeBlock(file, built.body);
76
+ } catch (error) {
77
+ return { bot: bot.name, file, state: 'conflict', trouble: error.message };
78
+ }
79
+
80
+ return { bot: bot.name, file, state, units: built.units, bytes: statSync(file).size, ...claudeMd(home, file) };
81
+ }
82
+
83
+ /**
84
+ * Build every bot in the folder, or the one named, in name order. This is the
85
+ * whole of `obk rules build`: it writes files and asks Orca nothing.
86
+ *
87
+ * One bot's trouble is one bot's: a bad list or an edited file stops that bot
88
+ * and the others are built as usual.
89
+ */
90
+ export function buildRules(bots, { bot: onlyBot } = {}) {
91
+ const names = botNames(bots);
92
+ if (onlyBot !== undefined && !names.includes(onlyBot)) {
93
+ throw new Error(`there is no bot called ${onlyBot} in ${bots}. The bots there are: ${names.join(', ')}.`);
94
+ }
95
+
96
+ return (onlyBot === undefined ? names : [onlyBot]).map((name) => {
97
+ const home = botDir(bots, name);
98
+ try {
99
+ return buildAgents(bots, home, readBot(home, name));
100
+ } catch (error) {
101
+ // A `bot.yaml` the kit cannot read at all. Same answer as a list it
102
+ // cannot follow: this bot is not built, and nothing else changes.
103
+ return { bot: name, file: path.join(home, AGENTS), state: 'failed', trouble: error.message };
104
+ }
105
+ });
106
+ }
107
+
108
+ /**
109
+ * What the block should hold: the bot's name, its charter, and every unit it
110
+ * carries under its own title. Returns the text and the unit names in the order
111
+ * they were written.
112
+ */
113
+ export function blockFor(bots, bot) {
114
+ const units = unitsFor(bots, bot);
115
+ const parts = [`# ${displayName(bot.name)}`];
116
+
117
+ const charter = typeof bot.charter === 'string' ? bot.charter.trim() : '';
118
+ if (charter !== '') parts.push('## Charter', charter);
119
+ for (const unit of units) parts.push(`## ${unit.title}`, unit.body);
120
+
121
+ return { body: parts.join('\n\n'), units: units.map((unit) => unit.name) };
122
+ }
123
+
124
+ /**
125
+ * The units a bot carries, in the order they go into its file: the kit's
126
+ * defaults, then what the bots folder gives every bot, then the bot's own. A
127
+ * unit named more than once is carried where it was named first, and `kit:review`
128
+ * and `review` are two different units, not one named twice.
129
+ */
130
+ function unitsFor(bots, bot) {
131
+ const named = [
132
+ ...kitDefaults(bots),
133
+ ...listIn(path.join(bots, 'defaults.yaml')),
134
+ ...asList(bot.rules, path.join(botDir(bots, bot.name), 'bot.yaml')),
135
+ ];
136
+ return [...new Set(named)].map((ref) => readUnit(bots, ref));
137
+ }
138
+
139
+ /** The kit's own units that every bot carries without anyone asking, in name order. */
140
+ function kitDefaults(bots) {
141
+ return readdirSync(KIT_RULES)
142
+ .filter((entry) => entry.endsWith('.md'))
143
+ .sort()
144
+ .map((entry) => `${KIT}${entry.slice(0, -'.md'.length)}`)
145
+ .filter((ref) => readUnit(bots, ref).applies === 'all');
146
+ }
147
+
148
+ /** Where a name in a list points: the kit's shelf, or the user's own. */
149
+ const fileOf = (bots, ref) => (ref.startsWith(KIT)
150
+ ? path.join(KIT_RULES, `${ref.slice(KIT.length)}.md`)
151
+ : path.join(bots, 'rules', `${ref}.md`));
152
+
153
+ /** A unit file is frontmatter and then the rule. The compiler reads a title and a body. */
154
+ const FRONTMATTER = /^---\n([\s\S]*?)\n---(?:\n([\s\S]*))?$/;
155
+
156
+ /**
157
+ * One unit, read. Every way this can go wrong names the file, because the way
158
+ * out of all of them is to open it.
159
+ */
160
+ function readUnit(bots, ref) {
161
+ const file = fileOf(bots, ref);
162
+
163
+ let text;
164
+ try {
165
+ text = readFileSync(file, 'utf8');
166
+ } catch {
167
+ throw new Error(`${ref} names no rule unit: there is no ${file}.`);
168
+ }
169
+
170
+ const found = FRONTMATTER.exec(text);
171
+ if (found === null) {
172
+ throw new Error(`${file} is not a rule unit: it should open with a line ---, the title, a line ---, and then the rule.`);
173
+ }
174
+
175
+ let data;
176
+ try {
177
+ data = parse(found[1]);
178
+ } catch (error) {
179
+ throw new Error(`${file} has frontmatter that is not YAML: ${error.message}`);
180
+ }
181
+
182
+ const title = data?.title;
183
+ if (typeof title !== 'string' || title.trim() === '') {
184
+ throw new Error(`${file} has no title, and a unit is written into AGENTS.md under its title.`);
185
+ }
186
+
187
+ return {
188
+ // The kit's units name themselves; one of the user's is named by its file.
189
+ name: typeof data.name === 'string' ? data.name : path.basename(file, '.md'),
190
+ title: title.trim(),
191
+ body: (found[2] ?? '').trim(),
192
+ applies: data.applies,
193
+ };
194
+ }
195
+
196
+ /**
197
+ * A named list in one of the user's YAML files — `rules:` or `skills:` — out of
198
+ * `defaults.yaml` or a `bot.yaml`. A file that is not there lists nothing.
199
+ */
200
+ export function listIn(file, key = 'rules') {
201
+ if (!existsSync(file)) return [];
202
+
203
+ let doc;
204
+ try {
205
+ doc = parse(readFileSync(file, 'utf8'));
206
+ } catch (error) {
207
+ throw new Error(`${file} is not readable as YAML, and the ${key} every bot gets are listed in it: ${error.message}`);
208
+ }
209
+ return asList(doc?.[key], file, key);
210
+ }
211
+
212
+ /** A list of names, or a plain refusal to guess at what else it might be. */
213
+ function asList(value, file, key = 'rules') {
214
+ if (value === undefined || value === null) return [];
215
+ if (!Array.isArray(value)) {
216
+ throw new Error(`the rules entry in ${file} is not a list of rule unit names.`);
217
+ }
218
+
219
+ return value.map((ref) => {
220
+ if (typeof ref !== 'string' || ref.trim() === '') {
221
+ throw new Error(`the ${key} list in ${file} holds ${JSON.stringify(ref)}, and an entry is a name: house, or kit:review.`);
222
+ }
223
+ return ref.trim();
224
+ });
225
+ }
226
+
227
+ /** How many of a marker a file turned out to carry, in words that read. */
228
+ const markers = (count, what) => `${count} ${what} marker${count === 1 ? '' : 's'}`;
229
+
230
+ /**
231
+ * The build's own block in `file` as it stands: where it begins, where it ends,
232
+ * and what is between the markers. Undefined when the file holds none of it.
233
+ *
234
+ * Throws when what is there is not a block the build may write over — the wrong
235
+ * number of markers, or text somebody typed in between them. Read-only, so the
236
+ * health check asks the same question without writing anything.
237
+ */
238
+ function blockIn(file, lines) {
239
+ const begins = [];
240
+ const ends = [];
241
+ lines.forEach((line, at) => {
242
+ if (line === END) ends.push(at);
243
+ else if (line.startsWith('<!-- obk:rules')) begins.push(at);
244
+ });
245
+
246
+ if (begins.length === 0 && ends.length === 0) return undefined;
247
+
248
+ if (begins.length !== 1 || ends.length !== 1 || begins[0] > ends[0]) {
249
+ throw new Error(`${file} carries ${markers(begins.length, 'obk:rules begin')} and ${markers(ends.length, 'end')}, and the build needs one of each, the begin first. Put the markers back, or take the block out and let the build write a new one.`);
250
+ }
251
+
252
+ const was = lines.slice(begins[0] + 1, ends[0]).join('\n');
253
+ const stamp = BEGIN.exec(lines[begins[0]]);
254
+ if (stamp === null || stamp[1] !== checksum(was)) {
255
+ throw new Error(`the obk:rules block in ${file} is not the one obk wrote, so nothing was written. Move what you added below the end marker, or put the block back as it was, then build again.`);
256
+ }
257
+
258
+ return { at: begins[0], to: ends[0], was };
259
+ }
260
+
261
+ /**
262
+ * Put `body` between the markers in `file`, and leave the rest of it alone.
263
+ * Returns 'built', or 'unchanged' when the file already says this.
264
+ *
265
+ * The file is worked on as lines and handed back as lines, so what the user
266
+ * wrote outside the block comes back byte for byte, trailing spaces and all.
267
+ */
268
+ function writeBlock(file, body) {
269
+ const text = existsSync(file) ? readFileSync(file, 'utf8') : '';
270
+ const lines = text.split('\n');
271
+ const block = [beginLine(checksum(body)), ...body.split('\n'), END];
272
+ const found = blockIn(file, lines);
273
+
274
+ if (found === undefined) {
275
+ // Nothing of the build's in the file: the block goes at the top and
276
+ // everything that was there is kept below it.
277
+ const rest = text === '' ? [''] : ['', ...lines];
278
+ writeFileSync(file, [...block, ...rest].join('\n'));
279
+ return 'built';
280
+ }
281
+
282
+ if (found.was === body) return 'unchanged';
283
+
284
+ writeFileSync(file, [...lines.slice(0, found.at), ...block, ...lines.slice(found.to + 1)].join('\n'));
285
+ return 'built';
286
+ }
287
+
288
+ /**
289
+ * `CLAUDE.md` beside `AGENTS.md`, pointing at it: the one way Claude Code is
290
+ * certain to read a bot's rules whatever else is set (tech notes, section 2,
291
+ * and PRD 6.6). The link is relative, so it survives the bots repo being cloned
292
+ * elsewhere.
293
+ *
294
+ * The link is made by asking for it, not by looking first and then asking: the
295
+ * file system answers "there is already something there" in one step, and a
296
+ * look followed by an act has a gap in the middle where another run of the kit
297
+ * fits.
298
+ *
299
+ * Whatever is already there is the user's and stays. What the build owes them
300
+ * is the fact they cannot see: Claude Code reads a `CLAUDE.md` *instead of*
301
+ * `AGENTS.md`, so one that is not this bot's `AGENTS.md` leaves the two
302
+ * harnesses starting from different rules, which is the one thing ADR 0013
303
+ * says must not happen.
304
+ *
305
+ * Returns `{ linked }` on the run that made the link, `{ trouble }` when what
306
+ * is there is not this bot's rules, and nothing at all when it is.
307
+ */
308
+ function claudeMd(home, file) {
309
+ const link = path.join(home, CLAUDE);
310
+
311
+ try {
312
+ symlinkSync(AGENTS, link);
313
+ return { linked: link };
314
+ } catch (error) {
315
+ if (error.code !== 'EEXIST') {
316
+ return { trouble: `${link} could not be linked to AGENTS.md (${error.message}), so Claude Code may not read this bot's rules.` };
317
+ }
318
+ }
319
+
320
+ if (sameFile(link, file)) return {};
321
+ return { trouble: claudeMdTrouble(link) };
322
+ }
323
+
324
+ /** Why a `CLAUDE.md` that is not this bot's `AGENTS.md` matters, in one sentence. */
325
+ const claudeMdTrouble = (link) =>
326
+ `${link} is not this bot's AGENTS.md, and Claude Code reads it in place of one, so a Claude session and a Codex session here would start from different rules. Point it at AGENTS.md, or move it aside and let the build link it.`;
327
+
328
+ /**
329
+ * What is wrong with a bot's rules as they stand: `[{ where, says }]`, and
330
+ * nothing when they are in order. The health check's half of the build — it
331
+ * asks the same questions and writes nothing at all, so a file the build would
332
+ * refuse is reported rather than put right (PRD 6.8).
333
+ *
334
+ * A file with no obk block in it is not trouble. That is a bot whose owner
335
+ * wrote its instructions by hand, which the build leaves alone as well.
336
+ */
337
+ export function agentsTrouble(bots, home, bot) {
338
+ const file = path.join(home, AGENTS);
339
+ const trouble = [];
340
+
341
+ // What the charter and the rule units say now. It is a check of its own — a
342
+ // list naming a unit that is not there — and it is what the file is held
343
+ // against below.
344
+ let built;
345
+ try {
346
+ built = blockFor(bots, bot);
347
+ } catch (error) {
348
+ trouble.push({ where: file, says: error.message });
349
+ }
350
+
351
+ if (!existsSync(file)) {
352
+ trouble.push({
353
+ where: file,
354
+ says: `there is no ${file}, so this bot has no instructions: obk up does not start a bot's sessions without them. obk rules build writes the file.`,
355
+ });
356
+ return trouble;
357
+ }
358
+
359
+ const text = readFileSync(file, 'utf8');
360
+ try {
361
+ const found = blockIn(file, text.split('\n'));
362
+ if (found !== undefined && built !== undefined && found.was !== built.body) {
363
+ trouble.push({
364
+ where: file,
365
+ says: `${file} is not what ${bot.name}'s charter and rule units say now, so its sessions are reading the older text. obk rules build writes it, and obk up does too before it starts a session.`,
366
+ });
367
+ }
368
+ } catch (error) {
369
+ trouble.push({ where: file, says: error.message });
370
+ }
371
+
372
+ // Codex reads no more than this of an instructions file, and says nothing
373
+ // when it stops reading (tech notes, section 3).
374
+ if (Buffer.byteLength(text) > CODEX_CAP) {
375
+ trouble.push({
376
+ where: file,
377
+ says: `${file} is over the ${CODEX_CAP / 1024} KiB Codex reads, so a Codex session will not see all of it.`,
378
+ });
379
+ }
380
+
381
+ // A link that leads nowhere is still a CLAUDE.md in Claude Code's way, so the
382
+ // question is whether there is an entry at all, not whether it resolves —
383
+ // and a bot with none is not in the state the kit keeps its bots in either.
384
+ // Claude Code reads AGENTS.md directly when there is no CLAUDE.md anywhere
385
+ // above the folder, and not on Bedrock or with telemetry off (tech notes,
386
+ // section 2); the link is what makes it certain, which is why the build makes
387
+ // one (PRD 6.6).
388
+ const link = path.join(home, CLAUDE);
389
+ if (lstatSync(link, { throwIfNoEntry: false }) === undefined) {
390
+ trouble.push({
391
+ where: link,
392
+ says: `there is no ${link}. The kit keeps one there as a link to this bot's instructions, because it is the one way Claude Code is certain to read them whatever else is set. obk rules build makes it.`,
393
+ });
394
+ } else if (!sameFile(link, file)) {
395
+ trouble.push({ where: link, says: claudeMdTrouble(link) });
396
+ }
397
+
398
+ return trouble;
399
+ }
400
+
401
+ /** Whether two paths are the same file in the end. One that leads nowhere is not. */
402
+ function sameFile(one, other) {
403
+ try {
404
+ return realpathSync(one) === realpathSync(other);
405
+ } catch {
406
+ return false;
407
+ }
408
+ }