@sublang/playbook 5.0.0 → 6.0.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.
Files changed (65) hide show
  1. package/README.md +11 -7
  2. package/docs/cli.md +38 -35
  3. package/docs/configuration.md +58 -15
  4. package/docs/embedding.md +24 -16
  5. package/package.json +40 -21
  6. package/reference/sdlc/captain.playbook/captain.playbook.js +2 -0
  7. package/reference/sdlc/captain.playbook/captain.playbook.ts +2 -0
  8. package/reference/sdlc/code.md +55 -97
  9. package/reference/sdlc/code.playbook/code.fsm.d.ts +229 -94
  10. package/reference/sdlc/code.playbook/code.fsm.introspect.d.ts +26 -44
  11. package/reference/sdlc/code.playbook/code.fsm.introspect.js +61 -66
  12. package/reference/sdlc/code.playbook/code.fsm.introspect.ts +100 -149
  13. package/reference/sdlc/code.playbook/code.fsm.js +587 -1347
  14. package/reference/sdlc/code.playbook/code.fsm.ts +809 -1650
  15. package/reference/sdlc/code.playbook/code.gears.md +51 -263
  16. package/reference/sdlc/code.playbook/code.playbook.d.ts +8 -47
  17. package/reference/sdlc/code.playbook/code.playbook.js +69 -656
  18. package/reference/sdlc/code.playbook/code.playbook.ts +90 -867
  19. package/reference/sdlc/code.playbook/code.registry.d.ts +9 -25
  20. package/reference/sdlc/code.playbook/code.registry.js +20 -78
  21. package/reference/sdlc/code.playbook/code.registry.ts +58 -122
  22. package/reference/sdlc/code.playbook/playbook-captain.js +93 -15
  23. package/reference/sdlc/code.playbook/playbook-captain.ts +115 -19
  24. package/reference/sdlc/code.playbook/playbook.config.template.yaml +33 -22
  25. package/reference/sdlc/decide.md +54 -0
  26. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +261 -0
  27. package/reference/sdlc/decide.playbook/decide.fsm.js +894 -0
  28. package/reference/sdlc/decide.playbook/decide.fsm.ts +1152 -0
  29. package/reference/sdlc/decide.playbook/decide.gears.md +88 -0
  30. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +67 -0
  31. package/reference/sdlc/{discuss.playbook/discuss.playbook.js → decide.playbook/decide.playbook.js} +471 -362
  32. package/reference/sdlc/{discuss.playbook/discuss.playbook.ts → decide.playbook/decide.playbook.ts} +575 -443
  33. package/reference/sdlc/decide.playbook/decide.registry.d.ts +41 -0
  34. package/reference/sdlc/decide.playbook/decide.registry.js +60 -0
  35. package/reference/sdlc/decide.playbook/decide.registry.ts +125 -0
  36. package/reference/sdlc/review.md +81 -0
  37. package/reference/sdlc/review.playbook/review.fsm.d.ts +183 -0
  38. package/reference/sdlc/review.playbook/review.fsm.js +524 -0
  39. package/reference/sdlc/review.playbook/review.fsm.ts +652 -0
  40. package/reference/sdlc/review.playbook/review.gears.md +112 -0
  41. package/reference/sdlc/review.playbook/review.playbook.d.ts +12 -0
  42. package/reference/sdlc/review.playbook/review.playbook.js +112 -0
  43. package/reference/sdlc/review.playbook/review.playbook.ts +201 -0
  44. package/reference/sdlc/review.playbook/review.registry.d.ts +43 -0
  45. package/reference/sdlc/review.playbook/review.registry.js +73 -0
  46. package/reference/sdlc/review.playbook/review.registry.ts +138 -0
  47. package/slc/gears2fsm.md +13 -4
  48. package/slc/link.md +48 -2
  49. package/slc/text2gears.md +22 -2
  50. package/src/runtime.d.ts +7 -0
  51. package/src/runtime.ts +12 -0
  52. package/src/xstate-playbook-runtime.d.ts +9 -2
  53. package/src/xstate-playbook-runtime.js +255 -21
  54. package/src/xstate-playbook-runtime.ts +333 -28
  55. package/src/xstate-runtime.js +25 -0
  56. package/src/xstate-runtime.ts +51 -0
  57. package/reference/sdlc/discuss.md +0 -93
  58. package/reference/sdlc/discuss.playbook/discuss.fsm.d.ts +0 -396
  59. package/reference/sdlc/discuss.playbook/discuss.fsm.js +0 -2067
  60. package/reference/sdlc/discuss.playbook/discuss.fsm.ts +0 -2465
  61. package/reference/sdlc/discuss.playbook/discuss.gears.md +0 -258
  62. package/reference/sdlc/discuss.playbook/discuss.playbook.d.ts +0 -113
  63. package/reference/sdlc/discuss.playbook/discuss.registry.d.ts +0 -58
  64. package/reference/sdlc/discuss.playbook/discuss.registry.js +0 -97
  65. package/reference/sdlc/discuss.playbook/discuss.registry.ts +0 -153
@@ -3,28 +3,21 @@
3
3
  //
4
4
  // Generated by slc/link.md (FSM-to-Runtime linker).
5
5
  // Source FSM: ./code.fsm.ts
