@assuredloop/orca-bot-kit 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +545 -3
  3. package/SETUP.md +270 -0
  4. package/package.json +32 -13
  5. package/rules/evidence.md +15 -0
  6. package/rules/finishing.md +20 -0
  7. package/rules/limits.md +18 -0
  8. package/rules/mail.md +17 -0
  9. package/rules/profiles.md +9 -0
  10. package/rules/review.md +14 -0
  11. package/rules/scope.md +20 -0
  12. package/rules/simple.md +14 -0
  13. package/rules/talk.md +13 -0
  14. package/rules/tests-first.md +15 -0
  15. package/rules/the-ask.md +19 -0
  16. package/skills/obk-arch/LICENSES.md +45 -0
  17. package/skills/obk-arch/NOTICE.md +134 -0
  18. package/skills/obk-arch/SKILL.md +387 -0
  19. package/skills/obk-bot-building/LICENSES.md +21 -0
  20. package/skills/obk-bot-building/NOTICE.md +113 -0
  21. package/skills/obk-bot-building/SKILL.md +235 -0
  22. package/skills/obk-debugging/LICENSES.md +67 -0
  23. package/skills/obk-debugging/NOTICE.md +115 -0
  24. package/skills/obk-debugging/SKILL.md +419 -0
  25. package/skills/obk-decision-memo/LICENSES.md +49 -0
  26. package/skills/obk-decision-memo/NOTICE.md +181 -0
  27. package/skills/obk-decision-memo/SKILL.md +241 -0
  28. package/skills/obk-finops/LICENSES.md +19 -0
  29. package/skills/obk-finops/NOTICE.md +77 -0
  30. package/skills/obk-finops/SKILL.md +146 -0
  31. package/skills/obk-fleet-review/LICENSES.md +52 -0
  32. package/skills/obk-fleet-review/NOTICE.md +104 -0
  33. package/skills/obk-fleet-review/SKILL.md +219 -0
  34. package/skills/obk-grilling/LICENSES.md +42 -0
  35. package/skills/obk-grilling/NOTICE.md +103 -0
  36. package/skills/obk-grilling/SKILL.md +217 -0
  37. package/skills/obk-grooming/LICENSES.md +52 -0
  38. package/skills/obk-grooming/NOTICE.md +111 -0
  39. package/skills/obk-grooming/SKILL.md +219 -0
  40. package/skills/obk-handoff/LICENSES.md +45 -0
  41. package/skills/obk-handoff/NOTICE.md +51 -0
  42. package/skills/obk-handoff/SKILL.md +119 -0
  43. package/skills/obk-personal-facilitation/LICENSES.md +53 -0
  44. package/skills/obk-personal-facilitation/NOTICE.md +62 -0
  45. package/skills/obk-personal-facilitation/SKILL.md +86 -0
  46. package/skills/obk-recall/LICENSES.md +42 -0
  47. package/skills/obk-recall/NOTICE.md +53 -0
  48. package/skills/obk-recall/SKILL.md +129 -0
  49. package/skills/obk-researching/LICENSES.md +67 -0
  50. package/skills/obk-researching/NOTICE.md +170 -0
  51. package/skills/obk-researching/SKILL.md +259 -0
  52. package/skills/obk-reviewing/LICENSES.md +66 -0
  53. package/skills/obk-reviewing/NOTICE.md +58 -0
  54. package/skills/obk-reviewing/SKILL.md +408 -0
  55. package/skills/obk-tdd/LICENSES.md +68 -0
  56. package/skills/obk-tdd/NOTICE.md +54 -0
  57. package/skills/obk-tdd/SKILL.md +516 -0
  58. package/skills/obk-tdd/mutation-tools.md +80 -0
  59. package/skills/obk-teach/LICENSES.md +42 -0
  60. package/skills/obk-teach/NOTICE.md +65 -0
  61. package/skills/obk-teach/SKILL.md +106 -0
  62. package/skills/obk-why/LICENSES.md +42 -0
  63. package/skills/obk-why/NOTICE.md +74 -0
  64. package/skills/obk-why/SKILL.md +169 -0
  65. package/skills/obk-writing/LICENSES.md +272 -0
  66. package/skills/obk-writing/NOTICE.md +161 -0
  67. package/skills/obk-writing/SKILL.md +254 -0
  68. package/src/book.js +268 -0
  69. package/src/bot.js +451 -0
  70. package/src/cli.js +1130 -0
  71. package/src/conversations.js +150 -0
  72. package/src/groom.js +157 -0
  73. package/src/health.js +309 -0
  74. package/src/hooks.js +202 -0
  75. package/src/init.js +314 -0
  76. package/src/launch.js +276 -0
  77. package/src/message.js +380 -0
  78. package/src/orca.js +291 -0
  79. package/src/pause.js +58 -0
  80. package/src/record.js +209 -0
  81. package/src/restart.js +192 -0
  82. package/src/retire.js +92 -0
  83. package/src/roster.js +71 -0
  84. package/src/rules.js +408 -0
  85. package/src/skills.js +629 -0
  86. package/src/sources.js +370 -0
  87. package/src/up.js +475 -0
  88. package/src/usage.js +401 -0
  89. package/bin/okb.js +0 -4
  90. package/src/index.js +0 -11
