@sublang/playbook 0.2.0 → 0.4.1

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.
@@ -65,10 +65,15 @@ const BOSS_REPLY_ERRORS = {
65
65
 
66
66
  // Player-prompt composer — DR-004 §6.
67
67
  // Substitutes the three placeholder tokens in `input.prompt` (literal
68
- // string replace, no escaping) and prepends labelled blocks for any
69
- // populated structured field. When a state resumes from a Boss reply,
70
- // the continuation preamble and Q/A blocks precede the ordinary
71
- // labelled blocks. The FSM's prompt body is never re-flowed.
68
+ // string replace, no escaping) and arranges labelled blocks around
69
+ // the prompt body. Context blocks the body refers to as prior
70
+ // material (`Boss intent:`, `Task description:`) are prepended;
71
+ // action blocks the body refers to as material below
72
+ // (`Review items:`, `Rebuttals:`) are appended after the body so the
73
+ // CODE-N prompts' "review item below" / "rebuttal below" phrasing
74
+ // matches the rendered layout. When a state resumes from a Boss
75
+ // reply, the continuation preamble and Q/A blocks precede every
76
+ // ordinary block. The FSM's prompt body is never re-flowed.
72
77
 
73
78
  function composePlayerPrompt(input: CaptainInput): string {
74
79
  const blocks: string[] = [];
@@ -85,12 +90,6 @@ function composePlayerPrompt(input: CaptainInput): string {
85
90
  if (input.intent !== undefined) {
86
91
  blocks.push(`Boss intent:\n${input.intent}`);
87
92
  }
88
- if (input.reviews !== undefined) {
89
- blocks.push(`Review items:\n${input.reviews}`);
90
- }
91
- if (input.challenges !== undefined) {
92
- blocks.push(`Rebuttals:\n${input.challenges}`);
93
- }
94
93
  if (input.taskDescription !== undefined) {
95
94
  blocks.push(`Task description:\n${input.taskDescription}`);
96
95
  }
@@ -107,6 +106,14 @@ function composePlayerPrompt(input: CaptainInput): string {
107
106
  }
108
107
 
109
108
  blocks.push(body);
109
+
110
+ if (input.reviews !== undefined) {
111
+ blocks.push(`Review items:\n${input.reviews}`);
112
+ }
113
+ if (input.challenges !== undefined) {
114
+ blocks.push(`Rebuttals:\n${input.challenges}`);
115
+ }
116
+
110
117
  return blocks.join('\n\n');
111
118
  }
112
119
 
@@ -478,14 +485,23 @@ function captainBridge(
478
485
  }
479
486
 
480
487
  // Captain pane display — PBRT-3 / PBRT-14.
481
- // The Captain pane is a stream keyed on four glyphs so a reader can
482
- // parse each line at a glance:
483
- // ◆ basic idle entry (ready / done / failed)
484
- // Boss input echo
485
- // ⮕ captain-invoking state entry (label + player + CODE-N)
486
- // ⤷ transition (guard fired by the just-finished captain call)
487
- // Prompts and full player output ride the player panes; the Captain
488
- // pane keeps to the state-machine shape.
488
+ // The Captain pane is a stream of three glyphs plus one bare
489
+ // captain-speech act, designed so a reader can parse each line at a
490
+ // glance:
491
+ // (no glyph) bare FSM event type — host renders as captain speech
492
+ // (e.g., `captain> START_CODING`)
493
+ // ⤷ captain-invoking state entry: `<Player>: <label>`
494
+ // → transition guard outcome (`· field=N` tallies
495
+ // appended); the host presenter owns any visual
496
+ // nesting under the preceding ⤷ entry
497
+
498
+ // ◆ failure state (with `lastError` data) and the
499
+ // `awaitBossReply` suspension state (custom payload)
500
+ // The runtime emits no status line on entry to the idle state
501
+ // (`ready`) or the terminal state (`done`); the next `boss>` prompt
502
+ // is the implicit "turn over" signal. Prompts and full player output
503
+ // ride the player panes; the Captain pane keeps to the state-machine
504
+ // shape.
489
505
 
490
506
  const STATE_LABELS: Readonly<Record<string, string>> = {
491
507
  planAndImplement: 'plan & implement',
@@ -569,16 +585,21 @@ const QUIESCENT_STATES: ReadonlySet<string> = new Set([
569
585
  'failed',
570
586
  ]);
571
587
 
572
- // awaitBossReply is quiescent too, but uses a custom single-line
573
- // status frame rather than the plain `◆ <state>` entry.
574
- const BASIC_IDLE_GLYPH_STATES: ReadonlySet<string> = new Set(
575
- [...QUIESCENT_STATES].filter((stateId) => stateId !== 'awaitBossReply'),
576
- );
588
+ // States whose entry the runtime does not surface on the Captain
589
+ // pane per PBRT-3: the readline returning to its `boss>` prompt is
590
+ // the implicit "turn over" signal, so a `◆ ready` / `◆ done`
591
+ // tombstone is redundant.
592
+ const SUPPRESSED_ENTRY_STATES: ReadonlySet<string> = new Set([
593
+ 'ready',
594
+ 'done',
595
+ ]);
577
596
 
578
597
  // Captain-pane surface (PBRT-3): every captain-invoking state plus
579
- // the quiescent states. Wider than the prior
580
- // "Boss-relevant" set per slc/link.md's default "emit on every
581
- // transition; let the host filter."
598
+ // the quiescent states whose entry still carries information
599
+ // (failure with `lastError`, awaitBossReply with the pending
600
+ // question). `ready` and `done` flow through the inspect handler
601
+ // but their entries are dropped before emitStatus per
602
+ // SUPPRESSED_ENTRY_STATES above.
582
603
  const CAPTAIN_PANE_STATES: ReadonlySet<string> = new Set([
583
604
  ...stateMetadata.keys(),
584
605
  ...QUIESCENT_STATES,
@@ -633,12 +654,13 @@ function formatAwaitBossReplyEntry(context: Record<string, unknown>): string {
633
654
  function formatStateEntry(
634
655
  stateId: string,
635
656
  context: Record<string, unknown> = {},
636
- ): string {
657
+ ): string | undefined {
637
658
  if (stateId === 'awaitBossReply') return formatAwaitBossReplyEntry(context);
638
- if (BASIC_IDLE_GLYPH_STATES.has(stateId)) return `◆ ${stateId}`;
659
+ if (SUPPRESSED_ENTRY_STATES.has(stateId)) return undefined;
660
+ if (stateId === 'failed') return '◆ failed';
639
661
  const meta = stateMetadata.get(stateId);
640
- if (!meta) return `⮕ ${stateId}`;
641
- return `⮕ ${meta.label} ${meta.player} per ${meta.sourceItem}`;
662
+ if (!meta) return `⤷ ${stateId}`;
663
+ return `⤷ ${meta.player}: ${meta.label}`;
642
664
  }
643
665
 
644
666
  function formatTransition(event: unknown): string | undefined {
@@ -652,32 +674,16 @@ function formatTransition(event: unknown): string | undefined {
652
674
  tallies.push(`${field}=${items ? items.length : 1}`);
653
675
  }
654
676
  }
655
- const suffix = tallies.length > 0 ? ` ${tallies.join(' ')}` : '';
656
- return `⤷ ${output.guard}${suffix}`;
657
- }
658
-
659
- function formatBossEcho(text: string, eventType?: string): string {
660
- return eventType !== undefined
661
- ? `▸ BOSS ${text} → ${eventType}`
662
- : `▸ BOSS ${text}`;
677
+ const suffix = tallies.length > 0 ? ` · ${tallies.join(' · ')}` : '';
678
+ // No leading whitespace: visual nesting under the preceding ⤷
679
+ // entry is the host presenter's concern (cligent's writeStatusLine
680
+ // emits status messages verbatim, with its own chrome but no
681
+ // continuation indent; layout is its job, not ours).
682
+ return `→ ${output.guard}${suffix}`;
663
683
  }
664
684
 
665
- // Rider fields PBRT-14 names — surfaced inline on a state entry
666
- // whenever the FSM context populates them, regardless of which
667
- // captain-invoking state is being entered. Routing-only context
668
- // (reviewSubject, afterReview, etc.) stays out of the pane and is
669
- // visible via emitTelemetry instead.
670
- const RIDER_FIELDS = ['intent', 'irNumber', 'taskDescription'] as const;
671
-
672
- function formatRiders(context: Record<string, unknown>): string {
673
- const parts: string[] = [];
674
- for (const f of RIDER_FIELDS) {
675
- const v = context[f];
676
- if (typeof v === 'string' && v.length > 0) {
677
- parts.push(`${f}=${JSON.stringify(v)}`);
678
- }
679
- }
680
- return parts.length > 0 ? ` ${parts.join(' ')}` : '';
685
+ function formatClassification(eventType: string): string {
686
+ return eventType;
681
687
  }
682
688
 
683
689
  function stateTelemetryPayload(
@@ -712,8 +718,7 @@ export const _internal = {
712
718
  formatAwaitBossReplyEntry,
713
719
  formatStateEntry,
714
720
  formatTransition,
715
- formatBossEcho,
716
- formatRiders,
721
+ formatClassification,
717
722
  stateTelemetryPayload,
718
723
  };
719
724
 
@@ -805,16 +810,13 @@ export default function createPlaybookRuntime(
805
810
  enqueueEmit(() => ports.emitStatus(transitionLine));
806
811
  }
807
812
  const entryLine = formatStateEntry(to, context);
808
- const riderSuffix = stateMetadata.has(to)
809
- ? formatRiders(context)
810
- : '';
811
- const message = entryLine + riderSuffix;
813
+ if (entryLine === undefined) return;
812
814
  if (to === 'failed') {
813
815
  const lastError = (snap.context as { lastError?: unknown })
814
816
  ?.lastError;
815
- enqueueEmit(() => ports.emitStatus(message, { lastError }));
817
+ enqueueEmit(() => ports.emitStatus(entryLine, { lastError }));
816
818
  } else {
817
- enqueueEmit(() => ports.emitStatus(message));
819
+ enqueueEmit(() => ports.emitStatus(entryLine));
818
820
  }
819
821
  },
820
822
  },
@@ -856,13 +858,14 @@ export default function createPlaybookRuntime(
856
858
  await drainEmissions();
857
859
  return;
858
860
  }
859
- // 2. Captain-pane Boss-input echo (PBRT-14): the verbatim
860
- // Boss text and the FSM event it classified to, before
861
- // the FSM advances. Enqueued so it interleaves cleanly
862
- // with the inspect-driven transition emissions.
861
+ // 2. Captain-pane classification line (PBRT-14): the bare
862
+ // FSM event type, emitted before the FSM advances so the
863
+ // host can render it as captain speech (e.g.,
864
+ // `captain> START_CODING`). Enqueued so it interleaves
865
+ // cleanly with the inspect-driven transition emissions.
863
866
  const echoPorts = savedPorts;
864
867
  enqueueEmit(() =>
865
- echoPorts.emitStatus(formatBossEcho(text, event.type)),
868
+ echoPorts.emitStatus(formatClassification(event.type)),
866
869
  );
867
870
  // 3. final state ('done') cannot accept new events — dispose
868
871
  // and reconstruct per DR-004 §5.
@@ -2,31 +2,48 @@
2
2
  // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
3
  import createPlaybookRuntime from './code.playbook.js';
4
4
  // Captain factory per TMUX-014: `(options: unknown) => Captain`.
5
- // `options` is whatever `captain.options` carries in the YAML config;
6
- // for the CODE playbook this is `CodePlaybookOptions` (coderPlayer,
7
- // reviewerPlayer, plus the rest of CodingInput).
5
+ // `options` is whatever `captain.options` carries in the YAML config.
6
+ // The per-run player identity strings (`coderPlayer`, `reviewerPlayer`)
7
+ // are derived from `session.players` at init time per PBRT-4 —
8
+ // preferring each entry's `model` and falling back to `adapter` when
9
+ // no model is pinned — so player prompts and commit-message trailers
10
+ // carry the concrete model identity (e.g. `claude-opus-4-7`) rather
11
+ // than the adapter family name (e.g. `claude`). Any same-named keys
12
+ // in `options` are ignored.
8
13
  export default function createCodeTmuxPlayCaptain(options) {
9
- const runtime = createPlaybookRuntime(options);
10
14
  // CaptainSession is bound at init time and persists across turns;
11
15
  // CaptainContext is rebuilt per turn and carries the call
12
- // primitives. PlaybookPorts is built once at init, so the per-turn
13
- // context lives in a closure-scoped slot the port callbacks query
14
- // lazily.
16
+ // primitives. The runtime is constructed in `init` so identity
17
+ // strings derived from `session.players` can flow into its options;
18
+ // PlaybookPorts is built once at init, so the per-turn context
19
+ // lives in a closure-scoped slot the port callbacks query lazily.
20
+ let runtime;
15
21
  let activeContext;
16
22
  return {
17
23
  async init(session) {
24
+ const playerIdentity = (id) => {
25
+ const entry = session.players.find((p) => p.id === id);
26
+ return entry?.model ?? entry?.adapter;
27
+ };
28
+ const coderPlayer = playerIdentity('coder');
29
+ const reviewerPlayer = playerIdentity('reviewer');
30
+ runtime = createPlaybookRuntime({
31
+ ...options,
32
+ coderPlayer,
33
+ reviewerPlayer,
34
+ });
18
35
  const ports = {
19
36
  callPlayer: async (playerId, prompt, _signal) => {
20
37
  if (!activeContext) {
21
38
  throw new Error('callPlayer invoked outside a Boss turn');
22
39
  }
23
- // RoleRunResult per TMUX-033 already matches PlayerResult
40
+ // PlayerRunResult per TMUX-033 already matches PlayerResult
24
41
  // (`status: 'ok' | 'aborted' | 'error'`, `finalText?`,
25
42
  // `error?`). cligent honors context.signal internally;
26
43
  // the runtime's signal is the same source forwarded
27
44
  // through handleBossInput, so dropping `_signal` is
28
45
  // safe.
29
- const r = await activeContext.callRole(playerId, prompt);
46
+ const r = await activeContext.callPlayer(playerId, prompt);
30
47
  return {
31
48
  status: r.status,
32
49
  finalText: r.finalText,
@@ -56,6 +73,9 @@ export default function createCodeTmuxPlayCaptain(options) {
56
73
  await runtime.init(ports);
57
74
  },
58
75
  async handleBossTurn(turn, context) {
76
+ if (!runtime) {
77
+ throw new Error('init must be called first');
78
+ }
59
79
  activeContext = context;
60
80
  try {
61
81
  // Forward the Boss prompt + cligent's per-turn signal into
@@ -71,7 +91,7 @@ export default function createCodeTmuxPlayCaptain(options) {
71
91
  }
72
92
  },
73
93
  async dispose() {
74
- await runtime.dispose();
94
+ await runtime?.dispose();
75
95
  },
76
96
  };
77
97
  }
@@ -18,37 +18,51 @@ import createPlaybookRuntime, {
18
18
  } from './code.playbook.js';
19
19
 
20
20
  // Captain factory per TMUX-014: `(options: unknown) => Captain`.
21
- // `options` is whatever `captain.options` carries in the YAML config;
22
- // for the CODE playbook this is `CodePlaybookOptions` (coderPlayer,
23
- // reviewerPlayer, plus the rest of CodingInput).
21
+ // `options` is whatever `captain.options` carries in the YAML config.
22
+ // The per-run player identity strings (`coderPlayer`, `reviewerPlayer`)
23
+ // are derived from `session.players` at init time per PBRT-4 —
24
+ // preferring each entry's `model` and falling back to `adapter` when
25
+ // no model is pinned — so player prompts and commit-message trailers
26
+ // carry the concrete model identity (e.g. `claude-opus-4-7`) rather
27
+ // than the adapter family name (e.g. `claude`). Any same-named keys
28
+ // in `options` are ignored.
24
29
  export default function createCodeTmuxPlayCaptain(
25
30
  options: unknown,
26
31
  ): Captain {
27
- const runtime: PlaybookRuntime = createPlaybookRuntime(
28
- options as CodePlaybookOptions,
29
- );
30
-
31
32
  // CaptainSession is bound at init time and persists across turns;
32
33
  // CaptainContext is rebuilt per turn and carries the call
33
- // primitives. PlaybookPorts is built once at init, so the per-turn
34
- // context lives in a closure-scoped slot the port callbacks query
35
- // lazily.
34
+ // primitives. The runtime is constructed in `init` so identity
35
+ // strings derived from `session.players` can flow into its options;
36
+ // PlaybookPorts is built once at init, so the per-turn context
37
+ // lives in a closure-scoped slot the port callbacks query lazily.
38
+ let runtime: PlaybookRuntime | undefined;
36
39
  let activeContext: CaptainContext | undefined;
37
40
 
38
41
  return {
39
42
  async init(session: CaptainSession): Promise<void> {
43
+ const playerIdentity = (id: string): string | undefined => {
44
+ const entry = session.players.find((p) => p.id === id);
45
+ return entry?.model ?? entry?.adapter;
46
+ };
47
+ const coderPlayer = playerIdentity('coder');
48
+ const reviewerPlayer = playerIdentity('reviewer');
49
+ runtime = createPlaybookRuntime({
50
+ ...(options as CodePlaybookOptions),
51
+ coderPlayer,
52
+ reviewerPlayer,
53
+ });
40
54
  const ports: PlaybookPorts = {
41
55
  callPlayer: async (playerId, prompt, _signal) => {
42
56
  if (!activeContext) {
43
57
  throw new Error('callPlayer invoked outside a Boss turn');
44
58
  }
45
- // RoleRunResult per TMUX-033 already matches PlayerResult
59
+ // PlayerRunResult per TMUX-033 already matches PlayerResult
46
60
  // (`status: 'ok' | 'aborted' | 'error'`, `finalText?`,
47
61
  // `error?`). cligent honors context.signal internally;
48
62
  // the runtime's signal is the same source forwarded
49
63
  // through handleBossInput, so dropping `_signal` is
50
64
  // safe.
51
- const r = await activeContext.callRole(playerId, prompt);
65
+ const r = await activeContext.callPlayer(playerId, prompt);
52
66
  return {
53
67
  status: r.status,
54
68
  finalText: r.finalText,
@@ -89,6 +103,9 @@ export default function createCodeTmuxPlayCaptain(
89
103
  turn: BossTurn,
90
104
  context: CaptainContext,
91
105
  ): Promise<void> {
106
+ if (!runtime) {
107
+ throw new Error('init must be called first');
108
+ }
92
109
  activeContext = context;
93
110
  try {
94
111
  // Forward the Boss prompt + cligent's per-turn signal into
@@ -104,7 +121,7 @@ export default function createCodeTmuxPlayCaptain(
104
121
  },
105
122
 
106
123
  async dispose(): Promise<void> {
107
- await runtime.dispose();
124
+ await runtime?.dispose();
108
125
  },
109
126
  };
110
127
  }
@@ -0,0 +1,47 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
+
4
+ # Seed template for the user-level playbook-code config.
5
+ # First run copies this file to:
6
+ # ${XDG_CONFIG_HOME:-$HOME/.config}/playbook/playbook-code.config.yaml
7
+ #
8
+ # Safe tuning points:
9
+ # - Change captain.adapter / captain.model / captain.reasoningEffort to
10
+ # pick the judge/Captain agent and its reasoning tier.
11
+ # - Change each player's adapter, model, and reasoningEffort to pick
12
+ # the Coder and Reviewer agents.
13
+ #
14
+ # PBRT-4 host-configuration invariants:
15
+ # - captain.from must keep pointing at the CODE tmux-play adapter module.
16
+ # - players[].id must remain exactly "coder" and "reviewer"; the runtime routes
17
+ # to those baked ids and does not remap them. The adapter derives the
18
+ # per-run player identity strings (substituted into <coder-llm> /
19
+ # <reviewer-llm> in player prompts) from each entry's model when pinned
20
+ # and falls back to its adapter when no model is set, so the Committer's
21
+ # commit-message trailers can name the concrete model.
22
+
23
+ captain:
24
+ # PBRT-4 invariant: keep this adapter module path unchanged.
25
+ from: "@sublang/playbook/code/tmux-play"
26
+ # Tunable: the Captain/Judge adapter, model, and reasoning effort.
27
+ adapter: claude
28
+ model: claude-sonnet-4-6
29
+ reasoningEffort: high
30
+ permissions:
31
+ mode: auto
32
+
33
+ players:
34
+ # PBRT-4 invariant: keep id: coder. Tune adapter/model if desired.
35
+ - id: coder
36
+ adapter: claude
37
+ model: claude-opus-4-7
38
+ reasoningEffort: xhigh
39
+ permissions:
40
+ mode: auto
41
+ # PBRT-4 invariant: keep id: reviewer. Tune adapter/model if desired.
42
+ - id: reviewer
43
+ adapter: codex
44
+ model: gpt-5.5
45
+ reasoningEffort: xhigh
46
+ permissions:
47
+ mode: auto
@@ -10,32 +10,36 @@ captain:
10
10
  # @sublang/playbook is published, swap this for the package
11
11
  # specifier — e.g.
12
12
  # from: "@sublang/playbook/code/tmux-play"
13
- # (final form confirmed at publish time per DR-004 §11). Roles,
13
+ # (final form confirmed at publish time per DR-004 §11). Players,
14
14
  # options, and the rest of the config are unchanged.
15
15
  from: ./code.tmux-play.js
16
16
  adapter: claude
17
- model: claude-opus-4-7
17
+ model: claude-sonnet-4-6
18
+ reasoningEffort: high
18
19
  # Agents run in cligent's classifier/reviewer-protected auto mode
19
20
  # (cligent DR-005): claude → permissionMode auto, codex → auto_review.
20
21
  permissions:
21
22
  mode: auto
22
- options:
23
- # Substituted into the <coder-llm> placeholder in player prompts
24
- # (DR-004 §6).
25
- coderPlayer: claude
26
- # Substituted into <reviewer-llm>.
27
- reviewerPlayer: codex
28
23
 
29
- # `roles[].id` shall match the baked playerId strings the adapter
24
+ # `players[].id` shall match the baked playerId strings the adapter
30
25
  # routes to. The CODE playbook bakes Coder→'coder' and
31
26
  # Reviewer→'reviewer' at link time (DR-004 §2); the adapter does
32
- # not remap, so changing these ids would break callRole routing.
33
- roles:
27
+ # not remap, so changing these ids would break callPlayer routing.
28
+ # Each entry's `model` (when pinned) or `adapter` (otherwise) is
29
+ # substituted into the <coder-llm> / <reviewer-llm> placeholders in
30
+ # player prompts (DR-004 §6, PBRT-4) — pin a concrete `model:` if
31
+ # you want the Committer's commit-message trailers to name the
32
+ # specific model rather than the adapter family.
33
+ players:
34
34
  - id: coder
35
35
  adapter: claude
36
+ model: claude-opus-4-7
37
+ reasoningEffort: xhigh
36
38
  permissions:
37
39
  mode: auto
38
40
  - id: reviewer
39
41
  adapter: codex
42
+ model: gpt-5.5
43
+ reasoningEffort: xhigh
40
44
  permissions:
41
45
  mode: auto
@@ -9,19 +9,21 @@
9
9
  captain:
10
10
  from: "@sublang/playbook/code/tmux-play"
11
11
  adapter: claude
12
- model: claude-opus-4-7
12
+ model: claude-sonnet-4-6
13
+ reasoningEffort: high
13
14
  permissions:
14
15
  mode: auto
15
- options:
16
- coderPlayer: claude
17
- reviewerPlayer: codex
18
16
 
19
- roles:
17
+ players:
20
18
  - id: coder
21
19
  adapter: claude
20
+ model: claude-opus-4-7
21
+ reasoningEffort: xhigh
22
22
  permissions:
23
23
  mode: auto
24
24
  - id: reviewer
25
25
  adapter: codex
26
+ model: gpt-5.5
27
+ reasoningEffort: xhigh
26
28
  permissions:
27
29
  mode: auto