@sublang/slc 0.5.0 → 0.7.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 (120) hide show
  1. package/README.md +10 -5
  2. package/dist/app.d.ts +20 -20
  3. package/dist/app.d.ts.map +1 -1
  4. package/dist/app.js +44 -31
  5. package/dist/app.js.map +1 -1
  6. package/dist/artifacts.d.ts +6 -6
  7. package/dist/artifacts.d.ts.map +1 -1
  8. package/dist/artifacts.js +9 -9
  9. package/dist/artifacts.js.map +1 -1
  10. package/dist/cli.js +3 -3
  11. package/dist/cligent-agent.d.ts +9 -2
  12. package/dist/cligent-agent.d.ts.map +1 -1
  13. package/dist/cligent-agent.js +6 -5
  14. package/dist/cligent-agent.js.map +1 -1
  15. package/dist/compiled-execution.d.ts +44 -0
  16. package/dist/compiled-execution.d.ts.map +1 -0
  17. package/dist/compiled-execution.js +184 -0
  18. package/dist/compiled-execution.js.map +1 -0
  19. package/dist/compiled-executor.d.ts +16 -6
  20. package/dist/compiled-executor.d.ts.map +1 -1
  21. package/dist/compiled-executor.js +182 -35
  22. package/dist/compiled-executor.js.map +1 -1
  23. package/dist/config-file.d.ts +9 -6
  24. package/dist/config-file.d.ts.map +1 -1
  25. package/dist/config-file.js +27 -9
  26. package/dist/config-file.js.map +1 -1
  27. package/dist/config.d.ts +77 -24
  28. package/dist/config.d.ts.map +1 -1
  29. package/dist/config.js +128 -52
  30. package/dist/config.js.map +1 -1
  31. package/dist/emitted-imports.d.ts +14 -0
  32. package/dist/emitted-imports.d.ts.map +1 -1
  33. package/dist/emitted-imports.js +63 -7
  34. package/dist/emitted-imports.js.map +1 -1
  35. package/dist/entry-module.d.ts +5 -3
  36. package/dist/entry-module.d.ts.map +1 -1
  37. package/dist/entry-module.js +41 -13
  38. package/dist/entry-module.js.map +1 -1
  39. package/dist/execution.d.ts +10 -10
  40. package/dist/execution.d.ts.map +1 -1
  41. package/dist/execution.js +13 -13
  42. package/dist/execution.js.map +1 -1
  43. package/dist/hash.js +2 -2
  44. package/dist/interpreter.d.ts +2 -2
  45. package/dist/interpreter.d.ts.map +1 -1
  46. package/dist/interpreter.js +7 -7
  47. package/dist/invocation.d.ts +6 -6
  48. package/dist/invocation.d.ts.map +1 -1
  49. package/dist/invocation.js +1 -1
  50. package/dist/link.d.ts +4 -4
  51. package/dist/link.d.ts.map +1 -1
  52. package/dist/link.js +11 -11
  53. package/dist/link.js.map +1 -1
  54. package/dist/phase-runner.d.ts +13 -6
  55. package/dist/phase-runner.d.ts.map +1 -1
  56. package/dist/phase-runner.js +12 -10
  57. package/dist/phase-runner.js.map +1 -1
  58. package/dist/phase.d.ts +4 -4
  59. package/dist/phase.d.ts.map +1 -1
  60. package/dist/phase.js +7 -7
  61. package/dist/phase.js.map +1 -1
  62. package/dist/pin-closure.d.ts +35 -7
  63. package/dist/pin-closure.d.ts.map +1 -1
  64. package/dist/pin-closure.js +135 -21
  65. package/dist/pin-closure.js.map +1 -1
  66. package/dist/pin-currency.d.ts +9 -4
  67. package/dist/pin-currency.d.ts.map +1 -1
  68. package/dist/pin-currency.js +57 -20
  69. package/dist/pin-currency.js.map +1 -1
  70. package/dist/pin-generate.d.ts +7 -7
  71. package/dist/pin-generate.d.ts.map +1 -1
  72. package/dist/pin-generate.js +24 -10
  73. package/dist/pin-generate.js.map +1 -1
  74. package/dist/pin-inputs.d.ts +41 -0
  75. package/dist/pin-inputs.d.ts.map +1 -0
  76. package/dist/pin-inputs.js +179 -0
  77. package/dist/pin-inputs.js.map +1 -0
  78. package/dist/pin-paths.d.ts +1 -1
  79. package/dist/pin-paths.js +4 -4
  80. package/dist/pin-paths.js.map +1 -1
  81. package/dist/pins.d.ts +4 -4
  82. package/dist/pins.d.ts.map +1 -1
  83. package/dist/pins.js +5 -5
  84. package/dist/pins.js.map +1 -1
  85. package/dist/pipeline.d.ts +5 -5
  86. package/dist/pipeline.js +10 -10
  87. package/dist/playbook-contract.d.ts +49 -5
  88. package/dist/playbook-contract.d.ts.map +1 -1
  89. package/dist/playbook-contract.js +70 -21
  90. package/dist/playbook-contract.js.map +1 -1
  91. package/dist/playbook-ports.d.ts +9 -9
  92. package/dist/playbook-ports.d.ts.map +1 -1
  93. package/dist/playbook-ports.js +3 -3
  94. package/dist/playbook-ports.js.map +1 -1
  95. package/dist/progress.d.ts +8 -9
  96. package/dist/progress.d.ts.map +1 -1
  97. package/dist/progress.js +3 -3
  98. package/dist/progress.js.map +1 -1
  99. package/dist/resolver.d.ts +9 -9
  100. package/dist/resolver.d.ts.map +1 -1
  101. package/dist/resolver.js +12 -12
  102. package/dist/resolver.js.map +1 -1
  103. package/dist/runner.d.ts +2 -2
  104. package/dist/runner.d.ts.map +1 -1
  105. package/dist/runner.js +218 -81
  106. package/dist/runner.js.map +1 -1
  107. package/dist/runtime-contract.d.ts +17 -0
  108. package/dist/runtime-contract.d.ts.map +1 -0
  109. package/dist/runtime-contract.js +114 -0
  110. package/dist/runtime-contract.js.map +1 -0
  111. package/dist/slc.config.template.yaml +7 -2
  112. package/dist/verify-coverage.d.ts +4 -3
  113. package/dist/verify-coverage.d.ts.map +1 -1
  114. package/dist/verify-coverage.js +509 -71
  115. package/dist/verify-coverage.js.map +1 -1
  116. package/dist/verify.d.ts +153 -15
  117. package/dist/verify.d.ts.map +1 -1
  118. package/dist/verify.js +707 -59
  119. package/dist/verify.js.map +1 -1
  120. 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,83 @@ 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
+ /**
36
+ * True when the declaration admits the roleless schema-3 `composed-v3`
37
+ * generation: `RUNTIME_ABI` is exactly `1` and `SUPPORTED_ARTIFACT_SCHEMAS`
38
+ * contains `3` (DR-028).
39
+ */
40
+ export function declaresComposedV3(declaration) {
41
+ return (declaration.runtimeAbi === 1 &&
42
+ Array.isArray(declaration.supportedArtifactSchemas) &&
43
+ declaration.supportedArtifactSchemas.includes(3));
44
+ }
45
+ /** Names a declaration for fail-closed diagnostics. */
46
+ export function describeRuntimeDeclaration(declaration) {
47
+ const abi = declaration.runtimeAbi === undefined
48
+ ? 'no RUNTIME_ABI'
49
+ : `RUNTIME_ABI ${renderDeclared(declaration.runtimeAbi)}`;
50
+ const schemas = declaration.supportedArtifactSchemas === undefined
51
+ ? 'no SUPPORTED_ARTIFACT_SCHEMAS'
52
+ : `SUPPORTED_ARTIFACT_SCHEMAS ${renderDeclared(declaration.supportedArtifactSchemas)}`;
53
+ return `${declaration.provenance} declares ${abi} and ${schemas}`;
54
+ }
55
+ function renderDeclared(value) {
56
+ try {
57
+ const json = JSON.stringify(value);
58
+ return json === undefined ? String(value) : json;
59
+ }
60
+ catch {
61
+ return String(value);
62
+ }
63
+ }
64
+ /**
65
+ * Artifact schema recorded for an exact reviewed Playbook provenance: the
66
+ * historical map kept as recorded (DR-028). A later release supplies its
67
+ * schema through the installed engine's declaration instead
68
+ * ({@link resolveArtifactSchemaForVerification}).
69
+ */
70
+ export function artifactSchemaForPlaybookProvenance(provenance) {
71
+ switch (provenance) {
72
+ case '@sublang/playbook@0.10.0':
73
+ case '@sublang/playbook@1.0.0':
74
+ case '@sublang/playbook@2.0.0':
75
+ case '@sublang/playbook@3.1.0':
76
+ case '@sublang/playbook@4.0.0':
77
+ return 1;
78
+ case '@sublang/playbook@10.0.0':
79
+ return 3;
80
+ default:
81
+ return undefined;
82
+ }
83
+ }
84
+ /**
85
+ * Returns the Playbook package provenance that owns an invocation's concrete
86
+ * link target. The first package manifest above the target owns the file; a
87
+ * parent workspace manifest must not lend its identity to a nested local file.
88
+ */
89
+ export async function playbookProvenanceForLinkTarget(linkTarget) {
90
+ let cursor = dirname(resolve(linkTarget));
91
+ for (;;) {
92
+ const manifestPath = join(cursor, 'package.json');
93
+ if (existsSync(manifestPath)) {
94
+ try {
95
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
96
+ return manifest.name === '@sublang/playbook' &&
97
+ typeof manifest.version === 'string' &&
98
+ manifest.version.length > 0
99
+ ? `${manifest.name}@${manifest.version}`
100
+ : undefined;
101
+ }
102
+ catch {
103
+ return undefined;
104
+ }
105
+ }
106
+ const parent = dirname(cursor);
107
+ if (parent === cursor)
108
+ return undefined;
109
+ cursor = parent;
110
+ }
111
+ }
35
112
  const ITEM_HEADING = /^###\s+([A-Za-z][\w-]*)\s*$/;
