@sublang/playbook 2.0.0 → 3.1.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 (30) hide show
  1. package/README.md +102 -338
  2. package/docs/cli.md +123 -0
  3. package/docs/configuration.md +158 -0
  4. package/docs/embedding.md +161 -0
  5. package/package.json +7 -2
  6. package/reference/sdlc/code.md +105 -0
  7. package/reference/sdlc/code.playbook/bin/playbook.js +237 -38
  8. package/reference/sdlc/code.playbook/bin/provision.js +228 -0
  9. package/reference/sdlc/code.playbook/bin/run.js +76 -3
  10. package/reference/sdlc/code.playbook/code.fsm.js +38 -36
  11. package/reference/sdlc/code.playbook/code.fsm.ts +38 -36
  12. package/reference/sdlc/code.playbook/code.gears.md +30 -26
  13. package/reference/sdlc/code.playbook/code.playbook.js +4 -0
  14. package/reference/sdlc/code.playbook/code.playbook.ts +6 -0
  15. package/reference/sdlc/code.playbook/playbook-captain.js +67 -8
  16. package/reference/sdlc/code.playbook/playbook-captain.ts +80 -9
  17. package/reference/sdlc/code.playbook/playbook.config.template.yaml +38 -32
  18. package/reference/sdlc/discuss.md +93 -0
  19. package/reference/sdlc/discuss.playbook/discuss.fsm.js +5 -4
  20. package/reference/sdlc/discuss.playbook/discuss.fsm.ts +5 -4
  21. package/reference/sdlc/discuss.playbook/discuss.gears.md +19 -12
  22. package/reference/sdlc/discuss.playbook/discuss.playbook.js +3 -0
  23. package/reference/sdlc/discuss.playbook/discuss.playbook.ts +5 -0
  24. package/slc/link.md +20 -3
  25. package/src/xstate-playbook-runtime.d.ts +17 -0
  26. package/src/xstate-playbook-runtime.js +49 -0
  27. package/src/xstate-playbook-runtime.ts +81 -4
  28. package/src/xstate-runtime.d.ts +1 -0
  29. package/src/xstate-runtime.js +19 -0
  30. package/src/xstate-runtime.ts +20 -0
@@ -17,7 +17,11 @@ import {
17
17
  import { homedir, tmpdir } from 'node:os';
18
18
  import { dirname, join, resolve } from 'node:path';
19
19
  import { fileURLToPath } from 'node:url';
20
- import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
20
+ import {
21
+ parse as parseYaml,
22
+ parseDocument as parseYamlDocument,
23
+ stringify as stringifyYaml,
24
+ } from 'yaml';
21
25
 
22
26
  const here = dirname(fileURLToPath(import.meta.url));
23
27
  const templatePath = resolve(here, '..', 'playbook.config.template.yaml');
@@ -25,8 +29,8 @@ const templatePath = resolve(here, '..', 'playbook.config.template.yaml');
25
29
  // PBCLI-1/8: the launcher composes a tmux-play config whose Captain is the
26
30
  // Playbook Captain shell adapter module.
27
31
  export const PLAYBOOK_CAPTAIN_MODULE = '@sublang/playbook/playbook-captain';
28
- // PBCLI-8/12: known adapter shorthands. A `profiles` id may not collide
29
- // with one of these, and these are the adapters with readiness predicates.
32
+ // PBCLI-12: known adapter shorthands the adapters with readiness
33
+ // predicates.
30
34
  const ADAPTER_SHORTHANDS = ['claude', 'codex'];
31
35
  // PBCLI-8: launcher-owned keys inside a `playbooks.<id>` block; every other
32
36
  // key belongs to that playbook's option slice.
@@ -63,6 +67,7 @@ export async function runPlaybookCli(options = {}) {
63
67
  ...(options.createAgent ? { createAgent: options.createAgent } : {}),
64
68
  ...(options.readStdin ? { readStdin: options.readStdin } : {}),
65
69
  ...(options.sessionsDir ? { sessionsDir: options.sessionsDir } : {}),
70
+ ...(options.hostRoots ? { hostRoots: options.hostRoots } : {}),
66
71
  });
67
72
  }
68
73
 