6
- // Player bind: Coder→coder, Reviewer→reviewer,
7
- // Committer→ configured committerPlayer alias when set
8
- // (per-run remapping via options PBRT-8); else the
9
- // baked CODE-18/19 fallback (coder-first: CODE-19 wires
10
- // both coderPlayer and reviewerPlayer, Coder wins as the
11
- // alias's first alternative)
12
- // Boss event: free-text judge classification
13
- // Adjudication: LLM-judge per state
14
- // Contract: PlayerResult / PlaybookPorts / PlaybookSession /
15
- // PlaybookRuntime imported and re-exported from
16
- // @sublang/playbook/runtime
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.
24
- import { createPlayerBridge, createXStatePlaybookRuntime, adjudicatePlayerOutput, normalizeError, normalizeErrorCompact, normalizeErrorFull, parseJudgeJson, snapshotJsonValue, } from '../../../src/xstate-runtime.js';
6
+ // Player bind: Coder→coder (default lowercased binding)
7
+ // Boss event: deterministic START_CODE entry; exact Boss text becomes
8
+ // callerInput; pending Coder questions retain BOSS_REPLY
9
+ // Adjudication: LLM judge per player state; coderOutput is carried verbatim
10
+ // Nested call: literal review target through the shared bridge
11
+ // Compat: artifact schema 1 / runtime ABI 1
12
+ import { RUNTIME_ABI, createXStatePlaybookRuntime, snapshotJsonValue, } from '@sublang/playbook/xstate-runtime';
25
13
  import { codingMachine, } from './code.fsm.js';
