@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,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:
|
|
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
|
|
21
|
-
//
|
|
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
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
261
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
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
|
|
308
|
-
// parse each line at a
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
// ⤷
|
|
313
|
-
//
|
|
314
|
-
//
|
|
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
|
|
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
|
|
355
|
-
//
|
|
356
|
-
//
|
|
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
|
-
...
|
|
426
|
+
...QUIESCENT_STATES,
|
|
360
427
|
]);
|
|
361
|
-
function
|
|
362
|
-
|
|
363
|
-
|
|
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
|
|
367
|
-
return
|
|
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 ? `
|
|
382
|
-
|
|
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
|
|
385
|
-
return eventType
|
|
386
|
-
? `▸ BOSS ${text} → ${eventType}`
|
|
387
|
-
: `▸ BOSS ${text}`;
|
|
489
|
+
function formatClassification(eventType) {
|
|
490
|
+
return eventType;
|
|
388
491
|
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
|
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
|
-
|
|
420
|
-
|
|
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:
|
|
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
|
-
|
|
495
|
-
|
|
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(
|
|
599
|
+
enqueueEmit(() => ports.emitStatus(entryLine, { lastError }));
|
|
502
600
|
}
|
|
503
601
|
else {
|
|
504
|
-
enqueueEmit(() => ports.emitStatus(
|
|
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
|
|
523
|
-
const event = await classifyBossText(text, savedPorts, signal);
|
|
524
|
-
//
|
|
525
|
-
//
|
|
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
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
//
|
|
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(
|
|
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
|
|
696
|
+
return typeof v === 'string' && QUIESCENT_STATES.has(v);
|
|
598
697
|
}
|