@sublang/playbook 0.1.2 → 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 +27 -18
- package/package.json +23 -23
- 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
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// Committer→{coder per CODE-18/19} (CODE-19 wires both
|
|
8
8
|
// coderPlayer and reviewerPlayer; Coder wins as the
|
|
9
9
|
// alias's first alternative)
|
|
10
|
-
// Boss event:
|
|
10
|
+
// Boss event: free-text judge classification
|
|
11
11
|
// Adjudication: LLM-judge per state
|
|
12
12
|
|
|
13
13
|
import { createActor, fromPromise } from 'xstate';
|
|
@@ -18,7 +18,11 @@ import {
|
|
|
18
18
|
type CodingEvent,
|
|
19
19
|
type CodingInput,
|
|
20
20
|
} from './code.fsm.js';
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
enumerateAwaitBossReply,
|
|
23
|
+
enumerateCaptainStates,
|
|
24
|
+
enumerateRootEvents,
|
|
25
|
+
} from './code.fsm.introspect.js';
|
|
22
26
|
|
|
23
27
|
// Public contract — `PlayerResult`, `PlaybookPorts`, `PlaybookRuntime`,
|
|
24
28
|
// `CodePlaybookOptions`, and the default `createPlaybookRuntime` factory
|
|
@@ -49,6 +53,12 @@ export interface PlaybookRuntime {
|
|
|
49
53
|
|
|
50
54
|
export type CodePlaybookOptions = CodingInput;
|
|
51
55
|
|
|
56
|
+
const BOSS_REPLY_ERRORS = {
|
|
57
|
+
missingQuestion: "needsBossReply outcome missing 'question' field",
|
|
58
|
+
unregisteredState: (stateId: string) =>
|
|
59
|
+
`state ${stateId} declared needsBossReply but is not registered as resumable`,
|
|
60
|
+
} as const;
|
|
61
|
+
|
|
52
62
|
// Internal capabilities (DR-004 §10). Each ships with its final
|
|
53
63
|
// signature; behavior lands in the per-capability task noted by the
|
|
54
64
|
// TODO marker.
|
|
@@ -56,9 +66,22 @@ export type CodePlaybookOptions = CodingInput;
|
|
|
56
66
|
// Player-prompt composer — DR-004 §6.
|
|
57
67
|
// Substitutes the three placeholder tokens in `input.prompt` (literal
|
|
58
68
|
// string replace, no escaping) and prepends labelled blocks for any
|
|
59
|
-
// populated structured field.
|
|
69
|
+
// populated structured field. When a state resumes from a Boss reply,
|
|
70
|
+
// the continuation preamble and Q/A blocks precede the ordinary
|
|
71
|
+
// labelled blocks. The FSM's prompt body is never re-flowed.
|
|
72
|
+
|
|
60
73
|
function composePlayerPrompt(input: CaptainInput): string {
|
|
61
74
|
const blocks: string[] = [];
|
|
75
|
+
if (
|
|
76
|
+
input.pendingBossQuestion !== undefined &&
|
|
77
|
+
input.bossReply !== undefined
|
|
78
|
+
) {
|
|
79
|
+
blocks.push(
|
|
80
|
+
'You previously paused this task to ask Boss a question; Boss has now replied. Continue the same task using the reply below.',
|
|
81
|
+
);
|
|
82
|
+
blocks.push(`Boss question:\n${input.pendingBossQuestion.question}`);
|
|
83
|
+
blocks.push(`Boss reply:\n${input.bossReply}`);
|
|
84
|
+
}
|
|
62
85
|
if (input.intent !== undefined) {
|
|
63
86
|
blocks.push(`Boss intent:\n${input.intent}`);
|
|
64
87
|
}
|
|
@@ -153,6 +176,9 @@ async function adjudicate(
|
|
|
153
176
|
// the judge response.
|
|
154
177
|
for (const field of extractRequiredFields(input.result[guard])) {
|
|
155
178
|
if (typeof obj[field] !== 'string') {
|
|
179
|
+
if (guard === 'needsBossReply' && field === 'question') {
|
|
180
|
+
throw new Error(BOSS_REPLY_ERRORS.missingQuestion);
|
|
181
|
+
}
|
|
156
182
|
throw new Error(
|
|
157
183
|
`adjudicate: judge response missing required field "${field}" for guard "${guard}"`,
|
|
158
184
|
);
|
|
@@ -204,46 +230,22 @@ function parseJudgeJson(raw: string): unknown {
|
|
|
204
230
|
}
|
|
205
231
|
|
|
206
232
|
// Boss-event classifier — DR-004 §3.
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
233
|
+
// Every non-empty Boss turn goes through ports.callJudge. Slash-prefixed
|
|
234
|
+
// text is ordinary content once a host has routed the turn to this
|
|
235
|
+
// playbook; `/command` selection belongs outside handleBossInput. The
|
|
236
|
+
// classifier is state-aware so awaitBossReply can distinguish a direct
|
|
237
|
+
// answer (BOSS_REPLY) from a fresh directive that abandons the pending
|
|
238
|
+
// question through the FSM's existing transitions.
|
|
213
239
|
async function classifyBossText(
|
|
214
240
|
text: string,
|
|
215
241
|
ports: PlaybookPorts,
|
|
216
242
|
signal: AbortSignal,
|
|
243
|
+
snapshotOrState?: unknown,
|
|
217
244
|
): Promise<CodingEvent | undefined> {
|
|
218
245
|
const trimmed = text.trim();
|
|
219
246
|
if (trimmed === '') return undefined;
|
|
220
247
|
|
|
221
|
-
|
|
222
|
-
return { type: 'START_CODING', intent: trimmed.slice('/start'.length).trim() };
|
|
223
|
-
}
|
|
224
|
-
if (trimmed === '/continue' || trimmed.startsWith('/continue ')) {
|
|
225
|
-
return {
|
|
226
|
-
type: 'CONTINUE_IR',
|
|
227
|
-
irNumber: trimmed.slice('/continue'.length).trim(),
|
|
228
|
-
};
|
|
229
|
-
}
|
|
230
|
-
if (trimmed === '/summarize' || trimmed.startsWith('/summarize ')) {
|
|
231
|
-
return {
|
|
232
|
-
type: 'SUMMARIZE_IR',
|
|
233
|
-
irNumber: trimmed.slice('/summarize'.length).trim(),
|
|
234
|
-
};
|
|
235
|
-
}
|
|
236
|
-
if (trimmed === '/interrupt' || trimmed.startsWith('/interrupt ')) {
|
|
237
|
-
return parseInterruptSlash(trimmed, ports);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
if (trimmed.startsWith('/')) {
|
|
241
|
-
const cmd = trimmed.split(/\s+/)[0];
|
|
242
|
-
await ports.emitStatus(`Unknown slash command: ${cmd}`);
|
|
243
|
-
return undefined;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
return classifyWithLlm(trimmed, ports, signal);
|
|
248
|
+
return classifyWithLlm(text, ports, signal, snapshotOrState);
|
|
247
249
|
}
|
|
248
250
|
|
|
249
251
|
// JumpableStateId is internal to code.fsm.ts (not exported), so
|
|
@@ -253,31 +255,20 @@ type JumpableStateId = Extract<
|
|
|
253
255
|
{ type: 'BOSS_INTERRUPT' }
|
|
254
256
|
>['targetId'];
|
|
255
257
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
)
|
|
260
|
-
|
|
261
|
-
if (rest === '') {
|
|
262
|
-
await ports.emitStatus('/interrupt requires a stateId');
|
|
263
|
-
return undefined;
|
|
264
|
-
}
|
|
265
|
-
const firstSpace = rest.search(/\s/);
|
|
266
|
-
const targetId = firstSpace === -1 ? rest : rest.slice(0, firstSpace);
|
|
267
|
-
const intent = firstSpace === -1 ? '' : rest.slice(firstSpace).trim();
|
|
268
|
-
return {
|
|
269
|
-
type: 'BOSS_INTERRUPT',
|
|
270
|
-
targetId: targetId as JumpableStateId,
|
|
271
|
-
...(intent ? { intent } : {}),
|
|
272
|
-
};
|
|
273
|
-
}
|
|
258
|
+
const rootEvents = enumerateRootEvents(codingMachine);
|
|
259
|
+
const bossInterruptTargets = rootEvents.bossInterruptTargetDescriptions;
|
|
260
|
+
const bossInterruptTargetIds: ReadonlySet<string> = new Set(
|
|
261
|
+
bossInterruptTargets.map((target) => target.stateId),
|
|
262
|
+
);
|
|
274
263
|
|
|
275
264
|
async function classifyWithLlm(
|
|
276
265
|
text: string,
|
|
277
266
|
ports: PlaybookPorts,
|
|
278
267
|
signal: AbortSignal,
|
|
268
|
+
snapshotOrState?: unknown,
|
|
279
269
|
): Promise<CodingEvent | undefined> {
|
|
280
|
-
const
|
|
270
|
+
const state = classifierState(snapshotOrState);
|
|
271
|
+
const prompt = buildClassifierPrompt(text, state);
|
|
281
272
|
const raw = await ports.callJudge(prompt, signal);
|
|
282
273
|
const parsed = parseJudgeJson(raw);
|
|
283
274
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
@@ -298,6 +289,10 @@ async function classifyWithLlm(
|
|
|
298
289
|
: {};
|
|
299
290
|
|
|
300
291
|
switch (eventType) {
|
|
292
|
+
case 'NO_ACTION':
|
|
293
|
+
case 'NO_FSM_ACTION':
|
|
294
|
+
case 'NONE':
|
|
295
|
+
return undefined;
|
|
301
296
|
case 'START_CODING': {
|
|
302
297
|
if (typeof payload.intent !== 'string') {
|
|
303
298
|
await ports.emitStatus('Classifier omitted intent for START_CODING');
|
|
@@ -326,6 +321,12 @@ async function classifyWithLlm(
|
|
|
326
321
|
);
|
|
327
322
|
return undefined;
|
|
328
323
|
}
|
|
324
|
+
if (!bossInterruptTargetIds.has(payload.targetId)) {
|
|
325
|
+
await ports.emitStatus(
|
|
326
|
+
`Classifier supplied invalid targetId for BOSS_INTERRUPT: ${payload.targetId}`,
|
|
327
|
+
);
|
|
328
|
+
return undefined;
|
|
329
|
+
}
|
|
329
330
|
return {
|
|
330
331
|
type: 'BOSS_INTERRUPT',
|
|
331
332
|
targetId: payload.targetId as JumpableStateId,
|
|
@@ -337,6 +338,19 @@ async function classifyWithLlm(
|
|
|
337
338
|
: {}),
|
|
338
339
|
};
|
|
339
340
|
}
|
|
341
|
+
case 'BOSS_REPLY': {
|
|
342
|
+
if (state.value !== 'awaitBossReply') {
|
|
343
|
+
await ports.emitStatus(
|
|
344
|
+
'Classifier returned BOSS_REPLY outside awaitBossReply',
|
|
345
|
+
);
|
|
346
|
+
return undefined;
|
|
347
|
+
}
|
|
348
|
+
if (typeof payload.answer !== 'string') {
|
|
349
|
+
await ports.emitStatus('Classifier omitted answer for BOSS_REPLY');
|
|
350
|
+
return undefined;
|
|
351
|
+
}
|
|
352
|
+
return { type: 'BOSS_REPLY', answer: payload.answer };
|
|
353
|
+
}
|
|
340
354
|
default:
|
|
341
355
|
await ports.emitStatus(
|
|
342
356
|
`Classifier returned unknown event type: ${eventType}`,
|
|
@@ -345,22 +359,76 @@ async function classifyWithLlm(
|
|
|
345
359
|
}
|
|
346
360
|
}
|
|
347
361
|
|
|
348
|
-
|
|
349
|
-
|
|
362
|
+
interface ClassifierState {
|
|
363
|
+
value: unknown;
|
|
364
|
+
context: Record<string, unknown>;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function classifierState(snapshotOrState: unknown): ClassifierState {
|
|
368
|
+
if (
|
|
369
|
+
snapshotOrState !== null &&
|
|
370
|
+
typeof snapshotOrState === 'object' &&
|
|
371
|
+
'value' in snapshotOrState
|
|
372
|
+
) {
|
|
373
|
+
const candidate = snapshotOrState as {
|
|
374
|
+
value?: unknown;
|
|
375
|
+
context?: unknown;
|
|
376
|
+
};
|
|
377
|
+
return {
|
|
378
|
+
value: candidate.value,
|
|
379
|
+
context:
|
|
380
|
+
candidate.context !== null &&
|
|
381
|
+
typeof candidate.context === 'object' &&
|
|
382
|
+
!Array.isArray(candidate.context)
|
|
383
|
+
? (candidate.context as Record<string, unknown>)
|
|
384
|
+
: {},
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
return { value: snapshotOrState, context: {} };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function buildClassifierPrompt(text: string, state: ClassifierState): string {
|
|
391
|
+
const currentState =
|
|
392
|
+
typeof state.value === 'string' ? state.value : 'unknown';
|
|
393
|
+
const pendingBossQuestion = pendingBossQuestionFromContext(state.context);
|
|
394
|
+
const lines = [
|
|
350
395
|
'Classify the following Boss message into exactly one of these events.',
|
|
351
396
|
'Respond with JSON: { "event": "<TYPE>", "payload": { ...fields } }.',
|
|
397
|
+
'Use { "event": "NO_ACTION", "payload": {} } when no FSM action should be taken.',
|
|
398
|
+
'',
|
|
399
|
+
`Current state: ${currentState}`,
|
|
400
|
+
];
|
|
401
|
+
if (pendingBossQuestion !== undefined) {
|
|
402
|
+
lines.push(
|
|
403
|
+
`Pending Boss question: ${pendingBossQuestion.question}`,
|
|
404
|
+
`Pending resume state: ${pendingBossQuestion.resumeStateId}`,
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
lines.push(
|
|
352
408
|
'',
|
|
353
409
|
'Events:',
|
|
354
410
|
'- START_CODING: payload { intent: "<free-form goal>" }',
|
|
355
411
|
'- CONTINUE_IR: payload { irNumber: "<number>" }',
|
|
356
412
|
'- SUMMARIZE_IR: payload { irNumber: "<number>" }',
|
|
357
413
|
'- BOSS_INTERRUPT: payload { targetId: "<stateId>", intent?: "<free-form goal>", irNumber?: "<number>" }',
|
|
414
|
+
' targetId must be one of these jumpable states:',
|
|
415
|
+
);
|
|
416
|
+
for (const target of bossInterruptTargets) {
|
|
417
|
+
lines.push(` - ${target.stateId}: ${target.description}`);
|
|
418
|
+
}
|
|
419
|
+
if (currentState === 'awaitBossReply') {
|
|
420
|
+
lines.push('- BOSS_REPLY: payload { answer: "<verbatim Boss answer>" }');
|
|
421
|
+
} else {
|
|
422
|
+
lines.push('- BOSS_REPLY: valid only when Current state is awaitBossReply');
|
|
423
|
+
}
|
|
424
|
+
lines.push(
|
|
358
425
|
'',
|
|
359
426
|
'Boss message:',
|
|
360
427
|
'```',
|
|
361
428
|
text,
|
|
362
429
|
'```',
|
|
363
|
-
|
|
430
|
+
);
|
|
431
|
+
return lines.join('\n');
|
|
364
432
|
}
|
|
365
433
|
|
|
366
434
|
// Captain-actor bridge — DR-004 §7. One PromiseActorLogic that the
|
|
@@ -397,7 +465,14 @@ function captainBridge(
|
|
|
397
465
|
'captainBridge: callPlayer returned status=ok with no finalText',
|
|
398
466
|
);
|
|
399
467
|
}
|
|
400
|
-
|
|
468
|
+
const output = await adjudicate(
|
|
469
|
+
input,
|
|
470
|
+
result.finalText,
|
|
471
|
+
ports,
|
|
472
|
+
activeSignal,
|
|
473
|
+
);
|
|
474
|
+
validateBossReplyOutput(input, output);
|
|
475
|
+
return output;
|
|
401
476
|
},
|
|
402
477
|
);
|
|
403
478
|
}
|
|
@@ -405,7 +480,7 @@ function captainBridge(
|
|
|
405
480
|
// Captain pane display — PBRT-3 / PBRT-14.
|
|
406
481
|
// The Captain pane is a stream keyed on four glyphs so a reader can
|
|
407
482
|
// parse each line at a glance:
|
|
408
|
-
// ◆
|
|
483
|
+
// ◆ basic idle entry (ready / done / failed)
|
|
409
484
|
// ▸ Boss input echo
|
|
410
485
|
// ⮕ captain-invoking state entry (label + player + CODE-N)
|
|
411
486
|
// ⤷ transition (guard fired by the just-finished captain call)
|
|
@@ -458,23 +533,109 @@ const stateMetadata: ReadonlyMap<string, StateMetadata> = (() => {
|
|
|
458
533
|
return m;
|
|
459
534
|
})();
|
|
460
535
|
|
|
461
|
-
const
|
|
536
|
+
const stateIdBySourceItem: ReadonlyMap<string, string> = new Map(
|
|
537
|
+
[...stateMetadata.entries()].map(([stateId, meta]) => [
|
|
538
|
+
meta.sourceItem,
|
|
539
|
+
stateId,
|
|
540
|
+
]),
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
const registeredResumableStateIds: ReadonlySet<string> = new Set(
|
|
544
|
+
enumerateAwaitBossReply(codingMachine).bossReplyTransitions.map(
|
|
545
|
+
(transition) => transition.target,
|
|
546
|
+
),
|
|
547
|
+
);
|
|
548
|
+
|
|
549
|
+
function validateBossReplyOutput(
|
|
550
|
+
input: CaptainInput,
|
|
551
|
+
output: CaptainOutput,
|
|
552
|
+
): void {
|
|
553
|
+
if (output.guard !== 'needsBossReply') return;
|
|
554
|
+
if (typeof output.question !== 'string') {
|
|
555
|
+
throw new Error(BOSS_REPLY_ERRORS.missingQuestion);
|
|
556
|
+
}
|
|
557
|
+
const stateId = stateIdBySourceItem.get(input.sourceItem);
|
|
558
|
+
if (stateId === undefined || !registeredResumableStateIds.has(stateId)) {
|
|
559
|
+
throw new Error(
|
|
560
|
+
BOSS_REPLY_ERRORS.unregisteredState(stateId ?? input.sourceItem),
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const QUIESCENT_STATES: ReadonlySet<string> = new Set([
|
|
462
566
|
'ready',
|
|
567
|
+
'awaitBossReply',
|
|
463
568
|
'done',
|
|
464
569
|
'failed',
|
|
465
570
|
]);
|
|
466
571
|
|
|
572
|
+
// awaitBossReply is quiescent too, but uses a custom single-line
|
|
573
|
+
// status frame rather than the plain `◆ <state>` entry.
|
|
574
|
+
const BASIC_IDLE_GLYPH_STATES: ReadonlySet<string> = new Set(
|
|
575
|
+
[...QUIESCENT_STATES].filter((stateId) => stateId !== 'awaitBossReply'),
|
|
576
|
+
);
|
|
577
|
+
|
|
467
578
|
// Captain-pane surface (PBRT-3): every captain-invoking state plus
|
|
468
|
-
// the
|
|
579
|
+
// the quiescent states. Wider than the prior
|
|
469
580
|
// "Boss-relevant" set per slc/link.md's default "emit on every
|
|
470
581
|
// transition; let the host filter."
|
|
471
582
|
const CAPTAIN_PANE_STATES: ReadonlySet<string> = new Set([
|
|
472
583
|
...stateMetadata.keys(),
|
|
473
|
-
...
|
|
584
|
+
...QUIESCENT_STATES,
|
|
474
585
|
]);
|
|
475
586
|
|
|
476
|
-
|
|
477
|
-
|
|
587
|
+
interface PendingBossQuestionForStatus {
|
|
588
|
+
resumeStateId: string;
|
|
589
|
+
sourceItem: string;
|
|
590
|
+
player: string;
|
|
591
|
+
question: string;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function pendingBossQuestionFromContext(
|
|
595
|
+
context: Record<string, unknown>,
|
|
596
|
+
): PendingBossQuestionForStatus | undefined {
|
|
597
|
+
const pending = context.pendingBossQuestion;
|
|
598
|
+
if (pending === undefined || typeof pending !== 'object') {
|
|
599
|
+
return undefined;
|
|
600
|
+
}
|
|
601
|
+
const candidate = pending as Partial<
|
|
602
|
+
Record<keyof PendingBossQuestionForStatus, unknown>
|
|
603
|
+
>;
|
|
604
|
+
if (
|
|
605
|
+
typeof candidate.resumeStateId !== 'string' ||
|
|
606
|
+
typeof candidate.sourceItem !== 'string' ||
|
|
607
|
+
typeof candidate.player !== 'string' ||
|
|
608
|
+
typeof candidate.question !== 'string'
|
|
609
|
+
) {
|
|
610
|
+
return undefined;
|
|
611
|
+
}
|
|
612
|
+
return {
|
|
613
|
+
resumeStateId: candidate.resumeStateId,
|
|
614
|
+
sourceItem: candidate.sourceItem,
|
|
615
|
+
player: candidate.player,
|
|
616
|
+
question: candidate.question,
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function questionExcerpt(question: string): string {
|
|
621
|
+
return question.replace(/[\r\n]+/g, ' ').slice(0, 80);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function formatAwaitBossReplyEntry(context: Record<string, unknown>): string {
|
|
625
|
+
const pending = pendingBossQuestionFromContext(context);
|
|
626
|
+
const resumeStateId = pending?.resumeStateId ?? 'unknown';
|
|
627
|
+
const player = pending?.player ?? 'unknown';
|
|
628
|
+
const sourceItem = pending?.sourceItem ?? 'unknown';
|
|
629
|
+
const question = questionExcerpt(pending?.question ?? '');
|
|
630
|
+
return `◆ awaiting Boss reply · ${resumeStateId} · ${player} · ${sourceItem} · q=${JSON.stringify(question)}`;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function formatStateEntry(
|
|
634
|
+
stateId: string,
|
|
635
|
+
context: Record<string, unknown> = {},
|
|
636
|
+
): string {
|
|
637
|
+
if (stateId === 'awaitBossReply') return formatAwaitBossReplyEntry(context);
|
|
638
|
+
if (BASIC_IDLE_GLYPH_STATES.has(stateId)) return `◆ ${stateId}`;
|
|
478
639
|
const meta = stateMetadata.get(stateId);
|
|
479
640
|
if (!meta) return `⮕ ${stateId}`;
|
|
480
641
|
return `⮕ ${meta.label} ${meta.player} per ${meta.sourceItem}`;
|
|
@@ -519,6 +680,22 @@ function formatRiders(context: Record<string, unknown>): string {
|
|
|
519
680
|
return parts.length > 0 ? ` ${parts.join(' ')}` : '';
|
|
520
681
|
}
|
|
521
682
|
|
|
683
|
+
function stateTelemetryPayload(
|
|
684
|
+
from: unknown,
|
|
685
|
+
to: string,
|
|
686
|
+
event: unknown,
|
|
687
|
+
context: Record<string, unknown>,
|
|
688
|
+
): Record<string, unknown> {
|
|
689
|
+
const payload: Record<string, unknown> = { from, to, event };
|
|
690
|
+
if (to === 'awaitBossReply') {
|
|
691
|
+
const pendingBossQuestion = pendingBossQuestionFromContext(context);
|
|
692
|
+
if (pendingBossQuestion !== undefined) {
|
|
693
|
+
payload.pendingBossQuestion = pendingBossQuestion;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
return payload;
|
|
697
|
+
}
|
|
698
|
+
|
|
522
699
|
// Internal export surface for tests. Not part of the stable public API;
|
|
523
700
|
// the leading underscore signals "subject to change." Each member is
|
|
524
701
|
// referenced here so `noUnusedLocals` stays clean while later tasks
|
|
@@ -531,10 +708,13 @@ export const _internal = {
|
|
|
531
708
|
captainBridge,
|
|
532
709
|
STATE_LABELS,
|
|
533
710
|
stateMetadata,
|
|
711
|
+
pendingBossQuestionFromContext,
|
|
712
|
+
formatAwaitBossReplyEntry,
|
|
534
713
|
formatStateEntry,
|
|
535
714
|
formatTransition,
|
|
536
715
|
formatBossEcho,
|
|
537
716
|
formatRiders,
|
|
717
|
+
stateTelemetryPayload,
|
|
538
718
|
};
|
|
539
719
|
|
|
540
720
|
export default function createPlaybookRuntime(
|
|
@@ -602,10 +782,16 @@ export default function createPlaybookRuntime(
|
|
|
602
782
|
const from = priorState;
|
|
603
783
|
priorState = to;
|
|
604
784
|
// Telemetry on every transition (PBRT-14).
|
|
785
|
+
const context = snap.context ?? {};
|
|
605
786
|
enqueueEmit(() =>
|
|
606
787
|
ports.emitTelemetry({
|
|
607
788
|
topic: 'playbook.fsm.state',
|
|
608
|
-
payload:
|
|
789
|
+
payload: stateTelemetryPayload(
|
|
790
|
+
from,
|
|
791
|
+
to,
|
|
792
|
+
inspectionEvent.event,
|
|
793
|
+
context,
|
|
794
|
+
),
|
|
609
795
|
}),
|
|
610
796
|
);
|
|
611
797
|
// Captain pane (PBRT-3 / PBRT-14): show the transition
|
|
@@ -618,9 +804,9 @@ export default function createPlaybookRuntime(
|
|
|
618
804
|
if (transitionLine !== undefined) {
|
|
619
805
|
enqueueEmit(() => ports.emitStatus(transitionLine));
|
|
620
806
|
}
|
|
621
|
-
const entryLine = formatStateEntry(to);
|
|
807
|
+
const entryLine = formatStateEntry(to, context);
|
|
622
808
|
const riderSuffix = stateMetadata.has(to)
|
|
623
|
-
? formatRiders(
|
|
809
|
+
? formatRiders(context)
|
|
624
810
|
: '';
|
|
625
811
|
const message = entryLine + riderSuffix;
|
|
626
812
|
if (to === 'failed') {
|
|
@@ -657,10 +843,15 @@ export default function createPlaybookRuntime(
|
|
|
657
843
|
}
|
|
658
844
|
activeSignal = signal;
|
|
659
845
|
try {
|
|
660
|
-
// 1. Classify text into an FSM event
|
|
661
|
-
const event = await classifyBossText(
|
|
662
|
-
|
|
663
|
-
|
|
846
|
+
// 1. Classify non-empty text into an FSM event through the judge.
|
|
847
|
+
const event = await classifyBossText(
|
|
848
|
+
text,
|
|
849
|
+
savedPorts,
|
|
850
|
+
signal,
|
|
851
|
+
actor.getSnapshot(),
|
|
852
|
+
);
|
|
853
|
+
// Empty input, no-action classifier output, or invalid classifier
|
|
854
|
+
// output — nothing to send.
|
|
664
855
|
if (event === undefined) {
|
|
665
856
|
await drainEmissions();
|
|
666
857
|
return;
|
|
@@ -739,5 +930,5 @@ function driveToQuiescence(
|
|
|
739
930
|
|
|
740
931
|
function isQuiescent(snap: { value: unknown }): boolean {
|
|
741
932
|
const v = snap.value;
|
|
742
|
-
return
|
|
933
|
+
return typeof v === 'string' && QUIESCENT_STATES.has(v);
|
|
743
934
|
}
|
|
@@ -15,6 +15,10 @@ captain:
|
|
|
15
15
|
from: ./code.tmux-play.js
|
|
16
16
|
adapter: claude
|
|
17
17
|
model: claude-opus-4-7
|
|
18
|
+
# Agents run in cligent's classifier/reviewer-protected auto mode
|
|
19
|
+
# (cligent DR-005): claude → permissionMode auto, codex → auto_review.
|
|
20
|
+
permissions:
|
|
21
|
+
mode: auto
|
|
18
22
|
options:
|
|
19
23
|
# Substituted into the <coder-llm> placeholder in player prompts
|
|
20
24
|
# (DR-004 §6).
|
|
@@ -29,5 +33,9 @@ captain:
|
|
|
29
33
|
roles:
|
|
30
34
|
- id: coder
|
|
31
35
|
adapter: claude
|
|
36
|
+
permissions:
|
|
37
|
+
mode: auto
|
|
32
38
|
- id: reviewer
|
|
33
39
|
adapter: codex
|
|
40
|
+
permissions:
|
|
41
|
+
mode: auto
|
|
@@ -10,6 +10,8 @@ captain:
|
|
|
10
10
|
from: "@sublang/playbook/code/tmux-play"
|
|
11
11
|
adapter: claude
|
|
12
12
|
model: claude-opus-4-7
|
|
13
|
+
permissions:
|
|
14
|
+
mode: auto
|
|
13
15
|
options:
|
|
14
16
|
coderPlayer: claude
|
|
15
17
|
reviewerPlayer: codex
|
|
@@ -17,5 +19,9 @@ captain:
|
|
|
17
19
|
roles:
|
|
18
20
|
- id: coder
|
|
19
21
|
adapter: claude
|
|
22
|
+
permissions:
|
|
23
|
+
mode: auto
|
|
20
24
|
- id: reviewer
|
|
21
25
|
adapter: codex
|
|
26
|
+
permissions:
|
|
27
|
+
mode: auto
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|