@assuredloop/orca-bot-kit 0.6.0 → 0.7.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 CHANGED
@@ -340,7 +340,7 @@ with why that one ended and when
340
340
 
341
341
  The book stays true to the harness through a hook `obk up` puts in the bot's own
342
342
  folder — `.claude/settings.json` or `.codex/hooks.json`, never your user-level
343
- settings ([ADR 0020](docs/adr/0020-kit-hooks-live-in-the-bot-folder.md)). Your own
343
+ settings ([ADR 0022](docs/adr/0022-kit-hooks-live-in-the-bot-folder.md)). Your own
344
344
  settings in those files are kept. Codex asks you to trust a hooks file the first
345
345
  time it sees one; answer its question in the tab.
346
346
 
@@ -545,6 +545,7 @@ failing quietly.
545
545
  npm test # the whole suite, in a few minutes
546
546
  npm run test:system # what the system tests would drive, and nothing else
547
547
  npm run test:system -- --yes # drive them, on this machine, for real
548
+ npm run test:system -- --yes test/system/groom.test.js # only the ones you name
548
549
  npm run mutate # the mutation audit, on what your branch changed
549
550
  ```
550
551
 
@@ -570,7 +571,9 @@ which Orca, which files, and what they will do to it — and then stops without
570
571
  driving any of it, answering non-zero so that a run which did not run them
571
572
  cannot be read as one that passed. `-- --yes` is how you say you meant it. When
572
573
  Orca is not answering, the command says which Orca it asked and skips, rather
573
- than report a kit that is not broken.
574
+ than report a kit that is not broken. Name files after it to run only those;
575
+ each must be one of the `*.test.js` files under `test/system/`, links followed,
576
+ and a name that is not is refused before anything runs.
574
577
 
575
578
  `npm run mutate` runs [StrykerJS](https://stryker-mutator.io) over the
576
579
  JavaScript this branch changed against `main` — committed, still in the working
package/SETUP.md CHANGED
@@ -102,8 +102,9 @@ Ask both in one message, then wait.
102
102
 
103
103
  **Where the bots folder goes.** An absolute path. It becomes a git repository of
104
104
  theirs holding every bot's configuration: charters, rules, session settings, the
105
- book of sessions. It holds none of the kit's code. They may push it somewhere
106
- later; the kit never commits for them.
105
+ book of sessions. It holds none of the kit's code. The bots commit their own
106
+ changes to it; the kit's commands never commit, and pushing it somewhere is
107
+ theirs to decide.
107
108
 
108
109
  **Which harness Bot Father runs on**, `claude` or `codex`. There is no default
109
110
  and the kit will not guess. It is Bot Father's own harness only; each bot they
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assuredloop/orca-bot-kit",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,18 @@
1
+ ---
2
+ name: commit
3
+ title: Commit your changes
4
+ applies: all
5
+ ---
6
+
7
+ Commit your own changes to the bots repo often, as each piece of work there is
8
+ done: what you changed, in whatever folder it is, and what the kit wrote in
9
+ your own bot's folder (its files there and your book of sessions). Stage each
10
+ file by name and name them in the commit too (`git commit -- <files>`), so
11
+ nothing another session staged goes in with yours. What no bot owns (init's
12
+ files, the defaults, the user's rules and skill lists) is Bot Father's to
13
+ commit. Commit yours even when the repo has no commit yet or holds files that
14
+ are not yours; leave those to their owner.
15
+
16
+ Committing is not pushing: pushing is the user's call. A clone in your work dir
17
+ follows its own repo's rules. When a commit fails, for example on another
18
+ session's index lock, try again, and say so if it still fails.
@@ -214,8 +214,12 @@ index load at every start. Codex's memories belong to the user and are shared
214
214
  by every Codex session, so a Codex bot cannot have its own; say so before
215
215
  turning them on for one. It is a hand edit, and say that you made it.
216
216
 
217
- After each change, commit what it changed in the bots folder; the kit never
218
- commits by itself.
217
+ After each change, commit what it changed in the bots folder, naming each
218
+ file when you stage it and again in the commit. Commit with it what no bot
219
+ owns and nobody has committed yet: `init`'s files, `defaults.yaml`,
220
+ `skills.yaml`, and the user's `rules/` and `skills/`. What it changed
221
+ includes the folder of a bot it made or changed. The kit never commits by
222
+ itself.
219
223
 
220
224
  Do the reversible part and stop at the line. Making a bot, adding a session
221
225
  and linking skills add things and are safe to run again; closing a tab,
@@ -251,6 +251,7 @@ identifiers, counts and paths that only show the work; where evidence matters,
251
251
  say where it is.
252
252
 
253
253
  Say what you did not look at. Where you changed anything in the bots folder,
254
- commit it; the kit never commits by itself.
254
+ commit it, naming each file when you stage it and again in the commit; the
255
+ kit never commits by itself.
255
256
 
256
257
  Sources and licences: [NOTICE.md](NOTICE.md).
@@ -63,7 +63,8 @@ them says so and reads less, not everything.
63
63
  The open findings are what stops the same thing being reported every day:
64
64
  before filing an issue or sending feedback, look there, and where it is already
65
65
  open, add to it rather than raising it again. Take a finding off when it is
66
- fixed or no longer true. Commit these once a day, at the end of the run.
66
+ fixed or no longer true. Commit these at the end of each run, naming each
67
+ file when you stage it and again in the commit.
67
68
 
68
69
  ## Keep the run bounded
69
70
 
package/src/bot.js CHANGED
@@ -59,7 +59,7 @@ export function botNames(bots) {
59
59
  * link the user made takes it out of that folder; undefined when it stays in.
60
60
  *
61
61
  * The kit writes only in the bot folder, never in user-level settings (PRD 6.3,
62
- * ADR 0020), and a link would make the one the other without a word. So the
62
+ * ADR 0022), and a link would make the one the other without a word. So the
63
63
  * answer is the file system's, not the spelling's: see `whereItLeads`.
64
64
  */
65
65
  export function leadsOutside(home, target) {
package/src/cli.js CHANGED
@@ -414,7 +414,7 @@ const RECORD = 'session record';
414
414
  * A hook runs inside the user's own session, so this one stays out of the way:
415
415
  * it writes on standard output only what the harness is to read as JSON, and
416
416
  * whatever goes wrong, it goes wrong quietly. A book left stale is a thing the
417
- * health check finds later; a session disturbed is the user's work (ADR 0020).
417
+ * health check finds later; a session disturbed is the user's work (ADR 0022).
418
418
  */
419
419
  async function record(bots, bot) {
420
420
  try {
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/orca.js CHANGED
@@ -346,8 +346,8 @@ const psCli = () => process.env.OBK_PS || '/bin/ps';
346
346
 
347
347
  /**
348
348
  * Who holds the terminal of the pane `ptyId`: `{ front: 'shell' }`,
349
- * `{ front: 'program', command }` with the name of the process leading the
350
- * group in front, or `{ unreadable: <why> }`.
349
+ * `{ front: 'program', command, pid }` with the name and pid of the process
350
+ * leading the group in front, or `{ unreadable: <why> }`.
351
351
  *
352
352
  * Orca gives the pane's pid in `diagnostics memory` and nowhere else, and `ps`
353
353
  * gives that pid's terminal's foreground process group (ADR 0021).
@@ -374,7 +374,20 @@ function frontOf(ptyId) {
374
374
  const front = psLine(own.tpgid);
375
375
  if (front === undefined) return { unreadable: `ps could not read the process in front, pid ${own.tpgid}` };
376
376
  const shell = front.ppid === pane && path.basename(own.comm) === 'login';
377
- return shell ? { front: 'shell' } : { front: 'program', command: path.basename(front.comm) };
377
+ return shell ? { front: 'shell' } : { front: 'program', command: path.basename(front.comm), pid: own.tpgid };
378
+ }
379
+
380
+ /**
381
+ * The process `pid` as `ps -E` gives it, word by word: its command, its
382
+ * arguments, then its environment as `NAME=value` words. Undefined when it
383
+ * cannot be read. macOS gives a same-user harness's environment this way (tech
384
+ * notes, section 1); nothing in it says where the arguments end, so a caller
385
+ * looks for one whole word it knows.
386
+ */
387
+ export function wordsOfProcess(pid) {
388
+ const asked = spawnSync(psCli(), ['-E', '-ww', '-o', 'command=', '-p', String(pid)], { encoding: 'utf8' });
389
+ if (asked.error || asked.status !== 0) return undefined;
390
+ return asked.stdout.trim().split(/\s+/);
378
391
  }
379
392
 
380
393
  /** One process as `ps` gives it, read only: `{ ppid, tpgid, comm }`, or undefined. */
package/src/record.js CHANGED
@@ -166,22 +166,26 @@ function unclaimedFor(book, home, bot, tabId, id) {
166
166
  }
167
167
 
168
168
  /**
169
- * Whether the harness that ran this hook is the session's own — the one the kit
170
- * typed into the tab — rather than a harness started inside the session.
169
+ * Whether the harness that ran this hook is the session's own — the one the
170
+ * tab's shell started — rather than a harness started inside the session.
171
171
  *
172
172
  * Proved live on both harnesses (tech notes, sections 2 and 3): a hook's process
173
173
  * is the harness's own child, or the child of the shell the harness ran the hook
174
174
  * command through. Either way the harness itself is the process just above, and
175
- * the session's harness is the child of the shell named in the launch line. A
176
- * harness started inside the session sits further down a longer chain, under
177
- * whatever shell the agent ran it from, so it can never answer this.
175
+ * the session's harness is the child of the tab's shell. A harness started
176
+ * inside the session sits further down a longer chain, under whatever shell the
177
+ * agent ran it from, so it can never answer this.
178
+ *
179
+ * The launch line names the tab's shell. A tab Orca brought back by itself has
180
+ * no launch line (#318), and there the tab's shell is the one `login` started
181
+ * for the pane, which is where Orca's own resume puts the harness too (tech
182
+ * notes, section 1).
178
183
  *
179
184
  * Anything it cannot establish is a no: a report that cannot be shown to be the
180
185
  * session's own is not written down.
181
186
  */
182
187
  export function startedTheSession(shellPid, pid = process.pid) {
183
- if (!/^[0-9]+$/.test(String(shellPid ?? ''))) return false;
184
- const shell = Number(shellPid);
188
+ if (shellPid !== undefined && !/^[0-9]+$/.test(String(shellPid))) return false;
185
189
 
186
190
  const tree = processTree();
187
191
  if (tree === undefined) return false;
@@ -190,11 +194,21 @@ export function startedTheSession(shellPid, pid = process.pid) {
190
194
  const parent = mine === undefined ? undefined : tree.get(mine.ppid);
191
195
  if (parent === undefined) return false;
192
196
 
193
- if (parent.ppid === shell) return true;
194
- if (!isShell(parent.command)) return false;
197
+ if (shellPid !== undefined) {
198
+ const shell = Number(shellPid);
199
+ if (parent.ppid === shell) return true;
200
+ if (!isShell(parent.command)) return false;
201
+
202
+ const above = tree.get(parent.ppid);
203
+ return above !== undefined && above.ppid === shell;
204
+ }
195
205
 
196
- const above = tree.get(parent.ppid);
197
- return above !== undefined && above.ppid === shell;
206
+ // With no shell named, which process is the harness has to be settled first:
207
+ // the one above the shell the hook ran through, or the hook's own parent.
208
+ const harness = isShell(parent.command) ? tree.get(parent.ppid) : parent;
209
+ const shell = harness === undefined ? undefined : tree.get(harness.ppid);
210
+ const pane = shell === undefined ? undefined : tree.get(shell.ppid);
211
+ return pane !== undefined && path.basename(pane.command) === 'login';
198
212
  }
199
213
 
200
214
  /** Every process on the machine, by pid: who its parent is and what it runs. */
package/src/skills.js CHANGED
@@ -292,7 +292,7 @@ export function unlinkSkills(home) {
292
292
  * user made, or one of the kit's that they have since repointed, is theirs.
293
293
  *
294
294
  * It is the kit's own file and lives beside the hook files it already keeps in
295
- * the bot folder (ADR 0020). A record that cannot be read is treated as an
295
+ * the bot folder (ADR 0022). A record that cannot be read is treated as an
296
296
  * empty one: the worst that follows is that the kit leaves alone something it
297
297
  * would otherwise have tidied up, which is the safe way round.
298
298
  */
package/src/up.js CHANGED
@@ -94,7 +94,7 @@ export async function bringUp(bots, { bot: onlyBot, session: onlySession } = {})
94
94
  *
95
95
  * Separate from the tabs because the order matters twice over. A session reads
96
96
  * its rules, its skills and its hooks as it comes up, so all three have to be
97
- * there before the tab is (PRD 6.6, ADR 0020) — and a restart closes a tab in
97
+ * there before the tab is (PRD 6.6, ADR 0022) — and a restart closes a tab in
98
98
  * between, so everything that can refuse must have refused before that. It
99
99
  * refuses by throwing, exactly as `up` always has; a bot it leaves out of
100
100
  * `running` is one whose sessions must not be started.
@@ -140,7 +140,7 @@ export function prepareBots(bots, names, onlySession) {
140
140
 
141
141
  // And the kit's hook goes into every bot folder before Orca is asked for
142
142
  // anything, for the same reason: a harness reads its hooks when it comes up,
143
- // so one written later would miss the session it was written for (ADR 0020),
143
+ // so one written later would miss the session it was written for (ADR 0022),
144
144
  // and a bot folder the kit cannot write it into stops the run with nothing
145
145
  // opened anywhere. Only the harnesses a bot actually runs on; a bot with no
146
146
  // sessions gets none.