@sublang/playbook 0.1.3 → 0.4.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 (20) hide show
  1. package/README.md +98 -30
  2. package/package.json +24 -23
  3. package/reference/sdlc/code.playbook/bin/playbook-code.js +278 -0
  4. package/{code.fsm.d.ts → reference/sdlc/code.playbook/code.fsm.d.ts} +18 -0
  5. package/{code.fsm.introspect.d.ts → reference/sdlc/code.playbook/code.fsm.introspect.d.ts} +24 -0
  6. package/{code.fsm.introspect.js → reference/sdlc/code.playbook/code.fsm.introspect.js} +28 -1
  7. package/{code.fsm.introspect.ts → reference/sdlc/code.playbook/code.fsm.introspect.ts} +73 -6
  8. package/{code.fsm.js → reference/sdlc/code.playbook/code.fsm.js} +264 -115
  9. package/{code.fsm.ts → reference/sdlc/code.playbook/code.fsm.ts} +327 -119
  10. package/{code.gears.md → reference/sdlc/code.playbook/code.gears.md} +17 -16
  11. package/{code.playbook.d.ts → reference/sdlc/code.playbook/code.playbook.d.ts} +16 -6
  12. package/{code.playbook.js → reference/sdlc/code.playbook/code.playbook.js} +228 -129
  13. package/{code.playbook.ts → reference/sdlc/code.playbook/code.playbook.ts} +316 -122
  14. package/{code.tmux-play.js → reference/sdlc/code.playbook/code.tmux-play.js} +22 -10
  15. package/{code.tmux-play.ts → reference/sdlc/code.playbook/code.tmux-play.ts} +26 -13
  16. package/reference/sdlc/code.playbook/playbook-code.config.template.yaml +38 -0
  17. package/{tmux-play.config.yaml → reference/sdlc/code.playbook/tmux-play.config.yaml} +14 -10
  18. package/{tmux-play.production.config.yaml → reference/sdlc/code.playbook/tmux-play.production.config.yaml} +7 -4
  19. package/bin/playbook-code.js +0 -43
  20. /package/{code.tmux-play.d.ts → reference/sdlc/code.playbook/code.tmux-play.d.ts} +0 -0
@@ -7,7 +7,7 @@
7
7
  // Committer→{coder per CODE-18/19} (CODE-19 wires both
8
8
  // coderPlayer and reviewerPlayer; Coder wins as the
9
9
  // alias's first alternative)
10
- // Boss event: slash-prefix (LLM-classifier fallback)
10
+ // Boss event: free-text judge classification
11
11
  // Adjudication: LLM-judge per state
12
12
 
13
13
  import { createActor, fromPromise } from 'xstate';
@@ -18,7 +18,11 @@ import {
18
18
  type CodingEvent,
19
19
  type CodingInput,
20
20
  } from './code.fsm.js';
21
- import { enumerateCaptainStates } from './code.fsm.introspect.js';
21
+ import {
22
+ enumerateAwaitBossReply,
23
+ enumerateCaptainStates,
24
+ enumerateRootEvents,
25
+ } from './code.fsm.introspect.js';
22
26
 
23
27
  // Public contract — `PlayerResult`, `PlaybookPorts`, `PlaybookRuntime`,
24
28
  // `CodePlaybookOptions`, and the default `createPlaybookRuntime` factory
