@sublang/playbook 0.9.0 → 1.3.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 +190 -151
- package/package.json +50 -6
- package/reference/sdlc/captain.md +102 -0
- package/reference/sdlc/captain.playbook/captain.fsm.d.ts +227 -0
- package/reference/sdlc/captain.playbook/captain.fsm.js +628 -0
- package/reference/sdlc/captain.playbook/captain.fsm.ts +851 -0
- package/reference/sdlc/captain.playbook/captain.gears.md +60 -0
- package/reference/sdlc/captain.playbook/captain.playbook.d.ts +23 -0
- package/reference/sdlc/captain.playbook/captain.playbook.js +1053 -0
- package/reference/sdlc/captain.playbook/captain.playbook.ts +1144 -0
- package/reference/sdlc/code.playbook/bin/playbook.js +158 -12
- package/reference/sdlc/code.playbook/bin/run.js +999 -0
- package/reference/sdlc/code.playbook/code.fsm.d.ts +11 -4
- package/reference/sdlc/code.playbook/code.fsm.introspect.d.ts +2 -2
- package/reference/sdlc/code.playbook/code.fsm.introspect.js +1 -1
- package/reference/sdlc/code.playbook/code.fsm.introspect.ts +6 -6
- package/reference/sdlc/code.playbook/code.fsm.js +334 -102
- package/reference/sdlc/code.playbook/code.fsm.ts +467 -180
- package/reference/sdlc/code.playbook/code.gears.md +11 -10
- package/reference/sdlc/code.playbook/code.playbook.d.ts +16 -19
- package/reference/sdlc/code.playbook/code.playbook.js +199 -488
- package/reference/sdlc/code.playbook/code.playbook.ts +327 -566
- package/reference/sdlc/code.playbook/code.registry.d.ts +0 -3
- package/reference/sdlc/code.playbook/code.registry.js +0 -3
- package/reference/sdlc/code.playbook/code.registry.ts +0 -6
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +9 -4
- package/reference/sdlc/code.playbook/playbook-captain.js +889 -210
- package/reference/sdlc/code.playbook/playbook-captain.ts +1136 -257
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +21 -0
- package/reference/sdlc/discuss.playbook/discuss.fsm.d.ts +396 -0
- package/reference/sdlc/discuss.playbook/discuss.fsm.js +2066 -0
- package/reference/sdlc/discuss.playbook/discuss.fsm.ts +2464 -0
- package/reference/sdlc/discuss.playbook/discuss.gears.md +251 -0
- package/reference/sdlc/discuss.playbook/discuss.playbook.d.ts +113 -0
- package/reference/sdlc/discuss.playbook/discuss.playbook.js +1514 -0
- package/reference/sdlc/discuss.playbook/discuss.playbook.ts +1926 -0
- package/reference/sdlc/discuss.playbook/discuss.registry.d.ts +58 -0
- package/reference/sdlc/discuss.playbook/discuss.registry.js +97 -0
- package/reference/sdlc/discuss.playbook/discuss.registry.ts +153 -0
- package/slc/gears2fsm.md +557 -57
- package/slc/link.md +1165 -89
- package/slc/optimize.md +92 -0
- package/slc/text2gears.md +255 -7
- package/src/runtime.d.ts +146 -3
- package/src/runtime.ts +201 -2
- package/src/xstate-playbook-runtime.d.ts +201 -0
- package/src/xstate-playbook-runtime.js +2058 -0
- package/src/xstate-playbook-runtime.ts +2792 -0
- package/src/xstate-runtime.d.ts +95 -0
- package/src/xstate-runtime.js +1258 -0
- package/src/xstate-runtime.ts +1816 -0
|
@@ -11,15 +11,35 @@
|
|
|
11
11
|
// alias's first alternative)
|
|
12
12
|
// Boss event: free-text judge classification
|
|
13
13
|
// Adjudication: LLM-judge per state
|
|
14
|
-
// Contract: PlayerResult / PlaybookPorts /
|
|
15
|
-
// and re-exported from
|
|
14
|
+
// Contract: PlayerResult / PlaybookPorts / PlaybookSession /
|
|
15
|
+
// PlaybookRuntime imported and re-exported from
|
|
16
|
+
// @sublang/playbook/runtime
|
|
16
17
|
// (slc/link.md §Output, DR-004 Addendum A4)
|
|
18
|
+
// Runtime: the shared createXStatePlaybookRuntime factory from
|
|
19
|
+
// @sublang/playbook/xstate-runtime interprets the FSM
|
|
20
|
+
// (slc/link.md §Output, DR-019); this module carries only
|
|
21
|
+
// the CODE-specific spec — options validation, player
|
|
22
|
+
// binding, prompt composition, Boss-event classification,
|
|
23
|
+
// and Captain-pane status formatting.
|
|
17
24
|
|
|
18
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
createPlayerBridge,
|
|
27
|
+
createXStatePlaybookRuntime,
|
|
28
|
+
adjudicatePlayerOutput,
|
|
29
|
+
normalizeError,
|
|
30
|
+
normalizeErrorCompact,
|
|
31
|
+
normalizeErrorFull,
|
|
32
|
+
parseJudgeJson,
|
|
33
|
+
snapshotJsonValue,
|
|
34
|
+
type PlaybookPlayerInput,
|
|
35
|
+
type RuntimeBoundaryCalls,
|
|
36
|
+
type ScheduledStatus,
|
|
37
|
+
type XStatePlaybookRuntimeSpec,
|
|
38
|
+
} from '../../../src/xstate-runtime.js';
|
|
19
39
|
import {
|
|
20
40
|
codingMachine,
|
|
21
|
-
type
|
|
22
|
-
type
|
|
41
|
+
type PlayerInput,
|
|
42
|
+
type PlayerOutput,
|
|
23
43
|
type CodingEvent,
|
|
24
44
|
type CodingInput,
|
|
25
45
|
} from './code.fsm.js';
|
|
@@ -29,27 +49,87 @@ import {
|
|
|
29
49
|
enumerateRootEvents,
|
|
30
50
|
} from './code.fsm.introspect.js';
|
|
31
51
|
import type {
|
|
52
|
+
CaptainCallOptions,
|
|
53
|
+
CaptainResult,
|
|
54
|
+
JsonValue,
|
|
55
|
+
NormalizedError,
|
|
56
|
+
PlayerCallOptions,
|
|
57
|
+
PlaybookCallRequest,
|
|
58
|
+
PlaybookCallResult,
|
|
59
|
+
PlaybookCallStart,
|
|
60
|
+
PlaybookPendingCall,
|
|
61
|
+
PlaybookRunResult,
|
|
62
|
+
PlaybookRuntimeSnapshot,
|
|
63
|
+
PlaybookSession,
|
|
64
|
+
PlaybookState,
|
|
65
|
+
PlaybookStateValue,
|
|
66
|
+
PlaybookTraceEvent,
|
|
67
|
+
PlaybookTraceType,
|
|
32
68
|
PlaybookPorts,
|
|
33
69
|
PlaybookRuntime,
|
|
70
|
+
PlaybookRuntimeFactory,
|
|
34
71
|
PlayerResult,
|
|
35
72
|
} from '@sublang/playbook/runtime';
|
|
36
73
|
|
|
37
|
-
// Public contract. `PlayerResult`, `PlaybookPorts`, and
|
|
38
|
-
// are re-exported from the shared
|
|
74
|
+
// Public contract. `PlayerResult`, `PlaybookPorts`, `PlaybookSession`, and
|
|
75
|
+
// `PlaybookRuntime` are re-exported from the shared runtime module
|
|
39
76
|
// (slc/link.md §Output, DR-004 Addendum A4) so this playbook and any
|
|
40
77
|
// future one resolve one contract definition rather than redefining it.
|
|
41
78
|
// `CodePlaybookOptions` and the default `createPlaybookRuntime` factory
|
|
42
79
|
// (typed `PlaybookRuntimeFactory<CodePlaybookOptions>`) stay
|
|
43
80
|
// CODE-specific.
|
|
44
|
-
export type {
|
|
81
|
+
export type {
|
|
82
|
+
CaptainCallOptions,
|
|
83
|
+
CaptainResult,
|
|
84
|
+
JsonValue,
|
|
85
|
+
NormalizedError,
|
|
86
|
+
PlayerCallOptions,
|
|
87
|
+
PlaybookCallRequest,
|
|
88
|
+
PlaybookCallResult,
|
|
89
|
+
PlaybookCallStart,
|
|
90
|
+
PlaybookPendingCall,
|
|
91
|
+
PlaybookRunResult,
|
|
92
|
+
PlayerResult,
|
|
93
|
+
PlaybookPorts,
|
|
94
|
+
PlaybookSession,
|
|
95
|
+
PlaybookState,
|
|
96
|
+
PlaybookStateValue,
|
|
97
|
+
PlaybookTraceEvent,
|
|
98
|
+
PlaybookTraceType,
|
|
99
|
+
PlaybookRuntime,
|
|
100
|
+
PlaybookRuntimeFactory,
|
|
101
|
+
PlaybookRuntimeSnapshot,
|
|
102
|
+
};
|
|
45
103
|
|
|
46
104
|
export type CodePlaybookOptions = CodingInput;
|
|
47
105
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
106
|
+
function snapshotCodePlaybookOptions(value: unknown): CodePlaybookOptions {
|
|
107
|
+
const captured = snapshotJsonValue(value, 'CODE runtime options');
|
|
108
|
+
if (
|
|
109
|
+
captured === null ||
|
|
110
|
+
typeof captured !== 'object' ||
|
|
111
|
+
Array.isArray(captured)
|
|
112
|
+
) {
|
|
113
|
+
throw new TypeError('CODE runtime options must be an object');
|
|
114
|
+
}
|
|
115
|
+
const record = captured as Readonly<Record<string, JsonValue>>;
|
|
116
|
+
const allowed = new Set([
|
|
117
|
+
'intent',
|
|
118
|
+
'irNumber',
|
|
119
|
+
'coderPlayer',
|
|
120
|
+
'reviewerPlayer',
|
|
121
|
+
'committerPlayer',
|
|
122
|
+
]);
|
|
123
|
+
for (const [key, option] of Object.entries(record)) {
|
|
124
|
+
if (!allowed.has(key)) {
|
|
125
|
+
throw new TypeError(`CODE runtime options.${key} is not declared`);
|
|
126
|
+
}
|
|
127
|
+
if (typeof option !== 'string') {
|
|
128
|
+
throw new TypeError(`CODE runtime options.${key} must be a string`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return captured as unknown as CodePlaybookOptions;
|
|
132
|
+
}
|
|
53
133
|
|
|
54
134
|
// Required-payload fields whose value is the player's verbatim long-form
|
|
55
135
|
// prose. The runtime carries `finalText.trim()` into these fields rather
|
|
@@ -61,64 +141,56 @@ const VERBATIM_PAYLOAD_FIELDS: ReadonlySet<string> = new Set([
|
|
|
61
141
|
'challenges',
|
|
62
142
|
]);
|
|
63
143
|
|
|
64
|
-
// Normalize an unknown error value to the compact `{ name, message }`
|
|
65
|
-
// shape used by Captain-pane / status emissions. Returns `undefined`
|
|
66
|
-
// for nullish input so callers can omit absent errors.
|
|
67
|
-
function normalizeErrorCompact(
|
|
68
|
-
err: unknown,
|
|
69
|
-
): { name: string; message: string } | undefined {
|
|
70
|
-
if (err === undefined || err === null) return undefined;
|
|
71
|
-
if (err instanceof Error) {
|
|
72
|
-
return { name: err.name, message: err.message };
|
|
73
|
-
}
|
|
74
|
-
if (typeof err === 'object') {
|
|
75
|
-
const o = err as Record<string, unknown>;
|
|
76
|
-
if (typeof o.message === 'string') {
|
|
77
|
-
return {
|
|
78
|
-
name: typeof o.name === 'string' ? o.name : 'Error',
|
|
79
|
-
message: o.message,
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
return { name: 'Error', message: String(err) };
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Normalize an unknown error value to the full `{ name, message, stack }`
|
|
87
|
-
// shape used by telemetry emissions. Returns `undefined` for nullish
|
|
88
|
-
// input. `stack` is omitted when not available on the source value.
|
|
89
|
-
function normalizeErrorFull(
|
|
90
|
-
err: unknown,
|
|
91
|
-
): { name: string; message: string; stack?: string } | undefined {
|
|
92
|
-
const compact = normalizeErrorCompact(err);
|
|
93
|
-
if (compact === undefined) return undefined;
|
|
94
|
-
if (err instanceof Error) {
|
|
95
|
-
return err.stack !== undefined ? { ...compact, stack: err.stack } : compact;
|
|
96
|
-
}
|
|
97
|
-
if (typeof err === 'object' && err !== null) {
|
|
98
|
-
const stack = (err as Record<string, unknown>).stack;
|
|
99
|
-
if (typeof stack === 'string') {
|
|
100
|
-
return { ...compact, stack };
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
return compact;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
144
|
// Normalize any `error` field inside a telemetry event so failed
|
|
107
145
|
// transitions don't leak raw Error instances through the channel.
|
|
108
146
|
function normalizeEventForTelemetry(event: unknown): unknown {
|
|
109
|
-
if (event ===
|
|
110
|
-
|
|
147
|
+
if (event === undefined) return undefined;
|
|
148
|
+
return normalizeEventValue(event, 'FSM event', new Set());
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function normalizeEventValue(
|
|
152
|
+
value: unknown,
|
|
153
|
+
path: string,
|
|
154
|
+
ancestors: ReadonlySet<object>,
|
|
155
|
+
): JsonValue {
|
|
156
|
+
if (Array.isArray(value)) return snapshotJsonValue(value, path);
|
|
157
|
+
if (value === null || typeof value !== 'object') {
|
|
158
|
+
return snapshotJsonValue(value, path);
|
|
159
|
+
}
|
|
160
|
+
if (ancestors.has(value)) {
|
|
161
|
+
throw new TypeError(`${path} must not contain a JSON cycle`);
|
|
162
|
+
}
|
|
163
|
+
const prototype = Object.getPrototypeOf(value) as unknown;
|
|
164
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
165
|
+
return snapshotJsonValue(value, path);
|
|
166
|
+
}
|
|
167
|
+
if (Object.getOwnPropertySymbols(value).length > 0) {
|
|
168
|
+
return snapshotJsonValue(value, path);
|
|
169
|
+
}
|
|
170
|
+
const nextAncestors = new Set(ancestors).add(value);
|
|
171
|
+
const normalized: Record<string, JsonValue> = {};
|
|
172
|
+
for (const [key, descriptor] of Object.entries(
|
|
173
|
+
Object.getOwnPropertyDescriptors(value),
|
|
174
|
+
)) {
|
|
175
|
+
if (!descriptor.enumerable) {
|
|
176
|
+
throw new TypeError(`${path}.${key} must be an enumerable JSON property`);
|
|
177
|
+
}
|
|
178
|
+
if (!Object.prototype.hasOwnProperty.call(descriptor, 'value')) {
|
|
179
|
+
throw new TypeError(`${path}.${key} must be a JSON data property`);
|
|
180
|
+
}
|
|
181
|
+
if (descriptor.value === undefined) continue;
|
|
182
|
+
normalized[key] =
|
|
183
|
+
key === 'error'
|
|
184
|
+
? snapshotJsonValue(normalizeError(descriptor.value), `${path}.error`)
|
|
185
|
+
: normalizeEventValue(
|
|
186
|
+
descriptor.value,
|
|
187
|
+
`${path}.${key}`,
|
|
188
|
+
nextAncestors,
|
|
189
|
+
);
|
|
111
190
|
}
|
|
112
|
-
|
|
113
|
-
if (!('error' in e)) return event;
|
|
114
|
-
const normalized = normalizeErrorFull(e.error);
|
|
115
|
-
return { ...e, error: normalized };
|
|
191
|
+
return snapshotJsonValue(normalized, path);
|
|
116
192
|
}
|
|
117
193
|
|
|
118
|
-
// Internal capabilities (DR-004 §10). Each ships with its final
|
|
119
|
-
// signature; behavior lands in the per-capability task noted by the
|
|
120
|
-
// TODO marker.
|
|
121
|
-
|
|
122
194
|
// Player-prompt composer — DR-004 §6.
|
|
123
195
|
// Substitutes the three placeholder tokens in `input.prompt` (literal
|
|
124
196
|
// string replace, no escaping) and arranges labelled blocks around
|
|
@@ -131,7 +203,7 @@ function normalizeEventForTelemetry(event: unknown): unknown {
|
|
|
131
203
|
// reply, the continuation preamble and Q/A blocks precede every
|
|
132
204
|
// ordinary block. The FSM's prompt body is never re-flowed.
|
|
133
205
|
|
|
134
|
-
function composePlayerPrompt(input:
|
|
206
|
+
function composePlayerPrompt(input: PlayerInput): string {
|
|
135
207
|
const blocks: string[] = [];
|
|
136
208
|
if (
|
|
137
209
|
input.pendingBossQuestion !== undefined &&
|
|
@@ -189,7 +261,7 @@ function composePlayerPrompt(input: CaptainInput): string {
|
|
|
189
261
|
// set. The alias selects only the host pane; it is not a PBRT-4
|
|
190
262
|
// identity string, so it leaves <coder-llm> / <reviewer-llm>
|
|
191
263
|
// untouched and `input.player` stays `Committer` (PLAYBOOK-3).
|
|
192
|
-
function resolvePlayerId(input:
|
|
264
|
+
function resolvePlayerId(input: PlayerInput): string {
|
|
193
265
|
switch (input.player) {
|
|
194
266
|
case 'Coder':
|
|
195
267
|
return 'coder';
|
|
@@ -202,69 +274,9 @@ function resolvePlayerId(input: CaptainInput): string {
|
|
|
202
274
|
return 'coder';
|
|
203
275
|
default: {
|
|
204
276
|
const exhaustive: never = input.player;
|
|
205
|
-
throw new Error(
|
|
206
|
-
`resolvePlayerId: unknown player ${String(exhaustive)}`,
|
|
207
|
-
);
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// LLM judge — DR-004 §4. Builds a prompt that lists each declared
|
|
213
|
-
// outcome verbatim, asks ports.callJudge for a JSON
|
|
214
|
-
// `{ guard, …payloadFields }` response, and returns the parsed
|
|
215
|
-
// object once the chosen guard is one of the input.result keys.
|
|
216
|
-
// Adjudicator failures (malformed JSON, missing/unknown guard) are
|
|
217
|
-
// control-plane errors and propagate via throw per slc/link.md.
|
|
218
|
-
async function adjudicate(
|
|
219
|
-
input: CaptainInput,
|
|
220
|
-
finalText: string,
|
|
221
|
-
ports: PlaybookPorts,
|
|
222
|
-
signal: AbortSignal,
|
|
223
|
-
): Promise<CaptainOutput> {
|
|
224
|
-
const prompt = buildJudgePrompt(input, finalText);
|
|
225
|
-
const raw = await ports.callJudge(prompt, signal);
|
|
226
|
-
const parsed = parseJudgeJson(raw);
|
|
227
|
-
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
228
|
-
throw new Error('adjudicate: judge response is not a JSON object');
|
|
229
|
-
}
|
|
230
|
-
const obj = parsed as Record<string, unknown>;
|
|
231
|
-
const guard = obj.guard;
|
|
232
|
-
if (typeof guard !== 'string') {
|
|
233
|
-
throw new Error('adjudicate: judge response missing string "guard" field');
|
|
234
|
-
}
|
|
235
|
-
if (!Object.prototype.hasOwnProperty.call(input.result, guard)) {
|
|
236
|
-
throw new Error(
|
|
237
|
-
`adjudicate: unknown guard "${guard}" — declared guards: ${Object.keys(
|
|
238
|
-
input.result,
|
|
239
|
-
).join(', ')}`,
|
|
240
|
-
);
|
|
241
|
-
}
|
|
242
|
-
// Per slc/link.md, a missing payload field the state's `result`
|
|
243
|
-
// description requires is a control-plane error. The FSM names
|
|
244
|
-
// required fields with the literal phrase
|
|
245
|
-
// Output shall include `<fieldName>: <...>`
|
|
246
|
-
// so we extract those tokens and require each to be a string in
|
|
247
|
-
// the judge response — except for VERBATIM_PAYLOAD_FIELDS
|
|
248
|
-
// (`reviews`, `challenges`), where the runtime substitutes
|
|
249
|
-
// `finalText.trim()` so the long-form prose is not round-tripped
|
|
250
|
-
// through judge JSON. Short extracted fields like `question` and
|
|
251
|
-
// `taskDescription` keep the existing extract-and-validate path.
|
|
252
|
-
const verbatim = finalText.trim();
|
|
253
|
-
for (const field of extractRequiredFields(input.result[guard])) {
|
|
254
|
-
if (VERBATIM_PAYLOAD_FIELDS.has(field)) {
|
|
255
|
-
obj[field] = verbatim;
|
|
256
|
-
continue;
|
|
257
|
-
}
|
|
258
|
-
if (typeof obj[field] !== 'string') {
|
|
259
|
-
if (guard === 'needsBossReply' && field === 'question') {
|
|
260
|
-
throw new Error(BOSS_REPLY_ERRORS.missingQuestion);
|
|
261
|
-
}
|
|
262
|
-
throw new Error(
|
|
263
|
-
`adjudicate: judge response missing required field "${field}" for guard "${guard}"`,
|
|
264
|
-
);
|
|
277
|
+
throw new Error(`resolvePlayerId: unknown player ${String(exhaustive)}`);
|
|
265
278
|
}
|
|
266
279
|
}
|
|
267
|
-
return obj as CaptainOutput;
|
|
268
280
|
}
|
|
269
281
|
|
|
270
282
|
function extractRequiredFields(description: string): string[] {
|
|
@@ -276,7 +288,7 @@ function extractRequiredFields(description: string): string[] {
|
|
|
276
288
|
return fields;
|
|
277
289
|
}
|
|
278
290
|
|
|
279
|
-
function buildJudgePrompt(input:
|
|
291
|
+
function buildJudgePrompt(input: PlayerInput, finalText: string): string {
|
|
280
292
|
const lines: string[] = [];
|
|
281
293
|
lines.push(`The ${input.player} just produced this output:`);
|
|
282
294
|
lines.push('');
|
|
@@ -299,137 +311,38 @@ function buildJudgePrompt(input: CaptainInput, finalText: string): string {
|
|
|
299
311
|
return lines.join('\n');
|
|
300
312
|
}
|
|
301
313
|
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
// truncation) span, so a damaged object earlier in the prose is not
|
|
312
|
-
// overridden by a cleaner one later. Scanning every start (not just
|
|
313
|
-
// the first bracket) keeps a bracketed fragment in surrounding prose —
|
|
314
|
-
// e.g. an aside like `see [1]` or `{n/a}` before the real object —
|
|
315
|
-
// from masking a later, genuinely valid object. Both callers
|
|
316
|
-
// (classification and adjudication) expect an object, so plain objects
|
|
317
|
-
// win over arrays/scalars; the first value of any shape is remembered
|
|
318
|
-
// so a legitimately array/scalar reply still surfaces to the caller's
|
|
319
|
-
// own object check. Only a reply from which no JSON value can be
|
|
320
|
-
// recovered is treated as malformed and throws, preserving the
|
|
321
|
-
// control-plane error contract (PBRT-7, PBRT-10).
|
|
322
|
-
function parseJudgeJson(raw: string): unknown {
|
|
323
|
-
const fenced = stripCodeFence(raw.trim());
|
|
324
|
-
// Fast path: a well-formed (optionally fenced) JSON body.
|
|
325
|
-
try {
|
|
326
|
-
return JSON.parse(fenced);
|
|
327
|
-
} catch {
|
|
328
|
-
// Fall through to lenient extraction + repair.
|
|
329
|
-
}
|
|
330
|
-
const starts: number[] = [];
|
|
331
|
-
for (let i = 0; i < fenced.length; i++) {
|
|
332
|
-
const ch = fenced[i];
|
|
333
|
-
if (ch === '{' || ch === '[') starts.push(i);
|
|
334
|
-
}
|
|
335
|
-
// Walk starts in document order. At each start prefer a strict
|
|
336
|
-
// balanced span (most trustworthy) and fall back to a repaired one
|
|
337
|
-
// for a trailing-comma / truncated tail, so the earliest intended
|
|
338
|
-
// object wins even when it needs repair. Return the first plain
|
|
339
|
-
// object; remember the first value of any shape so a legitimately
|
|
340
|
-
// array/scalar reply still surfaces to the caller's own object check.
|
|
341
|
-
let firstValue: { value: unknown } | undefined;
|
|
342
|
-
for (const start of starts) {
|
|
343
|
-
let parsedHere: { value: unknown } | undefined;
|
|
344
|
-
for (const repair of [false, true]) {
|
|
345
|
-
const candidate = extractJsonValue(fenced, start, repair);
|
|
346
|
-
if (candidate === undefined) continue;
|
|
347
|
-
try {
|
|
348
|
-
parsedHere = { value: JSON.parse(candidate) };
|
|
349
|
-
} catch {
|
|
350
|
-
continue; // not parseable this way — try repair, then next start
|
|
351
|
-
}
|
|
352
|
-
break; // prefer the strict span at this start over its repair
|
|
353
|
-
}
|
|
354
|
-
if (parsedHere === undefined) continue;
|
|
355
|
-
if (isPlainObject(parsedHere.value)) return parsedHere.value;
|
|
356
|
-
if (firstValue === undefined) firstValue = parsedHere;
|
|
357
|
-
}
|
|
358
|
-
if (firstValue !== undefined) return firstValue.value;
|
|
359
|
-
throw new Error('adjudicate: judge response is not valid JSON');
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
function isPlainObject(value: unknown): boolean {
|
|
363
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
// Strip a single Markdown code fence that wraps the whole string.
|
|
367
|
-
function stripCodeFence(text: string): string {
|
|
368
|
-
const fence = text.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/);
|
|
369
|
-
return fence ? fence[1].trim() : text;
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
// Scan from `start` (a `{`/`[` index), tracking string and
|
|
373
|
-
// bracket-nesting state, and emit the balanced JSON value rooted
|
|
374
|
-
// there. Anything after the top-level value closes is ignored (so a
|
|
375
|
-
// trailing code fence or commentary does not matter). With
|
|
376
|
-
// `repair === false` the span is returned only if it actually closes,
|
|
377
|
-
// and trailing commas are left intact — so the caller can prefer a
|
|
378
|
-
// cleanly-balanced span before attempting repair; if input ends
|
|
379
|
-
// before the value closes, undefined is returned. With
|
|
380
|
-
// `repair === true` common damage is fixed: a trailing comma before a
|
|
381
|
-
// close is removed, an unterminated string is closed, and any
|
|
382
|
-
// brackets still open at end-of-input are closed in order.
|
|
383
|
-
function extractJsonValue(
|
|
384
|
-
text: string,
|
|
385
|
-
start: number,
|
|
386
|
-
repair: boolean,
|
|
387
|
-
): string | undefined {
|
|
388
|
-
const stack: string[] = [];
|
|
389
|
-
let out = '';
|
|
390
|
-
let inString = false;
|
|
391
|
-
let escaped = false;
|
|
392
|
-
for (let i = start; i < text.length; i++) {
|
|
393
|
-
const ch = text[i];
|
|
394
|
-
if (inString) {
|
|
395
|
-
out += ch;
|
|
396
|
-
if (escaped) escaped = false;
|
|
397
|
-
else if (ch === '\\') escaped = true;
|
|
398
|
-
else if (ch === '"') inString = false;
|
|
399
|
-
continue;
|
|
400
|
-
}
|
|
401
|
-
if (ch === '"') {
|
|
402
|
-
inString = true;
|
|
403
|
-
out += ch;
|
|
404
|
-
continue;
|
|
405
|
-
}
|
|
406
|
-
if (ch === '{' || ch === '[') {
|
|
407
|
-
stack.push(ch === '{' ? '}' : ']');
|
|
408
|
-
out += ch;
|
|
409
|
-
continue;
|
|
410
|
-
}
|
|
411
|
-
if (ch === '}' || ch === ']') {
|
|
412
|
-
if (repair) out = dropTrailingComma(out);
|
|
413
|
-
out += ch;
|
|
414
|
-
stack.pop();
|
|
415
|
-
if (stack.length === 0) return out; // top-level value complete
|
|
416
|
-
continue;
|
|
417
|
-
}
|
|
418
|
-
out += ch;
|
|
419
|
-
}
|
|
420
|
-
// End of input before the top-level value closed.
|
|
421
|
-
if (!repair) return undefined; // strict pass: no balanced span here
|
|
422
|
-
if (inString) out += '"';
|
|
423
|
-
out = dropTrailingComma(out);
|
|
424
|
-
while (stack.length > 0) out += stack.pop();
|
|
425
|
-
return out;
|
|
426
|
-
}
|
|
314
|
+
// CODE-specific adjudication strategy: the CODE judge prompt above, the
|
|
315
|
+
// DR-004 `Output shall include` required-field extraction, and the
|
|
316
|
+
// verbatim long-form payload fields.
|
|
317
|
+
const CODE_ADJUDICATION = {
|
|
318
|
+
buildJudgePrompt: (input: PlaybookPlayerInput, finalText: string) =>
|
|
319
|
+
buildJudgePrompt(input as unknown as PlayerInput, finalText),
|
|
320
|
+
extractRequiredFields,
|
|
321
|
+
verbatimPayloadFields: VERBATIM_PAYLOAD_FIELDS,
|
|
322
|
+
};
|
|
427
323
|
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
// `{
|
|
431
|
-
|
|
432
|
-
|
|
324
|
+
// LLM judge — DR-004 §4. Delegates to the shared adjudicator with the
|
|
325
|
+
// CODE strategy: it lists each declared outcome verbatim, asks
|
|
326
|
+
// ports.callJudge for a JSON `{ guard, …payloadFields }` response, and
|
|
327
|
+
// returns the parsed object once the chosen guard is one of the
|
|
328
|
+
// input.result keys. Adjudicator failures (malformed JSON,
|
|
329
|
+
// missing/unknown guard) are control-plane errors and propagate via
|
|
330
|
+
// throw per slc/link.md.
|
|
331
|
+
async function adjudicate(
|
|
332
|
+
input: PlayerInput,
|
|
333
|
+
finalText: string,
|
|
334
|
+
ports: PlaybookPorts,
|
|
335
|
+
signal: AbortSignal,
|
|
336
|
+
boundary?: RuntimeBoundaryCalls,
|
|
337
|
+
): Promise<PlayerOutput> {
|
|
338
|
+
return (await adjudicatePlayerOutput(
|
|
339
|
+
CODE_ADJUDICATION,
|
|
340
|
+
input,
|
|
341
|
+
finalText,
|
|
342
|
+
ports,
|
|
343
|
+
signal,
|
|
344
|
+
boundary,
|
|
345
|
+
)) as unknown as PlayerOutput;
|
|
433
346
|
}
|
|
434
347
|
|
|
435
348
|
// Boss-event classifier — DR-004 §3.
|
|
@@ -444,11 +357,12 @@ async function classifyBossText(
|
|
|
444
357
|
ports: PlaybookPorts,
|
|
445
358
|
signal: AbortSignal,
|
|
446
359
|
snapshotOrState?: unknown,
|
|
360
|
+
boundary?: RuntimeBoundaryCalls,
|
|
447
361
|
): Promise<CodingEvent | undefined> {
|
|
448
362
|
const trimmed = text.trim();
|
|
449
363
|
if (trimmed === '') return undefined;
|
|
450
364
|
|
|
451
|
-
return classifyWithLlm(text, ports, signal, snapshotOrState);
|
|
365
|
+
return classifyWithLlm(text, ports, signal, snapshotOrState, boundary);
|
|
452
366
|
}
|
|
453
367
|
|
|
454
368
|
// JumpableStateId is internal to code.fsm.ts (not exported), so
|
|
@@ -469,10 +383,19 @@ async function classifyWithLlm(
|
|
|
469
383
|
ports: PlaybookPorts,
|
|
470
384
|
signal: AbortSignal,
|
|
471
385
|
snapshotOrState?: unknown,
|
|
386
|
+
boundary?: RuntimeBoundaryCalls,
|
|
472
387
|
): Promise<CodingEvent | undefined> {
|
|
473
388
|
const state = classifierState(snapshotOrState);
|
|
474
389
|
const prompt = buildClassifierPrompt(text, state);
|
|
475
|
-
const
|
|
390
|
+
const stateId = typeof state.value === 'string' ? state.value : undefined;
|
|
391
|
+
const raw = boundary
|
|
392
|
+
? await boundary.callJudge(
|
|
393
|
+
'boss-input-classification',
|
|
394
|
+
stateId,
|
|
395
|
+
prompt,
|
|
396
|
+
signal,
|
|
397
|
+
)
|
|
398
|
+
: await ports.callJudge(prompt, signal);
|
|
476
399
|
let parsed: unknown;
|
|
477
400
|
try {
|
|
478
401
|
parsed = parseJudgeJson(raw);
|
|
@@ -562,7 +485,36 @@ async function classifyWithLlm(
|
|
|
562
485
|
await ports.emitStatus('Classifier omitted answer for BOSS_REPLY');
|
|
563
486
|
return undefined;
|
|
564
487
|
}
|
|
565
|
-
|
|
488
|
+
const pending = pendingBossQuestionFromContext(state.context);
|
|
489
|
+
if (!pending) {
|
|
490
|
+
await ports.emitStatus(
|
|
491
|
+
'Classifier returned BOSS_REPLY without a pending question',
|
|
492
|
+
);
|
|
493
|
+
return undefined;
|
|
494
|
+
}
|
|
495
|
+
if (
|
|
496
|
+
payload.questionId !== undefined &&
|
|
497
|
+
typeof payload.questionId !== 'string'
|
|
498
|
+
) {
|
|
499
|
+
await ports.emitStatus(
|
|
500
|
+
'Classifier supplied a non-string questionId for BOSS_REPLY',
|
|
501
|
+
);
|
|
502
|
+
return undefined;
|
|
503
|
+
}
|
|
504
|
+
if (
|
|
505
|
+
typeof payload.questionId === 'string' &&
|
|
506
|
+
payload.questionId !== pending.questionId
|
|
507
|
+
) {
|
|
508
|
+
await ports.emitStatus(
|
|
509
|
+
`Classifier supplied unknown questionId for BOSS_REPLY: ${payload.questionId}`,
|
|
510
|
+
);
|
|
511
|
+
return undefined;
|
|
512
|
+
}
|
|
513
|
+
return {
|
|
514
|
+
type: 'BOSS_REPLY',
|
|
515
|
+
answer: payload.answer,
|
|
516
|
+
questionId: pending.questionId,
|
|
517
|
+
};
|
|
566
518
|
}
|
|
567
519
|
default:
|
|
568
520
|
await ports.emitStatus(
|
|
@@ -602,7 +554,9 @@ function classifierState(snapshotOrState: unknown): ClassifierState {
|
|
|
602
554
|
|
|
603
555
|
function buildClassifierPrompt(text: string, state: ClassifierState): string {
|
|
604
556
|
const currentState =
|
|
605
|
-
typeof state.value === 'string'
|
|
557
|
+
typeof state.value === 'string'
|
|
558
|
+
? state.value
|
|
559
|
+
: JSON.stringify(state.value ?? null);
|
|
606
560
|
const pendingBossQuestion = pendingBossQuestionFromContext(state.context);
|
|
607
561
|
const lines = [
|
|
608
562
|
'Classify the following Boss message into exactly one of these events.',
|
|
@@ -613,8 +567,9 @@ function buildClassifierPrompt(text: string, state: ClassifierState): string {
|
|
|
613
567
|
];
|
|
614
568
|
if (pendingBossQuestion !== undefined) {
|
|
615
569
|
lines.push(
|
|
570
|
+
`Pending question id: ${pendingBossQuestion.questionId}`,
|
|
571
|
+
`Pending asking player: ${pendingBossQuestion.player}`,
|
|
616
572
|
`Pending Boss question: ${pendingBossQuestion.question}`,
|
|
617
|
-
`Pending resume state: ${pendingBossQuestion.resumeStateId}`,
|
|
618
573
|
);
|
|
619
574
|
}
|
|
620
575
|
lines.push(
|
|
@@ -630,27 +585,25 @@ function buildClassifierPrompt(text: string, state: ClassifierState): string {
|
|
|
630
585
|
lines.push(` - ${target.stateId}: ${target.description}`);
|
|
631
586
|
}
|
|
632
587
|
if (currentState === 'awaitBossReply') {
|
|
633
|
-
lines.push(
|
|
588
|
+
lines.push(
|
|
589
|
+
'- BOSS_REPLY: payload { answer: "<verbatim Boss answer>", questionId?: "<pending question id>" }',
|
|
590
|
+
);
|
|
634
591
|
} else {
|
|
635
592
|
lines.push('- BOSS_REPLY: valid only when Current state is awaitBossReply');
|
|
636
593
|
}
|
|
637
|
-
lines.push(
|
|
638
|
-
'',
|
|
639
|
-
'Boss message:',
|
|
640
|
-
'```',
|
|
641
|
-
text,
|
|
642
|
-
'```',
|
|
643
|
-
);
|
|
594
|
+
lines.push('', 'Boss message:', '```', text, '```');
|
|
644
595
|
return lines.join('\n');
|
|
645
596
|
}
|
|
646
597
|
|
|
647
|
-
//
|
|
648
|
-
// codingMachine invokes from every
|
|
649
|
-
//
|
|
650
|
-
//
|
|
651
|
-
//
|
|
652
|
-
//
|
|
653
|
-
//
|
|
598
|
+
// Delegated-player actor bridge — DR-004 §7. One PromiseActorLogic that the
|
|
599
|
+
// codingMachine invokes from every player-invoking state, built by the
|
|
600
|
+
// shared createPlayerBridge with the CODE binding, composer, and
|
|
601
|
+
// adjudication strategy. Per turn: resolve playerId, compose the player
|
|
602
|
+
// prompt, await ports.callPlayer, adjudicate the finalText. PlayerResult
|
|
603
|
+
// status of 'aborted' or 'error' throws so XState routes via onError →
|
|
604
|
+
// #failed (the single fail-stop sink for both Captain errors and player
|
|
605
|
+
// failures). Captain remains the orchestrator and adjudicator; it is not
|
|
606
|
+
// encoded as the delegated FSM actor.
|
|
654
607
|
//
|
|
655
608
|
// `getActiveSignal` is the runtime's hook for flowing the Boss's
|
|
656
609
|
// `handleBossInput.signal` into the host port calls — fromPromise
|
|
@@ -660,33 +613,22 @@ function buildClassifierPrompt(text: string, state: ClassifierState): string {
|
|
|
660
613
|
function captainBridge(
|
|
661
614
|
ports: PlaybookPorts,
|
|
662
615
|
getActiveSignal?: () => AbortSignal | undefined,
|
|
616
|
+
boundary?: RuntimeBoundaryCalls,
|
|
617
|
+
onControlPlaneError?: (error: unknown) => void,
|
|
663
618
|
) {
|
|
664
|
-
return
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
result.error ??
|
|
673
|
-
`captainBridge: callPlayer status "${result.status}"`,
|
|
674
|
-
);
|
|
675
|
-
}
|
|
676
|
-
if (result.finalText === undefined) {
|
|
677
|
-
throw new Error(
|
|
678
|
-
'captainBridge: callPlayer returned status=ok with no finalText',
|
|
679
|
-
);
|
|
680
|
-
}
|
|
681
|
-
const output = await adjudicate(
|
|
682
|
-
input,
|
|
683
|
-
result.finalText,
|
|
684
|
-
ports,
|
|
685
|
-
activeSignal,
|
|
686
|
-
);
|
|
687
|
-
validateBossReplyOutput(input, output);
|
|
688
|
-
return output;
|
|
619
|
+
return createPlayerBridge(
|
|
620
|
+
{
|
|
621
|
+
resolvePlayerId: (input) =>
|
|
622
|
+
resolvePlayerId(input as unknown as PlayerInput),
|
|
623
|
+
composePlayerPrompt: (input) =>
|
|
624
|
+
composePlayerPrompt(input as unknown as PlayerInput),
|
|
625
|
+
adjudication: CODE_ADJUDICATION,
|
|
626
|
+
resumableStateIds: registeredResumableStateIds,
|
|
689
627
|
},
|
|
628
|
+
ports,
|
|
629
|
+
getActiveSignal,
|
|
630
|
+
boundary,
|
|
631
|
+
onControlPlaneError,
|
|
690
632
|
);
|
|
691
633
|
}
|
|
692
634
|
|
|
@@ -696,7 +638,7 @@ function captainBridge(
|
|
|
696
638
|
// glance:
|
|
697
639
|
// (no glyph) bare FSM event type — host renders as captain speech
|
|
698
640
|
// (e.g., `captain> START_CODING`)
|
|
699
|
-
// ⤷
|
|
641
|
+
// ⤷ player-invoking state entry: `<Player>: <label>`
|
|
700
642
|
// → transition guard outcome (`· field=N` tallies
|
|
701
643
|
// appended); the host presenter owns any visual
|
|
702
644
|
// nesting under the preceding ⤷ entry
|
|
@@ -735,7 +677,7 @@ const STATE_LABELS: Readonly<Record<string, string>> = {
|
|
|
735
677
|
};
|
|
736
678
|
|
|
737
679
|
interface StateMetadata {
|
|
738
|
-
player:
|
|
680
|
+
player: PlayerInput['player'];
|
|
739
681
|
sourceItem: string;
|
|
740
682
|
label: string;
|
|
741
683
|
}
|
|
@@ -746,7 +688,7 @@ const stateMetadata: ReadonlyMap<string, StateMetadata> = (() => {
|
|
|
746
688
|
const label = STATE_LABELS[s.stateId];
|
|
747
689
|
if (!label) {
|
|
748
690
|
throw new Error(
|
|
749
|
-
`code.playbook.ts: STATE_LABELS missing entry for
|
|
691
|
+
`code.playbook.ts: STATE_LABELS missing entry for player-invoking state '${s.stateId}'`,
|
|
750
692
|
);
|
|
751
693
|
}
|
|
752
694
|
const input = s.getInput({});
|
|
@@ -755,35 +697,12 @@ const stateMetadata: ReadonlyMap<string, StateMetadata> = (() => {
|
|
|
755
697
|
return m;
|
|
756
698
|
})();
|
|
757
699
|
|
|
758
|
-
const stateIdBySourceItem: ReadonlyMap<string, string> = new Map(
|
|
759
|
-
[...stateMetadata.entries()].map(([stateId, meta]) => [
|
|
760
|
-
meta.sourceItem,
|
|
761
|
-
stateId,
|
|
762
|
-
]),
|
|
763
|
-
);
|
|
764
|
-
|
|
765
700
|
const registeredResumableStateIds: ReadonlySet<string> = new Set(
|
|
766
701
|
enumerateAwaitBossReply(codingMachine).bossReplyTransitions.map(
|
|
767
702
|
(transition) => transition.target,
|
|
768
703
|
),
|
|
769
704
|
);
|
|
770
705
|
|
|
771
|
-
function validateBossReplyOutput(
|
|
772
|
-
input: CaptainInput,
|
|
773
|
-
output: CaptainOutput,
|
|
774
|
-
): void {
|
|
775
|
-
if (output.guard !== 'needsBossReply') return;
|
|
776
|
-
if (typeof output.question !== 'string') {
|
|
777
|
-
throw new Error(BOSS_REPLY_ERRORS.missingQuestion);
|
|
778
|
-
}
|
|
779
|
-
const stateId = stateIdBySourceItem.get(input.sourceItem);
|
|
780
|
-
if (stateId === undefined || !registeredResumableStateIds.has(stateId)) {
|
|
781
|
-
throw new Error(
|
|
782
|
-
BOSS_REPLY_ERRORS.unregisteredState(stateId ?? input.sourceItem),
|
|
783
|
-
);
|
|
784
|
-
}
|
|
785
|
-
}
|
|
786
|
-
|
|
787
706
|
const QUIESCENT_STATES: ReadonlySet<string> = new Set([
|
|
788
707
|
'ready',
|
|
789
708
|
'awaitBossReply',
|
|
@@ -795,12 +714,9 @@ const QUIESCENT_STATES: ReadonlySet<string> = new Set([
|
|
|
795
714
|
// pane per PBRT-3: the readline returning to its `boss>` prompt is
|
|
796
715
|
// the implicit "turn over" signal, so a `◆ ready` / `◆ done`
|
|
797
716
|
// tombstone is redundant.
|
|
798
|
-
const SUPPRESSED_ENTRY_STATES: ReadonlySet<string> = new Set([
|
|
799
|
-
'ready',
|
|
800
|
-
'done',
|
|
801
|
-
]);
|
|
717
|
+
const SUPPRESSED_ENTRY_STATES: ReadonlySet<string> = new Set(['ready', 'done']);
|
|
802
718
|
|
|
803
|
-
// Captain-pane surface (PBRT-3): every
|
|
719
|
+
// Captain-pane surface (PBRT-3): every player-invoking state plus
|
|
804
720
|
// the quiescent states whose entry still carries information
|
|
805
721
|
// (failure with `lastError`, awaitBossReply with the pending
|
|
806
722
|
// question). `ready` and `done` flow through the inspect handler
|
|
@@ -811,7 +727,12 @@ const CAPTAIN_PANE_STATES: ReadonlySet<string> = new Set([
|
|
|
811
727
|
...QUIESCENT_STATES,
|
|
812
728
|
]);
|
|
813
729
|
|
|
730
|
+
type BossReplyQuestionId = NonNullable<
|
|
731
|
+
Extract<CodingEvent, { type: 'BOSS_REPLY' }>['questionId']
|
|
732
|
+
>;
|
|
733
|
+
|
|
814
734
|
interface PendingBossQuestionForStatus {
|
|
735
|
+
questionId: BossReplyQuestionId;
|
|
815
736
|
resumeStateId: string;
|
|
816
737
|
sourceItem: string;
|
|
817
738
|
player: string;
|
|
@@ -830,6 +751,7 @@ function pendingBossQuestionFromContext(
|
|
|
830
751
|
>;
|
|
831
752
|
if (
|
|
832
753
|
typeof candidate.resumeStateId !== 'string' ||
|
|
754
|
+
typeof candidate.questionId !== 'string' ||
|
|
833
755
|
typeof candidate.sourceItem !== 'string' ||
|
|
834
756
|
typeof candidate.player !== 'string' ||
|
|
835
757
|
typeof candidate.question !== 'string'
|
|
@@ -837,6 +759,7 @@ function pendingBossQuestionFromContext(
|
|
|
837
759
|
return undefined;
|
|
838
760
|
}
|
|
839
761
|
return {
|
|
762
|
+
questionId: candidate.questionId as BossReplyQuestionId,
|
|
840
763
|
resumeStateId: candidate.resumeStateId,
|
|
841
764
|
sourceItem: candidate.sourceItem,
|
|
842
765
|
player: candidate.player,
|
|
@@ -864,9 +787,7 @@ function formatAwaitBossReplyQuestion(
|
|
|
864
787
|
// It carries only the resume target, asking player, and source item;
|
|
865
788
|
// the former `q="<first 80 chars>"` excerpt rider is dropped now that
|
|
866
789
|
// the full question rides the captain-speech line above.
|
|
867
|
-
function formatAwaitBossReplyMarker(
|
|
868
|
-
context: Record<string, unknown>,
|
|
869
|
-
): string {
|
|
790
|
+
function formatAwaitBossReplyMarker(context: Record<string, unknown>): string {
|
|
870
791
|
const pending = pendingBossQuestionFromContext(context);
|
|
871
792
|
const resumeStateId = pending?.resumeStateId ?? 'unknown';
|
|
872
793
|
const player = pending?.player ?? 'unknown';
|
|
@@ -912,9 +833,9 @@ function stateTelemetryPayload(
|
|
|
912
833
|
context: Record<string, unknown>,
|
|
913
834
|
): Record<string, unknown> {
|
|
914
835
|
const payload: Record<string, unknown> = {
|
|
915
|
-
from,
|
|
836
|
+
from: from ?? null,
|
|
916
837
|
to,
|
|
917
|
-
event: normalizeEventForTelemetry(event),
|
|
838
|
+
event: normalizeEventForTelemetry(event) ?? null,
|
|
918
839
|
};
|
|
919
840
|
if (to === 'awaitBossReply') {
|
|
920
841
|
const pendingBossQuestion = pendingBossQuestionFromContext(context);
|
|
@@ -931,10 +852,47 @@ function stateTelemetryPayload(
|
|
|
931
852
|
return payload;
|
|
932
853
|
}
|
|
933
854
|
|
|
855
|
+
// Captain-pane status lines for a root transition (PBRT-3 / PBRT-14):
|
|
856
|
+
// the `→ guard` outcome line for the settling transition, then either
|
|
857
|
+
// the awaitBossReply question + rider-less marker pair or the state's
|
|
858
|
+
// entry line (with `lastError` data on `failed`).
|
|
859
|
+
function statusesForState(
|
|
860
|
+
state: PlaybookState,
|
|
861
|
+
context: Record<string, unknown>,
|
|
862
|
+
event: unknown,
|
|
863
|
+
): ScheduledStatus[] {
|
|
864
|
+
const to = state.stateId;
|
|
865
|
+
if (to === undefined || !CAPTAIN_PANE_STATES.has(to)) return [];
|
|
866
|
+
const statuses: ScheduledStatus[] = [];
|
|
867
|
+
const transitionLine = formatTransition(event);
|
|
868
|
+
if (transitionLine !== undefined) {
|
|
869
|
+
statuses.push({ message: transitionLine });
|
|
870
|
+
}
|
|
871
|
+
if (to === 'awaitBossReply') {
|
|
872
|
+
statuses.push(
|
|
873
|
+
{ message: formatAwaitBossReplyQuestion(context) },
|
|
874
|
+
{ message: formatAwaitBossReplyMarker(context) },
|
|
875
|
+
);
|
|
876
|
+
} else {
|
|
877
|
+
const entryLine = formatStateEntry(to);
|
|
878
|
+
if (entryLine !== undefined) {
|
|
879
|
+
const lastError =
|
|
880
|
+
to === 'failed' ? normalizeErrorCompact(context.lastError) : undefined;
|
|
881
|
+
statuses.push({
|
|
882
|
+
message: entryLine,
|
|
883
|
+
...(lastError === undefined
|
|
884
|
+
? {}
|
|
885
|
+
: {
|
|
886
|
+
data: snapshotJsonValue({ lastError }, 'failed status data'),
|
|
887
|
+
}),
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
return statuses;
|
|
892
|
+
}
|
|
893
|
+
|
|
934
894
|
// Internal export surface for tests. Not part of the stable public API;
|
|
935
|
-
// the leading underscore signals "subject to change."
|
|
936
|
-
// referenced here so `noUnusedLocals` stays clean while later tasks
|
|
937
|
-
// wire the factory body to use them.
|
|
895
|
+
// the leading underscore signals "subject to change."
|
|
938
896
|
export const _internal = {
|
|
939
897
|
composePlayerPrompt,
|
|
940
898
|
resolvePlayerId,
|
|
@@ -956,228 +914,31 @@ export const _internal = {
|
|
|
956
914
|
VERBATIM_PAYLOAD_FIELDS,
|
|
957
915
|
};
|
|
958
916
|
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
drainer = (async () => {
|
|
983
|
-
while (emitQueue.length > 0) {
|
|
984
|
-
try {
|
|
985
|
-
await emitQueue.shift()!();
|
|
986
|
-
} catch {
|
|
987
|
-
// Suppress host-side emission errors; the control plane
|
|
988
|
-
// surfaces real failures via handleBossInput throws.
|
|
989
|
-
}
|
|
990
|
-
}
|
|
991
|
-
drainer = undefined;
|
|
992
|
-
})();
|
|
993
|
-
}
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
function drainEmissions(): Promise<void> {
|
|
997
|
-
return drainer ?? Promise.resolve();
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
|
-
function buildActor(
|
|
1001
|
-
ports: PlaybookPorts,
|
|
1002
|
-
): ReturnType<typeof createActor> {
|
|
1003
|
-
priorState = undefined;
|
|
1004
|
-
return createActor(
|
|
1005
|
-
codingMachine.provide({
|
|
1006
|
-
actors: { captain: captainBridge(ports, () => activeSignal) },
|
|
1007
|
-
}),
|
|
1008
|
-
{
|
|
1009
|
-
input: options,
|
|
1010
|
-
inspect: (inspectionEvent) => {
|
|
1011
|
-
if (inspectionEvent.type !== '@xstate.snapshot') return;
|
|
1012
|
-
const snap = inspectionEvent.snapshot as {
|
|
1013
|
-
value?: unknown;
|
|
1014
|
-
context?: Record<string, unknown>;
|
|
1015
|
-
};
|
|
1016
|
-
// Filter out captain sub-actor (fromPromise) snapshots —
|
|
1017
|
-
// only the root codingMachine snapshot has a string value.
|
|
1018
|
-
if (typeof snap.value !== 'string') return;
|
|
1019
|
-
const to = snap.value;
|
|
1020
|
-
if (priorState === to) return;
|
|
1021
|
-
const from = priorState;
|
|
1022
|
-
priorState = to;
|
|
1023
|
-
// Telemetry on every transition (PBRT-14).
|
|
1024
|
-
const context = snap.context ?? {};
|
|
1025
|
-
enqueueEmit(() =>
|
|
1026
|
-
ports.emitTelemetry({
|
|
1027
|
-
topic: 'playbook.fsm.state',
|
|
1028
|
-
payload: stateTelemetryPayload(
|
|
1029
|
-
from,
|
|
1030
|
-
to,
|
|
1031
|
-
inspectionEvent.event,
|
|
1032
|
-
context,
|
|
1033
|
-
),
|
|
1034
|
-
}),
|
|
1035
|
-
);
|
|
1036
|
-
// Captain pane (PBRT-3 / PBRT-14): show the transition
|
|
1037
|
-
// guard first (when this is an actor-done transition with
|
|
1038
|
-
// a known guard), then the new state entry, then any
|
|
1039
|
-
// context riders the entering state cares about. Terminal
|
|
1040
|
-
// entry to `failed` carries `lastError` as the data arg.
|
|
1041
|
-
if (!CAPTAIN_PANE_STATES.has(to)) return;
|
|
1042
|
-
const transitionLine = formatTransition(inspectionEvent.event);
|
|
1043
|
-
if (transitionLine !== undefined) {
|
|
1044
|
-
enqueueEmit(() => ports.emitStatus(transitionLine));
|
|
1045
|
-
}
|
|
1046
|
-
// awaitBossReply surfaces two lines per PBRT-3 / PBRT-14: the
|
|
1047
|
-
// full player question as captain speech, then the rider-less
|
|
1048
|
-
// routing marker. The full-question telemetry rides
|
|
1049
|
-
// stateTelemetryPayload above.
|
|
1050
|
-
if (to === 'awaitBossReply') {
|
|
1051
|
-
const questionLine = formatAwaitBossReplyQuestion(context);
|
|
1052
|
-
const markerLine = formatAwaitBossReplyMarker(context);
|
|
1053
|
-
enqueueEmit(() => ports.emitStatus(questionLine));
|
|
1054
|
-
enqueueEmit(() => ports.emitStatus(markerLine));
|
|
1055
|
-
return;
|
|
1056
|
-
}
|
|
1057
|
-
const entryLine = formatStateEntry(to);
|
|
1058
|
-
if (entryLine === undefined) return;
|
|
1059
|
-
if (to === 'failed') {
|
|
1060
|
-
const lastError = (snap.context as { lastError?: unknown })
|
|
1061
|
-
?.lastError;
|
|
1062
|
-
const data = { lastError: normalizeErrorCompact(lastError) };
|
|
1063
|
-
enqueueEmit(() => ports.emitStatus(entryLine, data));
|
|
1064
|
-
} else {
|
|
1065
|
-
enqueueEmit(() => ports.emitStatus(entryLine));
|
|
1066
|
-
}
|
|
1067
|
-
},
|
|
1068
|
-
},
|
|
1069
|
-
);
|
|
1070
|
-
}
|
|
1071
|
-
|
|
1072
|
-
const runtime = {
|
|
1073
|
-
async init(ports: PlaybookPorts): Promise<void> {
|
|
1074
|
-
savedPorts = ports;
|
|
1075
|
-
actor = buildActor(ports);
|
|
1076
|
-
actor.start();
|
|
1077
|
-
await drainEmissions();
|
|
1078
|
-
},
|
|
1079
|
-
|
|
1080
|
-
async handleBossInput({
|
|
1081
|
-
text,
|
|
1082
|
-
signal,
|
|
1083
|
-
}: {
|
|
1084
|
-
text: string;
|
|
1085
|
-
signal: AbortSignal;
|
|
1086
|
-
}): Promise<void> {
|
|
1087
|
-
if (!actor || !savedPorts) {
|
|
1088
|
-
throw new Error(
|
|
1089
|
-
'createPlaybookRuntime.handleBossInput: init must be called first',
|
|
1090
|
-
);
|
|
1091
|
-
}
|
|
1092
|
-
activeSignal = signal;
|
|
1093
|
-
try {
|
|
1094
|
-
// 1. Classify non-empty text into an FSM event through the judge.
|
|
1095
|
-
const event = await classifyBossText(
|
|
1096
|
-
text,
|
|
1097
|
-
savedPorts,
|
|
1098
|
-
signal,
|
|
1099
|
-
actor.getSnapshot(),
|
|
1100
|
-
);
|
|
1101
|
-
// Empty input, no-action classifier output, or invalid classifier
|
|
1102
|
-
// output — nothing to send.
|
|
1103
|
-
if (event === undefined) {
|
|
1104
|
-
await drainEmissions();
|
|
1105
|
-
return;
|
|
1106
|
-
}
|
|
1107
|
-
// 2. Captain-pane classification line (PBRT-14): the bare
|
|
1108
|
-
// FSM event type, emitted before the FSM advances so the
|
|
1109
|
-
// host can render it as captain speech (e.g.,
|
|
1110
|
-
// `captain> START_CODING`). Enqueued so it interleaves
|
|
1111
|
-
// cleanly with the inspect-driven transition emissions.
|
|
1112
|
-
const echoPorts = savedPorts;
|
|
1113
|
-
enqueueEmit(() =>
|
|
1114
|
-
echoPorts.emitStatus(formatClassification(event.type)),
|
|
1115
|
-
);
|
|
1116
|
-
// 3. final state ('done') cannot accept new events — dispose
|
|
1117
|
-
// and reconstruct per DR-004 §5.
|
|
1118
|
-
if (actor.getSnapshot().status === 'done') {
|
|
1119
|
-
actor.stop();
|
|
1120
|
-
actor = buildActor(savedPorts);
|
|
1121
|
-
actor.start();
|
|
1122
|
-
}
|
|
1123
|
-
// 4. Send the event.
|
|
1124
|
-
actor.send(event);
|
|
1125
|
-
// 5. Drive to quiescence. On signal-abort we take no FSM
|
|
1126
|
-
// action: the captain bridge's awaited callPlayer rejects
|
|
1127
|
-
// naturally, the bridge throws, XState routes through
|
|
1128
|
-
// onError → #failed, and this loop sees the quiescent
|
|
1129
|
-
// snapshot and returns (DR-004 §8 natural rejection).
|
|
1130
|
-
await driveToQuiescence(actor);
|
|
1131
|
-
// Drain transition emissions before returning so the Boss
|
|
1132
|
-
// sees the final status line for this turn.
|
|
1133
|
-
await drainEmissions();
|
|
1134
|
-
} finally {
|
|
1135
|
-
activeSignal = undefined;
|
|
1136
|
-
}
|
|
1137
|
-
},
|
|
1138
|
-
|
|
1139
|
-
async dispose(): Promise<void> {
|
|
1140
|
-
if (actor) {
|
|
1141
|
-
actor.stop();
|
|
1142
|
-
actor = undefined;
|
|
1143
|
-
}
|
|
1144
|
-
// Drain any in-flight emissions per slc/link.md §Session
|
|
1145
|
-
// lifecycle ("stop the actor and drain pending port emissions").
|
|
1146
|
-
await drainEmissions();
|
|
1147
|
-
savedPorts = undefined;
|
|
1148
|
-
},
|
|
1149
|
-
|
|
1150
|
-
// @internal — test-only escape hatch for inspecting the
|
|
1151
|
-
// underlying actor's snapshot. Most state assertions are now
|
|
1152
|
-
// expressible via the recorded emitStatus / emitTelemetry
|
|
1153
|
-
// calls (DR-004 §9); the hatch stays for the few cases where
|
|
1154
|
-
// direct context inspection is clearer (e.g., the dispose
|
|
1155
|
-
// teardown test).
|
|
1156
|
-
_getActor() {
|
|
1157
|
-
return actor;
|
|
1158
|
-
},
|
|
1159
|
-
};
|
|
1160
|
-
return runtime as PlaybookRuntime;
|
|
1161
|
-
}
|
|
917
|
+
// The CODE-specific spec handed to the shared runtime factory
|
|
918
|
+
// (slc/link.md §Output, DR-019). The generic machinery — actor wiring,
|
|
919
|
+
// boundary tracing, Boss-turn lifecycle, nested-playbook bridge, and the
|
|
920
|
+
// DR-014 parked-session snapshot capability — lives in
|
|
921
|
+
// @sublang/playbook/xstate-runtime; this spec carries only what is
|
|
922
|
+
// CODE-specific.
|
|
923
|
+
const runtimeSpec: XStatePlaybookRuntimeSpec<CodePlaybookOptions> = {
|
|
924
|
+
label: 'CODE',
|
|
925
|
+
snapshotOptions: snapshotCodePlaybookOptions,
|
|
926
|
+
resolvePlayerId: (input) => resolvePlayerId(input as unknown as PlayerInput),
|
|
927
|
+
composePlayerPrompt: (input) =>
|
|
928
|
+
composePlayerPrompt(input as unknown as PlayerInput),
|
|
929
|
+
buildJudgePrompt: CODE_ADJUDICATION.buildJudgePrompt,
|
|
930
|
+
extractRequiredFields,
|
|
931
|
+
verbatimPayloadFields: VERBATIM_PAYLOAD_FIELDS,
|
|
932
|
+
resumableStateIds: registeredResumableStateIds,
|
|
933
|
+
classifyBossText: (text, ports, signal, snapshotOrState, boundary) =>
|
|
934
|
+
classifyBossText(text, ports, signal, snapshotOrState, boundary),
|
|
935
|
+
classificationStatus: (event) => formatClassification(event.type),
|
|
936
|
+
statusesForState,
|
|
937
|
+
normalizeTransitionEvent: (event) =>
|
|
938
|
+
normalizeEventForTelemetry(event) as JsonValue | undefined,
|
|
939
|
+
};
|
|
1162
940
|
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
): Promise<void> {
|
|
1166
|
-
return new Promise<void>((resolve) => {
|
|
1167
|
-
if (isQuiescent(actor.getSnapshot())) {
|
|
1168
|
-
resolve();
|
|
1169
|
-
return;
|
|
1170
|
-
}
|
|
1171
|
-
const sub = actor.subscribe((snap) => {
|
|
1172
|
-
if (isQuiescent(snap)) {
|
|
1173
|
-
sub.unsubscribe();
|
|
1174
|
-
resolve();
|
|
1175
|
-
}
|
|
1176
|
-
});
|
|
1177
|
-
});
|
|
1178
|
-
}
|
|
941
|
+
const createPlaybookRuntime: PlaybookRuntimeFactory<CodePlaybookOptions> =
|
|
942
|
+
createXStatePlaybookRuntime(codingMachine, runtimeSpec);
|
|
1179
943
|
|
|
1180
|
-
|
|
1181
|
-
const v = snap.value;
|
|
1182
|
-
return typeof v === 'string' && QUIESCENT_STATES.has(v);
|
|
1183
|
-
}
|
|
944
|
+
export default createPlaybookRuntime;
|