@cleocode/playbooks 2026.6.11 → 2026.6.13

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 CHANGED
@@ -27,5 +27,5 @@ export { approveGate, type CreateApprovalGateInput, createApprovalGate, E_APPROV
27
27
  export { type MigratePlaybookFileResult, migratePlaybook, migratePlaybookFile, type NodeComplianceEntry, type PlaybookComplianceReport, validatePlaybookCompliance, } from './migrate-e4.js';
28
28
  export { type ParsePlaybookResult, PlaybookParseError, parsePlaybook, } from './parser.js';
29
29
  export { DEFAULT_POLICY_RULES, type EvaluatePolicyResult, evaluatePolicy, type PolicyRule, } from './policy.js';
30
- export { type AgentDispatcher, type AgentDispatchInput, type AgentDispatchResult, type DeterministicRunInput, type DeterministicRunner, type DeterministicRunResult, E_PLAYBOOK_RESUME_BLOCKED, E_PLAYBOOK_RUNTIME_INVALID, type ExecutePlaybookOptions, type ExecutePlaybookResult, executePlaybook, type PlaybookTerminalStatus, type ResumePlaybookOptions, resumePlaybook, validateDecompositionTaskTree, validateIvtrEvidenceOutput, } from './runtime.js';
30
+ export { type AgentDispatcher, type AgentDispatchInput, type AgentDispatchResult, type DeterministicRunInput, type DeterministicRunner, type DeterministicRunResult, E_PLAYBOOK_RESUME_BLOCKED, E_PLAYBOOK_RUNTIME_INVALID, type ExecutePlaybookOptions, type ExecutePlaybookResult, executePlaybook, type PlaybookTerminalStatus, type ResumePlaybookOptions, resumePlaybook, } from './runtime.js';
31
31
  export { type CreatePlaybookApprovalInput, type CreatePlaybookRunInput, createPlaybookApproval, createPlaybookRun, deletePlaybookRun, getPlaybookApprovalByToken, getPlaybookRun, type ListPlaybookRunsOptions, listPlaybookApprovals, listPlaybookRuns, updatePlaybookApproval, updatePlaybookRun, } from './state.js';
package/dist/index.js CHANGED
@@ -31,7 +31,12 @@ export { PlaybookParseError, parsePlaybook, } from './parser.js';
31
31
  // W4-9: HITL auto-policy evaluator
32
32
  export { DEFAULT_POLICY_RULES, evaluatePolicy, } from './policy.js';
33
33
  // W4-10 / T930: playbook runtime state machine + HITL resume
34
- // T11499 E7-CLOSE-LOOPS AC3: schema validators exported for testing + external use
35
- export { E_PLAYBOOK_RESUME_BLOCKED, E_PLAYBOOK_RUNTIME_INVALID, executePlaybook, resumePlaybook, validateDecompositionTaskTree, validateIvtrEvidenceOutput, } from './runtime.js';
34
+ // T11762 ST-2: the bespoke `validateDecompositionTaskTree` /
35
+ // `validateIvtrEvidenceOutput` validators (and their barrel re-exports) were
36
+ // deleted — their rules are now the `task_tree` / `evidence` Zod schemas in the
37
+ // ensures-schema registry (`@cleocode/contracts` data + `@cleocode/core`
38
+ // accessors). No cross-package consumer imported them, so dropping the
39
+ // re-exports is safe.
40
+ export { E_PLAYBOOK_RESUME_BLOCKED, E_PLAYBOOK_RUNTIME_INVALID, executePlaybook, resumePlaybook, } from './runtime.js';
36
41
  // W4-8: state layer CRUD for playbook_runs + playbook_approvals
37
42
  export { createPlaybookApproval, createPlaybookRun, deletePlaybookRun, getPlaybookApprovalByToken, getPlaybookRun, listPlaybookApprovals, listPlaybookRuns, updatePlaybookApproval, updatePlaybookRun, } from './state.js';
package/dist/parser.js CHANGED
@@ -326,7 +326,87 @@ function parseEdge(raw, index, ids) {
326
326
  const ensures = parseStringArray(raw.contract.ensures, `edges[${index}].contract.ensures`);
327
327
  contract = { requires, ensures };
328
328
  }
329
- return { from: raw.from, to: raw.to, contract };
329
+ const when = parseEdgeCondition(raw.when, `edges[${index}].when`);
330
+ const edge = { from: raw.from, to: raw.to, contract };
331
+ if (when !== undefined)
332
+ edge.when = when;
333
+ return edge;
334
+ }
335
+ /**
336
+ * Recognised comparison operators on a {@link PlaybookEdgeCondition}. Exactly
337
+ * one MUST be present so a guarded edge is unambiguous (T11806).
338
+ */
339
+ const EDGE_CONDITION_OPERATORS = ['equals', 'notEquals', 'exists', 'in', 'truthy'];
340
+ /**
341
+ * Parse the optional `when` branch guard on an edge into a validated
342
+ * {@link PlaybookEdgeCondition}. Returns `undefined` for an absent guard
343
+ * (the edge is unconditional).
344
+ *
345
+ * Validation:
346
+ * - `field` MUST be a non-empty string.
347
+ * - Exactly ONE comparison operator (`equals` | `notEquals` | `exists` | `in`
348
+ * | `truthy`) MUST be supplied — zero is ambiguous, more than one is
349
+ * contradictory, both rejected at parse time so the runtime never has to
350
+ * disambiguate.
351
+ * - `exists` / `truthy` MUST be boolean; `in` MUST be a non-empty array.
352
+ *
353
+ * @param raw - Raw YAML value for `edge.when`.
354
+ * @param field - Field path for diagnostics (e.g. `"edges[2].when"`).
355
+ * @returns The validated condition, or `undefined` when absent.
356
+ * @throws {PlaybookParseError} On any structural violation.
357
+ *
358
+ * @task T11806 — cantbook runtime branching
359
+ */
360
+ function parseEdgeCondition(raw, field) {
361
+ if (raw === undefined)
362
+ return undefined;
363
+ if (!isRecord(raw)) {
364
+ throw new PlaybookParseError(`${field} must be an object`, field, raw);
365
+ }
366
+ if (typeof raw.field !== 'string' || raw.field.length === 0) {
367
+ throw new PlaybookParseError(`${field}.field must be a non-empty string`, `${field}.field`, raw.field);
368
+ }
369
+ const present = EDGE_CONDITION_OPERATORS.filter((op) => Object.hasOwn(raw, op));
370
+ if (present.length === 0) {
371
+ throw new PlaybookParseError(`${field} must declare exactly one of ${EDGE_CONDITION_OPERATORS.join(' | ')}`, field, raw);
372
+ }
373
+ if (present.length > 1) {
374
+ throw new PlaybookParseError(`${field} declares multiple operators (${present.join(', ')}); exactly one is allowed`, field, raw);
375
+ }
376
+ const condition = { field: raw.field };
377
+ const [op] = present;
378
+ switch (op) {
379
+ case 'equals':
380
+ condition.equals = raw.equals;
381
+ break;
382
+ case 'notEquals':
383
+ condition.notEquals = raw.notEquals;
384
+ break;
385
+ case 'in':
386
+ if (!Array.isArray(raw.in) || raw.in.length === 0) {
387
+ throw new PlaybookParseError(`${field}.in must be a non-empty array`, `${field}.in`, raw.in);
388
+ }
389
+ condition.in = [...raw.in];
390
+ break;
391
+ case 'exists':
392
+ if (typeof raw.exists !== 'boolean') {
393
+ throw new PlaybookParseError(`${field}.exists must be a boolean`, `${field}.exists`, raw.exists);
394
+ }
395
+ condition.exists = raw.exists;
396
+ break;
397
+ case 'truthy':
398
+ if (typeof raw.truthy !== 'boolean') {
399
+ throw new PlaybookParseError(`${field}.truthy must be a boolean`, `${field}.truthy`, raw.truthy);
400
+ }
401
+ condition.truthy = raw.truthy;
402
+ break;
403
+ default: {
404
+ // Exhaustiveness guard — EDGE_CONDITION_OPERATORS is the SSoT.
405
+ const exhaustive = op;
406
+ throw new PlaybookParseError(`${field} unknown operator ${String(exhaustive)}`, field, raw);
407
+ }
408
+ }
409
+ return condition;
330
410
  }
