@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.
- package/README.md +98 -30
- package/package.json +24 -23
- package/reference/sdlc/code.playbook/bin/playbook-code.js +278 -0
- package/{code.fsm.d.ts → reference/sdlc/code.playbook/code.fsm.d.ts} +18 -0
- package/{code.fsm.introspect.d.ts → reference/sdlc/code.playbook/code.fsm.introspect.d.ts} +24 -0
- package/{code.fsm.introspect.js → reference/sdlc/code.playbook/code.fsm.introspect.js} +28 -1
- package/{code.fsm.introspect.ts → reference/sdlc/code.playbook/code.fsm.introspect.ts} +73 -6
- package/{code.fsm.js → reference/sdlc/code.playbook/code.fsm.js} +264 -115
- package/{code.fsm.ts → reference/sdlc/code.playbook/code.fsm.ts} +327 -119
- package/{code.gears.md → reference/sdlc/code.playbook/code.gears.md} +17 -16
- package/{code.playbook.d.ts → reference/sdlc/code.playbook/code.playbook.d.ts} +16 -6
- package/{code.playbook.js → reference/sdlc/code.playbook/code.playbook.js} +228 -129
- package/{code.playbook.ts → reference/sdlc/code.playbook/code.playbook.ts} +316 -122
- package/{code.tmux-play.js → reference/sdlc/code.playbook/code.tmux-play.js} +22 -10
- package/{code.tmux-play.ts → reference/sdlc/code.playbook/code.tmux-play.ts} +26 -13
- package/reference/sdlc/code.playbook/playbook-code.config.template.yaml +38 -0
- package/{tmux-play.config.yaml → reference/sdlc/code.playbook/tmux-play.config.yaml} +14 -10
- package/{tmux-play.production.config.yaml → reference/sdlc/code.playbook/tmux-play.production.config.yaml} +7 -4
- package/bin/playbook-code.js +0 -43
- /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:
|
|
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 {
|
|
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
|
|
59
|
-
//
|
|
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
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
)
|
|
260
|
-
|
|
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
|
|
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
|
-
|
|
349
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
407
|
-
// parse each line at a
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
// ⤷
|
|
412
|
-
//
|
|
413
|
-
//
|
|
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
|
|
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
|
|
469
|
-
//
|
|
470
|
-
//
|
|
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
|
-
...
|
|
605
|
+
...QUIESCENT_STATES,
|
|
474
606
|
]);
|
|
475
607
|
|
|
476
|
-
|
|
477
|
-
|
|
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
|
|
480
|
-
return
|
|
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 ? `
|
|
495
|
-
|
|
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
|
|
499
|
-
return eventType
|
|
500
|
-
? `▸ BOSS ${text} → ${eventType}`
|
|
501
|
-
: `▸ BOSS ${text}`;
|
|
685
|
+
function formatClassification(eventType: string): string {
|
|
686
|
+
return eventType;
|
|
502
687
|
}
|
|
503
688
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
|
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
|
-
|
|
537
|
-
|
|
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:
|
|
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
|
-
|
|
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(
|
|
817
|
+
enqueueEmit(() => ports.emitStatus(entryLine, { lastError }));
|
|
630
818
|
} else {
|
|
631
|
-
enqueueEmit(() => ports.emitStatus(
|
|
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
|
|
661
|
-
const event = await classifyBossText(
|
|
662
|
-
|
|
663
|
-
|
|
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
|
|
669
|
-
//
|
|
670
|
-
//
|
|
671
|
-
//
|
|
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(
|
|
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
|
|
936
|
+
return typeof v === 'string' && QUIESCENT_STATES.has(v);
|
|
743
937
|
}
|