@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
@@ -31,6 +31,10 @@ const ADAPTER_SHORTHANDS = ['claude', 'codex'];
31
31
  // PBCLI-8: launcher-owned keys inside a `playbooks.<id>` block; every other
32
32
  // key belongs to that playbook's option slice.
33
33
  const PLAYBOOK_LAUNCHER_KEYS = ['from', 'command', 'players'];
34
+ const RESERVED_CAPTAIN_PLAYBOOK_ID = 'captain';
35
+ // PBCLI-9: the bare `captain` id names the tmux-play host Captain, so no
36
+ // playbook-local role may take it.
37
+ const RESERVED_CAPTAIN_ROLE_ID = 'captain';
34
38
  const READINESS_FAILURE_EXIT_CODE = 2;
35
39
  const COMPOSITION_FAILURE_EXIT_CODE = 1;
36
40
 
@@ -39,9 +43,25 @@ export async function runPlaybookCli(options = {}) {
39
43
  const env = options.env ?? process.env;
40
44
  const stdout = options.stdout ?? process.stdout;
41
45
  const stderr = options.stderr ?? process.stderr;
46
+ const loadModule = options.loadModule ?? ((specifier) => import(specifier));
47
+
48
+ // PBCLI-18: `playbook run ...` is the non-interactive one-shot path; it
49
+ // never seeds, composes, resolves tmux-play, or launches it.
50
+ if (argv[0] === 'run') {
51
+ const { runPlaybookRun } = await import('./run.js');
52
+ return await runPlaybookRun({
53
+ argv: argv.slice(1),
54
+ stdout,
55
+ stderr,
56
+ ...(options.loadModule ? { loadModule: options.loadModule } : {}),
57
+ ...(options.createAgent ? { createAgent: options.createAgent } : {}),
58
+ ...(options.readStdin ? { readStdin: options.readStdin } : {}),
59
+ ...(options.sessionsDir ? { sessionsDir: options.sessionsDir } : {}),
60
+ });
61
+ }
62
+
42
63
  const spawnFn = options.spawn ?? spawn;
43
64
  const tmuxPlayBin = options.tmuxPlayBin ?? resolveTmuxPlayBin();
44
- const loadModule = options.loadModule ?? ((specifier) => import(specifier));
45
65
  const home = options.homeDir ?? env.HOME ?? homedir();
46
66
  const userConfigPath = resolveUserConfigPath(env, home);
47
67
 
@@ -52,6 +72,25 @@ export async function runPlaybookCli(options = {}) {
52
72
  return { code: 0 };
53
73
  }
54
74
 
75
+ // PBCLI-25/26: `--with <path>` overlays are launcher-owned — consumed
76
+ // here, never forwarded to tmux-play, and incompatible with a raw
77
+ // `--config` launch, which bypasses the composition they target.
78
+ let withPaths;
79
+ let forwardArgv;
80
+ try {
81
+ ({ withPaths, rest: forwardArgv } = extractWithFlags(argv));
82
+ } catch (error) {
83
+ stderr.write(`playbook: ${errorMessage(error)}\n`);
84
+ return { code: COMPOSITION_FAILURE_EXIT_CODE };
85
+ }
86
+ if (withPaths.length > 0 && hasExplicitConfig(argv)) {
87
+ stderr.write(
88
+ 'playbook: --with overlays the top-level config and cannot combine ' +
89
+ 'with a raw --config launch\n',
90
+ );
91
+ return { code: COMPOSITION_FAILURE_EXIT_CODE };
92
+ }
93
+
55
94
  // PBCLI-1: explicit `--config <path>` launches that raw tmux-play config
56
95
  // directly, bypassing seeding, composition, and the readiness gate.
57
96
  if (hasExplicitConfig(argv)) {
@@ -62,10 +101,16 @@ export async function runPlaybookCli(options = {}) {
62
101
 
63
102
  let composed;
64
103
  try {
65
- composed = await composeGenericConfig(
66
- parseYaml(readFileSync(userConfigPath, 'utf8')) ?? {},
67
- loadModule,
68
- );
104
+ let top = parseYaml(readFileSync(userConfigPath, 'utf8')) ?? {};
105
+ if (withPaths.length > 0 && !isObject(top)) {
106
+ throw new Error(
107
+ `the top-level config at ${userConfigPath} must be a YAML map before --with can overlay it`,
108
+ );
109
+ }
110
+ for (const overlayPath of withPaths) {
111
+ top = mergeConfigs(top, loadOverlayFragment(overlayPath));
112
+ }
113
+ composed = await composeGenericConfig(top, loadModule);
69
114
  } catch (error) {
70
115
  stderr.write(`playbook: ${errorMessage(error)}\n`);
71
116
  return { code: COMPOSITION_FAILURE_EXIT_CODE };
@@ -104,7 +149,7 @@ export async function runPlaybookCli(options = {}) {
104
149
  try {
105
150
  return await launchTmuxPlay(
106
151
  spawnFn,
107
- [tmuxPlayBin, '--config', composedPath, ...argv],
152
+ [tmuxPlayBin, '--config', composedPath, ...forwardArgv],
108
153
  stderr,
109
154
  );
110
155
  } finally {
@@ -112,6 +157,72 @@ export async function runPlaybookCli(options = {}) {
112
157
  }
113
158
  }
114
159
 
160
+ // PBCLI-26: split `--with <path>` pairs out of the argument vector so
161
+ // they are consumed by the launcher rather than forwarded to tmux-play.
162
+ function extractWithFlags(argv) {
163
+ const withPaths = [];
164
+ const rest = [];
165
+ for (let i = 0; i < argv.length; i += 1) {
166
+ const arg = argv[i];
167
+ if (arg === '--with') {
168
+ const value = argv[i + 1];
169
+ if (value === undefined || value === '') {
170
+ throw new Error('--with needs a value');
171
+ }
172
+ withPaths.push(value);
173
+ i += 1;
174
+ } else if (arg.startsWith('--with=')) {
175
+ const value = arg.slice('--with='.length);
176
+ if (!value) throw new Error('--with needs a value');
177
+ withPaths.push(value);
178
+ } else {
179
+ rest.push(arg);
180
+ }
181
+ }
182
+ return { withPaths, rest };
183
+ }
184
+
185
+ // PBCLI-25: an overlay fragment is a top-level-format YAML map.
186
+ function loadOverlayFragment(overlayPath) {
187
+ const resolved = resolve(overlayPath);
188
+ let text;
189
+ try {
190
+ text = readFileSync(resolved, 'utf8');
191
+ } catch (error) {
192
+ throw new Error(
193
+ `cannot read --with overlay ${overlayPath}: ${errorMessage(error)}`,
194
+ );
195
+ }
196
+ let fragment;
197
+ try {
198
+ fragment = parseYaml(text);
199
+ } catch (error) {
200
+ throw new Error(
201
+ `cannot parse --with overlay ${overlayPath}: ${errorMessage(error)}`,
202
+ );
203
+ }
204
+ if (!isObject(fragment)) {
205
+ throw new Error(`--with overlay ${overlayPath} must be a YAML map`);
206
+ }
207
+ return fragment;
208
+ }
209
+
210
+ // PBCLI-25/26: recursive merge for plain maps, replacement for every
211
+ // other value; neither input is mutated. Object.fromEntries defines own
212
+ // data properties, so a hostile fragment key such as __proto__ cannot
213
+ // reach the prototype.
214
+ function mergeConfigs(base, overlay) {
215
+ return Object.fromEntries([
216
+ ...Object.entries(base),
217
+ ...Object.entries(overlay).map(([key, value]) => [
218
+ key,
219
+ isObject(base[key]) && isObject(value)
220
+ ? mergeConfigs(base[key], value)
221
+ : value,
222
+ ]),
223
+ ]);
224
+ }
225
+
115
226
  export function resolveConfigHome(env = process.env, home = homedir()) {
116
227
  return env.XDG_CONFIG_HOME || join(home, '.config');
117
228
  }
@@ -152,9 +263,6 @@ function isValidRegistryEntry(value) {
152
263
  typeof value.command === 'string' &&
153
264
  typeof value.intent === 'string' &&
154
265
  Array.isArray(value.requiredRoleIds) &&
155
- typeof value.idleStateId === 'string' &&
156
- typeof value.finalStateId === 'string' &&
157
- Array.isArray(value.parkStateIds) &&
158
266
  typeof value.validateOptions === 'function' &&
159
267
  typeof value.createRuntime === 'function'
160
268
  );
@@ -196,6 +304,11 @@ export async function composeGenericConfig(top, loadModule) {
196
304
  let firstVisible;
197
305
 
198
306
  for (const id of ids) {
307
+ if (id === RESERVED_CAPTAIN_PLAYBOOK_ID) {
308
+ throw new Error(
309
+ `playbooks.${id} collides with the reserved internal Captain id`,
310
+ );
311
+ }
199
312
  const block = requireObject(playbooksCfg[id], `playbooks.${id}`);
200
313
  const from = block.from;
201
314
  if (typeof from !== 'string' || from.length === 0) {
@@ -229,13 +342,35 @@ export async function composeGenericConfig(top, loadModule) {
229
342
  typeof block.command === 'string' && block.command.length > 0
230
343
  ? block.command
231
344
  : entry.command;
345
+ if (command === RESERVED_CAPTAIN_PLAYBOOK_ID) {
346
+ throw new Error(
347
+ `playbooks.${id}.command collides with the reserved internal Captain command`,
348
+ );
349
+ }
232
350
  if (seenCommands.has(command)) {
233
351
  throw new Error(`duplicate effective command "${command}"`);
234
352
  }
235
353
  seenCommands.set(command, id);
236
354
 
355
+ // PBCLI-9: reject the reserved role before the coverage checks below, so
356
+ // an entry requiring `captain` names the real fault rather than a missing
357
+ // players entry.
358
+ if (entry.requiredRoleIds.includes(RESERVED_CAPTAIN_ROLE_ID)) {
359
+ throw new Error(
360
+ `playbooks.${id} requires local role "${RESERVED_CAPTAIN_ROLE_ID}", ` +
361
+ 'which is reserved for the tmux-play Captain',
362
+ );
363
+ }
364
+
237
365
  const playersMap = requireObject(block.players, `playbooks.${id}.players`);
238
366
  const roles = Object.keys(playersMap);
367
+ if (roles.includes(RESERVED_CAPTAIN_ROLE_ID)) {
368
+ throw new Error(
369
+ `playbooks.${id}.players.${RESERVED_CAPTAIN_ROLE_ID} binds local ` +
370
+ `role "${RESERVED_CAPTAIN_ROLE_ID}", which is reserved for the ` +
371
+ 'tmux-play Captain',
372
+ );
373
+ }
239
374
  if (roles.length === 0) {
240
375
  throw new Error(`playbooks.${id} resolves no visible local role`);
241
376
  }
@@ -348,11 +483,18 @@ function helpText({ userConfigPath, failingAdapters = [] }) {
348
483
  return [
349
484
  ...failures,
350
485
  'Usage:',
351
- ' playbook [--list] [--config <path>] [tmux-play options]',
486
+ ' playbook [--list] [--with <path>]... [--config <path>] [tmux-play options]',
487
+ ' playbook run <from> [task] [options] # non-interactive one-shot',
488
+ ' playbook run resume <session-id> [reply] # answer a parked run',
352
489
  ' playbook --help',
353
490
  '',
354
491
  `Default config: ${userConfigPath}`,
355
492
  '',
493
+ ' --with <path> overlays a top-level config fragment (same format as',
494
+ ' the default config) over the default config for this launch only —',
495
+ ' maps merge recursively, other values replace, later files win. The',
496
+ ' default config file is never modified.',
497
+ '',
356
498
  'Adapter setup:',
357
499
  ' claude: run Claude Code once or set ANTHROPIC_API_KEY.',
358
500
  ' codex: run Codex CLI once or set OPENAI_API_KEY.',