@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.
- package/LICENSE +21 -0
- package/dist/approval.d.ts +113 -0
- package/dist/approval.js +244 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +32 -0
- package/dist/parser.d.ts +60 -0
- package/dist/parser.js +509 -0
- package/dist/policy.d.ts +55 -0
- package/dist/policy.js +85 -0
- package/dist/schema.d.ts +374 -0
- package/dist/schema.js +34 -0
- package/dist/state.d.ts +96 -0
- package/dist/state.js +322 -0
- package/package.json +51 -0
- package/src/__tests__/approval.test.ts +295 -0
- package/src/__tests__/parser.test.ts +456 -0
- package/src/__tests__/policy.test.ts +91 -0
- package/src/__tests__/schema.test.ts +209 -0
- package/src/__tests__/smoke.test.ts +9 -0
- package/src/__tests__/state.test.ts +258 -0
- package/src/approval.ts +321 -0
- package/src/index.ts +66 -0
- package/src/parser.ts +712 -0
- package/src/policy.ts +111 -0
- package/src/schema.ts +44 -0
- package/src/state.ts +471 -0
package/src/parser.ts
ADDED
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* .cantbook YAML parser → PlaybookDefinition.
|
|
3
|
+
*
|
|
4
|
+
* Grammar (see contracts/playbook.ts):
|
|
5
|
+
* version: "1.0"
|
|
6
|
+
* name: <string>
|
|
7
|
+
* description?: <string>
|
|
8
|
+
* inputs?: [{name, required?, default?, description?}]
|
|
9
|
+
* nodes: [<agentic | deterministic | approval node>]
|
|
10
|
+
* edges: [{from, to, contract?:{requires?[], ensures?[]}}]
|
|
11
|
+
* error_handlers?: [{on, action, message?}]
|
|
12
|
+
*
|
|
13
|
+
* Validation:
|
|
14
|
+
* - version MUST be "1.0"
|
|
15
|
+
* - name MUST be non-empty
|
|
16
|
+
* - node ids MUST be unique
|
|
17
|
+
* - every edge.from + edge.to MUST reference a known node id
|
|
18
|
+
* - nodes form a DAG when combined with edges (no cycles)
|
|
19
|
+
* - agentic nodes MUST have skill OR agent (at least one)
|
|
20
|
+
* - deterministic nodes MUST have command + args
|
|
21
|
+
* - approval nodes MUST have prompt
|
|
22
|
+
* - depends[] entries MUST be valid node ids
|
|
23
|
+
* - iteration_cap (max_iterations) MUST be 0..10 (hard limit)
|
|
24
|
+
*
|
|
25
|
+
* @task T889 / T904 / W4-7
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { createHash } from 'node:crypto';
|
|
29
|
+
import type {
|
|
30
|
+
PlaybookAgenticNode,
|
|
31
|
+
PlaybookApprovalNode,
|
|
32
|
+
PlaybookDefinition,
|
|
33
|
+
PlaybookDeterministicNode,
|
|
34
|
+
PlaybookEdge,
|
|
35
|
+
PlaybookEnsures,
|
|
36
|
+
PlaybookErrorHandler,
|
|
37
|
+
PlaybookInput,
|
|
38
|
+
PlaybookNode,
|
|
39
|
+
PlaybookNodeOnFailure,
|
|
40
|
+
PlaybookNodeType,
|
|
41
|
+
PlaybookPolicy,
|
|
42
|
+
PlaybookRequires,
|
|
43
|
+
} from '@cleocode/contracts';
|
|
44
|
+
import { load as yamlLoad } from 'js-yaml';
|
|
45
|
+
|
|
46
|
+
/** Supported playbook grammar version. Bump with migration plan only. */
|
|
47
|
+
const PLAYBOOK_VERSION = '1.0';
|
|
48
|
+
|
|
49
|
+
/** Hard ceiling on per-node retries to prevent runaway agents. */
|
|
50
|
+
const MAX_ITERATION_CAP = 10;
|
|
51
|
+
|
|
52
|
+
/** Allowed string literals for {@link PlaybookErrorHandler.on}. */
|
|
53
|
+
const ERROR_HANDLER_TRIGGERS = new Set<PlaybookErrorHandler['on']>([
|
|
54
|
+
'agentic_timeout',
|
|
55
|
+
'iteration_cap_exceeded',
|
|
56
|
+
'contract_violation',
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
/** Allowed string literals for {@link PlaybookErrorHandler.action}. */
|
|
60
|
+
const ERROR_HANDLER_ACTIONS = new Set<PlaybookErrorHandler['action']>([
|
|
61
|
+
'inject_hint',
|
|
62
|
+
'hitl_escalate',
|
|
63
|
+
'abort',
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
/** Allowed string literals for {@link PlaybookAgenticNode.role}. */
|
|
67
|
+
const AGENTIC_ROLES = new Set<NonNullable<PlaybookAgenticNode['role']>>([
|
|
68
|
+
'orchestrator',
|
|
69
|
+
'lead',
|
|
70
|
+
'worker',
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
/** Allowed string literals for {@link PlaybookApprovalNode.policy}. */
|
|
74
|
+
const APPROVAL_POLICIES = new Set<PlaybookPolicy>(['conservative', 'permissive', 'custom']);
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Error thrown on any structural or semantic parse failure. Carries a
|
|
78
|
+
* `code`/`exitCode` pair so callers can bubble up consistent LAFS envelopes.
|
|
79
|
+
*/
|
|
80
|
+
export class PlaybookParseError extends Error {
|
|
81
|
+
/** Stable envelope error code for LAFS. */
|
|
82
|
+
readonly code = 'E_PLAYBOOK_PARSE';
|
|
83
|
+
/** Process exit code used by CLI wrappers when a parse fails. */
|
|
84
|
+
readonly exitCode = 70;
|
|
85
|
+
/**
|
|
86
|
+
* @param message - Human-readable reason the playbook is invalid.
|
|
87
|
+
* @param field - Offending field path (e.g. `"nodes[0].id"`).
|
|
88
|
+
* @param value - Offending value for diagnostics (never re-thrown).
|
|
89
|
+
*/
|
|
90
|
+
constructor(
|
|
91
|
+
message: string,
|
|
92
|
+
public readonly field?: string,
|
|
93
|
+
public readonly value?: unknown,
|
|
94
|
+
) {
|
|
95
|
+
super(message);
|
|
96
|
+
this.name = 'PlaybookParseError';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Result of a successful {@link parsePlaybook} call. */
|
|
101
|
+
export interface ParsePlaybookResult {
|
|
102
|
+
/** Validated, normalized definition ready for runtime execution. */
|
|
103
|
+
definition: PlaybookDefinition;
|
|
104
|
+
/** SHA-256 hex of the input source (for tamper detection). */
|
|
105
|
+
sourceHash: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Parse raw .cantbook YAML text into a validated {@link PlaybookDefinition}.
|
|
110
|
+
*
|
|
111
|
+
* @param source - Raw .cantbook YAML text.
|
|
112
|
+
* @returns The validated definition plus a deterministic SHA-256 source hash.
|
|
113
|
+
* @throws {PlaybookParseError} On any structural or semantic violation.
|
|
114
|
+
*/
|
|
115
|
+
export function parsePlaybook(source: string): ParsePlaybookResult {
|
|
116
|
+
// 1. YAML parse
|
|
117
|
+
let raw: unknown;
|
|
118
|
+
try {
|
|
119
|
+
raw = yamlLoad(source);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
122
|
+
throw new PlaybookParseError(`YAML syntax error: ${msg}`);
|
|
123
|
+
}
|
|
124
|
+
if (!isRecord(raw)) {
|
|
125
|
+
throw new PlaybookParseError('.cantbook must be a YAML map at top level');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 2. Validate version
|
|
129
|
+
if (raw.version !== PLAYBOOK_VERSION) {
|
|
130
|
+
throw new PlaybookParseError(
|
|
131
|
+
`Unsupported version: ${formatValue(raw.version)}. Only "${PLAYBOOK_VERSION}" is supported.`,
|
|
132
|
+
'version',
|
|
133
|
+
raw.version,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
const version = raw.version;
|
|
137
|
+
|
|
138
|
+
// 3. Validate name
|
|
139
|
+
if (typeof raw.name !== 'string' || raw.name.length === 0) {
|
|
140
|
+
throw new PlaybookParseError('name must be a non-empty string', 'name', raw.name);
|
|
141
|
+
}
|
|
142
|
+
const name = raw.name;
|
|
143
|
+
|
|
144
|
+
// 4. Parse nodes
|
|
145
|
+
if (!Array.isArray(raw.nodes) || raw.nodes.length === 0) {
|
|
146
|
+
throw new PlaybookParseError('nodes must be a non-empty array', 'nodes', raw.nodes);
|
|
147
|
+
}
|
|
148
|
+
const nodes: PlaybookNode[] = raw.nodes.map((n, i) => parseNode(n, i));
|
|
149
|
+
|
|
150
|
+
// 5. Check node id uniqueness
|
|
151
|
+
const ids = new Set<string>();
|
|
152
|
+
for (const n of nodes) {
|
|
153
|
+
if (ids.has(n.id)) {
|
|
154
|
+
throw new PlaybookParseError(`duplicate node id: ${n.id}`, 'nodes', n.id);
|
|
155
|
+
}
|
|
156
|
+
ids.add(n.id);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 6. Parse edges
|
|
160
|
+
const edgesRaw = raw.edges === undefined ? [] : raw.edges;
|
|
161
|
+
if (!Array.isArray(edgesRaw)) {
|
|
162
|
+
throw new PlaybookParseError('edges must be an array', 'edges', edgesRaw);
|
|
163
|
+
}
|
|
164
|
+
const edges: PlaybookEdge[] = edgesRaw.map((e, i) => parseEdge(e, i, ids));
|
|
165
|
+
|
|
166
|
+
// 7. Validate depends[] references (must exist as known node ids)
|
|
167
|
+
for (const n of nodes) {
|
|
168
|
+
if (!n.depends) continue;
|
|
169
|
+
for (const dep of n.depends) {
|
|
170
|
+
if (!ids.has(dep)) {
|
|
171
|
+
throw new PlaybookParseError(`node ${n.id} depends on unknown node ${dep}`, 'depends', dep);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// 8. Iteration cap enforcement (0..MAX_ITERATION_CAP inclusive)
|
|
177
|
+
for (const n of nodes) {
|
|
178
|
+
const cap = n.on_failure?.max_iterations;
|
|
179
|
+
if (cap !== undefined && (cap < 0 || cap > MAX_ITERATION_CAP)) {
|
|
180
|
+
throw new PlaybookParseError(
|
|
181
|
+
`node ${n.id} max_iterations must be 0..${MAX_ITERATION_CAP} (got ${cap})`,
|
|
182
|
+
'max_iterations',
|
|
183
|
+
cap,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// 9. DAG check (edges + depends both contribute to the graph)
|
|
189
|
+
if (hasCycle(nodes, edges)) {
|
|
190
|
+
throw new PlaybookParseError('playbook contains a cycle in node graph');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 10. Build definition
|
|
194
|
+
const definition: PlaybookDefinition = {
|
|
195
|
+
version,
|
|
196
|
+
name,
|
|
197
|
+
description: typeof raw.description === 'string' ? raw.description : undefined,
|
|
198
|
+
inputs: parseInputs(raw.inputs),
|
|
199
|
+
nodes,
|
|
200
|
+
edges,
|
|
201
|
+
error_handlers: parseErrorHandlers(raw.error_handlers),
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const sourceHash = createHash('sha256').update(source).digest('hex');
|
|
205
|
+
return { definition, sourceHash };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Parse a single node entry. Dispatches on `type` to the appropriate
|
|
210
|
+
* specialization validator.
|
|
211
|
+
*
|
|
212
|
+
* @param raw - Raw YAML node object.
|
|
213
|
+
* @param index - Zero-based index for error messages.
|
|
214
|
+
*/
|
|
215
|
+
function parseNode(raw: unknown, index: number): PlaybookNode {
|
|
216
|
+
if (!isRecord(raw)) {
|
|
217
|
+
throw new PlaybookParseError(`nodes[${index}] must be an object`, `nodes[${index}]`, raw);
|
|
218
|
+
}
|
|
219
|
+
if (typeof raw.id !== 'string' || raw.id.length === 0) {
|
|
220
|
+
throw new PlaybookParseError(
|
|
221
|
+
`nodes[${index}].id must be a non-empty string`,
|
|
222
|
+
`nodes[${index}].id`,
|
|
223
|
+
raw.id,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
const id = raw.id;
|
|
227
|
+
|
|
228
|
+
if (typeof raw.type !== 'string') {
|
|
229
|
+
throw new PlaybookParseError(
|
|
230
|
+
`nodes[${index}].type must be a string`,
|
|
231
|
+
`nodes[${index}].type`,
|
|
232
|
+
raw.type,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
const type = raw.type as PlaybookNodeType;
|
|
236
|
+
|
|
237
|
+
const description = typeof raw.description === 'string' ? raw.description : undefined;
|
|
238
|
+
const depends = parseStringArray(raw.depends, `nodes[${index}].depends`);
|
|
239
|
+
const requires = parseRequires(raw.requires, `nodes[${index}].requires`);
|
|
240
|
+
const ensures = parseEnsures(raw.ensures, `nodes[${index}].ensures`);
|
|
241
|
+
const on_failure = parseOnFailure(raw.on_failure, `nodes[${index}].on_failure`);
|
|
242
|
+
|
|
243
|
+
const base = {
|
|
244
|
+
id,
|
|
245
|
+
description,
|
|
246
|
+
depends,
|
|
247
|
+
requires,
|
|
248
|
+
ensures,
|
|
249
|
+
on_failure,
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
switch (type) {
|
|
253
|
+
case 'agentic':
|
|
254
|
+
return parseAgenticNode(raw, base, index);
|
|
255
|
+
case 'deterministic':
|
|
256
|
+
return parseDeterministicNode(raw, base, index);
|
|
257
|
+
case 'approval':
|
|
258
|
+
return parseApprovalNode(raw, base, index);
|
|
259
|
+
default:
|
|
260
|
+
throw new PlaybookParseError(
|
|
261
|
+
`nodes[${index}].type must be one of agentic | deterministic | approval (got ${formatValue(
|
|
262
|
+
raw.type,
|
|
263
|
+
)})`,
|
|
264
|
+
`nodes[${index}].type`,
|
|
265
|
+
raw.type,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Shared shape assembled before node-type specialization. */
|
|
271
|
+
type BaseNodeFields = {
|
|
272
|
+
id: string;
|
|
273
|
+
description?: string;
|
|
274
|
+
depends?: string[];
|
|
275
|
+
requires?: PlaybookRequires;
|
|
276
|
+
ensures?: PlaybookEnsures;
|
|
277
|
+
on_failure?: PlaybookNodeOnFailure;
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
function parseAgenticNode(
|
|
281
|
+
raw: Record<string, unknown>,
|
|
282
|
+
base: BaseNodeFields,
|
|
283
|
+
index: number,
|
|
284
|
+
): PlaybookAgenticNode {
|
|
285
|
+
const skill = typeof raw.skill === 'string' ? raw.skill : undefined;
|
|
286
|
+
const agent = typeof raw.agent === 'string' ? raw.agent : undefined;
|
|
287
|
+
if (!skill && !agent) {
|
|
288
|
+
throw new PlaybookParseError(
|
|
289
|
+
`nodes[${index}] (agentic) must define at least one of 'skill' or 'agent'`,
|
|
290
|
+
`nodes[${index}]`,
|
|
291
|
+
raw,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
let role: PlaybookAgenticNode['role'];
|
|
296
|
+
if (raw.role !== undefined) {
|
|
297
|
+
if (
|
|
298
|
+
typeof raw.role !== 'string' ||
|
|
299
|
+
!AGENTIC_ROLES.has(raw.role as PlaybookAgenticNode['role'] as never)
|
|
300
|
+
) {
|
|
301
|
+
throw new PlaybookParseError(
|
|
302
|
+
`nodes[${index}].role must be one of orchestrator | lead | worker (got ${formatValue(
|
|
303
|
+
raw.role,
|
|
304
|
+
)})`,
|
|
305
|
+
`nodes[${index}].role`,
|
|
306
|
+
raw.role,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
role = raw.role as PlaybookAgenticNode['role'];
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
let inputs: Record<string, string> | undefined;
|
|
313
|
+
if (raw.inputs !== undefined) {
|
|
314
|
+
if (!isRecord(raw.inputs)) {
|
|
315
|
+
throw new PlaybookParseError(
|
|
316
|
+
`nodes[${index}].inputs must be an object`,
|
|
317
|
+
`nodes[${index}].inputs`,
|
|
318
|
+
raw.inputs,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
const acc: Record<string, string> = {};
|
|
322
|
+
for (const [k, v] of Object.entries(raw.inputs)) {
|
|
323
|
+
if (typeof v !== 'string') {
|
|
324
|
+
throw new PlaybookParseError(
|
|
325
|
+
`nodes[${index}].inputs.${k} must be a string`,
|
|
326
|
+
`nodes[${index}].inputs.${k}`,
|
|
327
|
+
v,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
acc[k] = v;
|
|
331
|
+
}
|
|
332
|
+
inputs = acc;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
...base,
|
|
337
|
+
type: 'agentic',
|
|
338
|
+
skill,
|
|
339
|
+
agent,
|
|
340
|
+
role,
|
|
341
|
+
inputs,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function parseDeterministicNode(
|
|
346
|
+
raw: Record<string, unknown>,
|
|
347
|
+
base: BaseNodeFields,
|
|
348
|
+
index: number,
|
|
349
|
+
): PlaybookDeterministicNode {
|
|
350
|
+
if (typeof raw.command !== 'string' || raw.command.length === 0) {
|
|
351
|
+
throw new PlaybookParseError(
|
|
352
|
+
`nodes[${index}] (deterministic) must have a non-empty 'command'`,
|
|
353
|
+
`nodes[${index}].command`,
|
|
354
|
+
raw.command,
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
const args = parseStringArray(raw.args, `nodes[${index}].args`) ?? [];
|
|
358
|
+
|
|
359
|
+
const cwd = typeof raw.cwd === 'string' ? raw.cwd : undefined;
|
|
360
|
+
|
|
361
|
+
let env: Record<string, string> | undefined;
|
|
362
|
+
if (raw.env !== undefined) {
|
|
363
|
+
if (!isRecord(raw.env)) {
|
|
364
|
+
throw new PlaybookParseError(
|
|
365
|
+
`nodes[${index}].env must be an object`,
|
|
366
|
+
`nodes[${index}].env`,
|
|
367
|
+
raw.env,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
const acc: Record<string, string> = {};
|
|
371
|
+
for (const [k, v] of Object.entries(raw.env)) {
|
|
372
|
+
if (typeof v !== 'string') {
|
|
373
|
+
throw new PlaybookParseError(
|
|
374
|
+
`nodes[${index}].env.${k} must be a string`,
|
|
375
|
+
`nodes[${index}].env.${k}`,
|
|
376
|
+
v,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
acc[k] = v;
|
|
380
|
+
}
|
|
381
|
+
env = acc;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
let timeout_ms: number | undefined;
|
|
385
|
+
if (raw.timeout_ms !== undefined) {
|
|
386
|
+
if (
|
|
387
|
+
typeof raw.timeout_ms !== 'number' ||
|
|
388
|
+
!Number.isFinite(raw.timeout_ms) ||
|
|
389
|
+
raw.timeout_ms < 0
|
|
390
|
+
) {
|
|
391
|
+
throw new PlaybookParseError(
|
|
392
|
+
`nodes[${index}].timeout_ms must be a non-negative number`,
|
|
393
|
+
`nodes[${index}].timeout_ms`,
|
|
394
|
+
raw.timeout_ms,
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
timeout_ms = raw.timeout_ms;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return {
|
|
401
|
+
...base,
|
|
402
|
+
type: 'deterministic',
|
|
403
|
+
command: raw.command,
|
|
404
|
+
args,
|
|
405
|
+
cwd,
|
|
406
|
+
env,
|
|
407
|
+
timeout_ms,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function parseApprovalNode(
|
|
412
|
+
raw: Record<string, unknown>,
|
|
413
|
+
base: BaseNodeFields,
|
|
414
|
+
index: number,
|
|
415
|
+
): PlaybookApprovalNode {
|
|
416
|
+
if (typeof raw.prompt !== 'string' || raw.prompt.length === 0) {
|
|
417
|
+
throw new PlaybookParseError(
|
|
418
|
+
`nodes[${index}] (approval) must have a non-empty 'prompt'`,
|
|
419
|
+
`nodes[${index}].prompt`,
|
|
420
|
+
raw.prompt,
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
let policy: PlaybookPolicy | undefined;
|
|
425
|
+
if (raw.policy !== undefined) {
|
|
426
|
+
if (typeof raw.policy !== 'string' || !APPROVAL_POLICIES.has(raw.policy as PlaybookPolicy)) {
|
|
427
|
+
throw new PlaybookParseError(
|
|
428
|
+
`nodes[${index}].policy must be one of conservative | permissive | custom (got ${formatValue(
|
|
429
|
+
raw.policy,
|
|
430
|
+
)})`,
|
|
431
|
+
`nodes[${index}].policy`,
|
|
432
|
+
raw.policy,
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
policy = raw.policy as PlaybookPolicy;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return {
|
|
439
|
+
...base,
|
|
440
|
+
type: 'approval',
|
|
441
|
+
prompt: raw.prompt,
|
|
442
|
+
policy,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Parse a single edge entry. Edges reference existing node ids by the time
|
|
448
|
+
* this is called (the `ids` set is fully populated before edge parsing).
|
|
449
|
+
*/
|
|
450
|
+
function parseEdge(raw: unknown, index: number, ids: ReadonlySet<string>): PlaybookEdge {
|
|
451
|
+
if (!isRecord(raw)) {
|
|
452
|
+
throw new PlaybookParseError(`edges[${index}] must be an object`, `edges[${index}]`, raw);
|
|
453
|
+
}
|
|
454
|
+
if (typeof raw.from !== 'string' || raw.from.length === 0) {
|
|
455
|
+
throw new PlaybookParseError(
|
|
456
|
+
`edges[${index}].from must be a non-empty string`,
|
|
457
|
+
`edges[${index}].from`,
|
|
458
|
+
raw.from,
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
if (typeof raw.to !== 'string' || raw.to.length === 0) {
|
|
462
|
+
throw new PlaybookParseError(
|
|
463
|
+
`edges[${index}].to must be a non-empty string`,
|
|
464
|
+
`edges[${index}].to`,
|
|
465
|
+
raw.to,
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
if (!ids.has(raw.from)) {
|
|
469
|
+
throw new PlaybookParseError(
|
|
470
|
+
`edges[${index}].from references unknown node ${raw.from}`,
|
|
471
|
+
`edges[${index}].from`,
|
|
472
|
+
raw.from,
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
if (!ids.has(raw.to)) {
|
|
476
|
+
throw new PlaybookParseError(
|
|
477
|
+
`edges[${index}].to references unknown node ${raw.to}`,
|
|
478
|
+
`edges[${index}].to`,
|
|
479
|
+
raw.to,
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
let contract: PlaybookEdge['contract'];
|
|
484
|
+
if (raw.contract !== undefined) {
|
|
485
|
+
if (!isRecord(raw.contract)) {
|
|
486
|
+
throw new PlaybookParseError(
|
|
487
|
+
`edges[${index}].contract must be an object`,
|
|
488
|
+
`edges[${index}].contract`,
|
|
489
|
+
raw.contract,
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
const requires = parseStringArray(raw.contract.requires, `edges[${index}].contract.requires`);
|
|
493
|
+
const ensures = parseStringArray(raw.contract.ensures, `edges[${index}].contract.ensures`);
|
|
494
|
+
contract = { requires, ensures };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
return { from: raw.from, to: raw.to, contract };
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function parseInputs(raw: unknown): PlaybookInput[] | undefined {
|
|
501
|
+
if (raw === undefined) return undefined;
|
|
502
|
+
if (!Array.isArray(raw)) {
|
|
503
|
+
throw new PlaybookParseError('inputs must be an array', 'inputs', raw);
|
|
504
|
+
}
|
|
505
|
+
return raw.map((r, i) => {
|
|
506
|
+
if (!isRecord(r)) {
|
|
507
|
+
throw new PlaybookParseError(`inputs[${i}] must be an object`, `inputs[${i}]`, r);
|
|
508
|
+
}
|
|
509
|
+
if (typeof r.name !== 'string' || r.name.length === 0) {
|
|
510
|
+
throw new PlaybookParseError(
|
|
511
|
+
`inputs[${i}].name must be a non-empty string`,
|
|
512
|
+
`inputs[${i}].name`,
|
|
513
|
+
r.name,
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
let required: boolean | undefined;
|
|
517
|
+
if (r.required !== undefined) {
|
|
518
|
+
if (typeof r.required !== 'boolean') {
|
|
519
|
+
throw new PlaybookParseError(
|
|
520
|
+
`inputs[${i}].required must be boolean`,
|
|
521
|
+
`inputs[${i}].required`,
|
|
522
|
+
r.required,
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
required = r.required;
|
|
526
|
+
}
|
|
527
|
+
const description = typeof r.description === 'string' ? r.description : undefined;
|
|
528
|
+
const input: PlaybookInput = { name: r.name };
|
|
529
|
+
if (required !== undefined) input.required = required;
|
|
530
|
+
if (Object.hasOwn(r, 'default')) input.default = r.default;
|
|
531
|
+
if (description !== undefined) input.description = description;
|
|
532
|
+
return input;
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function parseErrorHandlers(raw: unknown): PlaybookErrorHandler[] | undefined {
|
|
537
|
+
if (raw === undefined) return undefined;
|
|
538
|
+
if (!Array.isArray(raw)) {
|
|
539
|
+
throw new PlaybookParseError('error_handlers must be an array', 'error_handlers', raw);
|
|
540
|
+
}
|
|
541
|
+
return raw.map((r, i) => {
|
|
542
|
+
if (!isRecord(r)) {
|
|
543
|
+
throw new PlaybookParseError(
|
|
544
|
+
`error_handlers[${i}] must be an object`,
|
|
545
|
+
`error_handlers[${i}]`,
|
|
546
|
+
r,
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
if (
|
|
550
|
+
typeof r.on !== 'string' ||
|
|
551
|
+
!ERROR_HANDLER_TRIGGERS.has(r.on as PlaybookErrorHandler['on'])
|
|
552
|
+
) {
|
|
553
|
+
throw new PlaybookParseError(
|
|
554
|
+
`error_handlers[${i}].on must be one of agentic_timeout | iteration_cap_exceeded | contract_violation (got ${formatValue(
|
|
555
|
+
r.on,
|
|
556
|
+
)})`,
|
|
557
|
+
`error_handlers[${i}].on`,
|
|
558
|
+
r.on,
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
if (
|
|
562
|
+
typeof r.action !== 'string' ||
|
|
563
|
+
!ERROR_HANDLER_ACTIONS.has(r.action as PlaybookErrorHandler['action'])
|
|
564
|
+
) {
|
|
565
|
+
throw new PlaybookParseError(
|
|
566
|
+
`error_handlers[${i}].action must be one of inject_hint | hitl_escalate | abort (got ${formatValue(
|
|
567
|
+
r.action,
|
|
568
|
+
)})`,
|
|
569
|
+
`error_handlers[${i}].action`,
|
|
570
|
+
r.action,
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
const message = typeof r.message === 'string' ? r.message : undefined;
|
|
574
|
+
return {
|
|
575
|
+
on: r.on as PlaybookErrorHandler['on'],
|
|
576
|
+
action: r.action as PlaybookErrorHandler['action'],
|
|
577
|
+
message,
|
|
578
|
+
};
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function parseRequires(raw: unknown, field: string): PlaybookRequires | undefined {
|
|
583
|
+
if (raw === undefined) return undefined;
|
|
584
|
+
if (!isRecord(raw)) {
|
|
585
|
+
throw new PlaybookParseError(`${field} must be an object`, field, raw);
|
|
586
|
+
}
|
|
587
|
+
const from = typeof raw.from === 'string' ? raw.from : undefined;
|
|
588
|
+
const fields = parseStringArray(raw.fields, `${field}.fields`);
|
|
589
|
+
const schema = typeof raw.schema === 'string' ? raw.schema : undefined;
|
|
590
|
+
return { from, fields, schema };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function parseEnsures(raw: unknown, field: string): PlaybookEnsures | undefined {
|
|
594
|
+
if (raw === undefined) return undefined;
|
|
595
|
+
if (!isRecord(raw)) {
|
|
596
|
+
throw new PlaybookParseError(`${field} must be an object`, field, raw);
|
|
597
|
+
}
|
|
598
|
+
const outputFiles = parseStringArray(raw.outputFiles, `${field}.outputFiles`);
|
|
599
|
+
let exitCode: number | undefined;
|
|
600
|
+
if (raw.exitCode !== undefined) {
|
|
601
|
+
if (typeof raw.exitCode !== 'number' || !Number.isInteger(raw.exitCode)) {
|
|
602
|
+
throw new PlaybookParseError(
|
|
603
|
+
`${field}.exitCode must be an integer`,
|
|
604
|
+
`${field}.exitCode`,
|
|
605
|
+
raw.exitCode,
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
exitCode = raw.exitCode;
|
|
609
|
+
}
|
|
610
|
+
const schema = typeof raw.schema === 'string' ? raw.schema : undefined;
|
|
611
|
+
return { outputFiles, exitCode, schema };
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function parseOnFailure(raw: unknown, field: string): PlaybookNodeOnFailure | undefined {
|
|
615
|
+
if (raw === undefined) return undefined;
|
|
616
|
+
if (!isRecord(raw)) {
|
|
617
|
+
throw new PlaybookParseError(`${field} must be an object`, field, raw);
|
|
618
|
+
}
|
|
619
|
+
const inject_into = typeof raw.inject_into === 'string' ? raw.inject_into : undefined;
|
|
620
|
+
let max_iterations: number | undefined;
|
|
621
|
+
if (raw.max_iterations !== undefined) {
|
|
622
|
+
if (typeof raw.max_iterations !== 'number' || !Number.isInteger(raw.max_iterations)) {
|
|
623
|
+
throw new PlaybookParseError(
|
|
624
|
+
`${field}.max_iterations must be an integer`,
|
|
625
|
+
`${field}.max_iterations`,
|
|
626
|
+
raw.max_iterations,
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
max_iterations = raw.max_iterations;
|
|
630
|
+
}
|
|
631
|
+
let escalate: boolean | undefined;
|
|
632
|
+
if (raw.escalate !== undefined) {
|
|
633
|
+
if (typeof raw.escalate !== 'boolean') {
|
|
634
|
+
throw new PlaybookParseError(
|
|
635
|
+
`${field}.escalate must be boolean`,
|
|
636
|
+
`${field}.escalate`,
|
|
637
|
+
raw.escalate,
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
escalate = raw.escalate;
|
|
641
|
+
}
|
|
642
|
+
return { inject_into, max_iterations, escalate };
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function parseStringArray(raw: unknown, field: string): string[] | undefined {
|
|
646
|
+
if (raw === undefined) return undefined;
|
|
647
|
+
if (!Array.isArray(raw)) {
|
|
648
|
+
throw new PlaybookParseError(`${field} must be an array of strings`, field, raw);
|
|
649
|
+
}
|
|
650
|
+
return raw.map((v, i) => {
|
|
651
|
+
if (typeof v !== 'string') {
|
|
652
|
+
throw new PlaybookParseError(`${field}[${i}] must be a string`, `${field}[${i}]`, v);
|
|
653
|
+
}
|
|
654
|
+
return v;
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Detect cycles across the combined edge-set (explicit `edges[]` plus
|
|
660
|
+
* `depends[]` back-references). Uses 3-color DFS.
|
|
661
|
+
*
|
|
662
|
+
* @returns `true` if any cycle exists.
|
|
663
|
+
*/
|
|
664
|
+
function hasCycle(nodes: readonly PlaybookNode[], edges: readonly PlaybookEdge[]): boolean {
|
|
665
|
+
const adj = new Map<string, Set<string>>();
|
|
666
|
+
for (const n of nodes) adj.set(n.id, new Set());
|
|
667
|
+
for (const e of edges) adj.get(e.from)?.add(e.to);
|
|
668
|
+
// depends[] is a reverse dependency: dep → node. Add as incoming edge for DAG purposes.
|
|
669
|
+
for (const n of nodes) {
|
|
670
|
+
if (!n.depends) continue;
|
|
671
|
+
for (const dep of n.depends) adj.get(dep)?.add(n.id);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const WHITE = 0;
|
|
675
|
+
const GRAY = 1;
|
|
676
|
+
const BLACK = 2;
|
|
677
|
+
const color = new Map<string, number>();
|
|
678
|
+
for (const n of nodes) color.set(n.id, WHITE);
|
|
679
|
+
|
|
680
|
+
function visit(id: string): boolean {
|
|
681
|
+
color.set(id, GRAY);
|
|
682
|
+
for (const next of adj.get(id) ?? []) {
|
|
683
|
+
const c = color.get(next) ?? WHITE;
|
|
684
|
+
if (c === GRAY) return true;
|
|
685
|
+
if (c === WHITE && visit(next)) return true;
|
|
686
|
+
}
|
|
687
|
+
color.set(id, BLACK);
|
|
688
|
+
return false;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
for (const n of nodes) {
|
|
692
|
+
if (color.get(n.id) === WHITE && visit(n.id)) return true;
|
|
693
|
+
}
|
|
694
|
+
return false;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Type guard for YAML maps. `yaml.load` returns `unknown`, so we narrow here
|
|
699
|
+
* rather than using broad casts.
|
|
700
|
+
*/
|
|
701
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
702
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** Format arbitrary input for error messages without leaking huge structures. */
|
|
706
|
+
function formatValue(v: unknown): string {
|
|
707
|
+
if (v === null) return 'null';
|
|
708
|
+
if (v === undefined) return 'undefined';
|
|
709
|
+
if (typeof v === 'string') return JSON.stringify(v);
|
|
710
|
+
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
|
|
711
|
+
return typeof v;
|
|
712
|
+
}
|