@cleocode/playbooks 2026.6.13 → 2026.6.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -1
- package/dist/index.js +3 -1
- package/dist/parser.js +33 -0
- package/dist/runtime.d.ts +101 -1
- package/dist/runtime.js +84 -3
- package/package.json +3 -3
- package/src/__tests__/fixtures/pi-skill-only.cantbook +26 -0
- package/src/__tests__/guarded-deterministic-dispatch.test.ts +225 -0
- package/src/__tests__/inputs-bindings.test.ts +250 -0
- package/src/__tests__/node-allowlist.test.ts +186 -0
- package/src/__tests__/node-profile.test.ts +142 -0
- package/src/__tests__/parser.test.ts +80 -0
- package/src/__tests__/pi-cantbook-e2e.test.ts +294 -0
- package/src/__tests__/rcasd-one-envelope.test.ts +239 -0
- package/src/index.ts +2 -0
- package/src/parser.ts +35 -0
- package/src/runtime.ts +161 -2
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
|
-
|
|
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(
|
|
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,
|