@sublang/slc 0.1.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 (137) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +192 -0
  3. package/dist/app.d.ts +85 -0
  4. package/dist/app.d.ts.map +1 -0
  5. package/dist/app.js +233 -0
  6. package/dist/app.js.map +1 -0
  7. package/dist/artifacts.d.ts +61 -0
  8. package/dist/artifacts.d.ts.map +1 -0
  9. package/dist/artifacts.js +97 -0
  10. package/dist/artifacts.js.map +1 -0
  11. package/dist/cli.d.ts +3 -0
  12. package/dist/cli.d.ts.map +1 -0
  13. package/dist/cli.js +26 -0
  14. package/dist/cli.js.map +1 -0
  15. package/dist/cligent-agent.d.ts +11 -0
  16. package/dist/cligent-agent.d.ts.map +1 -0
  17. package/dist/cligent-agent.js +66 -0
  18. package/dist/cligent-agent.js.map +1 -0
  19. package/dist/compiled-executor.d.ts +42 -0
  20. package/dist/compiled-executor.d.ts.map +1 -0
  21. package/dist/compiled-executor.js +433 -0
  22. package/dist/compiled-executor.js.map +1 -0
  23. package/dist/config-file.d.ts +52 -0
  24. package/dist/config-file.d.ts.map +1 -0
  25. package/dist/config-file.js +159 -0
  26. package/dist/config-file.js.map +1 -0
  27. package/dist/config.d.ts +82 -0
  28. package/dist/config.d.ts.map +1 -0
  29. package/dist/config.js +161 -0
  30. package/dist/config.js.map +1 -0
  31. package/dist/entry-module.d.ts +22 -0
  32. package/dist/entry-module.d.ts.map +1 -0
  33. package/dist/entry-module.js +216 -0
  34. package/dist/entry-module.js.map +1 -0
  35. package/dist/execution.d.ts +71 -0
  36. package/dist/execution.d.ts.map +1 -0
  37. package/dist/execution.js +207 -0
  38. package/dist/execution.js.map +1 -0
  39. package/dist/file-capability.d.ts +46 -0
  40. package/dist/file-capability.d.ts.map +1 -0
  41. package/dist/file-capability.js +234 -0
  42. package/dist/file-capability.js.map +1 -0
  43. package/dist/file-grants.d.ts +79 -0
  44. package/dist/file-grants.d.ts.map +1 -0
  45. package/dist/file-grants.js +133 -0
  46. package/dist/file-grants.js.map +1 -0
  47. package/dist/hash.d.ts +14 -0
  48. package/dist/hash.d.ts.map +1 -0
  49. package/dist/hash.js +34 -0
  50. package/dist/hash.js.map +1 -0
  51. package/dist/index.d.ts +20 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +22 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/interpreter.d.ts +48 -0
  56. package/dist/interpreter.d.ts.map +1 -0
  57. package/dist/interpreter.js +118 -0
  58. package/dist/interpreter.js.map +1 -0
  59. package/dist/invocation.d.ts +64 -0
  60. package/dist/invocation.d.ts.map +1 -0
  61. package/dist/invocation.js +148 -0
  62. package/dist/invocation.js.map +1 -0
  63. package/dist/link.d.ts +63 -0
  64. package/dist/link.d.ts.map +1 -0
  65. package/dist/link.js +172 -0
  66. package/dist/link.js.map +1 -0
  67. package/dist/markdown.d.ts +12 -0
  68. package/dist/markdown.d.ts.map +1 -0
  69. package/dist/markdown.js +63 -0
  70. package/dist/markdown.js.map +1 -0
  71. package/dist/normalize.md +68 -0
  72. package/dist/phase-runner.d.ts +64 -0
  73. package/dist/phase-runner.d.ts.map +1 -0
  74. package/dist/phase-runner.js +322 -0
  75. package/dist/phase-runner.js.map +1 -0
  76. package/dist/phase.d.ts +62 -0
  77. package/dist/phase.d.ts.map +1 -0
  78. package/dist/phase.js +111 -0
  79. package/dist/phase.js.map +1 -0
  80. package/dist/pin-closure.d.ts +19 -0
  81. package/dist/pin-closure.d.ts.map +1 -0
  82. package/dist/pin-closure.js +110 -0
  83. package/dist/pin-closure.js.map +1 -0
  84. package/dist/pin-currency.d.ts +55 -0
  85. package/dist/pin-currency.d.ts.map +1 -0
  86. package/dist/pin-currency.js +435 -0
  87. package/dist/pin-currency.js.map +1 -0
  88. package/dist/pin-generate.d.ts +46 -0
  89. package/dist/pin-generate.d.ts.map +1 -0
  90. package/dist/pin-generate.js +131 -0
  91. package/dist/pin-generate.js.map +1 -0
  92. package/dist/pin-paths.d.ts +11 -0
  93. package/dist/pin-paths.d.ts.map +1 -0
  94. package/dist/pin-paths.js +108 -0
  95. package/dist/pin-paths.js.map +1 -0
  96. package/dist/pins.d.ts +84 -0
  97. package/dist/pins.d.ts.map +1 -0
  98. package/dist/pins.js +265 -0
  99. package/dist/pins.js.map +1 -0
  100. package/dist/pipeline.d.ts +61 -0
  101. package/dist/pipeline.d.ts.map +1 -0
  102. package/dist/pipeline.js +147 -0
  103. package/dist/pipeline.js.map +1 -0
  104. package/dist/playbook-contract.d.ts +194 -0
  105. package/dist/playbook-contract.d.ts.map +1 -0
  106. package/dist/playbook-contract.js +180 -0
  107. package/dist/playbook-contract.js.map +1 -0
  108. package/dist/playbook-ports.d.ts +53 -0
  109. package/dist/playbook-ports.d.ts.map +1 -0
  110. package/dist/playbook-ports.js +217 -0
  111. package/dist/playbook-ports.js.map +1 -0
  112. package/dist/resolver.d.ts +68 -0
  113. package/dist/resolver.d.ts.map +1 -0
  114. package/dist/resolver.js +133 -0
  115. package/dist/resolver.js.map +1 -0
  116. package/dist/runner.d.ts +45 -0
  117. package/dist/runner.d.ts.map +1 -0
  118. package/dist/runner.js +506 -0
  119. package/dist/runner.js.map +1 -0
  120. package/dist/runtime-package.d.ts +7 -0
  121. package/dist/runtime-package.d.ts.map +1 -0
  122. package/dist/runtime-package.js +132 -0
  123. package/dist/runtime-package.js.map +1 -0
  124. package/dist/slc.config.template.yaml +5 -0
  125. package/dist/verify-coverage.d.ts +99 -0
  126. package/dist/verify-coverage.d.ts.map +1 -0
  127. package/dist/verify-coverage.js +1949 -0
  128. package/dist/verify-coverage.js.map +1 -0
  129. package/dist/verify-support.d.ts +10 -0
  130. package/dist/verify-support.d.ts.map +1 -0
  131. package/dist/verify-support.js +52 -0
  132. package/dist/verify-support.js.map +1 -0
  133. package/dist/verify.d.ts +393 -0
  134. package/dist/verify.d.ts.map +1 -0
  135. package/dist/verify.js +1790 -0
  136. package/dist/verify.js.map +1 -0
  137. package/package.json +76 -0
