@sublang/playbook 0.1.2 → 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.
@@ -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,6 +53,12 @@ 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.
@@ -56,9 +66,22 @@ export type CodePlaybookOptions = CodingInput;
56
66
  // Player-prompt composer — DR-004 §6.
57
67
  // Substitutes the three placeholder tokens in `input.prompt` (literal
58
68
  // string replace, no escaping) and prepends labelled blocks for any
59
- // populated structured field. The FSM's prompt body is never re-flowed.
69
+ // populated structured field. When a state resumes from a Boss reply,
70
+ // the continuation preamble and Q/A blocks precede the ordinary
71
+ // labelled blocks. The FSM's prompt body is never re-flowed.
72
+
60
73
  function composePlayerPrompt(input: CaptainInput): string {
61
74
  const blocks: string[] = [];
75
+ if (
76
+ input.pendingBossQuestion !== undefined &&
77
+ input.bossReply !== undefined
78
+ ) {
79
+ blocks.push(
80
+ 'You previously paused this task to ask Boss a question; Boss has now replied. Continue the same task using the reply below.',
81
+ );
82
+ blocks.push(`Boss question:\n${input.pendingBossQuestion.question}`);
83
+ blocks.push(`Boss reply:\n${input.bossReply}`);
84
+ }
62
85
  if (input.intent !== undefined) {
63
86
  blocks.push(`Boss intent:\n${input.intent}`);
64
87
  }
@@ -153,6 +176,9 @@ async function adjudicate(
153
176
  // the judge response.
154
177
  for (const field of extractRequiredFields(input.result[guard])) {
155
178
  if (typeof obj[field] !== 'string') {
179
+ if (guard === 'needsBossReply' && field === 'question') {
180
+ throw new Error(BOSS_REPLY_ERRORS.missingQuestion);
181
+ }
156
182
  throw new Error(
157
183
  `adjudicate: judge response missing required field "${field}" for guard "${guard}"`,
158
184
  );
@@ -204,46 +230,22 @@ function parseJudgeJson(raw: string): unknown {
204
230
  }
205
231
 
206
232
  // 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.
233
+ // Every non-empty Boss turn goes through ports.callJudge. Slash-prefixed
234
+ // text is ordinary content once a host has routed the turn to this
235
+ // playbook; `/command` selection belongs outside handleBossInput. The
236
+ // classifier is state-aware so awaitBossReply can distinguish a direct
237
+ // answer (BOSS_REPLY) from a fresh directive that abandons the pending
238
+ // question through the FSM's existing transitions.
213
239
  async function classifyBossText(
214
240
  text: string,
215
241
  ports: PlaybookPorts,
216
242
  signal: AbortSignal,
243
+ snapshotOrState?: unknown,
217
244
  ): Promise<CodingEvent | undefined> {
218
245
  const trimmed = text.trim();
219
246
  if (trimmed === '') return undefined;
220
247
 
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);
248
+ return classifyWithLlm(text, ports, signal, snapshotOrState);
247
249
  }
248
250
 
249
251
  // JumpableStateId is internal to code.fsm.ts (not exported), so
@@ -253,31 +255,20 @@ type JumpableStateId = Extract<
253
255
  { type: 'BOSS_INTERRUPT' }
254
256
  >['targetId'];
255
257
 
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
- }
258
+ const rootEvents = enumerateRootEvents(codingMachine);
259
+ const bossInterruptTargets = rootEvents.bossInterruptTargetDescriptions;
260
+ const bossInterruptTargetIds: ReadonlySet<string> = new Set(
261
+ bossInterruptTargets.map((target) => target.stateId),
262
+ );
274
263
 
275
264
  async function classifyWithLlm(
276
265
  text: string,
277
266
  ports: PlaybookPorts,
278
267
  signal: AbortSignal,
268
+ snapshotOrState?: unknown,
279
269
  ): Promise<CodingEvent | undefined> {
280
- const prompt = buildClassifierPrompt(text);
270
+ const state = classifierState(snapshotOrState);
271
+ const prompt = buildClassifierPrompt(text, state);
281
272
  const raw = await ports.callJudge(prompt, signal);
282
273
  const parsed = parseJudgeJson(raw);
283
274
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
@@ -298,6 +289,10 @@ async function classifyWithLlm(
298
289
  : {};
299
290
 
300
291
  switch (eventType) {
292
+ case 'NO_ACTION':
293
+ case 'NO_FSM_ACTION':
294
+ case 'NONE':
295
+ return undefined;
301
296
  case 'START_CODING': {
302
297
  if (typeof payload.intent !== 'string') {
303
298
  await ports.emitStatus('Classifier omitted intent for START_CODING');
@@ -326,6 +321,12 @@ async function classifyWithLlm(
326
321
  );
327
322
  return undefined;
328
323
  }
324
+ if (!bossInterruptTargetIds.has(payload.targetId)) {
325
+ await ports.emitStatus(
326
+ `Classifier supplied invalid targetId for BOSS_INTERRUPT: ${payload.targetId}`,
327
+ );
328
+ return undefined;
329
+ }
329
330
  return {
330
331
  type: 'BOSS_INTERRUPT',
331
332
  targetId: payload.targetId as JumpableStateId,
@@ -337,6 +338,19 @@ async function classifyWithLlm(
337
338
  : {}),
338
339
  };
339
340
  }
341
+ case 'BOSS_REPLY': {
342
+ if (state.value !== 'awaitBossReply') {
343
+ await ports.emitStatus(
344
+ 'Classifier returned BOSS_REPLY outside awaitBossReply',
345
+ );
346
+ return undefined;
347
+ }
348
+ if (typeof payload.answer !== 'string') {
349
+ await ports.emitStatus('Classifier omitted answer for BOSS_REPLY');
350
+ return undefined;
351
+ }
352
+ return { type: 'BOSS_REPLY', answer: payload.answer };
353
+ }
340
354
  default:
341
355
  await ports.emitStatus(
342
356
  `Classifier returned unknown event type: ${eventType}`,
@@ -345,22 +359,76 @@ async function classifyWithLlm(
345
359
  }
346
360
  }
347
361
 
348
- function buildClassifierPrompt(text: string): string {
349
- return [
362
+ interface ClassifierState {
363
+ value: unknown;
364
+ context: Record<string, unknown>;
365
+ }
366
+
367
+ function classifierState(snapshotOrState: unknown): ClassifierState {
368
+ if (
369
+ snapshotOrState !== null &&
370
+ typeof snapshotOrState === 'object' &&
371
+ 'value' in snapshotOrState
372
+ ) {
373
+ const candidate = snapshotOrState as {
374
+ value?: unknown;
375
+ context?: unknown;
376
+ };
377
+ return {
378
+ value: candidate.value,
379
+ context:
380
+ candidate.context !== null &&
381
+ typeof candidate.context === 'object' &&
382
+ !Array.isArray(candidate.context)
383
+ ? (candidate.context as Record<string, unknown>)
384
+ : {},
385
+ };
386
+ }
387
+ return { value: snapshotOrState, context: {} };
388
+ }
389
+
390
+ function buildClassifierPrompt(text: string, state: ClassifierState): string {
391
+ const currentState =
392
+ typeof state.value === 'string' ? state.value : 'unknown';
393
+ const pendingBossQuestion = pendingBossQuestionFromContext(state.context);
394
+ const lines = [
350
395
  'Classify the following Boss message into exactly one of these events.',
351
396
  'Respond with JSON: { "event": "<TYPE>", "payload": { ...fields } }.',
397
+ 'Use { "event": "NO_ACTION", "payload": {} } when no FSM action should be taken.',
398
+ '',
399
+ `Current state: ${currentState}`,
400
+ ];
401
+ if (pendingBossQuestion !== undefined) {
402
+ lines.push(
403
+ `Pending Boss question: ${pendingBossQuestion.question}`,
404
+ `Pending resume state: ${pendingBossQuestion.resumeStateId}`,
405
+ );
406
+ }
407
+ lines.push(
352
408
  '',
353
409
  'Events:',
354
410
  '- START_CODING: payload { intent: "<free-form goal>" }',
355
411
  '- CONTINUE_IR: payload { irNumber: "<number>" }',
356
412
  '- SUMMARIZE_IR: payload { irNumber: "<number>" }',
357
413
  '- BOSS_INTERRUPT: payload { targetId: "<stateId>", intent?: "<free-form goal>", irNumber?: "<number>" }',
414
+ ' targetId must be one of these jumpable states:',
415
+ );
416
+ for (const target of bossInterruptTargets) {
417
+ lines.push(` - ${target.stateId}: ${target.description}`);
418
+ }
419
+ if (currentState === 'awaitBossReply') {
420
+ lines.push('- BOSS_REPLY: payload { answer: "<verbatim Boss answer>" }');
421
+ } else {
422
+ lines.push('- BOSS_REPLY: valid only when Current state is awaitBossReply');
423
+ }
424
+ lines.push(
358
425
  '',
359
426
  'Boss message:',
360
427
  '```',
361
428
  text,
362
429
  '```',
363
- ].join('\n');
430
+ );
431
+ return lines.join('\n');
364
432
  }
365
433
 
366
434
  // Captain-actor bridge — DR-004 §7. One PromiseActorLogic that the
@@ -397,7 +465,14 @@ function captainBridge(
397
465
  'captainBridge: callPlayer returned status=ok with no finalText',
398
466
  );
399
467
  }
400
- return adjudicate(input, result.finalText, ports, activeSignal);
468
+ const output = await adjudicate(
469
+ input,
470
+ result.finalText,
471
+ ports,
472
+ activeSignal,
473
+ );
474
+ validateBossReplyOutput(input, output);
475
+ return output;
401
476
  },
402
477
  );
403
478
  }
@@ -405,7 +480,7 @@ function captainBridge(
405
480
  // Captain pane display — PBRT-3 / PBRT-14.
406
481
  // The Captain pane is a stream keyed on four glyphs so a reader can
407
482
  // parse each line at a glance:
408
- // ◆ terminal entry (ready / done / failed)
483
+ // ◆ basic idle entry (ready / done / failed)
409
484
  // ▸ Boss input echo
410
485
  // ⮕ captain-invoking state entry (label + player + CODE-N)
411
486
  // ⤷ transition (guard fired by the just-finished captain call)
@@ -458,23 +533,109 @@ const stateMetadata: ReadonlyMap<string, StateMetadata> = (() => {
458
533
  return m;
459
534
  })();
460
535
 
461
- const TERMINAL_STATES: ReadonlySet<string> = new Set([
536
+ const stateIdBySourceItem: ReadonlyMap<string, string> = new Map(
537
+ [...stateMetadata.entries()].map(([stateId, meta]) => [
538
+ meta.sourceItem,
539
+ stateId,
540
+ ]),
541
+ );
542
+
543
+ const registeredResumableStateIds: ReadonlySet<string> = new Set(
544
+ enumerateAwaitBossReply(codingMachine).bossReplyTransitions.map(
545
+ (transition) => transition.target,
546
+ ),
547
+ );
548
+
549
+ function validateBossReplyOutput(
550
+ input: CaptainInput,
551
+ output: CaptainOutput,
552
+ ): void {
553
+ if (output.guard !== 'needsBossReply') return;
554
+ if (typeof output.question !== 'string') {
555
+ throw new Error(BOSS_REPLY_ERRORS.missingQuestion);
556
+ }
557
+ const stateId = stateIdBySourceItem.get(input.sourceItem);
558
+ if (stateId === undefined || !registeredResumableStateIds.has(stateId)) {
559
+ throw new Error(
560
+ BOSS_REPLY_ERRORS.unregisteredState(stateId ?? input.sourceItem),
561
+ );
562
+ }
563
+ }
564
+
565
+ const QUIESCENT_STATES: ReadonlySet<string> = new Set([
462
566
  'ready',
567
+ 'awaitBossReply',
463
568
  'done',
464
569
  'failed',
465
570
  ]);
466
571
 
572
+ // awaitBossReply is quiescent too, but uses a custom single-line
573
+ // status frame rather than the plain `◆ <state>` entry.
574
+ const BASIC_IDLE_GLYPH_STATES: ReadonlySet<string> = new Set(
575
+ [...QUIESCENT_STATES].filter((stateId) => stateId !== 'awaitBossReply'),
576
+ );
577
+
467
578
  // Captain-pane surface (PBRT-3): every captain-invoking state plus
468
- // the three terminal/idle states. Wider than the prior
579
+ // the quiescent states. Wider than the prior
469
580
  // "Boss-relevant" set per slc/link.md's default "emit on every
470
581
  // transition; let the host filter."
471
582
  const CAPTAIN_PANE_STATES: ReadonlySet<string> = new Set([
472
583
  ...stateMetadata.keys(),
473
- ...TERMINAL_STATES,
584
+ ...QUIESCENT_STATES,
474
585
  ]);
475
586
 
476
- function formatStateEntry(stateId: string): string {
477
- if (TERMINAL_STATES.has(stateId)) return `◆ ${stateId}`;
587
+ interface PendingBossQuestionForStatus {
588
+ resumeStateId: string;
589
+ sourceItem: string;
590
+ player: string;
591
+ question: string;
592
+ }
593
+
594
+ function pendingBossQuestionFromContext(
595
+ context: Record<string, unknown>,
596
+ ): PendingBossQuestionForStatus | undefined {
597
+ const pending = context.pendingBossQuestion;
598
+ if (pending === undefined || typeof pending !== 'object') {
599
+ return undefined;
600
+ }
601
+ const candidate = pending as Partial<
602
+ Record<keyof PendingBossQuestionForStatus, unknown>
603
+ >;
604
+ if (
605
+ typeof candidate.resumeStateId !== 'string' ||
606
+ typeof candidate.sourceItem !== 'string' ||
607
+ typeof candidate.player !== 'string' ||
608
+ typeof candidate.question !== 'string'
609
+ ) {
610
+ return undefined;
611
+ }
612
+ return {
613
+ resumeStateId: candidate.resumeStateId,
614
+ sourceItem: candidate.sourceItem,
615
+ player: candidate.player,
616
+ question: candidate.question,
617
+ };
618
+ }
619
+
620
+ function questionExcerpt(question: string): string {
621
+ return question.replace(/[\r\n]+/g, ' ').slice(0, 80);
622
+ }
623
+
624
+ function formatAwaitBossReplyEntry(context: Record<string, unknown>): string {
625
+ const pending = pendingBossQuestionFromContext(context);
626
+ const resumeStateId = pending?.resumeStateId ?? 'unknown';
627
+ const player = pending?.player ?? 'unknown';
628
+ const sourceItem = pending?.sourceItem ?? 'unknown';
629
+ const question = questionExcerpt(pending?.question ?? '');
630
+ return `◆ awaiting Boss reply · ${resumeStateId} · ${player} · ${sourceItem} · q=${JSON.stringify(question)}`;
631
+ }
632
+
633
+ function formatStateEntry(
634
+ stateId: string,
635
+ context: Record<string, unknown> = {},
636
+ ): string {
637
+ if (stateId === 'awaitBossReply') return formatAwaitBossReplyEntry(context);
638
+ if (BASIC_IDLE_GLYPH_STATES.has(stateId)) return `◆ ${stateId}`;
478
639
  const meta = stateMetadata.get(stateId);
479
640
  if (!meta) return `⮕ ${stateId}`;
480
641
  return `⮕ ${meta.label} ${meta.player} per ${meta.sourceItem}`;
@@ -519,6 +680,22 @@ function formatRiders(context: Record<string, unknown>): string {
519
680
  return parts.length > 0 ? ` ${parts.join(' ')}` : '';
520
681
  }
521
682
 
683
+ function stateTelemetryPayload(
684
+ from: unknown,
685
+ to: string,
686
+ event: unknown,
687
+ context: Record<string, unknown>,
688
+ ): Record<string, unknown> {
689
+ const payload: Record<string, unknown> = { from, to, event };
690
+ if (to === 'awaitBossReply') {
691
+ const pendingBossQuestion = pendingBossQuestionFromContext(context);
692
+ if (pendingBossQuestion !== undefined) {
693
+ payload.pendingBossQuestion = pendingBossQuestion;
694
+ }
695
+ }
696
+ return payload;
697
+ }
698
+
522
699
  // Internal export surface for tests. Not part of the stable public API;
523
700
  // the leading underscore signals "subject to change." Each member is
524
701
  // referenced here so `noUnusedLocals` stays clean while later tasks
@@ -531,10 +708,13 @@ export const _internal = {
531
708
  captainBridge,
532
709
  STATE_LABELS,
533
710
  stateMetadata,
711
+ pendingBossQuestionFromContext,
712
+ formatAwaitBossReplyEntry,
534
713
  formatStateEntry,
535
714
  formatTransition,
536
715
  formatBossEcho,
537
716
  formatRiders,
717
+ stateTelemetryPayload,
538
718
  };
539
719
 
540
720
  export default function createPlaybookRuntime(
@@ -602,10 +782,16 @@ export default function createPlaybookRuntime(
602
782
  const from = priorState;
603
783
  priorState = to;
604
784
  // Telemetry on every transition (PBRT-14).
785
+ const context = snap.context ?? {};
605
786
  enqueueEmit(() =>
606
787
  ports.emitTelemetry({
607
788
  topic: 'playbook.fsm.state',
608
- payload: { from, to, event: inspectionEvent.event },
789
+ payload: stateTelemetryPayload(
790
+ from,
791
+ to,
792
+ inspectionEvent.event,
793
+ context,
794
+ ),
609
795
  }),
610
796
  );
611
797
  // Captain pane (PBRT-3 / PBRT-14): show the transition
@@ -618,9 +804,9 @@ export default function createPlaybookRuntime(
618
804
  if (transitionLine !== undefined) {
619
805
  enqueueEmit(() => ports.emitStatus(transitionLine));
620
806
  }
621
- const entryLine = formatStateEntry(to);
807
+ const entryLine = formatStateEntry(to, context);
622
808
  const riderSuffix = stateMetadata.has(to)
623
- ? formatRiders(snap.context ?? {})
809
+ ? formatRiders(context)
624
810
  : '';
625
811
  const message = entryLine + riderSuffix;
626
812
  if (to === 'failed') {
@@ -657,10 +843,15 @@ export default function createPlaybookRuntime(
657
843
  }
658
844
  activeSignal = signal;
659
845
  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.
846
+ // 1. Classify non-empty text into an FSM event through the judge.
847
+ const event = await classifyBossText(
848
+ text,
849
+ savedPorts,
850
+ signal,
851
+ actor.getSnapshot(),
852
+ );
853
+ // Empty input, no-action classifier output, or invalid classifier
854
+ // output — nothing to send.
664
855
  if (event === undefined) {
665
856
  await drainEmissions();
666
857
  return;
@@ -739,5 +930,5 @@ function driveToQuiescence(
739
930
 
740
931
  function isQuiescent(snap: { value: unknown }): boolean {
741
932
  const v = snap.value;
742
- return v === 'ready' || v === 'failed' || v === 'done';
933
+ return typeof v === 'string' && QUIESCENT_STATES.has(v);
743
934
  }
@@ -15,6 +15,10 @@ captain:
15
15
  from: ./code.tmux-play.js
16
16
  adapter: claude
17
17
  model: claude-opus-4-7
18
+ # Agents run in cligent's classifier/reviewer-protected auto mode
19
+ # (cligent DR-005): claude → permissionMode auto, codex → auto_review.
20
+ permissions:
21
+ mode: auto
18
22
  options:
19
23
  # Substituted into the <coder-llm> placeholder in player prompts
20
24
  # (DR-004 §6).
@@ -29,5 +33,9 @@ captain:
29
33
  roles:
30
34
  - id: coder
31
35
  adapter: claude
36
+ permissions:
37
+ mode: auto
32
38
  - id: reviewer
33
39
  adapter: codex
40
+ permissions:
41
+ mode: auto
@@ -10,6 +10,8 @@ captain:
10
10
  from: "@sublang/playbook/code/tmux-play"
11
11
  adapter: claude
12
12
  model: claude-opus-4-7
13
+ permissions:
14
+ mode: auto
13
15
  options:
14
16
  coderPlayer: claude
15
17
  reviewerPlayer: codex
@@ -17,5 +19,9 @@ captain:
17
19
  roles:
18
20
  - id: coder
19
21
  adapter: claude
22
+ permissions:
23
+ mode: auto
20
24
  - id: reviewer
21
25
  adapter: codex
26
+ permissions:
27
+ mode: auto