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