@sublang/playbook 0.4.2 → 0.6.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.
@@ -4,8 +4,10 @@
4
4
  // Generated by slc/link.md (FSM-to-Runtime linker).
5
5
  // Source FSM: ./code.fsm.ts
6
6
  // Player bind: Coder→coder, Reviewer→reviewer,
7
- // Committer→{coder per CODE-18/19} (CODE-19 wires both
8
- // coderPlayer and reviewerPlayer; Coder wins as the
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
9
11
  // alias's first alternative)
10
12
  // Boss event: free-text judge classification
11
13
  // Adjudication: LLM-judge per state
@@ -16,6 +18,65 @@ const BOSS_REPLY_ERRORS = {
16
18
  missingQuestion: "needsBossReply outcome missing 'question' field",
17
19
  unregisteredState: (stateId) => `state ${stateId} declared needsBossReply but is not registered as resumable`,
18
20
  };
21
+ // Required-payload fields whose value is the player's verbatim long-form
22
+ // prose. The runtime carries `finalText.trim()` into these fields rather
23
+ // than asking the judge to round-trip the text through JSON. Short
24
+ // extracted fields (`question`, `taskDescription`, `irNumber`, …) stay
25
+ // judge-extracted — they are not in this set.
26
+ const VERBATIM_PAYLOAD_FIELDS = new Set([
27
+ 'reviews',
28
+ 'challenges',
29
+ ]);
30
+ // Normalize an unknown error value to the compact `{ name, message }`
31
+ // shape used by Captain-pane / status emissions. Returns `undefined`
32
+ // for nullish input so callers can omit absent errors.
33
+ function normalizeErrorCompact(err) {
34
+ if (err === undefined || err === null)
35
+ return undefined;
36
+ if (err instanceof Error) {
37
+ return { name: err.name, message: err.message };
38
+ }
39
+ if (typeof err === 'object') {
40
+ const o = err;
41
+ if (typeof o.message === 'string') {
42
+ return {
43
+ name: typeof o.name === 'string' ? o.name : 'Error',
44
+ message: o.message,
45
+ };
46
+ }
47
+ }
48
+ return { name: 'Error', message: String(err) };
49
+ }
50
+ // Normalize an unknown error value to the full `{ name, message, stack }`
51
+ // shape used by telemetry emissions. Returns `undefined` for nullish
52
+ // input. `stack` is omitted when not available on the source value.
53
+ function normalizeErrorFull(err) {
54
+ const compact = normalizeErrorCompact(err);
55
+ if (compact === undefined)
56
+ return undefined;
57
+ if (err instanceof Error) {
58
+ return err.stack !== undefined ? { ...compact, stack: err.stack } : compact;
59
+ }
60
+ if (typeof err === 'object' && err !== null) {
61
+ const stack = err.stack;
62
+ if (typeof stack === 'string') {
63
+ return { ...compact, stack };
64
+ }
65
+ }
66
+ return compact;
67
+ }
68
+ // Normalize any `error` field inside a telemetry event so failed
69
+ // transitions don't leak raw Error instances through the channel.
70
+ function normalizeEventForTelemetry(event) {
71
+ if (event === null || typeof event !== 'object' || Array.isArray(event)) {
72
+ return event;
73
+ }
74
+ const e = event;
75
+ if (!('error' in e))
76
+ return event;
77
+ const normalized = normalizeErrorFull(e.error);
78
+ return { ...e, error: normalized };
79
+ }
19
80
  // Internal capabilities (DR-004 §10). Each ships with its final
20
81
  // signature; behavior lands in the per-capability task noted by the
21
82
  // TODO marker.
@@ -63,15 +124,21 @@ function composePlayerPrompt(input) {
63
124
  }
64
125
  return blocks.join('\n\n');
65
126
  }
66
- // Player-id resolver — DR-004 §2.
127
+ // Player-id resolver — DR-004 §2 / PBRT-8.
67
128
  // Non-composite: Coder→'coder', Reviewer→'reviewer'. The composite
