@sublang/slc 0.7.0 → 0.9.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 (44) hide show
  1. package/dist/cligent-agent.d.ts.map +1 -1
  2. package/dist/cligent-agent.js +43 -3
  3. package/dist/cligent-agent.js.map +1 -1
  4. package/dist/compiled-executor.d.ts.map +1 -1
  5. package/dist/compiled-executor.js +63 -9
  6. package/dist/compiled-executor.js.map +1 -1
  7. package/dist/entry-module.d.ts +0 -8
  8. package/dist/entry-module.d.ts.map +1 -1
  9. package/dist/entry-module.js +75 -77
  10. package/dist/entry-module.js.map +1 -1
  11. package/dist/execution.d.ts +19 -0
  12. package/dist/execution.d.ts.map +1 -1
  13. package/dist/execution.js.map +1 -1
  14. package/dist/interpreter.d.ts +18 -2
  15. package/dist/interpreter.d.ts.map +1 -1
  16. package/dist/interpreter.js +6 -0
  17. package/dist/interpreter.js.map +1 -1
  18. package/dist/link.d.ts +7 -0
  19. package/dist/link.d.ts.map +1 -1
  20. package/dist/link.js +4 -2
  21. package/dist/link.js.map +1 -1
  22. package/dist/playbook-ports.d.ts +11 -1
  23. package/dist/playbook-ports.d.ts.map +1 -1
  24. package/dist/playbook-ports.js +8 -1
  25. package/dist/playbook-ports.js.map +1 -1
  26. package/dist/reviewing-agent.d.ts +9 -2
  27. package/dist/reviewing-agent.d.ts.map +1 -1
  28. package/dist/reviewing-agent.js +232 -38
  29. package/dist/reviewing-agent.js.map +1 -1
  30. package/dist/runner.d.ts.map +1 -1
  31. package/dist/runner.js +171 -32
  32. package/dist/runner.js.map +1 -1
  33. package/dist/verify-coverage.d.ts.map +1 -1
  34. package/dist/verify-coverage.js +35 -0
  35. package/dist/verify-coverage.js.map +1 -1
  36. package/dist/verify-source.d.ts +61 -0
  37. package/dist/verify-source.d.ts.map +1 -0
  38. package/dist/verify-source.js +280 -0
  39. package/dist/verify-source.js.map +1 -0
  40. package/dist/verify.d.ts +26 -1
  41. package/dist/verify.d.ts.map +1 -1
  42. package/dist/verify.js +357 -78
  43. package/dist/verify.js.map +1 -1
  44. package/package.json +2 -2
package/dist/verify.js CHANGED
@@ -517,6 +517,15 @@ function metadataRole(state) {
517
517
  return undefined;
518
518
  return playbook.role;
519
519
  }
520
+ /** The published terminal kind a root final state declares, if any. */
521
+ function metadataTerminal(state) {
522
+ if (typeof state.meta !== 'object' || state.meta === null)
523
+ return undefined;
524
+ const playbook = state.meta.playbook;
525
+ if (typeof playbook !== 'object' || playbook === null)
526
+ return undefined;
527
+ return playbook.terminal;
528
+ }
520
529
  function stateIdConsistencyFindings(node, inputStateId) {
521
530
  if (!isNonEmptyString(inputStateId))
522
531
  return [];
@@ -554,15 +563,46 @@ function structuredStateIdentityFindings(nodes) {
554
563
  }
555
564
  return findings;
556
565
  }
