@sublang/playbook 0.4.2 → 0.5.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.
@@ -4,8 +4,10 @@
4
4
  // Generated by slc/link.md (FSM-to-Runtime linker).
5
5
  // Source FSM: ./code.fsm.ts
6
6
  // Player bind: Coder→coder, Reviewer→reviewer,
7
- // Committer→{coder per CODE-18/19} (CODE-19 wires both
8
- // coderPlayer and reviewerPlayer; Coder wins as the
7
+ // Committer→ configured committerPlayer alias when set
8
+ // (per-run remapping via options PBRT-8); else the
9
+ // baked CODE-18/19 fallback (coder-first: CODE-19 wires
10
+ // both coderPlayer and reviewerPlayer, Coder wins as the
9
11
  // alias's first alternative)
10
12
  // Boss event: free-text judge classification
11
13
  // Adjudication: LLM-judge per state
@@ -59,6 +61,70 @@ const BOSS_REPLY_ERRORS = {
59
61
  `state ${stateId} declared needsBossReply but is not registered as resumable`,
60
62
  } as const;
61
63
 
64
+ // Required-payload fields whose value is the player's verbatim long-form
65
+ // prose. The runtime carries `finalText.trim()` into these fields rather
66
+ // than asking the judge to round-trip the text through JSON. Short
67
+ // extracted fields (`question`, `taskDescription`, `irNumber`, …) stay
68
+ // judge-extracted — they are not in this set.
69
+ const VERBATIM_PAYLOAD_FIELDS: ReadonlySet<string> = new Set([
70
+ 'reviews',
71
+ 'challenges',
72
+ ]);
73
+
74
+ // Normalize an unknown error value to the compact `{ name, message }`
75
+ // shape used by Captain-pane / status emissions. Returns `undefined`
76
+ // for nullish input so callers can omit absent errors.
77
+ function normalizeErrorCompact(
78
+ err: unknown,
79
+ ): { name: string; message: string } | undefined {
80
+ if (err === undefined || err === null) return undefined;
81
+ if (err instanceof Error) {
82
+ return { name: err.name, message: err.message };
83
+ }
84
+ if (typeof err === 'object') {
85
+ const o = err as Record<string, unknown>;
86
+ if (typeof o.message === 'string') {
87
+ return {
88
+ name: typeof o.name === 'string' ? o.name : 'Error',
89
+ message: o.message,
90
+ };
91
+ }
92
+ }
93
+ return { name: 'Error', message: String(err) };
94
+ }
95
+
96
+ // Normalize an unknown error value to the full `{ name, message, stack }`
97
+ // shape used by telemetry emissions. Returns `undefined` for nullish
98
+ // input. `stack` is omitted when not available on the source value.
99
+ function normalizeErrorFull(
100
+ err: unknown,
101
+ ): { name: string; message: string; stack?: string } | undefined {
102
+ const compact = normalizeErrorCompact(err);
103
+ if (compact === undefined) return undefined;
104
+ if (err instanceof Error) {
105
+ return err.stack !== undefined ? { ...compact, stack: err.stack } : compact;
106
+ }
107
+ if (typeof err === 'object' && err !== null) {
108
+ const stack = (err as Record<string, unknown>).stack;
109
+ if (typeof stack === 'string') {
110
+ return { ...compact, stack };
111
+ }
112
+ }
113
+ return compact;
114
+ }
115
+
116
+ // Normalize any `error` field inside a telemetry event so failed
117
+ // transitions don't leak raw Error instances through the channel.
118
+ function normalizeEventForTelemetry(event: unknown): unknown {
119
+ if (event === null || typeof event !== 'object' || Array.isArray(event)) {
120
+ return event;
121
+ }
122
+ const e = event as Record<string, unknown>;
123
+ if (!('error' in e)) return event;
124
+ const normalized = normalizeErrorFull(e.error);
125
+ return { ...e, error: normalized };
126
+ }
127
+
62
128
  // Internal capabilities (DR-004 §10). Each ships with its final
63
129
  // signature; behavior lands in the per-capability task noted by the
64
130
  // TODO marker.
