@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.
- package/README.md +93 -74
- package/package.json +10 -15
- package/reference/sdlc/code.playbook/bin/playbook.js +438 -0
- package/reference/sdlc/code.playbook/code.fsm.ts +3 -2
- package/reference/sdlc/code.playbook/code.playbook.js +3 -2
- package/reference/sdlc/code.playbook/code.playbook.ts +3 -2
- package/reference/sdlc/code.playbook/code.registry.d.ts +18 -3
- package/reference/sdlc/code.playbook/code.registry.js +50 -32
- package/reference/sdlc/code.playbook/code.registry.ts +77 -37
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +8 -5
- package/reference/sdlc/code.playbook/playbook-captain.js +141 -55
- package/reference/sdlc/code.playbook/playbook-captain.ts +203 -73
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +58 -0
- package/reference/sdlc/code.playbook/bin/playbook-code.js +0 -453
- package/reference/sdlc/code.playbook/code.tmux-play.d.ts +0 -4
- package/reference/sdlc/code.playbook/code.tmux-play.js +0 -11
- package/reference/sdlc/code.playbook/code.tmux-play.ts +0 -29
- package/reference/sdlc/code.playbook/playbook-code.config.template.yaml +0 -72
- package/reference/sdlc/code.playbook/tmux-play.config.yaml +0 -50
- package/reference/sdlc/code.playbook/tmux-play.production.config.yaml +0 -33
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
// SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
|
|
4
|
+
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import {
|
|
7
|
+
constants,
|
|
8
|
+
copyFileSync,
|
|
9
|
+
existsSync,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
mkdtempSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
realpathSync,
|
|
14
|
+
rmSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
} from 'node:fs';
|
|
17
|
+
import { homedir, tmpdir } from 'node:os';
|
|
18
|
+
import { dirname, join, resolve } from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
21
|
+
|
|
22
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const templatePath = resolve(here, '..', 'playbook.config.template.yaml');
|
|
24
|
+
|
|
25
|
+
// PBCLI-1/8: the launcher composes a tmux-play config whose Captain is the
|
|
26
|
+
// Playbook Captain shell adapter module.
|
|
27
|
+
export const PLAYBOOK_CAPTAIN_MODULE = '@sublang/playbook/playbook-captain';
|
|
28
|
+
// PBCLI-8/12: known adapter shorthands. A `profiles` id may not collide
|
|
29
|
+
// with one of these, and these are the adapters with readiness predicates.
|
|
30
|
+
const ADAPTER_SHORTHANDS = ['claude', 'codex'];
|
|
31
|
+
// PBCLI-8: launcher-owned keys inside a `playbooks.<id>` block; every other
|
|
32
|
+
// key belongs to that playbook's option slice.
|
|
33
|
+
const PLAYBOOK_LAUNCHER_KEYS = ['from', 'command', 'players'];
|
|
34
|
+
const READINESS_FAILURE_EXIT_CODE = 2;
|
|
35
|
+
const COMPOSITION_FAILURE_EXIT_CODE = 1;
|
|
36
|
+
|
|
37
|
+
export async function runPlaybookCli(options = {}) {
|
|
38
|
+
const argv = [...(options.argv ?? process.argv.slice(2))];
|
|
39
|
+
const env = options.env ?? process.env;
|
|
40
|
+
const stdout = options.stdout ?? process.stdout;
|
|
41
|
+
const stderr = options.stderr ?? process.stderr;
|
|
42
|
+
const spawnFn = options.spawn ?? spawn;
|
|
43
|
+
const tmuxPlayBin = options.tmuxPlayBin ?? resolveTmuxPlayBin();
|
|
44
|
+
const loadModule = options.loadModule ?? ((specifier) => import(specifier));
|
|
45
|
+
const home = options.homeDir ?? env.HOME ?? homedir();
|
|
46
|
+
const userConfigPath = resolveUserConfigPath(env, home);
|
|
47
|
+
|
|
48
|
+
// PBCLI-6: `--help` / `-h` print help and exit 0 without seeding,
|
|
49
|
+
// composing, or launching.
|
|
50
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
51
|
+
stdout.write(helpText({ userConfigPath }));
|
|
52
|
+
return { code: 0 };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// PBCLI-1: explicit `--config <path>` launches that raw tmux-play config
|
|
56
|
+
// directly, bypassing seeding, composition, and the readiness gate.
|
|
57
|
+
if (hasExplicitConfig(argv)) {
|
|
58
|
+
return await launchTmuxPlay(spawnFn, [tmuxPlayBin, ...argv], stderr);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
seedUserConfigIfMissing(userConfigPath, stderr);
|
|
62
|
+
|
|
63
|
+
let composed;
|
|
64
|
+
try {
|
|
65
|
+
composed = await composeGenericConfig(
|
|
66
|
+
parseYaml(readFileSync(userConfigPath, 'utf8')) ?? {},
|
|
67
|
+
loadModule,
|
|
68
|
+
);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
stderr.write(`playbook: ${errorMessage(error)}\n`);
|
|
71
|
+
return { code: COMPOSITION_FAILURE_EXIT_CODE };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// PBCLI-5: `--list` prints each configured playbook's id, effective
|
|
75
|
+
// command, and intent without launching tmux-play.
|
|
76
|
+
if (argv.includes('--list')) {
|
|
77
|
+
for (const pb of composed.playbooks) {
|
|
78
|
+
stdout.write(`/${pb.command} ${pb.id} — ${pb.intent}\n`);
|
|
79
|
+
}
|
|
80
|
+
return { code: 0 };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// PBCLI-12: readiness reads the adapters of the composed config.
|
|
84
|
+
const readiness = checkReadiness(
|
|
85
|
+
adaptersFromComposedConfig(composed.config),
|
|
86
|
+
env,
|
|
87
|
+
home,
|
|
88
|
+
);
|
|
89
|
+
for (const adapter of readiness.unknownAdapters) {
|
|
90
|
+
stderr.write(
|
|
91
|
+
`playbook: warning: no readiness check for adapter "${adapter}"\n`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
if (readiness.failingAdapters.length > 0) {
|
|
95
|
+
stderr.write(
|
|
96
|
+
helpText({ userConfigPath, failingAdapters: readiness.failingAdapters }),
|
|
97
|
+
);
|
|
98
|
+
return { code: READINESS_FAILURE_EXIT_CODE };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const { dir: tempDir, path: composedPath } = writeComposedConfig(
|
|
102
|
+
composed.config,
|
|
103
|
+
);
|
|
104
|
+
try {
|
|
105
|
+
return await launchTmuxPlay(
|
|
106
|
+
spawnFn,
|
|
107
|
+
[tmuxPlayBin, '--config', composedPath, ...argv],
|
|
108
|
+
stderr,
|
|
109
|
+
);
|
|
110
|
+
} finally {
|
|
111
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function resolveConfigHome(env = process.env, home = homedir()) {
|
|
116
|
+
return env.XDG_CONFIG_HOME || join(home, '.config');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function resolveUserConfigPath(env = process.env, home = homedir()) {
|
|
120
|
+
return join(resolveConfigHome(env, home), 'playbook', 'playbook.config.yaml');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// PBCLI-8: resolve a scalar `captain` / `players.<role>` value as a profile
|
|
124
|
+
// id or adapter shorthand, or a full agent block whose optional `profile`
|
|
125
|
+
// key names a `profiles` entry whose settings are the base under the block's
|
|
126
|
+
// own explicit fields. The composed block carries no `profile` key.
|
|
127
|
+
export function resolveAgent(value, profiles, path) {
|
|
128
|
+
if (typeof value === 'string') {
|
|
129
|
+
if (hasOwn(profiles, value)) return { ...profiles[value] };
|
|
130
|
+
return { adapter: value };
|
|
131
|
+
}
|
|
132
|
+
if (isObject(value)) {
|
|
133
|
+
const { profile, ...rest } = value;
|
|
134
|
+
let base = {};
|
|
135
|
+
if (profile !== undefined) {
|
|
136
|
+
if (typeof profile !== 'string' || !hasOwn(profiles, profile)) {
|
|
137
|
+
throw new Error(`${path}.profile must name a profiles entry`);
|
|
138
|
+
}
|
|
139
|
+
base = { ...profiles[profile] };
|
|
140
|
+
}
|
|
141
|
+
return { ...base, ...rest };
|
|
142
|
+
}
|
|
143
|
+
throw new Error(
|
|
144
|
+
`${path} must be a profile id, an adapter shorthand, or an agent block`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function isValidRegistryEntry(value) {
|
|
149
|
+
if (!isObject(value)) return false;
|
|
150
|
+
return (
|
|
151
|
+
typeof value.id === 'string' &&
|
|
152
|
+
typeof value.command === 'string' &&
|
|
153
|
+
typeof value.intent === 'string' &&
|
|
154
|
+
Array.isArray(value.requiredRoleIds) &&
|
|
155
|
+
typeof value.idleStateId === 'string' &&
|
|
156
|
+
typeof value.finalStateId === 'string' &&
|
|
157
|
+
Array.isArray(value.parkStateIds) &&
|
|
158
|
+
typeof value.validateOptions === 'function' &&
|
|
159
|
+
typeof value.createRuntime === 'function'
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// PBCLI-8/9/10: normalize the top-level `profiles` / `playbooks` config into
|
|
164
|
+
// a tmux-play config (Captain = the shell adapter; `captain.options.playbooks`
|
|
165
|
+
// the normalized enablement; a launch-time namespaced `<id>-<role>` roster;
|
|
166
|
+
// launcher-owned `layout.initialVisible`).
|
|
167
|
+
export async function composeGenericConfig(top, loadModule) {
|
|
168
|
+
const profiles = isObject(top.profiles) ? top.profiles : {};
|
|
169
|
+
for (const id of Object.keys(profiles)) {
|
|
170
|
+
if (ADAPTER_SHORTHANDS.includes(id)) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`profiles.${id} collides with the "${id}" adapter shorthand`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const playbooksCfg = requireObject(top.playbooks, 'playbooks');
|
|
178
|
+
const ids = Object.keys(playbooksCfg);
|
|
179
|
+
if (ids.length === 0) {
|
|
180
|
+
throw new Error('playbooks must enable at least one playbook');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const captain = {
|
|
184
|
+
from: PLAYBOOK_CAPTAIN_MODULE,
|
|
185
|
+
...resolveAgent(top.captain, profiles, 'captain'),
|
|
186
|
+
};
|
|
187
|
+
if (captain.adapter === undefined) {
|
|
188
|
+
throw new Error('captain must resolve an adapter');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const optionsPlaybooks = {};
|
|
192
|
+
const roster = [];
|
|
193
|
+
const listing = [];
|
|
194
|
+
const seenCommands = new Map();
|
|
195
|
+
const seenIds = new Set();
|
|
196
|
+
let firstVisible;
|
|
197
|
+
|
|
198
|
+
for (const id of ids) {
|
|
199
|
+
const block = requireObject(playbooksCfg[id], `playbooks.${id}`);
|
|
200
|
+
const from = block.from;
|
|
201
|
+
if (typeof from !== 'string' || from.length === 0) {
|
|
202
|
+
throw new Error(`playbooks.${id}.from must be a module specifier`);
|
|
203
|
+
}
|
|
204
|
+
let mod;
|
|
205
|
+
try {
|
|
206
|
+
mod = await loadModule(from);
|
|
207
|
+
} catch (cause) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
`playbooks.${id}.from "${from}" failed to import: ${errorMessage(cause)}`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
const entry = mod?.default;
|
|
213
|
+
if (!isValidRegistryEntry(entry)) {
|
|
214
|
+
throw new Error(
|
|
215
|
+
`playbooks.${id}.from "${from}" exposes no valid registry entry`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
if (entry.id !== id) {
|
|
219
|
+
throw new Error(
|
|
220
|
+
`playbooks.${id} key must equal the module manifest id "${entry.id}"`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
if (seenIds.has(entry.id)) {
|
|
224
|
+
throw new Error(`duplicate playbook id "${entry.id}"`);
|
|
225
|
+
}
|
|
226
|
+
seenIds.add(entry.id);
|
|
227
|
+
|
|
228
|
+
const command =
|
|
229
|
+
typeof block.command === 'string' && block.command.length > 0
|
|
230
|
+
? block.command
|
|
231
|
+
: entry.command;
|
|
232
|
+
if (seenCommands.has(command)) {
|
|
233
|
+
throw new Error(`duplicate effective command "${command}"`);
|
|
234
|
+
}
|
|
235
|
+
seenCommands.set(command, id);
|
|
236
|
+
|
|
237
|
+
const playersMap = requireObject(block.players, `playbooks.${id}.players`);
|
|
238
|
+
const roles = Object.keys(playersMap);
|
|
239
|
+
if (roles.length === 0) {
|
|
240
|
+
throw new Error(`playbooks.${id} resolves no visible local role`);
|
|
241
|
+
}
|
|
242
|
+
for (const required of entry.requiredRoleIds) {
|
|
243
|
+
if (!roles.includes(required)) {
|
|
244
|
+
throw new Error(
|
|
245
|
+
`playbooks.${id} required role "${required}" has no players entry`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const generated = [];
|
|
250
|
+
for (const role of roles) {
|
|
251
|
+
const agent = resolveAgent(
|
|
252
|
+
playersMap[role],
|
|
253
|
+
profiles,
|
|
254
|
+
`playbooks.${id}.players.${role}`,
|
|
255
|
+
);
|
|
256
|
+
if (agent.adapter === undefined) {
|
|
257
|
+
throw new Error(
|
|
258
|
+
`playbooks.${id}.players.${role} must resolve an adapter`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
const hostId = `${id}-${role}`;
|
|
262
|
+
roster.push({ id: hostId, ...agent });
|
|
263
|
+
generated.push(hostId);
|
|
264
|
+
}
|
|
265
|
+
if (firstVisible === undefined) firstVisible = generated;
|
|
266
|
+
|
|
267
|
+
const optionSlice = {};
|
|
268
|
+
for (const key of Object.keys(block)) {
|
|
269
|
+
if (!PLAYBOOK_LAUNCHER_KEYS.includes(key)) {
|
|
270
|
+
optionSlice[key] = block[key];
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
optionsPlaybooks[id] = {
|
|
274
|
+
from,
|
|
275
|
+
...(typeof block.command === 'string' && block.command.length > 0
|
|
276
|
+
? { command: block.command }
|
|
277
|
+
: {}),
|
|
278
|
+
options: optionSlice,
|
|
279
|
+
};
|
|
280
|
+
listing.push({ id, command, intent: entry.intent });
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
captain.options = { playbooks: optionsPlaybooks };
|
|
284
|
+
const config = { captain, players: roster };
|
|
285
|
+
// PBCLI-10: carry the user's tmux-play layout window/weight fields through;
|
|
286
|
+
// the launcher owns `layout.initialVisible` (first enabled playbook).
|
|
287
|
+
const layout = isObject(top.layout) ? { ...top.layout } : {};
|
|
288
|
+
layout.initialVisible = firstVisible;
|
|
289
|
+
config.layout = layout;
|
|
290
|
+
if (top.notifications !== undefined) config.notifications = top.notifications;
|
|
291
|
+
if (top.theme !== undefined) config.theme = top.theme;
|
|
292
|
+
return { config, playbooks: listing };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function adaptersFromComposedConfig(config) {
|
|
296
|
+
const adapters = new Set();
|
|
297
|
+
if (config?.captain?.adapter) adapters.add(config.captain.adapter);
|
|
298
|
+
for (const player of config?.players ?? []) {
|
|
299
|
+
if (player?.adapter) adapters.add(player.adapter);
|
|
300
|
+
}
|
|
301
|
+
return [...adapters];
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function checkReadiness(adapters, env = process.env, home = homedir()) {
|
|
305
|
+
const failingAdapters = [];
|
|
306
|
+
const unknownAdapters = [];
|
|
307
|
+
for (const adapter of adapters) {
|
|
308
|
+
if (adapter === 'claude') {
|
|
309
|
+
if (!env.ANTHROPIC_API_KEY && !existsSync(join(home, '.claude'))) {
|
|
310
|
+
failingAdapters.push(adapter);
|
|
311
|
+
}
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (adapter === 'codex') {
|
|
315
|
+
if (!env.OPENAI_API_KEY && !existsSync(join(home, '.codex'))) {
|
|
316
|
+
failingAdapters.push(adapter);
|
|
317
|
+
}
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
unknownAdapters.push(adapter);
|
|
321
|
+
}
|
|
322
|
+
return { failingAdapters, unknownAdapters };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function writeComposedConfig(composed) {
|
|
326
|
+
const dir = mkdtempSync(join(tmpdir(), 'playbook-'));
|
|
327
|
+
const path = join(dir, 'tmux-play.config.yaml');
|
|
328
|
+
writeFileSync(path, stringifyYaml(composed));
|
|
329
|
+
return { dir, path };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function seedUserConfigIfMissing(userConfigPath, stderr) {
|
|
333
|
+
if (existsSync(userConfigPath)) return;
|
|
334
|
+
mkdirSync(dirname(userConfigPath), { recursive: true });
|
|
335
|
+
copyFileSync(templatePath, userConfigPath, constants.COPYFILE_EXCL);
|
|
336
|
+
stderr.write(`playbook: created config at ${userConfigPath}\n`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function hasExplicitConfig(argv) {
|
|
340
|
+
return argv.some((arg) => arg === '--config' || arg.startsWith('--config='));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function helpText({ userConfigPath, failingAdapters = [] }) {
|
|
344
|
+
const failures =
|
|
345
|
+
failingAdapters.length > 0
|
|
346
|
+
? [`Adapters not ready: ${failingAdapters.join(', ')}`, '']
|
|
347
|
+
: [];
|
|
348
|
+
return [
|
|
349
|
+
...failures,
|
|
350
|
+
'Usage:',
|
|
351
|
+
' playbook [--list] [--config <path>] [tmux-play options]',
|
|
352
|
+
' playbook --help',
|
|
353
|
+
'',
|
|
354
|
+
`Default config: ${userConfigPath}`,
|
|
355
|
+
'',
|
|
356
|
+
'Adapter setup:',
|
|
357
|
+
' claude: run Claude Code once or set ANTHROPIC_API_KEY.',
|
|
358
|
+
' codex: run Codex CLI once or set OPENAI_API_KEY.',
|
|
359
|
+
'',
|
|
360
|
+
'Agent swap recipe:',
|
|
361
|
+
' - reuse agent settings under top-level profiles',
|
|
362
|
+
' - point each playbooks.<id>.captain / players.<role> at a profile id',
|
|
363
|
+
' or an adapter shorthand (claude, codex)',
|
|
364
|
+
' - the launcher injects captain.from and the namespaced <id>-<role>',
|
|
365
|
+
' host players',
|
|
366
|
+
'',
|
|
367
|
+
].join('\n');
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function launchTmuxPlay(spawnFn, childArgs, stderr) {
|
|
371
|
+
return await new Promise((resolveResult) => {
|
|
372
|
+
let child;
|
|
373
|
+
try {
|
|
374
|
+
child = spawnFn(process.execPath, childArgs, { stdio: 'inherit' });
|
|
375
|
+
} catch (error) {
|
|
376
|
+
stderr.write(
|
|
377
|
+
`playbook: failed to launch tmux-play: ${errorMessage(error)}\n`,
|
|
378
|
+
);
|
|
379
|
+
resolveResult({ code: 127 });
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
let settled = false;
|
|
383
|
+
const settle = (result) => {
|
|
384
|
+
if (settled) return;
|
|
385
|
+
settled = true;
|
|
386
|
+
resolveResult(result);
|
|
387
|
+
};
|
|
388
|
+
child.on('error', (err) => {
|
|
389
|
+
stderr.write(
|
|
390
|
+
`playbook: failed to launch tmux-play: ${errorMessage(err)}\n`,
|
|
391
|
+
);
|
|
392
|
+
settle({ code: 127 });
|
|
393
|
+
});
|
|
394
|
+
child.on('exit', (code, signal) => {
|
|
395
|
+
if (signal) settle({ signal });
|
|
396
|
+
else settle({ code: code ?? 0 });
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function resolveTmuxPlayBin() {
|
|
402
|
+
const tmuxPlayIndexUrl = import.meta.resolve('@sublang/cligent/tmux-play');
|
|
403
|
+
return join(dirname(fileURLToPath(tmuxPlayIndexUrl)), 'cli.js');
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function isObject(value) {
|
|
407
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function hasOwn(value, key) {
|
|
411
|
+
return Object.prototype.hasOwnProperty.call(value, key);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function requireObject(value, path) {
|
|
415
|
+
if (!isObject(value)) {
|
|
416
|
+
throw new Error(`${path} must be an object`);
|
|
417
|
+
}
|
|
418
|
+
return value;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function errorMessage(error) {
|
|
422
|
+
return error instanceof Error ? error.message : String(error);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function isCliEntry(argv1 = process.argv[1], moduleUrl = import.meta.url) {
|
|
426
|
+
if (!argv1) return false;
|
|
427
|
+
try {
|
|
428
|
+
return realpathSync(argv1) === realpathSync(fileURLToPath(moduleUrl));
|
|
429
|
+
} catch {
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (isCliEntry()) {
|
|
435
|
+
const result = await runPlaybookCli();
|
|
436
|
+
if (result.signal) process.kill(process.pid, result.signal);
|
|
437
|
+
else process.exit(result.code ?? 0);
|
|
438
|
+
}
|
|
@@ -67,8 +67,9 @@ export type CaptainInput = {
|
|
|
67
67
|
coderPlayer?: string;
|
|
68
68
|
reviewerPlayer?: string;
|
|
69
69
|
// Routing-only: the configured Committer-alias player id (`coder` /
|
|
70
|
-
// `reviewer`) threaded from
|
|
71
|
-
// (PBRT-8 / PBRT-30).
|
|
70
|
+
// `reviewer`) threaded from
|
|
71
|
+
// `captain.options.playbooks.code.options.committer` (PBRT-8 / PBRT-30).
|
|
72
|
+
// It selects which host pane runs a `Committer`
|
|
72
73
|
// commit; it is not a prompt-placeholder source, so it never affects
|
|
73
74
|
// <coder-llm> / <reviewer-llm> substitution or any labelled block,
|
|
74
75
|
// and `input.player` stays `Committer` (PLAYBOOK-3).
|
|
@@ -131,8 +131,9 @@ function composePlayerPrompt(input) {
|
|
|
131
131
|
// Non-composite: Coder→'coder', Reviewer→'reviewer'. The composite
|
|
132
132
|
// Committer (= Coder | Reviewer per code.gears.md) resolves to the
|
|
133
133
|
// configured alias when present: `input.committerPlayer`, the
|
|
134
|
-
// validated `captain.options.code.committer`
|
|
135
|
-
// already a baked player id ('coder' / 'reviewer').
|
|
134
|
+
// validated `captain.options.playbooks.code.options.committer`
|
|
135
|
+
// (PBRT-8 / PBRT-30), already a baked player id ('coder' / 'reviewer').
|
|
136
|
+
// Absent a
|
|
136
137
|
// configured alias it falls back to the DR-004 §2 baked binding by
|
|
137
138
|
// populated <playerName>Player field: prefer `coderPlayer` (CODE-18
|
|
138
139
|
// wires only coderPlayer; CODE-19 wires both so coderPlayer still wins
|
|
@@ -177,8 +177,9 @@ function composePlayerPrompt(input: CaptainInput): string {
|
|
|
177
177
|
// Non-composite: Coder→'coder', Reviewer→'reviewer'. The composite
|
|
178
178
|
// Committer (= Coder | Reviewer per code.gears.md) resolves to the
|
|
179
179
|
// configured alias when present: `input.committerPlayer`, the
|
|
180
|
-
// validated `captain.options.code.committer`
|
|
181
|
-
// already a baked player id ('coder' / 'reviewer').
|
|
180
|
+
// validated `captain.options.playbooks.code.options.committer`
|
|
181
|
+
// (PBRT-8 / PBRT-30), already a baked player id ('coder' / 'reviewer').
|
|
182
|
+
// Absent a
|
|
182
183
|
// configured alias it falls back to the DR-004 §2 baked binding by
|
|
183
184
|
// populated <playerName>Player field: prefer `coderPlayer` (CODE-18
|
|
184
185
|
// wires only coderPlayer; CODE-19 wires both so coderPlayer still wins
|
|
@@ -15,6 +15,19 @@ export declare const codeStateCountLabels: {
|
|
|
15
15
|
readonly reviewChangesAndChallengesCode: "review round";
|
|
16
16
|
readonly reviewChangesAndChallengesMixed: "review round";
|
|
17
17
|
};
|
|
18
|
+
export declare function codeSavedCountsLine(counts: {
|
|
19
|
+
interruptions: number;
|
|
20
|
+
copyPastes: number;
|
|
21
|
+
}, rounds: number): string;
|
|
22
|
+
export interface PlaybookSummaryPolicy {
|
|
23
|
+
stateCountLabels: Readonly<Record<string, string>>;
|
|
24
|
+
copyPasteGuardNames: readonly string[];
|
|
25
|
+
savedCountsLine(counts: {
|
|
26
|
+
interruptions: number;
|
|
27
|
+
copyPastes: number;
|
|
28
|
+
}, rounds: number): string;
|
|
29
|
+
}
|
|
30
|
+
export declare const codeSummaryPolicy: PlaybookSummaryPolicy;
|
|
18
31
|
export interface CodeOptions {
|
|
19
32
|
committer?: 'coder' | 'reviewer';
|
|
20
33
|
}
|
|
@@ -31,13 +44,15 @@ export interface CodePlaybookRegistryEntry {
|
|
|
31
44
|
id: 'code';
|
|
32
45
|
command: 'code';
|
|
33
46
|
intent: string;
|
|
47
|
+
requiredRoleIds: readonly string[];
|
|
34
48
|
idleStateId: 'ready';
|
|
35
49
|
finalStateId: 'done';
|
|
36
|
-
|
|
37
|
-
|
|
50
|
+
parkStateIds: readonly string[];
|
|
51
|
+
summaryPolicy: PlaybookSummaryPolicy;
|
|
38
52
|
validateOptions(captainOptions: unknown): CodeOptions;
|
|
39
53
|
createRuntime(options: CreateCodeRuntimeOptions): PlaybookRuntime;
|
|
40
54
|
}
|
|
41
|
-
export declare function validateCodeOptions(
|
|
55
|
+
export declare function validateCodeOptions(optionSlice: unknown): CodeOptions;
|
|
42
56
|
export declare function createCodeRuntimeOptions({ captainOptions, players, }: CreateCodeRuntimeOptions): CodePlaybookOptions;
|
|
43
57
|
export declare const codePlaybookRegistryEntry: CodePlaybookRegistryEntry;
|
|
58
|
+
export default codePlaybookRegistryEntry;
|
|
@@ -1,19 +1,15 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
// SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
|
|
3
3
|
import createPlaybookRuntime from './code.playbook.js';
|
|
4
|
-
// PBRT-
|
|
5
|
-
// `captain.options.code
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// `committer` value is rejected naming `captain.options.code.committer`.
|
|
14
|
-
// A further CODE option shall be introduced as its own higher-numbered
|
|
15
|
-
// item that widens `CODE_OPTION_KEYS`; the validator still fails closed
|
|
16
|
-
// on stray keys.
|
|
4
|
+
// PBRT-30: the CODE registry entry validates the option slice the shell
|
|
5
|
+
// passes it (`captain.options.playbooks.code.options`), not a namespace
|
|
6
|
+
// it extracts from the full Captain options bag. The schema defines one
|
|
7
|
+
// key, `committer`: an optional Committer-alias player id, one of the
|
|
8
|
+
// baked role ids `coder` / `reviewer`. A valid slice is absent, `{}`, or
|
|
9
|
+
// `{ committer: 'coder' | 'reviewer' }`; every other key is unknown and
|
|
10
|
+
// rejected with a path-named error. A further CODE option shall be
|
|
11
|
+
// introduced as its own higher-numbered item that widens
|
|
12
|
+
// `CODE_OPTION_KEYS`; the validator still fails closed on stray keys.
|
|
17
13
|
const CODE_OPTION_KEYS = new Set(['committer']);
|
|
18
14
|
const COMMITTER_PLAYER_IDS = new Set(['coder', 'reviewer']);
|
|
19
15
|
export const codeCopyPasteGuardNames = [
|
|
@@ -48,36 +44,53 @@ export const codeStateCountLabels = {
|
|
|
48
44
|
reviewChangesAndChallengesCode: 'review round',
|
|
49
45
|
reviewChangesAndChallengesMixed: 'review round',
|
|
50
46
|
};
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
47
|
+
function countNoun(count, singular, plural = `${singular}s`) {
|
|
48
|
+
return `${count} ${count === 1 ? singular : plural}`;
|
|
49
|
+
}
|
|
50
|
+
// PBRT-15 / CAPTAIN-19: the CODE saved-counts line wording is
|
|
51
|
+
// registry-owned. The shell renders this exact line through the active
|
|
52
|
+
// entry's summary policy rather than hardcoding CODE phrasing.
|
|
53
|
+
export function codeSavedCountsLine(counts, rounds) {
|
|
54
|
+
return [
|
|
55
|
+
'Saved you',
|
|
56
|
+
countNoun(counts.interruptions, 'interruption'),
|
|
57
|
+
'and',
|
|
58
|
+
countNoun(counts.copyPastes, 'copy-paste'),
|
|
59
|
+
'across',
|
|
60
|
+
countNoun(rounds, 'round'),
|
|
61
|
+
'of reviews/rebuttals.',
|
|
62
|
+
].join(' ');
|
|
63
|
+
}
|
|
64
|
+
export const codeSummaryPolicy = {
|
|
65
|
+
stateCountLabels: codeStateCountLabels,
|
|
66
|
+
copyPasteGuardNames: codeCopyPasteGuardNames,
|
|
67
|
+
savedCountsLine: codeSavedCountsLine,
|
|
68
|
+
};
|
|
69
|
+
export function validateCodeOptions(optionSlice) {
|
|
70
|
+
if (optionSlice === undefined)
|
|
54
71
|
return {};
|
|
55
|
-
if (typeof
|
|
56
|
-
|
|
72
|
+
if (typeof optionSlice !== 'object' ||
|
|
73
|
+
optionSlice === null ||
|
|
74
|
+
Array.isArray(optionSlice)) {
|
|
75
|
+
throw new Error('captain.options.playbooks.code.options must be an object');
|
|
57
76
|
}
|
|
58
|
-
|
|
77
|
+
const slice = optionSlice;
|
|
78
|
+
for (const key of Object.keys(slice)) {
|
|
59
79
|
if (!CODE_OPTION_KEYS.has(key)) {
|
|
60
|
-
throw new Error(`Unknown config field captain.options.code.${key}`);
|
|
80
|
+
throw new Error(`Unknown config field captain.options.playbooks.code.options.${key}`);
|
|
61
81
|
}
|
|
62
82
|
}
|
|
63
83
|
const options = {};
|
|
64
|
-
const committer =
|
|
84
|
+
const committer = slice.committer;
|
|
65
85
|
if (committer !== undefined) {
|
|
66
86
|
if (typeof committer !== 'string' || !COMMITTER_PLAYER_IDS.has(committer)) {
|
|
67
|
-
throw new Error("captain.options.code.committer must be
|
|
87
|
+
throw new Error("captain.options.playbooks.code.options.committer must be " +
|
|
88
|
+
"'coder' or 'reviewer'");
|
|
68
89
|
}
|
|
69
90
|
options.committer = committer;
|
|
70
91
|
}
|
|
71
92
|
return options;
|
|
72
93
|
}
|
|
73
|
-
function readCodeNamespace(captainOptions) {
|
|
74
|
-
if (typeof captainOptions !== 'object' ||
|
|
75
|
-
captainOptions === null ||
|
|
76
|
-
Array.isArray(captainOptions)) {
|
|
77
|
-
return undefined;
|
|
78
|
-
}
|
|
79
|
-
return captainOptions.code;
|
|
80
|
-
}
|
|
81
94
|
function playerIdentity(players, id) {
|
|
82
95
|
const entry = players.find((p) => p.id === id);
|
|
83
96
|
return entry?.model ?? entry?.adapter;
|
|
@@ -98,12 +111,17 @@ export const codePlaybookRegistryEntry = {
|
|
|
98
111
|
id: 'code',
|
|
99
112
|
command: 'code',
|
|
100
113
|
intent: 'software development / SDLC coding workflow',
|
|
114
|
+
requiredRoleIds: ['coder', 'reviewer'],
|
|
101
115
|
idleStateId: 'ready',
|
|
102
116
|
finalStateId: 'done',
|
|
103
|
-
|
|
104
|
-
|
|
117
|
+
parkStateIds: ['failed', 'awaitBossReply'],
|
|
118
|
+
summaryPolicy: codeSummaryPolicy,
|
|
105
119
|
validateOptions: validateCodeOptions,
|
|
106
120
|
createRuntime(options) {
|
|
107
121
|
return createPlaybookRuntime(createCodeRuntimeOptions(options));
|
|
108
122
|
},
|
|
109
123
|
};
|
|
124
|
+
// CAPTAIN-16 / PBRT-16: the published `@sublang/playbook/code/registry`
|
|
125
|
+
// module's default export is the CODE registry entry the Playbook Captain
|
|
126
|
+
// shell loads when a playbook block's `from` names this module.
|
|
127
|
+
export default codePlaybookRegistryEntry;
|