@sublang/playbook 0.1.3 → 0.2.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.
@@ -18,7 +18,6 @@ When Boss gives a coding intent, Captain shall relay it to Coder along with the
18
18
  > If yes, implement and test, updating both code and specs; otherwise, decompose into tasks as a new IR under @specs/iterations.
19
19
  > Consult @specs/map.md for relevant context if needed; ensure it reflects the changes.
20
20
  > Do not commit.
21
-
22
21
  The resulting changes are Initial Changes.
23
22
 
24
23
  ### CODE-2
@@ -34,7 +33,6 @@ When a new IR or IR task passes review and is committed, Captain shall prompt Co
34
33
  > Implement one task at a time (including corresponding tests if any).
35
34
  > Stop after each task for review — do not commit yet.
36
35
  > If relevant, mark progress in the IR.
37
-
38
36
  The resulting changes are Initial Changes.
39
37
 
40
38
  ### CODE-4
@@ -52,7 +50,6 @@ When an IR is done, Captain shall prompt Coder:
52
50
  > Avoid implementation specifics.
53
51
  > Avoid redundant spec items.
54
52
  > Consult @specs/map.md for relevant context and update it to reflect your changes.
55
-
56
53
  ## Reviewer
57
54
 
58
55
  For each finding in a review round, Coder either addresses it with changes or challenges it with a rebuttal.
@@ -243,8 +240,12 @@ When Coder makes any Initial Changes and Reviewer has not played since the last
243
240
  > Make a commit of the changes that belong in the repo, following @specs/dev/git.md (reread if necessary).
244
241
  > Coder is <coder-llm>.
245
242
 
243
+ Result guard: `needsBossInput` — Committing requires additional Boss input or rescoping rather than a specific answer to resume this same state.
244
+
246
245
  ### CODE-19
247
246
 
248
247
  When Coder makes any Initial Changes and Reviewer has played since the last commit, or Reviewer raises no findings on uncommitted changes and Coder has played since the last commit, Captain shall prompt Committer:
249
248
  > Make a commit of the changes that belong in the repo, following @specs/dev/git.md (reread if necessary).
250
249
  > Coder is <coder-llm>; Reviewer is <reviewer-llm>.
250
+
251
+ Result guard: `needsBossInput` — Committing requires additional Boss input or rescoping rather than a specific answer to resume this same state.
@@ -25,17 +25,26 @@ export type CodePlaybookOptions = CodingInput;
25
25
  declare function composePlayerPrompt(input: CaptainInput): string;
26
26
  declare function resolvePlayerId(input: CaptainInput): string;
27
27
  declare function adjudicate(input: CaptainInput, finalText: string, ports: PlaybookPorts, signal: AbortSignal): Promise<CaptainOutput>;
28
- declare function classifyBossText(text: string, ports: PlaybookPorts, signal: AbortSignal): Promise<CodingEvent | undefined>;
28
+ declare function classifyBossText(text: string, ports: PlaybookPorts, signal: AbortSignal, snapshotOrState?: unknown): Promise<CodingEvent | undefined>;
29
29
  declare function captainBridge(ports: PlaybookPorts, getActiveSignal?: () => AbortSignal | undefined): import("xstate").PromiseActorLogic<CaptainOutput, CaptainInput, import("xstate").EventObject>;
30
30
  interface StateMetadata {
31
31
  player: CaptainInput['player'];
32
32
  sourceItem: string;
33
33
  label: string;
34
34
  }
35
- declare function formatStateEntry(stateId: string): string;
35
+ interface PendingBossQuestionForStatus {
36
+ resumeStateId: string;
37
+ sourceItem: string;
38
+ player: string;
39
+ question: string;
40
+ }
41
+ declare function pendingBossQuestionFromContext(context: Record<string, unknown>): PendingBossQuestionForStatus | undefined;
42
+ declare function formatAwaitBossReplyEntry(context: Record<string, unknown>): string;
43
+ declare function formatStateEntry(stateId: string, context?: Record<string, unknown>): string;
36
44
  declare function formatTransition(event: unknown): string | undefined;
37
45
  declare function formatBossEcho(text: string, eventType?: string): string;
38
46
  declare function formatRiders(context: Record<string, unknown>): string;