package/src/sources.js ADDED
@@ -0,0 +1,370 @@
1
+ // Skills from an online repo, at the version the user pinned (PRD 6.7, ADR 0004).
2
+ //
3
+ // A source is a git repository the user names in `<bots>/skills.yaml`. The kit
4
+ // clones it into `<bots>.skill-sources/<name>`, a sibling of the bots folder and
5
+ // never inside it, so the user's repo holds no copy of anybody else's work.
6
+ //
7
+ // The one thing this must never do is move a clone on its own. `fetch` gets
8
+ // what is missing and leaves what is there exactly where it is — even when the
9
+ // ref is a branch the origin has carried on past. `update` is the user asking,
10
+ // and it is the only thing that moves a clone. So the version a bot runs
11
+ // changes when they say so and at no other time.
12
+ //
13
+ // What they asked for is `ref` and what they got is `sha`, both in their own
14
+ // file, so the difference is theirs to see.
15
+
16
+ import { spawnSync } from 'node:child_process';
17
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
18
+ import path from 'node:path';
19
+ import { parseDocument } from 'yaml';
20
+
21
+ import { changesExactly, requireBotsFolder, YAML_OUT } from './bot.js';
22
+
23
+ /** The user's file that lists where skills come from. */
24
+ const SKILLS_YAML = 'skills.yaml';
25
+
26
+ /** Where the clones go: beside the bots folder, named after it. */
27
+ export const sourcesDir = (bots) => `${bots}.skill-sources`;
28
+
29
+ /** Where one source's clone goes. */
30
+ export const cloneDir = (bots, name) => path.join(sourcesDir(bots), name);
31
+
32
+ /** A source's name, which is also the folder it is cloned into and the prefix a bot names it by. */
33
+ const NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/;
34
+
35
+ /** The prefix issue #35 gave the kit's own shelf, and so not a name a source may take. */
36
+ const TAKEN = ['kit'];
37
+
38
+ /**
39
+ * What looks like something that runs. Not a scan and not a gate: the user
40
+ * takes the risk for a third-party skill (PRD 6.7), and what the kit owes them
41
+ * is knowing there is a risk to take.
42
+ */
43
+ const RUNS = /\.(sh|bash|zsh|py|rb|pl|js|mjs|cjs|ts)$/i;
44
+ const RUNS_DIR = /^(scripts?|hooks?|bin)$/i;
45
+
46
+ /**
47
+ * Write one source into the user's own `skills.yaml`, and change nothing else
48
+ * in it. Returns the source as it was written.
49
+ *
50
+ * What this accepts is what `readSources` accepts, and for the same reason the
51
+ * bot commands check a name before writing it: an entry the next command
52
+ * refuses to read is worse than a refusal here, because the user finds it two
53
+ * commands later with nothing to say which one wrote it.
54
+ *
55
+ * It does not clone. Pinning a version and going to the network are two
56
+ * different decisions, and `skills fetch` is the one that goes.
57
+ */
58
+ export function addSource(bots, { name, repo, ref, path: inside }) {
59
+ requireBotsFolder(bots);
60
+
61
+ if (!NAME.test(name)) {
62
+ throw new Error(`${name} cannot be a source's name: a name is lower-case letters, digits and single hyphens, such as someones-skills.`);
63
+ }
64
+ if (TAKEN.includes(name)) {
65
+ throw new Error(`${name} is the kit's own shelf, which a bot names kit:<name>, so a source cannot be called that. Call it something else.`);
66
+ }
67
+
68
+ const file = path.join(bots, SKILLS_YAML);
69
+ // Read through the same reader every other command uses, so a file this one
70
+ // would add to is a file they can all still read afterwards.
71
+ if (readSources(file).some((source) => source.name === name)) {
72
+ throw new Error(`${file} already lists a source called ${name}, and source add never writes over one.`);
73
+ }
74
+
75
+ const source = { name, repo, ref, ...(inside === undefined ? {} : { path: inside }) };
76
+ const was = existsSync(file) ? readFileSync(file, 'utf8') : '';
77
+ const doc = parseDocument(was);
78
+ const listed = doc.get('sources', true);
79
+ if (listed?.items === undefined) {
80
+ // No list to add to: an empty `sources:`, or no sources key at all.
81
+ doc.set('sources', [source]);
82
+ } else {
83
+ // A list written inline becomes a block list from here on, which is the one
84
+ // shape a source with four fields in it reads well in.
85
+ listed.flow = false;
86
+ doc.addIn(['sources'], source);
87
+ }
88
+
89
+ const text = doc.toString(YAML_OUT);
90
+ if (!changesExactly(was, text, (had) => ({ ...had, sources: [...(had.sources ?? []), source] }))) {
91
+ throw new Error(`${file} cannot have a source added to it without changing something else in it, so nothing was written. Add ${name} to its sources list by hand.`);
92
+ }
93
+ writeFileSync(file, text);
94
+ return source;
95
+ }
96
+
97
+ /**
98
+ * Fetch what is missing, or move what the user asks to move.
99
+ * Returns one entry per source: `{ name, state, ref, sha }`, or `{ name, trouble }`.
100
+ *
101
+ * `moving` is the whole difference between the two commands. Without it a
102
+ * source already cloned is reported as it stands and nothing reaches the
103
+ * network; with it the ref is resolved again and the clone follows.
104
+ */
105
+ export function fetchSources(bots, { source: onlySource, moving = false } = {}) {
106
+ const file = path.join(bots, SKILLS_YAML);
107
+ const sources = readSources(file);
108
+
109
+ if (onlySource !== undefined && !sources.some((source) => source.name === onlySource)) {
110
+ throw new Error(`${file} lists no source called ${onlySource}. The sources it lists are: ${sources.map((one) => one.name).join(', ') || 'none'}.`);
111
+ }
112
+
113
+ const wanted = onlySource === undefined ? sources : sources.filter((source) => source.name === onlySource);
114
+ const report = [];
115
+ const got = new Map();
116
+
117
+ for (const source of wanted) {
118
+ try {
119
+ const at = bring(bots, source, moving);
120
+ got.set(source.name, at.sha);
121
+ report.push({ name: source.name, state: at.state, ref: source.ref, sha: at.sha, ...(at.runs ? { runs: true } : {}) });
122
+ } catch (error) {
123
+ report.push({ name: source.name, trouble: error.message });
124
+ }
125
+ }
126
+
127
+ writeShas(file, got);
128
+ return report;
129
+ }
130
+
131
+ /**
132
+ * Put one source's clone where it belongs, and say what that took.
133
+ *
134
+ * Three things this has to get right, because each is a way of running a
135
+ * version nobody chose:
136
+ *
137
+ * - A directory under the sources folder means a source at the version that was
138
+ * asked for, and nothing else. The clone and the checkout happen out of the
139
+ * way and are moved into place once both have worked, so a checkout that
140
+ * fails leaves nothing for the next run to mistake for a source.
141
+ * - A clone that has gone missing comes back at the commit the file records,
142
+ * not at whatever its ref points to today. Restoring is not moving.
143
+ * - A clone is this source's only while it came from the repository the file
144
+ * names. Point a source somewhere else and the old clone is not an answer.
145
+ */
146
+ function bring(bots, source, moving) {
147
+ const dir = cloneDir(bots, source.name);
148
+
149
+ if (existsSync(dir)) {
150
+ const wrong = wrongClone(bots, source);
151
+ if (wrong !== undefined) {
152
+ if (!moving) throw new Error(wrong);
153
+ rmSync(dir, { recursive: true, force: true });
154
+ return clone(bots, source, source.ref, false);
155
+ }
156
+
157
+ if (!moving) return { state: 'there', sha: headOf(dir), runs: carriesScripts(skillsIn(bots, source)) };
158
+
159
+ const was = headOf(dir);
160
+ git(['fetch', '--quiet', '--tags', '--force', 'origin'], dir, `${source.name}: ${source.repo} could not be fetched from`);
161
+ checkout(dir, source, source.ref, false);
162
+ const now = headOf(dir);
163
+ return { state: now === was ? 'there' : 'moved', sha: now, runs: carriesScripts(skillsIn(bots, source)) };
164
+ }
165
+
166
+ // Nothing there. A fetch puts back the version the file records; only the
167
+ // asking takes what the ref names now.
168
+ const recorded = typeof source.sha === 'string' && source.sha.trim() !== '';
169
+ return clone(bots, source, !moving && recorded ? source.sha.trim() : source.ref, !moving && recorded);
170
+ }
171
+
172
+ /**
173
+ * Clone a source and check it out out of the way, and put it in place only once
174
+ * both have worked. `<name>.fetching` is where it happens; a run that cannot
175
+ * finish leaves that behind and not a source.
176
+ */
177
+ function clone(bots, source, rev, recorded) {
178
+ mkdirSync(sourcesDir(bots), { recursive: true });
179
+ const dir = cloneDir(bots, source.name);
180
+ const fetching = `${dir}.fetching`;
181
+
182
+ try {
183
+ rmSync(fetching, { recursive: true, force: true });
184
+ // `--`: a repo value from skills.yaml, which may be someone else's, is a
185
+ // repository and never one of git's own options, whatever it begins with.
186
+ git(['clone', '--quiet', '--', source.repo, fetching], undefined, `${source.name}: ${source.repo} could not be cloned`);
187
+ checkout(fetching, source, rev, recorded);
188
+ renameSync(fetching, dir);
189
+ } finally {
190
+ rmSync(fetching, { recursive: true, force: true });
191
+ }
192
+
193
+ return { state: 'cloned', sha: headOf(dir), runs: carriesScripts(skillsIn(bots, source)) };
194
+ }
195
+
196
+ /**
197
+ * Why the clone beside the bots folder is not this source's, or undefined when
198
+ * it is. A clone is a source's only while it came from the repository the file
199
+ * names; fetch and build both ask here, so neither takes what the other refuses.
200
+ */
201
+ export function wrongClone(bots, source) {
202
+ const from = originOf(cloneDir(bots, source.name));
203
+ if (from === source.repo) return undefined;
204
+ return `${source.name}: the clone beside your bots folder came from ${from}, and skills.yaml now names ${source.repo}. Run obk skills update --source ${source.name} to take it from there instead.`;
205
+ }
206
+
207
+ /** Where a clone came from, as git itself has it written down. */
208
+ function originOf(dir) {
209
+ const found = spawnSync('git', ['config', '--get', 'remote.origin.url'], { cwd: dir, encoding: 'utf8' });
210
+ return found.status === 0 ? found.stdout.trim() : undefined;
211
+ }
212
+
213
+ /**
214
+ * Check a clone out at `rev`. A branch, a tag and a sha all go the same way in,
215
+ * which is why the kit does not ask which kind it was given.
216
+ *
217
+ * The origin's idea of a branch is the one that counts. A fetch moves
218
+ * `origin/main` and leaves the local `main` where it was, so checking out the
219
+ * bare name would quietly keep the old commit and report a move that never
220
+ * happened. A tag or a sha has no `origin/` form and falls through to itself.
221
+ */
222
+ function checkout(dir, source, rev, recorded) {
223
+ const at = [`refs/remotes/origin/${rev}`, rev]
224
+ .map((candidate) => resolve(dir, candidate))
225
+ .find((sha) => sha !== undefined);
226
+
227
+ if (at === undefined) {
228
+ throw new Error(recorded
229
+ ? `${source.name}: ${source.repo} no longer has the commit skills.yaml records for it (${rev.slice(0, 7)}), so the version you pinned cannot be put back. Run obk skills update --source ${source.name} to take what ${source.ref} names now, or point it at a version the repo still has.`
230
+ : `${source.name}: ${source.repo} has nothing called ${rev}`);
231
+ }
232
+ git(['-c', 'advice.detachedHead=false', 'checkout', '--quiet', at], dir, `${source.name}: could not go to ${rev}`);
233
+ }
234
+
235
+ /** The commit a name stands for in this clone, or nothing when it stands for none. */
236
+ function resolve(dir, rev) {
237
+ const found = spawnSync('git', ['rev-parse', '--verify', '--quiet', `${rev}^{commit}`], { cwd: dir, encoding: 'utf8' });
238
+ return found.status === 0 && found.stdout.trim() !== '' ? found.stdout.trim() : undefined;
239
+ }
240
+
241
+ /** What a clone is at now. */
242
+ const headOf = (dir) => git(['rev-parse', 'HEAD'], dir, 'could not read what the clone is at');
243
+
244
+ /** Run git, and turn a refusal into a sentence the user can act on. */
245
+ function git(args, cwd, trouble) {
246
+ const result = spawnSync('git', args, { cwd, encoding: 'utf8' });
247
+ if (result.error) throw new Error(`could not run git: ${result.error.message}`);
248
+ if (result.status !== 0) throw new Error(`${trouble}: ${(result.stderr || '').trim().split('\n').at(-1) ?? ''}`);
249
+ return result.stdout.trim();
250
+ }
251
+
252
+ /** Where a source's skills sit inside its clone: its root, or the subfolder it names. */
253
+ export const skillsIn = (bots, source) => path.join(cloneDir(bots, source.name), source.path ?? '');
254
+
255
+ /**
256
+ * Whether anything under a source's skills runs. One answer for the whole
257
+ * source, because one line is what the user is owed, however much is in there.
258
+ */
259
+ function carriesScripts(dir) {
260
+ let found = false;
261
+ const look = (at, depth) => {
262
+ if (found || depth > 4) return;
263
+ for (const entry of readdirSync(at, { withFileTypes: true })) {
264
+ if (found) return;
265
+ if (entry.isDirectory()) {
266
+ if (RUNS_DIR.test(entry.name)) found = true;
267
+ else look(path.join(at, entry.name), depth + 1);
268
+ } else if (RUNS.test(entry.name)) {
269
+ found = true;
270
+ }
271
+ }
272
+ };
273
+
274
+ try {
275
+ // The skills themselves, and not the repository around them: every clone
276
+ // carries a `.git/hooks`, and a question about what a source runs that
277
+ // answers yes for all of them answers nothing.
278
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
279
+ if (!entry.isDirectory() || !existsSync(path.join(dir, entry.name, 'SKILL.md'))) continue;
280
+ look(path.join(dir, entry.name), 1);
281
+ }
282
+ } catch {
283
+ // A source whose skills cannot be walked is not a source to make claims
284
+ // about. Saying nothing is the honest answer.
285
+ return false;
286
+ }
287
+ return found;
288
+ }
289
+
290
+ /**
291
+ * The sources the user listed, checked far enough that the kit knows what it is
292
+ * being asked for. A file it cannot read is refused whole rather than half
293
+ * followed, and nothing is written to it.
294
+ */
295
+ export function readSources(file) {
296
+ if (!existsSync(file)) return [];
297
+
298
+ const doc = parseDocument(readFileSync(file, 'utf8'));
299
+ if (doc.errors.length > 0) {
300
+ throw new Error(`${file} is not readable as YAML: ${doc.errors[0].message.split('\n')[0]}`);
301
+ }
302
+
303
+ const listed = doc.toJS()?.sources;
304
+ if (listed === undefined || listed === null) return [];
305
+ if (!Array.isArray(listed)) {
306
+ throw new Error(`${file} has a sources entry that is not a list of sources.`);
307
+ }
308
+
309
+ const names = new Set();
310
+ return listed.map((source) => {
311
+ if (source === null || typeof source !== 'object' || Array.isArray(source)) {
312
+ throw new Error(`${file} has a source that is not a mapping: a source has a name, a repo and a ref.`);
313
+ }
314
+ const { name, repo, ref } = source;
315
+ if (typeof name !== 'string' || !NAME.test(name)) {
316
+ throw new Error(`${file} has a source called ${JSON.stringify(name)}, and a source is named in lower-case letters, digits and single hyphens.`);
317
+ }
318
+ if (TAKEN.includes(name)) {
319
+ throw new Error(`${file} has a source called ${name}, and that name is the kit's own shelf: a bot names one of the kit's skills kit:<name>. Call it something else.`);
320
+ }
321
+ if (names.has(name)) throw new Error(`${file} lists ${name} more than once.`);
322
+ names.add(name);
323
+
324
+ if (typeof repo !== 'string' || repo.trim() === '') {
325
+ throw new Error(`${file}: the source ${name} has no repo to clone.`);
326
+ }
327
+ if (typeof ref !== 'string' || ref.trim() === '') {
328
+ throw new Error(`${file}: the source ${name} has no ref, so there is no version to pin it at.`);
329
+ }
330
+ if (source.path !== undefined && typeof source.path !== 'string') {
331
+ throw new Error(`${file}: the source ${name} has a path that is not a subfolder inside the repo.`);
332
+ }
333
+ return source;
334
+ });
335
+ }
336
+
337
+ /**
338
+ * Write each source's resolved sha back into the user's own file, and change
339
+ * nothing else in it. The file is theirs: the comments, the order and every
340
+ * value they wrote come back as they wrote them, and a file the kit cannot
341
+ * edit that narrowly is left alone and said so.
342
+ */
343
+ function writeShas(file, got) {
344
+ if (got.size === 0) return;
345
+
346
+ const source = readFileSync(file, 'utf8');
347
+ const doc = parseDocument(source);
348
+ const listed = doc.get('sources', true);
349
+ let changed = false;
350
+
351
+ for (const entry of listed?.items ?? []) {
352
+ const name = entry?.get?.('name');
353
+ if (!got.has(name) || entry.get('sha') === got.get(name)) continue;
354
+ entry.set('sha', got.get(name));
355
+ changed = true;
356
+ }
357
+ if (!changed) return;
358
+
359
+ const text = doc.toString(YAML_OUT);
360
+ if (!changesExactly(source, text, (was) => ({
361
+ ...was,
362
+ sources: was.sources.map((one) => (got.has(one.name) ? { ...one, sha: got.get(one.name) } : one)),
363
+ }))) {
364
+ throw new Error(`${file} cannot have the versions written into it without changing something else in it, so nothing was written. Add a sha to each source by hand, or let the kit rewrite the file.`);
365
+ }
366
+ writeFileSync(file, text);
367
+ }
368
+
369
+ /** Whether a bots folder has a clone of this source yet. */
370
+ export const isCloned = (bots, name) => statSync(cloneDir(bots, name), { throwIfNoEntry: false })?.isDirectory() === true;