@sublang/playbook 0.9.0 → 1.3.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 +190 -151
- package/package.json +50 -6
- package/reference/sdlc/captain.md +102 -0
- package/reference/sdlc/captain.playbook/captain.fsm.d.ts +227 -0
- package/reference/sdlc/captain.playbook/captain.fsm.js +628 -0
- package/reference/sdlc/captain.playbook/captain.fsm.ts +851 -0
- package/reference/sdlc/captain.playbook/captain.gears.md +60 -0
- package/reference/sdlc/captain.playbook/captain.playbook.d.ts +23 -0
- package/reference/sdlc/captain.playbook/captain.playbook.js +1053 -0
- package/reference/sdlc/captain.playbook/captain.playbook.ts +1144 -0
- package/reference/sdlc/code.playbook/bin/playbook.js +158 -12
- package/reference/sdlc/code.playbook/bin/run.js +999 -0
- package/reference/sdlc/code.playbook/code.fsm.d.ts +11 -4
- package/reference/sdlc/code.playbook/code.fsm.introspect.d.ts +2 -2
- package/reference/sdlc/code.playbook/code.fsm.introspect.js +1 -1
- package/reference/sdlc/code.playbook/code.fsm.introspect.ts +6 -6
- package/reference/sdlc/code.playbook/code.fsm.js +334 -102
- package/reference/sdlc/code.playbook/code.fsm.ts +467 -180
- package/reference/sdlc/code.playbook/code.gears.md +11 -10
- package/reference/sdlc/code.playbook/code.playbook.d.ts +16 -19
- package/reference/sdlc/code.playbook/code.playbook.js +199 -488
- package/reference/sdlc/code.playbook/code.playbook.ts +327 -566
- package/reference/sdlc/code.playbook/code.registry.d.ts +0 -3
- package/reference/sdlc/code.playbook/code.registry.js +0 -3
- package/reference/sdlc/code.playbook/code.registry.ts +0 -6
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +9 -4
- package/reference/sdlc/code.playbook/playbook-captain.js +889 -210
- package/reference/sdlc/code.playbook/playbook-captain.ts +1136 -257
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +21 -0
- package/reference/sdlc/discuss.playbook/discuss.fsm.d.ts +396 -0
- package/reference/sdlc/discuss.playbook/discuss.fsm.js +2066 -0
- package/reference/sdlc/discuss.playbook/discuss.fsm.ts +2464 -0
- package/reference/sdlc/discuss.playbook/discuss.gears.md +251 -0
- package/reference/sdlc/discuss.playbook/discuss.playbook.d.ts +113 -0
- package/reference/sdlc/discuss.playbook/discuss.playbook.js +1514 -0
- package/reference/sdlc/discuss.playbook/discuss.playbook.ts +1926 -0
- package/reference/sdlc/discuss.playbook/discuss.registry.d.ts +58 -0
- package/reference/sdlc/discuss.playbook/discuss.registry.js +97 -0
- package/reference/sdlc/discuss.playbook/discuss.registry.ts +153 -0
- package/slc/gears2fsm.md +557 -57
- package/slc/link.md +1165 -89
- package/slc/optimize.md +92 -0
- package/slc/text2gears.md +255 -7
- package/src/runtime.d.ts +146 -3
- package/src/runtime.ts +201 -2
- package/src/xstate-playbook-runtime.d.ts +201 -0
- package/src/xstate-playbook-runtime.js +2058 -0
- package/src/xstate-playbook-runtime.ts +2792 -0
- package/src/xstate-runtime.d.ts +95 -0
- package/src/xstate-runtime.js +1258 -0
- package/src/xstate-runtime.ts +1816 -0
|
@@ -0,0 +1,999 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
|
|
3
|
+
|
|
4
|
+
// PBCLI-18/19/20: `playbook run <from> [task]` runs one playbook once,
|
|
5
|
+
// non-interactively and without tmux-play, over a headless PlaybookPorts
|
|
6
|
+
// host backed by cligent's Cligent. The playbook need not be enabled in
|
|
7
|
+
// config; its registry entry is loaded straight from the `<from>` module.
|
|
8
|
+
// PBCLI-22/23 (DR-014): a turn that parks awaiting a Boss reply persists
|
|
9
|
+
// the session under ${XDG_STATE_HOME:-$HOME/.local/state}/playbook/sessions,
|
|
10
|
+
// prints the pending question to stdout, and `playbook run resume
|
|
11
|
+
// <session-id> [reply]` (or `--last`) finishes it in a later invocation.
|
|
12
|
+
|
|
13
|
+
import { randomUUID } from 'node:crypto';
|
|
14
|
+
import {
|
|
15
|
+
mkdir,
|
|
16
|
+
readdir,
|
|
17
|
+
readFile,
|
|
18
|
+
rename,
|
|
19
|
+
rm,
|
|
20
|
+
writeFile,
|
|
21
|
+
} from 'node:fs/promises';
|
|
22
|
+
import { homedir } from 'node:os';
|
|
23
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
24
|
+
import { pathToFileURL } from 'node:url';
|
|
25
|
+
import {
|
|
26
|
+
Cligent,
|
|
27
|
+
isEffortSupported,
|
|
28
|
+
supportedEffortValues,
|
|
29
|
+
} from '@sublang/cligent';
|
|
30
|
+
import { parse as parseYaml } from 'yaml';
|
|
31
|
+
|
|
32
|
+
// PBCLI-19: adapter shorthands the run host can construct.
|
|
33
|
+
const ADAPTER_LOADERS = {
|
|
34
|
+
claude: async () =>
|
|
35
|
+
(await import('@sublang/cligent/adapters/claude-code')).ClaudeCodeAdapter,
|
|
36
|
+
codex: async () => (await import('@sublang/cligent/adapters/codex')).CodexAdapter,
|
|
37
|
+
gemini: async () =>
|
|
38
|
+
(await import('@sublang/cligent/adapters/gemini')).GeminiAdapter,
|
|
39
|
+
opencode: async () =>
|
|
40
|
+
(await import('@sublang/cligent/adapters/opencode')).OpenCodeAdapter,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const DEFAULT_ADAPTER = 'claude';
|
|
44
|
+
// PBCLI-18: exit-code map — terminal 0, arg/import 1, failed/aborted 2,
|
|
45
|
+
// suspended/quiescent 3.
|
|
46
|
+
const EXIT = { terminal: 0, arg: 1, failed: 2, suspended: 3 };
|
|
47
|
+
// PBCLI-23: session-file schema version for the park/resume store.
|
|
48
|
+
const SESSION_STORE_VERSION = 1;
|
|
49
|
+
// PBCLI-23: a session id names a file inside the store; anything with a
|
|
50
|
+
// path separator (or any character a fresh UUID cannot contain) would
|
|
51
|
+
// escape the 0700-protected directory and must be rejected before join.
|
|
52
|
+
const SESSION_REF_PATTERN = /^[A-Za-z0-9-]+$/;
|
|
53
|
+
|
|
54
|
+
export async function runPlaybookRun(options = {}) {
|
|
55
|
+
const argv = options.argv ?? [];
|
|
56
|
+
const stdout = options.stdout ?? process.stdout;
|
|
57
|
+
const stderr = options.stderr ?? process.stderr;
|
|
58
|
+
const cwdDefault = options.cwd ?? process.cwd();
|
|
59
|
+
const loadModule =
|
|
60
|
+
options.loadModule ??
|
|
61
|
+
((specifier) => import(registryImportSpecifier(specifier, cwdDefault)));
|
|
62
|
+
const createAgent = options.createAgent ?? defaultCreateAgent;
|
|
63
|
+
const readStdin = options.readStdin ?? readAllStdin;
|
|
64
|
+
const sessionsDir = options.sessionsDir ?? defaultSessionsDir(process.env);
|
|
65
|
+
// PBCLI-28/29: config defaults come from the same user config file the
|
|
66
|
+
// interactive launcher resolves; tests inject a hermetic path.
|
|
67
|
+
const userConfigPath =
|
|
68
|
+
options.userConfigPath ?? (await defaultUserConfigPath());
|
|
69
|
+
const ctx = {
|
|
70
|
+
stdout,
|
|
71
|
+
stderr,
|
|
72
|
+
cwdDefault,
|
|
73
|
+
loadModule,
|
|
74
|
+
createAgent,
|
|
75
|
+
readStdin,
|
|
76
|
+
sessionsDir,
|
|
77
|
+
userConfigPath,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
let args;
|
|
81
|
+
try {
|
|
82
|
+
args = parseRunArgs(argv);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
stderr.write(`playbook run: ${message(error)}\n`);
|
|
85
|
+
return { code: EXIT.arg };
|
|
86
|
+
}
|
|
87
|
+
if (args.help) {
|
|
88
|
+
stdout.write(runHelpText());
|
|
89
|
+
return { code: 0 };
|
|
90
|
+
}
|
|
91
|
+
if (args.resume) return runResume(args, ctx);
|
|
92
|
+
return runFirst(args, ctx);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// PBCLI-18/20: the one-shot first run.
|
|
96
|
+
async function runFirst(args, ctx) {
|
|
97
|
+
const { stderr, cwdDefault, readStdin } = ctx;
|
|
98
|
+
if (!args.from) {
|
|
99
|
+
stderr.write('playbook run: missing <from> registry module\n');
|
|
100
|
+
return { code: EXIT.arg };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const loaded = await loadRegistryEntry(args.from, ctx);
|
|
104
|
+
if (loaded.code !== undefined) return loaded;
|
|
105
|
+
const { entry } = loaded;
|
|
106
|
+
|
|
107
|
+
let task = args.task;
|
|
108
|
+
if (task === undefined) task = (await readStdin()).trim();
|
|
109
|
+
if (!task) {
|
|
110
|
+
stderr.write('playbook run: empty task; pass it as an argument or on stdin\n');
|
|
111
|
+
return { code: EXIT.arg };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// PBCLI-28 (DR-017): config-supplied defaults bind only a first run;
|
|
115
|
+
// runResume rebuilds the lineup stored with the session.
|
|
116
|
+
let runDefaults;
|
|
117
|
+
try {
|
|
118
|
+
runDefaults = await loadRunDefaults(ctx.userConfigPath);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
stderr.write(`playbook run: ${message(error)}\n`);
|
|
121
|
+
return { code: EXIT.arg };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// PBCLI-19/28: bind every required role, then the captain — flag over
|
|
125
|
+
// config default over built-in claude. An unrequired run.players role is
|
|
126
|
+
// ignored (the config is global across playbooks); an unrequired
|
|
127
|
+
// --player flag stays an error below.
|
|
128
|
+
const roleSpecs = new Map(
|
|
129
|
+
entry.requiredRoleIds.map((role) => [
|
|
130
|
+
role,
|
|
131
|
+
{
|
|
132
|
+
...(runDefaults.players.get(role) ??
|
|
133
|
+
runDefaults.player ?? { adapter: DEFAULT_ADAPTER }),
|
|
134
|
+
},
|
|
135
|
+
]),
|
|
136
|
+
);
|
|
137
|
+
for (const [role, spec] of args.players) {
|
|
138
|
+
if (!roleSpecs.has(role)) {
|
|
139
|
+
stderr.write(`playbook run: --player ${role} is not a required role\n`);
|
|
140
|
+
return { code: EXIT.arg };
|
|
141
|
+
}
|
|
142
|
+
roleSpecs.set(role, spec);
|
|
143
|
+
}
|
|
144
|
+
const captainSpec =
|
|
145
|
+
args.captain ?? runDefaults.captain ?? { adapter: DEFAULT_ADAPTER };
|
|
146
|
+
const specError = specsDiagnostic([...roleSpecs.values(), captainSpec]);
|
|
147
|
+
if (specError !== undefined) {
|
|
148
|
+
stderr.write(`playbook run: ${specError}\n`);
|
|
149
|
+
return { code: EXIT.arg };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let runtime;
|
|
153
|
+
try {
|
|
154
|
+
runtime = entry.createRuntime({
|
|
155
|
+
captainOptions: args.option,
|
|
156
|
+
players: playersFromSpecs(roleSpecs),
|
|
157
|
+
});
|
|
158
|
+
} catch (error) {
|
|
159
|
+
stderr.write(`playbook run: ${message(error)}\n`);
|
|
160
|
+
return { code: EXIT.arg };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// PBCLI-23: the record stores everything a resume needs to rebuild the
|
|
164
|
+
// identical host. `cwd` is resolved to an absolute path so a resume from
|
|
165
|
+
// another directory rebinds the agents to the same place.
|
|
166
|
+
const store = {
|
|
167
|
+
schemaVersion: SESSION_STORE_VERSION,
|
|
168
|
+
sessionId: randomUUID(),
|
|
169
|
+
playbookId: entry.id,
|
|
170
|
+
from: registryImportSpecifier(args.from, cwdDefault),
|
|
171
|
+
cwd: resolve(cwdDefault, args.cwd ?? '.'),
|
|
172
|
+
captain: captainSpec,
|
|
173
|
+
players: Object.fromEntries(roleSpecs),
|
|
174
|
+
option: args.option,
|
|
175
|
+
};
|
|
176
|
+
return driveTurn({
|
|
177
|
+
ctx,
|
|
178
|
+
runtime,
|
|
179
|
+
store,
|
|
180
|
+
text: task,
|
|
181
|
+
json: args.json,
|
|
182
|
+
verbose: args.verbose,
|
|
183
|
+
restoreFrom: undefined,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// PBCLI-22/23: continue a persisted parked session.
|
|
188
|
+
async function runResume(args, ctx) {
|
|
189
|
+
const { stderr, readStdin, sessionsDir } = ctx;
|
|
190
|
+
if (
|
|
191
|
+
args.players.size > 0 ||
|
|
192
|
+
args.captain !== undefined ||
|
|
193
|
+
Object.keys(args.option).length > 0 ||
|
|
194
|
+
args.cwd !== undefined
|
|
195
|
+
) {
|
|
196
|
+
stderr.write(
|
|
197
|
+
'playbook run: resume uses the bindings stored with the session; ' +
|
|
198
|
+
'drop --player/--captain/--option/--cwd\n',
|
|
199
|
+
);
|
|
200
|
+
return { code: EXIT.arg };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let sessionFile;
|
|
204
|
+
let record;
|
|
205
|
+
if (args.last) {
|
|
206
|
+
const latest = await latestSessionRecord(sessionsDir);
|
|
207
|
+
if (!latest) {
|
|
208
|
+
stderr.write(`playbook run: no persisted session under ${sessionsDir}\n`);
|
|
209
|
+
return { code: EXIT.arg };
|
|
210
|
+
}
|
|
211
|
+
({ file: sessionFile, record } = latest);
|
|
212
|
+
} else if (args.sessionRef) {
|
|
213
|
+
if (!SESSION_REF_PATTERN.test(args.sessionRef)) {
|
|
214
|
+
stderr.write(
|
|
215
|
+
`playbook run: "${args.sessionRef}" is not a session id\n`,
|
|
216
|
+
);
|
|
217
|
+
return { code: EXIT.arg };
|
|
218
|
+
}
|
|
219
|
+
sessionFile = join(sessionsDir, `${args.sessionRef}.json`);
|
|
220
|
+
try {
|
|
221
|
+
record = JSON.parse(await readFile(sessionFile, 'utf8'));
|
|
222
|
+
} catch (error) {
|
|
223
|
+
stderr.write(
|
|
224
|
+
`playbook run: cannot read session ${args.sessionRef}: ${message(error)}\n`,
|
|
225
|
+
);
|
|
226
|
+
return { code: EXIT.arg };
|
|
227
|
+
}
|
|
228
|
+
} else {
|
|
229
|
+
stderr.write('playbook run: resume needs a <session-id> or --last\n');
|
|
230
|
+
return { code: EXIT.arg };
|
|
231
|
+
}
|
|
232
|
+
if (!isValidSessionRecord(record)) {
|
|
233
|
+
stderr.write(
|
|
234
|
+
`playbook run: ${sessionFile} is not a schema-version-${SESSION_STORE_VERSION} playbook run session\n`,
|
|
235
|
+
);
|
|
236
|
+
return { code: EXIT.arg };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let reply = args.task;
|
|
240
|
+
if (reply === undefined) reply = (await readStdin()).trim();
|
|
241
|
+
if (!reply) {
|
|
242
|
+
stderr.write(
|
|
243
|
+
'playbook run: empty reply; pass it as an argument or on stdin\n',
|
|
244
|
+
);
|
|
245
|
+
return { code: EXIT.arg };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const loaded = await loadRegistryEntry(record.from, ctx);
|
|
249
|
+
if (loaded.code !== undefined) return loaded;
|
|
250
|
+
const { entry } = loaded;
|
|
251
|
+
// PBCLI-23: the module may have changed since the session parked; a
|
|
252
|
+
// different playbook id means a different machine, which the stored
|
|
253
|
+
// snapshot cannot rehydrate.
|
|
254
|
+
if (entry.id !== record.playbookId) {
|
|
255
|
+
stderr.write(
|
|
256
|
+
`playbook run: ${record.from} now exposes playbook "${entry.id}", ` +
|
|
257
|
+
`but the stored session belongs to "${record.playbookId}"\n`,
|
|
258
|
+
);
|
|
259
|
+
return { code: EXIT.arg };
|
|
260
|
+
}
|
|
261
|
+
const roleSpecs = new Map(Object.entries(record.players));
|
|
262
|
+
const missingRole = entry.requiredRoleIds.find(
|
|
263
|
+
(role) => !roleSpecs.has(role),
|
|
264
|
+
);
|
|
265
|
+
if (missingRole !== undefined) {
|
|
266
|
+
stderr.write(
|
|
267
|
+
`playbook run: stored session lacks required role "${missingRole}"; ` +
|
|
268
|
+
`the ${record.from} module changed since the session parked\n`,
|
|
269
|
+
);
|
|
270
|
+
return { code: EXIT.arg };
|
|
271
|
+
}
|
|
272
|
+
const specError = specsDiagnostic([...roleSpecs.values(), record.captain]);
|
|
273
|
+
if (specError !== undefined) {
|
|
274
|
+
stderr.write(`playbook run: ${specError}\n`);
|
|
275
|
+
return { code: EXIT.arg };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
let runtime;
|
|
279
|
+
try {
|
|
280
|
+
runtime = entry.createRuntime({
|
|
281
|
+
captainOptions: record.option,
|
|
282
|
+
players: playersFromSpecs(roleSpecs),
|
|
283
|
+
});
|
|
284
|
+
} catch (error) {
|
|
285
|
+
stderr.write(`playbook run: ${message(error)}\n`);
|
|
286
|
+
return { code: EXIT.arg };
|
|
287
|
+
}
|
|
288
|
+
if (typeof runtime.restore !== 'function') {
|
|
289
|
+
stderr.write(
|
|
290
|
+
`playbook run: the ${record.from} runtime does not support resume\n`,
|
|
291
|
+
);
|
|
292
|
+
return { code: EXIT.arg };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return driveTurn({
|
|
296
|
+
ctx,
|
|
297
|
+
runtime,
|
|
298
|
+
store: record,
|
|
299
|
+
text: reply,
|
|
300
|
+
json: args.json,
|
|
301
|
+
verbose: args.verbose,
|
|
302
|
+
restoreFrom: { snapshot: record.snapshot, sessionFile },
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// PBCLI-18: shared module-load pipeline for `<from>` and a stored resume
|
|
307
|
+
// specifier. Returns { entry } or an { code } failure already reported.
|
|
308
|
+
async function loadRegistryEntry(specifier, { loadModule, stderr }) {
|
|
309
|
+
let entry;
|
|
310
|
+
try {
|
|
311
|
+
entry = (await loadModule(specifier))?.default;
|
|
312
|
+
} catch (cause) {
|
|
313
|
+
stderr.write(
|
|
314
|
+
`playbook run: ${specifier} failed to import: ${message(cause)}\n`,
|
|
315
|
+
);
|
|
316
|
+
return { code: EXIT.arg };
|
|
317
|
+
}
|
|
318
|
+
if (!isValidRegistryEntry(entry)) {
|
|
319
|
+
stderr.write(
|
|
320
|
+
`playbook run: ${specifier} exposes no valid registry entry\n`,
|
|
321
|
+
);
|
|
322
|
+
return { code: EXIT.arg };
|
|
323
|
+
}
|
|
324
|
+
return { entry };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// PBCLI-20/23: one Boss turn over the headless cligent-backed ports,
|
|
328
|
+
// parking to the session store when the playbook awaits a Boss reply.
|
|
329
|
+
async function driveTurn({ ctx, runtime, store, text, json, verbose, restoreFrom }) {
|
|
330
|
+
const { stdout, stderr, createAgent } = ctx;
|
|
331
|
+
const { sessionId, cwd } = store;
|
|
332
|
+
|
|
333
|
+
const agentsByRole = new Map();
|
|
334
|
+
for (const [role, spec] of Object.entries(store.players)) {
|
|
335
|
+
agentsByRole.set(role, createAgent({ ...spec, role, cwd }));
|
|
336
|
+
}
|
|
337
|
+
const captainAgent = createAgent({ ...store.captain, role: 'captain', cwd });
|
|
338
|
+
|
|
339
|
+
const controller = new AbortController();
|
|
340
|
+
const ports = {
|
|
341
|
+
async callPlayer(playerId, prompt, signal, callOptions) {
|
|
342
|
+
const agent = agentsByRole.get(playerId);
|
|
343
|
+
if (!agent) return { status: 'error', error: `unknown player ${playerId}` };
|
|
344
|
+
const result = await agent.run(prompt, { resume: callOptions?.resume, signal });
|
|
345
|
+
return toPlayerResult(result);
|
|
346
|
+
},
|
|
347
|
+
async callCaptain(prompt, signal, callOptions) {
|
|
348
|
+
const result = await captainAgent.run(prompt, {
|
|
349
|
+
resume: callOptions?.resume,
|
|
350
|
+
...(callOptions?.allowedTools === undefined
|
|
351
|
+
? {}
|
|
352
|
+
: { allowedTools: callOptions.allowedTools }),
|
|
353
|
+
signal,
|
|
354
|
+
});
|
|
355
|
+
return {
|
|
356
|
+
status: result.status,
|
|
357
|
+
...(result.finalText === undefined
|
|
358
|
+
? {}
|
|
359
|
+
: { finalText: result.finalText }),
|
|
360
|
+
...(result.error ? { error: result.error } : {}),
|
|
361
|
+
};
|
|
362
|
+
},
|
|
363
|
+
async callJudge(prompt, signal) {
|
|
364
|
+
const result = await captainAgent.run(prompt, {
|
|
365
|
+
resume: false,
|
|
366
|
+
allowedTools: [],
|
|
367
|
+
signal,
|
|
368
|
+
});
|
|
369
|
+
if (result.status !== 'ok' || result.finalText === undefined) {
|
|
370
|
+
throw new Error(result.error ?? 'judge call failed');
|
|
371
|
+
}
|
|
372
|
+
return result.finalText;
|
|
373
|
+
},
|
|
374
|
+
async callPlaybook() {
|
|
375
|
+
// The one-shot host cannot drive the child, but returning a suspended
|
|
376
|
+
// start lets the linked runtime expose that boundary as outcome
|
|
377
|
+
// `suspended`, which finishRun maps to the documented exit code 3.
|
|
378
|
+
return { state: 'suspended', childSessionId: randomUUID() };
|
|
379
|
+
},
|
|
380
|
+
async emitStatus(statusText) {
|
|
381
|
+
stderr.write(`◇ ${statusText}\n`);
|
|
382
|
+
},
|
|
383
|
+
async emitTelemetry(event) {
|
|
384
|
+
if (verbose) stderr.write(`· ${event.topic}\n`);
|
|
385
|
+
},
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
const session = {
|
|
389
|
+
sessionId,
|
|
390
|
+
playbookId: store.playbookId,
|
|
391
|
+
rootSessionId: sessionId,
|
|
392
|
+
depth: 0,
|
|
393
|
+
ports,
|
|
394
|
+
};
|
|
395
|
+
// DR-014 §2: only a successfully persisted parked hand-off skips
|
|
396
|
+
// disposal; the session is then suspended, not ended.
|
|
397
|
+
let parked = false;
|
|
398
|
+
try {
|
|
399
|
+
if (restoreFrom) {
|
|
400
|
+
try {
|
|
401
|
+
await runtime.restore(session, restoreFrom.snapshot);
|
|
402
|
+
} catch (error) {
|
|
403
|
+
stderr.write(
|
|
404
|
+
`playbook run: session ${sessionId} cannot be resumed: ${message(error)}\n`,
|
|
405
|
+
);
|
|
406
|
+
return { code: EXIT.arg };
|
|
407
|
+
}
|
|
408
|
+
} else {
|
|
409
|
+
await runtime.init(session);
|
|
410
|
+
}
|
|
411
|
+
const result = await runtime.handleBossInput({
|
|
412
|
+
text,
|
|
413
|
+
signal: controller.signal,
|
|
414
|
+
});
|
|
415
|
+
const parkedSnapshot =
|
|
416
|
+
(result.outcome === 'quiescent' || result.outcome === 'no-action') &&
|
|
417
|
+
typeof runtime.exportSnapshot === 'function'
|
|
418
|
+
? runtime.exportSnapshot()
|
|
419
|
+
: undefined;
|
|
420
|
+
if (parkedSnapshot && parkedSnapshot.pendingBossQuestions.length > 0) {
|
|
421
|
+
const outcome = await finishParked({
|
|
422
|
+
ctx,
|
|
423
|
+
store,
|
|
424
|
+
snapshot: parkedSnapshot,
|
|
425
|
+
json,
|
|
426
|
+
});
|
|
427
|
+
parked = outcome.code === EXIT.suspended;
|
|
428
|
+
return outcome;
|
|
429
|
+
}
|
|
430
|
+
const outcome = finishRun(result, { stdout, stderr, json, sessionId });
|
|
431
|
+
if (restoreFrom && result.outcome === 'terminal') {
|
|
432
|
+
// The turn succeeded; a session-file removal failure must not mask
|
|
433
|
+
// the terminal output or flip the exit code.
|
|
434
|
+
try {
|
|
435
|
+
await rm(restoreFrom.sessionFile, { force: true });
|
|
436
|
+
} catch (error) {
|
|
437
|
+
stderr.write(
|
|
438
|
+
`playbook run: warning: could not remove ${restoreFrom.sessionFile}: ${message(error)}\n`,
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return outcome;
|
|
443
|
+
} catch (error) {
|
|
444
|
+
stderr.write(`playbook run: ${message(error)}\n`);
|
|
445
|
+
return { code: EXIT.failed };
|
|
446
|
+
} finally {
|
|
447
|
+
if (!parked) {
|
|
448
|
+
try {
|
|
449
|
+
await runtime.dispose();
|
|
450
|
+
} catch {
|
|
451
|
+
// A dispose failure must not mask the run's own outcome.
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// PBCLI-23: persist the parked session and surface the pending question —
|
|
458
|
+
// stdout carries the question text (the run's product), stderr one hint
|
|
459
|
+
// naming the session id and the exact resume command.
|
|
460
|
+
async function finishParked({ ctx, store, snapshot, json }) {
|
|
461
|
+
const { stdout, stderr } = ctx;
|
|
462
|
+
const now = new Date().toISOString();
|
|
463
|
+
const record = {
|
|
464
|
+
...store,
|
|
465
|
+
createdAt: store.createdAt ?? now,
|
|
466
|
+
updatedAt: now,
|
|
467
|
+
snapshot,
|
|
468
|
+
};
|
|
469
|
+
const file = join(ctx.sessionsDir, `${store.sessionId}.json`);
|
|
470
|
+
try {
|
|
471
|
+
await mkdir(ctx.sessionsDir, { recursive: true, mode: 0o700 });
|
|
472
|
+
// Write-then-rename so a crash mid-write can never truncate the only
|
|
473
|
+
// durable copy of the session.
|
|
474
|
+
const tmpFile = `${file}.${process.pid}.tmp`;
|
|
475
|
+
await writeFile(tmpFile, `${JSON.stringify(record, null, 2)}\n`, {
|
|
476
|
+
mode: 0o600,
|
|
477
|
+
});
|
|
478
|
+
await rename(tmpFile, file);
|
|
479
|
+
} catch (error) {
|
|
480
|
+
stderr.write(`playbook run: cannot persist session: ${message(error)}\n`);
|
|
481
|
+
return { code: EXIT.failed };
|
|
482
|
+
}
|
|
483
|
+
const questions = snapshot.pendingBossQuestions;
|
|
484
|
+
if (json) {
|
|
485
|
+
stdout.write(
|
|
486
|
+
`${JSON.stringify(
|
|
487
|
+
{
|
|
488
|
+
outcome: 'awaiting-reply',
|
|
489
|
+
sessionId: store.sessionId,
|
|
490
|
+
questions: questions.map(({ questionId, player, question }) => ({
|
|
491
|
+
questionId,
|
|
492
|
+
player,
|
|
493
|
+
question,
|
|
494
|
+
})),
|
|
495
|
+
},
|
|
496
|
+
null,
|
|
497
|
+
2,
|
|
498
|
+
)}\n`,
|
|
499
|
+
);
|
|
500
|
+
} else {
|
|
501
|
+
stdout.write(`${questions.map(({ question }) => question).join('\n')}\n`);
|
|
502
|
+
}
|
|
503
|
+
stderr.write(
|
|
504
|
+
`playbook run: session ${store.sessionId} is awaiting a Boss reply; ` +
|
|
505
|
+
`continue with: playbook run resume ${store.sessionId} "<answer>"\n`,
|
|
506
|
+
);
|
|
507
|
+
return { code: EXIT.suspended };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// PBCLI-18: map the single turn's outcome to stdout output and an exit code.
|
|
511
|
+
function finishRun(result, { stdout, stderr, json, sessionId }) {
|
|
512
|
+
switch (result.outcome) {
|
|
513
|
+
case 'terminal':
|
|
514
|
+
stdout.write(
|
|
515
|
+
(json
|
|
516
|
+
? JSON.stringify(
|
|
517
|
+
{
|
|
518
|
+
outcome: 'terminal',
|
|
519
|
+
sessionId,
|
|
520
|
+
output: result.output ?? null,
|
|
521
|
+
},
|
|
522
|
+
null,
|
|
523
|
+
2,
|
|
524
|
+
)
|
|
525
|
+
: renderOutput(result.output)) + '\n',
|
|
526
|
+
);
|
|
527
|
+
return { code: EXIT.terminal };
|
|
528
|
+
case 'failed':
|
|
529
|
+
case 'aborted':
|
|
530
|
+
stderr.write(
|
|
531
|
+
`playbook run: ${result.outcome}${
|
|
532
|
+
result.error ? `: ${result.error.message}` : ''
|
|
533
|
+
}\n`,
|
|
534
|
+
);
|
|
535
|
+
return { code: EXIT.failed };
|
|
536
|
+
case 'suspended':
|
|
537
|
+
stderr.write(
|
|
538
|
+
'playbook run: playbook made a nested call a one-shot run cannot answer\n',
|
|
539
|
+
);
|
|
540
|
+
return { code: EXIT.suspended };
|
|
541
|
+
default:
|
|
542
|
+
// quiescent / no-action without a persistable pending question:
|
|
543
|
+
// the pre-DR-014 diagnostic path.
|
|
544
|
+
stderr.write(
|
|
545
|
+
'playbook run: playbook is awaiting Boss input; a one-shot run cannot continue\n',
|
|
546
|
+
);
|
|
547
|
+
return { code: EXIT.suspended };
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function renderOutput(output) {
|
|
552
|
+
if (output === null || output === undefined) return '';
|
|
553
|
+
if (typeof output === 'string') return output;
|
|
554
|
+
if (
|
|
555
|
+
typeof output === 'object' &&
|
|
556
|
+
typeof (output.response ?? output.finalText) === 'string'
|
|
557
|
+
) {
|
|
558
|
+
return output.response ?? output.finalText;
|
|
559
|
+
}
|
|
560
|
+
return JSON.stringify(output);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function toPlayerResult(result) {
|
|
564
|
+
return {
|
|
565
|
+
status: result.status,
|
|
566
|
+
...(result.finalText === undefined ? {} : { finalText: result.finalText }),
|
|
567
|
+
...(result.resumeToken ? { resumeToken: result.resumeToken } : {}),
|
|
568
|
+
...(result.error ? { error: result.error } : {}),
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function playersFromSpecs(roleSpecs) {
|
|
573
|
+
return [...roleSpecs].map(([role, spec]) => ({
|
|
574
|
+
id: role,
|
|
575
|
+
adapter: spec.adapter,
|
|
576
|
+
...(spec.model ? { model: spec.model } : {}),
|
|
577
|
+
}));
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// PBCLI-19/26: returns a diagnostic for the first invalid spec — an
|
|
581
|
+
// unknown adapter or an effort the adapter does not support — or
|
|
582
|
+
// undefined when every spec resolves. The caller must compare against
|
|
583
|
+
// undefined.
|
|
584
|
+
function specsDiagnostic(specs) {
|
|
585
|
+
for (const spec of specs) {
|
|
586
|
+
if (
|
|
587
|
+
!isAgentSpec(spec) ||
|
|
588
|
+
!Object.prototype.hasOwnProperty.call(ADAPTER_LOADERS, spec.adapter)
|
|
589
|
+
) {
|
|
590
|
+
const adapter = isAgentSpec(spec)
|
|
591
|
+
? spec.adapter
|
|
592
|
+
: String(spec?.adapter);
|
|
593
|
+
return `unknown adapter "${adapter}"`;
|
|
594
|
+
}
|
|
595
|
+
if (spec.effort !== undefined && !isEffortSupported(spec.adapter, spec.effort)) {
|
|
596
|
+
const supported = supportedEffortValues(spec.adapter).join(', ');
|
|
597
|
+
return `adapter "${spec.adapter}" does not support effort "${spec.effort}" (supported: ${supported})`;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
return undefined;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function isAgentSpec(spec) {
|
|
604
|
+
return (
|
|
605
|
+
typeof spec === 'object' &&
|
|
606
|
+
spec !== null &&
|
|
607
|
+
typeof spec.adapter === 'string' &&
|
|
608
|
+
spec.adapter.length > 0 &&
|
|
609
|
+
(spec.model === undefined || typeof spec.model === 'string') &&
|
|
610
|
+
(spec.effort === undefined ||
|
|
611
|
+
(typeof spec.effort === 'string' && spec.effort.length > 0))
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// PBCLI-29: the run host reads the same user config file the interactive
|
|
616
|
+
// launcher resolves. The resolver is imported lazily: a static import of
|
|
617
|
+
// ./playbook.js would deadlock the CLI entry — playbook.js is still
|
|
618
|
+
// mid-evaluation of its own top-level await when it dynamically imports
|
|
619
|
+
// this module, and a circular static edge back to it can never settle.
|
|
620
|
+
// The launcher always injects userConfigPath, so this default runs only
|
|
621
|
+
// for direct runPlaybookRun callers, where playbook.js is not evaluating.
|
|
622
|
+
async function defaultUserConfigPath() {
|
|
623
|
+
const { resolveUserConfigPath } = await import('./playbook.js');
|
|
624
|
+
return resolveUserConfigPath(process.env, process.env.HOME ?? homedir());
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// PBCLI-28/29 (DR-017): default agent specs for a first run, read from the
|
|
628
|
+
// user config's top-level `run` map. An absent file or absent map is an
|
|
629
|
+
// empty default set; a malformed file or block fails closed — the run must
|
|
630
|
+
// never silently bind different agents than the user configured. Adapter
|
|
631
|
+
// and effort support of the specs actually bound flow through the shared
|
|
632
|
+
// specsDiagnostic path.
|
|
633
|
+
async function loadRunDefaults(userConfigPath) {
|
|
634
|
+
const defaults = { players: new Map() };
|
|
635
|
+
let text;
|
|
636
|
+
try {
|
|
637
|
+
text = await readFile(userConfigPath, 'utf8');
|
|
638
|
+
} catch (error) {
|
|
639
|
+
if (error?.code === 'ENOENT') return defaults;
|
|
640
|
+
throw new Error(`cannot read config ${userConfigPath}: ${message(error)}`);
|
|
641
|
+
}
|
|
642
|
+
let config;
|
|
643
|
+
try {
|
|
644
|
+
config = parseYaml(text);
|
|
645
|
+
} catch (error) {
|
|
646
|
+
throw new Error(`cannot parse config ${userConfigPath}: ${message(error)}`);
|
|
647
|
+
}
|
|
648
|
+
const run = isPlainMap(config) ? config.run : undefined;
|
|
649
|
+
if (run === undefined || run === null) return defaults;
|
|
650
|
+
if (!isPlainMap(run)) {
|
|
651
|
+
throw new Error(`${userConfigPath}: run must be a map of agent defaults`);
|
|
652
|
+
}
|
|
653
|
+
if (run.captain !== undefined) {
|
|
654
|
+
defaults.captain = parseAgentDefault(run.captain, 'run.captain', userConfigPath);
|
|
655
|
+
}
|
|
656
|
+
if (run.player !== undefined) {
|
|
657
|
+
defaults.player = parseAgentDefault(run.player, 'run.player', userConfigPath);
|
|
658
|
+
}
|
|
659
|
+
if (run.players !== undefined && run.players !== null) {
|
|
660
|
+
if (!isPlainMap(run.players)) {
|
|
661
|
+
throw new Error(
|
|
662
|
+
`${userConfigPath}: run.players must be a map of <role>: <agent>`,
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
for (const [role, value] of Object.entries(run.players)) {
|
|
666
|
+
defaults.players.set(
|
|
667
|
+
role,
|
|
668
|
+
parseAgentDefault(value, `run.players.${role}`, userConfigPath),
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
return defaults;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function parseAgentDefault(value, key, userConfigPath) {
|
|
676
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
677
|
+
throw new Error(
|
|
678
|
+
`${userConfigPath}: ${key} must be an <adapter>[:<model>][@<effort>] string`,
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
try {
|
|
682
|
+
return parseAgent(value);
|
|
683
|
+
} catch (error) {
|
|
684
|
+
throw new Error(`${userConfigPath}: ${key}: ${message(error)}`);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function isPlainMap(value) {
|
|
689
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// PBCLI-23: the session store honors XDG_STATE_HOME at invocation time.
|
|
693
|
+
export function defaultSessionsDir(env = process.env) {
|
|
694
|
+
const stateHome =
|
|
695
|
+
typeof env.XDG_STATE_HOME === 'string' && env.XDG_STATE_HOME.trim() !== ''
|
|
696
|
+
? env.XDG_STATE_HOME
|
|
697
|
+
: join(homedir(), '.local', 'state');
|
|
698
|
+
return join(stateHome, 'playbook', 'sessions');
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// PBCLI-23: `--last` selects by the record's own update timestamp, not
|
|
702
|
+
// filesystem mtime. Unreadable or foreign .json files are skipped.
|
|
703
|
+
async function latestSessionRecord(sessionsDir) {
|
|
704
|
+
let names;
|
|
705
|
+
try {
|
|
706
|
+
names = await readdir(sessionsDir);
|
|
707
|
+
} catch {
|
|
708
|
+
return undefined;
|
|
709
|
+
}
|
|
710
|
+
const candidates = await Promise.all(
|
|
711
|
+
names
|
|
712
|
+
.filter((name) => name.endsWith('.json'))
|
|
713
|
+
.map(async (name) => {
|
|
714
|
+
const file = join(sessionsDir, name);
|
|
715
|
+
try {
|
|
716
|
+
const record = JSON.parse(await readFile(file, 'utf8'));
|
|
717
|
+
if (!isValidSessionRecord(record)) return undefined;
|
|
718
|
+
if (typeof record.updatedAt !== 'string') return undefined;
|
|
719
|
+
return { file, record };
|
|
720
|
+
} catch {
|
|
721
|
+
return undefined;
|
|
722
|
+
}
|
|
723
|
+
}),
|
|
724
|
+
);
|
|
725
|
+
let latest;
|
|
726
|
+
for (const candidate of candidates) {
|
|
727
|
+
if (!candidate) continue;
|
|
728
|
+
if (!latest || candidate.record.updatedAt > latest.record.updatedAt) {
|
|
729
|
+
latest = candidate;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
return latest;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function isValidSessionRecord(record) {
|
|
736
|
+
return (
|
|
737
|
+
typeof record === 'object' &&
|
|
738
|
+
record !== null &&
|
|
739
|
+
record.schemaVersion === SESSION_STORE_VERSION &&
|
|
740
|
+
typeof record.sessionId === 'string' &&
|
|
741
|
+
SESSION_REF_PATTERN.test(record.sessionId) &&
|
|
742
|
+
typeof record.playbookId === 'string' &&
|
|
743
|
+
typeof record.from === 'string' &&
|
|
744
|
+
typeof record.cwd === 'string' &&
|
|
745
|
+
isAgentSpec(record.captain) &&
|
|
746
|
+
typeof record.players === 'object' &&
|
|
747
|
+
record.players !== null &&
|
|
748
|
+
Object.values(record.players).every(isAgentSpec) &&
|
|
749
|
+
typeof record.option === 'object' &&
|
|
750
|
+
record.option !== null &&
|
|
751
|
+
typeof record.snapshot === 'object' &&
|
|
752
|
+
record.snapshot !== null
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// PBCLI-20: default agent — one lazily-built Cligent per role, run through
|
|
757
|
+
// the same event drain as tmux-play's host.
|
|
758
|
+
function defaultCreateAgent({ adapter, model, effort, cwd, role }) {
|
|
759
|
+
let cligent;
|
|
760
|
+
return {
|
|
761
|
+
async run(prompt, callOptions) {
|
|
762
|
+
if (!cligent) {
|
|
763
|
+
const AdapterClass = await ADAPTER_LOADERS[adapter]();
|
|
764
|
+
// Protected auto mode (as the seeded lineup uses, PBCLI-11) so a
|
|
765
|
+
// one-shot run does not block on routine approval prompts.
|
|
766
|
+
cligent = new Cligent(new AdapterClass(), {
|
|
767
|
+
cwd,
|
|
768
|
+
role,
|
|
769
|
+
permissions: { mode: 'auto' },
|
|
770
|
+
...(model ? { model } : {}),
|
|
771
|
+
...(effort ? { effort } : {}),
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
return runCligentCall(cligent, prompt, callOptions);
|
|
775
|
+
},
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
export async function runCligentCall(cligent, prompt, callOptions = {}) {
|
|
780
|
+
const { resume, allowedTools, signal } = callOptions;
|
|
781
|
+
const gen = cligent.run(prompt, {
|
|
782
|
+
...(signal ? { abortSignal: signal } : {}),
|
|
783
|
+
...(resume !== undefined ? { resume } : {}),
|
|
784
|
+
...(allowedTools !== undefined ? { allowedTools: [...allowedTools] } : {}),
|
|
785
|
+
});
|
|
786
|
+
const textParts = [];
|
|
787
|
+
let done;
|
|
788
|
+
let lastError;
|
|
789
|
+
let completed = false;
|
|
790
|
+
try {
|
|
791
|
+
for (;;) {
|
|
792
|
+
let next;
|
|
793
|
+
try {
|
|
794
|
+
next = await gen.next();
|
|
795
|
+
} catch (error) {
|
|
796
|
+
return { status: signal?.aborted ? 'aborted' : 'error', error: message(error) };
|
|
797
|
+
}
|
|
798
|
+
if (next.done) {
|
|
799
|
+
completed = true;
|
|
800
|
+
break;
|
|
801
|
+
}
|
|
802
|
+
const event = next.value;
|
|
803
|
+
if (event.type === 'text' && typeof event.payload?.content === 'string') {
|
|
804
|
+
textParts.push(event.payload.content);
|
|
805
|
+
} else if (
|
|
806
|
+
event.type === 'text_delta' &&
|
|
807
|
+
typeof event.payload?.delta === 'string'
|
|
808
|
+
) {
|
|
809
|
+
textParts.push(event.payload.delta);
|
|
810
|
+
}
|
|
811
|
+
if (event.type === 'error') lastError = event.payload?.message;
|
|
812
|
+
if (event.type === 'done') done = event.payload;
|
|
813
|
+
}
|
|
814
|
+
} finally {
|
|
815
|
+
if (!completed) {
|
|
816
|
+
try {
|
|
817
|
+
await gen.return(undefined);
|
|
818
|
+
} catch {
|
|
819
|
+
// The original outcome is already captured.
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
const status = done ? mapStatus(done.status) : 'error';
|
|
824
|
+
const finalText = done?.result ?? (textParts.length > 0 ? textParts.join('') : undefined);
|
|
825
|
+
return {
|
|
826
|
+
status,
|
|
827
|
+
finalText,
|
|
828
|
+
...(done?.resumeToken ? { resumeToken: done.resumeToken } : {}),
|
|
829
|
+
...(status === 'error'
|
|
830
|
+
? { error: done?.result ?? lastError ?? 'agent run failed' }
|
|
831
|
+
: {}),
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function mapStatus(doneStatus) {
|
|
836
|
+
if (doneStatus === 'success') return 'ok';
|
|
837
|
+
if (doneStatus === 'interrupted') return 'aborted';
|
|
838
|
+
return 'error';
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
export function parseRunArgs(argv) {
|
|
842
|
+
const args = {
|
|
843
|
+
from: undefined,
|
|
844
|
+
task: undefined,
|
|
845
|
+
resume: false,
|
|
846
|
+
sessionRef: undefined,
|
|
847
|
+
last: false,
|
|
848
|
+
players: new Map(),
|
|
849
|
+
captain: undefined,
|
|
850
|
+
option: {},
|
|
851
|
+
cwd: undefined,
|
|
852
|
+
json: false,
|
|
853
|
+
verbose: false,
|
|
854
|
+
help: false,
|
|
855
|
+
};
|
|
856
|
+
const positionals = [];
|
|
857
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
858
|
+
const arg = argv[i];
|
|
859
|
+
if (arg === '--help' || arg === '-h') args.help = true;
|
|
860
|
+
else if (arg === '--json') args.json = true;
|
|
861
|
+
else if (arg === '--verbose') args.verbose = true;
|
|
862
|
+
else if (arg === '--last') args.last = true;
|
|
863
|
+
else if (arg === '--cwd') args.cwd = takeValue(argv, (i += 1), '--cwd');
|
|
864
|
+
else if (arg === '--captain')
|
|
865
|
+
args.captain = parseAgent(takeValue(argv, (i += 1), '--captain'));
|
|
866
|
+
else if (arg === '--player') {
|
|
867
|
+
const [role, agent] = takePair(takeValue(argv, (i += 1), '--player'), '--player');
|
|
868
|
+
args.players.set(role, parseAgent(agent));
|
|
869
|
+
} else if (arg === '--option') {
|
|
870
|
+
const [key, value] = takePair(takeValue(argv, (i += 1), '--option'), '--option');
|
|
871
|
+
args.option[key] = value;
|
|
872
|
+
} else if (arg.startsWith('-')) {
|
|
873
|
+
throw new Error(`unknown option ${arg}`);
|
|
874
|
+
} else positionals.push(arg);
|
|
875
|
+
}
|
|
876
|
+
// PBCLI-22: `playbook run resume <session-id>|--last [reply]`.
|
|
877
|
+
if (positionals[0] === 'resume') {
|
|
878
|
+
args.resume = true;
|
|
879
|
+
let rest = positionals.slice(1);
|
|
880
|
+
if (!args.last) {
|
|
881
|
+
args.sessionRef = rest[0];
|
|
882
|
+
rest = rest.slice(1);
|
|
883
|
+
}
|
|
884
|
+
if (rest.length > 0) args.task = rest.join(' ');
|
|
885
|
+
return args;
|
|
886
|
+
}
|
|
887
|
+
if (args.last) throw new Error('--last applies to `playbook run resume`');
|
|
888
|
+
args.from = positionals[0];
|
|
889
|
+
if (positionals.length > 1) args.task = positionals.slice(1).join(' ');
|
|
890
|
+
return args;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// PBCLI-19: `<agent>` is `<adapter>[:<model>][@<effort>]`. The effort
|
|
894
|
+
// rides after the last `@` so a model name may itself contain colons
|
|
895
|
+
// (`opencode:ollama/llama3:8b@max`); `claude@high` keeps the default
|
|
896
|
+
// model while setting effort.
|
|
897
|
+
function parseAgent(value) {
|
|
898
|
+
const at = value.lastIndexOf('@');
|
|
899
|
+
const spec = at === -1 ? value : value.slice(0, at);
|
|
900
|
+
const effort = at === -1 ? undefined : value.slice(at + 1);
|
|
901
|
+
if (at !== -1 && !effort) {
|
|
902
|
+
throw new Error(`agent "${value}" has an empty effort after '@'`);
|
|
903
|
+
}
|
|
904
|
+
const colon = spec.indexOf(':');
|
|
905
|
+
const adapter = colon === -1 ? spec : spec.slice(0, colon);
|
|
906
|
+
const model = colon === -1 ? undefined : spec.slice(colon + 1);
|
|
907
|
+
return {
|
|
908
|
+
adapter,
|
|
909
|
+
...(model ? { model } : {}),
|
|
910
|
+
...(effort ? { effort } : {}),
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function takeValue(argv, index, flag) {
|
|
915
|
+
const value = argv[index];
|
|
916
|
+
if (value === undefined) throw new Error(`${flag} needs a value`);
|
|
917
|
+
return value;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function takePair(value, flag) {
|
|
921
|
+
const eq = value.indexOf('=');
|
|
922
|
+
if (eq <= 0) throw new Error(`${flag} needs <key>=<value>`);
|
|
923
|
+
return [value.slice(0, eq), value.slice(eq + 1)];
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function registryImportSpecifier(specifier, cwd) {
|
|
927
|
+
if (
|
|
928
|
+
isAbsolute(specifier) ||
|
|
929
|
+
specifier.startsWith('./') ||
|
|
930
|
+
specifier.startsWith('../') ||
|
|
931
|
+
specifier.startsWith('.\\') ||
|
|
932
|
+
specifier.startsWith('..\\')
|
|
933
|
+
) {
|
|
934
|
+
return pathToFileURL(resolve(cwd, specifier)).href;
|
|
935
|
+
}
|
|
936
|
+
return specifier;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function isValidRegistryEntry(value) {
|
|
940
|
+
return (
|
|
941
|
+
typeof value === 'object' &&
|
|
942
|
+
value !== null &&
|
|
943
|
+
typeof value.id === 'string' &&
|
|
944
|
+
typeof value.command === 'string' &&
|
|
945
|
+
typeof value.intent === 'string' &&
|
|
946
|
+
Array.isArray(value.requiredRoleIds) &&
|
|
947
|
+
typeof value.validateOptions === 'function' &&
|
|
948
|
+
typeof value.createRuntime === 'function'
|
|
949
|
+
);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
async function readAllStdin() {
|
|
953
|
+
const chunks = [];
|
|
954
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
955
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function runHelpText() {
|
|
959
|
+
return [
|
|
960
|
+
'Usage:',
|
|
961
|
+
' playbook run <from> [task] [options]',
|
|
962
|
+
' playbook run resume <session-id> [reply] [options]',
|
|
963
|
+
' playbook run resume --last [reply] [options]',
|
|
964
|
+
'',
|
|
965
|
+
' <from> registry module specifier (package subpath, path, or file: URL)',
|
|
966
|
+
' [task] Boss intent; read from stdin when omitted',
|
|
967
|
+
' [reply] Boss reply to a parked session; read from stdin when omitted',
|
|
968
|
+
'',
|
|
969
|
+
'Options:',
|
|
970
|
+
' --player <role>=<agent> bind a required role (repeatable)',
|
|
971
|
+
' --captain <agent> set the captain/judge agent',
|
|
972
|
+
' --option <key>=<value> playbook option slice (repeatable)',
|
|
973
|
+
' --cwd <dir> agents working directory',
|
|
974
|
+
' --json print one JSON envelope (outcome, sessionId,',
|
|
975
|
+
' output or questions) instead of plain text',
|
|
976
|
+
' --last resume the most recently parked session',
|
|
977
|
+
' --verbose forward telemetry topics to stderr',
|
|
978
|
+
' -h, --help print this help',
|
|
979
|
+
'',
|
|
980
|
+
' <agent> is <adapter>[:<model>][@<effort>] over the shorthands',
|
|
981
|
+
' claude, codex, gemini, opencode — e.g. codex:gpt-5.5@xhigh, or',
|
|
982
|
+
' claude@high for the default model at high effort. Every role and',
|
|
983
|
+
' the captain default to claude, unless a top-level run: block in',
|
|
984
|
+
' ${XDG_CONFIG_HOME:-$HOME/.config}/playbook/playbook.config.yaml',
|
|
985
|
+
' supplies defaults — run.captain, run.players.<role>, or the',
|
|
986
|
+
' run.player catch-all for other roles; flags override per role.',
|
|
987
|
+
'',
|
|
988
|
+
' When a playbook needs a Boss reply, the run prints the question,',
|
|
989
|
+
' parks the session under',
|
|
990
|
+
' ${XDG_STATE_HOME:-$HOME/.local/state}/playbook/sessions, and exits 3;',
|
|
991
|
+
' answer with `playbook run resume`. Bindings are stored with the',
|
|
992
|
+
' session, so resume takes no --player/--captain/--option/--cwd.',
|
|
993
|
+
'',
|
|
994
|
+
].join('\n');
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function message(error) {
|
|
998
|
+
return error instanceof Error ? error.message : String(error);
|
|
999
|
+
}
|