@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/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, } 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, resolveStepBindings, 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
|
@@ -37,6 +37,8 @@ export { DEFAULT_POLICY_RULES, evaluatePolicy, } from './policy.js';
|
|
|
37
37
|
// ensures-schema registry (`@cleocode/contracts` data + `@cleocode/core`
|
|
38
38
|
// accessors). No cross-package consumer imported them, so dropping the
|
|
39
39
|
// re-exports is safe.
|
|
40
|
-
export { E_PLAYBOOK_RESUME_BLOCKED, E_PLAYBOOK_RUNTIME_INVALID, executePlaybook,
|
|
40
|
+
export { E_PLAYBOOK_RESUME_BLOCKED, E_PLAYBOOK_RUNTIME_INVALID, executePlaybook,
|
|
41
|
+
// T1944 (M4): per-step `node.inputs` → dispatch-boundary binding resolver.
|
|
42
|
+
resolveStepBindings, resumePlaybook, } from './runtime.js';
|
|
41
43
|
// W4-8: state layer CRUD for playbook_runs + playbook_approvals
|
|
42
44
|
export { createPlaybookApproval, createPlaybookRun, deletePlaybookRun, getPlaybookApprovalByToken, getPlaybookRun, listPlaybookApprovals, listPlaybookRuns, updatePlaybookApproval, updatePlaybookRun, } from './state.js';
|
package/dist/parser.js
CHANGED
|
@@ -230,6 +230,17 @@ function parseAgenticNode(raw, base, index) {
|
|
|
230
230
|
inputs = acc;
|
|
231
231
|
}
|
|
232
232
|
const context_files = parseStringArray(raw.context_files, `nodes[${index}].context_files`);
|
|
233
|
+
// T11759 (M4): per-stage LLM pin. Each is an optional non-empty string — the
|
|
234
|
+
// dispatcher resolves them through the E9 chokepoint at dispatch time. Absent
|
|
235
|
+
// fields leave resolution unchanged (additive).
|
|
236
|
+
const profile = parseOptionalNonEmptyString(raw.profile, `nodes[${index}].profile`);
|
|
237
|
+
const model = parseOptionalNonEmptyString(raw.model, `nodes[${index}].model`);
|
|
238
|
+
const provider = parseOptionalNonEmptyString(raw.provider, `nodes[${index}].provider`);
|
|
239
|
+
// T1947 (M4): per-step skill/tool allowlists. Each is an optional string[] —
|
|
240
|
+
// enforced at spawn time by composeSpawnPayload as an INTERSECTION against the
|
|
241
|
+
// Tier-0/1/2 baseline (restrict-only, never expand). Absent → no restriction.
|
|
242
|
+
const allowed_skills = parseStringArray(raw.allowed_skills, `nodes[${index}].allowed_skills`);
|
|
243
|
+
const allowed_tools = parseStringArray(raw.allowed_tools, `nodes[${index}].allowed_tools`);
|
|
233
244
|
return {
|
|
234
245
|
...base,
|
|
235
246
|
type: 'agentic',
|
|
@@ -238,6 +249,11 @@ function parseAgenticNode(raw, base, index) {
|
|
|
238
249
|
role,
|
|
239
250
|
inputs,
|
|
240
251
|
...(context_files !== undefined ? { context_files } : {}),
|
|
252
|
+
...(profile !== undefined ? { profile } : {}),
|
|
253
|
+
...(model !== undefined ? { model } : {}),
|
|
254
|
+
...(provider !== undefined ? { provider } : {}),
|
|
255
|
+
...(allowed_skills !== undefined ? { allowed_skills } : {}),
|
|
256
|
+
...(allowed_tools !== undefined ? { allowed_tools } : {}),
|
|
241
257
|
};
|
|
242
258
|
}
|
|
243
259
|
function parseDeterministicNode(raw, base, index) {
|
|
@@ -529,6 +545,23 @@ function parseStringArray(raw, field) {
|
|
|
529
545
|
return v;
|
|
530
546
|
});
|
|
531
547
|
}
|
|
548
|
+
/**
|
|
549
|
+
* Parse an optional scalar string field that, when present, MUST be a non-empty
|
|
550
|
+
* string (T11759 — the per-stage LLM pins `profile` / `model` / `provider`).
|
|
551
|
+
*
|
|
552
|
+
* @param raw - Raw YAML value.
|
|
553
|
+
* @param field - Field path for diagnostics.
|
|
554
|
+
* @returns The trimmed-as-authored string, or `undefined` when absent.
|
|
555
|
+
* @throws {PlaybookParseError} When present but not a non-empty string.
|
|
556
|
+
*/
|
|
557
|
+
function parseOptionalNonEmptyString(raw, field) {
|
|
558
|
+
if (raw === undefined)
|
|
559
|
+
return undefined;
|
|
560
|
+
if (typeof raw !== 'string' || raw.length === 0) {
|
|
561
|
+
throw new PlaybookParseError(`${field} must be a non-empty string`, field, raw);
|
|
562
|
+
}
|
|
563
|
+
return raw;
|
|
564
|
+
}
|
|
532
565
|
/**
|
|
533
566
|
* Detect cycles across the combined edge-set (explicit `edges[]` plus
|
|
534
567
|
* `depends[]` back-references). Uses 3-color DFS.
|
package/dist/runtime.d.ts
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
* @task T1261 PSYCHE E4 — contract enforcement + context boundary
|
|
36
36
|
*/
|
|
37
37
|
import type { DatabaseSync } from 'node:sqlite';
|
|
38
|
-
import type { PlaybookDefinition } from '@cleocode/contracts';
|
|
38
|
+
import type { PlaybookAgenticNode, PlaybookDefinition } from '@cleocode/contracts';
|
|
39
39
|
/**
|
|
40
40
|
* Input payload handed to {@link AgentDispatcher.dispatch} on every
|
|
41
41
|
* `agentic` node execution. All fields are read-only from the dispatcher's
|
|
@@ -52,8 +52,69 @@ export interface AgentDispatchInput {
|
|
|
52
52
|
taskId: string;
|
|
53
53
|
/** Snapshot of the accumulated bindings at dispatch time. */
|
|
54
54
|
context: Record<string, unknown>;
|
|
55
|
+
/**
|
|
56
|
+
* Per-step resolved bindings for this node (T1944 · M4 cantbook done-gate).
|
|
57
|
+
*
|
|
58
|
+
* Additive field. Built by {@link resolveStepBindings} from
|
|
59
|
+
* {@link PlaybookAgenticNode.inputs} — each declared input is a mustache
|
|
60
|
+
* `{{path}}` template resolved against the accumulated run `context` (via the
|
|
61
|
+
* canonical T1238 {@link DefaultVariableResolver}), then layered ON TOP of
|
|
62
|
+
* that context so a step input SHADOWS a same-named context key.
|
|
63
|
+
*
|
|
64
|
+
* The runtime always populates this when dispatching an agentic node (it is a
|
|
65
|
+
* shallow copy of `context` when the node declares no `inputs`). It is typed
|
|
66
|
+
* optional only to keep the change strictly additive for any out-of-tree
|
|
67
|
+
* dispatcher that constructs an {@link AgentDispatchInput} directly.
|
|
68
|
+
*
|
|
69
|
+
* Resolver precedence (highest → lowest):
|
|
70
|
+
* `step bindings ⊳ playbook bindings (run context) ⊳ session ⊳ project-context ⊳ env ⊳ default`.
|
|
71
|
+
*/
|
|
72
|
+
bindings?: Record<string, unknown>;
|
|
55
73
|
/** 1-based iteration counter for this specific node (retry-aware). */
|
|
56
74
|
iteration: number;
|
|
75
|
+
/**
|
|
76
|
+
* Name of the running playbook (`.cantbook`) — the first segment of the
|
|
77
|
+
* cantbook node identity `cantbook:<playbook>#<nodeId>` (T11759 · M4).
|
|
78
|
+
*
|
|
79
|
+
* Additive. The runtime always populates it for agentic nodes; the dispatcher
|
|
80
|
+
* uses it (with {@link nodeId}) to resolve a declared per-stage LLM profile
|
|
81
|
+
* through the E9 chokepoint. Optional only so an out-of-tree dispatcher that
|
|
82
|
+
* constructs an {@link AgentDispatchInput} directly remains source-compatible.
|
|
83
|
+
*/
|
|
84
|
+
playbookName?: string;
|
|
85
|
+
/**
|
|
86
|
+
* The node's declared per-stage LLM pin (T11759 · M4) — lifted verbatim from
|
|
87
|
+
* {@link PlaybookAgenticNode.profile} / `.model` / `.provider`.
|
|
88
|
+
*
|
|
89
|
+
* Additive + all-optional. When any field is set the dispatcher resolves the
|
|
90
|
+
* stage's LLM through `resolveLLMForSystem` keyed by the cantbook node
|
|
91
|
+
* identity, honoring the profile pin; when all are unset resolution is
|
|
92
|
+
* unchanged. The runtime NEVER acts on these itself — it only forwards them.
|
|
93
|
+
*/
|
|
94
|
+
profile?: string;
|
|
95
|
+
/** Inline model-id pin forwarded from {@link PlaybookAgenticNode.model} (T11759). */
|
|
96
|
+
model?: string;
|
|
97
|
+
/** Inline provider pin forwarded from {@link PlaybookAgenticNode.provider} (T11759). */
|
|
98
|
+
provider?: string;
|
|
99
|
+
/**
|
|
100
|
+
* Per-step skill allowlist lifted verbatim from
|
|
101
|
+
* {@link PlaybookAgenticNode.allowed_skills} (T1947 · M4).
|
|
102
|
+
*
|
|
103
|
+
* Additive + optional. When present the dispatcher passes it to
|
|
104
|
+
* `composeSpawnPayload`, which intersects it with the resolved agent's skill
|
|
105
|
+
* baseline (Tier-0/1/2) — the allowlist can only RESTRICT, never expand. When
|
|
106
|
+
* absent the spawn carries its full baseline skill set. The runtime NEVER acts
|
|
107
|
+
* on this itself — it only forwards it.
|
|
108
|
+
*/
|
|
109
|
+
allowedSkills?: string[];
|
|
110
|
+
/**
|
|
111
|
+
* Per-step tool allowlist lifted verbatim from
|
|
112
|
+
* {@link PlaybookAgenticNode.allowed_tools} (T1947 · M4).
|
|
113
|
+
*
|
|
114
|
+
* Same restrict-only intersection semantics as {@link allowedSkills}, applied
|
|
115
|
+
* to the spawn's tool baseline.
|
|
116
|
+
*/
|
|
117
|
+
allowedTools?: string[];
|
|
57
118
|
}
|
|
58
119
|
/**
|
|
59
120
|
* Terminal output returned by {@link AgentDispatcher.dispatch}. The runtime
|
|
@@ -191,6 +252,45 @@ export declare const E_PLAYBOOK_RUNTIME_INVALID: "E_PLAYBOOK_RUNTIME_INVALID";
|
|
|
191
252
|
* does not resolve to an `approved` gate.
|
|
192
253
|
*/
|
|
193
254
|
export declare const E_PLAYBOOK_RESUME_BLOCKED: "E_PLAYBOOK_RESUME_BLOCKED";
|
|
255
|
+
/**
|
|
256
|
+
* Resolve a {@link PlaybookAgenticNode}'s declared `inputs` into the per-step
|
|
257
|
+
* binding map handed to the dispatcher (T1944 · M4 cantbook done-gate).
|
|
258
|
+
*
|
|
259
|
+
* Each `inputs` value is a mustache `{{path}}` template. Templates are resolved
|
|
260
|
+
* against the accumulated run `context` using the canonical T1238
|
|
261
|
+
* {@link DefaultVariableResolver} (no second substitution engine — DRY) and the
|
|
262
|
+
* resolved key/value pairs are then layered ON TOP of the run context. The
|
|
263
|
+
* result is the dispatch-time binding map.
|
|
264
|
+
*
|
|
265
|
+
* ## Canonical resolver precedence (highest → lowest)
|
|
266
|
+
*
|
|
267
|
+
* 1. **step bindings** — the resolved `node.inputs` themselves. They are merged
|
|
268
|
+
* LAST over the run context, so a step input always SHADOWS a same-named
|
|
269
|
+
* context key (AC3 / AC6).
|
|
270
|
+
* 2. **playbook bindings** — the accumulated run `context` (seeded from the
|
|
271
|
+
* playbook's declared inputs + every prior node's merged output).
|
|
272
|
+
* 3. **session / project-context / env / default** — supplied to the underlying
|
|
273
|
+
* {@link DefaultVariableResolver} via the {@link SubstitutionContext} below.
|
|
274
|
+
*
|
|
275
|
+
* The run `context` is exposed to the resolver through BOTH the `projectContext`
|
|
276
|
+
* tier (which supports dot-notation, so `{{inputs.epicId}}` and `{{context.foo}}`
|
|
277
|
+
* resolve into nested objects) and `process.env` (the `env` tier). The
|
|
278
|
+
* resolver runs in lenient mode.
|
|
279
|
+
*
|
|
280
|
+
* ## Missing-key rule
|
|
281
|
+
*
|
|
282
|
+
* Lenient. When a `{{path}}` template references a value that resolves through
|
|
283
|
+
* NO tier, the literal placeholder text is left intact in the bound value (e.g.
|
|
284
|
+
* `{{missing}}` stays `"{{missing}}"`). This mirrors the canonical T1238
|
|
285
|
+
* lenient posture — a missing input never aborts the dispatch; the agent
|
|
286
|
+
* receives the literal placeholder and can fail loudly downstream if required.
|
|
287
|
+
*
|
|
288
|
+
* @param node - The agentic node whose `inputs` are resolved.
|
|
289
|
+
* @param context - The accumulated run bindings at dispatch time.
|
|
290
|
+
* @returns A new binding map = `{ ...context, ...resolvedStepInputs }`. When the
|
|
291
|
+
* node declares no `inputs`, this is a shallow copy of `context`.
|
|
292
|
+
*/
|
|
293
|
+
export declare function resolveStepBindings(node: PlaybookAgenticNode, context: Record<string, unknown>): Record<string, unknown>;
|
|
194
294
|
/**
|
|
195
295
|
* Execute a playbook from its entry node until a terminal state is reached
|
|
196
296
|
* (`completed`, `failed`, `exceeded_iteration_cap`, or `pending_approval`).
|
package/dist/runtime.js
CHANGED
|
@@ -41,7 +41,10 @@ import { dirname, join } from 'node:path';
|
|
|
41
41
|
// contracts leaf); core does NOT depend on playbooks, so this import direction
|
|
42
42
|
// is cycle-free (mirrors the existing `@cleocode/core/store/schema` import in
|
|
43
43
|
// `./schema.ts`).
|
|
44
|
-
|
|
44
|
+
// T1944 (M4): `defaultVariableResolver` is the canonical T1238 mustache engine,
|
|
45
|
+
// re-used here (DRY) to resolve `PlaybookAgenticNode.inputs` `{{path}}`
|
|
46
|
+
// templates at the dispatch boundary rather than re-implementing substitution.
|
|
47
|
+
import { defaultVariableResolver, getEnsuresSchema, listEnsuresSchemaNames } from '@cleocode/core';
|
|
45
48
|
import { createApprovalGate, getPlaybookSecret } from './approval.js';
|
|
46
49
|
import { createPlaybookApproval, createPlaybookRun, getPlaybookApprovalByToken, getPlaybookRun, updatePlaybookRun, } from './state.js';
|
|
47
50
|
/**
|
|
@@ -249,12 +252,74 @@ function resolveNode(nodeId, idx) {
|
|
|
249
252
|
// ---------------------------------------------------------------------------
|
|
250
253
|
// Per-node execution
|
|
251
254
|
// ---------------------------------------------------------------------------
|
|
255
|
+
/**
|
|
256
|
+
* Resolve a {@link PlaybookAgenticNode}'s declared `inputs` into the per-step
|
|
257
|
+
* binding map handed to the dispatcher (T1944 · M4 cantbook done-gate).
|
|
258
|
+
*
|
|
259
|
+
* Each `inputs` value is a mustache `{{path}}` template. Templates are resolved
|
|
260
|
+
* against the accumulated run `context` using the canonical T1238
|
|
261
|
+
* {@link DefaultVariableResolver} (no second substitution engine — DRY) and the
|
|
262
|
+
* resolved key/value pairs are then layered ON TOP of the run context. The
|
|
263
|
+
* result is the dispatch-time binding map.
|
|
264
|
+
*
|
|
265
|
+
* ## Canonical resolver precedence (highest → lowest)
|
|
266
|
+
*
|
|
267
|
+
* 1. **step bindings** — the resolved `node.inputs` themselves. They are merged
|
|
268
|
+
* LAST over the run context, so a step input always SHADOWS a same-named
|
|
269
|
+
* context key (AC3 / AC6).
|
|
270
|
+
* 2. **playbook bindings** — the accumulated run `context` (seeded from the
|
|
271
|
+
* playbook's declared inputs + every prior node's merged output).
|
|
272
|
+
* 3. **session / project-context / env / default** — supplied to the underlying
|
|
273
|
+
* {@link DefaultVariableResolver} via the {@link SubstitutionContext} below.
|
|
274
|
+
*
|
|
275
|
+
* The run `context` is exposed to the resolver through BOTH the `projectContext`
|
|
276
|
+
* tier (which supports dot-notation, so `{{inputs.epicId}}` and `{{context.foo}}`
|
|
277
|
+
* resolve into nested objects) and `process.env` (the `env` tier). The
|
|
278
|
+
* resolver runs in lenient mode.
|
|
279
|
+
*
|
|
280
|
+
* ## Missing-key rule
|
|
281
|
+
*
|
|
282
|
+
* Lenient. When a `{{path}}` template references a value that resolves through
|
|
283
|
+
* NO tier, the literal placeholder text is left intact in the bound value (e.g.
|
|
284
|
+
* `{{missing}}` stays `"{{missing}}"`). This mirrors the canonical T1238
|
|
285
|
+
* lenient posture — a missing input never aborts the dispatch; the agent
|
|
286
|
+
* receives the literal placeholder and can fail loudly downstream if required.
|
|
287
|
+
*
|
|
288
|
+
* @param node - The agentic node whose `inputs` are resolved.
|
|
289
|
+
* @param context - The accumulated run bindings at dispatch time.
|
|
290
|
+
* @returns A new binding map = `{ ...context, ...resolvedStepInputs }`. When the
|
|
291
|
+
* node declares no `inputs`, this is a shallow copy of `context`.
|
|
292
|
+
*/
|
|
293
|
+
export function resolveStepBindings(node, context) {
|
|
294
|
+
// No declared inputs — bindings are just a snapshot of the run context.
|
|
295
|
+
if (node.inputs === undefined || Object.keys(node.inputs).length === 0) {
|
|
296
|
+
return { ...context };
|
|
297
|
+
}
|
|
298
|
+
// Expose the run context to the canonical resolver. `projectContext` is the
|
|
299
|
+
// dot-notation-capable tier, so `{{inputs.epicId}}` / `{{context.foo.bar}}`
|
|
300
|
+
// walk into nested objects; flat keys (`{{epicId}}`) resolve via a
|
|
301
|
+
// single-segment walk on the same tier.
|
|
302
|
+
const substitutionContext = {
|
|
303
|
+
projectContext: context,
|
|
304
|
+
env: process.env,
|
|
305
|
+
};
|
|
306
|
+
const resolvedInputs = {};
|
|
307
|
+
for (const [key, template] of Object.entries(node.inputs)) {
|
|
308
|
+
// Lenient resolve — missing placeholders are left literal (see TSDoc).
|
|
309
|
+
const result = defaultVariableResolver.resolve(template, substitutionContext, {
|
|
310
|
+
strict: false,
|
|
311
|
+
});
|
|
312
|
+
resolvedInputs[key] = result.text;
|
|
313
|
+
}
|
|
314
|
+
// Step bindings shadow the run context for same-named keys (AC3 / AC6).
|
|
315
|
+
return { ...context, ...resolvedInputs };
|
|
316
|
+
}
|
|
252
317
|
/**
|
|
253
318
|
* Execute a single `agentic` node via the injected {@link AgentDispatcher}.
|
|
254
319
|
* The dispatcher receives the current context and must return a success /
|
|
255
320
|
* failure envelope — any thrown exception is normalized into a failure.
|
|
256
321
|
*/
|
|
257
|
-
async function executeAgenticNode(node, runId, context, iteration, dispatcher) {
|
|
322
|
+
async function executeAgenticNode(node, runId, context, iteration, dispatcher, playbookName) {
|
|
258
323
|
const agentId = node.agent ?? node.skill;
|
|
259
324
|
if (agentId === undefined) {
|
|
260
325
|
// Parser guarantees at-least-one, but narrow defensively.
|
|
@@ -265,6 +330,9 @@ async function executeAgenticNode(node, runId, context, iteration, dispatcher) {
|
|
|
265
330
|
}
|
|
266
331
|
const taskIdRaw = context['taskId'];
|
|
267
332
|
const taskId = typeof taskIdRaw === 'string' && taskIdRaw.length > 0 ? taskIdRaw : runId;
|
|
333
|
+
// T1944 (M4): resolve `node.inputs` `{{path}}` templates against the run
|
|
334
|
+
// context and layer them on top so step inputs shadow context (AC1/AC2/AC3).
|
|
335
|
+
const bindings = resolveStepBindings(node, context);
|
|
268
336
|
try {
|
|
269
337
|
const result = await dispatcher.dispatch({
|
|
270
338
|
runId,
|
|
@@ -272,7 +340,20 @@ async function executeAgenticNode(node, runId, context, iteration, dispatcher) {
|
|
|
272
340
|
agentId,
|
|
273
341
|
taskId,
|
|
274
342
|
context: { ...context },
|
|
343
|
+
bindings,
|
|
275
344
|
iteration,
|
|
345
|
+
// T11759 (M4): forward the playbook name + the node's per-stage LLM pin so
|
|
346
|
+
// the dispatcher can resolve a declared `profile:` through the E9
|
|
347
|
+
// chokepoint. All-additive; omitted when the node declares no pin.
|
|
348
|
+
playbookName,
|
|
349
|
+
...(node.profile !== undefined ? { profile: node.profile } : {}),
|
|
350
|
+
...(node.model !== undefined ? { model: node.model } : {}),
|
|
351
|
+
...(node.provider !== undefined ? { provider: node.provider } : {}),
|
|
352
|
+
// T1947 (M4): forward the per-step skill/tool allowlists so the dispatcher
|
|
353
|
+
// can intersect them against the Tier-0/1/2 baseline at spawn time. Omitted
|
|
354
|
+
// when the node declares no allowlist (additive — baseline unchanged).
|
|
355
|
+
...(node.allowed_skills !== undefined ? { allowedSkills: node.allowed_skills } : {}),
|
|
356
|
+
...(node.allowed_tools !== undefined ? { allowedTools: node.allowed_tools } : {}),
|
|
276
357
|
});
|
|
277
358
|
if (result.status === 'success') {
|
|
278
359
|
return { kind: 'success', output: result.output };
|
|
@@ -512,7 +593,7 @@ async function runFromNode(args) {
|
|
|
512
593
|
}
|
|
513
594
|
let outcome;
|
|
514
595
|
if (node.type === 'agentic') {
|
|
515
|
-
outcome = await executeAgenticNode(node, run.runId, context, attempt, dispatcher);
|
|
596
|
+
outcome = await executeAgenticNode(node, run.runId, context, attempt, dispatcher, playbook.name);
|
|
516
597
|
}
|
|
517
598
|
else if (node.type === 'deterministic') {
|
|
518
599
|
outcome = await executeDeterministicNode(node, run.runId, context, attempt, dispatcher, deterministicRunner);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cleocode/playbooks",
|
|
3
|
-
"version": "2026.6.
|
|
3
|
+
"version": "2026.6.14",
|
|
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/contracts": "2026.6.
|
|
23
|
-
"@cleocode/core": "2026.6.
|
|
22
|
+
"@cleocode/contracts": "2026.6.14",
|
|
23
|
+
"@cleocode/core": "2026.6.14"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/js-yaml": "^4.0.9",
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
version: "1.0"
|
|
2
|
+
name: pi-skill-only
|
|
3
|
+
description: >
|
|
4
|
+
T11945 (M4) deterministic e2e fixture — a single skill-only agentic node. The
|
|
5
|
+
node names a real `ct-*` skill so the production dispatcher's
|
|
6
|
+
`runSkillNodeOrSpawn` takes the IN-PROCESS skill path (findSkill resolves it)
|
|
7
|
+
and, when a Pi runner is injected, routes the node THROUGH the PiAgentAdapter.
|
|
8
|
+
No `requires`/`ensures` contracts so schema enforcement never interferes with
|
|
9
|
+
the dispatch-path assertion. With no LLM credential resolvable in CI the Pi
|
|
10
|
+
loop terminates with the cleo-owned no-credential failure — proving the call
|
|
11
|
+
reached the Pi streamFn (the E9 chokepoint), not a live round-trip.
|
|
12
|
+
|
|
13
|
+
inputs:
|
|
14
|
+
- name: taskId
|
|
15
|
+
required: true
|
|
16
|
+
description: Task id being executed (seeds the run context).
|
|
17
|
+
|
|
18
|
+
nodes:
|
|
19
|
+
- id: execute
|
|
20
|
+
type: agentic
|
|
21
|
+
skill: ct-task-executor
|
|
22
|
+
description: >
|
|
23
|
+
Single skill-only node — routes in-process through the SkillExecutorAdapter,
|
|
24
|
+
and through the PiAgentAdapter when the Pi runner is wired (T11945).
|
|
25
|
+
|
|
26
|
+
edges: []
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T11802 (M4 cantbook done-gate) — every `deterministic` (shell) node dispatch in
|
|
3
|
+
* `executePlaybook` routes through the `guard.ts` deny-first chokepoint.
|
|
4
|
+
*
|
|
5
|
+
* ## What this proves
|
|
6
|
+
*
|
|
7
|
+
* - **AC1 / AC2** — a minimal `.cantbook` with a `deterministic` shell step,
|
|
8
|
+
* executed through the REAL {@link executePlaybook} runtime with the
|
|
9
|
+
* production-shaped {@link createGuardedDeterministicRunner} wired to a real
|
|
10
|
+
* {@link createToolGuard}, reaches the guard: the spawned command actually runs
|
|
11
|
+
* THROUGH `guard.executeShell` (proven by captured stdout in the merged run
|
|
12
|
+
* context). There is NO `child_process` import in the runtime call graph — the
|
|
13
|
+
* guard owns the spawn.
|
|
14
|
+
* - **AC3** — when the SAME runtime dispatches a `deterministic` node whose
|
|
15
|
+
* command is on the guard's denylist (`enforce` mode), the guard REJECTS it
|
|
16
|
+
* BEFORE any process is spawned; the node terminates as a contained failure
|
|
17
|
+
* (the runtime contract is non-throwing) carrying the guard's
|
|
18
|
+
* `E_TOOL_GUARD_DENIED` message.
|
|
19
|
+
* - **AC5** — no new state machine: the runtime is unchanged; only the injected
|
|
20
|
+
* `deterministicRunner` is the guard-routed one.
|
|
21
|
+
*
|
|
22
|
+
* ## Determinism + isolation
|
|
23
|
+
*
|
|
24
|
+
* - IN-PROCESS only — vitest source, NO subprocess driver (tsx unresolvable in CI).
|
|
25
|
+
* - daemon-OFF — an in-memory `node:sqlite` DB, no daemon.
|
|
26
|
+
* - The allowed command is `node -e <print>` (always present on the test box, no
|
|
27
|
+
* network, no filesystem mutation) so the "guard actually spawned" assertion is
|
|
28
|
+
* portable and side-effect-free.
|
|
29
|
+
* - A temp project root scopes the guard's `allowedRoots`; `cwd` is pinned to it.
|
|
30
|
+
*
|
|
31
|
+
* @epic T11391
|
|
32
|
+
* @task T11802
|
|
33
|
+
* @saga T11387
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
37
|
+
import { createRequire } from 'node:module';
|
|
38
|
+
import { tmpdir } from 'node:os';
|
|
39
|
+
import { dirname, join, resolve } from 'node:path';
|
|
40
|
+
import type { DatabaseSync as _DatabaseSyncType } from 'node:sqlite';
|
|
41
|
+
import { fileURLToPath } from 'node:url';
|
|
42
|
+
import type { PlaybookDefinition, PlaybookDeterministicNode } from '@cleocode/contracts';
|
|
43
|
+
import {
|
|
44
|
+
createGuardedDeterministicRunner,
|
|
45
|
+
createToolGuard,
|
|
46
|
+
DEFAULT_DETERMINISTIC_DENIED_COMMANDS,
|
|
47
|
+
} from '@cleocode/core/internal';
|
|
48
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
49
|
+
import { type DeterministicRunner, executePlaybook } from '../runtime.js';
|
|
50
|
+
|
|
51
|
+
const _require = createRequire(import.meta.url);
|
|
52
|
+
type DatabaseSync = _DatabaseSyncType;
|
|
53
|
+
const { DatabaseSync } = _require('node:sqlite') as {
|
|
54
|
+
DatabaseSync: new (...args: ConstructorParameters<typeof _DatabaseSyncType>) => DatabaseSync;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
58
|
+
|
|
59
|
+
/** Absolute path to the T889 playbook-tables migration SQL. */
|
|
60
|
+
const MIGRATION_SQL = resolve(
|
|
61
|
+
__dirname,
|
|
62
|
+
'../../../core/migrations/drizzle-tasks/20260417220000_t889-playbook-tables/migration.sql',
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
/** Apply a multi-statement Drizzle migration (split on the breakpoint token). */
|
|
66
|
+
function applyMigration(db: DatabaseSync, sql: string): void {
|
|
67
|
+
const statements = sql
|
|
68
|
+
.split(/--> statement-breakpoint/)
|
|
69
|
+
.map((s) => s.trim())
|
|
70
|
+
.filter((s) => s.length > 0);
|
|
71
|
+
for (const stmt of statements) {
|
|
72
|
+
const lines = stmt.split('\n');
|
|
73
|
+
const hasSql = lines.some((l) => l.trim().length > 0 && !l.trim().startsWith('--'));
|
|
74
|
+
if (hasSql) db.exec(stmt);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Build a single-node `deterministic` playbook (the entry node is terminal). */
|
|
79
|
+
function singleDeterministicPlaybook(node: PlaybookDeterministicNode): PlaybookDefinition {
|
|
80
|
+
return { version: '1.0', name: 'guarded-shell', nodes: [node], edges: [] };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe('T11802 (M4): deterministic node dispatch routes through guard.ts', () => {
|
|
84
|
+
let db: DatabaseSync;
|
|
85
|
+
let projectRoot: string;
|
|
86
|
+
|
|
87
|
+
beforeEach(() => {
|
|
88
|
+
db = new DatabaseSync(':memory:');
|
|
89
|
+
db.exec('PRAGMA foreign_keys=ON');
|
|
90
|
+
applyMigration(db, readFileSync(MIGRATION_SQL, 'utf8'));
|
|
91
|
+
projectRoot = mkdtempSync(join(tmpdir(), 't11802-guarded-shell-'));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
afterEach(() => {
|
|
95
|
+
db.close();
|
|
96
|
+
rmSync(projectRoot, { recursive: true, force: true });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Build the production-shaped deterministic runner: the SAME guard the CLI's
|
|
101
|
+
* `buildDefaultDeterministicRunner` constructs (allowedRoots + denylist +
|
|
102
|
+
* enforce), wrapped by `createGuardedDeterministicRunner`.
|
|
103
|
+
*/
|
|
104
|
+
function buildGuardedRunner(): DeterministicRunner {
|
|
105
|
+
const tools = createToolGuard({
|
|
106
|
+
allowedRoots: [projectRoot],
|
|
107
|
+
deniedCommands: DEFAULT_DETERMINISTIC_DENIED_COMMANDS,
|
|
108
|
+
mode: 'enforce',
|
|
109
|
+
});
|
|
110
|
+
// Structurally assignable to the runtime's DeterministicRunner.
|
|
111
|
+
return createGuardedDeterministicRunner({ tools });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
it('AC2: a shell step is intercepted by the guard — the command runs THROUGH guard.executeShell', async () => {
|
|
115
|
+
// `node -e` prints a sentinel; capturing it proves the guard actually
|
|
116
|
+
// spawned the process (not a mock).
|
|
117
|
+
const SENTINEL = 'guard-intercepted-T11802';
|
|
118
|
+
const node: PlaybookDeterministicNode = {
|
|
119
|
+
id: 'shell',
|
|
120
|
+
type: 'deterministic',
|
|
121
|
+
command: process.execPath, // node — always present, never on the denylist
|
|
122
|
+
args: ['-e', `process.stdout.write(${JSON.stringify(SENTINEL)})`],
|
|
123
|
+
cwd: projectRoot,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const result = await executePlaybook({
|
|
127
|
+
db,
|
|
128
|
+
playbook: singleDeterministicPlaybook(node),
|
|
129
|
+
playbookHash: 'hash-allowed',
|
|
130
|
+
initialContext: { taskId: 'T11802' },
|
|
131
|
+
dispatcher: {
|
|
132
|
+
// The agentic dispatcher must NEVER be reached for a deterministic node.
|
|
133
|
+
async dispatch() {
|
|
134
|
+
return {
|
|
135
|
+
status: 'failure',
|
|
136
|
+
output: {},
|
|
137
|
+
error: 'agentic dispatcher reached for a deterministic node — guard bypassed!',
|
|
138
|
+
};
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
deterministicRunner: buildGuardedRunner(),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
expect(result.terminalStatus).toBe('completed');
|
|
145
|
+
// The guard's executeShell captured the child's stdout into the merged
|
|
146
|
+
// context — dispositive that the command was spawned THROUGH the guard.
|
|
147
|
+
expect(result.finalContext['shell_stdout']).toBe(SENTINEL);
|
|
148
|
+
expect(result.finalContext['shell_exitCode']).toBe(0);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('AC3: a denied command is rejected by the guard denylist when dispatched from executePlaybook', async () => {
|
|
152
|
+
// `rm` is on DEFAULT_DETERMINISTIC_DENIED_COMMANDS — the guard rejects it
|
|
153
|
+
// BEFORE any process is spawned (enforce mode).
|
|
154
|
+
expect(DEFAULT_DETERMINISTIC_DENIED_COMMANDS).toContain('rm');
|
|
155
|
+
|
|
156
|
+
const node: PlaybookDeterministicNode = {
|
|
157
|
+
id: 'danger',
|
|
158
|
+
type: 'deterministic',
|
|
159
|
+
command: 'rm',
|
|
160
|
+
args: ['-rf', join(projectRoot, 'anything')],
|
|
161
|
+
cwd: projectRoot,
|
|
162
|
+
// One attempt → first denial is terminal (no retry storms).
|
|
163
|
+
on_failure: { max_iterations: 1 },
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const result = await executePlaybook({
|
|
167
|
+
db,
|
|
168
|
+
playbook: singleDeterministicPlaybook(node),
|
|
169
|
+
playbookHash: 'hash-denied',
|
|
170
|
+
initialContext: { taskId: 'T11802' },
|
|
171
|
+
dispatcher: {
|
|
172
|
+
async dispatch() {
|
|
173
|
+
return {
|
|
174
|
+
status: 'failure',
|
|
175
|
+
output: {},
|
|
176
|
+
error: 'agentic dispatcher reached for a deterministic node — guard bypassed!',
|
|
177
|
+
};
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
deterministicRunner: buildGuardedRunner(),
|
|
181
|
+
maxIterationsDefault: 1,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// The denial is a contained node failure (the runtime never throws).
|
|
185
|
+
expect(result.terminalStatus).toBe('exceeded_iteration_cap');
|
|
186
|
+
expect(result.exceededNodeId).toBe('danger');
|
|
187
|
+
// The guard's enforce-mode rejection message reached the runtime.
|
|
188
|
+
expect(result.errorContext ?? '').toMatch(/denylist|E_TOOL_GUARD_DENIED|"rm"/);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('AC1: the guard is the ONLY spawn path — a denied command never spawns rm', async () => {
|
|
192
|
+
// Targeting a path INSIDE the project root so, IF the guard were bypassed,
|
|
193
|
+
// rm would actually delete it. The guard must stop it first. We assert the
|
|
194
|
+
// failure shape (no rm executed) rather than filesystem side effects so the
|
|
195
|
+
// test is hermetic even on a box without `rm`.
|
|
196
|
+
const node: PlaybookDeterministicNode = {
|
|
197
|
+
id: 'danger',
|
|
198
|
+
type: 'deterministic',
|
|
199
|
+
command: '/bin/rm', // absolute path — basename `rm` is still denied
|
|
200
|
+
args: ['-f', join(projectRoot, 'sentinel')],
|
|
201
|
+
cwd: projectRoot,
|
|
202
|
+
on_failure: { max_iterations: 1 },
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
let agenticReached = false;
|
|
206
|
+
const result = await executePlaybook({
|
|
207
|
+
db,
|
|
208
|
+
playbook: singleDeterministicPlaybook(node),
|
|
209
|
+
playbookHash: 'hash-abs-denied',
|
|
210
|
+
initialContext: { taskId: 'T11802' },
|
|
211
|
+
dispatcher: {
|
|
212
|
+
async dispatch() {
|
|
213
|
+
agenticReached = true;
|
|
214
|
+
return { status: 'success', output: { bypassed: true } };
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
deterministicRunner: buildGuardedRunner(),
|
|
218
|
+
maxIterationsDefault: 1,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
expect(agenticReached).toBe(false); // never fell back to the agentic path
|
|
222
|
+
expect(result.terminalStatus).toBe('exceeded_iteration_cap');
|
|
223
|
+
expect(result.errorContext ?? '').toMatch(/denylist|E_TOOL_GUARD_DENIED|"rm"/);
|
|
224
|
+
});
|
|
225
|
+
});
|