@@ -103,6 +108,15 @@ export async function runPlaybookCli(options = {}) {
103
108
 
104
109
  seedUserConfigIfMissing(userConfigPath, stderr);
105
110
 
111
+ // DR-021 §3: an existing profiles-based config is rewritten in place once,
112
+ // with the original kept beside it, so the user launches without editing.
113
+ try {
114
+ migrateUserConfigIfRetired(userConfigPath, stderr);
115
+ } catch (error) {
116
+ stderr.write(`playbook: ${errorMessage(error)}\n`);
117
+ return { code: COMPOSITION_FAILURE_EXIT_CODE };
118
+ }
119
+
106
120
  let composed;
107
121
  try {
108
122
  let top = parseYaml(readFileSync(userConfigPath, 'utf8')) ?? {};
@@ -114,7 +128,7 @@ export async function runPlaybookCli(options = {}) {
114
128
  for (const overlayPath of withPaths) {
115
129
  top = mergeConfigs(top, loadOverlayFragment(overlayPath));
116
130
  }
117
- composed = await composeGenericConfig(top, loadModule);
131
+ composed = await composeGenericConfig(top, loadModule, userConfigPath);
118
132
  } catch (error) {
119
133
  stderr.write(`playbook: ${errorMessage(error)}\n`);
120
134
  return { code: COMPOSITION_FAILURE_EXIT_CODE };
@@ -235,29 +249,213 @@ export function resolveUserConfigPath(env = process.env, home = homedir()) {
235
249
  return join(resolveConfigHome(env, home), 'playbook', 'playbook.config.yaml');
236
250
  }
237
251
 
238
- // PBCLI-8: resolve a scalar `captain` / `players.<role>` value as a profile
239
- // id or adapter shorthand, or a full agent block whose optional `profile`
240
- // key names a `profiles` entry whose settings are the base under the block's
241
- // own explicit fields. The composed block carries no `profile` key.
242
- export function resolveAgent(value, profiles, path) {
243
- if (typeof value === 'string') {
244
- if (hasOwn(profiles, value)) return { ...profiles[value] };
245
- return { adapter: value };
252
+ // PBCLI-8 (DR-021): a scalar `captain` / `players.<role>` value is an
253
+ // adapter shorthand; a full block is a self-contained tmux-play agent block
254
+ // carrying its own adapter/model/effort/permissions. There is no profile
255
+ // indirection, so retuning one agent cannot change another.
256
+ export function resolveAgent(value, path) {
257
+ if (typeof value === 'string') return { adapter: value };
258
+ if (isObject(value)) return { ...value };
259
+ throw new Error(`${path} must be an adapter shorthand or an agent block`);
260
+ }
261
+
262
+ // DR-021 §3: migrate the user's config on disk, once, keeping the original.
263
+ // The backup is written before the rewrite and never overwrites an existing
264
+ // file, so a prior backup — or a user's own .bak — cannot be lost.
265
+ function migrateUserConfigIfRetired(userConfigPath, stderr) {
266
+ let text;
267
+ try {
268
+ text = readFileSync(userConfigPath, 'utf8');
269
+ } catch {
270
+ return;
246
271
  }
247
- if (isObject(value)) {
248
- const { profile, ...rest } = value;
249
- let base = {};
250
- if (profile !== undefined) {
251
- if (typeof profile !== 'string' || !hasOwn(profiles, profile)) {
252
- throw new Error(`${path}.profile must name a profiles entry`);
272
+ let migrated;
273
+ try {
274
+ migrated = migrateRetiredProfiles(text);
275
+ } catch (error) {
276
+ throw new Error(
277
+ `cannot migrate the retired profiles config at ${userConfigPath}: ` +
278
+ `${errorMessage(error)} — edit it by hand: each agent takes its own ` +
279
+ 'adapter, model, effort, and permissions',
280
+ );
281
+ }
282
+ if (migrated === undefined) return;
283
+ const backupPath = freeBackupPath(userConfigPath);
284
+ writeFileSync(backupPath, text, { mode: 0o600 });
285
+ writeFileSync(userConfigPath, migrated);
286
+ stderr.write(
287
+ `playbook: migrated ${userConfigPath} to inline agent settings ` +
288
+ `(the top-level "profiles" map was removed in 3.0.0); ` +
289
+ `the original is at ${backupPath}\n`,
290
+ );
291
+ }
292
+
293
+ function freeBackupPath(userConfigPath) {
294
+ const first = `${userConfigPath}.bak`;
295
+ if (!existsSync(first)) return first;
296
+ for (let n = 2; ; n += 1) {
297
+ const candidate = `${userConfigPath}.bak.${n}`;
298
+ if (!existsSync(candidate)) return candidate;
299
+ }
300
+ }
301
+
302
+ // DR-021 §3: rewrite a config written for the retired profiles model in
303
+ // place, inlining each agent's settings and keeping the original beside it.
304
+ // Edits go through the YAML Document API so the user's comments survive;
305
+ // only the profiles block and its own commentary are removed. Returns the
306
+ // migrated text, or undefined when there is nothing to migrate.
307
+ export function migrateRetiredProfiles(text) {
308
+ const doc = parseYamlDocument(text);
309
+ const contents = doc.contents;
310
+ if (!contents || !Array.isArray(contents.items)) return undefined;
311
+ const profiles = doc.get('profiles');
312
+ const agentPaths = [['captain']];
313
+ const playbooks = doc.get('playbooks');
314
+ if (playbooks && Array.isArray(playbooks.items)) {
315
+ for (const entry of playbooks.items) {
316
+ const id = String(entry.key);
317
+ const players = doc.getIn(['playbooks', id, 'players']);
318
+ if (!players || !Array.isArray(players.items)) continue;
319
+ for (const player of players.items) {
320
+ agentPaths.push(['playbooks', id, 'players', String(player.key)]);
321
+ }
322
+ }
323
+ }
324
+
325
+ const profileSettings = (name) =>
326
+ profiles && typeof profiles.get === 'function'
327
+ ? profiles.get(name)
328
+ : undefined;
329
+
330
+ let changed = false;
331
+ for (const path of agentPaths) {
332
+ const node = doc.getIn(path, true);
333
+ if (node && typeof node.value === 'string' && !Array.isArray(node.items)) {
334
+ // A scalar that named a profile; a bare adapter shorthand stays.
335
+ const settings = profileSettings(node.value);
336
+ if (settings === undefined) continue;
337
+ const inlined = settings.clone();
338
+ // The scalar carried any comment on that line, and replacing the node
339
+ // would drop it. Re-attach it above the block that replaces it.
340
+ carryScalarComment(node, inlined);
341
+ doc.setIn(path, inlined);
342
+ changed = true;
343
+ } else if (node && Array.isArray(node.items)) {
344
+ const named = node.get?.('profile');
345
+ if (named === undefined) continue;
346
+ const settings = profileSettings(named);
347
+ if (settings === undefined) {
348
+ throw new Error(
349
+ `${path.join('.')}.profile names "${String(named)}", which no ` +
350
+ 'profiles entry defines',
351
+ );
253
352
  }
254
- base = { ...profiles[profile] };
353
+ // Fill the block from its profile in place — never rebuild it — so
354
+ // the user's own keys, ordering, and comments survive untouched. The
355
+ // block's own fields stay authoritative, so only absent keys are added.
356
+ node.delete('profile');
357
+ for (const item of settings.items) {
358
+ if (node.has(String(item.key))) continue;
359
+ // Append the whole pair, not a rebuilt key/value: a comment above a
360
+ // setting rides on that setting's key node, so stringifying the key
361
+ // would drop it.
362
+ node.add(item.clone());
363
+ }
364
+ changed = true;
365
+ }
366
+ }
367
+
368
+ if (profiles !== undefined) {
369
+ // The comment block above `profiles` usually carries the file's own
370
+ // header, which must outlive the removed section: keep every paragraph
371
+ // except the last, which documents profiles themselves.
372
+ const index = contents.items.findIndex(
373
+ (item) => String(item.key) === 'profiles',
374
+ );
375
+ const lead = index === -1 ? undefined : contents.items[index]?.key
376
+ ?.commentBefore;
377
+ doc.delete('profiles');
378
+ const header = keptHeaderComment(lead);
379
+ const next = contents.items[0];
380
+ if (header !== undefined && next?.key) {
381
+ next.key.commentBefore =
382
+ next.key.commentBefore === undefined
383
+ ? header
384
+ : `${header}\n\n${next.key.commentBefore}`;
255
385
  }
256
- return { ...base, ...rest };
386
+ changed = true;
257
387
  }
258
- throw new Error(
259
- `${path} must be a profile id, an adapter shorthand, or an agent block`,
388
+ if (!changed) return undefined;
389
+ // Say what happened at the top of the file the user will open next:
390
+ // some of their remaining comments describe the retired model.
391
+ doc.commentBefore = MIGRATION_NOTE;
392
+ return doc.toString();
393
+ }
394
+
395
+ const MIGRATION_NOTE =
396
+ ' Migrated by playbook 3.0.0: the top-level `profiles` map was removed and\n' +
397
+ ' each agent now carries its settings inline. The pre-migration file is\n' +
398
+ ' kept beside this one as a .bak. Comments below may still describe the\n' +
399
+ ' retired profiles model.';
400
+
401
+ // Move a scalar agent's own comments onto the block that replaces it, so
402
+ // `captain: base # the judge` keeps its note. The pair's key comments are
403
+ // untouched by the replacement and need no carrying.
404
+ function carryScalarComment(node, inlined) {
405
+ const parts = [node.commentBefore, node.comment].filter(
406
+ (part) => typeof part === 'string' && part.trim() !== '',
260
407
  );
408
+ if (parts.length === 0) return;
409
+ const first = inlined.items?.[0]?.key;
410
+ if (!first) return;
411
+ // A flow map carrying a comment renders as a multi-line brace block; the
412
+ // ordinary block form is what the rest of the config looks like.
413
+ inlined.flow = false;
414
+ const carried = parts.join('\n');
415
+ first.commentBefore =
416
+ first.commentBefore === undefined
417
+ ? carried
418
+ : `${carried}\n${first.commentBefore}`;
419
+ }
420
+
421
+ // Drop the trailing paragraph — the one describing the profiles block —
422
+ // and keep the rest of the leading comment (SPDX header, file overview).
423
+ function keptHeaderComment(comment) {
424
+ if (typeof comment !== 'string' || comment.trim() === '') return undefined;
425
+ const paragraphs = comment.split('\n\n');
426
+ const kept = paragraphs.slice(0, -1).join('\n\n');
427
+ return kept.trim() === '' ? undefined : kept;
428
+ }
429
+
430
+ // A `profile` key that survives migration — introduced by a `--with`
431
+ // overlay rather than the user's own config — is still rejected.
432
+ function assertNoRetiredProfiles(top, configPath) {
433
+ const where = configPath ? ` in ${configPath}` : '';
434
+ if (top.profiles !== undefined) {
435
+ throw new Error(
436
+ `top-level "profiles" was removed${where}: write each agent's settings ` +
437
+ 'inline under captain and each playbooks.<id>.players.<role> ' +
438
+ '(adapter, model, effort, permissions)',
439
+ );
440
+ }
441
+ const blocks = [['captain', top.captain]];
442
+ const playbooksCfg = isObject(top.playbooks) ? top.playbooks : {};
443
+ for (const [id, block] of Object.entries(playbooksCfg)) {
444
+ const playersMap = isObject(block) && isObject(block.players)
445
+ ? block.players
446
+ : {};
447
+ for (const [role, agent] of Object.entries(playersMap)) {
448
+ blocks.push([`playbooks.${id}.players.${role}`, agent]);
449
+ }
450
+ }
451
+ for (const [path, block] of blocks) {
452
+ if (isObject(block) && block.profile !== undefined) {
453
+ throw new Error(
454
+ `${path}.profile was removed${where}: write the agent's settings ` +
455
+ 'inline in that block (adapter, model, effort, permissions)',
456
+ );
457
+ }
458
+ }
261
459
  }
262
460
 
263
461
  function isValidRegistryEntry(value) {
@@ -272,19 +470,12 @@ function isValidRegistryEntry(value) {
272
470
  );
273
471
  }
274
472
 
275
- // PBCLI-8/9/10: normalize the top-level `profiles` / `playbooks` config into
473
+ // PBCLI-8/9/10: normalize the top-level `playbooks` config into
276
474
  // a tmux-play config (Captain = the shell adapter; `captain.options.playbooks`
277
475
  // the normalized enablement; a launch-time namespaced `<id>-<role>` roster;
278
476
  // launcher-owned `layout.initialVisible`).
279
- export async function composeGenericConfig(top, loadModule) {
280
- const profiles = isObject(top.profiles) ? top.profiles : {};
281
- for (const id of Object.keys(profiles)) {
282
- if (ADAPTER_SHORTHANDS.includes(id)) {
283
- throw new Error(
284
- `profiles.${id} collides with the "${id}" adapter shorthand`,
285
- );
286
- }
287
- }
477
+ export async function composeGenericConfig(top, loadModule, configPath) {
478
+ assertNoRetiredProfiles(top, configPath);
288
479
 
289
480
  const playbooksCfg = requireObject(top.playbooks, 'playbooks');
290
481
  const ids = Object.keys(playbooksCfg);
@@ -294,7 +485,7 @@ export async function composeGenericConfig(top, loadModule) {
294
485
 
295
486
  const captain = {
296
487
  from: PLAYBOOK_CAPTAIN_MODULE,
297
- ...resolveAgent(top.captain, profiles, 'captain'),
488
+ ...resolveAgent(top.captain, 'captain'),
298
489
  };
299
490
  if (captain.adapter === undefined) {
300
491
  throw new Error('captain must resolve an adapter');
@@ -389,7 +580,6 @@ export async function composeGenericConfig(top, loadModule) {
389
580
  for (const role of roles) {
390
581
  const agent = resolveAgent(
391
582
  playersMap[role],
392
- profiles,
393
583
  `playbooks.${id}.players.${role}`,
394
584
  );
395
585
  if (agent.adapter === undefined) {
@@ -419,7 +609,16 @@ export async function composeGenericConfig(top, loadModule) {
419
609
  listing.push({ id, command, intent: entry.intent });
420
610
  }
421
611
 
422
- captain.options = { playbooks: optionsPlaybooks };
612
+ // DR-013 A1: the shell cannot see its own captain's adapter through the
613
+ // tmux-play CaptainContext, so the launcher — which resolved it — passes it
614
+ // through. The shell needs it to decide whether an explicit empty tool
615
+ // allowlist can be enforced or must degrade to prompt-level restriction.
616
+ captain.options = {
617
+ playbooks: optionsPlaybooks,
618
+ ...(typeof captain.adapter === 'string' && captain.adapter.length > 0
619
+ ? { captainAdapter: captain.adapter }
620
+ : {}),
621
+ };
423
622
  const config = { captain, players: roster };
424
623
  // PBCLI-10: carry the user's tmux-play layout window/weight fields through;
425
624
  // the launcher owns `layout.initialVisible` (first enabled playbook).
@@ -504,9 +703,9 @@ function helpText({ userConfigPath, failingAdapters = [] }) {
504
703
  ' codex: run Codex CLI once or set OPENAI_API_KEY.',
505
704
  '',
506
705
  'Agent swap recipe:',
507
- ' - reuse agent settings under top-level profiles',
508
- ' - point each playbooks.<id>.captain / players.<role> at a profile id',
509
- ' or an adapter shorthand (claude, codex)',
706
+ ' - set each agent inline: the top-level captain and every',
707
+ ' playbooks.<id>.players.<role> takes an adapter shorthand',
708
+ ' (claude, codex) or a block with adapter/model/effort/permissions',
510
709
  ' - the launcher injects captain.from and the namespaced <id>-<role>',
511
710
  ' host players',
512
711
  '',
@@ -0,0 +1,228 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
+
4
+ // PBCLI-36/37 (DR-024): probe-first engine provisioning for filesystem
5
+ // registry modules. A compiled thin artifact imports `xstate` and
6
+ // `@sublang/playbook/xstate-runtime`, which Node resolves by walking up
7
+ // from the artifact's own directory; a globally installed host therefore
8
+ // fails at artifact load in a bare directory. Before importing such a
9
+ // module, `playbook run` probes both specifiers with the module's path as
10
+ // resolution parent and, only when a probe fails, symlinks the running
11
+ // host's own installed package roots beside the module. It never shells
12
+ // out to `npm link` and never installs from the registry.
13
+
14
+ import { existsSync, lstatSync, readFileSync, readlinkSync } from 'node:fs';
15
+ import { mkdir, symlink, unlink } from 'node:fs/promises';
16
+ import { createRequire } from 'node:module';
17
+ import { dirname, join } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ // PBCLI-37: probe specifier → the package name provisioning may link.
21
+ const ENGINE_LINKS = new Map([
22
+ ['xstate', 'xstate'],
23
+ ['@sublang/playbook/xstate-runtime', '@sublang/playbook'],
24
+ ]);
25
+
26
+ const DEPENDENCY_FIELDS = [
27
+ 'dependencies',
28
+ 'devDependencies',
29
+ 'peerDependencies',
30
+ 'optionalDependencies',
31
+ ];
32
+
33
+ // PBCLI-37: package names whose probes fail with the module as resolution
34
+ // parent. `createRequire` follows the same walk-up Node uses for the
35
+ // module's own imports, so an empty result means a project-local (or
36
+ // already provisioned) engine wins and provisioning must touch nothing.
37
+ function missingEngineLinks(modulePath) {
38
+ const req = createRequire(modulePath);
39
+ const missing = [];
40
+ for (const [specifier, name] of ENGINE_LINKS) {
41
+ try {
42
+ req.resolve(specifier);
43
+ } catch {
44
+ missing.push(name);
45
+ }
46
+ }
47
+ return missing;
48
+ }
49
+
50
+ // PBCLI-36: a manifest at or above the module declaring @sublang/playbook
51
+ // means a project chose a dependency and its install is broken or absent;
52
+ // shadow-provisioning would mask the real fix.
53
+ function declaringManifest(startDir) {
54
+ for (let dir = startDir; ; ) {
55
+ const manifestPath = join(dir, 'package.json');
56
+ if (existsSync(manifestPath)) {
57
+ try {
58
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
59
+ for (const field of DEPENDENCY_FIELDS) {
60
+ const block = manifest?.[field];
61
+ if (
62
+ block !== null &&
63
+ typeof block === 'object' &&
64
+ Object.prototype.hasOwnProperty.call(block, '@sublang/playbook')
65
+ ) {
66
+ return manifestPath;
67
+ }
68
+ }
69
+ } catch {
70
+ // An unreadable manifest cannot declare the dependency.
71
+ }
72
+ }
73
+ const parent = dirname(dir);
74
+ if (parent === dir) return undefined;
75
+ dir = parent;
76
+ }
77
+ }
78
+
79
+ function packageRootUpward(startDir, name) {
80
+ for (let dir = startDir; ; ) {
81
+ const manifestPath = join(dir, 'package.json');
82
+ if (existsSync(manifestPath)) {
83
+ try {
84
+ if (JSON.parse(readFileSync(manifestPath, 'utf8')).name === name) {
85
+ return dir;
86
+ }
87
+ } catch {
88
+ // Keep walking past an unreadable manifest.
89
+ }
90
+ }
91
+ const parent = dirname(dir);
92
+ if (parent === dir) return undefined;
93
+ dir = parent;
94
+ }
95
+ }
96
+
97
+ // PBCLI-37: the running host's own installed package roots, resolved from
98
+ // the host's module scope — this file lives inside @sublang/playbook, and
99
+ // xstate resolves from that root's own dependency tree.
100
+ function defaultHostRoots() {
101
+ const here = dirname(fileURLToPath(import.meta.url));
102
+ const playbookRoot = packageRootUpward(here, '@sublang/playbook');
103
+ if (playbookRoot === undefined) {
104
+ throw new Error(
105
+ 'cannot locate the running @sublang/playbook package root for provisioning',
106
+ );
107
+ }
108
+ const req = createRequire(join(playbookRoot, 'package.json'));
109
+ let xstateRoot;
110
+ try {
111
+ xstateRoot = packageRootUpward(dirname(req.resolve('xstate')), 'xstate');
112
+ } catch {
113
+ xstateRoot = undefined;
114
+ }
115
+ if (xstateRoot === undefined) {
116
+ throw new Error(
117
+ "cannot locate the running host's own xstate package for provisioning",
118
+ );
119
+ }
120
+ return { xstate: xstateRoot, '@sublang/playbook': playbookRoot };
121
+ }
122
+
123
+ // 'absent' | 'dangling' | 'live' (a symlink with an existing target) |
124
+ // 'occupied' (a real file or directory, never removed).
125
+ function linkState(linkPath) {
126
+ let stat;
127
+ try {
128
+ stat = lstatSync(linkPath);
129
+ } catch {
130
+ return 'absent';
131
+ }
132
+ if (!stat.isSymbolicLink()) return 'occupied';
133
+ return existsSync(linkPath) ? 'live' : 'dangling';
134
+ }
135
+
136
+ // PBCLI-36/37: probe, then provision the missing engine links beside a
137
+ // filesystem registry module. Returns {} when the run may proceed (either
138
+ // nothing was needed or links were created and logged) or { code: 1 }
139
+ // after writing one `playbook run: <message>` diagnostic to stderr.
140
+ export async function provisionEngine({
141
+ modulePath,
142
+ stderr,
143
+ enabled = true,
144
+ hostRoots,
145
+ }) {
146
+ const missing = missingEngineLinks(modulePath);
147
+ if (missing.length === 0) return {};
148
+
149
+ const moduleDir = dirname(modulePath);
150
+ if (!enabled) {
151
+ // PBCLI-36: --no-provision still owes the dangling-link diagnostic —
152
+ // a stale link we (or a prior host) created must not surface as a raw
153
+ // module-not-found error.
154
+ for (const name of missing) {
155
+ const linkPath = join(moduleDir, 'node_modules', name);
156
+ if (linkState(linkPath) === 'dangling') {
157
+ stderr.write(
158
+ `playbook run: ${linkPath} is a stale engine link to missing ` +
159
+ `${readlinkSync(linkPath)}; rerun without --no-provision to relink\n`,
160
+ );
161
+ return { code: 1 };
162
+ }
163
+ }
164
+ return {};
165
+ }
166
+
167
+ const manifestPath = declaringManifest(moduleDir);
168
+ if (manifestPath !== undefined) {
169
+ stderr.write(
170
+ `playbook run: ${manifestPath} declares @sublang/playbook; ` +
171
+ 'provisioning would shadow the project install — run the ' +
172
+ "project's dependency install (e.g. npm install) instead\n",
173
+ );
174
+ return { code: 1 };
175
+ }
176
+
177
+ let roots;
178
+ try {
179
+ roots = hostRoots ?? defaultHostRoots();
180
+ } catch (error) {
181
+ stderr.write(
182
+ `playbook run: ${error instanceof Error ? error.message : String(error)}\n`,
183
+ );
184
+ return { code: 1 };
185
+ }
186
+
187
+ // PBCLI-37: validate every destination before mutating any, so an
188
+ // occupied-path refusal leaves the module directory unchanged rather
189
+ // than half-provisioned.
190
+ const plans = [];
191
+ for (const name of missing) {
192
+ const linkPath = join(moduleDir, 'node_modules', name);
193
+ const state = linkState(linkPath);
194
+ if (state === 'occupied' || state === 'live') {
195
+ // A live-but-unresolvable link is as foreign as a real directory:
196
+ // neither is a link this host may replace.
197
+ stderr.write(
198
+ `playbook run: cannot provision ${linkPath}: the path is already ` +
199
+ `occupied${state === 'live' ? ' by a foreign symbolic link' : ''}\n`,
200
+ );
201
+ return { code: 1 };
202
+ }
203
+ plans.push({
204
+ linkPath,
205
+ target: roots[name],
206
+ dangling: state === 'dangling',
207
+ });
208
+ }
209
+
210
+ const created = [];
211
+ try {
212
+ for (const { linkPath, target, dangling } of plans) {
213
+ if (dangling) await unlink(linkPath);
214
+ await mkdir(dirname(linkPath), { recursive: true });
215
+ await symlink(target, linkPath, 'dir');
216
+ created.push(`${linkPath} -> ${target}`);
217
+ }
218
+ } catch (error) {
219
+ // PBCLI-37: a filesystem failure is a load fault, not a raw crash.
220
+ stderr.write(
221
+ 'playbook run: cannot provision engine links: ' +
222
+ `${error instanceof Error ? error.message : String(error)}\n`,
223
+ );
224
+ return { code: 1 };
225
+ }
226
+ stderr.write(`playbook run: provisioned ${created.join(', ')}\n`);
227
+ return {};
228
+ }