36
113
  // The `text2gears` item form names a delegated player as "Captain shall prompt
37
114
  // <Player>" (or a "relay ... to <Player>" variant); English players are
@@ -41,6 +118,7 @@ const ITEM_HEADING = /^###\s+([A-Za-z][\w-]*)\s*$/;
41
118
  const ITEM_PLAYER = /Captain shall (?:prompt|relay\b[^.]*?\bto)\s+(?:`([^`]+)`|"([^"]+)"|“([^”]+)”|([A-Z][\w]*)|([^\p{ASCII}][^\s::,,。;;]*))/u;
42
119
  const ITEM_PLAYBOOK = /Captain shall call playbook\s+(?:`([^`]+)`|"([^"]+)"|“([^”]+)”|([A-Za-z0-9][\w.-]*))\s*:/;
43
120
  const ITEM_DYNAMIC_PLAYBOOK = /Captain shall call playbook selected by\s+`([^`]+)`\s*:/;
121
+ const PARALLEL_GROUP = /^Parallel group:\s*(\S(?:.*\S)?)\s*$/;
44
122
  const DYNAMIC_TEXT = /^<([A-Za-z_$][A-Za-z0-9_$]*)>$/;
45
123
  // An optimizer-introduced script item runs a shell command without any agent
46
124
  // (text2gears.md "Script behaviors"; DR-013). The clause is fixed machine
@@ -112,6 +190,9 @@ export function parseGearsItems(gears) {
112
190
  ...(dynamicText === null ? {} : { textContext: dynamicText[1] }),
113
191
  }
114
192
  : {}),
193
+ ...(current.parallelGroup !== ''
194
+ ? { parallelGroup: current.parallelGroup }
195
+ : {}),
115
196
  ...(current.resultDeclared
116
197
  ? { result: Object.fromEntries(current.results) }
117
198
  : {}),
@@ -133,6 +214,7 @@ export function parseGearsItems(gears) {
133
214
  script: false,
134
215
  playbookId: '',
135
216
  playbookIdContext: '',
217
+ parallelGroup: '',
136
218
  prompt: [],
137
219
  resultsEligible: false,
138
220
  resultDeclared: false,
@@ -195,6 +277,10 @@ export function parseGearsItems(gears) {
195
277
  }
196
278
  if (line.trim() !== '')
197
279
  current.resultsEligible = false;
280
+ const parallelGroup = PARALLEL_GROUP.exec(line.trim());
281
+ if (parallelGroup !== null && current.parallelGroup === '') {
282
+ current.parallelGroup = parallelGroup[1];
283
+ }
198
284
  const player = ITEM_PLAYER.exec(line);
199
285
  if (player !== null && current.player === '') {
200
286
  current.player =
@@ -221,6 +307,152 @@ export function parseGearsItems(gears) {
221
307
  flush();
222
308
  return items;
223
309
  }
310
+ const ROLE_DECLARATION = /^(?:#{1,6}\s+(Roles|Players)|(Roles|Players):)\s*$/;
311
+ /** Canonical lowercase local-role id used by schema-3 artifacts. */
312
+ export function canonicalRoleId(name) {
313
+ return name.toLowerCase();
314
+ }
315
+ function declarationName(value) {
316
+ const match = /^[`"“]?([^`"”]+?)[`"”]?\s*$/.exec(value.trim());
317
+ return match?.[1].trim() || undefined;
318
+ }
319
+ /** Parses Roles/Players plus source-derived concurrent role sets without host bindings. */
320
+ export function inspectGearsRoleContract(gears) {
321
+ const findings = [];
322
+ const declarations = [];
323
+ let active;
324
+ for (const line of gears.split('\n')) {
325
+ const heading = ROLE_DECLARATION.exec(line.trim());
326
+ if (heading !== null) {
327
+ active = {
328
+ kind: (heading[1] ?? heading[2]),
329
+ names: [],
330
+ };
331
+ declarations.push(active);
332
+ continue;
333
+ }
334
+ if (active === undefined)
335
+ continue;
336
+ if (line.trim() === '')
337
+ continue;
338
+ const bullet = /^-\s+(.*)$/.exec(line.trim());
339
+ if (bullet === null) {
340
+ active = undefined;
341
+ continue;
342
+ }
343
+ const declaration = bullet[1].trim();
344
+ if (active.kind === 'Roles' && /[=|]/.test(declaration)) {
345
+ findings.push(`Roles declaration ${JSON.stringify(declaration)} uses removed alias syntax`);
346
+ continue;
347
+ }
348
+ // Historical schema-1 Players may declare a composite launcher choice
349
+ // (`Committer = Coder | Reviewer`). It is not one concrete player binding
350
+ // and therefore does not enter the source-order player list, including
351
+ // when every name is backtick- or quote-delimited.
352
+ if (active.kind === 'Players' && declaration.includes('='))
353
+ continue;
354
+ const name = declarationName(declaration);
355
+ if (name === undefined) {
356
+ findings.push(`malformed ${active.kind} declaration ${JSON.stringify(declaration)}`);
357
+ continue;
358
+ }
359
+ active.names.push(name);
360
+ }
361
+ const kinds = new Set(declarations.map(({ kind }) => kind));
362
+ if (declarations.length > 1) {
363
+ findings.push('GEARS declares more than one Roles/Players section');
364
+ }
365
+ if (kinds.size > 1) {
366
+ findings.push('GEARS mixes Roles and Players declarations');
367
+ }
368
+ const selected = declarations[0];
369
+ const generation = selected?.kind === 'Roles'
370
+ ? 'schema-3'
371
+ : selected?.kind === 'Players'
372
+ ? 'schema-1'
373
+ : 'unspecified';
374
+ const names = selected?.names ?? [];
375
+ const roleIds = names.map(canonicalRoleId);
376
+ if (generation === 'schema-3') {
377
+ const byCanonical = new Map();
378
+ for (let index = 0; index < names.length; index += 1) {
379
+ const name = names[index];
380
+ const roleId = roleIds[index];
381
+ const existing = byCanonical.get(roleId);
382
+ if (existing !== undefined) {
383
+ findings.push(existing === name
384
+ ? `Roles declaration repeats ${JSON.stringify(name)}`
385
+ : `Roles declarations ${JSON.stringify(existing)} and ${JSON.stringify(name)} collide as canonical role ${JSON.stringify(roleId)}`);
386
+ }
387
+ else {
388
+ byCanonical.set(roleId, name);
389
+ }
390
+ // A canonical local role id is the declared name lowercased. Playbook 10
391
+ // explicitly admits non-English role names ("quote non-English names
392
+ // (e.g., `作者`)"), and a script without case - Chinese among them -
393
+ // lowercases to itself, so an ASCII-only class would reject exactly the
394
+ // names the definition sanctions. Require instead that the id be genuinely
395
+ // canonical: already lowercase, carrying no whitespace or separator that
396
+ // would make it ambiguous as an identifier.
397
+ if (roleId.length === 0 ||
398
+ roleId !== roleId.toLowerCase() ||
399
+ /[\s.,;:/\\'"`()[\]{}<>|]/u.test(roleId)) {
400
+ findings.push(`Roles declaration ${JSON.stringify(name)} derives noncanonical local role ${JSON.stringify(roleId)}`);
401
+ }
402
+ else if (roleId === 'captain') {
403
+ findings.push('Roles declaration uses reserved local role "captain"');
404
+ }
405
+ }
406
+ }
407
+ const groups = new Map();
408
+ if (generation === 'schema-3') {
409
+ const declared = new Set(roleIds);
410
+ for (const item of parseGearsItems(gears)) {
411
+ if (item.actor === 'player') {
412
+ const roleId = canonicalRoleId(item.player);
413
+ if (!declared.has(roleId)) {
414
+ findings.push(`GEARS item ${item.id} delegates to undeclared role ${JSON.stringify(item.player)}`);
415
+ }
416
+ }
417
+ if (item.parallelGroup === undefined)
418
+ continue;
419
+ if (item.actor !== 'player') {
420
+ findings.push(`parallel group ${JSON.stringify(item.parallelGroup)} contains non-role item ${item.id}`);
421
+ continue;
422
+ }
423
+ const roleId = canonicalRoleId(item.player);
424
+ const members = groups.get(item.parallelGroup) ?? [];
425
+ if (members.includes(roleId)) {
426
+ findings.push(`parallel group ${JSON.stringify(item.parallelGroup)} repeats canonical role ${JSON.stringify(roleId)}`);
427
+ }
428
+ members.push(roleId);
429
+ groups.set(item.parallelGroup, members);
430
+ }
431
+ const groupByMembers = new Map();
432
+ for (const [group, members] of groups) {
433
+ if (members.length < 2) {
434
+ findings.push(`parallel group ${JSON.stringify(group)} contains fewer than two roles`);
435
+ }
436
+ // A concurrent role set is unordered for duplicate detection even
437
+ // though its source member order remains significant in the FSM export.
438
+ const signature = JSON.stringify([...members].sort());
439
+ const existing = groupByMembers.get(signature);
440
+ if (existing !== undefined) {
441
+ findings.push(`parallel groups ${JSON.stringify(existing)} and ${JSON.stringify(group)} duplicate concurrent role set ${signature}`);
442
+ }
443
+ else {
444
+ groupByMembers.set(signature, group);
445
+ }
446
+ }
447
+ }
448
+ return {
449
+ generation,
450
+ names,
451
+ roleIds,
452
+ concurrentRoleSets: [...groups.values()],
453
+ findings,
454
+ };
455
+ }
224
456
  /** Walks every state node depth-first in declaration order. */
