@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
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// What the harness itself knows, for when the book's own record is incomplete.
|
|
2
|
+
//
|
|
3
|
+
// The kit's hook is the way a session's id reaches the book, and it can miss:
|
|
4
|
+
// on Codex a hooks file must be trusted before any hook runs, and trusting it
|
|
5
|
+
// does not replay the event it missed (tech notes, section 3). A conversation
|
|
6
|
+
// the kit never recorded must not be mistaken for a session that never had one,
|
|
7
|
+
// so when the book cannot say, the harness is asked instead.
|
|
8
|
+
//
|
|
9
|
+
// Both harnesses keep their own record of every conversation, per working
|
|
10
|
+
// directory, and every session starts at its bot home. Read only, never
|
|
11
|
+
// written: these are the harness's files (tech notes, sections 2 and 3).
|
|
12
|
+
|
|
13
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
|
|
17
|
+
/** Claude Code's transcripts: one folder per working directory, one file per session. */
|
|
18
|
+
const claudeDir = (home) => path.join(homedir(), '.claude', 'projects', slug(home));
|
|
19
|
+
|
|
20
|
+
/** The folder's name is the working directory with everything else turned into a dash. */
|
|
21
|
+
const slug = (home) => home.replaceAll(/[^A-Za-z0-9]/g, '-');
|
|
22
|
+
|
|
23
|
+
/** Codex's rollouts: a file per conversation, filed by the day it started. */
|
|
24
|
+
const codexDir = () => path.join(homedir(), '.codex', 'sessions');
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Every conversation the harness has on record for this bot home, oldest first,
|
|
28
|
+
* and when each began. `since` leaves out anything older than the moment the kit
|
|
29
|
+
* started a harness in the tab: what came before belongs to an earlier run.
|
|
30
|
+
*
|
|
31
|
+
* An unreadable record says nothing rather than throwing: the caller's answer is
|
|
32
|
+
* then that it does not know, which is the honest one.
|
|
33
|
+
*/
|
|
34
|
+
export function conversationsIn(harness, home, since) {
|
|
35
|
+
return transcriptsIn(harness, home, since).map((one) => ({ id: one.id, at: one.at }));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The same conversations, each with the file the harness keeps it in, for a
|
|
40
|
+
* caller that has to read what is inside one rather than only know it is there.
|
|
41
|
+
*/
|
|
42
|
+
export function transcriptsIn(harness, home, since) {
|
|
43
|
+
const from = since === undefined ? 0 : Date.parse(since);
|
|
44
|
+
const found = harness === 'claude' ? claudeConversations(home) : codexConversations(home, from);
|
|
45
|
+
return found
|
|
46
|
+
.filter((one) => Number.isNaN(from) || one.at >= from)
|
|
47
|
+
.sort((left, right) => left.at - right.at)
|
|
48
|
+
.map((one) => ({ id: one.id, at: new Date(one.at).toISOString(), file: one.file }));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function claudeConversations(home) {
|
|
52
|
+
return files(claudeDir(home))
|
|
53
|
+
.filter((name) => name.endsWith('.jsonl'))
|
|
54
|
+
.flatMap((name) => {
|
|
55
|
+
const file = path.join(claudeDir(home), name);
|
|
56
|
+
// What the transcript says about itself first, and the file's own age only
|
|
57
|
+
// where it says nothing: a line of its own is the harness talking, and a
|
|
58
|
+
// file's times can be set by anything that touches it.
|
|
59
|
+
const at = said(file) ?? startedAt(file);
|
|
60
|
+
return at === undefined ? [] : [{ id: name.slice(0, -'.jsonl'.length), at, file }];
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** When a transcript's first line says the conversation was, if it says at all. */
|
|
65
|
+
function said(file) {
|
|
66
|
+
try {
|
|
67
|
+
const first = readFileSync(file, 'utf8').split('\n', 1)[0];
|
|
68
|
+
const when = Date.parse(JSON.parse(first)?.timestamp ?? '');
|
|
69
|
+
return Number.isNaN(when) ? undefined : when;
|
|
70
|
+
} catch {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Codex files its rollouts by day, and each one says which folder it was in.
|
|
77
|
+
* The folders are walked rather than guessed at: a conversation from today may
|
|
78
|
+
* sit under yesterday's date in another time zone.
|
|
79
|
+
*/
|
|
80
|
+
function codexConversations(home, from) {
|
|
81
|
+
return rollouts(codexDir())
|
|
82
|
+
// A rollout is written while its conversation runs, so one last touched
|
|
83
|
+
// before the kit started this harness cannot be its conversation. Asked of
|
|
84
|
+
// the file system, which is cheap, before the file is opened, which is not:
|
|
85
|
+
// a machine with years of conversations answers this in one walk.
|
|
86
|
+
.filter((file) => Number.isNaN(from) || (startedAt(file) ?? 0) >= from || lastTouched(file) >= from)
|
|
87
|
+
.flatMap((file) => {
|
|
88
|
+
const meta = sessionMeta(file);
|
|
89
|
+
return meta?.cwd === home && typeof meta.id === 'string'
|
|
90
|
+
? [{ id: meta.id, at: Date.parse(meta.timestamp ?? '') || startedAt(file) || 0, file }]
|
|
91
|
+
: [];
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Every rollout file under Codex's sessions folder, however deep it files them. */
|
|
96
|
+
function rollouts(dir, depth = 0) {
|
|
97
|
+
if (depth > 4) return [];
|
|
98
|
+
return files(dir).flatMap((name) => {
|
|
99
|
+
const full = path.join(dir, name);
|
|
100
|
+
if (name.endsWith('.jsonl')) return [full];
|
|
101
|
+
return isDir(full) ? rollouts(full, depth + 1) : [];
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A rollout's first line says what the conversation is: its id, folder and time. */
|
|
106
|
+
function sessionMeta(file) {
|
|
107
|
+
try {
|
|
108
|
+
const first = readFileSync(file, 'utf8').split('\n', 1)[0];
|
|
109
|
+
const parsed = JSON.parse(first);
|
|
110
|
+
return parsed?.type === 'session_meta' ? parsed.payload : undefined;
|
|
111
|
+
} catch {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** When a file was last written, for leaving out what is plainly too old. */
|
|
117
|
+
function lastTouched(file) {
|
|
118
|
+
try {
|
|
119
|
+
return statSync(file).mtimeMs;
|
|
120
|
+
} catch {
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** When a conversation's own file came into being, as the file system has it. */
|
|
126
|
+
function startedAt(file) {
|
|
127
|
+
try {
|
|
128
|
+
const stat = statSync(file);
|
|
129
|
+
return stat.birthtimeMs > 0 ? stat.birthtimeMs : stat.mtimeMs;
|
|
130
|
+
} catch {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function files(dir) {
|
|
136
|
+
try {
|
|
137
|
+
return readdirSync(dir);
|
|
138
|
+
} catch {
|
|
139
|
+
// No folder means no conversations on record, which is an answer.
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const isDir = (full) => {
|
|
145
|
+
try {
|
|
146
|
+
return statSync(full).isDirectory();
|
|
147
|
+
} catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
};
|
package/src/groom.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// The daily grooming run (PRD 6.8): an optional pass that reads what the bots
|
|
2
|
+
// have been doing, works out what it cost, and tells Bot Father.
|
|
3
|
+
//
|
|
4
|
+
// It is an Orca automation rather than a tab in the book, and that is forced by
|
|
5
|
+
// how Orca works rather than chosen: an automation can only reuse a session it
|
|
6
|
+
// started itself, so it cannot be pointed at a tab the kit made (tech notes,
|
|
7
|
+
// section 1). So grooming is the automation's own session, and whatever it has
|
|
8
|
+
// to remember between runs lives in files, which is what the profile notes are.
|
|
9
|
+
//
|
|
10
|
+
// It is created off. It spends tokens every day, and a routine nobody has
|
|
11
|
+
// watched should not be running unwatched, so it waits for one explicit yes.
|
|
12
|
+
//
|
|
13
|
+
// The one thing this must get right is that there is never more than one.
|
|
14
|
+
// Orca does not deduplicate an automation by name: asked twice, it makes two,
|
|
15
|
+
// and a user with two of these is groomed twice a day for ever. So the kit
|
|
16
|
+
// looks for its own before it creates anything, and the looking is by both the
|
|
17
|
+
// name it gives its own and the folder it attached it to, because a user's own
|
|
18
|
+
// automation can live in the same project and must be left alone.
|
|
19
|
+
|
|
20
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
|
|
23
|
+
import { botDir, readBot, requireBotsFolder } from './bot.js';
|
|
24
|
+
import { readBook, takeLock } from './book.js';
|
|
25
|
+
import { ownCli, shellWord } from './launch.js';
|
|
26
|
+
import { orca } from './orca.js';
|
|
27
|
+
import { BOT_FATHER } from './up.js';
|
|
28
|
+
|
|
29
|
+
/** What the kit calls its own grooming automation. */
|
|
30
|
+
const NAME = 'obk grooming';
|
|
31
|
+
|
|
32
|
+
/** A time of day, as a person writes one. */
|
|
33
|
+
const AT = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
|
34
|
+
|
|
35
|
+
/** What the automation's session is told to do when it wakes. */
|
|
36
|
+
const prompt = (bots) =>
|
|
37
|
+
'Run the daily grooming for this fleet, with the obk-grooming skill. Fix this '
|
|
38
|
+
+ 'run\'s end now and read what has happened since the last run\'s end up to it, '
|
|
39
|
+
+ `counting with ${shellWord(ownCli())} usage --since <last end> --until <this end>, and write this `
|
|
40
|
+
+ 'end down for the next run. Work out what it cost with obk-finops, keep '
|
|
41
|
+
+ 'each bot\'s profile notes, and send one short report to Bot Father\'s '
|
|
42
|
+
+ `management session. The kit here is ${shellWord(ownCli())}: run it wherever a skill says obk. `
|
|
43
|
+
+ `The bots folder is ${bots}.`;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Say what the grooming is, and make it so when asked.
|
|
47
|
+
* Returns `{ exists }`, and `{ exists, enabled, at, id }` once there is one.
|
|
48
|
+
*
|
|
49
|
+
* Reading and setting are one command because they are one question: a caller
|
|
50
|
+
* that has just moved the time wants to be told what it is now, and what it is
|
|
51
|
+
* now is whatever Orca says rather than whatever the kit last asked for.
|
|
52
|
+
*/
|
|
53
|
+
export function grooming(bots, { at, on } = {}) {
|
|
54
|
+
requireBotsFolder(bots);
|
|
55
|
+
|
|
56
|
+
// The folder as the file system knows it, the way `restart` and `message`
|
|
57
|
+
// take it: Orca records an automation's workspace by its real path, so a
|
|
58
|
+
// spelling that is not the canonical one finds nothing and offers to make a
|
|
59
|
+
// second of what is already there.
|
|
60
|
+
const home = realHome(botDir(bots, BOT_FATHER));
|
|
61
|
+
if (!existsSync(path.join(home, 'bot.yaml'))) {
|
|
62
|
+
throw new Error(`there is no ${BOT_FATHER} in ${bots}, and the grooming runs in its Orca project. Run obk init first.`);
|
|
63
|
+
}
|
|
64
|
+
if (at !== undefined && !AT.test(at)) {
|
|
65
|
+
throw new Error(`--at is a time of day as 24 hours, such as 04:00, and got: ${at}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const bot = readBot(home, BOT_FATHER);
|
|
69
|
+
const book = readBook(home);
|
|
70
|
+
if (book.orca.project === undefined) {
|
|
71
|
+
throw new Error(`${BOT_FATHER} has no Orca project yet, so there is nothing for the grooming to run in. Run ${shellWord(ownCli())} up --bots ${shellWord(bots)} --bot ${BOT_FATHER} first.`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Looking and making are one turn. Orca will not stop two runs creating two
|
|
75
|
+
// automations for one fleet, and two of these means being groomed twice a day
|
|
76
|
+
// for ever, so the lock the kit already uses for a bot's own file is held
|
|
77
|
+
// across the pair (src/book.js).
|
|
78
|
+
const lock = takeLock(home);
|
|
79
|
+
try {
|
|
80
|
+
return settle(bots, home, bot, { at, on });
|
|
81
|
+
} finally {
|
|
82
|
+
lock.release();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The looking and the making, with the turn already taken. */
|
|
87
|
+
function settle(bots, home, bot, { at, on }) {
|
|
88
|
+
let mine = ours(home);
|
|
89
|
+
|
|
90
|
+
if (mine === undefined && at !== undefined) {
|
|
91
|
+
orca([
|
|
92
|
+
'automations', 'create',
|
|
93
|
+
'--name', NAME,
|
|
94
|
+
'--provider', bot.harness,
|
|
95
|
+
'--trigger', 'daily',
|
|
96
|
+
'--time', at,
|
|
97
|
+
'--prompt', prompt(bots),
|
|
98
|
+
'--workspace', `path:${home}`,
|
|
99
|
+
'--workspace-mode', 'existing',
|
|
100
|
+
// Off, whatever else was asked for: turning it on is a separate decision
|
|
101
|
+
// and is made below, so that the one path into "it runs daily" is the
|
|
102
|
+
// same whether it was made just now or a month ago.
|
|
103
|
+
'--disabled',
|
|
104
|
+
]);
|
|
105
|
+
mine = ours(home);
|
|
106
|
+
} else if (mine !== undefined && at !== undefined && at !== timeOf(mine)) {
|
|
107
|
+
// Orca refuses a time on its own, so the trigger goes with it every time.
|
|
108
|
+
orca(['automations', 'edit', '--id', mine.id, '--trigger', 'daily', '--time', at]);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (on !== undefined && mine !== undefined) {
|
|
112
|
+
orca(['automations', 'edit', '--id', mine.id, on ? '--enabled' : '--disabled']);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Asked again rather than assumed: the user can move it or switch it in
|
|
116
|
+
// Orca's own interface, and what this prints has to be what is there.
|
|
117
|
+
const now = ours(home);
|
|
118
|
+
return now === undefined
|
|
119
|
+
? { exists: false }
|
|
120
|
+
: { exists: true, enabled: now.enabled === true, at: timeOf(now), id: now.id };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The kit's own grooming automation for this Bot Father, if there is one.
|
|
125
|
+
*
|
|
126
|
+
* Both halves are needed. The folder alone would adopt whatever the user set up
|
|
127
|
+
* in the same project, and the name alone would collide across two bots folders
|
|
128
|
+
* on one machine, which each have a Bot Father of their own.
|
|
129
|
+
*/
|
|
130
|
+
function ours(home) {
|
|
131
|
+
const listed = orca(['automations', 'list']).automations ?? [];
|
|
132
|
+
const found = listed.filter((one) => one?.name === NAME && one?.runContext?.path === home);
|
|
133
|
+
|
|
134
|
+
// More than one is not a thing to pick from. Whichever were chosen, the other
|
|
135
|
+
// would go on running unseen, and this command is where the user would have
|
|
136
|
+
// looked for it. So it says what it found and leaves both alone.
|
|
137
|
+
if (found.length > 1) {
|
|
138
|
+
throw new Error(`${home} has ${found.length} groomings rather than one: ${found.map((one) => one.id).join(', ')}. Remove the ones you do not want in Orca, then run this again.`);
|
|
139
|
+
}
|
|
140
|
+
return found[0];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** A bot home as the file system knows it, or as it was given when it is not there. */
|
|
144
|
+
function realHome(home) {
|
|
145
|
+
try {
|
|
146
|
+
return realpathSync(home);
|
|
147
|
+
} catch {
|
|
148
|
+
return home;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** When an automation runs, read out of Orca's own recurrence rule. */
|
|
153
|
+
function timeOf(automation) {
|
|
154
|
+
const rule = String(automation?.rrule ?? '');
|
|
155
|
+
const hour = /BYHOUR=(\d+)/.exec(rule)?.[1];
|
|
156
|
+
const minute = /BYMINUTE=(\d+)/.exec(rule)?.[1];
|
|
157
|
+
if (hour === undefined || minute === undefined) return undefined;
|
|
158
|
+
return `${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`;
|
|
159
|
+
}
|
package/src/health.js
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// `obk health`: what is wrong with the setup, said in facts.
|
|
2
|
+
//
|
|
3
|
+
// PRD 4.12 names what it has to find — configuration that will not work, a
|
|
4
|
+
// skill that is not where its list says, a session the book knows that Orca
|
|
5
|
+
// does not, and leftovers no book owns — and PRD 6.5 adds Orca's own default
|
|
6
|
+
// launch arguments on top of them.
|
|
7
|
+
//
|
|
8
|
+
// Two rules hold it together.
|
|
9
|
+
//
|
|
10
|
+
// It reports and never repairs. The kit's code reports facts; judging them and
|
|
11
|
+
// proposing a fix is the skill's job (PRD 6.8). So nothing here writes: no
|
|
12
|
+
// AGENTS.md built, no skill linked, no book touched, and of Orca it asks only
|
|
13
|
+
// the things that tell it something. Every check is the read-only half of a
|
|
14
|
+
// command that does the writing, and lives beside that command rather than
|
|
15
|
+
// here, so the two cannot drift apart.
|
|
16
|
+
//
|
|
17
|
+
// And a finding has to be worth reading. Each one says which kind of trouble it
|
|
18
|
+
// is, the one thing to go and look at, and a sentence naming it — so that
|
|
19
|
+
// somebody who cannot read code knows what was found and where.
|
|
20
|
+
|
|
21
|
+
import { existsSync, readdirSync, realpathSync } from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
|
|
24
|
+
import { bookFile, readBook, tabIdsIn } from './book.js';
|
|
25
|
+
import { botDir, botNames, botsDir, readBot } from './bot.js';
|
|
26
|
+
import { transcriptsIn } from './conversations.js';
|
|
27
|
+
import { hookTrouble } from './hooks.js';
|
|
28
|
+
import { bypassFlags, harnessOf, HARNESSES, sessionTrouble } from './launch.js';
|
|
29
|
+
import { orcaDefaultArgs, projects, tabs } from './orca.js';
|
|
30
|
+
import { agentsTrouble } from './rules.js';
|
|
31
|
+
import { skillsTrouble } from './skills.js';
|
|
32
|
+
import { readSources, sourcesDir } from './sources.js';
|
|
33
|
+
import { BOT_FATHER, botsNamed, promptPath } from './up.js';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Everything wrong with the setup at `bots`, in one list: what Orca's own
|
|
37
|
+
* settings do to every session, what is lying about that no book owns, and then
|
|
38
|
+
* each bot in name order. `bot` narrows the per-bot checks to one; what the
|
|
39
|
+
* whole fleet lives under is reported whichever bot was named.
|
|
40
|
+
*/
|
|
41
|
+
export function checkHealth(bots, { bot: onlyBot } = {}) {
|
|
42
|
+
const names = botsNamed(bots, onlyBot);
|
|
43
|
+
const setups = projects();
|
|
44
|
+
|
|
45
|
+
const found = [...orcaSettingFindings(), ...leftInOrca(bots, setups), ...leftBeside(bots)];
|
|
46
|
+
for (const name of names) found.push(...aboutBot(bots, name, setups));
|
|
47
|
+
return found;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One finding, as the report and the `--json` answer carry it. */
|
|
51
|
+
const finding = (kind, where, says, bot) => ({ kind, ...(bot === undefined ? {} : { bot }), where, says });
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* What Orca adds to an agent of its own accord, when it is a permission bypass.
|
|
55
|
+
*
|
|
56
|
+
* Every session Orca relaunches or resumes takes these, whatever the kit asked
|
|
57
|
+
* for, so this is said every time it is true (PRD 6.5) — and a setting the kit
|
|
58
|
+
* could not read is said too, because silence here would be read as a clean
|
|
59
|
+
* bill of health.
|
|
60
|
+
*
|
|
61
|
+
* Exported because the PRD asks for it in two places: here, and in the setup
|
|
62
|
+
* that makes a fleet, which is where the user is still standing when they can
|
|
63
|
+
* put it right.
|
|
64
|
+
*/
|
|
65
|
+
export function orcaSettingFindings() {
|
|
66
|
+
const { dir, profiles } = orcaDefaultArgs();
|
|
67
|
+
if (profiles.length === 0) return [unreadableSettings(dir)];
|
|
68
|
+
|
|
69
|
+
return profiles.flatMap(({ file, args }) => {
|
|
70
|
+
if (args === undefined) return [unreadableSettings(file)];
|
|
71
|
+
|
|
72
|
+
return HARNESSES.flatMap((harness) => {
|
|
73
|
+
const flags = bypassFlags(harness);
|
|
74
|
+
// A missing entry is Orca's own default, and Orca's own default is the
|
|
75
|
+
// bypass (tech notes, section 1), so it is read as one.
|
|
76
|
+
const recorded = typeof args[harness] === 'string' ? args[harness] : undefined;
|
|
77
|
+
const effective = recorded ?? flags.join(' ');
|
|
78
|
+
const bypass = flags.filter((flag) => carries(effective, flag));
|
|
79
|
+
if (bypass.length === 0) return [];
|
|
80
|
+
|
|
81
|
+
const how = recorded === undefined
|
|
82
|
+
? `${file} records no default launch arguments for ${harness}, and Orca's own default for it is ${bypass.join(' ')}.`
|
|
83
|
+
: `${file} gives ${harness} the default launch arguments ${recorded.trim()}.`;
|
|
84
|
+
return [finding('orca', file, `${how} Every ${harness} session Orca launches, relaunches or resumes runs with a permission bypass, whatever approval level the kit asked for, so a session meant to ask first will not. Change it to something safer in Orca's settings; the kit does not touch that setting itself.`)];
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const unreadableSettings = (where) => finding(
|
|
90
|
+
'orca',
|
|
91
|
+
where,
|
|
92
|
+
`The kit could not read Orca's own settings at ${where}, so it cannot say whether Orca adds default launch arguments of its own. When those carry a permission bypass, every session Orca launches, relaunches or resumes runs in that mode whatever approval level the kit asked for.`,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
/** Whether a line of arguments carries `flag` as a word of its own. */
|
|
96
|
+
const carries = (args, flag) =>
|
|
97
|
+
new RegExp(`(^|\\s)${flag.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\s|$)`).test(args);
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Orca projects inside the bots folder that are no longer a bot: the bot was
|
|
101
|
+
* moved or deleted and its Orca project stayed behind. Anything outside the
|
|
102
|
+
* bots folder is the user's own work and is not looked at.
|
|
103
|
+
*/
|
|
104
|
+
function leftInOrca(bots, setups) {
|
|
105
|
+
const dir = botsDir(bots);
|
|
106
|
+
const dirs = [...new Set([dir, realpathOf(dir)].filter((one) => one !== undefined))];
|
|
107
|
+
|
|
108
|
+
return setups.flatMap((setup) => {
|
|
109
|
+
const under = dirs.find((one) => setup.path.startsWith(`${one}${path.sep}`));
|
|
110
|
+
if (under === undefined) return [];
|
|
111
|
+
|
|
112
|
+
const name = path.relative(under, setup.path);
|
|
113
|
+
if (!name.includes(path.sep) && existsSync(path.join(dir, name, 'bot.yaml'))) return [];
|
|
114
|
+
|
|
115
|
+
return [finding('leftover', setup.path, `Orca has a project for ${setup.path}, which is inside your bots folder, and there is no bot there. Its Orca project is ${setup.id}. Close its tabs before the project goes: a project taken away before its tabs leaves tabs that nothing can close from the command line.`)];
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* What the kit keeps beside the bots folder that nothing answers to any more:
|
|
121
|
+
* a start prompt written for a session that has gone, and a clone of a skills
|
|
122
|
+
* source `skills.yaml` no longer lists.
|
|
123
|
+
*/
|
|
124
|
+
function leftBeside(bots) {
|
|
125
|
+
const found = [];
|
|
126
|
+
|
|
127
|
+
// Every start-prompt file the sessions of every bot would be given. A bot the
|
|
128
|
+
// kit cannot read has sessions it cannot name, and a file called a leftover
|
|
129
|
+
// on that footing might be a live session's duty, so the check waits until
|
|
130
|
+
// the bot.yaml beside it is settled.
|
|
131
|
+
const owned = new Set();
|
|
132
|
+
let known = true;
|
|
133
|
+
for (const name of botNames(bots)) {
|
|
134
|
+
try {
|
|
135
|
+
for (const session of readBot(botDir(bots, name), name).sessions) {
|
|
136
|
+
owned.add(promptPath(bots, name, session.name));
|
|
137
|
+
}
|
|
138
|
+
} catch {
|
|
139
|
+
known = false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (known) {
|
|
144
|
+
for (const file of namesIn(`${bots}.prompts`)) {
|
|
145
|
+
const at = path.join(`${bots}.prompts`, file);
|
|
146
|
+
if (owned.has(at)) continue;
|
|
147
|
+
found.push(finding('leftover', at, `${at} is a start prompt the kit wrote beside your bots folder, and no session of any bot answers to it now. Nothing reads it.`));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const file = path.join(bots, 'skills.yaml');
|
|
152
|
+
let listed;
|
|
153
|
+
try {
|
|
154
|
+
listed = new Set(readSources(file).map((source) => source.name));
|
|
155
|
+
} catch (error) {
|
|
156
|
+
return [...found, finding('config', file, error.message)];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
for (const name of namesIn(sourcesDir(bots))) {
|
|
160
|
+
if (listed.has(name)) continue;
|
|
161
|
+
const at = path.join(sourcesDir(bots), name);
|
|
162
|
+
found.push(finding('leftover', at, `${at} is a clone of a skills source, and ${file} lists no source called ${name}. Nothing links skills out of it now.`));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return found;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Everything wrong with one bot. */
|
|
169
|
+
function aboutBot(bots, name, setups) {
|
|
170
|
+
const home = botDir(bots, name);
|
|
171
|
+
|
|
172
|
+
let bot;
|
|
173
|
+
try {
|
|
174
|
+
bot = readBot(home, name);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
// Nothing else about this bot can be asked: what it carries, what its
|
|
177
|
+
// sessions are and which harness they run on are all in the file that
|
|
178
|
+
// cannot be read. The other bots are reported as usual.
|
|
179
|
+
return [finding('config', path.join(home, 'bot.yaml'), error.message, name)];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const said = (kind) => ({ where, says }) => finding(kind, where, says, name);
|
|
183
|
+
return [
|
|
184
|
+
...sessionSettings(home, bot).map(said('config')),
|
|
185
|
+
...agentsTrouble(bots, home, bot).map(said('config')),
|
|
186
|
+
...hooksOf(bots, home, bot).map(said('config')),
|
|
187
|
+
...skillsTrouble(bots, home, bot).map(said('skill')),
|
|
188
|
+
...inOrca(home, bot, setups),
|
|
189
|
+
];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** A session the kit would refuse to start, in the words `up` refuses it with. */
|
|
193
|
+
function sessionSettings(home, bot) {
|
|
194
|
+
const file = path.join(home, 'bot.yaml');
|
|
195
|
+
return bot.sessions.flatMap((session) => {
|
|
196
|
+
const trouble = sessionTrouble(session, harnessOf(session, bot.harness), home);
|
|
197
|
+
return trouble === undefined
|
|
198
|
+
? []
|
|
199
|
+
: [{ where: file, says: `${file}: ${trouble} obk up will not start ${bot.name} until that is settled.` }];
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The kit's own hook, in the file of every harness this bot's sessions run on. */
|
|
204
|
+
const hooksOf = (bots, home, bot) => [...new Set(bot.sessions.map((session) => harnessOf(session, bot.harness)))]
|
|
205
|
+
.filter((harness) => HARNESSES.includes(harness))
|
|
206
|
+
.map((harness) => hookTrouble(home, harness, { bots, bot: bot.name }))
|
|
207
|
+
.filter((trouble) => trouble !== undefined);
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* What Orca has of this bot against what the book says it should have: a
|
|
211
|
+
* session whose tab is gone, a conversation nobody claims, and a tab in the
|
|
212
|
+
* bot's project that the book does not name.
|
|
213
|
+
*/
|
|
214
|
+
function inOrca(home, bot, setups) {
|
|
215
|
+
// The book is read where it is read, and a book nothing can parse is a
|
|
216
|
+
// finding like any other: it says which tab each session is in and which
|
|
217
|
+
// conversation it is running, so without it nothing else here can be asked —
|
|
218
|
+
// but the findings already made, and the bots still to come, are none of its
|
|
219
|
+
// business.
|
|
220
|
+
let book;
|
|
221
|
+
try {
|
|
222
|
+
book = readBook(home);
|
|
223
|
+
} catch (error) {
|
|
224
|
+
return [finding('config', bookFile(home), `${bookFile(home)} cannot be read (${error.message}), and it is where the kit keeps this bot's sessions: which Orca tab each one is in and which conversation it is running. Until it is readable the kit cannot say either. Fix it, or move it aside and let obk up write a new one.`, bot.name)];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const real = realpathOf(home);
|
|
228
|
+
const project = setups.find((setup) => setup.path === real || setup.path === home);
|
|
229
|
+
// Orca refuses to list the tabs of a folder it has no project for, rather
|
|
230
|
+
// than answering with none (tech notes, section 1), so it is asked only when
|
|
231
|
+
// there is one.
|
|
232
|
+
const live = project === undefined ? [] : tabs(project.path);
|
|
233
|
+
const there = new Set(live.map((tab) => tab.tabId));
|
|
234
|
+
|
|
235
|
+
// A paused bot or session was closed on purpose, and its tab being gone is
|
|
236
|
+
// what paused means rather than something lost.
|
|
237
|
+
const closed = new Set(bot.sessions.filter((session) => bot.paused === true || session.paused === true).map((session) => session.name));
|
|
238
|
+
|
|
239
|
+
const found = [];
|
|
240
|
+
for (const [name, entry] of Object.entries(book.sessions)) {
|
|
241
|
+
if (typeof entry?.tab === 'string' && !there.has(entry.tab) && !closed.has(name)) {
|
|
242
|
+
found.push(finding('session', entry.tab, `${bot.name}'s session ${name} is in the book with tab ${entry.tab}, and Orca has no tab of that id: the tab was closed, or the machine was restarted. The conversation is in the book, and obk up opens a tab and brings it back.`, bot.name));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const unclaimed = Array.isArray(entry?.unclaimed) ? entry.unclaimed : [];
|
|
246
|
+
if (unclaimed.length > 0) {
|
|
247
|
+
found.push(finding('leftover', bookFile(home), `The book notes conversations under ${bot.name}'s session ${name} that no session of this bot claims: ${unclaimed.join(', ')}. They ran in this bot's folder, and the kit will not say whose they were. To give one back, write it into ${bookFile(home)} under ${name} as session: <id> and run obk up again.`, bot.name));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
found.push(...offTheBook(real ?? home, bot, book));
|
|
252
|
+
|
|
253
|
+
// A tab outside the book is somebody else's, and in Bot Father's project it
|
|
254
|
+
// is the ops tab: the one tab the kit deliberately keeps no record of, so it
|
|
255
|
+
// cannot be a leftover (PRD 6.2).
|
|
256
|
+
if (bot.name !== BOT_FATHER) {
|
|
257
|
+
const known = tabIdsIn(book);
|
|
258
|
+
for (const tab of live.filter((one) => !known.has(one.tabId))) {
|
|
259
|
+
found.push(finding('leftover', tab.tabId, `Orca has a tab in ${bot.name}'s Orca project, ${tab.tabId}, that the book does not name, so it is none of this bot's sessions. The kit did not open it and does not touch it.`, bot.name));
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return found;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The conversations a harness has on record in this bot's folder that the book
|
|
268
|
+
* does not name anywhere. The hook is how an id reaches the book, and when it
|
|
269
|
+
* fails it fails quietly (ADR 0020), so this is where a stale book shows: the
|
|
270
|
+
* harness's own record set beside it (ADR 0012).
|
|
271
|
+
*/
|
|
272
|
+
function offTheBook(home, bot, book) {
|
|
273
|
+
const named = new Set();
|
|
274
|
+
for (const entry of Object.values(book.sessions)) {
|
|
275
|
+
if (typeof entry?.session === 'string') named.add(entry.session);
|
|
276
|
+
for (const was of Array.isArray(entry?.history) ? entry.history : []) named.add(was?.session);
|
|
277
|
+
for (const id of Array.isArray(entry?.unclaimed) ? entry.unclaimed : []) named.add(id);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Every harness any of its sessions runs on, as `obk usage` reads them.
|
|
281
|
+
const harnesses = new Set(bot.sessions.map((session) => harnessOf(session, bot.harness)));
|
|
282
|
+
if (harnesses.size === 0) harnesses.add(bot.harness);
|
|
283
|
+
const stray = [...harnesses]
|
|
284
|
+
.filter((harness) => HARNESSES.includes(harness))
|
|
285
|
+
.flatMap((harness) => transcriptsIn(harness, home))
|
|
286
|
+
.filter((one) => !named.has(one.id));
|
|
287
|
+
if (stray.length === 0) return [];
|
|
288
|
+
|
|
289
|
+
const listed = stray.map((one) => `${one.id} (begun ${one.at})`).join(', ');
|
|
290
|
+
return [finding('session', bookFile(home), `The harness has conversations on record in ${bot.name}'s folder that the book does not name: ${listed}. The kit's hook may have missed them: a clear it did not record, or a Codex hooks file trusted late. To give one to a session, write it into ${bookFile(home)} under that session as session: <id> and run obk up again.`, bot.name)];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** What a directory holds, and nothing at all when there is no directory. */
|
|
294
|
+
function namesIn(dir) {
|
|
295
|
+
try {
|
|
296
|
+
return readdirSync(dir);
|
|
297
|
+
} catch {
|
|
298
|
+
return [];
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Where a path really leads, or undefined when it leads nowhere. */
|
|
303
|
+
function realpathOf(at) {
|
|
304
|
+
try {
|
|
305
|
+
return realpathSync(at);
|
|
306
|
+
} catch {
|
|
307
|
+
return undefined;
|
|
308
|
+
}
|
|
309
|
+
}
|