@assuredloop/orca-bot-kit 0.6.0 → 0.8.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/README.md +30 -2
- package/SETUP.md +3 -2
- package/package.json +1 -1
- package/rules/commit.md +18 -0
- package/skills/obk-bot-building/SKILL.md +6 -2
- package/skills/obk-fleet-review/SKILL.md +2 -1
- package/skills/obk-grooming/NOTICE.md +21 -5
- package/skills/obk-grooming/SKILL.md +49 -12
- package/src/book.js +2 -4
- package/src/bot.js +1 -1
- package/src/cli.js +92 -25
- package/src/conversations.js +7 -1
- package/src/groom.js +263 -105
- package/src/health.js +29 -7
- package/src/hooks.js +3 -3
- package/src/message.js +8 -6
- package/src/orca-window.cjs +1 -1
- package/src/orca.js +79 -7
- package/src/record.js +25 -11
- package/src/skills.js +1 -1
- package/src/up.js +8 -5
package/src/groom.js
CHANGED
|
@@ -1,145 +1,312 @@
|
|
|
1
1
|
// The daily grooming run (PRD 6.8): an optional pass that reads what the bots
|
|
2
2
|
// have been doing, works out what it cost, and tells Bot Father.
|
|
3
3
|
//
|
|
4
|
-
// It
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
4
|
+
// It runs on Claude Code's own scheduler, in a session of Bot Father's called
|
|
5
|
+
// `grooming`, because the owner decided that scheduled work does not use an
|
|
6
|
+
// Orca automation: an automation cannot carry a model or an effort of its own,
|
|
7
|
+
// and a session's launch line can (#223). The user adds that session like any
|
|
8
|
+
// other, with the model and effort it is to run at, and brings it up; the kit
|
|
9
|
+
// never makes, starts or closes it.
|
|
9
10
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
11
|
+
// So the kit schedules nothing itself. The job is the session's: its own
|
|
12
|
+
// `CronCreate`, which lives in the running Claude Code and only while its tab is
|
|
13
|
+
// up, comes back on a resume, and outlives a `/clear` (tech notes, section 2).
|
|
14
|
+
// The kit reads which grooming jobs there are out of the transcripts of the
|
|
15
|
+
// conversations that process has had, and when asked it types one line into the
|
|
16
|
+
// grooming tab: schedule, unschedule, run once, or compact. It keeps no copy of
|
|
17
|
+
// what is scheduled, so what it reports is what the conversation holds.
|
|
12
18
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
+
// A line typed into the tab is run when the session gets to it, which may be
|
|
20
|
+
// after the kit has typed another, so what the kit read from the transcript
|
|
21
|
+
// when it typed can be out of date: an `--on` not yet run leaves no job to see.
|
|
22
|
+
// So the lines that schedule and unschedule say what the schedule is to be
|
|
23
|
+
// rather than what to change: when the session runs one, it looks at what it
|
|
24
|
+
// has itself (CronList), removes every grooming job, and then makes the one
|
|
25
|
+
// asked for, or none. Run in any order, the last one typed is what holds
|
|
26
|
+
// (review of PR #322).
|
|
27
|
+
//
|
|
28
|
+
// Claude Code ends a recurring job a week after it was made, so the job's own
|
|
29
|
+
// prompt ends by renewing it. A renewal that fails either way shows here: no
|
|
30
|
+
// job means the fleet has quietly stopped being groomed, two mean it is groomed
|
|
31
|
+
// twice, and both are said plainly.
|
|
19
32
|
|
|
20
33
|
import { existsSync, realpathSync } from 'node:fs';
|
|
21
34
|
import path from 'node:path';
|
|
22
35
|
|
|
23
36
|
import { botDir, readBot, requireBotsFolder } from './bot.js';
|
|
24
|
-
import { readBook
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
37
|
+
import { readBook } from './book.js';
|
|
38
|
+
import { claudeTranscript } from './conversations.js';
|
|
39
|
+
import { harnessOf, ownCli, shellWord } from './launch.js';
|
|
40
|
+
import { tabs, tabToTypeInto, typeIntoTab } from './orca.js';
|
|
27
41
|
import { BOT_FATHER } from './up.js';
|
|
42
|
+
import { lines } from './usage.js';
|
|
28
43
|
|
|
29
|
-
/**
|
|
30
|
-
const
|
|
44
|
+
/** Bot Father's session the grooming runs in. */
|
|
45
|
+
export const GROOMING = 'grooming';
|
|
31
46
|
|
|
32
47
|
/** A time of day, as a person writes one. */
|
|
33
48
|
const AT = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
|
34
49
|
|
|
35
|
-
/**
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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}.`;
|
|
50
|
+
/** A daily cron as the kit writes one: minute, hour, every day. */
|
|
51
|
+
const DAILY = /^(\d{1,2}) (\d{1,2}) \* \* \*$/;
|
|
52
|
+
|
|
53
|
+
/** How long Claude Code keeps a recurring job after it was made (tech notes, section 2). */
|
|
54
|
+
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
44
55
|
|
|
45
56
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
57
|
+
* What the session looks for in its own CronList to find its grooming jobs.
|
|
58
|
+
* Only the start of the marker: CronList shows a prompt cut short, and the
|
|
59
|
+
* grooming session is one Bot Father's, so its jobs are all this fleet's.
|
|
60
|
+
*/
|
|
61
|
+
const PREFIX = 'obk grooming';
|
|
62
|
+
|
|
63
|
+
/** How long Orca is given to say what is in the grooming tab. */
|
|
64
|
+
const LOOK_MS = 2000;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* What every grooming job's prompt starts with, which is how the kit and the
|
|
68
|
+
* session tell it from a job of the user's own in the same conversation.
|
|
69
|
+
*/
|
|
70
|
+
export const markerOf = (bots) => `obk grooming for ${bots}`;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Say what the grooming is, and ask the grooming session for a change when
|
|
74
|
+
* told to. `ask` is one of `on`, `move` (a new time for grooming that is on),
|
|
75
|
+
* `off`, `now` and `compact`, or undefined to only report; `at` is a time of
|
|
76
|
+
* day, `HH:MM`.
|
|
48
77
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
78
|
+
* Returns `{ session, jobs, asked }`: the grooming session as the book has it
|
|
79
|
+
* (or null), the grooming jobs alive in its conversation as the command found
|
|
80
|
+
* them, before anything it typed was done, and what it typed (or null).
|
|
52
81
|
*/
|
|
53
|
-
export function grooming(bots, { at,
|
|
82
|
+
export function grooming(bots, { at, ask } = {}) {
|
|
54
83
|
requireBotsFolder(bots);
|
|
55
84
|
|
|
56
|
-
// The folder as the file system knows it
|
|
57
|
-
//
|
|
58
|
-
// spelling that is not the canonical one finds nothing and offers to make a
|
|
59
|
-
// second of what is already there.
|
|
85
|
+
// The folder as the file system knows it: Claude Code files a transcript
|
|
86
|
+
// under the real path, and Orca knows a tab's project by it.
|
|
60
87
|
const home = realHome(botDir(bots, BOT_FATHER));
|
|
61
88
|
if (!existsSync(path.join(home, 'bot.yaml'))) {
|
|
62
|
-
throw new Error(`there is no ${BOT_FATHER} in ${bots}, and the grooming runs in
|
|
89
|
+
throw new Error(`there is no ${BOT_FATHER} in ${bots}, and the grooming runs in a session of its. Run obk init first.`);
|
|
63
90
|
}
|
|
64
91
|
if (at !== undefined && !AT.test(at)) {
|
|
65
92
|
throw new Error(`--at is a time of day as 24 hours, such as 04:00, and got: ${at}`);
|
|
66
93
|
}
|
|
67
94
|
|
|
68
95
|
const bot = readBot(home, BOT_FATHER);
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
96
|
+
const found = bot.sessions.find((one) => one.name === GROOMING);
|
|
97
|
+
const entry = readBook(home).sessions[GROOMING] ?? {};
|
|
98
|
+
const tab = typeof entry.tab === 'string' ? entry.tab : undefined;
|
|
99
|
+
const conversation = typeof entry.session === 'string' ? entry.session : null;
|
|
100
|
+
|
|
101
|
+
const session = found === undefined ? null : {
|
|
102
|
+
name: GROOMING,
|
|
103
|
+
harness: harnessOf(found, bot.harness),
|
|
104
|
+
up: tab !== undefined && tabs(home).some((one) => one.tabId === tab),
|
|
105
|
+
conversation,
|
|
106
|
+
};
|
|
107
|
+
const jobs = session?.harness === 'claude' && conversation !== null
|
|
108
|
+
? groomingJobs(ofThisProcess(home, entry), markerOf(bots), Date.now())
|
|
109
|
+
: [];
|
|
110
|
+
|
|
111
|
+
const line = ask === undefined ? undefined : lineFor(ask, { bots, session, jobs, at });
|
|
112
|
+
if (line !== undefined) typeInto(bots, home, session, tab, line);
|
|
113
|
+
|
|
114
|
+
// A move is turning it on at another time, and is typed as one.
|
|
115
|
+
return { session, jobs, asked: line === undefined ? null : (ask === 'move' ? 'on' : ask) };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The line that asks the grooming session for `ask`, or undefined when there is
|
|
120
|
+
* nothing to ask. Everything that makes the ask impossible refuses here, before
|
|
121
|
+
* Orca is asked for anything.
|
|
122
|
+
*/
|
|
123
|
+
function lineFor(ask, { bots, session, jobs, at }) {
|
|
124
|
+
// Nothing to turn off is an answer, unless a session is there to be told: an
|
|
125
|
+
// `--on` typed a moment ago may still be waiting in its tab.
|
|
126
|
+
if (ask === 'off' && jobs.length === 0 && !(session?.harness === 'claude' && session.up)) return undefined;
|
|
127
|
+
if (session === null) {
|
|
128
|
+
throw new Error(`${BOT_FATHER} has no ${GROOMING} session, so there is nothing to ask. Add it: ${addCommand(bots)}, then ${upCommand(bots)}.`);
|
|
129
|
+
}
|
|
130
|
+
if (session.harness !== 'claude') {
|
|
131
|
+
throw new Error(`${BOT_FATHER}'s ${GROOMING} session runs on ${session.harness}, and grooming runs on Claude Code's own schedule, so its session has to be a Claude Code one. Retire it and add it again: ${retireCommand(bots)}, then ${addCommand(bots)} --harness claude.`);
|
|
72
132
|
}
|
|
73
133
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
return settle(bots, home, bot, { at, on });
|
|
81
|
-
} finally {
|
|
82
|
-
lock.release();
|
|
134
|
+
if (ask === 'off') return offLine(jobs);
|
|
135
|
+
if (ask === 'now') return nowLine(bots);
|
|
136
|
+
if (ask === 'compact') return '/compact';
|
|
137
|
+
|
|
138
|
+
if (ask === 'move' && jobs.length === 0) {
|
|
139
|
+
throw new Error(`grooming is off, so there is no job to move. Turning it on at that time is ${groomCommand(bots)} --on --at ${at}, once the user has said yes.`);
|
|
83
140
|
}
|
|
84
|
-
}
|
|
85
141
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
'
|
|
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]);
|
|
142
|
+
// On: at the time given, or at the one time every job there already has.
|
|
143
|
+
const times = [...new Set(jobs.map((job) => job.at))];
|
|
144
|
+
const when = at ?? (times.length === 1 ? times[0] : undefined);
|
|
145
|
+
if (when === undefined) {
|
|
146
|
+
throw new Error(jobs.length === 0
|
|
147
|
+
? `grooming is off, so there is no time to keep. Say when: ${groomCommand(bots)} --on --at <HH:MM>.`
|
|
148
|
+
: `the grooming jobs there run at different times (${times.join(', ')}). Say which one to keep: ${groomCommand(bots)} --on --at <HH:MM>.`);
|
|
109
149
|
}
|
|
150
|
+
return onLine(bots, cronOf(when), jobs);
|
|
151
|
+
}
|
|
110
152
|
|
|
111
|
-
|
|
112
|
-
|
|
153
|
+
/**
|
|
154
|
+
* Type `line` into the grooming tab, through the kit's one gate for typing into
|
|
155
|
+
* a running session: nothing goes into a tab with something on screen waiting
|
|
156
|
+
* for an answer, or with anything but Claude Code in front of it.
|
|
157
|
+
*/
|
|
158
|
+
function typeInto(bots, home, session, tab, line) {
|
|
159
|
+
if (!session.up) {
|
|
160
|
+
throw new Error(`${BOT_FATHER}'s ${GROOMING} session is not up, so nothing was typed. Bring it up with ${upCommand(bots)}, then run this again.`);
|
|
113
161
|
}
|
|
162
|
+
const found = tabToTypeInto(home, tab, LOOK_MS);
|
|
163
|
+
if (found.blocked !== undefined) {
|
|
164
|
+
throw new Error(`the ${GROOMING} tab is waiting for an answer (${found.blocked}), so nothing was typed. Answer it in the tab, then run this again.`);
|
|
165
|
+
}
|
|
166
|
+
if (found.unsure !== undefined) throw new Error(`the ${GROOMING} tab: ${found.unsure}.`);
|
|
167
|
+
if (found.handle === undefined || found.agent !== 'claude') {
|
|
168
|
+
throw new Error(`the ${GROOMING} tab has no Claude Code in front of it, so nothing was typed. Bring it back with ${restartCommand(bots)}, then run this again.`);
|
|
169
|
+
}
|
|
170
|
+
typeIntoTab(found.handle, line);
|
|
171
|
+
}
|
|
114
172
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
173
|
+
/**
|
|
174
|
+
* The transcript lines of every conversation the grooming session's running
|
|
175
|
+
* Claude Code has had, oldest conversation first.
|
|
176
|
+
*
|
|
177
|
+
* A job lives in the process, not in one conversation: seen live, a `/clear`
|
|
178
|
+
* left the job made before it running, and the new conversation's CronList
|
|
179
|
+
* still answered it. So the conversation the book holds now is read, and before
|
|
180
|
+
* it each one it cleared, back through the book's history, until the one where
|
|
181
|
+
* this process began: the kit's own SessionStart hook leaves a line in every
|
|
182
|
+
* conversation it sees start, and `startup` or `resume` there means a process
|
|
183
|
+
* started with it (a fresh start, the kit's restart, or Orca restoring the tab).
|
|
184
|
+
* Whatever came before that belonged to a process that has gone.
|
|
185
|
+
*/
|
|
186
|
+
function ofThisProcess(home, entry) {
|
|
187
|
+
const history = Array.isArray(entry.history) ? entry.history : [];
|
|
188
|
+
const read = [];
|
|
189
|
+
let id = entry.session;
|
|
190
|
+
for (let back = history.length - 1; ; back -= 1) {
|
|
191
|
+
const those = lines(claudeTranscript(home, id));
|
|
192
|
+
read.unshift(those);
|
|
193
|
+
if (those.some(beganHere)) break;
|
|
194
|
+
const was = history[back];
|
|
195
|
+
if (was === null || typeof was !== 'object' || was.ended !== 'clear' || typeof was.session !== 'string') break;
|
|
196
|
+
id = was.session;
|
|
197
|
+
}
|
|
198
|
+
return read.flat();
|
|
121
199
|
}
|
|
122
200
|
|
|
201
|
+
/** Whether a transcript line is the kit's hook seeing a process start on this conversation. */
|
|
202
|
+
const beganHere = (line) => line.type === 'attachment'
|
|
203
|
+
&& ['SessionStart:startup', 'SessionStart:resume'].includes(line.attachment?.hookName);
|
|
204
|
+
|
|
123
205
|
/**
|
|
124
|
-
* The
|
|
206
|
+
* The grooming jobs alive in transcript lines at `now`, oldest first:
|
|
207
|
+
* `{ id, at, cron, made, expires }`.
|
|
125
208
|
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
209
|
+
* A job is made by a CronCreate that succeeded, recurring (Claude Code's
|
|
210
|
+
* default), with a prompt that starts with the marker. It ends at a CronDelete
|
|
211
|
+
* of its id that succeeded, or a week after it was made. A call is matched to
|
|
212
|
+
* its answer by the tool call's id; an answer that failed is a string, not the
|
|
213
|
+
* job, and makes or ends nothing.
|
|
129
214
|
*/
|
|
130
|
-
function
|
|
131
|
-
const
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
215
|
+
export function groomingJobs(entries, marker, now) {
|
|
216
|
+
const calls = new Map();
|
|
217
|
+
const alive = new Map();
|
|
218
|
+
for (const entry of entries) {
|
|
219
|
+
const content = Array.isArray(entry.message?.content) ? entry.message.content : [];
|
|
220
|
+
for (const item of content) {
|
|
221
|
+
if (entry.type === 'assistant' && item?.type === 'tool_use') calls.set(item.id, item);
|
|
222
|
+
if (entry.type !== 'user' || item?.type !== 'tool_result' || item.is_error === true) continue;
|
|
223
|
+
|
|
224
|
+
const call = calls.get(item.tool_use_id);
|
|
225
|
+
const result = entry.toolUseResult;
|
|
226
|
+
if (call === undefined || result === null || typeof result !== 'object' || typeof result.id !== 'string') continue;
|
|
227
|
+
if (call.name === 'CronDelete') alive.delete(result.id);
|
|
228
|
+
if (call.name !== 'CronCreate') continue;
|
|
229
|
+
|
|
230
|
+
const { cron, prompt, recurring } = call.input ?? {};
|
|
231
|
+
const made = Date.parse(entry.timestamp ?? '');
|
|
232
|
+
if (recurring === false || typeof prompt !== 'string' || !prompt.startsWith(marker) || Number.isNaN(made)) continue;
|
|
233
|
+
alive.set(result.id, { id: result.id, at: timeOf(cron), cron, made, expires: made + WEEK_MS });
|
|
234
|
+
}
|
|
139
235
|
}
|
|
140
|
-
return
|
|
236
|
+
return [...alive.values()]
|
|
237
|
+
.filter((job) => job.expires > now)
|
|
238
|
+
.sort((left, right) => left.made - right.made)
|
|
239
|
+
.map((job) => ({ ...job, made: new Date(job.made).toISOString(), expires: new Date(job.expires).toISOString() }));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** `HH:MM` as the daily cron Claude Code takes: `04:00` is `0 4 * * *`. */
|
|
243
|
+
const cronOf = (at) => {
|
|
244
|
+
const [, hour, minute] = AT.exec(at);
|
|
245
|
+
return `${Number(minute)} ${Number(hour)} * * *`;
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
/** A daily cron read back as `HH:MM`, or undefined for any other shape. */
|
|
249
|
+
function timeOf(cron) {
|
|
250
|
+
const found = DAILY.exec(String(cron ?? ''));
|
|
251
|
+
if (found === null) return undefined;
|
|
252
|
+
return `${found[2].padStart(2, '0')}:${found[1].padStart(2, '0')}`;
|
|
141
253
|
}
|
|
142
254
|
|
|
255
|
+
/** The grooming run itself: what a job does each day, and what `--now` asks for once. */
|
|
256
|
+
const run = (bots) =>
|
|
257
|
+
`${markerOf(bots)}: run the daily grooming for this fleet, with the obk-grooming skill. Fix this `
|
|
258
|
+
+ 'run\'s end now and read what has happened since the last run\'s end up to it, '
|
|
259
|
+
+ `counting with ${shellWord(ownCli())} usage --since <last end> --until <this end>, and write this `
|
|
260
|
+
+ 'end down for the next run. Work out what it cost with obk-finops, keep '
|
|
261
|
+
+ 'each bot\'s profile notes, and send one short report to Bot Father\'s '
|
|
262
|
+
+ `management session. The kit here is ${shellWord(ownCli())}: run it wherever a skill says obk. `
|
|
263
|
+
+ `The bots folder is ${bots}.`;
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* A job's prompt: the run, and then its own renewal, since Claude Code ends a
|
|
267
|
+
* recurring job a week after it was made. The renewal copies the prompt it was
|
|
268
|
+
* fired with, so the job carries itself on; the one that fired is then the
|
|
269
|
+
* older of the two.
|
|
270
|
+
*/
|
|
271
|
+
const jobPrompt = (bots, cron) =>
|
|
272
|
+
`${run(bots)} When the report is sent, renew this schedule, which Claude Code ends a week after it was made: `
|
|
273
|
+
+ `call CronCreate with cron "${cron}", recurring true, and this whole prompt, word for word, as its prompt; `
|
|
274
|
+
+ `then call CronList and CronDelete every other job there whose prompt starts with "${PREFIX}".`;
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The part of a line that clears the grooming jobs the session has when it runs
|
|
278
|
+
* the line, which is not always what the kit saw when it typed it.
|
|
279
|
+
*/
|
|
280
|
+
const clearing = (jobs) =>
|
|
281
|
+
`call CronList, and call CronDelete on every job there whose prompt starts with "${PREFIX}"`
|
|
282
|
+
+ (jobs.length === 0 ? '' : ` (when this was typed they were ${jobs.map((job) => job.id).join(', ')})`);
|
|
283
|
+
|
|
284
|
+
const onLine = (bots, cron, jobs) =>
|
|
285
|
+
`obk groom asks you to schedule the daily grooming. First ${clearing(jobs)}. `
|
|
286
|
+
+ `Then call CronCreate once, with cron "${cron}", recurring true, and as its prompt everything after "Prompt:" `
|
|
287
|
+
+ 'at the end of this line, word for word. Do not run the grooming now; say in one line what you scheduled. '
|
|
288
|
+
+ `Prompt: ${jobPrompt(bots, cron)}`;
|
|
289
|
+
|
|
290
|
+
const offLine = (jobs) =>
|
|
291
|
+
`obk groom asks you to turn the daily grooming off: ${clearing(jobs)}. `
|
|
292
|
+
+ 'Schedule nothing in their place, and say in one line what you cancelled.';
|
|
293
|
+
|
|
294
|
+
const nowLine = (bots) => `obk groom asks you to run the daily grooming once, now, and to schedule nothing. ${run(bots)}`;
|
|
295
|
+
|
|
296
|
+
/** The command that adds the grooming session, for a caller to run. */
|
|
297
|
+
export const addCommand = (bots) =>
|
|
298
|
+
`${shellWord(ownCli())} session add --bots ${shellWord(bots)} --bot ${BOT_FATHER} --name ${GROOMING}`;
|
|
299
|
+
|
|
300
|
+
/** The command that brings Bot Father's sessions up. */
|
|
301
|
+
export const upCommand = (bots) => `${shellWord(ownCli())} up --bots ${shellWord(bots)} --bot ${BOT_FATHER}`;
|
|
302
|
+
|
|
303
|
+
/** This command again, for a caller to add its flags to. */
|
|
304
|
+
export const groomCommand = (bots) => `${shellWord(ownCli())} groom --bots ${shellWord(bots)}`;
|
|
305
|
+
|
|
306
|
+
const retireCommand = (bots) => `${shellWord(ownCli())} retire --bots ${shellWord(bots)} --bot ${BOT_FATHER} --session ${GROOMING}`;
|
|
307
|
+
|
|
308
|
+
const restartCommand = (bots) => `${shellWord(ownCli())} restart --bots ${shellWord(bots)} --bot ${BOT_FATHER} --session ${GROOMING}`;
|
|
309
|
+
|
|
143
310
|
/** A bot home as the file system knows it, or as it was given when it is not there. */
|
|
144
311
|
function realHome(home) {
|
|
145
312
|
try {
|
|
@@ -148,12 +315,3 @@ function realHome(home) {
|
|
|
148
315
|
return home;
|
|
149
316
|
}
|
|
150
317
|
}
|
|
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
CHANGED
|
@@ -25,8 +25,9 @@ import { bookFile, readBook, sessionIdsIn, tabIdsIn } from './book.js';
|
|
|
25
25
|
import { botDir, botNames, botsDir, readBot, unknownKeys } from './bot.js';
|
|
26
26
|
import { transcriptsIn } from './conversations.js';
|
|
27
27
|
import { hookTrouble } from './hooks.js';
|
|
28
|
-
import { bypassFlags, harnessOf, HARNESSES, ownCli, sessionTrouble, shellWord } from './launch.js';
|
|
29
|
-
import { frontOfTab, orcaDefaultArgs, projects, tabs } from './orca.js';
|
|
28
|
+
import { bypassFlags, harnessOf, HARNESSES, ownCli, sessionTrouble, SHELL_ENV, shellWord } from './launch.js';
|
|
29
|
+
import { frontOfTab, orcaDefaultArgs, projects, tabs, wordsOfProcess } from './orca.js';
|
|
30
|
+
import { TAB_ENV } from './record.js';
|
|
30
31
|
import { agentsTrouble, rulesStamp } from './rules.js';
|
|
31
32
|
import { settingsInUse } from './settings.js';
|
|
32
33
|
import { skillsTrouble } from './skills.js';
|
|
@@ -215,8 +216,9 @@ const hooksOf = (bots, home, bot) => [...new Set(bot.sessions.map((session) => h
|
|
|
215
216
|
/**
|
|
216
217
|
* Each session of this bot running in a tab Orca has, with what it runs on set
|
|
217
218
|
* beside what the bot asks for now, added to `sessions`; a finding for each
|
|
218
|
-
* one that runs on something else (#271, #272)
|
|
219
|
-
*
|
|
219
|
+
* one that runs on something else (#271, #272), or whose harness the kit's
|
|
220
|
+
* launch line did not start (#318); and a finding for each one whose tab is
|
|
221
|
+
* open with its harness gone from it (#300).
|
|
220
222
|
*
|
|
221
223
|
* Two questions, each answered from where the answer is written. The settings
|
|
222
224
|
* from the harness's own record of the conversation the session is in now,
|
|
@@ -270,8 +272,15 @@ function runningOn(bots, home, bot, book, handles, sessions) {
|
|
|
270
272
|
if (running !== 'yes') continue;
|
|
271
273
|
|
|
272
274
|
const off = Object.entries(settings).filter(([, one]) => one.state === 'mismatch');
|
|
273
|
-
|
|
274
|
-
|
|
275
|
+
const parts = off.map(([name, one]) => `${name}: bot.yaml asks for ${one.configured}, and it runs on ${one.observed}`).join('; ');
|
|
276
|
+
if (startedByKit(front.pid, entry.tab) === false) {
|
|
277
|
+
// Orca brings its tabs back by itself after a restart or an update, with
|
|
278
|
+
// a bare resume and none of the kit's launch line (#318). What health can
|
|
279
|
+
// see may still match, by the harness's own defaults; the rest of what
|
|
280
|
+
// bot.yaml asks for is not on it either way.
|
|
281
|
+
const drift = off.length === 0 ? '' : ` ${parts}, as the harness's own record of its conversation ${conversation} says.`;
|
|
282
|
+
found.push(finding('session', entry.tab, `${bot.name}'s session ${session.name} was not started by the kit: the harness in its tab ${entry.tab} carries nothing of the kit's launch line, as when Orca brings its tabs back by itself after a restart or an update. So it runs on the harness's own defaults and whatever Orca added, not on what ${path.join(home, 'bot.yaml')} asks for.${drift} ${restart} starts it on bot.yaml.`, bot.name));
|
|
283
|
+
} else if (off.length > 0) {
|
|
275
284
|
found.push(finding('session', file, `${bot.name}'s session ${session.name} does not run on what ${path.join(home, 'bot.yaml')} asks for. ${parts}. That is what the harness's own record of its conversation ${conversation} says. A session takes these when it starts, so if bot.yaml changed after it started, ${restart} starts it on them; if not, something else set them, such as a default of the harness's own or a change made inside the session.`, bot.name));
|
|
276
285
|
}
|
|
277
286
|
|
|
@@ -289,6 +298,19 @@ function runningOn(bots, home, bot, book, handles, sessions) {
|
|
|
289
298
|
return found;
|
|
290
299
|
}
|
|
291
300
|
|
|
301
|
+
/**
|
|
302
|
+
* Whether the kit's launch line started the process `pid` in the tab `tab`:
|
|
303
|
+
* true when it carries the marker the line puts there, false when it carries
|
|
304
|
+
* that tab's id and no marker, and undefined when its environment cannot be
|
|
305
|
+
* read (tech notes, section 1). Only a whole word counts: an argument, such as
|
|
306
|
+
* a start prompt, can hold anything.
|
|
307
|
+
*/
|
|
308
|
+
function startedByKit(pid, tab) {
|
|
309
|
+
const words = pid === undefined ? undefined : wordsOfProcess(pid);
|
|
310
|
+
if (words === undefined || !words.includes(`${TAB_ENV}=${tab}`)) return undefined;
|
|
311
|
+
return words.some((word) => new RegExp(`^${SHELL_ENV}=[0-9]+$`).test(word));
|
|
312
|
+
}
|
|
313
|
+
|
|
292
314
|
/**
|
|
293
315
|
* What Orca has of this bot against what the book says it should have: a
|
|
294
316
|
* session whose tab is gone, a conversation nobody claims, and a tab in the
|
|
@@ -357,7 +379,7 @@ function inOrca(bots, home, bot, setups, sessions) {
|
|
|
357
379
|
/**
|
|
358
380
|
* The conversations a harness has on record in this bot's folder that the book
|
|
359
381
|
* does not name anywhere. The hook is how an id reaches the book, and when it
|
|
360
|
-
* fails it fails quietly (ADR
|
|
382
|
+
* fails it fails quietly (ADR 0022), so this is where a stale book shows: the
|
|
361
383
|
* harness's own record set beside it (ADR 0012).
|
|
362
384
|
*/
|
|
363
385
|
function offTheBook(home, bot, book, onRecord) {
|
package/src/hooks.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The kit's own hook, in the bot's folder and nowhere else (ADR
|
|
1
|
+
// The kit's own hook, in the bot's folder and nowhere else (ADR 0022).
|
|
2
2
|
//
|
|
3
3
|
// Both harnesses read a hooks file out of the folder a session starts in, and
|
|
4
4
|
// every session starts at its bot home, so the bot folder is where the kit's
|
|
@@ -35,7 +35,7 @@ const TIMEOUT = 10;
|
|
|
35
35
|
* What the hook runs: the CLI that wrote it, by its own path, so the session
|
|
36
36
|
* reports to the kit that set it up and not to whatever `obk` PATH finds in the
|
|
37
37
|
* harness's shell (#220). `|| true` and a quiet stderr because a hook must never
|
|
38
|
-
* disturb the session it fires in (ADR
|
|
38
|
+
* disturb the session it fires in (ADR 0022): a kit that is not there, or a book
|
|
39
39
|
* that cannot be written, leaves the session alone and the health check finds
|
|
40
40
|
* the stale book later.
|
|
41
41
|
*/
|
|
@@ -124,7 +124,7 @@ export function hookTrouble(home, harness, { bots, bot }) {
|
|
|
124
124
|
/**
|
|
125
125
|
* What to say about a hooks file that a link takes out of the bot folder, or
|
|
126
126
|
* undefined when it stays in. The kit writes its hook in the bot folder and
|
|
127
|
-
* nowhere else (ADR
|
|
127
|
+
* nowhere else (ADR 0022); through a link like that it would be writing the
|
|
128
128
|
* user's own settings, so it does not, and says why.
|
|
129
129
|
*/
|
|
130
130
|
function outside(home, file) {
|
package/src/message.js
CHANGED
|
@@ -350,13 +350,15 @@ const stamp = () => new Date().toISOString().replaceAll(':', '-').replace('.', '
|
|
|
350
350
|
* into one of those is not a message: it is an answer to whatever question is
|
|
351
351
|
* up. That is not a worry, it is a thing that happened — in slice 03 a second
|
|
352
352
|
* line went into a tab on Claude Code's folder-trust list, confirmed its
|
|
353
|
-
* default, `No, exit`, and the harness quit (tech notes, section 1).
|
|
354
|
-
*
|
|
353
|
+
* default, `No, exit`, and the harness quit (tech notes, section 1). And in
|
|
354
|
+
* #329 a return took Codex's "Update now". So where Orca says a tab is
|
|
355
|
+
* blocked, or the kit sees a numbered choice list of the harness's own on its
|
|
356
|
+
* screen, nothing is typed and the mail waits.
|
|
355
357
|
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
*
|
|
358
|
+
* That narrows the case rather than closing it: a question drawn any other way,
|
|
359
|
+
* such as Claude Code's unnumbered trust list, is seen by neither. What closes
|
|
360
|
+
* it is nobody sending to a session before its first screens are answered,
|
|
361
|
+
* which is the caller's work either way.
|
|
360
362
|
*/
|
|
361
363
|
function nudge(to, from, subject) {
|
|
362
364
|
if (to.tab === undefined) return { nudged: false };
|
package/src/orca-window.cjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
//
|
|
4
4
|
// It asks Orca's runtime, through Orca's own client, for `project.update` with
|
|
5
5
|
// no changes on one project. That is the call that makes Orca's window read its
|
|
6
|
-
// projects again, and Orca's CLI does not offer it (#224, ADR
|
|
6
|
+
// projects again, and Orca's CLI does not offer it (#224, ADR 0023). It exits 0
|
|
7
7
|
// only when the runtime took the call; anything else is a non-zero exit, and
|
|
8
8
|
// the kit reads every non-zero exit the same way.
|
|
9
9
|
|