@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/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 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.
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
- // 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.
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
- // 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
+ // 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, takeLock } from './book.js';
25
- import { ownCli, shellWord } from './launch.js';
26
- import { orca } from './orca.js';
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
- /** What the kit calls its own grooming automation. */
30
- const NAME = 'obk grooming';
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
- /** 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}.`;
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
- * Say what the grooming is, and make it so when asked.
47
- * Returns `{ exists }`, and `{ exists, enabled, at, id }` once there is one.
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
- * 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.
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, on } = {}) {
82
+ export function grooming(bots, { at, ask } = {}) {
54
83
  requireBotsFolder(bots);
55
84
 
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.
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 its Orca project. Run obk init first.`);
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 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.`);
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
- // 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();
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
- /** 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]);
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
- if (on !== undefined && mine !== undefined) {
112
- orca(['automations', 'edit', '--id', mine.id, on ? '--enabled' : '--disabled']);
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
- // 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 };
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 kit's own grooming automation for this Bot Father, if there is one.
206
+ * The grooming jobs alive in transcript lines at `now`, oldest first:
207
+ * `{ id, at, cron, made, expires }`.
125
208
  *
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.
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 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.`);
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 found[0];
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); and a finding for each one
219
- * whose tab is open with its harness gone from it (#300).
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
- if (off.length > 0) {
274
- const parts = off.map(([name, one]) => `${name}: bot.yaml asks for ${one.configured}, and it runs on ${one.observed}`).join('; ');
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 0020), so this is where a stale book shows: the
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 0020).
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 0020): a kit that is not there, or a book
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 0020); through a link like that it would be writing the
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). So where
354
- * Orca says a tab is blocked, nothing is typed and the mail waits.
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
- * Orca's `blockedReason` catches Codex's screens and not Claude Code's, which
357
- * answer as idle with nothing said, so this narrows the case rather than
358
- * closing it. What closes it is nobody sending to a session before its first
359
- * screens are answered, which is the caller's work either way.
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 };
@@ -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 0021). It exits 0
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