@sublang/playbook 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -36,7 +36,8 @@ The reference is the canonical worked example —
36
36
  [CODE source](reference/sdlc/code.md) →
37
37
  [gears](reference/sdlc/code.playbook/code.gears.md) →
38
38
  [FSM](reference/sdlc/code.playbook/code.fsm.ts) → runtime — with
39
- the runtime ported to cligent's `tmux-play` host out of the box.
39
+ the runtime registered behind the built-in Playbook Captain shell for
40
+ cligent's `tmux-play` host out of the box.
40
41
  The compiled artifacts live under
41
42
  [`reference/sdlc/code.playbook/`](reference/sdlc/code.playbook),
42
43
  the slc pipeline's `<basename>.<pipeline>/` output directory.
@@ -82,7 +83,10 @@ playbook-code --help
82
83
  ```
83
84
 
84
85
  The seed template runs each agent in cligent's protected auto mode
85
- (`permissions.mode: auto`), suppressing routine approval prompts.
86
+ (`permissions.mode: auto`), suppressing routine approval prompts. Its
87
+ Codex Coder also grants `permissions.writablePaths: [.git]` so git
88
+ metadata writes stay available under auto mode without switching to
89
+ bypass permissions.
86
90
 
87
91
  ### Configure agents
88
92
 
@@ -95,20 +99,27 @@ $EDITOR "${XDG_CONFIG_HOME:-$HOME/.config}/playbook/playbook-code.config.yaml"
95
99
  Both CODE players can use `claude` or `codex`; other adapter ids are
96
100
  passed through to `tmux-play` with a warning because `playbook-code`
97
101
  does not know how to preflight their auth. The safe tuning points are
98
- `captain.adapter`, `captain.model`, each player's `adapter`, and each
99
- player's `model`. Keep `captain.from` and the `players[].id` values
100
- fixed; the runtime binds to those host-configuration invariants per
102
+ `captain.adapter`, `captain.model`, and each role's `adapter` and
103
+ `model` under `players.coder` / `players.reviewer`. The composer owns
104
+ `captain.from` and points it at the Playbook Captain shell adapter, and
105
+ it owns the `coder` / `reviewer` role keys, so leave those keys as-is;
106
+ the runtime binds to those host-configuration invariants per
101
107
  [PBRT-4](specs/user/playbook-runtime.md#pbrt-4) and derives the
102
- `<coder-llm>` / `<reviewer-llm>` substitution strings from each
103
- player entry's `model` when pinned and `adapter` otherwise — so the
104
- Committer's commit-message trailers can name the concrete model
105
- (e.g. `claude-opus-4-7`) rather than the adapter family (`claude`).
108
+ `<coder-llm>` / `<reviewer-llm>` substitution strings from each role's
109
+ `model` when pinned and `adapter` otherwise — so the Committer's
110
+ commit-message trailers can name the concrete model
111
+ (e.g. `claude-opus-4-8`) rather than the adapter family (`claude`).
106
112
 
107
- For example, this swaps the Coder to Codex and the Reviewer to Claude:
113
+ `players.committer` is an optional alias naming which role `coder`
114
+ or `reviewer` — runs the commit turn; the seeded overlay points it at
115
+ the Reviewer. Absent the alias the Committer falls back to the Coder
116
+ ([PBRT-8](specs/user/playbook-runtime.md#pbrt-8)).
117
+
118
+ For example, the seeded overlay runs the Coder on Codex and the
119
+ Reviewer on Claude, with the Committer aliased to the Reviewer:
108
120
 
109
121
  ```yaml
110
122
  captain:
111
- from: "@sublang/playbook/code/tmux-play"
112
123
  adapter: claude
113
124
  model: claude-sonnet-4-6
114
125
  reasoningEffort: high
@@ -116,18 +127,21 @@ captain:
116
127
  mode: auto
117
128
 
118
129
  players:
119
- - id: coder # must stay `coder` — see PBRT-4
130
+ coder: # role key must stay `coder` — see PBRT-4
120
131
  adapter: codex
121
132
  model: gpt-5.5
