@sublang/playbook 13.3.0 → 14.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 (32) hide show
  1. package/README.md +5 -5
  2. package/docs/configuration.md +10 -2
  3. package/docs/embedding.md +20 -1
  4. package/package.json +6 -1
  5. package/reference/sdlc/code.playbook/bin/launch-config.js +88 -5
  6. package/reference/sdlc/code.playbook/bin/playbook.js +4 -0
  7. package/reference/sdlc/code.playbook/bin/repository-effects.js +11 -3
  8. package/reference/sdlc/code.playbook/bin/run.js +4 -0
  9. package/reference/sdlc/code.playbook/bin/session-store.js +2 -2
  10. package/reference/sdlc/code.playbook/playbook-captain.js +17 -4
  11. package/reference/sdlc/code.playbook/playbook-captain.ts +19 -4
  12. package/reference/sdlc/dev.playbook/dev.playbook.d.ts +3 -10
  13. package/reference/sdlc/dev.playbook/dev.playbook.js +5 -5
  14. package/reference/sdlc/dev.playbook/dev.playbook.ts +7 -7
  15. package/reference/sdlc/pr.md +17 -4
  16. package/reference/sdlc/pr.playbook/pr.fsm.d.ts +17 -3
  17. package/reference/sdlc/pr.playbook/pr.fsm.js +49 -18
  18. package/reference/sdlc/pr.playbook/pr.fsm.ts +63 -20
  19. package/reference/sdlc/pr.playbook/pr.gears.md +20 -7
  20. package/reference/sdlc/pr.playbook/pr.playbook.js +2 -1
  21. package/reference/sdlc/pr.playbook/pr.playbook.ts +2 -1
  22. package/slc/experiments/fsm-scaffold-guidance.md +31 -0
  23. package/slc/gears2fsm.md +145 -58
  24. package/slc/link.md +158 -7
  25. package/slc/materialize-link.mjs +460 -0
  26. package/slc/optimize.md +46 -6
  27. package/slc/scaffold-fsm.mjs +225 -0
  28. package/slc/slc.pin-inputs.json +13 -0
  29. package/slc/text2gears.md +38 -3
  30. package/slc/workflow-contracts.json +371 -0
  31. package/src/xstate-playbook-runtime.js +35 -1
  32. package/src/xstate-playbook-runtime.ts +54 -1
