@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.
@@ -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 = `