@sublang/playbook 0.1.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/LICENSE +201 -0
- package/README.md +181 -0
- package/bin/playbook-code.js +40 -0
- package/code.fsm.d.ts +86 -0
- package/code.fsm.introspect.d.ts +30 -0
- package/code.fsm.introspect.js +50 -0
- package/code.fsm.introspect.ts +129 -0
- package/code.fsm.js +1022 -0
- package/code.fsm.ts +1224 -0
- package/code.gears.md +250 -0
- package/code.playbook.d.ts +53 -0
- package/code.playbook.js +598 -0
- package/code.playbook.ts +743 -0
- package/code.tmux-play.d.ts +2 -0
- package/code.tmux-play.js +77 -0
- package/code.tmux-play.ts +110 -0
- package/package.json +77 -0
- package/tmux-play.config.yaml +33 -0
- package/tmux-play.production.config.yaml +21 -0
package/code.playbook.js
ADDED
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
|
|
3
|
+
//
|
|
4
|
+
// Generated by slc/link.md (FSM-to-Runtime linker).
|
|
5
|
+
// Source FSM: ./code.fsm.ts
|
|
6
|
+
// Player bind: Coder→coder, Reviewer→reviewer,
|
|
7
|
+
// Committer→{coder per CODE-18/19} (CODE-19 wires both
|
|
8
|
+
// coderPlayer and reviewerPlayer; Coder wins as the
|
|
9
|
+
// alias's first alternative)
|
|
10
|
+
// Boss event: slash-prefix (LLM-classifier fallback)
|
|
11
|
+
// Adjudication: LLM-judge per state
|
|
12
|
+
import { createActor, fromPromise } from 'xstate';
|
|
13
|
+
import { codingMachine, } from './code.fsm.js';
|
|
14
|
+
import { enumerateCaptainStates } from './code.fsm.introspect.js';
|
|
15
|
+
// Internal capabilities (DR-004 §10). Each ships with its final
|
|
16
|
+
// signature; behavior lands in the per-capability task noted by the
|
|
17
|
+
// TODO marker.
|
|
18
|
+
// Player-prompt composer — DR-004 §6.
|
|
19
|
+
// Substitutes the three placeholder tokens in `input.prompt` (literal
|
|
20
|
+
// string replace, no escaping) and prepends labelled blocks for any
|
|
21
|
+
// populated structured field. The FSM's prompt body is never re-flowed.
|
|
22
|
+
function composePlayerPrompt(input) {
|
|
23
|
+
const blocks = [];
|
|
24
|
+
if (input.intent !== undefined) {
|
|
25
|
+
blocks.push(`Boss intent:\n${input.intent}`);
|
|
26
|
+
}
|
|
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
|
+
if (input.taskDescription !== undefined) {
|
|
34
|
+
blocks.push(`Task description:\n${input.taskDescription}`);
|
|
35
|
+
}
|
|
36
|
+
let body = input.prompt;
|
|
37
|
+
if (input.irNumber !== undefined) {
|
|
38
|
+
body = body.replaceAll('<#>', input.irNumber);
|
|
39
|
+
}
|
|
40
|
+
if (input.coderPlayer !== undefined) {
|
|
41
|
+
body = body.replaceAll('<coder-llm>', input.coderPlayer);
|
|
42
|
+
}
|
|
43
|
+
if (input.reviewerPlayer !== undefined) {
|
|
44
|
+
body = body.replaceAll('<reviewer-llm>', input.reviewerPlayer);
|
|
45
|
+
}
|
|
46
|
+
blocks.push(body);
|
|
47
|
+
return blocks.join('\n\n');
|
|
48
|
+
}
|
|
49
|
+
// Player-id resolver — DR-004 §2.
|
|
50
|
+
// Non-composite: Coder→'coder', Reviewer→'reviewer'. The composite
|
|
51
|
+
// Committer (= Coder | Reviewer per code.gears.md) resolves per
|
|
52
|
+
// populated <playerName>Player field on `CaptainInput`: prefer
|
|
53
|
+
// `coderPlayer` (CODE-18 wires only coderPlayer; CODE-19 wires both
|
|
54
|
+
// so coderPlayer still wins as the alias's first alternative), fall
|
|
55
|
+
// back to `reviewerPlayer` only if a future gear ships a
|
|
56
|
+
// reviewer-only Committer item, and finally to the alias's first
|
|
57
|
+
// alternative (Coder) when neither is set.
|
|
58
|
+
function resolvePlayerId(input) {
|
|
59
|
+
switch (input.player) {
|
|
60
|
+
case 'Coder':
|
|
61
|
+
return 'coder';
|
|
62
|
+
case 'Reviewer':
|
|
63
|
+
return 'reviewer';
|
|
64
|
+
case 'Committer':
|
|
65
|
+
if (input.coderPlayer !== undefined)
|
|
66
|
+
return 'coder';
|
|
67
|
+
if (input.reviewerPlayer !== undefined)
|
|
68
|
+
return 'reviewer';
|
|
69
|
+
return 'coder';
|
|
70
|
+
default: {
|
|
71
|
+
const exhaustive = input.player;
|
|
72
|
+
throw new Error(`resolvePlayerId: unknown player ${String(exhaustive)}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// LLM judge — DR-004 §4. Builds a prompt that lists each declared
|
|
77
|
+
// outcome verbatim, asks ports.callJudge for a JSON
|
|
78
|
+
// `{ guard, …payloadFields }` response, and returns the parsed
|
|
79
|
+
// object once the chosen guard is one of the input.result keys.
|
|
80
|
+
// Adjudicator failures (malformed JSON, missing/unknown guard) are
|
|
81
|
+
// control-plane errors and propagate via throw per slc/link.md.
|
|
82
|
+
async function adjudicate(input, finalText, ports, signal) {
|
|
83
|
+
const prompt = buildJudgePrompt(input, finalText);
|
|
84
|
+
const raw = await ports.callJudge(prompt, signal);
|
|
85
|
+
const parsed = parseJudgeJson(raw);
|
|
86
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
87
|
+
throw new Error('adjudicate: judge response is not a JSON object');
|
|
88
|
+
}
|
|
89
|
+
const obj = parsed;
|
|
90
|
+
const guard = obj.guard;
|
|
91
|
+
if (typeof guard !== 'string') {
|
|
92
|
+
throw new Error('adjudicate: judge response missing string "guard" field');
|
|
93
|
+
}
|
|
94
|
+
if (!Object.prototype.hasOwnProperty.call(input.result, guard)) {
|
|
95
|
+
throw new Error(`adjudicate: unknown guard "${guard}" — declared guards: ${Object.keys(input.result).join(', ')}`);
|
|
96
|
+
}
|
|
97
|
+
// Per slc/link.md, a missing payload field the state's `result`
|
|
98
|
+
// description requires is a control-plane error. The FSM names
|
|
99
|
+
// required fields with the literal phrase
|
|
100
|
+
// Output shall include `<fieldName>: <...>`
|
|
101
|
+
// so we extract those tokens and require each to be a string in
|
|
102
|
+
// the judge response.
|
|
103
|
+
for (const field of extractRequiredFields(input.result[guard])) {
|
|
104
|
+
if (typeof obj[field] !== 'string') {
|
|
105
|
+
throw new Error(`adjudicate: judge response missing required field "${field}" for guard "${guard}"`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return obj;
|
|
109
|
+
}
|
|
110
|
+
function extractRequiredFields(description) {
|
|
111
|
+
const fields = [];
|
|
112
|
+
const re = /Output shall include `([A-Za-z_][A-Za-z0-9_]*):/g;
|
|
113
|
+
for (const m of description.matchAll(re)) {
|
|
114
|
+
fields.push(m[1]);
|
|
115
|
+
}
|
|
116
|
+
return fields;
|
|
117
|
+
}
|
|
118
|
+
function buildJudgePrompt(input, finalText) {
|
|
119
|
+
const lines = [];
|
|
120
|
+
lines.push(`The ${input.player} just produced this output:`);
|
|
121
|
+
lines.push('');
|
|
122
|
+
lines.push('```');
|
|
123
|
+
lines.push(finalText);
|
|
124
|
+
lines.push('```');
|
|
125
|
+
lines.push('');
|
|
126
|
+
lines.push('Pick exactly one outcome by `guard` and return JSON ' +
|
|
127
|
+
'`{ guard, …payloadFields }`. Required payload fields are ' +
|
|
128
|
+
'named in the outcome description.');
|
|
129
|
+
lines.push('');
|
|
130
|
+
for (const [key, description] of Object.entries(input.result)) {
|
|
131
|
+
lines.push(`- \`${key}\` — ${description}`);
|
|
132
|
+
}
|
|
133
|
+
return lines.join('\n');
|
|
134
|
+
}
|
|
135
|
+
function parseJudgeJson(raw) {
|
|
136
|
+
let text = raw.trim();
|
|
137
|
+
const fence = text.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/);
|
|
138
|
+
if (fence)
|
|
139
|
+
text = fence[1].trim();
|
|
140
|
+
try {
|
|
141
|
+
return JSON.parse(text);
|
|
142
|
+
}
|
|
143
|
+
catch (e) {
|
|
144
|
+
throw new Error(`adjudicate: judge response is not valid JSON: ${e.message}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// Boss-event classifier — DR-004 §3.
|
|
148
|
+
// Slash forms tried first; unknown slash forms surface as
|
|
149
|
+
// emitStatus and return undefined per slc/link.md ("Unknown
|
|
150
|
+
// commands surface as emitStatus, not as a silently-dropped
|
|
151
|
+
// turn"). Anything that isn't a slash form falls through to the
|
|
152
|
+
// LLM classifier via ports.callJudge with a fixed prompt that
|
|
153
|
+
// names the four event types and their payload shapes.
|
|
154
|
+
async function classifyBossText(text, ports, signal) {
|
|
155
|
+
const trimmed = text.trim();
|
|
156
|
+
if (trimmed === '')
|
|
157
|
+
return undefined;
|
|
158
|
+
if (trimmed === '/start' || trimmed.startsWith('/start ')) {
|
|
159
|
+
return { type: 'START_CODING', intent: trimmed.slice('/start'.length).trim() };
|
|
160
|
+
}
|
|
161
|
+
if (trimmed === '/continue' || trimmed.startsWith('/continue ')) {
|
|
162
|
+
return {
|
|
163
|
+
type: 'CONTINUE_IR',
|
|
164
|
+
irNumber: trimmed.slice('/continue'.length).trim(),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
if (trimmed === '/summarize' || trimmed.startsWith('/summarize ')) {
|
|
168
|
+
return {
|
|
169
|
+
type: 'SUMMARIZE_IR',
|
|
170
|
+
irNumber: trimmed.slice('/summarize'.length).trim(),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
if (trimmed === '/interrupt' || trimmed.startsWith('/interrupt ')) {
|
|
174
|
+
return parseInterruptSlash(trimmed, ports);
|
|
175
|
+
}
|
|
176
|
+
if (trimmed.startsWith('/')) {
|
|
177
|
+
const cmd = trimmed.split(/\s+/)[0];
|
|
178
|
+
await ports.emitStatus(`Unknown slash command: ${cmd}`);
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
return classifyWithLlm(trimmed, ports, signal);
|
|
182
|
+
}
|
|
183
|
+
async function parseInterruptSlash(trimmed, ports) {
|
|
184
|
+
const rest = trimmed.slice('/interrupt'.length).trim();
|
|
185
|
+
if (rest === '') {
|
|
186
|
+
await ports.emitStatus('/interrupt requires a stateId');
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
const firstSpace = rest.search(/\s/);
|
|
190
|
+
const targetId = firstSpace === -1 ? rest : rest.slice(0, firstSpace);
|
|
191
|
+
const intent = firstSpace === -1 ? '' : rest.slice(firstSpace).trim();
|
|
192
|
+
return {
|
|
193
|
+
type: 'BOSS_INTERRUPT',
|
|
194
|
+
targetId: targetId,
|
|
195
|
+
...(intent ? { intent } : {}),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
async function classifyWithLlm(text, ports, signal) {
|
|
199
|
+
const prompt = buildClassifierPrompt(text);
|
|
200
|
+
const raw = await ports.callJudge(prompt, signal);
|
|
201
|
+
const parsed = parseJudgeJson(raw);
|
|
202
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
203
|
+
await ports.emitStatus('Classifier returned a non-object JSON response');
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
const obj = parsed;
|
|
207
|
+
const eventType = obj.event;
|
|
208
|
+
if (typeof eventType !== 'string') {
|
|
209
|
+
await ports.emitStatus('Classifier did not name an event type');
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
const payload = typeof obj.payload === 'object' &&
|
|
213
|
+
obj.payload !== null &&
|
|
214
|
+
!Array.isArray(obj.payload)
|
|
215
|
+
? obj.payload
|
|
216
|
+
: {};
|
|
217
|
+
switch (eventType) {
|
|
218
|
+
case 'START_CODING': {
|
|
219
|
+
if (typeof payload.intent !== 'string') {
|
|
220
|
+
await ports.emitStatus('Classifier omitted intent for START_CODING');
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
return { type: 'START_CODING', intent: payload.intent };
|
|
224
|
+
}
|
|
225
|
+
case 'CONTINUE_IR': {
|
|
226
|
+
if (typeof payload.irNumber !== 'string') {
|
|
227
|
+
await ports.emitStatus('Classifier omitted irNumber for CONTINUE_IR');
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
return { type: 'CONTINUE_IR', irNumber: payload.irNumber };
|
|
231
|
+
}
|
|
232
|
+
case 'SUMMARIZE_IR': {
|
|
233
|
+
if (typeof payload.irNumber !== 'string') {
|
|
234
|
+
await ports.emitStatus('Classifier omitted irNumber for SUMMARIZE_IR');
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
return { type: 'SUMMARIZE_IR', irNumber: payload.irNumber };
|
|
238
|
+
}
|
|
239
|
+
case 'BOSS_INTERRUPT': {
|
|
240
|
+
if (typeof payload.targetId !== 'string') {
|
|
241
|
+
await ports.emitStatus('Classifier omitted targetId for BOSS_INTERRUPT');
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
type: 'BOSS_INTERRUPT',
|
|
246
|
+
targetId: payload.targetId,
|
|
247
|
+
...(typeof payload.intent === 'string'
|
|
248
|
+
? { intent: payload.intent }
|
|
249
|
+
: {}),
|
|
250
|
+
...(typeof payload.irNumber === 'string'
|
|
251
|
+
? { irNumber: payload.irNumber }
|
|
252
|
+
: {}),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
default:
|
|
256
|
+
await ports.emitStatus(`Classifier returned unknown event type: ${eventType}`);
|
|
257
|
+
return undefined;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
function buildClassifierPrompt(text) {
|
|
261
|
+
return [
|
|
262
|
+
'Classify the following Boss message into exactly one of these events.',
|
|
263
|
+
'Respond with JSON: { "event": "<TYPE>", "payload": { ...fields } }.',
|
|
264
|
+
'',
|
|
265
|
+
'Events:',
|
|
266
|
+
'- START_CODING: payload { intent: "<free-form goal>" }',
|
|
267
|
+
'- CONTINUE_IR: payload { irNumber: "<number>" }',
|
|
268
|
+
'- SUMMARIZE_IR: payload { irNumber: "<number>" }',
|
|
269
|
+
'- BOSS_INTERRUPT: payload { targetId: "<stateId>", intent?: "<free-form goal>", irNumber?: "<number>" }',
|
|
270
|
+
'',
|
|
271
|
+
'Boss message:',
|
|
272
|
+
'```',
|
|
273
|
+
text,
|
|
274
|
+
'```',
|
|
275
|
+
].join('\n');
|
|
276
|
+
}
|
|
277
|
+
// Captain-actor bridge — DR-004 §7. One PromiseActorLogic that the
|
|
278
|
+
// codingMachine invokes from every captain-invoking state. Per turn:
|
|
279
|
+
// resolve playerId, compose the player prompt, await
|
|
280
|
+
// ports.callPlayer, adjudicate the finalText. PlayerResult status of
|
|
281
|
+
// 'aborted' or 'error' throws so XState routes via onError → #failed
|
|
282
|
+
// (the single fail-stop sink for both Captain errors and player
|
|
283
|
+
// failures).
|
|
284
|
+
//
|
|
285
|
+
// `getActiveSignal` is the runtime's hook for flowing the Boss's
|
|
286
|
+
// `handleBossInput.signal` into the host port calls — fromPromise
|
|
287
|
+
// hands the bridge XState's actor-scoped signal, which only fires
|
|
288
|
+
// on actor.stop(), not on Boss abort. When omitted (e.g. direct
|
|
289
|
+
// captainBridge tests), the bridge falls back to XState's signal.
|
|
290
|
+
function captainBridge(ports, getActiveSignal) {
|
|
291
|
+
return fromPromise(async ({ input, signal }) => {
|
|
292
|
+
const activeSignal = getActiveSignal?.() ?? signal;
|
|
293
|
+
const playerId = resolvePlayerId(input);
|
|
294
|
+
const prompt = composePlayerPrompt(input);
|
|
295
|
+
const result = await ports.callPlayer(playerId, prompt, activeSignal);
|
|
296
|
+
if (result.status !== 'ok') {
|
|
297
|
+
throw new Error(result.error ??
|
|
298
|
+
`captainBridge: callPlayer status "${result.status}"`);
|
|
299
|
+
}
|
|
300
|
+
if (result.finalText === undefined) {
|
|
301
|
+
throw new Error('captainBridge: callPlayer returned status=ok with no finalText');
|
|
302
|
+
}
|
|
303
|
+
return adjudicate(input, result.finalText, ports, activeSignal);
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
// Captain pane display — PBRT-3 / PBRT-14.
|
|
307
|
+
// The Captain pane is a stream keyed on four glyphs so a reader can
|
|
308
|
+
// parse each line at a glance:
|
|
309
|
+
// ◆ terminal entry (ready / done / failed)
|
|
310
|
+
// ▸ Boss input echo
|
|
311
|
+
// ⮕ captain-invoking state entry (label + player + CODE-N)
|
|
312
|
+
// ⤷ transition (guard fired by the just-finished captain call)
|
|
313
|
+
// Prompts and full player output ride the player panes; the Captain
|
|
314
|
+
// pane keeps to the state-machine shape.
|
|
315
|
+
const STATE_LABELS = {
|
|
316
|
+
planAndImplement: 'plan & implement',
|
|
317
|
+
respondToReview: 'respond to review',
|
|
318
|
+
continueIr: 'continue IR task',
|
|
319
|
+
summarizeSpecs: 'summarize IR into specs',
|
|
320
|
+
reviewBossCommitSpecs: 'review Boss-intent commit (specs only)',
|
|
321
|
+
reviewBossCommitCode: 'review Boss-intent commit (code only)',
|
|
322
|
+
reviewBossCommitMixed: 'review Boss-intent commit (specs + code)',
|
|
323
|
+
reviewIrTaskCommitSpecs: 'review IR-task commit (specs only)',
|
|
324
|
+
reviewIrTaskCommitCode: 'review IR-task commit (code only)',
|
|
325
|
+
reviewIrTaskCommitMixed: 'review IR-task commit (specs + code)',
|
|
326
|
+
reviewChangesSpecs: 'review uncommitted edits (specs only)',
|
|
327
|
+
reviewChangesCode: 'review uncommitted edits (code only)',
|
|
328
|
+
reviewChangesMixed: 'review uncommitted edits (specs + code)',
|
|
329
|
+
reviewChangesAndChallengesSpecs: 'review uncommitted edits + rebuttals (specs only)',
|
|
330
|
+
reviewChangesAndChallengesCode: 'review uncommitted edits + rebuttals (code only)',
|
|
331
|
+
reviewChangesAndChallengesMixed: 'review uncommitted edits + rebuttals (specs + code)',
|
|
332
|
+
adjudicateChallenges: 'adjudicate rebuttals',
|
|
333
|
+
commitCoderInitial: "commit Coder's initial changes",
|
|
334
|
+
commitJoint: 'commit reviewed changes',
|
|
335
|
+
};
|
|
336
|
+
const stateMetadata = (() => {
|
|
337
|
+
const m = new Map();
|
|
338
|
+
for (const s of enumerateCaptainStates(codingMachine)) {
|
|
339
|
+
const label = STATE_LABELS[s.stateId];
|
|
340
|
+
if (!label) {
|
|
341
|
+
throw new Error(`code.playbook.ts: STATE_LABELS missing entry for captain-invoking state '${s.stateId}'`);
|
|
342
|
+
}
|
|
343
|
+
const input = s.getInput({});
|
|
344
|
+
m.set(s.stateId, { player: input.player, sourceItem: s.sourceItem, label });
|
|
345
|
+
}
|
|
346
|
+
return m;
|
|
347
|
+
})();
|
|
348
|
+
const TERMINAL_STATES = new Set([
|
|
349
|
+
'ready',
|
|
350
|
+
'done',
|
|
351
|
+
'failed',
|
|
352
|
+
]);
|
|
353
|
+
// Captain-pane surface (PBRT-3): every captain-invoking state plus
|
|
354
|
+
// the three terminal/idle states. Wider than the prior
|
|
355
|
+
// "Boss-relevant" set per slc/link.md's default "emit on every
|
|
356
|
+
// transition; let the host filter."
|
|
357
|
+
const CAPTAIN_PANE_STATES = new Set([
|
|
358
|
+
...stateMetadata.keys(),
|
|
359
|
+
...TERMINAL_STATES,
|
|
360
|
+
]);
|
|
361
|
+
function formatStateEntry(stateId) {
|
|
362
|
+
if (TERMINAL_STATES.has(stateId))
|
|
363
|
+
return `◆ ${stateId}`;
|
|
364
|
+
const meta = stateMetadata.get(stateId);
|
|
365
|
+
if (!meta)
|
|
366
|
+
return `⮕ ${stateId}`;
|
|
367
|
+
return `⮕ ${meta.label} ${meta.player} per ${meta.sourceItem}`;
|
|
368
|
+
}
|
|
369
|
+
function formatTransition(event) {
|
|
370
|
+
const output = event?.output;
|
|
371
|
+
if (!output || typeof output.guard !== 'string')
|
|
372
|
+
return undefined;
|
|
373
|
+
const tallies = [];
|
|
374
|
+
for (const field of ['reviews', 'challenges']) {
|
|
375
|
+
const value = output[field];
|
|
376
|
+
if (typeof value === 'string' && value.trim().length > 0) {
|
|
377
|
+
const items = value.match(/^\s*\d+\.\s/gm);
|
|
378
|
+
tallies.push(`${field}=${items ? items.length : 1}`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
const suffix = tallies.length > 0 ? ` ${tallies.join(' ')}` : '';
|
|
382
|
+
return `⤷ ${output.guard}${suffix}`;
|
|
383
|
+
}
|
|
384
|
+
function formatBossEcho(text, eventType) {
|
|
385
|
+
return eventType !== undefined
|
|
386
|
+
? `▸ BOSS ${text} → ${eventType}`
|
|
387
|
+
: `▸ BOSS ${text}`;
|
|
388
|
+
}
|
|
389
|
+
// Rider fields PBRT-14 names — surfaced inline on a state entry
|
|
390
|
+
// whenever the FSM context populates them, regardless of which
|
|
391
|
+
// captain-invoking state is being entered. Routing-only context
|
|
392
|
+
// (reviewSubject, afterReview, etc.) stays out of the pane and is
|
|
393
|
+
// visible via emitTelemetry instead.
|
|
394
|
+
const RIDER_FIELDS = ['intent', 'irNumber', 'taskDescription'];
|
|
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)}`);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return parts.length > 0 ? ` ${parts.join(' ')}` : '';
|
|
404
|
+
}
|
|
405
|
+
// Internal export surface for tests. Not part of the stable public API;
|
|
406
|
+
// the leading underscore signals "subject to change." Each member is
|
|
407
|
+
// referenced here so `noUnusedLocals` stays clean while later tasks
|
|
408
|
+
// wire the factory body to use them.
|
|
409
|
+
export const _internal = {
|
|
410
|
+
composePlayerPrompt,
|
|
411
|
+
resolvePlayerId,
|
|
412
|
+
adjudicate,
|
|
413
|
+
classifyBossText,
|
|
414
|
+
captainBridge,
|
|
415
|
+
STATE_LABELS,
|
|
416
|
+
stateMetadata,
|
|
417
|
+
formatStateEntry,
|
|
418
|
+
formatTransition,
|
|
419
|
+
formatBossEcho,
|
|
420
|
+
formatRiders,
|
|
421
|
+
};
|
|
422
|
+
export default function createPlaybookRuntime(options) {
|
|
423
|
+
let actor;
|
|
424
|
+
let savedPorts;
|
|
425
|
+
// The Boss's per-turn AbortSignal, surfaced to captainBridge so
|
|
426
|
+
// ports.callPlayer / callJudge see the right cancellation source.
|
|
427
|
+
// null between turns; set by handleBossInput.
|
|
428
|
+
let activeSignal;
|
|
429
|
+
// Previous root-machine state for the inspect-driven telemetry /
|
|
430
|
+
// status emitter. undefined before the first inspect firing.
|
|
431
|
+
let priorState;
|
|
432
|
+
// Emission queue. slc/link.md says emissions "shall be ordered,
|
|
433
|
+
// awaited, and never-dropped"; subscribe/inspect callbacks are
|
|
434
|
+
// synchronous and can't await, so each emit is enqueued and a
|
|
435
|
+
// single drainer processes them sequentially.
|
|
436
|
+
const emitQueue = [];
|
|
437
|
+
let drainer;
|
|
438
|
+
function enqueueEmit(fn) {
|
|
439
|
+
emitQueue.push(fn);
|
|
440
|
+
if (!drainer) {
|
|
441
|
+
drainer = (async () => {
|
|
442
|
+
while (emitQueue.length > 0) {
|
|
443
|
+
try {
|
|
444
|
+
await emitQueue.shift()();
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
// Suppress host-side emission errors; the control plane
|
|
448
|
+
// surfaces real failures via handleBossInput throws.
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
drainer = undefined;
|
|
452
|
+
})();
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
function drainEmissions() {
|
|
456
|
+
return drainer ?? Promise.resolve();
|
|
457
|
+
}
|
|
458
|
+
function buildActor(ports) {
|
|
459
|
+
priorState = undefined;
|
|
460
|
+
return createActor(codingMachine.provide({
|
|
461
|
+
actors: { captain: captainBridge(ports, () => activeSignal) },
|
|
462
|
+
}), {
|
|
463
|
+
input: options,
|
|
464
|
+
inspect: (inspectionEvent) => {
|
|
465
|
+
if (inspectionEvent.type !== '@xstate.snapshot')
|
|
466
|
+
return;
|
|
467
|
+
const snap = inspectionEvent.snapshot;
|
|
468
|
+
// Filter out captain sub-actor (fromPromise) snapshots —
|
|
469
|
+
// only the root codingMachine snapshot has a string value.
|
|
470
|
+
if (typeof snap.value !== 'string')
|
|
471
|
+
return;
|
|
472
|
+
const to = snap.value;
|
|
473
|
+
if (priorState === to)
|
|
474
|
+
return;
|
|
475
|
+
const from = priorState;
|
|
476
|
+
priorState = to;
|
|
477
|
+
// Telemetry on every transition (PBRT-14).
|
|
478
|
+
enqueueEmit(() => ports.emitTelemetry({
|
|
479
|
+
topic: 'playbook.fsm.state',
|
|
480
|
+
payload: { from, to, event: inspectionEvent.event },
|
|
481
|
+
}));
|
|
482
|
+
// Captain pane (PBRT-3 / PBRT-14): show the transition
|
|
483
|
+
// guard first (when this is an actor-done transition with
|
|
484
|
+
// a known guard), then the new state entry, then any
|
|
485
|
+
// context riders the entering state cares about. Terminal
|
|
486
|
+
// entry to `failed` carries `lastError` as the data arg.
|
|
487
|
+
if (!CAPTAIN_PANE_STATES.has(to))
|
|
488
|
+
return;
|
|
489
|
+
const transitionLine = formatTransition(inspectionEvent.event);
|
|
490
|
+
if (transitionLine !== undefined) {
|
|
491
|
+
enqueueEmit(() => ports.emitStatus(transitionLine));
|
|
492
|
+
}
|
|
493
|
+
const entryLine = formatStateEntry(to);
|
|
494
|
+
const riderSuffix = stateMetadata.has(to)
|
|
495
|
+
? formatRiders(snap.context ?? {})
|
|
496
|
+
: '';
|
|
497
|
+
const message = entryLine + riderSuffix;
|
|
498
|
+
if (to === 'failed') {
|
|
499
|
+
const lastError = snap.context
|
|
500
|
+
?.lastError;
|
|
501
|
+
enqueueEmit(() => ports.emitStatus(message, { lastError }));
|
|
502
|
+
}
|
|
503
|
+
else {
|
|
504
|
+
enqueueEmit(() => ports.emitStatus(message));
|
|
505
|
+
}
|
|
506
|
+
},
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
const runtime = {
|
|
510
|
+
async init(ports) {
|
|
511
|
+
savedPorts = ports;
|
|
512
|
+
actor = buildActor(ports);
|
|
513
|
+
actor.start();
|
|
514
|
+
await drainEmissions();
|
|
515
|
+
},
|
|
516
|
+
async handleBossInput({ text, signal, }) {
|
|
517
|
+
if (!actor || !savedPorts) {
|
|
518
|
+
throw new Error('createPlaybookRuntime.handleBossInput: init must be called first');
|
|
519
|
+
}
|
|
520
|
+
activeSignal = signal;
|
|
521
|
+
try {
|
|
522
|
+
// 1. Classify text into an FSM event (slash → event; else LLM).
|
|
523
|
+
const event = await classifyBossText(text, savedPorts, signal);
|
|
524
|
+
// Unknown slash or empty input — classifier already surfaced
|
|
525
|
+
// status; nothing to send.
|
|
526
|
+
if (event === undefined) {
|
|
527
|
+
await drainEmissions();
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
// 2. Captain-pane Boss-input echo (PBRT-14): the verbatim
|
|
531
|
+
// Boss text and the FSM event it classified to, before
|
|
532
|
+
// the FSM advances. Enqueued so it interleaves cleanly
|
|
533
|
+
// with the inspect-driven transition emissions.
|
|
534
|
+
const echoPorts = savedPorts;
|
|
535
|
+
enqueueEmit(() => echoPorts.emitStatus(formatBossEcho(text, event.type)));
|
|
536
|
+
// 3. final state ('done') cannot accept new events — dispose
|
|
537
|
+
// and reconstruct per DR-004 §5.
|
|
538
|
+
if (actor.getSnapshot().status === 'done') {
|
|
539
|
+
actor.stop();
|
|
540
|
+
actor = buildActor(savedPorts);
|
|
541
|
+
actor.start();
|
|
542
|
+
}
|
|
543
|
+
// 4. Send the event.
|
|
544
|
+
actor.send(event);
|
|
545
|
+
// 5. Drive to quiescence. On signal-abort we take no FSM
|
|
546
|
+
// action: the captain bridge's awaited callPlayer rejects
|
|
547
|
+
// naturally, the bridge throws, XState routes through
|
|
548
|
+
// onError → #failed, and this loop sees the quiescent
|
|
549
|
+
// snapshot and returns (DR-004 §8 natural rejection).
|
|
550
|
+
await driveToQuiescence(actor);
|
|
551
|
+
// Drain transition emissions before returning so the Boss
|
|
552
|
+
// sees the final status line for this turn.
|
|
553
|
+
await drainEmissions();
|
|
554
|
+
}
|
|
555
|
+
finally {
|
|
556
|
+
activeSignal = undefined;
|
|
557
|
+
}
|
|
558
|
+
},
|
|
559
|
+
async dispose() {
|
|
560
|
+
if (actor) {
|
|
561
|
+
actor.stop();
|
|
562
|
+
actor = undefined;
|
|
563
|
+
}
|
|
564
|
+
// Drain any in-flight emissions per slc/link.md §Session
|
|
565
|
+
// lifecycle ("stop the actor and drain pending port emissions").
|
|
566
|
+
await drainEmissions();
|
|
567
|
+
savedPorts = undefined;
|
|
568
|
+
},
|
|
569
|
+
// @internal — test-only escape hatch for inspecting the
|
|
570
|
+
// underlying actor's snapshot. Most state assertions are now
|
|
571
|
+
// expressible via the recorded emitStatus / emitTelemetry
|
|
572
|
+
// calls (DR-004 §9); the hatch stays for the few cases where
|
|
573
|
+
// direct context inspection is clearer (e.g., the dispose
|
|
574
|
+
// teardown test).
|
|
575
|
+
_getActor() {
|
|
576
|
+
return actor;
|
|
577
|
+
},
|
|
578
|
+
};
|
|
579
|
+
return runtime;
|
|
580
|
+
}
|
|
581
|
+
function driveToQuiescence(actor) {
|
|
582
|
+
return new Promise((resolve) => {
|
|
583
|
+
if (isQuiescent(actor.getSnapshot())) {
|
|
584
|
+
resolve();
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
const sub = actor.subscribe((snap) => {
|
|
588
|
+
if (isQuiescent(snap)) {
|
|
589
|
+
sub.unsubscribe();
|
|
590
|
+
resolve();
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
function isQuiescent(snap) {
|
|
596
|
+
const v = snap.value;
|
|
597
|
+
return v === 'ready' || v === 'failed' || v === 'done';
|
|
598
|
+
}
|