@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.
@@ -11,9 +11,9 @@ import type {
11
11
  PlaybookPorts,
12
12
  PlaybookRuntime,
13
13
  } from './code.playbook.js';
14
- import {
15
- codePlaybookRegistryEntry,
16
- type RegistryPlayer,
14
+ import type {
15
+ PlaybookSummaryPolicy,
16
+ RegistryPlayer,
17
17
  } from './code.registry.js';
18
18
 
19
19
  export interface CreatePlaybookRuntimeOptions {
@@ -21,20 +21,38 @@ export interface CreatePlaybookRuntimeOptions {
21
21
  players: readonly RegistryPlayer[];
22
22
  }
23
23
 
24
+ export interface PlaybookCaptainDeps {
25
+ loadModule?: (specifier: string) => Promise<unknown>;
26
+ }
27
+
24
28
  export interface PlaybookCaptainRegistryEntry {
25
29
  id: string;
26
30
  command: string;
27
31
  intent: string;
32
+ requiredRoleIds: readonly string[];
28
33
  idleStateId: string;
29
34
  finalStateId: string;
30
- copyPasteGuardNames: readonly string[];
31
- stateCountLabels?: Readonly<Record<string, string>>;
35
+ parkStateIds: readonly string[];
36
+ summaryPolicy?: PlaybookSummaryPolicy;
32
37
  validateOptions(captainOptions: unknown): unknown;
33
38
  createRuntime(options: CreatePlaybookRuntimeOptions): PlaybookRuntime;
34
39
  }
35
40
 
41
+ // Per-enabled-playbook binding the shell resolves at init from
42
+ // `captain.options.playbooks`: each playbook binds its local roles to
43
+ // `<id>-<role>` host players and carries the generated visible set.
44
+ interface Enablement {
45
+ entry: PlaybookCaptainRegistryEntry;
46
+ command: string;
47
+ optionInput: unknown;
48
+ boundPlayers: readonly RegistryPlayer[];
49
+ hostPlayerId: (localRole: string) => string;
50
+ visiblePlayerIds?: readonly string[];
51
+ }
52
+
36
53
  interface ActiveEngagement {
37
54
  entry: PlaybookCaptainRegistryEntry;
55
+ enablement: Enablement;
38
56
  runtime: PlaybookRuntime;
39
57
  }
40
58
 
@@ -70,10 +88,6 @@ interface ActiveTurnSummary {
70
88
  stateCounts: Map<string, number>;
71
89
  }
72
90
 
73
- export const playbookCaptainRegistry: readonly PlaybookCaptainRegistryEntry[] = [
74
- codePlaybookRegistryEntry,
75
- ];
76
-
77
91
  function parseRegisteredCommand(
78
92
  prompt: string,
79
93
  ): { command: string; text: string } | undefined {
@@ -84,10 +98,6 @@ function parseRegisteredCommand(
84
98
  return { command: match[1], text: (match[2] ?? '').trim() };
85
99
  }
86
100
 
87
- function playbookCommandLabel(entry: PlaybookCaptainRegistryEntry): string {
88
- return `/${entry.command}`;
89
- }
90
-
91
101
  function visibleChatEnvelope(message: string): string {
92
102
  return [
93
103
  'You are the Playbook Captain shell.',
@@ -101,9 +111,9 @@ function visibleTurnSummaryEnvelope(input: {
101
111
  submittedText: string;
102
112
  counts: TurnSummaryCounts;
103
113
  progressPhrase: string;
104
- reviewRebuttalRounds: number;
114
+ progressRounds: number;
115
+ savedLine: string;
105
116
  }): string {
106
- const savedLine = savedCountsLine(input.counts, input.reviewRebuttalRounds);
107
117
  return [
108
118
  'You are the Playbook Captain shell.',
109
119
  'This is visible Boss chat after a sub-playbook command completed. Do not reveal hidden control JSON, hidden router decisions, or hidden judge replies.',
@@ -112,39 +122,20 @@ function visibleTurnSummaryEnvelope(input: {
112
122
  'State only what was done or what changed; do not explain how it was done.',
113
123
  'Do not list raw state names, transitions, guard names, prompts, tools, hidden calls, or reasoning.',
114
124
  'If progress detail is useful, use only the aggregate progress phrase supplied below.',
115
- 'Do not mention counts for plan or implementation steps, tests green, or any other internal state.',
116
- `Then write the saved-counts line exactly: ${savedLine}`,
125
+ "Do not mention counts for states the active playbook's summary policy does not label.",
126
+ `Then write the saved-counts line exactly: ${input.savedLine}`,
117
127
  'Use the exact counts supplied; do not change them.',
118
- 'Do not repeat the exact review/rebuttal round count outside the saved-counts line.',
128
+ 'Do not repeat the exact progress round count outside the saved-counts line.',
119
129
  `Playbook: ${input.playbookId}`,
120
130
  `Submitted Boss text:\n${input.submittedText}`,
121
131
  `Progress counts:\n${input.progressPhrase}`,
122
132
  `Counts:\n${JSON.stringify({
123
133
  ...input.counts,
124
- reviewRebuttalRounds: input.reviewRebuttalRounds,
134
+ progressRounds: input.progressRounds,
125
135
  })}`,
126
136
  ].join('\n\n');
127
137
  }
128
138
 
129
- function countNoun(count: number, singular: string, plural = `${singular}s`): string {
130
- return `${count} ${count === 1 ? singular : plural}`;
131
- }
132
-
133
- function savedCountsLine(
134
- counts: TurnSummaryCounts,
135
- reviewRebuttalRounds: number,
136
- ): string {
137
- return [
138
- 'Saved you',
139
- countNoun(counts.interruptions, 'interruption'),
140
- 'and',
141
- countNoun(counts.copyPastes, 'copy-paste'),
142
- 'across',
143
- countNoun(reviewRebuttalRounds, 'round'),
144
- 'of reviews/rebuttals.',
145
- ].join(' ');
146
- }
147
-
148
139
  function stateCountLabel(
149
140
  stateId: string,
150
141
  entry: PlaybookCaptainRegistryEntry,
@@ -152,7 +143,7 @@ function stateCountLabel(
152
143
  if (stateId === entry.idleStateId || stateId === entry.finalStateId) {
153
144
  return undefined;
154
145
  }
155
- const registryLabel = entry.stateCountLabels?.[stateId]?.trim();
146
+ const registryLabel = entry.summaryPolicy?.stateCountLabels?.[stateId]?.trim();
156
147
  return registryLabel || undefined;
157
148
  }
158
149
 
@@ -178,27 +169,144 @@ function guardFromJudgeReply(finalText: string): string | undefined {
178
169
  return /"guard"\s*:\s*"([^"]+)"/.exec(finalText)?.[1];
179
170
  }
180
171
 
181
- function normalizeRegistry(
182
- registry: readonly PlaybookCaptainRegistryEntry[],
183
- ): {
172
+ function isValidRegistryEntry(
173
+ value: unknown,
174
+ ): value is PlaybookCaptainRegistryEntry {
175
+ if (typeof value !== 'object' || value === null) return false;
176
+ const e = value as Record<string, unknown>;
177
+ return (
178
+ typeof e.id === 'string' &&
179
+ typeof e.command === 'string' &&
180
+ typeof e.intent === 'string' &&
181
+ Array.isArray(e.requiredRoleIds) &&
182
+ typeof e.idleStateId === 'string' &&
183
+ typeof e.finalStateId === 'string' &&
184
+ Array.isArray(e.parkStateIds) &&
185
+ typeof e.validateOptions === 'function' &&
186
+ typeof e.createRuntime === 'function'
187
+ );
188
+ }
189
+
190
+ function readPlaybooksConfig(
191
+ options: unknown,
192
+ ): Record<string, unknown> | undefined {
193
+ if (typeof options !== 'object' || options === null) return undefined;
194
+ const pb = (options as Record<string, unknown>).playbooks;
195
+ if (typeof pb !== 'object' || pb === null || Array.isArray(pb)) {
196
+ return undefined;
197
+ }
198
+ return pb as Record<string, unknown>;
199
+ }
200
+
201
+ interface BuiltRegistry {
184
202
  entries: readonly PlaybookCaptainRegistryEntry[];
185
203
  byCommand: Map<string, PlaybookCaptainRegistryEntry>;
186
204
  byId: Map<string, PlaybookCaptainRegistryEntry>;
187
- } {
205
+ enablementById: Map<string, Enablement>;
206
+ }
207
+
208
+ // Resolve the active registry at init from `captain.options.playbooks`
209
+ // (CAPTAIN-16): each enabled playbook is loaded from its explicit `from`
210
+ // module and bound to namespaced `<id>-<role>` host players.
211
+ async function buildEnablements(
212
+ options: unknown,
213
+ players: readonly RegistryPlayer[],
214
+ loadModule: (specifier: string) => Promise<unknown>,
215
+ ): Promise<BuiltRegistry> {
216
+ const entries: PlaybookCaptainRegistryEntry[] = [];
188
217
  const byCommand = new Map<string, PlaybookCaptainRegistryEntry>();
189
218
  const byId = new Map<string, PlaybookCaptainRegistryEntry>();
190
- for (const entry of registry) {
191
- byCommand.set(entry.command, entry);
219
+ const enablementById = new Map<string, Enablement>();
220
+
221
+ const config = readPlaybooksConfig(options);
222
+ if (config === undefined) {
223
+ throw new Error('captain.options.playbooks is required');
224
+ }
225
+
226
+ const ids = Object.keys(config);
227
+ if (ids.length === 0) {
228
+ throw new Error(
229
+ 'captain.options.playbooks must enable at least one playbook',
230
+ );
231
+ }
232
+ for (const id of ids) {
233
+ const block = config[id];
234
+ if (typeof block !== 'object' || block === null || Array.isArray(block)) {
235
+ throw new Error(`captain.options.playbooks.${id} must be an object`);
236
+ }
237
+ const record = block as Record<string, unknown>;
238
+ const from = record.from;
239
+ if (typeof from !== 'string' || from.length === 0) {
240
+ throw new Error(
241
+ `captain.options.playbooks.${id}.from must be a module specifier`,
242
+ );
243
+ }
244
+ let mod: unknown;
245
+ try {
246
+ mod = await loadModule(from);
247
+ } catch (cause) {
248
+ throw new Error(
249
+ `captain.options.playbooks.${id}.from "${from}" failed to import: ${String(
250
+ (cause as { message?: unknown })?.message ?? cause,
251
+ )}`,
252
+ );
253
+ }
254
+ const entry = (mod as { default?: unknown })?.default;
255
+ if (!isValidRegistryEntry(entry)) {
256
+ throw new Error(
257
+ `captain.options.playbooks.${id}.from "${from}" exposes no valid registry entry`,
258
+ );
259
+ }
260
+ if (entry.id !== id) {
261
+ throw new Error(
262
+ `captain.options.playbooks.${id} key must equal the module manifest id "${entry.id}"`,
263
+ );
264
+ }
265
+ if (byId.has(entry.id)) {
266
+ throw new Error(
267
+ `captain.options.playbooks has a duplicate playbook id "${entry.id}"`,
268
+ );
269
+ }
270
+ const command =
271
+ typeof record.command === 'string' && record.command.length > 0
272
+ ? record.command
273
+ : entry.command;
274
+ if (byCommand.has(command)) {
275
+ throw new Error(
276
+ `captain.options.playbooks has a duplicate effective command "${command}"`,
277
+ );
278
+ }
279
+ const boundPlayers = entry.requiredRoleIds.map((role) => {
280
+ const host = players.find((p) => p.id === `${entry.id}-${role}`);
281
+ return { id: role, adapter: host?.adapter, model: host?.model };
282
+ });
283
+ entries.push(entry);
192
284
  byId.set(entry.id, entry);
285
+ byCommand.set(command, entry);
286
+ enablementById.set(entry.id, {
287
+ entry,
288
+ command,
289
+ optionInput: record.options,
290
+ boundPlayers,
291
+ hostPlayerId: (localRole) => `${entry.id}-${localRole}`,
292
+ visiblePlayerIds: entry.requiredRoleIds.map(
293
+ (role) => `${entry.id}-${role}`,
294
+ ),
295
+ });
193
296
  }
194
- return { entries: registry, byCommand, byId };
297
+ return { entries, byCommand, byId, enablementById };
195
298
  }
196
299
 
197
300
  export function createPlaybookCaptainShell(
198
301
  options: unknown,
199
- registry: readonly PlaybookCaptainRegistryEntry[] = playbookCaptainRegistry,
302
+ deps: PlaybookCaptainDeps = {},
200
303
  ): Captain {
201
- const { entries, byCommand, byId } = normalizeRegistry(registry);
304
+ const loadModule =
305
+ deps.loadModule ?? ((specifier: string) => import(specifier));
306
+ let entries: readonly PlaybookCaptainRegistryEntry[] = [];
307
+ let byCommand = new Map<string, PlaybookCaptainRegistryEntry>();
308
+ let byId = new Map<string, PlaybookCaptainRegistryEntry>();
309
+ let enablementById = new Map<string, Enablement>();
202
310
  let session: CaptainSession | undefined;
203
311
  let players: readonly RegistryPlayer[] = [];
204
312
  let activeContext: CaptainContext | undefined;
@@ -315,8 +423,7 @@ export function createPlaybookCaptainShell(
315
423
 
316
424
  if (
317
425
  stateId === active.entry.idleStateId ||
318
- stateId === 'failed' ||
319
- stateId === 'awaitBossReply'
426
+ active.entry.parkStateIds.includes(stateId)
320
427
  ) {
321
428
  await setMode('engaged.parked', `sub-runtime:${stateId}`);
322
429
  }
@@ -327,7 +434,10 @@ export function createPlaybookCaptainShell(
327
434
  if (!activeContext) {
328
435
  throw new Error('callPlayer invoked outside a Boss turn');
329
436
  }
330
- const result = await activeContext.callPlayer(playerId, prompt);
437
+ const hostPlayerId = active
438
+ ? active.enablement.hostPlayerId(playerId)
439
+ : playerId;
440
+ const result = await activeContext.callPlayer(hostPlayerId, prompt);
331
441
  if (activeTurnSummary) {
332
442
  activeTurnSummary.counts.interruptions++;
333
443
  }
@@ -355,7 +465,7 @@ export function createPlaybookCaptainShell(
355
465
  const guard = guardFromJudgeReply(result.finalText);
356
466
  if (
357
467
  guard &&
358
- active?.entry.copyPasteGuardNames.includes(guard) &&
468
+ active?.entry.summaryPolicy?.copyPasteGuardNames.includes(guard) &&
359
469
  activeTurnSummary
360
470
  ) {
361
471
  activeTurnSummary.counts.copyPastes++;
@@ -376,24 +486,33 @@ export function createPlaybookCaptainShell(
376
486
  },
377
487
  });
378
488
 
489
+ // CAPTAIN-22: before dispatching to a playbook, request tmux-play
490
+ // visibility for that playbook's generated host players. A pane
491
+ // reconciliation failure is display-only in tmux-play and does not
492
+ // reject; the legacy path carries no generated set and skips this.
493
+ const requestVisibility = async (enablement: Enablement): Promise<void> => {
494
+ const ids = enablement.visiblePlayerIds;
495
+ if (!ids || ids.length === 0 || !activeContext) return;
496
+ await activeContext.setVisiblePlayers(ids);
497
+ };
498
+
379
499
  const engage = async (
380
500
  entry: PlaybookCaptainRegistryEntry,
381
501
  ): Promise<ActiveEngagement> => {
382
502
  if (active?.entry.id === entry.id) return active;
503
+ const enablement = enablementById.get(entry.id)!;
383
504
  const runtime = entry.createRuntime({
384
- captainOptions: options,
385
- players,
505
+ captainOptions: enablement.optionInput,
506
+ players: enablement.boundPlayers,
386
507
  });
387
- active = { entry, runtime };
508
+ active = { entry, enablement, runtime };
388
509
  latestSubRuntimeStateId = undefined;
389
510
  pendingBossQuestion = undefined;
390
511
  lastError = undefined;
391
512
  finalDisposalRequested = undefined;
392
513
  await setMode('engaged.parked', 'engage', entry.id);
393
514
  await runtime.init(createPorts());
394
- await requireSession().emitStatus(
395
- `◇ ${playbookCommandLabel(entry)} started`,
396
- );
515
+ await requireSession().emitStatus(`◇ /${enablement.command} started`);
397
516
  return active;
398
517
  };
399
518
 
@@ -402,23 +521,24 @@ export function createPlaybookCaptainShell(
402
521
  text: string,
403
522
  context: CaptainContext,
404
523
  ): Promise<void> => {
524
+ await requestVisibility(engagement.enablement);
525
+ const policy = engagement.entry.summaryPolicy;
405
526
  const summaryCounts: TurnSummaryCounts = {
406
527
  interruptions: 0,
407
528
  copyPastes: 0,
408
529
  };
409
530
  const summaryStateCounts = new Map<string, number>();
410
531
  let shouldSummarize = false;
411
- activeTurnSummary = {
412
- counts: summaryCounts,
413
- stateCounts: summaryStateCounts,
414
- };
532
+ activeTurnSummary = policy
533
+ ? { counts: summaryCounts, stateCounts: summaryStateCounts }
534
+ : undefined;
415
535
  await setMode('engaged.driving', 'submit');
416
536
  try {
417
537
  await engagement.runtime.handleBossInput({
418
538
  text,
419
539
  signal: context.signal,
420
540
  });
421
- shouldSummarize = true;
541
+ shouldSummarize = policy !== undefined;
422
542
  } finally {
423
543
  activeTurnSummary = undefined;
424
544
  if (active === engagement && finalDisposalRequested === engagement) {
@@ -428,13 +548,15 @@ export function createPlaybookCaptainShell(
428
548
  await setMode('engaged.parked', 'turn.settled');
429
549
  }
430
550
  }
431
- if (shouldSummarize) {
551
+ if (shouldSummarize && policy) {
552
+ const progressRounds = summaryProgressRoundCount(summaryStateCounts);
432
553
  await callVisibleTurnSummary(context, {
433
554
  playbookId: engagement.entry.id,
434
555
  submittedText: text,
435
556
  counts: summaryCounts,
436
557
  progressPhrase: summaryProgressPhrase(summaryStateCounts),
437
- reviewRebuttalRounds: summaryProgressRoundCount(summaryStateCounts),
558
+ progressRounds,
559
+ savedLine: policy.savedCountsLine(summaryCounts, progressRounds),
438
560
  });
439
561
  }
440
562
  };
@@ -445,7 +567,7 @@ export function createPlaybookCaptainShell(
445
567
  const engagement = active;
446
568
  if (!engagement) return;
447
569
  const playbookId = engagement.entry.id;
448
- const commandLabel = playbookCommandLabel(engagement.entry);
570
+ const commandLabel = `/${engagement.enablement.command}`;
449
571
  active = undefined;
450
572
  finalDisposalRequested = undefined;
451
573
  if (reason === 'dispose') {
@@ -487,7 +609,8 @@ export function createPlaybookCaptainShell(
487
609
  submittedText: string;
488
610
  counts: TurnSummaryCounts;
489
611
  progressPhrase: string;
490
- reviewRebuttalRounds: number;
612
+ progressRounds: number;
613
+ savedLine: string;
491
614
  },
492
615
  ): Promise<void> => {
493
616
  const result = await context.callCaptain(visibleTurnSummaryEnvelope(input));
@@ -513,7 +636,7 @@ export function createPlaybookCaptainShell(
513
636
  `Registry:\n${JSON.stringify(
514
637
  entries.map((entry) => ({
515
638
  id: entry.id,
516
- command: entry.command,
639
+ command: enablementById.get(entry.id)?.command ?? entry.command,
517
640
  intent: entry.intent,
518
641
  })),
519
642
  )}`,
@@ -626,7 +749,7 @@ export function createPlaybookCaptainShell(
626
749
  return;
627
750
  }
628
751
 
629
- const dismissedCommandLabel = playbookCommandLabel(active.entry);
752
+ const dismissedCommandLabel = `/${active.enablement.command}`;
630
753
  await disposeActive('dismiss');
631
754
  await callVisibleChat(
632
755
  context,
@@ -639,19 +762,21 @@ export function createPlaybookCaptainShell(
639
762
  text: string,
640
763
  context: CaptainContext,
641
764
  ): Promise<void> => {
765
+ const enablement = enablementById.get(entry.id)!;
642
766
  if (active && active.entry.id !== entry.id) {
643
767
  await callVisibleChat(
644
768
  context,
645
- `/${active.entry.command} is already running. Finish or stop it before starting /${entry.command}.`,
769
+ `/${active.enablement.command} is already running. Finish or stop it before starting /${enablement.command}.`,
646
770
  );
647
771
  return;
648
772
  }
649
773
 
650
774
  const engagement = await engage(entry);
651
775
  if (text.length === 0) {
776
+ await requestVisibility(engagement.enablement);
652
777
  await callVisibleChat(
653
778
  context,
654
- `Ask what task to run with /${entry.command}.`,
779
+ `Ask what task to run with /${enablement.command}.`,
655
780
  );
656
781
  return;
657
782
  }
@@ -663,8 +788,13 @@ export function createPlaybookCaptainShell(
663
788
  async init(initSession: CaptainSession): Promise<void> {
664
789
  session = initSession;
665
790
  players = initSession.players;
666
- for (const entry of entries) {
667
- entry.validateOptions(options);
791
+ const built = await buildEnablements(options, players, loadModule);
792
+ entries = built.entries;
793
+ byCommand = built.byCommand;
794
+ byId = built.byId;
795
+ enablementById = built.enablementById;
796
+ for (const enablement of enablementById.values()) {
797
+ enablement.entry.validateOptions(enablement.optionInput);
668
798
  }
669
799
  await setMode('chat', 'init');
670
800
  },
@@ -0,0 +1,58 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
+
4
+ # Generic `playbook` launcher config.
5
+ # Top-level host fields only — no `config:` wrapper, no top-level `players`.
6
+ # The launcher injects captain.from and the namespaced <id>-<role> host
7
+ # players, then launches cligent's tmux-play under the Playbook Captain shell.
8
+
9
+ # Reusable agent settings. A profile id must not be an adapter shorthand
10
+ # (claude, codex). Reference a profile by id from captain / players below.
11
+ # Profile ids name the underlying agent/model (e.g. claude-opus,
12
+ # codex-gpt) so they read distinctly from the captain / coder / reviewer
13
+ # player roles that reference them.
14
+ # Every seeded agent runs in cligent's protected auto mode
15
+ # (permissions.mode: auto): claude maps it to permissionMode auto, codex to
16
+ # on-request + auto_review. Codex roles also grant writablePaths: ['.git']
17
+ # so commit turns can write git metadata under the codex sandbox.
18
+ profiles:
19
+ claude-opus:
20
+ adapter: claude
21
+ model: claude-opus-4-8
22
+ reasoningEffort: high
23
+ permissions:
24
+ mode: auto
25
+ claude-opus-1m:
26
+ adapter: claude
27
+ model: claude-opus-4-8[1m]
28
+ reasoningEffort: xhigh
29
+ permissions:
30
+ mode: auto
31
+ codex-gpt:
32
+ adapter: codex
33
+ model: gpt-5.5
34
+ reasoningEffort: xhigh
35
+ permissions:
36
+ mode: auto
37
+ writablePaths: ['.git']
38
+
39
+ # The Captain/Judge agent: a profile id, an adapter shorthand, or a full
40
+ # tmux-play agent block (which may carry a `profile:` key).
41
+ captain: claude-opus
42
+
43
+ # Host notifications. Omitting turn_aborted resolves it to off.
44
+ notifications:
45
+ player_finished: bell
46
+ turn_finished: desktop
47
+
48
+ # Enabled playbooks. Each is loaded from its explicit `from` module; the
49
+ # `<id>` key must equal that module's manifest id. `from`, `command`, and
50
+ # `players` are launcher-owned; every other key (e.g. CODE's `committer`)
51
+ # is that playbook's option slice.
52
+ playbooks:
53
+ code:
54
+ from: "@sublang/playbook/code/registry"
55
+ players:
56
+ coder: claude-opus-1m
57
+ reviewer: codex-gpt
58
+ committer: coder