@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/src/runtime.ts CHANGED
@@ -44,10 +44,21 @@ import type {
44
44
  PlaybookDefinition,
45
45
  PlaybookDeterministicNode,
46
46
  PlaybookEdge,
47
+ PlaybookEdgeCondition,
47
48
  PlaybookNode,
48
49
  PlaybookRun,
49
50
  PlaybookRunStatus,
51
+ SubstitutionContext,
50
52
  } from '@cleocode/contracts';
53
+ // T11762 ST-2: registry-driven ensures.schema resolution. The bodied accessors
54
+ // live in `@cleocode/core` (Gate-10 contracts-purity forbids them in the
55
+ // contracts leaf); core does NOT depend on playbooks, so this import direction
56
+ // is cycle-free (mirrors the existing `@cleocode/core/store/schema` import in
57
+ // `./schema.ts`).
58
+ // T1944 (M4): `defaultVariableResolver` is the canonical T1238 mustache engine,
59
+ // re-used here (DRY) to resolve `PlaybookAgenticNode.inputs` `{{path}}`
60
+ // templates at the dispatch boundary rather than re-implementing substitution.
61
+ import { defaultVariableResolver, getEnsuresSchema, listEnsuresSchemaNames } from '@cleocode/core';
51
62
  import { createApprovalGate, getPlaybookSecret } from './approval.js';
52
63
  import {
53
64
  createPlaybookApproval,
@@ -77,8 +88,69 @@ export interface AgentDispatchInput {
77
88
  taskId: string;
78
89
  /** Snapshot of the accumulated bindings at dispatch time. */
79
90
  context: Record<string, unknown>;
91
+ /**
92
+ * Per-step resolved bindings for this node (T1944 · M4 cantbook done-gate).
93
+ *
94
+ * Additive field. Built by {@link resolveStepBindings} from
95
+ * {@link PlaybookAgenticNode.inputs} — each declared input is a mustache
96
+ * `{{path}}` template resolved against the accumulated run `context` (via the
97
+ * canonical T1238 {@link DefaultVariableResolver}), then layered ON TOP of
98
+ * that context so a step input SHADOWS a same-named context key.
99
+ *
100
+ * The runtime always populates this when dispatching an agentic node (it is a
101
+ * shallow copy of `context` when the node declares no `inputs`). It is typed
102
+ * optional only to keep the change strictly additive for any out-of-tree
103
+ * dispatcher that constructs an {@link AgentDispatchInput} directly.
104
+ *
105
+ * Resolver precedence (highest → lowest):
106
+ * `step bindings ⊳ playbook bindings (run context) ⊳ session ⊳ project-context ⊳ env ⊳ default`.
107
+ */
108
+ bindings?: Record<string, unknown>;
80
109
  /** 1-based iteration counter for this specific node (retry-aware). */
81
110
  iteration: number;
111
+ /**
112
+ * Name of the running playbook (`.cantbook`) — the first segment of the
113
+ * cantbook node identity `cantbook:<playbook>#<nodeId>` (T11759 · M4).
114
+ *
115
+ * Additive. The runtime always populates it for agentic nodes; the dispatcher
116
+ * uses it (with {@link nodeId}) to resolve a declared per-stage LLM profile
117
+ * through the E9 chokepoint. Optional only so an out-of-tree dispatcher that
118
+ * constructs an {@link AgentDispatchInput} directly remains source-compatible.
119
+ */
120
+ playbookName?: string;
121
+ /**
122
+ * The node's declared per-stage LLM pin (T11759 · M4) — lifted verbatim from
123
+ * {@link PlaybookAgenticNode.profile} / `.model` / `.provider`.
124
+ *
125
+ * Additive + all-optional. When any field is set the dispatcher resolves the
126
+ * stage's LLM through `resolveLLMForSystem` keyed by the cantbook node
127
+ * identity, honoring the profile pin; when all are unset resolution is
128
+ * unchanged. The runtime NEVER acts on these itself — it only forwards them.
129
+ */
130
+ profile?: string;
131
+ /** Inline model-id pin forwarded from {@link PlaybookAgenticNode.model} (T11759). */
132
+ model?: string;
133
+ /** Inline provider pin forwarded from {@link PlaybookAgenticNode.provider} (T11759). */
134
+ provider?: string;
135
+ /**
136
+ * Per-step skill allowlist lifted verbatim from
137
+ * {@link PlaybookAgenticNode.allowed_skills} (T1947 · M4).
138
+ *
139
+ * Additive + optional. When present the dispatcher passes it to
140
+ * `composeSpawnPayload`, which intersects it with the resolved agent's skill
141
+ * baseline (Tier-0/1/2) — the allowlist can only RESTRICT, never expand. When
142
+ * absent the spawn carries its full baseline skill set. The runtime NEVER acts
143
+ * on this itself — it only forwards it.
144
+ */
145
+ allowedSkills?: string[];
146
+ /**
147
+ * Per-step tool allowlist lifted verbatim from
148
+ * {@link PlaybookAgenticNode.allowed_tools} (T1947 · M4).
149
+ *
150
+ * Same restrict-only intersection semantics as {@link allowedSkills}, applied
151
+ * to the spawn's tool baseline.
152
+ */
153
+ allowedTools?: string[];
82
154
  }
83
155
 
84
156
  /**
@@ -237,6 +309,13 @@ interface EdgeIndex {
237
309
  readonly outgoing: ReadonlyMap<string, readonly string[]>;
238
310
  /** Map from node id → list of predecessor node ids in declaration order. */
239
311
  readonly incoming: ReadonlyMap<string, readonly string[]>;
312
+ /**
313
+ * Map from node id → list of outgoing {@link PlaybookEdge} objects in
314
+ * declaration order. Carries the optional `when` branch guard (T11806) so
315
+ * the runtime can route conditionally. `depends[]`-derived reverse edges
316
+ * are unconditional and represented here as `{ from: dep, to: node }`.
317
+ */
318
+ readonly outgoingEdges: ReadonlyMap<string, readonly PlaybookEdge[]>;
240
319
  }
