@sublang/slc 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/README.md +4 -4
  2. package/dist/app.d.ts +20 -20
  3. package/dist/app.js +29 -29
  4. package/dist/app.js.map +1 -1
  5. package/dist/artifacts.d.ts +6 -6
  6. package/dist/artifacts.d.ts.map +1 -1
  7. package/dist/artifacts.js +9 -9
  8. package/dist/artifacts.js.map +1 -1
  9. package/dist/cli.js +3 -3
  10. package/dist/cligent-agent.d.ts +1 -1
  11. package/dist/cligent-agent.js +5 -5
  12. package/dist/compiled-executor.d.ts +14 -6
  13. package/dist/compiled-executor.d.ts.map +1 -1
  14. package/dist/compiled-executor.js +133 -31
  15. package/dist/compiled-executor.js.map +1 -1
  16. package/dist/config-file.d.ts +6 -6
  17. package/dist/config-file.js +5 -5
  18. package/dist/config.d.ts +17 -15
  19. package/dist/config.d.ts.map +1 -1
  20. package/dist/config.js +26 -16
  21. package/dist/config.js.map +1 -1
  22. package/dist/emitted-imports.d.ts +14 -0
  23. package/dist/emitted-imports.d.ts.map +1 -1
  24. package/dist/emitted-imports.js +63 -7
  25. package/dist/emitted-imports.js.map +1 -1
  26. package/dist/entry-module.d.ts +5 -3
  27. package/dist/entry-module.d.ts.map +1 -1
  28. package/dist/entry-module.js +41 -13
  29. package/dist/entry-module.js.map +1 -1
  30. package/dist/execution.d.ts +10 -10
  31. package/dist/execution.d.ts.map +1 -1
  32. package/dist/execution.js +13 -13
  33. package/dist/execution.js.map +1 -1
  34. package/dist/hash.js +2 -2
  35. package/dist/host-capabilities.d.ts +115 -0
  36. package/dist/host-capabilities.d.ts.map +1 -0
  37. package/dist/host-capabilities.js +507 -0
  38. package/dist/host-capabilities.js.map +1 -0
  39. package/dist/interpreter.d.ts +2 -2
  40. package/dist/interpreter.d.ts.map +1 -1
  41. package/dist/interpreter.js +7 -7
  42. package/dist/invocation.d.ts +6 -6
  43. package/dist/invocation.d.ts.map +1 -1
  44. package/dist/invocation.js +1 -1
  45. package/dist/link.d.ts +4 -4
  46. package/dist/link.d.ts.map +1 -1
  47. package/dist/link.js +11 -11
  48. package/dist/link.js.map +1 -1
  49. package/dist/phase-runner.d.ts +4 -4
  50. package/dist/phase-runner.js +9 -9
  51. package/dist/phase.d.ts +4 -4
  52. package/dist/phase.d.ts.map +1 -1
  53. package/dist/phase.js +7 -7
  54. package/dist/phase.js.map +1 -1
  55. package/dist/pin-closure.d.ts +35 -7
  56. package/dist/pin-closure.d.ts.map +1 -1
  57. package/dist/pin-closure.js +135 -21
  58. package/dist/pin-closure.js.map +1 -1
  59. package/dist/pin-currency.d.ts +9 -4
  60. package/dist/pin-currency.d.ts.map +1 -1
  61. package/dist/pin-currency.js +57 -20
  62. package/dist/pin-currency.js.map +1 -1
  63. package/dist/pin-generate.d.ts +7 -7
  64. package/dist/pin-generate.d.ts.map +1 -1
  65. package/dist/pin-generate.js +14 -8
  66. package/dist/pin-generate.js.map +1 -1
  67. package/dist/pin-inputs.d.ts +41 -0
  68. package/dist/pin-inputs.d.ts.map +1 -0
  69. package/dist/pin-inputs.js +179 -0
  70. package/dist/pin-inputs.js.map +1 -0
  71. package/dist/pin-paths.d.ts +1 -1
  72. package/dist/pin-paths.js +4 -4
  73. package/dist/pin-paths.js.map +1 -1
  74. package/dist/pins.d.ts +4 -4
  75. package/dist/pins.d.ts.map +1 -1
  76. package/dist/pins.js +5 -5
  77. package/dist/pins.js.map +1 -1
  78. package/dist/pipeline.d.ts +5 -5
  79. package/dist/pipeline.js +10 -10
  80. package/dist/playbook-contract.d.ts +53 -5
  81. package/dist/playbook-contract.d.ts.map +1 -1
  82. package/dist/playbook-contract.js +70 -21
  83. package/dist/playbook-contract.js.map +1 -1
  84. package/dist/playbook-ports.d.ts +9 -9
  85. package/dist/playbook-ports.d.ts.map +1 -1
  86. package/dist/playbook-ports.js +3 -3
  87. package/dist/playbook-ports.js.map +1 -1
  88. package/dist/progress.d.ts +8 -9
  89. package/dist/progress.d.ts.map +1 -1
  90. package/dist/progress.js +3 -3
  91. package/dist/progress.js.map +1 -1
  92. package/dist/resolver.d.ts +9 -9
  93. package/dist/resolver.d.ts.map +1 -1
  94. package/dist/resolver.js +12 -12
  95. package/dist/resolver.js.map +1 -1
  96. package/dist/runner.d.ts +1 -1
  97. package/dist/runner.d.ts.map +1 -1
  98. package/dist/runner.js +195 -80
  99. package/dist/runner.js.map +1 -1
  100. package/dist/verify-coverage.d.ts +4 -3
  101. package/dist/verify-coverage.d.ts.map +1 -1
  102. package/dist/verify-coverage.js +509 -71
  103. package/dist/verify-coverage.js.map +1 -1
  104. package/dist/verify.d.ts +111 -15
  105. package/dist/verify.d.ts.map +1 -1
  106. package/dist/verify.js +656 -59
  107. package/dist/verify.js.map +1 -1
  108. package/package.json +3 -3
package/dist/verify.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
3
  /**
4
4
  * Compilation-correctness verification for a compiled `playbook` artifact
5
- * (IR-007 Task 8; DR-009).
5
+ * (DR-009).
6
6
  *
7
7
  * A compiled artifact is a judgment-produced program, so `slc` re-checks it
8
8
  * against its source. The GEARS↔FSM conformance check verifies that every GEARS
@@ -16,7 +16,7 @@
16
16
  * inputs; {@link generateGearsFsmConformanceTest} emits a per-artifact test that
17
17
  * runs it beside the artifacts. The checker reads the `text2gears` item format
18
18
  * and the `gears2fsm` `invoke.input` contract, not any one artifact, so it holds
19
- * for every compiled `playbook`. See specs/dev/verification.md.
19
+ * for every compiled `playbook`. See specs/packages/verification.md.
20
20
  */
21
21
  import { randomUUID } from 'node:crypto';
22
22
  import { existsSync } from 'node:fs';
@@ -32,6 +32,49 @@ import { hashFile } from './hash.js';
32
32
  */
33
33
  export const NEEDS_BOSS_REPLY = 'needsBossReply';
34
34
  export const BOSS_QUESTION_MARKER = 'Output shall include `question:';
35
+ /** Artifact schema selected only by a complete reviewed Playbook provenance. */
36
+ export function artifactSchemaForPlaybookProvenance(provenance) {
37
+ switch (provenance) {
38
+ case '@sublang/playbook@0.10.0':
39
+ case '@sublang/playbook@1.0.0':
40
+ case '@sublang/playbook@2.0.0':
41
+ case '@sublang/playbook@3.1.0':
42
+ case '@sublang/playbook@4.0.0':
43
+ return 1;
44
+ case '@sublang/playbook@10.0.0':
45
+ return 3;
46
+ default:
47
+ return undefined;
48
+ }
49
+ }
50
+ /**
51
+ * Returns the Playbook package provenance that owns an invocation's concrete
52
+ * link target. The first package manifest above the target owns the file; a
53
+ * parent workspace manifest must not lend its identity to a nested local file.
54
+ */
55
+ export async function playbookProvenanceForLinkTarget(linkTarget) {
56
+ let cursor = dirname(resolve(linkTarget));
57
+ for (;;) {
58
+ const manifestPath = join(cursor, 'package.json');
59
+ if (existsSync(manifestPath)) {
60
+ try {
61
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
62
+ return manifest.name === '@sublang/playbook' &&
63
+ typeof manifest.version === 'string' &&
64
+ manifest.version.length > 0
65
+ ? `${manifest.name}@${manifest.version}`
66
+ : undefined;
67
+ }
68
+ catch {
69
+ return undefined;
70
+ }
71
+ }
72
+ const parent = dirname(cursor);
73
+ if (parent === cursor)
74
+ return undefined;
75
+ cursor = parent;
76
+ }
77
+ }
35
78
  const ITEM_HEADING = /^###\s+([A-Za-z][\w-]*)\s*$/;
