@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,29 +7,40 @@
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
  import { createActor, fromPromise } from 'xstate';
13
13
  import { codingMachine, } from './code.fsm.js';
14
- import { enumerateCaptainStates } from './code.fsm.introspect.js';
14
+ import { enumerateAwaitBossReply, enumerateCaptainStates, enumerateRootEvents, } from './code.fsm.introspect.js';
15
+ const BOSS_REPLY_ERRORS = {
16
+ missingQuestion: "needsBossReply outcome missing 'question' field",
17
+ unregisteredState: (stateId) => `state ${stateId} declared needsBossReply but is not registered as resumable`,
18
+ };
15
19
  // Internal capabilities (DR-004 §10). Each ships with its final
16
20
  // signature; behavior lands in the per-capability task noted by the
17
21
  // TODO marker.
18
22
  // Player-prompt composer — DR-004 §6.
19
23
  // Substitutes the three placeholder tokens in `input.prompt` (literal
20
- // string replace, no escaping) and prepends labelled blocks for any
21
- // populated structured field. The FSM's prompt body is never re-flowed.
24
+ // string replace, no escaping) and arranges labelled blocks around
25
+ // the prompt body. Context blocks the body refers to as prior
26
+ // material (`Boss intent:`, `Task description:`) are prepended;
27
+ // action blocks the body refers to as material below
28
+ // (`Review items:`, `Rebuttals:`) are appended after the body so the
29
+ // CODE-N prompts' "review item below" / "rebuttal below" phrasing
30
+ // matches the rendered layout. When a state resumes from a Boss
31
+ // reply, the continuation preamble and Q/A blocks precede every
32
+ // ordinary block. The FSM's prompt body is never re-flowed.
22
33
  function composePlayerPrompt(input) {
23
34
  const blocks = [];
35
+ if (input.pendingBossQuestion !== undefined &&
36
+ input.bossReply !== undefined) {
37
+ blocks.push('You previously paused this task to ask Boss a question; Boss has now replied. Continue the same task using the reply below.');
38
+ blocks.push(`Boss question:\n${input.pendingBossQuestion.question}`);
39
+ blocks.push(`Boss reply:\n${input.bossReply}`);
40
+ }
24
41
  if (input.intent !== undefined) {
25
42
  blocks.push(`Boss intent:\n${input.intent}`);
26
43
  }
27
- if (input.reviews !== undefined) {
28
- blocks.push(`Review items:\n${input.reviews}`);
29
- }
30
- if (input.challenges !== undefined) {
31
- blocks.push(`Rebuttals:\n${input.challenges}`);
32
- }
33
44
  if (input.taskDescription !== undefined) {
34
45
  blocks.push(`Task description:\n${input.taskDescription}`);
35
46
  }
@@ -44,6 +55,12 @@ function composePlayerPrompt(input) {
44
55
  body = body.replaceAll('<reviewer-llm>', input.reviewerPlayer);
45
56
  }
46
57
  blocks.push(body);
58
+ if (input.reviews !== undefined) {
59
+ blocks.push(`Review items:\n${input.reviews}`);
60
+ }
61
+ if (input.challenges !== undefined) {
62
+ blocks.push(`Rebuttals:\n${input.challenges}`);
63
+ }
47
64
  return blocks.join('\n\n');
48
65
  }
49
66
  // Player-id resolver — DR-004 §2.
@@ -102,6 +119,9 @@ async function adjudicate(input, finalText, ports, signal) {
102
119
  // the judge response.
103
120
  for (const field of extractRequiredFields(input.result[guard])) {
104
121
  if (typeof obj[field] !== 'string') {
122
+ if (guard === 'needsBossReply' && field === 'question') {
123
+ throw new Error(BOSS_REPLY_ERRORS.missingQuestion);
124
+ }
105
125
  throw new Error(`adjudicate: judge response missing required field "${field}" for guard "${guard}"`);
106
126
  }
107
127
  }
@@ -145,58 +165,24 @@ function parseJudgeJson(raw) {
145
165
  }
146
166
  }
147
167
  // Boss-event classifier — DR-004 §3.