241
320
 
242
321
  /**
@@ -282,13 +361,16 @@ export const E_PLAYBOOK_RESUME_BLOCKED = 'E_PLAYBOOK_RESUME_BLOCKED' as const;
282
361
  function buildEdgeIndex(def: PlaybookDefinition): EdgeIndex {
283
362
  const outgoing = new Map<string, string[]>();
284
363
  const incoming = new Map<string, string[]>();
364
+ const outgoingEdges = new Map<string, PlaybookEdge[]>();
285
365
  for (const n of def.nodes) {
286
366
  outgoing.set(n.id, []);
287
367
  incoming.set(n.id, []);
368
+ outgoingEdges.set(n.id, []);
288
369
  }
289
370
  for (const e of def.edges) {
290
371
  outgoing.get(e.from)?.push(e.to);
291
372
  incoming.get(e.to)?.push(e.from);
373
+ outgoingEdges.get(e.from)?.push(e);
292
374
  }
293
375
  for (const n of def.nodes) {
294
376
  if (!n.depends) continue;
@@ -299,11 +381,15 @@ function buildEdgeIndex(def: PlaybookDefinition): EdgeIndex {
299
381
  if (out && !out.includes(n.id)) out.push(n.id);
300
382
  const inc = incoming.get(n.id);
301
383
  if (inc && !inc.includes(dep)) inc.push(dep);
384
+ // depends-derived reverse edges are always unconditional.
385
+ const outE = outgoingEdges.get(dep);
386
+ if (outE && !outE.some((e) => e.to === n.id)) outE.push({ from: dep, to: n.id });
302
387
  }
303
388
  }
304
389
  return {
305
390
  outgoing: new Map([...outgoing].map(([k, v]) => [k, Object.freeze([...v])])),
306
391
  incoming: new Map([...incoming].map(([k, v]) => [k, Object.freeze([...v])])),
392
+ outgoingEdges: new Map([...outgoingEdges].map(([k, v]) => [k, Object.freeze([...v])])),
307
393
  };
308
394
  }
309
395
 
@@ -327,23 +413,136 @@ function resolveEntryNode(def: PlaybookDefinition, idx: EdgeIndex): PlaybookNode
327
413
  }
328
414
 
329
415
  /**
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).
416
+ * Evaluate a declarative {@link PlaybookEdgeCondition} against the run
417
+ * `context`. The predicate is pure data (no `eval`, no expression parsing) so
418
+ * routing stays deterministic and sandbox-safe (T11806).
419
+ *
420
+ * Exactly one operator is set on a parsed condition (the parser enforces
421
+ * this); each is checked in turn:
422
+ * - `equals` / `notEquals` — strict `Object.is`-style equality on
423
+ * `context[field]`.
424
+ * - `exists` — `field` present-in / absent-from context.
425
+ * - `in` — `context[field]` is one of the listed values (strict equality).
426
+ * - `truthy` — JS truthiness of `context[field]`.
427
+ *
428
+ * A condition naming a field absent from context evaluates to `false` for
429
+ * value comparisons (`equals`/`in`), `true` for `notEquals` (absent ≠ value),
430
+ * and is handled explicitly by `exists`/`truthy`.
431
+ *
432
+ * @param condition - Parsed, validated branch guard.
433
+ * @param context - Current accumulated run context.
434
+ * @returns `true` when the edge should be traversed.
435
+ *
436
+ * @task T11806 — cantbook runtime branching
437
+ */
438
+ function evaluateEdgeCondition(
439
+ condition: PlaybookEdgeCondition,
440
+ context: Record<string, unknown>,
441
+ ): boolean {
442
+ const has = Object.hasOwn(context, condition.field);
443
+ const value = context[condition.field];
444
+
445
+ if (condition.exists !== undefined) {
446
+ return condition.exists ? has : !has;
447
+ }
448
+ if (condition.truthy !== undefined) {
449
+ return condition.truthy ? Boolean(value) : !value;
450
+ }
451
+ if (Object.hasOwn(condition, 'equals')) {
452
+ return has && Object.is(value, condition.equals);
453
+ }
454
+ if (Object.hasOwn(condition, 'notEquals')) {
455
+ // Absent field is, by definition, not equal to any concrete value.
456
+ return !has || !Object.is(value, condition.notEquals);
457
+ }
458
+ if (condition.in !== undefined) {
459
+ return has && condition.in.some((candidate) => Object.is(value, candidate));
460
+ }
461
+ // Parser guarantees one operator is set; unreachable in practice.
462
+ return false;
463
+ }
464
+
465
+ /**
466
+ * Resolve the next node id to traverse from `nodeId`, given the current run
467
+ * `context`, with support for guarded conditional branching (T11806).
468
+ *
469
+ * Routing rules (minimal, fail-closed, backward-compatible):
470
+ * - **0 outgoing edges** → `null` (terminal "end" state).
471
+ * - Edges are partitioned into *unconditional* (no `when`) and *guarded*
472
+ * (`when` predicate satisfied against `context`).
473
+ * - **>1 unconditional successor** → throw `E_PLAYBOOK_RUNTIME_INVALID`
474
+ * (genuine ambiguity — author must add `when` guards). This preserves the
475
+ * pre-T11806 guard for the un-guarded fan-out case.
476
+ * - **Exactly 1 satisfied successor** (unconditional or guarded) → traverse
477
+ * it. Linear playbooks are unchanged: their single un-guarded edge is the
478
+ * sole candidate.
479
+ * - **Outgoing edges exist but NONE is satisfied** → throw (fail-closed: a
480
+ * branch must always have a reachable default; silently terminating would
481
+ * mask an authoring bug).
482
+ * - **>1 satisfied successor** → throw (ambiguous branch — predicates must be
483
+ * mutually exclusive; parallel fan-out is a deferred follow-up).
332
484
  *
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.
485
+ * @param nodeId - Current node id.
486
+ * @param idx - Pre-computed edge index.
487
+ * @param context - Current accumulated run context (predicate evaluation).
488
+ * @returns The single successor node id, or `null` when terminal.
489
+ * @throws {Error} stamped {@link E_PLAYBOOK_RUNTIME_INVALID} on ambiguous or
490
+ * unsatisfiable branching.
336
491
  */
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) {
492
+ function resolveNextNodeId(
493
+ nodeId: string,
494
+ idx: EdgeIndex,
495
+ context: Record<string, unknown>,
496
+ ): string | null {
497
+ const edges = idx.outgoingEdges.get(nodeId) ?? [];
498
+ if (edges.length === 0) return null;
499
+
500
+ // Fast path: a single unconditional edge (the linear, pre-T11806 shape).
501
+ if (edges.length === 1) {
502
+ const [only] = edges;
503
+ if (only === undefined) {
504
+ throw new Error(`${E_PLAYBOOK_RUNTIME_INVALID}: node ${nodeId} has undefined successor`);
505
+ }
506
+ if (only.when !== undefined && !evaluateEdgeCondition(only.when, context)) {
507
+ throw new Error(
508
+ `${E_PLAYBOOK_RUNTIME_INVALID}: node ${nodeId} sole successor "${only.to}" is guarded ` +
509
+ `by a "when" predicate that is not satisfied — branch has no reachable default`,
510
+ );
511
+ }
512
+ return only.to;
513
+ }
514
+
515
+ // Fan-out: reject >1 unconditional successor (genuine ambiguity).
516
+ const unconditional = edges.filter((e) => e.when === undefined);
517
+ if (unconditional.length > 1) {
518
+ throw new Error(
519
+ `${E_PLAYBOOK_RUNTIME_INVALID}: node ${nodeId} has ${unconditional.length} unconditional ` +
520
+ `successors; guarded branching requires every additional edge to carry a "when" predicate`,
521
+ );
522
+ }
523
+
524
+ // Collect satisfied successors: unconditional edge (if any) + guarded edges
525
+ // whose predicate holds.
526
+ const satisfied: string[] = [];
527
+ for (const e of edges) {
528
+ if (e.when === undefined || evaluateEdgeCondition(e.when, context)) {
529
+ satisfied.push(e.to);
530
+ }
531
+ }
532
+
533
+ if (satisfied.length === 0) {
534
+ throw new Error(
535
+ `${E_PLAYBOOK_RUNTIME_INVALID}: node ${nodeId} has ${edges.length} successors but no ` +
536
+ `"when" predicate is satisfied — branch has no reachable default`,
537
+ );
538
+ }
539
+ if (satisfied.length > 1) {
341
540
  throw new Error(
342
- `${E_PLAYBOOK_RUNTIME_INVALID}: node ${nodeId} has ${outs.length} successors; branching requires an approval/decide node`,
541
+ `${E_PLAYBOOK_RUNTIME_INVALID}: node ${nodeId} has ${satisfied.length} satisfied successors ` +
542
+ `(${satisfied.join(', ')}); branch predicates must be mutually exclusive`,
343
543
  );
344
544
  }
345
- // Safe: length === 1
346
- const [next] = outs;
545
+ const [next] = satisfied;
347
546
  if (next === undefined) {
348
547
  throw new Error(`${E_PLAYBOOK_RUNTIME_INVALID}: node ${nodeId} has undefined successor`);
349
548
  }
@@ -366,6 +565,75 @@ function resolveNode(nodeId: string, idx: NodeIndex): PlaybookNode {
366
565
  // Per-node execution
367
566
  // ---------------------------------------------------------------------------
368
567
 
568
+ /**
569
+ * Resolve a {@link PlaybookAgenticNode}'s declared `inputs` into the per-step
570
+ * binding map handed to the dispatcher (T1944 · M4 cantbook done-gate).
571
+ *
572
+ * Each `inputs` value is a mustache `{{path}}` template. Templates are resolved
573
+ * against the accumulated run `context` using the canonical T1238
574
+ * {@link DefaultVariableResolver} (no second substitution engine — DRY) and the
575
+ * resolved key/value pairs are then layered ON TOP of the run context. The
576
+ * result is the dispatch-time binding map.
577
+ *
578
+ * ## Canonical resolver precedence (highest → lowest)
579
+ *
580
+ * 1. **step bindings** — the resolved `node.inputs` themselves. They are merged
581
+ * LAST over the run context, so a step input always SHADOWS a same-named
582
+ * context key (AC3 / AC6).
583
+ * 2. **playbook bindings** — the accumulated run `context` (seeded from the
584
+ * playbook's declared inputs + every prior node's merged output).
585
+ * 3. **session / project-context / env / default** — supplied to the underlying
586
+ * {@link DefaultVariableResolver} via the {@link SubstitutionContext} below.
587
+ *
588
+ * The run `context` is exposed to the resolver through BOTH the `projectContext`
589
+ * tier (which supports dot-notation, so `{{inputs.epicId}}` and `{{context.foo}}`
590
+ * resolve into nested objects) and `process.env` (the `env` tier). The
591
+ * resolver runs in lenient mode.
592
+ *
593
+ * ## Missing-key rule
594
+ *
595
+ * Lenient. When a `{{path}}` template references a value that resolves through
596
+ * NO tier, the literal placeholder text is left intact in the bound value (e.g.
597
+ * `{{missing}}` stays `"{{missing}}"`). This mirrors the canonical T1238
598
+ * lenient posture — a missing input never aborts the dispatch; the agent
599
+ * receives the literal placeholder and can fail loudly downstream if required.
600
+ *
601
+ * @param node - The agentic node whose `inputs` are resolved.
602
+ * @param context - The accumulated run bindings at dispatch time.
603
+ * @returns A new binding map = `{ ...context, ...resolvedStepInputs }`. When the
604
+ * node declares no `inputs`, this is a shallow copy of `context`.
605
+ */
606
+ export function resolveStepBindings(
607
+ node: PlaybookAgenticNode,
608
+ context: Record<string, unknown>,
609
+ ): Record<string, unknown> {
610
+ // No declared inputs — bindings are just a snapshot of the run context.
611
+ if (node.inputs === undefined || Object.keys(node.inputs).length === 0) {
612
+ return { ...context };
613
+ }
614
+
615
+ // Expose the run context to the canonical resolver. `projectContext` is the
616
+ // dot-notation-capable tier, so `{{inputs.epicId}}` / `{{context.foo.bar}}`
617
+ // walk into nested objects; flat keys (`{{epicId}}`) resolve via a
618
+ // single-segment walk on the same tier.
619
+ const substitutionContext: SubstitutionContext = {
620
+ projectContext: context,
621
+ env: process.env,
622
+ };
623
+
624
+ const resolvedInputs: Record<string, unknown> = {};
625
+ for (const [key, template] of Object.entries(node.inputs)) {
626
+ // Lenient resolve — missing placeholders are left literal (see TSDoc).
627
+ const result = defaultVariableResolver.resolve(template, substitutionContext, {
628
+ strict: false,
629
+ });
630
+ resolvedInputs[key] = result.text;
631
+ }
632
+
633
+ // Step bindings shadow the run context for same-named keys (AC3 / AC6).
634
+ return { ...context, ...resolvedInputs };
635
+ }
636
+
369
637
  /**
370
638
  * Execute a single `agentic` node via the injected {@link AgentDispatcher}.
371
639
  * The dispatcher receives the current context and must return a success /
@@ -377,6 +645,7 @@ async function executeAgenticNode(
377
645
  context: Record<string, unknown>,
378
646
  iteration: number,
379
647
  dispatcher: AgentDispatcher,
648
+ playbookName: string,
380
649
  ): Promise<NodeOutcome> {
381
650
  const agentId = node.agent ?? node.skill;
382
651
  if (agentId === undefined) {
@@ -389,6 +658,10 @@ async function executeAgenticNode(
389
658
  const taskIdRaw = context['taskId'];
390
659
  const taskId = typeof taskIdRaw === 'string' && taskIdRaw.length > 0 ? taskIdRaw : runId;
391
660
 
661
+ // T1944 (M4): resolve `node.inputs` `{{path}}` templates against the run
662
+ // context and layer them on top so step inputs shadow context (AC1/AC2/AC3).
663
+ const bindings = resolveStepBindings(node, context);
664
+
392
665
  try {
393
666
  const result = await dispatcher.dispatch({
394
667
  runId,
@@ -396,7 +669,20 @@ async function executeAgenticNode(
396
669
  agentId,
397
670
  taskId,
398
671
  context: { ...context },
672
+ bindings,
399
673
  iteration,
674
+ // T11759 (M4): forward the playbook name + the node's per-stage LLM pin so
675
+ // the dispatcher can resolve a declared `profile:` through the E9
676
+ // chokepoint. All-additive; omitted when the node declares no pin.
677
+ playbookName,
678
+ ...(node.profile !== undefined ? { profile: node.profile } : {}),
679
+ ...(node.model !== undefined ? { model: node.model } : {}),
680
+ ...(node.provider !== undefined ? { provider: node.provider } : {}),
681
+ // T1947 (M4): forward the per-step skill/tool allowlists so the dispatcher
682
+ // can intersect them against the Tier-0/1/2 baseline at spawn time. Omitted
683
+ // when the node declares no allowlist (additive — baseline unchanged).
684
+ ...(node.allowed_skills !== undefined ? { allowedSkills: node.allowed_skills } : {}),
685
+ ...(node.allowed_tools !== undefined ? { allowedTools: node.allowed_tools } : {}),
400
686
  });
401
687
  if (result.status === 'success') {
402
688
  return { kind: 'success', output: result.output };
@@ -612,167 +898,6 @@ function iterationCapFor(node: PlaybookNode, runtimeDefault: number): number {
612
898
  return runtimeDefault;
613
899
  }
614
900
 
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
901
  /**
777
902
  * Core step-by-step executor shared by {@link executePlaybook} and
778
903
  * {@link resumePlaybook}. Starts at `startNodeId` and walks the graph until
@@ -873,7 +998,14 @@ async function runFromNode(args: {
873
998
 
874
999
  let outcome: NodeOutcome;
875
1000
  if (node.type === 'agentic') {
876
- outcome = await executeAgenticNode(node, run.runId, context, attempt, dispatcher);
1001
+ outcome = await executeAgenticNode(
1002
+ node,
1003
+ run.runId,
1004
+ context,
1005
+ attempt,
1006
+ dispatcher,
1007
+ playbook.name,
1008
+ );
877
1009
  } else if (node.type === 'deterministic') {
878
1010
  outcome = await executeDeterministicNode(
879
1011
  node,
@@ -918,19 +1050,46 @@ async function runFromNode(args: {
918
1050
  }
919
1051
  }
920
1052
 
921
- // RCASD/IVTR output schema validation (T11499 AC3):
1053
+ // RCASD/IVTR + generic output schema validation (T11499 AC3 · T11762 ST-2):
922
1054
  // Enforce ensures.schema beyond key-presence so garbage decompositions
923
1055
  // cannot silently pass the runtime contract check.
1056
+ //
1057
+ // De-hardcoded (T11762): the former `if (schema === 'task_tree') …
1058
+ // else if (=== 'evidence')` block — which SILENTLY SKIPPED any other
1059
+ // schema name — is replaced by a registry-driven lookup against the
1060
+ // ensures-schema Zod registry (`getEnsuresSchema` in `@cleocode/core`,
1061
+ // backed by the `@cleocode/contracts` registry DATA). An UNKNOWN schema
1062
+ // name now FAILS CLOSED (AC2) and is routed through the SAME
1063
+ // `contract_violation` handler below (AC3), rather than passing silently.
924
1064
  if (node.ensures?.schema) {
925
1065
  let schemaViolation: string | null = null;
926
1066
 
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']);
1067
+ const spec = getEnsuresSchema(node.ensures.schema);
1068
+ if (spec === null) {
1069
+ // FAIL-CLOSED (AC2): unknown schema names were historically skipped
1070
+ // silently. They are now an explicit, audited contract violation.
1071
+ schemaViolation =
1072
+ `ensures.schema[${node.ensures.schema}] on ${node.id}: unknown schema name. ` +
1073
+ `Registered: ${listEnsuresSchemaNames().join(', ')}`;
1074
+ } else {
1075
+ // Validate the post-merge context value against the registered Zod
1076
+ // schema. The message wrapper re-applies the historical
1077
+ // `ensures.schema[<name>] on <nodeId>: <issue>` prefix so the
1078
+ // end-to-end violation strings stay parity-identical to the deleted
1079
+ // bespoke validators (the Zod issue messages were crafted in ST-1 to
1080
+ // echo the original suffixes).
1081
+ const result = spec.schema.safeParse(context[spec.contextKey]);
1082
+ if (!result.success) {
1083
+ const issue = result.error.issues[0];
1084
+ const path = issue?.path ?? [];
1085
+ const at = path.length > 0 ? ` at ${path.join('.')}` : '';
1086
+ schemaViolation =
1087
+ `ensures.schema[${spec.name}] on ${node.id}: ` +
1088
+ `${issue?.message ?? 'invalid shape'}${at}`;
1089
+ }
931
1090
  }
932
- // Future schema names are silently skipped (open for extension).
933
1091
 
1092
+ // ── UNCHANGED from here (the existing contract_violation handler) ──
934
1093
  if (schemaViolation !== null) {
935
1094
  auditContractViolation(
936
1095
  args.projectRoot,
@@ -962,7 +1121,9 @@ async function runFromNode(args: {
962
1121
  if (failedNodeId !== undefined) break;
963
1122
 
964
1123
  // Validate outgoing edge contracts (edge.contract.requires on the FROM side).
965
- const nextId = resolveNextNodeId(node.id, edgeIndex);
1124
+ // Branch routing (T11806) reads the post-merge context so guard
1125
+ // predicates can act on this node's freshly merged output.
1126
+ const nextId = resolveNextNodeId(node.id, edgeIndex, context);
966
1127
  if (nextId !== null) {
967
1128
  const edge = resolveEdge(node.id, nextId, playbook.edges);
968
1129
  if (edge?.contract?.requires) {
@@ -1241,10 +1402,29 @@ export async function resumePlaybook(
1241
1402
  `${E_PLAYBOOK_RESUME_BLOCKED}: approval node ${approval.nodeId} not found in playbook ${options.playbook.name}`,
1242
1403
  );
1243
1404
  }
1244
- const successor = resolveNextNodeId(approvalNode.id, edgeIndex);
1405
+
1406
+ const now = options.now ?? (() => new Date());
1407
+ const maxIterationsDefault = options.maxIterationsDefault ?? 3;
1408
+ const approvalSecret = options.approvalSecret ?? getPlaybookSecret();
1409
+ const context: Record<string, unknown> = { ...run.bindings };
1410
+ const iterationCounts: Record<string, number> = { ...run.iterationCounts };
1411
+
1412
+ // Log the approval decision into the context so downstream nodes can act on
1413
+ // it. Set BEFORE resolving the successor so a gate can branch on the
1414
+ // approver's decision via a `when: { field: __lastApproval... }` edge (T11806
1415
+ // approval/resume branch parity).
1416
+ context['__lastApproval'] = {
1417
+ nodeId: approval.nodeId,
1418
+ approvalId: approval.approvalId,
1419
+ approver: approval.approver,
1420
+ reason: approval.reason,
1421
+ approvedAt: approval.approvedAt,
1422
+ };
1423
+
1424
+ const successor = resolveNextNodeId(approvalNode.id, edgeIndex, context);
1245
1425
  if (successor === null) {
1246
1426
  // Approval at the tail of the graph completes the run immediately.
1247
- const completedAt = (options.now ?? (() => new Date()))().toISOString();
1427
+ const completedAt = now().toISOString();
1248
1428
  updatePlaybookRun(options.db, run.runId, {
1249
1429
  status: 'completed',
1250
1430
  currentNode: null,
@@ -1265,21 +1445,6 @@ export async function resumePlaybook(
1265
1445
  errorContext: null,
1266
1446
  });
1267
1447
 
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
1448
  // Persist an approval trace row for audit purposes. createPlaybookApproval
1284
1449
  // is distinct from createApprovalGate — the latter generates the HMAC
1285
1450
  // resume token, while this helper records arbitrary approval state.