@cleocode/playbooks 2026.4.128 → 2026.4.129
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 -0
- package/dist/index.js +3 -1
- package/dist/migrate-e4.d.ts +103 -0
- package/dist/migrate-e4.js +163 -0
- package/dist/parser.d.ts +2 -0
- package/dist/parser.js +4 -0
- package/dist/runtime.d.ts +16 -5
- package/dist/runtime.js +136 -2
- package/package.json +3 -3
- package/src/__tests__/migrate-e4.test.ts +251 -0
- package/src/__tests__/starter.e2e.test.ts +49 -1
- package/src/index.ts +10 -1
- package/src/migrate-e4.ts +232 -0
- package/src/parser.ts +5 -0
- package/src/runtime.ts +195 -6
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PSYCHE E4 migration tool tests.
|
|
3
|
+
*
|
|
4
|
+
* Validates the STRICT cutover compliance validator and migration function.
|
|
5
|
+
* No disk I/O; uses inline YAML strings.
|
|
6
|
+
*
|
|
7
|
+
* @task T1261 PSYCHE E4
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it } from 'vitest';
|
|
11
|
+
import { migratePlaybook, validatePlaybookCompliance } from '../migrate-e4.js';
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Shared fixtures
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
const MINIMAL_COMPLIANT = `
|
|
18
|
+
version: "1.0"
|
|
19
|
+
name: compliant
|
|
20
|
+
nodes:
|
|
21
|
+
- id: start
|
|
22
|
+
type: agentic
|
|
23
|
+
skill: ct-research-agent
|
|
24
|
+
requires:
|
|
25
|
+
from: ctx
|
|
26
|
+
fields: [input]
|
|
27
|
+
ensures:
|
|
28
|
+
schema: output_summary
|
|
29
|
+
edges: []
|
|
30
|
+
error_handlers:
|
|
31
|
+
- on: iteration_cap_exceeded
|
|
32
|
+
action: hitl_escalate
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
const MINIMAL_NON_COMPLIANT = `
|
|
36
|
+
version: "1.0"
|
|
37
|
+
name: legacy
|
|
38
|
+
nodes:
|
|
39
|
+
- id: start
|
|
40
|
+
type: agentic
|
|
41
|
+
skill: ct-research-agent
|
|
42
|
+
edges: []
|
|
43
|
+
`;
|
|
44
|
+
|
|
45
|
+
const MINIMAL_PARTIAL = `
|
|
46
|
+
version: "1.0"
|
|
47
|
+
name: partial
|
|
48
|
+
nodes:
|
|
49
|
+
- id: start
|
|
50
|
+
type: agentic
|
|
51
|
+
skill: ct-research-agent
|
|
52
|
+
requires:
|
|
53
|
+
fields: [input]
|
|
54
|
+
edges: []
|
|
55
|
+
`;
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// validatePlaybookCompliance
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
describe('T1261-E4: validatePlaybookCompliance', () => {
|
|
62
|
+
it('returns compliant=true for a fully-wired playbook', () => {
|
|
63
|
+
const report = validatePlaybookCompliance(MINIMAL_COMPLIANT);
|
|
64
|
+
expect(report.parses).toBe(true);
|
|
65
|
+
expect(report.compliant).toBe(true);
|
|
66
|
+
expect(report.hasErrorHandlers).toBe(true);
|
|
67
|
+
expect(report.nodesMissingRequires).toBe(0);
|
|
68
|
+
expect(report.nodesMissingEnsures).toBe(0);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('returns compliant=false when error_handlers is absent', () => {
|
|
72
|
+
const report = validatePlaybookCompliance(MINIMAL_PARTIAL);
|
|
73
|
+
expect(report.parses).toBe(true);
|
|
74
|
+
expect(report.compliant).toBe(false);
|
|
75
|
+
expect(report.hasErrorHandlers).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('returns compliant=false when nodes lack requires/ensures', () => {
|
|
79
|
+
const report = validatePlaybookCompliance(MINIMAL_NON_COMPLIANT);
|
|
80
|
+
expect(report.parses).toBe(true);
|
|
81
|
+
expect(report.compliant).toBe(false);
|
|
82
|
+
expect(report.nodesMissingRequires).toBe(1);
|
|
83
|
+
expect(report.nodesMissingEnsures).toBe(1);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('returns parses=false for invalid YAML', () => {
|
|
87
|
+
const report = validatePlaybookCompliance('version: "1.0"\n : broken');
|
|
88
|
+
expect(report.parses).toBe(false);
|
|
89
|
+
expect(report.compliant).toBe(false);
|
|
90
|
+
expect(report.parseError).toBeDefined();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('reports correct node breakdown', () => {
|
|
94
|
+
const report = validatePlaybookCompliance(MINIMAL_NON_COMPLIANT);
|
|
95
|
+
expect(report.nodes).toHaveLength(1);
|
|
96
|
+
expect(report.nodes[0]).toMatchObject({
|
|
97
|
+
id: 'start',
|
|
98
|
+
type: 'agentic',
|
|
99
|
+
hasRequires: false,
|
|
100
|
+
hasEnsures: false,
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('exempts approval nodes from requires/ensures check', () => {
|
|
105
|
+
const yaml = `
|
|
106
|
+
version: "1.0"
|
|
107
|
+
name: with-approval
|
|
108
|
+
nodes:
|
|
109
|
+
- id: work
|
|
110
|
+
type: agentic
|
|
111
|
+
skill: ct-task-executor
|
|
112
|
+
requires:
|
|
113
|
+
fields: [input]
|
|
114
|
+
ensures:
|
|
115
|
+
schema: output
|
|
116
|
+
- id: gate
|
|
117
|
+
type: approval
|
|
118
|
+
prompt: "Approve release?"
|
|
119
|
+
edges:
|
|
120
|
+
- from: work
|
|
121
|
+
to: gate
|
|
122
|
+
error_handlers:
|
|
123
|
+
- on: iteration_cap_exceeded
|
|
124
|
+
action: hitl_escalate
|
|
125
|
+
`;
|
|
126
|
+
const report = validatePlaybookCompliance(yaml);
|
|
127
|
+
expect(report.compliant).toBe(true);
|
|
128
|
+
expect(report.nodes.find((n) => n.id === 'gate')).toMatchObject({
|
|
129
|
+
type: 'approval',
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// migratePlaybook
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
describe('T1261-E4: migratePlaybook', () => {
|
|
139
|
+
it('adds error_handlers when absent', () => {
|
|
140
|
+
const migrated = migratePlaybook(MINIMAL_NON_COMPLIANT);
|
|
141
|
+
const report = validatePlaybookCompliance(migrated);
|
|
142
|
+
expect(report.hasErrorHandlers).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('adds requires/ensures stubs to work nodes', () => {
|
|
146
|
+
const migrated = migratePlaybook(MINIMAL_NON_COMPLIANT);
|
|
147
|
+
const report = validatePlaybookCompliance(migrated);
|
|
148
|
+
expect(report.nodesMissingRequires).toBe(0);
|
|
149
|
+
expect(report.nodesMissingEnsures).toBe(0);
|
|
150
|
+
expect(report.compliant).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('preserves existing error_handlers when present', () => {
|
|
154
|
+
const migrated = migratePlaybook(MINIMAL_PARTIAL);
|
|
155
|
+
// MINIMAL_PARTIAL has requires but no error_handlers/ensures
|
|
156
|
+
const report = validatePlaybookCompliance(migrated);
|
|
157
|
+
expect(report.parses).toBe(true);
|
|
158
|
+
// ensures gets added
|
|
159
|
+
expect(report.nodesMissingEnsures).toBe(0);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('does not double-add requires/ensures when already present', () => {
|
|
163
|
+
// Migrating a compliant playbook should return parseable output
|
|
164
|
+
const migrated = migratePlaybook(MINIMAL_COMPLIANT);
|
|
165
|
+
const report = validatePlaybookCompliance(migrated);
|
|
166
|
+
expect(report.compliant).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('throws PlaybookParseError on structurally invalid source', () => {
|
|
170
|
+
const invalid = `
|
|
171
|
+
version: "1.0"
|
|
172
|
+
name: bad
|
|
173
|
+
nodes: []
|
|
174
|
+
`;
|
|
175
|
+
// nodes must be non-empty per parser
|
|
176
|
+
expect(() => migratePlaybook(invalid)).toThrow();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('does not modify approval nodes', () => {
|
|
180
|
+
const yaml = `
|
|
181
|
+
version: "1.0"
|
|
182
|
+
name: has-approval
|
|
183
|
+
nodes:
|
|
184
|
+
- id: step
|
|
185
|
+
type: agentic
|
|
186
|
+
skill: ct-task-executor
|
|
187
|
+
- id: gate
|
|
188
|
+
type: approval
|
|
189
|
+
prompt: "Approve?"
|
|
190
|
+
edges: []
|
|
191
|
+
error_handlers:
|
|
192
|
+
- on: iteration_cap_exceeded
|
|
193
|
+
action: hitl_escalate
|
|
194
|
+
`;
|
|
195
|
+
const migrated = migratePlaybook(yaml);
|
|
196
|
+
const report = validatePlaybookCompliance(migrated);
|
|
197
|
+
// approval node should still be exempted
|
|
198
|
+
const approvalEntry = report.nodes.find((n) => n.id === 'gate');
|
|
199
|
+
expect(approvalEntry?.type).toBe('approval');
|
|
200
|
+
expect(report.compliant).toBe(true);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// context_files parser support (T1261 E4 thin-agent boundary)
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
describe('T1261-E4: context_files parser support', () => {
|
|
209
|
+
it('parses context_files on an agentic node', async () => {
|
|
210
|
+
const { parsePlaybook } = await import('../parser.js');
|
|
211
|
+
const yaml = `
|
|
212
|
+
version: "1.0"
|
|
213
|
+
name: bounded
|
|
214
|
+
nodes:
|
|
215
|
+
- id: worker
|
|
216
|
+
type: agentic
|
|
217
|
+
skill: ct-task-executor
|
|
218
|
+
context_files:
|
|
219
|
+
- packages/core/src/foo.ts
|
|
220
|
+
- packages/contracts/src/index.ts
|
|
221
|
+
edges: []
|
|
222
|
+
`;
|
|
223
|
+
const { definition } = parsePlaybook(yaml);
|
|
224
|
+
const node = definition.nodes[0];
|
|
225
|
+
expect(node.type).toBe('agentic');
|
|
226
|
+
if (node.type === 'agentic') {
|
|
227
|
+
expect(node.context_files).toEqual([
|
|
228
|
+
'packages/core/src/foo.ts',
|
|
229
|
+
'packages/contracts/src/index.ts',
|
|
230
|
+
]);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('context_files is optional — absent nodes have undefined', async () => {
|
|
235
|
+
const { parsePlaybook } = await import('../parser.js');
|
|
236
|
+
const yaml = `
|
|
237
|
+
version: "1.0"
|
|
238
|
+
name: unbounded
|
|
239
|
+
nodes:
|
|
240
|
+
- id: worker
|
|
241
|
+
type: agentic
|
|
242
|
+
skill: ct-task-executor
|
|
243
|
+
edges: []
|
|
244
|
+
`;
|
|
245
|
+
const { definition } = parsePlaybook(yaml);
|
|
246
|
+
const node = definition.nodes[0];
|
|
247
|
+
if (node.type === 'agentic') {
|
|
248
|
+
expect(node.context_files).toBeUndefined();
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
});
|
|
@@ -110,15 +110,63 @@ function makeRecordingDispatcher(
|
|
|
110
110
|
|
|
111
111
|
/**
|
|
112
112
|
* Default "always succeed" handler that echoes the node id into the context
|
|
113
|
-
* via `{<nodeId>_done: true}
|
|
113
|
+
* via `{<nodeId>_done: true}`, plus enough fields to satisfy the ivtr/rcasd
|
|
114
|
+
* `requires` contracts so the E4 contract enforcement (T1261) doesn't fire.
|
|
115
|
+
*
|
|
116
|
+
* Fields added per-node follow the starter playbook schemas:
|
|
117
|
+
* - implement → diff (validate.requires.fields includes 'diff')
|
|
118
|
+
* - validate → passed (test.requires.fields includes 'passed')
|
|
119
|
+
* - research → summary, risks (consensus.requires.fields)
|
|
120
|
+
* - consensus → decision (architecture.requires.fields)
|
|
121
|
+
* - architecture → patterns, adrs (specification.requires.fields)
|
|
122
|
+
* - specification → acceptance, requirements (decomposition.requires.fields)
|
|
123
|
+
* - version_bump → versionBumped (changelog.requires.fields)
|
|
124
|
+
* - changelog → changelogUpdated (approval edge.contract.ensures)
|
|
114
125
|
*/
|
|
115
126
|
function alwaysSucceed(input: AgentDispatchInput): AgentDispatchResult {
|
|
127
|
+
const extraFields: Record<string, unknown> = {};
|
|
128
|
+
switch (input.nodeId) {
|
|
129
|
+
case 'implement':
|
|
130
|
+
extraFields.diff = `diff-${input.runId}`;
|
|
131
|
+
break;
|
|
132
|
+
case 'validate':
|
|
133
|
+
extraFields.passed = true;
|
|
134
|
+
break;
|
|
135
|
+
case 'research':
|
|
136
|
+
extraFields.summary = 'research summary';
|
|
137
|
+
extraFields.risks = [];
|
|
138
|
+
break;
|
|
139
|
+
case 'consensus':
|
|
140
|
+
extraFields.decision = 'consensus decision';
|
|
141
|
+
break;
|
|
142
|
+
case 'architecture':
|
|
143
|
+
extraFields.patterns = [];
|
|
144
|
+
extraFields.adrs = [];
|
|
145
|
+
break;
|
|
146
|
+
case 'specification':
|
|
147
|
+
extraFields.acceptance = [];
|
|
148
|
+
extraFields.requirements = [];
|
|
149
|
+
break;
|
|
150
|
+
case 'version_bump':
|
|
151
|
+
extraFields.versionBumped = true;
|
|
152
|
+
break;
|
|
153
|
+
case 'changelog':
|
|
154
|
+
extraFields.changelogUpdated = true;
|
|
155
|
+
extraFields.published = false;
|
|
156
|
+
break;
|
|
157
|
+
case 'publish':
|
|
158
|
+
extraFields.published = true;
|
|
159
|
+
break;
|
|
160
|
+
default:
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
116
163
|
return {
|
|
117
164
|
status: 'success',
|
|
118
165
|
output: {
|
|
119
166
|
[`${input.nodeId}_done`]: true,
|
|
120
167
|
lastNode: input.nodeId,
|
|
121
168
|
lastAgent: input.agentId,
|
|
169
|
+
...extraFields,
|
|
122
170
|
},
|
|
123
171
|
};
|
|
124
172
|
}
|
package/src/index.ts
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* Consumers can use this to assert dependency alignment at runtime
|
|
24
24
|
* (e.g. ensuring the `@cleocode/playbooks` runtime matches CLEO core).
|
|
25
25
|
*/
|
|
26
|
-
export const PLAYBOOKS_PACKAGE_VERSION: string = '2026.4.
|
|
26
|
+
export const PLAYBOOKS_PACKAGE_VERSION: string = '2026.4.129';
|
|
27
27
|
|
|
28
28
|
export {
|
|
29
29
|
approveGate,
|
|
@@ -36,6 +36,15 @@ export {
|
|
|
36
36
|
getPlaybookSecret,
|
|
37
37
|
rejectGate,
|
|
38
38
|
} from './approval.js';
|
|
39
|
+
// PSYCHE E4 migration tool (T1261) — STRICT cutover validator + migrator
|
|
40
|
+
export {
|
|
41
|
+
type MigratePlaybookFileResult,
|
|
42
|
+
migratePlaybook,
|
|
43
|
+
migratePlaybookFile,
|
|
44
|
+
type NodeComplianceEntry,
|
|
45
|
+
type PlaybookComplianceReport,
|
|
46
|
+
validatePlaybookCompliance,
|
|
47
|
+
} from './migrate-e4.js';
|
|
39
48
|
// W4-7: .cantbook YAML parser → PlaybookDefinition
|
|
40
49
|
export {
|
|
41
50
|
type ParsePlaybookResult,
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PSYCHE E4 STRICT cutover migration tool for `.cantbook` files.
|
|
3
|
+
*
|
|
4
|
+
* Per Council mandate (T1261 ADR-055 STRICT cutover): all `.cantbook` files
|
|
5
|
+
* MUST comply with the E4 DSL contract before the v2026.4.129 ship. This
|
|
6
|
+
* module provides:
|
|
7
|
+
*
|
|
8
|
+
* 1. {@link validatePlaybookCompliance} — pure validator, returns compliance
|
|
9
|
+
* report without modifying files.
|
|
10
|
+
* 2. {@link migratePlaybook} — enriches a `.cantbook` source string with
|
|
11
|
+
* minimal E4-compatible scaffolding (adds skeleton error_handlers if
|
|
12
|
+
* absent, adds requires/ensures stubs to nodes without them).
|
|
13
|
+
* 3. {@link migratePlaybookFile} — reads, migrates, and optionally writes a
|
|
14
|
+
* `.cantbook` file in place. Dry-run mode returns the migrated source
|
|
15
|
+
* without writing.
|
|
16
|
+
*
|
|
17
|
+
* STRICT cutover policy (no opt-in flag):
|
|
18
|
+
* - All existing `.cantbook` files MUST be validated/migrated at E4 ship.
|
|
19
|
+
* - The starter playbooks (rcasd, ivtr, release) ship with full E4 DSL and
|
|
20
|
+
* pass validation without modification.
|
|
21
|
+
* - User-authored playbooks MUST run this tool before using the E4 runtime.
|
|
22
|
+
*
|
|
23
|
+
* @task T1261 PSYCHE E4 — STRICT cutover migration
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
27
|
+
import { dump as yamlDump, load as yamlLoad } from 'js-yaml';
|
|
28
|
+
import { PlaybookParseError, parsePlaybook } from './parser.js';
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Compliance report
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/** Per-node compliance entry. */
|
|
35
|
+
export interface NodeComplianceEntry {
|
|
36
|
+
/** Node id. */
|
|
37
|
+
id: string;
|
|
38
|
+
/** Node type. */
|
|
39
|
+
type: string;
|
|
40
|
+
/** Whether the node has a `requires` block. */
|
|
41
|
+
hasRequires: boolean;
|
|
42
|
+
/** Whether the node has an `ensures` block. */
|
|
43
|
+
hasEnsures: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Result of {@link validatePlaybookCompliance}. Reports which E4 DSL
|
|
48
|
+
* features are present and which are missing.
|
|
49
|
+
*/
|
|
50
|
+
export interface PlaybookComplianceReport {
|
|
51
|
+
/** Whether the file parses successfully (pre-condition). */
|
|
52
|
+
parses: boolean;
|
|
53
|
+
/** Parse error message when `parses === false`. */
|
|
54
|
+
parseError?: string;
|
|
55
|
+
/** Whether the file has at least one `error_handlers` entry. */
|
|
56
|
+
hasErrorHandlers: boolean;
|
|
57
|
+
/** Number of agentic nodes missing `requires` (first predecessor context). */
|
|
58
|
+
nodesMissingRequires: number;
|
|
59
|
+
/** Number of nodes missing `ensures` (output guarantee). */
|
|
60
|
+
nodesMissingEnsures: number;
|
|
61
|
+
/** Per-node breakdown. */
|
|
62
|
+
nodes: NodeComplianceEntry[];
|
|
63
|
+
/** `true` when all E4 requirements are satisfied. */
|
|
64
|
+
compliant: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Validate a `.cantbook` source string for E4 DSL compliance without
|
|
69
|
+
* modifying it.
|
|
70
|
+
*
|
|
71
|
+
* @param source - Raw `.cantbook` YAML text.
|
|
72
|
+
* @returns A compliance report. Callers should check `compliant` before
|
|
73
|
+
* running the playbook.
|
|
74
|
+
*/
|
|
75
|
+
export function validatePlaybookCompliance(source: string): PlaybookComplianceReport {
|
|
76
|
+
let parsed: ReturnType<typeof parsePlaybook>;
|
|
77
|
+
try {
|
|
78
|
+
parsed = parsePlaybook(source);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
return {
|
|
81
|
+
parses: false,
|
|
82
|
+
parseError: err instanceof PlaybookParseError ? err.message : String(err),
|
|
83
|
+
hasErrorHandlers: false,
|
|
84
|
+
nodesMissingRequires: 0,
|
|
85
|
+
nodesMissingEnsures: 0,
|
|
86
|
+
nodes: [],
|
|
87
|
+
compliant: false,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const { definition } = parsed;
|
|
92
|
+
const hasErrorHandlers = (definition.error_handlers?.length ?? 0) > 0;
|
|
93
|
+
|
|
94
|
+
const nodeEntries: NodeComplianceEntry[] = definition.nodes.map((n) => ({
|
|
95
|
+
id: n.id,
|
|
96
|
+
type: n.type,
|
|
97
|
+
hasRequires: n.requires !== undefined,
|
|
98
|
+
hasEnsures: n.ensures !== undefined,
|
|
99
|
+
}));
|
|
100
|
+
|
|
101
|
+
// Agentic + deterministic nodes should have requires/ensures; approval
|
|
102
|
+
// nodes are exempt (they are gate-only, not data-producing).
|
|
103
|
+
const workNodes = nodeEntries.filter((e) => e.type !== 'approval');
|
|
104
|
+
const nodesMissingRequires = workNodes.filter((e) => !e.hasRequires).length;
|
|
105
|
+
const nodesMissingEnsures = workNodes.filter((e) => !e.hasEnsures).length;
|
|
106
|
+
|
|
107
|
+
const compliant = hasErrorHandlers && nodesMissingRequires === 0 && nodesMissingEnsures === 0;
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
parses: true,
|
|
111
|
+
hasErrorHandlers,
|
|
112
|
+
nodesMissingRequires,
|
|
113
|
+
nodesMissingEnsures,
|
|
114
|
+
nodes: nodeEntries,
|
|
115
|
+
compliant,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Migration
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Apply PSYCHE E4 STRICT cutover scaffolding to a `.cantbook` source string.
|
|
125
|
+
*
|
|
126
|
+
* Conservative strategy: adds skeleton DSL where absent; never removes or
|
|
127
|
+
* modifies existing DSL. Callers should review the migrated output before
|
|
128
|
+
* writing to disk.
|
|
129
|
+
*
|
|
130
|
+
* Migrations applied:
|
|
131
|
+
* - Adds a default `error_handlers` block if absent (iteration_cap_exceeded
|
|
132
|
+
* → hitl_escalate + contract_violation → inject_hint).
|
|
133
|
+
* - Adds a stub `requires: {}` to work nodes (non-approval) that lack it.
|
|
134
|
+
* - Adds a stub `ensures: {}` to work nodes that lack it.
|
|
135
|
+
*
|
|
136
|
+
* @param source - Raw `.cantbook` YAML text.
|
|
137
|
+
* @returns The migrated YAML source string.
|
|
138
|
+
* @throws {PlaybookParseError} when the source cannot be parsed.
|
|
139
|
+
*/
|
|
140
|
+
export function migratePlaybook(source: string): string {
|
|
141
|
+
// Parse first to validate structural correctness.
|
|
142
|
+
parsePlaybook(source);
|
|
143
|
+
|
|
144
|
+
// Work on the raw YAML object so we preserve ordering and comments where
|
|
145
|
+
// possible (js-yaml dump always re-serialises, but it's the safest
|
|
146
|
+
// approach for a migration tool).
|
|
147
|
+
const raw = yamlLoad(source) as Record<string, unknown>;
|
|
148
|
+
|
|
149
|
+
// 1. Ensure error_handlers exists.
|
|
150
|
+
if (!Array.isArray(raw.error_handlers) || (raw.error_handlers as unknown[]).length === 0) {
|
|
151
|
+
raw.error_handlers = [
|
|
152
|
+
{
|
|
153
|
+
on: 'iteration_cap_exceeded',
|
|
154
|
+
action: 'hitl_escalate',
|
|
155
|
+
message: 'Stage exhausted retries — escalate to human for direction.',
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
on: 'contract_violation',
|
|
159
|
+
action: 'inject_hint',
|
|
160
|
+
message: 'Contract violated at stage boundary — check requires/ensures fields.',
|
|
161
|
+
},
|
|
162
|
+
];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 2. Add requires/ensures stubs to work nodes.
|
|
166
|
+
if (Array.isArray(raw.nodes)) {
|
|
167
|
+
raw.nodes = (raw.nodes as Record<string, unknown>[]).map((node) => {
|
|
168
|
+
if (node.type === 'approval') return node; // approval nodes exempt
|
|
169
|
+
const patched = { ...node };
|
|
170
|
+
if (!patched.requires) patched.requires = {};
|
|
171
|
+
if (!patched.ensures) patched.ensures = {};
|
|
172
|
+
return patched;
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return yamlDump(raw, { lineWidth: 100, noRefs: true });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// File I/O helper
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
/** Result of {@link migratePlaybookFile}. */
|
|
184
|
+
export interface MigratePlaybookFileResult {
|
|
185
|
+
/** Path that was processed. */
|
|
186
|
+
filePath: string;
|
|
187
|
+
/** Pre-migration compliance report. */
|
|
188
|
+
before: PlaybookComplianceReport;
|
|
189
|
+
/** Post-migration compliance report (same as `before` in dry-run mode). */
|
|
190
|
+
after: PlaybookComplianceReport;
|
|
191
|
+
/** Whether the file was written (false in dry-run mode). */
|
|
192
|
+
written: boolean;
|
|
193
|
+
/** Migrated YAML source (always set, even in dry-run mode). */
|
|
194
|
+
migratedSource: string;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Read, validate, migrate, and optionally write a `.cantbook` file.
|
|
199
|
+
*
|
|
200
|
+
* @param filePath - Absolute path to the `.cantbook` file.
|
|
201
|
+
* @param dryRun - When `true`, return the migrated source without writing.
|
|
202
|
+
* @returns Migration result including before/after compliance reports.
|
|
203
|
+
*/
|
|
204
|
+
export function migratePlaybookFile(filePath: string, dryRun = false): MigratePlaybookFileResult {
|
|
205
|
+
const source = readFileSync(filePath, 'utf8');
|
|
206
|
+
const before = validatePlaybookCompliance(source);
|
|
207
|
+
|
|
208
|
+
if (!before.parses) {
|
|
209
|
+
return {
|
|
210
|
+
filePath,
|
|
211
|
+
before,
|
|
212
|
+
after: before,
|
|
213
|
+
written: false,
|
|
214
|
+
migratedSource: source,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const migratedSource = migratePlaybook(source);
|
|
219
|
+
const after = validatePlaybookCompliance(migratedSource);
|
|
220
|
+
|
|
221
|
+
if (!dryRun && !before.compliant) {
|
|
222
|
+
writeFileSync(filePath, migratedSource, 'utf8');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
filePath,
|
|
227
|
+
before,
|
|
228
|
+
after,
|
|
229
|
+
written: !dryRun && !before.compliant,
|
|
230
|
+
migratedSource,
|
|
231
|
+
};
|
|
232
|
+
}
|
package/src/parser.ts
CHANGED
|
@@ -17,12 +17,14 @@
|
|
|
17
17
|
* - every edge.from + edge.to MUST reference a known node id
|
|
18
18
|
* - nodes form a DAG when combined with edges (no cycles)
|
|
19
19
|
* - agentic nodes MUST have skill OR agent (at least one)
|
|
20
|
+
* - agentic nodes MAY have context_files (thin-agent boundary, T1261 E4)
|
|
20
21
|
* - deterministic nodes MUST have command + args
|
|
21
22
|
* - approval nodes MUST have prompt
|
|
22
23
|
* - depends[] entries MUST be valid node ids
|
|
23
24
|
* - iteration_cap (max_iterations) MUST be 0..10 (hard limit)
|
|
24
25
|
*
|
|
25
26
|
* @task T889 / T904 / W4-7
|
|
27
|
+
* @task T1261 PSYCHE E4 — context_files thin-agent boundary
|
|
26
28
|
*/
|
|
27
29
|
|
|
28
30
|
import { createHash } from 'node:crypto';
|
|
@@ -332,6 +334,8 @@ function parseAgenticNode(
|
|
|
332
334
|
inputs = acc;
|
|
333
335
|
}
|
|
334
336
|
|
|
337
|
+
const context_files = parseStringArray(raw.context_files, `nodes[${index}].context_files`);
|
|
338
|
+
|
|
335
339
|
return {
|
|
336
340
|
...base,
|
|
337
341
|
type: 'agentic',
|
|
@@ -339,6 +343,7 @@ function parseAgenticNode(
|
|
|
339
343
|
agent,
|
|
340
344
|
role,
|
|
341
345
|
inputs,
|
|
346
|
+
...(context_files !== undefined ? { context_files } : {}),
|
|
342
347
|
};
|
|
343
348
|
}
|
|
344
349
|
|