148
- // Slash forms tried first; unknown slash forms surface as
149
- // emitStatus and return undefined per slc/link.md ("Unknown
150
- // commands surface as emitStatus, not as a silently-dropped
151
- // turn"). Anything that isn't a slash form falls through to the
152
- // LLM classifier via ports.callJudge with a fixed prompt that
153
- // names the four event types and their payload shapes.
154
- async function classifyBossText(text, ports, signal) {
168
+ // Every non-empty Boss turn goes through ports.callJudge. Slash-prefixed
169
+ // text is ordinary content once a host has routed the turn to this
170
+ // playbook; `/command` selection belongs outside handleBossInput. The
171
+ // classifier is state-aware so awaitBossReply can distinguish a direct
172
+ // answer (BOSS_REPLY) from a fresh directive that abandons the pending
173
+ // question through the FSM's existing transitions.
174
+ async function classifyBossText(text, ports, signal, snapshotOrState) {
155
175
  const trimmed = text.trim();
156
176
  if (trimmed === '')
157
177
  return undefined;
158
- if (trimmed === '/start' || trimmed.startsWith('/start ')) {
159
- return { type: 'START_CODING', intent: trimmed.slice('/start'.length).trim() };
160
- }
161
- if (trimmed === '/continue' || trimmed.startsWith('/continue ')) {
162
- return {
163
- type: 'CONTINUE_IR',
164
- irNumber: trimmed.slice('/continue'.length).trim(),
165
- };
166
- }
167
- if (trimmed === '/summarize' || trimmed.startsWith('/summarize ')) {
168
- return {
169
- type: 'SUMMARIZE_IR',
170
- irNumber: trimmed.slice('/summarize'.length).trim(),
171
- };
172
- }
173
- if (trimmed === '/interrupt' || trimmed.startsWith('/interrupt ')) {
174
- return parseInterruptSlash(trimmed, ports);
175
- }
176
- if (trimmed.startsWith('/')) {
177
- const cmd = trimmed.split(/\s+/)[0];
178
- await ports.emitStatus(`Unknown slash command: ${cmd}`);
179
- return undefined;
180
- }
181
- return classifyWithLlm(trimmed, ports, signal);
178
+ return classifyWithLlm(text, ports, signal, snapshotOrState);
182
179
  }
183
- async function parseInterruptSlash(trimmed, ports) {
184
- const rest = trimmed.slice('/interrupt'.length).trim();
185
- if (rest === '') {
186
- await ports.emitStatus('/interrupt requires a stateId');
187
- return undefined;
188
- }
189
- const firstSpace = rest.search(/\s/);
190
- const targetId = firstSpace === -1 ? rest : rest.slice(0, firstSpace);
191
- const intent = firstSpace === -1 ? '' : rest.slice(firstSpace).trim();
192
- return {
193
- type: 'BOSS_INTERRUPT',
194
- targetId: targetId,
195
- ...(intent ? { intent } : {}),
196
- };
197
- }
198
- async function classifyWithLlm(text, ports, signal) {
199
- const prompt = buildClassifierPrompt(text);
180
+ const rootEvents = enumerateRootEvents(codingMachine);
181
+ const bossInterruptTargets = rootEvents.bossInterruptTargetDescriptions;
182
+ const bossInterruptTargetIds = new Set(bossInterruptTargets.map((target) => target.stateId));
183
+ async function classifyWithLlm(text, ports, signal, snapshotOrState) {
184
+ const state = classifierState(snapshotOrState);
185
+ const prompt = buildClassifierPrompt(text, state);
200
186
  const raw = await ports.callJudge(prompt, signal);
201
187
  const parsed = parseJudgeJson(raw);
202
188
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
@@ -215,6 +201,10 @@ async function classifyWithLlm(text, ports, signal) {
215
201
  ? obj.payload
216
202
  : {};
217
203
  switch (eventType) {
204
+ case 'NO_ACTION':
205
+ case 'NO_FSM_ACTION':
206
+ case 'NONE':
207
+ return undefined;
218
208
  case 'START_CODING': {
219
209
  if (typeof payload.intent !== 'string') {
220
210
  await ports.emitStatus('Classifier omitted intent for START_CODING');
@@ -241,6 +231,10 @@ async function classifyWithLlm(text, ports, signal) {
241
231
  await ports.emitStatus('Classifier omitted targetId for BOSS_INTERRUPT');
242
232
  return undefined;
243
233
  }
234
+ if (!bossInterruptTargetIds.has(payload.targetId)) {
235
+ await ports.emitStatus(`Classifier supplied invalid targetId for BOSS_INTERRUPT: ${payload.targetId}`);
236
+ return undefined;
237
+ }
244
238
  return {
245
239
  type: 'BOSS_INTERRUPT',
246
240
  targetId: payload.targetId,
@@ -252,27 +246,63 @@ async function classifyWithLlm(text, ports, signal) {
252
246
  : {}),
253
247
  };
254
248
  }
249
+ case 'BOSS_REPLY': {
250
+ if (state.value !== 'awaitBossReply') {
251
+ await ports.emitStatus('Classifier returned BOSS_REPLY outside awaitBossReply');
252
+ return undefined;
253
+ }
254
+ if (typeof payload.answer !== 'string') {
255
+ await ports.emitStatus('Classifier omitted answer for BOSS_REPLY');
256
+ return undefined;
257
+ }
258
+ return { type: 'BOSS_REPLY', answer: payload.answer };
259
+ }
255
260
  default:
256
261
  await ports.emitStatus(`Classifier returned unknown event type: ${eventType}`);
257
262
  return undefined;
258
263
  }
259
264
  }
260
- function buildClassifierPrompt(text) {
261
- return [
265
+ function classifierState(snapshotOrState) {
266
+ if (snapshotOrState !== null &&
267
+ typeof snapshotOrState === 'object' &&
268
+ 'value' in snapshotOrState) {
269
+ const candidate = snapshotOrState;
270
+ return {
271
+ value: candidate.value,
272
+ context: candidate.context !== null &&
273
+ typeof candidate.context === 'object' &&
274
+ !Array.isArray(candidate.context)
275
+ ? candidate.context
276
+ : {},
277
+ };
278
+ }
279
+ return { value: snapshotOrState, context: {} };
280
+ }
281
+ function buildClassifierPrompt(text, state) {
282
+ const currentState = typeof state.value === 'string' ? state.value : 'unknown';
283
+ const pendingBossQuestion = pendingBossQuestionFromContext(state.context);
284
+ const lines = [
262
285
  'Classify the following Boss message into exactly one of these events.',
263
286
  'Respond with JSON: { "event": "<TYPE>", "payload": { ...fields } }.',
287
+ 'Use { "event": "NO_ACTION", "payload": {} } when no FSM action should be taken.',
264
288
  '',
265
- 'Events:',
266
- '- START_CODING: payload { intent: "<free-form goal>" }',
267
- '- CONTINUE_IR: payload { irNumber: "<number>" }',
268
- '- SUMMARIZE_IR: payload { irNumber: "<number>" }',
269
- '- BOSS_INTERRUPT: payload { targetId: "<stateId>", intent?: "<free-form goal>", irNumber?: "<number>" }',
270
- '',
271
- 'Boss message:',
272
- '```',
273
- text,
274
- '```',
275
- ].join('\n');
289
+ `Current state: ${currentState}`,
290
+ ];
291
+ if (pendingBossQuestion !== undefined) {
292
+ lines.push(`Pending Boss question: ${pendingBossQuestion.question}`, `Pending resume state: ${pendingBossQuestion.resumeStateId}`);
293
+ }
294
+ lines.push('', 'Events:', '- START_CODING: payload { intent: "<free-form goal>" }', '- CONTINUE_IR: payload { irNumber: "<number>" }', '- SUMMARIZE_IR: payload { irNumber: "<number>" }', '- BOSS_INTERRUPT: payload { targetId: "<stateId>", intent?: "<free-form goal>", irNumber?: "<number>" }', ' targetId must be one of these jumpable states:');
295
+ for (const target of bossInterruptTargets) {
296
+ lines.push(` - ${target.stateId}: ${target.description}`);
297
+ }
298
+ if (currentState === 'awaitBossReply') {
299
+ lines.push('- BOSS_REPLY: payload { answer: "<verbatim Boss answer>" }');
300
+ }
301
+ else {
302
+ lines.push('- BOSS_REPLY: valid only when Current state is awaitBossReply');
303
+ }
304
+ lines.push('', 'Boss message:', '```', text, '```');
305
+ return lines.join('\n');
276
306
  }
277
307
  // Captain-actor bridge — DR-004 §7. One PromiseActorLogic that the
278
308
  // codingMachine invokes from every captain-invoking state. Per turn:
@@ -300,18 +330,28 @@ function captainBridge(ports, getActiveSignal) {
300
330
  if (result.finalText === undefined) {
301
331
  throw new Error('captainBridge: callPlayer returned status=ok with no finalText');
302
332
  }
303
- return adjudicate(input, result.finalText, ports, activeSignal);
333
+ const output = await adjudicate(input, result.finalText, ports, activeSignal);
334
+ validateBossReplyOutput(input, output);
335
+ return output;
304
336
  });
305
337
  }
306
338
  // Captain pane display — PBRT-3 / PBRT-14.
307
- // The Captain pane is a stream keyed on four glyphs so a reader can
308
- // parse each line at a glance:
309
- // ◆ terminal entry (ready / done / failed)
310
- // Boss input echo
311
- // ⮕ captain-invoking state entry (label + player + CODE-N)
312
- // ⤷ transition (guard fired by the just-finished captain call)
313
- // Prompts and full player output ride the player panes; the Captain
314
- // pane keeps to the state-machine shape.
339
+ // The Captain pane is a stream of three glyphs plus one bare
340
+ // captain-speech act, designed so a reader can parse each line at a
341
+ // glance:
342
+ // (no glyph) bare FSM event type — host renders as captain speech
343
+ // (e.g., `captain> START_CODING`)
344
+ // ⤷ captain-invoking state entry: `<Player>: <label>`
345
+ // → transition guard outcome (`· field=N` tallies
346
+ // appended); the host presenter owns any visual
347
+ // nesting under the preceding ⤷ entry
348
+ // ◆ failure state (with `lastError` data) and the
349
+ // `awaitBossReply` suspension state (custom payload)
350
+ // The runtime emits no status line on entry to the idle state
351
+ // (`ready`) or the terminal state (`done`); the next `boss>` prompt
352
+ // is the implicit "turn over" signal. Prompts and full player output
353
+ // ride the player panes; the Captain pane keeps to the state-machine
354
+ // shape.
315
355
  const STATE_LABELS = {
316
356
  planAndImplement: 'plan & implement',
317
357
  respondToReview: 'respond to review',
@@ -345,26 +385,87 @@ const stateMetadata = (() => {
345
385
  }
346
386
  return m;
347
387
  })();
348
- const TERMINAL_STATES = new Set([
388
+ const stateIdBySourceItem = new Map([...stateMetadata.entries()].map(([stateId, meta]) => [
389
+ meta.sourceItem,
390
+ stateId,
391
+ ]));
392
+ const registeredResumableStateIds = new Set(enumerateAwaitBossReply(codingMachine).bossReplyTransitions.map((transition) => transition.target));
393
+ function validateBossReplyOutput(input, output) {
394
+ if (output.guard !== 'needsBossReply')
395
+ return;
396
+ if (typeof output.question !== 'string') {
397
+ throw new Error(BOSS_REPLY_ERRORS.missingQuestion);
398
+ }
399
+ const stateId = stateIdBySourceItem.get(input.sourceItem);
400
+ if (stateId === undefined || !registeredResumableStateIds.has(stateId)) {
401
+ throw new Error(BOSS_REPLY_ERRORS.unregisteredState(stateId ?? input.sourceItem));
402
+ }
403
+ }
404
+ const QUIESCENT_STATES = new Set([
349
405
  'ready',
406
+ 'awaitBossReply',
350
407
  'done',
351
408
  'failed',
352
409
  ]);
410
+ // States whose entry the runtime does not surface on the Captain
411
+ // pane per PBRT-3: the readline returning to its `boss>` prompt is
412
+ // the implicit "turn over" signal, so a `◆ ready` / `◆ done`
413
+ // tombstone is redundant.
414
+ const SUPPRESSED_ENTRY_STATES = new Set([
415
+ 'ready',
416
+ 'done',
417
+ ]);
353
418
  // Captain-pane surface (PBRT-3): every captain-invoking state plus
354
- // the three terminal/idle states. Wider than the prior
355
- // "Boss-relevant" set per slc/link.md's default "emit on every
356
- // transition; let the host filter."
419
+ // the quiescent states whose entry still carries information
420
+ // (failure with `lastError`, awaitBossReply with the pending
421
+ // question). `ready` and `done` flow through the inspect handler
422
+ // but their entries are dropped before emitStatus per
423
+ // SUPPRESSED_ENTRY_STATES above.
357
424
  const CAPTAIN_PANE_STATES = new Set([
358
425
  ...stateMetadata.keys(),
359
- ...TERMINAL_STATES,
426
+ ...QUIESCENT_STATES,
360
427
  ]);
361
- function formatStateEntry(stateId) {
362
- if (TERMINAL_STATES.has(stateId))
363
- return `◆ ${stateId}`;
428
+ function pendingBossQuestionFromContext(context) {
429
+ const pending = context.pendingBossQuestion;
430
+ if (pending === undefined || typeof pending !== 'object') {
431
+ return undefined;
432
+ }
433
+ const candidate = pending;
434
+ if (typeof candidate.resumeStateId !== 'string' ||
435
+ typeof candidate.sourceItem !== 'string' ||
436
+ typeof candidate.player !== 'string' ||
437
+ typeof candidate.question !== 'string') {
438
+ return undefined;
439
+ }
440
+ return {
441
+ resumeStateId: candidate.resumeStateId,
442
+ sourceItem: candidate.sourceItem,
443
+ player: candidate.player,
444
+ question: candidate.question,
445
+ };
446
+ }
447
+ function questionExcerpt(question) {
448
+ return question.replace(/[\r\n]+/g, ' ').slice(0, 80);
449
+ }
450
+ function formatAwaitBossReplyEntry(context) {
451
+ const pending = pendingBossQuestionFromContext(context);
452
+ const resumeStateId = pending?.resumeStateId ?? 'unknown';
453
+ const player = pending?.player ?? 'unknown';
454
+ const sourceItem = pending?.sourceItem ?? 'unknown';
455
+ const question = questionExcerpt(pending?.question ?? '');
456
+ return `◆ awaiting Boss reply · ${resumeStateId} · ${player} · ${sourceItem} · q=${JSON.stringify(question)}`;
457
+ }
458
+ function formatStateEntry(stateId, context = {}) {
459
+ if (stateId === 'awaitBossReply')
460
+ return formatAwaitBossReplyEntry(context);
461
+ if (SUPPRESSED_ENTRY_STATES.has(stateId))
462
+ return undefined;
463
+ if (stateId === 'failed')
464
+ return '◆ failed';
364
465
  const meta = stateMetadata.get(stateId);
365
466
  if (!meta)
366
- return `⮕ ${stateId}`;
367
- return `⮕ ${meta.label} ${meta.player} per ${meta.sourceItem}`;
467
+ return `⤷ ${stateId}`;
468
+ return `⤷ ${meta.player}: ${meta.label}`;
368
469
  }
369
470
  function formatTransition(event) {
370
471
  const output = event?.output;
@@ -378,29 +479,25 @@ function formatTransition(event) {
378
479
  tallies.push(`${field}=${items ? items.length : 1}`);
379
480
  }
380
481
  }
381
- const suffix = tallies.length > 0 ? ` ${tallies.join(' ')}` : '';
382
- return `⤷ ${output.guard}${suffix}`;
482
+ const suffix = tallies.length > 0 ? ` · ${tallies.join(' · ')}` : '';
483
+ // No leading whitespace: visual nesting under the preceding ⤷
484
+ // entry is the host presenter's concern (cligent's writeStatusLine
485
+ // emits status messages verbatim, with its own chrome but no
486
+ // continuation indent; layout is its job, not ours).
487
+ return `→ ${output.guard}${suffix}`;
383
488
  }
384
- function formatBossEcho(text, eventType) {
385
- return eventType !== undefined
386
- ? `▸ BOSS ${text} → ${eventType}`
387
- : `▸ BOSS ${text}`;
489
+ function formatClassification(eventType) {
490
+ return eventType;
388
491
  }
389
- // Rider fields PBRT-14 names — surfaced inline on a state entry
390
- // whenever the FSM context populates them, regardless of which
391
- // captain-invoking state is being entered. Routing-only context
392
- // (reviewSubject, afterReview, etc.) stays out of the pane and is
393
- // visible via emitTelemetry instead.
394
- const RIDER_FIELDS = ['intent', 'irNumber', 'taskDescription'];
395
- function formatRiders(context) {
396
- const parts = [];
397
- for (const f of RIDER_FIELDS) {
398
- const v = context[f];
399
- if (typeof v === 'string' && v.length > 0) {
400
- parts.push(`${f}=${JSON.stringify(v)}`);
492
+ function stateTelemetryPayload(from, to, event, context) {
493
+ const payload = { from, to, event };
494
+ if (to === 'awaitBossReply') {
495
+ const pendingBossQuestion = pendingBossQuestionFromContext(context);
496
+ if (pendingBossQuestion !== undefined) {
497
+ payload.pendingBossQuestion = pendingBossQuestion;
401
498
  }
402
499
  }
403
- return parts.length > 0 ? ` ${parts.join(' ')}` : '';
500
+ return payload;
404
501
  }
405
502
  // Internal export surface for tests. Not part of the stable public API;
406
503
  // the leading underscore signals "subject to change." Each member is
@@ -414,10 +511,12 @@ export const _internal = {
414
511
  captainBridge,
415
512
  STATE_LABELS,
416
513
  stateMetadata,
514
+ pendingBossQuestionFromContext,
515
+ formatAwaitBossReplyEntry,
417
516
  formatStateEntry,
418
517
  formatTransition,
419
- formatBossEcho,
420
- formatRiders,
518
+ formatClassification,
519
+ stateTelemetryPayload,
421
520
  };
422
521
  export default function createPlaybookRuntime(options) {
423
522
  let actor;
@@ -475,9 +574,10 @@ export default function createPlaybookRuntime(options) {
475
574
  const from = priorState;
476
575
  priorState = to;
477
576
  // Telemetry on every transition (PBRT-14).
577
+ const context = snap.context ?? {};
478
578
  enqueueEmit(() => ports.emitTelemetry({
479
579
  topic: 'playbook.fsm.state',
480
- payload: { from, to, event: inspectionEvent.event },
580
+ payload: stateTelemetryPayload(from, to, inspectionEvent.event, context),
481
581
  }));
482
582
  // Captain pane (PBRT-3 / PBRT-14): show the transition
483
583
  // guard first (when this is an actor-done transition with
@@ -490,18 +590,16 @@ export default function createPlaybookRuntime(options) {
490
590
  if (transitionLine !== undefined) {
491
591
  enqueueEmit(() => ports.emitStatus(transitionLine));
492
592
  }
493
- const entryLine = formatStateEntry(to);
494
- const riderSuffix = stateMetadata.has(to)
495
- ? formatRiders(snap.context ?? {})
496
- : '';
497
- const message = entryLine + riderSuffix;
593
+ const entryLine = formatStateEntry(to, context);
594
+ if (entryLine === undefined)
595
+ return;
498
596
  if (to === 'failed') {
499
597
  const lastError = snap.context
500
598
  ?.lastError;
501
- enqueueEmit(() => ports.emitStatus(message, { lastError }));
599
+ enqueueEmit(() => ports.emitStatus(entryLine, { lastError }));
502
600
  }
503
601
  else {
504
- enqueueEmit(() => ports.emitStatus(message));
602
+ enqueueEmit(() => ports.emitStatus(entryLine));
505
603
  }
506
604
  },
507
605
  });
@@ -519,20 +617,21 @@ export default function createPlaybookRuntime(options) {
519
617
  }
520
618
  activeSignal = signal;
521
619
  try {
522
- // 1. Classify text into an FSM event (slash event; else LLM).
523
- const event = await classifyBossText(text, savedPorts, signal);
524
- // Unknown slash or empty input classifier already surfaced
525
- // status; nothing to send.
620
+ // 1. Classify non-empty text into an FSM event through the judge.
621
+ const event = await classifyBossText(text, savedPorts, signal, actor.getSnapshot());
622
+ // Empty input, no-action classifier output, or invalid classifier
623
+ // output nothing to send.
526
624
  if (event === undefined) {
527
625
  await drainEmissions();
528
626
  return;
529
627
  }
530
- // 2. Captain-pane Boss-input echo (PBRT-14): the verbatim
531
- // Boss text and the FSM event it classified to, before
532
- // the FSM advances. Enqueued so it interleaves cleanly
533
- // with the inspect-driven transition emissions.
628
+ // 2. Captain-pane classification line (PBRT-14): the bare
629
+ // FSM event type, emitted before the FSM advances so the
630
+ // host can render it as captain speech (e.g.,
631
+ // `captain> START_CODING`). Enqueued so it interleaves
632
+ // cleanly with the inspect-driven transition emissions.
534
633
  const echoPorts = savedPorts;
535
- enqueueEmit(() => echoPorts.emitStatus(formatBossEcho(text, event.type)));
634
+ enqueueEmit(() => echoPorts.emitStatus(formatClassification(event.type)));
536
635
  // 3. final state ('done') cannot accept new events — dispose
537
636
  // and reconstruct per DR-004 §5.
538
637
  if (actor.getSnapshot().status === 'done') {
@@ -594,5 +693,5 @@ function driveToQuiescence(actor) {
594
693
  }
595
694
  function isQuiescent(snap) {
596
695
  const v = snap.value;
597
- return v === 'ready' || v === 'failed' || v === 'done';
696
+ return typeof v === 'string' && QUIESCENT_STATES.has(v);
598
697
  }