@assuredloop/orca-bot-kit 0.3.0 → 0.5.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.
@@ -0,0 +1,158 @@
1
+ // What a running session really runs with, set beside what its bot.yaml asks for.
2
+ //
3
+ // A session can run on settings other than the ones bot.yaml asks for: it
4
+ // started before a change, a default of the user's own leaked in, or a flag was
5
+ // dropped. Each harness writes down what it used (tech notes, sections 2 and 3),
6
+ // so the two can be put side by side. Read only: these are the harness's files.
7
+ //
8
+ // Four answers, and only one of them is good news. A record that cannot be read,
9
+ // or that does not carry the setting, is not agreement: it is `unknown`. A
10
+ // setting the session never asked for is not a disagreement either, whatever the
11
+ // harness chose: it is `not-asked-for`.
12
+
13
+ import { DEFAULT_APPROVAL } from './launch.js';
14
+ import { lines } from './usage.js';
15
+
16
+ export const SETTINGS = ['model', 'effort', 'context', 'approval'];
17
+
18
+ /**
19
+ * Each setting of `session` as `{ state, configured?, observed? }`, judged
20
+ * against `file`, the harness's record of the conversation the session is in
21
+ * now, from `since`, when the kit last started it. No file is no record, and
22
+ * every setting asked for is then unknown.
23
+ */
24
+ export function settingsInUse(harness, session, file, since) {
25
+ const used = file === undefined ? {} : (harness === 'claude' ? fromClaude : fromCodex)(sinceStart(lines(file), since));
26
+ return Object.fromEntries(SETTINGS.map((name) => [name, judged(harness, name, asked(session, name), used[name])]));
27
+ }
28
+
29
+ /**
30
+ * What the record says since the kit last started the session. A start resumes
31
+ * the conversation it had, so the file still holds what the process before it
32
+ * wrote, and that says what that process ran with, not this one. Everything
33
+ * from the first line written at or after the start counts, a line with no time
34
+ * of its own included. With no start to go by, no line can be shown to be this
35
+ * process's, so none counts.
36
+ */
37
+ function sinceStart(entries, since) {
38
+ const from = Date.parse(since ?? '');
39
+ if (Number.isNaN(from)) return [];
40
+ const first = entries.findIndex((entry) => Date.parse(entry.timestamp ?? '') >= from);
41
+ return first === -1 ? [] : entries.slice(first);
42
+ }
43
+
44
+ /** What the session asks for, as bot.yaml has it, or undefined when it asks for nothing. */
45
+ function asked(session, name) {
46
+ const value = session[name];
47
+ const set = value !== undefined && value !== null && String(value).trim() !== '';
48
+ // The kit puts an approval level on every launch line, so a session always
49
+ // asks for one: the default, when it names none (ADR 0015).
50
+ if (name === 'approval') return set ? String(value).trim() : DEFAULT_APPROVAL;
51
+ return set ? String(value).trim() : undefined;
52
+ }
53
+
54
+ function judged(harness, name, configured, observed) {
55
+ const shown = observed === undefined ? {} : { observed: said(name, observed) };
56
+ if (configured === undefined) return { state: 'not-asked-for', ...shown };
57
+ const verdict = same(harness, name, configured, observed);
58
+ if (verdict === undefined) return { state: 'unknown', configured, ...shown };
59
+ return { state: verdict ? 'match' : 'mismatch', configured, ...shown };
60
+ }
61
+
62
+ /** Whether the record says what was asked for; undefined when it cannot say. */
63
+ function same(harness, name, configured, observed) {
64
+ if (observed === undefined) return undefined;
65
+ if (name === 'model') return sameModel(configured, observed);
66
+ // Codex reports the window it was given at 95%, rounded down (tech notes,
67
+ // section 3): 200000 comes back as 190000.
68
+ if (name === 'context') return Number(observed) === Math.floor(Number(configured) * 0.95);
69
+ if (name === 'approval') return harness === 'claude' ? observed === CLAUDE_MODES[configured] : sameCodexApproval(configured, observed);
70
+ return observed === configured;
71
+ }
72
+
73
+ /**
74
+ * A model named without a dash is an alias, `sonnet`, and the harness writes the
75
+ * id it stands for, `claude-sonnet-5`: it matches an id it is one part of. A
76
+ * model with a dash is an id already, and has to be the one that ran. The
77
+ * context window Claude Code carries on the model name, `sonnet[1m]`, is not
78
+ * part of what it writes down.
79
+ */
80
+ function sameModel(configured, observed) {
81
+ const model = configured.replace(/\[[^\]]*\]$/, '');
82
+ return model.includes('-') ? observed === model : observed.split('-').includes(model);
83
+ }
84
+
85
+ /** The permission mode Claude Code records for each level (tech notes, section 2). */
86
+ const CLAUDE_MODES = { auto: 'auto', ask: 'default', 'dangerously-skip': 'bypassPermissions' };
87
+
88
+ /**
89
+ * What Codex records for each level, and only the words that level decides: at
90
+ * `ask` the kit passes only the approval policy, so the sandbox is the user's
91
+ * own (ADR 0015), and at `dangerously-skip` nothing is reviewed (tech notes,
92
+ * section 3).
93
+ */
94
+ const CODEX_WORDS = {
95
+ auto: { policy: 'on-request', reviewer: 'auto_review', sandbox: 'workspace-write' },
96
+ ask: { policy: 'on-request', reviewer: 'user' },
97
+ 'dangerously-skip': { policy: 'never', sandbox: 'danger-full-access' },
98
+ };
99
+
100
+ function sameCodexApproval(configured, observed) {
101
+ const wanted = CODEX_WORDS[configured];
102
+ if (wanted === undefined) return false;
103
+ const pairs = Object.entries(wanted);
104
+ // A word the level decides and the record does not carry leaves it unsaid.
105
+ if (pairs.some(([key]) => observed[key] === undefined)) return undefined;
106
+ return pairs.every(([key, word]) => observed[key] === word);
107
+ }
108
+
109
+ /** A value the record carried, as the report shows it. */
110
+ function said(name, observed) {
111
+ if (name !== 'approval' || typeof observed === 'string') return String(observed);
112
+ return [
113
+ `approval_policy ${observed.policy}`,
114
+ ...(observed.reviewer === undefined ? [] : [`approvals_reviewer ${observed.reviewer}`]),
115
+ ...(observed.sandbox === undefined ? [] : [`sandbox ${observed.sandbox}`]),
116
+ ].join(', ');
117
+ }
118
+
119
+ /**
120
+ * What a Claude Code transcript says it runs with, the latest of each: the
121
+ * model and effort on its replies, the permission mode wherever it is noted.
122
+ * Claude Code writes nothing that shows the context window.
123
+ */
124
+ function fromClaude(entries) {
125
+ const used = {};
126
+ for (const entry of entries) {
127
+ if (entry.type === 'assistant' && entry.isSidechain !== true) {
128
+ const model = entry.message?.model;
129
+ if (typeof model === 'string' && model !== '<synthetic>') used.model = model;
130
+ if (typeof entry.effort === 'string') used.effort = entry.effort;
131
+ }
132
+ if (typeof entry.permissionMode === 'string') used.approval = entry.permissionMode;
133
+ }
134
+ return used;
135
+ }
136
+
137
+ /** What a Codex rollout says it runs with: its latest turn, and its latest count. */
138
+ function fromCodex(entries) {
139
+ const used = {};
140
+ for (const entry of entries) {
141
+ const payload = entry.payload;
142
+ if (payload === null || typeof payload !== 'object') continue;
143
+ if (entry.type === 'turn_context') {
144
+ if (typeof payload.model === 'string') used.model = payload.model;
145
+ if (typeof payload.effort === 'string') used.effort = payload.effort;
146
+ if (typeof payload.approval_policy === 'string') {
147
+ used.approval = {
148
+ policy: payload.approval_policy,
149
+ reviewer: typeof payload.approvals_reviewer === 'string' ? payload.approvals_reviewer : undefined,
150
+ sandbox: typeof payload.sandbox_policy?.type === 'string' ? payload.sandbox_policy.type : undefined,
151
+ };
152
+ }
153
+ }
154
+ const window = payload.info?.model_context_window;
155
+ if (entry.type === 'event_msg' && payload.type === 'token_count' && typeof window === 'number') used.context = window;
156
+ }
157
+ return used;
158
+ }
package/src/skills.js CHANGED
@@ -3,25 +3,30 @@
3
3
  //
