@sublang/playbook 0.6.0 → 0.8.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 CHANGED
@@ -84,7 +84,7 @@ playbook-code --help
84
84
 
85
85
  The seed template runs each agent in cligent's protected auto mode
86
86
  (`permissions.mode: auto`), suppressing routine approval prompts. Its
87
- Codex Coder also grants `permissions.writablePaths: [.git]` so git
87
+ Codex Reviewer also grants `permissions.writablePaths: [.git]` so git
88
88
  metadata writes stay available under auto mode without switching to
89
89
  bypass permissions.
90
90
 
@@ -108,40 +108,41 @@ the runtime binds to those host-configuration invariants per
108
108
  `<coder-llm>` / `<reviewer-llm>` substitution strings from each role's
109
109
  `model` when pinned and `adapter` otherwise — so the Committer's
110
110
  commit-message trailers can name the concrete model
111
- (e.g. `claude-opus-4-8`) rather than the adapter family (`claude`).
111
+ (e.g. `claude-opus-4-8[1m]`) rather than the adapter family
112
+ (`claude`).
112
113
 
113
114
  `players.committer` is an optional alias naming which role — `coder`
114
115
  or `reviewer` — runs the commit turn; the seeded overlay points it at
115
- the Reviewer. Absent the alias the Committer falls back to the Coder
116
+ the Coder. Absent the alias the Committer falls back to the Coder
116
117
  ([PBRT-8](specs/user/playbook-runtime.md#pbrt-8)).
117
118
 
118
- For example, the seeded overlay runs the Coder on Codex and the
119
- Reviewer on Claude, with the Committer aliased to the Reviewer:
119
+ For example, the seeded overlay runs the Coder on Claude Opus 4.8 1m
120
+ and the Reviewer on GPT-5.5, with the Committer aliased to the Coder:
120
121
 
121
122
  ```yaml
122
123
  captain:
123
124
  adapter: claude
124
- model: claude-sonnet-4-6
125
+ model: claude-opus-4-8
125
126
  reasoningEffort: high
126
127
  permissions:
127
128
  mode: auto
128
129
 
129
130
  players:
130
131
  coder: # role key must stay `coder` — see PBRT-4
131
- adapter: codex
132
- model: gpt-5.5
132
+ adapter: claude
133
+ model: claude-opus-4-8[1m]
133
134
  reasoningEffort: xhigh
134
135
  permissions:
135
136
  mode: auto
136
- writablePaths:
137
- - .git # allow git metadata writes under Codex auto mode
138
137
  reviewer: # role key must stay `reviewer` — see PBRT-4
139
- adapter: claude
140
- model: claude-opus-4-8
138
+ adapter: codex
139
+ model: gpt-5.5
141
140
  reasoningEffort: xhigh
142
141
  permissions:
143
142
  mode: auto
144
- committer: reviewer # which role commits — `coder` or `reviewer`
143
+ writablePaths:
144
+ - .git # allow git metadata writes under Codex auto mode
145
+ committer: coder # which role commits — `coder` or `reviewer`
145
146
  ```
146
147
 
147
148
  Normal `playbook-code` runs use the seeded path above. If you need a
@@ -170,15 +171,13 @@ pnpm test
170
171
  in the checked-in `pnpm-lock.yaml` — the same version CI installs
171
172
  via `--frozen-lockfile`, so contributor checkouts and CI agree.
172
173
  The published `package.json` declares `@sublang/cligent` as
173
- `latest`, so an end-user install with no lockfile (e.g., `npm
174
- install -g @sublang/playbook`) instead resolves whichever cligent
175
- release currently carries the `latest` dist-tag at install time
176
- (see [RELEASE-14](specs/dev/release.md#release-14)). To bump the
177
- contributor pin to today's `latest`, run
174
+ `^0.13.0`, so an end-user install with no lockfile (e.g., `npm
175
+ install -g @sublang/playbook`) resolves a compatible cligent 0.13.x
176
+ release (see [RELEASE-14](specs/dev/release.md#release-14)). To
177
+ refresh the contributor pin within that range, run
178
178
  `pnpm update @sublang/cligent` and commit the resulting
179
- `pnpm-lock.yaml` change a plain `pnpm install` won't refresh the
180
- pin, since pnpm sees `specifier: latest` in the lockfile as
181
- already matching `package.json` and skips re-resolving the tag.
179
+ `pnpm-lock.yaml` change. To adopt a later cligent minor, update the
180
+ `package.json` specifier and lockfile together.
182
181
  No local link required for any of this. To point pnpm at a local
183
182
  `cligent` checkout
184
183
  instead, copy
@@ -225,12 +224,19 @@ registered.
225
224
  ### Embedding the runtime in your own host
226
225
 
227
226
  The runtime is host-agnostic; the `tmux-play` adapter is one host.
227
+ The port and runtime contracts live in the type-only module
228
+ [`@sublang/playbook/runtime`](src/runtime.ts) — a public, semver-stable
229
+ surface (`PlayerResult`, `PlaybookPorts`, `PlaybookRuntime`,
230
+ `PlaybookRuntimeFactory`) that imports no CODE or FSM types, so a host
231
+ satisfies it once and inherits every playbook. The CODE runtime
232
+ re-exports `PlayerResult`, `PlaybookPorts`, and `PlaybookRuntime` from
233
+ `@sublang/playbook/code/playbook`; `PlaybookRuntimeFactory` is available
234
+ from `@sublang/playbook/runtime`.
228
235
  Construct the runtime against your own ports:
229
236
 
230
237
  ```ts
231
- import createPlaybookRuntime, {
232
- type PlaybookPorts,
233
- } from '@sublang/playbook/code/playbook';
238
+ import createPlaybookRuntime from '@sublang/playbook/code/playbook';
239
+ import type { PlaybookPorts } from '@sublang/playbook/runtime';
234
240
 
235
241
  const ports: PlaybookPorts = {
236
242
  callPlayer: async (playerId, prompt, signal) => { /* … */ },
@@ -257,6 +263,25 @@ See
257
263
  for the full range of port shapes (classifier, judge, abort, interrupt,
258
264
  status/telemetry) the runtime is contract-tested against.
259
265
 
266
+ ### Reading the published spec contracts
267
+
268
+ The authored compiler-phase specs ship in the package and are exposed
269
+ as a public, semver-stable surface under `@sublang/playbook/slc/*`.
270
+ Resolve and read one with `import.meta.resolve` plus `fs`:
271
+
272
+ ```ts
273
+ import { readFile } from 'node:fs/promises';
274
+ import { fileURLToPath } from 'node:url';
275
+
276
+ const url = import.meta.resolve('@sublang/playbook/slc/link.md');
277
+ const link = await readFile(fileURLToPath(url), 'utf8');
278
+ ```
279
+
280
+ The three specs are [`slc/text2gears.md`](slc/text2gears.md),
281
+ [`slc/gears2fsm.md`](slc/gears2fsm.md), and [`slc/link.md`](slc/link.md)
282
+ — the FSM-to-runtime contract that `@sublang/playbook/runtime` projects
283
+ into TypeScript.
284
+
260
285
  ## Workflow
261
286
 
262
287
  playbook is itself spec-driven: the compiler phases are specs in `slc/`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sublang/playbook",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "description": "Reference CODE playbook — XState v5 FSM, host-agnostic runtime, and Playbook Captain shell for a coder/reviewer/committer loop driven by GEARS spec items.",
6
6
  "license": "Apache-2.0",
@@ -25,6 +25,12 @@
25
25
  "node": ">=20.6.0"
26
26
  },
27
27
  "files": [
28
+ "src/runtime.ts",
29
+ "src/runtime.js",
30
+ "src/runtime.d.ts",
31
+ "slc/link.md",
32
+ "slc/gears2fsm.md",
33
+ "slc/text2gears.md",
28
34
  "reference/sdlc/code.playbook/code.fsm.ts",
29
35
  "reference/sdlc/code.playbook/code.fsm.js",
30
36
  "reference/sdlc/code.playbook/code.fsm.d.ts",
@@ -53,6 +59,10 @@
53
59
  "playbook-code": "reference/sdlc/code.playbook/bin/playbook-code.js"
54
60
  },
55
61
  "exports": {
62
+ "./runtime": {
63
+ "types": "./src/runtime.d.ts",
64
+ "default": "./src/runtime.js"
65
+ },
56
66
  "./code/playbook": {
57
67
  "types": "./reference/sdlc/code.playbook/code.playbook.d.ts",
58
68
  "default": "./reference/sdlc/code.playbook/code.playbook.js"
@@ -64,7 +74,8 @@
64
74
  "./code/tmux-play": {
65
75
  "types": "./reference/sdlc/code.playbook/code.tmux-play.d.ts",
66
76
  "default": "./reference/sdlc/code.playbook/code.tmux-play.js"
67
- }
77
+ },
78
+ "./slc/*": "./slc/*"
68
79
  },
69
80
  "scripts": {
70
81
  "build": "tsc",
@@ -78,7 +89,7 @@
78
89
  "dependencies": {
79
90
  "@anthropic-ai/claude-agent-sdk": "^0.3.143",
80
91
  "@openai/codex-sdk": "^0.139.0",
81
- "@sublang/cligent": "latest",
92
+ "@sublang/cligent": "^0.13.0",
82
93
  "xstate": "^5.19.4",
83
94
  "yaml": "^2.9.0"
84
95
  },
@@ -264,8 +264,15 @@ export function composeRuntimeConfig(
264
264
  const composed = {};
265
265
  const theme = overlayConfig.theme ?? base?.theme;
266
266
  if (theme !== undefined) composed.theme = theme;
267
- const layout = overlayConfig.layout ?? base?.layout;
268
- if (layout !== undefined) composed.layout = layout;
267
+ // PBCODE-16/17: an overlay `layout` is the user's own tmux-play layout
268
+ // and is carried through verbatim; a base-inherited `layout` is
269
+ // reconciled so it round-trips onto the composed coder + reviewer roster
270
+ // (cligent normalizes the base layout to the base config's own roster).
271
+ if (overlayConfig.layout !== undefined) {
272
+ composed.layout = overlayConfig.layout;
273
+ } else if (base?.layout !== undefined) {
274
+ composed.layout = composeInheritedLayout(base.layout);
275
+ }
269
276
  const notifications = overlayConfig.notifications ?? base?.notifications;
270
277
  if (notifications !== undefined) composed.notifications = notifications;
271
278
  composed.captain = captain;
@@ -273,6 +280,33 @@ export function composeRuntimeConfig(
273
280
  return composed;
274
281
  }
275
282
 
283
+ // PBCODE-17: reconcile a base-inherited `layout` so it round-trips onto
284
+ // the composed `coder` + `reviewer` roster. cligent's loaded base `layout`
285
+ // is normalized to the base config's own roster and shape: `initialVisible`
286
+ // names the base config's players (cligent rejects ids outside the composed
287
+ // roster) and the normalized output carries both the `columnWeights` alias
288
+ // and the canonical `singlePlayerColumnWeights` / `multiPlayerColumnWeights`
289
+ // (cligent emits the pair on load but rejects it on reload). Drop
290
+ // `initialVisible` — CODE shows its full composed roster, which cligent
291
+ // restores when the field is omitted — and drop the `columnWeights` alias
292
+ // whenever the canonical field for the alias's shape is present, carrying
293
+ // window and weights through otherwise. An overlay `layout` is the user's
294
+ // own and is not reconciled.
295
+ function composeInheritedLayout(layout) {
296
+ if (!isObject(layout)) return layout;
297
+ const result = { ...layout };
298
+ delete result.initialVisible;
299
+ const alias = result.columnWeights;
300
+ if (Array.isArray(alias)) {
301
+ const conflictsSingle =
302
+ alias.length === 2 && result.singlePlayerColumnWeights !== undefined;
303
+ const conflictsMulti =
304
+ alias.length === 3 && result.multiPlayerColumnWeights !== undefined;
305
+ if (conflictsSingle || conflictsMulti) delete result.columnWeights;
306
+ }
307
+ return result;
308
+ }
309
+
276
310
  export function adaptersFromComposedConfig(config) {
277
311
  const adapters = new Set();
278
312
  const captainAdapter = config?.captain?.adapter;
@@ -1,26 +1,6 @@
1
1
  import { type CaptainInput, type CaptainOutput, type CodingEvent, type CodingInput } from './code.fsm.js';
2
- export interface PlayerResult {
3
- status: 'ok' | 'aborted' | 'error';
4
- finalText?: string;
5
- error?: string;
6
- }
7
- export interface PlaybookPorts {
8
- callPlayer(playerId: string, prompt: string, signal: AbortSignal): Promise<PlayerResult>;
9
- callJudge(prompt: string, signal: AbortSignal): Promise<string>;
10
- emitStatus(message: string, data?: unknown): Promise<void>;
11
- emitTelemetry(event: {
12
- topic: string;
13
- payload: unknown;
14
- }): Promise<void>;
15
- }
16
- export interface PlaybookRuntime {
17
- init(ports: PlaybookPorts): Promise<void>;
18
- handleBossInput(turn: {
19
- text: string;
20
- signal: AbortSignal;
21
- }): Promise<void>;
22
- dispose(): Promise<void>;
23
- }
2
+ import type { PlaybookPorts, PlaybookRuntime, PlayerResult } from '@sublang/playbook/runtime';
3
+ export type { PlayerResult, PlaybookPorts, PlaybookRuntime };
24
4
  export type CodePlaybookOptions = CodingInput;
25
5
  declare function normalizeErrorCompact(err: unknown): {
26
6
  name: string;
@@ -76,4 +56,3 @@ export declare const _internal: {
76
56
  VERBATIM_PAYLOAD_FIELDS: ReadonlySet<string>;
77
57
  };
78
58
  export default function createPlaybookRuntime(options: CodePlaybookOptions): PlaybookRuntime;
79
- export {};
@@ -11,6 +11,9 @@
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 / PlaybookRuntime imported
15
+ // and re-exported from @sublang/playbook/runtime
16
+ // (slc/link.md §Output, DR-004 Addendum A4)
14
17
  import { createActor, fromPromise } from 'xstate';
15
18
  import { codingMachine, } from './code.fsm.js';
16
19
  import { enumerateAwaitBossReply, enumerateCaptainStates, enumerateRootEvents, } from './code.fsm.introspect.js';
@@ -11,6 +11,9 @@
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 / PlaybookRuntime imported
15
+ // and re-exported from @sublang/playbook/runtime
16
+ // (slc/link.md §Output, DR-004 Addendum A4)
14
17
 
15
18
  import { createActor, fromPromise } from 'xstate';
16
19
  import {
@@ -25,33 +28,20 @@ import {
25
28
  enumerateCaptainStates,
26
29
  enumerateRootEvents,
27
30
  } from './code.fsm.introspect.js';
28
-
29
- // Public contract — `PlayerResult`, `PlaybookPorts`, `PlaybookRuntime`,
30
- // `CodePlaybookOptions`, and the default `createPlaybookRuntime` factory
31
- // per slc/link.md and DR-004 §10.
32
-
33
- export interface PlayerResult {
34
- status: 'ok' | 'aborted' | 'error';
35
- finalText?: string;
36
- error?: string;
37
- }
38
-
39
- export interface PlaybookPorts {
40
- callPlayer(
41
- playerId: string,
42
- prompt: string,
43
- signal: AbortSignal,
44
- ): Promise<PlayerResult>;
45
- callJudge(prompt: string, signal: AbortSignal): Promise<string>;
46
- emitStatus(message: string, data?: unknown): Promise<void>;
47
- emitTelemetry(event: { topic: string; payload: unknown }): Promise<void>;
48
- }
49
-
50
- export interface PlaybookRuntime {
51
- init(ports: PlaybookPorts): Promise<void>;
52
- handleBossInput(turn: { text: string; signal: AbortSignal }): Promise<void>;
53
- dispose(): Promise<void>;
54
- }
31
+ import type {
32
+ PlaybookPorts,
33
+ PlaybookRuntime,
34
+ PlayerResult,
35
+ } from '@sublang/playbook/runtime';
36
+
37
+ // Public contract. `PlayerResult`, `PlaybookPorts`, and `PlaybookRuntime`
38
+ // are re-exported from the shared `@sublang/playbook/runtime` module
39
+ // (slc/link.md §Output, DR-004 Addendum A4) so this playbook and any
40
+ // future one resolve one contract definition rather than redefining it.
41
+ // `CodePlaybookOptions` and the default `createPlaybookRuntime` factory
42
+ // (typed `PlaybookRuntimeFactory<CodePlaybookOptions>`) stay
43
+ // CODE-specific.
44
+ export type { PlayerResult, PlaybookPorts, PlaybookRuntime };
55
45
 
56
46
  export type CodePlaybookOptions = CodingInput;
57
47
 
@@ -27,7 +27,7 @@
27
27
  # instead of requiring bypass permissions.
28
28
  # - players.committer optionally aliases the Committer to a role
29
29
  # (coder or reviewer); that role's pane runs the commit turn. The
30
- # seeded value is reviewer; omit it to fall back to the coder.
30
+ # seeded value is coder; omitting it uses the same Coder fallback.
31
31
  # - layout sizes the tmux window (layout.window: columns × rows) and
32
32
  # sets the relative column widths (layout.columnWeights) for the
33
33
  # Boss/Captain, Coder, and Reviewer columns.
@@ -49,24 +49,24 @@ notifications:
49
49
 
50
50
  captain:
51
51
  adapter: claude
52
- model: claude-sonnet-4-6
52
+ model: claude-opus-4-8
53
53
  reasoningEffort: high
54
54
  permissions:
55
55
  mode: auto
56
56
 
57
57
  players:
58
58
  coder:
59
- adapter: codex
60
- model: gpt-5.5
59
+ adapter: claude
60
+ model: claude-opus-4-8[1m]
61
61
  reasoningEffort: xhigh
62
62
  permissions:
63
63
  mode: auto
64
- writablePaths:
65
- - .git
66
64
  reviewer:
67
- adapter: claude
68
- model: claude-opus-4-8
65
+ adapter: codex
66
+ model: gpt-5.5
69
67
  reasoningEffort: xhigh
70
68
  permissions:
71
69
  mode: auto
72
- committer: reviewer
70
+ writablePaths:
71
+ - .git
72
+ committer: coder
@@ -19,12 +19,15 @@ captain:
19
19
  # shim that delegates to the same shell with CODE registered.
20
20
  from: ./playbook-captain.js
21
21
  adapter: claude
22
- model: claude-sonnet-4-6
22
+ model: claude-opus-4-8
23
23
  reasoningEffort: high
24
24
  # Agents run in cligent's classifier/reviewer-protected auto mode
25
25
  # (cligent DR-005): claude → permissionMode auto, codex → auto_review.
26
26
  permissions:
27
27
  mode: auto
28
+ options:
29
+ code:
30
+ committer: coder
28
31
 
29
32
  # `players[].id` shall match the baked playerId strings the adapter
30
33
  # routes to. The CODE playbook bakes Coder→'coder' and
@@ -38,7 +41,7 @@ captain:
38
41
  players:
39
42
  - id: coder
40
43
  adapter: claude
41
- model: claude-opus-4-7
44
+ model: claude-opus-4-8[1m]
42
45
  reasoningEffort: xhigh
43
46
  permissions:
44
47
  mode: auto
@@ -48,3 +51,5 @@ players:
48
51
  reasoningEffort: xhigh
49
52
  permissions:
50
53
  mode: auto
54
+ writablePaths:
55
+ - .git
@@ -13,15 +13,18 @@ notifications:
13
13
  captain:
14
14
  from: "@sublang/playbook/playbook-captain"
15
15
  adapter: claude
16
- model: claude-sonnet-4-6
16
+ model: claude-opus-4-8
17
17
  reasoningEffort: high
18
18
  permissions:
19
19
  mode: auto
20
+ options:
21
+ code:
22
+ committer: coder
20
23
 
21
24
  players:
22
25
  - id: coder
23
26
  adapter: claude
24
- model: claude-opus-4-7
27
+ model: claude-opus-4-8[1m]
25
28
  reasoningEffort: xhigh
26
29
  permissions:
27
30
  mode: auto
@@ -31,3 +34,5 @@ players:
31
34
  reasoningEffort: xhigh
32
35
  permissions:
33
36
  mode: auto
37
+ writablePaths:
38
+ - .git
@@ -0,0 +1,209 @@
1
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
2
+ <!-- SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai> -->
3
+
4
+ # GEARS-to-Finite-State-Machine Transformation
5
+
6
+ Second phase of a playbook (a state-machine agent orchestrating other agents).
7
+ Transforms normative GEARS spec items into an XState v5 finite state machine.
8
+
9
+ - Source: GEARS spec items produced by the first phase.
10
+ - Target: an XState v5 machine object artifact [[1]].
11
+
12
+ Target is an object artifact only: it defines the machine, actor contracts, and typed inputs, but shall not bind a runner or supply concrete runtime implementations.
13
+
14
+ ## Formats
15
+
16
+ | Role | Format | Extension |
17
+ | --- | --- | --- |
18
+ | source | gears | .md |
19
+ | target | fsm | .ts |
20
+
21
+ ## Setup
22
+
23
+ The artifact shall use XState v5's `setup(...)` then `.createMachine(...)` [[10]].
24
+ The `types` block shall declare `context`, `events`, machine `input`, and a typed `Captain` actor contract [[11]].
25
+ The artifact shall not import a runner or bake in a concrete Captain implementation; any actor placeholder shall fail explicitly (e.g., throw `'captain actor must be provided by the runner'`).
26
+
27
+ `CaptainInput` shall be a typed object with at least:
28
+
29
+ - `player`: the [player](text2gears.md#players) Captain is to invoke;
30
+ - `sourceItem`: the GEARS item ID this state realizes;
31
+ - `prompt`: the source item's full final prompt, verbatim;
32
+ - `result`: a record whose keys are the valid guard names this invocation may return.
33
+
34
+ `CaptainOutput` shall be a discriminated object with `guard: string` and any extracted fields downstream states need.
35
+
36
+ Guard names shall be specified and interpreted **per state**, not as a global union.
37
+ A global union encourages name reuse with divergent semantics and couples unrelated states.
38
+ Shared helpers may accept `string`, but each state's `invoke.input.result` is the authoritative local contract.
39
+
40
+ ## States
41
+
42
+ Each state shall declare:
43
+
44
+ - a stable `id` (for `#id` targeting and Boss interrupts);
45
+ - an intuitive state key (the property name under `states: { ... }`);
46
+ - a one-line `description` (for inspector tools and documentation);
47
+ - if it invokes Captain: `invoke.input` carrying `player`, `sourceItem`, `prompt`, `result` (per [Setup](#setup)).
48
+
49
+ The source item ID shall live in `invoke.input.sourceItem`, not in a comment — this keeps the GEARS-to-state mapping machine-readable.
50
+ A state's `invoke.input.player` shall match its source item's player.
51
+
52
+ Captain returns a discriminated result with `guard` set to one of `input.result`'s keys [[4]].
53
+ Guards [[5]] on `onDone` transitions inspect `event.output.guard` to route.
54
+
55
+ Example:
56
+
57
+ ```typescript
58
+ invoke: {
59
+ src: 'captain',
60
+ input: ({ context }): CaptainInput => ({
61
+ player: 'Reviewer',
62
+ sourceItem: '<ITEM-A>',
63
+ prompt: [
64
+ 'Flag any issues or improvements (numbered; no duplication).',
65
+ "Think thoroughly — don't just approve or reject.",
66
+ ].join('\n'),
67
+ result: {
68
+ hasFindings: 'Reviewer raised issues or suggestions.',
69
+ noFindings: 'Reviewer has no findings.',
70
+ },
71
+ }),
72
+ onDone: [...],
73
+ onError: { target: 'failed', actions: 'rememberCaptainError' },
74
+ }
75
+ ```
76
+
77
+ ## Mapping
78
+
79
+ Each Source spec item shall map to exactly one state in Target.
80
+ A state's `invoke.input.sourceItem` shall be that item's ID, and `invoke.input.prompt` shall carry the item's prompt verbatim.
81
+
82
+ Per text2gears [composition](text2gears.md#composition), each spec item already carries the full final prompt for one state behavior, with no duplicate lines.
83
+ The FSM compiler shall not concatenate prompts across items, re-compose them, or silently dedupe.
84
+ A spec item that still contains duplicate prompt lines is malformed; the compiler shall reject or flag it rather than silently propagate the duplication into `invoke.input.prompt`.
85
+
86
+ ## Context and prompts
87
+
88
+ Context fields used to drive guards or compose prompts shall be **typed and named**.
89
+ The compiler shall not branch on untyped properties of `lastResult`; persistent routing decisions belong in typed context fields. (`lastResult` is for inspection only.)
90
+
91
+ Prompts shall pass only the **specific extracted fields** the player needs.
92
+ The compiler shall not dump `JSON.stringify(lastResult)` or any opaque blob: it leaks internal `guard` strings, wastes tokens, and confuses the LLM.
93
+
94
+ Player bindings and per-run parameters shall flow in via the machine's `input` and be copied into context at start-up.
95
+ The artifact shall not bake in player bindings, model names, or per-run values.
96
+
97
+ ## Transitions
98
+
99
+ A transition fires on an event — typically `onDone` (actor completed) [[4]].
100
+ When multiple are possible, a synchronous guard [[5]] picks the path.
101
+ Transitions shall persist relevant typed fields from `event.output` to context via `assign` [[6]] so downstream prompts can read them.
102
+ Transitions shall be self-driving when source items define the next obligation.
103
+ Routing to an idle hub is for recovery, unrecoverable Boss input, or one-shot entry events — not the happy path.
104
+
105
+ ### Auto-advance on approval
106
+
107
+ A review/approval state's success outcome shall **target the next workflow step**, not idle back to a hub.
108
+ Returning to Boss on success is a defect: it forces manual stepping.
109
+
110
+ ### Don't re-validate what already passed
111
+
112
+ A state following an approval shall not enter a fresh approval of the same content — that adds latency and risks ping-pong loops.
113
+ A state may route through approval once when its input came from an unreviewed branch (e.g., re-do without an intervening review).
114
+
115
+ ### One feedback cycle across phases
116
+
117
+ When the source has a feedback cycle, all phases that need feedback shall reuse it, not duplicate it per phase.
118
+ Phases may set typed routing fields so terminal outcomes return to the originating branch.
119
+
120
+ ## Boss control
121
+
122
+ [Boss](text2gears.md#players) input enters the machine through three surfaces: pre-emptive interrupts on active states, typed entry events on idle or recoverable states, and Boss replies to player questions that suspended the FSM in a dedicated wait state.
123
+
124
+ ### Boss interrupts
125
+
126
+ Boss may interrupt any active state at any time. Every jumpable state shall have a stable `id` [[9]].
127
+ The runtime sends `{ type: 'BOSS_INTERRUPT', targetId: '<id>' }`; the root machine handles it with one guarded transition per jumpable state targeting `#<id>` with `reenter: true` [[7]][[8]][[9]], so invoked actors restart cleanly.
128
+ The compiler shall emit a `bossInterrupts(ids)` helper rather than hand-writing one transition per state.
129
+ XState automatically stops the current state's invoked actor on transition [[2]].
130
+
131
+ ### Boss entry events vs. BOSS_INTERRUPT
132
+
133
+ `BOSS_INTERRUPT` jumps into an **active** machine, pre-empting whichever state is running.
134
+ **Boss entry events** start or resume from idle or recoverable states when Boss-supplied parameters can't be inferred from machine state alone.
135
+ Entry events shall be typed alongside `BOSS_INTERRUPT` and populate context via a dedicated action.
136
+ The two surfaces shall not be collapsed: `BOSS_INTERRUPT` cannot carry payload, and a parameterless entry event may collapse to interrupt-style routing only when state-jump semantics are identical.
137
+ Entry events shall not be root-level transitions from every active state unless the workflow supports pre-emption; they belong on idle and recoverable states (e.g., `failed`).
138
+
139
+ ### Boss-reply suspension
140
+
141
+ When a captain-invoking state needs a Boss decision the player cannot supply alone, the machine shall suspend in a dedicated quiescent state and resume the same state with the Q+A in the next prompt.
142
+ This is a third Boss surface alongside `BOSS_INTERRUPT` and Boss entry events.
143
+
144
+ Every captain-invoking state supports this path.
145
+ There is no source-level opt-in annotation and no `needsBossReply` result metadata in GEARS output.
146
+ The FSM compiler shall preserve the GEARS blockquote as the state's domain `prompt` body and shall not inject any Boss-question instruction into `invoke.input.prompt`.
147
+
148
+ For every captain-invoking state, the compiler shall add `needsBossReply` to the state's `invoke.input.result` map.
149
+ The description shall be the standard adjudicator-facing text:
150
+
151
+ ```text
152
+ The player's prose surfaces a clarifying question for Boss that the player cannot answer alone. Output shall include `question: <verbatim question text from the player's prose>`.
153
+ ```
154
+
155
+ It shall include the load-bearing substring ``Output shall include `question:`` so the runtime's adjudicator requires `question` in the JSON reply.
156
+ The linked runtime composes player prompts per [link.md "Player prompt composition"](link.md#player-prompt-composition), without adding a player-visible Boss-question instruction.
157
+
158
+ The machine shall declare:
159
+
160
+ - An `awaitBossReply` state with stable `id: 'awaitBossReply'` and `description: 'Waiting for Boss to answer a player question.'`.
161
+ - A `BOSS_REPLY` event carrying `{ answer: string }`.
162
+ - Context fields `pendingBossQuestion?: { resumeStateId, sourceItem, player, question }` and `bossReply?: string`.
163
+ Field provenance is normative: `resumeStateId`, `sourceItem`, and `player` shall come from the suspended state's invocation metadata; only `question` shall come from adjudicated player output.
164
+
165
+ The compiler shall emit three helpers:
166
+
167
+ - `resumableStates(ids)` — emits one `BOSS_REPLY` arm per registered state on `awaitBossReply.on.BOSS_REPLY`, each guarded on `context.pendingBossQuestion?.resumeStateId === '<id>'` and targeting `'#<id>'` with `reenter: true`.
168
+ The compiler shall register every captain-invoking state id with this helper.
169
+ The helper is analogous to `bossInterrupts(ids)`.
170
+ - `setPendingBossQuestion` — `assign({ pendingBossQuestion: <new>, bossReply: undefined })`.
171
+ Used on every `needsBossReply` arm; clearing `bossReply` here prevents a follow-up question from inheriting the prior answer.
172
+ - `clearBossReplyContext` — `assign({ pendingBossQuestion: undefined, bossReply: undefined })`.
173
+ Used on every transition out of `awaitBossReply` other than the resume arm, and on every non-`needsBossReply` outcome of a captain-invoking state.
174
+
175
+ `awaitBossReply` is a quiescent state for the runtime's drive loop.
176
+ It shall declare the standard `bossInterrupts(ids)` handler with `actions: clearBossReplyContext`, so a Boss interrupt event abandons a pending question.
177
+ The machine's root-level Boss entry events shall be re-declared on `awaitBossReply` with `actions: clearBossReplyContext`, so a fresh Boss directive while waiting starts a fresh turn and clears stale context.
178
+
179
+ A captain-invoking state's `invoke.input` function shall carry `pendingBossQuestion` and `bossReply` fields when present so the linked runtime can compose the continuation prompt.
180
+ When both fields are present, the linked runtime shall compose the continuation preamble and labelled Q&A blocks per [link.md "Player prompt composition"](link.md#player-prompt-composition).
181
+ The FSM artifact shall not bake the continuation preamble into the GEARS-derived `prompt` body.
182
+
183
+ The following malformed states shall route to `failed` per [Errors and termination](#errors-and-termination):
184
+
185
+ - Captain output has `guard: 'needsBossReply'` but no `question` field.
186
+ - Captain output declares `needsBossReply` from a state not registered with `resumableStates(ids)`.
187
+ - `BOSS_REPLY` fired with empty or whitespace-only `answer`.
188
+
189
+ ## Errors and termination
190
+
191
+ Every `invoke` shall declare an `onError` handler routing to a dedicated `failed` state, with the error captured in `context.lastError` for inspection.
192
+ `failed` is not `final`: Boss may interrupt out of it to recover.
193
+
194
+ Every machine shall declare at least one `type: 'final'` state (typically `done`) reachable on completion.
195
+ A never-terminating machine is a defect: the runner has no completion signal.
196
+
197
+ ## References
198
+
199
+ [1]: https://stately.ai/docs/xstate "XState Official Documentation"
200
+ [2]: https://stately.ai/docs/invoke "Invoke — invoking actors from states"
201
+ [3]: https://stately.ai/docs/input "Input — passing data to invoked actors"
202
+ [4]: https://stately.ai/docs/output "Output — receiving actor results via onDone"
203
+ [5]: https://stately.ai/docs/guards "Guards — synchronous transition conditions"
204
+ [6]: https://stately.ai/docs/context "Context — persistent state and assign"
205
+ [7]: https://stately.ai/docs/transitions "Transitions — reenter, root-level routing"
206
+ [8]: https://stately.ai/docs/parent-states "Parent states — root-level event handling"
207
+ [9]: https://stately.ai/docs/finite-states "Finite states — state IDs"
208
+ [10]: https://stately.ai/docs/setup "Setup — typed machine setup"
209
+ [11]: https://stately.ai/docs/actors "Actors — typed actor contracts"
package/slc/link.md ADDED
@@ -0,0 +1,341 @@
1
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
2
+ <!-- SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai> -->
3
+
4
+ # FSM-to-Runtime Linking
5
+
6
+ Third phase of a playbook (a state-machine agent orchestrating other agents).
7
+ Compiles the [gears2fsm](gears2fsm.md) artifact into a **`PlaybookRuntime`**: a host-agnostic runner that:
8
+
9
+ - Drives the FSM.
10
+ - Classifies Boss input into typed events.
11
+ - Runs the Captain-actor against the playbook's players.
12
+ - Adjudicates player output into FSM guards.
13
+ - Surfaces transitions as status/telemetry.
14
+
15
+ The runtime is invoked through the stable `PlaybookPorts` contract.
16
+ Presentation layers (tmux-play, web, CLI, tests) implement the four ports once and inherit every playbook.
17
+
18
+ - Source: an XState v5 machine artifact (`.fsm.ts`) produced by gears2fsm.
19
+ - Target: a `PlaybookRuntime` factory module — TypeScript, host-agnostic.
20
+
21
+ Hosts are out of scope for this phase.
22
+ Each host has a small adapter (~30 lines) that loads a `PlaybookRuntime` module and supplies the host's primitives as `PlaybookPorts`.
23
+ The adapter shall speak only `PlaybookPorts` to the runtime and shall not leak host types back into it.
24
+
25
+ The link compiler shall not modify the FSM artifact and shall not re-derive Captain prompts, result keys, or guard semantics — those are fixed by the FSM.
26
+
27
+ ## Formats
28
+
29
+ | Role | Format | Extension |
30
+ | --- | --- | --- |
31
+ | source | fsm | .ts |
32
+ | target | playbook | .ts |
33
+
34
+ ## PlaybookRuntime contract
35
+
36
+ The emitted module shall default-export a factory of the following shape:
37
+
38
+ ```typescript
39
+ interface PlaybookRuntime {
40
+ init(ports: PlaybookPorts): Promise<void>;
41
+ handleBossInput(turn: { text: string; signal: AbortSignal }): Promise<void>;
42
+ dispose(): Promise<void>;
43
+ }
44
+
45
+ type PlaybookRuntimeFactory<Options = unknown> = (
46
+ options: Options,
47
+ ) => PlaybookRuntime;
48
+
49
+ export default function createPlaybookRuntime(
50
+ options: PlaybookRuntimeOptions,
51
+ ): PlaybookRuntime;
52
+ ```
53
+
54
+ The default export conforms to `PlaybookRuntimeFactory<PlaybookRuntimeOptions>`, the generic factory type the shared contract module exposes (§Output).
55
+
56
+ `init` receives the host's ports, constructs the XState actor with FSM `input` derived from `options`, and starts the actor.
57
+ The runtime owns the actor for its lifetime; `handleBossInput` runs one turn, and `dispose` stops the actor and drains pending port emissions.
58
+
59
+ `PlaybookRuntimeOptions` is host-agnostic and carries only *per-run* knobs such as identity strings (e.g., model names a playbook substitutes into prompt placeholders) and strategy overrides the linker exposes.
60
+ The link compiler emits a typed options interface per playbook based on the FSM's `CodingInput` (or equivalent).
61
+
62
+ Player binding is a *linker-time* input baked into the emitted runtime by default.
63
+ A linker may also expose it via `PlaybookRuntimeOptions` for per-run remapping; the contract requires only that the runtime ship with a deterministic binding it applies at every `callPlayer` site.
64
+
65
+ ## PlaybookPorts contract
66
+
67
+ ```typescript
68
+ interface PlaybookPorts {
69
+ callPlayer(playerId: string, prompt: string, signal: AbortSignal):
70
+ Promise<PlayerResult>;
71
+ callJudge(prompt: string, signal: AbortSignal):
72
+ Promise<string>;
73
+ emitStatus(message: string, data?: unknown): Promise<void>;
74
+ emitTelemetry(event: { topic: string; payload: unknown }): Promise<void>;
75
+ }
76
+
77
+ interface PlayerResult {
78
+ status: 'ok' | 'aborted' | 'error';
79
+ finalText?: string;
80
+ error?: string;
81
+ }
82
+ ```
83
+
84
+ `PlayerResult` mirrors cligent's `PlayerRunResult` shape ([TMUX-033](https://github.com/sublang-ai/cligent/blob/main/specs/user/tmux-play.md#tmux-033)), so a tmux-play port adapter is direct assignment.
85
+ The runtime treats `status !== 'ok'` as a player failure and routes it through the FSM's error path (§Abort).
86
+
87
+ `callJudge` returns free-form text.
88
+ The runtime parses it per the state's adjudication strategy (§Captain adjudication).
89
+ One port serves both classifier and adjudicator — they vary only in prompt.
90
+
91
+ `emitStatus` is human-readable; `emitTelemetry` is structured.
92
+ Both are async and shall be ordered, awaited, and never-dropped; the runtime awaits each emission before issuing the next.
93
+
94
+ The runtime never speaks to LLMs directly and never touches host types beyond `PlaybookPorts`.
95
+
96
+ ## Linker inputs
97
+
98
+ The link compiler shall accept:
99
+
100
+ - The FSM artifact (path to a `.fsm.ts`).
101
+ - A **player binding** mapping GEARS players (declared in the
102
+ [text2gears](text2gears.md#players) source) to opaque player-identifier
103
+ strings.
104
+ - An **adjudication strategy** (default: LLM-judge per state) and a
105
+ **Boss-event mapping** (default: free-text judge classification).
106
+ Both strategies are host-agnostic.
107
+
108
+ The host's identity does not enter compilation; the linked module runs unchanged under any host that implements `PlaybookPorts`.
109
+
110
+ ## Player binding
111
+
112
+ Each GEARS state names exactly one player (`invoke.input.player`).
113
+ The linker shall map every named player to a `playerId` string used in `PlaybookPorts.callPlayer(playerId, …)`.
114
+ The host adapter routes that opaque string to its concrete primitive.
115
+
116
+ For composite players declared with aliases (e.g., `Committer = Coder | Reviewer`), the linker shall resolve the alias **per source item**.
117
+ Resolution inspects the `CaptainInput` fields populated at that state:
118
+
119
+ - If only one `<playerName>Player` field is present, bind to that player.
120
+ - If multiple are present, prefer the first-listed alternative in the alias declaration order.
121
+ - If none are present, fall back to the alias's first alternative.
122
+
123
+ Resolution shall be deterministic and recorded in the emitted module so future maintainers can audit it without re-running the linker.
124
+
125
+ The linker shall not invent player identifiers and shall not silently collapse aliases at the FSM level — composite players keep their `player: 'Committer'` value on `CaptainInput`; resolution decides only the `callPlayer` invocation.
126
+
127
+ ## Player prompt composition
128
+
129
+ The runtime shall compose the actual player prompt from the state's `CaptainInput`.
130
+ `input.prompt` is the GEARS-derived domain prompt body and shall not be mutated, re-flowed, or treated as a place to store framework control instructions.
131
+
132
+ The composer may prepend structured labelled blocks from typed `CaptainInput` fields the FSM exposes (for example `Boss intent:`, `Review items:`, `Rebuttals:`, or `Task description:`).
133
+ Those blocks are outside the domain prompt body.
134
+
135
+ The composer shall not inject a player-visible Boss-question instruction.
136
+ Boss-question detection is adjudicator-facing: it comes from the state's `needsBossReply` result description, not from extra prompt text.
137
+
138
+ When `CaptainInput` carries both `pendingBossQuestion` and `bossReply`, the composer shall prepend the continuation preamble and labelled Q&A blocks before ordinary structured blocks and before the domain prompt body:
139
+
140
+ ```text
141
+ You previously paused this task to ask Boss a question; Boss has now replied. Continue the same task using the reply below.
142
+
143
+ Boss question:
144
+ <pendingBossQuestion.question>
145
+
146
+ Boss reply:
147
+ <bossReply>
148
+ ```
149
+
150
+ The continuation preamble is framework text supplied by the runtime.
151
+ It is not part of the GEARS blockquote and shall not appear in `invoke.input.prompt`.
152
+
153
+ ## Boss-event mapping
154
+
155
+ The FSM's `events` union enumerates every Boss-originated event.
156
+ The runtime receives Boss input as a free-form string (`handleBossInput.text`) and shall classify each non-empty turn into one of the FSM's events plus its payload, or no FSM action, by invoking `callJudge`.
157
+ Empty or whitespace-only text produces no event and no port call.
158
+
159
+ The classifier prompt shall demand JSON against the FSM's typed event union and any state-specific Boss input contract, including the payload fields required for each event.
160
+ When the FSM supports a Boss-reply suspension state, the prompt shall include the current state and the pending Boss question so the judge can distinguish a reply from a fresh directive.
161
+
162
+ A playbook runtime shall not define slash-prefix commands for states or features inside that playbook.
163
+ The `/command` namespace is reserved for host-level or playbook-selection UX before a turn reaches `handleBossInput`.
164
+ If a host forwards text beginning with `/` to `handleBossInput`, the runtime treats it as ordinary Boss text and classifies it through `callJudge`.
165
+
166
+ Hosts that receive structured control input shall resolve host-level concerns before choosing a playbook runtime.
167
+ Once they call `handleBossInput`, they shall pass the Boss content as text and shall not pre-classify in-playbook FSM events or rely on slash forms as a runtime protocol.
168
+
169
+ `BOSS_INTERRUPT` (or the FSM's equivalent explicit-state-jump event) is reached only by the judge choosing it and supplying its required target payload.
170
+ It is *not* an abort surface; aborts go through the abort signal and the strategies in §Abort.
171
+ Hosts where the abort signal is terminal (e.g., SIGINT runs shutdown) shall not route abort to `BOSS_INTERRUPT`.
172
+
173
+ ## Captain adjudication
174
+
175
+ After a player call returns, the runtime shall coerce `result.finalText` into one of the **per-state** `invoke.input.result` keys.
176
+ It shall also extract any payload fields the state's `result` description names as required.
177
+
178
+ Two default adjudication strategies, in selection order:
179
+
180
+ - **LLM-judge** (default): construct a fresh prompt for `callJudge` that
181
+ names the source item's player, includes the player's verbatim output,
182
+ lists the `result` keys with their descriptions, and demands a JSON
183
+ `{ guard, …payloadFields }` answer keyed to exactly one of the
184
+ declared guards. The judge prompt shall not interpret the player's
185
+ output, paraphrase it, or alter the FSM's `result` text — it carries
186
+ the description verbatim.
187
+ - **Marker-parse** (alternative): a deterministic parser that scans the
188
+ player output for a terminal control line such as
189
+ `FSM-RESULT: { "guard": "...", ... }`. Useful when player adapters can
190
+ be steered to emit structured trailers and the operator wants to avoid
191
+ the extra LLM call.
192
+
193
+ The linker may select different strategies per state; the default is **LLM-judge for every state**.
194
+
195
+ The adjudicator shall fail loudly on:
196
+
197
+ - A guard the state does not declare,
198
+ - A missing payload field the state's `result` description requires,
199
+ - An empty / malformed response.
200
+
201
+ Adjudicator failures are control-plane errors.
202
+ The runtime shall propagate them by throwing out of `handleBossInput` after attempting cleanup.
203
+ The host adapter surfaces the throw on its control-plane channel (cligent surfaces such throws as `runtime_error` per [TMUX-025](https://github.com/sublang-ai/cligent/blob/main/specs/user/tmux-play.md#tmux-025)).
204
+ The host's player-result channels (`player_finished` and equivalents) are reserved for failures the player itself produced; the host emits them when `callPlayer` resolves with `status !== 'ok'`.
205
+
206
+ ## Session lifecycle
207
+
208
+ The `PlaybookRuntime` shall:
209
+
210
+ - In `init`, construct the XState actor with FSM `input` derived from
211
+ `options`. The actor is session-scoped, not turn-scoped. Subscribe to
212
+ actor snapshots so each transition can be surfaced via `emitStatus`
213
+ and `emitTelemetry` before the next event fires. Start the actor.
214
+ - Per `handleBossInput`:
215
+ 1. Classify `turn.text` through the Boss-event mapping.
216
+ If it produces no event, return after draining any port emissions.
217
+ 2. If the actor is in a `final` state, dispose and reconstruct it —
218
+ `final` is terminal and cannot accept new events.
219
+ 3. Send the classified event to the actor.
220
+ 4. **Drive to quiescence**: each time the actor invokes its `captain`
221
+ actor, await the invoke's input, build a player prompt, call
222
+ `callPlayer`, adjudicate, and resolve the invoke. Repeat until the
223
+ actor's snapshot value is a state that takes a Boss event
224
+ (typically `ready` or `failed`) or a `final` state.
225
+ - In `dispose`, stop the actor and drain pending port emissions.
226
+
227
+ The actor's `lastError` field shall be surfaced via `emitStatus` when the machine enters its `failed` state.
228
+
229
+ ## Abort
230
+
231
+ `handleBossInput.signal` is the abort surface.
232
+ The runtime shall honor it at every `callPlayer`/`callJudge` and at every poll between transitions.
233
+ On abort, the runtime shall drive the actor to a quiescent state before returning from the turn.
234
+ Three strategies are permitted; the linker selects per FSM:
235
+
236
+ - **Natural rejection** — the runtime's Captain actor (e.g.,
237
+ `fromPromise`) ends the invocation by rejecting, and the FSM routes
238
+ the rejection through `onError` to a quiescent sink. The cancelled
239
+ port call may *itself* reject, or it may resolve with
240
+ `PlayerResult { status: 'aborted' | 'error' }` that the runtime
241
+ inspects and converts into a Captain-actor rejection. Either shape
242
+ is permitted — the contract is on the Captain-actor boundary, not on
243
+ the port's promise behavior. Preferred when every Captain-invoking
244
+ state's `onError` lands somewhere quiescent; the FSM's own error
245
+ wiring is the abort path.
246
+ - **Synthetic pre-emption to a quiescent target** — send the FSM's
247
+ pre-emption event (e.g., `BOSS_INTERRUPT { targetId: <state> }`) with
248
+ a target that is itself quiescent (typically `ready` or `failed`).
249
+ The runtime shall not pick the active state as the target:
250
+ `gears2fsm.md` prescribes `reenter: true` for `bossInterrupts`, so
251
+ re-entering the active state restarts its `invoke` and spawns a
252
+ fresh player call.
253
+ - **Programmatic stop** — `actor.stop()` and report the turn as aborted
254
+ via `emitStatus`. Reserved for FSMs with neither `onError` wiring nor
255
+ a pre-emption event.
256
+
257
+ Whether the host's outer abort (e.g., SIGINT) is recoverable or terminal is the host's concern.
258
+ The runtime exits `handleBossInput` cleanly in either case; the host decides whether to call `dispose` afterward.
259
+
260
+ ## Status and telemetry
261
+
262
+ The runtime shall emit, at minimum:
263
+
264
+ - One `emitStatus` per Boss-relevant transition (entering a state whose
265
+ semantics matter to Boss — e.g., `respondToReview`, `failed`). The
266
+ default is to emit on every transition and let the host filter; hosts
267
+ may bind a stricter rule.
268
+ - One `emitTelemetry` per state transition under a namespaced topic
269
+ (recommended `playbook.fsm.state`), with payload `{ from, to, event }`.
270
+ Observers consume telemetry; the runtime never interprets the topic.
271
+
272
+ Player prompts and adjudicator JSON ride the host's own record channels when the host has them (cligent's `captain_*` / `player_*`).
273
+ The runtime shall not duplicate them into `emitTelemetry`.
274
+
275
+ ## Output
276
+
277
+ The link compiler emits **one** TypeScript module that:
278
+
279
+ - Imports the FSM artifact by relative path.
280
+ - Imports XState's actor primitives (`createActor`, `fromPromise`,
281
+ `setup`'s `.provide`).
282
+ - Exports `createPlaybookRuntime` and the typed `PlaybookRuntimeOptions`
283
+ interface for that playbook.
284
+ - Holds no host-specific types and no host primitive calls. The runtime
285
+ speaks only `PlaybookPorts`.
286
+ - Records the linker inputs (FSM path, player binding, strategies) in a
287
+ top-of-file header comment so the file is reproducible from the same
288
+ inputs.
289
+ - Sources the contract types (`PlayerResult`, `PlaybookPorts`,
290
+ `PlaybookRuntime`, `PlaybookRuntimeFactory`) from a single shared
291
+ type-only module instead of redefining them, and re-exports the names
292
+ its consumers import, so every linked playbook shares one contract
293
+ definition. The shared module imports no FSM or host types, so the
294
+ dependency runs one way — from each linked module to the shared
295
+ contract, never the reverse.
296
+
297
+ ## Host adaptation (informative, not normative)
298
+
299
+ A host integrates with playbooks via a small adapter that:
300
+
301
+ 1. Accepts a path to a `PlaybookRuntime` module (either as a direct
302
+ import in a playbook-specific adapter, or via the host's config
303
+ surface in a generic adapter).
304
+ 2. Imports the module and constructs the runtime with options forwarded
305
+ verbatim from the host config.
306
+ 3. Implements `PlaybookPorts` by wrapping the host's own primitives —
307
+ for cligent/tmux-play this is `callPlayer ← context.callPlayer`,
308
+ `callJudge ← context.callCaptain`, `emitStatus`/`emitTelemetry` ←
309
+ `session.emitStatus`/`session.emitTelemetry`.
310
+ 4. Calls `runtime.init(ports)` once at session start, forwards each
311
+ Boss turn to `runtime.handleBossInput`, and calls
312
+ `runtime.dispose()` at session end.
313
+
314
+ The adapter is ~30 lines regardless of which playbook is loaded.
315
+ Its location is a project-organization choice:
316
+
317
+ - **Playbook repo** — simplest when the playbook author owns the integration; keeps host primitives a lower-layer dependency.
318
+ - **Host repo** — when the host author wants to ship an opt-in playbook Captain.
319
+ - **Third package** — otherwise.
320
+
321
+ This spec is silent on the choice; the contract is the same in any location.
322
+
323
+ ## Out of scope
324
+
325
+ - Defining player prompts, result keys, or guard semantics — those
326
+ belong in the GEARS source and the FSM artifact.
327
+ - Host adapter implementations, host configuration, presentation
328
+ layouts — where these live is a per-project decision (see
329
+ §Host adaptation); this spec only constrains the `PlaybookPorts`
330
+ contract they satisfy.
331
+ - Persisting FSM context across sessions, multi-Boss orchestration, or
332
+ visualizer rendering — separate hosts/observers may add them without
333
+ changing this spec.
334
+
335
+ New behavior in any of these areas requires a separate slc spec.
336
+
337
+ ## References
338
+
339
+ [1]: [text2gears](text2gears.md) "First phase: text → GEARS spec items."
340
+ [2]: [gears2fsm](gears2fsm.md) "Second phase: GEARS items → FSM artifact."
341
+ [3]: https://stately.ai/docs/actors "XState actors — `createActor`, snapshots, abort signal handling."
@@ -0,0 +1,85 @@
1
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
2
+ <!-- SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai> -->
3
+
4
+ # Text-to-GEARS Transformation
5
+
6
+ First phase of a playbook (a state-machine agent orchestrating other agents).
7
+ Transforms a user's procedure description into normative GEARS [[1]] spec items.
8
+
9
+ - Source: free-form natural-language description.
10
+ - Target: a package of GEARS spec items.
11
+
12
+ The second phase (spec items → state machine) is out of scope.
13
+
14
+ ## Formats
15
+
16
+ | Role | Format | Extension |
17
+ | --- | --- | --- |
18
+ | source | text | .md |
19
+ | target | gears | .md |
20
+
21
+ ## Players
22
+
23
+ Players name AI agents and the user.
24
+
25
+ Two default players:
26
+
27
+ - Boss: the human user
28
+ - Captain: the coordinating agent
29
+
30
+ Source may declare additional players in an opening `Players:` section.
31
+ A player may alias other players with `=` and `|`; Boss picks one at runtime.
32
+ E.g.:
33
+
34
+ - Coder
35
+ - Reviewer
36
+ - Committer = Coder | Reviewer
37
+
38
+ Capitalize English player names (e.g., `Writer`); quote non-English names (e.g., `作者`) when needed to distinguish from prose.
39
+
40
+ ## Behaviors
41
+
42
+ Each spec item names a condition, the player to prompt, and the prompt itself.
43
+ Prompts shall be blockquoted, one point per line.
44
+
45
+ E.g.:
46
+
47
+ ```markdown
48
+ ### CODE-10
49
+
50
+ When Reviewer is about to review any change, Captain shall prompt Reviewer:
51
+ > Flag any issues or improvements (numbered; no duplication).
52
+ > Think thoroughly — don't just approve or reject.
53
+ > If the change is ready to commit or push, don't raise nitpicks.
54
+ > Do not edit files or commit; report findings only.
55
+ ```
56
+
57
+ Target should be written in the same language as Source.
58
+
59
+ ## Composition
60
+
61
+ Source snippets may overlap or duplicate.
62
+ When composing them into a spec item, text2gears shall deduplicate identical prompt lines.
63
+
64
+ Each spec item addresses one state behavior and carries its full final prompt (the static part).
65
+ Cross-item duplication is acceptable: spec items are compiled artifacts; Source is what users maintain.
66
+
67
+ Test: a human shall be able to simulate a run by copying any single item's prompt verbatim — no cross-item composition needed.
68
+
69
+ ### Placeholders vs literals
70
+
71
+ Use `<placeholder>` for dynamic values in blockquoted prompts.
72
+ Everything else inside a blockquote is static text, not an example; examples belong in surrounding prose.
73
+
74
+ ### Split by content discriminator
75
+
76
+ Partition items by every variable that determines prompt content — including accumulated state when the trigger alone doesn't.
77
+
78
+ ### Prune dead disjuncts
79
+
80
+ Drop disjunctive branches incompatible with the rest of an item's condition or prompt.
81
+ Dead branches mislead readers and downstream phases.
82
+
83
+ ## References
84
+
85
+ [1]: [GEARS syntax](/specs/meta.md#item-syntax)
@@ -0,0 +1,23 @@
1
+ export interface PlayerResult {
2
+ status: 'ok' | 'aborted' | 'error';
3
+ finalText?: string;
4
+ error?: string;
5
+ }
6
+ export interface PlaybookPorts {
7
+ callPlayer(playerId: string, prompt: string, signal: AbortSignal): Promise<PlayerResult>;
8
+ callJudge(prompt: string, signal: AbortSignal): Promise<string>;
9
+ emitStatus(message: string, data?: unknown): Promise<void>;
10
+ emitTelemetry(event: {
11
+ topic: string;
12
+ payload: unknown;
13
+ }): Promise<void>;
14
+ }
15
+ export interface PlaybookRuntime {
16
+ init(ports: PlaybookPorts): Promise<void>;
17
+ handleBossInput(turn: {
18
+ text: string;
19
+ signal: AbortSignal;
20
+ }): Promise<void>;
21
+ dispose(): Promise<void>;
22
+ }
23
+ export type PlaybookRuntimeFactory<Options = unknown> = (options: Options) => PlaybookRuntime;
package/src/runtime.js ADDED
@@ -0,0 +1,10 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
+ //
4
+ // Public runtime contract for @sublang/playbook — the type-only single
5
+ // source for the PlaybookPorts / PlaybookRuntime contract authored in
6
+ // slc/link.md. It imports no CODE or FSM types, so the dependency runs
7
+ // one way: linked playbook runtimes (e.g. code.playbook.ts) import and
8
+ // re-export these names rather than redefining them
9
+ // (PBRT-5, PBRT-34, DR-004 Addendum A4).
10
+ export {};
package/src/runtime.ts ADDED
@@ -0,0 +1,36 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
+ //
4
+ // Public runtime contract for @sublang/playbook — the type-only single
5
+ // source for the PlaybookPorts / PlaybookRuntime contract authored in
6
+ // slc/link.md. It imports no CODE or FSM types, so the dependency runs
7
+ // one way: linked playbook runtimes (e.g. code.playbook.ts) import and
8
+ // re-export these names rather than redefining them
9
+ // (PBRT-5, PBRT-34, DR-004 Addendum A4).
10
+
11
+ export interface PlayerResult {
12
+ status: 'ok' | 'aborted' | 'error';
13
+ finalText?: string;
14
+ error?: string;
15
+ }
16
+
17
+ export interface PlaybookPorts {
18
+ callPlayer(
19
+ playerId: string,
20
+ prompt: string,
21
+ signal: AbortSignal,
22
+ ): Promise<PlayerResult>;
23
+ callJudge(prompt: string, signal: AbortSignal): Promise<string>;
24
+ emitStatus(message: string, data?: unknown): Promise<void>;
25
+ emitTelemetry(event: { topic: string; payload: unknown }): Promise<void>;
26
+ }
27
+
28
+ export interface PlaybookRuntime {
29
+ init(ports: PlaybookPorts): Promise<void>;
30
+ handleBossInput(turn: { text: string; signal: AbortSignal }): Promise<void>;
31
+ dispose(): Promise<void>;
32
+ }
33
+
34
+ export type PlaybookRuntimeFactory<Options = unknown> = (
35
+ options: Options,
36
+ ) => PlaybookRuntime;