@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/src/parser.ts CHANGED
@@ -34,6 +34,7 @@ import type {
34
34
  PlaybookDefinition,
35
35
  PlaybookDeterministicNode,
36
36
  PlaybookEdge,
37
+ PlaybookEdgeCondition,
37
38
  PlaybookEnsures,
38
39
  PlaybookErrorHandler,
39
40
  PlaybookInput,
@@ -499,7 +500,114 @@ function parseEdge(raw: unknown, index: number, ids: ReadonlySet<string>): Playb
499
500
  contract = { requires, ensures };
500
501
  }
501
502
 
502
- return { from: raw.from, to: raw.to, contract };
503
+ const when = parseEdgeCondition(raw.when, `edges[${index}].when`);
504
+
505
+ const edge: PlaybookEdge = { from: raw.from, to: raw.to, contract };
506
+ if (when !== undefined) edge.when = when;
507
+ return edge;
508
+ }
509
+
510
+ /**
511
+ * Recognised comparison operators on a {@link PlaybookEdgeCondition}. Exactly
512
+ * one MUST be present so a guarded edge is unambiguous (T11806).
513
+ */
514
+ const EDGE_CONDITION_OPERATORS = ['equals', 'notEquals', 'exists', 'in', 'truthy'] as const;
515
+
516
+ /**
517
+ * Parse the optional `when` branch guard on an edge into a validated
518
+ * {@link PlaybookEdgeCondition}. Returns `undefined` for an absent guard
519
+ * (the edge is unconditional).
520
+ *
521
+ * Validation:
522
+ * - `field` MUST be a non-empty string.
523
+ * - Exactly ONE comparison operator (`equals` | `notEquals` | `exists` | `in`
524
+ * | `truthy`) MUST be supplied — zero is ambiguous, more than one is
525
+ * contradictory, both rejected at parse time so the runtime never has to
526
+ * disambiguate.
527
+ * - `exists` / `truthy` MUST be boolean; `in` MUST be a non-empty array.
528
+ *
529
+ * @param raw - Raw YAML value for `edge.when`.
530
+ * @param field - Field path for diagnostics (e.g. `"edges[2].when"`).
531
+ * @returns The validated condition, or `undefined` when absent.
532
+ * @throws {PlaybookParseError} On any structural violation.
533
+ *
534
+ * @task T11806 — cantbook runtime branching
535
+ */
536
+ function parseEdgeCondition(raw: unknown, field: string): PlaybookEdgeCondition | undefined {
537
+ if (raw === undefined) return undefined;
538
+ if (!isRecord(raw)) {
539
+ throw new PlaybookParseError(`${field} must be an object`, field, raw);
540
+ }
541
+ if (typeof raw.field !== 'string' || raw.field.length === 0) {
542
+ throw new PlaybookParseError(
543
+ `${field}.field must be a non-empty string`,
544
+ `${field}.field`,
545
+ raw.field,
546
+ );
547
+ }
548
+
549
+ const present = EDGE_CONDITION_OPERATORS.filter((op) => Object.hasOwn(raw, op));
550
+ if (present.length === 0) {
551
+ throw new PlaybookParseError(
552
+ `${field} must declare exactly one of ${EDGE_CONDITION_OPERATORS.join(' | ')}`,
553
+ field,
554
+ raw,
555
+ );
556
+ }
557
+ if (present.length > 1) {
558
+ throw new PlaybookParseError(
559
+ `${field} declares multiple operators (${present.join(', ')}); exactly one is allowed`,
560
+ field,
561
+ raw,
562
+ );
563
+ }
564
+
565
+ const condition: PlaybookEdgeCondition = { field: raw.field };
566
+ const [op] = present;
567
+ switch (op) {
568
+ case 'equals':
569
+ condition.equals = raw.equals;
570
+ break;
571
+ case 'notEquals':
572
+ condition.notEquals = raw.notEquals;
573
+ break;
574
+ case 'in':
575
+ if (!Array.isArray(raw.in) || raw.in.length === 0) {
576
+ throw new PlaybookParseError(
577
+ `${field}.in must be a non-empty array`,
578
+ `${field}.in`,
579
+ raw.in,
580
+ );
581
+ }
582
+ condition.in = [...raw.in];
583
+ break;
584
+ case 'exists':
585
+ if (typeof raw.exists !== 'boolean') {
586
+ throw new PlaybookParseError(
587
+ `${field}.exists must be a boolean`,
588
+ `${field}.exists`,
589
+ raw.exists,
590
+ );
591
+ }
592
+ condition.exists = raw.exists;
593
+ break;
594
+ case 'truthy':
595
+ if (typeof raw.truthy !== 'boolean') {
596
+ throw new PlaybookParseError(
597
+ `${field}.truthy must be a boolean`,
598
+ `${field}.truthy`,
599
+ raw.truthy,
600
+ );
601
+ }
602
+ condition.truthy = raw.truthy;
603
+ break;
604
+ default: {
605
+ // Exhaustiveness guard — EDGE_CONDITION_OPERATORS is the SSoT.
606
+ const exhaustive: never = op;
607
+ throw new PlaybookParseError(`${field} unknown operator ${String(exhaustive)}`, field, raw);
608
+ }
609
+ }
610
+ return condition;
503
611
  }