47
+ declare function stateTelemetryPayload(from: unknown, to: string, event: unknown, context: Record<string, unknown>): Record<string, unknown>;
39
48
  export declare const _internal: {
40
49
  composePlayerPrompt: typeof composePlayerPrompt;
41
50
  resolvePlayerId: typeof resolvePlayerId;
@@ -44,10 +53,13 @@ export declare const _internal: {
44
53
  captainBridge: typeof captainBridge;
45
54
  STATE_LABELS: Readonly<Record<string, string>>;
46
55
  stateMetadata: ReadonlyMap<string, StateMetadata>;
56
+ pendingBossQuestionFromContext: typeof pendingBossQuestionFromContext;
57
+ formatAwaitBossReplyEntry: typeof formatAwaitBossReplyEntry;
47
58
  formatStateEntry: typeof formatStateEntry;
48
59
  formatTransition: typeof formatTransition;
49
60
  formatBossEcho: typeof formatBossEcho;
50
61
  formatRiders: typeof formatRiders;
62
+ stateTelemetryPayload: typeof stateTelemetryPayload;
51
63
  };
52
64
  export default function createPlaybookRuntime(options: CodePlaybookOptions): PlaybookRuntime;
53
65
  export {};
@@ -7,20 +7,32 @@
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
24
  // string replace, no escaping) and prepends labelled blocks for any
21
- // populated structured field. The FSM's prompt body is never re-flowed.
25
+ // populated structured field. When a state resumes from a Boss reply,
26
+ // the continuation preamble and Q/A blocks precede the ordinary
27
+ // labelled blocks. The FSM's prompt body is never re-flowed.
22
28
  function composePlayerPrompt(input) {
23
29
  const blocks = [];
30
+ if (input.pendingBossQuestion !== undefined &&
31
+ input.bossReply !== undefined) {
32
+ blocks.push('You previously paused this task to ask Boss a question; Boss has now replied. Continue the same task using the reply below.');
33
+ blocks.push(`Boss question:\n${input.pendingBossQuestion.question}`);
34
+ blocks.push(`Boss reply:\n${input.bossReply}`);
35
+ }
24
36
  if (input.intent !== undefined) {
25
37
  blocks.push(`Boss intent:\n${input.intent}`);
26
38
  }
@@ -102,6 +114,9 @@ async function adjudicate(input, finalText, ports, signal) {
102
114
  // the judge response.
103
115
  for (const field of extractRequiredFields(input.result[guard])) {
104
116
  if (typeof obj[field] !== 'string') {
117
+ if (guard === 'needsBossReply' && field === 'question') {
118
+ throw new Error(BOSS_REPLY_ERRORS.missingQuestion);
119
+ }
105
120
  throw new Error(`adjudicate: judge response missing required field "${field}" for guard "${guard}"`);
106
121
  }
107
122
  }
@@ -145,58 +160,24 @@ function parseJudgeJson(raw) {
145
160
  }
146
161
  }
147
162
  // 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) {
163
+ // Every non-empty Boss turn goes through ports.callJudge. Slash-prefixed
164
+ // text is ordinary content once a host has routed the turn to this
165
+ // playbook; `/command` selection belongs outside handleBossInput. The
166
+ // classifier is state-aware so awaitBossReply can distinguish a direct
167
+ // answer (BOSS_REPLY) from a fresh directive that abandons the pending
168
+ // question through the FSM's existing transitions.
169
+ async function classifyBossText(text, ports, signal, snapshotOrState) {
155
170
  const trimmed = text.trim();
156
171
  if (trimmed === '')
157
172
  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);
182
- }
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
- };
173
+ return classifyWithLlm(text, ports, signal, snapshotOrState);
197
174
  }