68
- // Committer (= Coder | Reviewer per code.gears.md) resolves per
69
- // populated <playerName>Player field on `CaptainInput`: prefer
70
- // `coderPlayer` (CODE-18 wires only coderPlayer; CODE-19 wires both
71
- // so coderPlayer still wins as the alias's first alternative), fall
72
- // back to `reviewerPlayer` only if a future gear ships a
73
- // reviewer-only Committer item, and finally to the alias's first
74
- // alternative (Coder) when neither is set.
129
+ // Committer (= Coder | Reviewer per code.gears.md) resolves to the
130
+ // configured alias when present: `input.committerPlayer`, the
131
+ // validated `captain.options.code.committer` (PBRT-8 / PBRT-30),
132
+ // already a baked player id ('coder' / 'reviewer'). Absent a
133
+ // configured alias it falls back to the DR-004 §2 baked binding by
134
+ // populated <playerName>Player field: prefer `coderPlayer` (CODE-18
135
+ // wires only coderPlayer; CODE-19 wires both so coderPlayer still wins
136
+ // as the alias's first alternative), fall back to `reviewerPlayer`
137
+ // only if a future gear ships a reviewer-only Committer item, and
138
+ // finally to the alias's first alternative (Coder) when neither is
139
+ // set. The alias selects only the host pane; it is not a PBRT-4
140
+ // identity string, so it leaves <coder-llm> / <reviewer-llm>
141
+ // untouched and `input.player` stays `Committer` (PLAYBOOK-3).
75
142
  function resolvePlayerId(input) {
76
143
  switch (input.player) {
77
144
  case 'Coder':
@@ -79,6 +146,8 @@ function resolvePlayerId(input) {
79
146
  case 'Reviewer':
80
147
  return 'reviewer';
81
148
  case 'Committer':
149
+ if (input.committerPlayer !== undefined)
150
+ return input.committerPlayer;
82
151
  if (input.coderPlayer !== undefined)
83
152
  return 'coder';
84
153
  if (input.reviewerPlayer !== undefined)
@@ -116,8 +185,17 @@ async function adjudicate(input, finalText, ports, signal) {
116
185
  // required fields with the literal phrase
117
186
  // Output shall include `<fieldName>: <...>`
118
187
  // so we extract those tokens and require each to be a string in
119
- // the judge response.
188
+ // the judge response — except for VERBATIM_PAYLOAD_FIELDS
189
+ // (`reviews`, `challenges`), where the runtime substitutes
190
+ // `finalText.trim()` so the long-form prose is not round-tripped
191
+ // through judge JSON. Short extracted fields like `question` and
192
+ // `taskDescription` keep the existing extract-and-validate path.
193
+ const verbatim = finalText.trim();
120
194
  for (const field of extractRequiredFields(input.result[guard])) {
195
+ if (VERBATIM_PAYLOAD_FIELDS.has(field)) {
196
+ obj[field] = verbatim;
197
+ continue;
198
+ }
121
199
  if (typeof obj[field] !== 'string') {
122
200
  if (guard === 'needsBossReply' && field === 'question') {
123
201
  throw new Error(BOSS_REPLY_ERRORS.missingQuestion);
@@ -145,24 +223,155 @@ function buildJudgePrompt(input, finalText) {
145
223
  lines.push('');
146
224
  lines.push('Pick exactly one outcome by `guard` and return JSON ' +
147
225
  '`{ guard, …payloadFields }`. Required payload fields are ' +
148
- 'named in the outcome description.');
226
+ 'named in the outcome description. Do not copy long-form ' +
227
+ 'verbatim fields (`reviews`, `challenges`) — the runtime ' +
228
+ "carries the player's output into those fields verbatim, " +
229
+ 'so any value you supply for them will be overwritten.');
149
230
  lines.push('');
150
231
  for (const [key, description] of Object.entries(input.result)) {
151
232
  lines.push(`- \`${key}\` — ${description}`);
152
233
  }
153
234
  return lines.join('\n');
154
235
  }
236
+ // Judge replies are meant to be a single JSON object, but LLMs
237
+ // routinely wrap them in prose ("Here is the result: …"), Markdown
238
+ // code fences, or trailing commentary, and occasionally emit a
239
+ // trailing comma or truncate the tail (a dropped closing brace, an
240
+ // unterminated string). parseJudgeJson is deliberately lenient: it
241
+ // first tries a strict parse of the (optionally fenced) body, then
242
+ // scans every `{`/`[` as a possible start in document order and
243
+ // returns the first recoverable object. At each start it prefers a
244
+ // strict balanced span and falls back to a repaired (trailing-comma /
245
+ // truncation) span, so a damaged object earlier in the prose is not
246
+ // overridden by a cleaner one later. Scanning every start (not just
247
+ // the first bracket) keeps a bracketed fragment in surrounding prose —
248
+ // e.g. an aside like `see [1]` or `{n/a}` before the real object —
249
+ // from masking a later, genuinely valid object. Both callers
250
+ // (classification and adjudication) expect an object, so plain objects
251
+ // win over arrays/scalars; the first value of any shape is remembered
252
+ // so a legitimately array/scalar reply still surfaces to the caller's
253
+ // own object check. Only a reply from which no JSON value can be
254
+ // recovered is treated as malformed and throws, preserving the
255
+ // control-plane error contract (PBRT-7, PBRT-10).
155
256
  function parseJudgeJson(raw) {
156
- let text = raw.trim();
157
- const fence = text.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/);
158
- if (fence)
159
- text = fence[1].trim();
257
+ const fenced = stripCodeFence(raw.trim());
258
+ // Fast path: a well-formed (optionally fenced) JSON body.
160
259
  try {
161
- return JSON.parse(text);
162
- }
163
- catch (e) {
164
- throw new Error(`adjudicate: judge response is not valid JSON: ${e.message}`);
165
- }
260
+ return JSON.parse(fenced);
261
+ }
262
+ catch {
263
+ // Fall through to lenient extraction + repair.
264
+ }
265
+ const starts = [];
266
+ for (let i = 0; i < fenced.length; i++) {
267
+ const ch = fenced[i];
268
+ if (ch === '{' || ch === '[')
269
+ starts.push(i);
270
+ }
271
+ // Walk starts in document order. At each start prefer a strict
272
+ // balanced span (most trustworthy) and fall back to a repaired one
273
+ // for a trailing-comma / truncated tail, so the earliest intended
274
+ // object wins even when it needs repair. Return the first plain
275
+ // object; remember the first value of any shape so a legitimately
276
+ // array/scalar reply still surfaces to the caller's own object check.
277
+ let firstValue;
278
+ for (const start of starts) {
279
+ let parsedHere;
280
+ for (const repair of [false, true]) {
281
+ const candidate = extractJsonValue(fenced, start, repair);
282
+ if (candidate === undefined)
283
+ continue;
284
+ try {
285
+ parsedHere = { value: JSON.parse(candidate) };
286
+ }
287
+ catch {
288
+ continue; // not parseable this way — try repair, then next start
289
+ }
290
+ break; // prefer the strict span at this start over its repair
291
+ }
292
+ if (parsedHere === undefined)
293
+ continue;
294
+ if (isPlainObject(parsedHere.value))
295
+ return parsedHere.value;
296
+ if (firstValue === undefined)
297
+ firstValue = parsedHere;
298
+ }
299
+ if (firstValue !== undefined)
300
+ return firstValue.value;
301
+ throw new Error('adjudicate: judge response is not valid JSON');
302
+ }
303
+ function isPlainObject(value) {
304
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
305
+ }
306
+ // Strip a single Markdown code fence that wraps the whole string.
307
+ function stripCodeFence(text) {
308
+ const fence = text.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/);
309
+ return fence ? fence[1].trim() : text;
310
+ }
311
+ // Scan from `start` (a `{`/`[` index), tracking string and
312
+ // bracket-nesting state, and emit the balanced JSON value rooted
313
+ // there. Anything after the top-level value closes is ignored (so a
314
+ // trailing code fence or commentary does not matter). With
315
+ // `repair === false` the span is returned only if it actually closes,
316
+ // and trailing commas are left intact — so the caller can prefer a
317
+ // cleanly-balanced span before attempting repair; if input ends
318
+ // before the value closes, undefined is returned. With
319
+ // `repair === true` common damage is fixed: a trailing comma before a
320
+ // close is removed, an unterminated string is closed, and any
321
+ // brackets still open at end-of-input are closed in order.
322
+ function extractJsonValue(text, start, repair) {
323
+ const stack = [];
324
+ let out = '';
325
+ let inString = false;
326
+ let escaped = false;
327
+ for (let i = start; i < text.length; i++) {
328
+ const ch = text[i];
329
+ if (inString) {
330
+ out += ch;
331
+ if (escaped)
332
+ escaped = false;
333
+ else if (ch === '\\')
334
+ escaped = true;
335
+ else if (ch === '"')
336
+ inString = false;
337
+ continue;
338
+ }
339
+ if (ch === '"') {
340
+ inString = true;
341
+ out += ch;
342
+ continue;
343
+ }
344
+ if (ch === '{' || ch === '[') {
345
+ stack.push(ch === '{' ? '}' : ']');
346
+ out += ch;
347
+ continue;
348
+ }
349
+ if (ch === '}' || ch === ']') {
350
+ if (repair)
351
+ out = dropTrailingComma(out);
352
+ out += ch;
353
+ stack.pop();
354
+ if (stack.length === 0)
355
+ return out; // top-level value complete
356
+ continue;
357
+ }
358
+ out += ch;
359
+ }
360
+ // End of input before the top-level value closed.
361
+ if (!repair)
362
+ return undefined; // strict pass: no balanced span here
363
+ if (inString)
364
+ out += '"';
365
+ out = dropTrailingComma(out);
366
+ while (stack.length > 0)
367
+ out += stack.pop();
368
+ return out;
369
+ }
370
+ // Remove a trailing comma (and any whitespace after it) at the end of
371
+ // the accumulated output, so `{"a":1,}` / `[1,2,]` and truncated
372
+ // `{"a":1,` repair to valid JSON.
373
+ function dropTrailingComma(out) {
374
+ return out.replace(/,(\s*)$/, '$1');
166
375
  }
167
376
  // Boss-event classifier — DR-004 §3.
168
377
  // Every non-empty Boss turn goes through ports.callJudge. Slash-prefixed
@@ -184,7 +393,18 @@ async function classifyWithLlm(text, ports, signal, snapshotOrState) {
184
393
  const state = classifierState(snapshotOrState);
185
394
  const prompt = buildClassifierPrompt(text, state);
186
395
  const raw = await ports.callJudge(prompt, signal);
187
- const parsed = parseJudgeJson(raw);
396
+ let parsed;
397
+ try {
398
+ parsed = parseJudgeJson(raw);
399
+ }
400
+ catch {
401
+ // No JSON value could be recovered. Classification failures are
402
+ // non-fatal — unlike adjudication, which throws to the failure
403
+ // state (PBRT-10) — so surface one status and take no FSM action,
404
+ // consistent with the other invalid-reply paths below (PBRT-7).
405
+ await ports.emitStatus('Classifier reply was not recoverable JSON');
406
+ return undefined;
407
+ }
188
408
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
189
409
  await ports.emitStatus('Classifier returned a non-object JSON response');
190
410
  return undefined;
@@ -444,20 +664,31 @@ function pendingBossQuestionFromContext(context) {
444
664
  question: candidate.question,
445
665
  };
446
666
  }
447
- function questionExcerpt(question) {
448
- return question.replace(/[\r\n]+/g, ' ').slice(0, 80);
667
+ // PBRT-3 / PBRT-14: on entry to `awaitBossReply` the runtime surfaces
668
+ // the pending player question as a captain-speech act attributed to the
669
+ // asking player (`<player> asks: <full question>`), emitted with no
670
+ // glyph so the host renders it as captain speech. The question is
671
+ // carried verbatim and in full — the judge JSON that produced it rides
672
+ // a hidden callCaptain (PBRT-15), so this line is the Boss's only
673
+ // legible view of what was asked.
674
+ function formatAwaitBossReplyQuestion(context) {
675
+ const pending = pendingBossQuestionFromContext(context);
676
+ const player = pending?.player ?? 'unknown';
677
+ const question = pending?.question ?? '';
678
+ return `${player} asks: ${question}`;
449
679
  }
450
- function formatAwaitBossReplyEntry(context) {
680
+ // The rider-less routing marker emitted right after the question line.
681
+ // It carries only the resume target, asking player, and source item;
682
+ // the former `q="<first 80 chars>"` excerpt rider is dropped now that
683
+ // the full question rides the captain-speech line above.
684
+ function formatAwaitBossReplyMarker(context) {
451
685
  const pending = pendingBossQuestionFromContext(context);
452
686
  const resumeStateId = pending?.resumeStateId ?? 'unknown';
453
687
  const player = pending?.player ?? 'unknown';
454
688
  const sourceItem = pending?.sourceItem ?? 'unknown';
455
- const question = questionExcerpt(pending?.question ?? '');
456
- return `◆ awaiting Boss reply · ${resumeStateId} · ${player} · ${sourceItem} · q=${JSON.stringify(question)}`;
689
+ return `◆ awaiting Boss reply · ${resumeStateId} · ${player} · ${sourceItem}`;
457
690
  }
458
- function formatStateEntry(stateId, context = {}) {
459
- if (stateId === 'awaitBossReply')
460
- return formatAwaitBossReplyEntry(context);
691
+ function formatStateEntry(stateId) {
461
692
  if (SUPPRESSED_ENTRY_STATES.has(stateId))
462
693
  return undefined;
463
694
  if (stateId === 'failed')
@@ -490,13 +721,23 @@ function formatClassification(eventType) {
490
721
  return eventType;
491
722
  }
492
723
  function stateTelemetryPayload(from, to, event, context) {
493
- const payload = { from, to, event };
724
+ const payload = {
725
+ from,
726
+ to,
727
+ event: normalizeEventForTelemetry(event),
728
+ };
494
729
  if (to === 'awaitBossReply') {
495
730
  const pendingBossQuestion = pendingBossQuestionFromContext(context);
496
731
  if (pendingBossQuestion !== undefined) {
497
732
  payload.pendingBossQuestion = pendingBossQuestion;
498
733
  }
499
734
  }
735
+ if (to === 'failed') {
736
+ const lastError = normalizeErrorFull(context.lastError);
737
+ if (lastError !== undefined) {
738
+ payload.lastError = lastError;
739
+ }
740
+ }
500
741
  return payload;
501
742
  }
502
743
  // Internal export surface for tests. Not part of the stable public API;
@@ -512,11 +753,16 @@ export const _internal = {
512
753
  STATE_LABELS,
513
754
  stateMetadata,
514
755
  pendingBossQuestionFromContext,
515
- formatAwaitBossReplyEntry,
756
+ formatAwaitBossReplyQuestion,
757
+ formatAwaitBossReplyMarker,
516
758
  formatStateEntry,
517
759
  formatTransition,
518
760
  formatClassification,
519
761
  stateTelemetryPayload,
762
+ normalizeErrorCompact,
763
+ normalizeErrorFull,
764
+ normalizeEventForTelemetry,
765
+ VERBATIM_PAYLOAD_FIELDS,
520
766
  };
521
767
  export default function createPlaybookRuntime(options) {
522
768
  let actor;
@@ -590,13 +836,25 @@ export default function createPlaybookRuntime(options) {
590
836
  if (transitionLine !== undefined) {
591
837
  enqueueEmit(() => ports.emitStatus(transitionLine));
592
838
  }
593
- const entryLine = formatStateEntry(to, context);
839
+ // awaitBossReply surfaces two lines per PBRT-3 / PBRT-14: the
840
+ // full player question as captain speech, then the rider-less
841
+ // routing marker. The full-question telemetry rides
842
+ // stateTelemetryPayload above.
843
+ if (to === 'awaitBossReply') {
844
+ const questionLine = formatAwaitBossReplyQuestion(context);
845
+ const markerLine = formatAwaitBossReplyMarker(context);
846
+ enqueueEmit(() => ports.emitStatus(questionLine));
847
+ enqueueEmit(() => ports.emitStatus(markerLine));
848
+ return;
849
+ }
850
+ const entryLine = formatStateEntry(to);
594
851
  if (entryLine === undefined)
595
852
  return;
596
853
  if (to === 'failed') {
597
854
  const lastError = snap.context
598
855
  ?.lastError;
599
- enqueueEmit(() => ports.emitStatus(entryLine, { lastError }));
856
+ const data = { lastError: normalizeErrorCompact(lastError) };
857
+ enqueueEmit(() => ports.emitStatus(entryLine, data));
600
858
  }
601
859
  else {
602
860
  enqueueEmit(() => ports.emitStatus(entryLine));