@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.
- package/README.md +23 -17
- package/package.json +22 -22
- 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} +252 -103
- package/{code.fsm.ts → reference/sdlc/code.playbook/code.fsm.ts} +315 -107
- package/{code.gears.md → reference/sdlc/code.playbook/code.gears.md} +4 -3
- package/{code.playbook.d.ts → reference/sdlc/code.playbook/code.playbook.d.ts} +14 -2
- package/{code.playbook.js → reference/sdlc/code.playbook/code.playbook.js} +172 -79
- package/{code.playbook.ts → reference/sdlc/code.playbook/code.playbook.ts} +263 -72
- package/{tmux-play.config.yaml → reference/sdlc/code.playbook/tmux-play.config.yaml} +8 -0
- package/{tmux-play.production.config.yaml → reference/sdlc/code.playbook/tmux-play.production.config.yaml} +6 -0
- /package/{bin → reference/sdlc/code.playbook/bin}/playbook-code.js +0 -0
- /package/{code.tmux-play.d.ts → reference/sdlc/code.playbook/code.tmux-play.d.ts} +0 -0
- /package/{code.tmux-play.js → reference/sdlc/code.playbook/code.tmux-play.js} +0 -0
- /package/{code.tmux-play.ts → reference/sdlc/code.playbook/code.tmux-play.ts} +0 -0
|
@@ -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
|
-
|
|
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:
|
|
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.
|
|
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
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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
|
|
261
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
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
|
-
// ◆
|
|
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
|
|
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
|
|
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
|
-
...
|
|
406
|
+
...QUIESCENT_STATES,
|
|
360
407
|
]);
|
|
361
|
-
function
|
|
362
|
-
|
|
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:
|
|
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(
|
|
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
|
|
523
|
-
const event = await classifyBossText(text, savedPorts, signal);
|
|
524
|
-
//
|
|
525
|
-
//
|
|
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
|
|
690
|
+
return typeof v === 'string' && QUIESCENT_STATES.has(v);
|
|
598
691
|
}
|