@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/skills.js ADDED
@@ -0,0 +1,629 @@
1
+ // The skills a bot has: links in the bot's own folder, into both harnesses
2
+ // (PRD 6.7, ADR 0004, ADR 0009).
3
+ //
4
+ // Linked, never copied, in both directions. Inwards: what lands in the bot home
5
+ // is a symlink to the skill's own directory, so a kit skill is read where the
6
+ // package installed it and an edit to a skill is what a running session reads —
7
+ // both harnesses follow a symlinked skill folder and pick a change up without a
8
+ // restart (tech notes, sections 2 and 3). Outwards: the kit takes away only the
9
+ // links it put there, and never the skill one was pointing at.
10
+ //
11
+ // An entry names one of four shelves: the kit's own (`kit:<name>`), an online
12
+ // source `skills.yaml` lists (`<source>:<name>`, read from its clone beside the
13
+ // bots folder), the user's common folder (a bare name), or a path to anywhere
14
+ // on disk.
15
+
16
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ import { parse, parseDocument, stringify } from 'yaml';
22
+
23
+ import { botDir, botNames, changesExactly, leadsOutside, readBot, YAML_OUT } from './bot.js';
24
+ import { listIn } from './rules.js';
25
+ import { cloneDir, isCloned, readSources, skillsIn, wrongClone } from './sources.js';
26
+
27
+ /** The kit's own skills, inside the installed package. */
28
+ const KIT_SKILLS = fileURLToPath(new URL('../skills', import.meta.url));
29
+
30
+ /** What an entry carries to mean one of the kit's rather than one of the user's. */
31
+ const KIT = 'kit:';
32
+
33
+ /** Where each harness reads a project's skills, inside the folder a session starts in. */
34
+ export const SKILL_DIRS = {
35
+ claude: path.join('.claude', 'skills'),
36
+ codex: path.join('.agents', 'skills'),
37
+ };
38
+
39
+ /**
40
+ * What a skill may be called: the Agent Skills name, which is also the name of
41
+ * the directory it lives in and of the link the kit makes (tech notes, section
42
+ * 4, and ADR 0009).
43
+ */
44
+ const NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/;
45
+
46
+ /** Where the kit writes down what it linked, inside the bot folder. */
47
+ export const RECORD = '.obk-skills.yaml';
48
+
49
+ /** What that file says about itself, for whoever opens it. */
50
+ const HEADER = '# Written by obk: the skill links it made, by harness and name. It changes or\n'
51
+ + '# takes away only an entry that still says this, so anything you link yourself\n'
52
+ + '# is left alone. Delete a line and the kit forgets it made that one.\n\n';
53
+
54
+ /** The file that makes a directory a skill. A directory without one is not one. */
55
+ const MANIFEST = 'SKILL.md';
56
+
57
+ /** The bot's own file, which is where its skills list lives. */
58
+ const BOT_YAML = 'bot.yaml';
59
+
60
+ /**
61
+ * Put one skill on a bot's list, in the user's own `bot.yaml`.
62
+ * Returns `{ bot, home, skill, state }`, where the state is `added` or `there`.
63
+ *
64
+ * It writes the list and stops. Linking is `obk skills build`, and the two are
65
+ * apart on purpose: a skill named from a source nobody has fetched yet cannot
66
+ * be linked, and a bot whose list grew by one is not a bot that should lose the
67
+ * links it already has to say so.
68
+ *
69
+ * An entry already on the list is left alone rather than written twice. That is
70
+ * the command doing what was asked, not refusing it: the list already says what
71
+ * the caller wanted it to say.
72
+ */
73
+ export function addSkill(bots, bot, ref) {
74
+ const known = botNames(bots);
75
+ const home = botDir(bots, bot);
76
+ const file = path.join(home, BOT_YAML);
77
+ if (!known.includes(bot)) {
78
+ throw new Error(`there is no bot called ${bot} in ${bots}: ${file} is not there. Create it with obk bot create.`);
79
+ }
80
+
81
+ if (listIn(file, 'skills').includes(ref)) return { bot, home, skill: ref, state: 'there' };
82
+
83
+ const was = readFileSync(file, 'utf8');
84
+ const doc = parseDocument(was);
85
+ const listed = doc.get('skills', true);
86
+ if (listed?.items === undefined) {
87
+ // No list to add to: an empty `skills:`, or no skills key at all.
88
+ doc.set('skills', [ref]);
89
+ } else {
90
+ listed.flow = false;
91
+ doc.addIn(['skills'], ref);
92
+ }
93
+
94
+ const text = doc.toString(YAML_OUT);
95
+ if (!changesExactly(was, text, (had) => ({ ...had, skills: [...(had.skills ?? []), ref] }))) {
96
+ throw new Error(`${file} cannot have a skill added to it without changing something else in it, so nothing was written. Add ${ref} to its skills list by hand.`);
97
+ }
98
+ writeFileSync(file, text);
99
+ return { bot, home, skill: ref, state: 'added' };
100
+ }
101
+
102
+ /**
103
+ * Take one skill off a bot's list: `addSkill` the other way round, and apart
104
+ * from linking for the same reason. Returns `{ bot, home, skill, state }`, where
105
+ * the state is `removed`, or `absent` for a skill the list does not name.
106
+ */
107
+ export function removeSkill(bots, bot, ref) {
108
+ const known = botNames(bots);
109
+ const home = botDir(bots, bot);
110
+ const file = path.join(home, BOT_YAML);
111
+ if (!known.includes(bot)) {
112
+ throw new Error(`there is no bot called ${bot} in ${bots}: ${file} is not there.`);
113
+ }
114
+
115
+ const index = listIn(file, 'skills').indexOf(ref);
116
+ if (index === -1) return { bot, home, skill: ref, state: 'absent' };
117
+
118
+ const was = readFileSync(file, 'utf8');
119
+ const doc = parseDocument(was);
120
+ doc.deleteIn(['skills', index]);
121
+
122
+ const text = doc.toString(YAML_OUT);
123
+ if (!changesExactly(was, text, (had) => ({ ...had, skills: had.skills.filter((one, at) => at !== index) }))) {
124
+ throw new Error(`${file} cannot have a skill taken off it without changing something else in it, so nothing was written. Take ${ref} off its skills list by hand.`);
125
+ }
126
+ writeFileSync(file, text);
127
+ return { bot, home, skill: ref, state: 'removed' };
128
+ }
129
+
130
+ /**
131
+ * Give one bot the skills its lists name. Returns what there is to report:
132
+ * `{ bot, skills, removed }` for a bot whose links are in place, and
133
+ * `{ bot, skills, trouble }` for one whose list the kit could not follow.
134
+ *
135
+ * A list with one entry the kit cannot follow links nothing at all: half a
136
+ * bot's skills is not a bot anybody asked for, and the user fixes one line.
137
+ */
138
+ export function linkSkills(bots, home, bot) {
139
+ const record = readRecord(home);
140
+ const held = new Map();
141
+
142
+ // What each harness's directory holds now, for a report made before anything
143
+ // was linked. This runs on the way out of a failure, so it must not fail in
144
+ // turn: a directory that is not one, or one the kit may not read, holds
145
+ // nothing it can report, and the bot's own trouble says why.
146
+ const heldAsItIs = () => {
147
+ for (const [harness, inside] of Object.entries(SKILL_DIRS)) {
148
+ const dir = path.join(home, inside);
149
+ let found = new Map();
150
+ try {
151
+ if (isDirectory(dir)) found = heldIn(dir, record[harness] ?? {});
152
+ } catch {
153
+ // Unreadable: reported as holding nothing, rather than thrown out of a
154
+ // report whose whole job is to keep one bot's trouble to that bot.
155
+ }
156
+ held.set(harness, found);
157
+ }
158
+ };
159
+
160
+ // Every bot has both directories, skills or none, and before its lists are
161
+ // even read. Claude Code notices a skill added while a session runs only in a
162
+ // skills directory that was there when the session started, and refuses one
163
+ // made afterwards as unknown; Codex does register a directory made later, but
164
+ // not dependably once it has (tech notes, section 3, with the evidence for
165
+ // each). So a bot given its first skill mid-session could not use it without
166
+ // a restart, which is what PRD 4.6 says a skill never needs — and that holds
167
+ // for a bot whose list the kit cannot follow as much as for one with none,
168
+ // since fixing the list is exactly when its first skill arrives. An empty
169
+ // directory costs the user's repo nothing: git does not track one, and what
170
+ // goes in it is gitignored (#136).
171
+ //
172
+ // Something of the user's in the way is reported, never thrown and never
173
+ // moved. A skills problem does not hold a bot down, let alone the fleet
174
+ // (`prepareBots` in up.js), so one bot's folder must not stop `obk up`.
175
+ const blocked = [];
176
+ // A skills directory a link of the user's takes out of the bot folder, to a
177
+ // shelf of their own or their user-level `~/.claude`, is theirs: the kit reads
178
+ // it and writes nothing into it (PRD 6.3), by harness.
179
+ const away = new Map();
180
+ for (const [harness, inside] of Object.entries(SKILL_DIRS)) {
181
+ const dir = path.join(home, inside);
182
+ const real = leadsOutside(home, dir);
183
+ if (real !== undefined) {
184
+ away.set(harness, real);
185
+ continue;
186
+ }
187
+ try {
188
+ mkdirSync(dir, { recursive: true });
189
+ } catch (error) {
190
+ blocked.push(`${dir} cannot be made, because something of yours is in the way (${error.code ?? error.message}). Every bot needs it, so that a skill given to it later reaches a session that is already running. Move what is there aside, then build again.`);
191
+ }
192
+ }
193
+ if (blocked.length > 0) {
194
+ heldAsItIs();
195
+ return { bot: bot.name, skills: heldBy(home, [], held), trouble: blocked.join(' ') };
196
+ }
197
+
198
+ let wanted;
199
+ try {
200
+ wanted = skillsFor(bots, home, bot);
201
+ } catch (error) {
202
+ heldAsItIs();
203
+ return { bot: bot.name, skills: heldBy(home, [], held), trouble: error.message };
204
+ }
205
+
206
+ // Linking is where the rest of the file system gets its say: a directory the
207
+ // kit could make but may not read or write into, a link that cannot be made.
208
+ // Whatever it is, it is this bot's trouble, like the cases above, and not
209
+ // something to throw through `prepareBots` and stop the fleet. What was
210
+ // linked before it went wrong is still written down, so the next run knows it
211
+ // for the kit's own.
212
+ const removed = [];
213
+ try {
214
+ for (const harness of Object.keys(SKILL_DIRS)) {
215
+ record[harness] ??= {};
216
+ if (away.has(harness)) {
217
+ held.set(harness, heldIn(path.join(home, SKILL_DIRS[harness]), record[harness]));
218
+ continue;
219
+ }
220
+ const done = link(path.join(home, SKILL_DIRS[harness]), wanted, record[harness]);
221
+ removed.push(...done.removed);
222
+ held.set(harness, done.held);
223
+ }
224
+ } catch (error) {
225
+ try {
226
+ writeRecord(home, record);
227
+ } catch {
228
+ // The record could not be kept either; the trouble below is still true.
229
+ }
230
+ heldAsItIs();
231
+ return {
232
+ bot: bot.name,
233
+ skills: heldBy(home, [], held),
234
+ trouble: `${bot.name}'s skills could not be linked: ${error.message}. Nothing of yours was changed; put right what is named there, then build again.`,
235
+ };
236
+ }
237
+ writeRecord(home, record);
238
+
239
+ const skills = heldBy(home, wanted, held);
240
+ // The lists name skills, and the kit will not put them where that link leads.
241
+ if (wanted.length > 0 && away.size > 0) {
242
+ return {
243
+ bot: bot.name,
244
+ skills,
245
+ removed: [...new Set(removed)].sort(),
246
+ trouble: [...away].map(([harness, real]) => `${path.join(home, SKILL_DIRS[harness])} leads outside the bot folder, to ${real}, through a link, and the kit links skills only inside the bot folder, so ${harness} does not get the skills the lists name. Replace the link with a directory of the bot's own, then build again.`).join(' '),
247
+ };
248
+ }
249
+ const clash = skills.filter((skill) => !skill.managed && wanted.some((one) => one.name === skill.name));
250
+ return {
251
+ bot: bot.name,
252
+ skills,
253
+ removed: [...new Set(removed)].sort(),
254
+ // A name the lists ask for that the bot has something else under. The user's
255
+ // own is never written over, so what they have to know is that the list did
256
+ // not take effect, and under which harness.
257
+ ...(clash.length === 0 ? {} : {
258
+ trouble: `${clash.map((skill) => skill.name).join(', ')}: the lists name ${clash.length === 1 ? 'this skill' : 'these skills'}, and what is in the bot's skills directories under ${clash.length === 1 ? 'that name' : 'those names'} is not the kit's to replace. Move yours aside if you want the listed one, or take the name out of the list.`,
259
+ }),
260
+ };
261
+ }
262
+
263
+ /**
264
+ * Take back every skill link the kit made in this bot, and its record of them.
265
+ * What the user put there is theirs and stays. Returns the names taken back.
266
+ */
267
+ export function unlinkSkills(home) {
268
+ const record = readRecord(home);
269
+ const removed = [];
270
+ for (const harness of Object.keys(SKILL_DIRS)) {
271
+ removed.push(...link(path.join(home, SKILL_DIRS[harness]), [], record[harness] ?? {}).removed);
272
+ }
273
+ writeRecord(home, {});
274
+ return [...new Set(removed)].sort();
275
+ }
276
+
277
+ /**
278
+ * What the kit last linked into this bot, by harness and name. The kit acts
279
+ * only where what is on disk is still what this says it wrote, so a link the
280
+ * user made, or one of the kit's that they have since repointed, is theirs.
281
+ *
282
+ * It is the kit's own file and lives beside the hook files it already keeps in
283
+ * the bot folder (ADR 0010). A record that cannot be read is treated as an
284
+ * empty one: the worst that follows is that the kit leaves alone something it
285
+ * would otherwise have tidied up, which is the safe way round.
286
+ */
287
+ function readRecord(home) {
288
+ try {
289
+ const record = parse(readFileSync(path.join(home, RECORD), 'utf8'));
290
+ return record !== null && typeof record === 'object' && !Array.isArray(record) ? record : {};
291
+ } catch {
292
+ return {};
293
+ }
294
+ }
295
+
296
+ function writeRecord(home, record) {
297
+ const kept = Object.fromEntries(Object.entries(record).filter(([, links]) => Object.keys(links).length > 0));
298
+ const file = path.join(home, RECORD);
299
+ if (Object.keys(kept).length === 0) {
300
+ rmSync(file, { force: true });
301
+ return;
302
+ }
303
+ writeFileSync(file, `${HEADER}${stringify(kept, YAML_OUT)}`);
304
+ }
305
+
306
+ /**
307
+ * What is wrong with a bot's skills as they stand: `[{ where, says }]`, and
308
+ * nothing when the bot has what its lists name. The health check's half of the
309
+ * linking, and it links nothing: it reads the directories, the record and the
310
+ * lists, and says what it finds (PRD 6.8).
311
+ *
312
+ * A link that leads nowhere is reported whoever made it. Whose it is changes
313
+ * what may be done about it, not the fact that a session cannot read the skill
314
+ * (ADR 0004).
315
+ */
316
+ export function skillsTrouble(bots, home, bot) {
317
+ const record = readRecord(home);
318
+ const trouble = [];
319
+
320
+ for (const [harness, inside] of Object.entries(SKILL_DIRS)) {
321
+ const dir = path.join(home, inside);
322
+
323
+ for (const name of namesIn(dir)) {
324
+ const at = path.join(dir, name);
325
+ const target = linkAt(at);
326
+ // `existsSync` follows the link, so a link with nothing at the end of it
327
+ // is exactly what answers false here.
328
+ if (target !== undefined && !existsSync(at)) {
329
+ trouble.push({
330
+ where: at,
331
+ says: `${at} is a link to ${target}, and there is nothing there any more, so a ${harness} session of this bot cannot read the skill it names.`,
332
+ });
333
+ }
334
+ }
335
+ }
336
+
337
+ let wanted;
338
+ try {
339
+ wanted = skillsFor(bots, home, bot);
340
+ } catch (error) {
341
+ trouble.push({ where: path.join(home, 'bot.yaml'), says: error.message });
342
+ return trouble;
343
+ }
344
+
345
+ // Every skill the lists name, against what is actually at the end of the link
346
+ // — not against what the kit once wrote there. The record says whether the
347
+ // kit may repoint a link; it says nothing about whether the link is still
348
+ // where the list points, and a list that has moved on leaves both harnesses
349
+ // reading the old skill with nothing to say so.
350
+ for (const skill of wanted) {
351
+ for (const [harness, inside] of Object.entries(SKILL_DIRS)) {
352
+ const at = path.join(home, inside, skill.name);
353
+ const target = linkAt(at);
354
+ if (target === skill.dir) continue;
355
+ // A link with nothing at the end of it is reported above, as the broken
356
+ // link it is, and is not worth a second finding under another name.
357
+ if (target !== undefined && !existsSync(at)) continue;
358
+
359
+ trouble.push({ where: at, says: how(skill, bot, harness, at, target, record[harness]?.[skill.name]) });
360
+ }
361
+ }
362
+
363
+ return trouble;
364
+ }
365
+
366
+ /**
367
+ * Why one harness is not reading the skill its lists name: there is nothing
368
+ * there, the kit's own link is pointing at what the lists used to name, or what
369
+ * is there is the user's own and is never written over.
370
+ */
371
+ function how(skill, bot, harness, at, target, wrote) {
372
+ const mine = `${skill.name} is in ${bot.name}'s skills list as ${skill.dir}`;
373
+ if (lstatSync(at, { throwIfNoEntry: false }) === undefined) {
374
+ return `${mine}, and there is nothing at ${at}, so a ${harness} session of this bot does not have it. obk skills build puts the link there.`;
375
+ }
376
+ if (target !== undefined && target === wrote) {
377
+ return `${mine}, and the link at ${at} points at ${target} instead, which is where an older list pointed, so a ${harness} session of this bot is reading that one. obk skills build points it where the list says now.`;
378
+ }
379
+ return `${mine}, and what is at ${at} is not the kit's link. What you put there is yours and the kit never writes over it, so a ${harness} session of this bot reads that instead of the skill the list names.`;
380
+ }
381
+
382
+ /** What a directory holds, and nothing when there is no directory. */
383
+ function namesIn(dir) {
384
+ try {
385
+ return readdirSync(dir);
386
+ } catch {
387
+ return [];
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Give every bot in the folder its skills, or the one named, in name order.
393
+ * This is the whole of `obk skills build`: it writes links and asks Orca nothing.
394
+ */
395
+ export function buildSkills(bots, { bot: onlyBot } = {}) {
396
+ const names = botNames(bots);
397
+ if (onlyBot !== undefined && !names.includes(onlyBot)) {
398
+ throw new Error(`there is no bot called ${onlyBot} in ${bots}. The bots there are: ${names.join(', ')}.`);
399
+ }
400
+
401
+ return (onlyBot === undefined ? names : [onlyBot]).map((name) => {
402
+ const home = botDir(bots, name);
403
+ try {
404
+ return linkSkills(bots, home, readBot(home, name));
405
+ } catch (error) {
406
+ return { bot: name, skills: [], trouble: error.message };
407
+ }
408
+ });
409
+ }
410
+
411
+ /**
412
+ * The skills a bot should have, in the order its lists name them: what every
413
+ * bot gets, then the bot's own, each one once however often it is named.
414
+ *
415
+ * Every entry is followed before any link is made, so a list the kit cannot
416
+ * follow leaves the bot as it was.
417
+ */
418
+ function skillsFor(bots, home, bot) {
419
+ // Read once for the bot rather than once per entry: it is the user's file and
420
+ // a list that names no source never opens it.
421
+ let sources = [];
422
+ const named = [
423
+ ...listIn(path.join(bots, 'defaults.yaml'), 'skills'),
424
+ ...listIn(path.join(home, 'bot.yaml'), 'skills'),
425
+ ];
426
+
427
+ if (named.some((ref) => ref.includes(':') && !ref.startsWith(KIT))) {
428
+ sources = readSources(path.join(bots, 'skills.yaml'));
429
+ }
430
+
431
+ const skills = new Map();
432
+ for (const ref of new Set(named)) {
433
+ const skill = follow(bots, ref, sources);
434
+ // Two entries pointing at the same name is the user naming one skill twice;
435
+ // the first is the one they get, as with a name written twice.
436
+ if (!skills.has(skill.name)) skills.set(skill.name, skill);
437
+ }
438
+ return [...skills.values()];
439
+ }
440
+
441
+ /**
442
+ * Where an entry points, and what it has to be for a bot to be given it.
443
+ *
444
+ * Four shelves: the kit's own, one of the online sources the user listed, their
445
+ * common folder, and any path on disk. A colon says which of the first two,
446
+ * and a source has to be one `skills.yaml` names — an unknown prefix is a
447
+ * misspelling, not a folder called `whatever:`.
448
+ */
449
+ function follow(bots, ref, sources) {
450
+ const named = ref.includes(':') ? ref.slice(0, ref.indexOf(':')) : undefined;
451
+ const from = named === undefined ? (ref.includes('/') ? 'path' : 'common')
452
+ : (named === KIT.slice(0, -1) ? 'kit' : 'source');
453
+ const after = named === undefined ? ref : ref.slice(named.length + 1);
454
+
455
+ const dir = {
456
+ kit: () => path.join(KIT_SKILLS, after),
457
+ source: () => {
458
+ const source = sources.find((one) => one.name === named);
459
+ if (source === undefined) {
460
+ throw new Error(`${ref} names the source ${named}, and skills.yaml lists no source called that.`);
461
+ }
462
+ if (!isCloned(bots, named)) {
463
+ throw new Error(`${ref}: ${named} has not been fetched yet, so there is nothing to link. Run obk skills fetch --bots ${bots} --source ${named}.`);
464
+ }
465
+ const wrong = wrongClone(bots, source);
466
+ if (wrong !== undefined) throw new Error(`${ref}: ${wrong}`);
467
+ return path.join(skillsIn(bots, source), after);
468
+ },
469
+ common: () => path.join(bots, 'skills', ref),
470
+ // The user's own spelling: absolute, at their home, or read from the bots
471
+ // folder, which is the folder the entry is written in.
472
+ path: () => path.resolve(bots, ref.startsWith('~') ? path.join(os.homedir(), ref.slice(1)) : ref),
473
+ }[from]();
474
+
475
+ const name = path.basename(dir);
476
+ if (!NAME.test(name)) {
477
+ throw new Error(`${ref} points at ${name}, and a skill is named in lower-case letters, digits and single hyphens, such as obk-tdd. Rename the directory, or point at one that is named that way.`);
478
+ }
479
+ if (statSync(dir, { throwIfNoEntry: false })?.isDirectory() !== true) {
480
+ throw new Error(`${ref} names no skill: there is no directory at ${dir}.`);
481
+ }
482
+ if (!existsSync(path.join(dir, MANIFEST))) {
483
+ throw new Error(`${ref} points at ${dir}, which is not a skill: a skill directory holds a ${MANIFEST}.`);
484
+ }
485
+
486
+ return { name, from: from === 'source' ? named : from, dir };
487
+ }
488
+
489
+ /**
490
+ * Make one harness's skills directory hold what `wanted` says, as far as the
491
+ * kit is allowed to. Returns `{ removed, held }` — the names it took away, and
492
+ * what is in the directory afterwards.
493
+ *
494
+ * The kit acts only where the entry on disk is still the link it wrote, which
495
+ * `record` says. A destination cannot answer who made a link — the user may
496
+ * link the very same skill by hand — so the kit remembers what it made rather
497
+ * than guessing from where a link points (PRD 6.7).
498
+ */
499
+ function link(dir, wanted, record) {
500
+ // The directory is there: `linkSkills` makes both before it calls this.
501
+
502
+ const keep = new Map(wanted.map((skill) => [skill.name, skill]));
503
+ const removed = [];
504
+
505
+ // What the lists no longer name. The kit takes back only its own, and only
506
+ // while it is untouched; anything else it forgets about and leaves.
507
+ for (const name of Object.keys(record)) {
508
+ // A key is a skill's name, which is a plain name and never a path: one that
509
+ // is not came from a hand edit, and is forgotten rather than followed out
510
+ // of the skills directory.
511
+ if (!NAME.test(name)) {
512
+ delete record[name];
513
+ continue;
514
+ }
515
+ if (keep.has(name)) continue;
516
+ if (linkAt(path.join(dir, name)) === record[name]) {
517
+ rmSync(path.join(dir, name));
518
+ removed.push(name);
519
+ }
520
+ delete record[name];
521
+ }
522
+
523
+ for (const skill of wanted) {
524
+ const at = path.join(dir, skill.name);
525
+ const there = linkAt(at);
526
+
527
+ // Already the link the kit wrote, pointing where the list says: nothing to
528
+ // do, so a second run writes nothing at all.
529
+ if (there === skill.dir && record[skill.name] === skill.dir) continue;
530
+
531
+ // Something is there that the kit did not write, or wrote and the user has
532
+ // since changed. It is theirs, and the report says the bot did not get what
533
+ // the list asked for.
534
+ if (there !== undefined || existsSync(at) || lstatSync(at, { throwIfNoEntry: false }) !== undefined) {
535
+ if (record[skill.name] === undefined || record[skill.name] !== there) continue;
536
+ rmSync(at);
537
+ }
538
+
539
+ // Absolute: a kit skill lives in the installed package, outside the user's
540
+ // repo, and the link has to reach out of the repo to find it (ADR 0004).
541
+ symlinkSync(skill.dir, at);
542
+ record[skill.name] = skill.dir;
543
+ }
544
+
545
+ return { removed, held: heldIn(dir, record) };
546
+ }
547
+
548
+ /**
549
+ * Whether `at` is a directory, following a link to one, and false for anything
550
+ * else, for nothing there, and for a path the kit is not allowed to look at.
551
+ * `throwIfNoEntry: false` spares only a missing path; a denied one still
552
+ * throws, and a question like this one must answer rather than throw.
553
+ */
554
+ function isDirectory(at) {
555
+ try {
556
+ return statSync(at, { throwIfNoEntry: false })?.isDirectory() === true;
557
+ } catch {
558
+ return false;
559
+ }
560
+ }
561
+
562
+ /**
563
+ * What a link at `at` says, or undefined when there is no link there.
564
+ *
565
+ * Read with `readlink`, which asks the link itself rather than what it points
566
+ * at: a link of the user's whose target has been deleted is a thing to report,
567
+ * not a thing to fall over. (`realpathSync` has no `throwIfNoEntry` — that
568
+ * option is `statSync`'s — so a dangling link throws there, which is how this
569
+ * once took a whole `obk up` down with it.)
570
+ */
571
+ function linkAt(at) {
572
+ try {
573
+ return readlinkSync(at);
574
+ } catch {
575
+ return undefined;
576
+ }
577
+ }
578
+
579
+ /** What one harness's directory holds, and whether each entry is the kit's own. */
580
+ function heldIn(dir, record) {
581
+ const held = new Map();
582
+ if (!existsSync(dir)) return held;
583
+
584
+ for (const name of readdirSync(dir)) {
585
+ // A link the kit wrote and the record still vouches for. Both halves have
586
+ // to be there: a directory of the user's has no link to read, and a name
587
+ // the record never knew has nothing to vouch for it — and `undefined` on
588
+ // both sides is not a match, it is two absences.
589
+ const target = linkAt(path.join(dir, name));
590
+ held.set(name, target !== undefined && target === record[name] ? 'linked' : 'yours');
591
+ }
592
+ return held;
593
+ }
594
+
595
+ /**
596
+ * What a bot has in its skills directories now, harness by harness.
597
+ *
598
+ * A name is the kit's only where both harnesses hold the link the kit wrote to
599
+ * the directory the lists name. Where they differ — the user keeps their own
600
+ * skill in one of them — that is said rather than averaged away, because the
601
+ * two harnesses are then reading different skills under one name.
602
+ */
603
+ function heldBy(home, wanted, held) {
604
+ const known = new Map(wanted.map((skill) => [skill.name, skill]));
605
+ // The ones the lists name come first, in the order they name them, and
606
+ // whatever else the directories hold follows in name order.
607
+ const names = new Set([
608
+ ...wanted.map((skill) => skill.name),
609
+ ...[...held.values()].flatMap((one) => [...one.keys()]).sort(),
610
+ ]);
611
+
612
+ return [...names].map((name) => {
613
+ // `missing` is the honest answer for a name no harness holds. No command
614
+ // here can report one: each links as it reports, so a wanted name is either
615
+ // linked in the same run or something of the user's is in its way. It is
616
+ // the word a command that reports without linking — a health check — would
617
+ // need.
618
+ const at = Object.fromEntries(Object.keys(SKILL_DIRS)
619
+ .map((harness) => [harness, held.get(harness)?.get(name) ?? 'missing']));
620
+ const ours = Object.values(at).every((state) => state === 'linked');
621
+
622
+ return known.has(name) && ours
623
+ ? { name, from: known.get(name).from, managed: true, at }
624
+ // Not the kit's: the user's own, or a name the list asks for that the bot
625
+ // has something else under. It says nothing about where it came from,
626
+ // because from here it cannot tell.
627
+ : { name, managed: false, at };
628
+ });
629
+ }