@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
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T1944 (M4 cantbook done-gate) — `PlaybookAgenticNode.inputs` →
|
|
3
|
+
* `AgentDispatchInput.bindings` wiring + mustache resolution at the dispatch
|
|
4
|
+
* boundary.
|
|
5
|
+
*
|
|
6
|
+
* Two layers of coverage:
|
|
7
|
+
*
|
|
8
|
+
* 1. Pure-unit tests of {@link resolveStepBindings} — precedence, shadowing,
|
|
9
|
+
* dot-path resolution, and the lenient missing-key rule, exercised in
|
|
10
|
+
* isolation with no DB.
|
|
11
|
+
* 2. In-process `executePlaybook` end-to-end — a `.cantbook`-equivalent node
|
|
12
|
+
* with `inputs: { topic: '{{inputs.epicId}}' }` resolves and the bound
|
|
13
|
+
* value reaches {@link AgentDispatchInput.bindings} at dispatch time.
|
|
14
|
+
*
|
|
15
|
+
* No `@cleocode/*` module is mocked — the resolver is the real canonical
|
|
16
|
+
* T1238 engine and the runtime runs against an in-memory `node:sqlite` DB.
|
|
17
|
+
*
|
|
18
|
+
* @task T1944 — wire PlaybookAgenticNode.inputs → AgentDispatchInput.bindings
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { readFileSync } from 'node:fs';
|
|
22
|
+
import { createRequire } from 'node:module';
|
|
23
|
+
import { dirname, resolve } from 'node:path';
|
|
24
|
+
import type { DatabaseSync as _DatabaseSyncType } from 'node:sqlite';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
import type { PlaybookAgenticNode, PlaybookDefinition } from '@cleocode/contracts';
|
|
27
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
28
|
+
import {
|
|
29
|
+
type AgentDispatcher,
|
|
30
|
+
type AgentDispatchInput,
|
|
31
|
+
type AgentDispatchResult,
|
|
32
|
+
executePlaybook,
|
|
33
|
+
resolveStepBindings,
|
|
34
|
+
} from '../runtime.js';
|
|
35
|
+
|
|
36
|
+
const _require = createRequire(import.meta.url);
|
|
37
|
+
type DatabaseSync = _DatabaseSyncType;
|
|
38
|
+
const { DatabaseSync } = _require('node:sqlite') as {
|
|
39
|
+
DatabaseSync: new (...args: ConstructorParameters<typeof _DatabaseSyncType>) => DatabaseSync;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
43
|
+
const MIGRATION_SQL = resolve(
|
|
44
|
+
__dirname,
|
|
45
|
+
'../../../core/migrations/drizzle-tasks/20260417220000_t889-playbook-tables/migration.sql',
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
function applyMigration(db: DatabaseSync, sql: string): void {
|
|
49
|
+
const statements = sql
|
|
50
|
+
.split(/--> statement-breakpoint/)
|
|
51
|
+
.map((s) => s.trim())
|
|
52
|
+
.filter((s) => s.length > 0);
|
|
53
|
+
for (const stmt of statements) {
|
|
54
|
+
const lines = stmt.split('\n');
|
|
55
|
+
const hasSql = lines.some((l) => l.trim().length > 0 && !l.trim().startsWith('--'));
|
|
56
|
+
if (hasSql) db.exec(stmt);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function agenticNode(
|
|
61
|
+
id: string,
|
|
62
|
+
overrides: Partial<PlaybookAgenticNode> = {},
|
|
63
|
+
): PlaybookAgenticNode {
|
|
64
|
+
return { id, type: 'agentic', skill: `skill-${id}`, ...overrides };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Dispatcher that records the full {@link AgentDispatchInput} per call. */
|
|
68
|
+
function makeBindingRecorder(
|
|
69
|
+
handler: (input: AgentDispatchInput) => AgentDispatchResult,
|
|
70
|
+
): AgentDispatcher & { calls: AgentDispatchInput[] } {
|
|
71
|
+
const calls: AgentDispatchInput[] = [];
|
|
72
|
+
return {
|
|
73
|
+
calls,
|
|
74
|
+
async dispatch(input: AgentDispatchInput): Promise<AgentDispatchResult> {
|
|
75
|
+
// Snapshot bindings/context so later mutation cannot taint the assertion.
|
|
76
|
+
calls.push({
|
|
77
|
+
...input,
|
|
78
|
+
context: { ...input.context },
|
|
79
|
+
bindings: input.bindings === undefined ? undefined : { ...input.bindings },
|
|
80
|
+
});
|
|
81
|
+
return handler(input);
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// resolveStepBindings — pure unit
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
describe('T1944: resolveStepBindings (pure)', () => {
|
|
91
|
+
it('AC5: resolves a {{inputs.epicId}} template from the run context', () => {
|
|
92
|
+
const node = agenticNode('research', { inputs: { topic: '{{inputs.epicId}}' } });
|
|
93
|
+
const context = { inputs: { epicId: 'T1942' } };
|
|
94
|
+
|
|
95
|
+
const bound = resolveStepBindings(node, context);
|
|
96
|
+
|
|
97
|
+
expect(bound['topic']).toBe('T1942');
|
|
98
|
+
// Original context keys remain present.
|
|
99
|
+
expect(bound['inputs']).toEqual({ epicId: 'T1942' });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('AC5: resolves a flat {{epicId}} single-segment template', () => {
|
|
103
|
+
const node = agenticNode('research', { inputs: { topic: '{{epicId}}' } });
|
|
104
|
+
const bound = resolveStepBindings(node, { epicId: 'T1942' });
|
|
105
|
+
expect(bound['topic']).toBe('T1942');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('AC6: step bindings shadow playbook bindings for the same key', () => {
|
|
109
|
+
// The run context already carries `topic`; the step input MUST win.
|
|
110
|
+
const node = agenticNode('research', { inputs: { topic: '{{inputs.epicId}}' } });
|
|
111
|
+
const context = { topic: 'stale-context-value', inputs: { epicId: 'T1942' } };
|
|
112
|
+
|
|
113
|
+
const bound = resolveStepBindings(node, context);
|
|
114
|
+
|
|
115
|
+
expect(bound['topic']).toBe('T1942');
|
|
116
|
+
expect(bound['topic']).not.toBe('stale-context-value');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('missing-key rule: unresolved {{path}} is left as a literal placeholder', () => {
|
|
120
|
+
const node = agenticNode('research', { inputs: { topic: '{{inputs.nope}}' } });
|
|
121
|
+
const bound = resolveStepBindings(node, { inputs: { epicId: 'T1942' } });
|
|
122
|
+
expect(bound['topic']).toBe('{{inputs.nope}}');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('resolves dot-path nesting deeper than one level', () => {
|
|
126
|
+
const node = agenticNode('research', { inputs: { fw: '{{project.testing.framework}}' } });
|
|
127
|
+
const bound = resolveStepBindings(node, {
|
|
128
|
+
project: { testing: { framework: 'vitest' } },
|
|
129
|
+
});
|
|
130
|
+
expect(bound['fw']).toBe('vitest');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('passes literal (non-template) input values through unchanged', () => {
|
|
134
|
+
const node = agenticNode('research', { inputs: { mode: 'fast' } });
|
|
135
|
+
const bound = resolveStepBindings(node, { x: 1 });
|
|
136
|
+
expect(bound['mode']).toBe('fast');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('returns a shallow copy of context when the node declares no inputs', () => {
|
|
140
|
+
const node = agenticNode('research');
|
|
141
|
+
const context = { taskId: 'T123', a: 1 };
|
|
142
|
+
const bound = resolveStepBindings(node, context);
|
|
143
|
+
expect(bound).toEqual(context);
|
|
144
|
+
expect(bound).not.toBe(context);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('returns a shallow copy of context for an empty inputs map', () => {
|
|
148
|
+
const node = agenticNode('research', { inputs: {} });
|
|
149
|
+
const context = { taskId: 'T123' };
|
|
150
|
+
expect(resolveStepBindings(node, context)).toEqual(context);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('does not mutate the source context', () => {
|
|
154
|
+
const node = agenticNode('research', { inputs: { topic: '{{epicId}}' } });
|
|
155
|
+
const context = { epicId: 'T1942' };
|
|
156
|
+
resolveStepBindings(node, context);
|
|
157
|
+
expect(context).toEqual({ epicId: 'T1942' });
|
|
158
|
+
expect(context).not.toHaveProperty('topic');
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// executePlaybook — bindings reach the dispatch boundary (in-process e2e)
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
describe('T1944: AgentDispatchInput.bindings (in-process)', () => {
|
|
167
|
+
let db: DatabaseSync;
|
|
168
|
+
|
|
169
|
+
beforeEach(() => {
|
|
170
|
+
db = new DatabaseSync(':memory:');
|
|
171
|
+
db.exec('PRAGMA foreign_keys=ON');
|
|
172
|
+
applyMigration(db, readFileSync(MIGRATION_SQL, 'utf8'));
|
|
173
|
+
});
|
|
174
|
+
afterEach(() => db.close());
|
|
175
|
+
|
|
176
|
+
it('AC2: resolved step input reaches AgentDispatchInput.bindings at dispatch', async () => {
|
|
177
|
+
const playbook: PlaybookDefinition = {
|
|
178
|
+
version: '1.0',
|
|
179
|
+
name: 'inputs-bindings',
|
|
180
|
+
nodes: [agenticNode('research', { inputs: { topic: '{{inputs.epicId}}' } })],
|
|
181
|
+
edges: [],
|
|
182
|
+
};
|
|
183
|
+
const dispatcher = makeBindingRecorder(() => ({ status: 'success', output: {} }));
|
|
184
|
+
|
|
185
|
+
const result = await executePlaybook({
|
|
186
|
+
db,
|
|
187
|
+
playbook,
|
|
188
|
+
playbookHash: 'hash-t1944',
|
|
189
|
+
initialContext: { inputs: { epicId: 'T1942' } },
|
|
190
|
+
dispatcher,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
expect(result.terminalStatus).toBe('completed');
|
|
194
|
+
expect(dispatcher.calls).toHaveLength(1);
|
|
195
|
+
// The resolved binding (NOT the raw template) reaches the dispatcher.
|
|
196
|
+
expect(dispatcher.calls[0]?.bindings?.['topic']).toBe('T1942');
|
|
197
|
+
// The raw accumulated context still carries the source object.
|
|
198
|
+
expect(dispatcher.calls[0]?.context['inputs']).toEqual({ epicId: 'T1942' });
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('AC2: bindings is always populated even when the node declares no inputs', async () => {
|
|
202
|
+
const playbook: PlaybookDefinition = {
|
|
203
|
+
version: '1.0',
|
|
204
|
+
name: 'no-inputs',
|
|
205
|
+
nodes: [agenticNode('plain')],
|
|
206
|
+
edges: [],
|
|
207
|
+
};
|
|
208
|
+
const dispatcher = makeBindingRecorder(() => ({ status: 'success', output: {} }));
|
|
209
|
+
|
|
210
|
+
await executePlaybook({
|
|
211
|
+
db,
|
|
212
|
+
playbook,
|
|
213
|
+
playbookHash: 'hash-t1944-b',
|
|
214
|
+
initialContext: { taskId: 'T123' },
|
|
215
|
+
dispatcher,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
expect(dispatcher.calls[0]?.bindings).toMatchObject({ taskId: 'T123' });
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('AC6: step binding shadows a same-named accumulated context key end-to-end', async () => {
|
|
222
|
+
// Node `a` emits `topic`; node `b` declares an `inputs.topic` template that
|
|
223
|
+
// MUST shadow the inherited context value when dispatched.
|
|
224
|
+
const playbook: PlaybookDefinition = {
|
|
225
|
+
version: '1.0',
|
|
226
|
+
name: 'shadowing',
|
|
227
|
+
nodes: [agenticNode('a'), agenticNode('b', { inputs: { topic: '{{inputs.epicId}}' } })],
|
|
228
|
+
edges: [{ from: 'a', to: 'b' }],
|
|
229
|
+
};
|
|
230
|
+
const dispatcher = makeBindingRecorder((input) =>
|
|
231
|
+
input.nodeId === 'a'
|
|
232
|
+
? { status: 'success', output: { topic: 'from-node-a' } }
|
|
233
|
+
: { status: 'success', output: {} },
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
await executePlaybook({
|
|
237
|
+
db,
|
|
238
|
+
playbook,
|
|
239
|
+
playbookHash: 'hash-t1944-c',
|
|
240
|
+
initialContext: { inputs: { epicId: 'T1942' } },
|
|
241
|
+
dispatcher,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const callB = dispatcher.calls.find((c) => c.nodeId === 'b');
|
|
245
|
+
// Accumulated context still has node-a's value...
|
|
246
|
+
expect(callB?.context['topic']).toBe('from-node-a');
|
|
247
|
+
// ...but the step binding shadows it.
|
|
248
|
+
expect(callB?.bindings?.['topic']).toBe('T1942');
|
|
249
|
+
});
|
|
250
|
+
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T1947 (M4 cantbook done-gate, Gap E/F) — `PlaybookAgenticNode.allowed_skills`
|
|
3
|
+
* / `.allowed_tools` are parsed (AC1) and reach the dispatch boundary as
|
|
4
|
+
* `AgentDispatchInput.allowedSkills` / `.allowedTools` (AC5) so the dispatcher
|
|
5
|
+
* can intersect them against the Tier-0/1/2 baseline at spawn time.
|
|
6
|
+
*
|
|
7
|
+
* Two layers:
|
|
8
|
+
* 1. Parser → runtime forwarding: a `.cantbook` node with `allowed_skills:` /
|
|
9
|
+
* `allowed_tools:` is parsed and the lists reach {@link AgentDispatchInput}
|
|
10
|
+
* at dispatch time — asserted with a recorder dispatcher (no live backend).
|
|
11
|
+
* 2. Default (un-restricted) nodes carry NO allowlist — the dispatch path is
|
|
12
|
+
* unchanged (additive).
|
|
13
|
+
*
|
|
14
|
+
* The intersection enforcement itself lives in `@cleocode/core`'s
|
|
15
|
+
* `composeSpawnPayload` and is covered by `spawn.test.ts`; this suite proves the
|
|
16
|
+
* runtime hands the allowlists to the dispatcher so that enforcement can occur.
|
|
17
|
+
* No `@cleocode/*` module is mocked.
|
|
18
|
+
*
|
|
19
|
+
* @task T1947
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { readFileSync } from 'node:fs';
|
|
23
|
+
import { createRequire } from 'node:module';
|
|
24
|
+
import { dirname, resolve } from 'node:path';
|
|
25
|
+
import type { DatabaseSync as _DatabaseSyncType } from 'node:sqlite';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
28
|
+
import { parsePlaybook } from '../parser.js';
|
|
29
|
+
import {
|
|
30
|
+
type AgentDispatcher,
|
|
31
|
+
type AgentDispatchInput,
|
|
32
|
+
type AgentDispatchResult,
|
|
33
|
+
executePlaybook,
|
|
34
|
+
} from '../runtime.js';
|
|
35
|
+
|
|
36
|
+
const _require = createRequire(import.meta.url);
|
|
37
|
+
type DatabaseSync = _DatabaseSyncType;
|
|
38
|
+
const { DatabaseSync } = _require('node:sqlite') as {
|
|
39
|
+
DatabaseSync: new (...args: ConstructorParameters<typeof _DatabaseSyncType>) => DatabaseSync;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
43
|
+
const MIGRATION_SQL = resolve(
|
|
44
|
+
__dirname,
|
|
45
|
+
'../../../core/migrations/drizzle-tasks/20260417220000_t889-playbook-tables/migration.sql',
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
function applyMigration(db: DatabaseSync, sql: string): void {
|
|
49
|
+
const statements = sql
|
|
50
|
+
.split(/--> statement-breakpoint/)
|
|
51
|
+
.map((s) => s.trim())
|
|
52
|
+
.filter((s) => s.length > 0);
|
|
53
|
+
for (const stmt of statements) {
|
|
54
|
+
const lines = stmt.split('\n');
|
|
55
|
+
const hasSql = lines.some((l) => l.trim().length > 0 && !l.trim().startsWith('--'));
|
|
56
|
+
if (hasSql) db.exec(stmt);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Records the full {@link AgentDispatchInput} per call. */
|
|
61
|
+
function makeRecorder(): AgentDispatcher & { calls: AgentDispatchInput[] } {
|
|
62
|
+
const calls: AgentDispatchInput[] = [];
|
|
63
|
+
return {
|
|
64
|
+
calls,
|
|
65
|
+
async dispatch(input: AgentDispatchInput): Promise<AgentDispatchResult> {
|
|
66
|
+
calls.push({ ...input, context: { ...input.context } });
|
|
67
|
+
return { status: 'success', output: {} };
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe('T1947: AC1 — parser accepts allowed_skills / allowed_tools', () => {
|
|
73
|
+
it('parses string[] allowlists onto the agentic node', () => {
|
|
74
|
+
const yaml = `
|
|
75
|
+
version: "1.0"
|
|
76
|
+
name: gated-flow
|
|
77
|
+
nodes:
|
|
78
|
+
- id: review
|
|
79
|
+
type: agentic
|
|
80
|
+
skill: ct-validator
|
|
81
|
+
allowed_skills: [ct-validator, ct-cleo]
|
|
82
|
+
allowed_tools: [Read, Grep]
|
|
83
|
+
`;
|
|
84
|
+
const { definition } = parsePlaybook(yaml);
|
|
85
|
+
const node = definition.nodes[0];
|
|
86
|
+
expect(node?.type).toBe('agentic');
|
|
87
|
+
if (node?.type !== 'agentic') throw new Error('expected agentic node');
|
|
88
|
+
expect(node.allowed_skills).toEqual(['ct-validator', 'ct-cleo']);
|
|
89
|
+
expect(node.allowed_tools).toEqual(['Read', 'Grep']);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('omits both fields when the node declares neither (additive)', () => {
|
|
93
|
+
const yaml = `
|
|
94
|
+
version: "1.0"
|
|
95
|
+
name: plain-flow
|
|
96
|
+
nodes:
|
|
97
|
+
- id: research
|
|
98
|
+
type: agentic
|
|
99
|
+
skill: ct-research-agent
|
|
100
|
+
`;
|
|
101
|
+
const { definition } = parsePlaybook(yaml);
|
|
102
|
+
const node = definition.nodes[0];
|
|
103
|
+
if (node?.type !== 'agentic') throw new Error('expected agentic node');
|
|
104
|
+
expect(node.allowed_skills).toBeUndefined();
|
|
105
|
+
expect(node.allowed_tools).toBeUndefined();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('rejects a non-string entry in allowed_skills', () => {
|
|
109
|
+
const yaml = `
|
|
110
|
+
version: "1.0"
|
|
111
|
+
name: bad-flow
|
|
112
|
+
nodes:
|
|
113
|
+
- id: review
|
|
114
|
+
type: agentic
|
|
115
|
+
skill: ct-validator
|
|
116
|
+
allowed_skills: [ct-validator, 42]
|
|
117
|
+
`;
|
|
118
|
+
expect(() => parsePlaybook(yaml)).toThrow(/allowed_skills/);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('T1947: AC5 — allowlists reach the dispatch boundary', () => {
|
|
123
|
+
let db: DatabaseSync;
|
|
124
|
+
|
|
125
|
+
beforeEach(() => {
|
|
126
|
+
db = new DatabaseSync(':memory:');
|
|
127
|
+
db.exec('PRAGMA foreign_keys=ON');
|
|
128
|
+
applyMigration(db, readFileSync(MIGRATION_SQL, 'utf8'));
|
|
129
|
+
});
|
|
130
|
+
afterEach(() => db.close());
|
|
131
|
+
|
|
132
|
+
it('forwards allowed_skills/allowed_tools as allowedSkills/allowedTools', async () => {
|
|
133
|
+
const yaml = `
|
|
134
|
+
version: "1.0"
|
|
135
|
+
name: gated-flow
|
|
136
|
+
nodes:
|
|
137
|
+
- id: review
|
|
138
|
+
type: agentic
|
|
139
|
+
skill: ct-validator
|
|
140
|
+
allowed_skills: [ct-validator]
|
|
141
|
+
allowed_tools: [Read, Grep]
|
|
142
|
+
`;
|
|
143
|
+
const { definition } = parsePlaybook(yaml);
|
|
144
|
+
const dispatcher = makeRecorder();
|
|
145
|
+
|
|
146
|
+
const result = await executePlaybook({
|
|
147
|
+
db,
|
|
148
|
+
playbook: definition,
|
|
149
|
+
playbookHash: 'hash-t1947',
|
|
150
|
+
initialContext: { taskId: 'T1947' },
|
|
151
|
+
dispatcher,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
expect(result.terminalStatus).toBe('completed');
|
|
155
|
+
expect(dispatcher.calls).toHaveLength(1);
|
|
156
|
+
const call = dispatcher.calls[0];
|
|
157
|
+
expect(call?.nodeId).toBe('review');
|
|
158
|
+
expect(call?.allowedSkills).toEqual(['ct-validator']);
|
|
159
|
+
expect(call?.allowedTools).toEqual(['Read', 'Grep']);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('an un-restricted stage carries NO allowedSkills/allowedTools (additive)', async () => {
|
|
163
|
+
const yaml = `
|
|
164
|
+
version: "1.0"
|
|
165
|
+
name: plain-flow
|
|
166
|
+
nodes:
|
|
167
|
+
- id: research
|
|
168
|
+
type: agentic
|
|
169
|
+
skill: ct-research-agent
|
|
170
|
+
`;
|
|
171
|
+
const { definition } = parsePlaybook(yaml);
|
|
172
|
+
const dispatcher = makeRecorder();
|
|
173
|
+
|
|
174
|
+
await executePlaybook({
|
|
175
|
+
db,
|
|
176
|
+
playbook: definition,
|
|
177
|
+
playbookHash: 'hash-t1947-b',
|
|
178
|
+
initialContext: { taskId: 'T123' },
|
|
179
|
+
dispatcher,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const call = dispatcher.calls[0];
|
|
183
|
+
expect(call?.allowedSkills).toBeUndefined();
|
|
184
|
+
expect(call?.allowedTools).toBeUndefined();
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T11759 (M4 cantbook done-gate) — `PlaybookAgenticNode.profile` / `.model` /
|
|
3
|
+
* `.provider` reach the dispatch boundary so a cantbook stage can pin its LLM.
|
|
4
|
+
*
|
|
5
|
+
* Two layers:
|
|
6
|
+
* 1. Parser → runtime forwarding: a `.cantbook` node with `profile:` is parsed
|
|
7
|
+
* and the pin reaches {@link AgentDispatchInput} (with `playbookName`) at
|
|
8
|
+
* dispatch time — asserted with a recorder dispatcher (no live backend).
|
|
9
|
+
* 2. Default (un-pinned) nodes carry NO pin — the dispatch path is unchanged.
|
|
10
|
+
*
|
|
11
|
+
* The dispatcher's E9 resolution itself (cantbook system key + profile pin) is
|
|
12
|
+
* covered in-process by `@cleocode/core`'s `cantbook-profile.test.ts`; this
|
|
13
|
+
* suite proves the runtime hands the pin to the dispatcher so that resolution
|
|
14
|
+
* can occur. No `@cleocode/*` module is mocked.
|
|
15
|
+
*
|
|
16
|
+
* @task T11759
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFileSync } from 'node:fs';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
import { dirname, resolve } from 'node:path';
|
|
22
|
+
import type { DatabaseSync as _DatabaseSyncType } from 'node:sqlite';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
25
|
+
import { parsePlaybook } from '../parser.js';
|
|
26
|
+
import {
|
|
27
|
+
type AgentDispatcher,
|
|
28
|
+
type AgentDispatchInput,
|
|
29
|
+
type AgentDispatchResult,
|
|
30
|
+
executePlaybook,
|
|
31
|
+
} from '../runtime.js';
|
|
32
|
+
|
|
33
|
+
const _require = createRequire(import.meta.url);
|
|
34
|
+
type DatabaseSync = _DatabaseSyncType;
|
|
35
|
+
const { DatabaseSync } = _require('node:sqlite') as {
|
|
36
|
+
DatabaseSync: new (...args: ConstructorParameters<typeof _DatabaseSyncType>) => DatabaseSync;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
40
|
+
const MIGRATION_SQL = resolve(
|
|
41
|
+
__dirname,
|
|
42
|
+
'../../../core/migrations/drizzle-tasks/20260417220000_t889-playbook-tables/migration.sql',
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
function applyMigration(db: DatabaseSync, sql: string): void {
|
|
46
|
+
const statements = sql
|
|
47
|
+
.split(/--> statement-breakpoint/)
|
|
48
|
+
.map((s) => s.trim())
|
|
49
|
+
.filter((s) => s.length > 0);
|
|
50
|
+
for (const stmt of statements) {
|
|
51
|
+
const lines = stmt.split('\n');
|
|
52
|
+
const hasSql = lines.some((l) => l.trim().length > 0 && !l.trim().startsWith('--'));
|
|
53
|
+
if (hasSql) db.exec(stmt);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Records the full {@link AgentDispatchInput} per call. */
|
|
58
|
+
function makeRecorder(): AgentDispatcher & { calls: AgentDispatchInput[] } {
|
|
59
|
+
const calls: AgentDispatchInput[] = [];
|
|
60
|
+
return {
|
|
61
|
+
calls,
|
|
62
|
+
async dispatch(input: AgentDispatchInput): Promise<AgentDispatchResult> {
|
|
63
|
+
calls.push({ ...input, context: { ...input.context } });
|
|
64
|
+
return { status: 'success', output: {} };
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe('T11759: node profile pin reaches the dispatch boundary', () => {
|
|
70
|
+
let db: DatabaseSync;
|
|
71
|
+
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
db = new DatabaseSync(':memory:');
|
|
74
|
+
db.exec('PRAGMA foreign_keys=ON');
|
|
75
|
+
applyMigration(db, readFileSync(MIGRATION_SQL, 'utf8'));
|
|
76
|
+
});
|
|
77
|
+
afterEach(() => db.close());
|
|
78
|
+
|
|
79
|
+
it('AC3: a `.cantbook` stage with `profile:` forwards the pin + playbookName', async () => {
|
|
80
|
+
const yaml = `
|
|
81
|
+
version: "1.0"
|
|
82
|
+
name: pinned-flow
|
|
83
|
+
nodes:
|
|
84
|
+
- id: review
|
|
85
|
+
type: agentic
|
|
86
|
+
skill: ct-validator
|
|
87
|
+
profile: frontier-review
|
|
88
|
+
model: claude-opus-4-8
|
|
89
|
+
provider: anthropic
|
|
90
|
+
`;
|
|
91
|
+
const { definition } = parsePlaybook(yaml);
|
|
92
|
+
const dispatcher = makeRecorder();
|
|
93
|
+
|
|
94
|
+
const result = await executePlaybook({
|
|
95
|
+
db,
|
|
96
|
+
playbook: definition,
|
|
97
|
+
playbookHash: 'hash-t11759',
|
|
98
|
+
initialContext: { taskId: 'T11759' },
|
|
99
|
+
dispatcher,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(result.terminalStatus).toBe('completed');
|
|
103
|
+
expect(dispatcher.calls).toHaveLength(1);
|
|
104
|
+
const call = dispatcher.calls[0];
|
|
105
|
+
// The cantbook node identity reaches the dispatcher so it can build the
|
|
106
|
+
// `cantbook:<playbook>#<nodeId>` system key (AC2 resolution input).
|
|
107
|
+
expect(call?.playbookName).toBe('pinned-flow');
|
|
108
|
+
expect(call?.nodeId).toBe('review');
|
|
109
|
+
// The per-stage LLM pin is forwarded verbatim (the dispatcher resolves it
|
|
110
|
+
// through `resolveCantbookNodeProfile` → E9).
|
|
111
|
+
expect(call?.profile).toBe('frontier-review');
|
|
112
|
+
expect(call?.model).toBe('claude-opus-4-8');
|
|
113
|
+
expect(call?.provider).toBe('anthropic');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('AC3: an un-pinned stage carries NO profile/model/provider (additive)', async () => {
|
|
117
|
+
const yaml = `
|
|
118
|
+
version: "1.0"
|
|
119
|
+
name: plain-flow
|
|
120
|
+
nodes:
|
|
121
|
+
- id: research
|
|
122
|
+
type: agentic
|
|
123
|
+
skill: ct-research-agent
|
|
124
|
+
`;
|
|
125
|
+
const { definition } = parsePlaybook(yaml);
|
|
126
|
+
const dispatcher = makeRecorder();
|
|
127
|
+
|
|
128
|
+
await executePlaybook({
|
|
129
|
+
db,
|
|
130
|
+
playbook: definition,
|
|
131
|
+
playbookHash: 'hash-t11759-b',
|
|
132
|
+
initialContext: { taskId: 'T123' },
|
|
133
|
+
dispatcher,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const call = dispatcher.calls[0];
|
|
137
|
+
expect(call?.playbookName).toBe('plain-flow');
|
|
138
|
+
expect(call?.profile).toBeUndefined();
|
|
139
|
+
expect(call?.model).toBeUndefined();
|
|
140
|
+
expect(call?.provider).toBeUndefined();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -340,6 +340,86 @@ nodes:
|
|
|
340
340
|
});
|
|
341
341
|
});
|
|
342
342
|
|
|
343
|
+
describe('T11759: agentic node LLM profile pin', () => {
|
|
344
|
+
it('AC1: parses profile/model/provider on an agentic node', () => {
|
|
345
|
+
const yaml = `
|
|
346
|
+
version: "1.0"
|
|
347
|
+
name: pinned
|
|
348
|
+
nodes:
|
|
349
|
+
- id: review
|
|
350
|
+
type: agentic
|
|
351
|
+
skill: ct-validator
|
|
352
|
+
profile: frontier-review
|
|
353
|
+
model: claude-opus-4-8
|
|
354
|
+
provider: anthropic
|
|
355
|
+
`;
|
|
356
|
+
const { definition } = parsePlaybook(yaml);
|
|
357
|
+
expect(definition.nodes[0]).toMatchObject({
|
|
358
|
+
id: 'review',
|
|
359
|
+
type: 'agentic',
|
|
360
|
+
profile: 'frontier-review',
|
|
361
|
+
model: 'claude-opus-4-8',
|
|
362
|
+
provider: 'anthropic',
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('AC1: omits profile/model/provider when not declared (additive)', () => {
|
|
367
|
+
const yaml = `
|
|
368
|
+
version: "1.0"
|
|
369
|
+
name: unpinned
|
|
370
|
+
nodes:
|
|
371
|
+
- id: plain
|
|
372
|
+
type: agentic
|
|
373
|
+
skill: ct-research-agent
|
|
374
|
+
`;
|
|
375
|
+
const node = parsePlaybook(yaml).definition.nodes[0];
|
|
376
|
+
expect(node).not.toHaveProperty('profile');
|
|
377
|
+
expect(node).not.toHaveProperty('model');
|
|
378
|
+
expect(node).not.toHaveProperty('provider');
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('AC1: a profile-only pin is valid', () => {
|
|
382
|
+
const yaml = `
|
|
383
|
+
version: "1.0"
|
|
384
|
+
name: profile-only
|
|
385
|
+
nodes:
|
|
386
|
+
- id: stage
|
|
387
|
+
type: agentic
|
|
388
|
+
skill: ct-task-executor
|
|
389
|
+
profile: fast-lane
|
|
390
|
+
`;
|
|
391
|
+
const node = parsePlaybook(yaml).definition.nodes[0];
|
|
392
|
+
expect(node).toMatchObject({ profile: 'fast-lane' });
|
|
393
|
+
expect(node).not.toHaveProperty('model');
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('AC1: rejects an empty-string profile', () => {
|
|
397
|
+
const yaml = `
|
|
398
|
+
version: "1.0"
|
|
399
|
+
name: bad-profile
|
|
400
|
+
nodes:
|
|
401
|
+
- id: stage
|
|
402
|
+
type: agentic
|
|
403
|
+
skill: ct-task-executor
|
|
404
|
+
profile: ""
|
|
405
|
+
`;
|
|
406
|
+
expect(() => parsePlaybook(yaml)).toThrow(/profile must be a non-empty string/);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it('AC1: rejects a non-string model', () => {
|
|
410
|
+
const yaml = `
|
|
411
|
+
version: "1.0"
|
|
412
|
+
name: bad-model
|
|
413
|
+
nodes:
|
|
414
|
+
- id: stage
|
|
415
|
+
type: agentic
|
|
416
|
+
skill: ct-task-executor
|
|
417
|
+
model: 42
|
|
418
|
+
`;
|
|
419
|
+
expect(() => parsePlaybook(yaml)).toThrow(/model must be a non-empty string/);
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
343
423
|
describe('bounds checks', () => {
|
|
344
424
|
it('rejects max_iterations > 10', () => {
|
|
345
425
|
const yaml = `
|