@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.
- package/LICENSE +21 -0
- package/README.md +548 -3
- package/SETUP.md +275 -0
- package/package.json +32 -13
- package/rules/evidence.md +15 -0
- package/rules/finishing.md +23 -0
- package/rules/limits.md +18 -0
- package/rules/mail.md +17 -0
- package/rules/profiles.md +9 -0
- package/rules/review.md +14 -0
- package/rules/scope.md +22 -0
- package/rules/simple.md +14 -0
- package/rules/talk.md +13 -0
- package/rules/tests-first.md +17 -0
- package/rules/the-ask.md +19 -0
- package/skills/obk-arch/LICENSES.md +52 -0
- package/skills/obk-arch/NOTICE.md +242 -0
- package/skills/obk-arch/SKILL.md +598 -0
- package/skills/obk-bot-building/LICENSES.md +21 -0
- package/skills/obk-bot-building/NOTICE.md +113 -0
- package/skills/obk-bot-building/SKILL.md +312 -0
- package/skills/obk-debugging/LICENSES.md +67 -0
- package/skills/obk-debugging/NOTICE.md +115 -0
- package/skills/obk-debugging/SKILL.md +434 -0
- package/skills/obk-decision-memo/LICENSES.md +49 -0
- package/skills/obk-decision-memo/NOTICE.md +181 -0
- package/skills/obk-decision-memo/SKILL.md +241 -0
- package/skills/obk-finops/LICENSES.md +19 -0
- package/skills/obk-finops/NOTICE.md +77 -0
- package/skills/obk-finops/SKILL.md +148 -0
- package/skills/obk-fleet-review/LICENSES.md +52 -0
- package/skills/obk-fleet-review/NOTICE.md +104 -0
- package/skills/obk-fleet-review/SKILL.md +256 -0
- package/skills/obk-grilling/LICENSES.md +42 -0
- package/skills/obk-grilling/NOTICE.md +111 -0
- package/skills/obk-grilling/SKILL.md +221 -0
- package/skills/obk-grooming/LICENSES.md +52 -0
- package/skills/obk-grooming/NOTICE.md +111 -0
- package/skills/obk-grooming/SKILL.md +222 -0
- package/skills/obk-handoff/LICENSES.md +45 -0
- package/skills/obk-handoff/NOTICE.md +51 -0
- package/skills/obk-handoff/SKILL.md +119 -0
- package/skills/obk-personal-facilitation/LICENSES.md +53 -0
- package/skills/obk-personal-facilitation/NOTICE.md +62 -0
- package/skills/obk-personal-facilitation/SKILL.md +86 -0
- package/skills/obk-recall/LICENSES.md +42 -0
- package/skills/obk-recall/NOTICE.md +53 -0
- package/skills/obk-recall/SKILL.md +129 -0
- package/skills/obk-researching/LICENSES.md +67 -0
- package/skills/obk-researching/NOTICE.md +170 -0
- package/skills/obk-researching/SKILL.md +269 -0
- package/skills/obk-reviewing/LICENSES.md +66 -0
- package/skills/obk-reviewing/NOTICE.md +58 -0
- package/skills/obk-reviewing/SKILL.md +411 -0
- package/skills/obk-tdd/LICENSES.md +68 -0
- package/skills/obk-tdd/NOTICE.md +54 -0
- package/skills/obk-tdd/SKILL.md +547 -0
- package/skills/obk-tdd/mutation-tools.md +80 -0
- package/skills/obk-teach/LICENSES.md +42 -0
- package/skills/obk-teach/NOTICE.md +65 -0
- package/skills/obk-teach/SKILL.md +106 -0
- package/skills/obk-why/LICENSES.md +42 -0
- package/skills/obk-why/NOTICE.md +74 -0
- package/skills/obk-why/SKILL.md +180 -0
- package/skills/obk-writing/LICENSES.md +272 -0
- package/skills/obk-writing/NOTICE.md +161 -0
- package/skills/obk-writing/SKILL.md +254 -0
- package/src/book.js +268 -0
- package/src/bot.js +451 -0
- package/src/cli.js +1169 -0
- package/src/conversations.js +150 -0
- package/src/groom.js +159 -0
- package/src/health.js +309 -0
- package/src/hooks.js +257 -0
- package/src/init.js +314 -0
- package/src/launch.js +299 -0
- package/src/message.js +402 -0
- package/src/orca.js +362 -0
- package/src/pause.js +63 -0
- package/src/record.js +209 -0
- package/src/restart.js +193 -0
- package/src/retire.js +92 -0
- package/src/roster.js +71 -0
- package/src/rules.js +408 -0
- package/src/skills.js +630 -0
- package/src/sources.js +371 -0
- package/src/up.js +500 -0
- package/src/usage.js +401 -0
- package/bin/okb.js +0 -4
- 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
|
+
}
|