@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.
package/README.md CHANGED
@@ -34,10 +34,30 @@ or `ANTHROPIC_API_KEY`, and signed-in
34
34
  [Codex CLI](https://github.com/openai/codex) or `OPENAI_API_KEY`.
35
35
 
36
36
  ```sh
37
- npm install -g @sublang/playbook
37
+ npm install -g @sublang/playbook @anthropic-ai/claude-agent-sdk @openai/codex-sdk
38
38
  playbook
39
39
  ```
40
40
 
41
+ The agent SDKs are optional, so you install only the vendors your
42
+ config names — install one and the package stays around 14 MB rather
43
+ than pulling every stack. Name each SDK as its own top-level install
44
+ root, exactly as above: an SDK nested inside another package's subtree
45
+ is not reachable from the adapter that loads it. Which versions work
46
+ is cligent's knowledge and ships with it: if a runtime is missing — or
47
+ installed below the version cligent supports — `playbook` says so
48
+ before it launches anything, naming the installed and required
49
+ versions and printing cligent's pinned install command
50
+ ([DR-026](specs/decisions/026-optional-adapter-sdks.md),
51
+ [DR-027](specs/decisions/027-runtime-compatibility-from-cligent.md)).
52
+
53
+ Upgrading from 3.1.0 or earlier? Use the same full line — npm removes
54
+ the SDK copies those releases bundled when it upgrades the package, so
55
+ upgrading `@sublang/playbook` alone leaves no agent SDK installed.
56
+ Running via `npx` instead? Name each SDK as a sibling package of the
57
+ same invocation (`npx -y -p @sublang/playbook -p <sdk> playbook`) — no
58
+ install command reaches npx's ephemeral tree; see
59
+ [docs/cli.md](docs/cli.md).
60
+
41
61
  The first launch seeds a commented config at
42
62
  `${XDG_CONFIG_HOME:-$HOME/.config}/playbook/playbook.config.yaml`,
43
63
  composes a `tmux-play` config, checks the declared adapters, and opens
@@ -70,14 +90,17 @@ playbook run @sublang/playbook/code/registry "add a test for parseArgs" --json
70
90
  - **[docs/embedding.md](docs/embedding.md)** — the six-port runtime
71
91
  contract for hosts other than `tmux-play`.
72
92
 
73
- > **Current release:** 3.0.0. The composed system — the compiled default
93
+ > **Current release:** 4.0.0. The composed system — the compiled default
74
94
  > Captain, CODE and DISCUSS, nested playbook calls, script actors and the
75
95
  > GEARS optimize pass, the semver-stable six-port runtime contract, and
76
96
  > non-interactive `playbook run` with parked-session resume — landed in
77
97
  > 1.0.0. Since then, `playbook run` gained defaults in the user config,
78
- > and 3.0.0 replaces the top-level `profiles` map with
79
- > inline agent settings (existing configs migrate themselves on the next
80
- > launch). See the [CHANGELOG](CHANGELOG.md).
98
+ > 3.0.0 replaced the top-level `profiles` map with inline agent settings
99
+ > (existing configs migrate themselves on the next launch), 3.1.0
100
+ > added the linked-artifact/engine compatibility check, and 4.0.0 made
101
+ > the agent SDKs optional — an install carries only the vendors you name
102
+ > — with which versions work now owned and published by cligent. See the
103
+ > [CHANGELOG](CHANGELOG.md).
81
104
 
82
105
  ## How it compiles
83
106
 
package/docs/cli.md CHANGED
@@ -7,6 +7,51 @@
7
7
  one-shot non-interactive `run`. Agent settings for both come from the
8
8
  [config](configuration.md).
9
9
 
10
+ ## Installing agent SDKs
11
+
12
+ Each adapter is backed by a vendor runtime that installing
13
+ `@sublang/playbook` never downloads for you, so no install carries an
14
+ agent stack you did not ask for. Which versions each adapter supports
15
+ is [cligent](https://github.com/sublang-ai/cligent)'s knowledge and
16
+ ships with it
17
+ ([DR-027](../specs/decisions/027-runtime-compatibility-from-cligent.md));
18
+ the commands below install the latest, which cligent accepts from its
19
+ supported floor up. Install the SDKs your config names, each as its
20
+ own top-level install root:
21
+
22
+ ```sh
23
+ npm install -g @sublang/playbook @anthropic-ai/claude-agent-sdk # claude
24
+ npm install -g @sublang/playbook @openai/codex-sdk # codex
25
+ npm install -g @sublang/playbook @opencode-ai/sdk opencode-ai # opencode (SDK + CLI)
26
+ ```
27
+
28
+ The `gemini` adapter needs no SDK install — its transport ships inside
29
+ cligent — only the `gemini` CLI on `PATH`, at a version cligent
30
+ supports; the preflight gates it like the SDKs.
31
+
32
+ **Upgrading from ≤ 3.1.0:** run the same full line. The old releases
33
+ bundled the SDKs inside `@sublang/playbook`'s own tree, and npm
34
+ removes that bundled copy when it upgrades to a version that no
35
+ longer declares them — an in-place `npm install -g @sublang/playbook`
36
+ alone leaves no SDK behind.
37
+
38
+ The "own top-level root" part matters. The adapter that imports the SDK
39
+ lives at `@sublang/playbook/node_modules/@sublang/cligent/`, and Node
40
+ finds a bare specifier by walking *up* from there — which reaches the
41
+ install prefix's own `node_modules`, but never into a sibling package's
42
+ subtree. An SDK that landed inside some other package is invisible to
43
+ the adapter even though it is on disk
44
+ ([DR-026](../specs/decisions/026-optional-adapter-sdks.md)).
45
+
46
+ Both surfaces check this before doing any work: a declared adapter
47
+ whose runtime is not loadable — or is installed below the version
48
+ cligent supports — blocks the launch and names the adapter. An absent
49
+ runtime is reported as not installed; a stale one with its installed
50
+ and required versions, never as absent. Either way the remedy printed
51
+ is cligent's pinned install, `npm install -g <package>@<version>`, so
52
+ following it cannot install a version the gate refuses again
53
+ ([PBCLI-40](../specs/user/playbook-cli.md#pbcli-40)).
54
+
10
55
  ## Interactive
11
56
 
12
57
  ```sh
@@ -15,7 +60,22 @@ playbook --list # ids, slash commands, and intents; no launch
15
60
  playbook --help # config path, auth pointers, agent-swap recipe
16
61
  ```
17
62
 
18
- Without a global install, `npx @sublang/playbook` runs the same bin.
63
+ Without a global install, `npx` runs the same bin — but name each
64
+ agent SDK as a sibling package of the same invocation:
65
+
66
+ ```sh
67
+ npx -y -p @sublang/playbook -p @anthropic-ai/claude-agent-sdk playbook
68
+ ```
69
+
70
+ A bare `npx @sublang/playbook` cannot be repaired by any install
71
+ command: npx materializes the run in an ephemeral cache tree whose
72
+ ancestor walk touches no global prefix, so an SDK installed with
73
+ `npm install -g` is invisible to it. The preflight detects this case
74
+ and prints the multi-package re-run instead of an install line, naming
75
+ every SDK your config needs at cligent's pinned version — including any
76
+ already present, since each distinct package set is a distinct tree —
77
+ and replaying your original arguments, so the printed command works in
78
+ one hop.
19
79
 
20
80
  The command resolves its config (seeding it on first run), composes a
21
81
  `tmux-play` config, checks adapter readiness, and launches. It exits
@@ -67,6 +127,7 @@ intents the same way you would to `claude -p` or `codex exec`.
67
127
  | `--option <key>=<value>` | a playbook option (CODE's `committer`) |
68
128
  | `--cwd <dir>` | the agents' working directory |
69
129
  | `--json` | one envelope: `outcome`, `sessionId`, output or questions |
130
+ | `--no-provision` | never create engine links beside a filesystem `<from>` |
70
131
 
71
132
  `<agent>` is `<adapter>[:<model>][@<effort>]` — `codex:gpt-5.5@xhigh`,
72
133
  or `claude@high` for the default model at high reasoning effort. The
@@ -79,6 +140,26 @@ Exit codes: `0` terminal, `1` bad argument or module, `2` failure, `3`
79
140
  the playbook needs a Boss reply
80
141
  ([PBCLI-18](../specs/user/playbook-cli.md#pbcli-18)).
81
142
 
143
+ ### Engine provisioning
144
+
145
+ A compiled playbook module imports `xstate` and
146
+ `@sublang/playbook/xstate-runtime` from its own directory. When a
147
+ filesystem `<from>` cannot resolve them — typically under a global
148
+ install with no project-local packages —
149
+ `playbook run` provisions them automatically before loading: it creates
150
+ `node_modules/xstate` and `node_modules/@sublang/playbook` beside the
151
+ module as symlinks to the running host's own packages and prints one
152
+ line naming what it linked
153
+ ([PBCLI-36](../specs/user/playbook-cli.md#pbcli-36),
154
+ [DR-024](../specs/decisions/024-runtime-engine-provisioning.md)).
155
+ A directory where the imports already resolve is never touched — a
156
+ project-local install always wins — and `--no-provision` disables the
157
+ mechanism entirely.
158
+
159
+ If the module's directory is a git repository, add `node_modules/` to
160
+ its `.gitignore` so the provisioned links never land in commits made by
161
+ player agents working there.
162
+
82
163
  ### Resuming a parked run
83
164
 
84
165
  When the playbook stops to ask something, the run is parked, not lost:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sublang/playbook",
3
- "version": "3.0.0",
3
+ "version": "4.0.0",
4
4
  "type": "module",
5
5
  "description": "Composable XState v5 playbook runtime with compiled Captain, CODE, and DISCUSS workflows driven by GEARS specs.",
6
6
  "license": "Apache-2.0",
@@ -67,6 +67,8 @@
67
67
  "reference/sdlc/code.playbook/playbook.config.template.yaml",
68
68
  "reference/sdlc/code.playbook/bin/playbook.js",
69
69
  "reference/sdlc/code.playbook/bin/run.js",
70
+ "reference/sdlc/code.playbook/bin/provision.js",
71
+ "reference/sdlc/code.playbook/bin/adapter-sdk.js",
70
72
  "reference/sdlc/discuss.playbook/discuss.gears.md",
71
73
  "reference/sdlc/discuss.playbook/discuss.fsm.ts",
72
74
  "reference/sdlc/discuss.playbook/discuss.fsm.js",
@@ -128,15 +130,15 @@
128
130
  "provenance": true
129
131
  },
130
132
  "dependencies": {
131
- "@anthropic-ai/claude-agent-sdk": "^0.3.154",
132
- "@openai/codex-sdk": "^0.139.0",
133
- "@sublang/cligent": "^0.16.0",
133
+ "@sublang/cligent": "^0.18.0",
134
134
  "@sublang/spex": "^0.3.0",
135
135
  "p-queue": "^9.3.1",
136
136
  "xstate": "^5.19.4",
137
137
  "yaml": "^2.9.0"
138
138
  },
139
139
  "devDependencies": {
140
+ "@anthropic-ai/claude-agent-sdk": "^0.3.221",
141
+ "@openai/codex-sdk": "^0.146.0",
140
142
  "@types/node": "^22.0.0",
141
143
  "typescript": "^5.8.0",
142
144
  "vitest": "^3.0.0"
@@ -9,7 +9,7 @@
9
9
  import PQueue from 'p-queue';
10
10
  import { createActor, fromPromise } from 'xstate';
11
11
  import { captainMachine, } from './captain.fsm.js';
12
- import { assertJsonSafe, combineAbortSignals, createNestedPlaybookBridge, normalizeError, normalizePlaybookSnapshot, snapshotJsonValue, snapshotPlaybookSession, validateCaptainResult, waitForPlaybookQuiescence, } from '../../../src/xstate-runtime.js';
12
+ import { assertJsonSafe, combineAbortSignals, createNestedPlaybookBridge, defaultBuildCaptainJudgePrompt, normalizeError, normalizePlaybookSnapshot, snapshotJsonValue, snapshotPlaybookSession, validateCaptainResult, waitForPlaybookQuiescence, } from '../../../src/xstate-runtime.js';
13
13
  const CAPTAIN_OPTIONS = {
14
14
  visibility: 'visible',
15
15
  resume: false,
@@ -207,19 +207,16 @@ function requiredOutputFields(description) {
207
207
  return fields;
208
208
  }
209
209
  function makeJudgePrompt(input, visibleText) {
210
+ return defaultBuildCaptainJudgePrompt(input, visibleText);
211
+ }
212
+ // CAPPLAY-18: a structurally malformed adjudication reply gets exactly one
213
+ // corrective re-ask carrying the rejection reason and the restated shape.
214
+ function makeJudgeRetryPrompt(judgePrompt, rejection) {
210
215
  return [
211
- 'Adjudicate the direct Captain output for this FSM state.',
212
- `State id: ${input.stateId}`,
213
- `Source item: ${input.sourceItem}`,
214
- '',
215
- 'Visible Captain output:',
216
- visibleText,
216
+ judgePrompt,
217
217
  '',
218
- 'Result keys and descriptions:',
219
- ...Object.entries(input.result).map(([key, description]) => `- ${key}: ${description}`),
220
- '',
221
- 'Return one JSON object with exactly one declared guard.',
222
- 'For direct Captain question or response guards, do not include question or response; the runtime injects the visible text.',
218
+ `Your previous control reply was rejected: ${normalizeError(rejection).message}.`,
219
+ 'Reply again with exactly one JSON object naming one declared `guard` key and only its required structural fields, with no prose.',
223
220
  ].join('\n');
224
221
  }
225
222
  function adjudicateCaptainOutput(input, visibleText, judgeText) {
@@ -714,7 +711,18 @@ class CaptainPlaybookRuntime {
714
711
  }
715
712
  const judgePrompt = makeJudgePrompt(input, result.finalText);
716
713
  const judgeText = await this.callJudge('captain-output-adjudication', judgePrompt, signal, input.stateId);
717
- return adjudicateCaptainOutput(input, result.finalText, judgeText);
714
+ try {
715
+ return adjudicateCaptainOutput(input, result.finalText, judgeText);
716
+ }
717
+ catch (rejection) {
718
+ // One corrective re-ask on a malformed control reply (CAPPLAY-18);
719
+ // a judge transport failure above never reaches this catch.
720
+ if (signal.aborted)
721
+ throw signal.reason;
722
+ const retryPrompt = makeJudgeRetryPrompt(judgePrompt, rejection);
723
+ const retryText = await this.callJudge('captain-output-adjudication', retryPrompt, signal, input.stateId);
724
+ return adjudicateCaptainOutput(input, result.finalText, retryText);
725
+ }
718
726
  }
719
727
  catch (error) {
720
728
  if (!signal.aborted)
@@ -36,6 +36,7 @@ import {
36
36
  assertJsonSafe,
37
37
  combineAbortSignals,
38
38
  createNestedPlaybookBridge,
39
+ defaultBuildCaptainJudgePrompt,
39
40
  normalizeError,
40
41
  normalizePlaybookSnapshot,
41
42
  snapshotJsonValue,
@@ -291,19 +292,17 @@ function requiredOutputFields(description: string): readonly string[] {
291
292
  }
292
293
 
293
294
  function makeJudgePrompt(input: CaptainInput, visibleText: string): string {
295
+ return defaultBuildCaptainJudgePrompt(input, visibleText);
296
+ }
297
+
298
+ // CAPPLAY-18: a structurally malformed adjudication reply gets exactly one
299
+ // corrective re-ask carrying the rejection reason and the restated shape.
300
+ function makeJudgeRetryPrompt(judgePrompt: string, rejection: unknown): string {
294
301
  return [
295
- 'Adjudicate the direct Captain output for this FSM state.',
296
- `State id: ${input.stateId}`,
297
- `Source item: ${input.sourceItem}`,
298
- '',
299
- 'Visible Captain output:',
300
- visibleText,
301
- '',
302
- 'Result keys and descriptions:',
303
- ...Object.entries(input.result).map(([key, description]) => `- ${key}: ${description}`),
302
+ judgePrompt,
304
303
  '',
305
- 'Return one JSON object with exactly one declared guard.',
306
- 'For direct Captain question or response guards, do not include question or response; the runtime injects the visible text.',
304
+ `Your previous control reply was rejected: ${normalizeError(rejection).message}.`,
305
+ 'Reply again with exactly one JSON object naming one declared `guard` key and only its required structural fields, with no prose.',
307
306
  ].join('\n');
308
307
  }
309
308
 
@@ -780,7 +779,16 @@ class CaptainPlaybookRuntime implements PlaybookRuntime {
780
779
  }
781
780
  const judgePrompt = makeJudgePrompt(input, result.finalText);
782
781
  const judgeText = await this.callJudge('captain-output-adjudication', judgePrompt, signal, input.stateId);
783
- return adjudicateCaptainOutput(input, result.finalText, judgeText);
782
+ try {
783
+ return adjudicateCaptainOutput(input, result.finalText, judgeText);
784
+ } catch (rejection) {
785
+ // One corrective re-ask on a malformed control reply (CAPPLAY-18);
786
+ // a judge transport failure above never reaches this catch.
787
+ if (signal.aborted) throw signal.reason;
788
+ const retryPrompt = makeJudgeRetryPrompt(judgePrompt, rejection);
789
+ const retryText = await this.callJudge('captain-output-adjudication', retryPrompt, signal, input.stateId);
790
+ return adjudicateCaptainOutput(input, result.finalText, retryText);
791
+ }
784
792
  } catch (error) {
785
793
  if (!signal.aborted) this.latchControlError(error);
786
794
  throw error;
@@ -0,0 +1,247 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
+
4
+ // PBCLI-39/40 (DR-027): the agent runtimes are cligent's to know. This
5
+ // module keeps only cligent module-path knowledge — which subpath exports
6
+ // which adapter class — and derives every runtime identity, supported
7
+ // floor, and repair from cligent's shipped descriptor, so a cligent
8
+ // upgrade alone moves the compatibility policy. A runtime's absence or
9
+ // staleness has to be a named gate failure rather than a mid-turn adapter
10
+ // error, which is what this probe provides for both the interactive
11
+ // launcher and `run`.
12
+
13
+ import { readFileSync } from 'node:fs';
14
+ import { sep } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ import { classifyRuntime } from '@sublang/cligent';
18
+ import { AGENT_RUNTIME_TARGETS } from '@sublang/cligent/runtime-targets';
19
+
20
+ // PBCLI-39: adapter shorthand -> the cligent module that constructs it.
21
+ // This is API-shape knowledge, not version knowledge; versions, floors,
22
+ // and repairs come from the descriptor. Adapters absent from cligent's
23
+ // descriptor are excluded from the gate and stay covered by PBCLI-12's
24
+ // unknown-adapter warning.
25
+ export const ADAPTER_MODULES = {
26
+ claude: {
27
+ module: '@sublang/cligent/adapters/claude-code',
28
+ export: 'ClaudeCodeAdapter',
29
+ },
30
+ codex: {
31
+ module: '@sublang/cligent/adapters/codex',
32
+ export: 'CodexAdapter',
33
+ },
34
+ // DR-027 ends gemini's exemption: its missing-SDK rationale was true but
35
+ // incomplete — the CLI can be absent or below cligent's floor, and the
36
+ // descriptor names both.
37
+ gemini: {
38
+ module: '@sublang/cligent/adapters/gemini',
39
+ export: 'GeminiAdapter',
40
+ },
41
+ kimi: {
42
+ module: '@sublang/cligent/adapters/kimi',
43
+ export: 'KimiAdapter',
44
+ },
45
+ opencode: {
46
+ module: '@sublang/cligent/adapters/opencode',
47
+ export: 'OpenCodeAdapter',
48
+ },
49
+ };
50
+
51
+ // PBCLI-39: probe through cligent's own adapter rather than by resolution.
52
+ // `isAvailable()` performs the same load the adapter performs at run time,
53
+ // from cligent's installed module scope — and since cligent enforces its
54
+ // version floors inside that same loader, a passing probe cannot disagree
55
+ // with a failing run, for absence and for staleness alike.
56
+ export async function probeAdapterSdk(adapter) {
57
+ const entry = ADAPTER_MODULES[adapter];
58
+ if (entry === undefined) return true;
59
+ try {
60
+ const AdapterClass = (await import(entry.module))[entry.export];
61
+ return await new AdapterClass().isAvailable();
62
+ } catch {
63
+ // An adapter module that cannot be imported at all is unavailable,
64
+ // not an internal error — the remedy is the same install line.
65
+ return false;
66
+ }
67
+ }
68
+
69
+ // The descriptor rows for one adapter shorthand, in declaration order.
70
+ function runtimeTargetsFor(adapter) {
71
+ return adapter in ADAPTER_MODULES
72
+ ? (AGENT_RUNTIME_TARGETS[adapter] ?? [])
73
+ : [];
74
+ }
75
+
76
+ // PBCLI-39: probe each distinct gated adapter at most once, and classify
77
+ // an unavailable adapter's runtimes through cligent's structured verdict.
78
+ // Only the runtimes at fault are reported: an `opencode` whose CLI is
79
+ // present and in range names the SDK alone, because the two halves have
80
+ // different repairs and naming a healthy one sends the user to install
81
+ // what is already there.
82
+ export async function checkAdapterSdks(
83
+ adapters,
84
+ probe = probeAdapterSdk,
85
+ classify = classifyRuntime,
86
+ ) {
87
+ const known = [...new Set(adapters)].filter(
88
+ (a) => runtimeTargetsFor(a).length > 0,
89
+ );
90
+ const results = await Promise.all(known.map((a) => probe(a)));
91
+ const unusableAdapters = [];
92
+ known.forEach((adapter, i) => {
93
+ if (results[i]) return;
94
+ const verdicts = runtimeTargetsFor(adapter).map((target) =>
95
+ classify(target, false),
96
+ );
97
+ const unsupported = verdicts.filter((v) => v.state === 'unsupported');
98
+ const missing = verdicts.filter(
99
+ (v) => v.state === 'missing' && v.installed === undefined,
100
+ );
101
+ // When neither explains the failure (e.g. the cligent module itself
102
+ // failed to import), fall back to every runtime so the gate still
103
+ // blocks with a usable remedy.
104
+ const culprits =
105
+ unsupported.length > 0 || missing.length > 0
106
+ ? [...unsupported, ...missing]
107
+ : verdicts;
108
+ unusableAdapters.push({ adapter, verdicts: culprits });
109
+ });
110
+ return { unusableAdapters };
111
+ }
112
+
113
+ // PBCLI-40: a run under `npx` / `npm exec` lives in npm's ephemeral cache
114
+ // tree. No `npm install` invocation reaches that tree — a global SDK install
115
+ // is not on its directory-ancestor walk — so the only honest remedy is to
116
+ // re-run with each SDK named as a sibling package of the same exec.
117
+ export function detectEphemeralNpxInstall(moduleUrl = import.meta.url) {
118
+ return fileURLToPath(moduleUrl).split(sep).includes('_npx');
119
+ }
120
+
121
+ // PBCLI-39: the pinned repair specifiers of every descriptor-backed peer
122
+ // SDK in a lineup, deduplicated in descriptor order. The ephemeral re-run
123
+ // must be built from this full set: a fresh exec tree starts empty, so a
124
+ // re-run named after only the currently missing SDKs drops the ones this
125
+ // tree does have and alternates between vendors forever. Pinned specs also
126
+ // mean the re-run installs versions the gate accepts.
127
+ export function mappedSdksFor(adapters) {
128
+ const distinct = new Set(adapters);
129
+ const specs = [];
130
+ for (const [adapter, targets] of Object.entries(AGENT_RUNTIME_TARGETS)) {
131
+ if (!distinct.has(adapter) || !(adapter in ADAPTER_MODULES)) continue;
132
+ for (const target of targets) {
133
+ if (target.kind === 'peer' && !specs.includes(target.repairSpec)) {
134
+ specs.push(target.repairSpec);
135
+ }
136
+ }
137
+ }
138
+ return specs;
139
+ }
140
+
141
+ // Minimal POSIX quoting so a preserved argument survives copy-paste; matches
142
+ // cligent's shared shellQuote. tmux-play is POSIX-only, so no cmd.exe form.
143
+ export function shellQuote(value) {
144
+ if (/^[a-zA-Z0-9_./:=@-]+$/.test(value)) {
145
+ return value;
146
+ }
147
+ return "'" + value.replace(/'/g, "'\\''") + "'";
148
+ }
149
+
150
+ // The running package's own spec, so the re-run reinstalls exactly this
151
+ // version rather than whatever dist-tag `npx` would resolve today.
152
+ function selfPackageSpec() {
153
+ try {
154
+ const manifest = JSON.parse(
155
+ readFileSync(new URL('../../../../package.json', import.meta.url), 'utf8'),
156
+ );
157
+ if (typeof manifest.name === 'string' && typeof manifest.version === 'string') {
158
+ return `${manifest.name}@${manifest.version}`;
159
+ }
160
+ } catch {
161
+ // Fall through to the unpinned name.
162
+ }
163
+ return '@sublang/playbook';
164
+ }
165
+
166
+ // One clause describing a runtime verdict. `unsupported` carries versions,
167
+ // because "not installed" for a runtime that is installed sends the user
168
+ // hunting for something already present (PBCLI-40).
169
+ function describeVerdict(verdict) {
170
+ const named = verdict.target.bundles ?? verdict.target.package;
171
+ if (verdict.state === 'unsupported') {
172
+ return `${named} ${verdict.installed} installed, >=${verdict.target.supportedFrom} required`;
173
+ }
174
+ return `${named} not installed`;
175
+ }
176
+
177
+ // PBCLI-40: name every unusable adapter with its per-runtime verdicts and,
178
+ // for each runtime at fault, the exact pinned command that supplies it.
179
+ // External CLIs are found through PATH, which an exec tree inherits, so
180
+ // their global install lines hold in both cases.
181
+ //
182
+ // options.requiredSdks: the full mapped-spec set of the lineup (see
183
+ // mappedSdksFor) — the ephemeral re-run is built from it, not from the
184
+ // missing subset. options.invocation: the original CLI arguments, preserved
185
+ // on the re-run so the printed command is executable as printed.
186
+ export function adapterSdkFailureLines(unusableAdapters, options = {}) {
187
+ if (unusableAdapters.length === 0) return [];
188
+ const ephemeralNpx = options.ephemeralNpx ?? detectEphemeralNpxInstall();
189
+ const lines = [
190
+ `Adapter runtimes not usable: ${unusableAdapters
191
+ .map(
192
+ ({ adapter, verdicts }) =>
193
+ `${adapter} (${verdicts.map(describeVerdict).join('; ')})`,
194
+ )
195
+ .join(', ')}`,
196
+ ];
197
+ // External CLIs are found through PATH, which persists across exec trees
198
+ // and installed prefixes alike, so their pinned global installs are keyed
199
+ // to the runtimes actually at fault and hold in both branches. A CLI's
200
+ // one-time steps (e.g. a login) follow its install.
201
+ const cliInstalls = unusableAdapters.flatMap(({ verdicts }) =>
202
+ verdicts
203
+ .filter((v) => v.target.kind === 'cli')
204
+ .flatMap((v) => [`npm install -g ${v.repair.spec}`, ...v.repair.steps]),
205
+ );
206
+ const peerInstalls = unusableAdapters.flatMap(({ verdicts }) =>
207
+ verdicts
208
+ .filter((v) => v.target.kind === 'peer')
209
+ .map((v) => `npm install -g ${v.repair.spec}`),
210
+ );
211
+ if (ephemeralNpx) {
212
+ const sdks =
213
+ options.requiredSdks ??
214
+ unusableAdapters.flatMap(({ verdicts }) =>
215
+ verdicts
216
+ .filter((v) => v.target.kind === 'peer')
217
+ .map((v) => v.repair.spec),
218
+ );
219
+ const args = (options.invocation ?? [])
220
+ .map((arg) => ` ${shellQuote(arg)}`)
221
+ .join('');
222
+ lines.push(
223
+ ' This npx / npm exec run is ephemeral: no npm install reaches its tree.',
224
+ );
225
+ if (cliInstalls.length > 0) {
226
+ // Prerequisites first: the re-run probes the CLI again, so following
227
+ // the output top-to-bottom must install it before re-running.
228
+ lines.push(
229
+ ' First install the required CLI (it persists on PATH):',
230
+ ...cliInstalls.map((command) => ` ${command}`),
231
+ );
232
+ }
233
+ lines.push(
234
+ ` ${cliInstalls.length > 0 ? 'Then re-run' : 'Re-run'} with every SDK your config needs named alongside the package:`,
235
+ ` npx -y -p ${selfPackageSpec()}${sdks
236
+ .map((sdk) => ` -p ${sdk}`)
237
+ .join('')} playbook${args}`,
238
+ );
239
+ } else {
240
+ lines.push(
241
+ ...peerInstalls.map((command) => ` ${command}`),
242
+ ...cliInstalls.map((command) => ` ${command}`),
243
+ );
244
+ }
245
+ lines.push('');
246
+ return lines;
247
+ }
@@ -22,6 +22,12 @@ import {
22
22
  parseDocument as parseYamlDocument,
23
23
  stringify as stringifyYaml,
24
24
  } from 'yaml';
25
+ import {
26
+ adapterSdkFailureLines,
27
+ checkAdapterSdks,
28
+ mappedSdksFor,
29
+ probeAdapterSdk,
30
+ } from './adapter-sdk.js';
25
31
 
26
32
  const here = dirname(fileURLToPath(import.meta.url));
27
33
  const templatePath = resolve(here, '..', 'playbook.config.template.yaml');
@@ -67,6 +73,17 @@ export async function runPlaybookCli(options = {}) {
67
73
  ...(options.createAgent ? { createAgent: options.createAgent } : {}),
68
74
  ...(options.readStdin ? { readStdin: options.readStdin } : {}),
69
75
  ...(options.sessionsDir ? { sessionsDir: options.sessionsDir } : {}),
76
+ ...(options.hostRoots ? { hostRoots: options.hostRoots } : {}),
77
+ // PBCLI-39: the run path gates on SDK availability too.
78
+ ...(options.probeAdapterSdk
79
+ ? { probeAdapterSdk: options.probeAdapterSdk }
80
+ : {}),
81
+ ...(options.classifyRuntime
82
+ ? { classifyRuntime: options.classifyRuntime }
83
+ : {}),
84
+ ...(options.ephemeralNpx !== undefined
85
+ ? { ephemeralNpx: options.ephemeralNpx }
86
+ : {}),
70
87
  });
71
88
  }
72
89
 
@@ -143,19 +160,37 @@ export async function runPlaybookCli(options = {}) {
143
160
  }
144
161
 
145
162
  // PBCLI-12: readiness reads the adapters of the composed config.
146
- const readiness = checkReadiness(
147
- adaptersFromComposedConfig(composed.config),
148
- env,
149
- home,
163
+ const declaredAdapters = adaptersFromComposedConfig(composed.config);
164
+ const readiness = checkReadiness(declaredAdapters, env, home);
165
+ // PBCLI-39/40: SDK availability is an independent check with its own
166
+ // remedy — a credential and an SDK can be missing at once, and reporting
167
+ // only the first would send the user round the loop twice.
168
+ const { unusableAdapters } = await checkAdapterSdks(
169
+ declaredAdapters,
170
+ options.probeAdapterSdk ?? probeAdapterSdk,
171
+ ...(options.classifyRuntime ? [options.classifyRuntime] : []),
150
172
  );
151
173
  for (const adapter of readiness.unknownAdapters) {
152
174
  stderr.write(
153
175
  `playbook: warning: no readiness check for adapter "${adapter}"\n`,
154
176
  );
155
177
  }
156
- if (readiness.failingAdapters.length > 0) {
178
+ if (readiness.failingAdapters.length > 0 || unusableAdapters.length > 0) {
157
179
  stderr.write(
158
- helpText({ userConfigPath, failingAdapters: readiness.failingAdapters }),
180
+ helpText({
181
+ userConfigPath,
182
+ failingAdapters: readiness.failingAdapters,
183
+ // PBCLI-40: the ephemeral re-run must carry the lineup's full mapped
184
+ // SDK set and the user's own arguments, so it completes in one hop
185
+ // and is executable exactly as printed.
186
+ sdkFailureLines: adapterSdkFailureLines(unusableAdapters, {
187
+ requiredSdks: mappedSdksFor(declaredAdapters),
188
+ invocation: argv,
189
+ ...(options.ephemeralNpx !== undefined
190
+ ? { ephemeralNpx: options.ephemeralNpx }
191
+ : {}),
192
+ }),
193
+ }),
159
194
  );
160
195
  return { code: READINESS_FAILURE_EXIT_CODE };
161
196
  }
@@ -677,12 +712,19 @@ function hasExplicitConfig(argv) {
677
712
  return argv.some((arg) => arg === '--config' || arg.startsWith('--config='));
678
713
  }
679
714
 
680
- function helpText({ userConfigPath, failingAdapters = [] }) {
715
+ function helpText({
716
+ userConfigPath,
717
+ failingAdapters = [],
718
+ sdkFailureLines = [],
719
+ }) {
681
720
  const failures =
682
721
  failingAdapters.length > 0
683
722
  ? [`Adapters not ready: ${failingAdapters.join(', ')}`, '']
684
723
  : [];
685
724
  return [
725
+ // PBCLI-40: the SDK remedy leads, because an unusable adapter cannot be
726
+ // fixed by the credential advice further down.
727
+ ...sdkFailureLines,
686
728
  ...failures,
687
729
  'Usage:',
688
730
  ' playbook [--list] [--with <path>]... [--config <path>] [tmux-play options]',
@@ -698,8 +740,12 @@ function helpText({ userConfigPath, failingAdapters = [] }) {
698
740
  ' default config file is never modified.',
699
741
  '',
700
742
  'Adapter setup:',
701
- ' claude: run Claude Code once or set ANTHROPIC_API_KEY.',
702
- ' codex: run Codex CLI once or set OPENAI_API_KEY.',
743
+ ' claude: npm install -g @anthropic-ai/claude-agent-sdk, then run',
744
+ ' Claude Code once or set ANTHROPIC_API_KEY.',
745
+ ' codex: npm install -g @openai/codex-sdk, then run Codex CLI once',
746
+ ' or set OPENAI_API_KEY.',
747
+ ' Each SDK is an optional peer dependency, so you install only the',
748
+ ' vendors your config actually names.',
703
749
  '',
704
750
  'Agent swap recipe:',
705
751
  ' - set each agent inline: the top-level captain and every',