@assuredloop/orca-bot-kit 0.11.0 → 0.12.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
@@ -587,10 +587,11 @@ The tests come in two layers, and every test file belongs to one of them.
587
587
 
588
588
  `npm test` is `test/*.test.js`: unit tests and end-to-end runs of the CLI in a
589
589
  throwaway folder, with a fake `orca` on PATH. It needs nothing but Node, so
590
- [GitHub Actions](.github/workflows/ci.yml) runs it on every pull request and on
591
- every push to `main`: on Node 25.8.0, the current line, and again on 24.21.0,
592
- the floor `engines.node` promises users, with only the kit's own dependencies
593
- installed.
590
+ [GitHub Actions](.github/workflows/ci.yml) runs it on every pull request, split
591
+ into shards that run side by side, on Node 25.8.0, the current line. A release
592
+ runs every shard on 25.8.0 and again on 24.21.0, the floor `engines.node`
593
+ promises users, with only the kit's own dependencies installed, before anything
594
+ is published.
594
595
 
595
596
  `npm run test:system` is `test/system/*.test.js`: the real `obk` against the
596
597
  real Orca and the real harnesses on your own machine. No CI runner can do that,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assuredloop/orca-bot-kit",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -24,6 +24,7 @@
24
24
  ],
25
25
  "scripts": {
26
26
  "test": "node --test test/*.test.js",
27
+ "test:shard": "node --test --test-shard=$SHARD test/*.test.js",
27
28
  "test:system": "node scripts/test-system.js",
28
29
  "mutate": "node scripts/mutate.js"
29
30
  },
package/rules/limits.md CHANGED
@@ -18,5 +18,5 @@ When something you were told to use is missing (a model, a tool, a file), say
18
18
  so and ask. Quietly using a different one hides the change.
19
19
 
20
20
  Permission rules in a harness's settings or rules files are the kit's to write,
21
- after the user's yes to the exact rules: `obk bot change --allow <rule>` records
22
- and writes them. Never edit them into such a file yourself, Bot Father included.
21
+ after the user's yes to the exact rules: `obk bot change --allow <rule>` or
22
+ `--disallow <rule>`. Never edit one in or out yourself, Bot Father included.
@@ -226,9 +226,12 @@ such as an `Edit` rule or one exact command.
226
226
  Show the user the list word for word, with a line on what each lets the bot do,
227
227
  and only after their yes run `obk bot change --allow`, once per rule they said
228
228
  yes to. On a no, run nothing. `obk bot change --charter` names the rules the
229
- bot is allowed now; say which of them the new charter no longer grants, and
230
- that they stay allowed until the user takes them out, since the kit takes none
231
- out. Keep
229
+ bot is allowed now; say which of them the new charter no longer grants, show
230
+ each word for word, and ask whether to take it back. Only after their yes run
231
+ `obk bot change --disallow`, once per rule, which takes it out of `bot.yaml`
232
+ and the bot's settings or Codex rules; on a no, run nothing, and it stays
233
+ allowed. A rule the user added to a settings file by hand is theirs to take
234
+ out; the kit refuses it. Keep
232
235
  each rule narrow: a program and what it does. The kit refuses a rule that lets
233
236
  the bot run any command, a program with any arguments, or a shell or
234
237
  interpreter with any arguments; a user who wants one for a bot adds it to that
