@assuredloop/orca-bot-kit 0.25.4 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.js CHANGED
@@ -12,15 +12,16 @@ 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, disallowRules, leadsOutside, readBot, SESSION_FIELDS } from './bot.js';
15
+ import { addSession, allowedNow, allowRules, botDir, changeBot, changeSession, createBot, disallowRules, leadsOutside, readBot, SESSION_FIELDS, setRoleCaps, setTempApproval } from './bot.js';
16
+ import { COMMANDS } from './commands.js';
16
17
  import { addCommand, groomCommand, grooming, upCommand } from './groom.js';
17
18
  import { checkHealth, orcaSettingFindings } from './health.js';
18
19
  import { initBots } from './init.js';
19
20
  import { nameSession } from './name.js';
20
- import { APPROVALS, HARNESSES, ownCli, shellWord, workDirOf } from './launch.js';
21
+ import { APPROVALS, harnessOf, HARNESSES, ownCli, refuseApprovalArgs, shellWord, workDirOf } from './launch.js';
21
22
  import { checkMail, decideLeftNudges, lookUp, noMailboxYet, sendMessage, sessionInTab, stillUnread, WATCH_MS } from './message.js';
22
23
  import { orcaCli, orcaTrouble, RELOAD_LINE, TERMINAL_ENV } from './orca.js';
23
- import { allowCommand, allowIn, beyondDefaults, refuseBroad, refuseNoCodexForm, runsOnClaude, runsOnCodex, takeBack, writePermissions } from './permissions.js';
24
+ import { allowIn, beyondDefaults, refuseBroad, refuseNoCodexForm, runsOnClaude, runsOnCodex, takeBack, writePermissions } from './permissions.js';
24
25
  import { pauseSessions, unpauseSessions } from './pause.js';
25
26
  import { recordSession, SHELL_ENV, TAB_ENV } from './record.js';
26
27
  import { restartSessions } from './restart.js';
@@ -31,7 +32,7 @@ import { readRoster } from './roster.js';
31
32
  import { buildAgents, buildRules, CODEX_CAP } from './rules.js';
32
33
  import { addSkill, buildSkills, linkSkills, removeSkill } from './skills.js';
33
34
  import { addSource, fetchSources } from './sources.js';
34
- import { answerTemp, listRoles, makeTemp, optionsLine, retireTemp, trustHooks } from './temp.js';
35
+ import { answerSession, answerTemp, listRoles, makeTemp, optionsLine, retireTemp, trustHooks, trustSessionHooks } from './temp.js';
35
36
  import { readUsage } from './usage.js';
36
37
  import { BOT_FATHER, bringUp, ownMailbox } from './up.js';
37
38
 
@@ -48,11 +49,12 @@ Usage:
48
49
  opened in Orca until you run obk up.
49
50
  obk session add --bots <path> --bot <bot> --name <session>
50
51
  [--harness claude|codex] [--model <m>] [--effort <e>]
51
- [--context <c>] [--approval ${APPROVALS.join('|')}]
52
+ [--context <c>]
52
53
  [--prompt <text> | --prompt-file <path>] [--work-dir <path>]
53
54
  [--extra-arg=<arg>]
54
55
  Add a session to a bot. Anything left out is the
55
- harness's own default; approval is auto. A long
56
+ harness's own default; approval is auto, and only
57
+ obk permission approval changes it. A long
56
58
  start prompt lives in a file in the bot home, and
57
59
  --prompt-file names it.
58
60
  A value of your own that starts with a dash is
@@ -60,20 +62,32 @@ Usage:
60
62
  as ours: --prompt='- a bullet', and
61
63
  --extra-arg=--search, once per extra argument.
62
64
  obk bot change --bots <path> --bot <bot> [--charter <text>]
63
- [--allow <rule> ... | --disallow <rule> ...]
65
+ [--role-cap <role>=<n> ...]
64
66
  Give a bot a new charter and rebuild its AGENTS.md.
65
67
  A running session reads it when it next starts.
