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