@sublang/playbook 9.0.0 → 11.0.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.
Files changed (71) hide show
  1. package/README.md +1 -1
  2. package/docs/cli.md +109 -21
  3. package/docs/configuration.md +89 -40
  4. package/docs/embedding.md +126 -12
  5. package/package.json +14 -3
  6. package/reference/sdlc/captain.md +14 -10
  7. package/reference/sdlc/captain.playbook/captain.fsm.d.ts +33 -13
  8. package/reference/sdlc/captain.playbook/captain.fsm.js +80 -9
  9. package/reference/sdlc/captain.playbook/captain.fsm.ts +137 -18
  10. package/reference/sdlc/captain.playbook/captain.gears.md +10 -6
  11. package/reference/sdlc/captain.playbook/captain.playbook.d.ts +5 -1
  12. package/reference/sdlc/captain.playbook/captain.playbook.js +140 -10
  13. package/reference/sdlc/captain.playbook/captain.playbook.ts +188 -16
  14. package/reference/sdlc/code.md +35 -16
  15. package/reference/sdlc/code.playbook/bin/interactive-session.js +228 -23
  16. package/reference/sdlc/code.playbook/bin/launch-config.js +611 -221
  17. package/reference/sdlc/code.playbook/bin/playbook.js +304 -178
  18. package/reference/sdlc/code.playbook/bin/replay-observer.js +221 -0
  19. package/reference/sdlc/code.playbook/bin/repository-effects.js +2930 -0
  20. package/reference/sdlc/code.playbook/bin/run.js +669 -215
  21. package/reference/sdlc/code.playbook/bin/session-store.js +4546 -502
  22. package/reference/sdlc/code.playbook/code.fsm.d.ts +7 -0
  23. package/reference/sdlc/code.playbook/code.fsm.js +74 -25
  24. package/reference/sdlc/code.playbook/code.fsm.ts +83 -29
  25. package/reference/sdlc/code.playbook/code.gears.md +0 -2
  26. package/reference/sdlc/code.playbook/code.playbook.d.ts +5 -2
  27. package/reference/sdlc/code.playbook/code.playbook.js +54 -2
  28. package/reference/sdlc/code.playbook/code.playbook.ts +75 -6
  29. package/reference/sdlc/code.playbook/code.registry.d.ts +10 -3
  30. package/reference/sdlc/code.playbook/code.registry.js +10 -3
  31. package/reference/sdlc/code.playbook/code.registry.ts +23 -5
  32. package/reference/sdlc/code.playbook/playbook-captain.d.ts +103 -8
  33. package/reference/sdlc/code.playbook/playbook-captain.js +1871 -75
  34. package/reference/sdlc/code.playbook/playbook-captain.ts +2801 -102
  35. package/reference/sdlc/code.playbook/playbook.config.template.yaml +14 -10
  36. package/reference/sdlc/code.playbook/session-store.d.ts +82 -0
  37. package/reference/sdlc/code.playbook/session-store.js +113 -0
  38. package/reference/sdlc/decide.md +24 -16
  39. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +7 -0
  40. package/reference/sdlc/decide.playbook/decide.fsm.js +80 -29
  41. package/reference/sdlc/decide.playbook/decide.fsm.ts +89 -31
  42. package/reference/sdlc/decide.playbook/decide.gears.md +0 -1
  43. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +13 -5
  44. package/reference/sdlc/decide.playbook/decide.playbook.js +1712 -91
  45. package/reference/sdlc/decide.playbook/decide.playbook.ts +2677 -136
  46. package/reference/sdlc/decide.playbook/decide.registry.d.ts +7 -3
  47. package/reference/sdlc/decide.playbook/decide.registry.js +10 -3
  48. package/reference/sdlc/decide.playbook/decide.registry.ts +20 -5
  49. package/reference/sdlc/review.md +36 -18
  50. package/reference/sdlc/review.playbook/review.fsm.d.ts +7 -0
  51. package/reference/sdlc/review.playbook/review.fsm.js +133 -12
  52. package/reference/sdlc/review.playbook/review.fsm.ts +140 -12
  53. package/reference/sdlc/review.playbook/review.playbook.d.ts +5 -2
  54. package/reference/sdlc/review.playbook/review.playbook.js +65 -2
  55. package/reference/sdlc/review.playbook/review.playbook.ts +83 -6
  56. package/reference/sdlc/review.playbook/review.registry.d.ts +10 -3
  57. package/reference/sdlc/review.playbook/review.registry.js +10 -3
  58. package/reference/sdlc/review.playbook/review.registry.ts +23 -5
  59. package/slc/gears2fsm.md +6 -5
  60. package/slc/link.md +544 -41
  61. package/src/accepted-outcome.d.ts +18 -0
  62. package/src/accepted-outcome.js +94 -0
  63. package/src/accepted-outcome.ts +140 -0
  64. package/src/runtime.d.ts +164 -3
  65. package/src/runtime.ts +213 -2
  66. package/src/xstate-playbook-runtime.d.ts +149 -10
  67. package/src/xstate-playbook-runtime.js +2569 -270
  68. package/src/xstate-playbook-runtime.ts +4133 -490
  69. package/src/xstate-runtime.d.ts +59 -1
  70. package/src/xstate-runtime.js +866 -7
  71. package/src/xstate-runtime.ts +1397 -7
@@ -6,47 +6,52 @@
6
6
  // control and stores no imported registry functions in the normalized plan.
7
7
 
8
8
  import {
9
+ chmodSync,
9
10
  constants,
10
11
  copyFileSync,
11
12
  existsSync,
13
+ linkSync,
14
+ lstatSync,
12
15
  mkdirSync,
13
16
  mkdtempSync,
14
17
  readFileSync,
15
18
  rmSync,
16
19
  writeFileSync,
17
- } from 'node:fs';
18
- import { homedir, tmpdir } from 'node:os';
19
- import { dirname, isAbsolute, join, resolve } from 'node:path';
20
- import { fileURLToPath, pathToFileURL } from 'node:url';
21
- import { isDeepStrictEqual } from 'node:util';
20
+ } from "node:fs";
21
+ import { homedir, tmpdir } from "node:os";
22
+ import { dirname, isAbsolute, join, resolve } from "node:path";
23
+ import { fileURLToPath, pathToFileURL } from "node:url";
24
+ import { isDeepStrictEqual } from "node:util";
22
25
  import {
23
26
  parse as parseYaml,
24
27
  parseDocument as parseYamlDocument,
25
28
  stringify as stringifyYaml,
26
- } from 'yaml';
27
- import { loadTmuxPlayConfig } from '@sublang/cligent/tmux-play';
29
+ } from "yaml";
30
+ import { loadTmuxPlayConfig } from "@sublang/cligent/tmux-play";
31
+ import { defaultCaptainSessionsDir } from "./session-store.js";
28
32
 
29
33
  const here = dirname(fileURLToPath(import.meta.url));
30
34
  const DEFAULT_TEMPLATE_PATH = resolve(
31
35
  here,
32
- '..',
33
- 'playbook.config.template.yaml',
36
+ "..",
37
+ "playbook.config.template.yaml",
34
38
  );
35
39
 
36
40
  // PBCLI-1/8: the tmux projection uses the Playbook Captain shell adapter.
37
- export const PLAYBOOK_CAPTAIN_MODULE =
38
- '@sublang/playbook/playbook-captain';
39
- const PLAYBOOK_LAUNCHER_KEYS = ['from', 'command', 'roles'];
41
+ export const PLAYBOOK_CAPTAIN_MODULE = "@sublang/playbook/playbook-captain";
42
+ const PLAYBOOK_LAUNCHER_KEYS = ["from", "command", "roles"];
43
+ const HOST_CAPABILITIES_OPTION_KEY = "hostCapabilities";
40
44
  const PLAYBOOK_TOP_LEVEL_KEYS = new Set([
41
- 'captain',
42
- 'players',
43
- 'playbooks',
44
- 'layout',
45
- 'notifications',
46
- 'theme',
45
+ "captain",
46
+ "players",
47
+ "playbooks",
48
+ "layout",
49
+ "notifications",
50
+ "theme",
51
+ "sessions",
47
52
  ]);
48
- const RESERVED_CAPTAIN_PLAYBOOK_ID = 'captain';
49
- const RESERVED_CAPTAIN_ROLE_ID = 'captain';
53
+ const RESERVED_CAPTAIN_PLAYBOOK_ID = "captain";
54
+ const RESERVED_CAPTAIN_ROLE_ID = "captain";
50
55
  const PLAYER_ID_PATTERN = /^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*$/;
51
56
  const ROLE_ID_PATTERN = /^[a-z][a-z0-9_-]*$/;
52
57
 