26
- import { enumerateAwaitBossReply, enumerateCaptainStates, enumerateRootEvents, } from './code.fsm.introspect.js';
27
- function snapshotCodePlaybookOptions(value) {
14
+ const OPTION_KEYS = new Set(['coderPlayer', 'runResults']);
15
+ const PLACEHOLDER = /<(#|[A-Za-z_$][A-Za-z0-9_$-]*)>/g;
16
+ const CONTINUATION_PREAMBLE = 'You previously paused this task to ask Boss a question; Boss has now replied. Continue the same task using the reply below.';
17
+ const VERBATIM_PAYLOAD_FIELDS = new Set([
18
+ 'coderOutput',
19
+ ]);
20
+ function snapshotCodeOptions(value) {
28
21
  const captured = snapshotJsonValue(value, 'CODE runtime options');
29
22
  if (captured === null ||
30
23
  typeof captured !== 'object' ||
@@ -32,15 +25,8 @@ function snapshotCodePlaybookOptions(value) {
32
25
  throw new TypeError('CODE runtime options must be an object');
33
26
  }
34
27
  const record = captured;
35
- const allowed = new Set([
36
- 'intent',
37
- 'irNumber',
38
- 'coderPlayer',
39
- 'reviewerPlayer',
40
- 'committerPlayer',
41
- ]);
42
28
  for (const [key, option] of Object.entries(record)) {
43
- if (!allowed.has(key)) {
29
+ if (!OPTION_KEYS.has(key)) {
44
30
  throw new TypeError(`CODE runtime options.${key} is not declared`);
45
31
  }
46
32
  if (typeof option !== 'string') {
@@ -49,643 +35,70 @@ function snapshotCodePlaybookOptions(value) {
49
35
  }
50
36
  return captured;
51
37
  }
52
- // Required-payload fields whose value is the player's verbatim long-form
53
- // prose. The runtime carries `finalText.trim()` into these fields rather
54
- // than asking the judge to round-trip the text through JSON. Short
55
- // extracted fields (`question`, `taskDescription`, `irNumber`, …) stay
56
- // judge-extracted — they are not in this set.
57
- const VERBATIM_PAYLOAD_FIELDS = new Set([
58
- 'reviews',
59
- 'challenges',
60
- ]);
61
- // Normalize any `error` field inside a telemetry event so failed
62
- // transitions don't leak raw Error instances through the channel.
63
- function normalizeEventForTelemetry(event) {
64
- if (event === undefined)
65
- return undefined;
66
- return normalizeEventValue(event, 'FSM event', new Set());
67
- }
68
- function normalizeEventValue(value, path, ancestors) {
69
- if (Array.isArray(value))
70
- return snapshotJsonValue(value, path);
71
- if (value === null || typeof value !== 'object') {
72
- return snapshotJsonValue(value, path);
73
- }
74
- if (ancestors.has(value)) {
75
- throw new TypeError(`${path} must not contain a JSON cycle`);
76
- }
77
- const prototype = Object.getPrototypeOf(value);
78
- if (prototype !== Object.prototype && prototype !== null) {
79
- return snapshotJsonValue(value, path);
80
- }
81
- if (Object.getOwnPropertySymbols(value).length > 0) {
82
- return snapshotJsonValue(value, path);
83
- }
84
- const nextAncestors = new Set(ancestors).add(value);
85
- const normalized = {};
86
- for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(value))) {
87
- if (!descriptor.enumerable) {
88
- throw new TypeError(`${path}.${key} must be an enumerable JSON property`);
89
- }
90
- if (!Object.prototype.hasOwnProperty.call(descriptor, 'value')) {
91
- throw new TypeError(`${path}.${key} must be a JSON data property`);
92
- }
93
- if (descriptor.value === undefined)
94
- continue;
95
- normalized[key] =
96
- key === 'error'
97
- ? snapshotJsonValue(normalizeError(descriptor.value), `${path}.error`)
98
- : normalizeEventValue(descriptor.value, `${path}.${key}`, nextAncestors);
99
- }
100
- return snapshotJsonValue(normalized, path);
101
- }
102
- // Player-prompt composer — DR-004 §6.
103
- // Substitutes the three placeholder tokens in `input.prompt` (literal
104
- // string replace, no escaping) and arranges labelled blocks around
105
- // the prompt body. Context blocks the body refers to as prior
106
- // material (`Boss intent:`, `Task description:`) are prepended;
107
- // action blocks the body refers to as material below
108
- // (`Review items:`, `Rebuttals:`) are appended after the body so the
109
- // CODE-N prompts' "review item below" / "rebuttal below" phrasing
110
- // matches the rendered layout. When a state resumes from a Boss
111
- // reply, the continuation preamble and Q/A blocks precede every
112
- // ordinary block. The FSM's prompt body is never re-flowed.
38
+ function placeholderField(token) {
39
+ if (token === '#')
40
+ return 'irNumber';
41
+ if (token === 'coder-llm')
42
+ return 'coderPlayer';
43
+ return token.replace(/-([A-Za-z0-9_$])/g, (_match, next) => next.toUpperCase());
44
+ }
45
+ function quoteContinuation(value) {
46
+ return value.replaceAll('\n', '\n> ');
47
+ }
48
+ /**
49
+ * Preserve authored Markdown quote markers around every line of relayed
50
+ * runtime text. The generic composer preserves the marker itself; CODE's
51
+ * override additionally keeps a multiline value inside that quote.
52
+ */
113
53
  function composePlayerPrompt(input) {
114
- const blocks = [];
115
- if (input.pendingBossQuestion !== undefined &&
116
- input.bossReply !== undefined) {
117
- blocks.push('You previously paused this task to ask Boss a question; Boss has now replied. Continue the same task using the reply below.');
118
- blocks.push(`Boss question:\n${input.pendingBossQuestion.question}`);
119
- blocks.push(`Boss reply:\n${input.bossReply}`);
120
- }
121
- if (input.intent !== undefined) {
122
- blocks.push(`Boss intent:\n${input.intent}`);
123
- }
124
- if (input.taskDescription !== undefined) {
125
- blocks.push(`Task description:\n${input.taskDescription}`);
126
- }
127
- let body = input.prompt;
128
- if (input.irNumber !== undefined) {
129
- body = body.replaceAll('<#>', input.irNumber);
130
- }
131
- if (input.coderPlayer !== undefined) {
132
- body = body.replaceAll('<coder-llm>', input.coderPlayer);
133
- }
134
- if (input.reviewerPlayer !== undefined) {
135
- body = body.replaceAll('<reviewer-llm>', input.reviewerPlayer);
136
- }
137
- blocks.push(body);
138
- if (input.reviews !== undefined) {
139
- blocks.push(`Review items:\n${input.reviews}`);
140
- }
141
- if (input.challenges !== undefined) {
142
- blocks.push(`Rebuttals:\n${input.challenges}`);
143
- }
144
- return blocks.join('\n\n');
145
- }
146
- // Player-id resolver — DR-004 §2 / PBRT-8.
147
- // Non-composite: Coder→'coder', Reviewer→'reviewer'. The composite
148
- // Committer (= Coder | Reviewer per code.gears.md) resolves to the
149
- // configured alias when present: `input.committerPlayer`, the
150
- // validated `captain.options.playbooks.code.options.committer`
151
- // (PBRT-8 / PBRT-30), already a baked player id ('coder' / 'reviewer').
152
- // Absent a
153
- // configured alias it falls back to the DR-004 §2 baked binding by
154
- // populated <playerName>Player field: prefer `coderPlayer` (CODE-18
155
- // wires only coderPlayer; CODE-19 wires both so coderPlayer still wins
156
- // as the alias's first alternative), fall back to `reviewerPlayer`
157
- // only if a future gear ships a reviewer-only Committer item, and
158
- // finally to the alias's first alternative (Coder) when neither is
159
- // set. The alias selects only the host pane; it is not a PBRT-4
160
- // identity string, so it leaves <coder-llm> / <reviewer-llm>
161
- // untouched and `input.player` stays `Committer` (PLAYBOOK-3).
162
- function resolvePlayerId(input) {
163
- switch (input.player) {
164
- case 'Coder':
165
- return 'coder';
166
- case 'Reviewer':
167
- return 'reviewer';
168
- case 'Committer':
169
- if (input.committerPlayer !== undefined)
170
- return input.committerPlayer;
171
- if (input.coderPlayer !== undefined)
172
- return 'coder';
173
- if (input.reviewerPlayer !== undefined)
174
- return 'reviewer';
175
- return 'coder';
176
- default: {
177
- const exhaustive = input.player;
178
- throw new Error(`resolvePlayerId: unknown player ${String(exhaustive)}`);
179
- }
180
- }
181
- }
182
- function extractRequiredFields(description) {
183
- const fields = [];
184
- const re = /Output shall include `([A-Za-z_][A-Za-z0-9_]*):/g;
185
- for (const m of description.matchAll(re)) {
186
- fields.push(m[1]);
187
- }
188
- return fields;
189
- }
190
- function buildJudgePrompt(input, finalText) {
191
- const lines = [];
192
- lines.push('This is hidden control work. Do not call tools, inspect files, or ' +
193
- 'seek external evidence. Decide only from the supplied player output ' +
194
- 'and outcome descriptions. Reply with exactly one JSON object and no prose.');
195
- lines.push('');
196
- lines.push(`The ${input.player} just produced this output:`);
197
- lines.push('');
198
- lines.push('```');
199
- lines.push(finalText);
200
- lines.push('```');
201
- lines.push('');
202
- lines.push('Pick exactly one outcome by `guard` and return JSON ' +
203
- '`{ guard, …payloadFields }`. Required payload fields are ' +
204
- 'named in the outcome description. Do not copy long-form ' +
205
- 'verbatim fields (`reviews`, `challenges`) — the runtime ' +
206
- "carries the player's output into those fields verbatim, " +
207
- 'so any value you supply for them will be overwritten.');
208
- lines.push('');
209
- for (const [key, description] of Object.entries(input.result)) {
210
- lines.push(`- \`${key}\` — ${description}`);
211
- }
212
- return lines.join('\n');
213
- }
214
- // CODE-specific adjudication strategy: the CODE judge prompt above, the
215
- // DR-004 `Output shall include` required-field extraction, and the
216
- // verbatim long-form payload fields.
217
- const CODE_ADJUDICATION = {
218
- buildJudgePrompt: (input, finalText) => buildJudgePrompt(input, finalText),
219
- extractRequiredFields,
220
- verbatimPayloadFields: VERBATIM_PAYLOAD_FIELDS,
221
- };
222
- // LLM judge — DR-004 §4. Delegates to the shared adjudicator with the
223
- // CODE strategy: it lists each declared outcome verbatim, asks
224
- // ports.callJudge for a JSON `{ guard, …payloadFields }` response, and
225
- // returns the parsed object once the chosen guard is one of the
226
- // input.result keys. Adjudicator failures (malformed JSON,
227
- // missing/unknown guard) are control-plane errors and propagate via
228
- // throw per slc/link.md.
229
- async function adjudicate(input, finalText, ports, signal, boundary) {
230
- return (await adjudicatePlayerOutput(CODE_ADJUDICATION, input, finalText, ports, signal, boundary));
231
- }
232
- // Boss-event classifier — DR-004 §3.
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.
239
- async function classifyBossText(text, ports, signal, snapshotOrState, boundary) {
240
- const trimmed = text.trim();
241
- if (trimmed === '')
242
- return undefined;
243
- return classifyWithLlm(text, ports, signal, snapshotOrState, boundary);
244
- }
245
- const rootEvents = enumerateRootEvents(codingMachine);
246
- const bossInterruptTargets = rootEvents.bossInterruptTargetDescriptions;
247
- const bossInterruptTargetIds = new Set(bossInterruptTargets.map((target) => target.stateId));
248
- async function classifyWithLlm(text, ports, signal, snapshotOrState, boundary) {
249
- const state = classifierState(snapshotOrState);
250
- const prompt = buildClassifierPrompt(text, state);
251
- const stateId = typeof state.value === 'string' ? state.value : undefined;
252
- const raw = boundary
253
- ? await boundary.callJudge('boss-input-classification', stateId, prompt, signal)
254
- : await ports.callJudge(prompt, signal);
255
- let parsed;
256
- try {
257
- parsed = parseJudgeJson(raw);
258
- }
259
- catch {
260
- // No JSON value could be recovered. Classification failures are
261
- // non-fatal — unlike adjudication, which throws to the failure
262
- // state (PBRT-10) — so surface one status and take no FSM action,
263
- // consistent with the other invalid-reply paths below (PBRT-7).
264
- await ports.emitStatus('Classifier reply was not recoverable JSON');
265
- return undefined;
266
- }
267
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
268
- await ports.emitStatus('Classifier returned a non-object JSON response');
269
- return undefined;
270
- }
271
- const obj = parsed;
272
- const eventType = obj.event;
273
- if (typeof eventType !== 'string') {
274
- await ports.emitStatus('Classifier did not name an event type');
275
- return undefined;
276
- }
277
- const payload = typeof obj.payload === 'object' &&
278
- obj.payload !== null &&
279
- !Array.isArray(obj.payload)
280
- ? obj.payload
281
- : {};
282
- switch (eventType) {
283
- case 'NO_ACTION':
284
- case 'NO_FSM_ACTION':
285
- case 'NONE':
286
- return undefined;
287
- case 'START_CODING': {
288
- if (typeof payload.intent !== 'string') {
289
- await ports.emitStatus('Classifier omitted intent for START_CODING');
290
- return undefined;
291
- }
292
- return { type: 'START_CODING', intent: payload.intent };
293
- }
294
- case 'CONTINUE_IR': {
295
- if (typeof payload.irNumber !== 'string') {
296
- await ports.emitStatus('Classifier omitted irNumber for CONTINUE_IR');
297
- return undefined;
298
- }
299
- return { type: 'CONTINUE_IR', irNumber: payload.irNumber };
300
- }
301
- case 'SUMMARIZE_IR': {
302
- if (typeof payload.irNumber !== 'string') {
303
- await ports.emitStatus('Classifier omitted irNumber for SUMMARIZE_IR');
304
- return undefined;
305
- }
306
- return { type: 'SUMMARIZE_IR', irNumber: payload.irNumber };
307
- }
308
- case 'BOSS_INTERRUPT': {
309
- if (typeof payload.targetId !== 'string') {
310
- await ports.emitStatus('Classifier omitted targetId for BOSS_INTERRUPT');
311
- return undefined;
312
- }
313
- if (!bossInterruptTargetIds.has(payload.targetId)) {
314
- await ports.emitStatus(`Classifier supplied invalid targetId for BOSS_INTERRUPT: ${payload.targetId}`);
315
- return undefined;
316
- }
317
- return {
318
- type: 'BOSS_INTERRUPT',
319
- targetId: payload.targetId,
320
- ...(typeof payload.intent === 'string'
321
- ? { intent: payload.intent }
322
- : {}),
323
- ...(typeof payload.irNumber === 'string'
324
- ? { irNumber: payload.irNumber }
325
- : {}),
326
- };
327
- }
328
- case 'BOSS_REPLY': {
329
- if (state.value !== 'awaitBossReply') {
330
- await ports.emitStatus('Classifier returned BOSS_REPLY outside awaitBossReply');
331
- return undefined;
332
- }
333
- if (typeof payload.answer !== 'string') {
334
- await ports.emitStatus('Classifier omitted answer for BOSS_REPLY');
335
- return undefined;
336
- }
337
- const pending = pendingBossQuestionFromContext(state.context);
338
- if (!pending) {
339
- await ports.emitStatus('Classifier returned BOSS_REPLY without a pending question');
340
- return undefined;
341
- }
342
- if (payload.questionId !== undefined &&
343
- typeof payload.questionId !== 'string') {
344
- await ports.emitStatus('Classifier supplied a non-string questionId for BOSS_REPLY');
345
- return undefined;
346
- }
347
- if (typeof payload.questionId === 'string' &&
348
- payload.questionId !== pending.questionId) {
349
- await ports.emitStatus(`Classifier supplied unknown questionId for BOSS_REPLY: ${payload.questionId}`);
350
- return undefined;
351
- }
352
- return {
353
- type: 'BOSS_REPLY',
354
- answer: payload.answer,
355
- questionId: pending.questionId,
356
- };
357
- }
358
- default:
359
- await ports.emitStatus(`Classifier returned unknown event type: ${eventType}`);
360
- return undefined;
361
- }
362
- }
363
- function classifierState(snapshotOrState) {
364
- if (snapshotOrState !== null &&
365
- typeof snapshotOrState === 'object' &&
366
- 'value' in snapshotOrState) {
367
- const candidate = snapshotOrState;
368
- return {
369
- value: candidate.value,
370
- context: candidate.context !== null &&
371
- typeof candidate.context === 'object' &&
372
- !Array.isArray(candidate.context)
373
- ? candidate.context
374
- : {},
375
- };
376
- }
377
- return { value: snapshotOrState, context: {} };
378
- }
379
- function buildClassifierPrompt(text, state) {
380
- const currentState = typeof state.value === 'string'
381
- ? state.value
382
- : JSON.stringify(state.value ?? null);
383
- const pendingBossQuestion = pendingBossQuestionFromContext(state.context);
384
- const lines = [
385
- 'Classify the following Boss message into exactly one of these events.',
386
- 'Respond with JSON: { "event": "<TYPE>", "payload": { ...fields } }.',
387
- 'Use { "event": "NO_ACTION", "payload": {} } when no FSM action should be taken.',
388
- '',
389
- `Current state: ${currentState}`,
390
- ];
391
- if (pendingBossQuestion !== undefined) {
392
- lines.push(`Pending question id: ${pendingBossQuestion.questionId}`, `Pending asking player: ${pendingBossQuestion.player}`, `Pending Boss question: ${pendingBossQuestion.question}`);
393
- }
394
- lines.push('', 'Events:', '- START_CODING: payload { intent: "<free-form goal>" }', '- CONTINUE_IR: payload { irNumber: "<number>" }', '- SUMMARIZE_IR: payload { irNumber: "<number>" }', '- BOSS_INTERRUPT: payload { targetId: "<stateId>", intent?: "<free-form goal>", irNumber?: "<number>" }', ' targetId must be one of these jumpable states:');
395
- for (const target of bossInterruptTargets) {
396
- lines.push(` - ${target.stateId}: ${target.description}`);
397
- }
398
- if (currentState === 'awaitBossReply') {
399
- lines.push('- BOSS_REPLY: payload { answer: "<verbatim Boss answer>", questionId?: "<pending question id>" }');
400
- }
401
- else {
402
- lines.push('- BOSS_REPLY: valid only when Current state is awaitBossReply');
403
- }
404
- lines.push('', 'Boss message:', '```', text, '```');
405
- return lines.join('\n');
406
- }
407
- // Delegated-player actor bridge — DR-004 §7. One PromiseActorLogic that the
408
- // codingMachine invokes from every player-invoking state, built by the
409
- // shared createPlayerBridge with the CODE binding, composer, and
410
- // adjudication strategy. Per turn: resolve playerId, compose the player
411
- // prompt, await ports.callPlayer, adjudicate the finalText. PlayerResult
412
- // status of 'aborted' or 'error' throws so XState routes via onError →
413
- // #failed (the single fail-stop sink for both Captain errors and player
414
- // failures). Captain remains the orchestrator and adjudicator; it is not
415
- // encoded as the delegated FSM actor.
416
- //
417
- // `getActiveSignal` is the runtime's hook for flowing the Boss's
418
- // `handleBossInput.signal` into the host port calls — fromPromise
419
- // hands the bridge XState's actor-scoped signal, which only fires
420
- // on actor.stop(), not on Boss abort. When omitted (e.g. direct
421
- // captainBridge tests), the bridge falls back to XState's signal.
422
- function captainBridge(ports, getActiveSignal, boundary, onControlPlaneError) {
423
- return createPlayerBridge({
424
- resolvePlayerId: (input) => resolvePlayerId(input),
425
- composePlayerPrompt: (input) => composePlayerPrompt(input),
426
- adjudication: CODE_ADJUDICATION,
427
- resumableStateIds: registeredResumableStateIds,
428
- }, ports, getActiveSignal, boundary, onControlPlaneError);
429
- }
430
- // Captain pane display — PBRT-3 / PBRT-14.
431
- // The Captain pane is a stream of three glyphs plus one bare
432
- // captain-speech act, designed so a reader can parse each line at a
433
- // glance:
434
- // (no glyph) bare FSM event type — host renders as captain speech
435
- // (e.g., `captain> START_CODING`)
436
- // ⤷ player-invoking state entry: `<Player>: <label>`
437
- // → transition guard outcome (`· field=N` tallies
438
- // appended); the host presenter owns any visual
439
- // nesting under the preceding ⤷ entry
440
- // ◆ failure state (with `lastError` data) and the
441
- // `awaitBossReply` suspension state (custom payload)
442
- // The runtime emits no status line on entry to the idle state
443
- // (`ready`) or the terminal state (`done`); the next `boss>` prompt
444
- // is the implicit "turn over" signal. Prompts and full player output
445
- // ride the player panes; the Captain pane keeps to the state-machine
446
- // shape.
447
- const STATE_LABELS = {
448
- planAndImplement: 'plan & implement',
449
- respondToReview: 'respond to review',
450
- continueIr: 'continue IR task',
451
- summarizeSpecs: 'summarize IR into specs',
452
- reviewBossCommitSpecs: 'review Boss-intent commit (specs only)',
453
- reviewBossCommitCode: 'review Boss-intent commit (code only)',
454
- reviewBossCommitMixed: 'review Boss-intent commit (specs + code)',
455
- reviewIrTaskCommitSpecs: 'review IR-task commit (specs only)',
456
- reviewIrTaskCommitCode: 'review IR-task commit (code only)',
457
- reviewIrTaskCommitMixed: 'review IR-task commit (specs + code)',
458
- reviewChangesSpecs: 'review uncommitted edits (specs only)',
459
- reviewChangesCode: 'review uncommitted edits (code only)',
460
- reviewChangesMixed: 'review uncommitted edits (specs + code)',
461
- reviewChangesAndChallengesSpecs: 'review uncommitted edits + rebuttals (specs only)',
462
- reviewChangesAndChallengesCode: 'review uncommitted edits + rebuttals (code only)',
463
- reviewChangesAndChallengesMixed: 'review uncommitted edits + rebuttals (specs + code)',
464
- adjudicateChallenges: 'adjudicate rebuttals',
465
- commitCoderInitial: "commit Coder's initial changes",
466
- commitJoint: 'commit reviewed changes',
467
- };
468
- const stateMetadata = (() => {
469
- const m = new Map();
470
- for (const s of enumerateCaptainStates(codingMachine)) {
471
- const label = STATE_LABELS[s.stateId];
472
- if (!label) {
473
- throw new Error(`code.playbook.ts: STATE_LABELS missing entry for player-invoking state '${s.stateId}'`);
474
- }
475
- const input = s.getInput({});
476
- m.set(s.stateId, { player: input.player, sourceItem: s.sourceItem, label });
477
- }
478
- return m;
479
- })();
480
- const registeredResumableStateIds = new Set(enumerateAwaitBossReply(codingMachine).bossReplyTransitions.map((transition) => transition.target));
481
- const QUIESCENT_STATES = new Set([
482
- 'ready',
483
- 'awaitBossReply',
484
- 'done',
485
- 'failed',
486
- ]);
487
- // States whose entry the runtime does not surface on the Captain
488
- // pane per PBRT-3: the readline returning to its `boss>` prompt is
489
- // the implicit "turn over" signal, so a `◆ ready` / `◆ done`
490
- // tombstone is redundant.
491
- const SUPPRESSED_ENTRY_STATES = new Set(['ready', 'done']);
492
- // Captain-pane surface (PBRT-3): every player-invoking state plus
493
- // the quiescent states whose entry still carries information
494
- // (failure with `lastError`, awaitBossReply with the pending
495
- // question). `ready` and `done` flow through the inspect handler
496
- // but their entries are dropped before emitStatus per
497
- // SUPPRESSED_ENTRY_STATES above.
498
- const CAPTAIN_PANE_STATES = new Set([
499
- ...stateMetadata.keys(),
500
- ...QUIESCENT_STATES,
501
- ]);
502
- function pendingBossQuestionFromContext(context) {
503
- const pending = context.pendingBossQuestion;
504
- if (pending === undefined || typeof pending !== 'object') {
505
- return undefined;
506
- }
507
- const candidate = pending;
508
- if (typeof candidate.resumeStateId !== 'string' ||
509
- typeof candidate.questionId !== 'string' ||
510
- typeof candidate.sourceItem !== 'string' ||
511
- typeof candidate.player !== 'string' ||
512
- typeof candidate.question !== 'string') {
513
- return undefined;
514
- }
515
- return {
516
- questionId: candidate.questionId,
517
- resumeStateId: candidate.resumeStateId,
518
- sourceItem: candidate.sourceItem,
519
- player: candidate.player,
520
- question: candidate.question,
521
- };
522
- }
523
- // PBRT-3 / PBRT-14: on entry to `awaitBossReply` the runtime surfaces
524
- // the pending player question as a captain-speech act attributed to the
525
- // asking player (`<player> asks: <full question>`), emitted with no
526
- // glyph so the host renders it as captain speech. The question is
527
- // carried verbatim and in full — the judge JSON that produced it rides
528
- // a hidden callCaptain (PBRT-15), so this line is the Boss's only
529
- // legible view of what was asked.
530
- function formatAwaitBossReplyQuestion(context) {
531
- const pending = pendingBossQuestionFromContext(context);
532
- const player = pending?.player ?? 'unknown';
533
- const question = pending?.question ?? '';
534
- return `${player} asks: ${question}`;
535
- }
536
- // The rider-less routing marker emitted right after the question line.
537
- // It carries only the resume target, asking player, and source item;
538
- // the former `q="<first 80 chars>"` excerpt rider is dropped now that
539
- // the full question rides the captain-speech line above.
540
- function formatAwaitBossReplyMarker(context) {
541
- const pending = pendingBossQuestionFromContext(context);
542
- const resumeStateId = pending?.resumeStateId ?? 'unknown';
543
- const player = pending?.player ?? 'unknown';
544
- const sourceItem = pending?.sourceItem ?? 'unknown';
545
- return `◆ awaiting Boss reply · ${resumeStateId} · ${player} · ${sourceItem}`;
546
- }
547
- function formatStateEntry(stateId) {
548
- if (SUPPRESSED_ENTRY_STATES.has(stateId))
549
- return undefined;
550
- if (stateId === 'failed')
551
- return '◆ failed';
552
- const meta = stateMetadata.get(stateId);
553
- if (!meta)
554
- return `⤷ ${stateId}`;
555
- return `⤷ ${meta.player}: ${meta.label}`;
556
- }
557
- function formatTransition(event) {
558
- const output = event?.output;
559
- if (!output || typeof output.guard !== 'string')
560
- return undefined;
561
- const tallies = [];
562
- for (const field of ['reviews', 'challenges']) {
563
- const value = output[field];
564
- if (typeof value === 'string' && value.trim().length > 0) {
565
- const items = value.match(/^\s*\d+\.\s/gm);
566
- tallies.push(`${field}=${items ? items.length : 1}`);
567
- }
568
- }
569
- const suffix = tallies.length > 0 ? ` · ${tallies.join(' · ')}` : '';
570
- // No leading whitespace: visual nesting under the preceding ⤷
571
- // entry is the host presenter's concern (cligent's writeStatusLine
572
- // emits status messages verbatim, with its own chrome but no
573
- // continuation indent; layout is its job, not ours).
574
- return `→ ${output.guard}${suffix}`;
575
- }
576
- function formatClassification(eventType) {
577
- return eventType;
578
- }
579
- function stateTelemetryPayload(from, to, event, context) {
580
- const payload = {
581
- from: from ?? null,
582
- to,
583
- event: normalizeEventForTelemetry(event) ?? null,
584
- };
585
- if (to === 'awaitBossReply') {
586
- const pendingBossQuestion = pendingBossQuestionFromContext(context);
587
- if (pendingBossQuestion !== undefined) {
588
- payload.pendingBossQuestion = pendingBossQuestion;
589
- }
590
- }
591
- if (to === 'failed') {
592
- const lastError = normalizeErrorFull(context.lastError);
593
- if (lastError !== undefined) {
594
- payload.lastError = lastError;
595
- }
596
- }
597
- return payload;
598
- }
599
- // Captain-pane status lines for a root transition (PBRT-3 / PBRT-14):
600
- // the `→ guard` outcome line for the settling transition, then either
601
- // the awaitBossReply question + rider-less marker pair or the state's
602
- // entry line (with `lastError` data on `failed`).
603
- function statusesForState(state, context, event) {
604
- const to = state.stateId;
605
- if (to === undefined || !CAPTAIN_PANE_STATES.has(to))
606
- return [];
607
- const statuses = [];
608
- const transitionLine = formatTransition(event);
609
- if (transitionLine !== undefined) {
610
- statuses.push({ message: transitionLine });
611
- }
612
- if (to === 'awaitBossReply') {
613
- statuses.push({ message: formatAwaitBossReplyQuestion(context) }, { message: formatAwaitBossReplyMarker(context) });
614
- }
615
- else {
616
- const entryLine = formatStateEntry(to);
617
- if (entryLine !== undefined) {
618
- const lastError = to === 'failed' ? normalizeErrorCompact(context.lastError) : undefined;
619
- statuses.push({
620
- message: entryLine,
621
- ...(lastError === undefined
622
- ? {}
623
- : {
624
- data: snapshotJsonValue({ lastError }, 'failed status data'),
625
- }),
626
- });
627
- }
628
- }
629
- return statuses;
54
+ const fields = input;
55
+ const template = input.prompt
56
+ .split('\n')
57
+ .filter((line) => !(line === '> <run-results>' && input.runResults.length === 0))
58
+ .join('\n');
59
+ const body = template.replace(PLACEHOLDER, (match, token, offset, source) => {
60
+ const value = fields[placeholderField(token)];
61
+ if (typeof value !== 'string')
62
+ return match;
63
+ const lineStart = source.lastIndexOf('\n', offset - 1) + 1;
64
+ const literal = source.slice(lineStart, offset);
65
+ return literal === '> ' ? quoteContinuation(value) : value;
66
+ });
67
+ if (input.pendingBossQuestion === undefined ||
68
+ input.bossReply === undefined) {
69
+ return body;
70
+ }
71
+ return [
72
+ CONTINUATION_PREAMBLE,
73
+ `Boss question:\n${input.pendingBossQuestion.question}`,
74
+ `Boss reply:\n${input.bossReply}`,
75
+ body,
76
+ ].join('\n\n');
630
77
  }
631
- // Internal export surface for tests. Not part of the stable public API;
632
- // the leading underscore signals "subject to change."
633
78
  export const _internal = {
634
79
  composePlayerPrompt,
635
- resolvePlayerId,
636
- adjudicate,
637
- classifyBossText,
638
- captainBridge,
639
- STATE_LABELS,
640
- stateMetadata,
641
- pendingBossQuestionFromContext,
642
- formatAwaitBossReplyQuestion,
643
- formatAwaitBossReplyMarker,
644
- formatStateEntry,
645
- formatTransition,
646
- formatClassification,
647
- stateTelemetryPayload,
648
- normalizeErrorCompact,
649
- normalizeErrorFull,
650
- normalizeEventForTelemetry,
651
80
  VERBATIM_PAYLOAD_FIELDS,
652
81
  };
653
- // The CODE-specific spec handed to the shared runtime factory
654
- // (slc/link.md §Output, DR-019). The generic machinery — actor wiring,
655
- // boundary tracing, Boss-turn lifecycle, nested-playbook bridge, and the
656
- // DR-014 parked-session snapshot capability — lives in
657
- // @sublang/playbook/xstate-runtime; this spec carries only what is
658
- // CODE-specific.
659
82
  const runtimeSpec = {
660
83
  label: 'CODE',
661
- snapshotOptions: snapshotCodePlaybookOptions,
662
- resolvePlayerId: (input) => resolvePlayerId(input),
84
+ compat: { artifactSchema: 1, runtimeAbi: RUNTIME_ABI },
85
+ snapshotOptions: snapshotCodeOptions,
86
+ entryEvent: { type: 'START_CODE', textField: 'callerInput' },
87
+ playerStates: {
88
+ runFirstPhase: {
89
+ player: 'Coder',
90
+ label: 'Coder is implementing one direct phase or committing a new intent record.',
91
+ },
92
+ runIrTask: {
93
+ player: 'Coder',
94
+ label: 'Coder is implementing exactly one unfinished intent-record task.',
95
+ },
96
+ },
97
+ placeholderFields: { 'coder-llm': 'coderPlayer' },
663
98
  composePlayerPrompt: (input) => composePlayerPrompt(input),
664
- buildJudgePrompt: CODE_ADJUDICATION.buildJudgePrompt,
665
- extractRequiredFields,
666
99
  verbatimPayloadFields: VERBATIM_PAYLOAD_FIELDS,
667
- resumableStateIds: registeredResumableStateIds,
668
- // PBRT-52: CODE's own ControlView context projection. Exactly the four
669
- // closed-vocabulary classification members the FSM assigns itself, which
670
- // are what a controller needs to see to describe or steer a CODE
671
- // engagement. Everything else in `CodingContext` is deliberately absent:
672
- // `coderPlayer` / `reviewerPlayer` / `committerPlayer` are the resolved
673
- // host player roster, `intent` is an option value, and `irNumber`,
674
- // `taskDescription`, `reviews`, `challenges`, and `lastResult` all carry
675
- // player-authored text — all of which a session-Captain prompt must
676
- // exclude, or may carry only as fenced quotes
677
- // (CAPTAIN-9). A member added to `CodingContext`
678
- // later stays private until it is named here.
679
- controlContextFields: [
680
- 'workflow',
681
- 'changeOrigin',
682
- 'reviewSubject',
683
- 'afterReview',
684
- ],
685
- classifyBossText: (text, ports, signal, snapshotOrState, boundary) => classifyBossText(text, ports, signal, snapshotOrState, boundary),
686
- classificationStatus: (event) => formatClassification(event.type),
687
- statusesForState,
688
- normalizeTransitionEvent: (event) => normalizeEventForTelemetry(event),
100
+ controlContextFields: ['phase'],
101
+ transitionEventFields: ['callerInput', 'answer', 'questionId'],
689
102
  };
690
103
  const createPlaybookRuntime = createXStatePlaybookRuntime(codingMachine, runtimeSpec);
691
104
  export default createPlaybookRuntime;