@sublang/playbook 17.4.1 → 17.5.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/docs/cli.md CHANGED
@@ -181,6 +181,7 @@ stderr, and `--verbose` adds only telemetry topic names to stderr.
181
181
  | Flag | Meaning |
182
182
  | --- | --- |
183
183
  | `--with <path>` | overlay current config for a fresh session or compatible ordinary reopen; repeatable |
184
+ | `--module <id>=<specifier>` | supply playbook `<id>`'s registry module ahead of its `playbooks.<id>.from`; repeatable, one per id; a relative path resolves against the current directory |
184
185
  | `--no-provision` | do not create missing engine links for configured filesystem registries |
185
186
  | `--json` | print exactly one `sessionId` / `reply` object |
186
187
  | `--verbose` | add Captain telemetry topic names to stderr |
@@ -243,6 +244,26 @@ A directory where both imports already resolve is untouched, and
243
244
  module's directory is a git repository, add `node_modules/` to its
244
245
  `.gitignore` so provisioned links never enter player commits.
245
246
 
247
+ A launch can also supply a playbook's module, which is never written back to
248
+ your config files, for example when an embedding host keeps each playbook
249
+ installed in an environment of its own. `--module <id>=<specifier>` (or
250
+ `--module=<id>=<specifier>`) is accepted by `playbook`, `playbook --session`,
251
+ `--list`, `--theme-diagnostics`, and fresh or continuing `playbook run`, and
252
+ wins over that playbook's `from` the way a `--with` fragment wins over the
253
+ file. An absolute path becomes a file URL, a relative path resolves against
254
+ the directory where you run the command, and a file URL or package specifier
255
+ is kept. The flag is never forwarded to tmux-play or sent as Boss input; it is
256
+ refused beside a raw `--config`, during uncertain-turn recovery, for an id
257
+ given twice, and for an id the config does not enable
258
+ ([[playbook-cli-94](https://github.com/sublang-ai/playbook/blob/main/specs/packages/playbook-cli.md#playbook-cli-94)],
259
+ [[playbook-cli-96](https://github.com/sublang-ai/playbook/blob/main/specs/packages/playbook-cli.md#playbook-cli-96)],
260
+ [DR-083](https://github.com/sublang-ai/playbook/blob/main/specs/decisions/083-module-locations-supplied-at-launch.md)).
261
+
262
+ ```sh
263
+ playbook --module code=/opt/env/code/registry.mjs
264
+ playbook run --module review=@sublang/playbook/review/registry "/review the last commit"
265
+ ```
266
+
246
267
  ### Continuing a Captain session
247
268
 
248
269
  Interactive and headless commands write the same logical-session records in
@@ -279,9 +300,15 @@ competing front end fails closed instead of forking the history.
279
300
 
280
301
  An ordinary reopen reads current config and any opening `--with` fragments,
281
302
  projects them to the stored catalog and player roster, and requires the stored
282
- role bindings plus every structural setting to remain exact. Compatible
283
- current `model`, `effort`, and optional `fastMode`, `subagentModel`, and
284
- `subagentEffort` settings apply to the next call. Boolean `false` explicitly
303
+ role bindings plus every structural setting to remain exact. A playbook whose
304
+ current config names no `from` and receives no `--module` keeps the module the
305
+ session records. Otherwise the module so taken — the supplied one, else a
306
+ present `from` — must equal the stored module; a present `from` beside a
307
+ supplied module is checked for form only. A `--module` for a playbook your
308
+ config enables but the session does not hold is ignored, while one naming no
309
+ playbook of your config is refused. Compatible current `model`, `effort`, and
310
+ optional `fastMode`, `subagentModel`, and `subagentEffort` settings apply to
311
+ the next call. Boolean `false` explicitly
285
312
  resets model, effort, subagent model, or subagent effort to the provider
286
313
  default, but `fastMode: false` is a literal
287
314
  disabled request; omission selects the provider default or inherits the player
@@ -57,8 +57,11 @@ preflight their auth.
57
57
 
58
58
  Within a `playbooks.<id>` block, `from` (the registry module), `command` (an
59
59
  optional slash-command override), and `roles` are launcher-owned; every other
60
- key is that playbook's option slice. Every manifest role must be present
61
- exactly once. The launcher injects the rest — you do not write host wiring by
60
+ key is that playbook's option slice. `from` is required unless the launch
61
+ supplies that playbook's module with `--module <id>=<specifier>` (or an
62
+ embedding host's `modules` option), or a reopened session already records
63
+ one; see [Modules supplied at launch](#modules-supplied-at-launch). Every
64
+ manifest role must be present exactly once. The launcher injects the rest — you do not write host wiring by
62
65
  hand.
63
66
 
64
67
  The launcher seeds one adapter for the Captain and all stable players from
@@ -422,6 +425,33 @@ Before either front end imports a filesystem registry, the shared launcher
422
425
  checks and, unless `--no-provision` is set, provisions its runtime engine
423
426
  links as described in [Using the CLI](cli.md#external-playbooks-and-engine-provisioning).
424
427
 
428
+ ### Modules supplied at launch
429
+
430
+ A config may leave `playbooks.<id>.from` out when the launch supplies the
431
+ module instead, so the shared file names no machine-specific path:
432
+
433
+ ```yaml
434
+ playbooks:
435
+ my-workflow:
436
+ roles:
437
+ worker: my.worker
438
+ ```
439
+
440
+ ```sh
441
+ playbook run --module my-workflow=/opt/env/my-workflow.js "/my-workflow perform the task"
442
+ ```
443
+
444
+ A supplied module wins over a `from` the file does carry, and it is never
445
+ written back to your config files. A playbook with neither is refused before any import:
446
+ `playbooks.<id> names no module: set playbooks.<id>.from or supply a module for
447
+ <id> (--module <id>=<specifier>)`. A malformed `from` is still refused even
448
+ when a module is supplied. Reopening a recorded session needs neither: with
449
+ no `from` and no `--module`, it uses the module the session already records.
450
+ Otherwise the module so taken — the supplied one, else a present `from` — must
451
+ equal the stored module; a present `from` beside a supplied module is checked
452
+ for form only
453
+ ([DR-083](https://github.com/sublang-ai/playbook/blob/main/specs/decisions/083-module-locations-supplied-at-launch.md)).
454
+
425
455
  ## Migrating per-playbook players
426
456
 
427
457
  The former `playbooks.<id>.players` shape made agent configuration and local
package/docs/embedding.md CHANGED
@@ -402,6 +402,35 @@ The controller owns uncertainty, reconciliation, settlement and lease release;
402
402
  observers receive presentation events or exact appended envelopes through
403
403
  `onStoredRecord`. Keep the controller open for successive turns.
404
404
 
405
+ `loadLaunchPlan()` and `normalizeLaunchPlan()` accept an optional `modules`
406
+ option, a read-only record from playbook id to module specifier. A host that
407
+ keeps each playbook installed somewhere of its own hands the module locations
408
+ there instead of writing them into the shared config, whose
409
+ `playbooks.<id>.from` may then be left out:
410
+
411
+ ```ts
412
+ import { loadLaunchPlan } from '@sublang/playbook/session-host';
413
+
414
+ const plan = await loadLaunchPlan({
415
+ userConfigPath,
416
+ modules: { code: '/opt/env/code/registry.mjs', review: '@sublang/playbook/review/registry' },
417
+ });
418
+ ```
419
+
420
+ Each enabled playbook takes `modules[id]` ahead of its `from`. An absolute
421
+ path becomes a file URL, while a file URL or package specifier is kept; a
422
+ relative path is refused, so resolve it against your own working directory
423
+ first. On a new launch, a module naming no enabled playbook is refused before
424
+ any import; with `selectedMembers`, one for a current playbook outside the
425
+ selection is skipped, while one naming no current playbook is still refused.
426
+ These loaders hold no stored session record, so they refuse a playbook with
427
+ neither a supplied module nor `from`, with or without `selectedMembers`; only
428
+ the CLI's reopen of a recorded session falls back to the module that record
429
+ holds. The preparation hook, single import, registry checks and the plan's
430
+ canonical `from` are unchanged, and a supplied module is never written back to
431
+ your config files
432
+ ([DR-083](https://github.com/sublang-ai/playbook/blob/main/specs/decisions/083-module-locations-supplied-at-launch.md)).
433
+
405
434
  `shared.readLeaseState(sessionId)` reports `active`, `idle` or `unknown` without
406
435
  changing files. Use it for presentation; mutations still require a lease.
407
436
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sublang/playbook",
3
- "version": "17.4.1",
3
+ "version": "17.5.0",
4
4
  "type": "module",
5
5
  "description": "Composable XState v5 playbook runtime with compiled Captain, CODE, REVIEW, DECIDE, DEV, BRANCH, PR, and INSPECT workflows driven by GEARS specs.",
6
6
  "license": "Apache-2.0",
@@ -84,6 +84,73 @@ export function extractWithFlags(argv) {
84
84
  return { withPaths, rest };
85
85
  }
86
86
 
87
+ // PBCLI-94 (DR-083): one `--module` value names `<id>=<specifier>`, split at
88
+ // its first `=` so a specifier may itself carry one.
89
+ export function parseModuleFlagValue(value) {
90
+ if (typeof value !== "string" || value.length === 0) {
91
+ throw new Error("--module needs an <id>=<specifier> value");
92
+ }
93
+ const separator = value.indexOf("=");
94
+ if (separator < 0) {
95
+ throw new Error(
96
+ `--module ${JSON.stringify(value)} must be <id>=<specifier>`,
97
+ );
98
+ }
99
+ const id = value.slice(0, separator);
100
+ const specifier = value.slice(separator + 1);
101
+ if (id.length === 0) {
102
+ throw new Error(`--module ${JSON.stringify(value)} names no playbook id`);
103
+ }
104
+ if (specifier.length === 0) {
105
+ throw new Error(
106
+ `--module ${JSON.stringify(value)} names no module specifier`,
107
+ );
108
+ }
109
+ return [id, specifier];
110
+ }
111
+
112
+ // PBCLI-94 (DR-083): split ordered `--module <id>=<specifier>` pairs out of an
113
+ // argument vector, beside `--with`. The caller's vector is never changed.
114
+ export function extractModuleFlags(argv) {
115
+ const modulePairs = [];
116
+ const rest = [];
117
+ for (let i = 0; i < argv.length; i += 1) {
118
+ const arg = argv[i];
119
+ if (arg === "--module") {
120
+ modulePairs.push(parseModuleFlagValue(argv[i + 1]));
121
+ i += 1;
122
+ } else if (arg.startsWith("--module=")) {
123
+ modulePairs.push(parseModuleFlagValue(arg.slice("--module=".length)));
124
+ } else {
125
+ rest.push(arg);
126
+ }
127
+ }
128
+ return { modulePairs, rest };
129
+ }
130
+
131
+ // PBCLI-94 (DR-083): the CLI's supplied modules, one per playbook id. A
132
+ // relative filesystem path resolves against the invocation's working
133
+ // directory here, because the loader refuses relative paths.
134
+ export function suppliedModulesFromFlags(modulePairs, cwd = process.cwd()) {
135
+ const seen = new Set();
136
+ const entries = [];
137
+ for (const [id, specifier] of modulePairs) {
138
+ if (seen.has(id)) {
139
+ throw new Error(
140
+ `--module names playbook ${JSON.stringify(id)} more than once`,
141
+ );
142
+ }
143
+ seen.add(id);
144
+ entries.push([
145
+ id,
146
+ isRelativeFilesystemRegistrySpecifier(specifier)
147
+ ? resolve(cwd, specifier)
148
+ : specifier,
149
+ ]);
150
+ }
151
+ return Object.freeze(Object.fromEntries(entries));
152
+ }
153
+
87
154
  // PBCLI-25: an overlay fragment is a top-level-format YAML map.
88
155
  export function loadOverlayFragment(overlayPath) {
89
156
  const resolved = resolve(overlayPath);
@@ -244,6 +311,76 @@ export function canonicalizeRegistrySpecifier(from, configPath) {
244
311
  return from;
245
312
  }
246
313
 
314
+ // PBCLI-94 (DR-083): validate the loader's `modules` option — a read-only
315
+ // record from playbook id to module specifier — and canonicalize each value by
316
+ // the configured-module rules, except that a relative path has no config
317
+ // directory to anchor it and is refused. Every id must name a playbook of the
318
+ // current config; an ordinary reopen skips one the stored catalog lacks.
319
+ function resolveSuppliedModules(modules, enabledIds, currentIds) {
320
+ const supplied = new Map();
321
+ if (modules === undefined) return supplied;
322
+ const record = cloneJson(modules, "modules");
323
+ if (!isPlainObject(record)) {
324
+ throw new Error(
325
+ "modules must be a record from playbook id to module specifier",
326
+ );
327
+ }
328
+ for (const [id, specifier] of Object.entries(record)) {
329
+ if (!currentIds.has(id)) {
330
+ throw new Error(
331
+ `a module was supplied for ${JSON.stringify(id)}, which names no enabled playbook`,
332
+ );
333
+ }
334
+ if (
335
+ typeof specifier !== "string" ||
336
+ specifier.trim().length === 0 ||
337
+ specifier !== specifier.trim()
338
+ ) {
339
+ throw new Error(
340
+ `the module supplied for ${id} must be a canonical trimmed module specifier`,
341
+ );
342
+ }
343
+ if (isRelativeFilesystemRegistrySpecifier(specifier)) {
344
+ throw new Error(
345
+ `the module supplied for ${id} is a relative path; supply an absolute path, a file URL, or a package specifier`,
346
+ );
347
+ }
348
+ // A current playbook outside the stored catalog is ignored on a reopen,
349
+ // once its value has passed the checks above.
350
+ if (!enabledIds.includes(id)) continue;
351
+ supplied.set(
352
+ id,
353
+ !specifier.startsWith("file:") && isAbsolute(specifier)
354
+ ? pathToFileURL(specifier).href
355
+ : specifier,
356
+ );
357
+ }
358
+ return supplied;
359
+ }
360
+
361
+ // PBCLI-94 (DR-083): add the ids a layer's own `playbooks` map holds, read as
362
+ // keys only, so a reopen can name a stray supplied id without inspecting any
363
+ // member outside its stored selection.
364
+ function addCurrentPlaybookIds(layer, ids = new Set()) {
365
+ if (!isPlainObject(layer)) return ids;
366
+ const descriptor = Object.getOwnPropertyDescriptor(layer, "playbooks");
367
+ if (
368
+ descriptor === undefined ||
369
+ !hasOwn(descriptor, "value") ||
370
+ !isPlainObject(descriptor.value)
371
+ ) {
372
+ return ids;
373
+ }
374
+ for (const id of Object.keys(descriptor.value)) ids.add(id);
375
+ return ids;
376
+ }
377
+
378
+ function missingModuleError(id) {
379
+ return new Error(
380
+ `playbooks.${id} names no module: set playbooks.${id}.from or supply a module for ${id} (--module ${id}=<specifier>)`,
381
+ );
382
+ }
383
+
247
384
  function isRelativeFilesystemRegistrySpecifier(from) {
248
385
  return (
249
386
  from.startsWith("./") ||
@@ -264,6 +401,7 @@ export async function loadLaunchPlan({
264
401
  templatePath = DEFAULT_TEMPLATE_PATH,
265
402
  onNotice = () => {},
266
403
  selectedMembers,
404
+ modules,
267
405
  env = process.env,
268
406
  homeDir = env.HOME ?? homedir(),
269
407
  }) {
@@ -280,19 +418,26 @@ export async function loadLaunchPlan({
280
418
  `the top-level config at ${userConfigPath} must be a YAML map before --with can overlay it`,
281
419
  );
282
420
  }
421
+ const currentPlaybookIds = addCurrentPlaybookIds(top);
283
422
  for (const overlayPath of overlayPaths) {
284
423
  const overlay = loadOverlayFragment(overlayPath);
424
+ addCurrentPlaybookIds(overlay, currentPlaybookIds);
285
425
  top =
286
426
  selectedMembers === undefined
287
427
  ? mergeConfigs(top, overlay)
288
428
  : mergeSelectedConfigs(top, overlay, selectedMembers);
289
429
  }
290
- return await normalizeLaunchPlan(top, {
291
- loadModule,
292
- configPath: userConfigPath,
293
- prepareRegistryModule,
294
- selectedMembers,
295
- });
430
+ return await normalizeLaunchPlanWith(
431
+ top,
432
+ {
433
+ loadModule,
434
+ configPath: userConfigPath,
435
+ prepareRegistryModule,
436
+ selectedMembers,
437
+ modules,
438
+ },
439
+ currentPlaybookIds,
440
+ );
296
441
  }
297
442
 
298
443
  // PBCLI-22/49: an interactive selected launch may project current tuning and
@@ -304,6 +449,7 @@ export async function loadSelectedLaunchPlanDataOnly({
304
449
  userConfigPath,
305
450
  overlayPaths = [],
306
451
  structuralProjection,
452
+ modules,
307
453
  templatePath = DEFAULT_TEMPLATE_PATH,
308
454
  onNotice = () => {},
309
455
  env = process.env,
@@ -319,28 +465,38 @@ export async function loadSelectedLaunchPlanDataOnly({
319
465
  `the top-level config at ${userConfigPath} must be a YAML map before --with can overlay it`,
320
466
  );
321
467
  }
468
+ const currentPlaybookIds = addCurrentPlaybookIds(top);
322
469
  top = projectSelectedLayer(
323
470
  top,
324
471
  validateSelectedMembers(selectedMembers),
325
472
  "config",
326
473
  );
327
474
  for (const overlayPath of overlayPaths) {
328
- top = mergeSelectedConfigs(
329
- top,
330
- loadOverlayFragment(overlayPath),
331
- selectedMembers,
332
- );
475
+ const overlay = loadOverlayFragment(overlayPath);
476
+ addCurrentPlaybookIds(overlay, currentPlaybookIds);
477
+ top = mergeSelectedConfigs(top, overlay, selectedMembers);
333
478
  }
334
- return await normalizeSelectedLaunchPlanDataOnly(top, {
335
- configPath: userConfigPath,
336
- stored,
337
- selectedMembers,
338
- });
479
+ return await normalizeSelectedLaunchPlanDataOnlyWith(
480
+ top,
481
+ { configPath: userConfigPath, stored, selectedMembers, modules },
482
+ currentPlaybookIds,
483
+ );
339
484
  }
340
485
 
341
- export async function normalizeSelectedLaunchPlanDataOnly(
486
+ export async function normalizeSelectedLaunchPlanDataOnly(top, options = {}) {
487
+ return await normalizeSelectedLaunchPlanDataOnlyWith(
488
+ top,
489
+ options,
490
+ addCurrentPlaybookIds(top),
491
+ );
492
+ }
493
+
494
+ // `currentPlaybookIds` names every playbook of the current merged config,
495
+ // gathered before the layers were projected to the stored selection.
496
+ async function normalizeSelectedLaunchPlanDataOnlyWith(
342
497
  top,
343
- { configPath, stored, selectedMembers } = {},
498
+ { configPath, stored, selectedMembers, modules } = {},
499
+ currentPlaybookIds,
344
500
  ) {
345
501
  stored = validateStoredStructuralProjection(stored);
346
502
  const expectedMembers = selectedMembersFromStoredStructure(stored);
@@ -390,6 +546,11 @@ export async function normalizeSelectedLaunchPlanDataOnly(
390
546
  );
391
547
 
392
548
  const playbooksCfg = requireObject(top.playbooks, "playbooks");
549
+ const suppliedModules = resolveSuppliedModules(
550
+ modules,
551
+ expectedMembers.playbookIds,
552
+ currentPlaybookIds,
553
+ );
393
554
  const tuningChecks = [];
394
555
  let tuningCheckIndex = 0;
395
556
  const authored = new Map();
@@ -400,20 +561,30 @@ export async function normalizeSelectedLaunchPlanDataOnly(
400
561
  throw legacyPlayersError(`playbooks.${id}.players`, configPath);
401
562
  }
402
563
  rejectConfiguredHostCapabilities(block, `playbooks.${id}`);
564
+ const hasFrom = hasOwn(block, "from");
403
565
  if (
404
- typeof block.from !== "string" ||
405
- block.from.trim().length === 0 ||
406
- block.from !== block.from.trim()
566
+ hasFrom &&
567
+ (typeof block.from !== "string" ||
568
+ block.from.trim().length === 0 ||
569
+ block.from !== block.from.trim())
407
570
  ) {
408
571
  throw new Error(
409
572
  `playbooks.${id}.from must be a canonical trimmed module specifier`,
410
573
  );
411
574
  }
412
- const configuredFrom = canonicalizeRegistrySpecifier(
413
- block.from,
414
- configPath,
415
- );
416
- if (configuredFrom !== storedItem.from) {
575
+ // PBCLI-22/94 (DR-083): a supplied module wins over `from`; with neither,
576
+ // the reopen takes the module the session already records.
577
+ const suppliedModule = suppliedModules.get(id);
578
+ if (suppliedModule !== undefined) {
579
+ if (suppliedModule !== storedItem.from) {
580
+ throw new Error(
581
+ `the module supplied for ${id} differs from the stored structural projection`,
582
+ );
583
+ }
584
+ } else if (
585
+ hasFrom &&
586
+ canonicalizeRegistrySpecifier(block.from, configPath) !== storedItem.from
587
+ ) {
417
588
  throw new Error(
418
589
  `playbooks.${id}.from changed from the stored structural projection`,
419
590
  );
@@ -663,9 +834,26 @@ function rejectConfiguredHostCapabilities(value, path) {
663
834
  // PBCLI-46: normalize into a detached, deeply frozen JSON plan. The plan has
664
835
  // only execution data and presentation data; imported registry functions are
665
836
  // consulted for validation and then discarded.
666
- export async function normalizeLaunchPlan(
837
+ export async function normalizeLaunchPlan(top, options = {}) {
838
+ return await normalizeLaunchPlanWith(
839
+ top,
840
+ options,
841
+ addCurrentPlaybookIds(top),
842
+ );
843
+ }
844
+
845
+ // `currentPlaybookIds` names every playbook of the current merged config,
846
+ // gathered before a reopen projected the layers to its selection.
847
+ async function normalizeLaunchPlanWith(
667
848
  top,
668
- { loadModule, configPath, prepareRegistryModule, selectedMembers } = {},
849
+ {
850
+ loadModule,
851
+ configPath,
852
+ prepareRegistryModule,
853
+ selectedMembers,
854
+ modules,
855
+ } = {},
856
+ currentPlaybookIds,
669
857
  ) {
670
858
  const importModule = loadModule ?? ((specifier) => import(specifier));
671
859
  top = projectSelectedMembers(top, selectedMembers);
@@ -714,6 +902,13 @@ export async function normalizeLaunchPlan(
714
902
  if (ids.some((id) => id.trim().length === 0 || id !== id.trim())) {
715
903
  throw new Error("playbooks keys must be canonical trimmed nonblank ids");
716
904
  }
905
+ // PBCLI-94 (DR-083): supplied modules are checked before any preparation
906
+ // or import; an ordinary reopen skips a current playbook it does not hold.
907
+ const suppliedModules = resolveSuppliedModules(
908
+ modules,
909
+ ids,
910
+ selectedMembers === undefined ? new Set(ids) : currentPlaybookIds,
911
+ );
717
912
 
718
913
  let captain = resolveDelegationDefault(
719
914
  resolveAgent(top.captain, "captain", ["from", "options"]),
@@ -741,16 +936,28 @@ export async function normalizeLaunchPlan(
741
936
  throw legacyPlayersError(`playbooks.${id}.players`, configPath);
742
937
  }
743
938
  rejectConfiguredHostCapabilities(block, `playbooks.${id}`);
744
- const from = block.from;
939
+ const hasFrom = hasOwn(block, "from");
745
940
  if (
746
- typeof from !== "string" ||
747
- from.trim().length === 0 ||
748
- from !== from.trim()
941
+ hasFrom &&
942
+ (typeof block.from !== "string" ||
943
+ block.from.trim().length === 0 ||
944
+ block.from !== block.from.trim())
749
945
  ) {
750
946
  throw new Error(
751
947
  `playbooks.${id}.from must be a canonical trimmed module specifier`,
752
948
  );
753
949
  }
950
+ // PBCLI-9/94 (DR-083): the supplied module wins over `from`, as a --with
951
+ // fragment wins over the file; with neither, the playbook names no module.
952
+ const suppliedModule = suppliedModules.get(id);
953
+ if (!hasFrom && suppliedModule === undefined) {
954
+ throw missingModuleError(id);
955
+ }
956
+ const from = suppliedModule ?? block.from;
957
+ const fromLabel =
958
+ suppliedModule === undefined
959
+ ? `playbooks.${id}.from`
960
+ : `the module supplied for ${id}`;
754
961
  if (
755
962
  block.command !== undefined &&
756
963
  (typeof block.command !== "string" ||
@@ -816,10 +1023,12 @@ export async function normalizeLaunchPlan(
816
1023
  ([key]) => !PLAYBOOK_LAUNCHER_KEYS.includes(key),
817
1024
  ),
818
1025
  );
819
- const configuredFrom = canonicalizeRegistrySpecifier(from, configPath);
1026
+ const configuredFrom =
1027
+ suppliedModule ?? canonicalizeRegistrySpecifier(from, configPath);
820
1028
  configuredPlaybooks.push({
821
1029
  id,
822
1030
  from,
1031
+ fromLabel,
823
1032
  configuredFrom,
824
1033
  commandOverride: block.command,
825
1034
  roles,
@@ -906,7 +1115,7 @@ export async function normalizeLaunchPlan(
906
1115
  // registry modules evaluated and others untouched.
907
1116
  const preparedPlaybooks = [];
908
1117
  for (const configured of configuredPlaybooks) {
909
- const { id, from, configuredFrom } = configured;
1118
+ const { id, from, fromLabel, configuredFrom } = configured;
910
1119
  let preparedFrom = configuredFrom;
911
1120
  if (prepareRegistryModule !== undefined) {
912
1121
  try {
@@ -919,7 +1128,7 @@ export async function normalizeLaunchPlan(
919
1128
  if (prepared !== undefined) preparedFrom = prepared;
920
1129
  } catch (cause) {
921
1130
  throw new Error(
922
- `playbooks.${id}.from "${from}" failed to prepare: ${errorMessage(cause)}`,
1131
+ `${fromLabel} "${from}" failed to prepare: ${errorMessage(cause)}`,
923
1132
  );
924
1133
  }
925
1134
  if (
@@ -927,13 +1136,13 @@ export async function normalizeLaunchPlan(
927
1136
  preparedFrom.trim().length === 0
928
1137
  ) {
929
1138
  throw new Error(
930
- `playbooks.${id}.from "${from}" preparation must return a module specifier`,
1139
+ `${fromLabel} "${from}" preparation must return a module specifier`,
931
1140
  );
932
1141
  }
933
1142
  }
934
1143
  preparedFrom = canonicalizePreparedRegistrySpecifier(
935
1144
  preparedFrom,
936
- `playbooks.${id}.from`,
1145
+ fromLabel,
937
1146
  );
938
1147
  preparedPlaybooks.push({ ...configured, preparedFrom });
939
1148
  }
@@ -945,6 +1154,7 @@ export async function normalizeLaunchPlan(
945
1154
  for (const {
946
1155
  id,
947
1156
  from,
1157
+ fromLabel,
948
1158
  preparedFrom,
949
1159
  commandOverride,
950
1160
  roles,
@@ -956,14 +1166,14 @@ export async function normalizeLaunchPlan(
956
1166
  mod = await importModule(preparedFrom);
957
1167
  } catch (cause) {
958
1168
  throw new Error(
959
- `playbooks.${id}.from "${from}" failed to import: ${errorMessage(cause)}`,
1169
+ `${fromLabel} "${from}" failed to import: ${errorMessage(cause)}`,
960
1170
  );
961
1171
  }
962
1172
  const entry = snapshotRegistryEntry(mod?.default);
963
1173
  const registryProblem = invalidRegistryEntryReason(entry);
964
1174
  if (registryProblem !== undefined) {
965
1175
  throw new Error(
966
- `playbooks.${id}.from "${from}" exposes no valid registry entry: ` +
1176
+ `${fromLabel} "${from}" exposes no valid registry entry: ` +
967
1177
  registryProblem,
968
1178
  );
969
1179
  }
@@ -18,10 +18,12 @@ import {
18
18
  } from "./adapter-sdk.js";
19
19
  import {
20
20
  adaptersFromLaunchPlan,
21
+ extractModuleFlags,
21
22
  extractWithFlags,
22
23
  loadLaunchPlan,
23
24
  loadSelectedLaunchPlanDataOnly,
24
25
  projectTmuxConfig,
26
+ suppliedModulesFromFlags,
25
27
  resolveLaunchSessionsDir,
26
28
  relocateLegacyUserConfig,
27
29
  resolveFormerUserConfigPaths,
@@ -54,15 +56,18 @@ export {
54
56
  checkReadiness,
55
57
  composeGenericConfig,
56
58
  deriveLaunchReadiness,
59
+ extractModuleFlags,
57
60
  extractWithFlags,
58
61
  loadLaunchPlan,
59
62
  loadOverlayFragment,
60
63
  mergeConfigs,
61
64
  migrateRetiredProfiles,
62
65
  normalizeLaunchPlan,
66
+ parseModuleFlagValue,
63
67
  projectTmuxConfig,
64
68
  resolveAgent,
65
69
  resolveUserConfigPath,
70
+ suppliedModulesFromFlags,
66
71
  } from "./launch-config.js";
67
72
 
68
73
  const READINESS_FAILURE_EXIT_CODE = 2;
@@ -188,10 +193,15 @@ export async function runPlaybookCli(options = {}) {
188
193
  // PBCLI-25/26: `--with <path>` overlays are launcher-owned — consumed
189
194
  // here, never forwarded to tmux-play, and incompatible with a raw
190
195
  // `--config` launch, which bypasses the composition they target.
196
+ // PBCLI-94 (DR-083): `--module <id>=<specifier>` is launcher-owned in the
197
+ // same way: consumed, never forwarded, and refused beside raw `--config`.
191
198
  let withPaths;
199
+ let modulePairs;
200
+ let modules;
192
201
  let forwardArgv;
193
202
  try {
194
203
  ({ withPaths, rest: forwardArgv } = extractWithFlags(argv));
204
+ ({ modulePairs, rest: forwardArgv } = extractModuleFlags(forwardArgv));
195
205
  } catch (error) {
196
206
  stderr.write(`playbook: ${errorMessage(error)}\n`);
197
207
  return { code: COMPOSITION_FAILURE_EXIT_CODE };
@@ -203,6 +213,22 @@ export async function runPlaybookCli(options = {}) {
203
213
  );
204
214
  return { code: COMPOSITION_FAILURE_EXIT_CODE };
205
215
  }
216
+ if (modulePairs.length > 0 && hasExplicitConfig(argv)) {
217
+ stderr.write(
218
+ "playbook: --module supplies a playbook module for the top-level " +
219
+ "config and cannot combine with a raw --config launch\n",
220
+ );
221
+ return { code: COMPOSITION_FAILURE_EXIT_CODE };
222
+ }
223
+ try {
224
+ modules = suppliedModulesFromFlags(
225
+ modulePairs,
226
+ options.cwd ?? process.cwd(),
227
+ );
228
+ } catch (error) {
229
+ stderr.write(`playbook: ${errorMessage(error)}\n`);
230
+ return { code: COMPOSITION_FAILURE_EXIT_CODE };
231
+ }
206
232
  const noProvision = forwardArgv.includes("--no-provision");
207
233
  forwardArgv = forwardArgv.filter((arg) => arg !== "--no-provision");
208
234
  if (noProvision && hasExplicitConfig(argv)) {
@@ -349,6 +375,7 @@ export async function runPlaybookCli(options = {}) {
349
375
  ? await loadSelectedLaunchPlanDataOnly({
350
376
  userConfigPath,
351
377
  overlayPaths: withPaths,
378
+ modules,
352
379
  structuralProjection: selectedRecord.structuralProjection,
353
380
  onNotice: (line) => stderr.write(line),
354
381
  env,
@@ -357,6 +384,7 @@ export async function runPlaybookCli(options = {}) {
357
384
  : await loadLaunchPlan({
358
385
  userConfigPath,
359
386
  overlayPaths: withPaths,
387
+ modules,
360
388
  env,
361
389
  homeDir: home,
362
390
  loadModule,
@@ -923,13 +951,17 @@ function helpText({
923
951
  ...sdkFailureLines,
924
952
  ...failures,
925
953
  "Usage:",
926
- " playbook [--with <path>]... [--no-provision] [--cwd <path>]",
927
- " playbook --session <id> [--with <path>]... [--no-provision]",
928
- " playbook --list [--with <path>]... [--no-provision]",
929
- " playbook --theme-diagnostics [--with <path>]... [--cwd <path>]",
954
+ " playbook [--with <path>]... [--module <id>=<specifier>]...",
955
+ " [--no-provision] [--cwd <path>]",
956
+ " playbook --session <id> [--with <path>]...",
957
+ " [--module <id>=<specifier>]... [--no-provision]",
958
+ " playbook --list [--with <path>]... [--module <id>=<specifier>]...",
959
+ " [--no-provision]",
960
+ " playbook --theme-diagnostics [--with <path>]...",
961
+ " [--module <id>=<specifier>]... [--cwd <path>]",
930
962
  " playbook --config <path> [tmux-play arguments...]",
931
- " playbook run [--with <path>]... [--no-provision] [--json]",
932
- " [--verbose] [--] [input]",
963
+ " playbook run [--with <path>]... [--module <id>=<specifier>]...",
964
+ " [--no-provision] [--json] [--verbose] [--] [input]",
933
965
  " playbook run (--continue | --session <id>) [reply]",
934
966
  " playbook run --session <id> --retry-uncertain",
935
967
  " playbook run --session <id> --discard-uncertain",
@@ -947,6 +979,11 @@ function helpText({
947
979
  " the default config) for a fresh launch or compatible ordinary reopen —",
948
980
  " maps merge recursively, other values replace, later files win, and the",
949
981
  " default config file is never modified.",
982
+ " --module <id>=<specifier> supplies playbook <id>'s registry module",
983
+ " ahead of its playbooks.<id>.from (repeatable, one per id); a relative",
984
+ " path resolves against the current directory, and the module is never",
985
+ " written back to your config files. Without --module or from, a",
986
+ " reopened session uses the module it already records.",
950
987
  " --no-provision keeps configured filesystem registries read-only;",
951
988
  " any missing engine links remain a launch error.",
952
989
  " `playbook run --verbose` prints Captain telemetry topics to stderr.",
@@ -39,12 +39,14 @@ import {
39
39
  invalidRegistryEntryReason,
40
40
  loadLaunchPlan,
41
41
  loadSelectedLaunchPlanDataOnly,
42
+ parseModuleFlagValue,
42
43
  projectHostAgent,
43
44
  resolveLaunchSessionsDir,
44
45
  relocateLegacyUserConfig,
45
46
  resolveFormerUserConfigPaths,
46
47
  resolveUserConfigPath,
47
48
  snapshotRegistryEntry,
49
+ suppliedModulesFromFlags,
48
50
  } from "./launch-config.js";
49
51
  import { prepareConfiguredRegistries } from "./provision.js";
50
52
  import {
@@ -122,6 +124,12 @@ export async function runPlaybookRun(options = {}) {
122
124
  const recovering = args.retryUncertain || args.discardUncertain;
123
125
  const continuing = args.continue || args.sessionId !== undefined;
124
126
  let input = args.input;
127
+ // PBCLI-94 (DR-083): a relative supplied module resolves against the
128
+ // invocation's working directory before the loader sees it.
129
+ const modules = suppliedModulesFromFlags(
130
+ args.modulePairs,
131
+ options.cwd ?? process.cwd(),
132
+ );
125
133
 
126
134
  // PBCLI-18/40: a fresh piped producer is drained before config,
127
135
  // preparation, import, or readiness. Continuations first inspect the
@@ -369,6 +377,7 @@ export async function runPlaybookRun(options = {}) {
369
377
  ? await loadSelectedLaunchPlanDataOnly({
370
378
  userConfigPath,
371
379
  overlayPaths: args.withPaths,
380
+ modules,
372
381
  structuralProjection: priorRecord.structuralProjection,
373
382
  onNotice: (line) => configNotices.push(line),
374
383
  env,
@@ -377,6 +386,7 @@ export async function runPlaybookRun(options = {}) {
377
386
  : await loadLaunchPlan({
378
387
  userConfigPath,
379
388
  overlayPaths: args.withPaths,
389
+ modules,
380
390
  env,
381
391
  homeDir: home,
382
392
  loadModule,
@@ -1561,6 +1571,7 @@ export function parseRunArgs(argv) {
1561
1571
  const parsed = {
1562
1572
  input: undefined,
1563
1573
  withPaths: [],
1574
+ modulePairs: [],
1564
1575
  noProvision: false,
1565
1576
  json: false,
1566
1577
  verbose: false,
@@ -1625,6 +1636,18 @@ export function parseRunArgs(argv) {
1625
1636
  const value = arg.slice("--with=".length);
1626
1637
  if (value === "") throw new Error("--with needs a value");
1627
1638
  parsed.withPaths.push(value);
1639
+ } else if (arg === "--module" || arg.startsWith("--module=")) {
1640
+ // PBCLI-94 (DR-083): one `<id>=<specifier>` per playbook id.
1641
+ const pair = parseModuleFlagValue(
1642
+ arg === "--module" ? argv[index + 1] : arg.slice("--module=".length),
1643
+ );
1644
+ if (arg === "--module") index += 1;
1645
+ if (parsed.modulePairs.some(([id]) => id === pair[0])) {
1646
+ throw new Error(
1647
+ `--module names playbook ${JSON.stringify(pair[0])} more than once`,
1648
+ );
1649
+ }
1650
+ parsed.modulePairs.push(pair);
1628
1651
  } else if (
1629
1652
  RETIRED_FLAGS.has(arg) ||
1630
1653
  [...RETIRED_FLAGS].some((flag) => arg.startsWith(`${flag}=`))
@@ -1681,6 +1704,12 @@ export function parseRunArgs(argv) {
1681
1704
  ) {
1682
1705
  throw new Error("--with is unavailable during uncertain-turn recovery");
1683
1706
  }
1707
+ if (
1708
+ (parsed.retryUncertain || parsed.discardUncertain) &&
1709
+ parsed.modulePairs.length > 0
1710
+ ) {
1711
+ throw new Error("--module is unavailable during uncertain-turn recovery");
1712
+ }
1684
1713
  if (
1685
1714
  parsed.sessionId !== undefined &&
1686
1715
  !SESSION_ID_PATTERN.test(parsed.sessionId)
@@ -1928,10 +1957,11 @@ async function writeStream(stream, text) {
1928
1957
  function runHelpText(userConfigPath) {
1929
1958
  return [
1930
1959
  "Usage:",
1931
- " playbook run [--with <path>]... [--no-provision] [--json]",
1932
- " [--verbose] [--] [input]",
1960
+ " playbook run [--with <path>]... [--module <id>=<specifier>]...",
1961
+ " [--no-provision] [--json] [--verbose] [--] [input]",
1933
1962
  " playbook run (--continue | --session <id>) [--with <path>]...",
1934
- " [--no-provision] [--json] [--verbose] [--] [reply]",
1963
+ " [--module <id>=<specifier>]... [--no-provision] [--json]",
1964
+ " [--verbose] [--] [reply]",
1935
1965
  " playbook run --session <id> --retry-uncertain [--no-provision]",
1936
1966
  " playbook run --session <id> --discard-uncertain",
1937
1967
  "",
@@ -1959,6 +1989,10 @@ function runHelpText(userConfigPath) {
1959
1989
  "",
1960
1990
  "Options:",
1961
1991
  " --with <path> overlay a generic config fragment (repeatable)",
1992
+ " --module <id>=<specifier>",
1993
+ " supply playbook <id>'s module ahead of its",
1994
+ " playbooks.<id>.from (repeatable, one per id); a",
1995
+ " continuation without either uses the stored module",
1962
1996
  " --no-provision do not provision thin filesystem registry engines",
1963
1997
  " --continue prefer this working directory, else global newest",
1964
1998
  " --session <id> reply to one durable Captain session UUID",
@@ -43,8 +43,15 @@ export declare function installRetainedGenerationsForLaunch(options: {
43
43
  export declare function executionConfigFromPlan(plan: any): SessionExecutionProjection;
44
44
  export declare function validateFrozenExecutionConfig(structural: SessionStructuralProjection, execution: SessionExecutionProjection, dependencies: { loadModule: (specifier: string) => Promise<any>; prepareRegistryModule?: (request: {id: string; from: string; authoredFrom: string}) => Promise<string | void> }): Promise<SessionExecutionProjection>;
45
45
  export declare function driveHeadlessCaptainTurn(options: any): Promise<any>;
46
- export declare function normalizeLaunchPlan(top: any, options?: any): Promise<any>;
47
- export declare function loadLaunchPlan(options: any): Promise<any>;
46
+ /** Playbook id to registry module specifier, supplied at launch ahead of `playbooks.<id>.from` (DR-083). */
47
+ export type PlaybookModuleLocations = Readonly<Record<string, string>>;
48
+ export interface LaunchPlanModuleOptions {
49
+ /** Absolute paths become file URLs; file URLs and package specifiers are kept; relative paths are refused. */
50
+ readonly modules?: PlaybookModuleLocations;
51
+ readonly [key: string]: any;
52
+ }
53
+ export declare function normalizeLaunchPlan(top: any, options?: LaunchPlanModuleOptions): Promise<any>;
54
+ export declare function loadLaunchPlan(options: LaunchPlanModuleOptions): Promise<any>;
48
55
  export declare function composeGenericConfig(top: any, loadModule: (specifier: string) => Promise<any>, configPath?: string): Promise<any>;
49
56
  export declare function projectTmuxConfig(plan: any): any;
50
57
  export declare function resolveLaunchSessionsDir(options: any): string;