package/dist/verify.js ADDED
@@ -0,0 +1,1790 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
+ /**
4
+ * Compilation-correctness verification for a compiled `playbook` artifact
5
+ * (IR-007 Task 8; DR-009).
6
+ *
7
+ * A compiled artifact is a judgment-produced program, so `slc` re-checks it
8
+ * against its source. The GEARS↔FSM conformance check verifies that every GEARS
9
+ * item the `text2gears` phase produced maps to exactly one FSM state carrying
10
+ * that item's player binding and its prompt body verbatim, and that no FSM state
11
+ * references an unknown item — so a `gears2fsm` result cannot silently drift from
12
+ * its GEARS source (the [DR-005](../decisions/005-slc-self-hosting-meta-pipeline.md)
13
+ * auditable GEARS-to-FSM mapping).
14
+ *
15
+ * {@link checkGearsFsmConformance} is the deterministic checker over parsed
16
+ * inputs; {@link generateGearsFsmConformanceTest} emits a per-artifact test that
17
+ * runs it beside the artifacts. The checker reads the `text2gears` item format
18
+ * and the `gears2fsm` `invoke.input` contract, not any one artifact, so it holds
19
+ * for every compiled `playbook`. See specs/dev/verification.md.
20
+ */
21
+ import { randomUUID } from 'node:crypto';
22
+ import { existsSync } from 'node:fs';
23
+ import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
24
+ import { basename, dirname, join, resolve } from 'node:path';
25
+ import { pathToFileURL } from 'node:url';
26
+ import { hashFile } from './hash.js';
27
+ /**
28
+ * The Boss-reply result key `gears2fsm` adds to every captain-invoking state's
29
+ * `result` map, and the load-bearing substring its adjudicator-facing
30
+ * description must carry so the runtime's judge requires a `question` payload
31
+ * (gears2fsm.md "Boss-reply suspension"; DR-009).
32
+ */
33
+ export const NEEDS_BOSS_REPLY = 'needsBossReply';
34
+ export const BOSS_QUESTION_MARKER = 'Output shall include `question:';
35
+ const ITEM_HEADING = /^###\s+([A-Za-z][\w-]*)\s*$/;
36
+ // The `text2gears` item form names a delegated player as "Captain shall prompt
37
+ // <Player>" (or a "relay ... to <Player>" variant); English players are
38
+ // capitalized, non-English names are quoted only "when needed to distinguish
39
+ // from prose" (text2gears.md), so backtick/straight/CJK-quoted forms and bare
40
+ // non-ASCII names are all accepted.
41
+ const ITEM_PLAYER = /Captain shall (?:prompt|relay\b[^.]*?\bto)\s+(?:`([^`]+)`|"([^"]+)"|“([^”]+)”|([A-Z][\w]*)|([^\p{ASCII}][^\s::,,。;;]*))/u;
42
+ const ITEM_PLAYBOOK = /Captain shall call playbook\s+(?:`([^`]+)`|"([^"]+)"|“([^”]+)”|([A-Za-z0-9][\w.-]*))\s*:/;
43
+ const ITEM_DYNAMIC_PLAYBOOK = /Captain shall call playbook selected by\s+`([^`]+)`\s*:/;
44
+ const DYNAMIC_TEXT = /^<([A-Za-z_$][A-Za-z0-9_$]*)>$/;
45
+ // An optimizer-introduced script item runs a shell command without any agent
46
+ // (text2gears.md "Script behaviors"; DR-013). The clause is fixed machine
47
+ // syntax, so it is matched literally ahead of the generic Captain form.
48
+ const SCRIPT_CLAUSE = /\bCaptain shall run\s*:/;
49
+ // Some items have Captain act directly ("Captain shall <verb> ...") with no
50
+ // delegated player; their player is Captain itself.
51
+ const CAPTAIN_ACTS = /\bCaptain shall\b/;
52
+ const BLOCKQUOTE = /^>\s?(.*)$/;
53
+ const SECTION_HEADING = /^##\s/;
54
+ const RESULTS_LABEL = /^Results:\s*$/;
55
+ const RESULTS_LABEL_NEAR_MISS = /^Results\s*:?[ \t]*$/;
56
+ const RESULT_BULLET = /^-\s+`([A-Za-z_$][A-Za-z0-9_$]*)`:\s+(\S(?:.*\S)?)\s*$/;
57
+ /**
58
+ * Parses the GEARS items from a `gears` artifact: each `### <ID>` item's player,
59
+ * blockquoted acting prompt, and optional ordered `Results:` metadata.
60
+ */
61
+ export function parseGearsItems(gears) {
62
+ const items = [];
63
+ let current = null;
64
+ const flush = () => {
65
+ if (current !== null) {
66
+ const player = current.player !== ''
67
+ ? current.player
68
+ : current.captainActs
69
+ ? 'Captain'
70
+ : '';
71
+ const prompt = current.prompt.join('\n');
72
+ const dynamicText = DYNAMIC_TEXT.exec(prompt);
73
+ const playbookCall = current.playbookId !== '' || current.playbookIdContext !== '';
74
+ if (current.resultDeclared && current.results.length === 0) {
75
+ current.resultFindings.push('Results block declares no valid entries');
76
+ }
77
+ if (playbookCall && current.resultDeclared) {
78
+ current.resultFindings.push('nested-playbook call item shall not declare Results metadata');
79
+ }
80
+ if (current.script && !playbookCall) {
81
+ // A script item carries exactly two exit-status guards, zero-exit
82
+ // first (text2gears.md "Script behaviors").
83
+ if (!current.resultDeclared) {
84
+ current.resultFindings.push('script item shall declare a two-guard Results contract');
85
+ }
86
+ else if (current.results.length !== 2) {
87
+ current.resultFindings.push(`script item declares ${current.results.length} Results guards (expected exactly 2)`);
88
+ }
89
+ }
90
+ items.push({
91
+ id: current.id,
92
+ player: current.script && !playbookCall ? '' : player,
93
+ prompt,
94
+ ...(!playbookCall && current.script
95
+ ? { actor: 'script' }
96
+ : {}),
97
+ ...(!playbookCall && !current.script && current.player !== ''
98
+ ? { actor: 'player' }
99
+ : {}),
100
+ ...(!playbookCall &&
101
+ !current.script &&
102
+ current.player === '' &&
103
+ current.captainActs
104
+ ? { actor: 'captain' }
105
+ : {}),
106
+ ...(current.playbookId !== ''
107
+ ? { playbookId: current.playbookId }
108
+ : {}),
109
+ ...(current.playbookIdContext !== ''
110
+ ? {
111
+ playbookIdContext: current.playbookIdContext,
112
+ ...(dynamicText === null ? {} : { textContext: dynamicText[1] }),
113
+ }
114
+ : {}),
115
+ ...(current.resultDeclared
116
+ ? { result: Object.fromEntries(current.results) }
117
+ : {}),
118
+ ...(current.resultFindings.length > 0
119
+ ? { resultFindings: current.resultFindings }
120
+ : {}),
121
+ });
122
+ }
123
+ current = null;
124
+ };
125
+ for (const line of gears.split('\n')) {
126
+ const heading = ITEM_HEADING.exec(line);
127
+ if (heading !== null) {
128
+ flush();
129
+ current = {
130
+ id: heading[1],
131
+ player: '',
132
+ captainActs: false,
133
+ script: false,
134
+ playbookId: '',
135
+ playbookIdContext: '',
136
+ prompt: [],
137
+ resultsEligible: false,
138
+ resultDeclared: false,
139
+ inResults: false,
140
+ results: [],
141
+ resultFindings: [],
142
+ };
143
+ continue;
144
+ }
145
+ if (SECTION_HEADING.test(line)) {
146
+ flush();
147
+ continue;
148
+ }
149
+ if (current === null)
150
+ continue;
151
+ if (RESULTS_LABEL.test(line)) {
152
+ if (current.resultDeclared) {
153
+ current.resultFindings.push('duplicate Results label');
154
+ }
155
+ if (current.prompt.length === 0) {
156
+ current.resultFindings.push('Results block shall follow a non-empty acting blockquote');
157
+ }
158
+ else if (!current.resultsEligible) {
159
+ current.resultFindings.push('Results block shall immediately follow the acting blockquote');
160
+ }
161
+ current.resultDeclared = true;
162
+ current.inResults = true;
163
+ continue;
164
+ }
165
+ if (current.resultsEligible && RESULTS_LABEL_NEAR_MISS.test(line)) {
166
+ current.resultFindings.push(`malformed Results label ${JSON.stringify(line)}`);
167
+ current.resultDeclared = true;
168
+ current.inResults = true;
169
+ continue;
170
+ }
171
+ if (current.inResults) {
172
+ if (line.trim() === '')
173
+ continue;
174
+ const result = RESULT_BULLET.exec(line);
175
+ if (result === null) {
176
+ current.resultFindings.push(`malformed Results entry ${JSON.stringify(line)}`);
177
+ continue;
178
+ }
179
+ const [, guard, description] = result;
180
+ if (current.results.some(([existing]) => existing === guard)) {
181
+ current.resultFindings.push(`duplicate Results guard ${guard}`);
182
+ continue;
183
+ }
184
+ if (guard === NEEDS_BOSS_REPLY) {
185
+ current.resultFindings.push(`${NEEDS_BOSS_REPLY} is compiler-owned and shall not be source metadata`);
186
+ }
187
+ current.results.push([guard, description]);
188
+ continue;
189
+ }
190
+ const quote = BLOCKQUOTE.exec(line);
191
+ if (quote !== null) {
192
+ current.prompt.push(quote[1]);
193
+ current.resultsEligible = true;
194
+ continue;
195
+ }
196
+ if (line.trim() !== '')
197
+ current.resultsEligible = false;
198
+ const player = ITEM_PLAYER.exec(line);
199
+ if (player !== null && current.player === '') {
200
+ current.player =
201
+ player[1] ?? player[2] ?? player[3] ?? player[4] ?? player[5];
202
+ }
203
+ const dynamicPlaybook = ITEM_DYNAMIC_PLAYBOOK.exec(line);
204
+ if (dynamicPlaybook !== null &&
205
+ current.playbookIdContext === '' &&
206
+ current.playbookId === '') {
207
+ current.playbookIdContext = dynamicPlaybook[1];
208
+ }
209
+ const playbook = ITEM_PLAYBOOK.exec(line);
210
+ if (playbook !== null &&
211
+ current.playbookId === '' &&
212
+ current.playbookIdContext === '') {
213
+ current.playbookId =
214
+ playbook[1] ?? playbook[2] ?? playbook[3] ?? playbook[4];
215
+ }
216
+ if (SCRIPT_CLAUSE.test(line))
217
+ current.script = true;
218
+ if (CAPTAIN_ACTS.test(line))
219
+ current.captainActs = true;
220
+ }
221
+ flush();
222
+ return items;
223
+ }
224
+ /** Walks every state node depth-first in declaration order. */
225
+ function walkStateNodes(config) {
226
+ const out = [];
227
+ const visit = (states, parent) => {
228
+ for (const [key, state] of Object.entries(states ?? {})) {
229
+ const path = [...parent, key];
230
+ out.push({ key, path, statePath: path.join('.'), state });
231
+ visit(state.states, path);
232
+ }
233
+ };
234
+ visit(config.states, []);
235
+ return out;
236
+ }
237
+ /** Normalizes XState's one-or-many invoke declaration to declaration order. */
238
+ function normalizeInvokes(invoke) {
239
+ if (invoke === undefined)
240
+ return [];
241
+ if (Array.isArray(invoke)) {
242
+ return invoke.filter((candidate) => typeof candidate === 'object' && candidate !== null);
243
+ }
244
+ return typeof invoke === 'object' && invoke !== null ? [invoke] : [];
245
+ }
246
+ function invocationInput(invoke, context = {}) {
247
+ if (typeof invoke.input !== 'function')
248
+ return { invalid: true };
249
+ let value;
250
+ try {
251
+ value = invoke.input({ context });
252
+ }
253
+ catch (error) {
254
+ return { error: messageOf(error) };
255
+ }
256
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
257
+ return { invalid: true };
258
+ }
259
+ return { value: value };
260
+ }
261
+ function publicStateId(node, input) {
262
+ if (isNonEmptyString(input?.stateId))
263
+ return input.stateId;
264
+ if (isNonEmptyString(node.state.id))
265
+ return node.state.id;
266
+ return node.path.length === 1 ? node.key : node.statePath;
267
+ }
268
+ function nestedStatePath(node) {
269
+ return node.path.length > 1 ? { statePath: node.statePath } : {};
270
+ }
271
+ function metadataStateId(state) {
272
+ if (typeof state.meta !== 'object' || state.meta === null)
273
+ return undefined;
274
+ const playbook = state.meta.playbook;
275
+ if (typeof playbook !== 'object' || playbook === null)
276
+ return undefined;
277
+ const stateId = playbook.stateId;
278
+ return isNonEmptyString(stateId) ? stateId : undefined;
279
+ }
280
+ function stateIdConsistencyFindings(node, inputStateId) {
281
+ if (!isNonEmptyString(inputStateId))
282
+ return [];
283
+ const findings = [];
284
+ if (isNonEmptyString(node.state.id) && node.state.id !== inputStateId) {
285
+ findings.push(`invoke.input.stateId "${inputStateId}" does not match state.id "${node.state.id}"`);
286
+ }
287
+ const metaStateId = metadataStateId(node.state);
288
+ if (metaStateId !== undefined && metaStateId !== inputStateId) {
289
+ findings.push(`invoke.input.stateId "${inputStateId}" does not match state.meta.playbook.stateId "${metaStateId}"`);
290
+ }
291
+ return findings;
292
+ }
293
+ function hasStructuredTopology(nodes) {
294
+ return nodes.some(({ path, state }) => path.length > 1 ||
295
+ state.type === 'parallel' ||
296
+ Object.keys(state.states ?? {}).length > 0);
297
+ }
298
+ function structuredStateIdentityFindings(nodes) {
299
+ if (!hasStructuredTopology(nodes))
300
+ return [];
301
+ const findings = [];
302
+ for (const node of nodes) {
303
+ const configId = node.state.id;
304
+ const metaId = metadataStateId(node.state);
305
+ if (!isNonEmptyString(configId)) {
306
+ findings.push(`FSM structured state ${node.statePath}: state.id is not a non-empty string`);
307
+ }
308
+ if (metaId === undefined) {
309
+ findings.push(`FSM structured state ${node.statePath}: state.meta.playbook.stateId is not a non-empty string`);
310
+ }
311
+ else if (isNonEmptyString(configId) && metaId !== configId) {
312
+ findings.push(`FSM structured state ${node.statePath}: state.meta.playbook.stateId "${metaId}" does not match state.id "${configId}"`);
313
+ }
314
+ }
315
+ return findings;
316
+ }
317
+ function enumerateCaptainBindings(config) {
318
+ const out = [];
319
+ for (const node of walkStateNodes(config)) {
320
+ for (const invoke of normalizeInvokes(node.state.invoke)) {
321
+ const source = invokeSource(invoke.src);
322
+ const explicitlyWorkActor = source === 'captain' || source === 'player';
323
+ if (!explicitlyWorkActor && invoke.src !== undefined)
324
+ continue;
325
+ const inspected = invocationInput(invoke);
326
+ if ('error' in inspected) {
327
+ if (explicitlyWorkActor) {
328
+ const actor = source === 'player' ? 'player' : 'captain';
329
+ out.push({
330
+ state: malformedCaptainState(publicStateId(node, undefined), `invoke.input threw during introspection: ${inspected.error}`, actor, node),
331
+ node,
332
+ invoke,
333
+ inputFn: invoke.input,
334
+ pinActor: true,
335
+ });
336
+ }
337
+ continue;
338
+ }
339
+ if ('invalid' in inspected) {
340
+ if (explicitlyWorkActor) {
341
+ const actor = source === 'player' ? 'player' : 'captain';
342
+ out.push({
343
+ state: malformedCaptainState(publicStateId(node, undefined), typeof invoke.input === 'function'
344
+ ? 'invoke.input returned a non-object'
345
+ : 'invoke.input is not a function', actor, node),
346
+ node,
347
+ invoke,
348
+ inputFn: invoke.input,
349
+ pinActor: true,
350
+ });
351
+ }
352
+ continue;
353
+ }
354
+ const fields = inspected.value;
355
+ // Preserve the legacy sourceItem-recognition path only when no explicit
356
+ // actor is named. A playbook actor carrying source metadata is not a
357
+ // player invocation.
358
+ if (!explicitlyWorkActor &&
359
+ (invoke.src !== undefined || !isNonEmptyString(fields.sourceItem))) {
360
+ continue;
361
+ }
362
+ // Published artifacts used `captain` for every work call and carried a
363
+ // player field. Preserve that shape as delegated work until regeneration.
364
+ // In the new model, direct Captain work omits player and delegated work
365
+ // names the `player` actor explicitly.
366
+ const actor = source === 'player' || Object.hasOwn(fields, 'player')
367
+ ? 'player'
368
+ : 'captain';
369
+ const pinActor = source === 'player' || (source === 'captain' && actor === 'captain');
370
+ const bindingFindings = [];
371
+ if (!isNonEmptyString(fields.sourceItem)) {
372
+ bindingFindings.push('invoke.input.sourceItem is not a non-empty string');
373
+ }
374
+ if (actor === 'player' && typeof fields.player !== 'string') {
375
+ bindingFindings.push('invoke.input.player is not a string');
376
+ }
377
+ if (typeof fields.prompt !== 'string') {
378
+ bindingFindings.push('invoke.input.prompt is not a string');
379
+ }
380
+ if (!isStringMap(fields.result)) {
381
+ bindingFindings.push('invoke.input.result is not a string-valued object');
382
+ }
383
+ if (node.path.length > 1 && !isNonEmptyString(fields.stateId)) {
384
+ bindingFindings.push('nested invoke.input.stateId is not a non-empty string');
385
+ }
386
+ bindingFindings.push(...stateIdConsistencyFindings(node, fields.stateId));
387
+ if (Object.keys(node.state.states ?? {}).length > 0) {
388
+ bindingFindings.push(`${source === 'player' ? 'player' : 'captain'} invocation is declared on a compound state instead of a leaf`);
389
+ }
390
+ out.push({
391
+ state: {
392
+ stateId: publicStateId(node, fields),
393
+ sourceItem: isNonEmptyString(fields.sourceItem)
394
+ ? fields.sourceItem
395
+ : '',
396
+ actor,
397
+ player: typeof fields.player === 'string' ? fields.player : '',
398
+ prompt: typeof fields.prompt === 'string' ? fields.prompt : '',
399
+ result: resultMap(fields.result),
400
+ ...nestedStatePath(node),
401
+ ...(bindingFindings.length > 0 ? { bindingFindings } : {}),
402
+ },
403
+ node,
404
+ invoke,
405
+ inputFn: invoke.input,
406
+ pinActor,
407
+ });
408
+ }
409
+ }
410
+ return out;
411
+ }
412
+ /**
413
+ * Enumerates a machine's direct-Captain and delegated-player states, reading
414
+ * `invoke.input` under a stub context to recover the static source binding.
415
+ */
416
+ export function enumerateCaptainStates(config) {
417
+ return enumerateCaptainBindings(config).map(({ state }) => state);
418
+ }
419
+ function enumeratePlaybookBindings(config) {
420
+ const out = [];
421
+ for (const node of walkStateNodes(config)) {
422
+ for (const invoke of normalizeInvokes(node.state.invoke)) {
423
+ if (invokeSource(invoke.src) !== 'playbook')
424
+ continue;
425
+ const inspected = invocationInput(invoke);
426
+ if ('error' in inspected) {
427
+ out.push({
428
+ state: malformedPlaybookState(publicStateId(node, undefined), `invoke.input threw during introspection: ${inspected.error}`, node),
429
+ node,
430
+ invoke,
431
+ });
432
+ continue;
433
+ }
434
+ if ('invalid' in inspected) {
435
+ out.push({
436
+ state: malformedPlaybookState(publicStateId(node, undefined), typeof invoke.input === 'function'
437
+ ? 'invoke.input returned a non-object'
438
+ : 'invoke.input is not a function', node),
439
+ node,
440
+ invoke,
441
+ });
442
+ continue;
443
+ }
444
+ const fields = inspected.value;
445
+ const bindingFindings = [];
446
+ if (!isNonEmptyString(fields.stateId)) {
447
+ bindingFindings.push('invoke.input.stateId is not a non-empty string');
448
+ }
449
+ const dynamic = Object.hasOwn(fields, 'playbookIdContext') ||
450
+ Object.hasOwn(fields, 'textContext');
451
+ if (dynamic) {
452
+ if (!isNonEmptyString(fields.playbookIdContext)) {
453
+ bindingFindings.push('invoke.input.playbookIdContext is not a non-empty string');
454
+ }
455
+ if (!isNonEmptyString(fields.textContext)) {
456
+ bindingFindings.push('invoke.input.textContext is not a non-empty string');
457
+ }
458
+ if (isNonEmptyString(fields.playbookIdContext) &&
459
+ isNonEmptyString(fields.textContext)) {
460
+ const playbookIdSentinel = sentinelFor(fields.playbookIdContext);
461
+ const textSentinel = sentinelFor(fields.textContext);
462
+ const wired = invocationInput(invoke, {
463
+ [fields.playbookIdContext]: playbookIdSentinel,
464
+ [fields.textContext]: textSentinel,
465
+ });
466
+ if ('error' in wired) {
467
+ bindingFindings.push(`invoke.input threw during dynamic context introspection: ${wired.error}`);
468
+ }
469
+ else if ('invalid' in wired) {
470
+ bindingFindings.push('invoke.input returned a non-object during dynamic context introspection');
471
+ }
472
+ else {
473
+ if (wired.value.playbookId !== playbookIdSentinel) {
474
+ bindingFindings.push(`invoke.input.playbookId is not wired from context.${fields.playbookIdContext}`);
475
+ }
476
+ if (wired.value.text !== textSentinel) {
477
+ bindingFindings.push(`invoke.input.text is not wired from context.${fields.textContext}`);
478
+ }
479
+ }
480
+ }
481
+ }
482
+ else {
483
+ if (!isNonEmptyString(fields.playbookId)) {
484
+ bindingFindings.push('invoke.input.playbookId is not a non-empty string');
485
+ }
486
+ if (typeof fields.text !== 'string') {
487
+ bindingFindings.push('invoke.input.text is not a string');
488
+ }
489
+ }
490
+ bindingFindings.push(...stateIdConsistencyFindings(node, fields.stateId));
491
+ if (Object.keys(node.state.states ?? {}).length > 0) {
492
+ bindingFindings.push('playbook invocation is declared on a compound state instead of a leaf');
493
+ }
494
+ out.push({
495
+ state: {
496
+ stateId: publicStateId(node, fields),
497
+ playbookId: !dynamic && isNonEmptyString(fields.playbookId)
498
+ ? fields.playbookId
499
+ : '',
500
+ text: !dynamic && typeof fields.text === 'string' ? fields.text : '',
501
+ ...(isNonEmptyString(fields.playbookIdContext)
502
+ ? { playbookIdContext: fields.playbookIdContext }
503
+ : {}),
504
+ ...(isNonEmptyString(fields.textContext)
505
+ ? { textContext: fields.textContext }
506
+ : {}),
507
+ ...(isNonEmptyString(fields.sourceItem)
508
+ ? { sourceItem: fields.sourceItem }
509
+ : {}),
510
+ ...nestedStatePath(node),
511
+ ...(bindingFindings.length > 0 ? { bindingFindings } : {}),
512
+ },
513
+ node,
514
+ invoke,
515
+ });
516
+ }
517
+ }
518
+ return out;
519
+ }
520
+ /** Enumerates typed `playbook` actor calls across the complete state tree. */
521
+ export function enumeratePlaybookStates(config) {
522
+ return enumeratePlaybookBindings(config).map(({ state }) => state);
523
+ }
524
+ /**
525
+ * Enumerates typed `script` actor calls across the complete state tree
526
+ * (gears2fsm.md "Setup"; DR-013). A script state carries `stateId`,
527
+ * `sourceItem`, the verbatim `command`, and exactly two exit-status guards; it
528
+ * is not agent-invoking, so `needsBossReply` in its result map is malformed.
529
+ */
530
+ export function enumerateScriptStates(config) {
531
+ const out = [];
532
+ for (const node of walkStateNodes(config)) {
533
+ for (const invoke of normalizeInvokes(node.state.invoke)) {
534
+ if (invokeSource(invoke.src) !== 'script')
535
+ continue;
536
+ const malformed = (finding) => ({
537
+ stateId: publicStateId(node, undefined),
538
+ sourceItem: '',
539
+ command: '',
540
+ result: {},
541
+ ...nestedStatePath(node),
542
+ bindingFindings: [finding],
543
+ });
544
+ const inspected = invocationInput(invoke);
545
+ if ('error' in inspected) {
546
+ out.push(malformed(`invoke.input threw during introspection: ${inspected.error}`));
547
+ continue;
548
+ }
549
+ if ('invalid' in inspected) {
550
+ out.push(malformed(typeof invoke.input === 'function'
551
+ ? 'invoke.input returned a non-object'
552
+ : 'invoke.input is not a function'));
553
+ continue;
554
+ }
555
+ const fields = inspected.value;
556
+ const bindingFindings = [];
557
+ if (!isNonEmptyString(fields.stateId)) {
558
+ bindingFindings.push('invoke.input.stateId is not a non-empty string');
559
+ }
560
+ if (!isNonEmptyString(fields.sourceItem)) {
561
+ bindingFindings.push('invoke.input.sourceItem is not a non-empty string');
562
+ }
563
+ if (!isNonEmptyString(fields.command)) {
564
+ bindingFindings.push('invoke.input.command is not a non-empty string');
565
+ }
566
+ if (!isStringMap(fields.result)) {
567
+ bindingFindings.push('invoke.input.result is not a string-valued object');
568
+ }
569
+ else {
570
+ const guards = Object.keys(fields.result);
571
+ if (guards.length !== 2) {
572
+ bindingFindings.push(`script invoke.input.result declares ${guards.length} guards (expected exactly 2)`);
573
+ }
574
+ if (guards.includes(NEEDS_BOSS_REPLY)) {
575
+ bindingFindings.push(`script state shall not declare ${NEEDS_BOSS_REPLY}`);
576
+ }
577
+ }
578
+ bindingFindings.push(...stateIdConsistencyFindings(node, fields.stateId));
579
+ if (Object.keys(node.state.states ?? {}).length > 0) {
580
+ bindingFindings.push('script invocation is declared on a compound state instead of a leaf');
581
+ }
582
+ out.push({
583
+ stateId: publicStateId(node, fields),
584
+ sourceItem: isNonEmptyString(fields.sourceItem)
585
+ ? fields.sourceItem
586
+ : '',
587
+ command: isNonEmptyString(fields.command) ? fields.command : '',
588
+ result: resultMap(fields.result),
589
+ ...nestedStatePath(node),
590
+ ...(bindingFindings.length > 0 ? { bindingFindings } : {}),
591
+ });
592
+ }
593
+ }
594
+ return out;
595
+ }
596
+ function malformedCaptainState(stateId, finding, actor, node) {
597
+ return {
598
+ stateId,
599
+ sourceItem: '',
600
+ actor,
601
+ player: '',
602
+ prompt: '',
603
+ result: {},
604
+ ...(node === undefined ? {} : nestedStatePath(node)),
605
+ bindingFindings: [finding],
606
+ };
607
+ }
608
+ function malformedPlaybookState(stateId, finding, node) {
609
+ return {
610
+ stateId,
611
+ playbookId: '',
612
+ text: '',
613
+ ...nestedStatePath(node),
614
+ bindingFindings: [finding],
615
+ };
616
+ }
617
+ function isNonEmptyString(value) {
618
+ return typeof value === 'string' && value.length > 0;
619
+ }
620
+ function isStringMap(value) {
621
+ return (typeof value === 'object' &&
622
+ value !== null &&
623
+ !Array.isArray(value) &&
624
+ Object.values(value).every((entry) => typeof entry === 'string'));
625
+ }
626
+ /** Narrows a state's `invoke.input.result` to its string-described guard keys. */
627
+ function resultMap(value) {
628
+ if (typeof value !== 'object' || value === null)
629
+ return {};
630
+ const out = {};
631
+ for (const [key, description] of Object.entries(value)) {
632
+ if (typeof description === 'string')
633
+ out[key] = description;
634
+ }
635
+ return out;
636
+ }
637
+ function isPlaybookItem(item) {
638
+ return item.playbookId !== undefined || item.playbookIdContext !== undefined;
639
+ }
640
+ function gearsPlaybookSignature(item) {
641
+ return item.playbookIdContext === undefined
642
+ ? JSON.stringify(['static', item.playbookId, item.prompt])
643
+ : JSON.stringify([
644
+ 'dynamic',
645
+ item.playbookIdContext,
646
+ item.textContext ?? null,
647
+ ]);
648
+ }
649
+ function statePlaybookSignature(state) {
650
+ return state.playbookIdContext === undefined
651
+ ? JSON.stringify(['static', state.playbookId, state.text])
652
+ : JSON.stringify([
653
+ 'dynamic',
654
+ state.playbookIdContext,
655
+ state.textContext ?? null,
656
+ ]);
657
+ }
658
+ /**
659
+ * Checks GEARS↔FSM conformance and returns human-readable findings (empty when
660
+ * conformant): every GEARS item maps to one state with the same player and the
661
+ * prompt verbatim, every captain state references a known item, and every
662
+ * captain state's `result` map declares the Boss-reply suspension key with its
663
+ * adjudicator contract (VERIFY-1, VERIFY-3; DR-009).
664
+ */
665
+ export function checkGearsFsmConformance(gears, config) {
666
+ const items = parseGearsItems(gears);
667
+ const captainBindings = enumerateCaptainBindings(config);
668
+ const states = captainBindings.map(({ state }) => state);
669
+ const explicitActorStates = new Set(captainBindings
670
+ .filter(({ pinActor }) => pinActor)
671
+ .map(({ state }) => state));
672
+ const playbookStates = enumeratePlaybookStates(config);
673
+ const scriptStates = enumerateScriptStates(config);
674
+ const findings = [];
675
+ findings.push(...structuredStateIdentityFindings(walkStateNodes(config)));
676
+ for (const item of items) {
677
+ findings.push(...(item.resultFindings ?? []).map((finding) => `GEARS item ${item.id}: ${finding}`));
678
+ }
679
+ for (const state of states) {
680
+ findings.push(...(state.bindingFindings ?? []).map((finding) => `FSM state ${state.stateId}: ${finding}`));
681
+ }
682
+ for (const state of playbookStates) {
683
+ findings.push(...(state.bindingFindings ?? []).map((finding) => `FSM playbook state ${state.stateId}: ${finding}`));
684
+ }
685
+ for (const state of scriptStates) {
686
+ findings.push(...(state.bindingFindings ?? []).map((finding) => `FSM script state ${state.stateId}: ${finding}`));
687
+ }
688
+ const scriptStatesByItem = new Map();
689
+ for (const state of scriptStates) {
690
+ if (state.sourceItem === '')
691
+ continue;
692
+ const matched = scriptStatesByItem.get(state.sourceItem);
693
+ if (matched === undefined)
694
+ scriptStatesByItem.set(state.sourceItem, [state]);
695
+ else
696
+ matched.push(state);
697
+ }
698
+ const statesByItem = new Map();
699
+ for (const state of states) {
700
+ if (state.sourceItem === '')
701
+ continue;
702
+ const matched = statesByItem.get(state.sourceItem);
703
+ if (matched === undefined)
704
+ statesByItem.set(state.sourceItem, [state]);
705
+ else
706
+ matched.push(state);
707
+ }
708
+ const playbookItems = items.filter(isPlaybookItem);
709
+ const matchedPlaybookStates = new Set();
710
+ const playbookMatchesByItem = new Map();
711
+ const playbookItemsByState = new Map();
712
+ const addPlaybookMatch = (item, state) => {
713
+ const matchedStates = playbookMatchesByItem.get(item);
714
+ if (matchedStates === undefined) {
715
+ playbookMatchesByItem.set(item, [state]);
716
+ }
717
+ else {
718
+ matchedStates.push(state);
719
+ }
720
+ const matchedItems = playbookItemsByState.get(state);
721
+ if (matchedItems === undefined) {
722
+ playbookItemsByState.set(state, [item.id]);
723
+ }
724
+ else {
725
+ matchedItems.push(item.id);
726
+ }
727
+ matchedPlaybookStates.add(state);
728
+ };
729
+ // An explicit sourceItem is authoritative, including when its target or text
730
+ // drifted; retaining that pairing lets conformance report the precise drift.
731
+ for (const state of playbookStates) {
732
+ if (state.sourceItem === undefined)
733
+ continue;
734
+ for (const item of playbookItems) {
735
+ if (item.id === state.sourceItem)
736
+ addPlaybookMatch(item, state);
737
+ }
738
+ }
739
+ // The PlaybookInput contract does not require sourceItem. Pair otherwise
740
+ // indistinguishable calls by signature and declaration order, comparing each
741
+ // signature as a multiset. Equal duplicate cardinalities are conformant;
742
+ // surplus items or states remain unmatched and are reported below.
743
+ const itemsBySignature = new Map();
744
+ for (const item of playbookItems) {
745
+ if ((playbookMatchesByItem.get(item)?.length ?? 0) > 0)
746
+ continue;
747
+ const key = gearsPlaybookSignature(item);
748
+ const grouped = itemsBySignature.get(key);
749
+ if (grouped === undefined)
750
+ itemsBySignature.set(key, [item]);
751
+ else
752
+ grouped.push(item);
753
+ }
754
+ const statesBySignature = new Map();
755
+ for (const state of playbookStates) {
756
+ if (state.sourceItem !== undefined)
757
+ continue;
758
+ const key = statePlaybookSignature(state);
759
+ const grouped = statesBySignature.get(key);
760
+ if (grouped === undefined)
761
+ statesBySignature.set(key, [state]);
762
+ else
763
+ grouped.push(state);
764
+ }
765
+ for (const [key, groupedItems] of itemsBySignature) {
766
+ const groupedStates = statesBySignature.get(key) ?? [];
767
+ const pairs = Math.min(groupedItems.length, groupedStates.length);
768
+ for (let index = 0; index < pairs; index += 1) {
769
+ addPlaybookMatch(groupedItems[index], groupedStates[index]);
770
+ }
771
+ }
772
+ for (const item of items) {
773
+ if (isPlaybookItem(item)) {
774
+ const matched = playbookMatchesByItem.get(item) ?? [];
775
+ if (matched.length === 0) {
776
+ findings.push(`GEARS item ${item.id} maps to no FSM playbook state`);
777
+ continue;
778
+ }
779
+ if (matched.length > 1) {
780
+ findings.push(`GEARS item ${item.id} maps to ${matched.length} FSM playbook states (expected exactly one: ${matched.map((state) => state.stateId).join(', ')})`);
781
+ }
782
+ const state = matched[0];
783
+ if (item.playbookIdContext !== undefined) {
784
+ if (item.textContext === undefined) {
785
+ findings.push(`${item.id}: GEARS dynamic playbook text is not a single <contextField> placeholder`);
786
+ }
787
+ if (state.playbookIdContext !== item.playbookIdContext) {
788
+ findings.push(`${item.id}: FSM playbookIdContext "${state.playbookIdContext ?? ''}" is not GEARS context "${item.playbookIdContext}"`);
789
+ }
790
+ if (state.textContext !== item.textContext) {
791
+ findings.push(`${item.id}: FSM textContext "${state.textContext ?? ''}" is not GEARS context "${item.textContext ?? ''}"`);
792
+ }
793
+ }
794
+ else {
795
+ if (state.playbookId !== item.playbookId) {
796
+ findings.push(`${item.id}: FSM playbook "${state.playbookId ?? ''}" is not GEARS playbook "${item.playbookId ?? ''}"`);
797
+ }
798
+ if (state.text !== item.prompt) {
799
+ findings.push(`${item.id}: FSM playbook text is not the GEARS prompt verbatim`);
800
+ }
801
+ }
802
+ continue;
803
+ }
804
+ if (item.actor === 'script') {
805
+ const matchedScripts = scriptStatesByItem.get(item.id) ?? [];
806
+ if (matchedScripts.length === 0) {
807
+ const drifted = statesByItem.get(item.id) ?? [];
808
+ findings.push(drifted.length > 0
809
+ ? `${item.id}: FSM actor "${drifted[0].actor}" is not GEARS actor "script"`
810
+ : `GEARS item ${item.id} maps to no FSM script state`);
811
+ continue;
812
+ }
813
+ if (matchedScripts.length > 1) {
814
+ findings.push(`GEARS item ${item.id} maps to ${matchedScripts.length} FSM script states (expected exactly one: ${matchedScripts.map((s) => s.stateId).join(', ')})`);
815
+ }
816
+ const scriptState = matchedScripts[0];
817
+ if (scriptState.command !== item.prompt) {
818
+ findings.push(`${item.id}: FSM script command is not the GEARS blockquote verbatim`);
819
+ }
820
+ if (item.result !== undefined) {
821
+ const expected = Object.entries(item.result);
822
+ const actual = Object.entries(scriptState.result);
823
+ if (JSON.stringify(actual) !== JSON.stringify(expected)) {
824
+ findings.push(`${item.id}: FSM script result contract ${JSON.stringify(actual)} is not GEARS Results ${JSON.stringify(expected)}`);
825
+ }
826
+ }
827
+ continue;
828
+ }
829
+ const matched = statesByItem.get(item.id) ?? [];
830
+ if (matched.length === 0) {
831
+ findings.push(`GEARS item ${item.id} maps to no FSM state`);
832
+ continue;
833
+ }
834
+ if (matched.length > 1) {
835
+ findings.push(`GEARS item ${item.id} maps to ${matched.length} FSM states (expected exactly one: ${matched.map((s) => s.stateId).join(', ')})`);
836
+ }
837
+ const state = matched[0];
838
+ if (item.actor !== undefined &&
839
+ explicitActorStates.has(state) &&
840
+ state.actor !== item.actor) {
841
+ findings.push(`${item.id}: FSM actor "${state.actor}" is not GEARS actor "${item.actor}"`);
842
+ }
843
+ if (item.actor === 'player' && state.player !== item.player) {
844
+ findings.push(`${item.id}: FSM player "${state.player}" is not GEARS player "${item.player}"`);
845
+ }
846
+ if (state.prompt !== item.prompt) {
847
+ findings.push(`${item.id}: FSM prompt is not the GEARS prompt verbatim`);
848
+ }
849
+ if (item.result !== undefined) {
850
+ const expected = Object.entries(item.result);
851
+ const actual = Object.entries(state.result).filter(([guard]) => guard !== NEEDS_BOSS_REPLY);
852
+ if (JSON.stringify(actual) !== JSON.stringify(expected)) {
853
+ findings.push(`${item.id}: FSM domain result contract ${JSON.stringify(actual)} is not GEARS Results ${JSON.stringify(expected)}`);
854
+ }
855
+ }
856
+ }
857
+ const itemIds = new Set(items.map((item) => item.id));
858
+ const playbookItemIds = new Set(playbookItems.map((item) => item.id));
859
+ const scriptItemIds = new Set(items.filter((item) => item.actor === 'script').map((item) => item.id));
860
+ for (const state of scriptStates) {
861
+ if (state.sourceItem === '')
862
+ continue;
863
+ if (!itemIds.has(state.sourceItem)) {
864
+ findings.push(`FSM script state ${state.stateId} references unknown GEARS item ${state.sourceItem}`);
865
+ }
866
+ else if (!scriptItemIds.has(state.sourceItem)) {
867
+ findings.push(`FSM script state ${state.stateId} realizes non-script GEARS item ${state.sourceItem}`);
868
+ }
869
+ }
870
+ for (const state of states) {
871
+ if (state.sourceItem !== '' && !itemIds.has(state.sourceItem)) {
872
+ findings.push(`FSM state ${state.stateId} references unknown GEARS item ${state.sourceItem}`);
873
+ }
874
+ // Every captain-invoking state supports Boss-reply suspension: its result
875
+ // map carries `needsBossReply` with the adjudicator-facing contract text
876
+ // (gears2fsm.md; VERIFY-3).
877
+ const bossReply = state.result[NEEDS_BOSS_REPLY];
878
+ if (bossReply === undefined) {
879
+ findings.push(`FSM state ${state.stateId} declares no ${NEEDS_BOSS_REPLY} result`);
880
+ }
881
+ else if (!bossReply.includes(BOSS_QUESTION_MARKER)) {
882
+ findings.push(`FSM state ${state.stateId}: ${NEEDS_BOSS_REPLY} description lacks the ${BOSS_QUESTION_MARKER}\` contract`);
883
+ }
884
+ }
885
+ for (const state of playbookStates) {
886
+ const matchedItems = playbookItemsByState.get(state) ?? [];
887
+ if (matchedItems.length > 1) {
888
+ findings.push(`FSM playbook state ${state.stateId} maps to ${matchedItems.length} GEARS playbook-call items (expected exactly one: ${matchedItems.join(', ')})`);
889
+ }
890
+ if (state.sourceItem !== undefined &&
891
+ !playbookItemIds.has(state.sourceItem)) {
892
+ findings.push(`FSM playbook state ${state.stateId} references unknown GEARS playbook item ${state.sourceItem}`);
893
+ }
894
+ else if (!matchedPlaybookStates.has(state)) {
895
+ findings.push(`FSM playbook state ${state.stateId} maps to no GEARS playbook-call item`);
896
+ }
897
+ }
898
+ return findings;
899
+ }
900
+ /*
901
+ * Machine introspection (VERIFY-4).
902
+ *
903
+ * `pinIntrospection` reduces a machine config to its structural facts — the
904
+ * captain-state bindings, every transition arm, the root and quiescent event
905
+ * surfaces, and the `BOSS_INTERRUPT` jumpable set — computed once at build time
906
+ * and baked into the emitted introspection test, so any unintended topology
907
+ * change to the artifact fails the test (DR-009).
908
+ */
909
+ /** The `gears2fsm`-mandated root pre-emption event name. */
910
+ export const INTERRUPT_EVENT = 'BOSS_INTERRUPT';
911
+ /** The `gears2fsm`-mandated Boss-reply event and wait-state names. */
912
+ export const BOSS_REPLY_EVENT = 'BOSS_REPLY';
913
+ export const AWAIT_BOSS_REPLY_STATE = 'awaitBossReply';
914
+ /**
915
+ * Normalizes an XState transition declaration — a string target, a
916
+ * target/guard/actions object, or an array of either — into ordered
917
+ * {@link TransitionArm}s.
918
+ */
919
+ export function normalizeArms(raw) {
920
+ const arms = Array.isArray(raw) ? raw : raw === undefined ? [] : [raw];
921
+ return arms.map((arm, index) => {
922
+ if (typeof arm === 'string') {
923
+ return { index, target: stripHash(arm), guarded: false };
924
+ }
925
+ if (typeof arm === 'object' && arm !== null) {
926
+ const record = arm;
927
+ return {
928
+ index,
929
+ target: typeof record.target === 'string' ? stripHash(record.target) : null,
930
+ guarded: record.guard !== undefined,
931
+ };
932
+ }
933
+ return { index, target: null, guarded: false };
934
+ });
935
+ }
936
+ function stripHash(target) {
937
+ return target.startsWith('#') ? target.slice(1) : target;
938
+ }
939
+ function eventArms(on) {
940
+ const out = {};
941
+ for (const [event, raw] of Object.entries(on ?? {})) {
942
+ out[event] = normalizeArms(raw);
943
+ }
944
+ return out;
945
+ }
946
+ function normalizedTags(tags) {
947
+ if (typeof tags === 'string')
948
+ return [tags];
949
+ return Array.isArray(tags)
950
+ ? tags.filter((tag) => typeof tag === 'string')
951
+ : [];
952
+ }
953
+ function invokeSource(src) {
954
+ if (typeof src === 'string')
955
+ return src;
956
+ if (typeof src === 'object' &&
957
+ src !== null &&
958
+ 'type' in src &&
959
+ typeof src.type === 'string') {
960
+ return src.type;
961
+ }
962
+ return null;
963
+ }
964
+ /**
965
+ * Reduces a machine config to the structural facts the emitted introspection
966
+ * test pins (VERIFY-4): captain bindings with result keys and every transition
967
+ * arm, the quiescent states' event surfaces, the root event surface, and the
968
+ * `BOSS_INTERRUPT` jumpable set.
969
+ */
970
+ export function pinIntrospection(config) {
971
+ const nodes = walkStateNodes(config);
972
+ const captainBindings = enumerateCaptainBindings(config);
973
+ const playbookBindings = enumeratePlaybookBindings(config);
974
+ const invokingPaths = new Set([
975
+ ...captainBindings.map(({ node }) => node.statePath),
976
+ ...playbookBindings.map(({ node }) => node.statePath),
977
+ ]);
978
+ const captain = [];
979
+ const quiescent = [];
980
+ for (const binding of captainBindings) {
981
+ captain.push({
982
+ state: binding.state.stateId,
983
+ ...(binding.state.statePath !== undefined
984
+ ? { path: binding.state.statePath }
985
+ : {}),
986
+ // Captain-binding enumeration never yields `script`; the widened
987
+ // CaptainState union exists only for coverage-driving views.
988
+ ...(binding.pinActor && binding.state.actor !== 'script'
989
+ ? { actor: binding.state.actor }
990
+ : {}),
991
+ sourceItem: binding.state.sourceItem,
992
+ player: binding.state.player,
993
+ resultKeys: Object.keys(binding.state.result).sort(),
994
+ onDone: normalizeArms(binding.invoke.onDone),
995
+ onError: normalizeArms(binding.invoke.onError),
996
+ on: eventArms(binding.node.state.on),
997
+ });
998
+ }
999
+ for (const [stateId, state] of Object.entries(config.states ?? {})) {
1000
+ if (!invokingPaths.has(stateId)) {
1001
+ quiescent.push({
1002
+ state: stateId,
1003
+ final: state.type === 'final',
1004
+ on: eventArms(state.on),
1005
+ });
1006
+ }
1007
+ }
1008
+ const rootOn = eventArms(config.on);
1009
+ const interruptTargets = (rootOn[INTERRUPT_EVENT] ?? [])
1010
+ .map((arm) => arm.target)
1011
+ .filter((target) => target !== null);
1012
+ const playbook = playbookBindings.map((binding) => ({
1013
+ state: binding.state.stateId,
1014
+ ...(binding.state.statePath !== undefined
1015
+ ? { path: binding.state.statePath }
1016
+ : {}),
1017
+ ...(binding.state.playbookIdContext === undefined
1018
+ ? { playbookId: binding.state.playbookId }
1019
+ : {}),
1020
+ ...(binding.state.playbookIdContext !== undefined
1021
+ ? { playbookIdContext: binding.state.playbookIdContext }
1022
+ : {}),
1023
+ ...(binding.state.textContext !== undefined
1024
+ ? { textContext: binding.state.textContext }
1025
+ : {}),
1026
+ ...(binding.state.sourceItem !== undefined
1027
+ ? { sourceItem: binding.state.sourceItem }
1028
+ : {}),
1029
+ onDone: normalizeArms(binding.invoke.onDone),
1030
+ onError: normalizeArms(binding.invoke.onError),
1031
+ on: eventArms(binding.node.state.on),
1032
+ }));
1033
+ const structured = hasStructuredTopology(nodes)
1034
+ ? {
1035
+ states: nodes.map(({ path, state, statePath }) => ({
1036
+ path: statePath,
1037
+ parent: path.length > 1 ? path.slice(0, -1).join('.') : null,
1038
+ id: typeof state.id === 'string' ? state.id : null,
1039
+ publicStateId: metadataStateId(state) ?? null,
1040
+ type: typeof state.type === 'string' ? state.type : null,
1041
+ initial: typeof state.initial === 'string' ? state.initial : null,
1042
+ tags: normalizedTags(state.tags),
1043
+ children: Object.keys(state.states ?? {}),
1044
+ invokes: normalizeInvokes(state.invoke)
1045
+ .map(({ src }) => invokeSource(src))
1046
+ .filter((source) => source !== null),
1047
+ onDone: normalizeArms(state.onDone),
1048
+ onError: normalizeArms(state.onError),
1049
+ on: eventArms(state.on),
1050
+ })),
1051
+ }
1052
+ : undefined;
1053
+ return {
1054
+ initial: typeof config.initial === 'string' ? config.initial : null,
1055
+ captain,
1056
+ quiescent,
1057
+ rootOn,
1058
+ interruptTargets,
1059
+ ...(playbook.length > 0 ? { playbook } : {}),
1060
+ ...(structured !== undefined ? { structured } : {}),
1061
+ };
1062
+ }
1063
+ /*
1064
+ * Prompt-contract capture and composition checks (VERIFY-5).
1065
+ *
1066
+ * The contract is derived from the artifacts, never hand-authored: context
1067
+ * reads are traced through each state's `invoke.input` thunk with a recording
1068
+ * proxy, wiring by sentinel values, placeholders by scanning the prompt body,
1069
+ * and substitution by composing with sentinels and observing which tokens the
1070
+ * linked composer replaces. The derived facts are pinned into the emitted test
1071
+ * so contract drift fails it (DR-009).
1072
+ */
1073
+ /** The exact continuation preamble the link contract mandates (link.md). */
1074
+ export const CONTINUATION_PREAMBLE = 'You previously paused this task to ask Boss a question; Boss has now replied. Continue the same task using the reply below.';
1075
+ export const BOSS_QUESTION_LABEL = 'Boss question:';
1076
+ export const BOSS_REPLY_LABEL = 'Boss reply:';
1077
+ // Direct Captain prompts cross the callCaptain boundary and therefore must
1078
+ // not acquire player-only routing or session-control text. Match the stable
1079
+ // labelled form as well as natural-language variants; occurrence deltas below
1080
+ // keep self-hosting prompt bodies free to quote either marker verbatim.
1081
+ const PLAYER_BINDING_MARKER = /\bplayer\s+binding\b|(?:^|\n)[ \t]*player[ \t]*:[ \t]*(?=\S)/gi;
1082
+ const PLAYER_RESUME_MARKER = /\b(?:resume|resuming)\b[^\n]{0,120}\bplayer(?:'s)?\b|\bplayer(?:'s)?\b[^\n]{0,120}\b(?:resume|resuming)\b/gi;
1083
+ const PLACEHOLDER = /<[^\s<>`]{1,60}>/g;
1084
+ /** Lists the distinct `<...>` placeholder tokens in a prompt body, in order. */
1085
+ export function placeholdersIn(prompt) {
1086
+ const seen = [];
1087
+ for (const token of prompt.match(PLACEHOLDER) ?? []) {
1088
+ if (!seen.includes(token))
1089
+ seen.push(token);
1090
+ }
1091
+ return seen;
1092
+ }
1093
+ const sentinelFor = (field) => `«${field}»`;
1094
+ /**
1095
+ * Traces which context fields an `invoke.input` thunk reads, via a recording
1096
+ * proxy context; reads collected up to a throw are kept.
1097
+ */
1098
+ export function probeContextReads(inputFn) {
1099
+ const reads = new Set();
1100
+ const context = new Proxy({}, {
1101
+ get(_target, prop) {
1102
+ if (typeof prop === 'string')
1103
+ reads.add(prop);
1104
+ return undefined;
1105
+ },
1106
+ has() {
1107
+ return true;
1108
+ },
1109
+ });
1110
+ try {
1111
+ inputFn({ context });
1112
+ }
1113
+ catch {
1114
+ // Reads observed before the throw still pin the contract.
1115
+ }
1116
+ return [...reads].sort();
1117
+ }
1118
+ function sentinelContext(reads) {
1119
+ return Object.fromEntries(reads.map((field) => [field, sentinelFor(field)]));
1120
+ }
1121
+ // The gears2fsm-normative Boss-reply context fields: present only on a
1122
+ // continuation turn, so an ordinary-turn probe must leave them unset.
1123
+ const BOSS_CONTEXT_FIELDS = [
1124
+ 'pendingBossQuestion',
1125
+ 'bossReply',
1126
+ 'pendingBossQuestions',
1127
+ 'bossReplies',
1128
+ ];
1129
+ function ordinaryContext(reads) {
1130
+ return sentinelContext(reads.filter((field) => !BOSS_CONTEXT_FIELDS.includes(field)));
1131
+ }
1132
+ function carriesSentinel(value, sentinel) {
1133
+ try {
1134
+ return (JSON.stringify(value) ?? '').includes(sentinel);
1135
+ }
1136
+ catch {
1137
+ return false;
1138
+ }
1139
+ }
1140
+ /**
1141
+ * Derives every captain state's prompt contract from the machine config
1142
+ * (VERIFY-5): traced context reads, sentinel-traced input wiring, and the
1143
+ * prompt body's placeholder tokens.
1144
+ */
1145
+ export function capturePromptContract(config) {
1146
+ const rows = [];
1147
+ for (const binding of enumerateCaptainBindings(config)) {
1148
+ const { state, inputFn } = binding;
1149
+ if (typeof inputFn !== 'function')
1150
+ continue;
1151
+ const reads = probeContextReads(inputFn);
1152
+ const wires = {};
1153
+ try {
1154
+ const input = inputFn({ context: sentinelContext(reads) });
1155
+ if (typeof input === 'object' && input !== null) {
1156
+ for (const [key, value] of Object.entries(input)) {
1157
+ const carried = reads.filter((field) => carriesSentinel(value, sentinelFor(field)));
1158
+ if (carried.length > 0)
1159
+ wires[key] = carried;
1160
+ }
1161
+ }
1162
+ }
1163
+ catch {
1164
+ // Wiring stays empty; the traced reads alone still pin the contract.
1165
+ }
1166
+ rows.push({
1167
+ state: state.stateId,
1168
+ sourceItem: state.sourceItem,
1169
+ player: state.player,
1170
+ reads,
1171
+ wires,
1172
+ placeholders: placeholdersIn(state.prompt),
1173
+ });
1174
+ }
1175
+ return rows;
1176
+ }
1177
+ /**
1178
+ * Derives, per captain state, which of its prompt's placeholder tokens the
1179
+ * linked composer substitutes when the wired context is present — pinned into
1180
+ * the emitted test so a token that later leaks unsubstituted fails it
1181
+ * (VERIFY-5).
1182
+ */
1183
+ export function deriveSubstitutions(config, compose, actor) {
1184
+ const out = {};
1185
+ for (const binding of enumerateCaptainBindings(config)) {
1186
+ const { state, inputFn } = binding;
1187
+ if (actor !== undefined && state.actor !== actor)
1188
+ continue;
1189
+ if (typeof inputFn !== 'function')
1190
+ continue;
1191
+ try {
1192
+ const reads = probeContextReads(inputFn);
1193
+ const composed = compose(inputFn({ context: ordinaryContext(reads) }));
1194
+ if (typeof composed !== 'string') {
1195
+ out[state.stateId] = [];
1196
+ continue;
1197
+ }
1198
+ // A placeholder counts as substituted only when the exact body survives
1199
+ // on its source line and that token's position carries one of the context
1200
+ // sentinels. Derive line-by-line so an unrelated mutated line does not
1201
+ // hide valid evidence, while merely deleting a token still cannot
1202
+ // masquerade as substitution.
1203
+ const evidenced = new Set();
1204
+ for (const line of state.prompt.split('\n')) {
1205
+ for (const token of matchPromptBody(line, composed, reads)
1206
+ ?.substitutions ?? []) {
1207
+ evidenced.add(token);
1208
+ }
1209
+ }
1210
+ out[state.stateId] = placeholdersIn(state.prompt).filter((token) => evidenced.has(token));
1211
+ }
1212
+ catch {
1213
+ out[state.stateId] = [];
1214
+ }
1215
+ }
1216
+ return out;
1217
+ }
1218
+ /**
1219
+ * Checks the linked composer against the link contract for every captain state
1220
+ * (VERIFY-5), returning findings (empty when conformant): the prompt body is
1221
+ * preserved modulo substituted placeholders, the adjudicator-facing Boss-reply
1222
+ * contract never leaks into a player prompt, no continuation appears on an
1223
+ * ordinary turn, and a Boss-reply continuation turn opens with the exact
1224
+ * preamble and labelled Q&A blocks before the body.
1225
+ */
1226
+ export function checkPromptComposition(opts) {
1227
+ const findings = [];
1228
+ const substitutions = deriveSubstitutions(opts.config, opts.compose, opts.actor);
1229
+ const composerName = opts.actor === 'captain' ? 'composeCaptainPrompt' : 'composePlayerPrompt';
1230
+ for (const binding of enumerateCaptainBindings(opts.config)) {
1231
+ const { state, inputFn } = binding;
1232
+ if (opts.actor !== undefined && state.actor !== opts.actor)
1233
+ continue;
1234
+ if (typeof inputFn !== 'function')
1235
+ continue;
1236
+ const reads = probeContextReads(inputFn);
1237
+ const substituted = substitutions[state.stateId] ?? [];
1238
+ let ordinary;
1239
+ try {
1240
+ ordinary = opts.compose(inputFn({ context: ordinaryContext(reads) }));
1241
+ if (typeof ordinary !== 'string') {
1242
+ throw new Error(`${composerName} returned a non-string value`);
1243
+ }
1244
+ }
1245
+ catch (error) {
1246
+ findings.push(`${state.stateId}: ${composerName} threw on an ordinary turn: ${messageOf(error)}`);
1247
+ continue;
1248
+ }
1249
+ findings.push(...bodyFindings(state, ordinary, substituted, reads, 'ordinary'));
1250
+ pushUnique(findings, ...directCaptainControlFindings(state, ordinary));
1251
+ // A self-hosted playbook's domain body may legitimately quote the
1252
+ // adjudicator contract or the continuation texts (it instructs a compiler
1253
+ // about them); only occurrences the composer ADDS beyond the body's own
1254
+ // are leaks.
1255
+ if (occurrences(ordinary, BOSS_QUESTION_MARKER) >
1256
+ occurrences(state.prompt, BOSS_QUESTION_MARKER)) {
1257
+ findings.push(`${state.stateId}: the adjudicator-facing ${NEEDS_BOSS_REPLY} contract leaks into the player prompt`);
1258
+ }
1259
+ if ([CONTINUATION_PREAMBLE, BOSS_QUESTION_LABEL, BOSS_REPLY_LABEL].some((needle) => occurrences(ordinary, needle) > occurrences(state.prompt, needle))) {
1260
+ findings.push(`${state.stateId}: continuation blocks appear on an ordinary turn`);
1261
+ }
1262
+ // A Boss-reply continuation turn: the thunk carries the pending question
1263
+ // and reply, and the composer opens with the exact preamble and labelled
1264
+ // Q&A blocks before the domain body (gears2fsm.md, link.md).
1265
+ const question = sentinelFor('question');
1266
+ const reply = sentinelFor('bossReply');
1267
+ const pendingBossQuestion = {
1268
+ resumeStateId: state.stateId,
1269
+ sourceItem: state.sourceItem,
1270
+ player: state.player,
1271
+ question,
1272
+ };
1273
+ let continuation;
1274
+ let input;
1275
+ try {
1276
+ input = inputFn({
1277
+ context: {
1278
+ ...ordinaryContext(reads),
1279
+ pendingBossQuestion,
1280
+ bossReply: reply,
1281
+ pendingBossQuestions: {
1282
+ [state.stateId]: pendingBossQuestion,
1283
+ },
1284
+ bossReplies: { [state.stateId]: reply },
1285
+ },
1286
+ });
1287
+ continuation = opts.compose(input);
1288
+ if (typeof continuation !== 'string') {
1289
+ throw new Error(`${composerName} returned a non-string value`);
1290
+ }
1291
+ }
1292
+ catch (error) {
1293
+ findings.push(`${state.stateId}: ${composerName} threw on a continuation turn: ${messageOf(error)}`);
1294
+ continue;
1295
+ }
1296
+ if (!carriesSentinel(input, question) || !carriesSentinel(input, reply)) {
1297
+ findings.push(`${state.stateId}: invoke.input does not carry pendingBossQuestion/bossReply for a continuation turn`);
1298
+ continue;
1299
+ }
1300
+ if (!continuation.startsWith(`${CONTINUATION_PREAMBLE}\n\n`)) {
1301
+ findings.push(`${state.stateId}: a continuation turn does not open with the exact preamble`);
1302
+ }
1303
+ const bodyStart = bodyIndex(state, continuation, substituted, reads);
1304
+ const questionBlock = `${BOSS_QUESTION_LABEL}\n${question}`;
1305
+ const replyBlock = `${BOSS_REPLY_LABEL}\n${reply}`;
1306
+ for (const [label, value] of [
1307
+ [BOSS_QUESTION_LABEL, questionBlock],
1308
+ [BOSS_REPLY_LABEL, replyBlock],
1309
+ ]) {
1310
+ // The composer must ADD the labelled block (beyond any body-carried
1311
+ // occurrence), with its sentinel value immediately below the label and
1312
+ // before the body.
1313
+ const at = continuation.indexOf(value);
1314
+ if (occurrences(continuation, value) <= occurrences(state.prompt, value)) {
1315
+ findings.push(`${state.stateId}: a continuation turn lacks the "${label}" block`);
1316
+ }
1317
+ else if (bodyStart !== -1 && at > bodyStart) {
1318
+ findings.push(`${state.stateId}: the "${label}" block appears after the domain prompt body`);
1319
+ }
1320
+ }
1321
+ const exactContinuationPrefix = `${CONTINUATION_PREAMBLE}\n\n${questionBlock}\n\n${replyBlock}\n\n`;
1322
+ if (!continuation.startsWith(exactContinuationPrefix)) {
1323
+ findings.push(`${state.stateId}: a continuation turn does not preserve the exact ordered Boss question/reply blocks`);
1324
+ }
1325
+ findings.push(...bodyFindings(state, continuation, substituted, reads, 'continuation'));
1326
+ pushUnique(findings, ...directCaptainControlFindings(state, continuation));
1327
+ }
1328
+ return findings;
1329
+ }
1330
+ function directCaptainControlFindings(state, composed) {
1331
+ if (state.actor !== 'captain')
1332
+ return [];
1333
+ const findings = [];
1334
+ if (patternOccurrences(composed, PLAYER_BINDING_MARKER) >
1335
+ patternOccurrences(state.prompt, PLAYER_BINDING_MARKER)) {
1336
+ findings.push(`${state.stateId}: composeCaptainPrompt introduces a player binding into a direct-Captain prompt`);
1337
+ }
1338
+ if (patternOccurrences(composed, PLAYER_RESUME_MARKER) >
1339
+ patternOccurrences(state.prompt, PLAYER_RESUME_MARKER)) {
1340
+ findings.push(`${state.stateId}: composeCaptainPrompt introduces a player resume instruction into a direct-Captain prompt`);
1341
+ }
1342
+ return findings;
1343
+ }
1344
+ function patternOccurrences(hay, pattern) {
1345
+ return [...hay.matchAll(new RegExp(pattern.source, pattern.flags))].length;
1346
+ }
1347
+ function pushUnique(target, ...values) {
1348
+ for (const value of values) {
1349
+ if (!target.includes(value))
1350
+ target.push(value);
1351
+ }
1352
+ }
1353
+ /** Counts non-overlapping occurrences of `needle` in `hay`. */
1354
+ function occurrences(hay, needle) {
1355
+ return needle === '' ? 0 : hay.split(needle).length - 1;
1356
+ }
1357
+ /**
1358
+ * Finds the prompt body as one exact, line-bounded block inside a composed
1359
+ * prompt. In derivation mode, a placeholder may remain literal or be replaced
1360
+ * by one exact non-empty context sentinel; mixed replacement of repeated
1361
+ * tokens is rejected. With `expectedSubstitutions`, substituted positions must
1362
+ * carry a sentinel and every other placeholder must remain literal.
1363
+ */
1364
+ function matchPromptBody(prompt, composed, reads, expectedSubstitutions) {
1365
+ const placeholderMatches = [...prompt.matchAll(PLACEHOLDER)];
1366
+ const sentinels = reads.map(sentinelFor);
1367
+ const sentinelForms = sentinels.flatMap((sentinel) => [
1368
+ sentinel,
1369
+ JSON.stringify(sentinel),
1370
+ ]);
1371
+ // Decompose the prompt into literal segments separated by placeholders, then
1372
+ // scan the composed text segment by segment. A monolithic escaped regex over
1373
+ // a meta-scale prompt exceeds the engine's pattern-size limit, so matching
1374
+ // is plain string comparison over a finite candidate set at each gap.
1375
+ const segments = [];
1376
+ const tokens = [];
1377
+ let offset = 0;
1378
+ for (const match of placeholderMatches) {
1379
+ segments.push(prompt.slice(offset, match.index));
1380
+ tokens.push(match[0]);
1381
+ offset = match.index + match[0].length;
1382
+ }
1383
+ segments.push(prompt.slice(offset));
1384
+ const tryFrom = (start) => {
1385
+ if (!composed.startsWith(segments[0], start))
1386
+ return null;
1387
+ let pos = start + segments[0].length;
1388
+ const values = [];
1389
+ for (let gap = 0; gap < tokens.length; gap++) {
1390
+ const token = tokens[gap];
1391
+ const candidates = expectedSubstitutions === undefined
1392
+ ? [token, ...sentinelForms]
1393
+ : expectedSubstitutions.includes(token)
1394
+ ? sentinelForms
1395
+ : [token];
1396
+ const next = segments[gap + 1];
1397
+ const chosen = candidates.find((candidate) => composed.startsWith(candidate, pos) &&
1398
+ composed.startsWith(next, pos + candidate.length));
1399
+ if (chosen === undefined)
1400
+ return null;
1401
+ values.push(chosen);
1402
+ pos += chosen.length + next.length;
1403
+ }
1404
+ if (pos !== composed.length && composed[pos] !== '\n')
1405
+ return null;
1406
+ return { end: pos, values };
1407
+ };
1408
+ for (let start = 0; start <= composed.length; start++) {
1409
+ if (start !== 0 && composed[start - 1] !== '\n')
1410
+ continue;
1411
+ const attempt = tryFrom(start);
1412
+ if (attempt === null)
1413
+ continue;
1414
+ if (expectedSubstitutions !== undefined) {
1415
+ return { index: start, substitutions: [...expectedSubstitutions] };
1416
+ }
1417
+ const modes = new Map();
1418
+ for (let gap = 0; gap < tokens.length; gap++) {
1419
+ const token = tokens[gap];
1420
+ const tokenModes = modes.get(token) ?? new Set();
1421
+ tokenModes.add(attempt.values[gap] === token ? 'literal' : 'sentinel');
1422
+ modes.set(token, tokenModes);
1423
+ }
1424
+ if ([...modes.values()].some((tokenModes) => tokenModes.size > 1)) {
1425
+ continue;
1426
+ }
1427
+ const substitutions = placeholdersIn(prompt).filter((token) => modes.get(token)?.has('sentinel') === true);
1428
+ return { index: start, substitutions };
1429
+ }
1430
+ return null;
1431
+ }
1432
+ /** Findings when a composed prompt does not preserve the domain body (VERIFY-5). */
1433
+ function bodyFindings(state, composed, substituted, reads, turn) {
1434
+ if (matchPromptBody(state.prompt, composed, reads, substituted) !== null) {
1435
+ return [];
1436
+ }
1437
+ // Preserve the established line-specific diagnostic where possible, while
1438
+ // the whole-body match above additionally catches reordering, inserted
1439
+ // lines, and prefixes/suffixes around otherwise present lines.
1440
+ for (const line of state.prompt.split('\n')) {
1441
+ if (line.trim() === '')
1442
+ continue;
1443
+ if (matchPromptBody(line, composed, reads, substituted) === null) {
1444
+ return [
1445
+ `${state.stateId}: a ${turn} turn does not preserve the body line "${line}"`,
1446
+ ];
1447
+ }
1448
+ }
1449
+ return [
1450
+ `${state.stateId}: a ${turn} turn does not preserve the prompt body verbatim and in order`,
1451
+ ];
1452
+ }
1453
+ /** The index of the body's first preserved line in a composed prompt, or -1. */
1454
+ function bodyIndex(state, composed, substituted, reads) {
1455
+ return (matchPromptBody(state.prompt, composed, reads, substituted)?.index ?? -1);
1456
+ }
1457
+ function messageOf(error) {
1458
+ return error instanceof Error ? error.message : String(error);
1459
+ }
1460
+ /** Serializes an arbitrary string as a safe JavaScript/TypeScript literal. */
1461
+ function sourceString(value) {
1462
+ return JSON.stringify(value)
1463
+ .replace(/\u2028/g, '\\u2028')
1464
+ .replace(/\u2029/g, '\\u2029');
1465
+ }
1466
+ /**
1467
+ * Package-export default for direct emitter callers. Full reserved-pipeline
1468
+ * runs override it with the artifact-local verifier support module.
1469
+ */
1470
+ export const VERIFY_MODULE = '@sublang/slc/verify';
1471
+ /**
1472
+ * Finds the XState machine an `fsm` module exports — the export whose value has a
1473
+ * `.config.states` — so callers need not know its export name, and returns that
1474
+ * machine's config for {@link checkGearsFsmConformance}.
1475
+ *
1476
+ * @throws when the module exports no such machine.
1477
+ */
1478
+ export function findMachineConfig(fsmModule) {
1479
+ if (typeof fsmModule === 'object' && fsmModule !== null) {
1480
+ for (const value of Object.values(fsmModule)) {
1481
+ if (typeof value === 'object' && value !== null && 'config' in value) {
1482
+ const config = value.config;
1483
+ if (typeof config === 'object' &&
1484
+ config !== null &&
1485
+ 'states' in config) {
1486
+ return config;
1487
+ }
1488
+ }
1489
+ }
1490
+ }
1491
+ throw new Error('fsm module exports no XState machine with a `.config.states`');
1492
+ }
1493
+ /**
1494
+ * Builds a per-artifact vitest module that fails when the compiled FSM drifts
1495
+ * from its GEARS source: it reads the artifact's `gears` file and the machine its
1496
+ * `fsm` module exports (via {@link findMachineConfig}, so no export name is
1497
+ * needed), then asserts {@link checkGearsFsmConformance} finds nothing.
1498
+ */
1499
+ export function generateGearsFsmConformanceTest(opts) {
1500
+ return `// SPDX-License-Identifier: Apache-2.0
1501
+ // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
1502
+
1503
+ // Generated by slc (IR-007 Task 8): GEARS↔FSM conformance.
1504
+ import { readFileSync } from 'node:fs';
1505
+ import { fileURLToPath } from 'node:url';
1506
+
1507
+ import { describe, expect, it } from 'vitest';
1508
+
1509
+ import { checkGearsFsmConformance, findMachineConfig } from ${sourceString(opts.verifyModule)};
1510
+ import * as fsm from ${sourceString(opts.fsmModule)};
1511
+
1512
+ describe(${sourceString(`${opts.basename}: GEARS↔FSM conformance`)}, () => {
1513
+ it('maps every GEARS item to a state with its player and verbatim prompt', () => {
1514
+ const gears = readFileSync(
1515
+ fileURLToPath(new URL(${sourceString(opts.gearsFile)}, import.meta.url)),
1516
+ 'utf8',
1517
+ );
1518
+ expect(checkGearsFsmConformance(gears, findMachineConfig(fsm))).toEqual([]);
1519
+ });
1520
+ });
1521
+ `;
1522
+ }
1523
+ /**
1524
+ * Emits the GEARS↔FSM conformance test as `slc` output beside a compiled
1525
+ * `playbook` artifact: writes `<basename>.gears-fsm.test.ts` into the artifact
1526
+ * directory (`<basename>.playbook/`), wiring the artifact's `gears` file and its
1527
+ * `fsm` module's machine to the checker, and returns the written path (VERIFY-2;
1528
+ * [DR-009](../decisions/009-slc-playbook-pipeline-compilation.md)).
1529
+ */
1530
+ export async function emitGearsFsmConformanceTest(opts) {
1531
+ const content = generateGearsFsmConformanceTest({
1532
+ basename: opts.basename,
1533
+ // NodeNext source imports the TypeScript artifact through its runtime
1534
+ // `.js` specifier; Vitest resolves that edge to the sibling source.
1535
+ fsmModule: `./${opts.basename}.fsm.js`,
1536
+ gearsFile: `./${opts.basename}.gears.md`,
1537
+ verifyModule: opts.verifyModule ?? VERIFY_MODULE,
1538
+ });
1539
+ await mkdir(opts.artifactDir, { recursive: true });
1540
+ const path = join(opts.artifactDir, `${opts.basename}.gears-fsm.test.ts`);
1541
+ await writeFile(path, content);
1542
+ return path;
1543
+ }
1544
+ /**
1545
+ * Imports a produced `fsm` artifact module for emission-time derivation. The
1546
+ * artifact is TypeScript; under Node's type stripping (erasable-syntax-only)
1547
+ * the direct import works, and a failure is reported to the caller so emission
1548
+ * degrades to a diagnostic rather than failing the run. The URL carries the
1549
+ * content hash so a rebuilt artifact at the same path is never served from the
1550
+ * module cache.
1551
+ */
1552
+ export async function loadFsmModule(fsmPath) {
1553
+ const resolved = resolve(fsmPath);
1554
+ const url = pathToFileURL(resolved);
1555
+ url.searchParams.set('v', await hashFile(resolved));
1556
+ return import(url.href);
1557
+ }
1558
+ /**
1559
+ * Imports the generated linked TypeScript module before its sibling FSM has
1560
+ * been built to JavaScript. NodeNext source correctly names the runtime-safe
1561
+ * `./<basename>.fsm.js` edge, but emission-time verification runs while only
1562
+ * `./<basename>.fsm.ts` exists. Stage a same-directory copy whose one generated
1563
+ * module specifier points at the hashed TypeScript artifact, import that copy,
1564
+ * and remove it without changing the linked source or its production import.
1565
+ */
1566
+ async function loadLinkedModuleForVerification(opts) {
1567
+ const linkedSource = await readFile(opts.linkedPath, 'utf8');
1568
+ const fsmStem = basename(opts.fsmPath, '.ts');
1569
+ const runtimeSpecifier = `./${fsmStem}.js`;
1570
+ const verificationSpecifier = `./${fsmStem}.ts?v=${await hashFile(opts.fsmPath)}`;
1571
+ const stagedSource = linkedSource
1572
+ .replaceAll(sourceString(runtimeSpecifier), sourceString(verificationSpecifier))
1573
+ .replaceAll(`'${runtimeSpecifier}'`, `'${verificationSpecifier}'`);
1574
+ // Linked fixtures that do not import their FSM need no staging and retain
1575
+ // the established direct-loading behavior.
1576
+ if (stagedSource === linkedSource) {
1577
+ return loadFsmModule(opts.linkedPath);
1578
+ }
1579
+ const linkedStem = basename(opts.linkedPath, '.ts');
1580
+ const stagedPath = join(dirname(opts.linkedPath), `.${linkedStem}.slc-verify-${randomUUID()}.ts`);
1581
+ await writeFile(stagedPath, stagedSource, { flag: 'wx' });
1582
+ try {
1583
+ return await loadFsmModule(stagedPath);
1584
+ }
1585
+ finally {
1586
+ await unlink(stagedPath);
1587
+ }
1588
+ }
1589
+ /**
1590
+ * Builds a per-artifact vitest module that fails when the machine's structure
1591
+ * drifts from the topology pinned at build time (VERIFY-4).
1592
+ */
1593
+ export function generateFsmIntrospectionTest(opts) {
1594
+ return `// SPDX-License-Identifier: Apache-2.0
1595
+ // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
1596
+
1597
+ // Generated by slc (DR-009): FSM introspection pins.
1598
+ // The PINNED topology was derived from the artifact at build time; any
1599
+ // unintended structural change to the machine fails this test.
1600
+ import { describe, expect, it } from 'vitest';
1601
+
1602
+ import { findMachineConfig, pinIntrospection } from ${sourceString(opts.verifyModule)};
1603
+ import * as fsm from ${sourceString(opts.fsmModule)};
1604
+
1605
+ const PINNED = ${JSON.stringify(opts.pins, null, 2)};
1606
+
1607
+ describe(${sourceString(`${opts.basename}: FSM introspection`)}, () => {
1608
+ it('matches the machine topology pinned at build time', () => {
1609
+ expect(pinIntrospection(findMachineConfig(fsm))).toEqual(PINNED);
1610
+ });
1611
+ });
1612
+ `;
1613
+ }
1614
+ /**
1615
+ * Builds a per-artifact vitest module pinning the prompt contract derived from
1616
+ * the artifacts at build time (VERIFY-5): the per-state context reads, input
1617
+ * wiring, and placeholders always; and, when the linked module exposes its
1618
+ * matching Captain/player composers, the composition checks and pinned
1619
+ * substitution maps.
1620
+ */
1621
+ export function generatePromptContractTest(opts) {
1622
+ const composerImports = opts.composer
1623
+ ? `import * as playbook from ${sourceString(opts.composer.playbookModule)};\n`
1624
+ : '';
1625
+ const composerBlock = [
1626
+ ['captain', 'Captain', 'composeCaptainPrompt'],
1627
+ ['player', 'player', 'composePlayerPrompt'],
1628
+ ]
1629
+ .flatMap(([actor, label, exportName]) => {
1630
+ const substituted = opts.composer?.[actor];
1631
+ if (substituted === undefined)
1632
+ return [];
1633
+ const constant = `${actor.toUpperCase()}_SUBSTITUTED`;
1634
+ const compose = `compose${label === 'Captain' ? 'Captain' : 'Player'}`;
1635
+ return [
1636
+ `
1637
+ const ${constant} = ${JSON.stringify(substituted, null, 2)};
1638
+
1639
+ const ${compose} = (
1640
+ playbook as unknown as {
1641
+ _internal: { ${exportName}: (input: unknown) => string };
1642
+ }
1643
+ )._internal.${exportName};
1644
+
1645
+ it('composes ${label} prompts per the link contract', () => {
1646
+ expect(
1647
+ checkPromptComposition({
1648
+ config: findMachineConfig(fsm),
1649
+ compose: ${compose},
1650
+ actor: '${actor}',
1651
+ }),
1652
+ ).toEqual([]);
1653
+ });
1654
+
1655
+ it('substitutes the ${label} placeholders pinned at build time', () => {
1656
+ expect(
1657
+ deriveSubstitutions(
1658
+ findMachineConfig(fsm),
1659
+ ${compose},
1660
+ '${actor}',
1661
+ ),
1662
+ ).toEqual(${constant});
1663
+ });
1664
+ `,
1665
+ ];
1666
+ })
1667
+ .join('');
1668
+ const checkerImports = opts.composer
1669
+ ? 'capturePromptContract,\n checkPromptComposition,\n deriveSubstitutions,\n findMachineConfig,'
1670
+ : 'capturePromptContract,\n findMachineConfig,';
1671
+ return `// SPDX-License-Identifier: Apache-2.0
1672
+ // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
1673
+
1674
+ // Generated by slc (DR-009): prompt contract.
1675
+ // The pinned rows were derived from the artifacts at build time; wiring,
1676
+ // placeholder, or composition drift fails this test.
1677
+ import { describe, expect, it } from 'vitest';
1678
+
1679
+ import {
1680
+ ${checkerImports}
1681
+ } from ${sourceString(opts.verifyModule)};
1682
+ import * as fsm from ${sourceString(opts.fsmModule)};
1683
+ ${composerImports}
1684
+ const CONTRACT = ${JSON.stringify(opts.rows, null, 2)};
1685
+
1686
+ describe(${sourceString(`${opts.basename}: prompt contract`)}, () => {
1687
+ it('matches the prompt contract pinned at build time', () => {
1688
+ expect(capturePromptContract(findMachineConfig(fsm))).toEqual(CONTRACT);
1689
+ });
1690
+ ${composerBlock}});
1691
+ `;
1692
+ }
1693
+ /**
1694
+ * Emits the prompt-contract test beside a compiled `playbook` artifact
1695
+ * (VERIFY-5): derives and pins the per-state contract from the physical
1696
+ * `<basename>.fsm.ts` artifact, then emits NodeNext `.js` imports for that FSM
1697
+ * and any linked `<basename>.playbook.ts` module. When the linked module
1698
+ * exposes the `_internal` composer matching each state actor —
1699
+ * `composeCaptainPrompt` for direct Captain work and `composePlayerPrompt` for
1700
+ * delegated work — the test pins substitution maps and composition checks.
1701
+ * Returns the written path and any diagnostics (a linked module that cannot be
1702
+ * imported or exposes no matching composer degrades independently to the
1703
+ * artifact-only checks).
1704
+ *
1705
+ * @throws when the `fsm` artifact cannot be imported or exports no machine.
1706
+ */
1707
+ export async function emitPromptContractTest(opts) {
1708
+ const diagnostics = [];
1709
+ const fsmPath = join(opts.artifactDir, `${opts.basename}.fsm.ts`);
1710
+ const config = findMachineConfig(await loadFsmModule(fsmPath));
1711
+ const rows = capturePromptContract(config);
1712
+ let composer;
1713
+ const linkedPath = join(opts.artifactDir, `${opts.basename}.playbook.ts`);
1714
+ if (existsSync(linkedPath)) {
1715
+ try {
1716
+ const linked = (await loadLinkedModuleForVerification({
1717
+ linkedPath,
1718
+ fsmPath,
1719
+ }));
1720
+ const actors = new Set(enumerateCaptainStates(config).map(({ actor }) => actor));
1721
+ const substitutions = {};
1722
+ for (const actor of ['captain', 'player']) {
1723
+ if (!actors.has(actor))
1724
+ continue;
1725
+ const exportName = actor === 'captain' ? 'composeCaptainPrompt' : 'composePlayerPrompt';
1726
+ const compose = linked._internal?.[exportName];
1727
+ if (typeof compose !== 'function') {
1728
+ diagnostics.push(`prompt contract: linked module exposes no _internal.${exportName}; ${actor} composition checks not emitted`);
1729
+ continue;
1730
+ }
1731
+ const typedCompose = compose;
1732
+ substitutions[actor] = deriveSubstitutions(config, typedCompose, actor);
1733
+ const findings = checkPromptComposition({
1734
+ config,
1735
+ compose: typedCompose,
1736
+ actor,
1737
+ });
1738
+ diagnostics.push(...findings.map((finding) => `prompt contract: ${finding}`));
1739
+ }
1740
+ if (substitutions.captain !== undefined ||
1741
+ substitutions.player !== undefined) {
1742
+ composer = {
1743
+ playbookModule: `./${opts.basename}.playbook.js`,
1744
+ ...substitutions,
1745
+ };
1746
+ }
1747
+ }
1748
+ catch (error) {
1749
+ diagnostics.push(`prompt contract: linked module could not be imported (${messageOf(error)}); composition checks not emitted`);
1750
+ }
1751
+ }
1752
+ const content = generatePromptContractTest({
1753
+ basename: opts.basename,
1754
+ fsmModule: `./${opts.basename}.fsm.js`,
1755
+ verifyModule: opts.verifyModule ?? VERIFY_MODULE,
1756
+ rows,
1757
+ composer,
1758
+ });
1759
+ await mkdir(opts.artifactDir, { recursive: true });
1760
+ const path = join(opts.artifactDir, `${opts.basename}.prompt-contract.test.ts`);
1761
+ await writeFile(path, content);
1762
+ return { path, diagnostics };
1763
+ }
1764
+ /**
1765
+ * Emits the introspection test beside a compiled `playbook` artifact
1766
+ * (VERIFY-4): derives topology pins from the physical `<basename>.fsm.ts`,
1767
+ * emits a NodeNext `.js` import for that sibling source, and writes
1768
+ * `<basename>.fsm.introspect.test.ts` into the artifact directory.
1769
+ *
1770
+ * @throws when the `fsm` artifact cannot be imported or exports no machine.
1771
+ */
1772
+ export async function emitFsmIntrospectionTest(opts) {
1773
+ const fsmPath = join(opts.artifactDir, `${opts.basename}.fsm.ts`);
1774
+ const pins = pinIntrospection(findMachineConfig(await loadFsmModule(fsmPath)));
1775
+ const content = generateFsmIntrospectionTest({
1776
+ basename: opts.basename,
1777
+ fsmModule: `./${opts.basename}.fsm.js`,
1778
+ verifyModule: opts.verifyModule ?? VERIFY_MODULE,
1779
+ pins,
1780
+ });
1781
+ await mkdir(opts.artifactDir, { recursive: true });
1782
+ const path = join(opts.artifactDir, `${opts.basename}.fsm.introspect.test.ts`);
1783
+ await writeFile(path, content);
1784
+ return path;
1785
+ }
1786
+ // Transition-coverage verification (VERIFY-6) lives in its own module — it
1787
+ // depends on `xstate` to drive the machine — and is re-exported here so every
1788
+ // generated test imports one checker module (`@sublang/slc/verify`).
1789
+ export * from './verify-coverage.js';
1790
+ //# sourceMappingURL=verify.js.map