198
- async function classifyWithLlm(text, ports, signal) {
199
- const prompt = buildClassifierPrompt(text);
175
+ const rootEvents = enumerateRootEvents(codingMachine);
176
+ const bossInterruptTargets = rootEvents.bossInterruptTargetDescriptions;
177
+ const bossInterruptTargetIds = new Set(bossInterruptTargets.map((target) => target.stateId));
178
+ async function classifyWithLlm(text, ports, signal, snapshotOrState) {
179
+ const state = classifierState(snapshotOrState);
180
+ const prompt = buildClassifierPrompt(text, state);
200
181
  const raw = await ports.callJudge(prompt, signal);
201
182
  const parsed = parseJudgeJson(raw);
202
183
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
@@ -215,6 +196,10 @@ async function classifyWithLlm(text, ports, signal) {
215
196
  ? obj.payload
216
197
  : {};
217
198
  switch (eventType) {
199
+ case 'NO_ACTION':
200
+ case 'NO_FSM_ACTION':
201
+ case 'NONE':
202
+ return undefined;
218
203
  case 'START_CODING': {
219
204
  if (typeof payload.intent !== 'string') {
220
205
  await ports.emitStatus('Classifier omitted intent for START_CODING');
@@ -241,6 +226,10 @@ async function classifyWithLlm(text, ports, signal) {
241
226
  await ports.emitStatus('Classifier omitted targetId for BOSS_INTERRUPT');
242
227
  return undefined;
243
228
  }
229
+ if (!bossInterruptTargetIds.has(payload.targetId)) {
230
+ await ports.emitStatus(`Classifier supplied invalid targetId for BOSS_INTERRUPT: ${payload.targetId}`);
231
+ return undefined;
232
+ }
244
233
  return {
245
234
  type: 'BOSS_INTERRUPT',
246
235
  targetId: payload.targetId,
@@ -252,27 +241,63 @@ async function classifyWithLlm(text, ports, signal) {
252
241
  : {}),
253
242
  };
254
243
  }
244
+ case 'BOSS_REPLY': {
245
+ if (state.value !== 'awaitBossReply') {
246
+ await ports.emitStatus('Classifier returned BOSS_REPLY outside awaitBossReply');
247
+ return undefined;
248
+ }
249
+ if (typeof payload.answer !== 'string') {
250
+ await ports.emitStatus('Classifier omitted answer for BOSS_REPLY');
251
+ return undefined;
252
+ }
253
+ return { type: 'BOSS_REPLY', answer: payload.answer };
254
+ }
255
255
  default:
256
256
  await ports.emitStatus(`Classifier returned unknown event type: ${eventType}`);
257
257
  return undefined;
258
258
  }
259
259
  }
260
- function buildClassifierPrompt(text) {
261
- return [
260
+ function classifierState(snapshotOrState) {
261
+ if (snapshotOrState !== null &&
262
+ typeof snapshotOrState === 'object' &&
263
+ 'value' in snapshotOrState) {
264
+ const candidate = snapshotOrState;
265
+ return {
266
+ value: candidate.value,
267
+ context: candidate.context !== null &&
268
+ typeof candidate.context === 'object' &&
269
+ !Array.isArray(candidate.context)
270
+ ? candidate.context
271
+ : {},
272
+ };
273
+ }
274
+ return { value: snapshotOrState, context: {} };
275
+ }
276
+ function buildClassifierPrompt(text, state) {
277
+ const currentState = typeof state.value === 'string' ? state.value : 'unknown';
278
+ const pendingBossQuestion = pendingBossQuestionFromContext(state.context);
279
+ const lines = [
262
280
  'Classify the following Boss message into exactly one of these events.',
263
281
  'Respond with JSON: { "event": "<TYPE>", "payload": { ...fields } }.',
282
+ 'Use { "event": "NO_ACTION", "payload": {} } when no FSM action should be taken.',
264
283
  '',
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');
284
+ `Current state: ${currentState}`,
285
+ ];
286
+ if (pendingBossQuestion !== undefined) {
287
+ lines.push(`Pending Boss question: ${pendingBossQuestion.question}`, `Pending resume state: ${pendingBossQuestion.resumeStateId}`);
288
+ }
289
+ 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:');
290
+ for (const target of bossInterruptTargets) {
291
+ lines.push(` - ${target.stateId}: ${target.description}`);
292
+ }
293
+ if (currentState === 'awaitBossReply') {
294
+ lines.push('- BOSS_REPLY: payload { answer: "<verbatim Boss answer>" }');
295
+ }
296
+ else {
297
+ lines.push('- BOSS_REPLY: valid only when Current state is awaitBossReply');
298
+ }
299
+ lines.push('', 'Boss message:', '```', text, '```');
300
+ return lines.join('\n');
276
301
  }
277
302
  // Captain-actor bridge — DR-004 §7. One PromiseActorLogic that the
278
303
  // codingMachine invokes from every captain-invoking state. Per turn:
@@ -300,13 +325,15 @@ function captainBridge(ports, getActiveSignal) {
300
325
  if (result.finalText === undefined) {
301
326
  throw new Error('captainBridge: callPlayer returned status=ok with no finalText');
302
327
  }
303
- return adjudicate(input, result.finalText, ports, activeSignal);
328
+ const output = await adjudicate(input, result.finalText, ports, activeSignal);
329
+ validateBossReplyOutput(input, output);
330
+ return output;
304
331
  });
305
332
  }
306
333
  // Captain pane display — PBRT-3 / PBRT-14.
307
334
  // The Captain pane is a stream keyed on four glyphs so a reader can
308
335
  // parse each line at a glance:
309
- // ◆ terminal entry (ready / done / failed)
336
+ // ◆ basic idle entry (ready / done / failed)
310
337
  // ▸ Boss input echo
311
338
  // ⮕ captain-invoking state entry (label + player + CODE-N)
312
339
  // ⤷ transition (guard fired by the just-finished captain call)
@@ -345,21 +372,73 @@ const stateMetadata = (() => {
345
372
  }
346
373
  return m;
347
374
  })();
348
- const TERMINAL_STATES = new Set([
375
+ const stateIdBySourceItem = new Map([...stateMetadata.entries()].map(([stateId, meta]) => [
376
+ meta.sourceItem,
377
+ stateId,
378
+ ]));
379
+ const registeredResumableStateIds = new Set(enumerateAwaitBossReply(codingMachine).bossReplyTransitions.map((transition) => transition.target));
380
+ function validateBossReplyOutput(input, output) {
381
+ if (output.guard !== 'needsBossReply')
382
+ return;
383
+ if (typeof output.question !== 'string') {
384
+ throw new Error(BOSS_REPLY_ERRORS.missingQuestion);
385
+ }
386
+ const stateId = stateIdBySourceItem.get(input.sourceItem);
387
+ if (stateId === undefined || !registeredResumableStateIds.has(stateId)) {
388
+ throw new Error(BOSS_REPLY_ERRORS.unregisteredState(stateId ?? input.sourceItem));
389
+ }
390
+ }
391
+ const QUIESCENT_STATES = new Set([
349
392
  'ready',
393
+ 'awaitBossReply',
350
394
  'done',
351
395
  'failed',
352
396
  ]);
397
+ // awaitBossReply is quiescent too, but uses a custom single-line
398
+ // status frame rather than the plain `◆ <state>` entry.
399
+ const BASIC_IDLE_GLYPH_STATES = new Set([...QUIESCENT_STATES].filter((stateId) => stateId !== 'awaitBossReply'));
353
400
  // Captain-pane surface (PBRT-3): every captain-invoking state plus
354
- // the three terminal/idle states. Wider than the prior
401
+ // the quiescent states. Wider than the prior
355
402
  // "Boss-relevant" set per slc/link.md's default "emit on every
356
403
  // transition; let the host filter."
357
404
  const CAPTAIN_PANE_STATES = new Set([
358
405
  ...stateMetadata.keys(),
359
- ...TERMINAL_STATES,
406
+ ...QUIESCENT_STATES,
360
407
  ]);
361
- function formatStateEntry(stateId) {
362
- if (TERMINAL_STATES.has(stateId))
408
+ function pendingBossQuestionFromContext(context) {
409
+ const pending = context.pendingBossQuestion;
410
+ if (pending === undefined || typeof pending !== 'object') {
411
+ return undefined;
412
+ }
413
+ const candidate = pending;
414
+ if (typeof candidate.resumeStateId !== 'string' ||
415
+ typeof candidate.sourceItem !== 'string' ||
416
+ typeof candidate.player !== 'string' ||
417
+ typeof candidate.question !== 'string') {
418
+ return undefined;
419
+ }
420
+ return {
421
+ resumeStateId: candidate.resumeStateId,
422
+ sourceItem: candidate.sourceItem,
423
+ player: candidate.player,
424
+ question: candidate.question,
425
+ };
426
+ }
427
+ function questionExcerpt(question) {
428
+ return question.replace(/[\r\n]+/g, ' ').slice(0, 80);
429
+ }
430
+ function formatAwaitBossReplyEntry(context) {
431
+ const pending = pendingBossQuestionFromContext(context);
432
+ const resumeStateId = pending?.resumeStateId ?? 'unknown';
433
+ const player = pending?.player ?? 'unknown';
434
+ const sourceItem = pending?.sourceItem ?? 'unknown';
435
+ const question = questionExcerpt(pending?.question ?? '');
436
+ return `◆ awaiting Boss reply · ${resumeStateId} · ${player} · ${sourceItem} · q=${JSON.stringify(question)}`;
437
+ }
438
+ function formatStateEntry(stateId, context = {}) {
439
+ if (stateId === 'awaitBossReply')
440
+ return formatAwaitBossReplyEntry(context);
441
+ if (BASIC_IDLE_GLYPH_STATES.has(stateId))
363
442
  return `◆ ${stateId}`;
364
443
  const meta = stateMetadata.get(stateId);
365
444
  if (!meta)
@@ -402,6 +481,16 @@ function formatRiders(context) {
402
481
  }
403
482
  return parts.length > 0 ? ` ${parts.join(' ')}` : '';
404
483
  }
484
+ function stateTelemetryPayload(from, to, event, context) {
485
+ const payload = { from, to, event };
486
+ if (to === 'awaitBossReply') {
487
+ const pendingBossQuestion = pendingBossQuestionFromContext(context);
488
+ if (pendingBossQuestion !== undefined) {
489
+ payload.pendingBossQuestion = pendingBossQuestion;
490
+ }
491
+ }
492
+ return payload;
493
+ }
405
494
  // Internal export surface for tests. Not part of the stable public API;
406
495
  // the leading underscore signals "subject to change." Each member is
407
496
  // referenced here so `noUnusedLocals` stays clean while later tasks
@@ -414,10 +503,13 @@ export const _internal = {
414
503
  captainBridge,
415
504
  STATE_LABELS,
416
505
  stateMetadata,
506
+ pendingBossQuestionFromContext,
507
+ formatAwaitBossReplyEntry,
417
508
  formatStateEntry,
418
509
  formatTransition,
419
510
  formatBossEcho,
420
511
  formatRiders,
512
+ stateTelemetryPayload,
421
513
  };
422
514
  export default function createPlaybookRuntime(options) {
423
515
  let actor;
@@ -475,9 +567,10 @@ export default function createPlaybookRuntime(options) {
475
567
  const from = priorState;
476
568
  priorState = to;
477
569
  // Telemetry on every transition (PBRT-14).
570
+ const context = snap.context ?? {};
478
571
  enqueueEmit(() => ports.emitTelemetry({
479
572
  topic: 'playbook.fsm.state',
480
- payload: { from, to, event: inspectionEvent.event },
573
+ payload: stateTelemetryPayload(from, to, inspectionEvent.event, context),
481
574
  }));
482
575
  // Captain pane (PBRT-3 / PBRT-14): show the transition
483
576
  // guard first (when this is an actor-done transition with
@@ -490,9 +583,9 @@ export default function createPlaybookRuntime(options) {
490
583
  if (transitionLine !== undefined) {
491
584
  enqueueEmit(() => ports.emitStatus(transitionLine));
492
585
  }
493
- const entryLine = formatStateEntry(to);
586
+ const entryLine = formatStateEntry(to, context);
494
587
  const riderSuffix = stateMetadata.has(to)
495
- ? formatRiders(snap.context ?? {})
588
+ ? formatRiders(context)
496
589
  : '';
497
590
  const message = entryLine + riderSuffix;
498
591
  if (to === 'failed') {
@@ -519,10 +612,10 @@ export default function createPlaybookRuntime(options) {
519
612
  }
520
613
  activeSignal = signal;
521
614
  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.
615
+ // 1. Classify non-empty text into an FSM event through the judge.
616
+ const event = await classifyBossText(text, savedPorts, signal, actor.getSnapshot());
617
+ // Empty input, no-action classifier output, or invalid classifier
618
+ // output nothing to send.
526
619
  if (event === undefined) {
527
620
  await drainEmissions();
528
621
  return;
@@ -594,5 +687,5 @@ function driveToQuiescence(actor) {
594
687
  }
595
688
  function isQuiescent(snap) {
596
689
  const v = snap.value;
597
- return v === 'ready' || v === 'failed' || v === 'done';
690
+ return typeof v === 'string' && QUIESCENT_STATES.has(v);
598
691
  }