331
411
  function parseInputs(raw) {
332
412
  if (raw === undefined)
package/dist/runtime.d.ts CHANGED
@@ -191,48 +191,6 @@ export declare const E_PLAYBOOK_RUNTIME_INVALID: "E_PLAYBOOK_RUNTIME_INVALID";
191
191
  * does not resolve to an `approved` gate.
192
192
  */
193
193
  export declare const E_PLAYBOOK_RESUME_BLOCKED: "E_PLAYBOOK_RESUME_BLOCKED";
194
- /**
195
- * Validate the `task_tree` shape emitted by a RCASD decomposition node.
196
- *
197
- * Returns a human-readable violation string when the shape is invalid, or
198
- * `null` when the tree is well-formed. The check is performed AFTER the node
199
- * output is merged into the playbook context (post-merge ensures check).
200
- *
201
- * Rules enforced (AC3 — T11499):
202
- * 1. `task_tree` must be a non-empty array.
203
- * 2. Each entry must have a non-empty string `title`.
204
- * 3. Each entry must have a non-empty `acceptance` array (at least one string).
205
- * 4. No entry may have an empty `depends` array that references a non-existent
206
- * sibling (intra-tree orphan check).
207
- *
208
- * The validator is intentionally lenient on optional fields (id, parentId,
209
- * depends) so that agents can emit a minimal valid tree without being rejected
210
- * for cosmetic omissions.
211
- *
212
- * @param nodeId - Node identifier (used in error messages).
213
- * @param taskTree - The raw value stored at `context.task_tree`.
214
- * @returns Violation string or `null` (valid).
215
- *
216
- * @task T11499 E7-CLOSE-LOOPS AC3
217
- */
218
- export declare function validateDecompositionTaskTree(nodeId: string, taskTree: unknown): string | null;
219
- /**
220
- * Validate the `evidence` field emitted by an IVTR validation node.
221
- *
222
- * IVTR validation nodes must emit an `evidence` key whose value is either:
223
- * - An object with at least one key (structured evidence map), OR
224
- * - A non-empty string (free-text evidence reference).
225
- *
226
- * Empty evidence (`{}`, `''`, `[]`) is rejected so that spawned agents
227
- * cannot satisfy the gate by emitting an empty object.
228
- *
229
- * @param nodeId - Node identifier (used in error messages).
230
- * @param evidence - The raw value stored at `context.evidence`.
231
- * @returns Violation string or `null` (valid).
232
- *
233
- * @task T11499 E7-CLOSE-LOOPS AC3
234
- */
235
- export declare function validateIvtrEvidenceOutput(nodeId: string, evidence: unknown): string | null;
236
194
  /**
237
195
  * Execute a playbook from its entry node until a terminal state is reached
238
196
  * (`completed`, `failed`, `exceeded_iteration_cap`, or `pending_approval`).
package/dist/runtime.js CHANGED
@@ -36,6 +36,12 @@
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
+ import { getEnsuresSchema, listEnsuresSchemaNames } from '@cleocode/core';
39
45
  import { createApprovalGate, getPlaybookSecret } from './approval.js';
40
46
  import { createPlaybookApproval, createPlaybookRun, getPlaybookApprovalByToken, getPlaybookRun, updatePlaybookRun, } from './state.js';
41
47
  /**
@@ -61,13 +67,16 @@ export const E_PLAYBOOK_RESUME_BLOCKED = 'E_PLAYBOOK_RESUME_BLOCKED';
61
67
  function buildEdgeIndex(def) {
62
68
  const outgoing = new Map();
63
69
  const incoming = new Map();
70
+ const outgoingEdges = new Map();
64
71
  for (const n of def.nodes) {
65
72
  outgoing.set(n.id, []);
66
73
  incoming.set(n.id, []);
74
+ outgoingEdges.set(n.id, []);
67
75
  }
68
76
  for (const e of def.edges) {
69
77
  outgoing.get(e.from)?.push(e.to);
70
78
  incoming.get(e.to)?.push(e.from);
79
+ outgoingEdges.get(e.from)?.push(e);
71
80
  }
72
81
  for (const n of def.nodes) {
73
82
  if (!n.depends)
@@ -81,11 +90,16 @@ function buildEdgeIndex(def) {
81
90
  const inc = incoming.get(n.id);
82
91
  if (inc && !inc.includes(dep))
83
92
  inc.push(dep);
93
+ // depends-derived reverse edges are always unconditional.
94
+ const outE = outgoingEdges.get(dep);
95
+ if (outE && !outE.some((e) => e.to === n.id))
96
+ outE.push({ from: dep, to: n.id });
84
97
  }
85
98
  }
86
99
  return {
87
100
  outgoing: new Map([...outgoing].map(([k, v]) => [k, Object.freeze([...v])])),
88
101
  incoming: new Map([...incoming].map(([k, v]) => [k, Object.freeze([...v])])),
102
+ outgoingEdges: new Map([...outgoingEdges].map(([k, v]) => [k, Object.freeze([...v])])),
89
103
  };
90
104
  }
91
105
  /**
@@ -106,22 +120,116 @@ function resolveEntryNode(def, idx) {
106
120
  throw new Error(`${E_PLAYBOOK_RUNTIME_INVALID}: no entry node (every node has a predecessor) in ${def.name}`);
107
121
  }
108
122
  /**
109
- * Return the single successor node id for `nodeId`, or `null` if `nodeId` is
110
- * terminal (no outgoing edges — the "end" state in the design contract).
123
+ * Evaluate a declarative {@link PlaybookEdgeCondition} against the run
124
+ * `context`. The predicate is pure data (no `eval`, no expression parsing) so
125
+ * routing stays deterministic and sandbox-safe (T11806).
111
126
  *
112
- * Throws on fan-out (> 1 successor) because the deterministic runtime does
113
- * not support branching without an explicit `decide`-node contract. A
114
- * follow-up task can add guarded branching here — see README.
127
+ * Exactly one operator is set on a parsed condition (the parser enforces
128
+ * this); each is checked in turn:
129
+ * - `equals` / `notEquals` — strict `Object.is`-style equality on
130
+ * `context[field]`.
131
+ * - `exists` — `field` present-in / absent-from context.
132
+ * - `in` — `context[field]` is one of the listed values (strict equality).
133
+ * - `truthy` — JS truthiness of `context[field]`.
134
+ *
135
+ * A condition naming a field absent from context evaluates to `false` for
136
+ * value comparisons (`equals`/`in`), `true` for `notEquals` (absent ≠ value),
137
+ * and is handled explicitly by `exists`/`truthy`.
138
+ *
139
+ * @param condition - Parsed, validated branch guard.
140
+ * @param context - Current accumulated run context.
141
+ * @returns `true` when the edge should be traversed.
142
+ *
143
+ * @task T11806 — cantbook runtime branching
144
+ */
145
+ function evaluateEdgeCondition(condition, context) {
146
+ const has = Object.hasOwn(context, condition.field);
147
+ const value = context[condition.field];
148
+ if (condition.exists !== undefined) {
149
+ return condition.exists ? has : !has;
150
+ }
151
+ if (condition.truthy !== undefined) {
152
+ return condition.truthy ? Boolean(value) : !value;
153
+ }
154
+ if (Object.hasOwn(condition, 'equals')) {
155
+ return has && Object.is(value, condition.equals);
156
+ }
157
+ if (Object.hasOwn(condition, 'notEquals')) {
158
+ // Absent field is, by definition, not equal to any concrete value.
159
+ return !has || !Object.is(value, condition.notEquals);
160
+ }
161
+ if (condition.in !== undefined) {
162
+ return has && condition.in.some((candidate) => Object.is(value, candidate));
163
+ }
164
+ // Parser guarantees one operator is set; unreachable in practice.
165
+ return false;
166
+ }
167
+ /**
168
+ * Resolve the next node id to traverse from `nodeId`, given the current run
169
+ * `context`, with support for guarded conditional branching (T11806).
170
+ *
171
+ * Routing rules (minimal, fail-closed, backward-compatible):
172
+ * - **0 outgoing edges** → `null` (terminal "end" state).
173
+ * - Edges are partitioned into *unconditional* (no `when`) and *guarded*
174
+ * (`when` predicate satisfied against `context`).
175
+ * - **>1 unconditional successor** → throw `E_PLAYBOOK_RUNTIME_INVALID`
176
+ * (genuine ambiguity — author must add `when` guards). This preserves the
177
+ * pre-T11806 guard for the un-guarded fan-out case.
178
+ * - **Exactly 1 satisfied successor** (unconditional or guarded) → traverse
179
+ * it. Linear playbooks are unchanged: their single un-guarded edge is the
180
+ * sole candidate.
181
+ * - **Outgoing edges exist but NONE is satisfied** → throw (fail-closed: a
182
+ * branch must always have a reachable default; silently terminating would
183
+ * mask an authoring bug).
184
+ * - **>1 satisfied successor** → throw (ambiguous branch — predicates must be
185
+ * mutually exclusive; parallel fan-out is a deferred follow-up).
186
+ *
187
+ * @param nodeId - Current node id.
188
+ * @param idx - Pre-computed edge index.
189
+ * @param context - Current accumulated run context (predicate evaluation).
190
+ * @returns The single successor node id, or `null` when terminal.
191
+ * @throws {Error} stamped {@link E_PLAYBOOK_RUNTIME_INVALID} on ambiguous or
192
+ * unsatisfiable branching.
115
193
  */
116
- function resolveNextNodeId(nodeId, idx) {
117
- const outs = idx.outgoing.get(nodeId) ?? [];
118
- if (outs.length === 0)
194
+ function resolveNextNodeId(nodeId, idx, context) {
195
+ const edges = idx.outgoingEdges.get(nodeId) ?? [];
196
+ if (edges.length === 0)
119
197
  return null;
120
- if (outs.length > 1) {
121
- throw new Error(`${E_PLAYBOOK_RUNTIME_INVALID}: node ${nodeId} has ${outs.length} successors; branching requires an approval/decide node`);
198
+ // Fast path: a single unconditional edge (the linear, pre-T11806 shape).
199
+ if (edges.length === 1) {
200
+ const [only] = edges;
201
+ if (only === undefined) {
202
+ throw new Error(`${E_PLAYBOOK_RUNTIME_INVALID}: node ${nodeId} has undefined successor`);
203
+ }
204
+ if (only.when !== undefined && !evaluateEdgeCondition(only.when, context)) {
205
+ throw new Error(`${E_PLAYBOOK_RUNTIME_INVALID}: node ${nodeId} sole successor "${only.to}" is guarded ` +
206
+ `by a "when" predicate that is not satisfied — branch has no reachable default`);
207
+ }
208
+ return only.to;
209
+ }
210
+ // Fan-out: reject >1 unconditional successor (genuine ambiguity).
211
+ const unconditional = edges.filter((e) => e.when === undefined);
212
+ if (unconditional.length > 1) {
213
+ throw new Error(`${E_PLAYBOOK_RUNTIME_INVALID}: node ${nodeId} has ${unconditional.length} unconditional ` +
214
+ `successors; guarded branching requires every additional edge to carry a "when" predicate`);
215
+ }
216
+ // Collect satisfied successors: unconditional edge (if any) + guarded edges
217
+ // whose predicate holds.
218
+ const satisfied = [];
219
+ for (const e of edges) {
220
+ if (e.when === undefined || evaluateEdgeCondition(e.when, context)) {
221
+ satisfied.push(e.to);
222
+ }
223
+ }
224
+ if (satisfied.length === 0) {
225
+ throw new Error(`${E_PLAYBOOK_RUNTIME_INVALID}: node ${nodeId} has ${edges.length} successors but no ` +
226
+ `"when" predicate is satisfied — branch has no reachable default`);
122
227
  }
123
- // Safe: length === 1
124
- const [next] = outs;
228
+ if (satisfied.length > 1) {
229
+ throw new Error(`${E_PLAYBOOK_RUNTIME_INVALID}: node ${nodeId} has ${satisfied.length} satisfied successors ` +
230
+ `(${satisfied.join(', ')}); branch predicates must be mutually exclusive`);
231
+ }
232
+ const [next] = satisfied;
125
233
  if (next === undefined) {
126
234
  throw new Error(`${E_PLAYBOOK_RUNTIME_INVALID}: node ${nodeId} has undefined successor`);
127
235
  }
@@ -347,119 +455,6 @@ function iterationCapFor(node, runtimeDefault) {
347
455
  return cap;
348
456
  return runtimeDefault;
349
457
  }
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
458
  /**
464
459
  * Core step-by-step executor shared by {@link executePlaybook} and
465
460
  * {@link resumePlaybook}. Starts at `startNodeId` and walks the graph until
@@ -546,18 +541,45 @@ async function runFromNode(args) {
546
541
  }
547
542
  }
548
543
  }
549
- // RCASD/IVTR output schema validation (T11499 AC3):
544
+ // RCASD/IVTR + generic output schema validation (T11499 AC3 · T11762 ST-2):
550
545
  // Enforce ensures.schema beyond key-presence so garbage decompositions
551
546
  // cannot silently pass the runtime contract check.
547
+ //
548
+ // De-hardcoded (T11762): the former `if (schema === 'task_tree') …
549
+ // else if (=== 'evidence')` block — which SILENTLY SKIPPED any other
550
+ // schema name — is replaced by a registry-driven lookup against the
551
+ // ensures-schema Zod registry (`getEnsuresSchema` in `@cleocode/core`,
552
+ // backed by the `@cleocode/contracts` registry DATA). An UNKNOWN schema
553
+ // name now FAILS CLOSED (AC2) and is routed through the SAME
554
+ // `contract_violation` handler below (AC3), rather than passing silently.
552
555
  if (node.ensures?.schema) {
553
556
  let schemaViolation = null;
554
- if (node.ensures.schema === 'task_tree') {
555
- schemaViolation = validateDecompositionTaskTree(node.id, context['task_tree']);
557
+ const spec = getEnsuresSchema(node.ensures.schema);
558
+ if (spec === null) {
559
+ // FAIL-CLOSED (AC2): unknown schema names were historically skipped
560
+ // silently. They are now an explicit, audited contract violation.
561
+ schemaViolation =
562
+ `ensures.schema[${node.ensures.schema}] on ${node.id}: unknown schema name. ` +
563
+ `Registered: ${listEnsuresSchemaNames().join(', ')}`;
556
564
  }
557
- else if (node.ensures.schema === 'evidence') {
558
- schemaViolation = validateIvtrEvidenceOutput(node.id, context['evidence']);
565
+ else {
566
+ // Validate the post-merge context value against the registered Zod
567
+ // schema. The message wrapper re-applies the historical
568
+ // `ensures.schema[<name>] on <nodeId>: <issue>` prefix so the
569
+ // end-to-end violation strings stay parity-identical to the deleted
570
+ // bespoke validators (the Zod issue messages were crafted in ST-1 to
571
+ // echo the original suffixes).
572
+ const result = spec.schema.safeParse(context[spec.contextKey]);
573
+ if (!result.success) {
574
+ const issue = result.error.issues[0];
575
+ const path = issue?.path ?? [];
576
+ const at = path.length > 0 ? ` at ${path.join('.')}` : '';
577
+ schemaViolation =
578
+ `ensures.schema[${spec.name}] on ${node.id}: ` +
579
+ `${issue?.message ?? 'invalid shape'}${at}`;
580
+ }
559
581
  }
560
- // Future schema names are silently skipped (open for extension).
582
+ // ── UNCHANGED from here (the existing contract_violation handler) ──
561
583
  if (schemaViolation !== null) {
562
584
  auditContractViolation(args.projectRoot, run.runId, node.id, 'ensures', node.ensures.schema, playbook.name);
563
585
  const handled = handleContractErrorHandler(playbook, 'contract_violation', schemaViolation);
@@ -578,7 +600,9 @@ async function runFromNode(args) {
578
600
  if (failedNodeId !== undefined)
579
601
  break;
580
602
  // Validate outgoing edge contracts (edge.contract.requires on the FROM side).
581
- const nextId = resolveNextNodeId(node.id, edgeIndex);
603
+ // Branch routing (T11806) reads the post-merge context so guard
604
+ // predicates can act on this node's freshly merged output.
605
+ const nextId = resolveNextNodeId(node.id, edgeIndex, context);
582
606
  if (nextId !== null) {
583
607
  const edge = resolveEdge(node.id, nextId, playbook.edges);
584
608
  if (edge?.contract?.requires) {
@@ -820,10 +844,26 @@ export async function resumePlaybook(options) {
820
844
  if (approvalNode === undefined || approvalNode.type !== 'approval') {
821
845
  throw new Error(`${E_PLAYBOOK_RESUME_BLOCKED}: approval node ${approval.nodeId} not found in playbook ${options.playbook.name}`);
822
846
  }
823
- const successor = resolveNextNodeId(approvalNode.id, edgeIndex);
847
+ const now = options.now ?? (() => new Date());
848
+ const maxIterationsDefault = options.maxIterationsDefault ?? 3;
849
+ const approvalSecret = options.approvalSecret ?? getPlaybookSecret();
850
+ const context = { ...run.bindings };
851
+ const iterationCounts = { ...run.iterationCounts };
852
+ // Log the approval decision into the context so downstream nodes can act on
853
+ // it. Set BEFORE resolving the successor so a gate can branch on the
854
+ // approver's decision via a `when: { field: __lastApproval... }` edge (T11806
855
+ // approval/resume branch parity).
856
+ context['__lastApproval'] = {
857
+ nodeId: approval.nodeId,
858
+ approvalId: approval.approvalId,
859
+ approver: approval.approver,
860
+ reason: approval.reason,
861
+ approvedAt: approval.approvedAt,
862
+ };
863
+ const successor = resolveNextNodeId(approvalNode.id, edgeIndex, context);
824
864
  if (successor === null) {
825
865
  // Approval at the tail of the graph completes the run immediately.
826
- const completedAt = (options.now ?? (() => new Date()))().toISOString();
866
+ const completedAt = now().toISOString();
827
867
  updatePlaybookRun(options.db, run.runId, {
828
868
  status: 'completed',
829
869
  currentNode: null,
@@ -842,19 +882,6 @@ export async function resumePlaybook(options) {
842
882
  currentNode: successor,
843
883
  errorContext: null,
844
884
  });
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
885
  // Persist an approval trace row for audit purposes. createPlaybookApproval
859
886
  // is distinct from createApprovalGate — the latter generates the HMAC
860
887
  // 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.11",
3
+ "version": "2026.6.13",
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/core": "2026.6.11",
23
- "@cleocode/contracts": "2026.6.11"
22
+ "@cleocode/contracts": "2026.6.13",
23
+ "@cleocode/core": "2026.6.13"
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