package/src/book.js CHANGED
@@ -191,12 +191,17 @@ function lockOn(file, waitMs) {
191
191
  * is nobody's to sweep, and the user can see it and delete it.
192
192
  *
193
193
  * One folder per bots folder, so two bots folders each holding an api-bot cannot
194
- * take each other's turn.
194
+ * take each other's turn. Resolved first, as the mailbox's is: one writer reaches
195
+ * the bots folder through a link and another by its real path, and two paths
196
+ * would be two locks for one book (#375).
195
197
  */
196
- const lockFile = (home) => path.join(
197
- `${path.dirname(path.dirname(home))}.locks`,
198
- `${encodeURIComponent(path.basename(home))}.lock`,
199
- );
198
+ const lockFile = (home) => {
199
+ const real = realpathSync(home);
200
+ return path.join(
201
+ `${path.dirname(path.dirname(real))}.locks`,
202
+ `${encodeURIComponent(path.basename(real))}.lock`,
203
+ );
204
+ };
200
205
 
201
206
  /**
202
207
  * One session's turn at its mailbox (#321): held by `session mailbox` for its
package/src/bot.js CHANGED
@@ -254,14 +254,14 @@ export function changeBot(bots, bot, { charter }) {
254
254
  }
255
255
 
256
256
  /**
257
- * The bot's `allow` list as it is, after refusing what `allowRules` would refuse:
258
- * an empty rule, or a list already there that is not a list of rules. Asked
259
- * before anything is written, so a refused `bot change` changes nothing, its
260
- * charter included.
257
+ * The bot's `allow` list as it is, after refusing what `allowRules` or
258
+ * `disallowRules` would refuse: an empty rule, or a list already there that is
259
+ * not a list of rules. Asked before anything is written, so a refused `bot
260
+ * change` changes nothing, its charter included.
261
261
  */
262
- export function allowedNow(bots, bot, rules) {
262
+ export function allowedNow(bots, bot, rules, flag = '--allow') {
263
263
  if (rules.some((rule) => rule.trim() === '')) {
264
- throw new Error('--allow is empty. Give it the exact permission rule the user said yes to, such as Bash(git add:*).');
264
+ throw new Error(`${flag} is empty. Give it the exact permission rule the user said yes to, such as Bash(git add:*).`);
265
265
  }
266
266
  const home = existingBot(bots, bot);
267
267
  const was = readBot(home, bot).allow ?? [];
@@ -289,6 +289,21 @@ export function allowRules(bots, bot, rules) {
289
289
  return { bot, home, allow, added };
290
290
  }
291
291
 
292
+ /**
293
+ * Take the permission rules the user said yes to taking back, `rules`, out of
294
+ * the bot's `allow` (#360). Returns { bot, home, allow, disallowed }: the whole
295
+ * list now, and what this call took out. The caller has refused a rule `allow`
296
+ * does not hold, and takes them out of the harness's files first. With `write`
297
+ * false it only refuses what the edit would refuse, and writes nothing.
298
+ */
299
+ export function disallowRules(bots, bot, rules, { write = true } = {}) {
300
+ const { home, was } = allowedNow(bots, bot, rules, '--disallow');
301
+ const disallowed = [...new Set(rules)];
302
+ const allow = was.filter((rule) => !disallowed.includes(rule));
303
+ editBot(bots, bot, `take back ${disallowed.join(', ')} for ${bot}`, (doc) => doc.set('allow', allow), (before) => ({ ...before, allow }), write);
304
+ return { bot, home, allow, disallowed };
305
+ }
306
+
292
307
  /**
293
308
  * Change the settings of the session `name` of the bot `bot`. Returns
294
309
  * { bot, home, session } — the session as it now reads.
@@ -387,9 +402,10 @@ function editSession(bots, bot, index, session, what) {
387
402
 
388
403
  /**
389
404
  * Make one edit to a bot's `bot.yaml` through the YAML library, and write it
390
- * only if exactly the change `expected` describes is what came out.
405
+ * only if exactly the change `expected` describes is what came out, and
406
+ * `write` is not false.
391
407
  */
392
- function editBot(bots, bot, what, edit, expected) {
408
+ function editBot(bots, bot, what, edit, expected, write = true) {
393
409
  const file = path.join(existingBot(bots, bot), BOT_YAML);
394
410
  const source = readFileSync(file, 'utf8');
395
411
  const doc = parseDocument(source);
@@ -398,7 +414,7 @@ function editBot(bots, bot, what, edit, expected) {
398
414
  if (!changesExactly(source, text, expected)) {
399
415
  throw new Error(`${file} cannot be changed to ${what} without changing something else in it, so nothing was written. Make the change by hand.`);
400
416
  }
401
- if (text !== source) writeFileSync(file, text);
417
+ if (write && text !== source) writeFileSync(file, text);
402
418
  }
403
419
 
404
420
  /** A bot that is there, by its home, or the refusal a caller who named another is owed. */
package/src/cli.js CHANGED
@@ -12,14 +12,14 @@ import path from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { parseArgs } from 'node:util';
14
14
 
15
- import { addSession, allowedNow, allowRules, botDir, changeBot, changeSession, createBot, leadsOutside, readBot, SESSION_FIELDS } from './bot.js';
15
+ import { addSession, allowedNow, allowRules, botDir, changeBot, changeSession, createBot, disallowRules, leadsOutside, readBot, SESSION_FIELDS } from './bot.js';
16
16
  import { addCommand, groomCommand, grooming, upCommand } from './groom.js';
17
17
  import { checkHealth, orcaSettingFindings } from './health.js';
18
18
  import { initBots } from './init.js';
19
19
  import { APPROVALS, HARNESSES, ownCli, shellWord, workDirOf } from './launch.js';
20
20
  import { checkMail, lookUp, noMailboxYet, sendMessage } from './message.js';
21
21
  import { orcaCli, orcaTrouble, RELOAD_LINE } from './orca.js';
22
- import { allowCommand, beyondDefaults, refuseBroad, refuseNoCodexForm, runsOnClaude, runsOnCodex, writePermissions } from './permissions.js';
22
+ import { allowCommand, beyondDefaults, refuseBroad, refuseNoCodexForm, runsOnClaude, runsOnCodex, takeBack, writePermissions } from './permissions.js';
23
23
  import { pauseSessions, unpauseSessions } from './pause.js';
24
24
  import { recordSession, SHELL_ENV, TAB_ENV } from './record.js';
25
25
  import { restartSessions } from './restart.js';
@@ -56,13 +56,17 @@ Usage:
56
56
  as ours: --prompt='- a bullet', and
57
57
  --extra-arg=--search, once per extra argument.
58
58
  obk bot change --bots <path> --bot <bot> [--charter <text>]
59
- [--allow <rule> ...]
59
+ [--allow <rule> ... | --disallow <rule> ...]
60
60
  Give a bot a new charter and rebuild its AGENTS.md.
61
61
  A running session reads it when it next starts.
62
62
  --allow records a permission rule the user said
63
63
  yes to, once per rule, and writes it into the bot's
64
64
  Claude settings. A broad rule, such as Bash(gh:*),
65
65
  is refused and nothing is written.
66
+ --disallow takes back a rule the bot was allowed,
67
+ once the user has said yes to that, from bot.yaml
68
+ and the bot's settings. A rule the user added by
69
+ hand is not the kit's, and is refused.
66
70
  obk session change --bots <path> --bot <bot> --session <session>
67
71
  [--model <m>] [--effort <e>] [--context <c>]
68
72
  [--approval ${APPROVALS.join('|')}]
@@ -184,14 +188,15 @@ Usage:
184
188
  only while its tab is up in Orca.
185
189
  obk usage --bots <path> [--bot <bot>] [--session <name>] [--since <time>] [--until <time>]
186
190
  Say what your sessions have used: the conversations
187
- each one had, their calls and tokens, the models and
188
- efforts they ran at, and how often they were
189
- compacted. --since counts the calls made from that
190
- moment on, and --until the calls made before that
191
- one: a daily run asks from the last run's end up to
192
- its own, and the next starts where it stopped, so no
193
- call is counted twice. It counts tokens and never
194
- money: what a token costs is looked up live by
191
+ each one had, their calls and tokens (their
192
+ subagents' included, and how many were theirs), the
193
+ models and efforts they ran at, and how often they
194
+ were compacted. --since counts the calls made from
195
+ that moment on, and --until the calls made before
196
+ that one: a daily run asks from the last run's end
197
+ up to its own, and the next starts where it stopped,
198
+ so no call is counted twice. It counts tokens and
199
+ never money: what a token costs is looked up live by
195
200
  whoever is asking.
196
201
  obk session record --bots <path> --bot <bot>
197
202
  For the kit's own hook, not for typing: it reads
@@ -307,6 +312,7 @@ async function run(argv) {
307
312
  peek: { type: 'boolean' },
308
313
  charter: { type: 'string' },
309
314
  allow: { type: 'string', multiple: true },
315
+ disallow: { type: 'string', multiple: true },
310
316
  ...Object.fromEntries(SETTINGS.map(([flag]) => [flag, { type: 'string' }])),
311
317
  'extra-arg': { type: 'string', multiple: true },
312
318
  json: { type: 'boolean' },
@@ -604,8 +610,11 @@ const commands = {
604
610
  if (values.harness !== undefined) {
605
611
  throw new Error(`bot change does not change a bot's harness: its sessions' conversations belong to the harness they ran on. To move to ${values.harness}, give it a session on ${values.harness} with obk session add, or retire the bot with obk retire and create a new one.`);
606
612
  }
607
- if (values.charter === undefined && values.allow === undefined) {
608
- throw new Error('bot change needs --charter <text> or --allow <rule>: what to change.');
613
+ if (values.charter === undefined && values.allow === undefined && values.disallow === undefined) {
614
+ throw new Error('bot change needs --charter <text>, --allow <rule> or --disallow <rule>: what to change.');
615
+ }
616
+ if (values.allow !== undefined && values.disallow !== undefined) {
617
+ throw new Error('bot change takes --allow or --disallow, not both at once, so nothing was changed. Run one, then the other.');
609
618
  }
610
619
  // Refused before anything is written, so a bad rule, or a bad list already
611
620
  // there, leaves the charter as it was too.
@@ -614,6 +623,12 @@ const commands = {
614
623
  refuseBroad(home, values.allow);
615
624
  refuseNoCodexForm(readBot(home, values.bot), values.allow);
616
625
  }
626
+ let takeBackFrom;
627
+ if (values.disallow !== undefined) {
628
+ const { home } = allowedNow(bots, values.bot, values.disallow, '--disallow');
629
+ takeBackFrom = takeBack(bots, home, readBot(home, values.bot), values.disallow);
630
+ disallowRules(bots, values.bot, values.disallow, { write: false });
631
+ }
617
632
 
618
633
  const answer = { bots, bot: values.bot, home: botDir(bots, values.bot) };
619
634
  const lines = [];
@@ -637,7 +652,7 @@ const commands = {
637
652
  // An allow list that is not a list is named by rules build and health,
638
653
  // and a charter already written is not undone for it.
639
654
  const bot = readBot(changed.home, changed.bot);
640
- if (values.allow === undefined && (runsOnClaude(bot) || runsOnCodex(bot))) {
655
+ if (values.allow === undefined && values.disallow === undefined && (runsOnClaude(bot) || runsOnCodex(bot))) {
641
656
  try {
642
657
  answer.beyondDefaults = beyondDefaults(bots, changed.home, bot);
643
658
  lines.push(...charterRulesLines(bots, bot, answer.beyondDefaults));
@@ -664,6 +679,23 @@ const commands = {
664
679
  : `${allowed.bot}'s allow list in ${path.join('bots', allowed.bot, 'bot.yaml')} holds the user's yes.`,
665
680
  );
666
681
  }
682
+ if (values.disallow !== undefined) {
683
+ // The harness's files first, bot.yaml last: a write that fails leaves the
684
+ // rules in `allow`, so the same command can take them back again (#360).
685
+ const permissions = takeBackFrom();
686
+ const taken = disallowRules(bots, values.bot, values.disallow);
687
+ Object.assign(answer, { allow: taken.allow, disallowed: taken.disallowed, permissions });
688
+ const codex = permissions.find((entry) => entry.unwritten !== undefined);
689
+ lines.push(
690
+ ...taken.disallowed.map((rule) => `${'took back'.padEnd(9)} ${rule}`),
691
+ ...permissions.filter((entry) => entry.removed?.length > 0).map((entry) => `${'wrote'.padEnd(9)} ${path.relative(bots, entry.file)} ${entry.removed.length} permission rule${entry.removed.length === 1 ? '' : 's'} taken out`),
692
+ ...permissionsLines(permissions, bots),
693
+ ...(codex === undefined || codex.removed.length === 0 ? [] : [
694
+ `Codex reads ${codex.file} when a session starts: a Codex session of ${taken.bot} that is running now keeps ${codex.removed.length === 1 ? 'this rule' : 'these rules'} until its next start (obk restart).`,
695
+ ]),
696
+ `${taken.bot}'s allow list in ${path.join('bots', taken.bot, 'bot.yaml')} no longer holds ${taken.disallowed.length === 1 ? 'it' : 'them'}.`,
697
+ );
698
+ }
667
699
  return { answer, lines, code: trouble ? 1 : 0 };
668
700
  },
669
701
 
@@ -1053,7 +1085,7 @@ function usageLines(usage, bots) {
1053
1085
  for (const entry of usage) {
1054
1086
  lines.push(`${'bot'.padEnd(9)} ${entry.bot}`);
1055
1087
  for (const session of entry.sessions) {
1056
- lines.push(`${'session'.padEnd(9)} ${session.name}`);
1088
+ lines.push(`${'session'.padEnd(9)} ${session.name}${'retired' in session ? ` retired ${session.retired}` : ''}`);
1057
1089
  const notCounted = notCountedLine(session.not_counted);
1058
1090
  lines.push(...session.conversations.map(conversationLine));
1059
1091
  if (session.conversations.length === 0 && notCounted.length === 0) lines.push(' nothing on record');
@@ -1093,6 +1125,7 @@ function conversationLine(one) {
1093
1125
  .map(([kind, count]) => `${kind} ${count}`)
1094
1126
  .join(' ');
1095
1127
  return ` ${one.id} ${one.calls} call${one.calls === 1 ? '' : 's'}`
1128
+ + `${one.subagent_calls > 0 ? ` of which subagents: ${one.subagent_calls} call${one.subagent_calls === 1 ? '' : 's'}` : ''}`
1096
1129
  + `${ran === '' ? '' : ` ${ran}`}`
1097
1130
  + `${used === '' ? '' : ` ${used}`}`
1098
1131
  + `${one.compactions > 0 ? ` compacted ${one.compactions}` : ''}`;
@@ -1264,7 +1297,7 @@ function charterRulesLines(bots, bot, rules) {
1264
1297
  return [
1265
1298
  `${name} is allowed these permission rules beyond the kit's defaults, from before this change:`,
1266
1299
  ...rules.map((rule) => ` ${rule}`),
1267
- `They stay allowed, whatever the user answers, until the user takes one out of ${path.join('bots', name, 'bot.yaml')}${settings}${codex}; the kit takes none out. ${until}`,
1300
+ `They stay allowed, whatever the user answers, until the user says yes to taking one back out of ${path.join('bots', name, 'bot.yaml')}${settings}${codex}. For one the new charter no longer grants, ask the user, and only after their yes run ${shellWord(ownCli())} bot change --bots ${shellWord(bots)} --bot ${shellWord(name)} --disallow <rule>. ${until}`,
1268
1301
  ];
1269
1302
  }
1270
1303
 
@@ -59,6 +59,30 @@ export function hasConversation(harness, home, id) {
59
59
  */
60
60
  export const claudeTranscript = (home, id) => path.join(claudeDir(home), `${id}.jsonl`);
61
61
 
62
+ /**
63
+ * The transcripts of the subagents a Claude Code conversation started, given the
64
+ * file the conversation is in. They sit beside it, in a folder named by its id,
65
+ * one `agent-<name>.jsonl` each, with a `.meta.json` beside each that is not a
66
+ * transcript (tech notes, section 2).
67
+ *
68
+ * No folder is a conversation that started no subagent. A folder that is there
69
+ * and cannot be listed is `unreadable`: what is in it cannot be counted, and
70
+ * taking it as empty would say nothing of that.
71
+ */
72
+ export function claudeSubagentTranscripts(file) {
73
+ const dir = path.join(file.slice(0, -'.jsonl'.length), 'subagents');
74
+ let names;
75
+ try {
76
+ names = readdirSync(dir);
77
+ } catch (error) {
78
+ return { files: [], unreadable: error.code === 'ENOENT' ? 0 : 1 };
79
+ }
80
+ return {
81
+ files: names.filter((name) => name.endsWith('.jsonl')).sort().map((name) => path.join(dir, name)),
82
+ unreadable: 0,
83
+ };
84
+ }
85
+
62
86
  /**
63
87
  * The same conversations, each with the file the harness keeps it in, for a
64
88
  * caller that has to read what is inside one rather than only know it is there,
@@ -12,13 +12,14 @@
12
12
  // editing the file by hand may get the format wrong; code does not.
13
13
  //
14
14
  // The kit owns only the entries `allow` holds. Any other entry in the file is
15
- // left where it is, and health names it.
15
+ // left where it is, and health names it. A rule the user says yes to taking
16
+ // back leaves `allow` and the files through `bot change --disallow` (#360).
16
17
  //
17
18
  // A bot on Codex gets the same yes in Codex's own form (#354, ADR 0028): each
18
19
  // `Bash(<words>:*)` becomes a `prefix_rule` of those words in
19
20
  // `.codex/rules/obk.rules`, a file the kit owns whole and rewrites from `allow`.
20
21
 
21
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
22
+ import { accessSync, constants, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
22
23
  import path from 'node:path';
23
24
 
24
25
  import { leadsOutside } from './bot.js';
@@ -304,6 +305,98 @@ function writeClaude(bots, home, bot) {
304
305
  return { ...answer, written: missing };
305
306
  }
306
307
 
308
+ /**
309
+ * Take the rules the user said yes to taking back out of the files of the
310
+ * harnesses the bot runs on (#360): from its Claude settings every entry of
311
+ * that exact text and nothing else, and its Codex rules rewritten from what
312
+ * `allow` still holds. Only rules `allow` holds are taken back; one it does not
313
+ * hold is refused, since the kit did not write it (ADR 0029).
314
+ *
315
+ * Everything is checked before anything is written, a file the kit could not
316
+ * write included, bot.yaml too. What comes back writes the files: one entry per file,
317
+ * Claude's first, with `removed` the rules taken out of it. The caller takes
318
+ * them out of `allow` after, so a write that fails leaves them there to take
319
+ * back again.
320
+ */
321
+ export function takeBack(bots, home, bot, rules) {
322
+ const allowed = allowOf(home, bot);
323
+ const foreign = rules.find((rule) => !allowed.includes(rule));
324
+ if (foreign !== undefined) throw new Error(notAllowed(home, bot, foreign));
325
+ const after = { ...bot, allow: allowed.filter((rule) => !rules.includes(rule)) };
326
+ const taken = [...new Set(rules)];
327
+ // bot.yaml is written last, and a harness file taken out of step with it
328
+ // would leave nothing to say a running Codex session still has the rule.
329
+ refuseUnwritable(path.join(home, 'bot.yaml'));
330
+
331
+ const file = path.join(home, FILE);
332
+ let claude;
333
+ if (runsOnClaude(bot)) {
334
+ refuseOutside(home, file);
335
+ const settings = readSettings(file, 'the permission rules the user allowed');
336
+ const present = presentIn(settings, file);
337
+ claude = { settings, present, removed: taken.filter((rule) => present.includes(rule)) };
338
+ if (claude.removed.length > 0) refuseUnwritable(file);
339
+ }
340
+ const codex = path.join(home, CODEX_FILE);
341
+ let before = [];
342
+ if (runsOnCodex(bot)) {
343
+ refuseOutside(home, codex);
344
+ if (existsSync(codex)) {
345
+ before = ruleLines(readFileSync(codex, 'utf8'));
346
+ refuseUnwritable(codex);
347
+ }
348
+ }
349
+
350
+ return () => {
351
+ const entries = [];
352
+ if (claude !== undefined) {
353
+ const { settings, present, removed } = claude;
354
+ if (removed.length > 0) {
355
+ const allow = present.filter((rule) => !removed.includes(rule));
356
+ writeFileSync(file, `${JSON.stringify({ ...settings, permissions: { ...settings.permissions, allow } }, null, 2)}\n`);
357
+ }
358
+ entries.push({ bot: bot.name, file, written: [], removed, waiting: waitingFor(bots, home, after, 'claude') });
359
+ }
360
+ if (runsOnCodex(after)) {
361
+ const entry = writeCodex(bots, home, after);
362
+ // A line goes only when no rule still allowed gives it.
363
+ const kept = [...codexOf(home, after).lines.keys()];
364
+ const removed = taken.filter((rule) => {
365
+ const { line } = codexForm(rule);
366
+ return line !== undefined && before.includes(line) && !kept.includes(line);
367
+ });
368
+ entries.push({ ...entry, written: [], removed });
369
+ }
370
+ return entries;
371
+ };
372
+ }
373
+
374
+ /** A refusal, before anything is written, of a file the kit could not write. */
375
+ function refuseUnwritable(file) {
376
+ try {
377
+ accessSync(file, constants.W_OK);
378
+ } catch (error) {
379
+ throw new Error(`${file} cannot be written (${error.code ?? error.message}), so nothing was changed. Let the kit write it, then run the command again.`);
380
+ }
381
+ }
382
+
383
+ /** Why `--disallow` refuses a rule `allow` does not hold, and where one of that text is. */
384
+ function notAllowed(home, bot, rule) {
385
+ const yaml = path.join(home, 'bot.yaml');
386
+ const file = path.join(home, FILE);
387
+ let byHand = false;
388
+ try {
389
+ byHand = runsOnClaude(bot) && leadsOutside(home, file) === undefined
390
+ && presentIn(readSettings(file), file).includes(rule);
391
+ } catch {
392
+ // A file the kit cannot read holds nothing it would take out.
393
+ }
394
+ const where = byHand
395
+ ? ` ${file} allows it, which the user added by hand, not the kit: it stays theirs, to take out themselves.`
396
+ : '';
397
+ return `--disallow ${rule} is not in the allow list in ${yaml}, so it is not the kit's to take back, and nothing was changed.${where} The kit takes back only rules that list holds, spelled exactly as they are there.`;
398
+ }
399
+
307
400
  /**
308
401
  * The Codex form of what the bot is allowed: the file's rule lines, each once,
309
402
  * with the rules that gave them, and the rules Codex has no form for.
package/src/usage.js CHANGED
@@ -9,7 +9,9 @@
9
9
  //
10
10
  // Which conversation belongs to which session is the book's to say and is never
11
11
  // guessed, as everywhere else in the kit (ADR 0012). A session's conversations
12
- // are the one the book names for it now and the ones in its history. One in the
12
+ // are the one the book names for it now and the ones in its history. A session
13
+ // since retired keeps a block of its own, marked retired, so a window that spans
14
+ // a retirement loses nothing (#379). One in the
13
15
  // bot's folder that no session claims is reported under the bot instead, with
14
16
  // its figures, so that what it spent is visible rather than quietly dropped.
15
17
  //
@@ -29,14 +31,17 @@
29
31
  // uncached input on both. Taken as written, one is many times the other for
30
32
  // the same work, and the cached tokens get priced at full rate.
31
33
  // 3. Claude Code writes the same call down more than once, so a call counts
32
- // once per `requestId` and `message.id`.
34
+ // once per `requestId` and `message.id`. A subagent's calls are in a file
35
+ // of their own beside the conversation that started it, and belong to it;
36
+ // the same call can be in more than one of those files, so a call counts
37
+ // once across all of a conversation's files together (#371).
33
38
 
34
39
  import { readFileSync, realpathSync } from 'node:fs';
35
40
 
36
41
  import { botDir, botNames, readBot } from './bot.js';
37
42
  import { readBook, sessionIdsIn } from './book.js';
38
- import { transcriptsIn } from './conversations.js';
39
- import { harnessOf } from './launch.js';
43
+ import { claudeSubagentTranscripts, transcriptsIn } from './conversations.js';
44
+ import { HARNESSES, harnessOf } from './launch.js';
40
45
 
41
46
  /** The kinds a call's tokens are reported in, the same on either harness. */
42
47
  const KINDS = ['input', 'output', 'cache_read', 'cache_write', 'reasoning'];
@@ -98,6 +103,9 @@ function forBot(bots, name, onlySession, window) {
98
103
  // them runs on is asked. The bot's own is the answer when it has no sessions.
99
104
  const harnesses = new Set(bot.sessions.map((session) => harnessOf(session, bot.harness)));
100
105
  if (harnesses.size === 0) harnesses.add(bot.harness);
106
+ // The book does not say which harness a retired session ran on, so any is.
107
+ const retired = Array.isArray(book.retired) ? book.retired : [];
108
+ if (retired.length > 0) for (const harness of HARNESSES) harnesses.add(harness);
101
109
 
102
110
  const onRecord = new Map();
103
111
  for (const harness of harnesses) {
@@ -112,15 +120,22 @@ function forBot(bots, name, onlySession, window) {
112
120
 
113
121
  const claimed = sessionIdsIn(book);
114
122
 
115
- const sessions = bot.sessions
116
- .filter((session) => onlySession === undefined || session.name === onlySession)
117
- .map((session) => {
118
- const { conversations, gaps } = all(
119
- idsIn(book.sessions[session.name] ?? {}).filter((id) => onRecord.has(id)).map((id) => onRecord.get(id)),
120
- window,
121
- );
122
- return { name: session.name, conversations, not_counted: gaps };
123
- });
123
+ // Two entries can name the same conversation, one resumed after its session
124
+ // was retired, and one entry can name it twice, now and in its history: it is
125
+ // counted once, in the first block asked for that names it.
126
+ const taken = new Set();
127
+ const block = (entry) => {
128
+ const ids = [...new Set(idsIn(entry))].filter((id) => onRecord.has(id) && !taken.has(id));
129
+ for (const id of ids) taken.add(id);
130
+ const { conversations, gaps } = all(ids.map((id) => onRecord.get(id)), window);
131
+ return { conversations, not_counted: gaps };
132
+ };
133
+ const sessions = [
134
+ ...bot.sessions.map((session) => ({ name: session.name, entry: book.sessions[session.name] ?? {} })),
135
+ ...retired.map((entry) => ({ name: entry?.name, retired: entry?.retired, entry: entry ?? {} })),
136
+ ]
137
+ .filter(({ name }) => onlySession === undefined || name === onlySession)
138
+ .map(({ name, entry, ...was }) => ({ name, ...('retired' in was ? { retired: was.retired } : {}), ...block(entry) }));
124
139
 
125
140
  const unclaimed = all([...onRecord.values()].filter((one) => !claimed.has(one.id)), window);
126
141
 
@@ -151,9 +166,9 @@ const idsIn = (entry) => [
151
166
  * shorter page. What it could not count is still said.
152
167
  */
153
168
  function counted(one, window) {
154
- const read = one.harness === 'claude' ? fromClaude : fromCodex;
155
169
  const tally = {
156
170
  calls: 0,
171
+ subagentCalls: 0,
157
172
  compactions: 0,
158
173
  tokens: Object.fromEntries(KINDS.map((kind) => [kind, 0])),
159
174
  models: new Set(),
@@ -166,15 +181,25 @@ function counted(one, window) {
166
181
  gaps: noGaps(),
167
182
  };
168
183
 
169
- const { entries, unreadable, broken } = transcript(one.file);
170
- tally.gaps.unreadable_transcripts = unreadable;
171
- tally.gaps.broken_lines = broken;
172
- read(entries, window, tally);
184
+ // A Claude Code conversation's subagents wrote their calls to files of their
185
+ // own, and what they spent is the conversation's.
186
+ const subagents = one.harness === 'claude' ? claudeSubagentTranscripts(one.file) : { files: [], unreadable: 0 };
187
+ tally.gaps.unreadable_transcripts += subagents.unreadable;
188
+ const sources = [one.file, ...subagents.files]
189
+ .map((file, index) => {
190
+ const { entries, unreadable, broken } = transcript(file);
191
+ tally.gaps.unreadable_transcripts += unreadable;
192
+ tally.gaps.broken_lines += broken;
193
+ return { entries, subagent: index > 0 };
194
+ });
195
+ if (one.harness === 'claude') fromClaude(sources, window, tally);
196
+ else fromCodex(sources[0].entries, window, tally);
173
197
  if (tally.calls === 0 && KINDS.every((kind) => tally.tokens[kind] === 0)) return { gaps: tally.gaps };
174
198
 
175
199
  return { gaps: tally.gaps, row: {
176
200
  id: one.id,
177
201
  calls: tally.calls,
202
+ subagent_calls: tally.subagentCalls,
178
203
  tokens: tally.tokens,
179
204
  by_model: [...tally.byModel].map(([model, its]) => ({ model, calls: its.calls, tokens: its.tokens })),
180
205
  models: [...tally.models],
@@ -204,6 +229,15 @@ function counted(one, window) {
204
229
  * the runs add up to the whole, with a call still being written at a boundary
205
230
  * charged once, part to each side (#169).
206
231
  *
232
+ * A call is one `requestId` and `message.id` across the main transcript and its
233
+ * subagents' files together, since the same call can be in more than one of
234
+ * them, and its records are taken in the order of their times. Copies written
235
+ * at the very same moment are taken smallest first, since a call's figures only
236
+ * grow: the largest is the latest. One with a figure missing may be the latest
237
+ * of them, so it is taken as that, and what it hides is said rather than
238
+ * measured past it. It is a subagent's when the main transcript has no record
239
+ * of it.
240
+ *
207
241
  * A record with no time, or with a figure missing, is left out whole and said to
208
242
  * be; the call it belongs to is counted from its other records, if it has any.
209
243
  * But when the record last written before the window's start is one of those,
@@ -211,10 +245,10 @@ function counted(one, window) {
211
245
  * the window cannot be told apart from what it grew by before. So the call is
212
246
  * not counted in that window, and is said to be.
213
247
  */
214
- function fromClaude(entries, window, tally) {
248
+ function fromClaude(sources, window, tally) {
215
249
  const byCall = new Map();
216
250
 
217
- for (const entry of entries) {
251
+ for (const { entries, subagent } of sources) for (const entry of entries) {
218
252
  const when = Date.parse(entry.timestamp ?? '');
219
253
  if (entry.type === 'system' && entry.subtype === 'compact_boundary') {
220
254
  if (Number.isNaN(when)) tally.gaps.records_without_time += 1;
@@ -233,10 +267,11 @@ function fromClaude(entries, window, tally) {
233
267
  if (broken && inside(when, window)) tally.gaps.records_without_numbers += 1;
234
268
  const call = `${entry.requestId}\u0000${entry.message?.id}`;
235
269
  if (!byCall.has(call)) byCall.set(call, []);
236
- byCall.get(call).push({ entry, when, broken });
270
+ byCall.get(call).push({ entry, when, broken, subagent });
237
271
  }
238
272
 
239
273
  for (const records of byCall.values()) {
274
+ records.sort((one, other) => one.when - other.when || one.broken - other.broken || size(one.entry) - size(other.entry));
240
275
  const made = records[0].when;
241
276
  const atEnd = records.findLast(({ when, broken }) => !broken && when < window.to);
242
277
  if (atEnd === undefined) continue;
@@ -252,10 +287,17 @@ function fromClaude(entries, window, tally) {
252
287
  const isNew = inside(made, window);
253
288
  if (!isNew && KINDS.every((kind) => grew[kind] === 0)) continue;
254
289
 
255
- count(tally, grew, atEnd.entry.message?.model, atEnd.entry.effort, isNew ? made : atEnd.when, isNew ? 1 : 0);
290
+ const subagent = records.every((record) => record.subagent);
291
+ count(tally, grew, atEnd.entry.message?.model, atEnd.entry.effort, isNew ? made : atEnd.when, isNew ? 1 : 0, subagent);
256
292
  }
257
293
  }
258
294
 
295
+ /** How much one Claude Code record says its call has used so far, all kinds together. */
296
+ const size = (entry) => {
297
+ const now = figures(entry);
298
+ return KINDS.reduce((sum, kind) => sum + now[kind], 0);
299
+ };
300
+
259
301
  /** The figures Claude Code writes on every call, all of which a record needs to be counted. */
260
302
  const CLAUDE_FIELDS = ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens'];
261
303
 
@@ -300,15 +342,15 @@ const CODEX_FIELDS = [
300
342
  * The running total is followed through events outside the window as well, since
301
343
  * what a call added can only be measured against the event before it.
302
344
  *
303
- * An event whose running total has a figure missing is left out and said to be,
304
- * and after it the running total is not known, so neither is what the next
305
- * complete event added. Measured against the event before the broken one, it
306
- * would take in what the broken one used, at its own time and perhaps in
307
- * another window; taken by its own per-call figure, it may be the broken one
308
- * written down again. Telling those apart is guessing, so it is not counted
309
- * either, and is said to be; the running total follows on from it. A call whose
310
- * figure is needed and has something missing is not counted, and is said to
311
- * be; nothing missing is taken as zero.
345
+ * An event whose running total has a figure missing, or has none at all, is left
346
+ * out and said to be, and after it the running total is not known, so neither
347
+ * is what the next complete event added. Measured against the event before the
348
+ * broken one, it would take in what the broken one used, at its own time and
349
+ * perhaps in another window; taken by its own per-call figure, it may be the
350
+ * broken one written down again. Telling those apart is guessing, so it is not
351
+ * counted either, and is said to be; the running total follows on from it. A
352
+ * call whose figure is needed and has something missing is not counted, and is
353
+ * said to be; nothing missing is taken as zero.
312
354
  */
313
355
  function fromCodex(entries, window, tally) {
314
356
  let model;
@@ -338,14 +380,11 @@ function fromCodex(entries, window, tally) {
338
380
  const info = entry.payload?.info;
339
381
  if (info === undefined || info === null) continue;
340
382
 
341
- const total = info.total_token_usage ?? undefined;
383
+ const total = info.total_token_usage;
342
384
  let used;
343
- if (total === undefined) {
344
- // Nothing to measure against: the per-call figure is all there is. After a
345
- // broken running total it may be the broken one written again, so it is
346
- // not counted either until a complete running total says where things are.
347
- used = broken ? null : perCall(info);
348
- } else if (!complete(total, CODEX_READ)) {
385
+ // No running total at all is a running total with every figure missing:
386
+ // the next one's rise would take this call in again (#302).
387
+ if (!complete(total, CODEX_READ)) {
349
388
  used = null;
350
389
  broken = true;
351
390
  } else {
@@ -418,8 +457,9 @@ function kindsOf(raw) {
418
457
  * the total that has to be complete, and a row headed by nothing would be worse
419
458
  * than no row.
420
459
  */
421
- function count(tally, used, model, effort, when, calls = 1) {
460
+ function count(tally, used, model, effort, when, calls = 1, subagent = false) {
422
461
  tally.calls += calls;
462
+ if (subagent) tally.subagentCalls += calls;
423
463
  for (const kind of KINDS) tally.tokens[kind] += used[kind];
424
464
  add(tally.models, model);
425
465
  add(tally.efforts, effort);