225
457
  function walkStateNodes(config) {
226
458
  const out = [];
@@ -277,6 +509,14 @@ function metadataStateId(state) {
277
509
  const stateId = playbook.stateId;
278
510
  return isNonEmptyString(stateId) ? stateId : undefined;
279
511
  }
512
+ function metadataRole(state) {
513
+ if (typeof state.meta !== 'object' || state.meta === null)
514
+ return undefined;
515
+ const playbook = state.meta.playbook;
516
+ if (typeof playbook !== 'object' || playbook === null)
517
+ return undefined;
518
+ return playbook.role;
519
+ }
280
520
  function stateIdConsistencyFindings(node, inputStateId) {
281
521
  if (!isNonEmptyString(inputStateId))
282
522
  return [];
@@ -359,11 +599,11 @@ function enumerateCaptainBindings(config) {
359
599
  (invoke.src !== undefined || !isNonEmptyString(fields.sourceItem))) {
360
600
  continue;
361
601
  }
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')
602
+ // Published schema-1 artifacts carried a concrete player field. Schema 3
603
+ // carries only its canonical local role and repeats it in public metadata.
604
+ const actor = source === 'player' ||
605
+ Object.hasOwn(fields, 'player') ||
606
+ Object.hasOwn(fields, 'role')
367
607
  ? 'player'
368
608
  : 'captain';
369
609
  const pinActor = source === 'player' || (source === 'captain' && actor === 'captain');
@@ -371,8 +611,37 @@ function enumerateCaptainBindings(config) {
371
611
  if (!isNonEmptyString(fields.sourceItem)) {
372
612
  bindingFindings.push('invoke.input.sourceItem is not a non-empty string');
373
613
  }
374
- if (actor === 'player' && typeof fields.player !== 'string') {
375
- bindingFindings.push('invoke.input.player is not a string');
614
+ if (actor === 'player') {
615
+ const hasPlayer = Object.hasOwn(fields, 'player');
616
+ const hasRole = Object.hasOwn(fields, 'role');
617
+ if (hasPlayer && hasRole) {
618
+ bindingFindings.push('invoke.input carries both historical player and schema-3 role');
619
+ }
620
+ if (hasRole && typeof fields.role !== 'string') {
621
+ bindingFindings.push('invoke.input.role is not a string');
622
+ }
623
+ if (hasRole && source !== 'player') {
624
+ bindingFindings.push('schema-3 delegated work does not invoke the player actor');
625
+ }
626
+ if (!hasRole && typeof fields.player !== 'string') {
627
+ bindingFindings.push('invoke.input.player is not a string');
628
+ }
629
+ const publicRole = metadataRole(node.state);
630
+ if (hasRole) {
631
+ if (typeof publicRole !== 'string') {
632
+ bindingFindings.push('state.meta.playbook.role is not a string for schema-3 delegated work');
633
+ }
634
+ else if (publicRole !== fields.role) {
635
+ bindingFindings.push(`state.meta.playbook.role ${JSON.stringify(publicRole)} does not match invoke.input.role ${JSON.stringify(fields.role)}`);
636
+ }
637
+ }
638
+ else if (publicRole !== undefined) {
639
+ bindingFindings.push('historical delegated-player state unexpectedly declares state.meta.playbook.role');
640
+ }
641
+ }
642
+ else if (Object.hasOwn(fields, 'role') ||
643
+ metadataRole(node.state) !== undefined) {
644
+ bindingFindings.push('direct-Captain state unexpectedly declares a role binding');
376
645
  }
377
646
  if (typeof fields.prompt !== 'string') {
378
647
  bindingFindings.push('invoke.input.prompt is not a string');
@@ -395,6 +664,7 @@ function enumerateCaptainBindings(config) {
395
664
  : '',
396
665
  actor,
397
666
  player: typeof fields.player === 'string' ? fields.player : '',
667
+ ...(typeof fields.role === 'string' ? { role: fields.role } : {}),
398
668
  prompt: typeof fields.prompt === 'string' ? fields.prompt : '',
399
669
  result: resultMap(fields.result),
400
670
  ...nestedStatePath(node),
@@ -655,24 +925,115 @@ function statePlaybookSignature(state) {
655
925
  state.textContext ?? null,
656
926
  ]);
657
927
  }
928
+ /** Exact action guards of Playbook 10's controller decision result. */
929
+ export const CONTROLLER_ACTION_GUARDS = [
930
+ 'respond',
931
+ 'resume',
932
+ 'start',
933
+ 'switch',
934
+ 'dismiss',
935
+ 'deliver',
936
+ 'runtime',
937
+ ];
938
+ /** Whether a result map has the exact Playbook 10 controller domain union. */
939
+ export function isControllerDecisionResult(result) {
940
+ if (!isStringMap(result))
941
+ return false;
942
+ const keys = Object.keys(result).filter((key) => key !== NEEDS_BOSS_REPLY);
943
+ return (keys.length === CONTROLLER_ACTION_GUARDS.length &&
944
+ CONTROLLER_ACTION_GUARDS.every((guard) => Object.hasOwn(result, guard)));
945
+ }
946
+ /** A single missing or extra key against Playbook 10's controller domain. */
947
+ export function controllerDecisionNearMiss(result) {
948
+ if (!isStringMap(result))
949
+ return undefined;
950
+ const actual = Object.keys(result).filter((key) => key !== NEEDS_BOSS_REPLY);
951
+ const expected = new Set(CONTROLLER_ACTION_GUARDS);
952
+ const present = new Set(actual);
953
+ const missing = CONTROLLER_ACTION_GUARDS.filter((key) => !present.has(key));
954
+ const extra = actual.filter((key) => !expected.has(key));
955
+ return missing.length + extra.length === 1 ? { missing, extra } : undefined;
956
+ }
957
+ /** Whether a machine contains Playbook 10's grounded controller decision state. */
958
+ export function isControllerMachine(config) {
959
+ return enumerateCaptainBindings(config).some(({ state, pinActor }) => {
960
+ if (!pinActor || state.actor !== 'captain')
961
+ return false;
962
+ if (state.bindingFindings?.includes('invoke.input.result is not a string-valued object')) {
963
+ return false;
964
+ }
965
+ return isControllerDecisionResult(state.result);
966
+ });
967
+ }
968
+ /** Whether an explicit direct-Captain result is one key from the controller domain. */
969
+ export function hasControllerDecisionNearMiss(config) {
970
+ return enumerateCaptainBindings(config).some(({ state, pinActor }) => pinActor &&
971
+ state.actor === 'captain' &&
972
+ state.result[NEEDS_BOSS_REPLY] === undefined &&
973
+ controllerDecisionNearMiss(state.result) !== undefined);
974
+ }
975
+ function concurrentRoleSets(value) {
976
+ if (!Array.isArray(value))
977
+ return undefined;
978
+ const sets = [];
979
+ for (const candidate of value) {
980
+ if (!Array.isArray(candidate) ||
981
+ candidate.some((role) => typeof role !== 'string')) {
982
+ return undefined;
983
+ }
984
+ sets.push([...candidate]);
985
+ }
986
+ return sets;
987
+ }
658
988
  /**
659
989
  * Checks GEARS↔FSM conformance and returns human-readable findings (empty when
660
990
  * conformant): every GEARS item maps to one state with the same player and the
661
991
  * prompt verbatim, every captain state references a known item, and every
662
992
  * captain state's `result` map declares the Boss-reply suspension key with its
663
- * adjudicator contract (VERIFY-1, VERIFY-3; DR-009).
993
+ * adjudicator contract (verification-1, verification-3; DR-009).
664
994
  */
665
- export function checkGearsFsmConformance(gears, config) {
995
+ export function checkGearsFsmConformance(gears, config, options = {}) {
666
996
  const items = parseGearsItems(gears);
997
+ const roleContract = inspectGearsRoleContract(gears);
998
+ const controller = isControllerMachine(config);
667
999
  const captainBindings = enumerateCaptainBindings(config);
668
1000
  const states = captainBindings.map(({ state }) => state);
1001
+ const controllerNearMisses = captainBindings.flatMap(({ state, pinActor }) => {
1002
+ if (!pinActor ||
1003
+ state.actor !== 'captain' ||
1004
+ state.result[NEEDS_BOSS_REPLY] !== undefined) {
1005
+ return [];
1006
+ }
1007
+ const nearMiss = controllerDecisionNearMiss(state.result);
1008
+ return nearMiss === undefined ? [] : [{ state, nearMiss }];
1009
+ });
1010
+ const controllerNearMissStates = new Set(controllerNearMisses.map(({ state }) => state));
669
1011
  const explicitActorStates = new Set(captainBindings
670
1012
  .filter(({ pinActor }) => pinActor)
671
1013
  .map(({ state }) => state));
672
1014
  const playbookStates = enumeratePlaybookStates(config);
673
1015
  const scriptStates = enumerateScriptStates(config);
674
1016
  const findings = [];
1017
+ for (const { state, nearMiss } of controllerNearMisses) {
1018
+ const detail = nearMiss.missing.length > 0
1019
+ ? `missing ${JSON.stringify(nearMiss.missing[0])}`
1020
+ : `extra ${JSON.stringify(nearMiss.extra[0])}`;
1021
+ findings.push(`FSM state ${state.stateId}: controller decision contract near-miss (${detail}); the controller domain requires exactly ${CONTROLLER_ACTION_GUARDS.join(', ')}`);
1022
+ }
675
1023
  findings.push(...structuredStateIdentityFindings(walkStateNodes(config)));
1024
+ findings.push(...roleContract.findings);
1025
+ const requiresConcurrentRoleSets = roleContract.generation === 'schema-3' ||
1026
+ controller ||
1027
+ options.artifactSchema === 3;
1028
+ if (requiresConcurrentRoleSets) {
1029
+ const actual = concurrentRoleSets(options.concurrentRoleSets);
1030
+ if (actual === undefined) {
1031
+ findings.push('schema-3 FSM exports no valid concurrentRoleSets array');
1032
+ }
1033
+ else if (JSON.stringify(actual) !== JSON.stringify(roleContract.concurrentRoleSets)) {
1034
+ findings.push(`schema-3 FSM concurrentRoleSets ${JSON.stringify(actual)} do not match GEARS groups ${JSON.stringify(roleContract.concurrentRoleSets)}`);
1035
+ }
1036
+ }
676
1037
  for (const item of items) {
677
1038
  findings.push(...(item.resultFindings ?? []).map((finding) => `GEARS item ${item.id}: ${finding}`));
678
1039
  }
@@ -840,8 +1201,24 @@ export function checkGearsFsmConformance(gears, config) {
840
1201
  state.actor !== item.actor) {
841
1202
  findings.push(`${item.id}: FSM actor "${state.actor}" is not GEARS actor "${item.actor}"`);
842
1203
  }
843
- if (item.actor === 'player' && state.player !== item.player) {
844
- findings.push(`${item.id}: FSM player "${state.player}" is not GEARS player "${item.player}"`);
1204
+ if (item.actor === 'player') {
1205
+ if (roleContract.generation === 'schema-3') {
1206
+ const expectedRole = canonicalRoleId(item.player);
1207
+ if (state.role !== expectedRole) {
1208
+ findings.push(`${item.id}: FSM role ${JSON.stringify(state.role ?? '')} is not GEARS canonical role ${JSON.stringify(expectedRole)}`);
1209
+ }
1210
+ if (state.player !== '') {
1211
+ findings.push(`${item.id}: schema-3 delegated role carries removed invoke.input.player ${JSON.stringify(state.player)}`);
1212
+ }
1213
+ }
1214
+ else {
1215
+ if (state.player !== item.player) {
1216
+ findings.push(`${item.id}: FSM player "${state.player}" is not GEARS player "${item.player}"`);
1217
+ }
1218
+ if (state.role !== undefined) {
1219
+ findings.push(`${item.id}: historical delegated player unexpectedly carries invoke.input.role ${JSON.stringify(state.role)}`);
1220
+ }
1221
+ }
845
1222
  }
846
1223
  if (state.prompt !== item.prompt) {
847
1224
  findings.push(`${item.id}: FSM prompt is not the GEARS prompt verbatim`);
@@ -871,11 +1248,19 @@ export function checkGearsFsmConformance(gears, config) {
871
1248
  if (state.sourceItem !== '' && !itemIds.has(state.sourceItem)) {
872
1249
  findings.push(`FSM state ${state.stateId} references unknown GEARS item ${state.sourceItem}`);
873
1250
  }
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
1251
  const bossReply = state.result[NEEDS_BOSS_REPLY];
878
- if (bossReply === undefined) {
1252
+ if (controller) {
1253
+ if (bossReply !== undefined) {
1254
+ findings.push(`FSM controller state ${state.stateId} unexpectedly declares ${NEEDS_BOSS_REPLY}`);
1255
+ }
1256
+ }
1257
+ else if (controllerNearMissStates.has(state)) {
1258
+ // The precise controller-domain diagnostic above owns a malformed
1259
+ // near-controller state; do not suggest adding the ordinary wait key to
1260
+ // that state, while retaining ordinary findings for every other state.
1261
+ continue;
1262
+ }
1263
+ else if (bossReply === undefined) {
879
1264
  findings.push(`FSM state ${state.stateId} declares no ${NEEDS_BOSS_REPLY} result`);
880
1265
  }
881
1266
  else if (!bossReply.includes(BOSS_QUESTION_MARKER)) {
@@ -898,7 +1283,7 @@ export function checkGearsFsmConformance(gears, config) {
898
1283
  return findings;
899
1284
  }
900
1285
  /*
901
- * Machine introspection (VERIFY-4).
1286
+ * Machine introspection (verification-4).
902
1287
  *
903
1288
  * `pinIntrospection` reduces a machine config to its structural facts — the
904
1289
  * captain-state bindings, every transition arm, the root and quiescent event
@@ -963,7 +1348,7 @@ function invokeSource(src) {
963
1348
  }
964
1349
  /**
965
1350
  * Reduces a machine config to the structural facts the emitted introspection
966
- * test pins (VERIFY-4): captain bindings with result keys and every transition
1351
+ * test pins (verification-4): captain bindings with result keys and every transition
967
1352
  * arm, the quiescent states' event surfaces, the root event surface, and the
968
1353
  * `BOSS_INTERRUPT` jumpable set.
969
1354
  */
@@ -990,6 +1375,7 @@ export function pinIntrospection(config) {
990
1375
  : {}),
991
1376
  sourceItem: binding.state.sourceItem,
992
1377
  player: binding.state.player,
1378
+ ...(binding.state.role !== undefined ? { role: binding.state.role } : {}),
993
1379
  resultKeys: Object.keys(binding.state.result).sort(),
994
1380
  onDone: normalizeArms(binding.invoke.onDone),
995
1381
  onError: normalizeArms(binding.invoke.onError),
@@ -1061,7 +1447,7 @@ export function pinIntrospection(config) {
1061
1447
  };
1062
1448
  }
1063
1449
  /*
1064
- * Prompt-contract capture and composition checks (VERIFY-5).
1450
+ * Prompt-contract capture and composition checks (verification-5).
1065
1451
  *
1066
1452
  * The contract is derived from the artifacts, never hand-authored: context
1067
1453
  * reads are traced through each state's `invoke.input` thunk with a recording
@@ -1079,6 +1465,7 @@ export const BOSS_REPLY_LABEL = 'Boss reply:';
1079
1465
  // labelled form as well as natural-language variants; occurrence deltas below
1080
1466
  // keep self-hosting prompt bodies free to quote either marker verbatim.
1081
1467
  const PLAYER_BINDING_MARKER = /\bplayer\s+binding\b|(?:^|\n)[ \t]*player[ \t]*:[ \t]*(?=\S)/gi;
1468
+ const ROLE_BINDING_MARKER = /\brole\s+binding\b|(?:^|\n)[ \t]*role[ \t]*:[ \t]*(?=\S)/gi;
1082
1469
  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
1470
  const PLACEHOLDER = /<[^\s<>`]{1,60}>/g;
1084
1471
  /** Lists the distinct `<...>` placeholder tokens in a prompt body, in order. */
@@ -1139,7 +1526,7 @@ function carriesSentinel(value, sentinel) {
1139
1526
  }
1140
1527
  /**
1141
1528
  * Derives every captain state's prompt contract from the machine config
1142
- * (VERIFY-5): traced context reads, sentinel-traced input wiring, and the
1529
+ * (verification-5): traced context reads, sentinel-traced input wiring, and the
1143
1530
  * prompt body's placeholder tokens.
1144
1531
  */
1145
1532
  export function capturePromptContract(config) {
@@ -1167,6 +1554,7 @@ export function capturePromptContract(config) {
1167
1554
  state: state.stateId,
1168
1555
  sourceItem: state.sourceItem,
1169
1556
  player: state.player,
1557
+ ...(state.role !== undefined ? { role: state.role } : {}),
1170
1558
  reads,
1171
1559
  wires,
1172
1560
  placeholders: placeholdersIn(state.prompt),
@@ -1178,7 +1566,7 @@ export function capturePromptContract(config) {
1178
1566
  * Derives, per captain state, which of its prompt's placeholder tokens the
1179
1567
  * linked composer substitutes when the wired context is present — pinned into
1180
1568
  * the emitted test so a token that later leaks unsubstituted fails it
1181
- * (VERIFY-5).
1569
+ * (verification-5).
1182
1570
  */
1183
1571
  export function deriveSubstitutions(config, compose, actor) {
1184
1572
  const out = {};
@@ -1190,7 +1578,7 @@ export function deriveSubstitutions(config, compose, actor) {
1190
1578
  continue;
1191
1579
  try {
1192
1580
  const reads = probeContextReads(inputFn);
1193
- const composed = compose(inputFn({ context: ordinaryContext(reads) }));
1581
+ const composed = composeForState(compose, state, inputFn({ context: ordinaryContext(reads) }));
1194
1582
  if (typeof composed !== 'string') {
1195
1583
  out[state.stateId] = [];
1196
1584
  continue;
@@ -1201,8 +1589,9 @@ export function deriveSubstitutions(config, compose, actor) {
1201
1589
  // hide valid evidence, while merely deleting a token still cannot
1202
1590
  // masquerade as substitution.
1203
1591
  const evidenced = new Set();
1592
+ const promptReads = promptSentinelFields(state, reads);
1204
1593
  for (const line of state.prompt.split('\n')) {
1205
- for (const token of matchPromptBody(line, composed, reads)
1594
+ for (const token of matchPromptBody(line, composed, promptReads)
1206
1595
  ?.substitutions ?? []) {
1207
1596
  evidenced.add(token);
1208
1597
  }
@@ -1217,7 +1606,7 @@ export function deriveSubstitutions(config, compose, actor) {
1217
1606
  }
1218
1607
  /**
1219
1608
  * Checks the linked composer against the link contract for every captain state
1220
- * (VERIFY-5), returning findings (empty when conformant): the prompt body is
1609
+ * (verification-5), returning findings (empty when conformant): the prompt body is
1221
1610
  * preserved modulo substituted placeholders, the adjudicator-facing Boss-reply
1222
1611
  * contract never leaks into a player prompt, no continuation appears on an
1223
1612
  * ordinary turn, and a Boss-reply continuation turn opens with the exact
@@ -1225,19 +1614,30 @@ export function deriveSubstitutions(config, compose, actor) {
1225
1614
  */
1226
1615
  export function checkPromptComposition(opts) {
1227
1616
  const findings = [];
1617
+ const controller = isControllerMachine(opts.config);
1618
+ const schemaResolution = resolveArtifactSchemaForVerification({
1619
+ config: opts.config,
1620
+ ...(opts.artifactSchema === undefined
1621
+ ? {}
1622
+ : { artifactSchema: opts.artifactSchema }),
1623
+ });
1624
+ findings.push(...schemaResolution.findings);
1625
+ const inferredArtifactSchema = schemaResolution.artifactSchema;
1228
1626
  const substitutions = deriveSubstitutions(opts.config, opts.compose, opts.actor);
1229
1627
  const composerName = opts.actor === 'captain' ? 'composeCaptainPrompt' : 'composePlayerPrompt';
1230
- for (const binding of enumerateCaptainBindings(opts.config)) {
1628
+ const bindings = enumerateCaptainBindings(opts.config);
1629
+ for (const binding of bindings) {
1231
1630
  const { state, inputFn } = binding;
1232
1631
  if (opts.actor !== undefined && state.actor !== opts.actor)
1233
1632
  continue;
1234
1633
  if (typeof inputFn !== 'function')
1235
1634
  continue;
1236
1635
  const reads = probeContextReads(inputFn);
1636
+ const promptReads = promptSentinelFields(state, reads);
1237
1637
  const substituted = substitutions[state.stateId] ?? [];
1238
1638
  let ordinary;
1239
1639
  try {
1240
- ordinary = opts.compose(inputFn({ context: ordinaryContext(reads) }));
1640
+ ordinary = composeForState(opts.compose, state, inputFn({ context: ordinaryContext(reads) }));
1241
1641
  if (typeof ordinary !== 'string') {
1242
1642
  throw new Error(`${composerName} returned a non-string value`);
1243
1643
  }
@@ -1246,8 +1646,8 @@ export function checkPromptComposition(opts) {
1246
1646
  findings.push(`${state.stateId}: ${composerName} threw on an ordinary turn: ${messageOf(error)}`);
1247
1647
  continue;
1248
1648
  }
1249
- findings.push(...bodyFindings(state, ordinary, substituted, reads, 'ordinary'));
1250
- pushUnique(findings, ...directCaptainControlFindings(state, ordinary));
1649
+ findings.push(...bodyFindings(state, ordinary, substituted, promptReads, 'ordinary'));
1650
+ pushUnique(findings, ...promptControlFindings(state, ordinary));
1251
1651
  // A self-hosted playbook's domain body may legitimately quote the
1252
1652
  // adjudicator contract or the continuation texts (it instructs a compiler
1253
1653
  // about them); only occurrences the composer ADDS beyond the body's own
@@ -1259,15 +1659,34 @@ export function checkPromptComposition(opts) {
1259
1659
  if ([CONTINUATION_PREAMBLE, BOSS_QUESTION_LABEL, BOSS_REPLY_LABEL].some((needle) => occurrences(ordinary, needle) > occurrences(state.prompt, needle))) {
1260
1660
  findings.push(`${state.stateId}: continuation blocks appear on an ordinary turn`);
1261
1661
  }
1662
+ // Controllers own no Boss-reply wait. A missing ordinary result is already
1663
+ // diagnosed by conformance, so do not fabricate a continuation contract.
1664
+ if (controller || !Object.hasOwn(state.result, NEEDS_BOSS_REPLY))
1665
+ continue;
1666
+ const artifactSchema = schemaResolution.findings.length > 0
1667
+ ? undefined
1668
+ : (inferredArtifactSchema ??
1669
+ (state.role !== undefined ? 3 : state.player !== '' ? 1 : undefined));
1670
+ if (artifactSchema === undefined) {
1671
+ findings.push(`${state.stateId}: prompt composition requires artifactSchema 1 or 3 to probe this direct-Captain continuation`);
1672
+ continue;
1673
+ }
1262
1674
  // A Boss-reply continuation turn: the thunk carries the pending question
1263
1675
  // and reply, and the composer opens with the exact preamble and labelled
1264
1676
  // Q&A blocks before the domain body (gears2fsm.md, link.md).
1265
1677
  const question = sentinelFor('question');
1266
1678
  const reply = sentinelFor('bossReply');
1267
1679
  const pendingBossQuestion = {
1680
+ ...(artifactSchema === 3
1681
+ ? state.actor === 'captain'
1682
+ ? { asker: { kind: 'captain' } }
1683
+ : { asker: { kind: 'role', roleId: state.role ?? '' } }
1684
+ : {
1685
+ player: state.actor === 'captain' ? 'Captain' : state.player,
1686
+ }),
1687
+ questionId: state.stateId,
1268
1688
  resumeStateId: state.stateId,
1269
1689
  sourceItem: state.sourceItem,
1270
- player: state.player,
1271
1690
  question,
1272
1691
  };
1273
1692
  let continuation;
@@ -1284,7 +1703,7 @@ export function checkPromptComposition(opts) {
1284
1703
  bossReplies: { [state.stateId]: reply },
1285
1704
  },
1286
1705
  });
1287
- continuation = opts.compose(input);
1706
+ continuation = composeForState(opts.compose, state, input);
1288
1707
  if (typeof continuation !== 'string') {
1289
1708
  throw new Error(`${composerName} returned a non-string value`);
1290
1709
  }
@@ -1300,7 +1719,7 @@ export function checkPromptComposition(opts) {
1300
1719
  if (!continuation.startsWith(`${CONTINUATION_PREAMBLE}\n\n`)) {
1301
1720
  findings.push(`${state.stateId}: a continuation turn does not open with the exact preamble`);
1302
1721
  }
1303
- const bodyStart = bodyIndex(state, continuation, substituted, reads);
1722
+ const bodyStart = bodyIndex(state, continuation, substituted, promptReads);
1304
1723
  const questionBlock = `${BOSS_QUESTION_LABEL}\n${question}`;
1305
1724
  const replyBlock = `${BOSS_REPLY_LABEL}\n${reply}`;
1306
1725
  for (const [label, value] of [
@@ -1322,21 +1741,59 @@ export function checkPromptComposition(opts) {
1322
1741
  if (!continuation.startsWith(exactContinuationPrefix)) {
1323
1742
  findings.push(`${state.stateId}: a continuation turn does not preserve the exact ordered Boss question/reply blocks`);
1324
1743
  }
1325
- findings.push(...bodyFindings(state, continuation, substituted, reads, 'continuation'));
1326
- pushUnique(findings, ...directCaptainControlFindings(state, continuation));
1744
+ findings.push(...bodyFindings(state, continuation, substituted, promptReads, 'continuation'));
1745
+ pushUnique(findings, ...promptControlFindings(state, continuation));
1327
1746
  }
1328
1747
  return findings;
1329
1748
  }
1330
- function directCaptainControlFindings(state, composed) {
1331
- if (state.actor !== 'captain')
1332
- return [];
1749
+ function promptSentinelFields(state, reads) {
1750
+ return state.role === undefined
1751
+ ? [...reads]
1752
+ : [...reads, `promptIdentity:${state.role}`];
1753
+ }
1754
+ function composeForState(compose, state, input) {
1755
+ // Schema-1 composers and the shared default composer use their second
1756
+ // positional argument as a placeholder-field map. A callable proxy with a
1757
+ // property-clean view is therefore both an invocation-scoped schema-3 lookup
1758
+ // and an empty map to historical/default composition, without Function.name,
1759
+ // Function.length, or Function.prototype token collisions.
1760
+ const lookup = (roleId) => {
1761
+ if (state.role === undefined) {
1762
+ throw new Error(`prompt identity lookup used role ${JSON.stringify(roleId)} for a direct-Captain or historical state`);
1763
+ }
1764
+ if (roleId !== state.role) {
1765
+ throw new Error(`prompt identity lookup used role ${JSON.stringify(roleId)} instead of canonical local role ${JSON.stringify(state.role)}`);
1766
+ }
1767
+ return sentinelFor(`promptIdentity:${roleId}`);
1768
+ };
1769
+ const promptIdentity = new Proxy(lookup, {
1770
+ get: () => undefined,
1771
+ has: () => false,
1772
+ ownKeys: () => [],
1773
+ getOwnPropertyDescriptor: () => undefined,
1774
+ });
1775
+ return state.actor === 'player' && state.role !== undefined
1776
+ ? compose(input, promptIdentity)
1777
+ : compose(input);
1778
+ }
1779
+ function promptControlFindings(state, composed) {
1333
1780
  const findings = [];
1334
- if (patternOccurrences(composed, PLAYER_BINDING_MARKER) >
1335
- patternOccurrences(state.prompt, PLAYER_BINDING_MARKER)) {
1781
+ const introducesPlayerBinding = patternOccurrences(composed, PLAYER_BINDING_MARKER) >
1782
+ patternOccurrences(state.prompt, PLAYER_BINDING_MARKER);
1783
+ if (state.actor === 'captain' && introducesPlayerBinding) {
1336
1784
  findings.push(`${state.stateId}: composeCaptainPrompt introduces a player binding into a direct-Captain prompt`);
1337
1785
  }
1338
- if (patternOccurrences(composed, PLAYER_RESUME_MARKER) >
1339
- patternOccurrences(state.prompt, PLAYER_RESUME_MARKER)) {
1786
+ else if (state.role !== undefined && introducesPlayerBinding) {
1787
+ findings.push(`${state.stateId}: composePlayerPrompt exposes a concrete player binding in a schema-3 delegated-role prompt`);
1788
+ }
1789
+ if (state.actor === 'captain' &&
1790
+ patternOccurrences(composed, ROLE_BINDING_MARKER) >
1791
+ patternOccurrences(state.prompt, ROLE_BINDING_MARKER)) {
1792
+ findings.push(`${state.stateId}: composeCaptainPrompt introduces a role binding into a direct-Captain prompt`);
1793
+ }
1794
+ if (state.actor === 'captain' &&
1795
+ patternOccurrences(composed, PLAYER_RESUME_MARKER) >
1796
+ patternOccurrences(state.prompt, PLAYER_RESUME_MARKER)) {
1340
1797
  findings.push(`${state.stateId}: composeCaptainPrompt introduces a player resume instruction into a direct-Captain prompt`);
1341
1798
  }
1342
1799
  return findings;
@@ -1429,7 +1886,7 @@ function matchPromptBody(prompt, composed, reads, expectedSubstitutions) {
1429
1886
  }
1430
1887
  return null;
1431
1888
  }
1432
- /** Findings when a composed prompt does not preserve the domain body (VERIFY-5). */
1889
+ /** Findings when a composed prompt does not preserve the domain body (verification-5). */
1433
1890
  function bodyFindings(state, composed, substituted, reads, turn) {
1434
1891
  if (matchPromptBody(state.prompt, composed, reads, substituted) !== null) {
1435
1892
  return [];
@@ -1454,7 +1911,7 @@ function bodyFindings(state, composed, substituted, reads, turn) {
1454
1911
  function bodyIndex(state, composed, substituted, reads) {
1455
1912
  return (matchPromptBody(state.prompt, composed, reads, substituted)?.index ?? -1);
1456
1913
  }
1457
- // Local copy: this module is copied verbatim beside the artifact (VERIFY-12),
1914
+ // Local copy: this module is copied verbatim beside the artifact (verification-12),
1458
1915
  // so it may not import a sibling module.
1459
1916
  function messageOf(error) {
1460
1917
  return error instanceof Error ? error.message : String(error);
@@ -1492,6 +1949,12 @@ export function findMachineConfig(fsmModule) {
1492
1949
  }
1493
1950
  throw new Error('fsm module exports no XState machine with a `.config.states`');
1494
1951
  }
1952
+ /** Reads the schema-3 cohort declaration from an imported FSM module. */
1953
+ export function findConcurrentRoleSets(fsmModule) {
1954
+ if (typeof fsmModule !== 'object' || fsmModule === null)
1955
+ return undefined;
1956
+ return fsmModule.concurrentRoleSets;
1957
+ }
1495
1958
  /**
1496
1959
  * Builds a per-artifact vitest module that fails when the compiled FSM drifts
1497
1960
  * from its GEARS source: it reads the artifact's `gears` file and the machine its
@@ -1502,22 +1965,35 @@ export function generateGearsFsmConformanceTest(opts) {
1502
1965
  return `// SPDX-License-Identifier: Apache-2.0
1503
1966
  // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
1504
1967
 
1505
- // Generated by slc (IR-007 Task 8): GEARS↔FSM conformance.
1968
+ // Generated by slc (DR-009): GEARS↔FSM conformance.
1506
1969
  import { readFileSync } from 'node:fs';
1507
1970
  import { fileURLToPath } from 'node:url';
1508
1971
 
1509
1972
  import { describe, expect, it } from 'vitest';
1510
1973
 
1511
- import { checkGearsFsmConformance, findMachineConfig } from ${sourceString(opts.verifyModule)};
1974
+ import { checkGearsFsmConformance, findConcurrentRoleSets, findMachineConfig } from ${sourceString(opts.verifyModule)};
1512
1975
  import * as fsm from ${sourceString(opts.fsmModule)};
1513
1976
 
1977
+ const SCHEMA_FINDINGS = ${JSON.stringify(opts.schemaFindings ?? [], null, 2)};
1978
+
1514
1979
  describe(${sourceString(`${opts.basename}: GEARS↔FSM conformance`)}, () => {
1980
+ it('uses consistent artifact-schema evidence', () => {
1981
+ expect(SCHEMA_FINDINGS).toEqual([]);
1982
+ });
1983
+
1515
1984
  it('maps every GEARS item to a state with its player and verbatim prompt', () => {
1516
1985
  const gears = readFileSync(
1517
1986
  fileURLToPath(new URL(${sourceString(opts.gearsFile)}, import.meta.url)),
1518
1987
  'utf8',
1519
1988
  );
1520
- expect(checkGearsFsmConformance(gears, findMachineConfig(fsm))).toEqual([]);
1989
+ expect(
1990
+ checkGearsFsmConformance(gears, findMachineConfig(fsm), {
1991
+ concurrentRoleSets: findConcurrentRoleSets(fsm),
1992
+ ${opts.artifactSchema === undefined
1993
+ ? ''
1994
+ : `artifactSchema: ${opts.artifactSchema},`}
1995
+ }),
1996
+ ).toEqual([]);
1521
1997
  });
1522
1998
  });
1523
1999
  `;
@@ -1526,7 +2002,7 @@ describe(${sourceString(`${opts.basename}: GEARS↔FSM conformance`)}, () => {
1526
2002
  * Emits the GEARS↔FSM conformance test as `slc` output beside a compiled
1527
2003
  * `playbook` artifact: writes `<basename>.gears-fsm.test.ts` into the artifact
1528
2004
  * 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;
2005
+ * `fsm` module's machine to the checker, and returns the written path (verification-2;
1530
2006
  * [DR-009](../decisions/009-slc-playbook-pipeline-compilation.md)).
1531
2007
  */
1532
2008
  export async function emitGearsFsmConformanceTest(opts) {
@@ -1537,6 +2013,10 @@ export async function emitGearsFsmConformanceTest(opts) {
1537
2013
  fsmModule: `./${opts.basename}.fsm.js`,
1538
2014
  gearsFile: `./${opts.basename}.gears.md`,
1539
2015
  verifyModule: opts.verifyModule ?? VERIFY_MODULE,
2016
+ ...(opts.artifactSchema === undefined
2017
+ ? {}
2018
+ : { artifactSchema: opts.artifactSchema }),
2019
+ schemaFindings: opts.schemaFindings,
1540
2020
  });
1541
2021
  await mkdir(opts.artifactDir, { recursive: true });
1542
2022
  const path = join(opts.artifactDir, `${opts.basename}.gears-fsm.test.ts`);
@@ -1558,14 +2038,15 @@ export async function loadFsmModule(fsmPath) {
1558
2038
  return import(url.href);
1559
2039
  }
1560
2040
  /**
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.
2041
+ * Imports generated linked TypeScript for emission-time, standalone, or
2042
+ * equivalence review before its sibling FSM has been built to JavaScript.
2043
+ * NodeNext source correctly names the runtime-safe `./<basename>.fsm.js` edge,
2044
+ * but review may run while only `./<basename>.fsm.ts` exists. Stage a
2045
+ * same-directory copy whose one generated module specifier points at the
2046
+ * hashed TypeScript artifact, import that copy, and remove it without changing
2047
+ * the linked source or its production import.
1567
2048
  */
1568
- async function loadLinkedModuleForVerification(opts) {
2049
+ export async function loadLinkedModuleForVerification(opts) {
1569
2050
  const linkedSource = await readFile(opts.linkedPath, 'utf8');
1570
2051
  const fsmStem = basename(opts.fsmPath, '.ts');
1571
2052
  const runtimeSpecifier = `./${fsmStem}.js`;
@@ -1590,7 +2071,7 @@ async function loadLinkedModuleForVerification(opts) {
1590
2071
  }
1591
2072
  /**
1592
2073
  * Builds a per-artifact vitest module that fails when the machine's structure
1593
- * drifts from the topology pinned at build time (VERIFY-4).
2074
+ * drifts from the topology pinned at build time (verification-4).
1594
2075
  */
1595
2076
  export function generateFsmIntrospectionTest(opts) {
1596
2077
  return `// SPDX-License-Identifier: Apache-2.0
@@ -1615,7 +2096,7 @@ describe(${sourceString(`${opts.basename}: FSM introspection`)}, () => {
1615
2096
  }
1616
2097
  /**
1617
2098
  * 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
2099
+ * the artifacts at build time (verification-5): the per-state context reads, input
1619
2100
  * wiring, and placeholders always; and, when the linked module exposes its
1620
2101
  * matching Captain/player composers, the composition checks and pinned
1621
2102
  * substitution maps.
@@ -1650,6 +2131,9 @@ const ${compose} = (
1650
2131
  config: findMachineConfig(fsm),
1651
2132
  compose: ${compose},
1652
2133
  actor: '${actor}',
2134
+ ${opts.artifactSchema === undefined
2135
+ ? ''
2136
+ : `artifactSchema: ${opts.artifactSchema},`}
1653
2137
  }),
1654
2138
  ).toEqual([]);
1655
2139
  });
@@ -1684,17 +2168,157 @@ import {
1684
2168
  import * as fsm from ${sourceString(opts.fsmModule)};
1685
2169
  ${composerImports}
1686
2170
  const CONTRACT = ${JSON.stringify(opts.rows, null, 2)};
2171
+ const SCHEMA_FINDINGS = ${JSON.stringify(opts.schemaFindings ?? [], null, 2)};
1687
2172
 
1688
2173
  describe(${sourceString(`${opts.basename}: prompt contract`)}, () => {
2174
+ it('uses consistent artifact-schema evidence', () => {
2175
+ expect(SCHEMA_FINDINGS).toEqual([]);
2176
+ });
2177
+
1689
2178
  it('matches the prompt contract pinned at build time', () => {
1690
2179
  expect(capturePromptContract(findMachineConfig(fsm))).toEqual(CONTRACT);
1691
2180
  });
1692
2181
  ${composerBlock}});
1693
2182
  `;
1694
2183
  }
2184
+ function promptArtifactSchemaSignalsFromConfig(config) {
2185
+ const states = enumerateCaptainStates(config);
2186
+ return {
2187
+ schema1: states.some(({ player }) => player !== ''),
2188
+ schema3: isControllerMachine(config) ||
2189
+ states.some(({ role }) => role !== undefined),
2190
+ };
2191
+ }
2192
+ function linkedArtifactSchemaSignal(linked) {
2193
+ const factory = linked.default;
2194
+ if (typeof factory !== 'function') {
2195
+ return { historicalFallback: false, invalidCompatibility: false };
2196
+ }
2197
+ if (!Object.hasOwn(factory, 'compat')) {
2198
+ return { historicalFallback: true, invalidCompatibility: false };
2199
+ }
2200
+ const descriptor = Object.getOwnPropertyDescriptor(factory, 'compat');
2201
+ if (descriptor === undefined ||
2202
+ !Object.hasOwn(descriptor, 'value') ||
2203
+ descriptor.enumerable !== true ||
2204
+ descriptor.writable !== false ||
2205
+ descriptor.configurable !== false) {
2206
+ return { historicalFallback: false, invalidCompatibility: true };
2207
+ }
2208
+ const compat = descriptor.value;
2209
+ if (typeof compat !== 'object' ||
2210
+ compat === null ||
2211
+ Array.isArray(compat) ||
2212
+ Object.getPrototypeOf(compat) !== Object.prototype ||
2213
+ !Object.isFrozen(compat) ||
2214
+ Object.getOwnPropertySymbols(compat).length !== 0) {
2215
+ return { historicalFallback: false, invalidCompatibility: true };
2216
+ }
2217
+ const names = Object.getOwnPropertyNames(compat);
2218
+ const artifactSchema = Object.getOwnPropertyDescriptor(compat, 'artifactSchema');
2219
+ const runtimeAbi = Object.getOwnPropertyDescriptor(compat, 'runtimeAbi');
2220
+ const exact = names.length === 2 &&
2221
+ names.includes('artifactSchema') &&
2222
+ names.includes('runtimeAbi') &&
2223
+ artifactSchema?.enumerable === true &&
2224
+ Object.hasOwn(artifactSchema, 'value') &&
2225
+ artifactSchema.value === 3 &&
2226
+ runtimeAbi?.enumerable === true &&
2227
+ Object.hasOwn(runtimeAbi, 'value') &&
2228
+ runtimeAbi.value === 1;
2229
+ return exact
2230
+ ? { schema: 3, historicalFallback: false, invalidCompatibility: false }
2231
+ : { historicalFallback: false, invalidCompatibility: true };
2232
+ }
2233
+ /**
2234
+ * Schema decision shared by generated and standalone artifact verification
2235
+ * (verification-21). Reviewed provenance is evidence through its exact
2236
+ * historical map; any other provenance is evidence only through the engine
2237
+ * declaration read from the link target's installed package — `RUNTIME_ABI`
2238
+ * `1` with artifact schema `3` — and is otherwise reported unsupported
2239
+ * (DR-028).
2240
+ */
2241
+ export function resolveArtifactSchemaForVerification(opts) {
2242
+ const candidates = [];
2243
+ const invalidSignalFindings = [];
2244
+ if (opts.artifactSchema !== undefined) {
2245
+ candidates.push({
2246
+ source: 'review-supplied artifact schema',
2247
+ schema: opts.artifactSchema,
2248
+ });
2249
+ }
2250
+ const provenance = opts.provenance ?? opts.runtimeDeclaration?.provenance;
2251
+ const provenanceSchema = artifactSchemaForPlaybookProvenance(provenance);
2252
+ if (provenanceSchema !== undefined) {
2253
+ candidates.push({
2254
+ source: 'reviewed link-target provenance',
2255
+ schema: provenanceSchema,
2256
+ });
2257
+ }
2258
+ else if (provenance !== undefined) {
2259
+ if (opts.runtimeDeclaration === undefined) {
2260
+ invalidSignalFindings.push(`artifact schema has unsupported link-target provenance ${JSON.stringify(provenance)}`);
2261
+ }
2262
+ else if (declaresComposedV3(opts.runtimeDeclaration)) {
2263
+ candidates.push({ source: 'declared link-target contract', schema: 3 });
2264
+ }
2265
+ else {
2266
+ invalidSignalFindings.push(`artifact schema has an unsupported link-target contract: ${describeRuntimeDeclaration(opts.runtimeDeclaration)}`);
2267
+ }
2268
+ }
2269
+ if (opts.config !== undefined) {
2270
+ const configSignals = promptArtifactSchemaSignalsFromConfig(opts.config);
2271
+ if (configSignals.schema1)
2272
+ candidates.push({ source: 'FSM historical-player structure', schema: 1 });
2273
+ if (configSignals.schema3)
2274
+ candidates.push({ source: 'FSM role/controller structure', schema: 3 });
2275
+ }
2276
+ const linkedSignal = opts.linked === undefined
2277
+ ? undefined
2278
+ : linkedArtifactSchemaSignal(opts.linked);
2279
+ if (linkedSignal?.schema !== undefined) {
2280
+ candidates.push({
2281
+ source: 'linked factory compatibility',
2282
+ schema: linkedSignal.schema,
2283
+ });
2284
+ }
2285
+ if (linkedSignal?.invalidCompatibility) {
2286
+ invalidSignalFindings.push('linked factory has an own compatibility declaration that is not exact immutable schema 3/runtime ABI 1');
2287
+ }
2288
+ if (invalidSignalFindings.length > 0) {
2289
+ return { findings: invalidSignalFindings };
2290
+ }
2291
+ const schemas = new Set(candidates.map(({ schema }) => schema));
2292
+ if (schemas.size > 1) {
2293
+ return {
2294
+ findings: [
2295
+ `artifact schema signals disagree (${candidates
2296
+ .map(({ source, schema }) => `${source}: ${schema}`)
2297
+ .join(', ')})`,
2298
+ ],
2299
+ };
2300
+ }
2301
+ const [artifactSchema] = schemas;
2302
+ if (artifactSchema !== undefined) {
2303
+ return { artifactSchema, findings: [] };
2304
+ }
2305
+ if (linkedSignal?.historicalFallback) {
2306
+ return { artifactSchema: 1, findings: [] };
2307
+ }
2308
+ const hasAmbiguousCaptainContinuation = opts.config !== undefined &&
2309
+ enumerateCaptainStates(opts.config).some((state) => state.actor === 'captain' &&
2310
+ Object.hasOwn(state.result, NEEDS_BOSS_REPLY));
2311
+ return hasAmbiguousCaptainContinuation
2312
+ ? {
2313
+ findings: [
2314
+ 'artifact schema has no reviewed provenance, generation-specific actor structure, or callable linked factory for a direct-Captain continuation',
2315
+ ],
2316
+ }
2317
+ : { findings: [] };
2318
+ }
1695
2319
  /**
1696
2320
  * Emits the prompt-contract test beside a compiled `playbook` artifact
1697
- * (VERIFY-5): derives and pins the per-state contract from the physical
2321
+ * (verification-5): derives and pins the per-state contract from the physical
1698
2322
  * `<basename>.fsm.ts` artifact, then emits NodeNext `.js` imports for that FSM
1699
2323
  * and any linked `<basename>.playbook.ts` module. When the linked module
1700
2324
  * exposes the `_internal` composer matching each state actor —
@@ -1711,6 +2335,20 @@ export async function emitPromptContractTest(opts) {
1711
2335
  const fsmPath = join(opts.artifactDir, `${opts.basename}.fsm.ts`);
1712
2336
  const config = findMachineConfig(await loadFsmModule(fsmPath));
1713
2337
  const rows = capturePromptContract(config);
2338
+ const evidence = {
2339
+ ...(opts.provenance === undefined ? {} : { provenance: opts.provenance }),
2340
+ ...(opts.runtimeDeclaration === undefined
2341
+ ? {}
2342
+ : { runtimeDeclaration: opts.runtimeDeclaration }),
2343
+ ...(opts.artifactSchema === undefined
2344
+ ? {}
2345
+ : { artifactSchema: opts.artifactSchema }),
2346
+ };
2347
+ let schemaResolution = resolveArtifactSchemaForVerification({
2348
+ config,
2349
+ ...evidence,
2350
+ });
2351
+ let artifactSchema = schemaResolution.artifactSchema;
1714
2352
  let composer;
1715
2353
  const linkedPath = join(opts.artifactDir, `${opts.basename}.playbook.ts`);
1716
2354
  if (existsSync(linkedPath)) {
@@ -1719,6 +2357,12 @@ export async function emitPromptContractTest(opts) {
1719
2357
  linkedPath,
1720
2358
  fsmPath,
1721
2359
  }));
2360
+ schemaResolution = resolveArtifactSchemaForVerification({
2361
+ config,
2362
+ linked,
2363
+ ...evidence,
2364
+ });
2365
+ artifactSchema = schemaResolution.artifactSchema;
1722
2366
  const actors = new Set(enumerateCaptainStates(config).map(({ actor }) => actor));
1723
2367
  const substitutions = {};
1724
2368
  for (const actor of ['captain', 'player']) {
@@ -1736,6 +2380,7 @@ export async function emitPromptContractTest(opts) {
1736
2380
  config,
1737
2381
  compose: typedCompose,
1738
2382
  actor,
2383
+ ...(artifactSchema === undefined ? {} : { artifactSchema }),
1739
2384
  });
1740
2385
  diagnostics.push(...findings.map((finding) => `prompt contract: ${finding}`));
1741
2386
  }
@@ -1751,11 +2396,14 @@ export async function emitPromptContractTest(opts) {
1751
2396
  diagnostics.push(`prompt contract: linked module could not be imported (${messageOf(error)}); composition checks not emitted`);
1752
2397
  }
1753
2398
  }
2399
+ diagnostics.unshift(...schemaResolution.findings.map((finding) => `prompt contract: ${finding}`));
1754
2400
  const content = generatePromptContractTest({
1755
2401
  basename: opts.basename,
1756
2402
  fsmModule: `./${opts.basename}.fsm.js`,
1757
2403
  verifyModule: opts.verifyModule ?? VERIFY_MODULE,
1758
2404
  rows,
2405
+ ...(artifactSchema === undefined ? {} : { artifactSchema }),
2406
+ schemaFindings: schemaResolution.findings,
1759
2407
  composer,
1760
2408
  });
1761
2409
  await mkdir(opts.artifactDir, { recursive: true });
@@ -1765,7 +2413,7 @@ export async function emitPromptContractTest(opts) {
1765
2413
  }
1766
2414
  /**
1767
2415
  * Emits the introspection test beside a compiled `playbook` artifact
1768
- * (VERIFY-4): derives topology pins from the physical `<basename>.fsm.ts`,
2416
+ * (verification-4): derives topology pins from the physical `<basename>.fsm.ts`,
1769
2417
  * emits a NodeNext `.js` import for that sibling source, and writes
1770
2418
  * `<basename>.fsm.introspect.test.ts` into the artifact directory.
1771
2419
  *
@@ -1785,7 +2433,7 @@ export async function emitFsmIntrospectionTest(opts) {
1785
2433
  await writeFile(path, content);
1786
2434
  return path;
1787
2435
  }
1788
- // Transition-coverage verification (VERIFY-6) lives in its own module — it
2436
+ // Transition-coverage verification (verification-6) lives in its own module — it
1789
2437
  // depends on `xstate` to drive the machine — and is re-exported here so every
1790
2438
  // generated test imports one checker module (`@sublang/slc/verify`).
1791
2439
  export * from './verify-coverage.js';