@cleocode/playbooks 2026.6.13 → 2026.6.15

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
@@ -48,13 +48,17 @@ import type {
48
48
  PlaybookNode,
49
49
  PlaybookRun,
50
50
  PlaybookRunStatus,
51
+ SubstitutionContext,
51
52
  } from '@cleocode/contracts';
52
53
  // T11762 ST-2: registry-driven ensures.schema resolution. The bodied accessors
53
54
  // live in `@cleocode/core` (Gate-10 contracts-purity forbids them in the
54
55
  // contracts leaf); core does NOT depend on playbooks, so this import direction
55
56
  // is cycle-free (mirrors the existing `@cleocode/core/store/schema` import in
56
57
  // `./schema.ts`).
57
- import { getEnsuresSchema, listEnsuresSchemaNames } from '@cleocode/core';
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';
58
62
  import { createApprovalGate, getPlaybookSecret } from './approval.js';
59
63
  import {
60
64
  createPlaybookApproval,
@@ -84,8 +88,69 @@ export interface AgentDispatchInput {
84
88
  taskId: string;
85
89
  /** Snapshot of the accumulated bindings at dispatch time. */
86
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>;
87
109
  /** 1-based iteration counter for this specific node (retry-aware). */
88
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[];
89
154
  }
90
155
 
91
156
  /**
@@ -500,6 +565,75 @@ function resolveNode(nodeId: string, idx: NodeIndex): PlaybookNode {
500
565
  // Per-node execution
501
566
  // ---------------------------------------------------------------------------
502
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
+
503
637
  /**
504
638
  * Execute a single `agentic` node via the injected {@link AgentDispatcher}.
505
639
  * The dispatcher receives the current context and must return a success /
@@ -511,6 +645,7 @@ async function executeAgenticNode(
511
645
  context: Record<string, unknown>,
512
646
  iteration: number,
513
647
  dispatcher: AgentDispatcher,
648
+ playbookName: string,
514
649
  ): Promise<NodeOutcome> {
515
650
  const agentId = node.agent ?? node.skill;
516
651
  if (agentId === undefined) {
@@ -523,6 +658,10 @@ async function executeAgenticNode(
523
658
  const taskIdRaw = context['taskId'];
524
659
  const taskId = typeof taskIdRaw === 'string' && taskIdRaw.length > 0 ? taskIdRaw : runId;
525
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
+
526
665
  try {
527
666
  const result = await dispatcher.dispatch({
528
667
  runId,
@@ -530,7 +669,20 @@ async function executeAgenticNode(
530
669
  agentId,
531
670
  taskId,
532
671
  context: { ...context },
672
+ bindings,
533
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 } : {}),
534
686
  });
535
687
  if (result.status === 'success') {
536
688
  return { kind: 'success', output: result.output };
@@ -846,7 +998,14 @@ async function runFromNode(args: {
846
998
 
847
999
  let outcome: NodeOutcome;
848
1000
  if (node.type === 'agentic') {
849
- 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
+ );
850
1009
  } else if (node.type === 'deterministic') {
851
1010
  outcome = await executeDeterministicNode(
852
1011
  node,