504
612
 
505
613
  function parseInputs(raw: unknown): PlaybookInput[] | undefined {
package/src/runtime.ts CHANGED
@@ -44,10 +44,17 @@ import type {
44
44
  PlaybookDefinition,
45
45
  PlaybookDeterministicNode,
46
46
  PlaybookEdge,
47
+ PlaybookEdgeCondition,
47
48
  PlaybookNode,
48
49
  PlaybookRun,
49
50
  PlaybookRunStatus,
50
51
  } from '@cleocode/contracts';
52
+ // T11762 ST-2: registry-driven ensures.schema resolution. The bodied accessors
53
+ // live in `@cleocode/core` (Gate-10 contracts-purity forbids them in the
54
+ // contracts leaf); core does NOT depend on playbooks, so this import direction
55
+ // is cycle-free (mirrors the existing `@cleocode/core/store/schema` import in
56
+ // `./schema.ts`).
57
+ import { getEnsuresSchema, listEnsuresSchemaNames } from '@cleocode/core';
51
58
  import { createApprovalGate, getPlaybookSecret } from './approval.js';
52
59
  import {
53
60
  createPlaybookApproval,
@@ -237,6 +244,13 @@ interface EdgeIndex {
237
244
  readonly outgoing: ReadonlyMap<string, readonly string[]>;
238
245
  /** Map from node id → list of predecessor node ids in declaration order. */
239
246
  readonly incoming: ReadonlyMap<string, readonly string[]>;
247
+ /**
248
+ * Map from node id → list of outgoing {@link PlaybookEdge} objects in
249
+ * declaration order. Carries the optional `when` branch guard (T11806) so
250
+ * the runtime can route conditionally. `depends[]`-derived reverse edges
251
+ * are unconditional and represented here as `{ from: dep, to: node }`.
252
+ */
253
+ readonly outgoingEdges: ReadonlyMap<string, readonly PlaybookEdge[]>;
240
254
  }
241
255
 
242
256
  /**
@@ -282,13 +296,16 @@ export const E_PLAYBOOK_RESUME_BLOCKED = 'E_PLAYBOOK_RESUME_BLOCKED' as const;
282
296
  function buildEdgeIndex(def: PlaybookDefinition): EdgeIndex {
283
297
  const outgoing = new Map<string, string[]>();
284
298
  const incoming = new Map<string, string[]>();
299
+ const outgoingEdges = new Map<string, PlaybookEdge[]>();
285
300
  for (const n of def.nodes) {
286
301
  outgoing.set(n.id, []);
287
302
  incoming.set(n.id, []);
303
+ outgoingEdges.set(n.id, []);
288
304
  }
289
305
  for (const e of def.edges) {
290
306
  outgoing.get(e.from)?.push(e.to);
291
307
  incoming.get(e.to)?.push(e.from);
308
+ outgoingEdges.get(e.from)?.push(e);
292
309
  }
293
310
  for (const n of def.nodes) {
294
311
  if (!n.depends) continue;
@@ -299,11 +316,15 @@ function buildEdgeIndex(def: PlaybookDefinition): EdgeIndex {
299
316
  if (out && !out.includes(n.id)) out.push(n.id);
300
317
  const inc = incoming.get(n.id);
301
318
  if (inc && !inc.includes(dep)) inc.push(dep);
319
+ // depends-derived reverse edges are always unconditional.
320
+ const outE = outgoingEdges.get(dep);
321
+ if (outE && !outE.some((e) => e.to === n.id)) outE.push({ from: dep, to: n.id });
302
322
  }
303
323
  }
304
324
  return {
305
325
  outgoing: new Map([...outgoing].map(([k, v]) => [k, Object.freeze([...v])])),
306
326
  incoming: new Map([...incoming].map(([k, v]) => [k, Object.freeze([...v])])),
327
+ outgoingEdges: new Map([...outgoingEdges].map(([k, v]) => [k, Object.freeze([...v])])),
307
328
  };
308
329
  }
309
330
 
@@ -327,23 +348,136 @@ function resolveEntryNode(def: PlaybookDefinition, idx: EdgeIndex): PlaybookNode
327
348
  }
328
349
 
329
350
  /**
330
- * Return the single successor node id for `nodeId`, or `null` if `nodeId` is
331
- * terminal (no outgoing edges — the "end" state in the design contract).
351
+ * Evaluate a declarative {@link PlaybookEdgeCondition} against the run
352
+ * `context`. The predicate is pure data (no `eval`, no expression parsing) so
353
+ * routing stays deterministic and sandbox-safe (T11806).
354
+ *
355
+ * Exactly one operator is set on a parsed condition (the parser enforces
356
+ * this); each is checked in turn:
357
+ * - `equals` / `notEquals` — strict `Object.is`-style equality on
358
+ * `context[field]`.
359
+ * - `exists` — `field` present-in / absent-from context.
360
+ * - `in` — `context[field]` is one of the listed values (strict equality).
361
+ * - `truthy` — JS truthiness of `context[field]`.
332
362
  *
333
- * Throws on fan-out (> 1 successor) because the deterministic runtime does
334
- * not support branching without an explicit `decide`-node contract. A
335
- * follow-up task can add guarded branching here — see README.
363
+ * A condition naming a field absent from context evaluates to `false` for
364
+ * value comparisons (`equals`/`in`), `true` for `notEquals` (absent ≠ value),
365
+ * and is handled explicitly by `exists`/`truthy`.
366
+ *
367
+ * @param condition - Parsed, validated branch guard.
368
+ * @param context - Current accumulated run context.
369
+ * @returns `true` when the edge should be traversed.
370
+ *
371
+ * @task T11806 — cantbook runtime branching
336
372
  */
337
- function resolveNextNodeId(nodeId: string, idx: EdgeIndex): string | null {
338
- const outs = idx.outgoing.get(nodeId) ?? [];
339
- if (outs.length === 0) return null;
340
- if (outs.length > 1) {
373
+ function evaluateEdgeCondition(
374
+ condition: PlaybookEdgeCondition,
375
+ context: Record<string, unknown>,
376
+ ): boolean {
377
+ const has = Object.hasOwn(context, condition.field);
378
+ const value = context[condition.field];
379
+
380
+ if (condition.exists !== undefined) {
381
+ return condition.exists ? has : !has;
382
+ }
383
+ if (condition.truthy !== undefined) {
384
+ return condition.truthy ? Boolean(value) : !value;
385
+ }
386
+ if (Object.hasOwn(condition, 'equals')) {
387
+ return has && Object.is(value, condition.equals);
388
+ }
389
+ if (Object.hasOwn(condition, 'notEquals')) {
390
+ // Absent field is, by definition, not equal to any concrete value.
391
+ return !has || !Object.is(value, condition.notEquals);
392
+ }
393
+ if (condition.in !== undefined) {
394
+ return has && condition.in.some((candidate) => Object.is(value, candidate));
395
+ }
396
+ // Parser guarantees one operator is set; unreachable in practice.
397
+ return false;
398
+ }
399
+
400
+ /**
401
+ * Resolve the next node id to traverse from `nodeId`, given the current run
402
+ * `context`, with support for guarded conditional branching (T11806).
403
+ *
404
+ * Routing rules (minimal, fail-closed, backward-compatible):
405
+ * - **0 outgoing edges** → `null` (terminal "end" state).
406
+ * - Edges are partitioned into *unconditional* (no `when`) and *guarded*
407
+ * (`when` predicate satisfied against `context`).
408
+ * - **>1 unconditional successor** → throw `E_PLAYBOOK_RUNTIME_INVALID`
409
+ * (genuine ambiguity — author must add `when` guards). This preserves the
410
+ * pre-T11806 guard for the un-guarded fan-out case.
411
+ * - **Exactly 1 satisfied successor** (unconditional or guarded) → traverse
412
+ * it. Linear playbooks are unchanged: their single un-guarded edge is the
413
+ * sole candidate.
414
+ * - **Outgoing edges exist but NONE is satisfied** → throw (fail-closed: a
415
+ * branch must always have a reachable default; silently terminating would
416
+ * mask an authoring bug).
417
+ * - **>1 satisfied successor** → throw (ambiguous branch — predicates must be
418
+ * mutually exclusive; parallel fan-out is a deferred follow-up).
419
+ *
420
+ * @param nodeId - Current node id.
421
+ * @param idx - Pre-computed edge index.
422
+ * @param context - Current accumulated run context (predicate evaluation).
423
+ * @returns The single successor node id, or `null` when terminal.
424
+ * @throws {Error} stamped {@link E_PLAYBOOK_RUNTIME_INVALID} on ambiguous or
425
+ * unsatisfiable branching.
426
+ */
427
+ function resolveNextNodeId(
428
+ nodeId: string,
429
+ idx: EdgeIndex,
430
+ context: Record<string, unknown>,
431
+ ): string | null {
432
+ const edges = idx.outgoingEdges.get(nodeId) ?? [];
433
+ if (edges.length === 0) return null;
434
+
435
+ // Fast path: a single unconditional edge (the linear, pre-T11806 shape).
436
+ if (edges.length === 1) {
437
+ const [only] = edges;
438
+ if (only === undefined) {
439
+ throw new Error(`${E_PLAYBOOK_RUNTIME_INVALID}: node ${nodeId} has undefined successor`);
440
+ }
441
+ if (only.when !== undefined && !evaluateEdgeCondition(only.when, context)) {
442
+ throw new Error(
443
+ `${E_PLAYBOOK_RUNTIME_INVALID}: node ${nodeId} sole successor "${only.to}" is guarded ` +
444
+ `by a "when" predicate that is not satisfied — branch has no reachable default`,
445
+ );
446
+ }
447
+ return only.to;
448
+ }
449
+
450
+ // Fan-out: reject >1 unconditional successor (genuine ambiguity).
451
+ const unconditional = edges.filter((e) => e.when === undefined);
452
+ if (unconditional.length > 1) {
341
453
  throw new Error(
342
- `${E_PLAYBOOK_RUNTIME_INVALID}: node ${nodeId} has ${outs.length} successors; branching requires an approval/decide node`,
454
+ `${E_PLAYBOOK_RUNTIME_INVALID}: node ${nodeId} has ${unconditional.length} unconditional ` +
455
+ `successors; guarded branching requires every additional edge to carry a "when" predicate`,
343
456
  );
344
457
  }
345
- // Safe: length === 1
346
- const [next] = outs;
458
+
459
+ // Collect satisfied successors: unconditional edge (if any) + guarded edges
460
+ // whose predicate holds.
461
+ const satisfied: string[] = [];
462
+ for (const e of edges) {
463
+ if (e.when === undefined || evaluateEdgeCondition(e.when, context)) {
464
+ satisfied.push(e.to);
465
+ }
466
+ }
467
+
468
+ if (satisfied.length === 0) {
469
+ throw new Error(
470
+ `${E_PLAYBOOK_RUNTIME_INVALID}: node ${nodeId} has ${edges.length} successors but no ` +
471
+ `"when" predicate is satisfied — branch has no reachable default`,
472
+ );
473
+ }
474
+ if (satisfied.length > 1) {
475
+ throw new Error(
476
+ `${E_PLAYBOOK_RUNTIME_INVALID}: node ${nodeId} has ${satisfied.length} satisfied successors ` +
477
+ `(${satisfied.join(', ')}); branch predicates must be mutually exclusive`,
478
+ );
479
+ }
480
+ const [next] = satisfied;
347
481
  if (next === undefined) {
348
482
  throw new Error(`${E_PLAYBOOK_RUNTIME_INVALID}: node ${nodeId} has undefined successor`);
349
483
  }
@@ -612,167 +746,6 @@ function iterationCapFor(node: PlaybookNode, runtimeDefault: number): number {
612
746
  return runtimeDefault;
613
747
  }
614
748
 
615
- // ---------------------------------------------------------------------------
616
- // RCASD/IVTR node output schema validation (T11499 AC3)
617
- // ---------------------------------------------------------------------------
618
-
619
- /**
620
- * A single task entry within a decomposition `task_tree` output.
621
- *
622
- * Agents emitting a decomposition must produce a `task_tree` key in the
623
- * playbook context whose value is an array of objects conforming to this shape.
624
- * Validating the real shape — not just key presence — prevents garbage
625
- * decompositions from silently entering the run queue.
626
- *
627
- * @task T11499 E7-CLOSE-LOOPS AC3
628
- */
629
- interface DecompositionTaskEntry {
630
- /** Task title — required, non-empty string. */
631
- title: string;
632
- /** Acceptance criteria — must be present and non-empty. */
633
- acceptance: string[];
634
- /** Optional task ID (may be pre-assigned by the agent). */
635
- id?: string;
636
- /** Optional parent task ID. */
637
- parentId?: string;
638
- /** Optional dependency IDs. */
639
- depends?: string[];
640
- }
641
-
642
- /**
643
- * Validate the `task_tree` shape emitted by a RCASD decomposition node.
644
- *
645
- * Returns a human-readable violation string when the shape is invalid, or
646
- * `null` when the tree is well-formed. The check is performed AFTER the node
647
- * output is merged into the playbook context (post-merge ensures check).
648
- *
649
- * Rules enforced (AC3 — T11499):
650
- * 1. `task_tree` must be a non-empty array.
651
- * 2. Each entry must have a non-empty string `title`.
652
- * 3. Each entry must have a non-empty `acceptance` array (at least one string).
653
- * 4. No entry may have an empty `depends` array that references a non-existent
654
- * sibling (intra-tree orphan check).
655
- *
656
- * The validator is intentionally lenient on optional fields (id, parentId,
657
- * depends) so that agents can emit a minimal valid tree without being rejected
658
- * for cosmetic omissions.
659
- *
660
- * @param nodeId - Node identifier (used in error messages).
661
- * @param taskTree - The raw value stored at `context.task_tree`.
662
- * @returns Violation string or `null` (valid).
663
- *
664
- * @task T11499 E7-CLOSE-LOOPS AC3
665
- */
666
- export function validateDecompositionTaskTree(nodeId: string, taskTree: unknown): string | null {
667
- if (!Array.isArray(taskTree)) {
668
- return `ensures.schema[task_tree] on ${nodeId}: task_tree must be a non-empty array, got ${typeof taskTree}`;
669
- }
670
-
671
- if (taskTree.length === 0) {
672
- return `ensures.schema[task_tree] on ${nodeId}: task_tree is an empty array — decomposition produced no tasks`;
673
- }
674
-
675
- const knownIds = new Set<string>();
676
- for (const entry of taskTree) {
677
- if (typeof (entry as DecompositionTaskEntry).id === 'string') {
678
- knownIds.add((entry as DecompositionTaskEntry).id!);
679
- }
680
- }
681
-
682
- for (let i = 0; i < taskTree.length; i++) {
683
- const entry = taskTree[i] as DecompositionTaskEntry;
684
-
685
- if (typeof entry !== 'object' || entry === null) {
686
- return `ensures.schema[task_tree] on ${nodeId}: entry[${i}] must be an object, got ${entry === null ? 'null' : typeof entry}`;
687
- }
688
-
689
- if (typeof entry.title !== 'string' || entry.title.trim().length === 0) {
690
- return `ensures.schema[task_tree] on ${nodeId}: entry[${i}].title must be a non-empty string`;
691
- }
692
-
693
- if (!Array.isArray(entry.acceptance) || entry.acceptance.length === 0) {
694
- return (
695
- `ensures.schema[task_tree] on ${nodeId}: entry[${i}] ("${entry.title}") ` +
696
- `must have a non-empty acceptance array`
697
- );
698
- }
699
-
700
- const hasValidAc = entry.acceptance.some(
701
- (ac) => typeof ac === 'string' && ac.trim().length > 0,
702
- );
703
- if (!hasValidAc) {
704
- return (
705
- `ensures.schema[task_tree] on ${nodeId}: entry[${i}] ("${entry.title}") ` +
706
- `acceptance array contains no non-empty strings`
707
- );
708
- }
709
-
710
- // Intra-tree orphan check: if depends[] are specified, they must reference
711
- // another entry in the same tree (by id). References to external tasks are
712
- // allowed (they have no id in this tree), so we only flag ids that look
713
- // like intra-tree references but are absent from knownIds.
714
- if (Array.isArray(entry.depends) && knownIds.size > 0) {
715
- for (const depId of entry.depends) {
716
- // Only flag if the depId matches the T#### pattern and is not in knownIds
717
- // (external task IDs that exist in the DB are valid but we can't check here).
718
- if (typeof depId === 'string' && /^T\d{3,}$/.test(depId) && !knownIds.has(depId)) {
719
- // Soft warn only (not a hard violation) — the dep may be a pre-existing task.
720
- // We don't hard-fail here because agents may correctly depend on existing tasks.
721
- }
722
- }
723
- }
724
- }
725
-
726
- return null; // valid
727
- }
728
-
729
- /**
730
- * Validate the `evidence` field emitted by an IVTR validation node.
731
- *
732
- * IVTR validation nodes must emit an `evidence` key whose value is either:
733
- * - An object with at least one key (structured evidence map), OR
734
- * - A non-empty string (free-text evidence reference).
735
- *
736
- * Empty evidence (`{}`, `''`, `[]`) is rejected so that spawned agents
737
- * cannot satisfy the gate by emitting an empty object.
738
- *
739
- * @param nodeId - Node identifier (used in error messages).
740
- * @param evidence - The raw value stored at `context.evidence`.
741
- * @returns Violation string or `null` (valid).
742
- *
743
- * @task T11499 E7-CLOSE-LOOPS AC3
744
- */
745
- export function validateIvtrEvidenceOutput(nodeId: string, evidence: unknown): string | null {
746
- if (evidence === null || evidence === undefined) {
747
- return `ensures.schema[evidence] on ${nodeId}: evidence must be present (non-null, non-undefined)`;
748
- }
749
-
750
- if (typeof evidence === 'string') {
751
- if (evidence.trim().length === 0) {
752
- return `ensures.schema[evidence] on ${nodeId}: evidence string must not be empty`;
753
- }
754
- return null; // valid non-empty string
755
- }
756
-
757
- if (Array.isArray(evidence)) {
758
- if (evidence.length === 0) {
759
- return `ensures.schema[evidence] on ${nodeId}: evidence array must not be empty`;
760
- }
761
- return null; // valid non-empty array
762
- }
763
-
764
- if (typeof evidence === 'object') {
765
- const keys = Object.keys(evidence as object);
766
- if (keys.length === 0) {
767
- return `ensures.schema[evidence] on ${nodeId}: evidence object must have at least one key (got {})`;
768
- }
769
- return null; // valid non-empty object
770
- }
771
-
772
- // Numbers, booleans etc. are not valid evidence shapes.
773
- return `ensures.schema[evidence] on ${nodeId}: evidence must be a string, array, or object (got ${typeof evidence})`;
774
- }
775
-
776
749
  /**
777
750
  * Core step-by-step executor shared by {@link executePlaybook} and
778
751
  * {@link resumePlaybook}. Starts at `startNodeId` and walks the graph until
@@ -918,19 +891,46 @@ async function runFromNode(args: {
918
891
  }
919
892
  }
920
893
 
921
- // RCASD/IVTR output schema validation (T11499 AC3):
894
+ // RCASD/IVTR + generic output schema validation (T11499 AC3 · T11762 ST-2):
922
895
  // Enforce ensures.schema beyond key-presence so garbage decompositions
923
896
  // cannot silently pass the runtime contract check.
897
+ //
898
+ // De-hardcoded (T11762): the former `if (schema === 'task_tree') …
899
+ // else if (=== 'evidence')` block — which SILENTLY SKIPPED any other
900
+ // schema name — is replaced by a registry-driven lookup against the
901
+ // ensures-schema Zod registry (`getEnsuresSchema` in `@cleocode/core`,
902
+ // backed by the `@cleocode/contracts` registry DATA). An UNKNOWN schema
903
+ // name now FAILS CLOSED (AC2) and is routed through the SAME
904
+ // `contract_violation` handler below (AC3), rather than passing silently.
924
905
  if (node.ensures?.schema) {
925
906
  let schemaViolation: string | null = null;
926
907
 
927
- if (node.ensures.schema === 'task_tree') {
928
- schemaViolation = validateDecompositionTaskTree(node.id, context['task_tree']);
929
- } else if (node.ensures.schema === 'evidence') {
930
- schemaViolation = validateIvtrEvidenceOutput(node.id, context['evidence']);
908
+ const spec = getEnsuresSchema(node.ensures.schema);
909
+ if (spec === null) {
910
+ // FAIL-CLOSED (AC2): unknown schema names were historically skipped
911
+ // silently. They are now an explicit, audited contract violation.
912
+ schemaViolation =
913
+ `ensures.schema[${node.ensures.schema}] on ${node.id}: unknown schema name. ` +
914
+ `Registered: ${listEnsuresSchemaNames().join(', ')}`;
915
+ } else {
916
+ // Validate the post-merge context value against the registered Zod
917
+ // schema. The message wrapper re-applies the historical
918
+ // `ensures.schema[<name>] on <nodeId>: <issue>` prefix so the
919
+ // end-to-end violation strings stay parity-identical to the deleted
920
+ // bespoke validators (the Zod issue messages were crafted in ST-1 to
921
+ // echo the original suffixes).
922
+ const result = spec.schema.safeParse(context[spec.contextKey]);
923
+ if (!result.success) {
924
+ const issue = result.error.issues[0];
925
+ const path = issue?.path ?? [];
926
+ const at = path.length > 0 ? ` at ${path.join('.')}` : '';
927
+ schemaViolation =
928
+ `ensures.schema[${spec.name}] on ${node.id}: ` +
929
+ `${issue?.message ?? 'invalid shape'}${at}`;
930
+ }
931
931
  }
932
- // Future schema names are silently skipped (open for extension).
933
932
 
933
+ // ── UNCHANGED from here (the existing contract_violation handler) ──
934
934
  if (schemaViolation !== null) {
935
935
  auditContractViolation(
936
936
  args.projectRoot,
@@ -962,7 +962,9 @@ async function runFromNode(args: {
962
962
  if (failedNodeId !== undefined) break;
963
963
 
964
964
  // Validate outgoing edge contracts (edge.contract.requires on the FROM side).
965
- const nextId = resolveNextNodeId(node.id, edgeIndex);
965
+ // Branch routing (T11806) reads the post-merge context so guard
966
+ // predicates can act on this node's freshly merged output.
967
+ const nextId = resolveNextNodeId(node.id, edgeIndex, context);
966
968
  if (nextId !== null) {
967
969
  const edge = resolveEdge(node.id, nextId, playbook.edges);
968
970
  if (edge?.contract?.requires) {
@@ -1241,10 +1243,29 @@ export async function resumePlaybook(
1241
1243
  `${E_PLAYBOOK_RESUME_BLOCKED}: approval node ${approval.nodeId} not found in playbook ${options.playbook.name}`,
1242
1244
  );
1243
1245
  }
1244
- const successor = resolveNextNodeId(approvalNode.id, edgeIndex);
1246
+
1247
+ const now = options.now ?? (() => new Date());
1248
+ const maxIterationsDefault = options.maxIterationsDefault ?? 3;
1249
+ const approvalSecret = options.approvalSecret ?? getPlaybookSecret();
1250
+ const context: Record<string, unknown> = { ...run.bindings };
1251
+ const iterationCounts: Record<string, number> = { ...run.iterationCounts };
1252
+
1253
+ // Log the approval decision into the context so downstream nodes can act on
1254
+ // it. Set BEFORE resolving the successor so a gate can branch on the
1255
+ // approver's decision via a `when: { field: __lastApproval... }` edge (T11806
1256
+ // approval/resume branch parity).
1257
+ context['__lastApproval'] = {
1258
+ nodeId: approval.nodeId,
1259
+ approvalId: approval.approvalId,
1260
+ approver: approval.approver,
1261
+ reason: approval.reason,
1262
+ approvedAt: approval.approvedAt,
1263
+ };
1264
+
1265
+ const successor = resolveNextNodeId(approvalNode.id, edgeIndex, context);
1245
1266
  if (successor === null) {
1246
1267
  // Approval at the tail of the graph completes the run immediately.
1247
- const completedAt = (options.now ?? (() => new Date()))().toISOString();
1268
+ const completedAt = now().toISOString();
1248
1269
  updatePlaybookRun(options.db, run.runId, {
1249
1270
  status: 'completed',
1250
1271
  currentNode: null,
@@ -1265,21 +1286,6 @@ export async function resumePlaybook(
1265
1286
  errorContext: null,
1266
1287
  });
1267
1288
 
1268
- const now = options.now ?? (() => new Date());
1269
- const maxIterationsDefault = options.maxIterationsDefault ?? 3;
1270
- const approvalSecret = options.approvalSecret ?? getPlaybookSecret();
1271
- const context: Record<string, unknown> = { ...run.bindings };
1272
- const iterationCounts: Record<string, number> = { ...run.iterationCounts };
1273
-
1274
- // Log the approval decision into the context so downstream nodes can act on it.
1275
- context['__lastApproval'] = {
1276
- nodeId: approval.nodeId,
1277
- approvalId: approval.approvalId,
1278
- approver: approval.approver,
1279
- reason: approval.reason,
1280
- approvedAt: approval.approvedAt,
1281
- };
1282
-
1283
1289
  // Persist an approval trace row for audit purposes. createPlaybookApproval
1284
1290
  // is distinct from createApprovalGate — the latter generates the HMAC
1285
1291
  // resume token, while this helper records arbitrary approval state.