122
133
  reasoningEffort: xhigh
123
134
  permissions:
124
135
  mode: auto
125
- - id: reviewer # must stay `reviewer` — see PBRT-4
136
+ writablePaths:
137
+ - .git # allow git metadata writes under Codex auto mode
138
+ reviewer: # role key must stay `reviewer` — see PBRT-4
126
139
  adapter: claude
127
- model: claude-opus-4-7
140
+ model: claude-opus-4-8
128
141
  reasoningEffort: xhigh
129
142
  permissions:
130
143
  mode: auto
144
+ committer: reviewer # which role commits — `coder` or `reviewer`
131
145
  ```
132
146
 
133
147
  Normal `playbook-code` runs use the seeded path above. If you need a
@@ -174,7 +188,7 @@ production install.
174
188
 
175
189
  Drive a Boss turn against the source tree (uses the developer
176
190
  [`tmux-play.config.yaml`](reference/sdlc/code.playbook/tmux-play.config.yaml)
177
- that imports the compiled adapter via relative path):
191
+ that imports the compiled Playbook Captain shell via relative path):
178
192
 
179
193
  ```sh
180
194
  pnpm exec tmux-play --config reference/sdlc/code.playbook/tmux-play.config.yaml
@@ -186,7 +200,9 @@ installed globally.
186
200
 
187
201
  ### Running a Boss turn
188
202
 
189
- The Boss pane takes plain-language turns; the judge classifies each
203
+ The Boss pane starts at the Playbook Captain shell. Use `/code <task>`
204
+ to explicitly select the CODE playbook, or use ordinary text and let the
205
+ shell route it. Once a turn reaches CODE, the CODE judge classifies it
190
206
  into an FSM event (start a coding turn, continue or summarize an IR,
191
207
  interrupt to a named state, or nothing) per