36
79
  // The `text2gears` item form names a delegated player as "Captain shall prompt
37
80
  // <Player>" (or a "relay ... to <Player>" variant); English players are
@@ -41,6 +84,7 @@ const ITEM_HEADING = /^###\s+([A-Za-z][\w-]*)\s*$/;
41
84
  const ITEM_PLAYER = /Captain shall (?:prompt|relay\b[^.]*?\bto)\s+(?:`([^`]+)`|"([^"]+)"|“([^”]+)”|([A-Z][\w]*)|([^\p{ASCII}][^\s::,,。;;]*))/u;
42
85
  const ITEM_PLAYBOOK = /Captain shall call playbook\s+(?:`([^`]+)`|"([^"]+)"|“([^”]+)”|([A-Za-z0-9][\w.-]*))\s*:/;
43
86
  const ITEM_DYNAMIC_PLAYBOOK = /Captain shall call playbook selected by\s+`([^`]+)`\s*:/;
87
+ const PARALLEL_GROUP = /^Parallel group:\s*(\S(?:.*\S)?)\s*$/;
44
88
  const DYNAMIC_TEXT = /^<([A-Za-z_$][A-Za-z0-9_$]*)>$/;
45
89
  // An optimizer-introduced script item runs a shell command without any agent
46
90
  // (text2gears.md "Script behaviors"; DR-013). The clause is fixed machine
@@ -112,6 +156,9 @@ export function parseGearsItems(gears) {
112
156
  ...(dynamicText === null ? {} : { textContext: dynamicText[1] }),
113
157
  }
114
158
  : {}),
159
+ ...(current.parallelGroup !== ''
160
+ ? { parallelGroup: current.parallelGroup }
161
+ : {}),
115
162
  ...(current.resultDeclared
116
163
  ? { result: Object.fromEntries(current.results) }
117
164
  : {}),
@@ -133,6 +180,7 @@ export function parseGearsItems(gears) {
133
180
  script: false,
134
181
  playbookId: '',
135
182
  playbookIdContext: '',
183
+ parallelGroup: '',
136
184
  prompt: [],
137
185
  resultsEligible: false,
138
186
  resultDeclared: false,
@@ -195,6 +243,10 @@ export function parseGearsItems(gears) {
195
243
  }
196
244
  if (line.trim() !== '')
197
245
  current.resultsEligible = false;
246
+ const parallelGroup = PARALLEL_GROUP.exec(line.trim());
247
+ if (parallelGroup !== null && current.parallelGroup === '') {
248
+ current.parallelGroup = parallelGroup[1];
249
+ }
198
250
  const player = ITEM_PLAYER.exec(line);
199
251
  if (player !== null && current.player === '') {
200
252
  current.player =
@@ -221,6 +273,152 @@ export function parseGearsItems(gears) {
221
273
  flush();
222
274
  return items;
223
275
  }
276
+ const ROLE_DECLARATION = /^(?:#{1,6}\s+(Roles|Players)|(Roles|Players):)\s*$/;
277
+ /** Canonical lowercase local-role id used by schema-3 artifacts. */
278
+ export function canonicalRoleId(name) {
279
+ return name.toLowerCase();
280
+ }
281
+ function declarationName(value) {
282
+ const match = /^[`"“]?([^`"”]+?)[`"”]?\s*$/.exec(value.trim());
283
+ return match?.[1].trim() || undefined;
284
+ }
285
+ /** Parses Roles/Players plus source-derived concurrent role sets without host bindings. */
286
+ export function inspectGearsRoleContract(gears) {
287
+ const findings = [];
288
+ const declarations = [];
289
+ let active;
290
+ for (const line of gears.split('\n')) {
291
+ const heading = ROLE_DECLARATION.exec(line.trim());
292
+ if (heading !== null) {
293
+ active = {
294
+ kind: (heading[1] ?? heading[2]),
295
+ names: [],
296
+ };
297
+ declarations.push(active);
298
+ continue;
299
+ }
300
+ if (active === undefined)
301
+ continue;
302
+ if (line.trim() === '')
303
+ continue;
304
+ const bullet = /^-\s+(.*)$/.exec(line.trim());
305
+ if (bullet === null) {
306
+ active = undefined;
307
+ continue;
308
+ }
309
+ const declaration = bullet[1].trim();
310
+ if (active.kind === 'Roles' && /[=|]/.test(declaration)) {
311
+ findings.push(`Roles declaration ${JSON.stringify(declaration)} uses removed alias syntax`);
312
+ continue;
313
+ }
314
+ // Historical schema-1 Players may declare a composite launcher choice
315
+ // (`Committer = Coder | Reviewer`). It is not one concrete player binding
316
+ // and therefore does not enter the source-order player list, including
317
+ // when every name is backtick- or quote-delimited.
318
+ if (active.kind === 'Players' && declaration.includes('='))
319
+ continue;
320
+ const name = declarationName(declaration);
321
+ if (name === undefined) {
322
+ findings.push(`malformed ${active.kind} declaration ${JSON.stringify(declaration)}`);
323
+ continue;
324
+ }
325
+ active.names.push(name);
326
+ }
327
+ const kinds = new Set(declarations.map(({ kind }) => kind));
328
+ if (declarations.length > 1) {
329
+ findings.push('GEARS declares more than one Roles/Players section');
330
+ }
331
+ if (kinds.size > 1) {
332
+ findings.push('GEARS mixes Roles and Players declarations');
333
+ }
334
+ const selected = declarations[0];
335
+ const generation = selected?.kind === 'Roles'
336
+ ? 'schema-3'
337
+ : selected?.kind === 'Players'
338
+ ? 'schema-1'
339
+ : 'unspecified';
340
+ const names = selected?.names ?? [];
341
+ const roleIds = names.map(canonicalRoleId);
342
+ if (generation === 'schema-3') {
343
+ const byCanonical = new Map();
344
+ for (let index = 0; index < names.length; index += 1) {
345
+ const name = names[index];
346
+ const roleId = roleIds[index];
347
+ const existing = byCanonical.get(roleId);
348
+ if (existing !== undefined) {
349
+ findings.push(existing === name
350
+ ? `Roles declaration repeats ${JSON.stringify(name)}`
351
+ : `Roles declarations ${JSON.stringify(existing)} and ${JSON.stringify(name)} collide as canonical role ${JSON.stringify(roleId)}`);
352
+ }
353
+ else {
354
+ byCanonical.set(roleId, name);
355
+ }
356
+ // A canonical local role id is the declared name lowercased. Playbook 10
357
+ // explicitly admits non-English role names ("quote non-English names
358
+ // (e.g., `作者`)"), and a script without case - Chinese among them -
359
+ // lowercases to itself, so an ASCII-only class would reject exactly the
360
+ // names the definition sanctions. Require instead that the id be genuinely
361
+ // canonical: already lowercase, carrying no whitespace or separator that
362
+ // would make it ambiguous as an identifier.
363
+ if (roleId.length === 0 ||
364
+ roleId !== roleId.toLowerCase() ||
365
+ /[\s.,;:/\\'"`()[\]{}<>|]/u.test(roleId)) {
366
+ findings.push(`Roles declaration ${JSON.stringify(name)} derives noncanonical local role ${JSON.stringify(roleId)}`);
367
+ }
368
+ else if (roleId === 'captain') {
369
+ findings.push('Roles declaration uses reserved local role "captain"');
370
+ }
371
+ }
372
+ }
373
+ const groups = new Map();
374
+ if (generation === 'schema-3') {
375
+ const declared = new Set(roleIds);
376
+ for (const item of parseGearsItems(gears)) {
377
+ if (item.actor === 'player') {
378
+ const roleId = canonicalRoleId(item.player);
379
+ if (!declared.has(roleId)) {
380
+ findings.push(`GEARS item ${item.id} delegates to undeclared role ${JSON.stringify(item.player)}`);
381
+ }
382
+ }
383
+ if (item.parallelGroup === undefined)
384
+ continue;
385
+ if (item.actor !== 'player') {
386
+ findings.push(`parallel group ${JSON.stringify(item.parallelGroup)} contains non-role item ${item.id}`);
387
+ continue;
388
+ }
389
+ const roleId = canonicalRoleId(item.player);
390
+ const members = groups.get(item.parallelGroup) ?? [];
391
+ if (members.includes(roleId)) {
392
+ findings.push(`parallel group ${JSON.stringify(item.parallelGroup)} repeats canonical role ${JSON.stringify(roleId)}`);
393
+ }
394
+ members.push(roleId);
395
+ groups.set(item.parallelGroup, members);
396
+ }
397
+ const groupByMembers = new Map();
398
+ for (const [group, members] of groups) {
399
+ if (members.length < 2) {
400
+ findings.push(`parallel group ${JSON.stringify(group)} contains fewer than two roles`);
401
+ }
402
+ // A concurrent role set is unordered for duplicate detection even
403
+ // though its source member order remains significant in the FSM export.
404
+ const signature = JSON.stringify([...members].sort());
405
+ const existing = groupByMembers.get(signature);
406
+ if (existing !== undefined) {
407
+ findings.push(`parallel groups ${JSON.stringify(existing)} and ${JSON.stringify(group)} duplicate concurrent role set ${signature}`);
408
+ }
409
+ else {
410
+ groupByMembers.set(signature, group);
411
+ }
412
+ }
413
+ }
414
+ return {
415
+ generation,
416
+ names,
417
+ roleIds,
418
+ concurrentRoleSets: [...groups.values()],
419
+ findings,
420
+ };
421
+ }
224
422
  /** Walks every state node depth-first in declaration order. */
