@sublang/playbook 0.7.0 → 0.9.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.
@@ -6,19 +6,15 @@ import createPlaybookRuntime, {
6
6
  type PlaybookRuntime,
7
7
  } from './code.playbook.js';
8
8
 
9
- // PBRT-29/30: CODE runtime options are carried under
10
- // `captain.options.code`, a namespaced object the host forwards
11
- // verbatim through `captain.options`. cligent neither reads nor
12
- // validates `options.code`; the CODE registry entry is the sole
13
- // validator. The CODE options schema defines one key, `committer`: an
14
- // optional Committer-alias player id, one of the baked player ids
15
- // `coder` / `reviewer`. A valid `options.code` is absent, `{}`, or
16
- // `{ committer: 'coder' | 'reviewer' }`; every other key is unknown
17
- // and rejected with a path-named error, and an out-of-range
18
- // `committer` value is rejected naming `captain.options.code.committer`.
19
- // A further CODE option shall be introduced as its own higher-numbered
20
- // item that widens `CODE_OPTION_KEYS`; the validator still fails closed
21
- // on stray keys.
9
+ // PBRT-30: the CODE registry entry validates the option slice the shell
10
+ // passes it (`captain.options.playbooks.code.options`), not a namespace
11
+ // it extracts from the full Captain options bag. The schema defines one
12
+ // key, `committer`: an optional Committer-alias player id, one of the
13
+ // baked role ids `coder` / `reviewer`. A valid slice is absent, `{}`, or
14
+ // `{ committer: 'coder' | 'reviewer' }`; every other key is unknown and
15
+ // rejected with a path-named error. A further CODE option shall be
16
+ // introduced as its own higher-numbered item that widens
17
+ // `CODE_OPTION_KEYS`; the validator still fails closed on stray keys.
22
18
  const CODE_OPTION_KEYS = new Set<string>(['committer']);
23
19
  const COMMITTER_PLAYER_IDS = new Set<string>(['coder', 'reviewer']);