66
- --allow records a permission rule the user said
67
- yes to, once per rule, and writes it into the bot's
68
- Claude settings. A broad rule, such as Bash(gh:*),
69
- is refused and nothing is written.
70
- --disallow takes back a rule the bot was allowed,
71
- once the user has said yes to that, from bot.yaml
72
- and the bot's settings. A rule the user added by
73
- hand is not the kit's, and is refused.
68
+ --role-cap sets the cap of a role in the bot's
69
+ temp_roles, once per role; <role>= takes it off.
70
+ obk permission allow --bots <path> --bot <bot> --rule <rule> ...
71
+ Record a permission rule the user said yes to,
72
+ once per rule, and write it into the bot's Claude
73
+ settings and Codex rules. A broad rule, such as
74
+ Bash(gh:*), is refused and nothing is written.
75
+ Every bot has the kit's own default rules already,
76
+ with nobody asked.
77
+ obk permission disallow --bots <path> --bot <bot> --rule <rule> ...
78
+ Take back a rule the bot was allowed, once the user
79
+ has said yes to that, from bot.yaml and the bot's
80
+ settings. A rule the user added by hand is not the
81
+ kit's, and a rule of the kit's default set is
82
+ written again, so both are refused.
83
+ obk permission approval --bots <path> --bot <bot>
84
+ (--session <name> | --temps) --approval ${APPROVALS.join('|')}
85
+ Set a session's approval level once the user has
86
+ said yes to it, or with --temps the widest level
87
+ the bot's temporary sessions may be made at. A
88
+ running session takes it when it next starts.
74
89
  obk session change --bots <path> --bot <bot> --session <session>
75
90
  [--model <m>] [--effort <e>] [--context <c>]
76
- [--approval ${APPROVALS.join('|')}]
77
91
  [--prompt <text> | --prompt-file <path>] [--work-dir <path>]
78
92
  [--extra-arg=<arg>]
79
93
  Change a session's settings. What you leave out
@@ -111,7 +125,9 @@ Usage:
111
125
  a time, and one it made makes none. It takes your
112
126
  harness, model, effort, context and approval unless
113
127
  you say otherwise; on another harness than yours
114
- it takes only your approval. It works in
128
+ it takes only your approval. --approval is refused
129
+ wider than yours, unless obk permission approval
130
+ --temps allowed it for the bot. It works in
115
131
  work/<session>, and has the task as its start
116
132
  prompt. The book records it as temporary, made by
117
133
  you.
@@ -139,7 +155,8 @@ Usage:
139
155
  of a Codex temporary session it made with "Trust
140
156
  all and continue", and check the review went. It
141
157
  refuses anything else on that screen, and types
142
- nothing then.
158
+ nothing then. It refuses, as obk session
159
+ trust-hooks does, hooks that are not the kit's.
143
160
  obk temp answer --bots <path> --name <session>
144
161
  Run in the maker's own tab: answer Claude Code's
145
162
  "Teach auto mode" screen on a temporary session it
@@ -266,6 +283,19 @@ Usage:
266
283
  next starts where it stopped, so no call is counted
267
284
  twice. It counts tokens and never money: what a
268
285
  token costs is looked up live by whoever is asking.
286
+ obk session trust-hooks --bots <path> --bot <bot> --session <name>
287
+ Run by Bot Father or the user: answer the hooks
288
+ review of a long-lived Codex session with "Trust
289
+ all and continue", as obk temp trust-hooks does,
290
+ and check the review went. It refuses when the
291
+ bot's .codex/hooks.json holds a hook that is not
292
+ the kit's, or when the review counts other hooks
293
+ than the kit's own that Codex does not trust yet.
294
+ obk session answer --bots <path> --bot <bot> --session <name>
295
+ Run by Bot Father or the user: answer Claude Code's
296
+ "Teach auto mode" screen on a long-lived session
297
+ with Not now, as obk temp answer does, and check
298
+ the screen went.
269
299
  obk session record --bots <path> --bot <bot>
270
300
  For the kit's own hook, not for typing: it reads
271
301
  what the harness says about a session starting on
@@ -299,50 +329,12 @@ Usage:
299
329
  obk --help Print this text.
300
330
 
301
331
  Every command is safe to run again. Most only add what is missing; the ones
302
- that close a tab or take something away say so above.
332
+ that close a tab or take something away say so above. Every bot is allowed
333
+ the kit's commands by default, except obk permission, retire, pause and init,
334
+ which keep the user's yes.
303
335
  Add --json to any of them for the same answer as JSON.
