@cleocode/playbooks 2026.4.88

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,456 @@
1
+ /**
2
+ * W4-7 real-YAML parser tests (no mocks, no fixture files).
3
+ * Uses template-literal YAML strings inline so every expectation is readable
4
+ * in situ and reviewers can trace input → output without chasing fixtures.
5
+ *
6
+ * @task T889 / T904 / W4-7
7
+ */
8
+
9
+ import { describe, expect, it } from 'vitest';
10
+ import { PlaybookParseError, parsePlaybook } from '../parser.js';
11
+
12
+ describe('W4-7: parsePlaybook', () => {
13
+ describe('happy path', () => {
14
+ it('parses a minimal valid playbook (1 agentic node, 0 edges)', () => {
15
+ const yaml = `
16
+ version: "1.0"
17
+ name: minimal
18
+ nodes:
19
+ - id: start
20
+ type: agentic
21
+ skill: ct-research-agent
22
+ `;
23
+ const { definition, sourceHash } = parsePlaybook(yaml);
24
+ expect(definition.version).toBe('1.0');
25
+ expect(definition.name).toBe('minimal');
26
+ expect(definition.nodes).toHaveLength(1);
27
+ expect(definition.nodes[0]).toMatchObject({
28
+ id: 'start',
29
+ type: 'agentic',
30
+ skill: 'ct-research-agent',
31
+ });
32
+ expect(definition.edges).toEqual([]);
33
+ expect(sourceHash).toMatch(/^[a-f0-9]{64}$/);
34
+ });
35
+
36
+ it('parses a full rcasd-style playbook (5 nodes + edges)', () => {
37
+ const yaml = `
38
+ version: "1.0"
39
+ name: rcasd
40
+ description: Research -> Constraint -> Architect -> Spec -> Decompose
41
+ inputs:
42
+ - name: epicId
43
+ required: true
44
+ description: Parent epic id
45
+ - name: scope
46
+ default: global
47
+ nodes:
48
+ - id: research
49
+ type: agentic
50
+ skill: ct-research-agent
51
+ role: lead
52
+ inputs:
53
+ topic: "{{inputs.epicId}}"
54
+ - id: constraint
55
+ type: agentic
56
+ agent: ct-validator
57
+ role: worker
58
+ depends:
59
+ - research
60
+ - id: lint
61
+ type: deterministic
62
+ command: pnpm
63
+ args:
64
+ - biome
65
+ - ci
66
+ - .
67
+ timeout_ms: 60000
68
+ depends:
69
+ - constraint
70
+ - id: approve
71
+ type: approval
72
+ prompt: "Approve RCASD plan?"
73
+ policy: conservative
74
+ depends:
75
+ - lint
76
+ - id: decompose
77
+ type: agentic
78
+ skill: ct-epic-architect
79
+ depends:
80
+ - approve
81
+ on_failure:
82
+ max_iterations: 3
83
+ escalate: true
84
+ edges:
85
+ - from: research
86
+ to: constraint
87
+ contract:
88
+ requires: ["topic"]
89
+ ensures: ["plan"]
90
+ - from: constraint
91
+ to: lint
92
+ - from: lint
93
+ to: approve
94
+ - from: approve
95
+ to: decompose
96
+ error_handlers:
97
+ - on: agentic_timeout
98
+ action: inject_hint
99
+ message: "retry with narrower scope"
100
+ - on: iteration_cap_exceeded
101
+ action: hitl_escalate
102
+ `;
103
+ const { definition } = parsePlaybook(yaml);
104
+ expect(definition.nodes).toHaveLength(5);
105
+ expect(definition.edges).toHaveLength(4);
106
+ expect(definition.inputs).toHaveLength(2);
107
+ expect(definition.error_handlers).toHaveLength(2);
108
+
109
+ const research = definition.nodes.find((n) => n.id === 'research');
110
+ expect(research?.type).toBe('agentic');
111
+ if (research?.type === 'agentic') {
112
+ expect(research.skill).toBe('ct-research-agent');
113
+ expect(research.role).toBe('lead');
114
+ expect(research.inputs?.topic).toBe('{{inputs.epicId}}');
115
+ }
116
+
117
+ const lint = definition.nodes.find((n) => n.id === 'lint');
118
+ expect(lint?.type).toBe('deterministic');
119
+ if (lint?.type === 'deterministic') {
120
+ expect(lint.command).toBe('pnpm');
121
+ expect(lint.args).toEqual(['biome', 'ci', '.']);
122
+ expect(lint.timeout_ms).toBe(60000);
123
+ }
124
+
125
+ const approve = definition.nodes.find((n) => n.id === 'approve');
126
+ expect(approve?.type).toBe('approval');
127
+ if (approve?.type === 'approval') {
128
+ expect(approve.prompt).toBe('Approve RCASD plan?');
129
+ expect(approve.policy).toBe('conservative');
130
+ }
131
+
132
+ const decompose = definition.nodes.find((n) => n.id === 'decompose');
133
+ expect(decompose?.on_failure?.max_iterations).toBe(3);
134
+ expect(decompose?.on_failure?.escalate).toBe(true);
135
+
136
+ const firstEdge = definition.edges[0];
137
+ expect(firstEdge?.contract?.requires).toEqual(['topic']);
138
+ expect(firstEdge?.contract?.ensures).toEqual(['plan']);
139
+ });
140
+ });
141
+
142
+ describe('version validation', () => {
143
+ it('rejects missing version', () => {
144
+ const yaml = `
145
+ name: nope
146
+ nodes:
147
+ - id: a
148
+ type: agentic
149
+ skill: x
150
+ `;
151
+ expect(() => parsePlaybook(yaml)).toThrow(PlaybookParseError);
152
+ expect(() => parsePlaybook(yaml)).toThrow(/Unsupported version/);
153
+ });
154
+
155
+ it('rejects unsupported version "2.0"', () => {
156
+ const yaml = `
157
+ version: "2.0"
158
+ name: nope
159
+ nodes:
160
+ - id: a
161
+ type: agentic
162
+ skill: x
163
+ `;
164
+ expect(() => parsePlaybook(yaml)).toThrow(/Unsupported version: "2\.0"/);
165
+ });
166
+ });
167
+
168
+ describe('structural validation', () => {
169
+ it('rejects YAML that is not a top-level map', () => {
170
+ expect(() => parsePlaybook('- just\n- a list\n')).toThrow(/top level/);
171
+ });
172
+
173
+ it('rejects YAML syntax errors', () => {
174
+ expect(() => parsePlaybook('version: "1.0"\n : broken')).toThrow(/YAML syntax error/);
175
+ });
176
+
177
+ it('rejects empty name', () => {
178
+ const yaml = `
179
+ version: "1.0"
180
+ name: ""
181
+ nodes:
182
+ - id: a
183
+ type: agentic
184
+ skill: x
185
+ `;
186
+ expect(() => parsePlaybook(yaml)).toThrow(/name must be a non-empty string/);
187
+ });
188
+
189
+ it('rejects empty nodes array', () => {
190
+ const yaml = `
191
+ version: "1.0"
192
+ name: x
193
+ nodes: []
194
+ `;
195
+ expect(() => parsePlaybook(yaml)).toThrow(/nodes must be a non-empty array/);
196
+ });
197
+
198
+ it('rejects duplicate node ids', () => {
199
+ const yaml = `
200
+ version: "1.0"
201
+ name: dup
202
+ nodes:
203
+ - id: same
204
+ type: agentic
205
+ skill: a
206
+ - id: same
207
+ type: agentic
208
+ skill: b
209
+ `;
210
+ expect(() => parsePlaybook(yaml)).toThrow(/duplicate node id: same/);
211
+ });
212
+
213
+ it('rejects edge referencing unknown node (from)', () => {
214
+ const yaml = `
215
+ version: "1.0"
216
+ name: e1
217
+ nodes:
218
+ - id: a
219
+ type: agentic
220
+ skill: x
221
+ edges:
222
+ - from: ghost
223
+ to: a
224
+ `;
225
+ expect(() => parsePlaybook(yaml)).toThrow(/from references unknown node ghost/);
226
+ });
227
+
228
+ it('rejects edge referencing unknown node (to)', () => {
229
+ const yaml = `
230
+ version: "1.0"
231
+ name: e2
232
+ nodes:
233
+ - id: a
234
+ type: agentic
235
+ skill: x
236
+ edges:
237
+ - from: a
238
+ to: missing
239
+ `;
240
+ expect(() => parsePlaybook(yaml)).toThrow(/to references unknown node missing/);
241
+ });
242
+
243
+ it('rejects cycle (A -> B -> A)', () => {
244
+ const yaml = `
245
+ version: "1.0"
246
+ name: cyc
247
+ nodes:
248
+ - id: A
249
+ type: agentic
250
+ skill: x
251
+ - id: B
252
+ type: agentic
253
+ skill: y
254
+ edges:
255
+ - from: A
256
+ to: B
257
+ - from: B
258
+ to: A
259
+ `;
260
+ expect(() => parsePlaybook(yaml)).toThrow(/cycle/);
261
+ });
262
+
263
+ it('rejects cycle via depends self-loop', () => {
264
+ const yaml = `
265
+ version: "1.0"
266
+ name: cyc2
267
+ nodes:
268
+ - id: A
269
+ type: agentic
270
+ skill: x
271
+ depends:
272
+ - B
273
+ - id: B
274
+ type: agentic
275
+ skill: y
276
+ depends:
277
+ - A
278
+ `;
279
+ expect(() => parsePlaybook(yaml)).toThrow(/cycle/);
280
+ });
281
+ });
282
+
283
+ describe('node-kind validation', () => {
284
+ it('rejects deterministic node missing command', () => {
285
+ const yaml = `
286
+ version: "1.0"
287
+ name: d1
288
+ nodes:
289
+ - id: n
290
+ type: deterministic
291
+ args: [x]
292
+ `;
293
+ expect(() => parsePlaybook(yaml)).toThrow(/must have a non-empty 'command'/);
294
+ });
295
+
296
+ it('rejects approval node missing prompt', () => {
297
+ const yaml = `
298
+ version: "1.0"
299
+ name: ap
300
+ nodes:
301
+ - id: n
302
+ type: approval
303
+ `;
304
+ expect(() => parsePlaybook(yaml)).toThrow(/must have a non-empty 'prompt'/);
305
+ });
306
+
307
+ it('rejects agentic node missing both skill and agent', () => {
308
+ const yaml = `
309
+ version: "1.0"
310
+ name: ag
311
+ nodes:
312
+ - id: n
313
+ type: agentic
314
+ `;
315
+ expect(() => parsePlaybook(yaml)).toThrow(/must define at least one of 'skill' or 'agent'/);
316
+ });
317
+
318
+ it('accepts agentic node with only agent (no skill)', () => {
319
+ const yaml = `
320
+ version: "1.0"
321
+ name: ag2
322
+ nodes:
323
+ - id: n
324
+ type: agentic
325
+ agent: ct-validator
326
+ `;
327
+ const { definition } = parsePlaybook(yaml);
328
+ expect(definition.nodes).toHaveLength(1);
329
+ });
330
+
331
+ it('rejects unknown node type', () => {
332
+ const yaml = `
333
+ version: "1.0"
334
+ name: bad
335
+ nodes:
336
+ - id: n
337
+ type: whoops
338
+ `;
339
+ expect(() => parsePlaybook(yaml)).toThrow(/one of agentic \| deterministic \| approval/);
340
+ });
341
+ });
342
+
343
+ describe('bounds checks', () => {
344
+ it('rejects max_iterations > 10', () => {
345
+ const yaml = `
346
+ version: "1.0"
347
+ name: cap
348
+ nodes:
349
+ - id: n
350
+ type: agentic
351
+ skill: x
352
+ on_failure:
353
+ max_iterations: 11
354
+ `;
355
+ expect(() => parsePlaybook(yaml)).toThrow(/max_iterations must be 0\.\.10 \(got 11\)/);
356
+ });
357
+
358
+ it('rejects max_iterations < 0', () => {
359
+ const yaml = `
360
+ version: "1.0"
361
+ name: cap2
362
+ nodes:
363
+ - id: n
364
+ type: agentic
365
+ skill: x
366
+ on_failure:
367
+ max_iterations: -1
368
+ `;
369
+ expect(() => parsePlaybook(yaml)).toThrow(/max_iterations must be 0\.\.10 \(got -1\)/);
370
+ });
371
+
372
+ it('accepts max_iterations at the upper bound (10)', () => {
373
+ const yaml = `
374
+ version: "1.0"
375
+ name: cap3
376
+ nodes:
377
+ - id: n
378
+ type: agentic
379
+ skill: x
380
+ on_failure:
381
+ max_iterations: 10
382
+ `;
383
+ const { definition } = parsePlaybook(yaml);
384
+ expect(definition.nodes[0]?.on_failure?.max_iterations).toBe(10);
385
+ });
386
+
387
+ it('rejects depends pointing to unknown node', () => {
388
+ const yaml = `
389
+ version: "1.0"
390
+ name: dep
391
+ nodes:
392
+ - id: a
393
+ type: agentic
394
+ skill: x
395
+ depends:
396
+ - missing_one
397
+ `;
398
+ expect(() => parsePlaybook(yaml)).toThrow(/depends on unknown node missing_one/);
399
+ });
400
+ });
401
+
402
+ describe('determinism', () => {
403
+ it('produces deterministic sourceHash (same input -> same hash)', () => {
404
+ const yaml = `
405
+ version: "1.0"
406
+ name: stable
407
+ nodes:
408
+ - id: a
409
+ type: agentic
410
+ skill: x
411
+ `;
412
+ const h1 = parsePlaybook(yaml).sourceHash;
413
+ const h2 = parsePlaybook(yaml).sourceHash;
414
+ expect(h1).toBe(h2);
415
+ expect(h1).toMatch(/^[a-f0-9]{64}$/);
416
+ });
417
+
418
+ it('produces different sourceHash for different input', () => {
419
+ const a = parsePlaybook(`
420
+ version: "1.0"
421
+ name: a
422
+ nodes:
423
+ - id: n
424
+ type: agentic
425
+ skill: x
426
+ `).sourceHash;
427
+ const b = parsePlaybook(`
428
+ version: "1.0"
429
+ name: b
430
+ nodes:
431
+ - id: n
432
+ type: agentic
433
+ skill: x
434
+ `).sourceHash;
435
+ expect(a).not.toBe(b);
436
+ });
437
+ });
438
+
439
+ describe('PlaybookParseError shape', () => {
440
+ it('carries code + exitCode + field', () => {
441
+ try {
442
+ parsePlaybook(
443
+ 'version: "9.9"\nname: x\nnodes:\n - id: n\n type: agentic\n skill: y',
444
+ );
445
+ throw new Error('should have thrown');
446
+ } catch (err) {
447
+ expect(err).toBeInstanceOf(PlaybookParseError);
448
+ const e = err as PlaybookParseError;
449
+ expect(e.code).toBe('E_PLAYBOOK_PARSE');
450
+ expect(e.exitCode).toBe(70);
451
+ expect(e.field).toBe('version');
452
+ expect(e.value).toBe('9.9');
453
+ }
454
+ });
455
+ });
456
+ });
@@ -0,0 +1,91 @@
1
+ /**
2
+ * W4-9 HITL auto-policy tests. Pure-function — no DB, no mocks needed.
3
+ *
4
+ * @task T889 / T908 / W4-9
5
+ */
6
+
7
+ import { describe, expect, it } from 'vitest';
8
+ import { DEFAULT_POLICY_RULES, evaluatePolicy, type PolicyRule } from '../policy.js';
9
+
10
+ describe('W4-9: evaluatePolicy — conservative defaults', () => {
11
+ it('npm publish → require-human, reason=publish', () => {
12
+ const r = evaluatePolicy('npm publish --access public');
13
+ expect(r.action).toBe('require-human');
14
+ expect(r.reason).toBe('publish');
15
+ });
16
+
17
+ it('pnpm test → auto-approve, reason=safe-qa-tool', () => {
18
+ const r = evaluatePolicy('pnpm test');
19
+ expect(r.action).toBe('auto-approve');
20
+ expect(r.reason).toBe('safe-qa-tool');
21
+ });
22
+
23
+ it('rm -rf / → require-human, reason=destructive', () => {
24
+ const r = evaluatePolicy('rm -rf /');
25
+ expect(r.action).toBe('require-human');
26
+ expect(r.reason).toBe('destructive');
27
+ });
28
+
29
+ it('cleo show T123 → auto-approve, reason=safe-cleo-read', () => {
30
+ const r = evaluatePolicy('cleo show T123');
31
+ expect(r.action).toBe('auto-approve');
32
+ expect(r.reason).toBe('safe-cleo-read');
33
+ });
34
+
35
+ it('git push origin main → require-human, reason=push', () => {
36
+ const r = evaluatePolicy('git push origin main');
37
+ expect(r.action).toBe('require-human');
38
+ expect(r.reason).toBe('push');
39
+ });
40
+
41
+ it('git tag v1.0.0 → require-human, reason=tag', () => {
42
+ const r = evaluatePolicy('git tag v1.0.0');
43
+ expect(r.action).toBe('require-human');
44
+ expect(r.reason).toBe('tag');
45
+ });
46
+
47
+ it('gh release create → require-human, reason=release', () => {
48
+ const r = evaluatePolicy('gh release create v2026.4.86 --notes "..."');
49
+ expect(r.action).toBe('require-human');
50
+ expect(r.reason).toBe('release');
51
+ });
52
+
53
+ it('unknown command → require-human, reason=default', () => {
54
+ const r = evaluatePolicy('some-random-binary --weird-flag');
55
+ expect(r.action).toBe('require-human');
56
+ expect(r.reason).toBe('default');
57
+ expect(r.matchedPattern).toBeUndefined();
58
+ });
59
+
60
+ it('curl https://api.example.com → require-human, reason=external-api', () => {
61
+ const r = evaluatePolicy('curl -X POST https://api.example.com/v1/webhook');
62
+ expect(r.action).toBe('require-human');
63
+ expect(r.reason).toBe('external-api');
64
+ });
65
+
66
+ it('require-human rules cannot be bypassed by a later auto-approve override', () => {
67
+ const custom: PolicyRule[] = [
68
+ { pattern: /git/, action: 'auto-approve', reason: 'custom-git-allow' },
69
+ ...DEFAULT_POLICY_RULES,
70
+ ];
71
+ const r = evaluatePolicy('git push origin main', custom);
72
+ expect(r.action).toBe('require-human');
73
+ expect(r.reason).toBe('push');
74
+ });
75
+
76
+ it('pnpm biome + pnpm tsc also auto-approve as safe-qa-tool', () => {
77
+ expect(evaluatePolicy('pnpm biome ci .').action).toBe('auto-approve');
78
+ expect(evaluatePolicy('pnpm tsc --noEmit').action).toBe('auto-approve');
79
+ });
80
+
81
+ it('exposes matchedPattern on hits', () => {
82
+ const r = evaluatePolicy('pnpm publish');
83
+ expect(r.action).toBe('require-human');
84
+ expect(r.matchedPattern).toBeDefined();
85
+ expect(typeof r.matchedPattern).toBe('string');
86
+ });
87
+
88
+ it('DEFAULT_POLICY_RULES is frozen so callers cannot mutate the defaults', () => {
89
+ expect(Object.isFrozen(DEFAULT_POLICY_RULES)).toBe(true);
90
+ });
91
+ });