@cleocode/playbooks 2026.6.12 → 2026.6.13

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.
@@ -453,4 +453,115 @@ nodes:
453
453
  }
454
454
  });
455
455
  });
456
+
457
+ describe('T11806: edge.when branch guard', () => {
458
+ const base = `
459
+ version: "1.0"
460
+ name: branch
461
+ nodes:
462
+ - id: classify
463
+ type: agentic
464
+ skill: x
465
+ - id: a
466
+ type: agentic
467
+ skill: y
468
+ - id: b
469
+ type: agentic
470
+ skill: z
471
+ `;
472
+
473
+ it('parses an equals/notEquals guarded branch', () => {
474
+ const { definition } = parsePlaybook(`${base}edges:
475
+ - from: classify
476
+ to: a
477
+ when:
478
+ field: needsResearch
479
+ equals: true
480
+ - from: classify
481
+ to: b
482
+ when:
483
+ field: needsResearch
484
+ notEquals: true
485
+ `);
486
+ expect(definition.edges).toHaveLength(2);
487
+ expect(definition.edges[0]?.when).toEqual({ field: 'needsResearch', equals: true });
488
+ expect(definition.edges[1]?.when).toEqual({ field: 'needsResearch', notEquals: true });
489
+ });
490
+
491
+ it('parses exists / truthy / in operators', () => {
492
+ const { definition } = parsePlaybook(`${base}edges:
493
+ - from: classify
494
+ to: a
495
+ when:
496
+ field: route
497
+ in: [x, y, z]
498
+ - from: classify
499
+ to: b
500
+ when:
501
+ field: route
502
+ exists: false
503
+ `);
504
+ expect(definition.edges[0]?.when).toEqual({ field: 'route', in: ['x', 'y', 'z'] });
505
+ expect(definition.edges[1]?.when).toEqual({ field: 'route', exists: false });
506
+ });
507
+
508
+ it('rejects a when guard with no operator', () => {
509
+ expect(() =>
510
+ parsePlaybook(`${base}edges:
511
+ - from: classify
512
+ to: a
513
+ when:
514
+ field: route
515
+ `),
516
+ ).toThrow(/must declare exactly one of/);
517
+ });
518
+
519
+ it('rejects a when guard with multiple operators', () => {
520
+ expect(() =>
521
+ parsePlaybook(`${base}edges:
522
+ - from: classify
523
+ to: a
524
+ when:
525
+ field: route
526
+ equals: x
527
+ exists: true
528
+ `),
529
+ ).toThrow(/declares multiple operators/);
530
+ });
531
+
532
+ it('rejects a when guard with a missing field', () => {
533
+ expect(() =>
534
+ parsePlaybook(`${base}edges:
535
+ - from: classify
536
+ to: a
537
+ when:
538
+ equals: x
539
+ `),
540
+ ).toThrow(/field must be a non-empty string/);
541
+ });
542
+
543
+ it('rejects a non-boolean exists operator', () => {
544
+ expect(() =>
545
+ parsePlaybook(`${base}edges:
546
+ - from: classify
547
+ to: a
548
+ when:
549
+ field: route
550
+ exists: "yes"
551
+ `),
552
+ ).toThrow(/exists must be a boolean/);
553
+ });
554
+
555
+ it('rejects an empty in array', () => {
556
+ expect(() =>
557
+ parsePlaybook(`${base}edges:
558
+ - from: classify
559
+ to: a
560
+ when:
561
+ field: route
562
+ in: []
563
+ `),
564
+ ).toThrow(/in must be a non-empty array/);
565
+ });
566
+ });
456
567
  });
@@ -8,9 +8,10 @@
8
8
  * @task T930 — Playbook Runtime State Machine
9
9
  */
10
10
 
11
- import { readFileSync } from 'node:fs';
11
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
12
12
  import { createRequire } from 'node:module';
13
- import { dirname, resolve } from 'node:path';
13
+ import { tmpdir } from 'node:os';
14
+ import { dirname, join, resolve } from 'node:path';
14
15
  import type { DatabaseSync as _DatabaseSyncType } from 'node:sqlite';
15
16
  import { fileURLToPath } from 'node:url';
