@sublang/playbook 13.2.0 → 13.3.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.
@@ -55,8 +55,8 @@ key is that playbook's option slice. Every manifest role must be present
55
55
  exactly once. The launcher injects the rest — you do not write host wiring by
56
56
  hand.
57
57
 
58
- The seeded config runs the stable Coder player on GPT-5.6 Sol with fast mode
59
- enabled and the stable Reviewer player on Claude Opus 5:
58
+ The seeded config runs every stable player on Claude Opus 5 — the Coder at
59
+ `high` effort, the Reviewer and Analyst at `xhigh`:
60
60
 
61
61
  ```yaml
62
62
  captain:
@@ -68,14 +68,11 @@ captain:
68
68
 
69
69
  players:
70
70
  dev.coder:
71
- adapter: codex
72
- model: gpt-5.6-sol
73
- effort: ultra
74
- fastMode: true
71
+ adapter: claude
72
+ model: claude-opus-5
73
+ effort: high
75
74
  permissions:
76
- mode: auto
77
- writablePaths:
78
- - .git # allow git metadata writes under Codex auto mode
75
+ mode: auto # protected auto mode for the Claude Coder
79
76
 
80
77
  dev.reviewer:
81
78
  adapter: claude
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sublang/playbook",
3
- "version": "13.2.0",
3
+ "version": "13.3.0",
4
4
  "type": "module",
5
5
  "description": "Composable XState v5 playbook runtime with compiled Captain, CODE, REVIEW, DECIDE, DEV, BRANCH, and PR workflows driven by GEARS specs.",
6
6
  "license": "Apache-2.0",
@@ -43,7 +43,7 @@ export async function openSessionHost(options) {
43
43
  await installRetainedGenerationsForLaunch({ lease, shell: created.shell, ...(record === undefined ? { freshBoundary: { cwd, structuralProjection: structure, executionProjection: config, snapshot: created.snapshot } } : {}), retainedGenerations: record?.retainedGenerations ?? {}, reconcileRepositoryEffects: created.reconcileRepositoryEffects });
44
44
  record = await lease.read();
45
45
  await replay.flushStoredRecords();
46
- const execute = async (input, retry) => {
46
+ const execute = async (input, retry, actionId, shellActionId) => {
47
47
  if (closed || closing) throw new Error('session host is closing');
48
48
  if (active) throw new Error('session turn is already active');
49
49
  const operation = (async () => {
@@ -53,8 +53,18 @@ export async function openSessionHost(options) {
53
53
  input = prior.uncertain.input;
54
54
  if (!retryPending) throw new Error('retry requires reopening the uncertain checkpoint');
55
55
  } else if (prior?.state !== 'settled') throw new Error('session has an uncertain turn; select Retry or Discard');
56
- if (typeof input !== 'string' || input.trim().length === 0) throw new Error('session input must be nonempty');
56
+ if (actionId === undefined && shellActionId === undefined && (typeof input !== 'string' || input.trim().length === 0)) throw new Error('session input must be nonempty');
57
57
  await created.reconcileRepositoryEffects();
58
+ // The selection is made after reconciliation, so the advertised
59
+ // reading it is validated against is the one this turn settles
60
+ // against, and its Boss text is this turn's durable input.
61
+ if (actionId !== undefined) {
62
+ if (typeof created.shell.submitRuntimeAction !== 'function') throw new Error('this Captain shell advertises no runtime actions');
63
+ input = created.shell.submitRuntimeAction(actionId);
64
+ } else if (shellActionId !== undefined) {
65
+ if (typeof created.shell.submitShellAction !== 'function') throw new Error('this Captain shell advertises no controls of its own');
66
+ input = created.shell.submitShellAction(shellActionId);
67
+ }
58
68
  terminal = undefined; replies = [];
59
69
  const attemptId = options.createAttemptId?.() ?? randomUUID();
60
70
  const marked = retry ? await lease.beginRetry({ expectedAttemptId: prior.uncertain.attemptId, nextAttemptId: attemptId }) : await lease.beginTurn({ input, attemptId, attemptedExecutionProjection: config });
@@ -84,7 +94,7 @@ export async function openSessionHost(options) {
84
94
  })();
85
95
  return closing;
86
96
  };
87
- return Object.freeze({ sessionId, host: created.host, shell: created.shell, lease, read: () => lease.read(), handleBossTurn: (input) => execute(input, false), retry: () => execute(undefined, true), dispose });
97
+ return Object.freeze({ sessionId, host: created.host, shell: created.shell, lease, read: () => lease.read(), handleBossTurn: (input) => execute(input, false), listRuntimeActions: () => created.shell.describeRuntimeActions?.() ?? Object.freeze([]), submitRuntimeAction: (actionId) => execute(undefined, false, actionId), listShellActions: () => created.shell.describeShellActions?.() ?? Object.freeze([]), submitShellAction: (actionId) => execute(undefined, false, undefined, actionId), retry: () => execute(undefined, true), dispose });
88
98
  } catch (cause) {
89
99
  const failures = [cause];
90
100
  let disposed = true;
@@ -1,6 +1,6 @@
1
1
  import { type Captain, type CaptainSession, type TuningSelection } from '@sublang/cligent/tmux-play';
2
2
  import type { Effort, PermissionPolicy } from '@sublang/cligent';
3
- import type { JsonValue, PlaybookEffectLedger, PlaybookEffectLedgerCommandBatch, PlaybookRuntime, PlaybookRuntimeSnapshot } from '@sublang/playbook/runtime';
3
+ import type { JsonValue, PlaybookEffectLedger, PlaybookEffectLedgerCommandBatch, PlaybookControlAction, PlaybookRuntime, PlaybookRuntimeSnapshot } from '@sublang/playbook/runtime';
4
4
  import { type CaptainControllerPort } from '../captain.playbook/captain.playbook.js';
5
5
  import type { PlaybookSummaryPolicy } from './code.registry.js';
6
6
  interface SessionAgent {
@@ -215,6 +215,31 @@ export interface PlaybookCaptainShell extends Captain {
215
215
  exportSnapshot(): PlaybookCaptainShellSnapshot | undefined;
216
216
  exportSettlement(): PlaybookCaptainSettlement | undefined;
217
217
  restore(session: CaptainSession, snapshot: PlaybookCaptainShellSnapshot): Promise<void>;
218
+ /**
219
+ * The active leaf's currently advertised runtime actions, as the host's own
220
+ * reading of the control view the ControlView digest reads (CAPTAIN-60).
221
+ * Declared optional because capability absence is member absence: a shell
222
+ * that publishes no reader advertises nothing.
223
+ */
224
+ describeRuntimeActions?(): readonly PlaybookControlAction[];
225
+ /**
226
+ * Select one currently advertised runtime action as the next Boss turn's
227
+ * decision and return that turn's Boss text, which the host submits through
228
+ * `handleBossTurn` (CAPTAIN-7).
229
+ */
230
+ submitRuntimeAction?(actionId: string): string;
231
+ /**
232
+ * The controls the shell effects on its own behalf, disjoint from the leaf's
233
+ * advertised runtime actions (CAPTAIN-62). Declared optional for the same
234
+ * reason: a shell that publishes no reader advertises nothing.
235
+ */
236
+ describeShellActions?(): readonly PlaybookControlAction[];
237
+ /**
238
+ * Select one currently advertised shell control as the next Boss turn's
239
+ * decision and return that turn's Boss text, which the host submits through
240
+ * `handleBossTurn` (CAPTAIN-7).
241
+ */
242
+ submitShellAction?(actionId: string): string;
218
243
  }
219
244
  export declare function assertPlaybookCaptainUnresolvedEffects(value: unknown): readonly PlaybookCaptainUnresolvedEffect[];
220
245
  /** Validate, detach, and freeze one untrusted shell snapshot. */
@@ -77,6 +77,9 @@ function isCanonicalLocalRoleId(value) {
77
77
  }
78
78
  const UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID = 'reconcile:unresolved-effect';
79
79
  const UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID = 'abandon:unresolved-effect';
80
+ // CAPTAIN-62: the shell's own control, not a runtime action. The id is machine
81
+ // data the host echoes back; the Boss never sees it (CAPPLAY-5).
82
+ const GIVE_UP_ACTION_ID = 'give-up';
80
83
  // The fixed machine-syntax guard of a player's Boss-question suspension
81
84
  // (slc/link.md §Boss-reply suspension): the accepted outcome that parks on
82
85
  // Boss rather than sparing Boss a relay.
@@ -1732,6 +1735,12 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1732
1735
  // CAPTAIN-40's corrective re-ask on the very call whose prose it surfaces —
1733
1736
  // the selection reaches the controller port after that call's frame is gone.
1734
1737
  let decisionCall;
1738
+ // CAPTAIN-7: the control the host selected for the turn it is about to
1739
+ // submit, held with the exact Boss text that turn carries so an unrelated
1740
+ // turn never consumes the selection. One turn consumes it, whichever it is,
1741
+ // and one slot holds it whichever surface it came from — the leaf's
1742
+ // advertised actions or the shell's own controls (CAPTAIN-62).
1743
+ let pendingHostSelection;
1735
1744
  let lastAction;
1736
1745
  let lastSettlementStatus;
1737
1746
  const retainedGenerationCandidates = new Map();
@@ -3889,6 +3898,118 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3889
3898
  lines.push(retainedResumptionDigest());
3890
3899
  return lines.join('\n');
3891
3900
  };
3901
+ // CAPTAIN-60: the same reading the ControlView digest performs, published as
3902
+ // data instead of prose. It states what the digest states and withholds what
3903
+ // the digest withholds: an idle shell, a leaf with no control surface, and a
3904
+ // control view that cannot be read all advertise nothing, and a leaf fenced
3905
+ // for repository-effect reconciliation advertises only its fence controls.
3906
+ // The pairs are detached and frozen, so a host holds the shell's reading
3907
+ // rather than a live view the runtime can rewrite underneath it.
3908
+ const advertisedRuntimeActions = () => {
3909
+ const empty = Object.freeze([]);
3910
+ if (lifecycle !== 'ready' || terminallyDisposed)
3911
+ return empty;
3912
+ // Between turns: while a turn is running, the leaf's control view is the
3913
+ // decision's grounding and the host can act on no selection, so the shell
3914
+ // publishes none and reads nothing from a runtime mid-run.
3915
+ if (activeTurn !== undefined || activeTurnHostCalls !== undefined) {
3916
+ return empty;
3917
+ }
3918
+ const leaf = leafFrame();
3919
+ if (!leaf || typeof leaf.runtime.describe !== 'function')
3920
+ return empty;
3921
+ refreshRetainedEffectFence();
3922
+ const fenced = retainedEffectReconciliation !== undefined;
3923
+ if (fenced && typeof leaf.runtime.apply !== 'function')
3924
+ return empty;
3925
+ let actions;
3926
+ try {
3927
+ actions = leaf.runtime.describe().actions;
3928
+ }
3929
+ catch {
3930
+ // An unreadable control view advertises nothing.
3931
+ return empty;
3932
+ }
3933
+ return Object.freeze(actions
3934
+ .filter((action) => !fenced ||
3935
+ action.id === UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID ||
3936
+ action.id === UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID)
3937
+ .map((action) => Object.freeze({ id: action.id, label: action.label })));
3938
+ };
3939
+ // CAPTAIN-62: the controls the shell effects itself, published beside the
3940
+ // leaf's advertised actions and never mixed with them — these are the
3941
+ // shell's own, so an engaged root offers one whatever its leaf advertises,
3942
+ // fenced or not. A fenced root keeps its control because giving up reports
3943
+ // unresolved effects (CAPTAIN-58) rather than reconciling them, and a run
3944
+ // whose effects are unknown is exactly the run a Boss gives up on.
3945
+ const advertisedShellActions = () => {
3946
+ const empty = Object.freeze([]);
3947
+ if (lifecycle !== 'ready' || terminallyDisposed)
3948
+ return empty;
3949
+ // Between turns, on the same rule the leaf's actions take: mid-turn the
3950
+ // host can act on no selection.
3951
+ if (activeTurn !== undefined || activeTurnHostCalls !== undefined) {
3952
+ return empty;
3953
+ }
3954
+ const root = rootFrame();
3955
+ if (!root)
3956
+ return empty;
3957
+ return Object.freeze([
3958
+ Object.freeze({
3959
+ id: GIVE_UP_ACTION_ID,
3960
+ // The label is the turn's durable Boss text, so it names the run the
3961
+ // way the Boss started it rather than the frame that happens to be
3962
+ // parked inside it.
3963
+ label: `Stop ${frameLabel(root)}`,
3964
+ }),
3965
+ ]);
3966
+ };
3967
+ // CAPTAIN-7: the host's selection of the shell's own control, validated here
3968
+ // against the same live reading. The returned text is the turn the host
3969
+ // submits, exactly as for an advertised runtime action.
3970
+ const selectShellAction = (actionId) => {
3971
+ if (lifecycle !== 'ready' || terminallyDisposed) {
3972
+ throw new Error('a shell control requires an initialized or restored Captain shell');
3973
+ }
3974
+ if (activeTurn !== undefined || activeTurnHostCalls !== undefined) {
3975
+ throw new Error('a shell control cannot be selected during a Boss turn');
3976
+ }
3977
+ if (typeof actionId !== 'string' || actionId.length === 0) {
3978
+ throw new TypeError('a shell control id must be a nonempty string');
3979
+ }
3980
+ const advertised = advertisedShellActions().find((action) => action.id === actionId);
3981
+ if (advertised === undefined) {
3982
+ throw new Error('the shell does not advertise that control');
3983
+ }
3984
+ pendingHostSelection = { kind: 'give-up', text: advertised.label };
3985
+ return advertised.label;
3986
+ };
3987
+ // CAPTAIN-7: the host's own selection of one advertised action, validated
3988
+ // here against the same live reading and validated again at the controller
3989
+ // port before any effect. The returned text is the turn the host submits.
3990
+ const selectRuntimeAction = (actionId) => {
3991
+ if (lifecycle !== 'ready' || terminallyDisposed) {
3992
+ throw new Error('a runtime action requires an initialized or restored Captain shell');
3993
+ }
3994
+ if (activeTurn !== undefined || activeTurnHostCalls !== undefined) {
3995
+ throw new Error('a runtime action cannot be selected during a Boss turn');
3996
+ }
3997
+ if (typeof actionId !== 'string' || actionId.length === 0) {
3998
+ throw new TypeError('a runtime action id must be a nonempty string');
3999
+ }
4000
+ const advertised = advertisedRuntimeActions().find((action) => action.id === actionId);
4001
+ if (advertised === undefined) {
4002
+ // CAPPLAY-5: the chosen id is control data whether or not the leaf
4003
+ // advertises it, so the refusal names the fact and not the string.
4004
+ throw new Error('the active leaf does not advertise that action');
4005
+ }
4006
+ const text = typeof advertised.label === 'string' ? advertised.label.trim() : '';
4007
+ if (text.length === 0) {
4008
+ throw new Error('the advertised action carries no Boss-facing label to submit as its turn');
4009
+ }
4010
+ pendingHostSelection = { kind: 'runtime', actionId, text };
4011
+ return text;
4012
+ };
3892
4013
  // The catalog is registry-authored, not shell-authored: an id, a command,
3893
4014
  // and an intent all arrive from an enabled module. They pass the same seam
3894
4015
  // the ControlView lines do, so an intent carrying a newline cannot open a
@@ -4341,6 +4462,42 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4341
4462
  ? []
4342
4463
  : [labeledBlock('Conversation recap', options.reseedDigest)]),
4343
4464
  ]);
4465
+ // CAPTAIN-7: a turn whose decision the host already made allocates no
4466
+ // decision call. The host's selection is this state's decision reply, so
4467
+ // it passes the runtime's own reply validation and reaches the shell
4468
+ // through the controller port like every other selection — validation,
4469
+ // execution, the outcome report, and the closing reply are the loop's.
4470
+ if (kind === 'decision' && turn?.hostRuntimeActionId !== undefined) {
4471
+ return {
4472
+ status: 'ok',
4473
+ finalText: JSON.stringify({
4474
+ action: 'runtime',
4475
+ actionId: turn.hostRuntimeActionId,
4476
+ }),
4477
+ };
4478
+ }
4479
+ // CAPTAIN-63: a give-up allocates neither call. Its decision is the
4480
+ // host's, and its result phase is answered below from the settlement the
4481
+ // shell already holds, so an exit from a failing provider does not ask
4482
+ // that provider for permission.
4483
+ if (kind === 'decision' && turn?.hostGiveUp === true) {
4484
+ return {
4485
+ status: 'ok',
4486
+ finalText: JSON.stringify({ action: 'dismiss' }),
4487
+ };
4488
+ }
4489
+ if (kind === 'closingReply' && turn?.hostGiveUp === true) {
4490
+ // The durable conversation did not receive this turn, so it is marked
4491
+ // for the CAPTAIN-35 catch-up rather than left believing it is
4492
+ // current: the next call carries the journal records it missed.
4493
+ markConversationCatchUp();
4494
+ // Through the one presentation seam, exactly where a model-composed
4495
+ // reply is presented, so this reply is journaled and single-attempt
4496
+ // like every other. It needs no reply validation or corrective re-ask:
4497
+ // `giveUpReplyText` already validated what it composed.
4498
+ await surfaceSettlement({ context, text: giveUpReplyText() });
4499
+ return { status: 'ok', finalText: 'ok' };
4500
+ }
4344
4501
  const outcome = await durableCall(context, compose);
4345
4502
  if (kind === 'decision') {
4346
4503
  // A model-decided `respond` surfaces this call's own prose, so the
@@ -4537,8 +4694,18 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4537
4694
  return false;
4538
4695
  const label = frameLabel(root);
4539
4696
  // Dismissal leaves the procedure unfinished. Persist the latest safe
4540
- // generation captured for this turn before disposal erases the frames.
4541
- retainOrClearDisposedRoot(root);
4697
+ // generation captured for this turn before disposal erases the frames —
4698
+ // unless the Boss gave up on it (CAPTAIN-44), in which case retaining it
4699
+ // would offer the abandoned run back on the idle digest's next reading.
4700
+ if (activeTurn?.hostGiveUp === true) {
4701
+ pendingRetentionUpdates.set(root.entry.id, {
4702
+ kind: 'clear',
4703
+ rootPlaybookId: root.entry.id,
4704
+ });
4705
+ }
4706
+ else {
4707
+ retainOrClearDisposedRoot(root);
4708
+ }
4542
4709
  try {
4543
4710
  await runEffect(() => disposeStack('dismiss'));
4544
4711
  facts.push(`Dismissed the ${label} engagement.`);
@@ -4994,10 +5161,16 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4994
5161
  if (!leaf) {
4995
5162
  return rejectSelection(selection, 'no engagement is active to dismiss');
4996
5163
  }
5164
+ // CAPTAIN-63: a give-up ends the run. An ordinary dismissal of a nested
5165
+ // call returns to its caller, which is the right reading of "stop this
5166
+ // call" and the wrong one of "stop this workflow" — the Boss gave up on
5167
+ // the root, not on whichever child was parked when they did.
5168
+ const giveUp = turn.hostGiveUp === true;
5169
+ const dismissed = giveUp ? (rootFrame() ?? leaf) : leaf;
4997
5170
  turn.settled = true;
4998
- journalAction({ action: 'dismiss', playbookId: leaf.entry.id });
4999
- const label = frameLabel(leaf);
5000
- if (leaf.parent) {
5171
+ journalAction({ action: 'dismiss', playbookId: dismissed.entry.id });
5172
+ const label = frameLabel(dismissed);
5173
+ if (leaf.parent && !giveUp) {
5001
5174
  // No boundary around the return itself: `resumeParent` disposes the
5002
5175
  // child and drives the parent, and each of those is marked where it
5003
5176
  // happens. A visibility rejection raised on the way back is shell
@@ -5291,6 +5464,28 @@ export function createPlaybookCaptainShell(options, deps = {}) {
5291
5464
  // The reply composes from the authoritative report, never the early
5292
5465
  // `settled` guard. That guard closes duplicate submissions before an effect
5293
5466
  // starts; it is not evidence that anything ran.
5467
+ // CAPTAIN-63: the give-up's closing reply, composed from the same settlement
5468
+ // facts the result-phase prompt would have carried. Shell-authored Boss
5469
+ // prose passes the validation every model reply passes (CAPTAIN-34); what it
5470
+ // interpolates is not host-authored all the way down, so a fact set that
5471
+ // fails validation is dropped rather than spoken and the reply still states
5472
+ // the settlement truthfully.
5473
+ const giveUpReplyText = () => {
5474
+ const report = activeTurn?.report;
5475
+ const facts = report?.bossFacts ?? report?.facts ?? [];
5476
+ const opening = 'Stopped that workflow. I will not resume it.';
5477
+ const closing = 'Send the command again to start fresh work.';
5478
+ const composed = [
5479
+ opening,
5480
+ ...(facts.length === 0
5481
+ ? []
5482
+ : ['Here is what happened:', ...facts.map((fact) => `- ${fact}`)]),
5483
+ closing,
5484
+ ].join('\n');
5485
+ return replyRejection(composed) === undefined
5486
+ ? composed
5487
+ : [opening, closing].join('\n');
5488
+ };
5294
5489
  const failureReplyText = () => {
5295
5490
  const commands = [...enablementById.values()]
5296
5491
  .map((enablement) => `/${enablement.command} <task>`)
@@ -6151,6 +6346,10 @@ export function createPlaybookCaptainShell(options, deps = {}) {
6151
6346
  exportSettlement,
6152
6347
  restore: restoreShellSnapshot,
6153
6348
  installRetainedGenerations,
6349
+ describeRuntimeActions: advertisedRuntimeActions,
6350
+ submitRuntimeAction: selectRuntimeAction,
6351
+ describeShellActions: advertisedShellActions,
6352
+ submitShellAction: selectShellAction,
6154
6353
  async handleBossTurn(turn, context) {
6155
6354
  if (lifecycle !== 'ready' || terminallyDisposed) {
6156
6355
  throw new Error('init must be called first, or restore must complete before handling a Boss turn');
@@ -6184,13 +6383,25 @@ export function createPlaybookCaptainShell(options, deps = {}) {
6184
6383
  const turnHostCalls = new Set();
6185
6384
  activeTurnHostCalls = turnHostCalls;
6186
6385
  activeContext = context;
6187
- const parsed = resolveCommandTurn(turn.prompt);
6386
+ // CAPTAIN-7: one host selection is consumed by one turn. It decides only
6387
+ // the turn carrying the exact text the selection was made for; any other
6388
+ // turn drops it and resolves normally.
6389
+ const selected = pendingHostSelection;
6390
+ pendingHostSelection = undefined;
6391
+ const decided = selected !== undefined && selected.text === turn.prompt
6392
+ ? selected
6393
+ : undefined;
6394
+ const hostRuntimeActionId = decided?.kind === 'runtime' ? decided.actionId : undefined;
6395
+ const hostGiveUp = decided?.kind === 'give-up' ? true : undefined;
6396
+ const parsed = decided === undefined ? resolveCommandTurn(turn.prompt) : undefined;
6188
6397
  activeTurn = {
6189
6398
  id: ++turnSequence,
6190
6399
  captainSyncedJournalSeq: journalSeq,
6191
6400
  bossText: turn.prompt,
6192
6401
  authoritativeText: parsed?.authoritativeText ?? turn.prompt,
6193
6402
  ...(parsed ? { resolution: parsed.resolution } : {}),
6403
+ ...(hostRuntimeActionId === undefined ? {} : { hostRuntimeActionId }),
6404
+ ...(hostGiveUp === undefined ? {} : { hostGiveUp }),
6194
6405
  settled: false,
6195
6406
  presentationAttempted: false,
6196
6407
  settlementFacts: [],
@@ -6324,6 +6535,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
6324
6535
  failure ??= error;
6325
6536
  }
6326
6537
  }
6538
+ pendingHostSelection = undefined;
6327
6539
  // Quarantine is session-wide by design. Only terminal teardown may drop
6328
6540
  // its ownership after every frame host call and the Captain are drained.
6329
6541
  playerTransactions.clear();
@@ -24,6 +24,7 @@ import type {
24
24
  PlaybookCallRequest,
25
25
  PlaybookCallResult,
26
26
  PlaybookCallStart,
27
+ PlaybookControlAction,
27
28
  PlaybookControlView,
28
29
  PlaybookPorts,
29
30
  PlaybookRunResult,
@@ -353,6 +354,31 @@ export interface PlaybookCaptainShell extends Captain {
353
354
  session: CaptainSession,
354
355
  snapshot: PlaybookCaptainShellSnapshot,
355
356
  ): Promise<void>;
357
+ /**
358
+ * The active leaf's currently advertised runtime actions, as the host's own
359
+ * reading of the control view the ControlView digest reads (CAPTAIN-60).
360
+ * Declared optional because capability absence is member absence: a shell
361
+ * that publishes no reader advertises nothing.
362
+ */
363
+ describeRuntimeActions?(): readonly PlaybookControlAction[];
364
+ /**
365
+ * Select one currently advertised runtime action as the next Boss turn's
366
+ * decision and return that turn's Boss text, which the host submits through
367
+ * `handleBossTurn` (CAPTAIN-7).
368
+ */
369
+ submitRuntimeAction?(actionId: string): string;
370
+ /**
371
+ * The controls the shell effects on its own behalf, disjoint from the leaf's
372
+ * advertised runtime actions (CAPTAIN-62). Declared optional for the same
373
+ * reason: a shell that publishes no reader advertises nothing.
374
+ */
375
+ describeShellActions?(): readonly PlaybookControlAction[];
376
+ /**
377
+ * Select one currently advertised shell control as the next Boss turn's
378
+ * decision and return that turn's Boss text, which the host submits through
379
+ * `handleBossTurn` (CAPTAIN-7).
380
+ */
381
+ submitShellAction?(actionId: string): string;
356
382
  }
357
383
 
358
384
  // Per-enabled-playbook binding the shell resolves at init from the exact
@@ -575,6 +601,9 @@ function isCanonicalLocalRoleId(value: unknown): value is string {
575
601
  const UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID =
576
602
  'reconcile:unresolved-effect';
577
603
  const UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID = 'abandon:unresolved-effect';
604
+ // CAPTAIN-62: the shell's own control, not a runtime action. The id is machine
605
+ // data the host echoes back; the Boss never sees it (CAPPLAY-5).
606
+ const GIVE_UP_ACTION_ID = 'give-up';
578
607
  // The fixed machine-syntax guard of a player's Boss-question suspension
579
608
  // (slc/link.md §Boss-reply suspension): the accepted outcome that parks on
580
609
  // Boss rather than sparing Boss a relay.
@@ -608,6 +637,19 @@ interface ActiveTurn {
608
637
  */
609
638
  readonly authoritativeText: string;
610
639
  readonly resolution?: CaptainParsedResolution;
640
+ /**
641
+ * The advertised action id the host selected for this turn (CAPTAIN-7). Its
642
+ * presence is the turn's decision: the decision state receives that
643
+ * selection from the shell and allocates no decision call.
644
+ */
645
+ readonly hostRuntimeActionId?: string;
646
+ /**
647
+ * Set where the host decided this turn by selecting the shell's own give-up
648
+ * control (CAPTAIN-62). Like `hostRuntimeActionId` its presence is the
649
+ * turn's decision, and it additionally settles the result phase in the shell
650
+ * so the turn makes no model call at all (CAPTAIN-63).
651
+ */
652
+ readonly hostGiveUp?: true;
611
653
  /** A settlement with status `ok` is final for the turn (DR-029). */
612
654
  settled: boolean;
613
655
  /**
@@ -3118,6 +3160,15 @@ export function createPlaybookCaptainShell(
3118
3160
  outcome: DurableCallOutcome;
3119
3161
  }
3120
3162
  | undefined;
3163
+ // CAPTAIN-7: the control the host selected for the turn it is about to
3164
+ // submit, held with the exact Boss text that turn carries so an unrelated
3165
+ // turn never consumes the selection. One turn consumes it, whichever it is,
3166
+ // and one slot holds it whichever surface it came from — the leaf's
3167
+ // advertised actions or the shell's own controls (CAPTAIN-62).
3168
+ let pendingHostSelection:
3169
+ | { readonly kind: 'runtime'; readonly actionId: string; readonly text: string }
3170
+ | { readonly kind: 'give-up'; readonly text: string }
3171
+ | undefined;
3121
3172
  let lastAction: ControllerAction | undefined;
3122
3173
  let lastSettlementStatus: SettlementEvidence['status'] | undefined;
3123
3174
  // DR-038 §2: a turn can dispose its root before the durable caller asks
@@ -5900,6 +5951,132 @@ export function createPlaybookCaptainShell(
5900
5951
  return lines.join('\n');
5901
5952
  };
5902
5953
 
5954
+ // CAPTAIN-60: the same reading the ControlView digest performs, published as
5955
+ // data instead of prose. It states what the digest states and withholds what
5956
+ // the digest withholds: an idle shell, a leaf with no control surface, and a
5957
+ // control view that cannot be read all advertise nothing, and a leaf fenced
5958
+ // for repository-effect reconciliation advertises only its fence controls.
5959
+ // The pairs are detached and frozen, so a host holds the shell's reading
5960
+ // rather than a live view the runtime can rewrite underneath it.
5961
+ const advertisedRuntimeActions = (): readonly PlaybookControlAction[] => {
5962
+ const empty: readonly PlaybookControlAction[] = Object.freeze([]);
5963
+ if (lifecycle !== 'ready' || terminallyDisposed) return empty;
5964
+ // Between turns: while a turn is running, the leaf's control view is the
5965
+ // decision's grounding and the host can act on no selection, so the shell
5966
+ // publishes none and reads nothing from a runtime mid-run.
5967
+ if (activeTurn !== undefined || activeTurnHostCalls !== undefined) {
5968
+ return empty;
5969
+ }
5970
+ const leaf = leafFrame();
5971
+ if (!leaf || typeof leaf.runtime.describe !== 'function') return empty;
5972
+ refreshRetainedEffectFence();
5973
+ const fenced = retainedEffectReconciliation !== undefined;
5974
+ if (fenced && typeof leaf.runtime.apply !== 'function') return empty;
5975
+ let actions: readonly PlaybookControlAction[];
5976
+ try {
5977
+ actions = leaf.runtime.describe().actions;
5978
+ } catch {
5979
+ // An unreadable control view advertises nothing.
5980
+ return empty;
5981
+ }
5982
+ return Object.freeze(
5983
+ actions
5984
+ .filter(
5985
+ (action) =>
5986
+ !fenced ||
5987
+ action.id === UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID ||
5988
+ action.id === UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID,
5989
+ )
5990
+ .map((action) => Object.freeze({ id: action.id, label: action.label })),
5991
+ );
5992
+ };
5993
+
5994
+ // CAPTAIN-62: the controls the shell effects itself, published beside the
5995
+ // leaf's advertised actions and never mixed with them — these are the
5996
+ // shell's own, so an engaged root offers one whatever its leaf advertises,
5997
+ // fenced or not. A fenced root keeps its control because giving up reports
5998
+ // unresolved effects (CAPTAIN-58) rather than reconciling them, and a run
5999
+ // whose effects are unknown is exactly the run a Boss gives up on.
6000
+ const advertisedShellActions = (): readonly PlaybookControlAction[] => {
6001
+ const empty: readonly PlaybookControlAction[] = Object.freeze([]);
6002
+ if (lifecycle !== 'ready' || terminallyDisposed) return empty;
6003
+ // Between turns, on the same rule the leaf's actions take: mid-turn the
6004
+ // host can act on no selection.
6005
+ if (activeTurn !== undefined || activeTurnHostCalls !== undefined) {
6006
+ return empty;
6007
+ }
6008
+ const root = rootFrame();
6009
+ if (!root) return empty;
6010
+ return Object.freeze([
6011
+ Object.freeze({
6012
+ id: GIVE_UP_ACTION_ID,
6013
+ // The label is the turn's durable Boss text, so it names the run the
6014
+ // way the Boss started it rather than the frame that happens to be
6015
+ // parked inside it.
6016
+ label: `Stop ${frameLabel(root)}`,
6017
+ }),
6018
+ ]);
6019
+ };
6020
+
6021
+ // CAPTAIN-7: the host's selection of the shell's own control, validated here
6022
+ // against the same live reading. The returned text is the turn the host
6023
+ // submits, exactly as for an advertised runtime action.
6024
+ const selectShellAction = (actionId: string): string => {
6025
+ if (lifecycle !== 'ready' || terminallyDisposed) {
6026
+ throw new Error(
6027
+ 'a shell control requires an initialized or restored Captain shell',
6028
+ );
6029
+ }
6030
+ if (activeTurn !== undefined || activeTurnHostCalls !== undefined) {
6031
+ throw new Error('a shell control cannot be selected during a Boss turn');
6032
+ }
6033
+ if (typeof actionId !== 'string' || actionId.length === 0) {
6034
+ throw new TypeError('a shell control id must be a nonempty string');
6035
+ }
6036
+ const advertised = advertisedShellActions().find(
6037
+ (action) => action.id === actionId,
6038
+ );
6039
+ if (advertised === undefined) {
6040
+ throw new Error('the shell does not advertise that control');
6041
+ }
6042
+ pendingHostSelection = { kind: 'give-up', text: advertised.label };
6043
+ return advertised.label;
6044
+ };
6045
+
6046
+ // CAPTAIN-7: the host's own selection of one advertised action, validated
6047
+ // here against the same live reading and validated again at the controller
6048
+ // port before any effect. The returned text is the turn the host submits.
6049
+ const selectRuntimeAction = (actionId: string): string => {
6050
+ if (lifecycle !== 'ready' || terminallyDisposed) {
6051
+ throw new Error(
6052
+ 'a runtime action requires an initialized or restored Captain shell',
6053
+ );
6054
+ }
6055
+ if (activeTurn !== undefined || activeTurnHostCalls !== undefined) {
6056
+ throw new Error('a runtime action cannot be selected during a Boss turn');
6057
+ }
6058
+ if (typeof actionId !== 'string' || actionId.length === 0) {
6059
+ throw new TypeError('a runtime action id must be a nonempty string');
6060
+ }
6061
+ const advertised = advertisedRuntimeActions().find(
6062
+ (action) => action.id === actionId,
6063
+ );
6064
+ if (advertised === undefined) {
6065
+ // CAPPLAY-5: the chosen id is control data whether or not the leaf
6066
+ // advertises it, so the refusal names the fact and not the string.
6067
+ throw new Error('the active leaf does not advertise that action');
6068
+ }
6069
+ const text =
6070
+ typeof advertised.label === 'string' ? advertised.label.trim() : '';
6071
+ if (text.length === 0) {
6072
+ throw new Error(
6073
+ 'the advertised action carries no Boss-facing label to submit as its turn',
6074
+ );
6075
+ }
6076
+ pendingHostSelection = { kind: 'runtime', actionId, text };
6077
+ return text;
6078
+ };
6079
+
5903
6080
  // The catalog is registry-authored, not shell-authored: an id, a command,
5904
6081
  // and an intent all arrive from an enabled module. They pass the same seam
5905
6082
  // the ControlView lines do, so an intent carrying a newline cannot open a
@@ -6471,6 +6648,42 @@ export function createPlaybookCaptainShell(
6471
6648
  ? []
6472
6649
  : [labeledBlock('Conversation recap', options.reseedDigest)]),
6473
6650
  ]);
6651
+ // CAPTAIN-7: a turn whose decision the host already made allocates no
6652
+ // decision call. The host's selection is this state's decision reply, so
6653
+ // it passes the runtime's own reply validation and reaches the shell
6654
+ // through the controller port like every other selection — validation,
6655
+ // execution, the outcome report, and the closing reply are the loop's.
6656
+ if (kind === 'decision' && turn?.hostRuntimeActionId !== undefined) {
6657
+ return {
6658
+ status: 'ok' as const,
6659
+ finalText: JSON.stringify({
6660
+ action: 'runtime',
6661
+ actionId: turn.hostRuntimeActionId,
6662
+ }),
6663
+ };
6664
+ }
6665
+ // CAPTAIN-63: a give-up allocates neither call. Its decision is the
6666
+ // host's, and its result phase is answered below from the settlement the
6667
+ // shell already holds, so an exit from a failing provider does not ask
6668
+ // that provider for permission.
6669
+ if (kind === 'decision' && turn?.hostGiveUp === true) {
6670
+ return {
6671
+ status: 'ok' as const,
6672
+ finalText: JSON.stringify({ action: 'dismiss' }),
6673
+ };
6674
+ }
6675
+ if (kind === 'closingReply' && turn?.hostGiveUp === true) {
6676
+ // The durable conversation did not receive this turn, so it is marked
6677
+ // for the CAPTAIN-35 catch-up rather than left believing it is
6678
+ // current: the next call carries the journal records it missed.
6679
+ markConversationCatchUp();
6680
+ // Through the one presentation seam, exactly where a model-composed
6681
+ // reply is presented, so this reply is journaled and single-attempt
6682
+ // like every other. It needs no reply validation or corrective re-ask:
6683
+ // `giveUpReplyText` already validated what it composed.
6684
+ await surfaceSettlement({ context, text: giveUpReplyText() });
6685
+ return { status: 'ok' as const, finalText: 'ok' };
6686
+ }
6474
6687
  const outcome = await durableCall(context, compose);
6475
6688
  if (kind === 'decision') {
6476
6689
  // A model-decided `respond` surfaces this call's own prose, so the
@@ -6701,8 +6914,17 @@ export function createPlaybookCaptainShell(
6701
6914
  if (!root) return false;
6702
6915
  const label = frameLabel(root);
6703
6916
  // Dismissal leaves the procedure unfinished. Persist the latest safe
6704
- // generation captured for this turn before disposal erases the frames.
6705
- retainOrClearDisposedRoot(root);
6917
+ // generation captured for this turn before disposal erases the frames —
6918
+ // unless the Boss gave up on it (CAPTAIN-44), in which case retaining it
6919
+ // would offer the abandoned run back on the idle digest's next reading.
6920
+ if (activeTurn?.hostGiveUp === true) {
6921
+ pendingRetentionUpdates.set(root.entry.id, {
6922
+ kind: 'clear',
6923
+ rootPlaybookId: root.entry.id,
6924
+ });
6925
+ } else {
6926
+ retainOrClearDisposedRoot(root);
6927
+ }
6706
6928
  try {
6707
6929
  await runEffect(() => disposeStack('dismiss'));
6708
6930
  facts.push(`Dismissed the ${label} engagement.`);
@@ -7276,10 +7498,16 @@ export function createPlaybookCaptainShell(
7276
7498
  if (!leaf) {
7277
7499
  return rejectSelection(selection, 'no engagement is active to dismiss');
7278
7500
  }
7501
+ // CAPTAIN-63: a give-up ends the run. An ordinary dismissal of a nested
7502
+ // call returns to its caller, which is the right reading of "stop this
7503
+ // call" and the wrong one of "stop this workflow" — the Boss gave up on
7504
+ // the root, not on whichever child was parked when they did.
7505
+ const giveUp = turn.hostGiveUp === true;
7506
+ const dismissed = giveUp ? (rootFrame() ?? leaf) : leaf;
7279
7507
  turn.settled = true;
7280
- journalAction({ action: 'dismiss', playbookId: leaf.entry.id });
7281
- const label = frameLabel(leaf);
7282
- if (leaf.parent) {
7508
+ journalAction({ action: 'dismiss', playbookId: dismissed.entry.id });
7509
+ const label = frameLabel(dismissed);
7510
+ if (leaf.parent && !giveUp) {
7283
7511
  // No boundary around the return itself: `resumeParent` disposes the
7284
7512
  // child and drives the parent, and each of those is marked where it
7285
7513
  // happens. A visibility rejection raised on the way back is shell
@@ -7615,6 +7843,29 @@ export function createPlaybookCaptainShell(
7615
7843
  // The reply composes from the authoritative report, never the early
7616
7844
  // `settled` guard. That guard closes duplicate submissions before an effect
7617
7845
  // starts; it is not evidence that anything ran.
7846
+ // CAPTAIN-63: the give-up's closing reply, composed from the same settlement
7847
+ // facts the result-phase prompt would have carried. Shell-authored Boss
7848
+ // prose passes the validation every model reply passes (CAPTAIN-34); what it
7849
+ // interpolates is not host-authored all the way down, so a fact set that
7850
+ // fails validation is dropped rather than spoken and the reply still states
7851
+ // the settlement truthfully.
7852
+ const giveUpReplyText = (): string => {
7853
+ const report = activeTurn?.report;
7854
+ const facts = report?.bossFacts ?? report?.facts ?? [];
7855
+ const opening = 'Stopped that workflow. I will not resume it.';
7856
+ const closing = 'Send the command again to start fresh work.';
7857
+ const composed = [
7858
+ opening,
7859
+ ...(facts.length === 0
7860
+ ? []
7861
+ : ['Here is what happened:', ...facts.map((fact) => `- ${fact}`)]),
7862
+ closing,
7863
+ ].join('\n');
7864
+ return replyRejection(composed) === undefined
7865
+ ? composed
7866
+ : [opening, closing].join('\n');
7867
+ };
7868
+
7618
7869
  const failureReplyText = (): string => {
7619
7870
  const commands = [...enablementById.values()]
7620
7871
  .map((enablement) => `/${enablement.command} <task>`)
@@ -8685,6 +8936,14 @@ export function createPlaybookCaptainShell(
8685
8936
 
8686
8937
  installRetainedGenerations,
8687
8938
 
8939
+ describeRuntimeActions: advertisedRuntimeActions,
8940
+
8941
+ submitRuntimeAction: selectRuntimeAction,
8942
+
8943
+ describeShellActions: advertisedShellActions,
8944
+
8945
+ submitShellAction: selectShellAction,
8946
+
8688
8947
  async handleBossTurn(
8689
8948
  turn: BossTurn,
8690
8949
  context: CaptainContext,
@@ -8724,13 +8983,28 @@ export function createPlaybookCaptainShell(
8724
8983
  const turnHostCalls = new Set<Promise<unknown>>();
8725
8984
  activeTurnHostCalls = turnHostCalls;
8726
8985
  activeContext = context;
8727
- const parsed = resolveCommandTurn(turn.prompt);
8986
+ // CAPTAIN-7: one host selection is consumed by one turn. It decides only
8987
+ // the turn carrying the exact text the selection was made for; any other
8988
+ // turn drops it and resolves normally.
8989
+ const selected = pendingHostSelection;
8990
+ pendingHostSelection = undefined;
8991
+ const decided =
8992
+ selected !== undefined && selected.text === turn.prompt
8993
+ ? selected
8994
+ : undefined;
8995
+ const hostRuntimeActionId =
8996
+ decided?.kind === 'runtime' ? decided.actionId : undefined;
8997
+ const hostGiveUp = decided?.kind === 'give-up' ? true : undefined;
8998
+ const parsed =
8999
+ decided === undefined ? resolveCommandTurn(turn.prompt) : undefined;
8728
9000
  activeTurn = {
8729
9001
  id: ++turnSequence,
8730
9002
  captainSyncedJournalSeq: journalSeq,
8731
9003
  bossText: turn.prompt,
8732
9004
  authoritativeText: parsed?.authoritativeText ?? turn.prompt,
8733
9005
  ...(parsed ? { resolution: parsed.resolution } : {}),
9006
+ ...(hostRuntimeActionId === undefined ? {} : { hostRuntimeActionId }),
9007
+ ...(hostGiveUp === undefined ? {} : { hostGiveUp }),
8734
9008
  settled: false,
8735
9009
  presentationAttempted: false,
8736
9010
  settlementFacts: [],
@@ -8880,6 +9154,7 @@ export function createPlaybookCaptainShell(
8880
9154
  failure ??= error;
8881
9155
  }
8882
9156
  }
9157
+ pendingHostSelection = undefined;
8883
9158
  // Quarantine is session-wide by design. Only terminal teardown may drop
8884
9159
  // its ownership after every frame host call and the Captain are drained.
8885
9160
  playerTransactions.clear();
@@ -21,7 +21,8 @@
21
21
  # Drop an adapter from this file and you can skip its SDK entirely.
22
22
  # Every seeded agent runs in cligent's protected auto mode
23
23
  # (permissions.mode: auto): claude maps it to permissionMode auto, codex to
24
- # on-request + auto_review. Codex roles also grant writablePaths: ['.git']
24
+ # on-request + auto_review. A codex agent additionally needs
25
+ # writablePaths: ['.git'] to write git metadata under its sandbox.
25
26
  # so they can inspect and work with repository metadata under the sandbox.
26
27
 
27
28
  # The Captain/Judge agent.
@@ -46,15 +47,13 @@ notifications:
46
47
  # shares one provider conversation across those playbooks.
47
48
  players:
48
49
  dev.coder:
49
- adapter: codex
50
- model: gpt-5.6-sol
51
- effort: ultra
50
+ adapter: claude
51
+ model: claude-opus-5
52
+ effort: high
52
53
  # Adapter-scoped fast mode is validated by the installed Cligent contract;
53
54
  # omitting it takes the provider default.
54
- fastMode: true
55
55
  permissions:
56
56
  mode: auto
57
- writablePaths: ['.git']
58
57
 
59
58
  dev.reviewer:
60
59
  adapter: claude
@@ -1,6 +1,7 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
3
 
4
+ import type { PlaybookControlAction } from '@sublang/playbook/runtime';
4
5
  import type { PlaybookCaptainShell, PlaybookCaptainShellSnapshot } from './playbook-captain.js';
5
6
  import type { PlaybookEffectLedger } from './host-capabilities.js';
6
7
  import type { PlaybookSessionLifecycle, SessionExecutionProjection, SessionStructuralProjection, SessionFreshBoundary, SessionRecovery } from './session-store.js';
@@ -68,6 +69,31 @@ export interface SessionHostController {
68
69
  readonly lease: PlaybookSessionLifecycle;
69
70
  read(): Promise<SessionRecovery | undefined>;
70
71
  handleBossTurn(input: string): Promise<SessionRecovery>;
72
+ /**
73
+ * The active leaf's currently advertised runtime actions, read live between
74
+ * turns; empty whenever nothing is advertised, a turn is active, or the
75
+ * leaf's control view cannot be read.
76
+ */
77
+ listRuntimeActions(): readonly PlaybookControlAction[];
78
+ /**
79
+ * Submit one currently advertised runtime action as the session's next turn.
80
+ * The turn is the same durable transaction `handleBossTurn` runs, and its
81
+ * decision is the submitted selection, so no decision model call is made.
82
+ */
83
+ submitRuntimeAction(actionId: string): Promise<SessionRecovery>;
84
+ /**
85
+ * The controls the shell offers on its own behalf, read live between turns;
86
+ * today one give-up while a root is engaged, and empty while idle or while a
87
+ * turn is active. Disjoint from `listRuntimeActions`, which reads the leaf.
88
+ */
89
+ listShellActions(): readonly PlaybookControlAction[];
90
+ /**
91
+ * Submit one currently advertised shell control as the session's next turn.
92
+ * The turn is the same durable transaction `handleBossTurn` runs; both its
93
+ * decision and its closing reply are the shell's, so the turn makes no model
94
+ * call and a give-up survives a provider that is refusing everything.
95
+ */
96
+ submitShellAction(actionId: string): Promise<SessionRecovery>;
71
97
  retry(): Promise<SessionRecovery>;
72
98
  dispose(): Promise<void>;
73
99
  }