@sublang/playbook 3.0.0 → 4.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.
@@ -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
+ }
@@ -21,7 +21,7 @@ import {
21
21
  } from 'node:fs/promises';
22
22
  import { homedir } from 'node:os';
23
23
  import { isAbsolute, join, resolve } from 'node:path';
24
- import { pathToFileURL } from 'node:url';
24
+ import { fileURLToPath, pathToFileURL } from 'node:url';
25
25
  import {
26
26
  Cligent,
27
27
  isEffortSupported,
@@ -29,6 +29,13 @@ import {
29
29
  } from '@sublang/cligent';
30
30
  import { parse as parseYaml } from 'yaml';
31
31
  import { hiddenControlEnvelope } from '../../../../src/xstate-runtime.js';
32
+ import {
33
+ adapterSdkFailureLines,
34
+ checkAdapterSdks,
35
+ mappedSdksFor,
36
+ probeAdapterSdk,
37
+ } from './adapter-sdk.js';
38
+ import { provisionEngine } from './provision.js';
32
39
 
33
40
  // PBCLI-19: adapter shorthands the run host can construct.
34
41
  const ADAPTER_LOADERS = {
@@ -76,6 +83,18 @@ export async function runPlaybookRun(options = {}) {
76
83
  readStdin,
77
84
  sessionsDir,
78
85
  userConfigPath,
86
+ // PBCLI-39: the adapter SDK probe and the runtime classifier, injectable
87
+ // like createAgent so tests can drive an unavailable or below-floor
88
+ // runtime without uninstalling or downgrading one.
89
+ probeAdapterSdk: options.probeAdapterSdk ?? probeAdapterSdk,
90
+ classifyRuntime: options.classifyRuntime,
91
+ // PBCLI-40: the original invocation, preserved on the ephemeral re-run;
92
+ // this module receives argv with the leading `run` already consumed.
93
+ rawArgv: ['run', ...argv],
94
+ ephemeralNpx: options.ephemeralNpx,
95
+ // PBCLI-37: injected host package roots let tests provision against
96
+ // synthetic trees, like the injected session store.
97
+ hostRoots: options.hostRoots,
79
98
  };
80
99
 
81
100
  let args;
@@ -101,6 +120,11 @@ async function runFirst(args, ctx) {
101
120
  return { code: EXIT.arg };
102
121
  }
103
122
 
123
+ // PBCLI-36/37 (DR-024): provision engine links for a filesystem module
124
+ // before importing it; a resolvable engine is never touched.
125
+ const provisioned = await maybeProvision(args.from, args, ctx);
126
+ if (provisioned.code !== undefined) return provisioned;
127
+
104
128
  const loaded = await loadRegistryEntry(args.from, ctx);
105
129
  if (loaded.code !== undefined) return loaded;
106
130
  const { entry } = loaded;
@@ -150,6 +174,18 @@ async function runFirst(args, ctx) {
150
174
  return { code: EXIT.arg };
151
175
  }
152
176
 
177
+ // PBCLI-39/40: an optional-peer SDK that is not installed fails here,
178
+ // before the runtime exists and before any agent call — never mid-turn.
179
+ const sdkError = await adapterSdksDiagnostic(
180
+ [...roleSpecs.values(), captainSpec],
181
+ ctx,
182
+ stdinReplayArgs(args, task),
183
+ );
184
+ if (sdkError !== undefined) {
185
+ stderr.write(sdkError);
186
+ return { code: EXIT.arg };
187
+ }
188
+
153
189
  let runtime;
154
190
  try {
155
191
  runtime = entry.createRuntime({
@@ -246,6 +282,11 @@ async function runResume(args, ctx) {
246
282
  return { code: EXIT.arg };
247
283
  }
248
284
 
285
+ // PBCLI-37: a stored filesystem `from` (a file: URL) is probed and
286
+ // provisioned on resume exactly as on a first run.
287
+ const provisioned = await maybeProvision(record.from, args, ctx);
288
+ if (provisioned.code !== undefined) return provisioned;
289
+
249
290
  const loaded = await loadRegistryEntry(record.from, ctx);
250
291
  if (loaded.code !== undefined) return loaded;
251
292
  const { entry } = loaded;
@@ -276,6 +317,18 @@ async function runResume(args, ctx) {
276
317
  return { code: EXIT.arg };
277
318
  }
278
319
 
320
+ // PBCLI-39: a resume rebuilds the stored lineup, so it needs the same
321
+ // SDKs — an install that lost one must not resume into a mid-turn error.
322
+ const sdkError = await adapterSdksDiagnostic(
323
+ [...roleSpecs.values(), record.captain],
324
+ ctx,
325
+ stdinReplayArgs(args, reply),
326
+ );
327
+ if (sdkError !== undefined) {
328
+ stderr.write(sdkError);
329
+ return { code: EXIT.arg };
330
+ }
331
+
279
332
  let runtime;
280
333
  try {
281
334
  runtime = entry.createRuntime({
@@ -625,6 +678,50 @@ function specsDiagnostic(specs) {
625
678
  return undefined;
626
679
  }
627
680
 
681
+ // PBCLI-40: the replay tail for input the command consumed from stdin —
682
+ // the pipe that carried it will not exist when the printed command runs.
683
+ // The value rides behind a `--` end-of-options terminator, because quoting
684
+ // alone cannot keep a flag-shaped value (`--json`, `--last`, a `-`-leading
685
+ // bullet) from being read as an option; where the original invocation
686
+ // already activated a terminator of its own, that one is reused — a second
687
+ // `--` after the first would itself be positional data on the replay,
688
+ // turning a `--json` task into `-- --json`.
689
+ function stdinReplayArgs(args, resolved) {
690
+ if (args.task !== undefined) return [];
691
+ return args.terminated ? [resolved] : ['--', resolved];
692
+ }
693
+
694
+ // PBCLI-39/40: returns the ready-to-write stderr block naming every bound
695
+ // adapter whose optional-peer SDK is not installed, or undefined when every
696
+ // one of them loads. Runs only after specsDiagnostic has accepted the
697
+ // adapter names, so every spec here carries a known adapter.
698
+ async function adapterSdksDiagnostic(specs, ctx, stdinArgs = []) {
699
+ const adapters = specs.map((spec) => spec.adapter);
700
+ const { unusableAdapters } = await checkAdapterSdks(
701
+ adapters,
702
+ ctx.probeAdapterSdk,
703
+ ...(ctx.classifyRuntime ? [ctx.classifyRuntime] : []),
704
+ );
705
+ if (unusableAdapters.length === 0) return undefined;
706
+ const [header, ...commands] = adapterSdkFailureLines(unusableAdapters, {
707
+ // PBCLI-40: the ephemeral re-run carries the lineup's full mapped SDK
708
+ // set and the original arguments, so it completes in one hop and runs
709
+ // exactly as printed. stdinArgs arrive terminator-ready from
710
+ // stdinReplayArgs — appended verbatim here, because whether a `--` is
711
+ // needed depends on the original invocation's own parse state.
712
+ requiredSdks: mappedSdksFor(adapters),
713
+ invocation: [...ctx.rawArgv, ...stdinArgs],
714
+ ...(ctx.ephemeralNpx !== undefined
715
+ ? { ephemeralNpx: ctx.ephemeralNpx }
716
+ : {}),
717
+ }).filter((line) => line !== '');
718
+ // Only the header takes the command prefix; the install lines stay
719
+ // copy-pasteable.
720
+ return [`playbook run: ${header}`, ...commands]
721
+ .map((line) => `${line}\n`)
722
+ .join('');
723
+ }
724
+
628
725
  function isAgentSpec(spec) {
629
726
  return (
630
727
  typeof spec === 'object' &&
@@ -876,14 +973,29 @@ export function parseRunArgs(argv) {
876
973
  cwd: undefined,
877
974
  json: false,
878
975
  verbose: false,
976
+ noProvision: false,
879
977
  help: false,
978
+ terminated: false,
880
979
  };
881
980
  const positionals = [];
882
981
  for (let i = 0; i < argv.length; i += 1) {
883
982
  const arg = argv[i];
983
+ // PBCLI-40: end-of-options — everything after `--` is positional, so a
984
+ // flag-shaped task or reply (a stdin-derived `--json`, a `- bullet`
985
+ // line) survives the ephemeral re-run round trip instead of being
986
+ // reinterpreted as an option. `terminated` records that this branch
987
+ // fired — only a `--` the walk itself treats as the terminator counts,
988
+ // never one consumed as an option's value (`--cwd --`) — so the re-run
989
+ // builder can reuse an active terminator instead of doubling it.
990
+ if (arg === '--') {
991
+ args.terminated = true;
992
+ positionals.push(...argv.slice(i + 1));
993
+ break;
994
+ }
884
995
  if (arg === '--help' || arg === '-h') args.help = true;
885
996
  else if (arg === '--json') args.json = true;
886
997
  else if (arg === '--verbose') args.verbose = true;
998
+ else if (arg === '--no-provision') args.noProvision = true;
887
999
  else if (arg === '--last') args.last = true;
888
1000
  else if (arg === '--cwd') args.cwd = takeValue(argv, (i += 1), '--cwd');
889
1001
  else if (arg === '--captain')
@@ -961,6 +1073,36 @@ function registryImportSpecifier(specifier, cwd) {
961
1073
  return specifier;
962
1074
  }
963
1075
 
1076
+ // PBCLI-37: the absolute file path of a filesystem `<from>` (path or
1077
+ // file: URL), or undefined for a bare package specifier — those resolve
1078
+ // from the host's own module tree and are neither probed nor provisioned.
1079
+ function moduleFilePath(specifier, cwd) {
1080
+ if (specifier.startsWith('file:')) return fileURLToPath(specifier);
1081
+ if (
1082
+ isAbsolute(specifier) ||
1083
+ specifier.startsWith('./') ||
1084
+ specifier.startsWith('../') ||
1085
+ specifier.startsWith('.\\') ||
1086
+ specifier.startsWith('..\\')
1087
+ ) {
1088
+ return resolve(cwd, specifier);
1089
+ }
1090
+ return undefined;
1091
+ }
1092
+
1093
+ // PBCLI-36/37: probe-and-provision for a filesystem registry module.
1094
+ // Returns {} to proceed or { code } after a reported provisioning fault.
1095
+ async function maybeProvision(specifier, args, ctx) {
1096
+ const modulePath = moduleFilePath(specifier, ctx.cwdDefault);
1097
+ if (modulePath === undefined) return {};
1098
+ return provisionEngine({
1099
+ modulePath,
1100
+ stderr: ctx.stderr,
1101
+ enabled: !args.noProvision,
1102
+ hostRoots: ctx.hostRoots,
1103
+ });
1104
+ }
1105
+
964
1106
  function isValidRegistryEntry(value) {
965
1107
  return (
966
1108
  typeof value === 'object' &&
@@ -990,6 +1132,7 @@ function runHelpText() {
990
1132
  ' <from> registry module specifier (package subpath, path, or file: URL)',
991
1133
  ' [task] Boss intent; read from stdin when omitted',
992
1134
  ' [reply] Boss reply to a parked session; read from stdin when omitted',
1135
+ ' -- end of options; use before a task or reply that starts with -',
993
1136
  '',
994
1137
  'Options:',
995
1138
  ' --player <role>=<agent> bind a required role (repeatable)',
@@ -1000,6 +1143,8 @@ function runHelpText() {
1000
1143
  ' output or questions) instead of plain text',
1001
1144
  ' --last resume the most recently parked session',
1002
1145
  ' --verbose forward telemetry topics to stderr',
1146
+ ' --no-provision never create engine links beside a',
1147
+ ' filesystem <from> module',
1003
1148
  ' -h, --help print this help',
1004
1149
  '',
1005
1150
  ' <agent> is <adapter>[:<model>][@<effort>] over the shorthands',
@@ -919,9 +919,30 @@ export function createPlaybookCaptainShell(options, deps = {}) {
919
919
  if (visibilityControlError !== undefined)
920
920
  throw visibilityControlError;
921
921
  }
922
+ // CAPTAIN-34/35: a parentless internal Captain frame holds no recoverable
923
+ // work, so any rejected boundary call disposes the stack instead of
924
+ // stranding a frame that would refuse every later registered command. A
925
+ // parentless external root keeps its frame for Boss recovery.
926
+ async function failParentlessBoundary(frame, error) {
927
+ if (!frame.internal || !frames.includes(frame))
928
+ throw error;
929
+ if (!disposing) {
930
+ try {
931
+ await disposeStack('failure');
932
+ }
933
+ catch {
934
+ // The boundary failure wins; disposal detail stays on telemetry.
935
+ }
936
+ }
937
+ const commands = [...enablementById.values()]
938
+ .map((enablement) => `/${enablement.command} <task>`)
939
+ .join(' or ');
940
+ throw new Error('Captain could not finish that turn and the engagement was reset. ' +
941
+ `Send the request again${commands ? `, or start a playbook directly with ${commands}` : ''}.`, { cause: error });
942
+ }
922
943
  async function returnBoundaryFailure(frame, error, context) {
923
944
  if (!frame.parent)
924
- throw error;
945
+ await failParentlessBoundary(frame, error);
925
946
  await resumeParent(frame, {
926
947
  status: context.signal.aborted ? 'aborted' : 'error',
927
948
  playbookId: frame.entry.id,
@@ -1126,7 +1147,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1126
1147
  completed = true;
1127
1148
  }
1128
1149
  else {
1129
- throw error;
1150
+ await failParentlessBoundary(frame, error);
1130
1151
  }
1131
1152
  }
1132
1153
  finally {
@@ -103,7 +103,7 @@ class VisibilityControlError extends Error {
103
103
  }
104
104
  }
105
105
 
106
- type DisposalReason = 'dismiss' | 'final' | 'dispose';
106
+ type DisposalReason = 'dismiss' | 'final' | 'dispose' | 'failure';
107
107
 
108
108
  interface ControlLedger {
109
109
  activePlaybookId?: string;
@@ -1275,12 +1275,38 @@ export function createPlaybookCaptainShell(
1275
1275
  if (visibilityControlError !== undefined) throw visibilityControlError;
1276
1276
  }
1277
1277
 
1278
+ // CAPTAIN-34/35: a parentless internal Captain frame holds no recoverable
1279
+ // work, so any rejected boundary call disposes the stack instead of
1280
+ // stranding a frame that would refuse every later registered command. A
1281
+ // parentless external root keeps its frame for Boss recovery.
1282
+ async function failParentlessBoundary(
1283
+ frame: EngagementFrame,
1284
+ error: unknown,
1285
+ ): Promise<never> {
1286
+ if (!frame.internal || !frames.includes(frame)) throw error;
1287
+ if (!disposing) {
1288
+ try {
1289
+ await disposeStack('failure');
1290
+ } catch {
1291
+ // The boundary failure wins; disposal detail stays on telemetry.
1292
+ }
1293
+ }
1294
+ const commands = [...enablementById.values()]
1295
+ .map((enablement) => `/${enablement.command} <task>`)
1296
+ .join(' or ');
1297
+ throw new Error(
1298
+ 'Captain could not finish that turn and the engagement was reset. ' +
1299
+ `Send the request again${commands ? `, or start a playbook directly with ${commands}` : ''}.`,
1300
+ { cause: error },
1301
+ );
1302
+ }
1303
+
1278
1304
  async function returnBoundaryFailure(
1279
1305
  frame: EngagementFrame,
1280
1306
  error: unknown,
1281
1307
  context: CaptainContext,
1282
1308
  ): Promise<void> {
1283
- if (!frame.parent) throw error;
1309
+ if (!frame.parent) await failParentlessBoundary(frame, error);
1284
1310
  await resumeParent(
1285
1311
  frame,
1286
1312
  {
@@ -1524,7 +1550,7 @@ export function createPlaybookCaptainShell(
1524
1550
  await returnBoundaryFailure(frame, error, context);
1525
1551
  completed = true;
1526
1552
  } else {
1527
- throw error;
1553
+ await failParentlessBoundary(frame, error);
1528
1554
  }
1529
1555
  } finally {
1530
1556
  activeTurnSummary = undefined;
@@ -10,6 +10,13 @@
10
10
  # settings inline: an adapter shorthand (claude, codex) or a block with
11
11
  # adapter/model/effort/permissions. Retuning one agent never changes
12
12
  # another.
13
+
14
+ # Each adapter needs its vendor SDK installed as its own top-level
15
+ # install root — they are optional peer dependencies, so you pay only
16
+ # for the vendors named below:
17
+ # claude -> npm install -g @anthropic-ai/claude-agent-sdk
18
+ # codex -> npm install -g @openai/codex-sdk
19
+ # Drop an adapter from this file and you can skip its SDK entirely.
13
20
  # Every seeded agent runs in cligent's protected auto mode
14
21
  # (permissions.mode: auto): claude maps it to permissionMode auto, codex to
15
22
  # on-request + auto_review. Codex roles also grant writablePaths: ['.git']
package/slc/link.md CHANGED
@@ -1335,6 +1335,20 @@ The emitted module:
1335
1335
  type; supplying one is a construction error, so a linker that judges a
1336
1336
  runtime-owned arm to have lost payload detail under erasure shall report
1337
1337
  that gap rather than emit the entry.
1338
+ - Supplies `spec.compat` with the compatibility values current at link time:
1339
+ `{ artifactSchema, runtimeAbi }`, where `artifactSchema` is `1` — the
1340
+ schema number of the thin-module format this §Output defines — and
1341
+ `runtimeAbi` is the installed shared engine's `RUNTIME_ABI` self-report.
1342
+ The linker shall verify that the installed engine lists the emitted
1343
+ schema in `SUPPORTED_ARTIFACT_SCHEMAS` and treat its absence as a
1344
+ link-time error; it shall not stamp a different member (such as the
1345
+ highest) merely because that engine also supports a newer artifact
1346
+ format — the declaration names the format of the emitted module, not
1347
+ the capability of the emitting engine. The factory checks the
1348
+ declaration against the engine instance that actually loads the emitted
1349
+ module and fails construction on a mismatch, so an artifact linked under
1350
+ one engine cannot run silently skewed under another. Modules emitted
1351
+ before this contract carry no `compat` member and remain loadable.
1338
1352
  - Default-exports the factory call as `createPlaybookRuntime`, typed
1339
1353
  `PlaybookRuntimeFactory<PlaybookRuntimeOptions>`.
1340
1354
  - Exposes, under an `_internal` export, the pure helpers verification
@@ -70,9 +70,26 @@ export declare const BOSS_REPLY_ERRORS: {
70
70
  readonly missingQuestion: "needsBossReply outcome missing 'question' field";
71
71
  readonly unregisteredState: (stateId: string) => string;
72
72
  };
73
+ /** The runtime ABI this engine implements (DR-022). */
74
+ export declare const RUNTIME_ABI = 1;
75
+ /** The linked-artifact schema versions this engine accepts (DR-022). */
76
+ export declare const SUPPORTED_ARTIFACT_SCHEMAS: readonly number[];
77
+ /** A linked artifact's declared link-time compatibility values (DR-022). */
78
+ export interface XStatePlaybookRuntimeCompat {
79
+ /** The artifact schema version the linker emitted. */
80
+ artifactSchema: number;
81
+ /** The engine ABI the artifact was linked against. */
82
+ runtimeAbi: number;
83
+ }
73
84
  export interface XStatePlaybookRuntimeSpec<TOptions> {
74
85
  /** Diagnostic label used in internal invariant errors. Default 'playbook'. */
75
86
  label?: string;
87
+ /**
88
+ * Link-time compatibility declaration checked at construction against the
89
+ * loaded engine's self-report (DR-022). Absent: a legacy artifact emitted
90
+ * before the contract — constructed with no compatibility check.
91
+ */
92
+ compat?: XStatePlaybookRuntimeCompat;
76
93
  /** Validate and JSON-snapshot the caller's per-run options. */
77
94
  snapshotOptions: (value: unknown) => TOptions;
78
95
  /** Derive the FSM machine input from validated options. Default: identity. */
@@ -186,6 +203,16 @@ export interface PlayerBridgeSpec {
186
203
  resumableStateIds: ReadonlySet<string>;
187
204
  }
188
205
  export declare function createPlayerBridge(spec: PlayerBridgeSpec, ports: PlaybookPorts, getActiveSignal?: () => AbortSignal | undefined, boundary?: RuntimeBoundaryCalls, onControlPlaneError?: (error: unknown) => void): PromiseActorLogic<PlaybookActorOutput, PlaybookPlayerInput>;
206
+ /**
207
+ * Default direct-Captain adjudicator prompt (DR-025). The single statement of
208
+ * the `{ guard, …structuralPayloadFields }` reply contract, shared with the
209
+ * compiled default Captain artifact so the wording cannot drift.
210
+ */
211
+ export declare function defaultBuildCaptainJudgePrompt(input: {
212
+ readonly stateId: string;
213
+ readonly sourceItem: string;
214
+ readonly result: Readonly<Record<string, string>>;
215
+ }, finalText: string): string;
189
216
  /** Targets of the FSM's `awaitBossReply` BOSS_REPLY transitions. */
190
217
  export declare function resumableStateIdsFromMachine(machine: AnyStateMachine): ReadonlySet<string>;
191
218
  /**