@@ -0,0 +1,225 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
+
4
+ import { randomUUID } from 'node:crypto';
5
+ import { link, readFile, unlink, writeFile } from 'node:fs/promises';
6
+ import { createRequire } from 'node:module';
7
+ import { dirname, join, resolve } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const HEADING = /^### ([A-Za-z][\w-]*)$/;
11
+ const QUOTE = /^>[ \t]?(.*)$/;
12
+ const RESULT = /^- `([A-Za-z_$][A-Za-z0-9_$]*)`: (\S(?:.*\S)?)$/;
13
+ const HELP = 'Usage: node scaffold-fsm.mjs --source <GEARS.md> --out <new.fsm.ts>\nCreates an incomplete authoring scaffold; ordinary compiler checks remain required.\n';
14
+
15
+ function refuse(message) {
16
+ throw new Error(message);
17
+ }
18
+
19
+ /** A closed copying profile, not a GEARS semantic compiler. */
20
+ function sourceItems(source) {
21
+ if (/[\r\u2028\u2029\uFEFF]/.test(source)) refuse('unsupported line encoding; the scaffold profile requires LF text');
22
+ if (source.split('\n').some(line => /^\s*(```|~~~)/.test(line))) refuse('fenced source bodies are outside this profile');
23
+ const items = [];
24
+ let current;
25
+ for (const line of source.split('\n')) {
26
+ if (line.startsWith('###')) {
27
+ const heading = HEADING.exec(line);
28
+ if (!heading) refuse('unsupported item heading');
29
+ if (items.some(item => item.id === heading[1])) refuse('duplicate item id');
30
+ current = { id: heading[1], lines: [] };
31
+ items.push(current);
32
+ } else if (/^#{1,2} /.test(line)) {
33
+ current = undefined;
34
+ } else if (current) {
35
+ current.lines.push(line);
36
+ }
37
+ }
38
+ if (items.length === 0) refuse('no supported item headings');
39
+ const prompted = [];
40
+ for (const { id, lines } of items) {
41
+ const first = lines.findIndex(line => QUOTE.test(line));
42
+ const preamble = lines.slice(0, first === -1 ? lines.length : first);
43
+ if (preamble.some(line => /Parallel group:|Captain shall run\s*:|Captain shall call playbook selected by/.test(line))) {
44
+ refuse(`${id}: script, dynamic or parallel item is outside this profile`);
45
+ }
46
+ if (first === -1) {
47
+ if (lines.some(line => /Captain shall|^Results\b/.test(line))) refuse(`${id}: acting item has no blockquote`);
48
+ continue;
49
+ }
50
+ const before = preamble.join(' ');
51
+ const child = /\bCaptain shall call playbook (?:`([A-Za-z0-9][\w.-]*)`|([A-Za-z0-9][\w.-]*)):\s*$/.exec(before.trim());
52
+ const player = /\bCaptain shall (?:prompt ([A-Z][\w]*)|relay\b[^.]*?\bto ([A-Z][\w]*)):\s*$/.exec(before.trim());
53
+ if (!child && /\bCaptain shall call playbook\b/.test(before)) refuse(`${id}: unsupported nested-call form`);
54
+ if (!player && /\bCaptain shall (?:prompt|relay)\b/.test(before)) refuse(`${id}: unsupported delegated-role form`);
55
+ if (!child && !player && !/\bCaptain shall\b.*:\s*$/.test(before.trim())) refuse(`${id}: unsupported acting clause`);
56
+ if (/Results\s*:/.test(before)) refuse(`${id}: Results precedes its prompt`);
57
+ const prompt = [];
58
+ let cursor = first;
59
+ while (cursor < lines.length) {
60
+ const quote = QUOTE.exec(lines[cursor]);
61
+ if (!quote) break;
62
+ prompt.push(quote[1]);
63
+ cursor++;
64
+ }
65
+ const after = lines.slice(cursor);
66
+ if (after.some(line => QUOTE.test(line))) refuse(`${id}: noncontiguous prompt`);
67
+ const nonblank = after.filter(line => line.trim() !== '');
68
+ let results;
69
+ if (nonblank.some(line => /^Results\b/.test(line))) {
70
+ if (child) refuse(`${id}: nested-call Results are forbidden`);
71
+ if (nonblank[0] !== 'Results:') refuse(`${id}: Results must immediately follow its prompt`);
72
+ results = [];
73
+ for (const line of nonblank.slice(1)) {
74
+ const result = RESULT.exec(line);
75
+ if (!result) refuse(`${id}: malformed Results entry`);
76
+ if (results.some(([guard]) => guard === result[1])) refuse(`${id}: duplicate Results guard`);
77
+ results.push([result[1], result[2]]);
78
+ }
79
+ if (results.length === 0) refuse(`${id}: empty Results block`);
80
+ } else if (!child && nonblank.length > 0) {
81
+ refuse(`${id}: trailing acting-item prose is outside this profile`);
82
+ }
83
+ prompted.push({ id, actor: child ? 'playbook' : player ? 'player' : 'captain', prompt: prompt.join('\n'), results });
84
+ }
85
+ if (prompted.length === 0) refuse('no supported prompted items');
86
+ return prompted;
87
+ }
88
+
89
+ function sourceLicense(source) {
90
+ const header = /^(?:<!--[^]*?-->\s*)+/.exec(source)?.[0] ?? '';
91
+ return [...header.matchAll(/<!--\s*(SPDX-[^]*?)\s*-->/g)]
92
+ .flatMap(match => match[1].split('\n').map(line => `// ${line.trim()}`))
93
+ .join('\n');
94
+ }
95
+
96
+ const CHILD_VALIDATION_IMPORTS = `import { validatePlaybookCallResult } from '@sublang/playbook/xstate-runtime';
97
+ import type { PlaybookCallResult } from '@sublang/playbook/runtime';`;
98
+
99
+ const CHILD_VALIDATION_HELPER = `export function authoredChildResult(error: unknown, expectedPlaybookId: string): PlaybookCallResult | undefined {
100
+ if (!(error instanceof Error)) return undefined;
101
+ try {
102
+ const result = validatePlaybookCallResult(
103
+ (error as Error & { result?: unknown }).result,
104
+ expectedPlaybookId,
105
+ );
106
+ return result.status !== 'ok' || result.terminal?.kind === 'failure' ? result : undefined;
107
+ } catch {
108
+ return undefined;
109
+ }
110
+ }`;
111
+
112
+ function render(source, items) {
113
+ const kinds = [...new Set(items.map(item => item.actor))];
114
+ const constants = items.map(item => {
115
+ const result = item.results === undefined ? 'undefined' : `{ ${item.results.map(([guard, description]) => `[${JSON.stringify(guard)}]: ${JSON.stringify(description)}`).join(', ')} }`;
116
+ return ` [${JSON.stringify(item.id)}]: { prompt: ${JSON.stringify(item.prompt)}, result: ${result} }`;
117
+ }).join(',\n');
118
+ const types = kinds.map(kind => {
119
+ const title = kind[0].toUpperCase() + kind.slice(1);
120
+ if (kind === 'playbook') return `export type PlaybookInput = { stateId: string; sourceItem: string; playbookId: string; text: string };\nexport type PlaybookOutput = JsonValue | undefined;`;
121
+ return `export type ${title}Input = { stateId: string; sourceItem: string; ${kind === 'player' ? 'role: string; ' : ''}prompt: string; result: Readonly<Record<string, string>> } & __AUTHOR_${kind.toUpperCase()}_FIELDS__;\nexport type ${title}Output = __AUTHOR_${kind.toUpperCase()}_OUTPUT__;`;
122
+ }).join('\n');
123
+ const actors = kinds.map(kind => {
124
+ const title = kind[0].toUpperCase() + kind.slice(1);
125
+ return ` ${kind}: fromPromise<${title}Output, ${title}Input>(async () => { throw new Error('${kind} actor must be provided by the runner'); })`;
126
+ }).join(',\n');
127
+ const json = kinds.includes('playbook') ? `export type JsonValue = null | boolean | number | string | readonly JsonValue[] | { readonly [key: string]: JsonValue };\n` : '';
128
+ const license = sourceLicense(source);
129
+ return `${license ? `${license}\n\n` : ''}// INCOMPLETE AUTHORING SCAFFOLD: replace every __AUTHOR_* marker and finish the workflow.
130
+ // Read the complete GEARS Source and phase definition; constants are not its routing semantics.
131
+ import { assign, fromPromise, setup } from 'xstate';
132
+ ${kinds.includes('playbook') ? `${CHILD_VALIDATION_IMPORTS}\n` : ''}
133
+ // Acting prompts stay literal; carry runtime values beside invoke.input.prompt.
134
+ // Nested prompts are templates: compose child text under the phase's actual rules.
135
+ // Results are only authored declarations. Apply the phase's default/question/controller rules.
136
+ export const GEARS_ITEMS = {
137
+ ${constants}
138
+ } as const;
139
+
140
+ type Context = __AUTHOR_CONTEXT__;
141
+ type Event = __AUTHOR_EVENTS__;
142
+ export type MachineInput = __AUTHOR_MACHINE_INPUT__;
143
+ export type MachineOutput = __AUTHOR_MACHINE_OUTPUT__;
144
+ ${json}${types}
145
+ ${kinds.includes('playbook') ? `\n${CHILD_VALIDATION_HELPER}\n` : ''}
146
+ function invocationOutput(event: unknown): unknown {
147
+ return typeof event === 'object' && event !== null && 'output' in event ? event.output : undefined;
148
+ }
149
+
150
+ const actorSetup = setup({
151
+ types: {} as { context: Context; events: Event; input: MachineInput; output: MachineOutput },
152
+ actors: {
153
+ ${actors}
154
+ },
155
+ });
156
+
157
+ // Keep reusable completion assignments registered and reference their names.
158
+ // A standalone typed action reused directly on onDone can mismatch external event types.
159
+ const machineSetup = actorSetup.extend({
160
+ actions: {
161
+ rememberResult: assign(({ event }) => {
162
+ const output = invocationOutput(event);
163
+ // Narrow unknown output to the declared result arm before reading payload fields.
164
+ return __AUTHOR_CONTEXT_UPDATE_FROM_OUTPUT__;
165
+ }),
166
+ },
167
+ });
168
+
169
+ // Use machineSetup.createStateConfig(...) for shared transition/config fragments.
170
+ // Agent-owned work includes routes, guards, public metadata, failures, Boss continuation,
171
+ // actor value relays and any child-result predicates. No default topology is supplied.
172
+ export const machine = machineSetup.createMachine({
173
+ ...__AUTHOR_MACHINE_CONFIG__,
174
+ });
175
+ `;
176
+ }
177
+
178
+ export async function scaffoldFsm({ sourcePath, outputPath }) {
179
+ const source = await readFile(sourcePath, 'utf8');
180
+ if (!outputPath.endsWith('.fsm.ts')) refuse('output must end in .fsm.ts');
181
+ if (resolve(sourcePath) === resolve(outputPath)) refuse('source and output must differ');
182
+ const items = sourceItems(source);
183
+ try {
184
+ const xstate = createRequire(resolve(outputPath))('xstate');
185
+ const typed = xstate.setup({});
186
+ if (typeof typed.extend !== 'function' || typeof typed.createStateConfig !== 'function') {
187
+ refuse('unsupported XState setup API');
188
+ }
189
+ } catch {
190
+ refuse('target dependencies must supply XState setup.extend and createStateConfig');
191
+ }
192
+ const target = render(source, items);
193
+ const staging = join(dirname(outputPath), `.fsm-scaffold-${randomUUID()}.tmp`);
194
+ try {
195
+ await writeFile(staging, target, { flag: 'wx' });
196
+ // Exclusive publication prevents replacing a target created after our read.
197
+ await link(staging, outputPath);
198
+ } finally {
199
+ await unlink(staging).catch(error => { if (error.code !== 'ENOENT') throw error; });
200
+ }
201
+ return { status: 'scaffold', bytes: Buffer.byteLength(target), items: items.length };
202
+ }
203
+
204
+ async function main(args) {
205
+ if (args.length === 1 && args[0] === '--help') {
206
+ process.stdout.write(HELP);
207
+ return;
208
+ }
209
+ const options = {};
210
+ for (let index = 0; index < args.length; index += 2) {
211
+ const key = args[index];
212
+ if (!['--source', '--out'].includes(key) || typeof args[index + 1] !== 'string' || args[index + 1].startsWith('--') || Object.hasOwn(options, key)) refuse(HELP.trim());
213
+ options[key] = args[index + 1];
214
+ }
215
+ if (!options['--source'] || !options['--out']) refuse(HELP.trim());
216
+ const result = await scaffoldFsm({ sourcePath: resolve(options['--source']), outputPath: resolve(options['--out']) });
217
+ process.stdout.write(`${JSON.stringify(result)}\n`);
218
+ }
219
+
220
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
221
+ main(process.argv.slice(2)).catch(error => {
222
+ process.stderr.write(`FSM scaffold: ${error instanceof Error ? error.message : 'initialization failed'}\n`);
223
+ process.exitCode = 1;
224
+ });
225
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "schema": "sublang.slc.pin-inputs.v1",
3
+ "closures": {
4
+ "gears2fsm": ["text2gears.md", "link.md", "workflow-contracts.json"],
5
+ "link": [
6
+ "text2gears.md",
7
+ "gears2fsm.md",
8
+ "optimize.md",
9
+ "materialize-link.mjs",
10
+ "workflow-contracts.json"
11
+ ]
12
+ }
13
+ }
package/slc/text2gears.md CHANGED
@@ -76,9 +76,12 @@ text2gears shall not move a shared instruction ahead of behavior-specific contex
76
76
 
77
77
  Where Source says that a runtime value is relayed in quotes, the leading `>` is prompt content rather than Source-only blockquote syntax.
78
78
  If Source supplies a blockquoted template for that relay, text2gears shall keep one literal leading `>` on every quoted line; the target GEARS line therefore uses its outer blockquote marker followed by the literal marker, such as `> > Coder output: <coder-output>`.
79
- If Source names the relayed value but supplies no template, text2gears shall emit its canonical typed placeholder on a line beginning with literal `> ` and shall not summarize, paraphrase, or invent a value in its place.
79
+ If Source names the relayed value but supplies no template, text2gears shall emit a bare quoted prompt-content line, exactly `> <token>`, written as `> > <token>` in the GEARS file because the first marker encloses the prompt and the second is literal content, without an added label or surrounding prose, and shall not summarize, paraphrase, or invent the relayed value.
80
80
  An ordinary Source blockquote that specifies a complete acting prompt without requiring quoted relay retains the existing rule above: its one leading marker is Source syntax and is not prompt content.
81
81
 
82
+ Apply each Source-authored relay to every acting behavior it governs, including relays described only in prose.
83
+ Mentioning a value in a condition, result contract, or machine context does not deliver it to the acting role; its complete prompt blockquote shall carry the required quoted placeholder.
84
+
82
85
  An acting prompt whose instructions refer to a runtime value the acting role cannot otherwise observe — for example the Boss input task that triggered the workflow — shall relay that value as a quoted `<placeholder>` line appended to the prompt even when Source states no explicit relay.
83
86
  A prompt that references an undelivered value asks its player to act on data it never received; omitting the relay is a compilation defect, not a faithful rendering of Source.
84
87
 
@@ -137,6 +140,10 @@ Results:
137
140
  - `delegation`: Captain selected a call. Output shall include `remainingPlan: <JSON-safe array>`, `nextPlaybookId: <stable id>`, and `nextPlaybookInput: <complete request>`.
138
141
  ```
139
142
 
143
+ A `Results:` block continues until the next item or section heading; after the label, emit only result bullets and blank lines.
144
+ Put other acting-item conditions and invariants before the acting blockquote, never between the blockquote and `Results:` or after its bullets.
145
+ Nested-call items remain without `Results:` and keep their child-continuation prose after the blockquote.
146
+
140
147
  `Results:` shall be a plain label rather than a heading.
141
148
  Every result shall occupy one bullet with exactly a backtick-delimited guard
142
149
  name, a colon, and a non-empty description.
@@ -145,6 +152,13 @@ The guard name shall match the ASCII identifier pattern
145
152
  The bullet order is authoritative, guard names are unique within the item, and
146
153
  the description shall name every required output property with its exact
147
154
  case-sensitive identifier.
155
+ After `Output shall include`, reserve backticks for output-field declarations.
156
+ Keep each declaration outside plain-text parentheses; explanatory symbols in
157
+ parenthetical guidance use plain text, never separate backticks, because those
158
+ backticks would declare extra required fields. Guidance may instead occur
159
+ inside a field's complete annotation, including any parentheses there.
160
+ For example, use `` `codeCommit` (new code-owned commit) `` or
161
+ `` `codeCommit: <new code-owned commit>` ``, without backticks around code.
148
162
  An output property name shall match the same ASCII identifier pattern as a
149
163
  guard name: a kebab-case Source placeholder such as `<coder-output>` names the
150
164
  property `coderOutput` through the canonical kebab-token-to-camel-field mapping
@@ -187,6 +201,10 @@ contract, so text2gears shall not invent a one-bullet `Results:` block for it.
187
201
  When a later item does consume its output, the produced-value rule above
188
202
  applies instead.
189
203
 
204
+ When Source requires a terminal return to the caller, preserve every returned value or fact and its return condition as an explicit workflow output obligation in GEARS.
205
+ Merely naming a value in a completion predicate or an acting result does not state that the workflow returns it.
206
+ Keep this non-acting requirement outside prompt blockquotes, in the item's pre-prompt prose, an explicit terminal-return clause in the relevant Results description, or existing nested-call continuation; do not create a Captain action solely to restate the return.
207
+
190
208
  ### Boss-reply continuation
191
209
 
192
210
  Where a direct-Captain or delegated-player behavior may ask Boss a question
@@ -197,6 +215,10 @@ when the answer changes its complete runtime prompt. It shall not emit a
197
215
  second item solely for "Boss answers," "after the question," or clearing the
198
216
  consumed question/reply. The FSM and linker own the same-leaf suspension,
199
217
  continuation blocks, and consumed-context cleanup.
218
+ When such an authored result asks Boss and waits, its `Results:` description
219
+ shall declare `question: <verbatim final text>` as an output property; the
220
+ result name or prose saying that a question is asked is not the field
221
+ declaration.
200
222
 
201
223
  This rule is an exception to splitting by accumulated prompt content below.
202
224
  Split only when Source requires a genuinely different acting behavior after
@@ -247,6 +269,11 @@ text2gears shall emit an item whose behavior uses
247
269
  complete JSON-safe input-text template for that call.
248
270
  The literal target id shall be a stable configured playbook id, not a slash
249
271
  command or module specifier.
272
+ In both literal and dynamic nested-call forms, the behavior's verb phrase shall
273
+ be exact: `Captain shall call playbook ...:`.
274
+ Text2gears shall not insert sequencing words such as `first`, `then`, `next`,
275
+ or `finally` between `shall` and `call`; required sequencing belongs in the
276
+ `When` or `While` clause or in continuation prose around the item.
250
277
  A nested-call item shall carry no `Results:` label: the child's terminal result
251
278
  is its outcome, so Source's continuation after child success, abort, or failure
252
279
  stays as prose after the blockquote for
@@ -299,8 +326,16 @@ compiled items.
299
326
  The kind is defined here so every consumer of the GEARS format shares one
300
327
  item-syntax contract.
301
328
 
302
- A script item's blockquote is static shell text: it shall contain no
303
- `<placeholder>`, and Markdown escapes resolve exactly as in acting prompts.
329
+ A script item's blockquote is static shell text apart from the
330
+ `<placeholder>` forms an acting prompt may carry, which relay a runtime value
331
+ the machine already retains; Markdown escapes resolve exactly as in acting
332
+ prompts.
333
+ A script reads no conversation and produces no prose, so a placeholder is
334
+ sound there only to bind the command to a target the script cannot otherwise
335
+ name — never to carry a value the script would have to interpret.
336
+ It shall occupy a single-quoted shell word, so the authored command is valid
337
+ shell as written and the compiled one binds a literal
338
+ ([gears2fsm "Setup"](gears2fsm.md#setup)).
304
339
  A script item shall carry a `Results:` label with exactly two bullets in this
305
340
  fixed interpretation: the first guard reports the script exiting with status
306
341
  zero, the second reports a nonzero exit status.