@sublang/playbook 13.2.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.
- package/README.md +5 -5
- package/docs/configuration.md +14 -9
- package/docs/embedding.md +20 -1
- package/package.json +6 -1
- package/reference/sdlc/code.playbook/bin/launch-config.js +88 -5
- package/reference/sdlc/code.playbook/bin/playbook.js +4 -0
- package/reference/sdlc/code.playbook/bin/repository-effects.js +11 -3
- package/reference/sdlc/code.playbook/bin/run.js +4 -0
- package/reference/sdlc/code.playbook/bin/session-host.js +13 -3
- package/reference/sdlc/code.playbook/bin/session-store.js +2 -2
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +26 -1
- package/reference/sdlc/code.playbook/playbook-captain.js +233 -8
- package/reference/sdlc/code.playbook/playbook-captain.ts +298 -8
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +5 -6
- package/reference/sdlc/code.playbook/session-host.d.ts +26 -0
- package/reference/sdlc/dev.playbook/dev.playbook.d.ts +3 -10
- package/reference/sdlc/dev.playbook/dev.playbook.js +5 -5
- package/reference/sdlc/dev.playbook/dev.playbook.ts +7 -7
- package/reference/sdlc/pr.md +17 -4
- package/reference/sdlc/pr.playbook/pr.fsm.d.ts +17 -3
- package/reference/sdlc/pr.playbook/pr.fsm.js +49 -18
- package/reference/sdlc/pr.playbook/pr.fsm.ts +63 -20
- package/reference/sdlc/pr.playbook/pr.gears.md +20 -7
- package/reference/sdlc/pr.playbook/pr.playbook.js +2 -1
- package/reference/sdlc/pr.playbook/pr.playbook.ts +2 -1
- package/slc/experiments/fsm-scaffold-guidance.md +31 -0
- package/slc/gears2fsm.md +145 -58
- package/slc/link.md +158 -7
- package/slc/materialize-link.mjs +460 -0
- package/slc/optimize.md +46 -6
- package/slc/scaffold-fsm.mjs +225 -0
- package/slc/slc.pin-inputs.json +13 -0
- package/slc/text2gears.md +38 -3
- package/slc/workflow-contracts.json +371 -0
- package/src/xstate-playbook-runtime.js +35 -1
- package/src/xstate-playbook-runtime.ts +54 -1
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
// SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
|
|
4
|
+
|
|
5
|
+
// Optional compiler tool, not an interpreter. The complete link.md contract
|
|
6
|
+
// and emitted conformance checks still govern every generated artifact.
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
8
|
+
import { lstat, open, realpath, rename, unlink } from 'node:fs/promises';
|
|
9
|
+
import { randomUUID } from 'node:crypto';
|
|
10
|
+
import { dirname, extname, relative, resolve } from 'node:path';
|
|
11
|
+
import { pathToFileURL } from 'node:url';
|
|
12
|
+
|
|
13
|
+
// Compile-time source only; emitted modules depend only on the shared engine.
|
|
14
|
+
function quotedPlayerSource(placeholderFields, continuationMode) {
|
|
15
|
+
return `function composePlayerPrompt(input: PlaybookPlayerInput, _identity?: unknown${continuationMode ? ', resuming = false' : ''}): string {
|
|
16
|
+
const fields = input as unknown as Readonly<Record<string, unknown>>;
|
|
17
|
+
const mapping: Readonly<Record<string, string>> = ${JSON.stringify(placeholderFields)};
|
|
18
|
+
const body = input.prompt.replace(
|
|
19
|
+
/^> <(#|[A-Za-z_$][A-Za-z0-9_$-]*)>(\\r?\\n|$)|<(#|[A-Za-z_$][A-Za-z0-9_$-]*)>/gm,
|
|
20
|
+
(match: string, quoted: string | undefined, ending: string | undefined, inline: string | undefined): string => {
|
|
21
|
+
const token = quoted ?? inline!;
|
|
22
|
+
const field = mapping[token] ?? (token === '#' ? 'irNumber' : token.replace(/-([A-Za-z0-9])/g, (_match: string, next: string) => next.toUpperCase()));
|
|
23
|
+
const value = fields[field];
|
|
24
|
+
if (typeof value !== 'string') return match;
|
|
25
|
+
if (quoted === undefined) return value;
|
|
26
|
+
if (value === '') return '';
|
|
27
|
+
return value.split(/(\\r?\\n)/).map((part, index) => index % 2 === 0 && part !== '' ? '> ' + part : part).join('') + (ending ?? '');
|
|
28
|
+
},
|
|
29
|
+
);
|
|
30
|
+
// The shared composer supplies only the continuation prefix; rendered values
|
|
31
|
+
// never reenter substitution and the original PlayerInput remains unchanged.
|
|
32
|
+
return defaultComposePlayerPrompt({ ...input, prompt: '' }, mapping${continuationMode ? ', resuming' : ''}) + body;
|
|
33
|
+
}
|
|
34
|
+
`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function labelledPlayerSource(descriptor) {
|
|
38
|
+
return `function renderPlayerPrompt(input: SourcePlayerInput, promptIdentity: XStatePromptIdentity, resuming = false): string {
|
|
39
|
+
const fields = input as unknown as Readonly<Record<string, unknown>>;
|
|
40
|
+
const mapping: Readonly<Record<string, string>> = ${JSON.stringify(descriptor.placeholderFields)};
|
|
41
|
+
const identities: Readonly<Record<string, string>> = ${JSON.stringify(descriptor.identityPlaceholders)};
|
|
42
|
+
const omitted = new Set<string>(${JSON.stringify(descriptor.omitEmptyRelayLines)});
|
|
43
|
+
const fieldFor = (token: string): string => Object.hasOwn(mapping, token) ? mapping[token]! : (token === '#' ? 'irNumber' : token.replace(/-([A-Za-z0-9_$])/g, (_match: string, next: string) => next.toUpperCase()));
|
|
44
|
+
const parts = input.prompt.split(/(\\r?\\n)/);
|
|
45
|
+
let body = '';
|
|
46
|
+
for (let index = 0; index < parts.length; index += 2) {
|
|
47
|
+
const line = parts[index]!;
|
|
48
|
+
if (omitted.has(line)) {
|
|
49
|
+
const token = /<(#|[A-Za-z_$][A-Za-z0-9_$-]*)>$/.exec(line)![1]!;
|
|
50
|
+
if (fields[fieldFor(token)] === '') continue;
|
|
51
|
+
}
|
|
52
|
+
body += line.replace(/<(#|[A-Za-z_$][A-Za-z0-9_$-]*)>/g, (match: string, token: string): string => {
|
|
53
|
+
const value = Object.hasOwn(identities, token) ? promptIdentity(identities[token]!) : fields[fieldFor(token)];
|
|
54
|
+
if (typeof value !== 'string') return match;
|
|
55
|
+
return line.startsWith('> ') ? value.replace(/\\r?\\n/g, (ending: string) => ending + '> ') : value;
|
|
56
|
+
}) + (parts[index + 1] ?? '');
|
|
57
|
+
}
|
|
58
|
+
return composePlayerContinuation(input, body, resuming);
|
|
59
|
+
}
|
|
60
|
+
const composePlayerPrompt = (input: PlaybookPlayerInput, promptIdentity: XStatePromptIdentity, resuming?: boolean): string =>
|
|
61
|
+
renderPlayerPrompt(input as SourcePlayerInput, promptIdentity, resuming);
|
|
62
|
+
`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class UnsupportedLinkProfile extends Error {
|
|
66
|
+
constructor(message) {
|
|
67
|
+
super(message);
|
|
68
|
+
this.name = 'UnsupportedLinkProfile';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const SCHEMA = 'sublang.playbook.link.v1';
|
|
73
|
+
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
74
|
+
const RESERVED_KEYS = new Set(['__proto__', 'prototype', 'constructor', 'hostCapabilities']);
|
|
75
|
+
const DESCRIPTOR_KEYS = [
|
|
76
|
+
'schema', 'profile', 'machineExport', 'label', 'options', 'inputMapping',
|
|
77
|
+
'entryEvent', 'bossEvents', 'outcomeAuthority', 'placeholderFields',
|
|
78
|
+
'transitionEventFields', 'verbatimPayloadFields', 'resumableStateIds',
|
|
79
|
+
'unfinishedFinalStateIds', 'controlContextFields',
|
|
80
|
+
];
|
|
81
|
+
const LABELLED_KEYS = ['playerInputExport', 'omitEmptyRelayLines', 'identityPlaceholders'];
|
|
82
|
+
const TOKEN = /^(#|[A-Za-z_$][A-Za-z0-9_$-]*)$/;
|
|
83
|
+
const CONTRACT_TYPES = [
|
|
84
|
+
'PlayerResult', 'PlayerCallOptions', 'PlayerSessionStore', 'CaptainResult',
|
|
85
|
+
'CaptainCallOptions', 'JsonValue', 'NormalizedError', 'PlaybookCallRequest',
|
|
86
|
+
'PlaybookCallResult', 'PlaybookCallStart', 'PlaybookStateValue', 'PlaybookState',
|
|
87
|
+
'PlaybookPendingCall', 'PlaybookRunResult', 'PlaybookRuntime',
|
|
88
|
+
'PlaybookRuntimeFactory', 'PlaybookRuntimeSnapshot', 'PlaybookSession',
|
|
89
|
+
'PlaybookPorts', 'PlaybookTraceEvent', 'PlaybookTraceType',
|
|
90
|
+
'PlaybookControlReceipt', 'PlaybookControlView',
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
function record(value, path, allowed, required = []) {
|
|
94
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value) ||
|
|
95
|
+
![Object.prototype, null].includes(Object.getPrototypeOf(value))) {
|
|
96
|
+
throw new TypeError(`${path} must be a plain object`);
|
|
97
|
+
}
|
|
98
|
+
for (const key of Object.keys(value)) {
|
|
99
|
+
if (RESERVED_KEYS.has(key) || (allowed && !allowed.includes(key))) {
|
|
100
|
+
throw new TypeError(`${path}.${key} is not declared`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
for (const key of required) {
|
|
104
|
+
if (!Object.hasOwn(value, key)) throw new TypeError(`${path}.${key} is required`);
|
|
105
|
+
}
|
|
106
|
+
return value;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function nonempty(value, path) {
|
|
110
|
+
if (typeof value !== 'string' || !value.trim()) throw new TypeError(`${path} must be a nonempty string`);
|
|
111
|
+
return value;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function strings(value, path, allowEmptyStrings = false) {
|
|
115
|
+
if (!Array.isArray(value)) throw new TypeError(`${path} must be an array`);
|
|
116
|
+
for (const item of value) {
|
|
117
|
+
if (allowEmptyStrings) {
|
|
118
|
+
if (typeof item !== 'string') throw new TypeError(`${path} values must be strings`);
|
|
119
|
+
} else nonempty(item, path);
|
|
120
|
+
}
|
|
121
|
+
if (new Set(value).size !== value.length) throw new TypeError(`${path} must not repeat values`);
|
|
122
|
+
return value;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function validateDescriptor(value, engine) {
|
|
126
|
+
const descriptor = engine.snapshotJsonValue(value, 'link descriptor');
|
|
127
|
+
const labelled = descriptor?.profile === 'flat-labelled-relays';
|
|
128
|
+
const keys = labelled ? [...DESCRIPTOR_KEYS, ...LABELLED_KEYS] : DESCRIPTOR_KEYS;
|
|
129
|
+
record(descriptor, 'descriptor', keys, keys);
|
|
130
|
+
if (descriptor.schema !== SCHEMA) throw new TypeError(`descriptor.schema must equal ${SCHEMA}`);
|
|
131
|
+
if (!['flat-defaults', 'flat-quoted-relays', 'flat-labelled-relays'].includes(descriptor.profile)) throw new UnsupportedLinkProfile('profile requires ordinary normative linking');
|
|
132
|
+
if (labelled) {
|
|
133
|
+
if (typeof engine.composePlayerContinuation !== 'function') throw new UnsupportedLinkProfile('labelled relays require the shared continuation API');
|
|
134
|
+
if (typeof descriptor.playerInputExport !== 'string' || !IDENTIFIER.test(descriptor.playerInputExport)) throw new TypeError('playerInputExport must name the exported FSM player-input type');
|
|
135
|
+
strings(descriptor.omitEmptyRelayLines, 'omitEmptyRelayLines');
|
|
136
|
+
record(descriptor.identityPlaceholders, 'identityPlaceholders');
|
|
137
|
+
for (const [token, role] of Object.entries(descriptor.identityPlaceholders)) {
|
|
138
|
+
if (!TOKEN.test(token)) throw new TypeError('identityPlaceholders key must be a placeholder token');
|
|
139
|
+
nonempty(role, `identityPlaceholders.${token}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
nonempty(descriptor.label, 'descriptor.label');
|
|
143
|
+
if (typeof descriptor.machineExport !== 'string' || !IDENTIFIER.test(descriptor.machineExport)) {
|
|
144
|
+
throw new TypeError('descriptor.machineExport must be an exported JavaScript identifier');
|
|
145
|
+
}
|
|
146
|
+
record(descriptor.options, 'descriptor.options');
|
|
147
|
+
for (const [key, option] of Object.entries(descriptor.options)) {
|
|
148
|
+
nonempty(key, 'option name');
|
|
149
|
+
record(option, `options.${key}`, ['type', 'required'], ['type', 'required']);
|
|
150
|
+
if (!['string', 'boolean', 'number'].includes(option.type)) {
|
|
151
|
+
throw new UnsupportedLinkProfile(`options.${key} requires an unsupported option shape`);
|
|
152
|
+
}
|
|
153
|
+
if (typeof option.required !== 'boolean') throw new TypeError(`options.${key}.required must be boolean`);
|
|
154
|
+
}
|
|
155
|
+
record(descriptor.inputMapping, 'descriptor.inputMapping');
|
|
156
|
+
for (const [key, option] of Object.entries(descriptor.inputMapping)) {
|
|
157
|
+
nonempty(key, 'input field');
|
|
158
|
+
nonempty(option, `inputMapping.${key}`);
|
|
159
|
+
if (!Object.hasOwn(descriptor.options, option)) throw new TypeError(`inputMapping.${key} names undeclared option ${option}`);
|
|
160
|
+
}
|
|
161
|
+
for (const key of Object.keys(descriptor.options)) {
|
|
162
|
+
if (key !== 'cwd' && !Object.values(descriptor.inputMapping).includes(key)) {
|
|
163
|
+
throw new UnsupportedLinkProfile(`option ${key} needs a strategy outside the input-mapping profile`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (descriptor.entryEvent !== null) {
|
|
167
|
+
record(descriptor.entryEvent, 'entryEvent', ['type', 'textField', 'contextField'], ['type', 'textField']);
|
|
168
|
+
for (const [key, value] of Object.entries(descriptor.entryEvent)) nonempty(value, `entryEvent.${key}`);
|
|
169
|
+
}
|
|
170
|
+
if (!Array.isArray(descriptor.bossEvents)) throw new TypeError('bossEvents must be an array');
|
|
171
|
+
const eventTypes = new Set();
|
|
172
|
+
for (const event of descriptor.bossEvents) {
|
|
173
|
+
record(event, 'bossEvents entry', ['type', 'fields'], ['type']);
|
|
174
|
+
nonempty(event.type, 'bossEvents.type');
|
|
175
|
+
if (eventTypes.has(event.type)) throw new TypeError(`bossEvents repeats ${event.type}`);
|
|
176
|
+
eventTypes.add(event.type);
|
|
177
|
+
if (event.fields !== undefined) {
|
|
178
|
+
record(event.fields, 'bossEvents.fields');
|
|
179
|
+
for (const [key, field] of Object.entries(event.fields)) {
|
|
180
|
+
nonempty(key, 'Boss event field');
|
|
181
|
+
record(field, `bossEvents.fields.${key}`, ['source', 'required', 'values'], ['source']);
|
|
182
|
+
if (!['judge', 'text'].includes(field.source)) throw new TypeError(`bossEvents.fields.${key}.source is invalid`);
|
|
183
|
+
if (field.required !== undefined && typeof field.required !== 'boolean') throw new TypeError(`bossEvents.fields.${key}.required must be boolean`);
|
|
184
|
+
if (field.values !== undefined) {
|
|
185
|
+
strings(field.values, `bossEvents.fields.${key}.values`, true);
|
|
186
|
+
if (!field.values.length) throw new TypeError(`bossEvents.fields.${key}.values must not be empty`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
record(descriptor.outcomeAuthority, 'outcomeAuthority', ['governedPlayerStates'], ['governedPlayerStates']);
|
|
192
|
+
record(descriptor.outcomeAuthority.governedPlayerStates, 'governedPlayerStates');
|
|
193
|
+
for (const [state, outcomes] of Object.entries(descriptor.outcomeAuthority.governedPlayerStates)) {
|
|
194
|
+
record(outcomes, `outcomeAuthority.${state}`);
|
|
195
|
+
for (const [outcome, declaration] of Object.entries(outcomes)) {
|
|
196
|
+
nonempty(outcome, 'outcome');
|
|
197
|
+
record(declaration, `outcomeAuthority.${state}.${outcome}`, ['fields', 'repositoryDisposition'], ['fields', 'repositoryDisposition']);
|
|
198
|
+
record(declaration.fields, `outcomeAuthority.${state}.${outcome}.fields`);
|
|
199
|
+
for (const authority of Object.values(declaration.fields)) {
|
|
200
|
+
if (!['presentation', 'semantic', 'effect', 'runtime'].includes(authority)) throw new TypeError(`outcomeAuthority.${state}.${outcome} has an unknown field authority`);
|
|
201
|
+
}
|
|
202
|
+
if (!['unchanged', 'one-descendant-commit', 'deferred'].includes(declaration.repositoryDisposition)) {
|
|
203
|
+
throw new TypeError(`outcomeAuthority.${state}.${outcome} has an unknown repository disposition`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
record(descriptor.placeholderFields, 'placeholderFields');
|
|
208
|
+
for (const [key, value] of Object.entries(descriptor.placeholderFields)) {
|
|
209
|
+
nonempty(key, 'placeholder token');
|
|
210
|
+
nonempty(value, `placeholderFields.${key}`);
|
|
211
|
+
}
|
|
212
|
+
if (labelled) {
|
|
213
|
+
for (const token of Object.keys(descriptor.identityPlaceholders)) {
|
|
214
|
+
if (Object.hasOwn(descriptor.placeholderFields, token)) throw new TypeError(`identity placeholder ${token} also declares a field mapping`);
|
|
215
|
+
}
|
|
216
|
+
for (const line of descriptor.omitEmptyRelayLines) {
|
|
217
|
+
const matched = /^> [^<>\r\n]*<(#|[A-Za-z_$][A-Za-z0-9_$-]*)>$/.exec(line);
|
|
218
|
+
if (!matched) throw new UnsupportedLinkProfile('optional relay must be a complete quoted line with one terminal placeholder');
|
|
219
|
+
if (Object.hasOwn(descriptor.identityPlaceholders, matched[1])) throw new TypeError('an identity relay cannot be optional');
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
for (const field of ['transitionEventFields', 'verbatimPayloadFields', 'resumableStateIds', 'unfinishedFinalStateIds', 'controlContextFields']) {
|
|
223
|
+
strings(descriptor[field], field);
|
|
224
|
+
}
|
|
225
|
+
return descriptor;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function inspectMachine(machine, engine, labelled = false) {
|
|
229
|
+
const config = machine?.config;
|
|
230
|
+
if (!config || typeof config !== 'object' || !config.states) throw new TypeError('FSM export must be an XState machine');
|
|
231
|
+
if (config.type === 'parallel' || config.invoke) throw new UnsupportedLinkProfile('root parallel states or invocations require ordinary linking');
|
|
232
|
+
const descriptions = engine.stateDescriptionsFromMachine(machine);
|
|
233
|
+
const roleStates = Object.create(null);
|
|
234
|
+
let hasScript = false;
|
|
235
|
+
for (const [key, state] of Object.entries(config.states)) {
|
|
236
|
+
if (RESERVED_KEYS.has(key)) throw new UnsupportedLinkProfile(`state ${key} requires ordinary linking`);
|
|
237
|
+
if (!state || typeof state !== 'object') throw new TypeError(`state ${key} must be an object`);
|
|
238
|
+
if (state.states || (state.type !== undefined && !['atomic', 'final'].includes(state.type))) {
|
|
239
|
+
throw new UnsupportedLinkProfile(`state ${key} is outside the flat single-region profile`);
|
|
240
|
+
}
|
|
241
|
+
const invokes = Array.isArray(state.invoke) ? state.invoke : state.invoke ? [state.invoke] : [];
|
|
242
|
+
if (invokes.length > 1) throw new UnsupportedLinkProfile(`state ${key} has multiple actors`);
|
|
243
|
+
for (const invoke of invokes) {
|
|
244
|
+
if (!(labelled ? ['player', 'script', 'playbook'] : ['player', 'script']).includes(invoke.src)) throw new UnsupportedLinkProfile(`state ${key} actor ${String(invoke.src)} requires ordinary linking`);
|
|
245
|
+
if (invoke.src === 'script') hasScript = true;
|
|
246
|
+
else if (invoke.src === 'player') {
|
|
247
|
+
const role = nonempty(state.meta?.playbook?.role, `state ${key} role`);
|
|
248
|
+
const label = nonempty(descriptions.get(key), `state ${key} description`);
|
|
249
|
+
roleStates[key] = { role, label };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return { roleStates, hasScript };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function snapshotPrimitiveOptions(value, options, engine, label) {
|
|
257
|
+
const captured = engine.snapshotJsonValue(value, `${label} runtime options`);
|
|
258
|
+
record(captured, `${label} runtime options`, Object.keys(options));
|
|
259
|
+
for (const [key, schema] of Object.entries(options)) {
|
|
260
|
+
if (!Object.hasOwn(captured, key)) {
|
|
261
|
+
if (schema.required) throw new TypeError(`${label} runtime options.${key} is required`);
|
|
262
|
+
} else if (typeof captured[key] !== schema.type || (schema.type === 'number' && !Number.isFinite(captured[key]))) {
|
|
263
|
+
throw new TypeError(`${label} runtime options.${key} must be ${schema.type}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return captured;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Pure emission: no filesystem writes, actor construction, or host calls. */
|
|
270
|
+
export function materializeLink({ machine, descriptor: value, fsmSpecifier, engine }) {
|
|
271
|
+
for (const method of ['snapshotJsonValue', 'stateDescriptionsFromMachine', 'createXStatePlaybookRuntime', 'defaultComposePlayerPrompt']) {
|
|
272
|
+
if (typeof engine?.[method] !== 'function') throw new TypeError(`installed shared engine is missing ${method}`);
|
|
273
|
+
}
|
|
274
|
+
if (!engine.SUPPORTED_ARTIFACT_SCHEMAS?.includes(3) || !Number.isInteger(engine.RUNTIME_ABI)) {
|
|
275
|
+
throw new TypeError('installed shared engine does not declare schema 3 and a literal ABI');
|
|
276
|
+
}
|
|
277
|
+
if (typeof fsmSpecifier !== 'string' || !/^(?:\.\/|\.\.\/)/.test(fsmSpecifier) || !/\.(?:ts|js)$/.test(fsmSpecifier) || /[\r\n]/.test(fsmSpecifier)) {
|
|
278
|
+
throw new TypeError('FSM import must be an extension-bearing relative .ts or .js specifier');
|
|
279
|
+
}
|
|
280
|
+
const descriptor = validateDescriptor(value, engine);
|
|
281
|
+
const labelledPlayer = descriptor.profile === 'flat-labelled-relays';
|
|
282
|
+
const { roleStates, hasScript } = inspectMachine(machine, engine, labelledPlayer);
|
|
283
|
+
if (labelledPlayer) {
|
|
284
|
+
const roles = new Set(Object.values(roleStates).map((state) => state.role));
|
|
285
|
+
if (!roles.size) throw new UnsupportedLinkProfile('labelled profile requires a delegated player');
|
|
286
|
+
for (const role of Object.values(descriptor.identityPlaceholders)) {
|
|
287
|
+
if (!roles.has(role)) throw new TypeError(`identity placeholder names undeclared role ${role}`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const options = { ...descriptor.options };
|
|
291
|
+
if (hasScript) {
|
|
292
|
+
if (options.cwd && (options.cwd.type !== 'string' || options.cwd.required)) {
|
|
293
|
+
throw new UnsupportedLinkProfile('script cwd must be an optional string');
|
|
294
|
+
}
|
|
295
|
+
options.cwd = { type: 'string', required: false };
|
|
296
|
+
}
|
|
297
|
+
for (const state of descriptor.resumableStateIds) {
|
|
298
|
+
if (!Object.hasOwn(roleStates, state)) throw new TypeError(`resumableStateIds contains non-player state ${state}`);
|
|
299
|
+
}
|
|
300
|
+
const spec = {
|
|
301
|
+
label: descriptor.label,
|
|
302
|
+
compat: { artifactSchema: 3, runtimeAbi: engine.RUNTIME_ABI },
|
|
303
|
+
snapshotOptions: (value) => snapshotPrimitiveOptions(value, options, engine, descriptor.label),
|
|
304
|
+
machineInput: (options) => Object.fromEntries(Object.entries(descriptor.inputMapping)
|
|
305
|
+
.filter(([, key]) => options[key] !== undefined).map(([field, key]) => [field, options[key]])),
|
|
306
|
+
...(descriptor.entryEvent === null ? {} : { entryEvent: descriptor.entryEvent }),
|
|
307
|
+
bossEvents: descriptor.bossEvents,
|
|
308
|
+
roleStates,
|
|
309
|
+
outcomeAuthority: descriptor.outcomeAuthority,
|
|
310
|
+
placeholderFields: descriptor.placeholderFields,
|
|
311
|
+
transitionEventFields: descriptor.transitionEventFields,
|
|
312
|
+
verbatimPayloadFields: new Set(descriptor.verbatimPayloadFields),
|
|
313
|
+
resumableStateIds: new Set(descriptor.resumableStateIds),
|
|
314
|
+
unfinishedFinalStateIds: new Set(descriptor.unfinishedFinalStateIds),
|
|
315
|
+
controlContextFields: descriptor.controlContextFields,
|
|
316
|
+
};
|
|
317
|
+
// The engine validates exact role/state identity, authority shape, Boss
|
|
318
|
+
// contracts, projection, and final-state membership without starting actors.
|
|
319
|
+
engine.createXStatePlaybookRuntime(machine, spec);
|
|
320
|
+
const json = (value) => JSON.stringify(value, null, 2);
|
|
321
|
+
const hasPlayer = Object.keys(roleStates).length > 0;
|
|
322
|
+
const quotedPlayer = hasPlayer && descriptor.profile === 'flat-quoted-relays';
|
|
323
|
+
// The continuation API was added after 12.3. Emit only arguments supported
|
|
324
|
+
// by the installed engine's public surface, rather than widening its types.
|
|
325
|
+
const hasContinuationMode = typeof engine.composePlayerContinuation === 'function';
|
|
326
|
+
const fsmImport = descriptor.machineExport === 'default'
|
|
327
|
+
? `import machine from ${JSON.stringify(fsmSpecifier)};`
|
|
328
|
+
: `import { ${descriptor.machineExport} as machine } from ${JSON.stringify(fsmSpecifier)};`;
|
|
329
|
+
const data = { ...spec };
|
|
330
|
+
for (const key of ['snapshotOptions', 'machineInput', 'verbatimPayloadFields', 'resumableStateIds', 'unfinishedFinalStateIds']) delete data[key];
|
|
331
|
+
return `// SPDX-License-Identifier: Apache-2.0
|
|
332
|
+
// SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
|
|
333
|
+
// Generated by slc/materialize-link.mjs; governed by the complete slc/link.md.
|
|
334
|
+
// FSM: ${JSON.stringify(fsmSpecifier)}; export: ${descriptor.machineExport}; strategies: ${labelledPlayer ? 'shared defaults with labelled string relays and nested actors' : quotedPlayer ? 'shared defaults with standalone quoted relays' : 'shared defaults'}.
|
|
335
|
+
${fsmImport}
|
|
336
|
+
${labelledPlayer ? `import type { ${descriptor.playerInputExport} as SourcePlayerInput } from ${JSON.stringify(fsmSpecifier)};\n` : ''}import {
|
|
337
|
+
createXStatePlaybookRuntime, snapshotJsonValue,${labelledPlayer ? '\n composePlayerContinuation, type XStatePromptIdentity,' : hasPlayer ? '\n defaultComposePlayerPrompt,' : ''}
|
|
338
|
+
${hasPlayer ? 'type PlaybookPlayerInput,' : ''}
|
|
339
|
+
type XStatePlaybookRuntimeConstruction,
|
|
340
|
+
type XStatePlaybookRuntimeFactory,
|
|
341
|
+
type XStatePlaybookRuntimeSpecV3,
|
|
342
|
+
} from '@sublang/playbook/xstate-runtime';
|
|
343
|
+
export type {
|
|
344
|
+
${CONTRACT_TYPES.join(',\n ')},
|
|
345
|
+
} from '@sublang/playbook/runtime';
|
|
346
|
+
|
|
347
|
+
export interface PlaybookRuntimeOptions {
|
|
348
|
+
${Object.entries(options).map(([key, value]) => ` readonly ${JSON.stringify(key)}${value.required ? '' : '?'}: ${value.type};`).join('\n')}
|
|
349
|
+
}
|
|
350
|
+
export type PlaybookHostCapabilities = XStatePlaybookRuntimeConstruction<
|
|
351
|
+
PlaybookRuntimeOptions, { readonly authority: object }
|
|
352
|
+
>['hostCapabilities'];
|
|
353
|
+
|
|
354
|
+
const OPTION_SCHEMA = ${json(options)} as const;
|
|
355
|
+
const INPUT_MAPPING = ${json(descriptor.inputMapping)} as const;
|
|
356
|
+
const RESUMABLE_STATE_IDS: ReadonlySet<string> = new Set(${json(descriptor.resumableStateIds)});
|
|
357
|
+
const UNFINISHED_FINAL_STATE_IDS: ReadonlySet<string> = new Set(${json(descriptor.unfinishedFinalStateIds)});
|
|
358
|
+
export function validateOptions(value: unknown): PlaybookRuntimeOptions {
|
|
359
|
+
const captured = snapshotJsonValue(value === undefined ? {} : value, ${JSON.stringify(`${descriptor.label} runtime options`)});
|
|
360
|
+
if (captured === null || typeof captured !== 'object' || Array.isArray(captured)) {
|
|
361
|
+
throw new TypeError(${labelledPlayer ? json(`${descriptor.label} runtime options must be an object`) : "'runtime options must be an object'"});
|
|
362
|
+
}
|
|
363
|
+
const fields = captured as Readonly<Record<string, unknown>>;
|
|
364
|
+
for (const key of Object.keys(captured)) {
|
|
365
|
+
if (!Object.hasOwn(OPTION_SCHEMA, key)) throw new TypeError(${labelledPlayer ? json(`${descriptor.label} runtime options.`) : "'runtime options.'"} + key + ' is not declared');
|
|
366
|
+
}
|
|
367
|
+
for (const [key, schema] of Object.entries(OPTION_SCHEMA) as [string, { type: string; required: boolean }][]) {
|
|
368
|
+
if (!Object.hasOwn(captured, key)) {
|
|
369
|
+
if (schema.required) throw new TypeError(${labelledPlayer ? json(`${descriptor.label} runtime options.`) : "'runtime options.'"} + key + ' is required');
|
|
370
|
+
} else if (typeof fields[key] !== schema.type || (schema.type === 'number' && !Number.isFinite(fields[key]))) {
|
|
371
|
+
throw new TypeError(${labelledPlayer ? json(`${descriptor.label} runtime options.`) : "'runtime options.'"} + key + ${labelledPlayer ? "' must be a '" : "' must be '"} + schema.type);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return captured as unknown as PlaybookRuntimeOptions;
|
|
375
|
+
}
|
|
376
|
+
${labelledPlayer ? labelledPlayerSource(descriptor) : quotedPlayer ? quotedPlayerSource(descriptor.placeholderFields, hasContinuationMode) : ''}const runtimeSpec = {
|
|
377
|
+
${quotedPlayer || labelledPlayer ? ' composePlayerPrompt,\n' : ''} ...${json(data)},
|
|
378
|
+
snapshotOptions: validateOptions,
|
|
379
|
+
machineInput: (options: PlaybookRuntimeOptions) => Object.fromEntries(
|
|
380
|
+
Object.entries(INPUT_MAPPING).filter(([, key]) => options[key as keyof PlaybookRuntimeOptions] !== undefined)
|
|
381
|
+
.map(([field, key]) => [field, options[key as keyof PlaybookRuntimeOptions]]),
|
|
382
|
+
),
|
|
383
|
+
verbatimPayloadFields: new Set<string>(${json(descriptor.verbatimPayloadFields)}),
|
|
384
|
+
resumableStateIds: RESUMABLE_STATE_IDS,
|
|
385
|
+
unfinishedFinalStateIds: UNFINISHED_FINAL_STATE_IDS,
|
|
386
|
+
} satisfies XStatePlaybookRuntimeSpecV3<PlaybookRuntimeOptions>;
|
|
387
|
+
|
|
388
|
+
export const _internal = {
|
|
389
|
+
${quotedPlayer || labelledPlayer ? 'composePlayerPrompt,\n ' : hasPlayer ? `composePlayerPrompt: (input: PlaybookPlayerInput, _identity?: unknown${hasContinuationMode ? ', resuming = false' : ''}) =>
|
|
390
|
+
defaultComposePlayerPrompt(input, ${json(descriptor.placeholderFields)}${hasContinuationMode ? ', resuming' : ''}),\n ` : ''}RESUMABLE_STATE_IDS,
|
|
391
|
+
UNFINISHED_FINAL_STATE_IDS,
|
|
392
|
+
};
|
|
393
|
+
const createPlaybookRuntime: XStatePlaybookRuntimeFactory<
|
|
394
|
+
XStatePlaybookRuntimeConstruction<PlaybookRuntimeOptions, PlaybookHostCapabilities>, 3
|
|
395
|
+
> = createXStatePlaybookRuntime<PlaybookRuntimeOptions, PlaybookHostCapabilities>(machine, runtimeSpec);
|
|
396
|
+
export default createPlaybookRuntime;
|
|
397
|
+
`;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function argumentsOf(argv) {
|
|
401
|
+
if (argv.length !== 4) throw new TypeError('Usage: node materialize-link.mjs --fsm <.fsm.ts|.fsm.js> --out <.playbook.ts> < descriptor.json');
|
|
402
|
+
const fields = {};
|
|
403
|
+
for (let index = 0; index < argv.length; index += 2) {
|
|
404
|
+
const key = argv[index];
|
|
405
|
+
if (!['--fsm', '--out'].includes(key) || Object.hasOwn(fields, key)) throw new TypeError(`Unknown or repeated argument ${key}`);
|
|
406
|
+
fields[key] = resolve(nonempty(argv[index + 1], key));
|
|
407
|
+
}
|
|
408
|
+
return { fsm: fields['--fsm'], out: fields['--out'] };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export async function runMaterializer(argv, input) {
|
|
412
|
+
const { fsm, out } = argumentsOf(argv);
|
|
413
|
+
if (!['.js', '.ts'].includes(extname(fsm))) throw new UnsupportedLinkProfile('FSM loading supports only .js or native-stripped .ts');
|
|
414
|
+
if (extname(fsm) === '.ts' && !process.features.typescript) {
|
|
415
|
+
throw new UnsupportedLinkProfile('Loading .fsm.ts requires Node native type stripping (Node >=23.6 or >=22.18); use a compiled .fsm.js on Node 20');
|
|
416
|
+
}
|
|
417
|
+
if (extname(out) !== '.ts') throw new TypeError('declared target must be a TypeScript module');
|
|
418
|
+
const source = await realpath(fsm);
|
|
419
|
+
const targetInfo = await lstat(out).catch((error) => { if (error.code !== 'ENOENT') throw error; });
|
|
420
|
+
if (targetInfo?.isSymbolicLink()) throw new TypeError('declared target must not be a symbolic link');
|
|
421
|
+
if (source === out || (targetInfo && source === await realpath(out))) throw new TypeError('declared target must not replace the FSM');
|
|
422
|
+
const enginePath = createRequire(pathToFileURL(source)).resolve('@sublang/playbook/xstate-runtime');
|
|
423
|
+
const targetEnginePath = createRequire(pathToFileURL(out)).resolve('@sublang/playbook/xstate-runtime');
|
|
424
|
+
if (await realpath(enginePath) !== await realpath(targetEnginePath)) throw new TypeError('FSM and target must resolve the same installed shared engine');
|
|
425
|
+
if (Buffer.byteLength(input, 'utf8') > 1024 * 1024) throw new TypeError('descriptor exceeds 1 MiB');
|
|
426
|
+
const descriptor = JSON.parse(input);
|
|
427
|
+
const engine = await import(pathToFileURL(enginePath).href);
|
|
428
|
+
// Validate the export name before selecting it from the imported module.
|
|
429
|
+
validateDescriptor(descriptor, engine);
|
|
430
|
+
const module = await import(pathToFileURL(source).href);
|
|
431
|
+
const specifier = relative(dirname(out), fsm).replaceAll('\\', '/');
|
|
432
|
+
const code = materializeLink({ machine: module[descriptor.machineExport], descriptor,
|
|
433
|
+
fsmSpecifier: specifier.startsWith('.') ? specifier : `./${specifier}`, engine });
|
|
434
|
+
const temporary = `${out}.${randomUUID()}.tmp`;
|
|
435
|
+
try {
|
|
436
|
+
const file = await open(temporary, 'wx', 0o644);
|
|
437
|
+
try { await file.writeFile(code, 'utf8'); } finally { await file.close(); }
|
|
438
|
+
await rename(temporary, out);
|
|
439
|
+
} finally {
|
|
440
|
+
await unlink(temporary).catch((error) => { if (error.code !== 'ENOENT') throw error; });
|
|
441
|
+
}
|
|
442
|
+
return { status: 'ok', target: out, bytes: Buffer.byteLength(code, 'utf8') };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
|
|
446
|
+
try {
|
|
447
|
+
argumentsOf(process.argv.slice(2));
|
|
448
|
+
let input = '';
|
|
449
|
+
for await (const chunk of process.stdin) {
|
|
450
|
+
input += chunk;
|
|
451
|
+
if (Buffer.byteLength(input, 'utf8') > 1024 * 1024) throw new TypeError('descriptor exceeds 1 MiB');
|
|
452
|
+
}
|
|
453
|
+
process.stdout.write(`${JSON.stringify(await runMaterializer(process.argv.slice(2), input))}\n`);
|
|
454
|
+
} catch (error) {
|
|
455
|
+
const unsupported = error instanceof UnsupportedLinkProfile;
|
|
456
|
+
process.stderr.write(`${JSON.stringify({ status: unsupported ? 'unsupported' : 'error', message: error.message,
|
|
457
|
+
...(unsupported ? { fallback: 'Continue ordinary linking under the complete link.md definition.' } : {}) })}\n`);
|
|
458
|
+
process.exitCode = unsupported ? 2 : 1;
|
|
459
|
+
}
|
|
460
|
+
}
|
package/slc/optimize.md
CHANGED
|
@@ -31,8 +31,11 @@ The pass shall rewrite an item only when **all** of the following hold:
|
|
|
31
31
|
- The item's behavior is mechanical: a fixed shell command performs it
|
|
32
32
|
completely, with no judgment, no natural-language generation, and no
|
|
33
33
|
reading of conversational context.
|
|
34
|
-
- The command is static: it needs no
|
|
35
|
-
beyond the working directory the runtime executes in
|
|
34
|
+
- The command is static apart from naming its target: it needs no runtime
|
|
35
|
+
value beyond the working directory the runtime executes in, except a
|
|
36
|
+
`<placeholder>` the source already established for a target the command
|
|
37
|
+
cannot otherwise identify, which binds as a shell literal
|
|
38
|
+
([text2gears "Script behaviors"](text2gears.md#script-behaviors-optimizer-introduced)).
|
|
36
39
|
- No other item's condition or prompt consumes prose this item's acting agent
|
|
37
40
|
would have produced; the item's effect is entirely on the environment
|
|
38
41
|
(files, repository state, directories) plus a success/failure signal.
|
|
@@ -41,9 +44,42 @@ The pass shall rewrite an item only when **all** of the following hold:
|
|
|
41
44
|
than success/failure, or whose outcomes require extracted output fields,
|
|
42
45
|
is ineligible.
|
|
43
46
|
|
|
44
|
-
The
|
|
45
|
-
|
|
46
|
-
|
|
47
|
+
The command shall preserve the exact environmental predicate and effect of
|
|
48
|
+
the source behavior, including its location and scope; a weaker success
|
|
49
|
+
condition or an operation on a different resource is not an optimization.
|
|
50
|
+
|
|
51
|
+
For example, these repository requirements have different predicates.
|
|
52
|
+
When the source only requires being inside a Git working tree, an ancestor
|
|
53
|
+
repository may satisfy it:
|
|
54
|
+
|
|
55
|
+
Captain shall run:
|
|
56
|
+
|
|
57
|
+
> test "$(git rev-parse --is-inside-work-tree 2>/dev/null)" = true || git init
|
|
58
|
+
|
|
59
|
+
Results:
|
|
60
|
+
|
|
61
|
+
- `ok`: The command exits zero.
|
|
62
|
+
- `failed`: The command exits nonzero.
|
|
63
|
+
|
|
64
|
+
When the source requires the current directory to be the root of its own Git
|
|
65
|
+
repository and says to initialize there if `.git` is absent, membership in an
|
|
66
|
+
ancestor repository does not satisfy it:
|
|
67
|
+
|
|
68
|
+
Captain shall run:
|
|
69
|
+
|
|
70
|
+
> if [ ! -e .git ]; then git init || exit; fi
|
|
71
|
+
> test "$(git rev-parse --show-toplevel 2>/dev/null)" = "$(pwd -P)"
|
|
72
|
+
|
|
73
|
+
Results:
|
|
74
|
+
|
|
75
|
+
- `ok`: The command exits zero.
|
|
76
|
+
- `failed`: The command exits nonzero.
|
|
77
|
+
|
|
78
|
+
The `.git` existence check accepts a linked worktree's `.git` file as well as
|
|
79
|
+
a repository's `.git` directory; the final check verifies the required root.
|
|
80
|
+
Neither example authorizes adding repository setup where the source does not
|
|
81
|
+
require it, and the linker shall not have to repair a weakened optimized
|
|
82
|
+
behavior later.
|
|
47
83
|
|
|
48
84
|
Judgment stays conservative: when eligibility is uncertain, the pass shall
|
|
49
85
|
leave the item unchanged rather than guess.
|
|
@@ -60,7 +96,11 @@ For each eligible item, the pass shall:
|
|
|
60
96
|
stays in this exact English form even when the surrounding item text is
|
|
61
97
|
in another language.
|
|
62
98
|
- Replace the blockquoted prompt with the exact POSIX shell script that
|
|
63
|
-
performs the behavior
|
|
99
|
+
performs the behavior: static text, apart from the eligible target
|
|
100
|
+
`<placeholder>`, written as a single-quoted shell word
|
|
101
|
+
([text2gears "Script behaviors"](text2gears.md#script-behaviors-optimizer-introduced)),
|
|
102
|
+
retaining a `>` blockquote on every script line; a fenced code block is not
|
|
103
|
+
a GEARS acting body.
|
|
64
104
|
- Emit exactly two `Results:` bullets per
|
|
65
105
|
[text2gears "Script behaviors"](text2gears.md#script-behaviors-optimizer-introduced):
|
|
66
106
|
first the zero-exit guard, then the nonzero-exit guard. When the original
|