@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/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
+ }