@@ -57,16 +62,16 @@ export function extractWithFlags(argv) {
57
62
  const rest = [];
58
63
  for (let i = 0; i < argv.length; i += 1) {
59
64
  const arg = argv[i];
60
- if (arg === '--with') {
65
+ if (arg === "--with") {
61
66
  const value = argv[i + 1];
62
- if (value === undefined || value === '') {
63
- throw new Error('--with needs a value');
67
+ if (value === undefined || value === "") {
68
+ throw new Error("--with needs a value");
64
69
  }
65
70
  withPaths.push(value);
66
71
  i += 1;
67
- } else if (arg.startsWith('--with=')) {
68
- const value = arg.slice('--with='.length);
69
- if (!value) throw new Error('--with needs a value');
72
+ } else if (arg.startsWith("--with=")) {
73
+ const value = arg.slice("--with=".length);
74
+ if (!value) throw new Error("--with needs a value");
70
75
  withPaths.push(value);
71
76
  } else {
72
77
  rest.push(arg);
@@ -80,7 +85,7 @@ export function loadOverlayFragment(overlayPath) {
80
85
  const resolved = resolve(overlayPath);
81
86
  let text;
82
87
  try {
83
- text = readFileSync(resolved, 'utf8');
88
+ text = readFileSync(resolved, "utf8");
84
89
  } catch (error) {
85
90
  throw new Error(
86
91
  `cannot read --with overlay ${overlayPath}: ${errorMessage(error)}`,
@@ -121,36 +126,113 @@ export function mergeConfigs(base, overlay) {
121
126
  export function mergeSelectedConfigs(base, overlay, selectedMembers) {
122
127
  const selected = validateSelectedMembers(selectedMembers);
123
128
  return mergeConfigs(
124
- projectSelectedLayer(base, selected, 'config'),
125
- projectSelectedLayer(overlay, selected, 'overlay'),
129
+ projectSelectedLayer(base, selected, "config"),
130
+ projectSelectedLayer(overlay, selected, "overlay"),
126
131
  );
127
132
  }
128
133
 
129
- export function resolveConfigHome(env = process.env, home = homedir()) {
130
- return env.XDG_CONFIG_HOME || join(home, '.config');
134
+ // DR-043: the config lives under the shared Spex root, resolved exactly as
135
+ // Spex's own shells resolve it so both hosts open one file. The singular
136
+ // `playbook/` namespace is ours; Spex owns the plural `playbooks/` library.
137
+ export function resolveSpexHome(env = process.env, home = homedir()) {
138
+ const explicit = env.SPEX_HOME;
139
+ if (typeof explicit === "string" && explicit.trim().length > 0) {
140
+ return explicit;
141
+ }
142
+ const fromEnv = env.HOME;
143
+ const base =
144
+ typeof fromEnv === "string" && fromEnv.trim().length > 0 ? fromEnv : home;
145
+ return join(base, ".spex");
131
146
  }
132
147
 
133
148
  export function resolveUserConfigPath(env = process.env, home = homedir()) {
134
- return join(resolveConfigHome(env, home), 'playbook', 'playbook.config.yaml');
149
+ return join(resolveSpexHome(env, home), "playbook", "playbook.config.yaml");
150
+ }
151
+
152
+ // The pre-DR-043 location, kept only to relocate a config written there.
153
+ export function resolveLegacyUserConfigPath(
154
+ env = process.env,
155
+ home = homedir(),
156
+ ) {
157
+ const configHome = env.XDG_CONFIG_HOME || join(home, ".config");
158
+ return join(configHome, "playbook", "playbook.config.yaml");
159
+ }
160
+
161
+ // PBCLI-46/78: session selection needs this one root locator before the
162
+ // complete launch plan can be selected and normalized. Read only that scalar
163
+ // from each layer so an ordinary reopen does not inspect unrelated current
164
+ // catalog members before its durable projection is known.
165
+ export function resolveLaunchSessionsDir({
166
+ userConfigPath,
167
+ overlayPaths = [],
168
+ env = process.env,
169
+ homeDir = env.HOME ?? homedir(),
170
+ sessionsDir,
171
+ preparePrimary = false,
172
+ templatePath = DEFAULT_TEMPLATE_PATH,
173
+ onNotice = () => {},
174
+ }) {
175
+ // The private injection used by tests and managed launch plumbing remains
176
+ // authoritative over configuration, just like an injected store.
177
+ if (sessionsDir !== undefined) return sessionsDir;
178
+
179
+ if (preparePrimary) {
180
+ seedUserConfigIfMissing(userConfigPath, templatePath, onNotice);
181
+ migrateUserConfigIfRetired(userConfigPath, onNotice);
182
+ }
183
+
184
+ let locator;
185
+ if (existsSync(userConfigPath)) {
186
+ const primary = parseYaml(readFileSync(userConfigPath, "utf8")) ?? {};
187
+ if (!isObject(primary)) {
188
+ throw new Error(
189
+ `the top-level config at ${userConfigPath} must be a YAML map`,
190
+ );
191
+ }
192
+ if (hasOwn(primary, "sessions")) locator = primary.sessions;
193
+ }
194
+ for (const overlayPath of overlayPaths) {
195
+ const overlay = loadOverlayFragment(overlayPath);
196
+ if (hasOwn(overlay, "sessions")) locator = overlay.sessions;
197
+ }
198
+
199
+ if (locator === undefined) {
200
+ return defaultCaptainSessionsDir(env, homeDir);
201
+ }
202
+ if (typeof locator !== "string" || locator.length === 0) {
203
+ throw new Error("sessions must be a nonempty filesystem path");
204
+ }
205
+ if (locator === "~") return homeDir;
206
+ if (locator.startsWith("~/")) {
207
+ return join(homeDir, locator.slice(2));
208
+ }
209
+ if (locator.startsWith("~")) {
210
+ throw new Error("sessions does not support named-user tilde expansion");
211
+ }
212
+ if (isAbsolute(locator)) return locator;
213
+ return resolve(dirname(userConfigPath), locator);
135
214
  }
136
215
 
137
216
  // PBCLI-46: configured filesystem modules are anchored once to the primary
138
217
  // config, including paths introduced by overlays. Bare/custom specifiers and
139
218
  // already-authored file URLs retain their module semantics.
140
219
  export function canonicalizeRegistrySpecifier(from, configPath) {
141
- if (configPath === undefined || from.startsWith('file:')) return from;
142
- if (
143
- isAbsolute(from) ||
144
- from.startsWith('./') ||
145
- from.startsWith('../') ||
146
- from.startsWith('.\\') ||
147
- from.startsWith('..\\')
148
- ) {
220
+ if (configPath === undefined || from.startsWith("file:")) return from;
221
+ if (isAbsolute(from) || isRelativeFilesystemRegistrySpecifier(from)) {
149
222
  return pathToFileURL(resolve(dirname(configPath), from)).href;
150
223
  }
151
224
  return from;
152
225
  }
153
226
 
227
+ function isRelativeFilesystemRegistrySpecifier(from) {
228
+ return (
229
+ from.startsWith("./") ||
230
+ from.startsWith("../") ||
231
+ from.startsWith(".\\") ||
232
+ from.startsWith("..\\")
233
+ );
234
+ }
235
+
154
236
  // PBCLI-46: seed, migrate, overlay, validate, and normalize through one path.
155
237
  // `prepareRegistryModule` is the single provision-before-import seam used by
156
238
  // both front ends, so filesystem registry handling cannot drift by presenter.
@@ -170,7 +252,7 @@ export async function loadLaunchPlan({
170
252
  migrateUserConfigIfRetired(userConfigPath, onNotice);
171
253
  }
172
254
 
173
- let top = parseYaml(readFileSync(userConfigPath, 'utf8')) ?? {};
255
+ let top = parseYaml(readFileSync(userConfigPath, "utf8")) ?? {};
174
256
  if (overlayPaths.length > 0 && !isObject(top)) {
175
257
  throw new Error(
176
258
  `the top-level config at ${userConfigPath} must be a YAML map before --with can overlay it`,
@@ -207,7 +289,7 @@ export async function loadSelectedLaunchPlanDataOnly({
207
289
  const selectedMembers = selectedMembersFromStoredStructure(stored);
208
290
  seedUserConfigIfMissing(userConfigPath, templatePath, onNotice);
209
291
 
210
- let top = parseYaml(readFileSync(userConfigPath, 'utf8')) ?? {};
292
+ let top = parseYaml(readFileSync(userConfigPath, "utf8")) ?? {};
211
293
  if (overlayPaths.length > 0 && !isObject(top)) {
212
294
  throw new Error(
213
295
  `the top-level config at ${userConfigPath} must be a YAML map before --with can overlay it`,
@@ -216,7 +298,7 @@ export async function loadSelectedLaunchPlanDataOnly({
216
298
  top = projectSelectedLayer(
217
299
  top,
218
300
  validateSelectedMembers(selectedMembers),
219
- 'config',
301
+ "config",
220
302
  );
221
303
  for (const overlayPath of overlayPaths) {
222
304
  top = mergeSelectedConfigs(
@@ -245,17 +327,17 @@ export async function normalizeSelectedLaunchPlanDataOnly(
245
327
  !isDeepStrictEqual(supplied.playerIds, expectedMembers.playerIds)
246
328
  ) {
247
329
  throw new Error(
248
- 'selected launch members do not match the stored structural projection',
330
+ "selected launch members do not match the stored structural projection",
249
331
  );
250
332
  }
251
333
  }
252
334
  top = projectSelectedMembers(top, expectedMembers);
253
- top = cloneJson(top, 'config');
335
+ top = cloneJson(top, "config");
254
336
  assertNoRetiredProfiles(top, configPath);
255
- if (hasOwn(top, 'run')) {
337
+ if (hasOwn(top, "run")) {
256
338
  throw new Error(
257
339
  'top-level "run" was removed: configure the shared Captain under ' +
258
- 'captain, top-level players, and playbooks.<id>.roles instead',
340
+ "captain, top-level players, and playbooks.<id>.roles instead",
259
341
  );
260
342
  }
261
343
  const unknownTopLevel = Object.keys(top).filter(
@@ -267,32 +349,33 @@ export async function normalizeSelectedLaunchPlanDataOnly(
267
349
  );
268
350
  }
269
351
  if (top.layout !== undefined && !isObject(top.layout)) {
270
- throw new Error('layout must be a map');
352
+ throw new Error("layout must be a map");
271
353
  }
272
354
 
273
- const playersCfg = requireObject(top.players, 'players');
355
+ const playersCfg = requireObject(top.players, "players");
274
356
  const configuredAgents = new Map();
275
357
  for (const playerId of expectedMembers.playerIds) {
276
358
  assertPlayerId(playerId, `players.${playerId}`);
277
359
  const agent = resolveAgent(playersCfg[playerId], `players.${playerId}`, [
278
- 'id',
360
+ "id",
279
361
  ]);
280
362
  configuredAgents.set(playerId, agent);
281
363
  }
282
- let captain = resolveAgent(top.captain, 'captain', ['from', 'options']);
364
+ let captain = resolveAgent(top.captain, "captain", ["from", "options"]);
283
365
 
284
- const playbooksCfg = requireObject(top.playbooks, 'playbooks');
366
+ const playbooksCfg = requireObject(top.playbooks, "playbooks");
285
367
  const tuningChecks = [];
286
368
  let tuningCheckIndex = 0;
287
369
  const authored = new Map();
288
370
  for (const id of expectedMembers.playbookIds) {
289
371
  const storedItem = stored.catalog[id];
290
372
  const block = requireObject(playbooksCfg[id], `playbooks.${id}`);
291
- if (hasOwn(block, 'players')) {
373
+ if (hasOwn(block, "players")) {
292
374
  throw legacyPlayersError(`playbooks.${id}.players`, configPath);
293
375
  }
376
+ rejectConfiguredHostCapabilities(block, `playbooks.${id}`);
294
377
  if (
295
- typeof block.from !== 'string' ||
378
+ typeof block.from !== "string" ||
296
379
  block.from.trim().length === 0 ||
297
380
  block.from !== block.from.trim()
298
381
  ) {
@@ -300,7 +383,10 @@ export async function normalizeSelectedLaunchPlanDataOnly(
300
383
  `playbooks.${id}.from must be a canonical trimmed module specifier`,
301
384
  );
302
385
  }
303
- const configuredFrom = canonicalizeRegistrySpecifier(block.from, configPath);
386
+ const configuredFrom = canonicalizeRegistrySpecifier(
387
+ block.from,
388
+ configPath,
389
+ );
304
390
  if (configuredFrom !== storedItem.from) {
305
391
  throw new Error(
306
392
  `playbooks.${id}.from changed from the stored structural projection`,
@@ -330,7 +416,11 @@ export async function normalizeSelectedLaunchPlanDataOnly(
330
416
  );
331
417
  }
332
418
  bindings[role] = binding;
333
- if (binding.model !== undefined || binding.effort !== undefined) {
419
+ if (
420
+ binding.model !== undefined ||
421
+ binding.effort !== undefined ||
422
+ binding.fastMode !== undefined
423
+ ) {
334
424
  let checkId;
335
425
  do {
336
426
  checkId = `binding-check-${tuningCheckIndex}`;
@@ -400,9 +490,12 @@ export async function normalizeSelectedLaunchPlanDataOnly(
400
490
  layout: { ...provisional.layout, initialVisible: [] },
401
491
  })
402
492
  : normalizedHost;
403
- const { from: _captainFrom, options: _captainOptions, ...normalizedCaptain } =
404
- normalizedHost.captain;
405
- captain = sessionAgentFromHostAgent(normalizedCaptain, 'captain');
493
+ const {
494
+ from: _captainFrom,
495
+ options: _captainOptions,
496
+ ...normalizedCaptain
497
+ } = normalizedHost.captain;
498
+ captain = sessionAgentFromHostAgent(normalizedCaptain, "captain");
406
499
  const hostAgents = new Map(
407
500
  normalizedHost.players.map(({ id, ...agent }) => [id, agent]),
408
501
  );
@@ -437,6 +530,11 @@ export async function normalizeSelectedLaunchPlanDataOnly(
437
530
  binding.effort === undefined
438
531
  ? agent.effort
439
532
  : overrideTuningSelection(binding.effort),
533
+ ...(binding.fastMode === undefined
534
+ ? agent.fastMode === undefined
535
+ ? {}
536
+ : { fastMode: agent.fastMode }
537
+ : { fastMode: binding.fastMode }),
440
538
  },
441
539
  ];
442
540
  }),
@@ -469,7 +567,7 @@ export async function normalizeSelectedLaunchPlanDataOnly(
469
567
  };
470
568
  if (!isDeepStrictEqual(candidateStructure, stored)) {
471
569
  throw new Error(
472
- 'current selected config does not reproduce the stored structural projection',
570
+ "current selected config does not reproduce the stored structural projection",
473
571
  );
474
572
  }
475
573
 
@@ -494,7 +592,7 @@ export async function normalizeSelectedLaunchPlanDataOnly(
494
592
  : { theme: normalizedPresentationHost.theme }),
495
593
  },
496
594
  },
497
- 'selected launch config',
595
+ "selected launch config",
498
596
  ),
499
597
  );
500
598
  }
@@ -502,7 +600,7 @@ export async function normalizeSelectedLaunchPlanDataOnly(
502
600
  // PBCLI-8 (DR-021): scalar agents are adapter shorthands and full blocks
503
601
  // carry their own settings without profile indirection.
504
602
  export function resolveAgent(value, path, reservedKeys = []) {
505
- if (typeof value === 'string') {
603
+ if (typeof value === "string") {
506
604
  if (value.trim().length === 0) {
507
605
  throw new Error(`${path} must name an adapter`);
508
606
  }
@@ -519,6 +617,19 @@ export function resolveAgent(value, path, reservedKeys = []) {
519
617
  throw new Error(`${path} must be an adapter shorthand or an agent block`);
520
618
  }
521
619
 
620
+ function rejectConfiguredHostCapabilities(value, path) {
621
+ if (
622
+ value !== null &&
623
+ typeof value === "object" &&
624
+ !Array.isArray(value) &&
625
+ hasOwn(value, HOST_CAPABILITIES_OPTION_KEY)
626
+ ) {
627
+ throw new Error(
628
+ `${path}.${HOST_CAPABILITIES_OPTION_KEY} is host-owned and cannot be configured`,
629
+ );
630
+ }
631
+ }
632
+
522
633
  // PBCLI-46: normalize into a detached, deeply frozen JSON plan. The plan has
523
634
  // only execution data and presentation data; imported registry functions are
524
635
  // consulted for validation and then discarded.
@@ -528,12 +639,12 @@ export async function normalizeLaunchPlan(
528
639
  ) {
529
640
  const importModule = loadModule ?? ((specifier) => import(specifier));
530
641
  top = projectSelectedMembers(top, selectedMembers);
531
- top = cloneJson(top, 'config');
642
+ top = cloneJson(top, "config");
532
643
  assertNoRetiredProfiles(top, configPath);
533
- if (hasOwn(top, 'run')) {
644
+ if (hasOwn(top, "run")) {
534
645
  throw new Error(
535
646
  'top-level "run" was removed: configure the shared Captain under ' +
536
- 'captain, top-level players, and playbooks.<id>.roles instead',
647
+ "captain, top-level players, and playbooks.<id>.roles instead",
537
648
  );
538
649
  }
539
650
  const unknownTopLevel = Object.keys(top).filter(
@@ -545,19 +656,19 @@ export async function normalizeLaunchPlan(
545
656
  );
546
657
  }
547
658
  if (top.layout !== undefined && !isObject(top.layout)) {
548
- throw new Error('layout must be a map');
659
+ throw new Error("layout must be a map");
549
660
  }
550
661
 
551
- const playersCfg = requireObject(top.players, 'players');
662
+ const playersCfg = requireObject(top.players, "players");
552
663
  const allPlayerIds = Object.keys(playersCfg);
553
664
  const configuredAgents = new Map();
554
665
  for (const playerId of allPlayerIds) {
555
666
  assertPlayerId(playerId, `players.${playerId}`);
556
667
  const agent = resolveAgent(playersCfg[playerId], `players.${playerId}`, [
557
- 'id',
668
+ "id",
558
669
  ]);
559
670
  if (
560
- typeof agent.adapter !== 'string' ||
671
+ typeof agent.adapter !== "string" ||
561
672
  agent.adapter.trim().length === 0
562
673
  ) {
563
674
  throw new Error(`players.${playerId} must resolve an adapter`);
@@ -565,21 +676,21 @@ export async function normalizeLaunchPlan(
565
676
  configuredAgents.set(playerId, agent);
566
677
  }
567
678
 
568
- const playbooksCfg = requireObject(top.playbooks, 'playbooks');
679
+ const playbooksCfg = requireObject(top.playbooks, "playbooks");
569
680
  const ids = Object.keys(playbooksCfg);
570
681
  if (ids.length === 0) {
571
- throw new Error('playbooks must enable at least one playbook');
682
+ throw new Error("playbooks must enable at least one playbook");
572
683
  }
573
684
  if (ids.some((id) => id.trim().length === 0 || id !== id.trim())) {
574
- throw new Error('playbooks keys must be canonical trimmed nonblank ids');
685
+ throw new Error("playbooks keys must be canonical trimmed nonblank ids");
575
686
  }
576
687
 
577
- let captain = resolveAgent(top.captain, 'captain', ['from', 'options']);
688
+ let captain = resolveAgent(top.captain, "captain", ["from", "options"]);
578
689
  if (
579
- typeof captain.adapter !== 'string' ||
690
+ typeof captain.adapter !== "string" ||
580
691
  captain.adapter.trim().length === 0
581
692
  ) {
582
- throw new Error('captain must resolve an adapter');
693
+ throw new Error("captain must resolve an adapter");
583
694
  }
584
695
 
585
696
  // Validate and detach every retained config-owned value before provisioning
@@ -594,12 +705,13 @@ export async function normalizeLaunchPlan(
594
705
  );
595
706
  }
596
707
  const block = requireObject(playbooksCfg[id], `playbooks.${id}`);
597
- if (hasOwn(block, 'players')) {
708
+ if (hasOwn(block, "players")) {
598
709
  throw legacyPlayersError(`playbooks.${id}.players`, configPath);
599
710
  }
711
+ rejectConfiguredHostCapabilities(block, `playbooks.${id}`);
600
712
  const from = block.from;
601
713
  if (
602
- typeof from !== 'string' ||
714
+ typeof from !== "string" ||
603
715
  from.trim().length === 0 ||
604
716
  from !== from.trim()
605
717
  ) {
@@ -609,7 +721,7 @@ export async function normalizeLaunchPlan(
609
721
  }
610
722
  if (
611
723
  block.command !== undefined &&
612
- (typeof block.command !== 'string' ||
724
+ (typeof block.command !== "string" ||
613
725
  block.command.trim().length === 0 ||
614
726
  block.command !== block.command.trim())
615
727
  ) {
@@ -631,7 +743,7 @@ export async function normalizeLaunchPlan(
631
743
  throw new Error(
632
744
  `playbooks.${id}.roles.${RESERVED_CAPTAIN_ROLE_ID} binds local ` +
633
745
  `role "${RESERVED_CAPTAIN_ROLE_ID}", which is reserved for the ` +
634
- 'tmux-play Captain',
746
+ "tmux-play Captain",
635
747
  );
636
748
  }
637
749
  const bindings = Object.create(null);
@@ -649,7 +761,11 @@ export async function normalizeLaunchPlan(
649
761
  );
650
762
  }
651
763
  bindings[role] = binding;
652
- if (binding.model !== undefined || binding.effort !== undefined) {
764
+ if (
765
+ binding.model !== undefined ||
766
+ binding.effort !== undefined ||
767
+ binding.fastMode !== undefined
768
+ ) {
653
769
  let checkId;
654
770
  do {
655
771
  checkId = `binding-check-${tuningCheckIndex}`;
@@ -735,9 +851,12 @@ export async function normalizeLaunchPlan(
735
851
  layout: { ...provisional.layout, initialVisible: [] },
736
852
  })
737
853
  : normalizedHost;
738
- const { from: _captainFrom, options: _captainOptions, ...normalizedCaptain } =
739
- normalizedHost.captain;
740
- captain = sessionAgentFromHostAgent(normalizedCaptain, 'captain');
854
+ const {
855
+ from: _captainFrom,
856
+ options: _captainOptions,
857
+ ...normalizedCaptain
858
+ } = normalizedHost.captain;
859
+ captain = sessionAgentFromHostAgent(normalizedCaptain, "captain");
741
860
  const hostAgents = new Map(
742
861
  normalizedHost.players.map(({ id, ...agent }) => [id, agent]),
743
862
  );
@@ -770,7 +889,7 @@ export async function normalizeLaunchPlan(
770
889
  );
771
890
  }
772
891
  if (
773
- typeof preparedFrom !== 'string' ||
892
+ typeof preparedFrom !== "string" ||
774
893
  preparedFrom.trim().length === 0
775
894
  ) {
776
895
  throw new Error(
@@ -806,7 +925,7 @@ export async function normalizeLaunchPlan(
806
925
  `playbooks.${id}.from "${from}" failed to import: ${errorMessage(cause)}`,
807
926
  );
808
927
  }
809
- const entry = mod?.default;
928
+ const entry = snapshotRegistryEntry(mod?.default);
810
929
  const registryProblem = invalidRegistryEntryReason(entry);
811
930
  if (registryProblem !== undefined) {
812
931
  throw new Error(
@@ -852,6 +971,11 @@ export async function normalizeLaunchPlan(
852
971
  binding.effort === undefined
853
972
  ? agent.effort
854
973
  : overrideTuningSelection(binding.effort),
974
+ ...(binding.fastMode === undefined
975
+ ? agent.fastMode === undefined
976
+ ? {}
977
+ : { fastMode: agent.fastMode }
978
+ : { fastMode: binding.fastMode }),
855
979
  },
856
980
  ];
857
981
  }),
@@ -921,7 +1045,7 @@ export async function normalizeLaunchPlan(
921
1045
  catalog: Object.fromEntries(catalogEntries),
922
1046
  presentation,
923
1047
  },
924
- 'launch config',
1048
+ "launch config",
925
1049
  ),
926
1050
  );
927
1051
  }
@@ -930,8 +1054,8 @@ export async function normalizeLaunchPlan(
930
1054
  // generic launch planning tracks the installed host schema without importing
931
1055
  // or duplicating cligent's private validators.
932
1056
  export async function normalizeHostConfig(config) {
933
- const dir = mkdtempSync(join(tmpdir(), 'playbook-host-config-'));
934
- const path = join(dir, 'tmux-play.config.yaml');
1057
+ const dir = mkdtempSync(join(tmpdir(), "playbook-host-config-"));
1058
+ const path = join(dir, "tmux-play.config.yaml");
935
1059
  try {
936
1060
  writeFileSync(path, stringifyYaml(config));
937
1061
  return (await loadTmuxPlayConfig({ configPath: path })).config;
@@ -955,13 +1079,13 @@ export function projectTmuxConfig(plan) {
955
1079
  ]),
956
1080
  );
957
1081
  const captain = {
958
- ...projectHostAgent(plan.captain, 'captain'),
1082
+ ...projectHostAgent(plan.captain, "captain"),
959
1083
  from: PLAYBOOK_CAPTAIN_MODULE,
960
1084
  };
961
1085
  captain.options = {
962
1086
  playbooks,
963
1087
  sessionAgents: {
964
- captain: cloneJson(plan.captain, 'captain'),
1088
+ captain: cloneJson(plan.captain, "captain"),
965
1089
  players: Object.fromEntries(
966
1090
  plan.players.map(({ id, agent }) => [
967
1091
  id,
@@ -969,7 +1093,7 @@ export function projectTmuxConfig(plan) {
969
1093
  ]),
970
1094
  ),
971
1095
  },
972
- ...(typeof captain.adapter === 'string' && captain.adapter.length > 0
1096
+ ...(typeof captain.adapter === "string" && captain.adapter.length > 0
973
1097
  ? { captainAdapter: captain.adapter }
974
1098
  : {}),
975
1099
  };
@@ -981,20 +1105,20 @@ export function projectTmuxConfig(plan) {
981
1105
  })),
982
1106
  layout: projectHostLayout(plan.presentation.layout),
983
1107
  };
984
- if (hasOwn(plan.presentation, 'notifications')) {
1108
+ if (hasOwn(plan.presentation, "notifications")) {
985
1109
  config.notifications = cloneJson(
986
1110
  plan.presentation.notifications,
987
- 'presentation.notifications',
1111
+ "presentation.notifications",
988
1112
  );
989
1113
  }
990
- if (hasOwn(plan.presentation, 'theme')) {
991
- config.theme = cloneJson(plan.presentation.theme, 'presentation.theme');
1114
+ if (hasOwn(plan.presentation, "theme")) {
1115
+ config.theme = cloneJson(plan.presentation.theme, "presentation.theme");
992
1116
  }
993
1117
  return config;
994
1118
  }
995
1119
 
996
1120
  function projectHostLayout(layout) {
997
- const projected = cloneJson(layout, 'presentation.layout');
1121
+ const projected = cloneJson(layout, "presentation.layout");
998
1122
  // cligent's normalized runtime shape carries `columnWeights` as the
999
1123
  // derived active-shape value alongside both canonical shape fields. The
1000
1124
  // authored schema deliberately rejects that alias/canonical combination,
@@ -1045,14 +1169,14 @@ export function checkReadiness(adapters, env = process.env, home = homedir()) {
1045
1169
  const failingAdapters = [];
1046
1170
  const unknownAdapters = [];
1047
1171
  for (const adapter of adapters) {
1048
- if (adapter === 'claude') {
1049
- if (!env.ANTHROPIC_API_KEY && !existsSync(join(home, '.claude'))) {
1172
+ if (adapter === "claude") {
1173
+ if (!env.ANTHROPIC_API_KEY && !existsSync(join(home, ".claude"))) {
1050
1174
  failingAdapters.push(adapter);
1051
1175
  }
1052
1176
  continue;
1053
1177
  }
1054
- if (adapter === 'codex') {
1055
- if (!env.OPENAI_API_KEY && !existsSync(join(home, '.codex'))) {
1178
+ if (adapter === "codex") {
1179
+ if (!env.OPENAI_API_KEY && !existsSync(join(home, ".codex"))) {
1056
1180
  failingAdapters.push(adapter);
1057
1181
  }
1058
1182
  continue;
@@ -1071,6 +1195,142 @@ export function deriveLaunchReadiness(
1071
1195
  return { adapters, ...checkReadiness(adapters, env, home) };
1072
1196
  }
1073
1197
 
1198
+ // DR-043: a user-authored config cannot be regenerated, so the one-time move
1199
+ // to the canonical path is the deliberate exception to this project's
1200
+ // reject-don't-migrate posture. It runs before seeding, never clobbers a
1201
+ // canonical file, and is a no-op once the legacy file is gone.
1202
+ export function relocateLegacyUserConfig(
1203
+ userConfigPath,
1204
+ legacyUserConfigPath,
1205
+ onNotice,
1206
+ ) {
1207
+ if (legacyUserConfigPath === undefined) return;
1208
+ // Any canonical filesystem entry wins, including a dangling symlink. The
1209
+ // relocation is considered only when that exact pathname is absent; this
1210
+ // keeps an obsolete or malformed legacy entry from blocking a valid config.
1211
+ try {
1212
+ lstatSync(userConfigPath);
1213
+ return;
1214
+ } catch (error) {
1215
+ if (error?.code !== "ENOENT") throw error;
1216
+ }
1217
+ let source;
1218
+ try {
1219
+ source = lstatSync(legacyUserConfigPath);
1220
+ } catch (error) {
1221
+ if (error?.code === "ENOENT") return;
1222
+ throw error;
1223
+ }
1224
+ if (!source.isFile()) {
1225
+ throw new Error(
1226
+ `cannot relocate legacy config at ${legacyUserConfigPath}: ` +
1227
+ "the path is not a regular file",
1228
+ );
1229
+ }
1230
+ mkdirSync(dirname(userConfigPath), { recursive: true });
1231
+
1232
+ // Stage complete bytes and the exact source permission bits beside the
1233
+ // destination, then publish with an exclusive hard link. Unlike rename,
1234
+ // link can never replace a canonical entry that appears after inspection.
1235
+ const stagingDir = mkdtempSync(
1236
+ join(dirname(userConfigPath), ".playbook-config-relocation-"),
1237
+ );
1238
+ const stagedPath = join(stagingDir, "playbook.config.yaml");
1239
+ let published = false;
1240
+ try {
1241
+ copyFileSync(
1242
+ legacyUserConfigPath,
1243
+ stagedPath,
1244
+ constants.COPYFILE_EXCL,
1245
+ );
1246
+ chmodSync(stagedPath, source.mode & 0o7777);
1247
+ assertLegacyRelocationLocatorsSafe(
1248
+ readFileSync(stagedPath, "utf8"),
1249
+ userConfigPath,
1250
+ legacyUserConfigPath,
1251
+ );
1252
+ try {
1253
+ linkSync(stagedPath, userConfigPath);
1254
+ published = true;
1255
+ } catch (error) {
1256
+ // A pre-existing or concurrently published canonical config wins.
1257
+ // Keep both it and the still-authoritative legacy source unchanged.
1258
+ if (error?.code !== "EEXIST") throw error;
1259
+ }
1260
+ } finally {
1261
+ rmSync(stagingDir, { recursive: true, force: true });
1262
+ }
1263
+ if (!published) return;
1264
+
1265
+ rmSync(legacyUserConfigPath, { force: true });
1266
+ onNotice(
1267
+ `playbook: moved config from ${legacyUserConfigPath} to ${userConfigPath}\n`,
1268
+ );
1269
+ }
1270
+
1271
+ function assertLegacyRelocationLocatorsSafe(
1272
+ source,
1273
+ userConfigPath,
1274
+ legacyUserConfigPath,
1275
+ ) {
1276
+ // PBCLI-85: byte-preserving relocation is safe only when it also preserves
1277
+ // the absolute targets that primary-directory-relative locators denote.
1278
+ let top;
1279
+ try {
1280
+ top = parseYaml(source) ?? {};
1281
+ } catch {
1282
+ // Invalid YAML cannot be consumed at either location. Preserve the
1283
+ // relocation's existing byte-for-byte behavior and let config loading
1284
+ // report the parse fault from the canonical pathname.
1285
+ return;
1286
+ }
1287
+ if (!isObject(top)) return;
1288
+
1289
+ const affected = [];
1290
+ const legacyDir = dirname(legacyUserConfigPath);
1291
+ const canonicalDir = dirname(userConfigPath);
1292
+ if (
1293
+ typeof top.sessions === "string" &&
1294
+ top.sessions.length > 0 &&
1295
+ !top.sessions.startsWith("~") &&
1296
+ !isAbsolute(top.sessions)
1297
+ ) {
1298
+ const formerTarget = resolve(legacyDir, top.sessions);
1299
+ if (formerTarget !== resolve(canonicalDir, top.sessions)) {
1300
+ affected.push({ path: "sessions", replacement: formerTarget });
1301
+ }
1302
+ }
1303
+
1304
+ if (isObject(top.playbooks)) {
1305
+ for (const [id, block] of Object.entries(top.playbooks)) {
1306
+ const from = isObject(block) ? block.from : undefined;
1307
+ if (
1308
+ typeof from !== "string" ||
1309
+ !isRelativeFilesystemRegistrySpecifier(from)
1310
+ ) {
1311
+ continue;
1312
+ }
1313
+ const formerTarget = resolve(legacyDir, from);
1314
+ if (formerTarget !== resolve(canonicalDir, from)) {
1315
+ affected.push({
1316
+ path: `playbooks.${id}.from`,
1317
+ replacement: pathToFileURL(formerTarget).href,
1318
+ });
1319
+ }
1320
+ }
1321
+ }
1322
+
1323
+ if (affected.length === 0) return;
1324
+ const replacements = affected
1325
+ .map(({ path, replacement }) => `${path} = ${JSON.stringify(replacement)}`)
1326
+ .join("; ");
1327
+ throw new Error(
1328
+ `cannot relocate legacy config at ${legacyUserConfigPath}: relative ` +
1329
+ `locators would change targets under ${userConfigPath}; replace them ` +
1330
+ `with target-preserving absolute values before retrying: ${replacements}`,
1331
+ );
1332
+ }
1333
+
1074
1334
  function seedUserConfigIfMissing(userConfigPath, templatePath, onNotice) {
1075
1335
  if (existsSync(userConfigPath)) return;
1076
1336
  mkdirSync(dirname(userConfigPath), { recursive: true });
@@ -1082,7 +1342,7 @@ function seedUserConfigIfMissing(userConfigPath, templatePath, onNotice) {
1082
1342
  function migrateUserConfigIfRetired(userConfigPath, onNotice) {
1083
1343
  let text;
1084
1344
  try {
1085
- text = readFileSync(userConfigPath, 'utf8');
1345
+ text = readFileSync(userConfigPath, "utf8");
1086
1346
  } catch {
1087
1347
  return;
1088
1348
  }
@@ -1090,13 +1350,13 @@ function migrateUserConfigIfRetired(userConfigPath, onNotice) {
1090
1350
  try {
1091
1351
  migrated = migrateRetiredProfiles(text);
1092
1352
  } catch (error) {
1093
- if (error?.code === 'PLAYBOOK_LEGACY_PLAYERS') {
1353
+ if (error?.code === "PLAYBOOK_LEGACY_PLAYERS") {
1094
1354
  throw legacyPlayersError(error.legacyPath, userConfigPath);
1095
1355
  }
1096
1356
  throw new Error(
1097
1357
  `cannot migrate the retired profiles config at ${userConfigPath}: ` +
1098
1358
  `${errorMessage(error)} — edit it by hand: each agent takes its own ` +
1099
- 'adapter, model, effort, and permissions',
1359
+ "adapter, model, effort, fast mode, and permissions",
1100
1360
  );
1101
1361
  }
1102
1362
  if (migrated === undefined) return;
@@ -1124,33 +1384,33 @@ export function migrateRetiredProfiles(text) {
1124
1384
  const doc = parseYamlDocument(text);
1125
1385
  const contents = doc.contents;
1126
1386
  if (!contents || !Array.isArray(contents.items)) return undefined;
1127
- const playbooks = doc.get('playbooks');
1387
+ const playbooks = doc.get("playbooks");
1128
1388
  if (playbooks && Array.isArray(playbooks.items)) {
1129
1389
  for (const entry of playbooks.items) {
1130
1390
  const id = String(entry.key);
1131
- if (doc.getIn(['playbooks', id, 'players']) !== undefined) {
1391
+ if (doc.getIn(["playbooks", id, "players"]) !== undefined) {
1132
1392
  throw legacyPlayersError(`playbooks.${id}.players`);
1133
1393
  }
1134
1394
  }
1135
1395
  }
1136
- const profiles = doc.get('profiles');
1137
- const agentPaths = [['captain']];
1138
- const players = doc.get('players');
1396
+ const profiles = doc.get("profiles");
1397
+ const agentPaths = [["captain"]];
1398
+ const players = doc.get("players");
1139
1399
  if (players && Array.isArray(players.items)) {
1140
1400
  for (const player of players.items) {
1141
- agentPaths.push(['players', String(player.key)]);
1401
+ agentPaths.push(["players", String(player.key)]);
1142
1402
  }
1143
1403
  }
1144
1404
 
1145
1405
  const profileSettings = (name) =>
1146
- profiles && typeof profiles.get === 'function'
1406
+ profiles && typeof profiles.get === "function"
1147
1407
  ? profiles.get(name)
1148
1408
  : undefined;
1149
1409
 
1150
1410
  let changed = false;
1151
1411
  for (const path of agentPaths) {
1152
1412
  const node = doc.getIn(path, true);
1153
- if (node && typeof node.value === 'string' && !Array.isArray(node.items)) {
1413
+ if (node && typeof node.value === "string" && !Array.isArray(node.items)) {
1154
1414
  const settings = profileSettings(node.value);
1155
1415
  if (settings === undefined) continue;
1156
1416
  const inlined = settings.clone();
@@ -1158,16 +1418,16 @@ export function migrateRetiredProfiles(text) {
1158
1418
  doc.setIn(path, inlined);
1159
1419
  changed = true;
1160
1420
  } else if (node && Array.isArray(node.items)) {
1161
- const named = node.get?.('profile');
1421
+ const named = node.get?.("profile");
1162
1422
  if (named === undefined) continue;
1163
1423
  const settings = profileSettings(named);
1164
1424
  if (settings === undefined) {
1165
1425
  throw new Error(
1166
- `${path.join('.')}.profile names "${String(named)}", which no ` +
1167
- 'profiles entry defines',
1426
+ `${path.join(".")}.profile names "${String(named)}", which no ` +
1427
+ "profiles entry defines",
1168
1428
  );
1169
1429
  }
1170
- node.delete('profile');
1430
+ node.delete("profile");
1171
1431
  for (const item of settings.items) {
1172
1432
  if (node.has(String(item.key))) continue;
1173
1433
  node.add(item.clone());
@@ -1178,11 +1438,11 @@ export function migrateRetiredProfiles(text) {
1178
1438
 
1179
1439
  if (profiles !== undefined) {
1180
1440
  const index = contents.items.findIndex(
1181
- (item) => String(item.key) === 'profiles',
1441
+ (item) => String(item.key) === "profiles",
1182
1442
  );
1183
1443
  const lead =
1184
1444
  index === -1 ? undefined : contents.items[index]?.key?.commentBefore;
1185
- doc.delete('profiles');
1445
+ doc.delete("profiles");
1186
1446
  const header = keptHeaderComment(lead);
1187
1447
  const next = contents.items[0];
1188
1448
  if (header !== undefined && next?.key) {
@@ -1199,20 +1459,20 @@ export function migrateRetiredProfiles(text) {
1199
1459
  }
1200
1460
 
1201
1461
  const MIGRATION_NOTE =
1202
- ' Migrated by playbook 3.0.0: the top-level `profiles` map was removed and\n' +
1203
- ' each agent now carries its settings inline. The pre-migration file is\n' +
1204
- ' kept beside this one as a .bak. Comments below may still describe the\n' +
1205
- ' retired profiles model.';
1462
+ " Migrated by playbook 3.0.0: the top-level `profiles` map was removed and\n" +
1463
+ " each agent now carries its settings inline. The pre-migration file is\n" +
1464
+ " kept beside this one as a .bak. Comments below may still describe the\n" +
1465
+ " retired profiles model.";
1206
1466
 
1207
1467
  function carryScalarComment(node, inlined) {
1208
1468
  const parts = [node.commentBefore, node.comment].filter(
1209
- (part) => typeof part === 'string' && part.trim() !== '',
1469
+ (part) => typeof part === "string" && part.trim() !== "",
1210
1470
  );
1211
1471
  if (parts.length === 0) return;
1212
1472
  const first = inlined.items?.[0]?.key;
1213
1473
  if (!first) return;
1214
1474
  inlined.flow = false;
1215
- const carried = parts.join('\n');
1475
+ const carried = parts.join("\n");
1216
1476
  first.commentBefore =
1217
1477
  first.commentBefore === undefined
1218
1478
  ? carried
@@ -1220,24 +1480,25 @@ function carryScalarComment(node, inlined) {
1220
1480
  }
1221
1481
 
1222
1482
  function keptHeaderComment(comment) {
1223
- if (typeof comment !== 'string' || comment.trim() === '') return undefined;
1224
- const paragraphs = comment.split('\n\n');
1225
- const kept = paragraphs.slice(0, -1).join('\n\n');
1226
- return kept.trim() === '' ? undefined : kept;
1483
+ if (typeof comment !== "string" || comment.trim() === "") return undefined;
1484
+ const paragraphs = comment.split("\n\n");
1485
+ const kept = paragraphs.slice(0, -1).join("\n\n");
1486
+ return kept.trim() === "" ? undefined : kept;
1227
1487
  }
1228
1488
 
1229
1489
  function assertNoRetiredProfiles(top, configPath) {
1230
- const where = configPath ? ` in ${configPath}` : '';
1490
+ const where = configPath ? ` in ${configPath}` : "";
1231
1491
  if (top.profiles !== undefined) {
1232
1492
  throw new Error(
1233
1493
  `top-level "profiles" was removed${where}: write each agent's settings ` +
1234
- 'inline under captain and each top-level players.<player-id> ' +
1235
- '(adapter, model, effort, permissions)',
1494
+ "inline under captain and each top-level players.<player-id> " +
1495
+ "(adapter, model, effort, fast mode, permissions)",
1236
1496
  );
1237
1497
  }
1238
1498
  const legacyPath = findLegacyPlayersPath(top);
1239
- if (legacyPath !== undefined) throw legacyPlayersError(legacyPath, configPath);
1240
- const blocks = [['captain', top.captain]];
1499
+ if (legacyPath !== undefined)
1500
+ throw legacyPlayersError(legacyPath, configPath);
1501
+ const blocks = [["captain", top.captain]];
1241
1502
  const playersCfg = isObject(top.players) ? top.players : {};
1242
1503
  for (const [playerId, agent] of Object.entries(playersCfg)) {
1243
1504
  blocks.push([`players.${playerId}`, agent]);
@@ -1246,32 +1507,56 @@ function assertNoRetiredProfiles(top, configPath) {
1246
1507
  if (isObject(block) && block.profile !== undefined) {
1247
1508
  throw new Error(
1248
1509
  `${path}.profile was removed${where}: write the agent's settings ` +
1249
- 'inline in that block (adapter, model, effort, permissions)',
1510
+ "inline in that block (adapter, model, effort, fast mode, permissions)",
1250
1511
  );
1251
1512
  }
1252
1513
  }
1253
1514
  }
1254
1515
 
1255
- function invalidRegistryEntryReason(value) {
1256
- if (!isObject(value)) return 'the default export must be an object';
1516
+ // Imported manifests remain live JavaScript objects. Capture every member this
1517
+ // host consumes once, then validate and project only the detached record so a
1518
+ // getter or later mutation cannot make those two phases observe different
1519
+ // compatibility declarations or identities.
1520
+ export function snapshotRegistryEntry(value) {
1521
+ if (!isObject(value)) return value;
1522
+ return {
1523
+ id: value.id,
1524
+ command: value.command,
1525
+ intent: value.intent,
1526
+ artifactSchema: value.artifactSchema,
1527
+ runtimeProfile: value.runtimeProfile,
1528
+ requiredRoleIds: value.requiredRoleIds,
1529
+ concurrentRoleSets: value.concurrentRoleSets,
1530
+ validateOptions: value.validateOptions,
1531
+ createRuntime: value.createRuntime,
1532
+ };
1533
+ }
1534
+
1535
+ export function invalidRegistryEntryReason(value) {
1536
+ if (!isObject(value)) return "the default export must be an object";
1257
1537
  if (
1258
- typeof value.id !== 'string' ||
1538
+ typeof value.id !== "string" ||
1259
1539
  value.id.trim().length === 0 ||
1260
1540
  value.id !== value.id.trim()
1261
1541
  ) {
1262
- return 'id must be a canonical trimmed nonblank string';
1542
+ return "id must be a canonical trimmed nonblank string";
1263
1543
  }
1264
1544
  if (
1265
- typeof value.command !== 'string' ||
1545
+ typeof value.command !== "string" ||
1266
1546
  value.command.trim().length === 0 ||
1267
1547
  value.command !== value.command.trim()
1268
1548
  ) {
1269
- return 'command must be a canonical trimmed nonblank string';
1549
+ return "command must be a canonical trimmed nonblank string";
1270
1550
  }
1271
- if (typeof value.intent !== 'string') return 'intent must be a string';
1272
- if (value.artifactSchema !== 2) {
1273
- return 'artifactSchema must be exactly 2';
1551
+ if (typeof value.intent !== "string") return "intent must be a string";
1552
+ if (value.artifactSchema !== 3) {
1553
+ return "artifactSchema must be 3";
1274
1554
  }
1555
+ const runtimeProfileProblem = invalidRuntimeProfileReason(
1556
+ value.runtimeProfile,
1557
+ value.artifactSchema,
1558
+ );
1559
+ if (runtimeProfileProblem !== undefined) return runtimeProfileProblem;
1275
1560
  const roleProblem = invalidManifestRoles(value.requiredRoleIds);
1276
1561
  if (roleProblem !== undefined) return `requiredRoleIds ${roleProblem}`;
1277
1562
  const concurrentProblem = invalidConcurrentRoleSets(
@@ -1281,33 +1566,114 @@ function invalidRegistryEntryReason(value) {
1281
1566
  if (concurrentProblem !== undefined) {
1282
1567
  return `concurrentRoleSets ${concurrentProblem}`;
1283
1568
  }
1284
- if (typeof value.validateOptions !== 'function') {
1285
- return 'validateOptions must be a function';
1569
+ if (typeof value.validateOptions !== "function") {
1570
+ return "validateOptions must be a function";
1286
1571
  }
1287
- if (typeof value.createRuntime !== 'function') {
1288
- return 'createRuntime must be a function';
1572
+ if (typeof value.createRuntime !== "function") {
1573
+ return "createRuntime must be a function";
1289
1574
  }
1290
1575
  return undefined;
1291
1576
  }
1292
1577
 
1578
+ function invalidRuntimeProfileReason(value, advertisedArtifactSchema) {
1579
+ if (!isPlainObject(value)) {
1580
+ return "runtimeProfile must be a plain object";
1581
+ }
1582
+ const kindDescriptor = Object.getOwnPropertyDescriptor(value, "kind");
1583
+ if (
1584
+ kindDescriptor === undefined ||
1585
+ kindDescriptor.get !== undefined ||
1586
+ kindDescriptor.set !== undefined ||
1587
+ kindDescriptor.enumerable !== true
1588
+ ) {
1589
+ return "runtimeProfile.kind must be an enumerable data property";
1590
+ }
1591
+ const kind = kindDescriptor.value;
1592
+ if (kind === "shared-factory") {
1593
+ const profile = exactPlainDataRecord(value, ["kind", "compat"]);
1594
+ if (profile === undefined) {
1595
+ return "shared-factory runtimeProfile must contain exactly kind and compat data properties";
1596
+ }
1597
+ const compat = exactPlainDataRecord(profile.compat, [
1598
+ "artifactSchema",
1599
+ "runtimeAbi",
1600
+ ]);
1601
+ if (compat === undefined) {
1602
+ return "shared-factory runtimeProfile.compat must contain exactly artifactSchema and runtimeAbi data properties";
1603
+ }
1604
+ if (!Number.isSafeInteger(compat.artifactSchema)) {
1605
+ return "shared-factory runtimeProfile.compat.artifactSchema must be an integer";
1606
+ }
1607
+ if (!Number.isSafeInteger(compat.runtimeAbi)) {
1608
+ return "shared-factory runtimeProfile.compat.runtimeAbi must be an integer";
1609
+ }
1610
+ if (compat.artifactSchema !== advertisedArtifactSchema) {
1611
+ return "artifactSchema must match runtimeProfile.compat.artifactSchema";
1612
+ }
1613
+ return undefined;
1614
+ }
1615
+ if (kind === "bespoke") {
1616
+ const profile = exactPlainDataRecord(value, ["kind", "artifactSchema"]);
1617
+ if (profile === undefined) {
1618
+ return "bespoke runtimeProfile must contain exactly kind and artifactSchema data properties";
1619
+ }
1620
+ if (!Number.isSafeInteger(profile.artifactSchema)) {
1621
+ return "bespoke runtimeProfile.artifactSchema must be an integer";
1622
+ }
1623
+ if (profile.artifactSchema !== advertisedArtifactSchema) {
1624
+ return "artifactSchema must match runtimeProfile.artifactSchema";
1625
+ }
1626
+ return undefined;
1627
+ }
1628
+ return "runtimeProfile.kind must be shared-factory or bespoke";
1629
+ }
1630
+
1631
+ function exactPlainDataRecord(value, expectedKeys) {
1632
+ if (!isPlainObject(value)) return undefined;
1633
+ const keys = Reflect.ownKeys(value);
1634
+ if (
1635
+ keys.length !== expectedKeys.length ||
1636
+ keys.some((key) => typeof key !== "string" || !expectedKeys.includes(key))
1637
+ ) {
1638
+ return undefined;
1639
+ }
1640
+ const descriptors = Object.getOwnPropertyDescriptors(value);
1641
+ if (
1642
+ expectedKeys.some((key) => {
1643
+ const descriptor = descriptors[key];
1644
+ return (
1645
+ descriptor === undefined ||
1646
+ descriptor.get !== undefined ||
1647
+ descriptor.set !== undefined ||
1648
+ descriptor.enumerable !== true
1649
+ );
1650
+ })
1651
+ ) {
1652
+ return undefined;
1653
+ }
1654
+ return Object.fromEntries(
1655
+ expectedKeys.map((key) => [key, descriptors[key].value]),
1656
+ );
1657
+ }
1658
+
1293
1659
  function validateStoredStructuralProjection(value) {
1294
- const stored = cloneJson(value, 'stored structural projection');
1660
+ const stored = cloneJson(value, "stored structural projection");
1295
1661
  if (!isPlainObject(stored) || stored.schemaVersion !== 1) {
1296
- throw new Error('stored structural projection schema 1 is required');
1662
+ throw new Error("stored structural projection schema 1 is required");
1297
1663
  }
1298
- const captain = requireObject(stored.captain, 'stored structural captain');
1299
- if (typeof captain.adapter !== 'string' || captain.adapter.length === 0) {
1300
- throw new Error('stored structural captain must name an adapter');
1664
+ const captain = requireObject(stored.captain, "stored structural captain");
1665
+ if (typeof captain.adapter !== "string" || captain.adapter.length === 0) {
1666
+ throw new Error("stored structural captain must name an adapter");
1301
1667
  }
1302
1668
  if (!Array.isArray(stored.players) || !isPlainObject(stored.catalog)) {
1303
1669
  throw new Error(
1304
- 'stored structural projection must contain players and catalog',
1670
+ "stored structural projection must contain players and catalog",
1305
1671
  );
1306
1672
  }
1307
1673
  const playerIds = stored.players.map((player, index) => {
1308
1674
  const record = requireObject(player, `stored structural players.${index}`);
1309
1675
  assertPlayerId(record.id, `stored structural players.${index}.id`);
1310
- if (typeof record.adapter !== 'string' || record.adapter.length === 0) {
1676
+ if (typeof record.adapter !== "string" || record.adapter.length === 0) {
1311
1677
  throw new Error(
1312
1678
  `stored structural players.${index} must name an adapter`,
1313
1679
  );
@@ -1315,20 +1681,15 @@ function validateStoredStructuralProjection(value) {
1315
1681
  return record.id;
1316
1682
  });
1317
1683
  if (new Set(playerIds).size !== playerIds.length) {
1318
- throw new Error('stored structural player ids must be unique');
1684
+ throw new Error("stored structural player ids must be unique");
1319
1685
  }
1320
1686
  for (const [id, itemValue] of Object.entries(stored.catalog)) {
1321
1687
  const item = requireObject(itemValue, `stored structural catalog.${id}`);
1322
1688
  if (item.id !== id || id === RESERVED_CAPTAIN_PLAYBOOK_ID) {
1323
1689
  throw new Error(`stored structural catalog.${id}.id is invalid`);
1324
1690
  }
1325
- for (const field of [
1326
- 'from',
1327
- 'manifestCommand',
1328
- 'command',
1329
- 'intent',
1330
- ]) {
1331
- if (typeof item[field] !== 'string') {
1691
+ for (const field of ["from", "manifestCommand", "command", "intent"]) {
1692
+ if (typeof item[field] !== "string") {
1332
1693
  throw new Error(`stored structural catalog.${id}.${field} is invalid`);
1333
1694
  }
1334
1695
  }
@@ -1340,6 +1701,15 @@ function validateStoredStructuralProjection(value) {
1340
1701
  ) {
1341
1702
  throw new Error(`stored structural catalog.${id} is malformed`);
1342
1703
  }
1704
+ if (item.artifactSchema !== 3) {
1705
+ throw new Error(
1706
+ `stored structural catalog.${id}.artifactSchema must be 3`,
1707
+ );
1708
+ }
1709
+ rejectConfiguredHostCapabilities(
1710
+ item.options,
1711
+ `stored structural catalog.${id}.options`,
1712
+ );
1343
1713
  if (
1344
1714
  JSON.stringify(Object.keys(item.roles)) !==
1345
1715
  JSON.stringify(item.requiredRoleIds)
@@ -1387,7 +1757,7 @@ function projectSelectedMembers(top, selectedMembers) {
1387
1757
  if (selectedMembers === undefined) return top;
1388
1758
  const { playbookIds, playerIds } = validateSelectedMembers(selectedMembers);
1389
1759
  if (!isPlainObject(top)) {
1390
- throw new Error('config must contain only plain JSON objects');
1760
+ throw new Error("config must contain only plain JSON objects");
1391
1761
  }
1392
1762
 
1393
1763
  const descriptors = Object.getOwnPropertyDescriptors(top);
@@ -1395,7 +1765,7 @@ function projectSelectedMembers(top, selectedMembers) {
1395
1765
  for (const key of keys) {
1396
1766
  const descriptor = descriptors[key];
1397
1767
  if (
1398
- typeof key === 'symbol' ||
1768
+ typeof key === "symbol" ||
1399
1769
  descriptor?.get !== undefined ||
1400
1770
  descriptor?.set !== undefined ||
1401
1771
  descriptor?.enumerable !== true
@@ -1411,20 +1781,20 @@ function projectSelectedMembers(top, selectedMembers) {
1411
1781
  projected.playbooks = projectSelectedMap(
1412
1782
  projected.playbooks,
1413
1783
  playbookIds,
1414
- 'playbooks',
1784
+ "playbooks",
1415
1785
  );
1416
1786
  projected.players = projectSelectedMap(
1417
1787
  projected.players,
1418
1788
  playerIds,
1419
- 'players',
1789
+ "players",
1420
1790
  );
1421
1791
  return projected;
1422
1792
  }
1423
1793
 
1424
1794
  function validateSelectedMembers(selectedMembers) {
1425
- const selected = cloneJson(selectedMembers, 'selectedMembers');
1795
+ const selected = cloneJson(selectedMembers, "selectedMembers");
1426
1796
  const unknownSelectionKeys = Object.keys(selected).filter(
1427
- (key) => !['playbookIds', 'playerIds'].includes(key),
1797
+ (key) => !["playbookIds", "playerIds"].includes(key),
1428
1798
  );
1429
1799
  if (unknownSelectionKeys.length > 0) {
1430
1800
  throw new Error(
@@ -1434,12 +1804,9 @@ function validateSelectedMembers(selectedMembers) {
1434
1804
  return {
1435
1805
  playbookIds: selectedIdList(
1436
1806
  selected.playbookIds,
1437
- 'selectedMembers.playbookIds',
1438
- ),
1439
- playerIds: selectedIdList(
1440
- selected.playerIds,
1441
- 'selectedMembers.playerIds',
1807
+ "selectedMembers.playbookIds",
1442
1808
  ),
1809
+ playerIds: selectedIdList(selected.playerIds, "selectedMembers.playerIds"),
1443
1810
  };
1444
1811
  }
1445
1812
 
@@ -1452,7 +1819,7 @@ function projectSelectedLayer(value, selected, path) {
1452
1819
  for (const key of Reflect.ownKeys(value)) {
1453
1820
  const descriptor = descriptors[key];
1454
1821
  if (
1455
- typeof key === 'symbol' ||
1822
+ typeof key === "symbol" ||
1456
1823
  descriptor?.get !== undefined ||
1457
1824
  descriptor?.set !== undefined ||
1458
1825
  descriptor?.enumerable !== true
@@ -1461,12 +1828,12 @@ function projectSelectedLayer(value, selected, path) {
1461
1828
  `${path}.${String(key)} must be an enumerable data property`,
1462
1829
  );
1463
1830
  }
1464
- if (key === 'playbooks' || key === 'players') {
1831
+ if (key === "playbooks" || key === "players") {
1465
1832
  entries.push([
1466
1833
  key,
1467
1834
  projectOptionalSelectedMap(
1468
1835
  descriptor.value,
1469
- key === 'playbooks' ? selected.playbookIds : selected.playerIds,
1836
+ key === "playbooks" ? selected.playbookIds : selected.playerIds,
1470
1837
  `${path}.${key}`,
1471
1838
  ),
1472
1839
  ]);
@@ -1524,7 +1891,7 @@ function projectSelectedMap(value, ids, path) {
1524
1891
  function selectedIdList(value, path) {
1525
1892
  if (
1526
1893
  !Array.isArray(value) ||
1527
- value.some((id) => typeof id !== 'string' || id.trim().length === 0) ||
1894
+ value.some((id) => typeof id !== "string" || id.trim().length === 0) ||
1528
1895
  new Set(value).size !== value.length
1529
1896
  ) {
1530
1897
  throw new Error(`${path} must be a duplicate-free array of nonblank ids`);
@@ -1533,33 +1900,40 @@ function selectedIdList(value, path) {
1533
1900
  }
1534
1901
 
1535
1902
  function resolveRoleBinding(value, path) {
1536
- if (typeof value === 'string') {
1903
+ if (typeof value === "string") {
1537
1904
  assertPlayerId(value, path);
1538
1905
  return { playerId: value };
1539
1906
  }
1540
1907
  const block = requireObject(value, path);
1541
1908
  const unknown = Object.keys(block).filter(
1542
- (key) => !['player', 'model', 'effort'].includes(key),
1909
+ (key) => !["player", "model", "effort", "fastMode"].includes(key),
1543
1910
  );
1544
1911
  if (unknown.length > 0) {
1545
1912
  throw new Error(`${path} has unknown ${formatKeyList(unknown)}`);
1546
1913
  }
1547
1914
  assertPlayerId(block.player, `${path}.player`);
1548
- for (const field of ['model', 'effort']) {
1915
+ for (const field of ["model", "effort"]) {
1549
1916
  if (
1550
1917
  block[field] !== undefined &&
1551
1918
  block[field] !== false &&
1552
- (typeof block[field] !== 'string' || block[field].trim().length === 0)
1919
+ (typeof block[field] !== "string" || block[field].trim().length === 0)
1553
1920
  ) {
1554
1921
  throw new Error(
1555
1922
  `${path}.${field} must be a nonblank string or false for provider-default`,
1556
1923
  );
1557
1924
  }
1558
1925
  }
1926
+ // Fast mode carries no provider-default sentinel: omission inherits the
1927
+ // player's value and `false` is a literal request, so it is a plain boolean
1928
+ // rather than the string-or-false tuning shape above.
1929
+ if (block.fastMode !== undefined && typeof block.fastMode !== "boolean") {
1930
+ throw new Error(`${path}.fastMode must be a boolean`);
1931
+ }
1559
1932
  return {
1560
1933
  playerId: block.player,
1561
1934
  ...(block.model === undefined ? {} : { model: block.model }),
1562
1935
  ...(block.effort === undefined ? {} : { effort: block.effort }),
1936
+ ...(block.fastMode === undefined ? {} : { fastMode: block.fastMode }),
1563
1937
  };
1564
1938
  }
1565
1939
 
@@ -1572,17 +1946,21 @@ function applyTuningOverrides(agent, binding) {
1572
1946
  if (binding.effort === false) delete effective.effort;
1573
1947
  else effective.effort = binding.effort;
1574
1948
  }
1949
+ if (binding.fastMode !== undefined) effective.fastMode = binding.fastMode;
1575
1950
  return effective;
1576
1951
  }
1577
1952
 
1578
1953
  function sessionAgentFromHostAgent(agent, path) {
1579
1954
  if (!isObject(agent)) {
1580
- throw new Error('installed cligent omitted a retained agent');
1955
+ throw new Error("installed cligent omitted a retained agent");
1581
1956
  }
1582
1957
  return {
1583
1958
  adapter: agent.adapter,
1584
1959
  model: tuningSelection(agent.model, `${path}.model`),
1585
1960
  effort: tuningSelection(agent.effort, `${path}.effort`),
1961
+ ...(agent.fastMode === undefined
1962
+ ? {}
1963
+ : { fastMode: fastModeSelection(agent.fastMode, `${path}.fastMode`) }),
1586
1964
  ...(agent.instruction === undefined
1587
1965
  ? {}
1588
1966
  : { instruction: agent.instruction }),
@@ -1595,16 +1973,19 @@ function sessionAgentFromHostAgent(agent, path) {
1595
1973
  // Shared by the interactive and headless host projections. A tagged
1596
1974
  // provider-default is represented to cligent by omitting that configured
1597
1975
  // default; the complete tagged selection remains in sessionAgents.
1598
- export function projectHostAgent(agent, path = 'agent') {
1976
+ export function projectHostAgent(agent, path = "agent") {
1599
1977
  const normalized = cloneJson(agent, path);
1600
1978
  return {
1601
1979
  adapter: normalized.adapter,
1602
- ...(normalized.model?.kind === 'value'
1980
+ ...(normalized.model?.kind === "value"
1603
1981
  ? { model: normalized.model.value }
1604
1982
  : {}),
1605
- ...(normalized.effort?.kind === 'value'
1983
+ ...(normalized.effort?.kind === "value"
1606
1984
  ? { effort: normalized.effort.value }
1607
1985
  : {}),
1986
+ ...(normalized.fastMode === undefined
1987
+ ? {}
1988
+ : { fastMode: normalized.fastMode }),
1608
1989
  ...(normalized.instruction === undefined
1609
1990
  ? {}
1610
1991
  : { instruction: normalized.instruction }),
@@ -1614,22 +1995,29 @@ export function projectHostAgent(agent, path = 'agent') {
1614
1995
  };
1615
1996
  }
1616
1997
 
1998
+ function fastModeSelection(value, path) {
1999
+ if (typeof value !== "boolean") {
2000
+ throw new Error(`${path} must be a boolean`);
2001
+ }
2002
+ return value;
2003
+ }
2004
+
1617
2005
  function tuningSelection(value, path) {
1618
2006
  if (
1619
2007
  value !== undefined &&
1620
- (typeof value !== 'string' || value.trim().length === 0)
2008
+ (typeof value !== "string" || value.trim().length === 0)
1621
2009
  ) {
1622
2010
  throw new Error(`${path} must be a nonblank string`);
1623
2011
  }
1624
2012
  return value === undefined
1625
- ? { kind: 'provider-default' }
1626
- : { kind: 'value', value };
2013
+ ? { kind: "provider-default" }
2014
+ : { kind: "value", value };
1627
2015
  }
1628
2016
 
1629
2017
  function overrideTuningSelection(value) {
1630
2018
  return value === false
1631
- ? { kind: 'provider-default' }
1632
- : tuningSelection(value, 'role tuning override');
2019
+ ? { kind: "provider-default" }
2020
+ : tuningSelection(value, "role tuning override");
1633
2021
  }
1634
2022
 
1635
2023
  function canonicalizePreparedRegistrySpecifier(value, path) {
@@ -1646,7 +2034,7 @@ function canonicalizePreparedRegistrySpecifier(value, path) {
1646
2034
  `${path} preparation must return a canonical module specifier`,
1647
2035
  );
1648
2036
  }
1649
- if (value.startsWith('file:')) {
2037
+ if (value.startsWith("file:")) {
1650
2038
  let canonical;
1651
2039
  try {
1652
2040
  canonical = pathToFileURL(fileURLToPath(value)).href;
@@ -1661,7 +2049,7 @@ function canonicalizePreparedRegistrySpecifier(value, path) {
1661
2049
  }
1662
2050
 
1663
2051
  function assertPlayerId(value, path) {
1664
- if (typeof value !== 'string' || !PLAYER_ID_PATTERN.test(value)) {
2052
+ if (typeof value !== "string" || !PLAYER_ID_PATTERN.test(value)) {
1665
2053
  throw new Error(
1666
2054
  `${path} must name a player matching ${PLAYER_ID_PATTERN.source}`,
1667
2055
  );
@@ -1672,7 +2060,7 @@ function assertPlayerId(value, path) {
1672
2060
  }
1673
2061
 
1674
2062
  function assertRoleId(value, path) {
1675
- if (typeof value !== 'string' || !ROLE_ID_PATTERN.test(value)) {
2063
+ if (typeof value !== "string" || !ROLE_ID_PATTERN.test(value)) {
1676
2064
  throw new Error(`${path} must use a canonical lowercase local role id`);
1677
2065
  }
1678
2066
  if (value === RESERVED_CAPTAIN_ROLE_ID) {
@@ -1681,13 +2069,13 @@ function assertRoleId(value, path) {
1681
2069
  }
1682
2070
 
1683
2071
  function invalidManifestRoles(value) {
1684
- if (!Array.isArray(value)) return 'must be an array';
1685
- if (value.some((role) => typeof role !== 'string')) {
1686
- return 'must contain only strings';
2072
+ if (!Array.isArray(value)) return "must be an array";
2073
+ if (value.some((role) => typeof role !== "string")) {
2074
+ return "must contain only strings";
1687
2075
  }
1688
2076
  const canonical = value.map((role) => role.toLowerCase());
1689
2077
  if (new Set(canonical).size !== canonical.length) {
1690
- return 'contains roles that collide after canonical lowercase derivation';
2078
+ return "contains roles that collide after canonical lowercase derivation";
1691
2079
  }
1692
2080
  const invalid = value.find((role) => !ROLE_ID_PATTERN.test(role));
1693
2081
  if (invalid !== undefined) {
@@ -1700,7 +2088,7 @@ function invalidManifestRoles(value) {
1700
2088
  }
1701
2089
 
1702
2090
  function invalidConcurrentRoleSets(value, requiredRoleIds) {
1703
- if (!Array.isArray(value)) return 'must be an array';
2091
+ if (!Array.isArray(value)) return "must be an array";
1704
2092
  const required = new Set(requiredRoleIds);
1705
2093
  const seen = new Set();
1706
2094
  for (let index = 0; index < value.length; index += 1) {
@@ -1711,7 +2099,7 @@ function invalidConcurrentRoleSets(value, requiredRoleIds) {
1711
2099
  if (
1712
2100
  set.some(
1713
2101
  (role) =>
1714
- typeof role !== 'string' ||
2102
+ typeof role !== "string" ||
1715
2103
  !ROLE_ID_PATTERN.test(role) ||
1716
2104
  role === RESERVED_CAPTAIN_ROLE_ID ||
1717
2105
  !required.has(role),
@@ -1735,11 +2123,11 @@ function assertExactRoleBindings(id, configured, required) {
1735
2123
  if (missing.length > 0 || extra.length > 0) {
1736
2124
  throw new Error(
1737
2125
  `playbooks.${id}.roles must exactly cover requiredRoleIds` +
1738
- `${missing.length === 0 ? '' : `; missing ${missing.map(JSON.stringify).join(', ')}`}` +
2126
+ `${missing.length === 0 ? "" : `; missing ${missing.map(JSON.stringify).join(", ")}`}` +
1739
2127
  `${
1740
2128
  extra.length === 0
1741
- ? ''
1742
- : `; extra ${extra.map(JSON.stringify).join(', ')}`
2129
+ ? ""
2130
+ : `; extra ${extra.map(JSON.stringify).join(", ")}`
1743
2131
  }`,
1744
2132
  );
1745
2133
  }
@@ -1751,7 +2139,7 @@ function assertConcurrentPlayers(id, sets, roles) {
1751
2139
  if (new Set(playerIds).size !== playerIds.length) {
1752
2140
  throw new Error(
1753
2141
  `playbooks.${id}.concurrentRoleSets ${JSON.stringify(set)} must bind ` +
1754
- 'to pairwise-distinct player ids',
2142
+ "to pairwise-distinct player ids",
1755
2143
  );
1756
2144
  }
1757
2145
  }
@@ -1764,7 +2152,7 @@ function distinct(values) {
1764
2152
  function findLegacyPlayersPath(top) {
1765
2153
  const playbooks = isObject(top?.playbooks) ? top.playbooks : {};
1766
2154
  for (const [id, block] of Object.entries(playbooks)) {
1767
- if (isObject(block) && hasOwn(block, 'players')) {
2155
+ if (isObject(block) && hasOwn(block, "players")) {
1768
2156
  return `playbooks.${id}.players`;
1769
2157
  }
1770
2158
  }
@@ -1772,27 +2160,31 @@ function findLegacyPlayersPath(top) {
1772
2160
  }
1773
2161
 
1774
2162
  function legacyPlayersError(path, configPath) {
1775
- const where = configPath ? ` in ${configPath}` : '';
2163
+ const where = configPath ? ` in ${configPath}` : "";
1776
2164
  const error = new Error(
1777
2165
  `${path} was removed in the explicit-session-player major release${where}: ` +
1778
- 'define stable ids in top-level players and bind them explicitly under ' +
1779
- 'playbooks.<id>.roles; automatic migration would choose which prior ' +
1780
- 'conversations share a session',
2166
+ "define stable ids in top-level players and bind them explicitly under " +
2167
+ "playbooks.<id>.roles; automatic migration would choose which prior " +
2168
+ "conversations share a session",
1781
2169
  );
1782
- error.code = 'PLAYBOOK_LEGACY_PLAYERS';
2170
+ error.code = "PLAYBOOK_LEGACY_PLAYERS";
1783
2171
  error.legacyPath = path;
1784
2172
  return error;
1785
2173
  }
1786
2174
 
1787
2175
  function cloneJson(value, path, seen = new Set()) {
1788
- if (value === null || typeof value === 'string' || typeof value === 'boolean') {
2176
+ if (
2177
+ value === null ||
2178
+ typeof value === "string" ||
2179
+ typeof value === "boolean"
2180
+ ) {
1789
2181
  return value;
1790
2182
  }
1791
- if (typeof value === 'number') {
2183
+ if (typeof value === "number") {
1792
2184
  if (Number.isFinite(value)) return value;
1793
2185
  throw new Error(`${path} must contain only finite JSON numbers`);
1794
2186
  }
1795
- if (typeof value !== 'object') {
2187
+ if (typeof value !== "object") {
1796
2188
  throw new Error(`${path} must contain only JSON values`);
1797
2189
  }
1798
2190
  if (seen.has(value)) throw new Error(`${path} must not contain a cycle`);
@@ -1803,8 +2195,8 @@ function cloneJson(value, path, seen = new Set()) {
1803
2195
  if (
1804
2196
  keys.some(
1805
2197
  (key) =>
1806
- typeof key === 'symbol' ||
1807
- (key !== 'length' &&
2198
+ typeof key === "symbol" ||
2199
+ (key !== "length" &&
1808
2200
  (!/^(?:0|[1-9]\d*)$/.test(key) || Number(key) >= value.length)),
1809
2201
  )
1810
2202
  ) {
@@ -1826,9 +2218,7 @@ function cloneJson(value, path, seen = new Set()) {
1826
2218
  `${path}[${index}] must be an enumerable data property`,
1827
2219
  );
1828
2220
  }
1829
- cloned.push(
1830
- cloneJson(descriptor.value, `${path}[${index}]`, seen),
1831
- );
2221
+ cloned.push(cloneJson(descriptor.value, `${path}[${index}]`, seen));
1832
2222
  }
1833
2223
  return cloned;
1834
2224
  }
@@ -1837,7 +2227,7 @@ function cloneJson(value, path, seen = new Set()) {
1837
2227
  throw new Error(`${path} must contain only plain JSON objects`);
1838
2228
  }
1839
2229
  const keys = Reflect.ownKeys(value);
1840
- if (keys.some((key) => typeof key === 'symbol')) {
2230
+ if (keys.some((key) => typeof key === "symbol")) {
1841
2231
  throw new Error(`${path} must not contain symbol keys`);
1842
2232
  }
1843
2233
  const descriptors = Object.getOwnPropertyDescriptors(value);
@@ -1863,7 +2253,7 @@ function cloneJson(value, path, seen = new Set()) {
1863
2253
  }
1864
2254
 
1865
2255
  function deepFreeze(value) {
1866
- if (value === null || typeof value !== 'object' || Object.isFrozen(value)) {
2256
+ if (value === null || typeof value !== "object" || Object.isFrozen(value)) {
1867
2257
  return value;
1868
2258
  }
1869
2259
  for (const child of Object.values(value)) deepFreeze(child);
@@ -1871,7 +2261,7 @@ function deepFreeze(value) {
1871
2261
  }
1872
2262
 
1873
2263
  function isObject(value) {
1874
- return typeof value === 'object' && value !== null && !Array.isArray(value);
2264
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1875
2265
  }
1876
2266
 
1877
2267
  function isPlainObject(value) {
@@ -1890,9 +2280,9 @@ function requireObject(value, path) {
1890
2280
  }
1891
2281
 
1892
2282
  function formatKeyList(keys) {
1893
- return `${keys.length === 1 ? 'key' : 'keys'} ${keys
2283
+ return `${keys.length === 1 ? "key" : "keys"} ${keys
1894
2284
  .map((key) => JSON.stringify(key))
1895
- .join(', ')}`;
2285
+ .join(", ")}`;
1896
2286
  }
1897
2287
 
1898
2288
  function errorMessage(error) {