225
423
  function walkStateNodes(config) {
226
424
  const out = [];
@@ -277,6 +475,14 @@ function metadataStateId(state) {
277
475
  const stateId = playbook.stateId;
278
476
  return isNonEmptyString(stateId) ? stateId : undefined;
279
477
  }
478
+ function metadataRole(state) {
479
+ if (typeof state.meta !== 'object' || state.meta === null)
480
+ return undefined;
481
+ const playbook = state.meta.playbook;
482
+ if (typeof playbook !== 'object' || playbook === null)
483
+ return undefined;
484
+ return playbook.role;
485
+ }
280
486
  function stateIdConsistencyFindings(node, inputStateId) {
281
487
  if (!isNonEmptyString(inputStateId))
282
488
  return [];
@@ -359,11 +565,11 @@ function enumerateCaptainBindings(config) {
359
565
  (invoke.src !== undefined || !isNonEmptyString(fields.sourceItem))) {
360
566
  continue;
361
567
  }
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')
568
+ // Published schema-1 artifacts carried a concrete player field. Schema 3
569
+ // carries only its canonical local role and repeats it in public metadata.
570
+ const actor = source === 'player' ||
571
+ Object.hasOwn(fields, 'player') ||
572
+ Object.hasOwn(fields, 'role')
367
573
  ? 'player'
368
574
  : 'captain';
369
575
  const pinActor = source === 'player' || (source === 'captain' && actor === 'captain');
@@ -371,8 +577,37 @@ function enumerateCaptainBindings(config) {
371
577
  if (!isNonEmptyString(fields.sourceItem)) {
372
578
  bindingFindings.push('invoke.input.sourceItem is not a non-empty string');
373
579
  }
374
- if (actor === 'player' && typeof fields.player !== 'string') {
375
- bindingFindings.push('invoke.input.player is not a string');
580
+ if (actor === 'player') {
581
+ const hasPlayer = Object.hasOwn(fields, 'player');
582
+ const hasRole = Object.hasOwn(fields, 'role');
583
+ if (hasPlayer && hasRole) {
584
+ bindingFindings.push('invoke.input carries both historical player and schema-3 role');
585
+ }
586
+ if (hasRole && typeof fields.role !== 'string') {
587
+ bindingFindings.push('invoke.input.role is not a string');
588
+ }
589
+ if (hasRole && source !== 'player') {
590
+ bindingFindings.push('schema-3 delegated work does not invoke the player actor');
591
+ }
592
+ if (!hasRole && typeof fields.player !== 'string') {
593
+ bindingFindings.push('invoke.input.player is not a string');
594
+ }
595
+ const publicRole = metadataRole(node.state);
596
+ if (hasRole) {
597
+ if (typeof publicRole !== 'string') {
598
+ bindingFindings.push('state.meta.playbook.role is not a string for schema-3 delegated work');
599
+ }
600
+ else if (publicRole !== fields.role) {
601
+ bindingFindings.push(`state.meta.playbook.role ${JSON.stringify(publicRole)} does not match invoke.input.role ${JSON.stringify(fields.role)}`);
602
+ }
603
+ }
604
+ else if (publicRole !== undefined) {
605
+ bindingFindings.push('historical delegated-player state unexpectedly declares state.meta.playbook.role');
606
+ }
607
+ }
608
+ else if (Object.hasOwn(fields, 'role') ||
609
+ metadataRole(node.state) !== undefined) {
610
+ bindingFindings.push('direct-Captain state unexpectedly declares a role binding');
376
611
  }
377
612
  if (typeof fields.prompt !== 'string') {
378
613
  bindingFindings.push('invoke.input.prompt is not a string');
@@ -395,6 +630,7 @@ function enumerateCaptainBindings(config) {
395
630
  : '',
396
631
  actor,
397
632
  player: typeof fields.player === 'string' ? fields.player : '',
633
+ ...(typeof fields.role === 'string' ? { role: fields.role } : {}),
398
634
  prompt: typeof fields.prompt === 'string' ? fields.prompt : '',
399
635
  result: resultMap(fields.result),
400
636
  ...nestedStatePath(node),
@@ -655,24 +891,115 @@ function statePlaybookSignature(state) {
655
891
  state.textContext ?? null,
656
892
  ]);
657
893
  }
894
+ /** Exact action guards of Playbook 10's controller decision result. */
895
+ export const CONTROLLER_ACTION_GUARDS = [
896
+ 'respond',
897
+ 'resume',
898
+ 'start',
899
+ 'switch',
900
+ 'dismiss',
901
+ 'deliver',
902
+ 'runtime',
903
+ ];
904
+ /** Whether a result map has the exact Playbook 10 controller domain union. */
905
+ export function isControllerDecisionResult(result) {
906
+ if (!isStringMap(result))
907
+ return false;
908
+ const keys = Object.keys(result).filter((key) => key !== NEEDS_BOSS_REPLY);
909
+ return (keys.length === CONTROLLER_ACTION_GUARDS.length &&
910
+ CONTROLLER_ACTION_GUARDS.every((guard) => Object.hasOwn(result, guard)));
911
+ }
912
+ /** A single missing or extra key against Playbook 10's controller domain. */
913
+ export function controllerDecisionNearMiss(result) {
914
+ if (!isStringMap(result))
915
+ return undefined;
916
+ const actual = Object.keys(result).filter((key) => key !== NEEDS_BOSS_REPLY);
917
+ const expected = new Set(CONTROLLER_ACTION_GUARDS);
918
+ const present = new Set(actual);
919
+ const missing = CONTROLLER_ACTION_GUARDS.filter((key) => !present.has(key));
920
+ const extra = actual.filter((key) => !expected.has(key));
921
+ return missing.length + extra.length === 1 ? { missing, extra } : undefined;
922
+ }
923
+ /** Whether a machine contains Playbook 10's grounded controller decision state. */
924
+ export function isControllerMachine(config) {
925
+ return enumerateCaptainBindings(config).some(({ state, pinActor }) => {
926
+ if (!pinActor || state.actor !== 'captain')
927
+ return false;
928
+ if (state.bindingFindings?.includes('invoke.input.result is not a string-valued object')) {
929
+ return false;
930
+ }
931
+ return isControllerDecisionResult(state.result);
932
+ });
933
+ }
934
+ /** Whether an explicit direct-Captain result is one key from the controller domain. */
935
+ export function hasControllerDecisionNearMiss(config) {
936
+ return enumerateCaptainBindings(config).some(({ state, pinActor }) => pinActor &&
937
+ state.actor === 'captain' &&
938
+ state.result[NEEDS_BOSS_REPLY] === undefined &&
939
+ controllerDecisionNearMiss(state.result) !== undefined);
940
+ }
941
+ function concurrentRoleSets(value) {
942
+ if (!Array.isArray(value))
943
+ return undefined;
944
+ const sets = [];
945
+ for (const candidate of value) {
946
+ if (!Array.isArray(candidate) ||
947
+ candidate.some((role) => typeof role !== 'string')) {
948
+ return undefined;
949
+ }
950
+ sets.push([...candidate]);
951
+ }
952
+ return sets;
953
+ }
658
954
  /**
659
955
  * Checks GEARS↔FSM conformance and returns human-readable findings (empty when
660
956
  * conformant): every GEARS item maps to one state with the same player and the
661
957
  * prompt verbatim, every captain state references a known item, and every
662
958
  * captain state's `result` map declares the Boss-reply suspension key with its
663
- * adjudicator contract (VERIFY-1, VERIFY-3; DR-009).
959
+ * adjudicator contract (verification-1, verification-3; DR-009).
664
960
  */
665
- export function checkGearsFsmConformance(gears, config) {
961
+ export function checkGearsFsmConformance(gears, config, options = {}) {
666
962
  const items = parseGearsItems(gears);
963
+ const roleContract = inspectGearsRoleContract(gears);
964
+ const controller = isControllerMachine(config);
667
965
  const captainBindings = enumerateCaptainBindings(config);
668
966
  const states = captainBindings.map(({ state }) => state);
967
+ const controllerNearMisses = captainBindings.flatMap(({ state, pinActor }) => {
968
+ if (!pinActor ||
969
+ state.actor !== 'captain' ||
970
+ state.result[NEEDS_BOSS_REPLY] !== undefined) {
971
+ return [];
972
+ }
973
+ const nearMiss = controllerDecisionNearMiss(state.result);
974
+ return nearMiss === undefined ? [] : [{ state, nearMiss }];
975
+ });
976
+ const controllerNearMissStates = new Set(controllerNearMisses.map(({ state }) => state));
669
977
  const explicitActorStates = new Set(captainBindings
670
978
  .filter(({ pinActor }) => pinActor)
671
979
  .map(({ state }) => state));
672
980
  const playbookStates = enumeratePlaybookStates(config);
673
981
  const scriptStates = enumerateScriptStates(config);
674
982
  const findings = [];
983
+ for (const { state, nearMiss } of controllerNearMisses) {
984
+ const detail = nearMiss.missing.length > 0
985
+ ? `missing ${JSON.stringify(nearMiss.missing[0])}`
986
+ : `extra ${JSON.stringify(nearMiss.extra[0])}`;
987
+ findings.push(`FSM state ${state.stateId}: controller decision contract near-miss (${detail}); the controller domain requires exactly ${CONTROLLER_ACTION_GUARDS.join(', ')}`);
988
+ }
675
989
  findings.push(...structuredStateIdentityFindings(walkStateNodes(config)));
990
+ findings.push(...roleContract.findings);
991
+ const requiresConcurrentRoleSets = roleContract.generation === 'schema-3' ||
992
+ controller ||
993
+ options.artifactSchema === 3;
994
+ if (requiresConcurrentRoleSets) {
995
+ const actual = concurrentRoleSets(options.concurrentRoleSets);
996
+ if (actual === undefined) {
997
+ findings.push('schema-3 FSM exports no valid concurrentRoleSets array');
998
+ }
999
+ else if (JSON.stringify(actual) !== JSON.stringify(roleContract.concurrentRoleSets)) {
1000
+ findings.push(`schema-3 FSM concurrentRoleSets ${JSON.stringify(actual)} do not match GEARS groups ${JSON.stringify(roleContract.concurrentRoleSets)}`);
1001
+ }
1002
+ }
676
1003
  for (const item of items) {
677
1004
  findings.push(...(item.resultFindings ?? []).map((finding) => `GEARS item ${item.id}: ${finding}`));
678
1005
  }
@@ -840,8 +1167,24 @@ export function checkGearsFsmConformance(gears, config) {
840
1167
  state.actor !== item.actor) {
841
1168
  findings.push(`${item.id}: FSM actor "${state.actor}" is not GEARS actor "${item.actor}"`);
842
1169
  }
843
- if (item.actor === 'player' && state.player !== item.player) {
844
- findings.push(`${item.id}: FSM player "${state.player}" is not GEARS player "${item.player}"`);
1170
+ if (item.actor === 'player') {
1171
+ if (roleContract.generation === 'schema-3') {
1172
+ const expectedRole = canonicalRoleId(item.player);
1173
+ if (state.role !== expectedRole) {
1174
+ findings.push(`${item.id}: FSM role ${JSON.stringify(state.role ?? '')} is not GEARS canonical role ${JSON.stringify(expectedRole)}`);
1175
+ }
1176
+ if (state.player !== '') {
1177
+ findings.push(`${item.id}: schema-3 delegated role carries removed invoke.input.player ${JSON.stringify(state.player)}`);
1178
+ }
1179
+ }
1180
+ else {
1181
+ if (state.player !== item.player) {
1182
+ findings.push(`${item.id}: FSM player "${state.player}" is not GEARS player "${item.player}"`);
1183
+ }
1184
+ if (state.role !== undefined) {
1185
+ findings.push(`${item.id}: historical delegated player unexpectedly carries invoke.input.role ${JSON.stringify(state.role)}`);
1186
+ }
1187
+ }
845
1188
  }
846
1189
  if (state.prompt !== item.prompt) {
847
1190
  findings.push(`${item.id}: FSM prompt is not the GEARS prompt verbatim`);
@@ -871,11 +1214,19 @@ export function checkGearsFsmConformance(gears, config) {
871
1214
  if (state.sourceItem !== '' && !itemIds.has(state.sourceItem)) {
872
1215
  findings.push(`FSM state ${state.stateId} references unknown GEARS item ${state.sourceItem}`);
873
1216
  }
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
1217
  const bossReply = state.result[NEEDS_BOSS_REPLY];
878
- if (bossReply === undefined) {
1218
+ if (controller) {
1219
+ if (bossReply !== undefined) {
1220
+ findings.push(`FSM controller state ${state.stateId} unexpectedly declares ${NEEDS_BOSS_REPLY}`);
1221
+ }
1222
+ }
1223
+ else if (controllerNearMissStates.has(state)) {
1224
+ // The precise controller-domain diagnostic above owns a malformed
1225
+ // near-controller state; do not suggest adding the ordinary wait key to
1226
+ // that state, while retaining ordinary findings for every other state.
1227
+ continue;
1228
+ }
1229
+ else if (bossReply === undefined) {
879
1230
  findings.push(`FSM state ${state.stateId} declares no ${NEEDS_BOSS_REPLY} result`);
880
1231
  }
881
1232
  else if (!bossReply.includes(BOSS_QUESTION_MARKER)) {
@@ -898,7 +1249,7 @@ export function checkGearsFsmConformance(gears, config) {
898
1249
  return findings;
899
1250
  }
900
1251
  /*
901
- * Machine introspection (VERIFY-4).
1252
+ * Machine introspection (verification-4).
902
1253
  *
903
1254
  * `pinIntrospection` reduces a machine config to its structural facts — the
904
1255
  * captain-state bindings, every transition arm, the root and quiescent event
@@ -963,7 +1314,7 @@ function invokeSource(src) {
963
1314
  }
964
1315
  /**
965
1316
  * Reduces a machine config to the structural facts the emitted introspection
966
- * test pins (VERIFY-4): captain bindings with result keys and every transition
1317
+ * test pins (verification-4): captain bindings with result keys and every transition
967
1318
  * arm, the quiescent states' event surfaces, the root event surface, and the
968
1319
  * `BOSS_INTERRUPT` jumpable set.
969
1320
  */
@@ -990,6 +1341,7 @@ export function pinIntrospection(config) {
990
1341
  : {}),
991
1342
  sourceItem: binding.state.sourceItem,
992
1343
  player: binding.state.player,
1344
+ ...(binding.state.role !== undefined ? { role: binding.state.role } : {}),
993
1345
  resultKeys: Object.keys(binding.state.result).sort(),
994
1346
  onDone: normalizeArms(binding.invoke.onDone),
995
1347
  onError: normalizeArms(binding.invoke.onError),
@@ -1061,7 +1413,7 @@ export function pinIntrospection(config) {
1061
1413
  };
1062
1414
  }
1063
1415
  /*
1064
- * Prompt-contract capture and composition checks (VERIFY-5).
1416
+ * Prompt-contract capture and composition checks (verification-5).
1065
1417
  *
1066
1418
  * The contract is derived from the artifacts, never hand-authored: context
1067
1419
  * reads are traced through each state's `invoke.input` thunk with a recording
@@ -1079,6 +1431,7 @@ export const BOSS_REPLY_LABEL = 'Boss reply:';
1079
1431
  // labelled form as well as natural-language variants; occurrence deltas below
1080
1432
  // keep self-hosting prompt bodies free to quote either marker verbatim.
1081
1433
  const PLAYER_BINDING_MARKER = /\bplayer\s+binding\b|(?:^|\n)[ \t]*player[ \t]*:[ \t]*(?=\S)/gi;
1434
+ const ROLE_BINDING_MARKER = /\brole\s+binding\b|(?:^|\n)[ \t]*role[ \t]*:[ \t]*(?=\S)/gi;
1082
1435
  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
1436
  const PLACEHOLDER = /<[^\s<>`]{1,60}>/g;
1084
1437
  /** Lists the distinct `<...>` placeholder tokens in a prompt body, in order. */
@@ -1139,7 +1492,7 @@ function carriesSentinel(value, sentinel) {
1139
1492
  }
1140
1493
  /**
1141
1494
  * Derives every captain state's prompt contract from the machine config
1142
- * (VERIFY-5): traced context reads, sentinel-traced input wiring, and the
1495
+ * (verification-5): traced context reads, sentinel-traced input wiring, and the
1143
1496
  * prompt body's placeholder tokens.
1144
1497
  */
1145
1498
  export function capturePromptContract(config) {
@@ -1167,6 +1520,7 @@ export function capturePromptContract(config) {
1167
1520
  state: state.stateId,
1168
1521
  sourceItem: state.sourceItem,
1169
1522
  player: state.player,
1523
+ ...(state.role !== undefined ? { role: state.role } : {}),
1170
1524
  reads,
1171
1525
  wires,
1172
1526
  placeholders: placeholdersIn(state.prompt),
@@ -1178,7 +1532,7 @@ export function capturePromptContract(config) {
1178
1532
  * Derives, per captain state, which of its prompt's placeholder tokens the
1179
1533
  * linked composer substitutes when the wired context is present — pinned into
1180
1534
  * the emitted test so a token that later leaks unsubstituted fails it
1181
- * (VERIFY-5).
1535
+ * (verification-5).
1182
1536
  */
1183
1537
  export function deriveSubstitutions(config, compose, actor) {
1184
1538
  const out = {};
@@ -1190,7 +1544,7 @@ export function deriveSubstitutions(config, compose, actor) {
1190
1544
  continue;
1191
1545
  try {
1192
1546
  const reads = probeContextReads(inputFn);
1193
- const composed = compose(inputFn({ context: ordinaryContext(reads) }));
1547
+ const composed = composeForState(compose, state, inputFn({ context: ordinaryContext(reads) }));
1194
1548
  if (typeof composed !== 'string') {
1195
1549
  out[state.stateId] = [];
1196
1550
  continue;
@@ -1201,8 +1555,9 @@ export function deriveSubstitutions(config, compose, actor) {
1201
1555
  // hide valid evidence, while merely deleting a token still cannot
1202
1556
  // masquerade as substitution.
1203
1557
  const evidenced = new Set();
1558
+ const promptReads = promptSentinelFields(state, reads);
1204
1559
  for (const line of state.prompt.split('\n')) {
1205
- for (const token of matchPromptBody(line, composed, reads)
1560
+ for (const token of matchPromptBody(line, composed, promptReads)
1206
1561
  ?.substitutions ?? []) {
1207
1562
  evidenced.add(token);
1208
1563
  }
@@ -1217,7 +1572,7 @@ export function deriveSubstitutions(config, compose, actor) {
1217
1572
  }
1218
1573
  /**
1219
1574
  * Checks the linked composer against the link contract for every captain state
1220
- * (VERIFY-5), returning findings (empty when conformant): the prompt body is
1575
+ * (verification-5), returning findings (empty when conformant): the prompt body is
1221
1576
  * preserved modulo substituted placeholders, the adjudicator-facing Boss-reply
1222
1577
  * contract never leaks into a player prompt, no continuation appears on an
1223
1578
  * ordinary turn, and a Boss-reply continuation turn opens with the exact
@@ -1225,19 +1580,30 @@ export function deriveSubstitutions(config, compose, actor) {
1225
1580
  */
1226
1581
  export function checkPromptComposition(opts) {
1227
1582
  const findings = [];
1583
+ const controller = isControllerMachine(opts.config);
1584
+ const schemaResolution = resolveArtifactSchemaForVerification({
1585
+ config: opts.config,
1586
+ ...(opts.artifactSchema === undefined
1587
+ ? {}
1588
+ : { artifactSchema: opts.artifactSchema }),
1589
+ });
1590
+ findings.push(...schemaResolution.findings);
1591
+ const inferredArtifactSchema = schemaResolution.artifactSchema;
1228
1592
  const substitutions = deriveSubstitutions(opts.config, opts.compose, opts.actor);
1229
1593
  const composerName = opts.actor === 'captain' ? 'composeCaptainPrompt' : 'composePlayerPrompt';
1230
- for (const binding of enumerateCaptainBindings(opts.config)) {
1594
+ const bindings = enumerateCaptainBindings(opts.config);
1595
+ for (const binding of bindings) {
1231
1596
  const { state, inputFn } = binding;
1232
1597
  if (opts.actor !== undefined && state.actor !== opts.actor)
1233
1598
  continue;
1234
1599
  if (typeof inputFn !== 'function')
1235
1600
  continue;
1236
1601
  const reads = probeContextReads(inputFn);
1602
+ const promptReads = promptSentinelFields(state, reads);
1237
1603
  const substituted = substitutions[state.stateId] ?? [];
1238
1604
  let ordinary;
1239
1605
  try {
1240
- ordinary = opts.compose(inputFn({ context: ordinaryContext(reads) }));
1606
+ ordinary = composeForState(opts.compose, state, inputFn({ context: ordinaryContext(reads) }));
1241
1607
  if (typeof ordinary !== 'string') {
1242
1608
  throw new Error(`${composerName} returned a non-string value`);
1243
1609
  }
@@ -1246,8 +1612,8 @@ export function checkPromptComposition(opts) {
1246
1612
  findings.push(`${state.stateId}: ${composerName} threw on an ordinary turn: ${messageOf(error)}`);
1247
1613
  continue;
1248
1614
  }
1249
- findings.push(...bodyFindings(state, ordinary, substituted, reads, 'ordinary'));
1250
- pushUnique(findings, ...directCaptainControlFindings(state, ordinary));
1615
+ findings.push(...bodyFindings(state, ordinary, substituted, promptReads, 'ordinary'));
1616
+ pushUnique(findings, ...promptControlFindings(state, ordinary));
1251
1617
  // A self-hosted playbook's domain body may legitimately quote the
1252
1618
  // adjudicator contract or the continuation texts (it instructs a compiler
1253
1619
  // about them); only occurrences the composer ADDS beyond the body's own
@@ -1259,15 +1625,34 @@ export function checkPromptComposition(opts) {
1259
1625
  if ([CONTINUATION_PREAMBLE, BOSS_QUESTION_LABEL, BOSS_REPLY_LABEL].some((needle) => occurrences(ordinary, needle) > occurrences(state.prompt, needle))) {
1260
1626
  findings.push(`${state.stateId}: continuation blocks appear on an ordinary turn`);
1261
1627
  }
1628
+ // Controllers own no Boss-reply wait. A missing ordinary result is already
1629
+ // diagnosed by conformance, so do not fabricate a continuation contract.
1630
+ if (controller || !Object.hasOwn(state.result, NEEDS_BOSS_REPLY))
1631
+ continue;
1632
+ const artifactSchema = schemaResolution.findings.length > 0
1633
+ ? undefined
1634
+ : (inferredArtifactSchema ??
1635
+ (state.role !== undefined ? 3 : state.player !== '' ? 1 : undefined));
1636
+ if (artifactSchema === undefined) {
1637
+ findings.push(`${state.stateId}: prompt composition requires artifactSchema 1 or 3 to probe this direct-Captain continuation`);
1638
+ continue;
1639
+ }
1262
1640
  // A Boss-reply continuation turn: the thunk carries the pending question
1263
1641
  // and reply, and the composer opens with the exact preamble and labelled
1264
1642
  // Q&A blocks before the domain body (gears2fsm.md, link.md).
1265
1643
  const question = sentinelFor('question');
1266
1644
  const reply = sentinelFor('bossReply');
1267
1645
  const pendingBossQuestion = {
1646
+ ...(artifactSchema === 3
1647
+ ? state.actor === 'captain'
1648
+ ? { asker: { kind: 'captain' } }
1649
+ : { asker: { kind: 'role', roleId: state.role ?? '' } }
1650
+ : {
1651
+ player: state.actor === 'captain' ? 'Captain' : state.player,
1652
+ }),
1653
+ questionId: state.stateId,
1268
1654
  resumeStateId: state.stateId,
1269
1655
  sourceItem: state.sourceItem,
1270
- player: state.player,
1271
1656
  question,
1272
1657
  };
1273
1658
  let continuation;
@@ -1284,7 +1669,7 @@ export function checkPromptComposition(opts) {
1284
1669
  bossReplies: { [state.stateId]: reply },
1285
1670
  },
1286
1671
  });
1287
- continuation = opts.compose(input);
1672
+ continuation = composeForState(opts.compose, state, input);
1288
1673
  if (typeof continuation !== 'string') {
1289
1674
  throw new Error(`${composerName} returned a non-string value`);
1290
1675
  }
@@ -1300,7 +1685,7 @@ export function checkPromptComposition(opts) {
1300
1685
  if (!continuation.startsWith(`${CONTINUATION_PREAMBLE}\n\n`)) {
1301
1686
  findings.push(`${state.stateId}: a continuation turn does not open with the exact preamble`);
1302
1687
  }
1303
- const bodyStart = bodyIndex(state, continuation, substituted, reads);
1688
+ const bodyStart = bodyIndex(state, continuation, substituted, promptReads);
1304
1689
  const questionBlock = `${BOSS_QUESTION_LABEL}\n${question}`;
1305
1690
  const replyBlock = `${BOSS_REPLY_LABEL}\n${reply}`;
1306
1691
  for (const [label, value] of [
@@ -1322,21 +1707,59 @@ export function checkPromptComposition(opts) {
1322
1707
  if (!continuation.startsWith(exactContinuationPrefix)) {
1323
1708
  findings.push(`${state.stateId}: a continuation turn does not preserve the exact ordered Boss question/reply blocks`);
1324
1709
  }
1325
- findings.push(...bodyFindings(state, continuation, substituted, reads, 'continuation'));
1326
- pushUnique(findings, ...directCaptainControlFindings(state, continuation));
1710
+ findings.push(...bodyFindings(state, continuation, substituted, promptReads, 'continuation'));
1711
+ pushUnique(findings, ...promptControlFindings(state, continuation));
1327
1712
  }
1328
1713
  return findings;
1329
1714
  }
1330
- function directCaptainControlFindings(state, composed) {
1331
- if (state.actor !== 'captain')
1332
- return [];
1715
+ function promptSentinelFields(state, reads) {
1716
+ return state.role === undefined
1717
+ ? [...reads]
1718
+ : [...reads, `promptIdentity:${state.role}`];
1719
+ }
1720
+ function composeForState(compose, state, input) {
1721
+ // Schema-1 composers and the shared default composer use their second
1722
+ // positional argument as a placeholder-field map. A callable proxy with a
1723
+ // property-clean view is therefore both an invocation-scoped schema-3 lookup
1724
+ // and an empty map to historical/default composition, without Function.name,
1725
+ // Function.length, or Function.prototype token collisions.
1726
+ const lookup = (roleId) => {
1727
+ if (state.role === undefined) {
1728
+ throw new Error(`prompt identity lookup used role ${JSON.stringify(roleId)} for a direct-Captain or historical state`);
1729
+ }
1730
+ if (roleId !== state.role) {
1731
+ throw new Error(`prompt identity lookup used role ${JSON.stringify(roleId)} instead of canonical local role ${JSON.stringify(state.role)}`);
1732
+ }
1733
+ return sentinelFor(`promptIdentity:${roleId}`);
1734
+ };
1735
+ const promptIdentity = new Proxy(lookup, {
1736
+ get: () => undefined,
1737
+ has: () => false,
1738
+ ownKeys: () => [],
1739
+ getOwnPropertyDescriptor: () => undefined,
1740
+ });
1741
+ return state.actor === 'player' && state.role !== undefined
1742
+ ? compose(input, promptIdentity)
1743
+ : compose(input);
1744
+ }
1745
+ function promptControlFindings(state, composed) {
1333
1746
  const findings = [];
1334
- if (patternOccurrences(composed, PLAYER_BINDING_MARKER) >
1335
- patternOccurrences(state.prompt, PLAYER_BINDING_MARKER)) {
1747
+ const introducesPlayerBinding = patternOccurrences(composed, PLAYER_BINDING_MARKER) >
1748
+ patternOccurrences(state.prompt, PLAYER_BINDING_MARKER);
1749
+ if (state.actor === 'captain' && introducesPlayerBinding) {
1336
1750
  findings.push(`${state.stateId}: composeCaptainPrompt introduces a player binding into a direct-Captain prompt`);
1337
1751
  }
1338
- if (patternOccurrences(composed, PLAYER_RESUME_MARKER) >
1339
- patternOccurrences(state.prompt, PLAYER_RESUME_MARKER)) {
1752
+ else if (state.role !== undefined && introducesPlayerBinding) {
1753
+ findings.push(`${state.stateId}: composePlayerPrompt exposes a concrete player binding in a schema-3 delegated-role prompt`);
1754
+ }
1755
+ if (state.actor === 'captain' &&
1756
+ patternOccurrences(composed, ROLE_BINDING_MARKER) >
1757
+ patternOccurrences(state.prompt, ROLE_BINDING_MARKER)) {
1758
+ findings.push(`${state.stateId}: composeCaptainPrompt introduces a role binding into a direct-Captain prompt`);
1759
+ }
1760
+ if (state.actor === 'captain' &&
1761
+ patternOccurrences(composed, PLAYER_RESUME_MARKER) >
1762
+ patternOccurrences(state.prompt, PLAYER_RESUME_MARKER)) {
1340
1763
  findings.push(`${state.stateId}: composeCaptainPrompt introduces a player resume instruction into a direct-Captain prompt`);
1341
1764
  }
1342
1765
  return findings;
@@ -1429,7 +1852,7 @@ function matchPromptBody(prompt, composed, reads, expectedSubstitutions) {
1429
1852
  }
1430
1853
  return null;
1431
1854
  }
1432
- /** Findings when a composed prompt does not preserve the domain body (VERIFY-5). */
1855
+ /** Findings when a composed prompt does not preserve the domain body (verification-5). */
1433
1856
  function bodyFindings(state, composed, substituted, reads, turn) {
1434
1857
  if (matchPromptBody(state.prompt, composed, reads, substituted) !== null) {
1435
1858
  return [];
@@ -1454,7 +1877,7 @@ function bodyFindings(state, composed, substituted, reads, turn) {
1454
1877
  function bodyIndex(state, composed, substituted, reads) {
1455
1878
  return (matchPromptBody(state.prompt, composed, reads, substituted)?.index ?? -1);
1456
1879
  }
1457
- // Local copy: this module is copied verbatim beside the artifact (VERIFY-12),
1880
+ // Local copy: this module is copied verbatim beside the artifact (verification-12),
1458
1881
  // so it may not import a sibling module.
1459
1882
  function messageOf(error) {
1460
1883
  return error instanceof Error ? error.message : String(error);
@@ -1492,6 +1915,12 @@ export function findMachineConfig(fsmModule) {
1492
1915
  }
1493
1916
  throw new Error('fsm module exports no XState machine with a `.config.states`');
1494
1917
  }
1918
+ /** Reads the schema-3 cohort declaration from an imported FSM module. */
1919
+ export function findConcurrentRoleSets(fsmModule) {
1920
+ if (typeof fsmModule !== 'object' || fsmModule === null)
1921
+ return undefined;
1922
+ return fsmModule.concurrentRoleSets;
1923
+ }
1495
1924
  /**
1496
1925
  * Builds a per-artifact vitest module that fails when the compiled FSM drifts
1497
1926
  * from its GEARS source: it reads the artifact's `gears` file and the machine its
@@ -1502,22 +1931,35 @@ export function generateGearsFsmConformanceTest(opts) {
1502
1931
  return `// SPDX-License-Identifier: Apache-2.0
1503
1932
  // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
1504
1933
 
1505
- // Generated by slc (IR-007 Task 8): GEARS↔FSM conformance.
1934
+ // Generated by slc (DR-009): GEARS↔FSM conformance.
1506
1935
  import { readFileSync } from 'node:fs';
1507
1936
  import { fileURLToPath } from 'node:url';
1508
1937
 
1509
1938
  import { describe, expect, it } from 'vitest';
1510
1939
 
1511
- import { checkGearsFsmConformance, findMachineConfig } from ${sourceString(opts.verifyModule)};
1940
+ import { checkGearsFsmConformance, findConcurrentRoleSets, findMachineConfig } from ${sourceString(opts.verifyModule)};
1512
1941
  import * as fsm from ${sourceString(opts.fsmModule)};
1513
1942
 
1943
+ const SCHEMA_FINDINGS = ${JSON.stringify(opts.schemaFindings ?? [], null, 2)};
1944
+
1514
1945
  describe(${sourceString(`${opts.basename}: GEARS↔FSM conformance`)}, () => {
1946
+ it('uses consistent artifact-schema evidence', () => {
1947
+ expect(SCHEMA_FINDINGS).toEqual([]);
1948
+ });
1949
+
1515
1950
  it('maps every GEARS item to a state with its player and verbatim prompt', () => {
1516
1951
  const gears = readFileSync(
1517
1952
  fileURLToPath(new URL(${sourceString(opts.gearsFile)}, import.meta.url)),
1518
1953
  'utf8',
1519
1954
  );
1520
- expect(checkGearsFsmConformance(gears, findMachineConfig(fsm))).toEqual([]);
1955
+ expect(
1956
+ checkGearsFsmConformance(gears, findMachineConfig(fsm), {
1957
+ concurrentRoleSets: findConcurrentRoleSets(fsm),
1958
+ ${opts.artifactSchema === undefined
1959
+ ? ''
1960
+ : `artifactSchema: ${opts.artifactSchema},`}
1961
+ }),
1962
+ ).toEqual([]);
1521
1963
  });
1522
1964
  });
1523
1965
  `;
@@ -1526,7 +1968,7 @@ describe(${sourceString(`${opts.basename}: GEARS↔FSM conformance`)}, () => {
1526
1968
  * Emits the GEARS↔FSM conformance test as `slc` output beside a compiled
1527
1969
  * `playbook` artifact: writes `<basename>.gears-fsm.test.ts` into the artifact
1528
1970
  * directory (`<basename>.playbook/`), wiring the artifact's `gears` file and its
1529
- * `fsm` module's machine to the checker, and returns the written path (VERIFY-2;
1971
+ * `fsm` module's machine to the checker, and returns the written path (verification-2;
1530
1972
  * [DR-009](../decisions/009-slc-playbook-pipeline-compilation.md)).
1531
1973
  */
1532
1974
  export async function emitGearsFsmConformanceTest(opts) {
@@ -1537,6 +1979,10 @@ export async function emitGearsFsmConformanceTest(opts) {
1537
1979
  fsmModule: `./${opts.basename}.fsm.js`,
1538
1980
  gearsFile: `./${opts.basename}.gears.md`,
1539
1981
  verifyModule: opts.verifyModule ?? VERIFY_MODULE,
1982
+ ...(opts.artifactSchema === undefined
1983
+ ? {}
1984
+ : { artifactSchema: opts.artifactSchema }),
1985
+ schemaFindings: opts.schemaFindings,
1540
1986
  });
1541
1987
  await mkdir(opts.artifactDir, { recursive: true });
1542
1988
  const path = join(opts.artifactDir, `${opts.basename}.gears-fsm.test.ts`);
@@ -1558,14 +2004,15 @@ export async function loadFsmModule(fsmPath) {
1558
2004
  return import(url.href);
1559
2005
  }
1560
2006
  /**
1561
- * Imports the generated linked TypeScript module before its sibling FSM has
1562
- * been built to JavaScript. NodeNext source correctly names the runtime-safe
1563
- * `./<basename>.fsm.js` edge, but emission-time verification runs while only
1564
- * `./<basename>.fsm.ts` exists. Stage a same-directory copy whose one generated
1565
- * module specifier points at the hashed TypeScript artifact, import that copy,
1566
- * and remove it without changing the linked source or its production import.
2007
+ * Imports generated linked TypeScript for emission-time, standalone, or
2008
+ * equivalence review before its sibling FSM has been built to JavaScript.
2009
+ * NodeNext source correctly names the runtime-safe `./<basename>.fsm.js` edge,
2010
+ * but review may run while only `./<basename>.fsm.ts` exists. Stage a
2011
+ * same-directory copy whose one generated module specifier points at the
2012
+ * hashed TypeScript artifact, import that copy, and remove it without changing
2013
+ * the linked source or its production import.
1567
2014
  */
1568
- async function loadLinkedModuleForVerification(opts) {
2015
+ export async function loadLinkedModuleForVerification(opts) {
1569
2016
  const linkedSource = await readFile(opts.linkedPath, 'utf8');
1570
2017
  const fsmStem = basename(opts.fsmPath, '.ts');
1571
2018
  const runtimeSpecifier = `./${fsmStem}.js`;
@@ -1590,7 +2037,7 @@ async function loadLinkedModuleForVerification(opts) {
1590
2037
  }
1591
2038
  /**
1592
2039
  * Builds a per-artifact vitest module that fails when the machine's structure
1593
- * drifts from the topology pinned at build time (VERIFY-4).
2040
+ * drifts from the topology pinned at build time (verification-4).
1594
2041
  */
1595
2042
  export function generateFsmIntrospectionTest(opts) {
1596
2043
  return `// SPDX-License-Identifier: Apache-2.0
@@ -1615,7 +2062,7 @@ describe(${sourceString(`${opts.basename}: FSM introspection`)}, () => {
1615
2062
  }
1616
2063
  /**
1617
2064
  * Builds a per-artifact vitest module pinning the prompt contract derived from
1618
- * the artifacts at build time (VERIFY-5): the per-state context reads, input
2065
+ * the artifacts at build time (verification-5): the per-state context reads, input
1619
2066
  * wiring, and placeholders always; and, when the linked module exposes its
1620
2067
  * matching Captain/player composers, the composition checks and pinned
1621
2068
  * substitution maps.
@@ -1650,6 +2097,9 @@ const ${compose} = (
1650
2097
  config: findMachineConfig(fsm),
1651
2098
  compose: ${compose},
1652
2099
  actor: '${actor}',
2100
+ ${opts.artifactSchema === undefined
2101
+ ? ''
2102
+ : `artifactSchema: ${opts.artifactSchema},`}
1653
2103
  }),
1654
2104
  ).toEqual([]);
1655
2105
  });
@@ -1684,17 +2134,141 @@ import {
1684
2134
  import * as fsm from ${sourceString(opts.fsmModule)};
1685
2135
  ${composerImports}
1686
2136
  const CONTRACT = ${JSON.stringify(opts.rows, null, 2)};
2137
+ const SCHEMA_FINDINGS = ${JSON.stringify(opts.schemaFindings ?? [], null, 2)};
1687
2138
 
1688
2139
  describe(${sourceString(`${opts.basename}: prompt contract`)}, () => {
2140
+ it('uses consistent artifact-schema evidence', () => {
2141
+ expect(SCHEMA_FINDINGS).toEqual([]);
2142
+ });
2143
+
1689
2144
  it('matches the prompt contract pinned at build time', () => {
1690
2145
  expect(capturePromptContract(findMachineConfig(fsm))).toEqual(CONTRACT);
1691
2146
  });
1692
2147
  ${composerBlock}});
1693
2148
  `;
1694
2149
  }
2150
+ function promptArtifactSchemaSignalsFromConfig(config) {
2151
+ const states = enumerateCaptainStates(config);
2152
+ return {
2153
+ schema1: states.some(({ player }) => player !== ''),
2154
+ schema3: isControllerMachine(config) ||
2155
+ states.some(({ role }) => role !== undefined),
2156
+ };
2157
+ }
2158
+ function linkedArtifactSchemaSignal(linked) {
2159
+ const factory = linked.default;
2160
+ if (typeof factory !== 'function') {
2161
+ return { historicalFallback: false, invalidCompatibility: false };
2162
+ }
2163
+ if (!Object.hasOwn(factory, 'compat')) {
2164
+ return { historicalFallback: true, invalidCompatibility: false };
2165
+ }
2166
+ const descriptor = Object.getOwnPropertyDescriptor(factory, 'compat');
2167
+ if (descriptor === undefined ||
2168
+ !Object.hasOwn(descriptor, 'value') ||
2169
+ descriptor.enumerable !== true ||
2170
+ descriptor.writable !== false ||
2171
+ descriptor.configurable !== false) {
2172
+ return { historicalFallback: false, invalidCompatibility: true };
2173
+ }
2174
+ const compat = descriptor.value;
2175
+ if (typeof compat !== 'object' ||
2176
+ compat === null ||
2177
+ Array.isArray(compat) ||
2178
+ Object.getPrototypeOf(compat) !== Object.prototype ||
2179
+ !Object.isFrozen(compat) ||
2180
+ Object.getOwnPropertySymbols(compat).length !== 0) {
2181
+ return { historicalFallback: false, invalidCompatibility: true };
2182
+ }
2183
+ const names = Object.getOwnPropertyNames(compat);
2184
+ const artifactSchema = Object.getOwnPropertyDescriptor(compat, 'artifactSchema');
2185
+ const runtimeAbi = Object.getOwnPropertyDescriptor(compat, 'runtimeAbi');
2186
+ const exact = names.length === 2 &&
2187
+ names.includes('artifactSchema') &&
2188
+ names.includes('runtimeAbi') &&
2189
+ artifactSchema?.enumerable === true &&
2190
+ Object.hasOwn(artifactSchema, 'value') &&
2191
+ artifactSchema.value === 3 &&
2192
+ runtimeAbi?.enumerable === true &&
2193
+ Object.hasOwn(runtimeAbi, 'value') &&
2194
+ runtimeAbi.value === 1;
2195
+ return exact
2196
+ ? { schema: 3, historicalFallback: false, invalidCompatibility: false }
2197
+ : { historicalFallback: false, invalidCompatibility: true };
2198
+ }
2199
+ /** Schema decision shared by generated and standalone artifact verification. */
2200
+ export function resolveArtifactSchemaForVerification(opts) {
2201
+ const candidates = [];
2202
+ const invalidSignalFindings = [];
2203
+ if (opts.artifactSchema !== undefined) {
2204
+ candidates.push({
2205
+ source: 'review-supplied artifact schema',
2206
+ schema: opts.artifactSchema,
2207
+ });
2208
+ }
2209
+ const provenanceSchema = artifactSchemaForPlaybookProvenance(opts.provenance);
2210
+ if (opts.provenance !== undefined && provenanceSchema === undefined) {
2211
+ invalidSignalFindings.push(`artifact schema has unsupported link-target provenance ${JSON.stringify(opts.provenance)}`);
2212
+ }
2213
+ if (provenanceSchema !== undefined) {
2214
+ candidates.push({
2215
+ source: 'reviewed link-target provenance',
2216
+ schema: provenanceSchema,
2217
+ });
2218
+ }
2219
+ if (opts.config !== undefined) {
2220
+ const configSignals = promptArtifactSchemaSignalsFromConfig(opts.config);
2221
+ if (configSignals.schema1)
2222
+ candidates.push({ source: 'FSM historical-player structure', schema: 1 });
2223
+ if (configSignals.schema3)
2224
+ candidates.push({ source: 'FSM role/controller structure', schema: 3 });
2225
+ }
2226
+ const linkedSignal = opts.linked === undefined
2227
+ ? undefined
2228
+ : linkedArtifactSchemaSignal(opts.linked);
2229
+ if (linkedSignal?.schema !== undefined) {
2230
+ candidates.push({
2231
+ source: 'linked factory compatibility',
2232
+ schema: linkedSignal.schema,
2233
+ });
2234
+ }
2235
+ if (linkedSignal?.invalidCompatibility) {
2236
+ invalidSignalFindings.push('linked factory has an own compatibility declaration that is not exact immutable schema 3/runtime ABI 1');
2237
+ }
2238
+ if (invalidSignalFindings.length > 0) {
2239
+ return { findings: invalidSignalFindings };
2240
+ }
2241
+ const schemas = new Set(candidates.map(({ schema }) => schema));
2242
+ if (schemas.size > 1) {
2243
+ return {
2244
+ findings: [
2245
+ `artifact schema signals disagree (${candidates
2246
+ .map(({ source, schema }) => `${source}: ${schema}`)
2247
+ .join(', ')})`,
2248
+ ],
2249
+ };
2250
+ }
2251
+ const [artifactSchema] = schemas;
2252
+ if (artifactSchema !== undefined) {
2253
+ return { artifactSchema, findings: [] };
2254
+ }
2255
+ if (linkedSignal?.historicalFallback) {
2256
+ return { artifactSchema: 1, findings: [] };
2257
+ }
2258
+ const hasAmbiguousCaptainContinuation = opts.config !== undefined &&
2259
+ enumerateCaptainStates(opts.config).some((state) => state.actor === 'captain' &&
2260
+ Object.hasOwn(state.result, NEEDS_BOSS_REPLY));
2261
+ return hasAmbiguousCaptainContinuation
2262
+ ? {
2263
+ findings: [
2264
+ 'artifact schema has no reviewed provenance, generation-specific actor structure, or callable linked factory for a direct-Captain continuation',
2265
+ ],
2266
+ }
2267
+ : { findings: [] };
2268
+ }
1695
2269
  /**
1696
2270
  * Emits the prompt-contract test beside a compiled `playbook` artifact
1697
- * (VERIFY-5): derives and pins the per-state contract from the physical
2271
+ * (verification-5): derives and pins the per-state contract from the physical
1698
2272
  * `<basename>.fsm.ts` artifact, then emits NodeNext `.js` imports for that FSM
1699
2273
  * and any linked `<basename>.playbook.ts` module. When the linked module
1700
2274
  * exposes the `_internal` composer matching each state actor —
@@ -1711,6 +2285,14 @@ export async function emitPromptContractTest(opts) {
1711
2285
  const fsmPath = join(opts.artifactDir, `${opts.basename}.fsm.ts`);
1712
2286
  const config = findMachineConfig(await loadFsmModule(fsmPath));
1713
2287
  const rows = capturePromptContract(config);
2288
+ let schemaResolution = resolveArtifactSchemaForVerification({
2289
+ config,
2290
+ ...(opts.provenance === undefined ? {} : { provenance: opts.provenance }),
2291
+ ...(opts.artifactSchema === undefined
2292
+ ? {}
2293
+ : { artifactSchema: opts.artifactSchema }),
2294
+ });
2295
+ let artifactSchema = schemaResolution.artifactSchema;
1714
2296
  let composer;
1715
2297
  const linkedPath = join(opts.artifactDir, `${opts.basename}.playbook.ts`);
1716
2298
  if (existsSync(linkedPath)) {
@@ -1719,6 +2301,17 @@ export async function emitPromptContractTest(opts) {
1719
2301
  linkedPath,
1720
2302
  fsmPath,
1721
2303
  }));
2304
+ schemaResolution = resolveArtifactSchemaForVerification({
2305
+ config,
2306
+ linked,
2307
+ ...(opts.provenance === undefined
2308
+ ? {}
2309
+ : { provenance: opts.provenance }),
2310
+ ...(opts.artifactSchema === undefined
2311
+ ? {}
2312
+ : { artifactSchema: opts.artifactSchema }),
2313
+ });
2314
+ artifactSchema = schemaResolution.artifactSchema;
1722
2315
  const actors = new Set(enumerateCaptainStates(config).map(({ actor }) => actor));
1723
2316
  const substitutions = {};
1724
2317
  for (const actor of ['captain', 'player']) {
@@ -1736,6 +2329,7 @@ export async function emitPromptContractTest(opts) {
1736
2329
  config,
1737
2330
  compose: typedCompose,
1738
2331
  actor,
2332
+ ...(artifactSchema === undefined ? {} : { artifactSchema }),
1739
2333
  });
1740
2334
  diagnostics.push(...findings.map((finding) => `prompt contract: ${finding}`));
1741
2335
  }
@@ -1751,11 +2345,14 @@ export async function emitPromptContractTest(opts) {
1751
2345
  diagnostics.push(`prompt contract: linked module could not be imported (${messageOf(error)}); composition checks not emitted`);
1752
2346
  }
1753
2347
  }
2348
+ diagnostics.unshift(...schemaResolution.findings.map((finding) => `prompt contract: ${finding}`));
1754
2349
  const content = generatePromptContractTest({
1755
2350
  basename: opts.basename,
1756
2351
  fsmModule: `./${opts.basename}.fsm.js`,
1757
2352
  verifyModule: opts.verifyModule ?? VERIFY_MODULE,
1758
2353
  rows,
2354
+ ...(artifactSchema === undefined ? {} : { artifactSchema }),
2355
+ schemaFindings: schemaResolution.findings,
1759
2356
  composer,
1760
2357
  });
1761
2358
  await mkdir(opts.artifactDir, { recursive: true });
@@ -1765,7 +2362,7 @@ export async function emitPromptContractTest(opts) {
1765
2362
  }
1766
2363
  /**
1767
2364
  * Emits the introspection test beside a compiled `playbook` artifact
1768
- * (VERIFY-4): derives topology pins from the physical `<basename>.fsm.ts`,
2365
+ * (verification-4): derives topology pins from the physical `<basename>.fsm.ts`,
1769
2366
  * emits a NodeNext `.js` import for that sibling source, and writes
1770
2367
  * `<basename>.fsm.introspect.test.ts` into the artifact directory.
1771
2368
  *
@@ -1785,7 +2382,7 @@ export async function emitFsmIntrospectionTest(opts) {
1785
2382
  await writeFile(path, content);
1786
2383
  return path;
1787
2384
  }
1788
- // Transition-coverage verification (VERIFY-6) lives in its own module — it
2385
+ // Transition-coverage verification (verification-6) lives in its own module — it
1789
2386
  // depends on `xstate` to drive the machine — and is re-exported here so every
1790
2387
  // generated test imports one checker module (`@sublang/slc/verify`).
1791
2388
  export * from './verify-coverage.js';