192
208
  [PBRT-1](specs/user/playbook-runtime.md#pbrt-1).
@@ -195,9 +211,16 @@ the FSM parks at `awaitBossReply` and the pane shows the question; your
195
211
  next turn is normally classified as the reply, or a fresh directive
196
212
  abandons it ([PBRT-2](specs/user/playbook-runtime.md#pbrt-2)).
197
213
 
198
- The Captain pane streams the state machine with a four-glyph vocabulary —
199
- `◆ ▸ ⮕ ⤷` per [PBRT-3](specs/user/playbook-runtime.md#pbrt-3) — while
200
- player prompts ride their own panes.
214
+ The Captain pane shows `/code` start/stop/finished status with `◇` lines
215
+ and streams the CODE state machine with the four-glyph vocabulary `◆ ▸ ⮕ ⤷` per
216
+ [PBRT-3](specs/user/playbook-runtime.md#pbrt-3), while player prompts
217
+ ride their own panes.
218
+
219
+ Published configs should import the shell adapter from
220
+ `@sublang/playbook/playbook-captain`. Existing explicit configs that
221
+ still import `@sublang/playbook/code/tmux-play` keep resolving through
222
+ a compatibility shim that delegates to the same shell with CODE
223
+ registered.
201
224
 
202
225
  ### Embedding the runtime in your own host
203
226
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@sublang/playbook",
3
- "version": "0.4.2",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
- "description": "Reference CODE playbook — XState v5 FSM, host-agnostic runtime, and tmux-play adapter for a coder/reviewer/committer loop driven by GEARS spec items.",
5
+ "description": "Reference CODE playbook — XState v5 FSM, host-agnostic runtime, and Playbook Captain shell for a coder/reviewer/committer loop driven by GEARS spec items.",
6
6
  "license": "Apache-2.0",
7
7
  "homepage": "https://github.com/sublang-ai/playbook#readme",
8
8
  "repository": {
@@ -34,6 +34,12 @@
34
34
  "reference/sdlc/code.playbook/code.playbook.ts",
35
35
  "reference/sdlc/code.playbook/code.playbook.js",
36
36
  "reference/sdlc/code.playbook/code.playbook.d.ts",
37
+ "reference/sdlc/code.playbook/code.registry.ts",
38
+ "reference/sdlc/code.playbook/code.registry.js",
39
+ "reference/sdlc/code.playbook/code.registry.d.ts",
40
+ "reference/sdlc/code.playbook/playbook-captain.ts",
41
+ "reference/sdlc/code.playbook/playbook-captain.js",
42
+ "reference/sdlc/code.playbook/playbook-captain.d.ts",
37
43
  "reference/sdlc/code.playbook/code.tmux-play.ts",
38
44
  "reference/sdlc/code.playbook/code.tmux-play.js",
39
45
  "reference/sdlc/code.playbook/code.tmux-play.d.ts",
@@ -41,7 +47,7 @@
41
47
  "reference/sdlc/code.playbook/tmux-play.config.yaml",
42
48
  "reference/sdlc/code.playbook/tmux-play.production.config.yaml",
43
49
  "reference/sdlc/code.playbook/playbook-code.config.template.yaml",
44
- "reference/sdlc/code.playbook/bin"
50
+ "reference/sdlc/code.playbook/bin/playbook-code.js"
45
51
  ],
46
52
  "bin": {
47
53
  "playbook-code": "reference/sdlc/code.playbook/bin/playbook-code.js"
@@ -51,6 +57,10 @@
51
57
  "types": "./reference/sdlc/code.playbook/code.playbook.d.ts",
52
58
  "default": "./reference/sdlc/code.playbook/code.playbook.js"
53
59
  },
60
+ "./playbook-captain": {
61
+ "types": "./reference/sdlc/code.playbook/playbook-captain.d.ts",
62
+ "default": "./reference/sdlc/code.playbook/playbook-captain.js"
63
+ },
54
64
  "./code/tmux-play": {
55
65
  "types": "./reference/sdlc/code.playbook/code.tmux-play.d.ts",
56
66
  "default": "./reference/sdlc/code.playbook/code.tmux-play.js"
@@ -58,7 +68,8 @@
58
68
  },
59
69
  "scripts": {
60
70
  "build": "tsc",
61
- "test": "vitest run"
71
+ "test": "vitest run",
72
+ "playbook-code": "node reference/sdlc/code.playbook/bin/playbook-code.js"
62
73
  },
63
74
  "publishConfig": {
64
75
  "access": "public",
@@ -66,9 +77,10 @@
66
77
  },
67
78
  "dependencies": {
68
79
  "@anthropic-ai/claude-agent-sdk": "^0.3.143",
69
- "@openai/codex-sdk": "^0.130.0",
80
+ "@openai/codex-sdk": "^0.139.0",
70
81
  "@sublang/cligent": "latest",
71
- "xstate": "^5.19.4"
82
+ "xstate": "^5.19.4",
83
+ "yaml": "^2.9.0"
72
84
  },
73
85
  "devDependencies": {
74
86
  "@types/node": "^22.0.0",
@@ -8,16 +8,52 @@ import {
8
8
  copyFileSync,
9
9
  existsSync,
10
10
  mkdirSync,
11
+ mkdtempSync,
11
12
  readFileSync,
12
13
  realpathSync,
14
+ rmSync,
15
+ writeFileSync,
13
16
  } from 'node:fs';
14
- import { homedir } from 'node:os';
17
+ import { homedir, tmpdir } from 'node:os';
15
18
  import { dirname, join, resolve } from 'node:path';
16
19
  import { fileURLToPath } from 'node:url';
20
+ import {
21
+ findTmuxPlayConfig,
22
+ loadTmuxPlayConfig,
23
+ } from '@sublang/cligent/tmux-play';
24
+ import {
25
+ parse as parseYaml,
26
+ parseDocument as parseYamlDocument,
27
+ stringify as stringifyYaml,
28
+ } from 'yaml';
17
29
 
18
30
  const here = dirname(fileURLToPath(import.meta.url));
19
31
  const templatePath = resolve(here, '..', 'playbook-code.config.template.yaml');
20
32
  const READINESS_FAILURE_EXIT_CODE = 2;
33
+ const COMPOSITION_FAILURE_EXIT_CODE = 1;
34
+ const DEFAULT_NOTIFICATION_BLOCK = [
35
+ '',
36
+ '# tmux-play host notifications. Omitted turn_aborted resolves to off.',
37
+ 'notifications:',
38
+ ' player_finished: bell',
39
+ ' turn_finished: desktop',
40
+ '',
41
+ ].join('\n');
42
+
43
+ // PBCODE-16: the composer injects `captain.from` (the Playbook
44
+ // Captain shell adapter module) and the `coder` / `reviewer` player
45
+ // ids, so the user-edited overlay carries neither.
46
+ export const PLAYBOOK_CAPTAIN_MODULE = '@sublang/playbook/playbook-captain';
47
+ export const CODE_ADAPTER_MODULE = PLAYBOOK_CAPTAIN_MODULE;
48
+ const CODE_ROLES = ['coder', 'reviewer'];
49
+ // Accepted `players` keys: the two fixed CODE roles plus the optional
50
+ // `committer` alias (a string naming one of the roles); PBCODE-17.
51
+ const CODE_PLAYER_KEYS = [...CODE_ROLES, 'committer'];
52
+ // Captain-judge fields inherited from a base config when the overlay
53
+ // leaves them unset (PBCODE-16); `adapter` is handled separately
54
+ // because it is required in the composed config.
55
+ const CAPTAIN_INHERITED_FIELDS = ['model', 'reasoningEffort', 'permissions'];
56
+ const PLAYER_FIELDS = ['model', 'reasoningEffort', 'permissions'];
21
57
 
22
58
  export async function runPlaybookCodeCli(options = {}) {
23
59
  const argv = [...(options.argv ?? process.argv.slice(2))];
@@ -27,6 +63,8 @@ export async function runPlaybookCodeCli(options = {}) {
27
63
  const spawnFn = options.spawn ?? spawn;
28
64
  const tmuxPlayBin = options.tmuxPlayBin ?? resolveTmuxPlayBin();
29
65
  const home = options.homeDir ?? env.HOME ?? homedir();
66
+ const cwd = options.cwd ?? process.cwd();
67
+ const configHome = resolveConfigHome(env, home);
30
68
  const userConfigPath = resolveUserConfigPath(env, home);
31
69
 
32
70
  if (argv.includes('--help') || argv.includes('-h')) {
@@ -34,92 +72,218 @@ export async function runPlaybookCodeCli(options = {}) {
34
72
  return { code: 0 };
35
73
  }
36
74
 
37
- const explicitConfig = hasExplicitConfig(argv);
38
- const configPath = explicitConfig ? undefined : userConfigPath;
75
+ // PBCODE-1: explicit `--config <path>` bypasses seeding, readiness,
76
+ // and composition the path is launched verbatim.
77
+ if (hasExplicitConfig(argv)) {
78
+ return await launchTmuxPlay(spawnFn, [tmuxPlayBin, ...argv], stderr);
79
+ }
39
80
 
40
- if (!explicitConfig) {
41
- seedUserConfigIfMissing(userConfigPath, stderr);
42
- const readiness = checkReadiness(userConfigPath, env, home);
43
- for (const adapter of readiness.unknownAdapters) {
44
- stderr.write(
45
- `playbook-code: warning: no readiness check for adapter "${adapter}"\n`,
46
- );
47
- }
48
- if (readiness.failingAdapters.length > 0) {
49
- stderr.write(
50
- helpText({
51
- userConfigPath,
52
- failingAdapters: readiness.failingAdapters,
53
- }),
54
- );
55
- return { code: READINESS_FAILURE_EXIT_CODE };
56
- }
81
+ seedUserConfigIfMissing(userConfigPath, stderr);
82
+ migrateUserConfigNotificationsIfMissing(userConfigPath, stderr);
83
+
84
+ // PBCODE-16/17: compose the launched config from the overlay plus an
85
+ // optional base tmux-play config. Composition failures (missing role,
86
+ // non-CODE role id, missing `captain.adapter`) surface a path-named
87
+ // error and abort before launch.
88
+ let composed;
89
+ try {
90
+ composed = await composeLaunchConfig({
91
+ overlayPath: userConfigPath,
92
+ cwd,
93
+ configHome,
94
+ });
95
+ } catch (error) {
96
+ stderr.write(`playbook-code: ${errorMessage(error)}\n`);
97
+ return { code: COMPOSITION_FAILURE_EXIT_CODE };
57
98
  }
58
99
 
59
- const childArgs = explicitConfig
60
- ? [tmuxPlayBin, ...argv]
61
- : [tmuxPlayBin, '--config', configPath, ...argv];
62
- return await launchTmuxPlay(spawnFn, childArgs, stderr);
100
+ // PBCODE-8: readiness reads the adapters of the composed config —
101
+ // including a captain adapter that may have been inherited from the
102
+ // base not the raw overlay.
103
+ const readiness = checkReadiness(
104
+ adaptersFromComposedConfig(composed),
105
+ env,
106
+ home,
107
+ );
108
+ for (const adapter of readiness.unknownAdapters) {
109
+ stderr.write(
110
+ `playbook-code: warning: no readiness check for adapter "${adapter}"\n`,
111
+ );
112
+ }
113
+ if (readiness.failingAdapters.length > 0) {
114
+ stderr.write(
115
+ helpText({
116
+ userConfigPath,
117
+ failingAdapters: readiness.failingAdapters,
118
+ }),
119
+ );
120
+ return { code: READINESS_FAILURE_EXIT_CODE };
121
+ }
122
+
123
+ // PBCODE-16: materialize the composed config to a temp file, launch
124
+ // against it, and remove it before the shim exits — on normal exit,
125
+ // on non-zero child exit, and before re-raising a forwarded signal.
126
+ const { dir: tempDir, path: composedPath } = writeComposedConfig(composed);
127
+ try {
128
+ return await launchTmuxPlay(
129
+ spawnFn,
130
+ [tmuxPlayBin, '--config', composedPath, ...argv],
131
+ stderr,
132
+ );
133
+ } finally {
134
+ rmSync(tempDir, { recursive: true, force: true });
135
+ }
136
+ }
137
+
138
+ export function resolveConfigHome(env = process.env, home = homedir()) {
139
+ return env.XDG_CONFIG_HOME || join(home, '.config');
63
140
  }
64
141
 
65
142
  export function resolveUserConfigPath(env = process.env, home = homedir()) {
66
- const configHome = env.XDG_CONFIG_HOME || join(home, '.config');
67
- return join(configHome, 'playbook', 'playbook-code.config.yaml');
143
+ return join(resolveConfigHome(env, home), 'playbook', 'playbook-code.config.yaml');
68
144
  }
69
145
 
70
- export function collectAdaptersFromConfig(source) {
71
- const adapters = new Set();
72
- let section = '';
73
- let captainChildIndent;
74
- let playerItemIndent;
75
- let playerChildIndent;
76
-
77
- for (const line of source.split(/\r?\n/)) {
78
- const uncommented = stripYamlComment(line);
79
- if (uncommented.trim() === '') continue;
80
-
81
- const indent = leadingSpaceCount(uncommented);
82
- const trimmed = uncommented.trim();
83
-
84
- if (indent === 0) {
85
- if (trimmed === 'captain:') section = 'captain';
86
- else if (trimmed === 'players:') section = 'players';
87
- else section = '';
88
- captainChildIndent = undefined;
89
- playerItemIndent = undefined;
90
- playerChildIndent = undefined;
91
- continue;
146
+ // PBCODE-17: read the overlay, locate an optional base tmux-play config
147
+ // with cligent's exported `findTmuxPlayConfig` (never the bare
148
+ // `loadTmuxPlayConfig`, which writes a default config when none is
149
+ // found), load the base only when a path is located, and compose.
150
+ async function composeLaunchConfig({ overlayPath, cwd, configHome }) {
151
+ const overlay = parseYaml(readFileSync(overlayPath, 'utf8')) ?? {};
152
+ const basePath = findTmuxPlayConfig(cwd, configHome);
153
+ let base;
154
+ if (basePath) {
155
+ base = (await loadTmuxPlayConfig({ configPath: basePath })).config;
156
+ }
157
+ return composeRuntimeConfig(overlay, base);
158
+ }
159
+
160
+ /**
161
+ * Compose the launched tmux-play config (PBCODE-16/17) from the CODE
162
+ * overlay and an optional base tmux-play config.
163
+ *
164
+ * @returns {import('@sublang/cligent/tmux-play').TmuxPlayConfig}
165
+ */
166
+ export function composeRuntimeConfig(
167
+ overlay,
168
+ base,
169
+ adapterModule = PLAYBOOK_CAPTAIN_MODULE,
170
+ ) {
171
+ const overlayConfig = requireObject(overlay, 'config');
172
+ const overlayPlayers = requireObject(overlayConfig.players, 'players');
173
+
174
+ // Reject any players key other than the two fixed CODE roles and the
175
+ // optional `committer` alias.
176
+ for (const key of Object.keys(overlayPlayers)) {
177
+ if (!CODE_PLAYER_KEYS.includes(key)) {
178
+ throw new Error(`Unknown config field players.${key}`);
92
179
  }
180
+ }
93
181
 
94
- if (section === 'captain') {
95
- captainChildIndent ??= indent;
96
- if (indent === captainChildIndent) {
97
- collectAdapterValue(trimmed, adapters);
98
- }
99
- continue;
182
+ // PBCODE-17: `players.committer` is an optional string naming `coder`
183
+ // or `reviewer`. The composer resolves it into the composed
184
+ // `captain.options.code.committer` below and emits no extra
185
+ // `players[]` entry, so the roster stays coder + reviewer.
186
+ let committerAlias;
187
+ if (overlayPlayers.committer !== undefined) {
188
+ const value = overlayPlayers.committer;
189
+ if (!CODE_ROLES.includes(value)) {
190
+ throw new Error(
191
+ `players.committer must name one of: ${CODE_ROLES.join(', ')}`,
192
+ );
100
193
  }
194
+ committerAlias = value;
195
+ }
101
196
 
102
- if (section === 'players') {
103
- if (trimmed.startsWith('- ')) {
104
- playerItemIndent = indent;
105
- playerChildIndent = undefined;
106
- collectAdapterValue(trimmed.slice(2).trim(), adapters);
107
- continue;
108
- }
109
- if (playerItemIndent !== undefined && indent > playerItemIndent) {
110
- playerChildIndent ??= indent;
111
- if (indent === playerChildIndent) {
112
- collectAdapterValue(trimmed, adapters);
113
- }
197
+ // One composed `players[]` entry per role, with `id` = the role key
198
+ // and that role's required `adapter` plus optional fields.
199
+ const players = CODE_ROLES.map((role) => {
200
+ if (overlayPlayers[role] === undefined) {
201
+ throw new Error(`Missing required field players.${role}`);
202
+ }
203
+ const block = requireObject(overlayPlayers[role], `players.${role}`);
204
+ if (block.adapter === undefined) {
205
+ throw new Error(`Missing required field players.${role}.adapter`);
206
+ }
207
+ const entry = { id: role, adapter: block.adapter };
208
+ for (const field of PLAYER_FIELDS) {
209
+ if (block[field] !== undefined) entry[field] = block[field];
210
+ }
211
+ return entry;
212
+ });
213
+
214
+ const overlayCaptain =
215
+ overlayConfig.captain === undefined
216
+ ? {}
217
+ : requireObject(overlayConfig.captain, 'captain');
218
+ const baseCaptain = isObject(base?.captain) ? base.captain : {};
219
+
220
+ // `captain.adapter` comes from the overlay when present, else the
221
+ // base; composition fails with a path-named error when neither
222
+ // supplies it. Role adapters are required in the overlay and are not
223
+ // inherited.
224
+ const captainAdapter = overlayCaptain.adapter ?? baseCaptain.adapter;
225
+ if (captainAdapter === undefined) {
226
+ throw new Error('Missing required field captain.adapter');
227
+ }
228
+
229
+ const captain = { from: adapterModule, adapter: captainAdapter };
230
+ for (const field of CAPTAIN_INHERITED_FIELDS) {
231
+ const value = overlayCaptain[field] ?? baseCaptain[field];
232
+ if (value !== undefined) captain[field] = value;
233
+ }
234
+
235
+ // PBCODE-17: the shim is the sole writer of the composed
236
+ // `captain.options.code.committer`. Read the overlay's
237
+ // `captain.options.code` (carried through unchanged), reject a
238
+ // directly-set `committer` there, then layer the resolved
239
+ // `players.committer` alias on top.
240
+ let overlayCode;
241
+ if (overlayCaptain.options !== undefined) {
242
+ const captainOptions = requireObject(
243
+ overlayCaptain.options,
244
+ 'captain.options',
245
+ );
246
+ if (captainOptions.code !== undefined) {
247
+ overlayCode = requireObject(captainOptions.code, 'captain.options.code');
248
+ if (overlayCode.committer !== undefined) {
249
+ throw new Error(
250
+ 'captain.options.code.committer is composer-owned; ' +
251
+ 'set players.committer instead',
252
+ );
114
253
  }
115
254
  }
116
255
  }
256
+ if (overlayCode !== undefined || committerAlias !== undefined) {
257
+ const code = { ...(overlayCode ?? {}) };
258
+ if (committerAlias !== undefined) code.committer = committerAlias;
259
+ captain.options = { code };
260
+ }
261
+
262
+ // Inherit host-owned top-level fields from the base when the overlay
263
+ // omits them; never the base `players[]` roster.
264
+ const composed = {};
265
+ const theme = overlayConfig.theme ?? base?.theme;
266
+ if (theme !== undefined) composed.theme = theme;
267
+ const layout = overlayConfig.layout ?? base?.layout;
268
+ if (layout !== undefined) composed.layout = layout;
269
+ const notifications = overlayConfig.notifications ?? base?.notifications;
270
+ if (notifications !== undefined) composed.notifications = notifications;
271
+ composed.captain = captain;
272
+ composed.players = players;
273
+ return composed;
274
+ }
275
+
276
+ export function adaptersFromComposedConfig(config) {
277
+ const adapters = new Set();
278
+ const captainAdapter = config?.captain?.adapter;
279
+ if (captainAdapter) adapters.add(captainAdapter);
280
+ for (const player of config?.players ?? []) {
281
+ if (player?.adapter) adapters.add(player.adapter);
282
+ }
117
283
  return [...adapters];
118
284
  }
119
285
 
120
- export function checkReadiness(configPath, env = process.env, home = homedir()) {
121
- const config = readFileSync(configPath, 'utf8');
122
- const adapters = collectAdaptersFromConfig(config);
286
+ export function checkReadiness(adapters, env = process.env, home = homedir()) {
123
287
  const failingAdapters = [];
124
288
  const unknownAdapters = [];
125
289
 
@@ -142,6 +306,15 @@ export function checkReadiness(configPath, env = process.env, home = homedir())
142
306
  return { failingAdapters, unknownAdapters };
143
307
  }
144
308
 
309
+ function writeComposedConfig(composed) {
310
+ const dir = mkdtempSync(join(tmpdir(), 'playbook-code-'));
311
+ // cligent's loader only accepts a `.yaml` extension; name the temp
312
+ // file accordingly.
313
+ const path = join(dir, 'tmux-play.config.yaml');
314
+ writeFileSync(path, stringifyYaml(composed));
315
+ return { dir, path };
316
+ }
317
+
145
318
  function seedUserConfigIfMissing(userConfigPath, stderr) {
146
319
  if (existsSync(userConfigPath)) return;
147
320
  mkdirSync(dirname(userConfigPath), { recursive: true });
@@ -149,6 +322,27 @@ function seedUserConfigIfMissing(userConfigPath, stderr) {
149
322
  stderr.write(`playbook-code: created config at ${userConfigPath}\n`);
150
323
  }
151
324
 
325
+ function migrateUserConfigNotificationsIfMissing(userConfigPath, stderr) {
326
+ const source = readFileSync(userConfigPath, 'utf8');
327
+ let parsed;
328
+ try {
329
+ const document = parseYamlDocument(source);
330
+ if (document.errors.length > 0) return;
331
+ parsed = document.contents === null ? {} : document.toJS();
332
+ } catch {
333
+ return;
334
+ }
335
+ if (!isObject(parsed) || hasOwn(parsed, 'notifications')) return;
336
+ const separator = source.endsWith('\n') ? '' : '\n';
337
+ writeFileSync(
338
+ userConfigPath,
339
+ `${source}${separator}${DEFAULT_NOTIFICATION_BLOCK}`,
340
+ );
341
+ stderr.write(
342
+ `playbook-code: added notifications defaults to config at ${userConfigPath}\n`,
343
+ );
344
+ }
345
+
152
346
  function hasExplicitConfig(argv) {
153
347
  return argv.some((arg) => arg === '--config' || arg.startsWith('--config='));
154
348
  }
@@ -175,10 +369,11 @@ function helpText({ userConfigPath, failingAdapters = [] }) {
175
369
  '',
176
370
  'Agent swap recipe:',
177
371
  ' - change captain.adapter and captain.model for the Captain/Judge',
178
- ' - change each player adapter and (optional) model for the Coder',
179
- ' and Reviewer; model when pinned, else adapter, is substituted',
180
- ' into <coder-llm>/<reviewer-llm> player prompts (PBRT-4)',
181
- ' - keep captain.from and players[].id fixed',
372
+ ' - change adapter and model under players.coder and players.reviewer',
373
+ ' for the Coder and Reviewer; model when pinned, else adapter, is',
374
+ ' substituted into <coder-llm>/<reviewer-llm> player prompts (PBRT-4)',
375
+ ' - the composer injects captain.from; the role keys coder and',
376
+ ' reviewer are fixed',
182
377
  '',
183
378
  ].join('\n');
184
379
  }
@@ -220,41 +415,19 @@ function resolveTmuxPlayBin() {
220
415
  return join(dirname(fileURLToPath(tmuxPlayIndexUrl)), 'cli.js');
221
416
  }
222
417
 
223
- function stripYamlComment(line) {
224
- let quote = '';
225
- for (let index = 0; index < line.length; index += 1) {
226
- const char = line[index];
227
- if ((char === '"' || char === "'") && line[index - 1] !== '\\') {
228
- quote = quote === char ? '' : quote || char;
229
- continue;
230
- }
231
- if (char === '#' && !quote) {
232
- return line.slice(0, index);
233
- }
234
- }
235
- return line;
418
+ function isObject(value) {
419
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
236
420
  }
237
421
 
238
- function unquoteYamlScalar(value) {
239
- const trimmed = value.trim();
240
- if (
241
- (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
242
- (trimmed.startsWith("'") && trimmed.endsWith("'"))
243
- ) {
244
- return trimmed.slice(1, -1).trim();
245
- }
246
- return trimmed;
422
+ function hasOwn(value, key) {
423
+ return Object.prototype.hasOwnProperty.call(value, key);
247
424
  }
248
425
 
249
- function collectAdapterValue(trimmedLine, adapters) {
250
- const match = trimmedLine.match(/^adapter\s*:\s*(.+?)\s*$/);
251
- if (!match) return;
252
- const value = unquoteYamlScalar(match[1].trim());
253
- if (value) adapters.add(value);
254
- }
255
-
256
- function leadingSpaceCount(line) {
257
- return line.length - line.trimStart().length;
426
+ function requireObject(value, path) {
427
+ if (!isObject(value)) {
428
+ throw new Error(`${path} must be an object`);
429
+ }
430
+ return value;
258
431
  }
259
432
 
260
433
  function errorMessage(error) {
@@ -23,6 +23,7 @@ export type CaptainInput = {
23
23
  challenges?: string;
24
24
  coderPlayer?: string;
25
25
  reviewerPlayer?: string;
26
+ committerPlayer?: string;
26
27
  pendingBossQuestion?: PendingBossQuestion;
27
28
  bossReply?: string;
28
29
  };