@sublang/playbook 1.0.0 → 2.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 CHANGED
@@ -149,7 +149,14 @@ and the captain default to `claude`; bind them with
149
149
  `claude@high` for the default model at high reasoning effort; the
150
150
  model keeps every interior colon (`opencode:ollama/llama3:8b@max`),
151
151
  and unsupported efforts are rejected up front naming the adapter's
152
- supported values. Pass a playbook option with
152
+ supported values. To stop retyping those flags, set durable defaults
153
+ once in the user config's top-level `run:` block — `run.captain`,
154
+ `run.players.<role>`, and a `run.player` catch-all for any other
155
+ required role, each the same agent string; flags keep winning per
156
+ role, and `resume` always keeps the lineup stored with the parked
157
+ session ([PBCLI-28](specs/user/playbook-cli.md#pbcli-28),
158
+ [DR-017](specs/decisions/017-run-defaults-config.md)). Pass a
159
+ playbook option with
153
160
  `--option <key>=<value>`, and add `--json` to print one JSON envelope
154
161
  (`outcome`, `sessionId`, and the output or pending questions) instead
155
162
  of plain text. It exits `0` on a terminal outcome, `2` on failure, `3`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sublang/playbook",
3
- "version": "1.0.0",
3
+ "version": "2.0.0",
4
4
  "type": "module",
5
5
  "description": "Composable XState v5 playbook runtime with compiled Captain, CODE, and DISCUSS workflows driven by GEARS specs.",
6
6
  "license": "Apache-2.0",
@@ -31,6 +31,9 @@
31
31
  "src/xstate-runtime.ts",
32
32
  "src/xstate-runtime.js",
33
33
  "src/xstate-runtime.d.ts",
34
+ "src/xstate-playbook-runtime.ts",
35
+ "src/xstate-playbook-runtime.js",
36
+ "src/xstate-playbook-runtime.d.ts",
34
37
  "slc/link.md",
35
38
  "slc/gears2fsm.md",
36
39
  "slc/text2gears.md",
@@ -123,7 +126,8 @@
123
126
  "dependencies": {
124
127
  "@anthropic-ai/claude-agent-sdk": "^0.3.154",
125
128
  "@openai/codex-sdk": "^0.139.0",
126
- "@sublang/cligent": "^0.15.0",
129
+ "@sublang/cligent": "^0.16.0",
130
+ "@sublang/spex": "^0.3.0",
127
131
  "p-queue": "^9.3.1",
128
132
  "xstate": "^5.19.4",
129
133
  "yaml": "^2.9.0"
@@ -198,7 +198,7 @@ export declare const captainMachine: import("xstate").StateMachine<Context, Boss
198
198
  } | {
199
199
  type: "isAuthoredChildError";
200
200
  params: unknown;
201
- }, never, "done" | "failed" | "callPlaybook" | "routing" | "reassessing" | "ready" | "awaitBossReply", string, CaptainMachineInput, CaptainMachineOutput, import("xstate").EventObject, import("xstate").MetaObject, {
201
+ }, never, "done" | "failed" | "callPlaybook" | "awaitBossReply" | "ready" | "routing" | "reassessing", string, CaptainMachineInput, CaptainMachineOutput, import("xstate").EventObject, import("xstate").MetaObject, {
202
202
  id: "captain";
203
203
  states: {
204
204
  readonly ready: {
@@ -44,6 +44,9 @@ export async function runPlaybookCli(options = {}) {
44
44
  const stdout = options.stdout ?? process.stdout;
45
45
  const stderr = options.stderr ?? process.stderr;
46
46
  const loadModule = options.loadModule ?? ((specifier) => import(specifier));
47
+ const home = options.homeDir ?? env.HOME ?? homedir();
48
+ const userConfigPath =
49
+ options.userConfigPath ?? resolveUserConfigPath(env, home);
47
50
 
48
51
  // PBCLI-18: `playbook run ...` is the non-interactive one-shot path; it
49
52
  // never seeds, composes, resolves tmux-play, or launches it.
@@ -53,6 +56,9 @@ export async function runPlaybookCli(options = {}) {
53
56
  argv: argv.slice(1),
54
57
  stdout,
55
58
  stderr,
59
+ // PBCLI-29: the run host reads the same user config as the launcher,
60
+ // honoring any injected env, home, or explicit path.
61
+ userConfigPath,
56
62
  ...(options.loadModule ? { loadModule: options.loadModule } : {}),
57
63
  ...(options.createAgent ? { createAgent: options.createAgent } : {}),
58
64
  ...(options.readStdin ? { readStdin: options.readStdin } : {}),
@@ -62,8 +68,6 @@ export async function runPlaybookCli(options = {}) {
62
68
 
63
69
  const spawnFn = options.spawn ?? spawn;
64
70
  const tmuxPlayBin = options.tmuxPlayBin ?? resolveTmuxPlayBin();
65
- const home = options.homeDir ?? env.HOME ?? homedir();
66
- const userConfigPath = resolveUserConfigPath(env, home);
67
71
 
68
72
  // PBCLI-6: `--help` / `-h` print help and exit 0 without seeding,
69
73
  // composing, or launching.
@@ -27,6 +27,7 @@ import {
27
27
  isEffortSupported,
28
28
  supportedEffortValues,
29
29
  } from '@sublang/cligent';
30
+ import { parse as parseYaml } from 'yaml';
30
31
 
31
32
  // PBCLI-19: adapter shorthands the run host can construct.
32
33
  const ADAPTER_LOADERS = {
@@ -61,6 +62,10 @@ export async function runPlaybookRun(options = {}) {
61
62
  const createAgent = options.createAgent ?? defaultCreateAgent;
62
63
  const readStdin = options.readStdin ?? readAllStdin;
63
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());
64
69
  const ctx = {
65
70
  stdout,
66
71
  stderr,
@@ -69,6 +74,7 @@ export async function runPlaybookRun(options = {}) {
69
74
  createAgent,
70
75
  readStdin,
71
76
  sessionsDir,
77
+ userConfigPath,
72
78
  };
73
79
 
74
80
  let args;
@@ -105,9 +111,28 @@ async function runFirst(args, ctx) {
105
111
  return { code: EXIT.arg };
106
112
  }
107
113
 
108
- // PBCLI-19: bind every required role, then the captain; defaults to claude.
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.
109
128
  const roleSpecs = new Map(
110
- entry.requiredRoleIds.map((role) => [role, { adapter: DEFAULT_ADAPTER }]),
129
+ entry.requiredRoleIds.map((role) => [
130
+ role,
131
+ {
132
+ ...(runDefaults.players.get(role) ??
133
+ runDefaults.player ?? { adapter: DEFAULT_ADAPTER }),
134
+ },
135
+ ]),
111
136
  );
112
137
  for (const [role, spec] of args.players) {
113
138
  if (!roleSpecs.has(role)) {
@@ -116,7 +141,8 @@ async function runFirst(args, ctx) {
116
141
  }
117
142
  roleSpecs.set(role, spec);
118
143
  }
119
- const captainSpec = args.captain ?? { adapter: DEFAULT_ADAPTER };
144
+ const captainSpec =
145
+ args.captain ?? runDefaults.captain ?? { adapter: DEFAULT_ADAPTER };
120
146
  const specError = specsDiagnostic([...roleSpecs.values(), captainSpec]);
121
147
  if (specError !== undefined) {
122
148
  stderr.write(`playbook run: ${specError}\n`);
@@ -586,6 +612,83 @@ function isAgentSpec(spec) {
586
612
  );
587
613
  }
588
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
+
589
692
  // PBCLI-23: the session store honors XDG_STATE_HOME at invocation time.
590
693
  export function defaultSessionsDir(env = process.env) {
591
694
  const stateHome =
@@ -877,7 +980,10 @@ function runHelpText() {
877
980
  ' <agent> is <adapter>[:<model>][@<effort>] over the shorthands',
878
981
  ' claude, codex, gemini, opencode — e.g. codex:gpt-5.5@xhigh, or',
879
982
  ' claude@high for the default model at high effort. Every role and',
880
- ' the captain default to claude.',
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.',
881
987
  '',
882
988
  ' When a playbook needs a Boss reply, the run prints the question,',
883
989
  ' parks the session under',
@@ -1,27 +1,14 @@
1
+ import { normalizeErrorCompact, normalizeErrorFull, type PlaybookPlayerInput, type RuntimeBoundaryCalls } from '../../../src/xstate-runtime.js';
1
2
  import { type PlayerInput, type PlayerOutput, type CodingEvent, type CodingInput } from './code.fsm.js';
2
3
  import type { CaptainCallOptions, CaptainResult, JsonValue, NormalizedError, PlayerCallOptions, PlaybookCallRequest, PlaybookCallResult, PlaybookCallStart, PlaybookPendingCall, PlaybookRunResult, PlaybookRuntimeSnapshot, PlaybookSession, PlaybookState, PlaybookStateValue, PlaybookTraceEvent, PlaybookTraceType, PlaybookPorts, PlaybookRuntime, PlaybookRuntimeFactory, PlayerResult } from '@sublang/playbook/runtime';
3
4
  export type { CaptainCallOptions, CaptainResult, JsonValue, NormalizedError, PlayerCallOptions, PlaybookCallRequest, PlaybookCallResult, PlaybookCallStart, PlaybookPendingCall, PlaybookRunResult, PlayerResult, PlaybookPorts, PlaybookSession, PlaybookState, PlaybookStateValue, PlaybookTraceEvent, PlaybookTraceType, PlaybookRuntime, PlaybookRuntimeFactory, PlaybookRuntimeSnapshot, };
4
5
  export type CodePlaybookOptions = CodingInput;
5
- declare function normalizeErrorCompact(err: unknown): {
6
- name: string;
7
- message: string;
8
- } | undefined;
9
- declare function normalizeErrorFull(err: unknown): {
10
- name: string;
11
- message: string;
12
- stack?: string;
13
- } | undefined;
14
6
  declare function normalizeEventForTelemetry(event: unknown): unknown;
15
7
  declare function composePlayerPrompt(input: PlayerInput): string;
16
8
  declare function resolvePlayerId(input: PlayerInput): string;
17
- type JudgePurpose = 'boss-input-classification' | 'player-output-adjudication';
18
- interface RuntimeBoundaryCalls {
19
- callPlayer(input: PlayerInput, playerId: string, prompt: string, signal: AbortSignal): Promise<PlayerResult>;
20
- callJudge(purpose: JudgePurpose, stateId: string | undefined, prompt: string, signal: AbortSignal): Promise<string>;
21
- }
22
9
  declare function adjudicate(input: PlayerInput, finalText: string, ports: PlaybookPorts, signal: AbortSignal, boundary?: RuntimeBoundaryCalls): Promise<PlayerOutput>;
23
10
  declare function classifyBossText(text: string, ports: PlaybookPorts, signal: AbortSignal, snapshotOrState?: unknown, boundary?: RuntimeBoundaryCalls): Promise<CodingEvent | undefined>;
24
- declare function captainBridge(ports: PlaybookPorts, getActiveSignal?: () => AbortSignal | undefined, boundary?: RuntimeBoundaryCalls, onControlPlaneError?: (error: unknown) => void): import("xstate").PromiseActorLogic<import("./code.fsm.js").CaptainOutput, PlayerInput, import("xstate").EventObject>;
11
+ declare function captainBridge(ports: PlaybookPorts, getActiveSignal?: () => AbortSignal | undefined, boundary?: RuntimeBoundaryCalls, onControlPlaneError?: (error: unknown) => void): import("xstate").PromiseActorLogic<import("../../../src/xstate-playbook-runtime.js").PlaybookActorOutput, PlaybookPlayerInput>;
25
12
  interface StateMetadata {
26
13
  player: PlayerInput['player'];
27
14
  sourceItem: string;
@@ -64,4 +51,5 @@ export declare const _internal: {
64
51
  normalizeEventForTelemetry: typeof normalizeEventForTelemetry;
65
52
  VERBATIM_PAYLOAD_FIELDS: ReadonlySet<string>;
66
53
  };
67
- export default function createPlaybookRuntime(options: CodePlaybookOptions): PlaybookRuntime;
54
+ declare const createPlaybookRuntime: PlaybookRuntimeFactory<CodePlaybookOptions>;
55
+ export default createPlaybookRuntime;