304
336
  `;
305
337
 
306
- /** The commands, and the flags each one cannot do without. */
307
- const COMMANDS = {
308
- init: ['bots', 'harness'],
309
- up: ['bots'],
310
- restart: ['bots', 'bot'],
311
- pause: ['bots', 'bot'],
312
- unpause: ['bots', 'bot'],
313
- retire: ['bots', 'bot'],
314
- health: ['bots'],
315
- groom: ['bots'],
316
- roster: ['bots'],
317
- usage: ['bots'],
318
- 'bot create': ['bots', 'name', 'harness'],
319
- 'bot change': ['bots', 'bot'],
320
- 'rules build': ['bots'],
321
- 'skills add': ['bots', 'bot', 'skill'],
322
- 'skills remove': ['bots', 'bot', 'skill'],
323
- 'skills build': ['bots'],
324
- 'skills fetch': ['bots'],
325
- 'skills update': ['bots'],
326
- 'source add': ['bots', 'name', 'repo', 'ref'],
327
- 'session add': ['bots', 'bot', 'name'],
328
- 'session change': ['bots', 'bot', 'session'],
329
- 'session clear': ['bots', 'bot', 'session'],
330
- 'session compact': ['bots', 'bot', 'session'],
331
- 'message to': ['bots', 'to'],
332
- 'message send': ['bots', 'to', 'subject'],
333
- 'message check': ['bots'],
334
- 'session record': ['bots', 'bot'],
335
- 'session sent': ['bots', 'bot'],
336
- 'session nudge': ['bots', 'bot'],
337
- 'session mail': ['bots', 'bot'],
338
- 'session name': ['bots', 'bot'],
339
- 'session mailbox': ['bots', 'bot', 'session'],
340
- 'temp make': ['bots', 'name'],
341
- 'temp roles': ['bots'],
342
- 'temp retire': ['bots', 'name'],
343
- 'temp trust-hooks': ['bots', 'name'],
344
- 'temp answer': ['bots', 'name'],
345
- };
346
338
 
347
339
  /** What each flag is for, in the sentence a caller reads when it is missing. */
348
340
  const NEEDED = {
@@ -363,10 +355,15 @@ const NEEDED = {
363
355
  from: '--from <bot>/<session>: which session is writing',
364
356
  subject: '--subject <text>: what the message is about',
365
357
  role: '--role <role>[:<option>]: which role of the bot\'s temp_roles',
358
+ rule: '--rule <rule>: the exact permission rule the user said yes to',
359
+ approval: `--approval ${APPROVALS.join('|')}: the approval level the user said yes to`,
366
360
  };
367
361
 
368
- /** The flags that name something. A name that is empty names nothing. */
369
- const IDENTIFIERS = Object.keys(NEEDED);
362
+ /**
363
+ * The flags that name something. A name that is empty names nothing. A rule
364
+ * and an approval are judged by their own commands.
365
+ */
366
+ const IDENTIFIERS = Object.keys(NEEDED).filter((flag) => flag !== 'rule' && flag !== 'approval');
370
367
 
371
368
  /**
372
369
  * The session settings a flag can carry, as `[flag, field]`: every field a
@@ -383,9 +380,10 @@ function version() {
383
380
  }
384
381
 
385
382
  async function run(argv) {
386
- const { values, positionals } = parseArgs({
383
+ const { values, positionals, tokens } = parseArgs({
387
384
  args: argv,
388
385
  allowPositionals: true,
386
+ tokens: true,
389
387
  options: {
390
388
  bots: { type: 'string' },
391
389
  harness: { type: 'string' },
@@ -414,6 +412,9 @@ async function run(argv) {
414
412
  charter: { type: 'string' },
415
413
  allow: { type: 'string', multiple: true },
416
414
  disallow: { type: 'string', multiple: true },
415
+ rule: { type: 'string', multiple: true },
416
+ temps: { type: 'boolean' },
417
+ 'role-cap': { type: 'string', multiple: true },
417
418
  ...Object.fromEntries(SETTINGS.map(([flag]) => [flag, { type: 'string' }])),
418
419
  'extra-arg': { type: 'string', multiple: true },
419
420
  'run-on': { type: 'string' },
@@ -438,9 +439,16 @@ async function run(argv) {
438
439
  return 1;
439
440
  }
440
441
 
441
- // `bot`, `rules`, `skills`, `session`, `source`, `message` and `temp` are
442
- // commands of two words; the rest are one.
443
- const words = ['bot', 'rules', 'skills', 'session', 'source', 'message', 'temp'].includes(positionals[0]) ? 2 : 1;
442
+ // A permission rule narrows a command to one bots folder by its first words,
443
+ // whatever comes after them (ADR 0036), and the last of two `--bots` would
444
+ // win: so a second one is refused before anything runs.
445
+ if (tokens.filter((token) => token.kind === 'option' && token.name === 'bots').length > 1) {
446
+ throw new Error('--bots is given more than once. Give it once: one command works on one bots folder. Nothing was done.');
447
+ }
448
+
449
+ // `bot`, `permission`, `rules`, `skills`, `session`, `source`, `message` and
450
+ // `temp` are commands of two words; the rest are one.
451
+ const words = ['bot', 'permission', 'rules', 'skills', 'session', 'source', 'message', 'temp'].includes(positionals[0]) ? 2 : 1;
444
452
  const command = positionals.slice(0, words).join(' ');
445
453
  const extra = positionals.slice(words);
446
454
 
@@ -840,11 +848,31 @@ const commands = {
840
848
  answer: { bots, ...answered },
841
849
  lines: [
842
850
  `answered ${answered.bot} ${answered.session}, a temporary session of ${answered.maker}'s: sent ${answered.sent} to its "Teach auto mode about your environment?" screen, and the screen has gone`,