24
20
  export const codeCopyPasteGuardNames = [
@@ -55,6 +51,47 @@ export const codeStateCountLabels = {
55
51
  reviewChangesAndChallengesMixed: 'review round',
56
52
  } as const;
57
53
 
54
+ function countNoun(count: number, singular: string, plural = `${singular}s`): string {
55
+ return `${count} ${count === 1 ? singular : plural}`;
56
+ }
57
+
58
+ // PBRT-15 / CAPTAIN-19: the CODE saved-counts line wording is
59
+ // registry-owned. The shell renders this exact line through the active
60
+ // entry's summary policy rather than hardcoding CODE phrasing.
61
+ export function codeSavedCountsLine(
62
+ counts: { interruptions: number; copyPastes: number },
63
+ rounds: number,
64
+ ): string {
65
+ return [
66
+ 'Saved you',
67
+ countNoun(counts.interruptions, 'interruption'),
68
+ 'and',
69
+ countNoun(counts.copyPastes, 'copy-paste'),
70
+ 'across',
71
+ countNoun(rounds, 'round'),
72
+ 'of reviews/rebuttals.',
73
+ ].join(' ');
74
+ }
75
+
76
+ // CAPTAIN-20 summary policy: the counted state-id labels, the
77
+ // copy-paste guard names, and the saved-counts line wording an entry
78
+ // owns for the shell's turn-summary aggregation. An entry without a
79
+ // summary policy opts out of visible turn summaries.
80
+ export interface PlaybookSummaryPolicy {
81
+ stateCountLabels: Readonly<Record<string, string>>;
82
+ copyPasteGuardNames: readonly string[];
83
+ savedCountsLine(
84
+ counts: { interruptions: number; copyPastes: number },
85
+ rounds: number,
86
+ ): string;
87
+ }
88
+
89
+ export const codeSummaryPolicy: PlaybookSummaryPolicy = {
90
+ stateCountLabels: codeStateCountLabels,
91
+ copyPasteGuardNames: codeCopyPasteGuardNames,
92
+ savedCountsLine: codeSavedCountsLine,
93
+ };
94
+
58
95
  // The validated CODE options set. `committer`, when present, is the
59
96
  // resolved Committer-alias player id (PBRT-8 / PBRT-30); a future CODE
60
97
  // option widens both this type and `CODE_OPTION_KEYS`.
@@ -77,31 +114,39 @@ export interface CodePlaybookRegistryEntry {
77
114
  id: 'code';
78
115
  command: 'code';
79
116
  intent: string;
117
+ requiredRoleIds: readonly string[];
80
118
  idleStateId: 'ready';
81
119
  finalStateId: 'done';
82
- copyPasteGuardNames: readonly string[];
83
- stateCountLabels: typeof codeStateCountLabels;
120
+ parkStateIds: readonly string[];
121
+ summaryPolicy: PlaybookSummaryPolicy;
84
122
  validateOptions(captainOptions: unknown): CodeOptions;
85
123
  createRuntime(options: CreateCodeRuntimeOptions): PlaybookRuntime;
86
124
  }
87
125
 
88
- export function validateCodeOptions(captainOptions: unknown): CodeOptions {
89
- const code = readCodeNamespace(captainOptions);
90
- if (code === undefined) return {};
91
- if (typeof code !== 'object' || code === null || Array.isArray(code)) {
92
- throw new Error('captain.options.code must be an object');
126
+ export function validateCodeOptions(optionSlice: unknown): CodeOptions {
127
+ if (optionSlice === undefined) return {};
128
+ if (
129
+ typeof optionSlice !== 'object' ||
130
+ optionSlice === null ||
131
+ Array.isArray(optionSlice)
132
+ ) {
133
+ throw new Error('captain.options.playbooks.code.options must be an object');
93
134
  }
94
- for (const key of Object.keys(code)) {
135
+ const slice = optionSlice as Record<string, unknown>;
136
+ for (const key of Object.keys(slice)) {
95
137
  if (!CODE_OPTION_KEYS.has(key)) {
96
- throw new Error(`Unknown config field captain.options.code.${key}`);
138
+ throw new Error(
139
+ `Unknown config field captain.options.playbooks.code.options.${key}`,
140
+ );
97
141
  }
98
142
  }
99
143
  const options: CodeOptions = {};
100
- const committer = (code as Record<string, unknown>).committer;
144
+ const committer = slice.committer;
101
145
  if (committer !== undefined) {
102
146
  if (typeof committer !== 'string' || !COMMITTER_PLAYER_IDS.has(committer)) {
103
147
  throw new Error(
104
- "captain.options.code.committer must be 'coder' or 'reviewer'",
148
+ "captain.options.playbooks.code.options.committer must be " +
149
+ "'coder' or 'reviewer'",
105
150
  );
106
151
  }
107
152
  options.committer = committer as 'coder' | 'reviewer';
@@ -109,17 +154,6 @@ export function validateCodeOptions(captainOptions: unknown): CodeOptions {
109
154
  return options;
110
155
  }
111
156
 
112
- function readCodeNamespace(captainOptions: unknown): unknown {
113
- if (
114
- typeof captainOptions !== 'object' ||
115
- captainOptions === null ||
116
- Array.isArray(captainOptions)
117
- ) {
118
- return undefined;
119
- }
120
- return (captainOptions as Record<string, unknown>).code;
121
- }
122
-
123
157
  function playerIdentity(
124
158
  players: readonly RegistryPlayer[],
125
159
  id: string,
@@ -148,12 +182,18 @@ export const codePlaybookRegistryEntry: CodePlaybookRegistryEntry = {
148
182
  id: 'code',
149
183
  command: 'code',
150
184
  intent: 'software development / SDLC coding workflow',
185
+ requiredRoleIds: ['coder', 'reviewer'],
151
186
  idleStateId: 'ready',
152
187
  finalStateId: 'done',
153
- copyPasteGuardNames: codeCopyPasteGuardNames,
154
- stateCountLabels: codeStateCountLabels,
188
+ parkStateIds: ['failed', 'awaitBossReply'],
189
+ summaryPolicy: codeSummaryPolicy,
155
190
  validateOptions: validateCodeOptions,
156
191
  createRuntime(options) {
157
192
  return createPlaybookRuntime(createCodeRuntimeOptions(options));
158
193
  },
159
194
  };
195
+
196
+ // CAPTAIN-16 / PBRT-16: the published `@sublang/playbook/code/registry`
197
+ // module's default export is the CODE registry entry the Playbook Captain
198
+ // shell loads when a playbook block's `from` names this module.
199
+ export default codePlaybookRegistryEntry;
@@ -1,21 +1,24 @@
1
1
  import type { Captain } from '@sublang/cligent/tmux-play';
2
2
  import type { PlaybookRuntime } from './code.playbook.js';
3
- import { type RegistryPlayer } from './code.registry.js';
3
+ import type { PlaybookSummaryPolicy, RegistryPlayer } from './code.registry.js';
4
4
  export interface CreatePlaybookRuntimeOptions {
5
5
  captainOptions: unknown;
6
6
  players: readonly RegistryPlayer[];
7
7
  }
8
+ export interface PlaybookCaptainDeps {
9
+ loadModule?: (specifier: string) => Promise<unknown>;
10
+ }
8
11
  export interface PlaybookCaptainRegistryEntry {
9
12
  id: string;
10
13
  command: string;
11
14
  intent: string;
15
+ requiredRoleIds: readonly string[];
12
16
  idleStateId: string;
13
17
  finalStateId: string;
14
- copyPasteGuardNames: readonly string[];
15
- stateCountLabels?: Readonly<Record<string, string>>;
18
+ parkStateIds: readonly string[];
19
+ summaryPolicy?: PlaybookSummaryPolicy;
16
20
  validateOptions(captainOptions: unknown): unknown;
17
21
  createRuntime(options: CreatePlaybookRuntimeOptions): PlaybookRuntime;
18
22
  }
19
- export declare const playbookCaptainRegistry: readonly PlaybookCaptainRegistryEntry[];
20
- export declare function createPlaybookCaptainShell(options: unknown, registry?: readonly PlaybookCaptainRegistryEntry[]): Captain;
23
+ export declare function createPlaybookCaptainShell(options: unknown, deps?: PlaybookCaptainDeps): Captain;
21
24
  export default createPlaybookCaptainShell;
@@ -1,20 +1,13 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
- import { codePlaybookRegistryEntry, } from './code.registry.js';
4
3
  const SUB_RUNTIME_FSM_TOPIC = 'playbook.fsm.state';
5
4
  const SHELL_FSM_TOPIC = 'playbook.captain.fsm.state';
6
- export const playbookCaptainRegistry = [
7
- codePlaybookRegistryEntry,
8
- ];
9
5
  function parseRegisteredCommand(prompt) {
10
6
  const match = /^\/([A-Za-z][A-Za-z0-9_-]*)(?:\s+([\s\S]*))?$/.exec(prompt.trim());
11
7
  if (!match)
12
8
  return undefined;
13
9
  return { command: match[1], text: (match[2] ?? '').trim() };
14
10
  }
15
- function playbookCommandLabel(entry) {
16
- return `/${entry.command}`;
17
- }
18
11
  function visibleChatEnvelope(message) {
19
12
  return [
20
13
  'You are the Playbook Captain shell.',
@@ -23,7 +16,6 @@ function visibleChatEnvelope(message) {
23
16
  ].join('\n\n');
24
17
  }
25
18
  function visibleTurnSummaryEnvelope(input) {
26
- const savedLine = savedCountsLine(input.counts, input.reviewRebuttalRounds);
27
19
  return [
28
20
  'You are the Playbook Captain shell.',
29
21
  'This is visible Boss chat after a sub-playbook command completed. Do not reveal hidden control JSON, hidden router decisions, or hidden judge replies.',
@@ -32,38 +24,24 @@ function visibleTurnSummaryEnvelope(input) {
32
24
  'State only what was done or what changed; do not explain how it was done.',
33
25
  'Do not list raw state names, transitions, guard names, prompts, tools, hidden calls, or reasoning.',
34
26
  'If progress detail is useful, use only the aggregate progress phrase supplied below.',
35
- 'Do not mention counts for plan or implementation steps, tests green, or any other internal state.',
36
- `Then write the saved-counts line exactly: ${savedLine}`,
27
+ "Do not mention counts for states the active playbook's summary policy does not label.",
28
+ `Then write the saved-counts line exactly: ${input.savedLine}`,
37
29
  'Use the exact counts supplied; do not change them.',
38
- 'Do not repeat the exact review/rebuttal round count outside the saved-counts line.',
30
+ 'Do not repeat the exact progress round count outside the saved-counts line.',
39
31
  `Playbook: ${input.playbookId}`,
40
32
  `Submitted Boss text:\n${input.submittedText}`,
41
33
  `Progress counts:\n${input.progressPhrase}`,
42
34
  `Counts:\n${JSON.stringify({
43
35
  ...input.counts,
44
- reviewRebuttalRounds: input.reviewRebuttalRounds,
36
+ progressRounds: input.progressRounds,
45
37
  })}`,
46
38
  ].join('\n\n');
47
39
  }
48
- function countNoun(count, singular, plural = `${singular}s`) {
49
- return `${count} ${count === 1 ? singular : plural}`;
50
- }
51
- function savedCountsLine(counts, reviewRebuttalRounds) {
52
- return [
53
- 'Saved you',
54
- countNoun(counts.interruptions, 'interruption'),
55
- 'and',
56
- countNoun(counts.copyPastes, 'copy-paste'),
57
- 'across',
58
- countNoun(reviewRebuttalRounds, 'round'),
59
- 'of reviews/rebuttals.',
60
- ].join(' ');
61
- }
62
40
  function stateCountLabel(stateId, entry) {
63
41
  if (stateId === entry.idleStateId || stateId === entry.finalStateId) {
64
42
  return undefined;
65
43
  }
66
- const registryLabel = entry.stateCountLabels?.[stateId]?.trim();
44
+ const registryLabel = entry.summaryPolicy?.stateCountLabels?.[stateId]?.trim();
67
45
  return registryLabel || undefined;
68
46
  }
69
47
  function pluralizeStateCount(label, count) {
@@ -88,17 +66,102 @@ function summaryProgressRoundCount(stateCounts) {
88
66
  function guardFromJudgeReply(finalText) {
89
67
  return /"guard"\s*:\s*"([^"]+)"/.exec(finalText)?.[1];
90
68
  }
91
- function normalizeRegistry(registry) {
69
+ function isValidRegistryEntry(value) {
70
+ if (typeof value !== 'object' || value === null)
71
+ return false;
72
+ const e = value;
73
+ return (typeof e.id === 'string' &&
74
+ typeof e.command === 'string' &&
75
+ typeof e.intent === 'string' &&
76
+ Array.isArray(e.requiredRoleIds) &&
77
+ typeof e.idleStateId === 'string' &&
78
+ typeof e.finalStateId === 'string' &&
79
+ Array.isArray(e.parkStateIds) &&
80
+ typeof e.validateOptions === 'function' &&
81
+ typeof e.createRuntime === 'function');
82
+ }
83
+ function readPlaybooksConfig(options) {
84
+ if (typeof options !== 'object' || options === null)
85
+ return undefined;
86
+ const pb = options.playbooks;
87
+ if (typeof pb !== 'object' || pb === null || Array.isArray(pb)) {
88
+ return undefined;
89
+ }
90
+ return pb;
91
+ }
92
+ // Resolve the active registry at init from `captain.options.playbooks`
93
+ // (CAPTAIN-16): each enabled playbook is loaded from its explicit `from`
94
+ // module and bound to namespaced `<id>-<role>` host players.
95
+ async function buildEnablements(options, players, loadModule) {
96
+ const entries = [];
92
97
  const byCommand = new Map();
93
98
  const byId = new Map();
94
- for (const entry of registry) {
95
- byCommand.set(entry.command, entry);
99
+ const enablementById = new Map();
100
+ const config = readPlaybooksConfig(options);
101
+ if (config === undefined) {
102
+ throw new Error('captain.options.playbooks is required');
103
+ }
104
+ const ids = Object.keys(config);
105
+ if (ids.length === 0) {
106
+ throw new Error('captain.options.playbooks must enable at least one playbook');
107
+ }
108
+ for (const id of ids) {
109
+ const block = config[id];
110
+ if (typeof block !== 'object' || block === null || Array.isArray(block)) {
111
+ throw new Error(`captain.options.playbooks.${id} must be an object`);
112
+ }
113
+ const record = block;
114
+ const from = record.from;
115
+ if (typeof from !== 'string' || from.length === 0) {
116
+ throw new Error(`captain.options.playbooks.${id}.from must be a module specifier`);
117
+ }
118
+ let mod;
119
+ try {
120
+ mod = await loadModule(from);
121
+ }
122
+ catch (cause) {
123
+ throw new Error(`captain.options.playbooks.${id}.from "${from}" failed to import: ${String(cause?.message ?? cause)}`);
124
+ }
125
+ const entry = mod?.default;
126
+ if (!isValidRegistryEntry(entry)) {
127
+ throw new Error(`captain.options.playbooks.${id}.from "${from}" exposes no valid registry entry`);
128
+ }
129
+ if (entry.id !== id) {
130
+ throw new Error(`captain.options.playbooks.${id} key must equal the module manifest id "${entry.id}"`);
131
+ }
132
+ if (byId.has(entry.id)) {
133
+ throw new Error(`captain.options.playbooks has a duplicate playbook id "${entry.id}"`);
134
+ }
135
+ const command = typeof record.command === 'string' && record.command.length > 0
136
+ ? record.command
137
+ : entry.command;
138
+ if (byCommand.has(command)) {
139
+ throw new Error(`captain.options.playbooks has a duplicate effective command "${command}"`);
140
+ }
141
+ const boundPlayers = entry.requiredRoleIds.map((role) => {
142
+ const host = players.find((p) => p.id === `${entry.id}-${role}`);
143
+ return { id: role, adapter: host?.adapter, model: host?.model };
144
+ });
145
+ entries.push(entry);
96
146
  byId.set(entry.id, entry);
147
+ byCommand.set(command, entry);
148
+ enablementById.set(entry.id, {
149
+ entry,
150
+ command,
151
+ optionInput: record.options,
152
+ boundPlayers,
153
+ hostPlayerId: (localRole) => `${entry.id}-${localRole}`,
154
+ visiblePlayerIds: entry.requiredRoleIds.map((role) => `${entry.id}-${role}`),
155
+ });
97
156
  }
98
- return { entries: registry, byCommand, byId };
157
+ return { entries, byCommand, byId, enablementById };
99
158
  }
100
- export function createPlaybookCaptainShell(options, registry = playbookCaptainRegistry) {
101
- const { entries, byCommand, byId } = normalizeRegistry(registry);
159
+ export function createPlaybookCaptainShell(options, deps = {}) {
160
+ const loadModule = deps.loadModule ?? ((specifier) => import(specifier));
161
+ let entries = [];
162
+ let byCommand = new Map();
163
+ let byId = new Map();
164
+ let enablementById = new Map();
102
165
  let session;
103
166
  let players = [];
104
167
  let activeContext;
@@ -189,8 +252,7 @@ export function createPlaybookCaptainShell(options, registry = playbookCaptainRe
189
252
  return;
190
253
  }
191
254
  if (stateId === active.entry.idleStateId ||
192
- stateId === 'failed' ||
193
- stateId === 'awaitBossReply') {
255
+ active.entry.parkStateIds.includes(stateId)) {
194
256
  await setMode('engaged.parked', `sub-runtime:${stateId}`);
195
257
  }
196
258
  };
@@ -199,7 +261,10 @@ export function createPlaybookCaptainShell(options, registry = playbookCaptainRe
199
261
  if (!activeContext) {
200
262
  throw new Error('callPlayer invoked outside a Boss turn');
201
263
  }
202
- const result = await activeContext.callPlayer(playerId, prompt);
264
+ const hostPlayerId = active
265
+ ? active.enablement.hostPlayerId(playerId)
266
+ : playerId;
267
+ const result = await activeContext.callPlayer(hostPlayerId, prompt);
203
268
  if (activeTurnSummary) {
204
269
  activeTurnSummary.counts.interruptions++;
205
270
  }
@@ -224,7 +289,7 @@ export function createPlaybookCaptainShell(options, registry = playbookCaptainRe
224
289
  }
225
290
  const guard = guardFromJudgeReply(result.finalText);
226
291
  if (guard &&
227
- active?.entry.copyPasteGuardNames.includes(guard) &&
292
+ active?.entry.summaryPolicy?.copyPasteGuardNames.includes(guard) &&
228
293
  activeTurnSummary) {
229
294
  activeTurnSummary.counts.copyPastes++;
230
295
  }
@@ -240,41 +305,53 @@ export function createPlaybookCaptainShell(options, registry = playbookCaptainRe
240
305
  await requireSession().emitTelemetry(event);
241
306
  },
242
307
  });
308
+ // CAPTAIN-22: before dispatching to a playbook, request tmux-play
309
+ // visibility for that playbook's generated host players. A pane
310
+ // reconciliation failure is display-only in tmux-play and does not
311
+ // reject; the legacy path carries no generated set and skips this.
312
+ const requestVisibility = async (enablement) => {
313
+ const ids = enablement.visiblePlayerIds;
314
+ if (!ids || ids.length === 0 || !activeContext)
315
+ return;
316
+ await activeContext.setVisiblePlayers(ids);
317
+ };
243
318
  const engage = async (entry) => {
244
319
  if (active?.entry.id === entry.id)
245
320
  return active;
321
+ const enablement = enablementById.get(entry.id);
246
322
  const runtime = entry.createRuntime({
247
- captainOptions: options,
248
- players,
323
+ captainOptions: enablement.optionInput,
324
+ players: enablement.boundPlayers,
249
325
  });
250
- active = { entry, runtime };
326
+ active = { entry, enablement, runtime };
251
327
  latestSubRuntimeStateId = undefined;
252
328
  pendingBossQuestion = undefined;
253
329
  lastError = undefined;
254
330
  finalDisposalRequested = undefined;
255
331
  await setMode('engaged.parked', 'engage', entry.id);
256
332
  await runtime.init(createPorts());
257
- await requireSession().emitStatus(`◇ ${playbookCommandLabel(entry)} started`);
333
+ await requireSession().emitStatus(`◇ /${enablement.command} started`);
258
334
  return active;
259
335
  };
260
336
  const submitToActive = async (engagement, text, context) => {
337
+ await requestVisibility(engagement.enablement);
338
+ const policy = engagement.entry.summaryPolicy;
261
339
  const summaryCounts = {
262
340
  interruptions: 0,
263
341
  copyPastes: 0,
264
342
  };
265
343
  const summaryStateCounts = new Map();
266
344
  let shouldSummarize = false;
267
- activeTurnSummary = {
268
- counts: summaryCounts,
269
- stateCounts: summaryStateCounts,
270
- };
345
+ activeTurnSummary = policy
346
+ ? { counts: summaryCounts, stateCounts: summaryStateCounts }
347
+ : undefined;
271
348
  await setMode('engaged.driving', 'submit');
272
349
  try {
273
350
  await engagement.runtime.handleBossInput({
274
351
  text,
275
352
  signal: context.signal,
276
353
  });
277
- shouldSummarize = true;
354
+ shouldSummarize = policy !== undefined;
278
355
  }
279
356
  finally {
280
357
  activeTurnSummary = undefined;
@@ -286,13 +363,15 @@ export function createPlaybookCaptainShell(options, registry = playbookCaptainRe
286
363
  await setMode('engaged.parked', 'turn.settled');
287
364
  }
288
365
  }
289
- if (shouldSummarize) {
366
+ if (shouldSummarize && policy) {
367
+ const progressRounds = summaryProgressRoundCount(summaryStateCounts);
290
368
  await callVisibleTurnSummary(context, {
291
369
  playbookId: engagement.entry.id,
292
370
  submittedText: text,
293
371
  counts: summaryCounts,
294
372
  progressPhrase: summaryProgressPhrase(summaryStateCounts),
295
- reviewRebuttalRounds: summaryProgressRoundCount(summaryStateCounts),
373
+ progressRounds,
374
+ savedLine: policy.savedCountsLine(summaryCounts, progressRounds),
296
375
  });
297
376
  }
298
377
  };
@@ -301,7 +380,7 @@ export function createPlaybookCaptainShell(options, registry = playbookCaptainRe
301
380
  if (!engagement)
302
381
  return;
303
382
  const playbookId = engagement.entry.id;
304
- const commandLabel = playbookCommandLabel(engagement.entry);
383
+ const commandLabel = `/${engagement.enablement.command}`;
305
384
  active = undefined;
306
385
  finalDisposalRequested = undefined;
307
386
  if (reason === 'dispose') {
@@ -349,7 +428,7 @@ export function createPlaybookCaptainShell(options, registry = playbookCaptainRe
349
428
  `Ledger:\n${JSON.stringify(ledgerSnapshot())}`,
350
429
  `Registry:\n${JSON.stringify(entries.map((entry) => ({
351
430
  id: entry.id,
352
- command: entry.command,
431
+ command: enablementById.get(entry.id)?.command ?? entry.command,
353
432
  intent: entry.intent,
354
433
  })))}`,
355
434
  `Boss message:\n${prompt}`,
@@ -439,18 +518,20 @@ export function createPlaybookCaptainShell(options, registry = playbookCaptainRe
439
518
  await routerClarification(context);
440
519
  return;
441
520
  }
442
- const dismissedCommandLabel = playbookCommandLabel(active.entry);
521
+ const dismissedCommandLabel = `/${active.enablement.command}`;
443
522
  await disposeActive('dismiss');
444
523
  await callVisibleChat(context, decision.text ?? `${dismissedCommandLabel} stopped.`);
445
524
  };
446
525
  const handleRegisteredCommand = async (entry, text, context) => {
526
+ const enablement = enablementById.get(entry.id);
447
527
  if (active && active.entry.id !== entry.id) {
448
- await callVisibleChat(context, `/${active.entry.command} is already running. Finish or stop it before starting /${entry.command}.`);
528
+ await callVisibleChat(context, `/${active.enablement.command} is already running. Finish or stop it before starting /${enablement.command}.`);
449
529
  return;
450
530
  }
451
531
  const engagement = await engage(entry);
452
532
  if (text.length === 0) {
453
- await callVisibleChat(context, `Ask what task to run with /${entry.command}.`);
533
+ await requestVisibility(engagement.enablement);
534
+ await callVisibleChat(context, `Ask what task to run with /${enablement.command}.`);
454
535
  return;
455
536
  }
456
537
  await submitToActive(engagement, text, context);
@@ -459,8 +540,13 @@ export function createPlaybookCaptainShell(options, registry = playbookCaptainRe
459
540
  async init(initSession) {
460
541
  session = initSession;
461
542
  players = initSession.players;
462
- for (const entry of entries) {
463
- entry.validateOptions(options);
543
+ const built = await buildEnablements(options, players, loadModule);
544
+ entries = built.entries;
545
+ byCommand = built.byCommand;
546
+ byId = built.byId;
547
+ enablementById = built.enablementById;
548
+ for (const enablement of enablementById.values()) {
549
+ enablement.entry.validateOptions(enablement.optionInput);
464
550
  }
465
551
  await setMode('chat', 'init');
466
552
  },