@cleocode/playbooks 2026.6.12 → 2026.6.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -1
- package/dist/index.js +9 -2
- package/dist/parser.js +114 -1
- package/dist/runtime.d.ts +92 -34
- package/dist/runtime.js +257 -149
- package/package.json +3 -3
- package/src/__tests__/fixtures/conditional-branch.cantbook +50 -0
- package/src/__tests__/fixtures/pi-skill-only.cantbook +26 -0
- package/src/__tests__/guarded-deterministic-dispatch.test.ts +225 -0
- package/src/__tests__/inputs-bindings.test.ts +250 -0
- package/src/__tests__/node-allowlist.test.ts +186 -0
- package/src/__tests__/node-profile.test.ts +142 -0
- package/src/__tests__/parser.test.ts +191 -0
- package/src/__tests__/pi-cantbook-e2e.test.ts +294 -0
- package/src/__tests__/rcasd-one-envelope.test.ts +239 -0
- package/src/__tests__/runtime.test.ts +502 -4
- package/src/index.ts +8 -3
- package/src/parser.ts +144 -1
- package/src/runtime.ts +363 -198
package/dist/runtime.js
CHANGED
|
@@ -36,6 +36,15 @@
|
|
|
36
36
|
*/
|
|
37
37
|
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
38
38
|
import { dirname, join } from 'node:path';
|
|
39
|
+
// T11762 ST-2: registry-driven ensures.schema resolution. The bodied accessors
|
|
40
|
+
// live in `@cleocode/core` (Gate-10 contracts-purity forbids them in the
|
|
41
|
+
// contracts leaf); core does NOT depend on playbooks, so this import direction
|
|
42
|
+
// is cycle-free (mirrors the existing `@cleocode/core/store/schema` import in
|
|
43
|
+
// `./schema.ts`).
|
|
44
|
+
// T1944 (M4): `defaultVariableResolver` is the canonical T1238 mustache engine,
|
|
45
|
+
// re-used here (DRY) to resolve `PlaybookAgenticNode.inputs` `{{path}}`
|
|
46
|
+
// templates at the dispatch boundary rather than re-implementing substitution.
|
|
47
|
+
import { defaultVariableResolver, getEnsuresSchema, listEnsuresSchemaNames } from '@cleocode/core';
|
|
39
48
|
import { createApprovalGate, getPlaybookSecret } from './approval.js';
|
|
40
49
|
import { createPlaybookApproval, createPlaybookRun, getPlaybookApprovalByToken, getPlaybookRun, updatePlaybookRun, } from './state.js';
|
|
41
50
|
/**
|
|
@@ -61,13 +70,16 @@ export const E_PLAYBOOK_RESUME_BLOCKED = 'E_PLAYBOOK_RESUME_BLOCKED';
|
|
|
61
70
|
function buildEdgeIndex(def) {
|
|
62
71
|
const outgoing = new Map();
|
|
63
72
|
const incoming = new Map();
|
|
73
|
+
const outgoingEdges = new Map();
|
|
64
74
|
for (const n of def.nodes) {
|
|
65
75
|
outgoing.set(n.id, []);
|
|
66
76
|
incoming.set(n.id, []);
|
|
77
|
+
outgoingEdges.set(n.id, []);
|
|
67
78
|
}
|
|
68
79
|
for (const e of def.edges) {
|
|
69
80
|
outgoing.get(e.from)?.push(e.to);
|
|
70
81
|
incoming.get(e.to)?.push(e.from);
|
|
82
|
+
outgoingEdges.get(e.from)?.push(e);
|
|
71
83
|
}
|
|
72
84
|
for (const n of def.nodes) {
|
|
73
85
|
if (!n.depends)
|
|
@@ -81,11 +93,16 @@ function buildEdgeIndex(def) {
|
|
|
81
93
|
const inc = incoming.get(n.id);
|
|
82
94
|
if (inc && !inc.includes(dep))
|
|
83
95
|
inc.push(dep);
|
|
96
|
+
// depends-derived reverse edges are always unconditional.
|
|
97
|
+
const outE = outgoingEdges.get(dep);
|
|
98
|
+
if (outE && !outE.some((e) => e.to === n.id))
|
|
99
|
+
outE.push({ from: dep, to: n.id });
|
|
84
100
|
}
|
|
85
101
|
}
|
|
86
102
|
return {
|
|
87
103
|
outgoing: new Map([...outgoing].map(([k, v]) => [k, Object.freeze([...v])])),
|
|
88
104
|
incoming: new Map([...incoming].map(([k, v]) => [k, Object.freeze([...v])])),
|
|
105
|
+
outgoingEdges: new Map([...outgoingEdges].map(([k, v]) => [k, Object.freeze([...v])])),
|
|
89
106
|
};
|
|
90
107
|
}
|
|
91
108
|
/**
|
|
@@ -106,22 +123,116 @@ function resolveEntryNode(def, idx) {
|
|
|
106
123
|
throw new Error(`${E_PLAYBOOK_RUNTIME_INVALID}: no entry node (every node has a predecessor) in ${def.name}`);
|
|
107
124
|
}
|
|
108
125
|
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
126
|
+
* Evaluate a declarative {@link PlaybookEdgeCondition} against the run
|
|
127
|
+
* `context`. The predicate is pure data (no `eval`, no expression parsing) so
|
|
128
|
+
* routing stays deterministic and sandbox-safe (T11806).
|
|
111
129
|
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
130
|
+
* Exactly one operator is set on a parsed condition (the parser enforces
|
|
131
|
+
* this); each is checked in turn:
|
|
132
|
+
* - `equals` / `notEquals` — strict `Object.is`-style equality on
|
|
133
|
+
* `context[field]`.
|
|
134
|
+
* - `exists` — `field` present-in / absent-from context.
|
|
135
|
+
* - `in` — `context[field]` is one of the listed values (strict equality).
|
|
136
|
+
* - `truthy` — JS truthiness of `context[field]`.
|
|
137
|
+
*
|
|
138
|
+
* A condition naming a field absent from context evaluates to `false` for
|
|
139
|
+
* value comparisons (`equals`/`in`), `true` for `notEquals` (absent ≠ value),
|
|
140
|
+
* and is handled explicitly by `exists`/`truthy`.
|
|
141
|
+
*
|
|
142
|
+
* @param condition - Parsed, validated branch guard.
|
|
143
|
+
* @param context - Current accumulated run context.
|
|
144
|
+
* @returns `true` when the edge should be traversed.
|
|
145
|
+
*
|
|
146
|
+
* @task T11806 — cantbook runtime branching
|
|
115
147
|
*/
|
|
116
|
-
function
|
|
117
|
-
const
|
|
118
|
-
|
|
148
|
+
function evaluateEdgeCondition(condition, context) {
|
|
149
|
+
const has = Object.hasOwn(context, condition.field);
|
|
150
|
+
const value = context[condition.field];
|
|
151
|
+
if (condition.exists !== undefined) {
|
|
152
|
+
return condition.exists ? has : !has;
|
|
153
|
+
}
|
|
154
|
+
if (condition.truthy !== undefined) {
|
|
155
|
+
return condition.truthy ? Boolean(value) : !value;
|
|
156
|
+
}
|
|
157
|
+
if (Object.hasOwn(condition, 'equals')) {
|
|
158
|
+
return has && Object.is(value, condition.equals);
|
|
159
|
+
}
|
|
160
|
+
if (Object.hasOwn(condition, 'notEquals')) {
|
|
161
|
+
// Absent field is, by definition, not equal to any concrete value.
|
|
162
|
+
return !has || !Object.is(value, condition.notEquals);
|
|
163
|
+
}
|
|
164
|
+
if (condition.in !== undefined) {
|
|
165
|
+
return has && condition.in.some((candidate) => Object.is(value, candidate));
|
|
166
|
+
}
|
|
167
|
+
// Parser guarantees one operator is set; unreachable in practice.
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Resolve the next node id to traverse from `nodeId`, given the current run
|
|
172
|
+
* `context`, with support for guarded conditional branching (T11806).
|
|
173
|
+
*
|
|
174
|
+
* Routing rules (minimal, fail-closed, backward-compatible):
|
|
175
|
+
* - **0 outgoing edges** → `null` (terminal "end" state).
|
|
176
|
+
* - Edges are partitioned into *unconditional* (no `when`) and *guarded*
|
|
177
|
+
* (`when` predicate satisfied against `context`).
|
|
178
|
+
* - **>1 unconditional successor** → throw `E_PLAYBOOK_RUNTIME_INVALID`
|
|
179
|
+
* (genuine ambiguity — author must add `when` guards). This preserves the
|
|
180
|
+
* pre-T11806 guard for the un-guarded fan-out case.
|
|
181
|
+
* - **Exactly 1 satisfied successor** (unconditional or guarded) → traverse
|
|
182
|
+
* it. Linear playbooks are unchanged: their single un-guarded edge is the
|
|
183
|
+
* sole candidate.
|
|
184
|
+
* - **Outgoing edges exist but NONE is satisfied** → throw (fail-closed: a
|
|
185
|
+
* branch must always have a reachable default; silently terminating would
|
|
186
|
+
* mask an authoring bug).
|
|
187
|
+
* - **>1 satisfied successor** → throw (ambiguous branch — predicates must be
|
|
188
|
+
* mutually exclusive; parallel fan-out is a deferred follow-up).
|
|
189
|
+
*
|
|
190
|
+
* @param nodeId - Current node id.
|
|
191
|
+
* @param idx - Pre-computed edge index.
|
|
192
|
+
* @param context - Current accumulated run context (predicate evaluation).
|
|
193
|
+
* @returns The single successor node id, or `null` when terminal.
|
|
194
|
+
* @throws {Error} stamped {@link E_PLAYBOOK_RUNTIME_INVALID} on ambiguous or
|
|
195
|
+
* unsatisfiable branching.
|
|
196
|
+
*/
|
|
197
|
+
function resolveNextNodeId(nodeId, idx, context) {
|
|
198
|
+
const edges = idx.outgoingEdges.get(nodeId) ?? [];
|
|
199
|
+
if (edges.length === 0)
|
|
119
200
|
return null;
|
|
120
|
-
|
|
121
|
-
|
|
201
|
+
// Fast path: a single unconditional edge (the linear, pre-T11806 shape).
|
|
202
|
+
if (edges.length === 1) {
|
|
203
|
+
const [only] = edges;
|
|
204
|
+
if (only === undefined) {
|
|
205
|
+
throw new Error(`${E_PLAYBOOK_RUNTIME_INVALID}: node ${nodeId} has undefined successor`);
|
|
206
|
+
}
|
|
207
|
+
if (only.when !== undefined && !evaluateEdgeCondition(only.when, context)) {
|
|
208
|
+
throw new Error(`${E_PLAYBOOK_RUNTIME_INVALID}: node ${nodeId} sole successor "${only.to}" is guarded ` +
|
|
209
|
+
`by a "when" predicate that is not satisfied — branch has no reachable default`);
|
|
210
|
+
}
|
|
211
|
+
return only.to;
|
|
212
|
+
}
|
|
213
|
+
// Fan-out: reject >1 unconditional successor (genuine ambiguity).
|
|
214
|
+
const unconditional = edges.filter((e) => e.when === undefined);
|
|
215
|
+
if (unconditional.length > 1) {
|
|
216
|
+
throw new Error(`${E_PLAYBOOK_RUNTIME_INVALID}: node ${nodeId} has ${unconditional.length} unconditional ` +
|
|
217
|
+
`successors; guarded branching requires every additional edge to carry a "when" predicate`);
|
|
218
|
+
}
|
|
219
|
+
// Collect satisfied successors: unconditional edge (if any) + guarded edges
|
|
220
|
+
// whose predicate holds.
|
|
221
|
+
const satisfied = [];
|
|
222
|
+
for (const e of edges) {
|
|
223
|
+
if (e.when === undefined || evaluateEdgeCondition(e.when, context)) {
|
|
224
|
+
satisfied.push(e.to);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (satisfied.length === 0) {
|
|
228
|
+
throw new Error(`${E_PLAYBOOK_RUNTIME_INVALID}: node ${nodeId} has ${edges.length} successors but no ` +
|
|
229
|
+
`"when" predicate is satisfied — branch has no reachable default`);
|
|
122
230
|
}
|
|
123
|
-
|
|
124
|
-
|
|
231
|
+
if (satisfied.length > 1) {
|
|
232
|
+
throw new Error(`${E_PLAYBOOK_RUNTIME_INVALID}: node ${nodeId} has ${satisfied.length} satisfied successors ` +
|
|
233
|
+
`(${satisfied.join(', ')}); branch predicates must be mutually exclusive`);
|
|
234
|
+
}
|
|
235
|
+
const [next] = satisfied;
|
|
125
236
|
if (next === undefined) {
|
|
126
237
|
throw new Error(`${E_PLAYBOOK_RUNTIME_INVALID}: node ${nodeId} has undefined successor`);
|
|
127
238
|
}
|
|
@@ -141,12 +252,74 @@ function resolveNode(nodeId, idx) {
|
|
|
141
252
|
// ---------------------------------------------------------------------------
|
|
142
253
|
// Per-node execution
|
|
143
254
|
// ---------------------------------------------------------------------------
|
|
255
|
+
/**
|
|
256
|
+
* Resolve a {@link PlaybookAgenticNode}'s declared `inputs` into the per-step
|
|
257
|
+
* binding map handed to the dispatcher (T1944 · M4 cantbook done-gate).
|
|
258
|
+
*
|
|
259
|
+
* Each `inputs` value is a mustache `{{path}}` template. Templates are resolved
|
|
260
|
+
* against the accumulated run `context` using the canonical T1238
|
|
261
|
+
* {@link DefaultVariableResolver} (no second substitution engine — DRY) and the
|
|
262
|
+
* resolved key/value pairs are then layered ON TOP of the run context. The
|
|
263
|
+
* result is the dispatch-time binding map.
|
|
264
|
+
*
|
|
265
|
+
* ## Canonical resolver precedence (highest → lowest)
|
|
266
|
+
*
|
|
267
|
+
* 1. **step bindings** — the resolved `node.inputs` themselves. They are merged
|
|
268
|
+
* LAST over the run context, so a step input always SHADOWS a same-named
|
|
269
|
+
* context key (AC3 / AC6).
|
|
270
|
+
* 2. **playbook bindings** — the accumulated run `context` (seeded from the
|
|
271
|
+
* playbook's declared inputs + every prior node's merged output).
|
|
272
|
+
* 3. **session / project-context / env / default** — supplied to the underlying
|
|
273
|
+
* {@link DefaultVariableResolver} via the {@link SubstitutionContext} below.
|
|
274
|
+
*
|
|
275
|
+
* The run `context` is exposed to the resolver through BOTH the `projectContext`
|
|
276
|
+
* tier (which supports dot-notation, so `{{inputs.epicId}}` and `{{context.foo}}`
|
|
277
|
+
* resolve into nested objects) and `process.env` (the `env` tier). The
|
|
278
|
+
* resolver runs in lenient mode.
|
|
279
|
+
*
|
|
280
|
+
* ## Missing-key rule
|
|
281
|
+
*
|
|
282
|
+
* Lenient. When a `{{path}}` template references a value that resolves through
|
|
283
|
+
* NO tier, the literal placeholder text is left intact in the bound value (e.g.
|
|
284
|
+
* `{{missing}}` stays `"{{missing}}"`). This mirrors the canonical T1238
|
|
285
|
+
* lenient posture — a missing input never aborts the dispatch; the agent
|
|
286
|
+
* receives the literal placeholder and can fail loudly downstream if required.
|
|
287
|
+
*
|
|
288
|
+
* @param node - The agentic node whose `inputs` are resolved.
|
|
289
|
+
* @param context - The accumulated run bindings at dispatch time.
|
|
290
|
+
* @returns A new binding map = `{ ...context, ...resolvedStepInputs }`. When the
|
|
291
|
+
* node declares no `inputs`, this is a shallow copy of `context`.
|
|
292
|
+
*/
|
|
293
|
+
export function resolveStepBindings(node, context) {
|
|
294
|
+
// No declared inputs — bindings are just a snapshot of the run context.
|
|
295
|
+
if (node.inputs === undefined || Object.keys(node.inputs).length === 0) {
|
|
296
|
+
return { ...context };
|
|
297
|
+
}
|
|
298
|
+
// Expose the run context to the canonical resolver. `projectContext` is the
|
|
299
|
+
// dot-notation-capable tier, so `{{inputs.epicId}}` / `{{context.foo.bar}}`
|
|
300
|
+
// walk into nested objects; flat keys (`{{epicId}}`) resolve via a
|
|
301
|
+
// single-segment walk on the same tier.
|
|
302
|
+
const substitutionContext = {
|
|
303
|
+
projectContext: context,
|
|
304
|
+
env: process.env,
|
|
305
|
+
};
|
|
306
|
+
const resolvedInputs = {};
|
|
307
|
+
for (const [key, template] of Object.entries(node.inputs)) {
|
|
308
|
+
// Lenient resolve — missing placeholders are left literal (see TSDoc).
|
|
309
|
+
const result = defaultVariableResolver.resolve(template, substitutionContext, {
|
|
310
|
+
strict: false,
|
|
311
|
+
});
|
|
312
|
+
resolvedInputs[key] = result.text;
|
|
313
|
+
}
|
|
314
|
+
// Step bindings shadow the run context for same-named keys (AC3 / AC6).
|
|
315
|
+
return { ...context, ...resolvedInputs };
|
|
316
|
+
}
|
|
144
317
|
/**
|
|
145
318
|
* Execute a single `agentic` node via the injected {@link AgentDispatcher}.
|
|
146
319
|
* The dispatcher receives the current context and must return a success /
|
|
147
320
|
* failure envelope — any thrown exception is normalized into a failure.
|
|
148
321
|
*/
|
|
149
|
-
async function executeAgenticNode(node, runId, context, iteration, dispatcher) {
|
|
322
|
+
async function executeAgenticNode(node, runId, context, iteration, dispatcher, playbookName) {
|
|
150
323
|
const agentId = node.agent ?? node.skill;
|
|
151
324
|
if (agentId === undefined) {
|
|
152
325
|
// Parser guarantees at-least-one, but narrow defensively.
|
|
@@ -157,6 +330,9 @@ async function executeAgenticNode(node, runId, context, iteration, dispatcher) {
|
|
|
157
330
|
}
|
|
158
331
|
const taskIdRaw = context['taskId'];
|
|
159
332
|
const taskId = typeof taskIdRaw === 'string' && taskIdRaw.length > 0 ? taskIdRaw : runId;
|
|
333
|
+
// T1944 (M4): resolve `node.inputs` `{{path}}` templates against the run
|
|
334
|
+
// context and layer them on top so step inputs shadow context (AC1/AC2/AC3).
|
|
335
|
+
const bindings = resolveStepBindings(node, context);
|
|
160
336
|
try {
|
|
161
337
|
const result = await dispatcher.dispatch({
|
|
162
338
|
runId,
|
|
@@ -164,7 +340,20 @@ async function executeAgenticNode(node, runId, context, iteration, dispatcher) {
|
|
|
164
340
|
agentId,
|
|
165
341
|
taskId,
|
|
166
342
|
context: { ...context },
|
|
343
|
+
bindings,
|
|
167
344
|
iteration,
|
|
345
|
+
// T11759 (M4): forward the playbook name + the node's per-stage LLM pin so
|
|
346
|
+
// the dispatcher can resolve a declared `profile:` through the E9
|
|
347
|
+
// chokepoint. All-additive; omitted when the node declares no pin.
|
|
348
|
+
playbookName,
|
|
349
|
+
...(node.profile !== undefined ? { profile: node.profile } : {}),
|
|
350
|
+
...(node.model !== undefined ? { model: node.model } : {}),
|
|
351
|
+
...(node.provider !== undefined ? { provider: node.provider } : {}),
|
|
352
|
+
// T1947 (M4): forward the per-step skill/tool allowlists so the dispatcher
|
|
353
|
+
// can intersect them against the Tier-0/1/2 baseline at spawn time. Omitted
|
|
354
|
+
// when the node declares no allowlist (additive — baseline unchanged).
|
|
355
|
+
...(node.allowed_skills !== undefined ? { allowedSkills: node.allowed_skills } : {}),
|
|
356
|
+
...(node.allowed_tools !== undefined ? { allowedTools: node.allowed_tools } : {}),
|
|
168
357
|
});
|
|
169
358
|
if (result.status === 'success') {
|
|
170
359
|
return { kind: 'success', output: result.output };
|
|
@@ -347,119 +536,6 @@ function iterationCapFor(node, runtimeDefault) {
|
|
|
347
536
|
return cap;
|
|
348
537
|
return runtimeDefault;
|
|
349
538
|
}
|
|
350
|
-
/**
|
|
351
|
-
* Validate the `task_tree` shape emitted by a RCASD decomposition node.
|
|
352
|
-
*
|
|
353
|
-
* Returns a human-readable violation string when the shape is invalid, or
|
|
354
|
-
* `null` when the tree is well-formed. The check is performed AFTER the node
|
|
355
|
-
* output is merged into the playbook context (post-merge ensures check).
|
|
356
|
-
*
|
|
357
|
-
* Rules enforced (AC3 — T11499):
|
|
358
|
-
* 1. `task_tree` must be a non-empty array.
|
|
359
|
-
* 2. Each entry must have a non-empty string `title`.
|
|
360
|
-
* 3. Each entry must have a non-empty `acceptance` array (at least one string).
|
|
361
|
-
* 4. No entry may have an empty `depends` array that references a non-existent
|
|
362
|
-
* sibling (intra-tree orphan check).
|
|
363
|
-
*
|
|
364
|
-
* The validator is intentionally lenient on optional fields (id, parentId,
|
|
365
|
-
* depends) so that agents can emit a minimal valid tree without being rejected
|
|
366
|
-
* for cosmetic omissions.
|
|
367
|
-
*
|
|
368
|
-
* @param nodeId - Node identifier (used in error messages).
|
|
369
|
-
* @param taskTree - The raw value stored at `context.task_tree`.
|
|
370
|
-
* @returns Violation string or `null` (valid).
|
|
371
|
-
*
|
|
372
|
-
* @task T11499 E7-CLOSE-LOOPS AC3
|
|
373
|
-
*/
|
|
374
|
-
export function validateDecompositionTaskTree(nodeId, taskTree) {
|
|
375
|
-
if (!Array.isArray(taskTree)) {
|
|
376
|
-
return `ensures.schema[task_tree] on ${nodeId}: task_tree must be a non-empty array, got ${typeof taskTree}`;
|
|
377
|
-
}
|
|
378
|
-
if (taskTree.length === 0) {
|
|
379
|
-
return `ensures.schema[task_tree] on ${nodeId}: task_tree is an empty array — decomposition produced no tasks`;
|
|
380
|
-
}
|
|
381
|
-
const knownIds = new Set();
|
|
382
|
-
for (const entry of taskTree) {
|
|
383
|
-
if (typeof entry.id === 'string') {
|
|
384
|
-
knownIds.add(entry.id);
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
for (let i = 0; i < taskTree.length; i++) {
|
|
388
|
-
const entry = taskTree[i];
|
|
389
|
-
if (typeof entry !== 'object' || entry === null) {
|
|
390
|
-
return `ensures.schema[task_tree] on ${nodeId}: entry[${i}] must be an object, got ${entry === null ? 'null' : typeof entry}`;
|
|
391
|
-
}
|
|
392
|
-
if (typeof entry.title !== 'string' || entry.title.trim().length === 0) {
|
|
393
|
-
return `ensures.schema[task_tree] on ${nodeId}: entry[${i}].title must be a non-empty string`;
|
|
394
|
-
}
|
|
395
|
-
if (!Array.isArray(entry.acceptance) || entry.acceptance.length === 0) {
|
|
396
|
-
return (`ensures.schema[task_tree] on ${nodeId}: entry[${i}] ("${entry.title}") ` +
|
|
397
|
-
`must have a non-empty acceptance array`);
|
|
398
|
-
}
|
|
399
|
-
const hasValidAc = entry.acceptance.some((ac) => typeof ac === 'string' && ac.trim().length > 0);
|
|
400
|
-
if (!hasValidAc) {
|
|
401
|
-
return (`ensures.schema[task_tree] on ${nodeId}: entry[${i}] ("${entry.title}") ` +
|
|
402
|
-
`acceptance array contains no non-empty strings`);
|
|
403
|
-
}
|
|
404
|
-
// Intra-tree orphan check: if depends[] are specified, they must reference
|
|
405
|
-
// another entry in the same tree (by id). References to external tasks are
|
|
406
|
-
// allowed (they have no id in this tree), so we only flag ids that look
|
|
407
|
-
// like intra-tree references but are absent from knownIds.
|
|
408
|
-
if (Array.isArray(entry.depends) && knownIds.size > 0) {
|
|
409
|
-
for (const depId of entry.depends) {
|
|
410
|
-
// Only flag if the depId matches the T#### pattern and is not in knownIds
|
|
411
|
-
// (external task IDs that exist in the DB are valid but we can't check here).
|
|
412
|
-
if (typeof depId === 'string' && /^T\d{3,}$/.test(depId) && !knownIds.has(depId)) {
|
|
413
|
-
// Soft warn only (not a hard violation) — the dep may be a pre-existing task.
|
|
414
|
-
// We don't hard-fail here because agents may correctly depend on existing tasks.
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
return null; // valid
|
|
420
|
-
}
|
|
421
|
-
/**
|
|
422
|
-
* Validate the `evidence` field emitted by an IVTR validation node.
|
|
423
|
-
*
|
|
424
|
-
* IVTR validation nodes must emit an `evidence` key whose value is either:
|
|
425
|
-
* - An object with at least one key (structured evidence map), OR
|
|
426
|
-
* - A non-empty string (free-text evidence reference).
|
|
427
|
-
*
|
|
428
|
-
* Empty evidence (`{}`, `''`, `[]`) is rejected so that spawned agents
|
|
429
|
-
* cannot satisfy the gate by emitting an empty object.
|
|
430
|
-
*
|
|
431
|
-
* @param nodeId - Node identifier (used in error messages).
|
|
432
|
-
* @param evidence - The raw value stored at `context.evidence`.
|
|
433
|
-
* @returns Violation string or `null` (valid).
|
|
434
|
-
*
|
|
435
|
-
* @task T11499 E7-CLOSE-LOOPS AC3
|
|
436
|
-
*/
|
|
437
|
-
export function validateIvtrEvidenceOutput(nodeId, evidence) {
|
|
438
|
-
if (evidence === null || evidence === undefined) {
|
|
439
|
-
return `ensures.schema[evidence] on ${nodeId}: evidence must be present (non-null, non-undefined)`;
|
|
440
|
-
}
|
|
441
|
-
if (typeof evidence === 'string') {
|
|
442
|
-
if (evidence.trim().length === 0) {
|
|
443
|
-
return `ensures.schema[evidence] on ${nodeId}: evidence string must not be empty`;
|
|
444
|
-
}
|
|
445
|
-
return null; // valid non-empty string
|
|
446
|
-
}
|
|
447
|
-
if (Array.isArray(evidence)) {
|
|
448
|
-
if (evidence.length === 0) {
|
|
449
|
-
return `ensures.schema[evidence] on ${nodeId}: evidence array must not be empty`;
|
|
450
|
-
}
|
|
451
|
-
return null; // valid non-empty array
|
|
452
|
-
}
|
|
453
|
-
if (typeof evidence === 'object') {
|
|
454
|
-
const keys = Object.keys(evidence);
|
|
455
|
-
if (keys.length === 0) {
|
|
456
|
-
return `ensures.schema[evidence] on ${nodeId}: evidence object must have at least one key (got {})`;
|
|
457
|
-
}
|
|
458
|
-
return null; // valid non-empty object
|
|
459
|
-
}
|
|
460
|
-
// Numbers, booleans etc. are not valid evidence shapes.
|
|
461
|
-
return `ensures.schema[evidence] on ${nodeId}: evidence must be a string, array, or object (got ${typeof evidence})`;
|
|
462
|
-
}
|
|
463
539
|
/**
|
|
464
540
|
* Core step-by-step executor shared by {@link executePlaybook} and
|
|
465
541
|
* {@link resumePlaybook}. Starts at `startNodeId` and walks the graph until
|
|
@@ -517,7 +593,7 @@ async function runFromNode(args) {
|
|
|
517
593
|
}
|
|
518
594
|
let outcome;
|
|
519
595
|
if (node.type === 'agentic') {
|
|
520
|
-
outcome = await executeAgenticNode(node, run.runId, context, attempt, dispatcher);
|
|
596
|
+
outcome = await executeAgenticNode(node, run.runId, context, attempt, dispatcher, playbook.name);
|
|
521
597
|
}
|
|
522
598
|
else if (node.type === 'deterministic') {
|
|
523
599
|
outcome = await executeDeterministicNode(node, run.runId, context, attempt, dispatcher, deterministicRunner);
|
|
@@ -546,18 +622,45 @@ async function runFromNode(args) {
|
|
|
546
622
|
}
|
|
547
623
|
}
|
|
548
624
|
}
|
|
549
|
-
// RCASD/IVTR output schema validation (T11499 AC3):
|
|
625
|
+
// RCASD/IVTR + generic output schema validation (T11499 AC3 · T11762 ST-2):
|
|
550
626
|
// Enforce ensures.schema beyond key-presence so garbage decompositions
|
|
551
627
|
// cannot silently pass the runtime contract check.
|
|
628
|
+
//
|
|
629
|
+
// De-hardcoded (T11762): the former `if (schema === 'task_tree') …
|
|
630
|
+
// else if (=== 'evidence')` block — which SILENTLY SKIPPED any other
|
|
631
|
+
// schema name — is replaced by a registry-driven lookup against the
|
|
632
|
+
// ensures-schema Zod registry (`getEnsuresSchema` in `@cleocode/core`,
|
|
633
|
+
// backed by the `@cleocode/contracts` registry DATA). An UNKNOWN schema
|
|
634
|
+
// name now FAILS CLOSED (AC2) and is routed through the SAME
|
|
635
|
+
// `contract_violation` handler below (AC3), rather than passing silently.
|
|
552
636
|
if (node.ensures?.schema) {
|
|
553
637
|
let schemaViolation = null;
|
|
554
|
-
|
|
555
|
-
|
|
638
|
+
const spec = getEnsuresSchema(node.ensures.schema);
|
|
639
|
+
if (spec === null) {
|
|
640
|
+
// FAIL-CLOSED (AC2): unknown schema names were historically skipped
|
|
641
|
+
// silently. They are now an explicit, audited contract violation.
|
|
642
|
+
schemaViolation =
|
|
643
|
+
`ensures.schema[${node.ensures.schema}] on ${node.id}: unknown schema name. ` +
|
|
644
|
+
`Registered: ${listEnsuresSchemaNames().join(', ')}`;
|
|
556
645
|
}
|
|
557
|
-
else
|
|
558
|
-
|
|
646
|
+
else {
|
|
647
|
+
// Validate the post-merge context value against the registered Zod
|
|
648
|
+
// schema. The message wrapper re-applies the historical
|
|
649
|
+
// `ensures.schema[<name>] on <nodeId>: <issue>` prefix so the
|
|
650
|
+
// end-to-end violation strings stay parity-identical to the deleted
|
|
651
|
+
// bespoke validators (the Zod issue messages were crafted in ST-1 to
|
|
652
|
+
// echo the original suffixes).
|
|
653
|
+
const result = spec.schema.safeParse(context[spec.contextKey]);
|
|
654
|
+
if (!result.success) {
|
|
655
|
+
const issue = result.error.issues[0];
|
|
656
|
+
const path = issue?.path ?? [];
|
|
657
|
+
const at = path.length > 0 ? ` at ${path.join('.')}` : '';
|
|
658
|
+
schemaViolation =
|
|
659
|
+
`ensures.schema[${spec.name}] on ${node.id}: ` +
|
|
660
|
+
`${issue?.message ?? 'invalid shape'}${at}`;
|
|
661
|
+
}
|
|
559
662
|
}
|
|
560
|
-
//
|
|
663
|
+
// ── UNCHANGED from here (the existing contract_violation handler) ──
|
|
561
664
|
if (schemaViolation !== null) {
|
|
562
665
|
auditContractViolation(args.projectRoot, run.runId, node.id, 'ensures', node.ensures.schema, playbook.name);
|
|
563
666
|
const handled = handleContractErrorHandler(playbook, 'contract_violation', schemaViolation);
|
|
@@ -578,7 +681,9 @@ async function runFromNode(args) {
|
|
|
578
681
|
if (failedNodeId !== undefined)
|
|
579
682
|
break;
|
|
580
683
|
// Validate outgoing edge contracts (edge.contract.requires on the FROM side).
|
|
581
|
-
|
|
684
|
+
// Branch routing (T11806) reads the post-merge context so guard
|
|
685
|
+
// predicates can act on this node's freshly merged output.
|
|
686
|
+
const nextId = resolveNextNodeId(node.id, edgeIndex, context);
|
|
582
687
|
if (nextId !== null) {
|
|
583
688
|
const edge = resolveEdge(node.id, nextId, playbook.edges);
|
|
584
689
|
if (edge?.contract?.requires) {
|
|
@@ -820,10 +925,26 @@ export async function resumePlaybook(options) {
|
|
|
820
925
|
if (approvalNode === undefined || approvalNode.type !== 'approval') {
|
|
821
926
|
throw new Error(`${E_PLAYBOOK_RESUME_BLOCKED}: approval node ${approval.nodeId} not found in playbook ${options.playbook.name}`);
|
|
822
927
|
}
|
|
823
|
-
const
|
|
928
|
+
const now = options.now ?? (() => new Date());
|
|
929
|
+
const maxIterationsDefault = options.maxIterationsDefault ?? 3;
|
|
930
|
+
const approvalSecret = options.approvalSecret ?? getPlaybookSecret();
|
|
931
|
+
const context = { ...run.bindings };
|
|
932
|
+
const iterationCounts = { ...run.iterationCounts };
|
|
933
|
+
// Log the approval decision into the context so downstream nodes can act on
|
|
934
|
+
// it. Set BEFORE resolving the successor so a gate can branch on the
|
|
935
|
+
// approver's decision via a `when: { field: __lastApproval... }` edge (T11806
|
|
936
|
+
// approval/resume branch parity).
|
|
937
|
+
context['__lastApproval'] = {
|
|
938
|
+
nodeId: approval.nodeId,
|
|
939
|
+
approvalId: approval.approvalId,
|
|
940
|
+
approver: approval.approver,
|
|
941
|
+
reason: approval.reason,
|
|
942
|
+
approvedAt: approval.approvedAt,
|
|
943
|
+
};
|
|
944
|
+
const successor = resolveNextNodeId(approvalNode.id, edgeIndex, context);
|
|
824
945
|
if (successor === null) {
|
|
825
946
|
// Approval at the tail of the graph completes the run immediately.
|
|
826
|
-
const completedAt =
|
|
947
|
+
const completedAt = now().toISOString();
|
|
827
948
|
updatePlaybookRun(options.db, run.runId, {
|
|
828
949
|
status: 'completed',
|
|
829
950
|
currentNode: null,
|
|
@@ -842,19 +963,6 @@ export async function resumePlaybook(options) {
|
|
|
842
963
|
currentNode: successor,
|
|
843
964
|
errorContext: null,
|
|
844
965
|
});
|
|
845
|
-
const now = options.now ?? (() => new Date());
|
|
846
|
-
const maxIterationsDefault = options.maxIterationsDefault ?? 3;
|
|
847
|
-
const approvalSecret = options.approvalSecret ?? getPlaybookSecret();
|
|
848
|
-
const context = { ...run.bindings };
|
|
849
|
-
const iterationCounts = { ...run.iterationCounts };
|
|
850
|
-
// Log the approval decision into the context so downstream nodes can act on it.
|
|
851
|
-
context['__lastApproval'] = {
|
|
852
|
-
nodeId: approval.nodeId,
|
|
853
|
-
approvalId: approval.approvalId,
|
|
854
|
-
approver: approval.approver,
|
|
855
|
-
reason: approval.reason,
|
|
856
|
-
approvedAt: approval.approvedAt,
|
|
857
|
-
};
|
|
858
966
|
// Persist an approval trace row for audit purposes. createPlaybookApproval
|
|
859
967
|
// is distinct from createApprovalGate — the latter generates the HMAC
|
|
860
968
|
// resume token, while this helper records arbitrary approval state.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cleocode/playbooks",
|
|
3
|
-
"version": "2026.6.
|
|
3
|
+
"version": "2026.6.14",
|
|
4
4
|
"description": "Playbook DSL + runtime for CLEO — T889 Orchestration Coherence v3",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"drizzle-orm": "1.0.0-rc.3",
|
|
21
21
|
"js-yaml": "^4.1.0",
|
|
22
|
-
"@cleocode/contracts": "2026.6.
|
|
23
|
-
"@cleocode/core": "2026.6.
|
|
22
|
+
"@cleocode/contracts": "2026.6.14",
|
|
23
|
+
"@cleocode/core": "2026.6.14"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/js-yaml": "^4.0.9",
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
version: "1.0"
|
|
2
|
+
name: conditional-branch
|
|
3
|
+
description: >
|
|
4
|
+
T11806 branching fixture — a single classify node fans out to two mutually
|
|
5
|
+
exclusive successors via `when` guards. When context.needsResearch is true the
|
|
6
|
+
run routes through branch-A (deep-research); otherwise it routes through
|
|
7
|
+
branch-B (skip-research). Both branches converge on a final ship node so the
|
|
8
|
+
run always reaches a single terminal state.
|
|
9
|
+
|
|
10
|
+
inputs:
|
|
11
|
+
- name: taskId
|
|
12
|
+
required: true
|
|
13
|
+
description: Task id being executed.
|
|
14
|
+
|
|
15
|
+
nodes:
|
|
16
|
+
- id: classify
|
|
17
|
+
type: agentic
|
|
18
|
+
skill: ct-research-agent
|
|
19
|
+
description: Classify whether the task needs research; emits needsResearch.
|
|
20
|
+
|
|
21
|
+
- id: deep-research
|
|
22
|
+
type: agentic
|
|
23
|
+
skill: ct-research-agent
|
|
24
|
+
description: Branch-A — taken when needsResearch is true.
|
|
25
|
+
|
|
26
|
+
- id: skip-research
|
|
27
|
+
type: agentic
|
|
28
|
+
skill: ct-task-executor
|
|
29
|
+
description: Branch-B — taken when needsResearch is not true.
|
|
30
|
+
|
|
31
|
+
- id: ship
|
|
32
|
+
type: agentic
|
|
33
|
+
skill: ct-task-executor
|
|
34
|
+
description: Converge — runs after whichever branch was taken.
|
|
35
|
+
|
|
36
|
+
edges:
|
|
37
|
+
- from: classify
|
|
38
|
+
to: deep-research
|
|
39
|
+
when:
|
|
40
|
+
field: needsResearch
|
|
41
|
+
equals: true
|
|
42
|
+
- from: classify
|
|
43
|
+
to: skip-research
|
|
44
|
+
when:
|
|
45
|
+
field: needsResearch
|
|
46
|
+
notEquals: true
|
|
47
|
+
- from: deep-research
|
|
48
|
+
to: ship
|
|
49
|
+
- from: skip-research
|
|
50
|
+
to: ship
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
version: "1.0"
|
|
2
|
+
name: pi-skill-only
|
|
3
|
+
description: >
|
|
4
|
+
T11945 (M4) deterministic e2e fixture — a single skill-only agentic node. The
|
|
5
|
+
node names a real `ct-*` skill so the production dispatcher's
|
|
6
|
+
`runSkillNodeOrSpawn` takes the IN-PROCESS skill path (findSkill resolves it)
|
|
7
|
+
and, when a Pi runner is injected, routes the node THROUGH the PiAgentAdapter.
|
|
8
|
+
No `requires`/`ensures` contracts so schema enforcement never interferes with
|
|
9
|
+
the dispatch-path assertion. With no LLM credential resolvable in CI the Pi
|
|
10
|
+
loop terminates with the cleo-owned no-credential failure — proving the call
|
|
11
|
+
reached the Pi streamFn (the E9 chokepoint), not a live round-trip.
|
|
12
|
+
|
|
13
|
+
inputs:
|
|
14
|
+
- name: taskId
|
|
15
|
+
required: true
|
|
16
|
+
description: Task id being executed (seeds the run context).
|
|
17
|
+
|
|
18
|
+
nodes:
|
|
19
|
+
- id: execute
|
|
20
|
+
type: agentic
|
|
21
|
+
skill: ct-task-executor
|
|
22
|
+
description: >
|
|
23
|
+
Single skill-only node — routes in-process through the SkillExecutorAdapter,
|
|
24
|
+
and through the PiAgentAdapter when the Pi runner is wired (T11945).
|
|
25
|
+
|
|
26
|
+
edges: []
|