566
+ /**
567
+ * A schema-3 machine publishes each of its own terminal outcomes as
568
+ * `success` or `failure`, so a caller reads the child's meaning from the
569
+ * state it reached rather than from its output fields. Only the machine's own
570
+ * root final states carry that kind: a final inside a parallel region merely
571
+ * stages the join and publishes no workflow outcome.
572
+ *
573
+ * Artifacts compiled before the definition declared the kind carry none at
574
+ * all, and they are retained rather than rebuilt, so the requirement engages
575
+ * once the artifact declares one — a partly declared artifact is drift.
576
+ */
577
+ function terminalKindFindings(nodes, schema3) {
578
+ if (!schema3)
579
+ return [];
580
+ const finals = nodes.filter(({ path, state }) => path.length === 1 && state.type === 'final');
581
+ if (!finals.some(({ state }) => metadataTerminal(state) !== undefined)) {
582
+ return [];
583
+ }
584
+ const findings = [];
585
+ for (const { statePath, state } of finals) {
586
+ const terminal = metadataTerminal(state);
587
+ if (terminal === undefined) {
588
+ findings.push(`FSM final state ${statePath}: state.meta.playbook.terminal is missing while other final states declare it`);
589
+ }
590
+ else if (terminal !== 'success' && terminal !== 'failure') {
591
+ findings.push(`FSM final state ${statePath}: state.meta.playbook.terminal ${JSON.stringify(terminal)} is neither "success" nor "failure"`);
592
+ }
593
+ }
594
+ return findings;
595
+ }
557
596
  function enumerateCaptainBindings(config) {
558
597
  const out = [];
598
+ const initial = initialMachineContext(config) ?? {};
559
599
  for (const node of walkStateNodes(config)) {
560
600
  for (const invoke of normalizeInvokes(node.state.invoke)) {
561
601
  const source = invokeSource(invoke.src);
562
602
  const explicitlyWorkActor = source === 'captain' || source === 'player';
563
603
  if (!explicitlyWorkActor && invoke.src !== undefined)
564
604
  continue;
565
- const inspected = invocationInput(invoke);
605
+ const inspected = invocationInput(invoke, initial);
566
606
  if ('error' in inspected) {
567
607
  if (explicitlyWorkActor) {
568
608
  const actor = source === 'player' ? 'player' : 'captain';
@@ -688,11 +728,12 @@ export function enumerateCaptainStates(config) {
688
728
  }
689
729
  function enumeratePlaybookBindings(config) {
690
730
  const out = [];
731
+ const initial = initialMachineContext(config) ?? {};
691
732
  for (const node of walkStateNodes(config)) {
692
733
  for (const invoke of normalizeInvokes(node.state.invoke)) {
693
734
  if (invokeSource(invoke.src) !== 'playbook')
694
735
  continue;
695
- const inspected = invocationInput(invoke);
736
+ const inspected = invocationInput(invoke, initial);
696
737
  if ('error' in inspected) {
697
738
  out.push({
698
739
  state: malformedPlaybookState(publicStateId(node, undefined), `invoke.input threw during introspection: ${inspected.error}`, node),
@@ -730,6 +771,7 @@ function enumeratePlaybookBindings(config) {
730
771
  const playbookIdSentinel = sentinelFor(fields.playbookIdContext);
731
772
  const textSentinel = sentinelFor(fields.textContext);
732
773
  const wired = invocationInput(invoke, {
774
+ ...initial,
733
775
  [fields.playbookIdContext]: playbookIdSentinel,
734
776
  [fields.textContext]: textSentinel,
735
777
  });
@@ -799,6 +841,7 @@ export function enumeratePlaybookStates(config) {
799
841
  */
800
842
  export function enumerateScriptStates(config) {
801
843
  const out = [];
844
+ const initial = initialMachineContext(config) ?? {};
802
845
  for (const node of walkStateNodes(config)) {
803
846
  for (const invoke of normalizeInvokes(node.state.invoke)) {
804
847
  if (invokeSource(invoke.src) !== 'script')
@@ -811,7 +854,7 @@ export function enumerateScriptStates(config) {
811
854
  ...nestedStatePath(node),
812
855
  bindingFindings: [finding],
813
856
  });
814
- const inspected = invocationInput(invoke);
857
+ const inspected = invocationInput(invoke, initial);
815
858
  if ('error' in inspected) {
816
859
  out.push(malformed(`invoke.input threw during introspection: ${inspected.error}`));
817
860
  continue;
@@ -1020,12 +1063,14 @@ export function checkGearsFsmConformance(gears, config, options = {}) {
1020
1063
  : `extra ${JSON.stringify(nearMiss.extra[0])}`;
1021
1064
  findings.push(`FSM state ${state.stateId}: controller decision contract near-miss (${detail}); the controller domain requires exactly ${CONTROLLER_ACTION_GUARDS.join(', ')}`);
1022
1065
  }
1023
- findings.push(...structuredStateIdentityFindings(walkStateNodes(config)));
1066
+ const nodes = walkStateNodes(config);
1067
+ findings.push(...structuredStateIdentityFindings(nodes));
1024
1068
  findings.push(...roleContract.findings);
1025
- const requiresConcurrentRoleSets = roleContract.generation === 'schema-3' ||
1069
+ const schema3 = roleContract.generation === 'schema-3' ||
1026
1070
  controller ||
1027
1071
  options.artifactSchema === 3;
1028
- if (requiresConcurrentRoleSets) {
1072
+ findings.push(...terminalKindFindings(nodes, schema3));
1073
+ if (schema3) {
1029
1074
  const actual = concurrentRoleSets(options.concurrentRoleSets);
1030
1075
  if (actual === undefined) {
1031
1076
  findings.push('schema-3 FSM exports no valid concurrentRoleSets array');
@@ -1482,13 +1527,15 @@ const sentinelFor = (field) => `«${field}»`;
1482
1527
  * Traces which context fields an `invoke.input` thunk reads, via a recording
1483
1528
  * proxy context; reads collected up to a throw are kept.
1484
1529
  */
1485
- export function probeContextReads(inputFn) {
1530
+ export function probeContextReads(inputFn,
1531
+ /** The machine's initial context, so a typed field does not truncate the trace. */
1532
+ initial = {}) {
1486
1533
  const reads = new Set();
1487
- const context = new Proxy({}, {
1488
- get(_target, prop) {
1534
+ const context = new Proxy({ ...initial }, {
1535
+ get(target, prop) {
1489
1536
  if (typeof prop === 'string')
1490
1537
  reads.add(prop);
1491
- return undefined;
1538
+ return Reflect.get(target, prop);
1492
1539
  },
1493
1540
  has() {
1494
1541
  return true;
@@ -1516,6 +1563,117 @@ const BOSS_CONTEXT_FIELDS = [
1516
1563
  function ordinaryContext(reads) {
1517
1564
  return sentinelContext(reads.filter((field) => !BOSS_CONTEXT_FIELDS.includes(field)));
1518
1565
  }
1566
+ /**
1567
+ * The machine's resolved initial context (verification-5): a literal record, or
1568
+ * the config's factory resolved with the declared initial input, so a typed
1569
+ * context field an ordinary turn reads keeps its initial shape instead of a
1570
+ * string sentinel. Returns undefined when no initial snapshot can be produced
1571
+ * without running the machine — the caller then degrades to sentinels alone.
1572
+ */
1573
+ function initialMachineContext(config) {
1574
+ const declared = config.context;
1575
+ if (declared === undefined)
1576
+ return {};
1577
+ if (typeof declared === 'object' && declared !== null) {
1578
+ return { ...declared };
1579
+ }
1580
+ if (typeof declared !== 'function')
1581
+ return undefined;
1582
+ try {
1583
+ const resolved = declared({
1584
+ input: {},
1585
+ spawn: () => undefined,
1586
+ self: undefined,
1587
+ event: { type: 'xstate.init' },
1588
+ });
1589
+ return typeof resolved === 'object' && resolved !== null
1590
+ ? { ...resolved }
1591
+ : undefined;
1592
+ }
1593
+ catch {
1594
+ return undefined;
1595
+ }
1596
+ }
1597
+ /**
1598
+ * The context an ordinary-turn probe drives an `invoke.input` thunk with: the
1599
+ * machine's initial context, overlaid with one string sentinel per traced read
1600
+ * whose initial value is absent or itself a string, and never a Boss-reply
1601
+ * field (verification-5).
1602
+ */
1603
+ function ordinaryTurnContext(reads, initial,
1604
+ /** Traces the Boss-reply fields too, for wiring capture rather than a turn. */
1605
+ includeBossFields = false) {
1606
+ if (initial === undefined) {
1607
+ return includeBossFields ? sentinelContext(reads) : ordinaryContext(reads);
1608
+ }
1609
+ const context = { ...initial };
1610
+ if (!includeBossFields) {
1611
+ for (const field of BOSS_CONTEXT_FIELDS)
1612
+ delete context[field];
1613
+ }
1614
+ for (const field of reads) {
1615
+ if (!includeBossFields && BOSS_CONTEXT_FIELDS.includes(field))
1616
+ continue;
1617
+ const current = context[field];
1618
+ if (current === undefined || typeof current === 'string') {
1619
+ context[field] = sentinelFor(field);
1620
+ }
1621
+ }
1622
+ return context;
1623
+ }
1624
+ // Input fields the composition contract itself owns: a placeholder naming one
1625
+ // of them relays no runtime value, so the probe never overwrites it.
1626
+ const CONTRACT_INPUT_FIELDS = [
1627
+ 'prompt',
1628
+ 'result',
1629
+ 'stateId',
1630
+ 'sourceItem',
1631
+ 'role',
1632
+ 'player',
1633
+ ...BOSS_CONTEXT_FIELDS,
1634
+ ];
1635
+ /** The canonical field a `<kebab-token>` placeholder relays: its segments joined camel-case. */
1636
+ function placeholderField(token) {
1637
+ const segments = token.replace(/^<|>$/g, '').split('-');
1638
+ return segments
1639
+ .map((segment, index) => index === 0
1640
+ ? segment
1641
+ : segment.charAt(0).toUpperCase() + segment.slice(1))
1642
+ .join('');
1643
+ }
1644
+ /**
1645
+ * Overlays one string sentinel on every placeholder-derived input field the
1646
+ * prompt relays, so a field the machine's initial context leaves empty or
1647
+ * derives through a typed value still evidences substitution (verification-5).
1648
+ * Returns the probed input and the sentinel field names it carries.
1649
+ */
1650
+ function withPlaceholderValues(state, input) {
1651
+ if (typeof input !== 'object' || input === null)
1652
+ return { input, fields: [] };
1653
+ const record = input;
1654
+ const probed = { ...record };
1655
+ const fields = [];
1656
+ for (const token of placeholdersIn(state.prompt)) {
1657
+ const field = placeholderField(token);
1658
+ if (field === '' || CONTRACT_INPUT_FIELDS.includes(field))
1659
+ continue;
1660
+ if (typeof record[field] !== 'string')
1661
+ continue;
1662
+ probed[field] = sentinelFor(field);
1663
+ if (!fields.includes(field))
1664
+ fields.push(field);
1665
+ }
1666
+ return { input: probed, fields };
1667
+ }
1668
+ /** Every canonical local role the artifact's player states declare (verification-1). */
1669
+ function declaredLocalRoles(config) {
1670
+ const roles = new Set();
1671
+ for (const state of enumerateCaptainStates(config)) {
1672
+ if (state.role !== undefined)
1673
+ roles.add(state.role);
1674
+ }
1675
+ return [...roles].sort();
1676
+ }
1519
1677
  function carriesSentinel(value, sentinel) {
1520
1678
  try {
1521
1679
  return (JSON.stringify(value) ?? '').includes(sentinel);
@@ -1531,14 +1689,17 @@ function carriesSentinel(value, sentinel) {
1531
1689
  */
1532
1690
  export function capturePromptContract(config) {
1533
1691
  const rows = [];
1692
+ const initial = initialMachineContext(config);
1534
1693
  for (const binding of enumerateCaptainBindings(config)) {
1535
1694
  const { state, inputFn } = binding;
1536
1695
  if (typeof inputFn !== 'function')
1537
1696
  continue;
1538
- const reads = probeContextReads(inputFn);
1697
+ const reads = probeContextReads(inputFn, initial);
1539
1698
  const wires = {};
1540
1699
  try {
1541
- const input = inputFn({ context: sentinelContext(reads) });
1700
+ const input = inputFn({
1701
+ context: ordinaryTurnContext(reads, initial, true),
1702
+ });
1542
1703
  if (typeof input === 'object' && input !== null) {
1543
1704
  for (const [key, value] of Object.entries(input)) {
1544
1705
  const carried = reads.filter((field) => carriesSentinel(value, sentinelFor(field)));
@@ -1570,6 +1731,8 @@ export function capturePromptContract(config) {
1570
1731
  */
1571
1732
  export function deriveSubstitutions(config, compose, actor) {
1572
1733
  const out = {};
1734
+ const roles = declaredLocalRoles(config);
1735
+ const initial = initialMachineContext(config);
1573
1736
  for (const binding of enumerateCaptainBindings(config)) {
1574
1737
  const { state, inputFn } = binding;
1575
1738
  if (actor !== undefined && state.actor !== actor)
@@ -1577,8 +1740,9 @@ export function deriveSubstitutions(config, compose, actor) {
1577
1740
  if (typeof inputFn !== 'function')
1578
1741
  continue;
1579
1742
  try {
1580
- const reads = probeContextReads(inputFn);
1581
- const composed = composeForState(compose, state, inputFn({ context: ordinaryContext(reads) }));
1743
+ const reads = probeContextReads(inputFn, initial);
1744
+ const probed = withPlaceholderValues(state, inputFn({ context: ordinaryTurnContext(reads, initial) }));
1745
+ const composed = composeForState(compose, state, probed.input, roles);
1582
1746
  if (typeof composed !== 'string') {
1583
1747
  out[state.stateId] = [];
1584
1748
  continue;
@@ -1589,7 +1753,7 @@ export function deriveSubstitutions(config, compose, actor) {
1589
1753
  // hide valid evidence, while merely deleting a token still cannot
1590
1754
  // masquerade as substitution.
1591
1755
  const evidenced = new Set();
1592
- const promptReads = promptSentinelFields(state, reads);
1756
+ const promptReads = promptSentinelFields(state, reads, probed.fields, roles);
1593
1757
  for (const line of state.prompt.split('\n')) {
1594
1758
  for (const token of matchPromptBody(line, composed, promptReads)
1595
1759
  ?.substitutions ?? []) {
@@ -1615,6 +1779,11 @@ export function deriveSubstitutions(config, compose, actor) {
1615
1779
  export function checkPromptComposition(opts) {
1616
1780
  const findings = [];
1617
1781
  const controller = isControllerMachine(opts.config);
1782
+ const roles = declaredLocalRoles(opts.config);
1783
+ const initial = initialMachineContext(opts.config);
1784
+ if (initial === undefined) {
1785
+ opts.diagnostics?.push('the machine has no resolvable initial context; ordinary-turn input is synthesized from context sentinels alone');
1786
+ }
1618
1787
  const schemaResolution = resolveArtifactSchemaForVerification({
1619
1788
  config: opts.config,
1620
1789
  ...(opts.artifactSchema === undefined
@@ -1632,12 +1801,14 @@ export function checkPromptComposition(opts) {
1632
1801
  continue;
1633
1802
  if (typeof inputFn !== 'function')
1634
1803
  continue;
1635
- const reads = probeContextReads(inputFn);
1636
- const promptReads = promptSentinelFields(state, reads);
1804
+ const reads = probeContextReads(inputFn, initial);
1637
1805
  const substituted = substitutions[state.stateId] ?? [];
1638
1806
  let ordinary;
1807
+ let promptReads;
1639
1808
  try {
1640
- ordinary = composeForState(opts.compose, state, inputFn({ context: ordinaryContext(reads) }));
1809
+ const probed = withPlaceholderValues(state, inputFn({ context: ordinaryTurnContext(reads, initial) }));
1810
+ promptReads = promptSentinelFields(state, reads, probed.fields, roles);
1811
+ ordinary = composeForState(opts.compose, state, probed.input, roles);
1641
1812
  if (typeof ordinary !== 'string') {
1642
1813
  throw new Error(`${composerName} returned a non-string value`);
1643
1814
  }
@@ -1692,9 +1863,9 @@ export function checkPromptComposition(opts) {
1692
1863
  let continuation;
1693
1864
  let input;
1694
1865
  try {
1695
- input = inputFn({
1866
+ const probed = withPlaceholderValues(state, inputFn({
1696
1867
  context: {
1697
- ...ordinaryContext(reads),
1868
+ ...ordinaryTurnContext(reads, initial),
1698
1869
  pendingBossQuestion,
1699
1870
  bossReply: reply,
1700
1871
  pendingBossQuestions: {
@@ -1702,8 +1873,10 @@ export function checkPromptComposition(opts) {
1702
1873
  },
1703
1874
  bossReplies: { [state.stateId]: reply },
1704
1875
  },
1705
- });
1706
- continuation = composeForState(opts.compose, state, input);
1876
+ }));
1877
+ input = probed.input;
1878
+ promptReads = promptSentinelFields(state, reads, probed.fields, roles);
1879
+ continuation = composeForState(opts.compose, state, input, roles);
1707
1880
  if (typeof continuation !== 'string') {
1708
1881
  throw new Error(`${composerName} returned a non-string value`);
1709
1882
  }
@@ -1746,12 +1919,13 @@ export function checkPromptComposition(opts) {
1746
1919
  }
1747
1920
  return findings;
1748
1921
  }
1749
- function promptSentinelFields(state, reads) {
1922
+ function promptSentinelFields(state, reads, placeholderFields, declaredRoles) {
1923
+ const fields = [...reads, ...placeholderFields];
1750
1924
  return state.role === undefined
1751
- ? [...reads]
1752
- : [...reads, `promptIdentity:${state.role}`];
1925
+ ? fields
1926
+ : [...fields, ...declaredRoles.map((role) => `promptIdentity:${role}`)];
1753
1927
  }
1754
- function composeForState(compose, state, input) {
1928
+ function composeForState(compose, state, input, declaredRoles) {
1755
1929
  // Schema-1 composers and the shared default composer use their second
1756
1930
  // positional argument as a placeholder-field map. A callable proxy with a
1757
1931
  // property-clean view is therefore both an invocation-scoped schema-3 lookup
@@ -1761,8 +1935,11 @@ function composeForState(compose, state, input) {
1761
1935
  if (state.role === undefined) {
1762
1936
  throw new Error(`prompt identity lookup used role ${JSON.stringify(roleId)} for a direct-Captain or historical state`);
1763
1937
  }
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)}`);
1938
+ // A prompt may name any declared role's identity — a Coder prompt reaches
1939
+ // the Reviewer's `<reviewer-llm>` — so only an undeclared role is drift
1940
+ // (link.md, "Player prompt composition").
1941
+ if (!declaredRoles.includes(roleId)) {
1942
+ throw new Error(`prompt identity lookup used undeclared role ${JSON.stringify(roleId)}; the artifact declares ${JSON.stringify(declaredRoles)}`);
1766
1943
  }
1767
1944
  return sentinelFor(`promptIdentity:${roleId}`);
1768
1945
  };
@@ -2316,6 +2493,135 @@ export function resolveArtifactSchemaForVerification(opts) {
2316
2493
  }
2317
2494
  : { findings: [] };
2318
2495
  }
2496
+ /** The `_internal` composer export serving one state actor (verification-5). */
2497
+ const COMPOSER_EXPORTS = {
2498
+ captain: 'composeCaptainPrompt',
2499
+ player: 'composePlayerPrompt',
2500
+ };
2501
+ /**
2502
+ * Runs the deterministic prompt-contract checks over one live linked module
2503
+ * beside its FSM (verification-5, verification-27): the module imports through
2504
+ * the ephemeral TypeScript FSM edge, its schema evidence reconciles, and every
2505
+ * actor the machine invokes has a composer that honors the link contract.
2506
+ *
2507
+ * Shared by the emitted suite's generator, which pins the derived substitutions
2508
+ * and degrades each observation to a diagnostic, and by the link-fidelity gate,
2509
+ * which relays the same observations as findings — so the gate adds no check of
2510
+ * its own (DR-030).
2511
+ */
2512
+ async function inspectLinkedPromptContract(opts) {
2513
+ const { config, evidence } = opts;
2514
+ const withoutModule = (extra) => {
2515
+ const resolution = resolveArtifactSchemaForVerification({
2516
+ config,
2517
+ ...evidence,
2518
+ });
2519
+ return {
2520
+ ...(resolution.artifactSchema === undefined
2521
+ ? {}
2522
+ : { artifactSchema: resolution.artifactSchema }),
2523
+ schemaFindings: resolution.findings,
2524
+ present: true,
2525
+ composers: [],
2526
+ ...extra,
2527
+ };
2528
+ };
2529
+ if (!existsSync(opts.linkedPath))
2530
+ return withoutModule({ present: false });
2531
+ let linked;
2532
+ try {
2533
+ linked = (await loadLinkedModuleForVerification({
2534
+ linkedPath: opts.linkedPath,
2535
+ fsmPath: opts.fsmPath,
2536
+ }));
2537
+ }
2538
+ catch (error) {
2539
+ return withoutModule({ loadError: messageOf(error) });
2540
+ }
2541
+ const resolution = resolveArtifactSchemaForVerification({
2542
+ config,
2543
+ linked,
2544
+ ...evidence,
2545
+ });
2546
+ const artifactSchema = resolution.artifactSchema;
2547
+ const actors = new Set(enumerateCaptainStates(config).map(({ actor }) => actor));
2548
+ const composers = [];
2549
+ for (const actor of ['captain', 'player']) {
2550
+ if (!actors.has(actor))
2551
+ continue;
2552
+ const exportName = COMPOSER_EXPORTS[actor];
2553
+ const compose = linked._internal?.[exportName];
2554
+ if (typeof compose !== 'function') {
2555
+ composers.push({ actor, exportName, findings: [], diagnostics: [] });
2556
+ continue;
2557
+ }
2558
+ const typedCompose = compose;
2559
+ const diagnostics = [];
2560
+ composers.push({
2561
+ actor,
2562
+ exportName,
2563
+ substitutions: deriveSubstitutions(config, typedCompose, actor),
2564
+ findings: checkPromptComposition({
2565
+ config,
2566
+ compose: typedCompose,
2567
+ actor,
2568
+ ...(artifactSchema === undefined ? {} : { artifactSchema }),
2569
+ diagnostics,
2570
+ }),
2571
+ diagnostics,
2572
+ });
2573
+ }
2574
+ return {
2575
+ ...(artifactSchema === undefined ? {} : { artifactSchema }),
2576
+ schemaFindings: resolution.findings,
2577
+ present: true,
2578
+ composers,
2579
+ };
2580
+ }
2581
+ /**
2582
+ * The deterministic link-fidelity checks over one live linked `playbook` module
2583
+ * beside its FSM (verification-27): exactly the checks the emitted
2584
+ * prompt-contract suite asserts, decided from those two paths alone and
2585
+ * returning one finding per violation.
2586
+ *
2587
+ * The gate that runs this before any Reviewer call must never throw into the
2588
+ * executor it guards (DR-030, phase-execution-53), so a module that cannot be
2589
+ * imported yields its import diagnostic as a finding. An absent module or FSM
2590
+ * yields none: an artifact the performing call has not written is the generic
2591
+ * target check's business (phase-execution-4). An FSM that cannot be imported
2592
+ * yields none either — the suite's checks derive from that machine, so they
2593
+ * degrade exactly as emission degrades its prompt-contract test to a
2594
+ * diagnostic (verification-5).
2595
+ */
2596
+ export async function checkLinkedModuleContract(opts) {
2597
+ if (!existsSync(opts.linkedPath) || !existsSync(opts.fsmPath))
2598
+ return [];
2599
+ let config;
2600
+ try {
2601
+ config = findMachineConfig(await loadFsmModule(opts.fsmPath));
2602
+ }
2603
+ catch {
2604
+ return [];
2605
+ }
2606
+ const contract = await inspectLinkedPromptContract({
2607
+ config,
2608
+ linkedPath: opts.linkedPath,
2609
+ fsmPath: opts.fsmPath,
2610
+ evidence: {},
2611
+ });
2612
+ const findings = [...contract.schemaFindings];
2613
+ if (contract.loadError !== undefined) {
2614
+ findings.push(`linked module could not be imported: ${contract.loadError}`);
2615
+ return findings;
2616
+ }
2617
+ // A module exposing no matching composer degrades exactly as emission does
2618
+ // (verification-5): the suite asserts nothing about it, so neither does the
2619
+ // gate that reuses the suite's checks.
2620
+ for (const composer of contract.composers) {
2621
+ findings.push(...composer.findings);
2622
+ }
2623
+ return findings;
2624
+ }
2319
2625
  /**
2320
2626
  * Emits the prompt-contract test beside a compiled `playbook` artifact
2321
2627
  * (verification-5): derives and pins the per-state contract from the physical
@@ -2344,66 +2650,39 @@ export async function emitPromptContractTest(opts) {
2344
2650
  ? {}
2345
2651
  : { artifactSchema: opts.artifactSchema }),
2346
2652
  };
2347
- let schemaResolution = resolveArtifactSchemaForVerification({
2653
+ const contract = await inspectLinkedPromptContract({
2348
2654
  config,
2349
- ...evidence,
2655
+ linkedPath: join(opts.artifactDir, `${opts.basename}.playbook.ts`),
2656
+ fsmPath,
2657
+ evidence,
2350
2658
  });
2351
- let artifactSchema = schemaResolution.artifactSchema;
2352
- let composer;
2353
- const linkedPath = join(opts.artifactDir, `${opts.basename}.playbook.ts`);
2354
- if (existsSync(linkedPath)) {
2355
- try {
2356
- const linked = (await loadLinkedModuleForVerification({
2357
- linkedPath,
2358
- fsmPath,
2359
- }));
2360
- schemaResolution = resolveArtifactSchemaForVerification({
2361
- config,
2362
- linked,
2363
- ...evidence,
2364
- });
2365
- artifactSchema = schemaResolution.artifactSchema;
2366
- const actors = new Set(enumerateCaptainStates(config).map(({ actor }) => actor));
2367
- const substitutions = {};
2368
- for (const actor of ['captain', 'player']) {
2369
- if (!actors.has(actor))
2370
- continue;
2371
- const exportName = actor === 'captain' ? 'composeCaptainPrompt' : 'composePlayerPrompt';
2372
- const compose = linked._internal?.[exportName];
2373
- if (typeof compose !== 'function') {
2374
- diagnostics.push(`prompt contract: linked module exposes no _internal.${exportName}; ${actor} composition checks not emitted`);
2375
- continue;
2376
- }
2377
- const typedCompose = compose;
2378
- substitutions[actor] = deriveSubstitutions(config, typedCompose, actor);
2379
- const findings = checkPromptComposition({
2380
- config,
2381
- compose: typedCompose,
2382
- actor,
2383
- ...(artifactSchema === undefined ? {} : { artifactSchema }),
2384
- });
2385
- diagnostics.push(...findings.map((finding) => `prompt contract: ${finding}`));
2386
- }
2387
- if (substitutions.captain !== undefined ||
2388
- substitutions.player !== undefined) {
2389
- composer = {
2390
- playbookModule: `./${opts.basename}.playbook.js`,
2391
- ...substitutions,
2392
- };
2393
- }
2394
- }
2395
- catch (error) {
2396
- diagnostics.push(`prompt contract: linked module could not be imported (${messageOf(error)}); composition checks not emitted`);
2659
+ const artifactSchema = contract.artifactSchema;
2660
+ if (contract.loadError !== undefined) {
2661
+ diagnostics.push(`prompt contract: linked module could not be imported (${contract.loadError}); composition checks not emitted`);
2662
+ }
2663
+ const substitutions = {};
2664
+ for (const inspected of contract.composers) {
2665
+ if (inspected.substitutions === undefined) {
2666
+ diagnostics.push(`prompt contract: linked module exposes no _internal.${inspected.exportName}; ${inspected.actor} composition checks not emitted`);
2667
+ continue;
2397
2668
  }
2669
+ substitutions[inspected.actor] = inspected.substitutions;
2670
+ diagnostics.push(...inspected.diagnostics.map((diagnostic) => `prompt contract: ${diagnostic}`), ...inspected.findings.map((finding) => `prompt contract: ${finding}`));
2398
2671
  }
2399
- diagnostics.unshift(...schemaResolution.findings.map((finding) => `prompt contract: ${finding}`));
2672
+ const composer = substitutions.captain !== undefined || substitutions.player !== undefined
2673
+ ? {
2674
+ playbookModule: `./${opts.basename}.playbook.js`,
2675
+ ...substitutions,
2676
+ }
2677
+ : undefined;
2678
+ diagnostics.unshift(...contract.schemaFindings.map((finding) => `prompt contract: ${finding}`));
2400
2679
  const content = generatePromptContractTest({
2401
2680
  basename: opts.basename,
2402
2681
  fsmModule: `./${opts.basename}.fsm.js`,
2403
2682
  verifyModule: opts.verifyModule ?? VERIFY_MODULE,
2404
2683
  rows,
2405
2684
  ...(artifactSchema === undefined ? {} : { artifactSchema }),
2406
- schemaFindings: schemaResolution.findings,
2685
+ schemaFindings: contract.schemaFindings,
2407
2686
  composer,
2408
2687
  });
2409
2688
  await mkdir(opts.artifactDir, { recursive: true });