@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/hooks.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// The kit's own hook, in the bot's folder and nowhere else (ADR 0020).
|
|
2
|
+
//
|
|
3
|
+
// Both harnesses read a hooks file out of the folder a session starts in, and
|
|
4
|
+
// every session starts at its bot home, so the bot folder is where the kit's
|
|
5
|
+
// hook goes: versioned with the bot, and never in the user's own settings,
|
|
6
|
+
// which other tools already fight over.
|
|
7
|
+
//
|
|
8
|
+
// The two files hold the same JSON shape and the kit asks each for the same
|
|
9
|
+
// one event. Proved live on 2026-09-20 (Claude Code 2.1.278, Codex 0.155.1): a
|
|
10
|
+
// hook in each file runs, is handed the session id on stdin, and can answer
|
|
11
|
+
// with text the session then has (tech notes, sections 2 and 3).
|
|
12
|
+
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
16
|
+
|
|
17
|
+
import { leadsOutside } from './bot.js';
|
|
18
|
+
import { ownCli, shellWord } from './launch.js';
|
|
19
|
+
|
|
20
|
+
/** Where each harness reads a project's hooks, inside the bot home. */
|
|
21
|
+
const HOOK_FILE = { claude: '.claude/settings.json', codex: '.codex/hooks.json' };
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The one thing the kit asks to be told: a session beginning. Both harnesses
|
|
25
|
+
* report it whenever a session starts, resumes or is begun again by the user —
|
|
26
|
+
* Claude Code's `/clear` and Codex's `/new` — and hand over the id it is
|
|
27
|
+
* running as, which is all the book needs.
|
|
28
|
+
*/
|
|
29
|
+
const EVENT = 'SessionStart';
|
|
30
|
+
|
|
31
|
+
/** How long the harness gives the hook before it goes on without it. */
|
|
32
|
+
const TIMEOUT = 10;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* What the hook runs: the CLI that wrote it, by its own path, so the session
|
|
36
|
+
* reports to the kit that set it up and not to whatever `obk` PATH finds in the
|
|
37
|
+
* harness's shell (#220). `|| true` and a quiet stderr because a hook must never
|
|
38
|
+
* disturb the session it fires in (ADR 0020): a kit that is not there, or a book
|
|
39
|
+
* that cannot be written, leaves the session alone and the health check finds
|
|
40
|
+
* the stale book later.
|
|
41
|
+
*/
|
|
42
|
+
export const hookCommand = (bots, bot, cli = ownCli()) =>
|
|
43
|
+
`${shellWord(cli)} session record --bots ${shellWord(bots)} --bot ${shellWord(bot)} 2>/dev/null || true`;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Make sure the bot at `home` has the kit's SessionStart hook for `harness`.
|
|
47
|
+
* Returns the file it wrote, relative to the bot home, or undefined when the
|
|
48
|
+
* file already said this.
|
|
49
|
+
*
|
|
50
|
+
* The file is the user's as much as the kit's — it is where their own project
|
|
51
|
+
* settings for that harness live — so everything else in it is kept, and only
|
|
52
|
+
* the kit's own entry is written (PRD 6.3).
|
|
53
|
+
*/
|
|
54
|
+
export function installHook(home, harness, { bots, bot }) {
|
|
55
|
+
const file = path.join(home, HOOK_FILE[harness]);
|
|
56
|
+
const away = outside(home, file);
|
|
57
|
+
if (away !== undefined) throw new Error(away);
|
|
58
|
+
const settings = readSettings(file);
|
|
59
|
+
|
|
60
|
+
const mine = { type: 'command', command: hookCommand(bots, bot), timeout: TIMEOUT };
|
|
61
|
+
// The kit's own entry is put right where it already sits, and nothing else in
|
|
62
|
+
// the file is the kit's to move: a second run adds nothing, a changed bots
|
|
63
|
+
// path is corrected rather than doubled, an entry under an event the kit no
|
|
64
|
+
// longer asks about is taken out, and a hook of the user's beside the kit's
|
|
65
|
+
// stays where they put it.
|
|
66
|
+
const wanted = { ...settings, hooks: withKitHook(settings.hooks, file, mine) };
|
|
67
|
+
|
|
68
|
+
// Compared as documents, not as text: how the user laid their file out is
|
|
69
|
+
// theirs, and a run that changes nothing writes nothing.
|
|
70
|
+
if (isDeepStrictEqual(settings, wanted)) return undefined;
|
|
71
|
+
|
|
72
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
73
|
+
writeFileSync(file, `${JSON.stringify(wanted, null, 2)}\n`);
|
|
74
|
+
return HOOK_FILE[harness];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Whether the kit's hook is in place for one harness, and what to say when it
|
|
79
|
+
* is not: `{ where, says }`, or nothing when the file already holds it.
|
|
80
|
+
*
|
|
81
|
+
* The same question `installHook` asks, without the writing. It matters more
|
|
82
|
+
* than it looks: the hook is the whole of how the book learns which
|
|
83
|
+
* conversation a session is running as (ADR 0012), and when it is not there
|
|
84
|
+
* nothing else says so — the book simply stops being true.
|
|
85
|
+
*/
|
|
86
|
+
export function hookTrouble(home, harness, { bots, bot }) {
|
|
87
|
+
const file = path.join(home, HOOK_FILE[harness]);
|
|
88
|
+
const stale = `Until it is there, nothing tells the book which conversation this bot's ${harness} sessions are running as, and the book goes stale.`;
|
|
89
|
+
|
|
90
|
+
const away = outside(home, file);
|
|
91
|
+
if (away !== undefined) return { where: file, says: `${away} ${stale}` };
|
|
92
|
+
|
|
93
|
+
let wanted;
|
|
94
|
+
let settings;
|
|
95
|
+
try {
|
|
96
|
+
settings = readSettings(file);
|
|
97
|
+
// The hook in the file is judged by what it runs, not by whether this is
|
|
98
|
+
// the install that wrote it. A bot made before the kit named itself by path
|
|
99
|
+
// runs the bare `obk`, and one another install wrote runs that install: both
|
|
100
|
+
// still reach a kit, and the next `obk up` writes this one's. So the line is
|
|
101
|
+
// checked against the program already in it, and only one that names a CLI
|
|
102
|
+
// that is not there any more is wrong — `|| true` would keep that quiet, and
|
|
103
|
+
// the book would stop being true with nothing to say why.
|
|
104
|
+
const running = kitProgramIn(settings);
|
|
105
|
+
if (running !== undefined && running !== BARE && !existsSync(running)) {
|
|
106
|
+
return {
|
|
107
|
+
where: file,
|
|
108
|
+
says: `${file} holds the kit's session hook, but it runs ${running}, which is not there any more. ${stale} obk up puts back one that runs the kit you have.`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const program = running ?? ownCli();
|
|
112
|
+
wanted = { ...settings, hooks: withKitHook(settings.hooks, file, { type: 'command', command: hookCommand(bots, bot, program), timeout: TIMEOUT }) };
|
|
113
|
+
} catch (error) {
|
|
114
|
+
return { where: file, says: `${error.message} ${stale}` };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (isDeepStrictEqual(settings, wanted)) return undefined;
|
|
118
|
+
return {
|
|
119
|
+
where: file,
|
|
120
|
+
says: `${file} does not hold the kit's session hook, and ${harness} reads this bot's hooks from it. ${stale} obk up puts it back.`,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* What to say about a hooks file that a link takes out of the bot folder, or
|
|
126
|
+
* undefined when it stays in. The kit writes its hook in the bot folder and
|
|
127
|
+
* nowhere else (ADR 0020); through a link like that it would be writing the
|
|
128
|
+
* user's own settings, so it does not, and says why.
|
|
129
|
+
*/
|
|
130
|
+
function outside(home, file) {
|
|
131
|
+
const real = leadsOutside(home, file);
|
|
132
|
+
if (real === undefined) return undefined;
|
|
133
|
+
return `${file} leads outside the bot folder, to ${real}, through a link, and the kit writes its session hook only inside the bot folder. Replace the link with a file of the bot's own, then run the command again.`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The file's hooks with the kit's own entry where it belongs and everything else
|
|
138
|
+
* as the user left it.
|
|
139
|
+
*
|
|
140
|
+
* The kit owns one entry, not the group it sits in. A group is the user's: it
|
|
141
|
+
* carries their other hooks and its own settings, and the kit's entry is only
|
|
142
|
+
* one of the things in it. So the kit's entry is written in place where one is
|
|
143
|
+
* already there, taken out where the kit no longer asks about that event, and
|
|
144
|
+
* added on its own only when the file has none. A group the kit empties goes,
|
|
145
|
+
* and an event left with no groups goes with it; a group that still has the
|
|
146
|
+
* user's hooks stays, whatever the kit took out of it.
|
|
147
|
+
*
|
|
148
|
+
* Anything under `hooks` that is not a mapping of events, or an event that is
|
|
149
|
+
* not a list of groups, is theirs and is left alone.
|
|
150
|
+
*/
|
|
151
|
+
function withKitHook(hooks, file, mine) {
|
|
152
|
+
if (hooks === undefined) return { [EVENT]: [{ hooks: [mine] }] };
|
|
153
|
+
if (hooks === null || typeof hooks !== 'object' || Array.isArray(hooks)) {
|
|
154
|
+
// Whatever this is, it is theirs, and writing the kit's hook here would
|
|
155
|
+
// write over it. Better to say so than to take it away.
|
|
156
|
+
throw new Error(`${file} has a hooks entry that is not a mapping of events, and the kit's session hook goes in there. Fix it or move it aside, then run the command again.`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// One entry of the kit's, and only one: a file that somehow holds two keeps
|
|
160
|
+
// the first and loses the rest.
|
|
161
|
+
let placed = false;
|
|
162
|
+
const events = Object.fromEntries(Object.entries(hooks).flatMap(([event, groups]) => {
|
|
163
|
+
if (!Array.isArray(groups)) return [[event, groups]];
|
|
164
|
+
|
|
165
|
+
const kept = groups.flatMap((group) => {
|
|
166
|
+
if (!Array.isArray(group?.hooks)) return [group];
|
|
167
|
+
|
|
168
|
+
const entries = group.hooks.flatMap((hook) => {
|
|
169
|
+
if (!isKitHook(hook)) return [hook];
|
|
170
|
+
if (event !== EVENT || placed) return [];
|
|
171
|
+
placed = true;
|
|
172
|
+
return [mine];
|
|
173
|
+
});
|
|
174
|
+
return entries.length === 0 ? [] : [{ ...group, hooks: entries }];
|
|
175
|
+
});
|
|
176
|
+
return kept.length === 0 ? [] : [[event, kept]];
|
|
177
|
+
}));
|
|
178
|
+
|
|
179
|
+
if (placed) return events;
|
|
180
|
+
return { ...events, [EVENT]: [...(events[EVENT] ?? []), { hooks: [mine] }] };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The kit's own hook entry, wherever it sits and whatever sits beside it: a
|
|
185
|
+
* command of exactly the shape `hookCommand` writes, for any bots folder and
|
|
186
|
+
* bot, run by a kit — the bare `obk` a bot made before #220 still has, this
|
|
187
|
+
* install, or another — so an entry written before the folder moved or the kit
|
|
188
|
+
* changed is still the kit's, and is rewritten in place rather than joined by a
|
|
189
|
+
* second. A line of the user's that only mentions the command, or runs it with
|
|
190
|
+
* a program of their own, is theirs (PRD 6.5).
|
|
191
|
+
*/
|
|
192
|
+
const isKitHook = (hook) => kitProgramOf(hook?.command) !== undefined;
|
|
193
|
+
|
|
194
|
+
/** One word as `shellWord` writes it: bare, or single-quoted with `'\''` inside. */
|
|
195
|
+
const WORD = String.raw`(?:[A-Za-z0-9,._+:@%/=-]+|'(?:[^']|'\\'')*')`;
|
|
196
|
+
const KIT_HOOK = new RegExp(String.raw`^(${WORD}) session record --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
|
|
197
|
+
|
|
198
|
+
/** The program a bot made before the kit named itself by path runs. */
|
|
199
|
+
const BARE = 'obk';
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Whether a program is a kit: the bare name, an install's `obk` wherever it
|
|
203
|
+
* lives, or a checkout's `src/cli.js` — the only two ways `ownCli` is reached.
|
|
204
|
+
*/
|
|
205
|
+
const isKitCli = (program) => path.basename(program) === BARE || program.endsWith('/src/cli.js');
|
|
206
|
+
|
|
207
|
+
/** The program a hook command of the kit's runs, unquoted, or undefined for any other command. */
|
|
208
|
+
function kitProgramOf(command) {
|
|
209
|
+
const found = typeof command === 'string' ? KIT_HOOK.exec(command) : null;
|
|
210
|
+
if (found === null) return undefined;
|
|
211
|
+
const program = unquoted(found[1]);
|
|
212
|
+
return isKitCli(program) ? program : undefined;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* What the kit's hook in these settings runs, as a path or the bare name, or
|
|
217
|
+
* undefined when there is none. Read back out of its shell quoting, so it can be
|
|
218
|
+
* looked for on disk.
|
|
219
|
+
*/
|
|
220
|
+
function kitProgramIn(settings) {
|
|
221
|
+
for (const groups of Object.values(settings.hooks ?? {})) {
|
|
222
|
+
if (!Array.isArray(groups)) continue;
|
|
223
|
+
for (const group of groups) {
|
|
224
|
+
for (const hook of Array.isArray(group?.hooks) ? group.hooks : []) {
|
|
225
|
+
const program = kitProgramOf(hook?.command);
|
|
226
|
+
if (program !== undefined) return program;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** A word as `shellWord` wrote it, read back to what it stands for. */
|
|
234
|
+
const unquoted = (word) => (word.startsWith("'") ? word.slice(1, -1).replaceAll(`'\\''`, "'") : word);
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* What the file says, or nothing when there is no file yet. A file the kit
|
|
238
|
+
* cannot read as JSON is the user's, and guessing at it would lose what they
|
|
239
|
+
* wrote, so the command says so and writes nothing.
|
|
240
|
+
*/
|
|
241
|
+
function readSettings(file) {
|
|
242
|
+
if (!existsSync(file)) return {};
|
|
243
|
+
|
|
244
|
+
const parsed = parse(readFileSync(file, 'utf8'));
|
|
245
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
246
|
+
throw new Error(`${file} is not readable as JSON, and the kit's session hook goes in it. Fix it or move it aside, then run the command again.`);
|
|
247
|
+
}
|
|
248
|
+
return parsed;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function parse(text) {
|
|
252
|
+
try {
|
|
253
|
+
return JSON.parse(text);
|
|
254
|
+
} catch {
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
}
|
package/src/init.js
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
// Creates the user's bots folder: their own git repo, seeded with the files the
|
|
2
|
+
// kit reads, and Bot Father. The kit puts none of its own code in here — kit
|
|
3
|
+
// skills are linked from the installed package (ADR 0014).
|
|
4
|
+
//
|
|
5
|
+
// Seeding only ever adds what is missing. The user edits these files, by hand
|
|
6
|
+
// or through Bot Father, so a re-run must never write over them.
|
|
7
|
+
|
|
8
|
+
import { spawnSync } from 'node:child_process';
|
|
9
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { parseDocument } from 'yaml';
|
|
12
|
+
|
|
13
|
+
import { BOT_GITIGNORE, changesExactly, YAML_OUT } from './bot.js';
|
|
14
|
+
import { RECORD, SKILL_DIRS } from './skills.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* What the kit makes on this machine, and what the repo therefore does not
|
|
18
|
+
* carry (ADR 0014, PRD 6.3).
|
|
19
|
+
*
|
|
20
|
+
* A bot's skills are symlinks into wherever the kit and the user's skill
|
|
21
|
+
* sources are installed on the machine that made them, so a clone somewhere
|
|
22
|
+
* else holds links to nothing until something relinks. They are not the repo's
|
|
23
|
+
* to carry: every one of them is made again, at this machine's paths, by
|
|
24
|
+
* `obk up` and `obk skills build`.
|
|
25
|
+
*
|
|
26
|
+
* The entries are ignored rather than the directories holding them, because
|
|
27
|
+
* git cannot take a file back into a directory it has been told to ignore, and
|
|
28
|
+
* what the user keeps in there beside the kit's links is theirs.
|
|
29
|
+
*
|
|
30
|
+
* Written once, when the folder is made, and the user's from then on like
|
|
31
|
+
* everything else `init` seeds.
|
|
32
|
+
*/
|
|
33
|
+
const GITIGNORE = `# What obk links on this machine, rather than what this repo carries.
|
|
34
|
+
#
|
|
35
|
+
# A bot's skills are symlinks into wherever the kit and your skill sources are
|
|
36
|
+
# installed here, so they say nothing true on another machine. obk up and obk
|
|
37
|
+
# skills build make them again wherever this repo is checked out, and
|
|
38
|
+
# ${RECORD} is the kit's own record of the ones it made.
|
|
39
|
+
#
|
|
40
|
+
# Anything of your own in a bot's skills directories is still yours: git add
|
|
41
|
+
# --force takes one in, or name it below after a !.
|
|
42
|
+
|
|
43
|
+
${Object.values(SKILL_DIRS).map((dir) => `bots/*/${dir.split(path.sep).join('/')}/*\n`).join('')}bots/*/${RECORD}
|
|
44
|
+
`;
|
|
45
|
+
|
|
46
|
+
const DEFAULTS_YAML = `# Rules and skills every bot gets, on top of its own.
|
|
47
|
+
#
|
|
48
|
+
# rules: rule units built into every bot's AGENTS.md, on top of the kit's own
|
|
49
|
+
# defaults, which every bot carries already. A bare name is a file in
|
|
50
|
+
# rules/ beside this one; kit:<name> is one of the kit's.
|
|
51
|
+
# skills: skills every bot gets, linked into both harnesses. kit:<name> is one
|
|
52
|
+
# of the kit's; <source>:<name> is one from an online source you listed
|
|
53
|
+
# in skills.yaml; a bare name is a directory in skills/ beside this one;
|
|
54
|
+
# anything with a / in it is a path to a skill directory on disk.
|
|
55
|
+
|
|
56
|
+
rules: []
|
|
57
|
+
skills: []
|
|
58
|
+
`;
|
|
59
|
+
|
|
60
|
+
const SKILLS_YAML = `# Online skill sources. The kit clones each one beside this folder, in
|
|
61
|
+
# <bots>.skill-sources/, at the ref you pin here, and records the sha it got.
|
|
62
|
+
#
|
|
63
|
+
# sources:
|
|
64
|
+
# - name: someones-skills
|
|
65
|
+
# repo: https://github.com/someone/skills
|
|
66
|
+
# path: skills # subfolder inside the repo, optional
|
|
67
|
+
# ref: v1.2.0 # branch, tag or sha
|
|
68
|
+
|
|
69
|
+
sources: []
|
|
70
|
+
`;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Where a problem with the kit itself is reported: the one address, as the
|
|
74
|
+
* package gives it, so Bot Father is told the same place npm and the README are.
|
|
75
|
+
*/
|
|
76
|
+
const ISSUES = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).bugs.url;
|
|
77
|
+
|
|
78
|
+
const botFatherYaml = (harness) => `# Bot Father runs the fleet. Ask it for changes rather than editing this file.
|
|
79
|
+
|
|
80
|
+
name: bot-father
|
|
81
|
+
harness: ${harness}
|
|
82
|
+
charter: |
|
|
83
|
+
Bot Father owns the fleet. It creates, changes, pauses and retires bots and
|
|
84
|
+
their sessions, and keeps each bot's rules and skills in order.
|
|
85
|
+
|
|
86
|
+
Good looks like: every bot has a charter its owner recognises, the skills it
|
|
87
|
+
needs and no more, and sessions that come back after a restart.
|
|
88
|
+
|
|
89
|
+
Ask first before: retiring a bot or a session, restarting anything, or
|
|
90
|
+
changing a bot's rules in a way its owner did not ask for.
|
|
91
|
+
|
|
92
|
+
When the kit itself goes wrong (an obk command fails in a way its own message
|
|
93
|
+
does not explain, or does something other than what it says), tell your owner
|
|
94
|
+
plainly, and with their go-ahead report it as an issue at ${ISSUES}. The
|
|
95
|
+
report is public, so ask before filing and take out anything private first:
|
|
96
|
+
names in paths, tokens, what a charter says. Put in the command that was run
|
|
97
|
+
and its whole output, what you expected instead, and what these print:
|
|
98
|
+
obk --version, obk health --bots <folder> --json, node --version, the Orca
|
|
99
|
+
version, and claude --version or codex --version; and the operating system.
|
|
100
|
+
One problem per issue. If you cannot file it yourself, give your owner the
|
|
101
|
+
text to paste.
|
|
102
|
+
rules: []
|
|
103
|
+
skills:
|
|
104
|
+
${MANAGEMENT_SKILLS.map((name) => ` - kit:${name}\n`).join('')}sessions:
|
|
105
|
+
# The management session: the tab you talk to Bot Father in.
|
|
106
|
+
- name: ${DAILY_SESSION}
|
|
107
|
+
`;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The kit's own skills for the job Bot Father has: writing a bot, looking over
|
|
111
|
+
* the fleet that exists, the daily pass over it, and what the work costs. The
|
|
112
|
+
* last two are here because the grooming run is told to use them by name, and a
|
|
113
|
+
* session woken to do a job with a skill it cannot load improvises instead,
|
|
114
|
+
* which is the thing the skill was written to stop. They go on its list when the kit writes its
|
|
115
|
+
* `bot.yaml`, because a fleet manager with no way to manage the fleet is not
|
|
116
|
+
* something the user can ask it to fix — asking it is the thing that does not
|
|
117
|
+
* work yet.
|
|
118
|
+
*
|
|
119
|
+
* Only then. A `bot.yaml` that is already there keeps whatever list it has: an
|
|
120
|
+
* older kit that never wrote these and an owner who took one off leave exactly
|
|
121
|
+
* the same file behind, and of the two readings, putting back what somebody
|
|
122
|
+
* removed is the worse one to be wrong about.
|
|
123
|
+
*/
|
|
124
|
+
const MANAGEMENT_SKILLS = ['obk-bot-building', 'obk-finops', 'obk-fleet-review', 'obk-grooming'];
|
|
125
|
+
|
|
126
|
+
const BOT_FATHER_YAML = 'bots/bot-father/bot.yaml';
|
|
127
|
+
|
|
128
|
+
/** The management session every Bot Father has: the tab you talk to it in. */
|
|
129
|
+
export const DAILY_SESSION = 'daily';
|
|
130
|
+
|
|
131
|
+
const seeds = (harness) => [
|
|
132
|
+
['.gitignore', GITIGNORE],
|
|
133
|
+
['defaults.yaml', DEFAULTS_YAML],
|
|
134
|
+
['skills.yaml', SKILLS_YAML],
|
|
135
|
+
['rules/.gitkeep', ''],
|
|
136
|
+
['skills/.gitkeep', ''],
|
|
137
|
+
[BOT_FATHER_YAML, botFatherYaml(harness)],
|
|
138
|
+
// Bot Father's work folder stays out of the repo like every bot's.
|
|
139
|
+
['bots/bot-father/.gitignore', BOT_GITIGNORE],
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
// The directories the layout implies, parents before children, taken from the
|
|
143
|
+
// seed list so the two cannot drift apart.
|
|
144
|
+
const LAYOUT_DIRS = (() => {
|
|
145
|
+
const dirs = new Set();
|
|
146
|
+
for (const [entry] of seeds('claude')) {
|
|
147
|
+
const parts = entry.split('/').slice(0, -1);
|
|
148
|
+
for (let depth = 1; depth <= parts.length; depth += 1) {
|
|
149
|
+
dirs.add(parts.slice(0, depth).join('/'));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return [...dirs];
|
|
153
|
+
})();
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Create or complete the bots folder at `target`.
|
|
157
|
+
* Returns { bots, created } — `created` lists what was missing, in order.
|
|
158
|
+
*
|
|
159
|
+
* Everything is checked before anything is written, so a folder `init` cannot
|
|
160
|
+
* make usable is left exactly as it was found rather than half seeded.
|
|
161
|
+
*/
|
|
162
|
+
export function initBots(target, harness) {
|
|
163
|
+
const bots = path.resolve(target);
|
|
164
|
+
const SEEDS = seeds(harness);
|
|
165
|
+
|
|
166
|
+
// The bots path is held to the same rule as everything inside it, so a link
|
|
167
|
+
// to a folder on another volume is the folder it points at.
|
|
168
|
+
checkKind(bots, 'folder');
|
|
169
|
+
|
|
170
|
+
const created = [];
|
|
171
|
+
mkdirSync(bots, { recursive: true });
|
|
172
|
+
|
|
173
|
+
for (const dir of LAYOUT_DIRS) checkKind(path.join(bots, dir), 'folder');
|
|
174
|
+
for (const [entry] of SEEDS) checkKind(path.join(bots, entry), 'file');
|
|
175
|
+
checkBotFather(path.join(bots, BOT_FATHER_YAML), harness);
|
|
176
|
+
|
|
177
|
+
if (!existsSync(path.join(bots, '.git'))) {
|
|
178
|
+
gitInit(bots);
|
|
179
|
+
created.push('.git');
|
|
180
|
+
}
|
|
181
|
+
checkRepo(bots);
|
|
182
|
+
|
|
183
|
+
for (const [entry, contents] of SEEDS) {
|
|
184
|
+
const file = path.join(bots, entry);
|
|
185
|
+
if (existsSync(file)) continue;
|
|
186
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
187
|
+
// 'wx' so a file that appeared between the check and the write is kept.
|
|
188
|
+
writeFileSync(file, contents, { flag: 'wx' });
|
|
189
|
+
created.push(entry);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// A bot.yaml from an earlier version of the kit is missing what this one
|
|
193
|
+
// needs. It is the user's file by now, so what is missing is filled in and
|
|
194
|
+
// nothing else is touched.
|
|
195
|
+
const completed = created.includes(BOT_FATHER_YAML) ? [] : completeBotFather(path.join(bots, BOT_FATHER_YAML), harness);
|
|
196
|
+
|
|
197
|
+
return { bots, created, completed };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// What is already at `target` must be the kind of thing `init` needs there, or
|
|
201
|
+
// `init` refuses: it never moves the user's data aside to make room.
|
|
202
|
+
// A symlink counts by what it points at.
|
|
203
|
+
function checkKind(target, kind) {
|
|
204
|
+
if (!follow(target, () => lstatSync(target, { throwIfNoEntry: false }))) return;
|
|
205
|
+
|
|
206
|
+
const resolved = follow(target, () => statSync(target, { throwIfNoEntry: false }));
|
|
207
|
+
if (!resolved) {
|
|
208
|
+
throw new Error(`${target} is a symlink that points nowhere. Remove it, then run init again.`);
|
|
209
|
+
}
|
|
210
|
+
if (kind === 'folder' ? !resolved.isDirectory() : !resolved.isFile()) {
|
|
211
|
+
throw new Error(`${target} is in the way: init needs a ${kind} there. Move it aside, then run init again.`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Look at `target`, and say plainly when the look cannot be made at all because
|
|
216
|
+
// the links there run in a circle. Node reports that as ELOOP and the name of a
|
|
217
|
+
// call the user never made; what they need is which path is the loop and that
|
|
218
|
+
// nobody but them can undo it.
|
|
219
|
+
function follow(target, look) {
|
|
220
|
+
try {
|
|
221
|
+
return look();
|
|
222
|
+
} catch (error) {
|
|
223
|
+
if (error.code !== 'ELOOP') throw error;
|
|
224
|
+
throw new Error(`${target} is a symlink loop: following it comes back to where it started. Fix the links, then run init again.`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// An existing `.git` is not proof of a repository, and a repository found by
|
|
229
|
+
// walking up to a parent is not this folder's own.
|
|
230
|
+
function checkRepo(bots) {
|
|
231
|
+
const top = spawnSync('git', ['-C', bots, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' });
|
|
232
|
+
// Only git's record terminator comes off: trailing whitespace may be part of the folder's name.
|
|
233
|
+
const toplevel = (top.stdout ?? '').replace(/\n$/, '');
|
|
234
|
+
if (top.status !== 0 || toplevel === '' || realpathSync(toplevel) !== realpathSync(bots)) {
|
|
235
|
+
throw new Error(`${bots} has a .git entry but is not a git repository of its own. Remove or repair it, then run init again.`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// git's own output is captured, not inherited: the CLI decides what the user reads.
|
|
240
|
+
function gitInit(bots) {
|
|
241
|
+
const result = spawnSync('git', ['init', '--quiet', bots], { encoding: 'utf8' });
|
|
242
|
+
if (result.error) throw new Error(`could not run git: ${result.error.message}`);
|
|
243
|
+
if (result.status !== 0) {
|
|
244
|
+
throw new Error(`git init failed in ${bots}: ${(result.stderr || '').trim()}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// A bot.yaml that is already there has to be one `init` can finish. What it
|
|
249
|
+
// cannot finish, it refuses before anything is written, rather than guess at
|
|
250
|
+
// what the user meant.
|
|
251
|
+
function checkBotFather(file, harness) {
|
|
252
|
+
if (!existsSync(file)) return;
|
|
253
|
+
|
|
254
|
+
const doc = parseDocument(readFileSync(file, 'utf8'));
|
|
255
|
+
const bot = doc.errors.length > 0 ? undefined : doc.toJS();
|
|
256
|
+
if (bot === null || typeof bot !== 'object' || Array.isArray(bot)) {
|
|
257
|
+
throw new Error(`${file} is not a bot: it should be a YAML mapping with a name, a harness and a list of sessions. Fix it or move it aside, then run init again.`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// The harness is the user's, written once and never rewritten: a second init
|
|
261
|
+
// naming the other one is a mistake worth stopping, not a quiet change.
|
|
262
|
+
if (bot.harness !== undefined && bot.harness !== harness) {
|
|
263
|
+
throw new Error(`you asked for --harness ${harness}, but Bot Father is already on ${bot.harness}, and init does not change it. Run init again with --harness ${bot.harness}, or ask Bot Father to move it.`);
|
|
264
|
+
}
|
|
265
|
+
if (bot.sessions !== undefined && bot.sessions !== null && !Array.isArray(bot.sessions)) {
|
|
266
|
+
throw new Error(`${file} has a sessions entry that is not a list, so init cannot add Bot Father's daily session to it. Fix it, then run init again.`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Fill in what an older `bot.yaml` is missing, and nothing else. Returns what
|
|
272
|
+
* was completed, for the report.
|
|
273
|
+
*
|
|
274
|
+
* The file is edited through the YAML library, which keeps the user's comments
|
|
275
|
+
* and their own values; what it reformats on the way is its business. What is
|
|
276
|
+
* checked here is the thing that matters: that the file says what it said
|
|
277
|
+
* before, plus the harness and the session this fills in.
|
|
278
|
+
*
|
|
279
|
+
* Sessions the user already has are left alone, whatever they are called. They
|
|
280
|
+
* are theirs, and `up` brings up what it finds.
|
|
281
|
+
*/
|
|
282
|
+
function completeBotFather(file, harness) {
|
|
283
|
+
const source = readFileSync(file, 'utf8');
|
|
284
|
+
const doc = parseDocument(source);
|
|
285
|
+
|
|
286
|
+
// What is missing is settled from the document, not from the text it is
|
|
287
|
+
// written back as: a file that needs nothing is not rewritten, however the
|
|
288
|
+
// library would have laid it out.
|
|
289
|
+
const sessions = doc.get('sessions', true);
|
|
290
|
+
const needsSession = sessions?.items === undefined || sessions.items.length === 0;
|
|
291
|
+
const needsHarness = doc.get('harness') === undefined;
|
|
292
|
+
if (!needsSession && !needsHarness) return [];
|
|
293
|
+
|
|
294
|
+
if (sessions?.items === undefined) {
|
|
295
|
+
doc.set('sessions', [{ name: DAILY_SESSION }]);
|
|
296
|
+
} else if (needsSession) {
|
|
297
|
+
sessions.flow = false;
|
|
298
|
+
doc.addIn(['sessions'], { name: DAILY_SESSION });
|
|
299
|
+
}
|
|
300
|
+
if (needsHarness) doc.set('harness', harness);
|
|
301
|
+
|
|
302
|
+
const text = doc.toString(YAML_OUT);
|
|
303
|
+
|
|
304
|
+
if (!changesExactly(source, text, (was) => ({
|
|
305
|
+
...was,
|
|
306
|
+
harness: was.harness ?? harness,
|
|
307
|
+
sessions: was.sessions?.length > 0 ? was.sessions : [{ name: DAILY_SESSION }],
|
|
308
|
+
}))) {
|
|
309
|
+
throw new Error(`${file} cannot be completed without changing something else in it, so nothing was written. Give it a harness and a session by hand, then run init again.`);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
writeFileSync(file, text);
|
|
313
|
+
return [BOT_FATHER_YAML];
|
|
314
|
+
}
|