@sublang/playbook 3.1.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/README.md +64 -99
  2. package/docs/assets/playbook-venn.svg +13 -0
  3. package/docs/cli.md +83 -9
  4. package/docs/configuration.md +5 -3
  5. package/package.json +7 -4
  6. package/reference/sdlc/captain.md +70 -83
  7. package/reference/sdlc/captain.playbook/captain.fsm.d.ts +127 -142
  8. package/reference/sdlc/captain.playbook/captain.fsm.js +349 -470
  9. package/reference/sdlc/captain.playbook/captain.fsm.ts +535 -598
  10. package/reference/sdlc/captain.playbook/captain.gears.md +37 -41
  11. package/reference/sdlc/captain.playbook/captain.playbook.d.ts +90 -15
  12. package/reference/sdlc/captain.playbook/captain.playbook.js +464 -968
  13. package/reference/sdlc/captain.playbook/captain.playbook.ts +696 -993
  14. package/reference/sdlc/code.playbook/bin/adapter-sdk.js +247 -0
  15. package/reference/sdlc/code.playbook/bin/playbook.js +54 -9
  16. package/reference/sdlc/code.playbook/bin/run.js +97 -0
  17. package/reference/sdlc/code.playbook/code.playbook.js +17 -0
  18. package/reference/sdlc/code.playbook/code.playbook.ts +17 -0
  19. package/reference/sdlc/code.playbook/playbook-captain.d.ts +2 -0
  20. package/reference/sdlc/code.playbook/playbook-captain.js +1784 -215
  21. package/reference/sdlc/code.playbook/playbook-captain.ts +2293 -330
  22. package/reference/sdlc/code.playbook/playbook.config.template.yaml +7 -0
  23. package/reference/sdlc/discuss.playbook/discuss.playbook.js +41 -9
  24. package/reference/sdlc/discuss.playbook/discuss.playbook.ts +42 -9
  25. package/slc/gears2fsm.md +54 -2
  26. package/slc/link.md +293 -25
  27. package/src/runtime.d.ts +29 -1
  28. package/src/runtime.ts +47 -0
  29. package/src/xstate-playbook-runtime.d.ts +97 -5
  30. package/src/xstate-playbook-runtime.js +769 -29
  31. package/src/xstate-playbook-runtime.ts +962 -34
@@ -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');
@@ -68,6 +74,16 @@ export async function runPlaybookCli(options = {}) {
68
74
  ...(options.readStdin ? { readStdin: options.readStdin } : {}),
69
75
  ...(options.sessionsDir ? { sessionsDir: options.sessionsDir } : {}),
70
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
+ : {}),
71
87
  });
72
88
  }
73
89
 
@@ -144,19 +160,37 @@ export async function runPlaybookCli(options = {}) {
144
160
  }
145
161
 
146
162
  // PBCLI-12: readiness reads the adapters of the composed config.
147
- const readiness = checkReadiness(
148
- adaptersFromComposedConfig(composed.config),
149
- env,
150
- 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] : []),
151
172
  );
152
173
  for (const adapter of readiness.unknownAdapters) {
153
174
  stderr.write(
154
175
  `playbook: warning: no readiness check for adapter "${adapter}"\n`,
155
176
  );
156
177
  }
157
- if (readiness.failingAdapters.length > 0) {
178
+ if (readiness.failingAdapters.length > 0 || unusableAdapters.length > 0) {
158
179
  stderr.write(
159
- 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
+ }),
160
194
  );
161
195
  return { code: READINESS_FAILURE_EXIT_CODE };
162
196
  }
@@ -678,12 +712,19 @@ function hasExplicitConfig(argv) {
678
712
  return argv.some((arg) => arg === '--config' || arg.startsWith('--config='));
679
713
  }
680
714
 