16
17
  import type {
@@ -21,6 +22,7 @@ import type {
21
22
  } from '@cleocode/contracts';
22
23
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
23
24
  import { approveGate, rejectGate } from '../approval.js';
25
+ import { parsePlaybook } from '../parser.js';
24
26
  import {
25
27
  type AgentDispatcher,
26
28
  type AgentDispatchInput,
@@ -45,6 +47,7 @@ const MIGRATION_SQL = resolve(
45
47
  __dirname,
46
48
  '../../../core/migrations/drizzle-tasks/20260417220000_t889-playbook-tables/migration.sql',
47
49
  );
50
+ const CONDITIONAL_BRANCH_CANTBOOK = resolve(__dirname, 'fixtures/conditional-branch.cantbook');
48
51
 
49
52
  function applyMigration(db: DatabaseSync, sql: string): void {
50
53
  const statements = sql
@@ -359,7 +362,7 @@ describe('W4-10 / T930: playbook runtime state machine', () => {
359
362
  });
360
363
 
361
364
  // 6 ------------------------------------------------------------------------
362
- it('invalid node: multi-successor fan-out throws runtime invariant error', async () => {
365
+ it('invalid node: unconditional multi-successor fan-out throws runtime invariant error', async () => {
363
366
  const playbook: PlaybookDefinition = {
364
367
  version: '1.0',
365
368
  name: 'fanout',
@@ -379,7 +382,7 @@ describe('W4-10 / T930: playbook runtime state machine', () => {
379
382
  initialContext: {},
380
383
  dispatcher,
381
384
  }),
382
- ).rejects.toThrow(/has 2 successors/);
385
+ ).rejects.toThrow(/has 2 unconditional successors/);
383
386
  });
384
387
 
385
388
  // 7 ------------------------------------------------------------------------
@@ -676,3 +679,498 @@ describe('W4-10 / T930: playbook runtime state machine', () => {
676
679
  expect(runs[0]?.completed_at).toBe('2026-04-17T22:30:00.000Z');
677
680
  });
678
681
  });
682
+
683
+ // ---------------------------------------------------------------------------
684
+ // T11806 — guarded conditional branching
685
+ // ---------------------------------------------------------------------------
686
+
687
+ describe('T11806: cantbook runtime guarded branching', () => {
688
+ let db: DatabaseSync;
689
+
690
+ beforeEach(() => {
691
+ db = new DatabaseSync(':memory:');
692
+ db.exec('PRAGMA foreign_keys=ON');
693
+ applyMigration(db, readFileSync(MIGRATION_SQL, 'utf8'));
694
+ });
695
+ afterEach(() => db.close());
696
+
697
+ // B1 -----------------------------------------------------------------------
698
+ it('2-branch .cantbook fixture: condition-true routes branch-A (deep-research)', async () => {
699
+ const { definition, sourceHash } = parsePlaybook(
700
+ readFileSync(CONDITIONAL_BRANCH_CANTBOOK, 'utf8'),
701
+ );
702
+ // classify emits needsResearch=true → deep-research branch taken.
703
+ const dispatcher = makeRecordingDispatcher((input) => {
704
+ if (input.nodeId === 'classify') {
705
+ return { status: 'success', output: { needsResearch: true } };
706
+ }
707
+ return { status: 'success', output: { [`${input.nodeId}_done`]: true } };
708
+ });
709
+
710
+ const result = await executePlaybook({
711
+ db,
712
+ playbook: definition,
713
+ playbookHash: sourceHash,
714
+ initialContext: { taskId: 'T-A' },
715
+ dispatcher,
716
+ });
717
+
718
+ expect(result.terminalStatus).toBe('completed');
719
+ const visited = dispatcher.calls.map((c) => c.nodeId);
720
+ expect(visited).toEqual(['classify', 'deep-research', 'ship']);
721
+ // Branch-B (skip-research) was NOT executed.
722
+ expect(visited).not.toContain('skip-research');
723
+ expect(result.finalContext).toMatchObject({
724
+ taskId: 'T-A',
725
+ needsResearch: true,
726
+ 'deep-research_done': true,
727
+ ship_done: true,
728
+ });
729
+ });
730
+
731
+ // B2 -----------------------------------------------------------------------
732
+ it('2-branch .cantbook fixture: condition-false routes branch-B (skip-research)', async () => {
733
+ const { definition, sourceHash } = parsePlaybook(
734
+ readFileSync(CONDITIONAL_BRANCH_CANTBOOK, 'utf8'),
735
+ );
736
+ // classify emits needsResearch=false → skip-research branch taken.
737
+ const dispatcher = makeRecordingDispatcher((input) => {
738
+ if (input.nodeId === 'classify') {
739
+ return { status: 'success', output: { needsResearch: false } };
740
+ }
741
+ return { status: 'success', output: { [`${input.nodeId}_done`]: true } };
742
+ });
743
+
744
+ const result = await executePlaybook({
745
+ db,
746
+ playbook: definition,
747
+ playbookHash: sourceHash,
748
+ initialContext: { taskId: 'T-B' },
749
+ dispatcher,
750
+ });
751
+
752
+ expect(result.terminalStatus).toBe('completed');
753
+ const visited = dispatcher.calls.map((c) => c.nodeId);
754
+ expect(visited).toEqual(['classify', 'skip-research', 'ship']);
755
+ // Branch-A (deep-research) was NOT executed.
756
+ expect(visited).not.toContain('deep-research');
757
+ expect(result.finalContext).toMatchObject({
758
+ taskId: 'T-B',
759
+ needsResearch: false,
760
+ 'skip-research_done': true,
761
+ ship_done: true,
762
+ });
763
+ });
764
+
765
+ // B3 -----------------------------------------------------------------------
766
+ it('fail-closed: all branch predicates unsatisfiable throws no-reachable-default', async () => {
767
+ // Both edges are guarded; neither matches when needsResearch is undefined.
768
+ const playbook: PlaybookDefinition = {
769
+ version: '1.0',
770
+ name: 'dead-branch',
771
+ nodes: [agenticNode('classify'), agenticNode('a'), agenticNode('b')],
772
+ edges: [
773
+ { from: 'classify', to: 'a', when: { field: 'route', equals: 'x' } },
774
+ { from: 'classify', to: 'b', when: { field: 'route', equals: 'y' } },
775
+ ],
776
+ };
777
+ const dispatcher = makeRecordingDispatcher(() => ({
778
+ status: 'success',
779
+ output: { route: 'z' }, // matches neither guard
780
+ }));
781
+
782
+ await expect(
783
+ executePlaybook({
784
+ db,
785
+ playbook,
786
+ playbookHash: 'hash-b3',
787
+ initialContext: {},
788
+ dispatcher,
789
+ }),
790
+ ).rejects.toThrow(/no .*when.* predicate is satisfied|no reachable default/);
791
+ });
792
+
793
+ // B4 -----------------------------------------------------------------------
794
+ it('ambiguous: more than one satisfied guarded successor throws mutually-exclusive', async () => {
795
+ const playbook: PlaybookDefinition = {
796
+ version: '1.0',
797
+ name: 'ambiguous-branch',
798
+ nodes: [agenticNode('classify'), agenticNode('a'), agenticNode('b')],
799
+ edges: [
800
+ { from: 'classify', to: 'a', when: { field: 'flag', truthy: true } },
801
+ { from: 'classify', to: 'b', when: { field: 'flag', exists: true } },
802
+ ],
803
+ };
804
+ const dispatcher = makeRecordingDispatcher(() => ({
805
+ status: 'success',
806
+ output: { flag: 1 }, // satisfies BOTH guards (truthy AND exists)
807
+ }));
808
+
809
+ await expect(
810
+ executePlaybook({
811
+ db,
812
+ playbook,
813
+ playbookHash: 'hash-b4',
814
+ initialContext: {},
815
+ dispatcher,
816
+ }),
817
+ ).rejects.toThrow(/satisfied successors|mutually exclusive/);
818
+ });
819
+
820
+ // B5 -----------------------------------------------------------------------
821
+ it('regression: linear playbook (all edges unconditional) unchanged by branching', async () => {
822
+ const playbook = linearPlaybook('linear-regress', ['a', 'b', 'c']);
823
+ const dispatcher = makeRecordingDispatcher((input) => ({
824
+ status: 'success',
825
+ output: { [`${input.nodeId}_ok`]: true },
826
+ }));
827
+
828
+ const result = await executePlaybook({
829
+ db,
830
+ playbook,
831
+ playbookHash: 'hash-b5',
832
+ initialContext: { taskId: 'T-LIN' },
833
+ dispatcher,
834
+ });
835
+
836
+ expect(result.terminalStatus).toBe('completed');
837
+ expect(dispatcher.calls.map((c) => c.nodeId)).toEqual(['a', 'b', 'c']);
838
+ });
839
+
840
+ // B6 -----------------------------------------------------------------------
841
+ it('mixed: one unconditional default + one guarded edge routes guarded when satisfied', async () => {
842
+ // classify has an unconditional fallback (to b) plus a guarded fast-path
843
+ // (to a). When the guard holds, exactly two successors are "satisfied"
844
+ // (the guarded one + the unconditional fallback) → ambiguous, so a sound
845
+ // authoring requires the fallback ALSO be guarded. This test asserts the
846
+ // fail-closed ambiguity guard fires for the unsound mixed shape.
847
+ const playbook: PlaybookDefinition = {
848
+ version: '1.0',
849
+ name: 'mixed-default',
850
+ nodes: [agenticNode('classify'), agenticNode('a'), agenticNode('b')],
851
+ edges: [
852
+ { from: 'classify', to: 'a', when: { field: 'fast', truthy: true } },
853
+ { from: 'classify', to: 'b' }, // unconditional default
854
+ ],
855
+ };
856
+ const dispatcher = makeRecordingDispatcher(() => ({
857
+ status: 'success',
858
+ output: { fast: true },
859
+ }));
860
+
861
+ await expect(
862
+ executePlaybook({
863
+ db,
864
+ playbook,
865
+ playbookHash: 'hash-b6',
866
+ initialContext: {},
867
+ dispatcher,
868
+ }),
869
+ ).rejects.toThrow(/satisfied successors|mutually exclusive/);
870
+ });
871
+
872
+ // B7 -----------------------------------------------------------------------
873
+ it('guarded single sole-successor: unsatisfied guard on the only edge fails closed', async () => {
874
+ const playbook: PlaybookDefinition = {
875
+ version: '1.0',
876
+ name: 'sole-guarded',
877
+ nodes: [agenticNode('a'), agenticNode('b')],
878
+ edges: [{ from: 'a', to: 'b', when: { field: 'go', equals: 'never' } }],
879
+ };
880
+ const dispatcher = makeRecordingDispatcher(() => ({
881
+ status: 'success',
882
+ output: { go: 'stop' },
883
+ }));
884
+
885
+ await expect(
886
+ executePlaybook({
887
+ db,
888
+ playbook,
889
+ playbookHash: 'hash-b7',
890
+ initialContext: {},
891
+ dispatcher,
892
+ }),
893
+ ).rejects.toThrow(/no reachable default/);
894
+ });
895
+ });
896
+
897
+ // ---------------------------------------------------------------------------
898
+ // T11762 ST-2: registry-driven ensures.schema enforcement
899
+ // ---------------------------------------------------------------------------
900
+
901
+ /**
902
+ * These tests cover the de-hardcoded `ensures.schema` runtime path (T11762):
903
+ *
904
+ * - a KNOWN registered schema (`task_tree` / `evidence`) is ENFORCED via the
905
+ * ensures-schema Zod registry and a failing shape is routed through the
906
+ * EXISTING `contract_violation` handler — `abort` / `hitl_escalate` /
907
+ * `inject_hint` all produce their pre-existing terminal/continue behavior;
908
+ * - an UNKNOWN schema name now FAILS CLOSED (previously silently skipped) and
909
+ * is audited to `.cleo/audit/contract-violations.jsonl`;
910
+ * - a VALID shape passes through with no violation.
911
+ *
912
+ * The runtime re-applies the historical `ensures.schema[<name>] on <nodeId>: `
913
+ * prefix to the first Zod issue message, so the end-to-end violation strings
914
+ * stay parity-identical to the deleted bespoke validators.
915
+ *
916
+ * @task T11902 — ST-2
917
+ * @epic T11762
918
+ */
919
+ describe('T11762 ST-2: registry-driven ensures.schema enforcement', () => {
920
+ let db: DatabaseSync;
921
+ let tmpRoot: string;
922
+
923
+ beforeEach(() => {
924
+ db = new DatabaseSync(':memory:');
925
+ db.exec('PRAGMA foreign_keys=ON');
926
+ applyMigration(db, readFileSync(MIGRATION_SQL, 'utf8'));
927
+ tmpRoot = mkdtempSync(join(tmpdir(), 'cleo-ensures-schema-'));
928
+ });
929
+ afterEach(() => {
930
+ db.close();
931
+ rmSync(tmpRoot, { recursive: true, force: true });
932
+ });
933
+
934
+ /** Read the contract-violation audit log as parsed NDJSON rows (or `[]`). */
935
+ function readAuditLog(root: string): Array<Record<string, unknown>> {
936
+ const file = join(root, '.cleo', 'audit', 'contract-violations.jsonl');
937
+ if (!existsSync(file)) return [];
938
+ return readFileSync(file, 'utf8')
939
+ .split('\n')
940
+ .filter((l) => l.trim().length > 0)
941
+ .map((l) => JSON.parse(l) as Record<string, unknown>);
942
+ }
943
+
944
+ /**
945
+ * Single agentic node that emits `output` and declares `ensures.schema`.
946
+ * The optional `errorHandlerAction` wires a `contract_violation` handler.
947
+ */
948
+ function ensuresSchemaPlaybook(
949
+ schema: string,
950
+ errorHandlerAction?: 'abort' | 'hitl_escalate' | 'inject_hint',
951
+ ): PlaybookDefinition {
952
+ return {
953
+ version: '1.0',
954
+ name: `ensures-${schema}`,
955
+ nodes: [agenticNode('emit', { ensures: { schema } })],
956
+ edges: [],
957
+ ...(errorHandlerAction
958
+ ? { error_handlers: [{ on: 'contract_violation', action: errorHandlerAction }] }
959
+ : {}),
960
+ };
961
+ }
962
+
963
+ // 1 ------------------------------------------------------------------------
964
+ it('valid task_tree shape passes — no violation, run completes', async () => {
965
+ const playbook = ensuresSchemaPlaybook('task_tree');
966
+ const dispatcher = makeRecordingDispatcher(() => ({
967
+ status: 'success',
968
+ output: { task_tree: [{ title: 'Build X', acceptance: ['does X'] }] },
969
+ }));
970
+
971
+ const result = await executePlaybook({
972
+ db,
973
+ playbook,
974
+ playbookHash: 'hash-es-1',
975
+ initialContext: {},
976
+ dispatcher,
977
+ projectRoot: tmpRoot,
978
+ });
979
+
980
+ expect(result.terminalStatus).toBe('completed');
981
+ expect(result.finalContext['__ensuresSchemaViolation']).toBeUndefined();
982
+ expect(readAuditLog(tmpRoot)).toHaveLength(0);
983
+ });
984
+
985
+ // 2 ------------------------------------------------------------------------
986
+ it('invalid task_tree (empty array) + abort handler → failed, parity message, audited', async () => {
987
+ const playbook = ensuresSchemaPlaybook('task_tree', 'abort');
988
+ const dispatcher = makeRecordingDispatcher(() => ({
989
+ status: 'success',
990
+ output: { task_tree: [] },
991
+ }));
992
+
993
+ const result = await executePlaybook({
994
+ db,
995
+ playbook,
996
+ playbookHash: 'hash-es-2',
997
+ initialContext: {},
998
+ dispatcher,
999
+ projectRoot: tmpRoot,
1000
+ });
1001
+
1002
+ expect(result.terminalStatus).toBe('failed');
1003
+ expect(result.failedNodeId).toBe('emit');
1004
+ // Parity: prefix re-applied around the ported Zod issue message.
1005
+ expect(result.errorContext).toBe(
1006
+ 'ensures.schema[task_tree] on emit: task_tree is an empty array — decomposition produced no tasks',
1007
+ );
1008
+ const audit = readAuditLog(tmpRoot);
1009
+ expect(audit).toHaveLength(1);
1010
+ expect(audit[0]).toMatchObject({ nodeId: 'emit', field: 'ensures', key: 'task_tree' });
1011
+ });
1012
+
1013
+ // 3 ------------------------------------------------------------------------
1014
+ it('invalid task_tree (missing title) → parity message carries the path suffix', async () => {
1015
+ const playbook = ensuresSchemaPlaybook('task_tree', 'abort');
1016
+ const dispatcher = makeRecordingDispatcher(() => ({
1017
+ status: 'success',
1018
+ output: { task_tree: [{ acceptance: ['ac1'] }] },
1019
+ }));
1020
+
1021
+ const result = await executePlaybook({
1022
+ db,
1023
+ playbook,
1024
+ playbookHash: 'hash-es-3',
1025
+ initialContext: {},
1026
+ dispatcher,
1027
+ projectRoot: tmpRoot,
1028
+ });
1029
+
1030
+ expect(result.terminalStatus).toBe('failed');
1031
+ // First Zod issue is at path [0, 'title']; the runtime appends ` at 0.title`.
1032
+ expect(result.errorContext).toBe(
1033
+ 'ensures.schema[task_tree] on emit: title must be a non-empty string at 0.title',
1034
+ );
1035
+ });
1036
+
1037
+ // 4 ------------------------------------------------------------------------
1038
+ it('invalid evidence ({}) + hitl_escalate handler → exceeded_iteration_cap, stashed, audited', async () => {
1039
+ const playbook = ensuresSchemaPlaybook('evidence', 'hitl_escalate');
1040
+ const dispatcher = makeRecordingDispatcher(() => ({
1041
+ status: 'success',
1042
+ output: { evidence: {} },
1043
+ }));
1044
+
1045
+ const result = await executePlaybook({
1046
+ db,
1047
+ playbook,
1048
+ playbookHash: 'hash-es-4',
1049
+ initialContext: {},
1050
+ dispatcher,
1051
+ projectRoot: tmpRoot,
1052
+ });
1053
+
1054
+ // hitl_escalate sets exceededNodeId (NOT failedNodeId) — preserved behavior.
1055
+ // `errorContext` is only mirrored on the `abort` path (it sets `lastError`),
1056
+ // so for hitl_escalate the violation surfaces via the stashed context key.
1057
+ expect(result.terminalStatus).toBe('exceeded_iteration_cap');
1058
+ expect(result.exceededNodeId).toBe('emit');
1059
+ // hitl_escalate stashes the violation in context (does not abort).
1060
+ expect(result.finalContext['__ensuresSchemaViolation']).toBe(
1061
+ 'ensures.schema[evidence] on emit: evidence object must have at least one key (got {})',
1062
+ );
1063
+ expect(readAuditLog(tmpRoot)).toHaveLength(1);
1064
+ });
1065
+
1066
+ // 5 ------------------------------------------------------------------------
1067
+ it('invalid evidence (numeric) + inject_hint handler → run completes, hint stashed', async () => {
1068
+ const playbook = ensuresSchemaPlaybook('evidence', 'inject_hint');
1069
+ const dispatcher = makeRecordingDispatcher(() => ({
1070
+ status: 'success',
1071
+ output: { evidence: 42 },
1072
+ }));
1073
+
1074
+ const result = await executePlaybook({
1075
+ db,
1076
+ playbook,
1077
+ playbookHash: 'hash-es-5',
1078
+ initialContext: {},
1079
+ dispatcher,
1080
+ projectRoot: tmpRoot,
1081
+ });
1082
+
1083
+ // inject_hint does not abort/escalate — the run continues to completion.
1084
+ expect(result.terminalStatus).toBe('completed');
1085
+ expect(result.finalContext['__ensuresSchemaViolation']).toBe(
1086
+ 'ensures.schema[evidence] on emit: evidence must be a string, array, or object (got number)',
1087
+ );
1088
+ expect(readAuditLog(tmpRoot)).toHaveLength(1);
1089
+ });
1090
+
1091
+ // 6 ------------------------------------------------------------------------
1092
+ it('UNKNOWN schema name now FAILS CLOSED (was silently skipped) + audited', async () => {
1093
+ const playbook = ensuresSchemaPlaybook('not_a_registered_schema', 'abort');
1094
+ const dispatcher = makeRecordingDispatcher(() => ({
1095
+ status: 'success',
1096
+ output: { whatever: true },
1097
+ }));
1098
+
1099
+ const result = await executePlaybook({
1100
+ db,
1101
+ playbook,
1102
+ playbookHash: 'hash-es-6',
1103
+ initialContext: {},
1104
+ dispatcher,
1105
+ projectRoot: tmpRoot,
1106
+ });
1107
+
1108
+ expect(result.terminalStatus).toBe('failed');
1109
+ expect(result.failedNodeId).toBe('emit');
1110
+ expect(result.errorContext).toMatch(
1111
+ /^ensures\.schema\[not_a_registered_schema\] on emit: unknown schema name\. Registered: /,
1112
+ );
1113
+ // The valid registered set is listed for the author.
1114
+ expect(result.errorContext).toContain('task_tree');
1115
+ expect(result.errorContext).toContain('evidence');
1116
+ const audit = readAuditLog(tmpRoot);
1117
+ expect(audit).toHaveLength(1);
1118
+ expect(audit[0]).toMatchObject({ nodeId: 'emit', key: 'not_a_registered_schema' });
1119
+ });
1120
+
1121
+ // 7 ------------------------------------------------------------------------
1122
+ it('UNKNOWN schema name with NO handler also fails closed (default null → continue, hint stashed)', async () => {
1123
+ // With no contract_violation handler, handleContractErrorHandler returns
1124
+ // null → the violation is stashed and the run continues to completion. This
1125
+ // confirms fail-closed surfaces the violation even without a handler wired.
1126
+ const playbook = ensuresSchemaPlaybook('ghost_schema');
1127
+ const dispatcher = makeRecordingDispatcher(() => ({
1128
+ status: 'success',
1129
+ output: {},
1130
+ }));
1131
+
1132
+ const result = await executePlaybook({
1133
+ db,
1134
+ playbook,
1135
+ playbookHash: 'hash-es-7',
1136
+ initialContext: {},
1137
+ dispatcher,
1138
+ projectRoot: tmpRoot,
1139
+ });
1140
+
1141
+ expect(result.terminalStatus).toBe('completed');
1142
+ expect(result.finalContext['__ensuresSchemaViolation']).toMatch(
1143
+ /^ensures\.schema\[ghost_schema\] on emit: unknown schema name\./,
1144
+ );
1145
+ expect(readAuditLog(tmpRoot)).toHaveLength(1);
1146
+ });
1147
+
1148
+ // 8 ------------------------------------------------------------------------
1149
+ it('R1: legacy starter schema names (passthrough) still pass — no regression', async () => {
1150
+ // These names were SILENTLY SKIPPED pre-T11762; they are registered with the
1151
+ // passthrough schema so the fail-closed flip does not break the shipped
1152
+ // starter playbooks. Run a representative one with no context value present —
1153
+ // passthrough accepts `undefined`, so the run must complete cleanly.
1154
+ for (const schema of ['version_bump_report', 'specification_document', 'test_report']) {
1155
+ const playbook = ensuresSchemaPlaybook(schema, 'abort');
1156
+ const dispatcher = makeRecordingDispatcher(() => ({
1157
+ status: 'success',
1158
+ output: {},
1159
+ }));
1160
+
1161
+ const result = await executePlaybook({
1162
+ db,
1163
+ playbook,
1164
+ playbookHash: `hash-es-8-${schema}`,
1165
+ initialContext: {},
1166
+ dispatcher,
1167
+ projectRoot: tmpRoot,
1168
+ });
1169
+
1170
+ expect(result.terminalStatus).toBe('completed');
1171
+ expect(result.finalContext['__ensuresSchemaViolation']).toBeUndefined();
1172
+ }
1173
+ // No passthrough name produced a violation.
1174
+ expect(readAuditLog(tmpRoot)).toHaveLength(0);
1175
+ });
1176
+ });
package/src/index.ts CHANGED
@@ -59,7 +59,12 @@ export {
59
59
  type PolicyRule,
60
60
  } from './policy.js';
61
61
  // W4-10 / T930: playbook runtime state machine + HITL resume
62
- // T11499 E7-CLOSE-LOOPS AC3: schema validators exported for testing + external use
62
+ // T11762 ST-2: the bespoke `validateDecompositionTaskTree` /
63
+ // `validateIvtrEvidenceOutput` validators (and their barrel re-exports) were
64
+ // deleted — their rules are now the `task_tree` / `evidence` Zod schemas in the
65
+ // ensures-schema registry (`@cleocode/contracts` data + `@cleocode/core`
66
+ // accessors). No cross-package consumer imported them, so dropping the
67
+ // re-exports is safe.
63
68
  export {
64
69
  type AgentDispatcher,
65
70
  type AgentDispatchInput,
@@ -75,8 +80,6 @@ export {
75
80
  type PlaybookTerminalStatus,
76
81
  type ResumePlaybookOptions,
77
82
  resumePlaybook,
78
- validateDecompositionTaskTree,
79
- validateIvtrEvidenceOutput,
80
83
  } from './runtime.js';
81
84
  // W4-8: state layer CRUD for playbook_runs + playbook_approvals
82
85
  export {