4
4
  // Linked, never copied, in both directions. Inwards: what lands in the bot home
5
5
  // is a symlink to the skill's own directory, so a kit skill is read where the
6
- // package installed it and an edit to a skill is what a running session reads —
7
- // both harnesses follow a symlinked skill folder and pick a change up without a
8
- // restart (tech notes, sections 2 and 3). Outwards: the kit takes away only the
9
- // links it put there, and never the skill one was pointing at.
6
+ // package installed it, and both harnesses follow the link. A running session
7
+ // reads an edited skill the next time it loads it. A skill linked in or taken
8
+ // away reaches it without a restart. Claude Code takes it through its own
9
+ // `/reload-skills`, which `obk skills build` types into the session's tab.
10
+ // Codex has no such command and takes it at the start of its next turn (tech
11
+ // notes, sections 2 and 3). Outwards: the kit takes away only the links it put
12
+ // there, and never the skill one was pointing at.
10
13
  //
11
14
  // An entry names one of four shelves: the kit's own (`kit:<name>`), an online
12
15
  // source `skills.yaml` lists (`<source>:<name>`, read from its clone beside the
13
16
  // bots folder), the user's common folder (a bare name), or a path to anywhere
14
17
  // on disk.
15
18
 
16
- import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
19
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
17
20
  import os from 'node:os';