@@ -49,25 +53,43 @@ export interface PlaybookRuntime {
49
53
 
50
54
  export type CodePlaybookOptions = CodingInput;
51
55
 
56
+ const BOSS_REPLY_ERRORS = {
57
+ missingQuestion: "needsBossReply outcome missing 'question' field",
58
+ unregisteredState: (stateId: string) =>
59
+ `state ${stateId} declared needsBossReply but is not registered as resumable`,
60
+ } as const;
61
+
52
62
  // Internal capabilities (DR-004 §10). Each ships with its final
53
63
  // signature; behavior lands in the per-capability task noted by the
54
64
  // TODO marker.
55
65
 
56
66
  // Player-prompt composer — DR-004 §6.
57
67
  // Substitutes the three placeholder tokens in `input.prompt` (literal
58
- // string replace, no escaping) and prepends labelled blocks for any
59
- // populated structured field. The FSM's prompt body is never re-flowed.
68
+ // string replace, no escaping) and arranges labelled blocks around
69
+ // the prompt body. Context blocks the body refers to as prior
70
+ // material (`Boss intent:`, `Task description:`) are prepended;
71
+ // action blocks the body refers to as material below
72
+ // (`Review items:`, `Rebuttals:`) are appended after the body so the
73
+ // CODE-N prompts' "review item below" / "rebuttal below" phrasing
74
+ // matches the rendered layout. When a state resumes from a Boss
75
+ // reply, the continuation preamble and Q/A blocks precede every
76
+ // ordinary block. The FSM's prompt body is never re-flowed.
77
+
60
78
  function composePlayerPrompt(input: CaptainInput): string {
61
79
  const blocks: string[] = [];
80
+ if (
81
+ input.pendingBossQuestion !== undefined &&
82
+ input.bossReply !== undefined
83
+ ) {
84
+ blocks.push(
85
+ 'You previously paused this task to ask Boss a question; Boss has now replied. Continue the same task using the reply below.',
86
+ );
87
+ blocks.push(`Boss question:\n${input.pendingBossQuestion.question}`);
88
+ blocks.push(`Boss reply:\n${input.bossReply}`);
89
+ }
62
90
  if (input.intent !== undefined) {
63
91
  blocks.push(`Boss intent:\n${input.intent}`);
64
92
  }
65
- if (input.reviews !== undefined) {
66
- blocks.push(`Review items:\n${input.reviews}`);
67
- }
68
- if (input.challenges !== undefined) {
69
- blocks.push(`Rebuttals:\n${input.challenges}`);
70
- }
71
93
  if (input.taskDescription !== undefined) {
72
94
  blocks.push(`Task description:\n${input.taskDescription}`);
73
95
  }
@@ -84,6 +106,14 @@ function composePlayerPrompt(input: CaptainInput): string {
84
106
  }
85
107
 
86
108
  blocks.push(body);
109
+
110
+ if (input.reviews !== undefined) {
111
+ blocks.push(`Review items:\n${input.reviews}`);
112
+ }
113
+ if (input.challenges !== undefined) {
114
+ blocks.push(`Rebuttals:\n${input.challenges}`);
115
+ }
116
+
87
117
  return blocks.join('\n\n');
88
118
  }
89
119
 
@@ -153,6 +183,9 @@ async function adjudicate(
153
183
  // the judge response.
154
184
  for (const field of extractRequiredFields(input.result[guard])) {
155
185
  if (typeof obj[field] !== 'string') {
186
+ if (guard === 'needsBossReply' && field === 'question') {
187
+ throw new Error(BOSS_REPLY_ERRORS.missingQuestion);
188
+ }
156
189
  throw new Error(
157
190
  `adjudicate: judge response missing required field "${field}" for guard "${guard}"`,
158
191
  );
@@ -204,46 +237,22 @@ function parseJudgeJson(raw: string): unknown {
204
237
  }
205
238
 
206
239
  // Boss-event classifier — DR-004 §3.
207
- // Slash forms tried first; unknown slash forms surface as
208
- // emitStatus and return undefined per slc/link.md ("Unknown
209
- // commands surface as emitStatus, not as a silently-dropped
210
- // turn"). Anything that isn't a slash form falls through to the
211
- // LLM classifier via ports.callJudge with a fixed prompt that
212
- // names the four event types and their payload shapes.
240
+ // Every non-empty Boss turn goes through ports.callJudge. Slash-prefixed
241
+ // text is ordinary content once a host has routed the turn to this
242
+ // playbook; `/command` selection belongs outside handleBossInput. The
243
+ // classifier is state-aware so awaitBossReply can distinguish a direct
244
+ // answer (BOSS_REPLY) from a fresh directive that abandons the pending
245
+ // question through the FSM's existing transitions.
213
246
  async function classifyBossText(
214
247
  text: string,
215
248
  ports: PlaybookPorts,
216
249
  signal: AbortSignal,
250
+ snapshotOrState?: unknown,
217
251
  ): Promise<CodingEvent | undefined> {
218
252
  const trimmed = text.trim();
219
253
  if (trimmed === '') return undefined;
220
254
 
221
- if (trimmed === '/start' || trimmed.startsWith('/start ')) {
222
- return { type: 'START_CODING', intent: trimmed.slice('/start'.length).trim() };
223
- }
224
- if (trimmed === '/continue' || trimmed.startsWith('/continue ')) {
225
- return {
226
- type: 'CONTINUE_IR',
227
- irNumber: trimmed.slice('/continue'.length).trim(),
228
- };
229
- }
230
- if (trimmed === '/summarize' || trimmed.startsWith('/summarize ')) {
231
- return {
232
- type: 'SUMMARIZE_IR',
233
- irNumber: trimmed.slice('/summarize'.length).trim(),
234
- };
235
- }
236
- if (trimmed === '/interrupt' || trimmed.startsWith('/interrupt ')) {
237
- return parseInterruptSlash(trimmed, ports);
238
- }
239
-
240
- if (trimmed.startsWith('/')) {
241
- const cmd = trimmed.split(/\s+/)[0];
242
- await ports.emitStatus(`Unknown slash command: ${cmd}`);
243
- return undefined;
244
- }
245
-
246
- return classifyWithLlm(trimmed, ports, signal);
255
+ return classifyWithLlm(text, ports, signal, snapshotOrState);
247
256
  }
248
257
 
249
258
  // JumpableStateId is internal to code.fsm.ts (not exported), so
@@ -253,31 +262,20 @@ type JumpableStateId = Extract<
253
262
  { type: 'BOSS_INTERRUPT' }
254
263
  >['targetId'];
255
264
 
256
- async function parseInterruptSlash(
257
- trimmed: string,
258
- ports: PlaybookPorts,
259
- ): Promise<CodingEvent | undefined> {
260
- const rest = trimmed.slice('/interrupt'.length).trim();
261
- if (rest === '') {
262
- await ports.emitStatus('/interrupt requires a stateId');
263
- return undefined;
264
- }
265
- const firstSpace = rest.search(/\s/);
266
- const targetId = firstSpace === -1 ? rest : rest.slice(0, firstSpace);
267
- const intent = firstSpace === -1 ? '' : rest.slice(firstSpace).trim();
268
- return {
269
- type: 'BOSS_INTERRUPT',
270
- targetId: targetId as JumpableStateId,
271
- ...(intent ? { intent } : {}),
272
- };
273
- }
265
+ const rootEvents = enumerateRootEvents(codingMachine);
266
+ const bossInterruptTargets = rootEvents.bossInterruptTargetDescriptions;
267
+ const bossInterruptTargetIds: ReadonlySet<string> = new Set(
268
+ bossInterruptTargets.map((target) => target.stateId),
269
+ );
274
270
 
275
271
  async function classifyWithLlm(
276
272
  text: string,
277
273
  ports: PlaybookPorts,
278
274
  signal: AbortSignal,
275
+ snapshotOrState?: unknown,
279
276
  ): Promise<CodingEvent | undefined> {
280
- const prompt = buildClassifierPrompt(text);
277
+ const state = classifierState(snapshotOrState);
278
+ const prompt = buildClassifierPrompt(text, state);
281
279
  const raw = await ports.callJudge(prompt, signal);
282
280
  const parsed = parseJudgeJson(raw);
283
281
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
@@ -298,6 +296,10 @@ async function classifyWithLlm(
298
296
  : {};
299
297
 
300
298
  switch (eventType) {
299
+ case 'NO_ACTION':
300
+ case 'NO_FSM_ACTION':
301
+ case 'NONE':
302
+ return undefined;
301
303
  case 'START_CODING': {
302
304
  if (typeof payload.intent !== 'string') {
303
305
  await ports.emitStatus('Classifier omitted intent for START_CODING');
@@ -326,6 +328,12 @@ async function classifyWithLlm(
326
328
  );
327
329
  return undefined;
328
330
  }
331
+ if (!bossInterruptTargetIds.has(payload.targetId)) {
332
+ await ports.emitStatus(
333
+ `Classifier supplied invalid targetId for BOSS_INTERRUPT: ${payload.targetId}`,
334
+ );
335
+ return undefined;
336
+ }
329
337
  return {
330
338
  type: 'BOSS_INTERRUPT',
331
339
  targetId: payload.targetId as JumpableStateId,
@@ -337,6 +345,19 @@ async function classifyWithLlm(
337
345
  : {}),
338
346
  };
339
347
  }
348
+ case 'BOSS_REPLY': {
349
+ if (state.value !== 'awaitBossReply') {
350
+ await ports.emitStatus(
351
+ 'Classifier returned BOSS_REPLY outside awaitBossReply',
352
+ );
353
+ return undefined;
354
+ }
355
+ if (typeof payload.answer !== 'string') {
356
+ await ports.emitStatus('Classifier omitted answer for BOSS_REPLY');
357
+ return undefined;
358
+ }
359
+ return { type: 'BOSS_REPLY', answer: payload.answer };
360
+ }
340
361
  default:
341
362
  await ports.emitStatus(
342
363
  `Classifier returned unknown event type: ${eventType}`,
@@ -345,22 +366,76 @@ async function classifyWithLlm(
345
366
  }
346
367
  }
347
368
 
348
- function buildClassifierPrompt(text: string): string {
349
- return [
369
+ interface ClassifierState {
370
+ value: unknown;
371
+ context: Record<string, unknown>;
372
+ }
373
+
374
+ function classifierState(snapshotOrState: unknown): ClassifierState {
375
+ if (
376
+ snapshotOrState !== null &&
377
+ typeof snapshotOrState === 'object' &&
378
+ 'value' in snapshotOrState
379
+ ) {
380
+ const candidate = snapshotOrState as {
381
+ value?: unknown;
382
+ context?: unknown;
383
+ };
384
+ return {
385
+ value: candidate.value,
386
+ context:
387
+ candidate.context !== null &&
388
+ typeof candidate.context === 'object' &&
389
+ !Array.isArray(candidate.context)
390
+ ? (candidate.context as Record<string, unknown>)
391
+ : {},
392
+ };
393
+ }
394
+ return { value: snapshotOrState, context: {} };
395
+ }
396
+
397
+ function buildClassifierPrompt(text: string, state: ClassifierState): string {
398
+ const currentState =
399
+ typeof state.value === 'string' ? state.value : 'unknown';
400
+ const pendingBossQuestion = pendingBossQuestionFromContext(state.context);
401
+ const lines = [
350
402
  'Classify the following Boss message into exactly one of these events.',
351
403
  'Respond with JSON: { "event": "<TYPE>", "payload": { ...fields } }.',
404
+ 'Use { "event": "NO_ACTION", "payload": {} } when no FSM action should be taken.',
405
+ '',
406
+ `Current state: ${currentState}`,
407
+ ];
408
+ if (pendingBossQuestion !== undefined) {
409
+ lines.push(
410
+ `Pending Boss question: ${pendingBossQuestion.question}`,
411
+ `Pending resume state: ${pendingBossQuestion.resumeStateId}`,
412
+ );
413
+ }
414
+ lines.push(
352
415
  '',
353
416
  'Events:',
354
417
  '- START_CODING: payload { intent: "<free-form goal>" }',
355
418
  '- CONTINUE_IR: payload { irNumber: "<number>" }',
356
419
  '- SUMMARIZE_IR: payload { irNumber: "<number>" }',
357
420
  '- BOSS_INTERRUPT: payload { targetId: "<stateId>", intent?: "<free-form goal>", irNumber?: "<number>" }',
421
+ ' targetId must be one of these jumpable states:',
422
+ );
423
+ for (const target of bossInterruptTargets) {
424
+ lines.push(` - ${target.stateId}: ${target.description}`);
425
+ }
426
+ if (currentState === 'awaitBossReply') {
427
+ lines.push('- BOSS_REPLY: payload { answer: "<verbatim Boss answer>" }');
428
+ } else {
429
+ lines.push('- BOSS_REPLY: valid only when Current state is awaitBossReply');
430
+ }
431
+ lines.push(
358
432
  '',
359
433
  'Boss message:',
360
434
  '```',
361
435
  text,
362
436
  '```',
363
- ].join('\n');
437
+ );
438
+ return lines.join('\n');
364
439
  }
365
440
 
366
441
  // Captain-actor bridge — DR-004 §7. One PromiseActorLogic that the
@@ -397,20 +472,36 @@ function captainBridge(
397
472
  'captainBridge: callPlayer returned status=ok with no finalText',
398
473
  );
399
474
  }
400
- return adjudicate(input, result.finalText, ports, activeSignal);
475
+ const output = await adjudicate(
476
+ input,
477
+ result.finalText,
478
+ ports,
479
+ activeSignal,
480
+ );
481
+ validateBossReplyOutput(input, output);
482
+ return output;
401
483
  },
402
484
  );
403
485
  }
404
486
 
405
487
  // Captain pane display — PBRT-3 / PBRT-14.
406
- // The Captain pane is a stream keyed on four glyphs so a reader can
407
- // parse each line at a glance:
408
- // ◆ terminal entry (ready / done / failed)
409
- // Boss input echo
410
- // ⮕ captain-invoking state entry (label + player + CODE-N)
411
- // ⤷ transition (guard fired by the just-finished captain call)
412
- // Prompts and full player output ride the player panes; the Captain
413
- // pane keeps to the state-machine shape.
488
+ // The Captain pane is a stream of three glyphs plus one bare
489
+ // captain-speech act, designed so a reader can parse each line at a
490
+ // glance:
491
+ // (no glyph) bare FSM event type — host renders as captain speech
492
+ // (e.g., `captain> START_CODING`)
493
+ // ⤷ captain-invoking state entry: `<Player>: <label>`
494
+ // → transition guard outcome (`· field=N` tallies
495
+ // appended); the host presenter owns any visual
496
+ // nesting under the preceding ⤷ entry
497
+
498
+ // ◆ failure state (with `lastError` data) and the
499
+ // `awaitBossReply` suspension state (custom payload)
500
+ // The runtime emits no status line on entry to the idle state
501
+ // (`ready`) or the terminal state (`done`); the next `boss>` prompt
502
+ // is the implicit "turn over" signal. Prompts and full player output
503
+ // ride the player panes; the Captain pane keeps to the state-machine
504
+ // shape.
414
505
 
415
506
  const STATE_LABELS: Readonly<Record<string, string>> = {
416
507
  planAndImplement: 'plan & implement',
@@ -458,26 +549,118 @@ const stateMetadata: ReadonlyMap<string, StateMetadata> = (() => {
458
549
  return m;
459
550
  })();
460
551
 
461
- const TERMINAL_STATES: ReadonlySet<string> = new Set([
552
+ const stateIdBySourceItem: ReadonlyMap<string, string> = new Map(
553
+ [...stateMetadata.entries()].map(([stateId, meta]) => [
554
+ meta.sourceItem,
555
+ stateId,
556
+ ]),
557
+ );
558
+
559
+ const registeredResumableStateIds: ReadonlySet<string> = new Set(
560
+ enumerateAwaitBossReply(codingMachine).bossReplyTransitions.map(
561
+ (transition) => transition.target,
562
+ ),
563
+ );
564
+
565
+ function validateBossReplyOutput(
566
+ input: CaptainInput,
567
+ output: CaptainOutput,
568
+ ): void {
569
+ if (output.guard !== 'needsBossReply') return;
570
+ if (typeof output.question !== 'string') {
571
+ throw new Error(BOSS_REPLY_ERRORS.missingQuestion);
572
+ }
573
+ const stateId = stateIdBySourceItem.get(input.sourceItem);
574
+ if (stateId === undefined || !registeredResumableStateIds.has(stateId)) {
575
+ throw new Error(
576
+ BOSS_REPLY_ERRORS.unregisteredState(stateId ?? input.sourceItem),
577
+ );
578
+ }
579
+ }
580
+
581
+ const QUIESCENT_STATES: ReadonlySet<string> = new Set([
462
582
  'ready',
583
+ 'awaitBossReply',
463
584
  'done',
464
585
  'failed',
465
586
  ]);
466
587
 
588
+ // States whose entry the runtime does not surface on the Captain
589
+ // pane per PBRT-3: the readline returning to its `boss>` prompt is
590
+ // the implicit "turn over" signal, so a `◆ ready` / `◆ done`
591
+ // tombstone is redundant.
592
+ const SUPPRESSED_ENTRY_STATES: ReadonlySet<string> = new Set([
593
+ 'ready',
594
+ 'done',
595
+ ]);
596
+
467
597
  // Captain-pane surface (PBRT-3): every captain-invoking state plus
468
- // the three terminal/idle states. Wider than the prior
469
- // "Boss-relevant" set per slc/link.md's default "emit on every
470
- // transition; let the host filter."
598
+ // the quiescent states whose entry still carries information
599
+ // (failure with `lastError`, awaitBossReply with the pending
600
+ // question). `ready` and `done` flow through the inspect handler
601
+ // but their entries are dropped before emitStatus per
602
+ // SUPPRESSED_ENTRY_STATES above.
471
603
  const CAPTAIN_PANE_STATES: ReadonlySet<string> = new Set([
472
604
  ...stateMetadata.keys(),
473
- ...TERMINAL_STATES,
605
+ ...QUIESCENT_STATES,
474
606
  ]);
475
607
 
476
- function formatStateEntry(stateId: string): string {
477
- if (TERMINAL_STATES.has(stateId)) return `◆ ${stateId}`;
608
+ interface PendingBossQuestionForStatus {
609
+ resumeStateId: string;
610
+ sourceItem: string;
611
+ player: string;
612
+ question: string;
613
+ }
614
+
615
+ function pendingBossQuestionFromContext(
616
+ context: Record<string, unknown>,
617
+ ): PendingBossQuestionForStatus | undefined {
618
+ const pending = context.pendingBossQuestion;
619
+ if (pending === undefined || typeof pending !== 'object') {
620
+ return undefined;
621
+ }
622
+ const candidate = pending as Partial<
623
+ Record<keyof PendingBossQuestionForStatus, unknown>
624
+ >;
625
+ if (
626
+ typeof candidate.resumeStateId !== 'string' ||
627
+ typeof candidate.sourceItem !== 'string' ||
628
+ typeof candidate.player !== 'string' ||
629
+ typeof candidate.question !== 'string'
630
+ ) {
631
+ return undefined;
632
+ }
633
+ return {
634
+ resumeStateId: candidate.resumeStateId,
635
+ sourceItem: candidate.sourceItem,
636
+ player: candidate.player,
637
+ question: candidate.question,
638
+ };
639
+ }
640
+
641
+ function questionExcerpt(question: string): string {
642
+ return question.replace(/[\r\n]+/g, ' ').slice(0, 80);
643
+ }
644
+
645
+ function formatAwaitBossReplyEntry(context: Record<string, unknown>): string {
646
+ const pending = pendingBossQuestionFromContext(context);
647
+ const resumeStateId = pending?.resumeStateId ?? 'unknown';
648
+ const player = pending?.player ?? 'unknown';
649
+ const sourceItem = pending?.sourceItem ?? 'unknown';
650
+ const question = questionExcerpt(pending?.question ?? '');
651
+ return `◆ awaiting Boss reply · ${resumeStateId} · ${player} · ${sourceItem} · q=${JSON.stringify(question)}`;
652
+ }
653
+
654
+ function formatStateEntry(
655
+ stateId: string,
656
+ context: Record<string, unknown> = {},
657
+ ): string | undefined {
658
+ if (stateId === 'awaitBossReply') return formatAwaitBossReplyEntry(context);
659
+ if (SUPPRESSED_ENTRY_STATES.has(stateId)) return undefined;
660
+ if (stateId === 'failed') return '◆ failed';
478
661
  const meta = stateMetadata.get(stateId);
479
- if (!meta) return `⮕ ${stateId}`;
480
- return `⮕ ${meta.label} ${meta.player} per ${meta.sourceItem}`;
662
+ if (!meta) return `⤷ ${stateId}`;
663
+ return `⤷ ${meta.player}: ${meta.label}`;
481
664
  }
482
665
 
483
666
  function formatTransition(event: unknown): string | undefined {
@@ -491,32 +674,32 @@ function formatTransition(event: unknown): string | undefined {
491
674
  tallies.push(`${field}=${items ? items.length : 1}`);
492
675
  }
493
676
  }
494
- const suffix = tallies.length > 0 ? ` ${tallies.join(' ')}` : '';
495
- return `⤷ ${output.guard}${suffix}`;
677
+ const suffix = tallies.length > 0 ? ` · ${tallies.join(' · ')}` : '';
678
+ // No leading whitespace: visual nesting under the preceding ⤷
679
+ // entry is the host presenter's concern (cligent's writeStatusLine
680
+ // emits status messages verbatim, with its own chrome but no
681
+ // continuation indent; layout is its job, not ours).
682
+ return `→ ${output.guard}${suffix}`;
496
683
  }
497
684
 
498
- function formatBossEcho(text: string, eventType?: string): string {
499
- return eventType !== undefined
500
- ? `▸ BOSS ${text} → ${eventType}`
501
- : `▸ BOSS ${text}`;
685
+ function formatClassification(eventType: string): string {
686
+ return eventType;
502
687
  }
503
688
 
504
- // Rider fields PBRT-14 names — surfaced inline on a state entry
505
- // whenever the FSM context populates them, regardless of which
506
- // captain-invoking state is being entered. Routing-only context
507
- // (reviewSubject, afterReview, etc.) stays out of the pane and is
508
- // visible via emitTelemetry instead.
509
- const RIDER_FIELDS = ['intent', 'irNumber', 'taskDescription'] as const;
510
-
511
- function formatRiders(context: Record<string, unknown>): string {
512
- const parts: string[] = [];
513
- for (const f of RIDER_FIELDS) {
514
- const v = context[f];
515
- if (typeof v === 'string' && v.length > 0) {
516
- parts.push(`${f}=${JSON.stringify(v)}`);
689
+ function stateTelemetryPayload(
690
+ from: unknown,
691
+ to: string,
692
+ event: unknown,
693
+ context: Record<string, unknown>,
694
+ ): Record<string, unknown> {
695
+ const payload: Record<string, unknown> = { from, to, event };
696
+ if (to === 'awaitBossReply') {
697
+ const pendingBossQuestion = pendingBossQuestionFromContext(context);
698
+ if (pendingBossQuestion !== undefined) {
699
+ payload.pendingBossQuestion = pendingBossQuestion;
517
700
  }
518
701
  }
519
- return parts.length > 0 ? ` ${parts.join(' ')}` : '';
702
+ return payload;
520
703
  }
521
704
 
522
705
  // Internal export surface for tests. Not part of the stable public API;
@@ -531,10 +714,12 @@ export const _internal = {
531
714
  captainBridge,
532
715
  STATE_LABELS,
533
716
  stateMetadata,
717
+ pendingBossQuestionFromContext,
718
+ formatAwaitBossReplyEntry,
534
719
  formatStateEntry,
535
720
  formatTransition,
536
- formatBossEcho,
537
- formatRiders,
721
+ formatClassification,
722
+ stateTelemetryPayload,
538
723
  };
539
724
 
540
725
  export default function createPlaybookRuntime(
@@ -602,10 +787,16 @@ export default function createPlaybookRuntime(
602
787
  const from = priorState;
603
788
  priorState = to;
604
789
  // Telemetry on every transition (PBRT-14).
790
+ const context = snap.context ?? {};
605
791
  enqueueEmit(() =>
606
792
  ports.emitTelemetry({
607
793
  topic: 'playbook.fsm.state',
608
- payload: { from, to, event: inspectionEvent.event },
794
+ payload: stateTelemetryPayload(
795
+ from,
796
+ to,
797
+ inspectionEvent.event,
798
+ context,
799
+ ),
609
800
  }),
610
801
  );
611
802
  // Captain pane (PBRT-3 / PBRT-14): show the transition
@@ -618,17 +809,14 @@ export default function createPlaybookRuntime(
618
809
  if (transitionLine !== undefined) {
619
810
  enqueueEmit(() => ports.emitStatus(transitionLine));
620
811
  }
621
- const entryLine = formatStateEntry(to);
622
- const riderSuffix = stateMetadata.has(to)
623
- ? formatRiders(snap.context ?? {})
624
- : '';
625
- const message = entryLine + riderSuffix;
812
+ const entryLine = formatStateEntry(to, context);
813
+ if (entryLine === undefined) return;
626
814
  if (to === 'failed') {
627
815
  const lastError = (snap.context as { lastError?: unknown })
628
816
  ?.lastError;
629
- enqueueEmit(() => ports.emitStatus(message, { lastError }));
817
+ enqueueEmit(() => ports.emitStatus(entryLine, { lastError }));
630
818
  } else {
631
- enqueueEmit(() => ports.emitStatus(message));
819
+ enqueueEmit(() => ports.emitStatus(entryLine));
632
820
  }
633
821
  },
634
822
  },
@@ -657,21 +845,27 @@ export default function createPlaybookRuntime(
657
845
  }
658
846
  activeSignal = signal;
659
847
  try {
660
- // 1. Classify text into an FSM event (slash event; else LLM).
661
- const event = await classifyBossText(text, savedPorts, signal);
662
- // Unknown slash or empty input — classifier already surfaced
663
- // status; nothing to send.
848
+ // 1. Classify non-empty text into an FSM event through the judge.
849
+ const event = await classifyBossText(
850
+ text,
851
+ savedPorts,
852
+ signal,
853
+ actor.getSnapshot(),
854
+ );
855
+ // Empty input, no-action classifier output, or invalid classifier
856
+ // output — nothing to send.
664
857
  if (event === undefined) {
665
858
  await drainEmissions();
666
859
  return;
667
860
  }
668
- // 2. Captain-pane Boss-input echo (PBRT-14): the verbatim
669
- // Boss text and the FSM event it classified to, before
670
- // the FSM advances. Enqueued so it interleaves cleanly
671
- // with the inspect-driven transition emissions.
861
+ // 2. Captain-pane classification line (PBRT-14): the bare
862
+ // FSM event type, emitted before the FSM advances so the
863
+ // host can render it as captain speech (e.g.,
864
+ // `captain> START_CODING`). Enqueued so it interleaves
865
+ // cleanly with the inspect-driven transition emissions.
672
866
  const echoPorts = savedPorts;
673
867
  enqueueEmit(() =>
674
- echoPorts.emitStatus(formatBossEcho(text, event.type)),
868
+ echoPorts.emitStatus(formatClassification(event.type)),
675
869
  );
676
870
  // 3. final state ('done') cannot accept new events — dispose
677
871
  // and reconstruct per DR-004 §5.
@@ -739,5 +933,5 @@ function driveToQuiescence(
739
933
 
740
934
  function isQuiescent(snap: { value: unknown }): boolean {
741
935
  const v = snap.value;
742
- return v === 'ready' || v === 'failed' || v === 'done';
936
+ return typeof v === 'string' && QUIESCENT_STATES.has(v);
743
937
  }