@@ -117,15 +183,21 @@ function composePlayerPrompt(input: CaptainInput): string {
117
183
  return blocks.join('\n\n');
118
184
  }
119
185
 
120
- // Player-id resolver — DR-004 §2.
186
+ // Player-id resolver — DR-004 §2 / PBRT-8.
121
187
  // Non-composite: Coder→'coder', Reviewer→'reviewer'. The composite
122
- // Committer (= Coder | Reviewer per code.gears.md) resolves per
123
- // populated <playerName>Player field on `CaptainInput`: prefer
124
- // `coderPlayer` (CODE-18 wires only coderPlayer; CODE-19 wires both
125
- // so coderPlayer still wins as the alias's first alternative), fall
126
- // back to `reviewerPlayer` only if a future gear ships a
127
- // reviewer-only Committer item, and finally to the alias's first
128
- // alternative (Coder) when neither is set.
188
+ // Committer (= Coder | Reviewer per code.gears.md) resolves to the
189
+ // configured alias when present: `input.committerPlayer`, the
190
+ // validated `captain.options.code.committer` (PBRT-8 / PBRT-30),
191
+ // already a baked player id ('coder' / 'reviewer'). Absent a
192
+ // configured alias it falls back to the DR-004 §2 baked binding by
193
+ // populated <playerName>Player field: prefer `coderPlayer` (CODE-18
194
+ // wires only coderPlayer; CODE-19 wires both so coderPlayer still wins
195
+ // as the alias's first alternative), fall back to `reviewerPlayer`
196
+ // only if a future gear ships a reviewer-only Committer item, and
197
+ // finally to the alias's first alternative (Coder) when neither is
198
+ // set. The alias selects only the host pane; it is not a PBRT-4
199
+ // identity string, so it leaves <coder-llm> / <reviewer-llm>
200
+ // untouched and `input.player` stays `Committer` (PLAYBOOK-3).
129
201
  function resolvePlayerId(input: CaptainInput): string {
130
202
  switch (input.player) {
131
203
  case 'Coder':
@@ -133,6 +205,7 @@ function resolvePlayerId(input: CaptainInput): string {
133
205
  case 'Reviewer':
134
206
  return 'reviewer';
135
207
  case 'Committer':
208
+ if (input.committerPlayer !== undefined) return input.committerPlayer;
136
209
  if (input.coderPlayer !== undefined) return 'coder';
137
210
  if (input.reviewerPlayer !== undefined) return 'reviewer';
138
211
  return 'coder';
@@ -180,8 +253,17 @@ async function adjudicate(
180
253
  // required fields with the literal phrase
181
254
  // Output shall include `<fieldName>: <...>`
182
255
  // so we extract those tokens and require each to be a string in
183
- // the judge response.
256
+ // the judge response — except for VERBATIM_PAYLOAD_FIELDS
257
+ // (`reviews`, `challenges`), where the runtime substitutes
258
+ // `finalText.trim()` so the long-form prose is not round-tripped
259
+ // through judge JSON. Short extracted fields like `question` and
260
+ // `taskDescription` keep the existing extract-and-validate path.
261
+ const verbatim = finalText.trim();
184
262
  for (const field of extractRequiredFields(input.result[guard])) {
263
+ if (VERBATIM_PAYLOAD_FIELDS.has(field)) {
264
+ obj[field] = verbatim;
265
+ continue;
266
+ }
185
267
  if (typeof obj[field] !== 'string') {
186
268
  if (guard === 'needsBossReply' && field === 'question') {
187
269
  throw new Error(BOSS_REPLY_ERRORS.missingQuestion);
@@ -214,7 +296,10 @@ function buildJudgePrompt(input: CaptainInput, finalText: string): string {
214
296
  lines.push(
215
297
  'Pick exactly one outcome by `guard` and return JSON ' +
216
298
  '`{ guard, …payloadFields }`. Required payload fields are ' +
217
- 'named in the outcome description.',
299
+ 'named in the outcome description. Do not copy long-form ' +
300
+ 'verbatim fields (`reviews`, `challenges`) — the runtime ' +
301
+ "carries the player's output into those fields verbatim, " +
302
+ 'so any value you supply for them will be overwritten.',
218
303
  );
219
304
  lines.push('');
220
305
  for (const [key, description] of Object.entries(input.result)) {
@@ -223,17 +308,137 @@ function buildJudgePrompt(input: CaptainInput, finalText: string): string {
223
308
  return lines.join('\n');
224
309
  }
225
310
 
311
+ // Judge replies are meant to be a single JSON object, but LLMs
312
+ // routinely wrap them in prose ("Here is the result: …"), Markdown
313
+ // code fences, or trailing commentary, and occasionally emit a
314
+ // trailing comma or truncate the tail (a dropped closing brace, an
315
+ // unterminated string). parseJudgeJson is deliberately lenient: it
316
+ // first tries a strict parse of the (optionally fenced) body, then
317
+ // scans every `{`/`[` as a possible start in document order and
318
+ // returns the first recoverable object. At each start it prefers a
319
+ // strict balanced span and falls back to a repaired (trailing-comma /
320
+ // truncation) span, so a damaged object earlier in the prose is not
321
+ // overridden by a cleaner one later. Scanning every start (not just
322
+ // the first bracket) keeps a bracketed fragment in surrounding prose —
323
+ // e.g. an aside like `see [1]` or `{n/a}` before the real object —
324
+ // from masking a later, genuinely valid object. Both callers
325
+ // (classification and adjudication) expect an object, so plain objects
326
+ // win over arrays/scalars; the first value of any shape is remembered
327
+ // so a legitimately array/scalar reply still surfaces to the caller's
328
+ // own object check. Only a reply from which no JSON value can be
329
+ // recovered is treated as malformed and throws, preserving the
330
+ // control-plane error contract (PBRT-7, PBRT-10).
226
331
  function parseJudgeJson(raw: string): unknown {
227
- let text = raw.trim();
228
- const fence = text.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/);
229
- if (fence) text = fence[1].trim();
332
+ const fenced = stripCodeFence(raw.trim());
333
+ // Fast path: a well-formed (optionally fenced) JSON body.
230
334
  try {
231
- return JSON.parse(text);
232
- } catch (e) {
233
- throw new Error(
234
- `adjudicate: judge response is not valid JSON: ${(e as Error).message}`,
235
- );
335
+ return JSON.parse(fenced);
336
+ } catch {
337
+ // Fall through to lenient extraction + repair.
236
338
  }
339
+ const starts: number[] = [];
340
+ for (let i = 0; i < fenced.length; i++) {
341
+ const ch = fenced[i];
342
+ if (ch === '{' || ch === '[') starts.push(i);
343
+ }
344
+ // Walk starts in document order. At each start prefer a strict
345
+ // balanced span (most trustworthy) and fall back to a repaired one
346
+ // for a trailing-comma / truncated tail, so the earliest intended
347
+ // object wins even when it needs repair. Return the first plain
348
+ // object; remember the first value of any shape so a legitimately
349
+ // array/scalar reply still surfaces to the caller's own object check.
350
+ let firstValue: { value: unknown } | undefined;
351
+ for (const start of starts) {
352
+ let parsedHere: { value: unknown } | undefined;
353
+ for (const repair of [false, true]) {
354
+ const candidate = extractJsonValue(fenced, start, repair);
355
+ if (candidate === undefined) continue;
356
+ try {
357
+ parsedHere = { value: JSON.parse(candidate) };
358
+ } catch {
359
+ continue; // not parseable this way — try repair, then next start
360
+ }
361
+ break; // prefer the strict span at this start over its repair
362
+ }
363
+ if (parsedHere === undefined) continue;
364
+ if (isPlainObject(parsedHere.value)) return parsedHere.value;
365
+ if (firstValue === undefined) firstValue = parsedHere;
366
+ }
367
+ if (firstValue !== undefined) return firstValue.value;
368
+ throw new Error('adjudicate: judge response is not valid JSON');
369
+ }
370
+
371
+ function isPlainObject(value: unknown): boolean {
372
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
373
+ }
374
+
375
+ // Strip a single Markdown code fence that wraps the whole string.
376
+ function stripCodeFence(text: string): string {
377
+ const fence = text.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/);
378
+ return fence ? fence[1].trim() : text;
379
+ }
380
+
381
+ // Scan from `start` (a `{`/`[` index), tracking string and
382
+ // bracket-nesting state, and emit the balanced JSON value rooted
383
+ // there. Anything after the top-level value closes is ignored (so a
384
+ // trailing code fence or commentary does not matter). With
385
+ // `repair === false` the span is returned only if it actually closes,
386
+ // and trailing commas are left intact — so the caller can prefer a
387
+ // cleanly-balanced span before attempting repair; if input ends
388
+ // before the value closes, undefined is returned. With
389
+ // `repair === true` common damage is fixed: a trailing comma before a
390
+ // close is removed, an unterminated string is closed, and any
391
+ // brackets still open at end-of-input are closed in order.
392
+ function extractJsonValue(
393
+ text: string,
394
+ start: number,
395
+ repair: boolean,
396
+ ): string | undefined {
397
+ const stack: string[] = [];
398
+ let out = '';
399
+ let inString = false;
400
+ let escaped = false;
401
+ for (let i = start; i < text.length; i++) {
402
+ const ch = text[i];
403
+ if (inString) {
404
+ out += ch;
405
+ if (escaped) escaped = false;
406
+ else if (ch === '\\') escaped = true;
407
+ else if (ch === '"') inString = false;
408
+ continue;
409
+ }
410
+ if (ch === '"') {
411
+ inString = true;
412
+ out += ch;
413
+ continue;
414
+ }
415
+ if (ch === '{' || ch === '[') {
416
+ stack.push(ch === '{' ? '}' : ']');
417
+ out += ch;
418
+ continue;
419
+ }
420
+ if (ch === '}' || ch === ']') {
421
+ if (repair) out = dropTrailingComma(out);
422
+ out += ch;
423
+ stack.pop();
424
+ if (stack.length === 0) return out; // top-level value complete
425
+ continue;
426
+ }
427
+ out += ch;
428
+ }
429
+ // End of input before the top-level value closed.
430
+ if (!repair) return undefined; // strict pass: no balanced span here
431
+ if (inString) out += '"';
432
+ out = dropTrailingComma(out);
433
+ while (stack.length > 0) out += stack.pop();
434
+ return out;
435
+ }
436
+
437
+ // Remove a trailing comma (and any whitespace after it) at the end of
438
+ // the accumulated output, so `{"a":1,}` / `[1,2,]` and truncated
439
+ // `{"a":1,` repair to valid JSON.
440
+ function dropTrailingComma(out: string): string {
441
+ return out.replace(/,(\s*)$/, '$1');
237
442
  }
238
443
 
239
444
  // Boss-event classifier — DR-004 §3.
@@ -277,7 +482,17 @@ async function classifyWithLlm(
277
482
  const state = classifierState(snapshotOrState);
278
483
  const prompt = buildClassifierPrompt(text, state);
279
484
  const raw = await ports.callJudge(prompt, signal);
280
- const parsed = parseJudgeJson(raw);
485
+ let parsed: unknown;
486
+ try {
487
+ parsed = parseJudgeJson(raw);
488
+ } catch {
489
+ // No JSON value could be recovered. Classification failures are
490
+ // non-fatal — unlike adjudication, which throws to the failure
491
+ // state (PBRT-10) — so surface one status and take no FSM action,
492
+ // consistent with the other invalid-reply paths below (PBRT-7).
493
+ await ports.emitStatus('Classifier reply was not recoverable JSON');
494
+ return undefined;
495
+ }
281
496
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
282
497
  await ports.emitStatus('Classifier returned a non-object JSON response');
283
498
  return undefined;
@@ -638,24 +853,37 @@ function pendingBossQuestionFromContext(
638
853
  };
639
854
  }
640
855
 
641
- function questionExcerpt(question: string): string {
642
- return question.replace(/[\r\n]+/g, ' ').slice(0, 80);
856
+ // PBRT-3 / PBRT-14: on entry to `awaitBossReply` the runtime surfaces
857
+ // the pending player question as a captain-speech act attributed to the
858
+ // asking player (`<player> asks: <full question>`), emitted with no
859
+ // glyph so the host renders it as captain speech. The question is
860
+ // carried verbatim and in full — the judge JSON that produced it rides
861
+ // a hidden callCaptain (PBRT-15), so this line is the Boss's only
862
+ // legible view of what was asked.
863
+ function formatAwaitBossReplyQuestion(
864
+ context: Record<string, unknown>,
865
+ ): string {
866
+ const pending = pendingBossQuestionFromContext(context);
867
+ const player = pending?.player ?? 'unknown';
868
+ const question = pending?.question ?? '';
869
+ return `${player} asks: ${question}`;
643
870
  }
644
871
 
645
- function formatAwaitBossReplyEntry(context: Record<string, unknown>): string {
872
+ // The rider-less routing marker emitted right after the question line.
873
+ // It carries only the resume target, asking player, and source item;
874
+ // the former `q="<first 80 chars>"` excerpt rider is dropped now that
875
+ // the full question rides the captain-speech line above.
876
+ function formatAwaitBossReplyMarker(
877
+ context: Record<string, unknown>,
878
+ ): string {
646
879
  const pending = pendingBossQuestionFromContext(context);
647
880
  const resumeStateId = pending?.resumeStateId ?? 'unknown';
648
881
  const player = pending?.player ?? 'unknown';
649
882
  const sourceItem = pending?.sourceItem ?? 'unknown';
650
- const question = questionExcerpt(pending?.question ?? '');
651
- return `◆ awaiting Boss reply · ${resumeStateId} · ${player} · ${sourceItem} · q=${JSON.stringify(question)}`;
883
+ return `◆ awaiting Boss reply · ${resumeStateId} · ${player} · ${sourceItem}`;
652
884
  }
653
885
 
654
- function formatStateEntry(
655
- stateId: string,
656
- context: Record<string, unknown> = {},
657
- ): string | undefined {
658
- if (stateId === 'awaitBossReply') return formatAwaitBossReplyEntry(context);
886
+ function formatStateEntry(stateId: string): string | undefined {
659
887
  if (SUPPRESSED_ENTRY_STATES.has(stateId)) return undefined;
660
888
  if (stateId === 'failed') return '◆ failed';
661
889
  const meta = stateMetadata.get(stateId);
@@ -692,13 +920,23 @@ function stateTelemetryPayload(
692
920
  event: unknown,
693
921
  context: Record<string, unknown>,
694
922
  ): Record<string, unknown> {
695
- const payload: Record<string, unknown> = { from, to, event };
923
+ const payload: Record<string, unknown> = {
924
+ from,
925
+ to,
926
+ event: normalizeEventForTelemetry(event),
927
+ };
696
928
  if (to === 'awaitBossReply') {
697
929
  const pendingBossQuestion = pendingBossQuestionFromContext(context);
698
930
  if (pendingBossQuestion !== undefined) {
699
931
  payload.pendingBossQuestion = pendingBossQuestion;
700
932
  }
701
933
  }
934
+ if (to === 'failed') {
935
+ const lastError = normalizeErrorFull(context.lastError);
936
+ if (lastError !== undefined) {
937
+ payload.lastError = lastError;
938
+ }
939
+ }
702
940
  return payload;
703
941
  }
704
942
 
@@ -715,11 +953,16 @@ export const _internal = {
715
953
  STATE_LABELS,
716
954
  stateMetadata,
717
955
  pendingBossQuestionFromContext,
718
- formatAwaitBossReplyEntry,
956
+ formatAwaitBossReplyQuestion,
957
+ formatAwaitBossReplyMarker,
719
958
  formatStateEntry,
720
959
  formatTransition,
721
960
  formatClassification,
722
961
  stateTelemetryPayload,
962
+ normalizeErrorCompact,
963
+ normalizeErrorFull,
964
+ normalizeEventForTelemetry,
965
+ VERBATIM_PAYLOAD_FIELDS,
723
966
  };
724
967
 
725
968
  export default function createPlaybookRuntime(
@@ -809,12 +1052,24 @@ export default function createPlaybookRuntime(
809
1052
  if (transitionLine !== undefined) {
810
1053
  enqueueEmit(() => ports.emitStatus(transitionLine));
811
1054
  }
812
- const entryLine = formatStateEntry(to, context);
1055
+ // awaitBossReply surfaces two lines per PBRT-3 / PBRT-14: the
1056
+ // full player question as captain speech, then the rider-less
1057
+ // routing marker. The full-question telemetry rides
1058
+ // stateTelemetryPayload above.
1059
+ if (to === 'awaitBossReply') {
1060
+ const questionLine = formatAwaitBossReplyQuestion(context);
1061
+ const markerLine = formatAwaitBossReplyMarker(context);
1062
+ enqueueEmit(() => ports.emitStatus(questionLine));
1063
+ enqueueEmit(() => ports.emitStatus(markerLine));
1064
+ return;
1065
+ }
1066
+ const entryLine = formatStateEntry(to);
813
1067
  if (entryLine === undefined) return;
814
1068
  if (to === 'failed') {
815
1069
  const lastError = (snap.context as { lastError?: unknown })
816
1070
  ?.lastError;
817
- enqueueEmit(() => ports.emitStatus(entryLine, { lastError }));
1071
+ const data = { lastError: normalizeErrorCompact(lastError) };
1072
+ enqueueEmit(() => ports.emitStatus(entryLine, data));
818
1073
  } else {
819
1074
  enqueueEmit(() => ports.emitStatus(entryLine));
820
1075
  }
@@ -1,2 +1,6 @@
1
1
  import type { Captain } from '@sublang/cligent/tmux-play';
2
+ export interface CodeOptions {
3
+ committer?: 'coder' | 'reviewer';
4
+ }
5
+ export declare function validateCodeOptions(captainOptions: unknown): CodeOptions;
2
6
  export default function createCodeTmuxPlayCaptain(options: unknown): Captain;
@@ -1,15 +1,62 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
3
  import createPlaybookRuntime from './code.playbook.js';
4
+ // PBRT-29/30: CODE runtime options are carried under
5
+ // `captain.options.code`, a namespaced object the host forwards
6
+ // verbatim through `captain.options`. cligent neither reads nor
7
+ // validates `options.code` (PBRT-30); this adapter is the sole
8
+ // validator. The CODE options schema defines one key, `committer`: an
9
+ // optional Committer-alias player id, one of the baked player ids
10
+ // `coder` / `reviewer`. A valid `options.code` is absent, `{}`, or
11
+ // `{ committer: 'coder' | 'reviewer' }`; every other key is unknown
12
+ // and rejected with a path-named error, and an out-of-range
13
+ // `committer` value is rejected naming `captain.options.code.committer`.
14
+ // A further CODE option shall be introduced as its own higher-numbered
15
+ // item that widens `CODE_OPTION_KEYS`; the validator still fails closed
16
+ // on stray keys.
17
+ const CODE_OPTION_KEYS = new Set(['committer']);
18
+ const COMMITTER_PLAYER_IDS = new Set(['coder', 'reviewer']);
19
+ export function validateCodeOptions(captainOptions) {
20
+ const code = readCodeNamespace(captainOptions);
21
+ if (code === undefined)
22
+ return {};
23
+ if (typeof code !== 'object' || code === null || Array.isArray(code)) {
24
+ throw new Error('captain.options.code must be an object');
25
+ }
26
+ for (const key of Object.keys(code)) {
27
+ if (!CODE_OPTION_KEYS.has(key)) {
28
+ throw new Error(`Unknown config field captain.options.code.${key}`);
29
+ }
30
+ }
31
+ const options = {};
32
+ const committer = code.committer;
33
+ if (committer !== undefined) {
34
+ if (typeof committer !== 'string' || !COMMITTER_PLAYER_IDS.has(committer)) {
35
+ throw new Error("captain.options.code.committer must be 'coder' or 'reviewer'");
36
+ }
37
+ options.committer = committer;
38
+ }
39
+ return options;
40
+ }
41
+ function readCodeNamespace(captainOptions) {
42
+ if (typeof captainOptions !== 'object' ||
43
+ captainOptions === null ||
44
+ Array.isArray(captainOptions)) {
45
+ return undefined;
46
+ }
47
+ return captainOptions.code;
48
+ }
4
49
  // Captain factory per TMUX-014: `(options: unknown) => Captain`.
5
- // `options` is whatever `captain.options` carries in the YAML config.
6
- // The per-run player identity strings (`coderPlayer`, `reviewerPlayer`)
7
- // are derived from `session.players` at init time per PBRT-4 —
8
- // preferring each entry's `model` and falling back to `adapter` when
9
- // no model is pinned so player prompts and commit-message trailers
10
- // carry the concrete model identity (e.g. `claude-opus-4-7`) rather
11
- // than the adapter family name (e.g. `claude`). Any same-named keys
12
- // in `options` are ignored.
50
+ // `options` is whatever `captain.options` carries in the YAML config;
51
+ // CODE reads only the namespaced `options.code` (PBRT-30). The per-run
52
+ // player identity strings (`coderPlayer`, `reviewerPlayer`) are
53
+ // derived from `session.players` at init time per PBRT-4 — preferring
54
+ // each entry's `model` and falling back to `adapter` when no model is
55
+ // pinned so player prompts and commit-message trailers carry the
56
+ // concrete model identity (e.g. `claude-opus-4-7`) rather than the
57
+ // adapter family name (e.g. `claude`). They come from `session.players`
58
+ // independent of `captain.options.code` and override any same-named
59
+ // keys.
13
60
  export default function createCodeTmuxPlayCaptain(options) {
14
61
  // CaptainSession is bound at init time and persists across turns;
15
62
  // CaptainContext is rebuilt per turn and carries the call
@@ -21,17 +68,28 @@ export default function createCodeTmuxPlayCaptain(options) {
21
68
  let activeContext;
22
69
  return {
23
70
  async init(session) {
71
+ // PBRT-30: validate `captain.options.code` before constructing
72
+ // the runtime so a stray key fails `init` closed with a
73
+ // path-named error; the empty schema yields an empty options set.
74
+ const codeOptions = validateCodeOptions(options);
24
75
  const playerIdentity = (id) => {
25
76
  const entry = session.players.find((p) => p.id === id);
26
77
  return entry?.model ?? entry?.adapter;
27
78
  };
28
79
  const coderPlayer = playerIdentity('coder');
29
80
  const reviewerPlayer = playerIdentity('reviewer');
30
- runtime = createPlaybookRuntime({
31
- ...options,
81
+ // Identity strings from `session.players` override any same-named
82
+ // keys and are independent of `captain.options.code` (PBRT-30).
83
+ // The validated `committer` alias threads in as the runtime's
84
+ // Committer player id (`committerPlayer`, PBRT-8).
85
+ const runtimeOptions = {
32
86
  coderPlayer,
33
87
  reviewerPlayer,
34
- });
88
+ ...(codeOptions.committer !== undefined
89
+ ? { committerPlayer: codeOptions.committer }
90
+ : {}),
91
+ };
92
+ runtime = createPlaybookRuntime(runtimeOptions);
35
93
  const ports = {
36
94
  callPlayer: async (playerId, prompt, _signal) => {
37
95
  if (!activeContext) {
@@ -54,7 +112,12 @@ export default function createCodeTmuxPlayCaptain(options) {
54
112
  if (!activeContext) {
55
113
  throw new Error('callJudge invoked outside a Boss turn');
56
114
  }
57
- const r = await activeContext.callCaptain(prompt);
115
+ // PBRT-15 / DR-007: run the judge call hidden so its JSON
116
+ // reply never reaches the Boss pane; the runtime composes the
117
+ // human-readable pane lines (PBRT-3) from the parsed result.
118
+ const r = await activeContext.callCaptain(prompt, {
119
+ visibility: 'hidden',
120
+ });
58
121
  if (r.status !== 'ok') {
59
122
  throw new Error(r.error ?? `callCaptain status "${r.status}"`);
60
123
  }