18
21
  import path from 'node:path';
19
22
  import { fileURLToPath } from 'node:url';
20
23
 
21
24
  import { parse, parseDocument, stringify } from 'yaml';
22
25
 
26
+ import { bookFile, readBook } from './book.js';
23
27
  import { botDir, botNames, changesExactly, leadsOutside, readBot, YAML_OUT } from './bot.js';
24
- import { ownCli, shellWord } from './launch.js';
28
+ import { harnessOf, ownCli, shellWord } from './launch.js';
29
+ import { orcaCli, orcaTrouble, tabToTypeInto, typeIntoTab } from './orca.js';
25
30
  import { listIn } from './rules.js';
26
31
  import { cloneDir, isCloned, readSources, skillsIn, wrongClone } from './sources.js';
27
32
 
@@ -130,7 +135,7 @@ export function removeSkill(bots, bot, ref) {
130
135
 
131
136
  /**
132
137
  * Give one bot the skills its lists name. Returns what there is to report:
133
- * `{ bot, skills, removed }` for a bot whose links are in place, and
138
+ * `{ bot, skills, linked, removed }` for a bot whose links are in place, and
134
139
  * `{ bot, skills, trouble }` for one whose list the kit could not follow.
135
140
  *
136
141
  * A list with one entry the kit cannot follow links nothing at all: half a
@@ -210,6 +215,7 @@ export function linkSkills(bots, home, bot) {
210
215
  // something to throw through `prepareBots` and stop the fleet. What was
211
216
  // linked before it went wrong is still written down, so the next run knows it
212
217
  // for the kit's own.
218
+ const linked = [];
213
219
  const removed = [];
214
220
  try {
215
221
  for (const harness of Object.keys(SKILL_DIRS)) {
@@ -218,8 +224,8 @@ export function linkSkills(bots, home, bot) {
218
224
  held.set(harness, heldIn(path.join(home, SKILL_DIRS[harness]), record[harness]));
219
225
  continue;
220
226
  }
221
- const done = link(path.join(home, SKILL_DIRS[harness]), wanted, record[harness]);
222
- removed.push(...done.removed);
227
+ // Filled as it goes, so what was changed before a failure is still known.
228
+ const done = link(path.join(home, SKILL_DIRS[harness]), wanted, record[harness], linked, removed);
223
229
  held.set(harness, done.held);
224
230
  }
225
231
  } catch (error) {
@@ -232,6 +238,9 @@ export function linkSkills(bots, home, bot) {
232
238
  return {
233
239
  bot: bot.name,
234
240
  skills: heldBy(home, [], held),
241
+ // What did change before it went wrong, so the sessions it reaches are told.
242
+ linked: [...new Set(linked)].sort(),
243
+ removed: [...new Set(removed)].sort(),
235
244
  trouble: `${bot.name}'s skills could not be linked: ${error.message}. Nothing of yours was changed; put right what is named there, then build again.`,
236
245
  };
237
246
  }
@@ -243,6 +252,7 @@ export function linkSkills(bots, home, bot) {
243
252
  return {
244
253
  bot: bot.name,
245
254
  skills,
255
+ linked: [...new Set(linked)].sort(),
246
256
  removed: [...new Set(removed)].sort(),
247
257
  trouble: [...away].map(([harness, real]) => `${path.join(home, SKILL_DIRS[harness])} leads outside the bot folder, to ${real}, through a link, and the kit links skills only inside the bot folder, so ${harness} does not get the skills the lists name. Replace the link with a directory of the bot's own, then build again.`).join(' '),
248
258
  };
@@ -251,6 +261,7 @@ export function linkSkills(bots, home, bot) {
251
261
  return {
252
262
  bot: bot.name,
253
263
  skills,
264
+ linked: [...new Set(linked)].sort(),
254
265
  removed: [...new Set(removed)].sort(),
255
266
  // A name the lists ask for that the bot has something else under. The user's
256
267
  // own is never written over, so what they have to know is that the list did
@@ -391,7 +402,8 @@ function namesIn(dir) {
391
402
 
392
403
  /**
393
404
  * Give every bot in the folder its skills, or the one named, in name order.
394
- * This is the whole of `obk skills build`: it writes links and asks Orca nothing.
405
+ * This is the whole of `obk skills build`: it writes links, and a bot whose
406
+ * links changed has its running sessions told (`tellSessions`).
395
407
  */
396
408
  export function buildSkills(bots, { bot: onlyBot } = {}) {
397
409
  const names = botNames(bots);
@@ -401,11 +413,89 @@ export function buildSkills(bots, { bot: onlyBot } = {}) {
401
413
 
402
414
  return (onlyBot === undefined ? names : [onlyBot]).map((name) => {
403
415
  const home = botDir(bots, name);
416
+ let bot;
417
+ let entry;
404
418
  try {
405
- return linkSkills(bots, home, readBot(home, name));
419
+ bot = readBot(home, name);
420
+ entry = linkSkills(bots, home, bot);
406
421
  } catch (error) {
407
422
  return { bot: name, skills: [], trouble: error.message };
408
423
  }
424
+ const changed = (entry.linked?.length ?? 0) + (entry.removed?.length ?? 0) > 0;
425
+ return changed ? { ...entry, sessions: tellSessions(home, bot, entry.linked) } : entry;
426
+ });
427
+ }
428
+
429
+ /** How long Orca is given to say what is in a session's tab. */
430
+ const LOOK_MS = 2000;
431
+
432
+ /**
433
+ * Tell each session of a bot whose skill links just changed, through its
434
+ * harness's own means (PRD 6.5: changes reach running sessions without a
435
+ * restart, and the sessions a change affects are told). Returns one entry per
436
+ * session, in the bot's order: `{ session, harness, state }`, where the state
437
+ * is `reloaded`, `next-turn` (with `read`), `not-up`, `blocked` (with
438
+ * `blocked`) or `unknown` (with `trouble`).
439
+ *
440
+ * Claude Code gets `/reload-skills` typed into its tab, through the same gate
441
+ * as the mail nudge. A busy session queues it and runs it as a command when
442
+ * its turn ends, so it never reaches the model as text. Codex has no reload
443
+ * command and takes the change at the start of its next turn by itself, so
444
+ * nothing is typed, and the caller is given the SKILL.md of each skill linked
445
+ * in, for the session to read if the skill has not appeared (tech notes,
446
+ * sections 2 and 3). Nothing is ever restarted.
447
+ */
448
+ function tellSessions(home, bot, linked) {
449
+ let book;
450
+ try {
451
+ book = readBook(home).sessions;
452
+ } catch (error) {
453
+ // The links are made; which tab is whose is what cannot be known.
454
+ const trouble = `${bookFile(home)} cannot be read (${error.message}), so the kit could not tell which tab is this session's, and nothing was typed`;
455
+ return bot.sessions.map((session) => ({ session: session.name, harness: harnessOf(session, bot.harness), state: 'unknown', trouble }));
456
+ }
457
+ // Resolved, because Orca is asked about this folder by path and does not
458
+ // follow a link to it (tech notes, section 1).
459
+ let real;
460
+ try {
461
+ real = realpathSync(home);
462
+ } catch {
463
+ real = home;
464
+ }
465
+ // An Orca whose runtime is not there still answers some commands from what
466
+ // it last knew, so it is asked whether it is up before it is asked anything
467
+ // about a tab.
468
+ const down = bot.sessions.some((session) => typeof book[session.name]?.tab === 'string') ? orcaTrouble() : undefined;
469
+
470
+ return bot.sessions.map((session) => {
471
+ const harness = harnessOf(session, bot.harness);
472
+ const told = { session: session.name, harness };
473
+ const tab = typeof book[session.name]?.tab === 'string' ? book[session.name].tab : undefined;
474
+ if (tab === undefined) return { ...told, state: 'not-up' };
475
+ if (down !== undefined) return { ...told, state: 'unknown', trouble: `Orca is not answering at ${orcaCli()}, so the kit could not look at its tab, and nothing was typed` };
476
+ try {
477
+ const found = tabToTypeInto(real, tab, LOOK_MS);
478
+ if (found.blocked !== undefined) return { ...told, state: 'blocked', blocked: found.blocked };
479
+ if (found.unsure !== undefined) return { ...told, state: 'unknown', trouble: found.unsure };
480
+ if (found.handle === undefined) return { ...told, state: 'not-up' };
481
+ // The book's tab, now running the other harness: its reload is not this one's.
482
+ if (found.agent !== harness) {
483
+ return { ...told, state: 'unknown', trouble: `Orca names ${found.agent} in its tab, not ${harness}, so nothing was typed` };
484
+ }
485
+
486
+ if (harness === 'codex') {
487
+ // Only what Codex can actually read: a link can fail for one harness
488
+ // alone, and a skills directory that is the user's gets none.
489
+ const read = linked.map((name) => path.join(home, SKILL_DIRS.codex, name, MANIFEST)).filter((file) => existsSync(file));
490
+ return { ...told, state: 'next-turn', read };
491
+ }
492
+ typeIntoTab(found.handle, '/reload-skills');
493
+ return { ...told, state: 'reloaded' };
494
+ } catch (error) {
495
+ // The links are made whatever Orca says, so this is reported, not
496
+ // thrown: the build did what it was asked, and the session was not told.
497
+ return { ...told, state: 'unknown', trouble: error.message };
498
+ }
409
499
  });
410
500
  }
411
501
 
@@ -489,7 +579,9 @@ function follow(bots, ref, sources) {
489
579
 
490
580
  /**
491
581
  * Make one harness's skills directory hold what `wanted` says, as far as the
492
- * kit is allowed to. Returns `{ removed, held }` — the names it took away, and
582
+ * kit is allowed to. Returns `{ linked, removed, held }` — the names it linked
583
+ * in or repointed, the names it took away (added to the lists given, as it
584
+ * goes, so a caller whose run throws still has them), and
493
585
  * what is in the directory afterwards.
494
586
  *
495
587
  * The kit acts only where the entry on disk is still the link it wrote, which
@@ -497,12 +589,10 @@ function follow(bots, ref, sources) {
497
589
  * link the very same skill by hand — so the kit remembers what it made rather
498
590
  * than guessing from where a link points (PRD 6.7).
499
591
  */
500
- function link(dir, wanted, record) {
592
+ function link(dir, wanted, record, linked = [], removed = []) {
501
593
  // The directory is there: `linkSkills` makes both before it calls this.
502
594
 
503
595
  const keep = new Map(wanted.map((skill) => [skill.name, skill]));
504
- const removed = [];
505
-
506
596
  // What the lists no longer name. The kit takes back only its own, and only
507
597
  // while it is untouched; anything else it forgets about and leaves.
508
598
  for (const name of Object.keys(record)) {
@@ -541,9 +631,10 @@ function link(dir, wanted, record) {
541
631
  // repo, and the link has to reach out of the repo to find it (ADR 0014).
542
632
  symlinkSync(skill.dir, at);
543
633
  record[skill.name] = skill.dir;
634
+ linked.push(skill.name);
544
635
  }
545
636
 
546
- return { removed, held: heldIn(dir, record) };
637
+ return { linked, removed, held: heldIn(dir, record) };
547
638
  }
548
639
 
549
640
  /**
package/src/up.js CHANGED
@@ -6,14 +6,15 @@
6
6
 
7
7
  import { existsSync, mkdirSync, realpathSync, writeFileSync } from 'node:fs';
8
8
  import path from 'node:path';
9
+ import { setTimeout as pause } from 'node:timers/promises';
9
10
 
10
11
  import { forgetClaimed, readBook, sessionIdsIn, tabIdsIn, updateBook, withUnclaimed } from './book.js';
11
12
  import { botDir, botNames, displayName, readBot } from './bot.js';
12
- import { conversationsIn } from './conversations.js';
13
+ import { conversationsIn, heldAsUserTurn, transcriptsIn } from './conversations.js';
13
14
  import { installHook } from './hooks.js';
14
15
  import { addressOf, harnessOf, isShortPrompt, launchCommand, reachesMail, sessionTrouble, startPrompt, workDirOf } from './launch.js';
15
- import { asFolderProject, findProject, harnessInTab, makeMailbox, makeProject, openTab, retitleTab, tabs, typeIntoTab, useMailbox } from './orca.js';
16
- import { buildAgents } from './rules.js';
16
+ import { asFolderProject, findProject, harnessInTab, makeMailbox, makeProject, openTab, retitleTab, tabs, tellWindow, typeIntoTab, useMailbox } from './orca.js';
17
+ import { buildAgents, rulesStamp } from './rules.js';
17
18
  import { linkSkills } from './skills.js';
18
19
 
19
20
  /** The one bot with a tab beside its sessions: the ops tab (PRD 6.2). */
@@ -35,6 +36,7 @@ const SECOND_LOOK_MS = 2000;
35
36
  * Bring bots up in Orca. Returns `{ tabs, rules }`: one tab entry per tab it
36
37
  * looked at — the sessions the book knows, and, for Bot Father, whatever else
37
38
  * is open in its project — and one rules entry and one skills entry per bot.
39
+ * `projects` has one entry per Orca project this run made or renamed.
38
40
  *
39
41
  * With no name it is every bot in the folder, in name order. `bot` brings up
40
42
  * one bot and `session` one of its sessions, for a caller that wants one thing
@@ -58,11 +60,14 @@ export async function bringUp(bots, { bot: onlyBot, session: onlySession } = {})
58
60
  const { running, rules, skills } = prepareBots(bots, names, onlySession);
59
61
 
60
62
  const report = [];
63
+ const projects = [];
61
64
  for (const { bot, home } of running) {
62
65
  for (const session of sessionsOf(bot, onlySession)) {
63
66
  if (session.paused === true) paused.push({ bot: bot.name, session: session.name });
64
67
  }
65
- report.push(...await bringUpBot(bots, home, bot, onlySession));
68
+ const up = await bringUpBot(bots, home, bot, onlySession);
69
+ report.push(...up.tabs);
70
+ if (up.project !== undefined) projects.push(up.project);
66
71
  }
67
72
 
68
73
  // A bot whose rules would not build is reported here rather than in the
@@ -78,6 +83,7 @@ export async function bringUp(bots, { bot: onlyBot, session: onlySession } = {})
78
83
  })),
79
84
  skills,
80
85
  paused,
86
+ projects,
81
87
  };
82
88
  }