843
- `rule a maker's bot runs this under one permission rule, which the user approves: Bash(${shellWord(ownCli())} temp answer:*)`,
851
+ `rule a maker's bot runs this under a rule of the kit's default set, which every bot has: Bash(${shellWord(ownCli())} temp answer --bots ${shellWord(bots)}:*)`,
844
852
  ],
845
853
  };
846
854
  },
847
855
 
856
+ async 'session trust-hooks'(bots, values) {
857
+ const tab = callerTab(bots, true);
858
+ refuseWhenOrcaIsDown();
859
+ const trusted = await trustSessionHooks(bots, { tab, bot: values.bot, session: values.session });
860
+ return {
861
+ answer: { bots, ...trusted },
862
+ lines: [`trusted ${trusted.bot} ${trusted.session}'s hooks: chose "Trust all and continue" on its hooks review, and the review has gone`],
863
+ };
864
+ },
865
+
866
+ async 'session answer'(bots, values) {
867
+ const tab = callerTab(bots, true);
868
+ refuseWhenOrcaIsDown();
869
+ const answered = await answerSession(bots, { tab, bot: values.bot, session: values.session });
870
+ return {
871
+ answer: { bots, ...answered },
872
+ lines: [`answered ${answered.bot} ${answered.session}: sent ${answered.sent} to its "Teach auto mode about your environment?" screen, and the screen has gone`],
873
+ };
874
+ },
875
+
848
876
  'bot create'(bots, values) {
849
877
  const made = createBot(bots, { name: values.name, harness: values.harness, charter: values.charter });
850
878
  // The bot's AGENTS.md is the rules build's, here as everywhere else, so
@@ -853,14 +881,14 @@ const commands = {
853
881
  // A new bot is given what the lists already name, so it is whole before
854
882
  // anybody opens a tab on it.
855
883
  const skills = [linkSkills(bots, made.home, readBot(made.home))];
856
- // Nothing is allowed yet, so this writes nothing: it says which of the
857
- // kit's rules wait for the user's yes (#344).
858
- const permissions = writePermissions(bots, made.home, readBot(made.home));
859
- const answer = { bots, bot: made.bot, home: made.home, created: made.created, rules, skills, permissions };
860
884
  // A bot whose rules would not build is made but not finished: it has no
861
885
  // instructions, so `up` will not start it, and saying "give it a session"
862
886
  // would send the caller past the thing that needs settling first.
863
887
  const trouble = rules[0].trouble !== undefined;
888
+ // The kit's default rules (ADR 0036), for a bot that is set up to run: one
889
+ // with no instructions gets them at the rules build that gives it some.
890
+ const permissions = trouble ? [] : writePermissions(bots, made.home, readBot(made.home));
891
+ const answer = { bots, bot: made.bot, home: made.home, created: made.created, rules, skills, permissions };
864
892
  return {
865
893
  answer,
866
894
  lines: [
@@ -880,29 +908,18 @@ const commands = {
880
908
  if (values.harness !== undefined) {
881
909
  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.`);
882
910
  }
883
- if (values.charter === undefined && values.allow === undefined && values.disallow === undefined) {
884
- throw new Error('bot change needs --charter <text>, --allow <rule> or --disallow <rule>: what to change.');
885
- }
886
- if (values.allow !== undefined && values.disallow !== undefined) {
887
- throw new Error('bot change takes --allow or --disallow, not both at once, so nothing was changed. Run one, then the other.');
911
+ // Permission changes have commands of their own, which keep the user's yes
912
+ // (ADR 0037): the old spelling refuses, and changes nothing.
913
+ const moved = [['allow', 'permission allow'], ['disallow', 'permission disallow']].filter(([flag]) => values[flag] !== undefined);
914
+ if (moved.length > 0) {
915
+ throw new Error(`bot change no longer takes ${moved.map(([flag]) => `--${flag}`).join(' or ')}: permission rules change through ${moved.map(([, now]) => `${shellWord(ownCli())} ${now} --bots ${shellWord(bots)} --bot ${values.bot} --rule <rule>`).join(' and ')}, after the user's yes. Nothing was changed.`);
888
916
  }
889
- // Refused before anything is written, so a bad rule, or a bad list already
890
- // there, leaves the charter as it was too.
891
- let allowInto;
892
- if (values.allow !== undefined) {
893
- const { home } = allowedNow(bots, values.bot, values.allow);
894
- refuseBroad(home, values.allow);
895
- const bot = readBot(home, values.bot);
896
- refuseNoCodexForm(bot, values.allow);
897
- const { allow } = allowRules(bots, values.bot, values.allow, { write: false });
898
- allowInto = allowIn(bots, home, { ...bot, allow });
899
- }
900
- let takeBackFrom;
901
- if (values.disallow !== undefined) {
902
- const { home } = allowedNow(bots, values.bot, values.disallow, '--disallow');
903
- takeBackFrom = takeBack(bots, home, readBot(home, values.bot), values.disallow);
904
- disallowRules(bots, values.bot, values.disallow, { write: false });
917
+ if (values.charter === undefined && values['role-cap'] === undefined) {
918
+ throw new Error('bot change needs --charter <text> or --role-cap <role>=<n>: what to change.');
905
919
  }
920
+ // Refused before anything is written, so a bad cap leaves the charter as it was too.
921
+ const caps = values['role-cap'] === undefined ? undefined : values['role-cap'].map(roleCap);
922
+ if (caps !== undefined) setRoleCaps(bots, values.bot, caps.map(({ role, cap }) => [role, cap]), { write: false });
906
923
 
907
924
  const answer = { bots, bot: values.bot, home: botDir(bots, values.bot) };
908
925
  const lines = [];
@@ -926,7 +943,7 @@ const commands = {
926
943
  // An allow list that is not a list is named by rules build and health,
927
944
  // and a charter already written is not undone for it.
928
945
  const bot = readBot(changed.home, changed.bot);
929
- if (values.allow === undefined && values.disallow === undefined && (runsOnClaude(bot) || runsOnCodex(bot))) {
946
+ if (runsOnClaude(bot) || runsOnCodex(bot)) {
930
947
  try {
931
948
  answer.beyondDefaults = beyondDefaults(bots, changed.home, bot);
932
949
  lines.push(...charterRulesLines(bots, bot, answer.beyondDefaults));
@@ -935,15 +952,31 @@ const commands = {
935
952
  }
936
953
  }
937
954
  }
938
- if (values.allow !== undefined) {
939
- // Written at once, as the charter's rules are built at once (#344): the
940
- // harness's files first, bot.yaml last, so a write that fails leaves
941
- // `allow` as it was, and the same command can run again (#383).
942
- const permissions = allowInto();
943
- const allowed = allowRules(bots, values.bot, values.allow);
944
- Object.assign(answer, { allow: allowed.allow, permissions });
945
- const codex = permissions.find((entry) => entry.unwritten !== undefined);
946
- lines.push(
955
+ if (caps !== undefined) {
956
+ setRoleCaps(bots, values.bot, caps.map(({ role, cap }) => [role, cap]));
957
+ answer.caps = Object.fromEntries(caps.map(({ role, cap }) => [role, cap ?? null]));
958
+ lines.push(...caps.map(({ role, cap }) => (cap === undefined
959
+ ? `changed the role ${role} in ${path.join('bots', values.bot, 'bot.yaml')}: no cap`
960
+ : `changed the role ${role} in ${path.join('bots', values.bot, 'bot.yaml')}: cap ${cap}`)));
961
+ }
962
+ return { answer, lines, code: trouble ? 1 : 0 };
963
+ },
964
+
965
+ 'permission allow'(bots, values) {
966
+ // Refused before anything is written, a bad list already there included.
967
+ const { home } = allowedNow(bots, values.bot, values.rule);
968
+ refuseBroad(home, values.rule);
969
+ const bot = readBot(home, values.bot);
970
+ refuseNoCodexForm(bot, values.rule);
971
+ const { allow } = allowRules(bots, values.bot, values.rule, { write: false });
972
+ // The harness's files first, bot.yaml last, so a write that fails leaves
973
+ // `allow` as it was, and the same command can run again (#383).
974
+ const permissions = allowIn(bots, home, { ...bot, allow })();
975
+ const allowed = allowRules(bots, values.bot, values.rule);
976
+ const codex = permissions.find((entry) => entry.unwritten !== undefined);
977
+ return {
978
+ answer: { bots, bot: values.bot, home, allow: allowed.allow, permissions },
979
+ lines: [
947
980
  ...allowed.added.map((rule) => `${'allowed'.padEnd(9)} ${rule}`),
948
981
  ...permissionsLines(permissions, bots),
949
982
  // Codex reads its rules when a session starts, not while it runs.
@@ -953,16 +986,22 @@ const commands = {
953
986
  allowed.added.length === 0
954
987
  ? `${allowed.bot} was allowed every one of these already.`
955
988
  : `${allowed.bot}'s allow list in ${path.join('bots', allowed.bot, 'bot.yaml')} holds the user's yes.`,
956
- );
957
- }
958
- if (values.disallow !== undefined) {
959
- // The harness's files first, bot.yaml last: a write that fails leaves the
960
- // rules in `allow`, so the same command can take them back again (#360).
961
- const permissions = takeBackFrom();
962
- const taken = disallowRules(bots, values.bot, values.disallow);
963
- Object.assign(answer, { allow: taken.allow, disallowed: taken.disallowed, permissions });
964
- const codex = permissions.find((entry) => entry.unwritten !== undefined);
965
- lines.push(
989
+ ],
990
+ };
991
+ },
992
+
993
+ 'permission disallow'(bots, values) {
994
+ const { home } = allowedNow(bots, values.bot, values.rule);
995
+ const takeBackFrom = takeBack(bots, home, readBot(home, values.bot), values.rule);
996
+ disallowRules(bots, values.bot, values.rule, { write: false });
997
+ // The harness's files first, bot.yaml last: a write that fails leaves the
998
+ // rules in `allow`, so the same command can take them back again (#360).
999
+ const permissions = takeBackFrom();
1000
+ const taken = disallowRules(bots, values.bot, values.rule);
1001
+ const codex = permissions.find((entry) => entry.unwritten !== undefined);
1002
+ return {
1003
+ answer: { bots, bot: values.bot, home, allow: taken.allow, disallowed: taken.disallowed, permissions },
1004
+ lines: [
966
1005
  ...taken.disallowed.map((rule) => `${'took back'.padEnd(9)} ${rule}`),
967
1006
  ...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`),
968
1007
  ...permissionsLines(permissions, bots),
@@ -970,9 +1009,36 @@ const commands = {
970
1009
  `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).`,
971
1010
  ]),
972
1011
  `${taken.bot}'s allow list in ${path.join('bots', taken.bot, 'bot.yaml')} no longer holds ${taken.disallowed.length === 1 ? 'it' : 'them'}.`,
973
- );
1012
+ ],
1013
+ };
1014
+ },
1015
+
1016
+ 'permission approval'(bots, values) {
1017
+ if ((values.session === undefined) === (values.temps !== true)) {
1018
+ throw new Error('permission approval needs --session <name>, for a session, or --temps, for the bot\'s temporary sessions, and not both. Nothing was changed.');
974
1019
  }
975
- return { answer, lines, code: trouble ? 1 : 0 };
1020
+ if (!APPROVALS.includes(values.approval)) {
1021
+ throw new Error(`--approval is ${APPROVALS.join(', ')}, and got: ${values.approval === '' ? 'nothing' : values.approval}. Nothing was changed.`);
1022
+ }
1023
+ if (values.temps) {
1024
+ const set = setTempApproval(bots, values.bot, values.approval);
1025
+ return {
1026
+ answer: { bots, bot: set.bot, home: set.home, temp_approval: set.level },
1027
+ lines: [
1028
+ `changed temp_approval in ${path.join('bots', set.bot, 'bot.yaml')}: ${set.level}`,
1029
+ `${set.bot}'s temporary sessions may now be made at ${set.level}, or at their maker's own level where that is wider.`,
1030
+ ],
1031
+ };
1032
+ }
1033
+ const changed = changeSession(bots, values.bot, values.session, { approval: values.approval });
1034
+ const restart = `${shellWord(ownCli())} restart --bots ${shellWord(bots)} --bot ${changed.bot} --session ${values.session}`;
1035
+ return {
1036
+ answer: { bots, bot: changed.bot, home: changed.home, session: changed.session, restart },
1037
+ lines: [
1038
+ `changed session ${values.session} in ${path.join('bots', changed.bot, 'bot.yaml')}: approval ${values.approval}`,
1039
+ `A running session takes this when it next starts: ${restart}`,
1040
+ ],
1041
+ };
976
1042
  },
977
1043
 
978
1044
  'skills build'(bots, values) {
@@ -1050,6 +1116,8 @@ const commands = {
1050
1116
  ...(values.effort === undefined ? {} : { effort: values.effort }),
1051
1117
  ...(values['extra-arg'] === undefined ? {} : { extra_args: values['extra-arg'] }),
1052
1118
  };
1119
+ // A run's launch may not widen its approval (ADR 0037).
1120
+ if (values['run-on'] === 'codex') refuseApprovalArgs('codex', values['extra-arg']);
1053
1121
  const groom = grooming(bots, { at: values.at, ask, run });
1054
1122
  return { answer: { bots, groom }, lines: groomLines(groom, bots) };
1055
1123
  },
@@ -1181,6 +1249,7 @@ const commands = {
1181
1249
  },
1182
1250
 
1183
1251
  'session add'(bots, values) {
1252
+ refuseApproval(bots, values, 'session add');
1184
1253
  const added = addSession(bots, values.bot, settingsOf(values));
1185
1254
  const found = workDirFound(values, added.bot, added.home, added.session.name);
1186
1255
  const answer = { bots, bot: added.bot, home: added.home, session: added.session, found };
@@ -1213,6 +1282,7 @@ const commands = {
1213
1282
  if (values.harness !== undefined) {
1214
1283
  throw new Error(`session change does not change a session's harness: its conversations belong to the harness they ran on. To move it, retire it with obk retire and add one on ${values.harness} with obk session add.`);
1215
1284
  }
1285
+ refuseApproval(bots, values, 'session change');
1216
1286
  const { name, ...settings } = settingsOf(values);
1217
1287
  const changed = changeSession(bots, values.bot, values.session, settings);
1218
1288
  const restart = `${shellWord(ownCli())} restart --bots ${shellWord(bots)} --bot ${changed.bot} --session ${values.session}`;
@@ -1262,7 +1332,12 @@ const commands = {
1262
1332
  function toLines(answer, bots, where) {
1263
1333
  if (answer.trouble !== undefined) return [` ${answer.trouble}`];
1264
1334
  if (answer.transport === 'native') {
1265
- return [` Write to ${answer.address} with your own harness's messaging. The kit does not carry that road.`];
1335
+ // A refused call is not re-sent by another road (PRD 6.9), so the answer
1336
+ // says what to do instead, before the bot needs it (#521).
1337
+ return [
1338
+ ` Write to ${answer.address} with your own harness's messaging. The kit does not carry that road.`,
1339
+ " If your harness refuses that call, do not send the message again by another road. Tell your maker or your user that it was refused, with the harness's reason. A refusal can mean a missing permission rule, which they can add through the kit after the user's yes.",
1340
+ ];
1266
1341
  }
1267
1342
  return [
1268
1343
  ...(answer.unnamed === true
@@ -1330,6 +1405,43 @@ function workDirFound(values, bot, home, session) {
1330
1405
  }];
1331
1406
  }
1332
1407
 
1408
+ /**
1409
+ * Refuse what would change a session's approval through `session add` or
1410
+ * `session change` (ADR 0037): `--approval`, and an extra argument that sets
1411
+ * it on the session's harness. Only `permission approval` changes it.
1412
+ */
1413
+ function refuseApproval(bots, values, command) {
1414
+ if (values.approval !== undefined) {
1415
+ throw new Error(`${command} no longer takes --approval: a session's approval changes through ${shellWord(ownCli())} permission approval --bots ${shellWord(bots)} --bot ${values.bot} --session ${values.session ?? values.name} --approval <level>, after the user's yes. Nothing was changed.`);
1416
+ }
1417
+ if (values['extra-arg'] === undefined) return;
1418
+ let bot;
1419
+ try {
1420
+ bot = readBot(botDir(bots, values.bot), values.bot);
1421
+ } catch {
1422
+ // A bot that is not there is the command's own to refuse.
1423
+ return;
1424
+ }
1425
+ const session = bot.sessions.find((one) => one.name === (values.session ?? values.name)) ?? {};
1426
+ refuseApprovalArgs(harnessOf({ ...session, ...(values.harness === undefined ? {} : { harness: values.harness }) }, bot.harness), values['extra-arg']);
1427
+ }
1428
+
1429
+ /**
1430
+ * One `--role-cap <role>=<n>`, as `{ role, cap }`: a cap of undefined, from
1431
+ * `<role>=`, takes it off. A cap is a whole number of open sessions, 1 or more.
1432
+ */
1433
+ function roleCap(given) {
1434
+ const at = given.indexOf('=');
1435
+ if (at <= 0) throw new Error(`--role-cap is <role>=<n>, such as reviewer=2, or <role>= to take a cap off, and got: ${given}. Nothing was changed.`);
1436
+ const role = given.slice(0, at);
1437
+ const text = given.slice(at + 1);
1438
+ if (text === '') return { role };
1439
+ if (!/^\d+$/.test(text) || Number(text) < 1) {
1440
+ throw new Error(`--role-cap ${given}: a cap is a whole number of open sessions, 1 or more, such as ${role}=2. Nothing was changed.`);
1441
+ }
1442
+ return { role, cap: Number(text) };
1443
+ }
1444
+
1333
1445
  /** The settings a `session add` was given, as they go into bot.yaml. */
1334
1446
  function settingsOf(values) {
1335
1447
  const settings = { name: values.name };
@@ -1661,7 +1773,7 @@ function permissionsOf(bots, name) {
1661
1773
  */
1662
1774
  function charterRulesLines(bots, bot, rules) {
1663
1775
  const name = bot.name;
1664
- const until = `No new rule is written until the user answers: list the rules the new charter grants, show them to the user word for word, and allow the ones they say yes to with ${shellWord(ownCli())} bot change --bots ${shellWord(bots)} --bot ${shellWord(name)} --allow <rule>`;
1776
+ const until = `No new rule is written until the user answers: list the rules the new charter grants, show them to the user word for word, and allow the ones they say yes to with ${shellWord(ownCli())} permission allow --bots ${shellWord(bots)} --bot ${shellWord(name)} --rule <rule>`;
1665
1777
  if (rules.length === 0) return [`${name} is allowed no permission rules beyond the kit's defaults. ${until}`];
1666
1778
  const settings = runsOnClaude(bot) ? ` and ${path.join('bots', name, '.claude', 'settings.json')}` : '';
1667
1779
  // The kit owns the Codex file whole and rewrites it from bot.yaml (#354).
@@ -1669,34 +1781,30 @@ function charterRulesLines(bots, bot, rules) {
1669
1781
  return [
1670
1782
  `${name} is allowed these permission rules beyond the kit's defaults, from before this change:`,
1671
1783
  ...rules.map((rule) => ` ${rule}`),
1672
- `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}`,
1784
+ `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())} permission disallow --bots ${shellWord(bots)} --bot ${shellWord(name)} --rule <rule>. ${until}`,
1673
1785
  ];
1674
1786
  }
1675
1787
 
1676
1788
  /**
1677
- * What became of each bot's permission rules: the file the allowed ones were
1678
- * written into, and the kit's rules that still wait for the user's yes, each
1679
- * word for word, with the one command that allows them once the user has said
1680
- * it (#344). Nothing for a bot with nothing written and nothing waiting.
1789
+ * What became of each bot's permission rules: each of the kit's default rules
1790
+ * the run added to the bot, word for word (ADR 0036), and the file the allowed
1791
+ * ones were written into. Nothing for a bot with nothing added or written.
1681
1792
  */
1682
1793
  function permissionsLines(permissions, bots) {
1683
- // A bot on both harnesses has an entry for each file, and what waits for
1684
- // Codex is also waiting for Claude: the rules are listed once, with the first.
1794
+ // A bot on both harnesses has an entry for each file, with the same
1795
+ // defaults: they are listed once, with the first.
1685
1796
  const listed = new Set();
1686
1797
  return permissions.flatMap((entry) => [
1687
1798
  ...(entry.trouble === undefined ? [] : [`${'refused'.padEnd(9)} ${entry.trouble}`]),
1799
+ ...((entry.defaults ?? []).length === 0 || listed.has(entry.bot) || !listed.add(entry.bot) ? []
1800
+ : entry.defaults.map((rule) => `${'allowed'.padEnd(9)} ${entry.bot} ${rule}`)),
1688
1801
  ...(entry.written.length === 0
1689
1802
  ? []
1690
- : [`${'wrote'.padEnd(9)} ${path.relative(bots, entry.file)} ${entry.written.length} permission rule${entry.written.length === 1 ? '' : 's'} the user allowed`]),
1803
+ : [`${'wrote'.padEnd(9)} ${path.relative(bots, entry.file)} ${entry.written.length} permission rule${entry.written.length === 1 ? '' : 's'} from its allow list`]),
1691
1804
  // What the user allowed and Codex has no form for is said at every build,
1692
1805
  // so a bot given a Codex session later hears of it too (#354). For a bot
1693
- // only on Codex, `--allow` refused it already.
1806
+ // only on Codex, `permission allow` refused it already.
1694
1807
  ...(entry.unwritten ?? []).map((one) => `${'not'.padEnd(9)} written for Codex: ${one.rule}. ${one.why}.`),
1695
- ...(entry.waiting.length === 0 || listed.has(entry.bot) || !listed.add(entry.bot) ? [] : [
1696
- `${'waiting'.padEnd(9)} ${entry.bot}: these permission rules wait for the user's yes, and none of them is written until then:`,
1697
- ...entry.waiting.map((rule) => ` ${rule}`),
1698
- ` Show them to the user. Once they say yes: ${allowCommand(bots, entry.bot, entry.waiting)}`,
1699
- ]),
1700
1808
  ]);
1701
1809
  }
1702
1810