681
- function helpText({ userConfigPath, failingAdapters = [] }) {
715
+ function helpText({
716
+ userConfigPath,
717
+ failingAdapters = [],
718
+ sdkFailureLines = [],
719
+ }) {
682
720
  const failures =
683
721
  failingAdapters.length > 0
684
722
  ? [`Adapters not ready: ${failingAdapters.join(', ')}`, '']
685
723
  : [];
686
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,
687
728
  ...failures,
688
729
  'Usage:',
689
730
  ' playbook [--list] [--with <path>]... [--config <path>] [tmux-play options]',
@@ -699,8 +740,12 @@ function helpText({ userConfigPath, failingAdapters = [] }) {
699
740
  ' default config file is never modified.',
700
741
  '',
701
742
  'Adapter setup:',
702
- ' claude: run Claude Code once or set ANTHROPIC_API_KEY.',
703
- ' 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.',
704
749
  '',
705
750
  'Agent swap recipe:',
706
751
  ' - set each agent inline: the top-level captain and every',
@@ -29,6 +29,12 @@ 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';
32
38
  import { provisionEngine } from './provision.js';
33
39
 
34
40
  // PBCLI-19: adapter shorthands the run host can construct.
@@ -77,6 +83,15 @@ export async function runPlaybookRun(options = {}) {
77
83
  readStdin,
78
84
  sessionsDir,
79
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,
80
95
  // PBCLI-37: injected host package roots let tests provision against
81
96
  // synthetic trees, like the injected session store.
82
97
  hostRoots: options.hostRoots,
@@ -159,6 +174,18 @@ async function runFirst(args, ctx) {
159
174
  return { code: EXIT.arg };
160
175
  }
161
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
+
162
189
  let runtime;
163
190
  try {
164
191
  runtime = entry.createRuntime({
@@ -290,6 +317,18 @@ async function runResume(args, ctx) {
290
317
  return { code: EXIT.arg };
291
318
  }
292
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
+
293
332
  let runtime;
294
333
  try {
295
334
  runtime = entry.createRuntime({
@@ -639,6 +678,50 @@ function specsDiagnostic(specs) {
639
678
  return undefined;
640
679
  }
641
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
+
642
725
  function isAgentSpec(spec) {
643
726
  return (
644
727
  typeof spec === 'object' &&
@@ -892,10 +975,23 @@ export function parseRunArgs(argv) {
892
975
  verbose: false,
893
976
  noProvision: false,
894
977
  help: false,
978
+ terminated: false,
895
979
  };
896
980
  const positionals = [];
897
981
  for (let i = 0; i < argv.length; i += 1) {
898
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
+ }
899
995
  if (arg === '--help' || arg === '-h') args.help = true;
900
996
  else if (arg === '--json') args.json = true;
901
997
  else if (arg === '--verbose') args.verbose = true;
@@ -1036,6 +1132,7 @@ function runHelpText() {
1036
1132
  ' <from> registry module specifier (package subpath, path, or file: URL)',
1037
1133
  ' [task] Boss intent; read from stdin when omitted',
1038
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 -',
1039
1136
  '',
1040
1137
  'Options:',
1041
1138
  ' --player <role>=<agent> bind a required role (repeatable)',
@@ -665,6 +665,23 @@ const runtimeSpec = {
665
665
  extractRequiredFields,
666
666
  verbatimPayloadFields: VERBATIM_PAYLOAD_FIELDS,
667
667
  resumableStateIds: registeredResumableStateIds,
668
+ // PBRT-52: CODE's own ControlView context projection. Exactly the four
669
+ // closed-vocabulary classification members the FSM assigns itself, which
670
+ // are what a controller needs to see to describe or steer a CODE
671
+ // engagement. Everything else in `CodingContext` is deliberately absent:
672
+ // `coderPlayer` / `reviewerPlayer` / `committerPlayer` are the resolved
673
+ // host player roster, `intent` is an option value, and `irNumber`,
674
+ // `taskDescription`, `reviews`, `challenges`, and `lastResult` all carry
675
+ // player-authored text — all of which a session-Captain prompt must
676
+ // exclude, or may carry only as fenced quotes
677
+ // (CAPTAIN-9). A member added to `CodingContext`
678
+ // later stays private until it is named here.
679
+ controlContextFields: [
680
+ 'workflow',
681
+ 'changeOrigin',
682
+ 'reviewSubject',
683
+ 'afterReview',
684
+ ],
668
685
  classifyBossText: (text, ports, signal, snapshotOrState, boundary) => classifyBossText(text, ports, signal, snapshotOrState, boundary),
669
686
  classificationStatus: (event) => formatClassification(event.type),
670
687
  statusesForState,
@@ -936,6 +936,23 @@ const runtimeSpec: XStatePlaybookRuntimeSpec<CodePlaybookOptions> = {
936
936
  extractRequiredFields,
937
937
  verbatimPayloadFields: VERBATIM_PAYLOAD_FIELDS,
938
938
  resumableStateIds: registeredResumableStateIds,
939
+ // PBRT-52: CODE's own ControlView context projection. Exactly the four
940
+ // closed-vocabulary classification members the FSM assigns itself, which
941
+ // are what a controller needs to see to describe or steer a CODE
942
+ // engagement. Everything else in `CodingContext` is deliberately absent:
943
+ // `coderPlayer` / `reviewerPlayer` / `committerPlayer` are the resolved
944
+ // host player roster, `intent` is an option value, and `irNumber`,
945
+ // `taskDescription`, `reviews`, `challenges`, and `lastResult` all carry
946
+ // player-authored text — all of which a session-Captain prompt must
947
+ // exclude, or may carry only as fenced quotes
948
+ // (CAPTAIN-9). A member added to `CodingContext`
949
+ // later stays private until it is named here.
950
+ controlContextFields: [
951
+ 'workflow',
952
+ 'changeOrigin',
953
+ 'reviewSubject',
954
+ 'afterReview',
955
+ ],
939
956
  classifyBossText: (text, ports, signal, snapshotOrState, boundary) =>
940
957
  classifyBossText(text, ports, signal, snapshotOrState, boundary),
941
958
  classificationStatus: (event) => formatClassification(event.type),
@@ -1,5 +1,6 @@
1
1
  import type { Captain } from '@sublang/cligent/tmux-play';
2
2
  import type { PlaybookRuntime } from '@sublang/playbook/runtime';
3
+ import { type CaptainControllerPort } from '../captain.playbook/captain.playbook.js';
3
4
  import type { PlaybookSummaryPolicy, RegistryPlayer } from './code.registry.js';
4
5
  export interface CreatePlaybookRuntimeOptions {
5
6
  captainOptions: unknown;
@@ -14,6 +15,7 @@ export interface PlaybookCaptainDeps {
14
15
  readonly command: string;
15
16
  readonly intent: string;
16
17
  }[];
18
+ readonly controller: CaptainControllerPort;
17
19
  }) => PlaybookRuntime;
18
20
  }
19
21
  export interface PlaybookCaptainRegistryEntry {