83
89
 
@@ -197,8 +203,11 @@ async function bringUpBot(bots, home, bot, onlySession) {
197
203
  // Orca is asked first and the book is written after: nothing that takes time
198
204
  // happens while the book is held, because a session's own hook may be writing
199
205
  // its id into that same file at any moment.
200
- const orca = orcaProject(home, title);
206
+ const { change, ...orca } = orcaProject(home, title);
201
207
  await updateBook(home, (book) => { book.orca = orca; });
208
+ // Orca's window does not see a project made or renamed on the command line
209
+ // until it is told (#224).
210
+ if (change !== undefined) tellWindow(orca.project);
202
211
 
203
212
  const live = new Map(tabs(home).map((tab) => [tab.tabId, tab]));
204
213
  const report = [];
@@ -217,7 +226,7 @@ async function bringUpBot(bots, home, bot, onlySession) {
217
226
  : spare.map((tab) => entry(tab, { bot: name, name: null, created: false }))));
218
227
  }
219
228
 
220
- return report;
229
+ return { tabs: report, project: change === undefined ? undefined : { bot: name, project: orca.project, change } };
221
230
  }
222
231
 
223
232
  async function bringUpSession(bots, home, live, session, bot, title) {
@@ -292,12 +301,17 @@ async function bringUpSession(bots, home, live, session, bot, title) {
292
301
  // started a harness in it, and a conversation taken over from the harness's
293
302
  // own record are new.
294
303
  const launched = new Date().toISOString();
304
+ // Which instructions it is about to read: the AGENTS.md this run has just
305
+ // built, noted so that health can say when the file moves on and the session
306
+ // does not (#272).
307
+ const rules = rulesStamp(home);
295
308
  await updateBook(home, (current) => {
296
309
  // What the harness has in this folder that nobody claims goes on the record,
297
310
  // for a person or Bot Father to settle — added to whatever was already noted,
298
311
  // because this run's scan cannot see what an earlier one found. The kit never
299
312
  // settles it itself.
300
- const entry = { ...current.sessions[session.name], tab: made.tabId, launched };
313
+ const entry = { ...current.sessions[session.name], tab: made.tabId, launched, rules };
314
+ if (rules === undefined) delete entry.rules;
301
315
  current.sessions[session.name] = withUnclaimed(entry, which.unclaimed ?? []);
302
316
  forgetClaimed(current);
303
317
  });
@@ -326,24 +340,54 @@ async function bringUpSession(bots, home, live, session, bot, title) {
326
340
  const tui = first.running ? lookFor(made.handle, SECOND_LOOK_MS) : first;
327
341
 
328
342
  // The start prompt went in with that line, as the harness's own prompt
329
- // argument, so it is the harness that holds it until it is ready — through
330
- // its folder-trust question and its update offer. What is still worth saying
331
- // is whether the line took at all: a shell that swallowed it swallowed the
332
- // duty with it, and nobody has been told anything.
333
- const promptSent = prompt === undefined ? undefined : tui.running === true;
343
+ // argument, and a harness running in the tab is not yet a session told its
344
+ // duty: one held on a first-run screen, or one that refused the argument, has
345
+ // been told nothing. So it is received only when the session's own record
346
+ // holds it as a user turn, and otherwise not confirmed (#274).
347
+ const promptReceived = prompt === undefined
348
+ ? undefined
349
+ : await heldInRecord(home, session.name, harness, prompt, { launched, running: tui.running === true });
334
350
 
335
351
  return entry(made, {
336
352
  bot: bot.name,
337
353
  name: session.name,
338
354
  created: true,
339
355
  ...tui,
340
- promptSent,
356
+ promptReceived,
341
357
  promptFile,
342
358
  resumed: resume !== undefined,
343
359
  unclaimed: which.unclaimed,
344
360
  });
345
361
  }
346
362
 
363
+ /**
364
+ * How long a running harness's own record is given to show the start prompt.
365
+ * Seen live (Claude Code 2.1.282, a trusted folder): the hook's id and the
366
+ * user turn landed about as the second look ended, so one read there comes a
367
+ * moment too early. A harness held on a first-run screen costs the whole wait.
368
+ */
369
+ const RECORD_MS = 5000;
370
+ const RECORD_ASK_MS = 250;
371
+
372
+ /**
373
+ * Whether the conversation the book names for this session holds `prompt` as a
374
+ * user turn in the harness's own record. The name comes from the session's own
375
+ * hook, so a harness that has not reported one — held on a first-run screen,
376
+ * say — has no record to be read, and the answer is that it is not confirmed.
377
+ * Another conversation in the same folder is never asked: it may be a sister
378
+ * session told the same words.
379
+ */
380
+ async function heldInRecord(home, name, harness, prompt, { launched, running }) {
381
+ const until = Date.now() + (running ? RECORD_MS : 0);
382
+ for (;;) {
383
+ const id = readBook(home).sessions[name]?.session;
384
+ const record = typeof id === 'string' ? transcriptsIn(harness, home, launched).find((one) => one.id === id) : undefined;
385
+ if (record !== undefined && heldAsUserTurn(harness, record.file, prompt)) return true;
386
+ if (Date.now() >= until) return false;
387
+ await pause(RECORD_ASK_MS);
388
+ }
389
+ }
390
+
347
391
  /**
348
392
  * One look at a tab the kit just typed a launch line into: whether a harness
349
393
  * is running there, and what it is waiting on if Orca says.
@@ -455,7 +499,8 @@ function whichConversation(book, home, bot, session, was, harness) {
455
499
  }
456
500
 
457
501
  /**
458
- * A bot's Orca project, made if it is not there yet.
502
+ * A bot's Orca project, made if it is not there yet, with `change` saying
503
+ * whether this run `made` it, `renamed` it or left it as it was (undefined).
459
504
  *
460
505
  * It has to be a folder workspace. Registering a folder that sits inside the
461
506
  * bots repo gets it recorded as a git one, which Orca gives no workspace at
@@ -463,12 +508,12 @@ function whichConversation(book, home, bot, session, was, harness) {
463
508
  */
464
509
  function orcaProject(home, title) {
465
510
  const found = findProject(home);
466
- const setup = found === undefined
467
- ? makeProject(home, title)
468
- : (found.kind === 'folder' ? found : asFolderProject(found.id, title));
469
- return { project: setup.projectId, setup: setup.id };
511
+ if (found === undefined) return ids(makeProject(home, title), 'made');
512
+ return found.kind === 'folder' ? ids(found) : ids(asFolderProject(found.id, title), 'renamed');
470
513
  }
471
514
 
515
+ const ids = (setup, change) => ({ project: setup.projectId, setup: setup.id, change });
516
+
472
517
  /**
473
518
  * Where the kit leaves a prompt for the launch line to pick up: a folder of its
474
519
  * own beside the bots repo, the way skill-source clones sit beside it (PRD 6.3)
@@ -481,7 +526,7 @@ function orcaProject(home, title) {
481
526
  export const promptPath = (bots, bot, session) =>
482
527
  path.join(`${bots}.prompts`, `${encodeURIComponent(bot)}.${encodeURIComponent(session)}.txt`);
483
528
 
484
- function entry(tab, { bot, name, created, running = false, blockedReason, promptSent, promptFile, resumed, unclaimed }) {
529
+ function entry(tab, { bot, name, created, running = false, blockedReason, promptReceived, promptFile, resumed, unclaimed }) {
485
530
  const made = { bot, name, title: tab.title, tabId: tab.tabId, terminal: tab.handle, created, harnessStarted: running };
486
531
  // Whether this run picked the session up where it was or started a new one.
487
532
  // Only for a tab this run opened: a tab that was already there was left alone.
@@ -493,7 +538,7 @@ function entry(tab, { bot, name, created, running = false, blockedReason, prompt
493
538
  // claims: the caller is told, because the kit will not pick one.
494
539
  if (unclaimed !== undefined && unclaimed.length > 0) made.unclaimed = unclaimed;
495
540
  // Only for a session this run started that had something to be told.
496
- if (promptSent !== undefined) made.promptSent = promptSent;
541
+ if (promptReceived !== undefined) made.promptReceived = promptReceived;
497
542
  // And the file it was told it out of, when it was too long for the line.
498
543
  if (promptFile !== undefined) made.promptFile = promptFile;
499
544
  return made;