@sublang/playbook 6.0.0 → 8.0.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 +28 -11
- package/docs/cli.md +158 -68
- package/docs/configuration.md +246 -108
- package/docs/embedding.md +71 -25
- package/package.json +6 -3
- package/reference/sdlc/captain.playbook/captain.playbook.js +3 -3
- package/reference/sdlc/captain.playbook/captain.playbook.ts +3 -3
- package/reference/sdlc/code.md +1 -1
- package/reference/sdlc/code.playbook/bin/interactive-session.js +816 -0
- package/reference/sdlc/code.playbook/bin/launch-config.js +1900 -0
- package/reference/sdlc/code.playbook/bin/playbook.js +573 -535
- package/reference/sdlc/code.playbook/bin/provision.js +84 -38
- package/reference/sdlc/code.playbook/bin/run.js +1164 -991
- package/reference/sdlc/code.playbook/bin/session-store.js +1961 -0
- package/reference/sdlc/code.playbook/code.fsm.d.ts +5 -5
- package/reference/sdlc/code.playbook/code.fsm.introspect.js +2 -2
- package/reference/sdlc/code.playbook/code.fsm.introspect.ts +2 -2
- package/reference/sdlc/code.playbook/code.fsm.js +7 -11
- package/reference/sdlc/code.playbook/code.fsm.ts +9 -17
- package/reference/sdlc/code.playbook/code.gears.md +1 -1
- package/reference/sdlc/code.playbook/code.playbook.d.ts +2 -1
- package/reference/sdlc/code.playbook/code.playbook.js +12 -13
- package/reference/sdlc/code.playbook/code.playbook.ts +22 -15
- package/reference/sdlc/code.playbook/code.registry.d.ts +5 -13
- package/reference/sdlc/code.playbook/code.registry.js +3 -10
- package/reference/sdlc/code.playbook/code.registry.ts +7 -32
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +101 -9
- package/reference/sdlc/code.playbook/playbook-captain.js +1690 -213
- package/reference/sdlc/code.playbook/playbook-captain.ts +2492 -253
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +44 -62
- package/reference/sdlc/decide.md +4 -4
- package/reference/sdlc/decide.playbook/decide.fsm.d.ts +9 -9
- package/reference/sdlc/decide.playbook/decide.fsm.js +21 -14
- package/reference/sdlc/decide.playbook/decide.fsm.ts +27 -23
- package/reference/sdlc/decide.playbook/decide.gears.md +3 -5
- package/reference/sdlc/decide.playbook/decide.playbook.d.ts +9 -13
- package/reference/sdlc/decide.playbook/decide.playbook.js +244 -143
- package/reference/sdlc/decide.playbook/decide.playbook.ts +326 -171
- package/reference/sdlc/decide.playbook/decide.registry.d.ts +5 -13
- package/reference/sdlc/decide.playbook/decide.registry.js +3 -9
- package/reference/sdlc/decide.playbook/decide.registry.ts +7 -31
- package/reference/sdlc/review.md +4 -5
- package/reference/sdlc/review.playbook/review.fsm.d.ts +9 -11
- package/reference/sdlc/review.playbook/review.fsm.js +30 -24
- package/reference/sdlc/review.playbook/review.fsm.ts +39 -35
- package/reference/sdlc/review.playbook/review.gears.md +6 -5
- package/reference/sdlc/review.playbook/review.playbook.d.ts +2 -1
- package/reference/sdlc/review.playbook/review.playbook.js +16 -21
- package/reference/sdlc/review.playbook/review.playbook.ts +26 -26
- package/reference/sdlc/review.playbook/review.registry.d.ts +5 -13
- package/reference/sdlc/review.playbook/review.registry.js +3 -16
- package/reference/sdlc/review.playbook/review.registry.ts +7 -38
- package/slc/gears2fsm.md +27 -23
- package/slc/link.md +140 -97
- package/slc/text2gears.md +19 -18
- package/src/runtime.d.ts +24 -8
- package/src/runtime.ts +29 -13
- package/src/xstate-playbook-runtime.d.ts +21 -17
- package/src/xstate-playbook-runtime.js +301 -159
- package/src/xstate-playbook-runtime.ts +405 -186
- package/src/xstate-runtime.d.ts +19 -2
- package/src/xstate-runtime.js +403 -62
- package/src/xstate-runtime.ts +566 -78
|
@@ -0,0 +1,1900 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
|
|
3
|
+
|
|
4
|
+
// PBCLI-46: this module owns the host-neutral launch-configuration path used
|
|
5
|
+
// by both presentation front ends. It deliberately contains no tmux process
|
|
6
|
+
// control and stores no imported registry functions in the normalized plan.
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
constants,
|
|
10
|
+
copyFileSync,
|
|
11
|
+
existsSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
mkdtempSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
rmSync,
|
|
16
|
+
writeFileSync,
|
|
17
|
+
} from 'node:fs';
|
|
18
|
+
import { homedir, tmpdir } from 'node:os';
|
|
19
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
20
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
21
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
22
|
+
import {
|
|
23
|
+
parse as parseYaml,
|
|
24
|
+
parseDocument as parseYamlDocument,
|
|
25
|
+
stringify as stringifyYaml,
|
|
26
|
+
} from 'yaml';
|
|
27
|
+
import { loadTmuxPlayConfig } from '@sublang/cligent/tmux-play';
|
|
28
|
+
|
|
29
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
const DEFAULT_TEMPLATE_PATH = resolve(
|
|
31
|
+
here,
|
|
32
|
+
'..',
|
|
33
|
+
'playbook.config.template.yaml',
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
// PBCLI-1/8: the tmux projection uses the Playbook Captain shell adapter.
|
|
37
|
+
export const PLAYBOOK_CAPTAIN_MODULE =
|
|
38
|
+
'@sublang/playbook/playbook-captain';
|
|
39
|
+
const PLAYBOOK_LAUNCHER_KEYS = ['from', 'command', 'roles'];
|
|
40
|
+
const PLAYBOOK_TOP_LEVEL_KEYS = new Set([
|
|
41
|
+
'captain',
|
|
42
|
+
'players',
|
|
43
|
+
'playbooks',
|
|
44
|
+
'layout',
|
|
45
|
+
'notifications',
|
|
46
|
+
'theme',
|
|
47
|
+
]);
|
|
48
|
+
const RESERVED_CAPTAIN_PLAYBOOK_ID = 'captain';
|
|
49
|
+
const RESERVED_CAPTAIN_ROLE_ID = 'captain';
|
|
50
|
+
const PLAYER_ID_PATTERN = /^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*$/;
|
|
51
|
+
const ROLE_ID_PATTERN = /^[a-z][a-z0-9_-]*$/;
|
|
52
|
+
|
|
53
|
+
// PBCLI-26: split ordered `--with <path>` pairs out of an argument vector.
|
|
54
|
+
// The returned arrays are new values; the caller's vector is never changed.
|
|
55
|
+
export function extractWithFlags(argv) {
|
|
56
|
+
const withPaths = [];
|
|
57
|
+
const rest = [];
|
|
58
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
59
|
+
const arg = argv[i];
|
|
60
|
+
if (arg === '--with') {
|
|
61
|
+
const value = argv[i + 1];
|
|
62
|
+
if (value === undefined || value === '') {
|
|
63
|
+
throw new Error('--with needs a value');
|
|
64
|
+
}
|
|
65
|
+
withPaths.push(value);
|
|
66
|
+
i += 1;
|
|
67
|
+
} else if (arg.startsWith('--with=')) {
|
|
68
|
+
const value = arg.slice('--with='.length);
|
|
69
|
+
if (!value) throw new Error('--with needs a value');
|
|
70
|
+
withPaths.push(value);
|
|
71
|
+
} else {
|
|
72
|
+
rest.push(arg);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return { withPaths, rest };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// PBCLI-25: an overlay fragment is a top-level-format YAML map.
|
|
79
|
+
export function loadOverlayFragment(overlayPath) {
|
|
80
|
+
const resolved = resolve(overlayPath);
|
|
81
|
+
let text;
|
|
82
|
+
try {
|
|
83
|
+
text = readFileSync(resolved, 'utf8');
|
|
84
|
+
} catch (error) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`cannot read --with overlay ${overlayPath}: ${errorMessage(error)}`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
let fragment;
|
|
90
|
+
try {
|
|
91
|
+
fragment = parseYaml(text);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`cannot parse --with overlay ${overlayPath}: ${errorMessage(error)}`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
if (!isObject(fragment)) {
|
|
98
|
+
throw new Error(`--with overlay ${overlayPath} must be a YAML map`);
|
|
99
|
+
}
|
|
100
|
+
return fragment;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// PBCLI-25/46: recursively merge maps and replace every other value without
|
|
104
|
+
// mutating either input. Object.fromEntries also makes __proto__ a data key.
|
|
105
|
+
export function mergeConfigs(base, overlay) {
|
|
106
|
+
return Object.fromEntries([
|
|
107
|
+
...Object.entries(base),
|
|
108
|
+
...Object.entries(overlay).map(([key, value]) => [
|
|
109
|
+
key,
|
|
110
|
+
isObject(base[key]) && isObject(value)
|
|
111
|
+
? mergeConfigs(base[key], value)
|
|
112
|
+
: value,
|
|
113
|
+
]),
|
|
114
|
+
]);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// PBCLI-22/46: ordinary reopen must merge only durable catalog members.
|
|
118
|
+
// A selected member may first appear in an overlay, so each layer is pruned
|
|
119
|
+
// permissively before merge and the final strict projection below owns the
|
|
120
|
+
// missing-member diagnostic. Unselected accessors are never observed.
|
|
121
|
+
export function mergeSelectedConfigs(base, overlay, selectedMembers) {
|
|
122
|
+
const selected = validateSelectedMembers(selectedMembers);
|
|
123
|
+
return mergeConfigs(
|
|
124
|
+
projectSelectedLayer(base, selected, 'config'),
|
|
125
|
+
projectSelectedLayer(overlay, selected, 'overlay'),
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function resolveConfigHome(env = process.env, home = homedir()) {
|
|
130
|
+
return env.XDG_CONFIG_HOME || join(home, '.config');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function resolveUserConfigPath(env = process.env, home = homedir()) {
|
|
134
|
+
return join(resolveConfigHome(env, home), 'playbook', 'playbook.config.yaml');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// PBCLI-46: configured filesystem modules are anchored once to the primary
|
|
138
|
+
// config, including paths introduced by overlays. Bare/custom specifiers and
|
|
139
|
+
// already-authored file URLs retain their module semantics.
|
|
140
|
+
export function canonicalizeRegistrySpecifier(from, configPath) {
|
|
141
|
+
if (configPath === undefined || from.startsWith('file:')) return from;
|
|
142
|
+
if (
|
|
143
|
+
isAbsolute(from) ||
|
|
144
|
+
from.startsWith('./') ||
|
|
145
|
+
from.startsWith('../') ||
|
|
146
|
+
from.startsWith('.\\') ||
|
|
147
|
+
from.startsWith('..\\')
|
|
148
|
+
) {
|
|
149
|
+
return pathToFileURL(resolve(dirname(configPath), from)).href;
|
|
150
|
+
}
|
|
151
|
+
return from;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// PBCLI-46: seed, migrate, overlay, validate, and normalize through one path.
|
|
155
|
+
// `prepareRegistryModule` is the single provision-before-import seam used by
|
|
156
|
+
// both front ends, so filesystem registry handling cannot drift by presenter.
|
|
157
|
+
export async function loadLaunchPlan({
|
|
158
|
+
userConfigPath,
|
|
159
|
+
overlayPaths = [],
|
|
160
|
+
loadModule = (specifier) => import(specifier),
|
|
161
|
+
prepareRegistryModule,
|
|
162
|
+
templatePath = DEFAULT_TEMPLATE_PATH,
|
|
163
|
+
onNotice = () => {},
|
|
164
|
+
selectedMembers,
|
|
165
|
+
}) {
|
|
166
|
+
seedUserConfigIfMissing(userConfigPath, templatePath, onNotice);
|
|
167
|
+
// PBCLI-22/46: a reopen must not rewrite, validate, or otherwise inspect
|
|
168
|
+
// config members outside the stored projection.
|
|
169
|
+
if (selectedMembers === undefined) {
|
|
170
|
+
migrateUserConfigIfRetired(userConfigPath, onNotice);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let top = parseYaml(readFileSync(userConfigPath, 'utf8')) ?? {};
|
|
174
|
+
if (overlayPaths.length > 0 && !isObject(top)) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
`the top-level config at ${userConfigPath} must be a YAML map before --with can overlay it`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
for (const overlayPath of overlayPaths) {
|
|
180
|
+
const overlay = loadOverlayFragment(overlayPath);
|
|
181
|
+
top =
|
|
182
|
+
selectedMembers === undefined
|
|
183
|
+
? mergeConfigs(top, overlay)
|
|
184
|
+
: mergeSelectedConfigs(top, overlay, selectedMembers);
|
|
185
|
+
}
|
|
186
|
+
return await normalizeLaunchPlan(top, {
|
|
187
|
+
loadModule,
|
|
188
|
+
configPath: userConfigPath,
|
|
189
|
+
prepareRegistryModule,
|
|
190
|
+
selectedMembers,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// PBCLI-22/49: an interactive selected launch may project current tuning and
|
|
195
|
+
// presentation before its pane child owns the writer lease, but it must not
|
|
196
|
+
// prepare or import a registry from an unlocked advisory read. Rebuild the
|
|
197
|
+
// durable catalog from the validated stored structural projection and use the
|
|
198
|
+
// current config only for compatible Captain/player tuning and presentation.
|
|
199
|
+
export async function loadSelectedLaunchPlanDataOnly({
|
|
200
|
+
userConfigPath,
|
|
201
|
+
overlayPaths = [],
|
|
202
|
+
structuralProjection,
|
|
203
|
+
templatePath = DEFAULT_TEMPLATE_PATH,
|
|
204
|
+
onNotice = () => {},
|
|
205
|
+
}) {
|
|
206
|
+
const stored = validateStoredStructuralProjection(structuralProjection);
|
|
207
|
+
const selectedMembers = selectedMembersFromStoredStructure(stored);
|
|
208
|
+
seedUserConfigIfMissing(userConfigPath, templatePath, onNotice);
|
|
209
|
+
|
|
210
|
+
let top = parseYaml(readFileSync(userConfigPath, 'utf8')) ?? {};
|
|
211
|
+
if (overlayPaths.length > 0 && !isObject(top)) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
`the top-level config at ${userConfigPath} must be a YAML map before --with can overlay it`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
top = projectSelectedLayer(
|
|
217
|
+
top,
|
|
218
|
+
validateSelectedMembers(selectedMembers),
|
|
219
|
+
'config',
|
|
220
|
+
);
|
|
221
|
+
for (const overlayPath of overlayPaths) {
|
|
222
|
+
top = mergeSelectedConfigs(
|
|
223
|
+
top,
|
|
224
|
+
loadOverlayFragment(overlayPath),
|
|
225
|
+
selectedMembers,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
return await normalizeSelectedLaunchPlanDataOnly(top, {
|
|
229
|
+
configPath: userConfigPath,
|
|
230
|
+
stored,
|
|
231
|
+
selectedMembers,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export async function normalizeSelectedLaunchPlanDataOnly(
|
|
236
|
+
top,
|
|
237
|
+
{ configPath, stored, selectedMembers } = {},
|
|
238
|
+
) {
|
|
239
|
+
stored = validateStoredStructuralProjection(stored);
|
|
240
|
+
const expectedMembers = selectedMembersFromStoredStructure(stored);
|
|
241
|
+
if (selectedMembers !== undefined) {
|
|
242
|
+
const supplied = validateSelectedMembers(selectedMembers);
|
|
243
|
+
if (
|
|
244
|
+
!isDeepStrictEqual(supplied.playbookIds, expectedMembers.playbookIds) ||
|
|
245
|
+
!isDeepStrictEqual(supplied.playerIds, expectedMembers.playerIds)
|
|
246
|
+
) {
|
|
247
|
+
throw new Error(
|
|
248
|
+
'selected launch members do not match the stored structural projection',
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
top = projectSelectedMembers(top, expectedMembers);
|
|
253
|
+
top = cloneJson(top, 'config');
|
|
254
|
+
assertNoRetiredProfiles(top, configPath);
|
|
255
|
+
if (hasOwn(top, 'run')) {
|
|
256
|
+
throw new Error(
|
|
257
|
+
'top-level "run" was removed: configure the shared Captain under ' +
|
|
258
|
+
'captain, top-level players, and playbooks.<id>.roles instead',
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
const unknownTopLevel = Object.keys(top).filter(
|
|
262
|
+
(key) => !PLAYBOOK_TOP_LEVEL_KEYS.has(key),
|
|
263
|
+
);
|
|
264
|
+
if (unknownTopLevel.length > 0) {
|
|
265
|
+
throw new Error(
|
|
266
|
+
`config has unknown top-level ${formatKeyList(unknownTopLevel)}`,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
if (top.layout !== undefined && !isObject(top.layout)) {
|
|
270
|
+
throw new Error('layout must be a map');
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const playersCfg = requireObject(top.players, 'players');
|
|
274
|
+
const configuredAgents = new Map();
|
|
275
|
+
for (const playerId of expectedMembers.playerIds) {
|
|
276
|
+
assertPlayerId(playerId, `players.${playerId}`);
|
|
277
|
+
const agent = resolveAgent(playersCfg[playerId], `players.${playerId}`, [
|
|
278
|
+
'id',
|
|
279
|
+
]);
|
|
280
|
+
configuredAgents.set(playerId, agent);
|
|
281
|
+
}
|
|
282
|
+
let captain = resolveAgent(top.captain, 'captain', ['from', 'options']);
|
|
283
|
+
|
|
284
|
+
const playbooksCfg = requireObject(top.playbooks, 'playbooks');
|
|
285
|
+
const tuningChecks = [];
|
|
286
|
+
let tuningCheckIndex = 0;
|
|
287
|
+
const authored = new Map();
|
|
288
|
+
for (const id of expectedMembers.playbookIds) {
|
|
289
|
+
const storedItem = stored.catalog[id];
|
|
290
|
+
const block = requireObject(playbooksCfg[id], `playbooks.${id}`);
|
|
291
|
+
if (hasOwn(block, 'players')) {
|
|
292
|
+
throw legacyPlayersError(`playbooks.${id}.players`, configPath);
|
|
293
|
+
}
|
|
294
|
+
if (
|
|
295
|
+
typeof block.from !== 'string' ||
|
|
296
|
+
block.from.trim().length === 0 ||
|
|
297
|
+
block.from !== block.from.trim()
|
|
298
|
+
) {
|
|
299
|
+
throw new Error(
|
|
300
|
+
`playbooks.${id}.from must be a canonical trimmed module specifier`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
const configuredFrom = canonicalizeRegistrySpecifier(block.from, configPath);
|
|
304
|
+
if (configuredFrom !== storedItem.from) {
|
|
305
|
+
throw new Error(
|
|
306
|
+
`playbooks.${id}.from changed from the stored structural projection`,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
const command = block.command ?? storedItem.manifestCommand;
|
|
310
|
+
if (command !== storedItem.command) {
|
|
311
|
+
throw new Error(
|
|
312
|
+
`playbooks.${id}.command changed from the stored structural projection`,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
const rolesMap = requireObject(block.roles, `playbooks.${id}.roles`);
|
|
316
|
+
assertExactRoleBindings(
|
|
317
|
+
id,
|
|
318
|
+
Object.keys(rolesMap),
|
|
319
|
+
storedItem.requiredRoleIds,
|
|
320
|
+
);
|
|
321
|
+
const bindings = {};
|
|
322
|
+
for (const role of storedItem.requiredRoleIds) {
|
|
323
|
+
const binding = resolveRoleBinding(
|
|
324
|
+
rolesMap[role],
|
|
325
|
+
`playbooks.${id}.roles.${role}`,
|
|
326
|
+
);
|
|
327
|
+
if (binding.playerId !== storedItem.roles[role].playerId) {
|
|
328
|
+
throw new Error(
|
|
329
|
+
`playbooks.${id}.roles.${role} changed its stored player binding`,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
bindings[role] = binding;
|
|
333
|
+
if (binding.model !== undefined || binding.effort !== undefined) {
|
|
334
|
+
let checkId;
|
|
335
|
+
do {
|
|
336
|
+
checkId = `binding-check-${tuningCheckIndex}`;
|
|
337
|
+
tuningCheckIndex += 1;
|
|
338
|
+
} while (configuredAgents.has(checkId));
|
|
339
|
+
tuningChecks.push({
|
|
340
|
+
id: checkId,
|
|
341
|
+
agent: applyTuningOverrides(
|
|
342
|
+
configuredAgents.get(binding.playerId),
|
|
343
|
+
binding,
|
|
344
|
+
),
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const optionSlice = Object.fromEntries(
|
|
349
|
+
Object.entries(block).filter(
|
|
350
|
+
([key]) => !PLAYBOOK_LAUNCHER_KEYS.includes(key),
|
|
351
|
+
),
|
|
352
|
+
);
|
|
353
|
+
if (!isDeepStrictEqual(optionSlice, storedItem.options)) {
|
|
354
|
+
throw new Error(
|
|
355
|
+
`playbooks.${id} options changed from the stored structural projection`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
authored.set(id, { bindings });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const firstRoleful = expectedMembers.playbookIds
|
|
362
|
+
.map((id) => stored.catalog[id])
|
|
363
|
+
.find((item) => item.requiredRoleIds.length > 0);
|
|
364
|
+
const initialVisible =
|
|
365
|
+
firstRoleful === undefined
|
|
366
|
+
? []
|
|
367
|
+
: distinct(
|
|
368
|
+
firstRoleful.requiredRoleIds.map(
|
|
369
|
+
(role) => firstRoleful.roles[role].playerId,
|
|
370
|
+
),
|
|
371
|
+
);
|
|
372
|
+
const validationVisible =
|
|
373
|
+
initialVisible.length === 0
|
|
374
|
+
? expectedMembers.playerIds.slice(0, 1)
|
|
375
|
+
: initialVisible;
|
|
376
|
+
const provisional = {
|
|
377
|
+
captain: { ...captain, from: PLAYBOOK_CAPTAIN_MODULE, options: {} },
|
|
378
|
+
players: [
|
|
379
|
+
...expectedMembers.playerIds.map((id) => ({
|
|
380
|
+
id,
|
|
381
|
+
...configuredAgents.get(id),
|
|
382
|
+
})),
|
|
383
|
+
...tuningChecks.map(({ id, agent }) => ({ id, ...agent })),
|
|
384
|
+
],
|
|
385
|
+
layout: {
|
|
386
|
+
...(isObject(top.layout) ? top.layout : {}),
|
|
387
|
+
initialVisible: validationVisible,
|
|
388
|
+
},
|
|
389
|
+
...(top.notifications === undefined
|
|
390
|
+
? {}
|
|
391
|
+
: { notifications: top.notifications }),
|
|
392
|
+
...(top.theme === undefined ? {} : { theme: top.theme }),
|
|
393
|
+
};
|
|
394
|
+
const normalizedHost = await normalizeHostConfig(provisional);
|
|
395
|
+
const normalizedPresentationHost =
|
|
396
|
+
initialVisible.length === 0 && expectedMembers.playerIds.length > 0
|
|
397
|
+
? await normalizeHostConfig({
|
|
398
|
+
...provisional,
|
|
399
|
+
players: [],
|
|
400
|
+
layout: { ...provisional.layout, initialVisible: [] },
|
|
401
|
+
})
|
|
402
|
+
: normalizedHost;
|
|
403
|
+
const { from: _captainFrom, options: _captainOptions, ...normalizedCaptain } =
|
|
404
|
+
normalizedHost.captain;
|
|
405
|
+
captain = sessionAgentFromHostAgent(normalizedCaptain, 'captain');
|
|
406
|
+
const hostAgents = new Map(
|
|
407
|
+
normalizedHost.players.map(({ id, ...agent }) => [id, agent]),
|
|
408
|
+
);
|
|
409
|
+
const normalizedPlayerAgents = new Map(
|
|
410
|
+
expectedMembers.playerIds.map((id) => [
|
|
411
|
+
id,
|
|
412
|
+
sessionAgentFromHostAgent(hostAgents.get(id), `players.${id}`),
|
|
413
|
+
]),
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
const catalog = Object.fromEntries(
|
|
417
|
+
expectedMembers.playbookIds.map((id) => {
|
|
418
|
+
const storedItem = stored.catalog[id];
|
|
419
|
+
const bindings = authored.get(id).bindings;
|
|
420
|
+
return [
|
|
421
|
+
id,
|
|
422
|
+
{
|
|
423
|
+
...cloneJson(storedItem, `stored catalog.${id}`),
|
|
424
|
+
roles: Object.fromEntries(
|
|
425
|
+
storedItem.requiredRoleIds.map((role) => {
|
|
426
|
+
const binding = bindings[role];
|
|
427
|
+
const agent = normalizedPlayerAgents.get(binding.playerId);
|
|
428
|
+
return [
|
|
429
|
+
role,
|
|
430
|
+
{
|
|
431
|
+
playerId: binding.playerId,
|
|
432
|
+
model:
|
|
433
|
+
binding.model === undefined
|
|
434
|
+
? agent.model
|
|
435
|
+
: overrideTuningSelection(binding.model),
|
|
436
|
+
effort:
|
|
437
|
+
binding.effort === undefined
|
|
438
|
+
? agent.effort
|
|
439
|
+
: overrideTuningSelection(binding.effort),
|
|
440
|
+
},
|
|
441
|
+
];
|
|
442
|
+
}),
|
|
443
|
+
),
|
|
444
|
+
},
|
|
445
|
+
];
|
|
446
|
+
}),
|
|
447
|
+
);
|
|
448
|
+
const candidateStructure = {
|
|
449
|
+
schemaVersion: stored.schemaVersion,
|
|
450
|
+
captain: fixedAgentProjection(captain),
|
|
451
|
+
players: expectedMembers.playerIds.map((id) => ({
|
|
452
|
+
id,
|
|
453
|
+
...fixedAgentProjection(normalizedPlayerAgents.get(id)),
|
|
454
|
+
})),
|
|
455
|
+
catalog: Object.fromEntries(
|
|
456
|
+
Object.entries(catalog).map(([id, item]) => [
|
|
457
|
+
id,
|
|
458
|
+
{
|
|
459
|
+
...cloneJson(stored.catalog[id], `stored catalog.${id}`),
|
|
460
|
+
roles: Object.fromEntries(
|
|
461
|
+
Object.entries(item.roles).map(([role, binding]) => [
|
|
462
|
+
role,
|
|
463
|
+
{ playerId: binding.playerId },
|
|
464
|
+
]),
|
|
465
|
+
),
|
|
466
|
+
},
|
|
467
|
+
]),
|
|
468
|
+
),
|
|
469
|
+
};
|
|
470
|
+
if (!isDeepStrictEqual(candidateStructure, stored)) {
|
|
471
|
+
throw new Error(
|
|
472
|
+
'current selected config does not reproduce the stored structural projection',
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return deepFreeze(
|
|
477
|
+
cloneJson(
|
|
478
|
+
{
|
|
479
|
+
schemaVersion: 1,
|
|
480
|
+
captain,
|
|
481
|
+
players: expectedMembers.playerIds.map((id) => ({
|
|
482
|
+
id,
|
|
483
|
+
agent: normalizedPlayerAgents.get(id),
|
|
484
|
+
})),
|
|
485
|
+
catalog,
|
|
486
|
+
presentation: {
|
|
487
|
+
layout: {
|
|
488
|
+
...normalizedPresentationHost.layout,
|
|
489
|
+
initialVisible,
|
|
490
|
+
},
|
|
491
|
+
notifications: normalizedPresentationHost.notifications,
|
|
492
|
+
...(normalizedPresentationHost.theme === undefined
|
|
493
|
+
? {}
|
|
494
|
+
: { theme: normalizedPresentationHost.theme }),
|
|
495
|
+
},
|
|
496
|
+
},
|
|
497
|
+
'selected launch config',
|
|
498
|
+
),
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// PBCLI-8 (DR-021): scalar agents are adapter shorthands and full blocks
|
|
503
|
+
// carry their own settings without profile indirection.
|
|
504
|
+
export function resolveAgent(value, path, reservedKeys = []) {
|
|
505
|
+
if (typeof value === 'string') {
|
|
506
|
+
if (value.trim().length === 0) {
|
|
507
|
+
throw new Error(`${path} must name an adapter`);
|
|
508
|
+
}
|
|
509
|
+
return { adapter: value };
|
|
510
|
+
}
|
|
511
|
+
if (isObject(value)) {
|
|
512
|
+
for (const key of reservedKeys) {
|
|
513
|
+
if (hasOwn(value, key)) {
|
|
514
|
+
throw new Error(`${path}.${key} is launcher-owned`);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return cloneJson(value, path);
|
|
518
|
+
}
|
|
519
|
+
throw new Error(`${path} must be an adapter shorthand or an agent block`);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// PBCLI-46: normalize into a detached, deeply frozen JSON plan. The plan has
|
|
523
|
+
// only execution data and presentation data; imported registry functions are
|
|
524
|
+
// consulted for validation and then discarded.
|
|
525
|
+
export async function normalizeLaunchPlan(
|
|
526
|
+
top,
|
|
527
|
+
{ loadModule, configPath, prepareRegistryModule, selectedMembers } = {},
|
|
528
|
+
) {
|
|
529
|
+
const importModule = loadModule ?? ((specifier) => import(specifier));
|
|
530
|
+
top = projectSelectedMembers(top, selectedMembers);
|
|
531
|
+
top = cloneJson(top, 'config');
|
|
532
|
+
assertNoRetiredProfiles(top, configPath);
|
|
533
|
+
if (hasOwn(top, 'run')) {
|
|
534
|
+
throw new Error(
|
|
535
|
+
'top-level "run" was removed: configure the shared Captain under ' +
|
|
536
|
+
'captain, top-level players, and playbooks.<id>.roles instead',
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
const unknownTopLevel = Object.keys(top).filter(
|
|
540
|
+
(key) => !PLAYBOOK_TOP_LEVEL_KEYS.has(key),
|
|
541
|
+
);
|
|
542
|
+
if (unknownTopLevel.length > 0) {
|
|
543
|
+
throw new Error(
|
|
544
|
+
`config has unknown top-level ${formatKeyList(unknownTopLevel)}`,
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
if (top.layout !== undefined && !isObject(top.layout)) {
|
|
548
|
+
throw new Error('layout must be a map');
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const playersCfg = requireObject(top.players, 'players');
|
|
552
|
+
const allPlayerIds = Object.keys(playersCfg);
|
|
553
|
+
const configuredAgents = new Map();
|
|
554
|
+
for (const playerId of allPlayerIds) {
|
|
555
|
+
assertPlayerId(playerId, `players.${playerId}`);
|
|
556
|
+
const agent = resolveAgent(playersCfg[playerId], `players.${playerId}`, [
|
|
557
|
+
'id',
|
|
558
|
+
]);
|
|
559
|
+
if (
|
|
560
|
+
typeof agent.adapter !== 'string' ||
|
|
561
|
+
agent.adapter.trim().length === 0
|
|
562
|
+
) {
|
|
563
|
+
throw new Error(`players.${playerId} must resolve an adapter`);
|
|
564
|
+
}
|
|
565
|
+
configuredAgents.set(playerId, agent);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const playbooksCfg = requireObject(top.playbooks, 'playbooks');
|
|
569
|
+
const ids = Object.keys(playbooksCfg);
|
|
570
|
+
if (ids.length === 0) {
|
|
571
|
+
throw new Error('playbooks must enable at least one playbook');
|
|
572
|
+
}
|
|
573
|
+
if (ids.some((id) => id.trim().length === 0 || id !== id.trim())) {
|
|
574
|
+
throw new Error('playbooks keys must be canonical trimmed nonblank ids');
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
let captain = resolveAgent(top.captain, 'captain', ['from', 'options']);
|
|
578
|
+
if (
|
|
579
|
+
typeof captain.adapter !== 'string' ||
|
|
580
|
+
captain.adapter.trim().length === 0
|
|
581
|
+
) {
|
|
582
|
+
throw new Error('captain must resolve an adapter');
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Validate and detach every retained config-owned value before provisioning
|
|
586
|
+
// or importing any registry. That keeps malformed config side-effect free.
|
|
587
|
+
const configuredPlaybooks = [];
|
|
588
|
+
const tuningChecks = [];
|
|
589
|
+
let tuningCheckIndex = 0;
|
|
590
|
+
for (const id of ids) {
|
|
591
|
+
if (id === RESERVED_CAPTAIN_PLAYBOOK_ID) {
|
|
592
|
+
throw new Error(
|
|
593
|
+
`playbooks.${id} collides with the reserved internal Captain id`,
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
const block = requireObject(playbooksCfg[id], `playbooks.${id}`);
|
|
597
|
+
if (hasOwn(block, 'players')) {
|
|
598
|
+
throw legacyPlayersError(`playbooks.${id}.players`, configPath);
|
|
599
|
+
}
|
|
600
|
+
const from = block.from;
|
|
601
|
+
if (
|
|
602
|
+
typeof from !== 'string' ||
|
|
603
|
+
from.trim().length === 0 ||
|
|
604
|
+
from !== from.trim()
|
|
605
|
+
) {
|
|
606
|
+
throw new Error(
|
|
607
|
+
`playbooks.${id}.from must be a canonical trimmed module specifier`,
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
if (
|
|
611
|
+
block.command !== undefined &&
|
|
612
|
+
(typeof block.command !== 'string' ||
|
|
613
|
+
block.command.trim().length === 0 ||
|
|
614
|
+
block.command !== block.command.trim())
|
|
615
|
+
) {
|
|
616
|
+
throw new Error(
|
|
617
|
+
`playbooks.${id}.command must be a canonical trimmed nonblank string`,
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
if (block.command === RESERVED_CAPTAIN_PLAYBOOK_ID) {
|
|
621
|
+
throw new Error(
|
|
622
|
+
`playbooks.${id}.command collides with the reserved internal Captain command`,
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
const rolesMap = requireObject(block.roles, `playbooks.${id}.roles`);
|
|
626
|
+
const roles = Object.keys(rolesMap);
|
|
627
|
+
if (roles.some((role) => role.trim().length === 0)) {
|
|
628
|
+
throw new Error(`playbooks.${id}.roles keys must be nonblank role ids`);
|
|
629
|
+
}
|
|
630
|
+
if (roles.includes(RESERVED_CAPTAIN_ROLE_ID)) {
|
|
631
|
+
throw new Error(
|
|
632
|
+
`playbooks.${id}.roles.${RESERVED_CAPTAIN_ROLE_ID} binds local ` +
|
|
633
|
+
`role "${RESERVED_CAPTAIN_ROLE_ID}", which is reserved for the ` +
|
|
634
|
+
'tmux-play Captain',
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
const bindings = Object.create(null);
|
|
638
|
+
for (const role of roles) {
|
|
639
|
+
assertRoleId(role, `playbooks.${id}.roles.${role}`);
|
|
640
|
+
const binding = resolveRoleBinding(
|
|
641
|
+
rolesMap[role],
|
|
642
|
+
`playbooks.${id}.roles.${role}`,
|
|
643
|
+
);
|
|
644
|
+
const agent = configuredAgents.get(binding.playerId);
|
|
645
|
+
if (agent === undefined) {
|
|
646
|
+
throw new Error(
|
|
647
|
+
`playbooks.${id}.roles.${role} names unknown player ` +
|
|
648
|
+
JSON.stringify(binding.playerId),
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
bindings[role] = binding;
|
|
652
|
+
if (binding.model !== undefined || binding.effort !== undefined) {
|
|
653
|
+
let checkId;
|
|
654
|
+
do {
|
|
655
|
+
checkId = `binding-check-${tuningCheckIndex}`;
|
|
656
|
+
tuningCheckIndex += 1;
|
|
657
|
+
} while (configuredAgents.has(checkId));
|
|
658
|
+
tuningChecks.push({
|
|
659
|
+
id: checkId,
|
|
660
|
+
agent: applyTuningOverrides(agent, binding),
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
const optionSlice = Object.fromEntries(
|
|
665
|
+
Object.entries(block).filter(
|
|
666
|
+
([key]) => !PLAYBOOK_LAUNCHER_KEYS.includes(key),
|
|
667
|
+
),
|
|
668
|
+
);
|
|
669
|
+
const configuredFrom = canonicalizeRegistrySpecifier(from, configPath);
|
|
670
|
+
configuredPlaybooks.push({
|
|
671
|
+
id,
|
|
672
|
+
from,
|
|
673
|
+
configuredFrom,
|
|
674
|
+
commandOverride: block.command,
|
|
675
|
+
roles,
|
|
676
|
+
bindings,
|
|
677
|
+
optionSlice,
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Both front ends consume the exact installed cligent schema. Normalize a
|
|
682
|
+
// detached provisional projection before any registry preparation/import,
|
|
683
|
+
// then feed its agent and presentation fields back into the authoritative
|
|
684
|
+
// plan. The interactive child will only revalidate these same values.
|
|
685
|
+
const firstRoleful = configuredPlaybooks.find(
|
|
686
|
+
(configured) => configured.roles.length > 0,
|
|
687
|
+
);
|
|
688
|
+
const provisionalVisible =
|
|
689
|
+
firstRoleful === undefined
|
|
690
|
+
? []
|
|
691
|
+
: distinct(
|
|
692
|
+
firstRoleful.roles.map(
|
|
693
|
+
(role) => firstRoleful.bindings[role].playerId,
|
|
694
|
+
),
|
|
695
|
+
);
|
|
696
|
+
// An all-roleless catalog still validates every authored player through
|
|
697
|
+
// cligent, but the validation-only projection must respect tmux-play's
|
|
698
|
+
// invariant that a nonempty roster has at least one visible pane.
|
|
699
|
+
const validationVisible =
|
|
700
|
+
provisionalVisible.length === 0
|
|
701
|
+
? allPlayerIds.slice(0, 1)
|
|
702
|
+
: provisionalVisible;
|
|
703
|
+
const provisional = {
|
|
704
|
+
captain: {
|
|
705
|
+
...captain,
|
|
706
|
+
from: PLAYBOOK_CAPTAIN_MODULE,
|
|
707
|
+
options: {},
|
|
708
|
+
},
|
|
709
|
+
players: [
|
|
710
|
+
...allPlayerIds.map((id) => ({
|
|
711
|
+
id,
|
|
712
|
+
...configuredAgents.get(id),
|
|
713
|
+
})),
|
|
714
|
+
...tuningChecks.map(({ id, agent }) => ({ id, ...agent })),
|
|
715
|
+
],
|
|
716
|
+
layout: {
|
|
717
|
+
...(isObject(top.layout) ? top.layout : {}),
|
|
718
|
+
initialVisible: validationVisible,
|
|
719
|
+
},
|
|
720
|
+
...(top.notifications === undefined
|
|
721
|
+
? {}
|
|
722
|
+
: { notifications: top.notifications }),
|
|
723
|
+
...(top.theme === undefined ? {} : { theme: top.theme }),
|
|
724
|
+
};
|
|
725
|
+
const normalizedHost = await normalizeHostConfig(provisional);
|
|
726
|
+
// The authoritative all-roleless projection is Boss-only. Normalize that
|
|
727
|
+
// exact empty host shape separately so derived presentation aliases (most
|
|
728
|
+
// notably active `columnWeights`) match zero visible player panes rather
|
|
729
|
+
// than the temporary validation pane above.
|
|
730
|
+
const normalizedPresentationHost =
|
|
731
|
+
provisionalVisible.length === 0 && allPlayerIds.length > 0
|
|
732
|
+
? await normalizeHostConfig({
|
|
733
|
+
...provisional,
|
|
734
|
+
players: [],
|
|
735
|
+
layout: { ...provisional.layout, initialVisible: [] },
|
|
736
|
+
})
|
|
737
|
+
: normalizedHost;
|
|
738
|
+
const { from: _captainFrom, options: _captainOptions, ...normalizedCaptain } =
|
|
739
|
+
normalizedHost.captain;
|
|
740
|
+
captain = sessionAgentFromHostAgent(normalizedCaptain, 'captain');
|
|
741
|
+
const hostAgents = new Map(
|
|
742
|
+
normalizedHost.players.map(({ id, ...agent }) => [id, agent]),
|
|
743
|
+
);
|
|
744
|
+
const normalizedPlayerAgents = new Map(
|
|
745
|
+
allPlayerIds.map((id) => [
|
|
746
|
+
id,
|
|
747
|
+
sessionAgentFromHostAgent(hostAgents.get(id), `players.${id}`),
|
|
748
|
+
]),
|
|
749
|
+
);
|
|
750
|
+
|
|
751
|
+
// Preparation is a transaction-like pre-import phase across the complete
|
|
752
|
+
// configured catalog. A provisioning failure therefore cannot leave some
|
|
753
|
+
// registry modules evaluated and others untouched.
|
|
754
|
+
const preparedPlaybooks = [];
|
|
755
|
+
for (const configured of configuredPlaybooks) {
|
|
756
|
+
const { id, from, configuredFrom } = configured;
|
|
757
|
+
let preparedFrom = configuredFrom;
|
|
758
|
+
if (prepareRegistryModule !== undefined) {
|
|
759
|
+
try {
|
|
760
|
+
const prepared = await prepareRegistryModule({
|
|
761
|
+
id,
|
|
762
|
+
from: configuredFrom,
|
|
763
|
+
authoredFrom: from,
|
|
764
|
+
configPath,
|
|
765
|
+
});
|
|
766
|
+
if (prepared !== undefined) preparedFrom = prepared;
|
|
767
|
+
} catch (cause) {
|
|
768
|
+
throw new Error(
|
|
769
|
+
`playbooks.${id}.from "${from}" failed to prepare: ${errorMessage(cause)}`,
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
if (
|
|
773
|
+
typeof preparedFrom !== 'string' ||
|
|
774
|
+
preparedFrom.trim().length === 0
|
|
775
|
+
) {
|
|
776
|
+
throw new Error(
|
|
777
|
+
`playbooks.${id}.from "${from}" preparation must return a module specifier`,
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
preparedFrom = canonicalizePreparedRegistrySpecifier(
|
|
782
|
+
preparedFrom,
|
|
783
|
+
`playbooks.${id}.from`,
|
|
784
|
+
);
|
|
785
|
+
preparedPlaybooks.push({ ...configured, preparedFrom });
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
const catalogEntries = [];
|
|
789
|
+
const seenCommands = new Map();
|
|
790
|
+
const seenIds = new Set();
|
|
791
|
+
|
|
792
|
+
for (const {
|
|
793
|
+
id,
|
|
794
|
+
from,
|
|
795
|
+
preparedFrom,
|
|
796
|
+
commandOverride,
|
|
797
|
+
roles,
|
|
798
|
+
bindings,
|
|
799
|
+
optionSlice,
|
|
800
|
+
} of preparedPlaybooks) {
|
|
801
|
+
let mod;
|
|
802
|
+
try {
|
|
803
|
+
mod = await importModule(preparedFrom);
|
|
804
|
+
} catch (cause) {
|
|
805
|
+
throw new Error(
|
|
806
|
+
`playbooks.${id}.from "${from}" failed to import: ${errorMessage(cause)}`,
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
const entry = mod?.default;
|
|
810
|
+
const registryProblem = invalidRegistryEntryReason(entry);
|
|
811
|
+
if (registryProblem !== undefined) {
|
|
812
|
+
throw new Error(
|
|
813
|
+
`playbooks.${id}.from "${from}" exposes no valid registry entry: ` +
|
|
814
|
+
registryProblem,
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
if (entry.id !== id) {
|
|
818
|
+
throw new Error(
|
|
819
|
+
`playbooks.${id} key must equal the module manifest id "${entry.id}"`,
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
if (seenIds.has(entry.id)) {
|
|
823
|
+
throw new Error(`duplicate playbook id "${entry.id}"`);
|
|
824
|
+
}
|
|
825
|
+
seenIds.add(entry.id);
|
|
826
|
+
|
|
827
|
+
const command = commandOverride ?? entry.command;
|
|
828
|
+
if (command === RESERVED_CAPTAIN_PLAYBOOK_ID) {
|
|
829
|
+
throw new Error(
|
|
830
|
+
`playbooks.${id}.command collides with the reserved internal Captain command`,
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
if (seenCommands.has(command)) {
|
|
834
|
+
throw new Error(`duplicate effective command "${command}"`);
|
|
835
|
+
}
|
|
836
|
+
seenCommands.set(command, id);
|
|
837
|
+
|
|
838
|
+
assertExactRoleBindings(id, roles, entry.requiredRoleIds);
|
|
839
|
+
const resolvedRoles = Object.fromEntries(
|
|
840
|
+
entry.requiredRoleIds.map((role) => {
|
|
841
|
+
const binding = bindings[role];
|
|
842
|
+
const agent = normalizedPlayerAgents.get(binding.playerId);
|
|
843
|
+
return [
|
|
844
|
+
role,
|
|
845
|
+
{
|
|
846
|
+
playerId: binding.playerId,
|
|
847
|
+
model:
|
|
848
|
+
binding.model === undefined
|
|
849
|
+
? agent.model
|
|
850
|
+
: overrideTuningSelection(binding.model),
|
|
851
|
+
effort:
|
|
852
|
+
binding.effort === undefined
|
|
853
|
+
? agent.effort
|
|
854
|
+
: overrideTuningSelection(binding.effort),
|
|
855
|
+
},
|
|
856
|
+
];
|
|
857
|
+
}),
|
|
858
|
+
);
|
|
859
|
+
assertConcurrentPlayers(id, entry.concurrentRoleSets, resolvedRoles);
|
|
860
|
+
|
|
861
|
+
catalogEntries.push([
|
|
862
|
+
id,
|
|
863
|
+
{
|
|
864
|
+
id,
|
|
865
|
+
from: preparedFrom,
|
|
866
|
+
// Keep the registry-authored default even when launcher config
|
|
867
|
+
// overrides the effective command. Durable continuation validates
|
|
868
|
+
// this manifest identity but still executes the frozen command.
|
|
869
|
+
manifestCommand: entry.command,
|
|
870
|
+
command,
|
|
871
|
+
...(commandOverride === undefined ? {} : { commandOverride }),
|
|
872
|
+
intent: entry.intent,
|
|
873
|
+
artifactSchema: entry.artifactSchema,
|
|
874
|
+
requiredRoleIds: [...entry.requiredRoleIds],
|
|
875
|
+
concurrentRoleSets: entry.concurrentRoleSets.map((set) => [...set]),
|
|
876
|
+
roles: resolvedRoles,
|
|
877
|
+
options: optionSlice,
|
|
878
|
+
},
|
|
879
|
+
]);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// Registry role order is the canonical role order. Derive the one roster
|
|
883
|
+
// union only after every exact role map is closed against its manifest.
|
|
884
|
+
const referencedPlayerIds = distinct(
|
|
885
|
+
catalogEntries.flatMap(([, item]) =>
|
|
886
|
+
Object.values(item.roles).map((binding) => binding.playerId),
|
|
887
|
+
),
|
|
888
|
+
);
|
|
889
|
+
|
|
890
|
+
const firstVisibleCatalog = catalogEntries.find(
|
|
891
|
+
([, item]) => Object.keys(item.roles).length > 0,
|
|
892
|
+
);
|
|
893
|
+
const initialVisible =
|
|
894
|
+
firstVisibleCatalog === undefined
|
|
895
|
+
? []
|
|
896
|
+
: distinct(
|
|
897
|
+
Object.values(firstVisibleCatalog[1].roles).map(
|
|
898
|
+
(binding) => binding.playerId,
|
|
899
|
+
),
|
|
900
|
+
);
|
|
901
|
+
const presentation = {
|
|
902
|
+
layout: {
|
|
903
|
+
...normalizedPresentationHost.layout,
|
|
904
|
+
initialVisible,
|
|
905
|
+
},
|
|
906
|
+
notifications: normalizedPresentationHost.notifications,
|
|
907
|
+
...(normalizedPresentationHost.theme === undefined
|
|
908
|
+
? {}
|
|
909
|
+
: { theme: normalizedPresentationHost.theme }),
|
|
910
|
+
};
|
|
911
|
+
|
|
912
|
+
return deepFreeze(
|
|
913
|
+
cloneJson(
|
|
914
|
+
{
|
|
915
|
+
schemaVersion: 1,
|
|
916
|
+
captain,
|
|
917
|
+
players: referencedPlayerIds.map((id) => ({
|
|
918
|
+
id,
|
|
919
|
+
agent: normalizedPlayerAgents.get(id),
|
|
920
|
+
})),
|
|
921
|
+
catalog: Object.fromEntries(catalogEntries),
|
|
922
|
+
presentation,
|
|
923
|
+
},
|
|
924
|
+
'launch config',
|
|
925
|
+
),
|
|
926
|
+
);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// Run cligent's public explicit-config loader over an isolated projection so
|
|
930
|
+
// generic launch planning tracks the installed host schema without importing
|
|
931
|
+
// or duplicating cligent's private validators.
|
|
932
|
+
export async function normalizeHostConfig(config) {
|
|
933
|
+
const dir = mkdtempSync(join(tmpdir(), 'playbook-host-config-'));
|
|
934
|
+
const path = join(dir, 'tmux-play.config.yaml');
|
|
935
|
+
try {
|
|
936
|
+
writeFileSync(path, stringifyYaml(config));
|
|
937
|
+
return (await loadTmuxPlayConfig({ configPath: path })).config;
|
|
938
|
+
} finally {
|
|
939
|
+
rmSync(dir, { recursive: true, force: true });
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// PBCLI-8/9/10/46: tmux is one projection of the host-neutral plan. The
|
|
944
|
+
// projection is detached so cligent normalization cannot mutate the plan.
|
|
945
|
+
export function projectTmuxConfig(plan) {
|
|
946
|
+
const playbooks = Object.fromEntries(
|
|
947
|
+
Object.entries(plan.catalog).map(([id, item]) => [
|
|
948
|
+
id,
|
|
949
|
+
{
|
|
950
|
+
from: item.from,
|
|
951
|
+
command: item.command,
|
|
952
|
+
roles: cloneJson(item.roles, `catalog.${id}.roles`),
|
|
953
|
+
options: cloneJson(item.options, `catalog.${id}.options`),
|
|
954
|
+
},
|
|
955
|
+
]),
|
|
956
|
+
);
|
|
957
|
+
const captain = {
|
|
958
|
+
...projectHostAgent(plan.captain, 'captain'),
|
|
959
|
+
from: PLAYBOOK_CAPTAIN_MODULE,
|
|
960
|
+
};
|
|
961
|
+
captain.options = {
|
|
962
|
+
playbooks,
|
|
963
|
+
sessionAgents: {
|
|
964
|
+
captain: cloneJson(plan.captain, 'captain'),
|
|
965
|
+
players: Object.fromEntries(
|
|
966
|
+
plan.players.map(({ id, agent }) => [
|
|
967
|
+
id,
|
|
968
|
+
cloneJson(agent, `players.${id}.agent`),
|
|
969
|
+
]),
|
|
970
|
+
),
|
|
971
|
+
},
|
|
972
|
+
...(typeof captain.adapter === 'string' && captain.adapter.length > 0
|
|
973
|
+
? { captainAdapter: captain.adapter }
|
|
974
|
+
: {}),
|
|
975
|
+
};
|
|
976
|
+
const config = {
|
|
977
|
+
captain,
|
|
978
|
+
players: plan.players.map(({ id, agent }) => ({
|
|
979
|
+
...projectHostAgent(agent, `players.${id}.agent`),
|
|
980
|
+
id,
|
|
981
|
+
})),
|
|
982
|
+
layout: projectHostLayout(plan.presentation.layout),
|
|
983
|
+
};
|
|
984
|
+
if (hasOwn(plan.presentation, 'notifications')) {
|
|
985
|
+
config.notifications = cloneJson(
|
|
986
|
+
plan.presentation.notifications,
|
|
987
|
+
'presentation.notifications',
|
|
988
|
+
);
|
|
989
|
+
}
|
|
990
|
+
if (hasOwn(plan.presentation, 'theme')) {
|
|
991
|
+
config.theme = cloneJson(plan.presentation.theme, 'presentation.theme');
|
|
992
|
+
}
|
|
993
|
+
return config;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
function projectHostLayout(layout) {
|
|
997
|
+
const projected = cloneJson(layout, 'presentation.layout');
|
|
998
|
+
// cligent's normalized runtime shape carries `columnWeights` as the
|
|
999
|
+
// derived active-shape value alongside both canonical shape fields. The
|
|
1000
|
+
// authored schema deliberately rejects that alias/canonical combination,
|
|
1001
|
+
// so the interactive child projection must serialize canonical authority
|
|
1002
|
+
// only; its loader deterministically derives the same alias again.
|
|
1003
|
+
if (
|
|
1004
|
+
Array.isArray(projected.singlePlayerColumnWeights) &&
|
|
1005
|
+
Array.isArray(projected.multiPlayerColumnWeights)
|
|
1006
|
+
) {
|
|
1007
|
+
delete projected.columnWeights;
|
|
1008
|
+
}
|
|
1009
|
+
return projected;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// Compatibility surface used by existing integrations and tests. New hosts
|
|
1013
|
+
// consume normalizeLaunchPlan/loadLaunchPlan and choose their projection.
|
|
1014
|
+
export async function composeGenericConfig(top, loadModule, configPath) {
|
|
1015
|
+
const plan = await normalizeLaunchPlan(top, { loadModule, configPath });
|
|
1016
|
+
return {
|
|
1017
|
+
config: projectTmuxConfig(plan),
|
|
1018
|
+
playbooks: Object.values(plan.catalog).map(({ id, command, intent }) => ({
|
|
1019
|
+
id,
|
|
1020
|
+
command,
|
|
1021
|
+
intent,
|
|
1022
|
+
})),
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
export function adaptersFromLaunchPlan(plan) {
|
|
1027
|
+
const adapters = new Set();
|
|
1028
|
+
if (plan?.captain?.adapter) adapters.add(plan.captain.adapter);
|
|
1029
|
+
for (const player of plan?.players ?? []) {
|
|
1030
|
+
if (player?.agent?.adapter) adapters.add(player.agent.adapter);
|
|
1031
|
+
}
|
|
1032
|
+
return [...adapters];
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
export function adaptersFromComposedConfig(config) {
|
|
1036
|
+
const adapters = new Set();
|
|
1037
|
+
if (config?.captain?.adapter) adapters.add(config.captain.adapter);
|
|
1038
|
+
for (const player of config?.players ?? []) {
|
|
1039
|
+
if (player?.adapter) adapters.add(player.adapter);
|
|
1040
|
+
}
|
|
1041
|
+
return [...adapters];
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
export function checkReadiness(adapters, env = process.env, home = homedir()) {
|
|
1045
|
+
const failingAdapters = [];
|
|
1046
|
+
const unknownAdapters = [];
|
|
1047
|
+
for (const adapter of adapters) {
|
|
1048
|
+
if (adapter === 'claude') {
|
|
1049
|
+
if (!env.ANTHROPIC_API_KEY && !existsSync(join(home, '.claude'))) {
|
|
1050
|
+
failingAdapters.push(adapter);
|
|
1051
|
+
}
|
|
1052
|
+
continue;
|
|
1053
|
+
}
|
|
1054
|
+
if (adapter === 'codex') {
|
|
1055
|
+
if (!env.OPENAI_API_KEY && !existsSync(join(home, '.codex'))) {
|
|
1056
|
+
failingAdapters.push(adapter);
|
|
1057
|
+
}
|
|
1058
|
+
continue;
|
|
1059
|
+
}
|
|
1060
|
+
unknownAdapters.push(adapter);
|
|
1061
|
+
}
|
|
1062
|
+
return { failingAdapters, unknownAdapters };
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
export function deriveLaunchReadiness(
|
|
1066
|
+
plan,
|
|
1067
|
+
env = process.env,
|
|
1068
|
+
home = homedir(),
|
|
1069
|
+
) {
|
|
1070
|
+
const adapters = adaptersFromLaunchPlan(plan);
|
|
1071
|
+
return { adapters, ...checkReadiness(adapters, env, home) };
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function seedUserConfigIfMissing(userConfigPath, templatePath, onNotice) {
|
|
1075
|
+
if (existsSync(userConfigPath)) return;
|
|
1076
|
+
mkdirSync(dirname(userConfigPath), { recursive: true });
|
|
1077
|
+
copyFileSync(templatePath, userConfigPath, constants.COPYFILE_EXCL);
|
|
1078
|
+
onNotice(`playbook: created config at ${userConfigPath}\n`);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// DR-021 §3: migrate once, keeping the original before any rewrite.
|
|
1082
|
+
function migrateUserConfigIfRetired(userConfigPath, onNotice) {
|
|
1083
|
+
let text;
|
|
1084
|
+
try {
|
|
1085
|
+
text = readFileSync(userConfigPath, 'utf8');
|
|
1086
|
+
} catch {
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
let migrated;
|
|
1090
|
+
try {
|
|
1091
|
+
migrated = migrateRetiredProfiles(text);
|
|
1092
|
+
} catch (error) {
|
|
1093
|
+
if (error?.code === 'PLAYBOOK_LEGACY_PLAYERS') {
|
|
1094
|
+
throw legacyPlayersError(error.legacyPath, userConfigPath);
|
|
1095
|
+
}
|
|
1096
|
+
throw new Error(
|
|
1097
|
+
`cannot migrate the retired profiles config at ${userConfigPath}: ` +
|
|
1098
|
+
`${errorMessage(error)} — edit it by hand: each agent takes its own ` +
|
|
1099
|
+
'adapter, model, effort, and permissions',
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
1102
|
+
if (migrated === undefined) return;
|
|
1103
|
+
const backupPath = freeBackupPath(userConfigPath);
|
|
1104
|
+
writeFileSync(backupPath, text, { mode: 0o600 });
|
|
1105
|
+
writeFileSync(userConfigPath, migrated);
|
|
1106
|
+
onNotice(
|
|
1107
|
+
`playbook: migrated ${userConfigPath} to inline agent settings ` +
|
|
1108
|
+
`(the top-level "profiles" map was removed in 3.0.0); ` +
|
|
1109
|
+
`the original is at ${backupPath}\n`,
|
|
1110
|
+
);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function freeBackupPath(userConfigPath) {
|
|
1114
|
+
const first = `${userConfigPath}.bak`;
|
|
1115
|
+
if (!existsSync(first)) return first;
|
|
1116
|
+
for (let n = 2; ; n += 1) {
|
|
1117
|
+
const candidate = `${userConfigPath}.bak.${n}`;
|
|
1118
|
+
if (!existsSync(candidate)) return candidate;
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// DR-021 §3: rewrite through YAML's Document API so comments survive.
|
|
1123
|
+
export function migrateRetiredProfiles(text) {
|
|
1124
|
+
const doc = parseYamlDocument(text);
|
|
1125
|
+
const contents = doc.contents;
|
|
1126
|
+
if (!contents || !Array.isArray(contents.items)) return undefined;
|
|
1127
|
+
const playbooks = doc.get('playbooks');
|
|
1128
|
+
if (playbooks && Array.isArray(playbooks.items)) {
|
|
1129
|
+
for (const entry of playbooks.items) {
|
|
1130
|
+
const id = String(entry.key);
|
|
1131
|
+
if (doc.getIn(['playbooks', id, 'players']) !== undefined) {
|
|
1132
|
+
throw legacyPlayersError(`playbooks.${id}.players`);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
const profiles = doc.get('profiles');
|
|
1137
|
+
const agentPaths = [['captain']];
|
|
1138
|
+
const players = doc.get('players');
|
|
1139
|
+
if (players && Array.isArray(players.items)) {
|
|
1140
|
+
for (const player of players.items) {
|
|
1141
|
+
agentPaths.push(['players', String(player.key)]);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
const profileSettings = (name) =>
|
|
1146
|
+
profiles && typeof profiles.get === 'function'
|
|
1147
|
+
? profiles.get(name)
|
|
1148
|
+
: undefined;
|
|
1149
|
+
|
|
1150
|
+
let changed = false;
|
|
1151
|
+
for (const path of agentPaths) {
|
|
1152
|
+
const node = doc.getIn(path, true);
|
|
1153
|
+
if (node && typeof node.value === 'string' && !Array.isArray(node.items)) {
|
|
1154
|
+
const settings = profileSettings(node.value);
|
|
1155
|
+
if (settings === undefined) continue;
|
|
1156
|
+
const inlined = settings.clone();
|
|
1157
|
+
carryScalarComment(node, inlined);
|
|
1158
|
+
doc.setIn(path, inlined);
|
|
1159
|
+
changed = true;
|
|
1160
|
+
} else if (node && Array.isArray(node.items)) {
|
|
1161
|
+
const named = node.get?.('profile');
|
|
1162
|
+
if (named === undefined) continue;
|
|
1163
|
+
const settings = profileSettings(named);
|
|
1164
|
+
if (settings === undefined) {
|
|
1165
|
+
throw new Error(
|
|
1166
|
+
`${path.join('.')}.profile names "${String(named)}", which no ` +
|
|
1167
|
+
'profiles entry defines',
|
|
1168
|
+
);
|
|
1169
|
+
}
|
|
1170
|
+
node.delete('profile');
|
|
1171
|
+
for (const item of settings.items) {
|
|
1172
|
+
if (node.has(String(item.key))) continue;
|
|
1173
|
+
node.add(item.clone());
|
|
1174
|
+
}
|
|
1175
|
+
changed = true;
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
if (profiles !== undefined) {
|
|
1180
|
+
const index = contents.items.findIndex(
|
|
1181
|
+
(item) => String(item.key) === 'profiles',
|
|
1182
|
+
);
|
|
1183
|
+
const lead =
|
|
1184
|
+
index === -1 ? undefined : contents.items[index]?.key?.commentBefore;
|
|
1185
|
+
doc.delete('profiles');
|
|
1186
|
+
const header = keptHeaderComment(lead);
|
|
1187
|
+
const next = contents.items[0];
|
|
1188
|
+
if (header !== undefined && next?.key) {
|
|
1189
|
+
next.key.commentBefore =
|
|
1190
|
+
next.key.commentBefore === undefined
|
|
1191
|
+
? header
|
|
1192
|
+
: `${header}\n\n${next.key.commentBefore}`;
|
|
1193
|
+
}
|
|
1194
|
+
changed = true;
|
|
1195
|
+
}
|
|
1196
|
+
if (!changed) return undefined;
|
|
1197
|
+
doc.commentBefore = MIGRATION_NOTE;
|
|
1198
|
+
return doc.toString();
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
const MIGRATION_NOTE =
|
|
1202
|
+
' Migrated by playbook 3.0.0: the top-level `profiles` map was removed and\n' +
|
|
1203
|
+
' each agent now carries its settings inline. The pre-migration file is\n' +
|
|
1204
|
+
' kept beside this one as a .bak. Comments below may still describe the\n' +
|
|
1205
|
+
' retired profiles model.';
|
|
1206
|
+
|
|
1207
|
+
function carryScalarComment(node, inlined) {
|
|
1208
|
+
const parts = [node.commentBefore, node.comment].filter(
|
|
1209
|
+
(part) => typeof part === 'string' && part.trim() !== '',
|
|
1210
|
+
);
|
|
1211
|
+
if (parts.length === 0) return;
|
|
1212
|
+
const first = inlined.items?.[0]?.key;
|
|
1213
|
+
if (!first) return;
|
|
1214
|
+
inlined.flow = false;
|
|
1215
|
+
const carried = parts.join('\n');
|
|
1216
|
+
first.commentBefore =
|
|
1217
|
+
first.commentBefore === undefined
|
|
1218
|
+
? carried
|
|
1219
|
+
: `${carried}\n${first.commentBefore}`;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
function keptHeaderComment(comment) {
|
|
1223
|
+
if (typeof comment !== 'string' || comment.trim() === '') return undefined;
|
|
1224
|
+
const paragraphs = comment.split('\n\n');
|
|
1225
|
+
const kept = paragraphs.slice(0, -1).join('\n\n');
|
|
1226
|
+
return kept.trim() === '' ? undefined : kept;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
function assertNoRetiredProfiles(top, configPath) {
|
|
1230
|
+
const where = configPath ? ` in ${configPath}` : '';
|
|
1231
|
+
if (top.profiles !== undefined) {
|
|
1232
|
+
throw new Error(
|
|
1233
|
+
`top-level "profiles" was removed${where}: write each agent's settings ` +
|
|
1234
|
+
'inline under captain and each top-level players.<player-id> ' +
|
|
1235
|
+
'(adapter, model, effort, permissions)',
|
|
1236
|
+
);
|
|
1237
|
+
}
|
|
1238
|
+
const legacyPath = findLegacyPlayersPath(top);
|
|
1239
|
+
if (legacyPath !== undefined) throw legacyPlayersError(legacyPath, configPath);
|
|
1240
|
+
const blocks = [['captain', top.captain]];
|
|
1241
|
+
const playersCfg = isObject(top.players) ? top.players : {};
|
|
1242
|
+
for (const [playerId, agent] of Object.entries(playersCfg)) {
|
|
1243
|
+
blocks.push([`players.${playerId}`, agent]);
|
|
1244
|
+
}
|
|
1245
|
+
for (const [path, block] of blocks) {
|
|
1246
|
+
if (isObject(block) && block.profile !== undefined) {
|
|
1247
|
+
throw new Error(
|
|
1248
|
+
`${path}.profile was removed${where}: write the agent's settings ` +
|
|
1249
|
+
'inline in that block (adapter, model, effort, permissions)',
|
|
1250
|
+
);
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
function invalidRegistryEntryReason(value) {
|
|
1256
|
+
if (!isObject(value)) return 'the default export must be an object';
|
|
1257
|
+
if (
|
|
1258
|
+
typeof value.id !== 'string' ||
|
|
1259
|
+
value.id.trim().length === 0 ||
|
|
1260
|
+
value.id !== value.id.trim()
|
|
1261
|
+
) {
|
|
1262
|
+
return 'id must be a canonical trimmed nonblank string';
|
|
1263
|
+
}
|
|
1264
|
+
if (
|
|
1265
|
+
typeof value.command !== 'string' ||
|
|
1266
|
+
value.command.trim().length === 0 ||
|
|
1267
|
+
value.command !== value.command.trim()
|
|
1268
|
+
) {
|
|
1269
|
+
return 'command must be a canonical trimmed nonblank string';
|
|
1270
|
+
}
|
|
1271
|
+
if (typeof value.intent !== 'string') return 'intent must be a string';
|
|
1272
|
+
if (value.artifactSchema !== 2) {
|
|
1273
|
+
return 'artifactSchema must be exactly 2';
|
|
1274
|
+
}
|
|
1275
|
+
const roleProblem = invalidManifestRoles(value.requiredRoleIds);
|
|
1276
|
+
if (roleProblem !== undefined) return `requiredRoleIds ${roleProblem}`;
|
|
1277
|
+
const concurrentProblem = invalidConcurrentRoleSets(
|
|
1278
|
+
value.concurrentRoleSets,
|
|
1279
|
+
value.requiredRoleIds,
|
|
1280
|
+
);
|
|
1281
|
+
if (concurrentProblem !== undefined) {
|
|
1282
|
+
return `concurrentRoleSets ${concurrentProblem}`;
|
|
1283
|
+
}
|
|
1284
|
+
if (typeof value.validateOptions !== 'function') {
|
|
1285
|
+
return 'validateOptions must be a function';
|
|
1286
|
+
}
|
|
1287
|
+
if (typeof value.createRuntime !== 'function') {
|
|
1288
|
+
return 'createRuntime must be a function';
|
|
1289
|
+
}
|
|
1290
|
+
return undefined;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
function validateStoredStructuralProjection(value) {
|
|
1294
|
+
const stored = cloneJson(value, 'stored structural projection');
|
|
1295
|
+
if (!isPlainObject(stored) || stored.schemaVersion !== 1) {
|
|
1296
|
+
throw new Error('stored structural projection schema 1 is required');
|
|
1297
|
+
}
|
|
1298
|
+
const captain = requireObject(stored.captain, 'stored structural captain');
|
|
1299
|
+
if (typeof captain.adapter !== 'string' || captain.adapter.length === 0) {
|
|
1300
|
+
throw new Error('stored structural captain must name an adapter');
|
|
1301
|
+
}
|
|
1302
|
+
if (!Array.isArray(stored.players) || !isPlainObject(stored.catalog)) {
|
|
1303
|
+
throw new Error(
|
|
1304
|
+
'stored structural projection must contain players and catalog',
|
|
1305
|
+
);
|
|
1306
|
+
}
|
|
1307
|
+
const playerIds = stored.players.map((player, index) => {
|
|
1308
|
+
const record = requireObject(player, `stored structural players.${index}`);
|
|
1309
|
+
assertPlayerId(record.id, `stored structural players.${index}.id`);
|
|
1310
|
+
if (typeof record.adapter !== 'string' || record.adapter.length === 0) {
|
|
1311
|
+
throw new Error(
|
|
1312
|
+
`stored structural players.${index} must name an adapter`,
|
|
1313
|
+
);
|
|
1314
|
+
}
|
|
1315
|
+
return record.id;
|
|
1316
|
+
});
|
|
1317
|
+
if (new Set(playerIds).size !== playerIds.length) {
|
|
1318
|
+
throw new Error('stored structural player ids must be unique');
|
|
1319
|
+
}
|
|
1320
|
+
for (const [id, itemValue] of Object.entries(stored.catalog)) {
|
|
1321
|
+
const item = requireObject(itemValue, `stored structural catalog.${id}`);
|
|
1322
|
+
if (item.id !== id || id === RESERVED_CAPTAIN_PLAYBOOK_ID) {
|
|
1323
|
+
throw new Error(`stored structural catalog.${id}.id is invalid`);
|
|
1324
|
+
}
|
|
1325
|
+
for (const field of [
|
|
1326
|
+
'from',
|
|
1327
|
+
'manifestCommand',
|
|
1328
|
+
'command',
|
|
1329
|
+
'intent',
|
|
1330
|
+
]) {
|
|
1331
|
+
if (typeof item[field] !== 'string') {
|
|
1332
|
+
throw new Error(`stored structural catalog.${id}.${field} is invalid`);
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
if (
|
|
1336
|
+
!Array.isArray(item.requiredRoleIds) ||
|
|
1337
|
+
!Array.isArray(item.concurrentRoleSets) ||
|
|
1338
|
+
!isPlainObject(item.roles) ||
|
|
1339
|
+
!isPlainObject(item.options)
|
|
1340
|
+
) {
|
|
1341
|
+
throw new Error(`stored structural catalog.${id} is malformed`);
|
|
1342
|
+
}
|
|
1343
|
+
if (
|
|
1344
|
+
JSON.stringify(Object.keys(item.roles)) !==
|
|
1345
|
+
JSON.stringify(item.requiredRoleIds)
|
|
1346
|
+
) {
|
|
1347
|
+
throw new Error(
|
|
1348
|
+
`stored structural catalog.${id}.roles must follow requiredRoleIds`,
|
|
1349
|
+
);
|
|
1350
|
+
}
|
|
1351
|
+
for (const role of item.requiredRoleIds) {
|
|
1352
|
+
assertRoleId(role, `stored structural catalog.${id}.roles.${role}`);
|
|
1353
|
+
const binding = requireObject(
|
|
1354
|
+
item.roles[role],
|
|
1355
|
+
`stored structural catalog.${id}.roles.${role}`,
|
|
1356
|
+
);
|
|
1357
|
+
if (!playerIds.includes(binding.playerId)) {
|
|
1358
|
+
throw new Error(
|
|
1359
|
+
`stored structural catalog.${id}.roles.${role} names an absent player`,
|
|
1360
|
+
);
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
return stored;
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
function selectedMembersFromStoredStructure(stored) {
|
|
1368
|
+
return {
|
|
1369
|
+
playbookIds: Object.keys(stored.catalog),
|
|
1370
|
+
playerIds: stored.players.map((player) => player.id),
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
function fixedAgentProjection(agent) {
|
|
1375
|
+
return {
|
|
1376
|
+
adapter: agent.adapter,
|
|
1377
|
+
...(agent.instruction === undefined
|
|
1378
|
+
? {}
|
|
1379
|
+
: { instruction: agent.instruction }),
|
|
1380
|
+
...(agent.permissions === undefined
|
|
1381
|
+
? {}
|
|
1382
|
+
: { permissions: agent.permissions }),
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
function projectSelectedMembers(top, selectedMembers) {
|
|
1387
|
+
if (selectedMembers === undefined) return top;
|
|
1388
|
+
const { playbookIds, playerIds } = validateSelectedMembers(selectedMembers);
|
|
1389
|
+
if (!isPlainObject(top)) {
|
|
1390
|
+
throw new Error('config must contain only plain JSON objects');
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
const descriptors = Object.getOwnPropertyDescriptors(top);
|
|
1394
|
+
const keys = Reflect.ownKeys(top);
|
|
1395
|
+
for (const key of keys) {
|
|
1396
|
+
const descriptor = descriptors[key];
|
|
1397
|
+
if (
|
|
1398
|
+
typeof key === 'symbol' ||
|
|
1399
|
+
descriptor?.get !== undefined ||
|
|
1400
|
+
descriptor?.set !== undefined ||
|
|
1401
|
+
descriptor?.enumerable !== true
|
|
1402
|
+
) {
|
|
1403
|
+
throw new Error(
|
|
1404
|
+
`config.${String(key)} must be an enumerable data property`,
|
|
1405
|
+
);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
const projected = Object.fromEntries(
|
|
1409
|
+
keys.map((key) => [key, descriptors[key].value]),
|
|
1410
|
+
);
|
|
1411
|
+
projected.playbooks = projectSelectedMap(
|
|
1412
|
+
projected.playbooks,
|
|
1413
|
+
playbookIds,
|
|
1414
|
+
'playbooks',
|
|
1415
|
+
);
|
|
1416
|
+
projected.players = projectSelectedMap(
|
|
1417
|
+
projected.players,
|
|
1418
|
+
playerIds,
|
|
1419
|
+
'players',
|
|
1420
|
+
);
|
|
1421
|
+
return projected;
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
function validateSelectedMembers(selectedMembers) {
|
|
1425
|
+
const selected = cloneJson(selectedMembers, 'selectedMembers');
|
|
1426
|
+
const unknownSelectionKeys = Object.keys(selected).filter(
|
|
1427
|
+
(key) => !['playbookIds', 'playerIds'].includes(key),
|
|
1428
|
+
);
|
|
1429
|
+
if (unknownSelectionKeys.length > 0) {
|
|
1430
|
+
throw new Error(
|
|
1431
|
+
`selectedMembers has unknown ${formatKeyList(unknownSelectionKeys)}`,
|
|
1432
|
+
);
|
|
1433
|
+
}
|
|
1434
|
+
return {
|
|
1435
|
+
playbookIds: selectedIdList(
|
|
1436
|
+
selected.playbookIds,
|
|
1437
|
+
'selectedMembers.playbookIds',
|
|
1438
|
+
),
|
|
1439
|
+
playerIds: selectedIdList(
|
|
1440
|
+
selected.playerIds,
|
|
1441
|
+
'selectedMembers.playerIds',
|
|
1442
|
+
),
|
|
1443
|
+
};
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
function projectSelectedLayer(value, selected, path) {
|
|
1447
|
+
if (!isPlainObject(value)) {
|
|
1448
|
+
throw new Error(`${path} must contain only plain JSON objects`);
|
|
1449
|
+
}
|
|
1450
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
1451
|
+
const entries = [];
|
|
1452
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
1453
|
+
const descriptor = descriptors[key];
|
|
1454
|
+
if (
|
|
1455
|
+
typeof key === 'symbol' ||
|
|
1456
|
+
descriptor?.get !== undefined ||
|
|
1457
|
+
descriptor?.set !== undefined ||
|
|
1458
|
+
descriptor?.enumerable !== true
|
|
1459
|
+
) {
|
|
1460
|
+
throw new Error(
|
|
1461
|
+
`${path}.${String(key)} must be an enumerable data property`,
|
|
1462
|
+
);
|
|
1463
|
+
}
|
|
1464
|
+
if (key === 'playbooks' || key === 'players') {
|
|
1465
|
+
entries.push([
|
|
1466
|
+
key,
|
|
1467
|
+
projectOptionalSelectedMap(
|
|
1468
|
+
descriptor.value,
|
|
1469
|
+
key === 'playbooks' ? selected.playbookIds : selected.playerIds,
|
|
1470
|
+
`${path}.${key}`,
|
|
1471
|
+
),
|
|
1472
|
+
]);
|
|
1473
|
+
} else {
|
|
1474
|
+
entries.push([key, descriptor.value]);
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
return Object.fromEntries(entries);
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
function projectOptionalSelectedMap(value, ids, path) {
|
|
1481
|
+
if (!isPlainObject(value)) {
|
|
1482
|
+
throw new Error(`${path} must contain only plain JSON objects`);
|
|
1483
|
+
}
|
|
1484
|
+
const entries = [];
|
|
1485
|
+
for (const id of ids) {
|
|
1486
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, id);
|
|
1487
|
+
if (descriptor === undefined) continue;
|
|
1488
|
+
if (
|
|
1489
|
+
descriptor.get !== undefined ||
|
|
1490
|
+
descriptor.set !== undefined ||
|
|
1491
|
+
descriptor.enumerable !== true
|
|
1492
|
+
) {
|
|
1493
|
+
throw new Error(`${path}.${id} must be an enumerable data property`);
|
|
1494
|
+
}
|
|
1495
|
+
entries.push([id, descriptor.value]);
|
|
1496
|
+
}
|
|
1497
|
+
return Object.fromEntries(entries);
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
function projectSelectedMap(value, ids, path) {
|
|
1501
|
+
if (!isPlainObject(value)) {
|
|
1502
|
+
throw new Error(`${path} must contain only plain JSON objects`);
|
|
1503
|
+
}
|
|
1504
|
+
return Object.fromEntries(
|
|
1505
|
+
ids.map((id) => {
|
|
1506
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, id);
|
|
1507
|
+
if (descriptor === undefined) {
|
|
1508
|
+
throw new Error(
|
|
1509
|
+
`selected ${path} member ${JSON.stringify(id)} is missing`,
|
|
1510
|
+
);
|
|
1511
|
+
}
|
|
1512
|
+
if (
|
|
1513
|
+
descriptor.get !== undefined ||
|
|
1514
|
+
descriptor.set !== undefined ||
|
|
1515
|
+
descriptor.enumerable !== true
|
|
1516
|
+
) {
|
|
1517
|
+
throw new Error(`${path}.${id} must be an enumerable data property`);
|
|
1518
|
+
}
|
|
1519
|
+
return [id, descriptor.value];
|
|
1520
|
+
}),
|
|
1521
|
+
);
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
function selectedIdList(value, path) {
|
|
1525
|
+
if (
|
|
1526
|
+
!Array.isArray(value) ||
|
|
1527
|
+
value.some((id) => typeof id !== 'string' || id.trim().length === 0) ||
|
|
1528
|
+
new Set(value).size !== value.length
|
|
1529
|
+
) {
|
|
1530
|
+
throw new Error(`${path} must be a duplicate-free array of nonblank ids`);
|
|
1531
|
+
}
|
|
1532
|
+
return value;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
function resolveRoleBinding(value, path) {
|
|
1536
|
+
if (typeof value === 'string') {
|
|
1537
|
+
assertPlayerId(value, path);
|
|
1538
|
+
return { playerId: value };
|
|
1539
|
+
}
|
|
1540
|
+
const block = requireObject(value, path);
|
|
1541
|
+
const unknown = Object.keys(block).filter(
|
|
1542
|
+
(key) => !['player', 'model', 'effort'].includes(key),
|
|
1543
|
+
);
|
|
1544
|
+
if (unknown.length > 0) {
|
|
1545
|
+
throw new Error(`${path} has unknown ${formatKeyList(unknown)}`);
|
|
1546
|
+
}
|
|
1547
|
+
assertPlayerId(block.player, `${path}.player`);
|
|
1548
|
+
for (const field of ['model', 'effort']) {
|
|
1549
|
+
if (
|
|
1550
|
+
block[field] !== undefined &&
|
|
1551
|
+
block[field] !== false &&
|
|
1552
|
+
(typeof block[field] !== 'string' || block[field].trim().length === 0)
|
|
1553
|
+
) {
|
|
1554
|
+
throw new Error(
|
|
1555
|
+
`${path}.${field} must be a nonblank string or false for provider-default`,
|
|
1556
|
+
);
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
return {
|
|
1560
|
+
playerId: block.player,
|
|
1561
|
+
...(block.model === undefined ? {} : { model: block.model }),
|
|
1562
|
+
...(block.effort === undefined ? {} : { effort: block.effort }),
|
|
1563
|
+
};
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
function applyTuningOverrides(agent, binding) {
|
|
1567
|
+
const effective = { ...agent };
|
|
1568
|
+
if (binding.model === false) delete effective.model;
|
|
1569
|
+
else if (binding.model !== undefined) effective.model = binding.model;
|
|
1570
|
+
if (binding.effort !== undefined) {
|
|
1571
|
+
delete effective.reasoningEffort;
|
|
1572
|
+
if (binding.effort === false) delete effective.effort;
|
|
1573
|
+
else effective.effort = binding.effort;
|
|
1574
|
+
}
|
|
1575
|
+
return effective;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
function sessionAgentFromHostAgent(agent, path) {
|
|
1579
|
+
if (!isObject(agent)) {
|
|
1580
|
+
throw new Error('installed cligent omitted a retained agent');
|
|
1581
|
+
}
|
|
1582
|
+
return {
|
|
1583
|
+
adapter: agent.adapter,
|
|
1584
|
+
model: tuningSelection(agent.model, `${path}.model`),
|
|
1585
|
+
effort: tuningSelection(agent.effort, `${path}.effort`),
|
|
1586
|
+
...(agent.instruction === undefined
|
|
1587
|
+
? {}
|
|
1588
|
+
: { instruction: agent.instruction }),
|
|
1589
|
+
...(agent.permissions === undefined
|
|
1590
|
+
? {}
|
|
1591
|
+
: { permissions: agent.permissions }),
|
|
1592
|
+
};
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
// Shared by the interactive and headless host projections. A tagged
|
|
1596
|
+
// provider-default is represented to cligent by omitting that configured
|
|
1597
|
+
// default; the complete tagged selection remains in sessionAgents.
|
|
1598
|
+
export function projectHostAgent(agent, path = 'agent') {
|
|
1599
|
+
const normalized = cloneJson(agent, path);
|
|
1600
|
+
return {
|
|
1601
|
+
adapter: normalized.adapter,
|
|
1602
|
+
...(normalized.model?.kind === 'value'
|
|
1603
|
+
? { model: normalized.model.value }
|
|
1604
|
+
: {}),
|
|
1605
|
+
...(normalized.effort?.kind === 'value'
|
|
1606
|
+
? { effort: normalized.effort.value }
|
|
1607
|
+
: {}),
|
|
1608
|
+
...(normalized.instruction === undefined
|
|
1609
|
+
? {}
|
|
1610
|
+
: { instruction: normalized.instruction }),
|
|
1611
|
+
...(normalized.permissions === undefined
|
|
1612
|
+
? {}
|
|
1613
|
+
: { permissions: normalized.permissions }),
|
|
1614
|
+
};
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
function tuningSelection(value, path) {
|
|
1618
|
+
if (
|
|
1619
|
+
value !== undefined &&
|
|
1620
|
+
(typeof value !== 'string' || value.trim().length === 0)
|
|
1621
|
+
) {
|
|
1622
|
+
throw new Error(`${path} must be a nonblank string`);
|
|
1623
|
+
}
|
|
1624
|
+
return value === undefined
|
|
1625
|
+
? { kind: 'provider-default' }
|
|
1626
|
+
: { kind: 'value', value };
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
function overrideTuningSelection(value) {
|
|
1630
|
+
return value === false
|
|
1631
|
+
? { kind: 'provider-default' }
|
|
1632
|
+
: tuningSelection(value, 'role tuning override');
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
function canonicalizePreparedRegistrySpecifier(value, path) {
|
|
1636
|
+
if (value !== value.trim()) {
|
|
1637
|
+
throw new Error(
|
|
1638
|
+
`${path} preparation must return a canonical trimmed module specifier`,
|
|
1639
|
+
);
|
|
1640
|
+
}
|
|
1641
|
+
if (
|
|
1642
|
+
isAbsolute(value) ||
|
|
1643
|
+
/^(?:\.{1,2}(?:[\\/]|$)|[\\/]|[A-Za-z]:[\\/])/.test(value)
|
|
1644
|
+
) {
|
|
1645
|
+
throw new Error(
|
|
1646
|
+
`${path} preparation must return a canonical module specifier`,
|
|
1647
|
+
);
|
|
1648
|
+
}
|
|
1649
|
+
if (value.startsWith('file:')) {
|
|
1650
|
+
let canonical;
|
|
1651
|
+
try {
|
|
1652
|
+
canonical = pathToFileURL(fileURLToPath(value)).href;
|
|
1653
|
+
} catch {
|
|
1654
|
+
throw new Error(`${path} preparation must return a canonical file URL`);
|
|
1655
|
+
}
|
|
1656
|
+
if (canonical !== value) {
|
|
1657
|
+
throw new Error(`${path} preparation must return a canonical file URL`);
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
return value;
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
function assertPlayerId(value, path) {
|
|
1664
|
+
if (typeof value !== 'string' || !PLAYER_ID_PATTERN.test(value)) {
|
|
1665
|
+
throw new Error(
|
|
1666
|
+
`${path} must name a player matching ${PLAYER_ID_PATTERN.source}`,
|
|
1667
|
+
);
|
|
1668
|
+
}
|
|
1669
|
+
if (value === RESERVED_CAPTAIN_ROLE_ID) {
|
|
1670
|
+
throw new Error(`${path} uses reserved player id "captain"`);
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
function assertRoleId(value, path) {
|
|
1675
|
+
if (typeof value !== 'string' || !ROLE_ID_PATTERN.test(value)) {
|
|
1676
|
+
throw new Error(`${path} must use a canonical lowercase local role id`);
|
|
1677
|
+
}
|
|
1678
|
+
if (value === RESERVED_CAPTAIN_ROLE_ID) {
|
|
1679
|
+
throw new Error(`${path} uses reserved local role id "captain"`);
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
function invalidManifestRoles(value) {
|
|
1684
|
+
if (!Array.isArray(value)) return 'must be an array';
|
|
1685
|
+
if (value.some((role) => typeof role !== 'string')) {
|
|
1686
|
+
return 'must contain only strings';
|
|
1687
|
+
}
|
|
1688
|
+
const canonical = value.map((role) => role.toLowerCase());
|
|
1689
|
+
if (new Set(canonical).size !== canonical.length) {
|
|
1690
|
+
return 'contains roles that collide after canonical lowercase derivation';
|
|
1691
|
+
}
|
|
1692
|
+
const invalid = value.find((role) => !ROLE_ID_PATTERN.test(role));
|
|
1693
|
+
if (invalid !== undefined) {
|
|
1694
|
+
return `contains noncanonical role ${JSON.stringify(invalid)}`;
|
|
1695
|
+
}
|
|
1696
|
+
if (value.includes(RESERVED_CAPTAIN_ROLE_ID)) {
|
|
1697
|
+
return 'contains reserved local role "captain"';
|
|
1698
|
+
}
|
|
1699
|
+
return undefined;
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
function invalidConcurrentRoleSets(value, requiredRoleIds) {
|
|
1703
|
+
if (!Array.isArray(value)) return 'must be an array';
|
|
1704
|
+
const required = new Set(requiredRoleIds);
|
|
1705
|
+
const seen = new Set();
|
|
1706
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
1707
|
+
const set = value[index];
|
|
1708
|
+
if (!Array.isArray(set) || set.length < 2) {
|
|
1709
|
+
return `[${index}] must contain at least two roles`;
|
|
1710
|
+
}
|
|
1711
|
+
if (
|
|
1712
|
+
set.some(
|
|
1713
|
+
(role) =>
|
|
1714
|
+
typeof role !== 'string' ||
|
|
1715
|
+
!ROLE_ID_PATTERN.test(role) ||
|
|
1716
|
+
role === RESERVED_CAPTAIN_ROLE_ID ||
|
|
1717
|
+
!required.has(role),
|
|
1718
|
+
)
|
|
1719
|
+
) {
|
|
1720
|
+
return `[${index}] must contain only required canonical local roles`;
|
|
1721
|
+
}
|
|
1722
|
+
if (new Set(set).size !== set.length) {
|
|
1723
|
+
return `[${index}] must contain pairwise-distinct roles`;
|
|
1724
|
+
}
|
|
1725
|
+
const signature = JSON.stringify(set);
|
|
1726
|
+
if (seen.has(signature)) return `contains duplicate set ${signature}`;
|
|
1727
|
+
seen.add(signature);
|
|
1728
|
+
}
|
|
1729
|
+
return undefined;
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
function assertExactRoleBindings(id, configured, required) {
|
|
1733
|
+
const missing = required.filter((role) => !configured.includes(role));
|
|
1734
|
+
const extra = configured.filter((role) => !required.includes(role));
|
|
1735
|
+
if (missing.length > 0 || extra.length > 0) {
|
|
1736
|
+
throw new Error(
|
|
1737
|
+
`playbooks.${id}.roles must exactly cover requiredRoleIds` +
|
|
1738
|
+
`${missing.length === 0 ? '' : `; missing ${missing.map(JSON.stringify).join(', ')}`}` +
|
|
1739
|
+
`${
|
|
1740
|
+
extra.length === 0
|
|
1741
|
+
? ''
|
|
1742
|
+
: `; extra ${extra.map(JSON.stringify).join(', ')}`
|
|
1743
|
+
}`,
|
|
1744
|
+
);
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
function assertConcurrentPlayers(id, sets, roles) {
|
|
1749
|
+
for (const set of sets) {
|
|
1750
|
+
const playerIds = set.map((role) => roles[role].playerId);
|
|
1751
|
+
if (new Set(playerIds).size !== playerIds.length) {
|
|
1752
|
+
throw new Error(
|
|
1753
|
+
`playbooks.${id}.concurrentRoleSets ${JSON.stringify(set)} must bind ` +
|
|
1754
|
+
'to pairwise-distinct player ids',
|
|
1755
|
+
);
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
function distinct(values) {
|
|
1761
|
+
return [...new Set(values)];
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
function findLegacyPlayersPath(top) {
|
|
1765
|
+
const playbooks = isObject(top?.playbooks) ? top.playbooks : {};
|
|
1766
|
+
for (const [id, block] of Object.entries(playbooks)) {
|
|
1767
|
+
if (isObject(block) && hasOwn(block, 'players')) {
|
|
1768
|
+
return `playbooks.${id}.players`;
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
return undefined;
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
function legacyPlayersError(path, configPath) {
|
|
1775
|
+
const where = configPath ? ` in ${configPath}` : '';
|
|
1776
|
+
const error = new Error(
|
|
1777
|
+
`${path} was removed in the explicit-session-player major release${where}: ` +
|
|
1778
|
+
'define stable ids in top-level players and bind them explicitly under ' +
|
|
1779
|
+
'playbooks.<id>.roles; automatic migration would choose which prior ' +
|
|
1780
|
+
'conversations share a session',
|
|
1781
|
+
);
|
|
1782
|
+
error.code = 'PLAYBOOK_LEGACY_PLAYERS';
|
|
1783
|
+
error.legacyPath = path;
|
|
1784
|
+
return error;
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
function cloneJson(value, path, seen = new Set()) {
|
|
1788
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') {
|
|
1789
|
+
return value;
|
|
1790
|
+
}
|
|
1791
|
+
if (typeof value === 'number') {
|
|
1792
|
+
if (Number.isFinite(value)) return value;
|
|
1793
|
+
throw new Error(`${path} must contain only finite JSON numbers`);
|
|
1794
|
+
}
|
|
1795
|
+
if (typeof value !== 'object') {
|
|
1796
|
+
throw new Error(`${path} must contain only JSON values`);
|
|
1797
|
+
}
|
|
1798
|
+
if (seen.has(value)) throw new Error(`${path} must not contain a cycle`);
|
|
1799
|
+
seen.add(value);
|
|
1800
|
+
try {
|
|
1801
|
+
if (Array.isArray(value)) {
|
|
1802
|
+
const keys = Reflect.ownKeys(value);
|
|
1803
|
+
if (
|
|
1804
|
+
keys.some(
|
|
1805
|
+
(key) =>
|
|
1806
|
+
typeof key === 'symbol' ||
|
|
1807
|
+
(key !== 'length' &&
|
|
1808
|
+
(!/^(?:0|[1-9]\d*)$/.test(key) || Number(key) >= value.length)),
|
|
1809
|
+
)
|
|
1810
|
+
) {
|
|
1811
|
+
throw new Error(`${path} must be a plain JSON array`);
|
|
1812
|
+
}
|
|
1813
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
1814
|
+
const cloned = [];
|
|
1815
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
1816
|
+
const descriptor = descriptors[index];
|
|
1817
|
+
if (descriptor === undefined) {
|
|
1818
|
+
throw new Error(`${path} must not contain sparse array slots`);
|
|
1819
|
+
}
|
|
1820
|
+
if (
|
|
1821
|
+
descriptor.get !== undefined ||
|
|
1822
|
+
descriptor.set !== undefined ||
|
|
1823
|
+
descriptor.enumerable !== true
|
|
1824
|
+
) {
|
|
1825
|
+
throw new Error(
|
|
1826
|
+
`${path}[${index}] must be an enumerable data property`,
|
|
1827
|
+
);
|
|
1828
|
+
}
|
|
1829
|
+
cloned.push(
|
|
1830
|
+
cloneJson(descriptor.value, `${path}[${index}]`, seen),
|
|
1831
|
+
);
|
|
1832
|
+
}
|
|
1833
|
+
return cloned;
|
|
1834
|
+
}
|
|
1835
|
+
const prototype = Object.getPrototypeOf(value);
|
|
1836
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
1837
|
+
throw new Error(`${path} must contain only plain JSON objects`);
|
|
1838
|
+
}
|
|
1839
|
+
const keys = Reflect.ownKeys(value);
|
|
1840
|
+
if (keys.some((key) => typeof key === 'symbol')) {
|
|
1841
|
+
throw new Error(`${path} must not contain symbol keys`);
|
|
1842
|
+
}
|
|
1843
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
1844
|
+
for (const key of keys) {
|
|
1845
|
+
const descriptor = descriptors[key];
|
|
1846
|
+
if (
|
|
1847
|
+
descriptor?.get !== undefined ||
|
|
1848
|
+
descriptor?.set !== undefined ||
|
|
1849
|
+
descriptor?.enumerable !== true
|
|
1850
|
+
) {
|
|
1851
|
+
throw new Error(`${path}.${key} must be an enumerable data property`);
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
return Object.fromEntries(
|
|
1855
|
+
keys.map((key) => [
|
|
1856
|
+
key,
|
|
1857
|
+
cloneJson(descriptors[key].value, `${path}.${key}`, seen),
|
|
1858
|
+
]),
|
|
1859
|
+
);
|
|
1860
|
+
} finally {
|
|
1861
|
+
seen.delete(value);
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
function deepFreeze(value) {
|
|
1866
|
+
if (value === null || typeof value !== 'object' || Object.isFrozen(value)) {
|
|
1867
|
+
return value;
|
|
1868
|
+
}
|
|
1869
|
+
for (const child of Object.values(value)) deepFreeze(child);
|
|
1870
|
+
return Object.freeze(value);
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
function isObject(value) {
|
|
1874
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
function isPlainObject(value) {
|
|
1878
|
+
if (!isObject(value)) return false;
|
|
1879
|
+
const prototype = Object.getPrototypeOf(value);
|
|
1880
|
+
return prototype === Object.prototype || prototype === null;
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
function hasOwn(value, key) {
|
|
1884
|
+
return Object.prototype.hasOwnProperty.call(value, key);
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
function requireObject(value, path) {
|
|
1888
|
+
if (!isObject(value)) throw new Error(`${path} must be an object`);
|
|
1889
|
+
return value;
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
function formatKeyList(keys) {
|
|
1893
|
+
return `${keys.length === 1 ? 'key' : 'keys'} ${keys
|
|
1894
|
+
.map((key) => JSON.stringify(key))
|
|
1895
|
+
.join(', ')}`;
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
function errorMessage(error) {
|
|
1899
|
+
return error instanceof Error ? error.message : String(error);
|
|
1900
|
+
}
|