@cleocode/playbooks 2026.6.12 → 2026.6.14
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.
- package/dist/index.d.ts +1 -1
- package/dist/index.js +9 -2
- package/dist/parser.js +114 -1
- package/dist/runtime.d.ts +92 -34
- package/dist/runtime.js +257 -149
- package/package.json +3 -3
- package/src/__tests__/fixtures/conditional-branch.cantbook +50 -0
- package/src/__tests__/fixtures/pi-skill-only.cantbook +26 -0
- package/src/__tests__/guarded-deterministic-dispatch.test.ts +225 -0
- package/src/__tests__/inputs-bindings.test.ts +250 -0
- package/src/__tests__/node-allowlist.test.ts +186 -0
- package/src/__tests__/node-profile.test.ts +142 -0
- package/src/__tests__/parser.test.ts +191 -0
- package/src/__tests__/pi-cantbook-e2e.test.ts +294 -0
- package/src/__tests__/rcasd-one-envelope.test.ts +239 -0
- package/src/__tests__/runtime.test.ts +502 -4
- package/src/index.ts +8 -3
- package/src/parser.ts +144 -1
- package/src/runtime.ts +363 -198
|
@@ -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 {
|
|
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
|
-
//
|
|
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,
|
|
@@ -74,9 +79,9 @@ export {
|
|
|
74
79
|
executePlaybook,
|
|
75
80
|
type PlaybookTerminalStatus,
|
|
76
81
|
type ResumePlaybookOptions,
|
|
82
|
+
// T1944 (M4): per-step `node.inputs` → dispatch-boundary binding resolver.
|
|
83
|
+
resolveStepBindings,
|
|
77
84
|
resumePlaybook,
|
|
78
|
-
validateDecompositionTaskTree,
|
|
79
|
-
validateIvtrEvidenceOutput,
|
|
80
85
|
} from './runtime.js';
|
|
81
86
|
// W4-8: state layer CRUD for playbook_runs + playbook_approvals
|
|
82
87
|
export {
|