@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.
@@ -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}`. Enough for the happy-path assertions.
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.85';
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